@yeaft/webchat-agent 0.1.867 → 0.1.869

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.
@@ -37,7 +37,7 @@ import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getYeaftSettings, updateYeaftSettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../yeaft/config-api.js';
39
39
  import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
40
- import { handleYeaftGroupChat, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListGroups, handleYeaftCreateGroup, handleYeaftRenameGroup, handleYeaftUpdateGroup, handleYeaftUpdateGroupConfig, handleYeaftArchiveGroup, handleYeaftDeleteGroup, handleYeaftAddMember, handleYeaftRemoveMember, handleYeaftSetDefaultVp, handleYeaftChatSend, handleYeaftListChats, handleYeaftCreateChat, handleYeaftRenameChat, handleYeaftArchiveChat, handleYeaftDeleteChat, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, broadcastLanguageChange } from '../yeaft/web-bridge.js';
40
+ import { handleYeaftGroupChat, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListGroups, handleYeaftCreateGroup, handleYeaftRenameGroup, handleYeaftUpdateGroup, handleYeaftUpdateGroupConfig, handleYeaftArchiveGroup, handleYeaftDeleteGroup, handleYeaftAddMember, handleYeaftRemoveMember, handleYeaftSetDefaultVp, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, broadcastLanguageChange } from '../yeaft/web-bridge.js';
41
41
 
