selftune 0.1.2 → 0.2.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/.claude/agents/diagnosis-analyst.md +146 -0
- package/.claude/agents/evolution-reviewer.md +167 -0
- package/.claude/agents/integration-guide.md +200 -0
- package/.claude/agents/pattern-analyst.md +147 -0
- package/CHANGELOG.md +38 -1
- package/README.md +96 -256
- package/assets/BeforeAfter.gif +0 -0
- package/assets/FeedbackLoop.gif +0 -0
- package/assets/logo.svg +9 -0
- package/assets/skill-health-badge.svg +20 -0
- package/cli/selftune/activation-rules.ts +171 -0
- package/cli/selftune/badge/badge-data.ts +108 -0
- package/cli/selftune/badge/badge-svg.ts +212 -0
- package/cli/selftune/badge/badge.ts +103 -0
- package/cli/selftune/constants.ts +75 -1
- package/cli/selftune/contribute/bundle.ts +314 -0
- package/cli/selftune/contribute/contribute.ts +214 -0
- package/cli/selftune/contribute/sanitize.ts +162 -0
- package/cli/selftune/cron/setup.ts +266 -0
- package/cli/selftune/dashboard-server.ts +582 -0
- package/cli/selftune/dashboard.ts +31 -12
- package/cli/selftune/eval/baseline.ts +247 -0
- package/cli/selftune/eval/composability.ts +117 -0
- package/cli/selftune/eval/generate-unit-tests.ts +143 -0
- package/cli/selftune/eval/hooks-to-evals.ts +68 -2
- package/cli/selftune/eval/import-skillsbench.ts +221 -0
- package/cli/selftune/eval/synthetic-evals.ts +172 -0
- package/cli/selftune/eval/unit-test-cli.ts +152 -0
- package/cli/selftune/eval/unit-test.ts +196 -0
- package/cli/selftune/evolution/deploy-proposal.ts +142 -1
- package/cli/selftune/evolution/evolve-body.ts +492 -0
- package/cli/selftune/evolution/evolve.ts +479 -104
- package/cli/selftune/evolution/extract-patterns.ts +32 -1
- package/cli/selftune/evolution/pareto.ts +314 -0
- package/cli/selftune/evolution/propose-body.ts +171 -0
- package/cli/selftune/evolution/propose-description.ts +100 -2
- package/cli/selftune/evolution/propose-routing.ts +166 -0
- package/cli/selftune/evolution/refine-body.ts +141 -0
- package/cli/selftune/evolution/rollback.ts +20 -3
- package/cli/selftune/evolution/validate-body.ts +254 -0
- package/cli/selftune/evolution/validate-proposal.ts +257 -35
- package/cli/selftune/evolution/validate-routing.ts +177 -0
- package/cli/selftune/grading/grade-session.ts +145 -19
- package/cli/selftune/grading/pre-gates.ts +104 -0
- package/cli/selftune/hooks/auto-activate.ts +185 -0
- package/cli/selftune/hooks/evolution-guard.ts +165 -0
- package/cli/selftune/hooks/skill-change-guard.ts +112 -0
- package/cli/selftune/index.ts +88 -0
- package/cli/selftune/ingestors/claude-replay.ts +351 -0
- package/cli/selftune/ingestors/codex-rollout.ts +1 -1
- package/cli/selftune/ingestors/openclaw-ingest.ts +440 -0
- package/cli/selftune/ingestors/opencode-ingest.ts +2 -2
- package/cli/selftune/init.ts +168 -5
- package/cli/selftune/last.ts +2 -2
- package/cli/selftune/memory/writer.ts +447 -0
- package/cli/selftune/monitoring/watch.ts +25 -2
- package/cli/selftune/status.ts +18 -15
- package/cli/selftune/types.ts +377 -5
- package/cli/selftune/utils/frontmatter.ts +217 -0
- package/cli/selftune/utils/llm-call.ts +29 -3
- package/cli/selftune/utils/transcript.ts +35 -0
- package/cli/selftune/utils/trigger-check.ts +89 -0
- package/cli/selftune/utils/tui.ts +156 -0
- package/dashboard/index.html +585 -19
- package/package.json +17 -6
- package/skill/SKILL.md +127 -10
- package/skill/Workflows/AutoActivation.md +144 -0
- package/skill/Workflows/Badge.md +118 -0
- package/skill/Workflows/Baseline.md +121 -0
- package/skill/Workflows/Composability.md +100 -0
- package/skill/Workflows/Contribute.md +91 -0
- package/skill/Workflows/Cron.md +155 -0
- package/skill/Workflows/Dashboard.md +203 -0
- package/skill/Workflows/Doctor.md +37 -1
- package/skill/Workflows/Evals.md +73 -5
- package/skill/Workflows/EvolutionMemory.md +152 -0
- package/skill/Workflows/Evolve.md +111 -6
- package/skill/Workflows/EvolveBody.md +159 -0
- package/skill/Workflows/ImportSkillsBench.md +111 -0
- package/skill/Workflows/Ingest.md +129 -15
- package/skill/Workflows/Initialize.md +58 -3
- package/skill/Workflows/Replay.md +70 -0
- package/skill/Workflows/Rollback.md +20 -1
- package/skill/Workflows/UnitTest.md +138 -0
- package/skill/Workflows/Watch.md +22 -0
- package/skill/settings_snippet.json +23 -0
- package/templates/activation-rules-default.json +27 -0
- package/templates/multi-skill-settings.json +64 -0
- package/templates/single-skill-settings.json +58 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code PreToolUse hook: skill-change-guard.ts
|
|
4
|
+
*
|
|
5
|
+
* Fires before Write/Edit tool calls. If the target is a SKILL.md file,
|
|
6
|
+
* outputs a suggestion to run `selftune watch --skill <name>` to monitor
|
|
7
|
+
* the impact of the change.
|
|
8
|
+
*
|
|
9
|
+
* This is advisory only — exit code is always 0, never blocking.
|
|
10
|
+
* Uses session state to avoid repeating suggestions for the same skill.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { basename, dirname } from "node:path";
|
|
15
|
+
import { SESSION_STATE_DIR } from "../constants.js";
|
|
16
|
+
import type { PreToolUsePayload } from "../types.js";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Detection helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Check if a tool call is a Write or Edit targeting a SKILL.md file. */
|
|
23
|
+
export function isSkillMdWrite(toolName: string, filePath: string): boolean {
|
|
24
|
+
if (toolName !== "Write" && toolName !== "Edit") return false;
|
|
25
|
+
return basename(filePath).toUpperCase() === "SKILL.MD";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Extract the skill folder name from a path ending in SKILL.md. */
|
|
29
|
+
export function extractSkillNameFromPath(filePath: string): string {
|
|
30
|
+
return basename(dirname(filePath)) || "unknown";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Session state (minimal — just tracks which skills we've already warned about)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
interface GuardState {
|
|
38
|
+
session_id: string;
|
|
39
|
+
warned_skills: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadGuardState(path: string, sessionId: string): GuardState {
|
|
43
|
+
if (!existsSync(path)) {
|
|
44
|
+
return { session_id: sessionId, warned_skills: [] };
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const data = JSON.parse(readFileSync(path, "utf-8")) as GuardState;
|
|
48
|
+
if (data.session_id === sessionId && Array.isArray(data.warned_skills)) {
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// corrupt — start fresh
|
|
53
|
+
}
|
|
54
|
+
return { session_id: sessionId, warned_skills: [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saveGuardState(path: string, state: GuardState): void {
|
|
58
|
+
const dir = dirname(path);
|
|
59
|
+
if (!existsSync(dir)) {
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
writeFileSync(path, JSON.stringify(state, null, 2), "utf-8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Core processing logic
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Process a PreToolUse payload and return a suggestion string if the tool
|
|
71
|
+
* call is writing to a SKILL.md file that hasn't been warned about yet.
|
|
72
|
+
*/
|
|
73
|
+
export function processPreToolUse(payload: PreToolUsePayload, statePath: string): string | null {
|
|
74
|
+
const filePath =
|
|
75
|
+
typeof payload.tool_input?.file_path === "string" ? payload.tool_input.file_path : "";
|
|
76
|
+
|
|
77
|
+
if (!isSkillMdWrite(payload.tool_name, filePath)) return null;
|
|
78
|
+
|
|
79
|
+
const skillName = extractSkillNameFromPath(filePath);
|
|
80
|
+
const sessionId = payload.session_id ?? "unknown";
|
|
81
|
+
|
|
82
|
+
// Check if we've already warned about this skill in this session
|
|
83
|
+
const state = loadGuardState(statePath, sessionId);
|
|
84
|
+
if (state.warned_skills.includes(skillName)) return null;
|
|
85
|
+
|
|
86
|
+
// Record that we warned about this skill
|
|
87
|
+
state.warned_skills.push(skillName);
|
|
88
|
+
saveGuardState(statePath, state);
|
|
89
|
+
|
|
90
|
+
return `Run \`selftune watch --skill ${skillName}\` to monitor the impact of this SKILL.md change.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// stdin main (only when executed directly, not when imported)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
if (import.meta.main) {
|
|
98
|
+
try {
|
|
99
|
+
const payload: PreToolUsePayload = JSON.parse(await Bun.stdin.text());
|
|
100
|
+
const sessionId = payload.session_id ?? "unknown";
|
|
101
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
102
|
+
const statePath = `${SESSION_STATE_DIR}/guard-state-${safe}.json`;
|
|
103
|
+
|
|
104
|
+
const suggestion = processPreToolUse(payload, statePath);
|
|
105
|
+
if (suggestion) {
|
|
106
|
+
process.stderr.write(`[selftune] 💡 Suggestion: ${suggestion}\n`);
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// silent — hooks must never block Claude
|
|
110
|
+
}
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
package/cli/selftune/index.ts
CHANGED
|
@@ -8,14 +8,23 @@
|
|
|
8
8
|
* selftune grade [options] — Grade a skill session
|
|
9
9
|
* selftune ingest-codex [options] — Ingest Codex rollout logs
|
|
10
10
|
* selftune ingest-opencode [options] — Ingest OpenCode sessions
|
|
11
|
+
* selftune ingest-openclaw [options] — Ingest OpenClaw sessions
|
|
11
12
|
* selftune wrap-codex [options] — Wrap codex exec with telemetry
|
|
13
|
+
* selftune replay [options] — Replay Claude Code transcripts into logs
|
|
14
|
+
* selftune contribute [options] — Export anonymized skill data for community
|
|
12
15
|
* selftune evolve [options] — Evolve a skill description via failure patterns
|
|
16
|
+
* selftune evolve-body [options] — Evolve a skill body or routing table
|
|
13
17
|
* selftune rollback [options] — Rollback a skill to its pre-evolution state
|
|
14
18
|
* selftune watch [options] — Monitor post-deploy skill health
|
|
15
19
|
* selftune doctor — Run health checks
|
|
16
20
|
* selftune status — Show skill health summary
|
|
17
21
|
* selftune last — Show last session details
|
|
18
22
|
* selftune dashboard [options] — Open visual data dashboard
|
|
23
|
+
* selftune cron [options] — Manage OpenClaw cron jobs (setup, list, remove)
|
|
24
|
+
* selftune baseline [options] — Measure skill value vs. no-skill baseline
|
|
25
|
+
* selftune composability [options] — Analyze skill co-occurrence conflicts
|
|
26
|
+
* selftune unit-test [options] — Run or generate skill unit tests
|
|
27
|
+
* selftune import-skillsbench [options] — Import SkillsBench task corpus as eval entries
|
|
19
28
|
*/
|
|
20
29
|
|
|
21
30
|
const command = process.argv[2];
|
|
@@ -32,14 +41,24 @@ Commands:
|
|
|
32
41
|
grade Grade a skill session
|
|
33
42
|
ingest-codex Ingest Codex rollout logs
|
|
34
43
|
ingest-opencode Ingest OpenCode sessions
|
|
44
|
+
ingest-openclaw Ingest OpenClaw sessions
|
|
35
45
|
wrap-codex Wrap codex exec with telemetry
|
|
46
|
+
replay Replay Claude Code transcripts into logs
|
|
47
|
+
contribute Export anonymized skill data for community
|
|
36
48
|
evolve Evolve a skill description via failure patterns
|
|
49
|
+
evolve-body Evolve a skill body or routing table
|
|
37
50
|
rollback Rollback a skill to its pre-evolution state
|
|
38
51
|
watch Monitor post-deploy skill health
|
|
39
52
|
doctor Run health checks
|
|
40
53
|
status Show skill health summary
|
|
41
54
|
last Show last session details
|
|
42
55
|
dashboard Open visual data dashboard
|
|
56
|
+
cron Manage OpenClaw cron jobs (setup, list, remove)
|
|
57
|
+
badge Generate skill health badges for READMEs
|
|
58
|
+
baseline Measure skill value vs. no-skill baseline
|
|
59
|
+
composability Analyze skill co-occurrence conflicts
|
|
60
|
+
unit-test Run or generate skill unit tests
|
|
61
|
+
import-skillsbench Import SkillsBench task corpus as eval entries
|
|
43
62
|
|
|
44
63
|
Run 'selftune <command> --help' for command-specific options.`);
|
|
45
64
|
process.exit(0);
|
|
@@ -77,16 +96,41 @@ switch (command) {
|
|
|
77
96
|
cliMain();
|
|
78
97
|
break;
|
|
79
98
|
}
|
|
99
|
+
case "ingest-openclaw": {
|
|
100
|
+
const { cliMain } = await import("./ingestors/openclaw-ingest.js");
|
|
101
|
+
cliMain();
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
80
104
|
case "wrap-codex": {
|
|
81
105
|
const { cliMain } = await import("./ingestors/codex-wrapper.js");
|
|
82
106
|
await cliMain();
|
|
83
107
|
break;
|
|
84
108
|
}
|
|
109
|
+
case "replay": {
|
|
110
|
+
const { cliMain } = await import("./ingestors/claude-replay.js");
|
|
111
|
+
cliMain();
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "contribute": {
|
|
115
|
+
const { cliMain } = await import("./contribute/contribute.js");
|
|
116
|
+
await cliMain();
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
85
119
|
case "evolve": {
|
|
86
120
|
const { cliMain } = await import("./evolution/evolve.js");
|
|
87
121
|
await cliMain();
|
|
88
122
|
break;
|
|
89
123
|
}
|
|
124
|
+
case "evolve-body": {
|
|
125
|
+
const { cliMain } = await import("./evolution/evolve-body.js");
|
|
126
|
+
await cliMain();
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case "baseline": {
|
|
130
|
+
const { cliMain } = await import("./eval/baseline.js");
|
|
131
|
+
await cliMain();
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
90
134
|
case "rollback": {
|
|
91
135
|
const { cliMain } = await import("./evolution/rollback.js");
|
|
92
136
|
await cliMain();
|
|
@@ -116,9 +160,53 @@ switch (command) {
|
|
|
116
160
|
}
|
|
117
161
|
case "dashboard": {
|
|
118
162
|
const { cliMain } = await import("./dashboard.js");
|
|
163
|
+
await cliMain();
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "cron": {
|
|
167
|
+
const { cliMain } = await import("./cron/setup.js");
|
|
168
|
+
await cliMain();
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "badge": {
|
|
172
|
+
const { cliMain } = await import("./badge/badge.js");
|
|
173
|
+
cliMain();
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "unit-test": {
|
|
177
|
+
const { cliMain } = await import("./eval/unit-test-cli.js");
|
|
178
|
+
await cliMain();
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "import-skillsbench": {
|
|
182
|
+
const { cliMain } = await import("./eval/import-skillsbench.js");
|
|
119
183
|
cliMain();
|
|
120
184
|
break;
|
|
121
185
|
}
|
|
186
|
+
case "composability": {
|
|
187
|
+
const { parseArgs } = await import("node:util");
|
|
188
|
+
const { readJsonl } = await import("./utils/jsonl.js");
|
|
189
|
+
const { TELEMETRY_LOG } = await import("./constants.js");
|
|
190
|
+
const { analyzeComposability } = await import("./eval/composability.js");
|
|
191
|
+
const { values } = parseArgs({
|
|
192
|
+
options: {
|
|
193
|
+
skill: { type: "string" },
|
|
194
|
+
window: { type: "string" },
|
|
195
|
+
"telemetry-log": { type: "string" },
|
|
196
|
+
},
|
|
197
|
+
strict: true,
|
|
198
|
+
});
|
|
199
|
+
if (!values.skill) {
|
|
200
|
+
console.error("[ERROR] --skill <name> is required.");
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
const logPath = values["telemetry-log"] ?? TELEMETRY_LOG;
|
|
204
|
+
const telemetry = readJsonl(logPath);
|
|
205
|
+
const windowSize = values.window ? Number.parseInt(values.window, 10) : undefined;
|
|
206
|
+
const report = analyzeComposability(values.skill, telemetry, windowSize);
|
|
207
|
+
console.log(JSON.stringify(report, null, 2));
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
122
210
|
default:
|
|
123
211
|
console.error(`Unknown command: ${command}\nRun 'selftune --help' for available commands.`);
|
|
124
212
|
process.exit(1);
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code transcript ingestor: claude-replay.ts
|
|
4
|
+
*
|
|
5
|
+
* Retroactively ingests Claude Code session transcripts into our shared
|
|
6
|
+
* skill eval log format.
|
|
7
|
+
*
|
|
8
|
+
* Claude Code saves transcripts to:
|
|
9
|
+
* ~/.claude/projects/<hash>/<session-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 claude-replay.ts
|
|
18
|
+
* bun claude-replay.ts --since 2026-01-01
|
|
19
|
+
* bun claude-replay.ts --projects-dir /custom/path
|
|
20
|
+
* bun claude-replay.ts --dry-run
|
|
21
|
+
* bun claude-replay.ts --force
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
25
|
+
import { basename, join } from "node:path";
|
|
26
|
+
import { parseArgs } from "node:util";
|
|
27
|
+
import {
|
|
28
|
+
CLAUDE_CODE_MARKER,
|
|
29
|
+
CLAUDE_CODE_PROJECTS_DIR,
|
|
30
|
+
QUERY_LOG,
|
|
31
|
+
SKILL_LOG,
|
|
32
|
+
SKIP_PREFIXES,
|
|
33
|
+
TELEMETRY_LOG,
|
|
34
|
+
} from "../constants.js";
|
|
35
|
+
import type {
|
|
36
|
+
QueryLogRecord,
|
|
37
|
+
SessionTelemetryRecord,
|
|
38
|
+
SkillUsageRecord,
|
|
39
|
+
TranscriptMetrics,
|
|
40
|
+
} from "../types.js";
|
|
41
|
+
import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
|
|
42
|
+
import { parseTranscript } from "../utils/transcript.js";
|
|
43
|
+
|
|
44
|
+
export interface ParsedSession {
|
|
45
|
+
transcript_path: string;
|
|
46
|
+
session_id: string;
|
|
47
|
+
timestamp: string;
|
|
48
|
+
metrics: TranscriptMetrics;
|
|
49
|
+
user_queries: Array<{ query: string; timestamp: string }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Find all .jsonl transcript files under projectsDir/<hash>/<session>.jsonl.
|
|
54
|
+
* If `since` is given, only return files with mtime >= since.
|
|
55
|
+
*/
|
|
56
|
+
export function findTranscriptFiles(projectsDir: string, since?: Date): string[] {
|
|
57
|
+
if (!existsSync(projectsDir)) return [];
|
|
58
|
+
|
|
59
|
+
const files: string[] = [];
|
|
60
|
+
|
|
61
|
+
let hashDirs: string[];
|
|
62
|
+
try {
|
|
63
|
+
hashDirs = readdirSync(projectsDir).sort();
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const hashEntry of hashDirs) {
|
|
69
|
+
const hashDir = join(projectsDir, hashEntry);
|
|
70
|
+
try {
|
|
71
|
+
if (!statSync(hashDir).isDirectory()) continue;
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let sessionFiles: string[];
|
|
77
|
+
try {
|
|
78
|
+
sessionFiles = readdirSync(hashDir).sort();
|
|
79
|
+
} catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const file of sessionFiles) {
|
|
84
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
85
|
+
|
|
86
|
+
const filePath = join(hashDir, file);
|
|
87
|
+
if (since) {
|
|
88
|
+
try {
|
|
89
|
+
const mtime = statSync(filePath).mtime;
|
|
90
|
+
if (mtime < since) continue;
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
files.push(filePath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return files.sort();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract all user queries from a Claude Code transcript JSONL.
|
|
105
|
+
*
|
|
106
|
+
* Handles two transcript variants:
|
|
107
|
+
* Variant A: {"type": "user", "message": {"role": "user", "content": [...]}}
|
|
108
|
+
* Variant B: {"role": "user", "content": "..."}
|
|
109
|
+
*
|
|
110
|
+
* Filters out messages matching SKIP_PREFIXES and queries < 4 chars.
|
|
111
|
+
*/
|
|
112
|
+
export function extractAllUserQueries(
|
|
113
|
+
transcriptPath: string,
|
|
114
|
+
): Array<{ query: string; timestamp: string }> {
|
|
115
|
+
if (!existsSync(transcriptPath)) return [];
|
|
116
|
+
|
|
117
|
+
let content: string;
|
|
118
|
+
try {
|
|
119
|
+
content = readFileSync(transcriptPath, "utf-8");
|
|
120
|
+
} catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const results: Array<{ query: string; timestamp: string }> = [];
|
|
125
|
+
|
|
126
|
+
for (const raw of content.split("\n")) {
|
|
127
|
+
const line = raw.trim();
|
|
128
|
+
if (!line) continue;
|
|
129
|
+
|
|
130
|
+
let entry: Record<string, unknown>;
|
|
131
|
+
try {
|
|
132
|
+
entry = JSON.parse(line);
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Normalise: unwrap nested message if present
|
|
138
|
+
const msg = (entry.message as Record<string, unknown>) ?? entry;
|
|
139
|
+
const role = (msg.role as string) ?? (entry.role as string) ?? "";
|
|
140
|
+
|
|
141
|
+
if (role !== "user") continue;
|
|
142
|
+
|
|
143
|
+
const entryContent = msg.content ?? entry.content ?? "";
|
|
144
|
+
let text = "";
|
|
145
|
+
|
|
146
|
+
if (typeof entryContent === "string") {
|
|
147
|
+
text = entryContent.trim();
|
|
148
|
+
} else if (Array.isArray(entryContent)) {
|
|
149
|
+
const texts = entryContent
|
|
150
|
+
.filter(
|
|
151
|
+
(p): p is Record<string, unknown> =>
|
|
152
|
+
typeof p === "object" && p !== null && (p as Record<string, unknown>).type === "text",
|
|
153
|
+
)
|
|
154
|
+
.map((p) => (p.text as string) ?? "")
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
text = texts.join(" ").trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!text) continue;
|
|
160
|
+
|
|
161
|
+
// Apply SKIP_PREFIXES filter
|
|
162
|
+
const shouldSkip = SKIP_PREFIXES.some((prefix) => text.startsWith(prefix));
|
|
163
|
+
if (shouldSkip) continue;
|
|
164
|
+
|
|
165
|
+
// Apply 4-char minimum length filter
|
|
166
|
+
if (text.length < 4) continue;
|
|
167
|
+
|
|
168
|
+
// Extract timestamp from entry if present, else empty string
|
|
169
|
+
const timestamp = (entry.timestamp as string) ?? (msg.timestamp as string) ?? "";
|
|
170
|
+
|
|
171
|
+
results.push({ query: text, timestamp });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return results;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse a Claude Code session transcript into a ParsedSession.
|
|
179
|
+
* Returns null if no user queries are found after filtering.
|
|
180
|
+
*/
|
|
181
|
+
export function parseSession(transcriptPath: string): ParsedSession | null {
|
|
182
|
+
const metrics = parseTranscript(transcriptPath);
|
|
183
|
+
const userQueries = extractAllUserQueries(transcriptPath);
|
|
184
|
+
|
|
185
|
+
if (userQueries.length === 0) return null;
|
|
186
|
+
|
|
187
|
+
const sessionId = basename(transcriptPath, ".jsonl");
|
|
188
|
+
|
|
189
|
+
// Determine timestamp: use first query's timestamp, or file mtime as fallback
|
|
190
|
+
let timestamp = userQueries[0].timestamp;
|
|
191
|
+
if (!timestamp) {
|
|
192
|
+
try {
|
|
193
|
+
timestamp = statSync(transcriptPath).mtime.toISOString();
|
|
194
|
+
} catch {
|
|
195
|
+
timestamp = new Date().toISOString();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
transcript_path: transcriptPath,
|
|
201
|
+
session_id: sessionId,
|
|
202
|
+
timestamp,
|
|
203
|
+
metrics,
|
|
204
|
+
user_queries: userQueries,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Write parsed session data to shared JSONL logs.
|
|
210
|
+
* Writes ONE QueryLogRecord per user query, ONE SessionTelemetryRecord per session,
|
|
211
|
+
* and ONE SkillUsageRecord per triggered skill.
|
|
212
|
+
*/
|
|
213
|
+
export function writeSession(
|
|
214
|
+
session: ParsedSession,
|
|
215
|
+
dryRun = false,
|
|
216
|
+
queryLogPath: string = QUERY_LOG,
|
|
217
|
+
telemetryLogPath: string = TELEMETRY_LOG,
|
|
218
|
+
skillLogPath: string = SKILL_LOG,
|
|
219
|
+
): void {
|
|
220
|
+
if (dryRun) {
|
|
221
|
+
console.log(
|
|
222
|
+
` [DRY RUN] Would ingest: session=${session.session_id.slice(0, 12)}... ` +
|
|
223
|
+
`turns=${session.metrics.assistant_turns} queries=${session.user_queries.length} ` +
|
|
224
|
+
`skills=${JSON.stringify(session.metrics.skills_triggered)}`,
|
|
225
|
+
);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Write ONE query record per user query
|
|
230
|
+
for (const uq of session.user_queries) {
|
|
231
|
+
const queryRecord: QueryLogRecord = {
|
|
232
|
+
timestamp: uq.timestamp || session.timestamp,
|
|
233
|
+
session_id: session.session_id,
|
|
234
|
+
query: uq.query,
|
|
235
|
+
source: "claude_code_replay",
|
|
236
|
+
};
|
|
237
|
+
appendJsonl(queryLogPath, queryRecord, "all_queries");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Write ONE telemetry record per session
|
|
241
|
+
const telemetry: SessionTelemetryRecord = {
|
|
242
|
+
timestamp: session.timestamp,
|
|
243
|
+
session_id: session.session_id,
|
|
244
|
+
cwd: "",
|
|
245
|
+
transcript_path: session.transcript_path,
|
|
246
|
+
tool_calls: session.metrics.tool_calls,
|
|
247
|
+
total_tool_calls: session.metrics.total_tool_calls,
|
|
248
|
+
bash_commands: session.metrics.bash_commands,
|
|
249
|
+
skills_triggered: session.metrics.skills_triggered,
|
|
250
|
+
assistant_turns: session.metrics.assistant_turns,
|
|
251
|
+
errors_encountered: session.metrics.errors_encountered,
|
|
252
|
+
transcript_chars: session.metrics.transcript_chars,
|
|
253
|
+
last_user_query: session.metrics.last_user_query,
|
|
254
|
+
source: "claude_code_replay",
|
|
255
|
+
};
|
|
256
|
+
appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
|
|
257
|
+
|
|
258
|
+
// Write ONE skill record per triggered skill
|
|
259
|
+
for (const skillName of session.metrics.skills_triggered) {
|
|
260
|
+
const skillRecord: SkillUsageRecord = {
|
|
261
|
+
timestamp: session.timestamp,
|
|
262
|
+
session_id: session.session_id,
|
|
263
|
+
skill_name: skillName,
|
|
264
|
+
skill_path: `(claude_code:${skillName})`,
|
|
265
|
+
query: session.metrics.last_user_query,
|
|
266
|
+
triggered: true,
|
|
267
|
+
source: "claude_code_replay",
|
|
268
|
+
};
|
|
269
|
+
appendJsonl(skillLogPath, skillRecord, "skill_usage");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- CLI main ---
|
|
274
|
+
export function cliMain(): void {
|
|
275
|
+
const { values } = parseArgs({
|
|
276
|
+
options: {
|
|
277
|
+
"projects-dir": { type: "string", default: CLAUDE_CODE_PROJECTS_DIR },
|
|
278
|
+
since: { type: "string" },
|
|
279
|
+
"dry-run": { type: "boolean", default: false },
|
|
280
|
+
force: { type: "boolean", default: false },
|
|
281
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
282
|
+
},
|
|
283
|
+
strict: true,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const projectsDir = values["projects-dir"] ?? CLAUDE_CODE_PROJECTS_DIR;
|
|
287
|
+
let since: Date | undefined;
|
|
288
|
+
if (values.since) {
|
|
289
|
+
since = new Date(values.since);
|
|
290
|
+
if (Number.isNaN(since.getTime())) {
|
|
291
|
+
console.error(
|
|
292
|
+
`Error: Invalid --since date: "${values.since}". Use a valid date format (e.g., 2026-01-01).`,
|
|
293
|
+
);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const transcriptFiles = findTranscriptFiles(projectsDir, since);
|
|
299
|
+
if (transcriptFiles.length === 0) {
|
|
300
|
+
console.log(`No transcript files found under ${projectsDir}/`);
|
|
301
|
+
console.log("Make sure you've run some Claude Code sessions.");
|
|
302
|
+
process.exit(0);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const alreadyIngested = values.force ? new Set<string>() : loadMarker(CLAUDE_CODE_MARKER);
|
|
306
|
+
const newIngested = new Set<string>();
|
|
307
|
+
|
|
308
|
+
const pending = transcriptFiles.filter((f) => !alreadyIngested.has(f));
|
|
309
|
+
console.log(
|
|
310
|
+
`Found ${transcriptFiles.length} transcript files, ${pending.length} not yet ingested.`,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
if (since) {
|
|
314
|
+
console.log(` Filtering to sessions from ${values.since} onward.`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let ingestedCount = 0;
|
|
318
|
+
let skippedCount = 0;
|
|
319
|
+
|
|
320
|
+
for (const transcriptFile of pending) {
|
|
321
|
+
const session = parseSession(transcriptFile);
|
|
322
|
+
if (session === null) {
|
|
323
|
+
if (values.verbose) {
|
|
324
|
+
console.log(` SKIP (empty/no queries): ${basename(transcriptFile)}`);
|
|
325
|
+
}
|
|
326
|
+
skippedCount += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (values.verbose || values["dry-run"]) {
|
|
331
|
+
console.log(` ${values["dry-run"] ? "[DRY] " : ""}Ingesting: ${basename(transcriptFile)}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
writeSession(session, values["dry-run"]);
|
|
335
|
+
newIngested.add(transcriptFile);
|
|
336
|
+
ingestedCount += 1;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!values["dry-run"]) {
|
|
340
|
+
saveMarker(CLAUDE_CODE_MARKER, new Set([...alreadyIngested, ...newIngested]));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log(`\nDone. Ingested ${ingestedCount} sessions, skipped ${skippedCount}.`);
|
|
344
|
+
if (newIngested.size > 0 && !values["dry-run"]) {
|
|
345
|
+
console.log(`Marker updated: ${CLAUDE_CODE_MARKER}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (import.meta.main) {
|
|
350
|
+
cliMain();
|
|
351
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* bun codex-rollout.ts --force
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync,
|
|
24
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { basename, join } from "node:path";
|
|
27
27
|
import { parseArgs } from "node:util";
|