selftune 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/CHANGELOG.md +23 -0
- package/README.md +259 -0
- package/bin/selftune.cjs +29 -0
- package/cli/selftune/constants.ts +71 -0
- package/cli/selftune/eval/hooks-to-evals.ts +422 -0
- package/cli/selftune/evolution/audit.ts +44 -0
- package/cli/selftune/evolution/deploy-proposal.ts +244 -0
- package/cli/selftune/evolution/evolve.ts +406 -0
- package/cli/selftune/evolution/extract-patterns.ts +145 -0
- package/cli/selftune/evolution/propose-description.ts +146 -0
- package/cli/selftune/evolution/rollback.ts +242 -0
- package/cli/selftune/evolution/stopping-criteria.ts +69 -0
- package/cli/selftune/evolution/validate-proposal.ts +137 -0
- package/cli/selftune/grading/grade-session.ts +459 -0
- package/cli/selftune/hooks/prompt-log.ts +52 -0
- package/cli/selftune/hooks/session-stop.ts +54 -0
- package/cli/selftune/hooks/skill-eval.ts +73 -0
- package/cli/selftune/index.ts +104 -0
- package/cli/selftune/ingestors/codex-rollout.ts +416 -0
- package/cli/selftune/ingestors/codex-wrapper.ts +332 -0
- package/cli/selftune/ingestors/opencode-ingest.ts +565 -0
- package/cli/selftune/init.ts +297 -0
- package/cli/selftune/monitoring/watch.ts +328 -0
- package/cli/selftune/observability.ts +255 -0
- package/cli/selftune/types.ts +255 -0
- package/cli/selftune/utils/jsonl.ts +75 -0
- package/cli/selftune/utils/llm-call.ts +192 -0
- package/cli/selftune/utils/logging.ts +40 -0
- package/cli/selftune/utils/schema-validator.ts +47 -0
- package/cli/selftune/utils/seeded-random.ts +31 -0
- package/cli/selftune/utils/transcript.ts +260 -0
- package/package.json +29 -0
- package/skill/SKILL.md +120 -0
- package/skill/Workflows/Doctor.md +145 -0
- package/skill/Workflows/Evals.md +193 -0
- package/skill/Workflows/Evolve.md +159 -0
- package/skill/Workflows/Grade.md +157 -0
- package/skill/Workflows/Ingest.md +159 -0
- package/skill/Workflows/Initialize.md +125 -0
- package/skill/Workflows/Rollback.md +131 -0
- package/skill/Workflows/Watch.md +128 -0
- package/skill/references/grading-methodology.md +176 -0
- package/skill/references/invocation-taxonomy.md +144 -0
- package/skill/references/logs.md +168 -0
- package/skill/settings_snippet.json +41 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code PostToolUse hook: skill-eval.ts
|
|
4
|
+
*
|
|
5
|
+
* Fires whenever Claude reads a file. If that file is a SKILL.md, this hook:
|
|
6
|
+
* 1. Finds the triggering user query from the transcript JSONL
|
|
7
|
+
* 2. Appends a usage record to ~/.claude/skill_usage_log.jsonl
|
|
8
|
+
*
|
|
9
|
+
* This builds a real-usage eval dataset over time, seeding the
|
|
10
|
+
* `should_trigger: true` half of trigger evals.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { basename, dirname } from "node:path";
|
|
14
|
+
import { SKILL_LOG } from "../constants.js";
|
|
15
|
+
import type { PostToolUsePayload, SkillUsageRecord } from "../types.js";
|
|
16
|
+
import { appendJsonl } from "../utils/jsonl.js";
|
|
17
|
+
import { getLastUserMessage } from "../utils/transcript.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract the skill folder name from a file path ending in SKILL.md.
|
|
21
|
+
* Returns null if this doesn't look like a skill file.
|
|
22
|
+
*/
|
|
23
|
+
export function extractSkillName(filePath: string): string | null {
|
|
24
|
+
if (basename(filePath).toUpperCase() !== "SKILL.MD") return null;
|
|
25
|
+
return basename(dirname(filePath)) || "unknown";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Core processing logic, exported for testability.
|
|
30
|
+
* Returns the record that was appended, or null if skipped.
|
|
31
|
+
*/
|
|
32
|
+
export function processToolUse(
|
|
33
|
+
payload: PostToolUsePayload,
|
|
34
|
+
logPath: string = SKILL_LOG,
|
|
35
|
+
): SkillUsageRecord | null {
|
|
36
|
+
// Only care about Read tool
|
|
37
|
+
if (payload.tool_name !== "Read") return null;
|
|
38
|
+
|
|
39
|
+
const rawPath = payload.tool_input?.file_path;
|
|
40
|
+
const filePath = typeof rawPath === "string" ? rawPath : "";
|
|
41
|
+
const skillName = extractSkillName(filePath);
|
|
42
|
+
|
|
43
|
+
if (skillName === null) return null;
|
|
44
|
+
|
|
45
|
+
const transcriptPath = payload.transcript_path ?? "";
|
|
46
|
+
const sessionId = payload.session_id ?? "unknown";
|
|
47
|
+
|
|
48
|
+
const query = getLastUserMessage(transcriptPath) ?? "(query not found)";
|
|
49
|
+
|
|
50
|
+
const record: SkillUsageRecord = {
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
session_id: sessionId,
|
|
53
|
+
skill_name: skillName,
|
|
54
|
+
skill_path: filePath,
|
|
55
|
+
query,
|
|
56
|
+
triggered: true,
|
|
57
|
+
source: "claude_code",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
appendJsonl(logPath, record);
|
|
61
|
+
return record;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- stdin main (only when executed directly, not when imported) ---
|
|
65
|
+
if (import.meta.main) {
|
|
66
|
+
try {
|
|
67
|
+
const payload: PostToolUsePayload = JSON.parse(await Bun.stdin.text());
|
|
68
|
+
processToolUse(payload);
|
|
69
|
+
} catch {
|
|
70
|
+
// silent — hooks must never block Claude
|
|
71
|
+
}
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* selftune CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* selftune init [options] — Initialize agent identity and config
|
|
7
|
+
* selftune evals [options] — Generate eval sets from hook logs
|
|
8
|
+
* selftune grade [options] — Grade a skill session
|
|
9
|
+
* selftune ingest-codex [options] — Ingest Codex rollout logs
|
|
10
|
+
* selftune ingest-opencode [options] — Ingest OpenCode sessions
|
|
11
|
+
* selftune wrap-codex [options] — Wrap codex exec with telemetry
|
|
12
|
+
* selftune evolve [options] — Evolve a skill description via failure patterns
|
|
13
|
+
* selftune rollback [options] — Rollback a skill to its pre-evolution state
|
|
14
|
+
* selftune watch [options] — Monitor post-deploy skill health
|
|
15
|
+
* selftune doctor — Run health checks
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const command = process.argv[2];
|
|
19
|
+
|
|
20
|
+
if (!command || command === "--help" || command === "-h") {
|
|
21
|
+
console.log(`selftune — Skill observability and continuous improvement
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
selftune <command> [options]
|
|
25
|
+
|
|
26
|
+
Commands:
|
|
27
|
+
init Initialize agent identity and config
|
|
28
|
+
evals Generate eval sets from hook logs
|
|
29
|
+
grade Grade a skill session
|
|
30
|
+
ingest-codex Ingest Codex rollout logs
|
|
31
|
+
ingest-opencode Ingest OpenCode sessions
|
|
32
|
+
wrap-codex Wrap codex exec with telemetry
|
|
33
|
+
evolve Evolve a skill description via failure patterns
|
|
34
|
+
rollback Rollback a skill to its pre-evolution state
|
|
35
|
+
watch Monitor post-deploy skill health
|
|
36
|
+
doctor Run health checks
|
|
37
|
+
|
|
38
|
+
Run 'selftune <command> --help' for command-specific options.`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Route to the appropriate subcommand module.
|
|
43
|
+
// We use dynamic imports so only the needed module is loaded.
|
|
44
|
+
// Each module exports a cliMain() function that the router calls explicitly,
|
|
45
|
+
// since import.meta.main is false for dynamically imported modules.
|
|
46
|
+
process.argv = [process.argv[0], process.argv[1], ...process.argv.slice(3)];
|
|
47
|
+
|
|
48
|
+
switch (command) {
|
|
49
|
+
case "init": {
|
|
50
|
+
const { cliMain } = await import("./init.js");
|
|
51
|
+
await cliMain();
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case "evals": {
|
|
55
|
+
const { cliMain } = await import("./eval/hooks-to-evals.js");
|
|
56
|
+
cliMain();
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case "grade": {
|
|
60
|
+
const { cliMain } = await import("./grading/grade-session.js");
|
|
61
|
+
await cliMain();
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "ingest-codex": {
|
|
65
|
+
const { cliMain } = await import("./ingestors/codex-rollout.js");
|
|
66
|
+
cliMain();
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "ingest-opencode": {
|
|
70
|
+
const { cliMain } = await import("./ingestors/opencode-ingest.js");
|
|
71
|
+
cliMain();
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "wrap-codex": {
|
|
75
|
+
const { cliMain } = await import("./ingestors/codex-wrapper.js");
|
|
76
|
+
await cliMain();
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "evolve": {
|
|
80
|
+
const { cliMain } = await import("./evolution/evolve.js");
|
|
81
|
+
await cliMain();
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "rollback": {
|
|
85
|
+
const { cliMain } = await import("./evolution/rollback.js");
|
|
86
|
+
await cliMain();
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "watch": {
|
|
90
|
+
const { cliMain } = await import("./monitoring/watch.js");
|
|
91
|
+
await cliMain();
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "doctor": {
|
|
95
|
+
const { doctor } = await import("./observability.js");
|
|
96
|
+
const result = doctor();
|
|
97
|
+
console.log(JSON.stringify(result, null, 2));
|
|
98
|
+
process.exit(result.healthy ? 0 : 1);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
console.error(`Unknown command: ${command}\nRun 'selftune --help' for available commands.`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Codex rollout ingestor: codex-rollout.ts
|
|
4
|
+
*
|
|
5
|
+
* Retroactively ingests Codex's auto-written rollout logs into our shared
|
|
6
|
+
* skill eval log format.
|
|
7
|
+
*
|
|
8
|
+
* Codex CLI saves every session to:
|
|
9
|
+
* $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<thread_id>.jsonl
|
|
10
|
+
*
|
|
11
|
+
* This script scans those files and populates:
|
|
12
|
+
* ~/.claude/all_queries_log.jsonl
|
|
13
|
+
* ~/.claude/session_telemetry_log.jsonl
|
|
14
|
+
* ~/.claude/skill_usage_log.jsonl
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* bun codex-rollout.ts
|
|
18
|
+
* bun codex-rollout.ts --since 2026-01-01
|
|
19
|
+
* bun codex-rollout.ts --codex-home /custom/path
|
|
20
|
+
* bun codex-rollout.ts --dry-run
|
|
21
|
+
* bun codex-rollout.ts --force
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { basename, join } from "node:path";
|
|
27
|
+
import { parseArgs } from "node:util";
|
|
28
|
+
import { QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "../constants.js";
|
|
29
|
+
import type { QueryLogRecord, SessionTelemetryRecord, SkillUsageRecord } from "../types.js";
|
|
30
|
+
import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
|
|
31
|
+
|
|
32
|
+
const MARKER_FILE = join(homedir(), ".claude", "codex_ingested_rollouts.json");
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CODEX_HOME = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
35
|
+
|
|
36
|
+
const CODEX_SKILLS_DIRS = [
|
|
37
|
+
join(process.cwd(), ".codex", "skills"),
|
|
38
|
+
join(homedir(), ".codex", "skills"),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Return skill names from Codex skill directories. */
|
|
42
|
+
export function findSkillNames(dirs: string[] = CODEX_SKILLS_DIRS): Set<string> {
|
|
43
|
+
const names = new Set<string>();
|
|
44
|
+
for (const dir of dirs) {
|
|
45
|
+
if (!existsSync(dir)) continue;
|
|
46
|
+
for (const entry of readdirSync(dir)) {
|
|
47
|
+
const skillDir = join(dir, entry);
|
|
48
|
+
try {
|
|
49
|
+
if (statSync(skillDir).isDirectory() && existsSync(join(skillDir, "SKILL.md"))) {
|
|
50
|
+
names.add(entry);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// skip entries that can't be stat'd (broken symlinks, permission errors, etc.)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Find all rollout-*.jsonl files under codexHome/sessions/YYYY/MM/DD/.
|
|
62
|
+
* If `since` is given, only return files from that date onward.
|
|
63
|
+
*/
|
|
64
|
+
export function findRolloutFiles(codexHome: string, since?: Date): string[] {
|
|
65
|
+
const sessionsDir = join(codexHome, "sessions");
|
|
66
|
+
if (!existsSync(sessionsDir)) return [];
|
|
67
|
+
|
|
68
|
+
const files: string[] = [];
|
|
69
|
+
|
|
70
|
+
for (const yearEntry of readdirSync(sessionsDir).sort()) {
|
|
71
|
+
const yearDir = join(sessionsDir, yearEntry);
|
|
72
|
+
try {
|
|
73
|
+
if (!statSync(yearDir).isDirectory()) continue;
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const year = Number.parseInt(yearEntry, 10);
|
|
78
|
+
if (Number.isNaN(year)) continue;
|
|
79
|
+
|
|
80
|
+
for (const monthEntry of readdirSync(yearDir).sort()) {
|
|
81
|
+
const monthDir = join(yearDir, monthEntry);
|
|
82
|
+
try {
|
|
83
|
+
if (!statSync(monthDir).isDirectory()) continue;
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const month = Number.parseInt(monthEntry, 10);
|
|
88
|
+
if (Number.isNaN(month)) continue;
|
|
89
|
+
|
|
90
|
+
for (const dayEntry of readdirSync(monthDir).sort()) {
|
|
91
|
+
const dayDir = join(monthDir, dayEntry);
|
|
92
|
+
try {
|
|
93
|
+
if (!statSync(dayDir).isDirectory()) continue;
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const day = Number.parseInt(dayEntry, 10);
|
|
98
|
+
if (Number.isNaN(day)) continue;
|
|
99
|
+
|
|
100
|
+
if (since) {
|
|
101
|
+
const fileDate = new Date(year, month - 1, day);
|
|
102
|
+
if (fileDate < since) continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const file of readdirSync(dayDir).sort()) {
|
|
106
|
+
if (file.startsWith("rollout-") && file.endsWith(".jsonl")) {
|
|
107
|
+
files.push(join(dayDir, file));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return files;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ParsedRollout {
|
|
118
|
+
timestamp: string;
|
|
119
|
+
session_id: string;
|
|
120
|
+
source: string;
|
|
121
|
+
rollout_path: string;
|
|
122
|
+
query: string;
|
|
123
|
+
tool_calls: Record<string, number>;
|
|
124
|
+
total_tool_calls: number;
|
|
125
|
+
bash_commands: string[];
|
|
126
|
+
skills_triggered: string[];
|
|
127
|
+
assistant_turns: number;
|
|
128
|
+
errors_encountered: number;
|
|
129
|
+
input_tokens: number;
|
|
130
|
+
output_tokens: number;
|
|
131
|
+
transcript_chars: number;
|
|
132
|
+
cwd: string;
|
|
133
|
+
transcript_path: string;
|
|
134
|
+
last_user_query: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Parse a Codex rollout JSONL file.
|
|
139
|
+
* Returns parsed data or null if the file is empty/unparseable.
|
|
140
|
+
*/
|
|
141
|
+
export function parseRolloutFile(path: string, skillNames: Set<string>): ParsedRollout | null {
|
|
142
|
+
let content: string;
|
|
143
|
+
try {
|
|
144
|
+
content = readFileSync(path, "utf-8");
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const lines = content
|
|
150
|
+
.split("\n")
|
|
151
|
+
.map((l) => l.trim())
|
|
152
|
+
.filter((l) => l.length > 0);
|
|
153
|
+
|
|
154
|
+
if (lines.length === 0) return null;
|
|
155
|
+
|
|
156
|
+
const threadId = basename(path, ".jsonl").replace("rollout-", "");
|
|
157
|
+
let prompt = "";
|
|
158
|
+
const toolCalls: Record<string, number> = {};
|
|
159
|
+
const bashCommands: string[] = [];
|
|
160
|
+
const skillsTriggered: string[] = [];
|
|
161
|
+
let errors = 0;
|
|
162
|
+
let turns = 0;
|
|
163
|
+
let inputTokens = 0;
|
|
164
|
+
let outputTokens = 0;
|
|
165
|
+
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
let event: Record<string, unknown>;
|
|
168
|
+
try {
|
|
169
|
+
event = JSON.parse(line);
|
|
170
|
+
} catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const etype = (event.type as string) ?? "";
|
|
175
|
+
|
|
176
|
+
if (etype === "turn.started") {
|
|
177
|
+
turns += 1;
|
|
178
|
+
} else if (etype === "turn.completed") {
|
|
179
|
+
const usage = (event.usage as Record<string, number>) ?? {};
|
|
180
|
+
inputTokens += usage.input_tokens ?? 0;
|
|
181
|
+
outputTokens += usage.output_tokens ?? 0;
|
|
182
|
+
if (!prompt) {
|
|
183
|
+
prompt = (event.user_message as string) ?? "";
|
|
184
|
+
}
|
|
185
|
+
} else if (etype === "turn.failed") {
|
|
186
|
+
errors += 1;
|
|
187
|
+
} else if (etype === "item.completed" || etype === "item.started" || etype === "item.updated") {
|
|
188
|
+
const item = (event.item as Record<string, unknown>) ?? {};
|
|
189
|
+
const itemType = (item.item_type as string) ?? (item.type as string) ?? "";
|
|
190
|
+
|
|
191
|
+
if (etype === "item.completed") {
|
|
192
|
+
if (itemType === "command_execution") {
|
|
193
|
+
toolCalls.command_execution = (toolCalls.command_execution ?? 0) + 1;
|
|
194
|
+
const cmd = ((item.command as string) ?? "").trim();
|
|
195
|
+
if (cmd) bashCommands.push(cmd);
|
|
196
|
+
if ((item.exit_code as number) !== 0 && item.exit_code !== undefined) {
|
|
197
|
+
errors += 1;
|
|
198
|
+
}
|
|
199
|
+
} else if (itemType === "file_change") {
|
|
200
|
+
toolCalls.file_change = (toolCalls.file_change ?? 0) + 1;
|
|
201
|
+
} else if (itemType === "mcp_tool_call") {
|
|
202
|
+
toolCalls.mcp_tool_call = (toolCalls.mcp_tool_call ?? 0) + 1;
|
|
203
|
+
} else if (itemType === "web_search") {
|
|
204
|
+
toolCalls.web_search = (toolCalls.web_search ?? 0) + 1;
|
|
205
|
+
} else if (itemType === "reasoning") {
|
|
206
|
+
toolCalls.reasoning = (toolCalls.reasoning ?? 0) + 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Detect skill names in text content on completed events
|
|
211
|
+
const textContent = ((item.text as string) ?? "") + ((item.command as string) ?? "");
|
|
212
|
+
for (const skillName of skillNames) {
|
|
213
|
+
if (
|
|
214
|
+
textContent.includes(skillName) &&
|
|
215
|
+
!skillsTriggered.includes(skillName) &&
|
|
216
|
+
etype === "item.completed"
|
|
217
|
+
) {
|
|
218
|
+
skillsTriggered.push(skillName);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else if (etype === "error") {
|
|
222
|
+
errors += 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Some rollout formats embed the original prompt
|
|
226
|
+
if (!prompt && (event.prompt as string)) {
|
|
227
|
+
prompt = event.prompt as string;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Infer file date from path structure: .../YYYY/MM/DD/rollout-*.jsonl
|
|
232
|
+
let fileDate: string;
|
|
233
|
+
const parts = path.split("/");
|
|
234
|
+
try {
|
|
235
|
+
const dayStr = parts[parts.length - 2];
|
|
236
|
+
const monthStr = parts[parts.length - 3];
|
|
237
|
+
const yearStr = parts[parts.length - 4];
|
|
238
|
+
const year = Number.parseInt(yearStr, 10);
|
|
239
|
+
const month = Number.parseInt(monthStr, 10);
|
|
240
|
+
const day = Number.parseInt(dayStr, 10);
|
|
241
|
+
if (!Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
|
|
242
|
+
fileDate = new Date(Date.UTC(year, month - 1, day)).toISOString();
|
|
243
|
+
} else {
|
|
244
|
+
fileDate = new Date().toISOString();
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
fileDate = new Date().toISOString();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
timestamp: fileDate,
|
|
252
|
+
session_id: threadId,
|
|
253
|
+
source: "codex_rollout",
|
|
254
|
+
rollout_path: path,
|
|
255
|
+
query: prompt,
|
|
256
|
+
tool_calls: toolCalls,
|
|
257
|
+
total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
|
|
258
|
+
bash_commands: bashCommands,
|
|
259
|
+
skills_triggered: skillsTriggered,
|
|
260
|
+
assistant_turns: turns,
|
|
261
|
+
errors_encountered: errors,
|
|
262
|
+
input_tokens: inputTokens,
|
|
263
|
+
output_tokens: outputTokens,
|
|
264
|
+
transcript_chars: lines.reduce((sum, l) => sum + l.length, 0),
|
|
265
|
+
cwd: "",
|
|
266
|
+
transcript_path: path,
|
|
267
|
+
last_user_query: prompt,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Write parsed session data to shared logs. */
|
|
272
|
+
export function ingestFile(
|
|
273
|
+
parsed: ParsedRollout,
|
|
274
|
+
dryRun = false,
|
|
275
|
+
queryLogPath: string = QUERY_LOG,
|
|
276
|
+
telemetryLogPath: string = TELEMETRY_LOG,
|
|
277
|
+
skillLogPath: string = SKILL_LOG,
|
|
278
|
+
): boolean {
|
|
279
|
+
const { query: prompt, session_id: sessionId, skills_triggered: skills } = parsed;
|
|
280
|
+
|
|
281
|
+
if (dryRun) {
|
|
282
|
+
console.log(
|
|
283
|
+
` [DRY RUN] Would ingest: session=${sessionId.slice(0, 12)}... ` +
|
|
284
|
+
`turns=${parsed.assistant_turns} commands=${parsed.bash_commands.length} skills=${JSON.stringify(skills)}`,
|
|
285
|
+
);
|
|
286
|
+
if (prompt) console.log(` query: ${prompt.slice(0, 80)}`);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Write to all_queries_log if we have a prompt
|
|
291
|
+
if (prompt && prompt.length >= 4) {
|
|
292
|
+
const queryRecord: QueryLogRecord = {
|
|
293
|
+
timestamp: parsed.timestamp,
|
|
294
|
+
session_id: sessionId,
|
|
295
|
+
query: prompt,
|
|
296
|
+
source: "codex_rollout",
|
|
297
|
+
};
|
|
298
|
+
appendJsonl(queryLogPath, queryRecord, "all_queries");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Write telemetry — explicitly select SessionTelemetryRecord fields
|
|
302
|
+
const telemetry: SessionTelemetryRecord = {
|
|
303
|
+
timestamp: parsed.timestamp,
|
|
304
|
+
session_id: sessionId,
|
|
305
|
+
cwd: parsed.cwd,
|
|
306
|
+
transcript_path: parsed.transcript_path,
|
|
307
|
+
tool_calls: parsed.tool_calls,
|
|
308
|
+
total_tool_calls: parsed.total_tool_calls,
|
|
309
|
+
bash_commands: parsed.bash_commands,
|
|
310
|
+
skills_triggered: skills,
|
|
311
|
+
assistant_turns: parsed.assistant_turns,
|
|
312
|
+
errors_encountered: parsed.errors_encountered,
|
|
313
|
+
transcript_chars: parsed.transcript_chars,
|
|
314
|
+
last_user_query: parsed.last_user_query,
|
|
315
|
+
source: parsed.source,
|
|
316
|
+
input_tokens: parsed.input_tokens,
|
|
317
|
+
output_tokens: parsed.output_tokens,
|
|
318
|
+
rollout_path: parsed.rollout_path,
|
|
319
|
+
};
|
|
320
|
+
appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
|
|
321
|
+
|
|
322
|
+
// Write skill triggers
|
|
323
|
+
for (const skillName of skills) {
|
|
324
|
+
const skillRecord: SkillUsageRecord = {
|
|
325
|
+
timestamp: parsed.timestamp,
|
|
326
|
+
session_id: sessionId,
|
|
327
|
+
skill_name: skillName,
|
|
328
|
+
skill_path: `(codex:${skillName})`,
|
|
329
|
+
query: prompt,
|
|
330
|
+
triggered: true,
|
|
331
|
+
source: "codex_rollout",
|
|
332
|
+
};
|
|
333
|
+
appendJsonl(skillLogPath, skillRecord, "skill_usage");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- CLI main ---
|
|
340
|
+
export function cliMain(): void {
|
|
341
|
+
const { values } = parseArgs({
|
|
342
|
+
options: {
|
|
343
|
+
"codex-home": { type: "string", default: DEFAULT_CODEX_HOME },
|
|
344
|
+
since: { type: "string" },
|
|
345
|
+
"dry-run": { type: "boolean", default: false },
|
|
346
|
+
force: { type: "boolean", default: false },
|
|
347
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
348
|
+
},
|
|
349
|
+
strict: true,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const codexHome = values["codex-home"] ?? DEFAULT_CODEX_HOME;
|
|
353
|
+
let since: Date | undefined;
|
|
354
|
+
if (values.since) {
|
|
355
|
+
since = new Date(values.since);
|
|
356
|
+
if (Number.isNaN(since.getTime())) {
|
|
357
|
+
console.error(
|
|
358
|
+
`Error: Invalid --since date: "${values.since}". Use a valid date format (e.g., 2026-01-01).`,
|
|
359
|
+
);
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const rolloutFiles = findRolloutFiles(codexHome, since);
|
|
365
|
+
if (rolloutFiles.length === 0) {
|
|
366
|
+
console.log(`No rollout files found under ${codexHome}/sessions/`);
|
|
367
|
+
console.log("Make sure CODEX_HOME is correct and you've run some `codex exec` sessions.");
|
|
368
|
+
process.exit(0);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const alreadyIngested = values.force ? new Set<string>() : loadMarker(MARKER_FILE);
|
|
372
|
+
const skillNames = findSkillNames();
|
|
373
|
+
const newIngested = new Set<string>();
|
|
374
|
+
|
|
375
|
+
const pending = rolloutFiles.filter((f) => !alreadyIngested.has(f));
|
|
376
|
+
console.log(`Found ${rolloutFiles.length} rollout files, ${pending.length} not yet ingested.`);
|
|
377
|
+
|
|
378
|
+
if (since) {
|
|
379
|
+
console.log(` Filtering to sessions from ${values.since} onward.`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let ingestedCount = 0;
|
|
383
|
+
let skippedCount = 0;
|
|
384
|
+
|
|
385
|
+
for (const rolloutFile of pending) {
|
|
386
|
+
const parsed = parseRolloutFile(rolloutFile, skillNames);
|
|
387
|
+
if (parsed === null) {
|
|
388
|
+
if (values.verbose) {
|
|
389
|
+
console.log(` SKIP (empty/unparseable): ${basename(rolloutFile)}`);
|
|
390
|
+
}
|
|
391
|
+
skippedCount += 1;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (values.verbose || values["dry-run"]) {
|
|
396
|
+
console.log(` ${values["dry-run"] ? "[DRY] " : ""}Ingesting: ${basename(rolloutFile)}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
ingestFile(parsed, values["dry-run"]);
|
|
400
|
+
newIngested.add(rolloutFile);
|
|
401
|
+
ingestedCount += 1;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!values["dry-run"]) {
|
|
405
|
+
saveMarker(MARKER_FILE, new Set([...alreadyIngested, ...newIngested]));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
console.log(`\nDone. Ingested ${ingestedCount} sessions, skipped ${skippedCount}.`);
|
|
409
|
+
if (newIngested.size > 0 && !values["dry-run"]) {
|
|
410
|
+
console.log(`Marker updated: ${MARKER_FILE}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (import.meta.main) {
|
|
415
|
+
cliMain();
|
|
416
|
+
}
|