@yeaft/webchat-agent 0.1.532 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.532",
3
+ "version": "0.1.533",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/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
- // For "never summarized" (lastAt==0) we report age as nonSummaryCount's
498
- // session-coarse proxy: we print a dash so the prompt does not lie about
499
- // an exact minute count. The hint still carries the count of new msgs.
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
  }
@@ -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
+