diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 81b342320..e0e305d83 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -45,6 +45,7 @@ jobs: - run: npm install --legacy-peer-deps --no-audit --no-fund - run: npm run release:gate -- --out "${{ runner.temp }}/release-gate" - run: npm run test:startup:packaged -- --skip-build --out "${{ runner.temp }}/startup-gate" + - run: npm run test:summary-recovery:packaged -- --skip-build --out "${{ runner.temp }}/summary-recovery-gate" - name: Verify bounded graph migration with the packaged CLI run: | mkdir -p "$RUNNER_TEMP/graph-index-pack" @@ -58,6 +59,7 @@ jobs: path: | ${{ runner.temp }}/release-gate ${{ runner.temp }}/startup-gate + ${{ runner.temp }}/summary-recovery-gate ${{ runner.temp }}/graph-index-gate if-no-files-found: ignore retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index 2f8625084..8c1bfc2ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). ## Testing -- Before every commit run, in order: `npm run build`, `npm run skills:gen`, `npm run docs:sync`, `npm run skills:check`, `npm test` (3,500+ tests). Commit anything the generators changed. CI runs the same build, skills check and tests. +- Before every commit run, in order: `npm run build`, `npm run skills:gen`, `npm run docs:sync`, `npm run skills:check`, `npm test` (3,600+ tests). Commit anything the generators changed. CI runs the same build, skills check and tests. - Before any npm publish: `npm run release:gate` must pass. It installs the packed tarballs into a clean home and runs capture, recovery, restart, export/import, MCP and status checks against the installed CLI (see CONTRIBUTING.md, Release process) - Mock pattern: `vi.mock("iii-sdk")` with mock `sdk.trigger`, `kv.get/set/list` - Test files go in `test/` with `.test.ts` extension @@ -110,8 +110,8 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). ## Current Stats (v0.9.30) - 54 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) -- 141 REST endpoints +- 143 REST endpoints - 6 MCP resources, 3 MCP prompts - 12 hooks, 17 skills -- 318+ iii functions -- 3,500+ tests +- 324+ iii functions +- 3,400+ tests diff --git a/README.md b/README.md index 947334e4f..83576d11c 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1435,7 +1435,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | Custom plugin systems | `iii worker add ` | -**238 source files · ~56,000 LOC · 3,500+ tests · 318 functions · 66 KV scopes**, all on three primitives. No `agentmemory plugin install`. The plugin system is iii itself. +**242 source files · ~55,000 LOC · 3,400+ tests · 324 functions · 67 KV scopes**, all on three primitives. No `agentmemory plugin install`. The plugin system is iii itself. --- @@ -1781,7 +1781,7 @@ Create `~/.agentmemory/.env`:

API

-141 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer `, and mesh sync endpoints require an explicitly set `AGENTMEMORY_SECRET` on both peers. +143 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer `, and mesh sync endpoints require an explicitly set `AGENTMEMORY_SECRET` on both peers. **Authentication is on by default.** When `AGENTMEMORY_SECRET` is not set (in the shell or in `~/.agentmemory/.env`), the server generates a random secret on first start and stores it in `~/.agentmemory/secret` with mode `0600`. Most bundled clients read it from there when they talk to a local server: the CLI, the viewer, the hooks under `plugin/scripts`, the MCP server and the `@agentmemory/mcp` shim, the configs written by `agentmemory connect`, and the bundled OpenCode, Pi, OpenClaw and filesystem-watcher integrations. The Hermes provider and its optional MCP connection instead use credentials managed by Hermes; configure them with `hermes memory setup`. The stored secret is only sent to loopback URLs (`localhost`, `127.0.0.0/8`, `::1`). An explicit `AGENTMEMORY_SECRET` always wins, and remote clients still need it set. Docker and the `deploy/` entrypoints already generate and export their own secret. To call the API by hand: @@ -1867,7 +1867,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.ar-SA.md b/READMEs/README.ar-SA.md index 9409f425d..bd28c4684 100644 --- a/READMEs/README.ar-SA.md +++ b/READMEs/README.ar-SA.md @@ -73,7 +73,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1439,7 +1439,7 @@ fi | Prometheus / Grafana | OTEL في iii + مراقب الحالة الصحية | | أنظمة إضافات مخصصة | `iii worker add ` | -**238 ملف مصدري · ~56,000 سطر برمجي · 3,500+ اختبار · 318 دالة · 66 نطاق KV**، كل ذلك على ثلاث بنى أساسية. لا يوجد أمر `agentmemory plugin install`. نظام الإضافات هو iii نفسه. +**242 ملف مصدري · ~55,000 سطر برمجي · 3,600+ اختبار · 324 دالة · 67 نطاق KV**، كل ذلك على ثلاث بنى أساسية. لا يوجد أمر `agentmemory plugin install`. نظام الإضافات هو iii نفسه. --- @@ -1782,7 +1782,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-141 نقطة نهاية على المنفذ `3111`. ترتبط REST API بـ `127.0.0.1` افتراضيًا. تتطلب نقاط النهاية المحمية ترويسة `Authorization: Bearer `، وتتطلب نقاط نهاية مزامنة mesh ضبط `AGENTMEMORY_SECRET` صريحًا على كلا الطرفين. +143 نقطة نهاية على المنفذ `3111`. ترتبط REST API بـ `127.0.0.1` افتراضيًا. تتطلب نقاط النهاية المحمية ترويسة `Authorization: Bearer `، وتتطلب نقاط نهاية مزامنة mesh ضبط `AGENTMEMORY_SECRET` صريحًا على كلا الطرفين. **المصادقة مُفعَّلة افتراضيًا.** عندما لا يكون `AGENTMEMORY_SECRET` مضبوطًا (في الصدفة أو في `~/.agentmemory/.env`)، يُولِّد الخادم سرًا عشوائيًا عند أول تشغيل ويخزِّنه في `~/.agentmemory/secret` بوضع صلاحيات `0600`. ويقرؤه كل عميل مُضمَّن من هناك عند التحدث إلى خادم محلي: الواجهة الطرفية CLI، والعارض، والخطافات تحت `plugin/scripts`، وخادم MCP والوسيط `@agentmemory/mcp`، والإعدادات التي يكتبها `agentmemory connect`، وتكاملات OpenCode وPi وOpenClaw وHermes ومراقب نظام الملفات (filesystem-watcher) المُضمَّنة. ولا يُرسَل السر المخزَّن إلا إلى عناوين loopback (`localhost`، و`127.0.0.0/8`، و`::1`). ويتغلّب `AGENTMEMORY_SECRET` الصريح دائمًا، ولا يزال العملاء البعيدون بحاجة إلى ضبطه. وتُولِّد Docker وقوالب [`deploy/`](../deploy/) سرها الخاص وتُصدِّره مسبقًا. ولاستدعاء الواجهة البرمجية يدويًا: @@ -1863,7 +1863,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.bn-BD.md b/READMEs/README.bn-BD.md index b2d0f8901..4886b94c8 100644 --- a/READMEs/README.bn-BD.md +++ b/READMEs/README.bn-BD.md @@ -71,7 +71,7 @@ 54 MCP টুল 12 অটো হুক 0 এক্সটার্নাল ডিবি - 3,500+ টেস্ট পাস + 3,600+ টেস্ট পাস

@@ -1437,7 +1437,7 @@ fi | Prometheus / Grafana | iii OTEL + হেলথ মনিটর | | কাস্টম প্লাগইন সিস্টেম | `iii worker add ` | -**238 সোর্স ফাইল · ~56,000 LOC · 3,500+ টেস্ট · 318 ফাংশন · 66 KV স্কোপ**, সবকিছু তিনটি প্রিমিটিভের উপর। কোনো `agentmemory plugin install` নেই। প্লাগইন সিস্টেমটাই iii নিজে। +**242 সোর্স ফাইল · ~55,000 LOC · 3,400+ টেস্ট · 324 ফাংশন · 67 KV স্কোপ**, সবকিছু তিনটি প্রিমিটিভের উপর। কোনো `agentmemory plugin install` নেই। প্লাগইন সিস্টেমটাই iii নিজে। --- @@ -1780,7 +1780,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-`3111` পোর্টে 141 এন্ডপয়েন্ট। REST API ডিফল্টভাবে `127.0.0.1`-এ বাইন্ড করে। প্রোটেক্টেড এন্ডপয়েন্টে `Authorization: Bearer ` লাগে, এবং mesh sync এন্ডপয়েন্টে দুই পিয়ারেই স্পষ্টভাবে সেট করা `AGENTMEMORY_SECRET` লাগে। +`3111` পোর্টে 143 এন্ডপয়েন্ট। REST API ডিফল্টভাবে `127.0.0.1`-এ বাইন্ড করে। প্রোটেক্টেড এন্ডপয়েন্টে `Authorization: Bearer ` লাগে, এবং mesh sync এন্ডপয়েন্টে দুই পিয়ারেই স্পষ্টভাবে সেট করা `AGENTMEMORY_SECRET` লাগে। **অথেন্টিকেশন ডিফল্টভাবে চালু।** `AGENTMEMORY_SECRET` সেট না থাকলে (শেলে বা `~/.agentmemory/.env`-এ), সার্ভার প্রথম স্টার্টে একটি র‍্যান্ডম সিক্রেট জেনারেট করে এবং `0600` মোডে `~/.agentmemory/secret`-এ জমা রাখে। লোকাল সার্ভারের সাথে কথা বলার সময় প্রতিটি বান্ডলড ক্লায়েন্ট সেখান থেকেই এটা পড়ে: CLI, ভিউয়ার, `plugin/scripts`-এর নিচের হুক, MCP সার্ভার এবং `@agentmemory/mcp` শিম, `agentmemory connect`-এ লেখা কনফিগ, এবং বান্ডলড OpenCode, Pi, OpenClaw, Hermes, এবং filesystem-watcher ইন্টিগ্রেশন। জমা রাখা সিক্রেটটি কেবল লুপব্যাক URL-এ (`localhost`, `127.0.0.0/8`, `::1`) পাঠানো হয়। একটি এক্সপ্লিসিট `AGENTMEMORY_SECRET` সবসময় জেতে, এবং রিমোট ক্লায়েন্টদেরও এটা সেট করা লাগে। Docker এবং `deploy/` এন্ট্রিপয়েন্ট নিজেরাই নিজেদের সিক্রেট জেনারেট ও এক্সপোর্ট করে। হাতে API কল করতে: @@ -1861,7 +1861,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.cs-CZ.md b/READMEs/README.cs-CZ.md index 9028b2e2a..dc94f6a4f 100644 --- a/READMEs/README.cs-CZ.md +++ b/READMEs/README.cs-CZ.md @@ -71,7 +71,7 @@ 54 MCP nástrojů 12 automatických hooks 0 externích databází - 3,500+ testů úspěšně proběhlo + 3,600+ testů úspěšně proběhlo

@@ -1446,7 +1446,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | Vlastní systémy pluginů | `iii worker add ` | -**238 zdrojových souborů · ~56,000 řádků kódu · 3,500+ testů · 318 funkcí · 66 KV scopes**, vše na třech primitivech. Žádné `agentmemory plugin install`. Systém pluginů je samo iii. +**242 zdrojových souborů · ~55,000 řádků kódu · 3,400+ testů · 324 funkcí · 67 KV scopes**, vše na třech primitivech. Žádné `agentmemory plugin install`. Systém pluginů je samo iii. --- @@ -1789,7 +1789,7 @@ Vytvořte `~/.agentmemory/.env`:

API

-141 endpointů na portu `3111`. REST API se ve výchozím stavu váže na `127.0.0.1`. Chráněné endpointy vyžadují `Authorization: Bearer `, a endpointy pro mesh sync vyžadují explicitně nastavené `AGENTMEMORY_SECRET` na obou stranách. +143 endpointů na portu `3111`. REST API se ve výchozím stavu váže na `127.0.0.1`. Chráněné endpointy vyžadují `Authorization: Bearer `, a endpointy pro mesh sync vyžadují explicitně nastavené `AGENTMEMORY_SECRET` na obou stranách. **Autentizace je ve výchozím stavu zapnutá.** Když `AGENTMEMORY_SECRET` není nastaveno (v shellu ani v `~/.agentmemory/.env`), server při prvním startu vygeneruje náhodný secret a uloží ho do `~/.agentmemory/secret` s režimem `0600`. Každý zabalený klient ho odtud čte, když mluví s lokálním serverem: CLI, viewer, hooks pod `plugin/scripts`, server MCP a shim `@agentmemory/mcp`, konfigurace zapsané příkazem `agentmemory connect` a zabalené integrace OpenCode, Pi, OpenClaw, Hermes a filesystem-watcher. Uložený secret se posílá jen na loopback URL (`localhost`, `127.0.0.0/8`, `::1`). Explicitní `AGENTMEMORY_SECRET` má vždy přednost, a vzdálení klienti ho stejně potřebují nastavený. Docker a entrypointy v `deploy/` už svůj vlastní secret generují a exportují. Pro manuální volání API: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.da-DK.md b/READMEs/README.da-DK.md index 0d6234a52..0c53d6beb 100644 --- a/READMEs/README.da-DK.md +++ b/READMEs/README.da-DK.md @@ -71,7 +71,7 @@ 54 MCP-værktøjer 12 automatiske hooks 0 eksterne databaser - 3,500+ beståede tests + 3,600+ beståede tests

@@ -1436,7 +1436,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | Custom plugin-systemer | `iii worker add ` | -**238 kildefiler · ~56,000 LOC · 3,500+ tests · 318 funktioner · 66 KV-scopes**, alt på tre primitiver. Intet `agentmemory plugin install`. Plugin-systemet er iii selv. +**242 kildefiler · ~55,000 LOC · 3,400+ tests · 324 funktioner · 67 KV-scopes**, alt på tre primitiver. Intet `agentmemory plugin install`. Plugin-systemet er iii selv. --- @@ -1779,7 +1779,7 @@ Opret `~/.agentmemory/.env`:

API

-141 endpoints på port `3111`. REST API'et binder til `127.0.0.1` som standard. Beskyttede endpoints kræver `Authorization: Bearer `, og mesh-sync-endpoints kræver en eksplicit sat `AGENTMEMORY_SECRET` på begge peers. +143 endpoints på port `3111`. REST API'et binder til `127.0.0.1` som standard. Beskyttede endpoints kræver `Authorization: Bearer `, og mesh-sync-endpoints kræver en eksplicit sat `AGENTMEMORY_SECRET` på begge peers. **Autentificering er slået til som standard.** Når `AGENTMEMORY_SECRET` ikke er sat (i shellen eller i `~/.agentmemory/.env`), genererer serveren en tilfældig secret ved første opstart og lagrer den i `~/.agentmemory/secret` med rettigheden `0600`. Hver medfølgende klient læser den fra der, når den taler med en lokal server: CLI'en, viewer, hooks under `plugin/scripts`, MCP-serveren og `@agentmemory/mcp`-shimmen, konfigurationerne skrevet af `agentmemory connect`, og de medfølgende OpenCode-, Pi-, OpenClaw-, Hermes- og filesystem-watcher-integrationer. Den lagrede secret sendes kun til loopback-URL'er (`localhost`, `127.0.0.0/8`, `::1`). En eksplicit `AGENTMEMORY_SECRET` vinder altid, og eksterne klienter skal stadig have den sat. Docker og `deploy/`-entrypoints genererer og eksporterer allerede deres egen secret. For at kalde API'et manuelt: @@ -1860,7 +1860,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.de-DE.md b/READMEs/README.de-DE.md index 56842167a..95d5cb58d 100644 --- a/READMEs/README.de-DE.md +++ b/READMEs/README.de-DE.md @@ -71,7 +71,7 @@ 54 MCP-Tools 12 Auto-Hooks 0 externe DBs - 3,500+ bestandene Tests + 3,600+ bestandene Tests

@@ -1446,7 +1446,7 @@ fi | Prometheus / Grafana | iii OTEL + Health-Monitor | | Eigene Plugin-Systeme | `iii worker add ` | -**238 Quelldateien · ~56,000 LOC · 3,500+ Tests · 318 Funktionen · 66 KV-Scopes**, alles auf drei Primitiven. Kein `agentmemory plugin install`. Das Plugin-System ist iii selbst. +**242 Quelldateien · ~55,000 LOC · 3,400+ Tests · 324 Funktionen · 67 KV-Scopes**, alles auf drei Primitiven. Kein `agentmemory plugin install`. Das Plugin-System ist iii selbst. --- @@ -1789,7 +1789,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-141 Endpunkte auf Port `3111`. Die REST API bindet sich standardmäßig an `127.0.0.1`. Geschützte Endpunkte verlangen `Authorization: Bearer `, und Mesh-Sync-Endpunkte erfordern ein explizit gesetztes `AGENTMEMORY_SECRET` auf beiden Peers. +143 Endpunkte auf Port `3111`. Die REST API bindet sich standardmäßig an `127.0.0.1`. Geschützte Endpunkte verlangen `Authorization: Bearer `, und Mesh-Sync-Endpunkte erfordern ein explizit gesetztes `AGENTMEMORY_SECRET` auf beiden Peers. **Authentifizierung ist standardmäßig aktiv.** Wenn `AGENTMEMORY_SECRET` nicht gesetzt ist (weder in der Shell noch in `~/.agentmemory/.env`), erzeugt der Server beim ersten Start ein zufälliges Secret und speichert es in `~/.agentmemory/secret` mit dem Modus `0600`. Jeder mitgelieferte Client liest es von dort, wenn er mit einem lokalen Server spricht: die CLI, der Viewer, die Hooks unter `plugin/scripts`, der MCP-Server und das `@agentmemory/mcp`-Shim, die von `agentmemory connect` geschriebenen Konfigs sowie die mitgelieferten Integrationen für OpenCode, Pi, OpenClaw, Hermes und den Filesystem-Watcher. Das gespeicherte Secret wird nur an Loopback-URLs gesendet (`localhost`, `127.0.0.0/8`, `::1`). Ein explizites `AGENTMEMORY_SECRET` gewinnt immer, und Remote-Clients benötigen es trotzdem gesetzt. Docker und die `deploy/`-Entrypoints erzeugen und exportieren bereits ihr eigenes Secret. Um die API von Hand aufzurufen: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.el-GR.md b/READMEs/README.el-GR.md index 9c8681b3f..2169d4185 100644 --- a/READMEs/README.el-GR.md +++ b/READMEs/README.el-GR.md @@ -71,7 +71,7 @@ 54 εργαλεία MCP 12 αυτόματα hooks 0 εξωτερικές βάσεις δεδομένων - 3,500+ επιτυχημένες δοκιμές + 3,600+ επιτυχημένες δοκιμές

@@ -1438,7 +1438,7 @@ fi | Prometheus / Grafana | iii OTEL + monitor υγείας | | Προσαρμοσμένα συστήματα plugin | `iii worker add ` | -**238 αρχεία πηγαίου κώδικα · ~56,000 LOC · 3,500+ δοκιμές · 318 functions · 66 KV scopes**, όλα πάνω σε τρία primitives. Δεν υπάρχει `agentmemory plugin install`. Το σύστημα plugin είναι το ίδιο το iii. +**242 αρχεία πηγαίου κώδικα · ~55,000 LOC · 3,400+ δοκιμές · 324 functions · 67 KV scopes**, όλα πάνω σε τρία primitives. Δεν υπάρχει `agentmemory plugin install`. Το σύστημα plugin είναι το ίδιο το iii. --- @@ -1781,7 +1781,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-141 endpoints στη θύρα `3111`. Το REST API συνδέεται στο `127.0.0.1` από προεπιλογή. Τα προστατευμένα endpoints απαιτούν `Authorization: Bearer `, και τα endpoints mesh sync απαιτούν ένα ρητά ρυθμισμένο `AGENTMEMORY_SECRET` και στους δύο peers. +143 endpoints στη θύρα `3111`. Το REST API συνδέεται στο `127.0.0.1` από προεπιλογή. Τα προστατευμένα endpoints απαιτούν `Authorization: Bearer `, και τα endpoints mesh sync απαιτούν ένα ρητά ρυθμισμένο `AGENTMEMORY_SECRET` και στους δύο peers. **Η πιστοποίηση είναι ενεργή από προεπιλογή.** Όταν το `AGENTMEMORY_SECRET` δεν έχει ρυθμιστεί (στο shell ή στο `~/.agentmemory/.env`), ο διακομιστής δημιουργεί ένα τυχαίο secret στην πρώτη εκκίνηση και το αποθηκεύει στο `~/.agentmemory/secret` με mode `0600`. Κάθε ενσωματωμένος client το διαβάζει από εκεί όταν μιλά με έναν τοπικό διακομιστή: το CLI, το viewer, τα hooks κάτω από το `plugin/scripts`, ο διακομιστής MCP και το shim `@agentmemory/mcp`, οι ρυθμίσεις που γράφει το `agentmemory connect`, και οι ενσωματωμένες ενσωματώσεις OpenCode, Pi, OpenClaw, Hermes, και filesystem-watcher. Το αποθηκευμένο secret αποστέλλεται μόνο σε loopback URLs (`localhost`, `127.0.0.0/8`, `::1`). Ένα ρητό `AGENTMEMORY_SECRET` υπερισχύει πάντα, και οι απομακρυσμένοι clients εξακολουθούν να το χρειάζονται ρυθμισμένο. Το Docker και τα entrypoints του `deploy/` δημιουργούν και εξάγουν ήδη το δικό τους secret. Για να καλέσετε το API χειροκίνητα: @@ -1862,7 +1862,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.es-ES.md b/READMEs/README.es-ES.md index c249f88ec..dce73f859 100644 --- a/READMEs/README.es-ES.md +++ b/READMEs/README.es-ES.md @@ -71,7 +71,7 @@ 54 tools MCP 12 hooks automáticos 0 bases de datos externas - 3,500+ tests superados + 3,600+ tests superados

@@ -1446,7 +1446,7 @@ fi | Prometheus / Grafana | iii OTEL + monitor de salud | | Sistemas de plugins propios | `iii worker add ` | -**238 ficheros de código · ~56,000 LOC · 3,500+ tests · 318 funciones · 66 scopes KV**, todo sobre tres primitivos. No hay `agentmemory plugin install`. El sistema de plugins es iii mismo. +**242 ficheros de código · ~55,000 LOC · 3,400+ tests · 324 funciones · 67 scopes KV**, todo sobre tres primitivos. No hay `agentmemory plugin install`. El sistema de plugins es iii mismo. --- @@ -1789,7 +1789,7 @@ Crea `~/.agentmemory/.env`:

API

-141 endpoints en el puerto `3111`. La REST API se enlaza a `127.0.0.1` por defecto. Los endpoints protegidos requieren `Authorization: Bearer `, y los endpoints de mesh sync requieren un `AGENTMEMORY_SECRET` definido explícitamente en ambos peers. +143 endpoints en el puerto `3111`. La REST API se enlaza a `127.0.0.1` por defecto. Los endpoints protegidos requieren `Authorization: Bearer `, y los endpoints de mesh sync requieren un `AGENTMEMORY_SECRET` definido explícitamente en ambos peers. **La autenticación está activada por defecto.** Cuando `AGENTMEMORY_SECRET` no está definida (ni en el shell ni en `~/.agentmemory/.env`), el servidor genera un secreto aleatorio en el primer arranque y lo guarda en `~/.agentmemory/secret` con modo `0600`. Todos los clientes empaquetados lo leen desde ahí cuando hablan con un servidor local: la CLI, el visor, los hooks bajo `plugin/scripts`, el servidor MCP y el shim `@agentmemory/mcp`, las configs escritas por `agentmemory connect`, y las integraciones empaquetadas de OpenCode, Pi, OpenClaw, Hermes y filesystem-watcher. El secreto guardado solo se envía a URLs de loopback (`localhost`, `127.0.0.0/8`, `::1`). Un `AGENTMEMORY_SECRET` explícito siempre gana, y los clientes remotos igualmente necesitan tenerlo definido. Docker y los entrypoints de `deploy/` ya generan y exportan su propio secreto. Para llamar a la API a mano: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.fi-FI.md b/READMEs/README.fi-FI.md index 81f439be8..42e9a5e0a 100644 --- a/READMEs/README.fi-FI.md +++ b/READMEs/README.fi-FI.md @@ -71,7 +71,7 @@ 54 MCP-työkalua 12 automaattista hookia 0 ulkoista tietokantaa - 3,500+ testiä läpäisty + 3,600+ testiä läpäisty

@@ -1446,7 +1446,7 @@ fi | Prometheus / Grafana | iii OTEL + terveysmonitori | | Mukautetut plugin-järjestelmät | `iii worker add ` | -**238 lähdetiedostoa · ~56,000 LOC · 3,500+ testiä · 318 funktiota · 66 KV-scopea**, kaikki kolmen primitiivin päällä. Ei `agentmemory plugin install` -komentoa. Plugin-järjestelmä on iii itse. +**242 lähdetiedostoa · ~55,000 LOC · 3,400+ testiä · 324 funktiota · 67 KV-scopea**, kaikki kolmen primitiivin päällä. Ei `agentmemory plugin install` -komentoa. Plugin-järjestelmä on iii itse. --- @@ -1789,7 +1789,7 @@ Luo `~/.agentmemory/.env`:

API

-141 endpointia portissa `3111`. REST-rajapinta sitoutuu oletuksena osoitteeseen `127.0.0.1`. Suojatut endpointit vaativat `Authorization: Bearer `, ja mesh-synkronointiendpointit vaativat nimenomaisesti asetetun `AGENTMEMORY_SECRET`-arvon molemmilla vertaisilla. +143 endpointia portissa `3111`. REST-rajapinta sitoutuu oletuksena osoitteeseen `127.0.0.1`. Suojatut endpointit vaativat `Authorization: Bearer `, ja mesh-synkronointiendpointit vaativat nimenomaisesti asetetun `AGENTMEMORY_SECRET`-arvon molemmilla vertaisilla. **Autentikointi on oletuksena päällä.** Kun `AGENTMEMORY_SECRET` ei ole asetettu (shellissä tai tiedostossa `~/.agentmemory/.env`), palvelin generoi satunnaisen salaisuuden ensimmäisellä käynnistyksellä ja tallentaa sen polkuun `~/.agentmemory/secret` oikeuksilla `0600`. Joka mukana tuleva asiakas lukee sen sieltä puhuessaan paikalliselle palvelimelle: CLI, katseluohjelma, hookit kansiossa `plugin/scripts`, MCP-palvelin ja `@agentmemory/mcp`-shim, `agentmemory connect`-komennon kirjoittamat asetukset, ja mukana tulevat OpenCode-, Pi-, OpenClaw-, Hermes- ja tiedostojärjestelmän tarkkailijaintegraatiot. Tallennettu salaisuus lähetetään vain loopback-URL-osoitteisiin (`localhost`, `127.0.0.0/8`, `::1`). Nimenomainen `AGENTMEMORY_SECRET` voittaa aina, ja etäasiakkaat tarvitsevat sen yhä asetettuna. Docker ja `deploy/`-entrypointit generoivat ja vievät jo omansa. Kutsuaksesi rajapintaa käsin: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.fr-FR.md b/READMEs/README.fr-FR.md index 20a469c4e..d2dde35a2 100644 --- a/READMEs/README.fr-FR.md +++ b/READMEs/README.fr-FR.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1448,7 +1448,7 @@ fi | Prometheus / Grafana | iii OTEL + moniteur de santé | | Systèmes de plugins custom | `iii worker add ` | -**238 fichiers sources · ~56,000 LOC · 3,500+ tests · 318 fonctions · 66 scopes KV**, tout sur trois primitives. Pas de `agentmemory plugin install`. Le système de plugins, c'est iii lui-même. +**242 fichiers sources · ~55,000 LOC · 3,600+ tests · 324 fonctions · 67 scopes KV**, tout sur trois primitives. Pas de `agentmemory plugin install`. Le système de plugins, c'est iii lui-même. --- @@ -1791,7 +1791,7 @@ Créez `~/.agentmemory/.env` :

API

-141 endpoints sur le port `3111`. L'API REST se lie à `127.0.0.1` par défaut. Les endpoints protégés requièrent `Authorization: Bearer `, et les endpoints de mesh sync requièrent un `AGENTMEMORY_SECRET` explicitement défini sur les deux pairs. +143 endpoints sur le port `3111`. L'API REST se lie à `127.0.0.1` par défaut. Les endpoints protégés requièrent `Authorization: Bearer `, et les endpoints de mesh sync requièrent un `AGENTMEMORY_SECRET` explicitement défini sur les deux pairs. **L'authentification est activée par défaut.** Quand `AGENTMEMORY_SECRET` n'est pas défini (dans le shell ou dans `~/.agentmemory/.env`), le serveur génère un secret aléatoire au premier démarrage et le stocke dans `~/.agentmemory/secret` avec le mode `0600`. Chaque client embarqué le lit depuis là quand il parle à un serveur local : la CLI, le visualiseur, les hooks sous `plugin/scripts`, le serveur MCP et le shim `@agentmemory/mcp`, les configs écrites par `agentmemory connect`, et les intégrations embarquées OpenCode, Pi, OpenClaw, Hermes et filesystem-watcher. Le secret stocké n'est envoyé qu'à des URLs loopback (`localhost`, `127.0.0.0/8`, `::1`). Un `AGENTMEMORY_SECRET` explicite prime toujours, et les clients distants en ont toujours besoin. Docker et les points d'entrée `deploy/` génèrent et exportent déjà leur propre secret. Pour appeler l'API à la main : @@ -1872,7 +1872,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.he-IL.md b/READMEs/README.he-IL.md index ff6a3aa97..91f065d3a 100644 --- a/READMEs/README.he-IL.md +++ b/READMEs/README.he-IL.md @@ -73,7 +73,7 @@ 54 כלי MCP 12 hooks אוטומטיים 0 מסדי נתונים חיצוניים - 3,500+ בדיקות עוברות + 3,600+ בדיקות עוברות

@@ -1439,7 +1439,7 @@ fi | Prometheus / Grafana | iii OTEL + מוניטור health | | מערכות plugin מותאמות אישית | `iii worker add ` | -**238 קבצי מקור · ~56,000 LOC · 3,500+ בדיקות · 318 פונקציות · 66 תחומי KV**, הכול על שלושה primitives. אין `agentmemory plugin install`. מערכת ה-plugin היא iii עצמו. +**242 קבצי מקור · ~55,000 LOC · 3,400+ בדיקות · 324 פונקציות · 67 תחומי KV**, הכול על שלושה primitives. אין `agentmemory plugin install`. מערכת ה-plugin היא iii עצמו. --- @@ -1782,7 +1782,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-141 endpoints בפורט `3111`. ה-REST API נקשר ל-`127.0.0.1` כברירת מחדל. endpoints מוגנים דורשים `Authorization: Bearer `, ו-endpoints של mesh sync דורשים `AGENTMEMORY_SECRET` מוגדר באופן מפורש בשני ה-peers. +143 endpoints בפורט `3111`. ה-REST API נקשר ל-`127.0.0.1` כברירת מחדל. endpoints מוגנים דורשים `Authorization: Bearer `, ו-endpoints של mesh sync דורשים `AGENTMEMORY_SECRET` מוגדר באופן מפורש בשני ה-peers. **אימות (Authentication) פעיל כברירת מחדל.** כש-`AGENTMEMORY_SECRET` לא מוגדר (ב-shell או ב-`~/.agentmemory/.env`), השרת מייצר secret אקראי בהפעלה הראשונה ושומר אותו ב-`~/.agentmemory/secret` במוד `0600`. כל לקוח מצורף קורא אותו משם כשהוא מדבר עם שרת מקומי: ה-CLI, המציג, ה-hooks תחת `plugin/scripts`, שרת ה-MCP וה-shim `@agentmemory/mcp`, התצורות שנכתבות על ידי `agentmemory connect`, והאינטגרציות המצורפות של OpenCode, Pi, OpenClaw, Hermes וה-filesystem-watcher. ה-secret המאוחסן נשלח רק ל-URLs של loopback (`localhost`, `127.0.0.0/8`, `::1`). `AGENTMEMORY_SECRET` מפורש גובר תמיד, ולקוחות מרוחקים עדיין צריכים אותו מוגדר. Docker וה-entrypoints של `deploy/` כבר מייצרים ומייצאים את ה-secret שלהם. כדי לקרוא ל-API ביד: @@ -1863,7 +1863,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.hi-IN.md b/READMEs/README.hi-IN.md index 55fc9f1e6..927d6d554 100644 --- a/READMEs/README.hi-IN.md +++ b/READMEs/README.hi-IN.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1449,7 +1449,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | Custom plugin systems | `iii worker add ` | -**238 source files · ~56,000 LOC · 3,500+ tests · 318 functions · 66 KV scopes**, सब कुछ तीन primitives पर। कोई `agentmemory plugin install` नहीं। Plugin system iii स्वयं है। +**242 source files · ~55,000 LOC · 3,400+ tests · 324 functions · 67 KV scopes**, सब कुछ तीन primitives पर। कोई `agentmemory plugin install` नहीं। Plugin system iii स्वयं है। --- @@ -1792,7 +1792,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-Port `3111` पर 141 endpoints। REST API default रूप से `127.0.0.1` से bind होता है। Protected endpoints `Authorization: Bearer ` की आवश्यकता रखते हैं, और mesh sync endpoints दोनों peers पर एक explicitly set `AGENTMEMORY_SECRET` की आवश्यकता रखते हैं। +Port `3111` पर 143 endpoints। REST API default रूप से `127.0.0.1` से bind होता है। Protected endpoints `Authorization: Bearer ` की आवश्यकता रखते हैं, और mesh sync endpoints दोनों peers पर एक explicitly set `AGENTMEMORY_SECRET` की आवश्यकता रखते हैं। **Authentication डिफ़ॉल्ट रूप से on है।** जब `AGENTMEMORY_SECRET` set नहीं है (shell में या `~/.agentmemory/.env` में), server पहले start पर एक random secret generate करता है और इसे mode `0600` के साथ `~/.agentmemory/secret` में store करता है। हर bundled client किसी local server से बात करते समय इसे वहाँ से पढ़ता है: CLI, viewer, `plugin/scripts` के अंतर्गत hooks, MCP server और `@agentmemory/mcp` shim, `agentmemory connect` द्वारा लिखी गई configs, और bundled OpenCode, Pi, OpenClaw, Hermes, और filesystem-watcher integrations। Stored secret केवल loopback URLs (`localhost`, `127.0.0.0/8`, `::1`) पर भेजा जाता है। एक explicit `AGENTMEMORY_SECRET` हमेशा जीतता है, और remote clients को फिर भी इसे set करना होता है। Docker और `deploy/` entrypoints पहले से अपना स्वयं का secret generate और export करते हैं। API को हाथ से call करने के लिए: @@ -1873,7 +1873,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.hu-HU.md b/READMEs/README.hu-HU.md index 4bbab84b7..046e91fb4 100644 --- a/READMEs/README.hu-HU.md +++ b/READMEs/README.hu-HU.md @@ -71,7 +71,7 @@ 54 MCP eszköz 12 automatikus hook 0 külső adatbázis - 3,500+ sikeres teszt + 3,600+ sikeres teszt

@@ -1446,7 +1446,7 @@ A `/agentmemory/export` emellett elfogadja a `?maxSessions=`-t és a `?offset=`- | Prometheus / Grafana | iii OTEL + egészségfigyelő | | Egyéni plugin-rendszerek | `iii worker add ` | -**238 forrásfájl · ~56,000 LOC · 3,500+ teszt · 318 funkció · 66 KV-hatókör**, mindez három primitíven. Nincs `agentmemory plugin install`. A plugin-rendszer maga az iii. +**242 forrásfájl · ~55,000 LOC · 3,400+ teszt · 324 funkció · 67 KV-hatókör**, mindez három primitíven. Nincs `agentmemory plugin install`. A plugin-rendszer maga az iii. --- @@ -1789,7 +1789,7 @@ Hozd létre a `~/.agentmemory/.env`-et:

API

-141 endpoint a `3111`-es porton. A REST API alapértelmezetten a `127.0.0.1`-hez kötődik. A védett végpontok `Authorization: Bearer `-et igényelnek, és a mesh-sync végpontok mindkét oldalon explicit módon beállított `AGENTMEMORY_SECRET`-et igényelnek. +143 endpoint a `3111`-es porton. A REST API alapértelmezetten a `127.0.0.1`-hez kötődik. A védett végpontok `Authorization: Bearer `-et igényelnek, és a mesh-sync végpontok mindkét oldalon explicit módon beállított `AGENTMEMORY_SECRET`-et igényelnek. **A hitelesítés alapértelmezetten be van kapcsolva.** Amikor az `AGENTMEMORY_SECRET` nincs beállítva (sem a shellben, sem a `~/.agentmemory/.env`-ben), a szerver az első indításkor egy véletlen secretet generál, és `0600` móddal tárolja a `~/.agentmemory/secret`-ben. Minden becsomagolt kliens onnan olvassa, amikor egy lokális szerverrel beszél: a CLI, a megjelenítő, a `plugin/scripts` alatti hookok, az MCP szerver és a `@agentmemory/mcp` shim, az `agentmemory connect` által írt konfigurációk, és a becsomagolt OpenCode, Pi, OpenClaw, Hermes és fájlrendszer-figyelő integrációk. A tárolt secret csak loopback URL-eknek kerül elküldésre (`localhost`, `127.0.0.0/8`, `::1`). Egy explicit `AGENTMEMORY_SECRET` mindig elsőbbséget kap, és a távoli klienseknek továbbra is be kell állítaniuk. A Docker és a `deploy/` entrypointok már generálnak és exportálnak egy saját secretet. Az API kézi hívásához: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.id-ID.md b/READMEs/README.id-ID.md index aa44f1ba8..5503f72e2 100644 --- a/READMEs/README.id-ID.md +++ b/READMEs/README.id-ID.md @@ -71,7 +71,7 @@ 54 tool MCP 12 hook otomatis 0 DB eksternal - 3,500+ pengujian lolos + 3,600+ pengujian lolos

