@yeaft/webchat-agent 0.1.493 → 0.1.494

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, handleUnifyMergeThread } from '../unify/web-bridge.js';
28
+ import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread } from '../unify/web-bridge.js';
29
29
 
30
30
  export async function handleMessage(msg) {
31
31
  switch (msg.type) {
@@ -345,6 +345,10 @@ export async function handleMessage(msg) {
345
345
  handleUnifyMergeThread(msg);
346
346
  break;
347
347
 
348
+ case 'unify_fork_thread':
349
+ handleUnifyForkThread(msg);
350
+ break;
351
+
348
352
  // Expert roles definition (for ExpertPanel detail view)
349
353
  case 'get_expert_roles': {
350
354
  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.493",
3
+ "version": "0.1.494",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -175,7 +175,8 @@ export class ConversationStore {
175
175
  #coldDir; // ~/.yeaft/conversation/cold
176
176
  #indexPath; // ~/.yeaft/conversation/index.md
177
177
  #compactPath; // ~/.yeaft/conversation/compact.md
178
- #nextSeq; // next message sequence number
178
+ #nextSeq; // next message sequence number (global, legacy)
179
+ #nextSeqByThread; // Map<threadId, number> — per-thread counters (task-314)
179
180
 
180
181
  /**
181
182
  * @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
@@ -188,6 +189,7 @@ export class ConversationStore {
188
189
  this.#indexPath = join(dir, 'conversation', 'index.md');
189
190
  this.#compactPath = join(dir, 'conversation', 'compact.md');
190
191
  this.#nextSeq = null;
192
+ this.#nextSeqByThread = new Map();
191
193
 
192
194
  // Ensure directories exist (graceful on permission errors)
193
195
  for (const d of [this.#convDir, this.#msgDir, this.#coldDir]) {
@@ -523,6 +525,201 @@ export class ConversationStore {
523
525
  return rewritten;
524
526
  }
525
527
 
528
+ /**
529
+ * Copy every message on `sourceId` whose sequence id is <= `atMessageId`
530
+ * into new message files stamped with `threadId: targetId` and
531
+ * `sourceThreadId: sourceId` (symmetric with reassignThread's pill).
532
+ *
533
+ * Implementation notes:
534
+ * - Scans both hot (`messages/`) and cold (`cold/`) directories so a
535
+ * fork off a partially-compacted thread still works.
536
+ * - Copies are appended via `append()` so they receive fresh globally
537
+ * unique ids (m{NNNN}) — chronological order is preserved because we
538
+ * sort by filename before copying.
539
+ * - The source is NEVER modified. This is the key invariant separating
540
+ * fork from merge.
541
+ * - Returns the number of messages copied. `atMessageId` is inclusive.
542
+ *
543
+ * @param {string} sourceId
544
+ * @param {string} targetId
545
+ * @param {string} atMessageId — e.g. "m0007"; copy stops after this id
546
+ * @returns {number} copied count
547
+ */
548
+ copyThreadUpTo(sourceId, targetId, atMessageId) {
549
+ if (!sourceId || !targetId || sourceId === targetId) return 0;
550
+ if (!atMessageId || typeof atMessageId !== 'string') return 0;
551
+ // task-314 (rev-2 feedback): the target (forked) thread owns its own
552
+ // per-thread id namespace restarting at m0001. Source files are never
553
+ // touched, so there is no id-collision across threads (each thread
554
+ // loads from its own directory or by threadId filter on the shared
555
+ // legacy dir).
556
+ const targetDir = this.#threadMsgDir(targetId);
557
+ try {
558
+ if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true, mode: 0o755 });
559
+ } catch (err) {
560
+ if (isPermissionError(err)) return 0;
561
+ throw err;
562
+ }
563
+
564
+ // Collect source-thread candidate files from both hot + cold dirs.
565
+ const candidates = [];
566
+ for (const dir of [this.#coldDir, this.#msgDir]) {
567
+ if (!existsSync(dir)) continue;
568
+ let files;
569
+ try {
570
+ files = readdirSync(dir).filter(f => f.endsWith('.md'));
571
+ } catch (err) {
572
+ if (isPermissionError(err)) continue;
573
+ throw err;
574
+ }
575
+ for (const f of files) candidates.push(join(dir, f));
576
+ }
577
+ // Also pick up any already-forked sub-thread dir (chain fork).
578
+ const sourceSubDir = this.#threadMsgDir(sourceId);
579
+ if (existsSync(sourceSubDir)) {
580
+ try {
581
+ for (const f of readdirSync(sourceSubDir).filter(x => x.endsWith('.md'))) {
582
+ candidates.push(join(sourceSubDir, f));
583
+ }
584
+ } catch (err) {
585
+ if (!isPermissionError(err)) throw err;
586
+ }
587
+ }
588
+ // Sort by the "m{NNNN}" basename. For chain-fork, sub-thread ids also
589
+ // restart at m0001 so sorting by basename alone is ambiguous across
590
+ // dirs; but a given source thread stores messages in exactly ONE place
591
+ // (either legacy flat dir OR its sub-dir — see below), so ties never
592
+ // arise. Sorting by (path, seq) is still well-defined.
593
+ candidates.sort((a, b) => {
594
+ const ma = a.match(/m(\d+)\.md$/);
595
+ const mb = b.match(/m(\d+)\.md$/);
596
+ if (!ma || !mb) return 0;
597
+ return parseInt(ma[1], 10) - parseInt(mb[1], 10);
598
+ });
599
+ const cutoffMatch = atMessageId.match(/^m?(\d+)$/);
600
+ if (!cutoffMatch) return 0;
601
+ const cutoffSeq = parseInt(cutoffMatch[1], 10);
602
+
603
+ let copied = 0;
604
+ for (const path of candidates) {
605
+ const fileMatch = path.match(/m(\d+)\.md$/);
606
+ if (!fileMatch) continue;
607
+ const seq = parseInt(fileMatch[1], 10);
608
+ if (seq > cutoffSeq) continue; // do not break — sub-thread dir mixed in may interleave
609
+ let raw;
610
+ try {
611
+ raw = readFileSync(path, 'utf8');
612
+ } catch (err) {
613
+ if (isPermissionError(err)) continue;
614
+ throw err;
615
+ }
616
+ const msg = parseMessage(raw);
617
+ if (!msg || msg.threadId !== sourceId) continue;
618
+ // Mint a fresh per-thread id restarting at m0001 under the target
619
+ // thread's own namespace.
620
+ const nextSeq = this.#getNextThreadSeq(targetId);
621
+ const newId = `m${String(nextSeq).padStart(4, '0')}`;
622
+ const { id: _id, ...rest } = msg;
623
+ const copy = {
624
+ ...rest,
625
+ id: newId,
626
+ threadId: targetId,
627
+ sourceThreadId: msg.sourceThreadId || sourceId,
628
+ time: rest.time || new Date().toISOString(),
629
+ tokens_est: rest.tokens_est || estimateTokens(rest.content || ''),
630
+ };
631
+ const filePath = join(targetDir, `${newId}.md`);
632
+ try {
633
+ writeFileSync(filePath, serializeMessage(copy), { encoding: 'utf8', mode: 0o644 });
634
+ this.#nextSeqByThread.set(targetId, nextSeq + 1);
635
+ copied += 1;
636
+ } catch (err) {
637
+ if (isPermissionError(err)) continue;
638
+ throw err;
639
+ }
640
+ }
641
+ return copied;
642
+ }
643
+
644
+ /**
645
+ * Load messages for a specific thread. Reads from the per-thread subdir
646
+ * (created by forkThread via copyThreadUpTo) if present, otherwise
647
+ * filters the legacy flat `messages/` + `cold/` dirs by `threadId`.
648
+ * Results are sorted chronologically by file sequence number.
649
+ *
650
+ * @param {string} threadId
651
+ * @returns {object[]}
652
+ */
653
+ load(threadId) {
654
+ if (!threadId) return [];
655
+ const subDir = this.#threadMsgDir(threadId);
656
+ if (existsSync(subDir)) {
657
+ // Per-thread namespace: just load the whole dir, filtered by
658
+ // threadId for safety (guards against hand-edited files).
659
+ const out = [];
660
+ for (const f of readdirSync(subDir).filter(x => x.endsWith('.md')).sort()) {
661
+ try {
662
+ const raw = readFileSync(join(subDir, f), 'utf8');
663
+ const msg = parseMessage(raw);
664
+ if (msg && msg.threadId === threadId) out.push(msg);
665
+ } catch (err) {
666
+ if (!isPermissionError(err)) throw err;
667
+ }
668
+ }
669
+ return out;
670
+ }
671
+ // Legacy: messages live in the flat dir stamped with threadId.
672
+ const collected = [];
673
+ for (const dir of [this.#coldDir, this.#msgDir]) {
674
+ if (!existsSync(dir)) continue;
675
+ for (const f of readdirSync(dir).filter(x => x.endsWith('.md'))) {
676
+ try {
677
+ const raw = readFileSync(join(dir, f), 'utf8');
678
+ const msg = parseMessage(raw);
679
+ if (msg && msg.threadId === threadId) collected.push({ msg, f });
680
+ } catch (err) {
681
+ if (!isPermissionError(err)) throw err;
682
+ }
683
+ }
684
+ }
685
+ collected.sort((a, b) => {
686
+ const ma = a.f.match(/m(\d+)\.md$/);
687
+ const mb = b.f.match(/m(\d+)\.md$/);
688
+ return (parseInt(ma?.[1] || '0', 10)) - (parseInt(mb?.[1] || '0', 10));
689
+ });
690
+ return collected.map(x => x.msg);
691
+ }
692
+
693
+ // task-314: per-thread sub-directory for forked threads.
694
+ #threadMsgDir(threadId) {
695
+ return join(this.#convDir, 'threads', threadId, 'messages');
696
+ }
697
+
698
+ // task-314: next per-thread sequence number, restarting at 1 for each
699
+ // new thread. Scans the per-thread sub-dir (not the global flat dir).
700
+ #getNextThreadSeq(threadId) {
701
+ const cached = this.#nextSeqByThread.get(threadId);
702
+ if (cached != null) return cached;
703
+ const dir = this.#threadMsgDir(threadId);
704
+ let maxSeq = 0;
705
+ if (existsSync(dir)) {
706
+ try {
707
+ for (const f of readdirSync(dir)) {
708
+ const m = f.match(/^m(\d+)\.md$/);
709
+ if (m) {
710
+ const s = parseInt(m[1], 10);
711
+ if (s > maxSeq) maxSeq = s;
712
+ }
713
+ }
714
+ } catch (err) {
715
+ if (!isPermissionError(err)) throw err;
716
+ }
717
+ }
718
+ const next = maxSeq + 1;
719
+ this.#nextSeqByThread.set(threadId, next);
720
+ return next;
721
+ }
722
+
526
723
  /**
527
724
  * Load messages from a directory, sorted by filename, limited.
528
725
  * @param {string} dir
@@ -68,6 +68,7 @@ const FLUSH_DEBOUNCE_MS = 8;
68
68
  * @returns {string}
69
69
  */
70
70
  function serializeThread(t) {
71
+ const forkedFrom = serializeForkedFrom(t.forkedFrom);
71
72
  const fm = [
72
73
  '---',
73
74
  `id: ${t.id}`,
@@ -77,6 +78,7 @@ function serializeThread(t) {
77
78
  `status: ${t.status}`,
78
79
  `archived: ${t.archived ? 'true' : 'false'}`,
79
80
  `mergedInto: ${t.mergedInto == null ? 'null' : t.mergedInto}`,
81
+ `forkedFrom: ${forkedFrom}`,
80
82
  `messageCount: ${t.messageCount | 0}`,
81
83
  `lastMessageAt: ${t.lastMessageAt == null ? 'null' : t.lastMessageAt}`,
82
84
  `lastActivityAt: ${t.lastActivityAt == null ? 'null' : t.lastActivityAt}`,
@@ -91,6 +93,31 @@ function serializeThread(t) {
91
93
  return fm.join('\n') + '\n';
92
94
  }
93
95
 
96
+ /**
97
+ * Serialise `forkedFrom` as a single-line scalar so the YAML stays flat.
98
+ * Shape: `{threadId}|{messageId}|{timestamp}`. Null becomes literal `null`.
99
+ */
100
+ function serializeForkedFrom(ff) {
101
+ if (!ff || typeof ff !== 'object') return 'null';
102
+ if (!ff.threadId || !ff.messageId) return 'null';
103
+ const ts = Number.isFinite(ff.timestamp) ? ff.timestamp : 0;
104
+ return `${ff.threadId}|${ff.messageId}|${ts}`;
105
+ }
106
+
107
+ function parseForkedFrom(raw) {
108
+ if (!raw || raw === 'null') return null;
109
+ const parts = String(raw).split('|');
110
+ if (parts.length < 2) return null;
111
+ const [threadId, messageId, tsStr] = parts;
112
+ if (!threadId || !messageId) return null;
113
+ const ts = parseInt(tsStr || '0', 10);
114
+ return {
115
+ threadId,
116
+ messageId,
117
+ timestamp: Number.isFinite(ts) ? ts : 0,
118
+ };
119
+ }
120
+
94
121
  function escapeScalar(v) {
95
122
  if (v == null) return '';
96
123
  // Keep on one physical line; any embedded newline becomes a space so YAML
@@ -134,6 +161,8 @@ function parseThread(raw) {
134
161
  if (!THREAD_STATUSES.includes(record.status)) record.status = 'active';
135
162
  record.archived = record.status === 'archived';
136
163
  if (!('mergedInto' in record)) record.mergedInto = null;
164
+ // forkedFrom is a packed scalar — decode back to {threadId, messageId, timestamp}.
165
+ record.forkedFrom = parseForkedFrom(record.forkedFrom);
137
166
  record.messageCount = Number.isFinite(record.messageCount) ? record.messageCount : 0;
138
167
  record.unread = Number.isFinite(record.unread) ? record.unread : 0;
139
168
  record.preview = body;
@@ -311,6 +340,7 @@ export class ThreadStore {
311
340
  lastActivityAt: null,
312
341
  archived: false,
313
342
  mergedInto: null,
343
+ forkedFrom: null,
314
344
  unread: 0,
315
345
  preview: '',
316
346
  ...base,
@@ -625,6 +655,56 @@ export class ThreadStore {
625
655
  return { source, target };
626
656
  }
627
657
 
658
+ /**
659
+ * Fork a new thread from an existing one at a specific message cursor.
660
+ * ThreadStore only creates the new thread record (with `forkedFrom`
661
+ * pointing at source + message + timestamp); the actual copying of
662
+ * messages up to `atMessageId` is done by ConversationStore.copyThreadUpTo
663
+ * — this keeps the two stores' responsibilities separate.
664
+ *
665
+ * Validation:
666
+ * - source must exist
667
+ * - source must not be archived (forking a dead thread is confusing)
668
+ * - atMessageId must be a non-empty string (actual existence check is
669
+ * the caller's responsibility, since ThreadStore doesn't own messages)
670
+ * - source may itself be a fork (chain is supported)
671
+ *
672
+ * @param {string} sourceId
673
+ * @param {string} atMessageId
674
+ * @param {{ name?: string, title?: string, timestamp?: number }} [opts]
675
+ * @returns {Thread} the newly created forked thread record
676
+ */
677
+ forkThread(sourceId, atMessageId, opts = {}) {
678
+ if (!sourceId) throw new Error('forkThread: sourceId required');
679
+ if (!atMessageId || typeof atMessageId !== 'string') {
680
+ throw new Error('forkThread: atMessageId required');
681
+ }
682
+ const source = this.#threads.get(sourceId);
683
+ if (!source) throw new Error(`thread not found: ${sourceId}`);
684
+ if (source.archived || source.status === 'archived') {
685
+ throw new Error(`forkThread: cannot fork an archived thread (${sourceId})`);
686
+ }
687
+ const now = Date.now();
688
+ const id = `thr-${randomUUID().slice(0, 8)}`;
689
+ const defaultName = source.id === MAIN_THREAD_ID ? 'inbox-fork' : `${source.name}-fork`;
690
+ const thread = this.#newThreadRecord({
691
+ id,
692
+ name: (opts.name && opts.name.trim()) || defaultName,
693
+ goal: source.goal || '',
694
+ parentThreadId: sourceId,
695
+ createdAt: now,
696
+ updatedAt: now,
697
+ forkedFrom: {
698
+ threadId: sourceId,
699
+ messageId: atMessageId,
700
+ timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : now,
701
+ },
702
+ });
703
+ this.#threads.set(id, thread);
704
+ this.#markDirty(id);
705
+ return thread;
706
+ }
707
+
628
708
  setStatus(id, status) {
629
709
  if (!THREAD_STATUSES.includes(status)) {
630
710
  throw new Error(`invalid status: ${status}`);
@@ -626,6 +626,75 @@ export function handleUnifyMergeThread(msg) {
626
626
  sendThreadListUpdate();
627
627
  }
628
628
 
629
+ /**
630
+ * task-314: fork a new thread from an existing one at a specific message.
631
+ * Copies every message up to (and including) `atMessageId` from the source
632
+ * thread onto a fresh thread, stamps `forkedFrom` on the new thread record,
633
+ * and broadcasts `thread_forked` + refreshed thread list. The source is not
634
+ * modified.
635
+ *
636
+ * @param {{ sourceThreadId: string, atMessageId: string, name?: string }} msg
637
+ */
638
+ export function handleUnifyForkThread(msg) {
639
+ if (!session) {
640
+ console.warn('[Unify] unify_fork_thread received before session init — ignored');
641
+ return;
642
+ }
643
+ const { sourceThreadId, atMessageId, name } = msg || {};
644
+ if (!sourceThreadId || !atMessageId) {
645
+ sendUnifyEvent({
646
+ type: 'thread_fork_failed',
647
+ sourceThreadId,
648
+ atMessageId,
649
+ error: 'sourceThreadId and atMessageId required',
650
+ });
651
+ return;
652
+ }
653
+
654
+ let copied = 0;
655
+ let newThread;
656
+ try {
657
+ // 1. Create the fork record on ThreadStore (sets forkedFrom pointer).
658
+ const store = session.threadStore || getThreadStore();
659
+ newThread = store.forkThread(sourceThreadId, atMessageId, { name });
660
+ // 2. Copy messages up to the cursor (inclusive) into the new thread.
661
+ if (session.conversationStore && typeof session.conversationStore.copyThreadUpTo === 'function') {
662
+ copied = session.conversationStore.copyThreadUpTo(
663
+ sourceThreadId,
664
+ newThread.id,
665
+ atMessageId,
666
+ );
667
+ }
668
+ // 3. Roll cached counters on the new thread so the sidebar shows the
669
+ // copied messages without needing a rebuild pass.
670
+ if (copied > 0) {
671
+ newThread.messageCount = copied;
672
+ newThread.lastMessageAt = Date.now();
673
+ newThread.lastActivityAt = newThread.lastMessageAt;
674
+ }
675
+ // 4. Flush so the new thread is durable before the UI refreshes.
676
+ if (typeof store.flush === 'function') store.flush();
677
+ } catch (err) {
678
+ sendUnifyEvent({
679
+ type: 'thread_fork_failed',
680
+ sourceThreadId,
681
+ atMessageId,
682
+ error: err?.message || String(err),
683
+ });
684
+ return;
685
+ }
686
+
687
+ // 5. Broadcast the fork + refreshed thread list.
688
+ sendUnifyEvent({
689
+ type: 'thread_forked',
690
+ sourceThreadId,
691
+ targetThreadId: newThread.id,
692
+ forkedAtMessageId: atMessageId,
693
+ copiedMessages: copied,
694
+ });
695
+ sendThreadListUpdate();
696
+ }
697
+
629
698
  /**
630
699
  * Handle model switch from the web UI.
631
700
  * Updates Engine's config so the next query uses the new model.