mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix(memory): quiet transient capture timeouts and stop same-turn retries
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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<object>()): 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<object>()): 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<object>()): 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)
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
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 })
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof MemoryFiles.recentSessions>>, Failure>
|
||||
/** @deprecated Memory audit persistence was removed. */
|
||||
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 recordRecall: (input: RecordRecallInput) => Effect.Effect<void, Failure>
|
||||
/** @deprecated Memory audit persistence was removed. */
|
||||
readonly decide: (input: DecideInput) => Effect.Effect<void, Failure>
|
||||
readonly readSource: (input: ReadSourceInput) => Effect.Effect<string, Failure>
|
||||
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`.
|
||||
|
||||
@@ -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))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
ctl.abort()
|
||||
reject(new Error("memory model timed out"))
|
||||
reject(new DOMException("memory model timed out", "TimeoutError"))
|
||||
}, ms)
|
||||
})
|
||||
try {
|
||||
|
||||
@@ -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<string, Handler[]> = {}
|
||||
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" } } }),
|
||||
|
||||
@@ -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<LanguageModelV3["doGenerate"]>) => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user