fix(memory): add core project memory package
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-memory": minor
|
||||
---
|
||||
|
||||
Add the project memory storage, indexing, recall, and capture safety foundation.
|
||||
@@ -249,6 +249,19 @@
|
||||
"name": "@kilocode/kilo-jetbrains",
|
||||
"version": "7.3.49",
|
||||
},
|
||||
"packages/kilo-memory": {
|
||||
"name": "@kilocode/kilo-memory",
|
||||
"version": "7.3.45",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/kilo-telemetry": {
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.3.49",
|
||||
@@ -1297,6 +1310,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"],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-memory",
|
||||
"version": "7.3.45",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Project memory storage, indexing, recall, and command helpers for Kilo Code",
|
||||
"keywords": [
|
||||
"kilo",
|
||||
"kilocode",
|
||||
"memory",
|
||||
"agent"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./capture": "./src/capture/capture.ts",
|
||||
"./commands": "./src/commands.ts",
|
||||
"./digest": "./src/capture/digest.ts",
|
||||
"./memory": "./src/memory.ts",
|
||||
"./ops": "./src/capture/ops.ts",
|
||||
"./paths": "./src/storage/paths.ts",
|
||||
"./recall": "./src/recall/recall.ts",
|
||||
"./redact": "./src/capture/redact.ts",
|
||||
"./schema": "./src/schema.ts",
|
||||
"./shared": "./src/recall/shared.ts",
|
||||
"./store": "./src/storage/store.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "tsc",
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"peerDependencies": {}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { MemoryFiles } from "../storage/store"
|
||||
import { MemoryIndexer } from "../recall/indexer"
|
||||
import { MemoryMarkdown } from "../storage/markdown"
|
||||
import { MemoryRedact } from "./redact"
|
||||
import { MemoryReject } from "./reject"
|
||||
import { MemorySchema } from "../schema"
|
||||
import { MemoryShared } from "../recall/shared"
|
||||
import { MemoryText } from "../text"
|
||||
import { MemoryTopics } from "../recall/topics"
|
||||
import { MemorySlug } from "../slug"
|
||||
|
||||
/** Low-level raw-root operation applier. Prefer the Memory facade outside package adapters. */
|
||||
export namespace MemoryOperations {
|
||||
export type Add = {
|
||||
action: "add"
|
||||
file?: MemorySchema.Source
|
||||
section?: string
|
||||
key: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type Remove = {
|
||||
action: "remove"
|
||||
query: string
|
||||
}
|
||||
|
||||
export type Op = Add | Remove
|
||||
|
||||
export type Result = {
|
||||
operationCount: number
|
||||
added: number
|
||||
removed: number
|
||||
skipped: Rejection[]
|
||||
index: MemoryIndexer.Result
|
||||
}
|
||||
|
||||
// Content gating lives in MemoryReject; re-exported so MemoryOperations.reject/Rejection stay the stable surface.
|
||||
export type Rejection = MemoryReject.Rejection
|
||||
export const reject = MemoryReject.reject
|
||||
|
||||
function key(input: string) {
|
||||
const slug = MemorySlug.safe(input.trim(), { max: MemorySlug.max.key, fallback: "", lower: true })
|
||||
if (slug) return slug
|
||||
return MemorySlug.hash(input, "memory")
|
||||
}
|
||||
|
||||
function line(input: Add, max: number) {
|
||||
if (MemoryRedact.has(input.text) || MemoryRedact.has(input.key)) {
|
||||
throw new Error("memory operation rejected secret-like content")
|
||||
}
|
||||
const id = key(input.key)
|
||||
const body = MemoryText.brief(input.text, max)
|
||||
if (!id) throw new Error("memory operation key is required")
|
||||
if (!body) throw new Error("memory operation text is required")
|
||||
return { key: id, text: body, line: MemoryMarkdown.line(id, body) }
|
||||
}
|
||||
|
||||
type Prepared = {
|
||||
op: Add
|
||||
file: MemorySchema.Source
|
||||
section: string
|
||||
key: string
|
||||
text: string
|
||||
line: string
|
||||
}
|
||||
|
||||
function fallback(file: MemorySchema.Source | undefined) {
|
||||
if (file === "environment.md") return "Commands"
|
||||
if (file === "corrections.md") return "Corrections"
|
||||
return "Facts"
|
||||
}
|
||||
|
||||
function section(input: string | undefined, file: MemorySchema.Source) {
|
||||
const clean = input
|
||||
?.trim()
|
||||
.replaceAll(/[\x00-\x1f\x7f]+/g, " ")
|
||||
.replaceAll(/\s+/g, " ")
|
||||
.replaceAll(/^#+\s*/g, "")
|
||||
.replaceAll(/^\-\s+/g, "")
|
||||
.replaceAll(/\s+::\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80)
|
||||
.trim()
|
||||
return clean || fallback(file)
|
||||
}
|
||||
|
||||
function heading(input: Add, file = input.file) {
|
||||
return section(input.section, file ?? "project.md")
|
||||
}
|
||||
|
||||
function source(input: Add) {
|
||||
if (input.file) return input.file
|
||||
return "project.md"
|
||||
}
|
||||
|
||||
type Target = {
|
||||
ids: Set<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 }] })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { MemoryOperations } from "./ops"
|
||||
import { MemoryRedact } from "./redact"
|
||||
import { MemoryShared } from "../recall/shared"
|
||||
import type { MemoryFiles } from "../storage/store"
|
||||
import type { CaptureSkip } from "./parse"
|
||||
|
||||
export type CaptureSourceItem = { id: string; text: string }
|
||||
|
||||
export type CaptureDetail = {
|
||||
type: "saved" | "skipped"
|
||||
message: string
|
||||
tokens?: number
|
||||
operationCount?: number
|
||||
skippedCount?: number
|
||||
sources?: string[]
|
||||
files?: string[]
|
||||
}
|
||||
|
||||
export function usage(input: unknown) {
|
||||
if (!input || typeof input !== "object") return 0
|
||||
const value = input as { totalTokens?: unknown; inputTokens?: unknown; outputTokens?: unknown }
|
||||
const num = (item: unknown) => {
|
||||
if (typeof item === "number" && Number.isFinite(item)) return item
|
||||
if (typeof item !== "object" || item === null) return 0
|
||||
const nested = item as { total?: unknown }
|
||||
return typeof nested.total === "number" && Number.isFinite(nested.total) ? nested.total : 0
|
||||
}
|
||||
const total = num(value.totalTokens)
|
||||
if (total > 0) return total
|
||||
return num(value.inputTokens) + num(value.outputTokens)
|
||||
}
|
||||
|
||||
function detail(input: unknown) {
|
||||
if (input === undefined || input === null) return ""
|
||||
if (typeof input === "string") return input
|
||||
if (input instanceof Error) return input.message
|
||||
try {
|
||||
return JSON.stringify(input)
|
||||
} catch {
|
||||
return String(input)
|
||||
}
|
||||
}
|
||||
|
||||
export function errorReason(err: unknown) {
|
||||
if (!(err instanceof Error)) return MemoryShared.brief(String(err), 500)
|
||||
const value = err as Error & {
|
||||
cause?: unknown
|
||||
data?: unknown
|
||||
responseBody?: unknown
|
||||
response?: unknown
|
||||
status?: unknown
|
||||
statusCode?: unknown
|
||||
}
|
||||
const parts = [
|
||||
err.message,
|
||||
value.status === undefined ? "" : `status=${detail(value.status)}`,
|
||||
value.statusCode === undefined ? "" : `statusCode=${detail(value.statusCode)}`,
|
||||
value.data === undefined ? "" : `data=${detail(value.data)}`,
|
||||
value.responseBody === undefined ? "" : `body=${detail(value.responseBody)}`,
|
||||
value.response === undefined ? "" : `response=${detail(value.response)}`,
|
||||
value.cause === undefined ? "" : `cause=${detail(value.cause)}`,
|
||||
].filter(Boolean)
|
||||
return MemoryShared.brief(MemoryRedact.text(parts.join(" ")), 500)
|
||||
}
|
||||
|
||||
export function guardReason(input: string) {
|
||||
const value = input.toLowerCase()
|
||||
if (/\b(429|rate[_ -]?limit|too many requests)\b/.test(value)) return "rate_limit_guard"
|
||||
if (/\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/.test(value))
|
||||
return "quota_guard"
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function skipped(input: { sessionID: string; reason: string }): MemoryFiles.Decision {
|
||||
return {
|
||||
kind: "typed",
|
||||
trigger: "turn-close",
|
||||
sessionID: input.sessionID,
|
||||
result: "skipped",
|
||||
llm: false,
|
||||
parsed: false,
|
||||
fallback: false,
|
||||
reason: input.reason,
|
||||
tokens: 0,
|
||||
operationCount: 0,
|
||||
skippedCount: 1,
|
||||
summary: `memory capture skipped: ${input.reason}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function auditOps(ops: MemoryOperations.Op[]) {
|
||||
return MemoryShared.audit(ops)
|
||||
}
|
||||
|
||||
function tokens(input: string) {
|
||||
return MemoryShared.terms(input)
|
||||
}
|
||||
|
||||
function duplicate(text: string | undefined, items: CaptureSourceItem[]) {
|
||||
if (!text) return
|
||||
const query = tokens(text)
|
||||
if (query.length === 0) return
|
||||
// Majority overlap required: a few shared generic terms must not confirm a duplicate.
|
||||
const needed = Math.max(Math.min(3, query.length), Math.ceil(query.length / 2))
|
||||
const hits = items
|
||||
.map((item) => {
|
||||
const hay = tokens(item.text)
|
||||
const found = query.filter((term) => hay.includes(term)).length
|
||||
return { item, found }
|
||||
})
|
||||
.filter((item) => item.found >= needed)
|
||||
.sort((a, b) => b.found - a.found)
|
||||
return hits.at(0)?.item.id
|
||||
}
|
||||
|
||||
/** Model-claimed duplicates are verified against stored entries; unconfirmed claims are rescued as ops instead of lost. */
|
||||
export function verifySkips(input: { skipped: CaptureSkip[]; items: CaptureSourceItem[] }) {
|
||||
const skipped: CaptureSkip[] = []
|
||||
const rescued: MemoryOperations.Op[] = []
|
||||
for (const item of input.skipped) {
|
||||
if (item.reason !== "duplicate" || !item.text) {
|
||||
skipped.push(item)
|
||||
continue
|
||||
}
|
||||
const source = duplicate(item.text, input.items)
|
||||
if (source) {
|
||||
skipped.push({ ...item, duplicateOf: item.duplicateOf ?? source })
|
||||
continue
|
||||
}
|
||||
skipped.push({ reason: "unsupported", text: item.text })
|
||||
}
|
||||
return { skipped, rescued }
|
||||
}
|
||||
|
||||
export function duplicateOps(input: { ops: MemoryOperations.Op[]; skipped: CaptureSkip[]; items: CaptureSourceItem[] }) {
|
||||
const skipped = [...input.skipped]
|
||||
const ops = input.ops.filter((item) => {
|
||||
if (item.action !== "add") return true
|
||||
const rejected = MemoryOperations.reject(item)
|
||||
if (rejected) {
|
||||
skipped.push(rejected)
|
||||
return false
|
||||
}
|
||||
const source = duplicate(`${item.key} ${item.text}`, input.items)
|
||||
if (!source) return true
|
||||
skipped.push({ reason: "duplicate", text: item.text, duplicateOf: source })
|
||||
return false
|
||||
})
|
||||
return { ops, skipped }
|
||||
}
|
||||
|
||||
function attr(input: string | undefined) {
|
||||
if (!input) return ""
|
||||
return input
|
||||
.replaceAll(/\s+/g, "_")
|
||||
.replaceAll(/[^A-Za-z0-9_.:/=-]/g, "")
|
||||
.slice(0, 160)
|
||||
}
|
||||
|
||||
export function skipLine(input: CaptureSkip[]) {
|
||||
const item = input.at(0)
|
||||
if (!item) return ""
|
||||
const reason = attr(item.reason)
|
||||
const source = attr(item.duplicateOf)
|
||||
return [reason ? `reason=${reason}` : "", source ? `duplicateOf=${source}` : ""].filter(Boolean).join(" ")
|
||||
}
|
||||
|
||||
export function notice(input: {
|
||||
count: number
|
||||
ops: MemoryOperations.Op[]
|
||||
skipped: CaptureSkip[]
|
||||
tokens: number
|
||||
}): CaptureDetail | undefined {
|
||||
const references = MemoryShared.refs(input.ops)
|
||||
if (input.count > 0) {
|
||||
return {
|
||||
type: "saved",
|
||||
message: `Memory saved · ${references.join(", ") || `${input.count} ops`}`,
|
||||
tokens: input.tokens,
|
||||
operationCount: input.count,
|
||||
sources: references,
|
||||
files: MemoryShared.files(input.ops),
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "skipped",
|
||||
message: "Memory checked · no new items",
|
||||
tokens: input.tokens,
|
||||
operationCount: 0,
|
||||
skippedCount: input.skipped.length,
|
||||
sources: references,
|
||||
files: MemoryShared.files(input.ops),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import z from "zod"
|
||||
import { MemoryOperations } from "./ops"
|
||||
import digest from "../prompts/session-digest.txt"
|
||||
import typed from "../prompts/typed-consolidation.txt"
|
||||
|
||||
export const typedPrompt = typed
|
||||
export const digestPrompt = digest
|
||||
|
||||
const skip = z
|
||||
.enum([
|
||||
"duplicate",
|
||||
"transient",
|
||||
"unsupported",
|
||||
"secret",
|
||||
"too_specific",
|
||||
"in_progress",
|
||||
"policy_belongs_in_docs",
|
||||
"out_of_scope",
|
||||
"self_referential",
|
||||
"quota_guard",
|
||||
"rate_limit_guard",
|
||||
])
|
||||
.catch("unsupported")
|
||||
|
||||
const key = z.string().trim().min(1).max(80)
|
||||
const value = z.string().trim().min(1).max(2_000)
|
||||
const addSchema = (
|
||||
op: "upsert_project_fact" | "upsert_project_decision" | "upsert_project_constraint" | "append_correction",
|
||||
) =>
|
||||
z.object({ op: z.literal(op), key, value }).strict()
|
||||
|
||||
export const typedSchema = z
|
||||
.object({
|
||||
operations: z
|
||||
.array(
|
||||
z.discriminatedUnion("op", [
|
||||
addSchema("upsert_project_fact"),
|
||||
addSchema("upsert_project_decision"),
|
||||
addSchema("upsert_project_constraint"),
|
||||
addSchema("append_correction"),
|
||||
z
|
||||
.object({
|
||||
op: z.literal("upsert_environment_fact"),
|
||||
key,
|
||||
value,
|
||||
section: z.enum(["Commands", "Paths", "Tooling", "commands", "paths", "tooling"]),
|
||||
})
|
||||
.strict(),
|
||||
z.object({ op: z.literal("remove_memory"), query: z.string().trim().min(1).max(240) }).strict(),
|
||||
z
|
||||
.object({
|
||||
op: z.literal("noop"),
|
||||
key: z.string().max(80).optional(),
|
||||
value: z.string().max(2_000).optional(),
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
)
|
||||
.max(16),
|
||||
skipped: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
reason: skip,
|
||||
text: z.string().max(500).optional(),
|
||||
duplicateOf: z.string().max(240).optional(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.max(32)
|
||||
.default([]),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const digestSchema = z
|
||||
.object({
|
||||
topic: z.string().max(160).default(""),
|
||||
summary: z.string().max(4_000).default(""),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type CaptureSkip = z.infer<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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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}.`)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { MemoryFiles } from "./storage/store"
|
||||
import { MemoryIndexer } from "./recall/indexer"
|
||||
import { MemoryNotice } from "./memory-notice"
|
||||
import { MemoryOperations } from "./capture/ops"
|
||||
import { MemoryPaths } from "./storage/paths"
|
||||
import { MemoryRecall } from "./recall/recall"
|
||||
import { MemorySchema } from "./schema"
|
||||
import { MemoryShared } from "./recall/shared"
|
||||
import { MemoryToken } from "./recall/token"
|
||||
import { MemorySlug } from "./slug"
|
||||
|
||||
/** Root-bound package facade. External Kilo surfaces should derive root from workspace context first. */
|
||||
export namespace Memory {
|
||||
export type Block = {
|
||||
scope: "project"
|
||||
text: string
|
||||
bytes: number
|
||||
estimatedTokens: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type Trigger = "explicit" | "turn-close" | "rebuild"
|
||||
|
||||
export type Apply = {
|
||||
root: string
|
||||
state: MemorySchema.State
|
||||
result: MemoryOperations.Result
|
||||
ok: boolean
|
||||
detail?: {
|
||||
type: "saved"
|
||||
message: string
|
||||
operationCount: number
|
||||
sources: string[]
|
||||
files: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function key(text: string) {
|
||||
const slug = MemorySlug.safe(text, { max: MemorySlug.max.record, fallback: "", lower: true })
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.slice(0, MemorySlug.max.parts)
|
||||
.join("_")
|
||||
return slug || MemorySlug.hash(text, "memory")
|
||||
}
|
||||
|
||||
async function injected(input: {
|
||||
root: string
|
||||
index: MemoryIndexer.Result
|
||||
sessionID?: string
|
||||
}) {
|
||||
return MemoryFiles.queue(input.root, async () => {
|
||||
const state = await MemoryFiles.readState(input.root)
|
||||
const next = {
|
||||
...state,
|
||||
stats: {
|
||||
...state.stats,
|
||||
lastInjectedAt: Date.now(),
|
||||
lastInjectedBytes: input.index.bytes,
|
||||
lastInjectedTokens: input.index.tokens,
|
||||
lastInjectedSessionID: input.sessionID ?? null,
|
||||
},
|
||||
}
|
||||
await MemoryFiles.writeState(input.root, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export async function status(input: { root: string }) {
|
||||
const state = await MemoryFiles.readState(input.root)
|
||||
const paths = MemoryPaths.files(input.root)
|
||||
const index = await MemoryFiles.readIndex(input.root)
|
||||
return {
|
||||
root: input.root,
|
||||
state,
|
||||
exists: {
|
||||
state: await MemoryFiles.exists(paths.state),
|
||||
index: await MemoryFiles.exists(paths.index),
|
||||
},
|
||||
index: {
|
||||
bytes: Buffer.byteLength(index),
|
||||
estimatedTokens: MemoryToken.estimate(index),
|
||||
preview: index,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function enable(input: { root: string; id?: MemoryPaths.Identity }) {
|
||||
return MemoryFiles.queue(input.root, async () => {
|
||||
const state = await MemoryFiles.scaffold(input.root, input.id)
|
||||
const index = await MemoryIndexer.rebuild({ root: input.root, state })
|
||||
return { root: input.root, state, index }
|
||||
})
|
||||
}
|
||||
|
||||
export async function disable(input: { root: string }) {
|
||||
return MemoryFiles.queue(input.root, async () => {
|
||||
const state = await MemoryFiles.readState(input.root)
|
||||
const next = { ...state, enabled: false }
|
||||
await MemoryFiles.writeState(input.root, next)
|
||||
await MemoryFiles.append(input.root, `disable ${next.scope} source=command`)
|
||||
return { root: input.root, state: next }
|
||||
})
|
||||
}
|
||||
|
||||
export async function show(input: { root: string }) {
|
||||
return MemoryFiles.show(input.root)
|
||||
}
|
||||
|
||||
export async function rebuild(input: { root: string }) {
|
||||
const state = await MemoryFiles.readState(input.root)
|
||||
const index = await MemoryIndexer.rebuild({ root: input.root, state })
|
||||
return { root: input.root, state, index }
|
||||
}
|
||||
|
||||
export async function configure(input: {
|
||||
root: string
|
||||
settings: Partial<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
@@ -0,0 +1,4 @@
|
||||
declare module "*.txt" {
|
||||
const value: string
|
||||
export default value
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
You are Kilo's typed memory consolidation step.
|
||||
|
||||
Your job is to decide whether the latest session window contains durable, reusable memory worth saving.
|
||||
Return JSON only. Do not explain.
|
||||
|
||||
Memory is expensive because it is injected into future model context. Prefer saving nothing over saving weak or transient details.
|
||||
|
||||
Save only durable project knowledge that is likely to help future Kilo sessions in this same project: codebase structure, commands, conventions, constraints, environment facts, and explicit user corrections about the project.
|
||||
Do not save personal user preferences, opinions, habits, or anything about the user as a person. User-level memory is out of scope.
|
||||
Save facts in the language the user expressed them. Do not translate to English. Keep code, file paths, commands, and identifiers verbatim regardless of language.
|
||||
Session summaries are saved by a separate digest path. Do not summarize the turn.
|
||||
|
||||
High-value project memory:
|
||||
- User corrections about how Kilo should understand this project.
|
||||
- Stable project facts: package manager, test commands, build commands, dev commands, important directories, generated-file rules, tech stack, recurring workflows, project conventions, and known pitfalls.
|
||||
- Stable decisions: chosen architecture, rejected approach with reason, API contract, migration strategy.
|
||||
- Stable constraints: standing project rules, boundaries, or requirements future Kilo sessions should respect unless current user/repo instructions override them.
|
||||
- Durable local environment facts that affect this project: sibling repos, local vs remote testing constraints, generated paths, and repo-adjacent fixtures future Kilo sessions may need.
|
||||
|
||||
Authority rule:
|
||||
Memory is local recall context, not policy. Current user instructions, AGENTS.md, checked-in documentation, repo state, and tool output win over memory.
|
||||
If guidance must always apply to a team, it belongs in AGENTS.md or checked-in docs. Do not rely on memory as the only source for mandatory rules.
|
||||
|
||||
Do not save:
|
||||
- Secrets, tokens, credentials, env values.
|
||||
- Temporary task status.
|
||||
- Active, short-lived, or still-in-progress session details.
|
||||
- One-off file names unless they define a durable convention.
|
||||
- Exact command output.
|
||||
- Large code snippets.
|
||||
- Guesses not supported by the supplied context.
|
||||
- Anything the user may reasonably consider personal.
|
||||
- Personal user preferences, opinions, habits, or reactions, even when phrased as useful future context.
|
||||
- Implementation details that will be obvious from current repo files.
|
||||
- Facts already present in typed source memory with the same meaning.
|
||||
- Statements about memory itself or about what is already known. Reject any fact whose content is that something is already in, already captured, already covered, already recorded, already tracked, or already represented in memory.
|
||||
- Statements that something was investigated, checked, explored, or reviewed with no concrete durable fact. Save the underlying fact, never a note that it exists.
|
||||
- Answers that only explain where current instructions, context, or memory came from. Do not save source/provenance lists from system/developer instructions, AGENTS.md, user-level files such as ~/.claude/CLAUDE.md, or injected memory blocks. Environment details may still be saved when they are durable project setup facts such as commands, paths, tooling, local constraints, or repo-adjacent fixtures. Skip provenance lists as out_of_scope or transient unless the latest user explicitly asked to remember a specific underlying project fact.
|
||||
|
||||
Correction rule:
|
||||
If the user says existing memory is wrong, stale, or should be forgotten, prioritize a correction or removal. Corrections are more important than new facts.
|
||||
|
||||
Conflict rule:
|
||||
If supplied memory conflicts with the current user/repo context, prefer the current user/repo context and output a correction or removal.
|
||||
|
||||
Session context rule:
|
||||
Recent session digests and latest-session context are continuity hints, not durable typed source memory. Do not skip a durable project fact as duplicate merely because it appears in recent session context.
|
||||
If a durable fact appears in multiple recent session digests and is absent from typed source memory, promote it to typed memory.
|
||||
|
||||
Output schema:
|
||||
{
|
||||
"operations": [
|
||||
{
|
||||
"op": "upsert_project_fact" | "upsert_project_decision" | "upsert_project_constraint" | "upsert_environment_fact" | "append_correction" | "remove_memory" | "noop",
|
||||
"key": "stable_key_when_required",
|
||||
"value": "short durable value when required",
|
||||
"query": "forget query when op is remove_memory",
|
||||
"section": "Commands" | "Paths" | "Tooling"
|
||||
}
|
||||
],
|
||||
"skipped": [
|
||||
{
|
||||
"reason": "duplicate" | "transient" | "unsupported" | "secret" | "too_specific" | "in_progress" | "policy_belongs_in_docs" | "out_of_scope" | "self_referential" | "quota_guard" | "rate_limit_guard",
|
||||
"text": "short description",
|
||||
"duplicateOf": "source.md:key when the duplicate source is known"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Return at most 16 operations.
|
||||
- Return {"operations":[],"skipped":[]} when there is nothing worth saving.
|
||||
- Each key must be lowercase with dots or underscores.
|
||||
- Each value must be one concise sentence or phrase.
|
||||
- Use skipped reason "in_progress" for active or short-lived session details.
|
||||
- Use skipped reason "policy_belongs_in_docs" when a mandatory team rule should be in AGENTS.md or checked-in docs instead of only memory.
|
||||
- Use skipped reason "out_of_scope" for personal user-level content that is not project knowledge.
|
||||
- Use skipped reason "self_referential" for statements about memory itself or about facts already being captured.
|
||||
- Use skipped reason "quota_guard" or "rate_limit_guard" if the evidence says memory generation should avoid spending limited quota.
|
||||
- For upsert_environment_fact, use section "Commands" for runnable commands, "Paths" for important directories/files, and "Tooling" for package managers, runtimes, build systems, or test frameworks.
|
||||
- Omit section for other operations.
|
||||
- Do not include markdown.
|
||||
- Do not include commentary outside JSON.
|
||||
|
||||
Examples:
|
||||
- User says tests run from packages/opencode, not repo root: output append_correction with key "test_command".
|
||||
- Assistant establishes that project memory is project-only and stored under the global repo memory folder: output upsert_project_decision.
|
||||
- User or assistant establishes a standing project requirement such as "project memory should stay project-only": output upsert_project_constraint.
|
||||
- Assistant lists setup commands such as bun install or bun run dev: output upsert_environment_fact with section "Commands".
|
||||
- Assistant identifies important local paths: output upsert_environment_fact with section "Paths".
|
||||
- Assistant identifies durable tools such as Bun, Turbo, or Java 21: output upsert_environment_fact with section "Tooling".
|
||||
- Assistant only says it checked git status or continued a task: output no operations.
|
||||
@@ -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}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { MemoryDigest } from "../capture/digest"
|
||||
import { MemoryFiles } from "../storage/store"
|
||||
import { MemoryIndexer } from "./indexer"
|
||||
import { MemorySchema } from "../schema"
|
||||
import { MemoryShared } from "./shared"
|
||||
import { MemoryTopics } from "./topics"
|
||||
import { MemoryToken } from "./token"
|
||||
import { MemorySlug } from "../slug"
|
||||
|
||||
export namespace MemoryRecall {
|
||||
export type Mode = "search" | "typed" | "digest"
|
||||
|
||||
export type Hit = {
|
||||
type: "typed" | "digest"
|
||||
kind: string
|
||||
source: string
|
||||
text: string
|
||||
score: number
|
||||
topics?: MemorySchema.Topic[]
|
||||
current?: boolean
|
||||
updatedAt?: number
|
||||
id?: string
|
||||
time?: string
|
||||
}
|
||||
|
||||
export type Result = {
|
||||
block: string
|
||||
hits: Hit[]
|
||||
bytes: number
|
||||
tokens: number
|
||||
}
|
||||
|
||||
function has(input: string, term: string) {
|
||||
return MemoryShared.terms(input).includes(term)
|
||||
}
|
||||
|
||||
function typed(input: {
|
||||
file: MemorySchema.Source
|
||||
text: string
|
||||
max: number
|
||||
inventory: MemoryFiles.Inventory
|
||||
now: number
|
||||
}) {
|
||||
return MemoryShared.typed(input).map(
|
||||
(item) =>
|
||||
({
|
||||
type: "typed",
|
||||
kind: MemorySchema.recordKind(item.file, item.section),
|
||||
source: item.file,
|
||||
text: `${item.key} :: ${item.text}`,
|
||||
score: 0,
|
||||
topics: item.topics,
|
||||
current: true,
|
||||
updatedAt: item.updatedAt,
|
||||
}) satisfies Hit,
|
||||
)
|
||||
}
|
||||
|
||||
async function typedAll(input: {
|
||||
root: string
|
||||
state: MemorySchema.State
|
||||
inventory: MemoryFiles.Inventory
|
||||
now: number
|
||||
}) {
|
||||
const rows = await Promise.all(
|
||||
MemorySchema.Sources.map(async (file) =>
|
||||
typed({
|
||||
file,
|
||||
text: await MemoryFiles.readSource(input.root, file),
|
||||
max: input.state.limits.maxLineChars,
|
||||
inventory: input.inventory,
|
||||
now: input.now,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return rows.flat()
|
||||
}
|
||||
|
||||
function time(input: string | undefined) {
|
||||
if (!input) return
|
||||
const value = Date.parse(input)
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function digest(input: { file: string; id: string; time: string; topic: string; summary: string }): Hit {
|
||||
return {
|
||||
type: "digest",
|
||||
kind: "SESSION_DIGEST",
|
||||
source: input.file,
|
||||
text: `session=${input.id} topic="${input.topic.replaceAll('"', "'")}" ${input.time} :: ${input.summary}`,
|
||||
score: 0,
|
||||
topics: [],
|
||||
current: true,
|
||||
updatedAt: time(input.time),
|
||||
id: input.id,
|
||||
time: input.time,
|
||||
}
|
||||
}
|
||||
|
||||
async function digests(input: {
|
||||
root: string
|
||||
state: MemorySchema.State
|
||||
mode: Mode
|
||||
limit: number
|
||||
sessionID?: string
|
||||
currentSessionID?: string
|
||||
}) {
|
||||
if (input.mode === "typed") return [] as Hit[]
|
||||
if (input.sessionID) {
|
||||
if (input.sessionID === input.currentSessionID) return [] as Hit[]
|
||||
const item = await MemoryFiles.readSession(input.root, {
|
||||
sessionID: input.sessionID,
|
||||
max: input.state.limits.maxSessionLineChars,
|
||||
})
|
||||
if (!item || MemoryDigest.empty(item)) return [] as Hit[]
|
||||
return [digest(item)]
|
||||
}
|
||||
const items = await MemoryFiles.recentSessions(
|
||||
input.root,
|
||||
input.state.limits.maxSessionFiles,
|
||||
input.state.limits.maxSessionLineChars,
|
||||
)
|
||||
return items
|
||||
.filter((item) => item.id !== input.currentSessionID && !MemoryDigest.empty(item))
|
||||
.map(digest)
|
||||
}
|
||||
|
||||
function score(input: { hit: Hit; keys: string[] }) {
|
||||
const body = `${input.hit.kind} ${input.hit.source} ${input.hit.text}`
|
||||
return input.keys.reduce((sum, term) => sum + (has(body, term) ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
function fresh(input: Hit) {
|
||||
return input.updatedAt ?? 0
|
||||
}
|
||||
|
||||
function compare(a: Hit, b: Hit) {
|
||||
return (
|
||||
b.score - a.score ||
|
||||
fresh(b) - fresh(a) ||
|
||||
(a.type === b.type ? `${a.source}:${a.text}`.localeCompare(`${b.source}:${b.text}`) : a.type === "typed" ? -1 : 1)
|
||||
)
|
||||
}
|
||||
|
||||
function overlap(a: string, b: string) {
|
||||
const right = MemoryShared.terms(b)
|
||||
return MemoryShared.terms(a).filter((term) => right.includes(term)).length
|
||||
}
|
||||
|
||||
function session(input: Hit) {
|
||||
return input.type === "digest"
|
||||
}
|
||||
|
||||
function label(input: string) {
|
||||
return MemorySlug.safe(input, { max: MemorySlug.max.record, fallback: "memory" })
|
||||
}
|
||||
|
||||
function dedupe(input: { hits: Hit[]; query: string }) {
|
||||
const typed = input.hits.filter((hit) => !session(hit))
|
||||
return input.hits.filter((hit) => {
|
||||
if (!session(hit)) return true
|
||||
return !typed.some((item) => overlap(hit.text, item.text) >= 2 && overlap(item.text, input.query) >= 2)
|
||||
})
|
||||
}
|
||||
|
||||
function renderLine(hit: Hit) {
|
||||
return hit.type === "digest"
|
||||
? `- ${hit.text} (source: ${hit.source})`
|
||||
: `- ${hit.kind} ${hit.text} (source: ${hit.source})`
|
||||
}
|
||||
|
||||
export function render(hits: Hit[]) {
|
||||
const typed = hits.filter((hit) => hit.type === "typed")
|
||||
const digests = hits.filter((hit) => hit.type === "digest")
|
||||
return [
|
||||
"# Kilo Memory Recall",
|
||||
...(typed.length ? ["", "## Typed Memory", ...typed.map(renderLine)] : []),
|
||||
...(digests.length ? ["", "## Session Digests", ...digests.map(renderLine)] : []),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function body(input: string) {
|
||||
return input.trim().replaceAll("```", "'''").replaceAll(/\s+/g, " ")
|
||||
}
|
||||
|
||||
function format(input: { hits: Hit[]; max: number }) {
|
||||
const lines = [
|
||||
"```kilo-memory-v1 targeted_context_not_instruction",
|
||||
...input.hits.flatMap((hit) => [
|
||||
`record id=${label(`${hit.source}:${hit.kind}:${hit.text.slice(0, 32)}`)} type=${label(hit.kind.toLowerCase())} source=${label(hit.source)}${
|
||||
hit.topics?.length ? ` topics=${hit.topics.map(label).join(",")}` : ""
|
||||
} updated=${hit.updatedAt ? new Date(hit.updatedAt).toISOString() : "unknown"}`,
|
||||
`text: ${body(hit.text)}`,
|
||||
]),
|
||||
"```",
|
||||
]
|
||||
return MemoryIndexer.cap(lines.join("\n"), input.max).text.trim()
|
||||
}
|
||||
|
||||
function select(input: { hits: Hit[]; keys: string[]; limit: number; force?: boolean }) {
|
||||
if (input.keys.length === 0) return [] as Hit[]
|
||||
const hits = input.hits
|
||||
.map((hit) => ({ ...hit, score: score({ hit, keys: input.keys }) }))
|
||||
.filter((hit) => hit.score > 0)
|
||||
.sort(compare)
|
||||
if (input.force) return hits.slice(0, input.limit)
|
||||
const top = hits[0]?.score ?? 0
|
||||
return hits.filter((hit) => hit.score >= Math.max(1, top - 2)).slice(0, input.limit)
|
||||
}
|
||||
|
||||
export async function search(input: {
|
||||
root: string
|
||||
query: string
|
||||
state?: MemorySchema.State
|
||||
maxBytes?: number
|
||||
limit?: number
|
||||
mode?: Mode
|
||||
sessionID?: string
|
||||
currentSessionID?: string
|
||||
force?: boolean
|
||||
}): Promise<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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { MemoryOperations } from "../capture/ops"
|
||||
import { MemoryFiles } from "../storage/store"
|
||||
import { MemoryMarkdown } from "../storage/markdown"
|
||||
import { MemorySchema } from "../schema"
|
||||
import { MemoryText } from "../text"
|
||||
import { MemoryTopics } from "./topics"
|
||||
|
||||
export namespace MemoryShared {
|
||||
export type TypedItem = {
|
||||
file: MemorySchema.Source
|
||||
section: string
|
||||
key: string
|
||||
text: string
|
||||
topics: MemorySchema.Topic[]
|
||||
terms: string[]
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
export type SourceItem = {
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export const brief = MemoryText.brief
|
||||
|
||||
export function entry(input: string) {
|
||||
const idx = input.indexOf(" :: ")
|
||||
if (idx < 0) return
|
||||
const key = input.slice(0, idx).trim()
|
||||
const text = input.slice(idx + 4).trim()
|
||||
if (!key || !text) return
|
||||
return { key, text }
|
||||
}
|
||||
|
||||
export function terms(input: string) {
|
||||
return MemoryTopics.words(input)
|
||||
}
|
||||
|
||||
export function source(input: { file: MemorySchema.Source; text: string }): SourceItem[] {
|
||||
const result: SourceItem[] = []
|
||||
for (const raw of input.text.split("\n")) {
|
||||
const item = entry(raw.trim().replace(/^- /, ""))
|
||||
if (!item) continue
|
||||
result.push({ id: `${input.file}:${item.key}`, text: `${item.key} ${item.text}` })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function typed(input: {
|
||||
file: MemorySchema.Source
|
||||
text: string
|
||||
max: number
|
||||
inventory: MemoryFiles.Inventory
|
||||
}) {
|
||||
return MemoryMarkdown.parse(input.text).map((item) => {
|
||||
const id = MemoryFiles.inventoryKey({ file: input.file, section: item.section, key: item.key })
|
||||
const inv = input.inventory.items[id]
|
||||
const data = { file: input.file, section: item.section, key: item.key, text: item.text }
|
||||
return {
|
||||
file: input.file,
|
||||
section: item.section,
|
||||
key: item.key,
|
||||
text: brief(item.text, input.max),
|
||||
topics: inv?.topics?.length ? inv.topics : MemoryTopics.assign(data),
|
||||
terms: inv?.terms?.length ? inv.terms : MemoryTopics.terms(data),
|
||||
updatedAt: inv?.updatedAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function refs(ops: MemoryOperations.Op[]) {
|
||||
return [
|
||||
...new Set(
|
||||
ops.flatMap((item) => {
|
||||
if (item.action !== "add" || !item.file) return []
|
||||
return [`${item.file}:${item.key}`]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function files(ops: MemoryOperations.Op[]) {
|
||||
return [
|
||||
...new Set(
|
||||
ops.flatMap((item) => {
|
||||
if (item.action !== "add" || !item.file) return []
|
||||
return [item.file]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function audit(ops: MemoryOperations.Op[]) {
|
||||
return ops.map((item) =>
|
||||
item.action === "add"
|
||||
? {
|
||||
action: item.action,
|
||||
file: item.file,
|
||||
section: item.section,
|
||||
key: item.key,
|
||||
}
|
||||
: {
|
||||
action: item.action,
|
||||
query: brief(item.query, 120),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export namespace MemoryToken {
|
||||
const chars = 4
|
||||
|
||||
export function estimate(input: string) {
|
||||
return Math.max(0, Math.round((input || "").length / chars))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MemorySchema } from "../schema"
|
||||
|
||||
export namespace MemoryTopics {
|
||||
export type Input = {
|
||||
file?: MemorySchema.Source
|
||||
section?: string
|
||||
key?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const limit = {
|
||||
topics: 3,
|
||||
terms: 6,
|
||||
expanded: 24,
|
||||
}
|
||||
const matcher = /[\p{L}\p{N}][\p{L}\p{N}_.-]{1,}/gu
|
||||
|
||||
function uniq(input: MemorySchema.Topic[]): MemorySchema.Topic[] {
|
||||
return [...new Set(input)].slice(0, limit.topics)
|
||||
}
|
||||
|
||||
function section(input: string | undefined) {
|
||||
return input?.trim().toLowerCase() ?? ""
|
||||
}
|
||||
|
||||
export function assign(input: Input): MemorySchema.Topic[] {
|
||||
if (input.file === "corrections.md") return ["corrections"]
|
||||
if (input.file === "environment.md") return ["environment"]
|
||||
const name = section(input.section)
|
||||
if (name.includes("constraint")) return ["constraints"]
|
||||
if (name.includes("decision")) return ["project"]
|
||||
if (input.file === "project.md") return ["project"]
|
||||
return ["project"]
|
||||
}
|
||||
|
||||
export function words(input: string, max?: number) {
|
||||
const found =
|
||||
input
|
||||
.toLowerCase()
|
||||
// NFKC folds compatibility variants, such as full-width letters, before lexical recall matching.
|
||||
.normalize("NFKC")
|
||||
.match(matcher)
|
||||
?.map((item) => item.replaceAll(/[_.-]+/g, "_")) ?? []
|
||||
const result = [...new Set(found)]
|
||||
return max === undefined ? result : result.slice(0, max)
|
||||
}
|
||||
|
||||
export function terms(input: Input, max = limit.terms) {
|
||||
return words([input.key ?? "", input.text].join(" "), max)
|
||||
}
|
||||
|
||||
export function expand(input: string[], max = limit.expanded) {
|
||||
return [...new Set(input)].slice(0, max)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { appendFile, chmod } from "fs/promises"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { MemoryFs } from "./fs"
|
||||
import { MemoryPaths } from "./paths"
|
||||
import { MemoryRedact } from "../capture/redact"
|
||||
|
||||
export namespace MemoryAudit {
|
||||
const MAX_LOG = 128_000
|
||||
const LOG_MARGIN = 16_000
|
||||
const Log = z
|
||||
.object({
|
||||
kind: z.literal("log"),
|
||||
summary: z.string(),
|
||||
time: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export type Decision =
|
||||
| {
|
||||
kind: "log"
|
||||
result: "logged"
|
||||
summary: string
|
||||
}
|
||||
| {
|
||||
sessionID?: string
|
||||
kind: "digest" | "typed" | "recall"
|
||||
result: "saved" | "skipped" | "fallback" | "error" | "recalled"
|
||||
trigger?: "explicit" | "turn-close" | "targeted-recall" | "rebuild"
|
||||
llm?: boolean
|
||||
parsed?: boolean
|
||||
fallback?: boolean
|
||||
reason?: string
|
||||
tokens?: number
|
||||
operationCount?: number
|
||||
skippedCount?: number
|
||||
fallbackOperationCount?: number
|
||||
query?: string
|
||||
topics?: string[]
|
||||
files?: string[]
|
||||
summary?: string
|
||||
skipped?: { reason: string; text?: string; duplicateOf?: string }[]
|
||||
operations?: {
|
||||
action: "add" | "remove"
|
||||
file?: string
|
||||
section?: string
|
||||
key?: string
|
||||
query?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
function cap(input: string) {
|
||||
if (Buffer.byteLength(input) <= MAX_LOG) return input
|
||||
const lines = input.split("\n").reverse()
|
||||
const kept: string[] = []
|
||||
lines.reduce((sum, line) => {
|
||||
if (sum >= MAX_LOG) return sum
|
||||
kept.push(line)
|
||||
return sum + Buffer.byteLength(`${line}\n`)
|
||||
}, 0)
|
||||
return kept.reverse().join("\n")
|
||||
}
|
||||
|
||||
async function line(file: string, text: string) {
|
||||
await MemoryFs.dir(path.dirname(file))
|
||||
const info = await MemoryFs.guard(file)
|
||||
if (info && !info.isFile()) throw new Error(`memory path is not a file: ${file}`)
|
||||
await appendFile(file, text, { mode: MemoryFs.FILE })
|
||||
await chmod(file, MemoryFs.FILE).catch((error: unknown) => {
|
||||
if (process.platform === "win32") return
|
||||
throw error
|
||||
})
|
||||
const next = await MemoryFs.guard(file)
|
||||
if (!next?.isFile()) throw new Error(`memory path is not a file: ${file}`)
|
||||
if (next.size <= MAX_LOG + LOG_MARGIN) return
|
||||
await MemoryFs.write(file, cap((await MemoryFs.read(file)) ?? ""))
|
||||
}
|
||||
|
||||
async function audit(root: string, input: Decision) {
|
||||
const data = MemoryRedact.value(input) as Decision
|
||||
await MemoryFs.queue(root, () =>
|
||||
line(
|
||||
MemoryPaths.files(root).decisions,
|
||||
`${JSON.stringify({
|
||||
v: 1,
|
||||
time: new Date().toISOString(),
|
||||
...data,
|
||||
})}\n`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function append(root: string, text: string) {
|
||||
await audit(root, { kind: "log", result: "logged", summary: text })
|
||||
}
|
||||
|
||||
export async function decide(root: string, input: Decision) {
|
||||
await audit(root, input)
|
||||
}
|
||||
|
||||
export async function readDecisions(root: string) {
|
||||
return MemoryFs.read(MemoryPaths.files(root).decisions)
|
||||
.then((text) => text ?? "")
|
||||
.catch((error: unknown) => {
|
||||
if (MemoryFs.miss(error)) return ""
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
function record(input: string) {
|
||||
try {
|
||||
const data = JSON.parse(input)
|
||||
const parsed = Log.safeParse(data)
|
||||
return parsed.success ? parsed.data : undefined
|
||||
} catch (error) {
|
||||
if (MemoryFs.parse(error)) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function readChanges(root: string) {
|
||||
const lines = (await readDecisions(root))
|
||||
.split("\n")
|
||||
.flatMap((line) => {
|
||||
const data = record(line)
|
||||
if (!data) return []
|
||||
const time = data.time ?? ""
|
||||
return [`${time} ${data.summary}`.trim()]
|
||||
})
|
||||
return lines.join("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { AsyncLocalStorage } from "async_hooks"
|
||||
import { chmod, lstat, mkdir, readFile, rename, rm, stat as follow, utimes, writeFile } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
export namespace MemoryFs {
|
||||
const locks = new Map<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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { readFileSync, realpathSync, statSync } from "fs"
|
||||
import { createHash } from "crypto"
|
||||
import path from "path"
|
||||
import type { MemorySchema } from "../schema"
|
||||
import { MemorySlug } from "../slug"
|
||||
|
||||
export namespace MemoryPaths {
|
||||
export type Ctx = {
|
||||
directory: string
|
||||
worktree: string
|
||||
}
|
||||
|
||||
export type Files = {
|
||||
root: string
|
||||
state: string
|
||||
index: string
|
||||
manifest: string
|
||||
project: string
|
||||
environment: string
|
||||
corrections: string
|
||||
sessions: string
|
||||
decisions: string
|
||||
ignore: string
|
||||
}
|
||||
|
||||
export type Identity = {
|
||||
display: string
|
||||
canonical: string
|
||||
folder: string
|
||||
}
|
||||
|
||||
export type Host = {
|
||||
home: string
|
||||
config: string
|
||||
}
|
||||
|
||||
function base(ctx: Ctx) {
|
||||
return ctx.worktree === "/" ? ctx.directory : ctx.worktree
|
||||
}
|
||||
|
||||
function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false })
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
try {
|
||||
return readFileSync(file, "utf8").trim()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function checkout(dir: string) {
|
||||
return path.basename(dir) === ".git" ? path.dirname(dir) : undefined
|
||||
}
|
||||
|
||||
function common(dir: string) {
|
||||
const text = read(path.join(dir, "commondir"))
|
||||
return text ? path.resolve(dir, text) : dir
|
||||
}
|
||||
|
||||
function project(dir: string) {
|
||||
const dot = path.join(dir, ".git")
|
||||
const info = stat(dot)
|
||||
if (!info) return dir
|
||||
if (info.isDirectory()) return checkout(common(dot)) ?? dir
|
||||
if (!info.isFile()) return dir
|
||||
const text = read(dot)
|
||||
const match = text?.match(/^gitdir:\s*(.+)$/m)
|
||||
if (!match?.[1]) return dir
|
||||
const git = path.resolve(dir, match[1])
|
||||
return checkout(common(git)) ?? dir
|
||||
}
|
||||
|
||||
function canon(dir: string) {
|
||||
const resolved = path.resolve(dir)
|
||||
try {
|
||||
return realpathSync(resolved)
|
||||
} catch {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
export function identity(input: { ctx: Ctx }): Identity {
|
||||
const root = canon(project(base(input.ctx)))
|
||||
const display = MemorySlug.safe(path.basename(root), { max: MemorySlug.max.label, fallback: "project" })
|
||||
const hash = createHash("sha1").update(root).digest("hex").slice(0, 12)
|
||||
return {
|
||||
display,
|
||||
canonical: root,
|
||||
folder: `${display}-${hash}`,
|
||||
}
|
||||
}
|
||||
|
||||
function global(input: Host) {
|
||||
const dir = path.resolve(input.config)
|
||||
if (path.basename(dir) === ".kilo") return dir
|
||||
return path.join(input.home, ".kilo")
|
||||
}
|
||||
|
||||
export function root(input: { ctx: Ctx } & Host) {
|
||||
return path.join(global(input), "memory", identity(input).folder)
|
||||
}
|
||||
|
||||
export function files(root: string): Files {
|
||||
return {
|
||||
root,
|
||||
state: path.join(root, "state.json"),
|
||||
index: path.join(root, "index.kmem"),
|
||||
manifest: path.join(root, "manifest.json"),
|
||||
project: path.join(root, "project.md"),
|
||||
environment: path.join(root, "environment.md"),
|
||||
corrections: path.join(root, "corrections.md"),
|
||||
sessions: path.join(root, "sessions"),
|
||||
decisions: path.join(root, "decisions.jsonl"),
|
||||
ignore: path.join(root, ".gitignore"),
|
||||
}
|
||||
}
|
||||
|
||||
export function source(root: string, name: MemorySchema.Source) {
|
||||
const paths = files(root)
|
||||
if (name === "project.md") return paths.project
|
||||
if (name === "environment.md") return paths.environment
|
||||
return paths.corrections
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { readdir, unlink } from "fs/promises"
|
||||
import path from "path"
|
||||
import { MemoryFs } from "./fs"
|
||||
import { MemoryPaths } from "./paths"
|
||||
import { MemoryRedact } from "../capture/redact"
|
||||
import { MemorySlug } from "../slug"
|
||||
import { MemoryText } from "../text"
|
||||
|
||||
export namespace MemorySessions {
|
||||
type Digest = {
|
||||
file: string
|
||||
id: string
|
||||
time: string
|
||||
topic: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
function stamp(input: number) {
|
||||
return new Date(input).toISOString().replaceAll(":", "-")
|
||||
}
|
||||
|
||||
function session(file: string, content: string) {
|
||||
const header = content
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("# Session "))
|
||||
?.slice("# Session ".length)
|
||||
.trim()
|
||||
if (header) return header
|
||||
const idx = file.indexOf("_")
|
||||
return idx === -1 ? file.replace(/\.md$/, "") : file.slice(idx + 1).replace(/\.md$/, "")
|
||||
}
|
||||
|
||||
function topic(input: { summary: string; topic?: string }) {
|
||||
return MemoryText.brief(input.topic || input.summary.split(/[.;:]/)[0] || input.summary, 80)
|
||||
}
|
||||
|
||||
async function list(root: string) {
|
||||
const paths = MemoryPaths.files(root)
|
||||
const names = await readdir(paths.sessions).catch((error: unknown) => {
|
||||
if (MemoryFs.miss(error)) return [] as string[]
|
||||
throw error
|
||||
})
|
||||
return { paths, names }
|
||||
}
|
||||
|
||||
async function drop(file: string) {
|
||||
await unlink(file).catch((error: unknown) => {
|
||||
if (MemoryFs.miss(error)) return
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
function content(input: { id: string; topic: string; summary: string; time: number }) {
|
||||
return [
|
||||
`# Session ${input.id}`,
|
||||
"",
|
||||
"Version: 1",
|
||||
`Updated: ${new Date(input.time).toISOString()}`,
|
||||
`Topic: ${input.topic}`,
|
||||
"",
|
||||
"## Summary",
|
||||
input.summary,
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function draft(
|
||||
root: string,
|
||||
input: { sessionID: string; topic?: string; summary: string; max: number; time?: number },
|
||||
) {
|
||||
const paths = MemoryPaths.files(root)
|
||||
const id = MemorySlug.safe(input.sessionID, { max: MemorySlug.max.label, fallback: "session" })
|
||||
const time = input.time ?? Date.now()
|
||||
if (!Number.isFinite(time)) throw new RangeError("memory session time must be finite")
|
||||
const hash = MemorySlug.hash(input.sessionID, "id")
|
||||
const name = `${stamp(time)}_${id}_${hash}.md`
|
||||
const summary = MemoryText.brief(MemoryRedact.text(input.summary), input.max)
|
||||
const label = topic({ summary, topic: input.topic ? MemoryRedact.text(input.topic) : undefined })
|
||||
return {
|
||||
id: input.sessionID,
|
||||
name,
|
||||
file: path.join(paths.sessions, name),
|
||||
text: content({ id: input.sessionID, topic: label, summary, time }),
|
||||
}
|
||||
}
|
||||
|
||||
function parse(file: string, content: string, max: number): Digest | undefined {
|
||||
const lines = content.split("\n")
|
||||
const idx = lines.findIndex((line) => line.trim() === "## Summary")
|
||||
if (idx < 0) return
|
||||
const time =
|
||||
lines
|
||||
.find((line) => line.startsWith("Updated: "))
|
||||
?.slice("Updated: ".length)
|
||||
.trim() ?? file
|
||||
const label = lines
|
||||
.find((line) => line.startsWith("Topic: "))
|
||||
?.slice("Topic: ".length)
|
||||
.trim()
|
||||
const summary = MemoryText.brief(lines.slice(idx + 1).find((line) => line.trim()) ?? "", max)
|
||||
if (!summary) return
|
||||
return { file, id: session(file, content), time, topic: topic({ summary, topic: label }), summary }
|
||||
}
|
||||
|
||||
async function removePrior(root: string, id: string, keep: string) {
|
||||
const listed = await list(root)
|
||||
await Promise.all(
|
||||
listed.names.map(async (file) => {
|
||||
if (!file.endsWith(".md") || file === keep) return
|
||||
const content = await MemoryFs.read(path.join(listed.paths.sessions, file))
|
||||
if (!content || session(file, content) !== id) return
|
||||
await drop(path.join(listed.paths.sessions, file))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function writeSession(
|
||||
root: string,
|
||||
input: { sessionID: string; topic?: string; summary: string; max: number; time?: number },
|
||||
) {
|
||||
const paths = MemoryPaths.files(root)
|
||||
await MemoryFs.dir(paths.sessions)
|
||||
const next = draft(root, input)
|
||||
await MemoryFs.write(next.file, next.text)
|
||||
await removePrior(root, next.id, next.name)
|
||||
return next.file
|
||||
}
|
||||
|
||||
export async function readSession(root: string, input: { sessionID: string; max: number }) {
|
||||
const listed = await list(root)
|
||||
return listed.names
|
||||
.filter((item) => item.endsWith(".md"))
|
||||
.sort()
|
||||
.reverse()
|
||||
.reduce(async (prior, file) => {
|
||||
const current = await prior
|
||||
if (current) return current
|
||||
const content = await MemoryFs.read(path.join(listed.paths.sessions, file))
|
||||
if (!content) return
|
||||
const item = parse(file, content, input.max)
|
||||
if (item?.id !== input.sessionID) return
|
||||
return item
|
||||
}, Promise.resolve(undefined as Digest | undefined))
|
||||
}
|
||||
|
||||
export async function pruneSessions(root: string, max: number) {
|
||||
const listed = await list(root)
|
||||
const keep = Math.max(0, max)
|
||||
await Promise.all(
|
||||
listed.names
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(keep)
|
||||
.map((file) => drop(path.join(listed.paths.sessions, file))),
|
||||
)
|
||||
}
|
||||
|
||||
export async function recentSessions(root: string, limit: number, max: number) {
|
||||
const listed = await list(root)
|
||||
const result: Digest[] = []
|
||||
for (const file of listed.names
|
||||
.filter((item) => item.endsWith(".md"))
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, limit)) {
|
||||
const content = await MemoryFs.read(path.join(listed.paths.sessions, file))
|
||||
if (!content) continue
|
||||
const item = parse(file, content, max)
|
||||
if (item) result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { readdir, rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { MemoryAudit } from "./audit"
|
||||
import { MemoryFs } from "./fs"
|
||||
import { MemoryMarkdown } from "./markdown"
|
||||
import { MemoryPaths } from "./paths"
|
||||
import { MemorySchema } from "../schema"
|
||||
import { MemorySources } from "./sources"
|
||||
import { MemoryText } from "../text"
|
||||
import { MemoryTopics } from "../recall/topics"
|
||||
|
||||
export namespace MemoryState {
|
||||
const seed: Record<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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
capturePlan,
|
||||
duplicateOps,
|
||||
fallbackDigest,
|
||||
guardReason,
|
||||
hasDurableDiff,
|
||||
mergeOps,
|
||||
notice,
|
||||
parseJson,
|
||||
parseOps,
|
||||
skipLine,
|
||||
summarizeDiffs,
|
||||
typedSchema,
|
||||
verifySkips,
|
||||
digestSchema,
|
||||
} from "../src/capture/capture"
|
||||
import { MemoryOperations } from "../src/capture/ops"
|
||||
import { MemoryRedact } from "../src/capture/redact"
|
||||
|
||||
describe("memory capture parsing", () => {
|
||||
test("parses fenced json text from model output", () => {
|
||||
const parsed = parseJson(digestSchema, '```json\n{"topic":"repo setup","summary":"Run package tests."}\n```')
|
||||
|
||||
expect(parsed).toEqual({ topic: "repo setup", summary: "Run package tests." })
|
||||
})
|
||||
|
||||
test("maps consolidation operation names into deterministic memory operations", () => {
|
||||
const parsed = parseJson(
|
||||
typedSchema,
|
||||
JSON.stringify({
|
||||
operations: [
|
||||
{ op: "upsert_project_fact", key: "repo_tests", value: "Run tests from packages/opencode." },
|
||||
{
|
||||
op: "upsert_project_decision",
|
||||
key: "file_store",
|
||||
value: "Keep memory v0 file-based before adding databases.",
|
||||
},
|
||||
{ op: "upsert_project_constraint", key: "zod_only", value: "The memory package stays zod-only." },
|
||||
{ op: "upsert_environment_fact", section: "tooling", key: "bun", value: "Use bun for package scripts." },
|
||||
{ op: "append_correction", key: "root_tests", value: "Do not run bun test from the repo root." },
|
||||
{ op: "remove_memory", query: "old_memory" },
|
||||
{ op: "noop", key: "ignored", value: "ignored" },
|
||||
],
|
||||
skipped: [{ reason: "duplicate", text: "already saved" }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(parseOps(parsed)).toEqual([
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "repo_tests",
|
||||
text: "Run tests from packages/opencode.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Decisions",
|
||||
key: "file_store",
|
||||
text: "Keep memory v0 file-based before adding databases.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Constraints",
|
||||
key: "zod_only",
|
||||
text: "The memory package stays zod-only.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
file: "environment.md",
|
||||
section: "Tooling",
|
||||
key: "bun",
|
||||
text: "Use bun for package scripts.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
file: "corrections.md",
|
||||
section: "Corrections",
|
||||
key: "root_tests",
|
||||
text: "Do not run bun test from the repo root.",
|
||||
},
|
||||
{ action: "remove", query: "old_memory" },
|
||||
])
|
||||
expect(parsed.skipped).toEqual([{ reason: "duplicate", text: "already saved" }])
|
||||
})
|
||||
|
||||
test("merges fallback typed operations without duplicates", () => {
|
||||
const ops = mergeOps([
|
||||
{ action: "add", file: "environment.md", section: "Commands", key: "tests", text: "Run bun test." },
|
||||
{ action: "add", file: "environment.md", section: "Commands", key: "tests", text: "Run bun test again." },
|
||||
{ action: "remove", query: "stale" },
|
||||
{ action: "remove", query: "stale" },
|
||||
])
|
||||
|
||||
expect(ops).toEqual([
|
||||
{ action: "add", file: "environment.md", section: "Commands", key: "tests", text: "Run bun test." },
|
||||
{ action: "remove", query: "stale" },
|
||||
])
|
||||
})
|
||||
|
||||
test("filters self-referential generated adds", () => {
|
||||
const fact = {
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "memory_index",
|
||||
text: "Memory index records are rebuilt from project source files.",
|
||||
} satisfies MemoryOperations.Op
|
||||
const filtered = duplicateOps({
|
||||
items: [],
|
||||
skipped: [],
|
||||
ops: [
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "memory_echo",
|
||||
text: "Small model call-site behavior is already in project memory.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "scope_review",
|
||||
text: "Config preference scope/write behavior was investigated.",
|
||||
},
|
||||
fact,
|
||||
],
|
||||
})
|
||||
|
||||
expect(filtered.ops).toEqual([fact])
|
||||
expect(filtered.skipped.map((item) => item.reason)).toEqual(["self_referential", "self_referential"])
|
||||
expect(MemoryOperations.reject(fact)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("filters instruction provenance generated adds", () => {
|
||||
const fact = {
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "repo_test_rule",
|
||||
text: "Root AGENTS.md says to run package-level tests instead of root bun test.",
|
||||
} satisfies MemoryOperations.Op
|
||||
const filtered = duplicateOps({
|
||||
items: [],
|
||||
skipped: [],
|
||||
ops: [
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "instruction_sources",
|
||||
text: "Sources: system/developer instructions, AGENTS.md, packages/opencode/AGENTS.md, and ~/.claude/CLAUDE.md.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "user_context",
|
||||
text: "~/.claude/CLAUDE.md is user-level context for concise replies.",
|
||||
},
|
||||
fact,
|
||||
],
|
||||
})
|
||||
|
||||
expect(filtered.ops).toEqual([fact])
|
||||
expect(filtered.skipped.map((item) => item.reason)).toEqual(["out_of_scope", "out_of_scope"])
|
||||
})
|
||||
|
||||
test("parses project-only skip reasons", () => {
|
||||
const parsed = parseJson(
|
||||
typedSchema,
|
||||
JSON.stringify({
|
||||
operations: [{ op: "noop" }],
|
||||
skipped: [
|
||||
{ reason: "out_of_scope", text: "User prefers concise commit messages." },
|
||||
{ reason: "self_referential", text: "Existing memory already tracks the test command." },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(parseOps(parsed)).toEqual([])
|
||||
expect(parsed.skipped.map((item) => item.reason)).toEqual(["out_of_scope", "self_referential"])
|
||||
expect(skipLine([parsed.skipped[0]!])).toBe("reason=out_of_scope")
|
||||
})
|
||||
|
||||
test("plans capture cadence from a state table", () => {
|
||||
const base = {
|
||||
summary: "User: continue Result: updated code",
|
||||
echo: false,
|
||||
durable: false,
|
||||
priorTime: 0,
|
||||
now: 1_000,
|
||||
minIntervalMs: 500,
|
||||
lastConsolidatedAt: undefined,
|
||||
autoConsolidate: true,
|
||||
}
|
||||
const cases = [
|
||||
{
|
||||
name: "expected work: completed turn schedules digest and typed capture",
|
||||
input: base,
|
||||
expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined },
|
||||
},
|
||||
{
|
||||
name: "expected idle flush: completed turn inside interval skips now",
|
||||
input: { ...base, priorTime: 900, lastConsolidatedAt: 900 },
|
||||
expected: { digestDue: false, typedCall: false, skipReason: "interval", idleFlush: true },
|
||||
},
|
||||
{
|
||||
name: "expected work: bypass interval lets idle flush run typed capture",
|
||||
input: { ...base, priorTime: 900, lastConsolidatedAt: 900, bypassInterval: true },
|
||||
expected: { digestDue: false, typedCall: true, skipReason: undefined, idleFlush: false },
|
||||
},
|
||||
{
|
||||
name: "expected skip: recall echo with no durable diff",
|
||||
input: { ...base, echo: true },
|
||||
expected: { session: false, digestDue: false, typedCall: false, skipReason: "memory_echo" },
|
||||
},
|
||||
{
|
||||
name: "expected work: recall-assisted durable answer is modeled as non-echo by caller",
|
||||
input: { ...base, durable: true },
|
||||
expected: { session: true, digestDue: true, typedCall: true, skipReason: undefined },
|
||||
},
|
||||
{
|
||||
name: "expected skip: interrupted turn",
|
||||
input: { ...base, reason: "interrupted" as const, durable: true },
|
||||
expected: { completed: false, session: false, digestDue: false, typedCall: false, skipReason: "no_work" },
|
||||
},
|
||||
{
|
||||
name: "expected skip: errored turn",
|
||||
input: { ...base, reason: "error" as const },
|
||||
expected: { completed: false, session: false, digestDue: false, typedCall: false, skipReason: "no_work" },
|
||||
},
|
||||
{
|
||||
name: "expected skip: auto consolidation disabled",
|
||||
input: { ...base, autoConsolidate: false },
|
||||
expected: { session: false, digestDue: false, typedCall: false, typedWork: false, skipReason: "no_work" },
|
||||
},
|
||||
{
|
||||
name: "expected skip: no summary means no work",
|
||||
input: { ...base, summary: "" },
|
||||
expected: { session: false, digestDue: false, typedCall: false, typedWork: false, skipReason: "no_work" },
|
||||
},
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
expect(capturePlan(item.input), item.name).toMatchObject(item.expected)
|
||||
}
|
||||
})
|
||||
|
||||
test("summarizes durable diffs and fallback digests", () => {
|
||||
const diffs = [
|
||||
{ file: "src/index.ts", status: "modified", additions: 1, deletions: 1 },
|
||||
{ file: "README.md", status: "modified", additions: 1, deletions: 0 },
|
||||
]
|
||||
|
||||
expect(hasDurableDiff(diffs)).toBe(true)
|
||||
expect(hasDurableDiff([{ file: "docs/setup.md", additions: 1, deletions: 0 }])).toBe(true)
|
||||
expect(hasDurableDiff([{ file: ".kilo/rules.md", additions: 1, deletions: 0 }])).toBe(true)
|
||||
expect(hasDurableDiff([{ file: "src/plain.ts", additions: 1, deletions: 0 }])).toBe(false)
|
||||
expect(summarizeDiffs(diffs)).toContain("modified README.md +1 -0")
|
||||
expect(fallbackDigest({ prior: "Earlier state.", summary: "New state.", max: 80 })).toContain("Latest: New state.")
|
||||
})
|
||||
|
||||
test("verifies duplicate skips and operation duplicates", () => {
|
||||
const items = [{ id: "project.md:repo_tests", text: "repo_tests Run memory tests from packages/opencode." }]
|
||||
const verified = verifySkips({
|
||||
items,
|
||||
skipped: [
|
||||
{ reason: "duplicate", text: "Run memory tests from packages/opencode." },
|
||||
{ reason: "duplicate", text: "New durable workflow preference." },
|
||||
],
|
||||
})
|
||||
const deduped = duplicateOps({
|
||||
items,
|
||||
skipped: verified.skipped,
|
||||
ops: [
|
||||
{ action: "add", file: "project.md", section: "Facts", key: "repo_tests", text: "Run memory tests." },
|
||||
{ action: "add", file: "project.md", section: "Facts", key: "new_preference", text: "New durable workflow preference." },
|
||||
],
|
||||
})
|
||||
|
||||
expect(verified.skipped[0]?.duplicateOf).toBe("project.md:repo_tests")
|
||||
expect(verified.rescued).toEqual([])
|
||||
expect(verified.skipped).toContainEqual({ reason: "unsupported", text: "New durable workflow preference." })
|
||||
expect(deduped.ops).toEqual([
|
||||
{ action: "add", file: "project.md", section: "Facts", key: "new_preference", text: "New durable workflow preference." },
|
||||
])
|
||||
expect(deduped.skipped.some((item) => item.duplicateOf === "project.md:repo_tests")).toBe(true)
|
||||
})
|
||||
|
||||
test("builds capture notices and guard summaries", () => {
|
||||
const ops = [{ action: "add", file: "environment.md", section: "Commands", key: "tests", text: "Run bun test." }] as const
|
||||
|
||||
expect(notice({ count: 1, ops: [...ops], skipped: [], tokens: 12 })).toMatchObject({
|
||||
type: "saved",
|
||||
message: "Memory saved · environment.md:tests",
|
||||
files: ["environment.md"],
|
||||
})
|
||||
expect(notice({ count: 0, ops: [], skipped: [{ reason: "duplicate", duplicateOf: "project.md:tests" }], tokens: 3 }))
|
||||
.toMatchObject({ type: "skipped", skippedCount: 1 })
|
||||
expect(skipLine([{ reason: "duplicate", duplicateOf: "project.md:tests" }])).toBe(
|
||||
"reason=duplicate duplicateOf=project.md:tests",
|
||||
)
|
||||
expect(guardReason("429 too many requests")).toBe("rate_limit_guard")
|
||||
expect(guardReason("billing credits exhausted")).toBe("quota_guard")
|
||||
})
|
||||
|
||||
test("redacts common secret token shapes", () => {
|
||||
const github = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
const google = "AIzaabcdefghijklmnopqrstuvwxyz123456789"
|
||||
const jwt = "eyJabcdefghijklmnopqrstuvwxyz.eyJmnopqrstuvwxyz12345.signaturevalue12345"
|
||||
const bearer = "Bearer abcdefghijklmnopqrstuvwxyz123456"
|
||||
const text = [
|
||||
`github=${github}`,
|
||||
`google=${google}`,
|
||||
`jwt=${jwt}`,
|
||||
`Authorization: ${bearer}`,
|
||||
"client_secret=super-secret-value",
|
||||
"access_key=super-secret-value",
|
||||
"refresh_token=abcdefghijklmnopqrstuvwxyz",
|
||||
'password="two words"',
|
||||
"DATABASE_URL=postgres://alice:hunter2@host/db",
|
||||
].join("\n")
|
||||
const redacted = MemoryRedact.text(text)
|
||||
|
||||
expect(MemoryRedact.has(text)).toBe(true)
|
||||
expect(redacted).not.toContain(github)
|
||||
expect(redacted).not.toContain(google)
|
||||
expect(redacted).not.toContain(jwt)
|
||||
expect(redacted).not.toContain(bearer)
|
||||
expect(redacted.match(/\[redacted\]/g)?.length).toBeGreaterThanOrEqual(9)
|
||||
expect(redacted).not.toContain("two words")
|
||||
expect(redacted).not.toContain("hunter2")
|
||||
expect(MemoryRedact.value({ private_key: "abc", credential: "def", auth: "ghi" })).toEqual({
|
||||
private_key: "[redacted]",
|
||||
credential: "[redacted]",
|
||||
auth: "[redacted]",
|
||||
})
|
||||
})
|
||||
|
||||
test("redacts URI userinfo credentials", () => {
|
||||
const cases = [
|
||||
["postgres://alice:hunter2@db.local/app", "postgres://[redacted]@db.local/app", "hunter2"],
|
||||
["postgresql://alice:p%40ss@db.local/app", "postgresql://[redacted]@db.local/app", "p%40ss"],
|
||||
["mongodb+srv://user:secret@cluster.mongodb.net/app", "mongodb+srv://[redacted]@cluster.mongodb.net/app", "secret"],
|
||||
["redis://:cache-secret@localhost:6379/0", "redis://[redacted]@localhost:6379/0", "cache-secret"],
|
||||
["https://user:pass@example.com/path", "https://[redacted]@example.com/path", "pass"],
|
||||
] as const
|
||||
|
||||
for (const item of cases) {
|
||||
const redacted = MemoryRedact.text(item[0])
|
||||
expect(MemoryRedact.has(item[0]), item[0]).toBe(true)
|
||||
expect(redacted).toBe(item[1])
|
||||
expect(redacted).not.toContain(item[2])
|
||||
}
|
||||
|
||||
// Unknown/non-allowlisted scheme: parsing, not an enumerated list, decides.
|
||||
expect(MemoryRedact.text("clickhouse://svc:topsecret@host:9000/db")).toBe("clickhouse://[redacted]@host:9000/db")
|
||||
|
||||
// Fail closed on any userinfo: a bare user@host (no colon) may still be a token.
|
||||
expect(MemoryRedact.has("https://token@host/path")).toBe(true)
|
||||
expect(MemoryRedact.text("https://token@host/path")).toBe("https://[redacted]@host/path")
|
||||
|
||||
// Multiple URIs embedded in prose: each userinfo is redacted, surrounding text preserved.
|
||||
expect(MemoryRedact.text("primary postgres://u:p@h1/a then cache redis://:s@h2/0 done")).toBe(
|
||||
"primary postgres://[redacted]@h1/a then cache redis://[redacted]@h2/0 done",
|
||||
)
|
||||
|
||||
// Malformed URL the parser rejects must still redact via the raw-segment fallback.
|
||||
const malformed = "postgres://user:leaked@[bad"
|
||||
expect(MemoryRedact.has(malformed)).toBe(true)
|
||||
expect(MemoryRedact.text(malformed)).not.toContain("leaked")
|
||||
|
||||
// @ in the path or query with no userinfo must not be touched (no false positives).
|
||||
expect(MemoryRedact.has("https://example.com/a:b@c")).toBe(false)
|
||||
expect(MemoryRedact.text("https://example.com/a:b@c")).toBe("https://example.com/a:b@c")
|
||||
expect(MemoryRedact.text("https://example.com/p?to=a@b.com")).toBe("https://example.com/p?to=a@b.com")
|
||||
|
||||
// has() and text() must agree: anything has() flags is actually scrubbed by text().
|
||||
for (const item of [...cases.map((c) => c[0]), malformed, "no secrets here", "https://example.com/a:b@c"]) {
|
||||
if (MemoryRedact.has(item)) expect(MemoryRedact.text(item), item).not.toBe(item)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,1122 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, readdir, rm, symlink, utimes, writeFile } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Memory } from "../src/memory"
|
||||
import { MemoryDigest } from "../src/capture/digest"
|
||||
import { MemoryFiles } from "../src/storage/store"
|
||||
import { MemoryIndexer } from "../src/recall/indexer"
|
||||
import { MemoryOperations } from "../src/capture/ops"
|
||||
import { MemoryPaths } from "../src/storage/paths"
|
||||
import { MemoryRecall } from "../src/recall/recall"
|
||||
import { MemorySchema } from "../src/schema"
|
||||
|
||||
async function tmp() {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-memory-"))
|
||||
return {
|
||||
dir,
|
||||
root: path.join(dir, "memory"),
|
||||
async done() {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function use(fn: (input: Awaited<ReturnType<typeof tmp>>) => Promise<void>) {
|
||||
const t = await tmp()
|
||||
try {
|
||||
await fn(t)
|
||||
} finally {
|
||||
await t.done()
|
||||
}
|
||||
}
|
||||
|
||||
describe("memory core package", () => {
|
||||
test("enable scaffolds state, source files, gitignore, and index", async () => {
|
||||
await use(async (t) => {
|
||||
const enabled = await Memory.enable({ root: t.root })
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(enabled.state.enabled).toBe(true)
|
||||
expect(shown.sources.project).toContain("# Project Memory")
|
||||
expect(shown.sources.environment).toContain("## Commands")
|
||||
expect(shown.sources.corrections).toContain("## Corrections")
|
||||
expect(await Bun.file(path.join(t.root, ".gitignore")).text()).toBe("*\n!.gitignore\n")
|
||||
expect(shown.index).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
test("enable preserves existing memory settings", async () => {
|
||||
await use(async (t) => {
|
||||
const enabled = await Memory.enable({ root: t.root })
|
||||
await MemoryFiles.writeState(t.root, { ...enabled.state, autoInject: false, autoConsolidate: false })
|
||||
|
||||
const next = await Memory.enable({ root: t.root })
|
||||
|
||||
expect(next.state.autoInject).toBe(true)
|
||||
expect(next.state.autoConsolidate).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("state parser migrates legacy fields, ignores persisted limits, and rejects non-finite stats", () => {
|
||||
const paused = MemorySchema.parse({ autoInject: false })
|
||||
const missing = MemorySchema.missing()
|
||||
const state = MemorySchema.parse({
|
||||
limits: { maxSessionFiles: 50, maxRecentSessions: 10, maxSessionLineChars: 160, maxProjectIndexBytes: 1 },
|
||||
stats: {
|
||||
lastInjectedAt: Number.NaN,
|
||||
lastConsolidatedAt: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
})
|
||||
|
||||
expect(paused.autoInject).toBe(true)
|
||||
expect(paused.autoConsolidate).toBe(true)
|
||||
expect(missing.enabled).toBe(false)
|
||||
expect(missing.autoConsolidate).toBe(true)
|
||||
expect(state.limits.maxSessionFiles).toBe(20)
|
||||
expect(state.limits.maxRecentSessions).toBe(5)
|
||||
expect(state.limits.maxProjectIndexBytes).toBe(8192)
|
||||
expect(state.limits.maxSessionLineChars).toBe(480)
|
||||
expect(state.stats.lastInjectedAt).toBeNull()
|
||||
expect(state.stats.lastConsolidatedAt).toBeNull()
|
||||
})
|
||||
|
||||
test("state writes omit code-owned limits", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
|
||||
const raw = JSON.parse(await Bun.file(MemoryPaths.files(t.root).state).text())
|
||||
const state = await MemoryFiles.readState(t.root)
|
||||
|
||||
expect(raw.limits).toBeUndefined()
|
||||
expect(state.limits.maxSessionLineChars).toBe(480)
|
||||
})
|
||||
})
|
||||
|
||||
test("decision and change audit records redact secret-like text in one log", async () => {
|
||||
await use(async (t) => {
|
||||
const secret = "sk-abcdefghijklmnopqrstuvwxyz123456"
|
||||
await Memory.enable({ root: t.root })
|
||||
await MemoryFiles.decide(t.root, {
|
||||
kind: "recall",
|
||||
result: "skipped",
|
||||
query: `check api_key=${secret}`,
|
||||
skipped: [{ reason: "secret", text: `password=hunter2 ${secret}` }],
|
||||
})
|
||||
await MemoryFiles.append(t.root, `provider error "api_key": "${secret}"`)
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.decisions).toContain("[redacted]")
|
||||
expect(shown.decisions).toContain('"kind":"log"')
|
||||
expect(shown.decisions).not.toContain(secret)
|
||||
expect(shown.decisions).not.toContain("hunter2")
|
||||
expect(shown.changes).toContain("[redacted]")
|
||||
expect(shown.decisions).toContain("provider error")
|
||||
})
|
||||
})
|
||||
|
||||
test("stale locks are stolen before appending audit records", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
const lock = path.join(t.root, ".lock")
|
||||
const old = new Date(Date.now() - 60_000)
|
||||
await mkdir(lock)
|
||||
await utimes(lock, old, old)
|
||||
|
||||
await MemoryFiles.append(t.root, "after stale lock")
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.changes).toContain("after stale lock")
|
||||
})
|
||||
})
|
||||
|
||||
test("corrupted state recovers disabled with derived inventory", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await writeFile(MemoryPaths.files(t.root).state, "{", "utf8")
|
||||
|
||||
const state = await MemoryFiles.readState(t.root)
|
||||
const files = await readdir(t.root)
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(state.enabled).toBe(false)
|
||||
expect(files.some((file) => file.startsWith("state.json.bad-"))).toBe(true)
|
||||
expect(shown.inventory.items).toEqual({})
|
||||
expect(shown.changes).toContain("recover state.json")
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects symlinked memory roots", async () => {
|
||||
await use(async (t) => {
|
||||
const target = path.join(t.dir, "target")
|
||||
const link = path.join(t.dir, "link")
|
||||
await Memory.enable({ root: target })
|
||||
await symlink(target, link)
|
||||
|
||||
await expect(Memory.enable({ root: link })).rejects.toThrow("memory path rejects symlink")
|
||||
})
|
||||
})
|
||||
|
||||
test("uses unicode-safe project and memory identifiers", async () => {
|
||||
await use(async (t) => {
|
||||
const dir = path.join(t.dir, "proyecto_ñ_日本")
|
||||
await mkdir(dir)
|
||||
const id = MemoryPaths.identity({ ctx: { directory: dir, worktree: dir } })
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.remember({
|
||||
root: t.root,
|
||||
key: "設定_é",
|
||||
text: "日本語の設定は packages/kilo-vscode に保存します。",
|
||||
})
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(id.display).toBe("proyecto_ñ_日本")
|
||||
expect(id.folder).toContain("proyecto_ñ_日本-")
|
||||
expect(shown.sources.project).toContain("- 設定_é :: 日本語の設定は packages/kilo-vscode に保存します。")
|
||||
expect(shown.items).toContain("id=project.md:Facts:設定_é")
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes concurrent operations for one root", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
const ops: MemoryOperations.Op[] = [
|
||||
{ action: "add", key: "one", text: "first durable fact" },
|
||||
{ action: "add", key: "two", text: "second durable fact" },
|
||||
]
|
||||
|
||||
await Promise.all(ops.map((op) => Memory.apply({ root: t.root, ops: [op] })))
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.sources.project).toContain("- one :: first durable fact")
|
||||
expect(shown.sources.project).toContain("- two :: second durable fact")
|
||||
})
|
||||
})
|
||||
|
||||
test("apply upserts, removes, dedupes, and rejects secrets", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{ action: "add", key: "repo_tests", text: "Run CLI memory tests from packages/opencode." },
|
||||
{ action: "add", key: "repo_tests_copy", text: "Run CLI memory tests from packages/opencode." },
|
||||
{ action: "remove", query: "repo_tests" },
|
||||
{ action: "add", key: "repo_tests", text: "Run CLI memory tests from packages/opencode." },
|
||||
],
|
||||
})
|
||||
await expect(
|
||||
Memory.apply({ root: t.root, ops: [{ action: "add", key: "bad", text: "api_key=sk-abcdefghijklmnopqrstuvwxyz" }] }),
|
||||
).rejects.toThrow("secret-like content")
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.sources.project.match(/repo_tests/g)?.length).toBe(1)
|
||||
expect(shown.index).toContain("repo_tests")
|
||||
})
|
||||
})
|
||||
|
||||
test("automatic apply does not remove memory", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [{ action: "add", key: "durable_fact", text: "This durable fact must survive auto capture." }],
|
||||
})
|
||||
|
||||
const result = await Memory.apply({
|
||||
root: t.root,
|
||||
trigger: "turn-close",
|
||||
ops: [{ action: "remove", query: "durable_fact" }],
|
||||
})
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(result.result.operationCount).toBe(0)
|
||||
expect(result.result.removed).toBe(0)
|
||||
expect(shown.sources.project).toContain("durable_fact")
|
||||
expect(shown.index).toContain("durable_fact")
|
||||
})
|
||||
})
|
||||
|
||||
test("apply drops self-referential memory facts", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
|
||||
const result = await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{
|
||||
action: "add",
|
||||
key: "memory_echo",
|
||||
text: "Small model call-site behavior is already captured in project memory.",
|
||||
},
|
||||
],
|
||||
})
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(result.result.operationCount).toBe(0)
|
||||
expect(result.result.added).toBe(0)
|
||||
expect(result.result.skipped).toEqual([
|
||||
{ reason: "self_referential", text: "Small model call-site behavior is already captured in project memory." },
|
||||
])
|
||||
expect(shown.sources.project).not.toContain("memory_echo")
|
||||
expect(shown.index).not.toContain("memory_echo")
|
||||
expect(shown.decisions).toContain('"reason":"self_referential"')
|
||||
})
|
||||
})
|
||||
|
||||
test("apply drops personal preference facts but keeps project facts", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
|
||||
const blocked = await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{ action: "add", key: "reply_style", text: "I prefer terse summaries." },
|
||||
{ action: "add", key: "theme", text: "My preference is dark mode." },
|
||||
{ action: "add", key: "editor", text: "User prefers Vim keybindings." },
|
||||
],
|
||||
})
|
||||
const saved = await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [{ action: "add", key: "repo_style", text: "Repo convention: commit messages are concise." }],
|
||||
})
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(blocked.result.operationCount).toBe(0)
|
||||
expect(blocked.result.added).toBe(0)
|
||||
expect(blocked.result.skipped).toEqual([
|
||||
{ reason: "out_of_scope", text: "I prefer terse summaries." },
|
||||
{ reason: "out_of_scope", text: "My preference is dark mode." },
|
||||
{ reason: "out_of_scope", text: "User prefers Vim keybindings." },
|
||||
])
|
||||
expect(saved.result.operationCount).toBe(1)
|
||||
expect(saved.result.added).toBe(1)
|
||||
expect(shown.sources.project).toContain("- repo_style :: Repo convention: commit messages are concise.")
|
||||
expect(shown.sources.project).not.toContain("reply_style")
|
||||
expect(shown.sources.project).not.toContain("dark mode")
|
||||
expect(shown.sources.project).not.toContain("Vim keybindings")
|
||||
expect(shown.index).toContain("repo_style")
|
||||
expect(shown.index).not.toContain("reply_style")
|
||||
expect(shown.decisions).toContain('"reason":"out_of_scope"')
|
||||
expect(shown.decisions).not.toContain("reply_style")
|
||||
expect(shown.decisions).not.toContain("theme")
|
||||
expect(shown.decisions).not.toContain("editor")
|
||||
expect(shown.decisions).not.toContain("I prefer terse summaries")
|
||||
expect(shown.decisions).not.toContain("dark mode")
|
||||
expect(shown.decisions).not.toContain("Vim keybindings")
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes unsafe memory keys", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [{ action: "add", key: " Run root tests?! ", text: "Use package-level tests instead." }],
|
||||
})
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.sources.project).toContain("- run_root_tests :: Use package-level tests instead.")
|
||||
})
|
||||
})
|
||||
|
||||
test("sanitizes explicit sections before writing source files", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
const long = "x".repeat(120)
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{
|
||||
action: "add",
|
||||
key: "newline_section",
|
||||
section: "Notes\n## Injected\n- fake :: value",
|
||||
text: "Keep malformed section text inside one safe heading.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
key: "hash_section",
|
||||
section: "## Decisions",
|
||||
text: "Leading hash markers become a plain heading.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
key: "separator_section",
|
||||
section: "Custom :: Entry",
|
||||
text: "Entry separators are not preserved in section names.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
key: "dash_section",
|
||||
section: "- Bullet",
|
||||
text: "Leading list markers become a plain heading.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
key: "long_section",
|
||||
section: long,
|
||||
text: "Long section names are capped.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
file: "environment.md",
|
||||
key: "empty_section",
|
||||
section: "###",
|
||||
text: "Empty section names fall back to the file default.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
const inventory = await MemoryFiles.deriveInventory(t.root)
|
||||
const recall = await MemoryRecall.search({ root: t.root, query: "malformed section safe heading" })
|
||||
|
||||
expect(shown.sources.project).toContain("## Notes ## Injected - fake value")
|
||||
expect(shown.sources.project).toContain("## Decisions")
|
||||
expect(shown.sources.project).toContain("## Custom Entry")
|
||||
expect(shown.sources.project).toContain("## Bullet")
|
||||
expect(shown.sources.project).toContain(`## ${"x".repeat(80)}`)
|
||||
expect(shown.sources.project).not.toContain(`## ${"x".repeat(81)}`)
|
||||
expect(shown.sources.project).not.toContain("\n## Injected\n")
|
||||
expect(shown.sources.project).not.toContain("\n- fake :: value\n")
|
||||
expect(shown.sources.environment).toContain("## Commands")
|
||||
expect(Object.values(inventory.items).some((item) => item.key === "fake")).toBe(false)
|
||||
expect(Object.values(inventory.items).filter((item) => item.key.endsWith("_section")).length).toBe(6)
|
||||
expect(recall?.block).toContain("newline_section")
|
||||
})
|
||||
})
|
||||
|
||||
test("targeted recall returns typed memory and audits matched files", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.remember({
|
||||
root: t.root,
|
||||
file: "environment.md",
|
||||
section: "Commands",
|
||||
key: "cli_tests",
|
||||
text: "Run CLI tests from packages/opencode with bun test.",
|
||||
})
|
||||
|
||||
const result = await Memory.recall({ root: t.root, query: "what command runs cli tests?" })
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(result.result?.block).toContain("cli_tests")
|
||||
expect(result.files).toEqual(["environment.md"])
|
||||
expect(shown.decisions).toContain('"kind":"recall"')
|
||||
expect(shown.decisions).toContain('"result":"recalled"')
|
||||
})
|
||||
})
|
||||
|
||||
test("targeted recall uses source recency as a tiebreaker", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "older_docs",
|
||||
text: "Memory docs describe older recall ranking.",
|
||||
},
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "newer_docs",
|
||||
text: "Memory docs describe current recall ranking.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await MemoryRecall.search({ root: t.root, query: "memory docs recall ranking", limit: 1 })
|
||||
|
||||
expect(result?.block).toContain("newer_docs")
|
||||
expect(result?.block).not.toContain("older_docs")
|
||||
})
|
||||
})
|
||||
|
||||
test("derived inventory timestamps are source-mtime ranking hints", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await MemoryFiles.writeSource(
|
||||
t.root,
|
||||
"project.md",
|
||||
[
|
||||
"# Project Memory",
|
||||
"",
|
||||
"## Facts",
|
||||
"- first_hint :: First derived timestamp hint.",
|
||||
"- second_hint :: Second derived timestamp hint.",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
const stamp = new Date(Date.UTC(2026, 0, 1, 0, 0, 0))
|
||||
await utimes(MemoryPaths.source(t.root, "project.md"), stamp, stamp)
|
||||
|
||||
const inventory = await MemoryFiles.deriveInventory(t.root)
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
const first = inventory.items[
|
||||
MemoryFiles.inventoryKey({ file: "project.md", section: "Facts", key: "first_hint" })
|
||||
]!
|
||||
const second = inventory.items[
|
||||
MemoryFiles.inventoryKey({ file: "project.md", section: "Facts", key: "second_hint" })
|
||||
]!
|
||||
|
||||
expect(first.createdAt).toBe(first.updatedAt)
|
||||
expect(second.createdAt).toBe(second.updatedAt)
|
||||
expect(first.createdAt).toBeGreaterThan(second.createdAt)
|
||||
expect(first.createdAt - second.createdAt).toBe(1)
|
||||
expect(shown.items).toContain("timeSource=source_mtime_line_offset")
|
||||
})
|
||||
})
|
||||
|
||||
test("targeted recall dedupes lower-value session hits when typed memory answers", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.remember({
|
||||
root: t.root,
|
||||
key: "memory_tests",
|
||||
text: "Run memory tests from packages/opencode with bun test ./test/kilocode/memory.",
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_memory_tests",
|
||||
summary: "Discussed running memory tests from packages/opencode.",
|
||||
time: Date.UTC(2026, 0, 1),
|
||||
})
|
||||
|
||||
const result = await MemoryRecall.search({ root: t.root, query: "memory tests packages/opencode", limit: 5 })
|
||||
|
||||
expect(result?.block).toContain("memory_tests")
|
||||
expect(result?.block).not.toContain("ses_memory_tests")
|
||||
})
|
||||
})
|
||||
|
||||
test("digest recall honors requested sessions and ignores the active session", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_plan_memory",
|
||||
topic: "plan memory",
|
||||
summary: "Discussed token accounting for memory injection.",
|
||||
time: Date.UTC(2026, 0, 1),
|
||||
})
|
||||
|
||||
const active = await MemoryRecall.search({
|
||||
root: t.root,
|
||||
query: "token accounting",
|
||||
mode: "digest",
|
||||
sessionID: "ses_plan_memory",
|
||||
currentSessionID: "ses_plan_memory",
|
||||
})
|
||||
const prior = await MemoryRecall.search({
|
||||
root: t.root,
|
||||
query: "token accounting",
|
||||
mode: "digest",
|
||||
sessionID: "ses_plan_memory",
|
||||
currentSessionID: "other",
|
||||
})
|
||||
|
||||
expect(active).toBeUndefined()
|
||||
expect(prior?.block).toContain("session=ses_plan_memory")
|
||||
expect(prior?.block).toContain("token accounting")
|
||||
})
|
||||
})
|
||||
|
||||
test("non-English stored text remains searchable", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{ action: "add", key: "pruebas_cli", text: "Ejecuta las pruebas CLI desde packages/opencode." },
|
||||
{ action: "add", key: "memoire", text: "Les corrections de mémoire restent dans corrections.md." },
|
||||
{ action: "add", key: "設定", text: "日本語の設定は packages/kilo-vscode に保存します。" },
|
||||
],
|
||||
})
|
||||
|
||||
const spanish = await MemoryRecall.search({ root: t.root, query: "pruebas CLI" })
|
||||
const french = await MemoryRecall.search({ root: t.root, query: "corrections mémoire" })
|
||||
const japanese = await MemoryRecall.search({ root: t.root, query: "日本語 設定" })
|
||||
|
||||
expect(spanish?.block).toContain("pruebas_cli")
|
||||
expect(french?.block).toContain("memoire")
|
||||
expect(japanese?.block).toContain("設定")
|
||||
})
|
||||
})
|
||||
|
||||
test("index caps preserve priority records under tight budgets", async () => {
|
||||
await use(async (t) => {
|
||||
const enabled = await Memory.enable({ root: t.root })
|
||||
const state = {
|
||||
...enabled.state,
|
||||
limits: {
|
||||
...enabled.state.limits,
|
||||
maxProjectIndexBytes: 700,
|
||||
},
|
||||
}
|
||||
await MemoryFiles.writeSource(
|
||||
t.root,
|
||||
"project.md",
|
||||
[
|
||||
"# Project Memory",
|
||||
"",
|
||||
"## Facts",
|
||||
...Array.from({ length: 20 }, (_, idx) => `- fact_${idx} :: ${"x".repeat(40)}`),
|
||||
"",
|
||||
"## Decisions",
|
||||
"- architecture_choice :: Keep memory v0 file-based before adding databases.",
|
||||
"",
|
||||
"## Constraints",
|
||||
"- project_only :: Memory v0 must stay project-only.",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const index = await MemoryIndexer.rebuild({ root: t.root, state })
|
||||
|
||||
expect(index.truncated).toBe(true)
|
||||
expect(index.text).toContain("type=project_decision")
|
||||
expect(index.text).toContain("architecture_choice")
|
||||
expect(index.text).toContain("type=project_constraint")
|
||||
expect(index.text).toContain("project_only")
|
||||
})
|
||||
})
|
||||
|
||||
test("index keeps recent session digests and recognizes stale formats", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
for (let idx = 0; idx < 12; idx++) {
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: `ses_${idx}`,
|
||||
summary: `summary_${String(idx).padStart(2, "0")} durable result`,
|
||||
time: Date.UTC(2026, 0, 1, 0, idx),
|
||||
})
|
||||
}
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1)
|
||||
expect(shown.index.match(/type=session_digest/g)?.length).toBe(4)
|
||||
expect(shown.index).toContain("session=ses_11")
|
||||
expect(shown.index).toContain("session=ses_7")
|
||||
expect(shown.index).not.toContain("session=ses_6")
|
||||
expect(shown.index).toContain("type=latest_session_digest")
|
||||
expect(shown.index).not.toContain("summary_01 durable result")
|
||||
expect(MemoryIndexer.stale(shown.index)).toBe(false)
|
||||
expect(MemoryIndexer.stale("record id=session.ses type=session_digest source=ses.md")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("index renders the latest session digest fuller than older digests", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
const older =
|
||||
`Older digest starts. ${"older continuity detail ".repeat(14)}` +
|
||||
"OLDER_DETAIL_AFTER_RECENT_CAP should be hidden from recent digest rendering."
|
||||
const latest =
|
||||
`Latest digest starts. ${"latest continuity detail ".repeat(14)}` +
|
||||
"LATEST_DETAIL_AFTER_RECENT_CAP should remain visible in the newest digest."
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_older_rich",
|
||||
topic: "Older Rich Digest",
|
||||
summary: older,
|
||||
time: Date.UTC(2026, 0, 1, 0, 0),
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_latest_rich",
|
||||
topic: "Latest Rich Digest",
|
||||
summary: latest,
|
||||
time: Date.UTC(2026, 0, 1, 0, 1),
|
||||
})
|
||||
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
const newest = shown.index.match(/type=latest_session_digest[^\n]*\ntext: ([^\n]+)/)?.[1] ?? ""
|
||||
const recent = shown.index.match(/type=session_digest[^\n]*\ntext: ([^\n]+)/)?.[1] ?? ""
|
||||
|
||||
expect(newest).toContain("session=ses_latest_rich")
|
||||
expect(newest).toContain("LATEST_DETAIL_AFTER_RECENT_CAP")
|
||||
expect(recent).toContain("session=ses_older_rich")
|
||||
expect(recent).not.toContain("OLDER_DETAIL_AFTER_RECENT_CAP")
|
||||
expect(newest.length).toBeGreaterThan(recent.length)
|
||||
})
|
||||
})
|
||||
|
||||
test("index renders short session digests without padding", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_short",
|
||||
topic: "Short Digest",
|
||||
summary: "Short digest remains exact.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 0),
|
||||
})
|
||||
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.index).toContain("session=ses_short")
|
||||
expect(shown.index).toContain("Short digest remains exact.")
|
||||
expect(shown.index).not.toContain("Short digest remains exact. ")
|
||||
expect(shown.index).not.toContain("Short digest remains exact...")
|
||||
})
|
||||
})
|
||||
|
||||
test("richer digest rendering stays inside the index budget and preserves priority records", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await MemoryFiles.writeSource(
|
||||
t.root,
|
||||
"corrections.md",
|
||||
[
|
||||
"# Corrective Memory",
|
||||
"",
|
||||
"## Corrections",
|
||||
"- reviewer_correction :: Always keep reviewer-requested memory corrections visible.",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await MemoryFiles.writeSource(
|
||||
t.root,
|
||||
"project.md",
|
||||
[
|
||||
"# Project Memory",
|
||||
"",
|
||||
"## Decisions",
|
||||
"- architecture_choice :: Keep memory v0 file-based before adding databases.",
|
||||
"",
|
||||
"## Constraints",
|
||||
"- project_only :: Memory v0 must stay project-only.",
|
||||
"",
|
||||
"## Facts",
|
||||
...Array.from({ length: 120 }, (_, idx) => `- fact_${idx} :: ${"low priority fact ".repeat(8)}${idx}`),
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
for (let idx = 0; idx < 5; idx++) {
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: `ses_rich_${idx}`,
|
||||
topic: `Rich Digest ${idx}`,
|
||||
summary: `Rich digest ${idx}. ${"continuity detail ".repeat(30)}tail_${idx}.`,
|
||||
time: Date.UTC(2026, 0, 1, 0, idx),
|
||||
})
|
||||
}
|
||||
|
||||
const index = await MemoryIndexer.rebuild({ root: t.root })
|
||||
|
||||
expect(index.bytes).toBeLessThanOrEqual(8192)
|
||||
expect(index.text).toContain("reviewer_correction")
|
||||
expect(index.text).toContain("architecture_choice")
|
||||
expect(index.text).toContain("project_only")
|
||||
expect(index.text).toContain("session=ses_rich_4")
|
||||
})
|
||||
})
|
||||
|
||||
test("index preserves top durable facts before older session digest pressure", async () => {
|
||||
await use(async (t) => {
|
||||
const enabled = await Memory.enable({ root: t.root })
|
||||
const state = {
|
||||
...enabled.state,
|
||||
limits: {
|
||||
...enabled.state.limits,
|
||||
maxSessionFiles: 30,
|
||||
maxRecentSessions: 30,
|
||||
maxSessionLineChars: 1200,
|
||||
},
|
||||
}
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{
|
||||
action: "add",
|
||||
key: "fixture_palette_uses_teal",
|
||||
text: "The fixture palette uses teal and amber accents.",
|
||||
},
|
||||
],
|
||||
})
|
||||
for (let idx = 0; idx < 30; idx++) {
|
||||
const latest = idx === 29
|
||||
await MemoryFiles.writeSession(t.root, {
|
||||
sessionID: `ses_budget_${String(idx).padStart(2, "0")}`,
|
||||
topic: latest ? "Latest Fixture Continuity" : `Older Fixture Digest ${idx}`,
|
||||
summary: latest
|
||||
? `Latest digest starts. ${"latest continuity detail ".repeat(32)}LATEST_FIXTURE_BUDGET_END_SENTINEL`
|
||||
: `Older digest ${idx}. ${"older continuity detail ".repeat(60)}older_tail_${idx}.`,
|
||||
max: state.limits.maxSessionLineChars,
|
||||
time: Date.UTC(2026, 0, 1, 0, idx),
|
||||
})
|
||||
}
|
||||
|
||||
const index = await MemoryIndexer.rebuild({ root: t.root, state })
|
||||
const saved = await MemoryFiles.readIndex(t.root)
|
||||
|
||||
expect(index.bytes).toBeLessThanOrEqual(8192)
|
||||
expect(index.truncated).toBe(true)
|
||||
expect(saved).toBe(index.text)
|
||||
expect(saved).toContain("type=latest_session_digest")
|
||||
expect(saved).toContain("session=ses_budget_29")
|
||||
expect(saved).toContain("LATEST_FIXTURE_BUDGET_END_SENTINEL")
|
||||
expect(saved).toContain("teal and amber")
|
||||
expect(saved).toContain("mode=typed")
|
||||
expect(saved).not.toContain("mode=catalog")
|
||||
expect(saved).not.toContain("session=ses_budget_00")
|
||||
})
|
||||
})
|
||||
|
||||
test("index preserves top environment commands before older session digest pressure", async () => {
|
||||
await use(async (t) => {
|
||||
const enabled = await Memory.enable({ root: t.root })
|
||||
const state = {
|
||||
...enabled.state,
|
||||
limits: {
|
||||
...enabled.state.limits,
|
||||
maxSessionFiles: 30,
|
||||
maxRecentSessions: 30,
|
||||
maxSessionLineChars: 1200,
|
||||
},
|
||||
}
|
||||
await MemoryFiles.writeSource(
|
||||
t.root,
|
||||
"environment.md",
|
||||
[
|
||||
"# Environment Memory",
|
||||
"",
|
||||
"## Commands",
|
||||
"- sdk_regen :: Run SDK regeneration from repo root with ./script/generate.ts.",
|
||||
"- vscode_unit_tests :: Run VS Code memory unit tests from packages/kilo-vscode with bun test tests/unit/memory-command.test.ts.",
|
||||
...Array.from(
|
||||
{ length: 40 },
|
||||
(_, idx) =>
|
||||
`- env_tail_${idx} :: Environment command ${idx} stays reachable through recall catalog when the startup index is full.`,
|
||||
),
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
for (let idx = 0; idx < 30; idx++) {
|
||||
const latest = idx === 29
|
||||
await MemoryFiles.writeSession(t.root, {
|
||||
sessionID: `ses_env_budget_${String(idx).padStart(2, "0")}`,
|
||||
topic: latest ? "Latest Environment Continuity" : `Older Environment Digest ${idx}`,
|
||||
summary: latest
|
||||
? `Latest digest starts. ${"latest environment continuity ".repeat(32)}LATEST_ENV_BUDGET_END_SENTINEL`
|
||||
: `Older environment digest ${idx}. ${"older environment continuity ".repeat(60)}older_env_tail_${idx}.`,
|
||||
max: state.limits.maxSessionLineChars,
|
||||
time: Date.UTC(2026, 0, 1, 0, idx),
|
||||
})
|
||||
}
|
||||
|
||||
const index = await MemoryIndexer.rebuild({ root: t.root, state })
|
||||
const saved = await MemoryFiles.readIndex(t.root)
|
||||
|
||||
expect(index.bytes).toBeLessThanOrEqual(8192)
|
||||
expect(index.truncated).toBe(true)
|
||||
expect(saved).toBe(index.text)
|
||||
expect(saved).toContain("type=latest_session_digest")
|
||||
expect(saved).toContain("session=ses_env_budget_29")
|
||||
expect(saved).toContain("LATEST_ENV_BUDGET_END_SENTINEL")
|
||||
expect(saved).toContain("Run SDK regeneration from repo root")
|
||||
expect(saved).toContain("VS Code memory unit tests")
|
||||
expect(saved).not.toContain("session=ses_env_budget_00")
|
||||
})
|
||||
})
|
||||
|
||||
test("session digest files prune to the current default limit", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
for (let idx = 0; idx < 25; idx++) {
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: `ses_${idx}`,
|
||||
summary: `summary_${String(idx).padStart(2, "0")} durable result`,
|
||||
time: Date.UTC(2026, 0, 1, 0, idx),
|
||||
})
|
||||
}
|
||||
|
||||
const files = (await readdir(MemoryPaths.files(t.root).sessions)).filter((file) => file.endsWith(".md"))
|
||||
|
||||
expect(files).toHaveLength(20)
|
||||
expect(files.some((file) => file.includes("_ses_24_id_"))).toBe(true)
|
||||
expect(files.some((file) => file.includes("_ses_4_id_"))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("index dedupes bulk recent session digests by normalized topic", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_shared_old",
|
||||
topic: "Shared Topic",
|
||||
summary: "Older shared-topic work.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 0),
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_distinct",
|
||||
topic: "Distinct Topic",
|
||||
summary: "Distinct recent work.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 1),
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_shared_new",
|
||||
topic: " shared topic ",
|
||||
summary: "Newer shared-topic work.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 2),
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_latest",
|
||||
topic: "Latest Topic",
|
||||
summary: "Newest continuity pointer.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 3),
|
||||
})
|
||||
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1)
|
||||
expect(shown.index).toContain("session=ses_latest")
|
||||
expect(shown.index).toContain("session=ses_shared_new")
|
||||
expect(shown.index).toContain("session=ses_distinct")
|
||||
expect(shown.index).not.toContain("session=ses_shared_old")
|
||||
})
|
||||
})
|
||||
|
||||
test("index suppresses bulk session digests covered by typed memory", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{
|
||||
action: "add",
|
||||
file: "project.md",
|
||||
section: "Facts",
|
||||
key: "small_model_call_sites",
|
||||
text: "Small model call sites are selected in the OpenCode adapter during memory capture.",
|
||||
},
|
||||
],
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_small_model",
|
||||
topic: "Small model call sites",
|
||||
summary: "Small model call sites are selected in the OpenCode adapter during memory capture.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 0),
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_latest",
|
||||
topic: "Digest prompt polish",
|
||||
summary: "Objective: polish digest prompts. Next: verify latest session remains injected.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 1),
|
||||
})
|
||||
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.index).toContain("small_model_call_sites")
|
||||
expect(shown.index).toContain("session=ses_latest")
|
||||
expect(shown.index).not.toContain("session=ses_small_model")
|
||||
})
|
||||
})
|
||||
|
||||
test("index keeps the true newest continuation-style session", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_substantive",
|
||||
summary: "Objective: finish memory continuity. Next: verify startup index ordering.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 0),
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_empty_newest",
|
||||
summary: "Continue requested; no substantive work was done.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 1),
|
||||
})
|
||||
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(MemoryDigest.empty("Continue requested; no substantive work was done.")).toBe(false)
|
||||
expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1)
|
||||
expect(shown.index).toContain("type=latest_session_digest")
|
||||
expect(shown.index).toContain("session=ses_empty_newest")
|
||||
expect(shown.index.indexOf("session=ses_empty_newest")).toBeLessThan(shown.index.indexOf("session=ses_substantive"))
|
||||
})
|
||||
})
|
||||
|
||||
test("index keeps older non-empty continuation-style digests", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_old_empty",
|
||||
summary: "Continue requested; no substantive work was done.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 0),
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_latest",
|
||||
summary: "Objective: implement continuity. Next: keep latest session visible.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 1),
|
||||
})
|
||||
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1)
|
||||
expect(shown.index).toContain("type=latest_session_digest")
|
||||
expect(shown.index).toContain("session=ses_latest")
|
||||
expect(shown.index).not.toMatch(/type=session_digest[^\n]*\ntext: session=ses_latest/)
|
||||
expect(shown.index).toContain("session=ses_old_empty")
|
||||
})
|
||||
})
|
||||
|
||||
test("index surfaces non-English newest sessions as latest digests", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_older",
|
||||
summary: "Objective: older memory work. Next: continue old validation.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 0),
|
||||
})
|
||||
await Memory.recordSession({
|
||||
root: t.root,
|
||||
sessionID: "ses_es",
|
||||
topic: "continuidad",
|
||||
summary: "Objetivo: revisar continuidad de memoria. Siguiente: verificar la etiqueta mas reciente.",
|
||||
time: Date.UTC(2026, 0, 1, 0, 1),
|
||||
})
|
||||
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(shown.index.match(/type=latest_session_digest/g)?.length).toBe(1)
|
||||
expect(shown.index).toContain("session=ses_es")
|
||||
expect(shown.index).toContain("continuidad")
|
||||
})
|
||||
})
|
||||
|
||||
test("session digest classifier is structural only", () => {
|
||||
expect(
|
||||
MemoryDigest.empty({
|
||||
file: "2026.md",
|
||||
id: "ses",
|
||||
time: "2026-01-01T00:00:00.000Z",
|
||||
topic: "Memory Updates",
|
||||
summary:
|
||||
"Recent state: Latest commit: e83a920622 feat(cli): add project memory v0. Working tree has untracked .plans and memory docs. Last saved focus: memory v0 behavior.",
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(MemoryDigest.empty("")).toBe(true)
|
||||
expect(MemoryDigest.empty({ topic: "", summary: " " })).toBe(true)
|
||||
expect(MemoryDigest.empty({ topic: "continuidad", summary: " " })).toBe(false)
|
||||
expect(MemoryDigest.empty("User: continue")).toBe(false)
|
||||
expect(MemoryDigest.empty("Continue requested; no substantive work was done.")).toBe(false)
|
||||
expect(MemoryDigest.empty("Objective: implement recall. Next: wire prompt injection.")).toBe(false)
|
||||
})
|
||||
|
||||
test("purge rejects directories not owned by memory", async () => {
|
||||
await use(async (t) => {
|
||||
await mkdir(t.root)
|
||||
const file = path.join(t.root, "keep.txt")
|
||||
await writeFile(file, "keep")
|
||||
|
||||
await expect(Memory.purge({ root: t.root })).rejects.toThrow("unowned memory root")
|
||||
expect(await Bun.file(file).text()).toBe("keep")
|
||||
})
|
||||
})
|
||||
|
||||
test("qualified forget removes only the selected record", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{ action: "add", file: "project.md", section: "Facts", key: "tests", text: "Project tests" },
|
||||
{ action: "add", file: "environment.md", section: "Commands", key: "tests", text: "bun test" },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await Memory.forget({ root: t.root, query: "project.md:Facts:tests" })
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
|
||||
expect(result.result.removed).toBe(1)
|
||||
expect(shown.sources.project).not.toContain("Project tests")
|
||||
expect(shown.sources.environment).toContain("bun test")
|
||||
})
|
||||
})
|
||||
|
||||
test("same key can exist in separate sections", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.apply({
|
||||
root: t.root,
|
||||
ops: [
|
||||
{ action: "add", file: "project.md", section: "Facts", key: "runtime", text: "Bun runs the project" },
|
||||
{ action: "add", file: "project.md", section: "Decisions", key: "runtime", text: "Keep Bun for v1" },
|
||||
],
|
||||
})
|
||||
|
||||
const shown = await Memory.show({ root: t.root })
|
||||
expect(shown.sources.project).toContain("Bun runs the project")
|
||||
expect(shown.sources.project).toContain("Keep Bun for v1")
|
||||
})
|
||||
})
|
||||
|
||||
test("failed session replacement preserves the prior digest", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.recordSession({ root: t.root, sessionID: "same", summary: "valid", time: 1 })
|
||||
|
||||
await expect(
|
||||
Memory.recordSession({ root: t.root, sessionID: "same", summary: "replacement", time: Number.NaN }),
|
||||
).rejects.toThrow("finite")
|
||||
const prior = await MemoryFiles.readSession(t.root, { sessionID: "same", max: 480 })
|
||||
expect(prior?.summary).toBe("valid")
|
||||
})
|
||||
})
|
||||
|
||||
test("deleted source and session files expire the index", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.remember({ root: t.root, key: "tests", text: "Run package tests" })
|
||||
await rm(MemoryPaths.files(t.root).project)
|
||||
expect(await MemoryFiles.indexExpired(t.root)).toBe(true)
|
||||
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.recordSession({ root: t.root, sessionID: "session", summary: "Session summary", time: 1 })
|
||||
const files = await readdir(MemoryPaths.files(t.root).sessions)
|
||||
await rm(path.join(MemoryPaths.files(t.root).sessions, files[0]!))
|
||||
await utimes(MemoryPaths.files(t.root).sessions, 0, 0)
|
||||
expect(await MemoryFiles.indexExpired(t.root)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("empty recall queries do not return unrelated memory", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await Memory.remember({ root: t.root, key: "tests", text: "Run package tests" })
|
||||
|
||||
expect(await MemoryRecall.search({ root: t.root, query: "!!!", force: true })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("unsupported state versions recover disabled", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await writeFile(MemoryPaths.files(t.root).state, JSON.stringify({ version: 2, enabled: true }))
|
||||
|
||||
const state = await MemoryFiles.readState(t.root)
|
||||
const files = await readdir(t.root)
|
||||
expect(state.enabled).toBe(false)
|
||||
expect(files.some((file) => file.startsWith("state.json.bad-"))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { MemoryMarkdown } from "../src/storage/markdown"
|
||||
|
||||
describe("memory markdown serialization", () => {
|
||||
test("parses sections and key :: text items, skipping non-items and empties", () => {
|
||||
const doc = [
|
||||
"# Project Memory",
|
||||
"",
|
||||
"## Facts",
|
||||
MemoryMarkdown.line("runtime", "Bun 1.x"),
|
||||
"- malformed line without separator",
|
||||
"- :: missing key",
|
||||
"",
|
||||
"## Decisions",
|
||||
MemoryMarkdown.line("db", "Postgres for primary store"),
|
||||
].join("\n")
|
||||
|
||||
expect(MemoryMarkdown.parse(doc)).toEqual([
|
||||
{ section: "Facts", key: "runtime", text: "Bun 1.x" },
|
||||
{ section: "Decisions", key: "db", text: "Postgres for primary store" },
|
||||
])
|
||||
})
|
||||
|
||||
test("items before the first heading take the default section", () => {
|
||||
expect(MemoryMarkdown.parse(MemoryMarkdown.line("k", "v"))).toEqual([{ section: "Facts", key: "k", text: "v" }])
|
||||
})
|
||||
|
||||
test("upsert replaces a same-key line in the section and creates absent sections", () => {
|
||||
const base = `## Facts\n${MemoryMarkdown.line("runtime", "Bun 1.0")}\n`
|
||||
|
||||
const replaced = MemoryMarkdown.upsert({ text: base, section: "Facts", line: MemoryMarkdown.line("runtime", "Bun 1.3") })
|
||||
expect(replaced.changed).toBe(true)
|
||||
expect(MemoryMarkdown.parse(replaced.text)).toEqual([{ section: "Facts", key: "runtime", text: "Bun 1.3" }])
|
||||
|
||||
const added = MemoryMarkdown.upsert({ text: base, section: "Decisions", line: MemoryMarkdown.line("db", "Postgres") })
|
||||
expect(added.changed).toBe(true)
|
||||
expect(MemoryMarkdown.parse(added.text)).toContainEqual({ section: "Decisions", key: "db", text: "Postgres" })
|
||||
|
||||
const noop = MemoryMarkdown.upsert({ text: base, section: "Facts", line: MemoryMarkdown.line("runtime", "Bun 1.0") })
|
||||
expect(noop.changed).toBe(false)
|
||||
})
|
||||
|
||||
test("upsert scopes replacement to the target section and leaves same-key lines elsewhere", () => {
|
||||
const doc = [
|
||||
"## Facts",
|
||||
MemoryMarkdown.line("a", "facts-a"),
|
||||
"## Decisions",
|
||||
MemoryMarkdown.line("a", "decisions-a"),
|
||||
].join("\n")
|
||||
|
||||
const result = MemoryMarkdown.upsert({ text: doc, section: "Facts", line: MemoryMarkdown.line("a", "facts-a2") })
|
||||
expect(MemoryMarkdown.parse(result.text)).toEqual([
|
||||
{ section: "Facts", key: "a", text: "facts-a2" },
|
||||
{ section: "Decisions", key: "a", text: "decisions-a" },
|
||||
])
|
||||
})
|
||||
|
||||
test("upsert does not clobber a different key that shares a prefix", () => {
|
||||
const doc = ["## Facts", MemoryMarkdown.line("a", "one"), MemoryMarkdown.line("ab", "two")].join("\n")
|
||||
|
||||
const result = MemoryMarkdown.upsert({ text: doc, section: "Facts", line: MemoryMarkdown.line("a", "one-updated") })
|
||||
expect(MemoryMarkdown.parse(result.text)).toEqual([
|
||||
{ section: "Facts", key: "a", text: "one-updated" },
|
||||
{ section: "Facts", key: "ab", text: "two" },
|
||||
])
|
||||
})
|
||||
|
||||
test("remove with no match leaves the document and count untouched", () => {
|
||||
const doc = ["## Facts", MemoryMarkdown.line("a", "one")].join("\n")
|
||||
const result = MemoryMarkdown.remove({ text: doc, match: () => false })
|
||||
expect(result.count).toBe(0)
|
||||
expect(result.text).toBe(doc)
|
||||
})
|
||||
|
||||
test("remove drops only matching items, preserving headings and counting removals", () => {
|
||||
const doc = [
|
||||
"## Facts",
|
||||
MemoryMarkdown.line("a", "one"),
|
||||
MemoryMarkdown.line("b", "two"),
|
||||
"## Decisions",
|
||||
MemoryMarkdown.line("a", "three"),
|
||||
].join("\n")
|
||||
|
||||
const result = MemoryMarkdown.remove({ text: doc, match: (entry) => entry.section === "Facts" && entry.key === "a" })
|
||||
expect(result.count).toBe(1)
|
||||
expect(MemoryMarkdown.parse(result.text)).toEqual([
|
||||
{ section: "Facts", key: "b", text: "two" },
|
||||
{ section: "Decisions", key: "a", text: "three" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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("...")
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user