@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.
- package/README.md +154 -57
- package/README.zh-CN.md +126 -47
- package/dist/agents.d.ts +5 -1
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +37 -11
- package/dist/agents.js.map +1 -1
- package/dist/approval-gate.d.ts +54 -16
- package/dist/approval-gate.d.ts.map +1 -1
- package/dist/approval-gate.js +163 -42
- package/dist/approval-gate.js.map +1 -1
- package/dist/host-hooks.d.ts +115 -0
- package/dist/host-hooks.d.ts.map +1 -0
- package/dist/host-hooks.js +270 -0
- package/dist/host-hooks.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -4
- package/dist/index.js.map +1 -1
- package/dist/prompts/lead.d.ts +1 -1
- package/dist/prompts/lead.d.ts.map +1 -1
- package/dist/prompts/lead.js +88 -6
- package/dist/prompts/lead.js.map +1 -1
- package/dist/prompts/shared.d.ts +1 -1
- package/dist/prompts/shared.d.ts.map +1 -1
- package/dist/prompts/shared.js +203 -119
- package/dist/prompts/shared.js.map +1 -1
- package/dist/prompts/specialists.d.ts +2 -2
- package/dist/prompts/specialists.d.ts.map +1 -1
- package/dist/prompts/specialists.js +73 -23
- package/dist/prompts/specialists.js.map +1 -1
- package/dist/tm/args-schema.d.ts +31 -1
- package/dist/tm/args-schema.d.ts.map +1 -1
- package/dist/tm/args-schema.js +155 -39
- package/dist/tm/args-schema.js.map +1 -1
- package/dist/tm/bash-timeout.d.ts +72 -0
- package/dist/tm/bash-timeout.d.ts.map +1 -0
- package/dist/tm/bash-timeout.js +100 -0
- package/dist/tm/bash-timeout.js.map +1 -0
- package/dist/tm/browser.d.ts +250 -20
- package/dist/tm/browser.d.ts.map +1 -1
- package/dist/tm/browser.js +1200 -143
- package/dist/tm/browser.js.map +1 -1
- package/dist/tm/config.d.ts +129 -0
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +117 -1
- package/dist/tm/config.js.map +1 -1
- package/dist/tm/dispatch.d.ts +95 -0
- package/dist/tm/dispatch.d.ts.map +1 -0
- package/dist/tm/dispatch.js +369 -0
- package/dist/tm/dispatch.js.map +1 -0
- package/dist/tm/index.d.ts +32 -7
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +74 -5
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/memory.d.ts +88 -10
- package/dist/tm/memory.d.ts.map +1 -1
- package/dist/tm/memory.js +881 -99
- package/dist/tm/memory.js.map +1 -1
- package/dist/tm/pipelines.d.ts +26 -3
- package/dist/tm/pipelines.d.ts.map +1 -1
- package/dist/tm/pipelines.js +167 -11
- package/dist/tm/pipelines.js.map +1 -1
- package/dist/tm/ptc/budgets.d.ts +22 -1
- package/dist/tm/ptc/budgets.d.ts.map +1 -1
- package/dist/tm/ptc/budgets.js +87 -8
- package/dist/tm/ptc/budgets.js.map +1 -1
- package/dist/tm/ptc/contract.d.ts +49 -9
- package/dist/tm/ptc/contract.d.ts.map +1 -1
- package/dist/tm/ptc/contract.js +41 -3
- package/dist/tm/ptc/contract.js.map +1 -1
- package/dist/tm/ptc/driver.d.ts +5 -2
- package/dist/tm/ptc/driver.d.ts.map +1 -1
- package/dist/tm/ptc/driver.js +47 -15
- package/dist/tm/ptc/driver.js.map +1 -1
- package/dist/tm/ptc/engines.d.ts +15 -4
- package/dist/tm/ptc/engines.d.ts.map +1 -1
- package/dist/tm/ptc/engines.js +281 -28
- package/dist/tm/ptc/engines.js.map +1 -1
- package/dist/tm/ptc/index.d.ts +4 -4
- package/dist/tm/ptc/index.d.ts.map +1 -1
- package/dist/tm/ptc/index.js +2 -2
- package/dist/tm/ptc/index.js.map +1 -1
- package/dist/tm/ptc/pscan.d.ts +20 -1
- package/dist/tm/ptc/pscan.d.ts.map +1 -1
- package/dist/tm/ptc/pscan.js +328 -14
- package/dist/tm/ptc/pscan.js.map +1 -1
- package/dist/tm/ptc/summary.d.ts.map +1 -1
- package/dist/tm/ptc/summary.js +23 -1
- package/dist/tm/ptc/summary.js.map +1 -1
- package/dist/tm/ptc/tool.d.ts +34 -3
- package/dist/tm/ptc/tool.d.ts.map +1 -1
- package/dist/tm/ptc/tool.js +117 -20
- package/dist/tm/ptc/tool.js.map +1 -1
- package/dist/tm/pty.d.ts +70 -0
- package/dist/tm/pty.d.ts.map +1 -0
- package/dist/tm/pty.js +242 -0
- package/dist/tm/pty.js.map +1 -0
- package/dist/tm/search.d.ts +128 -15
- package/dist/tm/search.d.ts.map +1 -1
- package/dist/tm/search.js +556 -47
- package/dist/tm/search.js.map +1 -1
- package/dist/tm/shell-bridge.d.ts +14 -1
- package/dist/tm/shell-bridge.d.ts.map +1 -1
- package/dist/tm/shell-bridge.js +57 -10
- package/dist/tm/shell-bridge.js.map +1 -1
- package/dist/tm/webfetch.d.ts +29 -7
- package/dist/tm/webfetch.d.ts.map +1 -1
- package/dist/tm/webfetch.js +117 -15
- package/dist/tm/webfetch.js.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/installation.md +216 -17
- package/package.json +5 -2
package/dist/tm/memory.js
CHANGED
|
@@ -1,24 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tm_memory —
|
|
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
|
|
7
|
-
*
|
|
8
|
-
* under the git-aware base
|
|
9
|
-
* fallback), mirroring the
|
|
10
|
-
*
|
|
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
|
|
21
|
-
*
|
|
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
|
|
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
|
|
76
|
-
return
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
if (key === "title")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 {
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
322
|
+
function mtimeMs(file) {
|
|
323
|
+
try {
|
|
324
|
+
return fs.statSync(file).mtimeMs;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
165
329
|
}
|
|
166
|
-
function
|
|
167
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
253
|
-
const keywords = asStringArray(args.keywords).slice(0,
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
272
|
-
const
|
|
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
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
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
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1032
|
+
}
|
|
1033
|
+
for (const f of matched)
|
|
329
1034
|
rmForceSafe(f);
|
|
330
|
-
traj({ step_id: "memory", event: "forget", count:
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
-
|
|
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
|