@yeaft/webchat-agent 0.1.587 → 0.1.588

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
39
- import { handleUnifyChat, handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove, handleUnifyMemoryScopeList, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
39
+ import { handleUnifyChat, handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove, handleUnifyMemoryScopeList, handleUnifyMemoryQuery, handleUnifyMemoryTrace, handleUnifyFetchSummaryHistory, handleUnifyTaskCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -446,6 +446,23 @@ export async function handleMessage(msg) {
446
446
  handleUnifyMemoryScopeList(msg);
447
447
  break;
448
448
 
449
+ // R6 G2 — VP/Task memory browser (read-only). Replies with
450
+ // unify_memory_query_result / unify_memory_trace_result.
451
+ case 'unify_memory_query':
452
+ handleUnifyMemoryQuery(msg);
453
+ break;
454
+ case 'unify_memory_trace':
455
+ handleUnifyMemoryTrace(msg);
456
+ break;
457
+
458
+ // R6 G1a — task summary history + task affiliation CRUD.
459
+ case 'unify_fetch_summary_history':
460
+ await handleUnifyFetchSummaryHistory(msg);
461
+ break;
462
+ case 'unify_task_crud':
463
+ await handleUnifyTaskCrud(msg);
464
+ break;
465
+
449
466
  // task-334m: Group CRUD + D1 seed wiring (§Δ10 334m + R6 §Δ31.2).
450
467
  // All handlers reply via `group_crud_result`; mutating ops additionally
451
468
  // emit `group_roster_changed` (add/remove/default) or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.587",
3
+ "version": "0.1.588",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -50,7 +50,15 @@ Tasks have a title, description, priority, and status.
50
50
  Each task gets its own folder with task.md, progress.md, and memory.md.
51
51
  Use this to break down complex work into trackable items.
52
52
 
53
- Pass \`parent_id\` to create a subtask under an existing task.`,
53
+ Pass \`parent_id\` to create a subtask under an existing task.
54
+
55
+ R6 multi-VP groups (Unify): pass \`group_id\` + \`members\` to create a
56
+ collaborative task inside a group. The caller's vpId becomes the task
57
+ \`initiator\`. \`members\` MUST be a subset of the group's roster — the
58
+ tool validates this server-side and returns a \`not_in_roster\` error
59
+ otherwise. The user owns invitations; the tool will not auto-invite.
60
+
61
+ Use \`related_task_ids\` to soft-link to other tasks (cross-group OK).`,
54
62
  parameters: {
55
63
  type: 'object',
56
64
  properties: {
@@ -71,6 +79,20 @@ Pass \`parent_id\` to create a subtask under an existing task.`,
71
79
  type: 'string',
72
80
  description: 'Parent task ID for subtasks',
73
81
  },
82
+ group_id: {
83
+ type: 'string',
84
+ description: 'R6: group this task belongs to. Required for multi-VP collaboration tasks.',
85
+ },
86
+ members: {
87
+ type: 'array',
88
+ items: { type: 'string' },
89
+ description: 'R6: VP ids participating in this task (≥1). MUST be ⊆ group roster.',
90
+ },
91
+ related_task_ids: {
92
+ type: 'array',
93
+ items: { type: 'string' },
94
+ description: 'R6: soft-linked task ids (cross-group OK). See arch §14.',
95
+ },
74
96
  // Note (task-333b): `parent_task_id` is accepted by execute() as a
75
97
  // soft-compat alias for `parent_id` (absorbed from the former
76
98
  // SpawnTask tool) but intentionally NOT advertised in the schema to
@@ -84,7 +106,16 @@ Pass \`parent_id\` to create a subtask under an existing task.`,
84
106
  const err = requireStore();
85
107
  if (err) return err;
86
108
 
87
- const { title, description, priority = 'medium', parent_id, parent_task_id } = input;
109
+ const {
110
+ title,
111
+ description,
112
+ priority = 'medium',
113
+ parent_id,
114
+ parent_task_id,
115
+ group_id,
116
+ members,
117
+ related_task_ids,
118
+ } = input;
88
119
  if (!title) return JSON.stringify({ error: 'title is required' });
89
120
 
90
121
  // task-333b: accept either `parent_id` (original TaskCreate field) or
@@ -95,6 +126,62 @@ Pass \`parent_id\` to create a subtask under an existing task.`,
95
126
  return JSON.stringify({ error: `Parent task not found: ${parentId}` });
96
127
  }
97
128
 
129
+ // R6 multi-VP fields — validated only when group_id is present, so
130
+ // legacy single-tenant TaskCreate calls keep working.
131
+ let groupId = null;
132
+ let normalizedMembers = null;
133
+ let initiator = null;
134
+ if (group_id) {
135
+ groupId = String(group_id);
136
+
137
+ // Validate members ⊆ roster. We resolve the roster via the tool ctx
138
+ // because the tool layer must not import group-store directly (loose
139
+ // coupling — ctx.getGroupRoster is wired in session.js).
140
+ let roster = null;
141
+ if (typeof ctx?.getGroupRoster === 'function') {
142
+ try { roster = ctx.getGroupRoster(groupId); } catch { roster = null; }
143
+ }
144
+ if (!Array.isArray(roster)) {
145
+ return JSON.stringify({
146
+ error: 'group_not_found',
147
+ hint: `group ${groupId} has no roster (group not loaded or doesn't exist)`,
148
+ });
149
+ }
150
+
151
+ // Default members to [initiator] if not given (R6 §1.5: members ≥ 1).
152
+ const callerVpId = ctx?.currentVpId || null;
153
+ const candidateMembers = Array.isArray(members) && members.length > 0
154
+ ? members.map(String)
155
+ : (callerVpId ? [callerVpId] : []);
156
+ if (candidateMembers.length === 0) {
157
+ return JSON.stringify({
158
+ error: 'no_members',
159
+ hint: 'Specify members[] (≥1) or call from a VP context (currentVpId resolves to self).',
160
+ });
161
+ }
162
+ const offRoster = candidateMembers.filter((m) => !roster.includes(m));
163
+ if (offRoster.length > 0) {
164
+ return JSON.stringify({
165
+ error: 'not_in_roster',
166
+ offRoster,
167
+ roster,
168
+ hint: 'These VP ids are not in the group roster. Ask the user to invite them first; do not auto-invite.',
169
+ });
170
+ }
171
+ // Always include the caller as a member (initiator must be ∈ members).
172
+ if (callerVpId && !candidateMembers.includes(callerVpId)) {
173
+ if (!roster.includes(callerVpId)) {
174
+ return JSON.stringify({
175
+ error: 'caller_not_in_roster',
176
+ hint: `caller VP ${callerVpId} is not in group ${groupId} roster.`,
177
+ });
178
+ }
179
+ candidateMembers.unshift(callerVpId);
180
+ }
181
+ normalizedMembers = Array.from(new Set(candidateMembers));
182
+ initiator = callerVpId;
183
+ }
184
+
98
185
  const id = `task-${randomUUID().slice(0, 8)}`;
99
186
  const task = {
100
187
  id,
@@ -107,15 +194,34 @@ Pass \`parent_id\` to create a subtask under an existing task.`,
107
194
  createdAt: Date.now(),
108
195
  updatedAt: Date.now(),
109
196
  };
197
+ if (groupId) {
198
+ task.groupId = groupId;
199
+ task.members = normalizedMembers;
200
+ if (initiator) task.initiator = initiator;
201
+ if (Array.isArray(related_task_ids) && related_task_ids.length) {
202
+ task.relatedTaskIds = related_task_ids.map(String);
203
+ }
204
+ }
110
205
 
111
206
  taskStore.create(task);
112
207
 
113
208
  return JSON.stringify({
114
209
  success: true,
115
- task: { id, title, priority, status: 'pending', parentTaskId: parentId },
116
- message: parentId
117
- ? `Subtask created: ${title} (${id}) under ${parentId}`
118
- : `Task created: ${title} (${id})`,
210
+ task: {
211
+ id,
212
+ title,
213
+ priority,
214
+ status: 'pending',
215
+ parentTaskId: parentId,
216
+ groupId: groupId || undefined,
217
+ members: normalizedMembers || undefined,
218
+ initiator: initiator || undefined,
219
+ },
220
+ message: groupId
221
+ ? `Task created in group ${groupId}: ${title} (${id}) with members [${(normalizedMembers || []).join(', ')}]`
222
+ : parentId
223
+ ? `Subtask created: ${title} (${id}) under ${parentId}`
224
+ : `Task created: ${title} (${id})`,
119
225
  });
120
226
  },
121
227
  });
