@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.
- package/connection/message-router.js +30 -2
- package/context.js +5 -0
- package/package.json +1 -1
- package/unify/config-api.js +89 -0
- package/unify/config.js +61 -0
- package/unify/conversation/persist.js +198 -1
- package/unify/session.js +14 -1
- package/unify/threads/engine-registry.js +69 -1
- package/unify/threads/store.js +112 -1
- package/unify/web-bridge.js +113 -0
|
@@ -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
package/unify/config-api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/unify/threads/store.js
CHANGED
|
@@ -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}`);
|
package/unify/web-bridge.js
CHANGED
|
@@ -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
|
|