opencode-self-improvement 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +122 -2
- package/command/repo-init.md +16 -0
- package/index.ts +2 -0
- package/package.json +51 -3
- package/src/blocklist.ts +56 -0
- package/src/command.ts +25 -0
- package/src/config.ts +60 -0
- package/src/evidence.ts +50 -0
- package/src/gates.ts +511 -0
- package/src/index.ts +461 -0
- package/src/init.ts +300 -0
- package/src/inject.ts +150 -0
- package/src/logfmt.ts +67 -0
- package/src/memory.ts +242 -0
- package/src/prune.ts +23 -0
- package/src/resolver.ts +298 -0
- package/src/state.ts +88 -0
- package/src/triggers.ts +292 -0
- package/src/undo.ts +44 -0
- package/src/viewer/mutate.ts +152 -0
- package/src/viewer/page.html +362 -0
- package/src/viewer/server.ts +191 -0
- package/vendor/opencode-memory/LICENSE +21 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// opencode-self-improvement: one opencode plugin bundling durable memory, the
|
|
2
|
+
// 4-gate write pipeline, deterministic reflection triggers T1/T2/T4,
|
|
3
|
+
// tiny-digest injection with deterministic memory-first context and
|
|
4
|
+
// contextual pull, and snapshot/rollback.
|
|
5
|
+
//
|
|
6
|
+
// V2 port: registers tools via `ctx.tool.transform` (JSON Schema), mirrors the
|
|
7
|
+
// V1 hooks onto the OpenCode V2 session/tool hook API, and registers the
|
|
8
|
+
// /repo-init command via `ctx.command.transform`. The plugin contract is a
|
|
9
|
+
// default export object `{ id, setup(ctx) }` (OpenCode >= 2.0). All business
|
|
10
|
+
// logic still lives in the decoupled helper modules.
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { evidenceDir, memoryRoot, dateStr } from "./config.ts";
|
|
13
|
+
import { loadAll, isActive, markSuperseded, type Memory } from "./memory.ts";
|
|
14
|
+
import { runWriteGate, type GateContext, type WriteCandidate } from "./gates.ts";
|
|
15
|
+
import { pruneMemory } from "./prune.ts";
|
|
16
|
+
import { listSnapshots, restoreSnapshot } from "./undo.ts";
|
|
17
|
+
import { record } from "./evidence.ts";
|
|
18
|
+
import {
|
|
19
|
+
contextualPull,
|
|
20
|
+
formatMemory,
|
|
21
|
+
relevantKnowledge,
|
|
22
|
+
systemDigest,
|
|
23
|
+
} from "./inject.ts";
|
|
24
|
+
import { formatInit, runInit } from "./init.ts";
|
|
25
|
+
import { ensureInitCommand } from "./command.ts";
|
|
26
|
+
import {
|
|
27
|
+
afterTool,
|
|
28
|
+
checkNewSession,
|
|
29
|
+
evidenceCalls,
|
|
30
|
+
firstFrontierTap,
|
|
31
|
+
hasEngagement,
|
|
32
|
+
hasGateCheck,
|
|
33
|
+
markFrontierTap,
|
|
34
|
+
markTurn,
|
|
35
|
+
onCompacting,
|
|
36
|
+
} from "./triggers.ts";
|
|
37
|
+
import { getCurrent, setCurrent } from "./state.ts";
|
|
38
|
+
import { readLines } from "./logfmt.ts";
|
|
39
|
+
|
|
40
|
+
const TYPES = ["decision", "learning", "preference", "blocker", "context", "pattern"] as const;
|
|
41
|
+
const STATUSES = ["open", "settled"] as const;
|
|
42
|
+
|
|
43
|
+
// --- minimal JSON Schema helpers (OpenCode V2 tools take JSON Schema) --------
|
|
44
|
+
type JSONSchema = Record<string, unknown>;
|
|
45
|
+
const str = (): JSONSchema => ({ type: "string" });
|
|
46
|
+
const bool = (): JSONSchema => ({ type: "boolean" });
|
|
47
|
+
const num = (): JSONSchema => ({ type: "number" });
|
|
48
|
+
const strArray = (): JSONSchema => ({ type: "array", items: { type: "string" } });
|
|
49
|
+
const en = (values: readonly string[]): JSONSchema => ({ type: "string", enum: [...values] });
|
|
50
|
+
function obj(properties: Record<string, JSONSchema>, required: string[] = []): JSONSchema {
|
|
51
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
52
|
+
}
|
|
53
|
+
function withDesc(schema: JSONSchema, description: string): JSONSchema {
|
|
54
|
+
return { ...schema, description };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function scoreMemory(m: Memory, q: string): number {
|
|
58
|
+
const hay = `${m.type} ${m.scope} ${m.content}`.toLowerCase();
|
|
59
|
+
let score = 0;
|
|
60
|
+
for (const w of q.toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
61
|
+
if (hay.includes(w)) score++;
|
|
62
|
+
if (m.scope.toLowerCase() === w) score += 2;
|
|
63
|
+
if (m.type.toLowerCase() === w) score += 2;
|
|
64
|
+
}
|
|
65
|
+
return score;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- OpenCode V2 runtime shapes (loose: we only read a few fields) ----------
|
|
69
|
+
interface ToolDef {
|
|
70
|
+
name: string;
|
|
71
|
+
description: string;
|
|
72
|
+
input: JSONSchema;
|
|
73
|
+
options?: { codemode?: boolean; namespace?: string };
|
|
74
|
+
execute: (args: any, context: any) => Promise<{ content: string }>;
|
|
75
|
+
}
|
|
76
|
+
interface ToolEditorLike {
|
|
77
|
+
add: (tool: ToolDef) => void;
|
|
78
|
+
}
|
|
79
|
+
interface HookReg {
|
|
80
|
+
dispose?: () => Promise<void> | void;
|
|
81
|
+
}
|
|
82
|
+
interface PluginCtx {
|
|
83
|
+
location?: { directory?: string };
|
|
84
|
+
tool?: {
|
|
85
|
+
transform: (cb: (editor: ToolEditorLike) => void) => Promise<unknown>;
|
|
86
|
+
hook: (name: string, cb: (event: any) => Promise<void> | void) => Promise<HookReg | void>;
|
|
87
|
+
};
|
|
88
|
+
command?: {
|
|
89
|
+
transform: (cb: (editor: { add: (cmd: Record<string, unknown>) => void }) => void) => Promise<unknown>;
|
|
90
|
+
};
|
|
91
|
+
session?: {
|
|
92
|
+
hook: (name: string, cb: (event: any) => Promise<void> | void) => Promise<HookReg | void>;
|
|
93
|
+
prompt: (input: {
|
|
94
|
+
sessionID: string;
|
|
95
|
+
text: string;
|
|
96
|
+
delivery?: "steer" | "queue";
|
|
97
|
+
}) => Promise<unknown>;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Best-effort extraction of a tool's textual output + metadata from the V2
|
|
102
|
+
// execute.after `result` payload, whose exact encoding varies by tool.
|
|
103
|
+
function resultText(result: unknown): string {
|
|
104
|
+
if (typeof result === "string") return result;
|
|
105
|
+
if (!result || typeof result !== "object") return "";
|
|
106
|
+
const r = result as Record<string, unknown>;
|
|
107
|
+
for (const k of ["output", "text", "content", "stdout"]) {
|
|
108
|
+
const v = r[k];
|
|
109
|
+
if (typeof v === "string") return v;
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(r.content)) {
|
|
112
|
+
return (r.content as Array<Record<string, unknown>>)
|
|
113
|
+
.map((c) => (typeof c?.text === "string" ? c.text : ""))
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.join("\n");
|
|
116
|
+
}
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
function resultMeta(result: unknown): Record<string, unknown> {
|
|
120
|
+
if (result && typeof result === "object") {
|
|
121
|
+
const m = (result as Record<string, unknown>).metadata;
|
|
122
|
+
if (m && typeof m === "object") return m as Record<string, unknown>;
|
|
123
|
+
}
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const PLUGIN_ID = "opencode-self-improvement";
|
|
128
|
+
|
|
129
|
+
const plugin = {
|
|
130
|
+
id: PLUGIN_ID,
|
|
131
|
+
async setup(ctx: PluginCtx): Promise<void> {
|
|
132
|
+
const liveRoot = ctx?.location?.directory ?? process.cwd();
|
|
133
|
+
|
|
134
|
+
// Mirror the /repo-init slash command into opencode's global commands dir
|
|
135
|
+
// (idempotent, write-if-missing). OpenCode 2.x loads `*.md` from
|
|
136
|
+
// ~/.config/opencode/commands, so this is the canonical V2 install path.
|
|
137
|
+
await ensureInitCommand();
|
|
138
|
+
|
|
139
|
+
// Fail-closed hook guard: the plugin's own bookkeeping can never take down a
|
|
140
|
+
// tool call, the system transform, or compaction.
|
|
141
|
+
async function guarded(name: string, fn: () => Promise<void>): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
await fn();
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn(`[opencode-self-improvement] ${name} hook failed (ignored):`, err);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const gateContext = (): GateContext => ({
|
|
150
|
+
liveRoot,
|
|
151
|
+
evidenceCalls: evidenceCalls(getCurrent().sid),
|
|
152
|
+
hasGateCheck: hasGateCheck(getCurrent().sid),
|
|
153
|
+
hasEngagement: hasEngagement(getCurrent().sid),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ---- tools --------------------------------------------------------------
|
|
157
|
+
await ctx.tool?.transform((editor) => {
|
|
158
|
+
const direct = { codemode: false } as const;
|
|
159
|
+
|
|
160
|
+
editor.add({
|
|
161
|
+
name: "memory_remember",
|
|
162
|
+
description:
|
|
163
|
+
"Store a memory after the 4-gate write pipeline validates it (canonical graph, stale refs, dupes). Blocked writes record evidence instead of persisting.",
|
|
164
|
+
options: direct,
|
|
165
|
+
input: obj(
|
|
166
|
+
{
|
|
167
|
+
type: withDesc(en(TYPES), "Type of memory"),
|
|
168
|
+
scope: withDesc(str(), "Scope/area (e.g., auth, api, ctl)"),
|
|
169
|
+
content: withDesc(str(), "The memory content"),
|
|
170
|
+
issue: withDesc(str(), "Related issue"),
|
|
171
|
+
tags: strArray(),
|
|
172
|
+
status: withDesc(
|
|
173
|
+
en(STATUSES),
|
|
174
|
+
"open = live planning thread / frontier (surfaced at session start); settled or omitted = durable knowledge",
|
|
175
|
+
),
|
|
176
|
+
},
|
|
177
|
+
["type", "scope", "content"],
|
|
178
|
+
),
|
|
179
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
180
|
+
const candidate: WriteCandidate = {
|
|
181
|
+
type: args.type,
|
|
182
|
+
scope: args.scope,
|
|
183
|
+
content: args.content,
|
|
184
|
+
issue: args.issue,
|
|
185
|
+
tags: args.tags,
|
|
186
|
+
status: args.status,
|
|
187
|
+
};
|
|
188
|
+
const outcome = await runWriteGate(candidate, gateContext());
|
|
189
|
+
if (outcome.action === "promoted") return { content: `Remembered: ${args.type} in ${args.scope}` };
|
|
190
|
+
if (outcome.action === "collapsed") return { content: `Duplicate ignored: ${args.type}/${args.scope}` };
|
|
191
|
+
return {
|
|
192
|
+
content: `Write blocked at GATE (${outcome.reason}). ${
|
|
193
|
+
outcome.hint ?? "Evidence recorded; inspect with memory_evidence."
|
|
194
|
+
}`,
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
editor.add({
|
|
200
|
+
name: "memory_init",
|
|
201
|
+
description:
|
|
202
|
+
"Initialize repository memory and knowledge graph. Ensures the codebase-memory graph for this repo is indexed (one-shot `cli` spawn; no session MCP needed) and re-derives build/lint/test/typecheck command memories through the write gate, then stores an init stamp keyed on git HEAD. Idempotent: a no-op when the stamp matches HEAD, unless force=true (refresh after a refactor).",
|
|
203
|
+
options: direct,
|
|
204
|
+
input: obj({
|
|
205
|
+
force: withDesc(bool(), "Re-run even if initialized (refresh after drift)"),
|
|
206
|
+
mode: withDesc(en(["fast", "moderate", "full"]), "codebase-memory index mode (default moderate)"),
|
|
207
|
+
}),
|
|
208
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
209
|
+
const summary = await runInit({ liveRoot, force: args.force, mode: args.mode });
|
|
210
|
+
return { content: formatInit(summary) };
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
editor.add({
|
|
215
|
+
name: "memory_recall",
|
|
216
|
+
description:
|
|
217
|
+
"Look up durable project knowledge — build/test/lint commands, decisions, patterns, preferences, blockers — BEFORE searching the repo or reading files. Use for 'how do I run X', conventions, and past decisions. By scope, type, or free-text query; with no filters it returns the most recent memories.",
|
|
218
|
+
options: direct,
|
|
219
|
+
input: obj({ scope: str(), type: en(TYPES), query: str(), limit: num() }),
|
|
220
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
221
|
+
let results = (await loadAll()).filter(isActive);
|
|
222
|
+
if (!results.length) return { content: "No memories found" };
|
|
223
|
+
const total = results.length;
|
|
224
|
+
if (args.scope) results = results.filter((m) => m.scope.includes(args.scope!));
|
|
225
|
+
if (args.type) results = results.filter((m) => m.type === args.type);
|
|
226
|
+
if (args.query) {
|
|
227
|
+
results = results
|
|
228
|
+
.map((m) => ({ m, score: scoreMemory(m, args.query!) }))
|
|
229
|
+
.filter((x) => x.score > 0)
|
|
230
|
+
.sort((a, b) => b.score - a.score)
|
|
231
|
+
.map((x) => x.m);
|
|
232
|
+
}
|
|
233
|
+
const limit = args.limit || 20;
|
|
234
|
+
const limited = args.query ? results.slice(0, limit) : results.slice(-limit);
|
|
235
|
+
if (!limited.length) return { content: "No matching memories" };
|
|
236
|
+
const openIn = limited.filter((m) => m.status === "open").length;
|
|
237
|
+
if (openIn > 0) {
|
|
238
|
+
const cur = getCurrent();
|
|
239
|
+
markFrontierTap(cur.sid, cur.call);
|
|
240
|
+
await record("frontier_tapped", {
|
|
241
|
+
n_open: openIn,
|
|
242
|
+
in_results: limited.length,
|
|
243
|
+
turn: firstFrontierTap(cur.sid)?.turn,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return { content: `Found ${results.length} (${total} total)\n\n${limited.map(formatMemory).join("\n")}` };
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
editor.add({
|
|
251
|
+
name: "memory_update",
|
|
252
|
+
description: "Update an existing memory (gated like remember; the prior entry is superseded, not erased).",
|
|
253
|
+
options: direct,
|
|
254
|
+
input: obj(
|
|
255
|
+
{ scope: str(), type: en(TYPES), content: str(), query: str(), status: en(STATUSES) },
|
|
256
|
+
["scope", "type", "content"],
|
|
257
|
+
),
|
|
258
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
259
|
+
const prior = (await loadAll()).filter(
|
|
260
|
+
(m) => m.scope === args.scope && m.type === args.type && isActive(m),
|
|
261
|
+
);
|
|
262
|
+
const target = prior[prior.length - 1];
|
|
263
|
+
const outcome = await runWriteGate(
|
|
264
|
+
{ type: args.type, scope: args.scope, content: args.content, status: args.status },
|
|
265
|
+
gateContext(),
|
|
266
|
+
);
|
|
267
|
+
if (outcome.action === "promoted" && outcome.ts && target) {
|
|
268
|
+
await markSuperseded(target.ts, { superseded_by: outcome.ts, reason: "update" });
|
|
269
|
+
return { content: `Updated ${args.type} in ${args.scope}` };
|
|
270
|
+
}
|
|
271
|
+
if (outcome.action === "promoted") return { content: `Remembered: ${args.type} in ${args.scope}` };
|
|
272
|
+
if (outcome.action === "collapsed") return { content: `Duplicate ignored: ${args.type}/${args.scope}` };
|
|
273
|
+
return { content: `Write blocked at GATE (${outcome.reason}). ${outcome.hint ?? ""}`.trimEnd() };
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
editor.add({
|
|
278
|
+
name: "memory_forget",
|
|
279
|
+
description:
|
|
280
|
+
"Delete memories by scope and type. Snapshots first, logs the reason for audit, never touches a repo.",
|
|
281
|
+
options: direct,
|
|
282
|
+
input: obj({ scope: str(), type: en(TYPES), reason: withDesc(str(), "Why this is being deleted (audit)") }, [
|
|
283
|
+
"scope",
|
|
284
|
+
"type",
|
|
285
|
+
"reason",
|
|
286
|
+
]),
|
|
287
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
288
|
+
const res = await pruneMemory(args.scope, args.type, args.reason);
|
|
289
|
+
if (!res.deleted.length) return { content: `No memories found for ${args.type} in ${args.scope}` };
|
|
290
|
+
return { content: `Deleted ${res.deleted.length} ${args.type} memory(s) from ${args.scope}. Audit: ${res.audit}` };
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
editor.add({
|
|
295
|
+
name: "memory_list",
|
|
296
|
+
description: "List memory scopes and types for discovery.",
|
|
297
|
+
options: direct,
|
|
298
|
+
input: obj({}),
|
|
299
|
+
async execute(): Promise<{ content: string }> {
|
|
300
|
+
const mems = (await loadAll()).filter(isActive);
|
|
301
|
+
if (!mems.length) return { content: "No memories found" };
|
|
302
|
+
const scopes = new Map<string, number>();
|
|
303
|
+
const types = new Map<string, number>();
|
|
304
|
+
for (const m of mems) {
|
|
305
|
+
scopes.set(m.scope, (scopes.get(m.scope) ?? 0) + 1);
|
|
306
|
+
types.set(m.type, (types.get(m.type) ?? 0) + 1);
|
|
307
|
+
}
|
|
308
|
+
const fmt = (map: Map<string, number>) =>
|
|
309
|
+
[...map.entries()].sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
|
|
310
|
+
return { content: `Total: ${mems.length}\n\nScopes:\n${fmt(scopes)}\n\nTypes:\n${fmt(types)}` };
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
editor.add({
|
|
315
|
+
name: "memory_undo",
|
|
316
|
+
description: "List memory snapshots, or restore one by name (rollback).",
|
|
317
|
+
options: direct,
|
|
318
|
+
input: obj({
|
|
319
|
+
list: withDesc(bool(), "List snapshots"),
|
|
320
|
+
snapshot: withDesc(str(), "Snapshot file to restore"),
|
|
321
|
+
}),
|
|
322
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
323
|
+
if (args.snapshot) {
|
|
324
|
+
const target = await restoreSnapshot(args.snapshot, memoryRoot());
|
|
325
|
+
return { content: `Restored ${args.snapshot} -> ${target}` };
|
|
326
|
+
}
|
|
327
|
+
const snaps = await listSnapshots();
|
|
328
|
+
if (!snaps.length) return { content: "No snapshots" };
|
|
329
|
+
return { content: snaps.map((s) => `${s.name} (${s.bytes} bytes)`).join("\n") };
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
editor.add({
|
|
334
|
+
name: "memory_evidence",
|
|
335
|
+
description: "Read captured evidence (data only) for a date, optionally filtered by event.",
|
|
336
|
+
options: direct,
|
|
337
|
+
input: obj({ date: str(), ev: str(), limit: num() }),
|
|
338
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
339
|
+
const file = join(evidenceDir(), `${args.date || dateStr()}.logfmt`);
|
|
340
|
+
let lines = await readLines(file);
|
|
341
|
+
if (args.ev) lines = lines.filter((l) => l.includes(`ev=${args.ev}`));
|
|
342
|
+
if (!lines.length) return { content: "No evidence" };
|
|
343
|
+
const limit = args.limit || 50;
|
|
344
|
+
return { content: lines.slice(-limit).join("\n") };
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
editor.add({
|
|
349
|
+
name: "memory_merge_review",
|
|
350
|
+
description:
|
|
351
|
+
"Resolve an open merge/review gate non-destructively: mark the loser as superseded_by the survivor. Both rows stay active data (the loser is recoverable via memory_undo); nothing is deleted. Fails closed (no partial apply) when the reason or the survivor id is missing, or the loser is a canonical/settled/thin row that must not be superseded.",
|
|
352
|
+
options: direct,
|
|
353
|
+
input: obj(
|
|
354
|
+
{
|
|
355
|
+
loser: withDesc(str(), "Evidence id of the rival/overlap write to supersede"),
|
|
356
|
+
survivor: withDesc(str(), "Evidence id of the canonical winning write"),
|
|
357
|
+
reason: withDesc(str(), "Why the survivor is canonical over the loser (written to evidence)"),
|
|
358
|
+
},
|
|
359
|
+
["loser", "survivor", "reason"],
|
|
360
|
+
),
|
|
361
|
+
async execute(args: any): Promise<{ content: string }> {
|
|
362
|
+
if (!args.loser || !args.survivor || !args.reason) {
|
|
363
|
+
return { content: "merge_review blocked: loser, survivor, and reason are all required (fail-closed)." };
|
|
364
|
+
}
|
|
365
|
+
const ok = await markSuperseded(args.loser, {
|
|
366
|
+
superseded_by: args.survivor,
|
|
367
|
+
reason: args.reason,
|
|
368
|
+
symbol: args.loser,
|
|
369
|
+
from: args.loser,
|
|
370
|
+
to: args.survivor,
|
|
371
|
+
});
|
|
372
|
+
if (!ok) {
|
|
373
|
+
return { content: "merge_review: loser not found or already superseded — nothing to resolve. No evidence written." };
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
content: `Resolved merge: ${args.survivor} is canonical; ${args.loser} superseded (recoverable via memory_undo). Evidence recorded for reason='${args.reason}'.`,
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Per-session durable-memory blocks stashed by the prompt hook and pushed
|
|
383
|
+
// onto the system prompt by the context hook (invisible to the user).
|
|
384
|
+
const pendingKnowledge = new Map<string, string>();
|
|
385
|
+
|
|
386
|
+
// ---- hooks (all fail-closed) -------------------------------------------
|
|
387
|
+
// Bookkeeping: track the active session/tool/call before a tool runs.
|
|
388
|
+
await ctx.tool?.hook("execute.before", (event: any) =>
|
|
389
|
+
guarded("tool.execute.before", async () => {
|
|
390
|
+
setCurrent({
|
|
391
|
+
sessionID: event?.sessionID,
|
|
392
|
+
callID: event?.callID ?? event?.id,
|
|
393
|
+
tool: event?.tool,
|
|
394
|
+
});
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
await ctx.tool?.hook("execute.after", (event: any) =>
|
|
399
|
+
guarded("tool.execute.after", async () => {
|
|
400
|
+
const input = {
|
|
401
|
+
tool: event?.tool,
|
|
402
|
+
sessionID: event?.sessionID,
|
|
403
|
+
callID: event?.callID ?? event?.id,
|
|
404
|
+
args: event?.input,
|
|
405
|
+
};
|
|
406
|
+
setCurrent(input);
|
|
407
|
+
const out = { output: resultText(event?.result), metadata: resultMeta(event?.result) };
|
|
408
|
+
await afterTool(input, out);
|
|
409
|
+
await contextualPull(input, out as { output?: unknown });
|
|
410
|
+
// Write a contextual-pull append back when the tool result is text we own.
|
|
411
|
+
if (event?.result && typeof event.result === "object" && typeof event.result.content === "string") {
|
|
412
|
+
const text = resultText(event.result);
|
|
413
|
+
if (out.output && out.output !== text) event.result.content = out.output;
|
|
414
|
+
}
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// Tiny system digest + open-thread frontier block, injected into the system prompt.
|
|
419
|
+
await ctx.session?.hook("context", (event: any) =>
|
|
420
|
+
guarded("session.context (systemDigest)", async () => {
|
|
421
|
+
const parts: string[] = [];
|
|
422
|
+
await systemDigest({ sessionID: event?.sessionID }, { system: parts });
|
|
423
|
+
for (const text of parts) event?.system?.push?.({ type: "text", text });
|
|
424
|
+
// Deliver this turn's relevance-ranked memory via the system channel.
|
|
425
|
+
// The UI never renders system parts, unlike appended prompt text.
|
|
426
|
+
const sid = event?.sessionID;
|
|
427
|
+
const knowledge = typeof sid === "string" ? pendingKnowledge.get(sid) : undefined;
|
|
428
|
+
if (knowledge) event?.system?.push?.({ type: "text", text: knowledge });
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
// Memory-first injection: compute relevance against each user prompt and
|
|
433
|
+
// stash the block for invisible delivery via the context hook. Never mutate
|
|
434
|
+
// event.prompt.text — it is stored verbatim and rendered in the UI.
|
|
435
|
+
await ctx.session?.hook("prompt", (event: any) =>
|
|
436
|
+
guarded("session.prompt (inject)", async () => {
|
|
437
|
+
const sid = event?.sessionID;
|
|
438
|
+
if (typeof sid === "string") {
|
|
439
|
+
markTurn(sid);
|
|
440
|
+
await checkNewSession(sid);
|
|
441
|
+
}
|
|
442
|
+
const text = event?.prompt?.text ?? "";
|
|
443
|
+
const block = await relevantKnowledge(text);
|
|
444
|
+
if (typeof sid === "string") {
|
|
445
|
+
if (block) pendingKnowledge.set(sid, block);
|
|
446
|
+
else pendingKnowledge.delete(sid);
|
|
447
|
+
}
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
// Session-convergence rollup at compaction.
|
|
452
|
+
await ctx.session?.hook("compaction", (event: any) =>
|
|
453
|
+
guarded("session.compaction (onCompacting)", async () => {
|
|
454
|
+
await onCompacting({ sessionID: event?.sessionID });
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
export const SelfImprovement = plugin;
|
|
461
|
+
export default plugin;
|