@yeaft/webchat-agent 0.1.645 → 0.1.647

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.645",
3
+ "version": "0.1.647",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,197 @@
1
+ /**
2
+ * memory/ams.js — DESIGN-H2-AMS §5. Active Memory Set.
3
+ *
4
+ * Per-group session state. Three layers:
5
+ *
6
+ * resident summaries of all relevant scopes — always-on, high precision
7
+ * recent LRU of segments touched in last N turns — warm cache
8
+ * onDemand segments the pre-flow FTS pulled in this turn — hot recall
9
+ *
10
+ * AMS is in-memory; it's rebuilt at session start. The disk source of
11
+ * truth is `<scope>/memory.md` + `<scope>/summary.md`. AMS itself
12
+ * doesn't write to disk — that's Dream's job.
13
+ *
14
+ * Privacy (DESIGN-v2 §2.2): `vp/<other>` scopes are ALWAYS filtered out
15
+ * for any worker that isn't `<other>`. The owning code passes its own
16
+ * vpId at construction.
17
+ */
18
+
19
+ import { approxTokens, packWithinBudget } from './budget.js';
20
+
21
+ const RECENT_DEFAULT_CAPACITY = 64;
22
+
23
+ /**
24
+ * @typedef {object} AmsLayers
25
+ * @property {Map<string, string>} resident
26
+ * @property {Array<{ id: string, seg: import('./segment.js').Segment, ts: number }>} recent
27
+ * @property {Map<string, import('./segment.js').Segment>} onDemand
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} AmsSnapshot
32
+ * @property {Array<{ scope: string, summary: string }>} resident
33
+ * @property {import('./segment.js').Segment[]} recent
34
+ * @property {import('./segment.js').Segment[]} onDemand
35
+ * @property {{ resident: number, recent: number, onDemand: number, total: number }} usage
36
+ */
37
+
38
+ export class ActiveMemorySet {
39
+ /**
40
+ * @param {{
41
+ * ownVpId?: string | null,
42
+ * budget: import('./budget.js').BudgetSplit,
43
+ * recentCapacity?: number,
44
+ * }} opts
45
+ */
46
+ constructor(opts) {
47
+ if (!opts || !opts.budget) throw new Error('ActiveMemorySet: budget required');
48
+ this.ownVpId = opts.ownVpId || null;
49
+ this.budget = opts.budget;
50
+ this.recentCapacity = opts.recentCapacity || RECENT_DEFAULT_CAPACITY;
51
+ /** @type {Map<string, string>} */
52
+ this._resident = new Map(); // scope → summaryText
53
+ /** @type {Map<string, { seg: import('./segment.js').Segment, ts: number }>} */
54
+ this._recent = new Map(); // segId → entry (insertion-order is LRU order)
55
+ /** @type {Map<string, import('./segment.js').Segment>} */
56
+ this._onDemand = new Map(); // segId → segment
57
+ }
58
+
59
+ // ────────────────────────── resident ──────────────────────────
60
+
61
+ /**
62
+ * Replace the resident layer with a fresh set of scope→summary
63
+ * pairs. Foreign VP scopes are silently dropped.
64
+ *
65
+ * @param {Array<{ scope: string, summary: string }>} entries
66
+ */
67
+ setResident(entries) {
68
+ this._resident.clear();
69
+ for (const e of entries) {
70
+ if (this._isForeignVp(e.scope)) continue;
71
+ if (!e.summary) continue;
72
+ this._resident.set(e.scope, e.summary);
73
+ }
74
+ }
75
+
76
+ // ────────────────────────── recent ──────────────────────────
77
+
78
+ /**
79
+ * Touch a segment as "used this turn". LRU semantics: most-recent at
80
+ * the end. Trims to capacity automatically.
81
+ *
82
+ * @param {import('./segment.js').Segment} seg
83
+ */
84
+ touchRecent(seg) {
85
+ if (!seg || !seg.id) return;
86
+ if (this._isForeignVp(seg.scope)) return;
87
+ if (this._recent.has(seg.id)) this._recent.delete(seg.id);
88
+ this._recent.set(seg.id, { seg, ts: Date.now() });
89
+ while (this._recent.size > this.recentCapacity) {
90
+ const firstKey = this._recent.keys().next().value;
91
+ this._recent.delete(firstKey);
92
+ }
93
+ }
94
+
95
+ // ────────────────────────── onDemand ──────────────────────────
96
+
97
+ /**
98
+ * Replace the onDemand layer with this turn's FTS hits.
99
+ *
100
+ * @param {import('./segment.js').Segment[]} segments
101
+ */
102
+ setOnDemand(segments) {
103
+ this._onDemand.clear();
104
+ for (const seg of segments) {
105
+ if (this._isForeignVp(seg.scope)) continue;
106
+ this._onDemand.set(seg.id, seg);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Add segments to onDemand without clearing (used by adjustMemory).
112
+ *
113
+ * @param {import('./segment.js').Segment[]} segments
114
+ */
115
+ addOnDemand(segments) {
116
+ for (const seg of segments) {
117
+ if (this._isForeignVp(seg.scope)) continue;
118
+ this._onDemand.set(seg.id, seg);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Remove segment ids from onDemand (used by adjustMemory eviction).
124
+ *
125
+ * @param {string[]} ids
126
+ */
127
+ removeOnDemand(ids) {
128
+ for (const id of ids) this._onDemand.delete(id);
129
+ }
130
+
131
+ // ────────────────────────── snapshot ──────────────────────────
132
+
133
+ /**
134
+ * Produce a budget-aware snapshot that can be injected into the
135
+ * system prompt. Each layer is greedily packed within its budget;
136
+ * overflow is dropped from this turn but not from disk.
137
+ *
138
+ * @returns {AmsSnapshot}
139
+ */
140
+ snapshot() {
141
+ // Resident: pack scopes by priority order (caller provides via insert
142
+ // order — current group's own vp first, then user, etc.).
143
+ const resEntries = [...this._resident.entries()].map(([scope, summary]) => ({
144
+ scope, summary,
145
+ }));
146
+ const { picked: resPicked, cost: resCost } = packWithinBudget(
147
+ resEntries, this.budget.resident,
148
+ e => approxTokens(e.summary),
149
+ );
150
+
151
+ // Recent: insertion order is oldest-first; we want newest first.
152
+ const recentArr = [...this._recent.values()]
153
+ .reverse()
154
+ .map(e => e.seg);
155
+ const { picked: recPicked, cost: recCost } = packWithinBudget(
156
+ recentArr, this.budget.recent,
157
+ seg => approxTokens(seg.body),
158
+ );
159
+
160
+ // OnDemand: insertion order from caller (already FTS-ranked).
161
+ const odArr = [...this._onDemand.values()];
162
+ const { picked: odPicked, cost: odCost } = packWithinBudget(
163
+ odArr, this.budget.onDemand,
164
+ seg => approxTokens(seg.body),
165
+ );
166
+
167
+ return {
168
+ resident: resPicked,
169
+ recent: recPicked,
170
+ onDemand: odPicked,
171
+ usage: {
172
+ resident: resCost,
173
+ recent: recCost,
174
+ onDemand: odCost,
175
+ total: resCost + recCost + odCost,
176
+ },
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Read-only inspectors (for tests / observability / adjustMemory input).
182
+ */
183
+ residentScopes() { return [...this._resident.keys()]; }
184
+ recentIds() { return [...this._recent.keys()]; }
185
+ onDemandIds() { return [...this._onDemand.keys()]; }
186
+ onDemandSegments() { return [...this._onDemand.values()]; }
187
+ size() { return this._resident.size + this._recent.size + this._onDemand.size; }
188
+
189
+ // ────────────────────────── privacy ──────────────────────────
190
+
191
+ _isForeignVp(scope) {
192
+ if (!scope || !scope.startsWith('vp/')) return false;
193
+ if (!this.ownVpId) return false; // no own id → no filtering
194
+ const other = scope.slice(3).split('/')[0];
195
+ return other !== this.ownVpId;
196
+ }
197
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * memory/budget.js — DESIGN-H2-AMS §5.2.
3
+ *
4
+ * Memory budget = `min(50_000, modelMaxContext * 0.10)`.
5
+ *
6
+ * Then split across the three AMS layers (resident / recent / onDemand)
7
+ * with a configurable ratio. The defaults are tuned for ~200k context
8
+ * models (Claude / GPT-5):
9
+ *
10
+ * resident 40% → 20k (all relevant scope summaries)
11
+ * recent 25% → 12.5k (LRU of recently-used segments)
12
+ * onDemand 35% → 17.5k (this turn's FTS recall)
13
+ *
14
+ * Token counting here is approximate (chars / 4) — accurate enough for
15
+ * budget enforcement. The engine has a real tokenizer for prompt
16
+ * assembly; budget here is a guard rail, not the source of truth.
17
+ */
18
+
19
+ export const ABSOLUTE_CAP = 50_000;
20
+ export const MODEL_FRACTION = 0.10;
21
+
22
+ export const DEFAULT_RATIO = {
23
+ resident: 0.40,
24
+ recent: 0.25,
25
+ onDemand: 0.35,
26
+ };
27
+
28
+ /**
29
+ * @typedef {object} BudgetSplit
30
+ * @property {number} total
31
+ * @property {number} resident
32
+ * @property {number} recent
33
+ * @property {number} onDemand
34
+ */
35
+
36
+ /**
37
+ * @param {number} modelMaxContext tokens of the model's full context window
38
+ * @param {Partial<typeof DEFAULT_RATIO>} [ratio]
39
+ * @returns {BudgetSplit}
40
+ */
41
+ export function computeBudget(modelMaxContext, ratio = {}) {
42
+ const ctx = Number.isFinite(modelMaxContext) && modelMaxContext > 0
43
+ ? modelMaxContext : 200_000;
44
+ const total = Math.min(ABSOLUTE_CAP, Math.floor(ctx * MODEL_FRACTION));
45
+
46
+ const r = { ...DEFAULT_RATIO, ...ratio };
47
+ // Normalise so ratios sum to 1 (defensive).
48
+ const sum = r.resident + r.recent + r.onDemand;
49
+ const norm = sum > 0 ? sum : 1;
50
+ return {
51
+ total,
52
+ resident: Math.floor(total * (r.resident / norm)),
53
+ recent: Math.floor(total * (r.recent / norm)),
54
+ onDemand: Math.floor(total * (r.onDemand / norm)),
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Approximate token count of a string. Avg English ≈ 4 chars / token,
60
+ * Chinese ≈ 1 char / token. We use a conservative blended estimate.
61
+ *
62
+ * @param {string} text
63
+ * @returns {number}
64
+ */
65
+ export function approxTokens(text) {
66
+ if (!text) return 0;
67
+ // Count CJK chars as ~1 token each, the rest as char/4.
68
+ let cjk = 0;
69
+ let other = 0;
70
+ for (const ch of text) {
71
+ const c = ch.codePointAt(0) || 0;
72
+ if (
73
+ (c >= 0x4e00 && c <= 0x9fff) ||
74
+ (c >= 0x3040 && c <= 0x309f) ||
75
+ (c >= 0x30a0 && c <= 0x30ff) ||
76
+ (c >= 0xac00 && c <= 0xd7af)
77
+ ) {
78
+ cjk += 1;
79
+ } else {
80
+ other += 1;
81
+ }
82
+ }
83
+ return Math.ceil(cjk + other / 4);
84
+ }
85
+
86
+ /**
87
+ * Greedy pack: pick items in order until adding the next would exceed
88
+ * the budget. Returns the picked list and the total cost. Does NOT
89
+ * sort — caller decides ordering.
90
+ *
91
+ * @template T
92
+ * @param {T[]} items
93
+ * @param {number} budget
94
+ * @param {(item: T) => number} costFn
95
+ * @returns {{ picked: T[], cost: number, dropped: T[] }}
96
+ */
97
+ export function packWithinBudget(items, budget, costFn) {
98
+ const picked = [];
99
+ const dropped = [];
100
+ let cost = 0;
101
+ for (const it of items) {
102
+ const c = costFn(it);
103
+ if (cost + c <= budget) {
104
+ picked.push(it);
105
+ cost += c;
106
+ } else {
107
+ dropped.push(it);
108
+ }
109
+ }
110
+ return { picked, cost, dropped };
111
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * keywords.js — pure-rule keyword extraction shared by memory recall paths.
3
+ *
4
+ * Extracted from the legacy R5 recall.js so recall-v2.js (and any future
5
+ * recall path) can use it without dragging in the rest of the R5 module.
6
+ * Pure CPU, no LLM, <1ms.
7
+ */
8
+
9
+ /** Common stop words filtered out before frequency counting. */
10
+ const STOP_WORDS = new Set([
11
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
12
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
13
+ 'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for',
14
+ 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during',
15
+ 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over',
16
+ 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when',
17
+ 'where', 'why', 'how', 'all', 'both', 'each', 'few', 'more', 'most',
18
+ 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same',
19
+ 'so', 'than', 'too', 'very', 'just', 'because', 'but', 'and', 'or',
20
+ 'if', 'while', 'about', 'up', 'it', 'its', 'my', 'me', 'i', 'you',
21
+ 'your', 'we', 'our', 'they', 'them', 'their', 'this', 'that', 'what',
22
+ 'which', 'who', 'whom', 'these', 'those',
23
+ // Chinese stop words
24
+ '的', '了', '在', '是', '我', '有', '和', '就',
25
+ '不', '人', '都', '一', '一个', '上', '也',
26
+ '很', '到', '说', '要', '去', '你', '会',
27
+ '着', '没有', '看', '好', '自己', '这',
28
+ '他', '她', '吗', '呢', '吧', '把', '被',
29
+ '那', '它', '让', '给', '可以', '什么',
30
+ '怎么', '帮', '帮我', '请', '能', '想',
31
+ ]);
32
+
33
+ /**
34
+ * Extract keywords from a prompt (pure rules, no LLM).
35
+ *
36
+ * @param {string} prompt
37
+ * @returns {string[]} keywords sorted by frequency descending then alpha.
38
+ */
39
+ export function extractKeywords(prompt) {
40
+ if (!prompt || !prompt.trim()) return [];
41
+
42
+ // Tokenize: split on whitespace + punctuation, keep CJK chars.
43
+ const tokens = prompt
44
+ .toLowerCase()
45
+ .replace(/[^\w\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]+/g, ' ')
46
+ .split(/\s+/)
47
+ .filter(t => t.length > 1 && !STOP_WORDS.has(t));
48
+
49
+ const freq = new Map();
50
+ for (const t of tokens) {
51
+ freq.set(t, (freq.get(t) || 0) + 1);
52
+ }
53
+
54
+ return [...freq.entries()]
55
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
56
+ .map(([word]) => word);
57
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * memory/preflow.js — DESIGN-H2-AMS §6. Pre-turn memory recall.
3
+ *
4
+ * Pure-CPU pipeline (no LLM). Runs on every turn:
5
+ *
6
+ * userMsg
7
+ * → extractKeywords (rule-based tokeniser)
8
+ * → SQLite FTS5 MATCH (scope-filtered, bm25 ranked)
9
+ * → rerank by (scope match + tag overlap + recency)
10
+ * → onDemand layer (budget-clamped)
11
+ *
12
+ * Latency target: < 10ms p95 on a 10k-segment index.
13
+ *
14
+ * Honours `vp/<other>` privacy: caller passes `ownVpId` and the scope
15
+ * filter excludes foreign VP scopes.
16
+ */
17
+
18
+ import { extractKeywords } from './keywords.js';
19
+ import { approxTokens } from './budget.js';
20
+
21
+ /**
22
+ * @typedef {object} PreflowOptions
23
+ * @property {string} userMsg
24
+ * @property {string[]} relevantScopes e.g. ['user', 'group/g1', 'vp/alice']
25
+ * @property {string|null} [ownVpId]
26
+ * @property {string[]} [currentTags] tags from the current group/feature context
27
+ * @property {number} [topK] max FTS rows to fetch (default 50)
28
+ * @property {number} [budgetTokens] onDemand budget (caller-supplied)
29
+ */
30
+
31
+ /**
32
+ * @typedef {object} PreflowResult
33
+ * @property {string[]} keywords
34
+ * @property {string} ftsQuery
35
+ * @property {import('./index-db.js').SearchHit[]} hits
36
+ * @property {import('./segment.js').Segment[]} picked
37
+ * @property {number} pickedTokens
38
+ * @property {number} droppedCount
39
+ */
40
+
41
+ /**
42
+ * Run the pre-flow against a segment index.
43
+ *
44
+ * @param {import('./index-db.js').SegmentIndex} index
45
+ * @param {PreflowOptions} opts
46
+ * @returns {PreflowResult}
47
+ */
48
+ export function runPreflow(index, opts) {
49
+ const userMsg = (opts.userMsg || '').trim();
50
+ const relevantScopes = Array.isArray(opts.relevantScopes) ? opts.relevantScopes : [];
51
+ const ownVpId = opts.ownVpId || null;
52
+ const currentTags = Array.isArray(opts.currentTags) ? opts.currentTags : [];
53
+ const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? opts.topK : 50;
54
+ const budgetTokens = Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0
55
+ ? opts.budgetTokens : Infinity;
56
+
57
+ const keywords = extractKeywords(userMsg);
58
+ if (keywords.length === 0) {
59
+ return {
60
+ keywords: [], ftsQuery: '', hits: [],
61
+ picked: [], pickedTokens: 0, droppedCount: 0,
62
+ };
63
+ }
64
+
65
+ const ftsQuery = buildFtsQuery(keywords);
66
+ const scopeFilter = filterScopes(relevantScopes, ownVpId);
67
+ if (scopeFilter.length === 0) {
68
+ return {
69
+ keywords, ftsQuery, hits: [],
70
+ picked: [], pickedTokens: 0, droppedCount: 0,
71
+ };
72
+ }
73
+
74
+ const hits = index.search({ query: ftsQuery, scopeFilter, limit: topK });
75
+ const reranked = rerank(hits, { currentTags });
76
+
77
+ const picked = [];
78
+ let cost = 0;
79
+ let dropped = 0;
80
+ for (const h of reranked) {
81
+ const tk = approxTokens(h.body);
82
+ if (cost + tk <= budgetTokens) {
83
+ picked.push(toSegment(h));
84
+ cost += tk;
85
+ } else {
86
+ dropped += 1;
87
+ }
88
+ }
89
+
90
+ return {
91
+ keywords, ftsQuery, hits: reranked,
92
+ picked, pickedTokens: cost, droppedCount: dropped,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Compose an FTS5 MATCH query from keywords. Each keyword is OR'd with
98
+ * a prefix wildcard so morphological variants match. We escape any
99
+ * FTS5-special characters by quoting tokens.
100
+ *
101
+ * @param {string[]} keywords
102
+ * @returns {string}
103
+ */
104
+ export function buildFtsQuery(keywords) {
105
+ const cleaned = keywords
106
+ .map(k => k.replace(/"/g, ''))
107
+ .filter(k => k.length > 1)
108
+ .slice(0, 8); // top-8 keywords — avoid query bloat
109
+ if (cleaned.length === 0) return '';
110
+ return cleaned.map(k => `"${k}"*`).join(' OR ');
111
+ }
112
+
113
+ /**
114
+ * Strip foreign VP scopes from the filter list (privacy).
115
+ *
116
+ * @param {string[]} scopes
117
+ * @param {string|null} ownVpId
118
+ * @returns {string[]}
119
+ */
120
+ export function filterScopes(scopes, ownVpId) {
121
+ return scopes.filter(s => {
122
+ if (!s.startsWith('vp/')) return true;
123
+ if (!ownVpId) return true;
124
+ const other = s.slice(3).split('/')[0];
125
+ return other === ownVpId;
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Rerank FTS hits with two soft signals on top of bm25:
131
+ * - tag overlap with the current group/feature context (subtract penalty)
132
+ * - recency: recent items get a small bonus
133
+ *
134
+ * SQLite FTS5 bm25 returns NEGATIVE numbers (more negative = better
135
+ * match). We treat lower score as better. To make overlap & recency
136
+ * push hits ahead, we SUBTRACT bonuses from the bm25 base (making the
137
+ * score more negative).
138
+ *
139
+ * @param {import('./index-db.js').SearchHit[]} hits
140
+ * @param {{ currentTags: string[] }} ctx
141
+ * @returns {import('./index-db.js').SearchHit[]}
142
+ */
143
+ export function rerank(hits, ctx) {
144
+ const tagSet = new Set((ctx.currentTags || []).map(t => String(t).toLowerCase()));
145
+ const now = Date.now();
146
+ return [...hits]
147
+ .map(h => {
148
+ const overlap = (h.tags || []).reduce(
149
+ (n, t) => n + (tagSet.has(String(t).toLowerCase()) ? 1 : 0), 0,
150
+ );
151
+ const tagBonus = Math.min(2, overlap * 0.5); // up to 2 points
152
+ const ageDays = Math.max(0, (now - Date.parse(h.updatedAt || h.createdAt || '')) / 86400000);
153
+ const recencyBonus = Math.min(0.5, 0.2 / Math.max(0.5, ageDays + 1));
154
+ const base = h.rank ?? 0;
155
+ const score = base - tagBonus - recencyBonus;
156
+ return { ...h, _score: score };
157
+ })
158
+ .sort((a, b) => a._score - b._score)
159
+ .map(({ _score, ...rest }) => rest);
160
+ }
161
+
162
+ function toSegment(h) {
163
+ return {
164
+ id: h.id,
165
+ scope: h.scope,
166
+ kind: h.kind,
167
+ tags: h.tags,
168
+ sourceMessages: h.sourceMessages,
169
+ body: h.body,
170
+ createdAt: h.createdAt,
171
+ updatedAt: h.updatedAt,
172
+ };
173
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * memory/summary-store.js — DESIGN-H2-AMS §3.
3
+ *
4
+ * `summary.md` is a bounded, per-scope prose digest derived from all
5
+ * segments in that scope. Resident AMS layer = concatenation of all
6
+ * relevant scope summaries. Regenerated by Dream after segments change.
7
+ *
8
+ * Layout:
9
+ * ~/.yeaft/memory/<scope>/summary.md
10
+ *
11
+ * Format: plain prose, optionally prefixed with a one-line metadata
12
+ * header `<!-- updatedAt: ISO -->`. No required schema beyond non-empty
13
+ * text.
14
+ */
15
+
16
+ import {
17
+ readFileSync, writeFileSync, existsSync, mkdirSync, renameSync,
18
+ } from 'node:fs';
19
+ import { join, dirname } from 'node:path';
20
+
21
+ /**
22
+ * @param {string} memoryRoot
23
+ * @param {string} scope
24
+ * @returns {string}
25
+ */
26
+ export function summaryPath(memoryRoot, scope) {
27
+ return join(memoryRoot, scope, 'summary.md');
28
+ }
29
+
30
+ /**
31
+ * Read summary text for a scope (empty string if missing).
32
+ *
33
+ * @param {string} memoryRoot
34
+ * @param {string} scope
35
+ * @returns {string}
36
+ */
37
+ export function readSummary(memoryRoot, scope) {
38
+ const p = summaryPath(memoryRoot, scope);
39
+ if (!existsSync(p)) return '';
40
+ return stripHeader(readFileSync(p, 'utf8')).trim();
41
+ }
42
+
43
+ /**
44
+ * Atomic write summary text. Empty string = wipe content but keep file.
45
+ *
46
+ * @param {string} memoryRoot
47
+ * @param {string} scope
48
+ * @param {string} text
49
+ */
50
+ export function writeSummary(memoryRoot, scope, text) {
51
+ const p = summaryPath(memoryRoot, scope);
52
+ mkdirSync(dirname(p), { recursive: true });
53
+ const header = `<!-- updatedAt: ${new Date().toISOString()} -->\n`;
54
+ const body = (text || '').trim();
55
+ const final = body ? `${header}${body}\n` : header;
56
+ const tmp = `${p}.tmp`;
57
+ writeFileSync(tmp, final, 'utf8');
58
+ renameSync(tmp, p);
59
+ }
60
+
61
+ function stripHeader(text) {
62
+ return text.replace(/^<!--[^>]*-->\s*/m, '');
63
+ }