selftune 0.1.4 → 0.2.1

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.
Files changed (153) hide show
  1. package/.claude/agents/diagnosis-analyst.md +156 -0
  2. package/.claude/agents/evolution-reviewer.md +180 -0
  3. package/.claude/agents/integration-guide.md +212 -0
  4. package/.claude/agents/pattern-analyst.md +160 -0
  5. package/CHANGELOG.md +46 -1
  6. package/README.md +105 -257
  7. package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  8. package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  9. package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  10. package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
  11. package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
  12. package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
  13. package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
  14. package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
  15. package/apps/local-dashboard/dist/favicon.png +0 -0
  16. package/apps/local-dashboard/dist/index.html +17 -0
  17. package/apps/local-dashboard/dist/logo.png +0 -0
  18. package/apps/local-dashboard/dist/logo.svg +9 -0
  19. package/assets/BeforeAfter.gif +0 -0
  20. package/assets/FeedbackLoop.gif +0 -0
  21. package/assets/logo.svg +9 -0
  22. package/assets/skill-health-badge.svg +20 -0
  23. package/cli/selftune/activation-rules.ts +171 -0
  24. package/cli/selftune/badge/badge-data.ts +108 -0
  25. package/cli/selftune/badge/badge-svg.ts +212 -0
  26. package/cli/selftune/badge/badge.ts +99 -0
  27. package/cli/selftune/canonical-export.ts +183 -0
  28. package/cli/selftune/constants.ts +103 -1
  29. package/cli/selftune/contribute/bundle.ts +314 -0
  30. package/cli/selftune/contribute/contribute.ts +214 -0
  31. package/cli/selftune/contribute/sanitize.ts +162 -0
  32. package/cli/selftune/cron/setup.ts +266 -0
  33. package/cli/selftune/dashboard-contract.ts +202 -0
  34. package/cli/selftune/dashboard-server.ts +1049 -0
  35. package/cli/selftune/dashboard.ts +43 -156
  36. package/cli/selftune/eval/baseline.ts +248 -0
  37. package/cli/selftune/eval/composability-v2.ts +273 -0
  38. package/cli/selftune/eval/composability.ts +117 -0
  39. package/cli/selftune/eval/generate-unit-tests.ts +143 -0
  40. package/cli/selftune/eval/hooks-to-evals.ts +101 -16
  41. package/cli/selftune/eval/import-skillsbench.ts +221 -0
  42. package/cli/selftune/eval/synthetic-evals.ts +172 -0
  43. package/cli/selftune/eval/unit-test-cli.ts +152 -0
  44. package/cli/selftune/eval/unit-test.ts +196 -0
  45. package/cli/selftune/evolution/deploy-proposal.ts +142 -1
  46. package/cli/selftune/evolution/evidence.ts +26 -0
  47. package/cli/selftune/evolution/evolve-body.ts +586 -0
  48. package/cli/selftune/evolution/evolve.ts +825 -116
  49. package/cli/selftune/evolution/extract-patterns.ts +105 -16
  50. package/cli/selftune/evolution/pareto.ts +314 -0
  51. package/cli/selftune/evolution/propose-body.ts +171 -0
  52. package/cli/selftune/evolution/propose-description.ts +100 -2
  53. package/cli/selftune/evolution/propose-routing.ts +166 -0
  54. package/cli/selftune/evolution/refine-body.ts +141 -0
  55. package/cli/selftune/evolution/rollback.ts +21 -4
  56. package/cli/selftune/evolution/validate-body.ts +254 -0
  57. package/cli/selftune/evolution/validate-proposal.ts +257 -35
  58. package/cli/selftune/evolution/validate-routing.ts +177 -0
  59. package/cli/selftune/grading/auto-grade.ts +200 -0
  60. package/cli/selftune/grading/grade-session.ts +513 -42
  61. package/cli/selftune/grading/pre-gates.ts +104 -0
  62. package/cli/selftune/grading/results.ts +42 -0
  63. package/cli/selftune/hooks/auto-activate.ts +185 -0
  64. package/cli/selftune/hooks/evolution-guard.ts +165 -0
  65. package/cli/selftune/hooks/prompt-log.ts +172 -2
  66. package/cli/selftune/hooks/session-stop.ts +123 -3
  67. package/cli/selftune/hooks/skill-change-guard.ts +112 -0
  68. package/cli/selftune/hooks/skill-eval.ts +119 -3
  69. package/cli/selftune/index.ts +415 -48
  70. package/cli/selftune/ingestors/claude-replay.ts +377 -0
  71. package/cli/selftune/ingestors/codex-rollout.ts +345 -46
  72. package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
  73. package/cli/selftune/ingestors/openclaw-ingest.ts +573 -0
  74. package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
  75. package/cli/selftune/init.ts +376 -16
  76. package/cli/selftune/last.ts +14 -5
  77. package/cli/selftune/localdb/db.ts +63 -0
  78. package/cli/selftune/localdb/materialize.ts +428 -0
  79. package/cli/selftune/localdb/queries.ts +376 -0
  80. package/cli/selftune/localdb/schema.ts +204 -0
  81. package/cli/selftune/memory/writer.ts +447 -0
  82. package/cli/selftune/monitoring/watch.ts +90 -16
  83. package/cli/selftune/normalization.ts +682 -0
  84. package/cli/selftune/observability.ts +19 -44
  85. package/cli/selftune/orchestrate.ts +1073 -0
  86. package/cli/selftune/quickstart.ts +203 -0
  87. package/cli/selftune/repair/skill-usage.ts +576 -0
  88. package/cli/selftune/schedule.ts +561 -0
  89. package/cli/selftune/status.ts +59 -33
  90. package/cli/selftune/sync.ts +627 -0
  91. package/cli/selftune/types.ts +525 -5
  92. package/cli/selftune/utils/canonical-log.ts +45 -0
  93. package/cli/selftune/utils/frontmatter.ts +217 -0
  94. package/cli/selftune/utils/hooks.ts +41 -0
  95. package/cli/selftune/utils/html.ts +27 -0
  96. package/cli/selftune/utils/llm-call.ts +103 -19
  97. package/cli/selftune/utils/math.ts +10 -0
  98. package/cli/selftune/utils/query-filter.ts +139 -0
  99. package/cli/selftune/utils/skill-discovery.ts +340 -0
  100. package/cli/selftune/utils/skill-log.ts +68 -0
  101. package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
  102. package/cli/selftune/utils/transcript.ts +307 -26
  103. package/cli/selftune/utils/trigger-check.ts +89 -0
  104. package/cli/selftune/utils/tui.ts +156 -0
  105. package/cli/selftune/workflows/discover.ts +254 -0
  106. package/cli/selftune/workflows/skill-md-writer.ts +288 -0
  107. package/cli/selftune/workflows/workflows.ts +188 -0
  108. package/package.json +28 -11
  109. package/packages/telemetry-contract/README.md +11 -0
  110. package/packages/telemetry-contract/fixtures/golden.json +87 -0
  111. package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
  112. package/packages/telemetry-contract/index.ts +1 -0
  113. package/packages/telemetry-contract/package.json +19 -0
  114. package/packages/telemetry-contract/src/index.ts +2 -0
  115. package/packages/telemetry-contract/src/types.ts +163 -0
  116. package/packages/telemetry-contract/src/validators.ts +109 -0
  117. package/skill/SKILL.md +180 -33
  118. package/skill/Workflows/AutoActivation.md +145 -0
  119. package/skill/Workflows/Badge.md +124 -0
  120. package/skill/Workflows/Baseline.md +144 -0
  121. package/skill/Workflows/Composability.md +107 -0
  122. package/skill/Workflows/Contribute.md +94 -0
  123. package/skill/Workflows/Cron.md +132 -0
  124. package/skill/Workflows/Dashboard.md +214 -0
  125. package/skill/Workflows/Doctor.md +63 -14
  126. package/skill/Workflows/Evals.md +110 -18
  127. package/skill/Workflows/EvolutionMemory.md +154 -0
  128. package/skill/Workflows/Evolve.md +181 -21
  129. package/skill/Workflows/EvolveBody.md +159 -0
  130. package/skill/Workflows/Grade.md +36 -31
  131. package/skill/Workflows/ImportSkillsBench.md +117 -0
  132. package/skill/Workflows/Ingest.md +142 -21
  133. package/skill/Workflows/Initialize.md +91 -23
  134. package/skill/Workflows/Orchestrate.md +139 -0
  135. package/skill/Workflows/Replay.md +91 -0
  136. package/skill/Workflows/Rollback.md +23 -4
  137. package/skill/Workflows/Schedule.md +61 -0
  138. package/skill/Workflows/Sync.md +88 -0
  139. package/skill/Workflows/UnitTest.md +150 -0
  140. package/skill/Workflows/Watch.md +33 -1
  141. package/skill/Workflows/Workflows.md +129 -0
  142. package/skill/assets/activation-rules-default.json +26 -0
  143. package/skill/assets/multi-skill-settings.json +63 -0
  144. package/skill/assets/single-skill-settings.json +57 -0
  145. package/skill/references/invocation-taxonomy.md +2 -2
  146. package/skill/references/logs.md +164 -2
  147. package/skill/references/setup-patterns.md +65 -0
  148. package/skill/references/version-history.md +40 -0
  149. package/skill/settings_snippet.json +23 -0
  150. package/templates/activation-rules-default.json +27 -0
  151. package/templates/multi-skill-settings.json +64 -0
  152. package/templates/single-skill-settings.json +58 -0
  153. package/dashboard/index.html +0 -1119
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * OpenClaw session ingestor: openclaw-ingest.ts
4
+ *
5
+ * Ingests OpenClaw session history from JSONL files into our shared
6
+ * skill eval log format.
7
+ *
8
+ * OpenClaw stores sessions as JSONL at:
9
+ * ~/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl
10
+ *
11
+ * Each JSONL file has:
12
+ * Line 1 (session header): {"type":"session","version":5,"id":"<uuid>","timestamp":"<iso>","cwd":"<path>"}
13
+ * Line 2+ (messages): {"role":"user|assistant|toolResult","content":[...],"timestamp":<ms>}
14
+ *
15
+ * Usage:
16
+ * bun openclaw-ingest.ts
17
+ * bun openclaw-ingest.ts --since 2026-01-01
18
+ * bun openclaw-ingest.ts --agents-dir /custom/path
19
+ * bun openclaw-ingest.ts --dry-run
20
+ * bun openclaw-ingest.ts --force
21
+ * bun openclaw-ingest.ts --verbose
22
+ */
23
+
24
+ import { existsSync, readdirSync, readFileSync, 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 {
29
+ CANONICAL_LOG,
30
+ OPENCLAW_AGENTS_DIR,
31
+ OPENCLAW_INGEST_MARKER,
32
+ QUERY_LOG,
33
+ SKILL_LOG,
34
+ TELEMETRY_LOG,
35
+ } from "../constants.js";
36
+ import {
37
+ appendCanonicalRecords,
38
+ buildCanonicalExecutionFact,
39
+ buildCanonicalPrompt,
40
+ buildCanonicalSession,
41
+ buildCanonicalSkillInvocation,
42
+ type CanonicalBaseInput,
43
+ deriveInvocationMode,
44
+ derivePromptId,
45
+ deriveSkillInvocationId,
46
+ } from "../normalization.js";
47
+ import type { CanonicalRecord, QueryLogRecord, SkillUsageRecord } from "../types.js";
48
+ import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
49
+
50
+ export interface SessionFile {
51
+ agentId: string;
52
+ sessionId: string;
53
+ filePath: string;
54
+ timestamp: number; // epoch ms from file stat or header
55
+ }
56
+
57
+ interface TriggeredSkillDetection {
58
+ skill_name: string;
59
+ has_skill_md_read: boolean;
60
+ }
61
+
62
+ export interface ParsedSession {
63
+ timestamp: string;
64
+ session_id: string;
65
+ source: string;
66
+ transcript_path: string;
67
+ cwd: string;
68
+ last_user_query: string;
69
+ query: string;
70
+ tool_calls: Record<string, number>;
71
+ total_tool_calls: number;
72
+ bash_commands: string[];
73
+ skills_triggered: string[];
74
+ skill_detections?: TriggeredSkillDetection[];
75
+ assistant_turns: number;
76
+ errors_encountered: number;
77
+ transcript_chars: number;
78
+ /** Reserved transport fields from OpenClaw docs (may be absent in fixture-only captures). */
79
+ session_key?: string;
80
+ channel?: string;
81
+ agent_id?: string;
82
+ }
83
+
84
+ /**
85
+ * Scan <agentsDir>/<agentId>/sessions/*.jsonl for OpenClaw session files.
86
+ * Reads line 1 of each file to get the session header with id and timestamp.
87
+ * If sinceTs (epoch ms) is provided, skips sessions older than that.
88
+ */
89
+ export function findOpenClawSessions(agentsDir: string, sinceTs: number | null): SessionFile[] {
90
+ if (!existsSync(agentsDir)) return [];
91
+
92
+ const results: SessionFile[] = [];
93
+ let agentDirs: string[];
94
+
95
+ try {
96
+ agentDirs = readdirSync(agentsDir);
97
+ } catch {
98
+ return [];
99
+ }
100
+
101
+ for (const agentId of agentDirs) {
102
+ const sessionsDir = join(agentsDir, agentId, "sessions");
103
+ if (!existsSync(sessionsDir)) continue;
104
+
105
+ let files: string[];
106
+ try {
107
+ files = readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
108
+ } catch {
109
+ continue;
110
+ }
111
+
112
+ for (const file of files) {
113
+ const filePath = join(sessionsDir, file);
114
+ try {
115
+ const content = readFileSync(filePath, "utf-8");
116
+ const firstLine = content.split("\n")[0]?.trim();
117
+ if (!firstLine) continue;
118
+
119
+ const header = JSON.parse(firstLine);
120
+ if (header.type !== "session") continue;
121
+
122
+ const sessionId = header.id ?? basename(file, ".jsonl");
123
+ const headerTs = header.timestamp ? new Date(header.timestamp).getTime() : 0;
124
+ const fileTs = headerTs || statSync(filePath).mtimeMs;
125
+
126
+ if (sinceTs !== null && fileTs < sinceTs) continue;
127
+
128
+ results.push({
129
+ agentId,
130
+ sessionId,
131
+ filePath,
132
+ timestamp: fileTs,
133
+ });
134
+ } catch {
135
+ // Skip files that can't be read or parsed
136
+ }
137
+ }
138
+ }
139
+
140
+ return results;
141
+ }
142
+
143
+ /**
144
+ * Parse an OpenClaw session JSONL file into a ParsedSession.
145
+ *
146
+ * Line 1: session header with id, timestamp, cwd
147
+ * Lines 2+: messages with role user/assistant/toolResult
148
+ */
149
+ export function parseOpenClawSession(filePath: string, skillNames: Set<string>): ParsedSession {
150
+ const empty: ParsedSession = {
151
+ timestamp: "",
152
+ session_id: "",
153
+ source: "openclaw",
154
+ transcript_path: filePath,
155
+ cwd: "",
156
+ last_user_query: "",
157
+ query: "",
158
+ tool_calls: {},
159
+ total_tool_calls: 0,
160
+ bash_commands: [],
161
+ skills_triggered: [],
162
+ skill_detections: [],
163
+ assistant_turns: 0,
164
+ errors_encountered: 0,
165
+ transcript_chars: 0,
166
+ };
167
+
168
+ let content: string;
169
+ try {
170
+ content = readFileSync(filePath, "utf-8");
171
+ } catch {
172
+ return empty;
173
+ }
174
+
175
+ empty.transcript_chars = content.length;
176
+ const lines = content.split("\n").filter((l) => l.trim());
177
+
178
+ if (lines.length === 0) return empty;
179
+
180
+ // Parse session header (line 1)
181
+ let header: Record<string, unknown>;
182
+ try {
183
+ header = JSON.parse(lines[0]);
184
+ } catch {
185
+ return empty;
186
+ }
187
+
188
+ if (header.type !== "session") return empty;
189
+
190
+ const sessionId = (header.id as string) ?? "";
191
+ const timestamp = (header.timestamp as string) ?? "";
192
+ const cwd = (header.cwd as string) ?? "";
193
+ // Reserve transport fields from docs (may be absent in fixture-only captures)
194
+ const sessionKey = (header.sessionKey as string) ?? (header.session_key as string) ?? undefined;
195
+ const channel = (header.channel as string) ?? undefined;
196
+ const agentIdFromHeader = (header.agentId as string) ?? (header.agent_id as string) ?? undefined;
197
+
198
+ const toolCalls: Record<string, number> = {};
199
+ const bashCommands: string[] = [];
200
+ const skillDetections = new Map<string, TriggeredSkillDetection>();
201
+ let firstUserQuery = "";
202
+ let lastUserQuery = "";
203
+ let assistantTurns = 0;
204
+ let errors = 0;
205
+
206
+ const noteSkillDetection = (skillName: string, hasSkillMdRead: boolean): void => {
207
+ const normalizedSkillName = skillName.trim();
208
+ if (!normalizedSkillName) return;
209
+ const existing = skillDetections.get(normalizedSkillName);
210
+ if (existing) {
211
+ existing.has_skill_md_read = existing.has_skill_md_read || hasSkillMdRead;
212
+ return;
213
+ }
214
+ skillDetections.set(normalizedSkillName, {
215
+ skill_name: normalizedSkillName,
216
+ has_skill_md_read: hasSkillMdRead,
217
+ });
218
+ };
219
+
220
+ // Parse messages (lines 2+)
221
+ for (let i = 1; i < lines.length; i++) {
222
+ let msg: Record<string, unknown>;
223
+ try {
224
+ msg = JSON.parse(lines[i]);
225
+ } catch {
226
+ continue;
227
+ }
228
+
229
+ const role = (msg.role as string) ?? "";
230
+ const contentBlocks = normalizeContentBlocks(msg.content);
231
+
232
+ if (role === "user") {
233
+ // Extract text from user messages
234
+ for (const block of contentBlocks) {
235
+ if (block.type === "text") {
236
+ const text = ((block.text as string) ?? "").trim();
237
+ if (text) {
238
+ if (!firstUserQuery) firstUserQuery = text;
239
+ lastUserQuery = text;
240
+ break;
241
+ }
242
+ }
243
+ }
244
+ } else if (role === "assistant") {
245
+ assistantTurns += 1;
246
+
247
+ for (const block of contentBlocks) {
248
+ const blockType = (block.type as string) ?? "";
249
+
250
+ // Handle toolCall and toolUse (alias)
251
+ if (blockType === "toolCall" || blockType === "toolUse") {
252
+ const toolName = (block.name as string) ?? "unknown";
253
+ toolCalls[toolName] = (toolCalls[toolName] ?? 0) + 1;
254
+ const inp = (block.input as Record<string, unknown>) ?? {};
255
+
256
+ // Extract bash commands
257
+ if (["Bash", "bash", "execute_bash"].includes(toolName)) {
258
+ const cmd = ((inp.command as string) ?? (inp.cmd as string) ?? "").trim();
259
+ if (cmd) bashCommands.push(cmd);
260
+ }
261
+
262
+ // Skill detection: file reads of SKILL.md
263
+ if (["Read", "read_file"].includes(toolName)) {
264
+ const fp = (inp.file_path as string) ?? (inp.path as string) ?? "";
265
+ if (basename(fp).toUpperCase() === "SKILL.MD") {
266
+ const skillName = basename(join(fp, ".."));
267
+ noteSkillDetection(skillName, true);
268
+ }
269
+ }
270
+ }
271
+
272
+ // Check text content for skill name mentions
273
+ const textContent = (block.text as string) ?? "";
274
+ for (const skillName of skillNames) {
275
+ if (textContent.includes(skillName)) {
276
+ noteSkillDetection(skillName, false);
277
+ }
278
+ }
279
+ }
280
+ } else if (role === "toolResult") {
281
+ const blockHasError = contentBlocks.some(
282
+ (block) => block.isError === true || block.is_error === true,
283
+ );
284
+ if (msg.isError === true || blockHasError) {
285
+ errors += 1;
286
+ }
287
+ }
288
+ }
289
+
290
+ return {
291
+ timestamp,
292
+ session_id: sessionId,
293
+ source: "openclaw",
294
+ transcript_path: filePath,
295
+ cwd,
296
+ last_user_query: lastUserQuery || firstUserQuery,
297
+ query: firstUserQuery,
298
+ tool_calls: toolCalls,
299
+ total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
300
+ bash_commands: bashCommands,
301
+ skills_triggered: [...skillDetections.values()].map((entry) => entry.skill_name),
302
+ skill_detections: [...skillDetections.values()],
303
+ assistant_turns: assistantTurns,
304
+ errors_encountered: errors,
305
+ transcript_chars: content.length,
306
+ session_key: sessionKey,
307
+ channel,
308
+ agent_id: agentIdFromHeader,
309
+ };
310
+ }
311
+
312
+ /** Normalize message content into an array of content block objects. */
313
+ function normalizeContentBlocks(raw: unknown): Array<Record<string, unknown>> {
314
+ if (Array.isArray(raw)) {
315
+ return raw.filter((b): b is Record<string, unknown> => typeof b === "object" && b !== null);
316
+ }
317
+ if (typeof raw === "string") {
318
+ return [{ type: "text", text: raw }];
319
+ }
320
+ if (typeof raw === "object" && raw !== null) {
321
+ return [raw as Record<string, unknown>];
322
+ }
323
+ return [];
324
+ }
325
+
326
+ const OPENCLAW_SKILL_DIRS = [
327
+ join(homedir(), ".openclaw", "skills"),
328
+ join(process.cwd(), ".agents", "skills"),
329
+ ];
330
+
331
+ /**
332
+ * Find OpenClaw skill names from skill directories.
333
+ * By default checks:
334
+ * <agentsDir>/../skills/ (managed skills)
335
+ * ~/.openclaw/skills/
336
+ * process.cwd()/.agents/skills/ (workspace skills)
337
+ */
338
+ export function findOpenClawSkillNames(
339
+ agentsDir: string,
340
+ extraDirs: string[] = OPENCLAW_SKILL_DIRS,
341
+ ): Set<string> {
342
+ const names = new Set<string>();
343
+ const skillDirs = [join(agentsDir, "..", "skills"), join(agentsDir, "skills"), ...extraDirs];
344
+
345
+ for (const dir of skillDirs) {
346
+ if (!existsSync(dir)) continue;
347
+ try {
348
+ for (const entry of readdirSync(dir)) {
349
+ const skillDir = join(dir, entry);
350
+ try {
351
+ if (statSync(skillDir).isDirectory() && existsSync(join(skillDir, "SKILL.md"))) {
352
+ names.add(entry);
353
+ }
354
+ } catch {
355
+ // skip entries that can't be stat'd
356
+ }
357
+ }
358
+ } catch {
359
+ // skip dirs that can't be listed
360
+ }
361
+ }
362
+ return names;
363
+ }
364
+
365
+ /** Write a parsed session to our shared logs. Same pattern as opencode-ingest. */
366
+ export function writeSession(
367
+ session: ParsedSession,
368
+ dryRun = false,
369
+ queryLogPath: string = QUERY_LOG,
370
+ telemetryLogPath: string = TELEMETRY_LOG,
371
+ skillLogPath: string = SKILL_LOG,
372
+ canonicalLogPath: string = CANONICAL_LOG,
373
+ ): void {
374
+ const { query: prompt, session_id: sessionId, skills_triggered: skills } = session;
375
+
376
+ if (dryRun) {
377
+ console.log(
378
+ ` [DRY] session=${sessionId.slice(0, 12)}... turns=${session.assistant_turns} skills=${JSON.stringify(skills)}`,
379
+ );
380
+ if (prompt) console.log(` query: ${prompt.slice(0, 80)}`);
381
+ return;
382
+ }
383
+
384
+ if (prompt && prompt.length >= 4) {
385
+ const queryRecord: QueryLogRecord = {
386
+ timestamp: session.timestamp,
387
+ session_id: sessionId,
388
+ query: prompt,
389
+ source: session.source,
390
+ };
391
+ appendJsonl(queryLogPath, queryRecord, "all_queries");
392
+ }
393
+
394
+ const { query: _q, ...telemetry } = session;
395
+ appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
396
+
397
+ for (const skillName of skills) {
398
+ const skillRecord: SkillUsageRecord = {
399
+ timestamp: session.timestamp,
400
+ session_id: sessionId,
401
+ skill_name: skillName,
402
+ skill_path: `(openclaw:${skillName})`,
403
+ query: prompt,
404
+ triggered: true,
405
+ source: session.source,
406
+ };
407
+ appendJsonl(skillLogPath, skillRecord, "skill_usage");
408
+ }
409
+
410
+ // --- Canonical normalization records (additive) ---
411
+ const canonicalRecords = buildCanonicalRecordsFromOpenClaw(session);
412
+ appendCanonicalRecords(canonicalRecords, canonicalLogPath);
413
+ }
414
+
415
+ /** Build canonical records from a parsed OpenClaw session. */
416
+ export function buildCanonicalRecordsFromOpenClaw(session: ParsedSession): CanonicalRecord[] {
417
+ const records: CanonicalRecord[] = [];
418
+ const baseInput: CanonicalBaseInput = {
419
+ platform: "openclaw",
420
+ capture_mode: "batch_ingest",
421
+ source_session_kind: "replayed",
422
+ session_id: session.session_id,
423
+ raw_source_ref: {
424
+ path: session.transcript_path,
425
+ event_type: "openclaw",
426
+ },
427
+ };
428
+
429
+ records.push(
430
+ buildCanonicalSession({
431
+ ...baseInput,
432
+ started_at: session.timestamp,
433
+ workspace_path: session.cwd || undefined,
434
+ session_key: session.session_key,
435
+ channel: session.channel,
436
+ agent_id: session.agent_id,
437
+ }),
438
+ );
439
+
440
+ const promptEmitted = Boolean(session.query && session.query.length >= 4);
441
+ const promptId = promptEmitted ? derivePromptId(session.session_id, 0) : undefined;
442
+
443
+ if (promptId) {
444
+ records.push(
445
+ buildCanonicalPrompt({
446
+ ...baseInput,
447
+ prompt_id: promptId,
448
+ occurred_at: session.timestamp,
449
+ prompt_text: session.query,
450
+ prompt_index: 0,
451
+ }),
452
+ );
453
+ }
454
+
455
+ const skillDetections =
456
+ session.skill_detections ??
457
+ session.skills_triggered.map((skillName) => ({
458
+ skill_name: skillName,
459
+ has_skill_md_read: false,
460
+ }));
461
+
462
+ for (let i = 0; i < skillDetections.length; i++) {
463
+ const detection = skillDetections[i];
464
+ const skillName = detection.skill_name;
465
+ const { invocation_mode, confidence } = deriveInvocationMode({
466
+ has_skill_md_read: detection.has_skill_md_read,
467
+ is_text_mention_only: !detection.has_skill_md_read,
468
+ });
469
+ records.push(
470
+ buildCanonicalSkillInvocation({
471
+ ...baseInput,
472
+ skill_invocation_id: deriveSkillInvocationId(session.session_id, skillName, i),
473
+ occurred_at: session.timestamp,
474
+ matched_prompt_id: promptId,
475
+ skill_name: skillName,
476
+ skill_path: `(openclaw:${skillName})`,
477
+ invocation_mode,
478
+ triggered: true,
479
+ confidence,
480
+ }),
481
+ );
482
+ }
483
+
484
+ records.push(
485
+ buildCanonicalExecutionFact({
486
+ ...baseInput,
487
+ occurred_at: session.timestamp,
488
+ prompt_id: promptId,
489
+ tool_calls_json: session.tool_calls,
490
+ total_tool_calls: session.total_tool_calls,
491
+ bash_commands_redacted: session.bash_commands,
492
+ assistant_turns: session.assistant_turns,
493
+ errors_encountered: session.errors_encountered,
494
+ }),
495
+ );
496
+
497
+ return records;
498
+ }
499
+
500
+ // --- CLI main ---
501
+ export function cliMain(): void {
502
+ const { values } = parseArgs({
503
+ options: {
504
+ "agents-dir": { type: "string", default: OPENCLAW_AGENTS_DIR },
505
+ since: { type: "string" },
506
+ "dry-run": { type: "boolean", default: false },
507
+ force: { type: "boolean", default: false },
508
+ verbose: { type: "boolean", short: "v", default: false },
509
+ },
510
+ strict: true,
511
+ });
512
+
513
+ const agentsDir = values["agents-dir"] ?? OPENCLAW_AGENTS_DIR;
514
+
515
+ if (!existsSync(agentsDir)) {
516
+ console.log(`OpenClaw agents directory not found: ${agentsDir}`);
517
+ console.log("Is OpenClaw installed? Try --agents-dir to specify a custom location.");
518
+ process.exit(1);
519
+ }
520
+
521
+ let sinceTs: number | null = null;
522
+ if (values.since) {
523
+ const parsed = new Date(`${values.since}T00:00:00Z`);
524
+ if (Number.isNaN(parsed.getTime())) {
525
+ console.error(`[ERROR] Invalid --since date: "${values.since}". Use YYYY-MM-DD format.`);
526
+ process.exit(1);
527
+ }
528
+ sinceTs = parsed.getTime();
529
+ }
530
+
531
+ const skillNames = findOpenClawSkillNames(agentsDir);
532
+ const alreadyIngested = values.force ? new Set<string>() : loadMarker(OPENCLAW_INGEST_MARKER);
533
+ const allSessions = findOpenClawSessions(agentsDir, sinceTs);
534
+
535
+ console.log(`Found ${allSessions.length} total sessions.`);
536
+
537
+ const pending = allSessions.filter((s) => !alreadyIngested.has(s.sessionId));
538
+ console.log(`${pending.length} not yet ingested.`);
539
+
540
+ const newIngested = new Set<string>();
541
+ let ingestedCount = 0;
542
+
543
+ for (const sf of pending) {
544
+ const session = parseOpenClawSession(sf.filePath, skillNames);
545
+
546
+ if (!session.session_id || !session.timestamp) {
547
+ console.log(
548
+ ` [WARN] Skipping session ${sf.sessionId.slice(0, 12)}...: missing session_id or timestamp after parsing`,
549
+ );
550
+ continue;
551
+ }
552
+
553
+ if (values.verbose || values["dry-run"]) {
554
+ console.log(
555
+ ` ${values["dry-run"] ? "[DRY] " : ""}Ingesting: ${sf.sessionId.slice(0, 12)}...`,
556
+ );
557
+ }
558
+
559
+ writeSession(session, values["dry-run"]);
560
+ newIngested.add(sf.sessionId);
561
+ ingestedCount += 1;
562
+ }
563
+
564
+ if (!values["dry-run"]) {
565
+ saveMarker(OPENCLAW_INGEST_MARKER, new Set([...alreadyIngested, ...newIngested]));
566
+ }
567
+
568
+ console.log(`\nDone. Ingested ${ingestedCount} sessions.`);
569
+ }
570
+
571
+ if (import.meta.main) {
572
+ cliMain();
573
+ }