@modusensus/dsh-mneme 0.7.4 → 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.
Files changed (147) hide show
  1. package/.github/workflows/publish.yml +38 -0
  2. package/.github/workflows/test.yml +32 -0
  3. package/CHANGELOG.md +91 -0
  4. package/CONTRIBUTING.md +245 -0
  5. package/README.md +163 -421
  6. package/SECURITY.md +674 -0
  7. package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +247 -0
  8. package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +145 -0
  9. package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +56 -0
  10. package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +77 -0
  11. package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +64 -0
  12. package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +90 -0
  13. package/dsh-mneme/CHANGELOG.md +373 -0
  14. package/dsh-mneme/LICENSE +21 -0
  15. package/dsh-mneme/README.md +494 -0
  16. package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +183 -0
  17. package/dsh-mneme/docs/ENTITIES.md +245 -0
  18. package/dsh-mneme/docs/LOCAL_MODEL.md +141 -0
  19. package/dsh-mneme/docs/MIGRATION.md +127 -0
  20. package/dsh-mneme/docs/SEMANTIC.md +256 -0
  21. package/dsh-mneme/docs/SLEEP.md +163 -0
  22. package/{src → dsh-mneme/lib}/api.js +15 -0
  23. package/{lib → dsh-mneme/lib}/client.js +187 -2
  24. package/{lib → dsh-mneme/lib}/mirror.js +3 -1
  25. package/{lib → dsh-mneme/lib}/quality-filter.js +3 -1
  26. package/{src → dsh-mneme/lib}/service.js +47 -5
  27. package/{lib → dsh-mneme/lib}/store.js +57 -1
  28. package/{lib → dsh-mneme/lib}/summarize.js +2 -2
  29. package/{lib → dsh-mneme/lib}/tools.js +27 -21
  30. package/dsh-mneme/package-lock.json +1936 -0
  31. package/dsh-mneme/package.json +83 -0
  32. package/{scripts → dsh-mneme/scripts}/sync-lib.js +3 -3
  33. package/{lib → dsh-mneme/src}/api.js +15 -0
  34. package/dsh-mneme/src/client.js +2050 -0
  35. package/{src → dsh-mneme/src}/mirror.js +3 -1
  36. package/{src → dsh-mneme/src}/quality-filter.js +3 -1
  37. package/{lib → dsh-mneme/src}/service.js +47 -5
  38. package/{src → dsh-mneme/src}/store.js +57 -1
  39. package/{src → dsh-mneme/src}/summarize.js +2 -2
  40. package/{src → dsh-mneme/src}/tools.js +27 -21
  41. package/{test → dsh-mneme/test}/client.test.js +10 -5
  42. package/dsh-mneme/test/layered-types-stats.test.js +144 -0
  43. package/{test → dsh-mneme/test}/tools.test.js +133 -0
  44. package/package.json +18 -43
  45. package//346/250/252/345/271/205.png +0 -0
  46. /package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +0 -0
  47. /package/{lib → dsh-mneme/lib}/commands.js +0 -0
  48. /package/{lib → dsh-mneme/lib}/config.js +0 -0
  49. /package/{lib → dsh-mneme/lib}/dream/clustering.js +0 -0
  50. /package/{lib → dsh-mneme/lib}/dream/decisions.js +0 -0
  51. /package/{lib → dsh-mneme/lib}/dream/sleep.js +0 -0
  52. /package/{lib → dsh-mneme/lib}/dream/tag-extractor.js +0 -0
  53. /package/{lib → dsh-mneme/lib}/dream.js +0 -0
  54. /package/{lib → dsh-mneme/lib}/embedding.js +0 -0
  55. /package/{lib → dsh-mneme/lib}/entities/extractor.js +0 -0
  56. /package/{lib → dsh-mneme/lib}/heat.js +0 -0
  57. /package/{lib → dsh-mneme/lib}/hot-memory.js +0 -0
  58. /package/{lib → dsh-mneme/lib}/index.js +0 -0
  59. /package/{lib → dsh-mneme/lib}/inject.js +0 -0
  60. /package/{lib → dsh-mneme/lib}/local-embedder.js +0 -0
  61. /package/{lib → dsh-mneme/lib}/parser/tag.js +0 -0
  62. /package/{lib → dsh-mneme/lib}/parser/wiki-link.js +0 -0
  63. /package/{lib → dsh-mneme/lib}/reranker.js +0 -0
  64. /package/{lib → dsh-mneme/lib}/search/adaptive.js +0 -0
  65. /package/{lib → dsh-mneme/lib}/search/bm25.js +0 -0
  66. /package/{lib → dsh-mneme/lib}/search/tag-boost.js +0 -0
  67. /package/{lib → dsh-mneme/lib}/settings.js +0 -0
  68. /package/{lib → dsh-mneme/lib}/vector-index.js +0 -0
  69. /package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +0 -0
  70. /package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +0 -0
  71. /package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +0 -0
  72. /package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +0 -0
  73. /package/{scripts → dsh-mneme/scripts}/stress-dsh.js +0 -0
  74. /package/{src → dsh-mneme/src}/commands.js +0 -0
  75. /package/{src → dsh-mneme/src}/config.js +0 -0
  76. /package/{src → dsh-mneme/src}/dream/clustering.js +0 -0
  77. /package/{src → dsh-mneme/src}/dream/decisions.js +0 -0
  78. /package/{src → dsh-mneme/src}/dream/sleep.js +0 -0
  79. /package/{src → dsh-mneme/src}/dream/tag-extractor.js +0 -0
  80. /package/{src → dsh-mneme/src}/dream.js +0 -0
  81. /package/{src → dsh-mneme/src}/embedding.js +0 -0
  82. /package/{src → dsh-mneme/src}/entities/extractor.js +0 -0
  83. /package/{src → dsh-mneme/src}/heat.js +0 -0
  84. /package/{src → dsh-mneme/src}/hot-memory.js +0 -0
  85. /package/{src → dsh-mneme/src}/index.js +0 -0
  86. /package/{src → dsh-mneme/src}/inject.js +0 -0
  87. /package/{src → dsh-mneme/src}/local-embedder.js +0 -0
  88. /package/{src → dsh-mneme/src}/parser/tag.js +0 -0
  89. /package/{src → dsh-mneme/src}/parser/wiki-link.js +0 -0
  90. /package/{src → dsh-mneme/src}/reranker.js +0 -0
  91. /package/{src → dsh-mneme/src}/search/adaptive.js +0 -0
  92. /package/{src → dsh-mneme/src}/search/bm25.js +0 -0
  93. /package/{src → dsh-mneme/src}/search/tag-boost.js +0 -0
  94. /package/{src → dsh-mneme/src}/settings.js +0 -0
  95. /package/{src → dsh-mneme/src}/vector-index.js +0 -0
  96. /package/{test → dsh-mneme/test}/api.test.js +0 -0
  97. /package/{test → dsh-mneme/test}/audit.test.js +0 -0
  98. /package/{test → dsh-mneme/test}/benchmark.test.js +0 -0
  99. /package/{test → dsh-mneme/test}/boundary-v0625.test.js +0 -0
  100. /package/{test → dsh-mneme/test}/clustering.test.js +0 -0
  101. /package/{test → dsh-mneme/test}/commands.test.js +0 -0
  102. /package/{test → dsh-mneme/test}/config.test.js +0 -0
  103. /package/{test → dsh-mneme/test}/conflict-freeze.test.js +0 -0
  104. /package/{test → dsh-mneme/test}/directory.test.js +0 -0
  105. /package/{test → dsh-mneme/test}/dream.test.js +0 -0
  106. /package/{test → dsh-mneme/test}/entities.test.js +0 -0
  107. /package/{test → dsh-mneme/test}/epistemic.test.js +0 -0
  108. /package/{test → dsh-mneme/test}/fnew-0112.test.js +0 -0
  109. /package/{test → dsh-mneme/test}/fnew-03.test.js +0 -0
  110. /package/{test → dsh-mneme/test}/graph-api.test.js +0 -0
  111. /package/{test → dsh-mneme/test}/heat.test.js +0 -0
  112. /package/{test → dsh-mneme/test}/helpers/dream-mock.js +0 -0
  113. /package/{test → dsh-mneme/test}/hot-memory.test.js +0 -0
  114. /package/{test → dsh-mneme/test}/inject.test.js +0 -0
  115. /package/{test → dsh-mneme/test}/llm-audit.test.js +0 -0
  116. /package/{test → dsh-mneme/test}/local-embedder.test.js +0 -0
  117. /package/{test → dsh-mneme/test}/mirror-dirty.test.js +0 -0
  118. /package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +0 -0
  119. /package/{test → dsh-mneme/test}/mirror-generation.test.js +0 -0
  120. /package/{test → dsh-mneme/test}/mirror.test.js +0 -0
  121. /package/{test → dsh-mneme/test}/normalize-decisions.test.js +0 -0
  122. /package/{test → dsh-mneme/test}/peer-blockers.test.js +0 -0
  123. /package/{test → dsh-mneme/test}/policy-epoch.test.js +0 -0
  124. /package/{test → dsh-mneme/test}/provenance.test.js +0 -0
  125. /package/{test → dsh-mneme/test}/quality-filter.test.js +0 -0
  126. /package/{test → dsh-mneme/test}/reasoning-effort.test.js +0 -0
  127. /package/{test → dsh-mneme/test}/recall-evals.test.js +0 -0
  128. /package/{test → dsh-mneme/test}/recall-layer.test.js +0 -0
  129. /package/{test → dsh-mneme/test}/recall-runs.test.js +0 -0
  130. /package/{test → dsh-mneme/test}/receipt-chain.test.js +0 -0
  131. /package/{test → dsh-mneme/test}/reflection.test.js +0 -0
  132. /package/{test → dsh-mneme/test}/reranker.test.js +0 -0
  133. /package/{test → dsh-mneme/test}/search-fusion.test.js +0 -0
  134. /package/{test → dsh-mneme/test}/semantic.test.js +0 -0
  135. /package/{test → dsh-mneme/test}/service-search.test.js +0 -0
  136. /package/{test → dsh-mneme/test}/service.test.js +0 -0
  137. /package/{test → dsh-mneme/test}/settings.test.js +0 -0
  138. /package/{test → dsh-mneme/test}/sleep-heat.test.js +0 -0
  139. /package/{test → dsh-mneme/test}/sleep.test.js +0 -0
  140. /package/{test → dsh-mneme/test}/store.test.js +0 -0
  141. /package/{test → dsh-mneme/test}/stress.test.js +0 -0
  142. /package/{test → dsh-mneme/test}/summarize.test.js +0 -0
  143. /package/{test → dsh-mneme/test}/tag-boost.test.js +0 -0
  144. /package/{test → dsh-mneme/test}/tag.test.js +0 -0
  145. /package/{test → dsh-mneme/test}/updated-at-semantics.test.js +0 -0
  146. /package/{test → dsh-mneme/test}/vector-index.test.js +0 -0
  147. /package/{test → dsh-mneme/test}/wiki-link.test.js +0 -0
