@te-river/opencode-team-mode 1.5.11 → 1.5.13

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 (112) hide show
  1. package/README.md +154 -57
  2. package/README.zh-CN.md +126 -47
  3. package/dist/agents.d.ts +5 -1
  4. package/dist/agents.d.ts.map +1 -1
  5. package/dist/agents.js +37 -11
  6. package/dist/agents.js.map +1 -1
  7. package/dist/approval-gate.d.ts +54 -16
  8. package/dist/approval-gate.d.ts.map +1 -1
  9. package/dist/approval-gate.js +163 -42
  10. package/dist/approval-gate.js.map +1 -1
  11. package/dist/host-hooks.d.ts +115 -0
  12. package/dist/host-hooks.d.ts.map +1 -0
  13. package/dist/host-hooks.js +270 -0
  14. package/dist/host-hooks.js.map +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +68 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/prompts/lead.d.ts +1 -1
  19. package/dist/prompts/lead.d.ts.map +1 -1
  20. package/dist/prompts/lead.js +88 -6
  21. package/dist/prompts/lead.js.map +1 -1
  22. package/dist/prompts/shared.d.ts +1 -1
  23. package/dist/prompts/shared.d.ts.map +1 -1
  24. package/dist/prompts/shared.js +203 -119
  25. package/dist/prompts/shared.js.map +1 -1
  26. package/dist/prompts/specialists.d.ts +2 -2
  27. package/dist/prompts/specialists.d.ts.map +1 -1
  28. package/dist/prompts/specialists.js +73 -23
  29. package/dist/prompts/specialists.js.map +1 -1
  30. package/dist/tm/args-schema.d.ts +31 -1
  31. package/dist/tm/args-schema.d.ts.map +1 -1
  32. package/dist/tm/args-schema.js +155 -39
  33. package/dist/tm/args-schema.js.map +1 -1
  34. package/dist/tm/bash-timeout.d.ts +72 -0
  35. package/dist/tm/bash-timeout.d.ts.map +1 -0
  36. package/dist/tm/bash-timeout.js +100 -0
  37. package/dist/tm/bash-timeout.js.map +1 -0
  38. package/dist/tm/browser.d.ts +250 -20
  39. package/dist/tm/browser.d.ts.map +1 -1
  40. package/dist/tm/browser.js +1200 -143
  41. package/dist/tm/browser.js.map +1 -1
  42. package/dist/tm/config.d.ts +129 -0
  43. package/dist/tm/config.d.ts.map +1 -1
  44. package/dist/tm/config.js +117 -1
  45. package/dist/tm/config.js.map +1 -1
  46. package/dist/tm/dispatch.d.ts +95 -0
  47. package/dist/tm/dispatch.d.ts.map +1 -0
  48. package/dist/tm/dispatch.js +369 -0
  49. package/dist/tm/dispatch.js.map +1 -0
  50. package/dist/tm/index.d.ts +32 -7
  51. package/dist/tm/index.d.ts.map +1 -1
  52. package/dist/tm/index.js +74 -5
  53. package/dist/tm/index.js.map +1 -1
  54. package/dist/tm/memory.d.ts +88 -10
  55. package/dist/tm/memory.d.ts.map +1 -1
  56. package/dist/tm/memory.js +881 -99
  57. package/dist/tm/memory.js.map +1 -1
  58. package/dist/tm/pipelines.d.ts +26 -3
  59. package/dist/tm/pipelines.d.ts.map +1 -1
  60. package/dist/tm/pipelines.js +167 -11
  61. package/dist/tm/pipelines.js.map +1 -1
  62. package/dist/tm/ptc/budgets.d.ts +22 -1
  63. package/dist/tm/ptc/budgets.d.ts.map +1 -1
  64. package/dist/tm/ptc/budgets.js +87 -8
  65. package/dist/tm/ptc/budgets.js.map +1 -1
  66. package/dist/tm/ptc/contract.d.ts +49 -9
  67. package/dist/tm/ptc/contract.d.ts.map +1 -1
  68. package/dist/tm/ptc/contract.js +41 -3
  69. package/dist/tm/ptc/contract.js.map +1 -1
  70. package/dist/tm/ptc/driver.d.ts +5 -2
  71. package/dist/tm/ptc/driver.d.ts.map +1 -1
  72. package/dist/tm/ptc/driver.js +47 -15
  73. package/dist/tm/ptc/driver.js.map +1 -1
  74. package/dist/tm/ptc/engines.d.ts +15 -4
  75. package/dist/tm/ptc/engines.d.ts.map +1 -1
  76. package/dist/tm/ptc/engines.js +281 -28
  77. package/dist/tm/ptc/engines.js.map +1 -1
  78. package/dist/tm/ptc/index.d.ts +4 -4
  79. package/dist/tm/ptc/index.d.ts.map +1 -1
  80. package/dist/tm/ptc/index.js +2 -2
  81. package/dist/tm/ptc/index.js.map +1 -1
  82. package/dist/tm/ptc/pscan.d.ts +20 -1
  83. package/dist/tm/ptc/pscan.d.ts.map +1 -1
  84. package/dist/tm/ptc/pscan.js +328 -14
  85. package/dist/tm/ptc/pscan.js.map +1 -1
  86. package/dist/tm/ptc/summary.d.ts.map +1 -1
  87. package/dist/tm/ptc/summary.js +23 -1
  88. package/dist/tm/ptc/summary.js.map +1 -1
  89. package/dist/tm/ptc/tool.d.ts +34 -3
  90. package/dist/tm/ptc/tool.d.ts.map +1 -1
  91. package/dist/tm/ptc/tool.js +117 -20
  92. package/dist/tm/ptc/tool.js.map +1 -1
  93. package/dist/tm/pty.d.ts +70 -0
  94. package/dist/tm/pty.d.ts.map +1 -0
  95. package/dist/tm/pty.js +242 -0
  96. package/dist/tm/pty.js.map +1 -0
  97. package/dist/tm/search.d.ts +128 -15
  98. package/dist/tm/search.d.ts.map +1 -1
  99. package/dist/tm/search.js +556 -47
  100. package/dist/tm/search.js.map +1 -1
  101. package/dist/tm/shell-bridge.d.ts +14 -1
  102. package/dist/tm/shell-bridge.d.ts.map +1 -1
  103. package/dist/tm/shell-bridge.js +57 -10
  104. package/dist/tm/shell-bridge.js.map +1 -1
  105. package/dist/tm/webfetch.d.ts +29 -7
  106. package/dist/tm/webfetch.d.ts.map +1 -1
  107. package/dist/tm/webfetch.js +117 -15
  108. package/dist/tm/webfetch.js.map +1 -1
  109. package/dist/types.d.ts +16 -0
  110. package/dist/types.d.ts.map +1 -1
  111. package/docs/installation.md +216 -17
  112. package/package.json +5 -2