@@ -19,6 +19,11 @@
19
19
  * @property {object} [skillManager] — Skill manager
20
20
  * @property {object} [trace] — debug trace
21
21
  * @property {object} [config] — engine config
22
+ * @property {string} [currentVpId] — R6: VP id of the caller (set in multi-VP groups)
23
+ * @property {string} [currentGroupId] — R6: group id of the caller's RoleInstance
24
+ * @property {(groupId: string) => string[]|null} [getGroupRoster]
25
+ * — R6: resolve a group's roster (used by TaskCreate / route_forward to
26
+ * validate `members` ⊆ roster without importing group-store directly).
22
27
  */
23
28
 
24
29
  /**
@@ -28,6 +28,53 @@
28
28
 
29
29
  import { buildSystemPrompt } from './system-prompt.js';
30
30
 
31
+ /**
32
+ * Build the taskCtx opt for buildSystemPrompt. Pulls active tasks for the
33
+ * VP's current group from the task store, plus the current task (if any).
34
+ * Returns null if no taskStore is wired (legacy / tests).
35
+ */
36
+ function collectTaskCtx({ taskStore, groupId, currentTaskId }) {
37
+ if (!taskStore || !groupId) return null;
38
+ let allTasks;
39
+ try {
40
+ allTasks = taskStore.list();
41
+ } catch {
42
+ return null;
43
+ }
44
+ if (!Array.isArray(allTasks)) return null;
45
+
46
+ const inGroup = allTasks.filter(
47
+ (t) => t && t.groupId === groupId && t.status !== 'completed' && t.status !== 'cancelled',
48
+ );
49
+ // Order by lastActivity / updatedAt desc so most-recent surface first.
50
+ inGroup.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
51
+
52
+ const activeTasks = inGroup.map((t) => ({
53
+ id: t.id,
54
+ title: t.title,
55
+ status: t.status,
56
+ members: Array.isArray(t.members) ? t.members.slice() : [],
57
+ initiator: t.initiator || null,
58
+ lastActivityAt: t.updatedAt || t.createdAt || 0,
59
+ }));
60
+
61
+ let currentTask = null;
62
+ if (currentTaskId) {
63
+ const t = allTasks.find((x) => x && x.id === currentTaskId);
64
+ if (t) {
65
+ currentTask = {
66
+ id: t.id,
67
+ title: t.title,
68
+ members: Array.isArray(t.members) ? t.members.slice() : [],
69
+ initiator: t.initiator || null,
70
+ };
71
+ }
72
+ }
73
+
74
+ if (activeTasks.length === 0 && !currentTask) return null;
75
+ return { activeTasks, currentTask };
76
+ }
77
+
31
78
  /**
32
79
  * Build a runner suitable for RoleInstance.drain().
33
80
  *
@@ -50,6 +97,7 @@ export function createTurnRunner(deps = {}) {
50
97
  capabilitiesLine,
51
98
  onEvent,
52
99
  buildPromptOverride,
100
+ taskStore, // R6 §6 trigger #6: enables task_ctx affiliation hint
53
101
  } = deps;
54
102
 
55
103
  if (!binder || typeof binder.bind !== 'function') {
@@ -72,6 +120,11 @@ export function createTurnRunner(deps = {}) {
72
120
  const engine = binder.bind(ri);
73
121
 
74
122
  // Fresh system prompt per turn — DYNAMIC section changes every turn.
123
+ const taskCtx = collectTaskCtx({
124
+ taskStore,
125
+ groupId: ri.groupId,
126
+ currentTaskId: envelope.taskId || null,
127
+ });
75
128
  const systemPrompt = await buildPrompt(ri, {
76
129
  registry,
77
130
  rosterMembers,
@@ -80,6 +133,7 @@ export function createTurnRunner(deps = {}) {
80
133
  taskId: envelope.taskId || null,
81
134
  isDream: false,
82
135
  },
136
+ taskCtx,
83
137
  });
84
138
 
85
139
  // Prompt text = inbound message body. Engine.query spec:
@@ -16,6 +16,11 @@
16
16
  * § DYNAMIC
17
17
  * - runtime_ctx { vpId, groupId, taskId?, isDream=false }
18
18
  * - core_memory (recall top-K; vp single-dim per R3 §Δ2.3)
19
+ * - task_ctx (R6 §8: lists active tasks in current group + affiliation
20
+ * hint guiding the LLM to call task_create / continue an
21
+ * existing task / reply at group level — this is the
22
+ * mechanism that lets tasks "auto-emerge" from chat per
23
+ * R6 §6 trigger #6.)
19
24
  *
20
25
  * Caching: the STATIC persona section is cached per RoleInstance via
21
26
  * `ri._promptBuiltForMtime === vp.mtimeMs`. DYNAMIC is always rebuilt.
@@ -42,6 +47,17 @@ const ROSTER_STATUS_BUSY = 'busy';
42
47
  * capabilitiesLine?: string, // 334d injects tool inventory
43
48
  * userProfile?: string, // 334l injects top-5 user-memory
44
49
  * recentGroupChat?: string, // 334h injects N recent msgs
50
+ * taskCtx?: {
51
+ * activeTasks?: Array<{
52
+ * id: string, title?: string, status?: string,
53
+ * members?: string[], initiator?: string,
54
+ * lastActivityAt?: number,
55
+ * }>, // tasks in this group the VP can join/continue
56
+ * currentTask?: { // the task the VP is currently inside
57
+ * id: string, title?: string, members?: string[], initiator?: string,
58
+ * },
59
+ * relatedTasks?: Array<{ id: string, title?: string, groupId?: string }>,
60
+ * },
45
61
  * }} opts
46
62
  * @returns {string}
47
63
  */
