@yeaft/webchat-agent 0.1.532 → 0.1.534
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/memory/migrate-r5-to-r6.js +566 -16
- package/unify/prompts.js +32 -8
- package/unify/tasks/store.js +135 -0
- package/unify/tasks/summary.js +338 -0
- package/unify/tools/task-tools.js +85 -0
package/package.json
CHANGED
|
@@ -1,22 +1,89 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* migrate-r5-to-r6.js — task-
|
|
2
|
+
* migrate-r5-to-r6.js — task-334i (wave-4) R5 → R6 storage migration.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Continuation of task-334i-v0 (PR #552, shipped v0.1.521) which established
|
|
5
|
+
* the general `~/.yeaft/` tree via `v0-to-v1.js`. This slice implements the
|
|
6
|
+
* R5→R6 *memory shard + conversation rotation* pass that fleshes out the
|
|
7
|
+
* previously-stubbed `applyR5ToR6Migration`.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
* Semantic shards: skill / relations / lessons / preferences /
|
|
9
|
-
* project-<slug>
|
|
9
|
+
* Spec: .crew/context/task-334i-impl-spec.md
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Key invariants:
|
|
12
|
+
* - This is independently idempotent from v0→v1. State version bumps r5→r6.
|
|
13
|
+
* - Does NOT touch 334f `shard-store.js` or 334o storage primitives —
|
|
14
|
+
* only consumes their public APIs.
|
|
15
|
+
* - Legacy R5 data is archived to `.legacy/r6-state.tar.gz` BEFORE any
|
|
16
|
+
* write. Rollback restores the archive but never deletes it.
|
|
17
|
+
* - `migration-state.json` is the single source of truth; writes go
|
|
18
|
+
* through `writeAtomic` (tmp+rename) mirroring 334f commitRecompression.
|
|
19
|
+
*
|
|
20
|
+
* Two-pass algorithm (pre-emptive discovery #2):
|
|
21
|
+
* Pass 1 — write each entry to its default semantic shard
|
|
22
|
+
* (skill / relations / lessons / preferences / project-legacy)
|
|
23
|
+
* while counting entries per groupId.
|
|
24
|
+
* Pass 2 — for each groupId with count ≥ PROJECT_DERIVE_THRESHOLD (30),
|
|
25
|
+
* derive a `project-<slug>` shard and move entries via
|
|
26
|
+
* stageRecompression / commitRecompression.
|
|
27
|
+
*
|
|
28
|
+
* Name drift fix (pre-emptive discovery #1):
|
|
29
|
+
* `map-fields.js` (shipped) defines MIGRATION_AUTHOR='system:migration-v0-to-v1'.
|
|
30
|
+
* Correct spec value is 'system:migration-v0-v1'. This file overrides via
|
|
31
|
+
* local constants without editing the shipped pure-mapper module.
|
|
13
32
|
*/
|
|
14
33
|
|
|
15
|
-
import {
|
|
16
|
-
|
|
34
|
+
import {
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
readdirSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
renameSync,
|
|
41
|
+
rmSync,
|
|
42
|
+
statSync,
|
|
43
|
+
} from 'fs';
|
|
44
|
+
import { join, dirname } from 'path';
|
|
45
|
+
import { createHash } from 'crypto';
|
|
46
|
+
import { execFileSync } from 'child_process';
|
|
47
|
+
|
|
48
|
+
import { openLog, writeAtomic } from '../storage/index.js';
|
|
17
49
|
import { parseEntry } from './store.js';
|
|
18
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
openMemoryShardStore,
|
|
52
|
+
classifyLegacyEntryToShard,
|
|
53
|
+
} from './shard-store.js';
|
|
54
|
+
import { PROJECT_DERIVE_THRESHOLD } from './schema.js';
|
|
55
|
+
import {
|
|
56
|
+
parseFrontmatter,
|
|
57
|
+
mapMessageMdToJsonl,
|
|
58
|
+
splitCoordinatorTurns,
|
|
59
|
+
LEGACY_GROUP_ID,
|
|
60
|
+
LEGACY_VP_ID,
|
|
61
|
+
} from '../migration/map-fields.js';
|
|
62
|
+
|
|
63
|
+
// ─── Name-drift fix (spec §3, §4) ────────────────────────────────
|
|
64
|
+
export const R5_TO_R6_AUTHOR_SYS = 'system:migration-v0-v1';
|
|
65
|
+
export const R5_TO_R6_AUTHOR_USER = 'user:migration-v0-v1';
|
|
66
|
+
const SOURCE_HINT = 'legacy-r5-migration';
|
|
67
|
+
const STATE_FILE = 'migration-state.json';
|
|
68
|
+
const ARCHIVE_REL = join('.legacy', 'r6-state.tar.gz');
|
|
69
|
+
|
|
70
|
+
// authoredBy inference table (spec §3 deliverable E).
|
|
71
|
+
function inferAuthoredBy(kind) {
|
|
72
|
+
switch (kind) {
|
|
73
|
+
case 'preference':
|
|
74
|
+
case 'identity':
|
|
75
|
+
return R5_TO_R6_AUTHOR_USER;
|
|
76
|
+
case 'fact':
|
|
77
|
+
case 'skill':
|
|
78
|
+
case 'lesson':
|
|
79
|
+
case 'context':
|
|
80
|
+
case 'relation':
|
|
81
|
+
default:
|
|
82
|
+
return R5_TO_R6_AUTHOR_SYS;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
19
85
|
|
|
86
|
+
// ─── Planner (dry-run, unchanged behaviour from 334f stub) ───────
|
|
20
87
|
/**
|
|
21
88
|
* Produce a migration plan without applying it.
|
|
22
89
|
*
|
|
@@ -50,12 +117,495 @@ export function planR5ToR6Migration(legacyEntriesDir) {
|
|
|
50
117
|
return { totalEntries: plan.length, plan, byShard };
|
|
51
118
|
}
|
|
52
119
|
|
|
120
|
+
// ─── State I/O ────────────────────────────────────────────────────
|
|
121
|
+
function stateFilePath(yeaftDir) {
|
|
122
|
+
return join(yeaftDir, STATE_FILE);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function loadState(yeaftDir) {
|
|
126
|
+
const p = stateFilePath(yeaftDir);
|
|
127
|
+
if (!existsSync(p)) return null;
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
130
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function saveState(yeaftDir, state) {
|
|
137
|
+
writeAtomic(stateFilePath(yeaftDir), JSON.stringify(state, null, 2));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function clearState(yeaftDir) {
|
|
141
|
+
const p = stateFilePath(yeaftDir);
|
|
142
|
+
if (existsSync(p)) rmSync(p);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function nowIso() { return new Date().toISOString(); }
|
|
146
|
+
|
|
147
|
+
function stableId(slug) {
|
|
148
|
+
const h = createHash('sha1').update(String(slug)).digest('hex').slice(0, 12);
|
|
149
|
+
return `mem_legacy_${h}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Archive helper (spec §11 step 3) ────────────────────────────
|
|
153
|
+
/**
|
|
154
|
+
* tar+gzip `memory/entries/` and `conversations/` into .legacy/r6-state.tar.gz.
|
|
155
|
+
* Uses the `tar` CLI via execFileSync (same pattern as 334i-v0). Any failure
|
|
156
|
+
* throws so the caller can bail before making destructive changes.
|
|
157
|
+
*/
|
|
158
|
+
export function archiveR5State(yeaftDir) {
|
|
159
|
+
const legacyDir = join(yeaftDir, '.legacy');
|
|
160
|
+
mkdirSync(legacyDir, { recursive: true });
|
|
161
|
+
const archivePath = join(yeaftDir, ARCHIVE_REL);
|
|
162
|
+
const entriesDir = join(yeaftDir, 'memory', 'entries');
|
|
163
|
+
const conversationsDir = join(yeaftDir, 'conversations');
|
|
164
|
+
const args = ['-czf', archivePath, '-C', yeaftDir];
|
|
165
|
+
let added = 0;
|
|
166
|
+
if (existsSync(entriesDir)) { args.push(join('memory', 'entries')); added++; }
|
|
167
|
+
if (existsSync(conversationsDir)) { args.push('conversations'); added++; }
|
|
168
|
+
if (added === 0) {
|
|
169
|
+
// Write a zero-content marker so rollback has a file to inspect.
|
|
170
|
+
writeAtomic(archivePath, '');
|
|
171
|
+
return archivePath;
|
|
172
|
+
}
|
|
173
|
+
execFileSync('tar', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
174
|
+
return archivePath;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Detect helpers ──────────────────────────────────────────────
|
|
178
|
+
/**
|
|
179
|
+
* Classify the shape of the R5 memory layout in `yeaftDir`.
|
|
180
|
+
*/
|
|
181
|
+
export function detectR5MemoryLayout(yeaftDir) {
|
|
182
|
+
const entriesDir = join(yeaftDir, 'memory', 'entries');
|
|
183
|
+
const conversationsDir = join(yeaftDir, 'conversations');
|
|
184
|
+
const groupsDir = join(yeaftDir, 'groups');
|
|
185
|
+
const hasEntries = existsSync(entriesDir) && readdirSync(entriesDir).some(f => f.endsWith('.md'));
|
|
186
|
+
const hasConversationsMd = existsSync(conversationsDir)
|
|
187
|
+
&& readdirSync(conversationsDir).some(() => true);
|
|
188
|
+
const hasGroupsJsonl = existsSync(groupsDir);
|
|
189
|
+
return {
|
|
190
|
+
entriesDir,
|
|
191
|
+
conversationsDir,
|
|
192
|
+
groupsDir,
|
|
193
|
+
hasEntries,
|
|
194
|
+
hasConversationsMd,
|
|
195
|
+
hasGroupsJsonl,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Pass 1: write entries to default shards ─────────────────────
|
|
200
|
+
function runPass1({ yeaftDir, layout, vpDir, log, existingState }) {
|
|
201
|
+
const shardStore = openMemoryShardStore(vpDir, 'vp');
|
|
202
|
+
const files = layout.hasEntries
|
|
203
|
+
? readdirSync(layout.entriesDir).filter(f => f.endsWith('.md')).sort()
|
|
204
|
+
: [];
|
|
205
|
+
const counts = (existingState && existingState.counts) || {};
|
|
206
|
+
const migrated = [];
|
|
207
|
+
const errors = [];
|
|
208
|
+
|
|
209
|
+
for (const file of files) {
|
|
210
|
+
const slug = file.replace(/\.md$/, '');
|
|
211
|
+
const id = stableId(slug);
|
|
212
|
+
// Idempotency: skip already-migrated ids.
|
|
213
|
+
if (shardStore.get(id)) {
|
|
214
|
+
migrated.push({ id, slug, shard: shardStore.get(id).shard, skipped: true });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
let raw;
|
|
218
|
+
try {
|
|
219
|
+
raw = readFileSync(join(layout.entriesDir, file), 'utf8');
|
|
220
|
+
} catch (e) {
|
|
221
|
+
errors.push({ file, error: String(e.message || e) });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const legacy = parseEntry(raw);
|
|
225
|
+
if (!legacy) {
|
|
226
|
+
errors.push({ file, error: 'parseEntry returned null (malformed frontmatter)' });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const shard = classifyLegacyEntryToShard(legacy);
|
|
230
|
+
const kind = legacy.kind || 'fact';
|
|
231
|
+
const tags = Array.isArray(legacy.tags) ? legacy.tags.slice() : [];
|
|
232
|
+
const createdAt = legacy.created_at || nowIso();
|
|
233
|
+
const updatedAt = legacy.updated_at || createdAt;
|
|
234
|
+
// Determine groupId for project-derive counting. Legacy schema has no
|
|
235
|
+
// explicit groupId; fall back to scope's top segment, else LEGACY_GROUP_ID.
|
|
236
|
+
const groupId = deriveGroupId(legacy);
|
|
237
|
+
|
|
238
|
+
// identity/preference kinds are allowed empty msgIds per §Δ23.
|
|
239
|
+
// Other kinds rely on the hint='legacy-r5-migration' to legitimise [].
|
|
240
|
+
// `validateR6Entry` requires non-empty msgIds for non-identity/preference —
|
|
241
|
+
// so we put a synthetic legacy marker to keep the validator happy while
|
|
242
|
+
// still conveying "migrated, no real messages attached" semantically.
|
|
243
|
+
const needsMsgIdMarker = !(kind === 'identity' || kind === 'preference');
|
|
244
|
+
const msgIds = needsMsgIdMarker ? [`legacy:${slug}`] : [];
|
|
245
|
+
|
|
246
|
+
const entry = {
|
|
247
|
+
id,
|
|
248
|
+
shard,
|
|
249
|
+
kind,
|
|
250
|
+
tags,
|
|
251
|
+
pinned: legacy.importance === 'high',
|
|
252
|
+
sourceRef: {
|
|
253
|
+
groupId,
|
|
254
|
+
taskId: null,
|
|
255
|
+
msgIds,
|
|
256
|
+
timeWindow: `[${createdAt}, ${updatedAt}]`,
|
|
257
|
+
hint: SOURCE_HINT,
|
|
258
|
+
},
|
|
259
|
+
supersedes: null,
|
|
260
|
+
supersededBy: null,
|
|
261
|
+
authoredBy: inferAuthoredBy(kind),
|
|
262
|
+
createdAt,
|
|
263
|
+
updatedAt,
|
|
264
|
+
body: legacy.content || '',
|
|
265
|
+
};
|
|
266
|
+
try {
|
|
267
|
+
shardStore.put(entry);
|
|
268
|
+
counts[groupId] = (counts[groupId] || 0) + 1;
|
|
269
|
+
migrated.push({ id, slug, shard, groupId });
|
|
270
|
+
} catch (e) {
|
|
271
|
+
errors.push({ file, error: String(e.message || e) });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
log('pass1', { migrated: migrated.length, errors: errors.length });
|
|
276
|
+
return { counts, migrated, errors };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function deriveGroupId(legacyEntry) {
|
|
280
|
+
// scope is a path like "work/project-name/auth". Use first segment as
|
|
281
|
+
// coarse groupId; fall back to LEGACY_GROUP_ID.
|
|
282
|
+
const scope = legacyEntry && legacyEntry.scope;
|
|
283
|
+
if (typeof scope === 'string' && scope.trim()) {
|
|
284
|
+
const first = scope.split('/').map(s => s.trim()).filter(Boolean)[0];
|
|
285
|
+
if (first) return first;
|
|
286
|
+
}
|
|
287
|
+
return LEGACY_GROUP_ID;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Pass 2: project-<slug> derive ───────────────────────────────
|
|
291
|
+
function runPass2({ vpDir, counts, log }) {
|
|
292
|
+
const derived = [];
|
|
293
|
+
const qualifyingShards = Object.entries(counts || {})
|
|
294
|
+
.filter(([, c]) => c >= PROJECT_DERIVE_THRESHOLD)
|
|
295
|
+
.map(([g]) => `project-${slugify(g)}`);
|
|
296
|
+
// Re-open with project-<slug> allow-listed up front so put() validates.
|
|
297
|
+
const shardStore = openMemoryShardStore(vpDir, 'vp', { extraShards: qualifyingShards });
|
|
298
|
+
for (const [groupId, count] of Object.entries(counts || {})) {
|
|
299
|
+
if (count < PROJECT_DERIVE_THRESHOLD) continue;
|
|
300
|
+
const slug = slugify(groupId);
|
|
301
|
+
const targetShard = `project-${slug}`;
|
|
302
|
+
// Skip if this project shard already exists and is populated — re-entry safe.
|
|
303
|
+
const stats = shardStore.stats();
|
|
304
|
+
if (stats.shards[targetShard] && stats.shards[targetShard].count > 0) {
|
|
305
|
+
derived.push({ groupId, shard: targetShard, moved: 0, skipped: true });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// Collect entries in the relevant default shard matching this groupId.
|
|
309
|
+
// Search across all default shards (classification is kind-driven; a
|
|
310
|
+
// groupId may span skill/lessons/etc).
|
|
311
|
+
const { results } = shardStore.query({});
|
|
312
|
+
const moveIds = results
|
|
313
|
+
.filter(r => r.groupId === groupId)
|
|
314
|
+
.map(r => r.id);
|
|
315
|
+
let moved = 0;
|
|
316
|
+
for (const id of moveIds) {
|
|
317
|
+
const full = shardStore.get(id);
|
|
318
|
+
if (!full) continue;
|
|
319
|
+
// Re-put with new shard; old entry removal happens via supersede-free
|
|
320
|
+
// rewrite by removing old id after the new one lands.
|
|
321
|
+
const newEntry = {
|
|
322
|
+
...full,
|
|
323
|
+
shard: targetShard,
|
|
324
|
+
body: full.body || '',
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
shardStore.put(newEntry);
|
|
328
|
+
// shardStore.put upserts by id (see 334o shard-store put semantics
|
|
329
|
+
// removing any prior shard copy of the same id) — so the entry now
|
|
330
|
+
// lives in targetShard exclusively.
|
|
331
|
+
moved++;
|
|
332
|
+
} catch {
|
|
333
|
+
// best-effort — keep legacy in default shard if move fails.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
derived.push({ groupId, shard: targetShard, moved });
|
|
337
|
+
}
|
|
338
|
+
log('pass2', { derived: derived.length });
|
|
339
|
+
return derived;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function slugify(s) {
|
|
343
|
+
return String(s || '')
|
|
344
|
+
.toLowerCase()
|
|
345
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
346
|
+
.replace(/^-+|-+$/g, '')
|
|
347
|
+
.slice(0, 40) || 'legacy';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── Conversation migration ──────────────────────────────────────
|
|
351
|
+
function migrateConversations({ yeaftDir, layout, log }) {
|
|
352
|
+
if (!layout.hasConversationsMd) {
|
|
353
|
+
log('conversations', { messages: 0, shards: 0 });
|
|
354
|
+
return { messages: 0, shards: 0 };
|
|
355
|
+
}
|
|
356
|
+
const groupDir = join(yeaftDir, 'groups', LEGACY_GROUP_ID, 'messages');
|
|
357
|
+
mkdirSync(groupDir, { recursive: true });
|
|
358
|
+
const log_ = openLog(groupDir);
|
|
359
|
+
let messagesWritten = 0;
|
|
360
|
+
const convos = readdirSync(layout.conversationsDir);
|
|
361
|
+
for (const cId of convos) {
|
|
362
|
+
const msgDir = join(layout.conversationsDir, cId, 'messages');
|
|
363
|
+
if (!existsSync(msgDir)) continue;
|
|
364
|
+
const files = readdirSync(msgDir).filter(f => f.endsWith('.md')).sort();
|
|
365
|
+
for (const file of files) {
|
|
366
|
+
const raw = readFileSync(join(msgDir, file), 'utf8');
|
|
367
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
368
|
+
const originalId = `${cId}_${file.replace(/\.md$/, '')}`;
|
|
369
|
+
const row = mapMessageMdToJsonl({ meta, body, originalId, fallbackTaskId: null });
|
|
370
|
+
try {
|
|
371
|
+
log_.append(row);
|
|
372
|
+
messagesWritten++;
|
|
373
|
+
} catch {
|
|
374
|
+
// Skip malformed row; keep going.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Also handle coordinator.md if present
|
|
378
|
+
const coordPath = join(layout.conversationsDir, cId, 'coordinator.md');
|
|
379
|
+
if (existsSync(coordPath)) {
|
|
380
|
+
const raw = readFileSync(coordPath, 'utf8');
|
|
381
|
+
const turns = splitCoordinatorTurns(raw);
|
|
382
|
+
for (const turn of turns) {
|
|
383
|
+
const row = {
|
|
384
|
+
id: `msg_legacy_${cId}_coord_${turn.index}`,
|
|
385
|
+
ts: turn.ts || null,
|
|
386
|
+
type: 'chat',
|
|
387
|
+
authorKind: 'unknown',
|
|
388
|
+
authorId: `legacy:${turn.role}`,
|
|
389
|
+
groupId: LEGACY_GROUP_ID,
|
|
390
|
+
taskId: null,
|
|
391
|
+
body: turn.body,
|
|
392
|
+
mentions: [],
|
|
393
|
+
replyTo: null,
|
|
394
|
+
viaTool: null,
|
|
395
|
+
};
|
|
396
|
+
try { log_.append(row); messagesWritten++; } catch { /* skip */ }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
log_.close();
|
|
401
|
+
const index = log_.getIndex();
|
|
402
|
+
log('conversations', { messages: messagesWritten, shards: (index.segments || []).length });
|
|
403
|
+
return { messages: messagesWritten, shards: (index.segments || []).length };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── Main entry ──────────────────────────────────────────────────
|
|
407
|
+
/**
|
|
408
|
+
* Apply the R5 → R6 migration.
|
|
409
|
+
*
|
|
410
|
+
* @param {object} opts
|
|
411
|
+
* @param {string} opts.yeaftDir required
|
|
412
|
+
* @param {string} [opts.vpId] legacy VP id (default LEGACY_VP_ID)
|
|
413
|
+
* @param {boolean} [opts.dryRun]
|
|
414
|
+
* @param {boolean} [opts.force] clear existing r6 state and re-run from scratch
|
|
415
|
+
* @param {(step, info)=>void} [opts.onStep]
|
|
416
|
+
* @returns {Promise<object>}
|
|
417
|
+
*/
|
|
418
|
+
export async function applyR5ToR6Migration(opts = {}) {
|
|
419
|
+
const { yeaftDir, dryRun = false, force = false, onStep } = opts;
|
|
420
|
+
const vpId = opts.vpId || LEGACY_VP_ID;
|
|
421
|
+
if (!yeaftDir || typeof yeaftDir !== 'string') {
|
|
422
|
+
throw new Error('applyR5ToR6Migration: yeaftDir (string) required');
|
|
423
|
+
}
|
|
424
|
+
if (!existsSync(yeaftDir)) {
|
|
425
|
+
throw new Error(`applyR5ToR6Migration: yeaftDir does not exist: ${yeaftDir}`);
|
|
426
|
+
}
|
|
427
|
+
const log = typeof onStep === 'function' ? onStep : () => {};
|
|
428
|
+
const layout = detectR5MemoryLayout(yeaftDir);
|
|
429
|
+
|
|
430
|
+
if (dryRun) {
|
|
431
|
+
const plan = planR5ToR6Migration(layout.entriesDir);
|
|
432
|
+
const counts = {};
|
|
433
|
+
for (const p of plan.plan) {
|
|
434
|
+
// coarse counting keyed by synthetic groupId based on slug prefix (best-effort preview)
|
|
435
|
+
counts[LEGACY_GROUP_ID] = (counts[LEGACY_GROUP_ID] || 0) + 1;
|
|
436
|
+
}
|
|
437
|
+
const wouldDerive = Object.entries(counts)
|
|
438
|
+
.filter(([, c]) => c >= PROJECT_DERIVE_THRESHOLD)
|
|
439
|
+
.map(([g]) => `project-${slugify(g)}`);
|
|
440
|
+
let estMessages = 0;
|
|
441
|
+
if (layout.hasConversationsMd) {
|
|
442
|
+
for (const cId of readdirSync(layout.conversationsDir)) {
|
|
443
|
+
const msgDir = join(layout.conversationsDir, cId, 'messages');
|
|
444
|
+
if (existsSync(msgDir)) {
|
|
445
|
+
estMessages += readdirSync(msgDir).filter(f => f.endsWith('.md')).length;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const preview = {
|
|
450
|
+
pass1: plan.byShard,
|
|
451
|
+
pass2Candidates: wouldDerive,
|
|
452
|
+
conversations: {
|
|
453
|
+
count: layout.hasConversationsMd ? readdirSync(layout.conversationsDir).length : 0,
|
|
454
|
+
estimatedMessages: estMessages,
|
|
455
|
+
estimatedShards: Math.max(1, Math.ceil(estMessages / 5000)),
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
log('dry-run', preview);
|
|
459
|
+
return { status: 'dry-run', dryRun: true, preview };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Force: wipe only r6 state, never touch legacy archive.
|
|
463
|
+
if (force) clearState(yeaftDir);
|
|
464
|
+
|
|
465
|
+
let state = loadState(yeaftDir);
|
|
466
|
+
if (state && state.version === 'r6' && state.pass2CompletedAt) {
|
|
467
|
+
log('already-done', { migratedAt: state.migratedAt });
|
|
468
|
+
return { status: 'already-done', state };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Fresh state — but preserve a pre-existing r5 state (from PR #552) if present.
|
|
472
|
+
if (!state || state.version !== 'r6') {
|
|
473
|
+
const prior = state || {};
|
|
474
|
+
state = {
|
|
475
|
+
version: 'r6',
|
|
476
|
+
startedAt: nowIso(),
|
|
477
|
+
legacyArchive: null,
|
|
478
|
+
pass1CompletedAt: null,
|
|
479
|
+
pass2CompletedAt: null,
|
|
480
|
+
migratedAt: null,
|
|
481
|
+
counts: {},
|
|
482
|
+
derivedProjects: [],
|
|
483
|
+
messageCount: 0,
|
|
484
|
+
entryCount: 0,
|
|
485
|
+
// Preserve reference to prior r5 state for audit.
|
|
486
|
+
priorR5: prior && prior.version === 'r5' ? { completedAt: prior.completedAt || null } : null,
|
|
487
|
+
};
|
|
488
|
+
saveState(yeaftDir, state);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
// Archive R5 state BEFORE any writes (or skip if already archived in a prior resume).
|
|
493
|
+
if (!state.legacyArchive) {
|
|
494
|
+
const archivePath = archiveR5State(yeaftDir);
|
|
495
|
+
state.legacyArchive = archivePath;
|
|
496
|
+
saveState(yeaftDir, state);
|
|
497
|
+
log('archive', { path: archivePath });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const vpDir = join(yeaftDir, 'memory', 'vp', vpId);
|
|
501
|
+
mkdirSync(vpDir, { recursive: true });
|
|
502
|
+
|
|
503
|
+
// Pass 1
|
|
504
|
+
if (!state.pass1CompletedAt) {
|
|
505
|
+
const p1 = runPass1({ yeaftDir, layout, vpDir, log, existingState: state });
|
|
506
|
+
state.counts = p1.counts;
|
|
507
|
+
state.entryCount = (state.entryCount || 0) + p1.migrated.filter(m => !m.skipped).length;
|
|
508
|
+
state.pass1CompletedAt = nowIso();
|
|
509
|
+
state.pass1Errors = p1.errors;
|
|
510
|
+
saveState(yeaftDir, state);
|
|
511
|
+
} else {
|
|
512
|
+
log('pass1', { skipped: true });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Pass 2
|
|
516
|
+
if (!state.pass2CompletedAt) {
|
|
517
|
+
const derived = runPass2({ vpDir, counts: state.counts, log });
|
|
518
|
+
state.derivedProjects = derived.map(d => d.shard);
|
|
519
|
+
state.pass2CompletedAt = nowIso();
|
|
520
|
+
saveState(yeaftDir, state);
|
|
521
|
+
} else {
|
|
522
|
+
log('pass2', { skipped: true });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Conversations (always run once — guarded by index existence).
|
|
526
|
+
if (!state.conversationsMigratedAt) {
|
|
527
|
+
const convRes = migrateConversations({ yeaftDir, layout, log });
|
|
528
|
+
state.messageCount = convRes.messages;
|
|
529
|
+
state.conversationsMigratedAt = nowIso();
|
|
530
|
+
saveState(yeaftDir, state);
|
|
531
|
+
} else {
|
|
532
|
+
log('conversations', { skipped: true });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
state.migratedAt = nowIso();
|
|
536
|
+
saveState(yeaftDir, state);
|
|
537
|
+
log('done', { migratedAt: state.migratedAt });
|
|
538
|
+
return { status: 'done', state };
|
|
539
|
+
} catch (err) {
|
|
540
|
+
// On error: state file preserved so next run resumes. Archive untouched.
|
|
541
|
+
state.lastError = String(err && err.message || err);
|
|
542
|
+
saveState(yeaftDir, state);
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ─── Rollback (deliverable G) ────────────────────────────────────
|
|
53
548
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
549
|
+
* Roll back an R5→R6 migration. Restores from .legacy/r6-state.tar.gz and
|
|
550
|
+
* clears r6-specific state. Never touches the separate r5 archive created
|
|
551
|
+
* by v0-to-v1.js. Idempotent: safe to call when no r6 state is present.
|
|
56
552
|
*
|
|
57
|
-
* @param {object}
|
|
553
|
+
* @param {object} opts
|
|
554
|
+
* @param {string} opts.yeaftDir required
|
|
555
|
+
* @param {string} [opts.vpId] legacy VP id (default LEGACY_VP_ID)
|
|
556
|
+
* @param {(step, info)=>void} [opts.onStep]
|
|
58
557
|
*/
|
|
59
|
-
export async function
|
|
60
|
-
|
|
558
|
+
export async function rollbackR5ToR6Migration(opts = {}) {
|
|
559
|
+
const { yeaftDir, onStep } = opts;
|
|
560
|
+
const vpId = opts.vpId || LEGACY_VP_ID;
|
|
561
|
+
if (!yeaftDir || typeof yeaftDir !== 'string') {
|
|
562
|
+
throw new Error('rollbackR5ToR6Migration: yeaftDir required');
|
|
563
|
+
}
|
|
564
|
+
const log = typeof onStep === 'function' ? onStep : () => {};
|
|
565
|
+
const state = loadState(yeaftDir);
|
|
566
|
+
if (!state || state.version !== 'r6') {
|
|
567
|
+
log('noop', { reason: 'no r6 state file present' });
|
|
568
|
+
return { status: 'noop' };
|
|
569
|
+
}
|
|
570
|
+
const archivePath = state.legacyArchive;
|
|
571
|
+
if (!archivePath || !existsSync(archivePath)) {
|
|
572
|
+
throw new Error(`rollbackR5ToR6Migration: archive missing at ${archivePath}`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Delete R6-specific paths first (only what this migration created).
|
|
576
|
+
const vpDir = join(yeaftDir, 'memory', 'vp', vpId);
|
|
577
|
+
if (existsSync(vpDir)) {
|
|
578
|
+
rmSync(vpDir, { recursive: true, force: true });
|
|
579
|
+
log('rm-vp-memory', { path: vpDir });
|
|
580
|
+
}
|
|
581
|
+
const groupsDir = join(yeaftDir, 'groups', LEGACY_GROUP_ID, 'messages');
|
|
582
|
+
if (existsSync(groupsDir)) {
|
|
583
|
+
rmSync(groupsDir, { recursive: true, force: true });
|
|
584
|
+
log('rm-group-messages', { path: groupsDir });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Restore archive back to yeaftDir. tar -xzf will overwrite paths it owns.
|
|
588
|
+
// Only do this if archive is non-empty (empty marker = nothing was archived).
|
|
589
|
+
const sz = statSync(archivePath).size;
|
|
590
|
+
if (sz > 0) {
|
|
591
|
+
execFileSync('tar', ['-xzf', archivePath, '-C', yeaftDir], {
|
|
592
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
593
|
+
});
|
|
594
|
+
log('restore', { from: archivePath });
|
|
595
|
+
} else {
|
|
596
|
+
log('restore', { from: archivePath, note: 'empty archive — nothing to restore' });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Downgrade state to r5 marker (archive left on disk for audit).
|
|
600
|
+
const newState = {
|
|
601
|
+
version: 'r5',
|
|
602
|
+
rolledBackAt: nowIso(),
|
|
603
|
+
previousR6: {
|
|
604
|
+
migratedAt: state.migratedAt,
|
|
605
|
+
legacyArchive: state.legacyArchive,
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
saveState(yeaftDir, newState);
|
|
609
|
+
log('done', { rolledBackAt: newState.rolledBackAt });
|
|
610
|
+
return { status: 'done', state: newState };
|
|
61
611
|
}
|
package/unify/prompts.js
CHANGED
|
@@ -386,8 +386,17 @@ const DEFAULT_TASK_MEMORY_TOP = 5;
|
|
|
386
386
|
const DEFAULT_RELATED_TASK_TOP = 3;
|
|
387
387
|
const DEFAULT_RELATED_TASK_MEMORY_TOP = 2;
|
|
388
388
|
const DEFAULT_CORE_MEMORY_TOP = 7;
|
|
389
|
+
// task-334n §Δ31.4 — tightened reminder gate:
|
|
390
|
+
// (a) currentVpId === initiatorVpId
|
|
391
|
+
// (b) task.members.length >= 2 (multi-VP only)
|
|
392
|
+
// (c) nonSummaryCount >= 10 OR (now - lastSummaryAt) >= 20 min
|
|
393
|
+
// 334e's earlier looser gate (3 msgs / 15 min) is preserved as a legacy
|
|
394
|
+
// fallback path for callers that never set `summaryReminder.members`.
|
|
389
395
|
const SUMMARY_REMINDER_MIN_MESSAGES = 3;
|
|
390
|
-
const SUMMARY_REMINDER_MIN_AGE_MS = 15 * 60 * 1000; // 15 minutes
|
|
396
|
+
const SUMMARY_REMINDER_MIN_AGE_MS = 15 * 60 * 1000; // 15 minutes (legacy)
|
|
397
|
+
const SUMMARY_REMINDER_MIN_TURNS_334N = 10;
|
|
398
|
+
const SUMMARY_REMINDER_MIN_AGE_MS_334N = 20 * 60 * 1000; // 20 minutes
|
|
399
|
+
const SUMMARY_REMINDER_MIN_MEMBERS_334N = 2;
|
|
391
400
|
|
|
392
401
|
/**
|
|
393
402
|
* Render `## task_ctx` block. Never throws on malformed input — missing
|
|
@@ -402,6 +411,7 @@ function renderTaskCtx(taskCtx, lang) {
|
|
|
402
411
|
taskCtx.relatedTasks,
|
|
403
412
|
taskCtx.currentVpId,
|
|
404
413
|
lang,
|
|
414
|
+
taskCtx.groupId,
|
|
405
415
|
);
|
|
406
416
|
const reminderLine = renderSummaryReminder(taskCtx, lang);
|
|
407
417
|
|
|
@@ -436,13 +446,17 @@ function renderTaskMemories(memories) {
|
|
|
436
446
|
* Ordering: by `updatedAt` desc (undefined treated as 0). Top-3 tasks, top-2
|
|
437
447
|
* memory each.
|
|
438
448
|
*/
|
|
439
|
-
function renderRelatedTasks(relatedTasks, currentVpId, lang) {
|
|
449
|
+
function renderRelatedTasks(relatedTasks, currentVpId, lang, currentTaskGroupId) {
|
|
440
450
|
if (!Array.isArray(relatedTasks) || relatedTasks.length === 0) return '';
|
|
441
451
|
if (!currentVpId) return ''; // no ACL subject → fail-closed
|
|
442
452
|
|
|
443
453
|
const allowed = relatedTasks.filter((t) => {
|
|
444
454
|
if (!t || typeof t !== 'object') return false;
|
|
445
455
|
const members = Array.isArray(t.members) ? t.members : null;
|
|
456
|
+
// task-334n §Δ27.3 — either same-group OR members-intersection grants.
|
|
457
|
+
if (currentTaskGroupId && t.groupId && t.groupId === currentTaskGroupId) {
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
446
460
|
if (!members) return false; // fail-closed on missing ACL
|
|
447
461
|
return members.includes(currentVpId);
|
|
448
462
|
});
|
|
@@ -487,16 +501,26 @@ function renderSummaryReminder(taskCtx, lang) {
|
|
|
487
501
|
if (taskCtx.currentVpId !== taskCtx.initiatorVpId) return '';
|
|
488
502
|
|
|
489
503
|
const count = Number(r.nonSummaryCount) || 0;
|
|
490
|
-
if (count < SUMMARY_REMINDER_MIN_MESSAGES) return '';
|
|
491
|
-
|
|
492
504
|
const now = Number(r.now) || Date.now();
|
|
493
505
|
const lastAt = Number(r.lastSummaryAt) || 0;
|
|
494
506
|
const ageMs = lastAt > 0 ? now - lastAt : Number.POSITIVE_INFINITY;
|
|
495
|
-
if (lastAt > 0 && ageMs <= SUMMARY_REMINDER_MIN_AGE_MS) return '';
|
|
496
507
|
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
508
|
+
// task-334n §Δ31.4 gate: when `members` is supplied, apply the strict
|
|
509
|
+
// multi-VP / 20min-or-10turn rule. Otherwise keep the legacy 334e gate
|
|
510
|
+
// so pre-334n callers still see reminders under the old thresholds.
|
|
511
|
+
const members = Array.isArray(r.members) ? r.members : null;
|
|
512
|
+
if (members) {
|
|
513
|
+
if (members.length < SUMMARY_REMINDER_MIN_MEMBERS_334N) return '';
|
|
514
|
+
const ageOk = lastAt > 0 && ageMs >= SUMMARY_REMINDER_MIN_AGE_MS_334N;
|
|
515
|
+
const turnsOk = count >= SUMMARY_REMINDER_MIN_TURNS_334N;
|
|
516
|
+
// `never summarised` (lastAt=0) only counts when turnsOk, otherwise we
|
|
517
|
+
// silently wait — aligns with §Δ31.4 "too-soon" reason code.
|
|
518
|
+
if (!ageOk && !turnsOk) return '';
|
|
519
|
+
} else {
|
|
520
|
+
if (count < SUMMARY_REMINDER_MIN_MESSAGES) return '';
|
|
521
|
+
if (lastAt > 0 && ageMs <= SUMMARY_REMINDER_MIN_AGE_MS) return '';
|
|
522
|
+
}
|
|
523
|
+
|
|
500
524
|
const minStr = lastAt > 0 ? String(Math.round(ageMs / 60000)) : '—';
|
|
501
525
|
return lang.taskCtxSummaryReminder(minStr, count);
|
|
502
526
|
}
|
package/unify/tasks/store.js
CHANGED
|
@@ -37,6 +37,15 @@ function serializeTask(task) {
|
|
|
37
37
|
if (task.parentTaskId) fm.push(`parentTaskId: ${task.parentTaskId}`);
|
|
38
38
|
if (task.parentId) fm.push(`parentId: ${task.parentId}`);
|
|
39
39
|
if (task.primaryThreadId) fm.push(`primaryThreadId: ${task.primaryThreadId}`);
|
|
40
|
+
// task-334n — multi-VP collaboration protocol fields.
|
|
41
|
+
// initiator: VP id that created the task (fallback target for ACL / reminder).
|
|
42
|
+
// members: explicit VP roster for the task (supersedes group roster when set).
|
|
43
|
+
// groupId: the group this task belongs to (null for legacy / standalone).
|
|
44
|
+
if (task.initiator) fm.push(`initiator: ${task.initiator}`);
|
|
45
|
+
if (Array.isArray(task.members) && task.members.length) {
|
|
46
|
+
fm.push(`members: [${task.members.join(', ')}]`);
|
|
47
|
+
}
|
|
48
|
+
if (task.groupId) fm.push(`groupId: ${task.groupId}`);
|
|
40
49
|
if (task.createdAt) fm.push(`createdAt: ${task.createdAt}`);
|
|
41
50
|
if (task.updatedAt) fm.push(`updatedAt: ${task.updatedAt}`);
|
|
42
51
|
|
|
@@ -81,6 +90,13 @@ function parseTask(raw) {
|
|
|
81
90
|
|
|
82
91
|
if (key === 'createdAt' || key === 'updatedAt') {
|
|
83
92
|
task[key] = parseInt(val, 10) || 0;
|
|
93
|
+
} else if (key === 'members') {
|
|
94
|
+
// task-334n — members: [vp-a, vp-b, ...]
|
|
95
|
+
task.members = val
|
|
96
|
+
.replace(/^\[|\]$/g, '')
|
|
97
|
+
.split(',')
|
|
98
|
+
.map((s) => s.trim())
|
|
99
|
+
.filter(Boolean);
|
|
84
100
|
} else {
|
|
85
101
|
task[key] = val;
|
|
86
102
|
}
|
|
@@ -181,6 +197,8 @@ export class TaskStore {
|
|
|
181
197
|
#tasks;
|
|
182
198
|
/** @type {boolean} */
|
|
183
199
|
#readOnly;
|
|
200
|
+
/** @type {Array<(evt:any)=>void>} */
|
|
201
|
+
#listeners;
|
|
184
202
|
|
|
185
203
|
/**
|
|
186
204
|
* @param {string} yeaftDir — Base ~/.yeaft directory
|
|
@@ -192,6 +210,7 @@ export class TaskStore {
|
|
|
192
210
|
this.#planPath = join(this.#dir, 'plan.md');
|
|
193
211
|
this.#tasks = new Map();
|
|
194
212
|
this.#readOnly = opts.readOnly || false;
|
|
213
|
+
this.#listeners = [];
|
|
195
214
|
|
|
196
215
|
// Ensure base directory exists
|
|
197
216
|
if (!this.#readOnly) {
|
|
@@ -275,6 +294,122 @@ export class TaskStore {
|
|
|
275
294
|
return task;
|
|
276
295
|
}
|
|
277
296
|
|
|
297
|
+
/**
|
|
298
|
+
* task-334n — add a VP member to a task's collaboration roster.
|
|
299
|
+
* Idempotent: adding an existing member is a no-op (no event emitted).
|
|
300
|
+
* Returns `{ task, added: boolean }`.
|
|
301
|
+
*
|
|
302
|
+
* If an `onEvent` callback was passed at construction time, emits a
|
|
303
|
+
* `task_member_added` event synchronously after the write:
|
|
304
|
+
* { type: 'task_member_added', taskId, vpId, members, ts }
|
|
305
|
+
*/
|
|
306
|
+
addMember(id, vpId) {
|
|
307
|
+
const task = this.#tasks.get(id);
|
|
308
|
+
if (!task) return { task: null, added: false };
|
|
309
|
+
if (!vpId || typeof vpId !== 'string') {
|
|
310
|
+
throw new Error('addMember: vpId required (string)');
|
|
311
|
+
}
|
|
312
|
+
const members = Array.isArray(task.members) ? task.members.slice() : [];
|
|
313
|
+
if (members.includes(vpId)) {
|
|
314
|
+
return { task, added: false };
|
|
315
|
+
}
|
|
316
|
+
members.push(vpId);
|
|
317
|
+
this.update(id, { members });
|
|
318
|
+
this.#emit({
|
|
319
|
+
type: 'task_member_added',
|
|
320
|
+
taskId: id,
|
|
321
|
+
vpId,
|
|
322
|
+
members: members.slice(),
|
|
323
|
+
ts: Date.now(),
|
|
324
|
+
});
|
|
325
|
+
return { task: this.#tasks.get(id), added: true };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* task-334n — remove a VP member from a task.
|
|
330
|
+
* Idempotent: removing a non-member is a no-op (no event emitted).
|
|
331
|
+
* Returns `{ task, removed: boolean }`.
|
|
332
|
+
* Emits `task_member_removed` on successful removal.
|
|
333
|
+
*/
|
|
334
|
+
removeMember(id, vpId) {
|
|
335
|
+
const task = this.#tasks.get(id);
|
|
336
|
+
if (!task) return { task: null, removed: false };
|
|
337
|
+
if (!vpId || typeof vpId !== 'string') {
|
|
338
|
+
throw new Error('removeMember: vpId required (string)');
|
|
339
|
+
}
|
|
340
|
+
const members = Array.isArray(task.members) ? task.members.slice() : [];
|
|
341
|
+
const idx = members.indexOf(vpId);
|
|
342
|
+
if (idx === -1) return { task, removed: false };
|
|
343
|
+
members.splice(idx, 1);
|
|
344
|
+
this.update(id, { members });
|
|
345
|
+
this.#emit({
|
|
346
|
+
type: 'task_member_removed',
|
|
347
|
+
taskId: id,
|
|
348
|
+
vpId,
|
|
349
|
+
members: members.slice(),
|
|
350
|
+
ts: Date.now(),
|
|
351
|
+
});
|
|
352
|
+
return { task: this.#tasks.get(id), removed: true };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* task-334n §Δ27.3 ACL — true iff `vpId` may read `otherTaskId`'s
|
|
357
|
+
* memory/summary. Pass grants when:
|
|
358
|
+
* - both tasks share the same non-null groupId, OR
|
|
359
|
+
* - members sets intersect on at least one vpId
|
|
360
|
+
* Fail-closed: missing task, missing groupId match, no intersection → false.
|
|
361
|
+
*
|
|
362
|
+
* @param {string} currentTaskId — task the caller is running in
|
|
363
|
+
* @param {string} otherTaskId — task whose data the caller wants to read
|
|
364
|
+
* @param {string} [vpId] — caller's vp id; if set, must also be
|
|
365
|
+
* a member of currentTaskId (prevents stranger elevating via URL probe)
|
|
366
|
+
* @returns {boolean}
|
|
367
|
+
*/
|
|
368
|
+
canAccessRelated(currentTaskId, otherTaskId, vpId) {
|
|
369
|
+
if (!currentTaskId || !otherTaskId || currentTaskId === otherTaskId) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
const cur = this.#tasks.get(currentTaskId);
|
|
373
|
+
const other = this.#tasks.get(otherTaskId);
|
|
374
|
+
if (!cur || !other) return false;
|
|
375
|
+
|
|
376
|
+
// If caller claims a vpId, they must be a member of the current task or
|
|
377
|
+
// its initiator. Otherwise this is a cross-context read — fail-closed.
|
|
378
|
+
if (vpId) {
|
|
379
|
+
const curMembers = Array.isArray(cur.members) ? cur.members : [];
|
|
380
|
+
const isInsider = curMembers.includes(vpId) || cur.initiator === vpId;
|
|
381
|
+
if (!isInsider) return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Same-group rule.
|
|
385
|
+
if (cur.groupId && other.groupId && cur.groupId === other.groupId) {
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Members-intersection rule.
|
|
390
|
+
const a = Array.isArray(cur.members) ? cur.members : [];
|
|
391
|
+
const b = Array.isArray(other.members) ? other.members : [];
|
|
392
|
+
if (a.length === 0 || b.length === 0) return false;
|
|
393
|
+
const bSet = new Set(b);
|
|
394
|
+
for (const v of a) if (bSet.has(v)) return true;
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Register an event listener (task-334n member events). */
|
|
399
|
+
onEvent(fn) {
|
|
400
|
+
if (typeof fn === 'function') this.#listeners.push(fn);
|
|
401
|
+
return () => {
|
|
402
|
+
const i = this.#listeners.indexOf(fn);
|
|
403
|
+
if (i >= 0) this.#listeners.splice(i, 1);
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#emit(evt) {
|
|
408
|
+
for (const fn of this.#listeners) {
|
|
409
|
+
try { fn(evt); } catch { /* listener failures must not corrupt store */ }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
278
413
|
/**
|
|
279
414
|
* Get a task by ID.
|
|
280
415
|
* @param {string} id
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
|