From ead46f43415a121bacc28780bc47fe43b1b736ba Mon Sep 17 00:00:00 2001 From: niefangxin_yfmx Date: Fri, 7 Aug 2026 16:57:47 +0800 Subject: [PATCH 1/2] fix(ui): make Mermaid Copy PNG/SVG put images on the clipboard Copy PNG called fetch() on a data URL, which VS Code webview CSP rejects, so the copy silently did nothing. Decode the data URL locally instead. Copy SVG used clipboard.writeText() with SVG markup, so paste targets got source text instead of an image. Write SVG/PNG/text representations when supported, and fall back to PNG on hosts that reject multi-type items. --- .changeset/mermaid-copy-clipboard-images.md | 5 +++ .../src/kilocode/markdown-mermaid-actions.tsx | 13 ++++-- .../markdown-mermaid-data-url.test.ts | 24 ++++++++++ .../src/kilocode/markdown-mermaid-data-url.ts | 12 +++++ packages/ui/src/kilocode/markdown-mermaid.ts | 44 ++++++++++++++++--- 5 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 .changeset/mermaid-copy-clipboard-images.md create mode 100644 packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts create mode 100644 packages/ui/src/kilocode/markdown-mermaid-data-url.ts diff --git a/.changeset/mermaid-copy-clipboard-images.md b/.changeset/mermaid-copy-clipboard-images.md new file mode 100644 index 00000000000..c9c0a405f9c --- /dev/null +++ b/.changeset/mermaid-copy-clipboard-images.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Mermaid Copy PNG/SVG in VS Code webviews so they put images on the clipboard instead of failing or copying SVG markup. diff --git a/packages/ui/src/kilocode/markdown-mermaid-actions.tsx b/packages/ui/src/kilocode/markdown-mermaid-actions.tsx index 4f5756dbe1b..26fe5e639df 100644 --- a/packages/ui/src/kilocode/markdown-mermaid-actions.tsx +++ b/packages/ui/src/kilocode/markdown-mermaid-actions.tsx @@ -49,10 +49,15 @@ function Item(props: { label: string; onSelect: () => void }) { export function MermaidActions(props: Props) { const [copied, setCopied] = createSignal(false) const copy = (run: () => Promise) => { - void run().then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 1500) - }) + void run() + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + .catch((err) => { + // Avoid unhandledrejection; Copy PNG used to fail silently under webview CSP. + console.warn("Mermaid copy failed", err) + }) } return ( diff --git a/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts b/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts new file mode 100644 index 00000000000..592a53213bc --- /dev/null +++ b/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { dataUrlToBlob } from "./markdown-mermaid-data-url" + +describe("dataUrlToBlob", () => { + test("decodes base64 payload and preserves mime type", async () => { + const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]) + const dataUrl = `data:image/png;base64,${Buffer.from(bytes).toString("base64")}` + const blob = dataUrlToBlob(dataUrl) + + expect(blob.type).toBe("image/png") + expect(blob.size).toBe(bytes.length) + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes) + }) + + test("defaults mime when the data URL omits it", async () => { + const blob = dataUrlToBlob(`data:;base64,${Buffer.from("hi").toString("base64")}`) + expect(blob.type).toBe("application/octet-stream") + expect(await blob.text()).toBe("hi") + }) + + test("rejects data URLs without a comma separator", () => { + expect(() => dataUrlToBlob("data:image/png;base64")).toThrow("Unable to export Mermaid diagram.") + }) +}) diff --git a/packages/ui/src/kilocode/markdown-mermaid-data-url.ts b/packages/ui/src/kilocode/markdown-mermaid-data-url.ts new file mode 100644 index 00000000000..d9eda408f28 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-mermaid-data-url.ts @@ -0,0 +1,12 @@ +// VS Code webview CSP blocks fetch(data:...); decode locally for clipboard/export helpers. +export function dataUrlToBlob(dataUrl: string): Blob { + const comma = dataUrl.indexOf(",") + if (comma < 0) throw new Error("Unable to export Mermaid diagram.") + const header = dataUrl.slice(0, comma) + const data = dataUrl.slice(comma + 1) + const mime = /^data:([^;,]*)/.exec(header)?.[1] || "application/octet-stream" + const binary = atob(data) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: mime }) +} diff --git a/packages/ui/src/kilocode/markdown-mermaid.ts b/packages/ui/src/kilocode/markdown-mermaid.ts index 33f3e091783..0edeef600d5 100644 --- a/packages/ui/src/kilocode/markdown-mermaid.ts +++ b/packages/ui/src/kilocode/markdown-mermaid.ts @@ -1,6 +1,7 @@ import DOMPurify from "dompurify" import { fnv1a } from "../context/marked" import { mountMermaidActions } from "./markdown-mermaid-actions" +import { dataUrlToBlob } from "./markdown-mermaid-data-url" // DOMPurify >= 3.1.7 dropped foreignObject from the default HTML integration // points, which caused the inner
/ /

labels Mermaid renders @@ -303,14 +304,43 @@ async function copyText(text: string) { await navigator.clipboard.writeText(text) } -async function copyPng(svg: SVGSVGElement) { - const url = await png(svg) - const blob = await (await fetch(url)).blob() +function supportsClipboardType(type: string) { + return typeof ClipboardItem !== "undefined" && "supports" in ClipboardItem && ClipboardItem.supports(type) +} + +async function writeClipboard(items: Record) { if (typeof ClipboardItem === "undefined") { - await navigator.clipboard.writeText(serialize(svg)) - return + throw new Error("ClipboardItem is unavailable.") } - await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]) + await navigator.clipboard.write([new ClipboardItem(items)]) +} + +async function writePngClipboard(svg: SVGSVGElement) { + await writeClipboard({ "image/png": dataUrlToBlob(await png(svg)) }) +} + +async function copySvg(svg: SVGSVGElement) { + const markup = serialize(svg) + // Prefer a rich ClipboardItem so paste targets can pick SVG, PNG, or text. + // Previous writeText(serialize(svg)) always pasted markup instead of an image. + const pngBlob = dataUrlToBlob(await png(svg)) + const items: Record = { + "image/png": pngBlob, + "text/plain": new Blob([markup], { type: "text/plain" }), + } + if (supportsClipboardType("image/svg+xml")) { + items["image/svg+xml"] = new Blob([markup], { type: "image/svg+xml" }) + } + try { + await writeClipboard(items) + } catch { + // Some hosts reject multi-type items; keep an image on the clipboard. + await writeClipboard({ "image/png": pngBlob }) + } +} + +async function copyPng(svg: SVGSVGElement) { + await writePngClipboard(svg) } function renderActions(el: HTMLDivElement, pre: HTMLPreElement, source: string, labels: MermaidLabels) { @@ -329,7 +359,7 @@ function renderActions(el: HTMLDivElement, pre: HTMLPreElement, source: string, mountMermaidActions(el, { labels, onCopySource: () => copyText(sourceText), - onCopySvg: () => copyText(sourceSvg()), + onCopySvg: () => copySvg(svg), onCopyPng: () => copyPng(svg), onDownloadSvg: () => save(sourceSvgUrl(), "mermaid-diagram.svg"), onDownloadPng: async () => save(await png(svg), "mermaid-diagram.png"), From fdc46654c5860c01276d7034ba715f779b163bca Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Thu, 6 Aug 2026 18:08:52 +0200 Subject: [PATCH 2/2] fix(memory): quiet transient capture timeouts and stop same-turn retries --- .../fix-memory-model-timeout-warnings.md | 7 + .../kilo-memory/src/capture/operations.ts | 9 +- packages/kilo-memory/src/capture/outcome.ts | 73 +++++++++- packages/kilo-memory/src/effect/capture.ts | 122 +++-------------- packages/kilo-memory/src/effect/service.ts | 23 +--- packages/kilo-memory/src/memory-notice.ts | 24 +--- packages/kilo-memory/src/memory.ts | 59 -------- packages/kilo-memory/src/recall/indexer.ts | 1 - packages/kilo-memory/src/recall/shared.ts | 1 + packages/kilo-memory/src/schema.ts | 6 + packages/kilo-memory/src/storage/audit.ts | 44 +----- packages/kilo-memory/src/storage/state.ts | 11 +- packages/kilo-memory/src/tool.ts | 129 ++---------------- packages/kilo-memory/test/capture.test.ts | 84 ++++++++++-- packages/kilo-memory/test/core.test.ts | 16 +++ .../kilo-memory/test/effect-capture.test.ts | 127 ++++++++++++++++- packages/kilo-vscode/src/KiloProvider.ts | 7 +- .../unit/kilo-provider-memory-events.test.ts | 37 +++++ .../src/kilocode/cli/cmd/tui/memory-events.ts | 3 + .../opencode/src/kilocode/memory/ports.ts | 3 +- .../cli/cmd/tui/memory-command.test.ts | 13 +- .../test/kilocode/memory/memory-ports.test.ts | 48 ++++++- 22 files changed, 446 insertions(+), 401 deletions(-) create mode 100644 .changeset/fix-memory-model-timeout-warnings.md diff --git a/.changeset/fix-memory-model-timeout-warnings.md b/.changeset/fix-memory-model-timeout-warnings.md new file mode 100644 index 00000000000..5a0c8700162 --- /dev/null +++ b/.changeset/fix-memory-model-timeout-warnings.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-memory": patch +"kilo-code": patch +--- + +Reduce noisy memory timeout warnings and retry transient background consolidation failures once. diff --git a/packages/kilo-memory/src/capture/operations.ts b/packages/kilo-memory/src/capture/operations.ts index f4703fdd160..53a1dcf345f 100644 --- a/packages/kilo-memory/src/capture/operations.ts +++ b/packages/kilo-memory/src/capture/operations.ts @@ -126,7 +126,7 @@ export namespace MemoryOperations { const adds = input.ops .filter((item): item is Add => item.action === "add") // Redact rejected text too: this filter runs before the secret one, so a rejected op never - // reaches it, and skips flow into the persistent decisions audit (/memory/show, TUI). + // reaches it, and skips remain visible in the operation result. .filter((op) => { const item = reject(op) if (!item) return true @@ -314,7 +314,7 @@ export namespace MemoryOperations { } } - async function persist(input: { root: string; state: MemorySchema.State; count: number; removed: number }) { + async function persist(input: { root: string; state: MemorySchema.State; count: number }) { const index = await MemoryIndexer.rebuild({ root: input.root, state: input.state }) await MemoryFiles.writeState(input.root, { ...input.state, @@ -323,7 +323,6 @@ export namespace MemoryOperations { lastOperationCount: input.count, }, }) - await MemoryFiles.append(input.root, `apply ops=${input.count} removed=${input.removed}`) return index } @@ -365,9 +364,9 @@ export namespace MemoryOperations { 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. + // Commit (IO): write changed documents, then rebuild the index and persist state. await writeDocs({ root: input.root, plan }) - const index = await persist({ root: input.root, state, count: plan.count, removed: plan.removed }) + const index = await persist({ root: input.root, state, count: plan.count }) return { operationCount: plan.count, added: plan.added, diff --git a/packages/kilo-memory/src/capture/outcome.ts b/packages/kilo-memory/src/capture/outcome.ts index 9ee4be803d7..ce65e172862 100644 --- a/packages/kilo-memory/src/capture/outcome.ts +++ b/packages/kilo-memory/src/capture/outcome.ts @@ -1,9 +1,12 @@ import { MemoryOperations } from "./operations" import { MemoryRedact } from "./redact" import { MemoryShared } from "../recall/shared" +import { TRANSIENT } from "../schema" import type { MemoryFiles } from "../storage/store" import type { CaptureSkip } from "./parse" +export { TRANSIENT } + export type CaptureSourceItem = { id: string text: string @@ -69,14 +72,75 @@ export function errorReason(err: unknown) { 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)) +const RATE_TEXT = /\b(429|rate[_ -]?limit|too many requests)\b/ +const QUOTA_TEXT = /\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/ +const TIMEOUT_TEXT = + /\b(timeouterror|etimedout|deadline[ _-]?exceeded|timed out|(connect|headers|body|gateway)[ _-]?time[ -]?out)\b|^timeout$/ +const TIMEOUT_CODES = new Set(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]) + +/** Structural timeout detection over an error's name/status/code, its message text, and its nested + * cause/errors chains (cycle-safe). Message matching is limited to `message` — never the serialized + * body/data blobs — so response payloads that merely mention "timeout" cannot classify as transient. */ +function timedOut(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + name?: unknown + code?: unknown + status?: unknown + statusCode?: unknown + message?: unknown + cause?: unknown + errors?: unknown + } + if (err.name === "TimeoutError") return true + if (err.status === 504 || err.statusCode === 504) return true + if (typeof err.code === "string" && TIMEOUT_CODES.has(err.code)) return true + if (typeof err.message === "string" && TIMEOUT_TEXT.test(err.message.toLowerCase())) return true + if (timedOut(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => timedOut(entry, seen)) +} + +function rateGuarded(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + status?: unknown + statusCode?: unknown + message?: unknown + cause?: unknown + errors?: unknown + } + if (err.status === 429 || err.statusCode === 429) return true + if (typeof err.message === "string" && RATE_TEXT.test(err.message.toLowerCase())) return true + if (rateGuarded(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => rateGuarded(entry, seen)) +} + +function quotaGuarded(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + message?: unknown + cause?: unknown + errors?: unknown + } + if (typeof err.message === "string" && QUOTA_TEXT.test(err.message.toLowerCase())) return true + if (quotaGuarded(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => quotaGuarded(entry, seen)) +} + +export function guardReason(input: unknown) { + const value = (typeof input === "string" ? input : errorReason(input)).toLowerCase() + if (typeof input === "string" ? RATE_TEXT.test(value) : rateGuarded(input) || RATE_TEXT.test(value)) + return "rate_limit_guard" + if (typeof input === "string" ? QUOTA_TEXT.test(value) : quotaGuarded(input) || QUOTA_TEXT.test(value)) return "quota_guard" + if (typeof input === "string" ? TIMEOUT_TEXT.test(value) : timedOut(input)) return TRANSIENT return undefined } +/** @deprecated Memory audit persistence was removed. */ export function skipped(input: { sessionID: string; reason: string }): MemoryFiles.Decision { return { kind: "typed", @@ -94,6 +158,7 @@ export function skipped(input: { sessionID: string; reason: string }): MemoryFil } } +/** @deprecated Memory audit persistence was removed. */ export function auditOps(ops: MemoryOperations.Op[]) { return MemoryShared.audit(ops) } diff --git a/packages/kilo-memory/src/effect/capture.ts b/packages/kilo-memory/src/effect/capture.ts index fda14a1a19a..cd0e1308d20 100644 --- a/packages/kilo-memory/src/effect/capture.ts +++ b/packages/kilo-memory/src/effect/capture.ts @@ -1,6 +1,5 @@ import { Cause, Effect } from "effect" import { - auditOps, cap, capturePlan, digestPrompt, @@ -18,9 +17,9 @@ import { parseJson, parseOps, salvageTyped, - skipped, summarize, summarizeDiffs, + TRANSIENT, typedPrompt, usage, verifySkips, @@ -105,11 +104,16 @@ export namespace MemoryCapture { yield* memory.prepare({ root }) const state = yield* memory.state({ root }) const reported = new Set() - const fail = (reason: string) => + const fail = (reason: string, detail?: string) => Effect.promise(async () => { const safe = MemoryRedact.text(reason) if (reported.has(safe)) return reported.add(safe) + if (reason === TRANSIENT) + MemoryLog.warn("memory capture transient failure", { + reason: safe, + detail: MemoryShared.brief(MemoryRedact.text(detail ?? safe), 160), + }) await MemoryEvents.publish({ event: "error", payload: MemoryEvents.status({ @@ -123,7 +127,6 @@ export namespace MemoryCapture { }) 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", @@ -203,23 +206,6 @@ export namespace MemoryCapture { tokens: 0, fallback: true, }) - yield* memory.decide({ - root, - decision: { - kind: "digest", - trigger: "turn-close", - sessionID: input.sessionID, - result: "fallback", - llm: false, - parsed: false, - fallback: true, - reason: input.reason, - tokens: 0, - operationCount: 1, - skippedCount: 0, - summary: `session digest fallback on ${input.reason ?? "close"}`, - }, - }) } return yield* skip(plan.skipReason, plan.idleFlush ? { idleFlush: true } : undefined) } @@ -232,22 +218,12 @@ export namespace MemoryCapture { const model = digestDue || typedCall - ? yield* Effect.gen(function* () { - const resolution = yield* input.model.resolve({ + ? ( + 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 - }) + ).handle : undefined const digestEffect = digestDue ? Effect.gen(function* () { @@ -277,9 +253,8 @@ export namespace MemoryCapture { 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` }) + const reason = MemoryRedact.text(guardReason(err) ?? raw) + yield* fail(reason, raw) return { ok: false as const, reason } }), ), @@ -296,14 +271,9 @@ export namespace MemoryCapture { try: () => parseJson(digestSchema, result.result.text), catch: (error) => error, }).pipe( - Effect.catch((err: unknown) => + Effect.catch(() => 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 }), ), @@ -402,9 +372,8 @@ export namespace MemoryCapture { 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)}` }) + const reason = MemoryRedact.text(guardReason(err) ?? raw) + yield* fail(reason, raw) return { ok: false as const, reason } }), ), @@ -423,11 +392,9 @@ export namespace MemoryCapture { try: () => salvageTyped(result.result.text), catch: (error) => error, }).pipe( - Effect.catch((err: unknown) => + Effect.catch(() => 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 }), ), @@ -475,30 +442,6 @@ export namespace MemoryCapture { fallback: Boolean(digest.reason), }) } - 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", - }, - }) - } - // Apply adds only: a same-key add supersedes/updates an existing fact in place. reconcile also // surfaces exact-key auto-removes, but V0 keeps hard removes explicit-only — auto-capture never // deletes memory it merely paraphrased (or wrongly flags), so reconciled.removes is not applied. @@ -506,39 +449,11 @@ export namespace MemoryCapture { const ops = reconciled.ops.slice(0, state.capture.maxOpsPerRun) const project = ops.length > 0 ? yield* memory.apply({ root, ops, trigger: "turn-close", tokens: generated.tokens }) : undefined - // Apply-time skips (content gate + secret, both redacted at creation) surface in the typed audit - // record alongside the model's own declared skips. const applied: CaptureSkip[] = [...generated.skipped, ...(project?.skipped ?? [])] 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: applied.length, - skipped: applied, - 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 ${applied.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)) { + if (digestDue || typedCall || count > 0) { yield* memory.commit({ root, now, @@ -546,8 +461,7 @@ export namespace MemoryCapture { tokens, count, digest: Boolean(digest.summary), - typed: typedCall, - skipped: applied, + typed: typedCall && !generated.fallback, }) } const updated = yield* memory.state({ root }) diff --git a/packages/kilo-memory/src/effect/service.ts b/packages/kilo-memory/src/effect/service.ts index 9f705499360..026553a61a3 100644 --- a/packages/kilo-memory/src/effect/service.ts +++ b/packages/kilo-memory/src/effect/service.ts @@ -1,5 +1,4 @@ import { Context, Effect, Layer, Semaphore } from "effect" -import { skipLine, type CaptureSkip } from "../capture/capture" import type { Memory } from "../memory" import type { MemoryOperations } from "../capture/operations" import { MemoryRecall } from "../recall/recall" @@ -104,7 +103,6 @@ type CommitInput = RootInput & { // shared typed-interval clock (lastTypedConsolidationAt); a digest-only commit must leave it untouched so a // digest in one session cannot throttle another session's typed capture. typed: boolean - skipped: CaptureSkip[] cost?: number } @@ -156,10 +154,12 @@ export namespace MemoryService { readonly recent: ( input: RecentInput, ) => Effect.Effect>, Failure> + /** @deprecated Memory audit persistence was removed. */ readonly append: (input: AppendInput) => Effect.Effect readonly index: (input: RootInput) => Effect.Effect readonly commit: (input: CommitInput) => Effect.Effect readonly recordRecall: (input: RecordRecallInput) => Effect.Effect + /** @deprecated Memory audit persistence was removed. */ readonly decide: (input: DecideInput) => Effect.Effect readonly readSource: (input: ReadSourceInput) => Effect.Effect readonly turnLock: (sessionID: SessionID) => Semaphore.Semaphore @@ -200,7 +200,7 @@ export namespace MemoryService { 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)), + append: () => Effect.void, index: (input) => bridge(async () => { const text = await MemoryFiles.readIndex(input.root) @@ -219,20 +219,11 @@ export namespace MemoryService { lastSessionSavedAt: input.digest ? input.now : state.stats.lastSessionSavedAt, lastConsolidatedMessageID: input.messageID, lastConsolidationCost: input.cost ?? state.stats.lastConsolidationCost, - lastConsolidationTokens: input.tokens, - lastOperationCount: input.count, + lastConsolidationTokens: + input.typed || input.digest ? input.tokens : state.stats.lastConsolidationTokens, + lastOperationCount: input.typed ? input.count : state.stats.lastOperationCount, }, }) - 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(" "), - ) }), ), recordRecall: (input) => @@ -266,7 +257,7 @@ export namespace MemoryService { }), }) }), - decide: (input) => bridge(() => MemoryFiles.decide(input.root, input.decision)), + decide: () => Effect.void, 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`. diff --git a/packages/kilo-memory/src/memory-notice.ts b/packages/kilo-memory/src/memory-notice.ts index f96bca8eec7..983b4613879 100644 --- a/packages/kilo-memory/src/memory-notice.ts +++ b/packages/kilo-memory/src/memory-notice.ts @@ -1,23 +1,12 @@ import { MemoryShared } from "./recall/shared" import type { MemoryOperations } from "./capture/operations" -import { MemoryRedact } from "./capture/redact" -/** Human-facing messages and audit views describing an explicit apply result. */ +/** Human-facing messages 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` @@ -25,15 +14,4 @@ export namespace MemoryNotice { 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(MemoryRedact.text(item.text))), - ) - } } diff --git a/packages/kilo-memory/src/memory.ts b/packages/kilo-memory/src/memory.ts index 8bd14dbf421..267b92093af 100644 --- a/packages/kilo-memory/src/memory.ts +++ b/packages/kilo-memory/src/memory.ts @@ -8,7 +8,6 @@ import { MemorySchema } from "./schema" import { MemoryShared } from "./recall/shared" import { MemoryToken } from "./recall/token" import { MemorySlug } from "./slug" -import { MemoryRedact } from "./capture/redact" /** Root-bound package facade. External Kilo surfaces should derive root from workspace context first. */ export namespace Memory { @@ -97,7 +96,6 @@ export namespace Memory { 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 } }) } @@ -124,16 +122,6 @@ export namespace Memory { ...(input.settings.verbose === undefined ? {} : { verbose: input.settings.verbose }), } await MemoryFiles.writeState(input.root, next) - await MemoryFiles.append( - input.root, - [ - `settings ${next.scope}`, - input.settings.autoConsolidate === undefined ? "" : `autoConsolidate=${next.autoConsolidate}`, - input.settings.verbose === undefined ? "" : `verbose=${next.verbose}`, - ] - .filter(Boolean) - .join(" "), - ) return { root: input.root, state: next } }) } @@ -216,24 +204,6 @@ export namespace Memory { } 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: accepted, skipped: result.skipped }), - files: MemoryShared.files(accepted), - summary: MemoryNotice.summary({ added: result.added, removed: result.removed, count: result.operationCount }), - }) - } return { root: input.root, state, @@ -318,31 +288,6 @@ export namespace Memory { 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(MemoryRedact.text(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 } } @@ -368,10 +313,6 @@ export namespace Memory { }) await MemoryFiles.pruneSessions(input.root, state.limits.maxSessionFiles) const index = await MemoryIndexer.rebuild({ root: input.root, state }) - await MemoryFiles.append( - input.root, - `session digest session=${input.sessionID} tokens=${input.tokens ?? 0} indexTokens=${index.tokens}`, - ) return { root: input.root, state, skipped: false as const, index } }) } diff --git a/packages/kilo-memory/src/recall/indexer.ts b/packages/kilo-memory/src/recall/indexer.ts index e4c219cbb01..1f906d2867b 100644 --- a/packages/kilo-memory/src/recall/indexer.ts +++ b/packages/kilo-memory/src/recall/indexer.ts @@ -226,7 +226,6 @@ export namespace MemoryIndexer { return MemoryFiles.queue(input.root, async () => { const result = await build(input) await MemoryFiles.writeIndex(input.root, result.text) - await MemoryFiles.append(input.root, `regenerate index.kmem bytes=${result.bytes} tokens=${result.tokens}`) return result }) } diff --git a/packages/kilo-memory/src/recall/shared.ts b/packages/kilo-memory/src/recall/shared.ts index b24829b2f46..b38f215e6ac 100644 --- a/packages/kilo-memory/src/recall/shared.ts +++ b/packages/kilo-memory/src/recall/shared.ts @@ -94,6 +94,7 @@ export namespace MemoryShared { ] } + /** @deprecated Memory audit persistence was removed. */ export function audit(ops: MemoryOperations.Op[]) { return ops.map((item) => item.action === "add" diff --git a/packages/kilo-memory/src/schema.ts b/packages/kilo-memory/src/schema.ts index 59436a8c144..0ca36e7616e 100644 --- a/packages/kilo-memory/src/schema.ts +++ b/packages/kilo-memory/src/schema.ts @@ -1,3 +1,9 @@ +/** Wire contract for suppressible background-capture failures: guardReason emits this as the + * `memory.error` reason, and every client surface (TUI toast filter, VS Code provider) matches + * against this same constant rather than a local literal. Lives here (dependency-free module) + * so clients can import it without pulling the capture pipeline into their bundles. */ +export const TRANSIENT = "transient" as const + export namespace MemorySchema { export const VERSION = 1 export const maxStoredDigestSummary = 4_000 diff --git a/packages/kilo-memory/src/storage/audit.ts b/packages/kilo-memory/src/storage/audit.ts index 70c7c42bf3a..58fe9f496b7 100644 --- a/packages/kilo-memory/src/storage/audit.ts +++ b/packages/kilo-memory/src/storage/audit.ts @@ -1,15 +1,5 @@ -import z from "zod" -import { MemoryFs } from "./fs" - +/** Compatibility facade for the removed memory audit log. */ export namespace MemoryAudit { - const Log = z - .object({ - kind: z.literal("log"), - summary: z.string(), - time: z.string().optional(), - }) - .passthrough() - export type Decision = | { kind: "log" @@ -43,18 +33,14 @@ export namespace MemoryAudit { }[] } - function audit(root: string, input: Decision) { - void root - void input - return Promise.resolve() - } - export async function append(root: string, text: string) { - await audit(root, { kind: "log", result: "logged", summary: text }) + void root + void text } export async function decide(root: string, input: Decision) { - await audit(root, input) + void root + void input } export async function readDecisions(root: string) { @@ -62,24 +48,8 @@ export namespace MemoryAudit { return "" } - 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") + void root + return "" } } diff --git a/packages/kilo-memory/src/storage/state.ts b/packages/kilo-memory/src/storage/state.ts index bb373e24610..136a83a3e5e 100644 --- a/packages/kilo-memory/src/storage/state.ts +++ b/packages/kilo-memory/src/storage/state.ts @@ -1,6 +1,5 @@ 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" @@ -19,13 +18,10 @@ export namespace MemoryState { "corrections.md": "# Corrective Memory\n\n## Corrections\n", } - async function recover(root: string, file: string, error: unknown) { + async function recover(root: string, file: string) { 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 } @@ -33,14 +29,14 @@ export namespace MemoryState { 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) + if (MemoryFs.parse(error)) return recover(root, file) 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) + if (MemoryFs.parse(error)) return recover(root, file) throw error }) } @@ -199,7 +195,6 @@ export namespace MemoryState { ? { ...(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 } diff --git a/packages/kilo-memory/src/tool.ts b/packages/kilo-memory/src/tool.ts index e87fb85d0f5..cef71179d35 100644 --- a/packages/kilo-memory/src/tool.ts +++ b/packages/kilo-memory/src/tool.ts @@ -4,7 +4,6 @@ import { MemoryError, type MemoryError as Failure } from "./effect/errors" import { MemoryPaths } from "./effect/paths" import { MemoryService } from "./effect/service" import { MemoryRecall } from "./recall/recall" -import { MemoryToken } from "./recall/token" import { MemorySchema } from "./schema" import recallDescription from "./prompts/tool-memory-recall.txt" import saveDescription from "./prompts/tool-memory-save.txt" @@ -115,50 +114,6 @@ export namespace MemoryTool { } } - function audit( - memory: MemoryService.Interface, - input: { - root: string - params: RecallParams - current: string - hits: MemoryRecall.Hit[] - skipped?: string - output: string - }, - ) { - const files = [...new Set(input.hits.map((hit) => hit.source))] - const topics = [...new Set(input.hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))] - const query = - input.params.query ?? - (input.params.sessionID - ? `sessionID=${input.params.sessionID}` - : input.params.mode === "digest" - ? "recent digests" - : undefined) - return memory.decide({ - root: input.root, - decision: { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.current, - result: input.hits.length ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - reason: input.skipped, - query, - topics, - files, - tokens: MemoryToken.estimate(input.output), - operationCount: input.hits.length, - skippedCount: input.hits.length ? 0 : 1, - summary: input.hits.length - ? `memory recall returned ${input.hits.length} ${input.params.mode} hits` - : `memory recall found no ${input.params.mode} hits`, - }, - }) - } - function miss(input: { params: RecallParams; current: string }) { const self = input.params.sessionID === input.current if (self && input.params.mode === "digest") { @@ -239,35 +194,6 @@ export namespace MemoryTool { }) } - function catalogAudit( - memory: MemoryService.Interface, - input: { - root: string - current: string - query: string - result: { output: string; count: number; files: string[] } - }, - ) { - return memory.decide({ - root: input.root, - decision: { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.current, - result: input.result.count ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - query: input.query || "all keys", - files: input.result.files, - tokens: MemoryToken.estimate(input.result.output), - operationCount: input.result.count, - skippedCount: input.result.count ? 0 : 1, - summary: `memory catalog listed ${input.result.count} entries`, - }, - }) - } - function approvalRecall(input: Recall) { return input.ask({ permission: "kilo_memory_recall", @@ -285,7 +211,6 @@ export namespace MemoryTool { return Effect.gen(function* () { const result = yield* catalog(input.memory, { root: live.root, query }) const safe = { ...result, output: block(result.output) } - yield* catalogAudit(input.memory, { root: live.root, current: live.current, query, result: safe }) yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: result.count }) return { title: `Kilo memory catalog: ${result.count} entr${result.count === 1 ? "y" : "ies"}`, @@ -296,22 +221,13 @@ export namespace MemoryTool { } function recallQuery(input: Recall, live: Live) { - return Effect.gen(function* () { - const output = "Provide a topic query for typed/search memory recall." - yield* audit(input.memory, { - root: live.root, - params: input.params, - current: live.current, - hits: [], - skipped: "missing_query", - output, - }) - return { + return Effect.succeed( + { title: `Kilo memory ${input.params.mode}: no query`, - output, + output: "Provide a topic query for typed/search memory recall.", metadata: { sources: [], count: 0 }, - } satisfies Result - }) + } satisfies Result, + ) } function recallSearch(input: Recall, live: Live, query: string, mode: MemoryRecall.Mode) { @@ -327,15 +243,7 @@ export namespace MemoryTool { limit, }) const hits = result?.hits ?? [] - const self = input.params.sessionID === live.current - const skipped = - input.params.sessionID && input.params.mode === "digest" && hits.length === 0 - ? self - ? "current_session_digest" - : "missing_session_digest" - : undefined const output = hits.length ? result!.block : miss({ params: input.params, current: live.current }) - yield* audit(input.memory, { root: live.root, params: input.params, current: live.current, hits, skipped, output }) yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: hits.length }) if (hits.length === 0) { @@ -440,29 +348,8 @@ export namespace MemoryTool { return saved(input) } - function skip(input: Base & { params: SaveParams }, root: string) { - return Effect.gen(function* () { - const reason = input.params.reason ?? "out_of_scope" - yield* input.memory.decide({ - root, - decision: { - kind: "typed", - trigger: "explicit", - sessionID: input.sessionID, - result: "skipped", - llm: false, - parsed: true, - fallback: false, - reason, - tokens: 0, - operationCount: 0, - skippedCount: 1, - skipped: [{ reason }], - summary: `explicit memory save skipped: ${reason}`, - }, - }) - return skipped({ reason }) - }) + function skip(input: Base & { params: SaveParams }) { + return Effect.succeed(skipped({ reason: input.params.reason ?? "out_of_scope" })) } function forget(input: Save, root: string) { @@ -497,7 +384,7 @@ export namespace MemoryTool { const state = yield* input.memory.state({ root }) if (!state.enabled) return disabled() if (input.params.action === "forget") return yield* forget(input, root) - if (input.params.action === "skip") return yield* skip(input, root) + if (input.params.action === "skip") return yield* skip(input) return yield* write(input, root) }) } diff --git a/packages/kilo-memory/test/capture.test.ts b/packages/kilo-memory/test/capture.test.ts index a1dcdc609c2..516ed3a65d3 100644 --- a/packages/kilo-memory/test/capture.test.ts +++ b/packages/kilo-memory/test/capture.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { - auditOps, capturePlan, duplicateOps, + errorReason, fallbackDigest, guardReason, hasSubstantialDiff, @@ -128,7 +128,7 @@ describe("memory capture parsing", () => { expect(() => salvageTyped(`{"op":"upsert_project_fact","key":"good_one","value":"Keep this fact."}`)).toThrow() }) - test("redacts secrets in salvaged unsupported ops before they reach the audit", () => { + test("redacts secrets in salvaged unsupported ops before they reach callers", () => { const parsed = salvageTyped( `{"operations":[{"op":"not_a_real_op","key":"leak","value":"key is sk-abcdefghijklmnopqrstuvwxyz"}],"skipped":[]}`, ) @@ -169,17 +169,6 @@ describe("memory capture parsing", () => { expect(JSON.stringify(parsed.skipped)).not.toContain(secret.slice(0, 20)) }) - test("redacts remove audit queries before truncating", () => { - const secret = "sk-" + "a".repeat(40) - const query = "x".repeat(100) + secret - const audit = auditOps([{ action: "remove", query }]) - const text = JSON.stringify(audit) - - expect(text).toContain("[redacted]") - expect(text).not.toContain(secret) - expect(text).not.toContain(secret.slice(0, 20)) - }) - test("truncates typed batches beyond the op cap instead of failing", () => { const ops = Array.from( { length: 20 }, @@ -332,6 +321,11 @@ describe("memory capture parsing", () => { input: base, expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined }, }, + { + name: "expected work: a persisted null typed clock does not throttle capture", + input: { ...base, lastTypedConsolidationAt: null }, + 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, lastTypedConsolidationAt: 900 }, @@ -650,6 +644,70 @@ describe("memory capture parsing", () => { ) expect(guardReason("429 too many requests")).toBe("rate_limit_guard") expect(guardReason("billing credits exhausted")).toBe("quota_guard") + expect(guardReason("request timed out")).toBe("transient") + expect(guardReason("deadline exceeded")).toBe("transient") + expect(guardReason("DeadlineExceeded")).toBe("transient") + expect(guardReason('cause={"code":"ETIMEDOUT"}')).toBe("transient") + expect(guardReason("504 Gateway Timeout")).toBe("transient") + expect(guardReason("Connect Timeout Error")).toBe("transient") + expect(guardReason("Headers Timeout Error")).toBe("transient") + expect(guardReason("Body Timeout Error")).toBe("transient") + expect(guardReason("connect_timeout")).toBe("transient") + expect(guardReason('status=400 body={"error":"invalid parameter: timeout"}')).toBeUndefined() + expect(guardReason("set request timeout to 30000")).toBeUndefined() + const timeout = new Error("request aborted") + timeout.name = "TimeoutError" + expect(guardReason(timeout)).toBe("transient") + expect(errorReason(timeout)).toBe("request aborted") + expect(guardReason(Object.assign(new Error("request failed"), { status: 504 }))).toBe("transient") + expect( + guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_CONNECT_TIMEOUT" } })), + ).toBe("transient") + expect( + guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_HEADERS_TIMEOUT" } })), + ).toBe("transient") + expect(guardReason(Object.assign(new Error("request failed"), { cause: { code: "ETIMEDOUT" } }))).toBe( + "transient", + ) + expect( + guardReason(Object.assign(new Error("failed after 2 attempts"), { errors: [{ statusCode: 504 }] })), + ).toBe("transient") + expect(guardReason(new Error("set request timeout to 30000"))).toBeUndefined() + expect(guardReason(new Error("request timed out"))).toBe("transient") + expect(guardReason(Object.assign(new Error("request failed"), { cause: new Error("connect timeout") }))).toBe( + "transient", + ) + expect( + guardReason( + Object.assign(new Error("failed after 2 attempts"), { + errors: [Object.assign(new Error("rate limit reached"), { status: 429 }), timeout], + }), + ), + ).toBe("rate_limit_guard") + expect( + guardReason( + Object.assign(new Error("failed after 2 attempts"), { + errors: [new Error("insufficient quota available"), timeout], + }), + ), + ).toBe("quota_guard") + expect( + guardReason( + Object.assign(new Error("request failed"), { + cause: Object.assign(new Error("rate limit reached"), { status: 429 }), + }), + ), + ).toBe("rate_limit_guard") + expect( + guardReason( + Object.assign(new Error("timed out"), { + name: "TimeoutError", + cause: new Error("billing credit balance exhausted"), + }), + ), + ).toBe("quota_guard") + expect(guardReason("timeout after 429 too many requests")).toBe("rate_limit_guard") + expect(guardReason("timeout after quota exceeded")).toBe("quota_guard") }) test("redacts common secret token shapes", () => { diff --git a/packages/kilo-memory/test/core.test.ts b/packages/kilo-memory/test/core.test.ts index 04b52a03624..9c029e6e211 100644 --- a/packages/kilo-memory/test/core.test.ts +++ b/packages/kilo-memory/test/core.test.ts @@ -50,6 +50,22 @@ describe("memory core package", () => { }) }) + test("legacy audit compatibility APIs remain no-op", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await MemoryFiles.append(t.root, "provider error with sensitive detail") + await MemoryFiles.decide(t.root, { + kind: "typed", + result: "error", + reason: "provider error with sensitive detail", + }) + + expect(await MemoryFiles.readChanges(t.root)).toBe("") + expect(await MemoryFiles.readDecisions(t.root)).toBe("") + expect(await Bun.file(path.join(t.root, "decisions.jsonl")).exists()).toBe(false) + }) + }) + test("prepare removes legacy decisions once from owned memory roots", async () => { await use(async (t) => { await Memory.enable({ root: t.root }) diff --git a/packages/kilo-memory/test/effect-capture.test.ts b/packages/kilo-memory/test/effect-capture.test.ts index 91e0ec84e04..50a86b0f1f4 100644 --- a/packages/kilo-memory/test/effect-capture.test.ts +++ b/packages/kilo-memory/test/effect-capture.test.ts @@ -7,6 +7,7 @@ import { digestPrompt, typedPrompt } from "../src/capture/capture" import { MemoryCapture } from "../src/effect/capture" import { MemoryEvents } from "../src/effect/events" import { KiloMemory } from "../src/effect/index" +import { MemoryLog } from "../src/effect/log" import type { MemoryPorts } from "../src/effect/ports" import { MemoryService } from "../src/effect/service" import { MemoryTimers } from "../src/effect/timers" @@ -49,11 +50,18 @@ function session(turn: MemoryPorts.TurnView | undefined): MemoryPorts.SessionPor /** 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?: (system: string) => void }): MemoryPorts.ModelPort { +function model(input: { + digest: string + typed: string + fallback?: string + fail?: Error + onRun?: (system: string) => void +}): MemoryPorts.ModelPort { return { resolve: () => Effect.succeed({ handle: {}, ...(input.fallback ? { fallback: { reason: input.fallback } } : {}) }), run: async ({ system }) => { input.onRun?.(system) + if (system === typedPrompt && input.fail) throw input.fail const text = system === digestPrompt ? input.digest : system === typedPrompt ? input.typed : "{}" return { text, usage: USAGE } }, @@ -66,6 +74,7 @@ function run(input: { model: MemoryPorts.ModelPort memoryModel?: string reason?: "completed" | "interrupted" | "error" + bypassInterval?: boolean }) { return Effect.runPromise( MemoryCapture.turn({ @@ -75,6 +84,7 @@ function run(input: { model: input.model, memoryModel: input.memoryModel, reason: input.reason ?? "completed", + bypassInterval: input.bypassInterval, }).pipe(Effect.provideService(MemoryService.Service, MemoryService.make())), ) } @@ -107,6 +117,121 @@ describe("MemoryCapture (fake ports)", () => { } }) + test("typed timeout preserves digest progress without advancing the typed clock", async () => { + const t = await tmp() + const events: MemoryEvents.Status[] = [] + const logs: string[] = [] + try { + await KiloMemory.enable({ root: t.root }) + await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } }) + MemoryLog.setWarn((message, meta) => logs.push(`${message}:${meta?.reason}:${meta?.detail}`)) + MemoryEvents.setSink((input) => { + events.push(input.payload) + }) + + 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: "{}", + fail: new DOMException("memory model timed out", "TimeoutError"), + }), + }) + + expect(result).toMatchObject({ skipped: false, operationCount: 0 }) + const state = await MemoryFiles.readState(t.root) + expect(state.stats.lastTypedConsolidationAt).toBeNull() + expect(state.stats.lastSessionSavedAt).toEqual(expect.any(Number)) + expect(state.stats.lastConsolidatedMessageID).toBe("msg_assistant") + expect(events.find((item) => item.state === "error")?.reason).toBe("transient") + expect(logs).toEqual(["memory capture transient failure:transient:memory model timed out"]) + + const calls: string[] = [] + const retry = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: "{}", + typed: '{"operations":[],"skipped":[]}', + onRun: (system) => calls.push(system), + }), + bypassInterval: true, + }) + expect(retry).toMatchObject({ skipped: true, reason: "no_new_content" }) + expect(calls).toEqual([]) + + const next = await run({ + root: t.root, + session: session( + view({ + assistant: "Use bun install, then run the package tests and typecheck.", + lastAssistantID: "msg_assistant_next", + }), + ), + model: model({ + digest: "{}", + typed: '{"operations":[],"skipped":[]}', + onRun: (system) => calls.push(system), + }), + }) + expect(next).toMatchObject({ skipped: false, operationCount: 0 }) + expect(calls).toEqual([typedPrompt]) + const updated = await MemoryFiles.readState(t.root) + expect(updated.stats.lastTypedConsolidationAt).toEqual(expect.any(Number)) + expect(updated.stats.lastConsolidatedMessageID).toBe("msg_assistant_next") + } finally { + MemoryLog.setWarn(() => {}) + MemoryEvents.setSink(() => {}) + await t.done() + } + }) + + test("fallback commit preserves metrics from the prior successful typed consolidation", async () => { + const t = await tmp() + try { + await KiloMemory.enable({ root: t.root }) + await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } }) + + const first = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: '{"topic":"repo setup","summary":"Explored repo setup."}', + typed: + '{"operations":[{"op":"upsert_environment_fact","section":"Commands","key":"test_cmd","value":"bun test"}],"skipped":[]}', + }), + }) + expect(first).toMatchObject({ skipped: false, operationCount: 1 }) + const initial = await MemoryFiles.readState(t.root) + expect(initial.stats.lastOperationCount).toBe(1) + expect(initial.stats.lastTypedConsolidationAt).toEqual(expect.any(Number)) + + const second = await run({ + root: t.root, + session: session( + view({ + assistant: "Investigated a timeout edge case.", + lastAssistantID: "msg_assistant_timeout", + }), + ), + model: model({ + digest: '{"topic":"investigation","summary":"Investigated timeouts."}', + typed: "{}", + fail: new DOMException("memory model timed out", "TimeoutError"), + }), + bypassInterval: true, + }) + expect(second).toMatchObject({ skipped: false, operationCount: 0 }) + const preserved = await MemoryFiles.readState(t.root) + expect(preserved.stats.lastOperationCount).toBe(1) + expect(preserved.stats.lastTypedConsolidationAt).toBe(initial.stats.lastTypedConsolidationAt) + expect(preserved.stats.lastConsolidatedMessageID).toBe("msg_assistant_timeout") + } finally { + await t.done() + } + }) + test("turn-close skips a secret-like op and applies the rest of the batch", async () => { const t = await tmp() const events: MemoryEvents.Status[] = [] diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 4660904c00a..918b0a0afc3 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,6 +1,7 @@ import * as path from "path" import { existsSync } from "fs" import * as vscode from "vscode" +import { TRANSIENT as MEMORY_TRANSIENT } from "@kilocode/kilo-memory/schema" import type { KiloClient, Session, @@ -4307,8 +4308,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper for (const sid of trackedByDir) targets.add(sid) if (local && active) targets.add(active) if (targets.size === 0 && local) targets.add(undefined) - const detail = - props.detail && typeof props.detail === "object" + const transient = event.type === "memory.error" && props.reason === MEMORY_TRANSIENT + const detail = transient + ? undefined + : props.detail && typeof props.detail === "object" ? props.detail : event.type === "memory.error" && typeof props.reason === "string" ? { type: "error", message: props.reason, reason: props.reason } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts index 274cb8cc60d..815ab32f8bd 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts @@ -127,6 +127,43 @@ describe("KiloProvider memory events", () => { expect(calls).toEqual(["/repo", "/repo"]) }) + it("refreshes status without forwarding transient memory errors", async () => { + const calls: string[] = [] + const posts: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(input.directory) + return { data: status(input.directory) } + }, + }, + } as unknown as KiloClient + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + item.trackedSessionIds.add("ses_active") + provider.setSessionDirectory("ses_active", "/repo") + + item.handleEvent( + { + type: "memory.error", + properties: { sessionID: "ses_active", reason: "transient" }, + }, + "/repo", + ) + await item.memory.idle() + + expect(posts).not.toContainEqual(expect.objectContaining({ type: "memoryEvent" })) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" })) + expect(calls).toEqual(["/repo"]) + }) + it("uses the project directory when toggling memory", async () => { const calls: unknown[] = [] const client = { diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts b/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts index c388231b922..293feee20cf 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts @@ -1,3 +1,5 @@ +import { TRANSIENT } from "@kilocode/kilo-memory/schema" + type Event = { on(type: "memory.error", fn: (event: MemoryEvent) => void): void | (() => void) } @@ -22,6 +24,7 @@ export namespace MemoryTuiEvents { }) { const handler = (event: MemoryEvent) => { if (event.properties.sessionID && event.properties.sessionID !== input.sessionID) return + if (event.properties.reason === TRANSIENT) return const detail = event.properties.detail if (!detail || typeof detail !== "object") { input.toast.show({ diff --git a/packages/opencode/src/kilocode/memory/ports.ts b/packages/opencode/src/kilocode/memory/ports.ts index 03d26d54674..9b38ceac89d 100644 --- a/packages/opencode/src/kilocode/memory/ports.ts +++ b/packages/opencode/src/kilocode/memory/ports.ts @@ -191,6 +191,7 @@ async function memoryText(input: { temperature: input.temperature, topP: input.topP, topK: input.topK, + maxRetries: 1, } const work = async () => { if (!openai) return generateText(common) @@ -210,7 +211,7 @@ async function memoryText(input: { const timeout = new Promise((_, reject) => { timer = setTimeout(() => { ctl.abort() - reject(new Error("memory model timed out")) + reject(new DOMException("memory model timed out", "TimeoutError")) }, ms) }) try { diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts index 42df5344333..7448077955b 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts @@ -276,7 +276,7 @@ describe("memory TUI events", () => { expect(handlers).toEqual({ "memory.error": [expect.any(Function)] }) }) - test("keeps generic and detailed errors visible", async () => { + test("suppresses transient errors while keeping generic and detailed errors visible", async () => { const shown: string[] = [] const handlers: Record = {} MemoryTuiEvents.attach({ @@ -298,6 +298,17 @@ describe("memory TUI events", () => { fn({ properties: { sessionID: "ses_tui_memory", reason: "model failed" } }), ), ) + await Promise.all( + (handlers["memory.error"] ?? []).map((fn) => + fn({ + properties: { + sessionID: "ses_tui_memory", + reason: "transient", + detail: { message: "Memory model timed out" }, + }, + }), + ), + ) await Promise.all( (handlers["memory.error"] ?? []).map((fn) => fn({ properties: { sessionID: "ses_tui_memory", detail: { message: "Memory save failed" } } }), diff --git a/packages/opencode/test/kilocode/memory/memory-ports.test.ts b/packages/opencode/test/kilocode/memory/memory-ports.test.ts index 8016c1ab00f..8b39e8e99c0 100644 --- a/packages/opencode/test/kilocode/memory/memory-ports.test.ts +++ b/packages/opencode/test/kilocode/memory/memory-ports.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" +import { APICallError } from "ai" import { Effect } from "effect" import { ModelNotFoundError, type Provider } from "../../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -31,15 +32,22 @@ function mdl(id = mid): Provider.Model { } as unknown as Provider.Model } -function lang(outputs = ["{}"]): LanguageModelV3 { +function lang(outputs: (string | Error)[] = ["{}"], calls?: unknown[], hang?: boolean): LanguageModelV3 { let idx = 0 + const next = () => { + const item = outputs[idx++] ?? outputs.at(-1) ?? "{}" + if (item instanceof Error) throw item + return item + } return { specificationVersion: "v3", provider: "test", modelId: "fake-memory-model", supportedUrls: {}, - doGenerate: async () => { - const text = outputs[idx++] ?? outputs.at(-1) ?? "{}" + doGenerate: async (...args: Parameters) => { + calls?.push(args[0]) + if (hang) return new Promise(() => {}) + const text = next() return { content: [{ type: "text", text }], finishReason: { unified: "stop" }, @@ -57,7 +65,9 @@ function lang(outputs = ["{}"]): LanguageModelV3 { } as unknown as LanguageModelV3 } -function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider.Interface { +function provider( + input: { outputs?: (string | Error)[]; seen?: string[]; calls?: unknown[]; hang?: boolean } = {}, +): Provider.Interface { const base = mdl() const mem = mdl(ModelV2.ID.make("memory-config-model")) const info = { @@ -78,7 +88,7 @@ function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider }, getLanguage: (model) => { input.seen?.push(model.id) - return Effect.succeed(lang(input.outputs)) + return Effect.succeed(lang(input.outputs, input.calls, input.hang)) }, closest: () => Effect.succeed({ providerID: pid, modelID: base.id }), getSmallModel: () => Effect.succeed(mem), @@ -280,6 +290,34 @@ describe("memory ports", () => { expect(seen).toEqual(["memory-config-model", "fake-memory-model"]) }) + test("model port retries a transient provider failure once", async () => { + const calls: unknown[] = [] + const err = new APICallError({ + message: "temporarily unavailable", + url: "https://example.com/v1/generate", + requestBodyValues: {}, + statusCode: 503, + responseHeaders: {}, + responseBody: '{"error":"temporarily unavailable"}', + isRetryable: true, + }) + const port = MemoryModel.port({ provider: provider({ outputs: [err, "{}"], calls }) }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 30_000 }) + + expect(calls).toHaveLength(2) + }) + + test("model port emits a structured timeout error", async () => { + const port = MemoryModel.port({ provider: provider({ hang: true }) }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await expect( + port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 1 }), + ).rejects.toMatchObject({ name: "TimeoutError", message: "memory model timed out" }) + }) + test("model port clears its timeout after successful output", async () => { const set = globalThis.setTimeout const clear = globalThis.clearTimeout