From ce09eb39b5c7199e941a4df3229ab5ad2a3af230 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 29 Jun 2026 15:16:07 -0400 Subject: [PATCH 01/17] fix(cli): make plan follow-up refinement deterministic --- .changeset/plan-followup-refine.md | 6 ++ packages/kilo-i18n/src/ar.ts | 2 + packages/kilo-i18n/src/br.ts | 2 + packages/kilo-i18n/src/bs.ts | 2 + packages/kilo-i18n/src/da.ts | 2 + packages/kilo-i18n/src/de.ts | 2 + packages/kilo-i18n/src/en.ts | 2 + packages/kilo-i18n/src/es.ts | 2 + packages/kilo-i18n/src/fr.ts | 2 + packages/kilo-i18n/src/it.ts | 2 + packages/kilo-i18n/src/ja.ts | 2 + packages/kilo-i18n/src/ko.ts | 2 + packages/kilo-i18n/src/nl.ts | 2 + packages/kilo-i18n/src/no.ts | 2 + packages/kilo-i18n/src/pl.ts | 2 + packages/kilo-i18n/src/ru.ts | 2 + packages/kilo-i18n/src/th.ts | 2 + packages/kilo-i18n/src/tr.ts | 2 + packages/kilo-i18n/src/uk.ts | 2 + packages/kilo-i18n/src/zh.ts | 2 + packages/kilo-i18n/src/zht.ts | 2 + packages/kilo-telemetry/src/telemetry.ts | 5 +- .../unit/plan-followup-locale-keys.test.ts | 2 + .../opencode/src/kilocode/plan-followup.ts | 20 ++++++ .../kilocode/session/native-plan-prompt.txt | 13 ++-- .../opencode/src/kilocode/session/prompt.ts | 2 +- .../test/kilocode/plan-exit-detection.test.ts | 4 ++ .../test/kilocode/plan-followup.test.ts | 61 +++++++++++++++++-- 28 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 .changeset/plan-followup-refine.md diff --git a/.changeset/plan-followup-refine.md b/.changeset/plan-followup-refine.md new file mode 100644 index 0000000000..fab675a990 --- /dev/null +++ b/.changeset/plan-followup-refine.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Show an interactive Implement / Keep refining panel when Plan mode is ready instead of asking users to type a numbered choice. diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 4c267f2672..13170b89c6 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -112,6 +112,8 @@ export const dict = { "plan.followup.answer.newSession.description": "نفّذ في جلسة جديدة بسياق نظيف", "plan.followup.answer.continue": "المتابعة هنا", "plan.followup.answer.continue.description": "نفّذ الخطة في هذه الجلسة", + "plan.followup.answer.keepRefining": "واصل التحسين", + "plan.followup.answer.keepRefining.description": "واصل التخطيط دون التنفيذ الآن", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "اللقطة بطيئة", diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 1f816170e5..3e91b3d765 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -113,6 +113,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementar em uma nova sessão com contexto limpo", "plan.followup.answer.continue": "Continuar aqui", "plan.followup.answer.continue.description": "Implementar o plano nesta sessão", + "plan.followup.answer.keepRefining": "Continuar refinando", + "plan.followup.answer.keepRefining.description": "Continuar planejando sem implementar ainda", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Snapshot está lento", diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index 619faf6357..d1aece561e 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -118,6 +118,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementiraj u novoj sesiji s čistim kontekstom", "plan.followup.answer.continue": "Nastavi ovdje", "plan.followup.answer.continue.description": "Implementiraj plan u ovoj sesiji", + "plan.followup.answer.keepRefining": "Nastavi dorađivati", + "plan.followup.answer.keepRefining.description": "Nastavi planirati bez implementacije za sada", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Snapshot je spor", diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 457b3f97d2..3147ec1eee 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -113,6 +113,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementér i en ny session med ren kontekst", "plan.followup.answer.continue": "Fortsæt her", "plan.followup.answer.continue.description": "Implementér planen i denne session", + "plan.followup.answer.keepRefining": "Fortsæt med at finpudse", + "plan.followup.answer.keepRefining.description": "Fortsæt planlægningen uden at implementere endnu", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Snapshot er langsomt", diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 08808907e2..8e4fe468a5 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -115,6 +115,8 @@ export const dict = { "plan.followup.answer.newSession.description": "In einer neuen Sitzung mit leerem Kontext umsetzen", "plan.followup.answer.continue": "Hier fortfahren", "plan.followup.answer.continue.description": "Den Plan in dieser Sitzung umsetzen", + "plan.followup.answer.keepRefining": "Weiter verfeinern", + "plan.followup.answer.keepRefining.description": "Weiter planen, ohne jetzt zu implementieren", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Snapshot ist langsam", diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 6c79b7dc8d..9eb679e3a0 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -115,6 +115,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Implement in a fresh session with a clean context", "plan.followup.answer.continue": "Continue here", "plan.followup.answer.continue.description": "Implement the plan in this session", + "plan.followup.answer.keepRefining": "Keep refining", + "plan.followup.answer.keepRefining.description": "Keep planning without implementing yet", // Slow-repo snapshot prompt. The English strings here are the canonical // labels sent by the backend and must stay in sync with diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index bcbf615f04..a72ba48094 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -114,6 +114,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementar en una sesión nueva con contexto limpio", "plan.followup.answer.continue": "Continuar aquí", "plan.followup.answer.continue.description": "Implementar el plan en esta sesión", + "plan.followup.answer.keepRefining": "Seguir refinando", + "plan.followup.answer.keepRefining.description": "Seguir planificando sin implementar todavía", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "La instantánea es lenta", diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 895b9cff15..6f65477082 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -115,6 +115,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Implémenter dans une nouvelle session avec un contexte vierge", "plan.followup.answer.continue": "Continuer ici", "plan.followup.answer.continue.description": "Implémenter le plan dans cette session", + "plan.followup.answer.keepRefining": "Continuer à affiner", + "plan.followup.answer.keepRefining.description": "Continuer à planifier sans implémenter pour l'instant", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Instantané lent", diff --git a/packages/kilo-i18n/src/it.ts b/packages/kilo-i18n/src/it.ts index 8224cf6910..229be83c14 100644 --- a/packages/kilo-i18n/src/it.ts +++ b/packages/kilo-i18n/src/it.ts @@ -115,6 +115,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementa in una nuova sessione con contesto vuoto", "plan.followup.answer.continue": "Continua qui", "plan.followup.answer.continue.description": "Implementa il piano in questa sessione", + "plan.followup.answer.keepRefining": "Continua a rifinire", + "plan.followup.answer.keepRefining.description": "Continua a pianificare senza implementare per ora", "snapshot.slowRepo.header": "Snapshot lento", "snapshot.slowRepo.question": diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index 0092971f30..c1699834b6 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -112,6 +112,8 @@ export const dict = { "plan.followup.answer.newSession.description": "クリーンなコンテキストの新しいセッションで実装する", "plan.followup.answer.continue": "ここで続行", "plan.followup.answer.continue.description": "このセッションで計画を実装する", + "plan.followup.answer.keepRefining": "さらに調整する", + "plan.followup.answer.keepRefining.description": "まだ実装せずに計画を続ける", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "スナップショットが遅い", diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 09fbfa7cf7..a0e760dd54 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -112,6 +112,8 @@ export const dict = { "plan.followup.answer.newSession.description": "깨끗한 컨텍스트의 새 세션에서 구현", "plan.followup.answer.continue": "여기서 계속하기", "plan.followup.answer.continue.description": "이 세션에서 계획 구현", + "plan.followup.answer.keepRefining": "계속 다듬기", + "plan.followup.answer.keepRefining.description": "아직 구현하지 않고 계획을 계속 진행", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "스냅샷이 느립니다", diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 9e5fc2210b..bf4f116fd4 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -116,6 +116,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementeren in een nieuwe sessie met een lege context", "plan.followup.answer.continue": "Hier doorgaan", "plan.followup.answer.continue.description": "Het plan in deze sessie implementeren", + "plan.followup.answer.keepRefining": "Blijven verfijnen", + "plan.followup.answer.keepRefining.description": "Blijven plannen zonder nu te implementeren", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Snapshot is traag", diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index 9600db9243..c28bfb16cd 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -113,6 +113,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Implementer i en ny økt med ren kontekst", "plan.followup.answer.continue": "Fortsett her", "plan.followup.answer.continue.description": "Implementer planen i denne økten", + "plan.followup.answer.keepRefining": "Fortsett å finpusse", + "plan.followup.answer.keepRefining.description": "Fortsett planleggingen uten å implementere ennå", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Snapshot er tregt", diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 07845f6f11..6764a61f26 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -114,6 +114,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Wdróż w nowej sesji z czystym kontekstem", "plan.followup.answer.continue": "Kontynuuj tutaj", "plan.followup.answer.continue.description": "Wdróż plan w tej sesji", + "plan.followup.answer.keepRefining": "Dalej dopracowuj", + "plan.followup.answer.keepRefining.description": "Kontynuuj planowanie bez wdrażania na razie", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Snapshot jest wolny", diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 6a3e9c14d5..d426d1c0f1 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -115,6 +115,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Реализовать в новой сессии с чистым контекстом", "plan.followup.answer.continue": "Продолжить здесь", "plan.followup.answer.continue.description": "Реализовать план в этой сессии", + "plan.followup.answer.keepRefining": "Продолжить уточнение", + "plan.followup.answer.keepRefining.description": "Продолжить планирование без реализации пока что", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Снимок выполняется медленно", diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 13a1dbd0d7..11a2f317c9 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -113,6 +113,8 @@ export const dict = { "plan.followup.answer.newSession.description": "ดำเนินการในเซสชันใหม่ที่มีบริบทว่างเปล่า", "plan.followup.answer.continue": "ดำเนินการต่อที่นี่", "plan.followup.answer.continue.description": "ดำเนินการตามแผนในเซสชันนี้", + "plan.followup.answer.keepRefining": "ปรับแผนต่อ", + "plan.followup.answer.keepRefining.description": "วางแผนต่อโดยยังไม่ดำเนินการ", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "สแน็ปช็อตช้า", diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index 9b0724277a..a787e69f62 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -114,6 +114,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Temiz bir bağlamla yeni bir oturumda uygula", "plan.followup.answer.continue": "Burada devam et", "plan.followup.answer.continue.description": "Planı bu oturumda uygula", + "plan.followup.answer.keepRefining": "İyileştirmeye devam et", + "plan.followup.answer.keepRefining.description": "Henüz uygulamadan planlamaya devam et", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Anlık görüntü yavaş", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index d3a885bf03..b95a8240da 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -115,6 +115,8 @@ export const dict = { "plan.followup.answer.newSession.description": "Реалізувати в новій сесії з чистим контекстом", "plan.followup.answer.continue": "Продовжити тут", "plan.followup.answer.continue.description": "Реалізувати план у цій сесії", + "plan.followup.answer.keepRefining": "Продовжити уточнення", + "plan.followup.answer.keepRefining.description": "Продовжити планування без реалізації наразі", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "Знімок виконується повільно", diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index 96587a5374..8baddc98f3 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -108,6 +108,8 @@ export const dict = { "plan.followup.answer.newSession.description": "在具有干净上下文的新会话中实现", "plan.followup.answer.continue": "在此继续", "plan.followup.answer.continue.description": "在本会话中实现计划", + "plan.followup.answer.keepRefining": "继续完善", + "plan.followup.answer.keepRefining.description": "继续规划,暂不实现", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "快照速度较慢", diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 547fb96436..fdb58ffcaf 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -108,6 +108,8 @@ export const dict = { "plan.followup.answer.newSession.description": "在具有乾淨上下文的新工作階段中實作", "plan.followup.answer.continue": "在此繼續", "plan.followup.answer.continue.description": "在本工作階段中實作計畫", + "plan.followup.answer.keepRefining": "繼續完善", + "plan.followup.answer.keepRefining.description": "繼續規劃,暫不實作", // Slow-repo snapshot prompt "snapshot.slowRepo.header": "快照速度較慢", diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 21ea9043c8..009fc204a8 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -186,7 +186,10 @@ export namespace Telemetry { track(TelemetryEvent.AGENT_USED, { agent, sessionId }) } - export function trackPlanFollowup(sessionId: string, choice: "new_session" | "continue" | "custom" | "dismissed") { + export function trackPlanFollowup( + sessionId: string, + choice: "new_session" | "continue" | "keep_refining" | "custom" | "dismissed", + ) { track(TelemetryEvent.PLAN_FOLLOWUP, { sessionId, choice }) } diff --git a/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts b/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts index afdaccef76..86b4ef96dd 100644 --- a/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts +++ b/packages/kilo-vscode/tests/unit/plan-followup-locale-keys.test.ts @@ -50,6 +50,8 @@ const keys = [ "plan.followup.answer.newSession.description", "plan.followup.answer.continue", "plan.followup.answer.continue.description", + "plan.followup.answer.keepRefining", + "plan.followup.answer.keepRefining.description", ] describe("plan follow-up i18n keys", () => { diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index a32466ae19..2bc91c18ea 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -160,6 +160,7 @@ export namespace PlanFollowup { export const PLAN_PREFIX = "Implement the following plan:" export const ANSWER_NEW_SESSION = "Start new session" export const ANSWER_CONTINUE = "Continue here" + export const ANSWER_KEEP_REFINING = "Keep refining" export function abort(sessionID: SessionID) { const ctl = pending.get(sessionID) @@ -320,6 +321,13 @@ export namespace PlanFollowup { descriptionKey: "plan.followup.answer.continue.description", mode: "code", }, + { + label: ANSWER_KEEP_REFINING, + labelKey: "plan.followup.answer.keepRefining", + description: "Keep planning without implementing yet", + descriptionKey: "plan.followup.answer.keepRefining.description", + mode: "plan", + }, ], }, ], @@ -540,6 +548,18 @@ export namespace PlanFollowup { return "continue" } + if (answer === ANSWER_KEEP_REFINING) { + Telemetry.trackPlanFollowup(input.sessionID, "keep_refining") + const msg = await inject({ + sessionID: input.sessionID, + agent: "plan", + model: user.model, + text: "Continue refining the plan. Do not implement yet.", + }) + KiloSessionPromptQueue.retarget(input.sessionID, msg.id) + return "continue" + } + Telemetry.trackPlanFollowup(input.sessionID, "custom") const msg = await inject({ sessionID: input.sessionID, diff --git a/packages/opencode/src/kilocode/session/native-plan-prompt.txt b/packages/opencode/src/kilocode/session/native-plan-prompt.txt index f6cfdf2ece..e252279c63 100644 --- a/packages/opencode/src/kilocode/session/native-plan-prompt.txt +++ b/packages/opencode/src/kilocode/session/native-plan-prompt.txt @@ -24,8 +24,7 @@ Your job is to gather context, challenge assumptions, resolve design questions, - Follow the latest Plan File reminder for the target plan location. - If no exact plan file path is provided, follow the latest Plan File reminder for the directory and generated filename pattern. - Use repo-root `plans/`, `.plans/`, or `.opencode/plans/` only when requested or required by the repo/client and your permissions allow it. -- Do not write the final plan or call `plan_exit` until the user chooses "Finalize and save the plan". -- After final approval, write the final plan to the chosen plan file, then call `plan_exit` with the saved plan path. +- When the plan is implementation-ready, write the final plan to the chosen plan file, then call `plan_exit` with the saved plan path. - Do not edit source files or non-plan documentation files. - Do not run mutating commands. - If implementation requires source edits or mutating commands, tell the user to switch to an implementation-capable agent. @@ -34,13 +33,9 @@ Your job is to gather context, challenge assumptions, resolve design questions, - Keep planning until the important design decisions are resolved or explicitly marked out of scope. - If material uncertainty remains, keep the plan open: summarize the current state, identify the most important unresolved decision, and ask exactly one next question with your recommended answer. -- If the plan is implementation-ready but not saved, do not print the full plan in chat. Give a concise draft-ready summary, then ask exactly one question with these choices: - 1. Finalize and save the plan - 2. Continue refining -- Recommend "Finalize and save the plan" only when the goal, constraints, affected boundaries, data flow, failure modes, rollout or migration path, and validation plan are addressed or explicitly out of scope. -- If the user chooses "Finalize and save the plan", write the complete finalized Markdown plan to the chosen plan file, then call `plan_exit` as described above. -- If the user chooses "Continue refining", keep planning and do not write the final plan or call `plan_exit`. -- After `plan_exit`, rely on the client follow-up to ask whether the user wants to implement the saved plan in a new session. +- If the plan is implementation-ready, write the complete finalized Markdown plan to the chosen plan file, then call `plan_exit` as described above. Do not ask the user to choose between finalizing and refining in chat; the client follow-up after `plan_exit` asks whether to implement the saved plan or keep refining. +- Call `plan_exit` only when the goal, constraints, affected boundaries, data flow, failure modes, rollout or migration path, and validation plan are addressed or explicitly out of scope. +- After `plan_exit`, rely on the client follow-up to ask whether the user wants to implement the saved plan or keep refining it. - Do not implement source or documentation changes as this agent. Saved plans should be concise and actionable. Prefer a clear ordered task list over a lengthy design document. Include only the context, decisions, risks, validation steps, and open questions another implementation-capable agent needs to execute safely. diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 98c814c64f..469d25a1e8 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -299,7 +299,7 @@ export namespace KiloSessionPrompt { info, "Use the chosen plan path as the main plan file. Do not write or edit other files unless the user explicitly asks and your permissions allow it.", "Project/user instructions about plan location (for example plans/ or .plans/) are authorized when permissions allow them; they do not conflict with this reminder. When finalizing, call plan_exit with the path of the plan file you wrote.", - 'Before creating or updating the plan file, or calling plan_exit, ask the user to choose exactly one of: "Finalize and save the plan" or "Continue refining". If the user chooses to finalize, write the main plan file, then call plan_exit.', + "When the plan is implementation-ready, write the main plan file and call plan_exit. Do not ask the user to choose between finalizing and refining in chat; the client follow-up after plan_exit asks whether to implement the saved plan or keep refining.", ].join("\n") add(`\n${body}\n`) } diff --git a/packages/opencode/test/kilocode/plan-exit-detection.test.ts b/packages/opencode/test/kilocode/plan-exit-detection.test.ts index 5b99cd1992..1490be6c6f 100644 --- a/packages/opencode/test/kilocode/plan-exit-detection.test.ts +++ b/packages/opencode/test/kilocode/plan-exit-detection.test.ts @@ -338,10 +338,14 @@ describe("plan_exit detection", () => { expect(question.questions[0].options.map((item) => item.label)).toEqual([ PlanFollowup.ANSWER_NEW_SESSION, PlanFollowup.ANSWER_CONTINUE, + PlanFollowup.ANSWER_KEEP_REFINING, ]) expect(question.questions[0].options.find((item) => item.label === PlanFollowup.ANSWER_CONTINUE)?.mode).toBe( "code", ) + expect( + question.questions[0].options.find((item) => item.label === PlanFollowup.ANSWER_KEEP_REFINING)?.mode, + ).toBe("plan") await questions.reject(question.id) await expect(pending).resolves.toBe("break") } finally { diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 06664e7c21..f0142d8257 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -1,5 +1,8 @@ import { describe, expect, spyOn, test } from "bun:test" import { Effect } from "effect" +import { Telemetry } from "@kilocode/kilo-telemetry" +import { Global } from "@opencode-ai/core/global" +import * as Log from "@opencode-ai/core/util/log" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { TuiEvent } from "../../src/cli/cmd/tui/event" @@ -8,7 +11,6 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { formatTodos, generateHandover, PlanFollowup, PlanFollowupRuntime } from "../../src/kilocode/plan-followup" import { Instance } from "../../src/kilocode/instance" -import { provideTestInstance } from "../fixture/fixture" import { Provider } from "../../src/provider/provider" import { Question } from "../../src/question" import { Session } from "../../src/session/session" @@ -17,11 +19,9 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { makeRuntime } from "../../src/effect/run-service" import { SessionStatus } from "../../src/session/status" import { Todo } from "../../src/session/todo" -import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" import path from "path" import fs from "fs/promises" -import { tmpdir } from "../fixture/fixture" +import { provideTestInstance, tmpdir } from "../fixture/fixture" Log.init({ print: false }) process.env.KILO_CLIENT = "cli" @@ -309,13 +309,14 @@ describe("plan follow-up", () => { expect(q.options.map((item) => item.label)).toEqual([ PlanFollowup.ANSWER_NEW_SESSION, PlanFollowup.ANSWER_CONTINUE, + PlanFollowup.ANSWER_KEEP_REFINING, ]) await question.reject(item.id) await expect(pending).resolves.toBe("break") })) - test("ask - Continue here option carries mode: code so VS Code picker updates immediately", () => + test("ask - follow-up options carry modes so the picker updates immediately", () => withInstance(async () => { const seeded = await seed({ text: "1. Build" }) const pending = PlanFollowup.ask({ @@ -335,6 +336,9 @@ describe("plan follow-up", () => { const continueOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_CONTINUE) expect(continueOpt?.mode).toBe("code") + const refineOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_KEEP_REFINING) + expect(refineOpt?.mode).toBe("plan") + // Start new session should not carry a mode (it opens a new session — the // current picker is irrelevant once the session switches). const newOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_NEW_SESSION) @@ -400,15 +404,21 @@ describe("plan follow-up", () => { expect(q.options.map((o) => o.labelKey)).toEqual([ "plan.followup.answer.newSession", "plan.followup.answer.continue", + "plan.followup.answer.keepRefining", ]) expect(q.options.map((o) => o.descriptionKey)).toEqual([ "plan.followup.answer.newSession.description", "plan.followup.answer.continue.description", + "plan.followup.answer.keepRefining.description", ]) // Canonical English labels stay on the wire — the server still matches on `label`, // so translating the UI must not change the reply format. - expect(q.options.map((o) => o.label)).toEqual([PlanFollowup.ANSWER_NEW_SESSION, PlanFollowup.ANSWER_CONTINUE]) + expect(q.options.map((o) => o.label)).toEqual([ + PlanFollowup.ANSWER_NEW_SESSION, + PlanFollowup.ANSWER_CONTINUE, + PlanFollowup.ANSWER_KEEP_REFINING, + ]) await question.reject(item.id) await expect(pending).resolves.toBe("break") @@ -467,6 +477,45 @@ describe("plan follow-up", () => { expect(part.synthetic).toBe(true) })) + test("ask - returns continue and creates plan message on Keep refining", () => + withInstance(async () => { + const track = spyOn(Telemetry, "trackPlanFollowup").mockImplementation(() => {}) + using _ = { + [Symbol.dispose]() { + track.mockRestore() + }, + } + const seeded = await seed({ text: "1. Build\n2. Test" }) + const pending = PlanFollowup.ask({ + question, + sessionID: seeded.sessionID, + messages: seeded.messages, + abort: AbortSignal.any([]), + }) + + const item = await waitQuestion(seeded.sessionID) + expect(item).toBeDefined() + if (!item) return + await question.reply({ + requestID: item.id, + answers: [[PlanFollowup.ANSWER_KEEP_REFINING]], + }) + + await expect(pending).resolves.toBe("continue") + expect(track).toHaveBeenCalledWith(seeded.sessionID, "keep_refining") + + const user = await latestUser(seeded.sessionID) + expect(user?.info.role).toBe("user") + if (!user || user.info.role !== "user") return + expect(user.info.agent).toBe("plan") + + const part = user.parts.find((item) => item.type === "text") + expect(part?.type).toBe("text") + if (!part || part.type !== "text") return + expect(part.text).toBe("Continue refining the plan. Do not implement yet.") + expect(part.synthetic).toBe(true) + })) + test("ask - returns continue and creates plan message for custom text", () => withInstance(async () => { const seeded = await seed({ text: "1. Build\n2. Test" }) From febdc269a2812487c61b0b760e0afa512a3efe9f Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 29 Jun 2026 15:35:33 -0400 Subject: [PATCH 02/17] fix(cli): preserve plan approval for unsupported clients --- .../opencode/src/kilocode/session/prompt.ts | 10 +++- .../test/kilocode/plan-exit-detection.test.ts | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 469d25a1e8..0f7946f0d3 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -40,6 +40,10 @@ export namespace KiloSessionPrompt { return id === "architect" || name === "plan" || name === "architect" } + function supportsPlanFollowup() { + return ["cli", "vscode", "jetbrains"].includes(Flag.KILO_CLIENT) + } + /** * Determines whether the plan follow-up prompt should be shown. * Checks if the plan_exit tool was called in the last assistant turn. @@ -47,7 +51,7 @@ export namespace KiloSessionPrompt { */ export function shouldAskPlanFollowup(input: { messages: MessageV2.WithParts[]; abort: AbortSignal }) { if (input.abort.aborted) return false - if (!["cli", "vscode", "jetbrains"].includes(Flag.KILO_CLIENT)) return false + if (!supportsPlanFollowup()) return false const idx = input.messages.findLastIndex((m) => m.info.role === "user") return input.messages .slice(idx + 1) @@ -299,7 +303,9 @@ export namespace KiloSessionPrompt { info, "Use the chosen plan path as the main plan file. Do not write or edit other files unless the user explicitly asks and your permissions allow it.", "Project/user instructions about plan location (for example plans/ or .plans/) are authorized when permissions allow them; they do not conflict with this reminder. When finalizing, call plan_exit with the path of the plan file you wrote.", - "When the plan is implementation-ready, write the main plan file and call plan_exit. Do not ask the user to choose between finalizing and refining in chat; the client follow-up after plan_exit asks whether to implement the saved plan or keep refining.", + supportsPlanFollowup() + ? "When the plan is implementation-ready, write the main plan file and call plan_exit. Do not ask the user to choose between finalizing and refining in chat; the client follow-up after plan_exit asks whether to implement the saved plan or keep refining." + : 'Before creating or updating the plan file, or calling plan_exit, ask the user to choose exactly one of: "Finalize and save the plan" or "Continue refining". If the user chooses to finalize, write the main plan file, then call plan_exit.', ].join("\n") add(`\n${body}\n`) } diff --git a/packages/opencode/test/kilocode/plan-exit-detection.test.ts b/packages/opencode/test/kilocode/plan-exit-detection.test.ts index 1490be6c6f..cb11daeb23 100644 --- a/packages/opencode/test/kilocode/plan-exit-detection.test.ts +++ b/packages/opencode/test/kilocode/plan-exit-detection.test.ts @@ -149,6 +149,29 @@ async function waitQuestion(sessionID: string) { } } +function userMessage(input: { sessionID: SessionID; agent: string; text: string }) { + const id = MessageID.ascending() + return { + info: { + id, + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: input.agent, + model, + }, + parts: [ + { + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text", + text: input.text, + }, + ], + } satisfies MessageV2.WithParts +} + function content(message: MessageV2.WithParts) { return message.parts .filter((part): part is MessageV2.TextPart => part.type === "text") @@ -689,6 +712,42 @@ describe("plan_exit detection", () => { await expect(fs.stat(dir).then((stat) => stat.isDirectory())).resolves.toBe(true) })) + test("native plan reminder keeps in-chat approval for clients without follow-up support", () => + withInstance(async () => { + const prev = process.env.KILO_CLIENT + try { + const session = await sessions.create({}) + + process.env.KILO_CLIENT = "vscode" + const supported = userMessage({ sessionID: session.id, agent: "plan", text: "Create a plan." }) + await KiloSessionPrompt.insertPlanReminders({ + agent: { name: "plan", options: {} }, + session, + userMessage: supported, + messages: [supported], + }) + const supportedText = content(supported) + expect(supportedText).toContain("client follow-up after plan_exit asks whether to implement") + expect(supportedText).not.toContain("Finalize and save the plan") + + process.env.KILO_CLIENT = "acp" + const acp = userMessage({ sessionID: session.id, agent: "plan", text: "Create a plan." }) + await KiloSessionPrompt.insertPlanReminders({ + agent: { name: "plan", options: {} }, + session, + userMessage: acp, + messages: [acp], + }) + const text = content(acp) + expect(text).toContain("Finalize and save the plan") + expect(text).toContain("Continue refining") + expect(text).not.toContain("client follow-up after plan_exit asks") + } finally { + if (prev === undefined) delete process.env.KILO_CLIENT + else process.env.KILO_CLIENT = prev + } + })) + test("native plan reminder prefers project plan path instructions over fallback", () => withInstance(async () => { const session = await sessions.create({}) From 7183f5b7a962ddf52592f0f734fab4aff25961ce Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 30 Jun 2026 11:45:56 +0200 Subject: [PATCH 03/17] test(cli): deflake prompt cancellation race --- packages/opencode/test/session/prompt.test.ts | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 285546a1cf..1c2bb9bd8a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -160,11 +160,32 @@ const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) -const processorCreateStarted: Array<() => void> = [] +const agent: AgentSvc.Info = { + name: "build", + mode: "primary", + native: true, + permission: Permission.fromConfig({ "*": "allow" }), + model: ref, + options: {}, +} +const fastAgents = Layer.mock(AgentSvc.Service)({ + get: () => Effect.succeed(agent), + list: () => Effect.succeed([agent]), + defaultInfo: () => Effect.succeed(agent), + defaultAgent: () => Effect.succeed(agent.name), + guardRequirements: () => Effect.void, +}) + +const processorCreateStarted: Deferred.Deferred[] = [] const blockingProcessor = Layer.succeed( SessionProcessor.Service, SessionProcessor.Service.of({ - create: () => Effect.sync(() => processorCreateStarted.shift()?.()).pipe(Effect.andThen(Effect.never)), + create: () => + Effect.gen(function* () { + const started = processorCreateStarted.shift() + if (started) yield* Deferred.succeed(started, undefined).pipe(Effect.ignore) + return yield* Effect.never + }), }), ) @@ -174,7 +195,7 @@ function makePrompt(input?: { processor?: "blocking" }) { Snapshot.defaultLayer, LLM.defaultLayer, Env.defaultLayer, - AgentSvc.defaultLayer, + input?.processor === "blocking" ? fastAgents : AgentSvc.defaultLayer, Command.defaultLayer, Permission.defaultLayer, Plugin.defaultLayer, @@ -357,14 +378,6 @@ const deferredAsPromise = (deferred: Deferred.Deferred): PromiseLike => }, }) -function defer() { - let resolve!: (value: T | PromiseLike) => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - const succeedVoid = (deferred: Deferred.Deferred) => { Effect.runSync(Deferred.succeed(deferred, void 0).pipe(Effect.ignore)) } @@ -1149,10 +1162,10 @@ raceNoLLMServer.instance( parts: [{ type: "text", text: "first" }], }) - const firstCreate = defer() - processorCreateStarted.push(firstCreate.resolve) + const firstCreate = yield* Deferred.make() + processorCreateStarted.push(firstCreate) const first = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => firstCreate.promise) + yield* awaitWithTimeout(Deferred.await(firstCreate), "processor.create did not start for first turn") yield* prompt.cancel(chat.id) const firstExit = yield* Fiber.await(first) @@ -1175,10 +1188,10 @@ raceNoLLMServer.instance( parts: [{ type: "text", text: "second" }], }) - const secondCreate = defer() - processorCreateStarted.push(secondCreate.resolve) + const secondCreate = yield* Deferred.make() + processorCreateStarted.push(secondCreate) const second = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => secondCreate.promise) + yield* awaitWithTimeout(Deferred.await(secondCreate), "processor.create did not start for second turn") yield* prompt.cancel(chat.id) const secondExit = yield* Fiber.await(second) From 06e346b89a7a44b3505c6a82af25490aaf81d8c1 Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 30 Jun 2026 12:06:19 +0200 Subject: [PATCH 04/17] test(cli): annotate prompt flake fixture --- packages/opencode/test/session/prompt.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 1c2bb9bd8a..9634838f72 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -160,6 +160,7 @@ const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +// kilocode_change start const agent: AgentSvc.Info = { name: "build", mode: "primary", @@ -188,6 +189,7 @@ const blockingProcessor = Layer.succeed( }), }), ) +// kilocode_change end function makePrompt(input?: { processor?: "blocking" }) { const deps = Layer.mergeAll( @@ -195,7 +197,7 @@ function makePrompt(input?: { processor?: "blocking" }) { Snapshot.defaultLayer, LLM.defaultLayer, Env.defaultLayer, - input?.processor === "blocking" ? fastAgents : AgentSvc.defaultLayer, + input?.processor === "blocking" ? fastAgents : AgentSvc.defaultLayer, // kilocode_change Command.defaultLayer, Permission.defaultLayer, Plugin.defaultLayer, @@ -1162,10 +1164,12 @@ raceNoLLMServer.instance( parts: [{ type: "text", text: "first" }], }) + // kilocode_change start const firstCreate = yield* Deferred.make() processorCreateStarted.push(firstCreate) const first = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* awaitWithTimeout(Deferred.await(firstCreate), "processor.create did not start for first turn") + // kilocode_change end yield* prompt.cancel(chat.id) const firstExit = yield* Fiber.await(first) @@ -1188,10 +1192,12 @@ raceNoLLMServer.instance( parts: [{ type: "text", text: "second" }], }) + // kilocode_change start const secondCreate = yield* Deferred.make() processorCreateStarted.push(secondCreate) const second = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* awaitWithTimeout(Deferred.await(secondCreate), "processor.create did not start for second turn") + // kilocode_change end yield* prompt.cancel(chat.id) const secondExit = yield* Fiber.await(second) From 96f9de4b800ea653b043a6711595d01ca3cb436e Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Tue, 30 Jun 2026 12:27:40 +0200 Subject: [PATCH 05/17] fix(cli): flush persistent process logs before readiness --- .../src/kilocode/background-process/runner.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/kilocode/background-process/runner.ts b/packages/opencode/src/kilocode/background-process/runner.ts index 89b7f71891..fe8488158d 100644 --- a/packages/opencode/src/kilocode/background-process/runner.ts +++ b/packages/opencode/src/kilocode/background-process/runner.ts @@ -64,23 +64,23 @@ export namespace BackgroundProcessRunner { let size = (await file.stat()).size let queue = Promise.resolve() const append = (chunk: Buffer) => { - if (!process.stdout.destroyed) process.stdout.write(chunk) queue = queue.then(async () => { if (size + chunk.length <= MAX) { await file.write(chunk) size += chunk.length - return + } else { + await file.close() + const source = Bun.file(input.log) + const old = size + ? Buffer.from(await source.slice(Math.max(0, size - KEEP), size).arrayBuffer()) + : Buffer.alloc(0) + const next = Buffer.concat([old, chunk]) + const tail = next.subarray(Math.max(0, next.length - KEEP)) + await Filesystem.write(input.log, tail, MODE) + file = await open(input.log, "a", MODE) + size = tail.length } - await file.close() - const source = Bun.file(input.log) - const old = size - ? Buffer.from(await source.slice(Math.max(0, size - KEEP), size).arrayBuffer()) - : Buffer.alloc(0) - const next = Buffer.concat([old, chunk]) - const tail = next.subarray(Math.max(0, next.length - KEEP)) - await Filesystem.write(input.log, tail, MODE) - file = await open(input.log, "a", MODE) - size = tail.length + if (!process.stdout.destroyed) process.stdout.write(chunk) }) } return { From 76d0b2103ee855c66ac6568f052f077febd4f30f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 30 Jun 2026 16:25:06 +0200 Subject: [PATCH 06/17] fix(agent-manager): keep chat in sync with session selection while offline selectSession bailed out entirely when the webview's backend connection was momentarily unavailable. In Agent Manager the side diff is resolved from the worktree selection independently of currentSessionID, so a switch during a transient disconnect moved the diff while the chat stayed frozen on the previous session (the 'switching only changes the sidebar diff' report). Update currentSessionID/draft synchronously regardless of connection so the chat always follows the selection, and gate only the message fetch on the connection. A fetch deferred while offline is replayed once the backend reconnects, scoped to the still-current unloaded session so the normal connected path never double-fetches. --- ...ix-agent-manager-session-switch-offline.md | 5 ++ .../unit/session-select-connection.test.ts | 61 +++++++++++++++++++ .../webview-ui/src/context/session.tsx | 43 ++++++++++--- 3 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-agent-manager-session-switch-offline.md create mode 100644 packages/kilo-vscode/tests/unit/session-select-connection.test.ts diff --git a/.changeset/fix-agent-manager-session-switch-offline.md b/.changeset/fix-agent-manager-session-switch-offline.md new file mode 100644 index 0000000000..c59b4f3d2a --- /dev/null +++ b/.changeset/fix-agent-manager-session-switch-offline.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the chat in sync with the selected Agent Manager session when the backend connection is briefly unavailable, so switching sessions no longer updates only the side diff while the conversation stays on the previous session. diff --git a/packages/kilo-vscode/tests/unit/session-select-connection.test.ts b/packages/kilo-vscode/tests/unit/session-select-connection.test.ts new file mode 100644 index 0000000000..f31152f54b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-select-connection.test.ts @@ -0,0 +1,61 @@ +/** + * Source contract test for selectSession's connection handling. + * + * Static analysis — reads session.tsx and verifies that selectSession updates + * the current session id BEFORE (and independently of) the backend connection + * check, and only defers the message fetch when offline. + * + * Regression guard: previously selectSession bailed out entirely when + * `server.isConnected()` was false. In Agent Manager the side diff is resolved + * from the worktree selection independently of currentSessionID, so a switch + * during a transient disconnect moved the diff but left the chat frozen on the + * previous session ("switching only changes the sidebar diff"). The chat must + * always follow the selection; only the network fetch may wait for reconnect. + */ + +import { describe, it, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx") + +const source = fs.readFileSync(SESSION_FILE, "utf-8") + +describe("selectSession keeps the chat in sync with the selection while offline", () => { + const start = source.indexOf("function selectSession(") + const cloudGuard = source.indexOf('id.startsWith("cloud:")', start) + const setCurrent = source.indexOf("setCurrentSessionID(id)", start) + const offlineDefer = source.indexOf("if (!server.isConnected()) {", start) + + it("selectSession exists", () => { + expect(start).toBeGreaterThan(-1) + }) + + it("returns early for cloud preview ids before touching the current session", () => { + expect(cloudGuard).toBeGreaterThan(start) + expect(cloudGuard).toBeLessThan(setCurrent) + }) + + it("sets currentSessionID before checking the connection (chat follows selection offline)", () => { + expect(setCurrent).toBeGreaterThan(-1) + expect(offlineDefer).toBeGreaterThan(-1) + // The whole point of the fix: the local selection update must precede the + // connection guard, so a disconnected switch no longer freezes the chat. + expect(setCurrent).toBeLessThan(offlineDefer) + }) + + it("defers (does not drop) the fetch when offline", () => { + const body = source.slice(start, source.indexOf("\n function selectCloudSession(")) + expect(body).toContain("deferredFetch") + }) +}) + +describe("a deferred fetch is replayed on reconnect", () => { + it("watches the connection and replays the deferred session load", () => { + expect(source).toContain("on(server.isConnected") + const effect = source.slice(source.indexOf("on(server.isConnected")) + expect(effect).toContain("deferredFetch") + expect(effect).toMatch(/loadMessages.*mode: "replace"/s) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 921fff8f8e..820167e775 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -10,6 +10,7 @@ import { createSignal, createMemo, createEffect, + on, onMount, onCleanup, batch, @@ -2519,27 +2520,53 @@ export const SessionProvider: ParentComponent = (props) => { }) } + // Session whose message fetch was deferred because the backend was offline at + // selection time. Replayed by the reconnect effect below. + let deferredFetch: string | undefined + function selectSession(id: string) { - if (!server.isConnected()) { - console.warn("[Kilo New] Cannot select session: not connected") - return - } + // Cloud preview sessions use a separate keyed path (selectCloudSession). if (id.startsWith("cloud:")) { console.warn("[Kilo New] Cannot select cloud preview session via selectSession") return } const ready = loaded().has(id) + // Reflect the selection locally and synchronously so the chat always tracks + // the sidebar/tab selection. These are local signals and need no backend, so + // they update even while disconnected. Bailing out here when not connected + // froze the chat on the previous session while the side diff (resolved from + // the worktree selection) still moved — the reported "only the diff changes". setCurrentSessionID(id) setDraftSessionID(id) setLoading(!ready) - if (ready) { - vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "focus" }) + if (!ready) patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false }) + // Only the message fetch needs the backend. Defer it while offline and let + // the reconnect effect replay it; cached sessions already render from store. + if (!server.isConnected()) { + deferredFetch = ready ? undefined : id return } - patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false }) - vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "replace", limit: MESSAGE_PAGE_LIMIT }) + deferredFetch = undefined + vscode.postMessage( + ready + ? { type: "loadMessages", sessionID: id, mode: "focus" } + : { type: "loadMessages", sessionID: id, mode: "replace", limit: MESSAGE_PAGE_LIMIT }, + ) } + // Replay a fetch deferred while offline once the backend reconnects. Scoped to + // the still-current, still-unloaded session so the normal connected path never + // double-fetches. + createEffect( + on(server.isConnected, (connected) => { + if (!connected) return + const id = deferredFetch + deferredFetch = undefined + if (!id || id !== currentSessionID() || loaded().has(id)) return + vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "replace", limit: MESSAGE_PAGE_LIMIT }) + }), + ) + function selectCloudSession(cloudSessionId: string) { if (!server.isConnected()) { console.warn("[Kilo New] Cannot select cloud session: not connected") From 4786b34dcbb8cdff8d8d6378dcb011d9c87b144f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 30 Jun 2026 17:00:05 +0200 Subject: [PATCH 07/17] fix(agent-manager): replay focus load for cached sessions on reconnect A session selected while offline now defers its load regardless of whether it was already cached, and the reconnect effect replays it with the same focus/replace choice as a live selection. Clearing the deferral for cached sessions skipped the focus load on reconnect, which is what re-focuses the backend (focusSession, contextSessionID, SSE tracking, active worktree) and runs the reconcile self-heal, so the extension could stay focused on the previously selected session. --- .../unit/session-select-connection.test.ts | 19 ++++++++++++---- .../webview-ui/src/context/session.tsx | 22 +++++++++++++------ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/session-select-connection.test.ts b/packages/kilo-vscode/tests/unit/session-select-connection.test.ts index f31152f54b..08798e4a23 100644 --- a/packages/kilo-vscode/tests/unit/session-select-connection.test.ts +++ b/packages/kilo-vscode/tests/unit/session-select-connection.test.ts @@ -45,9 +45,13 @@ describe("selectSession keeps the chat in sync with the selection while offline" expect(setCurrent).toBeLessThan(offlineDefer) }) - it("defers (does not drop) the fetch when offline", () => { - const body = source.slice(start, source.indexOf("\n function selectCloudSession(")) - expect(body).toContain("deferredFetch") + it("defers the fetch for any session while offline, including cached ones", () => { + const body = source.slice(start, source.indexOf("\n function loadFocusedMessages(")) + // Queue a replay unconditionally. The earlier `deferredFetch = ready ? undefined : id` + // form skipped cached sessions, so a reconnect never re-sent the focus load that + // re-focuses the backend (focusSession/contextSessionID/SSE tracking/reconcile). + expect(body).toContain("deferredFetch = id") + expect(body).not.toMatch(/deferredFetch\s*=\s*ready\s*\?/) }) }) @@ -56,6 +60,13 @@ describe("a deferred fetch is replayed on reconnect", () => { expect(source).toContain("on(server.isConnected") const effect = source.slice(source.indexOf("on(server.isConnected")) expect(effect).toContain("deferredFetch") - expect(effect).toMatch(/loadMessages.*mode: "replace"/s) + // Replays with the focus/replace choice so cached sessions still re-focus the backend. + expect(effect).toMatch(/loadFocusedMessages\(\s*id,\s*loaded\(\)\.has\(id\)\s*\)/) + }) + + it("the focused load helper sends focus for cached sessions and replace otherwise", () => { + const helper = source.slice(source.indexOf("function loadFocusedMessages(")) + expect(helper).toMatch(/mode: "focus"/) + expect(helper).toMatch(/mode: "replace"/) }) }) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 820167e775..66fee2ee5f 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -2535,18 +2535,25 @@ export const SessionProvider: ParentComponent = (props) => { // the sidebar/tab selection. These are local signals and need no backend, so // they update even while disconnected. Bailing out here when not connected // froze the chat on the previous session while the side diff (resolved from - // the worktree selection) still moved — the reported "only the diff changes". + // the worktree selection) still moved (the reported "only the diff changes"). setCurrentSessionID(id) setDraftSessionID(id) setLoading(!ready) if (!ready) patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false }) // Only the message fetch needs the backend. Defer it while offline and let - // the reconnect effect replay it; cached sessions already render from store. + // the reconnect effect replay it. We defer even for cached sessions: the + // load message is what re-focuses the backend (focusSession, contextSessionID, + // SSE tracking, active worktree) and runs the reconcile self-heal, so skipping + // it would leave the extension focused on the previously selected session. if (!server.isConnected()) { - deferredFetch = ready ? undefined : id + deferredFetch = id return } deferredFetch = undefined + loadFocusedMessages(id, ready) + } + + function loadFocusedMessages(id: string, ready: boolean) { vscode.postMessage( ready ? { type: "loadMessages", sessionID: id, mode: "focus" } @@ -2555,15 +2562,16 @@ export const SessionProvider: ParentComponent = (props) => { } // Replay a fetch deferred while offline once the backend reconnects. Scoped to - // the still-current, still-unloaded session so the normal connected path never - // double-fetches. + // the still-current session so the normal connected path never double-fetches. + // Uses the same focus/replace choice as a live selection so a reconnect after + // a cached-session switch still re-focuses the backend and reconciles. createEffect( on(server.isConnected, (connected) => { if (!connected) return const id = deferredFetch deferredFetch = undefined - if (!id || id !== currentSessionID() || loaded().has(id)) return - vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "replace", limit: MESSAGE_PAGE_LIMIT }) + if (!id || id !== currentSessionID()) return + loadFocusedMessages(id, loaded().has(id)) }), ) From 78cdc9c8960c8feb7dae0f334644da5a9a7be499 Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Tue, 30 Jun 2026 21:36:30 +0200 Subject: [PATCH 08/17] feat(vscode): persist model selector expand/collapse state (#11824) * feat(vscode): persist model selector expand/collapse state Save the model picker's preview-panel expand/collapse preference via the VS Code webview state API so it survives reload, hide/show, and restart. * chore(vscode): add model picker changeset * fix(vscode): address model selector review feedback --- .changeset/persist-model-picker-state.md | 5 +++++ .../src/components/shared/ModelSelector.tsx | 6 +++++- .../kilo-vscode/webview-ui/src/context/vscode.tsx | 11 +++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .changeset/persist-model-picker-state.md diff --git a/.changeset/persist-model-picker-state.md b/.changeset/persist-model-picker-state.md new file mode 100644 index 0000000000..2630a94f93 --- /dev/null +++ b/.changeset/persist-model-picker-state.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve the model picker preview panel expand/collapse state across reloads and restarts. diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index d08274bc50..92b93b8ea3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -30,6 +30,7 @@ import { useProvider } from "../../context/provider" import type { EnrichedModel } from "../../context/provider" import { useSession, SessionContext } from "../../context/session" import { useLanguage } from "../../context/language" +import { useVSCode } from "../../context/vscode" import type { ModelSelection } from "../../types/messages" import { isEnterKeyCommitNotIme } from "../../utils/ime-enter" import { @@ -138,6 +139,7 @@ export interface ModelSelectorBaseProps { export const ModelSelectorBase: Component = (props) => { const { connected, models, findModel } = useProvider() const language = useLanguage() + const vscode = useVSCode() // Session context is optional — ModelSelectorBase is also used in Settings // where SessionProvider may not be mounted. const session = useContext(SessionContext) @@ -153,7 +155,9 @@ export const ModelSelectorBase: Component = (props) => { }) const [open, setOpen] = createSignal(false) - const [expanded, setExpanded] = createSignal(true) + const [expanded, setExpanded] = createSignal(vscode.getModelSelectorExpanded()) + // Persist the user's expand/collapse choice so it is restored on reopen. + createEffect(() => vscode.setModelSelectorExpanded(expanded())) const [search, setSearch] = createSignal("") const [debouncedSearch, setDebouncedSearch] = createSignal("") const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY) diff --git a/packages/kilo-vscode/webview-ui/src/context/vscode.tsx b/packages/kilo-vscode/webview-ui/src/context/vscode.tsx index 69d7a64ce9..a76c26c3a8 100644 --- a/packages/kilo-vscode/webview-ui/src/context/vscode.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/vscode.tsx @@ -8,6 +8,7 @@ import type { VSCodeAPI, WebviewMessage, ExtensionMessage } from "../types/messa // Get the VS Code API (only available in webview context) let vscodeApi: VSCodeAPI | undefined +const EXPANDED = "modelSelectorExpanded" export function getVSCodeAPI(): VSCodeAPI { if (!vscodeApi) { @@ -33,6 +34,8 @@ interface VSCodeContextValue { onMessage: (handler: (message: ExtensionMessage) => void) => () => void getState: () => T | undefined setState: (state: T) => void + getModelSelectorExpanded: () => boolean + setModelSelectorExpanded: (value: boolean) => void } const VSCodeContext = createContext() @@ -64,6 +67,14 @@ export const VSCodeProvider: ParentComponent = (props) => { }, getState: () => api.getState() as T | undefined, setState: (state: T) => api.setState(state), + getModelSelectorExpanded: () => { + const state = api.getState() as Record | undefined + return state?.[EXPANDED] !== false + }, + setModelSelectorExpanded: (value: boolean) => { + const state = (api.getState() as Record | undefined) ?? {} + api.setState({ ...state, [EXPANDED]: value }) + }, } return {props.children} From bf5c86601a7e3ed37ac9900e45238897ab8fd4ff Mon Sep 17 00:00:00 2001 From: Daniel Gutknecht <15870776+danielgutknecht@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:04:29 -0300 Subject: [PATCH 09/17] fix(session): trim whitespace from tool names in experimental_repairToolCall (#10886) Adds .trim() before .toLowerCase() so tool names like " bash" are repaired to "bash" instead of falling through to the invalid handler. Adds an integration test that sends a space-padded tool name through the full AI SDK stream path and asserts the correct tool executes. Fixes #10140 Co-authored-by: Johnny Amancio --- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/test/session/llm.test.ts | 96 ++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index fcdd9645b4..bfacd5524e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -344,7 +344,7 @@ const live: Layer.Layer< }) }, async experimental_repairToolCall(failed) { - const lower = failed.toolCall.toolName.toLowerCase() + const lower = failed.toolCall.toolName.trim().toLowerCase() // kilocode_change if (lower !== failed.toolCall.toolName && prepared.tools[lower]) { l.info("repairing tool call", { tool: failed.toolCall.toolName, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index dd42c2845d..5688c94dee 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1904,4 +1904,100 @@ describe("session.llm.stream", () => { }), }, ) + + // kilocode_change start + it.instance( + "repairs whitespace-padded tool names and executes the correct tool", + () => + Effect.gen(function* () { + const fixture = loadFixture(alibabaQwenFixture.providerID, alibabaQwenFixture.modelID) + let toolExecuted = false + + waitRequest( + "/chat/completions", + createEventResponse( + [ + { + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: null, + tool_calls: [{ index: 0, id: "call-1", type: "function", function: { name: " bash", arguments: "" } }], + }, + }, + ], + }, + { + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: "{}" } }] } }], + }, + { + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }, + ], + true, + ), + ) + + const resolved = yield* Provider.use.getModel( + ProviderID.make(alibabaQwenFixture.providerID), + ModelID.make(fixture.model.id), + ) + const sessionID = SessionID.make("session-test-repair") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user-repair"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make(alibabaQwenFixture.providerID), modelID: resolved.id }, + } satisfies MessageV2.User + + yield* drain({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Run bash" }], + tools: { + bash: tool({ + description: "Run bash", + inputSchema: z.object({}), + execute: async () => { + toolExecuted = true + return { output: "" } + }, + }), + }, + }) + + expect(toolExecuted).toBe(true) + }), + { + config: () => ({ + enabled_providers: [alibabaQwenFixture.providerID], + provider: { + [alibabaQwenFixture.providerID]: { + options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` }, + }, + }, + }), + }, + ) + // kilocode_change end }) From f0cbf8c2ff0bf5038f58a3e6a5eccd13a421d0b4 Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Wed, 1 Jul 2026 12:07:07 +0200 Subject: [PATCH 10/17] feat(core): add soft max-cost nudge state (#11831) * feat(core): soft max-cost nudge state machine Surface-agnostic, dependency-free MaxCostNudge: aggregates per-assistant message cost into a session total (session.cost is written via direct SQL and never emits session.updated), and decides when to fire a non-blocking "keep going?" alert once a global limit is crossed. check() alerts once per run; resolve("continue") suppresses re-alerts for that limit; rearm() re-arms on a new run; onSessionDeleted() clears state. Consumers own the limit (set via setLimit) and the UI/abort. * feat(core): extend max cost nudge state * fix(core): clear stale message cost when value becomes non-finite * test(core): add missing cost aggregation and session isolation tests --- .../core/src/kilocode/cost/max-cost-nudge.ts | 151 ++++++++++ .../test/kilocode/cost/max-cost-nudge.test.ts | 280 ++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 packages/core/src/kilocode/cost/max-cost-nudge.ts create mode 100644 packages/core/test/kilocode/cost/max-cost-nudge.test.ts diff --git a/packages/core/src/kilocode/cost/max-cost-nudge.ts b/packages/core/src/kilocode/cost/max-cost-nudge.ts new file mode 100644 index 0000000000..b276689b43 --- /dev/null +++ b/packages/core/src/kilocode/cost/max-cost-nudge.ts @@ -0,0 +1,151 @@ +/** + * Soft per-session max-cost nudge. + * + * Alert (not hard-stop) the moment a session's cumulative cost crosses a + * whole-dollar threshold. The alert is non-blocking: the session keeps running + * while it is shown. Continue dismisses it (won't nag again for that limit); + * Stop is the surface's cue to abort. + * + * Cost signal: `SessionTable.cost` is written via direct SQL during message-part + * projection, so `session.updated` does NOT fire on cost change. The reliable + * signal is the per-assistant-message `cost`, summed here into a session total. + */ + +export type MaxCostChoice = "continue" | "stop" + +// Minimal shape of a message needed to aggregate session cost. +export interface MaxCostMessage { + id: string + sessionID: string + role?: string + cost?: number +} + +export class MaxCostNudge { + readonly #msgs = new Map() + readonly #totals = new Map() + readonly #floors = new Map() + readonly #alerted = new Map>() // sid -> limit values shown this run + readonly #acked = new Map>() // sid -> limit values continued past + + #limit: number | undefined + + // `> 0` rounds up to whole dollars; everything else disables (undefined). + static normalizeLimit(value: number | undefined | null): number | undefined { + if (value == null || !Number.isFinite(value) || value <= 0) return undefined + return Math.ceil(value) + } + + // Format a cost as `$X.XX`, with 4 decimals below $1. + static formatCost(value: number): string { + return `$${value.toFixed(value < 1 ? 4 : 2)}` + } + + setLimit(value: number | undefined | null): void { + this.#limit = MaxCostNudge.normalizeLimit(value) + } + + get limit(): number | undefined { + return this.#limit + } + + // Rebuild a session's total from a full message snapshot (seed on load). + resetMessageCosts(sid: string, messages: MaxCostMessage[]): number { + this.#dropMessages(sid) + let total = 0 + for (const msg of messages) { + if (msg.sessionID !== sid || msg.role !== "assistant" || !Number.isFinite(msg.cost)) continue + const cost = msg.cost ?? 0 + this.#msgs.set(msg.id, { sid, cost }) + total += cost + } + this.#totals.set(sid, total) + return this.sessionCost(sid) + } + + // Floor the session total with a direct cost signal (e.g. session.cost). Monotonic. + setSessionCost(sid: string, value: number): number { + if (Number.isFinite(value)) this.#floors.set(sid, Math.max(this.#floors.get(sid) ?? 0, value)) + return this.sessionCost(sid) + } + + // Record an assistant message cost (message.updated). Returns the session total. + updateMessageCost(sid: string, id: string, role: string | undefined, value: number | undefined): number { + if (role === "assistant") { + const prev = this.#msgs.get(id) + if (Number.isFinite(value)) { + if (prev && prev.sid !== sid) { + this.#totals.set(prev.sid, Math.max(0, (this.#totals.get(prev.sid) ?? 0) - prev.cost)) + } + const before = prev?.sid === sid ? prev.cost : 0 + const cost = value! + this.#msgs.set(id, { sid, cost }) + this.#totals.set(sid, Math.max(0, (this.#totals.get(sid) ?? 0) - before + cost)) + } else if (prev) { + // value became non-finite — drop the stale contribution + this.#totals.set(prev.sid, Math.max(0, (this.#totals.get(prev.sid) ?? 0) - prev.cost)) + this.#msgs.delete(id) + } + } + return this.sessionCost(sid) + } + + // Drop a message's contribution (message.removed). + removeMessageCost(id: string): void { + const prev = this.#msgs.get(id) + if (!prev) return + this.#msgs.delete(id) + this.#totals.set(prev.sid, Math.max(0, (this.#totals.get(prev.sid) ?? 0) - prev.cost)) + } + + sessionCost(sid: string): number { + return Math.max(this.#totals.get(sid) ?? 0, this.#floors.get(sid) ?? 0) + } + + /** + * Decide whether to alert for `sid` now. Returns the limit + cost to show + * once per run, or undefined (below limit, already acknowledged, or already + * showing). Re-arm with {@link rearm} when the session runs again. + */ + check(sid: string): { limit: number; cost: number } | undefined { + const limit = this.#limit + if (limit === undefined) return undefined + const cost = this.sessionCost(sid) + if (cost < limit || this.#acked.get(sid)?.has(limit) || this.#alerted.get(sid)?.has(limit)) return undefined + this.#remember(this.#alerted, sid, limit) + return { limit, cost } + } + + // Apply the user's choice. Continue suppresses re-alerts for the current limit. + resolve(sid: string, choice: MaxCostChoice, limit = this.#limit): void { + if (choice === "continue" && limit !== undefined) this.#remember(this.#acked, sid, limit) + } + + // Re-arm alerts for a session that started running again. + rearm(sid: string): void { + this.#alerted.delete(sid) + } + + // Forget all state for a deleted session. + onSessionDeleted(sid: string): void { + this.#dropMessages(sid) + this.#totals.delete(sid) + this.#floors.delete(sid) + this.#alerted.delete(sid) + this.#acked.delete(sid) + } + + // Drop every message contribution belonging to a session. + #dropMessages(sid: string): void { + for (const [id, msg] of this.#msgs) { + if (msg.sid === sid) this.#msgs.delete(id) + } + } + + // Record a limit value seen for a session. + #remember(map: Map>, sid: string, limit: number): void { + const seen = map.get(sid) + if (seen) seen.add(limit) + else map.set(sid, new Set([limit])) + } +} diff --git a/packages/core/test/kilocode/cost/max-cost-nudge.test.ts b/packages/core/test/kilocode/cost/max-cost-nudge.test.ts new file mode 100644 index 0000000000..adcded41c0 --- /dev/null +++ b/packages/core/test/kilocode/cost/max-cost-nudge.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, test } from "bun:test" +import { MaxCostNudge } from "../../../src/kilocode/cost/max-cost-nudge" + +const sid = "ses_1" + +function assistant(id: string, cost: number, sessionID = sid) { + return { id, sessionID, role: "assistant", cost } +} + +describe("MaxCostNudge.normalizeLimit", () => { + test("disables unset and non-positive values", () => { + expect(MaxCostNudge.normalizeLimit(undefined)).toBeUndefined() + expect(MaxCostNudge.normalizeLimit(null)).toBeUndefined() + expect(MaxCostNudge.normalizeLimit(0)).toBeUndefined() + expect(MaxCostNudge.normalizeLimit(-1)).toBeUndefined() + expect(MaxCostNudge.normalizeLimit(Number.NaN)).toBeUndefined() + }) + + test("rounds positive values up to whole dollars", () => { + expect(MaxCostNudge.normalizeLimit(5)).toBe(5) + expect(MaxCostNudge.normalizeLimit(4.2)).toBe(5) + expect(MaxCostNudge.normalizeLimit(0.01)).toBe(1) + }) +}) + +describe("MaxCostNudge.formatCost", () => { + test("uses extra precision below one dollar", () => { + expect(MaxCostNudge.formatCost(0.5)).toBe("$0.5000") + expect(MaxCostNudge.formatCost(0.0001)).toBe("$0.0001") + expect(MaxCostNudge.formatCost(1.5)).toBe("$1.50") + expect(MaxCostNudge.formatCost(12)).toBe("$12.00") + }) +}) + +describe("MaxCostNudge cost aggregation", () => { + test("sums assistant costs for the requested session", () => { + const nudge = new MaxCostNudge() + const total = nudge.resetMessageCosts(sid, [ + assistant("a1", 1), + { id: "u1", sessionID: sid, role: "user" }, + assistant("a2", 2.5), + assistant("a3", 9, "ses_2"), + ]) + + expect(total).toBe(3.5) + expect(nudge.sessionCost(sid)).toBe(3.5) + expect(nudge.sessionCost("ses_2")).toBe(0) + }) + + test("replaces existing message cost instead of double counting", () => { + const nudge = new MaxCostNudge() + nudge.resetMessageCosts(sid, [assistant("a1", 1)]) + + expect(nudge.updateMessageCost(sid, "a1", "assistant", 4)).toBe(4) + expect(nudge.updateMessageCost(sid, "a2", "assistant", 1)).toBe(5) + expect(nudge.sessionCost(sid)).toBe(5) + }) + + test("reset replaces stale message costs for the session", () => { + const nudge = new MaxCostNudge() + nudge.resetMessageCosts(sid, [assistant("a1", 4), assistant("a2", 3)]) + nudge.resetMessageCosts(sid, [assistant("a2", 1)]) + + expect(nudge.sessionCost(sid)).toBe(1) + }) + + test("floors total from direct session cost signal", () => { + const nudge = new MaxCostNudge() + + nudge.updateMessageCost(sid, "a1", "assistant", 2) + + expect(nudge.setSessionCost(sid, 5)).toBe(5) + expect(nudge.setSessionCost(sid, 3)).toBe(5) + expect(nudge.setSessionCost(sid, Number.NaN)).toBe(5) + expect(nudge.sessionCost(sid)).toBe(5) + }) + + test("does not overcount when a floored message later updates", () => { + const nudge = new MaxCostNudge() + + nudge.updateMessageCost(sid, "a1", "assistant", 2) + nudge.setSessionCost(sid, 5) + // The message's own cost catches up to the floor; total must not stack to 8. + nudge.updateMessageCost(sid, "a1", "assistant", 5) + + expect(nudge.sessionCost(sid)).toBe(5) + }) + + test("moves message cost between sessions", () => { + const nudge = new MaxCostNudge() + + nudge.updateMessageCost("ses_a", "m1", "assistant", 5) + nudge.updateMessageCost("ses_b", "m1", "assistant", 7) + + expect(nudge.sessionCost("ses_a")).toBe(0) + expect(nudge.sessionCost("ses_b")).toBe(7) + }) + + test("removes a message contribution", () => { + const nudge = new MaxCostNudge() + nudge.resetMessageCosts(sid, [assistant("a1", 2), assistant("a2", 3)]) + nudge.removeMessageCost("a1") + + expect(nudge.sessionCost(sid)).toBe(3) + }) + + test("clears stale cost when value becomes non-finite", () => { + const nudge = new MaxCostNudge() + nudge.updateMessageCost(sid, "a1", "assistant", 5) + nudge.updateMessageCost(sid, "a1", "assistant", undefined) + + expect(nudge.sessionCost(sid)).toBe(0) + }) + + test("ignores non-assistant message costs", () => { + const nudge = new MaxCostNudge() + nudge.updateMessageCost(sid, "a1", "assistant", 3) + + expect(nudge.updateMessageCost(sid, "u1", "user", 10)).toBe(3) + expect(nudge.sessionCost(sid)).toBe(3) + }) + + test("ignores non-finite assistant costs for new messages", () => { + const nudge = new MaxCostNudge() + nudge.updateMessageCost(sid, "a1", "assistant", 3) + + expect(nudge.updateMessageCost(sid, "a2", "assistant", Number.NaN)).toBe(3) + expect(nudge.updateMessageCost(sid, "a3", "assistant", undefined)).toBe(3) + expect(nudge.sessionCost(sid)).toBe(3) + }) + + test("removing a message does not drop below the session-cost floor", () => { + const nudge = new MaxCostNudge() + nudge.updateMessageCost(sid, "a1", "assistant", 4) + nudge.setSessionCost(sid, 6) + + nudge.removeMessageCost("a1") + expect(nudge.sessionCost(sid)).toBe(6) + }) +}) + +describe("MaxCostNudge alerts", () => { + test("alerts once when the session crosses the limit", () => { + const nudge = new MaxCostNudge() + nudge.setLimit(5) + + nudge.updateMessageCost(sid, "a1", "assistant", 4.99) + expect(nudge.check(sid)).toBeUndefined() + + nudge.updateMessageCost(sid, "a2", "assistant", 0.01) + expect(nudge.check(sid)).toEqual({ limit: 5, cost: 5 }) + expect(nudge.check(sid)).toBeUndefined() + }) + + test("never alerts without a configured limit", () => { + const nudge = new MaxCostNudge() + nudge.updateMessageCost(sid, "a1", "assistant", 999) + + expect(nudge.check(sid)).toBeUndefined() + }) + + test("continue suppresses re-alerts until the limit changes", () => { + const nudge = new MaxCostNudge() + nudge.setLimit(5) + nudge.updateMessageCost(sid, "a1", "assistant", 6) + + expect(nudge.check(sid)?.cost).toBe(6) + nudge.resolve(sid, "continue") + + nudge.rearm(sid) + expect(nudge.check(sid)).toBeUndefined() + + nudge.setLimit(10) + nudge.updateMessageCost(sid, "a2", "assistant", 5) + expect(nudge.check(sid)).toEqual({ limit: 10, cost: 11 }) + }) + + test("active alerts are keyed by limit", () => { + const nudge = new MaxCostNudge() + + nudge.setLimit(5) + nudge.updateMessageCost(sid, "a1", "assistant", 7) + + expect(nudge.check(sid)).toEqual({ limit: 5, cost: 7 }) + + nudge.setLimit(6) + expect(nudge.check(sid)).toEqual({ limit: 6, cost: 7 }) + }) + + test("resolving with explicit limit acks that limit", () => { + const nudge = new MaxCostNudge() + + nudge.setLimit(5) + nudge.updateMessageCost(sid, "a1", "assistant", 7) + + expect(nudge.check(sid)).toEqual({ limit: 5, cost: 7 }) + nudge.setLimit(6) + nudge.resolve(sid, "continue", 5) + + nudge.rearm(sid) + expect(nudge.check(sid)).toEqual({ limit: 6, cost: 7 }) + + nudge.setLimit(5) + nudge.rearm(sid) + expect(nudge.check(sid)).toBeUndefined() + }) + + test("does not re-alert a limit value already seen this run", () => { + const nudge = new MaxCostNudge() + nudge.setLimit(5) + nudge.updateMessageCost(sid, "a1", "assistant", 7) + + expect(nudge.check(sid)).toEqual({ limit: 5, cost: 7 }) + nudge.setLimit(6) + expect(nudge.check(sid)).toEqual({ limit: 6, cost: 7 }) + + // Flipping the limit back to an already-alerted value stays silent. + nudge.setLimit(5) + expect(nudge.check(sid)).toBeUndefined() + }) + + test("continue persists per limit value across other limits", () => { + const nudge = new MaxCostNudge() + nudge.setLimit(5) + nudge.updateMessageCost(sid, "a1", "assistant", 7) + nudge.check(sid) + nudge.resolve(sid, "continue") + + nudge.setLimit(10) + nudge.updateMessageCost(sid, "a2", "assistant", 5) + nudge.check(sid) + nudge.resolve(sid, "continue") + + // Dropping back to an already-continued value stays silent. + nudge.setLimit(5) + nudge.rearm(sid) + expect(nudge.check(sid)).toBeUndefined() + }) + + test("rearm re-alerts after a stop and a new run", () => { + const nudge = new MaxCostNudge() + nudge.setLimit(5) + nudge.updateMessageCost(sid, "a1", "assistant", 7) + + expect(nudge.check(sid)?.cost).toBe(7) + nudge.resolve(sid, "stop") + expect(nudge.check(sid)).toBeUndefined() + + nudge.rearm(sid) + nudge.updateMessageCost(sid, "a2", "assistant", 1) + expect(nudge.check(sid)).toEqual({ limit: 5, cost: 8 }) + }) +}) + +describe("MaxCostNudge.onSessionDeleted", () => { + test("clears cost and alert state", () => { + const nudge = new MaxCostNudge() + nudge.setLimit(5) + nudge.resetMessageCosts(sid, [assistant("a1", 9)]) + nudge.check(sid) + + nudge.onSessionDeleted(sid) + expect(nudge.sessionCost(sid)).toBe(0) + + // A reused session id starts fresh and can alert again. + nudge.updateMessageCost(sid, "a2", "assistant", 6) + expect(nudge.check(sid)).toEqual({ limit: 5, cost: 6 }) + }) + + test("leaves other sessions intact", () => { + const nudge = new MaxCostNudge() + nudge.updateMessageCost("ses_a", "a1", "assistant", 4) + nudge.updateMessageCost("ses_b", "b1", "assistant", 7) + + nudge.onSessionDeleted("ses_a") + + expect(nudge.sessionCost("ses_a")).toBe(0) + expect(nudge.sessionCost("ses_b")).toBe(7) + }) +}) From 566e069532ae7244489f2c57ca79666495e3fba3 Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Wed, 1 Jul 2026 12:08:28 +0200 Subject: [PATCH 11/17] fix(memory): add core project memory package (#11355) --- .changeset/kilo-memory-core.md | 5 + bun.lock | 15 + packages/kilo-memory/package.json | 48 + packages/kilo-memory/src/capture/capture.ts | 7 + packages/kilo-memory/src/capture/diff.ts | 29 + .../kilo-memory/src/capture/digest-text.ts | 51 + packages/kilo-memory/src/capture/digest.ts | 15 + packages/kilo-memory/src/capture/ops.ts | 338 +++++ packages/kilo-memory/src/capture/outcome.ts | 194 +++ packages/kilo-memory/src/capture/parse.ts | 144 +++ packages/kilo-memory/src/capture/plan.ts | 59 + packages/kilo-memory/src/capture/redact.ts | 90 ++ packages/kilo-memory/src/capture/reject.ts | 44 + packages/kilo-memory/src/commands.ts | 125 ++ packages/kilo-memory/src/index.ts | 11 + packages/kilo-memory/src/memory-notice.ts | 36 + packages/kilo-memory/src/memory.ts | 363 ++++++ .../src/prompts/session-digest.txt | 33 + packages/kilo-memory/src/prompts/txt.d.ts | 4 + .../src/prompts/typed-consolidation.txt | 92 ++ packages/kilo-memory/src/recall/budget.ts | 108 ++ .../kilo-memory/src/recall/index-format.ts | 96 ++ packages/kilo-memory/src/recall/indexer.ts | 195 +++ packages/kilo-memory/src/recall/recall.ts | 265 ++++ packages/kilo-memory/src/recall/shared.ts | 108 ++ packages/kilo-memory/src/recall/token.ts | 7 + packages/kilo-memory/src/recall/topics.ts | 55 + packages/kilo-memory/src/schema.ts | 212 ++++ packages/kilo-memory/src/slug.ts | 25 + packages/kilo-memory/src/storage/audit.ts | 132 ++ packages/kilo-memory/src/storage/fs.ts | 241 ++++ packages/kilo-memory/src/storage/markdown.ts | 74 ++ packages/kilo-memory/src/storage/paths.ts | 126 ++ packages/kilo-memory/src/storage/sessions.ts | 174 +++ packages/kilo-memory/src/storage/sources.ts | 69 + packages/kilo-memory/src/storage/state.ts | 231 ++++ packages/kilo-memory/src/storage/store.ts | 44 + packages/kilo-memory/src/text.ts | 20 + packages/kilo-memory/test/capture.test.ts | 388 ++++++ packages/kilo-memory/test/command-cases.json | 119 ++ packages/kilo-memory/test/commands.test.ts | 54 + packages/kilo-memory/test/core.test.ts | 1122 +++++++++++++++++ packages/kilo-memory/test/markdown.test.ts | 91 ++ packages/kilo-memory/test/memory.test.ts | 139 ++ .../kilo-memory/test/recall-fixtures.test.ts | 170 +++ packages/kilo-memory/test/text.test.ts | 20 + packages/kilo-memory/tsconfig.json | 15 + 47 files changed, 6003 insertions(+) create mode 100644 .changeset/kilo-memory-core.md create mode 100644 packages/kilo-memory/package.json create mode 100644 packages/kilo-memory/src/capture/capture.ts create mode 100644 packages/kilo-memory/src/capture/diff.ts create mode 100644 packages/kilo-memory/src/capture/digest-text.ts create mode 100644 packages/kilo-memory/src/capture/digest.ts create mode 100644 packages/kilo-memory/src/capture/ops.ts create mode 100644 packages/kilo-memory/src/capture/outcome.ts create mode 100644 packages/kilo-memory/src/capture/parse.ts create mode 100644 packages/kilo-memory/src/capture/plan.ts create mode 100644 packages/kilo-memory/src/capture/redact.ts create mode 100644 packages/kilo-memory/src/capture/reject.ts create mode 100644 packages/kilo-memory/src/commands.ts create mode 100644 packages/kilo-memory/src/index.ts create mode 100644 packages/kilo-memory/src/memory-notice.ts create mode 100644 packages/kilo-memory/src/memory.ts create mode 100644 packages/kilo-memory/src/prompts/session-digest.txt create mode 100644 packages/kilo-memory/src/prompts/txt.d.ts create mode 100644 packages/kilo-memory/src/prompts/typed-consolidation.txt create mode 100644 packages/kilo-memory/src/recall/budget.ts create mode 100644 packages/kilo-memory/src/recall/index-format.ts create mode 100644 packages/kilo-memory/src/recall/indexer.ts create mode 100644 packages/kilo-memory/src/recall/recall.ts create mode 100644 packages/kilo-memory/src/recall/shared.ts create mode 100644 packages/kilo-memory/src/recall/token.ts create mode 100644 packages/kilo-memory/src/recall/topics.ts create mode 100644 packages/kilo-memory/src/schema.ts create mode 100644 packages/kilo-memory/src/slug.ts create mode 100644 packages/kilo-memory/src/storage/audit.ts create mode 100644 packages/kilo-memory/src/storage/fs.ts create mode 100644 packages/kilo-memory/src/storage/markdown.ts create mode 100644 packages/kilo-memory/src/storage/paths.ts create mode 100644 packages/kilo-memory/src/storage/sessions.ts create mode 100644 packages/kilo-memory/src/storage/sources.ts create mode 100644 packages/kilo-memory/src/storage/state.ts create mode 100644 packages/kilo-memory/src/storage/store.ts create mode 100644 packages/kilo-memory/src/text.ts create mode 100644 packages/kilo-memory/test/capture.test.ts create mode 100644 packages/kilo-memory/test/command-cases.json create mode 100644 packages/kilo-memory/test/commands.test.ts create mode 100644 packages/kilo-memory/test/core.test.ts create mode 100644 packages/kilo-memory/test/markdown.test.ts create mode 100644 packages/kilo-memory/test/memory.test.ts create mode 100644 packages/kilo-memory/test/recall-fixtures.test.ts create mode 100644 packages/kilo-memory/test/text.test.ts create mode 100644 packages/kilo-memory/tsconfig.json diff --git a/.changeset/kilo-memory-core.md b/.changeset/kilo-memory-core.md new file mode 100644 index 0000000000..0e766ecf75 --- /dev/null +++ b/.changeset/kilo-memory-core.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-memory": minor +--- + +Add the project memory storage, indexing, recall, and capture safety foundation. diff --git a/bun.lock b/bun.lock index b31c5ff38b..82762a5439 100644 --- a/bun.lock +++ b/bun.lock @@ -265,6 +265,19 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/kilo-memory": { + "name": "@kilocode/kilo-memory", + "version": "7.3.45", + "dependencies": { + "zod": "catalog:", + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", "version": "7.3.63", @@ -1316,6 +1329,8 @@ "@kilocode/kilo-jetbrains": ["@kilocode/kilo-jetbrains@workspace:packages/kilo-jetbrains"], + "@kilocode/kilo-memory": ["@kilocode/kilo-memory@workspace:packages/kilo-memory"], + "@kilocode/kilo-telemetry": ["@kilocode/kilo-telemetry@workspace:packages/kilo-telemetry"], "@kilocode/kilo-ui": ["@kilocode/kilo-ui@workspace:packages/kilo-ui"], diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json new file mode 100644 index 0000000000..936287f0e0 --- /dev/null +++ b/packages/kilo-memory/package.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@kilocode/kilo-memory", + "version": "7.3.45", + "type": "module", + "license": "MIT", + "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", + "keywords": [ + "kilo", + "kilocode", + "memory", + "agent" + ], + "exports": { + ".": "./src/index.ts", + "./capture": "./src/capture/capture.ts", + "./commands": "./src/commands.ts", + "./digest": "./src/capture/digest.ts", + "./memory": "./src/memory.ts", + "./ops": "./src/capture/ops.ts", + "./paths": "./src/storage/paths.ts", + "./recall": "./src/recall/recall.ts", + "./redact": "./src/capture/redact.ts", + "./schema": "./src/schema.ts", + "./shared": "./src/recall/shared.ts", + "./store": "./src/storage/store.ts" + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "typecheck": "tsgo --noEmit", + "build": "tsc", + "test": "bun test --timeout 30000", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" + }, + "dependencies": { + "zod": "catalog:" + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": {} +} diff --git a/packages/kilo-memory/src/capture/capture.ts b/packages/kilo-memory/src/capture/capture.ts new file mode 100644 index 0000000000..bccf2516f3 --- /dev/null +++ b/packages/kilo-memory/src/capture/capture.ts @@ -0,0 +1,7 @@ +// Public entry point (`@kilocode/kilo-memory/capture`). Implementation lives in focused siblings; +// this barrel keeps the import surface stable. +export * from "./parse" +export * from "./diff" +export * from "./digest-text" +export * from "./plan" +export * from "./outcome" diff --git a/packages/kilo-memory/src/capture/diff.ts b/packages/kilo-memory/src/capture/diff.ts new file mode 100644 index 0000000000..d6ed1d72dd --- /dev/null +++ b/packages/kilo-memory/src/capture/diff.ts @@ -0,0 +1,29 @@ +export type CaptureDiff = { + file?: string + status?: string + additions: number + deletions: number +} + +const durable = + /(^|\/)(AGENTS\.md|README(?:\.[^/]*)?|docs?\/.+|package\.json|bun\.lock|pnpm-lock\.yaml|package-lock\.json|turbo\.json|tsconfig[^/]*\.json|vite\.config|eslint|biome|prettier|kilo\.json|\.kilo\/.+|[^/]*(test|spec|config|command|agent|workflow)[^/]*\.(ts|tsx|js|json|md|yml|yaml))$/i + +export function hasDurableDiff(diffs: Pick[]) { + return diffs.some((item) => { + const file = item.file ?? "" + if (!file) return false + if (durable.test(file)) return true + return item.additions + item.deletions >= 20 && /\.(md|json|ya?ml|toml|ts|tsx|js)$/.test(file) + }) +} + +export function summarizeDiffs(diffs: Pick[]) { + return diffs + .filter((item) => item.file) + .slice(0, 20) + .map((item) => { + const status = item.status ?? "modified" + return `${status} ${item.file} +${item.additions} -${item.deletions}` + }) + .join("\n") +} diff --git a/packages/kilo-memory/src/capture/digest-text.ts b/packages/kilo-memory/src/capture/digest-text.ts new file mode 100644 index 0000000000..4d82588332 --- /dev/null +++ b/packages/kilo-memory/src/capture/digest-text.ts @@ -0,0 +1,51 @@ +import { MemoryDigest } from "./digest" +import { MemoryRedact } from "./redact" +import { MemoryShared } from "../recall/shared" +import type { CaptureDigest } from "./parse" + +export function cap(input: string, max: number) { + if (Buffer.byteLength(input) <= max) return input + const chars: string[] = [] + let bytes = 0 + for (const char of input) { + const size = Buffer.byteLength(char) + if (bytes + size > max) break + chars.push(char) + bytes += size + } + return chars.join("") +} + +function body(input: string | undefined, fallback = "(empty)") { + const text = MemoryRedact.text(input?.trim().replaceAll("```", "'''") ?? "") + return text || fallback +} + +export function evidence(sections: { title: string; body?: string }[]) { + return [ + "```kilo-memory-evidence-v1", + ...sections.flatMap((section) => [`## ${section.title}`, body(section.body)]), + "```", + ].join("\n") +} + +export function summarize(input: { user: string; assistant: string; max: number }) { + const user = MemoryShared.brief(MemoryRedact.text(input.user), Math.max(24, Math.floor(input.max * 0.45))) + const assistant = MemoryShared.brief(MemoryRedact.text(input.assistant), Math.max(24, Math.floor(input.max * 0.45))) + const text = [user ? `User: ${user}` : "", assistant ? `Result: ${assistant}` : ""].filter(Boolean).join(" ") + return MemoryShared.brief(text, input.max) +} + +export function fallbackDigest(input: { prior?: string; summary: string; max: number }) { + if (!input.prior?.trim()) return MemoryShared.brief(input.summary, input.max) + const prior = MemoryShared.brief(input.prior ?? "", Math.max(0, Math.floor(input.max * 0.55))) + const latest = MemoryShared.brief(input.summary, Math.max(0, input.max - prior.length - 9)) + return MemoryShared.brief([prior, latest ? `Latest: ${latest}` : ""].filter(Boolean).join(" "), input.max) +} + +export function parseDigest(input: CaptureDigest, fallback: string, max: number) { + const summary = MemoryShared.brief(input.summary.trim() || fallback, max) + const topic = MemoryShared.brief(input.topic.trim() || summary.split(/[.;:]/)[0] || summary, 80) + if (MemoryDigest.empty({ topic, summary })) return { topic: "", summary: "" } + return { topic, summary } +} diff --git a/packages/kilo-memory/src/capture/digest.ts b/packages/kilo-memory/src/capture/digest.ts new file mode 100644 index 0000000000..7a31f92a58 --- /dev/null +++ b/packages/kilo-memory/src/capture/digest.ts @@ -0,0 +1,15 @@ +export namespace MemoryDigest { + export type Summary = { + topic?: string + summary: string + } + + function blank(input: string | undefined) { + return !input?.trim() + } + + export function empty(input: string | Summary) { + if (typeof input === "string") return blank(input) + return blank(input.topic) && blank(input.summary) + } +} diff --git a/packages/kilo-memory/src/capture/ops.ts b/packages/kilo-memory/src/capture/ops.ts new file mode 100644 index 0000000000..6332eb2f1a --- /dev/null +++ b/packages/kilo-memory/src/capture/ops.ts @@ -0,0 +1,338 @@ +import { MemoryFiles } from "../storage/store" +import { MemoryIndexer } from "../recall/indexer" +import { MemoryMarkdown } from "../storage/markdown" +import { MemoryRedact } from "./redact" +import { MemoryReject } from "./reject" +import { MemorySchema } from "../schema" +import { MemoryShared } from "../recall/shared" +import { MemoryText } from "../text" +import { MemoryTopics } from "../recall/topics" +import { MemorySlug } from "../slug" + +/** Low-level raw-root operation applier. Prefer the Memory facade outside package adapters. */ +export namespace MemoryOperations { + export type Add = { + action: "add" + file?: MemorySchema.Source + section?: string + key: string + text: string + } + + export type Remove = { + action: "remove" + query: string + } + + export type Op = Add | Remove + + export type Result = { + operationCount: number + added: number + removed: number + skipped: Rejection[] + index: MemoryIndexer.Result + } + + // Content gating lives in MemoryReject; re-exported so MemoryOperations.reject/Rejection stay the stable surface. + export type Rejection = MemoryReject.Rejection + export const reject = MemoryReject.reject + + function key(input: string) { + const slug = MemorySlug.safe(input.trim(), { max: MemorySlug.max.key, fallback: "", lower: true }) + if (slug) return slug + return MemorySlug.hash(input, "memory") + } + + function line(input: Add, max: number) { + if (MemoryRedact.has(input.text) || MemoryRedact.has(input.key)) { + throw new Error("memory operation rejected secret-like content") + } + const id = key(input.key) + const body = MemoryText.brief(input.text, max) + if (!id) throw new Error("memory operation key is required") + if (!body) throw new Error("memory operation text is required") + return { key: id, text: body, line: MemoryMarkdown.line(id, body) } + } + + type Prepared = { + op: Add + file: MemorySchema.Source + section: string + key: string + text: string + line: string + } + + function fallback(file: MemorySchema.Source | undefined) { + if (file === "environment.md") return "Commands" + if (file === "corrections.md") return "Corrections" + return "Facts" + } + + function section(input: string | undefined, file: MemorySchema.Source) { + const clean = input + ?.trim() + .replaceAll(/[\x00-\x1f\x7f]+/g, " ") + .replaceAll(/\s+/g, " ") + .replaceAll(/^#+\s*/g, "") + .replaceAll(/^\-\s+/g, "") + .replaceAll(/\s+::\s+/g, " ") + .trim() + .slice(0, 80) + .trim() + return clean || fallback(file) + } + + function heading(input: Add, file = input.file) { + return section(input.section, file ?? "project.md") + } + + function source(input: Add) { + if (input.file) return input.file + return "project.md" + } + + type Target = { + ids: Set + items: { file: MemorySchema.Source; section: string; key: string }[] + fallback?: string + } + + function target(input: { query: string; inventory: MemoryFiles.Inventory }): Target { + const query = input.query.trim() + const slug = key(query) + const ids = new Set() + const items: Target["items"] = [] + if (!query) return { ids, items } + for (const [id, item] of Object.entries(input.inventory.items)) { + const aliases = new Set([id, item.key, `${item.file}:${item.key}`, `${item.file}:${item.section}:${item.key}`]) + if (!aliases.has(query) && (!slug || !aliases.has(slug))) continue + ids.add(id) + items.push({ file: item.file, section: item.section, key: item.key }) + } + return { ids, items, ...(ids.size === 0 ? { fallback: slug || query } : {}) } + } + + function prepare(input: { state: MemorySchema.State; ops: Op[]; max: number }) { + const skipped: Rejection[] = [] + const adds = input.ops + .filter((item): item is Add => item.action === "add") + .filter((op) => { + const item = reject(op) + if (!item) return true + skipped.push(item) + return false + }) + .map((op) => { + const file = source(op) + if (!(MemorySchema.Sources as readonly MemorySchema.Source[]).includes(file)) { + throw new Error(`memory source ${file} is not valid for project`) + } + const section = heading(op, file) + const item = line(op, input.max) + return { + op, + file, + section, + key: item.key, + text: item.text, + line: item.line, + } satisfies Prepared + }) + return { adds, skipped } + } + + function words(input: string) { + return MemoryShared.terms(MemoryText.normalized(input)) + } + + function similar(left: string, right: string) { + const a = MemoryText.normalized(left) + const b = MemoryText.normalized(right) + if (!a || !b) return false + if (a === b) return true + if (Math.min(a.length, b.length) >= 24 && (a.includes(b) || b.includes(a))) return true + const one = words(a) + const two = words(b) + const min = Math.min(one.length, two.length) + if (min < 4) return false + const overlap = one.filter((item) => two.includes(item)).length + return overlap / min >= 0.85 + } + + function duplicate(input: { item: Prepared; inventory: MemoryFiles.Inventory }) { + return Object.values(input.inventory.items).find( + (item) => + item.file === input.item.file && + item.section === input.item.section && + (item.key === input.item.key || similar(item.text, input.item.text)), + ) + } + + function rekey(input: { item: Prepared; key: string }) { + return { + ...input.item, + key: input.key, + line: MemoryMarkdown.line(input.key, input.item.text), + } satisfies Prepared + } + + function validate(input: { state: MemorySchema.State; ops: Op[] }) { + if (!input.state.enabled) throw new Error(`${input.state.scope} memory is disabled`) + if (input.ops.length <= input.state.capture.maxOpsPerRun) return + throw new Error(`memory operation limit exceeded: ${input.ops.length}/${input.state.capture.maxOpsPerRun}`) + } + + function entry(input: { item: Prepared; prior?: MemoryFiles.InventoryItem; now: number }) { + const topics = MemoryTopics.assign({ + file: input.item.file, + section: input.item.section, + key: input.item.key, + text: input.item.text, + }) + const terms = MemoryTopics.terms({ + file: input.item.file, + section: input.item.section, + key: input.item.key, + text: input.item.text, + }) + return { + file: input.item.file, + section: input.item.section, + key: input.item.key, + text: input.item.text, + topics, + terms, + createdAt: input.prior?.createdAt ?? input.now, + updatedAt: input.now, + } satisfies MemoryFiles.InventoryItem + } + + // In-memory copy of every source document, edited purely before any write reaches disk. + type Docs = Map + + type Plan = { + docs: Docs + touched: Set + inventory: MemoryFiles.Inventory + added: number + removed: number + count: number + } + + // Pure: delete matching lines from the in-memory documents and drop them from the working inventory. + function planRemove(plan: Plan, op: Remove) { + const exact = target({ query: op.query, inventory: plan.inventory }) + for (const source of MemorySchema.Sources) { + const next = MemoryMarkdown.remove({ + text: plan.docs.get(source) ?? "", + match: (item) => + exact.fallback === item.key || + exact.items.some((t) => t.file === source && t.section === item.section && t.key === item.key), + }) + if (next.count === 0) continue + plan.docs.set(source, next.text) + plan.touched.add(source) + plan.removed += next.count + } + for (const id of exact.ids) delete plan.inventory.items[id] + if (exact.fallback) { + for (const [id, item] of Object.entries(plan.inventory.items)) { + if (exact.fallback === item.key) delete plan.inventory.items[id] + } + } + plan.count++ + } + + // Pure: dedupe against the working inventory, edit the in-memory document, and record the inventory entry. + function planAdd(plan: Plan, item: Prepared, now: number) { + const found = duplicate({ item, inventory: plan.inventory }) + const next = found ? rekey({ item, key: found.key }) : item + const result = MemoryMarkdown.upsert({ text: plan.docs.get(next.file) ?? "", section: next.section, line: next.line }) + if (result.changed) { + plan.docs.set(next.file, result.text) + plan.touched.add(next.file) + } + const id = MemoryFiles.inventoryKey({ file: next.file, section: next.section, key: next.key }) + const prior = plan.inventory.items[id] + if (!result.changed && prior) return + plan.inventory.items[id] = entry({ item: next, prior, now }) + plan.added++ + plan.count++ + } + + // Pure: sequence removes-then-adds over the loaded documents and inventory, yielding the edits to persist. + function planOps(input: { + docs: Docs + inventory: MemoryFiles.Inventory + removes: Remove[] + adds: Prepared[] + now: number + }): Plan { + const plan: Plan = { + docs: input.docs, + touched: new Set(), + inventory: input.inventory, + added: 0, + removed: 0, + count: 0, + } + for (const op of input.removes) planRemove(plan, op) + for (const item of input.adds) planAdd(plan, item, input.now) + return plan + } + + async function readDocs(root: string): Promise { + const docs: Docs = new Map() + for (const source of MemorySchema.Sources) docs.set(source, await MemoryFiles.readSource(root, source)) + return docs + } + + async function writeDocs(input: { root: string; plan: Plan }) { + for (const source of input.plan.touched) { + await MemoryFiles.writeSource(input.root, source, input.plan.docs.get(source) ?? "") + } + } + + async function persist(input: { root: string; state: MemorySchema.State; count: number; removed: number }) { + const index = await MemoryIndexer.rebuild({ root: input.root, state: input.state }) + await MemoryFiles.writeState(input.root, { + ...input.state, + stats: { + ...input.state.stats, + lastOperationCount: input.count, + }, + }) + await MemoryFiles.append(input.root, `apply ops=${input.count} removed=${input.removed}`) + return index + } + + export async function apply(input: { root: string; ops: Op[] }) { + return MemoryFiles.queue(input.root, async () => { + // Load (IO): state, working inventory, and every source document. + const state = await MemoryFiles.readState(input.root) + validate({ state, ops: input.ops }) + const inventory = await MemoryFiles.deriveInventory(input.root) + const docs = await readDocs(input.root) + // Plan (pure): validate/normalize ops, then dedupe + edit documents + update inventory in memory. + const prepared = prepare({ state, ops: input.ops, max: state.limits.maxLineChars }) + const removes = input.ops.filter((item): item is Remove => item.action === "remove") + const plan = planOps({ docs, inventory, removes, adds: prepared.adds, now: Date.now() }) + // Commit (IO): write changed documents, then rebuild the index, persist state, and audit. + await writeDocs({ root: input.root, plan }) + const index = await persist({ root: input.root, state, count: plan.count, removed: plan.removed }) + return { + operationCount: plan.count, + added: plan.added, + removed: plan.removed, + skipped: prepared.skipped, + index, + } satisfies Result + }) + } + + export async function forget(input: { root: string; query: string }) { + return apply({ root: input.root, ops: [{ action: "remove", query: input.query }] }) + } +} diff --git a/packages/kilo-memory/src/capture/outcome.ts b/packages/kilo-memory/src/capture/outcome.ts new file mode 100644 index 0000000000..c2876092df --- /dev/null +++ b/packages/kilo-memory/src/capture/outcome.ts @@ -0,0 +1,194 @@ +import { MemoryOperations } from "./ops" +import { MemoryRedact } from "./redact" +import { MemoryShared } from "../recall/shared" +import type { MemoryFiles } from "../storage/store" +import type { CaptureSkip } from "./parse" + +export type CaptureSourceItem = { id: string; text: string } + +export type CaptureDetail = { + type: "saved" | "skipped" + message: string + tokens?: number + operationCount?: number + skippedCount?: number + sources?: string[] + files?: string[] +} + +export function usage(input: unknown) { + if (!input || typeof input !== "object") return 0 + const value = input as { totalTokens?: unknown; inputTokens?: unknown; outputTokens?: unknown } + const num = (item: unknown) => { + if (typeof item === "number" && Number.isFinite(item)) return item + if (typeof item !== "object" || item === null) return 0 + const nested = item as { total?: unknown } + return typeof nested.total === "number" && Number.isFinite(nested.total) ? nested.total : 0 + } + const total = num(value.totalTokens) + if (total > 0) return total + return num(value.inputTokens) + num(value.outputTokens) +} + +function detail(input: unknown) { + if (input === undefined || input === null) return "" + if (typeof input === "string") return input + if (input instanceof Error) return input.message + try { + return JSON.stringify(input) + } catch { + return String(input) + } +} + +export function errorReason(err: unknown) { + if (!(err instanceof Error)) return MemoryShared.brief(String(err), 500) + const value = err as Error & { + cause?: unknown + data?: unknown + responseBody?: unknown + response?: unknown + status?: unknown + statusCode?: unknown + } + const parts = [ + err.message, + value.status === undefined ? "" : `status=${detail(value.status)}`, + value.statusCode === undefined ? "" : `statusCode=${detail(value.statusCode)}`, + value.data === undefined ? "" : `data=${detail(value.data)}`, + value.responseBody === undefined ? "" : `body=${detail(value.responseBody)}`, + value.response === undefined ? "" : `response=${detail(value.response)}`, + value.cause === undefined ? "" : `cause=${detail(value.cause)}`, + ].filter(Boolean) + return MemoryShared.brief(MemoryRedact.text(parts.join(" ")), 500) +} + +export function guardReason(input: string) { + const value = input.toLowerCase() + if (/\b(429|rate[_ -]?limit|too many requests)\b/.test(value)) return "rate_limit_guard" + if (/\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/.test(value)) + return "quota_guard" + return undefined +} + +export function skipped(input: { sessionID: string; reason: string }): MemoryFiles.Decision { + return { + kind: "typed", + trigger: "turn-close", + sessionID: input.sessionID, + result: "skipped", + llm: false, + parsed: false, + fallback: false, + reason: input.reason, + tokens: 0, + operationCount: 0, + skippedCount: 1, + summary: `memory capture skipped: ${input.reason}`, + } +} + +export function auditOps(ops: MemoryOperations.Op[]) { + return MemoryShared.audit(ops) +} + +function tokens(input: string) { + return MemoryShared.terms(input) +} + +function duplicate(text: string | undefined, items: CaptureSourceItem[]) { + if (!text) return + const query = tokens(text) + if (query.length === 0) return + // Majority overlap required: a few shared generic terms must not confirm a duplicate. + const needed = Math.max(Math.min(3, query.length), Math.ceil(query.length / 2)) + const hits = items + .map((item) => { + const hay = tokens(item.text) + const found = query.filter((term) => hay.includes(term)).length + return { item, found } + }) + .filter((item) => item.found >= needed) + .sort((a, b) => b.found - a.found) + return hits.at(0)?.item.id +} + +/** Model-claimed duplicates are verified against stored entries; unconfirmed claims are rescued as ops instead of lost. */ +export function verifySkips(input: { skipped: CaptureSkip[]; items: CaptureSourceItem[] }) { + const skipped: CaptureSkip[] = [] + const rescued: MemoryOperations.Op[] = [] + for (const item of input.skipped) { + if (item.reason !== "duplicate" || !item.text) { + skipped.push(item) + continue + } + const source = duplicate(item.text, input.items) + if (source) { + skipped.push({ ...item, duplicateOf: item.duplicateOf ?? source }) + continue + } + skipped.push({ reason: "unsupported", text: item.text }) + } + return { skipped, rescued } +} + +export function duplicateOps(input: { ops: MemoryOperations.Op[]; skipped: CaptureSkip[]; items: CaptureSourceItem[] }) { + const skipped = [...input.skipped] + const ops = input.ops.filter((item) => { + if (item.action !== "add") return true + const rejected = MemoryOperations.reject(item) + if (rejected) { + skipped.push(rejected) + return false + } + const source = duplicate(`${item.key} ${item.text}`, input.items) + if (!source) return true + skipped.push({ reason: "duplicate", text: item.text, duplicateOf: source }) + return false + }) + return { ops, skipped } +} + +function attr(input: string | undefined) { + if (!input) return "" + return input + .replaceAll(/\s+/g, "_") + .replaceAll(/[^A-Za-z0-9_.:/=-]/g, "") + .slice(0, 160) +} + +export function skipLine(input: CaptureSkip[]) { + const item = input.at(0) + if (!item) return "" + const reason = attr(item.reason) + const source = attr(item.duplicateOf) + return [reason ? `reason=${reason}` : "", source ? `duplicateOf=${source}` : ""].filter(Boolean).join(" ") +} + +export function notice(input: { + count: number + ops: MemoryOperations.Op[] + skipped: CaptureSkip[] + tokens: number +}): CaptureDetail | undefined { + const references = MemoryShared.refs(input.ops) + if (input.count > 0) { + return { + type: "saved", + message: `Memory saved · ${references.join(", ") || `${input.count} ops`}`, + tokens: input.tokens, + operationCount: input.count, + sources: references, + files: MemoryShared.files(input.ops), + } + } + return { + type: "skipped", + message: "Memory checked · no new items", + tokens: input.tokens, + operationCount: 0, + skippedCount: input.skipped.length, + sources: references, + files: MemoryShared.files(input.ops), + } +} diff --git a/packages/kilo-memory/src/capture/parse.ts b/packages/kilo-memory/src/capture/parse.ts new file mode 100644 index 0000000000..dbb71608a9 --- /dev/null +++ b/packages/kilo-memory/src/capture/parse.ts @@ -0,0 +1,144 @@ +import z from "zod" +import { MemoryOperations } from "./ops" +import digest from "../prompts/session-digest.txt" +import typed from "../prompts/typed-consolidation.txt" + +export const typedPrompt = typed +export const digestPrompt = digest + +const skip = z + .enum([ + "duplicate", + "transient", + "unsupported", + "secret", + "too_specific", + "in_progress", + "policy_belongs_in_docs", + "out_of_scope", + "self_referential", + "quota_guard", + "rate_limit_guard", + ]) + .catch("unsupported") + +const key = z.string().trim().min(1).max(80) +const value = z.string().trim().min(1).max(2_000) +const addSchema = ( + op: "upsert_project_fact" | "upsert_project_decision" | "upsert_project_constraint" | "append_correction", +) => + z.object({ op: z.literal(op), key, value }).strict() + +export const typedSchema = z + .object({ + operations: z + .array( + z.discriminatedUnion("op", [ + addSchema("upsert_project_fact"), + addSchema("upsert_project_decision"), + addSchema("upsert_project_constraint"), + addSchema("append_correction"), + z + .object({ + op: z.literal("upsert_environment_fact"), + key, + value, + section: z.enum(["Commands", "Paths", "Tooling", "commands", "paths", "tooling"]), + }) + .strict(), + z.object({ op: z.literal("remove_memory"), query: z.string().trim().min(1).max(240) }).strict(), + z + .object({ + op: z.literal("noop"), + key: z.string().max(80).optional(), + value: z.string().max(2_000).optional(), + }) + .strict(), + ]), + ) + .max(16), + skipped: z + .array( + z + .object({ + reason: skip, + text: z.string().max(500).optional(), + duplicateOf: z.string().max(240).optional(), + }) + .strict(), + ) + .max(32) + .default([]), + }) + .strict() + +export const digestSchema = z + .object({ + topic: z.string().max(160).default(""), + summary: z.string().max(4_000).default(""), + }) + .strict() + +export type CaptureSkip = z.infer["skipped"][number] +export type CaptureDigest = z.infer + +function clean(input: string) { + return input + .trim() + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim() +} + +export function parseJson(schema: z.ZodType, input: string) { + if (Buffer.byteLength(input) > 64_000) throw new Error("memory model output exceeds 64000 bytes") + return schema.parse(JSON.parse(clean(input))) +} + +function add(op: { key: string; value: string }, file: MemoryOperations.Add["file"], section?: string) { + const key = op.key.trim() + const body = op.value.trim() + if (!key || !body) return [] + return [{ action: "add", file, section, key, text: body }] satisfies MemoryOperations.Op[] +} + +function env(input: string | undefined) { + const text = input?.trim().toLowerCase() + if (text === "paths" || text === "path") return "Paths" + if (text === "tooling" || text === "tools" || text === "tool") return "Tooling" + return "Commands" +} + +export function parseOps(input: z.infer): MemoryOperations.Op[] { + return input.operations.flatMap((op): MemoryOperations.Op[] => { + if (op.op === "remove_memory") return [{ action: "remove", query: op.query.trim() }] + if (op.op === "append_correction") return add(op, "corrections.md", "Corrections") + if (op.op === "upsert_project_decision") return add(op, "project.md", "Decisions") + if (op.op === "upsert_project_constraint") return add(op, "project.md", "Constraints") + if (op.op === "upsert_project_fact") return add(op, "project.md", "Facts") + if (op.op === "upsert_environment_fact") return add(op, "environment.md", env(op.section)) + return [] + }) +} + +export function mergeOps(ops: MemoryOperations.Op[]) { + const result: MemoryOperations.Op[] = [] + for (const item of ops) { + if (item.action === "remove") { + if (!result.some((prior) => prior.action === "remove" && prior.query === item.query)) result.push(item) + continue + } + if ( + !result.some( + (prior) => + prior.action === "add" && + prior.file === item.file && + prior.section === item.section && + prior.key === item.key, + ) + ) { + result.push(item) + } + } + return result +} diff --git a/packages/kilo-memory/src/capture/plan.ts b/packages/kilo-memory/src/capture/plan.ts new file mode 100644 index 0000000000..9e5c889848 --- /dev/null +++ b/packages/kilo-memory/src/capture/plan.ts @@ -0,0 +1,59 @@ +export type CaptureReason = "completed" | "error" | "interrupted" + +export function typedCapture(input: { reason?: CaptureReason; signal?: boolean; interval: boolean }) { + const completed = !input.reason || input.reason === "completed" + const fresh = !input.interval + return { + call: completed && fresh, + work: completed && fresh, + } +} + +export function capturePlan(input: { + reason?: CaptureReason + summary: string + echo: boolean + durable: boolean + priorTime: number + now: number + minIntervalMs: number + lastConsolidatedAt: number | null | undefined + bypassInterval?: boolean + autoConsolidate: boolean +}) { + const completed = !input.reason || input.reason === "completed" + const session = input.autoConsolidate && completed && !input.echo && Boolean(input.summary) + const digestDue = + session && + (!input.priorTime || + !Number.isFinite(input.priorTime) || + input.now - input.priorTime >= input.minIntervalMs || + input.durable) + const interval = Boolean( + !input.bypassInterval && + input.lastConsolidatedAt && + input.now - input.lastConsolidatedAt < input.minIntervalMs && + !input.durable, + ) + const typed = typedCapture({ reason: input.reason, interval }) + const typedCall = input.autoConsolidate && typed.call && session + const typedWork = input.autoConsolidate && typed.work && session + const skipReason = + !digestDue && !typedWork + ? input.echo && completed + ? "memory_echo" + : interval && (input.reason === undefined || input.reason === "completed") + ? "interval" + : "no_work" + : undefined + return { + completed, + session, + digestDue, + interval, + typedCall, + typedWork, + skipReason, + idleFlush: skipReason === "interval" && session, + } +} diff --git a/packages/kilo-memory/src/capture/redact.ts b/packages/kilo-memory/src/capture/redact.ts new file mode 100644 index 0000000000..25cda724a1 --- /dev/null +++ b/packages/kilo-memory/src/capture/redact.ts @@ -0,0 +1,90 @@ +export namespace MemoryRedact { + const keys = new Set([ + "accesskey", + "apikey", + "auth", + "authorization", + "bearer", + "clientsecret", + "credential", + "passphrase", + "password", + "privatekey", + "secret", + "token", + ]) + const secret = [ + /sk-[A-Za-z0-9_-]{20,}/, + /gh[pousr]_[A-Za-z0-9_]{20,}/, + /AIza[0-9A-Za-z_-]{30,}/, + /xox[baprs]-[A-Za-z0-9-]{20,}/, + /AKIA[0-9A-Z]{16}/, + /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, + /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/i, + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/, + /["']?[\w.-]*(?:password|passphrase|api[_ -]?key|secret|token|credential|authorization|auth|private[_ -]?key|access[_ -]?key)[\w.-]*["']?\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,}\r\n]+)/i, + ] + // Loosely find URL-like spans; the parser (not this pattern) decides whether they carry credentials. + const candidate = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi + + // Pull the raw userinfo segment out of a candidate without normalizing it, so redaction preserves + // the original shape (encoding, ports, path/query untouched). Any non-empty userinfo counts — a bare + // `user@host` may still be a token, so we fail closed rather than require a colon. + function rawUserinfo(raw: string): string | undefined { + const sep = raw.indexOf("//") + if (sep < 0) return undefined + const authority = raw.slice(sep + 2).split(/[/?#]/)[0] ?? "" + const at = authority.lastIndexOf("@") + if (at < 1) return undefined + return authority.slice(0, at) + } + + // Parser-primary: a well-formed URL with userinfo is authoritative (handles ports, paths, query + // strings, percent-encoding, and @ in the path without false positives). Regex-style raw extraction + // is the fallback so malformed-but-credentialed strings the parser rejects still redact. + function userinfo(raw: string): string | undefined { + try { + const url = new URL(raw) + if (!url.username && !url.password) return undefined + return rawUserinfo(raw) ?? `${url.username}:${url.password}` + } catch { + return rawUserinfo(raw) + } + } + + function hasUri(input: string) { + return (input.match(candidate) ?? []).some((raw) => userinfo(raw) !== undefined) + } + + function redactUri(input: string) { + return input.replace(candidate, (raw) => { + const info = userinfo(raw) + return info ? raw.replace(`${info}@`, "[redacted]@") : raw + }) + } + + function sensitive(input: string) { + const name = input.replaceAll(/[_\s-]/g, "").toLowerCase() + if (keys.has(name)) return true + return [...keys].some((key) => name.endsWith(key)) + } + + export function has(input: string) { + return hasUri(input) || secret.some((item) => item.test(input)) + } + + export function text(input: string) { + return secret.reduce((next, item) => { + const flags = item.flags.includes("g") ? item.flags : `${item.flags}g` + return next.replace(new RegExp(item.source, flags), "[redacted]") + }, redactUri(input)) + } + + export function value(input: unknown, name?: string): unknown { + if (name && sensitive(name)) return "[redacted]" + if (typeof input === "string") return text(input) + if (Array.isArray(input)) return input.map((item) => value(item)) + if (typeof input !== "object" || input === null) return input + return Object.fromEntries(Object.entries(input).map(([key, item]) => [key, value(item, key)])) + } +} diff --git a/packages/kilo-memory/src/capture/reject.ts b/packages/kilo-memory/src/capture/reject.ts new file mode 100644 index 0000000000..ea8e02dc52 --- /dev/null +++ b/packages/kilo-memory/src/capture/reject.ts @@ -0,0 +1,44 @@ +import { MemoryText } from "../text" + +/** Content gating for generated adds: drops self-referential, personal-preference, and instruction-provenance text. */ +export namespace MemoryReject { + export type Rejection = { + reason: "self_referential" | "out_of_scope" + text: string + } + + // English best-effort backstop; the typed-consolidation prompt is the primary, language-agnostic defense. + const self = [ + /\balready\b[^.]{0,120}\b(?:captured|covered|recorded|tracked|represented|saved|known)\b[^.]{0,120}\bmemor(?:y|ies)\b/i, + /\balready\b[^.]{0,120}\bin\b[^.]{0,120}\bmemor(?:y|ies)\b/i, + /\bmemor(?:y|ies)\b[^.]{0,120}\balready\b[^.]{0,120}\b(?:captures?|covers?|records?|tracks?|represents?|saves?|knows?|contains?)\b/i, + /\b(?:was|were)\s+(?:investigated|checked|explored|reviewed)[.;:!?]?\s*$/i, + ] + const personal = [ + /^i\s+prefer\b/i, + /^my\s+preferences?(?:\s+(?:is|are)\b|\b)/i, + /^(?:the\s+)?user\s+prefers?\b/i, + /^(?:the\s+)?users\s+preferences?(?:\s+(?:is|are)\b|\b)/i, + ] + const sourceMarkers = [ + /\bagents\.md\b/gi, + /(?:^|[~\/\s])\.claude\/claude\.md\b/gi, + /\bclaude\.md\b/gi, + /\bsystem\s*\/\s*developer\b/gi, + ] + + function provenance(input: string) { + const count = sourceMarkers.reduce((sum, rule) => sum + (input.match(rule)?.length ?? 0), 0) + if (/(?:^|[~\/\s])\.claude\/claude\.md\b/i.test(input)) return true + return count >= 3 + } + + export function reject(input: { text: string }): Rejection | undefined { + const raw = input.text.trim() + const value = MemoryText.normalized(raw) + if (personal.some((rule) => rule.test(value))) return { reason: "out_of_scope", text: input.text } + if (provenance(raw)) return { reason: "out_of_scope", text: input.text } + if (!self.some((rule) => rule.test(value))) return + return { reason: "self_referential", text: input.text } + } +} diff --git a/packages/kilo-memory/src/commands.ts b/packages/kilo-memory/src/commands.ts new file mode 100644 index 0000000000..77eed74ea3 --- /dev/null +++ b/packages/kilo-memory/src/commands.ts @@ -0,0 +1,125 @@ +export const MEMORY_USAGE = + "/memory [project] enable|status|show|inspect|auto status|auto on|auto off|remember |correct |forget |purge confirm|rebuild|disable" + +export const MEMORY_OPERATIONS = ["enable", "disable", "rebuild", "remember", "correct", "forget", "purge", "auto"] as const +export const MEMORY_PROMPT_OPERATIONS = ["remember", "forget"] as const + +export type MemoryOperation = (typeof MEMORY_OPERATIONS)[number] +export type MemoryPromptOperation = (typeof MEMORY_PROMPT_OPERATIONS)[number] + +export function isMemoryOperation(input: unknown): input is MemoryOperation { + return typeof input === "string" && (MEMORY_OPERATIONS as readonly string[]).includes(input) +} + +export function isMemoryPromptOperation(input: unknown): input is MemoryPromptOperation { + return typeof input === "string" && (MEMORY_PROMPT_OPERATIONS as readonly string[]).includes(input) +} + +type Inspect = { + kind: "inspect" +} + +type Operation = + | { + kind: "operation" + operation: "remember" | "correct" + text: string + } + | { + kind: "operation" + operation: "forget" + query: string + } + | { + kind: "operation" + operation: "auto" + mode: "status" | "on" | "off" + } + | { + kind: "operation" + operation: "purge" + confirm: true + } + | { + kind: "operation" + operation: Exclude + } + +type Usage = { + kind: "usage" + reason: string +} + +export type ParsedMemoryCommand = Inspect | Operation | Usage + +function split(input: string) { + const match = input.trim().match(/^(\S+)(?:\s+([\s\S]*))?$/) + return { + head: match?.[1]?.toLowerCase(), + tail: (match?.[2] ?? "").trim(), + } +} + +function target(input: string) { + const parts = split(input) + if (parts.head === "project") return { rest: parts.tail } + if (parts.head === "personal") return { rest: parts.tail, error: "Personal memory is not supported." } + return { rest: input.trim() } +} + +function usage(reason: string): ParsedMemoryCommand { + return { kind: "usage", reason } +} + +function operation(verb: string, text: string): ParsedMemoryCommand | undefined { + if (verb === "enable" || verb === "disable" || verb === "rebuild") { + return { kind: "operation", operation: verb } + } + if (verb === "purge") { + if (text.toLowerCase() === "confirm") return { kind: "operation", operation: "purge", confirm: true } + return usage("Purge requires confirmation. Run /memory purge confirm.") + } + if (verb === "auto" || verb === "auto-consolidate") { + const mode = text.toLowerCase() + if (mode === "status" || mode === "on" || mode === "off") return { kind: "operation", operation: "auto", mode } + return usage("Missing auto mode.") + } + if (verb === "remember") { + if (text) return { kind: "operation", operation: "remember", text } + return usage("Missing text.") + } + if (verb === "correct") { + if (text) return { kind: "operation", operation: "correct", text } + return usage("Missing correction.") + } + if (verb === "forget") { + if (text) return { kind: "operation", operation: "forget", query: text } + return usage("Missing query.") + } +} + +function blocked(verb: string): ParsedMemoryCommand | undefined { + if (verb === "use-personal" || verb === "personal-context" || verb === "personal-in-project") { + return usage("Personal memory is not supported.") + } +} + +export function parseMemoryCommand(input: string): ParsedMemoryCommand | undefined { + const match = input.trim().match(/^\/(?:memory|mem)(?:\s+([\s\S]*))?$/i) + if (!match) return + const body = (match[1] ?? "").trim() + if (!body) return { kind: "inspect" } + + const picked = target(body) + if (picked.error) return usage(picked.error) + const parts = split(picked.rest) + const verb = parts.head + if (!verb) return { kind: "inspect" } + if (verb === "status" || verb === "show" || verb === "inspect") return { kind: "inspect" } + + const op = operation(verb, parts.tail) + if (op) return op + const denied = blocked(verb) + if (denied) return denied + return usage(`Unknown memory action: ${verb}.`) +} diff --git a/packages/kilo-memory/src/index.ts b/packages/kilo-memory/src/index.ts new file mode 100644 index 0000000000..f5743df3e3 --- /dev/null +++ b/packages/kilo-memory/src/index.ts @@ -0,0 +1,11 @@ +export { digestSchema, mergeOps, parseJson, parseOps, typedSchema } from "./capture/capture" +export { MEMORY_USAGE, parseMemoryCommand } from "./commands" +export type { MemoryOperation, ParsedMemoryCommand } from "./commands" +export { MemoryDigest } from "./capture/digest" +export { Memory } from "./memory" +export { MemoryOperations } from "./capture/ops" +export { MemoryPaths } from "./storage/paths" +export { MemoryRecall } from "./recall/recall" +export { MemoryRedact } from "./capture/redact" +export { MemorySchema } from "./schema" +export { MemoryShared } from "./recall/shared" diff --git a/packages/kilo-memory/src/memory-notice.ts b/packages/kilo-memory/src/memory-notice.ts new file mode 100644 index 0000000000..0ad4aa8ace --- /dev/null +++ b/packages/kilo-memory/src/memory-notice.ts @@ -0,0 +1,36 @@ +import { MemoryShared } from "./recall/shared" +import type { MemoryOperations } from "./capture/ops" + +/** Human-facing messages and audit views describing an explicit apply result. */ +export namespace MemoryNotice { + export function saved(input: { added: number; removed: number }) { + return input.removed > 0 || input.added > 0 + } + + export function summary(input: { added: number; removed: number; count: number }) { + if (input.added > 0 && input.removed > 0) { + return `explicit memory operation saved ${input.added} and removed ${input.removed}` + } + if (input.added > 0) return `explicit memory operation saved ${input.added} ops` + if (input.removed > 0) return `explicit memory operation removed ${input.removed} entries` + if (input.count > 0) return "explicit memory operation matched no source memory" + return "explicit memory operation had no accepted ops" + } + + export function message(input: { ops: MemoryOperations.Op[]; added: number; removed: number; count: number }) { + const refs = MemoryShared.refs(input.ops) + if (input.added > 0 && input.removed > 0) return `Memory updated · ${input.added} saved, ${input.removed} removed` + if (input.added > 0) return `Memory saved · ${refs.join(", ") || `${input.added} ops`}` + if (input.removed > 0) return `Memory updated · ${input.removed} removed` + return `Memory unchanged · ${input.count} ops` + } + + export function skip(input: MemoryOperations.Rejection[]) { + return input.map((item) => (item.reason === "out_of_scope" ? { reason: item.reason } : item)) + } + + export function ops(input: { ops: MemoryOperations.Op[]; skipped: MemoryOperations.Rejection[] }) { + const blocked = new Set(input.skipped.filter((item) => item.reason === "out_of_scope").map((item) => item.text)) + return MemoryShared.audit(input.ops.filter((item) => item.action !== "add" || !blocked.has(item.text))) + } +} diff --git a/packages/kilo-memory/src/memory.ts b/packages/kilo-memory/src/memory.ts new file mode 100644 index 0000000000..8fefadd882 --- /dev/null +++ b/packages/kilo-memory/src/memory.ts @@ -0,0 +1,363 @@ +import { MemoryFiles } from "./storage/store" +import { MemoryIndexer } from "./recall/indexer" +import { MemoryNotice } from "./memory-notice" +import { MemoryOperations } from "./capture/ops" +import { MemoryPaths } from "./storage/paths" +import { MemoryRecall } from "./recall/recall" +import { MemorySchema } from "./schema" +import { MemoryShared } from "./recall/shared" +import { MemoryToken } from "./recall/token" +import { MemorySlug } from "./slug" + +/** Root-bound package facade. External Kilo surfaces should derive root from workspace context first. */ +export namespace Memory { + export type Block = { + scope: "project" + text: string + bytes: number + estimatedTokens: number + truncated: boolean + } + + export type Trigger = "explicit" | "turn-close" | "rebuild" + + export type Apply = { + root: string + state: MemorySchema.State + result: MemoryOperations.Result + ok: boolean + detail?: { + type: "saved" + message: string + operationCount: number + sources: string[] + files: string[] + } + } + + export function key(text: string) { + const slug = MemorySlug.safe(text, { max: MemorySlug.max.record, fallback: "", lower: true }) + .split("_") + .filter(Boolean) + .slice(0, MemorySlug.max.parts) + .join("_") + return slug || MemorySlug.hash(text, "memory") + } + + async function injected(input: { + root: string + index: MemoryIndexer.Result + sessionID?: string + }) { + return MemoryFiles.queue(input.root, async () => { + const state = await MemoryFiles.readState(input.root) + const next = { + ...state, + stats: { + ...state.stats, + lastInjectedAt: Date.now(), + lastInjectedBytes: input.index.bytes, + lastInjectedTokens: input.index.tokens, + lastInjectedSessionID: input.sessionID ?? null, + }, + } + await MemoryFiles.writeState(input.root, next) + return next + }) + } + + export async function status(input: { root: string }) { + const state = await MemoryFiles.readState(input.root) + const paths = MemoryPaths.files(input.root) + const index = await MemoryFiles.readIndex(input.root) + return { + root: input.root, + state, + exists: { + state: await MemoryFiles.exists(paths.state), + index: await MemoryFiles.exists(paths.index), + }, + index: { + bytes: Buffer.byteLength(index), + estimatedTokens: MemoryToken.estimate(index), + preview: index, + }, + } + } + + export async function enable(input: { root: string; id?: MemoryPaths.Identity }) { + return MemoryFiles.queue(input.root, async () => { + const state = await MemoryFiles.scaffold(input.root, input.id) + const index = await MemoryIndexer.rebuild({ root: input.root, state }) + return { root: input.root, state, index } + }) + } + + export async function disable(input: { root: string }) { + return MemoryFiles.queue(input.root, async () => { + const state = await MemoryFiles.readState(input.root) + const next = { ...state, enabled: false } + await MemoryFiles.writeState(input.root, next) + await MemoryFiles.append(input.root, `disable ${next.scope} source=command`) + return { root: input.root, state: next } + }) + } + + export async function show(input: { root: string }) { + return MemoryFiles.show(input.root) + } + + export async function rebuild(input: { root: string }) { + const state = await MemoryFiles.readState(input.root) + const index = await MemoryIndexer.rebuild({ root: input.root, state }) + return { root: input.root, state, index } + } + + export async function configure(input: { + root: string + settings: Partial> + }) { + return MemoryFiles.queue(input.root, async () => { + const state = await MemoryFiles.readState(input.root) + const next = { + ...state, + ...(input.settings.autoConsolidate === undefined ? {} : { autoConsolidate: input.settings.autoConsolidate }), + } + await MemoryFiles.writeState(input.root, next) + await MemoryFiles.append( + input.root, + [ + `settings ${next.scope}`, + input.settings.autoConsolidate === undefined ? "" : `autoConsolidate=${next.autoConsolidate}`, + ] + .filter(Boolean) + .join(" "), + ) + return { root: input.root, state: next } + }) + } + + export async function context(input: { root: string; sessionID?: string; record?: boolean }) { + const state = await MemoryFiles.readState(input.root) + const record = input.record ?? true + if (!state.enabled) { + return { + root: input.root, + state, + recorded: false, + blocks: [] as Block[], + meta: { enabled: state.enabled, estimatedTokens: 0, bytes: 0, truncated: false }, + } + } + + const paths = MemoryPaths.files(input.root) + const prior = (await MemoryFiles.exists(paths.index)) ? await MemoryFiles.readIndex(input.root) : undefined + const expired = prior ? await MemoryFiles.indexExpired(input.root) : true + const index = + prior && !MemoryIndexer.stale(prior) && !expired && MemoryIndexer.fresh(prior, state.limits) + ? prior + : (await rebuild(input)).index.text + const capped = MemoryIndexer.cap(index, state.limits.maxProjectIndexBytes) + const blocks = capped.text.trim() + ? [ + { + scope: state.scope, + text: capped.text, + bytes: capped.bytes, + estimatedTokens: capped.tokens, + truncated: capped.truncated, + }, + ] + : [] + const meta = { + enabled: true, + estimatedTokens: capped.tokens, + bytes: capped.bytes, + truncated: capped.truncated, + } + if (!record) return { root: input.root, state, index: capped, recorded: false, blocks, meta } + const next = await injected({ root: input.root, index: capped, sessionID: input.sessionID }) + return { + root: input.root, + state: next, + index: capped, + recorded: true, + blocks, + meta: blocks.length ? meta : { enabled: true, estimatedTokens: 0, bytes: 0, truncated: false }, + } + } + + export async function toolEnabled(input: { root: string }) { + const state = await MemoryFiles.readState(input.root) + return state.enabled + } + + export async function apply(input: { + root: string + ops: MemoryOperations.Op[] + trigger?: Trigger + sessionID?: string + tokens?: number + }): Promise { + const trigger = input.trigger ?? "explicit" + const inputOps = trigger === "explicit" ? input.ops : input.ops.filter((item) => item.action !== "remove") + const result = await MemoryOperations.apply({ root: input.root, ops: inputOps }) + const state = await MemoryFiles.readState(input.root) + const ok = MemoryNotice.saved({ added: result.added, removed: result.removed }) + if (trigger === "explicit") { + await MemoryFiles.decide(input.root, { + kind: "typed", + trigger, + sessionID: input.sessionID, + result: ok ? "saved" : "skipped", + llm: false, + parsed: true, + fallback: false, + tokens: input.tokens ?? 0, + operationCount: result.operationCount, + skippedCount: result.skipped.length || (ok ? 0 : 1), + skipped: MemoryNotice.skip(result.skipped), + operations: MemoryNotice.ops({ ops: inputOps, skipped: result.skipped }), + files: MemoryShared.files(inputOps), + summary: MemoryNotice.summary({ added: result.added, removed: result.removed, count: result.operationCount }), + }) + } + return { + root: input.root, + state, + result, + ok, + ...(ok + ? { + detail: { + type: "saved" as const, + message: MemoryNotice.message({ + ops: inputOps, + added: result.added, + removed: result.removed, + count: result.operationCount, + }), + operationCount: result.operationCount, + sources: MemoryShared.refs(inputOps), + files: MemoryShared.files(inputOps), + }, + } + : {}), + } + } + + export async function forget(input: { root: string; query: string; sessionID?: string }) { + return apply({ ...input, ops: [{ action: "remove", query: input.query }] }) + } + + export async function remember(input: { + root: string + text: string + key?: string + file?: MemorySchema.Source + section?: string + sessionID?: string + }) { + return apply({ + ...input, + ops: [ + { + action: "add", + file: input.file, + section: input.section, + key: input.key ?? key(input.text), + text: input.text, + }, + ], + }) + } + + export async function correct(input: { root: string; text: string; key?: string; sessionID?: string }) { + return remember({ + ...input, + file: "corrections.md", + section: "Corrections", + }) + } + + export async function purge(input: { root: string }) { + if (!(await MemoryFiles.owned(input.root))) { + const exists = await MemoryFiles.exists(input.root) + if (!exists) return { root: input.root, purged: false, state: MemorySchema.missing() } + throw new Error(`refusing to purge unowned memory root: ${input.root}`) + } + return MemoryFiles.queue(input.root, async () => { + const purged = await MemoryFiles.purge(input.root) + return { root: input.root, purged, state: MemorySchema.missing() } + }) + } + + export async function recall(input: { root: string; query: string; sessionID?: string }) { + const state = await MemoryFiles.readState(input.root) + if (!state.enabled) return { root: input.root, state } + const result = await MemoryRecall.search({ + root: input.root, + query: input.query, + state, + currentSessionID: input.sessionID, + force: true, + }) + const hits = result?.hits ?? [] + const files = [...new Set(hits.map((hit) => hit.source))] + const topics = [...new Set(hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))] + await MemoryFiles.decide(input.root, { + kind: "recall", + trigger: "targeted-recall", + sessionID: input.sessionID, + result: result ? "recalled" : "skipped", + llm: false, + parsed: false, + fallback: false, + reason: result ? undefined : "no_matches", + query: MemoryShared.brief(input.query, 240), + topics, + files, + tokens: result?.tokens ?? 0, + operationCount: hits.length, + skippedCount: result ? 0 : 1, + summary: result ? `targeted recall matched ${hits.length} memories` : "targeted recall found no matches", + }) + if (result) { + await MemoryFiles.queue(input.root, async () => { + await MemoryFiles.append( + input.root, + `recall session=${input.sessionID ?? ""} hits=${result.hits.length} tokens=${result.tokens} files=${files.join(",")}`, + ) + }) + } + return { root: input.root, state, result, hits, files, topics } + } + + export async function recordSession(input: { + root: string + sessionID: string + topic?: string + summary: string + time?: number + tokens?: number + }) { + return MemoryFiles.queue(input.root, async () => { + const state = await MemoryFiles.readState(input.root) + if (!state.enabled) return { root: input.root, state, skipped: true, reason: "memory_disabled" as const } + await MemoryFiles.writeSession(input.root, { + sessionID: input.sessionID, + topic: input.topic, + summary: input.summary, + max: state.limits.maxSessionLineChars, + time: input.time, + }) + await MemoryFiles.pruneSessions(input.root, state.limits.maxSessionFiles) + const index = await MemoryIndexer.rebuild({ root: input.root, state }) + await MemoryFiles.append( + input.root, + `session digest session=${input.sessionID} tokens=${input.tokens ?? 0} indexTokens=${index.tokens}`, + ) + return { root: input.root, state, skipped: false as const, index } + }) + } +} diff --git a/packages/kilo-memory/src/prompts/session-digest.txt b/packages/kilo-memory/src/prompts/session-digest.txt new file mode 100644 index 0000000000..c93eaca5ee --- /dev/null +++ b/packages/kilo-memory/src/prompts/session-digest.txt @@ -0,0 +1,33 @@ +You are Kilo's session digest updater. + +Your job is to update one compact handoff digest for the current session. +Return JSON only. Do not explain. + +The digest helps a future Kilo turn answer "where did we stop?" or "continue" without reading the full transcript. + +Use the previous digest plus the latest completed turn. Preserve useful continuity: +- current objective +- completed work +- important files or areas touched +- important decisions or constraints from this session +- next concrete step +- blockers or failed checks + +Do not copy transcript text, command output, logs, secrets, raw failure output, or large code snippets. +Preserve transient failure details only when they remain a real blocker or next step. +Do not summarize branch names, git status, latest commits, current working directory, or untracked files as handoff state unless the user's actual task is about git/rebase/commit history and there is a concrete next step. +If the latest turn only reconciles current repo status against memory, keep the previous digest. If there is no previous digest, return an empty summary. +Do not create durable project memory here; typed memory consolidation handles project facts, decisions, and corrections separately. + +Output schema: +{ + "topic": "2-6 word label for this session", + "summary": "one compact paragraph, no markdown" +} + +Rules: +- Keep topic short and specific enough to choose between recent sessions. +- Keep the summary within the supplied max characters. +- Prefer concrete file names, decisions, and next steps over generic progress. +- If the latest turn is vague and adds no useful state, preserve the previous digest. +- Return an empty summary only when both previous digest and latest turn are empty. \ No newline at end of file diff --git a/packages/kilo-memory/src/prompts/txt.d.ts b/packages/kilo-memory/src/prompts/txt.d.ts new file mode 100644 index 0000000000..c31d581847 --- /dev/null +++ b/packages/kilo-memory/src/prompts/txt.d.ts @@ -0,0 +1,4 @@ +declare module "*.txt" { + const value: string + export default value +} diff --git a/packages/kilo-memory/src/prompts/typed-consolidation.txt b/packages/kilo-memory/src/prompts/typed-consolidation.txt new file mode 100644 index 0000000000..6868cac284 --- /dev/null +++ b/packages/kilo-memory/src/prompts/typed-consolidation.txt @@ -0,0 +1,92 @@ +You are Kilo's typed memory consolidation step. + +Your job is to decide whether the latest session window contains durable, reusable memory worth saving. +Return JSON only. Do not explain. + +Memory is expensive because it is injected into future model context. Prefer saving nothing over saving weak or transient details. + +Save only durable project knowledge that is likely to help future Kilo sessions in this same project: codebase structure, commands, conventions, constraints, environment facts, and explicit user corrections about the project. +Do not save personal user preferences, opinions, habits, or anything about the user as a person. User-level memory is out of scope. +Save facts in the language the user expressed them. Do not translate to English. Keep code, file paths, commands, and identifiers verbatim regardless of language. +Session summaries are saved by a separate digest path. Do not summarize the turn. + +High-value project memory: +- User corrections about how Kilo should understand this project. +- Stable project facts: package manager, test commands, build commands, dev commands, important directories, generated-file rules, tech stack, recurring workflows, project conventions, and known pitfalls. +- Stable decisions: chosen architecture, rejected approach with reason, API contract, migration strategy. +- Stable constraints: standing project rules, boundaries, or requirements future Kilo sessions should respect unless current user/repo instructions override them. +- Durable local environment facts that affect this project: sibling repos, local vs remote testing constraints, generated paths, and repo-adjacent fixtures future Kilo sessions may need. + +Authority rule: +Memory is local recall context, not policy. Current user instructions, AGENTS.md, checked-in documentation, repo state, and tool output win over memory. +If guidance must always apply to a team, it belongs in AGENTS.md or checked-in docs. Do not rely on memory as the only source for mandatory rules. + +Do not save: +- Secrets, tokens, credentials, env values. +- Temporary task status. +- Active, short-lived, or still-in-progress session details. +- One-off file names unless they define a durable convention. +- Exact command output. +- Large code snippets. +- Guesses not supported by the supplied context. +- Anything the user may reasonably consider personal. +- Personal user preferences, opinions, habits, or reactions, even when phrased as useful future context. +- Implementation details that will be obvious from current repo files. +- Facts already present in typed source memory with the same meaning. +- Statements about memory itself or about what is already known. Reject any fact whose content is that something is already in, already captured, already covered, already recorded, already tracked, or already represented in memory. +- Statements that something was investigated, checked, explored, or reviewed with no concrete durable fact. Save the underlying fact, never a note that it exists. +- Answers that only explain where current instructions, context, or memory came from. Do not save source/provenance lists from system/developer instructions, AGENTS.md, user-level files such as ~/.claude/CLAUDE.md, or injected memory blocks. Environment details may still be saved when they are durable project setup facts such as commands, paths, tooling, local constraints, or repo-adjacent fixtures. Skip provenance lists as out_of_scope or transient unless the latest user explicitly asked to remember a specific underlying project fact. + +Correction rule: +If the user says existing memory is wrong, stale, or should be forgotten, prioritize a correction or removal. Corrections are more important than new facts. + +Conflict rule: +If supplied memory conflicts with the current user/repo context, prefer the current user/repo context and output a correction or removal. + +Session context rule: +Recent session digests and latest-session context are continuity hints, not durable typed source memory. Do not skip a durable project fact as duplicate merely because it appears in recent session context. +If a durable fact appears in multiple recent session digests and is absent from typed source memory, promote it to typed memory. + +Output schema: +{ + "operations": [ + { + "op": "upsert_project_fact" | "upsert_project_decision" | "upsert_project_constraint" | "upsert_environment_fact" | "append_correction" | "remove_memory" | "noop", + "key": "stable_key_when_required", + "value": "short durable value when required", + "query": "forget query when op is remove_memory", + "section": "Commands" | "Paths" | "Tooling" + } + ], + "skipped": [ + { + "reason": "duplicate" | "transient" | "unsupported" | "secret" | "too_specific" | "in_progress" | "policy_belongs_in_docs" | "out_of_scope" | "self_referential" | "quota_guard" | "rate_limit_guard", + "text": "short description", + "duplicateOf": "source.md:key when the duplicate source is known" + } + ] +} + +Rules: +- Return at most 16 operations. +- Return {"operations":[],"skipped":[]} when there is nothing worth saving. +- Each key must be lowercase with dots or underscores. +- Each value must be one concise sentence or phrase. +- Use skipped reason "in_progress" for active or short-lived session details. +- Use skipped reason "policy_belongs_in_docs" when a mandatory team rule should be in AGENTS.md or checked-in docs instead of only memory. +- Use skipped reason "out_of_scope" for personal user-level content that is not project knowledge. +- Use skipped reason "self_referential" for statements about memory itself or about facts already being captured. +- Use skipped reason "quota_guard" or "rate_limit_guard" if the evidence says memory generation should avoid spending limited quota. +- For upsert_environment_fact, use section "Commands" for runnable commands, "Paths" for important directories/files, and "Tooling" for package managers, runtimes, build systems, or test frameworks. +- Omit section for other operations. +- Do not include markdown. +- Do not include commentary outside JSON. + +Examples: +- User says tests run from packages/opencode, not repo root: output append_correction with key "test_command". +- Assistant establishes that project memory is project-only and stored under the global repo memory folder: output upsert_project_decision. +- User or assistant establishes a standing project requirement such as "project memory should stay project-only": output upsert_project_constraint. +- Assistant lists setup commands such as bun install or bun run dev: output upsert_environment_fact with section "Commands". +- Assistant identifies important local paths: output upsert_environment_fact with section "Paths". +- Assistant identifies durable tools such as Bun, Turbo, or Java 21: output upsert_environment_fact with section "Tooling". +- Assistant only says it checked git status or continued a task: output no operations. diff --git a/packages/kilo-memory/src/recall/budget.ts b/packages/kilo-memory/src/recall/budget.ts new file mode 100644 index 0000000000..89d7928347 --- /dev/null +++ b/packages/kilo-memory/src/recall/budget.ts @@ -0,0 +1,108 @@ +import path from "path" +import { MemoryToken } from "./token" +import { MemorySchema } from "../schema" + +/** Byte-budget capping, freshness fingerprinting, and the index envelope (the ```kilo-memory-v1 block). */ +export namespace MemoryBudget { + export type Result = { + text: string + bytes: number + tokens: number + truncated: boolean + } + + function rootName(root: string) { + const dir = path.basename(root) + return dir || "project" + } + + export function fingerprint(limits: MemorySchema.Limits) { + return `limits: ${limits.maxProjectIndexBytes}/${limits.maxRecentSessions}/${limits.maxSessionLineChars}` + } + + /** True when the index was built with the same limits; a limits change must invalidate it. */ + export function fresh(input: string, limits: MemorySchema.Limits) { + return input.includes(`\n${fingerprint(limits)}\n`) + } + + function wrap(input: { root: string; limits: MemorySchema.Limits; lines: string[] }) { + if (input.lines.length === 0) return "" + return [ + "```kilo-memory-v1 context_not_instruction", + "scope: project", + `root: ${rootName(input.root)}`, + fingerprint(input.limits), + "", + ...input.lines, + "```", + "", + ].join("\n") + } + + export function cap(input: string, max: number): Result { + if (!input.trim()) return { text: "", bytes: 0, tokens: 0, truncated: false } + const all = input.endsWith("\n") ? input : `${input}\n` + if (Buffer.byteLength(all) <= max) { + return { + text: all, + bytes: Buffer.byteLength(all), + tokens: MemoryToken.estimate(all), + truncated: false, + } + } + + const lines = all.split("\n") + const close = lines.findIndex((line, idx) => idx > 0 && line.trim() === "```") + if (lines[0]?.startsWith("```kilo-memory-v1") && close > 0) { + const foot = `${lines[close]}\n` + // This branch always truncates, so reserve room for a note telling the model how to list the + // rest — but never at tiny budgets where the note would displace actual memory. + const note = "note: index truncated; call kilo_memory_recall mode=typed query= to search omitted memory" + const reserve = max >= 1024 ? Buffer.byteLength(`${note}\n`) : 0 + const kept = [lines[0]] + let bytes = Buffer.byteLength(`${lines[0]}\n`) + Buffer.byteLength(foot) + reserve + for (const line of lines.slice(1, close)) { + const next = `${line}\n` + const size = Buffer.byteLength(next) + if (bytes + size > max) break + kept.push(line) + bytes += size + } + while (kept.at(-1)?.startsWith("record ")) kept.pop() + if (reserve) kept.push(note) + const text = `${kept.join("\n")}\n${foot}` + if (Buffer.byteLength(text) <= max) { + return { + text, + bytes: Buffer.byteLength(text), + tokens: MemoryToken.estimate(text), + truncated: true, + } + } + } + const kept: string[] = [] + let bytes = 0 + for (const line of lines) { + const next = `${line}\n` + const size = Buffer.byteLength(next) + if (bytes + size > max) break + kept.push(line) + bytes += size + } + const text = `${kept.join("\n")}\n` + return { + text, + bytes: Buffer.byteLength(text), + tokens: MemoryToken.estimate(text), + truncated: true, + } + } + + export function stale(input: string) { + return !input.trimStart().startsWith("```kilo-memory-v1") + } + + export function result(input: { root: string; limits: MemorySchema.Limits; lines: string[]; max: number }) { + return cap(wrap({ root: input.root, limits: input.limits, lines: input.lines }), input.max) + } +} diff --git a/packages/kilo-memory/src/recall/index-format.ts b/packages/kilo-memory/src/recall/index-format.ts new file mode 100644 index 0000000000..a1c6d07f96 --- /dev/null +++ b/packages/kilo-memory/src/recall/index-format.ts @@ -0,0 +1,96 @@ +import { MemoryFiles } from "../storage/store" +import { MemorySchema } from "../schema" +import { MemorySlug } from "../slug" +import type { MemoryShared } from "./shared" + +/** Serializes inventory items and topic routing into the index's `record id=… / text: …` lines. */ +export namespace MemoryIndexFormat { + type Item = MemoryShared.TypedItem + + function type(section: string) { + return MemorySchema.recordKind("project.md", section) + } + + function rank(section: string) { + const kind = type(section) + if (kind === "PROJECT_DECISION") return 0 + if (kind === "PROJECT_CONSTRAINT") return 1 + if (kind === "PROJECT_FACT") return 2 + return 3 + } + + function id(input: string) { + return MemorySlug.safe(input, { max: MemorySlug.max.record, fallback: "memory" }) + } + + function text(input: string) { + return input.trim().replaceAll("```", "'''").replaceAll(/\s+/g, " ") + } + + function date(input?: number | string) { + if (typeof input === "string") return input.replaceAll(/\s+/g, "_") + if (typeof input === "number" && Number.isFinite(input)) return new Date(input).toISOString() + return "unknown" + } + + export function record(input: { kind: string; id: string; source: string; updated?: number | string; text: string }) { + return [ + `record id=${id(input.id)} type=${id(input.kind.toLowerCase())} source=${id(input.source)} updated=${date(input.updated)}`, + `text: ${text(input.text)}`, + ].join("\n") + } + + export function lines(prefix: string, items: Item[]) { + return items.map((item) => + record({ + kind: prefix, + id: MemoryFiles.inventoryKey({ file: item.file, section: item.section, key: item.key }), + source: item.file, + updated: item.updatedAt, + text: `${item.key} :: ${item.text}`, + }), + ) + } + + // One compact record mapping topics to the files holding them, so the model knows what kilo_memory_recall can find. + export function hints(items: Item[]) { + const rows = MemorySchema.Topics.flatMap((topic) => { + const group = items.filter((item) => item.topics.includes(topic)) + if (group.length === 0) return [] + const files = [...new Set(group.map((item) => item.file))].sort().join(",") + const latest = Math.max(...group.map((item) => item.updatedAt ?? 0)) + return [{ text: `topic=${topic} sources=${files} records=${group.length}`, latest }] + }) + if (rows.length === 0) return [] + return [ + record({ + kind: "TOPIC_HINT", + id: "topic.map", + source: "inventory", + updated: Math.max(...rows.map((row) => row.latest)) || "unknown", + text: rows.map((row) => row.text).join(" | "), + }), + ] + } + + export function project(items: Item[], input?: { include?: string[]; exclude?: string[] }) { + const include = new Set(input?.include ?? []) + const exclude = new Set(input?.exclude ?? []) + return [...items] + .filter((item) => { + const kind = type(item.section) + if (include.size > 0 && !include.has(kind)) return false + return !exclude.has(kind) + }) + .sort((a, b) => rank(a.section) - rank(b.section)) + .map((item) => + record({ + kind: type(item.section), + id: MemoryFiles.inventoryKey({ file: item.file, section: item.section, key: item.key }), + source: item.file, + updated: item.updatedAt, + text: `${item.key} :: ${item.text}`, + }), + ) + } +} diff --git a/packages/kilo-memory/src/recall/indexer.ts b/packages/kilo-memory/src/recall/indexer.ts new file mode 100644 index 0000000000..f5df6bda40 --- /dev/null +++ b/packages/kilo-memory/src/recall/indexer.ts @@ -0,0 +1,195 @@ +import { MemoryBudget } from "./budget" +import { MemoryDigest } from "../capture/digest" +import { MemoryFiles } from "../storage/store" +import { MemoryIndexFormat } from "./index-format" +import { MemorySchema } from "../schema" +import { MemoryShared } from "./shared" + +export namespace MemoryIndexer { + // Budget/envelope concerns live in MemoryBudget; re-exported here to keep MemoryIndexer.* the stable facade. + export type Result = MemoryBudget.Result + export const cap = MemoryBudget.cap + export const fresh = MemoryBudget.fresh + export const stale = MemoryBudget.stale + + type Item = MemoryShared.TypedItem + type Digest = { id: string; topic: string; time: string; summary: string } + + export const digest = { + recent: 240, + latest(input: MemorySchema.Limits) { + return input.maxSessionLineChars + }, + } + const reserved = { + facts: 8, + environment: 12, + } + + function session(input: Digest, opts: { limits: MemorySchema.Limits; latest?: boolean }) { + const topic = input.topic.replaceAll('"', "'") + const max = opts.latest ? digest.latest(opts.limits) : digest.recent + const summary = MemoryShared.brief(input.summary, max) + return MemoryIndexFormat.record({ + kind: opts?.latest ? "LATEST_SESSION_DIGEST" : "SESSION_DIGEST", + id: `${opts?.latest ? "latest_session" : "session"}.${input.id}`, + source: `${input.id}.md`, + updated: input.time, + text: `session=${input.id} topic="${topic}" ${input.time} :: ${summary}`, + }) + } + + function hits(left: string[], right: string[]) { + const found = new Set(right) + return left.filter((item) => found.has(item)).length + } + + function covered(input: { digest: Digest; items: Item[] }) { + const label = MemoryShared.terms(input.digest.topic) + if (label.length < 2) return false + const detail = MemoryShared.terms(input.digest.summary) + return input.items.some((item) => { + const body = MemoryShared.terms(`${item.key} ${item.text}`) + if (hits(label, body) < label.length) return false + if (label.length >= 3) return true + return detail.length >= 2 && hits(detail, body) >= 2 + }) + } + + function topic(input: string) { + return input.toLowerCase().trim().replaceAll(/\s+/g, " ") + } + + function distinct(recent: T[]) { + const topics = new Set() + return recent.filter((item) => { + const value = topic(item.topic) + if (!value) return true + if (topics.has(value)) return false + topics.add(value) + return true + }) + } + + function has(input: { text: string; lines: string[] }) { + return input.lines.every((line) => { + const id = line.match(/\bsession=([^\s]+)/)?.[1] + return id ? input.text.includes(`session=${id}`) : input.text.includes(line) + }) + } + + function assemble(input: { + root: string + limits: MemorySchema.Limits + max: number + current: string[] + corrections: string[] + important: string[] + top: string[] + topEnv: string[] + hints: string[] + rest: string[] + environment: string[] + sessions: string[] + }) { + const keep = input.current + // Topic hints are compact recall routing (topic -> source files); keep them ahead of older sessions and bulk facts. + const primary = [ + ...input.corrections, + ...input.current, + ...input.important, + ...input.top, + ...input.topEnv, + ...input.hints, + ...input.sessions, + ...input.rest, + ...input.environment, + ] + const initial = MemoryBudget.result({ root: input.root, limits: input.limits, lines: primary, max: input.max }) + if (has({ text: initial.text, lines: keep })) return initial + return MemoryBudget.result({ + root: input.root, + limits: input.limits, + lines: [ + ...input.current, + ...input.corrections, + ...input.important, + ...input.top, + ...input.topEnv, + ...input.hints, + ...input.sessions, + ...input.rest, + ...input.environment, + ], + max: input.max, + }) + } + + export async function build(input: { root: string; state?: MemorySchema.State }): Promise { + const state = input.state ?? (await MemoryFiles.readState(input.root)) + const max = state.limits.maxProjectIndexBytes + const inventory = await MemoryFiles.deriveInventory(input.root) + const correctionItems = MemoryShared.typed({ + file: "corrections.md", + text: await MemoryFiles.readSource(input.root, "corrections.md"), + max: state.limits.maxLineChars, + inventory, + }) + const corrections = MemoryIndexFormat.lines("CORRECTION", correctionItems) + const projectItems = MemoryShared.typed({ + file: "project.md", + text: await MemoryFiles.readSource(input.root, "project.md"), + max: state.limits.maxLineChars, + inventory, + }) + const important = MemoryIndexFormat.project(projectItems, { include: ["PROJECT_DECISION", "PROJECT_CONSTRAINT"] }) + const facts = MemoryIndexFormat.project(projectItems, { exclude: ["PROJECT_DECISION", "PROJECT_CONSTRAINT"] }) + const top = facts.slice(0, reserved.facts) + const rest = facts.slice(reserved.facts) + const environmentItems = MemoryShared.typed({ + file: "environment.md", + text: await MemoryFiles.readSource(input.root, "environment.md"), + max: state.limits.maxLineChars, + inventory, + }) + const environment = MemoryIndexFormat.lines("ENV", environmentItems) + const topEnv = environment.slice(0, reserved.environment) + const restEnv = environment.slice(reserved.environment) + const all = [...correctionItems, ...projectItems, ...environmentItems] + const durable = [...projectItems, ...environmentItems] + const recent = await MemoryFiles.recentSessions( + input.root, + state.limits.maxSessionFiles, + state.limits.maxSessionLineChars, + ) + // The continuity pointer must be the true newest session. Only older bulk digests are curated by empty(). + const current = recent[0] ? [session(recent[0], { limits: state.limits, latest: true })] : [] + const sessions = distinct(recent.slice(1).filter((item) => !MemoryDigest.empty(item))) + .filter((item) => !covered({ digest: item, items: durable })) + .slice(0, Math.max(0, state.limits.maxRecentSessions - current.length)) + .map((item) => session(item, { limits: state.limits })) + return assemble({ + root: input.root, + limits: state.limits, + max, + current, + corrections, + important, + top, + topEnv, + hints: MemoryIndexFormat.hints(all), + rest, + environment: restEnv, + sessions, + }) + } + + export async function rebuild(input: { root: string; state?: MemorySchema.State }) { + return MemoryFiles.queue(input.root, async () => { + const result = await build(input) + await MemoryFiles.writeIndex(input.root, result.text) + await MemoryFiles.append(input.root, `regenerate index.kmem bytes=${result.bytes} tokens=${result.tokens}`) + return result + }) + } +} diff --git a/packages/kilo-memory/src/recall/recall.ts b/packages/kilo-memory/src/recall/recall.ts new file mode 100644 index 0000000000..494fc28f04 --- /dev/null +++ b/packages/kilo-memory/src/recall/recall.ts @@ -0,0 +1,265 @@ +import { MemoryDigest } from "../capture/digest" +import { MemoryFiles } from "../storage/store" +import { MemoryIndexer } from "./indexer" +import { MemorySchema } from "../schema" +import { MemoryShared } from "./shared" +import { MemoryTopics } from "./topics" +import { MemoryToken } from "./token" +import { MemorySlug } from "../slug" + +export namespace MemoryRecall { + export type Mode = "search" | "typed" | "digest" + + export type Hit = { + type: "typed" | "digest" + kind: string + source: string + text: string + score: number + topics?: MemorySchema.Topic[] + current?: boolean + updatedAt?: number + id?: string + time?: string + } + + export type Result = { + block: string + hits: Hit[] + bytes: number + tokens: number + } + + function has(input: string, term: string) { + return MemoryShared.terms(input).includes(term) + } + + function typed(input: { + file: MemorySchema.Source + text: string + max: number + inventory: MemoryFiles.Inventory + now: number + }) { + return MemoryShared.typed(input).map( + (item) => + ({ + type: "typed", + kind: MemorySchema.recordKind(item.file, item.section), + source: item.file, + text: `${item.key} :: ${item.text}`, + score: 0, + topics: item.topics, + current: true, + updatedAt: item.updatedAt, + }) satisfies Hit, + ) + } + + async function typedAll(input: { + root: string + state: MemorySchema.State + inventory: MemoryFiles.Inventory + now: number + }) { + const rows = await Promise.all( + MemorySchema.Sources.map(async (file) => + typed({ + file, + text: await MemoryFiles.readSource(input.root, file), + max: input.state.limits.maxLineChars, + inventory: input.inventory, + now: input.now, + }), + ), + ) + return rows.flat() + } + + function time(input: string | undefined) { + if (!input) return + const value = Date.parse(input) + return Number.isFinite(value) ? value : undefined + } + + function digest(input: { file: string; id: string; time: string; topic: string; summary: string }): Hit { + return { + type: "digest", + kind: "SESSION_DIGEST", + source: input.file, + text: `session=${input.id} topic="${input.topic.replaceAll('"', "'")}" ${input.time} :: ${input.summary}`, + score: 0, + topics: [], + current: true, + updatedAt: time(input.time), + id: input.id, + time: input.time, + } + } + + async function digests(input: { + root: string + state: MemorySchema.State + mode: Mode + limit: number + sessionID?: string + currentSessionID?: string + }) { + if (input.mode === "typed") return [] as Hit[] + if (input.sessionID) { + if (input.sessionID === input.currentSessionID) return [] as Hit[] + const item = await MemoryFiles.readSession(input.root, { + sessionID: input.sessionID, + max: input.state.limits.maxSessionLineChars, + }) + if (!item || MemoryDigest.empty(item)) return [] as Hit[] + return [digest(item)] + } + const items = await MemoryFiles.recentSessions( + input.root, + input.state.limits.maxSessionFiles, + input.state.limits.maxSessionLineChars, + ) + return items + .filter((item) => item.id !== input.currentSessionID && !MemoryDigest.empty(item)) + .map(digest) + } + + function score(input: { hit: Hit; keys: string[] }) { + const body = `${input.hit.kind} ${input.hit.source} ${input.hit.text}` + return input.keys.reduce((sum, term) => sum + (has(body, term) ? 1 : 0), 0) + } + + function fresh(input: Hit) { + return input.updatedAt ?? 0 + } + + function compare(a: Hit, b: Hit) { + return ( + b.score - a.score || + fresh(b) - fresh(a) || + (a.type === b.type ? `${a.source}:${a.text}`.localeCompare(`${b.source}:${b.text}`) : a.type === "typed" ? -1 : 1) + ) + } + + function overlap(a: string, b: string) { + const right = MemoryShared.terms(b) + return MemoryShared.terms(a).filter((term) => right.includes(term)).length + } + + function session(input: Hit) { + return input.type === "digest" + } + + function label(input: string) { + return MemorySlug.safe(input, { max: MemorySlug.max.record, fallback: "memory" }) + } + + function dedupe(input: { hits: Hit[]; query: string }) { + const typed = input.hits.filter((hit) => !session(hit)) + return input.hits.filter((hit) => { + if (!session(hit)) return true + return !typed.some((item) => overlap(hit.text, item.text) >= 2 && overlap(item.text, input.query) >= 2) + }) + } + + function renderLine(hit: Hit) { + return hit.type === "digest" + ? `- ${hit.text} (source: ${hit.source})` + : `- ${hit.kind} ${hit.text} (source: ${hit.source})` + } + + export function render(hits: Hit[]) { + const typed = hits.filter((hit) => hit.type === "typed") + const digests = hits.filter((hit) => hit.type === "digest") + return [ + "# Kilo Memory Recall", + ...(typed.length ? ["", "## Typed Memory", ...typed.map(renderLine)] : []), + ...(digests.length ? ["", "## Session Digests", ...digests.map(renderLine)] : []), + ].join("\n") + } + + function body(input: string) { + return input.trim().replaceAll("```", "'''").replaceAll(/\s+/g, " ") + } + + function format(input: { hits: Hit[]; max: number }) { + const lines = [ + "```kilo-memory-v1 targeted_context_not_instruction", + ...input.hits.flatMap((hit) => [ + `record id=${label(`${hit.source}:${hit.kind}:${hit.text.slice(0, 32)}`)} type=${label(hit.kind.toLowerCase())} source=${label(hit.source)}${ + hit.topics?.length ? ` topics=${hit.topics.map(label).join(",")}` : "" + } updated=${hit.updatedAt ? new Date(hit.updatedAt).toISOString() : "unknown"}`, + `text: ${body(hit.text)}`, + ]), + "```", + ] + return MemoryIndexer.cap(lines.join("\n"), input.max).text.trim() + } + + function select(input: { hits: Hit[]; keys: string[]; limit: number; force?: boolean }) { + if (input.keys.length === 0) return [] as Hit[] + const hits = input.hits + .map((hit) => ({ ...hit, score: score({ hit, keys: input.keys }) })) + .filter((hit) => hit.score > 0) + .sort(compare) + if (input.force) return hits.slice(0, input.limit) + const top = hits[0]?.score ?? 0 + return hits.filter((hit) => hit.score >= Math.max(1, top - 2)).slice(0, input.limit) + } + + export async function search(input: { + root: string + query: string + state?: MemorySchema.State + maxBytes?: number + limit?: number + mode?: Mode + sessionID?: string + currentSessionID?: string + force?: boolean + }): Promise { + const state = input.state ?? (await MemoryFiles.readState(input.root)) + if (!state.enabled) return + const query = input.query.trim() + const mode = input.mode ?? "search" + const limit = Math.max(1, Math.min(input.limit ?? 5, 20)) + const inventory = await MemoryFiles.deriveInventory(input.root) + const now = Date.now() + const typedItems = mode === "digest" ? [] : await typedAll({ root: input.root, state, inventory, now }) + const digestItems = await digests({ + root: input.root, + state, + mode, + limit, + sessionID: input.sessionID, + currentSessionID: input.currentSessionID, + }) + if (mode === "digest" && (input.sessionID || !query)) { + const hits = digestItems.slice(0, limit) + if (hits.length === 0) return + const block = format({ hits, max: input.maxBytes ?? 1200 }) + if (!block) return + return { + block, + hits, + bytes: Buffer.byteLength(block), + tokens: MemoryToken.estimate(block), + } + } + const keys = MemoryTopics.expand(MemoryShared.terms(query)) + const hits = dedupe({ + hits: select({ hits: [...typedItems, ...digestItems], keys, limit, force: input.force }), + query, + }) + if (hits.length === 0) return + const block = format({ hits, max: input.maxBytes ?? 1200 }) + if (!block) return + return { + block, + hits, + bytes: Buffer.byteLength(block), + tokens: MemoryToken.estimate(block), + } + } +} diff --git a/packages/kilo-memory/src/recall/shared.ts b/packages/kilo-memory/src/recall/shared.ts new file mode 100644 index 0000000000..441e7bec8b --- /dev/null +++ b/packages/kilo-memory/src/recall/shared.ts @@ -0,0 +1,108 @@ +import type { MemoryOperations } from "../capture/ops" +import { MemoryFiles } from "../storage/store" +import { MemoryMarkdown } from "../storage/markdown" +import { MemorySchema } from "../schema" +import { MemoryText } from "../text" +import { MemoryTopics } from "./topics" + +export namespace MemoryShared { + export type TypedItem = { + file: MemorySchema.Source + section: string + key: string + text: string + topics: MemorySchema.Topic[] + terms: string[] + updatedAt?: number + } + + export type SourceItem = { + id: string + text: string + } + + export const brief = MemoryText.brief + + export function entry(input: string) { + const idx = input.indexOf(" :: ") + if (idx < 0) return + const key = input.slice(0, idx).trim() + const text = input.slice(idx + 4).trim() + if (!key || !text) return + return { key, text } + } + + export function terms(input: string) { + return MemoryTopics.words(input) + } + + export function source(input: { file: MemorySchema.Source; text: string }): SourceItem[] { + const result: SourceItem[] = [] + for (const raw of input.text.split("\n")) { + const item = entry(raw.trim().replace(/^- /, "")) + if (!item) continue + result.push({ id: `${input.file}:${item.key}`, text: `${item.key} ${item.text}` }) + } + return result + } + + export function typed(input: { + file: MemorySchema.Source + text: string + max: number + inventory: MemoryFiles.Inventory + }) { + return MemoryMarkdown.parse(input.text).map((item) => { + const id = MemoryFiles.inventoryKey({ file: input.file, section: item.section, key: item.key }) + const inv = input.inventory.items[id] + const data = { file: input.file, section: item.section, key: item.key, text: item.text } + return { + file: input.file, + section: item.section, + key: item.key, + text: brief(item.text, input.max), + topics: inv?.topics?.length ? inv.topics : MemoryTopics.assign(data), + terms: inv?.terms?.length ? inv.terms : MemoryTopics.terms(data), + updatedAt: inv?.updatedAt, + } + }) + } + + export function refs(ops: MemoryOperations.Op[]) { + return [ + ...new Set( + ops.flatMap((item) => { + if (item.action !== "add" || !item.file) return [] + return [`${item.file}:${item.key}`] + }), + ), + ] + } + + export function files(ops: MemoryOperations.Op[]) { + return [ + ...new Set( + ops.flatMap((item) => { + if (item.action !== "add" || !item.file) return [] + return [item.file] + }), + ), + ] + } + + export function audit(ops: MemoryOperations.Op[]) { + return ops.map((item) => + item.action === "add" + ? { + action: item.action, + file: item.file, + section: item.section, + key: item.key, + } + : { + action: item.action, + query: brief(item.query, 120), + }, + ) + } +} diff --git a/packages/kilo-memory/src/recall/token.ts b/packages/kilo-memory/src/recall/token.ts new file mode 100644 index 0000000000..43f76da471 --- /dev/null +++ b/packages/kilo-memory/src/recall/token.ts @@ -0,0 +1,7 @@ +export namespace MemoryToken { + const chars = 4 + + export function estimate(input: string) { + return Math.max(0, Math.round((input || "").length / chars)) + } +} diff --git a/packages/kilo-memory/src/recall/topics.ts b/packages/kilo-memory/src/recall/topics.ts new file mode 100644 index 0000000000..3c39a88122 --- /dev/null +++ b/packages/kilo-memory/src/recall/topics.ts @@ -0,0 +1,55 @@ +import { MemorySchema } from "../schema" + +export namespace MemoryTopics { + export type Input = { + file?: MemorySchema.Source + section?: string + key?: string + text: string + } + + const limit = { + topics: 3, + terms: 6, + expanded: 24, + } + const matcher = /[\p{L}\p{N}][\p{L}\p{N}_.-]{1,}/gu + + function uniq(input: MemorySchema.Topic[]): MemorySchema.Topic[] { + return [...new Set(input)].slice(0, limit.topics) + } + + function section(input: string | undefined) { + return input?.trim().toLowerCase() ?? "" + } + + export function assign(input: Input): MemorySchema.Topic[] { + if (input.file === "corrections.md") return ["corrections"] + if (input.file === "environment.md") return ["environment"] + const name = section(input.section) + if (name.includes("constraint")) return ["constraints"] + if (name.includes("decision")) return ["project"] + if (input.file === "project.md") return ["project"] + return ["project"] + } + + export function words(input: string, max?: number) { + const found = + input + .toLowerCase() + // NFKC folds compatibility variants, such as full-width letters, before lexical recall matching. + .normalize("NFKC") + .match(matcher) + ?.map((item) => item.replaceAll(/[_.-]+/g, "_")) ?? [] + const result = [...new Set(found)] + return max === undefined ? result : result.slice(0, max) + } + + export function terms(input: Input, max = limit.terms) { + return words([input.key ?? "", input.text].join(" "), max) + } + + export function expand(input: string[], max = limit.expanded) { + return [...new Set(input)].slice(0, max) + } +} diff --git a/packages/kilo-memory/src/schema.ts b/packages/kilo-memory/src/schema.ts new file mode 100644 index 0000000000..68f76c318f --- /dev/null +++ b/packages/kilo-memory/src/schema.ts @@ -0,0 +1,212 @@ +export namespace MemorySchema { + export const VERSION = 1 + + export const Sources = ["project.md", "environment.md", "corrections.md"] as const + export const Topics = [ + "project", + "constraints", + "workflow", + "environment", + "quality", + "ui", + "integration", + "corrections", + ] as const + + export type Source = (typeof Sources)[number] + export type Topic = (typeof Topics)[number] + + export type Capture = { + mode: "selective" + turnClose: boolean + explicit: boolean + maxOpsPerRun: number + minIntervalMs: number + timeoutMs: number + } + + export type Limits = { + maxProjectIndexBytes: number + maxSessionFiles: number + maxRecentSessions: number + maxConsolidationInputBytes: number + maxLineChars: number + maxSessionLineChars: number + } + + export type Stats = { + lastInjectedAt: number | null + lastInjectedBytes: number + lastInjectedTokens: number + lastInjectedSessionID: string | null + lastConsolidatedAt: number | null + lastConsolidatedMessageID: string | null + lastConsolidationCost: number + lastConsolidationTokens: number + lastOperationCount: number + } + + export type State = { + version: 1 + enabled: boolean + scope: "project" + autoInject: boolean + autoConsolidate: boolean + capture: Capture + limits: Limits + stats: Stats + } + + const capture: Capture = { + mode: "selective", + turnClose: true, + explicit: true, + maxOpsPerRun: 16, + minIntervalMs: 300_000, + timeoutMs: 30_000, + } + + const limits: Limits = { + maxProjectIndexBytes: 8192, + maxSessionFiles: 20, + maxRecentSessions: 5, + maxConsolidationInputBytes: 24_000, + maxLineChars: 240, + maxSessionLineChars: 480, + } + + const stats: Stats = { + lastInjectedAt: null, + lastInjectedBytes: 0, + lastInjectedTokens: 0, + lastInjectedSessionID: null, + lastConsolidatedAt: null, + lastConsolidatedMessageID: null, + lastConsolidationCost: 0, + lastConsolidationTokens: 0, + lastOperationCount: 0, + } + + function rec(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) + } + + function bool(input: unknown, fallback: boolean) { + return typeof input === "boolean" ? input : fallback + } + + function num(input: unknown, fallback: number) { + return typeof input === "number" && Number.isFinite(input) && input >= 0 ? input : fallback + } + + function nullable(input: unknown, fallback: number | null) { + if (input === null) return null + return typeof input === "number" && Number.isFinite(input) && input >= 0 ? input : fallback + } + + function str(input: unknown, fallback: string | null) { + return input === null || typeof input === "string" ? input : fallback + } + + export function topic(input: unknown): Topic | undefined { + if (typeof input !== "string") return + return (Topics as readonly string[]).includes(input) ? (input as Topic) : undefined + } + + export function source(input: unknown): Source | undefined { + if (typeof input !== "string") return + return (Sources as readonly string[]).includes(input) ? (input as Source) : undefined + } + + export function topics(input: unknown): Topic[] { + if (!Array.isArray(input)) return [] + return [...new Set(input.flatMap((item) => topic(item) ?? []))].slice(0, 3) + } + + export function kind(file: Source, section: string) { + if (file === "corrections.md") return "correction" + if (file === "environment.md") return "environment" + const value = section.toLowerCase() + if (value.includes("decision")) return "project_decision" + if (value.includes("constraint")) return "project_constraint" + if (value.includes("question")) return "open_question" + return "project_fact" + } + + export function recordKind(file: Source, section: string) { + if (file === "corrections.md") return "CORRECTION" + if (file === "environment.md") return "ENV" + const value = section.toLowerCase() + if (value.includes("decision")) return "PROJECT_DECISION" + if (value.includes("constraint")) return "PROJECT_CONSTRAINT" + if (value.includes("question")) return "INFERENCE" + return "PROJECT_FACT" + } + + export function create(): State { + return { + version: VERSION, + enabled: false, + scope: "project", + autoInject: true, + autoConsolidate: true, + capture: { ...capture }, + limits: { ...limits }, + stats: { ...stats }, + } + } + + export function missing(): State { + return { ...create(), enabled: false } + } + + export function persist(input: State) { + return { + version: input.version, + enabled: input.enabled, + scope: input.scope, + autoInject: input.autoInject, + autoConsolidate: input.autoConsolidate, + capture: input.capture, + stats: input.stats, + } + } + + export function parse(input: unknown): State { + const base = create() + if (!rec(input)) throw new SyntaxError("memory state must be an object") + if (input.version !== undefined && input.version !== VERSION) { + throw new SyntaxError(`unsupported memory state version: ${String(input.version)}`) + } + + const cap = rec(input.capture) ? input.capture : {} + const stat = rec(input.stats) ? input.stats : {} + return { + version: VERSION, + enabled: bool(input.enabled, base.enabled), + scope: "project", + autoInject: true, + autoConsolidate: bool(input.autoConsolidate, base.autoConsolidate), + capture: { + mode: "selective", + turnClose: bool(cap.turnClose, base.capture.turnClose), + explicit: bool(cap.explicit, base.capture.explicit), + maxOpsPerRun: Math.max(1, num(cap.maxOpsPerRun, base.capture.maxOpsPerRun)), + minIntervalMs: num(cap.minIntervalMs, base.capture.minIntervalMs), + timeoutMs: num(cap.timeoutMs, base.capture.timeoutMs), + }, + limits: { ...base.limits }, + stats: { + lastInjectedAt: nullable(stat.lastInjectedAt, base.stats.lastInjectedAt), + lastInjectedBytes: num(stat.lastInjectedBytes, base.stats.lastInjectedBytes), + lastInjectedTokens: num(stat.lastInjectedTokens, base.stats.lastInjectedTokens), + lastInjectedSessionID: str(stat.lastInjectedSessionID, base.stats.lastInjectedSessionID), + lastConsolidatedAt: nullable(stat.lastConsolidatedAt, base.stats.lastConsolidatedAt), + lastConsolidatedMessageID: str(stat.lastConsolidatedMessageID, base.stats.lastConsolidatedMessageID), + lastConsolidationCost: num(stat.lastConsolidationCost, base.stats.lastConsolidationCost), + lastConsolidationTokens: num(stat.lastConsolidationTokens, base.stats.lastConsolidationTokens), + lastOperationCount: num(stat.lastOperationCount, base.stats.lastOperationCount), + }, + } + } +} diff --git a/packages/kilo-memory/src/slug.ts b/packages/kilo-memory/src/slug.ts new file mode 100644 index 0000000000..9fad723784 --- /dev/null +++ b/packages/kilo-memory/src/slug.ts @@ -0,0 +1,25 @@ +import { createHash } from "crypto" + +export namespace MemorySlug { + export const max = { + key: 80, + label: 96, + record: 120, + parts: 5, + hash: 10, + } + + export function safe(input: string, opts: { max: number; fallback: string; lower?: boolean }) { + const text = opts.lower ? input.toLowerCase() : input + const value = text + .normalize("NFKC") + .replaceAll(/[^\p{L}\p{N}_.-]+/gu, "_") + .replaceAll(/^_+|_+$/g, "") + .slice(0, opts.max) + return value || opts.fallback + } + + export function hash(input: string, prefix: string) { + return `${prefix}_${createHash("sha1").update(input).digest("hex").slice(0, max.hash)}` + } +} diff --git a/packages/kilo-memory/src/storage/audit.ts b/packages/kilo-memory/src/storage/audit.ts new file mode 100644 index 0000000000..5f1baca9ff --- /dev/null +++ b/packages/kilo-memory/src/storage/audit.ts @@ -0,0 +1,132 @@ +import { appendFile, chmod } from "fs/promises" +import path from "path" +import z from "zod" +import { MemoryFs } from "./fs" +import { MemoryPaths } from "./paths" +import { MemoryRedact } from "../capture/redact" + +export namespace MemoryAudit { + const MAX_LOG = 128_000 + const LOG_MARGIN = 16_000 + const Log = z + .object({ + kind: z.literal("log"), + summary: z.string(), + time: z.string().optional(), + }) + .passthrough() + + export type Decision = + | { + kind: "log" + result: "logged" + summary: string + } + | { + sessionID?: string + kind: "digest" | "typed" | "recall" + result: "saved" | "skipped" | "fallback" | "error" | "recalled" + trigger?: "explicit" | "turn-close" | "targeted-recall" | "rebuild" + llm?: boolean + parsed?: boolean + fallback?: boolean + reason?: string + tokens?: number + operationCount?: number + skippedCount?: number + fallbackOperationCount?: number + query?: string + topics?: string[] + files?: string[] + summary?: string + skipped?: { reason: string; text?: string; duplicateOf?: string }[] + operations?: { + action: "add" | "remove" + file?: string + section?: string + key?: string + query?: string + }[] + } + + function cap(input: string) { + if (Buffer.byteLength(input) <= MAX_LOG) return input + const lines = input.split("\n").reverse() + const kept: string[] = [] + lines.reduce((sum, line) => { + if (sum >= MAX_LOG) return sum + kept.push(line) + return sum + Buffer.byteLength(`${line}\n`) + }, 0) + return kept.reverse().join("\n") + } + + async function line(file: string, text: string) { + await MemoryFs.dir(path.dirname(file)) + const info = await MemoryFs.guard(file) + if (info && !info.isFile()) throw new Error(`memory path is not a file: ${file}`) + await appendFile(file, text, { mode: MemoryFs.FILE }) + await chmod(file, MemoryFs.FILE).catch((error: unknown) => { + if (process.platform === "win32") return + throw error + }) + const next = await MemoryFs.guard(file) + if (!next?.isFile()) throw new Error(`memory path is not a file: ${file}`) + if (next.size <= MAX_LOG + LOG_MARGIN) return + await MemoryFs.write(file, cap((await MemoryFs.read(file)) ?? "")) + } + + async function audit(root: string, input: Decision) { + const data = MemoryRedact.value(input) as Decision + await MemoryFs.queue(root, () => + line( + MemoryPaths.files(root).decisions, + `${JSON.stringify({ + v: 1, + time: new Date().toISOString(), + ...data, + })}\n`, + ), + ) + } + + export async function append(root: string, text: string) { + await audit(root, { kind: "log", result: "logged", summary: text }) + } + + export async function decide(root: string, input: Decision) { + await audit(root, input) + } + + export async function readDecisions(root: string) { + return MemoryFs.read(MemoryPaths.files(root).decisions) + .then((text) => text ?? "") + .catch((error: unknown) => { + if (MemoryFs.miss(error)) return "" + throw error + }) + } + + function record(input: string) { + try { + const data = JSON.parse(input) + const parsed = Log.safeParse(data) + return parsed.success ? parsed.data : undefined + } catch (error) { + if (MemoryFs.parse(error)) return undefined + throw error + } + } + + export async function readChanges(root: string) { + const lines = (await readDecisions(root)) + .split("\n") + .flatMap((line) => { + const data = record(line) + if (!data) return [] + const time = data.time ?? "" + return [`${time} ${data.summary}`.trim()] + }) + return lines.join("\n") + } +} diff --git a/packages/kilo-memory/src/storage/fs.ts b/packages/kilo-memory/src/storage/fs.ts new file mode 100644 index 0000000000..37da775cd6 --- /dev/null +++ b/packages/kilo-memory/src/storage/fs.ts @@ -0,0 +1,241 @@ +import { AsyncLocalStorage } from "async_hooks" +import { chmod, lstat, mkdir, readFile, rename, rm, stat as follow, utimes, writeFile } from "fs/promises" +import path from "path" + +export namespace MemoryFs { + const locks = new Map>() + export const DIR = 0o700 + export const FILE = 0o600 + const STALE = 30_000 + const local = new AsyncLocalStorage>() + + export function warn(message: string, data?: unknown) { + if (process.env.KILO_MEMORY_DEBUG !== "1") return + console.warn(`[memory.files] ${message}`, data) + } + + export function miss(error: unknown) { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT" + } + + export async function exists(file: string) { + await parents(path.dirname(file)) + return Boolean(await guard(file)) + } + + function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + function code(error: unknown) { + return typeof error === "object" && error !== null && "code" in error ? String(error.code) : "" + } + + export function parse(error: unknown) { + return error instanceof SyntaxError + } + + export function brief(error: unknown) { + return error instanceof Error ? error.message.replaceAll(/\s+/g, " ").slice(0, 160) : String(error).slice(0, 160) + } + + function trusted(file: string) { + if (process.platform !== "darwin") return false + return file === "/var" || file === "/tmp" || file === "/etc" + } + + export async function guard(file: string) { + const info = await lstat(file).catch((error: unknown) => { + if (miss(error)) return + throw error + }) + if (info?.isSymbolicLink()) { + if (trusted(path.resolve(file))) return follow(file) + throw new Error(`memory path rejects symlink: ${file}`) + } + return info + } + + async function parents(file: string) { + const root = path.parse(path.resolve(file)).root + const parts = path.resolve(file).slice(root.length).split(path.sep).filter(Boolean) + await parts.reduce(async (prev, part) => { + const base = await prev + const next = path.join(base, part) + const info = await guard(next) + if (info && !info.isDirectory()) throw new Error(`memory parent is not a directory: ${next}`) + return next + }, Promise.resolve(root)) + } + + export async function dir(file: string) { + await parents(path.dirname(file)) + await guard(file) + await mkdir(file, { recursive: true, mode: DIR }) + await chmod(file, DIR).catch((error: unknown) => { + if (process.platform === "win32") return + throw error + }) + const info = await guard(file) + if (!info?.isDirectory()) throw new Error(`memory path is not a directory: ${file}`) + } + + export async function write(file: string, text: string) { + await dir(path.dirname(file)) + const info = await guard(file) + if (info && !info.isFile()) throw new Error(`memory path is not a file: ${file}`) + const salt = Math.random().toString(36).slice(2) + const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.${salt}.tmp`) + await writeFile(tmp, text, { mode: FILE }) + await chmod(tmp, FILE).catch((error: unknown) => { + if (process.platform === "win32") return + throw error + }) + await rename(tmp, file).catch(async (error: unknown) => { + await rm(tmp, { force: true }).catch((err: unknown) => warn("failed to clean memory temp file", { err, tmp })) + throw error + }) + await chmod(file, FILE).catch((error: unknown) => { + if (process.platform === "win32") return + throw error + }) + } + + export async function read(file: string) { + await parents(path.dirname(file)) + const info = await guard(file) + if (!info) return undefined + if (!info.isFile()) throw new Error(`memory path is not a file: ${file}`) + return readFile(file, "utf8") + } + + export async function json(file: string) { + const text = await read(file) + return text === undefined ? undefined : JSON.parse(text) + } + + export async function backup(file: string) { + const text = await read(file).catch((error: unknown) => { + if (miss(error)) return undefined + throw error + }) + if (text === undefined) return + await write(`${file}.bad-${Date.now()}`, text) + await rm(file, { force: true }) + } + + export async function ensure(file: string, text: string) { + if (await exists(file)) { + const info = await guard(file) + if (!info?.isFile()) throw new Error(`memory path is not a file: ${file}`) + return + } + await write(file, text) + } + + export async function mtime(file: string) { + await parents(path.dirname(file)) + const info = await guard(file) + if (!info) return 0 + if (!info.isFile()) throw new Error(`memory path is not a file: ${file}`) + return info.mtimeMs + } + + export async function mtimeNs(file: string) { + await parents(path.dirname(file)) + const info = await lstat(file, { bigint: true }).catch((error: unknown) => { + if (miss(error)) return undefined + throw error + }) + if (!info) return 0n + if (info.isSymbolicLink()) throw new Error(`memory path must not be a symlink: ${file}`) + return info.mtimeNs + } + + async function lock(root: string) { + await dir(root) + const file = path.join(root, ".lock") + const acquire = async (left: number): Promise<() => Promise> => { + try { + await mkdir(file, { mode: DIR }) + const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}` + const owner = path.join(file, "owner") + await writeFile(owner, token, { mode: FILE }).catch(async (error: unknown) => { + await rm(file, { recursive: true, force: true }) + throw error + }) + const timer = setInterval(() => { + const now = new Date() + void utimes(file, now, now).catch((error: unknown) => warn("failed to refresh memory lock", { error, root })) + }, Math.floor(STALE / 3)) + timer.unref() + return async () => { + clearInterval(timer) + const active = await readFile(owner, "utf8").catch((error: unknown) => { + if (miss(error)) return "" + throw error + }) + if (active !== token) return + await rm(file, { recursive: true, force: true }) + } + } catch (error) { + if (code(error) !== "EEXIST") throw error + const info = await guard(file) + if (!info?.isDirectory()) throw new Error(`memory lock is not a directory: ${file}`) + if (Date.now() - info.mtimeMs > STALE) { + const stolen = `${file}.steal.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}` + const moved = await rename(file, stolen).then( + () => true, + async (err: unknown) => { + if (code(err) === "ENOENT") return false + if (code(err) === "EEXIST") { + await sleep(50) + return false + } + throw err + }, + ) + if (moved) await rm(stolen, { recursive: true, force: true }) + return acquire(left) + } + if (left <= 0) throw new Error(`timed out waiting for memory lock: ${root}`) + await sleep(50) + return acquire(left - 1) + } + } + return acquire(800) + } + + function nested(root: string) { + return local.getStore()?.has(root) === true + } + + export async function queue(root: string, fn: () => Promise): Promise { + if (nested(root)) return fn() + const prev = locks.get(root) ?? Promise.resolve() + const next = prev + .catch((err: unknown) => { + warn("previous memory queue operation failed", { root, err }) + }) + .then(async () => { + const release = await lock(root) + try { + const roots = new Set(local.getStore() ?? []) + roots.add(root) + return await local.run(roots, fn) + } finally { + await release() + } + }) + const done = next.then( + () => undefined, + () => undefined, + ) + locks.set(root, done) + try { + return await next + } finally { + if (locks.get(root) === done) locks.delete(root) + } + } +} diff --git a/packages/kilo-memory/src/storage/markdown.ts b/packages/kilo-memory/src/storage/markdown.ts new file mode 100644 index 0000000000..b733997423 --- /dev/null +++ b/packages/kilo-memory/src/storage/markdown.ts @@ -0,0 +1,74 @@ +/** Serialization for memory source documents: `## Section` headings containing `- key :: text` items. */ +export namespace MemoryMarkdown { + export type Entry = { section: string; key: string; text: string } + + const defaultSection = "Facts" + + export function header(section: string) { + return `## ${section}` + } + + export function line(key: string, text: string) { + return `- ${key} :: ${text}` + } + + // Parse a source document into ordered entries. Items before the first heading take the default + // section; non-item and malformed (empty key/body) lines are skipped. + export function parse(text: string): Entry[] { + const entries: Entry[] = [] + let section = defaultSection + for (const raw of text.split("\n")) { + const value = raw.trim() + if (value.startsWith("## ")) { + section = value.slice(3).trim() || section + continue + } + if (!value.startsWith("- ") || !value.includes(" :: ")) continue + const idx = value.indexOf(" :: ") + const key = value.slice(2, idx).trim() + const body = value.slice(idx + 4).trim() + if (!key || !body) continue + entries.push({ section, key, text: body }) + } + return entries + } + + // Upsert a line under its heading: replace an existing line with the same key in that section, + // otherwise append; create the heading when absent. Reports whether the document changed. + export function upsert(input: { text: string; section: string; line: string }) { + const marker = header(input.section) + const lines = input.text.split("\n") + const at = lines.findIndex((item) => item.trim() === marker) + if (at === -1) { + const next = `${input.text.trimEnd()}\n\n${marker}\n${input.line}\n` + return { text: next, changed: next !== input.text } + } + const end = lines.findIndex((item, idx) => idx > at && item.trim().startsWith("## ")) + const stop = end === -1 ? lines.length : end + const prefix = input.line.split(" :: ")[0] + const without = lines.filter((item, idx) => idx <= at || idx >= stop || !item.trim().startsWith(`${prefix} ::`)) + const head = without.slice(0, at + 1) + const tail = without.slice(at + 1) + const next = [...head, input.line, ...tail].join("\n") + return { text: next, changed: next !== input.text } + } + + // Remove every item line whose entry matches; headings and other lines are preserved. + export function remove(input: { text: string; match: (entry: Entry) => boolean }) { + const lines = input.text.split("\n") + let section = defaultSection + const kept = lines.filter((item) => { + const value = item.trim() + if (value.startsWith("## ")) { + section = value.slice(3).trim() || section + return true + } + if (!value.startsWith("- ") || !value.includes(" :: ")) return true + const idx = value.indexOf(" :: ") + const key = value.slice(2, idx).trim() + const text = value.slice(idx + 4).trim() + return !input.match({ section, key, text }) + }) + return { text: kept.join("\n"), count: lines.length - kept.length } + } +} diff --git a/packages/kilo-memory/src/storage/paths.ts b/packages/kilo-memory/src/storage/paths.ts new file mode 100644 index 0000000000..eabbfcb38f --- /dev/null +++ b/packages/kilo-memory/src/storage/paths.ts @@ -0,0 +1,126 @@ +import { readFileSync, realpathSync, statSync } from "fs" +import { createHash } from "crypto" +import path from "path" +import type { MemorySchema } from "../schema" +import { MemorySlug } from "../slug" + +export namespace MemoryPaths { + export type Ctx = { + directory: string + worktree: string + } + + export type Files = { + root: string + state: string + index: string + manifest: string + project: string + environment: string + corrections: string + sessions: string + decisions: string + ignore: string + } + + export type Identity = { + display: string + canonical: string + folder: string + } + + export type Host = { + home: string + config: string + } + + function base(ctx: Ctx) { + return ctx.worktree === "/" ? ctx.directory : ctx.worktree + } + + function stat(file: string) { + return statSync(file, { throwIfNoEntry: false }) + } + + function read(file: string) { + try { + return readFileSync(file, "utf8").trim() + } catch { + return undefined + } + } + + function checkout(dir: string) { + return path.basename(dir) === ".git" ? path.dirname(dir) : undefined + } + + function common(dir: string) { + const text = read(path.join(dir, "commondir")) + return text ? path.resolve(dir, text) : dir + } + + function project(dir: string) { + const dot = path.join(dir, ".git") + const info = stat(dot) + if (!info) return dir + if (info.isDirectory()) return checkout(common(dot)) ?? dir + if (!info.isFile()) return dir + const text = read(dot) + const match = text?.match(/^gitdir:\s*(.+)$/m) + if (!match?.[1]) return dir + const git = path.resolve(dir, match[1]) + return checkout(common(git)) ?? dir + } + + function canon(dir: string) { + const resolved = path.resolve(dir) + try { + return realpathSync(resolved) + } catch { + return resolved + } + } + + export function identity(input: { ctx: Ctx }): Identity { + const root = canon(project(base(input.ctx))) + const display = MemorySlug.safe(path.basename(root), { max: MemorySlug.max.label, fallback: "project" }) + const hash = createHash("sha1").update(root).digest("hex").slice(0, 12) + return { + display, + canonical: root, + folder: `${display}-${hash}`, + } + } + + function global(input: Host) { + const dir = path.resolve(input.config) + if (path.basename(dir) === ".kilo") return dir + return path.join(input.home, ".kilo") + } + + export function root(input: { ctx: Ctx } & Host) { + return path.join(global(input), "memory", identity(input).folder) + } + + export function files(root: string): Files { + return { + root, + state: path.join(root, "state.json"), + index: path.join(root, "index.kmem"), + manifest: path.join(root, "manifest.json"), + project: path.join(root, "project.md"), + environment: path.join(root, "environment.md"), + corrections: path.join(root, "corrections.md"), + sessions: path.join(root, "sessions"), + decisions: path.join(root, "decisions.jsonl"), + ignore: path.join(root, ".gitignore"), + } + } + + export function source(root: string, name: MemorySchema.Source) { + const paths = files(root) + if (name === "project.md") return paths.project + if (name === "environment.md") return paths.environment + return paths.corrections + } +} diff --git a/packages/kilo-memory/src/storage/sessions.ts b/packages/kilo-memory/src/storage/sessions.ts new file mode 100644 index 0000000000..10f7856de8 --- /dev/null +++ b/packages/kilo-memory/src/storage/sessions.ts @@ -0,0 +1,174 @@ +import { readdir, unlink } from "fs/promises" +import path from "path" +import { MemoryFs } from "./fs" +import { MemoryPaths } from "./paths" +import { MemoryRedact } from "../capture/redact" +import { MemorySlug } from "../slug" +import { MemoryText } from "../text" + +export namespace MemorySessions { + type Digest = { + file: string + id: string + time: string + topic: string + summary: string + } + + function stamp(input: number) { + return new Date(input).toISOString().replaceAll(":", "-") + } + + function session(file: string, content: string) { + const header = content + .split("\n") + .find((line) => line.startsWith("# Session ")) + ?.slice("# Session ".length) + .trim() + if (header) return header + const idx = file.indexOf("_") + return idx === -1 ? file.replace(/\.md$/, "") : file.slice(idx + 1).replace(/\.md$/, "") + } + + function topic(input: { summary: string; topic?: string }) { + return MemoryText.brief(input.topic || input.summary.split(/[.;:]/)[0] || input.summary, 80) + } + + async function list(root: string) { + const paths = MemoryPaths.files(root) + const names = await readdir(paths.sessions).catch((error: unknown) => { + if (MemoryFs.miss(error)) return [] as string[] + throw error + }) + return { paths, names } + } + + async function drop(file: string) { + await unlink(file).catch((error: unknown) => { + if (MemoryFs.miss(error)) return + throw error + }) + } + + function content(input: { id: string; topic: string; summary: string; time: number }) { + return [ + `# Session ${input.id}`, + "", + "Version: 1", + `Updated: ${new Date(input.time).toISOString()}`, + `Topic: ${input.topic}`, + "", + "## Summary", + input.summary, + "", + ].join("\n") + } + + function draft( + root: string, + input: { sessionID: string; topic?: string; summary: string; max: number; time?: number }, + ) { + const paths = MemoryPaths.files(root) + const id = MemorySlug.safe(input.sessionID, { max: MemorySlug.max.label, fallback: "session" }) + const time = input.time ?? Date.now() + if (!Number.isFinite(time)) throw new RangeError("memory session time must be finite") + const hash = MemorySlug.hash(input.sessionID, "id") + const name = `${stamp(time)}_${id}_${hash}.md` + const summary = MemoryText.brief(MemoryRedact.text(input.summary), input.max) + const label = topic({ summary, topic: input.topic ? MemoryRedact.text(input.topic) : undefined }) + return { + id: input.sessionID, + name, + file: path.join(paths.sessions, name), + text: content({ id: input.sessionID, topic: label, summary, time }), + } + } + + function parse(file: string, content: string, max: number): Digest | undefined { + const lines = content.split("\n") + const idx = lines.findIndex((line) => line.trim() === "## Summary") + if (idx < 0) return + const time = + lines + .find((line) => line.startsWith("Updated: ")) + ?.slice("Updated: ".length) + .trim() ?? file + const label = lines + .find((line) => line.startsWith("Topic: ")) + ?.slice("Topic: ".length) + .trim() + const summary = MemoryText.brief(lines.slice(idx + 1).find((line) => line.trim()) ?? "", max) + if (!summary) return + return { file, id: session(file, content), time, topic: topic({ summary, topic: label }), summary } + } + + async function removePrior(root: string, id: string, keep: string) { + const listed = await list(root) + await Promise.all( + listed.names.map(async (file) => { + if (!file.endsWith(".md") || file === keep) return + const content = await MemoryFs.read(path.join(listed.paths.sessions, file)) + if (!content || session(file, content) !== id) return + await drop(path.join(listed.paths.sessions, file)) + }), + ) + } + + export async function writeSession( + root: string, + input: { sessionID: string; topic?: string; summary: string; max: number; time?: number }, + ) { + const paths = MemoryPaths.files(root) + await MemoryFs.dir(paths.sessions) + const next = draft(root, input) + await MemoryFs.write(next.file, next.text) + await removePrior(root, next.id, next.name) + return next.file + } + + export async function readSession(root: string, input: { sessionID: string; max: number }) { + const listed = await list(root) + return listed.names + .filter((item) => item.endsWith(".md")) + .sort() + .reverse() + .reduce(async (prior, file) => { + const current = await prior + if (current) return current + const content = await MemoryFs.read(path.join(listed.paths.sessions, file)) + if (!content) return + const item = parse(file, content, input.max) + if (item?.id !== input.sessionID) return + return item + }, Promise.resolve(undefined as Digest | undefined)) + } + + export async function pruneSessions(root: string, max: number) { + const listed = await list(root) + const keep = Math.max(0, max) + await Promise.all( + listed.names + .filter((file) => file.endsWith(".md")) + .sort() + .reverse() + .slice(keep) + .map((file) => drop(path.join(listed.paths.sessions, file))), + ) + } + + export async function recentSessions(root: string, limit: number, max: number) { + const listed = await list(root) + const result: Digest[] = [] + for (const file of listed.names + .filter((item) => item.endsWith(".md")) + .sort() + .reverse() + .slice(0, limit)) { + const content = await MemoryFs.read(path.join(listed.paths.sessions, file)) + if (!content) continue + const item = parse(file, content, max) + if (item) result.push(item) + } + return result + } +} diff --git a/packages/kilo-memory/src/storage/sources.ts b/packages/kilo-memory/src/storage/sources.ts new file mode 100644 index 0000000000..d84672ab24 --- /dev/null +++ b/packages/kilo-memory/src/storage/sources.ts @@ -0,0 +1,69 @@ +import { MemoryFs } from "./fs" +import { MemoryMarkdown } from "./markdown" +import { MemoryPaths } from "./paths" +import { MemorySchema } from "../schema" +import { MemoryTopics } from "../recall/topics" +import { MemorySlug } from "../slug" + +export namespace MemorySources { + export type InventoryItem = { + file: MemorySchema.Source + section: string + key: string + text: string + topics?: MemorySchema.Topic[] + terms?: string[] + /** Derived from source file mtime and line offset; useful for ranking, not exact item creation time. */ + createdAt: number + /** Derived from source file mtime and line offset; useful for ranking, not exact item update time. */ + updatedAt: number + } + + export type Inventory = { + version: 1 + items: Record + } + + export function inventoryKey(input: { file: MemorySchema.Source; section: string; key: string }) { + return [input.file, input.section, input.key] + .map((item) => MemorySlug.safe(item, { max: MemorySlug.max.record, fallback: "" })) + .join(":") + } + + export async function readSource(root: string, name: MemorySchema.Source) { + const file = MemoryPaths.source(root, name) + return MemoryFs.read(file) + .then((text) => text ?? "") + .catch((error: unknown) => { + if (MemoryFs.miss(error)) return "" + throw error + }) + } + + export async function writeSource(root: string, name: MemorySchema.Source, text: string) { + await MemoryFs.write(MemoryPaths.source(root, name), text.endsWith("\n") ? text : `${text}\n`) + } + + export async function deriveInventory(root: string): Promise { + const items: Inventory["items"] = {} + for (const file of MemorySchema.Sources) { + const text = await readSource(root, file) + const time = await MemoryFs.mtime(MemoryPaths.source(root, file)).catch((error: unknown) => { + if (MemoryFs.miss(error)) return 0 + throw error + }) + MemoryMarkdown.parse(text).forEach((entry, offset) => { + const data = { file, section: entry.section, key: entry.key, text: entry.text } + const stamp = Math.max(0, time - offset) + items[inventoryKey(data)] = { + ...data, + topics: MemoryTopics.assign(data), + terms: MemoryTopics.terms(data), + createdAt: stamp, + updatedAt: stamp, + } + }) + } + return { version: 1, items } + } +} diff --git a/packages/kilo-memory/src/storage/state.ts b/packages/kilo-memory/src/storage/state.ts new file mode 100644 index 0000000000..94330b027e --- /dev/null +++ b/packages/kilo-memory/src/storage/state.ts @@ -0,0 +1,231 @@ +import { readdir, rm } from "fs/promises" +import path from "path" +import { MemoryAudit } from "./audit" +import { MemoryFs } from "./fs" +import { MemoryMarkdown } from "./markdown" +import { MemoryPaths } from "./paths" +import { MemorySchema } from "../schema" +import { MemorySources } from "./sources" +import { MemoryText } from "../text" +import { MemoryTopics } from "../recall/topics" + +export namespace MemoryState { + const seed: Record = { + "project.md": "# Project Memory\n\n## Facts\n\n## Decisions\n\n## Constraints\n\n## Open Questions\n", + "environment.md": "# Environment Memory\n\n## Commands\n\n## Paths\n\n## Tooling\n", + "corrections.md": "# Corrective Memory\n\n## Corrections\n", + } + + async function recover(root: string, file: string, error: unknown) { + await MemoryFs.backup(file) + const state = MemorySchema.missing() + await writeState(root, state) + await MemoryAudit.append(root, `recover state.json error=${MemoryFs.brief(error)}`).catch((err: unknown) => + MemoryFs.warn("failed to audit memory state recovery", { err, root }), + ) + return state + } + + export async function readState(root: string) { + const file = MemoryPaths.files(root).state + const data = await MemoryFs.json(file).catch(async (error: unknown) => { + if (MemoryFs.miss(error)) return undefined + if (MemoryFs.parse(error)) return recover(root, file, error) + throw error + }) + if (data === undefined) return MemorySchema.missing() + return Promise.resolve() + .then(() => MemorySchema.parse(data)) + .catch((error: unknown) => { + if (MemoryFs.parse(error)) return recover(root, file, error) + throw error + }) + } + + export async function writeState(root: string, state: MemorySchema.State) { + await MemoryFs.write(MemoryPaths.files(root).state, `${JSON.stringify(MemorySchema.persist(state), null, 2)}\n`) + } + + export async function writeManifest(root: string, id?: MemoryPaths.Identity) { + const file = MemoryPaths.files(root).manifest + const prior = await MemoryFs.json(file).catch((error: unknown) => { + if (MemoryFs.miss(error)) return undefined + throw error + }) + const createdAt = + typeof prior === "object" && prior !== null && "createdAt" in prior && typeof prior.createdAt === "string" + ? prior.createdAt + : new Date().toISOString() + await MemoryFs.write( + file, + `${JSON.stringify( + { + kind: "kilo-memory", + version: 1, + ...(id + ? { + display: id.display, + canonical: id.canonical, + folder: id.folder, + } + : {}), + createdAt, + }, + null, + 2, + )}\n`, + ) + } + + export async function owned(root: string) { + const data = await MemoryFs.json(MemoryPaths.files(root).manifest).catch((error: unknown) => { + if (MemoryFs.miss(error)) return undefined + throw error + }) + return ( + typeof data === "object" && + data !== null && + "kind" in data && + data.kind === "kilo-memory" && + "version" in data && + data.version === 1 + ) + } + + export async function readIndex(root: string) { + const file = MemoryPaths.files(root).index + return MemoryFs.read(file) + .then((text) => text ?? "") + .catch((error: unknown) => { + if (MemoryFs.miss(error)) return "" + throw error + }) + } + + export async function writeIndex(root: string, text: string) { + await MemoryFs.write(MemoryPaths.files(root).index, text) + } + + export async function indexExpired(root: string) { + const paths = MemoryPaths.files(root) + const index = await MemoryFs.guard(paths.index) + if (!index) return true + if (!index.isFile()) throw new Error(`memory path is not a file: ${paths.index}`) + const stamp = await MemoryFs.mtimeNs(paths.index) + const files = await readdir(paths.sessions).catch((error: unknown) => { + if (MemoryFs.miss(error)) return [] as string[] + throw error + }) + const digests = files.filter((file) => file.endsWith(".md")).sort().reverse() + const sources = [ + paths.project, + paths.environment, + paths.corrections, + ...digests.map((file) => path.join(paths.sessions, file)), + ] + const times = await Promise.all(sources.map((file) => MemoryFs.mtimeNs(file))) + if (times.slice(0, MemorySchema.Sources.length).some((time) => time === 0n)) return true + const content = (await MemoryFs.read(paths.index)) ?? "" + const indexed = new Set([...content.matchAll(/^text: session=([^\s]+)/gm)].map((match) => match[1])) + const current = await Promise.all( + digests.map(async (file) => { + const text = await MemoryFs.read(path.join(paths.sessions, file)) + if (!text) return + const lines = text.split("\n") + const at = lines.findIndex((line) => line.trim() === "## Summary") + if (at < 0 || !lines.slice(at + 1).some((line) => line.trim())) return + return text.match(/^# Session (.+)$/m)?.[1]?.trim() + }), + ) + const ids = new Set(current.filter((id): id is string => Boolean(id))) + if ([...indexed].some((id) => !ids.has(id))) return true + const latest = current.find((id): id is string => Boolean(id)) + if (latest && !indexed.has(latest)) return true + const sessions = await MemoryFs.guard(paths.sessions) + if (sessions && !sessions.isDirectory()) throw new Error(`memory path is not a directory: ${paths.sessions}`) + const changed = await MemoryFs.mtimeNs(paths.sessions) + return times.some((time) => time > stamp) || changed > stamp + } + + export async function scaffold(root: string, id?: MemoryPaths.Identity) { + const paths = MemoryPaths.files(root) + await MemoryFs.dir(root) + await MemoryFs.dir(paths.sessions) + await MemoryFs.ensure(paths.ignore, "*\n!.gitignore\n") + await MemoryFs.ensure(paths.project, seed["project.md"]) + await MemoryFs.ensure(paths.environment, seed["environment.md"]) + await MemoryFs.ensure(paths.corrections, seed["corrections.md"]) + await writeManifest(root, id) + const present = await MemoryFs.exists(paths.state) + const state = present + ? { ...(await readState(root)), enabled: true, autoInject: true } + : { ...MemorySchema.create(), enabled: true } + await writeState(root, state) + await MemoryAudit.append(root, "enable project source=command") + return state + } + + function iso(input?: number) { + if (!input || !Number.isFinite(input)) return "unknown" + return new Date(input).toISOString() + } + + async function inspect(root: string, data: MemorySources.Inventory) { + const lines: string[] = [] + for (const file of MemorySchema.Sources) { + const body = await MemorySources.readSource(root, file) + for (const { section, key, text } of MemoryMarkdown.parse(body)) { + const id = MemorySources.inventoryKey({ file, section, key }) + const inv = data.items[id] + const topics = inv?.topics?.length ? inv.topics : MemoryTopics.assign({ file, section, key, text }) + const terms = inv?.terms?.length ? inv.terms : MemoryTopics.terms({ file, section, key, text }) + lines.push( + [ + `- id=${id}`, + `type=${MemorySchema.kind(file, section)}`, + `source=${file}`, + `section=${section || "unknown"}`, + `key=${key}`, + `topics=${topics.join(",") || "unknown"}`, + `terms=${terms.join(",") || "unknown"}`, + `updated=${iso(inv?.updatedAt)}`, + `created=${iso(inv?.createdAt)}`, + "timeSource=source_mtime_line_offset", + "stale=no", + "expires=never", + `:: ${MemoryText.brief(text, 300)}`, + ].join(" "), + ) + } + } + return lines.join("\n") + } + + export async function show(root: string) { + const state = await readState(root) + const inventory = await MemorySources.deriveInventory(root) + return { + root, + state, + sources: { + project: await MemorySources.readSource(root, "project.md"), + environment: await MemorySources.readSource(root, "environment.md"), + corrections: await MemorySources.readSource(root, "corrections.md"), + }, + index: await readIndex(root), + inventory, + items: await inspect(root, inventory), + changes: await MemoryAudit.readChanges(root), + decisions: await MemoryAudit.readDecisions(root), + } + } + + export async function purge(root: string) { + const info = await MemoryFs.guard(root) + if (!info) return false + if (!info.isDirectory()) throw new Error(`memory root is not a directory: ${root}`) + if (!(await owned(root))) throw new Error(`refusing to purge unowned memory root: ${root}`) + await rm(root, { recursive: true, force: true }) + return true + } +} diff --git a/packages/kilo-memory/src/storage/store.ts b/packages/kilo-memory/src/storage/store.ts new file mode 100644 index 0000000000..c7ca4be45d --- /dev/null +++ b/packages/kilo-memory/src/storage/store.ts @@ -0,0 +1,44 @@ +import { MemoryAudit } from "./audit" +import { MemoryFs } from "./fs" +import { MemorySessions } from "./sessions" +import { MemorySources } from "./sources" +import { MemoryState } from "./state" + +/** Low-level raw-root APIs. Callers must pass a project-owned root from MemoryPaths.root(ctx). */ +export namespace MemoryFiles { + export type Decision = MemoryAudit.Decision + export type InventoryItem = MemorySources.InventoryItem + export type Inventory = MemorySources.Inventory + + export const exists = MemoryFs.exists + export const queue = MemoryFs.queue + + export const readState = MemoryState.readState + export const writeState = MemoryState.writeState + export const inventoryKey = MemorySources.inventoryKey + export const deriveInventory = MemorySources.deriveInventory + export const writeManifest = MemoryState.writeManifest + + export const append = MemoryAudit.append + export const decide = MemoryAudit.decide + export const readDecisions = MemoryAudit.readDecisions + export const readChanges = MemoryAudit.readChanges + + export const indexExpired = MemoryState.indexExpired + export const scaffold = MemoryState.scaffold + export const owned = MemoryState.owned + + export const writeSession = MemorySessions.writeSession + export const readSession = MemorySessions.readSession + export const pruneSessions = MemorySessions.pruneSessions + export const recentSessions = MemorySessions.recentSessions + + export const readSource = MemorySources.readSource + export const writeSource = MemorySources.writeSource + + export const readIndex = MemoryState.readIndex + export const writeIndex = MemoryState.writeIndex + + export const show = MemoryState.show + export const purge = MemoryState.purge +} diff --git a/packages/kilo-memory/src/text.ts b/packages/kilo-memory/src/text.ts new file mode 100644 index 0000000000..a25ad93435 --- /dev/null +++ b/packages/kilo-memory/src/text.ts @@ -0,0 +1,20 @@ +export namespace MemoryText { + /** Collapse internal whitespace and clip to `max` characters, appending an ellipsis when truncated. */ + export function brief(input: string, max: number) { + const text = input.trim().replaceAll(/\s+/g, " ") + if (text.length <= max) return text + return `${text.slice(0, Math.max(0, max - 3))}...` + } + + /** Normalize for fuzzy matching: lowercase, NFKC, strip quotes/punctuation, collapse whitespace. */ + export function normalized(input: string) { + return input + .trim() + .toLowerCase() + .normalize("NFKC") + .replaceAll(/[`'"“”‘’]/g, "") + .replaceAll(/[^\p{L}\p{N}_.-]+/gu, " ") + .replaceAll(/\s+/g, " ") + .trim() + } +} diff --git a/packages/kilo-memory/test/capture.test.ts b/packages/kilo-memory/test/capture.test.ts new file mode 100644 index 0000000000..eff34dcdbe --- /dev/null +++ b/packages/kilo-memory/test/capture.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, test } from "bun:test" +import { + capturePlan, + duplicateOps, + fallbackDigest, + guardReason, + hasDurableDiff, + mergeOps, + notice, + parseJson, + parseOps, + skipLine, + summarizeDiffs, + typedSchema, + verifySkips, + digestSchema, +} from "../src/capture/capture" +import { MemoryOperations } from "../src/capture/ops" +import { MemoryRedact } from "../src/capture/redact" + +describe("memory capture parsing", () => { + test("parses fenced json text from model output", () => { + const parsed = parseJson(digestSchema, '```json\n{"topic":"repo setup","summary":"Run package tests."}\n```') + + expect(parsed).toEqual({ topic: "repo setup", summary: "Run package tests." }) + }) + + test("maps consolidation operation names into deterministic memory operations", () => { + const parsed = parseJson( + typedSchema, + JSON.stringify({ + operations: [ + { op: "upsert_project_fact", key: "repo_tests", value: "Run tests from packages/opencode." }, + { + op: "upsert_project_decision", + key: "file_store", + value: "Keep memory v0 file-based before adding databases.", + }, + { op: "upsert_project_constraint", key: "zod_only", value: "The memory package stays zod-only." }, + { op: "upsert_environment_fact", section: "tooling", key: "bun", value: "Use bun for package scripts." }, + { op: "append_correction", key: "root_tests", value: "Do not run bun test from the repo root." }, + { op: "remove_memory", query: "old_memory" }, + { op: "noop", key: "ignored", value: "ignored" }, + ], + skipped: [{ reason: "duplicate", text: "already saved" }], + }), + ) + + expect(parseOps(parsed)).toEqual([ + { + action: "add", + file: "project.md", + section: "Facts", + key: "repo_tests", + text: "Run tests from packages/opencode.", + }, + { + action: "add", + file: "project.md", + section: "Decisions", + key: "file_store", + text: "Keep memory v0 file-based before adding databases.", + }, + { + action: "add", + file: "project.md", + section: "Constraints", + key: "zod_only", + text: "The memory package stays zod-only.", + }, + { + action: "add", + file: "environment.md", + section: "Tooling", + key: "bun", + text: "Use bun for package scripts.", + }, + { + action: "add", + file: "corrections.md", + section: "Corrections", + key: "root_tests", + text: "Do not run bun test from the repo root.", + }, + { action: "remove", query: "old_memory" }, + ]) + expect(parsed.skipped).toEqual([{ reason: "duplicate", text: "already saved" }]) + }) + + test("merges fallback typed operations without duplicates", () => { + const ops = mergeOps([ + { action: "add", file: "environment.md", section: "Commands", key: "tests", text: "Run bun test." }, + { action: "add", file: "environment.md", section: "Commands", key: "tests", text: "Run bun test again." }, + { action: "remove", query: "stale" }, + { action: "remove", query: "stale" }, + ]) + + expect(ops).toEqual([ + { action: "add", file: "environment.md", section: "Commands", key: "tests", text: "Run bun test." }, + { action: "remove", query: "stale" }, + ]) + }) + + test("filters self-referential generated adds", () => { + const fact = { + action: "add", + file: "project.md", + section: "Facts", + key: "memory_index", + text: "Memory index records are rebuilt from project source files.", + } satisfies MemoryOperations.Op + const filtered = duplicateOps({ + items: [], + skipped: [], + ops: [ + { + action: "add", + file: "project.md", + section: "Facts", + key: "memory_echo", + text: "Small model call-site behavior is already in project memory.", + }, + { + action: "add", + file: "project.md", + section: "Facts", + key: "scope_review", + text: "Config preference scope/write behavior was investigated.", + }, + fact, + ], + }) + + expect(filtered.ops).toEqual([fact]) + expect(filtered.skipped.map((item) => item.reason)).toEqual(["self_referential", "self_referential"]) + expect(MemoryOperations.reject(fact)).toBeUndefined() + }) + + test("filters instruction provenance generated adds", () => { + const fact = { + action: "add", + file: "project.md", + section: "Facts", + key: "repo_test_rule", + text: "Root AGENTS.md says to run package-level tests instead of root bun test.", + } satisfies MemoryOperations.Op + const filtered = duplicateOps({ + items: [], + skipped: [], + ops: [ + { + action: "add", + file: "project.md", + section: "Facts", + key: "instruction_sources", + text: "Sources: system/developer instructions, AGENTS.md, packages/opencode/AGENTS.md, and ~/.claude/CLAUDE.md.", + }, + { + action: "add", + file: "project.md", + section: "Facts", + key: "user_context", + text: "~/.claude/CLAUDE.md is user-level context for concise replies.", + }, + fact, + ], + }) + + expect(filtered.ops).toEqual([fact]) + expect(filtered.skipped.map((item) => item.reason)).toEqual(["out_of_scope", "out_of_scope"]) + }) + + test("parses project-only skip reasons", () => { + const parsed = parseJson( + typedSchema, + JSON.stringify({ + operations: [{ op: "noop" }], + skipped: [ + { reason: "out_of_scope", text: "User prefers concise commit messages." }, + { reason: "self_referential", text: "Existing memory already tracks the test command." }, + ], + }), + ) + + expect(parseOps(parsed)).toEqual([]) + expect(parsed.skipped.map((item) => item.reason)).toEqual(["out_of_scope", "self_referential"]) + expect(skipLine([parsed.skipped[0]!])).toBe("reason=out_of_scope") + }) + + test("plans capture cadence from a state table", () => { + const base = { + summary: "User: continue Result: updated code", + echo: false, + durable: false, + priorTime: 0, + now: 1_000, + minIntervalMs: 500, + lastConsolidatedAt: undefined, + autoConsolidate: true, + } + const cases = [ + { + name: "expected work: completed turn schedules digest and typed capture", + input: base, + expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined }, + }, + { + name: "expected idle flush: completed turn inside interval skips now", + input: { ...base, priorTime: 900, lastConsolidatedAt: 900 }, + expected: { digestDue: false, typedCall: false, skipReason: "interval", idleFlush: true }, + }, + { + name: "expected work: bypass interval lets idle flush run typed capture", + input: { ...base, priorTime: 900, lastConsolidatedAt: 900, bypassInterval: true }, + expected: { digestDue: false, typedCall: true, skipReason: undefined, idleFlush: false }, + }, + { + name: "expected skip: recall echo with no durable diff", + input: { ...base, echo: true }, + expected: { session: false, digestDue: false, typedCall: false, skipReason: "memory_echo" }, + }, + { + name: "expected work: recall-assisted durable answer is modeled as non-echo by caller", + input: { ...base, durable: true }, + expected: { session: true, digestDue: true, typedCall: true, skipReason: undefined }, + }, + { + name: "expected skip: interrupted turn", + input: { ...base, reason: "interrupted" as const, durable: true }, + expected: { completed: false, session: false, digestDue: false, typedCall: false, skipReason: "no_work" }, + }, + { + name: "expected skip: errored turn", + input: { ...base, reason: "error" as const }, + expected: { completed: false, session: false, digestDue: false, typedCall: false, skipReason: "no_work" }, + }, + { + name: "expected skip: auto consolidation disabled", + input: { ...base, autoConsolidate: false }, + expected: { session: false, digestDue: false, typedCall: false, typedWork: false, skipReason: "no_work" }, + }, + { + name: "expected skip: no summary means no work", + input: { ...base, summary: "" }, + expected: { session: false, digestDue: false, typedCall: false, typedWork: false, skipReason: "no_work" }, + }, + ] + + for (const item of cases) { + expect(capturePlan(item.input), item.name).toMatchObject(item.expected) + } + }) + + test("summarizes durable diffs and fallback digests", () => { + const diffs = [ + { file: "src/index.ts", status: "modified", additions: 1, deletions: 1 }, + { file: "README.md", status: "modified", additions: 1, deletions: 0 }, + ] + + expect(hasDurableDiff(diffs)).toBe(true) + expect(hasDurableDiff([{ file: "docs/setup.md", additions: 1, deletions: 0 }])).toBe(true) + expect(hasDurableDiff([{ file: ".kilo/rules.md", additions: 1, deletions: 0 }])).toBe(true) + expect(hasDurableDiff([{ file: "src/plain.ts", additions: 1, deletions: 0 }])).toBe(false) + expect(summarizeDiffs(diffs)).toContain("modified README.md +1 -0") + expect(fallbackDigest({ prior: "Earlier state.", summary: "New state.", max: 80 })).toContain("Latest: New state.") + }) + + test("verifies duplicate skips and operation duplicates", () => { + const items = [{ id: "project.md:repo_tests", text: "repo_tests Run memory tests from packages/opencode." }] + const verified = verifySkips({ + items, + skipped: [ + { reason: "duplicate", text: "Run memory tests from packages/opencode." }, + { reason: "duplicate", text: "New durable workflow preference." }, + ], + }) + const deduped = duplicateOps({ + items, + skipped: verified.skipped, + ops: [ + { action: "add", file: "project.md", section: "Facts", key: "repo_tests", text: "Run memory tests." }, + { action: "add", file: "project.md", section: "Facts", key: "new_preference", text: "New durable workflow preference." }, + ], + }) + + expect(verified.skipped[0]?.duplicateOf).toBe("project.md:repo_tests") + expect(verified.rescued).toEqual([]) + expect(verified.skipped).toContainEqual({ reason: "unsupported", text: "New durable workflow preference." }) + expect(deduped.ops).toEqual([ + { action: "add", file: "project.md", section: "Facts", key: "new_preference", text: "New durable workflow preference." }, + ]) + expect(deduped.skipped.some((item) => item.duplicateOf === "project.md:repo_tests")).toBe(true) + }) + + test("builds capture notices and guard summaries", () => { + const ops = [{ action: "add", file: "environment.md", section: "Commands", key: "tests", text: "Run bun test." }] as const + + expect(notice({ count: 1, ops: [...ops], skipped: [], tokens: 12 })).toMatchObject({ + type: "saved", + message: "Memory saved · environment.md:tests", + files: ["environment.md"], + }) + expect(notice({ count: 0, ops: [], skipped: [{ reason: "duplicate", duplicateOf: "project.md:tests" }], tokens: 3 })) + .toMatchObject({ type: "skipped", skippedCount: 1 }) + expect(skipLine([{ reason: "duplicate", duplicateOf: "project.md:tests" }])).toBe( + "reason=duplicate duplicateOf=project.md:tests", + ) + expect(guardReason("429 too many requests")).toBe("rate_limit_guard") + expect(guardReason("billing credits exhausted")).toBe("quota_guard") + }) + + test("redacts common secret token shapes", () => { + const github = "ghp_abcdefghijklmnopqrstuvwxyz1234567890" + const google = "AIzaabcdefghijklmnopqrstuvwxyz123456789" + const jwt = "eyJabcdefghijklmnopqrstuvwxyz.eyJmnopqrstuvwxyz12345.signaturevalue12345" + const bearer = "Bearer abcdefghijklmnopqrstuvwxyz123456" + const text = [ + `github=${github}`, + `google=${google}`, + `jwt=${jwt}`, + `Authorization: ${bearer}`, + "client_secret=super-secret-value", + "access_key=super-secret-value", + "refresh_token=abcdefghijklmnopqrstuvwxyz", + 'password="two words"', + "DATABASE_URL=postgres://alice:hunter2@host/db", + ].join("\n") + const redacted = MemoryRedact.text(text) + + expect(MemoryRedact.has(text)).toBe(true) + expect(redacted).not.toContain(github) + expect(redacted).not.toContain(google) + expect(redacted).not.toContain(jwt) + expect(redacted).not.toContain(bearer) + expect(redacted.match(/\[redacted\]/g)?.length).toBeGreaterThanOrEqual(9) + expect(redacted).not.toContain("two words") + expect(redacted).not.toContain("hunter2") + expect(MemoryRedact.value({ private_key: "abc", credential: "def", auth: "ghi" })).toEqual({ + private_key: "[redacted]", + credential: "[redacted]", + auth: "[redacted]", + }) + }) + + test("redacts URI userinfo credentials", () => { + const cases = [ + ["postgres://alice:hunter2@db.local/app", "postgres://[redacted]@db.local/app", "hunter2"], + ["postgresql://alice:p%40ss@db.local/app", "postgresql://[redacted]@db.local/app", "p%40ss"], + ["mongodb+srv://user:secret@cluster.mongodb.net/app", "mongodb+srv://[redacted]@cluster.mongodb.net/app", "secret"], + ["redis://:cache-secret@localhost:6379/0", "redis://[redacted]@localhost:6379/0", "cache-secret"], + ["https://user:pass@example.com/path", "https://[redacted]@example.com/path", "pass"], + ] as const + + for (const item of cases) { + const redacted = MemoryRedact.text(item[0]) + expect(MemoryRedact.has(item[0]), item[0]).toBe(true) + expect(redacted).toBe(item[1]) + expect(redacted).not.toContain(item[2]) + } + + // Unknown/non-allowlisted scheme: parsing, not an enumerated list, decides. + expect(MemoryRedact.text("clickhouse://svc:topsecret@host:9000/db")).toBe("clickhouse://[redacted]@host:9000/db") + + // Fail closed on any userinfo: a bare user@host (no colon) may still be a token. + expect(MemoryRedact.has("https://token@host/path")).toBe(true) + expect(MemoryRedact.text("https://token@host/path")).toBe("https://[redacted]@host/path") + + // Multiple URIs embedded in prose: each userinfo is redacted, surrounding text preserved. + expect(MemoryRedact.text("primary postgres://u:p@h1/a then cache redis://:s@h2/0 done")).toBe( + "primary postgres://[redacted]@h1/a then cache redis://[redacted]@h2/0 done", + ) + + // Malformed URL the parser rejects must still redact via the raw-segment fallback. + const malformed = "postgres://user:leaked@[bad" + expect(MemoryRedact.has(malformed)).toBe(true) + expect(MemoryRedact.text(malformed)).not.toContain("leaked") + + // @ in the path or query with no userinfo must not be touched (no false positives). + expect(MemoryRedact.has("https://example.com/a:b@c")).toBe(false) + expect(MemoryRedact.text("https://example.com/a:b@c")).toBe("https://example.com/a:b@c") + expect(MemoryRedact.text("https://example.com/p?to=a@b.com")).toBe("https://example.com/p?to=a@b.com") + + // has() and text() must agree: anything has() flags is actually scrubbed by text(). + for (const item of [...cases.map((c) => c[0]), malformed, "no secrets here", "https://example.com/a:b@c"]) { + if (MemoryRedact.has(item)) expect(MemoryRedact.text(item), item).not.toBe(item) + } + }) +}) diff --git a/packages/kilo-memory/test/command-cases.json b/packages/kilo-memory/test/command-cases.json new file mode 100644 index 0000000000..ff13d236c7 --- /dev/null +++ b/packages/kilo-memory/test/command-cases.json @@ -0,0 +1,119 @@ +[ + { + "name": "non memory prompt", + "input": "please remember this in normal chat", + "result": "none" + }, + { + "name": "empty memory command opens inspect", + "input": "/memory", + "result": "inspect" + }, + { + "name": "mem alias opens inspect", + "input": "/mem show", + "result": "inspect" + }, + { + "name": "project scope inspect", + "input": "/memory project show", + "result": "inspect" + }, + { + "name": "status opens inspect", + "input": "/memory status", + "result": "inspect" + }, + { + "name": "enable operation", + "input": "/memory enable", + "result": "operation", + "operation": "enable" + }, + { + "name": "disable operation", + "input": "/memory disable", + "result": "operation", + "operation": "disable" + }, + { + "name": "rebuild operation", + "input": "/memory rebuild", + "result": "operation", + "operation": "rebuild" + }, + { + "name": "purge requires confirmation", + "input": "/memory purge", + "result": "usage", + "reason": "Purge requires confirmation" + }, + { + "name": "purge operation with confirmation", + "input": "/memory purge confirm", + "result": "operation", + "operation": "purge", + "confirm": true + }, + { + "name": "auto status operation", + "input": "/memory auto status", + "result": "operation", + "operation": "auto", + "mode": "status" + }, + { + "name": "auto on operation", + "input": "/memory auto on", + "result": "operation", + "operation": "auto", + "mode": "on" + }, + { + "name": "auto off operation", + "input": "/memory auto off", + "result": "operation", + "operation": "auto", + "mode": "off" + }, + { + "name": "remember operation keeps text", + "input": "/memory remember use bun test from packages/opencode", + "result": "operation", + "operation": "remember", + "text": "use bun test from packages/opencode" + }, + { + "name": "correct operation keeps multiline text", + "input": "/memory correct old fact is wrong\nnew fact is stable", + "result": "operation", + "operation": "correct", + "text": "old fact is wrong\nnew fact is stable" + }, + { + "name": "forget operation keeps query", + "input": "/memory forget stale route", + "result": "operation", + "operation": "forget", + "query": "stale route" + }, + { + "name": "missing remember text", + "input": "/memory remember", + "result": "usage", + "reason": "Missing text" + }, + { + "name": "auto consolidate alias", + "input": "/memory auto-consolidate off", + "result": "operation", + "operation": "auto", + "mode": "off" + }, + { + "name": "unknown action", + "input": "/memory wat", + "result": "usage", + "reason": "Unknown memory action" + } +] diff --git a/packages/kilo-memory/test/commands.test.ts b/packages/kilo-memory/test/commands.test.ts new file mode 100644 index 0000000000..b83e1e8637 --- /dev/null +++ b/packages/kilo-memory/test/commands.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { parseMemoryCommand, type MemoryOperation, type ParsedMemoryCommand } from "../src/commands" + +type Case = { + name: string + input: string + result: "none" | "inspect" | "operation" | "usage" + operation?: MemoryOperation + mode?: "status" | "on" | "off" + confirm?: boolean + text?: string + query?: string + reason?: string +} + +const cases = (await Bun.file(new URL("./command-cases.json", import.meta.url)).json()) as Case[] + +function expected(item: Case): ParsedMemoryCommand | undefined { + if (item.result === "none") return + if (item.result === "inspect") return { kind: "inspect" } + if (item.result === "usage") return { kind: "usage", reason: item.reason ?? "" } + if (!item.operation) throw new Error(`Missing operation for fixture: ${item.name}`) + if (item.operation === "remember" || item.operation === "correct") { + if (!item.text) throw new Error(`Missing text for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, text: item.text } + } + if (item.operation === "forget") { + if (!item.query) throw new Error(`Missing query for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, query: item.query } + } + if (item.operation === "auto") { + if (!item.mode) throw new Error(`Missing mode for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, mode: item.mode } + } + if (item.operation === "purge") { + if (item.confirm !== true) throw new Error(`Missing confirmation for fixture: ${item.name}`) + return { kind: "operation", operation: item.operation, confirm: true } + } + return { kind: "operation", operation: item.operation } +} + +describe("memory commands", () => { + test("parse shared fixtures", () => { + for (const item of cases) { + const parsed = parseMemoryCommand(item.input) + if (item.result === "usage") { + expect(parsed?.kind, item.name).toBe("usage") + expect(parsed && "reason" in parsed ? parsed.reason : "", item.name).toContain(item.reason ?? "") + continue + } + expect(parsed, item.name).toEqual(expected(item)) + } + }) +}) diff --git a/packages/kilo-memory/test/core.test.ts b/packages/kilo-memory/test/core.test.ts new file mode 100644 index 0000000000..2fd458770f --- /dev/null +++ b/packages/kilo-memory/test/core.test.ts @@ -0,0 +1,1122 @@ +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readdir, rm, symlink, utimes, writeFile } from "fs/promises" +import os from "os" +import path from "path" +import { Memory } from "../src/memory" +import { MemoryDigest } from "../src/capture/digest" +import { MemoryFiles } from "../src/storage/store" +import { MemoryIndexer } from "../src/recall/indexer" +import { MemoryOperations } from "../src/capture/ops" +import { MemoryPaths } from "../src/storage/paths" +import { MemoryRecall } from "../src/recall/recall" +import { MemorySchema } from "../src/schema" + +async function tmp() { + const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-memory-")) + return { + dir, + root: path.join(dir, "memory"), + async done() { + await rm(dir, { recursive: true, force: true }) + }, + } +} + +async function use(fn: (input: Awaited>) => Promise) { + const t = await tmp() + try { + await fn(t) + } finally { + await t.done() + } +} + +describe("memory core package", () => { + test("enable scaffolds state, source files, gitignore, and index", async () => { + await use(async (t) => { + const enabled = await Memory.enable({ root: t.root }) + const shown = await Memory.show({ root: t.root }) + + expect(enabled.state.enabled).toBe(true) + expect(shown.sources.project).toContain("# Project Memory") + expect(shown.sources.environment).toContain("## Commands") + expect(shown.sources.corrections).toContain("## Corrections") + expect(await Bun.file(path.join(t.root, ".gitignore")).text()).toBe("*\n!.gitignore\n") + expect(shown.index).toBe("") + }) + }) + + test("enable preserves existing memory settings", async () => { + await use(async (t) => { + const enabled = await Memory.enable({ root: t.root }) + await MemoryFiles.writeState(t.root, { ...enabled.state, autoInject: false, autoConsolidate: false }) + + const next = await Memory.enable({ root: t.root }) + + expect(next.state.autoInject).toBe(true) + expect(next.state.autoConsolidate).toBe(false) + }) + }) + + test("state parser migrates legacy fields, ignores persisted limits, and rejects non-finite stats", () => { + const paused = MemorySchema.parse({ autoInject: false }) + const missing = MemorySchema.missing() + const state = MemorySchema.parse({ + limits: { maxSessionFiles: 50, maxRecentSessions: 10, maxSessionLineChars: 160, maxProjectIndexBytes: 1 }, + stats: { + lastInjectedAt: Number.NaN, + lastConsolidatedAt: Number.POSITIVE_INFINITY, + }, + }) + + expect(paused.autoInject).toBe(true) + expect(paused.autoConsolidate).toBe(true) + expect(missing.enabled).toBe(false) + expect(missing.autoConsolidate).toBe(true) + expect(state.limits.maxSessionFiles).toBe(20) + expect(state.limits.maxRecentSessions).toBe(5) + expect(state.limits.maxProjectIndexBytes).toBe(8192) + expect(state.limits.maxSessionLineChars).toBe(480) + expect(state.stats.lastInjectedAt).toBeNull() + expect(state.stats.lastConsolidatedAt).toBeNull() + }) + + test("state writes omit code-owned limits", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + + const raw = JSON.parse(await Bun.file(MemoryPaths.files(t.root).state).text()) + const state = await MemoryFiles.readState(t.root) + + expect(raw.limits).toBeUndefined() + expect(state.limits.maxSessionLineChars).toBe(480) + }) + }) + + test("decision and change audit records redact secret-like text in one log", async () => { + await use(async (t) => { + const secret = "sk-abcdefghijklmnopqrstuvwxyz123456" + await Memory.enable({ root: t.root }) + await MemoryFiles.decide(t.root, { + kind: "recall", + result: "skipped", + query: `check api_key=${secret}`, + skipped: [{ reason: "secret", text: `password=hunter2 ${secret}` }], + }) + await MemoryFiles.append(t.root, `provider error "api_key": "${secret}"`) + const shown = await Memory.show({ root: t.root }) + + expect(shown.decisions).toContain("[redacted]") + expect(shown.decisions).toContain('"kind":"log"') + expect(shown.decisions).not.toContain(secret) + expect(shown.decisions).not.toContain("hunter2") + expect(shown.changes).toContain("[redacted]") + expect(shown.decisions).toContain("provider error") + }) + }) + + test("stale locks are stolen before appending audit records", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + const lock = path.join(t.root, ".lock") + const old = new Date(Date.now() - 60_000) + await mkdir(lock) + await utimes(lock, old, old) + + await MemoryFiles.append(t.root, "after stale lock") + const shown = await Memory.show({ root: t.root }) + + expect(shown.changes).toContain("after stale lock") + }) + }) + + test("corrupted state recovers disabled with derived inventory", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await writeFile(MemoryPaths.files(t.root).state, "{", "utf8") + + const state = await MemoryFiles.readState(t.root) + const files = await readdir(t.root) + const shown = await Memory.show({ root: t.root }) + + expect(state.enabled).toBe(false) + expect(files.some((file) => file.startsWith("state.json.bad-"))).toBe(true) + expect(shown.inventory.items).toEqual({}) + expect(shown.changes).toContain("recover state.json") + }) + }) + + test("rejects symlinked memory roots", async () => { + await use(async (t) => { + const target = path.join(t.dir, "target") + const link = path.join(t.dir, "link") + await Memory.enable({ root: target }) + await symlink(target, link) + + await expect(Memory.enable({ root: link })).rejects.toThrow("memory path rejects symlink") + }) + }) + + test("uses unicode-safe project and memory identifiers", async () => { + await use(async (t) => { + const dir = path.join(t.dir, "proyecto_ñ_日本") + await mkdir(dir) + const id = MemoryPaths.identity({ ctx: { directory: dir, worktree: dir } }) + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + key: "設定_é", + text: "日本語の設定は packages/kilo-vscode に保存します。", + }) + const shown = await Memory.show({ root: t.root }) + + expect(id.display).toBe("proyecto_ñ_日本") + expect(id.folder).toContain("proyecto_ñ_日本-") + expect(shown.sources.project).toContain("- 設定_é :: 日本語の設定は packages/kilo-vscode に保存します。") + expect(shown.items).toContain("id=project.md:Facts:設定_é") + }) + }) + + test("serializes concurrent operations for one root", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + const ops: MemoryOperations.Op[] = [ + { action: "add", key: "one", text: "first durable fact" }, + { action: "add", key: "two", text: "second durable fact" }, + ] + + await Promise.all(ops.map((op) => Memory.apply({ root: t.root, ops: [op] }))) + const shown = await Memory.show({ root: t.root }) + + expect(shown.sources.project).toContain("- one :: first durable fact") + expect(shown.sources.project).toContain("- two :: second durable fact") + }) + }) + + test("apply upserts, removes, dedupes, and rejects secrets", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.apply({ + root: t.root, + ops: [ + { action: "add", key: "repo_tests", text: "Run CLI memory tests from packages/opencode." }, + { action: "add", key: "repo_tests_copy", text: "Run CLI memory tests from packages/opencode." }, + { action: "remove", query: "repo_tests" }, + { action: "add", key: "repo_tests", text: "Run CLI memory tests from packages/opencode." }, + ], + }) + await expect( + Memory.apply({ root: t.root, ops: [{ action: "add", key: "bad", text: "api_key=sk-abcdefghijklmnopqrstuvwxyz" }] }), + ).rejects.toThrow("secret-like content") + const shown = await Memory.show({ root: t.root }) + + expect(shown.sources.project.match(/repo_tests/g)?.length).toBe(1) + expect(shown.index).toContain("repo_tests") + }) + }) + + test("automatic apply does not remove memory", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.apply({ + root: t.root, + ops: [{ action: "add", key: "durable_fact", text: "This durable fact must survive auto capture." }], + }) + + const result = await Memory.apply({ + root: t.root, + trigger: "turn-close", + ops: [{ action: "remove", query: "durable_fact" }], + }) + const shown = await Memory.show({ root: t.root }) + + expect(result.result.operationCount).toBe(0) + expect(result.result.removed).toBe(0) + expect(shown.sources.project).toContain("durable_fact") + expect(shown.index).toContain("durable_fact") + }) + }) + + test("apply drops self-referential memory facts", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + + const result = await Memory.apply({ + root: t.root, + ops: [ + { + action: "add", + key: "memory_echo", + text: "Small model call-site behavior is already captured in project memory.", + }, + ], + }) + const shown = await Memory.show({ root: t.root }) + + expect(result.result.operationCount).toBe(0) + expect(result.result.added).toBe(0) + expect(result.result.skipped).toEqual([ + { reason: "self_referential", text: "Small model call-site behavior is already captured in project memory." }, + ]) + expect(shown.sources.project).not.toContain("memory_echo") + expect(shown.index).not.toContain("memory_echo") + expect(shown.decisions).toContain('"reason":"self_referential"') + }) + }) + + test("apply drops personal preference facts but keeps project facts", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + + const blocked = await Memory.apply({ + root: t.root, + ops: [ + { action: "add", key: "reply_style", text: "I prefer terse summaries." }, + { action: "add", key: "theme", text: "My preference is dark mode." }, + { action: "add", key: "editor", text: "User prefers Vim keybindings." }, + ], + }) + const saved = await Memory.apply({ + root: t.root, + ops: [{ action: "add", key: "repo_style", text: "Repo convention: commit messages are concise." }], + }) + const shown = await Memory.show({ root: t.root }) + + expect(blocked.result.operationCount).toBe(0) + expect(blocked.result.added).toBe(0) + expect(blocked.result.skipped).toEqual([ + { reason: "out_of_scope", text: "I prefer terse summaries." }, + { reason: "out_of_scope", text: "My preference is dark mode." }, + { reason: "out_of_scope", text: "User prefers Vim keybindings." }, + ]) + expect(saved.result.operationCount).toBe(1) + expect(saved.result.added).toBe(1) + expect(shown.sources.project).toContain("- repo_style :: Repo convention: commit messages are concise.") + expect(shown.sources.project).not.toContain("reply_style") + expect(shown.sources.project).not.toContain("dark mode") + expect(shown.sources.project).not.toContain("Vim keybindings") + expect(shown.index).toContain("repo_style") + expect(shown.index).not.toContain("reply_style") + expect(shown.decisions).toContain('"reason":"out_of_scope"') + expect(shown.decisions).not.toContain("reply_style") + expect(shown.decisions).not.toContain("theme") + expect(shown.decisions).not.toContain("editor") + expect(shown.decisions).not.toContain("I prefer terse summaries") + expect(shown.decisions).not.toContain("dark mode") + expect(shown.decisions).not.toContain("Vim keybindings") + }) + }) + + test("normalizes unsafe memory keys", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.apply({ + root: t.root, + ops: [{ action: "add", key: " Run root tests?! ", text: "Use package-level tests instead." }], + }) + const shown = await Memory.show({ root: t.root }) + + expect(shown.sources.project).toContain("- run_root_tests :: Use package-level tests instead.") + }) + }) + + test("sanitizes explicit sections before writing source files", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + const long = "x".repeat(120) + await Memory.apply({ + root: t.root, + ops: [ + { + action: "add", + key: "newline_section", + section: "Notes\n## Injected\n- fake :: value", + text: "Keep malformed section text inside one safe heading.", + }, + { + action: "add", + key: "hash_section", + section: "## Decisions", + text: "Leading hash markers become a plain heading.", + }, + { + action: "add", + key: "separator_section", + section: "Custom :: Entry", + text: "Entry separators are not preserved in section names.", + }, + { + action: "add", + key: "dash_section", + section: "- Bullet", + text: "Leading list markers become a plain heading.", + }, + { + action: "add", + key: "long_section", + section: long, + text: "Long section names are capped.", + }, + { + action: "add", + file: "environment.md", + key: "empty_section", + section: "###", + text: "Empty section names fall back to the file default.", + }, + ], + }) + + const shown = await Memory.show({ root: t.root }) + const inventory = await MemoryFiles.deriveInventory(t.root) + const recall = await MemoryRecall.search({ root: t.root, query: "malformed section safe heading" }) + + expect(shown.sources.project).toContain("## Notes ## Injected - fake value") + expect(shown.sources.project).toContain("## Decisions") + expect(shown.sources.project).toContain("## Custom Entry") + expect(shown.sources.project).toContain("## Bullet") + expect(shown.sources.project).toContain(`## ${"x".repeat(80)}`) + expect(shown.sources.project).not.toContain(`## ${"x".repeat(81)}`) + expect(shown.sources.project).not.toContain("\n## Injected\n") + expect(shown.sources.project).not.toContain("\n- fake :: value\n") + expect(shown.sources.environment).toContain("## Commands") + expect(Object.values(inventory.items).some((item) => item.key === "fake")).toBe(false) + expect(Object.values(inventory.items).filter((item) => item.key.endsWith("_section")).length).toBe(6) + expect(recall?.block).toContain("newline_section") + }) + }) + + test("targeted recall returns typed memory and audits matched files", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + file: "environment.md", + section: "Commands", + key: "cli_tests", + text: "Run CLI tests from packages/opencode with bun test.", + }) + + const result = await Memory.recall({ root: t.root, query: "what command runs cli tests?" }) + const shown = await Memory.show({ root: t.root }) + + expect(result.result?.block).toContain("cli_tests") + expect(result.files).toEqual(["environment.md"]) + expect(shown.decisions).toContain('"kind":"recall"') + expect(shown.decisions).toContain('"result":"recalled"') + }) + }) + + test("targeted recall uses source recency as a tiebreaker", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.apply({ + root: t.root, + ops: [ + { + action: "add", + file: "project.md", + section: "Facts", + key: "older_docs", + text: "Memory docs describe older recall ranking.", + }, + { + action: "add", + file: "project.md", + section: "Facts", + key: "newer_docs", + text: "Memory docs describe current recall ranking.", + }, + ], + }) + + const result = await MemoryRecall.search({ root: t.root, query: "memory docs recall ranking", limit: 1 }) + + expect(result?.block).toContain("newer_docs") + expect(result?.block).not.toContain("older_docs") + }) + }) + + test("derived inventory timestamps are source-mtime ranking hints", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await MemoryFiles.writeSource( + t.root, + "project.md", + [ + "# Project Memory", + "", + "## Facts", + "- first_hint :: First derived timestamp hint.", + "- second_hint :: Second derived timestamp hint.", + "", + ].join("\n"), + ) + const stamp = new Date(Date.UTC(2026, 0, 1, 0, 0, 0)) + await utimes(MemoryPaths.source(t.root, "project.md"), stamp, stamp) + + const inventory = await MemoryFiles.deriveInventory(t.root) + const shown = await Memory.show({ root: t.root }) + const first = inventory.items[ + MemoryFiles.inventoryKey({ file: "project.md", section: "Facts", key: "first_hint" }) + ]! + const second = inventory.items[ + MemoryFiles.inventoryKey({ file: "project.md", section: "Facts", key: "second_hint" }) + ]! + + expect(first.createdAt).toBe(first.updatedAt) + expect(second.createdAt).toBe(second.updatedAt) + expect(first.createdAt).toBeGreaterThan(second.createdAt) + expect(first.createdAt - second.createdAt).toBe(1) + expect(shown.items).toContain("timeSource=source_mtime_line_offset") + }) + }) + + test("targeted recall dedupes lower-value session hits when typed memory answers", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + key: "memory_tests", + text: "Run memory tests from packages/opencode with bun test ./test/kilocode/memory.", + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_memory_tests", + summary: "Discussed running memory tests from packages/opencode.", + time: Date.UTC(2026, 0, 1), + }) + + const result = await MemoryRecall.search({ root: t.root, query: "memory tests packages/opencode", limit: 5 }) + + expect(result?.block).toContain("memory_tests") + expect(result?.block).not.toContain("ses_memory_tests") + }) + }) + + test("digest recall honors requested sessions and ignores the active session", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_plan_memory", + topic: "plan memory", + summary: "Discussed token accounting for memory injection.", + time: Date.UTC(2026, 0, 1), + }) + + const active = await MemoryRecall.search({ + root: t.root, + query: "token accounting", + mode: "digest", + sessionID: "ses_plan_memory", + currentSessionID: "ses_plan_memory", + }) + const prior = await MemoryRecall.search({ + root: t.root, + query: "token accounting", + mode: "digest", + sessionID: "ses_plan_memory", + currentSessionID: "other", + }) + + expect(active).toBeUndefined() + expect(prior?.block).toContain("session=ses_plan_memory") + expect(prior?.block).toContain("token accounting") + }) + }) + + test("non-English stored text remains searchable", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.apply({ + root: t.root, + ops: [ + { action: "add", key: "pruebas_cli", text: "Ejecuta las pruebas CLI desde packages/opencode." }, + { action: "add", key: "memoire", text: "Les corrections de mémoire restent dans corrections.md." }, + { action: "add", key: "設定", text: "日本語の設定は packages/kilo-vscode に保存します。" }, + ], + }) + + const spanish = await MemoryRecall.search({ root: t.root, query: "pruebas CLI" }) + const french = await MemoryRecall.search({ root: t.root, query: "corrections mémoire" }) + const japanese = await MemoryRecall.search({ root: t.root, query: "日本語 設定" }) + + expect(spanish?.block).toContain("pruebas_cli") + expect(french?.block).toContain("memoire") + expect(japanese?.block).toContain("設定") + }) + }) + + test("index caps preserve priority records under tight budgets", async () => { + await use(async (t) => { + const enabled = await Memory.enable({ root: t.root }) + const state = { + ...enabled.state, + limits: { + ...enabled.state.limits, + maxProjectIndexBytes: 700, + }, + } + await MemoryFiles.writeSource( + t.root, + "project.md", + [ + "# Project Memory", + "", + "## Facts", + ...Array.from({ length: 20 }, (_, idx) => `- fact_${idx} :: ${"x".repeat(40)}`), + "", + "## Decisions", + "- architecture_choice :: Keep memory v0 file-based before adding databases.", + "", + "## Constraints", + "- project_only :: Memory v0 must stay project-only.", + "", + ].join("\n"), + ) + + const index = await MemoryIndexer.rebuild({ root: t.root, state }) + + expect(index.truncated).toBe(true) + expect(index.text).toContain("type=project_decision") + expect(index.text).toContain("architecture_choice") + expect(index.text).toContain("type=project_constraint") + expect(index.text).toContain("project_only") + }) + }) + + test("index keeps recent session digests and recognizes stale formats", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + for (let idx = 0; idx < 12; idx++) { + await Memory.recordSession({ + root: t.root, + sessionID: `ses_${idx}`, + summary: `summary_${String(idx).padStart(2, "0")} durable result`, + time: Date.UTC(2026, 0, 1, 0, idx), + }) + } + const shown = await Memory.show({ root: t.root }) + + expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1) + expect(shown.index.match(/type=session_digest/g)?.length).toBe(4) + expect(shown.index).toContain("session=ses_11") + expect(shown.index).toContain("session=ses_7") + expect(shown.index).not.toContain("session=ses_6") + expect(shown.index).toContain("type=latest_session_digest") + expect(shown.index).not.toContain("summary_01 durable result") + expect(MemoryIndexer.stale(shown.index)).toBe(false) + expect(MemoryIndexer.stale("record id=session.ses type=session_digest source=ses.md")).toBe(true) + }) + }) + + test("index renders the latest session digest fuller than older digests", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + const older = + `Older digest starts. ${"older continuity detail ".repeat(14)}` + + "OLDER_DETAIL_AFTER_RECENT_CAP should be hidden from recent digest rendering." + const latest = + `Latest digest starts. ${"latest continuity detail ".repeat(14)}` + + "LATEST_DETAIL_AFTER_RECENT_CAP should remain visible in the newest digest." + await Memory.recordSession({ + root: t.root, + sessionID: "ses_older_rich", + topic: "Older Rich Digest", + summary: older, + time: Date.UTC(2026, 0, 1, 0, 0), + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_latest_rich", + topic: "Latest Rich Digest", + summary: latest, + time: Date.UTC(2026, 0, 1, 0, 1), + }) + + const shown = await Memory.show({ root: t.root }) + const newest = shown.index.match(/type=latest_session_digest[^\n]*\ntext: ([^\n]+)/)?.[1] ?? "" + const recent = shown.index.match(/type=session_digest[^\n]*\ntext: ([^\n]+)/)?.[1] ?? "" + + expect(newest).toContain("session=ses_latest_rich") + expect(newest).toContain("LATEST_DETAIL_AFTER_RECENT_CAP") + expect(recent).toContain("session=ses_older_rich") + expect(recent).not.toContain("OLDER_DETAIL_AFTER_RECENT_CAP") + expect(newest.length).toBeGreaterThan(recent.length) + }) + }) + + test("index renders short session digests without padding", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_short", + topic: "Short Digest", + summary: "Short digest remains exact.", + time: Date.UTC(2026, 0, 1, 0, 0), + }) + + const shown = await Memory.show({ root: t.root }) + + expect(shown.index).toContain("session=ses_short") + expect(shown.index).toContain("Short digest remains exact.") + expect(shown.index).not.toContain("Short digest remains exact. ") + expect(shown.index).not.toContain("Short digest remains exact...") + }) + }) + + test("richer digest rendering stays inside the index budget and preserves priority records", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await MemoryFiles.writeSource( + t.root, + "corrections.md", + [ + "# Corrective Memory", + "", + "## Corrections", + "- reviewer_correction :: Always keep reviewer-requested memory corrections visible.", + "", + ].join("\n"), + ) + await MemoryFiles.writeSource( + t.root, + "project.md", + [ + "# Project Memory", + "", + "## Decisions", + "- architecture_choice :: Keep memory v0 file-based before adding databases.", + "", + "## Constraints", + "- project_only :: Memory v0 must stay project-only.", + "", + "## Facts", + ...Array.from({ length: 120 }, (_, idx) => `- fact_${idx} :: ${"low priority fact ".repeat(8)}${idx}`), + "", + ].join("\n"), + ) + for (let idx = 0; idx < 5; idx++) { + await Memory.recordSession({ + root: t.root, + sessionID: `ses_rich_${idx}`, + topic: `Rich Digest ${idx}`, + summary: `Rich digest ${idx}. ${"continuity detail ".repeat(30)}tail_${idx}.`, + time: Date.UTC(2026, 0, 1, 0, idx), + }) + } + + const index = await MemoryIndexer.rebuild({ root: t.root }) + + expect(index.bytes).toBeLessThanOrEqual(8192) + expect(index.text).toContain("reviewer_correction") + expect(index.text).toContain("architecture_choice") + expect(index.text).toContain("project_only") + expect(index.text).toContain("session=ses_rich_4") + }) + }) + + test("index preserves top durable facts before older session digest pressure", async () => { + await use(async (t) => { + const enabled = await Memory.enable({ root: t.root }) + const state = { + ...enabled.state, + limits: { + ...enabled.state.limits, + maxSessionFiles: 30, + maxRecentSessions: 30, + maxSessionLineChars: 1200, + }, + } + await Memory.apply({ + root: t.root, + ops: [ + { + action: "add", + key: "fixture_palette_uses_teal", + text: "The fixture palette uses teal and amber accents.", + }, + ], + }) + for (let idx = 0; idx < 30; idx++) { + const latest = idx === 29 + await MemoryFiles.writeSession(t.root, { + sessionID: `ses_budget_${String(idx).padStart(2, "0")}`, + topic: latest ? "Latest Fixture Continuity" : `Older Fixture Digest ${idx}`, + summary: latest + ? `Latest digest starts. ${"latest continuity detail ".repeat(32)}LATEST_FIXTURE_BUDGET_END_SENTINEL` + : `Older digest ${idx}. ${"older continuity detail ".repeat(60)}older_tail_${idx}.`, + max: state.limits.maxSessionLineChars, + time: Date.UTC(2026, 0, 1, 0, idx), + }) + } + + const index = await MemoryIndexer.rebuild({ root: t.root, state }) + const saved = await MemoryFiles.readIndex(t.root) + + expect(index.bytes).toBeLessThanOrEqual(8192) + expect(index.truncated).toBe(true) + expect(saved).toBe(index.text) + expect(saved).toContain("type=latest_session_digest") + expect(saved).toContain("session=ses_budget_29") + expect(saved).toContain("LATEST_FIXTURE_BUDGET_END_SENTINEL") + expect(saved).toContain("teal and amber") + expect(saved).toContain("mode=typed") + expect(saved).not.toContain("mode=catalog") + expect(saved).not.toContain("session=ses_budget_00") + }) + }) + + test("index preserves top environment commands before older session digest pressure", async () => { + await use(async (t) => { + const enabled = await Memory.enable({ root: t.root }) + const state = { + ...enabled.state, + limits: { + ...enabled.state.limits, + maxSessionFiles: 30, + maxRecentSessions: 30, + maxSessionLineChars: 1200, + }, + } + await MemoryFiles.writeSource( + t.root, + "environment.md", + [ + "# Environment Memory", + "", + "## Commands", + "- sdk_regen :: Run SDK regeneration from repo root with ./script/generate.ts.", + "- vscode_unit_tests :: Run VS Code memory unit tests from packages/kilo-vscode with bun test tests/unit/memory-command.test.ts.", + ...Array.from( + { length: 40 }, + (_, idx) => + `- env_tail_${idx} :: Environment command ${idx} stays reachable through recall catalog when the startup index is full.`, + ), + "", + ].join("\n"), + ) + for (let idx = 0; idx < 30; idx++) { + const latest = idx === 29 + await MemoryFiles.writeSession(t.root, { + sessionID: `ses_env_budget_${String(idx).padStart(2, "0")}`, + topic: latest ? "Latest Environment Continuity" : `Older Environment Digest ${idx}`, + summary: latest + ? `Latest digest starts. ${"latest environment continuity ".repeat(32)}LATEST_ENV_BUDGET_END_SENTINEL` + : `Older environment digest ${idx}. ${"older environment continuity ".repeat(60)}older_env_tail_${idx}.`, + max: state.limits.maxSessionLineChars, + time: Date.UTC(2026, 0, 1, 0, idx), + }) + } + + const index = await MemoryIndexer.rebuild({ root: t.root, state }) + const saved = await MemoryFiles.readIndex(t.root) + + expect(index.bytes).toBeLessThanOrEqual(8192) + expect(index.truncated).toBe(true) + expect(saved).toBe(index.text) + expect(saved).toContain("type=latest_session_digest") + expect(saved).toContain("session=ses_env_budget_29") + expect(saved).toContain("LATEST_ENV_BUDGET_END_SENTINEL") + expect(saved).toContain("Run SDK regeneration from repo root") + expect(saved).toContain("VS Code memory unit tests") + expect(saved).not.toContain("session=ses_env_budget_00") + }) + }) + + test("session digest files prune to the current default limit", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + for (let idx = 0; idx < 25; idx++) { + await Memory.recordSession({ + root: t.root, + sessionID: `ses_${idx}`, + summary: `summary_${String(idx).padStart(2, "0")} durable result`, + time: Date.UTC(2026, 0, 1, 0, idx), + }) + } + + const files = (await readdir(MemoryPaths.files(t.root).sessions)).filter((file) => file.endsWith(".md")) + + expect(files).toHaveLength(20) + expect(files.some((file) => file.includes("_ses_24_id_"))).toBe(true) + expect(files.some((file) => file.includes("_ses_4_id_"))).toBe(false) + }) + }) + + test("index dedupes bulk recent session digests by normalized topic", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_shared_old", + topic: "Shared Topic", + summary: "Older shared-topic work.", + time: Date.UTC(2026, 0, 1, 0, 0), + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_distinct", + topic: "Distinct Topic", + summary: "Distinct recent work.", + time: Date.UTC(2026, 0, 1, 0, 1), + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_shared_new", + topic: " shared topic ", + summary: "Newer shared-topic work.", + time: Date.UTC(2026, 0, 1, 0, 2), + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_latest", + topic: "Latest Topic", + summary: "Newest continuity pointer.", + time: Date.UTC(2026, 0, 1, 0, 3), + }) + + const shown = await Memory.show({ root: t.root }) + + expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1) + expect(shown.index).toContain("session=ses_latest") + expect(shown.index).toContain("session=ses_shared_new") + expect(shown.index).toContain("session=ses_distinct") + expect(shown.index).not.toContain("session=ses_shared_old") + }) + }) + + test("index suppresses bulk session digests covered by typed memory", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.apply({ + root: t.root, + ops: [ + { + action: "add", + file: "project.md", + section: "Facts", + key: "small_model_call_sites", + text: "Small model call sites are selected in the OpenCode adapter during memory capture.", + }, + ], + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_small_model", + topic: "Small model call sites", + summary: "Small model call sites are selected in the OpenCode adapter during memory capture.", + time: Date.UTC(2026, 0, 1, 0, 0), + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_latest", + topic: "Digest prompt polish", + summary: "Objective: polish digest prompts. Next: verify latest session remains injected.", + time: Date.UTC(2026, 0, 1, 0, 1), + }) + + const shown = await Memory.show({ root: t.root }) + + expect(shown.index).toContain("small_model_call_sites") + expect(shown.index).toContain("session=ses_latest") + expect(shown.index).not.toContain("session=ses_small_model") + }) + }) + + test("index keeps the true newest continuation-style session", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_substantive", + summary: "Objective: finish memory continuity. Next: verify startup index ordering.", + time: Date.UTC(2026, 0, 1, 0, 0), + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_empty_newest", + summary: "Continue requested; no substantive work was done.", + time: Date.UTC(2026, 0, 1, 0, 1), + }) + + const shown = await Memory.show({ root: t.root }) + + expect(MemoryDigest.empty("Continue requested; no substantive work was done.")).toBe(false) + expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1) + expect(shown.index).toContain("type=latest_session_digest") + expect(shown.index).toContain("session=ses_empty_newest") + expect(shown.index.indexOf("session=ses_empty_newest")).toBeLessThan(shown.index.indexOf("session=ses_substantive")) + }) + }) + + test("index keeps older non-empty continuation-style digests", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_old_empty", + summary: "Continue requested; no substantive work was done.", + time: Date.UTC(2026, 0, 1, 0, 0), + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_latest", + summary: "Objective: implement continuity. Next: keep latest session visible.", + time: Date.UTC(2026, 0, 1, 0, 1), + }) + + const shown = await Memory.show({ root: t.root }) + + expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1) + expect(shown.index).toContain("type=latest_session_digest") + expect(shown.index).toContain("session=ses_latest") + expect(shown.index).not.toMatch(/type=session_digest[^\n]*\ntext: session=ses_latest/) + expect(shown.index).toContain("session=ses_old_empty") + }) + }) + + test("index surfaces non-English newest sessions as latest digests", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_older", + summary: "Objective: older memory work. Next: continue old validation.", + time: Date.UTC(2026, 0, 1, 0, 0), + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_es", + topic: "continuidad", + summary: "Objetivo: revisar continuidad de memoria. Siguiente: verificar la etiqueta mas reciente.", + time: Date.UTC(2026, 0, 1, 0, 1), + }) + + const shown = await Memory.show({ root: t.root }) + + expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1) + expect(shown.index).toContain("session=ses_es") + expect(shown.index).toContain("continuidad") + }) + }) + + test("session digest classifier is structural only", () => { + expect( + MemoryDigest.empty({ + file: "2026.md", + id: "ses", + time: "2026-01-01T00:00:00.000Z", + topic: "Memory Updates", + summary: + "Recent state: Latest commit: e83a920622 feat(cli): add project memory v0. Working tree has untracked .plans and memory docs. Last saved focus: memory v0 behavior.", + }), + ).toBe(false) + expect(MemoryDigest.empty("")).toBe(true) + expect(MemoryDigest.empty({ topic: "", summary: " " })).toBe(true) + expect(MemoryDigest.empty({ topic: "continuidad", summary: " " })).toBe(false) + expect(MemoryDigest.empty("User: continue")).toBe(false) + expect(MemoryDigest.empty("Continue requested; no substantive work was done.")).toBe(false) + expect(MemoryDigest.empty("Objective: implement recall. Next: wire prompt injection.")).toBe(false) + }) + + test("purge rejects directories not owned by memory", async () => { + await use(async (t) => { + await mkdir(t.root) + const file = path.join(t.root, "keep.txt") + await writeFile(file, "keep") + + await expect(Memory.purge({ root: t.root })).rejects.toThrow("unowned memory root") + expect(await Bun.file(file).text()).toBe("keep") + }) + }) + + test("qualified forget removes only the selected record", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.apply({ + root: t.root, + ops: [ + { action: "add", file: "project.md", section: "Facts", key: "tests", text: "Project tests" }, + { action: "add", file: "environment.md", section: "Commands", key: "tests", text: "bun test" }, + ], + }) + + const result = await Memory.forget({ root: t.root, query: "project.md:Facts:tests" }) + const shown = await Memory.show({ root: t.root }) + + expect(result.result.removed).toBe(1) + expect(shown.sources.project).not.toContain("Project tests") + expect(shown.sources.environment).toContain("bun test") + }) + }) + + test("same key can exist in separate sections", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.apply({ + root: t.root, + ops: [ + { action: "add", file: "project.md", section: "Facts", key: "runtime", text: "Bun runs the project" }, + { action: "add", file: "project.md", section: "Decisions", key: "runtime", text: "Keep Bun for v1" }, + ], + }) + + const shown = await Memory.show({ root: t.root }) + expect(shown.sources.project).toContain("Bun runs the project") + expect(shown.sources.project).toContain("Keep Bun for v1") + }) + }) + + test("failed session replacement preserves the prior digest", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.recordSession({ root: t.root, sessionID: "same", summary: "valid", time: 1 }) + + await expect( + Memory.recordSession({ root: t.root, sessionID: "same", summary: "replacement", time: Number.NaN }), + ).rejects.toThrow("finite") + const prior = await MemoryFiles.readSession(t.root, { sessionID: "same", max: 480 }) + expect(prior?.summary).toBe("valid") + }) + }) + + test("deleted source and session files expire the index", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ root: t.root, key: "tests", text: "Run package tests" }) + await rm(MemoryPaths.files(t.root).project) + expect(await MemoryFiles.indexExpired(t.root)).toBe(true) + + await Memory.enable({ root: t.root }) + await Memory.recordSession({ root: t.root, sessionID: "session", summary: "Session summary", time: 1 }) + const files = await readdir(MemoryPaths.files(t.root).sessions) + await rm(path.join(MemoryPaths.files(t.root).sessions, files[0]!)) + await utimes(MemoryPaths.files(t.root).sessions, 0, 0) + expect(await MemoryFiles.indexExpired(t.root)).toBe(true) + }) + }) + + test("empty recall queries do not return unrelated memory", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ root: t.root, key: "tests", text: "Run package tests" }) + + expect(await MemoryRecall.search({ root: t.root, query: "!!!", force: true })).toBeUndefined() + }) + }) + + test("unsupported state versions recover disabled", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await writeFile(MemoryPaths.files(t.root).state, JSON.stringify({ version: 2, enabled: true })) + + const state = await MemoryFiles.readState(t.root) + const files = await readdir(t.root) + expect(state.enabled).toBe(false) + expect(files.some((file) => file.startsWith("state.json.bad-"))).toBe(true) + }) + }) +}) diff --git a/packages/kilo-memory/test/markdown.test.ts b/packages/kilo-memory/test/markdown.test.ts new file mode 100644 index 0000000000..d4efc07017 --- /dev/null +++ b/packages/kilo-memory/test/markdown.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test" +import { MemoryMarkdown } from "../src/storage/markdown" + +describe("memory markdown serialization", () => { + test("parses sections and key :: text items, skipping non-items and empties", () => { + const doc = [ + "# Project Memory", + "", + "## Facts", + MemoryMarkdown.line("runtime", "Bun 1.x"), + "- malformed line without separator", + "- :: missing key", + "", + "## Decisions", + MemoryMarkdown.line("db", "Postgres for primary store"), + ].join("\n") + + expect(MemoryMarkdown.parse(doc)).toEqual([ + { section: "Facts", key: "runtime", text: "Bun 1.x" }, + { section: "Decisions", key: "db", text: "Postgres for primary store" }, + ]) + }) + + test("items before the first heading take the default section", () => { + expect(MemoryMarkdown.parse(MemoryMarkdown.line("k", "v"))).toEqual([{ section: "Facts", key: "k", text: "v" }]) + }) + + test("upsert replaces a same-key line in the section and creates absent sections", () => { + const base = `## Facts\n${MemoryMarkdown.line("runtime", "Bun 1.0")}\n` + + const replaced = MemoryMarkdown.upsert({ text: base, section: "Facts", line: MemoryMarkdown.line("runtime", "Bun 1.3") }) + expect(replaced.changed).toBe(true) + expect(MemoryMarkdown.parse(replaced.text)).toEqual([{ section: "Facts", key: "runtime", text: "Bun 1.3" }]) + + const added = MemoryMarkdown.upsert({ text: base, section: "Decisions", line: MemoryMarkdown.line("db", "Postgres") }) + expect(added.changed).toBe(true) + expect(MemoryMarkdown.parse(added.text)).toContainEqual({ section: "Decisions", key: "db", text: "Postgres" }) + + const noop = MemoryMarkdown.upsert({ text: base, section: "Facts", line: MemoryMarkdown.line("runtime", "Bun 1.0") }) + expect(noop.changed).toBe(false) + }) + + test("upsert scopes replacement to the target section and leaves same-key lines elsewhere", () => { + const doc = [ + "## Facts", + MemoryMarkdown.line("a", "facts-a"), + "## Decisions", + MemoryMarkdown.line("a", "decisions-a"), + ].join("\n") + + const result = MemoryMarkdown.upsert({ text: doc, section: "Facts", line: MemoryMarkdown.line("a", "facts-a2") }) + expect(MemoryMarkdown.parse(result.text)).toEqual([ + { section: "Facts", key: "a", text: "facts-a2" }, + { section: "Decisions", key: "a", text: "decisions-a" }, + ]) + }) + + test("upsert does not clobber a different key that shares a prefix", () => { + const doc = ["## Facts", MemoryMarkdown.line("a", "one"), MemoryMarkdown.line("ab", "two")].join("\n") + + const result = MemoryMarkdown.upsert({ text: doc, section: "Facts", line: MemoryMarkdown.line("a", "one-updated") }) + expect(MemoryMarkdown.parse(result.text)).toEqual([ + { section: "Facts", key: "a", text: "one-updated" }, + { section: "Facts", key: "ab", text: "two" }, + ]) + }) + + test("remove with no match leaves the document and count untouched", () => { + const doc = ["## Facts", MemoryMarkdown.line("a", "one")].join("\n") + const result = MemoryMarkdown.remove({ text: doc, match: () => false }) + expect(result.count).toBe(0) + expect(result.text).toBe(doc) + }) + + test("remove drops only matching items, preserving headings and counting removals", () => { + const doc = [ + "## Facts", + MemoryMarkdown.line("a", "one"), + MemoryMarkdown.line("b", "two"), + "## Decisions", + MemoryMarkdown.line("a", "three"), + ].join("\n") + + const result = MemoryMarkdown.remove({ text: doc, match: (entry) => entry.section === "Facts" && entry.key === "a" }) + expect(result.count).toBe(1) + expect(MemoryMarkdown.parse(result.text)).toEqual([ + { section: "Facts", key: "b", text: "two" }, + { section: "Decisions", key: "a", text: "three" }, + ]) + }) +}) diff --git a/packages/kilo-memory/test/memory.test.ts b/packages/kilo-memory/test/memory.test.ts new file mode 100644 index 0000000000..276c2e723c --- /dev/null +++ b/packages/kilo-memory/test/memory.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, readdir, rm, symlink, writeFile } from "fs/promises" +import os from "os" +import path from "path" +import { Memory } from "../src/memory" +import { MemoryPaths } from "../src/storage/paths" +import { MemoryRecall } from "../src/recall/recall" + +async function tmp() { + const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-memory-")) + return { + dir, + root: path.join(dir, "memory"), + async done() { + await rm(dir, { recursive: true, force: true }) + }, + } +} + +describe("memory facade", () => { + test("enables, writes, indexes, and recalls project memory", async () => { + const t = await tmp() + try { + const enabled = await Memory.enable({ root: t.root }) + const status = await Memory.status({ root: t.root }) + + expect(enabled.state.enabled).toBe(true) + expect(status.exists.state).toBe(true) + expect(status.exists.index).toBe(true) + + await Memory.remember({ + root: t.root, + file: "environment.md", + section: "Commands", + text: "Run CLI tests from packages/opencode.", + }) + + const ctx = await Memory.context({ root: t.root, record: false }) + const recall = await Memory.recall({ root: t.root, query: "CLI tests packages opencode" }) + + expect(ctx.blocks[0]?.text).toContain("packages/opencode") + expect(recall.result?.block).toContain("packages/opencode") + } finally { + await t.done() + } + }) + + test("keeps Unicode keys and non-English text searchable", async () => { + const t = await tmp() + try { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + key: "設定", + text: "日本語の設定は packages/kilo-vscode に保存します。", + }) + + const shown = await Memory.show({ root: t.root }) + const recall = await Memory.recall({ root: t.root, query: "日本語 設定 kilo-vscode" }) + + expect(shown.sources.project).toContain("設定") + expect(recall.result?.block).toContain("日本語") + } finally { + await t.done() + } + }) + + test("does not expose natural-language recall intent predicates", () => { + const recall = MemoryRecall as unknown as Record + + expect("shouldRecall" in recall).toBe(false) + expect("direct" in recall).toBe(false) + expect("explicit" in recall).toBe(false) + expect("continuation" in recall).toBe(false) + }) + + test("rejects current-session digest reads", async () => { + const t = await tmp() + try { + await Memory.enable({ root: t.root }) + await Memory.recordSession({ + root: t.root, + sessionID: "same-session", + summary: "Captured deployment checklist for the release.", + time: Date.UTC(2026, 0, 1, 0, 0), + }) + + const current = await MemoryRecall.search({ + root: t.root, + query: "deployment checklist", + mode: "digest", + sessionID: "same-session", + currentSessionID: "same-session", + }) + const prior = await MemoryRecall.search({ + root: t.root, + query: "deployment checklist", + mode: "digest", + sessionID: "same-session", + currentSessionID: "other-session", + }) + + expect(current).toBeUndefined() + expect(prior?.block).toContain("deployment checklist") + } finally { + await t.done() + } + }) + + test("recovers corrupted state into a safe disabled state", async () => { + const t = await tmp() + try { + await Memory.enable({ root: t.root }) + await writeFile(MemoryPaths.files(t.root).state, "{", "utf8") + + const status = await Memory.status({ root: t.root }) + const files = await readdir(t.root) + + expect(status.state.enabled).toBe(false) + expect(files.some((file) => file.startsWith("state.json.bad-"))).toBe(true) + } finally { + await t.done() + } + }) + + test("rejects symlinked memory roots", async () => { + const t = await tmp() + try { + const target = path.join(t.dir, "target") + const link = path.join(t.dir, "link") + await Memory.enable({ root: target }) + await symlink(target, link) + + await expect(Memory.enable({ root: link })).rejects.toThrow("memory path rejects symlink") + } finally { + await t.done() + } + }) +}) diff --git a/packages/kilo-memory/test/recall-fixtures.test.ts b/packages/kilo-memory/test/recall-fixtures.test.ts new file mode 100644 index 0000000000..38f20e7c1f --- /dev/null +++ b/packages/kilo-memory/test/recall-fixtures.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "fs/promises" +import os from "os" +import path from "path" +import { Memory } from "../src/memory" +import { MemoryRecall } from "../src/recall/recall" + +async function tmp() { + const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-memory-recall-")) + return { + dir, + root: path.join(dir, "memory"), + async done() { + await rm(dir, { recursive: true, force: true }) + }, + } +} + +async function use(fn: (input: Awaited>) => Promise) { + const t = await tmp() + try { + await fn(t) + } finally { + await t.done() + } +} + +describe("memory recall lexical fixtures", () => { + test("expected hit: exact key match returns typed memory", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + key: "cli_tests", + text: "Run CLI tests from packages/opencode with bun test.", + }) + + const result = await MemoryRecall.search({ root: t.root, query: "cli_tests" }) + + expect(result?.hits[0]?.type).toBe("typed") + expect(result?.block).toContain("cli_tests") + }) + }) + + test("expected hit: phrasing mismatch works when anchor terms overlap", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + key: "cli_tests", + text: "Run CLI tests from packages/opencode with bun test.", + }) + + const result = await MemoryRecall.search({ root: t.root, query: "which packages/opencode command checks CLI?" }) + + expect(result?.block).toContain("cli_tests") + }) + }) + + test("expected miss: synonym-only query is not semantic recall", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + key: "cli_tests", + text: "Run CLI tests from packages/opencode with bun test.", + }) + + const result = await MemoryRecall.search({ root: t.root, query: "execute verification suite" }) + + expect(result).toBeUndefined() + }) + }) + + test("expected hit: path and tool query finds environment memory", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + file: "environment.md", + section: "Commands", + key: "opencode_memory_tests", + text: "Run bun test ./test/kilocode/memory from packages/opencode.", + }) + + const result = await MemoryRecall.search({ root: t.root, query: "bun packages/opencode memory" }) + + expect(result?.hits[0]?.source).toBe("environment.md") + expect(result?.block).toContain("opencode_memory_tests") + }) + }) + + test("expected hit: non-English stored text remains lexical", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + key: "設定", + text: "日本語の設定は packages/kilo-vscode に保存します。", + }) + + const result = await MemoryRecall.search({ root: t.root, query: "日本語 設定" }) + + expect(result?.block).toContain("設定") + expect(result?.block).toContain("日本語") + }) + }) + + test("expected digest fallback: requested continuation digest is returned without typed memory", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_continue", + topic: "memory continuity", + summary: "Objective: finish memory v0. Next: verify recall fixture behavior.", + time: Date.UTC(2026, 0, 1, 0, 0), + }) + + const result = await MemoryRecall.search({ + root: t.root, + query: "where were we", + mode: "digest", + sessionID: "ses_continue", + }) + + expect(result?.hits).toHaveLength(1) + expect(result?.hits[0]?.type).toBe("digest") + expect(result?.block).toContain("session=ses_continue") + }) + }) + + test("expected hit: typed memory beats weaker conflicting digest", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + key: "release_notes_summary", + text: "Release notes need Spanish summaries before reviewer handoff.", + }) + await Memory.recordSession({ + root: t.root, + sessionID: "ses_old_release_notes", + topic: "release notes", + summary: "Older release notes discussion said English summaries were enough.", + time: Date.UTC(2026, 0, 1, 0, 0), + }) + + const result = await MemoryRecall.search({ root: t.root, query: "release notes Spanish summary", limit: 5 }) + + expect(result?.hits[0]?.type).toBe("typed") + expect(result?.hits[0]?.text).toContain("release_notes_summary") + }) + }) + + test("expected miss: oversized unrelated query does not leak memory", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await Memory.remember({ + root: t.root, + key: "cli_tests", + text: "Run CLI tests from packages/opencode with bun test.", + }) + + const result = await MemoryRecall.search({ root: t.root, query: "zzzz ".repeat(2000), limit: 20 }) + + expect(result).toBeUndefined() + }) + }) +}) diff --git a/packages/kilo-memory/test/text.test.ts b/packages/kilo-memory/test/text.test.ts new file mode 100644 index 0000000000..f5638f6ca8 --- /dev/null +++ b/packages/kilo-memory/test/text.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { MemoryText } from "../src/text" + +describe("memory text helpers", () => { + test("brief collapses internal whitespace and trims edges", () => { + expect(MemoryText.brief(" a\t b\n c ", 80)).toBe("a b c") + }) + + test("brief returns input unchanged when within the limit", () => { + expect(MemoryText.brief("short", 80)).toBe("short") + }) + + test("brief clips overflow and appends an ellipsis", () => { + expect(MemoryText.brief("abcdefghij", 8)).toBe("abcde...") + }) + + test("brief degrades safely at tiny limits", () => { + expect(MemoryText.brief("abcdef", 2)).toBe("...") + }) +}) diff --git a/packages/kilo-memory/tsconfig.json b/packages/kilo-memory/tsconfig.json new file mode 100644 index 0000000000..5dc34dc555 --- /dev/null +++ b/packages/kilo-memory/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./src", + "skipLibCheck": true, + "noUncheckedIndexedAccess": false, + "types": ["node"], + "lib": ["ESNext", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} From 341f51f003f4ac92479473e4f6307e8c72925f94 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Wed, 1 Jul 2026 10:23:30 +0000 Subject: [PATCH 12/17] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 9bf86577db..a355d08148 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-WYLCq59M4c6MsNjIeBmgV66v6LBvsTVGA1xV2oY935s=", - "aarch64-linux": "sha256-kpJ1SFCmBrhDwuJNJNoDU5NK6mDvEG4Yd41w9YJnsOs=", - "aarch64-darwin": "sha256-junSolEGOe+xUmPY4SAJGj+XKuytxvD0IWQWtLiaIEs=", - "x86_64-darwin": "sha256-p+3Jtb5CZexwx0UlhVeqeMr25R+jkjahFp9YTZYK0rE=" + "x86_64-linux": "sha256-ppRrY3vgXyUy8G82gU73J7vLoZLZuYL6Zz6mlFAaEeg=", + "aarch64-linux": "sha256-d89BQvxfI3Ow0OYBj0lKTqm9a2vWKG5OaecAA1KG8/A=", + "aarch64-darwin": "sha256-nFURZ/IIuJ5edeKNI+9FwnIiXtbI9wQjSSzxiuGUQIg=", + "x86_64-darwin": "sha256-gSmyn9yuTBKyV4rhyDlSn6rQeYxGzHVA+c7SRu2Ioe4=" } } From 9638de532ba8a32bbc3f13d1113de1aed6776bee Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Wed, 1 Jul 2026 12:29:15 +0200 Subject: [PATCH 13/17] feat(vscode): show balance in account controls (#11803) --- .changeset/warm-balance-chip.md | 5 + .../profile/logged-in-chromium-linux.png | 4 +- .../logged-in-personal-chromium-linux.png | 4 +- packages/kilo-docs/source-links.md | 3 + .../src/components/chat/TaskHeader.tsx | 2 + .../src/components/profile/ProfileView.tsx | 185 ++++++++++++++---- .../src/components/shared/AccountSwitcher.tsx | 2 + .../src/components/shared/BalanceChip.tsx | 13 ++ .../kilo-vscode/webview-ui/src/i18n/ar.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 4 + .../src/stories/profile.stories.tsx | 9 +- .../webview-ui/src/styles/welcome.css | 21 +- .../webview-ui/src/types/messages/profile.ts | 8 + 31 files changed, 294 insertions(+), 42 deletions(-) create mode 100644 .changeset/warm-balance-chip.md create mode 100644 packages/kilo-vscode/webview-ui/src/components/shared/BalanceChip.tsx diff --git a/.changeset/warm-balance-chip.md b/.changeset/warm-balance-chip.md new file mode 100644 index 0000000000..deee0b85c7 --- /dev/null +++ b/.changeset/warm-balance-chip.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show account balance in the VS Code header account switcher and surface Kilo Pass details on the profile page. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png index d52ac47572..b1b38a2c68 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6485e2033c7eb88acf5347c392a1a789e471975ea086211c478d7229cc3c3dfd -size 14652 +oid sha256:2f353022aa26937291c9d2d3a34bba222e87695e609cc5de4f98cbe57e90a7c0 +size 19828 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png index 1fad2819c5..bd4801d5d6 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3cf6345efab3cefbc8d1ec2ae3b81103deb017bc930ede91fccb6efef59a65c -size 10738 +oid sha256:f5fe1c7f2960984736863aac8dc720eff1cf60cc5b1991e966740b395565b8ad +size 16069 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index dc778f715c..b64ddd110b 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -25,6 +25,7 @@ - + - - @@ -143,6 +144,8 @@ - +- + - diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx index 96ac022f93..4dce468a25 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskHeader.tsx @@ -22,6 +22,7 @@ import { ContextProgress } from "./ContextProgress" import { TaskUsage } from "./TaskUsage" import { hasModelUsage, tokenSummary } from "../../context/model-usage" import { SessionRenameEditor } from "../shared/SessionRenameEditor" +import { BalanceChip } from "../shared/BalanceChip" import { target as todoTarget } from "../../context/todo-revert" import type { Part, TodoItem, ExtensionMessage } from "../../types/messages" @@ -184,6 +185,7 @@ export const TaskHeader: Component = (props) => {
+ {(c) => ( diff --git a/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx b/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx index df4658ca43..05c1cb1bcc 100644 --- a/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx @@ -6,6 +6,7 @@ import { Select } from "@kilocode/kilo-ui/select" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" +import { localeToBcp47, type Locale } from "../../context/language-utils" import DeviceAuthCard from "./DeviceAuthCard" import type { ProfileData, DeviceAuthState } from "../../types/messages" @@ -21,6 +22,15 @@ const formatBalance = (amount: number): string => { return `$${amount.toFixed(2)}` } +const short = (amount: number): string => `$${Math.round(amount)}` + +const resetLabel = (iso: string | null | undefined, loc: Locale): string | undefined => { + if (!iso) return undefined + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return undefined + return new Intl.DateTimeFormat(localeToBcp47(loc), { month: "short", day: "numeric", timeZone: "UTC" }).format(date) +} + const PERSONAL = "personal" interface OrgOption { @@ -93,6 +103,14 @@ const ProfileView: Component = (props) => { vscode.postMessage({ type: "openExternal", url: "https://app.kilo.ai/profile" }) } + const handleTopUp = () => { + vscode.postMessage({ type: "openExternal", url: "https://app.kilo.ai/credits" }) + } + + const handleGetPass = () => { + vscode.postMessage({ type: "openExternal", url: "https://kilo.ai/pricing/kilo-pass" }) + } + const handleCancelLogin = () => { vscode.postMessage({ type: "cancelLogin" }) } @@ -207,41 +225,137 @@ const ProfileView: Component = (props) => { {/* Balance */} {(balance) => ( - -
-

- {language.t("profile.balance.title")} -

-

- {formatBalance(balance().balance)} -

+ +
+
+

+ {language.t("profile.balance.title")} +

+

+ {formatBalance(balance().balance)} +

+
+ + +
- - - + + {/* Kilo Pass is part of personal credits, so only show it on the personal account */} + + {(pass) => ( +
+
+ Kilo Pass + + {short(pass().currentPeriodUsageUsd)} / {short(pass().currentPeriodBaseCreditsUsd)} + +
+
+
+
+ 0}> +
+ {language.t("profile.pass.bonus")} + +{formatBalance(pass().currentPeriodBonusCreditsUsd)} +
+
+ + {(date) => ( +
+ {language.t("profile.pass.renews")} + {date()} +
+ )} +
+
+ )} + + + {/* No active Kilo Pass on the personal account — nudge to subscribe */} + +
+ +
+
)} @@ -251,6 +365,9 @@ const ProfileView: Component = (props) => { +