opencode-diane 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/WIKI.md +1430 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +1632 -0
- package/dist/ingest/adaptive.d.ts +47 -0
- package/dist/ingest/adaptive.js +182 -0
- package/dist/ingest/code-health.d.ts +58 -0
- package/dist/ingest/code-health.js +202 -0
- package/dist/ingest/code-map.d.ts +71 -0
- package/dist/ingest/code-map.js +670 -0
- package/dist/ingest/cross-refs.d.ts +59 -0
- package/dist/ingest/cross-refs.js +1207 -0
- package/dist/ingest/docs.d.ts +49 -0
- package/dist/ingest/docs.js +325 -0
- package/dist/ingest/git.d.ts +77 -0
- package/dist/ingest/git.js +390 -0
- package/dist/ingest/live-session.d.ts +101 -0
- package/dist/ingest/live-session.js +173 -0
- package/dist/ingest/project-notes.d.ts +28 -0
- package/dist/ingest/project-notes.js +102 -0
- package/dist/ingest/project.d.ts +35 -0
- package/dist/ingest/project.js +430 -0
- package/dist/ingest/session-snapshot.d.ts +63 -0
- package/dist/ingest/session-snapshot.js +94 -0
- package/dist/ingest/sessions.d.ts +29 -0
- package/dist/ingest/sessions.js +164 -0
- package/dist/ingest/tables.d.ts +52 -0
- package/dist/ingest/tables.js +360 -0
- package/dist/mining/skill-miner.d.ts +53 -0
- package/dist/mining/skill-miner.js +234 -0
- package/dist/search/bm25.d.ts +81 -0
- package/dist/search/bm25.js +334 -0
- package/dist/search/e5-embedder.d.ts +30 -0
- package/dist/search/e5-embedder.js +91 -0
- package/dist/search/embed-pass.d.ts +26 -0
- package/dist/search/embed-pass.js +43 -0
- package/dist/search/embedder.d.ts +58 -0
- package/dist/search/embedder.js +85 -0
- package/dist/search/inverted-index.d.ts +51 -0
- package/dist/search/inverted-index.js +139 -0
- package/dist/search/ppr.d.ts +44 -0
- package/dist/search/ppr.js +118 -0
- package/dist/search/tokenize.d.ts +26 -0
- package/dist/search/tokenize.js +98 -0
- package/dist/store/eviction.d.ts +16 -0
- package/dist/store/eviction.js +37 -0
- package/dist/store/repository.d.ts +222 -0
- package/dist/store/repository.js +420 -0
- package/dist/store/sqlite-store.d.ts +89 -0
- package/dist/store/sqlite-store.js +252 -0
- package/dist/store/vector-store.d.ts +66 -0
- package/dist/store/vector-store.js +160 -0
- package/dist/types.d.ts +385 -0
- package/dist/types.js +9 -0
- package/dist/utils/file-log.d.ts +87 -0
- package/dist/utils/file-log.js +215 -0
- package/dist/utils/peer-detection.d.ts +45 -0
- package/dist/utils/peer-detection.js +90 -0
- package/dist/utils/shell.d.ts +43 -0
- package/dist/utils/shell.js +110 -0
- package/dist/utils/usage-skill.d.ts +42 -0
- package/dist/utils/usage-skill.js +129 -0
- package/dist/utils/xlsx.d.ts +36 -0
- package/dist/utils/xlsx.js +270 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-css.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-html.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-json.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +80 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-diane — OpenCode plugin entry point.
|
|
3
|
+
*
|
|
4
|
+
* A hierarchical, BM25-ranked memory store for any git repository,
|
|
5
|
+
* in any language. Pre-fills from git history (read as structure —
|
|
6
|
+
* diff shape, co-change, churn, recency, never the commit message
|
|
7
|
+
* as a signal) and from project files (recognised by name,
|
|
8
|
+
* summarised by format — JSON/TOML/YAML/etc., never by language
|
|
9
|
+
* semantics). Ingests past OpenCode sessions on demand; mines
|
|
10
|
+
* reusable SKILL.md files from recurring memory clusters.
|
|
11
|
+
* No LLM at the core, no convention assumptions; optional opt-in
|
|
12
|
+
* cross-lingual semantic search via a small multilingual e5 model.
|
|
13
|
+
*
|
|
14
|
+
* Tools exposed to the agent:
|
|
15
|
+
* memory_recall — hierarchical search over the store
|
|
16
|
+
* memory_remember — add an explicit note
|
|
17
|
+
* memory_snapshot — record this session's understanding for resume by a later session
|
|
18
|
+
* memory_outline — table of contents (counts per category)
|
|
19
|
+
* memory_status — store size, hit stats, plugin version
|
|
20
|
+
* memory_code_map — Aider-style tree-sitter signature map
|
|
21
|
+
* memory_skill — read one mined skill by name
|
|
22
|
+
* memory_ingest_sessions — pull facts from past OpenCode sessions
|
|
23
|
+
* memory_ingest_git — re-scan git history (pull/merge/rebase)
|
|
24
|
+
* memory_mine_skills — turn clusters into .opencode/skills/<x>/SKILL.md (background)
|
|
25
|
+
*/
|
|
26
|
+
import { tool } from "@opencode-ai/plugin";
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { MemoryRepository } from "./store/repository.js";
|
|
31
|
+
import { ingestGitHistory } from "./ingest/git.js";
|
|
32
|
+
import { ingestProjectFacts } from "./ingest/project.js";
|
|
33
|
+
import { ingestDocs } from "./ingest/docs.js";
|
|
34
|
+
import { ingestProjectNotes } from "./ingest/project-notes.js";
|
|
35
|
+
import { ingestTableHeaders } from "./ingest/tables.js";
|
|
36
|
+
import { ingestCrossRefs } from "./ingest/cross-refs.js";
|
|
37
|
+
import { ingestSessions } from "./ingest/sessions.js";
|
|
38
|
+
import { ingestCodeHealth } from "./ingest/code-health.js";
|
|
39
|
+
import { ingestCodeMap, ingestCodeMapForFile } from "./ingest/code-map.js";
|
|
40
|
+
import { writeSnapshot, latestSnapshot, snapshotSummary } from "./ingest/session-snapshot.js";
|
|
41
|
+
import { measureRepo, applyAdaptiveTuning } from "./ingest/adaptive.js";
|
|
42
|
+
import { createE5Embedder } from "./search/e5-embedder.js";
|
|
43
|
+
import { DEFAULT_EMBEDDING_MODEL } from "./search/embedder.js";
|
|
44
|
+
import { VectorStore } from "./store/vector-store.js";
|
|
45
|
+
import { embedMissingMemories } from "./search/embed-pass.js";
|
|
46
|
+
import { isGitRepo, currentHead, changedFilesInWorktree } from "./utils/shell.js";
|
|
47
|
+
import { createFileLogger, truncateForLog } from "./utils/file-log.js";
|
|
48
|
+
import { detectPeerPlugins } from "./utils/peer-detection.js";
|
|
49
|
+
import { installUsageSkill } from "./utils/usage-skill.js";
|
|
50
|
+
import { mineSkills, readMinedSkills } from "./mining/skill-miner.js";
|
|
51
|
+
import { LiveSessionRecorder } from "./ingest/live-session.js";
|
|
52
|
+
const SERVICE = "opencode-diane";
|
|
53
|
+
/**
|
|
54
|
+
* Plugin version, read at startup from this package's own package.json.
|
|
55
|
+
*
|
|
56
|
+
* `package.json#version` is the **single source of truth** for the
|
|
57
|
+
* release version — change it there and it propagates everywhere this
|
|
58
|
+
* constant is used (the `plugin.active` startup event, the
|
|
59
|
+
* `memory_status` tool's output, and any downstream tooling that
|
|
60
|
+
* reads the logs). There is no second place to update, by design.
|
|
61
|
+
*
|
|
62
|
+
* The read is relative to this module's location, which resolves to
|
|
63
|
+
* the package root in BOTH dev (`src/index.ts` → `src/../package.json`)
|
|
64
|
+
* and after install (`dist/index.js` → `dist/../package.json`), so it
|
|
65
|
+
* works identically in both. If the file is unreachable for any
|
|
66
|
+
* reason the constant degrades to `"unknown"` rather than crashing —
|
|
67
|
+
* a missing version label is a worse outcome than a missing plugin.
|
|
68
|
+
*/
|
|
69
|
+
const PLUGIN_VERSION = (() => {
|
|
70
|
+
try {
|
|
71
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
72
|
+
const text = readFileSync(join(here, "..", "package.json"), "utf-8");
|
|
73
|
+
return JSON.parse(text).version ?? "unknown";
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return "unknown";
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
export const OpencodeDiane = async (ctx, options) => {
|
|
80
|
+
const root = pickRoot(ctx.directory, ctx.worktree);
|
|
81
|
+
const client = ctx.client;
|
|
82
|
+
// OpenCode passes per-plugin options when the plugin is listed as a
|
|
83
|
+
// tuple in opencode.json: ["opencode-diane", { ...options }].
|
|
84
|
+
// Coerce defensively — it's untrusted JSON, junk keys are ignored.
|
|
85
|
+
const config = resolveConfig(coerceUserConfig(options));
|
|
86
|
+
// ── Peer-plugin compatibility (auto-detected, opt-out) ─────────────
|
|
87
|
+
// Read the user's opencode.json(s) and see which known coexisting
|
|
88
|
+
// plugins are listed alongside us. Two compatibility decisions get
|
|
89
|
+
// made here when peers are present AND the user didn't override:
|
|
90
|
+
//
|
|
91
|
+
// - oh-my-opencode also rewrites tool output; two plugins both
|
|
92
|
+
// mutating `output.output` interleave unpredictably. Disable
|
|
93
|
+
// our nudge hook in its presence.
|
|
94
|
+
// - caveman writes skills into the shared `.opencode/skills/`
|
|
95
|
+
// directory under fixed slugs (`caveman`, `caveman-commit`, …).
|
|
96
|
+
// Namespace our mined-skill subdirs with `diane-` so they don't
|
|
97
|
+
// collide, and so `memory_skill` surfaces only our skills, not
|
|
98
|
+
// the peer's.
|
|
99
|
+
//
|
|
100
|
+
// Standalone — no peer listed — `peers` is all-false and behaviour
|
|
101
|
+
// is byte-for-byte the documented default.
|
|
102
|
+
const peers = detectPeerPlugins(root);
|
|
103
|
+
if (peers.ohMyOpencode && !config.explicitKeys.has("enableNudgeHook")) {
|
|
104
|
+
config.enableNudgeHook = false;
|
|
105
|
+
}
|
|
106
|
+
if ((peers.ohMyOpencode || peers.caveman) && !config.explicitKeys.has("skillsOutputDir")) {
|
|
107
|
+
config.minedSkillPrefix = "diane-";
|
|
108
|
+
}
|
|
109
|
+
// Rich on-disk log. Two sinks: OpenCode's session log channel (the
|
|
110
|
+
// existing `client.app.log` call below — for the user/agent in the
|
|
111
|
+
// UI) and a per-session JSONL file under `os.tmpdir()/diane/`
|
|
112
|
+
// (for debugging the plugin itself across runs). The file logger is
|
|
113
|
+
// failure-tolerant: a write error drops it silently — never the host.
|
|
114
|
+
const fileLog = createFileLogger({ service: SERVICE, base: { root } });
|
|
115
|
+
const log = (level, msg) => {
|
|
116
|
+
void client.app
|
|
117
|
+
.log({ body: { service: SERVICE, level, message: msg } })
|
|
118
|
+
.catch(() => { });
|
|
119
|
+
fileLog.log(level, msg);
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Structured-event sink. Use when the *shape* of the data matters
|
|
123
|
+
* more than the prose — counts from an ingester, ms from a flush, an
|
|
124
|
+
* eviction's removed/freed totals. Goes to the JSONL file only; the
|
|
125
|
+
* OpenCode log channel keeps its existing human-readable lines.
|
|
126
|
+
*/
|
|
127
|
+
const event = (name, data) => {
|
|
128
|
+
fileLog.event(name, data);
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Per-tool-call structured-event helper. Every tool's `execute`
|
|
132
|
+
* body wraps in a try/catch/finally and calls this from `finally`,
|
|
133
|
+
* passing the args, a start timestamp, and either a result summary
|
|
134
|
+
* (success) or an error message. One `tool.call` event lands per
|
|
135
|
+
* invocation, success or failure — the file becomes a complete
|
|
136
|
+
* audit trail of what the agent did and how long it took.
|
|
137
|
+
*
|
|
138
|
+
* Args are run through `truncateForLog` so a 10 KB `content` arg on
|
|
139
|
+
* `memory_remember` (or a long recall query) doesn't bloat the line.
|
|
140
|
+
* The per-tool `summary` is the *meaningful* return shape (counts,
|
|
141
|
+
* ids), not the prose return string — those are very different
|
|
142
|
+
* audiences: the agent sees prose, the analyst sees structure.
|
|
143
|
+
*/
|
|
144
|
+
const recordToolCall = (toolName, args, t0, summary, error) => {
|
|
145
|
+
event("tool.call", {
|
|
146
|
+
tool: toolName,
|
|
147
|
+
ms: Math.round(performance.now() - t0),
|
|
148
|
+
args: truncateForLog(args),
|
|
149
|
+
...(error === undefined ? { ok: true, result: summary } : { ok: false, error }),
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Inject a note into the live session as a `noReply: true` message.
|
|
154
|
+
* This is OpenCode's documented message-insertion pattern — the same
|
|
155
|
+
* mechanism the native skills system uses to deliver skill content
|
|
156
|
+
* so it persists in context even when tool outputs are later purged.
|
|
157
|
+
* It's how a skill mined *mid-session* becomes usable now instead of
|
|
158
|
+
* on the next restart.
|
|
159
|
+
*
|
|
160
|
+
* Best-effort: returns false (never throws) if there's no live
|
|
161
|
+
* session client — e.g. an older OpenCode, or a unit-test harness
|
|
162
|
+
* with no server. Callers fall back to returning the content as the
|
|
163
|
+
* tool result, which still puts it in front of the agent.
|
|
164
|
+
*/
|
|
165
|
+
const injectSessionNote = async (sessionId, text) => {
|
|
166
|
+
const c = client;
|
|
167
|
+
if (!sessionId || typeof c.session?.prompt !== "function")
|
|
168
|
+
return false;
|
|
169
|
+
try {
|
|
170
|
+
await c.session.prompt({
|
|
171
|
+
path: { id: sessionId },
|
|
172
|
+
body: { noReply: true, parts: [{ type: "text", text }] },
|
|
173
|
+
});
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
// Announce the log file in the OpenCode channel so a user looking
|
|
181
|
+
// for it doesn't have to guess. Debug level — informational, not
|
|
182
|
+
// worth the user's foreground attention.
|
|
183
|
+
log("debug", `rich logs at ${fileLog.path()}`);
|
|
184
|
+
// ── 1. Load the store ──────────────────────────────────────────────
|
|
185
|
+
// If the legacy JSON-to-SQLite migration fails (rare — typically
|
|
186
|
+
// when another plugin is touching the DB during startup, or a Bun
|
|
187
|
+
// build mismatch interferes with `bun:sqlite`), we DO NOT crash the
|
|
188
|
+
// plugin: a "db migration" exception during startup was a real
|
|
189
|
+
// failure mode observed in the field when running alongside other
|
|
190
|
+
// heavyweight plugins. Instead we emit a structured event so the
|
|
191
|
+
// cause is in the JSONL log, mirror a clear human-readable line to
|
|
192
|
+
// OpenCode, and continue with an empty fresh database. The next
|
|
193
|
+
// startup will retry the migration — losing memories is recoverable;
|
|
194
|
+
// failing to start is not.
|
|
195
|
+
const repo = await MemoryRepository.load(root, (e) => {
|
|
196
|
+
const reason = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
197
|
+
fileLog.event("store.migration.failed", { reason });
|
|
198
|
+
log("warn", `legacy diane.json migration failed (${reason}); ` +
|
|
199
|
+
`starting with an empty database — your .opencode/diane.json is ` +
|
|
200
|
+
`untouched and the next startup will retry the migration.`);
|
|
201
|
+
});
|
|
202
|
+
// ── Nudge state (session-scoped via this closure) ─────────────────
|
|
203
|
+
// "Kinda enforce" the recall-first workflow: if the agent does
|
|
204
|
+
// several raw discovery calls without ever touching a memory tool,
|
|
205
|
+
// append ONE gentle reminder to a discovery tool's output. It never
|
|
206
|
+
// blocks, never mutates file-read output, fires at most once, and
|
|
207
|
+
// goes quiet the moment any memory tool is used.
|
|
208
|
+
const MEMORY_TOOLS = new Set([
|
|
209
|
+
"memory_recall",
|
|
210
|
+
"memory_code_map",
|
|
211
|
+
"memory_outline",
|
|
212
|
+
"memory_ingest_sessions",
|
|
213
|
+
"memory_ingest_git",
|
|
214
|
+
"memory_remember",
|
|
215
|
+
"memory_mine_skills",
|
|
216
|
+
"memory_skill",
|
|
217
|
+
]);
|
|
218
|
+
// Tools whose output is free text and safe to append a line to.
|
|
219
|
+
const NUDGEABLE_DISCOVERY = new Set(["grep", "glob", "bash", "list"]);
|
|
220
|
+
// Tools that count toward "doing discovery" (includes read, but we
|
|
221
|
+
// never mutate read output — file contents must stay pristine).
|
|
222
|
+
const DISCOVERY_TOOLS = new Set(["grep", "glob", "bash", "list", "read"]);
|
|
223
|
+
let memoryToolUsed = false;
|
|
224
|
+
let discoveryCallCount = 0;
|
|
225
|
+
let nudgeShown = false;
|
|
226
|
+
// ── Code-map freshness (independent of the nudge) ─────────────────
|
|
227
|
+
// When the agent edits code, the edited file's code-map memory must
|
|
228
|
+
// be re-indexed or recall/memory_code_map would serve stale
|
|
229
|
+
// signatures for the rest of the session. These are the structured
|
|
230
|
+
// file-writing tools whose args carry a `filePath`; `bash` is
|
|
231
|
+
// deliberately excluded — an arbitrary shell command's file effects
|
|
232
|
+
// can't be known, so bash-driven changes (and deletions) are a known
|
|
233
|
+
// gap, documented as such.
|
|
234
|
+
const FILE_WRITE_TOOLS = new Set(["write", "edit", "patch"]);
|
|
235
|
+
// callID → the path a pending write/edit is about to change, recorded
|
|
236
|
+
// in the before-hook and consumed in the after-hook (once the file is
|
|
237
|
+
// actually on disk in its new form). Bounded to PENDING_MAP_CAP — the
|
|
238
|
+
// matching after-hook normally fires for every before, but a rare
|
|
239
|
+
// tool-execution abort or a plugin reload mid-tool can leave orphan
|
|
240
|
+
// entries; FIFO eviction at the cap keeps the map size finite over
|
|
241
|
+
// long-running sessions.
|
|
242
|
+
const PENDING_MAP_CAP = 256;
|
|
243
|
+
const pendingEditPaths = new Map();
|
|
244
|
+
/**
|
|
245
|
+
* Insert into a bounded Map keyed by callID, evicting the oldest
|
|
246
|
+
* entry when the cap is exceeded. JavaScript's `Map` preserves
|
|
247
|
+
* insertion order, so iterating keys() yields oldest-first.
|
|
248
|
+
*/
|
|
249
|
+
function setBoundedPending(m, k, v) {
|
|
250
|
+
m.set(k, v);
|
|
251
|
+
while (m.size > PENDING_MAP_CAP) {
|
|
252
|
+
const oldest = m.keys().next().value;
|
|
253
|
+
if (oldest === undefined)
|
|
254
|
+
break;
|
|
255
|
+
m.delete(oldest);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Re-index one file's code-map after the agent edits it. Defensive:
|
|
259
|
+
// a freshness refresh must never throw into a tool call.
|
|
260
|
+
async function refreshCodeMapAfterEdit(filePath) {
|
|
261
|
+
try {
|
|
262
|
+
if (!config.enableCodeMap)
|
|
263
|
+
return; // nothing to keep fresh
|
|
264
|
+
const abs = isAbsolute(filePath) ? filePath : join(root, filePath);
|
|
265
|
+
if (!abs.startsWith(root))
|
|
266
|
+
return; // only files inside the repo
|
|
267
|
+
const outcome = await ingestCodeMapForFile(repo, root, abs);
|
|
268
|
+
if (outcome === "updated") {
|
|
269
|
+
repo.applyEviction(config);
|
|
270
|
+
log("debug", `code-map: re-indexed ${abs.slice(root.length).replace(/^\/+/, "")} after edit`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
275
|
+
log("warn", `code-map refresh after edit failed: ${m}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// ── 2. Idle only on directories that aren't a workable repo ───────
|
|
279
|
+
// "Workable" = a git repo, or a directory with at least one
|
|
280
|
+
// recognised project/build file. This is language-neutral.
|
|
281
|
+
const workable = await detectWorkableRepo(root);
|
|
282
|
+
if (!workable && !config.forceActive) {
|
|
283
|
+
log("info", `no git history and no recognised project files — memory plugin idle. ` +
|
|
284
|
+
`Set forceActive: true to override.`);
|
|
285
|
+
event("plugin.idle", { reason: "no-workable-repo" });
|
|
286
|
+
fileLog.close();
|
|
287
|
+
return {};
|
|
288
|
+
}
|
|
289
|
+
// ── 3. Background pre-fill on first run / new commits ─────────────
|
|
290
|
+
const prefillDone = config.autoIngestOnStartup
|
|
291
|
+
? prefillInBackground(repo, root, client, config, log, event, ctx.sessionID)
|
|
292
|
+
: Promise.resolve();
|
|
293
|
+
// Fire-and-forget for the main path; prefill handles its own errors.
|
|
294
|
+
void prefillDone;
|
|
295
|
+
// ── 3b. Optional cross-lingual semantic search (opt-in) ───────────
|
|
296
|
+
// Disabled by default — when off, nothing below runs, no model is
|
|
297
|
+
// downloaded, `@huggingface/transformers` is never imported, and
|
|
298
|
+
// recall is the unchanged pure-lexical path. When on, the model
|
|
299
|
+
// loads in the background; recalls before it is ready simply use
|
|
300
|
+
// lexical search. A failure here (missing optional dependency,
|
|
301
|
+
// blocked download) degrades to lexical search — it never breaks
|
|
302
|
+
// the plugin.
|
|
303
|
+
let embedder;
|
|
304
|
+
if (config.enableSemanticSearch) {
|
|
305
|
+
void initSemanticSearch(prefillDone);
|
|
306
|
+
}
|
|
307
|
+
async function initSemanticSearch(prefill) {
|
|
308
|
+
try {
|
|
309
|
+
const e = await createE5Embedder(config.embeddingModel);
|
|
310
|
+
const vs = VectorStore.open(root, e.id);
|
|
311
|
+
repo.attachVectorStore(vs);
|
|
312
|
+
embedder = e; // from now on recalls fuse vector similarity with BM25
|
|
313
|
+
log("info", `semantic: model ${e.id} ready (${vs.size()} cached vectors)`);
|
|
314
|
+
event("semantic.ready", { model: e.id, cachedVectors: vs.size() });
|
|
315
|
+
await prefill; // every memory must exist before the embedding pass
|
|
316
|
+
const { embedded, pruned } = await embedMissingMemories(repo, vs, e, (m) => log("info", m));
|
|
317
|
+
log("info", `semantic: embedding pass done — ${embedded} embedded, ${pruned} pruned, ` +
|
|
318
|
+
`${vs.size()} vectors total`);
|
|
319
|
+
event("semantic.embedded", { embedded, pruned, total: vs.size() });
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
323
|
+
log("warn", `semantic search unavailable — ${m}; using lexical search only`);
|
|
324
|
+
event("semantic.unavailable", { reason: m });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
log("info", `active: store has ${repo.size()} memories, ${humanBytes(repo.totalBytes())} on disk, budget ${humanBytes(config.maxMemoryBytes)}.`);
|
|
328
|
+
// If we adapted to a coexisting plugin, say so once and name what
|
|
329
|
+
// we changed. Silent in the standalone case (peers.found is empty).
|
|
330
|
+
if (peers.found.length > 0) {
|
|
331
|
+
const adjusted = [];
|
|
332
|
+
if (peers.ohMyOpencode && !config.explicitKeys.has("enableNudgeHook")) {
|
|
333
|
+
adjusted.push("nudge hook disabled (oh-my-opencode also rewrites tool output)");
|
|
334
|
+
}
|
|
335
|
+
if ((peers.ohMyOpencode || peers.caveman) && !config.explicitKeys.has("skillsOutputDir")) {
|
|
336
|
+
adjusted.push("mined skills prefixed with 'diane-' to namespace them under .opencode/skills/");
|
|
337
|
+
}
|
|
338
|
+
log("info", `coexisting plugin(s) detected (${peers.found.join(", ")})` +
|
|
339
|
+
(adjusted.length > 0 ? ` — ${adjusted.join("; ")}` : " — no compatibility adjustments needed"));
|
|
340
|
+
}
|
|
341
|
+
event("plugin.active", {
|
|
342
|
+
version: PLUGIN_VERSION,
|
|
343
|
+
storeSize: repo.size(),
|
|
344
|
+
bytesTotal: repo.totalBytes(),
|
|
345
|
+
budgetBytes: config.maxMemoryBytes,
|
|
346
|
+
autoIngestOnStartup: config.autoIngestOnStartup,
|
|
347
|
+
enableCodeMap: config.enableCodeMap,
|
|
348
|
+
ingestSessions: config.ingestSessions,
|
|
349
|
+
enableSemanticSearch: config.enableSemanticSearch,
|
|
350
|
+
peers: {
|
|
351
|
+
ohMyOpencode: peers.ohMyOpencode,
|
|
352
|
+
caveman: peers.caveman,
|
|
353
|
+
found: peers.found,
|
|
354
|
+
},
|
|
355
|
+
enableNudgeHook: config.enableNudgeHook,
|
|
356
|
+
minedSkillPrefix: config.minedSkillPrefix,
|
|
357
|
+
});
|
|
358
|
+
// ── Install the agent-facing usage skill ──────────────────────────
|
|
359
|
+
// OpenCode discovers `.opencode/skills/<name>/SKILL.md` files at
|
|
360
|
+
// session start and surfaces their contents to the agent. We write
|
|
361
|
+
// ours on first startup so the agent learns to call memory_recall
|
|
362
|
+
// before raw grep/glob/read — the soft-force adoption mechanism
|
|
363
|
+
// promised in opencode.json: `installUsageSkill: true` (default).
|
|
364
|
+
//
|
|
365
|
+
// Failure here is a quality-of-life regression, never fatal: a
|
|
366
|
+
// read-only project root, a missing parent directory, anything —
|
|
367
|
+
// we log and continue. Plugin startup must never depend on this.
|
|
368
|
+
if (config.installUsageSkill) {
|
|
369
|
+
const res = installUsageSkill(root, config.skillsOutputDir, config.minedSkillPrefix);
|
|
370
|
+
fileLog.event("usage-skill.write", {
|
|
371
|
+
outcome: res.outcome,
|
|
372
|
+
path: res.path,
|
|
373
|
+
error: res.error ? String(res.error) : undefined,
|
|
374
|
+
});
|
|
375
|
+
if (res.outcome === "installed") {
|
|
376
|
+
log("info", `installed usage skill at ${res.path} — the agent will see it at session start`);
|
|
377
|
+
}
|
|
378
|
+
else if (res.outcome === "failed") {
|
|
379
|
+
log("warn", `could not write the usage skill (${res.path}): ${res.error}`);
|
|
380
|
+
}
|
|
381
|
+
// "preserved" is silent — that's the steady state once installed.
|
|
382
|
+
}
|
|
383
|
+
// ── 4. Live session reflection + git change detection ─────────────
|
|
384
|
+
// Three pieces of state added in this version, all defensive,
|
|
385
|
+
// none capable of breaking a tool call:
|
|
386
|
+
//
|
|
387
|
+
// (a) LiveSessionRecorder rolls up this session's file edits and
|
|
388
|
+
// bash commands into ONE memory under `live:${sessionId}`,
|
|
389
|
+
// updated after each event. Lets the current session recall
|
|
390
|
+
// what it has already touched, and pre-seeds the trace for
|
|
391
|
+
// successor sessions.
|
|
392
|
+
//
|
|
393
|
+
// (b) lastKnownHead is the git HEAD commit SHA observed at the
|
|
394
|
+
// last check. After each `bash` call we poll it again; a
|
|
395
|
+
// mismatch means a pull / merge / rebase / checkout happened
|
|
396
|
+
// in the working tree and the git ingester needs to run again.
|
|
397
|
+
//
|
|
398
|
+
// (c) gitReingestInFlight coalesces concurrent triggers. If three
|
|
399
|
+
// bash commands all move HEAD before the first re-ingest
|
|
400
|
+
// finishes, only one re-ingest pass actually runs; subsequent
|
|
401
|
+
// detections see the flag and exit. The next post-bash poll
|
|
402
|
+
// picks up where the previous pass left off.
|
|
403
|
+
//
|
|
404
|
+
// `pendingBashCommands` mirrors the existing `pendingEditPaths` —
|
|
405
|
+
// the before-hook stashes the bash command string keyed by callID
|
|
406
|
+
// and the after-hook consumes it to feed the recorder. Missing
|
|
407
|
+
// entries are silently ignored.
|
|
408
|
+
const sessionIdForRecorder = ctx.sessionID ??
|
|
409
|
+
`unknown-${Date.now()}`;
|
|
410
|
+
const liveRecorder = config.recordSessionActivity
|
|
411
|
+
? new LiveSessionRecorder(repo, sessionIdForRecorder)
|
|
412
|
+
: undefined;
|
|
413
|
+
const pendingBashCommands = new Map();
|
|
414
|
+
let lastKnownHead = null;
|
|
415
|
+
let gitReingestInFlight = false;
|
|
416
|
+
// Seed the HEAD baseline asynchronously — no need to block startup,
|
|
417
|
+
// the value only matters once a bash command has executed.
|
|
418
|
+
if (config.autoReingestGitOnHeadChange) {
|
|
419
|
+
void currentHead(root).then((h) => { lastKnownHead = h; });
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Check whether HEAD has moved since the last poll; if so, queue a
|
|
423
|
+
* background git re-ingest (idempotent — already-known commits are
|
|
424
|
+
* skipped). Coalesces concurrent triggers via `gitReingestInFlight`.
|
|
425
|
+
* Best-effort: any error is logged and swallowed.
|
|
426
|
+
*/
|
|
427
|
+
async function reingestGitIfHeadMoved() {
|
|
428
|
+
if (!config.autoReingestGitOnHeadChange)
|
|
429
|
+
return;
|
|
430
|
+
if (gitReingestInFlight)
|
|
431
|
+
return;
|
|
432
|
+
try {
|
|
433
|
+
const head = await currentHead(root);
|
|
434
|
+
if (head === null || head === lastKnownHead)
|
|
435
|
+
return;
|
|
436
|
+
const previous = lastKnownHead;
|
|
437
|
+
lastKnownHead = head;
|
|
438
|
+
gitReingestInFlight = true;
|
|
439
|
+
log("info", `git: HEAD moved ${(previous ?? "?").slice(0, 7)} → ${head.slice(0, 7)} — re-ingesting`);
|
|
440
|
+
event("git.head.changed", { from: previous, to: head });
|
|
441
|
+
// Fire-and-forget — we don't want to block the next tool call on
|
|
442
|
+
// a potentially-multi-second git history scan.
|
|
443
|
+
void (async () => {
|
|
444
|
+
try {
|
|
445
|
+
const git = await ingestGitHistory(repo, root, config.gitHistoryDepth, config.coChangeMaxCommits, config.coChangeMinOccurrences);
|
|
446
|
+
repo.applyEviction(config);
|
|
447
|
+
await repo.forceFlush();
|
|
448
|
+
log("info", `git: re-ingest after HEAD move — scanned ${git.scanned} commits, ` +
|
|
449
|
+
`${git.commitMemories} commit memories, ${git.coChangeMemories} co-change`);
|
|
450
|
+
event("ingest.git.reingested", {
|
|
451
|
+
trigger: "head-change",
|
|
452
|
+
scanned: git.scanned,
|
|
453
|
+
commitMemories: git.commitMemories,
|
|
454
|
+
coChangeMemories: git.coChangeMemories,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
log("warn", `git re-ingest after HEAD move failed: ${err}`);
|
|
459
|
+
event("ingest.git.reingested.failed", { error: String(err) });
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
gitReingestInFlight = false;
|
|
463
|
+
}
|
|
464
|
+
})();
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
log("warn", `git HEAD check failed: ${err}`);
|
|
468
|
+
gitReingestInFlight = false;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// ── 5. Tools ───────────────────────────────────────────────────────
|
|
472
|
+
return {
|
|
473
|
+
tool: {
|
|
474
|
+
memory_recall: tool({
|
|
475
|
+
description: "ALWAYS call this FIRST when a task touches existing code — before any " +
|
|
476
|
+
"read/grep/glob/bash discovery. A single recall typically replaces 3-8 " +
|
|
477
|
+
"discovery calls and costs ~10x fewer tokens: on a real repo, a recall pair " +
|
|
478
|
+
"answered a 'work on feature X' task in ~700 tokens versus ~5,400 tokens of raw " +
|
|
479
|
+
"git-log/cat/grep. Skipping it and going straight to read/grep is the slow, " +
|
|
480
|
+
"expensive path. The store holds compact facts mined from git history (commit " +
|
|
481
|
+
"diff-shapes, file co-change, churn, recency), project files (manifests/build/CI " +
|
|
482
|
+
"summarised by format), live code-health diagnostics, the code map (file " +
|
|
483
|
+
"signatures), past OpenCode sessions, and notes saved earlier. Results are " +
|
|
484
|
+
"co-change-boosted — a hit about file X also surfaces files X is historically " +
|
|
485
|
+
"modified with, so you find related context a grep would miss. Output is capped " +
|
|
486
|
+
"at `tokenBudget` (default 1200) so the cost is predictable. Workflow: recall " +
|
|
487
|
+
"first, act on what comes back, and only fall to raw discovery for the specific " +
|
|
488
|
+
"gaps recall didn't cover. Pass `category` to narrow to git-history, " +
|
|
489
|
+
"project-facts, code-health, code-map, session-trace, session-snapshot, " +
|
|
490
|
+
"agent-note, or " +
|
|
491
|
+
"skill-mined; pass `subject` to narrow to a file path or task name. " +
|
|
492
|
+
"Pass `prefer` to make ranking match intent: 'tests' when the user asks " +
|
|
493
|
+
"about tests/test coverage, 'code' when they want the implementation " +
|
|
494
|
+
"(gently down-ranks test files), 'history' for change history. Omit it for " +
|
|
495
|
+
"general queries — it is a mild lean, never a filter.",
|
|
496
|
+
args: {
|
|
497
|
+
query: tool.schema.string().describe("Free-form search query — words, file paths, identifiers."),
|
|
498
|
+
category: tool.schema.string().optional().describe("Optional category filter."),
|
|
499
|
+
subject: tool.schema.string().optional().describe("Optional subject filter (file path, task slug, etc.)."),
|
|
500
|
+
limit: tool.schema.number().optional().describe("Hard cap on hit count considered. Default 25."),
|
|
501
|
+
prefer: tool.schema
|
|
502
|
+
.string()
|
|
503
|
+
.optional()
|
|
504
|
+
.describe("Optional intent lean: 'code' (implementation), 'tests' (test files), " +
|
|
505
|
+
"'history' (change history), or 'any'. Set from what the user is asking " +
|
|
506
|
+
"for; omit for general queries."),
|
|
507
|
+
tokenBudget: tool.schema
|
|
508
|
+
.number()
|
|
509
|
+
.optional()
|
|
510
|
+
.describe("Ceiling on the formatted result size, in estimated tokens. Default 1200. " +
|
|
511
|
+
"Ranked hits are packed until the next would overflow."),
|
|
512
|
+
},
|
|
513
|
+
async execute(args) {
|
|
514
|
+
const t0 = performance.now();
|
|
515
|
+
const summary = {};
|
|
516
|
+
let error;
|
|
517
|
+
try {
|
|
518
|
+
const cat = isCategory(args.category) ? args.category : undefined;
|
|
519
|
+
const prefer = args.prefer === "code" ||
|
|
520
|
+
args.prefer === "tests" ||
|
|
521
|
+
args.prefer === "history" ||
|
|
522
|
+
args.prefer === "any"
|
|
523
|
+
? args.prefer
|
|
524
|
+
: undefined;
|
|
525
|
+
const formatHit = (h) => `[${h.memory.category} | ${h.memory.subject} | score ${h.score.toFixed(2)}] ${h.memory.content}`;
|
|
526
|
+
// When semantic search is on and the model is ready, embed
|
|
527
|
+
// the query so recall can fuse vector similarity with BM25.
|
|
528
|
+
// The embedding is done here, in the async tool handler, so
|
|
529
|
+
// the recall path itself stays synchronous. A failure falls
|
|
530
|
+
// back to lexical-only — it never fails the recall.
|
|
531
|
+
let queryVector;
|
|
532
|
+
if (embedder) {
|
|
533
|
+
try {
|
|
534
|
+
queryVector = await embedder.embedQuery(args.query);
|
|
535
|
+
}
|
|
536
|
+
catch (e) {
|
|
537
|
+
log("debug", `semantic: query embed failed, using lexical only — ` +
|
|
538
|
+
`${e instanceof Error ? e.message : String(e)}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const { hits, omitted } = repo.recallDetailed({
|
|
542
|
+
query: args.query,
|
|
543
|
+
category: cat,
|
|
544
|
+
subject: args.subject,
|
|
545
|
+
prefer,
|
|
546
|
+
limit: args.limit ?? 25,
|
|
547
|
+
tokenBudget: args.tokenBudget ?? 1200,
|
|
548
|
+
queryVector,
|
|
549
|
+
personalizedPageRank: config.personalizedPageRank,
|
|
550
|
+
}, formatHit);
|
|
551
|
+
summary.hits = hits.length;
|
|
552
|
+
summary.omitted = omitted;
|
|
553
|
+
summary.category = cat ?? null;
|
|
554
|
+
summary.prefer = prefer ?? null;
|
|
555
|
+
summary.semantic = queryVector !== undefined;
|
|
556
|
+
summary.hadSubject = args.subject !== undefined;
|
|
557
|
+
summary.tokenBudget = args.tokenBudget ?? 1200;
|
|
558
|
+
if (hits.length === 0)
|
|
559
|
+
return "(no memories matched)";
|
|
560
|
+
const lines = hits.map(formatHit);
|
|
561
|
+
if (omitted > 0) {
|
|
562
|
+
lines.push(`… (+${omitted} more hit${omitted === 1 ? "" : "s"} omitted to fit the ` +
|
|
563
|
+
`~${args.tokenBudget ?? 1200}-token budget — raise tokenBudget or narrow the query)`);
|
|
564
|
+
}
|
|
565
|
+
return lines.join("\n");
|
|
566
|
+
}
|
|
567
|
+
catch (e) {
|
|
568
|
+
error = e instanceof Error ? e.message : String(e);
|
|
569
|
+
throw e;
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
recordToolCall("memory_recall", args, t0, summary, error);
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
}),
|
|
576
|
+
memory_remember: tool({
|
|
577
|
+
description: "Save a note into project memory so a FUTURE turn can recall it instead of " +
|
|
578
|
+
"rediscovering it. Use this whenever you learn something non-obvious that cost " +
|
|
579
|
+
"you tool calls to find — a tricky file path, a non-obvious coupling, a decision " +
|
|
580
|
+
"the user just made, a regression to watch for. Treat it as the write-side of " +
|
|
581
|
+
"the recall-first workflow: what you remember now is what you (or the next " +
|
|
582
|
+
"session) won't have to grep for later. Use sparingly — only durable facts, " +
|
|
583
|
+
"never transient state. The note becomes searchable via memory_recall.",
|
|
584
|
+
args: {
|
|
585
|
+
subject: tool.schema.string().describe("Short slug (e.g. 'auth/login.py', 'task:add-pagination')."),
|
|
586
|
+
content: tool.schema.string().describe("The note itself — one short paragraph."),
|
|
587
|
+
tags: tool.schema.array(tool.schema.string()).optional().describe("Optional tags for filtering."),
|
|
588
|
+
},
|
|
589
|
+
async execute(args) {
|
|
590
|
+
const t0 = performance.now();
|
|
591
|
+
const summary = {};
|
|
592
|
+
let error;
|
|
593
|
+
try {
|
|
594
|
+
const m = repo.insertIfMissing({
|
|
595
|
+
category: "agent-note",
|
|
596
|
+
subject: args.subject,
|
|
597
|
+
content: args.content,
|
|
598
|
+
tags: args.tags ?? [],
|
|
599
|
+
source: "agent",
|
|
600
|
+
});
|
|
601
|
+
repo.applyEviction(config);
|
|
602
|
+
summary.id = m.id;
|
|
603
|
+
summary.sizeBytes = m.sizeBytes;
|
|
604
|
+
summary.bytesTotal = repo.totalBytes();
|
|
605
|
+
return `stored: ${m.id} (${humanBytes(repo.totalBytes())} of ${humanBytes(config.maxMemoryBytes)})`;
|
|
606
|
+
}
|
|
607
|
+
catch (e) {
|
|
608
|
+
error = e instanceof Error ? e.message : String(e);
|
|
609
|
+
throw e;
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
recordToolCall("memory_remember", args, t0, summary, error);
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
}),
|
|
616
|
+
memory_snapshot: tool({
|
|
617
|
+
description: "Record this session's hard-won UNDERSTANDING so a later — or parallel — " +
|
|
618
|
+
"session can resume from it instead of rebuilding it. Unlike memory_remember " +
|
|
619
|
+
"(individual facts), this captures the whole working picture: your mental model " +
|
|
620
|
+
"of the codebase/task, the decisions you've made and why, and conventions or " +
|
|
621
|
+
"constraints you've learned that aren't obvious from the code. Call it when you " +
|
|
622
|
+
"have built up real context — before a long task's context fills, after a key " +
|
|
623
|
+
"decision, or when wrapping up. It replaces this session's previous snapshot " +
|
|
624
|
+
"(one per session) and links the most recent other session as its parent, so " +
|
|
625
|
+
"snapshots form a branchable history. Snapshots are pinned — never evicted. The " +
|
|
626
|
+
"next session's prefill resumes from the latest one automatically.",
|
|
627
|
+
args: {
|
|
628
|
+
summary: tool.schema
|
|
629
|
+
.string()
|
|
630
|
+
.describe("One short paragraph: the working mental model of the codebase/task."),
|
|
631
|
+
decisions: tool.schema
|
|
632
|
+
.array(tool.schema.string())
|
|
633
|
+
.optional()
|
|
634
|
+
.describe("Decisions made and why — each a short line."),
|
|
635
|
+
conventions: tool.schema
|
|
636
|
+
.array(tool.schema.string())
|
|
637
|
+
.optional()
|
|
638
|
+
.describe("Conventions or constraints learned that aren't obvious from the code."),
|
|
639
|
+
},
|
|
640
|
+
async execute(args) {
|
|
641
|
+
const t0 = performance.now();
|
|
642
|
+
const summary = {};
|
|
643
|
+
let error;
|
|
644
|
+
try {
|
|
645
|
+
const sessionId = ctx.sessionID ?? `unknown-${Date.now()}`;
|
|
646
|
+
const res = writeSnapshot(repo, sessionId, {
|
|
647
|
+
summary: args.summary,
|
|
648
|
+
decisions: args.decisions,
|
|
649
|
+
conventions: args.conventions,
|
|
650
|
+
});
|
|
651
|
+
repo.applyEviction(config);
|
|
652
|
+
await repo.forceFlush();
|
|
653
|
+
summary.id = res.id;
|
|
654
|
+
summary.parentId = res.parentId ?? null;
|
|
655
|
+
summary.decisionCount = args.decisions?.length ?? 0;
|
|
656
|
+
summary.conventionCount = args.conventions?.length ?? 0;
|
|
657
|
+
return (`snapshot saved: ${res.id}` +
|
|
658
|
+
(res.parentId ? ` (parent: ${res.parentId})` : " (first snapshot — no parent)"));
|
|
659
|
+
}
|
|
660
|
+
catch (e) {
|
|
661
|
+
error = e instanceof Error ? e.message : String(e);
|
|
662
|
+
throw e;
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
recordToolCall("memory_snapshot", args, t0, summary, error);
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
}),
|
|
669
|
+
memory_outline: tool({
|
|
670
|
+
description: "Call this ONCE at the start of a session on an unfamiliar repo — it is the " +
|
|
671
|
+
"cheapest possible orientation (a few tokens) and tells you what the memory " +
|
|
672
|
+
"store already knows: how many entries per category. Use it to decide what to " +
|
|
673
|
+
"memory_recall next instead of guessing. If categories like git-history or " +
|
|
674
|
+
"code-map have entries, that knowledge is one recall away — don't rediscover it " +
|
|
675
|
+
"with raw tools.",
|
|
676
|
+
args: {},
|
|
677
|
+
async execute() {
|
|
678
|
+
const t0 = performance.now();
|
|
679
|
+
const summary = {};
|
|
680
|
+
let error;
|
|
681
|
+
try {
|
|
682
|
+
const counts = repo.countsByCategory();
|
|
683
|
+
summary.totalMemories = repo.size();
|
|
684
|
+
summary.bytesTotal = repo.totalBytes();
|
|
685
|
+
summary.categories = Object.fromEntries(counts);
|
|
686
|
+
if (counts.size === 0)
|
|
687
|
+
return "(memory is empty)";
|
|
688
|
+
const lines = Array.from(counts.entries())
|
|
689
|
+
.sort((a, b) => b[1] - a[1])
|
|
690
|
+
.map(([c, n]) => `${c}: ${n}`);
|
|
691
|
+
return `${repo.size()} memories total, ${humanBytes(repo.totalBytes())} on disk:\n${lines.join("\n")}`;
|
|
692
|
+
}
|
|
693
|
+
catch (e) {
|
|
694
|
+
error = e instanceof Error ? e.message : String(e);
|
|
695
|
+
throw e;
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
recordToolCall("memory_outline", {}, t0, summary, error);
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
}),
|
|
702
|
+
memory_code_map: tool({
|
|
703
|
+
description: "Call this BEFORE reading source files to understand how the codebase is laid " +
|
|
704
|
+
"out — it is the Aider-style structural map: per-file signatures of functions, " +
|
|
705
|
+
"classes, methods and types, bodies stripped, so you see the shape of the code " +
|
|
706
|
+
"for a fraction of the tokens that reading the files would cost (~45 tokens per " +
|
|
707
|
+
"file). Ranked by relevance to `query` and co-change-boosted, so a file's " +
|
|
708
|
+
"structurally-coupled neighbours surface too. Use it to find where a symbol " +
|
|
709
|
+
"likely lives, or to orient before diving into a feature area — then `read` only " +
|
|
710
|
+
"the specific files you actually need. Returns nothing useful unless code-map " +
|
|
711
|
+
"ingestion is enabled (config.enableCodeMap, on by default since v0.0.4 — set " +
|
|
712
|
+
"it to false to disable); if it is empty, fall back to memory_recall + targeted reads.",
|
|
713
|
+
args: {
|
|
714
|
+
query: tool.schema
|
|
715
|
+
.string()
|
|
716
|
+
.optional()
|
|
717
|
+
.describe("What you're looking for — a feature, symbol, or file area. " +
|
|
718
|
+
"Omit for a broad map of the most-connected files."),
|
|
719
|
+
tokenBudget: tool.schema
|
|
720
|
+
.number()
|
|
721
|
+
.optional()
|
|
722
|
+
.describe("Ceiling on result size in estimated tokens. Default 1500."),
|
|
723
|
+
},
|
|
724
|
+
async execute(args) {
|
|
725
|
+
const t0 = performance.now();
|
|
726
|
+
const summary = {};
|
|
727
|
+
let error;
|
|
728
|
+
try {
|
|
729
|
+
const formatHit = (h) => `[${h.memory.subject}] ${h.memory.content}`;
|
|
730
|
+
// A broad query when none is given: 'definition' appears in
|
|
731
|
+
// every code-map memory body, so it matches them all and the
|
|
732
|
+
// co-change boost + useCount bias do the ranking.
|
|
733
|
+
const { hits, omitted } = repo.recallDetailed({
|
|
734
|
+
query: args.query && args.query.trim() ? args.query : "definition function class",
|
|
735
|
+
category: "code-map",
|
|
736
|
+
limit: 60,
|
|
737
|
+
tokenBudget: args.tokenBudget ?? 1500,
|
|
738
|
+
}, formatHit);
|
|
739
|
+
summary.hits = hits.length;
|
|
740
|
+
summary.omitted = omitted;
|
|
741
|
+
summary.tokenBudget = args.tokenBudget ?? 1500;
|
|
742
|
+
summary.hadQuery = Boolean(args.query && args.query.trim());
|
|
743
|
+
if (hits.length === 0) {
|
|
744
|
+
return ("(code map is empty — it is enabled by default; if you set " +
|
|
745
|
+
"config.enableCodeMap: false, re-enable it and restart OpenCode " +
|
|
746
|
+
"so the plugin can parse the tree)");
|
|
747
|
+
}
|
|
748
|
+
const lines = hits.map(formatHit);
|
|
749
|
+
if (omitted > 0) {
|
|
750
|
+
lines.push(`… (+${omitted} more file map${omitted === 1 ? "" : "s"} omitted to fit the ` +
|
|
751
|
+
`~${args.tokenBudget ?? 1500}-token budget — narrow the query or raise tokenBudget)`);
|
|
752
|
+
}
|
|
753
|
+
return lines.join("\n");
|
|
754
|
+
}
|
|
755
|
+
catch (e) {
|
|
756
|
+
error = e instanceof Error ? e.message : String(e);
|
|
757
|
+
throw e;
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
recordToolCall("memory_code_map", args, t0, summary, error);
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
}),
|
|
764
|
+
memory_status: tool({
|
|
765
|
+
description: "Stats about the memory store — size, count, budget, last ingest timestamps.",
|
|
766
|
+
args: {},
|
|
767
|
+
async execute() {
|
|
768
|
+
const t0 = performance.now();
|
|
769
|
+
const summary = {};
|
|
770
|
+
let error;
|
|
771
|
+
try {
|
|
772
|
+
const lines = [
|
|
773
|
+
`version: ${PLUGIN_VERSION}`,
|
|
774
|
+
`count: ${repo.size()}`,
|
|
775
|
+
`bytes: ${humanBytes(repo.totalBytes())} / ${humanBytes(config.maxMemoryBytes)}`,
|
|
776
|
+
];
|
|
777
|
+
const ingestedAt = {};
|
|
778
|
+
for (const cat of ["git-history", "project-facts", "session-trace"]) {
|
|
779
|
+
const ts = repo.getIngestedAt(cat);
|
|
780
|
+
ingestedAt[cat] = ts ?? null;
|
|
781
|
+
lines.push(`${cat}: ${ts ? new Date(ts).toISOString() : "(never ingested)"}`);
|
|
782
|
+
}
|
|
783
|
+
summary.version = PLUGIN_VERSION;
|
|
784
|
+
summary.totalMemories = repo.size();
|
|
785
|
+
summary.bytesTotal = repo.totalBytes();
|
|
786
|
+
summary.budgetBytes = config.maxMemoryBytes;
|
|
787
|
+
summary.ingestedAt = ingestedAt;
|
|
788
|
+
return lines.join("\n");
|
|
789
|
+
}
|
|
790
|
+
catch (e) {
|
|
791
|
+
error = e instanceof Error ? e.message : String(e);
|
|
792
|
+
throw e;
|
|
793
|
+
}
|
|
794
|
+
finally {
|
|
795
|
+
recordToolCall("memory_status", {}, t0, summary, error);
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
}),
|
|
799
|
+
memory_ingest_sessions: tool({
|
|
800
|
+
description: "Run this EARLY on a returning project — it pulls what past OpenCode sessions " +
|
|
801
|
+
"already did into memory, so you can build on prior work instead of repeating " +
|
|
802
|
+
"it. Each past session contributes a 'task' memory (its opening request) and a " +
|
|
803
|
+
"'trace' memory (distinct files edited + bash commands run). After ingesting, " +
|
|
804
|
+
"memory_recall will surface 'this was tried before' context. Cheap insurance " +
|
|
805
|
+
"against redoing a teammate's — or your own earlier — discovery work.",
|
|
806
|
+
args: {},
|
|
807
|
+
async execute() {
|
|
808
|
+
const t0 = performance.now();
|
|
809
|
+
const summary = {};
|
|
810
|
+
let error;
|
|
811
|
+
try {
|
|
812
|
+
const sessionId = ctx.sessionID ?? undefined;
|
|
813
|
+
const res = await ingestSessions(repo, client, sessionId);
|
|
814
|
+
repo.applyEviction(config);
|
|
815
|
+
await repo.forceFlush();
|
|
816
|
+
summary.sessions = res.sessions;
|
|
817
|
+
summary.taskMemories = res.taskMemories;
|
|
818
|
+
summary.traceMemories = res.traceMemories;
|
|
819
|
+
summary.errors = res.errors;
|
|
820
|
+
return (`ingested ${res.sessions} past session(s): ` +
|
|
821
|
+
`${res.taskMemories} task memories + ${res.traceMemories} trace memories. ` +
|
|
822
|
+
(res.errors.length > 0 ? `errors: ${res.errors.join("; ")}` : ""));
|
|
823
|
+
}
|
|
824
|
+
catch (e) {
|
|
825
|
+
error = e instanceof Error ? e.message : String(e);
|
|
826
|
+
throw e;
|
|
827
|
+
}
|
|
828
|
+
finally {
|
|
829
|
+
recordToolCall("memory_ingest_sessions", {}, t0, summary, error);
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
}),
|
|
833
|
+
memory_ingest_git: tool({
|
|
834
|
+
description: "Re-scan git history for new commits since the session started. Useful after " +
|
|
835
|
+
"a 'git pull' / 'git merge' / 'git rebase' where new commits arrived while this " +
|
|
836
|
+
"session was running — the prefill scan only sees what existed at startup. " +
|
|
837
|
+
"Idempotent: already-known commits are skipped (deduped by content hash), so " +
|
|
838
|
+
"only the new ones get added. The plugin also auto-runs this in the background " +
|
|
839
|
+
"when it detects HEAD moved as a side effect of a bash call; this tool is the " +
|
|
840
|
+
"explicit-control version, e.g. after a fetch-only operation that did not move " +
|
|
841
|
+
"HEAD but where you nonetheless know history advanced.",
|
|
842
|
+
args: {},
|
|
843
|
+
async execute() {
|
|
844
|
+
const t0 = performance.now();
|
|
845
|
+
const summary = {};
|
|
846
|
+
let error;
|
|
847
|
+
try {
|
|
848
|
+
// Refuse cleanly on non-git roots — `ingestGitHistory` already
|
|
849
|
+
// handles this internally (returns zero counts), but a direct
|
|
850
|
+
// upfront message is clearer for the agent than a silent zero.
|
|
851
|
+
if (!(await isGitRepo(root))) {
|
|
852
|
+
summary.skipped = "not-a-git-repo";
|
|
853
|
+
return "not a git repository — nothing to ingest";
|
|
854
|
+
}
|
|
855
|
+
const git = await ingestGitHistory(repo, root, config.gitHistoryDepth, config.coChangeMaxCommits, config.coChangeMinOccurrences);
|
|
856
|
+
repo.applyEviction(config);
|
|
857
|
+
await repo.forceFlush();
|
|
858
|
+
// Refresh the cached HEAD so the next auto-trigger compares
|
|
859
|
+
// against the value we just ingested at.
|
|
860
|
+
try {
|
|
861
|
+
const head = await currentHead(root);
|
|
862
|
+
if (head !== null)
|
|
863
|
+
lastKnownHead = head;
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
/* HEAD refresh is best-effort */
|
|
867
|
+
}
|
|
868
|
+
summary.scanned = git.scanned;
|
|
869
|
+
summary.commitMemories = git.commitMemories;
|
|
870
|
+
summary.coChangeMemories = git.coChangeMemories;
|
|
871
|
+
summary.churnMemories = git.churnMemories;
|
|
872
|
+
summary.recencyMemories = git.recencyMemories;
|
|
873
|
+
event("ingest.git.reingested", {
|
|
874
|
+
trigger: "explicit-tool",
|
|
875
|
+
scanned: git.scanned,
|
|
876
|
+
commitMemories: git.commitMemories,
|
|
877
|
+
});
|
|
878
|
+
return (`re-ingested git history: scanned ${git.scanned} commits → ` +
|
|
879
|
+
`${git.commitMemories} commit memories (new entries only — ` +
|
|
880
|
+
`already-known commits skipped), ${git.coChangeMemories} co-change pairs, ` +
|
|
881
|
+
`${git.churnMemories} churn flags, ${git.recencyMemories} recency markers.`);
|
|
882
|
+
}
|
|
883
|
+
catch (e) {
|
|
884
|
+
error = e instanceof Error ? e.message : String(e);
|
|
885
|
+
throw e;
|
|
886
|
+
}
|
|
887
|
+
finally {
|
|
888
|
+
recordToolCall("memory_ingest_git", {}, t0, summary, error);
|
|
889
|
+
}
|
|
890
|
+
},
|
|
891
|
+
}),
|
|
892
|
+
memory_mine_skills: tool({
|
|
893
|
+
description: "Mine project memory for reusable skill files. Clusters memories by subject; " +
|
|
894
|
+
"any cluster with at least 3 entries becomes a SKILL.md under .opencode/skills/. " +
|
|
895
|
+
"The mined skills are usable IMMEDIATELY in this session — when mining finishes " +
|
|
896
|
+
"a note lists them and you load any one into context with the memory_skill " +
|
|
897
|
+
"tool, no restart required. (OpenCode also auto-loads them as native skills on " +
|
|
898
|
+
"the next start.) RUN THIS when you assess the current task as " +
|
|
899
|
+
"large/complex/multi-step. Runs in the background; this tool returns immediately.",
|
|
900
|
+
args: {
|
|
901
|
+
reason: tool.schema.string().optional().describe("Why mining was triggered (for logs)."),
|
|
902
|
+
},
|
|
903
|
+
async execute(args, toolCtx) {
|
|
904
|
+
const t0 = performance.now();
|
|
905
|
+
const summary = {};
|
|
906
|
+
let error;
|
|
907
|
+
// The session to inject the "skills ready" note into when
|
|
908
|
+
// mining finishes. Prefer the calling tool's session; fall
|
|
909
|
+
// back to the plugin-init session id.
|
|
910
|
+
const sessionId = toolCtx?.sessionID ??
|
|
911
|
+
ctx.sessionID;
|
|
912
|
+
try {
|
|
913
|
+
const startedAt = Date.now();
|
|
914
|
+
log("info", `mining started${args.reason ? ` (reason: ${args.reason})` : ""} — running in background`);
|
|
915
|
+
// Fire-and-forget — the background work logs its own
|
|
916
|
+
// mining.complete / mining.failed structured events. The
|
|
917
|
+
// outer tool.call event records just the synchronous
|
|
918
|
+
// "started" portion (which is what the agent sees).
|
|
919
|
+
const miningStarted = performance.now();
|
|
920
|
+
void (async () => {
|
|
921
|
+
try {
|
|
922
|
+
const res = await mineSkills(repo, root, config.skillsOutputDir, config.skillMiningMinCluster, config.minedSkillPrefix);
|
|
923
|
+
await repo.forceFlush();
|
|
924
|
+
const names = res.writtenPaths.length > 0
|
|
925
|
+
? res.writtenPaths
|
|
926
|
+
.map((p) => p.replace(root + "/", ""))
|
|
927
|
+
.join(", ")
|
|
928
|
+
: "(none)";
|
|
929
|
+
log("info", `mining done: ${res.skillsWritten} skill(s) written from ${res.clustersConsidered} candidate cluster(s). ` +
|
|
930
|
+
`Available now via the memory_skill tool — no restart needed. Files: ${names}`);
|
|
931
|
+
// Make the freshly-mined skills usable in THIS session:
|
|
932
|
+
// drop a note into the conversation so the agent knows
|
|
933
|
+
// they exist and can load any of them with memory_skill.
|
|
934
|
+
// OpenCode's own skill discovery only runs at startup,
|
|
935
|
+
// so without this the skills would sit unused until the
|
|
936
|
+
// next launch.
|
|
937
|
+
if (res.skillsWritten > 0) {
|
|
938
|
+
const mined = await readMinedSkills(root, config.skillsOutputDir, config.minedSkillPrefix);
|
|
939
|
+
const justMined = mined.filter((s) => res.writtenPaths.some((p) => p.includes(`/${s.slug}/`)));
|
|
940
|
+
const injected = await injectSessionNote(sessionId, `[diane] ${res.skillsWritten} skill(s) were just mined from project ` +
|
|
941
|
+
`memory and are available now — no restart needed:\n` +
|
|
942
|
+
justMined
|
|
943
|
+
.map((s) => ` • ${s.slug} — ${s.description}`)
|
|
944
|
+
.join("\n") +
|
|
945
|
+
`\nLoad any of them into context with the memory_skill tool ` +
|
|
946
|
+
`(e.g. memory_skill with name "${justMined[0]?.slug ?? ""}"), ` +
|
|
947
|
+
`or call memory_skill with no arguments to list them.`);
|
|
948
|
+
if (!injected) {
|
|
949
|
+
log("info", `(could not inject a live session note — call memory_skill to list/load the new skills)`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
event("mining.complete", {
|
|
953
|
+
ms: Math.round(performance.now() - miningStarted),
|
|
954
|
+
skillsWritten: res.skillsWritten,
|
|
955
|
+
clustersConsidered: res.clustersConsidered,
|
|
956
|
+
writtenPaths: res.writtenPaths.map((p) => p.replace(root + "/", "")),
|
|
957
|
+
reason: args.reason ?? null,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
catch (err) {
|
|
961
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
962
|
+
log("warn", `mining failed: ${m}`);
|
|
963
|
+
event("mining.failed", {
|
|
964
|
+
ms: Math.round(performance.now() - miningStarted),
|
|
965
|
+
error: m,
|
|
966
|
+
reason: args.reason ?? null,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
})();
|
|
970
|
+
summary.started = true;
|
|
971
|
+
summary.startedAt = startedAt;
|
|
972
|
+
summary.reason = args.reason ?? null;
|
|
973
|
+
return (`Skill mining started in background${args.reason ? ` (${args.reason})` : ""}. ` +
|
|
974
|
+
`Started at ${new Date(startedAt).toISOString()}. ` +
|
|
975
|
+
`When it finishes, the new skills are usable immediately in this ` +
|
|
976
|
+
`session — no restart needed: a note will be posted listing them, ` +
|
|
977
|
+
`and you can load any skill into context with the memory_skill tool.`);
|
|
978
|
+
}
|
|
979
|
+
catch (e) {
|
|
980
|
+
error = e instanceof Error ? e.message : String(e);
|
|
981
|
+
throw e;
|
|
982
|
+
}
|
|
983
|
+
finally {
|
|
984
|
+
recordToolCall("memory_mine_skills", args, t0, summary, error);
|
|
985
|
+
}
|
|
986
|
+
},
|
|
987
|
+
}),
|
|
988
|
+
memory_skill: tool({
|
|
989
|
+
description: "List or load the skills mined by memory_mine_skills. OpenCode discovers " +
|
|
990
|
+
"skills only at startup, so a skill mined during this session isn't a native " +
|
|
991
|
+
"skill yet — this tool bridges that gap. Call with no arguments to LIST the " +
|
|
992
|
+
"skill files currently on disk (including ones mined moments ago); call with " +
|
|
993
|
+
"a `name` to LOAD that skill's instructions into the conversation so you can " +
|
|
994
|
+
"act on them now, no restart. Use it right after memory_mine_skills reports " +
|
|
995
|
+
"new skills.",
|
|
996
|
+
args: {
|
|
997
|
+
name: tool.schema
|
|
998
|
+
.string()
|
|
999
|
+
.optional()
|
|
1000
|
+
.describe("Slug of the skill to load (as shown by a no-argument call). " +
|
|
1001
|
+
"Omit to list all available skills."),
|
|
1002
|
+
},
|
|
1003
|
+
async execute(args, toolCtx) {
|
|
1004
|
+
const t0 = performance.now();
|
|
1005
|
+
const summary = {};
|
|
1006
|
+
let error;
|
|
1007
|
+
const sessionId = toolCtx?.sessionID ??
|
|
1008
|
+
ctx.sessionID;
|
|
1009
|
+
try {
|
|
1010
|
+
// Always read fresh from disk — that's what makes a skill
|
|
1011
|
+
// mined mid-session visible here without a restart.
|
|
1012
|
+
const skills = await readMinedSkills(root, config.skillsOutputDir, config.minedSkillPrefix);
|
|
1013
|
+
// ── LIST mode ────────────────────────────────────────────
|
|
1014
|
+
if (!args.name || !args.name.trim()) {
|
|
1015
|
+
summary.mode = "list";
|
|
1016
|
+
summary.skillCount = skills.length;
|
|
1017
|
+
if (skills.length === 0) {
|
|
1018
|
+
return ("(no skills on disk yet — run memory_mine_skills to mine some " +
|
|
1019
|
+
"from project memory, then call memory_skill again to load them)");
|
|
1020
|
+
}
|
|
1021
|
+
const lines = skills.map((s) => `• ${s.slug}${s.generatedByPlugin ? "" : " (external)"} — ${s.description}`);
|
|
1022
|
+
return (`${skills.length} skill(s) available — load one with memory_skill ` +
|
|
1023
|
+
`name="<slug>":\n${lines.join("\n")}`);
|
|
1024
|
+
}
|
|
1025
|
+
// ── LOAD mode ────────────────────────────────────────────
|
|
1026
|
+
summary.mode = "load";
|
|
1027
|
+
const wanted = args.name.trim();
|
|
1028
|
+
const skill = skills.find((s) => s.slug === wanted);
|
|
1029
|
+
if (!skill) {
|
|
1030
|
+
summary.found = false;
|
|
1031
|
+
return (`(no skill "${wanted}" — available: ` +
|
|
1032
|
+
`${skills.map((s) => s.slug).join(", ") || "none"})`);
|
|
1033
|
+
}
|
|
1034
|
+
summary.found = true;
|
|
1035
|
+
summary.slug = skill.slug;
|
|
1036
|
+
// Inject the skill body into the session as a persistent
|
|
1037
|
+
// note. If there's no live session client (older OpenCode,
|
|
1038
|
+
// or a test harness), fall back to returning the body as
|
|
1039
|
+
// the tool result — the agent gets it either way.
|
|
1040
|
+
const note = `[diane] The following mined skill is now active for this ` +
|
|
1041
|
+
`session — "${skill.name}":\n\n${skill.body}`;
|
|
1042
|
+
const injected = await injectSessionNote(sessionId, note);
|
|
1043
|
+
summary.injected = injected;
|
|
1044
|
+
if (injected) {
|
|
1045
|
+
return (`Skill "${skill.slug}" loaded into the session context ` +
|
|
1046
|
+
`(${skill.body.length} chars). Its guidance is now active — ` +
|
|
1047
|
+
`you can act on it directly.`);
|
|
1048
|
+
}
|
|
1049
|
+
// Fallback path: return the content inline.
|
|
1050
|
+
return `Skill "${skill.slug}" — ${skill.description}\n\n${skill.body}`;
|
|
1051
|
+
}
|
|
1052
|
+
catch (e) {
|
|
1053
|
+
error = e instanceof Error ? e.message : String(e);
|
|
1054
|
+
throw e;
|
|
1055
|
+
}
|
|
1056
|
+
finally {
|
|
1057
|
+
recordToolCall("memory_skill", args, t0, summary, error);
|
|
1058
|
+
}
|
|
1059
|
+
},
|
|
1060
|
+
}),
|
|
1061
|
+
},
|
|
1062
|
+
// ── Live signal: LSP diagnostics → code-health memories ──────────
|
|
1063
|
+
// Fires whenever a language server re-analyses a file. We upsert
|
|
1064
|
+
// one memory per file so the store always reflects *current*
|
|
1065
|
+
// diagnostics. Extraction is defensive: an unrecognised payload
|
|
1066
|
+
// shape is a silent no-op, never an error out of the plugin.
|
|
1067
|
+
event: async ({ event }) => {
|
|
1068
|
+
try {
|
|
1069
|
+
const e = event;
|
|
1070
|
+
if (!e || e.type !== "lsp.client.diagnostics")
|
|
1071
|
+
return;
|
|
1072
|
+
const res = ingestCodeHealth(repo, e);
|
|
1073
|
+
if (res.filesUpdated > 0 || res.filesCleared > 0) {
|
|
1074
|
+
repo.applyEviction(config);
|
|
1075
|
+
log("debug", `code-health: ${res.filesUpdated} file(s) with diagnostics, ` +
|
|
1076
|
+
`${res.filesCleared} cleared`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch (err) {
|
|
1080
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
1081
|
+
log("warn", `code-health event handler failed: ${m}`);
|
|
1082
|
+
}
|
|
1083
|
+
},
|
|
1084
|
+
// ── tool.execute hooks ───────────────────────────────────────────
|
|
1085
|
+
// Two jobs ride these hooks:
|
|
1086
|
+
// (1) Code-map freshness — ALWAYS on. When the agent writes/edits
|
|
1087
|
+
// a file, re-index that file so the code map never goes stale
|
|
1088
|
+
// mid-session.
|
|
1089
|
+
// (2) The recall-first nudge — opt-out via config.enableNudgeHook.
|
|
1090
|
+
// If the agent racks up raw discovery calls without ever using
|
|
1091
|
+
// a memory tool, append ONE reminder to a discovery result.
|
|
1092
|
+
// Disable it when another plugin also post-processes tool
|
|
1093
|
+
// output (e.g. oh-my-opencode) so two don't both mutate it.
|
|
1094
|
+
// Both are wrapped so they can never break, block, or corrupt a
|
|
1095
|
+
// real tool call.
|
|
1096
|
+
"tool.execute.before": async (input, output) => {
|
|
1097
|
+
const name = input?.tool ?? "";
|
|
1098
|
+
// (1) Record which file a write/edit is about to change.
|
|
1099
|
+
try {
|
|
1100
|
+
if (FILE_WRITE_TOOLS.has(name)) {
|
|
1101
|
+
const fp = output?.args?.filePath;
|
|
1102
|
+
if (typeof fp === "string" && fp.length > 0) {
|
|
1103
|
+
setBoundedPending(pendingEditPaths, input?.callID ?? "_", fp);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
/* bookkeeping must never break a tool call */
|
|
1109
|
+
}
|
|
1110
|
+
// (1b) Stash the bash command string so the after-hook can record
|
|
1111
|
+
// it to the live-session memory. The OpenCode bash tool's
|
|
1112
|
+
// arg key is `command`; we also check a couple of common
|
|
1113
|
+
// alternatives (`cmd`, `script`) defensively — an
|
|
1114
|
+
// unrecognised key means the bash recording is skipped for
|
|
1115
|
+
// that call, never an error.
|
|
1116
|
+
try {
|
|
1117
|
+
if (name === "bash" && liveRecorder) {
|
|
1118
|
+
const a = output?.args ?? {};
|
|
1119
|
+
const cmd = (typeof a.command === "string" ? a.command : undefined) ??
|
|
1120
|
+
(typeof a.cmd === "string" ? a.cmd : undefined) ??
|
|
1121
|
+
(typeof a.script === "string" ? a.script : undefined);
|
|
1122
|
+
if (typeof cmd === "string" && cmd.length > 0) {
|
|
1123
|
+
setBoundedPending(pendingBashCommands, input?.callID ?? "_", cmd);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
/* bookkeeping must never break a tool call */
|
|
1129
|
+
}
|
|
1130
|
+
// (2) Nudge bookkeeping.
|
|
1131
|
+
if (config.enableNudgeHook) {
|
|
1132
|
+
try {
|
|
1133
|
+
if (MEMORY_TOOLS.has(name))
|
|
1134
|
+
memoryToolUsed = true;
|
|
1135
|
+
else if (DISCOVERY_TOOLS.has(name))
|
|
1136
|
+
discoveryCallCount += 1;
|
|
1137
|
+
}
|
|
1138
|
+
catch {
|
|
1139
|
+
/* never let bookkeeping break a tool call */
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
},
|
|
1143
|
+
"tool.execute.after": async (input, output) => {
|
|
1144
|
+
const name = input?.tool ?? "";
|
|
1145
|
+
// (1) The file is now written — re-index its code map. Awaited so
|
|
1146
|
+
// the index is fresh before the agent's next tool call; once
|
|
1147
|
+
// the tree-sitter engine is warm a one-file parse is cheap.
|
|
1148
|
+
try {
|
|
1149
|
+
const key = input?.callID ?? "_";
|
|
1150
|
+
const fp = pendingEditPaths.get(key);
|
|
1151
|
+
if (fp !== undefined) {
|
|
1152
|
+
pendingEditPaths.delete(key);
|
|
1153
|
+
await refreshCodeMapAfterEdit(fp);
|
|
1154
|
+
// (1a) Live session recording — feed the recorder the edit.
|
|
1155
|
+
// Wrapped — a failed live recording is not worth
|
|
1156
|
+
// interrupting the agent. Recorder.flush() upserts ONE
|
|
1157
|
+
// memory under `live:${sessionId}`, idempotent on
|
|
1158
|
+
// subject, so frequent flushing is cheap and safe.
|
|
1159
|
+
if (liveRecorder) {
|
|
1160
|
+
try {
|
|
1161
|
+
liveRecorder.recordFileEdit(fp, name);
|
|
1162
|
+
liveRecorder.flush();
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
/* live-trace recording is best-effort */
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
/* a stale-index refresh must never break a tool call */
|
|
1172
|
+
}
|
|
1173
|
+
// (1b) Bash-specific post-processing: detect what files the shell
|
|
1174
|
+
// command actually touched (via `git status --porcelain`),
|
|
1175
|
+
// refresh the code-map for each up to the configured cap,
|
|
1176
|
+
// and check whether HEAD moved (pull/merge/rebase/checkout)
|
|
1177
|
+
// — if so, queue a background git re-ingest.
|
|
1178
|
+
if (name === "bash") {
|
|
1179
|
+
// Record the bash command itself into the live trace.
|
|
1180
|
+
try {
|
|
1181
|
+
const key = input?.callID ?? "_";
|
|
1182
|
+
const cmd = pendingBashCommands.get(key);
|
|
1183
|
+
pendingBashCommands.delete(key);
|
|
1184
|
+
if (cmd !== undefined && liveRecorder) {
|
|
1185
|
+
try {
|
|
1186
|
+
liveRecorder.recordBash(cmd);
|
|
1187
|
+
liveRecorder.flush();
|
|
1188
|
+
}
|
|
1189
|
+
catch {
|
|
1190
|
+
/* live-trace recording is best-effort */
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
/* bash command extraction is best-effort */
|
|
1196
|
+
}
|
|
1197
|
+
// Refresh code-map for files the bash command modified.
|
|
1198
|
+
try {
|
|
1199
|
+
if (config.bashFileTrackingMaxFiles > 0 && config.enableCodeMap) {
|
|
1200
|
+
const changed = await changedFilesInWorktree(root);
|
|
1201
|
+
if (changed.length > 0) {
|
|
1202
|
+
const toRefresh = changed.slice(0, config.bashFileTrackingMaxFiles);
|
|
1203
|
+
const skipped = changed.length - toRefresh.length;
|
|
1204
|
+
for (const f of toRefresh) {
|
|
1205
|
+
// refreshCodeMapAfterEdit is already defensively wrapped.
|
|
1206
|
+
await refreshCodeMapAfterEdit(f);
|
|
1207
|
+
if (liveRecorder) {
|
|
1208
|
+
try {
|
|
1209
|
+
liveRecorder.recordFileEdit(f, "bash");
|
|
1210
|
+
}
|
|
1211
|
+
catch { /* best-effort */ }
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (liveRecorder) {
|
|
1215
|
+
try {
|
|
1216
|
+
liveRecorder.flush();
|
|
1217
|
+
}
|
|
1218
|
+
catch { /* best-effort */ }
|
|
1219
|
+
}
|
|
1220
|
+
if (skipped > 0) {
|
|
1221
|
+
log("debug", `bash post-hook: ${toRefresh.length} file(s) re-indexed, ` +
|
|
1222
|
+
`${skipped} skipped (over bashFileTrackingMaxFiles=${config.bashFileTrackingMaxFiles})`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
catch {
|
|
1228
|
+
/* bash post-hook scan is best-effort */
|
|
1229
|
+
}
|
|
1230
|
+
// Detect HEAD movement and queue git re-ingest if needed.
|
|
1231
|
+
try {
|
|
1232
|
+
await reingestGitIfHeadMoved();
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
/* HEAD detection is best-effort */
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
// (2) Nudge.
|
|
1239
|
+
if (config.enableNudgeHook) {
|
|
1240
|
+
try {
|
|
1241
|
+
if (nudgeShown || memoryToolUsed)
|
|
1242
|
+
return;
|
|
1243
|
+
// Only append to free-text discovery output, and only once a
|
|
1244
|
+
// couple of discovery calls have gone by — a single read is
|
|
1245
|
+
// not worth nagging about. `read` is intentionally excluded
|
|
1246
|
+
// from NUDGEABLE_DISCOVERY so file contents stay pristine.
|
|
1247
|
+
if (!NUDGEABLE_DISCOVERY.has(name) || discoveryCallCount < 2)
|
|
1248
|
+
return;
|
|
1249
|
+
if (!output || typeof output.output !== "string")
|
|
1250
|
+
return;
|
|
1251
|
+
output.output +=
|
|
1252
|
+
"\n\n[diane] Several discovery calls so far, no project-memory check yet. " +
|
|
1253
|
+
"memory_recall (and memory_code_map) usually answer 'where is X' / 'what changed' " +
|
|
1254
|
+
"in one call for ~10x fewer tokens — worth trying before more raw read/grep.";
|
|
1255
|
+
nudgeShown = true;
|
|
1256
|
+
}
|
|
1257
|
+
catch {
|
|
1258
|
+
/* a nudge is never worth an exception */
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
},
|
|
1262
|
+
};
|
|
1263
|
+
};
|
|
1264
|
+
/* ─── pre-fill orchestration ────────────────────────────────────────── */
|
|
1265
|
+
async function prefillInBackground(repo, root, client, config, log, event, sessionId) {
|
|
1266
|
+
const prefillStarted = performance.now();
|
|
1267
|
+
event("prefill.start", { sessionId: sessionId ?? null });
|
|
1268
|
+
try {
|
|
1269
|
+
// Adaptive sizing first — measure the repo with one cheap signal
|
|
1270
|
+
// and tune the size-derived knobs (in place, so the tools' config
|
|
1271
|
+
// closure picks them up) before any ingester runs with them.
|
|
1272
|
+
const signal = await measureRepo(root, await isGitRepo(root));
|
|
1273
|
+
const { summary } = applyAdaptiveTuning(config, signal);
|
|
1274
|
+
log("info", `prefill: ${summary}`);
|
|
1275
|
+
event("adaptive.tuned", { signal, summary });
|
|
1276
|
+
const proj = await ingestProjectFacts(repo, root);
|
|
1277
|
+
log("info", `prefill: project-facts ingested ${proj.facts} entries`);
|
|
1278
|
+
event("ingest.project", { facts: proj.facts });
|
|
1279
|
+
// ── Three new ingest passes (added in v0.0.4) ──────────────────
|
|
1280
|
+
// All three are opt-out via config; default-on because (a) they
|
|
1281
|
+
// surface high-signal information for typical recalls and (b)
|
|
1282
|
+
// cost is bounded by file caps inside each ingester.
|
|
1283
|
+
if (config.ingestDocs) {
|
|
1284
|
+
try {
|
|
1285
|
+
const docs = await ingestDocs(repo, root, {
|
|
1286
|
+
maxFiles: config.docsMaxFiles,
|
|
1287
|
+
bodyChars: config.docsBodyChars,
|
|
1288
|
+
maxHeadingLevel: config.docsMaxHeadingLevel,
|
|
1289
|
+
});
|
|
1290
|
+
if (docs.filesWalked > 0) {
|
|
1291
|
+
log("info", `prefill: docs ingested ${docs.headingsIndexed} headings across ${docs.filesWalked} files`);
|
|
1292
|
+
event("ingest.docs", { ...docs });
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
catch (err) {
|
|
1296
|
+
// Ingestion is best-effort — a broken docs/ tree must not
|
|
1297
|
+
// stall startup. Log and continue with whatever else works.
|
|
1298
|
+
log("warn", `docs ingest failed: ${err}`);
|
|
1299
|
+
event("ingest.docs.failed", { error: String(err) });
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if (config.ingestProjectNotes) {
|
|
1303
|
+
try {
|
|
1304
|
+
const notes = await ingestProjectNotes(repo, root, {
|
|
1305
|
+
maxBytes: config.notesMaxBytes,
|
|
1306
|
+
});
|
|
1307
|
+
if (notes.filesFound > 0) {
|
|
1308
|
+
log("info", `prefill: project-notes found ${notes.filesFound} agent-instruction files`);
|
|
1309
|
+
event("ingest.project-notes", { ...notes });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
catch (err) {
|
|
1313
|
+
log("warn", `project-notes ingest failed: ${err}`);
|
|
1314
|
+
event("ingest.project-notes.failed", { error: String(err) });
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
if (config.ingestTableHeaders) {
|
|
1318
|
+
try {
|
|
1319
|
+
const tables = await ingestTableHeaders(repo, root, {
|
|
1320
|
+
maxFiles: config.tablesMaxFiles,
|
|
1321
|
+
maxXlsxMB: config.tablesMaxXlsxMB,
|
|
1322
|
+
maxColumns: config.tablesMaxColumns,
|
|
1323
|
+
});
|
|
1324
|
+
if (tables.filesFound > 0) {
|
|
1325
|
+
log("info", `prefill: table-headers indexed for ${tables.filesFound} files ` +
|
|
1326
|
+
`(formats: ${tables.formatsSupported.join(", ")})`);
|
|
1327
|
+
event("ingest.tables", { ...tables });
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
catch (err) {
|
|
1331
|
+
log("warn", `tables ingest failed: ${err}`);
|
|
1332
|
+
event("ingest.tables.failed", { error: String(err) });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (config.ingestCrossRefs) {
|
|
1336
|
+
try {
|
|
1337
|
+
const xref = await ingestCrossRefs(repo, root, {
|
|
1338
|
+
rarityThreshold: config.crossRefsRarityThreshold,
|
|
1339
|
+
maxFiles: config.crossRefsMaxFiles,
|
|
1340
|
+
maxEdges: config.crossRefsMaxEdges,
|
|
1341
|
+
});
|
|
1342
|
+
if (xref.edgesEmitted > 0) {
|
|
1343
|
+
log("info", `prefill: cross-refs indexed ${xref.edgesEmitted} edges ` +
|
|
1344
|
+
`across ${xref.filesWalked} files ` +
|
|
1345
|
+
`(${xref.definitionsExtracted} definitions extracted)`);
|
|
1346
|
+
event("ingest.cross-refs", { ...xref, byEvidence: xref.byEvidence });
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
catch (err) {
|
|
1350
|
+
log("warn", `cross-refs ingest failed: ${err}`);
|
|
1351
|
+
event("ingest.cross-refs.failed", { error: String(err) });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
const git = await ingestGitHistory(repo, root, config.gitHistoryDepth, config.coChangeMaxCommits, config.coChangeMinOccurrences);
|
|
1355
|
+
const shapeSummary = Object.entries(git.shapeTagCounts)
|
|
1356
|
+
.sort((a, b) => b[1] - a[1])
|
|
1357
|
+
.slice(0, 6)
|
|
1358
|
+
.map(([t, n]) => `${t}:${n}`)
|
|
1359
|
+
.join(", ");
|
|
1360
|
+
log("info", `prefill: git scanned ${git.scanned} commits → ${git.commitMemories} commit memories, ` +
|
|
1361
|
+
`${git.coChangeMemories} co-change, ${git.churnMemories} churn, ` +
|
|
1362
|
+
`${git.recencyMemories} recency` +
|
|
1363
|
+
(shapeSummary ? ` [shapes: ${shapeSummary}]` : ""));
|
|
1364
|
+
event("ingest.git", {
|
|
1365
|
+
scanned: git.scanned,
|
|
1366
|
+
commitMemories: git.commitMemories,
|
|
1367
|
+
coChangeMemories: git.coChangeMemories,
|
|
1368
|
+
churnMemories: git.churnMemories,
|
|
1369
|
+
recencyMemories: git.recencyMemories,
|
|
1370
|
+
shapeTagCounts: git.shapeTagCounts,
|
|
1371
|
+
});
|
|
1372
|
+
if (config.ingestSessions) {
|
|
1373
|
+
const sess = await ingestSessions(repo, client);
|
|
1374
|
+
log("info", `prefill: ingested ${sess.sessions} past session(s) — ` +
|
|
1375
|
+
`${sess.taskMemories} task + ${sess.traceMemories} trace memories`);
|
|
1376
|
+
event("ingest.sessions", {
|
|
1377
|
+
sessions: sess.sessions,
|
|
1378
|
+
taskMemories: sess.taskMemories,
|
|
1379
|
+
traceMemories: sess.traceMemories,
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
if (config.enableCodeMap) {
|
|
1383
|
+
const cm = await ingestCodeMap(repo, root, undefined, config.codeMapMaxFiles);
|
|
1384
|
+
if (cm.unavailableReason) {
|
|
1385
|
+
log("warn", `prefill: code-map skipped — ${cm.unavailableReason}`);
|
|
1386
|
+
event("ingest.code-map.skipped", { reason: cm.unavailableReason });
|
|
1387
|
+
}
|
|
1388
|
+
else {
|
|
1389
|
+
log("info", `prefill: code-map parsed ${cm.filesParsed} file(s) ` +
|
|
1390
|
+
`[${cm.languagesSeen.join(", ") || "none"}], ` +
|
|
1391
|
+
`${cm.signaturesExtracted} signatures`);
|
|
1392
|
+
event("ingest.code-map", {
|
|
1393
|
+
filesParsed: cm.filesParsed,
|
|
1394
|
+
languagesSeen: cm.languagesSeen,
|
|
1395
|
+
signaturesExtracted: cm.signaturesExtracted,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
// Resume point: surface the most recent session snapshot so the
|
|
1400
|
+
// agent (and the human reading the log) knows there's accumulated
|
|
1401
|
+
// understanding one recall away. A parallel session reads the
|
|
1402
|
+
// same shared store, so it sees the same resume point.
|
|
1403
|
+
const snap = latestSnapshot(repo, sessionId);
|
|
1404
|
+
if (snap) {
|
|
1405
|
+
const total = snapshotSummary(repo).count;
|
|
1406
|
+
log("info", `prefill: resuming from session snapshot ${snap.id} ` +
|
|
1407
|
+
`(${total} snapshot${total === 1 ? "" : "s"} on record) — ` +
|
|
1408
|
+
`recall category 'session-snapshot' to load it`);
|
|
1409
|
+
event("snapshot.resume", { snapshotId: snap.id, totalSnapshots: total });
|
|
1410
|
+
}
|
|
1411
|
+
const ev = repo.applyEviction(config);
|
|
1412
|
+
if (ev.removed > 0) {
|
|
1413
|
+
log("info", `prefill: evicted ${ev.removed} entries to stay within disk budget`);
|
|
1414
|
+
event("eviction", {
|
|
1415
|
+
removed: ev.removed,
|
|
1416
|
+
bytesAfter: repo.totalBytes(),
|
|
1417
|
+
budgetBytes: config.maxMemoryBytes,
|
|
1418
|
+
trigger: "prefill",
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
await repo.forceFlush();
|
|
1422
|
+
event("prefill.complete", {
|
|
1423
|
+
ms: Math.round(performance.now() - prefillStarted),
|
|
1424
|
+
storeSize: repo.size(),
|
|
1425
|
+
bytesTotal: repo.totalBytes(),
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
catch (err) {
|
|
1429
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
1430
|
+
log("warn", `prefill failed: ${m}`);
|
|
1431
|
+
event("prefill.failed", {
|
|
1432
|
+
ms: Math.round(performance.now() - prefillStarted),
|
|
1433
|
+
error: m,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
/* ─── small helpers ─────────────────────────────────────────────────── */
|
|
1438
|
+
/**
|
|
1439
|
+
* Defensively coerce the untrusted `options` object OpenCode passes
|
|
1440
|
+
* from opencode.json into a typed UserConfig. Every field is checked
|
|
1441
|
+
* for the right primitive type; anything missing or wrong-typed is
|
|
1442
|
+
* dropped, so `resolveConfig` then fills the default. A malformed
|
|
1443
|
+
* config never throws — at worst the plugin runs on defaults.
|
|
1444
|
+
*/
|
|
1445
|
+
function coerceUserConfig(options) {
|
|
1446
|
+
if (!options || typeof options !== "object")
|
|
1447
|
+
return {};
|
|
1448
|
+
const o = options;
|
|
1449
|
+
const cfg = {};
|
|
1450
|
+
const num = (k) => {
|
|
1451
|
+
const v = o[k];
|
|
1452
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
1453
|
+
;
|
|
1454
|
+
cfg[k] = v;
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
const bool = (k) => {
|
|
1458
|
+
const v = o[k];
|
|
1459
|
+
if (typeof v === "boolean") {
|
|
1460
|
+
;
|
|
1461
|
+
cfg[k] = v;
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
const str = (k) => {
|
|
1465
|
+
const v = o[k];
|
|
1466
|
+
if (typeof v === "string" && v.length > 0) {
|
|
1467
|
+
;
|
|
1468
|
+
cfg[k] = v;
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
num("maxMemoryDiskMB");
|
|
1472
|
+
bool("autoIngestOnStartup");
|
|
1473
|
+
num("gitHistoryDepth");
|
|
1474
|
+
bool("forceActive");
|
|
1475
|
+
str("skillsOutputDir");
|
|
1476
|
+
num("skillMiningMinCluster");
|
|
1477
|
+
bool("ingestSessions");
|
|
1478
|
+
bool("enableCodeMap");
|
|
1479
|
+
bool("installUsageSkill");
|
|
1480
|
+
bool("ingestDocs");
|
|
1481
|
+
bool("ingestProjectNotes");
|
|
1482
|
+
bool("ingestTableHeaders");
|
|
1483
|
+
bool("ingestCrossRefs");
|
|
1484
|
+
num("crossRefsRarityThreshold");
|
|
1485
|
+
num("crossRefsMaxFiles");
|
|
1486
|
+
num("crossRefsMaxEdges");
|
|
1487
|
+
num("docsMaxFiles");
|
|
1488
|
+
num("docsBodyChars");
|
|
1489
|
+
num("docsMaxHeadingLevel");
|
|
1490
|
+
num("tablesMaxFiles");
|
|
1491
|
+
num("tablesMaxXlsxMB");
|
|
1492
|
+
num("tablesMaxColumns");
|
|
1493
|
+
num("notesMaxBytes");
|
|
1494
|
+
num("coChangeMinOccurrences");
|
|
1495
|
+
num("codeMapMaxFiles");
|
|
1496
|
+
num("coChangeMaxCommits");
|
|
1497
|
+
bool("enableNudgeHook");
|
|
1498
|
+
bool("adaptive");
|
|
1499
|
+
bool("enableSemanticSearch");
|
|
1500
|
+
str("embeddingModel");
|
|
1501
|
+
bool("personalizedPageRank");
|
|
1502
|
+
bool("recordSessionActivity");
|
|
1503
|
+
num("bashFileTrackingMaxFiles");
|
|
1504
|
+
bool("autoReingestGitOnHeadChange");
|
|
1505
|
+
return cfg;
|
|
1506
|
+
}
|
|
1507
|
+
function resolveConfig(user) {
|
|
1508
|
+
// Which keys the user actually set — adaptive tuning consults this
|
|
1509
|
+
// so it never overrides a deliberate choice.
|
|
1510
|
+
const explicitKeys = new Set(Object.keys(user).filter((k) => user[k] !== undefined));
|
|
1511
|
+
return {
|
|
1512
|
+
maxMemoryBytes: Math.max(1, user.maxMemoryDiskMB ?? 50) * 1024 * 1024,
|
|
1513
|
+
autoIngestOnStartup: user.autoIngestOnStartup ?? true,
|
|
1514
|
+
gitHistoryDepth: Math.max(10, user.gitHistoryDepth ?? 500),
|
|
1515
|
+
forceActive: user.forceActive ?? false,
|
|
1516
|
+
skillsOutputDir: user.skillsOutputDir ?? ".opencode/skills",
|
|
1517
|
+
minedSkillPrefix: "",
|
|
1518
|
+
skillMiningMinCluster: Math.max(2, user.skillMiningMinCluster ?? 3),
|
|
1519
|
+
ingestSessions: user.ingestSessions ?? true,
|
|
1520
|
+
enableCodeMap: user.enableCodeMap ?? true,
|
|
1521
|
+
installUsageSkill: user.installUsageSkill ?? true,
|
|
1522
|
+
ingestDocs: user.ingestDocs ?? true,
|
|
1523
|
+
ingestProjectNotes: user.ingestProjectNotes ?? true,
|
|
1524
|
+
ingestTableHeaders: user.ingestTableHeaders ?? true,
|
|
1525
|
+
ingestCrossRefs: user.ingestCrossRefs ?? true,
|
|
1526
|
+
crossRefsRarityThreshold: Math.max(1, Math.round(user.crossRefsRarityThreshold ?? 3)),
|
|
1527
|
+
crossRefsMaxFiles: Math.max(1, Math.round(user.crossRefsMaxFiles ?? 2000)),
|
|
1528
|
+
crossRefsMaxEdges: Math.max(1, Math.round(user.crossRefsMaxEdges ?? 10_000)),
|
|
1529
|
+
docsMaxFiles: Math.max(1, Math.round(user.docsMaxFiles ?? 200)),
|
|
1530
|
+
docsBodyChars: Math.max(40, Math.round(user.docsBodyChars ?? 240)),
|
|
1531
|
+
docsMaxHeadingLevel: Math.min(6, Math.max(1, Math.round(user.docsMaxHeadingLevel ?? 3))),
|
|
1532
|
+
tablesMaxFiles: Math.max(1, Math.round(user.tablesMaxFiles ?? 200)),
|
|
1533
|
+
tablesMaxXlsxMB: Math.max(0, user.tablesMaxXlsxMB ?? 50),
|
|
1534
|
+
tablesMaxColumns: Math.max(1, Math.round(user.tablesMaxColumns ?? 40)),
|
|
1535
|
+
notesMaxBytes: Math.max(256, Math.round(user.notesMaxBytes ?? 6144)),
|
|
1536
|
+
coChangeMinOccurrences: Math.max(1, Math.round(user.coChangeMinOccurrences ?? 3)),
|
|
1537
|
+
enableNudgeHook: user.enableNudgeHook ?? true,
|
|
1538
|
+
adaptive: user.adaptive ?? true,
|
|
1539
|
+
enableSemanticSearch: user.enableSemanticSearch ?? false,
|
|
1540
|
+
embeddingModel: user.embeddingModel ?? DEFAULT_EMBEDDING_MODEL,
|
|
1541
|
+
personalizedPageRank: user.personalizedPageRank ?? false,
|
|
1542
|
+
recordSessionActivity: user.recordSessionActivity ?? true,
|
|
1543
|
+
bashFileTrackingMaxFiles: Math.max(0, Math.round(user.bashFileTrackingMaxFiles ?? 20)),
|
|
1544
|
+
autoReingestGitOnHeadChange: user.autoReingestGitOnHeadChange ?? true,
|
|
1545
|
+
explicitKeys,
|
|
1546
|
+
// Size-derived knobs — these are the fixed (medium-tier) defaults;
|
|
1547
|
+
// applyAdaptiveTuning overwrites them from the measured repo
|
|
1548
|
+
// signal when `adaptive` is true.
|
|
1549
|
+
codeMapMaxFiles: Math.max(1, Math.round(user.codeMapMaxFiles ?? 4000)),
|
|
1550
|
+
coChangeMaxCommits: Math.max(1, Math.round(user.coChangeMaxCommits ?? 5000)),
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
function pickRoot(directory, worktree) {
|
|
1554
|
+
if (directory && directory !== "/")
|
|
1555
|
+
return directory;
|
|
1556
|
+
if (worktree && worktree !== "/")
|
|
1557
|
+
return worktree;
|
|
1558
|
+
return directory || worktree || process.cwd();
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* A directory is "workable" if it's a git repo OR contains at least
|
|
1562
|
+
* one recognised project/build/CI file. Language-neutral — the list
|
|
1563
|
+
* spans every ecosystem we know a manifest filename for, plus a `.git`
|
|
1564
|
+
* check. Truly empty or unrecognised directories get the idle path.
|
|
1565
|
+
*/
|
|
1566
|
+
async function detectWorkableRepo(root) {
|
|
1567
|
+
const { stat } = await import("node:fs/promises");
|
|
1568
|
+
// git repo?
|
|
1569
|
+
try {
|
|
1570
|
+
const s = await stat(`${root}/.git`);
|
|
1571
|
+
if (s.isDirectory() || s.isFile())
|
|
1572
|
+
return true; // .git dir, or worktree .git file
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
// not a git repo — fall through to manifest check
|
|
1576
|
+
}
|
|
1577
|
+
// any recognised project/build/CI file?
|
|
1578
|
+
for (const f of [
|
|
1579
|
+
"package.json",
|
|
1580
|
+
"Cargo.toml",
|
|
1581
|
+
"go.mod",
|
|
1582
|
+
"pyproject.toml",
|
|
1583
|
+
"setup.py",
|
|
1584
|
+
"requirements.txt",
|
|
1585
|
+
"pom.xml",
|
|
1586
|
+
"build.gradle",
|
|
1587
|
+
"build.gradle.kts",
|
|
1588
|
+
"Gemfile",
|
|
1589
|
+
"composer.json",
|
|
1590
|
+
"mix.exs",
|
|
1591
|
+
"Package.swift",
|
|
1592
|
+
"pubspec.yaml",
|
|
1593
|
+
"CMakeLists.txt",
|
|
1594
|
+
"Makefile",
|
|
1595
|
+
"makefile",
|
|
1596
|
+
"meson.build",
|
|
1597
|
+
"build.zig",
|
|
1598
|
+
"build.sbt",
|
|
1599
|
+
"project.clj",
|
|
1600
|
+
"deno.json",
|
|
1601
|
+
"flake.nix",
|
|
1602
|
+
"Dockerfile",
|
|
1603
|
+
".gitlab-ci.yml",
|
|
1604
|
+
]) {
|
|
1605
|
+
try {
|
|
1606
|
+
await stat(`${root}/${f}`);
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
1609
|
+
catch {
|
|
1610
|
+
// try next
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
return false;
|
|
1614
|
+
}
|
|
1615
|
+
function isCategory(s) {
|
|
1616
|
+
return (s === "git-history" ||
|
|
1617
|
+
s === "project-facts" ||
|
|
1618
|
+
s === "code-health" ||
|
|
1619
|
+
s === "code-map" ||
|
|
1620
|
+
s === "session-trace" ||
|
|
1621
|
+
s === "session-snapshot" ||
|
|
1622
|
+
s === "agent-note" ||
|
|
1623
|
+
s === "skill-mined" ||
|
|
1624
|
+
s === "custom");
|
|
1625
|
+
}
|
|
1626
|
+
function humanBytes(n) {
|
|
1627
|
+
if (n < 1024)
|
|
1628
|
+
return `${n}B`;
|
|
1629
|
+
if (n < 1024 * 1024)
|
|
1630
|
+
return `${(n / 1024).toFixed(1)}KB`;
|
|
1631
|
+
return `${(n / 1024 / 1024).toFixed(2)}MB`;
|
|
1632
|
+
}
|