@yeaft/webchat-agent 0.1.493 → 0.1.495

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.
@@ -24,8 +24,8 @@ import {
24
24
  import { sendToServer, flushMessageBuffer } from './buffer.js';
25
25
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
26
26
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
27
- import { getLlmConfig, updateLlmConfig } from '../unify/config-api.js';
28
- import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread } from '../unify/web-bridge.js';
27
+ import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.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) {
@@ -320,6 +320,30 @@ export async function handleMessage(msg) {
320
320
  break;
321
321
  }
322
322
 
323
+ // task-318: Unify runtime settings (thread concurrency + auto-archive).
324
+ // Read/write the nested `unify` section of config.json — LLM fields
325
+ // untouched. On update we broadcast a `unify_settings_updated` event
326
+ // so the UI reflects the new values and in-process consumers
327
+ // (ThreadEngineRegistry, ThreadStore) can reload their caps.
328
+ case 'get_unify_settings': {
329
+ const settings = getUnifySettings(ctx.CONFIG?.yeaftDir);
330
+ sendToServer({ type: 'unify_settings', ...settings });
331
+ break;
332
+ }
333
+
334
+ case 'update_unify_settings': {
335
+ const result = updateUnifySettings(msg.settings || msg.config || {}, ctx.CONFIG?.yeaftDir);
336
+ // Let live consumers pick up the new caps without a session restart.
337
+ // The registry/store are created per-session; we update the exported
338
+ // accessors so subsequent dispatches see the new values.
339
+ if (!result.error && ctx.unifyRuntimeSettings) {
340
+ ctx.unifyRuntimeSettings.maxConcurrentThreads = result.maxConcurrentThreads;
341
+ ctx.unifyRuntimeSettings.autoArchiveIdleDays = result.autoArchiveIdleDays;
342
+ }
343
+ sendToServer({ type: 'unify_settings_updated', ...result });
344
+ break;
345
+ }
346
+
323
347
  // Unify — independent chat via Engine
324
348
  case 'unify_chat':
325
349
  await handleUnifyChat(msg);
@@ -345,6 +369,10 @@ export async function handleMessage(msg) {
345
369
  handleUnifyMergeThread(msg);
346
370
  break;
347
371
 
372
+ case 'unify_fork_thread':
373
+ handleUnifyForkThread(msg);
374
+ break;
375
+
348
376
  // Expert roles definition (for ExpertPanel detail view)
349
377
  case 'get_expert_roles': {
350
378
  const { getExpertRolesDefinition } = await import('../expert-roles.js');
package/context.js CHANGED
@@ -30,4 +30,9 @@ export default {
30
30
  sendToServer: null,
31
31
  // 由 index.js 注册的配置保存函数
32
32
  saveConfig: null,
33
+ // task-318: live Unify runtime caps. Mutated in-place by
34
+ // `update_unify_settings` so in-process consumers (web-bridge) can
35
+ // pick up the new values without a session restart. `null` until the
36
+ // Unify session has finished loading.
37
+ unifyRuntimeSettings: null,
33
38
  };
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.495",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { DEFAULT_YEAFT_DIR } from './init.js';
14
14
  import { normalizeProviderModels, serializeModelForPersistence } from './models.js';
15
+ import { normaliseUnifySection } from './config.js';
15
16
 
16
17
  /**
17
18
  * Read the LLM-relevant portion of config.json.
@@ -126,3 +127,91 @@ export function updateLlmConfig(update, dir) {
126
127
  language: existing.language || 'en',
127
128
  };
128
129
  }
130
+
131
+ // ─── Unify runtime settings (task-318) ────────────────────────────
132
+
133
+ /**
134
+ * Read the Unify-section of config.json. Returns defaults when the file
135
+ * or section is absent. Callers (UI, registry, ThreadStore) rely on a
136
+ * stable shape — `normaliseUnifySection` guarantees that.
137
+ *
138
+ * @param {string} [dir] — Yeaft data directory
139
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number } | { error: string }}
140
+ */
141
+ export function getUnifySettings(dir) {
142
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
143
+ const configPath = join(root, 'config.json');
144
+ if (!existsSync(configPath)) return normaliseUnifySection(null);
145
+ try {
146
+ const raw = readFileSync(configPath, 'utf8');
147
+ const json = JSON.parse(raw);
148
+ return normaliseUnifySection(json.unify);
149
+ } catch (e) {
150
+ return { error: `Failed to read config.json: ${e.message}` };
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Update the Unify-section of config.json. Merges into existing config
156
+ * (LLM provider / model fields are untouched) and validates each field:
157
+ * `maxConcurrentThreads` must be 1..50, `autoArchiveIdleDays` must be
158
+ * 1..3650. Invalid values are rejected outright so the UI sees an error
159
+ * rather than silently reverting — a silent revert would make "I set it
160
+ * to 100 and nothing happened" impossible to debug.
161
+ *
162
+ * @param {{ maxConcurrentThreads?: number, autoArchiveIdleDays?: number }} update
163
+ * @param {string} [dir]
164
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number } | { error: string }}
165
+ */
166
+ export function updateUnifySettings(update, dir) {
167
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
168
+ const configPath = join(root, 'config.json');
169
+
170
+ if (!update || typeof update !== 'object') {
171
+ return { error: 'update payload required' };
172
+ }
173
+
174
+ // Validate before touching the file. We enforce the same clamp bounds
175
+ // that `normaliseUnifySection` uses for reads so round-trip is stable.
176
+ if (update.maxConcurrentThreads !== undefined) {
177
+ const n = Number(update.maxConcurrentThreads);
178
+ if (!Number.isFinite(n) || n < 1 || n > 50) {
179
+ return { error: 'maxConcurrentThreads must be between 1 and 50' };
180
+ }
181
+ }
182
+ if (update.autoArchiveIdleDays !== undefined) {
183
+ const n = Number(update.autoArchiveIdleDays);
184
+ if (!Number.isFinite(n) || n < 1 || n > 3650) {
185
+ return { error: 'autoArchiveIdleDays must be between 1 and 3650' };
186
+ }
187
+ }
188
+
189
+ // Read existing config (preserve LLM and other top-level fields).
190
+ let existing = {};
191
+ if (existsSync(configPath)) {
192
+ try {
193
+ existing = JSON.parse(readFileSync(configPath, 'utf8'));
194
+ } catch {
195
+ existing = {};
196
+ }
197
+ }
198
+
199
+ const prev = normaliseUnifySection(existing.unify);
200
+ const merged = {
201
+ maxConcurrentThreads: update.maxConcurrentThreads !== undefined
202
+ ? Math.floor(Number(update.maxConcurrentThreads))
203
+ : prev.maxConcurrentThreads,
204
+ autoArchiveIdleDays: update.autoArchiveIdleDays !== undefined
205
+ ? Math.floor(Number(update.autoArchiveIdleDays))
206
+ : prev.autoArchiveIdleDays,
207
+ };
208
+ existing.unify = merged;
209
+
210
+ try {
211
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
212
+ } catch (e) {
213
+ return { error: `Failed to write config.json: ${e.message}` };
214
+ }
215
+
216
+ return merged;
217
+ }
package/unify/config.js CHANGED
@@ -33,6 +33,15 @@ const DEFAULTS = {
33
33
  maxOutputTokens: 16384,
34
34
  messageTokenBudget: 8192,
35
35
  maxContinueTurns: 3,
36
+ // task-318: Unify-specific runtime caps. `maxConcurrentThreads` gates
37
+ // how many ThreadEngineRegistry instances may be live at once; dispatch
38
+ // refuses new threads beyond this. The count INCLUDES the always-on
39
+ // `main` thread, so the default 6 gives users 5 user-initiated threads
40
+ // on top of main. `autoArchiveIdleDays` is consumed by ThreadStore's
41
+ // idle-archive pass (task-317 will wire the pass itself; here we just
42
+ // plumb the knob through so the UI can set it).
43
+ unifyMaxConcurrentThreads: 6,
44
+ unifyAutoArchiveIdleDays: 30,
36
45
  };
37
46
 
38
47
  // ─── config.json reader ─────────────────────────────────────────
@@ -134,6 +143,52 @@ function isTruthy(val) {
134
143
  return val === '1' || val === 'true' || val === 'yes';
135
144
  }
136
145
 
146
+ /**
147
+ * Clamp and normalise the `unify` config section. Missing / non-numeric
148
+ * inputs fall back to the DEFAULTS; numeric-but-out-of-range inputs are
149
+ * *clamped* to the valid range rather than silently reverting to the
150
+ * default. This keeps the read path aligned with `updateUnifySettings`,
151
+ * which rejects out-of-range writes up front — a hand-edited
152
+ * `config.json` with `maxConcurrentThreads: 100` now loads as 50 (the
153
+ * nearest valid value) instead of collapsing back to 5.
154
+ *
155
+ * Exported for tests and for `config-api.js` to share the same clamp
156
+ * bounds via `clampUnifyField`.
157
+ *
158
+ * @param {any} raw — jsonConfig.unify (may be undefined / malformed)
159
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number }}
160
+ */
161
+ export function normaliseUnifySection(raw) {
162
+ const out = {
163
+ maxConcurrentThreads: DEFAULTS.unifyMaxConcurrentThreads,
164
+ autoArchiveIdleDays: DEFAULTS.unifyAutoArchiveIdleDays,
165
+ };
166
+ if (!raw || typeof raw !== 'object') return out;
167
+ const mc = clampUnifyField(raw.maxConcurrentThreads, 'maxConcurrentThreads');
168
+ if (mc !== null) out.maxConcurrentThreads = mc;
169
+ const ad = clampUnifyField(raw.autoArchiveIdleDays, 'autoArchiveIdleDays');
170
+ if (ad !== null) out.autoArchiveIdleDays = ad;
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * Clamp a single unify field to its valid range. Returns `null` if the
176
+ * input is not a finite number (so callers can treat it as "not set" and
177
+ * keep the previous value). Centralises the bounds used on both read
178
+ * (`normaliseUnifySection`) and write (`updateUnifySettings` validation).
179
+ *
180
+ * @param {unknown} v
181
+ * @param {'maxConcurrentThreads'|'autoArchiveIdleDays'} field
182
+ * @returns {number | null}
183
+ */
184
+ export function clampUnifyField(v, field) {
185
+ if (v === null || v === undefined) return null;
186
+ const n = Number(v);
187
+ if (!Number.isFinite(n)) return null;
188
+ const [lo, hi] = field === 'maxConcurrentThreads' ? [1, 50] : [1, 3650];
189
+ return Math.min(hi, Math.max(lo, Math.floor(n)));
190
+ }
191
+
137
192
  /**
138
193
  * Build config from legacy config.md + .env + env vars.
139
194
  * @deprecated — used only when config.json doesn't exist.
@@ -160,6 +215,8 @@ function loadLegacyConfig(dir, overrides) {
160
215
  maxOutputTokens: overrides.maxOutputTokens ?? fileConfig.maxOutputTokens ?? DEFAULTS.maxOutputTokens,
161
216
  messageTokenBudget: overrides.messageTokenBudget ?? (env.YEAFT_MESSAGE_TOKEN_BUDGET ? parseInt(env.YEAFT_MESSAGE_TOKEN_BUDGET, 10) : null) ?? fileConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
162
217
  maxContinueTurns: overrides.maxContinueTurns ?? fileConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
218
+ // task-318: legacy path never had the `unify` section — defaults.
219
+ unify: normaliseUnifySection(null),
163
220
  providers: null,
164
221
  primaryModel: null,
165
222
  fastModel: null,
@@ -252,6 +309,10 @@ export function loadConfig(overrides = {}) {
252
309
  messageTokenBudget: overrides.messageTokenBudget ?? jsonConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
253
310
  maxContinueTurns: overrides.maxContinueTurns ?? jsonConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
254
311
 
312
+ // task-318: Unify runtime caps. `unify` is a nested section so we
313
+ // don't pollute the flat config namespace used by chat/crew code.
314
+ unify: normaliseUnifySection(jsonConfig.unify),
315
+
255
316
  // Legacy fields (null when using config.json)
256
317
  apiKey: overrides.apiKey || null,
257
318
  openaiApiKey: null,
@@ -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
package/unify/session.js CHANGED
@@ -131,7 +131,13 @@ export async function loadSession(options = {}) {
131
131
  // ─── 5b. Initialize thread store (task-299 Phase 1) ────
132
132
  // task-307a: now file-backed under ~/.yeaft/threads/. Passing the
133
133
  // yeaftDir switches on disk persistence; read-only mode is honoured.
134
- initThreadStore(yeaftDir, { readOnly: config._readOnly || false, force: true });
134
+ // task-318: forward the Unify autoArchiveIdleDays knob so the
135
+ // archive pass (owned by task-317) can read it off the store.
136
+ initThreadStore(yeaftDir, {
137
+ readOnly: config._readOnly || false,
138
+ force: true,
139
+ idleArchiveDays: config.unify?.autoArchiveIdleDays ?? 0,
140
+ });
135
141
 
136
142
  // ─── 6. Load skills ────────────────────────────────────
137
143
  let skillManager;
@@ -187,6 +193,8 @@ export async function loadSession(options = {}) {
187
193
  skillManager,
188
194
  mcpManager,
189
195
  yeaftDir,
196
+ // task-318: concurrent-thread cap (UI-adjustable via Settings).
197
+ maxConcurrent: config.unify?.maxConcurrentThreads ?? null,
190
198
  });
191
199
  // Seed the main-thread instance so listActive() is non-empty from T=0.
192
200
  engineRegistry.ensure(MAIN_THREAD_ID);
@@ -254,6 +262,11 @@ export async function loadSession(options = {}) {
254
262
  toolRegistry,
255
263
  trace,
256
264
  yeaftDir,
265
+ // task-318 rev-1 fix: expose the live ThreadStore handle so callers
266
+ // (web-bridge, message-router via ctx) can invoke setIdleArchiveDays()
267
+ // on the exact instance that's wired into the dispatcher. Without this
268
+ // export the setter was effectively dead code.
269
+ threadStore: getThreadStore(),
257
270
  status,
258
271
  shutdown,
259
272
  };
@@ -29,6 +29,17 @@
29
29
  import { MAIN_THREAD_ID } from './store.js';
30
30
  import { createEngineInstance } from './engine-instance.js';
31
31
 
32
+ /**
33
+ * Coerce a maxConcurrent input to either a positive integer or null
34
+ * (meaning "no cap"). Keeps the cap logic branch-free elsewhere.
35
+ */
36
+ function normaliseMaxConcurrent(n) {
37
+ if (n === null || n === undefined) return null;
38
+ const v = Number(n);
39
+ if (!Number.isFinite(v) || v <= 0) return null;
40
+ return Math.floor(v);
41
+ }
42
+
32
43
  export class ThreadEngineRegistry {
33
44
  /** @type {Map<string, import('./engine-instance.js').EngineInstance>} */
34
45
  #instances;
@@ -39,18 +50,33 @@ export class ThreadEngineRegistry {
39
50
  /** @type {string} */
40
51
  #currentThreadId;
41
52
 
53
+ /**
54
+ * task-318: soft cap on concurrent live engine instances. When set,
55
+ * `ensure()` refuses to spawn a new instance beyond this many live
56
+ * entries. Already-live instances continue to serve query()s — the
57
+ * cap only gates net-new thread creation. Can be mutated at runtime
58
+ * by `setMaxConcurrent()` so the Settings UI takes effect without a
59
+ * session restart.
60
+ *
61
+ * Null / 0 / negative → unlimited (treat as "no cap").
62
+ * @type {number | null}
63
+ */
64
+ #maxConcurrent;
65
+
42
66
  /**
43
67
  * @param {{
44
68
  * factory: (threadId: string, opts?: object) => import('./engine-instance.js').EngineInstance,
69
+ * maxConcurrent?: number | null,
45
70
  * }} params
46
71
  */
47
- constructor({ factory } = {}) {
72
+ constructor({ factory, maxConcurrent } = {}) {
48
73
  if (typeof factory !== 'function') {
49
74
  throw new Error('ThreadEngineRegistry: factory function is required');
50
75
  }
51
76
  this.#instances = new Map();
52
77
  this.#factory = factory;
53
78
  this.#currentThreadId = MAIN_THREAD_ID;
79
+ this.#maxConcurrent = normaliseMaxConcurrent(maxConcurrent);
54
80
  }
55
81
 
56
82
  /** @returns {string} */
@@ -83,6 +109,22 @@ export class ThreadEngineRegistry {
83
109
  }
84
110
  const existing = this.#instances.get(threadId);
85
111
  if (existing && !existing.terminated) return existing;
112
+ // task-318: enforce concurrency cap before spawning a net-new
113
+ // instance. Replacing a terminated slot for the same threadId does
114
+ // NOT count against the cap — it's the same thread resuming.
115
+ if (!existing && this.#maxConcurrent !== null) {
116
+ const live = this.#countLive();
117
+ if (live >= this.#maxConcurrent) {
118
+ const err = new Error(
119
+ `ThreadEngineRegistry: concurrent thread limit reached (${live}/${this.#maxConcurrent}). ` +
120
+ `Archive or terminate an existing thread before starting a new one.`
121
+ );
122
+ err.code = 'ERR_MAX_CONCURRENT_THREADS';
123
+ err.limit = this.#maxConcurrent;
124
+ err.live = live;
125
+ throw err;
126
+ }
127
+ }
86
128
  const instance = this.#factory(threadId, opts);
87
129
  if (!instance || typeof instance.query !== 'function') {
88
130
  throw new Error(`ThreadEngineRegistry.ensure: factory did not return an EngineInstance for ${threadId}`);
@@ -91,6 +133,31 @@ export class ThreadEngineRegistry {
91
133
  return instance;
92
134
  }
93
135
 
136
+ /**
137
+ * Count currently non-terminated instances — the denominator for the
138
+ * concurrency cap. O(n) scan, n is small (≤ 50 in practice).
139
+ * @returns {number}
140
+ */
141
+ #countLive() {
142
+ let n = 0;
143
+ for (const inst of this.#instances.values()) {
144
+ if (!inst.terminated) n += 1;
145
+ }
146
+ return n;
147
+ }
148
+
149
+ /**
150
+ * task-318: read or update the concurrency cap. `setMaxConcurrent(null)`
151
+ * disables the cap entirely. Existing live instances are NOT terminated
152
+ * if the cap is lowered below their count — the cap only gates new
153
+ * `ensure()` calls going forward.
154
+ * @returns {number | null}
155
+ */
156
+ get maxConcurrent() { return this.#maxConcurrent; }
157
+ setMaxConcurrent(n) {
158
+ this.#maxConcurrent = normaliseMaxConcurrent(n);
159
+ }
160
+
94
161
  /**
95
162
  * Set the current thread marker. Does not lazy-create — caller must
96
163
  * ensure() if they want an instance for an unseen thread.
@@ -188,5 +255,6 @@ export function createThreadEngineRegistry(deps) {
188
255
  ...opts,
189
256
  threadId,
190
257
  }),
258
+ maxConcurrent: deps?.maxConcurrent ?? null,
191
259
  });
192
260
  }
@@ -52,6 +52,18 @@ export const MAIN_THREAD_ID = 'main';
52
52
  /** Valid thread status values. Mirrors design doc §5. */
53
53
  export const THREAD_STATUSES = ['active', 'idle', 'archived'];
54
54
 
55
+ /**
56
+ * task-318: Coerce an idle-archive days input to a non-negative integer
57
+ * (0 disables the feature). Anything invalid falls through to 0 so a
58
+ * malformed config can never silently enable auto-archive.
59
+ */
60
+ function normaliseIdleDays(n) {
61
+ if (n === null || n === undefined) return 0;
62
+ const v = Number(n);
63
+ if (!Number.isFinite(v) || v <= 0) return 0;
64
+ return Math.floor(v);
65
+ }
66
+
55
67
  /** Debounce window for grouped disk writes. Kept short so tests don't hang. */
56
68
  const FLUSH_DEBOUNCE_MS = 8;
57
69
 
@@ -68,6 +80,7 @@ const FLUSH_DEBOUNCE_MS = 8;
68
80
  * @returns {string}
69
81
  */
70
82
  function serializeThread(t) {
83
+ const forkedFrom = serializeForkedFrom(t.forkedFrom);
71
84
  const fm = [
72
85
  '---',
73
86
  `id: ${t.id}`,
@@ -77,6 +90,7 @@ function serializeThread(t) {
77
90
  `status: ${t.status}`,
78
91
  `archived: ${t.archived ? 'true' : 'false'}`,
79
92
  `mergedInto: ${t.mergedInto == null ? 'null' : t.mergedInto}`,
93
+ `forkedFrom: ${forkedFrom}`,
80
94
  `messageCount: ${t.messageCount | 0}`,
81
95
  `lastMessageAt: ${t.lastMessageAt == null ? 'null' : t.lastMessageAt}`,
82
96
  `lastActivityAt: ${t.lastActivityAt == null ? 'null' : t.lastActivityAt}`,
@@ -91,6 +105,31 @@ function serializeThread(t) {
91
105
  return fm.join('\n') + '\n';
92
106
  }
93
107
 
108
+ /**
109
+ * Serialise `forkedFrom` as a single-line scalar so the YAML stays flat.
110
+ * Shape: `{threadId}|{messageId}|{timestamp}`. Null becomes literal `null`.
111
+ */
112
+ function serializeForkedFrom(ff) {
113
+ if (!ff || typeof ff !== 'object') return 'null';
114
+ if (!ff.threadId || !ff.messageId) return 'null';
115
+ const ts = Number.isFinite(ff.timestamp) ? ff.timestamp : 0;
116
+ return `${ff.threadId}|${ff.messageId}|${ts}`;
117
+ }
118
+
119
+ function parseForkedFrom(raw) {
120
+ if (!raw || raw === 'null') return null;
121
+ const parts = String(raw).split('|');
122
+ if (parts.length < 2) return null;
123
+ const [threadId, messageId, tsStr] = parts;
124
+ if (!threadId || !messageId) return null;
125
+ const ts = parseInt(tsStr || '0', 10);
126
+ return {
127
+ threadId,
128
+ messageId,
129
+ timestamp: Number.isFinite(ts) ? ts : 0,
130
+ };
131
+ }
132
+
94
133
  function escapeScalar(v) {
95
134
  if (v == null) return '';
96
135
  // Keep on one physical line; any embedded newline becomes a space so YAML
@@ -134,6 +173,8 @@ function parseThread(raw) {
134
173
  if (!THREAD_STATUSES.includes(record.status)) record.status = 'active';
135
174
  record.archived = record.status === 'archived';
136
175
  if (!('mergedInto' in record)) record.mergedInto = null;
176
+ // forkedFrom is a packed scalar — decode back to {threadId, messageId, timestamp}.
177
+ record.forkedFrom = parseForkedFrom(record.forkedFrom);
137
178
  record.messageCount = Number.isFinite(record.messageCount) ? record.messageCount : 0;
138
179
  record.unread = Number.isFinite(record.unread) ? record.unread : 0;
139
180
  record.preview = body;
@@ -246,10 +287,12 @@ export class ThreadStore {
246
287
  #dirtyAttachments;
247
288
  /** @type {any} NodeJS.Timeout */
248
289
  #flushTimer;
290
+ /** @type {number} task-318: days of inactivity before auto-archive (0 = disabled) */
291
+ #idleArchiveDays;
249
292
 
250
293
  /**
251
294
  * @param {string} [yeaftDir] — Base ~/.yeaft directory. Omit for in-memory mode.
252
- * @param {{ readOnly?: boolean }} [opts]
295
+ * @param {{ readOnly?: boolean, idleArchiveDays?: number }} [opts]
253
296
  */
254
297
  constructor(yeaftDir, opts = {}) {
255
298
  this.#threads = new Map();
@@ -261,6 +304,11 @@ export class ThreadStore {
261
304
  this.#flushTimer = null;
262
305
 
263
306
  this.#readOnly = !!opts.readOnly;
307
+ // task-318: idle-archive threshold (days). Consumed by the archive
308
+ // pass in task-317; here we just accept + expose the knob so the
309
+ // config plumbing is testable end-to-end today. A value ≤ 0 means
310
+ // "no auto-archive" (feature disabled).
311
+ this.#idleArchiveDays = normaliseIdleDays(opts.idleArchiveDays);
264
312
  if (yeaftDir) {
265
313
  this.#dir = join(yeaftDir, 'threads');
266
314
  this.#indexPath = join(this.#dir, 'index.md');
@@ -311,6 +359,7 @@ export class ThreadStore {
311
359
  lastActivityAt: null,
312
360
  archived: false,
313
361
  mergedInto: null,
362
+ forkedFrom: null,
314
363
  unread: 0,
315
364
  preview: '',
316
365
  ...base,
@@ -457,6 +506,18 @@ export class ThreadStore {
457
506
  get currentId() { return this.#currentId; }
458
507
  get size() { return this.#threads.size; }
459
508
 
509
+ /**
510
+ * task-318: idle-archive threshold (days). 0 disables auto-archive.
511
+ * The actual archive pass is owned by task-317 — this accessor is the
512
+ * contract between config and that future consumer, plus a write path
513
+ * so the Settings UI takes effect without restarting the session.
514
+ * @returns {number}
515
+ */
516
+ get idleArchiveDays() { return this.#idleArchiveDays; }
517
+ setIdleArchiveDays(days) {
518
+ this.#idleArchiveDays = normaliseIdleDays(days);
519
+ }
520
+
460
521
  get(id) { return this.#threads.get(id) || null; }
461
522
  list() { return [...this.#threads.values()]; }
462
523
  has(id) { return this.#threads.has(id); }
@@ -625,6 +686,56 @@ export class ThreadStore {
625
686
  return { source, target };
626
687
  }
627
688
 
689
+ /**
690
+ * Fork a new thread from an existing one at a specific message cursor.
691
+ * ThreadStore only creates the new thread record (with `forkedFrom`
692
+ * pointing at source + message + timestamp); the actual copying of
693
+ * messages up to `atMessageId` is done by ConversationStore.copyThreadUpTo
694
+ * — this keeps the two stores' responsibilities separate.
695
+ *
696
+ * Validation:
697
+ * - source must exist
698
+ * - source must not be archived (forking a dead thread is confusing)
699
+ * - atMessageId must be a non-empty string (actual existence check is
700
+ * the caller's responsibility, since ThreadStore doesn't own messages)
701
+ * - source may itself be a fork (chain is supported)
702
+ *
703
+ * @param {string} sourceId
704
+ * @param {string} atMessageId
705
+ * @param {{ name?: string, title?: string, timestamp?: number }} [opts]
706
+ * @returns {Thread} the newly created forked thread record
707
+ */
708
+ forkThread(sourceId, atMessageId, opts = {}) {
709
+ if (!sourceId) throw new Error('forkThread: sourceId required');
710
+ if (!atMessageId || typeof atMessageId !== 'string') {
711
+ throw new Error('forkThread: atMessageId required');
712
+ }
713
+ const source = this.#threads.get(sourceId);
714
+ if (!source) throw new Error(`thread not found: ${sourceId}`);
715
+ if (source.archived || source.status === 'archived') {
716
+ throw new Error(`forkThread: cannot fork an archived thread (${sourceId})`);
717
+ }
718
+ const now = Date.now();
719
+ const id = `thr-${randomUUID().slice(0, 8)}`;
720
+ const defaultName = source.id === MAIN_THREAD_ID ? 'inbox-fork' : `${source.name}-fork`;
721
+ const thread = this.#newThreadRecord({
722
+ id,
723
+ name: (opts.name && opts.name.trim()) || defaultName,
724
+ goal: source.goal || '',
725
+ parentThreadId: sourceId,
726
+ createdAt: now,
727
+ updatedAt: now,
728
+ forkedFrom: {
729
+ threadId: sourceId,
730
+ messageId: atMessageId,
731
+ timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : now,
732
+ },
733
+ });
734
+ this.#threads.set(id, thread);
735
+ this.#markDirty(id);
736
+ return thread;
737
+ }
738
+
628
739
  setStatus(id, status) {
629
740
  if (!THREAD_STATUSES.includes(status)) {
630
741
  throw new Error(`invalid status: ${status}`);
@@ -73,6 +73,38 @@ function sendUnifyEvent(event) {
73
73
  });
74
74
  }
75
75
 
76
+ /**
77
+ * task-318 rev-1 fix: install live-setter bridge between the session's
78
+ * runtime handles (engineRegistry + threadStore) and `ctx.unifyRuntimeSettings`,
79
+ * which message-router's `update_unify_settings` branch reads. Previously
80
+ * that object was null and the setters were dead code. Now every
81
+ * `update_unify_settings` mutation pushes the new caps into the live
82
+ * session within the same tick — no reload required.
83
+ *
84
+ * Exported so tests can drive the same wiring with a mock session.
85
+ *
86
+ * @param {import('./session.js').Session} s
87
+ */
88
+ export function installUnifyRuntimeBridge(s) {
89
+ if (!s) return;
90
+ const initialMax = s.engineRegistry?.maxConcurrent ?? null;
91
+ const initialIdle = s.threadStore?.idleArchiveDays ?? 0;
92
+ ctx.unifyRuntimeSettings = {
93
+ get maxConcurrentThreads() { return s.engineRegistry?.maxConcurrent ?? initialMax; },
94
+ set maxConcurrentThreads(v) {
95
+ if (typeof s.engineRegistry?.setMaxConcurrent === 'function') {
96
+ s.engineRegistry.setMaxConcurrent(v);
97
+ }
98
+ },
99
+ get autoArchiveIdleDays() { return s.threadStore?.idleArchiveDays ?? initialIdle; },
100
+ set autoArchiveIdleDays(v) {
101
+ if (typeof s.threadStore?.setIdleArchiveDays === 'function') {
102
+ s.threadStore.setIdleArchiveDays(v);
103
+ }
104
+ },
105
+ };
106
+ }
107
+
76
108
  /**
77
109
  * task-301 Part 2: push the full thread list snapshot to the web client.
78
110
  * Called after any ThreadStore-mutating tool completes and at turn_end so
@@ -391,6 +423,14 @@ export async function handleUnifyChat(msg) {
391
423
  skipSkills: false,
392
424
  });
393
425
 
426
+ // task-318 rev-1 fix: expose live setters on ctx so message-router's
427
+ // update_unify_settings branch can push the new caps into the
428
+ // registry + thread store without a session reload. Previously this
429
+ // object was null and setMaxConcurrent/setIdleArchiveDays were dead
430
+ // code — the config file was updated on disk but the running
431
+ // session continued with the old caps until next restart.
432
+ installUnifyRuntimeBridge(session);
433
+
394
434
  // Create a stable conversationId for the Unify session
395
435
  unifyConversationId = `unify-${Date.now()}`;
396
436
 
@@ -626,6 +666,75 @@ export function handleUnifyMergeThread(msg) {
626
666
  sendThreadListUpdate();
627
667
  }
628
668
 
669
+ /**
670
+ * task-314: fork a new thread from an existing one at a specific message.
671
+ * Copies every message up to (and including) `atMessageId` from the source
672
+ * thread onto a fresh thread, stamps `forkedFrom` on the new thread record,
673
+ * and broadcasts `thread_forked` + refreshed thread list. The source is not
674
+ * modified.
675
+ *
676
+ * @param {{ sourceThreadId: string, atMessageId: string, name?: string }} msg
677
+ */
678
+ export function handleUnifyForkThread(msg) {
679
+ if (!session) {
680
+ console.warn('[Unify] unify_fork_thread received before session init — ignored');
681
+ return;
682
+ }
683
+ const { sourceThreadId, atMessageId, name } = msg || {};
684
+ if (!sourceThreadId || !atMessageId) {
685
+ sendUnifyEvent({
686
+ type: 'thread_fork_failed',
687
+ sourceThreadId,
688
+ atMessageId,
689
+ error: 'sourceThreadId and atMessageId required',
690
+ });
691
+ return;
692
+ }
693
+
694
+ let copied = 0;
695
+ let newThread;
696
+ try {
697
+ // 1. Create the fork record on ThreadStore (sets forkedFrom pointer).
698
+ const store = session.threadStore || getThreadStore();
699
+ newThread = store.forkThread(sourceThreadId, atMessageId, { name });
700
+ // 2. Copy messages up to the cursor (inclusive) into the new thread.
701
+ if (session.conversationStore && typeof session.conversationStore.copyThreadUpTo === 'function') {
702
+ copied = session.conversationStore.copyThreadUpTo(
703
+ sourceThreadId,
704
+ newThread.id,
705
+ atMessageId,
706
+ );
707
+ }
708
+ // 3. Roll cached counters on the new thread so the sidebar shows the
709
+ // copied messages without needing a rebuild pass.
710
+ if (copied > 0) {
711
+ newThread.messageCount = copied;
712
+ newThread.lastMessageAt = Date.now();
713
+ newThread.lastActivityAt = newThread.lastMessageAt;
714
+ }
715
+ // 4. Flush so the new thread is durable before the UI refreshes.
716
+ if (typeof store.flush === 'function') store.flush();
717
+ } catch (err) {
718
+ sendUnifyEvent({
719
+ type: 'thread_fork_failed',
720
+ sourceThreadId,
721
+ atMessageId,
722
+ error: err?.message || String(err),
723
+ });
724
+ return;
725
+ }
726
+
727
+ // 5. Broadcast the fork + refreshed thread list.
728
+ sendUnifyEvent({
729
+ type: 'thread_forked',
730
+ sourceThreadId,
731
+ targetThreadId: newThread.id,
732
+ forkedAtMessageId: atMessageId,
733
+ copiedMessages: copied,
734
+ });
735
+ sendThreadListUpdate();
736
+ }
737
+
629
738
  /**
630
739
  * Handle model switch from the web UI.
631
740
  * Updates Engine's config so the next query uses the new model.
@@ -668,6 +777,8 @@ export async function handleUnifyLoadHistory(msg) {
668
777
  skipMCP: false,
669
778
  skipSkills: false,
670
779
  });
780
+ // task-318 rev-1 fix: wire live setters; see handleUnifyChat.
781
+ installUnifyRuntimeBridge(session);
671
782
 
672
783
  unifyConversationId = `unify-${Date.now()}`;
673
784
 
@@ -742,6 +853,8 @@ export async function resetUnifySession() {
742
853
  skipMCP: false,
743
854
  skipSkills: false,
744
855
  });
856
+ // task-318 rev-1 fix: wire live setters; see handleUnifyChat.
857
+ installUnifyRuntimeBridge(session);
745
858
 
746
859
  unifyConversationId = `unify-${Date.now()}`;
747
860