opencode-self-improvement 0.1.0 → 0.1.1
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/README.md +5 -0
- package/package.json +3 -2
- package/src/index.ts +56 -8
- package/src/memory.ts +87 -3
package/README.md
CHANGED
|
@@ -105,6 +105,11 @@ Ongoing use is just the memory tools. Ask the agent to remember something and it
|
|
|
105
105
|
sticks across sessions; check what's stored with `memory_list`, refine with
|
|
106
106
|
`memory_update`, retract with `memory_forget`, roll back with `memory_undo`.
|
|
107
107
|
|
|
108
|
+
`memory_update` takes a `query` that picks the row: its id, a clock fragment such
|
|
109
|
+
as `00:11:25.867`, or a unique substring of its text. Omit it to update the newest
|
|
110
|
+
entry of that scope and type. A `query` matching nothing, or matching several rows,
|
|
111
|
+
writes nothing and says so rather than updating the wrong memory.
|
|
112
|
+
|
|
108
113
|
## Admin Tool
|
|
109
114
|
|
|
110
115
|
Editing and deleting memories is easy!
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Durable memory for opencode: remember facts, decisions, and commands across sessions, plus a one-command per-repo setup (/repo-init).",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/bun": "latest",
|
|
44
|
+
"semantic-release": "^25.0.9",
|
|
44
45
|
"typescript": "^7.0.2"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
@@ -51,4 +52,4 @@
|
|
|
51
52
|
"typecheck": "bunx tsc --noEmit -p tsconfig.json",
|
|
52
53
|
"memory-view": "bun run src/viewer/server.ts"
|
|
53
54
|
}
|
|
54
|
-
}
|
|
55
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
// logic still lives in the decoupled helper modules.
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { evidenceDir, memoryRoot, dateStr } from "./config.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
clearSuperseded,
|
|
15
|
+
isActive,
|
|
16
|
+
loadAll,
|
|
17
|
+
markSuperseded,
|
|
18
|
+
resolveMemoryTarget,
|
|
19
|
+
type Memory,
|
|
20
|
+
} from "./memory.ts";
|
|
14
21
|
import { runWriteGate, type GateContext, type WriteCandidate } from "./gates.ts";
|
|
15
22
|
import { pruneMemory } from "./prune.ts";
|
|
16
23
|
import { listSnapshots, restoreSnapshot } from "./undo.ts";
|
|
@@ -249,24 +256,58 @@ const plugin = {
|
|
|
249
256
|
|
|
250
257
|
editor.add({
|
|
251
258
|
name: "memory_update",
|
|
252
|
-
description:
|
|
259
|
+
description:
|
|
260
|
+
"Update an existing memory (gated like remember; the prior entry is superseded, not erased). `query` selects which row to update: its exact id (ts), a clock fragment (e.g. HH:MM:SS.mmm), or a unique substring of its content. Omitting `query` targets the most recent active row of scope+type. An unmatched or ambiguous `query` fails closed and writes nothing.",
|
|
253
261
|
options: direct,
|
|
254
262
|
input: obj(
|
|
255
|
-
{
|
|
263
|
+
{
|
|
264
|
+
scope: str(),
|
|
265
|
+
type: en(TYPES),
|
|
266
|
+
content: str(),
|
|
267
|
+
query: withDesc(
|
|
268
|
+
str(),
|
|
269
|
+
"Row selector: id (ts), clock fragment, or unique content substring. Omit to target the newest active row of scope+type.",
|
|
270
|
+
),
|
|
271
|
+
status: en(STATUSES),
|
|
272
|
+
},
|
|
256
273
|
["scope", "type", "content"],
|
|
257
274
|
),
|
|
258
275
|
async execute(args: any): Promise<{ content: string }> {
|
|
259
276
|
const prior = (await loadAll()).filter(
|
|
260
277
|
(m) => m.scope === args.scope && m.type === args.type && isActive(m),
|
|
261
278
|
);
|
|
262
|
-
|
|
279
|
+
// Resolve the row BEFORE the gate: an update that cannot identify its
|
|
280
|
+
// target must write nothing, rather than silently superseding whichever
|
|
281
|
+
// row happened to be newest in this scope+type.
|
|
282
|
+
const resolved = resolveMemoryTarget(prior, args.query);
|
|
283
|
+
if (resolved.status === "not_found") {
|
|
284
|
+
const where = args.query ? ` matches ${JSON.stringify(args.query)}` : "";
|
|
285
|
+
return {
|
|
286
|
+
content: `memory_update blocked: no active ${args.type} row in ${args.scope}${where}. Nothing was written.`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (resolved.status === "ambiguous") {
|
|
290
|
+
return {
|
|
291
|
+
content:
|
|
292
|
+
`memory_update blocked: ${JSON.stringify(args.query)} matches ${resolved.candidates.length} active ` +
|
|
293
|
+
`${args.type} rows in ${args.scope} (${resolved.candidates.map((m) => m.ts).join(", ")}). ` +
|
|
294
|
+
"Pass a row id or a unique content substring. Nothing was written.",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const target = resolved.target;
|
|
263
298
|
const outcome = await runWriteGate(
|
|
264
299
|
{ type: args.type, scope: args.scope, content: args.content, status: args.status },
|
|
265
300
|
gateContext(),
|
|
266
301
|
);
|
|
267
302
|
if (outcome.action === "promoted" && outcome.ts && target) {
|
|
268
|
-
await markSuperseded(target.ts, {
|
|
269
|
-
|
|
303
|
+
await markSuperseded(target.ts, {
|
|
304
|
+
superseded_by: outcome.ts,
|
|
305
|
+
reason: "update",
|
|
306
|
+
symbol: target.ts,
|
|
307
|
+
from: target.ts,
|
|
308
|
+
to: outcome.ts,
|
|
309
|
+
});
|
|
310
|
+
return { content: `Updated ${args.type} in ${args.scope} (superseded ${target.ts})` };
|
|
270
311
|
}
|
|
271
312
|
if (outcome.action === "promoted") return { content: `Remembered: ${args.type} in ${args.scope}` };
|
|
272
313
|
if (outcome.action === "collapsed") return { content: `Duplicate ignored: ${args.type}/${args.scope}` };
|
|
@@ -348,7 +389,7 @@ const plugin = {
|
|
|
348
389
|
editor.add({
|
|
349
390
|
name: "memory_merge_review",
|
|
350
391
|
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
|
|
392
|
+
"Resolve an open merge/review gate non-destructively: mark the loser as superseded_by the survivor, and clear any supersession the survivor itself carries so the canonical row stays recallable. 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 already superseded.",
|
|
352
393
|
options: direct,
|
|
353
394
|
input: obj(
|
|
354
395
|
{
|
|
@@ -372,8 +413,15 @@ const plugin = {
|
|
|
372
413
|
if (!ok) {
|
|
373
414
|
return { content: "merge_review: loser not found or already superseded — nothing to resolve. No evidence written." };
|
|
374
415
|
}
|
|
416
|
+
// The survivor may itself have been superseded earlier (e.g. by a row
|
|
417
|
+
// this very call just made the loser). Declaring it canonical has to
|
|
418
|
+
// make it recallable, so clear its own marker too.
|
|
419
|
+
const revived = await clearSuperseded(args.survivor);
|
|
375
420
|
return {
|
|
376
|
-
content:
|
|
421
|
+
content:
|
|
422
|
+
`Resolved merge: ${args.survivor} is canonical; ${args.loser} superseded (recoverable via memory_undo). ` +
|
|
423
|
+
(revived ? `Survivor's own supersession cleared, it is active again. ` : "") +
|
|
424
|
+
`Evidence recorded for reason='${args.reason}'.`,
|
|
377
425
|
};
|
|
378
426
|
},
|
|
379
427
|
});
|
package/src/memory.ts
CHANGED
|
@@ -140,6 +140,18 @@ export interface SupersedeFields {
|
|
|
140
140
|
entity?: string;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// Copy `file` into snapshots/ before a mutation. Names follow
|
|
144
|
+
// `<stamp>-<original>` because /undo strips the stamp to find its target, so a
|
|
145
|
+
// same-millisecond collision would clobber an earlier, still-undoable snapshot
|
|
146
|
+
// (supersede + clear in one merge_review can land in the same millisecond).
|
|
147
|
+
async function snapshotBefore(file: string): Promise<void> {
|
|
148
|
+
await ensureDir(snapshotDir());
|
|
149
|
+
let stamp = Date.now();
|
|
150
|
+
let snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
|
|
151
|
+
while (await Bun.file(snap).exists()) snap = join(snapshotDir(), `${++stamp}-${basename(file)}`);
|
|
152
|
+
await Bun.write(snap, (await Bun.file(file).text()) || "");
|
|
153
|
+
}
|
|
154
|
+
|
|
143
155
|
// Mark an existing memory as superseded IN PLACE (entry retained, recoverable).
|
|
144
156
|
// Idempotent: a second call is a no-op, so no double `superseded_by`.
|
|
145
157
|
export async function markSuperseded(ts: string, fields: SupersedeFields): Promise<boolean> {
|
|
@@ -161,15 +173,87 @@ export async function markSuperseded(ts: string, fields: SupersedeFields): Promi
|
|
|
161
173
|
entity: fields.entity,
|
|
162
174
|
});
|
|
163
175
|
lines[idx] = `${lines[idx]} ${extra}`;
|
|
164
|
-
await
|
|
165
|
-
|
|
166
|
-
|
|
176
|
+
await snapshotBefore(file);
|
|
177
|
+
await Bun.write(file, lines.join("\n") + "\n");
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Drop a row's supersession markers IN PLACE (snapshot-first), making it active
|
|
184
|
+
// again. Used by memory_merge_review: a declared survivor must be recallable
|
|
185
|
+
// even when an earlier row — now the loser — had superseded it first.
|
|
186
|
+
export async function clearSuperseded(ts: string): Promise<boolean> {
|
|
187
|
+
for (const file of await memoryFiles()) {
|
|
188
|
+
const lines = await readLines(file);
|
|
189
|
+
const idx = lines.findIndex((l) => {
|
|
190
|
+
const f = parseLine(l);
|
|
191
|
+
return f && f.ts === ts;
|
|
192
|
+
});
|
|
193
|
+
if (idx === -1) continue;
|
|
194
|
+
const parsed = parseLine(lines[idx]);
|
|
195
|
+
if (!parsed || typeof parsed.superseded_by !== "string") return false;
|
|
196
|
+
// These five keys are written only by markSuperseded (drift, canonical,
|
|
197
|
+
// update and merge_review), never by appendMemory, so they are all part of
|
|
198
|
+
// the marker and go with it. Field order round-trips because parseLine
|
|
199
|
+
// inserts keys in source order.
|
|
200
|
+
for (const key of ["superseded_by", "reason", "symbol", "from", "to", "entity"]) {
|
|
201
|
+
delete parsed[key];
|
|
202
|
+
}
|
|
203
|
+
lines[idx] = formatLine(parsed);
|
|
204
|
+
await snapshotBefore(file);
|
|
167
205
|
await Bun.write(file, lines.join("\n") + "\n");
|
|
168
206
|
return true;
|
|
169
207
|
}
|
|
170
208
|
return false;
|
|
171
209
|
}
|
|
172
210
|
|
|
211
|
+
export type TargetResolution =
|
|
212
|
+
| { status: "ok"; target: Memory }
|
|
213
|
+
| { status: "not_found" }
|
|
214
|
+
| { status: "ambiguous"; candidates: Memory[] };
|
|
215
|
+
|
|
216
|
+
// Score one row against a query: exact row id first, then content.
|
|
217
|
+
function targetScore(m: Memory, query: string): number {
|
|
218
|
+
const ts = m.ts.toLowerCase();
|
|
219
|
+
if (ts === query) return 100;
|
|
220
|
+
// A clock fragment ("00:11:25.867") is unique enough to identify a row.
|
|
221
|
+
if (query.length >= 8 && ts.includes(query)) return 95;
|
|
222
|
+
const content = m.content.toLowerCase();
|
|
223
|
+
if (content === query) return 90;
|
|
224
|
+
if (content.includes(query)) return 80;
|
|
225
|
+
if (query.includes(content)) return 70;
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Pick the row an update/merge call means. Without a query the caller keeps the
|
|
230
|
+
// legacy "most recent active row of this scope+type" behaviour; with one, a
|
|
231
|
+
// miss or a tie fails CLOSED rather than silently rewriting a different row.
|
|
232
|
+
export function resolveMemoryTarget(rows: Memory[], query?: string): TargetResolution {
|
|
233
|
+
if (!query || !query.trim()) {
|
|
234
|
+
const last = rows[rows.length - 1];
|
|
235
|
+
return last ? { status: "ok", target: last } : { status: "not_found" };
|
|
236
|
+
}
|
|
237
|
+
const q = query.trim().toLowerCase();
|
|
238
|
+
let best: Memory | undefined;
|
|
239
|
+
let bestScore = 0;
|
|
240
|
+
const tied: Memory[] = [];
|
|
241
|
+
for (const m of rows) {
|
|
242
|
+
const score = targetScore(m, q);
|
|
243
|
+
if (score > bestScore) {
|
|
244
|
+
best = m;
|
|
245
|
+
bestScore = score;
|
|
246
|
+
tied.length = 0;
|
|
247
|
+
tied.push(m);
|
|
248
|
+
} else if (score === bestScore && score > 0) {
|
|
249
|
+
tied.push(m);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!best) return { status: "not_found" };
|
|
253
|
+
if (tied.length > 1) return { status: "ambiguous", candidates: tied };
|
|
254
|
+
return { status: "ok", target: best };
|
|
255
|
+
}
|
|
256
|
+
|
|
173
257
|
export interface DeletedMemory {
|
|
174
258
|
memory: Memory;
|
|
175
259
|
reason: string;
|