@@ -1447,7 +1447,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | Sistem plugin custom | `iii worker add ` | -**238 file sumber · ~56,000 LOC · 3,500+ pengujian · 318 fungsi · 66 scope KV**, semua di atas tiga primitif. Tidak ada `agentmemory plugin install`. Sistem plugin-nya adalah iii itu sendiri. +**242 file sumber · ~55,000 LOC · 3,600+ pengujian · 324 fungsi · 67 scope KV**, semua di atas tiga primitif. Tidak ada `agentmemory plugin install`. Sistem plugin-nya adalah iii itu sendiri. --- @@ -1790,7 +1790,7 @@ Buat `~/.agentmemory/.env`:

API

-141 endpoint di port `3111`. REST API terbind ke `127.0.0.1` secara default. Endpoint yang terlindungi membutuhkan `Authorization: Bearer `, dan endpoint mesh sync membutuhkan `AGENTMEMORY_SECRET` yang diset secara eksplisit di kedua peer. +143 endpoint di port `3111`. REST API terbind ke `127.0.0.1` secara default. Endpoint yang terlindungi membutuhkan `Authorization: Bearer `, dan endpoint mesh sync membutuhkan `AGENTMEMORY_SECRET` yang diset secara eksplisit di kedua peer. **Autentikasi aktif secara default.** Saat `AGENTMEMORY_SECRET` tidak diset (di shell atau di `~/.agentmemory/.env`), server menghasilkan secret acak pada start pertama dan menyimpannya di `~/.agentmemory/secret` dengan mode `0600`. Setiap klien bundel membacanya dari sana saat berbicara dengan server lokal: CLI, viewer, hook di bawah `plugin/scripts`, server MCP dan shim `@agentmemory/mcp`, konfigurasi yang ditulis `agentmemory connect`, serta integrasi OpenCode, Pi, OpenClaw, Hermes, dan filesystem-watcher bundel. Secret yang tersimpan hanya dikirim ke URL loopback (`localhost`, `127.0.0.0/8`, `::1`). `AGENTMEMORY_SECRET` eksplisit selalu menang, dan klien remote tetap membutuhkannya diset. Docker dan entrypoint `deploy/` sudah menghasilkan dan mengekspor secret-nya sendiri. Untuk memanggil API secara manual: @@ -1871,7 +1871,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.it-IT.md b/READMEs/README.it-IT.md index 2672b955c..3f881f372 100644 --- a/READMEs/README.it-IT.md +++ b/READMEs/README.it-IT.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1446,7 +1446,7 @@ fi | Prometheus / Grafana | iii OTEL + monitor di salute | | Sistemi di plugin personalizzati | `iii worker add ` | -**238 file sorgente · ~56,000 righe di codice · 3,500+ test · 318 funzioni · 66 scope KV**, tutto su tre primitive. Nessun `agentmemory plugin install`. Il sistema di plugin è iii stesso. +**242 file sorgente · ~55,000 righe di codice · 3,400+ test · 324 funzioni · 67 scope KV**, tutto su tre primitive. Nessun `agentmemory plugin install`. Il sistema di plugin è iii stesso. --- @@ -1789,7 +1789,7 @@ Crea `~/.agentmemory/.env`:

API

-141 endpoint sulla porta `3111`. L'API REST si collega a `127.0.0.1` per impostazione predefinita. Gli endpoint protetti richiedono `Authorization: Bearer `, e gli endpoint di sincronizzazione mesh richiedono un `AGENTMEMORY_SECRET` impostato esplicitamente su entrambi i peer. +143 endpoint sulla porta `3111`. L'API REST si collega a `127.0.0.1` per impostazione predefinita. Gli endpoint protetti richiedono `Authorization: Bearer `, e gli endpoint di sincronizzazione mesh richiedono un `AGENTMEMORY_SECRET` impostato esplicitamente su entrambi i peer. **L'autenticazione è attiva per default.** Quando `AGENTMEMORY_SECRET` non è impostato (nella shell o in `~/.agentmemory/.env`), il server genera un secret casuale al primo avvio e lo memorizza in `~/.agentmemory/secret` con modalità `0600`. Ogni client incluso lo legge da lì quando parla con un server locale: la CLI, il viewer, gli hook sotto `plugin/scripts`, il server MCP e lo shim `@agentmemory/mcp`, le configurazioni scritte da `agentmemory connect`, e le integrazioni incluse OpenCode, Pi, OpenClaw, Hermes e filesystem-watcher. Il secret memorizzato viene inviato solo a URL loopback (`localhost`, `127.0.0.0/8`, `::1`). Un `AGENTMEMORY_SECRET` esplicito vince sempre, e i client remoti devono comunque averlo impostato. Docker e gli entrypoint di `deploy/` generano ed esportano già il proprio secret. Per chiamare l'API a mano: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.ja-JP.md b/READMEs/README.ja-JP.md index cd234b20b..e6160b754 100644 --- a/READMEs/README.ja-JP.md +++ b/READMEs/README.ja-JP.md @@ -71,7 +71,7 @@ 54 MCP ツール 12 自動 hooks 外部 DB 0 個 - 3,500+ 件のテストが成功 + 3,600+ 件のテストが成功

@@ -1445,7 +1445,7 @@ fi | Prometheus / Grafana | iii OTEL + ヘルスモニタ | | カスタムプラグインシステム | `iii worker add ` | -**238 ソースファイル · ~56,000 LOC · 3,500+ テスト · 318 functions · 66 KV スコープ** — すべて 3 つのプリミティブの上に。`agentmemory plugin install` はありません。プラグインシステムは iii そのものです。 +**242 ソースファイル · ~55,000 LOC · 3,400+ テスト · 324 functions · 67 KV スコープ** — すべて 3 つのプリミティブの上に。`agentmemory plugin install` はありません。プラグインシステムは iii そのものです。 --- @@ -1788,7 +1788,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-ポート `3111` 上の 141 エンドポイント。REST API はデフォルトで `127.0.0.1` にバインドします。保護されたエンドポイントは `Authorization: Bearer ` を要求し、mesh sync エンドポイントは両ピアで明示的に設定された `AGENTMEMORY_SECRET` を要求します。 +ポート `3111` 上の 143 エンドポイント。REST API はデフォルトで `127.0.0.1` にバインドします。保護されたエンドポイントは `Authorization: Bearer ` を要求し、mesh sync エンドポイントは両ピアで明示的に設定された `AGENTMEMORY_SECRET` を要求します。 **認証はデフォルトでオンです。** `AGENTMEMORY_SECRET` が(シェルにも `~/.agentmemory/.env` にも)設定されていない場合、サーバーは初回起動時にランダムなシークレットを生成し、モード `0600` で `~/.agentmemory/secret` に保存します。同梱のすべてのクライアント — CLI、ビューワー、`plugin/scripts` 配下の hooks、MCP サーバーと `@agentmemory/mcp` の shim、`agentmemory connect` が書き込む設定、同梱の OpenCode、Pi、OpenClaw、Hermes、ファイルシステムウォッチャーの各統合 — は、ローカルサーバーと話すときにそこから読み取ります。保存されたシークレットは、ループバックの URL(`localhost`、`127.0.0.0/8`、`::1`)にのみ送信されます。明示的な `AGENTMEMORY_SECRET` は常に優先され、リモートクライアントではそれでも設定が必要です。Docker と `deploy/` のエントリポイントは、既に独自のシークレットを生成・export しています。手動で API を呼ぶには: @@ -1869,7 +1869,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.ko-KR.md b/READMEs/README.ko-KR.md index 027dd6a41..2b806c19a 100644 --- a/READMEs/README.ko-KR.md +++ b/READMEs/README.ko-KR.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1446,7 +1446,7 @@ fi | Prometheus / Grafana | iii OTEL + health 모니터 | | 커스텀 플러그인 시스템 | `iii worker add ` | -**소스 파일 238개 · LOC ~56,000줄 · 테스트 3,500+개 · 함수 318개 · KV 스코프 66개**, 모두 세 가지 프리미티브 위에서 동작합니다. `agentmemory plugin install` 같은 것은 없습니다. 플러그인 시스템은 곧 iii 자체입니다. +**소스 파일 242개 · LOC ~55,000줄 · 테스트 3,400+개 · 함수 324개 · KV 스코프 67개**, 모두 세 가지 프리미티브 위에서 동작합니다. `agentmemory plugin install` 같은 것은 없습니다. 플러그인 시스템은 곧 iii 자체입니다. --- @@ -1789,7 +1789,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-포트 `3111`의 엔드포인트 141개. REST API는 기본적으로 `127.0.0.1`에 바인딩됩니다. 보호된 엔드포인트는 `Authorization: Bearer `을 요구하며, mesh sync 엔드포인트는 양쪽 피어 모두에 `AGENTMEMORY_SECRET`이 명시적으로 설정되어 있어야 합니다. +포트 `3111`의 엔드포인트 143개. REST API는 기본적으로 `127.0.0.1`에 바인딩됩니다. 보호된 엔드포인트는 `Authorization: Bearer `을 요구하며, mesh sync 엔드포인트는 양쪽 피어 모두에 `AGENTMEMORY_SECRET`이 명시적으로 설정되어 있어야 합니다. **인증은 기본적으로 켜져 있습니다.** `AGENTMEMORY_SECRET`이(셸이나 `~/.agentmemory/.env`에) 설정되어 있지 않으면, 서버는 첫 시작 시 무작위 시크릿을 생성해 `~/.agentmemory/secret`에 `0600` 권한으로 저장합니다. 번들된 모든 클라이언트는 로컬 서버와 통신할 때 그곳에서 이를 읽습니다: CLI, 뷰어, `plugin/scripts` 아래의 hook, MCP 서버와 `@agentmemory/mcp` shim, `agentmemory connect`가 작성하는 설정, 그리고 번들된 OpenCode, Pi, OpenClaw, Hermes, filesystem-watcher 통합까지입니다. 저장된 시크릿은 loopback URL(`localhost`, `127.0.0.0/8`, `::1`)에만 전송됩니다. 명시적인 `AGENTMEMORY_SECRET`은 항상 우선하며, 원격 클라이언트는 여전히 이를 설정해야 합니다. Docker와 `deploy/` 진입점은 이미 자체 시크릿을 생성하고 export합니다. API를 직접 호출하려면: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.nb-NO.md b/READMEs/README.nb-NO.md index 6a93cb8c9..31e07c3e6 100644 --- a/READMEs/README.nb-NO.md +++ b/READMEs/README.nb-NO.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1447,7 +1447,7 @@ fi | Prometheus / Grafana | iii OTEL + helseovervåker | | Egendefinerte plugin-systemer | `iii worker add ` | -**238 kildefiler · ~56,000 LOC · 3,500+ tester · 318 funksjoner · 66 KV-scopes**, alt på tre primitiver. Ingen `agentmemory plugin install`. Plugin-systemet er iii selv. +**242 kildefiler · ~55,000 LOC · 3,400+ tester · 324 funksjoner · 67 KV-scopes**, alt på tre primitiver. Ingen `agentmemory plugin install`. Plugin-systemet er iii selv. --- @@ -1790,7 +1790,7 @@ Opprett `~/.agentmemory/.env`:

API

-141 endepunkter på port `3111`. REST-API-en bindes til `127.0.0.1` som standard. Beskyttede endepunkter krever `Authorization: Bearer `, og mesh-synkroniseringsendepunkter krever en uttrykkelig satt `AGENTMEMORY_SECRET` på begge sider. +143 endepunkter på port `3111`. REST-API-en bindes til `127.0.0.1` som standard. Beskyttede endepunkter krever `Authorization: Bearer `, og mesh-synkroniseringsendepunkter krever en uttrykkelig satt `AGENTMEMORY_SECRET` på begge sider. **Autentisering er på som standard.** Når `AGENTMEMORY_SECRET` ikke er satt (i skallet eller i `~/.agentmemory/.env`), genererer serveren en tilfeldig hemmelighet ved første oppstart og lagrer den i `~/.agentmemory/secret` med modus `0600`. Hver medfølgende klient leser den fra der når den snakker med en lokal server: CLI-en, viewer, hookene under `plugin/scripts`, MCP-serveren og `@agentmemory/mcp`-shimmen, konfigurasjonene skrevet av `agentmemory connect`, og de medfølgende OpenCode-, Pi-, OpenClaw-, Hermes-, og filsystem-overvåker-integrasjonene. Den lagrede hemmeligheten sendes bare til loopback-URL-er (`localhost`, `127.0.0.0/8`, `::1`). En uttrykkelig `AGENTMEMORY_SECRET` vinner alltid, og eksterne klienter trenger den fortsatt satt. Docker og `deploy/`-entrypointene genererer og eksporterer allerede sin egen hemmelighet. For å kalle API-en manuelt: @@ -1871,7 +1871,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.nl-NL.md b/READMEs/README.nl-NL.md index d2e811041..097782665 100644 --- a/READMEs/README.nl-NL.md +++ b/READMEs/README.nl-NL.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1446,7 +1446,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | Custom plugin-systemen | `iii worker add ` | -**238 bronbestanden · ~56,000 LOC · 3,500+ tests · 318 functies · 66 KV-scopes**, allemaal op drie primitieven. Geen `agentmemory plugin install`. Het pluginsysteem is iii zelf. +**242 bronbestanden · ~55,000 LOC · 3,400+ tests · 324 functies · 67 KV-scopes**, allemaal op drie primitieven. Geen `agentmemory plugin install`. Het pluginsysteem is iii zelf. --- @@ -1789,7 +1789,7 @@ Maak `~/.agentmemory/.env` aan:

API

-141 endpoints op poort `3111`. De REST API bindt standaard aan `127.0.0.1`. Beschermde endpoints vereisen `Authorization: Bearer `, en mesh-sync-endpoints vereisen een expliciet ingestelde `AGENTMEMORY_SECRET` op beide peers. +143 endpoints op poort `3111`. De REST API bindt standaard aan `127.0.0.1`. Beschermde endpoints vereisen `Authorization: Bearer `, en mesh-sync-endpoints vereisen een expliciet ingestelde `AGENTMEMORY_SECRET` op beide peers. **Authenticatie staat standaard aan.** Wanneer `AGENTMEMORY_SECRET` niet is ingesteld (in de shell of in `~/.agentmemory/.env`), genereert de server bij de eerste start een willekeurig secret en bewaart dit in `~/.agentmemory/secret` met modus `0600`. Elke gebundelde client leest dit vandaar wanneer hij met een lokale server praat: de CLI, de viewer, de hooks onder `plugin/scripts`, de MCP-server en de `@agentmemory/mcp`-shim, de configs geschreven door `agentmemory connect`, en de gebundelde OpenCode-, Pi-, OpenClaw-, Hermes- en filesystem-watcher-integraties. Het opgeslagen secret wordt alleen verstuurd naar loopback-URL's (`localhost`, `127.0.0.0/8`, `::1`). Een expliciete `AGENTMEMORY_SECRET` wint altijd, en remote clients moeten deze nog steeds instellen. Docker en de `deploy/`-entrypoints genereren en exporteren al hun eigen secret. Om de API handmatig aan te roepen: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.pl-PL.md b/READMEs/README.pl-PL.md index d0bdff029..2e3255c9e 100644 --- a/READMEs/README.pl-PL.md +++ b/READMEs/README.pl-PL.md @@ -71,7 +71,7 @@ 54 narzędzia MCP 12 automatycznych hooków 0 zewnętrznych baz danych - 3,500+ zaliczonych testów + 3,600+ zaliczonych testów

@@ -1446,7 +1446,7 @@ fi | Prometheus / Grafana | iii OTEL + monitor stanu | | Niestandardowe systemy pluginów | `iii worker add ` | -**238 plików źródłowych · ~56,000 LOC · 3,500+ testów · 318 funkcji · 66 zakresów KV**, wszystko na trzech prymitywach. Nie ma `agentmemory plugin install`. Systemem pluginów jest sam iii. +**242 pliki źródłowe · ~55,000 LOC · 3,600+ testów · 324 funkcje · 67 zakresów KV**, wszystko na trzech prymitywach. Nie ma `agentmemory plugin install`. Systemem pluginów jest sam iii. --- @@ -1789,7 +1789,7 @@ Utwórz `~/.agentmemory/.env`:

API

-141 endpointów na porcie `3111`. REST API domyślnie wiąże się z `127.0.0.1`. Chronione endpointy wymagają `Authorization: Bearer `, a endpointy synchronizacji mesh wymagają wyraźnie ustawionego `AGENTMEMORY_SECRET` na obu peerach. +143 endpointów na porcie `3111`. REST API domyślnie wiąże się z `127.0.0.1`. Chronione endpointy wymagają `Authorization: Bearer `, a endpointy synchronizacji mesh wymagają wyraźnie ustawionego `AGENTMEMORY_SECRET` na obu peerach. **Autoryzacja jest domyślnie włączona.** Gdy `AGENTMEMORY_SECRET` nie jest ustawione (w shellu albo w `~/.agentmemory/.env`), serwer generuje losowy sekret przy pierwszym starcie i zapisuje go w `~/.agentmemory/secret` z uprawnieniami `0600`. Każdy dołączony klient czyta go z tego miejsca, gdy rozmawia z lokalnym serwerem: CLI, podgląd, hooki w `plugin/scripts`, serwer MCP i shim `@agentmemory/mcp`, konfiguracje zapisywane przez `agentmemory connect` oraz dołączone integracje OpenCode, Pi, OpenClaw, Hermes i filesystem-watchera. Zapisany sekret jest wysyłany tylko na adresy loopback (`localhost`, `127.0.0.0/8`, `::1`). Wyraźnie podane `AGENTMEMORY_SECRET` zawsze wygrywa, a zdalni klienci wciąż muszą mieć je ustawione. Docker i punkty wejścia w `deploy/` już generują i eksportują swój własny sekret. Aby wywołać API ręcznie: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.pt-BR.md b/READMEs/README.pt-BR.md index 18c0956b8..7fa780c89 100644 --- a/READMEs/README.pt-BR.md +++ b/READMEs/README.pt-BR.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1447,7 +1447,7 @@ fi | Prometheus / Grafana | iii OTEL + monitor de saúde | | Sistemas de plugin customizados | `iii worker add ` | -**238 arquivos de código · ~56,000 LOC · 3,500+ testes · 318 funções · 66 escopos KV**, tudo em cima de três primitivos. Sem `agentmemory plugin install`. O sistema de plugins é o próprio iii. +**242 arquivos de código · ~55,000 LOC · 3,400+ testes · 324 funções · 67 escopos KV**, tudo em cima de três primitivos. Sem `agentmemory plugin install`. O sistema de plugins é o próprio iii. --- @@ -1790,7 +1790,7 @@ Crie `~/.agentmemory/.env`:

API

-141 endpoints na porta `3111`. A REST API faz bind em `127.0.0.1` por padrão. Endpoints protegidos exigem `Authorization: Bearer `, e endpoints de mesh sync exigem um `AGENTMEMORY_SECRET` explicitamente definido em ambos os peers. +143 endpoints na porta `3111`. A REST API faz bind em `127.0.0.1` por padrão. Endpoints protegidos exigem `Authorization: Bearer `, e endpoints de mesh sync exigem um `AGENTMEMORY_SECRET` explicitamente definido em ambos os peers. **A autenticação vem ativada por padrão.** Quando `AGENTMEMORY_SECRET` não está definido (no shell ou em `~/.agentmemory/.env`), o servidor gera um secret aleatório no primeiro início e o guarda em `~/.agentmemory/secret` com modo `0600`. Todo cliente empacotado o lê de lá quando fala com um servidor local: o CLI, o viewer, os hooks em `plugin/scripts`, o servidor MCP e o shim `@agentmemory/mcp`, as configs escritas por `agentmemory connect`, e as integrações empacotadas de OpenCode, Pi, OpenClaw, Hermes e filesystem-watcher. O secret guardado só é enviado para URLs de loopback (`localhost`, `127.0.0.0/8`, `::1`). Um `AGENTMEMORY_SECRET` explícito sempre prevalece, e clientes remotos ainda precisam dele definido. O Docker e os entrypoints de `deploy/` já geram e exportam o próprio secret. Para chamar a API manualmente: @@ -1871,7 +1871,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.pt-PT.md b/READMEs/README.pt-PT.md index 6fc2a81e7..bd099331e 100644 --- a/READMEs/README.pt-PT.md +++ b/READMEs/README.pt-PT.md @@ -71,7 +71,7 @@ 54 ferramentas MCP 12 hooks automáticos 0 bases de dados externas - 3,500+ testes aprovados + 3,600+ testes aprovados

@@ -1446,7 +1446,7 @@ O `/agentmemory/export` também aceita `?maxSessions=` e `?offset=` para dividir | Prometheus / Grafana | iii OTEL + monitor de saúde | | Sistemas de plugins personalizados | `iii worker add ` | -**238 ficheiros de código-fonte · ~56,000 LOC · 3,500+ testes · 318 funções · 66 âmbitos de KV**, tudo sobre três primitivos. Sem `agentmemory plugin install`. O sistema de plugins é o próprio iii. +**242 ficheiros de código-fonte · ~55,000 LOC · 3,400+ testes · 324 funções · 67 âmbitos de KV**, tudo sobre três primitivos. Sem `agentmemory plugin install`. O sistema de plugins é o próprio iii. --- @@ -1789,7 +1789,7 @@ Crie `~/.agentmemory/.env`:

API

-141 endpoints na porta `3111`. A REST API faz bind em `127.0.0.1` por predefinição. Os endpoints protegidos exigem `Authorization: Bearer `, e os endpoints de sincronização mesh exigem uma `AGENTMEMORY_SECRET` explicitamente definida em ambos os pares. +143 endpoints na porta `3111`. A REST API faz bind em `127.0.0.1` por predefinição. Os endpoints protegidos exigem `Authorization: Bearer `, e os endpoints de sincronização mesh exigem uma `AGENTMEMORY_SECRET` explicitamente definida em ambos os pares. **A autenticação está ativa por predefinição.** Quando `AGENTMEMORY_SECRET` não está definida (na shell ou em `~/.agentmemory/.env`), o servidor gera um segredo aleatório no primeiro arranque e guarda-o em `~/.agentmemory/secret` com o modo `0600`. Todo o cliente incluído lê-o a partir daí quando fala com um servidor local: o CLI, o visualizador, os hooks em `plugin/scripts`, o servidor MCP e o shim `@agentmemory/mcp`, as configurações escritas por `agentmemory connect`, e as integrações incluídas do OpenCode, Pi, OpenClaw, Hermes e do filesystem-watcher. O segredo guardado só é enviado para URLs de loopback (`localhost`, `127.0.0.0/8`, `::1`). Uma `AGENTMEMORY_SECRET` explícita vence sempre, e os clientes remotos continuam a precisar de a ter definida. O Docker e os entrypoints de `deploy/` já geram e exportam o seu próprio segredo. Para chamar a API manualmente: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.ro-RO.md b/READMEs/README.ro-RO.md index c6acda9c0..c41760f0c 100644 --- a/READMEs/README.ro-RO.md +++ b/READMEs/README.ro-RO.md @@ -71,7 +71,7 @@ 54 de instrumente MCP 12 hook-uri automate 0 baze de date externe - 3,500+ teste trecute + 3,600+ teste trecute

@@ -1430,7 +1430,7 @@ fi | Prometheus / Grafana | iii OTEL + monitor de sănătate | | Sisteme de plugin-uri personalizate | `iii worker add ` | -**238 fișiere sursă · ~56,000 LOC · 3,500+ teste · 318 funcții · 66 de domenii KV**, toate pe trei primitive. Niciun `agentmemory plugin install`. Sistemul de plugin-uri este chiar iii. +**242 de fișiere sursă · ~55,000 LOC · 3,600+ de teste · 324 de funcții · 67 de domenii KV**, toate pe trei primitive. Niciun `agentmemory plugin install`. Sistemul de plugin-uri este chiar iii. --- @@ -1773,7 +1773,7 @@ Creează `~/.agentmemory/.env`:

API

-141 de endpoint-uri pe portul `3111`. API-ul REST se leagă implicit la `127.0.0.1`. Endpoint-urile protejate necesită `Authorization: Bearer `, iar endpoint-urile de sincronizare mesh necesită un `AGENTMEMORY_SECRET` setat explicit pe ambii parteneri (peers). +143 de endpoint-uri pe portul `3111`. API-ul REST se leagă implicit la `127.0.0.1`. Endpoint-urile protejate necesită `Authorization: Bearer `, iar endpoint-urile de sincronizare mesh necesită un `AGENTMEMORY_SECRET` setat explicit pe ambii parteneri (peers). **Autentificarea este activată implicit.** Când `AGENTMEMORY_SECRET` nu este setat (în shell sau în `~/.agentmemory/.env`), serverul generează un secret aleatoriu la prima pornire și îl stochează în `~/.agentmemory/secret`, cu modul `0600`. Fiecare client inclus îl citește de acolo, când vorbește cu un server local: CLI-ul, vizualizatorul, hook-urile din `plugin/scripts`, serverul MCP și shim-ul `@agentmemory/mcp`, configurațiile scrise de `agentmemory connect`, și integrările incluse OpenCode, Pi, OpenClaw, Hermes și filesystem-watcher. Secretul stocat este trimis doar către URL-uri loopback (`localhost`, `127.0.0.0/8`, `::1`). Un `AGENTMEMORY_SECRET` explicit are întotdeauna prioritate, iar clienții la distanță au nevoie, în continuare, să îl aibă setat. Docker și entrypoint-urile din `deploy/` își generează și exportă deja propriul secret. Pentru a apela API-ul manual: @@ -1854,7 +1854,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.ru-RU.md b/READMEs/README.ru-RU.md index 9c9641705..0e9423ebb 100644 --- a/READMEs/README.ru-RU.md +++ b/READMEs/README.ru-RU.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1446,7 +1446,7 @@ fi | Prometheus / Grafana | iii OTEL + монитор состояния | | Самописные плагинные системы | `iii worker add ` | -**238 исходных файлов · ~56,000 LOC · 3,500+ тестов · 318 функций · 66 KV-scope**, всё на трёх примитивах. Никакого `agentmemory plugin install`. Плагинная система — это сам iii. +**242 исходных файлов · ~55,000 LOC · 3,400+ тестов · 324 функций · 67 KV-scope**, всё на трёх примитивах. Никакого `agentmemory plugin install`. Плагинная система — это сам iii. --- @@ -1789,7 +1789,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-141 эндпоинтов на порту `3111`. REST API по умолчанию слушает на `127.0.0.1`. Защищённые эндпоинты требуют `Authorization: Bearer `, а эндпоинты mesh-синхронизации требуют явно заданного `AGENTMEMORY_SECRET` на обоих узлах. +143 эндпоинтов на порту `3111`. REST API по умолчанию слушает на `127.0.0.1`. Защищённые эндпоинты требуют `Authorization: Bearer `, а эндпоинты mesh-синхронизации требуют явно заданного `AGENTMEMORY_SECRET` на обоих узлах. **Аутентификация включена по умолчанию.** Когда `AGENTMEMORY_SECRET` не задан (ни в shell, ни в `~/.agentmemory/.env`), сервер генерирует случайный секрет при первом запуске и хранит его в `~/.agentmemory/secret` с правами `0600`. Каждый встроенный клиент читает его оттуда, когда говорит с локальным сервером: CLI, просмотрщик, хуки из `plugin/scripts`, MCP-сервер и shim `@agentmemory/mcp`, конфиги, записанные `agentmemory connect`, и встроенные интеграции OpenCode, Pi, OpenClaw, Hermes и filesystem-watcher. Сохранённый секрет отправляется только на loopback-адреса (`localhost`, `127.0.0.0/8`, `::1`). Явно заданный `AGENTMEMORY_SECRET` всегда выигрывает, и удалённым клиентам он всё равно нужен. Docker и entrypoint'ы `deploy/` уже генерируют и экспортируют собственный секрет. Чтобы вызвать API вручную: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.sv-SE.md b/READMEs/README.sv-SE.md index 90e646f49..fc1104b68 100644 --- a/READMEs/README.sv-SE.md +++ b/READMEs/README.sv-SE.md @@ -71,7 +71,7 @@ 54 MCP-verktyg 12 automatiska hooks 0 externa databaser - 3,500+ godkända tester + 3,600+ godkända tester

@@ -1437,7 +1437,7 @@ fi | Prometheus / Grafana | iii OTEL + hälsoövervakare | | Anpassade pluginsystem | `iii worker add ` | -**238 källkodsfiler · ~56,000 LOC · 3,500+ tester · 318 funktioner · 66 KV-scopes**, allt på tre primitiv. Inget `agentmemory plugin install`. Pluginsystemet är iii självt. +**242 källkodsfiler · ~55,000 LOC · 3,400+ tester · 324 funktioner · 67 KV-scopes**, allt på tre primitiv. Inget `agentmemory plugin install`. Pluginsystemet är iii självt. --- @@ -1780,7 +1780,7 @@ Skapa `~/.agentmemory/.env`:

API

-141 endpoints på port `3111`. REST API:et binder till `127.0.0.1` som standard. Skyddade endpoints kräver `Authorization: Bearer `, och mesh-synk-endpoints kräver en explicit satt `AGENTMEMORY_SECRET` på båda parterna. +143 endpoints på port `3111`. REST API:et binder till `127.0.0.1` som standard. Skyddade endpoints kräver `Authorization: Bearer `, och mesh-synk-endpoints kräver en explicit satt `AGENTMEMORY_SECRET` på båda parterna. **Autentisering är aktiverad som standard.** När `AGENTMEMORY_SECRET` inte är satt (i skalet eller i `~/.agentmemory/.env`), genererar servern en slumpmässig hemlighet vid första start och lagrar den i `~/.agentmemory/secret` med rättigheterna `0600`. Varje medföljande klient läser den därifrån när den talar med en lokal server: CLI:n, viewern, hookarna under `plugin/scripts`, MCP-servern och `@agentmemory/mcp`-shimmen, konfigurationerna skrivna av `agentmemory connect`, och de medföljande integrationerna för OpenCode, Pi, OpenClaw, Hermes och filsystemsbevakaren. Den lagrade hemligheten skickas bara till loopback-URL:er (`localhost`, `127.0.0.0/8`, `::1`). En explicit `AGENTMEMORY_SECRET` vinner alltid, och fjärrklienter måste fortfarande ha den satt. Docker och startpunkterna i `deploy/` genererar och exporterar redan sin egen hemlighet. För att anropa API:et för hand: @@ -1861,7 +1861,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.th-TH.md b/READMEs/README.th-TH.md index 02acd012f..d17b0cd20 100644 --- a/READMEs/README.th-TH.md +++ b/READMEs/README.th-TH.md @@ -71,7 +71,7 @@ 54 เครื่องมือ MCP 12 hook อัตโนมัติ ไม่ต้องพึ่ง external DB เลย - ผ่านการทดสอบ 3,500+ รายการ + ผ่านการทดสอบ 3,600+ รายการ

@@ -1444,7 +1444,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | Custom plugin systems | `iii worker add ` | -**238 source file · ~56,000 LOC · 3,500+ การทดสอบ · 318 function · 66 KV scope** ทั้งหมดอยู่บน primitive สามตัว ไม่มี `agentmemory plugin install` ระบบ plugin ก็คือ iii เองนั่นแหละ +**242 source file · ~55,000 LOC · 3,400+ การทดสอบ · 324 function · 67 KV scope** ทั้งหมดอยู่บน primitive สามตัว ไม่มี `agentmemory plugin install` ระบบ plugin ก็คือ iii เองนั่นแหละ --- @@ -1787,7 +1787,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-141 endpoint บนพอร์ต `3111` REST API จะ bind กับ `127.0.0.1` โดย default endpoint ที่ป้องกันไว้ต้องมี `Authorization: Bearer ` และ endpoint ของ mesh sync ต้องตั้งค่า `AGENTMEMORY_SECRET` ไว้อย่างชัดเจนทั้งสองฝั่ง +143 endpoint บนพอร์ต `3111` REST API จะ bind กับ `127.0.0.1` โดย default endpoint ที่ป้องกันไว้ต้องมี `Authorization: Bearer ` และ endpoint ของ mesh sync ต้องตั้งค่า `AGENTMEMORY_SECRET` ไว้อย่างชัดเจนทั้งสองฝั่ง **Authentication เปิดอยู่โดย default** เมื่อไม่ได้ตั้งค่า `AGENTMEMORY_SECRET` (ทั้งใน shell หรือใน `~/.agentmemory/.env`) server จะสร้าง secret แบบสุ่มตอนเริ่มครั้งแรก และเก็บไว้ที่ `~/.agentmemory/secret` ด้วย mode `0600` client ที่ bundle มาทุกตัวจะอ่านมันจากที่นั่นเมื่อคุยกับ server ในเครื่อง: CLI, viewer, hook ใต้ `plugin/scripts`, MCP server และ shim `@agentmemory/mcp`, config ที่เขียนโดย `agentmemory connect`, และ integration ของ OpenCode, Pi, OpenClaw, Hermes และ filesystem-watcher ที่ bundle มา secret ที่เก็บไว้จะถูกส่งไปที่ loopback URL เท่านั้น (`localhost`, `127.0.0.0/8`, `::1`) `AGENTMEMORY_SECRET` แบบ explicit จะมีสิทธิ์เหนือเสมอ และ client แบบ remote ยังต้องตั้งค่ามันไว้ Docker และ entrypoint ของ [deploy](../deploy/) สร้างและ export secret ของตัวเองไว้แล้ว เรียก API ด้วยมือ: @@ -1868,7 +1868,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.tl-PH.md b/READMEs/README.tl-PH.md index 04984bc45..1ac0b7d08 100644 --- a/READMEs/README.tl-PH.md +++ b/READMEs/README.tl-PH.md @@ -71,7 +71,7 @@ 54 MCP tool 12 awtomatikong hook 0 panlabas na DB - 3,500+ pumapasang test + 3,600+ pumapasang test

@@ -1446,7 +1446,7 @@ Tinatanggap din ng `/agentmemory/export` ang `?maxSessions=` at `?offset=` para | Prometheus / Grafana | iii OTEL + health monitor | | Custom na plugin system | `iii worker add ` | -**238 na source file · ~56,000 LOC · 3,500+ na test · 318 na function · 66 na KV scope**, lahat sa tatlong primitive. Walang `agentmemory plugin install`. Ang plugin system mismo ay ang iii. +**242 na source file · ~55,000 LOC · 3,400+ na test · 324 na function · 67 na KV scope**, lahat sa tatlong primitive. Walang `agentmemory plugin install`. Ang plugin system mismo ay ang iii. --- @@ -1789,7 +1789,7 @@ Gawin ang `~/.agentmemory/.env`:

API