package/dist/tm/memory.js CHANGED
@@ -1,24 +1,50 @@
1
1
  /**
2
- * tm_memory — project/global memory mirror (Qoder-inspired, re-implemented
3
- * from mechanism analysis; no code reused).
2
+ * tm_memory — THREE-layer memory store (global / project / session),
3
+ * Qoder-inspired, re-implemented from mechanism analysis; no code reused.
4
4
  *
5
5
  * Design: memories are plain Markdown files with a small YAML-ish
6
- * frontmatter (title / usage_scenario / keywords) — git-diffable, human-
7
- * editable, injectable into context without any database. The store lives
8
- * under the git-aware base (<repo>/.git/opencode-team/memories/ or tmpdir
9
- * fallback), mirroring the run-store philosophy: never pollute the working
10
- * tree, TTL-free (memories are long-lived by design; forget is explicit).
6
+ * frontmatter (title / usage_scenario / keywords / supersedes / created_at /
7
+ * updated_at / expires) — git-diffable, human-editable, injectable into
8
+ * context without any database. The store lives under the git-aware base
9
+ * (<repo>/.git/opencode-team/memories/ or tmpdir fallback), mirroring the
10
+ * run-store philosophy: never pollute the working tree.
11
11
  *
12
12
  * Layout:
13
13
  * GLOBAL (user-level, follows you across projects):
14
14
  * <globalRoot>/<category>/<title>.md — default ~/.opencode-team/memories/global/
15
15
  * PROJECT (repo-level, per checkout):
16
16
  * <storeBase>/memories/projects/<project-slug>/<category>/<title>.md
17
+ * SESSION (per ctx.sessionID — EPHEMERAL by default):
18
+ * in-process Map keyed by the session id; nothing touches the disk
19
+ * unless TM_MEMORY_SESSION_PERSIST=1, which mirrors every session entry
20
+ * to <storeBase>/memories/sessions/<sid>/<category>/<title>.md with an
21
+ * `expires:` frontmatter line. Expired entries die on a lazy per-call
22
+ * sweep AND a startup sweep (never read back after the TTL).
23
+ * COMPACT backups (rollback path, never walked — dot-dirs are skipped):
24
+ * <storeBase>/memories/.compact-backup/<UTC ts>/<scope>/<rel path>
25
+ *
26
+ * Precedence: session > project > global. search scores every layer it is
27
+ * asked to walk; same-title lower-layer entries are shadowed by the highest
28
+ * layer that carries them, and project gets a +2 / session a +3 near-tie
29
+ * weight so a layer's entries rank first.
30
+ *
31
+ * Bloat control (the root cause this tier fixes: near-identical memories
32
+ * piling up as separate files):
33
+ * - add NEVER creates a second entry that duplicates an existing one in
34
+ * the same scope+category: identity is `category + sorted(stopword-
35
+ * stripped title tokens)` OR a Jaccard(title∪keywords tokens) ≥ 0.6 —
36
+ * either match MERGES into the existing file (content takes the new
37
+ * value, keywords union, `supersedes:` grows, updated_at refreshes).
38
+ * - over cfg.memoryMaxEntries per scope, add fails with a compact/forget
39
+ * hint instead of growing the store.
40
+ * - cfg.memoryStaleDays > 0 tags search hits `[stale Nd]`.
41
+ * - action "compact" groups the existing duplicates: dry-run by default,
42
+ * `apply: true` merges for real after moving originals to the backup dir.
17
43
  *
18
44
  * The project slug is derived from the workspace path (drive letter + path
19
45
  * segments dashed, lowercased) so two checkouts never share a memory set.
20
- * Injection is PULL-based for now: agents search via this tool; the lead
21
- * distills relevant memories into dispatches (see SHARED_RULES).
46
+ * Injection is PULL-based: agents search via this tool; the lead distills
47
+ * relevant memories into dispatches (see SHARED_RULES).
22
48
  */
23
49
  import * as fs from "node:fs";
24
50
  import * as path from "node:path";
@@ -30,6 +56,20 @@ export const MEMORY_TITLE_MAX = 120;
30
56
  export const MEMORY_CONTENT_MAX = 4000;
31
57
  export const MEMORY_SEARCH_RESULTS = 5;
32
58
  export const MEMORY_EXCERPT_MAX = 600;
59
+ /** Layered precedence: session > project > global — (a) same-title entries in
60
+ * a lower layer are shadowed by the highest layer carrying them, (b) session
61
+ * entries get a +3 and project entries a +2 near-tie weight (matches
62
+ * category ×2 in the scoring grammar). */
63
+ export const MEMORY_PROJECT_SCOPE_BONUS = 2;
64
+ export const MEMORY_SESSION_SCOPE_BONUS = 3;
65
+ /** Near-duplicate merge threshold on title∪keywords token sets. */
66
+ export const MEMORY_DEDUP_JACCARD = 0.6;
67
+ /** Per-list frontmatter caps (kept stable so merges cannot grow unbounded). */
68
+ export const MEMORY_KEYWORDS_MAX = 10;
69
+ export const MEMORY_SCENARIOS_MAX = 8;
70
+ export const MEMORY_SUPERSEDES_MAX = 50;
71
+ /** Default session id when the host ctx carries no sessionID. */
72
+ export const MEMORY_DEFAULT_SESSION_ID = "default";
33
73
  /** Seeded category taxonomy (Qoder's seven, reused as the starting point). */
