threadlog 0.1.0
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/LICENSE +3 -0
- package/README.md +67 -0
- package/dist/agents/claude/adapter.js +147 -0
- package/dist/agents/claude/discovery.js +57 -0
- package/dist/agents/claude/title.js +91 -0
- package/dist/agents/codex/adapter.js +64 -0
- package/dist/agents/registry.js +17 -0
- package/dist/agents/types.js +2 -0
- package/dist/codex/discovery.js +63 -0
- package/dist/codex/metadata.js +55 -0
- package/dist/codex/promptPreview.js +133 -0
- package/dist/commands/login.js +26 -0
- package/dist/commands/logout.js +9 -0
- package/dist/commands/shareClaude.js +4 -0
- package/dist/commands/shareCodex.js +4 -0
- package/dist/commands/shareSource.js +68 -0
- package/dist/config/config.js +151 -0
- package/dist/config/credentials.js +180 -0
- package/dist/index.js +64 -0
- package/dist/lib/format.js +23 -0
- package/dist/redaction/redact.js +19 -0
- package/dist/thread/canonical.js +50 -0
- package/dist/upload/artifact.js +14 -0
- package/dist/upload/client.js +114 -0
- package/package.json +54 -0
package/LICENSE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Threadlog CLI
|
|
2
|
+
|
|
3
|
+
`threadlog` uploads local Codex and Claude sessions to Threadlog.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js `>=20`
|
|
8
|
+
- macOS or Linux
|
|
9
|
+
- a system keyring
|
|
10
|
+
- macOS: Keychain via `security`
|
|
11
|
+
- Linux: Secret Service via `secret-tool`
|
|
12
|
+
- a Threadlog account
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install -g threadlog
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or run with `npx`
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npx threadlog --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## First Use
|
|
27
|
+
|
|
28
|
+
Log in once with a personal access token:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
threadlog login
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
You can also pass the token explicitly:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
threadlog login --token tl_pat_xxx
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
By default the CLI targets production:
|
|
41
|
+
|
|
42
|
+
- API: `https://api.threadlog.dev`
|
|
43
|
+
- app: `https://app.threadlog.dev`
|
|
44
|
+
|
|
45
|
+
## Share Sessions
|
|
46
|
+
|
|
47
|
+
Interactive Codex picker:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
threadlog share codex
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Interactive Claude picker:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
threadlog share claude
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Explicit file upload:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
threadlog share codex --file /path/to/rollout.jsonl
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
threadlog share claude --file /path/to/session.jsonl
|
|
67
|
+
```
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { stat, readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildRedactedRawEventArrayFromJsonl } from "../../thread/canonical.js";
|
|
4
|
+
import { RAW_ARTIFACT_VERSION } from "../types.js";
|
|
5
|
+
import { claudeProjectsDir, discoverClaudeSessions } from "./discovery.js";
|
|
6
|
+
import { readClaudePromptPreviewTitle } from "./title.js";
|
|
7
|
+
export function createClaudeSourceAdapter() {
|
|
8
|
+
return {
|
|
9
|
+
source: "claude",
|
|
10
|
+
label: "Claude",
|
|
11
|
+
discoverSessions: discoverClaudeSourceSessions,
|
|
12
|
+
resolveExplicitFilePath,
|
|
13
|
+
buildUploadInput: buildClaudeUploadInput,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
async function discoverClaudeSourceSessions(options) {
|
|
17
|
+
const root = claudeProjectsDir();
|
|
18
|
+
const sessions = await discoverClaudeSessions({ maxResults: options.maxResults });
|
|
19
|
+
return Promise.all(sessions.map(async (session) => {
|
|
20
|
+
const promptPreview = await readClaudePromptPreviewTitle(session.filePath).catch(() => null);
|
|
21
|
+
const relativePath = path.relative(root, session.filePath) || session.filePath;
|
|
22
|
+
return {
|
|
23
|
+
mainFilePath: session.filePath,
|
|
24
|
+
displayName: promptPreview ?? path.basename(session.filePath),
|
|
25
|
+
description: `${session.workspaceSlug} ${relativePath}`,
|
|
26
|
+
mtimeMs: session.mtimeMs,
|
|
27
|
+
sizeBytes: session.sizeBytes,
|
|
28
|
+
};
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
async function buildClaudeUploadInput(mainFilePath) {
|
|
32
|
+
const title = await readClaudePromptPreviewTitle(mainFilePath).catch(() => null);
|
|
33
|
+
const mainEvents = await buildRedactedRawEventArrayFromJsonl(mainFilePath);
|
|
34
|
+
const capturedAt = extractFirstTimestamp(mainEvents);
|
|
35
|
+
const files = [
|
|
36
|
+
{
|
|
37
|
+
filePath: mainFilePath,
|
|
38
|
+
fileRole: "main",
|
|
39
|
+
events: mainEvents,
|
|
40
|
+
isSidechain: extractFirstBoolean(mainEvents, "isSidechain"),
|
|
41
|
+
agentId: extractFirstString(mainEvents, "agentId"),
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
const subagentFiles = await discoverSubagentSessionFiles(mainFilePath);
|
|
45
|
+
for (const subagentFilePath of subagentFiles) {
|
|
46
|
+
const subagentEvents = await buildRedactedRawEventArrayFromJsonl(subagentFilePath).catch((error) => {
|
|
47
|
+
if (error instanceof Error && error.message.includes("does not contain any JSONL events")) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
if (!subagentEvents || subagentEvents.length === 0) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const fallbackAgentId = extractAgentIdFromFileName(subagentFilePath);
|
|
56
|
+
files.push({
|
|
57
|
+
filePath: subagentFilePath,
|
|
58
|
+
fileRole: "subagent",
|
|
59
|
+
events: subagentEvents,
|
|
60
|
+
isSidechain: extractFirstBoolean(subagentEvents, "isSidechain"),
|
|
61
|
+
agentId: extractFirstString(subagentEvents, "agentId") ?? fallbackAgentId,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
artifact: {
|
|
66
|
+
rawVersion: RAW_ARTIFACT_VERSION,
|
|
67
|
+
source: "claude",
|
|
68
|
+
files,
|
|
69
|
+
},
|
|
70
|
+
title: title ?? path.basename(mainFilePath),
|
|
71
|
+
capturedAt: capturedAt ?? undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function resolveExplicitFilePath(inputPath) {
|
|
75
|
+
const resolved = path.resolve(inputPath);
|
|
76
|
+
const fileStats = await stat(resolved).catch((error) => {
|
|
77
|
+
if (error.code === "ENOENT") {
|
|
78
|
+
throw new Error(`session file not found: ${resolved}`);
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
});
|
|
82
|
+
if (!fileStats.isFile()) {
|
|
83
|
+
throw new Error(`session path must be a file: ${resolved}`);
|
|
84
|
+
}
|
|
85
|
+
if (!resolved.endsWith(".jsonl")) {
|
|
86
|
+
throw new Error(`session file must end with .jsonl: ${resolved}`);
|
|
87
|
+
}
|
|
88
|
+
return resolved;
|
|
89
|
+
}
|
|
90
|
+
async function discoverSubagentSessionFiles(mainFilePath) {
|
|
91
|
+
const sessionId = path.basename(mainFilePath, ".jsonl");
|
|
92
|
+
const subagentsDir = path.join(path.dirname(mainFilePath), sessionId, "subagents");
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = await readdir(subagentsDir, { encoding: "utf8" });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === "ENOENT") {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
return entries
|
|
104
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
105
|
+
.sort((a, b) => a.localeCompare(b))
|
|
106
|
+
.map((name) => path.join(subagentsDir, name));
|
|
107
|
+
}
|
|
108
|
+
function extractAgentIdFromFileName(filePath) {
|
|
109
|
+
const match = path.basename(filePath).match(/^agent-(.+)\.jsonl$/);
|
|
110
|
+
return match?.[1];
|
|
111
|
+
}
|
|
112
|
+
function extractFirstString(events, field) {
|
|
113
|
+
for (const event of events) {
|
|
114
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const value = event[field];
|
|
118
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
119
|
+
return value.trim();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
function extractFirstBoolean(events, field) {
|
|
125
|
+
for (const event of events) {
|
|
126
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const value = event[field];
|
|
130
|
+
if (typeof value === "boolean") {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
function extractFirstTimestamp(events) {
|
|
137
|
+
for (const event of events) {
|
|
138
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const timestamp = event.timestamp;
|
|
142
|
+
if (typeof timestamp === "string" && timestamp.trim().length > 0) {
|
|
143
|
+
return timestamp;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function claudeProjectsDir() {
|
|
5
|
+
const overridden = process.env.CLAUDE_HOME?.trim();
|
|
6
|
+
if (overridden) {
|
|
7
|
+
return path.join(path.resolve(overridden), "projects");
|
|
8
|
+
}
|
|
9
|
+
return path.join(os.homedir(), ".claude", "projects");
|
|
10
|
+
}
|
|
11
|
+
export async function discoverClaudeSessions(options) {
|
|
12
|
+
const projectsRoot = options?.projectsRoot
|
|
13
|
+
? path.resolve(options.projectsRoot)
|
|
14
|
+
: claudeProjectsDir();
|
|
15
|
+
const workspaceSlugs = await readDirs(projectsRoot);
|
|
16
|
+
const matches = [];
|
|
17
|
+
for (const slug of workspaceSlugs) {
|
|
18
|
+
const slugPath = path.join(projectsRoot, slug.name);
|
|
19
|
+
const entries = await readEntries(slugPath);
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const filePath = path.join(slugPath, entry.name);
|
|
25
|
+
const fileStats = await stat(filePath);
|
|
26
|
+
matches.push({
|
|
27
|
+
filePath,
|
|
28
|
+
workspaceSlug: slug.name,
|
|
29
|
+
sizeBytes: fileStats.size,
|
|
30
|
+
mtimeMs: fileStats.mtimeMs,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
matches.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
35
|
+
if (!options?.maxResults || options.maxResults <= 0) {
|
|
36
|
+
return matches;
|
|
37
|
+
}
|
|
38
|
+
return matches.slice(0, options.maxResults);
|
|
39
|
+
}
|
|
40
|
+
async function readDirs(rootDir) {
|
|
41
|
+
const entries = await readEntries(rootDir);
|
|
42
|
+
return entries.filter((entry) => entry.isDirectory());
|
|
43
|
+
}
|
|
44
|
+
async function readEntries(rootDir) {
|
|
45
|
+
try {
|
|
46
|
+
return await readdir(rootDir, {
|
|
47
|
+
withFileTypes: true,
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === "ENOENT") {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { truncateForThreadTitle } from "../../codex/promptPreview.js";
|
|
4
|
+
export async function readClaudePromptPreviewTitle(filePath) {
|
|
5
|
+
const reader = createInterface({
|
|
6
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
7
|
+
crlfDelay: Infinity,
|
|
8
|
+
});
|
|
9
|
+
let fallbackText = null;
|
|
10
|
+
for await (const rawLine of reader) {
|
|
11
|
+
const line = rawLine.trim();
|
|
12
|
+
if (!line) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const parsed = safeParseRecord(line);
|
|
16
|
+
if (!parsed) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const primaryUserMessage = extractPrimaryUserMessage(parsed);
|
|
20
|
+
if (primaryUserMessage) {
|
|
21
|
+
return truncateForThreadTitle(primaryUserMessage);
|
|
22
|
+
}
|
|
23
|
+
if (!fallbackText) {
|
|
24
|
+
fallbackText = extractFallbackText(parsed);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return truncateForThreadTitle(fallbackText);
|
|
28
|
+
}
|
|
29
|
+
function extractPrimaryUserMessage(event) {
|
|
30
|
+
if (asString(event.type)?.toLowerCase() !== "user") {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return firstNonBlank(extractText(event.message), extractText(event.content), asString(event.text), extractText(event.payload));
|
|
34
|
+
}
|
|
35
|
+
function extractFallbackText(event) {
|
|
36
|
+
return firstNonBlank(asString(event.type), extractText(event.message), extractText(event.content), asString(event.text), extractText(event.payload));
|
|
37
|
+
}
|
|
38
|
+
function extractText(value) {
|
|
39
|
+
if (typeof value === "string") {
|
|
40
|
+
const trimmed = value.trim();
|
|
41
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
const joined = value
|
|
45
|
+
.map((item) => extractText(item))
|
|
46
|
+
.filter((text) => Boolean(text))
|
|
47
|
+
.join("\n")
|
|
48
|
+
.trim();
|
|
49
|
+
return joined || null;
|
|
50
|
+
}
|
|
51
|
+
if (!value || typeof value !== "object") {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const obj = value;
|
|
55
|
+
const joined = ["text", "message", "content", "input", "thinking", "summary", "value"]
|
|
56
|
+
.map((key) => extractText(obj[key]))
|
|
57
|
+
.filter((text) => Boolean(text))
|
|
58
|
+
.join("\n")
|
|
59
|
+
.trim();
|
|
60
|
+
return joined || null;
|
|
61
|
+
}
|
|
62
|
+
function safeParseRecord(line) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(line);
|
|
65
|
+
return asRecord(parsed);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function asRecord(value) {
|
|
72
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function asString(value) {
|
|
78
|
+
if (typeof value !== "string") {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const trimmed = value.trim();
|
|
82
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
83
|
+
}
|
|
84
|
+
function firstNonBlank(...values) {
|
|
85
|
+
for (const value of values) {
|
|
86
|
+
if (value && value.trim().length > 0) {
|
|
87
|
+
return value.trim();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildRedactedRawEventArrayFromJsonl } from "../../thread/canonical.js";
|
|
4
|
+
import { codexHomeDir, discoverCodexRollouts } from "../../codex/discovery.js";
|
|
5
|
+
import { readCodexRolloutMetadata } from "../../codex/metadata.js";
|
|
6
|
+
import { readCodexPromptPreviewTitle } from "../../codex/promptPreview.js";
|
|
7
|
+
import { RAW_ARTIFACT_VERSION } from "../types.js";
|
|
8
|
+
export function createCodexSourceAdapter() {
|
|
9
|
+
return {
|
|
10
|
+
source: "codex",
|
|
11
|
+
label: "Codex",
|
|
12
|
+
discoverSessions: discoverCodexSessions,
|
|
13
|
+
resolveExplicitFilePath,
|
|
14
|
+
buildUploadInput: buildCodexUploadInput,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function discoverCodexSessions(options) {
|
|
18
|
+
const codexHome = codexHomeDir();
|
|
19
|
+
const rollouts = await discoverCodexRollouts({ maxResults: options.maxResults });
|
|
20
|
+
return Promise.all(rollouts.map(async (rollout) => {
|
|
21
|
+
const relativePath = path.relative(codexHome, rollout.filePath) || rollout.filePath;
|
|
22
|
+
const promptPreview = await readCodexPromptPreviewTitle(rollout.filePath).catch(() => null);
|
|
23
|
+
return {
|
|
24
|
+
mainFilePath: rollout.filePath,
|
|
25
|
+
displayName: promptPreview ?? path.basename(rollout.filePath),
|
|
26
|
+
description: relativePath,
|
|
27
|
+
mtimeMs: rollout.mtimeMs,
|
|
28
|
+
sizeBytes: rollout.sizeBytes,
|
|
29
|
+
};
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
async function buildCodexUploadInput(mainFilePath) {
|
|
33
|
+
const metadata = await readCodexRolloutMetadata(mainFilePath);
|
|
34
|
+
const events = await buildRedactedRawEventArrayFromJsonl(mainFilePath);
|
|
35
|
+
const title = await readCodexPromptPreviewTitle(mainFilePath).catch(() => null);
|
|
36
|
+
return {
|
|
37
|
+
artifact: {
|
|
38
|
+
rawVersion: RAW_ARTIFACT_VERSION,
|
|
39
|
+
source: "codex",
|
|
40
|
+
files: [
|
|
41
|
+
{
|
|
42
|
+
filePath: mainFilePath,
|
|
43
|
+
fileRole: "main",
|
|
44
|
+
events,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
title: title ?? path.basename(mainFilePath),
|
|
49
|
+
capturedAt: metadata.firstTimestamp ?? undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function resolveExplicitFilePath(inputPath) {
|
|
53
|
+
const resolved = path.resolve(inputPath);
|
|
54
|
+
const fileStats = await stat(resolved).catch((error) => {
|
|
55
|
+
if (error.code === "ENOENT") {
|
|
56
|
+
throw new Error(`session file not found: ${resolved}`);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
});
|
|
60
|
+
if (!fileStats.isFile()) {
|
|
61
|
+
throw new Error(`session path must be a file: ${resolved}`);
|
|
62
|
+
}
|
|
63
|
+
return resolved;
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createClaudeSourceAdapter } from "./claude/adapter.js";
|
|
2
|
+
import { createCodexSourceAdapter } from "./codex/adapter.js";
|
|
3
|
+
const SOURCE_ADAPTERS = [
|
|
4
|
+
createCodexSourceAdapter(),
|
|
5
|
+
createClaudeSourceAdapter(),
|
|
6
|
+
];
|
|
7
|
+
const SOURCE_ADAPTER_BY_ID = new Map(SOURCE_ADAPTERS.map((adapter) => [adapter.source, adapter]));
|
|
8
|
+
export function sourceAdapterById(source) {
|
|
9
|
+
const adapter = SOURCE_ADAPTER_BY_ID.get(source);
|
|
10
|
+
if (!adapter) {
|
|
11
|
+
throw new Error(`unsupported source: ${source}`);
|
|
12
|
+
}
|
|
13
|
+
return adapter;
|
|
14
|
+
}
|
|
15
|
+
export function supportedThreadSources() {
|
|
16
|
+
return [...SOURCE_ADAPTER_BY_ID.keys()];
|
|
17
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function codexHomeDir() {
|
|
5
|
+
const overridden = process.env.CODEX_HOME?.trim();
|
|
6
|
+
if (overridden) {
|
|
7
|
+
return path.resolve(overridden);
|
|
8
|
+
}
|
|
9
|
+
return path.join(os.homedir(), ".codex");
|
|
10
|
+
}
|
|
11
|
+
export async function discoverCodexRollouts(options) {
|
|
12
|
+
const sessionsRoot = options?.sessionsRoot
|
|
13
|
+
? path.resolve(options.sessionsRoot)
|
|
14
|
+
: path.join(codexHomeDir(), "sessions");
|
|
15
|
+
const paths = await walkRolloutFiles(sessionsRoot);
|
|
16
|
+
const files = await Promise.all(paths.map(async (filePath) => {
|
|
17
|
+
const fileStats = await stat(filePath);
|
|
18
|
+
return {
|
|
19
|
+
filePath,
|
|
20
|
+
sizeBytes: fileStats.size,
|
|
21
|
+
mtimeMs: fileStats.mtimeMs,
|
|
22
|
+
};
|
|
23
|
+
}));
|
|
24
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
25
|
+
if (!options?.maxResults || options.maxResults <= 0) {
|
|
26
|
+
return files;
|
|
27
|
+
}
|
|
28
|
+
return files.slice(0, options.maxResults);
|
|
29
|
+
}
|
|
30
|
+
async function walkRolloutFiles(rootDir) {
|
|
31
|
+
const pending = [rootDir];
|
|
32
|
+
const matches = [];
|
|
33
|
+
while (pending.length > 0) {
|
|
34
|
+
const current = pending.pop();
|
|
35
|
+
if (!current) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = await readdir(current, {
|
|
41
|
+
withFileTypes: true,
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "ENOENT") {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const fullPath = path.join(current, entry.name);
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
pending.push(fullPath);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
|
|
58
|
+
matches.push(fullPath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return matches;
|
|
63
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
export async function readCodexRolloutMetadata(filePath) {
|
|
5
|
+
const fileStats = await stat(filePath);
|
|
6
|
+
let lineCount = 0;
|
|
7
|
+
let firstTimestamp = null;
|
|
8
|
+
let lastTimestamp = null;
|
|
9
|
+
const lineReader = createInterface({
|
|
10
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
11
|
+
crlfDelay: Infinity,
|
|
12
|
+
});
|
|
13
|
+
for await (const rawLine of lineReader) {
|
|
14
|
+
const line = rawLine.trim();
|
|
15
|
+
if (!line) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
lineCount += 1;
|
|
19
|
+
const parsed = safeParse(line);
|
|
20
|
+
if (!parsed || typeof parsed !== "object") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const timestamp = pickString(parsed, ["timestamp", "time"]);
|
|
24
|
+
if (!timestamp) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!firstTimestamp) {
|
|
28
|
+
firstTimestamp = timestamp;
|
|
29
|
+
}
|
|
30
|
+
lastTimestamp = timestamp;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
lineCount,
|
|
34
|
+
firstTimestamp,
|
|
35
|
+
lastTimestamp,
|
|
36
|
+
sizeBytes: fileStats.size,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function safeParse(line) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(line);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function pickString(obj, keys) {
|
|
48
|
+
for (const key of keys) {
|
|
49
|
+
const value = obj[key];
|
|
50
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|