chatccc 0.2.41 → 0.2.42
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/demo/ilink_echo_probe.ts +222 -222
- package/package.json +59 -59
- package/src/__tests__/cards.test.ts +22 -6
- package/src/__tests__/crash-logging.test.ts +62 -62
- package/src/__tests__/session.test.ts +124 -11
- package/src/cards.ts +5 -3
- package/src/index.ts +116 -13
- package/src/session.ts +131 -7
- package/src/shared.ts +63 -63
package/src/session.ts
CHANGED
|
@@ -168,6 +168,87 @@ export async function getSessionTool(sessionId: string): Promise<string | null>
|
|
|
168
168
|
return record?.tool ?? null;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Conversation session registry for /sessions
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export const SESSION_REGISTRY_FILE = join(USER_DATA_DIR, "state", "session-registry.json");
|
|
176
|
+
let sessionRegistryFile = SESSION_REGISTRY_FILE;
|
|
177
|
+
|
|
178
|
+
export interface SessionRegistryUpdate {
|
|
179
|
+
chatId: string;
|
|
180
|
+
sessionId: string;
|
|
181
|
+
tool: string;
|
|
182
|
+
chatName?: string;
|
|
183
|
+
turnCount?: number;
|
|
184
|
+
lastContextTokens?: number;
|
|
185
|
+
startTime?: number;
|
|
186
|
+
updatedAt?: number;
|
|
187
|
+
running?: boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface SessionRegistryRecord {
|
|
191
|
+
chatId: string;
|
|
192
|
+
sessionId: string;
|
|
193
|
+
tool: string;
|
|
194
|
+
chatName: string;
|
|
195
|
+
turnCount: number;
|
|
196
|
+
lastContextTokens: number;
|
|
197
|
+
startTime: number;
|
|
198
|
+
updatedAt: number;
|
|
199
|
+
running: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
type SessionRegistryData = Record<string, SessionRegistryRecord>;
|
|
203
|
+
|
|
204
|
+
async function loadSessionRegistry(): Promise<SessionRegistryData> {
|
|
205
|
+
try {
|
|
206
|
+
const raw = await readFile(sessionRegistryFile, "utf-8");
|
|
207
|
+
const parsed = JSON.parse(raw) as SessionRegistryData;
|
|
208
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
209
|
+
} catch {
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
|
|
215
|
+
try {
|
|
216
|
+
await mkdir(dirname(sessionRegistryFile), { recursive: true });
|
|
217
|
+
await writeFile(sessionRegistryFile, JSON.stringify(data, null, 2), "utf-8");
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(`[${ts()}] Failed to save session-registry.json: ${(err as Error).message}`);
|
|
220
|
+
fileLog.flush();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function recordSessionRegistry(update: SessionRegistryUpdate): Promise<void> {
|
|
225
|
+
const data = await loadSessionRegistry();
|
|
226
|
+
const existing = data[update.chatId];
|
|
227
|
+
const now = update.updatedAt ?? Date.now();
|
|
228
|
+
|
|
229
|
+
data[update.chatId] = {
|
|
230
|
+
chatId: update.chatId,
|
|
231
|
+
sessionId: update.sessionId,
|
|
232
|
+
tool: update.tool,
|
|
233
|
+
chatName: update.chatName ?? existing?.chatName ?? "",
|
|
234
|
+
turnCount: update.turnCount ?? existing?.turnCount ?? 0,
|
|
235
|
+
lastContextTokens: update.lastContextTokens ?? existing?.lastContextTokens ?? 0,
|
|
236
|
+
startTime: update.startTime ?? existing?.startTime ?? now,
|
|
237
|
+
updatedAt: now,
|
|
238
|
+
running: update.running ?? existing?.running ?? false,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
await saveSessionRegistry(data);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function _setSessionRegistryFileForTest(filePath: string): void {
|
|
245
|
+
sessionRegistryFile = filePath;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function _resetSessionRegistryFileForTest(): void {
|
|
249
|
+
sessionRegistryFile = SESSION_REGISTRY_FILE;
|
|
250
|
+
}
|
|
251
|
+
|
|
171
252
|
// ---------------------------------------------------------------------------
|
|
172
253
|
// accumulateBlockContent — 将 UnifiedBlock 累积到渲染状态(纯函数,可测试)
|
|
173
254
|
// ---------------------------------------------------------------------------
|
|
@@ -385,13 +466,24 @@ export async function resumeAndPrompt(
|
|
|
385
466
|
|
|
386
467
|
const now = Date.now();
|
|
387
468
|
const existingInfo = sessionInfoMap.get(chatId);
|
|
469
|
+
const nextTurnCount = (existingInfo?.turnCount ?? 0) + 1;
|
|
470
|
+
const nextContextTokens = existingInfo?.lastContextTokens ?? 0;
|
|
388
471
|
sessionInfoMap.set(chatId, {
|
|
389
472
|
sessionId,
|
|
390
|
-
turnCount:
|
|
391
|
-
lastContextTokens:
|
|
473
|
+
turnCount: nextTurnCount,
|
|
474
|
+
lastContextTokens: nextContextTokens,
|
|
392
475
|
startTime: now,
|
|
393
476
|
tool,
|
|
394
477
|
});
|
|
478
|
+
await recordSessionRegistry({
|
|
479
|
+
chatId,
|
|
480
|
+
sessionId,
|
|
481
|
+
tool,
|
|
482
|
+
turnCount: nextTurnCount,
|
|
483
|
+
lastContextTokens: nextContextTokens,
|
|
484
|
+
startTime: now,
|
|
485
|
+
running: true,
|
|
486
|
+
});
|
|
395
487
|
|
|
396
488
|
let cardId: string | null = null;
|
|
397
489
|
cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
|
|
@@ -512,6 +604,13 @@ export async function resumeAndPrompt(
|
|
|
512
604
|
if (block.type === "compact_boundary" && block.post_tokens) {
|
|
513
605
|
const info = sessionInfoMap.get(chatId);
|
|
514
606
|
if (info) { info.lastContextTokens = block.post_tokens; }
|
|
607
|
+
await recordSessionRegistry({
|
|
608
|
+
chatId,
|
|
609
|
+
sessionId,
|
|
610
|
+
tool,
|
|
611
|
+
lastContextTokens: block.post_tokens,
|
|
612
|
+
running: true,
|
|
613
|
+
});
|
|
515
614
|
}
|
|
516
615
|
}
|
|
517
616
|
}
|
|
@@ -561,6 +660,16 @@ export async function resumeAndPrompt(
|
|
|
561
660
|
const finalReply = pickFinalReply(state).trim();
|
|
562
661
|
|
|
563
662
|
if (wasStopped) {
|
|
663
|
+
const finalInfo = sessionInfoMap.get(chatId);
|
|
664
|
+
await recordSessionRegistry({
|
|
665
|
+
chatId,
|
|
666
|
+
sessionId,
|
|
667
|
+
tool,
|
|
668
|
+
turnCount: finalInfo?.turnCount ?? nextTurnCount,
|
|
669
|
+
lastContextTokens: finalInfo?.lastContextTokens ?? nextContextTokens,
|
|
670
|
+
startTime: finalInfo?.startTime ?? now,
|
|
671
|
+
running: false,
|
|
672
|
+
});
|
|
564
673
|
if (finalReply) {
|
|
565
674
|
await sendTextReply(token, chatId, finalReply).catch((err) =>
|
|
566
675
|
console.error(`[${ts()}] Failed to send partial text: ${(err as Error).message}`)
|
|
@@ -597,6 +706,16 @@ export async function resumeAndPrompt(
|
|
|
597
706
|
}
|
|
598
707
|
|
|
599
708
|
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
709
|
+
const finalInfo = sessionInfoMap.get(chatId);
|
|
710
|
+
await recordSessionRegistry({
|
|
711
|
+
chatId,
|
|
712
|
+
sessionId,
|
|
713
|
+
tool,
|
|
714
|
+
turnCount: finalInfo?.turnCount ?? nextTurnCount,
|
|
715
|
+
lastContextTokens: finalInfo?.lastContextTokens ?? nextContextTokens,
|
|
716
|
+
startTime: finalInfo?.startTime ?? now,
|
|
717
|
+
running: false,
|
|
718
|
+
});
|
|
600
719
|
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
601
720
|
}
|
|
602
721
|
|
|
@@ -681,6 +800,7 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
|
|
|
681
800
|
export interface SessionsListEntry {
|
|
682
801
|
chatId: string;
|
|
683
802
|
sessionId: string;
|
|
803
|
+
chatName: string;
|
|
684
804
|
active: boolean;
|
|
685
805
|
turnCount: number;
|
|
686
806
|
startTime: number;
|
|
@@ -691,16 +811,20 @@ export interface SessionsListEntry {
|
|
|
691
811
|
}
|
|
692
812
|
|
|
693
813
|
export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
|
|
694
|
-
const
|
|
814
|
+
const registry = await loadSessionRegistry();
|
|
815
|
+
const entries = Object.values(registry)
|
|
816
|
+
.filter((record) => record.chatId && record.sessionId && record.tool)
|
|
817
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
818
|
+
.slice(0, 20);
|
|
695
819
|
// 并行解析每个 session 的 model/effort(cursor 涉及异步 store IO)
|
|
696
820
|
return Promise.all(
|
|
697
|
-
entries.map(async (
|
|
698
|
-
const active = chatSessionMap.get(chatId);
|
|
821
|
+
entries.map(async (info) => {
|
|
699
822
|
const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
|
|
700
823
|
return {
|
|
701
|
-
chatId,
|
|
824
|
+
chatId: info.chatId,
|
|
702
825
|
sessionId: info.sessionId,
|
|
703
|
-
|
|
826
|
+
chatName: info.chatName || "",
|
|
827
|
+
active: info.running === true,
|
|
704
828
|
turnCount: info.turnCount,
|
|
705
829
|
startTime: info.startTime,
|
|
706
830
|
model,
|
package/src/shared.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
appendFileSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
unlinkSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "node:fs";
|
|
10
|
-
import { createServer } from "node:http";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { inspect } from "node:util";
|
|
14
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { inspect } from "node:util";
|
|
14
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
15
15
|
|
|
16
16
|
import { printServiceDidNotStart } from "./exit-banner.ts";
|
|
17
17
|
|
|
@@ -355,56 +355,56 @@ export function installCrashLogging(opts: CrashHandlersOptions = {}): InstallCra
|
|
|
355
355
|
// 文件日志:同时输出到控制台和日志文件
|
|
356
356
|
// ---------------------------------------------------------------------------
|
|
357
357
|
|
|
358
|
-
export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
|
|
359
|
-
mkdirSync(logDir, { recursive: true });
|
|
360
|
-
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
361
|
-
const logPath = join(logDir, `${prefix}-${ts}.log`);
|
|
362
|
-
writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
|
|
363
|
-
const origConsoleLog = console.log.bind(console);
|
|
364
|
-
const origConsoleError = console.error.bind(console);
|
|
365
|
-
const formatArg = (arg: unknown): string => {
|
|
366
|
-
if (typeof arg === "string") return arg;
|
|
367
|
-
if (arg instanceof Error) return arg.stack ?? arg.message;
|
|
368
|
-
return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
|
|
369
|
-
};
|
|
370
|
-
const writeLine = (level: string, args: unknown[]) => {
|
|
371
|
-
try {
|
|
372
|
-
const line = args.map(formatArg).join(" ");
|
|
373
|
-
appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
|
|
374
|
-
} catch (err) {
|
|
375
|
-
try {
|
|
376
|
-
appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
|
|
377
|
-
} catch {
|
|
378
|
-
// 日志系统自身不能影响主流程
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
};
|
|
382
|
-
console.log = (...args: unknown[]) => {
|
|
383
|
-
writeLine("LOG", args);
|
|
384
|
-
try {
|
|
385
|
-
origConsoleLog(...args);
|
|
386
|
-
} catch {
|
|
387
|
-
// 控制台输出失败也不能拖垮服务
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
console.error = (...args: unknown[]) => {
|
|
391
|
-
writeLine("ERR", args);
|
|
392
|
-
try {
|
|
393
|
-
origConsoleError(...args);
|
|
394
|
-
} catch {
|
|
395
|
-
// 控制台输出失败也不能拖垮服务
|
|
396
|
-
}
|
|
397
|
-
};
|
|
398
|
-
const flush = () => {
|
|
399
|
-
try {
|
|
400
|
-
appendFileSync(logPath, "", "utf8");
|
|
401
|
-
} catch (err) {
|
|
402
|
-
appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
|
|
403
|
-
}
|
|
404
|
-
};
|
|
405
|
-
origConsoleLog(`Log file: ${logPath}`);
|
|
406
|
-
return { logPath, flush };
|
|
407
|
-
}
|
|
358
|
+
export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
|
|
359
|
+
mkdirSync(logDir, { recursive: true });
|
|
360
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
361
|
+
const logPath = join(logDir, `${prefix}-${ts}.log`);
|
|
362
|
+
writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
|
|
363
|
+
const origConsoleLog = console.log.bind(console);
|
|
364
|
+
const origConsoleError = console.error.bind(console);
|
|
365
|
+
const formatArg = (arg: unknown): string => {
|
|
366
|
+
if (typeof arg === "string") return arg;
|
|
367
|
+
if (arg instanceof Error) return arg.stack ?? arg.message;
|
|
368
|
+
return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
|
|
369
|
+
};
|
|
370
|
+
const writeLine = (level: string, args: unknown[]) => {
|
|
371
|
+
try {
|
|
372
|
+
const line = args.map(formatArg).join(" ");
|
|
373
|
+
appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
|
|
374
|
+
} catch (err) {
|
|
375
|
+
try {
|
|
376
|
+
appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
|
|
377
|
+
} catch {
|
|
378
|
+
// 日志系统自身不能影响主流程
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
console.log = (...args: unknown[]) => {
|
|
383
|
+
writeLine("LOG", args);
|
|
384
|
+
try {
|
|
385
|
+
origConsoleLog(...args);
|
|
386
|
+
} catch {
|
|
387
|
+
// 控制台输出失败也不能拖垮服务
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
console.error = (...args: unknown[]) => {
|
|
391
|
+
writeLine("ERR", args);
|
|
392
|
+
try {
|
|
393
|
+
origConsoleError(...args);
|
|
394
|
+
} catch {
|
|
395
|
+
// 控制台输出失败也不能拖垮服务
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const flush = () => {
|
|
399
|
+
try {
|
|
400
|
+
appendFileSync(logPath, "", "utf8");
|
|
401
|
+
} catch (err) {
|
|
402
|
+
appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
origConsoleLog(`Log file: ${logPath}`);
|
|
406
|
+
return { logPath, flush };
|
|
407
|
+
}
|
|
408
408
|
|
|
409
409
|
// ---------------------------------------------------------------------------
|
|
410
410
|
// 本地 WebSocket 中继服务器(同一端口、多客户端广播)
|