@yeaft/webchat-agent 0.1.531 → 0.1.533
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 +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/prompts.js +32 -8
- package/unify/tasks/store.js +135 -0
- package/unify/tasks/summary.js +338 -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/tools/task-tools.js +85 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* summary.js — task-334n: Task multi-VP collaboration summary protocol.
|
|
3
|
+
*
|
|
4
|
+
* Owns:
|
|
5
|
+
* - postSummary() — write a `type=summary` message to the group jsonl
|
|
6
|
+
* and run the extractor (B + C)
|
|
7
|
+
* - extractTaskMemory() — turn a summary body into 2-5 task-memory entries
|
|
8
|
+
* via 334f task-memory shard lib (C)
|
|
9
|
+
* - buildSummaryReminder() — compute the §Δ31.4 3-AND soft reminder shape
|
|
10
|
+
* consumed by 334e's `taskCtx.summaryReminder` (D)
|
|
11
|
+
* - buildTaskCtxMemories() — assemble task-memory top-5 (pinned + recent +
|
|
12
|
+
* tag relevance) for task_ctx (E)
|
|
13
|
+
*
|
|
14
|
+
* Hard boundaries:
|
|
15
|
+
* - does NOT touch 334o jsonl rotation internals (calls group.appendMessage)
|
|
16
|
+
* - does NOT touch 334f shard-store impl (calls openMemoryShardStore API)
|
|
17
|
+
* - does NOT touch 334e prompts main frame (returns plain shapes that feed
|
|
18
|
+
* the existing renderTaskCtx contract)
|
|
19
|
+
* - does NOT self-loop-write VP-memory (extractor writes task-memory only;
|
|
20
|
+
* VP-level synthesis is deferred to 334g dream)
|
|
21
|
+
* - softCap overflow does NOT create new shards (334f already routes into
|
|
22
|
+
* dream queue via projectDeriveHint; we just surface `needsRecompression`)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { openMemoryShardStore } from '../memory/shard-store.js';
|
|
27
|
+
import { AUTHORED_BY } from '../memory/schema.js';
|
|
28
|
+
|
|
29
|
+
// ─── §Δ31.4 soft-reminder thresholds ─────────────────────────────
|
|
30
|
+
/** Must be initiator AND members>1 AND (age≥20min OR turns≥10). */
|
|
31
|
+
export const SUMMARY_REMINDER_MIN_MEMBERS = 2;
|
|
32
|
+
export const SUMMARY_REMINDER_MIN_TURNS = 10;
|
|
33
|
+
export const SUMMARY_REMINDER_MIN_AGE_MS = 20 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
// ─── extractor limits ────────────────────────────────────────────
|
|
36
|
+
export const EXTRACT_MIN_ENTRIES = 2;
|
|
37
|
+
export const EXTRACT_MAX_ENTRIES = 5;
|
|
38
|
+
|
|
39
|
+
/** Whitelist of R6 kinds emitted by the summary-extractor. */
|
|
40
|
+
const EXTRACT_KINDS = Object.freeze(['progress', 'decision']);
|
|
41
|
+
|
|
42
|
+
/** Shard routing for each extracted kind (§Δ25.2 task-memory fixed set). */
|
|
43
|
+
const KIND_TO_SHARD = Object.freeze({
|
|
44
|
+
progress: 'progress',
|
|
45
|
+
decision: 'decision',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ─── (B) postSummary ─────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write a `type=summary` message to the group log, then auto-run the
|
|
52
|
+
* extractor to derive task-memory entries.
|
|
53
|
+
*
|
|
54
|
+
* @param {{
|
|
55
|
+
* group: import('../groups/group-store.js').GroupHandle,
|
|
56
|
+
* taskId: string,
|
|
57
|
+
* fromVpId: string,
|
|
58
|
+
* body: string,
|
|
59
|
+
* progress?: number, // 0..100
|
|
60
|
+
* supersedes?: string[], // prior summary msgIds being superseded
|
|
61
|
+
* memoryDir: string, // groups/<g>/tasks/<t>/memory/
|
|
62
|
+
* now?: () => number, // test clock
|
|
63
|
+
* extractor?: (body:string) => Array<{kind:string,body:string,tags?:string[]}>
|
|
64
|
+
* // optional hook; default uses `defaultExtractor` (heuristic, no LLM)
|
|
65
|
+
* }} opts
|
|
66
|
+
* @returns {{ message: any, memoryIds: string[], supersededSummaryIds: string[] }}
|
|
67
|
+
*/
|
|
68
|
+
export function postSummary(opts) {
|
|
69
|
+
const {
|
|
70
|
+
group,
|
|
71
|
+
taskId,
|
|
72
|
+
fromVpId,
|
|
73
|
+
body,
|
|
74
|
+
progress,
|
|
75
|
+
supersedes,
|
|
76
|
+
memoryDir,
|
|
77
|
+
now = () => Date.now(),
|
|
78
|
+
extractor = defaultExtractor,
|
|
79
|
+
} = opts || {};
|
|
80
|
+
|
|
81
|
+
if (!group || typeof group.appendMessage !== 'function') {
|
|
82
|
+
throw new Error('postSummary: group handle required');
|
|
83
|
+
}
|
|
84
|
+
if (!taskId) throw new Error('postSummary: taskId required');
|
|
85
|
+
if (!fromVpId) throw new Error('postSummary: fromVpId required');
|
|
86
|
+
if (typeof body !== 'string' || !body.trim()) {
|
|
87
|
+
throw new Error('postSummary: body required (non-empty string)');
|
|
88
|
+
}
|
|
89
|
+
if (progress != null) {
|
|
90
|
+
const p = Number(progress);
|
|
91
|
+
if (!Number.isFinite(p) || p < 0 || p > 100) {
|
|
92
|
+
throw new Error('postSummary: progress must be number in [0,100]');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const supersedesArr = Array.isArray(supersedes)
|
|
96
|
+
? supersedes.filter((s) => typeof s === 'string' && s)
|
|
97
|
+
: [];
|
|
98
|
+
|
|
99
|
+
// 1) Append the summary message to the group jsonl log (type=summary).
|
|
100
|
+
const stored = group.appendMessage({
|
|
101
|
+
from: fromVpId,
|
|
102
|
+
role: 'assistant',
|
|
103
|
+
text: body,
|
|
104
|
+
taskId,
|
|
105
|
+
meta: {
|
|
106
|
+
type: 'summary',
|
|
107
|
+
progress: progress == null ? null : Number(progress),
|
|
108
|
+
supersedes: supersedesArr,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 2) Run the extractor → write task-memory entries (C).
|
|
113
|
+
const memoryIds = [];
|
|
114
|
+
try {
|
|
115
|
+
const store = openMemoryShardStore(memoryDir, 'task');
|
|
116
|
+
const raw = extractor(body) || [];
|
|
117
|
+
const bounded = clampExtracted(raw);
|
|
118
|
+
for (const [i, item] of bounded.entries()) {
|
|
119
|
+
const kind = EXTRACT_KINDS.includes(item.kind) ? item.kind : 'progress';
|
|
120
|
+
const shard = KIND_TO_SHARD[kind] || 'progress';
|
|
121
|
+
const id = `mem-${stored.id}-${i + 1}`;
|
|
122
|
+
store.put({
|
|
123
|
+
id,
|
|
124
|
+
shard,
|
|
125
|
+
kind,
|
|
126
|
+
taskId,
|
|
127
|
+
body: typeof item.body === 'string' ? item.body.trim() : '',
|
|
128
|
+
tags: Array.isArray(item.tags) ? item.tags.slice(0, 5) : [],
|
|
129
|
+
authoredBy: AUTHORED_BY.SUMMARY,
|
|
130
|
+
sourceRef: { taskId, msgIds: [stored.id] },
|
|
131
|
+
createdAt: new Date(now()).toISOString(),
|
|
132
|
+
});
|
|
133
|
+
memoryIds.push(id);
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Extractor failures must not fail the summary post; the message is
|
|
137
|
+
// already persisted (audit property). We return the empty memoryIds so
|
|
138
|
+
// callers can surface a warning if they want.
|
|
139
|
+
// eslint-disable-next-line no-console
|
|
140
|
+
console.warn('[task-334n] summary-extractor failed:', err?.message || err);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
message: stored,
|
|
145
|
+
memoryIds,
|
|
146
|
+
supersededSummaryIds: supersedesArr,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Clamp raw extractor output to [EXTRACT_MIN_ENTRIES..EXTRACT_MAX_ENTRIES]. */
|
|
151
|
+
function clampExtracted(arr) {
|
|
152
|
+
const cleaned = arr.filter((x) => x && typeof x.body === 'string' && x.body.trim());
|
|
153
|
+
if (cleaned.length === 0) return [];
|
|
154
|
+
return cleaned.slice(0, EXTRACT_MAX_ENTRIES);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── (C) default extractor ───────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Heuristic extractor — no LLM, deterministic, safe for tests.
|
|
161
|
+
*
|
|
162
|
+
* Strategy:
|
|
163
|
+
* - Split body into non-empty lines (trim bullets).
|
|
164
|
+
* - Lines starting with keywords "decide/decision/chose/chosen" → kind=decision.
|
|
165
|
+
* - Lines starting with "progress/ship/shipped/done/completed/blocker/todo"
|
|
166
|
+
* → kind=progress.
|
|
167
|
+
* - Everything else → kind=progress (default).
|
|
168
|
+
* - Emit up to EXTRACT_MAX_ENTRIES.
|
|
169
|
+
*/
|
|
170
|
+
export function defaultExtractor(body) {
|
|
171
|
+
if (typeof body !== 'string') return [];
|
|
172
|
+
const lines = body
|
|
173
|
+
.split(/\r?\n/)
|
|
174
|
+
.map((l) => l.replace(/^[\s*\-•]+/, '').trim())
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
const lower = line.toLowerCase();
|
|
179
|
+
let kind = 'progress';
|
|
180
|
+
if (/^(decide|decision|chose|chosen|pick|choose)\b/.test(lower)) {
|
|
181
|
+
kind = 'decision';
|
|
182
|
+
}
|
|
183
|
+
out.push({ kind, body: line });
|
|
184
|
+
if (out.length >= EXTRACT_MAX_ENTRIES) break;
|
|
185
|
+
}
|
|
186
|
+
// If we ended up with fewer than MIN and there was a body, collapse to
|
|
187
|
+
// one "progress" entry carrying the trimmed full body so we never emit 0
|
|
188
|
+
// when the caller gave us real content and asked for 2-5.
|
|
189
|
+
if (out.length < EXTRACT_MIN_ENTRIES && lines.length === 0 && body.trim()) {
|
|
190
|
+
out.push({ kind: 'progress', body: body.trim() });
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── (D) soft reminder builder ───────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build the `taskCtx.summaryReminder` shape consumed by 334e's prompt.
|
|
199
|
+
* Returns null when the 3-AND conditions do not all hold. The prompt layer
|
|
200
|
+
* adds a 4th check (currentVpId === initiatorVpId) so we gate here too so
|
|
201
|
+
* callers can debug-log why it was suppressed.
|
|
202
|
+
*
|
|
203
|
+
* §Δ31.4 conditions:
|
|
204
|
+
* (1) task.members.length > 1
|
|
205
|
+
* (2) caller role === 'initiator' (i.e. currentVpId === task.initiator)
|
|
206
|
+
* (3) (now - lastSummaryAt) ≥ 20 min OR nonSummaryTurns ≥ 10
|
|
207
|
+
*
|
|
208
|
+
* @param {{
|
|
209
|
+
* task: { initiator?: string, members?: string[] },
|
|
210
|
+
* currentVpId: string,
|
|
211
|
+
* lastSummaryAt: number, // epoch ms, 0 = never
|
|
212
|
+
* nonSummaryTurns: number,
|
|
213
|
+
* now?: number,
|
|
214
|
+
* }} input
|
|
215
|
+
* @returns {{ triggered: boolean, nonSummaryCount: number, lastSummaryAt: number,
|
|
216
|
+
* now: number, reasons: string[] }}
|
|
217
|
+
*/
|
|
218
|
+
export function buildSummaryReminder(input) {
|
|
219
|
+
const { task, currentVpId, lastSummaryAt = 0, nonSummaryTurns = 0 } = input || {};
|
|
220
|
+
const now = typeof input?.now === 'number' ? input.now : Date.now();
|
|
221
|
+
const reasons = [];
|
|
222
|
+
|
|
223
|
+
if (!task || typeof task !== 'object') {
|
|
224
|
+
return { triggered: false, reasons: ['no-task'], nonSummaryCount: nonSummaryTurns, lastSummaryAt, now };
|
|
225
|
+
}
|
|
226
|
+
const members = Array.isArray(task.members) ? task.members : [];
|
|
227
|
+
const isInitiator = !!currentVpId && task.initiator === currentVpId;
|
|
228
|
+
|
|
229
|
+
if (!isInitiator) reasons.push('not-initiator');
|
|
230
|
+
if (members.length <= SUMMARY_REMINDER_MIN_MEMBERS - 1) reasons.push('solo-task');
|
|
231
|
+
|
|
232
|
+
const ageMs = lastSummaryAt > 0 ? now - lastSummaryAt : Number.POSITIVE_INFINITY;
|
|
233
|
+
const ageOk = ageMs >= SUMMARY_REMINDER_MIN_AGE_MS;
|
|
234
|
+
const turnsOk = nonSummaryTurns >= SUMMARY_REMINDER_MIN_TURNS;
|
|
235
|
+
if (!ageOk && !turnsOk) reasons.push('too-soon');
|
|
236
|
+
|
|
237
|
+
const triggered = isInitiator && members.length >= SUMMARY_REMINDER_MIN_MEMBERS && (ageOk || turnsOk);
|
|
238
|
+
return {
|
|
239
|
+
triggered,
|
|
240
|
+
reasons,
|
|
241
|
+
nonSummaryCount: nonSummaryTurns,
|
|
242
|
+
lastSummaryAt,
|
|
243
|
+
now,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── (E) task_ctx top-5 task-memory builder ──────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Assemble task-memory top-5 for 334e's `taskCtx.memories` field.
|
|
251
|
+
* Ordering (§Δ16.5): pinned first → recent → tag-relevant. Supersedes are
|
|
252
|
+
* hidden (entries with supersededBy != null are filtered out).
|
|
253
|
+
*
|
|
254
|
+
* @param {string} memoryDir groups/<g>/tasks/<t>/memory/
|
|
255
|
+
* @param {{ tags?: string[], top?: number }} [opts]
|
|
256
|
+
* tags : optional tag hints to boost relevance
|
|
257
|
+
* top : default 5
|
|
258
|
+
* @returns {Array<{body:string, shard:string}>}
|
|
259
|
+
*/
|
|
260
|
+
export function buildTaskCtxMemories(memoryDir, opts = {}) {
|
|
261
|
+
const top = Number.isFinite(opts.top) ? Number(opts.top) : 5;
|
|
262
|
+
const tagHints = Array.isArray(opts.tags) ? opts.tags : [];
|
|
263
|
+
let results = [];
|
|
264
|
+
try {
|
|
265
|
+
const store = openMemoryShardStore(memoryDir, 'task');
|
|
266
|
+
const q = store.query({});
|
|
267
|
+
// query() returns thin entries (id/shard/kind/tags/pinned/groupId/taskId/supersededBy);
|
|
268
|
+
// we need the body too.
|
|
269
|
+
const hits = (q.results || [])
|
|
270
|
+
.filter((r) => !r.supersededBy)
|
|
271
|
+
.map((r) => {
|
|
272
|
+
const full = store.get(r.id);
|
|
273
|
+
return {
|
|
274
|
+
id: r.id,
|
|
275
|
+
shard: r.shard || 'general',
|
|
276
|
+
body: full?.body || '',
|
|
277
|
+
tags: Array.isArray(r.tags) ? r.tags : [],
|
|
278
|
+
pinned: !!r.pinned,
|
|
279
|
+
createdAt: full?.createdAt || null,
|
|
280
|
+
};
|
|
281
|
+
})
|
|
282
|
+
.filter((r) => r.body && r.body.trim());
|
|
283
|
+
|
|
284
|
+
const score = (r) => {
|
|
285
|
+
let s = 0;
|
|
286
|
+
if (r.pinned) s += 1000;
|
|
287
|
+
// recency proxy (ISO string compare works lexicographically)
|
|
288
|
+
if (r.createdAt) s += 10;
|
|
289
|
+
// tag relevance
|
|
290
|
+
for (const t of tagHints) if (r.tags.includes(t)) s += 5;
|
|
291
|
+
return s;
|
|
292
|
+
};
|
|
293
|
+
hits.sort((a, b) => {
|
|
294
|
+
const ds = score(b) - score(a);
|
|
295
|
+
if (ds !== 0) return ds;
|
|
296
|
+
// stable recency tie-break
|
|
297
|
+
return String(b.createdAt || '').localeCompare(String(a.createdAt || ''));
|
|
298
|
+
});
|
|
299
|
+
results = hits.slice(0, top).map((r) => ({ body: r.body, shard: r.shard }));
|
|
300
|
+
} catch {
|
|
301
|
+
results = [];
|
|
302
|
+
}
|
|
303
|
+
return results;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── (F) related-task ACL fail-closed gate ───────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Return memory/summary hints for a related task only when ACL grants.
|
|
310
|
+
* Caller passes the TaskStore so we can ask `canAccessRelated()`.
|
|
311
|
+
*
|
|
312
|
+
* @param {{
|
|
313
|
+
* taskStore: import('./store.js').TaskStore,
|
|
314
|
+
* currentTaskId: string,
|
|
315
|
+
* otherTaskId: string,
|
|
316
|
+
* vpId: string,
|
|
317
|
+
* groupsRoot: string,
|
|
318
|
+
* top?: number,
|
|
319
|
+
* }} input
|
|
320
|
+
* @returns {null | { id:string, title:string, members:string[], updatedAt?:number, memories:Array<{body:string,shard:string}> }}
|
|
321
|
+
* null iff ACL denies — NEVER leak taskId in that case.
|
|
322
|
+
*/
|
|
323
|
+
export function getRelatedTaskCtx(input) {
|
|
324
|
+
const { taskStore, currentTaskId, otherTaskId, vpId, groupsRoot, top = 2 } = input || {};
|
|
325
|
+
if (!taskStore || !currentTaskId || !otherTaskId || !vpId || !groupsRoot) return null;
|
|
326
|
+
if (!taskStore.canAccessRelated(currentTaskId, otherTaskId, vpId)) return null;
|
|
327
|
+
const other = taskStore.get(otherTaskId);
|
|
328
|
+
if (!other || !other.groupId) return null;
|
|
329
|
+
const memoryDir = join(groupsRoot, other.groupId, 'tasks', other.id, 'memory');
|
|
330
|
+
const mems = buildTaskCtxMemories(memoryDir, { top });
|
|
331
|
+
return {
|
|
332
|
+
id: other.id,
|
|
333
|
+
title: other.title || other.id,
|
|
334
|
+
members: Array.isArray(other.members) ? other.members.slice() : [],
|
|
335
|
+
updatedAt: other.updatedAt || 0,
|
|
336
|
+
memories: mems,
|
|
337
|
+
};
|
|
338
|
+
}
|
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
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -515,3 +515,88 @@ approach, steps, and status of the current work.`,
|
|
|
515
515
|
}
|
|
516
516
|
},
|
|
517
517
|
});
|
|
518
|
+
|
|
519
|
+
// ─── TaskSummaryPost (task-334n) ────────────────────────
|
|
520
|
+
|
|
521
|
+
import { postSummary } from '../tasks/summary.js';
|
|
522
|
+
import { openGroup } from '../groups/group-store.js';
|
|
523
|
+
import { join } from 'path';
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* task-334n §B — initiator posts a progress summary to the group log.
|
|
527
|
+
* Triggers the summary-extractor automatically (§C).
|
|
528
|
+
*/
|
|
529
|
+
export const taskSummaryPost = defineTool({
|
|
530
|
+
name: 'task_summary_post',
|
|
531
|
+
description: `Post a progress summary for a multi-VP task (task-334n).
|
|
532
|
+
|
|
533
|
+
Only the task initiator should call this. The summary is written to the
|
|
534
|
+
group message log as \`type=summary\` and auto-extracts 2-5 task-memory
|
|
535
|
+
entries (kind=progress|decision) via the task-memory shard lib.
|
|
536
|
+
|
|
537
|
+
To revise a prior summary, pass its msgId in \`supersedes\` — the old
|
|
538
|
+
summary is marked \`supersededBy\` while staying on disk for audit.`,
|
|
539
|
+
parameters: {
|
|
540
|
+
type: 'object',
|
|
541
|
+
properties: {
|
|
542
|
+
taskId: { type: 'string', description: 'Target task id' },
|
|
543
|
+
body: { type: 'string', description: 'Summary body (markdown)' },
|
|
544
|
+
progress: { type: 'number', description: '0..100, optional' },
|
|
545
|
+
supersedes: {
|
|
546
|
+
type: 'array',
|
|
547
|
+
items: { type: 'string' },
|
|
548
|
+
description: 'Prior summary msgIds this revision supersedes',
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
required: ['taskId', 'body'],
|
|
552
|
+
},
|
|
553
|
+
isConcurrencySafe: () => false,
|
|
554
|
+
isReadOnly: () => false,
|
|
555
|
+
async execute(input, ctx) {
|
|
556
|
+
const err = requireStore();
|
|
557
|
+
if (err) return err;
|
|
558
|
+
const { taskId, body, progress, supersedes } = input || {};
|
|
559
|
+
if (!taskId || !body) {
|
|
560
|
+
return JSON.stringify({ error: 'taskId and body are required' });
|
|
561
|
+
}
|
|
562
|
+
const task = taskStore.get(taskId);
|
|
563
|
+
if (!task) return JSON.stringify({ error: `task not found: ${taskId}` });
|
|
564
|
+
if (!task.groupId) {
|
|
565
|
+
return JSON.stringify({ error: 'task has no groupId; summary requires a group' });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const currentVpId = ctx?.currentVpId;
|
|
569
|
+
if (currentVpId && task.initiator && currentVpId !== task.initiator) {
|
|
570
|
+
return JSON.stringify({ error: 'only the task initiator may post summaries' });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const yeaftDir = ctx?.yeaftDir;
|
|
574
|
+
if (!yeaftDir) {
|
|
575
|
+
return JSON.stringify({ error: 'yeaftDir missing from tool context' });
|
|
576
|
+
}
|
|
577
|
+
const groupsRoot = join(yeaftDir, 'groups');
|
|
578
|
+
const memoryDir = join(groupsRoot, task.groupId, 'tasks', task.id, 'memory');
|
|
579
|
+
|
|
580
|
+
const group = openGroup(groupsRoot, task.groupId);
|
|
581
|
+
try {
|
|
582
|
+
const res = postSummary({
|
|
583
|
+
group,
|
|
584
|
+
taskId,
|
|
585
|
+
fromVpId: currentVpId || task.initiator || 'unknown',
|
|
586
|
+
body,
|
|
587
|
+
progress,
|
|
588
|
+
supersedes,
|
|
589
|
+
memoryDir,
|
|
590
|
+
});
|
|
591
|
+
return JSON.stringify({
|
|
592
|
+
success: true,
|
|
593
|
+
messageId: res.message.id,
|
|
594
|
+
memoryIds: res.memoryIds,
|
|
595
|
+
supersededSummaryIds: res.supersededSummaryIds,
|
|
596
|
+
});
|
|
597
|
+
} finally {
|
|
598
|
+
group.close();
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|