@@ -7,7 +7,9 @@ export const TYPE_FILE = {
7
7
  project: "projects.md",
8
8
  decision: "decisions.md",
9
9
  history: "history.md",
10
- summary: "summary.md"
10
+ summary: "summary.md",
11
+ user: "user.md",
12
+ fact: "facts.md"
11
13
  };
12
14
 
13
15
  const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
@@ -34,7 +34,9 @@ const TYPE_LABELS = {
34
34
  decision: ["decision", "决策", "决定"],
35
35
  history: ["history", "历史", "事件"],
36
36
  summary: ["summary", "总结", "摘要", "总览"],
37
- pattern: ["pattern", "模式", "规律"]
37
+ pattern: ["pattern", "模式", "规律"],
38
+ user: ["user", "用户"],
39
+ fact: ["fact", "事实", "原子事实"]
38
40
  };
39
41
 
40
42
  /** Normalized bigram-overlap similarity in [0,1]; 0 for tiny/empty inputs. */
@@ -7,7 +7,7 @@ import { parseWikiLinks } from "./parser/wiki-link.js";
7
7
  import { extractQueryTags, applyTagBoost } from "./search/tag-boost.js";
8
8
  import { computeHeat } from "./heat.js";
9
9
 
10
- const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
10
+ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "user", "fact"]);
11
11
 
