@modusensus/dsh-mneme 0.7.5 → 0.7.6

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.
@@ -1611,6 +1611,44 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1611
1611
  count: (type, opts) => store.count(type, opts),
1612
1612
  stats: (opts) => store.stats(opts),
1613
1613
  getById: (id) => store.getById(id),
1614
+ // issue #48: resolve a possibly-truncated id to its canonical full id.
1615
+ // Exact hit wins; otherwise the input is treated as a prefix of the id
1616
+ // PRIMARY KEY. Never guesses on ambiguity — returns the candidates and the
1617
+ // caller must pass a full id. Outcome is {ok:true,id} or {ok:false,reason,
1618
+ // message} with reason ∈ invalid | not-found | ambiguous. warnMiss logs the
1619
+ // silent-miss case the delete tool previously swallowed (no return channel
1620
+ // for it, so observability has to live here in the service layer).
1621
+ resolveMemoryId: (input, { warnMiss = false } = {}) => {
1622
+ const bad = (reason, message) => ({ ok: false, reason, message });
1623
+ if (typeof input !== "string") return bad("invalid", "memory id is required");
1624
+ // 手抄/上下文压缩来的 id 可能带首尾空白,统一 trim 后再做精确与前缀解析。
1625
+ const id = input.trim();
1626
+ if (!id) return bad("invalid", "memory id is required");
1627
+ const exact = store.getById(id);
1628
+ if (exact) return { ok: true, id: exact.id };
1629
+ const matches = store.listByIdPrefix(id);
1630
+ if (matches.length === 0) {
1631
+ if (warnMiss) {
1632
+ logger?.warn?.(
1633
+ `[dsh-mneme] memory id "${id}" matched nothing (exact or prefix) — ` +
1634
+ "no entry was deleted; ids are full-length, pass one from memory_list/memory_search output or delete by query=…"
1635
+ );
1636
+ }
1637
+ return bad(
1638
+ "not-found",
1639
+ `memory not found: ${id} (checked exact id and prefix; use a full id from memory_list/memory_search output)`
1640
+ );
1641
+ }
1642
+ if (matches.length > 1) {
1643
+ const sample = matches.slice(0, 5).map((m) => m.id);
1644
+ const tail = matches.length > sample.length ? ` …(+${matches.length - sample.length})` : "";
1645
+ return bad(
1646
+ "ambiguous",
1647
+ `memory id prefix "${id}" matches ${matches.length} entries (${sample.join(", ")}${tail}); refusing to guess — pass a full id`
1648
+ );
1649
+ }
1650
+ return { ok: true, id: matches[0].id };
1651
+ },
1614
1652
  remove: (id) => {
1615
1653
  store.remove(id);
1616
1654
  afterSync("write");
@@ -697,6 +697,27 @@ export function createStore(path) {
697
697
  return toRow(row);
698
698
  }
699
699
 
700
+ /**
701
+ * issue #48: resolve a truncated id — as can leak through an agent's context
702
+ * window when list/search output is shortened — by matching it as a prefix of
703
+ * the id PRIMARY KEY. Exact lookups keep using getById; this only serves
704
+ * resolving a *candidate* id. Returns up to 51 rows so the caller can tell
705
+ * "unique" from "ambiguous" without a second query. LIKE wildcards are
706
+ * stripped from the input (a valid id fragment is hex/UUID text, never % or
707
+ * _). Prefix over a PK stays an index scan, so this is cheap even at scale.
708
+ */
709
+ function listByIdPrefix(idPrefix) {
710
+ if (typeof idPrefix !== "string" || !idPrefix.trim()) return [];
711
+ // LIKE 通配符不参与 id 匹配,一律剥掉。若剥完为空(如 id="%"),
712
+ // 不能让 SQL 退化成 `LIKE '%'` 全表命中——那会让单条记忆被误删,
713
+ // 一律视为无匹配返回。
714
+ const safe = idPrefix.replace(/[\\%_]/g, "");
715
+ if (!safe) return [];
716
+ const rows = db.prepare("SELECT * FROM memories WHERE id LIKE ? LIMIT 51")
717
+ .all(`${safe}%`);
718
+ return rows.map(toRow);
719
+ }
720
+
700
721
  /**
701
722
  * Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
702
723
  * folds ASCII case (CJK titles are inherently case-free, so they match
@@ -2210,6 +2231,7 @@ export function createStore(path) {
2210
2231
  count,
2211
2232
  stats,
2212
2233
  getById,
2234
+ listByIdPrefix,
2213
2235
  save,
2214
2236
  update,
2215
2237
  compareAndUpdate,
@@ -302,7 +302,7 @@ export function createTools(ctx, service, config, embedder) {
302
302
  name: "memory_update",
303
303
  description: "Modify an existing memory entry (title, content, type, tags, importance).",
304
304
  parameters: {
305
- id: { type: "string", required: true, description: "Memory id" },
305
+ id: { type: "string", required: true, description: "Memory id (full id from memory_list/memory_search output, or a unique prefix of it)" },
306
306
  title: { type: "string" },
307
307
  content: { type: "string" },
308
308
  type: { type: "string", enum: ["preference", "project", "decision", "history", "user", "fact"] },
@@ -329,7 +329,9 @@ export function createTools(ctx, service, config, embedder) {
329
329
  render: (_args, value) => TEXT_OUTPUT(`Updated memory ${value.memory.id}: ${value.memory.title}`)
330
330
  },
331
331
  async execute(args) {
332
- const memory = service.update(args.id, {
332
+ const resolved = service.resolveMemoryId(args.id);
333
+ if (!resolved.ok) throw new Error(resolved.message);
334
+ const memory = service.update(resolved.id, {
333
335
  title: args.title,
334
336
  content: args.content,
335
337
  type: args.type,
@@ -342,9 +344,9 @@ export function createTools(ctx, service, config, embedder) {
342
344
 
343
345
  defineTool({
344
346
  name: "memory_delete",
345
- description: "Permanently delete a memory entry. Pass id for exact delete, or query to delete the single best-matching entry by text — lets the agent honor 'delete the memory about X' without a prior list/search round trip.",
347
+ description: "Permanently delete a memory entry. Pass id for exact delete (full id, or a unique prefix of it — ambiguous prefixes are rejected), or query to delete the single best-matching entry by text — lets the agent honor 'delete the memory about X' without a prior list/search round trip. An id that matches nothing is logged as a warning instead of failing silently.",
346
348
  parameters: {
347
- id: { type: "string", description: "Exact memory id to delete (from memory_list/memory_search output)" },
349
+ id: { type: "string", description: "Memory id to delete: full id (from memory_list/memory_search output) or a unique prefix of it; a miss is logged (warn) and returns deleted:false" },
348
350
  query: { type: "string", description: "Delete the best-matching entry for this text (searches title/content/tags; uses hybrid recall when an embedder is configured)" }
349
351
  },
350
352
  output: {
@@ -353,13 +355,19 @@ export function createTools(ctx, service, config, embedder) {
353
355
  additionalProperties: false,
354
356
  properties: { deleted: { type: "boolean", required: true } }
355
357
  },
356
- render: (_args, value) => TEXT_OUTPUT(value.deleted ? "Memory deleted." : "Memory not found.")
358
+ render: (_args, value) => TEXT_OUTPUT(value.deleted
359
+ ? "Memory deleted."
360
+ : "Memory not found — nothing was deleted. Pass a full id (or a unique prefix) from memory_list/memory_search output, or delete by query=… instead.")
357
361
  },
358
362
  async execute(args) {
359
363
  if (args.id) {
360
- const existed = service.getById(args.id) !== undefined;
361
- if (existed) service.remove(args.id);
362
- return { deleted: existed };
364
+ const resolved = service.resolveMemoryId(args.id, { warnMiss: true });
365
+ if (!resolved.ok) {
366
+ if (resolved.reason === "ambiguous") throw new Error(resolved.message);
367
+ return { deleted: false };
368
+ }
369
+ service.remove(resolved.id);
370
+ return { deleted: true };
363
371
  }
364
372
  if (args.query) {
365
373
  const [best] = await service.searchMemories(args.query, { mode: "auto", topK: 1, useRerank: true });
@@ -378,7 +386,7 @@ export function createTools(ctx, service, config, embedder) {
378
386
  "Stop a memory from being auto-injected and from appearing in searches and lists without deleting it. " +
379
387
  "The entry stays in storage; pass forgotten: false to restore it.",
380
388
  parameters: {
381
- id: { type: "string", required: true },
389
+ id: { type: "string", required: true, description: "Memory id: full id (from memory_list/memory_search output) or a unique prefix of it; ambiguous prefixes are rejected" },
382
390
  forgotten: { type: "boolean", description: "Suppress (true, default) or restore (false) the entry's visibility" }
383
391
  },
384
392
  output: {
@@ -399,10 +407,9 @@ export function createTools(ctx, service, config, embedder) {
399
407
  render: (_args, value) => TEXT_OUTPUT(`Memory ${value.memory.id} injection ${value.memory.forgotten ? "suppressed" : "restored"}.`)
400
408
  },
401
409
  async execute(args) {
402
- if (service.getById(args.id) === undefined) {
403
- throw new Error("memory not found");
404
- }
405
- const memory = service.setForget(args.id, args.forgotten ?? true);
410
+ const resolved = service.resolveMemoryId(args.id);
411
+ if (!resolved.ok) throw new Error(resolved.message);
412
+ const memory = service.setForget(resolved.id, args.forgotten ?? true);
406
413
  return { memory: { id: memory.id, forgotten: memory.forgotten } };
407
414
  }
408
415
  }),
@@ -414,7 +421,7 @@ export function createTools(ctx, service, config, embedder) {
414
421
  "Archived entries stay in storage and are recoverable: pass archived=false to restore, and use memory_list with " +
415
422
  "include_archived=true to find archived entries.",
416
423
  parameters: {
417
- id: { type: "string", required: true, description: "Memory id" },
424
+ id: { type: "string", required: true, description: "Memory id: full id (from memory_list/memory_search output) or a unique prefix of it; ambiguous prefixes are rejected" },
418
425
  archived: { type: "boolean", description: "Archive (true, default) or restore (false) the entry" }
419
426
  },
420
427
  output: {
@@ -435,10 +442,9 @@ export function createTools(ctx, service, config, embedder) {
435
442
  render: (_args, value) => TEXT_OUTPUT(`Memory ${value.memory.id} ${value.memory.archived ? "archived" : "restored"}.`)
436
443
  },
437
444
  async execute(args) {
438
- if (service.getById(args.id) === undefined) {
439
- throw new Error("memory not found");
440
- }
441
- const memory = service.setArchived(args.id, args.archived ?? true);
445
+ const resolved = service.resolveMemoryId(args.id);
446
+ if (!resolved.ok) throw new Error(resolved.message);
447
+ const memory = service.setArchived(resolved.id, args.archived ?? true);
442
448
  return { memory: { id: memory.id, archived: memory.archived } };
443
449
  }
444
450
  })
@@ -5,7 +5,9 @@ import { fileURLToPath } from "node:url";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
7
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
8
- const clientSource = readFileSync(join(root, "lib/client.js"), "utf8");
8
+ // src/ is the single source of truth for every module (including the Web
9
+ // client bundle); lib/ is build output produced by `npm run sync`.
10
+ const clientSource = readFileSync(join(root, "src/client.js"), "utf8");
9
11
  const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
10
12
 
11
13
  // The Web client bundle registers itself via __ModuleLoader__.load. DSH
@@ -18,11 +20,14 @@ test("client bundle registers under the package name", () => {
18
20
  assert.equal(match[1], pkg.name, "registered id must equal package.json name");
19
21
  });
20
22
 
21
- // client.js is hand-authored under lib/ only (no src/ counterpart), so the
22
- // src->lib sync must never prune it.
23
- test("client bundle is lib-only with no src counterpart", () => {
24
- assert.equal(existsSync(join(root, "src/client.js")), false, "src/ must not contain client.js");
23
+ // client.js is authored under src/ like every other module; lib/client.js is
24
+ // build output generated by `npm run sync` (prepack), so it must stay a
25
+ // byte-identical copy drift here means the sync wasn't run.
26
+ test("client bundle is src-authored and synced into lib", () => {
27
+ assert.equal(existsSync(join(root, "src/client.js")), true, "src/client.js must exist");
25
28
  assert.equal(existsSync(join(root, "lib/client.js")), true, "lib/client.js must exist");
29
+ const libClient = readFileSync(join(root, "lib/client.js"), "utf8");
30
+ assert.equal(libClient, clientSource, "lib/client.js must equal src/client.js (run `npm run sync`)");
26
31
  });
27
32
 
28
33
  // The memory entry lives at the sidebar foot, not in the settings modal: the
@@ -398,6 +398,139 @@ test("memory_delete on missing id returns deleted:false", async () => {
398
398
  assert.equal(result.deleted, false);
399
399
  });
400
400
 
401
+ // --- issue #48: id resolution via unique prefix (delete/update/forget/archive) --
402
+
403
+ test("memory_delete accepts a unique id prefix", async () => {
404
+ const id = "a1b2c3d4-0000-0000-0000-000000000001";
405
+ const { registered, store } = setup();
406
+ store.save({ id, type: "decision", title: "t", content: "c" });
407
+ const del = registered.find((t) => t.name === "memory_delete");
408
+ const result = await del.execute({ id: "a1b2c3d4" });
409
+ assert.equal(result.deleted, true);
410
+ assert.equal(store.getById(id), undefined, "entry is gone");
411
+ assert.equal(store.count(), 0);
412
+ });
413
+
414
+ test("memory_delete rejects an ambiguous id prefix without deleting", async () => {
415
+ const { registered, store } = setup();
416
+ store.save({ id: "deadbeef-0000-0000-0000-000000000001", type: "decision", title: "t1", content: "c" });
417
+ store.save({ id: "deadbeef-0000-0000-0000-000000000002", type: "decision", title: "t2", content: "c" });
418
+ const del = registered.find((t) => t.name === "memory_delete");
419
+ await assert.rejects(() => del.execute({ id: "deadbeef" }), /matches 2 entries.*refusing to guess/s);
420
+ assert.equal(store.count(), 2, "ambiguous prefix removes nothing");
421
+ });
422
+
423
+ test("memory_delete with a non-matching id prefix returns deleted:false and leaves data", async () => {
424
+ const { registered, store } = setup();
425
+ store.save({ id: "a1b2c3d4-0000-0000-0000-000000000001", type: "decision", title: "t", content: "c" });
426
+ const del = registered.find((t) => t.name === "memory_delete");
427
+ const result = await del.execute({ id: "ffffffff" });
428
+ assert.equal(result.deleted, false);
429
+ assert.equal(store.count(), 1, "nothing removed when the prefix matches nothing");
430
+ });
431
+
432
+ test("memory_update accepts a unique id prefix", async () => {
433
+ const { registered, service, store } = setup();
434
+ const { memory } = service.saveWithDedupe({ type: "decision", title: "t", content: "c" });
435
+ const update = registered.find((t) => t.name === "memory_update");
436
+ const result = await update.execute({ id: memory.id.slice(0, 8), content: "updated via prefix" });
437
+ assert.equal(result.memory.id, memory.id, "resolved to the full id");
438
+ assert.equal(store.getById(memory.id).content, "updated via prefix");
439
+ });
440
+
441
+ test("memory_update rejects an ambiguous id prefix", async () => {
442
+ const { registered, store } = setup();
443
+ store.save({ id: "cafe1234-0000-0000-0000-000000000001", type: "decision", title: "t1", content: "c" });
444
+ store.save({ id: "cafe1234-0000-0000-0000-000000000002", type: "decision", title: "t2", content: "c" });
445
+ const update = registered.find((t) => t.name === "memory_update");
446
+ await assert.rejects(() => update.execute({ id: "cafe1234", content: "x" }), /matches 2 entries.*refusing to guess/s);
447
+ assert.equal(store.getById("cafe1234-0000-0000-0000-000000000001").content, "c");
448
+ });
449
+
450
+ test("memory_forget accepts a unique id prefix and restores it", async () => {
451
+ const id = "facade01-0000-0000-0000-000000000001";
452
+ const { registered, store } = setup();
453
+ store.save({ id, type: "project", title: "t", content: "c", importance: 5 });
454
+ const forget = registered.find((t) => t.name === "memory_forget");
455
+ const result = await forget.execute({ id: "facade01" });
456
+ assert.equal(result.memory.id, id, "resolved to the full id");
457
+ assert.equal(result.memory.forgotten, true);
458
+ assert.equal(store.getById(id).forgotten, true);
459
+ const restored = await forget.execute({ id: "facade01", forgotten: false });
460
+ assert.equal(restored.memory.forgotten, false);
461
+ });
462
+
463
+ test("memory_archive accepts a unique id prefix and restores it", async () => {
464
+ const id = "arcade01-0000-0000-0000-000000000001";
465
+ const { registered, service, store } = setup();
466
+ store.save({ id, type: "project", title: "t", content: "c" });
467
+ const archive = registered.find((t) => t.name === "memory_archive");
468
+ const result = await archive.execute({ id: "arcade01" });
469
+ assert.equal(result.memory.id, id, "resolved to the full id");
470
+ assert.equal(result.memory.archived, true);
471
+ assert.ok(!service.list({ type: "project" }).some((x) => x.id === id), "archived hidden from default list");
472
+ const restored = await archive.execute({ id: "arcade01", archived: false });
473
+ assert.equal(restored.memory.archived, false);
474
+ assert.ok(service.list({ type: "project" }).some((x) => x.id === id), "restored entry visible again");
475
+ });
476
+
477
+ // Exact id match must win even when the same string is also a prefix of
478
+ // another (custom/unequal-length ids): a full id is never "ambiguous" with
479
+ // a longer one that merely starts with it.
480
+ test("memory_delete prefers an exact id match over an identical-prefix collision", async () => {
481
+ const { registered, store } = setup();
482
+ store.save({ id: "abc", type: "decision", title: "short", content: "c" });
483
+ store.save({ id: "abc-def-000000000000000000000000001", type: "decision", title: "long", content: "c" });
484
+ const del = registered.find((t) => t.name === "memory_delete");
485
+ const result = await del.execute({ id: "abc" });
486
+ assert.equal(result.deleted, true);
487
+ assert.equal(store.getById("abc"), undefined, "exact-match entry removed");
488
+ assert.equal(store.count(), 1, "the longer id sharing the prefix stays");
489
+ });
490
+
491
+ // A wildcard-only id ("%", "_") strips to an empty prefix; it must never
492
+ // degrade into a `LIKE '%'` table-wide match that deletes a lone memory.
493
+ test("memory_delete with a wildcard-only id matches nothing and deletes nothing", async () => {
494
+ const { registered, store } = setup();
495
+ store.save({ id: "a1b2c3d4-0000-0000-0000-000000000001", type: "decision", title: "t", content: "c" });
496
+ const del = registered.find((t) => t.name === "memory_delete");
497
+ for (const wildcard of ["%", "_", "%_", "%%", "___"]) {
498
+ const result = await del.execute({ id: wildcard });
499
+ assert.equal(result.deleted, false, `id "${wildcard}" must not match the whole table`);
500
+ }
501
+ assert.equal(store.count(), 1, "nothing removed by wildcard-only ids");
502
+ });
503
+
504
+ // Hand-copied ids can carry surrounding whitespace; resolve against the
505
+ // trimmed form so a padded id still reaches the right memory.
506
+ test("memory_delete trims surrounding whitespace from the id", async () => {
507
+ const id = "a1b2c3d4-0000-0000-0000-000000000001";
508
+ const { registered, store } = setup();
509
+ store.save({ id, type: "decision", title: "t", content: "c" });
510
+ const del = registered.find((t) => t.name === "memory_delete");
511
+ const result = await del.execute({ id: ` ${id} ` });
512
+ assert.equal(result.deleted, true);
513
+ assert.equal(store.count(), 0);
514
+ });
515
+
516
+ // issue #48 observability: an unmatched id on memory_delete is logged (warn)
517
+ // instead of silently vanishing — the logger is optional, but when present the
518
+ // service layer records the silent-miss case.
519
+ test("memory_delete logs a warn when the id matches nothing", async () => {
520
+ const warns = [];
521
+ const store = createStore(":memory:");
522
+ const service = createService({ store, mirror: null, config: {}, logger: { warn: (msg) => warns.push(msg) } });
523
+ store.save({ id: "a1b2c3d4-0000-0000-0000-000000000001", type: "decision", title: "t", content: "c" });
524
+ const registered = [];
525
+ const ctx = { tools: { register(def) { registered.push(def); return () => {}; } } };
526
+ createTools(ctx, service, {}, undefined);
527
+ const del = registered.find((t) => t.name === "memory_delete");
528
+ const result = await del.execute({ id: "ffffffff" });
529
+ assert.equal(result.deleted, false);
530
+ assert.equal(warns.length, 1, "one warn for the silent-miss case");
531
+ assert.match(warns[0], /matched nothing/);
532
+ });
533
+
401
534
  test("memory_delete by query deletes the best match (no id round-trip)", async () => {
402
535
  const { registered, service, store } = setup();
403
536
  const { memory } = service.saveWithDedupe({ type: "preference", title: "喜欢 Rust", content: "用户偏好 Rust 优先于 Go", importance: 4 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Structured memory engine for DeepSeek Harness. Offline semantic search, entity-attribute-timeline, autoDream self-consolidation, and human-editable Markdown storage.",
5
5
  "license": "MIT",
6
6
  "repository": {