@yeaft/webchat-agent 0.1.529 → 0.1.531

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, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead } from '../unify/web-bridge.js';
39
+ import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyTaskMessage, handleUnifyUserMemoryWrite, handleUnifyUserMemoryRemove } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -420,6 +420,22 @@ export async function handleMessage(msg) {
420
420
  handleUnifyVpRead(msg);
421
421
  break;
422
422
 
423
+ // task-334h (R6 §Δ28 / §Δ31.6): task-scoped direct message echo.
424
+ // Replaces the withdrawn R3 `unify_task_private_chat`. Agent validates,
425
+ // stamps msgId + ts, and broadcasts the `task_message` mirror back.
426
+ case 'unify_task_message':
427
+ handleUnifyTaskMessage(msg);
428
+ break;
429
+
430
+ // task-334h (R6 §Δ29): user-memory skeleton. Payload schema + event
431
+ // names are wire-frozen here; real ingestion lands in task-334l.
432
+ case 'unify_user_memory_write':
433
+ handleUnifyUserMemoryWrite(msg);
434
+ break;
435
+ case 'unify_user_memory_remove':
436
+ handleUnifyUserMemoryRemove(msg);
437
+ break;
438
+
423
439
  // Expert roles definition (for ExpertPanel detail view)
424
440
  case 'get_expert_roles': {
425
441
  const { getExpertRolesDefinition } = await import('../expert-roles.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.529",
3
+ "version": "0.1.531",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/prompts.js CHANGED
@@ -18,12 +18,37 @@
18
18
  * - Legacy `memory={profile,entries}` param still supported for callers
19
19
  * (tests, CLI) that have not migrated.
20
20
  *
21
+ * task-334e additions (R6 §Δ24.5 / §Δ27.3 / §Δ31.4 / §Δ29.3):
22
+ * - `taskCtx` param → renders a `## task_ctx` block with:
23
+ * * task-memory top-5 bodies with semantic shard prefix `[shard]`
24
+ * (no sourceRef — memory_trace opens the trail on demand)
25
+ * * `### related tasks` sub-section — `relatedTaskIds` top-3 (sorted
26
+ * by updatedAt desc) + per-task top-2 memory; ACL-gated by
27
+ * `target.members` ∋ currentVpId (§Δ31.4)
28
+ * * `### summary reminder` soft nudge — condition (§Δ27.3):
29
+ * non-summary msgs ≥ 3 AND since-lastSummary > 15min AND
30
+ * currentVpId == task.initiatorVpId → emits a DYNAMIC hint
31
+ * - `userProfile` param → renders a `## user_profile` block. Stub
32
+ * implementation: when not passed explicitly, we fall back to reading
33
+ * `~/.yeaft/user/profile.json` ({ "content": "…string…" }) if present
34
+ * (§Δ29.3 placeholder until 334l wires real user-memory recall).
35
+ * - `coreMemory` param → renders a `## core_memory` block with recall
36
+ * top-7 memory bodies (no sourceRef) and a trailing meta line pointing
37
+ * at `memory_trace` as the way to open the original message.
38
+ *
39
+ * Hard constraints (task-334e contract):
40
+ * - Does NOT modify engine.js turn loop.
41
+ * - Does NOT implement memory_trace / open_source_message (task-334f).
42
+ * - Does NOT implement task_summary_post (task-334n).
43
+ * - Changes limited to prompts.js + templates/.
44
+ *
21
45
  * Reference: yeaft-unify-system-prompt-budget.md — Static + Dynamic + Context layers
22
46
  */
23
47
 
24
48
  import { readFileSync, existsSync } from 'fs';
25
49
  import { join, dirname } from 'path';
26
50
  import { fileURLToPath } from 'url';
51
+ import { homedir } from 'os';
27
52
 
28
53
  // ─── Template Loading (one-time at startup) ──────────────────────
29
54
 
@@ -159,6 +184,14 @@ const PROMPTS = {
159
184
  profileHeader: '### User Profile',
160
185
  recalledHeader: '### Recalled Memories',
161
186
  compactHeader: '## Conversation History Summary',
187
+ // task-334e — new section headers
188
+ taskCtxHeader: '## task_ctx',
189
+ taskCtxRelatedHeader: '### related tasks',
190
+ taskCtxSummaryReminder: (min, count) =>
191
+ `💡 ${min}min since last summary (+${count} new messages). Consider calling \`task_summary_post\`.`,
192
+ userProfileHeader: '## user_profile',
193
+ coreMemoryHeader: '## core_memory',
194
+ coreMemoryMeta: 'To open the original message behind any entry above, call `memory_trace`.',
162
195
  },
163
196
  zh: {
164
197
  identity: '你是 Yeaft,一个有用的 AI 助手。',
@@ -169,6 +202,14 @@ const PROMPTS = {
169
202
  profileHeader: '### 用户画像',
170
203
  recalledHeader: '### 相关记忆',
171
204
  compactHeader: '## 对话历史摘要',
205
+ // task-334e — new section headers
206
+ taskCtxHeader: '## task_ctx',
207
+ taskCtxRelatedHeader: '### 相关任务',
208
+ taskCtxSummaryReminder: (min, count) =>
209
+ `💡 距上次 summary 已过 ${min}min,新增 ${count} 条消息,建议调用 \`task_summary_post\`。`,
210
+ userProfileHeader: '## user_profile',
211
+ coreMemoryHeader: '## core_memory',
212
+ coreMemoryMeta: '如需原始 message,调 `memory_trace`。',
172
213
  },
173
214
  };
174
215
 
@@ -191,6 +232,35 @@ export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
191
232
  * 5. Skills section
192
233
  * 6. Memory section
193
234
  * 7. Compact summary section
235
+ * 8. Task context section (task-334e §Δ24.5 + §Δ27.3 + §Δ31.4)
236
+ * 9. User profile section (task-334e §Δ29.3 stub)
237
+ * 10. Core memory section (task-334e §Δ24.5)
238
+ *
239
+ * task-334e params:
240
+ * @param {object} [taskCtx] — per-task context
241
+ * @param {string} [taskCtx.taskId]
242
+ * @param {string} [taskCtx.currentVpId] — used for ACL + initiator check
243
+ * @param {string} [taskCtx.initiatorVpId] — task initiator VP id
244
+ * @param {Array<{body:string, shard?:string}>} [taskCtx.memories] — task-memory top-5
245
+ * @param {Array<{id:string, title?:string, members?:string[], updatedAt?:number,
246
+ * memories?:Array<{body:string, shard?:string}>}>} [taskCtx.relatedTasks]
247
+ * — related tasks; we take top-3 by updatedAt desc, top-2 mem each,
248
+ * ACL-gated (members must include currentVpId)
249
+ * @param {object} [taskCtx.summaryReminder]
250
+ * @param {number} [taskCtx.summaryReminder.nonSummaryCount] — msgs since last summary
251
+ * @param {number} [taskCtx.summaryReminder.lastSummaryAt] — epoch ms (0/missing = never)
252
+ * @param {number} [taskCtx.summaryReminder.now] — override clock (tests), default Date.now()
253
+ *
254
+ * @param {string} [userProfile] — explicit profile content (334l path);
255
+ * when omitted we read `~/.yeaft/user/profile.json` `{ content }` as stub.
256
+ * @param {{ entries?: Array<{body:string, shard?:string}>, max?: number }} [coreMemory]
257
+ * — recalled memory entries; we render top-7 bodies + meta line.
258
+ *
259
+ * @param {boolean} [memoryTraceAvailable=false] — feature flag gating the
260
+ * "call memory_trace" meta line in the core_memory block. Defaults to
261
+ * false so we don't point VPs at an unimplemented tool (prev-3 Nit-2 /
262
+ * PM-approved Option A). 334f will flip this to `true` from session.js
263
+ * once `memory_trace` ships; this slice stays decoupled from session.js.
194
264
  *
195
265
  * @param {{
196
266
  * language?: string,
@@ -200,6 +270,10 @@ export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
200
270
  * memoryInjection?: string,
201
271
  * compactSummary?: string,
202
272
  * skillContent?: string,
273
+ * taskCtx?: object,
274
+ * userProfile?: string,
275
+ * coreMemory?: object,
276
+ * memoryTraceAvailable?: boolean,
203
277
  * }} params
204
278
  * @returns {string}
205
279
  */
@@ -211,6 +285,10 @@ export function buildSystemPrompt({
211
285
  memoryInjection,
212
286
  compactSummary,
213
287
  skillContent,
288
+ taskCtx,
289
+ userProfile,
290
+ coreMemory,
291
+ memoryTraceAvailable = false,
214
292
  } = {}) {
215
293
  // Fallback to English for unknown languages
216
294
  const lang = PROMPTS[language] || PROMPTS.en;
@@ -287,5 +365,206 @@ export function buildSystemPrompt({
287
365
  parts.push(`${lang.compactHeader}\n${compactSummary}`);
288
366
  }
289
367
 
368
+ // ─── 8. Task Context Section (task-334e §Δ24.5 / §Δ27.3 / §Δ31.4) ─
369
+ const taskCtxBlock = renderTaskCtx(taskCtx, lang);
370
+ if (taskCtxBlock) parts.push(taskCtxBlock);
371
+
372
+ // ─── 9. User Profile Section (task-334e §Δ29.3 stub) ───
373
+ const profileBlock = renderUserProfile(userProfile, lang);
374
+ if (profileBlock) parts.push(profileBlock);
375
+
376
+ // ─── 10. Core Memory Section (task-334e §Δ24.5) ────────
377
+ const coreMemBlock = renderCoreMemory(coreMemory, lang, memoryTraceAvailable);
378
+ if (coreMemBlock) parts.push(coreMemBlock);
379
+
290
380
  return parts.join('\n\n');
291
381
  }
382
+
383
+ // ─── task-334e helpers ───────────────────────────────────────────
384
+
385
+ const DEFAULT_TASK_MEMORY_TOP = 5;
386
+ const DEFAULT_RELATED_TASK_TOP = 3;
387
+ const DEFAULT_RELATED_TASK_MEMORY_TOP = 2;
388
+ const DEFAULT_CORE_MEMORY_TOP = 7;
389
+ const SUMMARY_REMINDER_MIN_MESSAGES = 3;
390
+ const SUMMARY_REMINDER_MIN_AGE_MS = 15 * 60 * 1000; // 15 minutes
391
+
392
+ /**
393
+ * Render `## task_ctx` block. Never throws on malformed input — missing
394
+ * fields degrade to omission. The block is only emitted when at least one
395
+ * of { memories, relatedTasks (post-ACL), summaryReminder } has content.
396
+ */
397
+ function renderTaskCtx(taskCtx, lang) {
398
+ if (!taskCtx || typeof taskCtx !== 'object') return '';
399
+
400
+ const memLines = renderTaskMemories(taskCtx.memories);
401
+ const relatedLines = renderRelatedTasks(
402
+ taskCtx.relatedTasks,
403
+ taskCtx.currentVpId,
404
+ lang,
405
+ );
406
+ const reminderLine = renderSummaryReminder(taskCtx, lang);
407
+
408
+ if (!memLines && !relatedLines && !reminderLine) return '';
409
+
410
+ const out = [lang.taskCtxHeader];
411
+ if (taskCtx.taskId) out.push(`taskId: ${taskCtx.taskId}`);
412
+ if (memLines) out.push(memLines);
413
+ if (relatedLines) out.push(relatedLines);
414
+ if (reminderLine) out.push(reminderLine);
415
+ return out.join('\n');
416
+ }
417
+
418
+ /** Render task-memory top-N bodies with `[shard]` prefix, no sourceRef. */
419
+ function renderTaskMemories(memories) {
420
+ if (!Array.isArray(memories) || memories.length === 0) return '';
421
+ const lines = [];
422
+ for (const m of memories.slice(0, DEFAULT_TASK_MEMORY_TOP)) {
423
+ const body = typeof m?.body === 'string' ? m.body.trim() : '';
424
+ if (!body) continue;
425
+ const shard = typeof m?.shard === 'string' && m.shard.trim() ? m.shard.trim() : 'general';
426
+ lines.push(`- [${shard}] ${body}`);
427
+ }
428
+ return lines.join('\n');
429
+ }
430
+
431
+ /**
432
+ * Render `### related tasks` sub-block. §Δ31.4 ACL: a related task is only
433
+ * included if `task.members` contains `currentVpId`. Missing `members` is
434
+ * treated as private (excluded) — fail-closed.
435
+ *
436
+ * Ordering: by `updatedAt` desc (undefined treated as 0). Top-3 tasks, top-2
437
+ * memory each.
438
+ */
439
+ function renderRelatedTasks(relatedTasks, currentVpId, lang) {
440
+ if (!Array.isArray(relatedTasks) || relatedTasks.length === 0) return '';
441
+ if (!currentVpId) return ''; // no ACL subject → fail-closed
442
+
443
+ const allowed = relatedTasks.filter((t) => {
444
+ if (!t || typeof t !== 'object') return false;
445
+ const members = Array.isArray(t.members) ? t.members : null;
446
+ if (!members) return false; // fail-closed on missing ACL
447
+ return members.includes(currentVpId);
448
+ });
449
+ if (allowed.length === 0) return '';
450
+
451
+ // Sort by updatedAt desc; undefined coerces to 0 (i.e. pushed to the end).
452
+ const sorted = allowed
453
+ .slice()
454
+ .sort((a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0));
455
+
456
+ const out = [lang.taskCtxRelatedHeader];
457
+ for (const t of sorted.slice(0, DEFAULT_RELATED_TASK_TOP)) {
458
+ const title = typeof t.title === 'string' && t.title.trim() ? t.title.trim() : t.id;
459
+ out.push(`- **${t.id}** · ${title}`);
460
+ const mems = Array.isArray(t.memories) ? t.memories : [];
461
+ for (const m of mems.slice(0, DEFAULT_RELATED_TASK_MEMORY_TOP)) {
462
+ const body = typeof m?.body === 'string' ? m.body.trim() : '';
463
+ if (!body) continue;
464
+ const shard = typeof m?.shard === 'string' && m.shard.trim() ? m.shard.trim() : 'general';
465
+ out.push(` - [${shard}] ${body}`);
466
+ }
467
+ }
468
+ // If every allowed task had zero usable memory, we still keep the header +
469
+ // task list — the related-task identifiers themselves are useful context.
470
+ return out.join('\n');
471
+ }
472
+
473
+ /**
474
+ * Render the summary-reminder line (§Δ27.3).
475
+ *
476
+ * Conditions (ALL must hold):
477
+ * (a) currentVpId === task.initiatorVpId
478
+ * (b) summaryReminder.nonSummaryCount ≥ 3
479
+ * (c) (now - lastSummaryAt) > 15 minutes
480
+ * (lastSummaryAt == 0 / missing is treated as "never summarized":
481
+ * only triggers if nonSummaryCount ≥ 3)
482
+ */
483
+ function renderSummaryReminder(taskCtx, lang) {
484
+ const r = taskCtx && taskCtx.summaryReminder;
485
+ if (!r || typeof r !== 'object') return '';
486
+ if (!taskCtx.currentVpId || !taskCtx.initiatorVpId) return '';
487
+ if (taskCtx.currentVpId !== taskCtx.initiatorVpId) return '';
488
+
489
+ const count = Number(r.nonSummaryCount) || 0;
490
+ if (count < SUMMARY_REMINDER_MIN_MESSAGES) return '';
491
+
492
+ const now = Number(r.now) || Date.now();
493
+ const lastAt = Number(r.lastSummaryAt) || 0;
494
+ const ageMs = lastAt > 0 ? now - lastAt : Number.POSITIVE_INFINITY;
495
+ if (lastAt > 0 && ageMs <= SUMMARY_REMINDER_MIN_AGE_MS) return '';
496
+
497
+ // For "never summarized" (lastAt==0) we report age as nonSummaryCount's
498
+ // session-coarse proxy: we print a dash so the prompt does not lie about
499
+ // an exact minute count. The hint still carries the count of new msgs.
500
+ const minStr = lastAt > 0 ? String(Math.round(ageMs / 60000)) : '—';
501
+ return lang.taskCtxSummaryReminder(minStr, count);
502
+ }
503
+
504
+ /**
505
+ * Render `## user_profile` block. If the caller passed an explicit string,
506
+ * we use it verbatim (that's the 334l path). Otherwise we stub-read from
507
+ * `~/.yeaft/user/profile.json` (`{ content: "..." }`) per §Δ29.3. Any IO
508
+ * error is swallowed — this is best-effort context, not critical path.
509
+ */
510
+ function renderUserProfile(userProfile, lang) {
511
+ let content = '';
512
+ if (typeof userProfile === 'string' && userProfile.trim()) {
513
+ content = userProfile.trim();
514
+ } else if (userProfile == null) {
515
+ content = readUserProfileStub();
516
+ }
517
+ if (!content) return '';
518
+ return `${lang.userProfileHeader}\n${content}`;
519
+ }
520
+
521
+ function readUserProfileStub() {
522
+ try {
523
+ const path = join(homedir(), '.yeaft', 'user', 'profile.json');
524
+ if (!existsSync(path)) return '';
525
+ const raw = readFileSync(path, 'utf8');
526
+ const parsed = JSON.parse(raw);
527
+ if (parsed && typeof parsed.content === 'string') return parsed.content.trim();
528
+ return '';
529
+ } catch {
530
+ // File missing, unreadable, malformed JSON, or non-string content.
531
+ // Stub is best-effort — fall through silently.
532
+ return '';
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Render `## core_memory` block with recall top-7 bodies + (optional) meta line.
538
+ *
539
+ * Accepts `{ entries: [{body,shard}], max?: number }`. Never renders
540
+ * `sourceRef`. The "call memory_trace" meta line is gated by
541
+ * `memoryTraceAvailable` (prev-3 Nit-2 / PM-approved Option A): when the
542
+ * `memory_trace` tool is not yet implemented (334f), we omit the meta line
543
+ * entirely so the LLM does not try to call a non-existent tool. 334f will
544
+ * flip the flag to `true` when it wires session.js.
545
+ */
546
+ function renderCoreMemory(coreMemory, lang, memoryTraceAvailable) {
547
+ if (!coreMemory || typeof coreMemory !== 'object') return '';
548
+ const entries = Array.isArray(coreMemory.entries) ? coreMemory.entries : [];
549
+ if (entries.length === 0) return '';
550
+ const max = Number.isFinite(coreMemory.max) && coreMemory.max > 0
551
+ ? Math.floor(coreMemory.max)
552
+ : DEFAULT_CORE_MEMORY_TOP;
553
+
554
+ const lines = [lang.coreMemoryHeader];
555
+ let shown = 0;
556
+ for (const e of entries) {
557
+ if (shown >= max) break;
558
+ const body = typeof e?.body === 'string' ? e.body.trim() : '';
559
+ if (!body) continue;
560
+ const shard = typeof e?.shard === 'string' && e.shard.trim() ? e.shard.trim() : 'general';
561
+ lines.push(`- [${shard}] ${body}`);
562
+ shown += 1;
563
+ }
564
+ if (shown === 0) return '';
565
+ if (memoryTraceAvailable) {
566
+ lines.push('');
567
+ lines.push(lang.coreMemoryMeta);
568
+ }
569
+ return lines.join('\n');
570
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * task-message.js — R6 §Δ28 / §Δ31.6 task-scoped direct messaging.
3
+ *
4
+ * Replaces the withdrawn R3 `unify_task_private_chat` event with a simple
5
+ * echo-able `task_message` pair:
6
+ *
7
+ * inbound (web → agent): `unify_task_message`
8
+ * { type, groupId, taskId, vpId, text, mentions?, replyTo?, requestId? }
9
+ * outbound (agent → web): `task_message`
10
+ * { type, groupId, taskId, vpId, msgId, text, mentions, replyTo,
11
+ * ts, requestId? }
12
+ *
13
+ * This module owns only the *wire adapter* — the payload is validated,
14
+ * stamped with msgId + ts, and broadcast back so the sender's UI and any
15
+ * other connected views converge on the same record. Persistence + task
16
+ * ACL enforcement are deliberately deferred to task-334l (per PM dispatch:
17
+ * "user_memory_* 实际 ingestion 归 334l"); the parallel task-private
18
+ * storage hook follows the same phasing.
19
+ *
20
+ * Invariants:
21
+ * • Never throws on the WS hot path — bad payloads reply with a
22
+ * `task_message_rejected` event carrying a stable `code` string for
23
+ * UI i18n (mirrors the vp_crud_result contract from 334-ui-g).
24
+ * • The outbound event field order and keys are considered wire-frozen
25
+ * per R6 §Δ31.6 table; additive fields only in future slices.
26
+ */
27
+
28
+ import { nextMsgId, isValidVpId } from './groups/ids.js';
29
+
30
+ /** Known `reject` codes — kept stable so 334-ui-* can key i18n on them. */
31
+ export const TASK_MESSAGE_REJECT_CODES = Object.freeze({
32
+ MISSING_GROUP_ID: 'missing_group_id',
33
+ MISSING_TASK_ID: 'missing_task_id',
34
+ MISSING_VP_ID: 'missing_vp_id',
35
+ INVALID_VP_ID: 'invalid_vp_id',
36
+ EMPTY_TEXT: 'empty_text',
37
+ TEXT_TOO_LONG: 'text_too_long',
38
+ });
39
+
40
+ /** Soft body cap — matches the shard-entry cap used elsewhere in R6 (§Δ26.3). */
41
+ export const MAX_TEXT_LENGTH = 16_384;
42
+
43
+ /**
44
+ * Pure validator. Returns `{ ok: true, payload }` or `{ ok: false, code }`.
45
+ * No IO, no clock reads — safe to unit-test in isolation.
46
+ *
47
+ * @param {any} msg — raw WS message from the web client
48
+ */
49
+ export function validateTaskMessage(msg) {
50
+ if (!msg || typeof msg !== 'object') {
51
+ return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_GROUP_ID };
52
+ }
53
+ const { groupId, taskId, vpId, text } = msg;
54
+ if (!groupId || typeof groupId !== 'string') {
55
+ return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_GROUP_ID };
56
+ }
57
+ if (!taskId || typeof taskId !== 'string') {
58
+ return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_TASK_ID };
59
+ }
60
+ if (!vpId || typeof vpId !== 'string') {
61
+ return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_VP_ID };
62
+ }
63
+ // Allow the reserved `user` sentinel as a speaker here — tasks can have
64
+ // human-user messages alongside VP messages. Any other vpId must pass
65
+ // the full shape check (rejects `all`, `system`, pure digits, etc.).
66
+ if (vpId !== 'user' && !isValidVpId(vpId)) {
67
+ return { ok: false, code: TASK_MESSAGE_REJECT_CODES.INVALID_VP_ID };
68
+ }
69
+ if (typeof text !== 'string' || text.length === 0) {
70
+ return { ok: false, code: TASK_MESSAGE_REJECT_CODES.EMPTY_TEXT };
71
+ }
72
+ if (text.length > MAX_TEXT_LENGTH) {
73
+ return { ok: false, code: TASK_MESSAGE_REJECT_CODES.TEXT_TOO_LONG };
74
+ }
75
+
76
+ const mentions = Array.isArray(msg.mentions)
77
+ ? msg.mentions.filter(m => typeof m === 'string' && m.length > 0).slice(0, 32)
78
+ : [];
79
+ const replyTo = typeof msg.replyTo === 'string' && msg.replyTo.length > 0
80
+ ? msg.replyTo
81
+ : null;
82
+
83
+ return {
84
+ ok: true,
85
+ payload: { groupId, taskId, vpId, text, mentions, replyTo },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Build the outbound `task_message` event from a validated payload.
91
+ * Exposed separately so tests can snapshot the wire shape without
92
+ * needing a live send fn.
93
+ *
94
+ * @param {{groupId:string,taskId:string,vpId:string,text:string,mentions:string[],replyTo:?string}} payload
95
+ * @param {{now?:()=>number, msgId?:()=>string, requestId?:string}} [opts]
96
+ */
97
+ export function buildTaskMessageEvent(payload, opts = {}) {
98
+ const now = typeof opts.now === 'function' ? opts.now : Date.now;
99
+ const mkId = typeof opts.msgId === 'function' ? opts.msgId : nextMsgId;
100
+ const evt = {
101
+ type: 'task_message',
102
+ groupId: payload.groupId,
103
+ taskId: payload.taskId,
104
+ vpId: payload.vpId,
105
+ msgId: mkId(),
106
+ text: payload.text,
107
+ mentions: payload.mentions,
108
+ replyTo: payload.replyTo,
109
+ ts: now(),
110
+ };
111
+ if (opts.requestId) evt.requestId = opts.requestId;
112
+ return evt;
113
+ }
114
+
115
+ /**
116
+ * Build the outbound `task_message_rejected` event.
117
+ * @param {string} code — one of TASK_MESSAGE_REJECT_CODES
118
+ * @param {any} msg — original inbound msg (for requestId echo)
119
+ */
120
+ export function buildTaskMessageRejected(code, msg) {
121
+ const evt = { type: 'task_message_rejected', code };
122
+ if (msg && typeof msg.requestId === 'string') evt.requestId = msg.requestId;
123
+ if (msg && typeof msg.groupId === 'string') evt.groupId = msg.groupId;
124
+ if (msg && typeof msg.taskId === 'string') evt.taskId = msg.taskId;
125
+ return evt;
126
+ }
127
+
128
+ /**
129
+ * WS handler entry point. Validates, echoes, never throws.
130
+ *
131
+ * @param {any} msg
132
+ * @param {(event:object)=>void} sendUnifyEvent
133
+ * @param {{now?:()=>number, msgId?:()=>string}} [opts] — test seams
134
+ */
135
+ export function handleUnifyTaskMessage(msg, sendUnifyEvent, opts = {}) {
136
+ const result = validateTaskMessage(msg);
137
+ if (!result.ok) {
138
+ try { sendUnifyEvent(buildTaskMessageRejected(result.code, msg)); } catch { /* best-effort */ }
139
+ return;
140
+ }
141
+ const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
142
+ const evt = buildTaskMessageEvent(result.payload, { ...opts, requestId });
143
+ try { sendUnifyEvent(evt); } catch { /* never crash WS pipeline */ }
144
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * user-memory.js — R6 §Δ29 user-memory WS event skeleton.
3
+ *
4
+ * PLACEHOLDER ONLY. Actual ingestion / shard write / cross-task recall
5
+ * is owned by task-334l. This file reserves three event names on the
6
+ * wire + acknowledges the request so the web client can ship its
7
+ * emitter code without a dependency-cycle on 334l's storage layer.
8
+ *
9
+ * Wire shapes (frozen by R6 §Δ31.6 table; additive fields only):
10
+ *
11
+ * inbound (web → agent): `unify_user_memory_write`
12
+ * { type, text, tags?, sourceRef?, requestId? }
13
+ *
14
+ * outbound (agent → web): `user_memory_updated`
15
+ * { type, entryId?, reason: 'accepted'|'deferred'|'noop',
16
+ * requestId?, pending?: boolean }
17
+ *
18
+ * outbound (agent → web): `user_memory_removed`
19
+ * { type, entryId, requestId? }
20
+ *
21
+ * Current behaviour: every write is replied with `user_memory_updated`
22
+ * carrying `reason: 'deferred'` and `pending: true` — the frontend
23
+ * treats this as "queued but not yet persisted" and keeps the toast in
24
+ * a muted state. 334l will flip the reason to `'accepted'` with a
25
+ * concrete `entryId` once the ingestion pipeline lands.
26
+ *
27
+ * No removal path is offered yet (would require the storage layer to
28
+ * have produced entryIds first); the handler is exported as a named
29
+ * stub so the router can wire it without a second edit when 334l ships.
30
+ */
31
+
32
+ /** @type {(event:object)=>void | null} */
33
+ let _sendUnifyEvent = null;
34
+
35
+ /**
36
+ * Install a send fn. Called once during session init from web-bridge.js.
37
+ * Exposed so tests can swap in a collector without spinning up a session.
38
+ */
39
+ export function setUserMemorySender(fn) {
40
+ _sendUnifyEvent = (typeof fn === 'function') ? fn : null;
41
+ }
42
+
43
+ /**
44
+ * WS handler: `unify_user_memory_write`.
45
+ *
46
+ * Validates the minimum shape (non-empty string `text`) and replies with
47
+ * a `user_memory_updated` ack carrying `pending: true`. Never throws.
48
+ *
49
+ * @param {any} msg
50
+ * @param {(event:object)=>void} [sendUnifyEvent] — optional override
51
+ * (falls back to the module-level sender installed via setUserMemorySender)
52
+ */
53
+ export function handleUnifyUserMemoryWrite(msg, sendUnifyEvent) {
54
+ const send = sendUnifyEvent || _sendUnifyEvent;
55
+ if (!send) return;
56
+
57
+ const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
58
+ const text = msg && typeof msg.text === 'string' ? msg.text : '';
59
+
60
+ if (!text || text.length === 0) {
61
+ try {
62
+ send({
63
+ type: 'user_memory_updated',
64
+ reason: 'noop',
65
+ pending: false,
66
+ ...(requestId ? { requestId } : {}),
67
+ });
68
+ } catch { /* best-effort */ }
69
+ return;
70
+ }
71
+
72
+ // Placeholder — 334l replaces this with real ingestion.
73
+ try {
74
+ send({
75
+ type: 'user_memory_updated',
76
+ reason: 'deferred',
77
+ pending: true,
78
+ ...(requestId ? { requestId } : {}),
79
+ });
80
+ } catch { /* best-effort */ }
81
+ }
82
+
83
+ /**
84
+ * WS handler: `unify_user_memory_remove` (skeleton).
85
+ *
86
+ * Until 334l lands we have no entries to remove; reply with a noop
87
+ * `user_memory_updated` so the UI can clear its toast.
88
+ */
89
+ export function handleUnifyUserMemoryRemove(msg, sendUnifyEvent) {
90
+ const send = sendUnifyEvent || _sendUnifyEvent;
91
+ if (!send) return;
92
+
93
+ const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
94
+ const entryId = msg && typeof msg.entryId === 'string' ? msg.entryId : null;
95
+
96
+ try {
97
+ send({
98
+ type: 'user_memory_removed',
99
+ entryId,
100
+ pending: true, // 334l will flip once real removal lands
101
+ ...(requestId ? { requestId } : {}),
102
+ });
103
+ } catch { /* best-effort */ }
104
+ }
@@ -28,6 +28,11 @@ 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 { handleUnifyTaskMessage as _handleUnifyTaskMessage } from './task-message.js';
32
+ import {
33
+ handleUnifyUserMemoryWrite as _handleUnifyUserMemoryWrite,
34
+ handleUnifyUserMemoryRemove as _handleUnifyUserMemoryRemove,
35
+ } from './user-memory.js';
31
36
 
32
37
  /** @type {import('./session.js').Session | null} */
33
38
  let session = null;
@@ -198,6 +203,40 @@ export function handleUnifyVpDelete(msg) {
198
203
  }
199
204
  }
200
205
 
206
+ /**
207
+ * task-334h (R6 §Δ28 / §Δ31.6): task-scoped direct message echo.
208
+ *
209
+ * Replaces the withdrawn R3 `unify_task_private_chat`. The agent acts as a
210
+ * relay: validate → stamp msgId + ts → broadcast `task_message`. Real
211
+ * persistence + task ACL lands in 334l.
212
+ *
213
+ * @param {any} msg
214
+ */
215
+ export function handleUnifyTaskMessage(msg) {
216
+ _handleUnifyTaskMessage(msg, sendUnifyEvent);
217
+ }
218
+
219
+ /**
220
+ * task-334h (R6 §Δ29): user-memory write skeleton. Replies with a
221
+ * `user_memory_updated` ack carrying `pending: true`; 334l replaces the
222
+ * stub with real ingestion + entryId.
223
+ *
224
+ * @param {any} msg
225
+ */
226
+ export function handleUnifyUserMemoryWrite(msg) {
227
+ _handleUnifyUserMemoryWrite(msg, sendUnifyEvent);
228
+ }
229
+
230
+ /**
231
+ * task-334h (R6 §Δ29): user-memory remove skeleton. Replies with
232
+ * `user_memory_removed` ack; 334l replaces the stub.
233
+ *
234
+ * @param {any} msg
235
+ */
236
+ export function handleUnifyUserMemoryRemove(msg) {
237
+ _handleUnifyUserMemoryRemove(msg, sendUnifyEvent);
238
+ }
239
+
201
240
  export function handleUnifyVpRead(msg) {
202
241
  const requestId = msg && msg.requestId;
203
242
  const vpId = msg && msg.vpId;