34
74
  export const MEMORY_CATEGORIES = [
35
75
  "project_introduction",
@@ -40,6 +80,13 @@ export const MEMORY_CATEGORIES = [
40
80
  "development_code_specification",
41
81
  "task_summary_experience",
42
82
  ];
83
+ export const MEMORY_SCOPES = ["project", "global", "session"];
84
+ /** Layer precedence: session > project > global (also drives same-title shadowing). */
85
+ export const MEMORY_ALL_SCOPES = ["project", "global", "session"];
86
+ function scopeRank(scope) {
87
+ return scope === "session" ? 2 : scope === "project" ? 1 : 0;
88
+ }
89
+ const DAY_MS = 24 * 60 * 60 * 1000;
43
90
  // ---------- slugs + paths ----------------------------------------------------
44
91
  /** Path → project slug ("D:\Github\App" -> "d-github-app"). */
45
92
  export function projectSlug(directory) {
@@ -57,7 +104,7 @@ export function projectSlug(directory) {
57
104
  export function titleSlug(title) {
58
105
  return String(title ?? "")
59
106
  .toLowerCase()
60
- .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
107
+ .replace(/[^a-z0-9一-鿿]+/g, "-")
61
108
  .replace(/-{2,}/g, "-")
62
109
  .replace(/^-|-$/g, "")
63
110
  .slice(0, 80) || "untitled";
@@ -69,18 +116,95 @@ function categorySlug(category) {
69
116
  .replace(/[^a-z0-9_-]+/g, "_")
70
117
  .replace(/^_+|_+$/g, "") || "notes";
71
118
  }
119
+ /** Session id → path-safe dir name. */
120
+ export function sessionIdSlug(sid) {
121
+ const s = String(sid ?? "")
122
+ .trim()
123
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
124
+ .replace(/^-+|-+$/g, "")
125
+ .slice(0, 80);
126
+ return s || MEMORY_DEFAULT_SESSION_ID;
127
+ }
72
128
  function projectRoot(storeBase, directory) {
73
129
  return path.join(storeBase, "memories", "projects", projectSlug(directory));
74
130
  }
75
- function scopeRoot(globalRoot, storeBase, scope, directory) {
76
- return scope === "global"
77
- ? globalRoot
78
- : projectRoot(storeBase, directory);
131
+ function sessionsRoot(storeBase) {
132
+ return path.join(storeBase, "memories", "sessions");
133
+ }
134
+ function sessionDir(storeBase, sid) {
135
+ return path.join(sessionsRoot(storeBase), sid);
136
+ }
137
+ function compactBackupRoot(storeBase) {
138
+ return path.join(storeBase, "memories", ".compact-backup");
139
+ }
140
+ // ---------- tokens / near-duplicate keys -------------------------------------
141
+ /** Function words carry no identity — dropping them keeps "X 升级计划" and
142
+ * "the X upgrade plan" from being read as unrelated titles. */
143
+ const MEMORY_STOPWORDS = new Set([
144
+ "a", "an", "the", "and", "or", "of", "to", "in", "on", "for", "with",
145
+ "is", "are", "was", "were", "be", "been", "at", "by", "from", "this",
146
+ "that", "these", "those", "it", "its", "as", "not", "do", "does", "can",
147
+ "will", "should", "would", "have", "has", "had", "how", "what", "when",
148
+ "where", "which", "about", "into", "over", "per", "via",
149
+ // CJK particles — single-glyph tokens that carry no identity in a title
150
+ "的", "了", "是", "在", "和", "与", "及", "或", "为", "对", "之", "也",
151
+ "都", "就", "该", "等", "把", "被", "这", "那", "很", "更", "最",
152
+ ]);
153
+ /**
154
+ * Deterministic tokenizer: latin/digit runs + one token per CJK glyph
155
+ * (Chinese titles have no whitespace, so glyph tokens are what make the
156
+ * Jaccard signal work). Stopwords removed.
157
+ */
158
+ export function memoryTokens(text) {
159
+ const s = String(text ?? "").toLowerCase();
160
+ const latin = s.match(/[a-z0-9]+/g) ?? [];
161
+ const cjk = s.match(/[㐀-䶿一-鿿぀-ヿ가-힯]/g) ?? [];
162
+ const out = new Set();
163
+ for (const t of [...latin, ...cjk])
164
+ if (!MEMORY_STOPWORDS.has(t))
165
+ out.add(t);
166
+ return [...out];
167
+ }
168
+ /**
169
+ * Identity key of a memory: category + the sorted token set of its title.
170
+ * Two entries with the same key in the same scope+category are the same fact.
171
+ */
172
+ export function memoryDedupKey(category, title) {
173
+ const toks = memoryTokens(title).sort();
174
+ return `${categorySlug(category)}:${toks.join("+")}`;
175
+ }
176
+ /** Jaccard similarity of two token sets; empty-vs-empty is 0 (no signal). */
177
+ export function memoryJaccard(a, b) {
178
+ const A = new Set(a);
179
+ const B = new Set(b);
180
+ if (!A.size || !B.size)
181
+ return 0;
182
+ let inter = 0;
183
+ for (const t of A)
184
+ if (B.has(t))
185
+ inter++;
186
+ const union = A.size + B.size - inter;
187
+ return union > 0 ? inter / union : 0;
188
+ }
189
+ /** The token bag a merge decision is made on: title ∪ keywords. */
190
+ function dedupTokens(title, keywords) {
191
+ return memoryTokens(title).concat(...keywords.map((k) => memoryTokens(k)));
79
192
  }
80
193
  // ---------- frontmatter (minimal writer/parser, no YAML dep) ------------------
81
194
  function yamlList(items) {
82
195
  return items.map((s) => ` - ${JSON.stringify(s)}`).join("\n");
83
196
  }
197
+ function iso(ts) {
198
+ return typeof ts === "number" && Number.isFinite(ts) ? new Date(ts).toISOString() : "";
199
+ }
200
+ function scalarValue(rest) {
201
+ try {
202
+ return String(JSON.parse(rest));
203
+ }
204
+ catch {
205
+ return rest.replace(/^"|"$/g, "").trim();
206
+ }
207
+ }
84
208
  export function renderMemoryMarkdown(m) {
85
209
  const parts = [
86
210
  "---",
@@ -90,6 +214,18 @@ export function renderMemoryMarkdown(m) {
90
214
  parts.push("usage_scenario:", yamlList(m.usageScenario));
91
215
  if (m.keywords.length)
92
216
  parts.push("keywords:", yamlList(m.keywords));
217
+ const superseded = (m.supersedes ?? []).filter(Boolean).slice(0, MEMORY_SUPERSEDES_MAX);
218
+ if (superseded.length)
219
+ parts.push("supersedes:", yamlList(superseded));
220
+ const created = iso(m.createdAt);
221
+ if (created)
222
+ parts.push(`created_at: ${JSON.stringify(created)}`);
223
+ const updated = iso(m.updatedAt);
224
+ if (updated)
225
+ parts.push(`updated_at: ${JSON.stringify(updated)}`);
226
+ const expires = iso(m.expiresAt);
227
+ if (expires)
228
+ parts.push(`expires: ${JSON.stringify(expires)}`);
93
229
  parts.push("---", "", m.content.trim(), "");
94
230
  return parts.join("\n");
95
231
  }
@@ -107,6 +243,21 @@ export function parseMemoryMarkdown(raw, filePath) {
107
243
  let section = "";
108
244
  const usageScenario = [];
109
245
  const keywords = [];
246
+ const supersedes = [];
247
+ let createdAt = null;
248
+ let updatedAt = null;
249
+ let expiresAt = null;
250
+ const listSections = new Set(["usage_scenario", "keywords", "supersedes"]);
251
+ const pushList = (sec, v) => {
252
+ if (!v)
253
+ return;
254
+ if (sec === "usage_scenario")
255
+ usageScenario.push(v);
256
+ else if (sec === "keywords")
257
+ keywords.push(v);
258
+ else if (sec === "supersedes")
259
+ supersedes.push(v);
260
+ };
110
261
  for (const line of head.split(/\r?\n/)) {
111
262
  if (/^\s+-\s/.test(line) && section) {
112
263
  let v = line.trim().slice(2).trim();
@@ -114,30 +265,39 @@ export function parseMemoryMarkdown(raw, filePath) {
114
265
  v = String(JSON.parse(v));
115
266
  }
116
267
  catch { /* keep raw */ }
117
- if (section === "usage_scenario" && v)
118
- usageScenario.push(v);
119
- if (section === "keywords" && v)
120
- keywords.push(v);
268
+ pushList(section, v);
121
269
  continue;
122
270
  }
123
271
  const kv = /^([a-z_]+)\s*:\s*(.*)$/.exec(line);
124
272
  if (!kv)
125
273
  continue;
126
274
  const [, key, rest] = kv;
127
- section = key === "usage_scenario" || key === "keywords" ? key : "";
128
- if (key === "title") {
129
- try {
130
- title = String(JSON.parse(rest));
131
- }
132
- catch {
133
- title = rest.replace(/^"|"$/g, "");
134
- }
135
- }
275
+ section = listSections.has(key) ? key : "";
276
+ if (key === "title")
277
+ title = scalarValue(rest);
278
+ else if (key === "created_at")
279
+ createdAt = Date.parse(scalarValue(rest)) || null;
280
+ else if (key === "updated_at")
281
+ updatedAt = Date.parse(scalarValue(rest)) || null;
282
+ else if (key === "expires")
283
+ expiresAt = Date.parse(scalarValue(rest)) || null;
284
+ /* unknown keys (and any list under them) are ignored — forward compat */
136
285
  }
137
286
  if (!title)
138
287
  return null;
139
288
  const category = path.basename(path.dirname(filePath));
140
- return { title, usageScenario, keywords, content: body, filePath, category };
289
+ return {
290
+ title,
291
+ usageScenario,
292
+ keywords,
293
+ content: body,
294
+ filePath,
295
+ category,
296
+ supersedes,
297
+ createdAt,
298
+ updatedAt,
299
+ expiresAt,
300
+ };
141
301
  }
142
302
  function walkMemories(dir, out) {
143
303
  let entries;
@@ -148,6 +308,10 @@ function walkMemories(dir, out) {
148
308
  return;
149
309
  }
150
310
  for (const e of entries) {
311
+ // dot-dirs (.compact-backup, editor junk) are never memory content — the
312
+ // compact backup tree must not resurface as searchable memories
313
+ if (e.name.startsWith("."))
314
+ continue;
151
315
  const p = path.join(dir, e.name);
152
316
  if (e.isDirectory())
153
317
  walkMemories(p, out);
@@ -155,16 +319,31 @@ function walkMemories(dir, out) {
155
319
  out.push(p);
156
320
  }
157
321
  }
158
- function listMemoryFiles(globalRoot, storeBase, directory, scope) {
159
- const files = [];
160
- if (!scope || scope === "project")
161
- walkMemories(scopeRoot(globalRoot, storeBase, "project", directory), files);
162
- if (!scope || scope === "global")
163
- walkMemories(scopeRoot(globalRoot, storeBase, "global", directory), files);
164
- return files;
322
+ function mtimeMs(file) {
323
+ try {
324
+ return fs.statSync(file).mtimeMs;
325
+ }
326
+ catch {
327
+ return 0;
328
+ }
165
329
  }
166
- function scopeOf(globalRoot, filePath) {
167
- return filePath.startsWith(globalRoot + path.sep) ? "global" : "project";
330
+ function hitFromParsed(m, scope, sid) {
331
+ return {
332
+ title: m.title,
333
+ category: m.category,
334
+ scope,
335
+ filePath: m.filePath,
336
+ keywords: m.keywords,
337
+ usageScenario: m.usageScenario,
338
+ content: m.content,
339
+ score: 0,
340
+ slug: path.basename(m.filePath, ".md"),
341
+ createdAt: m.createdAt ?? mtimeMs(m.filePath),
342
+ updatedAt: m.updatedAt ?? mtimeMs(m.filePath),
343
+ expiresAt: m.expiresAt,
344
+ supersedes: m.supersedes,
345
+ sid,
346
+ };
168
347
  }
169
348
  /**
170
349
  * Substring scoring — no embeddings, deterministic: title ×5, keywords ×4,
@@ -194,31 +373,7 @@ export function scoreMemory(m, query) {
194
373
  }
195
374
  return score;
196
375
  }
197
- function readAllMemories(globalRoot, storeBase, directory, scope) {
198
- const hits = [];
199
- for (const file of listMemoryFiles(globalRoot, storeBase, directory, scope)) {
200
- try {
201
- const m = parseMemoryMarkdown(fs.readFileSync(file, "utf8"), file);
202
- if (!m)
203
- continue;
204
- hits.push({
205
- title: m.title,
206
- category: m.category,
207
- scope: scopeOf(globalRoot, file),
208
- filePath: file,
209
- keywords: m.keywords,
210
- usageScenario: m.usageScenario,
211
- content: m.content,
212
- score: 0,
213
- });
214
- }
215
- catch {
216
- /* unreadable foreign file — skip */
217
- }
218
- }
219
- return hits;
220
- }
221
- // ---------- tool ----------------------------------------------------------------
376
+ // ---------- the tool ----------------------------------------------------------
222
377
  function asStringArray(v) {
223
378
  if (Array.isArray(v))
224
379
  return v.map((x) => String(x).trim()).filter(Boolean);
@@ -226,15 +381,436 @@ function asStringArray(v) {
226
381
  return v.split(/[,;]/).map((s) => s.trim()).filter(Boolean);
227
382
  return [];
228
383
  }
384
+ /** Unknown / absent scope keeps the historical lenient default: project. */
229
385
  function normalizeScope(v) {
230
- return String(v ?? "").trim().toLowerCase() === "global" ? "global" : "project";
386
+ const s = String(v ?? "").trim().toLowerCase();
387
+ if (s === "global")
388
+ return "global";
389
+ if (s === "session")
390
+ return "session";
391
+ return "project";
392
+ }
393
+ function truthy(v) {
394
+ return v === true || /^(1|true|yes|on)$/i.test(String(v ?? "").trim());
231
395
  }
232
396
  export function buildTmMemoryTool(deps) {
233
- const { storeBase, globalRoot, directory, pipelines } = deps;
397
+ const { storeBase, globalRoot, directory, cfg, pipelines } = deps;
234
398
  const tool = "tm_memory";
235
399
  const traj = (e) => pipelines.store.appendTrajectory({ tool, ...e });
236
- const execute = async (rawArgs) => {
400
+ const now = () => (deps.now ? deps.now() : Date.now());
401
+ // ---- knobs (consumed from the P0 config fields) --------------------------
402
+ const sessionTtlMs = Math.max(1, cfg.memorySessionTtlMin) * 60 * 1000;
403
+ const maxEntries = Math.max(1, cfg.memoryMaxEntries);
404
+ const staleDays = cfg.memoryStaleDays;
405
+ const persistSessions = truthy(cfg.memorySessionPersist);
406
+ // ---- session tier: in-process Map, TTL-swept, disk only when PERSIST ----
407
+ // sid -> ("<category>/<slug>.md" -> entry)
408
+ const sessionStore = new Map();
409
+ const loadedSids = new Set();
410
+ function sessionKey(category, slug) {
411
+ return `${category}/${slug}.md`;
412
+ }
413
+ function sweepSessionMap(sid) {
414
+ const map = sessionStore.get(sid);
415
+ if (!map)
416
+ return 0;
417
+ const t = now();
418
+ let dropped = 0;
419
+ for (const [k, e] of map) {
420
+ if (e.expiresAt !== null && e.expiresAt <= t) {
421
+ map.delete(k);
422
+ dropped++;
423
+ }
424
+ }
425
+ if (!map.size)
426
+ sessionStore.delete(sid);
427
+ return dropped;
428
+ }
429
+ function ensureSessionLoaded(sid) {
430
+ let map = sessionStore.get(sid);
431
+ if (!map) {
432
+ map = new Map();
433
+ sessionStore.set(sid, map);
434
+ }
435
+ if (!persistSessions || loadedSids.has(sid))
436
+ return map;
437
+ loadedSids.add(sid);
438
+ const files = [];
439
+ walkMemories(sessionDir(storeBase, sid), files);
440
+ for (const f of files) {
441
+ try {
442
+ const m = parseMemoryMarkdown(fs.readFileSync(f, "utf8"), f);
443
+ if (!m)
444
+ continue;
445
+ const e = hitFromParsed(m, "session", sid);
446
+ // a persisted entry is only valid while its `expires:` is in the
447
+ // future; one without the line can never be reclaimed, so it goes too
448
+ if (e.expiresAt === null || e.expiresAt <= now()) {
449
+ rmForceSafe(f);
450
+ continue;
451
+ }
452
+ map.set(sessionKey(e.category, e.slug), e);
453
+ }
454
+ catch {
455
+ /* unreadable foreign file — skip */
456
+ }
457
+ }
458
+ return map;
459
+ }
460
+ /** Startup + lazy sweep of the persisted session tree (PERSIST only). */
461
+ function sweepSessionsDisk() {
462
+ if (!persistSessions)
463
+ return 0;
464
+ let dropped = 0;
465
+ let dirs;
237
466
  try {
467
+ dirs = fs.readdirSync(sessionsRoot(storeBase), { withFileTypes: true });
468
+ }
469
+ catch {
470
+ return 0;
471
+ }
472
+ const t = now();
473
+ for (const d of dirs) {
474
+ if (!d.isDirectory() || d.name.startsWith("."))
475
+ continue;
476
+ const files = [];
477
+ walkMemories(path.join(sessionsRoot(storeBase), d.name), files);
478
+ for (const f of files) {
479
+ let expired = false;
480
+ try {
481
+ const m = parseMemoryMarkdown(fs.readFileSync(f, "utf8"), f);
482
+ expired = !!m && (m.expiresAt === null || m.expiresAt <= t);
483
+ }
484
+ catch {
485
+ expired = false;
486
+ }
487
+ if (expired) {
488
+ rmForceSafe(f);
489
+ dropped++;
490
+ }
491
+ }
492
+ // drop the session dir once nothing is left in it
493
+ try {
494
+ const sidDir = path.join(sessionsRoot(storeBase), d.name);
495
+ const rest = [];
496
+ walkMemories(sidDir, rest);
497
+ if (!rest.length)
498
+ rmForceSafe(sidDir, { recursive: true });
499
+ }
500
+ catch {
501
+ /* best effort */
502
+ }
503
+ }
504
+ return dropped;
505
+ }
506
+ // boot sweep — expired session files never survive a plugin restart
507
+ sweepSessionsDisk();
508
+ function sessionEntries(sid) {
509
+ sweepSessionMap(sid);
510
+ return [...ensureSessionLoaded(sid).values()];
511
+ }
512
+ /** Persist a session entry when PERSIST=1 and return its real path; with
513
+ * the default (ephemeral) tier nothing is written, so a display-only
514
+ * virtual path comes back. */
515
+ function persistSessionEntry(e, sid) {
516
+ if (!persistSessions)
517
+ return `session/${sid}/${e.category}/${e.slug}.md`;
518
+ return writeSessionFile(e, sid);
519
+ }
520
+ function writeSessionFile(e, sid) {
521
+ const file = path.join(sessionDir(storeBase, sessionIdSlug(sid)), e.category, `${e.slug}.md`);
522
+ fs.mkdirSync(path.dirname(file), { recursive: true });
523
+ fs.writeFileSync(file, renderMemoryMarkdown({
524
+ title: e.title,
525
+ usageScenario: e.usageScenario,
526
+ keywords: e.keywords,
527
+ content: e.content,
528
+ supersedes: e.supersedes,
529
+ createdAt: e.createdAt,
530
+ updatedAt: e.updatedAt,
531
+ expiresAt: e.expiresAt,
532
+ }), "utf8");
533
+ e.filePath = file;
534
+ return file;
535
+ }
536
+ function removeSessionBacking(e) {
537
+ if (!persistSessions)
538
+ return;
539
+ try {
540
+ if (fs.existsSync(e.filePath))
541
+ rmForceSafe(e.filePath);
542
+ }
543
+ catch {
544
+ /* best effort — the Map is the source of truth */
545
+ }
546
+ }
547
+ // ---- disk readers --------------------------------------------------------
548
+ function diskRoot(scope) {
549
+ return scope === "global" ? globalRoot : projectRoot(storeBase, directory);
550
+ }
551
+ function diskEntries(scope) {
552
+ const hits = [];
553
+ for (const file of walkScopeFiles(scope)) {
554
+ try {
555
+ const m = parseMemoryMarkdown(fs.readFileSync(file, "utf8"), file);
556
+ if (!m)
557
+ continue;
558
+ hits.push(hitFromParsed(m, scope, ""));
559
+ }
560
+ catch {
561
+ /* unreadable foreign file — skip */
562
+ }
563
+ }
564
+ return hits.sort((a, b) => a.filePath.localeCompare(b.filePath));
565
+ }
566
+ function walkScopeFiles(scope) {
567
+ const files = [];
568
+ walkMemories(diskRoot(scope), files);
569
+ return files;
570
+ }
571
+ function entriesFor(scopes, sid) {
572
+ const out = [];
573
+ if (scopes.includes("project"))
574
+ out.push(...diskEntries("project"));
575
+ if (scopes.includes("global"))
576
+ out.push(...diskEntries("global"));
577
+ if (scopes.includes("session"))
578
+ out.push(...sessionEntries(sid));
579
+ return out;
580
+ }
581
+ /** Read every file backing an entry set path (parse → same shape as diskEntries). */
582
+ function readEntry(filePath, scope, sid) {
583
+ try {
584
+ const m = parseMemoryMarkdown(fs.readFileSync(filePath, "utf8"), filePath);
585
+ return m ? hitFromParsed(m, scope, sid) : null;
586
+ }
587
+ catch {
588
+ return null;
589
+ }
590
+ }
591
+ function writeDiskEntry(e) {
592
+ fs.mkdirSync(path.dirname(e.filePath), { recursive: true });
593
+ fs.writeFileSync(e.filePath, renderMemoryMarkdown({
594
+ title: e.title,
595
+ usageScenario: e.usageScenario,
596
+ keywords: e.keywords,
597
+ content: e.content,
598
+ supersedes: e.supersedes,
599
+ createdAt: e.createdAt,
600
+ updatedAt: e.updatedAt,
601
+ expiresAt: e.expiresAt,
602
+ }), "utf8");
603
+ return e.filePath;
604
+ }
605
+ function memoryPath(scope, sid, category, slug) {
606
+ const root = scope === "global"
607
+ ? path.join(globalRoot, category)
608
+ : scope === "session"
609
+ ? path.join(sessionDir(storeBase, sessionIdSlug(sid)), category)
610
+ : path.join(projectRoot(storeBase, directory), category);
611
+ return path.join(root, `${slug}.md`);
612
+ }
613
+ /** Find the existing entry a new add should fold into (same scope+category
614
+ * only — a different category is a different memory by contract). */
615
+ function findMergeTarget(candidates, category, title, keywords, selfPath) {
616
+ const key = memoryDedupKey(category, title);
617
+ const tokens = dedupTokens(title, keywords);
618
+ let best = null;
619
+ if (key.includes(":")) {
620
+ const [, keyToks] = key.split(":");
621
+ if (keyToks) {
622
+ for (const c of candidates) {
623
+ if (c.scope !== "session" && !c.filePath)
624
+ continue;
625
+ if (c.category !== category)
626
+ continue;
627
+ if (c.filePath === selfPath)
628
+ continue;
629
+ if (memoryDedupKey(c.category, c.title) !== key)
630
+ continue;
631
+ const sim = memoryJaccard(tokens, dedupTokens(c.title, c.keywords));
632
+ if (!best || sim > best.similarity)
633
+ best = { target: c, via: "key", similarity: sim };
634
+ }
635
+ }
636
+ }
637
+ for (const c of candidates) {
638
+ if (c.category !== category)
639
+ continue;
640
+ if (c.filePath === selfPath)
641
+ continue;
642
+ const sim = memoryJaccard(tokens, dedupTokens(c.title, c.keywords));
643
+ if (sim >= MEMORY_DEDUP_JACCARD && (!best || sim > best.similarity)) {
644
+ best = { target: c, via: "jaccard", similarity: sim };
645
+ }
646
+ }
647
+ return best;
648
+ }
649
+ /** Fold `incoming` content into `keeper` (newest content wins, keywords /
650
+ * scenarios union, keeper absorbs incoming's identity + history). */
651
+ function mergeInto(keeper, incoming) {
652
+ const union = (a, b, cap) => [...new Set([...a, ...b])].slice(0, cap);
653
+ keeper.content = incoming.content;
654
+ keeper.keywords = union(keeper.keywords, incoming.keywords, MEMORY_KEYWORDS_MAX);
655
+ keeper.usageScenario = union(keeper.usageScenario, incoming.usageScenario, MEMORY_SCENARIOS_MAX);
656
+ const absorbed = [...new Set([...keeper.supersedes, incoming.slug])].filter((s) => s && s !== keeper.slug);
657
+ keeper.supersedes = absorbed.slice(0, MEMORY_SUPERSEDES_MAX);
658
+ keeper.updatedAt = incoming.at;
659
+ keeper.createdAt = keeper.createdAt || incoming.at;
660
+ }
661
+ // ---- staleness + precedence ---------------------------------------------
662
+ function staleTag(e) {
663
+ if (staleDays <= 0 || e.scope === "session")
664
+ return "";
665
+ const age = Math.floor((now() - e.updatedAt) / DAY_MS);
666
+ return age > staleDays ? ` [stale ${age}d]` : "";
667
+ }
668
+ function scopeBonus(e, base) {
669
+ if (base <= 0)
670
+ return base;
671
+ if (e.scope === "session")
672
+ return base + MEMORY_SESSION_SCOPE_BONUS;
673
+ if (e.scope === "project")
674
+ return base + MEMORY_PROJECT_SCOPE_BONUS;
675
+ return base;
676
+ }
677
+ /** Union-find over same scope+category entries whose keys match or whose
678
+ * token Jaccard clears the threshold. */
679
+ function planGroups(entries) {
680
+ const groups = [];
681
+ const byCat = new Map();
682
+ for (const e of entries) {
683
+ const k = `${e.scope}|${e.category}`;
684
+ byCat.set(k, [...(byCat.get(k) ?? []), e]);
685
+ }
686
+ for (const [, bucket] of [...byCat.entries()].sort()) {
687
+ if (bucket.length < 2)
688
+ continue;
689
+ const parent = bucket.map((_, i) => i);
690
+ const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
691
+ const toks = bucket.map((e) => dedupTokens(e.title, e.keywords));
692
+ const keys = bucket.map((e) => memoryDedupKey(e.category, e.title).split(":")[1] ?? "");
693
+ for (let i = 0; i < bucket.length; i++) {
694
+ for (let j = i + 1; j < bucket.length; j++) {
695
+ // same identity key (when it carries tokens) or a close-enough
696
+ // title∪keywords bag → same fact
697
+ if ((keys[i] && keys[i] === keys[j]) || memoryJaccard(toks[i], toks[j]) >= MEMORY_DEDUP_JACCARD) {
698
+ parent[find(i)] = find(j);
699
+ }
700
+ }
701
+ }
702
+ const clusters = new Map();
703
+ bucket.forEach((e, i) => {
704
+ const r = find(i);
705
+ clusters.set(r, [...(clusters.get(r) ?? []), e]);
706
+ });
707
+ for (const members of clusters.values()) {
708
+ if (members.length < 2)
709
+ continue;
710
+ // the survivor is the ORIGINAL (earliest created); ties break on path
711
+ const keeper = [...members].sort((a, b) => a.createdAt - b.createdAt || a.filePath.localeCompare(b.filePath))[0];
712
+ groups.push({ keeper, members, category: keeper.category, scope: keeper.scope });
713
+ }
714
+ }
715
+ return groups.sort((a, b) => a.scope.localeCompare(b.scope) ||
716
+ a.category.localeCompare(b.category) ||
717
+ a.keeper.filePath.localeCompare(b.keeper.filePath));
718
+ }
719
+ /** Union of a cluster into its keeper (content 取新, lists unioned). */
720
+ function foldGroup(keeper, members) {
721
+ const newest = [...members].sort((a, b) => b.updatedAt - a.updatedAt)[0];
722
+ const union = (a, b, cap) => [...new Set([...a, ...b])].slice(0, cap);
723
+ keeper.content = newest.content;
724
+ keeper.keywords = union(keeper.keywords, newest.keywords, MEMORY_KEYWORDS_MAX);
725
+ keeper.usageScenario = union(keeper.usageScenario, newest.usageScenario, MEMORY_SCENARIOS_MAX);
726
+ const absorbed = [];
727
+ for (const m of members) {
728
+ if (m.slug === keeper.slug)
729
+ continue;
730
+ absorbed.push(m.slug, ...m.supersedes);
731
+ }
732
+ keeper.supersedes = [...new Set([...keeper.supersedes, ...absorbed])]
733
+ .filter((s) => s && s !== keeper.slug)
734
+ .slice(0, MEMORY_SUPERSEDES_MAX);
735
+ keeper.createdAt = Math.min(...members.map((m) => m.createdAt));
736
+ keeper.updatedAt = now();
737
+ }
738
+ /** Relative path of an entry under its layer root (backup layout mirror). */
739
+ function relFromScopeRoot(e) {
740
+ const root = e.scope === "global"
741
+ ? globalRoot
742
+ : e.scope === "project"
743
+ ? projectRoot(storeBase, directory)
744
+ : sessionsRoot(storeBase);
745
+ const rel = path.relative(root, e.filePath);
746
+ return !rel || rel.startsWith("..") ? path.join(e.category, `${e.slug}.md`) : rel;
747
+ }
748
+ /** Copy the originals of a group into the backup tree BEFORE mutating —
749
+ * a failed backup aborts the group (never delete without a rollback path). */
750
+ function backupGroup(members, backupRoot) {
751
+ for (const m of members) {
752
+ // an ephemeral (non-persisted) session entry has no file to back up —
753
+ // the merge is reported as irreversible for that entry
754
+ if (!persistSessions && m.scope === "session")
755
+ continue;
756
+ if (!m.filePath || !fs.existsSync(m.filePath))
757
+ continue;
758
+ try {
759
+ const dest = path.join(backupRoot, m.scope, relFromScopeRoot(m));
760
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
761
+ fs.copyFileSync(m.filePath, dest);
762
+ }
763
+ catch (err) {
764
+ return `${m.slug}: 备份失败 (${String(err?.message ?? err)})`;
765
+ }
766
+ }
767
+ return null;
768
+ }
769
+ function applyGroup(g, backupRoot) {
770
+ const victims = g.members.filter((m) => m !== g.keeper);
771
+ const failed = backupGroup(g.members, backupRoot);
772
+ if (failed)
773
+ return { ok: false, note: `跳过(${failed})` };
774
+ foldGroup(g.keeper, g.members);
775
+ try {
776
+ if (g.scope === "session") {
777
+ const map = ensureSessionLoaded(g.keeper.sid);
778
+ for (const v of victims) {
779
+ map.delete(sessionKey(v.category, v.slug));
780
+ removeSessionBacking(v);
781
+ }
782
+ map.set(sessionKey(g.keeper.category, g.keeper.slug), g.keeper);
783
+ if (persistSessions)
784
+ writeSessionFile(g.keeper, g.keeper.sid);
785
+ }
786
+ else {
787
+ writeDiskEntry(g.keeper);
788
+ for (const v of victims) {
789
+ try {
790
+ if (fs.existsSync(v.filePath))
791
+ rmForceSafe(v.filePath);
792
+ }
793
+ catch {
794
+ /* rmForceSafe already no-ops on a vanished file */
795
+ }
796
+ }
797
+ }
798
+ }
799
+ catch (err) {
800
+ return { ok: false, note: `写入失败 (${String(err?.message ?? err)})` };
801
+ }
802
+ return { ok: true, note: `→ ${g.keeper.filePath}` };
803
+ }
804
+ // ---- tool ----------------------------------------------------------------
805
+ const sidOf = (ctx) => sessionIdSlug(ctx?.sessionID);
806
+ const execute = async (rawArgs, ctx) => {
807
+ const sid = sidOf(ctx);
808
+ try {
809
+ // lazy TTL sweep of the persisted session tier (the boot sweep above
810
+ // only covers a restart — an entry that expired mid-run must not survive
811
+ // the next call)
812
+ if (persistSessions)
813
+ sweepSessionsDisk();
238
814
  const args = rawArgs ?? {};
239
815
  const action = String(args.action ?? "").trim();
240
816
  if (action === "add") {
@@ -249,47 +825,158 @@ export function buildTmMemoryTool(deps) {
249
825
  }
250
826
  const scope = normalizeScope(args.scope);
251
827
  const category = categorySlug(String(args.category ?? "notes"));
252
- const usageScenario = asStringArray(args.usage_scenario).slice(0, 8);
253
- const keywords = asStringArray(args.keywords).slice(0, 10);
254
- const file = path.join(scopeRoot(globalRoot, storeBase, scope, directory), category, `${titleSlug(title)}.md`);
255
- const existed = fs.existsSync(file);
256
- fs.mkdirSync(path.dirname(file), { recursive: true });
257
- const md = renderMemoryMarkdown({
828
+ const usageScenario = asStringArray(args.usage_scenario).slice(0, MEMORY_SCENARIOS_MAX);
829
+ const keywords = asStringArray(args.keywords).slice(0, MEMORY_KEYWORDS_MAX);
830
+ const slug = titleSlug(title);
831
+ const t = now();
832
+ const selfPath = memoryPath(scope, sid, category, slug);
833
+ const sameFile = scope === "session"
834
+ ? sessionEntries(sid).find((e) => e.category === category && e.slug === slug) ?? null
835
+ : readEntry(selfPath, scope, "");
836
+ // candidates = the whole layer (the same-file case is a plain update)
837
+ const candidates = entriesFor([scope], sid).filter((e) => e.filePath !== selfPath);
838
+ const dup = findMergeTarget(candidates, category, title, keywords, selfPath);
839
+ if (!sameFile && !dup && countInScope(candidates, scope) >= maxEntries) {
840
+ return tmError(tool, "args", `${scope} 层记忆已达上限 ${maxEntries} 条(memoryMaxEntries)——不再新增。` +
841
+ `先 tm_memory compact 压实近似重复(默认 dry-run,apply:true 执行,原件自动备份),` +
842
+ `或 tm_memory forget 过时条目。`);
843
+ }
844
+ // 1) exact identity → in-place update (behaviour unchanged)
845
+ if (sameFile) {
846
+ sameFile.title = title.slice(0, MEMORY_TITLE_MAX);
847
+ sameFile.content = content;
848
+ if (keywords.length)
849
+ sameFile.keywords = keywords;
850
+ if (usageScenario.length)
851
+ sameFile.usageScenario = usageScenario;
852
+ sameFile.updatedAt = t;
853
+ const where = scope === "session" ? persistSessionEntry(sameFile, sid) : writeDiskEntry(sameFile);
854
+ traj({ step_id: "memory", event: "update", scope, category, title: shorten(title, 80) });
855
+ return finish(scope, `记忆已更新(${scope}):${where}`, category, title, sid);
856
+ }
857
+ // 2) near-duplicate → MERGE into the existing entry, no new file
858
+ if (dup) {
859
+ mergeInto(dup.target, {
860
+ title: title.slice(0, MEMORY_TITLE_MAX),
861
+ content,
862
+ keywords,
863
+ usageScenario,
864
+ slug,
865
+ at: t,
866
+ });
867
+ const where = scope === "session" ? persistSessionEntry(dup.target, sid) : writeDiskEntry(dup.target);
868
+ traj({
869
+ step_id: "memory",
870
+ event: "merge",
871
+ scope,
872
+ category,
873
+ title: shorten(title, 80),
874
+ via: dup.via,
875
+ similarity: Number(dup.similarity.toFixed(2)),
876
+ into: dup.target.slug,
877
+ });
878
+ return finish(scope, `已合并:${where}\n去重命中(${dup.via === "key" ? "同 dedupKey" : `Jaccard ${dup.similarity.toFixed(2)} ≥ ${MEMORY_DEDUP_JACCARD}`})——` +
879
+ `未新建文件,标题保持 "${shorten(dup.target.title, 80)}",content 取新值,keywords 取并集,` +
880
+ `supersedes 记录 "${slug}"。`, category, title, sid);
881
+ }
882
+ // 3) fresh memory
883
+ if (scope === "session") {
884
+ const entry = {
885
+ title: title.slice(0, MEMORY_TITLE_MAX),
886
+ category,
887
+ scope,
888
+ filePath: memoryPath("session", sid, category, slug),
889
+ keywords,
890
+ usageScenario,
891
+ content,
892
+ score: 0,
893
+ slug,
894
+ createdAt: t,
895
+ updatedAt: t,
896
+ expiresAt: t + sessionTtlMs,
897
+ supersedes: [],
898
+ sid,
899
+ };
900
+ ensureSessionLoaded(sid).set(sessionKey(category, slug), entry);
901
+ const where = persistSessionEntry(entry, sid);
902
+ traj({ step_id: "memory", event: "add", scope, category, title: shorten(title, 80), persisted: persistSessions });
903
+ return finish(scope, persistSessions
904
+ ? `记忆已保存(session):${where}`
905
+ : `记忆已保存(session,进程内瞬态,不落盘):${where}`, category, title, sid);
906
+ }
907
+ fs.mkdirSync(path.dirname(selfPath), { recursive: true });
908
+ writeDiskEntry({
258
909
  title: title.slice(0, MEMORY_TITLE_MAX),
259
- usageScenario,
910
+ category,
911
+ scope,
912
+ filePath: selfPath,
260
913
  keywords,
914
+ usageScenario,
261
915
  content,
916
+ score: 0,
917
+ slug,
918
+ createdAt: t,
919
+ updatedAt: t,
920
+ expiresAt: null,
921
+ supersedes: [],
922
+ sid: "",
262
923
  });
263
- fs.writeFileSync(file, md, "utf8");
264
924
  traj({ step_id: "memory", event: "add", scope, category, title: shorten(title, 80) });
265
- return `记忆已${existed ? "更新" : "保存"}(${scope}):${file}\n分类: ${category}\n标题: ${shorten(title, 80)}\n注入时机: 该项目的 lead/researcher 通过 tm_memory search 检索后进入派发上下文。`;
925
+ return finish(scope, `记忆已保存(${scope}):${selfPath}`, category, title, sid);
266
926
  }
267
927
  if (action === "search") {
268
928
  const query = String(args.query ?? "").trim();
269
929
  if (!query)
270
930
  return tmError(tool, "args", "缺少 query 参数");
271
- const scope = args.scope ? normalizeScope(args.scope) : undefined;
272
- const all = readAllMemories(globalRoot, storeBase, directory, scope);
931
+ // a scope-less search walks ALL THREE layers
932
+ const scopes = args.scope ? [normalizeScope(args.scope)] : MEMORY_ALL_SCOPES;
933
+ sweepSessionMap(sid);
934
+ const all = entriesFor(scopes, sid);
273
935
  for (const m of all)
274
- m.score = scoreMemory(m, query);
275
- const top = all
276
- .filter((m) => m.score > 0)
936
+ m.score = scopeBonus(m, scoreMemory(m, query));
937
+ const candidates = all.filter((m) => m.score > 0);
938
+ const byTitle = new Map();
939
+ for (const m of candidates) {
940
+ const k = m.title.trim().toLowerCase();
941
+ const cur = byTitle.get(k);
942
+ if (!cur || scopeRank(m.scope) > scopeRank(cur))
943
+ byTitle.set(k, m.scope);
944
+ }
945
+ const shadowedBy = (m) => {
946
+ const top = byTitle.get(m.title.trim().toLowerCase()) ?? m.scope;
947
+ return top !== m.scope ? top : null;
948
+ };
949
+ const shadowNoteFor = (s) => s === "project" ? "已被项目层优先遮蔽" : s === "session" ? "已被会话层优先遮蔽" : "已被上层记忆优先遮蔽";
950
+ const top = candidates
951
+ .filter((m) => !shadowedBy(m))
277
952
  .sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
278
953
  .slice(0, MEMORY_SEARCH_RESULTS);
279
954
  traj({ step_id: "memory", event: "search", query: shorten(query, 80), hits: top.length });
280
955
  if (!top.length)
281
956
  return `无匹配记忆(query: ${shorten(query, 80)})。可用 tm_memory list 查看 existing 记忆。`;
282
957
  const blocks = top.map((m) => [
283
- `[score ${m.score}] ${m.title} (${m.scope}/${m.category})`,
958
+ `[score ${m.score}] ${m.title} (${m.scope}/${m.category})${staleTag(m)}`,
284
959
  m.keywords.length ? `keywords: ${m.keywords.join(", ")}` : "",
285
960
  `--- 内容 ---`,
286
961
  shorten(m.content, MEMORY_EXCERPT_MAX),
287
962
  ].filter(Boolean).join("\n"));
288
- return `命中 ${top.length}/${all.length} 条记忆(按相关度,取前 ${MEMORY_SEARCH_RESULTS}):\n\n${blocks.join("\n\n")}`;
963
+ const shadowCounts = new Map();
964
+ for (const m of candidates) {
965
+ const s = shadowedBy(m);
966
+ if (s)
967
+ shadowCounts.set(s, (shadowCounts.get(s) ?? 0) + 1);
968
+ }
969
+ const header = `命中 ${top.length}/${all.length} 条记忆(按相关度,取前 ${MEMORY_SEARCH_RESULTS}):`;
970
+ const shadowNote = [...shadowCounts.entries()]
971
+ .sort((a, b) => scopeRank(b[0]) - scopeRank(a[0]))
972
+ .map(([s, n]) => `\n(${n} 条同名记忆${shadowNoteFor(s)})`)
973
+ .join("");
974
+ return `${header}${shadowNote}\n\n${blocks.join("\n\n")}`;
289
975
  }
290
976
  if (action === "list") {
291
- const scope = args.scope ? normalizeScope(args.scope) : undefined;
292
- const all = readAllMemories(globalRoot, storeBase, directory, scope);
977
+ const scopes = args.scope ? [normalizeScope(args.scope)] : MEMORY_ALL_SCOPES;
978
+ sweepSessionMap(sid);
979
+ const all = entriesFor(scopes, sid);
293
980
  traj({ step_id: "memory", event: "list", count: all.length });
294
981
  if (!all.length)
295
982
  return "当前没有任何记忆。用 tm_memory add 保存第一条。";
@@ -304,7 +991,7 @@ export function buildTmMemoryTool(deps) {
304
991
  for (const t of titles)
305
992
  lines.push(` - ${t}`);
306
993
  }
307
- lines.push("", "检索用 tm_memory search;删除用 tm_memory forget。");
994
+ lines.push("", "检索用 tm_memory search;删除用 tm_memory forget;膨胀用 tm_memory compact(默认 dry-run)。");
308
995
  return lines.join("\n");
309
996
  }
310
997
  if (action === "forget") {
@@ -312,8 +999,13 @@ export function buildTmMemoryTool(deps) {
312
999
  if (!title)
313
1000
  return tmError(tool, "args", "缺少 title 参数");
314
1001
  const stem = titleSlug(title);
315
- const scope = args.scope ? normalizeScope(args.scope) : undefined;
316
- const files = listMemoryFiles(globalRoot, storeBase, directory, scope).filter((f) => {
1002
+ const scopeFilter = args.scope ? [normalizeScope(args.scope)] : ["project", "global", "session"];
1003
+ sweepSessionMap(sid);
1004
+ // disk layers only — the session tier is pruned from its Map below
1005
+ const files = scopeFilter
1006
+ .filter((s) => s !== "session")
1007
+ .flatMap((s) => walkScopeFiles(s));
1008
+ const matched = files.filter((f) => {
317
1009
  if (path.basename(f, ".md") !== stem)
318
1010
  return false;
319
1011
  // slugs collide ("API Rate Limits" / "API rate-limits") — when the
@@ -323,38 +1015,128 @@ export function buildTmMemoryTool(deps) {
323
1015
  const m = parseMemoryMarkdown(fs.readFileSync(f, "utf8"), f);
324
1016
  return m === null || m.title.trim().toLowerCase() === title.toLowerCase();
325
1017
  });
326
- if (!files.length)
1018
+ let sessionGone = 0;
1019
+ if (scopeFilter.includes("session")) {
1020
+ const map = ensureSessionLoaded(sid);
1021
+ for (const [k, e] of [...map.entries()]) {
1022
+ if (e.slug === stem && e.title.trim().toLowerCase() === title.toLowerCase()) {
1023
+ map.delete(k);
1024
+ removeSessionBacking(e);
1025
+ sessionGone++;
1026
+ }
1027
+ }
1028
+ }
1029
+ const total = matched.length + sessionGone;
1030
+ if (!total) {
327
1031
  return tmError(tool, "args", `没有找到标题为 "${shorten(title, 80)}" 的记忆(可用 tm_memory list 确认)`);
328
- for (const f of files)
1032
+ }
1033
+ for (const f of matched)
329
1034
  rmForceSafe(f);
330
- traj({ step_id: "memory", event: "forget", count: files.length, title: shorten(title, 80) });
331
- return `已删除 ${files.length} 条记忆:${files.join(", ")}`;
1035
+ traj({ step_id: "memory", event: "forget", count: total, title: shorten(title, 80) });
1036
+ const parts = [];
1037
+ if (matched.length)
1038
+ parts.push(matched.join(", "));
1039
+ if (sessionGone)
1040
+ parts.push(`session/${sid} × ${sessionGone}`);
1041
+ return `已删除 ${total} 条记忆:${parts.join(", ")}`;
332
1042
  }
333
- return tmError(tool, "args", `未知 action "${shorten(action, 30)}"——可用: add | search | list | forget`);
1043
+ if (action === "compact") {
1044
+ const apply = truthy(args.apply);
1045
+ const rawScope = String(args.scope ?? "project").trim().toLowerCase();
1046
+ const scopes = rawScope === "all" || rawScope === ""
1047
+ ? ["project", "global", "session"]
1048
+ : [normalizeScope(rawScope)];
1049
+ sweepSessionMap(sid);
1050
+ const entries = entriesFor(scopes, sid);
1051
+ const groups = planGroups(entries);
1052
+ const total = entries.length;
1053
+ const mergedCount = groups.reduce((n, g) => n + g.members.length - 1, 0);
1054
+ const tsLabel = new Date(now()).toISOString().replace(/[:.]/g, "-");
1055
+ const backupRoot = path.join(compactBackupRoot(storeBase), tsLabel);
1056
+ traj({ step_id: "memory", event: "compact", apply, scopes: scopes.join("+"), groups: groups.length, entries: total });
1057
+ if (!groups.length) {
1058
+ return `compact(${scopes.join("/")}):${total} 条记忆,未发现近似重复(无需压实)。` +
1059
+ (apply ? "" : "\n(dry-run;apply:true 才执行)");
1060
+ }
1061
+ if (!apply) {
1062
+ const report = groups.map((g) => {
1063
+ const victims = g.members.filter((m) => m !== g.keeper);
1064
+ return [
1065
+ `- [${g.scope}/${g.category}] 保留 "${g.keeper.title}" (${g.keeper.slug})`,
1066
+ ...victims.map((v) => ` 并入 "${v.title}" (${v.slug}) ← ${v.filePath}`),
1067
+ ].join("\n");
1068
+ });
1069
+ return [
1070
+ `compact dry-run(未执行任何修改):${groups.length} 个重复簇,涉及 ${mergedCount + groups.length} 条,将减少 ${mergedCount} 条(当前 ${total} 条)。`,
1071
+ ...report.slice(0, 40),
1072
+ ...(report.length > 40 ? [`…(另有 ${report.length - 40} 簇未列出)`] : []),
1073
+ "",
1074
+ `加 apply:true 执行;原件先复制到 ${backupRoot}(内部按 <scope>/<相对路径> 排列,回滚=原样复制回去)。`,
1075
+ ].join("\n");
1076
+ }
1077
+ const done = [];
1078
+ const skipped = [];
1079
+ let reduced = 0;
1080
+ for (const g of groups) {
1081
+ const r = applyGroup(g, backupRoot);
1082
+ if (r.ok) {
1083
+ reduced += g.members.length - 1;
1084
+ done.push(`- [${g.scope}/${g.category}] "${g.keeper.title}" ${r.note}`);
1085
+ }
1086
+ else {
1087
+ skipped.push(`- [${g.scope}/${g.category}] "${g.keeper.title}":${r.note}`);
1088
+ }
1089
+ }
1090
+ return [
1091
+ `compact 已执行:合并 ${done.length} 簇,减少 ${reduced} 条(${total} → ${total - reduced})。`,
1092
+ ...done.slice(0, 40),
1093
+ ...(skipped.length ? ["", `跳过 ${skipped.length} 簇:`, ...skipped.slice(0, 20)] : []),
1094
+ "",
1095
+ `备份(回滚路径):${backupRoot}(内部按 <scope>/<相对路径> 排列)`,
1096
+ ].join("\n");
1097
+ }
1098
+ return tmError(tool, "args", `未知 action "${shorten(action, 30)}"——可用: add | search | list | forget | compact`);
334
1099
  }
335
1100
  catch (err) {
336
1101
  return tmError(tool, "execute", String(err?.message ?? err ?? "memory 操作失败"));
337
1102
  }
338
1103
  };
339
- const DESCRIPTION = `Project/global memory store (Markdown files with frontmatter — persistent across conversations). Actions: add | search | list | forget.
340
- - add: { title, content (≤4000 chars, one condensed fact), category?, keywords?, usage_scenario?, scope? ("project" default | "global") }. Seeded categories: ${MEMORY_CATEGORIES.join(" / ")}; free-form allowed.
341
- - search: { query } — deterministic substring scoring (title > keywords > usage_scenario > body), top ${MEMORY_SEARCH_RESULTS}. Use BEFORE assuming project conventions.
1104
+ /** Shared add tail: the injection-timing note per layer. */
1105
+ function finish(scope, head, category, title, sid) {
1106
+ const injectTiming = scope === "global"
1107
+ ? "注入时机: 任何项目的 lead/researcher 通过 tm_memory search 检索后进入派发上下文(跨项目可见)。"
1108
+ : scope === "session"
1109
+ ? `注入时机: 仅本会话(sessionID=${sid})可见,TTL ${cfg.memorySessionTtlMin} 分钟后自动清扫${persistSessions ? "(已镜像落盘)" : "(不落盘)"}。`
1110
+ : "注入时机: 该项目的 lead/researcher 通过 tm_memory search 检索后进入派发上下文。";
1111
+ return `${head}\n分类: ${category}\n标题: ${shorten(title, 80)}\n${injectTiming}`;
1112
+ }
1113
+ function countInScope(entries, scope) {
1114
+ return entries.filter((e) => e.scope === scope).length;
1115
+ }
1116
+ const DESCRIPTION = `Project/global/session memory store (Markdown files with frontmatter — persistent across conversations, except the ephemeral session tier). Actions: add | search | list | forget | compact.
1117
+ - add: { title, content (≤4000 chars, one condensed fact), category?, keywords?, usage_scenario?, scope? ("project" default | "global" | "session") }. Seeded categories: ${MEMORY_CATEGORIES.join(" / ")}; free-form allowed.
1118
+ - Near-duplicate merge: an add that hits an existing entry in the SAME scope+category (same dedupKey = category + stopword-stripped title token set, or title∪keywords Jaccard ≥ ${MEMORY_DEDUP_JACCARD}) MERGES into that entry instead of creating a second file — content takes the new value, keywords union, frontmatter \`supersedes:\` records the folded slug. The reply says "已合并:path".
1119
+ - search: { query } — deterministic substring scoring (title > keywords > usage_scenario > body), top ${MEMORY_SEARCH_RESULTS}. Use BEFORE assuming project conventions. Entries older than cfg.memoryStaleDays (TM_MEMORY_STALE_DAYS, 0 = off) carry a \`[stale Nd]\` marker.
342
1120
  - list: { scope? } — everything, grouped.
343
- - forget: { title } — delete by title.
344
- - What belongs here: durable project facts (build commands, environment quirks, architecture decisions, user-stated conventions that outlive one conversation). What does NOT: task state (todo list owns that), oversized docs (board files own those).`;
1121
+ - forget: { title } — delete by title (all layers unless scope narrows it).
1122
+ - compact: { scope? ("project" default | "global" | "session" | "all"), apply? } — consolidate the near-duplicates that already exist. DRY-RUN by default (reports the planned merges, changes nothing); apply:true performs them after copying every original to \`memories/.compact-backup/<UTC ts>/<scope>/…\`, which is the rollback path.
1123
+ - Layers: session — transients for THIS conversation only (in-process, TTL-swept, not persisted unless TM_MEMORY_SESSION_PERSIST=1); project (default) — facts about THIS repo (build commands, environment quirks, architecture decisions); global — user-level conventions and preferences that follow the user across repos (preferred toolchain, commit style). search walks EVERY layer with session > project > global precedence: scope weight, same-title lower-layer entries shadowed.
1124
+ - Bloat guard: over TM_MEMORY_MAX_ENTRIES per scope, add fails and points at compact/forget.
1125
+ - What does NOT belong: task state (todo list owns that), oversized docs (board files own those).`;
345
1126
  return {
346
1127
  description: DESCRIPTION,
347
1128
  args: deps.args ?? {
348
- action: { descriptor: "action: add|search|list|forget (required)" },
1129
+ action: { descriptor: "action: add|search|list|forget|compact (required)" },
349
1130
  title: { descriptor: "title: string (add/forget)" },
350
1131
  content: { descriptor: "content: string (add, ≤4000 chars)" },
351
1132
  category: { descriptor: "category: string (add, optional)" },
352
1133
  keywords: { descriptor: "keywords: string[] or comma string (add, optional)" },
353
1134
  usage_scenario: { descriptor: "usage_scenario: string[] or comma string (add, optional)" },
354
1135
  query: { descriptor: "query: string (search)" },
355
- scope: { descriptor: "scope: project|global (optional, default project)" },
1136
+ scope: { descriptor: "scope: project|global|session (optional, default project; compact also accepts \"all\")" },
1137
+ apply: { descriptor: "apply: true to let compact write changes (compact only; absent = dry-run)" },
356
1138
  },
357
- execute: async (rawArgs) => toToolResult(await execute(rawArgs)),
1139
+ execute: async (rawArgs, ctx) => toToolResult(await execute(rawArgs, ctx)),
358
1140
  };
359
1141
  }
360
1142
  //# sourceMappingURL=memory.js.map