@yeaft/webchat-agent 0.1.857 → 0.1.859
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/conversation.js +92 -28
- package/package.json +1 -1
- package/providers/base.js +34 -0
- package/providers/claude-code.js +34 -0
- package/providers/copilot.js +278 -0
- package/providers/index.js +19 -0
- package/yeaft/conversation/persist.js +94 -53
- package/yeaft/conversation/search.js +24 -6
- package/yeaft/init.js +6 -11
package/conversation.js
CHANGED
|
@@ -6,6 +6,7 @@ import { query } from './sdk/index.js';
|
|
|
6
6
|
import { loadSessionHistory } from './history.js';
|
|
7
7
|
import { startClaudeQuery } from './claude.js';
|
|
8
8
|
import { crewSessions, loadCrewIndex } from './crew.js';
|
|
9
|
+
import { getProvider, DEFAULT_PROVIDER, isValidProvider } from './providers/index.js';
|
|
9
10
|
|
|
10
11
|
// 不支持的斜杠命令(真正需要交互式 CLI 的命令)
|
|
11
12
|
const UNSUPPORTED_SLASH_COMMANDS = ['/help', '/bug', '/login', '/logout', '/terminal-setup', '/vim', '/config'];
|
|
@@ -293,7 +294,8 @@ export async function sendConversationList() {
|
|
|
293
294
|
createdAt: state.createdAt,
|
|
294
295
|
processing: !!state.turnActive,
|
|
295
296
|
userId: state.userId,
|
|
296
|
-
username: state.username
|
|
297
|
+
username: state.username,
|
|
298
|
+
provider: state.providerName || DEFAULT_PROVIDER
|
|
297
299
|
};
|
|
298
300
|
list.push(entry);
|
|
299
301
|
}
|
|
@@ -356,33 +358,41 @@ export function sendError(conversationId, message) {
|
|
|
356
358
|
export async function createConversation(msg) {
|
|
357
359
|
const { conversationId, workDir, userId, username, disallowedTools } = msg;
|
|
358
360
|
const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
|
|
361
|
+
const provider = isValidProvider(msg.provider) ? msg.provider : DEFAULT_PROVIDER;
|
|
359
362
|
|
|
360
|
-
console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start)`);
|
|
363
|
+
console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start, provider=${provider})`);
|
|
361
364
|
if (username) console.log(` User: ${username} (${userId})`);
|
|
362
365
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
inputTokens: 0,
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
366
|
+
if (provider === 'claude-code') {
|
|
367
|
+
// Claude path: lazy-init state, real CLI boots on first message.
|
|
368
|
+
ctx.conversations.set(conversationId, {
|
|
369
|
+
query: null,
|
|
370
|
+
inputStream: null,
|
|
371
|
+
workDir: effectiveWorkDir,
|
|
372
|
+
claudeSessionId: null,
|
|
373
|
+
createdAt: Date.now(),
|
|
374
|
+
abortController: null,
|
|
375
|
+
tools: [],
|
|
376
|
+
slashCommands: [],
|
|
377
|
+
model: null,
|
|
378
|
+
userId,
|
|
379
|
+
username,
|
|
380
|
+
providerName: provider,
|
|
381
|
+
disallowedTools: disallowedTools || null,
|
|
382
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 }
|
|
383
|
+
});
|
|
384
|
+
} else {
|
|
385
|
+
// Non-Claude providers own their own state construction.
|
|
386
|
+
const driver = getProvider(provider);
|
|
387
|
+
const state = await driver.start({
|
|
388
|
+
conversationId,
|
|
389
|
+
workDir: effectiveWorkDir,
|
|
390
|
+
resumeSessionId: null,
|
|
391
|
+
userId,
|
|
392
|
+
username,
|
|
393
|
+
});
|
|
394
|
+
state.disallowedTools = disallowedTools || null;
|
|
395
|
+
}
|
|
386
396
|
|
|
387
397
|
ctx.sendToServer({
|
|
388
398
|
type: 'conversation_created',
|
|
@@ -390,6 +400,7 @@ export async function createConversation(msg) {
|
|
|
390
400
|
workDir: effectiveWorkDir,
|
|
391
401
|
userId,
|
|
392
402
|
username,
|
|
403
|
+
provider,
|
|
393
404
|
disallowedTools: disallowedTools || null
|
|
394
405
|
});
|
|
395
406
|
|
|
@@ -414,13 +425,16 @@ export async function createConversation(msg) {
|
|
|
414
425
|
|
|
415
426
|
// ★ Prestart Claude CLI in background to eagerly fetch skills/tools/model
|
|
416
427
|
// Fire-and-forget: failure just degrades to lazy-start behavior
|
|
417
|
-
|
|
428
|
+
if (provider === 'claude-code') {
|
|
429
|
+
prestartClaude(conversationId, effectiveWorkDir, null);
|
|
430
|
+
}
|
|
418
431
|
}
|
|
419
432
|
|
|
420
433
|
// Resume 历史 conversation (延迟启动 Claude,等待用户发送第一条消息)
|
|
421
434
|
export async function resumeConversation(msg) {
|
|
422
435
|
const { conversationId, claudeSessionId, workDir, userId, username, disallowedTools } = msg;
|
|
423
436
|
const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
|
|
437
|
+
const provider = isValidProvider(msg.provider) ? msg.provider : DEFAULT_PROVIDER;
|
|
424
438
|
|
|
425
439
|
console.log(`[Resume] conversationId: ${conversationId}`);
|
|
426
440
|
console.log(`[Resume] claudeSessionId: ${claudeSessionId}`);
|
|
@@ -458,6 +472,7 @@ export async function resumeConversation(msg) {
|
|
|
458
472
|
model: null,
|
|
459
473
|
userId,
|
|
460
474
|
username,
|
|
475
|
+
providerName: provider,
|
|
461
476
|
disallowedTools: disallowedTools || null, // null = 使用全局默认
|
|
462
477
|
usage: {
|
|
463
478
|
inputTokens: 0,
|
|
@@ -468,6 +483,19 @@ export async function resumeConversation(msg) {
|
|
|
468
483
|
}
|
|
469
484
|
});
|
|
470
485
|
|
|
486
|
+
// Non-Claude providers: re-init state via driver so sessionId/providerName are set correctly.
|
|
487
|
+
if (provider !== 'claude-code') {
|
|
488
|
+
const driver = getProvider(provider);
|
|
489
|
+
const state = await driver.start({
|
|
490
|
+
conversationId,
|
|
491
|
+
workDir: effectiveWorkDir,
|
|
492
|
+
resumeSessionId: claudeSessionId || null,
|
|
493
|
+
userId,
|
|
494
|
+
username,
|
|
495
|
+
});
|
|
496
|
+
state.disallowedTools = disallowedTools || null;
|
|
497
|
+
}
|
|
498
|
+
|
|
471
499
|
ctx.sendToServer({
|
|
472
500
|
type: 'conversation_resumed',
|
|
473
501
|
conversationId,
|
|
@@ -475,7 +503,8 @@ export async function resumeConversation(msg) {
|
|
|
475
503
|
workDir: effectiveWorkDir,
|
|
476
504
|
historyMessages,
|
|
477
505
|
userId,
|
|
478
|
-
username
|
|
506
|
+
username,
|
|
507
|
+
provider
|
|
479
508
|
});
|
|
480
509
|
|
|
481
510
|
// 立即发送 agent 级别的 MCP servers 列表
|
|
@@ -498,7 +527,7 @@ export async function resumeConversation(msg) {
|
|
|
498
527
|
// ★ Prestart Claude CLI in background to eagerly fetch skills/tools/model
|
|
499
528
|
// Skip if conversation already has an active query (shouldn't happen, but safety check)
|
|
500
529
|
const resumeState = ctx.conversations.get(conversationId);
|
|
501
|
-
if (!resumeState?.query) {
|
|
530
|
+
if (provider === 'claude-code' && !resumeState?.query) {
|
|
502
531
|
prestartClaude(conversationId, effectiveWorkDir, claudeSessionId);
|
|
503
532
|
}
|
|
504
533
|
}
|
|
@@ -704,6 +733,41 @@ export async function handleUserInput(msg) {
|
|
|
704
733
|
|
|
705
734
|
let state = ctx.conversations.get(conversationId);
|
|
706
735
|
|
|
736
|
+
// ★ Non-Claude providers: dispatch to driver and return
|
|
737
|
+
const providerName = state?.providerName || DEFAULT_PROVIDER;
|
|
738
|
+
if (providerName !== 'claude-code') {
|
|
739
|
+
const driver = getProvider(providerName);
|
|
740
|
+
const effectiveWorkDir = workDir || state?.workDir || ctx.CONFIG.workDir;
|
|
741
|
+
if (!state) {
|
|
742
|
+
state = await driver.start({
|
|
743
|
+
conversationId,
|
|
744
|
+
workDir: effectiveWorkDir,
|
|
745
|
+
resumeSessionId: claudeSessionId || null,
|
|
746
|
+
userId: msg.userId,
|
|
747
|
+
username: msg.username,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
if (workDir) state.workDir = workDir;
|
|
751
|
+
sendOutput(conversationId, { type: 'user', message: { role: 'user', content: prompt } });
|
|
752
|
+
state.turnActive = true;
|
|
753
|
+
sendConversationList();
|
|
754
|
+
try {
|
|
755
|
+
await driver.sendInput(state, prompt, { conversationId, raw: msg });
|
|
756
|
+
} catch (err) {
|
|
757
|
+
sendOutput(conversationId, {
|
|
758
|
+
type: 'result',
|
|
759
|
+
subtype: 'error',
|
|
760
|
+
session_id: state.sessionId || null,
|
|
761
|
+
is_error: true,
|
|
762
|
+
error: `${providerName} error: ${err?.message || err}`,
|
|
763
|
+
});
|
|
764
|
+
} finally {
|
|
765
|
+
state.turnActive = false;
|
|
766
|
+
sendConversationList();
|
|
767
|
+
}
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
707
771
|
// 如果没有活跃的查询,启动新的
|
|
708
772
|
if (!state || !state.query || !state.inputStream) {
|
|
709
773
|
const resumeSessionId = claudeSessionId || state?.claudeSessionId || null;
|
package/package.json
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat provider abstraction.
|
|
3
|
+
*
|
|
4
|
+
* Wire-protocol note: `claude_output` is now a PROTOCOL name, not a
|
|
5
|
+
* vendor name. Every driver MUST emit events on
|
|
6
|
+
* `ctx.sendToServer({ type: 'claude_output', conversationId, data })`,
|
|
7
|
+
* where `data` follows the Claude stream-json envelope:
|
|
8
|
+
* - { type: 'assistant', message: { role, content: [...] } }
|
|
9
|
+
* - { type: 'user', message: { role, content: [...] } }
|
|
10
|
+
* - { type: 'result', subtype, session_id, is_error, ... }
|
|
11
|
+
* - { type: 'system', subtype, ... }
|
|
12
|
+
*
|
|
13
|
+
* Non-Claude drivers (e.g. Copilot) MUST translate their native event
|
|
14
|
+
* streams into this envelope so the existing renderer needs no changes.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {Object} ChatProvider
|
|
17
|
+
* @property {string} name
|
|
18
|
+
* @property {(opts: StartOpts) => Promise<Object>} start
|
|
19
|
+
* @property {(state: Object, prompt: string, opts?: Object) => Promise<void>} sendInput
|
|
20
|
+
* @property {(state: Object) => void} abort
|
|
21
|
+
*
|
|
22
|
+
* @typedef {Object} StartOpts
|
|
23
|
+
* @property {string} conversationId
|
|
24
|
+
* @property {string} workDir
|
|
25
|
+
* @property {string|null} [resumeSessionId]
|
|
26
|
+
* @property {string} [userId]
|
|
27
|
+
* @property {string} [username]
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const PROVIDER_NAMES = Object.freeze(['claude-code', 'copilot']);
|
|
31
|
+
export const DEFAULT_PROVIDER = 'claude-code';
|
|
32
|
+
export function isValidProvider(name) {
|
|
33
|
+
return typeof name === 'string' && PROVIDER_NAMES.includes(name);
|
|
34
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { startClaudeQuery } from '../claude.js';
|
|
2
|
+
|
|
3
|
+
export const name = 'claude-code';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Start (or resume) a Claude Code CLI session.
|
|
7
|
+
* Returns the same state object that startClaudeQuery stores in ctx.conversations.
|
|
8
|
+
*/
|
|
9
|
+
export async function start(opts) {
|
|
10
|
+
const state = await startClaudeQuery(
|
|
11
|
+
opts.conversationId,
|
|
12
|
+
opts.workDir,
|
|
13
|
+
opts.resumeSessionId || null
|
|
14
|
+
);
|
|
15
|
+
state.providerName = name;
|
|
16
|
+
return state;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Claude CLI handles input via the persistent stdin Stream that
|
|
21
|
+
* conversation.js manages directly, so this driver's sendInput is a no-op.
|
|
22
|
+
* conversation.js's existing branch keeps owning the Claude path.
|
|
23
|
+
*/
|
|
24
|
+
export async function sendInput(_state, _prompt, _opts) {
|
|
25
|
+
/* handled inline by conversation.js for the Claude branch */
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function abort(state) {
|
|
29
|
+
if (state?.abortController) {
|
|
30
|
+
try { state.abortController.abort(); } catch { /* noop */ }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default { name, start, sendInput, abort };
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import ctx from '../context.js';
|
|
4
|
+
|
|
5
|
+
export const name = 'copilot';
|
|
6
|
+
|
|
7
|
+
const COPILOT_BIN = process.env.COPILOT_BIN || 'copilot';
|
|
8
|
+
// Opt-in only: --allow-all-tools is a destructive footgun by default in a
|
|
9
|
+
// multi-tenant agent. Set COPILOT_YOLO=1 (and only if you know what you're
|
|
10
|
+
// doing) to skip Copilot's tool prompts.
|
|
11
|
+
const YOLO = process.env.COPILOT_YOLO === '1';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Start (or resume) a Copilot session.
|
|
15
|
+
* Copilot's `-p` mode is one-shot per turn, so "start" just prepares state.
|
|
16
|
+
* Each sendInput() spawns one `copilot -p ...` child with the same
|
|
17
|
+
* --session-id for continuity.
|
|
18
|
+
*/
|
|
19
|
+
export async function start(opts) {
|
|
20
|
+
const conversationId = opts.conversationId;
|
|
21
|
+
// Tear down any prior entry so we don't leak children.
|
|
22
|
+
const prior = ctx.conversations.get(conversationId);
|
|
23
|
+
if (prior?.copilotChild) {
|
|
24
|
+
try { prior.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sessionId = opts.resumeSessionId || randomUUID();
|
|
28
|
+
const state = {
|
|
29
|
+
providerName: name,
|
|
30
|
+
conversationId: opts.conversationId,
|
|
31
|
+
query: null,
|
|
32
|
+
inputStream: null,
|
|
33
|
+
workDir: opts.workDir,
|
|
34
|
+
claudeSessionId: sessionId,
|
|
35
|
+
sessionId,
|
|
36
|
+
createdAt: prior?.createdAt || Date.now(),
|
|
37
|
+
abortController: null,
|
|
38
|
+
tools: [],
|
|
39
|
+
slashCommands: [],
|
|
40
|
+
model: 'copilot',
|
|
41
|
+
userId: opts.userId,
|
|
42
|
+
username: opts.username,
|
|
43
|
+
disallowedTools: prior?.disallowedTools || null,
|
|
44
|
+
copilotChild: null,
|
|
45
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
|
|
46
|
+
};
|
|
47
|
+
ctx.conversations.set(conversationId, state);
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function sendInput(state, prompt, opts = {}) {
|
|
52
|
+
const conversationId = opts.conversationId || state.conversationId;
|
|
53
|
+
if (!conversationId) throw new Error('copilot: conversationId required');
|
|
54
|
+
if (!state.sessionId) state.sessionId = randomUUID();
|
|
55
|
+
|
|
56
|
+
// Abort any in-flight turn.
|
|
57
|
+
if (state.copilotChild) {
|
|
58
|
+
try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
59
|
+
state.copilotChild = null;
|
|
60
|
+
}
|
|
61
|
+
const abortController = new AbortController();
|
|
62
|
+
state.abortController = abortController;
|
|
63
|
+
state.turnActive = true;
|
|
64
|
+
state.turnResultReceived = false;
|
|
65
|
+
|
|
66
|
+
const args = ['-p', prompt, '--output-format', 'json', '-C', state.workDir, '--session-id', state.sessionId];
|
|
67
|
+
if (YOLO) args.push('--allow-all-tools');
|
|
68
|
+
|
|
69
|
+
let child;
|
|
70
|
+
try {
|
|
71
|
+
child = spawn(COPILOT_BIN, args, {
|
|
72
|
+
cwd: state.workDir,
|
|
73
|
+
env: process.env,
|
|
74
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
75
|
+
});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
sendOutput(conversationId, {
|
|
78
|
+
type: 'result',
|
|
79
|
+
subtype: 'error',
|
|
80
|
+
session_id: state.sessionId,
|
|
81
|
+
is_error: true,
|
|
82
|
+
error: `copilot spawn failed: ${err?.message || err}`,
|
|
83
|
+
});
|
|
84
|
+
state.turnActive = false;
|
|
85
|
+
ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
state.copilotChild = child;
|
|
89
|
+
|
|
90
|
+
// Pre-register error handler so async ENOENT from spawn is never unhandled.
|
|
91
|
+
child.on('error', (err) => {
|
|
92
|
+
sendOutput(conversationId, {
|
|
93
|
+
type: 'result',
|
|
94
|
+
subtype: 'error',
|
|
95
|
+
session_id: state.sessionId,
|
|
96
|
+
is_error: true,
|
|
97
|
+
error: `copilot process error: ${err?.message || err}`,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let killTimer = null;
|
|
102
|
+
abortController.signal.addEventListener('abort', () => {
|
|
103
|
+
try { child.kill('SIGTERM'); } catch { /* noop */ }
|
|
104
|
+
// Escalate to SIGKILL if the child ignores SIGTERM, so the awaited
|
|
105
|
+
// close promise resolves and the next turn isn't blocked forever.
|
|
106
|
+
killTimer = setTimeout(() => {
|
|
107
|
+
try { child.kill('SIGKILL'); } catch { /* noop */ }
|
|
108
|
+
}, 5000);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let stderrBuf = '';
|
|
112
|
+
const STDERR_CAP = 64 * 1024;
|
|
113
|
+
let sawResult = false;
|
|
114
|
+
|
|
115
|
+
const parser = createNdjsonParser((evt) => {
|
|
116
|
+
const envelopes = translateCopilotEvent(evt, state);
|
|
117
|
+
for (const e of envelopes) {
|
|
118
|
+
sendOutput(conversationId, e);
|
|
119
|
+
if (e?.type === 'result') sawResult = true;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
child.stdout.on('data', (chunk) => parser.push(chunk));
|
|
124
|
+
child.stderr.on('data', (chunk) => {
|
|
125
|
+
if (stderrBuf.length < STDERR_CAP) {
|
|
126
|
+
stderrBuf += chunk.toString('utf8').slice(0, STDERR_CAP - stderrBuf.length);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await new Promise((resolve) => {
|
|
131
|
+
child.on('close', (code) => {
|
|
132
|
+
if (killTimer) clearTimeout(killTimer);
|
|
133
|
+
parser.flush();
|
|
134
|
+
if (!sawResult) {
|
|
135
|
+
const ok = code === 0;
|
|
136
|
+
sendOutput(conversationId, {
|
|
137
|
+
type: 'result',
|
|
138
|
+
subtype: ok ? 'success' : 'error',
|
|
139
|
+
session_id: state.sessionId,
|
|
140
|
+
is_error: !ok,
|
|
141
|
+
error: ok ? undefined : (stderrBuf.trim().slice(0, 2000) || `copilot exited with code ${code}`),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
state.copilotChild = null;
|
|
145
|
+
state.turnActive = false;
|
|
146
|
+
ctx.sendToServer({
|
|
147
|
+
type: 'turn_completed',
|
|
148
|
+
conversationId,
|
|
149
|
+
claudeSessionId: state.sessionId,
|
|
150
|
+
workDir: state.workDir,
|
|
151
|
+
});
|
|
152
|
+
resolve();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function abort(state) {
|
|
158
|
+
if (state?.abortController) {
|
|
159
|
+
try { state.abortController.abort(); } catch { /* noop */ }
|
|
160
|
+
}
|
|
161
|
+
if (state?.copilotChild) {
|
|
162
|
+
try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------- internals ----------
|
|
167
|
+
|
|
168
|
+
function sendOutput(conversationId, data) {
|
|
169
|
+
ctx.sendToServer({ type: 'claude_output', conversationId, data });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function createNdjsonParser(onEvent) {
|
|
173
|
+
let buf = '';
|
|
174
|
+
return {
|
|
175
|
+
push(chunk) {
|
|
176
|
+
buf += chunk.toString('utf8');
|
|
177
|
+
let idx;
|
|
178
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
179
|
+
const line = buf.slice(0, idx).trim();
|
|
180
|
+
buf = buf.slice(idx + 1);
|
|
181
|
+
if (!line) continue;
|
|
182
|
+
let evt;
|
|
183
|
+
try { evt = JSON.parse(line); }
|
|
184
|
+
catch (err) {
|
|
185
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] dropping unparsable line:', line.slice(0, 200));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
try { onEvent(evt); }
|
|
189
|
+
catch (err) { console.warn('[copilot] event handler error:', err?.message || err); }
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
flush() {
|
|
193
|
+
const line = buf.trim();
|
|
194
|
+
buf = '';
|
|
195
|
+
if (!line) return;
|
|
196
|
+
try {
|
|
197
|
+
const evt = JSON.parse(line);
|
|
198
|
+
onEvent(evt);
|
|
199
|
+
} catch { /* discard trailing junk */ }
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Map a Copilot NDJSON event to zero-or-more claude_output envelopes.
|
|
206
|
+
* Defensive: unknown shapes are logged and dropped.
|
|
207
|
+
*
|
|
208
|
+
* Recognized loose schemas (Copilot CLI JSON output is not yet stable, so
|
|
209
|
+
* we accept several aliases and forward only what we understand):
|
|
210
|
+
* - text: { type: 'text'|'text_delta'|'assistant_text', text|delta }
|
|
211
|
+
* - message: { type: 'message', role, content }
|
|
212
|
+
* - tool_call: { type: 'tool_call'|'tool_use', id, name|tool, input|arguments }
|
|
213
|
+
* - tool_result: { type: 'tool_result', tool_use_id|id, content|output }
|
|
214
|
+
* - done: { type: 'result'|'done'|'complete', session_id?, error? }
|
|
215
|
+
* - error: { type: 'error', message|error }
|
|
216
|
+
*/
|
|
217
|
+
export function translateCopilotEvent(evt, state) {
|
|
218
|
+
if (!evt || typeof evt !== 'object') return [];
|
|
219
|
+
const t = evt.type;
|
|
220
|
+
|
|
221
|
+
if (t === 'text' || t === 'text_delta' || t === 'assistant_text') {
|
|
222
|
+
const text = typeof evt.text === 'string' ? evt.text : (typeof evt.delta === 'string' ? evt.delta : '');
|
|
223
|
+
if (!text) return [];
|
|
224
|
+
return [{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text }] } }];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (t === 'message' && evt.role === 'assistant') {
|
|
228
|
+
const content = normalizeContent(evt.content);
|
|
229
|
+
return [{ type: 'assistant', message: { role: 'assistant', content } }];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (t === 'tool_call' || t === 'tool_use') {
|
|
233
|
+
const id = evt.id || evt.call_id || randomUUID();
|
|
234
|
+
const toolName = evt.name || evt.tool || 'unknown';
|
|
235
|
+
const input = evt.input ?? evt.arguments ?? {};
|
|
236
|
+
return [{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name: toolName, input }] } }];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (t === 'tool_result') {
|
|
240
|
+
const tool_use_id = evt.tool_use_id || evt.id || 'unknown';
|
|
241
|
+
const content = typeof evt.content === 'string'
|
|
242
|
+
? evt.content
|
|
243
|
+
: (typeof evt.output === 'string' ? evt.output : JSON.stringify(evt.content ?? evt.output ?? ''));
|
|
244
|
+
return [{ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id, content }] } }];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (t === 'result' || t === 'done' || t === 'complete') {
|
|
248
|
+
const isErr = !!evt.error || evt.is_error === true;
|
|
249
|
+
return [{
|
|
250
|
+
type: 'result',
|
|
251
|
+
subtype: isErr ? 'error' : 'success',
|
|
252
|
+
session_id: evt.session_id || state?.sessionId || null,
|
|
253
|
+
is_error: isErr,
|
|
254
|
+
error: isErr ? (evt.error || evt.message || 'copilot error') : undefined,
|
|
255
|
+
}];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (t === 'error') {
|
|
259
|
+
return [{
|
|
260
|
+
type: 'result',
|
|
261
|
+
subtype: 'error',
|
|
262
|
+
session_id: state?.sessionId || null,
|
|
263
|
+
is_error: true,
|
|
264
|
+
error: evt.message || evt.error || 'copilot error',
|
|
265
|
+
}];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (ctx?.CONFIG?.debug) console.warn('[copilot] dropping unknown event type:', t);
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function normalizeContent(content) {
|
|
273
|
+
if (typeof content === 'string') return [{ type: 'text', text: content }];
|
|
274
|
+
if (Array.isArray(content)) return content;
|
|
275
|
+
return [{ type: 'text', text: String(content ?? '') }];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export default { name, start, sendInput, abort };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PROVIDER_NAMES, DEFAULT_PROVIDER, isValidProvider } from './base.js';
|
|
2
|
+
import * as claudeCode from './claude-code.js';
|
|
3
|
+
import * as copilot from './copilot.js';
|
|
4
|
+
|
|
5
|
+
const REGISTRY = Object.freeze({
|
|
6
|
+
'claude-code': claudeCode,
|
|
7
|
+
'copilot': copilot,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export function getProvider(nameOrUndef) {
|
|
11
|
+
const key = nameOrUndef || DEFAULT_PROVIDER;
|
|
12
|
+
const driver = REGISTRY[key];
|
|
13
|
+
if (!driver) {
|
|
14
|
+
throw new Error(`Unknown chat provider: ${nameOrUndef} (known: ${PROVIDER_NAMES.join(', ')})`);
|
|
15
|
+
}
|
|
16
|
+
return driver;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { PROVIDER_NAMES, DEFAULT_PROVIDER, isValidProvider };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* persist.js — Conversation message persistence
|
|
3
3
|
*
|
|
4
4
|
* Each message is stored as a .md file with YAML frontmatter in
|
|
5
|
-
* ~/.yeaft/chat/messages/ or ~/.yeaft/
|
|
5
|
+
* ~/.yeaft/chat/messages/ or ~/.yeaft/groups/<groupId>/conversation/messages/. Design: zero JSON, all Markdown.
|
|
6
6
|
*
|
|
7
7
|
* Message format:
|
|
8
8
|
* ---
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Reference: yeaft-yeaft-core-systems.md §4.1, yeaft-yeaft-brainstorm-v5.1.md
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
|
|
21
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
22
22
|
import { join, basename } from 'path';
|
|
23
23
|
import { isPermissionError } from '../init.js';
|
|
24
24
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
@@ -328,34 +328,30 @@ export function parseMessage(raw) {
|
|
|
328
328
|
* messages/
|
|
329
329
|
* cold/
|
|
330
330
|
* blobs/
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* compact.md
|
|
331
|
+
* groups/<groupId>/conversation/
|
|
332
|
+
* compact/
|
|
334
333
|
* messages/
|
|
335
334
|
* cold/
|
|
336
335
|
* blobs/
|
|
337
336
|
*
|
|
338
337
|
* Legacy compatibility: ~/.yeaft/conversation is read as an old mixed store.
|
|
339
|
-
* New writes are split by mode: records with groupId go to
|
|
340
|
-
* go to chat/.
|
|
338
|
+
* New writes are split by mode: records with groupId go to
|
|
339
|
+
* groups/<groupId>/conversation/, all others go to chat/.
|
|
341
340
|
*/
|
|
342
341
|
export class ConversationStore {
|
|
343
342
|
#dir; // root dir (e.g. ~/.yeaft)
|
|
344
343
|
#chatDir; // ~/.yeaft/chat
|
|
345
|
-
#
|
|
344
|
+
#groupsDir; // ~/.yeaft/groups
|
|
346
345
|
#legacyConvDir; // ~/.yeaft/conversation (read-only compatibility)
|
|
347
346
|
#convDir; // default thread dir root: ~/.yeaft/chat
|
|
348
347
|
#msgDir; // default hot messages dir: ~/.yeaft/chat/messages
|
|
349
348
|
#coldDir; // default cold messages dir: ~/.yeaft/chat/cold
|
|
350
349
|
#indexPath; // ~/.yeaft/chat/index.md
|
|
351
350
|
#compactPath; // ~/.yeaft/chat/compact.md
|
|
352
|
-
#compactScopedDir; // ~/.yeaft/group/compact/ (per-(group,vp))
|
|
353
351
|
#legacyCompactPath;
|
|
354
352
|
#legacyCompactScopedDir;
|
|
355
353
|
#chatMsgDir;
|
|
356
354
|
#chatColdDir;
|
|
357
|
-
#groupMsgDir;
|
|
358
|
-
#groupColdDir;
|
|
359
355
|
#legacyMsgDir;
|
|
360
356
|
#legacyColdDir;
|
|
361
357
|
#nextSeq; // next message sequence number across chat/group/legacy
|
|
@@ -367,7 +363,7 @@ export class ConversationStore {
|
|
|
367
363
|
constructor(dir) {
|
|
368
364
|
this.#dir = dir;
|
|
369
365
|
this.#chatDir = join(dir, 'chat');
|
|
370
|
-
this.#
|
|
366
|
+
this.#groupsDir = join(dir, 'groups');
|
|
371
367
|
this.#legacyConvDir = join(dir, 'conversation');
|
|
372
368
|
|
|
373
369
|
this.#convDir = this.#chatDir;
|
|
@@ -379,23 +375,23 @@ export class ConversationStore {
|
|
|
379
375
|
|
|
380
376
|
this.#chatMsgDir = this.#msgDir;
|
|
381
377
|
this.#chatColdDir = this.#coldDir;
|
|
382
|
-
this.#groupMsgDir = join(this.#groupDir, 'messages');
|
|
383
|
-
this.#groupColdDir = join(this.#groupDir, 'cold');
|
|
384
378
|
this.#legacyMsgDir = join(this.#legacyConvDir, 'messages');
|
|
385
379
|
this.#legacyColdDir = join(this.#legacyConvDir, 'cold');
|
|
386
380
|
|
|
387
|
-
// Per-(groupId, vpId) compact summary files live
|
|
388
|
-
// ~/.yeaft/conversation/compact directory
|
|
389
|
-
|
|
381
|
+
// Per-(groupId, vpId) compact summary files live under that group's
|
|
382
|
+
// conversation directory. The legacy ~/.yeaft/conversation/compact directory
|
|
383
|
+
// is read for compatibility.
|
|
390
384
|
this.#legacyCompactScopedDir = join(this.#legacyConvDir, 'compact');
|
|
391
385
|
this.#nextSeq = null;
|
|
392
386
|
this.#nextSeqByThread = new Map();
|
|
393
387
|
|
|
394
|
-
// Ensure new chat
|
|
395
|
-
//
|
|
388
|
+
// Ensure new chat and group-root directories exist (graceful on permission
|
|
389
|
+
// errors). Per-group conversation directories are created lazily once a
|
|
390
|
+
// groupId is known. The legacy conversation directory is never created by
|
|
391
|
+
// new versions.
|
|
396
392
|
for (const d of [
|
|
397
393
|
this.#chatDir, join(this.#chatDir, 'blobs'), this.#chatMsgDir, this.#chatColdDir,
|
|
398
|
-
this.#
|
|
394
|
+
this.#groupsDir,
|
|
399
395
|
]) {
|
|
400
396
|
try {
|
|
401
397
|
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
|
|
@@ -539,10 +535,8 @@ export class ConversationStore {
|
|
|
539
535
|
/**
|
|
540
536
|
* Sanitize one id (groupId or vpId) into a safe filename component.
|
|
541
537
|
* Anything outside `[A-Za-z0-9._-]` collapses to `_`; max 120 chars.
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
* regex (a literal `..` stays as `..` here and becomes part of a
|
|
545
|
-
* regular filename via the `__` separator + `.md` suffix).
|
|
538
|
+
* For directory path components, use `#safeDirComponent` instead; this
|
|
539
|
+
* helper intentionally preserves historical compact-summary filenames.
|
|
546
540
|
*
|
|
547
541
|
* @param {string} s
|
|
548
542
|
* @returns {string}
|
|
@@ -551,6 +545,11 @@ export class ConversationStore {
|
|
|
551
545
|
return String(s).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
|
|
552
546
|
}
|
|
553
547
|
|
|
548
|
+
#safeDirComponent(s) {
|
|
549
|
+
const safe = this.#safeIdComponent(s).replace(/^\.+$/, '_');
|
|
550
|
+
return safe || '_';
|
|
551
|
+
}
|
|
552
|
+
|
|
554
553
|
/**
|
|
555
554
|
* Sanitize a (groupId, vpId) pair into a safe filename. We accept
|
|
556
555
|
* arbitrary user strings here (groupIds and vpIds are user-set), so
|
|
@@ -562,7 +561,8 @@ export class ConversationStore {
|
|
|
562
561
|
*/
|
|
563
562
|
#scopedCompactPath(groupId, vpId) {
|
|
564
563
|
if (!groupId || !vpId) return null;
|
|
565
|
-
|
|
564
|
+
const compactDir = join(this.#groupConversationDir(groupId, { create: true }), 'compact');
|
|
565
|
+
return join(compactDir, `${this.#safeIdComponent(vpId)}.md`);
|
|
566
566
|
}
|
|
567
567
|
|
|
568
568
|
#legacyScopedCompactPath(groupId, vpId) {
|
|
@@ -632,12 +632,13 @@ export class ConversationStore {
|
|
|
632
632
|
*/
|
|
633
633
|
hasAnyCompactSummaryForGroup(groupId) {
|
|
634
634
|
if (!groupId) return false;
|
|
635
|
-
const
|
|
636
|
-
for (const dir of [
|
|
635
|
+
const compactDir = join(this.#groupConversationDir(groupId), 'compact');
|
|
636
|
+
for (const dir of [compactDir, this.#legacyCompactScopedDir]) {
|
|
637
637
|
if (!existsSync(dir)) continue;
|
|
638
638
|
try {
|
|
639
639
|
for (const f of readdirSync(dir)) {
|
|
640
|
-
if (
|
|
640
|
+
if (dir === compactDir && f.endsWith('.md')) return true;
|
|
641
|
+
if (dir === this.#legacyCompactScopedDir && f.startsWith(`${this.#safeIdComponent(groupId)}__`) && f.endsWith('.md')) return true;
|
|
641
642
|
}
|
|
642
643
|
} catch { /* best-effort */ }
|
|
643
644
|
}
|
|
@@ -686,7 +687,7 @@ export class ConversationStore {
|
|
|
686
687
|
* Clear all messages (hot + cold + compact).
|
|
687
688
|
*/
|
|
688
689
|
clear() {
|
|
689
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#
|
|
690
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold')]) {
|
|
690
691
|
if (existsSync(dir)) {
|
|
691
692
|
for (const file of readdirSync(dir)) {
|
|
692
693
|
if (file.endsWith('.md')) {
|
|
@@ -785,7 +786,7 @@ export class ConversationStore {
|
|
|
785
786
|
*/
|
|
786
787
|
loadRecentByGroup(groupId, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
787
788
|
if (!groupId) return [];
|
|
788
|
-
const all = this.#loadGroupMessages()
|
|
789
|
+
const all = this.#loadGroupMessages(groupId)
|
|
789
790
|
const filtered = all.filter(m => m && m.groupId === groupId);
|
|
790
791
|
if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(filtered);
|
|
791
792
|
return pairSanitize(sliceLastNTurns(filtered, turnsLimit));
|
|
@@ -828,7 +829,7 @@ export class ConversationStore {
|
|
|
828
829
|
*/
|
|
829
830
|
loadGroupHistoryForVp(groupId, vpId) {
|
|
830
831
|
if (!groupId || !vpId) return [];
|
|
831
|
-
const all = this.#loadGroupMessages()
|
|
832
|
+
const all = this.#loadGroupMessages(groupId)
|
|
832
833
|
const out = [];
|
|
833
834
|
for (const m of all) {
|
|
834
835
|
if (!m || m.groupId !== groupId) continue;
|
|
@@ -900,8 +901,8 @@ export class ConversationStore {
|
|
|
900
901
|
*/
|
|
901
902
|
loadOlderByGroup(groupId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
902
903
|
if (!groupId) return { messages: [], oldestSeq: null, hasMore: false };
|
|
903
|
-
const hot = this.#loadGroupHotMessages();
|
|
904
|
-
const cold = this.#loadGroupColdMessages();
|
|
904
|
+
const hot = this.#loadGroupHotMessages(groupId);
|
|
905
|
+
const cold = this.#loadGroupColdMessages(groupId);
|
|
905
906
|
// Cold ids strictly < hot ids by construction → chronological concat.
|
|
906
907
|
const all = [...cold, ...hot];
|
|
907
908
|
const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
|
|
@@ -941,8 +942,8 @@ export class ConversationStore {
|
|
|
941
942
|
if (!groupId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
|
|
942
943
|
|
|
943
944
|
const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
|
|
944
|
-
const hot = this.#loadVisibleFromDirsByGroup([this.#
|
|
945
|
-
const cold = this.#loadVisibleFromDirsByGroup([this.#
|
|
945
|
+
const hot = this.#loadVisibleFromDirsByGroup([...this.#groupMessageDirs('messages', groupId), this.#legacyMsgDir], groupId, cutoff);
|
|
946
|
+
const cold = this.#loadVisibleFromDirsByGroup([...this.#groupMessageDirs('cold', groupId), this.#legacyColdDir], groupId, cutoff);
|
|
946
947
|
const visible = [...cold, ...hot];
|
|
947
948
|
if (visible.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
|
|
948
949
|
|
|
@@ -969,7 +970,7 @@ export class ConversationStore {
|
|
|
969
970
|
* @returns {number}
|
|
970
971
|
*/
|
|
971
972
|
countHot() {
|
|
972
|
-
return this.#countFilesInDirs([this.#chatMsgDir, this.#
|
|
973
|
+
return this.#countFilesInDirs([this.#chatMsgDir, ...this.#groupMessageDirs('messages'), this.#legacyMsgDir]);
|
|
973
974
|
}
|
|
974
975
|
|
|
975
976
|
/**
|
|
@@ -978,7 +979,7 @@ export class ConversationStore {
|
|
|
978
979
|
* @returns {number}
|
|
979
980
|
*/
|
|
980
981
|
countCold() {
|
|
981
|
-
return this.#countFilesInDirs([this.#chatColdDir, this.#
|
|
982
|
+
return this.#countFilesInDirs([this.#chatColdDir, ...this.#groupMessageDirs('cold'), this.#legacyColdDir]);
|
|
982
983
|
}
|
|
983
984
|
|
|
984
985
|
/**
|
|
@@ -1034,7 +1035,7 @@ export class ConversationStore {
|
|
|
1034
1035
|
deleteByGroup(groupId) {
|
|
1035
1036
|
if (!groupId) return 0;
|
|
1036
1037
|
let removed = 0;
|
|
1037
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#
|
|
1038
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1038
1039
|
if (!existsSync(dir)) continue;
|
|
1039
1040
|
let files;
|
|
1040
1041
|
try {
|
|
@@ -1092,7 +1093,7 @@ export class ConversationStore {
|
|
|
1092
1093
|
let scanned = 0;
|
|
1093
1094
|
let removed = 0;
|
|
1094
1095
|
const orphans = [];
|
|
1095
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#
|
|
1096
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1096
1097
|
if (!existsSync(dir)) continue;
|
|
1097
1098
|
let files;
|
|
1098
1099
|
try {
|
|
@@ -1146,7 +1147,7 @@ export class ConversationStore {
|
|
|
1146
1147
|
reassignThread(sourceId, targetId) {
|
|
1147
1148
|
if (!sourceId || !targetId || sourceId === targetId) return 0;
|
|
1148
1149
|
let rewritten = 0;
|
|
1149
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#
|
|
1150
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1150
1151
|
if (!existsSync(dir)) continue;
|
|
1151
1152
|
let files;
|
|
1152
1153
|
try {
|
|
@@ -1225,7 +1226,7 @@ export class ConversationStore {
|
|
|
1225
1226
|
|
|
1226
1227
|
// Collect source-thread candidate files from both hot + cold dirs.
|
|
1227
1228
|
const candidates = [];
|
|
1228
|
-
for (const dir of [this.#chatColdDir, this.#chatMsgDir, this.#
|
|
1229
|
+
for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#groupMessageDirs('cold'), ...this.#groupMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
|
|
1229
1230
|
if (!existsSync(dir)) continue;
|
|
1230
1231
|
let files;
|
|
1231
1232
|
try {
|
|
@@ -1332,7 +1333,7 @@ export class ConversationStore {
|
|
|
1332
1333
|
}
|
|
1333
1334
|
// Legacy: messages live in the flat dir stamped with threadId.
|
|
1334
1335
|
const collected = [];
|
|
1335
|
-
for (const dir of [this.#chatColdDir, this.#chatMsgDir, this.#
|
|
1336
|
+
for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#groupMessageDirs('cold'), ...this.#groupMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
|
|
1336
1337
|
if (!existsSync(dir)) continue;
|
|
1337
1338
|
for (const f of readdirSync(dir).filter(x => x.endsWith('.md'))) {
|
|
1338
1339
|
try {
|
|
@@ -1353,13 +1354,53 @@ export class ConversationStore {
|
|
|
1353
1354
|
}
|
|
1354
1355
|
|
|
1355
1356
|
#messageDirFor(msg) {
|
|
1356
|
-
|
|
1357
|
+
if (!msg?.groupId) return this.#chatMsgDir;
|
|
1358
|
+
return join(this.#groupConversationDir(msg.groupId, { create: true }), 'messages');
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
#groupConversationDir(groupId, { create = false } = {}) {
|
|
1362
|
+
const dir = join(this.#groupsDir, this.#safeDirComponent(groupId), 'conversation');
|
|
1363
|
+
if (create) this.#ensureConversationDirs(dir);
|
|
1364
|
+
return dir;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
#ensureConversationDirs(dir) {
|
|
1368
|
+
for (const d of [dir, join(dir, 'blobs'), join(dir, 'messages'), join(dir, 'cold'), join(dir, 'compact')]) {
|
|
1369
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
#groupConversationDirs() {
|
|
1374
|
+
if (!existsSync(this.#groupsDir)) return [];
|
|
1375
|
+
const dirs = [];
|
|
1376
|
+
for (const name of readdirSync(this.#groupsDir)) {
|
|
1377
|
+
const groupDir = join(this.#groupsDir, name);
|
|
1378
|
+
try {
|
|
1379
|
+
if (!statSync(groupDir).isDirectory()) continue;
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
if (isPermissionError(err)) continue;
|
|
1382
|
+
throw err;
|
|
1383
|
+
}
|
|
1384
|
+
const conversationDir = join(groupDir, 'conversation');
|
|
1385
|
+
if (existsSync(conversationDir)) dirs.push(conversationDir);
|
|
1386
|
+
}
|
|
1387
|
+
return dirs;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
#groupMessageDirs(kind, groupId = null) {
|
|
1391
|
+
if (groupId) {
|
|
1392
|
+
const dir = join(this.#groupConversationDir(groupId), kind);
|
|
1393
|
+
return existsSync(dir) ? [dir] : [];
|
|
1394
|
+
}
|
|
1395
|
+
return this.#groupConversationDirs()
|
|
1396
|
+
.map(dir => join(dir, kind))
|
|
1397
|
+
.filter(dir => existsSync(dir));
|
|
1357
1398
|
}
|
|
1358
1399
|
|
|
1359
1400
|
#hotColdDirPairs({ includeLegacy = true } = {}) {
|
|
1360
1401
|
const pairs = [
|
|
1361
1402
|
[this.#chatMsgDir, this.#chatColdDir],
|
|
1362
|
-
|
|
1403
|
+
...this.#groupConversationDirs().map(dir => [join(dir, 'messages'), join(dir, 'cold')]),
|
|
1363
1404
|
];
|
|
1364
1405
|
if (includeLegacy) pairs.push([this.#legacyMsgDir, this.#legacyColdDir]);
|
|
1365
1406
|
return pairs;
|
|
@@ -1375,22 +1416,22 @@ export class ConversationStore {
|
|
|
1375
1416
|
].sort(compareMessagesBySeq);
|
|
1376
1417
|
}
|
|
1377
1418
|
|
|
1378
|
-
#loadGroupHotMessages() {
|
|
1419
|
+
#loadGroupHotMessages(groupId = null) {
|
|
1379
1420
|
return [
|
|
1380
1421
|
...this.#loadFromDir(this.#legacyMsgDir, Infinity).filter(m => m?.groupId),
|
|
1381
|
-
...this.#
|
|
1422
|
+
...this.#groupMessageDirs('messages', groupId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1382
1423
|
].sort(compareMessagesBySeq);
|
|
1383
1424
|
}
|
|
1384
1425
|
|
|
1385
|
-
#loadGroupColdMessages() {
|
|
1426
|
+
#loadGroupColdMessages(groupId = null) {
|
|
1386
1427
|
return [
|
|
1387
1428
|
...this.#loadFromDir(this.#legacyColdDir, Infinity).filter(m => m?.groupId),
|
|
1388
|
-
...this.#
|
|
1429
|
+
...this.#groupMessageDirs('cold', groupId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1389
1430
|
].sort(compareMessagesBySeq);
|
|
1390
1431
|
}
|
|
1391
1432
|
|
|
1392
|
-
#loadGroupMessages() {
|
|
1393
|
-
return [...this.#loadGroupColdMessages(), ...this.#loadGroupHotMessages()].sort(compareMessagesBySeq);
|
|
1433
|
+
#loadGroupMessages(groupId = null) {
|
|
1434
|
+
return [...this.#loadGroupColdMessages(groupId), ...this.#loadGroupHotMessages(groupId)].sort(compareMessagesBySeq);
|
|
1394
1435
|
}
|
|
1395
1436
|
|
|
1396
1437
|
#loadAllMessages() {
|
|
@@ -1399,8 +1440,8 @@ export class ConversationStore {
|
|
|
1399
1440
|
...this.#loadFromDir(this.#legacyMsgDir, Infinity),
|
|
1400
1441
|
...this.#loadFromDir(this.#chatColdDir, Infinity),
|
|
1401
1442
|
...this.#loadFromDir(this.#chatMsgDir, Infinity),
|
|
1402
|
-
...this.#
|
|
1403
|
-
...this.#
|
|
1443
|
+
...this.#groupMessageDirs('cold').flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1444
|
+
...this.#groupMessageDirs('messages').flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1404
1445
|
].sort(compareMessagesBySeq);
|
|
1405
1446
|
}
|
|
1406
1447
|
|
|
@@ -1514,7 +1555,7 @@ export class ConversationStore {
|
|
|
1514
1555
|
if (this.#nextSeq != null) return this.#nextSeq;
|
|
1515
1556
|
|
|
1516
1557
|
let maxSeq = 0;
|
|
1517
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#
|
|
1558
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1518
1559
|
if (!existsSync(dir)) continue;
|
|
1519
1560
|
for (const file of readdirSync(dir)) {
|
|
1520
1561
|
const match = file.match(/^m(\d+)\.md$/);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Simple keyword search across hot and cold messages.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
8
8
|
import { join } from 'path';
|
|
9
9
|
import { parseMessage, parseSeqFromId } from './persist.js';
|
|
10
10
|
|
|
@@ -35,15 +35,34 @@ function searchDir(dir, keyword) {
|
|
|
35
35
|
return results;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
function
|
|
38
|
+
function compareNewest(a, b) {
|
|
39
39
|
const sa = parseSeqFromId(a?.id);
|
|
40
40
|
const sb = parseSeqFromId(b?.id);
|
|
41
41
|
if (Number.isFinite(sa) && Number.isFinite(sb) && sa !== sb) return sb - sa;
|
|
42
42
|
return String(b?.time || '').localeCompare(String(a?.time || ''));
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function groupConversationMessageDirs(dir) {
|
|
46
|
+
const groupsDir = join(dir, 'groups');
|
|
47
|
+
if (!existsSync(groupsDir)) return [];
|
|
48
|
+
|
|
49
|
+
const dirs = [];
|
|
50
|
+
for (const name of readdirSync(groupsDir)) {
|
|
51
|
+
const groupDir = join(groupsDir, name);
|
|
52
|
+
try {
|
|
53
|
+
if (!statSync(groupDir).isDirectory()) continue;
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const conversationDir = join(groupDir, 'conversation');
|
|
59
|
+
dirs.push(join(conversationDir, 'messages'), join(conversationDir, 'cold'));
|
|
60
|
+
}
|
|
61
|
+
return dirs;
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
/**
|
|
46
|
-
* Search Yeaft history (chat + group + legacy conversation) for a keyword.
|
|
65
|
+
* Search Yeaft history (chat + per-group + legacy conversation) for a keyword.
|
|
47
66
|
*
|
|
48
67
|
* @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
|
|
49
68
|
* @param {string} keyword — search term
|
|
@@ -56,8 +75,7 @@ export function searchMessages(dir, keyword, limit = 20) {
|
|
|
56
75
|
const dirs = [
|
|
57
76
|
join(dir, 'chat', 'messages'),
|
|
58
77
|
join(dir, 'chat', 'cold'),
|
|
59
|
-
|
|
60
|
-
join(dir, 'group', 'cold'),
|
|
78
|
+
...groupConversationMessageDirs(dir),
|
|
61
79
|
// Compatibility for profiles created before chat/group split.
|
|
62
80
|
join(dir, 'conversation', 'messages'),
|
|
63
81
|
join(dir, 'conversation', 'cold'),
|
|
@@ -65,6 +83,6 @@ export function searchMessages(dir, keyword, limit = 20) {
|
|
|
65
83
|
|
|
66
84
|
return dirs
|
|
67
85
|
.flatMap(d => searchDir(d, keyword))
|
|
68
|
-
.sort(
|
|
86
|
+
.sort(compareNewest)
|
|
69
87
|
.slice(0, limit);
|
|
70
88
|
}
|
package/yeaft/init.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* init.js — Yeaft directory structure initialization
|
|
3
3
|
*
|
|
4
4
|
* Ensures ~/.yeaft/ and all required subdirectories exist.
|
|
5
|
-
* Creates default config.md, MEMORY.md,
|
|
5
|
+
* Creates default config.md, MEMORY.md, and chat/index.md if missing.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, mkdirSync, writeFileSync, accessSync, constants } from 'fs';
|
|
@@ -77,10 +77,7 @@ const SUBDIRS = [
|
|
|
77
77
|
'chat/messages',
|
|
78
78
|
'chat/cold',
|
|
79
79
|
'chat/blobs',
|
|
80
|
-
'
|
|
81
|
-
'group/cold',
|
|
82
|
-
'group/blobs',
|
|
83
|
-
'group/compact',
|
|
80
|
+
'groups',
|
|
84
81
|
'memory/entries',
|
|
85
82
|
'tasks',
|
|
86
83
|
'skills',
|
|
@@ -200,12 +197,10 @@ export function initYeaftDir(dir) {
|
|
|
200
197
|
created.push(memoryPath);
|
|
201
198
|
}
|
|
202
199
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
created.push(indexPath);
|
|
208
|
-
}
|
|
200
|
+
const chatIndexPath = join(root, 'chat', 'index.md');
|
|
201
|
+
if (!existsSync(chatIndexPath)) {
|
|
202
|
+
safeWriteFile(chatIndexPath, DEFAULT_CONVERSATION_INDEX, warnings);
|
|
203
|
+
created.push(chatIndexPath);
|
|
209
204
|
}
|
|
210
205
|
|
|
211
206
|
// mcp.json.example — reference template for MCP server configuration
|