@yeaft/webchat-agent 0.1.491 → 0.1.492

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.
@@ -25,7 +25,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
25
25
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
26
26
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
27
27
  import { getLlmConfig, updateLlmConfig } from '../unify/config-api.js';
28
- import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory } from '../unify/web-bridge.js';
28
+ import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread } from '../unify/web-bridge.js';
29
29
 
30
30
  export async function handleMessage(msg) {
31
31
  switch (msg.type) {
@@ -341,6 +341,10 @@ export async function handleMessage(msg) {
341
341
  await resetUnifySession();
342
342
  break;
343
343
 
344
+ case 'unify_merge_thread':
345
+ handleUnifyMergeThread(msg);
346
+ break;
347
+
344
348
  // Expert roles definition (for ExpertPanel detail view)
345
349
  case 'get_expert_roles': {
346
350
  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.491",
3
+ "version": "0.1.492",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -60,6 +60,10 @@ function serializeMessage(msg) {
60
60
  // routing can filter/replay by thread without rescanning JSON blobs.
61
61
  // Defaults to 'main' for legacy messages (see migrate-messages-threadid.js).
62
62
  fm.push(`threadId: ${msg.threadId || 'main'}`);
63
+ // task-313: when a thread is merged into another, the messages keep
64
+ // their original thread id in `sourceThreadId` so the UI can still
65
+ // render a small "#source" pill next to each bubble.
66
+ if (msg.sourceThreadId) fm.push(`sourceThreadId: ${msg.sourceThreadId}`);
63
67
 
64
68
  // Token estimate
65
69
  const content = msg.content || '';
@@ -116,6 +120,7 @@ export function parseMessage(raw) {
116
120
  case 'isError': msg.isError = value === 'true'; break;
117
121
  case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
118
122
  case 'threadId': msg.threadId = value; break;
123
+ case 'sourceThreadId': msg.sourceThreadId = value; break;
119
124
  // toolCalls are multi-line YAML — handled separately below
120
125
  }
121
126
  }
@@ -461,6 +466,63 @@ export class ConversationStore {
461
466
 
462
467
  // ─── Internal ───────────────────────────────────────────
463
468
 
469
+ /**
470
+ * Reassign every message in this store whose `threadId === sourceId`
471
+ * to `targetId`. The original thread id is preserved in
472
+ * `sourceThreadId` so the UI can still render a "#source" pill.
473
+ * Scans both hot (`messages/`) and cold (`cold/`) directories.
474
+ *
475
+ * Idempotent: messages already carrying `sourceThreadId` are not
476
+ * overwritten, and messages not on `sourceId` are skipped.
477
+ *
478
+ * @param {string} sourceId
479
+ * @param {string} targetId
480
+ * @returns {number} number of messages rewritten
481
+ */
482
+ reassignThread(sourceId, targetId) {
483
+ if (!sourceId || !targetId || sourceId === targetId) return 0;
484
+ let rewritten = 0;
485
+ for (const dir of [this.#msgDir, this.#coldDir]) {
486
+ if (!existsSync(dir)) continue;
487
+ let files;
488
+ try {
489
+ files = readdirSync(dir).filter(f => f.endsWith('.md'));
490
+ } catch (err) {
491
+ if (isPermissionError(err)) continue;
492
+ throw err;
493
+ }
494
+ for (const file of files) {
495
+ const path = join(dir, file);
496
+ let raw;
497
+ try {
498
+ raw = readFileSync(path, 'utf8');
499
+ } catch (err) {
500
+ if (isPermissionError(err)) continue;
501
+ throw err;
502
+ }
503
+ const msg = parseMessage(raw);
504
+ if (!msg || msg.threadId !== sourceId) continue;
505
+ // Preserve original thread id for UI pill; only stamp once.
506
+ if (!msg.sourceThreadId) msg.sourceThreadId = sourceId;
507
+ msg.threadId = targetId;
508
+ try {
509
+ writeFileSync(path, serializeMessage(msg), { encoding: 'utf8', mode: 0o644 });
510
+ rewritten += 1;
511
+ } catch (err) {
512
+ if (isPermissionError(err)) {
513
+ if (!_permissionWarned) {
514
+ console.warn(`[Yeaft] Cannot rewrite message ${file}: ${err.code}`);
515
+ _permissionWarned = true;
516
+ }
517
+ continue;
518
+ }
519
+ throw err;
520
+ }
521
+ }
522
+ }
523
+ return rewritten;
524
+ }
525
+
464
526
  /**
465
527
  * Load messages from a directory, sorted by filename, limited.
466
528
  * @param {string} dir
@@ -76,6 +76,7 @@ function serializeThread(t) {
76
76
  `parentThreadId: ${t.parentThreadId == null ? 'null' : t.parentThreadId}`,
77
77
  `status: ${t.status}`,
78
78
  `archived: ${t.archived ? 'true' : 'false'}`,
79
+ `mergedInto: ${t.mergedInto == null ? 'null' : t.mergedInto}`,
79
80
  `messageCount: ${t.messageCount | 0}`,
80
81
  `lastMessageAt: ${t.lastMessageAt == null ? 'null' : t.lastMessageAt}`,
81
82
  `lastActivityAt: ${t.lastActivityAt == null ? 'null' : t.lastActivityAt}`,
@@ -132,6 +133,7 @@ function parseThread(raw) {
132
133
  // Default to safe values if the file pre-dates a field.
133
134
  if (!THREAD_STATUSES.includes(record.status)) record.status = 'active';
134
135
  record.archived = record.status === 'archived';
136
+ if (!('mergedInto' in record)) record.mergedInto = null;
135
137
  record.messageCount = Number.isFinite(record.messageCount) ? record.messageCount : 0;
136
138
  record.unread = Number.isFinite(record.unread) ? record.unread : 0;
137
139
  record.preview = body;
@@ -308,6 +310,7 @@ export class ThreadStore {
308
310
  lastMessageAt: null,
309
311
  lastActivityAt: null,
310
312
  archived: false,
313
+ mergedInto: null,
311
314
  unread: 0,
312
315
  preview: '',
313
316
  ...base,
@@ -543,6 +546,85 @@ export class ThreadStore {
543
546
  this.#markDirty(id);
544
547
  }
545
548
 
549
+ /**
550
+ * Merge the source thread into the target (task-313). The source is
551
+ * marked archived and gets `mergedInto: targetId`; target's cached
552
+ * counters pick up the source's message count and activity. Callers
553
+ * are still expected to reassign the actual messages on disk via
554
+ * `ConversationStore.reassignThread(sourceId, targetId)`.
555
+ *
556
+ * Constraints:
557
+ * - source !== target
558
+ * - both threads must exist
559
+ * - source cannot be the main thread (main cannot be archived)
560
+ * - source cannot already have been merged elsewhere (idempotency)
561
+ *
562
+ * @param {string} sourceId
563
+ * @param {string} targetId
564
+ * @returns {{ source: Thread, target: Thread }}
565
+ */
566
+ mergeThread(sourceId, targetId) {
567
+ if (!sourceId || !targetId) {
568
+ throw new Error('mergeThread: sourceId and targetId required');
569
+ }
570
+ if (sourceId === targetId) {
571
+ throw new Error('mergeThread: cannot merge a thread into itself');
572
+ }
573
+ if (sourceId === MAIN_THREAD_ID) {
574
+ throw new Error('mergeThread: cannot merge the main thread into another');
575
+ }
576
+ const source = this.#threads.get(sourceId);
577
+ if (!source) throw new Error(`thread not found: ${sourceId}`);
578
+ const target = this.#threads.get(targetId);
579
+ if (!target) throw new Error(`thread not found: ${targetId}`);
580
+ if (source.mergedInto) {
581
+ throw new Error(`thread ${sourceId} already merged into ${source.mergedInto}`);
582
+ }
583
+
584
+ const now = Date.now();
585
+
586
+ // Accumulate counters onto target.
587
+ target.messageCount += source.messageCount;
588
+ if (source.lastMessageAt && (!target.lastMessageAt || source.lastMessageAt > target.lastMessageAt)) {
589
+ target.lastMessageAt = source.lastMessageAt;
590
+ }
591
+ if (source.lastActivityAt && (!target.lastActivityAt || source.lastActivityAt > target.lastActivityAt)) {
592
+ target.lastActivityAt = source.lastActivityAt;
593
+ }
594
+ target.updatedAt = now;
595
+ // Revive target if it was archived — a merge is an activity signal.
596
+ if (target.status === 'archived') {
597
+ target.status = 'active';
598
+ target.archived = false;
599
+ }
600
+
601
+ // Mark source as archived + pointer to target.
602
+ source.status = 'archived';
603
+ source.archived = true;
604
+ source.mergedInto = targetId;
605
+ source.updatedAt = now;
606
+
607
+ // If source was current, move the pointer to target.
608
+ if (this.#currentId === sourceId) {
609
+ this.#currentId = targetId;
610
+ }
611
+
612
+ // Drop any task attachment on source (it now belongs to target).
613
+ if (this.#attachments.has(sourceId)) {
614
+ const taskId = this.#attachments.get(sourceId);
615
+ this.#attachments.delete(sourceId);
616
+ // Preserve attachment on target if it had none; otherwise keep target's.
617
+ if (!this.#attachments.has(targetId)) {
618
+ this.#attachments.set(targetId, taskId);
619
+ }
620
+ this.#markAttachmentsDirty();
621
+ }
622
+
623
+ this.#markDirty(sourceId);
624
+ this.#markDirty(targetId);
625
+ return { source, target };
626
+ }
627
+
546
628
  setStatus(id, status) {
547
629
  if (!THREAD_STATUSES.includes(status)) {
548
630
  throw new Error(`invalid status: ${status}`);
@@ -568,6 +568,64 @@ export function handleUnifyModeSwitch(_msg) {
568
568
  console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
569
569
  }
570
570
 
571
+ /**
572
+ * task-313: merge a source thread into a target thread.
573
+ * Reassigns messages, archives source with `mergedInto`, terminates source
574
+ * engine instance, broadcasts `thread_merged` + `thread_list_updated`.
575
+ *
576
+ * @param {{ sourceId: string, targetId: string }} msg
577
+ */
578
+ export function handleUnifyMergeThread(msg) {
579
+ if (!session) {
580
+ console.warn('[Unify] unify_merge_thread received before session init — ignored');
581
+ return;
582
+ }
583
+ const { sourceId, targetId } = msg || {};
584
+ if (!sourceId || !targetId) {
585
+ sendUnifyEvent({ type: 'thread_merge_failed', sourceId, targetId, error: 'sourceId and targetId required' });
586
+ return;
587
+ }
588
+
589
+ let reassigned = 0;
590
+ try {
591
+ // 1. Reassign messages (ConversationStore) — preserves sourceThreadId pill.
592
+ if (session.conversationStore && typeof session.conversationStore.reassignThread === 'function') {
593
+ reassigned = session.conversationStore.reassignThread(sourceId, targetId);
594
+ }
595
+ // 2. Mutate ThreadStore (mergedInto + archived + counter rollup).
596
+ const store = session.threadStore || getThreadStore();
597
+ store.mergeThread(sourceId, targetId);
598
+ // 3. Terminate + forget the source engine instance — releases its slot.
599
+ if (session.engineRegistry) {
600
+ session.engineRegistry.delete(sourceId);
601
+ // If the registry was tracking source as current, move to target.
602
+ if (typeof session.engineRegistry.setCurrent === 'function'
603
+ && session.engineRegistry.currentThreadId === sourceId) {
604
+ session.engineRegistry.setCurrent(targetId);
605
+ }
606
+ }
607
+ // 4. Flush ThreadStore so the merge is durable before the UI refreshes.
608
+ if (typeof store.flush === 'function') store.flush();
609
+ } catch (err) {
610
+ sendUnifyEvent({
611
+ type: 'thread_merge_failed',
612
+ sourceId,
613
+ targetId,
614
+ error: err?.message || String(err),
615
+ });
616
+ return;
617
+ }
618
+
619
+ // 5. Broadcast the merge + refreshed thread list.
620
+ sendUnifyEvent({
621
+ type: 'thread_merged',
622
+ sourceId,
623
+ targetId,
624
+ reassignedMessages: reassigned,
625
+ });
626
+ sendThreadListUpdate();
627
+ }
628
+
571
629
  /**
572
630
  * Handle model switch from the web UI.
573
631
  * Updates Engine's config so the next query uses the new model.