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 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.0",
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 { loadAll, isActive, markSuperseded, type Memory } from "./memory.ts";
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: "Update an existing memory (gated like remember; the prior entry is superseded, not erased).",
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
- { scope: str(), type: en(TYPES), content: str(), query: str(), status: en(STATUSES) },
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
- const target = prior[prior.length - 1];
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, { superseded_by: outcome.ts, reason: "update" });
269
- return { content: `Updated ${args.type} in ${args.scope}` };
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 a canonical/settled/thin row that must not be superseded.",
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: `Resolved merge: ${args.survivor} is canonical; ${args.loser} superseded (recoverable via memory_undo). Evidence recorded for reason='${args.reason}'.`,
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 ensureDir(snapshotDir());
165
- const snap = join(snapshotDir(), `${Date.now()}-${basename(file)}`);
166
- await Bun.write(snap, (await Bun.file(file).text()) || "");
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;