-141 na endpoint sa port `3111`. Nagbibind ang REST API sa `127.0.0.1` bilang default. Kinakailangan ng mga protected endpoint ang `Authorization: Bearer `, at kinakailangan ng mga mesh sync endpoint ang tahasang naka-set na `AGENTMEMORY_SECRET` sa dalawang peer. +143 na endpoint sa port `3111`. Nagbibind ang REST API sa `127.0.0.1` bilang default. Kinakailangan ng mga protected endpoint ang `Authorization: Bearer `, at kinakailangan ng mga mesh sync endpoint ang tahasang naka-set na `AGENTMEMORY_SECRET` sa dalawang peer. **Naka-on ang Authentication bilang default.** Kapag hindi naka-set ang `AGENTMEMORY_SECRET` (sa shell o sa `~/.agentmemory/.env`), gumagawa ang server ng random na secret sa unang start at itinatago ito sa `~/.agentmemory/secret` na may mode `0600`. Babasahin ito mula roon ng bawat bundled na client kapag nakikipag-usap ito sa isang local server: ang CLI, ang viewer, ang hooks sa ilalim ng `plugin/scripts`, ang MCP server at ang `@agentmemory/mcp` shim, ang mga config na isinulat ng `agentmemory connect`, at ang bundled na OpenCode, Pi, OpenClaw, Hermes, at filesystem-watcher integrations. Ipinapadala lamang ang itinagong secret sa mga loopback URL (`localhost`, `127.0.0.0/8`, `::1`). Palaging mananaig ang isang tahasang `AGENTMEMORY_SECRET`, at kailangan pa rin itong naka-set para sa mga remote client. Gumagawa at nag-export na ng sariling secret ang Docker at ang mga entrypoint ng `deploy/`. Para tawagin ang API nang manu-mano: @@ -1870,7 +1870,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.tr-TR.md b/READMEs/README.tr-TR.md index 3ab214410..7fb8e85a9 100644 --- a/READMEs/README.tr-TR.md +++ b/READMEs/README.tr-TR.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1437,7 +1437,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | Custom eklenti sistemleri | `iii worker add ` | -**238 kaynak dosya · ~56,000 LOC · 3,500+ test · 318 fonksiyon · 66 KV scope**, hepsi üç primitif üzerinde. `agentmemory plugin install` yok. Eklenti sistemi, iii'nin kendisidir. +**242 kaynak dosya · ~55,000 LOC · 3,400+ test · 324 fonksiyon · 67 KV scope**, hepsi üç primitif üzerinde. `agentmemory plugin install` yok. Eklenti sistemi, iii'nin kendisidir. --- @@ -1780,7 +1780,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-`3111` portunda 141 endpoint. REST API, varsayılan olarak `127.0.0.1`'e bağlanır. Korumalı endpoint'ler `Authorization: Bearer ` gerektirir ve mesh sync endpoint'leri her iki peer'da da açıkça ayarlanmış bir `AGENTMEMORY_SECRET` gerektirir. +`3111` portunda 143 endpoint. REST API, varsayılan olarak `127.0.0.1`'e bağlanır. Korumalı endpoint'ler `Authorization: Bearer ` gerektirir ve mesh sync endpoint'leri her iki peer'da da açıkça ayarlanmış bir `AGENTMEMORY_SECRET` gerektirir. **Authentication varsayılan olarak açıktır.** `AGENTMEMORY_SECRET` ayarlanmadığında (shell'de veya `~/.agentmemory/.env` içinde), sunucu ilk başlatmada rastgele bir secret üretir ve onu `0600` mod ile `~/.agentmemory/secret`'a saklar. Yerel bir sunucuyla konuştuğunda paketlenmiş her istemci bunu oradan okur: CLI, görüntüleyici, `plugin/scripts` altındaki hook'lar, MCP sunucusu ve `@agentmemory/mcp` shim'i, `agentmemory connect` tarafından yazılan config'ler ve paketlenmiş OpenCode, Pi, OpenClaw, Hermes ve filesystem-watcher entegrasyonları. Saklanan secret, yalnızca loopback URL'lerine (`localhost`, `127.0.0.0/8`, `::1`) gönderilir. Açık bir `AGENTMEMORY_SECRET` her zaman kazanır ve uzak istemcilerin de bunu ayarlamış olması gerekir. Docker ve `deploy/` entrypoint'leri zaten kendi secret'larını üretir ve export eder. API'yi elle çağırmak için: @@ -1861,7 +1861,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.uk-UA.md b/READMEs/README.uk-UA.md index c6dbd1b19..8bc2c90b6 100644 --- a/READMEs/README.uk-UA.md +++ b/READMEs/README.uk-UA.md @@ -71,7 +71,7 @@ 54 інструменти MCP 12 автоматичних хуків 0 зовнішніх БД - 3,500+ тестів пройдено + 3,600+ тестів пройдено

@@ -1447,7 +1447,7 @@ fi | Prometheus / Grafana | iii OTEL + монітор стану здоров'я | | Власні системи плагінів | `iii worker add ` | -**238 вихідних файлів · ~56,000 LOC · 3,500+ тестів · 318 функцій · 66 KV-скоупів**, усе на трьох примітивах. Жодного `agentmemory plugin install`. Система плагінів — це сам iii. +**242 вихідних файлів · ~55,000 LOC · 3,400+ тестів · 324 функцій · 67 KV-скоупів**, усе на трьох примітивах. Жодного `agentmemory plugin install`. Система плагінів — це сам iii. --- @@ -1790,7 +1790,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-141 ендпоінтів на порту `3111`. REST API за замовчуванням прив'язується до `127.0.0.1`. Захищені ендпоінти вимагають `Authorization: Bearer `, а ендпоінти синхронізації mesh вимагають явно встановленого `AGENTMEMORY_SECRET` на обох сторонах. +143 ендпоінтів на порту `3111`. REST API за замовчуванням прив'язується до `127.0.0.1`. Захищені ендпоінти вимагають `Authorization: Bearer `, а ендпоінти синхронізації mesh вимагають явно встановленого `AGENTMEMORY_SECRET` на обох сторонах. **Автентифікація увімкнена за замовчуванням.** Коли `AGENTMEMORY_SECRET` не встановлено (ні в оболонці, ні в `~/.agentmemory/.env`), сервер генерує випадковий секрет під час першого запуску та зберігає його в `~/.agentmemory/secret` з режимом `0600`. Кожен вбудований клієнт зчитує його звідти, коли спілкується з локальним сервером: CLI, переглядач, хуки в `plugin/scripts`, MCP-сервер та шим `@agentmemory/mcp`, конфігурації, записані `agentmemory connect`, а також вбудовані інтеграції OpenCode, Pi, OpenClaw, Hermes та спостерігача за файловою системою. Збережений секрет надсилається лише на loopback-URL (`localhost`, `127.0.0.0/8`, `::1`). Явний `AGENTMEMORY_SECRET` завжди переважає, і віддаленим клієнтам все одно потрібно його встановити. Docker та точки входу `deploy/` уже генерують і експортують власний секрет. Щоб викликати API вручну: @@ -1871,7 +1871,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.ur-PK.md b/READMEs/README.ur-PK.md index 72fac16ee..aa9038881 100644 --- a/READMEs/README.ur-PK.md +++ b/READMEs/README.ur-PK.md @@ -73,7 +73,7 @@ 54 MCP ٹولز 12 خودکار ہکس 0 خارجی ڈیٹابیسز - 3,500+ ٹیسٹس پاس ہو رہے ہیں + 3,600+ ٹیسٹس پاس ہو رہے ہیں

@@ -1432,7 +1432,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | کسٹم plugin سسٹمز | `iii worker add ` | -**238 سورس فائلز · ~56,000 لائنز کوڈ · 3,500+ ٹیسٹس · 318 فنکشنز · 66 KV اسکوپس**، سب تین primitives پر۔ کوئی `agentmemory plugin install` نہیں۔ plugin سسٹم خود iii ہی ہے۔ +**242 سورس فائلز · ~55,000 لائنز کوڈ · 3,600+ ٹیسٹس · 324 فنکشنز · 67 KV اسکوپس**، سب تین primitives پر۔ کوئی `agentmemory plugin install` نہیں۔ plugin سسٹم خود iii ہی ہے۔ --- @@ -1773,7 +1773,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-پورٹ `3111` پر 141 اینڈ پوائنٹس۔ REST API ڈیفالٹ کے طور پر `127.0.0.1` سے bind ہوتا ہے۔ محفوظ اینڈ پوائنٹس کو `Authorization: Bearer ` درکار ہے، اور mesh sync اینڈ پوائنٹس کو دونوں peers پر واضح طور پر سیٹ کیا گیا `AGENTMEMORY_SECRET` درکار ہے۔ +پورٹ `3111` پر 143 اینڈ پوائنٹس۔ REST API ڈیفالٹ کے طور پر `127.0.0.1` سے bind ہوتا ہے۔ محفوظ اینڈ پوائنٹس کو `Authorization: Bearer ` درکار ہے، اور mesh sync اینڈ پوائنٹس کو دونوں peers پر واضح طور پر سیٹ کیا گیا `AGENTMEMORY_SECRET` درکار ہے۔ **Authentication ڈیفالٹ کے طور پر آن ہے۔** جب `AGENTMEMORY_SECRET` سیٹ نہ ہو (shell میں یا `~/.agentmemory/.env` میں)، سرور پہلے اسٹارٹ پر ایک random secret تیار کرتا ہے اور اسے `0600` موڈ کے ساتھ `~/.agentmemory/secret` میں محفوظ کرتا ہے۔ ہر bundled کلائنٹ جب کسی مقامی سرور سے بات کرتا ہے تو اسے وہاں سے پڑھتا ہے: CLI، ویور، `plugin/scripts` کے تحت ہکس، MCP سرور اور `@agentmemory/mcp` shim، `agentmemory connect` کی لکھی گئی configs، اور bundled OpenCode، Pi، OpenClaw، Hermes اور filesystem-watcher integrations۔ محفوظ شدہ secret صرف loopback URLs (`localhost`، `127.0.0.0/8`، `::1`) کو بھیجا جاتا ہے۔ ایک واضح `AGENTMEMORY_SECRET` ہمیشہ جیتتا ہے، اور ریموٹ کلائنٹس کو بھی اسے سیٹ کرنا ضروری ہے۔ Docker اور `deploy/` entrypoints پہلے سے اپنا secret تیار اور export کرتے ہیں۔ API کو دستی طور پر کال کرنے کے لیے: @@ -1854,7 +1854,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.vi-VN.md b/READMEs/README.vi-VN.md index 2c52a0cca..ef7bb14ed 100644 --- a/READMEs/README.vi-VN.md +++ b/READMEs/README.vi-VN.md @@ -71,7 +71,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 3,500+ tests passing + 3,600+ tests passing

@@ -1437,7 +1437,7 @@ fi | Prometheus / Grafana | iii OTEL + health monitor | | Hệ thống plugin tùy chỉnh | `iii worker add ` | -**238 file source · ~56,000 LOC · 3,500+ test · 318 function · 66 KV scope**, tất cả trên ba primitive. Không có `agentmemory plugin install`. Hệ thống plugin chính là iii. +**242 file source · ~55,000 LOC · 3,400+ test · 324 function · 67 KV scope**, tất cả trên ba primitive. Không có `agentmemory plugin install`. Hệ thống plugin chính là iii. --- @@ -1780,7 +1780,7 @@ Tạo `~/.agentmemory/.env`:

API

-141 endpoint trên port `3111`. REST API bind vào `127.0.0.1` theo mặc định. Các endpoint được bảo vệ cần `Authorization: Bearer `, và các endpoint mesh sync cần `AGENTMEMORY_SECRET` được đặt rõ ràng trên cả hai phía. +143 endpoint trên port `3111`. REST API bind vào `127.0.0.1` theo mặc định. Các endpoint được bảo vệ cần `Authorization: Bearer `, và các endpoint mesh sync cần `AGENTMEMORY_SECRET` được đặt rõ ràng trên cả hai phía. **Authentication được bật theo mặc định.** Khi `AGENTMEMORY_SECRET` chưa được đặt (trong shell hay trong `~/.agentmemory/.env`), server sinh một secret ngẫu nhiên ở lần khởi động đầu tiên và lưu nó vào `~/.agentmemory/secret` với mode `0600`. Mọi client đi kèm đọc nó từ đó khi nói chuyện với một server cục bộ: CLI, viewer, các hook dưới `plugin/scripts`, MCP server và shim `@agentmemory/mcp`, các config do `agentmemory connect` viết ra, và các integration OpenCode, Pi, OpenClaw, Hermes và filesystem-watcher đi kèm. Secret đã lưu chỉ được gửi tới các URL loopback (`localhost`, `127.0.0.0/8`, `::1`). Một `AGENTMEMORY_SECRET` rõ ràng luôn thắng, và các client remote vẫn cần đặt nó. Docker và các entrypoint của `deploy/` đã tự sinh và export secret riêng của chúng. Để gọi API bằng tay: @@ -1861,7 +1861,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.zh-CN.md b/READMEs/README.zh-CN.md index 3197fa84d..8a98022c8 100644 --- a/READMEs/README.zh-CN.md +++ b/READMEs/README.zh-CN.md @@ -71,7 +71,7 @@ 54 个 MCP 工具 12 个自动 hooks 0 个外部数据库 - 3,500+ 项测试通过 + 3,600+ 项测试通过

@@ -1444,7 +1444,7 @@ fi | Prometheus / Grafana | iii OTEL + 健康监控 | | Custom plugin systems | `iii worker add ` | -**238 个源文件 · ~56,000 行代码 · 3,500+ 个测试 · 318 个函数 · 66 个 KV 作用域**,全部基于三种原语。没有 `agentmemory plugin install`。插件系统就是 iii 本身。 +**242 个源文件 · ~55,000 行代码 · 3,400+ 个测试 · 324 个函数 · 67 个 KV 作用域**,全部基于三种原语。没有 `agentmemory plugin install`。插件系统就是 iii 本身。 --- @@ -1787,7 +1787,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-端口 `3111` 上有 141 个端点。REST API 默认绑定到 `127.0.0.1`。受保护的端点需要 `Authorization: Bearer `,mesh 同步端点则需要双方都显式设置 `AGENTMEMORY_SECRET`。 +端口 `3111` 上有 143 个端点。REST API 默认绑定到 `127.0.0.1`。受保护的端点需要 `Authorization: Bearer `,mesh 同步端点则需要双方都显式设置 `AGENTMEMORY_SECRET`。 **身份验证默认是开启的。** 当 `AGENTMEMORY_SECRET`(在 shell 或 `~/.agentmemory/.env` 中)没有设置时,服务器会在首次启动时生成一个随机密钥,并以 `0600` 权限存储在 `~/.agentmemory/secret` 中。每个捆绑的客户端在与本地服务器通信时都会从那里读取它:CLI、查看器、`plugin/scripts` 下的 hooks、MCP 服务器和 `@agentmemory/mcp` shim、由 `agentmemory connect` 写入的配置,以及捆绑的 OpenCode、Pi、OpenClaw、Hermes 和文件系统监视集成。存储的密钥只会发送到回环地址(`localhost`、`127.0.0.0/8`、`::1`)。显式设置的 `AGENTMEMORY_SECRET` 始终优先,远程客户端仍然需要设置它。Docker 和 `deploy/` 的 entrypoint 已经会生成并导出自己的密钥。要手动调用 API: @@ -1868,7 +1868,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/READMEs/README.zh-TW.md b/READMEs/README.zh-TW.md index 27e946cb6..4fb78bc4f 100644 --- a/READMEs/README.zh-TW.md +++ b/READMEs/README.zh-TW.md @@ -71,7 +71,7 @@ 54 個 MCP 工具 12 個自動 hooks 0 個外部資料庫 - 3,500+ 測試通過 + 3,600+ 測試通過

@@ -1430,7 +1430,7 @@ fi | Prometheus / Grafana | iii OTEL + 健康監控 | | 自訂外掛系統 | `iii worker add ` | -**238 個原始檔 · ~56,000 行程式碼 · 3,500+ 測試 · 318 個函式 · 66 個 KV 範圍**,全部基於三種原語。沒有 `agentmemory plugin install`。外掛系統就是 iii 本身。 +**242 個原始檔 · ~55,000 行程式碼 · 3,400+ 測試 · 324 個函式 · 67 個 KV 範圍**,全部基於三種原語。沒有 `agentmemory plugin install`。外掛系統就是 iii 本身。 --- @@ -1773,7 +1773,7 @@ GRAPH_EXTRACTION_ENABLED=true

API

-連接埠 `3111` 上有 141 個端點。REST API 預設綁定 `127.0.0.1`。受保護的端點需要 `Authorization: Bearer `,而網狀同步端點要求兩端都明確設定 `AGENTMEMORY_SECRET`。 +連接埠 `3111` 上有 143 個端點。REST API 預設綁定 `127.0.0.1`。受保護的端點需要 `Authorization: Bearer `,而網狀同步端點要求兩端都明確設定 `AGENTMEMORY_SECRET`。 **驗證預設是開啟的。** 當 `AGENTMEMORY_SECRET` 未設定時(無論是在殼層中或 `~/.agentmemory/.env` 中),伺服器會在第一次啟動時產生一個隨機密鑰,並以 `0600` 權限存進 `~/.agentmemory/secret`。每個捆綁的客戶端在與本機伺服器溝通時都會從那裡讀取它:CLI、檢視器、`plugin/scripts` 下的 hooks、MCP 伺服器和 `@agentmemory/mcp` shim、由 `agentmemory connect` 寫入的設定,以及捆綁的 OpenCode、Pi、OpenClaw、Hermes 和檔案系統監看整合。儲存的密鑰只會被送到 loopback 的 URL(`localhost`、`127.0.0.0/8`、`::1`)。明確設定的 `AGENTMEMORY_SECRET` 永遠優先,而遠端客戶端仍需要設定它。Docker 和 `deploy/` 的 entrypoint 已經會自行產生並匯出自己的密鑰。要手動呼叫 API: @@ -1854,7 +1854,7 @@ curl -X POST http://localhost:3111/agentmemory/graph/compact -H "Content-Type: a ```bash npm run dev # Hot reload npm run build # Production build -npm test # 3,500+ tests +npm test # 3,600+ tests npm run test:integration # API tests (requires running services) ``` diff --git a/assets/tags/light/stat-tests.svg b/assets/tags/light/stat-tests.svg index e19f76124..1a99e20ad 100644 --- a/assets/tags/light/stat-tests.svg +++ b/assets/tags/light/stat-tests.svg @@ -1,5 +1,5 @@ - + - 3500+ + 3600+ TESTS PASSING diff --git a/assets/tags/stat-tests.svg b/assets/tags/stat-tests.svg index 610a4dcae..7ca382fa0 100644 --- a/assets/tags/stat-tests.svg +++ b/assets/tags/stat-tests.svg @@ -1,5 +1,5 @@ - + - 3500+ + 3600+ TESTS PASSING diff --git a/docs/reliability/compression-recovery.md b/docs/reliability/compression-recovery.md new file mode 100644 index 000000000..8e5fa1d06 --- /dev/null +++ b/docs/reliability/compression-recovery.md @@ -0,0 +1,37 @@ +# Recovering failed observation compression + +LLM compression is optional. With `AGENTMEMORY_AUTO_COMPRESS=true`, capture first stores a searchable synthetic observation with privacy-filtered source. A provider error, invalid response or interrupted worker leaves that observation available for search and records a pending or failed compression state. Capture acknowledgement means the observation was stored; it does not mean model compression succeeded. + +Inspect a session before retrying: + +```sh +agentmemory compression --session SESSION_ID --json +agentmemory compression --project PROJECT --agent-id AGENT_ID --limit 10 +``` + +Inspection makes no model calls. `--retry` explicitly sends the retained source to the configured model and may incur model usage costs: + +```sh +agentmemory compression --session SESSION_ID --retry --json +``` + +The command uses the running service selected by `AGENTMEMORY_URL`, `--port` or `--instance`, with the same authentication as other CLI requests. It fails if that service is unavailable and does not start a server. Separately, the running worker automatically retries pending compression needed by a persisted [session summary repair](summary-recovery.md), including after restart. + +Choose a session or project; a global unscoped retry is rejected. Combining `--session`, `--project` and `--agent-id` narrows the selection. `--limit` defaults to 10 and accepts 1 through 100. Each page scans at most 20 sessions and 1,000 observations. Use the returned `nextCursor` with `--cursor` and the same scope to continue. A page can contain no candidates and still have a cursor. + +Unusually long imported identifiers use compact cursor bookmarks. If that bookmark's session or observation is removed, or its session moves out of the selected project before the next page, the result reports `cursor_stale`. Restart without `--cursor`; already recovered observations remain skipped. Cursors track scan positions, not a snapshot of which rows match the agent filter. + +Retries run sequentially and stop after the first model failure. A batch stops starting work after 30 seconds; an in-flight attempt can finish within its 60-second deadline, so a batch can take about 90 seconds. Run the command again without a cursor to revisit failed observations. Concurrent requests within one worker for the same observation share the existing attempt; completed observations are skipped on subsequent retries. These locks do not provide distributed coordination between multiple workers writing one state store. + +Legacy raw observations are eligible. A failed attempt preserves their original stored payload and adds retry metadata; unavailable source leaves the row unchanged. They remain available to the recovery workflow across restarts and enter search only after successful compression. This differs from new captures, whose sanitized synthetic fallback is already searchable. Success preserves observation IDs, timestamps and session counts, replacing the legacy raw row with a compressed observation and bounded, privacy-filtered source. Recovery never overwrites an observation deleted or changed during the model call, and checks the scope again before committing. The keyword index updates with the stored result; asynchronous vector results also verify the current observation revision. + +Model input and source retained after success are bounded to 16 KiB per observation and an 8 MiB source allowance per session, subject to transport headroom. `truncated: true` means the model used only the bounded portion of the evidence. Missing source is reported as `source_unavailable` and is never counted as recovered. A legacy raw row blocked by a full source allowance can be retried after space is freed. Recovery cannot reconstruct evidence already omitted from stored source. Default synthetic observations without a pending or failed compression state are left alone. + +The authenticated REST equivalents are: + +- `GET /agentmemory/compression?sessionId=SESSION_ID&limit=10` +- `POST /agentmemory/compression/retry` with `{"sessionId":"SESSION_ID","limit":10}` + +Both accept `project`, `agentId`, `limit` and `cursor`; at least `sessionId` or `project` is required. Results contain identifiers, status, safe error codes and counts. They do not return retained source or raw provider errors. A retry result with `success: false` exits the CLI with status 1. Inspect `recovered`, `items[].outcome` and `items[].retryable` for each batch; source-unavailable or concurrently changed rows are not model successes. + +Responses also obey the engine's 15 MiB payload allowance. If unusually large imported identifiers exceed it, REST returns HTTP 413 with a small error. Lower `--limit` or select one session. Already completed retries are not repeated when you inspect again. diff --git a/docs/reliability/source-and-vector-recovery.md b/docs/reliability/source-and-vector-recovery.md index 4bfb8986f..945011790 100644 --- a/docs/reliability/source-and-vector-recovery.md +++ b/docs/reliability/source-and-vector-recovery.md @@ -10,6 +10,8 @@ Live compression and imports allocate new source from an 8 MiB session budget af These locks coordinate one worker process. They do not provide distributed transactions. JSONL imports commit one file at a time, so a rejected later file can leave earlier files imported. Raising the ordinary capture limit of 500 observations does not raise the retained-source allowance; summary data and temporary raw observations have separate storage and response-size requirements. +Failed opt-in model compression keeps a searchable synthetic observation and retryable status. See [compression recovery](compression-recovery.md) for scoped inspection and explicit retries of failed or legacy raw observations. + Retention adds storage and serialization work. It does not enable model compression, inject extra source into ordinary recall, or add a queue. Explicit replay and export responses can be larger and can consume agent context if an agent requests them. ## First checkpoint and crash window diff --git a/docs/reliability/summary-recovery.md b/docs/reliability/summary-recovery.md new file mode 100644 index 000000000..eb7e02ea6 --- /dev/null +++ b/docs/reliability/summary-recovery.md @@ -0,0 +1,25 @@ +# Session summary recovery + +Capture acknowledgement confirms that an observation was stored. Compression and session summarization can finish later. A completed session can therefore temporarily have a summary that does not include its latest processed observation. + +Session close records durable summary work before dispatching background processing. The worker checks that work at startup and every 30 seconds, including when idle-session finalization is disabled. Recovery retries pending compression through the existing compression functions, then refreshes the summary. Provider failures retain the work for another attempt with bounded backoff. Missing source remains pending because the worker cannot reconstruct evidence that was never retained. + +A new observation delivered after session completion schedules summary repair without requiring another session-end event. With idle finalization enabled, the session reopens until it becomes idle again; when disabled, it stays completed. Replaying an already stored capture ID leaves the observation and summary unchanged. Repair does not repeat graph extraction or consolidation. + +Summary coverage includes the ordered observation contents and session identity. New observations with older or identical timestamps and changes to existing observations invalidate that coverage. Model work runs outside the observation lock; publication checks the source again under the lock. A stale result cannot replace a summary for observations or a session that changed while the model was running. Partial chunk output does not count as complete coverage. + +Older summaries without coverage metadata are rebuilt on their next summarize request. The recovery worker processes recorded repair work; it does not automatically scan all historical sessions. + +Recovery runs at most two sessions concurrently per worker. Completed, unchanged work does not make another model call. These limits and locks coordinate one worker; they are not distributed locks for multiple workers sharing a state store. Recovery can use the configured model and incur its normal usage costs. + +## Packaged runtime check + +```sh +npm run test:summary-recovery:packaged -- --out /tmp/summary-recovery-check +``` + +Use a new or empty output directory. The gate builds and packs the CLI, extracts the archive into an isolated home, and uses cached dependencies and a matching cached native iii engine. Supply `--engine /path/to/iii` when the engine is not in the release-gate cache. `--tarball` checks an existing archive; `--skip-build` packs an already built checkout. + +The gate uses a deterministic model server on loopback. It checks interrupted compression, failed summaries, late replay, duplicate delivery, two daemon-and-engine restarts, and unchanged package contents. Recovery must succeed without another session-end, summarize, or retry request. Receipts include request traces, before/after exports, archive hashes, and port cleanup results. This is a packaged native-runtime test with cached dependencies, not a clean dependency installation or a live-model conversation test. + +The process-loss scenarios allow the engine's ordinary state flush before termination. They verify recovery from persisted work; they do not establish power-loss durability for writes that have not reached storage. diff --git a/package.json b/package.json index e84184641..0242d4fc1 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:integration": "vitest run test/integration.test.ts", "test:all": "vitest run", "test:startup:packaged": "node scripts/release-gate/startup.mjs", + "test:summary-recovery:packaged": "node scripts/release-gate/summary-recovery.mjs", "test:graph-index:packaged": "node scripts/release-gate/graph-index.mjs", "test:skills:packaged": "node scripts/release-gate/skills.mjs", "test:openclaw:packaged": "node scripts/release-gate/openclaw.mjs", diff --git a/plugin/skills/agentmemory-rest-api/REFERENCE.md b/plugin/skills/agentmemory-rest-api/REFERENCE.md index 771917143..ce7f3b436 100644 --- a/plugin/skills/agentmemory-rest-api/REFERENCE.md +++ b/plugin/skills/agentmemory-rest-api/REFERENCE.md @@ -5,7 +5,7 @@ Generated from `src/triggers/api.ts`. Do not edit the block below by hand; run ` The REST API is the primary surface. All paths are under `http://localhost:3111` (override with `--port`). Send `Authorization: Bearer `: the value of `AGENTMEMORY_SECRET`, or the one generated into `~/.agentmemory/secret` on first start when it is unset. Writes must use `Content-Type: application/json`. -141 registered endpoints: +143 registered endpoints: | Method | Path | | --- | --- | @@ -30,6 +30,8 @@ The REST API is the primary surface. All paths are under `http://localhost:3111` | POST | `/agentmemory/claude-bridge/sync` | | GET | `/agentmemory/commits` | | POST | `/agentmemory/compress-file` | +| GET | `/agentmemory/compression` | +| POST | `/agentmemory/compression/retry` | | GET | `/agentmemory/config/flags` | | POST | `/agentmemory/consolidate` | | POST | `/agentmemory/consolidate-pipeline` | diff --git a/scripts/docs-sync.state.json b/scripts/docs-sync.state.json index 9d3685174..5b38ec02a 100644 --- a/scripts/docs-sync.state.json +++ b/scripts/docs-sync.state.json @@ -11,7 +11,7 @@ "7" ], "restEndpoints": [ - "141" + "143" ], "skills": [ "17" @@ -20,19 +20,19 @@ "12" ], "tests": [ - "3500+" + "3600+" ], "functions": [ - "318" + "324" ], "sourceFiles": [ - "238" + "242" ], "loc": [ "56000" ], "kvScopes": [ - "66" + "67" ] } } diff --git a/scripts/docs-sync.ts b/scripts/docs-sync.ts index 1bf67ae18..19d4b810d 100644 --- a/scripts/docs-sync.ts +++ b/scripts/docs-sync.ts @@ -140,7 +140,7 @@ const FACTS: Fact[] = [ return n === null ? null : `${floorTo(n, 100)}+`; }, nouns: [ - "tests passing", "tests", "Tests", "test", "个测试", "個測試", "testes", "pruebas", "тест", "テスト", "测试", "測試", "테스트", "bestandene Tests", + "tests passing", "tests", "Tests", "test", "de teste", "个测试", "個測試", "testes", "pruebas", "тест", "テスト", "测试", "測試", "테스트", "bestandene Tests", "beståede test", "godkända test", "zaliczonych test", "επιτυχημένες δοκιμ", "δοκιμ", "sikeres teszt", "teszt", "pengujian", "件のテスト", "개", "pumapasang test", "na test", "รายการ", "การทดสอบ", "בדיקות", "اختبار", "ٹیسٹس", "টেস্ট", "项测试", "項測試", "testů", "testiä", ], @@ -153,7 +153,7 @@ const FACTS: Fact[] = [ nouns: [ "iii functions", "functions", "funciones", "funções", "fonctions", "Funktionen", "функци", "個函式", "个函数", "개 함수", "개의 함수", "fonksiyon", "funkc", "funktion", "funksjon", "funktio", "functie", "funcții", "funzioni", "fungsi", "function", "개", "פונקצי", "دالة", "فنکشن", "ফাংশন", - "na function", "функц", + "na function", "функц", "de funcții", ], }, { @@ -165,7 +165,7 @@ const FACTS: Fact[] = [ "fichiers source", "Quelldateien", "исходных файл", "個原始檔", "个源文件", "개 소스 파일", "개의 소스 파일", "ソースファイル", "kaynak dosya", "zdrojových soubor", "kildefiler", "αρχεία πηγαίου", "lähdetiedosto", "forrásfájl", "file sorgente", "bronbestand", "plików źródłowych", "ficheiros de código", "fișiere sursă", "källkodsfiler", "source file", "na source file", "file source", "вихідних файл", "file sumber", "קבצי מקור", "ملف مصدري", - "سورس فائل", "সোর্স ফাইল", "개", + "سورس فائل", "সোর্স ফাইল", "개", "pliki źródłowe", "de fișiere sursă", ], }, { diff --git a/scripts/release-gate/summary-recovery.mjs b/scripts/release-gate/summary-recovery.mjs new file mode 100644 index 000000000..42072a181 --- /dev/null +++ b/scripts/release-gate/summary-recovery.mjs @@ -0,0 +1,327 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import { arch, homedir, platform, tmpdir } from "node:os"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { assertCommandCompleted, assertSupportedPlatform, killProcess, runCommand } from "./command.mjs"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const options = { tarball: null, out: null, dependencies: join(repo, "node_modules"), engine: null, skipBuild: false, keep: false }; +for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (["--tarball", "--out", "--dependencies", "--engine"].includes(arg)) { + assert(process.argv[i + 1] && !process.argv[i + 1].startsWith("--"), `${arg} requires a path`); + options[arg.slice(2)] = resolve(process.argv[++i]); + } else if (arg === "--skip-build") options.skipBuild = true; + else if (arg === "--keep") options.keep = true; + else if (arg === "--help") { + console.log("Usage: node scripts/release-gate/summary-recovery.mjs [--tarball file.tgz] [--out empty-dir] [--dependencies node_modules] [--engine iii] [--skip-build] [--keep]\nTests a freshly extracted npm archive with cached dependencies and a loopback model fixture. No dependency installation or billable model calls."); + process.exit(0); + } else throw new Error(`Unknown argument ${arg}`); +} +assertSupportedPlatform(); +if (options.out && existsSync(options.out)) assert.equal(readdirSync(options.out).length, 0, "--out must be empty; existing evidence is never overwritten"); +const out = options.out ?? mkdtempSync(join(tmpdir(), "agentmemory-summary-gate-")); +mkdirSync(out, { recursive: true }); +const root = mkdtempSync(join(tmpdir(), "am-summary-")); +const home = join(root, "home"), runtime = join(home, ".agentmemory"), dataDir = join(root, "data"); +const modules = join(root, "runtime/node_modules"), pkgDir = join(modules, "@agentmemory/agentmemory"); +const cli = join(pkgDir, "dist/cli.mjs"); +for (const directory of [home, runtime, dataDir, pkgDir]) mkdirSync(directory, { recursive: true }); +const children = new Set(), daemons = new Set(), ownedPids = new Set(); +const artifact = {}, results = [], modelRequests = [], wire = []; +const began = Date.now(), sessionId = "summary-recovery-session", project = "summary-recovery-project"; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const sha = (file) => createHash("sha256").update(readFileSync(file)).digest("hex"); +let port, modelPort, modelServer, secret, installed, packageBefore, boot = 0; +let heldCompression = null, failSummary = false, phase = "setup"; +const markers = { BASE: "RECOVERED_BASE_COBALT", FINAL: "RECOVERED_FINAL_JUNIPER", LATE: "RECOVERED_LATE_QUARTZ" }; +function log(message) { + const line = `[${((Date.now() - began) / 1000).toFixed(1)}s] ${message}`; + console.log(line); appendFileSync(join(out, "gate.log"), `${line}\n`); +} +function packageManifest() { + const entries = []; + function walk(directory, relative = "") { + for (const item of readdirSync(directory, { withFileTypes: true })) { + const name = relative ? `${relative}/${item.name}` : item.name; + if (item.isDirectory()) walk(join(directory, item.name), name); + else if (item.isFile()) entries.push([name, sha(join(directory, item.name))]); + } + } + walk(pkgDir); return entries.sort(([a], [b]) => a.localeCompare(b)); +} +function cleanEnv() { + return { + HOME: home, USERPROFILE: home, PATH: [dirname(process.execPath), "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(delimiter), + TMPDIR: tmpdir(), CI: "1", NO_COLOR: "1", LANG: "C.UTF-8", III_TELEMETRY_ENABLED: "false", + AGENTMEMORY_DATA_DIR: dataDir, AGENTMEMORY_RUNTIME_DIR: runtime, + III_REST_PORT: String(port), III_STREAM_PORT: String(port + 1), III_VIEWER_PORT: String(port + 2), + III_ENGINE_PORT: String(port + 46023), AGENTMEMORY_METRICS_PORT: String(port + 6353), AGENTMEMORY_URL: `http://127.0.0.1:${port}`, + EMBEDDING_PROVIDER: "none", AGENTMEMORY_AUTO_COMPRESS: "true", AGENTMEMORY_FINALIZE_IDLE_MS: "0", + AGENTMEMORY_SESSION_SWEEP_ENABLED: "false", GRAPH_EXTRACTION_ENABLED: "false", CONSOLIDATION_ENABLED: "false", AGENTMEMORY_REFLECT: "false", + OPENAI_API_KEY: "summary-gate-synthetic-key", OPENAI_BASE_URL: `http://127.0.0.1:${modelPort}`, + OPENAI_MODEL: "summary-recovery-fixture", OPENAI_TIMEOUT_MS: "60000", + }; +} +async function command(executable, args, { name = "command", ...settings } = {}) { + const result = await runCommand(executable, args, { env: cleanEnv(), cwd: root, children, ...settings }); + appendFileSync(join(out, `${name}.log`), result.output); + assertCommandCompleted(result); + assert.equal(result.code, 0, `${name}: ${result.output.slice(-3000)}`); + return result.output; +} +async function poll(label, probe, timeoutMs = 30_000) { + const until = Date.now() + timeoutMs; + let last; + do { + try { const result = await probe(); if (result) return result; } catch (error) { last = error; } + await sleep(200); + } while (Date.now() < until); + throw new Error(`${label} timed out after ${timeoutMs}ms: ${last?.message ?? "condition not met"}`); +} +function portFree(value) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(value, "127.0.0.1", () => server.close(() => resolve(true))); + }); +} +const daemonPorts = () => port ? [port, port + 1, port + 2, port + 3, port + 6353, port + 46023] : []; +async function pickPorts() { + for (let attempt = 0; attempt < 200; attempt++) { + port = 12000 + Math.floor(Math.random() * 7000); + if ((await Promise.all(daemonPorts().map(portFree))).every(Boolean)) return; + } + port = undefined; throw new Error("No free native engine port block"); +} +async function preparePackage() { + const dependencyRoot = realpathSync(options.dependencies); + artifact.dependencies = { mode: "cached-symlinks", root: dependencyRoot, freshInstall: false }; + let tarball = options.tarball; + if (!tarball) { + const npmEnv = { ...process.env, npm_config_cache: join(root, "npm-cache") }; + if (!options.skipBuild) await command("npm", ["run", "build"], { cwd: repo, env: npmEnv, name: "build", timeoutMs: 600_000 }); + const packDir = join(out, "pack"); mkdirSync(packDir); + await command("npm", ["pack", "--json", "--pack-destination", packDir], { cwd: repo, env: npmEnv, name: "pack" }); + const archives = readdirSync(packDir).filter((name) => name.endsWith(".tgz")); + assert.equal(archives.length, 1, "npm pack must produce exactly one archive in the fresh output directory"); + tarball = join(packDir, archives[0]); + } + artifact.tarball = tarball; artifact.tarballSha256 = sha(tarball); + const names = (await command("tar", ["-tzf", tarball], { name: "archive-list" })).trim().split("\n"); + assert(names.every((name) => name.startsWith("package/") && !name.split("/").includes("..")), "Unexpected archive layout"); + await command("tar", ["-xzf", tarball, "--strip-components=1", "-C", pkgDir], { name: "extract" }); + for (const entry of readdirSync(dependencyRoot, { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + if (entry.name.startsWith("@")) { + const target = join(modules, entry.name); mkdirSync(target, { recursive: true }); + for (const name of readdirSync(join(dependencyRoot, entry.name))) { + if (entry.name === "@agentmemory" && name === "agentmemory") continue; + symlinkSync(join(dependencyRoot, entry.name, name), join(target, name), "dir"); + } + } else symlinkSync(join(dependencyRoot, entry.name), join(modules, entry.name), "dir"); + } + installed = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")); + artifact.dependencies.iiiSdkVersion = JSON.parse(readFileSync(join(modules, "iii-sdk/package.json"), "utf8")).version; + assert.equal(artifact.dependencies.iiiSdkVersion, installed.dependencies["iii-sdk"], "Cached iii-sdk must match the packed dependency"); + artifact.version = installed.version; artifact.cliSha256 = sha(cli); artifact.workerSha256 = sha(join(pkgDir, "dist/index.mjs")); + packageBefore = packageManifest(); + writeFileSync(join(out, "package-manifest.json"), JSON.stringify(packageBefore, null, 2) + "\n"); + const cache = process.env.AGENTMEMORY_GATE_CACHE ?? join(homedir(), ".cache/agentmemory-release-gate"); + const engine = options.engine ?? process.env.AGENTMEMORY_GATE_ENGINE ?? join(cache, `iii-${installed.dependencies["iii-sdk"]}-${platform()}-${arch()}`); + assert(existsSync(engine), "Cached native engine missing; use --engine or AGENTMEMORY_GATE_ENGINE"); + const version = (await command(engine, ["--version"], { name: "engine-version" })).trim(); + assert(version.includes(installed.dependencies["iii-sdk"]), `Engine mismatch: ${version}`); + artifact.engine = { version, sha256: sha(engine) }; + mkdirSync(join(runtime, "bin")); copyFileSync(engine, join(runtime, "bin/iii")); chmodSync(join(runtime, "bin/iii"), 0o755); +} +async function startModel() { + modelServer = http.createServer(async (request, response) => { + try { + let raw = ""; for await (const chunk of request) raw += chunk; + assert.equal(request.method, "POST"); assert.equal(request.url, "/v1/chat/completions"); + const body = JSON.parse(raw); + const system = body.messages.find((message) => message.role === "system")?.content ?? ""; + const prompt = body.messages.find((message) => message.role === "user")?.content ?? ""; + const compress = system.includes("memory compression engine"); + const summarize = system.includes("session summarizer") || system.includes("partial summaries"); + assert(compress || summarize, `Unexpected model operation: ${system.slice(0, 100)}`); + const item = { phase, boot, operation: compress ? "compression" : "summary", prompt }; + modelRequests.push(item); + let content; + if (compress) { + const token = prompt.match(/GATE_INPUT_(BASE|FINAL|LATE)\b/)?.[1]; + assert(token, "Compression request lost the retained raw fixture"); + item.token = token; + if (heldCompression === token) { item.outcome = "held-until-process-termination"; return; } + const marker = markers[token]; + content = `decision${marker}The verified result is ${marker}.The local fixture completed processing and verified ${marker} for the recovery scenario.recoveryfixture.txt8`; + } else if (failSummary) { + item.outcome = "malformed-summary"; content = "Fixture intentionally returns no summary XML."; + } else { + const present = Object.values(markers).filter((marker) => prompt.includes(marker)); + const narrative = present.length > 0 + ? `The completed session retained these verified outcomes: ${present.join(", ")}. Each outcome came from a processed observation.` + : "This observation is still represented by its captured tool output. No completed fixture outcome is available yet."; + content = `Verified recovery results${narrative}Retain all verified outcomes across recovery.fixture.txtrecovery`; + } + item.response = content; item.outcome ??= "valid"; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ choices: [{ message: { role: "assistant", content } }] })); + } catch (error) { + modelRequests.push({ phase, boot, error: error.message }); + response.writeHead(500, { "content-type": "application/json" }); response.end(JSON.stringify({ error: error.message })); + } + }); + await new Promise((resolve, reject) => { modelServer.once("error", reject); modelServer.listen(0, "127.0.0.1", resolve); }); + modelPort = modelServer.address().port; +} +async function rest(path, body, { auth = true, record = true } = {}) { + const headers = { "content-type": "application/json" }; + if (auth) headers.authorization = `Bearer ${secret}`; + const response = await fetch(`http://127.0.0.1:${port}/agentmemory${path}`, { method: body === undefined ? "GET" : "POST", headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10_000) }); + const text = await response.text(); + assert(response.status >= 200 && response.status < 300, `${path}: ${response.status} ${text.slice(0, 500)}`); + const json = JSON.parse(text); + if (record) wire.push({ phase, boot, path, status: response.status, request: body, response: json }); + return json; +} +function rememberPids() { + for (const name of ["worker", "iii"]) { + try { + const pid = Number(readFileSync(join(runtime, `${name}.pid`), "utf8").trim()); + if (Number.isSafeInteger(pid) && pid > 1 && pid !== process.pid) ownedPids.add(pid); + } catch {} + } +} +async function startDaemon() { + boot++; + const child = spawn(process.execPath, [cli, "--port", String(port), "--data-dir", dataDir], { env: cleanEnv(), cwd: root, detached: true, stdio: ["ignore", "pipe", "pipe"] }); + assert(child.pid, "CLI did not spawn"); daemons.add(child); ownedPids.add(child.pid); + const record = (chunk) => appendFileSync(join(out, `daemon-${boot}.log`), chunk); + child.stdout.on("data", record); child.stderr.on("data", record); + child.once("error", (error) => record(String(error))); child.once("close", () => daemons.delete(child)); + await poll("packaged native daemon startup", async () => { + const live = await rest("/livez", undefined, { auth: false, record: false }); + return live.viewerPort || live.viewerSkipped; + }, 90_000); + secret = readFileSync(join(runtime, "secret"), "utf8").trim(); assert(secret); + rememberPids(); log(`Native CLI boot ${boot} ready`); +} +async function crashDaemon() { + // iii FileKV flushes asynchronously; allow its ordinary flush before simulating process loss. + await sleep(5500); rememberPids(); + for (const pid of ownedPids) killProcess(pid); + await poll("crashed daemon ports closed", async () => (await Promise.all(daemonPorts().map(portFree))).every(Boolean), 15_000); + modelServer.closeAllConnections(); ownedPids.clear(); log(`Native CLI boot ${boot} terminated`); +} +function event(token) { + return { hookType: "post_tool_use", sessionId, project, cwd: root, timestamp: new Date().toISOString(), eventId: `summary-gate-${token.toLowerCase()}`, data: { tool_name: "Bash", tool_input: { command: `printf GATE_INPUT_${token}` }, tool_response: `GATE_INPUT_${token}: tool completed` } }; +} +async function snapshot(name) { + const data = await rest("/export", undefined, { record: false }); + writeFileSync(join(out, `${name}.json`), JSON.stringify(data, null, 2) + "\n"); return data; +} +const observations = (data) => data.observations?.[sessionId] ?? []; +const summaryFor = (data) => data.summaries?.find((summary) => summary.sessionId === sessionId); +async function waitSummary(expected) { + return poll(`summary contains ${expected.join(", ")}`, async () => { + const data = await rest("/export", undefined, { record: false }); + const summary = summaryFor(data); + return summary && expected.every((marker) => summary.narrative.includes(marker)) ? data : false; + }, 125_000); +} +async function scenario(name, run) { + phase = name; log(`RUN ${name}`); const start = Date.now(); + try { await run(); results.push({ name, status: "pass", ms: Date.now() - start }); log(`PASS ${name}`); } + catch (error) { results.push({ name, status: "fail", ms: Date.now() - start, error: error.stack }); throw error; } +} +async function main() { + await pickPorts(); await startModel(); await preparePackage(); await startDaemon(); + const base = event("BASE"), final = event("FINAL"), late = event("LATE"); + await scenario("pending-compression-and-summary-survive-process-loss", async () => { + await rest("/session/start", { sessionId, project, cwd: root }); await rest("/observe", base); + await poll("baseline compression", async () => observations(await rest("/export", undefined, { record: false })).some((row) => row.title === markers.BASE && row.compression?.status === "complete")); + assert.equal((await rest("/summarize", { sessionId })).success, true); + await waitSummary([markers.BASE]); heldCompression = "FINAL"; + await rest("/observe", final); + await poll("final provider request held", () => modelRequests.some((request) => request.token === "FINAL" && request.outcome === "held-until-process-termination")); + assert.equal((await rest("/session/end", { sessionId, final: true })).ended, true); + const before = await snapshot("before-compression-crash"); + assert.equal(before.sessions.find((session) => session.id === sessionId)?.status, "completed"); + const pending = observations(before).find((row) => row.compression?.status === "pending"); + assert(pending && JSON.stringify(pending.source).includes("GATE_INPUT_FINAL"), "Pending compression lost retained raw source"); + assert(!summaryFor(before)?.narrative.includes(markers.FINAL), "Final marker must require the blocked provider result"); + await crashDaemon(); heldCompression = null; await startDaemon(); + await waitSummary([markers.BASE, markers.FINAL]); + const after = await snapshot("after-compression-restart"); + assert.equal(observations(after).length, 2); assert.equal(summaryFor(after).observationCount, 2); + assert(observations(after).every((row) => row.compression?.status === "complete")); + }); + await scenario("late-replay-and-summary-failure-recover-without-another-end", async () => { + failSummary = true; await rest("/observe", late); + await poll("late compression", async () => observations(await rest("/export", undefined, { record: false })).some((row) => row.title === markers.LATE && row.compression?.status === "complete")); + await poll("summary provider failure", () => modelRequests.some((request) => request.phase === phase && request.outcome === "malformed-summary"), 65_000); + await rest("/observe", late); await rest("/observe", final); + const before = await snapshot("before-summary-crash"); + assert.equal(observations(before).length, 3); assert(!summaryFor(before)?.narrative.includes(markers.LATE)); + await crashDaemon(); failSummary = false; await startDaemon(); + await waitSummary(Object.values(markers)); + const after = await snapshot("after-summary-restart"); + assert.equal(after.sessions.find((session) => session.id === sessionId)?.status, "completed"); + assert.equal(observations(after).length, 3); assert.equal(summaryFor(after).observationCount, 3); + }); + await scenario("stable-replay-after-restart-is-idempotent", async () => { + const before = await snapshot("before-duplicate-replay"); + const modelCallsBefore = modelRequests.length; + const replays = await Promise.all([rest("/observe", base), rest("/observe", final), rest("/observe", late), rest("/observe", late)]); + assert(replays.every((replay) => replay.status === "duplicate"), "Stable capture IDs must replay as duplicates"); + // Cross an ordinary 30-second recovery tick to catch needless re-summarization. + await sleep(35_000); + const after = await snapshot("after-duplicate-replay"); + assert.equal(observations(after).length, 3); + assert.deepEqual(observations(after).map((row) => row.id).sort(), observations(before).map((row) => row.id).sort()); + assert.deepEqual(summaryFor(after), summaryFor(before)); + assert.equal(modelRequests.length, modelCallsBefore, "Duplicate replays must not schedule more model work"); + assert.equal(wire.filter((entry) => entry.path === "/session/end").length, 1, "Recovery must not depend on a repeated session end"); + assert.equal(wire.filter((entry) => entry.path === "/summarize").length, 1, "Only the initial baseline may explicitly summarize"); + assert(!wire.some((entry) => /retry|repair/.test(entry.path)), "Recovery must use the autonomous runtime path"); + assert(!modelRequests.some((entry) => entry.error), "The fixture rejected an unexpected model request"); + }); + await scenario("installed-package-unchanged", async () => { + assert.deepEqual(packageManifest(), packageBefore); assert.equal(sha(artifact.tarball), artifact.tarballSha256); + }); +} +function killAll() { + rememberPids(); for (const pid of ownedPids) killProcess(pid); for (const pid of children) killProcess(pid); + for (const child of daemons) { killProcess(child.pid); child.stdout.destroy(); child.stderr.destroy(); } + modelServer?.closeAllConnections(); +} +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(signal, () => { killAll(); process.exit(1); }); +process.once("exit", killAll); +try { await main(); } +catch (error) { + if (!results.some((result) => result.status === "fail")) results.push({ name: "setup", status: "fail", error: error.stack }); + log(`FAIL: ${error.message}`); + if (boot && secret) await snapshot("failure-state").catch(() => {}); +} +finally { + if (boot) await command(process.execPath, [cli, "stop", "--port", String(port)], { name: "stop", timeoutMs: 20_000 }).catch((error) => results.push({ name: "graceful-stop", status: "fail", error: error.message })); + killAll(); + if (modelServer?.listening) await new Promise((resolve) => modelServer.close(resolve)); + await poll("final port cleanup", async () => (await Promise.all([...daemonPorts(), modelPort].filter(Boolean).map(portFree))).every(Boolean), 15_000).catch((error) => results.push({ name: "cleanup", status: "fail", error: error.message })); + const ports = await Promise.all([...daemonPorts(), modelPort].filter(Boolean).map(async (value) => ({ port: value, closed: await portFree(value) }))); + const passed = results.length > 0 && results.every((result) => result.status === "pass") && ports.every((entry) => entry.closed); + writeFileSync(join(out, "model-requests.json"), JSON.stringify(modelRequests, null, 2) + "\n"); + writeFileSync(join(out, "wire.json"), JSON.stringify(wire, null, 2) + "\n"); + writeFileSync(join(out, "summary.json"), JSON.stringify({ passed, artifact, node: process.version, platform: `${platform()}-${arch()}`, scope: "Fresh npm archive, cached dependency symlinks, native iii engine, deterministic loopback model. No live model or clean dependency-install claim.", results, ports, boots: boot, modelRequests: modelRequests.length, runtimeRetained: options.keep ? root : null }, null, 2) + "\n"); + process.removeListener("exit", killAll); + if (!options.keep) rmSync(root, { recursive: true, force: true }); + log(`${passed ? "PASS" : "FAIL"}: receipts ${out}`); if (!passed) process.exitCode = 1; +} diff --git a/src/cli.ts b/src/cli.ts index 356625a1c..dd0f843bc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -105,6 +105,7 @@ const ALL_TOOLS_COUNT = getAllTools().length; const CORE_TOOLS_COUNT = getAllTools().filter((t) => ESSENTIAL_TOOLS.has(t.name)).length; import { resolveDataDir } from "./cli-data-dir.js"; import { runCaptureCommand } from "./cli/capture.js"; +import { runCompressionCommand } from "./cli/compression.js"; import { runGraphIndexCommand } from "./cli/graph-index.js"; import { bearerHeaders, resolveClientSecret } from "./secret-store.js"; @@ -240,6 +241,10 @@ Commands: capture [--drain] Show capture health: the local offline spool and the server inbox (pending, retrying, dead letters). --drain sends spooled observations now. --json for machine output. + compression Inspect pending or failed observation compression. + --session or --project selects the scope. + --agent-id narrows it; --limit bounds the batch. + --retry explicitly retries compression; --json for machine output. graph-index Inspect bounded graph index status. --json for machine output. migrate --allow-legacy-scan explicitly scans legacy graph data. resume continues a sealed migration without another full scan. @@ -306,7 +311,7 @@ if (toolsIdx !== -1 && args[toolsIdx + 1]) { process.env["AGENTMEMORY_TOOLS"] = toolsMode; } -const URL_CLIENT_COMMANDS = new Set(["status", "doctor", "mcp", "capture", "graph-index"]); +const URL_CLIENT_COMMANDS = new Set(["status", "doctor", "mcp", "capture", "compression", "graph-index"]); let hasExplicitLocalPortOverride = false; let selectedInstance = 0; @@ -4234,6 +4239,16 @@ async function runCapture(): Promise { process.exit(code); } +async function runCompression(): Promise { + const base = getBaseUrl(); + const code = await runCompressionCommand({ + base, + args: args.slice(1), + secret: resolveClientSecret(base), + }); + process.exit(code); +} + async function runGraphIndex(): Promise { const base = getBaseUrl(); process.exit(await runGraphIndexCommand({ base, args: args.slice(1), secret: resolveClientSecret(base) })); @@ -4252,6 +4267,7 @@ const commands: Record Promise> = { mcp: runMcp, "import-jsonl": runImportJsonl, capture: runCapture, + compression: runCompression, "graph-index": runGraphIndex, }; diff --git a/src/cli/compression.ts b/src/cli/compression.ts new file mode 100644 index 000000000..7fdb2e7e1 --- /dev/null +++ b/src/cli/compression.ts @@ -0,0 +1,59 @@ +import { parseCompressionScope } from "../functions/compression-recovery.js"; + +export async function runCompressionCommand(options: { + base: string; args: string[]; secret?: string; log?: (line: string) => void; +}): Promise { + const log = options.log ?? console.log; + const json = options.args.includes("--json"); + try { + const input: Record = {}; + let retry = false; + let dryRun = false; + const valueFlags: Record = { "--session": "sessionId", "--project": "project", "--agent-id": "agentId", "--limit": "limit", "--cursor": "cursor" }; + for (let i = 0; i < options.args.length; i++) { + const flag = options.args[i]; + if (flag === "--json") continue; + if (flag === "--retry") { retry = true; continue; } + if (flag === "--dry-run") { dryRun = true; continue; } + if (flag.startsWith("--port=") || flag.startsWith("--instance=")) { + if (!flag.slice(flag.indexOf("=") + 1)) throw new Error(`Missing value for ${flag.split("=")[0]}`); + continue; + } + if (flag === "--port" || flag === "--instance") { + if (!options.args[++i] || options.args[i].startsWith("--")) throw new Error(`Missing value for ${flag}`); + continue; + } + const name = valueFlags[flag]; + if (!name) throw new Error(`Unknown compression option: ${flag}`); + if (input[name] !== undefined) throw new Error(`Duplicate option: ${flag}`); + const value = options.args[++i]; + if (value === undefined || value.startsWith("--")) throw new Error(`Missing value for ${flag}`); + input[name] = value; + } + if (retry && dryRun) throw new Error("Use either --retry or --dry-run"); + const scope = parseCompressionScope(input); + const headers: Record = { "Content-Type": "application/json" }; + if (options.secret) headers.Authorization = `Bearer ${options.secret}`; + const query = new URLSearchParams(Object.entries(scope).filter(([, value]) => value !== undefined).map(([key, value]) => [key, String(value)])); + const response = await fetch(`${options.base}/agentmemory/compression${retry ? "/retry" : `?${query}`}`, { + method: retry ? "POST" : "GET", headers, ...(retry ? { body: JSON.stringify(scope) } : {}), + signal: AbortSignal.timeout(retry ? 120_000 : 10_000), + }); + if (!response.ok) throw new Error(`Compression endpoint returned HTTP ${response.status}`); + const result = await response.json() as { success?: boolean; error?: string; dryRun?: boolean; items?: Array<{ observationId: string; sessionId: string; status: string; retryable: boolean; error?: string; outcome?: string }>; attempted?: number; recovered?: number; failed?: number; nextCursor?: string }; + if (typeof result.success !== "boolean" || !Array.isArray(result.items)) throw new Error("Invalid compression response"); + if (json) log(JSON.stringify(result, null, 2)); + else { + log(retry ? `Compression: ${result.recovered ?? 0} recovered, ${result.failed ?? 0} failed, ${result.attempted ?? 0} attempted` : `Compression: ${result.items.length} pending or failed observations (inspection only)`); + if (result.error === "cursor_stale") log("Compression cursor changed. Restart without --cursor."); + for (const item of result.items) log(`${item.sessionId}/${item.observationId}: ${item.outcome ?? item.status}${item.error ? ` (${item.error})` : ""}${item.error === "source_unavailable" ? " [source unavailable]" : ""}`); + if (result.nextCursor) log(`Continue with --cursor ${result.nextCursor}`); + if (!retry && result.items.some((item) => item.retryable)) log("Use --retry to send retained, privacy-filtered source to the configured model. Model usage may incur costs."); + } + return result.success ? 0 : 1; + } catch (error) { + const message = error instanceof Error ? error.message : "Compression request failed"; + log(json ? JSON.stringify({ success: false, error: message }) : `Compression: ${message}`); + return 1; + } +} diff --git a/src/functions/auto-forget.ts b/src/functions/auto-forget.ts index 3ac936c8e..111707816 100644 --- a/src/functions/auto-forget.ts +++ b/src/functions/auto-forget.ts @@ -3,6 +3,7 @@ import { markCaptureEventDeleted } from "../capture/event-record.js"; import type { Memory, CompressedObservation, Session } from "../types.js"; import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; import { unindexObservationSession } from "../state/obs-index.js"; import { recordAudit } from "./audit.js"; import { deleteAccessLog } from "./access-tracker.js"; @@ -166,19 +167,27 @@ export function registerAutoForgetFunction(sdk: IIIClient, kv: StateKV): void { if (age > 180 * MS_PER_DAY && (obs.importance ?? 5) <= 2) { result.lowValueObs.push(obs.id); if (!dryRun) { - let deletedOk = false; + let removed: CompressedObservation | null = null; try { - await markCaptureEventDeleted(kv, obs); - await kv.delete(KV.observations(sessions[i].id), obs.id); - deletedOk = true; + removed = await withKeyedLock(`obs:${sessions[i].id}`, async () => { + const current = await kv.get(KV.observations(sessions[i].id), obs.id); + if (!current?.timestamp) return null; + const currentAge = now - new Date(current.timestamp).getTime(); + if (!(currentAge > 180 * MS_PER_DAY && (current.importance ?? 5) <= 2)) return null; + await markCaptureEventDeleted(kv, current); + await kv.delete(KV.observations(sessions[i].id), obs.id); + getSearchIndex().remove(obs.id); + vectorIndexRemove(obs.id); + return current; + }); } catch { - deletedOk = false; + logger.warn("Auto-forget observation cleanup failed"); } - if (deletedOk) { + if (removed) { await unindexObservationSession(kv, obs.id).catch(() => {}); - if (obs.imageData) await decrementImageRef(kv, sdk, obs.imageData); - if (obs.imageRef && obs.imageRef !== obs.imageData) { - await decrementImageRef(kv, sdk, obs.imageRef); + if (removed.imageData) await decrementImageRef(kv, sdk, removed.imageData); + if (removed.imageRef && removed.imageRef !== removed.imageData) { + await decrementImageRef(kv, sdk, removed.imageRef); } await recordAudit(kv, "delete", "mem::auto-forget", [obs.id], { resource: "observation", @@ -186,8 +195,6 @@ export function registerAutoForgetFunction(sdk: IIIClient, kv: StateKV): void { sessionId: sessions[i].id, timestamp: obs.timestamp, }); - getSearchIndex().remove(obs.id); - vectorIndexRemove(obs.id); } } } diff --git a/src/functions/compress.ts b/src/functions/compress.ts index 6771dc61a..41e6387f5 100644 --- a/src/functions/compress.ts +++ b/src/functions/compress.ts @@ -1,30 +1,51 @@ import { TriggerAction, type IIIClient } from "iii-sdk"; import { readFileSync } from "node:fs"; import { isManagedImagePath } from "../utils/image-store.js"; -import type { - RawObservation, - CompressedObservation, - MemoryProvider, -} from "../types.js"; +import type { RawObservation, CompressedObservation, MemoryProvider, ObservationCompression, Session } from "../types.js"; import { KV, STREAM } from "../state/schema.js"; -import { StateKV } from "../state/kv.js"; -import { - COMPRESSION_SYSTEM, - buildCompressionPrompt, -} from "../prompts/compression.js"; +import type { StateKV } from "../state/kv.js"; +import { COMPRESSION_SYSTEM, buildCompressionPrompt } from "../prompts/compression.js"; import { VISION_DESCRIPTION_PROMPT } from "../prompts/vision.js"; import { parseCompressionXml } from "../prompts/compression-output.js"; -import { getSearchIndex, scheduleIndexSave, vectorIndexAddGuarded } from "./search.js"; +import { getSearchIndex, getVectorIndex, scheduleIndexSave, vectorIndexAddGuarded } from "./search.js"; import { CompressOutputSchema } from "../eval/schemas.js"; import { validateOutput } from "../eval/validator.js"; import { scoreCompression } from "../eval/quality.js"; import { compressWithRetry } from "../eval/self-correct.js"; import type { MetricsStore } from "../eval/metrics-store.js"; import { logger } from "../logger.js"; -import { createObservationSource, withoutObservationSource } from "./observation-source.js"; +import { withoutObservationSource } from "./observation-source.js"; import { budgetLiveObservationSource } from "./observation-source-budget.js"; +import { buildSyntheticCompression } from "./compress-synthetic.js"; import { withKeyedLock } from "../state/keyed-mutex.js"; import { scrubRecord, stripPrivateData } from "./privacy.js"; +import { compressionRaw, needsCompression, observationRevision, pendingCompression, type StoredObservation } from "./compression-state.js"; +import { registerCompressionRecovery } from "./compression-recovery.js"; + +export const COMPRESSION_TIMEOUT_MS = 60_000; + +type CompressRequest = { observationId: string; sessionId: string; raw?: RawObservation; expectedRevision?: string; scope?: { project?: string; agentId?: string } }; +export type CompressResult = { + success: boolean; + error?: string; + skipped?: boolean; + retryable?: boolean; + compressed?: Omit; + qualityScore?: number; + sourceTruncated?: boolean; +}; +type CompressionAttempt = { revisions: Set; resultRevision?: string }; +type ActiveCompression = { promise: Promise; attempt: CompressionAttempt }; +const activeByStore = new WeakMap>(); + +export function compressionInFlight(kv: StateKV, sessionId: string, observationId: string): boolean { + return activeByStore.get(kv)?.has(JSON.stringify([sessionId, observationId])) ?? false; +} + +function indexObservation(observation: CompressedObservation): void { + getSearchIndex().add(observation); + scheduleIndexSave(); +} export function registerCompressFunction( sdk: IIIClient, @@ -32,201 +53,221 @@ export function registerCompressFunction( provider: MemoryProvider, metricsStore?: MetricsStore, ): void { - sdk.registerFunction("mem::compress", - async (data: { - observationId: string; - sessionId: string; - raw: RawObservation; - }) => { - const startMs = Date.now(); - if (!data?.raw || typeof data.raw.hookType !== "string" || !data.raw.hookType) { - return { success: false, error: "invalid_raw_observation" }; - } + const active = activeByStore.get(kv) ?? new Map(); + activeByStore.set(kv, active); + + async function matchesScope(data: CompressRequest, row: StoredObservation): Promise { + if (!data.scope) return true; + const session = await kv.get(KV.sessions, data.sessionId); + return Boolean(session && (!data.scope.project || session.project === data.scope.project) && + (!data.scope.agentId || (row.agentId ?? session.agentId) === data.scope.agentId)); + } + + async function compress(data: CompressRequest, attempt: CompressionAttempt, deadline: number): Promise { + const { sessionId, observationId } = data; + const startMs = Date.now(); + async function bounded(work: () => Promise): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("compression_timeout"); + let timer: ReturnType | undefined; + try { + return await Promise.race([work(), new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("compression_timeout")), remaining); + })]); + } finally { clearTimeout(timer); } + } + const prepared = await withKeyedLock(`obs:${sessionId}`, async () => { + const stored = await kv.get(KV.observations(sessionId), observationId); + if (!stored || typeof stored !== "object" || stored.id !== observationId || stored.sessionId !== sessionId) return null; + if (!await matchesScope(data, stored)) return { skipped: true as const }; + if (data.expectedRevision && observationRevision(stored) !== data.expectedRevision) return { skipped: true as const }; + if (!needsCompression(stored) && !data.raw) return { skipped: true as const }; + const rows = await kv.list(KV.observations(sessionId)); + const fallback = "title" in stored ? stored : budgetLiveObservationSource(buildSyntheticCompression(scrubRecord(stored)), rows); + const raw = compressionRaw(fallback); + if (!raw && !("title" in stored)) return { unavailable: true as const }; + const compression: ObservationCompression = { + ...pendingCompression((stored.compression?.attempts ?? 0) + (raw ? 1 : 0)), + ...(raw ? { lastAttemptAt: new Date().toISOString() } : { status: "failed" as const, error: "source_unavailable" as const }), + }; + const claimed = { ...stored, compression }; + await kv.set(KV.observations(sessionId), observationId, claimed); + attempt.revisions.add(observationRevision(stored)); + attempt.revisions.add(observationRevision(claimed)); + if ("title" in claimed) indexObservation(claimed); + return { raw, claimed, fallback, revision: observationRevision(claimed) }; + }); + if (!prepared) return { success: false, error: "observation_not_found", skipped: true, retryable: false }; + if ("skipped" in prepared) return { success: true, skipped: true }; + if ("unavailable" in prepared || !prepared.raw) return { success: false, error: "source_unavailable", retryable: false }; + const { raw, claimed, fallback, revision } = prepared; + async function fail(error: NonNullable): Promise { + const changed = await withKeyedLock(`obs:${sessionId}`, async () => { + const current = await kv.get(KV.observations(sessionId), observationId); + if (!current || observationRevision(current) !== revision) return true; + const failed = { ...claimed, compression: { ...claimed.compression!, status: "failed" as const, error } }; + await kv.set(KV.observations(sessionId), observationId, failed); + attempt.resultRevision = observationRevision(failed); + attempt.revisions.add(attempt.resultRevision); + return false; + }); + await metricsStore?.record("mem::compress", Date.now() - startMs, false).catch(() => {}); + return changed + ? { success: false, error: "observation_changed", skipped: true, retryable: false } + : { success: false, error, retryable: true, ...(fallback.source?.truncated ? { sourceTruncated: true } : {}) }; + } + + try { let imageDescription: string | undefined; - const hasImage = data.raw.modality === "image" || data.raw.modality === "mixed"; + const hasImage = raw.modality === "image" || raw.modality === "mixed"; - if (hasImage && data.raw.imageData && provider.describeImage) { + if (hasImage && raw.imageData && provider.describeImage) { try { - let base64Data = data.raw.imageData; + let base64Data = raw.imageData; let mimeType = "image/png"; - if (!data.raw.imageData.startsWith("/9j/") && !data.raw.imageData.startsWith("iVBOR")) { - if (!isManagedImagePath(data.raw.imageData)) { - throw new Error(`Refusing to read image outside managed store: ${data.raw.imageData}`); + if (!raw.imageData.startsWith("/9j/") && !raw.imageData.startsWith("iVBOR")) { + if (!isManagedImagePath(raw.imageData)) { + throw new Error(`Refusing to read image outside managed store: ${raw.imageData}`); } - const fileBuffer = readFileSync(data.raw.imageData); + const fileBuffer = readFileSync(raw.imageData); base64Data = fileBuffer.toString("base64"); - if (data.raw.imageData.endsWith(".jpg") || data.raw.imageData.endsWith(".jpeg")) mimeType = "image/jpeg"; - else if (data.raw.imageData.endsWith(".webp")) mimeType = "image/webp"; - else if (data.raw.imageData.endsWith(".gif")) mimeType = "image/gif"; + if (raw.imageData.endsWith(".jpg") || raw.imageData.endsWith(".jpeg")) mimeType = "image/jpeg"; + else if (raw.imageData.endsWith(".webp")) mimeType = "image/webp"; + else if (raw.imageData.endsWith(".gif")) mimeType = "image/gif"; } - imageDescription = await provider.describeImage(base64Data, mimeType, VISION_DESCRIPTION_PROMPT); - logger.info("Image described by vision model", { obsId: data.observationId }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.warn("Vision model call failed, falling back to text-only compression", { - obsId: data.observationId, - error: msg, - }); + imageDescription = await bounded(() => provider.describeImage!(base64Data, mimeType, VISION_DESCRIPTION_PROMPT)); + logger.info("Image described by vision model", { obsId: observationId }); + } catch { + logger.warn("Vision model call failed, falling back to text-only compression", { obsId: observationId }); } } + const retainedOutput = raw.toolOutput ?? raw.assistantResponse ?? (raw.raw && Object.keys(Object(raw.raw)).length > 0 ? raw.raw : undefined); const prompt = buildCompressionPrompt({ - hookType: data.raw.hookType, - toolName: data.raw.toolName, - toolInput: data.raw.toolInput, - toolOutput: imageDescription - ? `[Image Description]: ${imageDescription}\n\n${data.raw.toolOutput ?? ""}` - : data.raw.toolOutput, - userPrompt: data.raw.userPrompt, - timestamp: data.raw.timestamp, + hookType: raw.hookType, + toolName: raw.toolName, + toolInput: raw.toolInput, + toolOutput: imageDescription ? `[Image Description]: ${imageDescription}\n\n${retainedOutput ?? ""}` : retainedOutput, + userPrompt: raw.userPrompt, + timestamp: raw.timestamp, }); + const validator = (response: string) => { + const parsed = parseCompressionXml(response); + if (!parsed) return { valid: false as const, errors: ["xml_parse_failed"] }; + const result = validateOutput(CompressOutputSchema, scrubRecord(parsed), "mem::compress"); + return result.valid ? result : { valid: false as const, errors: result.result.errors }; + }; + const result = await compressWithRetry({ + ...provider, + compress: (system, user) => bounded(() => provider.compress(system, user)), + }, COMPRESSION_SYSTEM, prompt, validator, 1); + if (!result.valid) { + return fail(result.errors?.includes("xml_parse_failed") ? "parse_failed" : "validation_failed"); + } + const qualityScore = scoreCompression(result.data); + const candidate: CompressedObservation = { + ...fallback, + ...result.data, + compressedAt: new Date().toISOString(), + confidence: qualityScore / 100, + compression: { ...claimed.compression, status: "complete" }, + ...(raw.imageData ? { imageRef: raw.imageData } : {}), + ...(imageDescription ? { imageDescription: stripPrivateData(imageDescription) } : {}), + }; + const compressed = await withKeyedLock(`obs:${sessionId}`, async () => { + const current = await kv.get(KV.observations(sessionId), observationId); + if (!current || observationRevision(current) !== revision || !await matchesScope(data, current)) return null; + const rows = await kv.list(KV.observations(sessionId)); + const bounded = budgetLiveObservationSource(candidate, rows); + await kv.set(KV.observations(sessionId), observationId, bounded); + attempt.resultRevision = observationRevision(bounded); + attempt.revisions.add(attempt.resultRevision); + indexObservation(bounded); + return bounded; + }); + if (!compressed) return { success: false, error: "observation_changed", skipped: true, retryable: false }; + const committedRevision = observationRevision(compressed); + await bounded(() => vectorIndexAddGuarded( + observationId, sessionId, compressed.title + " " + compressed.narrative, + { kind: "observation", logId: observationId }, + (embedding, isCurrent) => withKeyedLock(`obs:${sessionId}`, async () => { + const current = await kv.get(KV.observations(sessionId), observationId); + if (!isCurrent() || !current || observationRevision(current) !== committedRevision) return false; + getVectorIndex()?.add(observationId, sessionId, embedding); + return true; + }), + )).catch(() => {}); + const published = await withKeyedLock(`obs:${sessionId}`, async () => { + const current = await kv.get(KV.observations(sessionId), observationId); + if (!current || observationRevision(current) !== committedRevision || !await matchesScope(data, current)) return false; + await sdk.trigger({ + function_id: "stream::send", + payload: { + stream_name: STREAM.name, group_id: STREAM.viewerGroup, + id: `compressed-${observationId}`, type: "compressed_observation", + data: { type: "compressed", observation: withoutObservationSource(compressed), sessionId }, + }, + action: TriggerAction.Void(), + }).catch(() => {}); + return true; + }); + if (!published) return { success: false, error: "observation_changed", skipped: true, retryable: false }; + await metricsStore?.record("mem::compress", Date.now() - startMs, true, qualityScore).catch(() => {}); + return { success: true, compressed: withoutObservationSource(compressed), qualityScore, ...(compressed.source?.truncated ? { sourceTruncated: true } : {}) }; + } catch { + logger.warn("Observation retained for compression retry", { observationId, sessionId }); + return fail("compression_failed"); + } + } - try { - const validator = (response: string) => { - const parsed = parseCompressionXml(response); - if (!parsed) return { valid: false as const, errors: ["xml_parse_failed"] }; - const result = validateOutput( - CompressOutputSchema, - scrubRecord(parsed), - "mem::compress", - ); - return result.valid - ? result - : { valid: false as const, errors: result.result.errors }; - }; - - const result = await compressWithRetry( - provider, - COMPRESSION_SYSTEM, - prompt, - validator, - 1, - ); - - if (!result.valid) { - const latencyMs = Date.now() - startMs; - if (metricsStore) { - await metricsStore.record("mem::compress", latencyMs, false); - } - logger.warn("Compression output rejected after validation", { - obsId: data.observationId, - retried: result.retried, - }); - return { - success: false, - error: result.errors?.includes("xml_parse_failed") ? "parse_failed" : "validation_failed", - }; - } - - const parsed = result.data; - const qualityScore = scoreCompression(parsed); - - const candidate: CompressedObservation = { - id: data.observationId, - sessionId: data.sessionId, - timestamp: data.raw.timestamp, - compressedAt: new Date().toISOString(), - ...parsed, - confidence: qualityScore / 100, - source: createObservationSource(data.raw), - ...(hasImage ? { modality: data.raw.modality } : {}), - ...(imageDescription ? { imageDescription: stripPrivateData(imageDescription) } : {}), - ...(data.raw.imageData ? { imageRef: data.raw.imageData } : {}), - ...(data.raw.agentId ? { agentId: data.raw.agentId } : {}), - ...(data.raw.origin ? { origin: data.raw.origin } : {}), - ...(data.raw.captureKey ? { captureKey: data.raw.captureKey } : {}), - }; - - const compressed = await withKeyedLock(`obs:${data.sessionId}`, async () => { - const existing = await kv.list(KV.observations(data.sessionId)); - const bounded = budgetLiveObservationSource(candidate, existing); - await kv.set(KV.observations(data.sessionId), data.observationId, bounded); - return bounded; - }); - - try { - getSearchIndex().add(compressed); - scheduleIndexSave(); - } catch (err) { - logger.warn("Failed to index compressed observation into BM25", { - obsId: compressed.id, - sessionId: compressed.sessionId, - title: compressed.title, - error: err instanceof Error ? err.message : String(err), - }); - } - - await vectorIndexAddGuarded( - compressed.id, - compressed.sessionId, - compressed.title + " " + (compressed.narrative || ""), - { kind: "observation", logId: compressed.id }, - ); - - const streamResults = await Promise.allSettled([ - sdk.trigger({ - function_id: "stream::send", - payload: { - stream_name: STREAM.name, - group_id: STREAM.viewerGroup, - id: `compressed-${data.observationId}`, - type: "compressed_observation", - data: { - type: "compressed", - observation: withoutObservationSource(compressed), - sessionId: data.sessionId, - }, - }, - action: TriggerAction.Void(), - }), - ]); - for (const result of streamResults) { - if (result.status === "rejected") { - logger.warn("Non-fatal stream publish failure after compress", { - sessionId: data.sessionId, - observationId: data.observationId, - error: - result.reason instanceof Error - ? result.reason.message - : String(result.reason), - }); - } - } - - const latencyMs = Date.now() - startMs; - if (metricsStore) { - await metricsStore.record( - "mem::compress", - latencyMs, - true, - qualityScore, - ); - } - - logger.info("Observation compressed", { - obsId: data.observationId, - type: compressed.type, - importance: compressed.importance, - qualityScore, - retried: result.retried, - }); + async function dispatch(data: CompressRequest, deadline: number): Promise { + const key = JSON.stringify([data.sessionId, data.observationId]); + const existing = active.get(key); + if (existing) { + const changed: CompressResult = { success: false, error: "observation_changed", skipped: true, retryable: false }; + const requestedRevision = await withKeyedLock(`obs:${data.sessionId}`, async () => { + const current = await kv.get(KV.observations(data.sessionId), data.observationId); + if (!current || current.id !== data.observationId || current.sessionId !== data.sessionId || !await matchesScope(data, current)) return null; + const revision = observationRevision(current); + if (data.expectedRevision && data.expectedRevision !== revision && !existing.attempt.revisions.has(data.expectedRevision)) return null; + return data.expectedRevision ?? revision; + }); + if (!requestedRevision) return changed; + let timer: ReturnType | undefined; + const result = await Promise.race([existing.promise, new Promise((resolve) => { + timer = setTimeout(() => resolve({ success: false, error: "compression_timeout", retryable: true }), Math.max(0, deadline - Date.now())); + })]).finally(() => clearTimeout(timer)); + if (result.error === "compression_timeout") return result; + const shared = await withKeyedLock(`obs:${data.sessionId}`, async () => { + const current = await kv.get(KV.observations(data.sessionId), data.observationId); + if (!current || current.id !== data.observationId || current.sessionId !== data.sessionId || !await matchesScope(data, current)) return changed; + const revision = observationRevision(current); + if (revision === existing.attempt.resultRevision && existing.attempt.revisions.has(requestedRevision)) return result; + return revision === requestedRevision && (needsCompression(current) || data.raw) ? null : changed; + }); + return shared ?? dispatch({ ...data, expectedRevision: requestedRevision }, deadline); + } + if (Date.now() >= deadline) return { success: false, error: "compression_timeout", retryable: true }; + const attempt: CompressionAttempt = { revisions: new Set() }; + const task: ActiveCompression = { attempt, promise: compress(data, attempt, deadline).finally(() => { + if (active.get(key) === task) active.delete(key); + }) }; + active.set(key, task); + return task.promise; + } - return { success: true, compressed: withoutObservationSource(compressed), qualityScore }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - const latencyMs = Date.now() - startMs; - if (metricsStore) { - await metricsStore.record("mem::compress", latencyMs, false); - } - logger.error("Compression failed", { - obsId: data.observationId, - error: msg, - }); - return { success: false, error: "compression_failed" }; - } - }, - ); + sdk.registerFunction("mem::compress", (data: CompressRequest): Promise => { + if (!data || typeof data.sessionId !== "string" || !data.sessionId.trim() || + typeof data.observationId !== "string" || !data.observationId.trim()) { + return Promise.resolve({ success: false, error: "invalid_observation_identity", retryable: false }); + } + if (data.raw !== undefined && (!data.raw || typeof data.raw.hookType !== "string" || !data.raw.hookType)) { + return Promise.resolve({ success: false, error: "invalid_raw_observation" }); + } + return dispatch(data, Date.now() + COMPRESSION_TIMEOUT_MS); + }); + registerCompressionRecovery(sdk, kv); } diff --git a/src/functions/compression-recovery.ts b/src/functions/compression-recovery.ts new file mode 100644 index 000000000..69f488be2 --- /dev/null +++ b/src/functions/compression-recovery.ts @@ -0,0 +1,172 @@ +import type { IIIClient } from "iii-sdk"; +import type { Session } from "../types.js"; +import type { StateKV } from "../state/kv.js"; +import { fingerprintId, KV } from "../state/schema.js"; +import { compressionInFlight, type CompressResult } from "./compress.js"; +import { compressionRaw, needsCompression, observationRevision, type StoredObservation } from "./compression-state.js"; +import { safeAudit } from "./audit.js"; +import { checkPayloadFrameSize } from "../state/frame-guard.js"; + +export interface CompressionScope { + sessionId?: string; + project?: string; + agentId?: string; + limit: number; + cursor?: string; +} + +const MAX_CURSOR_LENGTH = 32 * 1024; +type Cursor = { sessionId: string; observationId: string; hashed: boolean }; + +export function parseCompressionScope(input: unknown): CompressionScope { + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Expected an object"); + const data = input as Record; + if (Object.keys(data).some((key) => !["sessionId", "project", "agentId", "limit", "cursor"].includes(key))) { + throw new Error("Unknown compression option"); + } + const scope: CompressionScope = { limit: 10 }; + for (const key of ["sessionId", "project", "agentId", "cursor"] as const) { + const value = data[key]; + if (value === undefined || (key === "cursor" && value === "")) continue; + if (typeof value !== "string" || !value.trim() || value.length > (key === "cursor" ? MAX_CURSOR_LENGTH : 1024)) throw new Error(`Invalid ${key}`); + scope[key] = value; + } + if (!scope.sessionId && !scope.project) throw new Error("Specify sessionId or project"); + if (data.limit !== undefined) { + if ((typeof data.limit !== "number" && typeof data.limit !== "string") || !/^\d+$/.test(String(data.limit))) { + throw new Error("limit must be an integer from 1 to 100"); + } + scope.limit = Number(data.limit); + if (!Number.isSafeInteger(scope.limit) || scope.limit < 1 || scope.limit > 100) throw new Error("limit must be an integer from 1 to 100"); + } + readCursor(scope); + return scope; +} + +function scopeKey(scope: CompressionScope): string { + return fingerprintId("compression-scope", JSON.stringify([scope.sessionId, scope.project, scope.agentId])); +} + +function writeCursor(scope: CompressionScope, sessionId: string, observationId: string): string { + const encode = (parts: unknown[]) => Buffer.from(JSON.stringify(parts)).toString("base64url"); + const cursor = encode([scopeKey(scope), sessionId, observationId]); + return cursor.length <= MAX_CURSOR_LENGTH ? cursor : encode([ + scopeKey(scope), fingerprintId("session", sessionId), observationId ? fingerprintId("observation", observationId) : "", "hash", + ]); +} + +function readCursor(scope: CompressionScope): Cursor | null { + if (!scope.cursor) return null; + try { + const value: unknown = JSON.parse(Buffer.from(scope.cursor, "base64url").toString("utf8")); + if (!Array.isArray(value) || ![3, 4].includes(value.length) || value[0] !== scopeKey(scope) || + typeof value[1] !== "string" || typeof value[2] !== "string") throw new Error(); + const hashed = value.length === 4; + if (hashed && (value[3] !== "hash" || !/^session_[a-f0-9]{16}$/.test(value[1]) || + (value[2] !== "" && !/^observation_[a-f0-9]{16}$/.test(value[2])))) throw new Error(); + return { sessionId: value[1], observationId: value[2], hashed }; + } catch { throw new Error("Invalid cursor for this compression scope"); } +} + +export function registerCompressionRecovery(sdk: IIIClient, kv: StateKV): void { + async function run(input: unknown, retry: boolean) { + const request = input && typeof input === "object" && !Array.isArray(input) ? { ...input } : input; + if (request && typeof request === "object") delete (request as Record)._caller_worker_id; + const scope = parseCompressionScope(request); + const after = readCursor(scope); + const sessions = scope.sessionId + ? [await kv.get(KV.sessions, scope.sessionId)].filter((s): s is Session => Boolean(s)) + : await kv.list(KV.sessions); + const eligible = sessions.filter((s) => typeof s.id === "string" && (!scope.project || s.project === scope.project)) + .sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + let anchorRows: { sessionId: string; rows: StoredObservation[] } | undefined; + if (after?.hashed) { + const anchors = eligible.filter((session) => fingerprintId("session", session.id) === after.sessionId); + let observationId: string | undefined = after.observationId ? undefined : ""; + if (anchors.length === 1 && after.observationId) { + const rows = await kv.list(KV.observations(anchors[0].id)); + anchorRows = { sessionId: anchors[0].id, rows }; + const matches = rows.filter((row) => row && typeof row.id === "string" && + fingerprintId("observation", row.id) === after.observationId); + if (matches.length === 1) observationId = matches[0].id; + } + if (anchors.length !== 1 || observationId === undefined) return { + success: false, dryRun: !retry, error: "cursor_stale", items: [], + attempted: 0, recovered: 0, failed: 0, scannedSessions: 0, scannedObservations: 0, + }; + after.sessionId = anchors[0].id; + after.observationId = observationId; + } + const items: Array<{ sessionId: string; observationId: string; status: string; attempts: number; retryable: boolean; + truncated: boolean; inFlight: boolean; error?: string; outcome?: string }> = []; + let scannedSessions = 0; + let scannedObservations = 0; + let attempted = 0; + let recovered = 0; + let failed = 0; + let nextCursor: string | undefined; + let stop = false; + const startedAt = Date.now(); + for (const session of eligible) { + if (after && session.id < after.sessionId) continue; + if (scannedSessions >= 20) { + nextCursor = writeCursor(scope, session.id, ""); + break; + } + scannedSessions++; + const observations = anchorRows?.sessionId === session.id ? anchorRows.rows : await kv.list(KV.observations(session.id)); + const rows = observations.filter((row) => row && typeof row === "object" && typeof row.id === "string") + .sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + for (const row of rows) { + if (after && session.id === after.sessionId && row.id <= after.observationId) continue; + scannedObservations++; + nextCursor = writeCursor(scope, session.id, row.id); + if (row.sessionId === session.id && needsCompression(row) && + (!scope.agentId || (row.agentId ?? session.agentId) === scope.agentId)) { + const source = compressionRaw(row); + const metadata = row.compression; + const item = { + sessionId: session.id, observationId: row.id, + status: metadata?.status ?? "raw", + attempts: metadata?.attempts ?? 0, + retryable: Boolean(source), truncated: "source" in row ? Boolean(row.source?.truncated) : false, + inFlight: compressionInFlight(kv, session.id, row.id), + error: (!source ? "source_unavailable" : metadata?.error) as string | undefined, + outcome: undefined as string | undefined, + }; + items.push(item); + if (retry && source) { + attempted++; + let result: CompressResult; + try { + result = await sdk.trigger({ function_id: "mem::compress", payload: { + sessionId: session.id, observationId: row.id, expectedRevision: observationRevision(row), + scope: { project: scope.project, agentId: scope.agentId }, + } }) as CompressResult; + } catch { result = { success: false, error: "compression_failed" }; } + if (!result || typeof result.success !== "boolean") result = { success: false, error: "compression_failed" }; + item.truncated = result.sourceTruncated ?? item.truncated; + item.retryable = result.retryable ?? item.retryable; + if (result.success && !result.skipped) { recovered++; item.outcome = "recovered"; item.error = undefined; } + else if (result.skipped) { item.outcome = "changed"; item.error = result.error; } + else { failed++; item.outcome = "failed"; item.error = result.error ?? "compression_failed"; stop = true; } + } + } + if (stop || items.length >= scope.limit || scannedObservations >= 1000 || Date.now() - startedAt >= 30_000) { stop = true; break; } + } + if (stop) break; + nextCursor = undefined; + } + const result = { success: failed === 0, dryRun: !retry, items, attempted, recovered, failed, scannedSessions, scannedObservations, + ...(nextCursor ? { nextCursor } : {}) }; + const oversized = checkPayloadFrameSize(result, "lower limit or select one session for compression recovery"); + if (retry && attempted > 0) { + await safeAudit(kv, "compress", "mem::compression-retry", oversized ? [] : items.filter((i) => i.outcome === "recovered").map((i) => i.observationId), { + sessionId: scope.sessionId, project: scope.project, agentId: scope.agentId, attempted, recovered, failed, + }); + } + return oversized ?? result; + } + sdk.registerFunction("mem::compression-status", (input: unknown) => run(input, false)); + sdk.registerFunction("mem::compression-retry", (input: unknown) => run(input, true)); +} diff --git a/src/functions/compression-state.ts b/src/functions/compression-state.ts new file mode 100644 index 000000000..8638dd6b8 --- /dev/null +++ b/src/functions/compression-state.ts @@ -0,0 +1,52 @@ +import { randomUUID } from "node:crypto"; +import type { CompressedObservation, ObservationCompression, RawObservation } from "../types.js"; +import { stableStringify } from "../capture/event-id.js"; +import { fingerprintId, KV } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { rawFromObservationSource } from "./observation-source.js"; +import { scrubRecord } from "./privacy.js"; + +export type StoredObservation = (RawObservation & { compression?: ObservationCompression }) | CompressedObservation; + +export function pendingCompression(attempts = 0): ObservationCompression { + return { status: "pending", revision: randomUUID(), attempts }; +} + +export function observationRevision(observation: StoredObservation): string { + return fingerprintId("compression", stableStringify(observation)); +} + +export function needsCompression(observation: StoredObservation): boolean { + return "title" in observation + ? observation.compression?.status === "pending" || observation.compression?.status === "failed" + : typeof observation.hookType === "string" && Boolean(observation.hookType); +} + +export function compressionRaw(observation: StoredObservation): RawObservation | null { + const raw = "title" in observation ? rawFromObservationSource(observation) : observation; + if (!raw || !raw.id || !raw.sessionId || !raw.timestamp || !raw.hookType) return null; + const values = [raw.toolInput, raw.toolOutput, raw.userPrompt, raw.assistantResponse, raw.raw, raw.imageData]; + const retained = values.some((value) => { + if (value === undefined || value === null) return false; + if (typeof value === "string") return value.trim().length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + return retained ? scrubRecord({ ...raw, captureKey: observation.captureKey }) : null; +} + +export async function markCompressionDispatchFailed( + kv: StateKV, + observation: CompressedObservation, +): Promise { + await withKeyedLock(`obs:${observation.sessionId}`, async () => { + const current = await kv.get(KV.observations(observation.sessionId), observation.id); + if (!current?.compression || current.compression.revision !== observation.compression?.revision) return; + if (current.compression.status !== "pending" || current.compression.attempts !== 0) return; + await kv.set(KV.observations(current.sessionId), current.id, { + ...current, + compression: { ...current.compression, status: "failed", error: "dispatch_failed" }, + }); + }); +} diff --git a/src/functions/evict.ts b/src/functions/evict.ts index 8c623dbee..fa228c088 100644 --- a/src/functions/evict.ts +++ b/src/functions/evict.ts @@ -9,11 +9,13 @@ import type { } from "../types.js"; import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; import { removeSessionFromProjectIndex } from "../state/session-index.js"; import { unindexObservationSession } from "../state/obs-index.js"; import { isConsolidationEnabled } from "../config.js"; import { recordAudit } from "./audit.js"; import { deleteAccessLog } from "./access-tracker.js"; +import { getSearchIndex, vectorIndexRemove, flushIndexSave } from "./search.js"; import { logger } from "../logger.js"; interface EvictionConfig { @@ -113,6 +115,21 @@ export function registerEvictFunction(sdk: IIIClient, kv: StateKV): void { async (data: { dryRun?: boolean }): Promise => { const dryRun = data?.dryRun ?? false; const { decrementImageRef } = await import("./image-refs.js"); + let indexCleaned = false; + const removeObservation = ( + sessionId: string, + selected: CompressedObservation, + eligible: (current: CompressedObservation) => boolean, + ) => withKeyedLock(`obs:${sessionId}`, async () => { + const current = await kv.get(KV.observations(sessionId), selected.id); + if (!current || !eligible(current)) return null; + await markCaptureEventDeleted(kv, current); + await kv.delete(KV.observations(sessionId), selected.id); + getSearchIndex().remove(selected.id); + vectorIndexRemove(selected.id); + indexCleaned = true; + return current; + }); const configOverride = await kv .get>(KV.config, "eviction") @@ -157,6 +174,8 @@ export function registerEvictFunction(sdk: IIIClient, kv: StateKV): void { }); if (!observations) continue; + const sessionRevision = JSON.stringify(session); + const observationsRevision = JSON.stringify(observations); let recovered = false; const hasCompressedObservations = observations.some( isCompressedObservation, @@ -173,7 +192,16 @@ export function registerEvictFunction(sdk: IIIClient, kv: StateKV): void { } try { - await kv.delete(KV.sessions, session.id); + const removed = await withKeyedLock(`obs:${session.id}`, async () => { + const current = await kv.get(KV.sessions, session.id); + if (!current || JSON.stringify(current) !== sessionRevision) return false; + const currentObservations = await kv.list(KV.observations(session.id)); + if (JSON.stringify(currentObservations) !== observationsRevision) return false; + await kv.delete(KV.sessions, session.id); + await kv.delete(KV.summaryRepairs, session.id); + return true; + }); + if (!removed) continue; stats.staleSessions++; } catch (err) { logger.warn("Eviction delete failed", { @@ -224,9 +252,13 @@ export function registerEvictFunction(sdk: IIIClient, kv: StateKV): void { if (dryRun) { stats.lowImportanceObs++; } else { + let removed: CompressedObservation | null; try { - await markCaptureEventDeleted(kv, o); - await kv.delete(KV.observations(session.id), o.id); + removed = await removeObservation(session.id, o, (current) => + Boolean(current.title) && now - new Date(current.timestamp).getTime() > maxAge && + (current.importance ?? 5) < cfg.lowImportanceThreshold, + ); + if (!removed) continue; stats.lowImportanceObs++; } catch (err) { logger.warn("Eviction delete failed", { @@ -238,8 +270,8 @@ export function registerEvictFunction(sdk: IIIClient, kv: StateKV): void { continue; } await unindexObservationSession(kv, o.id).catch(() => {}); - if (o.imageData) await decrementImageRef(kv, sdk, o.imageData); - if (o.imageRef && o.imageRef !== o.imageData) await decrementImageRef(kv, sdk, o.imageRef); + if (removed.imageData) await decrementImageRef(kv, sdk, removed.imageData); + if (removed.imageRef && removed.imageRef !== removed.imageData) await decrementImageRef(kv, sdk, removed.imageRef); await recordAudit(kv, "delete", "mem::evict", [o.id], { resource: "observation", reason: "low_importance_old_observation", @@ -269,9 +301,12 @@ export function registerEvictFunction(sdk: IIIClient, kv: StateKV): void { stats.capEvictions += toEvict.length; } else { for (const o of toEvict) { + let removed: CompressedObservation | null; try { - await markCaptureEventDeleted(kv, o); - await kv.delete(KV.observations(o.sessionId), o.id); + removed = await removeObservation(o.sessionId, o, (current) => + JSON.stringify(current) === JSON.stringify(o), + ); + if (!removed) continue; stats.capEvictions++; } catch (err) { logger.warn("Eviction delete failed", { @@ -283,8 +318,8 @@ export function registerEvictFunction(sdk: IIIClient, kv: StateKV): void { continue; } await unindexObservationSession(kv, o.id).catch(() => {}); - if (o.imageData) await decrementImageRef(kv, sdk, o.imageData); - if (o.imageRef && o.imageRef !== o.imageData) await decrementImageRef(kv, sdk, o.imageRef); + if (removed.imageData) await decrementImageRef(kv, sdk, removed.imageData); + if (removed.imageRef && removed.imageRef !== removed.imageData) await decrementImageRef(kv, sdk, removed.imageRef); await recordAudit(kv, "delete", "mem::evict", [o.id], { resource: "observation", reason: "project_observation_cap", @@ -368,6 +403,7 @@ export function registerEvictFunction(sdk: IIIClient, kv: StateKV): void { } } + if (indexCleaned) await flushIndexSave(); logger.info("Eviction complete", { stats }); return stats; }, diff --git a/src/functions/export-import.ts b/src/functions/export-import.ts index c7938f216..86bad3c44 100644 --- a/src/functions/export-import.ts +++ b/src/functions/export-import.ts @@ -55,15 +55,17 @@ const IMPORT_CHUNK_SIZE = 20; // Run `fn` over `items` in fixed-size chunks, awaiting each chunk before // starting the next. Preserves ordering guarantees across chunks (chunk N // fully settles before chunk N+1 begins) while parallelizing within a -// chunk. Errors propagate — a failing item rejects the whole import, same -// as the original serial loops. +// chunk. Settle sibling writes before propagating a failure so mutation +// locks are not released while state writes are still pending. async function runChunked( items: readonly T[], fn: (item: T) => Promise, ): Promise { for (let i = 0; i < items.length; i += IMPORT_CHUNK_SIZE) { const chunk = items.slice(i, i + IMPORT_CHUNK_SIZE); - await Promise.all(chunk.map(fn)); + const results = await Promise.allSettled(chunk.map(fn)); + const failure = results.find((result) => result.status === "rejected"); + if (failure?.status === "rejected") throw failure.reason; } } @@ -324,7 +326,8 @@ export function registerExportImportFunction(sdk: IIIClient, state: StateKV): vo }; const replaceSessions = strategy === "replace" ? await kv.list(KV.sessions) : []; - const sessionKeys = [...obsBuckets, ...importData.sessions.map((session) => session.id), ...replaceSessions.map((session) => session.id)] + const sessionKeys = [...obsBuckets, ...importData.sessions.map((session) => session.id), + ...importData.summaries.map((summary) => summary.sessionId), ...replaceSessions.map((session) => session.id)] .map((id) => `obs:${id}`); const indexObs: CompressedObservation[] = []; const indexMems: Memory[] = []; @@ -349,6 +352,7 @@ export function registerExportImportFunction(sdk: IIIClient, state: StateKV): vo const obsDeletes: Array<{ sessionId: string; obsId: string; captureKey?: string }> = []; await runChunked(existing, async (session) => { await kv.delete(KV.sessions, session.id); + await kv.delete(KV.summaryRepairs, session.id); await removeSessionFromProjectIndex( kv, session.project, @@ -470,6 +474,20 @@ export function registerExportImportFunction(sdk: IIIClient, state: StateKV): vo }); } + await runChunked(importData.summaries, async (summary) => { + if (strategy === "skip") { + const existing = await kv + .get(KV.summaries, summary.sessionId) + .catch(() => null); + if (existing) { + stats.skipped++; + return; + } + } + await kv.set(KV.summaries, summary.sessionId, summary); + stats.summaries++; + }); + return null; }); if (preflightError) return preflightError; @@ -494,20 +512,6 @@ export function registerExportImportFunction(sdk: IIIClient, state: StateKV): vo indexMems.push(memory); }); - await runChunked(importData.summaries, async (summary) => { - if (strategy === "skip") { - const existing = await kv - .get(KV.summaries, summary.sessionId) - .catch(() => null); - if (existing) { - stats.skipped++; - return; - } - } - await kv.set(KV.summaries, summary.sessionId, summary); - stats.summaries++; - }); - if (strategy === "replace" || importData.graphNodes || importData.graphEdges) { const graph = await persistGraphRecords(kv, importData.graphNodes ?? [], importData.graphEdges ?? [], { mode: strategy === "skip" ? "skip" : "merge", diff --git a/src/functions/observe.ts b/src/functions/observe.ts index 2b695d895..dcffd7701 100644 --- a/src/functions/observe.ts +++ b/src/functions/observe.ts @@ -9,8 +9,8 @@ import { addSessionToProjectIndex } from "../state/session-index.js"; import { indexObservationSession } from "../state/obs-index.js"; import { stripPrivateData } from "./privacy.js"; import { DedupMap, recordDedupSkip } from "./dedup.js"; -import { withKeyedLock } from "../state/keyed-mutex.js"; -import { isAutoCompressEnabled } from "../config.js"; +import { afterKeyedLock, withKeyedLock } from "../state/keyed-mutex.js"; +import { getFinalizeIdleMs, isAutoCompressEnabled } from "../config.js"; import { buildSyntheticCompression } from "./compress-synthetic.js"; import { isCaptureKey } from "../capture/event-record.js"; import { claimBackfillPrompt, isBackfillPrompt, recordLivePrompt } from "../capture/prompt-ledger.js"; @@ -20,6 +20,9 @@ import { logger } from "../logger.js"; import { saveImageToDisk } from "../utils/image-store.js"; import { withoutObservationSource } from "./observation-source.js"; import { budgetLiveObservationSource } from "./observation-source-budget.js"; +import { markCompressionDispatchFailed, pendingCompression } from "./compression-state.js"; +import { requestSummaryRefresh } from "./summary-state.js"; +import { scheduleSessionFinalize } from "./session-finalize.js"; export function extractImage(d: unknown): string | undefined { if (!d) return undefined; @@ -243,9 +246,17 @@ export function registerObserveFunction( } } + const autoCompress = isAutoCompressEnabled(); + const finalizeIdleMs = getFinalizeIdleMs(); + if (existingSession && existingSession.status !== "active") { + const dueAt = Date.now() + finalizeIdleMs; + await requestSummaryRefresh(kv, payload.sessionId, dueAt); + if (finalizeIdleMs > 0) await scheduleSessionFinalize(kv, payload.sessionId, dueAt); + } + const synthetic = budgetLiveObservationSource(buildSyntheticCompression(raw), existing); + if (autoCompress) synthetic.compression = pendingCompression(); try { - - await kv.set(KV.observations(payload.sessionId), obsId, raw); + await kv.set(KV.observations(payload.sessionId), obsId, synthetic); await indexObservationSession(kv, obsId, payload.sessionId).catch( (err) => { logger.warn("observation index update failed", { @@ -304,7 +315,7 @@ export function registerObserveFunction( value: (session.observationCount || 0) + 1, }, ]; - if (session.status !== "active") { + if (session.status !== "active" && finalizeIdleMs > 0) { updates.push({ type: "set", path: "status", value: "active" }); updates.push({ type: "remove", path: "endedAt" }); } @@ -364,57 +375,49 @@ export function registerObserveFunction( }); } - // Per-observation LLM compression is opt-in as of 0.8.8. - // Default path: build a zero-LLM synthetic compression so recall - // and BM25 search still work without burning the user's Claude - // token allocation on every tool invocation. - if (isAutoCompressEnabled()) { - await sdk.trigger({ - function_id: "mem::compress", - payload: { - observationId: obsId, + getSearchIndex().add(synthetic); + scheduleIndexSave(); + await vectorIndexAddGuarded( + synthetic.id, + synthetic.sessionId, + synthetic.title + " " + (synthetic.narrative || ""), + { kind: "synthetic", logId: synthetic.id }, + ); + await sdk.trigger({ + function_id: "stream::set", + payload: { + stream_name: STREAM.name, + group_id: STREAM.viewerGroup, + item_id: obsId, + data: { + type: "compressed", + observation: withoutObservationSource(synthetic), sessionId: payload.sessionId, - raw, - }, - action: TriggerAction.Void(), - }); - } else { - const synthetic = budgetLiveObservationSource(buildSyntheticCompression(raw), existing); - await kv.set( - KV.observations(payload.sessionId), - obsId, - synthetic, - ); - getSearchIndex().add(synthetic); - scheduleIndexSave(); - await vectorIndexAddGuarded( - synthetic.id, - synthetic.sessionId, - synthetic.title + " " + (synthetic.narrative || ""), - { kind: "synthetic", logId: synthetic.id }, - ); - await sdk.trigger({ - function_id: "stream::set", - payload: { - stream_name: STREAM.name, - group_id: STREAM.viewerGroup, - item_id: obsId, - data: { - type: "compressed", - observation: withoutObservationSource(synthetic), - sessionId: payload.sessionId, - }, }, + }, + }); + trackViewerStreamItem(obsId); + void pruneViewerStreamIfDue(sdk).catch(() => {}); + + if (autoCompress) { + afterKeyedLock(`obs:${payload.sessionId}`, async () => { + try { + await sdk.trigger({ + function_id: "mem::compress", + payload: { observationId: obsId, sessionId: payload.sessionId }, + action: TriggerAction.Void(), + }); + } catch { + await markCompressionDispatchFailed(kv, synthetic); + } }); - trackViewerStreamItem(obsId); - void pruneViewerStreamIfDue(sdk).catch(() => {}); } logger.info("Observation captured", { obsId, sessionId: payload.sessionId, hook: payload.hookType, - compress: isAutoCompressEnabled() ? "llm" : "synthetic", + compress: autoCompress ? "llm" : "synthetic", }); return { observationId: obsId }; }); diff --git a/src/functions/remember.ts b/src/functions/remember.ts index 431884f11..cb06f90cb 100644 --- a/src/functions/remember.ts +++ b/src/functions/remember.ts @@ -354,7 +354,9 @@ export function registerRememberFunction(sdk: IIIClient, kv: StateKV): void { ) { const sessionId = data.sessionId; for (const obsId of data.observationIds) { - await attempt(obsId, () => forgetObservation(sessionId, obsId)); + await attempt(obsId, () => + withKeyedLock(`obs:${sessionId}`, () => forgetObservation(sessionId, obsId)), + ); } } @@ -364,34 +366,37 @@ export function registerRememberFunction(sdk: IIIClient, kv: StateKV): void { !data.memoryId ) { const sessionId = data.sessionId; - const observations = await kv.list( - KV.observations(sessionId), - ); - for (const obs of observations) { - await attempt(obs.id, () => forgetObservation(sessionId, obs.id, obs)); - } - await attempt(sessionId, async () => { - const session = await kv.get(KV.sessions, sessionId); - if (!session) return false; - await kv.delete(KV.sessions, sessionId); - await removeSessionFromProjectIndex( - kv, - session.project, - sessionId, - ).catch(() => {}); - deletedSession = true; - return true; - }); - try { - const summary = await kv.get(KV.summaries, sessionId); - if (summary) { - await kv.delete(KV.summaries, sessionId); - deletedSummary = true; - deleted++; + await withKeyedLock(`obs:${sessionId}`, async () => { + const observations = await kv.list( + KV.observations(sessionId), + ); + for (const obs of observations) { + await attempt(obs.id, () => forgetObservation(sessionId, obs.id, obs)); } - } catch (err) { - recordFailure(`summary:${sessionId}`, err); - } + await attempt(sessionId, async () => { + const session = await kv.get(KV.sessions, sessionId); + if (!session) return false; + await kv.delete(KV.sessions, sessionId); + await kv.delete(KV.summaryRepairs, sessionId); + await removeSessionFromProjectIndex( + kv, + session.project, + sessionId, + ).catch(() => {}); + deletedSession = true; + return true; + }); + try { + const summary = await kv.get(KV.summaries, sessionId); + if (summary) { + await kv.delete(KV.summaries, sessionId); + deletedSummary = true; + deleted++; + } + } catch (err) { + recordFailure(`summary:${sessionId}`, err); + } + }); } if (deleted > 0 || indexCleaned) await flushIndexSave(); diff --git a/src/functions/session-finalize.ts b/src/functions/session-finalize.ts index 5d6e49be6..5fdf3a4ab 100644 --- a/src/functions/session-finalize.ts +++ b/src/functions/session-finalize.ts @@ -5,6 +5,7 @@ import { withKeyedLock } from "../state/keyed-mutex.js"; import type { Session } from "../types.js"; import { logger } from "../logger.js"; import { getFinalizeIdleMs } from "../config.js"; +import { requestSummaryRefresh } from "./summary-state.js"; export const FINALIZE_DUE_KEY = "finalize:due"; export const FINALIZE_TICK_MS = 30_000; @@ -68,7 +69,7 @@ export function registerSessionFinalizeFunction(sdk: IIIClient, kv: StateKV): vo const due = await readDue(kv); const dueCount = Object.keys(due).length; const ready = Object.entries(due) - .filter(([, at]) => at <= now) + .filter(([, at]) => idleMs > 0 && at <= now) .sort((a, b) => a[1] - b[1]) .slice(0, FINALIZE_BATCH); @@ -85,6 +86,7 @@ export function registerSessionFinalizeFunction(sdk: IIIClient, kv: StateKV): vo } const idleUntil = lastActivityMs(session) + idleMs; if (idleUntil > now) return idleUntil; + await requestSummaryRefresh(kv, sessionId); await kv.update(KV.sessions, sessionId, [ { type: "set", path: "endedAt", value: new Date(now).toISOString() }, { type: "set", path: "status", value: "completed" }, @@ -132,6 +134,7 @@ export function registerSessionFinalizeFunction(sdk: IIIClient, kv: StateKV): vo failed, }); } + await sdk.trigger({ function_id: "mem::summary-recover", payload: { now } }); return { success: failed === 0, due: dueCount, diff --git a/src/functions/session-sweep.ts b/src/functions/session-sweep.ts index 5ade29856..3422eb106 100644 --- a/src/functions/session-sweep.ts +++ b/src/functions/session-sweep.ts @@ -5,6 +5,7 @@ import { withKeyedLock } from "../state/keyed-mutex.js"; import type { Session } from "../types.js"; import { logger } from "../logger.js"; import { safeAudit } from "./audit.js"; +import { requestSummaryRefresh } from "./summary-state.js"; import { clearSessionFinalize } from "./session-finalize.js"; import { isSessionSweepEnabled, @@ -54,6 +55,7 @@ export function registerSessionSweepFunction(sdk: IIIClient, kv: StateKV): void const abandonedSession = await withKeyedLock(`obs:${candidate.id}`, async () => { const current = await kv.get(KV.sessions, candidate.id); if (!isStaleActive(current, cutoff)) return null; + await requestSummaryRefresh(kv, candidate.id); await kv.update(KV.sessions, candidate.id, [ { type: "set", path: "status", value: "abandoned" }, { type: "set", path: "endedAt", value: new Date().toISOString() }, diff --git a/src/functions/snapshot.ts b/src/functions/snapshot.ts index 543a45fe1..15f93d965 100644 --- a/src/functions/snapshot.ts +++ b/src/functions/snapshot.ts @@ -225,12 +225,14 @@ export function registerSnapshotFunction( memoryIds.add(memory.id); } for (const [sessionId, observations] of Object.entries(archive.observations ?? {})) { - for (const observation of observations) { - await kv.set(KV.observations(sessionId), observation.id, observation); - getSearchIndex().remove(observation.id); - vectorIndexRemove(observation.id); - observationSessions.set(observation.id, sessionId); - } + await withKeyedLock(`obs:${sessionId}`, async () => { + for (const observation of observations) { + await kv.set(KV.observations(sessionId), observation.id, observation); + getSearchIndex().remove(observation.id); + vectorIndexRemove(observation.id); + observationSessions.set(observation.id, sessionId); + } + }); } if (archive.graphNodes?.length || archive.graphEdges?.length) { await persistGraphRecords(kv, archive.graphNodes ?? [], archive.graphEdges ?? [], { mode: "merge" }); diff --git a/src/functions/summarize.ts b/src/functions/summarize.ts index aaf8669c7..827cffcf5 100644 --- a/src/functions/summarize.ts +++ b/src/functions/summarize.ts @@ -1,4 +1,5 @@ import type { IIIClient } from "iii-sdk"; +import { randomUUID } from "node:crypto"; import type { CompressedObservation, SessionSummary, @@ -22,17 +23,17 @@ import type { MetricsStore } from "../eval/metrics-store.js"; import { safeAudit } from "./audit.js"; import { logger } from "../logger.js"; import { scrubRecord } from "./privacy.js"; +import { registerSummaryRecoveryFunction } from "./summary-recovery.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { summaryFingerprint, summaryObservations } from "./summary-state.js"; // Per-chunk observation budget when a session is too large to fit in one // LLM call. Default ≈ 50k input tokens per chunk at ~110 tok/obs — fits // comfortably in 128k-window models. Override via SUMMARIZE_CHUNK_SIZE. const CHUNK_SIZE_DEFAULT = 400; -// Concurrent in-flight chunk calls. 6 keeps a 100-chunk session under -// iii's 180s function-invocation timeout at ~8s/call while staying -// inside generous-but-not-unlimited provider rate limits (well below -// OpenAI free tier's 500 RPM). High-throughput providers -// (Novita / DeepInfra / DeepSeek) typically allow 100+ concurrent — set -// SUMMARIZE_CHUNK_CONCURRENCY higher to cover ~1000+ chunk sessions. +export const SUMMARY_TIMEOUT_MS = 120_000; +// Concurrent chunk calls share the summary deadline. Increase this for +// large sessions only when the configured provider supports the load. const CHUNK_CONCURRENCY_DEFAULT = 6; // Bail on the merged summary if more than this fraction of chunks fail // to parse — a half-blind narrative is worse than a clean error. @@ -259,8 +260,9 @@ export function registerSummarizeFunction( provider: MemoryProvider, metricsStore?: MetricsStore, ): void { - sdk.registerFunction("mem::summarize", - async (data: { sessionId: string; force?: boolean } | undefined) => { + registerSummaryRecoveryFunction(sdk, kv, () => !isNoopProvider(provider)); + const lockPrefix = `summary:${randomUUID()}`; + const summarize = async (data: { sessionId: string; force?: boolean } | undefined) => { const startMs = Date.now(); if (!data || typeof data.sessionId !== "string" || !data.sessionId.trim()) { return { success: false, error: "sessionId is required" }; @@ -278,9 +280,7 @@ export function registerSummarizeFunction( const observations = await kv.list( KV.observations(sessionId), ); - const compressed = observations - .filter((o) => o.title) - .sort((a, b) => visibleAt(a).localeCompare(visibleAt(b)) || a.id.localeCompare(b.id)); + const compressed = summaryObservations(observations); if (compressed.length === 0) { logger.info("No observations to summarize", { @@ -289,20 +289,19 @@ export function registerSummarizeFunction( return { success: false, error: "no_observations" }; } - const existing = - data.force === true - ? null - : await kv.get(KV.summaries, sessionId).catch(() => null); - const watermark = existing?.coveredThrough; - const delta = watermark - ? compressed.filter((o) => visibleAt(o) > watermark) - : compressed; - if (existing && watermark && delta.length === 0) { + const storedSummary = await kv.get(KV.summaries, sessionId); + const summaryRevision = JSON.stringify(storedSummary); + const existing = data.force === true ? null : storedSummary; + const fingerprint = summaryFingerprint(session, compressed); + const coveredCount = existing?.observationCount ?? 0; + const prefixMatches = existing?.coveredFingerprint && Number.isSafeInteger(coveredCount) && + coveredCount > 0 && coveredCount <= compressed.length && + summaryFingerprint(session, compressed.slice(0, coveredCount)) === existing.coveredFingerprint; + if (existing && prefixMatches && coveredCount === compressed.length) { return { success: true, unchanged: true, summary: existing }; } - const incremental = - existing && watermark && delta.length < compressed.length ? existing : null; - const input = incremental ? delta : compressed; + const incremental = existing && prefixMatches ? existing : null; + const input = incremental ? compressed.slice(coveredCount) : compressed; const coveredThrough = visibleAt(compressed[compressed.length - 1]); if (isNoopProvider(provider)) { @@ -317,14 +316,29 @@ export function registerSummarizeFunction( }; } + const boundedProvider: MemoryProvider = { + name: provider.name, + compress: (system, prompt) => provider.compress(system, prompt), + summarize: async (system, prompt) => { + const remaining = SUMMARY_TIMEOUT_MS - (Date.now() - startMs); + if (remaining <= 0) throw new Error("summary_timeout"); + let timer: ReturnType | undefined; + try { + return await Promise.race([provider.summarize(system, prompt), new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("summary_timeout")), remaining); + })]); + } finally { clearTimeout(timer); } + }, + }; try { let summary: SessionSummary | null = null; let response = ""; let mode = "single"; let chunks = 1; + let incomplete = false; for (let attempt = 1; attempt <= 2; attempt++) { const produced = await produceSummaryXml( - provider, + boundedProvider, input, sessionId, session.project, @@ -332,6 +346,7 @@ export function registerSummarizeFunction( response = produced.response; mode = incremental ? "incremental" : produced.mode; chunks = produced.chunks; + incomplete = (produced.skipped ?? 0) > 0; if (!response || !response.trim()) { logger.warn("Empty provider response on summarize", { sessionId, @@ -350,7 +365,7 @@ export function registerSummarizeFunction( input.length, ); if (summary && incremental) { - response = await reduceWithExisting(provider, incremental, summary, compressed.length); + response = await reduceWithExisting(boundedProvider, incremental, summary, compressed.length); summary = parseSummaryXml(response, sessionId, session.project, compressed.length); } if (summary) break; @@ -372,7 +387,9 @@ export function registerSummarizeFunction( } return { success: false, error: "parse_failed" }; } - summary = scrubRecord({ ...summary, observationCount: compressed.length, coveredThrough }); + summary = scrubRecord({ ...summary, observationCount: compressed.length, + ...(!incomplete ? { coveredThrough, coveredFingerprint: fingerprint } : {}), + }); const summaryForValidation = { title: summary.title, @@ -401,7 +418,16 @@ export function registerSummarizeFunction( const qualityScore = scoreSummary(summaryForValidation); - await kv.set(KV.summaries, sessionId, summary); + const published = await withKeyedLock(`obs:${sessionId}`, async () => { + const currentSession = await kv.get(KV.sessions, sessionId); + if (!currentSession || currentSession.id !== sessionId) return false; + const currentObservations = summaryObservations(await kv.list(KV.observations(sessionId))); + if (summaryFingerprint(currentSession, currentObservations) !== fingerprint || + JSON.stringify(await kv.get(KV.summaries, sessionId)) !== summaryRevision) return false; + await kv.set(KV.summaries, sessionId, summary); + return true; + }); + if (!published) return { success: false, error: "source_changed" }; await safeAudit(kv, "compress", "mem::summarize", [sessionId], { title: summary.title, observationCount: compressed.length, @@ -426,7 +452,7 @@ export function registerSummarizeFunction( valid: validation.valid, }); - return { success: true, summary, qualityScore, incremental: incremental !== null }; + return { success: true, summary, qualityScore, incremental: incremental !== null, ...(incomplete ? { incomplete: true } : {}) }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); const latencyMs = Date.now() - startMs; @@ -439,6 +465,10 @@ export function registerSummarizeFunction( }); return { success: false, error: msg }; } - }, + }; + sdk.registerFunction("mem::summarize", (data: { sessionId: string; force?: boolean } | undefined) => + typeof data?.sessionId === "string" && data.sessionId.trim() + ? withKeyedLock(`${lockPrefix}:${data.sessionId.trim()}`, () => summarize(data)) + : summarize(data), ); } diff --git a/src/functions/summary-recovery.ts b/src/functions/summary-recovery.ts new file mode 100644 index 000000000..9daaa1c5c --- /dev/null +++ b/src/functions/summary-recovery.ts @@ -0,0 +1,112 @@ +import { TriggerAction, type IIIClient } from "iii-sdk"; +import type { CompressedObservation, Session, SessionSummary, SessionSummaryRepair } from "../types.js"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { logger } from "../logger.js"; +import { compressionInFlight } from "./compress.js"; +import { compressionRaw, needsCompression, type StoredObservation } from "./compression-state.js"; +import { summaryCovers, summaryObservations } from "./summary-state.js"; + +export const SUMMARY_RETRY_MS = 30_000; +export const SUMMARY_RETRY_MAX_MS = 60 * 60_000; +const MAX_ACTIVE = 2; +const activeByStore = new WeakMap>(); + +type CompressionRetryResult = { nextCursor?: string; recovered?: number; failed?: number; error?: string }; + +export function registerSummaryRecoveryFunction(sdk: IIIClient, kv: StateKV, hasProvider: () => boolean): void { + const active = activeByStore.get(kv) ?? new Set(); + activeByStore.set(kv, active); + + async function repair(request: SessionSummaryRepair): Promise { + const { sessionId } = request; + const current = await kv.get(KV.summaryRepairs, sessionId); + if (!current || current.token !== request.token) return; + let lastError: NonNullable = "pending"; + let delay = SUMMARY_RETRY_MS; + let compressionCursor = current.compressionCursor; + let progress = false; + try { + const session = await kv.get(KV.sessions, sessionId); + if (session?.id === sessionId && session.status !== "active") { + if (!hasProvider()) { + lastError = "no_provider"; + delay = SUMMARY_RETRY_MAX_MS; + } else { + let observations = await kv.list(KV.observations(sessionId)); + const pending = observations.filter(needsCompression); + if (!pending.some((row) => compressionInFlight(kv, sessionId, row.id))) { + if (pending.length) { + const retried = await sdk.trigger, CompressionRetryResult>({ + function_id: "mem::compression-retry", + payload: { sessionId, limit: 1, ...(compressionCursor ? { cursor: compressionCursor } : {}) }, + }); + compressionCursor = retried?.nextCursor; + progress = (retried?.recovered ?? 0) > 0 || Boolean(compressionCursor && compressionCursor !== current.compressionCursor && !retried?.failed); + observations = await kv.list(KV.observations(sessionId)); + } + const remaining = observations.filter(needsCompression); + if (remaining.length === 0) { + const result = await sdk.trigger<{ sessionId: string }, { success?: boolean; incomplete?: boolean }>({ + function_id: "mem::summarize", payload: { sessionId }, + }); + if (!result?.success || result.incomplete) lastError = "summary_failed"; + } else if (remaining.every((row) => !compressionRaw(row))) { + lastError = "source_unavailable"; + delay = SUMMARY_RETRY_MAX_MS; + progress = false; + } else lastError = "compression_pending"; + } else lastError = "compression_pending"; + } + } + } catch (error) { + lastError = "recovery_failed"; + logger.warn("Session summary recovery failed", { sessionId, error: error instanceof Error ? error.message : String(error) }); + } + + await withKeyedLock(`obs:${sessionId}`, async () => { + const latest = await kv.get(KV.summaryRepairs, sessionId); + if (!latest || latest.token !== request.token) return; + try { + const session = await kv.get(KV.sessions, sessionId); + if (!session) { + await kv.delete(KV.summaryRepairs, sessionId); + return; + } + const rows = await kv.list(KV.observations(sessionId)); + if (session.id === sessionId && session.status !== "active" && !rows.some(needsCompression) && + (rows.length === 0 || summaryCovers(session, summaryObservations(rows as CompressedObservation[]), + await kv.get(KV.summaries, sessionId)))) { + await kv.delete(KV.summaryRepairs, sessionId); + return; + } + } catch { + lastError = "recovery_failed"; + } + const attempts = Number.isSafeInteger(latest.attempts) && latest.attempts >= 0 ? latest.attempts : 0; + await kv.set(KV.summaryRepairs, sessionId, { + ...latest, attempts: progress ? 0 : Math.min(attempts + 1, 100), compressionCursor, lastError, + dueAt: Date.now() + (progress ? SUMMARY_RETRY_MS : Math.min(SUMMARY_RETRY_MAX_MS, delay * 2 ** Math.min(attempts, 7))), + }); + }); + } + + sdk.registerFunction("mem::summary-repair", async (request: SessionSummaryRepair) => { + if (!request?.sessionId || !request.token || active.has(request.sessionId) || active.size >= MAX_ACTIVE) return; + active.add(request.sessionId); + try { await repair(request); } + catch (error) { logger.warn("Could not persist summary recovery progress", { sessionId: request.sessionId, error: error instanceof Error ? error.message : String(error) }); } + finally { active.delete(request.sessionId); } + }); + sdk.registerFunction("mem::summary-recover", async (data?: { now?: number }) => { + const now = typeof data?.now === "number" && Number.isFinite(data.now) ? data.now : Date.now(); + const pending = await kv.list(KV.summaryRepairs); + const ready = pending.filter((entry) => entry && typeof entry.sessionId === "string" && + typeof entry.token === "string" && Number.isFinite(entry.dueAt) && + (entry.dueAt <= now || (entry.lastError === "no_provider" && hasProvider())) && !active.has(entry.sessionId)) + .sort((a, b) => a.dueAt - b.dueAt).slice(0, Math.max(0, MAX_ACTIVE - active.size)); + for (const entry of ready) await sdk.trigger({ function_id: "mem::summary-repair", payload: entry, action: TriggerAction.Void() }); + return { scheduled: ready.length }; + }); +} diff --git a/src/functions/summary-state.ts b/src/functions/summary-state.ts new file mode 100644 index 000000000..37321b5c5 --- /dev/null +++ b/src/functions/summary-state.ts @@ -0,0 +1,34 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { CompressedObservation, Session, SessionSummary, SessionSummaryRepair } from "../types.js"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import { isKeyedLockHeld, withKeyedLock } from "../state/keyed-mutex.js"; + +export function summaryObservations(observations: CompressedObservation[]): CompressedObservation[] { + return observations.filter((row) => row.title).sort((a, b) => + (a.compressedAt ?? a.timestamp).localeCompare(b.compressedAt ?? b.timestamp) || a.id.localeCompare(b.id)); +} + +export function summaryFingerprint(session: Session, observations: CompressedObservation[]): string { + return createHash("sha256").update(JSON.stringify([ + session.id, session.project, session.cwd, session.agentId, session.startedAt, + observations.map((row) => [row.id, row.type, row.title, row.narrative, row.facts, row.files, row.concepts]), + ])).digest("hex"); +} + +export function summaryCovers(session: Session, observations: CompressedObservation[], summary: SessionSummary | null): boolean { + return Boolean(summary?.coveredFingerprint && summary.observationCount === observations.length && + summary.coveredFingerprint === summaryFingerprint(session, observations)); +} + +export async function requestSummaryRefresh(kv: StateKV, sessionId: string, dueAt = Date.now()): Promise { + const save = async () => { + const session = await kv.get(KV.sessions, sessionId); + if (!session || session.id !== sessionId) return; + await kv.set(KV.summaryRepairs, sessionId, { + sessionId, token: randomUUID(), dueAt, attempts: 0, + }); + }; + if (isKeyedLockHeld(`obs:${sessionId}`)) await save(); + else await withKeyedLock(`obs:${sessionId}`, save); +} diff --git a/src/index.ts b/src/index.ts index 9b514c3a4..e54bf5919 100644 --- a/src/index.ts +++ b/src/index.ts @@ -611,7 +611,7 @@ async function main() { `Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, ); bootLog( - `REST API: 141 endpoints at http://localhost:${config.restPort}/agentmemory/*`, + `REST API: 143 endpoints at http://localhost:${config.restPort}/agentmemory/*`, ); bootLog( `MCP surface (opt-in via \`npx @agentmemory/mcp\`): ${getAllTools().length} tools · 6 resources · 3 prompts`, @@ -731,17 +731,22 @@ async function main() { } const finalizeIdleMs = getFinalizeIdleMs(); - if (finalizeIdleMs > 0) { - const sessionFinalizeTimer = setInterval(async () => { - try { - await sdk.trigger({ function_id: "mem::session-finalize", payload: {} }); - } catch (err) { - bootLog(`Session finalize failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, FINALIZE_TICK_MS); - sessionFinalizeTimer.unref(); - bootLog(`Session finalize: idle sessions complete after ${Math.round(finalizeIdleMs / 1000)}s`); - } + let finalizeRunning = false; + const runSessionFinalize = async () => { + if (finalizeRunning) return; + finalizeRunning = true; + try { + await sdk.trigger({ function_id: "mem::session-finalize", payload: {} }); + } catch (err) { + bootLog(`Session finalize failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + finalizeRunning = false; + } + }; + const sessionFinalizeTimer = setInterval(runSessionFinalize, FINALIZE_TICK_MS); + sessionFinalizeTimer.unref(); + void runSessionFinalize(); + bootLog(`Session finalize: ${finalizeIdleMs > 0 ? `idle sessions complete after ${Math.round(finalizeIdleMs / 1000)}s` : "idle finalization disabled"}; pending summaries recover at startup and every ${FINALIZE_TICK_MS / 1000}s`); if (isConsolidationEnabled()) { const consolidationTimer = setInterval(async () => { @@ -766,6 +771,7 @@ async function main() { healthMonitor.stop(); dedupMap.stop(); capture.stop(); + clearInterval(sessionFinalizeTimer); await settleWithin(graphIndexMigration.stop(), SHUTDOWN_FLUSH_TIMEOUT_MS); indexPersistence.stop(); const viewerClosed = new Promise((resolve) => diff --git a/src/state/schema.ts b/src/state/schema.ts index 3485ac26b..c2b56a0cb 100644 --- a/src/state/schema.ts +++ b/src/state/schema.ts @@ -6,6 +6,7 @@ export const KV = { observations: (sessionId: string) => `mem:obs:${sessionId}`, memories: "mem:memories", summaries: "mem:summaries", + summaryRepairs: "mem:summary:repairs", config: "mem:config", metrics: "mem:metrics", health: "mem:health", diff --git a/src/triggers/api.ts b/src/triggers/api.ts index fdcd003d6..566eb86d4 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -57,6 +57,8 @@ import { isValidEventId } from "../capture/event-id.js"; import { getCaptureController, type CaptureResult } from "../functions/capture.js"; import { withoutObservationSource } from "../functions/observation-source.js"; import { clearSessionFinalize, scheduleSessionFinalize } from "../functions/session-finalize.js"; +import { requestSummaryRefresh } from "../functions/summary-state.js"; +import { registerCompressionApi } from "./compression.js"; import { getFinalizeIdleMs, isGraphExtractionEnabled, @@ -938,6 +940,24 @@ export function registerApiTriggers( }, }); + registerCompressionApi(sdk, secret); + sdk.registerTrigger({ + type: "http", + function_id: "api::compression-status", + config: { + http_method: "GET", + api_path: "/agentmemory/compression", + }, + }); + sdk.registerTrigger({ + type: "http", + function_id: "api::compression-retry", + config: { + http_method: "POST", + api_path: "/agentmemory/compression/retry", + }, + }); + sdk.registerFunction("api::compress-file", async (req: HttpRequest<{ filePath: string }>): Promise => { const authErr = checkAuth(req, secret); @@ -1175,6 +1195,7 @@ export function registerApiTriggers( if (!session || session.id !== sessionId) return "not_found" as const; if (session.status === "completed") return "already_completed" as const; + await requestSummaryRefresh(kv, sessionId); await kv.update(KV.sessions, sessionId, [ { type: "set", path: "endedAt", value: new Date().toISOString() }, { type: "set", path: "status", value: "completed" }, diff --git a/src/triggers/compression.ts b/src/triggers/compression.ts new file mode 100644 index 000000000..1b6b1b805 --- /dev/null +++ b/src/triggers/compression.ts @@ -0,0 +1,24 @@ +import type { IIIClient } from "iii-sdk"; +import type { HttpRequest } from "@iii-dev/helpers/http"; +import { checkAuth } from "./api.js"; +import { parseCompressionScope } from "../functions/compression-recovery.js"; + +export function registerCompressionApi(sdk: IIIClient, secret?: string): void { + const handler = (retry: boolean) => async (req: HttpRequest) => { + const method = retry ? "POST" : "GET"; + const rejected = checkAuth(req, secret); + if (rejected) return rejected; + if (req.method && req.method.toUpperCase() !== method) return { status_code: 405, body: { error: "method_not_allowed" } }; + let scope; + try { scope = parseCompressionScope(retry ? req.body : req.query_params ?? {}); } + catch (error) { return { status_code: 400, body: { error: error instanceof Error ? error.message : "invalid_scope" } }; } + try { + const result = await sdk.trigger({ function_id: retry ? "mem::compression-retry" : "mem::compression-status", payload: scope }); + const response = result as { success?: boolean; oversized?: boolean }; + if (response?.success === false && response?.oversized === true) return { status_code: 413, body: result }; + return { status_code: 200, body: result }; + } catch { return { status_code: 503, body: { error: "compression_recovery_unavailable" } }; } + }; + sdk.registerFunction("api::compression-status", handler(false)); + sdk.registerFunction("api::compression-retry", handler(true)); +} diff --git a/src/triggers/events.ts b/src/triggers/events.ts index 352ed46bf..fc6072c52 100644 --- a/src/triggers/events.ts +++ b/src/triggers/events.ts @@ -13,6 +13,7 @@ import { isConsolidationEnabled, } from "../config.js"; import { logger } from "../logger.js"; +import { requestSummaryRefresh } from "../functions/summary-state.js"; import { noteMemoryChange, noteSessionChange } from "../state/viewer-counts.js"; // Global marker recording when corpus consolidation last ran, used to debounce @@ -196,6 +197,7 @@ export function registerEventTriggers(sdk: IIIClient, kv: StateKV): void { const session = await kv.get(KV.sessions, sessionId); if (!session || session.id !== sessionId) return "not_found" as const; if (session.status === "completed") return "already_completed" as const; + await requestSummaryRefresh(kv, sessionId); await kv.update(KV.sessions, sessionId, [ { type: "set", path: "endedAt", value: new Date().toISOString() }, { type: "set", path: "status", value: "completed" }, diff --git a/src/types.ts b/src/types.ts index 25c5a999a..f7619cebe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,14 @@ export interface ObservationSource { payload?: unknown; } +export interface ObservationCompression { + status: "pending" | "failed" | "complete"; + revision: string; + attempts: number; + lastAttemptAt?: string; + error?: "dispatch_failed" | "compression_failed" | "parse_failed" | "validation_failed" | "source_unavailable"; +} + export interface CompressedObservation { id: string; sessionId: string; @@ -104,6 +112,7 @@ export interface CompressedObservation { origin?: Origin; captureKey?: string; compressedAt?: string; + compression?: ObservationCompression; } export type ObservationType = @@ -148,6 +157,15 @@ export interface Memory { origin?: Origin; } +export interface SessionSummaryRepair { + sessionId: string; + token: string; + dueAt: number; + attempts: number; + compressionCursor?: string; + lastError?: "pending" | "no_provider" | "summary_failed" | "compression_pending" | "source_unavailable" | "recovery_failed"; +} + export interface SessionSummary { sessionId: string; project: string; @@ -159,6 +177,7 @@ export interface SessionSummary { concepts: string[]; observationCount: number; coveredThrough?: string; + coveredFingerprint?: string; } export type HookType = diff --git a/test/cli-engine-startup.test.ts b/test/cli-engine-startup.test.ts index 78938bc43..fe738f14b 100644 --- a/test/cli-engine-startup.test.ts +++ b/test/cli-engine-startup.test.ts @@ -107,7 +107,7 @@ describe("fresh native engine startup", () => { }); it("keeps AGENTMEMORY_URL client-only and lets local port flags win", () => { - expect(source).toContain('const URL_CLIENT_COMMANDS = new Set(["status", "doctor", "mcp", "capture", "graph-index"])'); + expect(source).toContain('const URL_CLIENT_COMMANDS = new Set(["status", "doctor", "mcp", "capture", "compression", "graph-index"])'); expect(source).toContain("hasExplicitLocalPortOverride"); expect(source).toContain("shouldUseAgentmemoryUrl()"); expect(source).toContain('process.env["III_REST_PORT"] = String(base)'); diff --git a/test/compress-viewer-stream.test.ts b/test/compress-viewer-stream.test.ts index 6d7c47554..5c0660e79 100644 --- a/test/compress-viewer-stream.test.ts +++ b/test/compress-viewer-stream.test.ts @@ -11,7 +11,7 @@ vi.mock("../src/functions/search.js", () => ({ })); import { registerCompressFunction } from "../src/functions/compress.js"; -import { STREAM } from "../src/state/schema.js"; +import { KV, STREAM } from "../src/state/schema.js"; import type { MemoryProvider, RawObservation } from "../src/types.js"; const VALID_COMPRESS_XML = `command_run @@ -25,22 +25,21 @@ const VALID_COMPRESS_XML = `command_run function mockKV() { const store = new Map>(); return { - get: async () => null, + get: async (scope: string, key: string) => store.get(scope)?.get(key) ?? null, set: async (scope: string, key: string, data: unknown) => { if (!store.has(scope)) store.set(scope, new Map()); store.get(scope)!.set(key, data); return data; }, delete: async () => {}, - list: async () => [], + list: async (scope: string) => [...(store.get(scope)?.values() ?? [])], }; } describe("compress no longer writes a per-session mem-live group", () => { it("only targets the viewer group on stream:: calls", async () => { const trigger = vi.fn(async () => undefined); - const sdk = { trigger } as never; - const kv = mockKV() as never; + const kv = mockKV(); const provider: MemoryProvider = { name: "mock", @@ -65,7 +64,7 @@ describe("compress no longer writes a per-session mem-live group", () => { if (id === "mem::compress") compressCallback = fn; }, }; - registerCompressFunction(registerSdk as never, kv, provider); + registerCompressFunction(registerSdk as never, kv as never, provider); expect(compressCallback).not.toBeNull(); const sessionId = "ses_compress_stream_test"; @@ -80,6 +79,7 @@ describe("compress no longer writes a per-session mem-live group", () => { raw: {}, }; + await kv.set(KV.observations(sessionId), raw.id, raw); await compressCallback!({ observationId: "obs_1", sessionId, diff --git a/test/compression-command.test.ts b/test/compression-command.test.ts new file mode 100644 index 000000000..72027e1d8 --- /dev/null +++ b/test/compression-command.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runCompressionCommand } from "../src/cli/compression.js"; + +afterEach(() => vi.unstubAllGlobals()); + +function fixture(result: unknown = { success: true, items: [], dryRun: true }) { + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(result), { status: 200 })); + vi.stubGlobal("fetch", fetch); + const log = vi.fn(); + const run = (args: string[], secret?: string) => runCompressionCommand({ base: "http://127.0.0.1:18999", args, secret, log }); + return { fetch, log, run }; +} + +describe("compression CLI", () => { + it("defaults to authenticated inspection with encoded project and agent filters", async () => { + const f = fixture(); + expect(await f.run(["--project", "a/b & c", "--agent-id", "agent/one", "--limit", "2"], "synthetic-credential")).toBe(0); + const [url, options] = f.fetch.mock.calls[0]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/agentmemory/compression"); + expect(Object.fromEntries(parsed.searchParams)).toEqual({ project: "a/b & c", agentId: "agent/one", limit: "2" }); + expect(options).toMatchObject({ method: "GET", headers: { Authorization: "Bearer synthetic-credential" } }); + expect(options.body).toBeUndefined(); + expect(f.log.mock.calls.flat().join(" ")).not.toContain("synthetic-credential"); + }); + + it("retries only when explicitly requested and returns the server's unsuccessful result", async () => { + const result = { success: false, items: [{ sessionId: "s", observationId: "o", status: "failed", retryable: true, outcome: "failed", error: "compression_failed" }], attempted: 1, recovered: 0, failed: 1 }; + const f = fixture(result); + expect(await f.run(["--retry", "--session", "s", "--project", "p", "--json"])).toBe(1); + const [url, options] = f.fetch.mock.calls[0]; + expect(url).toBe("http://127.0.0.1:18999/agentmemory/compression/retry"); + expect(options.method).toBe("POST"); + expect(JSON.parse(options.body)).toEqual({ sessionId: "s", project: "p", limit: 10 }); + expect(options.headers.Authorization).toBeUndefined(); + expect(JSON.parse(f.log.mock.calls[0][0])).toEqual(result); + }); + + it("treats an empty cursor as the first page and accepts global port selectors", async () => { + const f = fixture(); + expect(await f.run(["--session", "s", "--cursor", "", "--dry-run", "--port", "18888", "--instance", "2"])).toBe(0); + expect(new URL(f.fetch.mock.calls[0][0]).searchParams.has("cursor")).toBe(false); + expect(f.fetch.mock.calls[0][1].method).toBe("GET"); + }); + + it("accepts equals-form global port and instance selectors", async () => { + const f = fixture(); + expect(await f.run(["--session", "s", "--port=18888", "--instance=0"])).toBe(0); + expect(f.fetch.mock.calls[0][1].method).toBe("GET"); + }); + + it.each([ + [], ["--retry"], ["--project", ""], ["--project", " "], ["--session"], ["--session", "--json"], + ["--session", "s", "--session", "s2"], ["--session", "s", "--wat"], + ["--session", "s", "--retry", "--dry-run"], ["--session", "s", "--port"], + ["--session", "s", "--port="], ["--session", "s", "--instance="], + ["--session", "s", "--cursor", "bad-cursor"], ["--session", "s", "--agent-id", ""], + ...["0", "101", "-1", "1.5", "1e2", "Infinity", "2x"].map((limit) => ["--session", "s", "--limit", limit]), + ])("rejects invalid options without contacting the service: %j", async (...args: string[]) => { + const f = fixture(); + expect(await f.run(args)).toBe(1); + expect(f.fetch).not.toHaveBeenCalled(); + }); + + it.each([401, 403, 404, 503])("reports HTTP %i without echoing sensitive server details", async (status) => { + const f = fixture(); + f.fetch.mockResolvedValue(new Response("private provider error", { status })); + expect(await f.run(["--session", "s", "--json"])).toBe(1); + expect(JSON.parse(f.log.mock.calls[0][0])).toEqual({ success: false, error: `Compression endpoint returned HTTP ${status}` }); + }); + + it.each([{ items: [] }, { success: true }, [], null])("rejects malformed successful responses: %j", async (result) => { + const f = fixture(result); + expect(await f.run(["--session", "s"])).toBe(1); + }); + + it("reports a network failure with an unsuccessful exit", async () => { + const f = fixture(); + f.fetch.mockRejectedValue(new Error("Connection refused")); + expect(await f.run(["--session", "s", "--json"])).toBe(1); + expect(JSON.parse(f.log.mock.calls[0][0])).toEqual({ success: false, error: "Connection refused" }); + }); + + it("shows source availability and the continuation cursor during inspection", async () => { + const f = fixture({ success: true, items: [{ sessionId: "s", observationId: "o", status: "failed", retryable: false, error: "source_unavailable" }], nextCursor: "continuation" }); + expect(await f.run(["--session", "s"])).toBe(0); + const output = f.log.mock.calls.flat().join("\n"); + expect(output).toContain("[source unavailable]"); + expect(output).toContain("--cursor continuation"); + expect(output).not.toContain("Use --retry"); + }); + + it("explains how to restart pagination when an imported-ID cursor anchor changes", async () => { + const f = fixture({ success: false, error: "cursor_stale", items: [] }); + expect(await f.run(["--project", "p"])).toBe(1); + expect(f.log.mock.calls.flat().join("\n")).toContain("Restart without --cursor"); + }); +}); diff --git a/test/compression-recovery.test.ts b/test/compression-recovery.test.ts new file mode 100644 index 000000000..308ccfda3 --- /dev/null +++ b/test/compression-recovery.test.ts @@ -0,0 +1,478 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerCompressFunction, COMPRESSION_TIMEOUT_MS } from "../src/functions/compress.js"; +import { registerObserveFunction } from "../src/functions/observe.js"; +import { registerCompressionApi } from "../src/triggers/compression.js"; +import { registerApiTriggers } from "../src/triggers/api.js"; +import { buildSyntheticCompression } from "../src/functions/compress-synthetic.js"; +import { observationRevision, pendingCompression } from "../src/functions/compression-state.js"; +import { getSearchIndex, setEmbeddingProvider, setVectorIndex, vectorIndexRemove } from "../src/functions/search.js"; +import { VectorIndex } from "../src/state/vector-index.js"; +import { KV } from "../src/state/schema.js"; +import { withKeyedLock } from "../src/state/keyed-mutex.js"; +import { SAFE_PAYLOAD_BYTES, payloadByteLength } from "../src/state/frame-guard.js"; +import type { MemoryProvider, RawObservation } from "../src/types.js"; + +vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); +const xml = 'file_readRecovered cobalt configurationThe cobalt port is 9921.Read the cobalt deployment configuration successfully.configuration6'; +const raw = (id = "obs-recovery", sessionId = "session-recovery"): RawObservation => ({ + id, sessionId, timestamp: "2026-10-13T00:00:00Z", hookType: "post_tool_use", toolName: "Read", + toolOutput: "cobalt retained evidence hidden-canary", raw: {}, +}); +function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } +function store() { + const values = new Map>(); + return { + get: async (scope: string, id: string) => structuredClone(values.get(scope)?.get(id) ?? null), + list: async (scope: string) => structuredClone([...(values.get(scope)?.values() ?? [])]), + set: async (scope: string, id: string, value: any) => { + if (!values.has(scope)) values.set(scope, new Map()); + values.get(scope)!.set(id, structuredClone(value)); return value; + }, + update: async (scope: string, id: string, updates: any[]) => { + const row = values.get(scope)!.get(id); + for (const update of updates) { if (update.type === "remove") delete row[update.path]; else row[update.path] = update.value; } + }, + delete: async (scope: string, id: string) => { values.get(scope)?.delete(id); }, + }; +} +function rig(provider: MemoryProvider = { name: "test", compress: vi.fn(async () => xml), summarize: vi.fn() }, kv = store()) { + const handlers = new Map Promise>(); + const pending: Promise[] = []; + const sdk = { + registerFunction: (id: string, fn: any) => handlers.set(id, fn), registerTrigger: vi.fn(), + trigger: vi.fn(async (input: any) => { + const result = handlers.get(input.function_id)?.({ ...input.payload, _caller_worker_id: "fixture-worker" }); + if (input.function_id === "mem::compress" && input.action) { if (result) pending.push(result); return undefined; } + return result; + }), + }; + registerCompressFunction(sdk as never, kv as never, provider); + registerObserveFunction(sdk as never, kv as never); + registerCompressionApi(sdk as never, "test-auth"); + const call = (id: string, input: any) => handlers.get(id)!(input); + const seed = async (id = "obs-recovery", sessionId = "session-recovery", project = "project-a", agentId = "agent-a") => { + await kv.set(KV.sessions, sessionId, { id: sessionId, project, agentId, observationCount: 1, status: "active", startedAt: raw().timestamp }); + await kv.set(KV.observations(sessionId), id, raw(id, sessionId)); + }; + return { kv, sdk, provider, call, seed, flush: () => Promise.all(pending.splice(0)) }; +} +beforeEach(() => { getSearchIndex().clear(); setEmbeddingProvider(null); setVectorIndex(null); vi.stubEnv("AGENTMEMORY_AUTO_COMPRESS", "true"); }); +afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers(); setEmbeddingProvider(null); setVectorIndex(null); }); + +describe("compression recovery", () => { + it("keeps failed automatic capture searchable, then recovers after restart without changing identity or counts", async () => { + const provider = { name: "test", compress: vi.fn(async () => { throw new Error("private-provider-body"); }), summarize: vi.fn() }; + const r = rig(provider); + const captured = await r.call("mem::observe", { sessionId: "session-recovery", project: "project-a", cwd: "/fixture", hookType: "post_tool_use", timestamp: raw().timestamp, + data: { tool_name: "Read", tool_output: raw().toolOutput } }); + await r.flush(); + const original = await r.kv.get(KV.observations("session-recovery"), captured.observationId); + expect(original.compression).toMatchObject({ status: "failed", attempts: 1, error: "compression_failed" }); + expect(original.narrative).toContain("cobalt"); + expect(getSearchIndex().has(captured.observationId)).toBe(true); + expect(JSON.stringify(original)).not.toContain("hidden-canary"); + const restarted = rig(undefined, r.kv); + const inspect = await restarted.call("mem::compression-status", { sessionId: "session-recovery" }); + expect(inspect).toMatchObject({ dryRun: true, attempted: 0, items: [{ status: "failed", retryable: true }] }); + expect(restarted.provider.compress).not.toHaveBeenCalled(); + expect(JSON.stringify(inspect)).not.toContain("cobalt"); + expect(await restarted.call("mem::compression-retry", { sessionId: "session-recovery" })).toMatchObject({ success: true, recovered: 1 }); + const recovered = await r.kv.get(KV.observations("session-recovery"), captured.observationId); + expect(recovered).toMatchObject({ id: original.id, timestamp: original.timestamp, source: original.source, compression: { status: "complete", attempts: 2 } }); + expect(await r.kv.get(KV.sessions, "session-recovery")).toMatchObject({ observationCount: 1 }); + expect(await restarted.call("mem::compression-retry", { sessionId: "session-recovery" })).toMatchObject({ attempted: 0, recovered: 0 }); + expect(restarted.provider.compress).toHaveBeenCalledOnce(); + }); + + it("accepts the engine caller envelope only at the internal function boundary", async () => { + const r = rig(); await r.seed(); + expect(await r.call("mem::compression-status", { sessionId: "session-recovery", _caller_worker_id: "engine-worker" })).toMatchObject({ dryRun: true, items: [{ status: "raw" }] }); + expect(await r.call("mem::compression-retry", { sessionId: "session-recovery", _caller_worker_id: "engine-worker" })).toMatchObject({ recovered: 1 }); + }); + + it.each(["provider", "parse"])("preserves oversized legacy raw through %s failure and restart until successful bounded recovery", async (failure) => { + const r = rig({ name: "test", compress: vi.fn(async () => { + if (failure === "provider") throw new Error("down"); + return "invalid output"; + }), summarize: vi.fn() }); + await r.seed("a"); await r.seed("b"); + const original = { ...raw("a"), toolOutput: raw().toolOutput + "z".repeat(40_000) + "preserved-tail-marker" }; + await r.kv.set(KV.observations("session-recovery"), "a", original); + const result = await r.call("mem::compression-retry", { project: "project-a", limit: 100 }); + expect(result).toMatchObject({ success: false, attempted: 1, recovered: 0, failed: 1, items: [{ truncated: true, retryable: true }] }); + expect(r.provider.compress).toHaveBeenCalledTimes(failure === "provider" ? 1 : 2); + const failed = await r.kv.get(KV.observations("session-recovery"), "a"); + const { compression, ...retained } = failed; + expect(retained).toEqual(original); + expect(compression).toMatchObject({ attempts: 1, status: "failed", error: failure === "provider" ? "compression_failed" : "parse_failed" }); + expect(getSearchIndex().has("a")).toBe(false); + expect(r.sdk.trigger.mock.calls.some(([input]) => input.function_id === "stream::send")).toBe(false); + const modelCalls = JSON.stringify(vi.mocked(r.provider.compress).mock.calls); + for (const canary of ["hidden-canary", "preserved-tail-marker"]) { + expect(modelCalls).not.toContain(canary); + expect(JSON.stringify(result)).not.toContain(canary); + } + expect(await r.kv.get(KV.observations("session-recovery"), "b")).toHaveProperty("hookType"); + const restarted = rig(undefined, r.kv); + const inspect = await restarted.call("api::compression-status", { method: "GET", headers: { authorization: "Bearer test-auth" }, query_params: { sessionId: "session-recovery" } }); + expect(inspect).toMatchObject({ status_code: 200, body: { items: [{ observationId: "a", status: "failed", attempts: 1, retryable: true }, { observationId: "b", status: "raw", attempts: 0 }] } }); + expect(JSON.stringify(inspect)).not.toContain("hidden-canary"); + expect(JSON.stringify(inspect)).not.toContain("preserved-tail-marker"); + expect(restarted.provider.compress).not.toHaveBeenCalled(); + expect(await restarted.call("mem::compression-retry", { sessionId: "session-recovery", limit: 1 })).toMatchObject({ success: true, recovered: 1, items: [{ truncated: true }] }); + const recovered = await r.kv.get(KV.observations("session-recovery"), "a"); + expect(recovered).toMatchObject({ id: original.id, timestamp: original.timestamp, source: { truncated: true }, compression: { attempts: 2, status: "complete" } }); + expect(recovered).not.toHaveProperty("toolOutput"); + expect(Buffer.byteLength(JSON.stringify(recovered.source))).toBeLessThanOrEqual(16 * 1024); + expect(JSON.stringify(recovered)).not.toContain("hidden-canary"); + expect(JSON.stringify(restarted.sdk.trigger.mock.calls)).not.toContain("hidden-canary"); + expect(JSON.stringify(restarted.sdk.trigger.mock.calls)).not.toContain("preserved-tail-marker"); + expect(getSearchIndex().has("a")).toBe(true); + expect(await r.kv.get(KV.sessions, "session-recovery")).toMatchObject({ observationCount: 1 }); + }); + + it("single-flights concurrent retries within one store while keeping independent stores separate", async () => { + const first = deferred(); const second = deferred(); + const p1 = { name: "test", compress: vi.fn(() => first.promise), summarize: vi.fn() }; + const p2 = { name: "test", compress: vi.fn(() => second.promise), summarize: vi.fn() }; + const a = rig(p1); const b = rig(p2); await a.seed(); await b.seed(); + const tasks = [a.call("mem::compression-retry", { sessionId: "session-recovery" }), a.call("mem::compression-retry", { sessionId: "session-recovery" }), b.call("mem::compression-retry", { sessionId: "session-recovery" })]; + await vi.waitFor(() => { expect(p1.compress).toHaveBeenCalledOnce(); expect(p2.compress).toHaveBeenCalledOnce(); }); + first.resolve(xml); second.resolve(xml); await Promise.all(tasks); + expect(await a.kv.get(KV.observations("session-recovery"), raw().id)).toMatchObject({ compression: { attempts: 1, status: "complete" } }); + expect(await b.kv.get(KV.observations("session-recovery"), raw().id)).toMatchObject({ compression: { attempts: 1, status: "complete" } }); + }); + + it.each(["project", "agentId"])("does not return shared compression outside a joining caller's %s scope", async (field) => { + const model = deferred(); const provider = { name: "test", compress: vi.fn(() => model.promise), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); + const automatic = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id }); + await vi.waitFor(() => expect(provider.compress).toHaveBeenCalledOnce()); + const scoped = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id, + scope: { [field]: "unrelated" } }); + model.resolve(xml); + expect(await automatic).toMatchObject({ success: true }); + expect(await scoped).toMatchObject({ skipped: true }); + expect(await scoped).not.toHaveProperty("compressed"); + expect(provider.compress).toHaveBeenCalledOnce(); + }); + + it.each(["project", "agentId"])("revalidates a joining retry after session-start changes its %s", async (field) => { + const model = deferred(); const provider = { name: "test", compress: vi.fn(() => model.promise), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); + const automatic = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id }); + await vi.waitFor(() => expect(provider.compress).toHaveBeenCalledOnce()); + const claimed = await r.kv.get(KV.observations("session-recovery"), raw().id); + const scoped = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id, + expectedRevision: observationRevision(claimed), scope: { project: "project-a", agentId: "agent-a" } }); + const retry = r.call("mem::compression-retry", { project: "project-a", agentId: "agent-a" }); + await vi.waitFor(() => expect(r.sdk.trigger.mock.calls.some(([input]) => input.function_id === "mem::compress")).toBe(true)); + registerApiTriggers(r.sdk as never, r.kv as never, "test-auth"); + r.sdk.registerFunction("mem::context", async () => ({ context: "" })); + expect(await r.call("api::session::start", { method: "POST", headers: { authorization: "Bearer test-auth" }, + body: { sessionId: "session-recovery", project: "project-a", agentId: "agent-a", [field]: `${field}-b`, cwd: "/fixture-b" } })).toMatchObject({ status_code: 200 }); + model.resolve(xml); + expect(await automatic).toMatchObject({ success: true }); + expect(await scoped).toMatchObject({ skipped: true }); + expect(await scoped).not.toHaveProperty("compressed"); + expect(await retry).toMatchObject({ recovered: 0, items: [{ outcome: "changed" }] }); + expect(await r.kv.get(KV.observations("session-recovery"), raw().id)).toMatchObject({ compression: { status: "complete" } }); + }); + + it("rejects an obsolete expected revision even while the requested observation is active", async () => { + const model = deferred(); const provider = { name: "test", compress: vi.fn(() => model.promise), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); + const automatic = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id }); + await vi.waitFor(() => expect(provider.compress).toHaveBeenCalledOnce()); + const obsolete = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id, expectedRevision: "obsolete" }); + model.resolve(xml); await automatic; + expect(await obsolete).toMatchObject({ skipped: true }); + expect(await obsolete).not.toHaveProperty("compressed"); + expect(provider.compress).toHaveBeenCalledOnce(); + }); + + it("keeps an observation's explicit agent scope when the session agent changes", async () => { + const model = deferred(); const provider = { name: "test", compress: vi.fn(() => model.promise), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); + await r.kv.set(KV.observations("session-recovery"), raw().id, { ...raw(), agentId: "agent-a" }); + const automatic = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id }); + await vi.waitFor(() => expect(provider.compress).toHaveBeenCalledOnce()); + const retry = r.call("mem::compression-retry", { project: "project-a", agentId: "agent-a" }); + await vi.waitFor(() => expect(r.sdk.trigger.mock.calls.some(([input]) => input.function_id === "mem::compress")).toBe(true)); + const session = await r.kv.get(KV.sessions, "session-recovery"); + await withKeyedLock("obs:session-recovery", () => r.kv.set(KV.sessions, session.id, { ...session, agentId: "agent-b" })); + model.resolve(xml); await automatic; + expect(await retry).toMatchObject({ recovered: 1 }); + expect(provider.compress).toHaveBeenCalledOnce(); + }); + + it("retries the current replacement once after an obsolete shared attempt finishes", async () => { + const model = deferred(); + const provider = { name: "test", compress: vi.fn().mockImplementationOnce(() => model.promise).mockResolvedValue(xml), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); + const automatic = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id }); + await vi.waitFor(() => expect(provider.compress).toHaveBeenCalledOnce()); + const replacement = { ...buildSyntheticCompression({ ...raw(), toolOutput: "replacement source" }), compression: pendingCompression() }; + await withKeyedLock("obs:session-recovery", () => r.kv.set(KV.observations("session-recovery"), raw().id, replacement)); + const retries = Array.from({ length: 2 }, () => r.call("mem::compression-retry", { project: "project-a", agentId: "agent-a" })); + await vi.waitFor(() => expect(r.sdk.trigger.mock.calls.filter(([input]) => input.function_id === "mem::compress")).toHaveLength(2)); + model.resolve(xml); + expect(await automatic).toMatchObject({ skipped: true, error: "observation_changed" }); + for (const result of await Promise.all(retries)) expect(result).toMatchObject({ recovered: 1 }); + expect(provider.compress).toHaveBeenCalledTimes(2); + expect(provider.compress.mock.calls[1][1]).toContain("replacement source"); + expect(await r.kv.get(KV.observations("session-recovery"), raw().id)).toMatchObject({ source: replacement.source, compression: { status: "complete", attempts: 1 } }); + const restarted = rig(undefined, r.kv); + expect(await restarted.call("mem::compression-retry", { sessionId: "session-recovery" })).toMatchObject({ attempted: 0 }); + expect(restarted.provider.compress).not.toHaveBeenCalled(); + }); + + it("does not share observations with equal IDs across sessions", async () => { + const model = deferred(); const provider = { name: "test", compress: vi.fn(() => model.promise), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); await r.seed(raw().id, "other-session", "project-b", "agent-b"); + const tasks = [r.call("mem::compression-retry", { project: "project-a", agentId: "agent-a" }), + r.call("mem::compression-retry", { project: "project-b", agentId: "agent-b" })]; + await vi.waitFor(() => expect(provider.compress).toHaveBeenCalledTimes(2)); + model.resolve(xml); + for (const result of await Promise.all(tasks)) expect(result).toMatchObject({ recovered: 1 }); + }); + + it("does not restart a joining retry when its replacement is deleted", async () => { + const model = deferred(); const provider = { name: "test", compress: vi.fn(() => model.promise), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); + const automatic = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id }); + await vi.waitFor(() => expect(provider.compress).toHaveBeenCalledOnce()); + const replacement = { ...buildSyntheticCompression(raw()), compression: pendingCompression() }; + await withKeyedLock("obs:session-recovery", () => r.kv.set(KV.observations("session-recovery"), raw().id, replacement)); + const retry = r.call("mem::compression-retry", { sessionId: "session-recovery" }); + await vi.waitFor(() => expect(r.sdk.trigger.mock.calls.some(([input]) => input.function_id === "mem::compress")).toBe(true)); + await withKeyedLock("obs:session-recovery", () => r.kv.delete(KV.observations("session-recovery"), raw().id)); + model.resolve(xml); await automatic; + expect(await retry).toMatchObject({ recovered: 0, items: [{ outcome: "changed" }] }); + expect(provider.compress).toHaveBeenCalledOnce(); + expect(await r.kv.get(KV.observations("session-recovery"), raw().id)).toBeNull(); + }); + + it("includes time spent waiting for obsolete work in a replacement retry's deadline", async () => { + vi.useFakeTimers(); + const oldModel = deferred(); const newModel = deferred(); + const provider = { name: "test", compress: vi.fn().mockImplementationOnce(() => oldModel.promise).mockImplementation(() => newModel.promise), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); + const automatic = r.call("mem::compress", { sessionId: "session-recovery", observationId: raw().id }); + await vi.advanceTimersByTimeAsync(10_000); + const replacement = { ...buildSyntheticCompression(raw()), compression: pendingCompression() }; + await withKeyedLock("obs:session-recovery", () => r.kv.set(KV.observations("session-recovery"), raw().id, replacement)); + const retry = r.call("mem::compression-retry", { sessionId: "session-recovery" }); + await vi.advanceTimersByTimeAsync(30_000); + oldModel.resolve(xml); await vi.advanceTimersByTimeAsync(1); await automatic; + expect(provider.compress).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(30_000); + expect(await retry).toMatchObject({ success: false, failed: 1, recovered: 0 }); + newModel.resolve(xml); await vi.advanceTimersByTimeAsync(1); + expect(await r.kv.get(KV.observations("session-recovery"), raw().id)).toMatchObject({ source: replacement.source, compression: { status: "failed", attempts: 1 } }); + }); + + it.each([["delete", xml], ["replace", xml], ["delete", "invalid"], ["replace", "invalid"]])("discards a late model result after %s (%s)", async (operation, output) => { + const model = deferred(); const provider = { name: "test", compress: vi.fn(() => model.promise), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); + const task = r.call("mem::compression-retry", { sessionId: "session-recovery" }); + await vi.waitFor(() => expect(provider.compress).toHaveBeenCalledOnce()); + const replacement = { ...buildSyntheticCompression(raw()), title: "replacement kept", compression: pendingCompression() }; + await withKeyedLock("obs:session-recovery", async () => { + if (operation === "delete") await r.kv.delete(KV.observations("session-recovery"), raw().id); + else await r.kv.set(KV.observations("session-recovery"), raw().id, replacement); + getSearchIndex().remove(raw().id); vectorIndexRemove(raw().id); + }); + model.resolve(output); + expect(await task).toMatchObject({ recovered: 0, items: [{ outcome: "changed" }] }); + expect(await r.kv.get(KV.observations("session-recovery"), raw().id)).toEqual(operation === "delete" ? null : replacement); + expect(getSearchIndex().has(raw().id)).toBe(false); + }); + + it("rechecks project and agent scope before committing a pending model result", async () => { + const model = deferred(); const provider = { name: "test", compress: vi.fn(() => model.promise), summarize: vi.fn() }; + const r = rig(provider); await r.seed(); + const task = r.call("mem::compression-retry", { project: "project-a", agentId: "agent-a" }); + await vi.waitFor(() => expect(provider.compress).toHaveBeenCalledOnce()); + const session = await r.kv.get(KV.sessions, "session-recovery"); + await r.kv.set(KV.sessions, session.id, { ...session, project: "project-b", agentId: "agent-b" }); + model.resolve(xml); + expect(await task).toMatchObject({ recovered: 0, items: [{ outcome: "changed" }] }); + const { compression, ...retained } = await r.kv.get(KV.observations(session.id), raw().id); + expect(compression.status).toBe("pending"); + expect(retained).toEqual(raw()); + expect(getSearchIndex().has(raw().id)).toBe(false); + }); + + it("does not restore a vector after deletion while embedding is pending", async () => { + const embedding = deferred(); const embed = vi.fn(() => embedding.promise); const vectors = new VectorIndex(); + setVectorIndex(vectors); setEmbeddingProvider({ name: "test", dimensions: 2, embed } as never); + const r = rig(); await r.seed(); const task = r.call("mem::compression-retry", { sessionId: "session-recovery" }); + await vi.waitFor(() => expect(embed).toHaveBeenCalledOnce()); + await withKeyedLock("obs:session-recovery", async () => { await r.kv.delete(KV.observations("session-recovery"), raw().id); getSearchIndex().remove(raw().id); vectorIndexRemove(raw().id); }); + embedding.resolve(new Float32Array([1, 0])); + expect(await task).toMatchObject({ recovered: 0, items: [{ outcome: "changed" }] }); + expect(r.sdk.trigger.mock.calls.some(([input]) => input.function_id === "stream::send")).toBe(false); + expect(vectors.has(raw().id)).toBe(false); + expect(await r.kv.get(KV.observations("session-recovery"), raw().id)).toBeNull(); + }); + + it("never sends or reports recovery for missing source, and leaves default synthetic rows alone", async () => { + const r = rig(); await r.seed(); + const row = buildSyntheticCompression(raw()); delete row.source; + await r.kv.set(KV.observations(row.sessionId), row.id, { ...row, compression: pendingCompression() }); + await r.kv.set(KV.observations(row.sessionId), "default", { ...buildSyntheticCompression(raw("default")) }); + const result = await r.call("mem::compression-retry", { sessionId: row.sessionId }); + expect(result).toMatchObject({ attempted: 0, recovered: 0, items: [{ retryable: false, error: "source_unavailable" }] }); + expect(result.items).toHaveLength(1); expect(r.provider.compress).not.toHaveBeenCalled(); + }); + + it("leaves legacy raw unchanged when source budget is exhausted and recovers after budget is freed", async () => { + const r = rig(); await r.seed(); + const original = await r.kv.get(KV.observations("session-recovery"), raw().id); + const seed = buildSyntheticCompression(raw("source-budget")); + await r.kv.set(KV.observations(seed.sessionId), seed.id, { ...seed, source: { ...seed.source, toolOutput: "x".repeat(8 * 1024 * 1024) } }); + expect(await r.call("mem::compression-retry", { sessionId: seed.sessionId })).toMatchObject({ + recovered: 0, failed: 1, items: [{ retryable: false, error: "source_unavailable" }], + }); + expect(r.provider.compress).not.toHaveBeenCalled(); + expect(await r.kv.get(KV.observations(seed.sessionId), raw().id)).toEqual(original); + expect(getSearchIndex().has(raw().id)).toBe(false); + await r.kv.delete(KV.observations(seed.sessionId), seed.id); + expect(await r.call("mem::compression-retry", { sessionId: seed.sessionId })).toMatchObject({ success: true, recovered: 1 }); + expect(await r.kv.get(KV.observations(seed.sessionId), raw().id)).toMatchObject({ compression: { status: "complete", attempts: 1 } }); + }); + + it("leaves legacy rows with no usable source unchanged", async () => { + const r = rig(); await r.seed(); + const original = { ...raw(), toolOutput: undefined }; + await r.kv.set(KV.observations(original.sessionId), original.id, original); + expect(await r.call("mem::compress", { sessionId: original.sessionId, observationId: original.id })).toMatchObject({ success: false, error: "source_unavailable", retryable: false }); + expect(await r.kv.get(KV.observations(original.sessionId), original.id)).toEqual(original); + expect(getSearchIndex().has(original.id)).toBe(false); + expect(r.provider.compress).not.toHaveBeenCalled(); + }); + + it.each([xml, "invalid late output"])("bounds hung model calls without starting correction calls after timeout (%s)", async (lateOutput) => { + vi.useFakeTimers(); const model = deferred(); const r = rig({ name: "test", compress: vi.fn(() => model.promise), summarize: vi.fn() }); await r.seed(); + const task = r.call("mem::compression-retry", { sessionId: "session-recovery" }); + await vi.advanceTimersByTimeAsync(COMPRESSION_TIMEOUT_MS + 1); + expect(await task).toMatchObject({ success: false, failed: 1 }); + model.resolve(lateOutput); await vi.advanceTimersByTimeAsync(1); + expect(r.provider.compress).toHaveBeenCalledOnce(); + const { compression, ...retained } = await r.kv.get(KV.observations("session-recovery"), raw().id); + expect(compression).toMatchObject({ status: "failed", attempts: 1 }); + expect(retained).toEqual(raw()); + }); + + it("stops starting work after the batch budget while allowing its current attempt to finish", async () => { + vi.useFakeTimers(); + const provider = { name: "test", compress: vi.fn(() => new Promise((resolve) => setTimeout(() => resolve(xml), 29_000))), summarize: vi.fn() }; + const r = rig(provider); for (const id of ["a", "b", "c"]) await r.seed(id); + const task = r.call("mem::compression-retry", { sessionId: "session-recovery", limit: 100 }); + await vi.advanceTimersByTimeAsync(60_000); + expect(await task).toMatchObject({ recovered: 2, attempted: 2, success: true }); + expect(provider.compress).toHaveBeenCalledTimes(2); + expect(await r.kv.get(KV.observations("session-recovery"), "c")).toHaveProperty("hookType"); + }); + + it("applies project and agent filters and paginates mixed identifiers without skipping", async () => { + const r = rig(); const ids = ["Z", "a", "_", "Á", "😀", "-", "A"]; + for (const id of ids) await r.seed(id); + await r.seed("other-project", "other-session", "project-b", "agent-a"); + await r.seed("other-agent", "agent-session", "project-a", "agent-b"); + let cursor: string | undefined; const seen: string[] = []; + do { + const result = await r.call("mem::compression-status", { project: "project-a", agentId: "agent-a", limit: 2, ...(cursor ? { cursor } : {}) }); + seen.push(...result.items.map((item: any) => item.observationId)); cursor = result.nextCursor; + } while (cursor); + expect(seen).toEqual([...ids].sort()); + const first = await r.call("mem::compression-status", { project: "project-a", limit: 1 }); + await expect(r.call("mem::compression-status", { project: "project-b", cursor: first.nextCursor })).rejects.toThrow("Invalid cursor"); + expect(await r.call("mem::compression-status", { sessionId: "session-recovery", project: "project-b" })).toMatchObject({ items: [] }); + }); + + it.each(["界".repeat(800), "\u0001".repeat(1023) + "s"])("round-trips cursors containing long Unicode or escaped identifiers", async (sessionId) => { + const r = rig(); const observationId = "😀".repeat(512); + await r.seed(observationId, sessionId); await r.seed(observationId + "z", sessionId); + const first = await r.call("mem::compression-status", { sessionId, limit: 1 }); + expect(first.nextCursor.length).toBeGreaterThan(1024); + const second = await r.call("mem::compression-status", { sessionId, limit: 1, cursor: first.nextCursor }); + expect(second.items.map((item: any) => item.observationId)).toEqual([observationId + "z"]); + expect(await r.call("mem::compression-status", { sessionId, limit: 1, cursor: second.nextCursor })).toMatchObject({ items: [] }); + }); + + it.each(["removed", "project"])("resumes compact imported-ID bookmarks and rejects a changed anchor (%s)", async (change) => { + const r = rig(); const sessionId = "s".repeat(40_000); const observationId = "a".repeat(40_000); + await r.seed(observationId, sessionId); await r.seed("z", sessionId); + const scope = { project: "project-a", agentId: "agent-a", limit: 1 }; + const first = await r.call("mem::compression-status", scope); + expect(first.nextCursor.length).toBeLessThan(1024); + const restarted = rig(undefined, r.kv); + expect(await restarted.call("mem::compression-status", { ...scope, cursor: first.nextCursor })).toMatchObject({ items: [{ observationId: "z" }] }); + if (change === "removed") await r.kv.delete(KV.observations(sessionId), observationId); + else await r.kv.set(KV.sessions, sessionId, { ...await r.kv.get(KV.sessions, sessionId), project: "project-b" }); + expect(await restarted.call("api::compression-status", { method: "GET", headers: { authorization: "Bearer test-auth" }, query_params: { + ...scope, cursor: first.nextCursor, + } })).toMatchObject({ status_code: 200, body: { success: false, error: "cursor_stale", items: [], attempted: 0 } }); + expect(restarted.provider.compress).not.toHaveBeenCalled(); + }); + + it("resumes a scan-limit bookmark even when its anchor is excluded by the agent filter", async () => { + const r = rig(); const sessionId = "s".repeat(40_000); await r.seed("z", sessionId); + for (let n = 0; n < 1000; n++) { + const id = String(n).padStart(4, "0"); + await r.kv.set(KV.observations(sessionId), id, { ...raw(id, sessionId), agentId: "agent-b" }); + } + const scope = { project: "project-a", agentId: "agent-a", limit: 1 }; + const first = await r.call("mem::compression-status", scope); + expect(first).toMatchObject({ items: [], scannedObservations: 1000 }); + expect(first.nextCursor).toBeTruthy(); + expect(await r.call("mem::compression-status", { ...scope, cursor: first.nextCursor })).toMatchObject({ + success: true, items: [{ observationId: "z" }], scannedObservations: 1, + }); + expect(r.provider.compress).not.toHaveBeenCalled(); + }); +}); + +describe("compression REST boundaries", () => { + it("bounds response bytes across sessions even when the candidate count is small", async () => { + const r = rig(); const large = "界".repeat(Math.ceil(SAFE_PAYLOAD_BYTES / 30)); + for (let n = 0; n < 11; n++) { + const sessionId = `frame-${n}`; const id = `${n}-${large}`; + await r.kv.set(KV.sessions, sessionId, { id: sessionId, project: "frame-project", startedAt: raw().timestamp }); + const row = buildSyntheticCompression(raw("fixture", sessionId)); delete row.source; + await r.kv.set(KV.observations(sessionId), id, { ...row, id, compression: pendingCompression() }); + } + const result = await r.call("mem::compression-status", { project: "frame-project", limit: 100 }); + expect(result).toMatchObject({ success: false, oversized: true, limitBytes: SAFE_PAYLOAD_BYTES }); + expect(result.bytes).toBeGreaterThan(SAFE_PAYLOAD_BYTES); + expect(payloadByteLength(result)).toBeLessThan(1024); + expect(result).not.toHaveProperty("items"); + expect(await r.call("api::compression-retry", { method: "POST", headers: { authorization: "Bearer test-auth" }, body: { + project: "frame-project", limit: 100, + } })).toMatchObject({ status_code: 413, body: { oversized: true } }); + expect(await r.call("mem::compression-status", { project: "frame-project", limit: 1 })).toMatchObject({ success: true, items: [{ retryable: false }] }); + expect(r.provider.compress).not.toHaveBeenCalled(); + }); + + it("requires auth on inspection and retry, validates methods and whitelists scope", async () => { + const r = rig(); await r.seed(); + for (const [id, method] of [["api::compression-status", "GET"], ["api::compression-retry", "POST"]]) { + const request = { method, headers: {}, query_params: { sessionId: "session-recovery" }, body: { sessionId: "session-recovery" } }; + expect(await r.call(id, request)).toMatchObject({ status_code: 401 }); + const headers = { authorization: "Bearer test-auth" }; + expect(await r.call(id, { ...request, method: "DELETE", headers })).toMatchObject({ status_code: 405 }); + for (const scope of [{}, { project: " " }, { project: "p", limit: "1x" }, { project: "p", limit: 101 }, { project: "p", raw: { secret: "x" } }, { project: "p", _caller_worker_id: "forged" }, { project: "p", cursor: "bad" }]) { + expect(await r.call(id, { ...request, headers, body: scope, query_params: scope })).toMatchObject({ status_code: 400 }); + } + } + expect(r.provider.compress).not.toHaveBeenCalled(); + expect(await r.call("api::compression-status", { method: "GET", headers: { authorization: "Bearer test-auth" }, query_params: { sessionId: "session-recovery", cursor: "" } })).toMatchObject({ status_code: 200, body: { dryRun: true } }); + }); + it("reports backend failures without echoing raw exception content", async () => { + const r = rig(); vi.spyOn(r.kv, "list").mockRejectedValue(new Error("private-source-body")); + const result = await r.call("api::compression-status", { method: "GET", headers: { authorization: "Bearer test-auth" }, query_params: { project: "project-a" } }); + expect(result).toEqual({ status_code: 503, body: { error: "compression_recovery_unavailable" } }); + }); +}); diff --git a/test/compression-validation.test.ts b/test/compression-validation.test.ts index 42f30dee3..0a17d8989 100644 --- a/test/compression-validation.test.ts +++ b/test/compression-validation.test.ts @@ -31,6 +31,7 @@ const raw: RawObservation = { function rig(provider: MemoryProvider, previous: RawObservation | CompressedObservation = raw) { let stored = structuredClone(previous); const kv = { + get: vi.fn(async () => structuredClone(stored)), list: vi.fn(async () => [structuredClone(stored)]), set: vi.fn(async (_scope: string, _id: string, value: CompressedObservation) => { stored = structuredClone(value); @@ -40,7 +41,7 @@ function rig(provider: MemoryProvider, previous: RawObservation | CompressedObse const trigger = vi.fn(async () => undefined); const record = vi.fn(async () => undefined); let handler!: (data: { observationId: string; sessionId: string; raw: RawObservation }) => Promise; - registerCompressFunction({ trigger, registerFunction: (_id: string, fn: typeof handler) => { handler = fn; } } as never, + registerCompressFunction({ trigger, registerFunction: (_id: string, fn: typeof handler) => { if (_id === "mem::compress") handler = fn; } } as never, kv as never, provider, { record } as never); return { call: () => handler({ observationId: raw.id, sessionId: raw.sessionId, raw }), @@ -100,10 +101,10 @@ describe("compression output boundaries", () => { ])("rejects %s before persistence", async (_name, response) => { const provider = providerReturning(response); const r = rig(provider); - expect(await r.call()).toEqual({ success: false, error: "parse_failed" }); + expect(await r.call()).toEqual({ success: false, error: "parse_failed", retryable: true }); expect(provider.compress).toHaveBeenCalledTimes(2); - expect(r.stored()).toEqual(raw); - expect(r.kv.set).not.toHaveBeenCalled(); + expect(r.stored()).toMatchObject({ ...raw, compression: { status: "failed" } }); + expect((r.stored() as CompressedObservation).compression?.status).toBe("failed"); expect(search.add).not.toHaveBeenCalled(); expect(search.vector).not.toHaveBeenCalled(); expect(r.trigger).not.toHaveBeenCalled(); @@ -123,11 +124,11 @@ describe("compression validation and retry", () => { ])("retains an existing valid summary and source after %s exhausts retries", async (_name, response) => { const previous = { ...buildSyntheticCompression(raw), ...CompressOutputSchema.parse(parseCompressionXml(xml)), confidence: 0.9 }; const r = rig(providerReturning(response), previous); - expect(await r.call()).toEqual({ success: false, error: "validation_failed" }); - expect(r.stored()).toEqual(previous); - expect(r.kv.set).not.toHaveBeenCalled(); + expect(await r.call()).toEqual({ success: false, error: "validation_failed", retryable: true }); + expect(r.stored()).toMatchObject({ ...previous, compression: { status: "failed" } }); + expect((r.stored() as CompressedObservation).compression?.status).toBe("failed"); expect(r.trigger).not.toHaveBeenCalled(); - expect(search.add).not.toHaveBeenCalled(); + expect(search.add).toHaveBeenCalledWith(expect.objectContaining({ source: { ...buildSyntheticCompression(raw).source } })); expect(r.record).toHaveBeenCalledExactlyOnceWith("mem::compress", expect.any(Number), false); }); @@ -136,9 +137,9 @@ describe("compression validation and retry", () => { const r = rig(provider, buildSyntheticCompression(raw)); expect(await r.call()).toMatchObject({ success: true, compressed: { title: "Updated configuration" } }); expect(provider.compress).toHaveBeenCalledTimes(2); - expect(r.kv.set).toHaveBeenCalledTimes(1); + expect((r.stored() as CompressedObservation).compression?.status).toBe("complete"); expect((r.stored() as CompressedObservation).source?.toolOutput).toBe(raw.toolOutput); - expect(search.add).toHaveBeenCalledTimes(1); + expect(search.add).toHaveBeenLastCalledWith(expect.objectContaining({ title: "Updated configuration" })); expect(r.trigger).toHaveBeenCalledTimes(1); expect(r.record).toHaveBeenCalledExactlyOnceWith("mem::compress", expect.any(Number), true, expect.any(Number)); }); @@ -158,9 +159,9 @@ describe("compression validation and retry", () => { const previous = buildSyntheticCompression(raw); expect(previous.facts).toEqual([]); const r = rig(new NoopProvider(), previous); - expect(await r.call()).toEqual({ success: false, error: "parse_failed" }); - expect(r.stored()).toEqual(previous); - expect(r.kv.set).not.toHaveBeenCalled(); + expect(await r.call()).toEqual({ success: false, error: "parse_failed", retryable: true }); + expect(r.stored()).toMatchObject({ ...previous, compression: { status: "failed" } }); + expect((r.stored() as CompressedObservation).compression?.status).toBe("failed"); }); it("leaves stored evidence intact if the retry provider call throws", async () => { @@ -169,9 +170,9 @@ describe("compression validation and retry", () => { .mockResolvedValueOnce("invalid") .mockRejectedValueOnce(new Error("provider unavailable")); const r = rig(provider); - expect(await r.call()).toEqual({ success: false, error: "compression_failed" }); - expect(r.stored()).toEqual(raw); - expect(r.kv.set).not.toHaveBeenCalled(); + expect(await r.call()).toEqual({ success: false, error: "compression_failed", retryable: true }); + expect(r.stored()).toMatchObject({ ...raw, compression: { status: "failed" } }); + expect((r.stored() as CompressedObservation).compression?.status).toBe("failed"); expect(provider.compress).toHaveBeenCalledTimes(2); expect(r.record).toHaveBeenCalledExactlyOnceWith("mem::compress", expect.any(Number), false); }); diff --git a/test/evict.test.ts b/test/evict.test.ts index 59a13f0c8..85572834e 100644 --- a/test/evict.test.ts +++ b/test/evict.test.ts @@ -6,6 +6,7 @@ import type { } from "../src/types.js"; import { registerEvictFunction } from "../src/functions/evict.js"; import { KV } from "../src/state/schema.js"; +import { withKeyedLock } from "../src/state/keyed-mutex.js"; import { currentAuditScope } from "./helpers/mocks.js"; import { indexObservationSession, @@ -135,6 +136,7 @@ describe("mem::evict stale sessions", () => { const store = storeForObservedSession(sessionId); const kv = mockKV(store); const { sdk, calls } = mockSdk(); + await kv.set(KV.summaryRepairs, sessionId, { sessionId, token: "old-repair", dueAt: 0, attempts: 0 }); registerEvictFunction(sdk as never, kv as never); sdk.registerFunction("event::session::stopped", async (payload) => { @@ -158,6 +160,7 @@ describe("mem::evict stale sessions", () => { expect(result.staleSessions).toBe(1); expect(await kv.get(KV.sessions, sessionId)).toBeNull(); + expect(await kv.get(KV.summaryRepairs, sessionId)).toBeNull(); const audits = await kv.list<{ details: { reason: string }; }>(currentAuditScope()); @@ -253,6 +256,34 @@ describe("mem::evict stale sessions", () => { expect(fnIds.filter((f) => f === "mem::auto-crystallize")).toHaveLength(1); }); + it("does not delete a session restarted while eviction waits for publication", async () => { + const sessionId = "ses_restarted"; + const kv = mockKV(storeForObservations(sessionId, [])); + const { sdk } = mockSdk(); + registerEvictFunction(sdk as never, kv as never); + const list = vi.spyOn(kv, "list"); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const ready = new Promise((resolve) => { entered = resolve; }); + const restarted = { ...makeSession(sessionId), startedAt: new Date().toISOString() }; + const repair = { sessionId, token: "new-repair", dueAt: 0, attempts: 0 }; + const publishing = withKeyedLock(`obs:${sessionId}`, async () => { + entered(); await gate; + await kv.set(KV.sessions, sessionId, restarted); + await kv.set(KV.summaryRepairs, sessionId, repair); + }); + await ready; + const evicting = sdk.trigger({ function_id: "mem::evict", payload: {} }); + try { + await vi.waitFor(() => expect(list).toHaveBeenCalledWith(KV.observations(sessionId))); + expect(await kv.get(KV.sessions, sessionId)).not.toBeNull(); + } finally { release(); await publishing; } + expect(await evicting).toMatchObject({ staleSessions: 0 }); + expect(await kv.get(KV.sessions, sessionId)).toEqual(restarted); + expect(await kv.get(KV.summaryRepairs, sessionId)).toEqual(repair); + }); + it("keeps a stale observed session when recovery fails", async () => { const sessionId = "ses_unrecovered"; const store = storeForObservedSession(sessionId); diff --git a/test/export-import.test.ts b/test/export-import.test.ts index 8dfe5fbb4..5e2c9c4cd 100644 --- a/test/export-import.test.ts +++ b/test/export-import.test.ts @@ -6,6 +6,8 @@ vi.mock("../src/logger.js", () => ({ import { registerExportImportFunction } from "../src/functions/export-import.js"; import { VERSION } from "../src/version.js"; +import { KV } from "../src/state/schema.js"; +import { withKeyedLock } from "../src/state/keyed-mutex.js"; import { getSearchIndex } from "../src/functions/search.js"; import type { Session, @@ -230,7 +232,66 @@ describe("Export/Import Functions", () => { expect(result.sessions).toBe(0); }); + it.each(["merge", "skip"] as const)("serializes a summary-only %s import with summary publication", async (strategy) => { + await kv.delete(KV.summaries, testSession.id); + const published = { ...testSummary, title: "Concurrent published summary" }; + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const ready = new Promise((resolve) => { entered = resolve; }); + const publishing = withKeyedLock(`obs:${testSession.id}`, async () => { + entered(); await gate; + await kv.set(KV.summaries, testSession.id, published); + }); + await ready; + const importing = sdk.trigger("mem::import", { strategy, exportData: { + version: VERSION, exportedAt: new Date().toISOString(), sessions: [], observations: {}, memories: [], summaries: [testSummary], + } }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(await kv.get(KV.summaries, testSession.id)).toBeNull(); + } finally { release(); await publishing; } + expect(await importing).toMatchObject({ success: true, summaries: strategy === "skip" ? 0 : 1 }); + expect(await kv.get(KV.summaries, testSession.id)).toEqual(strategy === "skip" ? published : testSummary); + }); + + it("keeps summary mutation locks until sibling writes settle after an import failure", async () => { + const delayed = { ...testSummary, sessionId: "ses_delayed", title: "Imported delayed summary" }; + const published = { ...delayed, title: "Summary published after import" }; + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const ready = new Promise((resolve) => { entered = resolve; }); + const originalSet = kv.set; + vi.spyOn(kv, "set").mockImplementation(async (scope, key, value) => { + if (scope === KV.summaries && key === testSession.id) throw new Error("summary write failed"); + if (scope === KV.summaries && key === delayed.sessionId && value === delayed) { + entered(); await gate; + } + return originalSet(scope, key, value); + }); + let settled = false; + const importing = sdk.trigger("mem::import", { exportData: { + version: VERSION, exportedAt: new Date().toISOString(), sessions: [], observations: {}, memories: [], + summaries: [testSummary, delayed], + } }).then(() => { settled = true; return null; }, (error: Error) => { settled = true; return error; }); + await ready; + let publishedWhileImporting = false; + const publishing = withKeyedLock(`obs:${delayed.sessionId}`, async () => { + publishedWhileImporting = true; + await kv.set(KV.summaries, delayed.sessionId, published); + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(publishedWhileImporting).toBe(false); + } finally { release(); await publishing; } + expect(await importing).toMatchObject({ message: "summary write failed" }); + expect(await kv.get(KV.summaries, delayed.sessionId)).toEqual(published); + }); + it("import with replace strategy clears existing data first", async () => { + await kv.set(KV.summaryRepairs, testSession.id, { sessionId: testSession.id, token: "old-repair", dueAt: 0, attempts: 0 }); const newSession: Session = { id: "ses_new", project: "new-project", @@ -258,6 +319,7 @@ describe("Export/Import Functions", () => { const oldSession = await kv.get("mem:sessions", "ses_1"); expect(oldSession).toBeNull(); + expect(await kv.get(KV.summaryRepairs, testSession.id)).toBeNull(); }); it("export then import round-trip preserves data", async () => { diff --git a/test/hermes-plugin.test.ts b/test/hermes-plugin.test.ts index 9c36d3c22..825dfe68d 100644 --- a/test/hermes-plugin.test.ts +++ b/test/hermes-plugin.test.ts @@ -66,14 +66,14 @@ describe("Hermes plugin manifest", () => { }); it("satisfies native provider runtime and credential contracts", () => { - const result = spawnSync("python3", ["test/hermes-provider-contracts.py"], { + const result = spawnSync("python3", ["test/hermes-provider-contracts.py", "-v"], { encoding: "utf8", - timeout: 20_000, + timeout: 60_000, env: { PATH: process.env.PATH, PYTHONDONTWRITEBYTECODE: "1" }, }); const diagnostics = [result.error?.message, result.signal, result.stderr, result.stdout] .filter(Boolean) .join("\n"); expect(result.status, diagnostics).toBe(0); - }, 30_000); + }, 70_000); }); diff --git a/test/live-source-budget.test.ts b/test/live-source-budget.test.ts index 06e0be3cd..0b92aea4e 100644 --- a/test/live-source-budget.test.ts +++ b/test/live-source-budget.test.ts @@ -46,8 +46,9 @@ function rig(mode: "observe" | "compress") { let nextId = 0; async function write(id = `live-${nextId++}`): Promise { if (mode === "compress") { + if (!await kv.get(scope, id)) await kv.set(scope, id, raw(id)); const result = await handlers.get("mem::compress")!({ observationId: id, sessionId, raw: raw(id) }); - expect(result.success).toBe(true); + if (!result.success) expect(result.error).toBe("source_unavailable"); } else { const result = await handlers.get("mem::observe")!({ sessionId, timestamp: raw(id).timestamp, hookType: "post_tool_use", data: { tool_name: "Read", tool_output: output } }); id = result.observationId; @@ -74,7 +75,7 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()); describe.each(["observe", "compress"] as const)("live %s source budget", (mode) => { - it("truncates to the remaining session allowance, then omits source while preserving summaries", async () => { + it("truncates to the remaining session allowance, then preserves observations without adding source", async () => { const expected = withoutObservationSource(await rig(mode).write()); const r = rig(mode); await r.seedSource(); @@ -83,7 +84,9 @@ describe.each(["observe", "compress"] as const)("live %s source budget", (mode) expect(payloadByteLength(first.source)).toBeLessThan(payloadByteLength(buildSyntheticCompression(raw("unbounded")).source)); const second = await r.write(); expect(second.source).toBeUndefined(); - expect(withoutObservationSource(second)).toEqual({ ...expected, id: second.id, compressedAt: second.compressedAt }); + if (mode === "compress") { + expect(second).toEqual(raw(second.id)); + } else expect(withoutObservationSource(second)).toEqual({ ...expected, id: second.id, compressedAt: second.compressedAt }); const rows = await r.kv.list(scope); expect(sourceBytes(rows)).toBeLessThanOrEqual(SESSION_SOURCE_MAX_BYTES); expect(payloadByteLength({ result: rows })).toBeLessThan(SAFE_PAYLOAD_BYTES); @@ -101,7 +104,7 @@ describe.each(["observe", "compress"] as const)("live %s source budget", (mode) expect(payloadByteLength({ result: await r.kv.list(scope) })).toBeLessThan(SAFE_PAYLOAD_BYTES); }); - it("preserves a completed summary when existing summaries leave no frame allowance", async () => { + it("preserves the observation when existing summaries leave no frame allowance", async () => { const expected = withoutObservationSource(await rig(mode).write()); const r = rig(mode); const seed = withoutObservationSource(buildSyntheticCompression(raw("oversized-summary"))); @@ -109,7 +112,9 @@ describe.each(["observe", "compress"] as const)("live %s source budget", (mode) await r.kv.set(scope, seed.id, seed); const saved = await r.write(); expect(saved.source).toBeUndefined(); - expect(withoutObservationSource(saved)).toEqual({ ...expected, id: saved.id, compressedAt: saved.compressedAt }); + if (mode === "compress") { + expect(saved).toEqual(raw(saved.id)); + } else expect(withoutObservationSource(saved)).toEqual({ ...expected, id: saved.id, compressedAt: saved.compressedAt }); }); }); diff --git a/test/multimodal.test.ts b/test/multimodal.test.ts index d07006a59..6132d03f1 100644 --- a/test/multimodal.test.ts +++ b/test/multimodal.test.ts @@ -148,7 +148,6 @@ describe("End-to-End Multimodal Flow", () => { const result = await compressCallback({ observationId: raw.id, sessionId: raw.sessionId, - raw, }); expect(result.success).toBe(true); @@ -401,6 +400,7 @@ describe("mem::compress image descriptions", () => { modality: "image", imageData: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", } as unknown as RawObservation; + await store.set(`mem:obs:${raw.sessionId}`, raw.id, raw); const result = await handler({ observationId: raw.id, sessionId: raw.sessionId, raw }); expect(result.success).toBe(true); return (await store.get("mem:obs:ses_img_scrub", "obs_img_scrub")) as CompressedObservation; diff --git a/test/observation-delete-race.test.ts b/test/observation-delete-race.test.ts new file mode 100644 index 000000000..009f7a5ee --- /dev/null +++ b/test/observation-delete-race.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerRememberFunction } from "../src/functions/remember.js"; +import { registerAutoForgetFunction } from "../src/functions/auto-forget.js"; +import { registerEvictFunction } from "../src/functions/evict.js"; +import { registerCompressFunction } from "../src/functions/compress.js"; +import { buildSyntheticCompression } from "../src/functions/compress-synthetic.js"; +import { pendingCompression } from "../src/functions/compression-state.js"; +import { getSearchIndex, setEmbeddingProvider, setIndexPersistence, setVectorIndex, vectorIndexAddGuarded } from "../src/functions/search.js"; +import { VectorIndex } from "../src/state/vector-index.js"; +import { withKeyedLock } from "../src/state/keyed-mutex.js"; +import { KV } from "../src/state/schema.js"; +import type { CompressedObservation } from "../src/types.js"; + +vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); + +const sessionId = "delete-race"; +const xml = "file_editUpdated configurationThe port was updated.The configuration was updated and verified for the local service.configurationconfig.ts1"; +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +async function fixture() { + const store = new Map>(); + const handlers = new Map Promise>(); + const kv = { + get: async (scope: string, key: string): Promise => structuredClone(store.get(scope)?.get(key) ?? null) as T | null, + set: async (scope: string, key: string, value: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, structuredClone(value)); + return value; + }, + list: async (scope: string): Promise => structuredClone([...store.get(scope)?.values() ?? []]) as T[], + delete: async (scope: string, key: string) => { store.get(scope)?.delete(key); }, + }; + const sdk = { + registerFunction: (id: string, handler: (data: any) => Promise) => handlers.set(id, handler), + trigger: async ({ function_id: id, payload }: { function_id: string; payload: any }) => id.startsWith("stream::") ? {} : handlers.get(id)!(payload), + }; + const obs: CompressedObservation = { + ...buildSyntheticCompression({ id: "obs_race", sessionId, timestamp: "2020-01-01T00:00:00Z", hookType: "post_tool_use", toolName: "Edit", toolOutput: "Original durable evidence", raw: {} }), + importance: 1, compression: pendingCompression(), + }; + await kv.set(KV.sessions, sessionId, { id: sessionId, project: "race", cwd: "/race", startedAt: new Date().toISOString() }); + await kv.set(KV.observations(sessionId), obs.id, obs); + getSearchIndex().add(obs); + const vectors = new VectorIndex(); vectors.add(obs.id, sessionId, new Float32Array([1, 0])); setVectorIndex(vectors); + const save = vi.fn(async () => {}); setIndexPersistence({ scheduleSave: vi.fn(), save }); + registerRememberFunction(sdk as never, kv as never); + registerAutoForgetFunction(sdk as never, kv as never); + registerEvictFunction(sdk as never, kv as never); + registerCompressFunction(sdk as never, kv as never, { name: "fixture", compress: async () => xml, summarize: vi.fn() }); + const call = (id: string, payload: any = {}) => sdk.trigger({ function_id: id, payload }); + return { kv, obs, vectors, save, call }; +} + +beforeEach(() => { getSearchIndex().clear(); }); +afterEach(() => { + vi.restoreAllMocks(); + getSearchIndex().clear(); setVectorIndex(null); setEmbeddingProvider(null); setIndexPersistence(null); +}); + +describe("observation deletion and compression commits", () => { + it.each(["selected", "session", "auto-forget", "evict"])("serializes %s deletion with a compression commit and removes both indexes", async (mode) => { + const f = await fixture(); const entered = deferred(); const release = deferred(); + const write = f.kv.set; + vi.spyOn(f.kv, "set").mockImplementation(async (scope, key, value) => { + if (scope === KV.observations(sessionId) && (value as CompressedObservation).compression?.status === "complete") { + entered.resolve(); await release.promise; + } + return write(scope, key, value); + }); + const compression = f.call("mem::compress", { sessionId, observationId: f.obs.id }); + await entered.promise; + let deleting: Promise; + if (mode === "selected") deleting = f.call("mem::forget", { sessionId, observationIds: [f.obs.id] }); + else if (mode === "session") deleting = f.call("mem::forget", { sessionId }); + else deleting = f.call(mode === "auto-forget" ? "mem::auto-forget" : "mem::evict"); + await new Promise((resolve) => setImmediate(resolve)); + expect(await f.kv.get(KV.observations(sessionId), f.obs.id)).not.toBeNull(); + release.resolve(); + await Promise.all([compression, deleting]); + expect(await f.kv.get(KV.observations(sessionId), f.obs.id)).toBeNull(); + expect(getSearchIndex().has(f.obs.id)).toBe(false); + expect(f.vectors.has(f.obs.id)).toBe(false); + expect(f.save).toHaveBeenCalled(); + if (mode === "session") expect(await f.kv.get(KV.sessions, sessionId)).toBeNull(); + }); + + it("invalidates a delayed vector job when project-cap eviction deletes its observation", async () => { + const f = await fixture(); const entered = deferred(); const release = deferred(); + await f.kv.set(KV.config, "eviction", { lowImportanceMaxDays: 10000, maxObservationsPerProject: 0 }); + setEmbeddingProvider({ name: "deferred", dimensions: 2, embed: async () => { entered.resolve(); await release.promise; return new Float32Array([0, 1]); }, embedBatch: async () => [] }); + const adding = vectorIndexAddGuarded(f.obs.id, sessionId, "stale embedding", { kind: "observation", logId: f.obs.id }); + await entered.promise; + expect(await f.call("mem::evict")).toMatchObject({ capEvictions: 1 }); + release.resolve(); + expect(await adding).toBe(false); + expect(await f.kv.get(KV.observations(sessionId), f.obs.id)).toBeNull(); + expect(getSearchIndex().has(f.obs.id)).toBe(false); + expect(f.vectors.has(f.obs.id)).toBe(false); + expect(f.save).toHaveBeenCalled(); + }); + + it.each(["auto-forget", "low-importance", "cap"])("rechecks a %s candidate changed after the sweep's scan", async (mode) => { + const f = await fixture(); const scanned = deferred(); const release = deferred(); + if (mode === "cap") await f.kv.set(KV.config, "eviction", { lowImportanceMaxDays: 10000, maxObservationsPerProject: 0 }); + const list = f.kv.list; let paused = false; + vi.spyOn(f.kv, "list").mockImplementation(async (scope) => { + const rows = await list(scope); + if (scope === KV.observations(sessionId) && !paused) { paused = true; scanned.resolve(); await release.promise; } + return rows; + }); + const sweeping = f.call(mode === "auto-forget" ? "mem::auto-forget" : "mem::evict"); + await scanned.promise; + const revised = { ...f.obs, importance: 9, title: "New important evidence" }; + await withKeyedLock(`obs:${sessionId}`, () => f.kv.set(KV.observations(sessionId), f.obs.id, revised)); + release.resolve(); await sweeping; + expect(await f.kv.get(KV.observations(sessionId), f.obs.id)).toEqual(revised); + expect(getSearchIndex().has(f.obs.id)).toBe(true); + expect(f.vectors.has(f.obs.id)).toBe(true); + }); +}); diff --git a/test/session-finalize.test.ts b/test/session-finalize.test.ts index 7ec1fcced..37771c050 100644 --- a/test/session-finalize.test.ts +++ b/test/session-finalize.test.ts @@ -102,6 +102,18 @@ describe("mem::session-finalize", () => { expect(await kv.get(KV.config, FINALIZE_DUE_KEY)).toEqual({ s2: nowMs + 2 * MIN }); }); + it("preserves disabled idle finalization while still draining summary repairs", async () => { + vi.stubEnv("AGENTMEMORY_FINALIZE_IDLE_MS", "0"); + await kv.set(KV.sessions, "s1", session({ updatedAt: new Date(nowMs - 3 * MIN).toISOString() })); + await scheduleSessionFinalize(kv as never, "s1", nowMs - 1); + const result = await sdk.trigger({ function_id: "mem::session-finalize", payload: { now: nowMs } }); + expect(result).toMatchObject({ finalized: 0 }); + expect((await kv.get(KV.sessions, "s1"))!.status).toBe("active"); + expect(await kv.get(KV.config, FINALIZE_DUE_KEY)).toEqual({ s1: nowMs - 1 }); + expect(sdk.triggers).toContainEqual({ function_id: "mem::summary-recover", payload: { now: nowMs } }); + expect(stoppedTriggers(sdk)).toHaveLength(0); + }); + it("completes an idle session once its entry is due and publishes the stopped lifecycle", async () => { await kv.set(KV.sessions, "s1", session({ updatedAt: new Date(nowMs - 3 * MIN).toISOString() })); await scheduleSessionFinalize(kv as never, "s1", nowMs - 1); diff --git a/test/session-summary-recovery.test.ts b/test/session-summary-recovery.test.ts new file mode 100644 index 000000000..618df58cb --- /dev/null +++ b/test/session-summary-recovery.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerObserveFunction } from "../src/functions/observe.js"; +import { registerCaptureFunctions } from "../src/functions/capture.js"; +import { registerCompressFunction } from "../src/functions/compress.js"; +import { registerSummarizeFunction } from "../src/functions/summarize.js"; +import { registerSessionFinalizeFunction } from "../src/functions/session-finalize.js"; +import { registerSessionSweepFunction } from "../src/functions/session-sweep.js"; +import { registerRememberFunction } from "../src/functions/remember.js"; +import { registerApiTriggers } from "../src/triggers/api.js"; +import { registerEventTriggers } from "../src/triggers/events.js"; +import { getSearchIndex, setEmbeddingProvider, setVectorIndex } from "../src/functions/search.js"; +import { KV } from "../src/state/schema.js"; +import type { MemoryProvider } from "../src/types.js"; + +vi.mock("../src/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); + +const finalCompression = 'decisionVerified conclusionThe cobalt fix passed verification.The final cobalt fix passed verification and is ready.cobalt7'; +const summaryXml = (final: boolean) => `${final ? "Verified conclusion" : "Original investigation"}${final ? "The final cobalt fix passed verification and is ready." : "The original investigation is still waiting for a verified result."}Verify the cobalt fix.cobalt`; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +function store(values = new Map>()) { + return { + restart: () => store(values), + get: async (scope: string, id: string) => structuredClone(values.get(scope)?.get(id) ?? null), + list: async (scope: string) => structuredClone([...(values.get(scope)?.values() ?? [])]), + set: async (scope: string, id: string, value: any) => { + if (!values.has(scope)) values.set(scope, new Map()); + values.get(scope)!.set(id, structuredClone(value)); + return value; + }, + update: async (scope: string, id: string, updates: any[]) => { + const row = values.get(scope)!.get(id); + for (const update of updates) { + if (update.type === "remove") delete row[update.path]; + else row[update.path] = update.value; + } + }, + delete: async (scope: string, id: string) => { values.get(scope)?.delete(id); }, + }; +} +function provider(): MemoryProvider { + return { name: "test", compress: vi.fn(async () => finalCompression), summarize: vi.fn(async (_system, prompt) => summaryXml(prompt.includes("Verified conclusion"))) }; +} +function rig(model = provider(), kv = store()) { + const handlers = new Map Promise>(); + const pending = new Set>(); + const sdk = { + registerFunction: (id: string, fn: any) => handlers.set(id, fn), registerTrigger: vi.fn(), + trigger: vi.fn(async (input: any) => { + const result = handlers.get(input.function_id)?.({ ...input.payload, _caller_worker_id: "test-worker" }); + if (input.action && result) { + pending.add(result); + void result.catch(() => {}).finally(() => pending.delete(result)); + return undefined; + } + return result; + }), + }; + registerObserveFunction(sdk as never, kv as never); + const capture = registerCaptureFunctions(sdk as never, kv as never, { durableAfterMs: 0 }); + registerCompressFunction(sdk as never, kv as never, model); + registerSummarizeFunction(sdk as never, kv as never, model); + registerSessionFinalizeFunction(sdk as never, kv as never); + registerRememberFunction(sdk as never, kv as never); + registerSessionSweepFunction(sdk as never, kv as never); + registerApiTriggers(sdk as never, kv as never); + registerEventTriggers(sdk as never, kv as never); + const call = (id: string, input: any) => handlers.get(id)!(input); + const observe = (eventId: string, text: string, sessionId = "summary-session") => call("api::observe", { headers: {}, body: { + sessionId, eventId, project: "summary-project", cwd: "/fixture", hookType: "post_tool_use", + timestamp: new Date().toISOString(), data: { tool_name: "Read", tool_output: text }, + } }); + const sessions = () => call("api::sessions", { headers: {}, query_params: { agentId: "*" } }); + const flush = async () => { while (pending.size) await Promise.allSettled([...pending]); }; + return { kv, sdk, call, model, observe, sessions, flush, close: capture.stop }; +} + +beforeEach(() => { + vi.stubEnv("AGENTMEMORY_AUTO_COMPRESS", "false"); + vi.stubEnv("AGENTMEMORY_FINALIZE_IDLE_MS", "0"); + vi.stubEnv("AGENTMEMORY_AGENT_SCOPE", "shared"); + vi.stubEnv("CONSOLIDATION_ENABLED", "false"); + vi.stubEnv("AGENTMEMORY_REFLECT", "false"); + getSearchIndex().clear(); setEmbeddingProvider(null); setVectorIndex(null); +}); +afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers(); }); + +describe("session summary recovery", () => { + it("rechecks source coverage before acknowledging a finished repair", async () => { + const r = rig(); + await r.observe("ack-event-0001", "Original investigation"); + await r.call("event::session::ended", { sessionId: "summary-session" }); + const get = r.kv.get; let reads = 0; + r.kv.get = async (scope, id) => { + if (scope === KV.summaryRepairs && ++reads === 2) { + const [row] = await r.kv.list(KV.observations("summary-session")); + await r.kv.set(KV.observations("summary-session"), row.id, { ...row, narrative: "Verified conclusion arrived after summary publication" }); + } + return get(scope, id); + }; + await r.call("mem::session-finalize", { now: Date.now() }); await r.flush(); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).not.toBeNull(); + await r.call("mem::session-finalize", { now: Date.now() + 600_000 }); await r.flush(); + expect((await r.sessions()).body.sessions[0].summary.title).toBe("Verified conclusion"); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).toBeNull(); + r.close(); + }); + + it("does not publish into a recreated session or overwrite an independently replaced summary", async () => { + for (const replacement of ["session", "summary"] as const) { + const held = deferred(); const model = provider(); + model.summarize = vi.fn(() => held.promise); + const r = rig(model); + await r.observe(`replacement-event-${replacement}`, "Original investigation"); + const task = r.call("mem::summarize", { sessionId: "summary-session" }); + await vi.waitFor(() => expect(model.summarize).toHaveBeenCalledOnce()); + if (replacement === "session") { + const session = await r.kv.get(KV.sessions, "summary-session"); + await r.kv.delete(KV.sessions, session.id); + await r.kv.set(KV.sessions, session.id, { ...session, startedAt: "2040-01-01T00:00:00.000Z", project: "new-project", agentId: "new-agent" }); + } else await r.kv.set(KV.summaries, "summary-session", { title: "Imported summary" }); + held.resolve(summaryXml(false)); + expect(await task).toMatchObject({ success: false, error: "source_changed" }); + expect(await r.kv.get(KV.summaries, "summary-session")).toEqual(replacement === "session" ? null : { title: "Imported summary" }); + r.close(); + } + }); + + it("backs off transient storage errors and normalizes malformed retry counters", async () => { + const r = rig(); + await r.observe("storage-event-0001", "Original investigation"); + await r.call("event::session::ended", { sessionId: "summary-session" }); + const queue = await r.kv.get(KV.summaryRepairs, "summary-session"); + await r.kv.set(KV.summaryRepairs, "summary-session", { ...queue, attempts: "bad" }); + const list = r.kv.list; let fail = true; + r.kv.list = async (scope) => { + if (scope === KV.observations("summary-session") && fail) { fail = false; throw new Error("storage unavailable"); } + return list(scope); + }; + await r.call("mem::session-finalize", { now: Date.now() }); await r.flush(); + const failed = await r.kv.get(KV.summaryRepairs, "summary-session"); + expect(failed).toMatchObject({ lastError: "recovery_failed", attempts: 1 }); + expect(Number.isFinite(failed.dueAt)).toBe(true); + expect(failed.dueAt).toBeGreaterThan(Date.now()); + expect(r.model.summarize).not.toHaveBeenCalled(); + await r.call("mem::session-finalize", { now: Date.now() + 600_000 }); await r.flush(); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).toBeNull(); + r.close(); + }); + + it("preserves newer late-replay intent while an older repair is in flight", async () => { + const held = deferred(); const model = provider(); + model.summarize = vi.fn().mockImplementationOnce(() => held.promise) + .mockImplementation(async (_system, prompt) => summaryXml(prompt.includes("Verified conclusion"))); + const r = rig(model); + await r.observe("token-event-0001", "Original investigation"); + await r.call("event::session::ended", { sessionId: "summary-session" }); + const original = await r.kv.get(KV.summaryRepairs, "summary-session"); + await r.call("mem::session-finalize", { now: Date.now() }); + await vi.waitFor(() => expect(model.summarize).toHaveBeenCalledOnce()); + await r.observe("token-event-0002", "Verified conclusion from late replay"); + const newer = await r.kv.get(KV.summaryRepairs, "summary-session"); + expect(newer.token).not.toBe(original.token); + expect((await r.sessions()).body.sessions[0].status).toBe("completed"); + held.resolve(summaryXml(false)); + await r.flush(); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).toEqual(newer); + expect(await r.kv.get(KV.summaries, "summary-session")).toBeNull(); + await r.call("mem::session-finalize", { now: Date.now() + 300_000 }); + await r.flush(); + expect((await r.sessions()).body.sessions[0].summary).toMatchObject({ title: "Verified conclusion", observationCount: 2 }); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).toBeNull(); + r.close(); + }); + + it("limits recovery to two in-flight sessions across repeated ticks", async () => { + const held = deferred(); const model = provider(); + model.summarize = vi.fn(() => held.promise); + const r = rig(model); + for (let i = 0; i < 4; i++) { + const sessionId = `bounded-session-${i}`; + await r.observe(`bounded-event-000${i}`, "Original investigation", sessionId); + await r.call("event::session::ended", { sessionId }); + } + await r.call("mem::session-finalize", { now: Date.now() + 300_000 }); + await vi.waitFor(() => expect(model.summarize).toHaveBeenCalledTimes(2)); + await r.call("mem::session-finalize", { now: Date.now() + 600_000 }); + expect(model.summarize).toHaveBeenCalledTimes(2); + held.resolve(summaryXml(false)); await r.flush(); + await r.call("mem::session-finalize", { now: Date.now() + 600_000 }); + await r.flush(); + expect(model.summarize).toHaveBeenCalledTimes(4); + expect(await r.kv.list(KV.summaryRepairs)).toEqual([]); + r.close(); + }); + + it("backs off compression failures and recovers retained source later", async () => { + const model = provider(); model.compress = vi.fn(async () => "invalid"); + const r = rig(model); + vi.stubEnv("AGENTMEMORY_AUTO_COMPRESS", "true"); + await r.observe("failed-compress-event-0001", "Retained cobalt evidence"); + await r.flush(); + await r.call("event::session::ended", { sessionId: "summary-session" }); + await r.call("mem::session-finalize", { now: Date.now() }); + await r.flush(); + const calls = vi.mocked(model.compress).mock.calls.length; + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).toMatchObject({ lastError: "compression_pending", attempts: 1 }); + await r.call("mem::session-finalize", { now: Date.now() }); await r.flush(); + expect(model.compress).toHaveBeenCalledTimes(calls); + model.compress = vi.fn(async () => finalCompression); + for (let i = 0; i < 3; i++) { + await r.call("mem::session-finalize", { now: Date.now() + 600_000 }); await r.flush(); + } + expect((await r.sessions()).body.sessions[0].summary.narrative).toContain("final cobalt fix passed verification"); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).toBeNull(); + r.close(); + }); + + it("retains actionable source-unavailable state with slow retries", async () => { + const r = rig(); + await r.observe("missing-source-event-0001", "Original investigation"); + const [row] = await r.kv.list(KV.observations("summary-session")); + const { source: _source, ...withoutSource } = row; + await r.kv.set(KV.observations("summary-session"), row.id, { ...withoutSource, + compression: { status: "failed", attempts: 1, error: "source_unavailable" } }); + await r.call("event::session::ended", { sessionId: "summary-session" }); + await r.call("mem::session-finalize", { now: Date.now() }); await r.flush(); + const queued = await r.kv.get(KV.summaryRepairs, "summary-session"); + expect(queued).toMatchObject({ lastError: "source_unavailable" }); + expect(queued.dueAt).toBeGreaterThan(Date.now() + 3_500_000); + expect(r.model.compress).not.toHaveBeenCalled(); + r.close(); + }); + + it("clears forgotten and empty closed-session repair intent without model work", async () => { + const r = rig(); + await r.observe("forget-event-0001", "Original investigation"); + await r.call("event::session::ended", { sessionId: "summary-session" }); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).not.toBeNull(); + await r.call("mem::forget", { sessionId: "summary-session" }); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).toBeNull(); + await r.kv.set(KV.sessions, "empty", { id: "empty", project: "empty", cwd: "/empty", startedAt: new Date().toISOString(), status: "active", observationCount: 0 }); + await r.call("event::session::ended", { sessionId: "empty" }); + await r.call("mem::session-finalize", { now: Date.now() + 300_000 }); + await r.flush(); + expect(await r.kv.get(KV.summaryRepairs, "empty")).toBeNull(); + expect(r.model.summarize).not.toHaveBeenCalled(); + r.close(); + }); + + it.each(["event::session::ended", "mem::session-sweep"])("persists repair before %s closes a session", async (closeFunction) => { + const r = rig(); + await r.observe("close-path-event-0001", "Original investigation"); + const session = await r.kv.get(KV.sessions, "summary-session"); + await r.kv.set(KV.sessions, session.id, { ...session, updatedAt: "2020-01-01T00:00:00.000Z" }); + await r.call(closeFunction, { sessionId: session.id }); + await r.flush(); + expect(await r.kv.get(KV.summaryRepairs, session.id)).toMatchObject({ sessionId: session.id }); + const restarted = rig(provider(), r.kv.restart()); + await restarted.call("mem::session-finalize", { now: Date.now() + 300_000 }); + await restarted.flush(); + expect((await restarted.sessions()).body.sessions[0].summary.title).toBe("Original investigation"); + expect(await restarted.kv.get(KV.summaryRepairs, session.id)).toBeNull(); + r.close(); restarted.close(); + }); + + it("bounds stalled summary calls and ignores their eventual late results", async () => { + const held = deferred(); const model = provider(); + model.summarize = vi.fn(() => held.promise); + const r = rig(model); + await r.observe("timeout-event-0001", "Original investigation"); + vi.useFakeTimers(); + const result = r.call("mem::summarize", { sessionId: "summary-session" }); + await vi.waitFor(() => expect(model.summarize).toHaveBeenCalledOnce()); + let settled = false; void result.then(() => { settled = true; }); + await vi.advanceTimersByTimeAsync(120_001); + expect(settled).toBe(true); + expect(await result).toMatchObject({ success: false, error: "summary_timeout" }); + held.resolve(summaryXml(false)); + await Promise.resolve(); + expect(await r.kv.get(KV.summaries, "summary-session")).toBeNull(); + r.close(); + }); + + it("backs off provider failures and resumes after provider configuration on restart", async () => { + const model = provider(); model.name = "noop"; + const r = rig(model); + await r.observe("no-provider-event-0001", "Original investigation"); + await r.call("api::session::end", { body: { sessionId: "summary-session", final: true } }); + await r.flush(); + await r.call("mem::session-finalize", { now: Date.now() }); + await r.flush(); + const queued = await r.kv.get(KV.summaryRepairs, "summary-session"); + expect(queued).toMatchObject({ lastError: "no_provider", attempts: 1 }); + expect(queued.dueAt).toBeGreaterThan(Date.now() + 3_500_000); + expect(model.summarize).not.toHaveBeenCalled(); + const configured = provider(); configured.summarize = vi.fn(async () => { throw new Error("outage"); }); + const restarted = rig(configured, r.kv.restart()); + await restarted.call("mem::session-finalize", { now: Date.now() }); + await restarted.flush(); + expect(configured.summarize).toHaveBeenCalledOnce(); + expect(await restarted.kv.get(KV.summaryRepairs, "summary-session")).toMatchObject({ lastError: "summary_failed" }); + await restarted.call("mem::session-finalize", { now: Date.now() }); + await restarted.flush(); + expect(configured.summarize).toHaveBeenCalledOnce(); + configured.summarize = vi.fn(async () => summaryXml(false)); + await restarted.call("mem::session-finalize", { now: Date.now() + 600_000 }); + await restarted.flush(); + expect(await restarted.kv.get(KV.summaryRepairs, "summary-session")).toBeNull(); + expect((await restarted.sessions()).body.sessions[0].summary.title).toBe("Original investigation"); + r.close(); restarted.close(); + }); + + it("does not certify skipped summary chunks as fully covered", async () => { + vi.stubEnv("SUMMARIZE_CHUNK_SIZE", "1"); + const model = provider(); + let fail = true; + model.summarize = vi.fn(async (_system, prompt) => + fail && prompt.includes("Missing evidence") ? "invalid" : summaryXml(false)); + const r = rig(model); + await r.observe("partial-event-0001", "Original investigation"); + await r.observe("partial-event-0002", "Missing evidence"); + const first = await r.call("mem::summarize", { sessionId: "summary-session" }); + expect(first).toMatchObject({ success: true, incomplete: true }); + expect(first.summary.coveredFingerprint).toBeUndefined(); + await r.call("api::session::end", { body: { sessionId: "summary-session", final: true } }); + await r.flush(); + await r.call("mem::session-finalize", { now: Date.now() + 300_000 }); + await r.flush(); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).not.toBeNull(); + fail = false; + const second = await r.call("mem::summarize", { sessionId: "summary-session" }); + expect(second).toMatchObject({ success: true }); + expect(second.unchanged).not.toBe(true); + expect(second.summary.coveredFingerprint).toEqual(expect.any(String)); + r.close(); + }); + + it("serializes overlapping summaries and skips repeated model work for unchanged inputs", async () => { + const held = deferred(); const model = provider(); + model.summarize = vi.fn(() => held.promise); + const r = rig(model); + await r.observe("overlap-event-0001", "Original investigation"); + const first = r.call("mem::summarize", { sessionId: "summary-session" }); + await vi.waitFor(() => expect(model.summarize).toHaveBeenCalledOnce()); + const second = r.call("mem::summarize", { sessionId: "summary-session" }); + await new Promise((resolve) => setImmediate(resolve)); + held.resolve(summaryXml(false)); + const results = await Promise.all([first, second]); + expect(model.summarize).toHaveBeenCalledOnce(); + expect(results).toMatchObject([{ success: true }, { success: true, unchanged: true }]); + r.close(); + }); + + it("does not resurrect a summary after the session is forgotten during generation", async () => { + const held = deferred(); const model = provider(); + model.summarize = vi.fn(() => held.promise); + const r = rig(model); + await r.observe("delete-event-0001", "Original investigation"); + const task = r.call("mem::summarize", { sessionId: "summary-session" }); + await vi.waitFor(() => expect(model.summarize).toHaveBeenCalledOnce()); + expect(await r.call("mem::forget", { sessionId: "summary-session" })).toMatchObject({ success: true }); + held.resolve(summaryXml(false)); + expect(await task).toMatchObject({ success: false, error: "source_changed" }); + expect(await r.kv.get(KV.summaries, "summary-session")).toBeNull(); + expect((await r.sessions()).body.sessions).toEqual([]); + r.close(); + }); + + it("retains pending repair across restart and recovers compression without another end event", async () => { + const held = deferred(); + const model = provider(); model.compress = vi.fn(() => held.promise); + const r = rig(model); + vi.stubEnv("AGENTMEMORY_AUTO_COMPRESS", "true"); + await r.observe("restart-event-0001", "Retained original evidence"); + await vi.waitFor(() => expect(model.compress).toHaveBeenCalledOnce()); + await r.call("api::session::end", { body: { sessionId: "summary-session", final: true } }); + await vi.waitFor(() => expect(model.summarize).toHaveBeenCalled()); + await r.call("mem::session-finalize", { now: Date.now() + 300_000 }); + await new Promise((resolve) => setImmediate(resolve)); + expect(await r.kv.get(KV.summaryRepairs, "summary-session")).not.toBeNull(); + r.close(); + const restarted = rig(provider(), r.kv.restart()); + await restarted.call("mem::session-finalize", { now: Date.now() + 600_000 }); + await restarted.flush(); + expect((await restarted.sessions()).body.sessions[0]).toMatchObject({ status: "completed", + summary: { title: "Verified conclusion", narrative: "The final cobalt fix passed verification and is ready.", observationCount: 1 } }); + const recoveredRows = await restarted.kv.list(KV.observations("summary-session")); + expect(recoveredRows[0].facts).toContain("The cobalt fix passed verification."); + const recoveredSummary = await restarted.kv.get(KV.summaries, "summary-session"); + held.resolve(finalCompression); + await r.flush(); + expect(await restarted.kv.list(KV.observations("summary-session"))).toEqual(recoveredRows); + expect(await restarted.kv.get(KV.summaries, "summary-session")).toEqual(recoveredSummary); + restarted.close(); + }); + + it("finalizes late replay without another Stop and leaves duplicate delivery unchanged", async () => { + const r = rig(); + await r.observe("seed-event-0002", "Original investigation"); + await r.call("api::session::end", { body: { sessionId: "summary-session", final: true } }); + await r.flush(); + await r.call("mem::session-finalize", { now: Date.now() + 300_000 }); + await r.flush(); + vi.stubEnv("AGENTMEMORY_FINALIZE_IDLE_MS", "120000"); + await r.observe("reply-event-0002", "Verified conclusion from replay"); + await r.call("mem::session-finalize", { now: Date.now() + 300_000 }); + await r.flush(); + const expected = (await r.sessions()).body.sessions[0]; + expect(expected).toMatchObject({ status: "completed", observationCount: 2, + summary: { title: "Verified conclusion", observationCount: 2 } }); + const modelCalls = vi.mocked(r.model.summarize).mock.calls.length; + expect(await r.observe("reply-event-0002", "Verified conclusion from replay")).toMatchObject({ status_code: 200 }); + await r.call("mem::session-finalize", { now: Date.now() + 600_000 }); + await r.flush(); + expect((await r.sessions()).body.sessions[0]).toEqual(expected); + expect(r.model.summarize).toHaveBeenCalledTimes(modelCalls); + r.close(); + }); + + it("refreshes an ended session after acknowledged capture finishes compression", async () => { + const held = deferred(); + const model = provider(); model.compress = vi.fn(() => held.promise); + const r = rig(model); + await r.observe("seed-event-0001", "Original investigation"); + vi.stubEnv("AGENTMEMORY_AUTO_COMPRESS", "true"); + const captured = await r.observe("reply-event-0001", "The final analysis is available"); + expect(captured).toMatchObject({ status_code: 201, body: { state: "completed" } }); + await vi.waitFor(() => expect(model.compress).toHaveBeenCalledOnce()); + await r.call("api::session::end", { headers: {}, body: { sessionId: "summary-session", final: true } }); + await vi.waitFor(() => expect(model.summarize).toHaveBeenCalled()); + held.resolve(finalCompression); + await r.flush(); + await r.call("mem::session-finalize", { now: Date.now() + 300_000 }); + await r.flush(); + expect((await r.sessions()).body.sessions[0]).toMatchObject({ + status: "completed", summary: { title: "Verified conclusion", observationCount: 2 }, + }); + r.close(); + }); +}); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index f6b74ac6d..f57d5b4d3 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -33,6 +33,8 @@ vi.mock("node:fs", () => ({ import { registerSnapshotFunction } from "../src/functions/snapshot.js"; import { currentAuditScope } from "./helpers/mocks.js"; +import { withKeyedLock } from "../src/state/keyed-mutex.js"; +import { KV } from "../src/state/schema.js"; import { getProjectSessionIndex } from "../src/state/session-index.js"; import type { Session, Memory, SnapshotMeta } from "../src/types.js"; @@ -188,6 +190,29 @@ describe("Snapshot Functions", () => { expect(index?.map((e) => e.id)).toEqual(["ses_restored"]); }); + it("waits for summary publication before replacing restored observation content", async () => { + const old = { id: "obs_locked", sessionId: "ses_1", timestamp: "2026-02-01T00:00:00Z", + type: "decision", title: "Original", narrative: "Old content", facts: [], concepts: [], files: [], importance: 5 }; + const replacement = { ...old, title: "Restored", narrative: "Changed content" }; + await kv.set(KV.observations("ses_1"), old.id, old); + snapshotContent.mockReturnValueOnce(JSON.stringify({ + sessions: [], memories: [], observations: { ses_1: [replacement] }, + })); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const ready = new Promise((resolve) => { entered = resolve; }); + const publishing = withKeyedLock("obs:ses_1", async () => { entered(); await gate; }); + await ready; + const restoring = sdk.trigger("mem::snapshot-restore", { commitHash: "abc1234" }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(await kv.get(KV.observations("ses_1"), old.id)).toEqual(old); + } finally { release(); await publishing; } + expect(await restoring).toMatchObject({ success: true }); + expect(await kv.get(KV.observations("ses_1"), old.id)).toEqual(replacement); + }); + it("snapshot-create records an audit entry", async () => { await sdk.trigger("mem::snapshot-create", { message: "Audit test" }); diff --git a/test/summarize-watermark.test.ts b/test/summarize-watermark.test.ts index 9074f4334..a17b2e497 100644 --- a/test/summarize-watermark.test.ts +++ b/test/summarize-watermark.test.ts @@ -129,6 +129,19 @@ async function setup(sessionId: string) { } describe("mem::summarize watermark", () => { + it.each([3, 5])("includes newly imported observations at or before the watermark (%s)", async (minute) => { + const t = await setup("ses_imported"); + await t.addObs(1, 5, (i) => i); + await t.handler({ sessionId: "ses_imported" }); + const late = makeObs(99, "ses_imported", at(minute)); + await t.kv.set("obs:ses_imported", late.id, late); + const result = await t.handler({ sessionId: "ses_imported" }); + expect(result).toMatchObject({ success: true }); + expect(result).not.toHaveProperty("unchanged", true); + expect(t.provider.calls[1].user).toContain("obs 99"); + expect((await t.stored()).observationCount).toBe(6); + }); + it("records how far the stored summary reaches", async () => { const t = await setup("ses_first"); await t.addObs(1, 5, (i) => i);