12
12
  // Epistemic trust weights (v0.4.5): when config.trustEpistemicWeighting is on,
13
13
  // each recall candidate's existing score is multiplied by the weight of its
@@ -1047,12 +1047,15 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1047
1047
  // (quality_score null) count as 100 (weight 1), so legacy stores keep their
1048
1048
  // exact summary>preference>importance ordering.
1049
1049
  const qualityWeight = (m) => (m.quality_score != null ? m.quality_score / 100 : 1);
1050
+ // summary/user/preference are injected regardless of importance (context
1051
+ // layers); every other INJECT_TYPES type (e.g. fact) only when
1052
+ // importance >= threshold — the frontend "常注入" badge maps to this split.
1050
1053
  const items = store.list({ limit: 200, includeForgotten: false })
1051
1054
  .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1052
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold))
1055
+ (m.type === "summary" || m.type === "preference" || m.type === "user" || m.importance >= threshold))
1053
1056
  .sort((a, b) => {
1054
- const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
1055
- const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
1057
+ const pa = a.type === "summary" ? 0 : (a.type === "preference" || a.type === "user") ? 1 : 2;
1058
+ const pb = b.type === "summary" ? 0 : (b.type === "preference" || b.type === "user") ? 1 : 2;
1056
1059
  return pa - pb || (b.importance * qualityWeight(b)) - (a.importance * qualityWeight(a));
1057
1060
  });
