feat(memory): add @kilocode/kilo-memory effect runtime layer (#11845)

* feat(memory): add @kilocode/kilo-memory effect runtime layer

* refactor(memory): drop dead defaultLayer alias

* chore: restore @kilocode/kilo-memory changeset from core

* fix(memory): isolate event sink failures and release per-session turn locks

- events: swallow+log host sink failures so best-effort event wiring never
  fails a memory op that already persisted (restores guard the port dropped)
- service/turn: drop the memoized turn lock once a session settles so the
  lock map no longer grows unbounded in a long-lived shared backend

* fix(memory): scope model-reported duplicate skips to claimed file/section

verifySkips() verified model-claimed duplicates against all stored items
regardless of scope. Add optional file/section to the skip schema, instruct
the model to report them for duplicate skips, and thread them into the
duplicate check so a cross-scope match downgrades to 'unsupported' instead
of confirming. Matches the scoping duplicateOps() already applies to adds.

* fix(memory): release per-root abort controllers and reject unscoped duplicate skips

- timers: ref-count the per-root AbortController and drop it when the last
  in-flight capture settles (released via an ensuring finalizer in capture),
  so the signals map no longer grows for every root a shared backend touches
- outcome/prompt: a 'duplicate' skip that names a file but no section can't be
  verified without risking a cross-section false confirm, so treat it as
  unverified; instruct the model to report both file and section

* fix(memory): only confirm duplicate skips that name the exact file+section

Extend the guard so any 'duplicate' skip missing either scope field — not just
section — is treated as unverified. A fully-unscoped claim could otherwise still
confirm against unrelated memory via fuzzy text matching. Confirm only when the
model pins the exact file+section; everything else downgrades to advisory.

* fix(memory): ref-count session turn locks instead of eager delete

Eager delete could hand a queued close() a fresh semaphore while a peer still
held the old one, reintroducing overlapping turn-close work. Ref-count holders
(queued acquirers included) and drop the entry only when the last holder
leaves; close() always releases its own holder and a deferred flush takes its
own turnLock/dropLock pair.
This commit is contained in:
Johnny Eric Amancio
2026-07-01 14:48:32 +02:00
committed by GitHub
parent 98f873a42a
commit b48570e93b
33 changed files with 2186 additions and 80 deletions
+3 -1
View File
@@ -2,4 +2,6 @@
"@kilocode/kilo-memory": minor
---
Add the project memory storage, indexing, recall, and capture safety foundation.
Add the `@kilocode/kilo-memory` foundation package: project memory storage, indexing, recall,
consolidation, and the Effect runtime layer (service, capture orchestration, and runtime ports).
Host wiring (CLI tools, prompts, HTTP API) lands in follow-up CLI/extension PRs.
+1
View File
@@ -269,6 +269,7 @@
"name": "@kilocode/kilo-memory",
"version": "7.3.45",
"dependencies": {
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
+13
View File
@@ -16,6 +16,18 @@
"./capture": "./src/capture/capture.ts",
"./commands": "./src/commands.ts",
"./digest": "./src/capture/digest.ts",
"./effect": "./src/effect/index.ts",
"./effect/capture": "./src/effect/capture.ts",
"./effect/config": "./src/effect/config.ts",
"./effect/errors": "./src/effect/errors.ts",
"./effect/events": "./src/effect/events.ts",
"./effect/instance": "./src/effect/instance.ts",
"./effect/log": "./src/effect/log.ts",
"./effect/paths": "./src/effect/paths.ts",
"./effect/ports": "./src/effect/ports.ts",
"./effect/service": "./src/effect/service.ts",
"./effect/timers": "./src/effect/timers.ts",
"./effect/turn": "./src/effect/turn.ts",
"./memory": "./src/memory.ts",
"./ops": "./src/capture/ops.ts",
"./paths": "./src/storage/paths.ts",
@@ -36,6 +48,7 @@
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"dependencies": {
"effect": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
+5 -1
View File
@@ -249,7 +249,11 @@ export namespace MemoryOperations {
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 })
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)
+37 -9
View File
@@ -4,7 +4,12 @@ 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 CaptureSourceItem = {
id: string
text: string
file?: MemoryOperations.Add["file"]
section?: string
}
export type CaptureDetail = {
type: "saved" | "skipped"
@@ -96,13 +101,21 @@ function tokens(input: string) {
return MemoryShared.terms(input)
}
function duplicate(text: string | undefined, items: CaptureSourceItem[]) {
function duplicate(input: {
text: string | undefined
items: CaptureSourceItem[]
file?: MemoryOperations.Add["file"]
section?: string
}) {
const text = input.text
if (!text) return
const query = tokens(text)
if (query.length === 0) return
// Majority overlap required: a few shared generic terms must not confirm a duplicate.
const needed = Math.max(Math.min(3, query.length), Math.ceil(query.length / 2))
const hits = items
const hits = input.items
.filter((item) => !input.file || !item.file || item.file === input.file)
.filter((item) => !input.section || !item.section || item.section === input.section)
.map((item) => {
const hay = tokens(item.text)
const found = query.filter((term) => hay.includes(term)).length
@@ -113,26 +126,36 @@ function duplicate(text: string | undefined, items: CaptureSourceItem[]) {
return hits.at(0)?.item.id
}
/** Model-claimed duplicates are verified against stored entries; unconfirmed claims are rescued as ops instead of lost. */
/** Model-claimed duplicates are verified against stored entries; unconfirmed claims are downgraded to
* "unsupported" so they read as advisory rather than confirmed against a real entry. */
export function verifySkips(input: { skipped: CaptureSkip[]; items: CaptureSourceItem[] }) {
const skipped: CaptureSkip[] = []
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)
// A model-claimed duplicate is only confirmable when it names the exact scope (file + section).
// Any missing scope field would let fuzzy text matching confirm against unrelated memory, so
// downgrade partially-scoped or unscoped claims to advisory instead.
const scoped = item.file !== undefined && item.section !== undefined
const source = scoped
? duplicate({ text: item.text, items: input.items, file: item.file, section: item.section })
: undefined
if (source) {
skipped.push({ ...item, duplicateOf: item.duplicateOf ?? source })
continue
}
skipped.push({ reason: "unsupported", text: item.text })
}
return { skipped, rescued }
return { skipped }
}
export function duplicateOps(input: { ops: MemoryOperations.Op[]; skipped: CaptureSkip[]; items: CaptureSourceItem[] }) {
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
@@ -141,7 +164,12 @@ export function duplicateOps(input: { ops: MemoryOperations.Op[]; skipped: Captu
skipped.push(rejected)
return false
}
const source = duplicate(`${item.key} ${item.text}`, input.items)
const source = duplicate({
text: `${item.key} ${item.text}`,
items: input.items,
file: item.file,
section: item.section,
})
if (!source) return true
skipped.push({ reason: "duplicate", text: item.text, duplicateOf: source })
return false
+5 -2
View File
@@ -26,8 +26,7 @@ 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()
) => z.object({ op: z.literal(op), key, value }).strict()
export const typedSchema = z
.object({
@@ -64,6 +63,10 @@ export const typedSchema = z
reason: skip,
text: z.string().max(500).optional(),
duplicateOf: z.string().max(240).optional(),
// Optional scope of the entry this skip claims to duplicate, so duplicate verification
// matches within the same file/section instead of across all stored memory.
file: z.enum(["project.md", "environment.md", "corrections.md"]).optional(),
section: z.string().max(80).optional(),
})
.strict(),
)
+10 -1
View File
@@ -1,7 +1,16 @@
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_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]
+509
View File
@@ -0,0 +1,509 @@
import { Cause, Effect } from "effect"
import {
auditOps,
cap,
capturePlan,
digestPrompt,
digestSchema,
duplicateOps,
errorReason,
evidence,
fallbackDigest,
guardReason,
hasDurableDiff,
mergeOps,
notice,
parseDigest,
parseJson,
parseOps,
skipped,
summarize,
summarizeDiffs,
typedPrompt,
typedSchema,
usage,
verifySkips,
type CaptureReason,
type CaptureSkip,
type CaptureSourceItem,
} from "../capture/capture"
import { MemoryDigest } from "../capture/digest"
import type { MemoryOperations } from "../capture/ops"
import { MemoryRedact } from "../capture/redact"
import { MemorySchema } from "../schema"
import { MemoryShared } from "../recall/shared"
import { MemoryEvents } from "./events"
import { MemoryLog } from "./log"
import type { MemoryPorts } from "./ports"
import { MemoryService } from "./service"
import { MemoryTimers } from "./timers"
const MESSAGE_WINDOW = 24
/** Heuristic: an assistant answer that mostly restates injected instructions/source files is not
* durable project memory and should not be consolidated. */
function provenance(input: { assistant: string }) {
const assistant = input.assistant.trim()
const markers = [/\bsystem\s*\/\s*developer\b/gi, /\bagents\.md\b/gi, /\bclaude\.md\b/gi].reduce(
(sum, item) => sum + (assistant.match(item)?.length ?? 0),
0,
)
const list = assistant.split("\n").filter((line) => /^\s*[-*]\s+\S/.test(line)).length
return markers >= 4 || (markers >= 3 && list >= 2)
}
function typedExisting(memory: MemoryService.Interface, root: string) {
return memory.sources({ root }).pipe(
Effect.map((sources) => {
const blocks = MemorySchema.Sources.map((file) => {
const body = sources[file].trim()
if (!body) return ""
return [`### source ${file}`, body].join("\n")
})
return blocks.filter(Boolean).join("\n")
}),
)
}
function itemSource(file: MemorySchema.Source, text: string): CaptureSourceItem[] {
return MemoryShared.source({ file, text })
}
function typedItems(memory: MemoryService.Interface, root: string) {
return memory
.sources({ root })
.pipe(Effect.map((sources) => MemorySchema.Sources.flatMap((file) => itemSource(file, sources[file]))))
}
export namespace MemoryCapture {
export const turn = Effect.fn("MemoryCapture.turn")(function* (input: {
root: string
sessionID: string
session: MemoryPorts.SessionPort
model: MemoryPorts.ModelPort
reason?: CaptureReason
bypassInterval?: boolean
memoryModel?: string
}) {
const root = input.root
// Acquire first (sync, cannot fail) so the matching `release` in the finalizer below always pairs
// with this acquire regardless of where the turn exits.
const signal = MemoryTimers.signal(root)
const memory = yield* MemoryService.Service
yield* memory.prepare({ root })
const state = yield* memory.state({ root })
const reported = new Set<string>()
const fail = (reason: string) =>
Effect.promise(async () => {
const safe = MemoryRedact.text(reason)
if (reported.has(safe)) return
reported.add(safe)
await MemoryEvents.publish({
event: "error",
payload: MemoryEvents.status({
root,
state,
phase: "error",
reason: safe,
sessionID: input.sessionID,
}),
})
})
const skip = (reason: string, opts?: { idleFlush?: boolean }) =>
Effect.gen(function* () {
if (state.enabled) yield* memory.decide({ root, decision: skipped({ sessionID: input.sessionID, reason }) })
yield* Effect.promise(() =>
MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root,
state,
phase: "skipped",
reason,
sessionID: input.sessionID,
}),
}),
)
return { root, skipped: true as const, reason, idleFlush: opts?.idleFlush === true }
})
if (!state.enabled || !state.capture.turnClose) return yield* skip("disabled")
const now = Date.now()
const view = yield* input.session.readTurn({ sessionID: input.sessionID, window: MESSAGE_WINDOW })
if (!view) return yield* skip("no_turn")
if (input.bypassInterval && state.stats.lastConsolidatedMessageID === view.lastAssistantID)
return yield* skip("no_new_content")
const user = view.user
const assistant = view.assistant
const recent = view.recent
const summary = summarize({ user, assistant, max: state.limits.maxSessionLineChars })
const diffs = view.diffs
const changed = summarizeDiffs(diffs)
const durable = hasDurableDiff(diffs)
const completed = !input.reason || input.reason === "completed"
// Echo = short lookup answered from memory with no file changes. Long recall-assisted answers
// (research, investigations) carry new content and must still be digested.
const echo = !durable && assistant.length < 1200 && view.recalledMemory
const sourced = provenance({ assistant })
const session = completed && !echo && Boolean(summary)
const prior = session
? yield* memory.session({ root, sessionID: input.sessionID, max: state.limits.maxSessionLineChars })
: undefined
const priorTime = prior?.time ? Date.parse(prior.time) : 0
const plan = capturePlan({
reason: input.reason,
summary,
echo,
durable,
priorTime,
now,
minIntervalMs: state.capture.minIntervalMs,
lastConsolidatedAt: state.stats.lastConsolidatedAt,
bypassInterval: input.bypassInterval,
autoConsolidate: state.autoConsolidate,
})
const digestDue = plan.digestDue
const typedCall = plan.typedCall
if (plan.skipReason) return yield* skip(plan.skipReason, plan.idleFlush ? { idleFlush: true } : undefined)
yield* Effect.promise(() =>
MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({ root, state, phase: "checking", sessionID: input.sessionID }),
}),
)
const model =
digestDue || typedCall
? yield* Effect.gen(function* () {
const resolution = yield* input.model.resolve({
configured: input.memoryModel,
session: view.sessionModel,
})
if (resolution.fallback) {
yield* memory.append({
root,
text: `memory_model_config reason=${MemoryShared.brief(
MemoryRedact.text(resolution.fallback.reason),
160,
)} fallback=1`,
})
}
return resolution.handle
})
: undefined
const fallback = MemoryRedact.text(
fallbackDigest({ prior: prior?.summary, summary, max: state.limits.maxSessionLineChars }),
)
const safe = MemoryDigest.empty(fallback) ? "" : fallback
const digestEffect = digestDue
? Effect.gen(function* () {
const body = cap(
evidence([
{ title: "latest_user", body: user },
{ title: "latest_assistant", body: assistant || "(no assistant text)" },
{ title: "diff_summary", body: changed || "(none)" },
{ title: "previous_digest", body: prior?.summary },
{ title: "max_characters", body: String(state.limits.maxSessionLineChars) },
]),
state.limits.maxConsolidationInputBytes,
)
const result = yield* Effect.tryPromise({
try: () =>
input.model.run({
handle: model!,
system: digestPrompt,
prompt: body,
timeoutMs: state.capture.timeoutMs,
signal,
}),
catch: (error) => error,
}).pipe(
Effect.map((result) => ({ ok: true as const, result })),
Effect.catch((err: unknown) =>
Effect.gen(function* () {
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
const raw = errorReason(err)
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
yield* fail(reason)
yield* memory.append({ root, text: `digest error=${MemoryShared.brief(reason, 160)} fallback=1` })
return { ok: false as const, reason }
}),
),
)
if (!result.ok) {
return {
topic: "",
summary: safe,
tokens: 0,
reason: result.reason,
}
}
const parsed = yield* Effect.try({
try: () => parseJson(digestSchema, result.result.text),
catch: (error) => error,
}).pipe(
Effect.catch((err: unknown) =>
Effect.gen(function* () {
const reason = MemoryRedact.text(errorReason(err))
yield* fail("digest parse_error")
yield* memory.append({
root,
text: `digest parse_error=${MemoryShared.brief(reason, 160)} fallback=1`,
})
return undefined
}),
),
)
if (!parsed) {
return { topic: "", summary: safe, tokens: usage(result.result.usage), reason: "parse_error" }
}
const parsedDigest = parseDigest(parsed, fallback, state.limits.maxSessionLineChars)
return {
topic: MemoryRedact.text(parsedDigest.topic),
summary: MemoryRedact.text(parsedDigest.summary),
tokens: usage(result.result.usage),
reason: undefined as string | undefined,
}
})
: Effect.succeed({
topic: "",
summary: "",
tokens: 0,
reason: undefined as string | undefined,
})
const typedEffect = typedCall
? Effect.gen(function* () {
if (sourced) {
return {
ops: [] as MemoryOperations.Op[],
tokens: 0,
fallback: false,
reason: undefined as string | undefined,
skipped: [
{
reason: "out_of_scope" as const,
text: "Instruction/source provenance answers are not durable project memory.",
},
] satisfies CaptureSkip[],
fallbackOperationCount: 0,
}
}
const existing = yield* typedExisting(memory, root)
const items = yield* typedItems(memory, root)
const sessions = yield* memory.recent({
root,
limit: state.limits.maxSessionFiles,
max: state.limits.maxSessionLineChars,
})
const body = cap(
evidence([
{ title: "close_reason", body: input.reason ?? "completed" },
{ title: "latest_user", body: user },
{ title: "latest_assistant", body: assistant || "(no assistant text)" },
{ title: "diff_summary", body: changed || "(none)" },
{ title: "existing_memory", body: existing },
{ title: "recent_session_context", body: recent },
{
title: "recent_memory_digests",
body: sessions
.map((item) => `${item.file} session=${item.id} ${item.time} :: ${item.summary}`)
.join("\n"),
},
]),
state.limits.maxConsolidationInputBytes,
)
const result = yield* Effect.tryPromise({
try: () =>
input.model.run({
handle: model!,
system: typedPrompt,
prompt: body,
timeoutMs: state.capture.timeoutMs,
signal,
}),
catch: (error) => error,
}).pipe(
Effect.map((result) => ({ ok: true as const, result })),
Effect.catch((err: unknown) =>
Effect.gen(function* () {
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
const raw = errorReason(err)
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
yield* fail(reason)
yield* memory.append({ root, text: `consolidate error=${MemoryShared.brief(reason, 160)}` })
return { ok: false as const, reason }
}),
),
)
if (!result.ok) {
return {
ops: [] as MemoryOperations.Op[],
tokens: 0,
fallback: true,
reason: result.reason,
skipped: [] as CaptureSkip[],
fallbackOperationCount: 0,
}
}
const parsed = yield* Effect.try({
try: () => parseJson(typedSchema, result.result.text),
catch: (error) => error,
}).pipe(
Effect.catch((err: unknown) =>
Effect.gen(function* () {
const reason = MemoryRedact.text(errorReason(err))
yield* fail("consolidate parse_error")
yield* memory.append({ root, text: `consolidate parse_error=${MemoryShared.brief(reason, 160)}` })
return undefined
}),
),
)
if (!parsed) {
return {
ops: [] as MemoryOperations.Op[],
tokens: usage(result.result.usage),
fallback: true,
reason: "parse_error",
skipped: [] as CaptureSkip[],
fallbackOperationCount: 0,
}
}
const verified = verifySkips({ skipped: parsed.skipped, items })
const deduped = duplicateOps({ ops: parseOps(parsed), skipped: verified.skipped, items })
return {
ops: deduped.ops,
tokens: usage(result.result.usage),
fallback: false,
reason: undefined as string | undefined,
skipped: deduped.skipped,
fallbackOperationCount: 0,
}
})
: Effect.succeed({
ops: [] as MemoryOperations.Op[],
tokens: 0,
fallback: false,
reason: undefined as string | undefined,
skipped: [] as CaptureSkip[],
fallbackOperationCount: 0,
})
// Digest and typed consolidation are independent model calls; run them concurrently.
const [digest, generated] = yield* Effect.all([digestEffect, typedEffect], { concurrency: 2 })
if (signal.aborted) return yield* skip("cancelled")
if (digest.summary) {
yield* memory.recordSession({
root,
sessionID: input.sessionID,
topic: digest.topic,
summary: digest.summary,
time: now,
tokens: digest.tokens,
})
}
if (digestDue) {
yield* memory.decide({
root,
decision: {
kind: "digest",
trigger: "turn-close",
sessionID: input.sessionID,
result: digest.reason ? "fallback" : digest.summary ? "saved" : "skipped",
llm: true,
parsed: Boolean(digest.summary && !digest.reason),
fallback: Boolean(digest.reason),
reason: digest.reason,
tokens: digest.tokens,
operationCount: digest.summary ? 1 : 0,
skippedCount: digest.summary ? 0 : 1,
summary: digest.reason
? `session digest used fallback after ${digest.reason}`
: digest.summary
? "session digest saved"
: "session digest skipped",
},
})
}
const ops = mergeOps(generated.ops)
.filter((item) => item.action !== "remove")
.slice(0, state.capture.maxOpsPerRun)
const project =
ops.length > 0 ? yield* memory.apply({ root, ops, trigger: "turn-close", tokens: generated.tokens }) : undefined
const count = project?.operationCount ?? 0
if (typedCall) {
yield* memory.decide({
root,
decision: {
kind: "typed",
trigger: "turn-close",
sessionID: input.sessionID,
result: generated.fallback ? "fallback" : count > 0 ? "saved" : "skipped",
llm: true,
parsed: !generated.fallback,
fallback: generated.fallback,
reason: generated.reason,
tokens: generated.tokens,
operationCount: count,
skippedCount: generated.skipped.length,
fallbackOperationCount: generated.fallbackOperationCount,
skipped: generated.skipped,
operations: auditOps(ops),
files: [...new Set(ops.flatMap((item) => (item.action === "add" && item.file ? [item.file] : [])))],
summary: generated.fallback
? `typed consolidation skipped after ${generated.reason ?? "model failure"}`
: count > 0
? `typed consolidation saved ${count} ops`
: `typed consolidation skipped ${generated.skipped.length} candidates`,
},
})
}
const tokens = digest.tokens + generated.tokens
if (!digest.summary && !typedCall && count === 0) return yield* skip("no_ops")
if ((digestDue || typedCall || count > 0) && (!typedCall || !generated.fallback)) {
yield* memory.commit({
root,
now,
messageID: view.lastAssistantID,
tokens,
count,
digest: Boolean(digest.summary),
skipped: generated.skipped,
})
}
const updated = yield* memory.state({ root })
const index = project?.index ?? (yield* memory.index({ root }))
const detail = typedCall
? notice({
count,
ops,
skipped: generated.skipped,
tokens: generated.tokens,
})
: undefined
yield* Effect.promise(() =>
MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root,
state: updated,
index,
phase: "idle",
sessionID: input.sessionID,
consolidation: { trigger: "turn-close", operationCount: count, cost: 0, tokens },
...(detail ? { detail } : {}),
}),
}),
)
return { root, skipped: false as const, operationCount: count, tokens }
},
// Release the per-root abort controller acquired at the top once the turn settles (any exit path).
(effect, input) => effect.pipe(Effect.ensuring(Effect.sync(() => MemoryTimers.release(input.root)))))
export function report(cause: Cause.Cause<unknown>) {
// Brief message only: API errors carry response headers/bodies that would flood the host log.
const err = Cause.squash(cause)
MemoryLog.warn("memory capture failed", {
err: (err instanceof Error ? err.message : String(err)).slice(0, 200),
})
}
}
+13
View File
@@ -0,0 +1,13 @@
export namespace MemoryConfig {
export type Model = { providerID: string; modelID: string }
/** Parse a `providerID/modelID` memory-model override. Returns undefined when blank or malformed
* so callers can fall back to the session model. */
export function parse(value: string | undefined): Model | undefined {
if (!value) return undefined
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) return undefined
return { providerID, modelID }
}
}
+168
View File
@@ -0,0 +1,168 @@
import { Schema } from "effect"
import { MemoryRedact } from "../capture/redact"
// Typed API error shapes so the SDK / OpenAPI reflect the real contract.
export class MemoryApiClientError extends Schema.ErrorClass<MemoryApiClientError>("MemoryApiClientError")(
{
name: Schema.Literal("MemoryApiClientError"),
data: Schema.Struct({ code: Schema.String, message: Schema.String }),
},
{ httpApiStatus: 400 },
) {}
export class MemoryApiServerError extends Schema.ErrorClass<MemoryApiServerError>("MemoryApiServerError")(
{
name: Schema.Literal("MemoryApiServerError"),
data: Schema.Struct({ code: Schema.String, message: Schema.String }),
},
{ httpApiStatus: 503 },
) {}
export class MemoryDisabledError extends Schema.TaggedErrorClass<MemoryDisabledError>()("MemoryDisabledError", {
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {
override get message() {
return this.reason
}
}
export class MemoryInvalidInputError extends Schema.TaggedErrorClass<MemoryInvalidInputError>()(
"MemoryInvalidInputError",
{
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
},
) {
override get message() {
return this.reason
}
}
export class MemoryStorageError extends Schema.TaggedErrorClass<MemoryStorageError>()("MemoryStorageError", {
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {
override get message() {
return this.reason
}
}
export class MemoryRootError extends Schema.TaggedErrorClass<MemoryRootError>()("MemoryRootError", {
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {
override get message() {
return this.reason
}
}
export class MemoryCorruptStateError extends Schema.TaggedErrorClass<MemoryCorruptStateError>()(
"MemoryCorruptStateError",
{
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
},
) {
override get message() {
return this.reason
}
}
export class MemoryUnknownError extends Schema.TaggedErrorClass<MemoryUnknownError>()("MemoryUnknownError", {
reason: Schema.String,
cause: Schema.optional(Schema.Unknown),
}) {
override get message() {
return this.reason
}
}
export type MemoryError =
| MemoryDisabledError
| MemoryInvalidInputError
| MemoryStorageError
| MemoryRootError
| MemoryCorruptStateError
| MemoryUnknownError
function reason(err: unknown) {
const raw = err instanceof Error ? err.message : String(err)
return MemoryRedact.text(raw.replaceAll(/\s+/g, " ").slice(0, 240)) || "unknown memory error"
}
function tag(err: unknown): MemoryError | undefined {
if (!err || typeof err !== "object" || !("_tag" in err)) return
const value = String(err._tag)
if (!value.startsWith("Memory")) return
return err as MemoryError
}
export namespace MemoryError {
export function from(err: unknown): MemoryError {
const known = tag(err)
if (known) return known
const text = reason(err)
const lower = text.toLowerCase()
if (lower.includes("memory is disabled")) return new MemoryDisabledError({ reason: text, cause: err })
if (
/\b(symlink|memory path|memory root|parent is not a directory|path is not a file|path is not a directory)\b/.test(
lower,
)
) {
return new MemoryRootError({ reason: text, cause: err })
}
if (/\b(state\.json|corrupt|recover|parse error|unexpected token)\b/.test(lower)) {
return new MemoryCorruptStateError({ reason: text, cause: err })
}
if (/\b(lock|eacces|eperm|enoent|eio|emfile|enospc)\b/.test(lower)) {
return new MemoryStorageError({ reason: text, cause: err })
}
if (/\b(invalid|schema|zod|section|key|text|source|secret-like|malformed|reject)\b/.test(lower)) {
return new MemoryInvalidInputError({ reason: text, cause: err })
}
return new MemoryUnknownError({ reason: text, cause: err })
}
export function message(err: unknown) {
return from(err).message
}
// Map typed taxonomy to HTTP error contract; redaction already applied via .message.
export function toHttp(err: MemoryError): MemoryApiClientError | MemoryApiServerError {
const msg = err.message
switch (err._tag) {
case "MemoryDisabledError":
return new MemoryApiClientError({
name: "MemoryApiClientError",
data: { code: "memory_disabled", message: msg },
})
case "MemoryInvalidInputError":
return new MemoryApiClientError({
name: "MemoryApiClientError",
data: { code: "memory_invalid_input", message: msg },
})
case "MemoryRootError":
return new MemoryApiClientError({
name: "MemoryApiClientError",
data: { code: "memory_root_error", message: msg },
})
case "MemoryStorageError":
return new MemoryApiServerError({
name: "MemoryApiServerError",
data: { code: "memory_storage_error", message: msg },
})
case "MemoryCorruptStateError":
return new MemoryApiServerError({
name: "MemoryApiServerError",
data: { code: "memory_corrupt_state", message: msg },
})
default:
return new MemoryApiServerError({ name: "MemoryApiServerError", data: { code: "memory_error", message: msg } })
}
}
export function toToolOutput(err: unknown, action: string) {
return `Kilo memory ${action} failed: ${message(err)}`
}
}
+123
View File
@@ -0,0 +1,123 @@
import { Schema } from "effect"
import type { MemorySchema } from "../schema"
import { MemoryLog } from "./log"
export namespace MemoryEvents {
const Metric = Schema.Struct({
bytes: Schema.Number,
estimatedTokens: Schema.Number,
truncated: Schema.Boolean,
updatedAt: Schema.optional(Schema.Number),
})
const Phase = Schema.Literals(["idle", "checking", "injecting", "updating", "skipped", "error"])
const Trigger = Schema.Literals(["explicit", "turn-close", "rebuild"])
const Consolidation = Schema.Struct({
trigger: Trigger,
operationCount: Schema.Number,
cost: Schema.Number,
tokens: Schema.Number,
})
const Detail = Schema.Struct({
type: Schema.Literals(["saved", "skipped", "recalled"]),
message: Schema.String,
reason: Schema.optional(Schema.String),
duplicateOf: Schema.optional(Schema.String),
tokens: Schema.optional(Schema.Number),
operationCount: Schema.optional(Schema.Number),
skippedCount: Schema.optional(Schema.Number),
sources: Schema.optional(Schema.Array(Schema.String)),
files: Schema.optional(Schema.Array(Schema.String)),
})
export const Payload = Schema.Struct({
directory: Schema.String,
sessionID: Schema.optional(Schema.String),
enabled: Schema.Boolean,
state: Phase,
reason: Schema.optional(Schema.String),
project: Metric,
consolidation: Schema.optional(Consolidation),
detail: Schema.optional(Detail),
})
export type Phase = Schema.Schema.Type<typeof Phase>
export type Trigger = Schema.Schema.Type<typeof Trigger>
export type Status = Schema.Schema.Type<typeof Payload>
export type Index = { bytes: number; tokens: number; truncated: boolean }
export type Inspect = {
root: string
state: MemorySchema.State
sources: {
project: string
environment: string
corrections: string
}
index: string
changes: string
}
function metric(index?: Index, updatedAt?: number | null) {
return {
bytes: index?.bytes ?? 0,
estimatedTokens: index?.tokens ?? 0,
truncated: index?.truncated ?? false,
...(updatedAt ? { updatedAt } : {}),
}
}
function latest(...items: (number | null)[]) {
const values = items.filter((item): item is number => typeof item === "number" && Number.isFinite(item))
return values.length ? Math.max(...values) : undefined
}
export function status(input: {
root: string
state: MemorySchema.State
index?: Index
phase?: Phase
reason?: string
sessionID?: string
consolidation?: Status["consolidation"]
detail?: Status["detail"]
}): Status {
const updated = latest(input.state.stats.lastInjectedAt, input.state.stats.lastConsolidatedAt)
const current = metric(input.index, updated)
return {
directory: input.root,
...(input.sessionID ? { sessionID: input.sessionID } : {}),
enabled: input.state.enabled,
state: input.phase ?? "idle",
...(input.reason ? { reason: input.reason } : {}),
project: current,
...(input.consolidation ? { consolidation: input.consolidation } : {}),
...(input.detail ? { detail: input.detail } : {}),
}
}
export type Event = "status" | "updated" | "error"
export type Sink = (input: { event?: Event; payload: Status }) => Promise<void> | void
// Best-effort: opencode wires this to its Bus at bootstrap; defaults to a no-op so the package
// never reaches into the host event system on its own.
let sink: Sink = () => {}
export function setSink(next: Sink) {
sink = next
}
export async function publish(input: { event?: Event; payload: Status }) {
// Event wiring is best-effort: a failing host sink must not fail a memory op that already
// persisted, so swallow and log instead of propagating to callers.
try {
await sink(input)
} catch (err) {
MemoryLog.warn("memory event publish failed", {
err: (err instanceof Error ? err.message : String(err)).slice(0, 200),
})
}
}
}
+340
View File
@@ -0,0 +1,340 @@
import { Memory } from "../memory"
import type { MemoryOperations } from "../capture/ops"
import { MemorySchema } from "../schema"
import { MemoryFiles } from "../storage/store"
import { MemoryToken } from "../recall/token"
import { MemoryEvents } from "./events"
import { MemoryPaths } from "./paths"
import { MemoryTimers } from "./timers"
import { MemoryDisabledError } from "./errors"
/** Context-bound Kilo adapter over the root-bound package primitives. Prefer ctx inputs at runtime edges. */
export namespace KiloMemory {
export type Input =
| {
root: string
sessionID?: string
record?: boolean
}
| {
ctx: MemoryPaths.Ctx
sessionID?: string
record?: boolean
}
export type Block = Memory.Block
function root(input: Input) {
return "root" in input ? input.root : MemoryPaths.root(input)
}
async function noop(dir: string): Promise<MemoryOperations.Result> {
const text = await MemoryFiles.readIndex(dir)
const index = {
text,
bytes: Buffer.byteLength(text),
tokens: MemoryToken.estimate(text),
truncated: false,
}
return { operationCount: 0, added: 0, removed: 0, skipped: [], index }
}
async function requireEnabled(dir: string) {
const state = await MemoryFiles.readState(dir)
if (state.enabled) return state
throw new MemoryDisabledError({ reason: "project memory is disabled" })
}
export async function prepare(input: Input) {
return root(input)
}
export async function status(input: Input) {
return Memory.status({ root: await prepare(input) })
}
export async function enable(input: Input) {
const dir = await prepare(input)
const id = "ctx" in input ? MemoryPaths.identity({ ctx: input.ctx }) : undefined
const result = await Memory.enable({ root: dir, id })
await MemoryEvents.publish({
event: "updated",
payload: MemoryEvents.status({
root: dir,
state: result.state,
index: result.index,
phase: "idle",
consolidation: { trigger: "rebuild", operationCount: 0, cost: 0, tokens: result.index.tokens },
}),
})
return result
}
export async function disable(input: Input) {
const dir = await prepare(input)
MemoryTimers.clear(dir)
const result = await Memory.disable({ root: dir })
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({ root: result.root, state: result.state, phase: "idle" }),
})
return result
}
export async function show(input: Input) {
return Memory.show({ root: await prepare(input) })
}
export async function rebuild(input: Input) {
const dir = await prepare(input)
const state = await MemoryFiles.readState(dir)
if (!state.enabled) {
const index = (await noop(dir)).index
return { root: dir, state, index }
}
const result = await Memory.rebuild({ root: dir })
await MemoryEvents.publish({
event: "updated",
payload: MemoryEvents.status({
root: result.root,
state: result.state,
index: result.index,
phase: "idle",
consolidation: { trigger: "rebuild", operationCount: 0, cost: 0, tokens: result.index.tokens },
}),
})
return result
}
export async function configure(
input: Input & {
settings: Partial<Pick<MemorySchema.State, "autoConsolidate">>
},
) {
const result = await Memory.configure({ root: await prepare(input), settings: input.settings })
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({ root: result.root, state: result.state, phase: "idle" }),
})
return result
}
export async function context(input: Input) {
const result = await Memory.context({
root: await prepare(input),
sessionID: input.sessionID,
record: input.record,
})
if (result.recorded) {
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: result.root,
state: result.state,
index: result.index,
phase: "injecting",
sessionID: input.sessionID,
}),
})
}
return result
}
export async function toolEnabled(input: Input) {
return Memory.toolEnabled({ root: "ctx" in input ? await prepare(input) : root(input) })
}
async function publish(input: {
output: Memory.Apply
sessionID?: string
trigger?: Memory.Trigger
cost?: number
tokens?: number
}) {
await MemoryEvents.publish({
event: "updated",
payload: MemoryEvents.status({
root: input.output.root,
state: input.output.state,
index: input.output.result.index,
phase: "updating",
sessionID: input.sessionID,
consolidation: {
trigger: input.trigger ?? "explicit",
operationCount: input.output.result.operationCount,
cost: input.cost ?? 0,
tokens: input.tokens ?? 0,
},
...(input.output.detail ? { detail: input.output.detail } : {}),
}),
})
}
export async function apply(
input: Input & {
ops: MemoryOperations.Op[]
trigger?: Memory.Trigger
cost?: number
tokens?: number
},
) {
const dir = await prepare(input)
await requireEnabled(dir)
const output = await Memory.apply({
root: dir,
ops: input.ops,
trigger: input.trigger,
sessionID: input.sessionID,
tokens: input.tokens,
})
await publish({
output,
sessionID: input.sessionID,
trigger: input.trigger,
cost: input.cost,
tokens: input.tokens,
})
return output.result
}
export async function forget(input: Input & { query: string }) {
const dir = await prepare(input)
await requireEnabled(dir)
const output = await Memory.forget({ root: dir, query: input.query, sessionID: input.sessionID })
await publish({ output, sessionID: input.sessionID })
return output.result
}
export async function remember(
input: Input & {
text: string
key?: string
file?: MemorySchema.Source
section?: string
},
) {
const dir = await prepare(input)
await requireEnabled(dir)
const output = await Memory.remember({
root: dir,
text: input.text,
key: input.key,
file: input.file,
section: input.section,
sessionID: input.sessionID,
})
await publish({ output, sessionID: input.sessionID })
return output.result
}
export async function correct(input: Input & { text: string; key?: string }) {
const dir = await prepare(input)
await requireEnabled(dir)
const output = await Memory.correct({
root: dir,
text: input.text,
key: input.key,
sessionID: input.sessionID,
})
await publish({ output, sessionID: input.sessionID })
return output.result
}
export async function purge(input: Input) {
const dir = root(input)
MemoryTimers.clear(dir)
const result = await Memory.purge({ root: dir })
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: result.root,
state: result.state,
phase: "idle",
reason: result.purged ? "purged" : "missing",
}),
})
return { root: result.root, purged: result.purged }
}
export async function recall(input: Input & { query: string; sessionID?: string }) {
const output = await Memory.recall({ root: await prepare(input), query: input.query, sessionID: input.sessionID })
if (!output.state.enabled) return
if (!output.result) {
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: output.root,
state: output.state,
phase: "skipped",
sessionID: input.sessionID,
detail: {
type: "skipped",
message: "Memory skipped · no recall matches",
reason: "no_matches",
skippedCount: 1,
},
}),
})
return
}
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: output.root,
state: output.state,
phase: "injecting",
sessionID: input.sessionID,
detail: {
type: "recalled",
message: `Memory recalled · ${output.result.hits.length} ${output.result.hits.length === 1 ? "item" : "items"}`,
tokens: output.result.tokens,
operationCount: output.result.hits.length,
sources: output.files,
files: output.files,
},
}),
})
return { root: output.root, ...output.result }
}
export async function recordSession(
input: Input & { sessionID: string; topic?: string; summary: string; time?: number; tokens?: number },
) {
const output = await Memory.recordSession({
root: await prepare(input),
sessionID: input.sessionID,
topic: input.topic,
summary: input.summary,
time: input.time,
tokens: input.tokens,
})
if (output.skipped) {
await MemoryEvents.publish({
event: "status",
payload: MemoryEvents.status({
root: output.root,
state: output.state,
phase: "skipped",
reason: output.reason,
sessionID: input.sessionID,
}),
})
return { skipped: true, reason: output.reason }
}
await MemoryEvents.publish({
event: "updated",
payload: MemoryEvents.status({
root: output.root,
state: output.state,
index: output.index,
phase: "updating",
sessionID: input.sessionID,
consolidation: { trigger: "turn-close", operationCount: 0, cost: 0, tokens: input.tokens ?? 0 },
}),
})
return { skipped: false, index: output.index }
}
}
export { MemoryEvents } from "./events"
export { MemoryPaths } from "./paths"
@@ -0,0 +1,16 @@
/** Injectable instance-context binder. The Effect service bridges async package calls through
* this binder so host-provided context (e.g. opencode's per-instance ALS) survives the await.
* Defaults to identity so the package stays runnable without a host. */
export namespace MemoryInstance {
export type Binder = <A>(fn: () => Promise<A>) => () => Promise<A>
let binder: Binder = (fn) => fn
export function setBinder(next: Binder) {
binder = next
}
export function bind<A>(fn: () => Promise<A>): () => Promise<A> {
return binder(fn)
}
}
+15
View File
@@ -0,0 +1,15 @@
/** Injectable diagnostic logger. Opencode wires this to its structured logger at bootstrap;
* the package defaults to a no-op so it never reaches into the host runtime on its own. */
export namespace MemoryLog {
export type Fn = (message: string, meta?: Record<string, unknown>) => void
let warnFn: Fn = () => {}
export function setWarn(fn: Fn) {
warnFn = fn
}
export function warn(message: string, meta?: Record<string, unknown>) {
warnFn(message, meta)
}
}
+32
View File
@@ -0,0 +1,32 @@
import { homedir } from "os"
import path from "path"
import { MemoryPaths as Core } from "../storage/paths"
/** Context-bound paths over the pure core. The host (home/config dirs) is injected at bootstrap so
* the package does not hard-code the opencode global directory; defaults to `~/.kilo`. */
export namespace MemoryPaths {
export type Ctx = Core.Ctx
export type Files = Core.Files
export type Identity = Core.Identity
export type Host = Core.Host
// A provider (not a snapshot) so hosts that resolve home/config dynamically — e.g. from env at
// call time — are reflected on every `root` call.
let host: () => Host = () => ({ home: homedir(), config: path.join(homedir(), ".kilo") })
export function configure(next: () => Host) {
host = next
}
export function identity(input: { ctx: Ctx }): Identity {
return Core.identity(input)
}
export function root(input: { ctx: Ctx }) {
const { home, config } = host()
return Core.root({ ctx: input.ctx, home, config })
}
export const files = Core.files
export const source = Core.source
}
+48
View File
@@ -0,0 +1,48 @@
import type { Effect } from "effect"
import type { CaptureDiff } from "../capture/diff"
import type { MemoryError } from "./errors"
/** Runtime ports the capture pipeline depends on. The host (opencode) implements these against its
* session store and LLM provider; the package orchestration stays free of `ai`/provider types by
* treating the resolved model as an opaque handle and consuming pre-extracted turn primitives. */
export namespace MemoryPorts {
export type ModelRef = { providerID: string; modelID: string }
/** Pre-extracted view of the latest turn. All transcript/message-shape handling happens host-side
* so the orchestrator never touches the host's message model. */
export type TurnView = {
user: string
assistant: string
recent: string
lastAssistantID: string
sessionModel: ModelRef
/** True when the turn was answered from targeted recall (digesting it would echo memory back). */
recalledMemory: boolean
diffs: CaptureDiff[]
}
export interface SessionPort {
readonly readTurn: (input: {
sessionID: string
window: number
}) => Effect.Effect<TurnView | undefined, MemoryError>
readonly get: (input: { sessionID: string }) => Effect.Effect<{ parentID?: string } | undefined, MemoryError>
}
/** Opaque resolved-model handle. Carries provider/language/options on the host side; the package
* only passes it back to `run`. */
export type ModelHandle = unknown
export type ModelResolution = { handle: ModelHandle; fallback?: { reason: string } }
export interface ModelPort {
readonly resolve: (input: { configured?: string; session: ModelRef }) => Effect.Effect<ModelResolution, MemoryError>
readonly run: (input: {
handle: ModelHandle
system: string
prompt: string
timeoutMs: number
signal?: AbortSignal
}) => Promise<{ text: string; usage: unknown }>
}
}
+256
View File
@@ -0,0 +1,256 @@
import { Context, Effect, Layer, Semaphore } from "effect"
import { skipLine, type CaptureSkip } from "../capture/capture"
import type { Memory } from "../memory"
import type { MemoryOperations } from "../capture/ops"
import { MemoryRecall } from "../recall/recall"
import { MemorySchema } from "../schema"
import { MemoryFiles } from "../storage/store"
import { MemoryToken } from "../recall/token"
import { KiloMemory } from "./index"
import { MemoryInstance } from "./instance"
import { MemoryError, type MemoryError as Failure } from "./errors"
type SessionID = string
const IDLE_SETTLE_MS = 30_000
type ConfigureInput = KiloMemory.Input & {
settings: Partial<Pick<MemorySchema.State, "autoConsolidate">>
}
type ApplyInput = KiloMemory.Input & {
ops: MemoryOperations.Op[]
trigger?: Memory.Trigger
cost?: number
tokens?: number
}
type RememberInput = KiloMemory.Input & {
text: string
key?: string
file?: MemorySchema.Source
section?: string
}
type CorrectInput = KiloMemory.Input & {
text: string
key?: string
}
type ForgetInput = KiloMemory.Input & {
query: string
}
type RecallInput = KiloMemory.Input & {
query: string
sessionID?: string
}
type SearchInput = Parameters<typeof MemoryRecall.search>[0]
type RecordInput = KiloMemory.Input & {
sessionID: string
topic?: string
summary: string
time?: number
tokens?: number
}
type DecideInput = {
root: string
decision: MemoryFiles.Decision
}
type ReadSourceInput = {
root: string
file: MemorySchema.Source
}
type RootInput = {
root: string
}
type SessionInput = RootInput & {
sessionID: string
max: number
}
type RecentInput = RootInput & {
limit: number
max: number
}
type AppendInput = RootInput & {
text: string
}
type Sources = Record<MemorySchema.Source, string>
type Index = {
bytes: number
tokens: number
truncated: false
}
type CommitInput = RootInput & {
now: number
messageID: string
tokens: number
count: number
digest: boolean
skipped: CaptureSkip[]
cost?: number
}
function bridge<A>(fn: () => Promise<A>) {
return Effect.tryPromise({
try: MemoryInstance.bind(fn),
catch: MemoryError.from,
})
}
export namespace MemoryService {
export type Timing = { settleMs: number }
export interface Interface {
readonly prepare: (input: KiloMemory.Input) => Effect.Effect<string, Failure>
readonly status: (input: KiloMemory.Input) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.status>>, Failure>
readonly show: (input: KiloMemory.Input) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.show>>, Failure>
readonly enable: (input: KiloMemory.Input) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.enable>>, Failure>
readonly disable: (
input: KiloMemory.Input,
) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.disable>>, Failure>
readonly rebuild: (
input: KiloMemory.Input,
) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.rebuild>>, Failure>
readonly configure: (
input: ConfigureInput,
) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.configure>>, Failure>
readonly apply: (input: ApplyInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.apply>>, Failure>
readonly remember: (input: RememberInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.remember>>, Failure>
readonly correct: (input: CorrectInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.correct>>, Failure>
readonly forget: (input: ForgetInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.forget>>, Failure>
readonly purge: (input: KiloMemory.Input) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.purge>>, Failure>
readonly recall: (input: RecallInput) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.recall>>, Failure>
readonly search: (input: SearchInput) => Effect.Effect<Awaited<ReturnType<typeof MemoryRecall.search>>, Failure>
readonly recordSession: (
input: RecordInput,
) => Effect.Effect<Awaited<ReturnType<typeof KiloMemory.recordSession>>, Failure>
readonly state: (input: RootInput) => Effect.Effect<MemorySchema.State, Failure>
readonly session: (
input: SessionInput,
) => Effect.Effect<Awaited<ReturnType<typeof MemoryFiles.readSession>>, Failure>
readonly sources: (input: RootInput) => Effect.Effect<Sources, Failure>
readonly recent: (
input: RecentInput,
) => Effect.Effect<Awaited<ReturnType<typeof MemoryFiles.recentSessions>>, Failure>
readonly append: (input: AppendInput) => Effect.Effect<void, Failure>
readonly index: (input: RootInput) => Effect.Effect<Index, Failure>
readonly commit: (input: CommitInput) => Effect.Effect<void, Failure>
readonly decide: (input: DecideInput) => Effect.Effect<void, Failure>
readonly readSource: (input: ReadSourceInput) => Effect.Effect<string, Failure>
readonly turnLock: (sessionID: SessionID) => Semaphore.Semaphore
readonly dropLock: (sessionID: SessionID) => void
readonly idleSettle: () => number
readonly setIdleSettle: (ms: number) => Timing
}
export class Service extends Context.Service<Service, Interface>()("@kilocode/MemoryService") {}
export function make() {
const locks = new Map<SessionID, { sema: Semaphore.Semaphore; holders: number }>()
let settle = IDLE_SETTLE_MS
return Service.of({
prepare: (input) => bridge(() => KiloMemory.prepare(input)),
status: (input) => bridge(() => KiloMemory.status(input)),
show: (input) => bridge(() => KiloMemory.show(input)),
enable: (input) => bridge(() => KiloMemory.enable(input)),
disable: (input) => bridge(() => KiloMemory.disable(input)),
rebuild: (input) => bridge(() => KiloMemory.rebuild(input)),
configure: (input) => bridge(() => KiloMemory.configure(input)),
apply: (input) => bridge(() => KiloMemory.apply(input)),
remember: (input) => bridge(() => KiloMemory.remember(input)),
correct: (input) => bridge(() => KiloMemory.correct(input)),
forget: (input) => bridge(() => KiloMemory.forget(input)),
purge: (input) => bridge(() => KiloMemory.purge(input)),
recall: (input) => bridge(() => KiloMemory.recall(input)),
search: (input) => bridge(() => MemoryRecall.search(input)),
recordSession: (input) => bridge(() => KiloMemory.recordSession(input)),
state: (input) => bridge(() => MemoryFiles.readState(input.root)),
session: (input) =>
bridge(() => MemoryFiles.readSession(input.root, { sessionID: input.sessionID, max: input.max })),
sources: (input) =>
bridge(async () => {
const entries = await Promise.all(
MemorySchema.Sources.map(async (file) => [file, await MemoryFiles.readSource(input.root, file)] as const),
)
return Object.fromEntries(entries) as Sources
}),
recent: (input) => bridge(() => MemoryFiles.recentSessions(input.root, input.limit, input.max)),
append: (input) => bridge(() => MemoryFiles.append(input.root, input.text)),
index: (input) =>
bridge(async () => {
const text = await MemoryFiles.readIndex(input.root)
return { bytes: Buffer.byteLength(text), tokens: MemoryToken.estimate(text), truncated: false }
}),
commit: (input) =>
bridge(() =>
MemoryFiles.queue(input.root, async () => {
const state = await MemoryFiles.readState(input.root)
await MemoryFiles.writeState(input.root, {
...state,
stats: {
...state.stats,
lastConsolidatedAt: input.now,
lastConsolidatedMessageID: input.messageID,
lastConsolidationCost: input.cost ?? state.stats.lastConsolidationCost,
lastConsolidationTokens: input.tokens,
lastOperationCount: input.count,
},
})
const skip = skipLine(input.skipped)
await MemoryFiles.append(
input.root,
[
`consolidate trigger=turn-close digest=${input.digest ? 1 : 0} ops=${input.count} tokens=${input.tokens}`,
skip,
]
.filter(Boolean)
.join(" "),
)
}),
),
decide: (input) => bridge(() => MemoryFiles.decide(input.root, input.decision)),
readSource: (input) => bridge(() => MemoryFiles.readSource(input.root, input.file)),
// Ref-counted so every acquirer — in-flight or queued behind `withPermits` — shares one
// semaphore. Each call must be balanced by exactly one `dropLock`.
turnLock: (sessionID) => {
const prior = locks.get(sessionID)
if (prior) {
prior.holders += 1
return prior.sema
}
const sema = Semaphore.makeUnsafe(1)
locks.set(sessionID, { sema, holders: 1 })
return sema
},
// Release one holder. The entry is dropped only when the last holder leaves, so a queued
// close() can never be handed a different semaphore than the peer it is waiting on — while the
// map still stops growing unbounded in a long-lived shared backend.
dropLock: (sessionID) => {
const item = locks.get(sessionID)
if (!item) return
item.holders -= 1
if (item.holders <= 0) locks.delete(sessionID)
},
idleSettle: () => settle,
setIdleSettle: (ms) => {
const prev = { settleMs: settle }
settle = Math.max(1, ms)
return prev
},
})
}
export const layer = Layer.sync(Service)(make)
}
+55
View File
@@ -0,0 +1,55 @@
type SessionID = string
export namespace MemoryTimers {
const pending = new Map<SessionID, { root: string; timer: ReturnType<typeof setTimeout> }>()
const signals = new Map<string, { ctl: AbortController; active: number }>()
export function cancel(sessionID: SessionID) {
const item = pending.get(sessionID)
if (!item) return
clearTimeout(item.timer)
pending.delete(sessionID)
}
export function clear(root: string) {
for (const [sessionID, item] of pending) {
if (item.root !== root) continue
clearTimeout(item.timer)
pending.delete(sessionID)
}
signals.get(root)?.ctl.abort()
signals.delete(root)
}
// One AbortController per root, shared across concurrent captures and ref-counted so it is dropped
// once the last in-flight capture for the root settles (see `release`). Without this the map grows
// for every distinct root a long-lived shared backend ever touches. disable/purge still force-abort
// via `clear`; `release` tolerates an already-cleared entry.
export function signal(root: string) {
const prior = signals.get(root)
if (prior) {
prior.active += 1
return prior.ctl.signal
}
const ctl = new AbortController()
signals.set(root, { ctl, active: 1 })
return ctl.signal
}
export function release(root: string) {
const item = signals.get(root)
if (!item) return
item.active -= 1
if (item.active <= 0) signals.delete(root)
}
export function done(sessionID: SessionID) {
pending.delete(sessionID)
}
export function set(sessionID: SessionID, root: string, timer: ReturnType<typeof setTimeout>) {
cancel(sessionID)
timer.unref?.()
pending.set(sessionID, { root, timer })
}
}
+103
View File
@@ -0,0 +1,103 @@
import { Cause, Effect } from "effect"
import { MemoryCapture } from "./capture"
import { MemoryInstance } from "./instance"
import { MemoryLog } from "./log"
import type { MemoryPorts } from "./ports"
import { MemoryService } from "./service"
import { MemoryTimers } from "./timers"
function brief(cause: Cause.Cause<unknown>) {
const err = Cause.squash(cause)
return (err instanceof Error ? err.message : String(err)).slice(0, 200)
}
function message(err: unknown) {
return (err instanceof Error ? err.message : String(err)).slice(0, 200)
}
export namespace MemoryTurn {
export type Reason = "completed" | "error" | "interrupted"
type Input = {
root: string
sessionID: string
reason: Reason
session: MemoryPorts.SessionPort
model: MemoryPorts.ModelPort
memoryModel?: string
}
function schedule(input: Input, memory: MemoryService.Interface, root: string) {
MemoryTimers.cancel(input.sessionID)
const run = MemoryInstance.bind(async () => {
MemoryTimers.done(input.sessionID)
void Effect.runPromise(
memory.turnLock(input.sessionID).withPermits(1)(
MemoryCapture.turn({
root: input.root,
sessionID: input.sessionID,
session: input.session,
model: input.model,
memoryModel: input.memoryModel,
reason: "completed",
bypassInterval: true,
}).pipe(
// Timer callbacks run outside the caller's Effect environment, so carry the resolved service from close.
Effect.provideService(MemoryService.Service, memory),
Effect.catchCause((cause) => Effect.sync(() => MemoryCapture.report(cause))),
),
),
)
.catch((err) => MemoryLog.warn("memory idle flush failed", { err: message(err) }))
.finally(() => memory.dropLock(input.sessionID))
})
MemoryTimers.set(input.sessionID, root, setTimeout(run, memory.idleSettle()))
}
export function open(input: { sessionID: string }) {
MemoryTimers.cancel(input.sessionID)
}
export const close = Effect.fn("MemoryTurn.close")(function* (input: Input) {
const memory = yield* MemoryService.Service
yield* memory
.turnLock(input.sessionID)
.withPermits(1)(
Effect.gen(function* () {
const info = yield* input.session.get({ sessionID: input.sessionID }).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
MemoryLog.warn("memory session lookup failed", { err: brief(cause) })
return undefined
}),
),
)
if (!info) return
if (info.parentID) return
const result = yield* MemoryCapture.turn({
root: input.root,
sessionID: input.sessionID,
session: input.session,
model: input.model,
reason: input.reason,
memoryModel: input.memoryModel,
}).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
MemoryCapture.report(cause)
return undefined
}),
),
)
if (result?.skipped && result.idleFlush) schedule(input, memory, result.root)
}),
)
.pipe(
Effect.catchCause((cause) =>
Effect.sync(() => MemoryLog.warn("memory turn-close hook failed", { err: brief(cause) })),
),
)
// Always release this holder. A deferred flush takes its own turnLock/dropLock pair when its
// timer fires, so exclusivity across overlapping closes is preserved by the ref count.
yield* Effect.sync(() => memory.dropLock(input.sessionID))
})
}
+1 -5
View File
@@ -44,11 +44,7 @@ export namespace Memory {
return slug || MemorySlug.hash(text, "memory")
}
async function injected(input: {
root: string
index: MemoryIndexer.Result
sessionID?: string
}) {
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 = {
@@ -62,7 +62,9 @@ Output schema:
{
"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"
"duplicateOf": "source.md:key when the duplicate source is known",
"file": "project.md" | "environment.md" | "corrections.md",
"section": "the section that holds the existing duplicate, when known"
}
]
}
@@ -77,6 +79,7 @@ Rules:
- Use skipped reason "out_of_scope" for personal user-level content that is not project knowledge.
- Use skipped reason "self_referential" for statements about memory itself or about facts already being captured.
- Use skipped reason "quota_guard" or "rate_limit_guard" if the evidence says memory generation should avoid spending limited quota.
- For a "duplicate" skip, set both "file" and "section" to where the existing entry lives, so the duplicate is verified within that exact scope and not against unrelated memory. A "duplicate" claim missing either field cannot be confirmed.
- For upsert_environment_fact, use section "Commands" for runnable commands, "Paths" for important directories/files, and "Tooling" for package managers, runtimes, build systems, or test frameworks.
- Omit section for other operations.
- Do not include markdown.
+1 -3
View File
@@ -120,9 +120,7 @@ export namespace MemoryRecall {
input.state.limits.maxSessionFiles,
input.state.limits.maxSessionLineChars,
)
return items
.filter((item) => item.id !== input.currentSessionID && !MemoryDigest.empty(item))
.map(digest)
return items.filter((item) => item.id !== input.currentSessionID && !MemoryDigest.empty(item)).map(digest)
}
function score(input: { hit: Hit; keys: string[] }) {
+10 -7
View File
@@ -18,6 +18,9 @@ export namespace MemoryShared {
export type SourceItem = {
id: string
file: MemorySchema.Source
section: string
key: string
text: string
}
@@ -37,13 +40,13 @@ export namespace MemoryShared {
}
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
return MemoryMarkdown.parse(input.text).map((item) => ({
id: `${input.file}:${item.section}:${item.key}`,
file: input.file,
section: item.section,
key: item.key,
text: `${item.key} ${item.text}`,
}))
}
export function typed(input: {
@@ -9,16 +9,11 @@ export namespace MemoryTopics {
}
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() ?? ""
}
+6 -8
View File
@@ -119,14 +119,12 @@ export namespace MemoryAudit {
}
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()]
})
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")
}
}
+9 -4
View File
@@ -164,10 +164,15 @@ export namespace MemoryFs {
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))
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)
@@ -69,9 +69,16 @@ export namespace MemoryPaths {
const match = text?.match(/^gitdir:\s*(.+)$/m)
if (!match?.[1]) return dir
const git = path.resolve(dir, match[1])
if (!belongs(dot, git)) return dir
return checkout(common(git)) ?? dir
}
function belongs(dot: string, git: string) {
const back = read(path.join(git, "gitdir"))
if (!back) return false
return canon(path.resolve(git, back)) === canon(dot)
}
function canon(dir: string) {
const resolved = path.resolve(dir)
try {
+12 -9
View File
@@ -132,15 +132,18 @@ export namespace MemorySessions {
.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))
.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) {
+4 -1
View File
@@ -116,7 +116,10 @@ export namespace MemoryState {
if (MemoryFs.miss(error)) return [] as string[]
throw error
})
const digests = files.filter((file) => file.endsWith(".md")).sort().reverse()
const digests = files
.filter((file) => file.endsWith(".md"))
.sort()
.reverse()
const sources = [
paths.project,
paths.environment,
+129 -11
View File
@@ -266,11 +266,21 @@ describe("memory capture parsing", () => {
})
test("verifies duplicate skips and operation duplicates", () => {
const items = [{ id: "project.md:repo_tests", text: "repo_tests Run memory tests from packages/opencode." }]
const items = [
{
id: "project.md:Facts:repo_tests",
file: "project.md" as const,
section: "Facts",
key: "repo_tests",
text: "repo_tests Run memory tests from packages/opencode.",
},
]
const verified = verifySkips({
items,
skipped: [
{ reason: "duplicate", text: "Run memory tests from packages/opencode." },
// Fully scoped to the stored entry → confirmed.
{ reason: "duplicate", text: "Run memory tests from packages/opencode.", file: "project.md", section: "Facts" },
// Unscoped → unverified regardless of any text overlap.
{ reason: "duplicate", text: "New durable workflow preference." },
],
})
@@ -279,29 +289,133 @@ describe("memory capture parsing", () => {
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." },
{
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[0]?.duplicateOf).toBe("project.md:Facts:repo_tests")
expect(verified.skipped).toContainEqual({ reason: "unsupported", text: "New durable workflow preference." })
expect(deduped.ops).toEqual([
{ action: "add", file: "project.md", section: "Facts", key: "new_preference", text: "New durable workflow preference." },
{
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)
expect(deduped.skipped.some((item) => item.duplicateOf === "project.md:Facts:repo_tests")).toBe(true)
})
test("does not pre-skip similar operations from different memory scopes", () => {
const filtered = duplicateOps({
items: [
{
id: "corrections.md:Corrections:repo_tests",
file: "corrections.md",
section: "Corrections",
key: "repo_tests",
text: "repo_tests Run memory tests from packages/opencode.",
},
],
skipped: [],
ops: [
{
action: "add",
file: "project.md",
section: "Facts",
key: "repo_tests",
text: "Run memory tests from packages/opencode.",
},
],
})
expect(filtered.ops).toHaveLength(1)
expect(filtered.skipped).toEqual([])
})
test("scopes model-reported duplicate skips to the claimed file/section", () => {
const items = [
{
id: "corrections.md:Corrections:repo_tests",
file: "corrections.md" as const,
section: "Corrections",
key: "repo_tests",
text: "repo_tests Run memory tests from packages/opencode.",
},
]
const verified = verifySkips({
items,
skipped: [
// Claims a duplicate in project.md/Facts, but the only match lives in corrections.md →
// unconfirmed, downgraded to advisory instead of confirmed cross-scope.
{
reason: "duplicate",
text: "Run memory tests from packages/opencode.",
file: "project.md",
section: "Facts",
},
// Same text, correctly scoped to where the entry actually lives → confirmed.
{
reason: "duplicate",
text: "Run memory tests from packages/opencode.",
file: "corrections.md",
section: "Corrections",
},
],
})
expect(verified.skipped[0]).toMatchObject({ reason: "unsupported" })
expect(verified.skipped[1]).toMatchObject({
reason: "duplicate",
duplicateOf: "corrections.md:Corrections:repo_tests",
})
})
test("does not confirm a duplicate skip scoped to a file without a section", () => {
const items = [
{
id: "project.md:Decisions:repo_tests",
file: "project.md" as const,
section: "Decisions",
key: "repo_tests",
text: "repo_tests Run memory tests from packages/opencode.",
},
]
const verified = verifySkips({
items,
skipped: [
// Claims project.md but not the section; the only match lives in Decisions. Confirming would
// risk a cross-section false positive, so it must downgrade to advisory.
{ reason: "duplicate", text: "Run memory tests from packages/opencode.", file: "project.md" },
],
})
expect(verified.skipped[0]).toEqual({
reason: "unsupported",
text: "Run memory tests from packages/opencode.",
})
})
test("builds capture notices and guard summaries", () => {
const ops = [{ action: "add", file: "environment.md", section: "Commands", key: "tests", text: "Run bun test." }] as const
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(
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",
)
@@ -346,7 +460,11 @@ describe("memory capture parsing", () => {
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"],
[
"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
+27 -8
View File
@@ -177,6 +177,22 @@ describe("memory core package", () => {
})
})
test("does not trust workspace-controlled gitdir pointers for project identity", async () => {
await use(async (t) => {
const victim = path.join(t.dir, "victim")
const other = path.join(t.dir, "other")
await mkdir(path.join(other, ".git"), { recursive: true })
await mkdir(victim)
await writeFile(path.join(victim, ".git"), "gitdir: ../other/.git\n")
const id = MemoryPaths.identity({ ctx: { directory: victim, worktree: victim } })
expect(path.basename(id.canonical)).toBe("victim")
expect(path.basename(id.canonical)).not.toBe("other")
expect(id.folder.startsWith("victim-")).toBe(true)
})
})
test("serializes concurrent operations for one root", async () => {
await use(async (t) => {
await Memory.enable({ root: t.root })
@@ -206,7 +222,10 @@ describe("memory core package", () => {
],
})
await expect(
Memory.apply({ root: t.root, ops: [{ action: "add", key: "bad", text: "api_key=sk-abcdefghijklmnopqrstuvwxyz" }] }),
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 })
@@ -457,12 +476,10 @@ describe("memory core package", () => {
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" })
]!
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)
@@ -948,7 +965,9 @@ describe("memory core package", () => {
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"))
expect(shown.index.indexOf("session=ses_empty_newest")).toBeLessThan(
shown.index.indexOf("session=ses_substantive"),
)
})
})
@@ -0,0 +1,205 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "fs/promises"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { digestPrompt, typedPrompt } from "../src/capture/capture"
import { MemoryCapture } from "../src/effect/capture"
import { KiloMemory } from "../src/effect/index"
import type { MemoryPorts } from "../src/effect/ports"
import { MemoryService } from "../src/effect/service"
import { MemoryTimers } from "../src/effect/timers"
async function tmp() {
const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-memory-effect-"))
return {
root: path.join(dir, "memory"),
async done() {
await rm(dir, { recursive: true, force: true })
},
}
}
const USAGE = { inputTokens: { total: 12 }, outputTokens: { total: 8 } }
function view(over: Partial<MemoryPorts.TurnView> = {}): MemoryPorts.TurnView {
return {
user: "what commands are needed for this repo setup?",
assistant: "Use bun install, then bun test ./test from packages/opencode.",
recent: "User: setup?\n\nAssistant: bun install then bun test.",
lastAssistantID: "msg_assistant",
sessionModel: { providerID: "test", modelID: "fake-memory-model" },
recalledMemory: false,
diffs: [],
...over,
}
}
/** Session port that always surfaces the given turn (or none). */
function session(turn: MemoryPorts.TurnView | undefined): MemoryPorts.SessionPort {
return {
readTurn: () => Effect.succeed(turn),
get: () => Effect.succeed({ parentID: undefined }),
}
}
/** Model port that answers digest/typed calls from canned JSON, keyed by system prompt so it is
* order-independent (digest and typed run concurrently). */
function model(input: { digest: string; typed: string; fallback?: string; onRun?: () => void }): MemoryPorts.ModelPort {
return {
resolve: () => Effect.succeed({ handle: {}, ...(input.fallback ? { fallback: { reason: input.fallback } } : {}) }),
run: async ({ system }) => {
input.onRun?.()
const text = system === digestPrompt ? input.digest : system === typedPrompt ? input.typed : "{}"
return { text, usage: USAGE }
},
}
}
function run(input: {
root: string
session: MemoryPorts.SessionPort
model: MemoryPorts.ModelPort
memoryModel?: string
}) {
return Effect.runPromise(
MemoryCapture.turn({
root: input.root,
sessionID: "ses_effect",
session: input.session,
model: input.model,
memoryModel: input.memoryModel,
reason: "completed",
}).pipe(Effect.provideService(MemoryService.Service, MemoryService.make())),
)
}
describe("MemoryCapture (fake ports)", () => {
test("turn-close typed LLM saves environment memory and audit records", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
const result = await run({
root: t.root,
session: session(view()),
model: model({
digest: '{"topic":"repo setup","summary":"Explored repo setup commands. Next step: verify memory tests."}',
typed:
'{"operations":[{"op":"upsert_environment_fact","section":"Commands","key":"cli_memory_tests","value":"Run bun test ./test from packages/opencode."}],"skipped":[]}',
}),
})
expect(result).toMatchObject({ skipped: false, operationCount: 1 })
if (!("tokens" in result)) throw new Error("expected capture to save memory")
expect(result.tokens).toBeGreaterThan(0)
const shown = await KiloMemory.show({ root: t.root })
expect(shown.sources.environment).toContain("cli_memory_tests")
expect(shown.decisions).toContain('"kind":"digest"')
expect(shown.decisions).toContain('"kind":"typed"')
expect(shown.decisions).toContain('"result":"saved"')
} finally {
await t.done()
}
})
test("auto-consolidate off skips digest and typed model writes", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: false } })
let runs = 0
const result = await run({
root: t.root,
session: session(view()),
model: model({
digest: '{"topic":"x","summary":"should not be saved"}',
typed: '{"operations":[{"op":"upsert_environment_fact","key":"nope","value":"x"}],"skipped":[]}',
onRun: () => runs++,
}),
})
expect(result).toMatchObject({ skipped: true })
expect(runs).toBe(0)
const shown = await KiloMemory.show({ root: t.root })
expect(shown.sources.environment).not.toContain("nope")
} finally {
await t.done()
}
})
test("records audit when configured memory model is unavailable", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
await run({
root: t.root,
session: session(view()),
memoryModel: "test/missing-memory-model",
model: model({
digest: '{"topic":"repo","summary":"Explored repo setup. Next: verify."}',
typed: '{"operations":[],"skipped":[]}',
fallback: "model unavailable",
}),
})
const shown = await KiloMemory.show({ root: t.root })
expect(shown.changes).toContain("memory_model_config reason=model unavailable fallback=1")
} finally {
await t.done()
}
})
test("no turn to capture is skipped", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
const result = await run({
root: t.root,
session: session(undefined),
model: model({ digest: "{}", typed: "{}" }),
})
expect(result).toMatchObject({ skipped: true, reason: "no_turn" })
} finally {
await t.done()
}
})
})
describe("MemoryService turn-lock ref-counting", () => {
test("keeps one semaphore per session until the last holder drops", () => {
const svc = MemoryService.make()
const a = svc.turnLock("ses_lock")
const b = svc.turnLock("ses_lock")
expect(b).toBe(a) // a queued close() shares the same semaphore as the holder it waits on
svc.dropLock("ses_lock") // first holder settles; second is still queued/holding
const c = svc.turnLock("ses_lock")
expect(c).toBe(a) // a later close() must not get a fresh semaphore while a holder remains
svc.dropLock("ses_lock")
svc.dropLock("ses_lock") // last holder leaves → entry dropped
const fresh = svc.turnLock("ses_lock")
expect(fresh).not.toBe(a) // only now does a new turn get a new semaphore
svc.dropLock("ses_lock")
})
})
describe("MemoryTimers signal ref-counting", () => {
test("shares one controller per root and drops it once the last capture releases", () => {
const root = "/kilo-memory/ref-count-root"
const first = MemoryTimers.signal(root)
const second = MemoryTimers.signal(root)
expect(second).toBe(first) // concurrent captures share the controller
MemoryTimers.release(root)
expect(MemoryTimers.signal(root)).toBe(first) // still alive while one capture remains
MemoryTimers.release(root)
MemoryTimers.release(root) // last in-flight capture settles → controller dropped
const fresh = MemoryTimers.signal(root)
expect(fresh).not.toBe(first) // next capture gets a new controller, proving cleanup
MemoryTimers.release(root)
})
})
+19 -4
View File
@@ -28,15 +28,27 @@ describe("memory markdown serialization", () => {
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") })
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") })
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") })
const noop = MemoryMarkdown.upsert({
text: base,
section: "Facts",
line: MemoryMarkdown.line("runtime", "Bun 1.0"),
})
expect(noop.changed).toBe(false)
})
@@ -81,7 +93,10 @@ describe("memory markdown serialization", () => {
MemoryMarkdown.line("a", "three"),
].join("\n")
const result = MemoryMarkdown.remove({ text: doc, match: (entry) => entry.section === "Facts" && entry.key === "a" })
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" },