@yeaft/webchat-agent 0.1.586 → 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.586",
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",
package/unify/engine.js CHANGED
@@ -297,7 +297,7 @@ export class Engine {
297
297
  * @param {string} [userProfile] — user profile from user-memory shard store
298
298
  * @returns {string}
299
299
  */
300
- #buildSystemPrompt(memory, compactSummary, prompt, memoryInjection, userProfile) {
300
+ #buildSystemPrompt(memory, compactSummary, prompt, memoryInjection, userProfile, vpPersona) {
301
301
  // Get relevant skill content if SkillManager is wired
302
302
  let skillContent = '';
303
303
  if (this.#skillManager && prompt) {
@@ -317,6 +317,7 @@ export class Engine {
317
317
  compactSummary,
318
318
  skillContent,
319
319
  userProfile,
320
+ vpPersona,
320
321
  // task-334f: memory_trace tool is now registered (49 → 51 tools), so
321
322
  // unlock the core_memory meta-line behind 334e's feature flag.
322
323
  memoryTraceAvailable: true,
@@ -329,7 +330,7 @@ export class Engine {
329
330
  * @param {AbortSignal} [signal]
330
331
  * @returns {object}
331
332
  */
332
- #buildToolContext(signal) {
333
+ #buildToolContext(signal, vpCtx) {
333
334
  return {
334
335
  signal,
335
336
  yeaftDir: this.#yeaftDir,
@@ -348,6 +349,13 @@ export class Engine {
348
349
  imageAllowlist: Array.isArray(this.#config?.unify?.imageAllowlist)
349
350
  ? this.#config.unify.imageAllowlist
350
351
  : [],
352
+ // Bug 4 fix — VP / routing context for RouteForward (and any other
353
+ // VP-aware tool). Undefined when running in non-group / no-VP flows.
354
+ router: vpCtx?.router,
355
+ senderVpId: vpCtx?.senderVpId,
356
+ inboundEnvelope: vpCtx?.inboundEnvelope,
357
+ taskId: vpCtx?.taskId,
358
+ taskMembers: vpCtx?.taskMembers,
351
359
  };
352
360
  }
353
361
 
@@ -510,7 +518,7 @@ export class Engine {
510
518
  * SCENARIO_EFFORT. Unknown values fall through to 'high'.
511
519
  * @yields {EngineEvent}
512
520
  */
513
- async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat' }) {
521
+ async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers } = {}) {
514
522
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
515
523
  yield {
516
524
  type: 'error',
@@ -561,7 +569,7 @@ export class Engine {
561
569
  const runSignal = abortCtrl.signal;
562
570
 
563
571
  try {
564
- yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario });
572
+ yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers });
565
573
  } finally {
566
574
  if (signal) {
567
575
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -579,7 +587,7 @@ export class Engine {
579
587
  * in a try/finally without indenting the whole loop.
580
588
  * @private
581
589
  */
582
- async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat' }) {
590
+ async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers }) {
583
591
 
584
592
  // ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
585
593
  // Two-layer recall:
@@ -618,7 +626,7 @@ export class Engine {
618
626
 
619
627
  const compactSummary = this.#getCompactSummary();
620
628
  const userProfile = recallResult?.profile || '';
621
- const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection, userProfile);
629
+ const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection, userProfile, vpPersona);
622
630
 
623
631
  // Build conversation: existing messages + new user message
624
632
  const conversationMessages = [
@@ -883,7 +891,7 @@ export class Engine {
883
891
  }
884
892
 
885
893
  // Execute tool calls and feed results back
886
- const toolCtx = this.#buildToolContext(signal);
894
+ const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers });
887
895
 
888
896
  // task-325a: track whether we aborted mid tool-loop so we can
889
897
  // break out of the outer while-loop cleanly once the current
@@ -164,6 +164,7 @@ export class Dispatcher {
164
164
  transientMeta.set(entry, {
165
165
  messageId: opts.messageId || undefined,
166
166
  override: opts.override || undefined,
167
+ queryOpts: opts.queryOpts || undefined,
167
168
  });
168
169
  const snapshot = this.#queueSnapshot();
169
170
  return { entry, snapshot };
@@ -299,7 +300,8 @@ export class Dispatcher {
299
300
  }
300
301
 
301
302
  try {
302
- for await (const event of instance.query({ prompt: claimed.text, signal })) {
303
+ const queryOpts = (transientMeta.get(claimed) || {}).queryOpts || {};
304
+ for await (const event of instance.query({ prompt: claimed.text, signal, ...queryOpts })) {
303
305
  yield { type: 'engine_event', threadId: targetThreadId, event };
304
306
  }
305
307
  inputQueue.markRouted(claimed.id, targetThreadId);
package/unify/prompts.js CHANGED
@@ -192,6 +192,9 @@ const PROMPTS = {
192
192
  userProfileHeader: '## user_profile',
193
193
  coreMemoryHeader: '## core_memory',
194
194
  coreMemoryMeta: 'To open the original message behind any entry above, call `memory_trace`.',
195
+ vpPersonaHeader: '## active_persona',
196
+ vpPersonaIntro: (name, role) =>
197
+ `For this turn you are speaking as **${name}**${role ? ` (${role})` : ''}. Stay in character; the persona below overrides the generic Yeaft identity for tone, expertise, and decision style.`,
195
198
  },
196
199
  zh: {
197
200
  identity: '你是 Yeaft,一个有用的 AI 助手。',
@@ -210,6 +213,9 @@ const PROMPTS = {
210
213
  userProfileHeader: '## user_profile',
211
214
  coreMemoryHeader: '## core_memory',
212
215
  coreMemoryMeta: '如需原始 message,调 `memory_trace`。',
216
+ vpPersonaHeader: '## active_persona',
217
+ vpPersonaIntro: (name, role) =>
218
+ `本轮你以 **${name}**${role ? `(${role})` : ''} 的身份说话。请保持人设:以下 persona 在语气、专业方向与判断风格上覆盖默认的 Yeaft 身份。`,
213
219
  },
214
220
  };
215
221
 
@@ -289,6 +295,7 @@ export function buildSystemPrompt({
289
295
  userProfile,
290
296
  coreMemory,
291
297
  memoryTraceAvailable = false,
298
+ vpPersona,
292
299
  } = {}) {
293
300
  // Fallback to English for unknown languages
294
301
  const lang = PROMPTS[language] || PROMPTS.en;
@@ -308,6 +315,14 @@ export function buildSystemPrompt({
308
315
  // ─── 2. Date Metadata ──────────────────────────────────
309
316
  parts.push(lang.date(new Date().toISOString().split('T')[0]));
310
317
 
318
+ // ─── 2.5 VP Persona Override (Bug 3 fix) ───────────────
319
+ // When the caller (web-bridge / dispatcher) addressed a specific VP via
320
+ // @-mention, inject that VP's persona body so the LLM stops speaking as
321
+ // generic Yeaft and adopts the VP's voice. Placed AFTER base identity so
322
+ // the persona section's directive ("override generic Yeaft") wins.
323
+ const vpBlock = renderVpPersona(vpPersona, lang);
324
+ if (vpBlock) parts.push(vpBlock);
325
+
311
326
  // ─── 3. Mode-Specific Instructions ─────────────────────
312
327
  // task-297: single unified mode for all normal operation.
313
328
  // `dream` is retained for background memory maintenance.
@@ -382,6 +397,36 @@ export function buildSystemPrompt({
382
397
 
383
398
  // ─── task-334e helpers ───────────────────────────────────────────
384
399
 
400
+ /**
401
+ * Render the `## active_persona` block when the engine is running on
402
+ * behalf of an addressed VP. Accepts `{ displayName, role?, persona }` —
403
+ * `persona` is the body text from the VP's role.md (loaded by the engine
404
+ * via readVp). When `persona` is empty we still emit the intro line so
405
+ * the LLM at least knows whose voice to adopt; if even displayName is
406
+ * missing we omit the whole block (no useful signal).
407
+ *
408
+ * @param {object} vpPersona
409
+ * @param {string} vpPersona.displayName
410
+ * @param {string} [vpPersona.role]
411
+ * @param {string} [vpPersona.persona]
412
+ * @param {object} lang
413
+ * @returns {string}
414
+ */
415
+ function renderVpPersona(vpPersona, lang) {
416
+ if (!vpPersona || typeof vpPersona !== 'object') return '';
417
+ const name = typeof vpPersona.displayName === 'string'
418
+ ? vpPersona.displayName.trim() : '';
419
+ if (!name) return '';
420
+ const role = typeof vpPersona.role === 'string' ? vpPersona.role.trim() : '';
421
+ const body = typeof vpPersona.persona === 'string' ? vpPersona.persona.trim() : '';
422
+ const lines = [
423
+ lang.vpPersonaHeader,
424
+ lang.vpPersonaIntro(name, role),
425
+ ];
426
+ if (body) lines.push('', body);
427
+ return lines.join('\n');
428
+ }
429
+
385
430
  const DEFAULT_TASK_MEMORY_TOP = 5;
386
431
  const DEFAULT_RELATED_TASK_TOP = 3;
387
432
  const DEFAULT_RELATED_TASK_MEMORY_TOP = 2;
@@ -97,7 +97,7 @@ export class EngineInstance {
97
97
  * @param {AbortSignal} [params.signal]
98
98
  * @yields {object} EngineEvent with { ...event, threadId }
99
99
  */
100
- async *query({ prompt, mode, signal }) {
100
+ async *query({ prompt, mode, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers } = {}) {
101
101
  if (this.#terminated) {
102
102
  yield {
103
103
  type: 'error',
@@ -173,7 +173,7 @@ export class EngineInstance {
173
173
  curToolResults = [];
174
174
  }
175
175
 
176
- for await (const event of this.#engine.query({ prompt, mode, messages: snapshot, signal })) {
176
+ for await (const event of this.#engine.query({ prompt, mode, messages: snapshot, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers })) {
177
177
  // Re-tag every event with the bound threadId. Non-object events
178
178
  // (shouldn't happen — all engine events are objects) are passed
179
179
  // through untouched.
@@ -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
+ }
@@ -28,6 +28,7 @@ import ctx from '../context.js';
28
28
  import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
29
29
  import { handleVpSubscribe } from './vp/vp-bridge.js';
30
30
  import { createVp, updateVp, deleteVp, readVp, VpCrudError } from './vp/vp-crud.js';
31
+ import { createRouter } from './routing/router.js';
31
32
  import { handleUnifyTaskMessage as _handleUnifyTaskMessage } from './task-message.js';
32
33
  import {
33
34
  handleUnifyUserMemoryWrite as _handleUnifyUserMemoryWrite,
@@ -1165,6 +1166,10 @@ export async function handleUnifyGroupChat(msg) {
1165
1166
  groupId,
1166
1167
  vpId,
1167
1168
  speakerVpId: vpId,
1169
+ // Bug 4: hand the coordinator down so handleUnifyChat can build a
1170
+ // Router for the RouteForward tool. Field is namespaced with `_`
1171
+ // to mark it as an internal hop, never sent over WS.
1172
+ _groupCoordinator: coord,
1168
1173
  });
1169
1174
  } catch (err) {
1170
1175
  console.warn('[Unify] unify_group_chat: per-vp dispatch failed', vpId, err?.message || err);
@@ -1181,6 +1186,42 @@ export async function handleUnifyGroupChat(msg) {
1181
1186
  }
1182
1187
  }
1183
1188
 
1189
+ /**
1190
+ * Build the per-query VP context for the Engine.
1191
+ *
1192
+ * - Loads the addressed VP's persona via readVp() so the system prompt
1193
+ * speaks in that VP's voice (Bug 3 fix).
1194
+ * - When a GroupCoordinator handle is supplied, wraps it in a Router so
1195
+ * the RouteForward tool actually forwards instead of returning
1196
+ * `router_unavailable` (Bug 4 fix).
1197
+ *
1198
+ * Returns `undefined` when we have nothing to inject — keeps the legacy
1199
+ * single-agent path identical to before.
1200
+ */
1201
+ function buildVpQueryOpts({ vpId, groupCoordinator }) {
1202
+ if (!vpId) return undefined;
1203
+ const out = { senderVpId: vpId };
1204
+ try {
1205
+ const vp = readVp(vpId);
1206
+ if (vp) {
1207
+ out.vpPersona = {
1208
+ displayName: vp.displayName || vpId,
1209
+ role: vp.role || '',
1210
+ persona: vp.persona || '',
1211
+ };
1212
+ }
1213
+ } catch { /* persona load is best-effort */ }
1214
+ if (groupCoordinator && typeof groupCoordinator.ingest === 'function') {
1215
+ try {
1216
+ out.router = createRouter({ coordinator: groupCoordinator });
1217
+ } catch {
1218
+ // Router build failure is non-fatal — RouteForward will report
1219
+ // router_unavailable and the VP can pivot.
1220
+ }
1221
+ }
1222
+ return out;
1223
+ }
1224
+
1184
1225
  /**
1185
1226
  * Handle a unify_chat message from the web UI.
1186
1227
  *
@@ -1191,6 +1232,12 @@ export async function handleUnifyGroupChat(msg) {
1191
1232
  export async function handleUnifyChat(msg) {
1192
1233
  const { prompt, mode } = msg;
1193
1234
  if (!prompt?.trim()) return;
1235
+ // Bug 3 / Bug 4 — when the upstream group dispatcher addresses a specific
1236
+ // VP, it stamps `vpId` (and optionally a coordinator handle in
1237
+ // `_groupCoordinator`). Hoist them now so they survive the lazy-init
1238
+ // branch and reach the dispatcher.submit() queryOpts.
1239
+ const vpId = typeof msg.vpId === 'string' && msg.vpId.trim() ? msg.vpId.trim() : null;
1240
+ const groupCoordinator = msg._groupCoordinator || null;
1194
1241
 
1195
1242
  // Deprecation warning — task-297 removed chat/work mode distinction
1196
1243
  if (mode !== undefined && mode !== null) {
@@ -1302,6 +1349,7 @@ export async function handleUnifyChat(msg) {
1302
1349
  const { entry } = session.dispatcher.submit(cleanedPrompt, {
1303
1350
  messageId: msg.messageId,
1304
1351
  override: override || undefined,
1352
+ queryOpts: buildVpQueryOpts({ vpId, groupCoordinator }),
1305
1353
  });
1306
1354
  sendUnifyEvent({
1307
1355
  type: 'input_queue_updated',
@@ -1587,6 +1635,279 @@ export function handleUnifyModeSwitch(_msg) {
1587
1635
  console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
1588
1636
  }
1589
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
+
1590
1911
  /**
1591
1912
  * task-313: merge a source thread into a target thread.
1592
1913
  * Reassigns messages, archives source with `mergedInto`, terminates source