Add memory plugin: three-tier markdown store with plugin-driven retrieval
A durable memory layer for OpenCode. Structure:
ROOT.md total plan: goals, hard rules, directory registry,
and a scenario -> file routing table
<category>/
index.md one line per file, COMPILED from frontmatter
<slug>.md the memory itself
Two roots, same shape: <config>/memory/ (global) and
<project canonical>/.memory/ (project). Project wins on conflict; global
hard rules stay resident everywhere.
Why a plugin rather than a skill that tells the model to read a folder:
model-driven retrieval is unreliable — the model forgets, reads half the
files, or reads the wrong ones. Markdown is the human-facing format; the
plugin is the engine that parses, scores, and injects.
Measured, not assumed: FTS5's two built-in tokenizers both fail on this
corpus. On SQLite 3.53.4 with "部署脚本用 pnpm 而不是 npm...": unicode61
emits 7 terms for the whole sentence and matches 0 hits for "部署";
trigram needs >= 3 characters and also returns 0 for that two-character
word. So tokenization happens in the plugin (CJK bigrams, latin words)
and no SQL index is used — a thousand memories are fine to scan.
index.md and ROOT's two tables are compiled, never hand-edited: a
hand-maintained index drifts, a generated one cannot. Hit counters live
in .state.json so cross-device sync does not conflict on every recall.
Tools are named recall/remember/forget/inspect rather than
write/read/status: generic names were observed shadowing the harness's
own built-in tools.
This commit is contained in:
@@ -0,0 +1,680 @@
|
||||
// Memory plugin for OpenCode — three-tier, multi-root, human-editable.
|
||||
//
|
||||
// ROOT.md the total plan: goals, hard rules, directory registry,
|
||||
// and a routing table of "scenario -> which file to load"
|
||||
// <category>/ one per kind of memory, same shape at every level
|
||||
// index.md one line per file. COMPILED from frontmatter, never edited
|
||||
// <slug>.md the memory, with frontmatter (summary / highlight / load_when)
|
||||
//
|
||||
// Two roots coexist and use identical rules:
|
||||
// global <config>/memory/ preferences, decisions, general pitfalls
|
||||
// project <project canonical>/.memory/ facts specific to this checkout
|
||||
// Project wins on conflict; global hard rules stay resident everywhere.
|
||||
//
|
||||
// Why a plugin rather than a skill telling the model to read a folder: model-
|
||||
// driven retrieval is unreliable — the model forgets, reads half the files, or
|
||||
// reads the wrong ones. Here the plugin does retrieval, scoring, and injection
|
||||
// itself. The Markdown is the human-facing format; the plugin is the engine.
|
||||
//
|
||||
// Docs consulted:
|
||||
// OpenCode V2 plugins: https://opencode.ai/v2/docs/build/plugins/
|
||||
// OpenCode plugins config: https://opencode.ai/v2/docs/plugins
|
||||
// Session hooks / events: see the plugin page's Hooks and Events sections
|
||||
//
|
||||
// Prerequisite: npm i @opencode/plugin@$(opencode --version | sed 's/^v//')
|
||||
// then `opencode service restart`.
|
||||
|
||||
import { Plugin, type Context } from "@opencode/plugin"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { buildInjection, readGoal, scoreFiles } from "./recall.ts"
|
||||
import { DEFAULT_EXTRACT, extract, redactSecrets, userAndAssistantText, type ExtractOptions } from "./extract.ts"
|
||||
import {
|
||||
type MemoryFile,
|
||||
type MemoryRoot,
|
||||
compileCategoryIndexes,
|
||||
compileRoot,
|
||||
ensureRoot,
|
||||
findNearestRoot,
|
||||
globalRoot,
|
||||
markSuperseded,
|
||||
projectRoot,
|
||||
readState,
|
||||
scanRoot,
|
||||
validateCategory,
|
||||
validateSlug,
|
||||
writeMemory,
|
||||
writeState,
|
||||
} from "./store.ts"
|
||||
|
||||
interface PluginConfig {
|
||||
/** Hard cap on injected characters per model request. Default 1200. */
|
||||
maxInjectChars?: number
|
||||
/** How many memory bodies to inline per request. Default 4. */
|
||||
maxInjectEntries?: number
|
||||
/** Half-life in days for the recency decay. Default 14; 0 disables. */
|
||||
halfLifeDays?: number
|
||||
autoExtract?: boolean
|
||||
minTurns?: number
|
||||
extractModel?: { providerID: string; id: string }
|
||||
/** Seed these categories on first run. */
|
||||
categories?: string[]
|
||||
}
|
||||
|
||||
const CATEGORIES = ["rules", "preferences", "decisions", "pitfalls", "facts"]
|
||||
const DEFAULT_MAX_INJECT = 1200
|
||||
|
||||
export default Plugin.define({
|
||||
id: "memory",
|
||||
|
||||
async setup(ctx) {
|
||||
const config = ctx.options as PluginConfig
|
||||
const maxChars = config.maxInjectChars ?? DEFAULT_MAX_INJECT
|
||||
const maxEntries = config.maxInjectEntries ?? 4
|
||||
const halfLifeDays = config.halfLifeDays ?? 14
|
||||
const categories = config.categories ?? CATEGORIES
|
||||
const extractOptions: ExtractOptions = {
|
||||
...DEFAULT_EXTRACT,
|
||||
enabled: config.autoExtract !== false,
|
||||
minTurns: config.minTurns ?? DEFAULT_EXTRACT.minTurns,
|
||||
...(config.extractModel ? { model: config.extractModel } : {}),
|
||||
}
|
||||
|
||||
// `ctx.location.directory` is the session's working directory, NOT the
|
||||
// config root, so it cannot be used to locate this plugin's own files. The
|
||||
// plugin directory itself is the only reliable anchor:
|
||||
// <config>/plugins/memory/index.ts -> up two levels -> <config>
|
||||
const configDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..")
|
||||
const global = globalRoot(configDir)
|
||||
|
||||
// A project root is only created on demand: an empty `.memory/` directory in
|
||||
// every checkout is worse than none at all.
|
||||
let project: MemoryRoot | undefined
|
||||
const canonical = ctx.location.project?.canonical
|
||||
if (canonical && canonical !== "/" && fs.existsSync(canonical)) {
|
||||
project = projectRoot(canonical, String(ctx.location.project.id))
|
||||
}
|
||||
|
||||
const roots = (): MemoryRoot[] => (project ? [global, project] : [global])
|
||||
|
||||
// Seeding the skeleton must never be able to break plugin load: an
|
||||
// unwritable directory should degrade to "no memories yet", not to a
|
||||
// failed plugin. Every read path already tolerates a missing root.
|
||||
try {
|
||||
ensureRoot(global, categories)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`memory: cannot create ${global.dir} (${(error as Error).message}). Memories are disabled until this is writable.`,
|
||||
)
|
||||
}
|
||||
|
||||
// -- in-memory view, refreshed when files change on disk ----------------
|
||||
let cache: { filesByRoot: Map<string, MemoryFile[]>; at: number } | undefined
|
||||
const filesByRoot = (): Map<string, MemoryFile[]> => {
|
||||
const now = Date.now()
|
||||
if (cache && now - cache.at < 2000) return cache.filesByRoot
|
||||
const map = new Map<string, MemoryFile[]>()
|
||||
for (const root of roots()) map.set(root.key, scanRoot(root))
|
||||
cache = { filesByRoot: map, at: now }
|
||||
return map
|
||||
}
|
||||
const allFiles = (): MemoryFile[] => roots().flatMap((r) => filesByRoot().get(r.key) ?? [])
|
||||
const states = (root: MemoryRoot) => readState(root)
|
||||
const invalidate = () => {
|
||||
cache = undefined
|
||||
}
|
||||
|
||||
// Recompile every derived index after a write.
|
||||
const recompile = () => {
|
||||
for (const root of roots()) {
|
||||
const files = scanRoot(root)
|
||||
compileCategoryIndexes(root, files)
|
||||
compileRoot(root, files)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// -- injection into the agent loop --------------------------------------
|
||||
// `context` covers the agent loop including tool continuations, and edits
|
||||
// only the outgoing request — the conversation transcript stays clean.
|
||||
await ctx.session.hook("context", (event) => {
|
||||
try {
|
||||
const query = latestUserText(event.messages)
|
||||
if (!query) return
|
||||
const text = buildInjection({
|
||||
roots: roots(),
|
||||
filesByRoot: filesByRoot(),
|
||||
statesByRoot: new Map(roots().map((r) => [r.key, states(r)])),
|
||||
query,
|
||||
options: { maxChars, maxEntries, halfLifeDays },
|
||||
})
|
||||
if (text) event.system.push({ type: "text", text })
|
||||
} catch (error) {
|
||||
console.error(`memory: injection failed: ${(error as Error).message}`)
|
||||
}
|
||||
})
|
||||
|
||||
// -- tools --------------------------------------------------------------
|
||||
await ctx.tool.transform((editor) => {
|
||||
editor.namespace({
|
||||
name: "memory",
|
||||
description:
|
||||
"Durable memory across sessions: recall what was learned before, record new facts, forget stale ones.",
|
||||
})
|
||||
|
||||
// memory_recall ------------------------------------------------------
|
||||
editor.add({
|
||||
name: "recall",
|
||||
description: [
|
||||
"Search memory for durable knowledge from earlier sessions: preferences, decisions, conventions, pitfalls.",
|
||||
"Start here when ROOT's outline mentions something relevant but you need the full detail,",
|
||||
"or when the user refers to something you do not remember deciding.",
|
||||
"Prefer this over re-deriving a convention from the repo.",
|
||||
].join(" "),
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "What you are trying to recall — a topic, a decision, a symptom.",
|
||||
},
|
||||
scope: {
|
||||
type: "string",
|
||||
enum: ["any", "project", "global"],
|
||||
description: "Restrict to one root. Default any (project wins on conflict).",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Max memories to return. Default 5.",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (input: { query: string; scope?: string; limit?: number }) => {
|
||||
const pool = pickRoots(roots(), input.scope)
|
||||
const scored = pool
|
||||
.flatMap((root) =>
|
||||
scoreFiles(filesByRoot().get(root.key) ?? [], input.query, states(root), { halfLifeDays }),
|
||||
)
|
||||
// Same rel path in two roots: the project one is the specific truth.
|
||||
.filter((entry, i, all) => {
|
||||
const same = all.filter((e) => e.file.rel === entry.file.rel)
|
||||
return same.length === 1 || same[0] === entry
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, input.limit ?? 5)
|
||||
|
||||
// Reinforcement: what gets used floats up next time.
|
||||
if (scored.length) {
|
||||
for (const root of pool) {
|
||||
const state = states(root)
|
||||
let dirty = false
|
||||
for (const entry of scored) {
|
||||
if (entry.file.root.key !== root.key) continue
|
||||
const current = state[entry.file.rel]
|
||||
state[entry.file.rel] = {
|
||||
hitCount: (current?.hitCount ?? 0) + 1,
|
||||
lastHitAt: Date.now(),
|
||||
}
|
||||
dirty = true
|
||||
}
|
||||
if (dirty) {
|
||||
try {
|
||||
writeState(root, state)
|
||||
} catch {
|
||||
/* stats are best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scored.length === 0) {
|
||||
return {
|
||||
content: `没有匹配 "${input.query}" 的记忆。\n\n当前记忆库:\n${outlineText(filesByRoot(), roots())}`,
|
||||
}
|
||||
}
|
||||
|
||||
const body = scored
|
||||
.map((entry) => {
|
||||
const tags = [
|
||||
`[${entry.file.root.label}/${entry.file.rel}]`,
|
||||
entry.file.kind ? `(${entry.file.kind})` : "",
|
||||
entry.reasons.length ? ` ${entry.reasons.join(", ")}` : "",
|
||||
].join("")
|
||||
return `${tags}\nscore ${entry.score.toFixed(2)}\n\n${entry.file.body}`
|
||||
})
|
||||
.join("\n\n---\n\n")
|
||||
return { content: body }
|
||||
},
|
||||
})
|
||||
|
||||
// memory_remember -------------------------------------------------------
|
||||
editor.add({
|
||||
name: "remember",
|
||||
description: [
|
||||
"Record a durable memory: a user preference, an architectural decision and its reason,",
|
||||
"a non-obvious gotcha, or a project convention.",
|
||||
"Do NOT record what the code already shows, current task state, branch names, or file paths.",
|
||||
"Writes automatically refresh the category index and ROOT's routing table.",
|
||||
].join(" "),
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
category: {
|
||||
type: "string",
|
||||
description: "Which kind of memory. Use rules/ only for things the user declared absolute.",
|
||||
enum: categories,
|
||||
},
|
||||
slug: {
|
||||
type: "string",
|
||||
description: "Stable kebab-case identifier. Reusing a slug updates that memory in place.",
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
|
||||
},
|
||||
title: { type: "string", description: "Short title." },
|
||||
summary: {
|
||||
type: "string",
|
||||
description: "ONE sentence under 100 chars. This is the only line shown in indexes — make it count.",
|
||||
},
|
||||
highlight: {
|
||||
type: "string",
|
||||
description: "The single most important detail, shown as the 重点 column.",
|
||||
},
|
||||
body: { type: "string", description: "The detail: context, the decision, the reason, the root cause." },
|
||||
scope: {
|
||||
type: "string",
|
||||
enum: ["project", "global"],
|
||||
description:
|
||||
"Which root. Default project. Use global for preferences and knowledge that holds across projects.",
|
||||
},
|
||||
loadWhen: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Scenarios or trigger words that should surface this memory. These build ROOT's routing table.",
|
||||
},
|
||||
pinned: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Pin the memory: never decays, never superseded automatically. Use for things you stated as absolute.",
|
||||
},
|
||||
supersedes: {
|
||||
type: "string",
|
||||
description: "Relative path of an older memory this replaces. The old one is kept for audit, not injected.",
|
||||
},
|
||||
},
|
||||
required: ["category", "slug", "title", "summary", "body"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (input: {
|
||||
category: string
|
||||
slug: string
|
||||
title: string
|
||||
summary: string
|
||||
highlight?: string
|
||||
body: string
|
||||
scope?: string
|
||||
loadWhen?: string[]
|
||||
pinned?: boolean
|
||||
supersedes?: string
|
||||
}) => {
|
||||
const categoryError = validateCategory(input.category)
|
||||
if (categoryError) return { content: `拒绝写入:${categoryError}` }
|
||||
const slugError = validateSlug(input.slug)
|
||||
if (slugError) return { content: `拒绝写入:${slugError}` }
|
||||
|
||||
const safeBody = redactSecrets(input.body)
|
||||
if (safeBody !== input.body) {
|
||||
return {
|
||||
content:
|
||||
"拒绝写入:正文里检测到疑似密钥(已定位)。记忆文件会进入 git,请改成描述性文字,例如“凭证放在环境变量 X”。",
|
||||
}
|
||||
}
|
||||
|
||||
const root =
|
||||
input.scope === "global" || !project ? global : project
|
||||
const created = fs.existsSync(path.join(root.dir, input.category, `${input.slug}.md`))
|
||||
|
||||
const file = writeMemory(root, {
|
||||
category: input.category,
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
highlight: input.highlight ?? "",
|
||||
body: safeBody,
|
||||
...(input.loadWhen?.length ? { loadWhen: input.loadWhen } : {}),
|
||||
// Pass `false` through explicitly so an explicit unpin works;
|
||||
// leaving it undefined lets writeMemory keep an existing pin.
|
||||
...(input.pinned === undefined ? {} : { pinned: input.pinned }),
|
||||
...(input.supersedes ? { supersedes: input.supersedes } : {}),
|
||||
})
|
||||
|
||||
if (input.supersedes) markSuperseded(root, input.supersedes, file.rel)
|
||||
|
||||
// Promote the whole root's routing table when a new file appears, so
|
||||
// the outline the model sees stays complete.
|
||||
recompile()
|
||||
|
||||
return {
|
||||
content: [
|
||||
`${created ? "已更新" : "已写入"}记忆:${file.rel}(根:${root.label})`,
|
||||
"",
|
||||
`概述:${file.summary}`,
|
||||
file.loadWhen.length ? `触发场景:${file.loadWhen.join("、")}` : "触发场景:未设置(不会被路由表自动命中,用 memory_recall 按关键词取)",
|
||||
"",
|
||||
"已同步更新:该目录 index.md" +
|
||||
(file.loadWhen.length ? " 与 ROOT.md 路由表" : "") +
|
||||
"。",
|
||||
].join("\n"),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// memory_forget ------------------------------------------------------
|
||||
editor.add({
|
||||
name: "forget",
|
||||
description: [
|
||||
"Remove or supersede a memory that turned out to be wrong.",
|
||||
"Prefer `supersedes` when a newer fact replaces an older one — the old entry stays for audit.",
|
||||
"Use this when the user corrects you or a memory is no longer true.",
|
||||
].join(" "),
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: 'Relative path of the memory, e.g. "pitfalls/sqlite-cjk.md".',
|
||||
},
|
||||
scope: {
|
||||
type: "string",
|
||||
enum: ["project", "global", "any"],
|
||||
description: "Which root to look in. Default any.",
|
||||
},
|
||||
reason: { type: "string", description: "Why it is being forgotten. Recorded for audit." },
|
||||
},
|
||||
required: ["path", "reason"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (input: { path: string; scope?: string; reason: string }) => {
|
||||
const target = input.path.replace(/^\/+/, "")
|
||||
if (!target.endsWith(".md") || target.includes("..")) {
|
||||
return { content: `拒绝:path 必须是根下的相对 .md 路径(收到 ${JSON.stringify(input.path)})` }
|
||||
}
|
||||
const pool = pickRoots(roots(), input.scope)
|
||||
for (const root of pool) {
|
||||
const abs = path.join(root.dir, target)
|
||||
if (!fs.existsSync(abs)) continue
|
||||
fs.rmSync(abs)
|
||||
recompile()
|
||||
return { content: `已删除 ${root.label}/${target}。\n理由:${input.reason}\n已同步重建 index.md 与 ROOT.md 路由表。` }
|
||||
}
|
||||
return { content: `未找到记忆 ${target}。\n现有记忆:\n${outlineText(filesByRoot(), roots())}` }
|
||||
},
|
||||
})
|
||||
|
||||
// memory_inspect ------------------------------------------------------
|
||||
editor.add({
|
||||
name: "inspect",
|
||||
description:
|
||||
"Inspect the memory library: every root, every memory with its summary and trigger, plus index consistency checks. Use when tuning memory or explaining what is remembered.",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
detail: {
|
||||
type: "boolean",
|
||||
description: "Include per-memory hit counts and scores. Default false.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (input: { detail?: boolean }) => {
|
||||
const lines: string[] = []
|
||||
for (const root of roots()) {
|
||||
const files = filesByRoot().get(root.key) ?? []
|
||||
const state = states(root)
|
||||
const goal = readGoal(root.dir)
|
||||
lines.push(`## 根:${root.label}(${root.scope})`)
|
||||
lines.push(`路径:${root.dir}${fs.existsSync(root.dir) ? "" : " ← 尚未创建"}`)
|
||||
if (goal) lines.push(`当前目标:${goal}`)
|
||||
if (files.length === 0) {
|
||||
lines.push("(空)")
|
||||
lines.push("")
|
||||
continue
|
||||
}
|
||||
lines.push("")
|
||||
for (const file of files) {
|
||||
const hits = state[file.rel]
|
||||
const superseded = file.fm.superseded_by ? ` [已被 ${String(file.fm.superseded_by)} 取代]` : ""
|
||||
lines.push(`- ${file.rel}${superseded}`)
|
||||
lines.push(` ${file.summary}`)
|
||||
if (file.loadWhen.length) lines.push(` 触发:${file.loadWhen.join("、")}`)
|
||||
if (file.pinned) lines.push(" 钉住(不衰减、不被取代)")
|
||||
if (input.detail) {
|
||||
lines.push(
|
||||
` 命中 ${hits?.hitCount ?? 0} 次 · 最后命中 ${
|
||||
hits?.lastHitAt ? new Date(hits.lastHitAt).toISOString().slice(0, 10) : "从未"
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
// Consistency: the compiled tables must match what is on disk, or the
|
||||
// outline the model trusts is a lie.
|
||||
const problems: string[] = []
|
||||
for (const root of roots()) {
|
||||
// A root nobody has written to yet is not a problem to report.
|
||||
if (!fs.existsSync(root.dir)) continue
|
||||
const files = scanRoot(root)
|
||||
const categories = [...new Set(files.map((f) => f.category))]
|
||||
for (const category of categories) {
|
||||
const indexPath = path.join(root.dir, category, "index.md")
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
problems.push(`${root.label}/${category}/index.md 缺失`)
|
||||
continue
|
||||
}
|
||||
const indexText = fs.readFileSync(indexPath, "utf8")
|
||||
for (const file of files.filter((f) => f.category === category)) {
|
||||
if (!indexText.includes(`${file.slug}.md`)) {
|
||||
problems.push(`${root.label}/${category}/index.md 缺少 ${file.slug}.md`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const rootPath = path.join(root.dir, "ROOT.md")
|
||||
if (!fs.existsSync(rootPath)) problems.push(`${root.label}/ROOT.md 缺失`)
|
||||
}
|
||||
lines.push(problems.length === 0 ? "索引一致性:正常" : `索引一致性:发现 ${problems.length} 个问题`)
|
||||
for (const problem of problems) lines.push(` - ${problem}`)
|
||||
|
||||
return { content: lines.join("\n") }
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// -- automatic extraction on idle --------------------------------------
|
||||
let extracting = false
|
||||
const controller = new AbortController()
|
||||
|
||||
if (extractOptions.enabled) {
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
||||
const record = event as unknown as { type?: string; data?: { sessionID?: string } }
|
||||
if (record?.type !== "session.idle") continue
|
||||
const sessionID = record.data?.sessionID
|
||||
if (!sessionID || extracting) continue
|
||||
|
||||
extracting = true
|
||||
try {
|
||||
await runExtraction(ctx, {
|
||||
sessionID,
|
||||
extractOptions,
|
||||
filesByRoot,
|
||||
project,
|
||||
global,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`memory: extraction failed: ${(error as Error).message}`)
|
||||
} finally {
|
||||
extracting = false
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Subscription teardown is normal on unload.
|
||||
if (!controller.signal.aborted) {
|
||||
console.error(`memory: event stream ended: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickRoots(roots: MemoryRoot[], scope: string | undefined): MemoryRoot[] {
|
||||
if (scope === "project") return roots.filter((r) => r.scope === "project")
|
||||
if (scope === "global") return roots.filter((r) => r.scope === "global")
|
||||
return roots
|
||||
}
|
||||
|
||||
function outlineText(filesByRoot: Map<string, MemoryFile[]>, roots: MemoryRoot[]): string {
|
||||
const lines: string[] = []
|
||||
for (const root of roots) {
|
||||
const files = filesByRoot.get(root.key) ?? []
|
||||
if (files.length === 0) continue
|
||||
lines.push(`${root.label}:`)
|
||||
for (const file of files) lines.push(` ${file.rel} — ${file.summary}`)
|
||||
}
|
||||
return lines.join("\n") || "(记忆库为空)"
|
||||
}
|
||||
|
||||
/** The newest user text in a request — the retrieval query. */
|
||||
function latestUserText(messages: readonly unknown[]): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const record = messages[i] as { role?: string; content?: unknown } | null
|
||||
if (!record || record.role !== "user") continue
|
||||
const text = extractUserText(record.content)
|
||||
if (text) return text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractUserText(content: unknown): string {
|
||||
if (typeof content === "string") return content.trim()
|
||||
if (!Array.isArray(content)) return ""
|
||||
const parts: string[] = []
|
||||
for (const part of content) {
|
||||
if (part && typeof part === "object") {
|
||||
const record = part as Record<string, unknown>
|
||||
if (record.type === "text" && typeof record.text === "string") parts.push(record.text)
|
||||
}
|
||||
}
|
||||
return parts.join(" ").trim()
|
||||
}
|
||||
|
||||
async function runExtraction(
|
||||
ctx: Context,
|
||||
input: {
|
||||
sessionID: string
|
||||
extractOptions: ExtractOptions
|
||||
filesByRoot: () => Map<string, MemoryFile[]>
|
||||
project: MemoryRoot | undefined
|
||||
global: MemoryRoot
|
||||
},
|
||||
): Promise<void> {
|
||||
const { sessionID, extractOptions } = input
|
||||
|
||||
const messages = (await ctx.session.context({ sessionID })) as readonly unknown[]
|
||||
const turns = messages.filter((m) => {
|
||||
const role = (m as { role?: string }).role
|
||||
return role === "user" || role === "assistant"
|
||||
})
|
||||
// Long enough to have learned something, short enough to stay cheap.
|
||||
if (turns.length < extractOptions.minTurns) return
|
||||
|
||||
const model = extractOptions.model ?? (await pickCheapModel(ctx))
|
||||
if (!model) return
|
||||
|
||||
const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages))
|
||||
if (transcript.length < 200) return
|
||||
|
||||
const existing = [...input.filesByRoot().values()].flat()
|
||||
const outcome = await extract({ ctx, model, transcript, existing })
|
||||
|
||||
if (outcome.candidates.length === 0) {
|
||||
for (const rejected of outcome.rejected) {
|
||||
console.log(
|
||||
`memory: skipped ${rejected.candidate.category}/${rejected.candidate.slug} — ${rejected.reason}`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const written: string[] = []
|
||||
for (const candidate of outcome.candidates) {
|
||||
// Content that mentions this project's own paths belongs to the project.
|
||||
const text = `${candidate.title}\n${candidate.summary}\n${candidate.body}`
|
||||
const projectSpecific =
|
||||
input.project !== undefined && (text.includes(ctx.location.project.directory) || text.includes(ctx.location.directory))
|
||||
const root = projectSpecific ? input.project! : input.global
|
||||
|
||||
const file = writeMemory(root, {
|
||||
category: candidate.category,
|
||||
slug: candidate.slug,
|
||||
title: candidate.title,
|
||||
summary: candidate.summary,
|
||||
highlight: candidate.highlight ?? "",
|
||||
body: redactSecrets(candidate.body),
|
||||
kind: candidate.kind ?? (candidate.proposeRule ? "rule-proposal" : "fact"),
|
||||
...(candidate.loadWhen?.length ? { loadWhen: candidate.loadWhen } : {}),
|
||||
})
|
||||
written.push(`${root.label}/${file.rel}`)
|
||||
}
|
||||
|
||||
if (written.length) {
|
||||
for (const root of [input.global, input.project].filter(Boolean) as MemoryRoot[]) {
|
||||
compileCategoryIndexes(root, scanRoot(root))
|
||||
compileRoot(root, scanRoot(root))
|
||||
}
|
||||
console.log(`memory: extracted ${written.length} memor${written.length === 1 ? "y" : "ies"} → ${written.join(", ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the cheapest available model for extraction. Falls back to the session
|
||||
* default when cost metadata is unavailable, and disables extraction entirely
|
||||
* when neither is available.
|
||||
*/
|
||||
async function pickCheapModel(ctx: Context): Promise<{ providerID: string; id: string } | undefined> {
|
||||
try {
|
||||
const models = await ctx.model.list()
|
||||
let best: { ref: { providerID: string; id: string }; cost: number } | undefined
|
||||
for (const model of models) {
|
||||
if (model.disabled) continue
|
||||
// A reasoning-only or tiny-context model cannot do the job reliably.
|
||||
if ((model.limit?.context ?? 0) > 0 && model.limit.context < 8_000) continue
|
||||
const output = model.cost?.[0]?.output ?? 0
|
||||
const input = model.cost?.[0]?.input ?? 0
|
||||
const cost = Number(output) + Number(input)
|
||||
if (!best || cost < best.cost) {
|
||||
best = { ref: { providerID: model.providerID, id: model.id }, cost }
|
||||
}
|
||||
}
|
||||
if (best) return best.ref
|
||||
const fallback = await ctx.model.default()
|
||||
if (fallback) return { providerID: fallback.providerID, id: fallback.modelID }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
Reference in New Issue
Block a user