1058
1061
  let candidates = items;
@@ -1068,7 +1071,7 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1068
1071
  const hits = vectorIndex.search(queryVector, { limit: maxItems * 2, threshold: 0 });
1069
1072
  for (const m of hits) {
1070
1073
  if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1071
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
1074
+ (m.type === "summary" || m.type === "preference" || m.type === "user" || m.importance >= threshold)) {
1072
1075
  semanticItems.push(m);
1073
1076
  }
1074
1077
  }
@@ -1606,7 +1609,46 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1606
1609
  list: (o) => store.list(o),
1607
1610
  all: () => store.all(),
1608
1611
  count: (type, opts) => store.count(type, opts),
1612
+ stats: (opts) => store.stats(opts),
1609
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
+ },
1610
1652
  remove: (id) => {
1611
1653
  store.remove(id);
1612
1654
  afterSync("write");
@@ -242,7 +242,7 @@ CREATE TABLE IF NOT EXISTS mirror_state (
242
242
  );
243
243
  `;
244
244
 
245
- const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
245
+ const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern", "user", "fact"]);
246
246
 
247
247
  // Epistemic status: what kind of evidence a memory rests on. Defaults to
248
248
  // 'subjective' so legacy rows (and rows without any signal) stay compatible.
@@ -659,11 +659,65 @@ export function createStore(path) {
659
659
  return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
660
660
  }
661
661
 
662
+ /**
663
+ * Aggregated stats for the Web panel layered overview: per-type distribution
664
+ * plus a recent daily creation trend. Counts live (non-forgotten /
665
+ * non-archived / not session-disposed) memories only, matching `count`.
666
+ * created_at is ISO TEXT, so date strings compare lexicographically.
667
+ */
668
+ function stats({ days = 7 } = {}) {
669
+ const LIVE = "forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL";
670
+ // Clamp + integer-coerce so fractional strings (e.g. ?days=7.5) can't
671
+ // produce ragged trend windows (kimi-k2.7-code 复验 S2).
672
+ days = Math.min(30, Math.max(1, Math.floor(Number(days) || 7)));
673
+ const now = Date.now(); // single clock read so the window doesn't span a day boundary
674
+ const byType = db.prepare(
675
+ `SELECT type, count(*) AS c FROM memories WHERE ${LIVE} GROUP BY type`
676
+ ).all();
677
+ const byTypeCounts = {};
678
+ for (const row of byType) byTypeCounts[row.type] = row.c;
679
+ const since = new Date(now - (days - 1) * 86400000).toISOString().slice(0, 10);
680
+ const trend = db.prepare(
681
+ `SELECT substr(created_at, 1, 10) AS d, count(*) AS c FROM memories
682
+ WHERE ${LIVE} AND created_at >= ? GROUP BY d ORDER BY d`
683
+ ).all(since);
684
+ const trendCounts = {};
685
+ for (const row of trend) trendCounts[row.d] = row.c;
686
+ const recent = [];
687
+ for (let i = days - 1; i >= 0; i--) {
688
+ const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
689
+ recent.push({ date: d, count: trendCounts[d] ?? 0 });
690
+ }
691
+ const total = Object.values(byTypeCounts).reduce((s, n) => s + n, 0);
692
+ return { byType: byTypeCounts, total, recent, days };
693
+ }
694
+
662
695
  function getById(id) {
663
696
  const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
664
697
  return toRow(row);
665
698
  }
666
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
+
667
721
  /**
668
722
  * Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
669
723
  * folds ASCII case (CJK titles are inherently case-free, so they match
@@ -2175,7 +2229,9 @@ export function createStore(path) {
2175
2229
  return {
2176
2230
  db,
2177
2231
  count,
2232
+ stats,
2178
2233
  getById,
2234
+ listByIdPrefix,
2179
2235
  save,
2180
2236
  update,
2181
2237
  compareAndUpdate,
@@ -1,7 +1,7 @@
1
1
  import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
2
 
3
3
  const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼 2-3 条值得跨会话记住的记忆。
4
- 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"一句话内容","importance":1-5}。
4
+ 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history|user|fact","title":"简短标题","content":"一句话内容","importance":1-5}。
5
5
  不要输出任何其他文字。`;
6
6
 
7
7
  /** Extract a JSON array from LLM output that may contain prose around it. */
@@ -17,7 +17,7 @@ export function parseSummaryJson(raw) {
17
17
  return [];
18
18
  }
19
19
  if (!Array.isArray(arr)) return [];
20
- const VALID = new Set(["preference", "project", "decision", "history"]);
20
+ const VALID = new Set(["preference", "project", "decision", "history", "user", "fact"]);
21
21
  return arr.filter(
22
22
  (item) =>
23
23
  item &&
@@ -192,7 +192,7 @@ export function createTools(ctx, service, config, embedder) {
192
192
  "Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
193
193
  "Merges into an existing entry of the same type when the title matches.",
194
194
  parameters: {
195
- type: { type: "string", required: true, enum: ["preference", "project", "decision", "history"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary" },
195
+ type: { type: "string", required: true, enum: ["preference", "project", "decision", "history", "user", "fact"], description: "preference=user preference; project=project knowledge/state; decision=key decision; history=conversation summary; user=user profile (background/identity); fact=atomic factual statement" },
196
196
  title: { type: "string", required: true, description: "Short unique title" },
197
197
  content: { type: "string", required: true, description: "Memory body" },
198
198
  tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
@@ -266,7 +266,7 @@ export function createTools(ctx, service, config, embedder) {
266
266
  name: "memory_list",
267
267
  description: "List at most 50 memory entries by type, high-importance first, then newest, paginated. Set include_archived=true to also list archived (hidden) entries so they can be located and restored. The JSONL summary reports returned/shown/omitted, offset/total/next_offset, and each rendered entry's exact id, type, title, importance, updated_at, tags, and truncated content_preview.",
268
268
  parameters: {
269
- type: { type: "string", enum: ["preference", "project", "decision", "history"], description: "Filter by type; omit for all" },
269
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "user", "fact"], description: "Filter by type; omit for all" },
270
270
  limit: { type: "integer", description: "Page size (default and maximum 50; nonpositive values use 50)" },
271
271
  offset: { type: "integer", description: "Page offset (default 0)" },
272
272
  include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" }
@@ -302,10 +302,10 @@ 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
- type: { type: "string", enum: ["preference", "project", "decision", "history"] },
308
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "user", "fact"] },
309
309
  tags: { type: "array", items: { type: "string" } },
310
310
  importance: { type: "integer", description: "1-5" },
311
311
  reason: { type: "string", description: "Optional context for the correction (what the user actually said/wanted), recorded for reflection" }
@@ -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
@@ -0,0 +1,144 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+ import { createApi } from "../src/api.js";
7
+ import { createSettings } from "../src/settings.js";
8
+ import { TYPE_FILE } from "../src/mirror.js";
9
+ import { parseSummaryJson } from "../src/summarize.js";
10
+
11
+ // v0.8.x: layered memory types (user / fact) + Web panel stats endpoint.
12
+ // user = user profile (background/identity), fact = atomic factual statement.
13
+ // Both ride the existing single-table `memories` design — only the type enum
14
+ // and downstream lists (mirror / tools / summarize / inject) were extended.
15
+
16
+ class FakeRes extends EventEmitter {
17
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
18
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
19
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
20
+ }
21
+
22
+ function req(path, method = "GET", body = null) {
23
+ const r = new EventEmitter();
24
+ r.url = path;
25
+ r.method = method;
26
+ r.headers = {};
27
+ if (body !== null) {
28
+ process.nextTick(() => {
29
+ r.emit("data", Buffer.from(JSON.stringify(body)));
30
+ r.emit("end");
31
+ });
32
+ }
33
+ return r;
34
+ }
35
+
36
+ function setup() {
37
+ const store = createStore(":memory:");
38
+ const settings = createSettings(store.db);
39
+ const service = createService({ store, mirror: null, config: {}, settings });
40
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
41
+ const routes = [];
42
+ const ctx = {
43
+ webServer: {
44
+ register(route) { routes.push(route); return () => {}; }
45
+ }
46
+ };
47
+ createApi(ctx, service, settings, commands, undefined, undefined, "");
48
+ const handler = (path) => routes.find((r) => r.path === path)?.handler;
49
+ return { store, service, handler };
50
+ }
51
+
52
+ test("user/fact are valid memory types for saveWithDedupe", () => {
53
+ const { service } = setup();
54
+ const a = service.saveWithDedupe({ type: "user", title: "桉桉", content: "湖南工业大学学生,考研公共管理学", importance: 5 });
55
+ const b = service.saveWithDedupe({ type: "fact", title: "服务器地址", content: "云服务器 Ubuntu 22.04", importance: 3 });
56
+ assert.equal(a.memory.type, "user");
57
+ assert.equal(b.memory.type, "fact");
58
+ assert.equal(service.count("user"), 1);
59
+ assert.equal(service.count("fact"), 1);
60
+ });
61
+
62
+ test("mirror TYPE_FILE covers user/fact layers", () => {
63
+ assert.equal(TYPE_FILE.user, "user.md");
64
+ assert.equal(TYPE_FILE.fact, "facts.md");
65
+ });
66
+
67
+ test("autoSummarize accepts user/fact types", () => {
68
+ const parsed = parseSummaryJson(
69
+ '[{"type":"user","title":"用户城市","content":"湖南株洲","importance":4},{"type":"fact","title":"端口","content":"23334","importance":3}]'
70
+ );
71
+ assert.equal(parsed.length, 2);
72
+ assert.deepEqual(parsed.map((i) => i.type), ["user", "fact"]);
73
+ // legacy types still pass, unknown types rejected
74
+ const mixed = parseSummaryJson(
75
+ '[{"type":"preference","title":"语言","content":"中文","importance":2},{"type":"nonsense","title":"x","content":"y","importance":1}]'
76
+ );
77
+ assert.equal(mixed.length, 1);
78
+ assert.equal(mixed[0].type, "preference");
79
+ });
80
+
81
+ test("GET /api/dsh-mneme/list?type=user filters the new layer", async () => {
82
+ const { service, handler } = setup();
83
+ service.saveWithDedupe({ type: "user", title: "桉桉", content: "用户画像", importance: 5 });
84
+ service.saveWithDedupe({ type: "fact", title: "事实", content: "原子事实", importance: 3 });
85
+ const res = new FakeRes();
86
+ await handler("/api/dsh-mneme/list")?.({ url: "/api/dsh-mneme/list?type=user" }, res);
87
+ const data = JSON.parse(res.body);
88
+ assert.equal(res.statusCode, 200);
89
+ assert.equal(data.total, 1);
90
+ assert.equal(data.items[0].type, "user");
91
+ });
92
+
93
+ test("GET /api/dsh-mneme/stats returns byType distribution + recent trend", async () => {
94
+ const { service, handler } = setup();
95
+ service.saveWithDedupe({ type: "user", title: "桉桉", content: "用户画像", importance: 5 });
96
+ service.saveWithDedupe({ type: "fact", title: "事实1", content: "原子事实", importance: 3 });
97
+ service.saveWithDedupe({ type: "fact", title: "事实2", content: "另一个事实", importance: 2 });
98
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文", importance: 2 });
99
+ const res = new FakeRes();
100
+ await handler("/api/dsh-mneme/stats")?.({ url: "/api/dsh-mneme/stats?days=7" }, res);
101
+ const data = JSON.parse(res.body);
102
+ assert.equal(res.statusCode, 200);
103
+ assert.equal(data.byType.user, 1);
104
+ assert.equal(data.byType.fact, 2);
105
+ assert.equal(data.byType.preference, 1);
106
+ assert.equal(data.total, 4);
107
+ // 7-day trend covers today (the only day with live rows)
108
+ assert.equal(data.recent.length, 7);
109
+ const today = new Date().toISOString().slice(0, 10);
110
+ assert.equal(data.recent[6].date, today);
111
+ assert.equal(data.recent[6].count, 4);
112
+ });
113
+
114
+ test("stats excludes archived/forgotten memories", async () => {
115
+ const { service, handler } = setup();
116
+ const a = service.saveWithDedupe({ type: "user", title: "桉桉", content: "用户画像", importance: 5 });
117
+ const b = service.saveWithDedupe({ type: "fact", title: "事实", content: "原子事实", importance: 3 });
118
+ service.setArchived(a.memory.id, true);
119
+ service.setForget(b.memory.id, true);
120
+ const res = new FakeRes();
121
+ await handler("/api/dsh-mneme/stats")?.({ url: "/api/dsh-mneme/stats?days=7" }, res);
122
+ const data = JSON.parse(res.body);
123
+ assert.equal(data.total, 0);
124
+ });
125
+
126
+ test("stats clamps days to 1..30", async () => {
127
+ const { handler } = setup();
128
+ const res = new FakeRes();
129
+ await handler("/api/dsh-mneme/stats")?.({ url: "/api/dsh-mneme/stats?days=999" }, res);
130
+ const data = JSON.parse(res.body);
131
+ assert.equal(res.statusCode, 200);
132
+ assert.equal(data.recent.length, 30);
133
+ });
134
+
135
+ test("stats coerces fractional days to integer (kimi S2)", async () => {
136
+ const { service, handler } = setup();
137
+ service.saveWithDedupe({ type: "fact", title: "事实", content: "原子事实", importance: 3 });
138
+ const res = new FakeRes();
139
+ await handler("/api/dsh-mneme/stats")?.({ url: "/api/dsh-mneme/stats?days=7.5" }, res);
140
+ const data = JSON.parse(res.body);
141
+ assert.equal(data.recent.length, 7); // floor(7.5) → 7, never a ragged 8
142
+ assert.equal(data.days, 7);
143
+ assert.equal(data.byType.fact, 1);
144
+ });