@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.
- package/connection/message-router.js +18 -1
- package/package.json +1 -1
- package/unify/engine.js +15 -7
- package/unify/pipeline/dispatcher.js +3 -1
- package/unify/prompts.js +45 -0
- package/unify/threads/engine-instance.js +2 -2
- package/unify/tools/task-tools.js +112 -6
- package/unify/tools/types.js +5 -0
- package/unify/vp/run-turn.js +54 -0
- package/unify/vp/system-prompt.js +109 -0
- package/unify/web-bridge.js +321 -0
|
@@ -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
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
|
-
|
|
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 {
|
|
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: {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
});
|
package/unify/tools/types.js
CHANGED
|
@@ -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
|
/**
|
package/unify/vp/run-turn.js
CHANGED
|
@@ -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
|
+
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -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
|