@love-moon/conductor-cli 0.2.16 → 0.2.17
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/package.json +4 -4
- package/src/daemon.js +56 -0
- package/src/log-collector.js +181 -0
- package/src/fire/history.js +0 -614
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"gitCommitId": "
|
|
3
|
+
"version": "0.2.17",
|
|
4
|
+
"gitCommitId": "c2654e1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"conductor": "bin/conductor.js"
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"test": "node --test"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@love-moon/ai-sdk": "0.2.
|
|
21
|
-
"@love-moon/conductor-sdk": "0.2.
|
|
20
|
+
"@love-moon/ai-sdk": "0.2.17",
|
|
21
|
+
"@love-moon/conductor-sdk": "0.2.17",
|
|
22
22
|
"dotenv": "^16.4.5",
|
|
23
23
|
"enquirer": "^2.4.1",
|
|
24
24
|
"js-yaml": "^4.1.1",
|
package/src/daemon.js
CHANGED
|
@@ -8,6 +8,7 @@ import dotenv from "dotenv";
|
|
|
8
8
|
import yaml from "js-yaml";
|
|
9
9
|
|
|
10
10
|
import { ConductorWebSocketClient, ConductorConfig, loadConfig, ConfigFileNotFound } from "@love-moon/conductor-sdk";
|
|
11
|
+
import { DaemonLogCollector } from "./log-collector.js";
|
|
11
12
|
|
|
12
13
|
dotenv.config();
|
|
13
14
|
|
|
@@ -212,6 +213,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
212
213
|
const createWebSocketClient =
|
|
213
214
|
deps.createWebSocketClient ||
|
|
214
215
|
((clientConfig, options) => new ConductorWebSocketClient(clientConfig, options));
|
|
216
|
+
const createLogCollector = deps.createLogCollector || ((backendUrl) => new DaemonLogCollector(backendUrl));
|
|
215
217
|
const PROJECT_PATH_LOOKUP_TIMEOUT_MS = parsePositiveInt(
|
|
216
218
|
process.env.CONDUCTOR_PROJECT_PATH_LOOKUP_TIMEOUT_MS,
|
|
217
219
|
1500,
|
|
@@ -364,6 +366,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
364
366
|
const activeTaskProcesses = new Map();
|
|
365
367
|
const suppressedExitStatusReports = new Set();
|
|
366
368
|
const seenCommandRequestIds = new Set();
|
|
369
|
+
const logCollector = createLogCollector(BACKEND_HTTP);
|
|
367
370
|
const client = createWebSocketClient(sdkConfig, {
|
|
368
371
|
extraHeaders: {
|
|
369
372
|
"x-conductor-host": AGENT_NAME,
|
|
@@ -574,6 +577,59 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
574
577
|
}
|
|
575
578
|
if (event.type === "stop_task") {
|
|
576
579
|
handleStopTask(event.payload);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (event.type === "collect_logs") {
|
|
583
|
+
void handleCollectLogs(event.payload);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function handleCollectLogs(payload) {
|
|
588
|
+
const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
|
|
589
|
+
const taskId = payload?.task_id ? String(payload.task_id).trim() : "";
|
|
590
|
+
const collectedAt = new Date().toISOString();
|
|
591
|
+
|
|
592
|
+
if (!requestId || !taskId) {
|
|
593
|
+
logError(`Invalid collect_logs payload: ${JSON.stringify(payload)}`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
let result;
|
|
598
|
+
try {
|
|
599
|
+
result = await Promise.resolve(
|
|
600
|
+
logCollector.collect(taskId, {
|
|
601
|
+
tailLines: payload?.options?.tail_lines,
|
|
602
|
+
since: payload?.options?.since,
|
|
603
|
+
}),
|
|
604
|
+
);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
result = {
|
|
607
|
+
projectPath: null,
|
|
608
|
+
logPath: null,
|
|
609
|
+
entries: [],
|
|
610
|
+
truncated: false,
|
|
611
|
+
error: `Failed to read log file: ${error?.message || error}`,
|
|
612
|
+
collectedAt,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
await client.sendJson({
|
|
618
|
+
type: "agent_log_collected",
|
|
619
|
+
payload: {
|
|
620
|
+
request_id: requestId,
|
|
621
|
+
task_id: taskId,
|
|
622
|
+
daemon_host: AGENT_NAME,
|
|
623
|
+
project_path: result.projectPath,
|
|
624
|
+
log_path: result.logPath,
|
|
625
|
+
logs: result.entries,
|
|
626
|
+
truncated: Boolean(result.truncated),
|
|
627
|
+
error: result.error,
|
|
628
|
+
collected_at: result.collectedAt || collectedAt,
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
} catch (error) {
|
|
632
|
+
logError(`Failed to report agent_log_collected for ${taskId}: ${error?.message || error}`);
|
|
577
633
|
}
|
|
578
634
|
}
|
|
579
635
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { SessionDiskStore } from "@love-moon/conductor-sdk";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TAIL_LINES = 200;
|
|
7
|
+
const MAX_TAIL_LINES = 500;
|
|
8
|
+
const DEFAULT_TAIL_BYTES = 256 * 1024;
|
|
9
|
+
const MAX_TAIL_BYTES = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function clampPositiveInt(value, fallback, max) {
|
|
12
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
13
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
return Math.min(parsed, max);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeSince(value) {
|
|
20
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const timestamp = Date.parse(value);
|
|
24
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function inferLevel(message) {
|
|
28
|
+
const normalized = String(message || "").toLowerCase();
|
|
29
|
+
if (normalized.includes("error") || normalized.includes("failed")) {
|
|
30
|
+
return "ERROR";
|
|
31
|
+
}
|
|
32
|
+
if (normalized.includes("warn")) {
|
|
33
|
+
return "WARN";
|
|
34
|
+
}
|
|
35
|
+
return "INFO";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseLogLine(line) {
|
|
39
|
+
const content = String(line || "").trim();
|
|
40
|
+
if (!content) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rfcMatch = content.match(/^\[([^\]]+)\]\s+\[([A-Z]+)\]\s+(.*)$/);
|
|
45
|
+
if (rfcMatch) {
|
|
46
|
+
const timestamp = Date.parse(rfcMatch[1]);
|
|
47
|
+
if (Number.isFinite(timestamp)) {
|
|
48
|
+
return {
|
|
49
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
50
|
+
level: rfcMatch[2],
|
|
51
|
+
message: rfcMatch[3],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const conductorMatch = content.match(/^\[([^\]]+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\]\s+(.*)$/);
|
|
57
|
+
if (conductorMatch) {
|
|
58
|
+
const timestamp = Date.parse(conductorMatch[2]);
|
|
59
|
+
if (Number.isFinite(timestamp)) {
|
|
60
|
+
return {
|
|
61
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
62
|
+
level: inferLevel(conductorMatch[3]),
|
|
63
|
+
message: `[${conductorMatch[1]}] ${conductorMatch[3]}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
timestamp: null,
|
|
70
|
+
level: inferLevel(content),
|
|
71
|
+
message: content,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readTailText(filePath, maxBytes) {
|
|
76
|
+
const stats = fs.statSync(filePath);
|
|
77
|
+
const bytesToRead = Math.min(stats.size, maxBytes);
|
|
78
|
+
const start = Math.max(0, stats.size - bytesToRead);
|
|
79
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
80
|
+
const fd = fs.openSync(filePath, "r");
|
|
81
|
+
try {
|
|
82
|
+
fs.readSync(fd, buffer, 0, bytesToRead, start);
|
|
83
|
+
} finally {
|
|
84
|
+
fs.closeSync(fd);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let text = buffer.toString("utf8");
|
|
88
|
+
if (start > 0) {
|
|
89
|
+
const firstNewline = text.indexOf("\n");
|
|
90
|
+
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
text,
|
|
94
|
+
truncatedByBytes: start > 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class DaemonLogCollector {
|
|
99
|
+
constructor(backendUrl, options = {}) {
|
|
100
|
+
this.sessionStore = options.sessionStore || SessionDiskStore.forBackendUrl(backendUrl);
|
|
101
|
+
this.readTailText = options.readTailText || readTailText;
|
|
102
|
+
this.existsSync = options.existsSync || fs.existsSync;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
collect(taskId, options = {}) {
|
|
106
|
+
const normalizedTaskId = String(taskId || "").trim();
|
|
107
|
+
const collectedAt = new Date().toISOString();
|
|
108
|
+
if (!normalizedTaskId) {
|
|
109
|
+
return {
|
|
110
|
+
projectPath: null,
|
|
111
|
+
logPath: null,
|
|
112
|
+
entries: [],
|
|
113
|
+
truncated: false,
|
|
114
|
+
error: "task_id is required",
|
|
115
|
+
collectedAt,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const record = this.sessionStore.findByTaskId(normalizedTaskId);
|
|
120
|
+
if (!record) {
|
|
121
|
+
return {
|
|
122
|
+
projectPath: null,
|
|
123
|
+
logPath: null,
|
|
124
|
+
entries: [],
|
|
125
|
+
truncated: false,
|
|
126
|
+
error: "Task not found in session store",
|
|
127
|
+
collectedAt,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const projectPath = path.resolve(record.projectPath);
|
|
132
|
+
const logPath = path.join(projectPath, "conductor.log");
|
|
133
|
+
if (!this.existsSync(logPath)) {
|
|
134
|
+
return {
|
|
135
|
+
projectPath,
|
|
136
|
+
logPath,
|
|
137
|
+
entries: [],
|
|
138
|
+
truncated: false,
|
|
139
|
+
error: "Log file not found",
|
|
140
|
+
collectedAt,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const tailLines = clampPositiveInt(options.tailLines, DEFAULT_TAIL_LINES, MAX_TAIL_LINES);
|
|
145
|
+
const maxBytes = clampPositiveInt(options.maxBytes, DEFAULT_TAIL_BYTES, MAX_TAIL_BYTES);
|
|
146
|
+
const sinceMs = normalizeSince(options.since);
|
|
147
|
+
const { text, truncatedByBytes } = this.readTailText(logPath, maxBytes);
|
|
148
|
+
let anchorTimestampMs = null;
|
|
149
|
+
const allEntries = [];
|
|
150
|
+
for (const line of text.split(/\r?\n/)) {
|
|
151
|
+
const entry = parseLogLine(line);
|
|
152
|
+
if (!entry) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const entryTimestampMs =
|
|
156
|
+
typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : anchorTimestampMs;
|
|
157
|
+
if (typeof entry.timestamp === "string" && Number.isFinite(entryTimestampMs)) {
|
|
158
|
+
anchorTimestampMs = entryTimestampMs;
|
|
159
|
+
}
|
|
160
|
+
if (sinceMs !== null) {
|
|
161
|
+
if (!Number.isFinite(entryTimestampMs)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (entryTimestampMs < sinceMs) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
allEntries.push(entry);
|
|
169
|
+
}
|
|
170
|
+
const entries = allEntries.slice(-tailLines);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
projectPath,
|
|
174
|
+
logPath,
|
|
175
|
+
entries,
|
|
176
|
+
truncated: truncatedByBytes || allEntries.length > entries.length,
|
|
177
|
+
error: null,
|
|
178
|
+
collectedAt,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/fire/history.js
DELETED
|
@@ -1,614 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import { promises as fsp } from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import readline from "node:readline";
|
|
6
|
-
import enquirer from "enquirer";
|
|
7
|
-
|
|
8
|
-
export {
|
|
9
|
-
findClaudeSessionPath,
|
|
10
|
-
findCodexSessionPath,
|
|
11
|
-
findCopilotSessionPath,
|
|
12
|
-
findSessionPath,
|
|
13
|
-
} from "./resume.js";
|
|
14
|
-
|
|
15
|
-
const { Select } = enquirer;
|
|
16
|
-
|
|
17
|
-
const SUPPORTED_FROM_PROVIDERS = ["codex", "claude"];
|
|
18
|
-
const DEFAULT_HISTORY_LIMIT = 50;
|
|
19
|
-
const DEFAULT_SESSION_LIMIT = 20;
|
|
20
|
-
|
|
21
|
-
export function parseFromSpec(rawFrom, rawProvider, defaultProvider) {
|
|
22
|
-
if (!rawFrom) {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const providerOverride = rawProvider ? String(rawProvider).trim().toLowerCase() : "";
|
|
27
|
-
if (providerOverride && !SUPPORTED_FROM_PROVIDERS.includes(providerOverride)) {
|
|
28
|
-
throw new Error(`Unsupported --from-provider: ${rawProvider}`);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (rawFrom === true) {
|
|
32
|
-
const provider = providerOverride || defaultProvider;
|
|
33
|
-
if (!provider) {
|
|
34
|
-
throw new Error(
|
|
35
|
-
"Missing provider for --from. Use --from <provider>:<sessionId> or --from-provider <provider>."
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
return { provider, sessionId: "" };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const text = typeof rawFrom === "string" ? rawFrom.trim() : "";
|
|
42
|
-
if (!text) {
|
|
43
|
-
const provider = providerOverride || defaultProvider;
|
|
44
|
-
if (!provider) {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
return { provider, sessionId: "" };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
let provider = providerOverride || "";
|
|
51
|
-
let sessionId = text;
|
|
52
|
-
|
|
53
|
-
const colonIndex = text.indexOf(":");
|
|
54
|
-
if (colonIndex > 0) {
|
|
55
|
-
const maybeProvider = text.slice(0, colonIndex).trim().toLowerCase();
|
|
56
|
-
const maybeSession = text.slice(colonIndex + 1).trim();
|
|
57
|
-
if (SUPPORTED_FROM_PROVIDERS.includes(maybeProvider)) {
|
|
58
|
-
provider = maybeProvider;
|
|
59
|
-
sessionId = maybeSession;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (!provider) {
|
|
64
|
-
provider = defaultProvider || "";
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (!provider) {
|
|
68
|
-
throw new Error(
|
|
69
|
-
"Missing provider for --from. Use --from <provider>:<sessionId> or --from-provider <provider>."
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (!sessionId) {
|
|
74
|
-
return { provider, sessionId: "" };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return { provider, sessionId };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export async function loadHistoryFromSpec(spec, options = {}) {
|
|
81
|
-
if (!spec) {
|
|
82
|
-
return { history: [], source: null, provider: null, sessionId: null };
|
|
83
|
-
}
|
|
84
|
-
const provider = spec.provider;
|
|
85
|
-
if (!SUPPORTED_FROM_PROVIDERS.includes(provider)) {
|
|
86
|
-
throw new Error(`Unsupported --from provider: ${provider}`);
|
|
87
|
-
}
|
|
88
|
-
if (provider === "codex") {
|
|
89
|
-
return loadCodexHistory(spec.sessionId, options);
|
|
90
|
-
}
|
|
91
|
-
return loadClaudeHistory(spec.sessionId, options);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export async function listHistorySessions(provider, options = {}) {
|
|
95
|
-
if (!SUPPORTED_FROM_PROVIDERS.includes(provider)) {
|
|
96
|
-
throw new Error(`Unsupported --from provider: ${provider}`);
|
|
97
|
-
}
|
|
98
|
-
if (provider === "codex") {
|
|
99
|
-
return listCodexSessions(options);
|
|
100
|
-
}
|
|
101
|
-
return listClaudeSessions(options);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export async function selectHistorySession(provider, options = {}) {
|
|
105
|
-
const sessions = await listHistorySessions(provider, options);
|
|
106
|
-
if (!sessions.length) {
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
110
|
-
throw new Error("Interactive --from requires a TTY.");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const choices = sessions.map((session) => ({
|
|
114
|
-
name: session.sessionId,
|
|
115
|
-
message: formatSessionLine(session),
|
|
116
|
-
value: session
|
|
117
|
-
}));
|
|
118
|
-
choices.push({
|
|
119
|
-
name: "__cancel__",
|
|
120
|
-
message: "Cancel",
|
|
121
|
-
value: null
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
const prompt = new Select({
|
|
125
|
-
name: "session",
|
|
126
|
-
message: `Select a ${provider} session to resume`,
|
|
127
|
-
choices,
|
|
128
|
-
initial: 0
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const selected = await prompt.run();
|
|
132
|
-
return selected || null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function resolveHomeDir(options) {
|
|
136
|
-
if (options?.homeDir) {
|
|
137
|
-
return options.homeDir;
|
|
138
|
-
}
|
|
139
|
-
return os.homedir();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function trimHistory(history, limit) {
|
|
143
|
-
const max = Number.isFinite(limit) ? Math.max(1, limit) : DEFAULT_HISTORY_LIMIT;
|
|
144
|
-
if (history.length <= max) {
|
|
145
|
-
return history;
|
|
146
|
-
}
|
|
147
|
-
return history.slice(history.length - max);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function loadCodexHistory(sessionId, options = {}) {
|
|
151
|
-
const homeDir = resolveHomeDir(options);
|
|
152
|
-
const sessionsDir = options.codexSessionsDir || path.join(homeDir, ".codex", "sessions");
|
|
153
|
-
const sessionFile = await findCodexSessionFile(sessionsDir, sessionId);
|
|
154
|
-
|
|
155
|
-
if (!sessionFile) {
|
|
156
|
-
return {
|
|
157
|
-
provider: "codex",
|
|
158
|
-
sessionId,
|
|
159
|
-
history: [],
|
|
160
|
-
source: null,
|
|
161
|
-
warning: `Codex session file not found for ${sessionId}`
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const messages = [];
|
|
166
|
-
const rl = readline.createInterface({
|
|
167
|
-
input: fs.createReadStream(sessionFile),
|
|
168
|
-
crlfDelay: Infinity
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
for await (const line of rl) {
|
|
172
|
-
const trimmed = line.trim();
|
|
173
|
-
if (!trimmed) {
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
let entry;
|
|
177
|
-
try {
|
|
178
|
-
entry = JSON.parse(trimmed);
|
|
179
|
-
} catch {
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (entry.type === "event_msg" && entry.payload?.type === "user_message") {
|
|
184
|
-
const content = String(entry.payload.message || "").trim();
|
|
185
|
-
if (content) {
|
|
186
|
-
messages.push({
|
|
187
|
-
role: "user",
|
|
188
|
-
content,
|
|
189
|
-
timestamp: entry.timestamp || Date.now()
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (entry.type === "response_item" && entry.payload?.type === "message") {
|
|
196
|
-
const role = entry.payload.role || "assistant";
|
|
197
|
-
const textContent = extractCodexText(entry.payload.content);
|
|
198
|
-
if (!textContent || textContent.includes("<environment_context>")) {
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
if (textContent.trim()) {
|
|
202
|
-
messages.push({
|
|
203
|
-
role: role === "user" ? "user" : "assistant",
|
|
204
|
-
content: textContent.trim(),
|
|
205
|
-
timestamp: entry.timestamp || Date.now()
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const history = trimHistory(
|
|
212
|
-
messages
|
|
213
|
-
.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0))
|
|
214
|
-
.map(({ role, content }) => ({ role, content })),
|
|
215
|
-
options.maxMessages
|
|
216
|
-
);
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
provider: "codex",
|
|
220
|
-
sessionId,
|
|
221
|
-
history,
|
|
222
|
-
source: sessionFile
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function listCodexSessions(options = {}) {
|
|
227
|
-
const homeDir = resolveHomeDir(options);
|
|
228
|
-
const sessionsDir = options.codexSessionsDir || path.join(homeDir, ".codex", "sessions");
|
|
229
|
-
const jsonlFiles = await findJsonlFiles(sessionsDir);
|
|
230
|
-
const sessions = [];
|
|
231
|
-
|
|
232
|
-
for (const filePath of jsonlFiles) {
|
|
233
|
-
const session = await parseCodexSessionFile(filePath);
|
|
234
|
-
if (session) {
|
|
235
|
-
sessions.push(session);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
sessions.sort((a, b) => new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0));
|
|
240
|
-
return sessions.slice(0, options.sessionLimit ?? DEFAULT_SESSION_LIMIT);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function loadClaudeHistory(sessionId, options = {}) {
|
|
244
|
-
const homeDir = resolveHomeDir(options);
|
|
245
|
-
const projectsDir = options.claudeProjectsDir || path.join(homeDir, ".claude", "projects");
|
|
246
|
-
const sessionEntries = await findClaudeSessionEntries(projectsDir, sessionId);
|
|
247
|
-
|
|
248
|
-
if (!sessionEntries.length) {
|
|
249
|
-
return {
|
|
250
|
-
provider: "claude",
|
|
251
|
-
sessionId,
|
|
252
|
-
history: [],
|
|
253
|
-
source: null,
|
|
254
|
-
warning: `Claude session not found for ${sessionId}`
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const history = [];
|
|
259
|
-
const sorted = sessionEntries.sort(
|
|
260
|
-
(a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
for (const entry of sorted) {
|
|
264
|
-
const message = entry.message;
|
|
265
|
-
if (!message || !message.role || !message.content) {
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
const role = String(message.role).toLowerCase();
|
|
269
|
-
if (role !== "user" && role !== "assistant") {
|
|
270
|
-
continue;
|
|
271
|
-
}
|
|
272
|
-
const content = extractClaudeText(message.content);
|
|
273
|
-
if (!content) {
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
if (shouldSkipClaudeMessage(content)) {
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
history.push({ role, content });
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
return {
|
|
283
|
-
provider: "claude",
|
|
284
|
-
sessionId,
|
|
285
|
-
history: trimHistory(history, options.maxMessages),
|
|
286
|
-
source: sessionEntries[0]?.source || null
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
async function listClaudeSessions(options = {}) {
|
|
291
|
-
const homeDir = resolveHomeDir(options);
|
|
292
|
-
const projectsDir = options.claudeProjectsDir || path.join(homeDir, ".claude", "projects");
|
|
293
|
-
const sessions = new Map();
|
|
294
|
-
|
|
295
|
-
let projectDirs = [];
|
|
296
|
-
try {
|
|
297
|
-
projectDirs = await fsp.readdir(projectsDir, { withFileTypes: true });
|
|
298
|
-
} catch {
|
|
299
|
-
return [];
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
for (const projectDir of projectDirs) {
|
|
303
|
-
if (!projectDir.isDirectory()) {
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
const projectPath = path.join(projectsDir, projectDir.name);
|
|
307
|
-
let files = [];
|
|
308
|
-
try {
|
|
309
|
-
files = await fsp.readdir(projectPath, { withFileTypes: true });
|
|
310
|
-
} catch {
|
|
311
|
-
continue;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
for (const file of files) {
|
|
315
|
-
if (!file.isFile()) {
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
if (!file.name.endsWith(".jsonl") || file.name.startsWith("agent-")) {
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
const filePath = path.join(projectPath, file.name);
|
|
322
|
-
const rl = readline.createInterface({
|
|
323
|
-
input: fs.createReadStream(filePath),
|
|
324
|
-
crlfDelay: Infinity
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
for await (const line of rl) {
|
|
328
|
-
const trimmed = line.trim();
|
|
329
|
-
if (!trimmed) {
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
let entry;
|
|
333
|
-
try {
|
|
334
|
-
entry = JSON.parse(trimmed);
|
|
335
|
-
} catch {
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
if (!entry.sessionId) {
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
341
|
-
const sessionId = entry.sessionId;
|
|
342
|
-
const record = sessions.get(sessionId) || {
|
|
343
|
-
provider: "claude",
|
|
344
|
-
sessionId,
|
|
345
|
-
projectName: projectDir.name,
|
|
346
|
-
summary: "Claude Session",
|
|
347
|
-
lastActivity: null,
|
|
348
|
-
messageCount: 0,
|
|
349
|
-
source: filePath
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
if (entry.timestamp) {
|
|
353
|
-
record.lastActivity = entry.timestamp;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
if (entry.message?.role && entry.message?.content) {
|
|
357
|
-
const content = extractClaudeText(entry.message.content);
|
|
358
|
-
if (content && !shouldSkipClaudeMessage(content)) {
|
|
359
|
-
record.messageCount += 1;
|
|
360
|
-
if (entry.message.role === "user") {
|
|
361
|
-
record.summary = truncateSummary(content);
|
|
362
|
-
} else if (record.summary === "Claude Session") {
|
|
363
|
-
record.summary = truncateSummary(content);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
sessions.set(sessionId, record);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const list = Array.from(sessions.values());
|
|
374
|
-
list.sort((a, b) => new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0));
|
|
375
|
-
return list.slice(0, options.sessionLimit ?? DEFAULT_SESSION_LIMIT);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
async function findCodexSessionFile(rootDir, sessionId) {
|
|
379
|
-
const queue = [rootDir];
|
|
380
|
-
while (queue.length) {
|
|
381
|
-
const current = queue.pop();
|
|
382
|
-
let entries = [];
|
|
383
|
-
try {
|
|
384
|
-
entries = await fsp.readdir(current, { withFileTypes: true });
|
|
385
|
-
} catch {
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
for (const entry of entries) {
|
|
389
|
-
const fullPath = path.join(current, entry.name);
|
|
390
|
-
if (entry.isDirectory()) {
|
|
391
|
-
queue.push(fullPath);
|
|
392
|
-
} else if (
|
|
393
|
-
entry.isFile() &&
|
|
394
|
-
entry.name.includes(sessionId) &&
|
|
395
|
-
entry.name.endsWith(".jsonl")
|
|
396
|
-
) {
|
|
397
|
-
return fullPath;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
async function findClaudeSessionEntries(projectsDir, sessionId) {
|
|
405
|
-
const entries = [];
|
|
406
|
-
let projectDirs = [];
|
|
407
|
-
try {
|
|
408
|
-
projectDirs = await fsp.readdir(projectsDir, { withFileTypes: true });
|
|
409
|
-
} catch {
|
|
410
|
-
return entries;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
for (const projectDir of projectDirs) {
|
|
414
|
-
if (!projectDir.isDirectory()) {
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
const projectPath = path.join(projectsDir, projectDir.name);
|
|
418
|
-
let files = [];
|
|
419
|
-
try {
|
|
420
|
-
files = await fsp.readdir(projectPath, { withFileTypes: true });
|
|
421
|
-
} catch {
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
for (const file of files) {
|
|
426
|
-
if (!file.isFile()) {
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
if (!file.name.endsWith(".jsonl") || file.name.startsWith("agent-")) {
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const filePath = path.join(projectPath, file.name);
|
|
434
|
-
const rl = readline.createInterface({
|
|
435
|
-
input: fs.createReadStream(filePath),
|
|
436
|
-
crlfDelay: Infinity
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
for await (const line of rl) {
|
|
440
|
-
const trimmed = line.trim();
|
|
441
|
-
if (!trimmed) {
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
let entry;
|
|
445
|
-
try {
|
|
446
|
-
entry = JSON.parse(trimmed);
|
|
447
|
-
} catch {
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
if (entry.sessionId === sessionId) {
|
|
451
|
-
entries.push({ ...entry, source: filePath });
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
return entries;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
async function findJsonlFiles(rootDir) {
|
|
461
|
-
const files = [];
|
|
462
|
-
const queue = [rootDir];
|
|
463
|
-
while (queue.length) {
|
|
464
|
-
const current = queue.pop();
|
|
465
|
-
let entries = [];
|
|
466
|
-
try {
|
|
467
|
-
entries = await fsp.readdir(current, { withFileTypes: true });
|
|
468
|
-
} catch {
|
|
469
|
-
continue;
|
|
470
|
-
}
|
|
471
|
-
for (const entry of entries) {
|
|
472
|
-
const fullPath = path.join(current, entry.name);
|
|
473
|
-
if (entry.isDirectory()) {
|
|
474
|
-
queue.push(fullPath);
|
|
475
|
-
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
476
|
-
files.push(fullPath);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
return files;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
async function parseCodexSessionFile(filePath) {
|
|
484
|
-
const fileStream = fs.createReadStream(filePath);
|
|
485
|
-
const rl = readline.createInterface({
|
|
486
|
-
input: fileStream,
|
|
487
|
-
crlfDelay: Infinity
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
let sessionMeta = null;
|
|
491
|
-
let lastTimestamp = null;
|
|
492
|
-
let lastUserMessage = null;
|
|
493
|
-
let messageCount = 0;
|
|
494
|
-
|
|
495
|
-
for await (const line of rl) {
|
|
496
|
-
if (!line.trim()) {
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
let entry;
|
|
500
|
-
try {
|
|
501
|
-
entry = JSON.parse(line);
|
|
502
|
-
} catch {
|
|
503
|
-
continue;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
if (entry.timestamp) {
|
|
507
|
-
lastTimestamp = entry.timestamp;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
if (entry.type === "session_meta" && entry.payload) {
|
|
511
|
-
sessionMeta = {
|
|
512
|
-
id: entry.payload.id,
|
|
513
|
-
cwd: entry.payload.cwd,
|
|
514
|
-
model: entry.payload.model || entry.payload.model_provider,
|
|
515
|
-
timestamp: entry.timestamp
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if (entry.type === "event_msg" && entry.payload?.type === "user_message") {
|
|
520
|
-
messageCount += 1;
|
|
521
|
-
if (entry.payload.message) {
|
|
522
|
-
lastUserMessage = entry.payload.message;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
if (entry.type === "response_item" && entry.payload?.type === "message") {
|
|
527
|
-
messageCount += 1;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (!sessionMeta?.id) {
|
|
532
|
-
return null;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
return {
|
|
536
|
-
provider: "codex",
|
|
537
|
-
sessionId: sessionMeta.id,
|
|
538
|
-
summary: lastUserMessage ? truncateSummary(lastUserMessage) : "Codex Session",
|
|
539
|
-
lastActivity: lastTimestamp || sessionMeta.timestamp,
|
|
540
|
-
messageCount,
|
|
541
|
-
cwd: sessionMeta.cwd,
|
|
542
|
-
model: sessionMeta.model,
|
|
543
|
-
source: filePath
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function extractCodexText(content) {
|
|
548
|
-
if (!Array.isArray(content)) {
|
|
549
|
-
return typeof content === "string" ? content : "";
|
|
550
|
-
}
|
|
551
|
-
return content
|
|
552
|
-
.map((item) => {
|
|
553
|
-
if (item?.type === "input_text" || item?.type === "output_text" || item?.type === "text") {
|
|
554
|
-
return item.text;
|
|
555
|
-
}
|
|
556
|
-
return "";
|
|
557
|
-
})
|
|
558
|
-
.filter(Boolean)
|
|
559
|
-
.join("\n");
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function extractClaudeText(content) {
|
|
563
|
-
if (Array.isArray(content)) {
|
|
564
|
-
const parts = content
|
|
565
|
-
.map((part) => {
|
|
566
|
-
if (part?.type === "text") {
|
|
567
|
-
return part.text;
|
|
568
|
-
}
|
|
569
|
-
return "";
|
|
570
|
-
})
|
|
571
|
-
.filter(Boolean);
|
|
572
|
-
return parts.join("\n").trim();
|
|
573
|
-
}
|
|
574
|
-
if (typeof content === "string") {
|
|
575
|
-
return content.trim();
|
|
576
|
-
}
|
|
577
|
-
return String(content || "").trim();
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function shouldSkipClaudeMessage(content) {
|
|
581
|
-
if (!content) {
|
|
582
|
-
return true;
|
|
583
|
-
}
|
|
584
|
-
const trimmed = content.trim();
|
|
585
|
-
return (
|
|
586
|
-
trimmed.startsWith("<command-name>") ||
|
|
587
|
-
trimmed.startsWith("<command-message>") ||
|
|
588
|
-
trimmed.startsWith("<command-args>") ||
|
|
589
|
-
trimmed.startsWith("<local-command-stdout>") ||
|
|
590
|
-
trimmed.startsWith("<system-reminder>") ||
|
|
591
|
-
trimmed.startsWith("Caveat:") ||
|
|
592
|
-
trimmed.startsWith("This session is being continued from a previous") ||
|
|
593
|
-
trimmed.startsWith("[Request interrupted")
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function truncateSummary(value) {
|
|
598
|
-
const text = String(value || "").trim();
|
|
599
|
-
if (!text) {
|
|
600
|
-
return "";
|
|
601
|
-
}
|
|
602
|
-
return text.length > 60 ? `${text.slice(0, 60)}...` : text;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function formatSessionLine(session) {
|
|
606
|
-
const summary = session.summary || "Untitled session";
|
|
607
|
-
const when = session.lastActivity
|
|
608
|
-
? new Date(session.lastActivity).toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" })
|
|
609
|
-
: "unknown time";
|
|
610
|
-
const project = session.projectName ? ` · ${session.projectName}` : "";
|
|
611
|
-
return `${summary} (${session.sessionId}) · ${when}${project}`;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
export { SUPPORTED_FROM_PROVIDERS };
|