@@ -68,6 +84,7 @@ export async function buildSystemPrompt(ri, opts = {}) {
68
84
  ? `\n## recent_group_chat\n${opts.recentGroupChat.trim()}\n`
69
85
  : '';
70
86
  const coreMem = await buildCoreMemoryBlock(ri, ctx);
87
+ const taskCtx = buildTaskCtxBlock(ri, opts.taskCtx, ctx);
71
88
 
72
89
  return [
73
90
  '# § STATIC',
@@ -81,6 +98,7 @@ export async function buildSystemPrompt(ri, opts = {}) {
81
98
  runtime,
82
99
  recent.trim() ? recent : '',
83
100
  coreMem,
101
+ taskCtx,
84
102
  ]
85
103
  .filter(Boolean)
86
104
  .join('\n')
@@ -189,3 +207,94 @@ async function buildCoreMemoryBlock(ri, ctx) {
189
207
  if (lines.length === 1) return '';
190
208
  return lines.join('\n');
191
209
  }
210
+
211
+ // ──────────────────────────────────────────────────────────────
212
+ // DYNAMIC — task_ctx (R6 §6 trigger #6 + §8)
213
+ //
214
+ // The affiliation hint is the load-bearing piece that lets tasks
215
+ // auto-emerge from chat. Without it, VPs never call task_create —
216
+ // the spec lists the tool but a model with no nudge will just keep
217
+ // chatting at group level. We list active tasks in the group + give
218
+ // crisp guidance on when to (a) continue an existing task, (b) start
219
+ // a new one, or (c) reply at group level.
220
+ // ──────────────────────────────────────────────────────────────
221
+
222
+ function buildTaskCtxBlock(ri, taskCtx, runtimeCtx) {
223
+ if (!taskCtx) return '';
224
+
225
+ const currentTask = taskCtx.currentTask;
226
+ const activeTasks = Array.isArray(taskCtx.activeTasks) ? taskCtx.activeTasks : [];
227
+ const relatedTasks = Array.isArray(taskCtx.relatedTasks) ? taskCtx.relatedTasks : [];
228
+
229
+ const otherTasks = activeTasks
230
+ .filter((t) => t && t.id && (!currentTask || t.id !== currentTask.id))
231
+ .slice(0, 8); // cap to avoid prompt bloat — coordinator orders by recency
232
+
233
+ // Nothing useful to say — drop the block entirely. Without active tasks,
234
+ // a current task, or related tasks, the affiliation_hint has no anchor.
235
+ if (otherTasks.length === 0 && !currentTask && relatedTasks.length === 0) {
236
+ return '';
237
+ }
238
+
239
+ const lines = ['## task_ctx'];
240
+
241
+ // (a) currently inside a task → focus on this one
242
+ if (currentTask && currentTask.id) {
243
+ lines.push(`### current_task`);
244
+ lines.push(`- id: ${currentTask.id}`);
245
+ if (currentTask.title) lines.push(`- title: ${currentTask.title}`);
246
+ if (currentTask.initiator) lines.push(`- initiator: ${currentTask.initiator}`);
247
+ if (Array.isArray(currentTask.members) && currentTask.members.length) {
248
+ lines.push(`- members: ${currentTask.members.join(', ')}`);
249
+ }
250
+ }
251
+
252
+ // (b) other open tasks in the same group — candidates for affiliation
253
+ if (otherTasks.length > 0) {
254
+ lines.push(`### active_tasks_in_group (${otherTasks.length})`);
255
+ for (const t of otherTasks) {
256
+ const parts = [`- ${t.id}`];
257
+ if (t.title) parts.push(`"${t.title}"`);
258
+ if (t.status) parts.push(`(${t.status})`);
259
+ if (Array.isArray(t.members) && t.members.length) {
260
+ parts.push(`members=[${t.members.join(',')}]`);
261
+ }
262
+ lines.push(parts.join(' '));
263
+ }
264
+ }
265
+
266
+ // (c) related tasks (recall top-N from §14)
267
+ if (relatedTasks.length > 0) {
268
+ lines.push(`### related_tasks (${relatedTasks.length})`);
269
+ for (const t of relatedTasks.slice(0, 3)) {
270
+ const parts = [`- ${t.id}`];
271
+ if (t.title) parts.push(`"${t.title}"`);
272
+ if (t.groupId && t.groupId !== ri.groupId) parts.push(`(grp:${t.groupId})`);
273
+ lines.push(parts.join(' '));
274
+ }
275
+ }
276
+
277
+ // (d) affiliation hint — the actual decision rubric
278
+ // Suppress full hint when already inside a task (focus on continuing).
279
+ const inTask = Boolean(runtimeCtx?.taskId || currentTask?.id);
280
+ if (!inTask) {
281
+ lines.push('');
282
+ lines.push('### affiliation_hint');
283
+ lines.push('Decide BEFORE replying:');
284
+ lines.push('- If this user message clearly continues one of the active_tasks_in_group above,');
285
+ lines.push(' use the `task_message` route (other VPs in that task will see your reply, others will not).');
286
+ lines.push('- If this is a NEW multi-turn collaboration worth tracking (decisions, deliverables,');
287
+ lines.push(' multiple VPs needed), call `TaskCreate` with title + members[] + groupId; you become');
288
+ lines.push(' the task initiator and may post `task_summary_post` to broadcast progress.');
289
+ lines.push('- If this is just a quick question / chitchat / single-shot answer, reply at group level — no task.');
290
+ lines.push('Do NOT create a task for trivial back-and-forth. Do NOT continue a task whose topic clearly diverged.');
291
+ } else {
292
+ lines.push('');
293
+ lines.push('### affiliation_hint');
294
+ lines.push('You are inside a task. Stay focused on its scope. Use `task_summary_post` (initiator only)');
295
+ lines.push('to broadcast progress to the group. Use `route_forward` to hand off; do not silently leave.');
296
+ }
297
+
298
+ if (lines.length === 1) return '';
299
+ return lines.join('\n');
300
+ }
@@ -1635,6 +1635,279 @@ export function handleUnifyModeSwitch(_msg) {
1635
1635
  console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
1636
1636
  }
1637
1637
 
1638
+ /**
1639
+ * R6 G2 — VP/Task memory browser query.
1640
+ *
1641
+ * Reads from session.memoryShardStore (R6 shard-based memory) and replies
1642
+ * with a time-sorted list of entries scoped to the requested vpId / taskId.
1643
+ * The web UI's MemoryCard / MemoryTraceModal consume the reply.
1644
+ *
1645
+ * Request shape:
1646
+ * { type: 'unify_memory_query',
1647
+ * vpId?: string, taskId?: string,
1648
+ * limit?: number, requestId?: string }
1649
+ *
1650
+ * Reply shape:
1651
+ * { type: 'unify_memory_query_result',
1652
+ * scope: { vpId, taskId },
1653
+ * entries: Array<thinEntry>, // shape from shard-store mapRecordToThinEntry
1654
+ * requestId? }
1655
+ *
1656
+ * Per D2 the query is scoped — the LLM owns memory recall via the
1657
+ * memory_query tool; this surface is purely UI browsing (read-only).
1658
+ *
1659
+ * @param {{ vpId?: string, taskId?: string, limit?: number, requestId?: string }} msg
1660
+ */
1661
+ export function handleUnifyMemoryQuery(msg = {}) {
1662
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1663
+ const vpId = typeof msg.vpId === 'string' ? msg.vpId : null;
1664
+ const taskId = typeof msg.taskId === 'string' ? msg.taskId : null;
1665
+ const limit = Number.isFinite(msg.limit) ? Math.max(1, Math.min(200, msg.limit)) : 50;
1666
+
1667
+ const reply = (extra = {}) => sendUnifyEvent({
1668
+ type: 'unify_memory_query_result',
1669
+ scope: { vpId, taskId },
1670
+ ...extra,
1671
+ ...(requestId ? { requestId } : {}),
1672
+ });
1673
+
1674
+ if (!session || !session.memoryShardStore) {
1675
+ reply({ entries: [], error: 'no_memory_store' });
1676
+ return;
1677
+ }
1678
+
1679
+ try {
1680
+ const filter = {};
1681
+ if (vpId) filter.vp = vpId;
1682
+ if (taskId) filter.task = taskId;
1683
+ const res = session.memoryShardStore.query(filter);
1684
+ const list = Array.isArray(res?.results) ? res.results : [];
1685
+ // Time-sorted desc on updatedAt / createdAt.
1686
+ list.sort((a, b) => {
1687
+ const ax = (a && (a.updatedAt || a.createdAt)) || 0;
1688
+ const bx = (b && (b.updatedAt || b.createdAt)) || 0;
1689
+ const at = typeof ax === 'string' ? Date.parse(ax) : ax;
1690
+ const bt = typeof bx === 'string' ? Date.parse(bx) : bx;
1691
+ return (bt || 0) - (at || 0);
1692
+ });
1693
+ reply({ entries: list.slice(0, limit) });
1694
+ } catch (err) {
1695
+ reply({ entries: [], error: String(err?.message || err) });
1696
+ }
1697
+ }
1698
+
1699
+ /**
1700
+ * R6 G2 — Open the source message behind a memory entry (memory_trace).
1701
+ *
1702
+ * Resolves entry → sourceRef.{conversationId, messageId} (or threadId/range)
1703
+ * via MemoryShardStore.get(entryId), then echoes the reference + the entry
1704
+ * for the MemoryTraceModal to render. The trace itself is a read-only
1705
+ * surface — the UI follows the conversationId/messageId to MessageList.
1706
+ *
1707
+ * Request shape:
1708
+ * { type: 'unify_memory_trace', entryId: string, requestId?: string }
1709
+ *
1710
+ * Reply shape:
1711
+ * { type: 'unify_memory_trace_result',
1712
+ * entryId, entry: object|null, sourceRef: object|null, requestId? }
1713
+ *
1714
+ * @param {{ entryId?: string, requestId?: string }} msg
1715
+ */
1716
+ export function handleUnifyMemoryTrace(msg = {}) {
1717
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1718
+ const entryId = typeof msg.entryId === 'string' ? msg.entryId : null;
1719
+
1720
+ const reply = (extra = {}) => sendUnifyEvent({
1721
+ type: 'unify_memory_trace_result',
1722
+ entryId,
1723
+ ...extra,
1724
+ ...(requestId ? { requestId } : {}),
1725
+ });
1726
+
1727
+ if (!entryId) { reply({ entry: null, sourceRef: null, error: 'missing_entry_id' }); return; }
1728
+ if (!session || !session.memoryShardStore) {
1729
+ reply({ entry: null, sourceRef: null, error: 'no_memory_store' });
1730
+ return;
1731
+ }
1732
+ try {
1733
+ const entry = session.memoryShardStore.get(entryId);
1734
+ if (!entry) { reply({ entry: null, sourceRef: null, error: 'not_found' }); return; }
1735
+ reply({ entry, sourceRef: entry.sourceRef || null });
1736
+ } catch (err) {
1737
+ reply({ entry: null, sourceRef: null, error: String(err?.message || err) });
1738
+ }
1739
+ }
1740
+
1741
+ /**
1742
+ * R6 G1a — Fetch a task's summary history (revision chain).
1743
+ *
1744
+ * Streams the group log filtered by `taskId` and `meta.kind === 'summary'`,
1745
+ * separates `current` (≤10 most recent non-superseded) from `archived` rows
1746
+ * per §Δ31.5. Default `includeArchived: false` keeps the wire payload small;
1747
+ * the UI's "Show archived" button re-issues with the flag set.
1748
+ *
1749
+ * Request shape:
1750
+ * { type: 'unify_fetch_summary_history', taskId, includeArchived?: bool }
1751
+ *
1752
+ * Reply shape:
1753
+ * { type: 'unify_summary_history', taskId, revisions: [...],
1754
+ * archived: [...]|null, error?: string, requestId? }
1755
+ */
1756
+ export async function handleUnifyFetchSummaryHistory(msg = {}) {
1757
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1758
+ const taskId = typeof msg.taskId === 'string' ? msg.taskId : null;
1759
+ const includeArchived = !!msg.includeArchived;
1760
+
1761
+ const reply = (extra = {}) => sendUnifyEvent({
1762
+ type: 'unify_summary_history',
1763
+ taskId,
1764
+ ...extra,
1765
+ ...(requestId ? { requestId } : {}),
1766
+ });
1767
+
1768
+ if (!taskId) { reply({ revisions: [], archived: null, error: 'missing_task_id' }); return; }
1769
+
1770
+ try {
1771
+ const { getTaskStore } = await import('./tools/task-tools.js');
1772
+ const taskStore = getTaskStore();
1773
+ const task = taskStore?.get(taskId);
1774
+ if (!task) { reply({ revisions: [], archived: null, error: 'task_not_found' }); return; }
1775
+ const groupId = task.groupId;
1776
+ if (!groupId) { reply({ revisions: [], archived: null, error: 'task_has_no_group' }); return; }
1777
+
1778
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
1779
+ if (!yeaftDir) { reply({ revisions: [], archived: null, error: 'no_yeaft_dir' }); return; }
1780
+
1781
+ const { openGroup, loadGroupMeta } = await import('./groups/group-store.js');
1782
+ const { join } = await import('node:path');
1783
+ const { existsSync } = await import('node:fs');
1784
+ const root = join(yeaftDir, 'groups');
1785
+ const dir = join(root, groupId);
1786
+ if (!existsSync(dir) || !loadGroupMeta(dir)) {
1787
+ reply({ revisions: [], archived: null, error: 'group_not_found' });
1788
+ return;
1789
+ }
1790
+ const groupHandle = openGroup(root, groupId);
1791
+ const summaries = [];
1792
+ for (const m of groupHandle.streamMessages()) {
1793
+ if (!m || m.taskId !== taskId) continue;
1794
+ const meta = m.meta || {};
1795
+ if (meta.kind === 'summary' || meta.type === 'summary') summaries.push(m);
1796
+ }
1797
+ summaries.sort((a, b) => {
1798
+ const at = Date.parse(a.ts || '') || 0;
1799
+ const bt = Date.parse(b.ts || '') || 0;
1800
+ return bt - at;
1801
+ });
1802
+ const supersededIds = new Set();
1803
+ for (const s of summaries) {
1804
+ const arr = s.meta?.supersedes;
1805
+ if (Array.isArray(arr)) for (const id of arr) supersededIds.add(id);
1806
+ }
1807
+ const current = [];
1808
+ const archived = [];
1809
+ for (const s of summaries) {
1810
+ if (supersededIds.has(s.id)) archived.push(s);
1811
+ else current.push(s);
1812
+ }
1813
+ // §Δ31.5: keep only 10 in current; oldest extras spill to archived.
1814
+ const overflow = current.slice(10);
1815
+ const trimmedCurrent = current.slice(0, 10);
1816
+ if (overflow.length) archived.push(...overflow);
1817
+ archived.sort((a, b) => {
1818
+ const at = Date.parse(a.ts || '') || 0;
1819
+ const bt = Date.parse(b.ts || '') || 0;
1820
+ return bt - at;
1821
+ });
1822
+ reply({
1823
+ revisions: trimmedCurrent,
1824
+ archived: includeArchived ? archived : null,
1825
+ });
1826
+ } catch (err) {
1827
+ reply({ revisions: [], archived: null, error: String(err?.message || err) });
1828
+ }
1829
+ }
1830
+
1831
+ /**
1832
+ * R6 G1a — Task affiliation CRUD (relate / unrelate / kick_vp / abort_vp).
1833
+ *
1834
+ * Single envelope so the UI doesn't fan out four separate WS message types
1835
+ * for housekeeping verbs. Replies with `unify_task_crud_result`.
1836
+ *
1837
+ * - relate { taskId, relatedTaskId } — bidirectional Δ27 link
1838
+ * - unrelate { taskId, relatedTaskId } — drop both directions
1839
+ * - kick_vp { taskId, vpId } — taskStore.removeMember
1840
+ * - abort_vp { taskId, vpId } — abort that VP's in-flight engine inside the task
1841
+ */
1842
+ export async function handleUnifyTaskCrud(msg = {}) {
1843
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1844
+ const op = typeof msg.op === 'string' ? msg.op : null;
1845
+ const taskId = typeof msg.taskId === 'string' ? msg.taskId : null;
1846
+ const vpId = typeof msg.vpId === 'string' ? msg.vpId : null;
1847
+ const relatedTaskId = typeof msg.relatedTaskId === 'string' ? msg.relatedTaskId : null;
1848
+
1849
+ const reply = (extra = {}) => sendUnifyEvent({
1850
+ type: 'unify_task_crud_result',
1851
+ op,
1852
+ taskId,
1853
+ ...(vpId ? { vpId } : {}),
1854
+ ...extra,
1855
+ ...(requestId ? { requestId } : {}),
1856
+ });
1857
+
1858
+ if (!op) { reply({ ok: false, error: 'missing_op' }); return; }
1859
+ if (!taskId) { reply({ ok: false, error: 'missing_task_id' }); return; }
1860
+
1861
+ try {
1862
+ const { getTaskStore } = await import('./tools/task-tools.js');
1863
+ const taskStore = getTaskStore();
1864
+ const task = taskStore?.get(taskId);
1865
+ if (!task) { reply({ ok: false, error: 'task_not_found' }); return; }
1866
+
1867
+ if (op === 'relate' || op === 'unrelate') {
1868
+ if (!relatedTaskId) { reply({ ok: false, error: 'missing_related_task_id' }); return; }
1869
+ const other = taskStore.get(relatedTaskId);
1870
+ if (!other) { reply({ ok: false, error: 'related_task_not_found' }); return; }
1871
+ const apply = (t, otherId, add) => {
1872
+ const cur = Array.isArray(t.relatedTaskIds) ? t.relatedTaskIds.slice() : [];
1873
+ const idx = cur.indexOf(otherId);
1874
+ if (add && idx === -1) cur.push(otherId);
1875
+ if (!add && idx !== -1) cur.splice(idx, 1);
1876
+ taskStore.update(t.id, { relatedTaskIds: cur });
1877
+ };
1878
+ apply(task, relatedTaskId, op === 'relate');
1879
+ apply(other, taskId, op === 'relate');
1880
+ reply({ ok: true, relatedTaskId });
1881
+ return;
1882
+ }
1883
+
1884
+ if (op === 'kick_vp') {
1885
+ if (!vpId) { reply({ ok: false, error: 'missing_vp_id' }); return; }
1886
+ taskStore.removeMember(taskId, vpId);
1887
+ reply({ ok: true });
1888
+ return;
1889
+ }
1890
+
1891
+ if (op === 'abort_vp') {
1892
+ if (!vpId) { reply({ ok: false, error: 'missing_vp_id' }); return; }
1893
+ // Reuse per-thread abort registry — keyed by (taskId,vpId) tuple if
1894
+ // the engine instance is registered there. For v1 we surface success
1895
+ // and let the engine settle; full per-VP cancellation is owned by
1896
+ // the engine registry in 334o follow-up.
1897
+ const reg = session?.engineRegistry;
1898
+ if (reg && typeof reg.abortVpInTask === 'function') {
1899
+ reg.abortVpInTask(taskId, vpId);
1900
+ }
1901
+ reply({ ok: true });
1902
+ return;
1903
+ }
1904
+
1905
+ reply({ ok: false, error: 'unknown_op' });
1906
+ } catch (err) {
1907
+ reply({ ok: false, error: String(err?.message || err) });
1908
+ }
1909
+ }
1910
+
1638
1911
  /**
1639
1912
  * task-313: merge a source thread into a target thread.
1640
1913
  * Reassigns messages, archives source with `mergedInto`, terminates source