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/init.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Repository init: ensure the codebase-memory graph exists for this repo and
|
|
2
|
+
// re-derive build/test command memories through the write gate. Deterministic
|
|
3
|
+
// (no model calls): the graph binary is spawned one-shot via `cli`, manifests
|
|
4
|
+
// are parsed locally, and every write goes through the 4-gate pipeline with the
|
|
5
|
+
// `trusted` flag (plugin-authored bookkeeping, not model claims). Degrades
|
|
6
|
+
// gracefully when the binary or git is absent, so install-from-scratch still
|
|
7
|
+
// writes script memories.
|
|
8
|
+
import { basename, join } from "node:path";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { cbmBin } from "./config.ts";
|
|
11
|
+
import { loadAll, markSuperseded } from "./memory.ts";
|
|
12
|
+
import { runWriteGate, type GateContext } from "./gates.ts";
|
|
13
|
+
import { record } from "./evidence.ts";
|
|
14
|
+
import { evidenceCalls, hasGateCheck } from "./triggers.ts";
|
|
15
|
+
import { getCurrent } from "./state.ts";
|
|
16
|
+
|
|
17
|
+
export interface ScriptRecord {
|
|
18
|
+
key: string;
|
|
19
|
+
cmd: string;
|
|
20
|
+
source: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GraphStatus {
|
|
24
|
+
action: "indexed" | "unavailable" | "index-failed";
|
|
25
|
+
name?: string;
|
|
26
|
+
mode: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InitArgs {
|
|
30
|
+
liveRoot: string;
|
|
31
|
+
force?: boolean;
|
|
32
|
+
mode?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface InitSummary {
|
|
36
|
+
scope: string;
|
|
37
|
+
root: string;
|
|
38
|
+
head: string;
|
|
39
|
+
graph: GraphStatus;
|
|
40
|
+
scripts: ScriptRecord[];
|
|
41
|
+
stampTs?: string;
|
|
42
|
+
skipped: boolean;
|
|
43
|
+
written: number;
|
|
44
|
+
collapsed: number;
|
|
45
|
+
blocked: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const NOTABLE_KEYS = ["test", "e2e", "lint", "typecheck", "check", "build", "format"];
|
|
49
|
+
|
|
50
|
+
function isNotable(key: string): boolean {
|
|
51
|
+
return NOTABLE_KEYS.includes(key) || NOTABLE_KEYS.some((k) => key.startsWith(k));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function run(cmd: string[], cwd: string): Promise<{ ok: boolean; out: string }> {
|
|
55
|
+
try {
|
|
56
|
+
const p = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
57
|
+
const [so, se] = await Promise.all([
|
|
58
|
+
new Response(p.stdout).text(),
|
|
59
|
+
new Response(p.stderr).text(),
|
|
60
|
+
]);
|
|
61
|
+
const code = await p.exited;
|
|
62
|
+
return { ok: code === 0, out: (so || se || "").trim() };
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, out: "" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function runSync(cmd: string[], cwd: string): { ok: boolean; out: string } {
|
|
69
|
+
try {
|
|
70
|
+
const p = Bun.spawnSync(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
71
|
+
const out = p.stdout?.toString() ?? "";
|
|
72
|
+
const err = p.stderr?.toString() ?? "";
|
|
73
|
+
return { ok: p.exitCode === 0, out: (out || err).trim() };
|
|
74
|
+
} catch {
|
|
75
|
+
return { ok: false, out: "" };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function gitRootOf(dir: string): Promise<string> {
|
|
80
|
+
const r = runSync(["git", "rev-parse", "--show-toplevel"], dir);
|
|
81
|
+
return r.ok && r.out ? r.out : dir;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function gitHead(root: string): Promise<string> {
|
|
85
|
+
const r = runSync(["git", "rev-parse", "--short", "HEAD"], root);
|
|
86
|
+
return r.ok && r.out ? r.out : "-";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readText(root: string, file: string): Promise<string | null> {
|
|
90
|
+
const p = join(root, file);
|
|
91
|
+
if (!existsSync(p)) return null;
|
|
92
|
+
try {
|
|
93
|
+
return await Bun.file(p).text();
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function detectPackageScripts(root: string, records: ScriptRecord[]): Promise<void> {
|
|
100
|
+
const text = await readText(root, "package.json");
|
|
101
|
+
if (!text) return;
|
|
102
|
+
let pkg: Record<string, unknown>;
|
|
103
|
+
try {
|
|
104
|
+
pkg = JSON.parse(text);
|
|
105
|
+
} catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!pkg.scripts || typeof pkg.scripts !== "object") return;
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
for (const name of Object.keys(pkg.scripts as Record<string, unknown>)) {
|
|
111
|
+
if (seen.has(name) || !isNotable(name.toLowerCase())) continue;
|
|
112
|
+
seen.add(name);
|
|
113
|
+
records.push({ key: name, cmd: `npm run ${name}`, source: "package.json" });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function detectMakeTargets(root: string, records: ScriptRecord[]): Promise<void> {
|
|
118
|
+
const targetRe = /^([A-Za-z0-9_-]+)\s*:/;
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
for (const file of ["Makefile", "Justfile"]) {
|
|
121
|
+
const text = await readText(root, file);
|
|
122
|
+
if (!text) continue;
|
|
123
|
+
for (const line of text.split("\n")) {
|
|
124
|
+
if (/^[\t ]|^\s*#|^\./.test(line)) continue;
|
|
125
|
+
const m = targetRe.exec(line);
|
|
126
|
+
if (!m) continue;
|
|
127
|
+
const name = m[1].toLowerCase();
|
|
128
|
+
if (!isNotable(name) || seen.has(name)) continue;
|
|
129
|
+
seen.add(name);
|
|
130
|
+
const runner = file === "Justfile" ? "just" : "make";
|
|
131
|
+
records.push({ key: name, cmd: `${runner} ${m[1]}`, source: file });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function detectLangManifests(root: string, records: ScriptRecord[]): Promise<void> {
|
|
137
|
+
if (existsSync(join(root, "Cargo.toml"))) {
|
|
138
|
+
records.push({ key: "test", cmd: "cargo test", source: "Cargo.toml" });
|
|
139
|
+
}
|
|
140
|
+
const py = await readText(root, "pyproject.toml");
|
|
141
|
+
if (py && /pytest|tool\.pytest|\[tool\]\.poetry/.test(py)) {
|
|
142
|
+
records.push({ key: "test", cmd: "pytest", source: "pyproject.toml" });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function detectScripts(root: string): Promise<ScriptRecord[]> {
|
|
147
|
+
const records: ScriptRecord[] = [];
|
|
148
|
+
await detectPackageScripts(root, records);
|
|
149
|
+
await detectMakeTargets(root, records);
|
|
150
|
+
await detectLangManifests(root, records);
|
|
151
|
+
return records.slice(0, 8);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function cbmProject(bin: string, root: string): Promise<{ name: string } | null> {
|
|
155
|
+
const r = await run(
|
|
156
|
+
[bin, "cli", "--quiet", "list_projects", "--format", "json", "--metadata-only"],
|
|
157
|
+
root,
|
|
158
|
+
);
|
|
159
|
+
if (!r.ok || !r.out) return null;
|
|
160
|
+
try {
|
|
161
|
+
const env = JSON.parse(r.out) as { projects?: { name: string; root_path: string }[] };
|
|
162
|
+
const hit = env.projects?.find((p) => p.root_path === root);
|
|
163
|
+
return hit ? { name: hit.name } : null;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function ensureGraph(root: string, mode: string, force: boolean): Promise<GraphStatus> {
|
|
170
|
+
const bin = cbmBin() ?? "codebase-memory-mcp";
|
|
171
|
+
const existing = await cbmProject(bin, root);
|
|
172
|
+
if (existing && !force) return { action: "indexed", name: existing.name, mode };
|
|
173
|
+
const r = await run(
|
|
174
|
+
[bin, "cli", "--quiet", "index_repository", "--repo-path", root, "--mode", mode],
|
|
175
|
+
root,
|
|
176
|
+
);
|
|
177
|
+
if (!r.ok) return { action: "unavailable", mode };
|
|
178
|
+
const after = await cbmProject(bin, root);
|
|
179
|
+
return { action: after ? "indexed" : "index-failed", name: after?.name, mode };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface InitStamp {
|
|
183
|
+
ts: string;
|
|
184
|
+
head: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function initStamp(scope: string): Promise<InitStamp | null> {
|
|
188
|
+
const mems = (await loadAll()).filter((m) => !m.superseded_by);
|
|
189
|
+
for (const m of mems) {
|
|
190
|
+
if (m.type !== "context" || m.scope !== scope) continue;
|
|
191
|
+
if (!m.tags?.some((t) => t === "init-stamp")) continue;
|
|
192
|
+
const head = /head=([^ ,]+)/.exec(m.content)?.[1] ?? "-";
|
|
193
|
+
return { ts: m.ts, head };
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function runInit(args: InitArgs): Promise<InitSummary> {
|
|
199
|
+
const root = await gitRootOf(args.liveRoot);
|
|
200
|
+
const scope = basename(root) || basename(args.liveRoot);
|
|
201
|
+
const head = await gitHead(root);
|
|
202
|
+
const force = Boolean(args.force);
|
|
203
|
+
const mode = args.mode ?? "moderate";
|
|
204
|
+
|
|
205
|
+
const gateCtx: GateContext = {
|
|
206
|
+
liveRoot: root,
|
|
207
|
+
evidenceCalls: evidenceCalls(getCurrent().sid),
|
|
208
|
+
hasGateCheck: hasGateCheck(getCurrent().sid),
|
|
209
|
+
trusted: true,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const prior = await initStamp(scope);
|
|
213
|
+
if (prior && !force && prior.head === head) {
|
|
214
|
+
await record("init_skip", { scope, head, since: prior.ts }, false);
|
|
215
|
+
return {
|
|
216
|
+
scope,
|
|
217
|
+
root,
|
|
218
|
+
head,
|
|
219
|
+
graph: { action: "indexed", mode },
|
|
220
|
+
scripts: [],
|
|
221
|
+
stampTs: prior.ts,
|
|
222
|
+
skipped: true,
|
|
223
|
+
written: 0,
|
|
224
|
+
collapsed: 0,
|
|
225
|
+
blocked: 0,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await record("init_start", { scope, head, force, mode }, false);
|
|
230
|
+
|
|
231
|
+
const graph = await ensureGraph(root, mode, force);
|
|
232
|
+
const scripts = await detectScripts(root);
|
|
233
|
+
|
|
234
|
+
let written = 0;
|
|
235
|
+
let collapsed = 0;
|
|
236
|
+
let blocked = 0;
|
|
237
|
+
for (const s of scripts) {
|
|
238
|
+
const content = `${scope} ${s.key} command: run "${s.cmd}" from the repo root (${s.source}).`;
|
|
239
|
+
const outcome = await runWriteGate(
|
|
240
|
+
{ type: "pattern", scope, content, tags: ["init", s.source] },
|
|
241
|
+
gateCtx,
|
|
242
|
+
);
|
|
243
|
+
if (outcome.action === "promoted") written++;
|
|
244
|
+
else if (outcome.action === "collapsed") collapsed++;
|
|
245
|
+
else blocked++;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const stampContent = `Init stamp: repo=${scope}, head=${head}, graph=${graph.action}, mode=${graph.mode}, scripts=${scripts.length}`;
|
|
249
|
+
const stampOutcome = await runWriteGate(
|
|
250
|
+
{ type: "context", scope, content: stampContent, tags: ["init-stamp"] },
|
|
251
|
+
gateCtx,
|
|
252
|
+
);
|
|
253
|
+
const stampTs = stampOutcome.ts ?? prior?.ts;
|
|
254
|
+
if (stampOutcome.action === "promoted" && prior && stampOutcome.ts) {
|
|
255
|
+
await markSuperseded(prior.ts, { superseded_by: stampOutcome.ts, reason: "init" });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await record(
|
|
259
|
+
"init_complete",
|
|
260
|
+
{
|
|
261
|
+
scope,
|
|
262
|
+
head,
|
|
263
|
+
force,
|
|
264
|
+
mode,
|
|
265
|
+
graph: graph.action,
|
|
266
|
+
scripts: scripts.length,
|
|
267
|
+
written,
|
|
268
|
+
collapsed,
|
|
269
|
+
blocked,
|
|
270
|
+
stamp: stampTs ?? "-",
|
|
271
|
+
},
|
|
272
|
+
false,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
scope,
|
|
277
|
+
root,
|
|
278
|
+
head,
|
|
279
|
+
graph,
|
|
280
|
+
scripts,
|
|
281
|
+
stampTs,
|
|
282
|
+
skipped: false,
|
|
283
|
+
written,
|
|
284
|
+
collapsed,
|
|
285
|
+
blocked,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function formatInit(s: InitSummary): string {
|
|
290
|
+
if (s.skipped) {
|
|
291
|
+
return `Already initialized for ${s.scope} at head ${s.head} (stamp ${s.stampTs}). Nothing to do.`;
|
|
292
|
+
}
|
|
293
|
+
const scr = s.scripts.map((r) => `- ${r.source}: "${r.cmd}"`).join("\n");
|
|
294
|
+
return [
|
|
295
|
+
`Initialized ${s.scope} (root ${s.root}, head ${s.head}).`,
|
|
296
|
+
`Graph: ${s.graph.action}${s.graph.name ? ` (${s.graph.name})` : ""} [mode=${s.graph.mode}]`,
|
|
297
|
+
scr ? `Scripts (${s.scripts.length}):\n${scr}` : "Scripts: none detected",
|
|
298
|
+
`Memories: written=${s.written}, collapsed=${s.collapsed}, blocked=${s.blocked}; stamp=${s.stampTs ?? "-"}`,
|
|
299
|
+
].join("\n");
|
|
300
|
+
}
|
package/src/inject.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Context surfaces. The system digest is TINY (≤2 lines): durable knowledge is
|
|
2
|
+
// pulled on demand, not pushed wholesale. Deterministic memory-first injection
|
|
3
|
+
// rides chat.message (synthetic parts), so relevant knowledge is in the model's
|
|
4
|
+
// context before it chooses any tool. Contextual pull rides grep/glob/read
|
|
5
|
+
// results. Evidence is never injected (invariant I3). Open threads (status=
|
|
6
|
+
// "open") get a single capped frontier block in the digest so planning sessions
|
|
7
|
+
// start at the previous session's frontier instead of re-deriving it.
|
|
8
|
+
import { isActive, loadAll, type Memory } from "./memory.ts";
|
|
9
|
+
import { record } from "./evidence.ts";
|
|
10
|
+
import { dateStr } from "./config.ts";
|
|
11
|
+
import { bracketList } from "./logfmt.ts";
|
|
12
|
+
import { recentConvergenceSummary } from "./triggers.ts";
|
|
13
|
+
|
|
14
|
+
export interface TransformOutput {
|
|
15
|
+
system: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PullInput {
|
|
19
|
+
tool?: unknown;
|
|
20
|
+
args?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PullOutput {
|
|
24
|
+
output?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function dateOf(ts: string): string {
|
|
28
|
+
return ts.split("T")[0];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatMemory(m: Memory): string {
|
|
32
|
+
const tag = m.status === "open" ? " [open]" : "";
|
|
33
|
+
return `- [${dateOf(m.ts)}]${tag} ${m.type}/${m.scope}: ${m.content}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function argText(args: unknown): string {
|
|
37
|
+
if (!args || typeof args !== "object") return "";
|
|
38
|
+
const a = args as Record<string, unknown>;
|
|
39
|
+
for (const k of ["pattern", "query", "path", "filePath", "command"]) {
|
|
40
|
+
const v = a[k];
|
|
41
|
+
if (typeof v === "string" && v) return v;
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function words(text: string): string[] {
|
|
47
|
+
return text
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.split(/[^a-z0-9_]+/)
|
|
50
|
+
.filter((t) => t.length > 2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scoreMemories(term: string, mems: Memory[], limit: number): Memory[] {
|
|
54
|
+
const query = new Set(words(term));
|
|
55
|
+
if (!query.size) return [];
|
|
56
|
+
return mems
|
|
57
|
+
.map((m) => {
|
|
58
|
+
const hay = new Set(words(`${m.scope} ${m.type} ${m.content}`));
|
|
59
|
+
let score = 0;
|
|
60
|
+
for (const w of query) if (hay.has(w)) score++;
|
|
61
|
+
return { m, score };
|
|
62
|
+
})
|
|
63
|
+
.filter((s) => s.score > 0)
|
|
64
|
+
.sort((a, b) => b.score - a.score || openRank(b.m) - openRank(a.m))
|
|
65
|
+
.slice(0, limit)
|
|
66
|
+
.map((s) => s.m);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function openRank(m: Memory): number {
|
|
70
|
+
return m.status === "open" ? 1 : 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only the newest open threads are surfaced, and each session sees the block
|
|
74
|
+
// once (per process), so the frontier costs a bounded number of tokens.
|
|
75
|
+
const OPEN_THREAD_LIMIT = 3;
|
|
76
|
+
const frontierInjectedSessions = new Set<string>();
|
|
77
|
+
|
|
78
|
+
export async function systemDigest(
|
|
79
|
+
_input: unknown,
|
|
80
|
+
output: TransformOutput,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const mems = (await loadAll()).filter(isActive);
|
|
83
|
+
if (!mems.length) return;
|
|
84
|
+
const latest = mems.map((m) => m.ts).sort().pop() ?? "?";
|
|
85
|
+
output.system.push(
|
|
86
|
+
"\n## Persistent memory (opencode-self-improvement)\n" +
|
|
87
|
+
`${mems.length} durable memories; latest: ${latest}. ` +
|
|
88
|
+
"Check memory_recall BEFORE grepping or reading, for build/test/lint commands, decisions, and conventions. Relevant memories are also auto-injected when your message matches.",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const open = mems
|
|
92
|
+
.filter((m) => m.status === "open")
|
|
93
|
+
.sort((a, b) => (a.ts < b.ts ? 1 : -1))
|
|
94
|
+
.slice(0, OPEN_THREAD_LIMIT);
|
|
95
|
+
if (!open.length) return;
|
|
96
|
+
|
|
97
|
+
output.system.push(
|
|
98
|
+
"\n## Open threads (frontier)\n" + open.map(formatMemory).join("\n"),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Aggregate of past sessions' convergence (one line, gated on data existing;
|
|
102
|
+
// I3: it is a derived fact, never the raw log). Gives the next session the
|
|
103
|
+
// signal to recall-first and stop re-deriving.
|
|
104
|
+
const conv = await recentConvergenceSummary();
|
|
105
|
+
if (conv) output.system.push(`\nConvergence tracking: ${conv}`);
|
|
106
|
+
|
|
107
|
+
const sid =
|
|
108
|
+
typeof _input === "object" && _input !== null &&
|
|
109
|
+
"sessionID" in _input &&
|
|
110
|
+
typeof (_input as Record<string, unknown>).sessionID === "string"
|
|
111
|
+
? String((_input as Record<string, unknown>).sessionID)
|
|
112
|
+
: "?";
|
|
113
|
+
if (frontierInjectedSessions.has(sid)) return;
|
|
114
|
+
frontierInjectedSessions.add(sid);
|
|
115
|
+
const openMems = mems.filter((m) => m.status === "open");
|
|
116
|
+
await record(
|
|
117
|
+
"frontier_injected",
|
|
118
|
+
{
|
|
119
|
+
session: sid,
|
|
120
|
+
scopes: bracketList([...new Set(open.map((m) => m.scope))]),
|
|
121
|
+
shown: open.length,
|
|
122
|
+
open_total: openMems.length,
|
|
123
|
+
ts_since: dateStr(),
|
|
124
|
+
},
|
|
125
|
+
false,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function relevantKnowledge(message: string, limit = 3): Promise<string> {
|
|
130
|
+
const text = message.trim();
|
|
131
|
+
if (!text) return "";
|
|
132
|
+
const mems = (await loadAll()).filter(isActive);
|
|
133
|
+
const top = scoreMemories(text, mems, limit);
|
|
134
|
+
if (!top.length) return "";
|
|
135
|
+
return `## Relevant durable memory\n${top.map(formatMemory).join("\n")}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function contextualPull(input: PullInput, output: PullOutput): Promise<void> {
|
|
139
|
+
const tool = typeof input.tool === "string" ? input.tool : "";
|
|
140
|
+
if (!["grep", "glob", "read"].includes(tool)) return;
|
|
141
|
+
const term = argText(input.args);
|
|
142
|
+
if (!term) return;
|
|
143
|
+
const mems = (await loadAll()).filter(isActive);
|
|
144
|
+
const top = scoreMemories(term, mems, 3);
|
|
145
|
+
if (!top.length) return;
|
|
146
|
+
const block = top.map(formatMemory).join("\n");
|
|
147
|
+
if (typeof output.output === "string") {
|
|
148
|
+
output.output += `\n## Related durable memory (pull)\n${block}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/logfmt.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Minimal logfmt reader/writer shared by the durable store and the evidence
|
|
2
|
+
// log. Field order is preserved on write so chains are diffable.
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
export type Fields = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export function quote(value: unknown): string {
|
|
9
|
+
const s = String(value);
|
|
10
|
+
if (s === "") return '""';
|
|
11
|
+
return /[\s"=]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatLine(fields: Fields): string {
|
|
15
|
+
const parts: string[] = [];
|
|
16
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
17
|
+
if (v === undefined || v === null) continue;
|
|
18
|
+
parts.push(`${k}=${quote(v)}`);
|
|
19
|
+
}
|
|
20
|
+
return parts.join(" ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Unescape a quoted logfmt value and read it back.
|
|
24
|
+
function unquote(s: string): string {
|
|
25
|
+
return s.replace(/\\"/g, '"');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseLine(line: string): Fields | null {
|
|
29
|
+
const trimmed = line.trim();
|
|
30
|
+
if (!trimmed || !trimmed.includes("=")) return null;
|
|
31
|
+
const fields: Fields = {};
|
|
32
|
+
const re = /([A-Za-z0-9_.-]+)=("(?:[^"\\]|\\.)*"|[^\s]*)/g;
|
|
33
|
+
let m: RegExpExecArray | null;
|
|
34
|
+
while ((m = re.exec(trimmed)) !== null) {
|
|
35
|
+
const key = m[1];
|
|
36
|
+
let raw = m[2];
|
|
37
|
+
if (raw.startsWith('"') && raw.endsWith('"')) {
|
|
38
|
+
raw = unquote(raw.slice(1, -1));
|
|
39
|
+
}
|
|
40
|
+
fields[key] = raw;
|
|
41
|
+
}
|
|
42
|
+
// parse booleans/numbers as strings is fine; callers coerce.
|
|
43
|
+
return fields;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function ensureDir(path: string): Promise<void> {
|
|
47
|
+
await mkdir(path, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function appendLog(file: string, line: string): Promise<void> {
|
|
51
|
+
await ensureDir(dirname(file));
|
|
52
|
+
const next = line.endsWith("\n") ? line : line + "\n";
|
|
53
|
+
const f = Bun.file(file);
|
|
54
|
+
const existing = (await f.exists()) ? await f.text() : "";
|
|
55
|
+
await Bun.write(file, existing + next);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function readLines(file: string): Promise<string[]> {
|
|
59
|
+
const f = Bun.file(file);
|
|
60
|
+
if (!(await f.exists())) return [];
|
|
61
|
+
const text = await f.text();
|
|
62
|
+
return text.split("\n").filter((l) => l.trim().length > 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function bracketList(items: string[]): string {
|
|
66
|
+
return `[${items.join(",")}]`;
|
|
67
|
+
}
|