92c612b94c
Four defects found by end-to-end testing, not by reading the code. 1. Extraction never ran, silently. Collecting every event type that ctx.event.subscribe actually delivers shows `session.idle` is NOT among them — it is defined in the protocol but never emitted on this stream. The subscription filtered on it alone, so auto-extraction was a permanent no-op with no error. Now debounced off events that do arrive (session.step.started / session.tool.called / session.inbox.delivered) after `settleMs` of quiet, which is what "idle" meant anyway. Verified: the trigger now fires and reports "轮数不足" for a 1-turn session. 2. Dedupe silently stopped rejecting duplicates. It compared against a fixed retrieval score, but that score carries idf and scope priors that drift as the corpus grows, so an obvious restatement of an existing memory scored 0.48 against a 0.9 threshold and was written as a rival entry. Now uses term coverage of the candidate, which answers "is this already said?" and does not move with corpus size. Verified: a 100%-duplicate is rejected, a novel pitfall is kept. 3. Harness-injected user turns became the retrieval query. "<system-reminder>" and "The server restarted while you were working" are user-role messages, so the hook retrieved on them and injected memories chosen by noise. Both are filtered out now. 4. The `pinned` flag was absent from the remember tool's input schema, so the schema stripped it and pinning was unreachable from the tool surface. `kind` also ignored the rules/ category when a stale value was stored. memory_inspect now reports runtime health for the two paths with no other observable effect: last injection size and query, and last extraction outcome with its reason. Without it, "working" and "silently dead" look identical — which is exactly how defects 1 and 2 survived the first round of testing.
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
// Automatic extraction: turn a finished turn into candidate memories.
|
|
//
|
|
// Runs on `session.idle`. The point is that memory capture must not depend on
|
|
// the model remembering to call `memory_write` — models forget. This is the
|
|
// safety net.
|
|
//
|
|
// Two deliberate limits keep it from becoming a garbage generator:
|
|
//
|
|
// 1. A separate, cheap model does the extraction. It never touches the main
|
|
// conversation, so a bad extraction cannot derail the user's work.
|
|
// 2. Hard rules are never written automatically. The extractor may *propose*
|
|
// one, but only a human confirms it. Letting a model write "iron rules"
|
|
// would let a hallucination become permanent policy.
|
|
|
|
import type { Context } from "@opencode/plugin"
|
|
import { tokenize } from "./recall.ts"
|
|
|
|
export interface ExtractOptions {
|
|
enabled: boolean
|
|
/** Minimum turns in a session before extraction kicks in. */
|
|
minTurns: number
|
|
/** Cap on how many messages are handed to the extractor. */
|
|
maxMessages: number
|
|
model?: { providerID: string; id: string }
|
|
}
|
|
|
|
export const DEFAULT_EXTRACT: ExtractOptions = {
|
|
enabled: true,
|
|
minTurns: 4,
|
|
maxMessages: 24,
|
|
}
|
|
|
|
export interface Candidate {
|
|
category: string
|
|
slug: string
|
|
title: string
|
|
summary: string
|
|
highlight?: string
|
|
body: string
|
|
kind?: string
|
|
loadWhen?: string[]
|
|
/** Rules the model believes should be hard rules, needing human confirmation. */
|
|
proposeRule?: boolean
|
|
}
|
|
|
|
const SYSTEM = `You extract durable memories from a coding conversation.
|
|
|
|
Return ONLY a JSON array. Each element:
|
|
{
|
|
"category": "preferences|decisions|pitfalls|facts",
|
|
"slug": "kebab-case-identifier",
|
|
"title": "short title",
|
|
"summary": "ONE sentence, under 100 chars. This is what shows in an index.",
|
|
"highlight": "the single most important detail",
|
|
"body": "markdown detail, 2-6 sentences",
|
|
"loadWhen": ["trigger words or situations where this is relevant"],
|
|
"proposeRule": false
|
|
}
|
|
|
|
Only record what stays true after the session ends:
|
|
GOOD A stated preference, an architectural decision and its reason,
|
|
a non-obvious gotcha and its root cause, a project convention.
|
|
BAD What the code currently looks like, task state, git branch names,
|
|
file paths, anything already obvious from the repo, one-off chatter.
|
|
|
|
NEVER set proposeRule to true unless the user stated an absolute rule about how
|
|
to behave ("always", "never", "必须", "禁止"). Doubtful -> omit the memory.
|
|
|
|
If nothing durable was learned, return [].`
|
|
|
|
/**
|
|
* Pull plain text out of a session message without depending on the exact
|
|
* message shape: the plugin package ships types for a schema the host may
|
|
* evolve, so this walks defensively and collects `text` fields.
|
|
*/
|
|
export function collectText(value: unknown, depth = 0, out: string[] = []): string[] {
|
|
if (depth > 6 || out.length > 400) return out
|
|
if (typeof value === "string") {
|
|
if (value.trim()) out.push(value)
|
|
return out
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectText(item, depth + 1, out)
|
|
return out
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const record = value as Record<string, unknown>
|
|
// Prefer a part's own text over its metadata noise.
|
|
if (typeof record.text === "string" && record.text.trim()) out.push(record.text)
|
|
for (const [key, child] of Object.entries(record)) {
|
|
if (key === "text") continue
|
|
if (key === "synthetic" || key === "cache" || key === "providerMetadata") continue
|
|
collectText(child, depth + 1, out)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
export function userAndAssistantText(messages: readonly unknown[]): string {
|
|
const parts: string[] = []
|
|
for (const message of messages) {
|
|
const record = message as Record<string, unknown> | null
|
|
if (!record) continue
|
|
const role = record.role
|
|
if (role !== "user" && role !== "assistant") continue
|
|
const texts = collectText(record.content)
|
|
const joined = texts.join("\n").trim()
|
|
if (joined) parts.push(`${role === "user" ? "USER" : "ASSISTANT"}: ${joined}`)
|
|
}
|
|
return parts.join("\n\n")
|
|
}
|
|
|
|
/** `generate.text` is documented as returning text; accept a few shapes. */
|
|
function coerceText(result: unknown): string {
|
|
if (typeof result === "string") return result
|
|
if (result && typeof result === "object") {
|
|
const record = result as Record<string, unknown>
|
|
for (const key of ["text", "content", "output", "result"]) {
|
|
const value = record[key]
|
|
if (typeof value === "string") return value
|
|
if (Array.isArray(value)) {
|
|
const joined = collectText(value).join("\n")
|
|
if (joined) return joined
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
/** Parse the extractor's JSON array, tolerating prose and code fences. */
|
|
export function parseCandidates(raw: string): Candidate[] {
|
|
const text = raw.trim()
|
|
if (!text) return []
|
|
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
|
|
const body = fenced?.[1] ?? text
|
|
const start = body.indexOf("[")
|
|
const end = body.lastIndexOf("]")
|
|
if (start < 0 || end <= start) return []
|
|
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(body.slice(start, end + 1))
|
|
} catch {
|
|
return []
|
|
}
|
|
if (!Array.isArray(parsed)) return []
|
|
|
|
const out: Candidate[] = []
|
|
for (const entry of parsed) {
|
|
if (!entry || typeof entry !== "object") continue
|
|
const record = entry as Record<string, unknown>
|
|
const title = str(record.title)
|
|
const bodyText = str(record.body)
|
|
if (!title || !bodyText) continue
|
|
|
|
const category = str(record.category).toLowerCase() || "facts"
|
|
const slug =
|
|
slugify(str(record.slug)) || slugify(title)
|
|
if (!slug) continue
|
|
|
|
out.push({
|
|
category: category.replace(/[^a-z0-9._-]/g, "") || "facts",
|
|
slug,
|
|
title,
|
|
summary: str(record.summary) || title,
|
|
highlight: str(record.highlight) || undefined,
|
|
body: bodyText,
|
|
kind: str(record.kind) || undefined,
|
|
loadWhen: Array.isArray(record.loadWhen)
|
|
? record.loadWhen.map((v) => str(v)).filter(Boolean).slice(0, 8)
|
|
: undefined,
|
|
proposeRule: record.proposeRule === true,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
function str(value: unknown): string {
|
|
return typeof value === "string" ? value.trim() : ""
|
|
}
|
|
|
|
export function slugify(input: string): string {
|
|
const ascii = input
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 48)
|
|
if (ascii) return ascii
|
|
// CJK titles have no ASCII to work with; fall back to a hash-free stable id.
|
|
return [...input].map((ch) => ch.codePointAt(0)!.toString(36)).join("").slice(0, 24)
|
|
}
|
|
|
|
/** Secrets that must never reach a git-tracked memory file. */
|
|
const SECRET_PATTERNS: RegExp[] = [
|
|
/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,
|
|
/\bgh[pousr]_[A-Za-z0-9]{16,}/g,
|
|
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
/\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*\S+/gi,
|
|
/-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g,
|
|
]
|
|
|
|
export function hasSecret(text: string): string | undefined {
|
|
for (const pattern of SECRET_PATTERNS) {
|
|
const match = pattern.exec(text)
|
|
if (match) return match[0].slice(0, 24)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export function redactSecrets(text: string): string {
|
|
let out = text
|
|
for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, "[REDACTED]")
|
|
return out
|
|
}
|
|
|
|
export interface ExtractionOutcome {
|
|
candidates: Candidate[]
|
|
rejected: { candidate: Candidate; reason: string }[]
|
|
}
|
|
|
|
/**
|
|
* Decide which candidates are worth writing, using existing memories for
|
|
* dedupe.
|
|
*
|
|
* Dedupe uses term COVERAGE of the candidate, not the retrieval score. The
|
|
* score carries idf and scope priors that shift as the corpus grows, so a fixed
|
|
* score threshold silently stops rejecting duplicates as the library fills up.
|
|
* Coverage answers the question we actually care about: "does an existing
|
|
* memory already say this?"
|
|
*/
|
|
export function screenCandidates(
|
|
candidates: Candidate[],
|
|
existing: readonly import("./store.ts").MemoryFile[],
|
|
options: { coverage?: number } = {},
|
|
): ExtractionOutcome {
|
|
const threshold = options.coverage ?? 0.55
|
|
const accepted: Candidate[] = []
|
|
const rejected: { candidate: Candidate; reason: string }[] = []
|
|
|
|
for (const candidate of candidates) {
|
|
const secret = hasSecret(`${candidate.title}\n${candidate.summary}\n${candidate.body}`)
|
|
if (secret) {
|
|
rejected.push({ candidate, reason: `含疑似密钥 (${secret}…)` })
|
|
continue
|
|
}
|
|
if (candidate.proposeRule) {
|
|
// Not rejected outright — a proposal is surfaced for a human, which is
|
|
// handled by the caller. Flag it here so it is written as a normal
|
|
// memory under `proposals` rather than promoted to policy.
|
|
candidate.category = "rules"
|
|
candidate.kind = "rule-proposal"
|
|
}
|
|
|
|
const probe = `${candidate.title}\n${candidate.summary}\n${candidate.body}`
|
|
const best = bestCoverage(probe, existing)
|
|
if (best.file && best.coverage >= threshold) {
|
|
rejected.push({
|
|
candidate,
|
|
reason: `与已有记忆重复 ${(best.coverage * 100).toFixed(0)}% (${best.file.rel})`,
|
|
})
|
|
continue
|
|
}
|
|
accepted.push(candidate)
|
|
}
|
|
|
|
return { candidates: accepted, rejected }
|
|
}
|
|
|
|
/**
|
|
* Fraction of the candidate's distinctive terms that an existing memory also
|
|
* contains. The candidate's own terms are the query; the best match wins.
|
|
*/
|
|
function bestCoverage(
|
|
probe: string,
|
|
existing: readonly import("./store.ts").MemoryFile[],
|
|
): { file?: import("./store.ts").MemoryFile; coverage: number } {
|
|
const probeTerms = new Set(tokenize(probe).filter((t) => t.length >= 2))
|
|
if (probeTerms.size === 0) return { coverage: 0 }
|
|
|
|
let best: { file?: import("./store.ts").MemoryFile; coverage: number } = { coverage: 0 }
|
|
for (const file of existing) {
|
|
if (file.fm.superseded_by) continue
|
|
// Same slug in the same root is the same memory, whatever the wording.
|
|
const doc = new Set(
|
|
tokenize([file.title, file.summary, file.highlight, file.body].join("\n")).filter((t) => t.length >= 2),
|
|
)
|
|
let shared = 0
|
|
for (const term of probeTerms) if (doc.has(term)) shared++
|
|
const coverage = shared / probeTerms.size
|
|
if (coverage > best.coverage) best = { file, coverage }
|
|
}
|
|
return best
|
|
}
|
|
|
|
export interface RunExtractionInput {
|
|
ctx: Context
|
|
model: { providerID: string; id: string }
|
|
transcript: string
|
|
existing: readonly import("./store.ts").MemoryFile[]
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
/** One model round-trip. Returns screened candidates and why things were dropped. */
|
|
export async function extract(input: RunExtractionInput): Promise<ExtractionOutcome> {
|
|
const prompt = `${SYSTEM}\n\n---\n\n${input.transcript}`
|
|
let result: unknown
|
|
try {
|
|
// `generate.text` has no cancellation documented on this surface, so the
|
|
// signal is forwarded when accepted and simply ignored when not.
|
|
result = await (input.ctx.generate as { text: (input: unknown) => Promise<unknown> }).text({
|
|
model: input.model,
|
|
prompt,
|
|
...(input.signal ? { signal: input.signal } : {}),
|
|
})
|
|
} catch {
|
|
// An extraction failure must never disturb the user's session.
|
|
return { candidates: [], rejected: [] }
|
|
}
|
|
const text = coerceText(result)
|
|
const candidates = parseCandidates(text)
|
|
return screenCandidates(candidates, input.existing)
|
|
} |