42
42
  export async function handleMessage(msg) {
43
43
  switch (msg.type) {
@@ -565,26 +565,6 @@ export async function handleMessage(msg) {
565
565
  handleYeaftGroupChat(msg);
566
566
  break;
567
567
 
568
- // Yeaft Chat Mode (1:1 single-VP) — separate from group fan-out.
569
- case 'yeaft_chat_send':
570
- await handleYeaftChatSend(msg);
571
- break;
572
- case 'yeaft_list_chats':
573
- await handleYeaftListChats(msg);
574
- break;
575
- case 'yeaft_create_chat':
576
- await handleYeaftCreateChat(msg);
577
- break;
578
- case 'yeaft_rename_chat':
579
- await handleYeaftRenameChat(msg);
580
- break;
581
- case 'yeaft_archive_chat':
582
- await handleYeaftArchiveChat(msg);
583
- break;
584
- case 'yeaft_delete_chat':
585
- await handleYeaftDeleteChat(msg);
586
- break;
587
-
588
568
  // wave-6b: manual dream trigger from VP detail page
589
569
  case 'yeaft_dream_trigger':
590
570
  case 'unify_dream_trigger':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.867",
3
+ "version": "0.1.869",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -45,15 +45,6 @@ import {
45
45
  groupsRoot,
46
46
  } from './groups/group-crud.js';
47
47
  import { openGroup, loadGroupMeta } from './groups/group-store.js';
48
- import {
49
- createChat as createChatStore,
50
- openChat,
51
- listChats,
52
- renameChat as renameChatStore,
53
- archiveChat as archiveChatStore,
54
- deleteChat as deleteChatStore,
55
- touchChat,
56
- } from './chats/chat-store.js';
57
48
  import { loadGroupConfig, resolveGroupConfig, GroupConfigError } from './groups/group-config.js';
58
49
  import { updateGroupConfig } from './groups/group-crud.js';
59
50
  import { createCoordinator } from './groups/coordinator.js';
@@ -1676,53 +1667,12 @@ export function handleYeaftSetDefaultVp(msg) {
1676
1667
  }
1677
1668
  }
1678
1669
 
1679
- // ─── Yeaft Chat Mode ──────────────────────────────────────────
1680
- //
1681
- // 1:1 conversation between the user and a single VP. Reuses the engine,
1682
- // memory, tool, and adapter stacks; bypasses the group coordinator and
1683
- // fan-out machinery entirely. Persisted under `~/.yeaft/chats/<chatId>/`.
1684
-
1685
- const chatEngines = new Map(); // key: `${chatId}::${vpId}` → Engine
1686
- const chatAborts = new Map(); // key: chatId → AbortController for the in-flight turn
1687
-
1688
- function chatsRootFor(yeaftDir) {
1689
- return join(yeaftDir, 'chats');
1690
- }
1691
-
1692
- function snapshotChats(yeaftDir) {
1693
- if (!yeaftDir) return [];
1694
- try { return listChats(chatsRootFor(yeaftDir)); }
1695
- catch { return []; }
1696
- }
1697
-
1698
- function sendChatCrudResult(payload) {
1699
- sendYeaftEvent({ type: 'chat_crud_result', ...payload });
1700
- }
1701
-
1702
- function sendChatSnapshotBroadcast() {
1703
- try {
1704
- const yeaftDir = ctx.CONFIG?.yeaftDir;
1705
- if (!yeaftDir) return;
1706
- sendYeaftEvent({ type: 'chat_list_updated', chats: snapshotChats(yeaftDir) });
1707
- } catch (err) {
1708
- console.warn('[Yeaft] sendChatSnapshotBroadcast failed:', err?.message || err);
1709
- }
1710
- }
1711
-
1712
- function chatErrorPayload(err) {
1713
- return {
1714
- code: (err && err.code) || 'unknown',
1715
- chatId: err && err.chatId,
1716
- message: err && err.message,
1717
- };
1718
- }
1719
1670
 
1720
1671
  /**
1721
1672
  * Build the vpPersona payload threaded into engine.query so the worker
1722
1673
  * system prompt carries the VP's identity/role/persona/planInstruction.
1723
1674
  * Returns null on miss — callers treat that as "use generic prompt".
1724
- * Shared by handleYeaftChatSend and the group fan-out path so the field
1725
- * set stays in lockstep.
1675
+ * Used by the group fan-out path (buildVpQueryOpts).
1726
1676
  */
1727
1677
  function buildVpPersona(vpId) {
1728
1678
  if (!vpId) return null;
@@ -1743,297 +1693,6 @@ function buildVpPersona(vpId) {
1743
1693
  }
1744
1694
  }
1745
1695
 
1746
- export function handleYeaftListChats(msg) {
1747
- const requestId = msg && msg.requestId;
1748
- try {
1749
- const yeaftDir = ctx.CONFIG?.yeaftDir;
1750
- sendChatCrudResult({ op: 'list', requestId, ok: true, chats: snapshotChats(yeaftDir) });
1751
- } catch (err) {
1752
- sendChatCrudResult({ op: 'list', requestId, ok: false, error: chatErrorPayload(err) });
1753
- }
1754
- }
1755
-
1756
- export function handleYeaftCreateChat(msg) {
1757
- const requestId = msg && msg.requestId;
1758
- // Accept fields at top-level (current wire shape) AND inside `payload`
1759
- // (legacy / future) — keeps backwards compatibility cheap.
1760
- const top = msg || {};
1761
- const payload = (msg && msg.payload) || {};
1762
- const displayName = payload.displayName || top.displayName;
1763
- const workDir = payload.workDir || top.workDir;
1764
- const explicitId = payload.id || top.id;
1765
- // Chat mode is 1:1 with the built-in Omni assistant by default. The
1766
- // VP picker is gone from the UI; callers may still override vpId to
1767
- // bind a chat to a specialist, but the default is omni.
1768
- const vpId = payload.vpId || top.vpId || 'omni';
1769
- try {
1770
- const yeaftDir = ctx.CONFIG?.yeaftDir;
1771
- if (!yeaftDir) throw new Error('no yeaft directory configured');
1772
- // Fail loud at create time if the requested VP (default: omni) is
1773
- // not installed — otherwise the chat would silently degrade to a
1774
- // generic prompt at first send.
1775
- if (!buildVpPersona(vpId)) {
1776
- throw new Error(`VP '${vpId}' is not installed`);
1777
- }
1778
- const root = chatsRootFor(yeaftDir);
1779
- const chatId = (explicitId && String(explicitId).trim())
1780
- || `chat_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1781
- const h = createChatStore(root, {
1782
- id: chatId,
1783
- vpId,
1784
- displayName,
1785
- workDir,
1786
- });
1787
- const meta = h.getMeta();
1788
- h.close();
1789
- sendChatCrudResult({ op: 'create', requestId, ok: true, chat: meta });
1790
- sendChatSnapshotBroadcast();
1791
- } catch (err) {
1792
- sendChatCrudResult({ op: 'create', requestId, ok: false, error: chatErrorPayload(err) });
1793
- }
1794
- }
1795
-
1796
- export function handleYeaftRenameChat(msg) {
1797
- const requestId = msg && msg.requestId;
1798
- const chatId = msg && msg.chatId;
1799
- const displayName = msg && msg.displayName;
1800
- try {
1801
- const yeaftDir = ctx.CONFIG?.yeaftDir;
1802
- const chat = renameChatStore(chatsRootFor(yeaftDir), chatId, displayName);
1803
- sendChatCrudResult({ op: 'rename', requestId, ok: true, chat });
1804
- sendChatSnapshotBroadcast();
1805
- } catch (err) {
1806
- sendChatCrudResult({ op: 'rename', requestId, ok: false, error: chatErrorPayload(err) });
1807
- }
1808
- }
1809
-
1810
- export function handleYeaftArchiveChat(msg) {
1811
- const requestId = msg && msg.requestId;
1812
- const chatId = msg && msg.chatId;
1813
- try {
1814
- const yeaftDir = ctx.CONFIG?.yeaftDir;
1815
- const ok = archiveChatStore(chatsRootFor(yeaftDir), chatId);
1816
- if (!ok) throw new Error(`chat ${chatId} not found`);
1817
- invalidateChatRuntime(chatId);
1818
- sendChatCrudResult({ op: 'archive', requestId, ok: true, chatId });
1819
- sendChatSnapshotBroadcast();
1820
- } catch (err) {
1821
- sendChatCrudResult({ op: 'archive', requestId, ok: false, error: chatErrorPayload(err) });
1822
- }
1823
- }
1824
-
1825
- export function handleYeaftDeleteChat(msg) {
1826
- const requestId = msg && msg.requestId;
1827
- const chatId = msg && msg.chatId;
1828
- try {
1829
- const yeaftDir = ctx.CONFIG?.yeaftDir;
1830
- const ok = deleteChatStore(chatsRootFor(yeaftDir), chatId);
1831
- if (!ok) throw new Error(`chat ${chatId} not found`);
1832
- invalidateChatRuntime(chatId);
1833
- sendChatCrudResult({ op: 'delete', requestId, ok: true, chatId });
1834
- sendChatSnapshotBroadcast();
1835
- } catch (err) {
1836
- sendChatCrudResult({ op: 'delete', requestId, ok: false, error: chatErrorPayload(err) });
1837
- }
1838
- }
1839
-
1840
- function invalidateChatRuntime(chatId) {
1841
- if (!chatId) return;
1842
- const prefix = `${chatId}::`;
1843
- for (const k of Array.from(chatEngines.keys())) {
1844
- if (k.startsWith(prefix)) chatEngines.delete(k);
1845
- }
1846
- const ctrl = chatAborts.get(chatId);
1847
- if (ctrl) {
1848
- try { ctrl.abort(); } catch { /* best-effort */ }
1849
- chatAborts.delete(chatId);
1850
- }
1851
- }
1852
-
1853
- function getOrCreateChatEngine(chatId, vpId) {
1854
- const key = `${chatId}::${vpId}`;
1855
- let eng = chatEngines.get(key);
1856
- if (eng) return eng;
1857
- if (!session) throw new Error('getOrCreateChatEngine: session not loaded');
1858
- eng = new Engine({
1859
- adapter: session.adapter,
1860
- trace: session.trace,
1861
- config: session.config,
1862
- conversationStore: session.conversationStore,
1863
- memoryIndex: session.memoryIndex || null,
1864
- amsRegistry: session.amsRegistry,
1865
- toolRegistry: session.toolRegistry,
1866
- skillManager: session.skillManager,
1867
- mcpManager: session.mcpManager,
1868
- yeaftDir: session.yeaftDir,
1869
- toolStats: session.toolStats || null,
1870
- chatId,
1871
- vpId,
1872
- });
1873
- chatEngines.set(key, eng);
1874
- return eng;
1875
- }
1876
-
1877
- /**
1878
- * Send a single user turn in a chat session. 1:1 — no fan-out, no
1879
- * coordinator, no @-mention dispatch. Engine events are forwarded as
1880
- * yeaft_output with `chatId` instead of `groupId`.
1881
- */
1882
- export async function handleYeaftChatSend(msg) {
1883
- if (!msg || typeof msg !== 'object') return;
1884
- const chatId = typeof msg.chatId === 'string' ? msg.chatId.trim() : '';
1885
- const text = typeof msg.text === 'string' ? msg.text : '';
1886
- if (!chatId) return;
1887
- if (!text.trim()) return;
1888
-
1889
- const yeaftDir = ctx.CONFIG?.yeaftDir;
1890
- if (!yeaftDir) {
1891
- sendYeaftOutput({
1892
- type: 'assistant',
1893
- message: { content: [{ type: 'text', text: '⚠️ Yeaft session error: no yeaft directory configured.' }] },
1894
- }, { chatId });
1895
- sendYeaftOutput({ type: 'result', result_text: '' }, { chatId });
1896
- return;
1897
- }
1898
-
1899
- await ensureSessionLoaded();
1900
-
1901
- const root = chatsRootFor(yeaftDir);
1902
- let handle;
1903
- try { handle = openChat(root, chatId); }
1904
- catch (err) {
1905
- sendYeaftOutput({
1906
- type: 'assistant',
1907
- message: { content: [{ type: 'text', text: `⚠️ ${err.message}` }] },
1908
- }, { chatId });
1909
- sendYeaftOutput({ type: 'result', result_text: '' }, { chatId });
1910
- return;
1911
- }
1912
- const meta = handle.getMeta();
1913
- if (!meta) {
1914
- handle.close();
1915
- sendYeaftOutput({
1916
- type: 'assistant',
1917
- message: { content: [{ type: 'text', text: `⚠️ Chat ${chatId} not found.` }] },
1918
- }, { chatId });
1919
- sendYeaftOutput({ type: 'result', result_text: '' }, { chatId });
1920
- return;
1921
- }
1922
- const vpId = meta.vpId;
1923
-
1924
- // Persist attachments + build LLM-side multimodal parts. Same flow as
1925
- // the group path (handleYeaftGroupChat) so chat mode isn't a
1926
- // second-class citizen for images / files.
1927
- const inboundFiles = Array.isArray(msg.files) ? msg.files : [];
1928
- let attachmentBundle = { promptAttachments: [], promptSuffix: '', promptParts: [], failed: [] };
1929
- if (inboundFiles.length > 0) {
1930
- try {
1931
- attachmentBundle = persistYeaftAttachments(inboundFiles, { subdir: `chat-${chatId}` });
1932
- } catch (err) {
1933
- console.warn('[Yeaft] yeaft_chat_send: attachment persist failed', err?.message || err);
1934
- }
1935
- }
1936
- if (Array.isArray(attachmentBundle.failed) && attachmentBundle.failed.length > 0) {
1937
- const detail = attachmentBundle.failed.map((f) => ` - ${f.name}: ${f.error}`).join('\n');
1938
- sendYeaftOutput({
1939
- type: 'assistant',
1940
- message: { content: [{ type: 'text', text: `⚠️ ${attachmentBundle.failed.length} file(s) could not be attached:\n${detail}` }] },
1941
- }, { chatId });
1942
- }
1943
- const persistedAttachments = attachmentsForPersistence(attachmentBundle.promptAttachments);
1944
-
1945
- // Append the user message to the chat log so subsequent reads see it.
1946
- try {
1947
- handle.appendMessage({ from: 'user', role: 'user', text, meta: { attachments: persistedAttachments } });
1948
- } catch (err) {
1949
- console.warn('[Yeaft] chat appendMessage failed:', err?.message || err);
1950
- } finally {
1951
- handle.close();
1952
- }
1953
-
1954
- const turnId = `t_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
1955
- const envelope = { chatId, vpId, turnId };
1956
-
1957
- // Cancel any in-flight turn on this chat — 1:1 has no parallelism.
1958
- const prior = chatAborts.get(chatId);
1959
- if (prior) { try { prior.abort(); } catch { /* ignore */ } }
1960
- const abort = new AbortController();
1961
- chatAborts.set(chatId, abort);
1962
-
1963
- sendYeaftEvent({ type: 'vp_turn_start', vpId, threadId: 'main', turnId, chatId, title: meta.displayName || '' }, envelope);
1964
-
1965
- let queryTimer = null;
1966
- const resetQueryTimer = () => {
1967
- if (queryTimer) clearTimeout(queryTimer);
1968
- queryTimer = setTimeout(() => {
1969
- if (!abort.signal.aborted) {
1970
- try { abort.abort(); } catch { /* ignore */ }
1971
- }
1972
- }, QUERY_TIMEOUT_MS);
1973
- };
1974
- resetQueryTimer();
1975
-
1976
- try {
1977
- const assistantTextParts = [];
1978
- const toolCallsAccum = [];
1979
- const toolResultsAccum = [];
1980
- const thinkingBlocksAccum = [];
1981
- const appendedUserPrompts = [];
1982
-
1983
- const eng = getOrCreateChatEngine(chatId, vpId);
1984
- const handlerCtx = {
1985
- assistantTextParts,
1986
- toolCallsAccum,
1987
- toolResultsAccum,
1988
- thinkingBlocksAccum,
1989
- resetQueryTimer,
1990
- chatId,
1991
- vpId,
1992
- turnId,
1993
- threadId: 'main',
1994
- thread: null,
1995
- appendedUserPrompts,
1996
- };
1997
-
1998
- // Load the VP persona (defaults to omni) so the engine's worker
1999
- // prompt has the right identity/role/persona blocks. Without this,
2000
- // chat mode runs with a generic system prompt instead of Omni.
2001
- const vpPersona = buildVpPersona(vpId);
2002
-
2003
- for await (const event of eng.query({
2004
- prompt: text,
2005
- promptParts: attachmentBundle.promptParts && attachmentBundle.promptParts.length > 0 ? attachmentBundle.promptParts : null,
2006
- signal: abort.signal,
2007
- userAlreadyPersisted: false,
2008
- threadId: 'main',
2009
- senderVpId: vpId,
2010
- vpPersona,
2011
- })) {
2012
- resetQueryTimer();
2013
- handleEngineEvent(event, handlerCtx);
2014
- }
2015
-
2016
- sendYeaftOutput({ type: 'assistant', message: { content: [] } }, envelope);
2017
- sendYeaftOutput({ type: 'result', result_text: '' }, envelope);
2018
-
2019
- try { touchChat(root, chatId); } catch { /* best-effort */ }
2020
- } catch (err) {
2021
- const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
2022
- if (isAbort) {
2023
- sendYeaftOutput({ type: 'result', result_text: '', stopped: true }, envelope);
2024
- } else {
2025
- console.error('[Yeaft] chat-send error:', err);
2026
- sendYeaftOutput({
2027
- type: 'assistant',
2028
- message: { content: [{ type: 'text', text: `⚠️ Chat error: ${err.message}` }] },
2029
- }, envelope);
2030
- sendYeaftOutput({ type: 'result', result_text: '' }, envelope);
2031
- }
2032
- } finally {
2033
- if (queryTimer) clearTimeout(queryTimer);
2034
- if (chatAborts.get(chatId) === abort) chatAborts.delete(chatId);
2035
- }
2036
- }
2037
1696
 
2038
1697
  /**
2039
1698
  * Install the dream pipeline progress sink and runtime settings bridge.
@@ -1,224 +0,0 @@
1
- /**
2
- * chat-store.js — Per-chat persistent store for Yeaft Chat Mode.
3
- *
4
- * Layout (architecture parity with group-store.js):
5
- * ~/.yeaft/chats/<chat-id>/
6
- * chat.json # { id, displayName, vpId, workDir, createdAt, lastTurnAt }
7
- * messages/ # JSONL size-rotation log
8
- * 000001.jsonl
9
- * index.json
10
- *
11
- * A chat is 1:1 with a single VP and persists messages the same way groups
12
- * do — same storage primitives, same jsonl shape — but without a roster.
13
- *
14
- * Hard constraint: no @-mention parsing, no dispatch, no engine awareness.
15
- * This module only owns chat.json + the messages log.
16
- */
17
-
18
- import {
19
- existsSync,
20
- mkdirSync,
21
- readFileSync,
22
- readdirSync,
23
- renameSync,
24
- rmSync,
25
- statSync,
26
- } from 'fs';
27
- import { join } from 'path';
28
- import { writeAtomic, openLog } from '../storage/index.js';
29
- import { nextMsgId, isReservedVpId, ReservedVpIdError, validateVpId, InvalidVpIdError } from '../groups/ids.js';
30
-
31
- const CHAT_FILE = 'chat.json';
32
- const MESSAGES_DIR = 'messages';
33
-
34
- const CHAT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
35
-
36
- /**
37
- * Open (or partially create) the directory for a chat. Returns a handle even
38
- * when chat.json is absent — call createChat() to materialise it.
39
- *
40
- * @param {string} chatsRoot
41
- * @param {string} chatId
42
- * @returns {ChatHandle}
43
- */
44
- export function openChat(chatsRoot, chatId) {
45
- if (!chatId || typeof chatId !== 'string') {
46
- throw new Error('openChat: chatId required (string)');
47
- }
48
- if (!CHAT_ID_RE.test(chatId)) {
49
- throw new Error(`openChat: invalid chatId "${chatId}"`);
50
- }
51
- const dir = join(chatsRoot, chatId);
52
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
53
-
54
- let meta = loadChatMeta(dir);
55
-
56
- const messagesDir = join(dir, MESSAGES_DIR);
57
- if (!existsSync(messagesDir)) mkdirSync(messagesDir, { recursive: true });
58
- const log = openLog(messagesDir);
59
-
60
- return {
61
- dir,
62
- id: chatId,
63
- getMeta() { return meta ? structuredClone(meta) : null; },
64
- saveMeta(next) {
65
- validateMeta(next);
66
- meta = next;
67
- writeAtomic(join(dir, CHAT_FILE), JSON.stringify(meta, null, 2));
68
- },
69
- appendMessage(record) {
70
- if (!record || typeof record !== 'object') {
71
- throw new Error('appendMessage: record required');
72
- }
73
- const leaked = Object.keys(record).filter((k) => typeof k === 'string' && k.startsWith('_'));
74
- if (leaked.length > 0) {
75
- throw new Error(`appendMessage: ephemeral fields leaked into log: ${leaked.join(', ')}`);
76
- }
77
- const stored = {
78
- id: record.id || nextMsgId(),
79
- ts: record.ts || new Date().toISOString(),
80
- from: record.from,
81
- role: record.role || (record.from === 'user' ? 'user' : 'assistant'),
82
- text: record.text ?? '',
83
- taskId: record.taskId || null,
84
- mentions: Array.isArray(record.mentions) ? record.mentions.slice() : [],
85
- meta: record.meta || {},
86
- };
87
- log.append(stored);
88
- return stored;
89
- },
90
- *streamMessages() { yield* log.streamAll(); },
91
- *readMessageRange(firstId, lastId) { yield* log.readRange(firstId, lastId); },
92
- close() { log.close(); },
93
- };
94
- }
95
-
96
- /**
97
- * Create a new chat on disk. Fails if chat.json already exists.
98
- * @param {string} chatsRoot
99
- * @param {{id:string, vpId:string, displayName?:string, workDir?:string, createdAt?:string}} spec
100
- * @returns {ChatHandle}
101
- */
102
- export function createChat(chatsRoot, spec) {
103
- if (!spec || !spec.id) throw new Error('createChat: spec.id required');
104
- if (!spec.vpId) throw new Error('createChat: spec.vpId required');
105
- if (isReservedVpId(spec.vpId)) throw new ReservedVpIdError(spec.vpId);
106
- const verdict = validateVpId(spec.vpId);
107
- if (!verdict.ok) throw new InvalidVpIdError(spec.vpId, verdict.reason);
108
-
109
- const h = openChat(chatsRoot, spec.id);
110
- if (h.getMeta()) {
111
- throw new Error(`chat ${spec.id} already exists`);
112
- }
113
- const meta = {
114
- id: spec.id,
115
- displayName: typeof spec.displayName === 'string' && spec.displayName.trim()
116
- ? spec.displayName.trim() : spec.id,
117
- vpId: spec.vpId,
118
- workDir: typeof spec.workDir === 'string' ? spec.workDir.trim() : '',
119
- createdAt: spec.createdAt || new Date().toISOString(),
120
- lastTurnAt: null,
121
- };
122
- h.saveMeta(meta);
123
- return h;
124
- }
125
-
126
- /** Non-destructive load — returns null if chat.json is missing/corrupt. */
127
- export function loadChatMeta(dir) {
128
- const path = join(dir, CHAT_FILE);
129
- if (!existsSync(path)) return null;
130
- try {
131
- const raw = readFileSync(path, 'utf8');
132
- const parsed = JSON.parse(raw);
133
- validateMeta(parsed);
134
- if (typeof parsed.displayName !== 'string') parsed.displayName = parsed.id;
135
- if (typeof parsed.workDir !== 'string') parsed.workDir = '';
136
- if (parsed.lastTurnAt === undefined) parsed.lastTurnAt = null;
137
- return parsed;
138
- } catch {
139
- return null;
140
- }
141
- }
142
-
143
- /** List every chat directory under `chatsRoot`. */
144
- export function listChats(chatsRoot) {
145
- if (!existsSync(chatsRoot)) return [];
146
- const out = [];
147
- for (const name of readdirSync(chatsRoot)) {
148
- if (name.startsWith('.')) continue;
149
- const p = join(chatsRoot, name);
150
- try {
151
- if (!statSync(p).isDirectory()) continue;
152
- } catch { continue; }
153
- const meta = loadChatMeta(p);
154
- if (meta) out.push(meta);
155
- }
156
- return out;
157
- }
158
-
159
- /** Update a chat's displayName. */
160
- export function renameChat(chatsRoot, chatId, displayName) {
161
- const h = openChat(chatsRoot, chatId);
162
- const meta = h.getMeta();
163
- if (!meta) throw new Error(`renameChat: chat ${chatId} not found`);
164
- const next = { ...meta, displayName: String(displayName || '').trim() || meta.id };
165
- h.saveMeta(next);
166
- h.close();
167
- return next;
168
- }
169
-
170
- /** Stamp lastTurnAt — invoked by web-bridge after a successful turn. */
171
- export function touchChat(chatsRoot, chatId, when = new Date().toISOString()) {
172
- const h = openChat(chatsRoot, chatId);
173
- const meta = h.getMeta();
174
- if (!meta) { h.close(); return null; }
175
- const next = { ...meta, lastTurnAt: when };
176
- h.saveMeta(next);
177
- h.close();
178
- return next;
179
- }
180
-
181
- /** Soft-archive: rename dir to `.archived-<chatId>-<ts>`. */
182
- export function archiveChat(chatsRoot, chatId) {
183
- const src = join(chatsRoot, chatId);
184
- if (!existsSync(src)) return false;
185
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
186
- const dst = join(chatsRoot, `.archived-${chatId}-${ts}`);
187
- renameSync(src, dst);
188
- return true;
189
- }
190
-
191
- /** Permanently delete a chat directory. Use after archive when sure. */
192
- export function deleteChat(chatsRoot, chatId) {
193
- const src = join(chatsRoot, chatId);
194
- if (!existsSync(src)) return false;
195
- rmSync(src, { recursive: true, force: true });
196
- return true;
197
- }
198
-
199
- function validateMeta(meta) {
200
- if (!meta || typeof meta !== 'object') throw new Error('chat.json must be object');
201
- if (!meta.id || typeof meta.id !== 'string') throw new Error('chat.id required');
202
- if (!meta.vpId || typeof meta.vpId !== 'string') throw new Error('chat.vpId required');
203
- if (meta.displayName != null && typeof meta.displayName !== 'string') {
204
- throw new Error('chat.displayName must be string');
205
- }
206
- if (meta.workDir != null && typeof meta.workDir !== 'string') {
207
- throw new Error('chat.workDir must be string');
208
- }
209
- if (meta.lastTurnAt != null && typeof meta.lastTurnAt !== 'string') {
210
- throw new Error('chat.lastTurnAt must be string|null');
211
- }
212
- }
213
-
214
- /**
215
- * @typedef {Object} ChatHandle
216
- * @property {string} dir
217
- * @property {string} id
218
- * @property {() => any} getMeta
219
- * @property {(next:any) => void} saveMeta
220
- * @property {(record:any) => any} appendMessage
221
- * @property {() => Generator<any>} streamMessages
222
- * @property {(first:string,last:string) => Generator<any>} readMessageRange
223
- * @property {() => void} close
224
- */