Merge branch 'main' into main

This commit is contained in:
Marian Alexandru Alecu
2026-07-01 17:34:10 +03:00
committed by GitHub
133 changed files with 9328 additions and 103 deletions
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
---
"@kilocode/kilo-memory": minor
---
Add the `@kilocode/kilo-memory` foundation package: project memory storage, indexing, recall,
consolidation, and the Effect runtime layer (service, capture orchestration, and runtime ports).
Host wiring (CLI tools, prompts, HTTP API) lands in follow-up CLI/extension PRs.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve the model picker preview panel expand/collapse state across reloads and restarts.
+6
View File
@@ -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.
+5
View File
@@ -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.
+16
View File
@@ -265,6 +265,20 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/kilo-memory": {
"name": "@kilocode/kilo-memory",
"version": "7.3.45",
"dependencies": {
"effect": "catalog:",
"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 +1330,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"],
+4 -4
View File
@@ -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-0rBUGp44ghR+BGT9uuZL6IBsrs5YFs+pR9kiOoXM1oM=",
"aarch64-linux": "sha256-YQ/ktd3iQZWQJZtuI93uKrc1s+LaXcTQqPAxIOIomXo=",
"aarch64-darwin": "sha256-Tzte5/NazfWgwMs9fqPsO9mRfnD0x5WJxYe8PtTcEZs=",
"x86_64-darwin": "sha256-qX9gFaRJRDlPIVuPGYfGeDguo8YOwUQekDVDO04SSPY="
}
}
@@ -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<string, { sid: string; cost: number }>()
readonly #totals = new Map<string, number>()
readonly #floors = new Map<string, number>()
readonly #alerted = new Map<string, Set<number>>() // sid -> limit values shown this run
readonly #acked = new Map<string, Set<number>>() // 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<string, Set<number>>, sid: string, limit: number): void {
const seen = map.get(sid)
if (seen) seen.add(limit)
else map.set(sid, new Set([limit]))
}
}
@@ -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)
})
})
@@ -29,6 +29,8 @@ You can edit MCP settings from the Kilo Code settings UI:
From here you can add, edit, enable/disable, and delete MCP servers. Changes are written directly to the appropriate config file.
If the UI cannot add a server, edit a Kilo config file directly and add the server under the top-level `mcp` key. For project-specific servers, edit `./kilo.json` or `./kilo.jsonc` if your project already has one; otherwise use `./.kilo/kilo.json` or `./.kilo/kilo.jsonc` for a cleaner setup. For servers you want in every workspace, use `~/.config/kilo/kilo.json` or `~/.config/kilo/kilo.jsonc`.
### Config Format
MCP servers are configured under the `mcp` key in `kilo.jsonc`:
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6485e2033c7eb88acf5347c392a1a789e471975ea086211c478d7229cc3c3dfd
size 14652
oid sha256:2f353022aa26937291c9d2d3a34bba222e87695e609cc5de4f98cbe57e90a7c0
size 19828
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f3cf6345efab3cefbc8d1ec2ae3b81103deb017bc930ede91fccb6efef59a65c
size 10738
oid sha256:f5fe1c7f2960984736863aac8dc720eff1cf60cc5b1991e966740b395565b8ad
size 16069
+3
View File
@@ -25,6 +25,7 @@
<!-- packages/opencode/src/config/config.ts -->
- <https://app.kilo.ai/credits>
<!-- packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts -->
<!-- packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx -->
- <https://app.kilo.ai/profile>
<!-- packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx -->
- <https://app.kilo.ai/tui.json>
@@ -143,6 +144,8 @@
<!-- packages/opencode/src/kilocode/components/dialog-claw-setup.tsx -->
- <https://kilo.ai/pricing>
<!-- packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx -->
- <https://kilo.ai/pricing/kilo-pass>
<!-- packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx -->
- <https://kilo.ai/support>
<!-- packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx -->
<!-- packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx -->
+2
View File
@@ -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": "اللقطة بطيئة",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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":
+2
View File
@@ -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": "スナップショットが遅い",
+2
View File
@@ -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": "스냅샷이 느립니다",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "Снимок выполняется медленно",
+2
View File
@@ -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": "สแน็ปช็อตช้า",
+2
View File
@@ -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ş",
+2
View File
@@ -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": "Знімок виконується повільно",
+2
View File
@@ -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": "快照速度较慢",
+2
View File
@@ -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": "快照速度較慢",
+61
View File
@@ -0,0 +1,61 @@
{
"$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",
"./effect": "./src/effect/index.ts",
"./effect/capture": "./src/effect/capture.ts",
"./effect/config": "./src/effect/config.ts",
"./effect/errors": "./src/effect/errors.ts",
"./effect/events": "./src/effect/events.ts",
"./effect/instance": "./src/effect/instance.ts",
"./effect/log": "./src/effect/log.ts",
"./effect/paths": "./src/effect/paths.ts",
"./effect/ports": "./src/effect/ports.ts",
"./effect/service": "./src/effect/service.ts",
"./effect/timers": "./src/effect/timers.ts",
"./effect/turn": "./src/effect/turn.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": {
"effect": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
},
"peerDependencies": {}
}
@@ -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"
+29
View File
@@ -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<CaptureDiff, "file" | "additions" | "deletions">[]) {
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<CaptureDiff, "file" | "status" | "additions" | "deletions">[]) {
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")
}
@@ -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 }
}
@@ -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)
}
}
+342
View File
@@ -0,0 +1,342 @@
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<string>
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<string>()
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<MemorySchema.Source, string>
type Plan = {
docs: Docs
touched: Set<MemorySchema.Source>
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<Docs> {
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 }] })
}
}
+222
View File
@@ -0,0 +1,222 @@
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
file?: MemoryOperations.Add["file"]
section?: 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(input: {
text: string | undefined
items: CaptureSourceItem[]
file?: MemoryOperations.Add["file"]
section?: string
}) {
const text = input.text
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 = input.items
.filter((item) => !input.file || !item.file || item.file === input.file)
.filter((item) => !input.section || !item.section || item.section === input.section)
.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 downgraded to
* "unsupported" so they read as advisory rather than confirmed against a real entry. */
export function verifySkips(input: { skipped: CaptureSkip[]; items: CaptureSourceItem[] }) {
const skipped: CaptureSkip[] = []
for (const item of input.skipped) {
if (item.reason !== "duplicate" || !item.text) {
skipped.push(item)
continue
}
// A model-claimed duplicate is only confirmable when it names the exact scope (file + section).
// Any missing scope field would let fuzzy text matching confirm against unrelated memory, so
// downgrade partially-scoped or unscoped claims to advisory instead.
const scoped = item.file !== undefined && item.section !== undefined
const source = scoped
? duplicate({ text: item.text, items: input.items, file: item.file, section: item.section })
: undefined
if (source) {
skipped.push({ ...item, duplicateOf: item.duplicateOf ?? source })
continue
}
skipped.push({ reason: "unsupported", text: item.text })
}
return { skipped }
}
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({
text: `${item.key} ${item.text}`,
items: input.items,
file: item.file,
section: item.section,
})
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),
}
}
+147
View File
@@ -0,0 +1,147 @@
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(),
// Optional scope of the entry this skip claims to duplicate, so duplicate verification
// matches within the same file/section instead of across all stored memory.
file: z.enum(["project.md", "environment.md", "corrections.md"]).optional(),
section: z.string().max(80).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<typeof typedSchema>["skipped"][number]
export type CaptureDigest = z.infer<typeof digestSchema>
function clean(input: string) {
return input
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim()
}
export function parseJson<T>(schema: z.ZodType<T>, 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<typeof typedSchema>): 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
}
+59
View File
@@ -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,
}
}
@@ -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)]))
}
}
@@ -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 }
}
}
+134
View File
@@ -0,0 +1,134 @@
export const MEMORY_USAGE =
"/memory [project] enable|status|show|inspect|auto status|auto on|auto off|remember <text>|correct <text>|forget <query>|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<MemoryOperation, "remember" | "correct" | "forget" | "purge" | "auto">
}
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}.`)
}
+509
View File
@@ -0,0 +1,509 @@
import { Cause, Effect } from "effect"
import {
auditOps,
cap,
capturePlan,
digestPrompt,
digestSchema,
duplicateOps,
errorReason,
evidence,
fallbackDigest,
guardReason,
hasDurableDiff,
mergeOps,
notice,
parseDigest,
parseJson,
parseOps,
skipped,
summarize,
summarizeDiffs,
typedPrompt,
typedSchema,
usage,
verifySkips,
type CaptureReason,
type CaptureSkip,
type CaptureSourceItem,
} from "../capture/capture"
import { MemoryDigest } from "../capture/digest"
import type { MemoryOperations } from "../capture/ops"
import { MemoryRedact } from "../capture/redact"
import { MemorySchema } from "../schema"
import { MemoryShared } from "../recall/shared"
import { MemoryEvents } from "./events"
import { MemoryLog } from "./log"
import type { MemoryPorts } from "./ports"
import { MemoryService } from "./service"
import { MemoryTimers } from "./timers"
const MESSAGE_WINDOW = 24
/** Heuristic: an assistant answer that mostly restates injected instructions/source files is not
* durable project memory and should not be consolidated. */
function provenance(input: { assistant: string }) {
const assistant = input.assistant.trim()
const markers = [/\bsystem\s*\/\s*developer\b/gi, /\bagents\.md\b/gi, /\bclaude\.md\b/gi].reduce(
(sum, item) => sum + (assistant.match(item)?.length ?? 0),
0,
)
const list = assistant.split("\n").filter((line) => /^\s*[-*]\s+\S/.test(line)).length
return markers >= 4 || (markers >= 3 && list >= 2)
}
function typedExisting(memory: MemoryService.Interface, root: string) {
return memory.sources({ root }).pipe(
Effect.map((sources) => {
const blocks = MemorySchema.Sources.map((file) => {
const body = sources[file].trim()
if (!body) return ""
return [`### source ${file}`, body].join("\n")
})
return blocks.filter(Boolean).join("\n")
}),
)
}
function itemSource(file: MemorySchema.Source, text: string): CaptureSourceItem[] {
return MemoryShared.source({ file, text })
}
function typedItems(memory: MemoryService.Interface, root: string) {
return memory
.sources({ root })
.pipe(Effect.map((sources) => MemorySchema.Sources.flatMap((file) => itemSource(file, sources[file]))))
}
export namespace MemoryCapture {
export const turn = Effect.fn("MemoryCapture.turn")(function* (input: {
root: string
sessionID: string
session: MemoryPorts.SessionPort
model: MemoryPorts.ModelPort
reason?: CaptureReason
bypassInterval?: boolean
memoryModel?: string
}) {
const root = input.root
// Acquire first (sync, cannot fail) so the matching `release` in the finalizer below always pairs
// with this acquire regardless of where the turn exits.
const signal = MemoryTimers.signal(root)
const memory = yield* MemoryService.Service
yield* memory.prepare({ root })
const state = yield* memory.state({ root })
const reported = new Set<string>()
const fail = (reason: string) =>
Effect.promise(async () => {
const safe = MemoryRedact.text(reason)
if (reported.has(safe)) return
reported.add(safe)
await MemoryEvents.publish({
event: "error",
payload: MemoryEvents.status({
root,
state,
phase: "error",
reason: safe,
sessionID: input.sessionID,
}),
})
})
const skip = (reason: string, opts?: { idleFlush?: boolean }) =>
Effect.gen(function* () {
if (state.enabled) yield* memory.decide({ root, decision: skipped({ sessionID: input.sessionID, reason }) })
yield* Effect.promise(() =>
MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root,
state,
phase: "skipped",
reason,
sessionID: input.sessionID,
}),
}),
)
return { root, skipped: true as const, reason, idleFlush: opts?.idleFlush === true }
})
if (!state.enabled || !state.capture.turnClose) return yield* skip("disabled")
const now = Date.now()
const view = yield* input.session.readTurn({ sessionID: input.sessionID, window: MESSAGE_WINDOW })
if (!view) return yield* skip("no_turn")
if (input.bypassInterval && state.stats.lastConsolidatedMessageID === view.lastAssistantID)
return yield* skip("no_new_content")
const user = view.user
const assistant = view.assistant
const recent = view.recent
const summary = summarize({ user, assistant, max: state.limits.maxSessionLineChars })
const diffs = view.diffs
const changed = summarizeDiffs(diffs)
const durable = hasDurableDiff(diffs)
const completed = !input.reason || input.reason === "completed"
// Echo = short lookup answered from memory with no file changes. Long recall-assisted answers
// (research, investigations) carry new content and must still be digested.
const echo = !durable && assistant.length < 1200 && view.recalledMemory
const sourced = provenance({ assistant })
const session = completed && !echo && Boolean(summary)
const prior = session
? yield* memory.session({ root, sessionID: input.sessionID, max: state.limits.maxSessionLineChars })
: undefined
const priorTime = prior?.time ? Date.parse(prior.time) : 0
const plan = capturePlan({
reason: input.reason,
summary,
echo,
durable,
priorTime,
now,
minIntervalMs: state.capture.minIntervalMs,
lastConsolidatedAt: state.stats.lastConsolidatedAt,
bypassInterval: input.bypassInterval,
autoConsolidate: state.autoConsolidate,
})
const digestDue = plan.digestDue
const typedCall = plan.typedCall
if (plan.skipReason) return yield* skip(plan.skipReason, plan.idleFlush ? { idleFlush: true } : undefined)
yield* Effect.promise(() =>
MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({ root, state, phase: "checking", sessionID: input.sessionID }),
}),
)
const model =
digestDue || typedCall
? yield* Effect.gen(function* () {
const resolution = yield* input.model.resolve({
configured: input.memoryModel,
session: view.sessionModel,
})
if (resolution.fallback) {
yield* memory.append({
root,
text: `memory_model_config reason=${MemoryShared.brief(
MemoryRedact.text(resolution.fallback.reason),
160,
)} fallback=1`,
})
}
return resolution.handle
})
: undefined
const fallback = MemoryRedact.text(
fallbackDigest({ prior: prior?.summary, summary, max: state.limits.maxSessionLineChars }),
)
const safe = MemoryDigest.empty(fallback) ? "" : fallback
const digestEffect = digestDue
? Effect.gen(function* () {
const body = cap(
evidence([
{ title: "latest_user", body: user },
{ title: "latest_assistant", body: assistant || "(no assistant text)" },
{ title: "diff_summary", body: changed || "(none)" },
{ title: "previous_digest", body: prior?.summary },
{ title: "max_characters", body: String(state.limits.maxSessionLineChars) },
]),
state.limits.maxConsolidationInputBytes,
)
const result = yield* Effect.tryPromise({
try: () =>
input.model.run({
handle: model!,
system: digestPrompt,
prompt: body,
timeoutMs: state.capture.timeoutMs,
signal,
}),
catch: (error) => error,
}).pipe(
Effect.map((result) => ({ ok: true as const, result })),
Effect.catch((err: unknown) =>
Effect.gen(function* () {
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
const raw = errorReason(err)
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
yield* fail(reason)
yield* memory.append({ root, text: `digest error=${MemoryShared.brief(reason, 160)} fallback=1` })
return { ok: false as const, reason }
}),
),
)
if (!result.ok) {
return {
topic: "",
summary: safe,
tokens: 0,
reason: result.reason,
}
}
const parsed = yield* Effect.try({
try: () => parseJson(digestSchema, result.result.text),
catch: (error) => error,
}).pipe(
Effect.catch((err: unknown) =>
Effect.gen(function* () {
const reason = MemoryRedact.text(errorReason(err))
yield* fail("digest parse_error")
yield* memory.append({
root,
text: `digest parse_error=${MemoryShared.brief(reason, 160)} fallback=1`,
})
return undefined
}),
),
)
if (!parsed) {
return { topic: "", summary: safe, tokens: usage(result.result.usage), reason: "parse_error" }
}
const parsedDigest = parseDigest(parsed, fallback, state.limits.maxSessionLineChars)
return {
topic: MemoryRedact.text(parsedDigest.topic),
summary: MemoryRedact.text(parsedDigest.summary),
tokens: usage(result.result.usage),
reason: undefined as string | undefined,
}
})
: Effect.succeed({
topic: "",
summary: "",
tokens: 0,
reason: undefined as string | undefined,
})
const typedEffect = typedCall
? Effect.gen(function* () {
if (sourced) {
return {
ops: [] as MemoryOperations.Op[],
tokens: 0,
fallback: false,
reason: undefined as string | undefined,
skipped: [
{
reason: "out_of_scope" as const,
text: "Instruction/source provenance answers are not durable project memory.",
},
] satisfies CaptureSkip[],
fallbackOperationCount: 0,
}
}
const existing = yield* typedExisting(memory, root)
const items = yield* typedItems(memory, root)
const sessions = yield* memory.recent({
root,
limit: state.limits.maxSessionFiles,
max: state.limits.maxSessionLineChars,
})
const body = cap(
evidence([
{ title: "close_reason", body: input.reason ?? "completed" },
{ title: "latest_user", body: user },
{ title: "latest_assistant", body: assistant || "(no assistant text)" },
{ title: "diff_summary", body: changed || "(none)" },
{ title: "existing_memory", body: existing },
{ title: "recent_session_context", body: recent },
{
title: "recent_memory_digests",
body: sessions
.map((item) => `${item.file} session=${item.id} ${item.time} :: ${item.summary}`)
.join("\n"),
},
]),
state.limits.maxConsolidationInputBytes,
)
const result = yield* Effect.tryPromise({
try: () =>
input.model.run({
handle: model!,
system: typedPrompt,
prompt: body,
timeoutMs: state.capture.timeoutMs,
signal,
}),
catch: (error) => error,
}).pipe(
Effect.map((result) => ({ ok: true as const, result })),
Effect.catch((err: unknown) =>
Effect.gen(function* () {
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
const raw = errorReason(err)
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
yield* fail(reason)
yield* memory.append({ root, text: `consolidate error=${MemoryShared.brief(reason, 160)}` })
return { ok: false as const, reason }
}),
),
)
if (!result.ok) {
return {
ops: [] as MemoryOperations.Op[],
tokens: 0,
fallback: true,
reason: result.reason,
skipped: [] as CaptureSkip[],
fallbackOperationCount: 0,
}
}
const parsed = yield* Effect.try({
try: () => parseJson(typedSchema, result.result.text),
catch: (error) => error,
}).pipe(
Effect.catch((err: unknown) =>
Effect.gen(function* () {
const reason = MemoryRedact.text(errorReason(err))
yield* fail("consolidate parse_error")
yield* memory.append({ root, text: `consolidate parse_error=${MemoryShared.brief(reason, 160)}` })
return undefined
}),
),
)
if (!parsed) {
return {
ops: [] as MemoryOperations.Op[],
tokens: usage(result.result.usage),
fallback: true,
reason: "parse_error",
skipped: [] as CaptureSkip[],
fallbackOperationCount: 0,
}
}
const verified = verifySkips({ skipped: parsed.skipped, items })
const deduped = duplicateOps({ ops: parseOps(parsed), skipped: verified.skipped, items })
return {
ops: deduped.ops,
tokens: usage(result.result.usage),
fallback: false,
reason: undefined as string | undefined,
skipped: deduped.skipped,
fallbackOperationCount: 0,
}
})
: Effect.succeed({
ops: [] as MemoryOperations.Op[],
tokens: 0,
fallback: false,
reason: undefined as string | undefined,
skipped: [] as CaptureSkip[],
fallbackOperationCount: 0,
})
// Digest and typed consolidation are independent model calls; run them concurrently.
const [digest, generated] = yield* Effect.all([digestEffect, typedEffect], { concurrency: 2 })
if (signal.aborted) return yield* skip("cancelled")
if (digest.summary) {
yield* memory.recordSession({
root,
sessionID: input.sessionID,
topic: digest.topic,
summary: digest.summary,
time: now,
tokens: digest.tokens,
})
}
if (digestDue) {
yield* memory.decide({
root,
decision: {
kind: "digest",
trigger: "turn-close",
sessionID: input.sessionID,
result: digest.reason ? "fallback" : digest.summary ? "saved" : "skipped",
llm: true,
parsed: Boolean(digest.summary && !digest.reason),
fallback: Boolean(digest.reason),
reason: digest.reason,
tokens: digest.tokens,
operationCount: digest.summary ? 1 : 0,
skippedCount: digest.summary ? 0 : 1,
summary: digest.reason
? `session digest used fallback after ${digest.reason}`
: digest.summary
? "session digest saved"
: "session digest skipped",
},
})
}
const ops = mergeOps(generated.ops)
.filter((item) => item.action !== "remove")
.slice(0, state.capture.maxOpsPerRun)
const project =
ops.length > 0 ? yield* memory.apply({ root, ops, trigger: "turn-close", tokens: generated.tokens }) : undefined
const count = project?.operationCount ?? 0
if (typedCall) {
yield* memory.decide({
root,
decision: {
kind: "typed",
trigger: "turn-close",
sessionID: input.sessionID,
result: generated.fallback ? "fallback" : count > 0 ? "saved" : "skipped",
llm: true,
parsed: !generated.fallback,
fallback: generated.fallback,
reason: generated.reason,
tokens: generated.tokens,
operationCount: count,
skippedCount: generated.skipped.length,
fallbackOperationCount: generated.fallbackOperationCount,
skipped: generated.skipped,
operations: auditOps(ops),
files: [...new Set(ops.flatMap((item) => (item.action === "add" && item.file ? [item.file] : [])))],
summary: generated.fallback
? `typed consolidation skipped after ${generated.reason ?? "model failure"}`
: count > 0
? `typed consolidation saved ${count} ops`
: `typed consolidation skipped ${generated.skipped.length} candidates`,
},
})
}
const tokens = digest.tokens + generated.tokens
if (!digest.summary && !typedCall && count === 0) return yield* skip("no_ops")
if ((digestDue || typedCall || count > 0) && (!typedCall || !generated.fallback)) {
yield* memory.commit({
root,
now,
messageID: view.lastAssistantID,
tokens,
count,
digest: Boolean(digest.summary),
skipped: generated.skipped,
})
}
const updated = yield* memory.state({ root })
const index = project?.index ?? (yield* memory.index({ root }))
const detail = typedCall
? notice({
count,
ops,
skipped: generated.skipped,
tokens: generated.tokens,
})
: undefined
yield* Effect.promise(() =>
MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root,
state: updated,
index,
phase: "idle",
sessionID: input.sessionID,
consolidation: { trigger: "turn-close", operationCount: count, cost: 0, tokens },
...(detail ? { detail } : {}),
}),
}),
)
return { root, skipped: false as const, operationCount: count, tokens }
},
// Release the per-root abort controller acquired at the top once the turn settles (any exit path).
(effect, input) => effect.pipe(Effect.ensuring(Effect.sync(() => MemoryTimers.release(input.root)))))
export function report(cause: Cause.Cause<unknown>) {
// Brief message only: API errors carry response headers/bodies that would flood the host log.
const err = Cause.squash(cause)
MemoryLog.warn("memory capture failed", {
err: (err instanceof Error ? err.message : String(err)).slice(0, 200),
})
}
}
+13
View File
@@ -0,0 +1,13 @@
export namespace MemoryConfig {
export type Model = { providerID: string; modelID: string }
/** Parse a `providerID/modelID` memory-model override. Returns undefined when blank or malformed
* so callers can fall back to the session model. */
export function parse(value: string | undefined): Model | undefined {
if (!value) return undefined
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) return undefined
return { providerID, modelID }
}
}
+168
View File
@@ -0,0 +1,168 @@
import { Schema } from "effect"
import { MemoryRedact } from "../capture/redact"
// Typed API error shapes so the SDK / OpenAPI reflect the real contract.
export class MemoryApiClientError extends Schema.ErrorClass<MemoryApiClientError>("MemoryApiClientError")(
{
name: Schema.Literal("MemoryApiClientError"),
data: Schema.Struct({ code: Schema.String, message: Schema.String }),
},
{ httpApiStatus: 400 },
) {}
export class MemoryApiServerError extends Schema.ErrorClass<MemoryApiServerError>("MemoryApiServerError")(
{
name: Schema.Literal("MemoryApiServerError"),
data: Schema.Struct({ code: Schema.String, message: Schema.String }),
},
{ httpApiStatus: 503 },
) {}
export class MemoryDisabledError extends Schema.TaggedErrorClass<MemoryDisabledError>()("MemoryDisabledError", {
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {
override get message() {
return this.reason
}
}
export class MemoryInvalidInputError extends Schema.TaggedErrorClass<MemoryInvalidInputError>()(
"MemoryInvalidInputError",
{
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
},
) {
override get message() {
return this.reason
}
}
export class MemoryStorageError extends Schema.TaggedErrorClass<MemoryStorageError>()("MemoryStorageError", {
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {
override get message() {
return this.reason
}
}
export class MemoryRootError extends Schema.TaggedErrorClass<MemoryRootError>()("MemoryRootError", {
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {
override get message() {
return this.reason
}
}
export class MemoryCorruptStateError extends Schema.TaggedErrorClass<MemoryCorruptStateError>()(
"MemoryCorruptStateError",
{
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
},
) {
override get message() {
return this.reason
}
}
export class MemoryUnknownError extends Schema.TaggedErrorClass<MemoryUnknownError>()("MemoryUnknownError", {
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {
override get message() {
return this.reason
}
}
export type MemoryError =
| MemoryDisabledError
| MemoryInvalidInputError
| MemoryStorageError
| MemoryRootError
| MemoryCorruptStateError
| MemoryUnknownError
function reason(err: unknown) {
const raw = err instanceof Error ? err.message : String(err)
return MemoryRedact.text(raw.replaceAll(/\s+/g, " ").slice(0, 240)) || "unknown memory error"
}
function tag(err: unknown): MemoryError | undefined {
if (!err || typeof err !== "object" || !("_tag" in err)) return
const value = String(err._tag)
if (!value.startsWith("Memory")) return
return err as MemoryError
}
export namespace MemoryError {
export function from(err: unknown): MemoryError {
const known = tag(err)
if (known) return known
const text = reason(err)
const lower = text.toLowerCase()
if (lower.includes("memory is disabled")) return new MemoryDisabledError({ reason: text, cause: err })
if (
/\b(symlink|memory path|memory root|parent is not a directory|path is not a file|path is not a directory)\b/.test(
lower,
)
) {
return new MemoryRootError({ reason: text, cause: err })
}
if (/\b(state\.json|corrupt|recover|parse error|unexpected token)\b/.test(lower)) {
return new MemoryCorruptStateError({ reason: text, cause: err })
}
if (/\b(lock|eacces|eperm|enoent|eio|emfile|enospc)\b/.test(lower)) {
return new MemoryStorageError({ reason: text, cause: err })
}
if (/\b(invalid|schema|zod|section|key|text|source|secret-like|malformed|reject)\b/.test(lower)) {
return new MemoryInvalidInputError({ reason: text, cause: err })
}
return new MemoryUnknownError({ reason: text, cause: err })
}
export function message(err: unknown) {
return from(err).message
}
// Map typed taxonomy to HTTP error contract; redaction already applied via .message.
export function toHttp(err: MemoryError): MemoryApiClientError | MemoryApiServerError {
const msg = err.message
switch (err._tag) {
case "MemoryDisabledError":
return new MemoryApiClientError({
name: "MemoryApiClientError",
data: { code: "memory_disabled", message: msg },
})
case "MemoryInvalidInputError":
return new MemoryApiClientError({
name: "MemoryApiClientError",
data: { code: "memory_invalid_input", message: msg },
})
case "MemoryRootError":
return new MemoryApiClientError({
name: "MemoryApiClientError",
data: { code: "memory_root_error", message: msg },
})
case "MemoryStorageError":
return new MemoryApiServerError({
name: "MemoryApiServerError",
data: { code: "memory_storage_error", message: msg },
})
case "MemoryCorruptStateError":
return new MemoryApiServerError({
name: "MemoryApiServerError",
data: { code: "memory_corrupt_state", message: msg },
})
default:
return new MemoryApiServerError({ name: "MemoryApiServerError", data: { code: "memory_error", message: msg } })
}
}
export function toToolOutput(err: unknown, action: string) {
return `Kilo memory ${action} failed: ${message(err)}`
}
}
+123
View File
@@ -0,0 +1,123 @@
import { Schema } from "effect"
import type { MemorySchema } from "../schema"
import { MemoryLog } from "./log"
export namespace MemoryEvents {
const Metric = Schema.Struct({
bytes: Schema.Number,
estimatedTokens: Schema.Number,
truncated: Schema.Boolean,
updatedAt: Schema.optional(Schema.Number),
})
const Phase = Schema.Literals(["idle", "checking", "injecting", "updating", "skipped", "error"])
const Trigger = Schema.Literals(["explicit", "turn-close", "rebuild"])
const Consolidation = Schema.Struct({
trigger: Trigger,
operationCount: Schema.Number,
cost: Schema.Number,
tokens: Schema.Number,
})
const Detail = Schema.Struct({
type: Schema.Literals(["saved", "skipped", "recalled"]),
message: Schema.String,
reason: Schema.optional(Schema.String),
duplicateOf: Schema.optional(Schema.String),
tokens: Schema.optional(Schema.Number),
operationCount: Schema.optional(Schema.Number),
skippedCount: Schema.optional(Schema.Number),
sources: Schema.optional(Schema.Array(Schema.String)),
files: Schema.optional(Schema.Array(Schema.String)),
})
export const Payload = Schema.Struct({
directory: Schema.String,
sessionID: Schema.optional(Schema.String),
enabled: Schema.Boolean,
state: Phase,
reason: Schema.optional(Schema.String),
project: Metric,
consolidation: Schema.optional(Consolidation),
detail: Schema.optional(Detail),
})
export type Phase = Schema.Schema.Type<typeof Phase>
export type Trigger = Schema.Schema.Type<typeof Trigger>
export type Status = Schema.Schema.Type<typeof Payload>
export type Index = { bytes: number; tokens: number; truncated: boolean }
export type Inspect = {
root: string
state: MemorySchema.State
sources: {
project: string
environment: string
corrections: string
}
index: string
changes: string
}
function metric(index?: Index, updatedAt?: number | null) {
return {
bytes: index?.bytes ?? 0,
estimatedTokens: index?.tokens ?? 0,
truncated: index?.truncated ?? false,
...(updatedAt ? { updatedAt } : {}),
}
}
function latest(...items: (number | null)[]) {
const values = items.filter((item): item is number => typeof item === "number" && Number.isFinite(item))
return values.length ? Math.max(...values) : undefined
}
export function status(input: {
root: string
state: MemorySchema.State
index?: Index
phase?: Phase
reason?: string
sessionID?: string
consolidation?: Status["consolidation"]
detail?: Status["detail"]
}): Status {
const updated = latest(input.state.stats.lastInjectedAt, input.state.stats.lastConsolidatedAt)
const current = metric(input.index, updated)
return {
directory: input.root,
...(input.sessionID ? { sessionID: input.sessionID } : {}),
enabled: input.state.enabled,
state: input.phase ?? "idle",
...(input.reason ? { reason: input.reason } : {}),
project: current,
...(input.consolidation ? { consolidation: input.consolidation } : {}),
...(input.detail ? { detail: input.detail } : {}),
}
}
export type Event = "status" | "updated" | "error"
export type Sink = (input: { event?: Event; payload: Status }) => Promise<void> | void
// Best-effort: opencode wires this to its Bus at bootstrap; defaults to a no-op so the package
// never reaches into the host event system on its own.
let sink: Sink = () => {}
export function setSink(next: Sink) {
sink = next
}
export async function publish(input: { event?: Event; payload: Status }) {
// Event wiring is best-effort: a failing host sink must not fail a memory op that already
// persisted, so swallow and log instead of propagating to callers.
try {
await sink(input)
} catch (err) {
MemoryLog.warn("memory event publish failed", {
err: (err instanceof Error ? err.message : String(err)).slice(0, 200),
})
}
}
}
+340
View File
@@ -0,0 +1,340 @@
import { Memory } from "../memory"
import type { MemoryOperations } from "../capture/ops"
import { MemorySchema } from "../schema"
import { MemoryFiles } from "../storage/store"
import { MemoryToken } from "../recall/token"
import { MemoryEvents } from "./events"
import { MemoryPaths } from "./paths"
import { MemoryTimers } from "./timers"
import { MemoryDisabledError } from "./errors"
/** Context-bound Kilo adapter over the root-bound package primitives. Prefer ctx inputs at runtime edges. */
export namespace KiloMemory {
export type Input =
| {
root: string
sessionID?: string
record?: boolean
}
| {
ctx: MemoryPaths.Ctx
sessionID?: string
record?: boolean
}
export type Block = Memory.Block
function root(input: Input) {
return "root" in input ? input.root : MemoryPaths.root(input)
}
async function noop(dir: string): Promise<MemoryOperations.Result> {
const text = await MemoryFiles.readIndex(dir)
const index = {
text,
bytes: Buffer.byteLength(text),
tokens: MemoryToken.estimate(text),
truncated: false,
}
return { operationCount: 0, added: 0, removed: 0, skipped: [], index }
}
async function requireEnabled(dir: string) {
const state = await MemoryFiles.readState(dir)
if (state.enabled) return state
throw new MemoryDisabledError({ reason: "project memory is disabled" })
}
export async function prepare(input: Input) {
return root(input)
}
export async function status(input: Input) {
return Memory.status({ root: await prepare(input) })
}
export async function enable(input: Input) {
const dir = await prepare(input)
const id = "ctx" in input ? MemoryPaths.identity({ ctx: input.ctx }) : undefined
const result = await Memory.enable({ root: dir, id })
await MemoryEvents.publish({
event: "updated",
payload: MemoryEvents.status({
root: dir,
state: result.state,
index: result.index,
phase: "idle",
consolidation: { trigger: "rebuild", operationCount: 0, cost: 0, tokens: result.index.tokens },
}),
})
return result
}
export async function disable(input: Input) {
const dir = await prepare(input)
MemoryTimers.clear(dir)
const result = await Memory.disable({ root: dir })
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({ root: result.root, state: result.state, phase: "idle" }),
})
return result
}
export async function show(input: Input) {
return Memory.show({ root: await prepare(input) })
}
export async function rebuild(input: Input) {
const dir = await prepare(input)
const state = await MemoryFiles.readState(dir)
if (!state.enabled) {
const index = (await noop(dir)).index
return { root: dir, state, index }
}
const result = await Memory.rebuild({ root: dir })
await MemoryEvents.publish({
event: "updated",
payload: MemoryEvents.status({
root: result.root,
state: result.state,
index: result.index,
phase: "idle",
consolidation: { trigger: "rebuild", operationCount: 0, cost: 0, tokens: result.index.tokens },
}),
})
return result
}
export async function configure(
input: Input & {
settings: Partial<Pick<MemorySchema.State, "autoConsolidate">>
},
) {
const result = await Memory.configure({ root: await prepare(input), settings: input.settings })
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({ root: result.root, state: result.state, phase: "idle" }),
})
return result
}
export async function context(input: Input) {
const result = await Memory.context({
root: await prepare(input),
sessionID: input.sessionID,
record: input.record,
})
if (result.recorded) {
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: result.root,
state: result.state,
index: result.index,
phase: "injecting",
sessionID: input.sessionID,
}),
})
}
return result
}
export async function toolEnabled(input: Input) {
return Memory.toolEnabled({ root: "ctx" in input ? await prepare(input) : root(input) })
}
async function publish(input: {
output: Memory.Apply
sessionID?: string
trigger?: Memory.Trigger
cost?: number
tokens?: number
}) {
await MemoryEvents.publish({
event: "updated",
payload: MemoryEvents.status({
root: input.output.root,
state: input.output.state,
index: input.output.result.index,
phase: "updating",
sessionID: input.sessionID,
consolidation: {
trigger: input.trigger ?? "explicit",
operationCount: input.output.result.operationCount,
cost: input.cost ?? 0,
tokens: input.tokens ?? 0,
},
...(input.output.detail ? { detail: input.output.detail } : {}),
}),
})
}
export async function apply(
input: Input & {
ops: MemoryOperations.Op[]
trigger?: Memory.Trigger
cost?: number
tokens?: number
},
) {
const dir = await prepare(input)
await requireEnabled(dir)
const output = await Memory.apply({
root: dir,
ops: input.ops,
trigger: input.trigger,
sessionID: input.sessionID,
tokens: input.tokens,
})
await publish({
output,
sessionID: input.sessionID,
trigger: input.trigger,
cost: input.cost,
tokens: input.tokens,
})
return output.result
}
export async function forget(input: Input & { query: string }) {
const dir = await prepare(input)
await requireEnabled(dir)
const output = await Memory.forget({ root: dir, query: input.query, sessionID: input.sessionID })
await publish({ output, sessionID: input.sessionID })
return output.result
}
export async function remember(
input: Input & {
text: string
key?: string
file?: MemorySchema.Source
section?: string
},
) {
const dir = await prepare(input)
await requireEnabled(dir)
const output = await Memory.remember({
root: dir,
text: input.text,
key: input.key,
file: input.file,
section: input.section,
sessionID: input.sessionID,
})
await publish({ output, sessionID: input.sessionID })
return output.result
}
export async function correct(input: Input & { text: string; key?: string }) {
const dir = await prepare(input)
await requireEnabled(dir)
const output = await Memory.correct({
root: dir,
text: input.text,
key: input.key,
sessionID: input.sessionID,
})
await publish({ output, sessionID: input.sessionID })
return output.result
}
export async function purge(input: Input) {
const dir = root(input)
MemoryTimers.clear(dir)
const result = await Memory.purge({ root: dir })
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: result.root,
state: result.state,
phase: "idle",
reason: result.purged ? "purged" : "missing",
}),
})
return { root: result.root, purged: result.purged }
}
export async function recall(input: Input & { query: string; sessionID?: string }) {
const output = await Memory.recall({ root: await prepare(input), query: input.query, sessionID: input.sessionID })
if (!output.state.enabled) return
if (!output.result) {
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: output.root,
state: output.state,
phase: "skipped",
sessionID: input.sessionID,
detail: {
type: "skipped",
message: "Memory skipped · no recall matches",
reason: "no_matches",
skippedCount: 1,
},
}),
})
return
}
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: output.root,
state: output.state,
phase: "injecting",
sessionID: input.sessionID,
detail: {
type: "recalled",
message: `Memory recalled · ${output.result.hits.length} ${output.result.hits.length === 1 ? "item" : "items"}`,
tokens: output.result.tokens,
operationCount: output.result.hits.length,
sources: output.files,
files: output.files,
},
}),
})
return { root: output.root, ...output.result }
}
export async function recordSession(
input: Input & { sessionID: string; topic?: string; summary: string; time?: number; tokens?: number },
) {
const output = await Memory.recordSession({
root: await prepare(input),
sessionID: input.sessionID,
topic: input.topic,
summary: input.summary,
time: input.time,
tokens: input.tokens,
})
if (output.skipped) {
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: output.root,
state: output.state,
phase: "skipped",
reason: output.reason,
sessionID: input.sessionID,
}),
})
return { skipped: true, reason: output.reason }
}
await MemoryEvents.publish({
event: "updated",
payload: MemoryEvents.status({
root: output.root,
state: output.state,
index: output.index,
phase: "updating",
sessionID: input.sessionID,
consolidation: { trigger: "turn-close", operationCount: 0, cost: 0, tokens: input.tokens ?? 0 },
}),
})
return { skipped: false, index: output.index }
}
}
export { MemoryEvents } from "./events"
export { MemoryPaths } from "./paths"
@@ -0,0 +1,16 @@
/** Injectable instance-context binder. The Effect service bridges async package calls through
* this binder so host-provided context (e.g. opencode's per-instance ALS) survives the await.
* Defaults to identity so the package stays runnable without a host. */
export namespace MemoryInstance {
export type Binder = <A>(fn: () => Promise<A>) => () => Promise<A>
let binder: Binder = (fn) => fn
export function setBinder(next: Binder) {
binder = next
}
export function bind<A>(fn: () => Promise<A>): () => Promise<A> {
return binder(fn)
}
}
+15
View File
@@ -0,0 +1,15 @@
/** Injectable diagnostic logger. Opencode wires this to its structured logger at bootstrap;
* the package defaults to a no-op so it never reaches into the host runtime on its own. */
export namespace MemoryLog {
export type Fn = (message: string, meta?: Record<string, unknown>) => void
let warnFn: Fn = () => {}
export function setWarn(fn: Fn) {
warnFn = fn
}
export function warn(message: string, meta?: Record<string, unknown>) {
warnFn(message, meta)
}
}
+32
View File
@@ -0,0 +1,32 @@
import { homedir } from "os"
import path from "path"
import { MemoryPaths as Core } from "../storage/paths"
/** Context-bound paths over the pure core. The host (home/config dirs) is injected at bootstrap so
* the package does not hard-code the opencode global directory; defaults to `~/.kilo`. */
export namespace MemoryPaths {
export type Ctx = Core.Ctx
export type Files = Core.Files
export type Identity = Core.Identity
export type Host = Core.Host
// A provider (not a snapshot) so hosts that resolve home/config dynamically — e.g. from env at
// call time — are reflected on every `root` call.
let host: () => Host = () => ({ home: homedir(), config: path.join(homedir(), ".kilo") })
export function configure(next: () => Host) {
host = next
}
export function identity(input: { ctx: Ctx }): Identity {
return Core.identity(input)
}
export function root(input: { ctx: Ctx }) {
const { home, config } = host()
return Core.root({ ctx: input.ctx, home, config })
}
export const files = Core.files
export const source = Core.source
}
+48
View File
@@ -0,0 +1,48 @@
import type { Effect } from "effect"
import type { CaptureDiff } from "../capture/diff"
import type { MemoryError } from "./errors"
/** Runtime ports the capture pipeline depends on. The host (opencode) implements these against its
* session store and LLM provider; the package orchestration stays free of `ai`/provider types by
* treating the resolved model as an opaque handle and consuming pre-extracted turn primitives. */
export namespace MemoryPorts {
export type ModelRef = { providerID: string; modelID: string }
/** Pre-extracted view of the latest turn. All transcript/message-shape handling happens host-side
* so the orchestrator never touches the host's message model. */
export type TurnView = {
user: string
assistant: string
recent: string
lastAssistantID: string
sessionModel: ModelRef
/** True when the turn was answered from targeted recall (digesting it would echo memory back). */
recalledMemory: boolean
diffs: CaptureDiff[]
}
export interface SessionPort {
readonly readTurn: (input: {
sessionID: string
window: number
}) => Effect.Effect<TurnView | undefined, MemoryError>
readonly get: (input: { sessionID: string }) => Effect.Effect<{ parentID?: string } | undefined, MemoryError>
}
/** Opaque resolved-model handle. Carries provider/language/options on the host side; the package
* only passes it back to `run`. */
export type ModelHandle = unknown
export type ModelResolution = { handle: ModelHandle; fallback?: { reason: string } }
export interface ModelPort {
readonly resolve: (input: { configured?: string; session: ModelRef }) => Effect.Effect<ModelResolution, MemoryError>
readonly run: (input: {
handle: ModelHandle
system: string
prompt: string
timeoutMs: number
signal?: AbortSignal
}) => Promise<{ text: string; usage: unknown }>
}
}
+256
View File
@@ -0,0 +1,256 @@
import { Context, Effect, Layer, Semaphore } from "effect"
import { skipLine, type CaptureSkip } from "../capture/capture"
import type { Memory } from "../memory"
import type { MemoryOperations } from "../capture/ops"
import { MemoryRecall } from "../recall/recall"
import { MemorySchema } from "../schema"
import { MemoryFiles } from "../storage/store"
import { MemoryToken } from "../recall/token"
import { KiloMemory } from "./index"
import { MemoryInstance } from "./instance"
import { MemoryError, type MemoryError as Failure } from "./errors"
type SessionID = string
const IDLE_SETTLE_MS = 30_000
type ConfigureInput = KiloMemory.Input & {
settings: Partial<Pick<MemorySchema.State, "autoConsolidate">>
}
type ApplyInput = KiloMemory.Input & {
ops: MemoryOperations.Op[]
trigger?: Memory.Trigger
cost?: number
tokens?: number
}
type RememberInput = KiloMemory.Input & {
text: string
key?: string
file?: MemorySchema.Source
section?: string
}
type CorrectInput = KiloMemory.Input & {
text: string
key?: string
}
type ForgetInput = KiloMemory.Input & {
query: string
}
type RecallInput = KiloMemory.Input & {
query: string
sessionID?: string
}
type SearchInput = Parameters<typeof MemoryRecall.search>[0]
type RecordInput = KiloMemory.Input & {
sessionID: string
topic?: string
summary: string
time?: number
tokens?: number
}
type DecideInput = {
root: string
decision: MemoryFiles.Decision
}
type ReadSourceInput = {
root: string
file: MemorySchema.Source
}
type RootInput = {
root: string
}
type SessionInput = RootInput & {
sessionID: string
max: number
}
type RecentInput = RootInput & {
limit: number
max: number
}
type AppendInput = RootInput & {
text: string
}
type Sources = Record<MemorySchema.Source, string>
type Index = {
bytes: number
tokens: number
truncated: false
}
type CommitInput = RootInput & {
now: number
messageID: string
tokens: number
count: number
digest: boolean
skipped: CaptureSkip[]
cost?: number
}
function bridge<A>(fn: () => Promise<A>) {
return Effect.tryPromise({
try: MemoryInstance.bind(fn),
catch: MemoryError.from,
})
}
export namespace MemoryService {
export type Timing = { settleMs: number }
export interface Interface {
readonly prepare: (input: KiloMemory.Input) => Effect.Effect<string, Failure>
readonly status: (input: KiloMemory.Input) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.status>>, Failure>
readonly show: (input: KiloMemory.Input) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.show>>, Failure>
readonly enable: (input: KiloMemory.Input) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.enable>>, Failure>
readonly disable: (
input: KiloMemory.Input,
) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.disable>>, Failure>
readonly rebuild: (
input: KiloMemory.Input,
) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.rebuild>>, Failure>
readonly configure: (
input: ConfigureInput,
) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.configure>>, Failure>
readonly apply: (input: ApplyInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.apply>>, Failure>
readonly remember: (input: RememberInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.remember>>, Failure>
readonly correct: (input: CorrectInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.correct>>, Failure>
readonly forget: (input: ForgetInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.forget>>, Failure>
readonly purge: (input: KiloMemory.Input) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.purge>>, Failure>
readonly recall: (input: RecallInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.recall>>, Failure>
readonly search: (input: SearchInput) => Effect.Effect<Awaited<ReturnType<typeof MemoryRecall.search>>, Failure>
readonly recordSession: (
input: RecordInput,
) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.recordSession>>, Failure>
readonly state: (input: RootInput) => Effect.Effect<MemorySchema.State, Failure>
readonly session: (
input: SessionInput,
) => Effect.Effect<Awaited<ReturnType<typeof MemoryFiles.readSession>>, Failure>
readonly sources: (input: RootInput) => Effect.Effect<Sources, Failure>
readonly recent: (
input: RecentInput,
) => Effect.Effect<Awaited<ReturnType<typeof MemoryFiles.recentSessions>>, Failure>
readonly append: (input: AppendInput) => Effect.Effect<void, Failure>
readonly index: (input: RootInput) => Effect.Effect<Index, Failure>
readonly commit: (input: CommitInput) => Effect.Effect<void, Failure>
readonly decide: (input: DecideInput) => Effect.Effect<void, Failure>
readonly readSource: (input: ReadSourceInput) => Effect.Effect<string, Failure>
readonly turnLock: (sessionID: SessionID) => Semaphore.Semaphore
readonly dropLock: (sessionID: SessionID) => void
readonly idleSettle: () => number
readonly setIdleSettle: (ms: number) => Timing
}
export class Service extends Context.Service<Service, Interface>()("@kilocode/MemoryService") {}
export function make() {
const locks = new Map<SessionID, { sema: Semaphore.Semaphore; holders: number }>()
let settle = IDLE_SETTLE_MS
return Service.of({
prepare: (input) => bridge(() => KiloMemory.prepare(input)),
status: (input) => bridge(() => KiloMemory.status(input)),
show: (input) => bridge(() => KiloMemory.show(input)),
enable: (input) => bridge(() => KiloMemory.enable(input)),
disable: (input) => bridge(() => KiloMemory.disable(input)),
rebuild: (input) => bridge(() => KiloMemory.rebuild(input)),
configure: (input) => bridge(() => KiloMemory.configure(input)),
apply: (input) => bridge(() => KiloMemory.apply(input)),
remember: (input) => bridge(() => KiloMemory.remember(input)),
correct: (input) => bridge(() => KiloMemory.correct(input)),
forget: (input) => bridge(() => KiloMemory.forget(input)),
purge: (input) => bridge(() => KiloMemory.purge(input)),
recall: (input) => bridge(() => KiloMemory.recall(input)),
search: (input) => bridge(() => MemoryRecall.search(input)),
recordSession: (input) => bridge(() => KiloMemory.recordSession(input)),
state: (input) => bridge(() => MemoryFiles.readState(input.root)),
session: (input) =>
bridge(() => MemoryFiles.readSession(input.root, { sessionID: input.sessionID, max: input.max })),
sources: (input) =>
bridge(async () => {
const entries = await Promise.all(
MemorySchema.Sources.map(async (file) => [file, await MemoryFiles.readSource(input.root, file)] as const),
)
return Object.fromEntries(entries) as Sources
}),
recent: (input) => bridge(() => MemoryFiles.recentSessions(input.root, input.limit, input.max)),
append: (input) => bridge(() => MemoryFiles.append(input.root, input.text)),
index: (input) =>
bridge(async () => {
const text = await MemoryFiles.readIndex(input.root)
return { bytes: Buffer.byteLength(text), tokens: MemoryToken.estimate(text), truncated: false }
}),
commit: (input) =>
bridge(() =>
MemoryFiles.queue(input.root, async () => {
const state = await MemoryFiles.readState(input.root)
await MemoryFiles.writeState(input.root, {
...state,
stats: {
...state.stats,
lastConsolidatedAt: input.now,
lastConsolidatedMessageID: input.messageID,
lastConsolidationCost: input.cost ?? state.stats.lastConsolidationCost,
lastConsolidationTokens: input.tokens,
lastOperationCount: input.count,
},
})
const skip = skipLine(input.skipped)
await MemoryFiles.append(
input.root,
[
`consolidate trigger=turn-close digest=${input.digest ? 1 : 0} ops=${input.count} tokens=${input.tokens}`,
skip,
]
.filter(Boolean)
.join(" "),
)
}),
),
decide: (input) => bridge(() => MemoryFiles.decide(input.root, input.decision)),
readSource: (input) => bridge(() => MemoryFiles.readSource(input.root, input.file)),
// Ref-counted so every acquirer — in-flight or queued behind `withPermits` — shares one
// semaphore. Each call must be balanced by exactly one `dropLock`.
turnLock: (sessionID) => {
const prior = locks.get(sessionID)
if (prior) {
prior.holders += 1
return prior.sema
}
const sema = Semaphore.makeUnsafe(1)
locks.set(sessionID, { sema, holders: 1 })
return sema
},
// Release one holder. The entry is dropped only when the last holder leaves, so a queued
// close() can never be handed a different semaphore than the peer it is waiting on — while the
// map still stops growing unbounded in a long-lived shared backend.
dropLock: (sessionID) => {
const item = locks.get(sessionID)
if (!item) return
item.holders -= 1
if (item.holders <= 0) locks.delete(sessionID)
},
idleSettle: () => settle,
setIdleSettle: (ms) => {
const prev = { settleMs: settle }
settle = Math.max(1, ms)
return prev
},
})
}
export const layer = Layer.sync(Service)(make)
}
+55
View File
@@ -0,0 +1,55 @@
type SessionID = string
export namespace MemoryTimers {
const pending = new Map<SessionID, { root: string; timer: ReturnType<typeof setTimeout> }>()
const signals = new Map<string, { ctl: AbortController; active: number }>()
export function cancel(sessionID: SessionID) {
const item = pending.get(sessionID)
if (!item) return
clearTimeout(item.timer)
pending.delete(sessionID)
}
export function clear(root: string) {
for (const [sessionID, item] of pending) {
if (item.root !== root) continue
clearTimeout(item.timer)
pending.delete(sessionID)
}
signals.get(root)?.ctl.abort()
signals.delete(root)
}
// One AbortController per root, shared across concurrent captures and ref-counted so it is dropped
// once the last in-flight capture for the root settles (see `release`). Without this the map grows
// for every distinct root a long-lived shared backend ever touches. disable/purge still force-abort
// via `clear`; `release` tolerates an already-cleared entry.
export function signal(root: string) {
const prior = signals.get(root)
if (prior) {
prior.active += 1
return prior.ctl.signal
}
const ctl = new AbortController()
signals.set(root, { ctl, active: 1 })
return ctl.signal
}
export function release(root: string) {
const item = signals.get(root)
if (!item) return
item.active -= 1
if (item.active <= 0) signals.delete(root)
}
export function done(sessionID: SessionID) {
pending.delete(sessionID)
}
export function set(sessionID: SessionID, root: string, timer: ReturnType<typeof setTimeout>) {
cancel(sessionID)
timer.unref?.()
pending.set(sessionID, { root, timer })
}
}
+103
View File
@@ -0,0 +1,103 @@
import { Cause, Effect } from "effect"
import { MemoryCapture } from "./capture"
import { MemoryInstance } from "./instance"
import { MemoryLog } from "./log"
import type { MemoryPorts } from "./ports"
import { MemoryService } from "./service"
import { MemoryTimers } from "./timers"
function brief(cause: Cause.Cause<unknown>) {
const err = Cause.squash(cause)
return (err instanceof Error ? err.message : String(err)).slice(0, 200)
}
function message(err: unknown) {
return (err instanceof Error ? err.message : String(err)).slice(0, 200)
}
export namespace MemoryTurn {
export type Reason = "completed" | "error" | "interrupted"
type Input = {
root: string
sessionID: string
reason: Reason
session: MemoryPorts.SessionPort
model: MemoryPorts.ModelPort
memoryModel?: string
}
function schedule(input: Input, memory: MemoryService.Interface, root: string) {
MemoryTimers.cancel(input.sessionID)
const run = MemoryInstance.bind(async () => {
MemoryTimers.done(input.sessionID)
void Effect.runPromise(
memory.turnLock(input.sessionID).withPermits(1)(
MemoryCapture.turn({
root: input.root,
sessionID: input.sessionID,
session: input.session,
model: input.model,
memoryModel: input.memoryModel,
reason: "completed",
bypassInterval: true,
}).pipe(
// Timer callbacks run outside the caller's Effect environment, so carry the resolved service from close.
Effect.provideService(MemoryService.Service, memory),
Effect.catchCause((cause) => Effect.sync(() => MemoryCapture.report(cause))),
),
),
)
.catch((err) => MemoryLog.warn("memory idle flush failed", { err: message(err) }))
.finally(() => memory.dropLock(input.sessionID))
})
MemoryTimers.set(input.sessionID, root, setTimeout(run, memory.idleSettle()))
}
export function open(input: { sessionID: string }) {
MemoryTimers.cancel(input.sessionID)
}
export const close = Effect.fn("MemoryTurn.close")(function* (input: Input) {
const memory = yield* MemoryService.Service
yield* memory
.turnLock(input.sessionID)
.withPermits(1)(
Effect.gen(function* () {
const info = yield* input.session.get({ sessionID: input.sessionID }).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
MemoryLog.warn("memory session lookup failed", { err: brief(cause) })
return undefined
}),
),
)
if (!info) return
if (info.parentID) return
const result = yield* MemoryCapture.turn({
root: input.root,
sessionID: input.sessionID,
session: input.session,
model: input.model,
reason: input.reason,
memoryModel: input.memoryModel,
}).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
MemoryCapture.report(cause)
return undefined
}),
),
)
if (result?.skipped && result.idleFlush) schedule(input, memory, result.root)
}),
)
.pipe(
Effect.catchCause((cause) =>
Effect.sync(() => MemoryLog.warn("memory turn-close hook failed", { err: brief(cause) })),
),
)
// Always release this holder. A deferred flush takes its own turnLock/dropLock pair when its
// timer fires, so exclusivity across overlapping closes is preserved by the ref count.
yield* Effect.sync(() => memory.dropLock(input.sessionID))
})
}
+11
View File
@@ -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"
+36
View File
@@ -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)))
}
}
+359
View File
@@ -0,0 +1,359 @@
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<Pick<MemorySchema.State, "autoConsolidate">>
}) {
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<Apply> {
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 }
})
}
}
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
declare module "*.txt" {
const value: string
export default value
}
@@ -0,0 +1,95 @@
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",
"file": "project.md" | "environment.md" | "corrections.md",
"section": "the section that holds the existing duplicate, when 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 a "duplicate" skip, set both "file" and "section" to where the existing entry lives, so the duplicate is verified within that exact scope and not against unrelated memory. A "duplicate" claim missing either field cannot be confirmed.
- 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.
+108
View File
@@ -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=<topic> 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)
}
}
@@ -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}`,
}),
)
}
}
+195
View File
@@ -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<T extends { topic: string }>(recent: T[]) {
const topics = new Set<string>()
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<Result> {
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
})
}
}
+263
View File
@@ -0,0 +1,263 @@
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<Result | undefined> {
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),
}
}
}
+111
View File
@@ -0,0 +1,111 @@
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
file: MemorySchema.Source
section: string
key: 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[] {
return MemoryMarkdown.parse(input.text).map((item) => ({
id: `${input.file}:${item.section}:${item.key}`,
file: input.file,
section: item.section,
key: item.key,
text: `${item.key} ${item.text}`,
}))
}
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),
},
)
}
}
+7
View File
@@ -0,0 +1,7 @@
export namespace MemoryToken {
const chars = 4
export function estimate(input: string) {
return Math.max(0, Math.round((input || "").length / chars))
}
}
+50
View File
@@ -0,0 +1,50 @@
import { MemorySchema } from "../schema"
export namespace MemoryTopics {
export type Input = {
file?: MemorySchema.Source
section?: string
key?: string
text: string
}
const limit = {
terms: 6,
expanded: 24,
}
const matcher = /[\p{L}\p{N}][\p{L}\p{N}_.-]{1,}/gu
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)
}
}
+212
View File
@@ -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<string, unknown> {
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),
},
}
}
}
+25
View File
@@ -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)}`
}
}
+130
View File
@@ -0,0 +1,130 @@
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")
}
}
+246
View File
@@ -0,0 +1,246 @@
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<string, Promise<void>>()
export const DIR = 0o700
export const FILE = 0o600
const STALE = 30_000
const local = new AsyncLocalStorage<Set<string>>()
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<void>> => {
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<T>(root: string, fn: () => Promise<T>): Promise<T> {
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)
}
}
}
@@ -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 }
}
}
+133
View File
@@ -0,0 +1,133 @@
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])
if (!belongs(dot, git)) return dir
return checkout(common(git)) ?? dir
}
function belongs(dot: string, git: string) {
const back = read(path.join(git, "gitdir"))
if (!back) return false
return canon(path.resolve(git, back)) === canon(dot)
}
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
}
}
@@ -0,0 +1,177 @@
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
}
}
@@ -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<string, InventoryItem>
}
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<Inventory> {
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 }
}
}
+234
View File
@@ -0,0 +1,234 @@
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<MemorySchema.Source, string> = {
"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
}
}
+44
View File
@@ -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
}
+20
View File
@@ -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()
}
}
+506
View File
@@ -0,0 +1,506 @@
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:Facts:repo_tests",
file: "project.md" as const,
section: "Facts",
key: "repo_tests",
text: "repo_tests Run memory tests from packages/opencode.",
},
]
const verified = verifySkips({
items,
skipped: [
// Fully scoped to the stored entry → confirmed.
{ reason: "duplicate", text: "Run memory tests from packages/opencode.", file: "project.md", section: "Facts" },
// Unscoped → unverified regardless of any text overlap.
{ 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:Facts:repo_tests")
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:Facts:repo_tests")).toBe(true)
})
test("does not pre-skip similar operations from different memory scopes", () => {
const filtered = duplicateOps({
items: [
{
id: "corrections.md:Corrections:repo_tests",
file: "corrections.md",
section: "Corrections",
key: "repo_tests",
text: "repo_tests Run memory tests from packages/opencode.",
},
],
skipped: [],
ops: [
{
action: "add",
file: "project.md",
section: "Facts",
key: "repo_tests",
text: "Run memory tests from packages/opencode.",
},
],
})
expect(filtered.ops).toHaveLength(1)
expect(filtered.skipped).toEqual([])
})
test("scopes model-reported duplicate skips to the claimed file/section", () => {
const items = [
{
id: "corrections.md:Corrections:repo_tests",
file: "corrections.md" as const,
section: "Corrections",
key: "repo_tests",
text: "repo_tests Run memory tests from packages/opencode.",
},
]
const verified = verifySkips({
items,
skipped: [
// Claims a duplicate in project.md/Facts, but the only match lives in corrections.md →
// unconfirmed, downgraded to advisory instead of confirmed cross-scope.
{
reason: "duplicate",
text: "Run memory tests from packages/opencode.",
file: "project.md",
section: "Facts",
},
// Same text, correctly scoped to where the entry actually lives → confirmed.
{
reason: "duplicate",
text: "Run memory tests from packages/opencode.",
file: "corrections.md",
section: "Corrections",
},
],
})
expect(verified.skipped[0]).toMatchObject({ reason: "unsupported" })
expect(verified.skipped[1]).toMatchObject({
reason: "duplicate",
duplicateOf: "corrections.md:Corrections:repo_tests",
})
})
test("does not confirm a duplicate skip scoped to a file without a section", () => {
const items = [
{
id: "project.md:Decisions:repo_tests",
file: "project.md" as const,
section: "Decisions",
key: "repo_tests",
text: "repo_tests Run memory tests from packages/opencode.",
},
]
const verified = verifySkips({
items,
skipped: [
// Claims project.md but not the section; the only match lives in Decisions. Confirming would
// risk a cross-section false positive, so it must downgrade to advisory.
{ reason: "duplicate", text: "Run memory tests from packages/opencode.", file: "project.md" },
],
})
expect(verified.skipped[0]).toEqual({
reason: "unsupported",
text: "Run memory tests from packages/opencode.",
})
})
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)
}
})
})
@@ -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"
}
]
@@ -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))
}
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,205 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "fs/promises"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { digestPrompt, typedPrompt } from "../src/capture/capture"
import { MemoryCapture } from "../src/effect/capture"
import { KiloMemory } from "../src/effect/index"
import type { MemoryPorts } from "../src/effect/ports"
import { MemoryService } from "../src/effect/service"
import { MemoryTimers } from "../src/effect/timers"
async function tmp() {
const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-memory-effect-"))
return {
root: path.join(dir, "memory"),
async done() {
await rm(dir, { recursive: true, force: true })
},
}
}
const USAGE = { inputTokens: { total: 12 }, outputTokens: { total: 8 } }
function view(over: Partial<MemoryPorts.TurnView> = {}): MemoryPorts.TurnView {
return {
user: "what commands are needed for this repo setup?",
assistant: "Use bun install, then bun test ./test from packages/opencode.",
recent: "User: setup?\n\nAssistant: bun install then bun test.",
lastAssistantID: "msg_assistant",
sessionModel: { providerID: "test", modelID: "fake-memory-model" },
recalledMemory: false,
diffs: [],
...over,
}
}
/** Session port that always surfaces the given turn (or none). */
function session(turn: MemoryPorts.TurnView | undefined): MemoryPorts.SessionPort {
return {
readTurn: () => Effect.succeed(turn),
get: () => Effect.succeed({ parentID: undefined }),
}
}
/** Model port that answers digest/typed calls from canned JSON, keyed by system prompt so it is
* order-independent (digest and typed run concurrently). */
function model(input: { digest: string; typed: string; fallback?: string; onRun?: () => void }): MemoryPorts.ModelPort {
return {
resolve: () => Effect.succeed({ handle: {}, ...(input.fallback ? { fallback: { reason: input.fallback } } : {}) }),
run: async ({ system }) => {
input.onRun?.()
const text = system === digestPrompt ? input.digest : system === typedPrompt ? input.typed : "{}"
return { text, usage: USAGE }
},
}
}
function run(input: {
root: string
session: MemoryPorts.SessionPort
model: MemoryPorts.ModelPort
memoryModel?: string
}) {
return Effect.runPromise(
MemoryCapture.turn({
root: input.root,
sessionID: "ses_effect",
session: input.session,
model: input.model,
memoryModel: input.memoryModel,
reason: "completed",
}).pipe(Effect.provideService(MemoryService.Service, MemoryService.make())),
)
}
describe("MemoryCapture (fake ports)", () => {
test("turn-close typed LLM saves environment memory and audit records", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
const result = await run({
root: t.root,
session: session(view()),
model: model({
digest: '{"topic":"repo setup","summary":"Explored repo setup commands. Next step: verify memory tests."}',
typed:
'{"operations":[{"op":"upsert_environment_fact","section":"Commands","key":"cli_memory_tests","value":"Run bun test ./test from packages/opencode."}],"skipped":[]}',
}),
})
expect(result).toMatchObject({ skipped: false, operationCount: 1 })
if (!("tokens" in result)) throw new Error("expected capture to save memory")
expect(result.tokens).toBeGreaterThan(0)
const shown = await KiloMemory.show({ root: t.root })
expect(shown.sources.environment).toContain("cli_memory_tests")
expect(shown.decisions).toContain('"kind":"digest"')
expect(shown.decisions).toContain('"kind":"typed"')
expect(shown.decisions).toContain('"result":"saved"')
} finally {
await t.done()
}
})
test("auto-consolidate off skips digest and typed model writes", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: false } })
let runs = 0
const result = await run({
root: t.root,
session: session(view()),
model: model({
digest: '{"topic":"x","summary":"should not be saved"}',
typed: '{"operations":[{"op":"upsert_environment_fact","key":"nope","value":"x"}],"skipped":[]}',
onRun: () => runs++,
}),
})
expect(result).toMatchObject({ skipped: true })
expect(runs).toBe(0)
const shown = await KiloMemory.show({ root: t.root })
expect(shown.sources.environment).not.toContain("nope")
} finally {
await t.done()
}
})
test("records audit when configured memory model is unavailable", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
await run({
root: t.root,
session: session(view()),
memoryModel: "test/missing-memory-model",
model: model({
digest: '{"topic":"repo","summary":"Explored repo setup. Next: verify."}',
typed: '{"operations":[],"skipped":[]}',
fallback: "model unavailable",
}),
})
const shown = await KiloMemory.show({ root: t.root })
expect(shown.changes).toContain("memory_model_config reason=model unavailable fallback=1")
} finally {
await t.done()
}
})
test("no turn to capture is skipped", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
const result = await run({
root: t.root,
session: session(undefined),
model: model({ digest: "{}", typed: "{}" }),
})
expect(result).toMatchObject({ skipped: true, reason: "no_turn" })
} finally {
await t.done()
}
})
})
describe("MemoryService turn-lock ref-counting", () => {
test("keeps one semaphore per session until the last holder drops", () => {
const svc = MemoryService.make()
const a = svc.turnLock("ses_lock")
const b = svc.turnLock("ses_lock")
expect(b).toBe(a) // a queued close() shares the same semaphore as the holder it waits on
svc.dropLock("ses_lock") // first holder settles; second is still queued/holding
const c = svc.turnLock("ses_lock")
expect(c).toBe(a) // a later close() must not get a fresh semaphore while a holder remains
svc.dropLock("ses_lock")
svc.dropLock("ses_lock") // last holder leaves → entry dropped
const fresh = svc.turnLock("ses_lock")
expect(fresh).not.toBe(a) // only now does a new turn get a new semaphore
svc.dropLock("ses_lock")
})
})
describe("MemoryTimers signal ref-counting", () => {
test("shares one controller per root and drops it once the last capture releases", () => {
const root = "/kilo-memory/ref-count-root"
const first = MemoryTimers.signal(root)
const second = MemoryTimers.signal(root)
expect(second).toBe(first) // concurrent captures share the controller
MemoryTimers.release(root)
expect(MemoryTimers.signal(root)).toBe(first) // still alive while one capture remains
MemoryTimers.release(root)
MemoryTimers.release(root) // last in-flight capture settles → controller dropped
const fresh = MemoryTimers.signal(root)
expect(fresh).not.toBe(first) // next capture gets a new controller, proving cleanup
MemoryTimers.release(root)
})
})
+106
View File
@@ -0,0 +1,106 @@
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" },
])
})
})
+139
View File
@@ -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<string, unknown>
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()
}
})
})
@@ -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<ReturnType<typeof tmp>>) => Promise<void>) {
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()
})
})
})
+20
View File
@@ -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("...")
})
})
+15
View File
@@ -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"]
}
+4 -1
View File
@@ -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 })
}
@@ -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", () => {
@@ -0,0 +1,72 @@
/**
* 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 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*\?/)
})
})
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")
// 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"/)
})
})
@@ -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<TaskHeaderProps> = (props) => {
</Show>
</div>
<div data-slot="task-header-stats">
<BalanceChip />
<Show when={cost()}>
{(c) => (
<Tooltip value={costTooltip()} placement="bottom">
@@ -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<ProfileViewProps> = (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<ProfileViewProps> = (props) => {
{/* Balance */}
<Show when={data().balance}>
{(balance) => (
<Card
style={{
display: "flex",
"align-items": "center",
"justify-content": "space-between",
}}
>
<div>
<p
style={{
"font-size": "var(--kilo-font-size-11)",
"text-transform": "uppercase",
"letter-spacing": "0.5px",
color: "var(--vscode-descriptionForeground)",
margin: "0 0 4px 0",
}}
>
{language.t("profile.balance.title")}
</p>
<p
style={{
"font-size": "var(--kilo-font-size-18)",
"font-weight": "600",
color: "var(--vscode-foreground)",
margin: 0,
}}
>
{formatBalance(balance().balance)}
</p>
<Card style={{ display: "flex", "flex-direction": "column", gap: "12px" }}>
<div style={{ display: "flex", "align-items": "center", "justify-content": "space-between" }}>
<div>
<p
style={{
"font-size": "var(--kilo-font-size-11)",
"text-transform": "uppercase",
"letter-spacing": "0.5px",
color: "var(--vscode-descriptionForeground)",
margin: "0 0 4px 0",
}}
>
{language.t("profile.balance.title")}
</p>
<p
style={{
"font-size": "var(--kilo-font-size-18)",
"font-weight": "600",
color: "var(--vscode-foreground)",
margin: 0,
}}
>
{formatBalance(balance().balance)}
</p>
</div>
<Tooltip value={language.t("profile.balance.refresh")} placement="left">
<Button variant="ghost" size="small" onClick={handleRefresh}>
{language.t("common.refresh")}
</Button>
</Tooltip>
</div>
<Tooltip value={language.t("profile.balance.refresh")} placement="left">
<Button variant="ghost" size="small" onClick={handleRefresh}>
{language.t("common.refresh")}
</Button>
</Tooltip>
{/* Kilo Pass is part of personal credits, so only show it on the personal account */}
<Show when={(data().currentOrgId ?? null) === null ? data().kiloPass : null}>
{(pass) => (
<div
style={{
"border-top": "1px solid var(--border-weak-base)",
"padding-top": "12px",
display: "flex",
"flex-direction": "column",
gap: "6px",
}}
>
<div
style={{
display: "flex",
"align-items": "baseline",
"justify-content": "space-between",
"font-size": "var(--kilo-font-size-13)",
}}
>
<span style={{ "font-weight": "600", color: "var(--vscode-foreground)" }}>Kilo Pass</span>
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
{short(pass().currentPeriodUsageUsd)} / {short(pass().currentPeriodBaseCreditsUsd)}
</span>
</div>
<div
style={{
height: "6px",
"border-radius": "3px",
background: "var(--border-weak-base)",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.min(100, (pass().currentPeriodUsageUsd / Math.max(1, pass().currentPeriodBaseCreditsUsd)) * 100)}%`,
background: "var(--vscode-progressBar-background, var(--vscode-button-background))",
}}
/>
</div>
<Show when={pass().currentPeriodBonusCreditsUsd > 0}>
<div
style={{
display: "flex",
"justify-content": "space-between",
"font-size": "var(--kilo-font-size-11)",
color: "var(--vscode-descriptionForeground)",
}}
>
<span>{language.t("profile.pass.bonus")}</span>
<span>+{formatBalance(pass().currentPeriodBonusCreditsUsd)}</span>
</div>
</Show>
<Show when={resetLabel(pass().nextBillingAt, language.locale())}>
{(date) => (
<div
style={{
display: "flex",
"justify-content": "space-between",
"font-size": "var(--kilo-font-size-11)",
color: "var(--vscode-descriptionForeground)",
}}
>
<span>{language.t("profile.pass.renews")}</span>
<span>{date()}</span>
</div>
)}
</Show>
</div>
)}
</Show>
{/* No active Kilo Pass on the personal account — nudge to subscribe */}
<Show when={(data().currentOrgId ?? null) === null && !data().kiloPass}>
<div
style={{
"border-top": "1px solid var(--border-weak-base)",
"padding-top": "12px",
}}
>
<button
type="button"
onClick={handleGetPass}
style={{
background: "none",
border: 0,
padding: 0,
"font-size": "var(--kilo-font-size-13)",
"font-family": "inherit",
color: "var(--vscode-textLink-foreground)",
cursor: "pointer",
"text-align": "left",
}}
>
{language.t("profile.pass.subscribe")}
</button>
</div>
</Show>
</Card>
)}
</Show>
@@ -251,6 +365,9 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
<Button variant="secondary" onClick={handleDashboard} style={{ flex: "1" }}>
{language.t("profile.action.dashboard")}
</Button>
<Button variant="secondary" onClick={handleTopUp} style={{ flex: "1" }}>
{language.t("profile.action.topUp")}
</Button>
<Button
variant="ghost"
onClick={handleLogout}
@@ -10,6 +10,7 @@ import { Spinner } from "@kilocode/kilo-ui/spinner"
import { useServer } from "../../context/server"
import { useVSCode } from "../../context/vscode"
import { useLanguage } from "../../context/language"
import { BalanceChip } from "./BalanceChip"
const PERSONAL = "personal"
@@ -92,6 +93,7 @@ export const AccountSwitcher: Component<{ class?: string }> = (props) => {
}
>
<span class="account-switcher-label">{label()}</span>
<BalanceChip class="account-switcher-balance" />
<span class="account-switcher-badges">
<Show when={selected() && !switching()}>
<span class="account-switcher-role">{selected()!.role.toUpperCase()}</span>
@@ -0,0 +1,13 @@
import { Component, Show } from "solid-js"
import { useServer } from "../../context/server"
export const BalanceChip: Component<{ class?: string }> = (props) => {
const server = useServer()
const balance = () => server.profileData()?.balance?.balance
return (
<Show when={balance() !== undefined}>
<span class={props.class}>${(balance() ?? 0).toFixed(2)}</span>
</Show>
)
}
@@ -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<ModelSelectorBaseProps> = (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<ModelSelectorBaseProps> = (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)
@@ -10,6 +10,7 @@ import {
createSignal,
createMemo,
createEffect,
on,
onMount,
onCleanup,
batch,
@@ -2519,27 +2520,61 @@ 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. 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 = 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
loadFocusedMessages(id, ready)
}
function loadFocusedMessages(id: string, ready: boolean) {
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 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()) return
loadFocusedMessages(id, loaded().has(id))
}),
)
function selectCloudSession(cloudSessionId: string) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot select cloud session: not connected")

Some files were not shown because too many files have changed in this diff Show More