메모리¶
메모리는 데스크톱 메모리 페이지 뒤에 있는 영속 사실 저장소입니다. 짧은 엔트리를 담은 이름 붙은 뱅크들로 이루어져 있고, 앱이 채팅 턴의 시스템 프롬프트에 이 내용을 주입해 모델이 대화를 넘어 선호, 프로젝트 관례, 도메인 사실을 기억하게 합니다. 관리 API는 이 전체를 노출합니다. /memory 아래 27개 엔드포인트이며 Tauri와 완전한 패리티를 갖습니다.
이 문서의 모든 엔드포인트는 관리 API 기본 주소(기본값 http://127.0.0.1:8001/api/v1)에서 접근할 수 있고, /api/docs의 Swagger UI에서는 Memory 태그 아래에 나타납니다. aigo memory 명령 그룹이 같은 엔드포인트를 감싸며, CLI에 스트리밍 전송이 붙기를 기다리는 이벤트 스트림 하나를 빼면 전부에 도달합니다. 플래그는 CLI 레퍼런스에 있습니다.
데스크톱 UI는 네임스페이스를 메모리 뱅크라고 부릅니다. API는 네임스페이스라고 부르고, 이 문서도 그렇게 씁니다.
헤드리스에서의 동작¶
이 표면을 상대로 개발하기 전에 알아 둘 것이 세 가지 있습니다.
저장소 자체는 헤드리스에서 동작합니다. aigo-server는 데스크톱 앱이 설치하는 것과 같은 공유 이벤트 이미터로 메모리를 초기화합니다. 그래서 네임스페이스, 엔트리, 검색, 주입 컨텍스트, 내보내기, 가져오기, 추출, 통합이 모두 헤드리스 서버에서 실행되고, 아래의 memory:* 이벤트 두 개도 데스크톱 WebView뿐 아니라 SSE 스트림으로 전달됩니다.
메모리 도구는 /tools/execute로 도달할 수 없습니다. read_memory, write_memory, search_memory는 headless_unavailable_reason(src-tauri/crates/aigo-core/src/tool_identity.rs)에 등록되어 있어 POST /tools/execute와 MCP 엔드포인트 모두 도구 카탈로그가 알려 주는 사유와 함께 거절합니다. 이는 메모리 표면이 아니라 도구 표면의 공백입니다. 헤드리스로 실행되는 에이전트는 메모리 도구를 호출할 수 없지만, 클라이언트는 이 문서의 엔드포인트로 같은 저장소를 읽고 쓸 수 있습니다. 이 제약을 푸는 작업은 별도로 추적하며 에픽 #4598의 범위가 아닙니다.
추출과 LLM 통합은 라우터가 떠 있어야 합니다. POST /memory/extract와 POST /memory/consolidation/run은 is_router_running()을 확인하고 실행 중이 아니면 503으로 답합니다. 라우터 중지 후에도 고정된 채 남아 있는 캐시된 엔드포인트로 연결을 시도하지 않기 위해서입니다. 추출은 대화록을 먼저 검사하므로, messages가 비었거나 너무 길면 라우터가 죽어 있어도 400입니다. 이 문서의 나머지는 모두 로컬 저장소에서 응답하며 모델이 전혀 필요 없습니다.
인증과 스코프¶
인증 방식은 관리 API의 나머지와 같습니다. X-API-Key 헤더, Authorization: Bearer 토큰, 또는 POST /api/v1/auth/login으로 받은 aigo_session 쿠키입니다.
액세스 키는 스코프를 가지며, 이 표면은 두 가지를 씁니다.
- 모든
GET과POST하나에memory_read. - 그 하나를 제외한 모든
POST,PUT,PATCH,DELETE에memory_write.
예외는 POST /memory/namespaces/{ns_id}/export이며 의도적으로 memory_read입니다. 네임스페이스와 그 엔트리를 렌더링할 뿐 아무것도 저장하지 않습니다. POST인 것은 상태를 바꾸기 때문이 아니라 역사적인 이유입니다. 권위 있는 표는 src-tauri/crates/aigo-rest/src/route_scope.rs의 ROUTE_MANIFEST이고, 아래 표의 스코프 열은 거기서 옮겨 온 것입니다.
스코프가 걸린 메모리 스트림은 SSE와 WebSocket 전송을 제공합니다. GET /memory/events와 GET /memory/ws는 memory:*만 실어 나르며 memory_read를 요구하고, 전역 /events 쌍은 모든 도메인의 이벤트를 실어 나르며 admin을 요구합니다. 이슈 #5040 이후로는 데스크톱 앱이 내장 관리 API를 함께 띄운 경우에도 양쪽 모두에 실립니다. 데스크톱의 모든 하위 시스템이 공유 싱크 하나로 이벤트를 발행하고, 그 서버가 떠 있는 동안 자신의 이벤트 버스를 두 번째 표면으로 붙이기 때문입니다.
관리형 설치에서 이 전송 표면을 게이팅하는 페이지 ID는 /memory가 아니라 /data입니다. features.hiddenPages에 /data를 넣으면 /data, /text-creations, /artifact-creations와 함께 /memory 경로 접두사도 게이팅됩니다. 메모리 관리는 데이터 페이지가 소유하기 때문입니다. /memory 페이지 ID는 독립 메모리 페이지를 숨길 뿐 어떤 경로도 어떤 명령도 게이팅하지 않습니다. 두 페이지가 소유한 경로는 두 페이지가 모두 숨겨졌을 때만 게이팅되므로, /memory를 두 번째 소유자로 지정했다면 /data 게이트가 강해지는 것이 아니라 약해졌을 것입니다.
리소스 모델¶
네임스페이스¶
네임스페이스는 설명, enabled 플래그, 생성·수정 시각을 가진 이름 붙은 엔트리 뱅크입니다. ID는 UUID입니다. 활성 네임스페이스만 주입 컨텍스트에 기여하며, 비활성화하면 엔트리는 그대로 두고 프롬프트에 도달하는 것만 멈춥니다. 네임스페이스를 삭제하면 그 안의 엔트리도 함께 삭제됩니다.
일부 네임스페이스는 에이전트별 경험 뱅크로, description의 예약 접두사로 표시됩니다. Layer-A 읽기 경로가 이 표시를 해석해 에이전트의 시스템 프롬프트를 구성하기 때문에, 가져오기는 이름 매칭의 양쪽 모두에서 그런 뱅크에 쓰려는 문서를 거절합니다.
엔트리¶
엔트리는 기억된 사실 하나입니다. id, namespaceId, content, auto 또는 manual인 source, tags 배열, 자유 형식 metadata 객체, 그리고 타임스탬프로 이루어집니다. source는 누가 썼는지를 나타내며 장식이 아니라 동작을 좌우합니다. 통합과 정리는 auto 엔트리만 건드리므로 수동 엔트리는 병합되거나 오래되었다는 이유로 사라지지 않습니다.
주입 컨텍스트¶
GET /memory/context는 채팅 턴이 주입할 블록을 돌려줍니다. content(포맷된 텍스트), tokenCount, entryCount, namespaceCount입니다. 컨텍스트를 만들면 기본적으로 포함된 엔트리에 참조 표시가 찍히고, 이는 관련도 스코어러의 최신성 점수에 반영됩니다. 표시 전용으로 만들 때는 recordReference=false를 넘기세요. 그러지 않으면 컨텍스트를 반복해서 열어 보는 것만으로 모든 엔트리의 최신성이 "방금"으로 올라갑니다.
내보내기 문서¶
형태는 두 가지이고, 가져오기 엔드포인트 하나가 둘 다 받습니다.
- 단일 네임스페이스:
{ "namespace": {...}, "entries": [...] }.POST /memory/namespaces/{ns_id}/export가 쓰는 형태입니다. - 전체 저장소:
{ "version": 1, "exportedAt": "...", "namespaces": [ { "namespace": {...}, "entries": [...] }, ... ] }.GET /memory/export가 쓰는 형태입니다.
판별자는 namespaces 키입니다. 내보내기는 이 기기의 ID를 담고 있으므로, 가져오기는 모든 네임스페이스와 엔트리에 새 ID를 찍고 기존 네임스페이스는 name으로 매칭합니다. 기본값 onConflict=merge에서는 같은 문서를 두 번 가져오면 덮어쓰는 것이 아니라 덧붙고, skip에서는 두 번째 가져오기가 매칭된 네임스페이스를 그대로 둡니다.
와이어 열거 값¶
모두 와이어에서 소문자이며, 철자가 틀리면 보정하지 않고 거절합니다.
MemorySource:auto,manual.- 엔트리 목록
sort:created,updated.order:asc,desc. - 가져오기
onConflict:merge,skip. ExtractionEmptyReason.reason:nothingWorthSaving,allDeduped,parseRecoveryFailed,validationEmpty.
엔드포인트¶
아래 경로는 /api/v1 접두사를 생략했습니다. {id}는 /memory/namespaces/{id} 아래에서는 네임스페이스 ID, /memory/namespaces/{ns_id}/entries/{id} 아래에서는 엔트리 ID입니다.
네임스페이스¶
| 메서드 | 경로 | 스코프 | 본문 또는 쿼리 | 반환 |
|---|---|---|---|---|
GET | /memory/namespaces | memory_read | - | MemoryNamespace[] |
POST | /memory/namespaces | memory_write | CreateNamespaceRequest | MemoryNamespace (201) |
GET | /memory/namespaces/{id} | memory_read | - | MemoryNamespace |
PUT | /memory/namespaces/{id} | memory_write | UpdateNamespaceRequest | MemoryNamespace |
DELETE | /memory/namespaces/{id} | memory_write | - | 204, 본문 없음 |
PATCH | /memory/namespaces/{id}/toggle | memory_write | ToggleNamespaceRequest | MemoryNamespace |
PATCH .../toggle은 상태를 뒤집는 것이 아니라 지정합니다. ToggleNamespaceRequest는 필수 enabled 불리언을 선언하므로 {}를 포함해 그 필드가 없는 본문은 역직렬화에 실패해 422입니다. 완전히 빈(0바이트) 본문은 필드 누락을 검사하기도 전에 JSON 파싱 자체가 실패하므로 400입니다. 뒤집기를 원하는 클라이언트는 먼저 네임스페이스를 읽고 반대 값을 보냅니다. aigo memory namespace toggle이 그렇게 합니다. 프로비저닝 스크립트에는 enable이나 disable이 맞습니다. 상태 지정은 멱등하고 뒤집기는 그렇지 않기 때문입니다.
PUT은 없는 필드를 건드리지 않으므로 바뀌는 것만 보내면 됩니다. DELETE는 네임스페이스와 그 안의 엔트리를 모두 지웁니다.
엔트리¶
| 메서드 | 경로 | 스코프 | 본문 또는 쿼리 | 반환 |
|---|---|---|---|---|
GET | /memory/namespaces/{ns_id}/entries | memory_read | ?tag=&source=&q=&limit=&offset=&sort=&order= | MemoryEntry[] 또는 MemoryEntryPage |
POST | /memory/namespaces/{ns_id}/entries | memory_write | CreateEntryRequest | MemoryEntry (201) |
DELETE | /memory/namespaces/{ns_id}/entries | memory_write | - | 204, 본문 없음 |
POST | /memory/namespaces/{ns_id}/entries/bulk-delete | memory_write | BulkDeleteEntriesRequest | MemoryBulkDeleteResult |
POST | /memory/namespaces/{ns_id}/entries/move | memory_write | MoveEntriesRequest | MemoryMoveResult |
GET | /memory/namespaces/{ns_id}/entries/{id} | memory_read | - | MemoryEntry |
PUT | /memory/namespaces/{ns_id}/entries/{id} | memory_write | UpdateEntryRequest | MemoryEntry |
DELETE | /memory/namespaces/{ns_id}/entries/{id} | memory_write | - | 204, 본문 없음 |
DELETE /memory/namespaces/{ns_id}/entries는 네임스페이스를 비우고 남겨 둡니다. DELETE /memory/namespaces/{id}는 네임스페이스까지 지웁니다.
두 벌크 동사는 멈추지 않고 보고합니다. MemoryBulkDeleteResult는 { deleted, missing }, MemoryMoveResult는 { moved, missing }이며, missing은 해당 네임스페이스에 없던 ID를 나열합니다. 그래서 일부가 낡은 선택도 나머지에는 그대로 적용됩니다. 둘 다 대상 네임스페이스 파일을 ID마다가 아니라 한 번만 씁니다. 목록 길이도 둘 다 제한합니다. ids는 최소 1개, 최대 1000개여야 하며 양쪽 끝을 벗어나면 400입니다.
이동은 각 엔트리의 ID, 내용, 소스, 태그, 메타데이터, 타임스탬프를 모두 유지하는데, 이는 지우고 다시 만들면 잃는 것들입니다. 대상 네임스페이스는 존재해야 하고, 원본과 같아서는 안 되며, 에이전트 경험 표시를 담고 있어서도 안 됩니다. 마지막 거절은 가져오기가 막는 것과 같은 접근입니다. 표시된 네임스페이스의 엔트리는 Layer-A 읽기 경로가 에이전트의 시스템 프롬프트에 접어 넣으므로, 임의의 엔트리를 그리로 옮기는 것은 그 프롬프트에 쓰는 일이 됩니다.
검색·통계·주입 컨텍스트¶
| 메서드 | 경로 | 스코프 | 본문 또는 쿼리 | 반환 |
|---|---|---|---|---|
GET | /memory/entries | memory_read | ?q=&namespace=&limit= | MemoryEntry[] |
GET | /memory/entries/enabled | memory_read | - | MemoryEntry[] |
GET | /memory/stats | memory_read | - | MemoryStats |
GET | /memory/context | memory_read | ?maxTokens=&recordReference= | MemoryInjection |
GET /memory/entries는 관련도 검색이고, 쿼리 키는 query가 아니라 q입니다. 결과는 공유 관련도 스코어러가 매긴 순서대로 상위부터 오므로 클라이언트는 Tauri 명령과 같은 정렬을 받습니다. namespace는 검색을 한 네임스페이스로 제한하고, limit은 순위 산정 뒤에 적용되는 상위 K 상한입니다.
GET /memory/entries/enabled는 주입이 사용하는 평평한 집합입니다. 활성 네임스페이스의 모든 엔트리를 순위나 필터 없이 돌려주며, 파라미터를 전혀 받지 않습니다.
GET /memory/stats는 전체 엔트리 수, 전체 및 활성 네임스페이스 수, auto와 manual 분포, 종류별 분류, 추정 토큰 총합, 네임스페이스별 표, createdPerDay 시계열, recentlyUpdated 엔트리를 보고합니다. 에이전트 경험 네임스페이스는 isAgentExperience가 설정된 채 perNamespace에 남아 있고 agentExperience에 따로 요약되며, 대표 총계·분포·토큰 추정·일자 시계열·최근 갱신 목록 어디에도 합산되지 않습니다.
내보내기와 가져오기¶
| 메서드 | 경로 | 스코프 | 본문 또는 쿼리 | 반환 |
|---|---|---|---|---|
POST | /memory/namespaces/{ns_id}/export | memory_read | - | { namespace, entries } |
GET | /memory/export | memory_read | - | MemoryExportDocument |
POST | /memory/import | memory_write | ImportRequest, ?onConflict=merge\|skip | MemoryImportResponse (201) |
ImportRequest는 문서를 중첩된 객체가 아니라 data 아래 JSON 문자열로 싣습니다. 파싱된 문서 자체를 보내 data가 문자열이 아니라 중첩된 객체로 도착하면 선언된 타입으로 역직렬화되지 않으므로 422입니다. data가 문자열이긴 하지만 그 자체로 JSON 파싱에 실패하는 경우는 import_namespace가 직접 검사하며, 이 실패는 400입니다.
onConflict는 전체 저장소 형태에만 적용되며, 그때 네임스페이스는 이름으로 매칭됩니다. merge(기본값)는 들어온 엔트리를 저장된 네임스페이스에 덧붙이고, skip은 그대로 두고 이름을 보고합니다. 단일 네임스페이스 경로는 언제나 새 네임스페이스를 만들므로 이 파라미터는 거기서 아무것도 하지 않습니다. ImportMemoryQuery는 deny_unknown_fields이므로 스네이크 케이스 ?on_conflict=skip은 조용히 merge로 되돌아가지 않고 400이 됩니다.
응답은 문서 형태 두 가지에 맞춰 두 가지입니다. 전체 저장소 문서는 MemoryImportSummary로 답합니다. { created, merged, skipped, refused, entriesImported }이며, 앞의 네 네임스페이스 목록은 서로 겹치지 않고 합치면 문서의 모든 네임스페이스를 설명합니다. 단일 네임스페이스 문서는 생성된 MemoryNamespace로 답하며, 이것이 기존 형태입니다. created의 존재 여부로 분기하세요.
보안과 관련된 것은 refused입니다. 설명에 예약된 에이전트 경험 표시를 담은 네임스페이스는 이름 매칭의 양쪽에서 거절됩니다. 들어오는 네임스페이스가 표시된 뱅크를 만들 수 없고, merge가 저장된 표시된 뱅크에 덧붙일 수도 없습니다. 두 거절 모두 조용히 버려지지 않고 응답에 이름으로 남습니다.
내보내기 결과는 민감합니다. GET /memory/export는 사용자의 엔트리를 그대로 돌려줍니다. 추출이 대화에서 저장한 것까지 포함되며, 전체 저장소 문서는 그 전부를 한 번에 담습니다. 에이전트 경험 네임스페이스를 포함해 아무것도 걸러 내지 않는데, 저장소의 일부를 말없이 빠뜨리는 백업이 무엇을 되돌릴 수 없는지 보고하는 백업보다 나쁘기 때문입니다. 응답을 자격 증명 덤프처럼 다루세요. 누구나 읽을 수 있는 경로, 공유 임시 디렉터리, 로그에 쓰지 마세요. aigo memory export --all -o <PATH>는 write_owner_only로 파일을 쓰며 유닉스에서 0600 모드를 적용합니다. 윈도우에는 적용할 모드가 없어 파일이 디렉터리의 ACL을 상속합니다. 문서를 직접 쓰는 클라이언트도 같게 해야 합니다. (여기서 --all은 선택이 아닙니다. 네임스페이스 ID도 --all도 주지 않은 호출은 아무도 요청하지 않은 전체 덤프가 아니라 인자 오류입니다.)
추출과 통합¶
| 메서드 | 경로 | 스코프 | 본문 또는 쿼리 | 반환 |
|---|---|---|---|---|
POST | /memory/extract | memory_write | ExtractRequest | ExtractionResult |
POST | /memory/ensure-model-available | memory_write | EnsureModelAvailableRequest (선택) | 결과 문자열 |
POST | /memory/namespaces/{ns_id}/consolidate | memory_write | ConsolidateRequest | ConsolidationResult |
POST | /memory/consolidation/run | memory_write | RunConsolidationRequest | LlmConsolidationReport |
GET | /memory/consolidation/status | memory_read | - | LlmConsolidationStatus |
POST /memory/extract는 대화록에 추출 파이프라인을 실행합니다. messages는 { role, content } 객체 배열로 필수이고 비어 있을 수 없으며 최대 100개입니다. model과 config는 선택 재정의입니다. ExtractionResult는 { created, updated, skipped, emptyReason? }입니다. emptyReason은 아무것도 저장되지 않았을 때만 존재하고, 문자열이 아니라 reason으로 태깅된 객체입니다. {"reason": "nothingWorthSaving"}이나 {"reason": "allDeduped", "candidateCount": 4} 같은 형태입니다. 이를 문자열로 읽는 클라이언트에는 아무것도 보이지 않습니다.
POST /memory/ensure-model-available은 권장 추출 모델이 아직 로드되지 않았다면 로드하고, JSON 문자열 하나로 답합니다. alreadyLoaded, loaded, disabled, modelNotFound, insufficientRam, timedOut, failed 중 하나입니다. 본문은 선택이며 model만 담습니다.
통합은 두 가지이고 서로 다른 패스입니다. POST /memory/namespaces/{ns_id}/consolidate는 한 네임스페이스에 대한 어휘 기반 정리입니다. 거의 중복인 auto 엔트리를 병합하고 오래된 것을 정리하며, 모델이 필요 없고 { mergedCount, removedCount, unchangedCount, prunedCount }를 돌려줍니다. 선택 config는 similarityThreshold(0.0에서 1.0, 범위를 벗어나면 거절)와 maxEntryAgeDays를 담습니다. 둘 중 하나만 줘도 되고 나머지는 서버 기본값으로 남습니다. 클러스터링이 제곱 비용이라 네임스페이스에서 가장 최근에 갱신된 auto 엔트리 500개까지만 처리하고 나머지는 두고 갑니다. POST /memory/consolidation/run은 자격 있는 모든 네임스페이스에 대한 LLM 패스로, 비슷한 auto 엔트리 클러스터를 정본 사실로 다시 씁니다. LlmConsolidationReport에 네임스페이스·클러스터·엔트리 카운터가 담깁니다.
수동 엔트리는 두 패스 모두 건드리지 않습니다. 두 패스 모두 호출자가 선택한 통합으로부터 Layer-A 에이전트 경험 네임스페이스를 보호합니다. POST /memory/consolidation/run은 이를 대상에서 걸러 내고, POST /memory/namespaces/{ns_id}/consolidate는 네임스페이스에 예약된 설명 표시나 Agent Experience: 이름 접두사가 있으면 변경 전에 400을 반환합니다. 추출 소유자 경로는 고정된 유사도 임계값 0.8과 최대 보관 기간 60일 설정으로 임계값 기반 Layer-A 정리를 계속 수행합니다.
GET /memory/consolidation/status는 { enabled, intervalHours, due, lastRunAt, lastAttemptAt?, lastError? }를 보고하며 라우터가 필요 없습니다. 그래서 무인 서버도 로그를 뒤지지 않고 유지보수가 실패하고 있음을 알 수 있습니다. lastAttemptAt과 lastRunAt은 의도적으로 분리되어 있습니다. 정리와 어휘 통합은 마쳤지만 라우터가 죽어 LLM 단계를 미룬 패스는 시도 시각만 올리고 실행 시각은 올리지 않습니다.
이벤트 스트림¶
| 메서드 | 경로 | 스코프 | 본문 또는 쿼리 | 반환 |
|---|---|---|---|---|
GET | /memory/events | memory_read | ?types= | text/event-stream |
GET | /memory/ws | memory_read | ?types=, ?since= | WebSocket (101) |
이벤트를 참고하세요.
요청 타입이 받는 것과 받지 않는 것¶
아래 항목 몇 가지는 짐작한 철자가 서버가 선언한 철자와 다른 자리입니다. 어떤 필드가 전달된다고 가정하기 전에 이 절을 확인하세요.
생성 시 source는 필수이고 기본값이 없습니다. CreateEntryRequest는 serde 기본값 없이 source: MemorySource를 선언하므로, {"content": "..."}만 담은 본문은 소스가 추론된 엔트리가 아니라 역직렬화 실패입니다. 사람이 쓴 것에는 manual을, 파이프라인이 만든 것에는 auto를 보내세요. tags와 metadata는 선택이며 준 경우에만 전달됩니다.
네임스페이스 생성 시 description은 필수입니다. CreateNamespaceRequest는 이를 순수 String으로 선언하므로 키를 빼면 역직렬화에 실패해 400이 아니라 422이고, 빈 문자열은 키가 있을 때 "설명 없음"을 뜻합니다. UpdateNamespaceRequest는 전부 선택이고 없는 필드를 그대로 두므로, 나머지를 다시 적지 않고 하나만 바꾸려면 PUT을 쓰세요.
검색은 query가 아니라 q입니다. SearchQuery는 q를 필수로 선언합니다. query로 적은 요청은 실패하는데, 이것이 바로 이슈 #4595가 CLI에서 고친 결함입니다. 값은 비어 있을 수 없고 최대 500바이트입니다. 서버 메시지는 글자 수라고 말하지만 실제 검사는 바이트 길이에 대해 이루어집니다.
엔트리 목록은 응답 형태가 둘이고, 파라미터 유무가 그것을 고릅니다. 쿼리 파라미터가 하나도 없으면 GET /memory/namespaces/{ns_id}/entries는 예전부터 돌려주던 저장 순서의 순수 MemoryEntry[]를 돌려줍니다. 이 엔드포인트를 상대로 작성된 모든 클라이언트가 response[0]을 읽기 때문입니다. 파라미터가 하나라도 있으면 { "entries": [...], "total": n }을 선택하게 되며, total은 limit과 offset을 적용하기 전 전체 매칭 수입니다. 기본값은 이후 릴리스에서 페이지 쪽으로 바뀌므로 배열이라고 가정하지 말고 형태를 읽으세요. tag는 엔트리 태그 중 하나와의 정확한 일치이고 q는 내용에 대한 대소문자 무시 부분 문자열이며, 둘 다 순위를 매기지 않습니다. 네임스페이스를 가로지르는 관련도 검색에는 GET /memory/entries를 쓰세요.
엔트리 목록에서 필터 키의 철자가 틀리면 400입니다. MemoryEntryListQuery는 deny_unknown_fields이므로 ?tags=tooling은 필터가 전부와 일치한 것처럼 보이는 무필터 목록이 아니라 거절로 답합니다. Tauri 쪽도 MemoryEntryFilter에서 같은 보호를 받습니다. 이 표면 전체의 규칙은 아니며, 그래서 다음 두 문단이 있습니다.
GET /memory/entries/enabled는 파라미터를 받지 않습니다. 쿼리 추출기 자체가 없으므로 여기로 보낸 필터는 아무 말 없이 버려지고, 호출자는 필터링되지 않은 목록을 필터링된 것으로 읽게 됩니다. aigo memory entry list --enabled가 인자 계층에서 모든 필터 플래그를 거절하는 이유입니다.
컨텍스트는 두 표기를 모두 받습니다. ContextQuery는 프로젝트가 카멜 케이스로 정착하기 전에 스네이크 케이스로 출시되었기 때문에 maxTokens와 max_tokens가 모두 동작하고, recordReference와 record_reference도 마찬가지입니다. 이 별칭은 의도된 것이며 이 표면의 다른 곳에는 없는 패턴입니다. maxTokens가 0이면 400이고, 더 큰 값은 거절하지 않고 100000으로 잘라 내며, 아예 없으면 2000입니다. recordReference가 없으면 "기록함"을 뜻하며, 이 플래그보다 앞선 모든 호출자와 같습니다.
전체 저장소 가져오기에는 엔트리 개수 상한도 크기 상한도 의도적으로 없습니다. 아래의 이름·설명·태그·내용 제한은 생성 엔드포인트에서 강제되고 저장소 자체는 강제하지 않습니다. 그래서 Tauri 명령으로 채운 네임스페이스는 이미 REST 생성 경로가 거절할 엔트리를 담고 있을 수 있습니다. 그 검사를 가져오기에서 다시 적용하면 이 빌드 자신의 GET /memory/export가 만든 문서를 거절하게 되는데, 이는 백업이 절대 해서는 안 되는 단 하나의 일입니다. 그래서 규칙은 내보내기가 만들 수 있는 것은 가져오기가 받는다는 것이고, 작업량을 묶는 것은 페이로드 크기뿐입니다. 검사는 두 가지만 남습니다. 스스로 만든 문서를 거절할 수 없는 검사, 곧 스키마 버전과 에이전트 경험 표시 거절입니다.
가져오기 경로는 자체 본문 제한을 가지며, 1 MiB가 아니라 50 MiB입니다. 다른 모든 관리 API 경로는 src-tauri/crates/aigo-rest/src/router.rs의 protect_bound에서 1 MiB DefaultBodyLimit를 물려받습니다. 요청 본문에는 맞는 크기지만 복원에는 틀린 크기입니다. 내보내기가 그 크기를 넘는 저장소는 백업은 되어도 되돌릴 수 없기 때문입니다. 그래서 POST /memory/import만 핸들러에 더 가까운 자리에서 MEMORY_IMPORT_BODY_LIMIT_BYTES, 즉 50 MiB를 겹쳐 씁니다. 이는 aigo memory import가 파일을 읽는 상한과도 같아서, 명령이 보낼 수 있는 것은 서버가 받습니다. 그보다 큰 문서는 413입니다. /memory 아래 다른 어떤 경로도 올리지 않았습니다.
이벤트¶
이벤트는 둘이며, 모두 런타임 중립 이벤트 이미터로 전달됩니다. 그래서 데스크톱 앱은 Tauri로, 헤드리스 클라이언트는 SSE로 동일한 페이로드를 받습니다.
GET /memory/events와 GET /memory/ws는 스코프가 걸린 스트림의 SSE와 WebSocket 전송입니다. GET /memory/entries와 같은 memory_read를 요구하고 memory:*만 실어 나릅니다. 데이터 허브, 스쿼드, 스케줄러의 이벤트는 이 연결로 넘어오지 않습니다. GET /events도 여전히 같은 두 이벤트를 다른 모든 것과 함께 전달하지만 admin을 요구하므로, 메모리만 읽는 키라면 스코프가 걸린 쪽을 쓰세요.
?types=로 쉼표로 구분한 부분집합만 받을 수 있습니다(예: ?types=memory:entries-changed). memory: 계열이 아닌 이름을 넘기면 아무것도 오지 않는 연결이 열리는 대신 400으로 거절합니다. Last-Event-ID와 ?since=를 지원하고, 잘린 이어받기는 stream:gap으로, 뒤처진 소비자는 stream:lagged로 알린다. GET /events와 같으며, 이어받은 이벤트도 실시간 스트림과 같은 도메인 필터를 통과한다. 이벤트 스트림을 참고하세요.
이 스트림은 데스크톱 앱에 내장된 관리 API에서도 동작합니다. 데스크톱의 모든 하위 시스템이 공유 이벤트 싱크 하나로 발행하고, 내장 서버는 시작할 때 자신의 이벤트 버스를 두 번째 표면으로 붙였다가 멈출 때 놓습니다(이슈 #5040). 그래서 재시작하면 전달 대상이 새 서버의 버스로 옮겨 가고, 그 동안에도 데스크톱 UI는 계속 이벤트를 받습니다. 이 수정 전에는 그 런타임에서 이 스트림이 열린 채 아무것도 오지 않았고, GET /events도 이 이벤트들에 대해서는 마찬가지였습니다.
| 이벤트 | 페이로드 | 발생 시점 |
|---|---|---|
memory:namespaces-changed | {} | 네임스페이스 생성·수정·삭제·토글이 성공했거나, 가져오기가 네임스페이스를 하나 이상 만들었을 때. 페이로드는 빈 객체이므로 네임스페이스 목록을 다시 읽으세요. |
memory:entries-changed | { namespaceId } | 한 네임스페이스의 엔트리가 바뀌었을 때. namespaceId가 어느 것을 다시 읽을지 알려 줍니다. |
namespaceId는 카멜 케이스이고 스네이크 케이스 페이로드는 경계에서 거절됩니다. 기존 네임스페이스에 병합만 한 전체 저장소 가져오기는 새로 만든 것이 없으므로, 건드린 네임스페이스마다 memory:entries-changed만 발생하고 memory:namespaces-changed는 발생하지 않습니다. 두 이벤트 모두 폴링으로 대체할 수 있으므로, SSE 연결을 유지할 수 없는 클라이언트가 잃는 것은 즉시성뿐입니다.
실습¶
모두 AIGO=http://127.0.0.1:8001/api/v1과 KEY에 담긴 액세스 키를 전제로 합니다.
네임스페이스와 엔트리 만들기¶
description과 source는 빠뜨리기 쉬우면서 서버가 요구하는 두 필드입니다.
NS=$(curl -sS -X POST "$AIGO/memory/namespaces" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"name": "Coding style", "description": "Conventions for this codebase"}' \
| jq -r '.id')
curl -sS -X POST "$AIGO/memory/namespaces/$NS/entries" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{
"content": "Prefer pnpm over npm in this repository.",
"source": "manual",
"tags": ["tooling"]
}'
둘 다 생성된 레코드와 함께 201로 답합니다. 필터를 걸어 목록을 다시 읽으면 페이지 형태와 전체 매칭 수를 볼 수 있습니다.
curl -sS -G "$AIGO/memory/namespaces/$NS/entries" -H "X-API-Key: $KEY" \
--data-urlencode 'tag=tooling' --data-urlencode 'sort=created' --data-urlencode 'order=desc' \
| jq '{total, shown: (.entries | length)}'
파라미터를 모두 빼면 같은 경로가 순수 배열을 돌려줍니다.
검색하고 주입 컨텍스트 읽기¶
먼저 순위 검색, 그다음 같은 저장소에 대해 채팅 턴이 주입할 블록입니다.
curl -sS -G "$AIGO/memory/entries" -H "X-API-Key: $KEY" \
--data-urlencode 'q=package manager' --data-urlencode 'limit=5'
curl -sS -G "$AIGO/memory/context" -H "X-API-Key: $KEY" \
--data-urlencode 'maxTokens=2000' --data-urlencode 'recordReference=false' \
| jq '{tokenCount, entryCount, namespaceCount}'
쿼리 키는 q입니다. 두 번째 호출을 사용이 아니라 조회로 만드는 것이 recordReference=false입니다. 이것이 없으면 컨텍스트를 읽는 것만으로 포함된 모든 엔트리에 참조 표시가 찍히고 최신성 순위가 올라갑니다.
저장소를 내보내고 다른 곳에 복원하기¶
내보내기는 저장소 전체를 문서 하나에 담고 엔트리를 그대로 싣습니다. 본인만 읽을 수 있는 곳에 쓰세요.
umask 077
curl -sS "$AIGO/memory/export" -H "X-API-Key: $KEY" > memory-backup.json
jq '{version, exportedAt, namespaces: (.namespaces | length)}' memory-backup.json
aigo memory export --all -o memory-backup.json도 같은 일을 하며 0600 모드를 스스로 적용합니다.
다른 기기에서 가져옵니다. 문서는 data 아래 문자열로 이동하므로 중첩하지 말고 인코딩하세요.
jq -Rs '{data: .}' memory-backup.json > import-body.json
curl -sS -X POST "$AIGO/memory/import?onConflict=merge" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
--data-binary @import-body.json \
| jq '{created: (.created | length), merged: (.merged | length), skipped, refused, entriesImported}'
created를 담은 응답은 전체 저장소 요약이고, id와 name을 담은 응답은 단일 네임스페이스 가져오기입니다. skipped는 onConflict=skip에서 그대로 둔 이름을, refused는 에이전트 경험 표시 규칙이 받지 않은 이름을 나열합니다.
제한¶
서버에서 강제되지만 강제되는 위치가 모두 같지는 않고, 그 차이가 겉으로 드러납니다. 아래에서 공통으로 표시한 항목은 메모리 도메인에 있어 Tauri 전송에서도 적용됩니다. 나머지는 REST 경계에서 검사되므로, 저장소가 담을 수 있는 것이 아니라 이 엔드포인트들이 받아들이는 것을 제한합니다. Tauri 명령으로 채운 네임스페이스는 REST 생성 경로가 거절할 엔트리를 이미 담고 있을 수 있는데, 가져오기 절이 근거로 삼는 사실이 바로 이것입니다.
- 요청 본문: 관리 API 전체 1 MiB,
POST /memory/import만 50 MiB로 상향. - 네임스페이스 이름 200바이트, 네임스페이스 설명 2000바이트.
- 엔트리 내용 102400바이트, 엔트리당 태그 50개, 태그 길이 100바이트.
- 검색어 500바이트이며 비어 있을 수 없음.
- 공통. 추출 대화록: 최소 1개, 최대 100개 메시지.
- 공통. 추출 메시지 내용: 메시지당 최대 50000바이트.
- 공통. 통합
similarityThreshold: 0.0 이상 1.0 이하. 어휘 정리는 네임스페이스당 가장 최근에 갱신된auto엔트리 500개까지만 처리. - 공통. 엔트리 목록 페이지:
limit기본값 100, 1000을 넘으면 거절, 0도 무제한이 아니라 거절. - 공통. 벌크 삭제와 이동:
ids는 최소 1개, 최대 1000개. - 공통. 주입 컨텍스트:
maxTokens기본값 2000, 100000으로 잘림.maxTokens가 0이면 REST 경계에서 400으로 거절되지만, Tauri 명령은 그대로 통과시키고 공유 코어는 이를 0 토큰 예산으로 읽어 오류 없이 빈 주입을 반환합니다. - 전체 저장소 가져오기: 네임스페이스 개수, 엔트리 개수, 필드별 크기 상한 없음. 위의 본문 제한이 유일한 경계.