linkshell-cli 0.3.7 → 0.3.9
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/dist/cli/src/runtime/acp/agent-workspace.d.ts +1 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +52 -12
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-sessions.d.ts +17 -0
- package/dist/cli/src/runtime/acp/claude-sessions.js +147 -1
- package/dist/cli/src/runtime/acp/claude-sessions.js.map +1 -1
- package/dist/cli/src/runtime/acp/codex-sessions.d.ts +17 -0
- package/dist/cli/src/runtime/acp/codex-sessions.js +163 -3
- package/dist/cli/src/runtime/acp/codex-sessions.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/runtime/acp/agent-workspace.ts +54 -20
- package/src/runtime/acp/claude-sessions.ts +160 -1
- package/src/runtime/acp/codex-sessions.ts +178 -3
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
import { AcpClient } from "./acp-client.js";
|
|
10
10
|
import { ClaudeSdkClient } from "./claude-sdk-client.js";
|
|
11
11
|
import { ClaudeStreamJsonClient } from "./claude-stream-json-client.js";
|
|
12
|
-
import { listClaudeStoredSessions } from "./claude-sessions.js";
|
|
13
|
-
import { listCodexStoredSessions } from "./codex-sessions.js";
|
|
12
|
+
import { listClaudeStoredSessions, loadClaudeStoredTimeline } from "./claude-sessions.js";
|
|
13
|
+
import { listCodexStoredSessions, loadCodexStoredTimeline } from "./codex-sessions.js";
|
|
14
14
|
import type { AgentProtocol, AgentProvider } from "./provider-resolver.js";
|
|
15
15
|
import { resolveAgentCommand } from "./provider-resolver.js";
|
|
16
16
|
|
|
@@ -1564,24 +1564,6 @@ export class AgentWorkspaceProxy {
|
|
|
1564
1564
|
title?: string;
|
|
1565
1565
|
}): Promise<AgentConversation | undefined> {
|
|
1566
1566
|
const provider = payload.provider ?? this.input.availableProviders[0];
|
|
1567
|
-
if (!provider) {
|
|
1568
|
-
return this.openFailure(payload, "没有可用的 Agent provider。");
|
|
1569
|
-
}
|
|
1570
|
-
if (!this.input.availableProviders.includes(provider)) {
|
|
1571
|
-
return this.openFailure(
|
|
1572
|
-
payload,
|
|
1573
|
-
`${providerLabel(provider)} 未安装或不可用。`,
|
|
1574
|
-
);
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
const client = await this.ensureProviderClient(provider);
|
|
1578
|
-
if (!client) {
|
|
1579
|
-
return this.openFailure(
|
|
1580
|
-
payload,
|
|
1581
|
-
`${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`,
|
|
1582
|
-
);
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
1567
|
const cwd = payload.cwd ?? this.input.cwd;
|
|
1586
1568
|
let agentSessionId = payload.agentSessionId;
|
|
1587
1569
|
let existingConversation =
|
|
@@ -1592,6 +1574,7 @@ export class AgentWorkspaceProxy {
|
|
|
1592
1574
|
if (payload.conversationId && existingConversation.id !== payload.conversationId) {
|
|
1593
1575
|
existingConversation = this.adoptConversationId(existingConversation.id, payload.conversationId);
|
|
1594
1576
|
}
|
|
1577
|
+
this.hydrateStoredTimeline(existingConversation);
|
|
1595
1578
|
this.activeConversationId = existingConversation.id;
|
|
1596
1579
|
this.input.send(createEnvelope({
|
|
1597
1580
|
type: "agent.v2.conversation.opened",
|
|
@@ -1604,6 +1587,24 @@ export class AgentWorkspaceProxy {
|
|
|
1604
1587
|
return existingConversation;
|
|
1605
1588
|
}
|
|
1606
1589
|
|
|
1590
|
+
if (!provider) {
|
|
1591
|
+
return this.openFailure(payload, "没有可用的 Agent provider。");
|
|
1592
|
+
}
|
|
1593
|
+
if (!this.input.availableProviders.includes(provider)) {
|
|
1594
|
+
return this.openFailure(
|
|
1595
|
+
payload,
|
|
1596
|
+
`${providerLabel(provider)} 未安装或不可用。`,
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const client = await this.ensureProviderClient(provider);
|
|
1601
|
+
if (!client) {
|
|
1602
|
+
return this.openFailure(
|
|
1603
|
+
payload,
|
|
1604
|
+
`${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`,
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1607
1608
|
try {
|
|
1608
1609
|
const result = agentSessionId
|
|
1609
1610
|
? await client.loadSession({ sessionId: agentSessionId, cwd })
|
|
@@ -1632,6 +1633,7 @@ export class AgentWorkspaceProxy {
|
|
|
1632
1633
|
this.conversationByAgentSessionId.set(agentSessionId, conversation.id);
|
|
1633
1634
|
this.activeConversationId = conversation.id;
|
|
1634
1635
|
this.timelines.set(conversation.id, this.timelines.get(conversation.id) ?? []);
|
|
1636
|
+
this.hydrateStoredTimeline(conversation);
|
|
1635
1637
|
this.input.send(createEnvelope({
|
|
1636
1638
|
type: "agent.v2.conversation.opened",
|
|
1637
1639
|
hostDeviceId: this.input.hostDeviceId,
|
|
@@ -2973,6 +2975,13 @@ export class AgentWorkspaceProxy {
|
|
|
2973
2975
|
}
|
|
2974
2976
|
|
|
2975
2977
|
private sendSnapshot(conversationId?: string): void {
|
|
2978
|
+
if (conversationId) {
|
|
2979
|
+
const conversation = this.conversations.get(conversationId);
|
|
2980
|
+
if (conversation) this.hydrateStoredTimeline(conversation);
|
|
2981
|
+
} else if (this.activeConversationId) {
|
|
2982
|
+
const conversation = this.conversations.get(this.activeConversationId);
|
|
2983
|
+
if (conversation) this.hydrateStoredTimeline(conversation);
|
|
2984
|
+
}
|
|
2976
2985
|
const conversations = [...this.conversations.values()];
|
|
2977
2986
|
const items = conversationId
|
|
2978
2987
|
? this.timelines.get(conversationId) ?? []
|
|
@@ -2988,6 +2997,31 @@ export class AgentWorkspaceProxy {
|
|
|
2988
2997
|
}));
|
|
2989
2998
|
}
|
|
2990
2999
|
|
|
3000
|
+
private hydrateStoredTimeline(conversation: AgentConversation): void {
|
|
3001
|
+
if (!conversation.agentSessionId) return;
|
|
3002
|
+
const existing = this.timelines.get(conversation.id) ?? [];
|
|
3003
|
+
if (existing.length > 0) return;
|
|
3004
|
+
const result = conversation.provider === "codex"
|
|
3005
|
+
? loadCodexStoredTimeline(conversation.agentSessionId, conversation.id, conversation.cwd || this.input.cwd)
|
|
3006
|
+
: conversation.provider === "claude"
|
|
3007
|
+
? loadClaudeStoredTimeline(conversation.agentSessionId, conversation.id)
|
|
3008
|
+
: { items: [] };
|
|
3009
|
+
if (result.items.length === 0) return;
|
|
3010
|
+
const items = result.items
|
|
3011
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
3012
|
+
.slice(-MAX_TIMELINE_ITEMS) as AgentTimelineItem[];
|
|
3013
|
+
this.timelines.set(conversation.id, items);
|
|
3014
|
+
for (const item of items) this.rememberItemConversationId(conversation.id, item);
|
|
3015
|
+
const lastMessage = [...items].reverse().find((item) => item.text?.trim());
|
|
3016
|
+
if (lastMessage?.text && !conversation.lastMessagePreview) {
|
|
3017
|
+
conversation.lastMessagePreview = previewText(lastMessage.text);
|
|
3018
|
+
}
|
|
3019
|
+
const lastActivityAt = items.at(-1)?.createdAt;
|
|
3020
|
+
if (lastActivityAt) {
|
|
3021
|
+
conversation.lastActivityAt = Math.max(conversation.lastActivityAt, lastActivityAt);
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
|
|
2991
3025
|
private conversationIdFromParams(params: unknown): string | undefined {
|
|
2992
3026
|
const raw = asRecord(params);
|
|
2993
3027
|
if (!raw) return undefined;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, readdirSync, readSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, join, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
const SAMPLE_BYTES = 64 * 1024;
|
|
6
|
+
const HISTORY_BYTES = 2 * 1024 * 1024;
|
|
6
7
|
const MAX_SESSIONS = 200;
|
|
8
|
+
const MAX_HISTORY_ITEMS = 200;
|
|
7
9
|
|
|
8
10
|
export interface ClaudeStoredSession {
|
|
9
11
|
id: string;
|
|
@@ -13,6 +15,18 @@ export interface ClaudeStoredSession {
|
|
|
13
15
|
lastModified: number;
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
export interface StoredAgentTimelineItem {
|
|
19
|
+
id: string;
|
|
20
|
+
conversationId: string;
|
|
21
|
+
type: "message";
|
|
22
|
+
role: "user" | "assistant" | "system";
|
|
23
|
+
content: Array<{ type: "text"; text: string }>;
|
|
24
|
+
text: string;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
updatedAt?: number;
|
|
27
|
+
metadata?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
17
31
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
18
32
|
? value as Record<string, unknown>
|
|
@@ -70,6 +84,32 @@ function extractMessageText(value: unknown): string | undefined {
|
|
|
70
84
|
return extractMessageText(record.content ?? record.text);
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
function extractHistoryText(value: unknown): string | undefined {
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
const text = value.replace(/\r\n/g, "\n").trim();
|
|
90
|
+
return text || undefined;
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
const text = value
|
|
94
|
+
.map((part) => {
|
|
95
|
+
if (typeof part === "string") return part;
|
|
96
|
+
const record = asRecord(part);
|
|
97
|
+
return typeof record?.text === "string"
|
|
98
|
+
? record.text
|
|
99
|
+
: typeof record?.content === "string"
|
|
100
|
+
? record.content
|
|
101
|
+
: "";
|
|
102
|
+
})
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.join("\n")
|
|
105
|
+
.trim();
|
|
106
|
+
return text || undefined;
|
|
107
|
+
}
|
|
108
|
+
const record = asRecord(value);
|
|
109
|
+
if (!record) return undefined;
|
|
110
|
+
return extractHistoryText(record.content ?? record.text ?? record.message);
|
|
111
|
+
}
|
|
112
|
+
|
|
73
113
|
function guessCwdFromProjectDir(projectDirName: string, fallbackCwd: string): string {
|
|
74
114
|
const trimmed = projectDirName.replace(/^-+/, "");
|
|
75
115
|
if (!trimmed) return resolve(fallbackCwd);
|
|
@@ -104,6 +144,32 @@ function readSample(filePath: string, size: number): string {
|
|
|
104
144
|
}
|
|
105
145
|
}
|
|
106
146
|
|
|
147
|
+
function readHistorySample(filePath: string, size: number): string {
|
|
148
|
+
try {
|
|
149
|
+
if (size <= HISTORY_BYTES) return readFileSync(filePath, "utf8");
|
|
150
|
+
} catch {
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let fd: number | undefined;
|
|
155
|
+
try {
|
|
156
|
+
fd = openSync(filePath, "r");
|
|
157
|
+
const buffer = Buffer.alloc(HISTORY_BYTES);
|
|
158
|
+
const bytesRead = readSync(fd, buffer, 0, HISTORY_BYTES, Math.max(0, size - HISTORY_BYTES));
|
|
159
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
160
|
+
} catch {
|
|
161
|
+
return "";
|
|
162
|
+
} finally {
|
|
163
|
+
if (typeof fd === "number") {
|
|
164
|
+
try {
|
|
165
|
+
closeSync(fd);
|
|
166
|
+
} catch {
|
|
167
|
+
// Ignore close failures while reading best-effort local history.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
107
173
|
function readClaudeSessionMetadata(filePath: string, fallbackCwd: string): Omit<ClaudeStoredSession, "id" | "lastModified"> & {
|
|
108
174
|
lastModified?: number;
|
|
109
175
|
} {
|
|
@@ -185,3 +251,96 @@ export function listClaudeStoredSessions(inputCwd: string): { sessions: ClaudeSt
|
|
|
185
251
|
sessions.sort((a, b) => b.lastModified - a.lastModified);
|
|
186
252
|
return { sessions: sessions.slice(0, MAX_SESSIONS) };
|
|
187
253
|
}
|
|
254
|
+
|
|
255
|
+
function findClaudeSessionFile(sessionId: string): string | undefined {
|
|
256
|
+
const root = join(homedir(), ".claude", "projects");
|
|
257
|
+
if (!existsSync(root)) return undefined;
|
|
258
|
+
try {
|
|
259
|
+
for (const projectEntry of readdirSync(root, { withFileTypes: true })) {
|
|
260
|
+
if (!projectEntry.isDirectory()) continue;
|
|
261
|
+
const projectDir = join(root, projectEntry.name);
|
|
262
|
+
for (const sessionEntry of readdirSync(projectDir, { withFileTypes: true })) {
|
|
263
|
+
if (!sessionEntry.isFile() || !sessionEntry.name.endsWith(".jsonl")) continue;
|
|
264
|
+
if (basename(sessionEntry.name, ".jsonl") === sessionId) {
|
|
265
|
+
return join(projectDir, sessionEntry.name);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function historyMessage(
|
|
276
|
+
conversationId: string,
|
|
277
|
+
index: number,
|
|
278
|
+
role: "user" | "assistant" | "system",
|
|
279
|
+
text: string,
|
|
280
|
+
createdAt: number,
|
|
281
|
+
source: string,
|
|
282
|
+
): StoredAgentTimelineItem {
|
|
283
|
+
return {
|
|
284
|
+
id: `history:${source}:${index}`,
|
|
285
|
+
conversationId,
|
|
286
|
+
type: "message",
|
|
287
|
+
role,
|
|
288
|
+
content: [{ type: "text", text }],
|
|
289
|
+
text,
|
|
290
|
+
createdAt,
|
|
291
|
+
metadata: { source: "device-history", provider: "claude" },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function loadClaudeStoredTimeline(
|
|
296
|
+
sessionId: string,
|
|
297
|
+
conversationId: string,
|
|
298
|
+
): { items: StoredAgentTimelineItem[] } {
|
|
299
|
+
const filePath = findClaudeSessionFile(sessionId);
|
|
300
|
+
if (!filePath) return { items: [] };
|
|
301
|
+
let statMtime = Date.now();
|
|
302
|
+
let statSize = 0;
|
|
303
|
+
try {
|
|
304
|
+
const stat = statSync(filePath);
|
|
305
|
+
statMtime = stat.mtimeMs;
|
|
306
|
+
statSize = stat.size;
|
|
307
|
+
} catch {
|
|
308
|
+
return { items: [] };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const items: StoredAgentTimelineItem[] = [];
|
|
312
|
+
let index = 0;
|
|
313
|
+
for (const line of readHistorySample(filePath, statSize).split(/\r?\n/)) {
|
|
314
|
+
const trimmed = line.trim();
|
|
315
|
+
if (!trimmed.startsWith("{")) continue;
|
|
316
|
+
try {
|
|
317
|
+
const record = asRecord(JSON.parse(trimmed));
|
|
318
|
+
if (!record) continue;
|
|
319
|
+
const message = asRecord(record.message);
|
|
320
|
+
const rawRole = typeof record.role === "string"
|
|
321
|
+
? record.role
|
|
322
|
+
: typeof message?.role === "string"
|
|
323
|
+
? message.role
|
|
324
|
+
: typeof record.type === "string"
|
|
325
|
+
? record.type
|
|
326
|
+
: undefined;
|
|
327
|
+
const role = rawRole === "assistant" ? "assistant" : rawRole === "user" ? "user" : undefined;
|
|
328
|
+
if (!role) continue;
|
|
329
|
+
const text = extractHistoryText(message?.content ?? record.content ?? record.text);
|
|
330
|
+
if (!text) continue;
|
|
331
|
+
const createdAt =
|
|
332
|
+
parseTimestamp(record.timestamp ?? record.createdAt ?? record.created_at) ??
|
|
333
|
+
statMtime + index;
|
|
334
|
+
items.push(historyMessage(conversationId, index, role, text, createdAt, sessionId));
|
|
335
|
+
index += 1;
|
|
336
|
+
} catch {
|
|
337
|
+
// Ignore malformed or partial JSONL lines in the history window.
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
items: items
|
|
343
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
344
|
+
.slice(-MAX_HISTORY_ITEMS),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, readdirSync, readSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, join, resolve } from "node:path";
|
|
4
4
|
|
|
5
|
-
const SAMPLE_BYTES =
|
|
5
|
+
const SAMPLE_BYTES = 512 * 1024;
|
|
6
|
+
const HISTORY_BYTES = 2 * 1024 * 1024;
|
|
6
7
|
const MAX_SESSIONS = 200;
|
|
8
|
+
const MAX_HISTORY_ITEMS = 200;
|
|
7
9
|
|
|
8
10
|
export interface CodexStoredSession {
|
|
9
11
|
id: string;
|
|
@@ -14,6 +16,18 @@ export interface CodexStoredSession {
|
|
|
14
16
|
archived?: boolean;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
export interface StoredAgentTimelineItem {
|
|
20
|
+
id: string;
|
|
21
|
+
conversationId: string;
|
|
22
|
+
type: "message";
|
|
23
|
+
role: "user" | "assistant" | "system";
|
|
24
|
+
content: Array<{ type: "text"; text: string }>;
|
|
25
|
+
text: string;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
updatedAt?: number;
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
interface CodexIndexEntry {
|
|
18
32
|
id: string;
|
|
19
33
|
title?: string;
|
|
@@ -77,6 +91,32 @@ function readSample(filePath: string, size: number): string {
|
|
|
77
91
|
}
|
|
78
92
|
}
|
|
79
93
|
|
|
94
|
+
function readHistorySample(filePath: string, size: number): string {
|
|
95
|
+
try {
|
|
96
|
+
if (size <= HISTORY_BYTES) return readFileSync(filePath, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let fd: number | undefined;
|
|
102
|
+
try {
|
|
103
|
+
fd = openSync(filePath, "r");
|
|
104
|
+
const buffer = Buffer.alloc(HISTORY_BYTES);
|
|
105
|
+
const bytesRead = readSync(fd, buffer, 0, HISTORY_BYTES, Math.max(0, size - HISTORY_BYTES));
|
|
106
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
107
|
+
} catch {
|
|
108
|
+
return "";
|
|
109
|
+
} finally {
|
|
110
|
+
if (typeof fd === "number") {
|
|
111
|
+
try {
|
|
112
|
+
closeSync(fd);
|
|
113
|
+
} catch {
|
|
114
|
+
// Best-effort local history scan.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
80
120
|
function loadSessionIndex(root: string): Map<string, CodexIndexEntry> {
|
|
81
121
|
const indexPath = join(root, "session_index.jsonl");
|
|
82
122
|
const entries = new Map<string, CodexIndexEntry>();
|
|
@@ -129,6 +169,7 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
|
|
|
129
169
|
|
|
130
170
|
let id = sessionIdFromRolloutFile(filePath);
|
|
131
171
|
let cwd: string | undefined;
|
|
172
|
+
let title: string | undefined;
|
|
132
173
|
let createdAt: number | undefined;
|
|
133
174
|
let lastActivityAt: number | undefined;
|
|
134
175
|
for (const line of readSample(filePath, statSize).split(/\r?\n/)) {
|
|
@@ -142,6 +183,9 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
|
|
|
142
183
|
if (typeof payload.cwd === "string" && payload.cwd.trim()) cwd = payload.cwd;
|
|
143
184
|
createdAt ??= parseTimestamp(payload.timestamp);
|
|
144
185
|
}
|
|
186
|
+
if (entry?.type === "event_msg" && payload?.type === "user_message") {
|
|
187
|
+
title ??= normalizeTitle(normalizeHistoryText(payload.message));
|
|
188
|
+
}
|
|
145
189
|
const timestamp = parseTimestamp(entry?.timestamp);
|
|
146
190
|
createdAt ??= timestamp;
|
|
147
191
|
if (timestamp) lastActivityAt = timestamp;
|
|
@@ -154,6 +198,7 @@ function readCodexSessionFile(filePath: string, fallbackCwd: string): Omit<Codex
|
|
|
154
198
|
return {
|
|
155
199
|
id,
|
|
156
200
|
cwd: cwd ?? resolve(fallbackCwd),
|
|
201
|
+
title,
|
|
157
202
|
createdAt,
|
|
158
203
|
lastModified: lastActivityAt ?? statMtime,
|
|
159
204
|
};
|
|
@@ -175,6 +220,83 @@ function collectJsonlFiles(dir: string, result: Array<{ path: string; archived:
|
|
|
175
220
|
}
|
|
176
221
|
}
|
|
177
222
|
|
|
223
|
+
function findCodexSessionFile(sessionId: string, inputCwd: string): { path: string; archived: boolean } | undefined {
|
|
224
|
+
const root = join(homedir(), ".codex");
|
|
225
|
+
if (!existsSync(root)) return undefined;
|
|
226
|
+
const files: Array<{ path: string; archived: boolean }> = [];
|
|
227
|
+
collectJsonlFiles(join(root, "sessions"), files, false);
|
|
228
|
+
collectJsonlFiles(join(root, "archived_sessions"), files, true);
|
|
229
|
+
|
|
230
|
+
let best: { path: string; archived: boolean; lastModified: number } | undefined;
|
|
231
|
+
for (const file of files) {
|
|
232
|
+
const fileId = sessionIdFromRolloutFile(file.path);
|
|
233
|
+
let metadata: ReturnType<typeof readCodexSessionFile> | undefined;
|
|
234
|
+
if (fileId !== sessionId) {
|
|
235
|
+
metadata = readCodexSessionFile(file.path, inputCwd);
|
|
236
|
+
if (metadata?.id !== sessionId) continue;
|
|
237
|
+
}
|
|
238
|
+
let lastModified = metadata?.lastModified;
|
|
239
|
+
if (!lastModified) {
|
|
240
|
+
try {
|
|
241
|
+
lastModified = statSync(file.path).mtimeMs;
|
|
242
|
+
} catch {
|
|
243
|
+
lastModified = 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (!best || file.archived || lastModified > best.lastModified) {
|
|
247
|
+
best = { ...file, lastModified };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return best;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizeHistoryText(value: unknown): string | undefined {
|
|
254
|
+
if (typeof value === "string") {
|
|
255
|
+
const text = value.replace(/\r\n/g, "\n").trim();
|
|
256
|
+
return text || undefined;
|
|
257
|
+
}
|
|
258
|
+
if (Array.isArray(value)) {
|
|
259
|
+
const text = value
|
|
260
|
+
.map((part) => {
|
|
261
|
+
if (typeof part === "string") return part;
|
|
262
|
+
const record = asRecord(part);
|
|
263
|
+
return typeof record?.text === "string"
|
|
264
|
+
? record.text
|
|
265
|
+
: typeof record?.content === "string"
|
|
266
|
+
? record.content
|
|
267
|
+
: "";
|
|
268
|
+
})
|
|
269
|
+
.filter(Boolean)
|
|
270
|
+
.join("\n")
|
|
271
|
+
.trim();
|
|
272
|
+
return text || undefined;
|
|
273
|
+
}
|
|
274
|
+
const record = asRecord(value);
|
|
275
|
+
if (!record) return undefined;
|
|
276
|
+
return normalizeHistoryText(record.content ?? record.text ?? record.message);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function historyMessage(
|
|
280
|
+
conversationId: string,
|
|
281
|
+
index: number,
|
|
282
|
+
role: "user" | "assistant" | "system",
|
|
283
|
+
text: string,
|
|
284
|
+
createdAt: number,
|
|
285
|
+
source: string,
|
|
286
|
+
): StoredAgentTimelineItem {
|
|
287
|
+
const id = `history:${source}:${index}`;
|
|
288
|
+
return {
|
|
289
|
+
id,
|
|
290
|
+
conversationId,
|
|
291
|
+
type: "message",
|
|
292
|
+
role,
|
|
293
|
+
content: [{ type: "text", text }],
|
|
294
|
+
text,
|
|
295
|
+
createdAt,
|
|
296
|
+
metadata: { source: "device-history", provider: "codex" },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
178
300
|
export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStoredSession[] } {
|
|
179
301
|
const root = join(homedir(), ".codex");
|
|
180
302
|
if (!existsSync(root)) return { sessions: [] };
|
|
@@ -192,7 +314,7 @@ export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStor
|
|
|
192
314
|
const session: CodexStoredSession = {
|
|
193
315
|
id: metadata.id,
|
|
194
316
|
cwd: metadata.cwd,
|
|
195
|
-
title: indexed?.title,
|
|
317
|
+
title: indexed?.title ?? metadata.title,
|
|
196
318
|
createdAt: metadata.createdAt,
|
|
197
319
|
lastModified: indexed?.updatedAt ?? metadata.lastModified ?? Date.now(),
|
|
198
320
|
archived: file.archived,
|
|
@@ -220,3 +342,56 @@ export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStor
|
|
|
220
342
|
.slice(0, MAX_SESSIONS),
|
|
221
343
|
};
|
|
222
344
|
}
|
|
345
|
+
|
|
346
|
+
export function loadCodexStoredTimeline(
|
|
347
|
+
sessionId: string,
|
|
348
|
+
conversationId: string,
|
|
349
|
+
inputCwd: string,
|
|
350
|
+
): { items: StoredAgentTimelineItem[] } {
|
|
351
|
+
const file = findCodexSessionFile(sessionId, inputCwd);
|
|
352
|
+
if (!file) return { items: [] };
|
|
353
|
+
let statMtime = Date.now();
|
|
354
|
+
let statSize = 0;
|
|
355
|
+
try {
|
|
356
|
+
const stat = statSync(file.path);
|
|
357
|
+
statMtime = stat.mtimeMs;
|
|
358
|
+
statSize = stat.size;
|
|
359
|
+
} catch {
|
|
360
|
+
return { items: [] };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const items: StoredAgentTimelineItem[] = [];
|
|
364
|
+
let index = 0;
|
|
365
|
+
for (const line of readHistorySample(file.path, statSize).split(/\r?\n/)) {
|
|
366
|
+
const trimmed = line.trim();
|
|
367
|
+
if (!trimmed.startsWith("{")) continue;
|
|
368
|
+
try {
|
|
369
|
+
const entry = asRecord(JSON.parse(trimmed));
|
|
370
|
+
const payload = asRecord(entry?.payload);
|
|
371
|
+
if (entry?.type !== "event_msg" || !payload) continue;
|
|
372
|
+
const eventType = typeof payload.type === "string" ? payload.type : undefined;
|
|
373
|
+
const createdAt =
|
|
374
|
+
parseTimestamp(entry.timestamp ?? payload.created_at ?? payload.started_at ?? payload.completed_at) ??
|
|
375
|
+
statMtime + index;
|
|
376
|
+
if (eventType === "user_message") {
|
|
377
|
+
const text = normalizeHistoryText(payload.message);
|
|
378
|
+
if (!text || text.startsWith("<turn_aborted>")) continue;
|
|
379
|
+
items.push(historyMessage(conversationId, index, "user", text, createdAt, sessionId));
|
|
380
|
+
index += 1;
|
|
381
|
+
} else if (eventType === "agent_message") {
|
|
382
|
+
const text = normalizeHistoryText(payload.message);
|
|
383
|
+
if (!text) continue;
|
|
384
|
+
items.push(historyMessage(conversationId, index, "assistant", text, createdAt, sessionId));
|
|
385
|
+
index += 1;
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
// Ignore malformed or partial JSONL lines in the history window.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
items: items
|
|
394
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
395
|
+
.slice(-MAX_HISTORY_ITEMS),
|
|
396
|
+
};
|
|
397
|
+
}
|