@yeaft/webchat-agent 0.1.530 → 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/connection/message-router.js +17 -1
- package/package.json +1 -1
- package/unify/engine.js +3 -0
- package/unify/memory/migrate-r5-to-r6.js +61 -0
- package/unify/memory/recall-r6.js +291 -0
- package/unify/memory/schema.js +166 -0
- package/unify/memory/shard-store.js +373 -0
- package/unify/task-message.js +144 -0
- package/unify/tools/index.js +4 -0
- package/unify/tools/memory-trace.js +135 -0
- package/unify/tools/open-source-message.js +49 -0
- package/unify/user-memory.js +104 -0
- package/unify/web-bridge.js +39 -0
|
@@ -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 };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-message.js — R6 §Δ28 / §Δ31.6 task-scoped direct messaging.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the withdrawn R3 `unify_task_private_chat` event with a simple
|
|
5
|
+
* echo-able `task_message` pair:
|
|
6
|
+
*
|
|
7
|
+
* inbound (web → agent): `unify_task_message`
|
|
8
|
+
* { type, groupId, taskId, vpId, text, mentions?, replyTo?, requestId? }
|
|
9
|
+
* outbound (agent → web): `task_message`
|
|
10
|
+
* { type, groupId, taskId, vpId, msgId, text, mentions, replyTo,
|
|
11
|
+
* ts, requestId? }
|
|
12
|
+
*
|
|
13
|
+
* This module owns only the *wire adapter* — the payload is validated,
|
|
14
|
+
* stamped with msgId + ts, and broadcast back so the sender's UI and any
|
|
15
|
+
* other connected views converge on the same record. Persistence + task
|
|
16
|
+
* ACL enforcement are deliberately deferred to task-334l (per PM dispatch:
|
|
17
|
+
* "user_memory_* 实际 ingestion 归 334l"); the parallel task-private
|
|
18
|
+
* storage hook follows the same phasing.
|
|
19
|
+
*
|
|
20
|
+
* Invariants:
|
|
21
|
+
* • Never throws on the WS hot path — bad payloads reply with a
|
|
22
|
+
* `task_message_rejected` event carrying a stable `code` string for
|
|
23
|
+
* UI i18n (mirrors the vp_crud_result contract from 334-ui-g).
|
|
24
|
+
* • The outbound event field order and keys are considered wire-frozen
|
|
25
|
+
* per R6 §Δ31.6 table; additive fields only in future slices.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { nextMsgId, isValidVpId } from './groups/ids.js';
|
|
29
|
+
|
|
30
|
+
/** Known `reject` codes — kept stable so 334-ui-* can key i18n on them. */
|
|
31
|
+
export const TASK_MESSAGE_REJECT_CODES = Object.freeze({
|
|
32
|
+
MISSING_GROUP_ID: 'missing_group_id',
|
|
33
|
+
MISSING_TASK_ID: 'missing_task_id',
|
|
34
|
+
MISSING_VP_ID: 'missing_vp_id',
|
|
35
|
+
INVALID_VP_ID: 'invalid_vp_id',
|
|
36
|
+
EMPTY_TEXT: 'empty_text',
|
|
37
|
+
TEXT_TOO_LONG: 'text_too_long',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Soft body cap — matches the shard-entry cap used elsewhere in R6 (§Δ26.3). */
|
|
41
|
+
export const MAX_TEXT_LENGTH = 16_384;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Pure validator. Returns `{ ok: true, payload }` or `{ ok: false, code }`.
|
|
45
|
+
* No IO, no clock reads — safe to unit-test in isolation.
|
|
46
|
+
*
|
|
47
|
+
* @param {any} msg — raw WS message from the web client
|
|
48
|
+
*/
|
|
49
|
+
export function validateTaskMessage(msg) {
|
|
50
|
+
if (!msg || typeof msg !== 'object') {
|
|
51
|
+
return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_GROUP_ID };
|
|
52
|
+
}
|
|
53
|
+
const { groupId, taskId, vpId, text } = msg;
|
|
54
|
+
if (!groupId || typeof groupId !== 'string') {
|
|
55
|
+
return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_GROUP_ID };
|
|
56
|
+
}
|
|
57
|
+
if (!taskId || typeof taskId !== 'string') {
|
|
58
|
+
return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_TASK_ID };
|
|
59
|
+
}
|
|
60
|
+
if (!vpId || typeof vpId !== 'string') {
|
|
61
|
+
return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_VP_ID };
|
|
62
|
+
}
|
|
63
|
+
// Allow the reserved `user` sentinel as a speaker here — tasks can have
|
|
64
|
+
// human-user messages alongside VP messages. Any other vpId must pass
|
|
65
|
+
// the full shape check (rejects `all`, `system`, pure digits, etc.).
|
|
66
|
+
if (vpId !== 'user' && !isValidVpId(vpId)) {
|
|
67
|
+
return { ok: false, code: TASK_MESSAGE_REJECT_CODES.INVALID_VP_ID };
|
|
68
|
+
}
|
|
69
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
70
|
+
return { ok: false, code: TASK_MESSAGE_REJECT_CODES.EMPTY_TEXT };
|
|
71
|
+
}
|
|
72
|
+
if (text.length > MAX_TEXT_LENGTH) {
|
|
73
|
+
return { ok: false, code: TASK_MESSAGE_REJECT_CODES.TEXT_TOO_LONG };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const mentions = Array.isArray(msg.mentions)
|
|
77
|
+
? msg.mentions.filter(m => typeof m === 'string' && m.length > 0).slice(0, 32)
|
|
78
|
+
: [];
|
|
79
|
+
const replyTo = typeof msg.replyTo === 'string' && msg.replyTo.length > 0
|
|
80
|
+
? msg.replyTo
|
|
81
|
+
: null;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
payload: { groupId, taskId, vpId, text, mentions, replyTo },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the outbound `task_message` event from a validated payload.
|
|
91
|
+
* Exposed separately so tests can snapshot the wire shape without
|
|
92
|
+
* needing a live send fn.
|
|
93
|
+
*
|
|
94
|
+
* @param {{groupId:string,taskId:string,vpId:string,text:string,mentions:string[],replyTo:?string}} payload
|
|
95
|
+
* @param {{now?:()=>number, msgId?:()=>string, requestId?:string}} [opts]
|
|
96
|
+
*/
|
|
97
|
+
export function buildTaskMessageEvent(payload, opts = {}) {
|
|
98
|
+
const now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
99
|
+
const mkId = typeof opts.msgId === 'function' ? opts.msgId : nextMsgId;
|
|
100
|
+
const evt = {
|
|
101
|
+
type: 'task_message',
|
|
102
|
+
groupId: payload.groupId,
|
|
103
|
+
taskId: payload.taskId,
|
|
104
|
+
vpId: payload.vpId,
|
|
105
|
+
msgId: mkId(),
|
|
106
|
+
text: payload.text,
|
|
107
|
+
mentions: payload.mentions,
|
|
108
|
+
replyTo: payload.replyTo,
|
|
109
|
+
ts: now(),
|
|
110
|
+
};
|
|
111
|
+
if (opts.requestId) evt.requestId = opts.requestId;
|
|
112
|
+
return evt;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the outbound `task_message_rejected` event.
|
|
117
|
+
* @param {string} code — one of TASK_MESSAGE_REJECT_CODES
|
|
118
|
+
* @param {any} msg — original inbound msg (for requestId echo)
|
|
119
|
+
*/
|
|
120
|
+
export function buildTaskMessageRejected(code, msg) {
|
|
121
|
+
const evt = { type: 'task_message_rejected', code };
|
|
122
|
+
if (msg && typeof msg.requestId === 'string') evt.requestId = msg.requestId;
|
|
123
|
+
if (msg && typeof msg.groupId === 'string') evt.groupId = msg.groupId;
|
|
124
|
+
if (msg && typeof msg.taskId === 'string') evt.taskId = msg.taskId;
|
|
125
|
+
return evt;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* WS handler entry point. Validates, echoes, never throws.
|
|
130
|
+
*
|
|
131
|
+
* @param {any} msg
|
|
132
|
+
* @param {(event:object)=>void} sendUnifyEvent
|
|
133
|
+
* @param {{now?:()=>number, msgId?:()=>string}} [opts] — test seams
|
|
134
|
+
*/
|
|
135
|
+
export function handleUnifyTaskMessage(msg, sendUnifyEvent, opts = {}) {
|
|
136
|
+
const result = validateTaskMessage(msg);
|
|
137
|
+
if (!result.ok) {
|
|
138
|
+
try { sendUnifyEvent(buildTaskMessageRejected(result.code, msg)); } catch { /* best-effort */ }
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
142
|
+
const evt = buildTaskMessageEvent(result.payload, { ...opts, requestId });
|
|
143
|
+
try { sendUnifyEvent(evt); } catch { /* never crash WS pipeline */ }
|
|
144
|
+
}
|
package/unify/tools/index.js
CHANGED
|
@@ -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
|
+
}
|