@yeaft/webchat-agent 0.1.531 → 0.1.532

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.531",
3
+ "version": "0.1.532",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -310,6 +310,9 @@ export class Engine {
310
310
  memoryInjection,
311
311
  compactSummary,
312
312
  skillContent,
313
+ // task-334f: memory_trace tool is now registered (49 → 51 tools), so
314
+ // unlock the core_memory meta-line behind 334e's feature flag.
315
+ memoryTraceAvailable: true,
313
316
  });
314
317
  }
315
318
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * migrate-r5-to-r6.js — task-334f §Δ23 migration stub.
3
+ *
4
+ * Legacy (R5): `~/.yeaft/memory/entries/<slug>.md` plus numeric shard files
5
+ * `memory-001.md`, `memory-002.md`, ...
6
+ *
7
+ * R6: `~/.yeaft/memory/vp/<vpId>/memory-<semantic>.md`
8
+ * Semantic shards: skill / relations / lessons / preferences /
9
+ * project-<slug>
10
+ *
11
+ * This slice (334f) only DEFINES the API surface and a dry-run classifier.
12
+ * The actual batch migration runs in 334i; 334f does not mutate disk.
13
+ */
14
+
15
+ import { existsSync, readdirSync, readFileSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { parseEntry } from './store.js';
18
+ import { classifyLegacyEntryToShard } from './shard-store.js';
19
+
20
+ /**
21
+ * Produce a migration plan without applying it.
22
+ *
23
+ * @param {string} legacyEntriesDir e.g. ~/.yeaft/memory/entries
24
+ * @returns {{
25
+ * totalEntries: number,
26
+ * plan: Array<{ slug: string, shard: string, kind: string, tags: string[] }>,
27
+ * byShard: Record<string, number>
28
+ * }}
29
+ */
30
+ export function planR5ToR6Migration(legacyEntriesDir) {
31
+ if (!legacyEntriesDir || !existsSync(legacyEntriesDir)) {
32
+ return { totalEntries: 0, plan: [], byShard: {} };
33
+ }
34
+ const files = readdirSync(legacyEntriesDir).filter(f => f.endsWith('.md'));
35
+ const plan = [];
36
+ const byShard = {};
37
+ for (const file of files) {
38
+ const raw = readFileSync(join(legacyEntriesDir, file), 'utf8');
39
+ const entry = parseEntry(raw);
40
+ if (!entry) continue;
41
+ const shard = classifyLegacyEntryToShard(entry);
42
+ plan.push({
43
+ slug: file.replace(/\.md$/, ''),
44
+ shard,
45
+ kind: entry.kind || 'fact',
46
+ tags: entry.tags || [],
47
+ });
48
+ byShard[shard] = (byShard[shard] || 0) + 1;
49
+ }
50
+ return { totalEntries: plan.length, plan, byShard };
51
+ }
52
+
53
+ /**
54
+ * Apply the migration. STUB — 334i will fill in the body writer. 334f keeps
55
+ * this function exported so downstream tests can assert the hook exists.
56
+ *
57
+ * @param {object} _opts { legacyEntriesDir, targetDir, vpId, dryRun }
58
+ */
59
+ export async function applyR5ToR6Migration(_opts) {
60
+ throw new Error('applyR5ToR6Migration: not yet implemented (task-334i)');
61
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * recall-r6.js — task-334f R6 4-step recall pipeline (§Δ24.2).
3
+ *
4
+ * Pipeline:
5
+ * Step 1: Shard classifier — fastModel OR keyword heuristic → top-1~2 shards
6
+ * Step 2: In-shard candidate — scan only the selected shards, filter by kind/tags/pinned
7
+ * Step 3: LLM rerank — pick top-7 from candidates
8
+ * Step 4: Inject body only — return entries without sourceRef (§Δ23)
9
+ *
10
+ * The legacy R5 recall (agent/unify/memory/recall.js) remains for back-compat
11
+ * of callers still on MemoryStore. R6 callers should use `recallR6()` below.
12
+ */
13
+
14
+ import { createHash } from 'crypto';
15
+ import { pickEffort } from '../effort.js';
16
+ import {
17
+ VP_DEFAULT_SHARDS,
18
+ TASK_SHARDS,
19
+ USER_SHARDS,
20
+ } from './schema.js';
21
+
22
+ const MAX_RECALL_RESULTS = 7;
23
+ const MAX_CANDIDATES = 15;
24
+
25
+ // ─── Step 1: Shard Classifier ───────────────────────────────────
26
+
27
+ /**
28
+ * Keyword → shard heuristic. Zero-cost fallback when fastModel is unavailable.
29
+ *
30
+ * The lexicons are intentionally small: classifier output only needs to point
31
+ * at the most likely shard; LLM rerank in Step 3 catches misses. Empirically
32
+ * this covers > 70% of queries with zero LLM cost.
33
+ */
34
+ const SHARD_LEXICON = {
35
+ skill: ['code', 'api', 'library', 'framework', 'implement', 'debug', 'syntax', 'typescript', 'vue', 'pattern', '代码', '实现', '调试', '语法', '模式', '技术'],
36
+ lessons: ['mistake', 'avoid', 'lesson', 'pitfall', 'gotcha', 'bug', 'regression', '教训', '避坑', '坑', '踩坑', '反模式'],
37
+ preferences: ['prefer', 'like', 'style', 'convention', 'favorite', '偏好', '风格', '习惯', '喜欢'],
38
+ relations: ['colleague', 'partner', 'team', 'user', 'collaborator', 'vp', '同事', '队友', '协作', '关系'],
39
+ // Task-memory shards
40
+ decision: ['decide', 'decision', 'chose', 'picked', 'resolved', '决定', '决策', '选择'],
41
+ progress: ['done', 'progress', 'milestone', 'shipped', 'finished', '完成', '进度', '交付'],
42
+ context: ['background', 'context', 'requirement', 'scope', '背景', '需求', '范围'],
43
+ blocker: ['block', 'stuck', 'blocker', 'issue', 'waiting', '阻塞', '卡住', '等待'],
44
+ artifact: ['pr', 'commit', 'doc', 'file', 'link', 'artifact', 'deliverable', 'commit', '产出', '文档'],
45
+ // User-memory shards
46
+ profile: ['name', 'role', 'background', 'who', 'identity', '身份', '角色', '背景'],
47
+ projects: ['project', 'repo', '项目', '仓库'],
48
+ goals: ['goal', 'target', 'okr', 'plan', '目标', '计划'],
49
+ };
50
+
51
+ /**
52
+ * Pick top-N shards via keyword heuristic.
53
+ * @param {string} prompt
54
+ * @param {string[]} availableShards all shards present in this store
55
+ * @param {number} [topN=2]
56
+ */
57
+ export function classifyShardsByKeyword(prompt, availableShards, topN = 2) {
58
+ if (!prompt || !availableShards || availableShards.length === 0) return [];
59
+ const lower = prompt.toLowerCase();
60
+ const scores = new Map();
61
+ for (const shard of availableShards) {
62
+ // project-<slug> shards score via their slug
63
+ const lex = SHARD_LEXICON[shard] || [];
64
+ let score = 0;
65
+ for (const kw of lex) {
66
+ if (lower.includes(kw)) score += 1;
67
+ }
68
+ if (shard.startsWith('project-')) {
69
+ const slug = shard.slice('project-'.length);
70
+ if (slug && lower.includes(slug.toLowerCase())) score += 3;
71
+ }
72
+ if (score > 0) scores.set(shard, score);
73
+ }
74
+ const ranked = [...scores.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
75
+ if (ranked.length >= 1) return ranked.slice(0, topN);
76
+ // Fallback: first N defaults in the shard set
77
+ return availableShards.slice(0, topN);
78
+ }
79
+
80
+ /**
81
+ * Shard classifier — calls fastModel if adapter provided, else falls back to
82
+ * the keyword heuristic. Budget < 200 tokens (§Δ24.2).
83
+ */
84
+ export async function classifyShards({
85
+ prompt,
86
+ availableShards,
87
+ adapter,
88
+ fastModel,
89
+ topN = 2,
90
+ }) {
91
+ if (!adapter || !fastModel) {
92
+ return classifyShardsByKeyword(prompt, availableShards, topN);
93
+ }
94
+ const system = `You classify user queries into memory shards. Return ONLY a JSON array of up to ${topN} shard names from the provided list. No prose.`;
95
+ const user = `Available shards: ${JSON.stringify(availableShards)}
96
+ Query: ${JSON.stringify(prompt)}
97
+ Return JSON array of up to ${topN} most relevant shard names.`;
98
+ try {
99
+ const res = await adapter.call({
100
+ model: fastModel,
101
+ system,
102
+ messages: [{ role: 'user', content: user }],
103
+ maxTokens: 64,
104
+ effort: pickEffort({ scenario: 'recall' }),
105
+ });
106
+ const m = (res.text || '').match(/\[[\s\S]*?\]/);
107
+ if (!m) return classifyShardsByKeyword(prompt, availableShards, topN);
108
+ const arr = JSON.parse(m[0]);
109
+ const valid = arr.filter(s => typeof s === 'string' && availableShards.includes(s));
110
+ if (valid.length === 0) return classifyShardsByKeyword(prompt, availableShards, topN);
111
+ return valid.slice(0, topN);
112
+ } catch {
113
+ return classifyShardsByKeyword(prompt, availableShards, topN);
114
+ }
115
+ }
116
+
117
+ // ─── Step 2: In-shard candidate generation ──────────────────────
118
+
119
+ function collectCandidates(memoryShardStore, { shards, kind, tags, pinned }) {
120
+ const filter = {};
121
+ if (shards && shards.length) filter.shard = shards.length === 1 ? shards[0] : shards;
122
+ if (kind) filter.kind = kind;
123
+ if (tags && tags.length) filter.tags = tags;
124
+ if (pinned !== undefined) filter.pinned = pinned;
125
+ const { results } = memoryShardStore.query(filter);
126
+ return results
127
+ // Drop superseded entries from the candidate pool (they stay on disk
128
+ // for memory_trace but should not compete for recall slots).
129
+ .filter(rec => !rec.supersededBy)
130
+ .slice(0, MAX_CANDIDATES);
131
+ }
132
+
133
+ // ─── Step 3: LLM Rerank ─────────────────────────────────────────
134
+
135
+ async function llmRerank({ adapter, fastModel, prompt, candidates, memoryShardStore }) {
136
+ if (candidates.length <= MAX_RECALL_RESULTS) {
137
+ return candidates.map(c => c.id);
138
+ }
139
+ const lines = candidates.map((c, i) => {
140
+ return `${i + 1}. [id=${c.id}] shard=${c.shard} kind=${c.kind || '?'} tags=[${(c.tags || []).join(',')}]`;
141
+ }).join('\n');
142
+ const system = `You pick the most relevant memories for the user's prompt. Return ONLY a JSON array of entry ids (up to ${MAX_RECALL_RESULTS}). No prose.`;
143
+ const user = `User prompt: ${JSON.stringify(prompt)}
144
+
145
+ Candidate memories:
146
+ ${lines}
147
+
148
+ Return JSON array of up to ${MAX_RECALL_RESULTS} ids.`;
149
+ try {
150
+ const res = await adapter.call({
151
+ model: fastModel,
152
+ system,
153
+ messages: [{ role: 'user', content: user }],
154
+ maxTokens: 256,
155
+ effort: pickEffort({ scenario: 'recall' }),
156
+ });
157
+ const m = (res.text || '').match(/\[[\s\S]*\]/);
158
+ if (!m) return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.id);
159
+ const arr = JSON.parse(m[0]).filter(x => typeof x === 'string');
160
+ const valid = arr.filter(id => candidates.find(c => c.id === id));
161
+ if (valid.length === 0) return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.id);
162
+ return valid.slice(0, MAX_RECALL_RESULTS);
163
+ } catch {
164
+ return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.id);
165
+ }
166
+ }
167
+
168
+ // ─── Step 4: Inject (no sourceRef) ──────────────────────────────
169
+
170
+ /**
171
+ * Produce the body-only injection payload (§Δ24.5). Prefix lines with
172
+ * `[mem:<shard>]` so the LLM knows the category without seeing the id.
173
+ */
174
+ export function formatForInjection(entries) {
175
+ return entries.map(e => {
176
+ const prefix = `[mem:${e.shard}]`;
177
+ const body = (e.body || '').trim();
178
+ return `${prefix} ${body}`;
179
+ }).join('\n\n');
180
+ }
181
+
182
+ // ─── Fingerprint cache ──────────────────────────────────────────
183
+
184
+ const _cache = new Map();
185
+ const CACHE_TTL = 5 * 60 * 1000;
186
+
187
+ function computeFingerprint({ shards, prompt, taskId }) {
188
+ const head = prompt.slice(0, 200);
189
+ const input = `${shards.join(',')}|${head}|${taskId || ''}`;
190
+ return createHash('sha256').update(input).digest('hex').slice(0, 16);
191
+ }
192
+
193
+ // ─── Entry point ────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Run the R6 4-step recall pipeline.
197
+ *
198
+ * @param {{
199
+ * prompt: string,
200
+ * memoryShardStore: object, // openMemoryShardStore() handle
201
+ * adapter?: object, // LLM adapter (null = keyword-only)
202
+ * fastModel?: string, // fast model for classifier + rerank
203
+ * availableShards?: string[], // defaults to store stats
204
+ * taskId?: string,
205
+ * kind?: string, tags?: string[], pinned?: boolean,
206
+ * }} params
207
+ * @returns {Promise<{entries: object[], shards: string[], fingerprint: string, cached: boolean}>}
208
+ */
209
+ export async function recallR6(params) {
210
+ const {
211
+ prompt,
212
+ memoryShardStore,
213
+ adapter,
214
+ fastModel,
215
+ availableShards,
216
+ taskId,
217
+ kind,
218
+ tags,
219
+ pinned,
220
+ } = params;
221
+
222
+ if (!prompt || !prompt.trim() || !memoryShardStore) {
223
+ return { entries: [], shards: [], fingerprint: '', cached: false };
224
+ }
225
+
226
+ const shardsFromStore = availableShards
227
+ || Object.keys(memoryShardStore.stats().shards);
228
+
229
+ if (shardsFromStore.length === 0) {
230
+ return { entries: [], shards: [], fingerprint: '', cached: false };
231
+ }
232
+
233
+ // Step 1
234
+ const chosenShards = await classifyShards({
235
+ prompt,
236
+ availableShards: shardsFromStore,
237
+ adapter,
238
+ fastModel,
239
+ topN: 2,
240
+ });
241
+
242
+ const fingerprint = computeFingerprint({ shards: chosenShards, prompt, taskId });
243
+ const cached = _cache.get(fingerprint);
244
+ if (cached && Date.now() - cached.t < CACHE_TTL) {
245
+ return { entries: cached.entries, shards: chosenShards, fingerprint, cached: true };
246
+ }
247
+
248
+ // Step 2
249
+ const candidates = collectCandidates(memoryShardStore, {
250
+ shards: chosenShards,
251
+ kind,
252
+ tags,
253
+ pinned,
254
+ });
255
+ if (candidates.length === 0) {
256
+ _cache.set(fingerprint, { entries: [], t: Date.now() });
257
+ return { entries: [], shards: chosenShards, fingerprint, cached: false };
258
+ }
259
+
260
+ // Step 3
261
+ const selectedIds = await llmRerank({
262
+ adapter, fastModel, prompt, candidates, memoryShardStore,
263
+ });
264
+
265
+ // Step 4 — load full bodies (minus sourceRef for injection).
266
+ const entries = [];
267
+ for (const id of selectedIds) {
268
+ const full = memoryShardStore.get(id);
269
+ if (!full) continue;
270
+ entries.push({
271
+ id: full.id,
272
+ shard: full.shard,
273
+ kind: full.kind,
274
+ body: full.body,
275
+ tags: full.tags || [],
276
+ // deliberately do NOT expose sourceRef here (§Δ23)
277
+ });
278
+ }
279
+ _cache.set(fingerprint, { entries, t: Date.now() });
280
+ return { entries, shards: chosenShards, fingerprint, cached: false };
281
+ }
282
+
283
+ export function clearR6RecallCache() {
284
+ _cache.clear();
285
+ }
286
+
287
+ export const R6_DEFAULTS = {
288
+ VP_DEFAULT_SHARDS,
289
+ TASK_SHARDS,
290
+ USER_SHARDS,
291
+ };
@@ -0,0 +1,166 @@
1
+ /**
2
+ * schema.js — task-334f R6 Memory schema constants.
3
+ *
4
+ * References:
5
+ * §Δ22 Memory 本质定性 (6 公理)
6
+ * §Δ23 Memory Entry Schema 扩字段
7
+ * §Δ25 Shard 语义初始分类
8
+ * §Δ26.3 Shard 软上限
9
+ *
10
+ * This module is **data only** — no I/O, no LLM calls. It is safe to import
11
+ * from any layer (store, recall, tools) without circular hazards.
12
+ */
13
+
14
+ // ─── §Δ25.1 VP-memory default shard set ─────────────────────────
15
+ export const VP_DEFAULT_SHARDS = Object.freeze([
16
+ 'skill',
17
+ 'relations',
18
+ 'lessons',
19
+ 'preferences',
20
+ ]);
21
+
22
+ // ─── §Δ25.2 Task-memory fixed 5 shards ──────────────────────────
23
+ export const TASK_SHARDS = Object.freeze([
24
+ 'decision',
25
+ 'progress',
26
+ 'context',
27
+ 'blocker',
28
+ 'artifact',
29
+ ]);
30
+
31
+ // ─── §Δ25.3 User-memory default shard set ───────────────────────
32
+ export const USER_SHARDS = Object.freeze([
33
+ 'profile',
34
+ 'preferences',
35
+ 'projects',
36
+ 'goals',
37
+ 'relations',
38
+ ]);
39
+
40
+ /**
41
+ * §Δ26.3 soft-cap table. `project-<slug>` is matched via the dedicated
42
+ * helper `softCapFor()` below because its key is dynamic.
43
+ *
44
+ * Shape: { entries: number, bytes: number }
45
+ */
46
+ export const SOFT_CAPS = Object.freeze({
47
+ // VP
48
+ skill: { entries: 80, bytes: 64 * 1024 },
49
+ lessons: { entries: 80, bytes: 64 * 1024 },
50
+ preferences: { entries: 80, bytes: 64 * 1024 },
51
+ relations: { entries: 50, bytes: 32 * 1024 },
52
+ // Task (Δ26.3 task-memory row)
53
+ decision: { entries: 40, bytes: 24 * 1024 },
54
+ progress: { entries: 40, bytes: 24 * 1024 },
55
+ context: { entries: 40, bytes: 24 * 1024 },
56
+ blocker: { entries: 40, bytes: 24 * 1024 },
57
+ artifact: { entries: 40, bytes: 24 * 1024 },
58
+ // User (Δ26.3 user-memory row — 60 entries / 48 KiB per shard)
59
+ profile: { entries: 60, bytes: 48 * 1024 },
60
+ projects: { entries: 60, bytes: 48 * 1024 },
61
+ goals: { entries: 60, bytes: 48 * 1024 },
62
+ });
63
+
64
+ /** Project shards are dynamic: `project-<slug>` → 150 entries / 128 KiB. */
65
+ export const PROJECT_SHARD_SOFT_CAP = Object.freeze({
66
+ entries: 150,
67
+ bytes: 128 * 1024,
68
+ });
69
+
70
+ /** Default cap if callers ask for a shard not in the canonical set. */
71
+ export const DEFAULT_SOFT_CAP = Object.freeze({
72
+ entries: 80,
73
+ bytes: 64 * 1024,
74
+ });
75
+
76
+ /**
77
+ * Trigger threshold: when a dream sweep finds ≥ PROJECT_DERIVE_THRESHOLD
78
+ * memory entries tagged with the same groupId, it may derive a
79
+ * `project-<slug>` shard. (§Δ25.1)
80
+ */
81
+ export const PROJECT_DERIVE_THRESHOLD = 30;
82
+
83
+ /**
84
+ * Max number of VP-memory shards — re-compression kicks in when exceeded
85
+ * (§Δ25.1: "最大 shard 数软上限 12").
86
+ */
87
+ export const MAX_VP_SHARDS = 12;
88
+
89
+ /**
90
+ * Return the soft cap for a given shard name.
91
+ * Handles the dynamic `project-<slug>` case.
92
+ */
93
+ export function softCapFor(shardName) {
94
+ if (typeof shardName !== 'string' || !shardName) return DEFAULT_SOFT_CAP;
95
+ if (shardName.startsWith('project-')) return PROJECT_SHARD_SOFT_CAP;
96
+ return SOFT_CAPS[shardName] || DEFAULT_SOFT_CAP;
97
+ }
98
+
99
+ /**
100
+ * Build a schema object suitable for `openShardStore(dir, schema)` (334o).
101
+ *
102
+ * @param {'vp'|'task'|'user'} kind
103
+ * @param {{ extraShards?: string[] }} [opts] e.g. existing project shards
104
+ * @returns {{ shards: string[], softCap: Record<string,{entries:number,bytes:number}>, defaultSoftCap: object }}
105
+ */
106
+ export function buildShardSchema(kind, opts = {}) {
107
+ let shards;
108
+ switch (kind) {
109
+ case 'vp': shards = [...VP_DEFAULT_SHARDS]; break;
110
+ case 'task': shards = [...TASK_SHARDS]; break;
111
+ case 'user': shards = [...USER_SHARDS]; break;
112
+ default: throw new Error(`buildShardSchema: unknown kind "${kind}"`);
113
+ }
114
+ if (Array.isArray(opts.extraShards)) {
115
+ for (const s of opts.extraShards) {
116
+ if (typeof s === 'string' && s && !shards.includes(s)) shards.push(s);
117
+ }
118
+ }
119
+ const softCap = {};
120
+ for (const s of shards) softCap[s] = softCapFor(s);
121
+ return {
122
+ shards,
123
+ softCap,
124
+ defaultSoftCap: DEFAULT_SOFT_CAP,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * R6 memory-entry authored-by enum (§Δ23).
130
+ * Free-form strings are allowed; these are canonical examples.
131
+ */
132
+ export const AUTHORED_BY = Object.freeze({
133
+ VP: (vpId) => `vp:${vpId}`,
134
+ USER: (uid) => `user:${uid}`,
135
+ SUMMARY: 'system:summary-extractor',
136
+ DREAM: 'system:dream',
137
+ });
138
+
139
+ /**
140
+ * Validate an R6 entry shape (schema-level, no I/O).
141
+ * Throws on structural violation. Callers that want soft warnings should
142
+ * wrap in try/catch.
143
+ */
144
+ export function validateR6Entry(entry) {
145
+ if (!entry || typeof entry !== 'object') throw new Error('entry must be an object');
146
+ if (!entry.id || typeof entry.id !== 'string') throw new Error('entry.id required');
147
+ if (!entry.shard || typeof entry.shard !== 'string') throw new Error('entry.shard required');
148
+ if (!entry.kind || typeof entry.kind !== 'string') throw new Error('entry.kind required');
149
+ // sourceRef is required except for identity/preference pure-declaration entries.
150
+ const needsSourceRef = !(entry.kind === 'identity' || entry.kind === 'preference');
151
+ if (needsSourceRef) {
152
+ if (!entry.sourceRef || typeof entry.sourceRef !== 'object') {
153
+ throw new Error('entry.sourceRef required for kind=' + entry.kind);
154
+ }
155
+ if (!Array.isArray(entry.sourceRef.msgIds) || entry.sourceRef.msgIds.length === 0) {
156
+ throw new Error('entry.sourceRef.msgIds required (non-empty array)');
157
+ }
158
+ }
159
+ if (entry.supersedes != null && !Array.isArray(entry.supersedes)) {
160
+ throw new Error('entry.supersedes must be an array when present');
161
+ }
162
+ if (entry.supersededBy != null && typeof entry.supersededBy !== 'string') {
163
+ throw new Error('entry.supersededBy must be a string when present');
164
+ }
165
+ return true;
166
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * shard-store.js — task-334f R6 semantic-shard memory store (VP/task/user).
3
+ *
4
+ * This sits ON TOP OF 334o's `storage/shard-store.js` primitive. It adds:
5
+ * - R6 entry schema (shard / sourceRef / supersedes / supersededBy / authoredBy)
6
+ * - Frontmatter body serialisation (markdown-friendly, grep-able on disk)
7
+ * - Supersede chain management (Δ26.2 Phase B)
8
+ * - Atomic re-compression handoff (`memory-<shard>.md.compacting`)
9
+ * - Migration stub from legacy R5 `memory-NNN.md` → semantic shards (334i)
10
+ *
11
+ * Hard boundaries (task-334f guardrails):
12
+ * - does NOT touch 334o's jsonl-log layer
13
+ * - does NOT run dream extract / re-compression decisions (334g)
14
+ * - does NOT implement user-memory business logic (334l reuses this lib)
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { openShardStore, writeAtomic } from '../storage/index.js';
20
+ import {
21
+ rebuildShardIndexFromDisk,
22
+ saveShardIndex,
23
+ } from '../storage/shard-index.js';
24
+ import {
25
+ buildShardSchema,
26
+ softCapFor,
27
+ PROJECT_DERIVE_THRESHOLD,
28
+ MAX_VP_SHARDS,
29
+ validateR6Entry,
30
+ AUTHORED_BY,
31
+ } from './schema.js';
32
+
33
+ const COMPACTING_SUFFIX = '.compacting';
34
+
35
+ /**
36
+ * Open (or create) an R6 memory shard store rooted at `dir`.
37
+ *
38
+ * @param {string} dir filesystem directory (e.g. `~/.yeaft/memory/vp/<vpId>`)
39
+ * @param {'vp'|'task'|'user'} kind
40
+ * @param {{ extraShards?: string[] }} [opts]
41
+ * @returns {object} handle with put/get/query/remove/compact/...
42
+ */
43
+ export function openMemoryShardStore(dir, kind = 'vp', opts = {}) {
44
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
45
+
46
+ // Hydrate any pre-existing project-* shards from disk so the schema
47
+ // allow-list recognises them on this open.
48
+ const extraShards = new Set(opts.extraShards || []);
49
+ for (const name of discoverOnDiskShards(dir)) {
50
+ if (!extraShards.has(name)) extraShards.add(name);
51
+ }
52
+ const schema = buildShardSchema(kind, { extraShards: [...extraShards] });
53
+ const inner = openShardStore(dir, schema);
54
+
55
+ /**
56
+ * Put an R6 entry. Merges the rich entry schema into `body` (frontmatter)
57
+ * and surfaces the 5 filter-worthy fields through `meta`.
58
+ */
59
+ function put(entry) {
60
+ validateR6Entry(entry);
61
+ const body = serialiseR6Body(entry);
62
+ const meta = pickMeta(entry);
63
+ const res = inner.put({
64
+ id: entry.id,
65
+ shard: entry.shard,
66
+ body,
67
+ meta,
68
+ });
69
+ return res;
70
+ }
71
+
72
+ /** Retrieve an R6 entry (returns the parsed frontmatter + body). */
73
+ function get(id) {
74
+ const raw = inner.get(id);
75
+ if (!raw) return null;
76
+ const parsed = parseR6Body(raw.body);
77
+ return {
78
+ ...parsed,
79
+ id: raw.id,
80
+ shard: raw.shard,
81
+ _meta: raw.meta || {},
82
+ };
83
+ }
84
+
85
+ /** Query. See storage/shard-store.js for the filter shape. */
86
+ function query(filter = {}) {
87
+ const res = inner.query(filter);
88
+ return {
89
+ results: res.results.map(mapRecordToThinEntry),
90
+ needsRecompression: res.needsRecompression.slice(),
91
+ };
92
+ }
93
+
94
+ /** Remove an entry; underlying shard compacts immediately. */
95
+ function remove(id) { return inner.remove(id); }
96
+
97
+ /**
98
+ * Supersede: create a new entry N that replaces olds M[...].
99
+ * Writes N with supersedes=M[...], then marks each M.supersededBy=N.
100
+ * Old entries are NOT removed — they stay for audit / memory_trace.
101
+ */
102
+ function supersede({ newEntry, oldIds }) {
103
+ validateR6Entry(newEntry);
104
+ if (!Array.isArray(oldIds) || oldIds.length === 0) {
105
+ throw new Error('supersede: oldIds required (non-empty)');
106
+ }
107
+ const supersedes = oldIds.slice();
108
+ const write = { ...newEntry, supersedes };
109
+ const r = put(write);
110
+
111
+ for (const oldId of oldIds) {
112
+ const existing = get(oldId);
113
+ if (!existing) continue;
114
+ const updated = { ...existing, supersededBy: newEntry.id };
115
+ put(updated);
116
+ }
117
+ return r;
118
+ }
119
+
120
+ /**
121
+ * Atomic re-compression handoff:
122
+ * caller writes the new shard body to `memory-<shard>.md.compacting`,
123
+ * then calls `commitRecompression(shard)` which atomically renames it
124
+ * over the live file. Readers always see either the old or the new file.
125
+ *
126
+ * Consumers (334g dream) build the new body themselves; we just manage
127
+ * the rename + stats recomputation.
128
+ */
129
+ function stageRecompression(shardName, newBody) {
130
+ const tmpPath = join(dir, `memory-${shardName}.md${COMPACTING_SUFFIX}`);
131
+ writeAtomic(tmpPath, newBody);
132
+ return tmpPath;
133
+ }
134
+
135
+ function commitRecompression(shardName) {
136
+ const livePath = join(dir, `memory-${shardName}.md`);
137
+ const tmpPath = join(dir, `memory-${shardName}.md${COMPACTING_SUFFIX}`);
138
+ if (!existsSync(tmpPath)) {
139
+ throw new Error(`commitRecompression: no tmp file at ${tmpPath}`);
140
+ }
141
+ renameSync(tmpPath, livePath);
142
+ // The caller-supplied body replaced the whole shard. The old index rows
143
+ // for this shard are stale (new ids / offsets). Rebuild that shard's
144
+ // index rows from disk by delegating to the storage primitive — it walks
145
+ // START/END markers and recomputes offsets. We keep other shards intact.
146
+ const schema = buildShardSchema(kind, { extraShards: [...extraShards] });
147
+ const rebuilt = rebuildShardIndexFromDisk(dir, schema);
148
+ const innerIndex = inner.getIndex();
149
+ // Swap this shard's rows + bucket.
150
+ innerIndex.entries = innerIndex.entries.filter(e => e.shard !== shardName)
151
+ .concat(rebuilt.entries.filter(e => e.shard === shardName));
152
+ if (rebuilt.shards[shardName]) {
153
+ innerIndex.shards[shardName] = rebuilt.shards[shardName];
154
+ }
155
+ saveShardIndex(dir, innerIndex);
156
+ }
157
+
158
+ function abortRecompression(shardName) {
159
+ const tmpPath = join(dir, `memory-${shardName}.md${COMPACTING_SUFFIX}`);
160
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
161
+ }
162
+
163
+ /** Return shard-level stats (byte count, entry count, soft cap). */
164
+ function stats() {
165
+ const raw = inner.stats();
166
+ const shards = {};
167
+ for (const [name, bucket] of Object.entries(raw.shards)) {
168
+ shards[name] = { ...bucket, softCap: softCapFor(name) };
169
+ }
170
+ return { shards, count: raw.count };
171
+ }
172
+
173
+ /**
174
+ * Hint for the dream layer — returns `project-<slug>` candidate when
175
+ * ≥ PROJECT_DERIVE_THRESHOLD entries share a groupId and no project
176
+ * shard exists for that slug yet. Returns `null` otherwise.
177
+ *
178
+ * The actual derive (creating the shard file + re-compressing entries
179
+ * into it) is 334g dream work; this slice only advertises the hint so
180
+ * dream can schedule.
181
+ */
182
+ function projectDeriveHint() {
183
+ const groupCounts = new Map();
184
+ const { results } = inner.query({});
185
+ for (const rec of results) {
186
+ const gid = rec.meta?.groupId;
187
+ if (!gid) continue;
188
+ groupCounts.set(gid, (groupCounts.get(gid) || 0) + 1);
189
+ }
190
+ for (const [gid, count] of groupCounts) {
191
+ if (count < PROJECT_DERIVE_THRESHOLD) continue;
192
+ const slug = slugify(gid);
193
+ const shardName = `project-${slug}`;
194
+ const shardNames = Object.keys(inner.stats().shards);
195
+ if (shardNames.includes(shardName)) continue;
196
+ if (shardNames.length >= MAX_VP_SHARDS) continue;
197
+ return { groupId: gid, shard: shardName, count };
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function close() { /* underlying store is stateless (index saved on every op) */ }
203
+
204
+ return {
205
+ put,
206
+ get,
207
+ query,
208
+ remove,
209
+ supersede,
210
+ stageRecompression,
211
+ commitRecompression,
212
+ abortRecompression,
213
+ stats,
214
+ projectDeriveHint,
215
+ close,
216
+ _innerForTest: inner,
217
+ };
218
+ }
219
+
220
+ // ─── Serialisation ──────────────────────────────────────────────
221
+
222
+ function serialiseR6Body(entry) {
223
+ const fm = ['---'];
224
+ fm.push(`id: ${entry.id}`);
225
+ if (entry.vp) fm.push(`vp: ${entry.vp}`);
226
+ if (entry.taskId) fm.push(`taskId: ${entry.taskId}`);
227
+ fm.push(`kind: ${entry.kind}`);
228
+ fm.push(`shard: ${entry.shard}`);
229
+ if (entry.sourceRef) {
230
+ fm.push('sourceRef:');
231
+ if (entry.sourceRef.groupId) fm.push(` groupId: ${entry.sourceRef.groupId}`);
232
+ if (entry.sourceRef.taskId) fm.push(` taskId: ${entry.sourceRef.taskId}`);
233
+ if (Array.isArray(entry.sourceRef.msgIds) && entry.sourceRef.msgIds.length) {
234
+ fm.push(` msgIds: [${entry.sourceRef.msgIds.join(', ')}]`);
235
+ }
236
+ if (entry.sourceRef.timeWindow) fm.push(` timeWindow: ${entry.sourceRef.timeWindow}`);
237
+ if (entry.sourceRef.hint) fm.push(` hint: ${JSON.stringify(entry.sourceRef.hint)}`);
238
+ }
239
+ if (Array.isArray(entry.supersedes) && entry.supersedes.length) {
240
+ fm.push(`supersedes: [${entry.supersedes.join(', ')}]`);
241
+ }
242
+ if (entry.supersededBy) fm.push(`supersededBy: ${entry.supersededBy}`);
243
+ if (entry.pinned != null) fm.push(`pinned: ${entry.pinned ? 'true' : 'false'}`);
244
+ if (Array.isArray(entry.tags) && entry.tags.length) {
245
+ fm.push(`tags: [${entry.tags.join(', ')}]`);
246
+ }
247
+ if (entry.authoredBy) fm.push(`authoredBy: ${entry.authoredBy}`);
248
+ const now = new Date().toISOString();
249
+ fm.push(`createdAt: ${entry.createdAt || now}`);
250
+ fm.push(`updatedAt: ${now}`);
251
+ fm.push('---');
252
+ fm.push('');
253
+ fm.push(entry.body || entry.content || '');
254
+ return fm.join('\n');
255
+ }
256
+
257
+ function parseR6Body(raw) {
258
+ if (!raw || !raw.startsWith('---')) return { body: raw || '' };
259
+ const endIdx = raw.indexOf('\n---', 3);
260
+ if (endIdx === -1) return { body: raw };
261
+ const fm = raw.slice(4, endIdx).trim();
262
+ const body = raw.slice(endIdx + 4).replace(/^\n+/, '');
263
+ const out = { body };
264
+ let inSourceRef = false;
265
+ const sourceRef = {};
266
+ for (const line of fm.split('\n')) {
267
+ if (/^sourceRef:\s*$/.test(line)) { inSourceRef = true; continue; }
268
+ if (inSourceRef && /^\s+/.test(line)) {
269
+ const m = line.match(/^\s+(\w+):\s*(.*)$/);
270
+ if (!m) continue;
271
+ const [, k, v] = m;
272
+ if (k === 'msgIds') {
273
+ sourceRef.msgIds = v.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).filter(Boolean);
274
+ } else if (k === 'hint') {
275
+ try { sourceRef.hint = JSON.parse(v); } catch { sourceRef.hint = v; }
276
+ } else {
277
+ sourceRef[k] = v;
278
+ }
279
+ continue;
280
+ }
281
+ inSourceRef = false;
282
+ const m = line.match(/^(\w+):\s*(.*)$/);
283
+ if (!m) continue;
284
+ const [, k, v] = m;
285
+ switch (k) {
286
+ case 'id': out.id = v; break;
287
+ case 'vp': out.vp = v; break;
288
+ case 'taskId': out.taskId = v; break;
289
+ case 'kind': out.kind = v; break;
290
+ case 'shard': out.shard = v; break;
291
+ case 'supersededBy': out.supersededBy = v; break;
292
+ case 'pinned': out.pinned = v === 'true'; break;
293
+ case 'authoredBy': out.authoredBy = v; break;
294
+ case 'createdAt': out.createdAt = v; break;
295
+ case 'updatedAt': out.updatedAt = v; break;
296
+ case 'supersedes':
297
+ out.supersedes = v.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).filter(Boolean);
298
+ break;
299
+ case 'tags':
300
+ out.tags = v.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).filter(Boolean);
301
+ break;
302
+ default: break;
303
+ }
304
+ }
305
+ if (Object.keys(sourceRef).length > 0) out.sourceRef = sourceRef;
306
+ return out;
307
+ }
308
+
309
+ function pickMeta(entry) {
310
+ // Meta is only the fields the 334o query() layer filters on.
311
+ const meta = {};
312
+ if (entry.kind) meta.kind = entry.kind;
313
+ if (entry.tags) meta.tags = entry.tags.slice();
314
+ if (entry.pinned) meta.pinned = true;
315
+ if (entry.sourceRef?.groupId) meta.groupId = entry.sourceRef.groupId;
316
+ if (entry.sourceRef?.taskId) meta.taskId = entry.sourceRef.taskId;
317
+ if (entry.supersededBy) meta.supersededBy = entry.supersededBy;
318
+ return meta;
319
+ }
320
+
321
+ function mapRecordToThinEntry(rec) {
322
+ return {
323
+ id: rec.id,
324
+ shard: rec.shard,
325
+ kind: rec.meta?.kind,
326
+ tags: rec.meta?.tags || [],
327
+ pinned: Boolean(rec.meta?.pinned),
328
+ groupId: rec.meta?.groupId,
329
+ taskId: rec.meta?.taskId,
330
+ supersededBy: rec.meta?.supersededBy || null,
331
+ };
332
+ }
333
+
334
+ function slugify(s) {
335
+ return String(s || '')
336
+ .toLowerCase()
337
+ .replace(/[^a-z0-9]+/g, '-')
338
+ .replace(/^-+|-+$/g, '')
339
+ .slice(0, 40);
340
+ }
341
+
342
+ function discoverOnDiskShards(dir) {
343
+ try {
344
+ return readdirSync(dir)
345
+ .filter(f => /^memory-[A-Za-z0-9_-]+\.md$/.test(f))
346
+ .map(f => f.replace(/^memory-/, '').replace(/\.md$/, ''));
347
+ } catch { return []; }
348
+ }
349
+
350
+ // ─── Migration stub (§Δ23 / 334i) ───────────────────────────────
351
+
352
+ /**
353
+ * Migration stub: map a legacy R5 `memory-NNN.md` shard file path into a
354
+ * semantic shard assignment. Actual batch migration runs in 334i; this
355
+ * slice only defines the classifier API so dependent code can stub it.
356
+ *
357
+ * @param {object} legacyEntry parsed legacy entry { kind, scope, tags, ... }
358
+ * @returns {string} semantic shard name ("skill" / "lessons" / ...)
359
+ */
360
+ export function classifyLegacyEntryToShard(legacyEntry) {
361
+ if (!legacyEntry || typeof legacyEntry !== 'object') return 'skill';
362
+ const kind = legacyEntry.kind || 'fact';
363
+ switch (kind) {
364
+ case 'lesson': return 'lessons';
365
+ case 'preference': return 'preferences';
366
+ case 'identity': return 'preferences';
367
+ case 'relation': return 'relations';
368
+ case 'skill': return 'skill';
369
+ default: return 'skill';
370
+ }
371
+ }
372
+
373
+ export { AUTHORED_BY };
@@ -22,6 +22,8 @@ import memoryRead from './memory-read.js';
22
22
  import memoryWrite from './memory-write.js';
23
23
  import memorySearch, { memorySearchAlias } from './memory-search.js';
24
24
  import memoryQuery from './memory-query.js';
25
+ import memoryTrace from './memory-trace.js';
26
+ import openSourceMessage from './open-source-message.js';
25
27
  import webSearch from './web-search.js';
26
28
  import webFetch from './web-fetch.js';
27
29
  import historySearch from './history-search.js';
@@ -98,6 +100,8 @@ export const allTools = [
98
100
  memorySearch,
99
101
  memorySearchAlias,
100
102
  memoryQuery,
103
+ memoryTrace,
104
+ openSourceMessage,
101
105
  webSearch,
102
106
  webFetch,
103
107
  historySearch,
@@ -0,0 +1,135 @@
1
+ /**
2
+ * memory-trace.js — task-334f R6 §Δ24.3.
3
+ *
4
+ * Given a memory id, return the full entry (including sourceRef) plus the
5
+ * original source messages referenced by sourceRef.msgIds / timeWindow.
6
+ *
7
+ * Hard guardrails (task-334f):
8
+ * - Results are returned to the current turn ONLY. Nothing is written back
9
+ * to memory; the extraction lane sees its own copy.
10
+ * - Does not do cross-group fan-out. A trace is anchored to one groupId.
11
+ */
12
+
13
+ import { defineTool } from './types.js';
14
+
15
+ const MAX_BYTES = 64 * 1024;
16
+
17
+ export default defineTool({
18
+ name: 'memory_trace',
19
+ description: `Trace a memory entry back to its original source messages.
20
+
21
+ Use this when a recalled memory body is insufficient and you need the raw
22
+ discussion. Returns the full memory entry (with sourceRef) plus the source
23
+ messages from the group jsonl log.
24
+
25
+ Parameters:
26
+ - memId (required): the memory id (from recall)
27
+ - expand: "full" (default, exact msgIds) | "window" (expand around timeWindow)
28
+
29
+ Returns JSON: { memory, messages[], truncated? }.
30
+ The result is NOT written back to memory — it is context for the current turn
31
+ only.`,
32
+ parameters: {
33
+ type: 'object',
34
+ properties: {
35
+ memId: { type: 'string', description: 'Memory entry id' },
36
+ expand: { type: 'string', enum: ['full', 'window'], default: 'full' },
37
+ },
38
+ required: ['memId'],
39
+ },
40
+ isConcurrencySafe: () => true,
41
+ isReadOnly: () => true,
42
+ async execute(input, ctx) {
43
+ const memId = input?.memId;
44
+ if (!memId || typeof memId !== 'string') {
45
+ return JSON.stringify({ error: 'memId required (string)' });
46
+ }
47
+ const expand = input?.expand === 'window' ? 'window' : 'full';
48
+
49
+ const store = ctx?.memoryShardStore;
50
+ if (!store) {
51
+ return JSON.stringify({ error: 'R6 memory shard store not initialised' });
52
+ }
53
+ const entry = store.get(memId);
54
+ if (!entry) {
55
+ return JSON.stringify({ error: `memory entry not found: ${memId}` });
56
+ }
57
+
58
+ const sourceRef = entry.sourceRef || null;
59
+ if (!sourceRef) {
60
+ return JSON.stringify({
61
+ memory: entry,
62
+ messages: [],
63
+ note: 'entry has no sourceRef (pure declaration)',
64
+ });
65
+ }
66
+
67
+ const coordinator = ctx?.coordinator;
68
+ const groupId = sourceRef.groupId;
69
+ if (!coordinator || !groupId) {
70
+ return JSON.stringify({
71
+ memory: entry,
72
+ messages: [],
73
+ note: 'no group coordinator available',
74
+ });
75
+ }
76
+
77
+ const group = typeof coordinator.openGroup === 'function'
78
+ ? coordinator.openGroup(groupId)
79
+ : null;
80
+ if (!group) {
81
+ return JSON.stringify({
82
+ memory: entry,
83
+ messages: [],
84
+ note: `group ${groupId} not resolvable`,
85
+ });
86
+ }
87
+
88
+ const messages = [];
89
+ let bytes = 0;
90
+ let truncated = false;
91
+
92
+ if (expand === 'full' && Array.isArray(sourceRef.msgIds) && sourceRef.msgIds.length) {
93
+ const targetSet = new Set(sourceRef.msgIds);
94
+ // Walk only the smallest overlapping range instead of streaming all.
95
+ const first = sourceRef.msgIds[0];
96
+ const last = sourceRef.msgIds[sourceRef.msgIds.length - 1];
97
+ const iter = typeof group.readMessageRange === 'function'
98
+ ? group.readMessageRange(first, last)
99
+ : group.streamMessages();
100
+ for (const msg of iter) {
101
+ if (!targetSet.has(msg.id)) continue;
102
+ const chunk = estimateBytes(msg);
103
+ if (bytes + chunk > MAX_BYTES) { truncated = true; break; }
104
+ messages.push(msg);
105
+ bytes += chunk;
106
+ }
107
+ } else if (expand === 'window' && sourceRef.timeWindow) {
108
+ // timeWindow is "ISO..ISO"; best-effort textual compare works for ULIDs/ISO.
109
+ const [t0, t1] = String(sourceRef.timeWindow).split('..');
110
+ for (const msg of group.streamMessages()) {
111
+ const ts = msg.ts || '';
112
+ if (t0 && ts < t0) continue;
113
+ if (t1 && ts > t1) break;
114
+ const chunk = estimateBytes(msg);
115
+ if (bytes + chunk > MAX_BYTES) { truncated = true; break; }
116
+ messages.push(msg);
117
+ bytes += chunk;
118
+ }
119
+ }
120
+
121
+ return JSON.stringify({
122
+ memory: entry,
123
+ messages,
124
+ ...(truncated ? { truncated: true } : {}),
125
+ });
126
+ },
127
+ });
128
+
129
+ function estimateBytes(msg) {
130
+ try {
131
+ return Buffer.byteLength(JSON.stringify(msg), 'utf8');
132
+ } catch {
133
+ return 512;
134
+ }
135
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * open-source-message.js — task-334f R6 §Δ24.4.
3
+ *
4
+ * Low-level random access: given a (groupId, msgId), fetch the raw message
5
+ * from the group's jsonl log. Used when a VP has an exact pointer but does
6
+ * not want to run the memory_trace wrapper (5% case: audit / debug).
7
+ */
8
+
9
+ import { defineTool } from './types.js';
10
+
11
+ export default defineTool({
12
+ name: 'open_source_message',
13
+ description: `Open a single source message by (groupId, msgId).
14
+
15
+ This is the low-level random-access primitive. Prefer memory_trace if you are
16
+ starting from a memory entry. Returns JSON: { message } or { error }.`,
17
+ parameters: {
18
+ type: 'object',
19
+ properties: {
20
+ groupId: { type: 'string', description: 'Group id' },
21
+ msgId: { type: 'string', description: 'Message id' },
22
+ },
23
+ required: ['groupId', 'msgId'],
24
+ },
25
+ isConcurrencySafe: () => true,
26
+ isReadOnly: () => true,
27
+ async execute(input, ctx) {
28
+ const { groupId, msgId } = input || {};
29
+ if (!groupId || !msgId) {
30
+ return JSON.stringify({ error: 'groupId and msgId required' });
31
+ }
32
+ const coordinator = ctx?.coordinator;
33
+ if (!coordinator || typeof coordinator.openGroup !== 'function') {
34
+ return JSON.stringify({ error: 'group coordinator not available' });
35
+ }
36
+ const group = coordinator.openGroup(groupId);
37
+ if (!group) return JSON.stringify({ error: `group not found: ${groupId}` });
38
+
39
+ const iter = typeof group.readMessageRange === 'function'
40
+ ? group.readMessageRange(msgId, msgId)
41
+ : group.streamMessages();
42
+ for (const msg of iter) {
43
+ if (msg.id === msgId) {
44
+ return JSON.stringify({ message: msg });
45
+ }
46
+ }
47
+ return JSON.stringify({ error: `message not found: ${msgId} in ${groupId}` });
48
+ },
49
+ });