selftune 0.2.21 → 0.2.23
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/README.md +15 -8
- package/apps/local-dashboard/dist/assets/index-CwOtTrUS.css +1 -0
- package/apps/local-dashboard/dist/assets/index-f1HQpbeH.js +59 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-jVSaIZey.js +12 -0
- package/apps/local-dashboard/dist/index.html +3 -3
- package/cli/selftune/adapters/cline/hook.ts +167 -0
- package/cli/selftune/adapters/cline/install.ts +197 -0
- package/cli/selftune/adapters/codex/hook.ts +296 -0
- package/cli/selftune/adapters/codex/install.ts +289 -0
- package/cli/selftune/adapters/opencode/hook.ts +222 -0
- package/cli/selftune/adapters/opencode/install.ts +543 -0
- package/cli/selftune/adapters/pi/hook.ts +273 -0
- package/cli/selftune/adapters/pi/install.ts +207 -0
- package/cli/selftune/constants.ts +10 -1
- package/cli/selftune/dashboard-contract.ts +14 -0
- package/cli/selftune/evolution/engines/judge-engine.ts +96 -0
- package/cli/selftune/evolution/engines/replay-engine.ts +158 -0
- package/cli/selftune/evolution/evidence.ts +2 -6
- package/cli/selftune/evolution/evolve-body.ts +73 -20
- package/cli/selftune/evolution/validate-body.ts +78 -42
- package/cli/selftune/evolution/validate-routing.ts +45 -104
- package/cli/selftune/hooks/auto-activate.ts +43 -37
- package/cli/selftune/hooks/skill-eval.ts +2 -1
- package/cli/selftune/hooks-shared/git-metadata.ts +149 -0
- package/cli/selftune/hooks-shared/hook-output.ts +105 -0
- package/cli/selftune/hooks-shared/normalize.ts +196 -0
- package/cli/selftune/hooks-shared/session-state.ts +76 -0
- package/cli/selftune/hooks-shared/skill-paths.ts +50 -0
- package/cli/selftune/hooks-shared/stdin-dispatch.ts +59 -0
- package/cli/selftune/hooks-shared/types.ts +91 -0
- package/cli/selftune/index.ts +76 -6
- package/cli/selftune/ingestors/pi-ingest.ts +726 -0
- package/cli/selftune/init.ts +11 -1
- package/cli/selftune/localdb/direct-write.ts +85 -0
- package/cli/selftune/localdb/materialize.ts +6 -7
- package/cli/selftune/localdb/queries.ts +126 -0
- package/cli/selftune/localdb/schema.ts +38 -0
- package/cli/selftune/observability.ts +8 -1
- package/cli/selftune/orchestrate.ts +43 -0
- package/cli/selftune/registry/client.ts +74 -0
- package/cli/selftune/registry/history.ts +54 -0
- package/cli/selftune/registry/index.ts +90 -0
- package/cli/selftune/registry/install.ts +141 -0
- package/cli/selftune/registry/list.ts +44 -0
- package/cli/selftune/registry/push.ts +171 -0
- package/cli/selftune/registry/rollback.ts +49 -0
- package/cli/selftune/registry/status.ts +62 -0
- package/cli/selftune/registry/sync.ts +125 -0
- package/cli/selftune/repair/skill-usage.ts +4 -1
- package/cli/selftune/status.ts +31 -0
- package/cli/selftune/sync.ts +127 -23
- package/cli/selftune/types.ts +2 -1
- package/cli/selftune/utils/jsonl.ts +1 -30
- package/cli/selftune/utils/llm-call.ts +99 -34
- package/cli/selftune/utils/skill-discovery.ts +22 -0
- package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/fixtures/golden.test.ts +0 -1
- package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
- package/node_modules/@selftune/telemetry-contract/package.json +1 -1
- package/node_modules/@selftune/telemetry-contract/src/index.ts +1 -0
- package/node_modules/@selftune/telemetry-contract/src/schemas.ts +22 -4
- package/node_modules/@selftune/telemetry-contract/src/types.ts +1 -12
- package/node_modules/@selftune/telemetry-contract/tests/compatibility.test.ts +0 -1
- package/package.json +1 -1
- package/packages/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
- package/packages/telemetry-contract/fixtures/golden.test.ts +0 -1
- package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
- package/packages/telemetry-contract/package.json +1 -1
- package/packages/telemetry-contract/src/index.ts +1 -0
- package/packages/telemetry-contract/src/schemas.ts +22 -4
- package/packages/telemetry-contract/src/types.ts +1 -12
- package/packages/telemetry-contract/tests/compatibility.test.ts +0 -1
- package/packages/ui/AGENTS.md +16 -0
- package/packages/ui/README.md +1 -1
- package/packages/ui/package.json +1 -1
- package/packages/ui/src/components/ActivityTimeline.tsx +152 -168
- package/packages/ui/src/components/AnalyticsCharts.tsx +344 -0
- package/packages/ui/src/components/EvidenceViewer.tsx +153 -443
- package/packages/ui/src/components/EvolutionTimeline.tsx +34 -87
- package/packages/ui/src/components/InfoTip.tsx +1 -2
- package/packages/ui/src/components/InvocationsPanel.tsx +413 -0
- package/packages/ui/src/components/JobHistoryTimeline.tsx +156 -0
- package/packages/ui/src/components/OrchestrateRunsPanel.tsx +18 -36
- package/packages/ui/src/components/OverviewPanels.tsx +652 -0
- package/packages/ui/src/components/PipelineStatusBar.tsx +65 -0
- package/packages/ui/src/components/SkillReportGuide.tsx +215 -0
- package/packages/ui/src/components/SkillReportPanels.tsx +919 -0
- package/packages/ui/src/components/SkillsLibrary.tsx +437 -0
- package/packages/ui/src/components/index.ts +56 -1
- package/packages/ui/src/components/section-cards.tsx +18 -35
- package/packages/ui/src/components/skill-health-grid.tsx +47 -37
- package/packages/ui/src/lib/constants.tsx +0 -1
- package/packages/ui/src/primitives/card.tsx +1 -1
- package/packages/ui/src/primitives/checkbox.tsx +1 -1
- package/packages/ui/src/primitives/dropdown-menu.tsx +2 -2
- package/packages/ui/src/primitives/select.tsx +2 -2
- package/packages/ui/src/types.ts +172 -4
- package/skill/SKILL.md +26 -2
- package/skill/Workflows/Ingest.md +60 -2
- package/skill/Workflows/Initialize.md +54 -9
- package/skill/Workflows/PlatformHooks.md +109 -0
- package/skill/Workflows/Registry.md +99 -0
- package/skill/Workflows/Sync.md +3 -1
- package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +0 -60
- package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +0 -1
- package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +0 -12
- package/cli/selftune/utils/html.ts +0 -27
- package/packages/ui/src/components/RecentActivityFeed.tsx +0 -117
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Pi session ingestor: pi-ingest.ts
|
|
4
|
+
*
|
|
5
|
+
* Ingests Pi coding agent session history from JSONL files into
|
|
6
|
+
* selftune's shared telemetry schema.
|
|
7
|
+
*
|
|
8
|
+
* Pi stores sessions as tree-structured JSONL at:
|
|
9
|
+
* ~/.pi/agent/sessions/--<path>--/<timestamp>_<uuid>.jsonl
|
|
10
|
+
*
|
|
11
|
+
* Each JSONL file has:
|
|
12
|
+
* Line 1 (session header): {"type":"session","version":3,"id":"<uuid>","timestamp":"<iso>","cwd":"<path>"}
|
|
13
|
+
* Line 2+ (entries): {"type":"message|model_change|...","id":"<hex>","parentId":"<hex>","timestamp":"<iso>"}
|
|
14
|
+
*
|
|
15
|
+
* The entries form an append-only tree (id/parentId). This ingestor
|
|
16
|
+
* linearizes by following the child with the latest timestamp at each
|
|
17
|
+
* branch point (greedy main-thread extraction).
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* bun pi-ingest.ts
|
|
21
|
+
* bun pi-ingest.ts --since 2026-01-01
|
|
22
|
+
* bun pi-ingest.ts --sessions-dir /custom/path
|
|
23
|
+
* bun pi-ingest.ts --dry-run
|
|
24
|
+
* bun pi-ingest.ts --force
|
|
25
|
+
* bun pi-ingest.ts --verbose
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
29
|
+
import { basename, join } from "node:path";
|
|
30
|
+
import { parseArgs } from "node:util";
|
|
31
|
+
|
|
32
|
+
import { PI_INGEST_MARKER, PI_SESSIONS_DIR } from "../constants.js";
|
|
33
|
+
import {
|
|
34
|
+
writeQueryToDb,
|
|
35
|
+
writeSessionTelemetryToDb,
|
|
36
|
+
writeSkillUsageToDb,
|
|
37
|
+
} from "../localdb/direct-write.js";
|
|
38
|
+
import {
|
|
39
|
+
appendCanonicalRecords,
|
|
40
|
+
buildCanonicalExecutionFact,
|
|
41
|
+
buildCanonicalPrompt,
|
|
42
|
+
buildCanonicalSession,
|
|
43
|
+
buildCanonicalSkillInvocation,
|
|
44
|
+
type CanonicalBaseInput,
|
|
45
|
+
deriveInvocationMode,
|
|
46
|
+
derivePromptId,
|
|
47
|
+
deriveSkillInvocationId,
|
|
48
|
+
} from "../normalization.js";
|
|
49
|
+
import type { CanonicalRecord, QueryLogRecord, SkillUsageRecord } from "../types.js";
|
|
50
|
+
import { loadMarker, saveMarker } from "../utils/jsonl.js";
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Types
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface SessionFile {
|
|
57
|
+
sessionId: string;
|
|
58
|
+
filePath: string;
|
|
59
|
+
timestamp: number; // epoch ms from header or file stat
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface PiEntry {
|
|
63
|
+
type: string;
|
|
64
|
+
id?: string;
|
|
65
|
+
parentId?: string | null;
|
|
66
|
+
timestamp?: string;
|
|
67
|
+
message?: PiMessage;
|
|
68
|
+
provider?: string;
|
|
69
|
+
modelId?: string;
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface PiMessage {
|
|
74
|
+
role: string;
|
|
75
|
+
content?: unknown;
|
|
76
|
+
api?: string;
|
|
77
|
+
provider?: string;
|
|
78
|
+
model?: string;
|
|
79
|
+
stopReason?: string;
|
|
80
|
+
usage?: {
|
|
81
|
+
input?: number;
|
|
82
|
+
output?: number;
|
|
83
|
+
cacheRead?: number;
|
|
84
|
+
cacheWrite?: number;
|
|
85
|
+
totalTokens?: number;
|
|
86
|
+
cost?: { total?: number };
|
|
87
|
+
};
|
|
88
|
+
timestamp?: number;
|
|
89
|
+
[key: string]: unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface TriggeredSkillDetection {
|
|
93
|
+
skill_name: string;
|
|
94
|
+
has_skill_md_read: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ParsedSession {
|
|
98
|
+
timestamp: string;
|
|
99
|
+
session_id: string;
|
|
100
|
+
source: string;
|
|
101
|
+
transcript_path: string;
|
|
102
|
+
cwd: string;
|
|
103
|
+
last_user_query: string;
|
|
104
|
+
query: string;
|
|
105
|
+
tool_calls: Record<string, number>;
|
|
106
|
+
total_tool_calls: number;
|
|
107
|
+
bash_commands: string[];
|
|
108
|
+
skills_triggered: string[];
|
|
109
|
+
skill_detections?: TriggeredSkillDetection[];
|
|
110
|
+
assistant_turns: number;
|
|
111
|
+
errors_encountered: number;
|
|
112
|
+
transcript_chars: number;
|
|
113
|
+
provider?: string;
|
|
114
|
+
model?: string;
|
|
115
|
+
input_tokens?: number;
|
|
116
|
+
output_tokens?: number;
|
|
117
|
+
completion_status?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Session discovery
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Recursively find Pi session JSONL files under the sessions directory.
|
|
126
|
+
* Pi stores sessions in subdirectories named --<path>--.
|
|
127
|
+
*/
|
|
128
|
+
export function findPiSessions(sessionsDir: string, sinceTs: number | null): SessionFile[] {
|
|
129
|
+
if (!existsSync(sessionsDir)) return [];
|
|
130
|
+
|
|
131
|
+
const results: SessionFile[] = [];
|
|
132
|
+
|
|
133
|
+
function walkDir(dir: string): void {
|
|
134
|
+
let entries: string[];
|
|
135
|
+
try {
|
|
136
|
+
entries = readdirSync(dir);
|
|
137
|
+
} catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
const fullPath = join(dir, entry);
|
|
143
|
+
try {
|
|
144
|
+
const stat = statSync(fullPath);
|
|
145
|
+
if (stat.isDirectory()) {
|
|
146
|
+
walkDir(fullPath);
|
|
147
|
+
} else if (entry.endsWith(".jsonl")) {
|
|
148
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
149
|
+
const firstLine = content.split("\n")[0]?.trim();
|
|
150
|
+
if (!firstLine) continue;
|
|
151
|
+
|
|
152
|
+
const header = JSON.parse(firstLine);
|
|
153
|
+
if (header.type !== "session") continue;
|
|
154
|
+
|
|
155
|
+
const sessionId = header.id ?? basename(entry, ".jsonl");
|
|
156
|
+
const headerTs = header.timestamp ? new Date(header.timestamp).getTime() : 0;
|
|
157
|
+
const fileTs = headerTs || stat.mtimeMs;
|
|
158
|
+
|
|
159
|
+
if (sinceTs !== null && fileTs < sinceTs) continue;
|
|
160
|
+
|
|
161
|
+
results.push({ sessionId, filePath: fullPath, timestamp: fileTs });
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Skip files that can't be read or parsed
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
walkDir(sessionsDir);
|
|
170
|
+
return results;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Tree linearization
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Linearize a Pi session tree by following the child with the latest
|
|
179
|
+
* timestamp at each branch point. Returns entries in chronological order
|
|
180
|
+
* from root to leaf.
|
|
181
|
+
*/
|
|
182
|
+
function linearizeTree(entries: PiEntry[]): PiEntry[] {
|
|
183
|
+
if (entries.length === 0) return [];
|
|
184
|
+
|
|
185
|
+
// Build id -> entry map and parent -> children adjacency
|
|
186
|
+
const byId = new Map<string, PiEntry>();
|
|
187
|
+
const children = new Map<string, PiEntry[]>();
|
|
188
|
+
let root: PiEntry | undefined;
|
|
189
|
+
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
if (!entry.id) continue;
|
|
192
|
+
byId.set(entry.id, entry);
|
|
193
|
+
|
|
194
|
+
const parentId = entry.parentId ?? null;
|
|
195
|
+
if (parentId === null) {
|
|
196
|
+
root = entry;
|
|
197
|
+
} else {
|
|
198
|
+
const siblings = children.get(parentId) ?? [];
|
|
199
|
+
siblings.push(entry);
|
|
200
|
+
children.set(parentId, siblings);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// If no root found, use first entry
|
|
205
|
+
if (!root) root = entries[0];
|
|
206
|
+
|
|
207
|
+
// Walk greedily: at each node, follow child with latest timestamp
|
|
208
|
+
const result: PiEntry[] = [root];
|
|
209
|
+
let current = root;
|
|
210
|
+
|
|
211
|
+
while (current.id) {
|
|
212
|
+
const kids = children.get(current.id);
|
|
213
|
+
if (!kids || kids.length === 0) break;
|
|
214
|
+
|
|
215
|
+
// Pick the child with the latest timestamp
|
|
216
|
+
let latest = kids[0];
|
|
217
|
+
let latestTs = latest.timestamp ? new Date(latest.timestamp).getTime() : 0;
|
|
218
|
+
|
|
219
|
+
for (let i = 1; i < kids.length; i++) {
|
|
220
|
+
const ts = kids[i].timestamp ? new Date(kids[i].timestamp).getTime() : 0;
|
|
221
|
+
if (ts > latestTs) {
|
|
222
|
+
latest = kids[i];
|
|
223
|
+
latestTs = ts;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
result.push(latest);
|
|
228
|
+
current = latest;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Session parsing
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/** Normalize message content into an array of content block objects. */
|
|
239
|
+
function normalizeContentBlocks(raw: unknown): Array<Record<string, unknown>> {
|
|
240
|
+
if (Array.isArray(raw)) {
|
|
241
|
+
return raw.filter((b): b is Record<string, unknown> => typeof b === "object" && b !== null);
|
|
242
|
+
}
|
|
243
|
+
if (typeof raw === "string") {
|
|
244
|
+
return [{ type: "text", text: raw }];
|
|
245
|
+
}
|
|
246
|
+
if (typeof raw === "object" && raw !== null) {
|
|
247
|
+
return [raw as Record<string, unknown>];
|
|
248
|
+
}
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Map Pi stopReason to canonical completion status. */
|
|
253
|
+
function mapStopReason(stopReason: string | undefined): string | undefined {
|
|
254
|
+
if (!stopReason) return undefined;
|
|
255
|
+
switch (stopReason) {
|
|
256
|
+
case "stop":
|
|
257
|
+
case "end_turn":
|
|
258
|
+
return "completed";
|
|
259
|
+
case "error":
|
|
260
|
+
return "failed";
|
|
261
|
+
case "aborted":
|
|
262
|
+
return "cancelled";
|
|
263
|
+
case "length":
|
|
264
|
+
case "max_tokens":
|
|
265
|
+
return "interrupted";
|
|
266
|
+
default:
|
|
267
|
+
return "unknown";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Parse a Pi session JSONL file into a ParsedSession.
|
|
273
|
+
*/
|
|
274
|
+
export function parsePiSession(filePath: string, skillNames: Set<string>): ParsedSession {
|
|
275
|
+
const empty: ParsedSession = {
|
|
276
|
+
timestamp: "",
|
|
277
|
+
session_id: "",
|
|
278
|
+
source: "pi",
|
|
279
|
+
transcript_path: filePath,
|
|
280
|
+
cwd: "",
|
|
281
|
+
last_user_query: "",
|
|
282
|
+
query: "",
|
|
283
|
+
tool_calls: {},
|
|
284
|
+
total_tool_calls: 0,
|
|
285
|
+
bash_commands: [],
|
|
286
|
+
skills_triggered: [],
|
|
287
|
+
skill_detections: [],
|
|
288
|
+
assistant_turns: 0,
|
|
289
|
+
errors_encountered: 0,
|
|
290
|
+
transcript_chars: 0,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
let content: string;
|
|
294
|
+
try {
|
|
295
|
+
content = readFileSync(filePath, "utf-8");
|
|
296
|
+
} catch {
|
|
297
|
+
return empty;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
empty.transcript_chars = content.length;
|
|
301
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
302
|
+
|
|
303
|
+
if (lines.length === 0) return empty;
|
|
304
|
+
|
|
305
|
+
// Parse session header (line 1)
|
|
306
|
+
let header: Record<string, unknown>;
|
|
307
|
+
try {
|
|
308
|
+
header = JSON.parse(lines[0]);
|
|
309
|
+
} catch {
|
|
310
|
+
return empty;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (header.type !== "session") return empty;
|
|
314
|
+
|
|
315
|
+
const sessionId = (header.id as string) ?? "";
|
|
316
|
+
const timestamp = (header.timestamp as string) ?? "";
|
|
317
|
+
const cwd = (header.cwd as string) ?? "";
|
|
318
|
+
|
|
319
|
+
// Parse all entries (lines 2+)
|
|
320
|
+
const entries: PiEntry[] = [];
|
|
321
|
+
for (let i = 1; i < lines.length; i++) {
|
|
322
|
+
try {
|
|
323
|
+
entries.push(JSON.parse(lines[i]) as PiEntry);
|
|
324
|
+
} catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Linearize the tree to get main conversation thread
|
|
330
|
+
const linearEntries = linearizeTree(entries);
|
|
331
|
+
|
|
332
|
+
const toolCalls: Record<string, number> = {};
|
|
333
|
+
const bashCommands: string[] = [];
|
|
334
|
+
const skillDetections = new Map<string, TriggeredSkillDetection>();
|
|
335
|
+
let firstUserQuery = "";
|
|
336
|
+
let lastUserQuery = "";
|
|
337
|
+
let assistantTurns = 0;
|
|
338
|
+
let errors = 0;
|
|
339
|
+
let lastProvider: string | undefined;
|
|
340
|
+
let lastModel: string | undefined;
|
|
341
|
+
let totalInputTokens = 0;
|
|
342
|
+
let totalOutputTokens = 0;
|
|
343
|
+
let lastStopReason: string | undefined;
|
|
344
|
+
|
|
345
|
+
const noteSkillDetection = (skillName: string, hasSkillMdRead: boolean): void => {
|
|
346
|
+
const normalizedSkillName = skillName.trim();
|
|
347
|
+
if (!normalizedSkillName) return;
|
|
348
|
+
const existing = skillDetections.get(normalizedSkillName);
|
|
349
|
+
if (existing) {
|
|
350
|
+
existing.has_skill_md_read = existing.has_skill_md_read || hasSkillMdRead;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
skillDetections.set(normalizedSkillName, {
|
|
354
|
+
skill_name: normalizedSkillName,
|
|
355
|
+
has_skill_md_read: hasSkillMdRead,
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
for (const entry of linearEntries) {
|
|
360
|
+
// Track model changes
|
|
361
|
+
if (entry.type === "model_change") {
|
|
362
|
+
if (entry.provider) lastProvider = entry.provider as string;
|
|
363
|
+
if (entry.modelId) lastModel = entry.modelId as string;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (entry.type !== "message") continue;
|
|
368
|
+
|
|
369
|
+
const msg = entry.message;
|
|
370
|
+
if (!msg) continue;
|
|
371
|
+
|
|
372
|
+
const role = msg.role ?? "";
|
|
373
|
+
const contentBlocks = normalizeContentBlocks(msg.content);
|
|
374
|
+
|
|
375
|
+
if (role === "user") {
|
|
376
|
+
for (const block of contentBlocks) {
|
|
377
|
+
if (block.type === "text") {
|
|
378
|
+
const text = ((block.text as string) ?? "").trim();
|
|
379
|
+
if (text) {
|
|
380
|
+
if (!firstUserQuery) firstUserQuery = text;
|
|
381
|
+
lastUserQuery = text;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} else if (role === "assistant") {
|
|
387
|
+
assistantTurns += 1;
|
|
388
|
+
|
|
389
|
+
// Extract metadata from assistant messages
|
|
390
|
+
if (msg.provider) lastProvider = msg.provider;
|
|
391
|
+
if (msg.model) lastModel = msg.model;
|
|
392
|
+
if (msg.stopReason) lastStopReason = msg.stopReason;
|
|
393
|
+
if (msg.usage) {
|
|
394
|
+
if (msg.usage.input) totalInputTokens += msg.usage.input;
|
|
395
|
+
if (msg.usage.output) totalOutputTokens += msg.usage.output;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const block of contentBlocks) {
|
|
399
|
+
const blockType = (block.type as string) ?? "";
|
|
400
|
+
|
|
401
|
+
// Handle toolCall blocks
|
|
402
|
+
if (blockType === "toolCall" || blockType === "toolUse") {
|
|
403
|
+
const toolName = (block.name as string) ?? "unknown";
|
|
404
|
+
toolCalls[toolName] = (toolCalls[toolName] ?? 0) + 1;
|
|
405
|
+
const inp =
|
|
406
|
+
(block.arguments as Record<string, unknown>) ??
|
|
407
|
+
(block.input as Record<string, unknown>) ??
|
|
408
|
+
{};
|
|
409
|
+
|
|
410
|
+
// Extract bash commands
|
|
411
|
+
if (["Bash", "bash", "execute_bash"].includes(toolName)) {
|
|
412
|
+
const cmd = ((inp.command as string) ?? (inp.cmd as string) ?? "").trim();
|
|
413
|
+
if (cmd) bashCommands.push(cmd);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Skill detection: file reads of SKILL.md
|
|
417
|
+
if (["Read", "read_file"].includes(toolName)) {
|
|
418
|
+
const fp = (inp.file_path as string) ?? (inp.path as string) ?? "";
|
|
419
|
+
if (basename(fp).toUpperCase() === "SKILL.MD") {
|
|
420
|
+
const skillName = basename(join(fp, ".."));
|
|
421
|
+
noteSkillDetection(skillName, true);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Check text content for skill name mentions
|
|
427
|
+
const textContent = (block.text as string) ?? "";
|
|
428
|
+
for (const skillName of skillNames) {
|
|
429
|
+
if (textContent.includes(skillName)) {
|
|
430
|
+
noteSkillDetection(skillName, false);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
} else if (role === "toolResult") {
|
|
435
|
+
const blockHasError = contentBlocks.some(
|
|
436
|
+
(block) => block.isError === true || block.is_error === true,
|
|
437
|
+
);
|
|
438
|
+
if (msg.isError === true || blockHasError) {
|
|
439
|
+
errors += 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
timestamp,
|
|
446
|
+
session_id: sessionId,
|
|
447
|
+
source: "pi",
|
|
448
|
+
transcript_path: filePath,
|
|
449
|
+
cwd,
|
|
450
|
+
last_user_query: lastUserQuery || firstUserQuery,
|
|
451
|
+
query: firstUserQuery,
|
|
452
|
+
tool_calls: toolCalls,
|
|
453
|
+
total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
|
|
454
|
+
bash_commands: bashCommands,
|
|
455
|
+
skills_triggered: [...skillDetections.values()].map((entry) => entry.skill_name),
|
|
456
|
+
skill_detections: [...skillDetections.values()],
|
|
457
|
+
assistant_turns: assistantTurns,
|
|
458
|
+
errors_encountered: errors,
|
|
459
|
+
transcript_chars: content.length,
|
|
460
|
+
provider: lastProvider,
|
|
461
|
+
model: lastModel,
|
|
462
|
+
input_tokens: totalInputTokens || undefined,
|
|
463
|
+
output_tokens: totalOutputTokens || undefined,
|
|
464
|
+
completion_status: mapStopReason(lastStopReason),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Write session to shared logs
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
/** Write a parsed session to selftune's shared logs. */
|
|
473
|
+
export function writeSession(session: ParsedSession, dryRun = false): void {
|
|
474
|
+
const { query: prompt, session_id: sessionId, skills_triggered: skills } = session;
|
|
475
|
+
|
|
476
|
+
if (dryRun) {
|
|
477
|
+
console.log(
|
|
478
|
+
` [DRY] session=${sessionId.slice(0, 12)}... turns=${session.assistant_turns} skills=${JSON.stringify(skills)}`,
|
|
479
|
+
);
|
|
480
|
+
if (prompt) console.log(` query: ${prompt.slice(0, 80)}`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (prompt && prompt.length >= 4) {
|
|
485
|
+
const queryRecord: QueryLogRecord = {
|
|
486
|
+
timestamp: session.timestamp,
|
|
487
|
+
session_id: sessionId,
|
|
488
|
+
query: prompt,
|
|
489
|
+
source: session.source,
|
|
490
|
+
};
|
|
491
|
+
writeQueryToDb(queryRecord);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
writeSessionTelemetryToDb({
|
|
495
|
+
timestamp: session.timestamp,
|
|
496
|
+
session_id: session.session_id,
|
|
497
|
+
cwd: session.cwd,
|
|
498
|
+
transcript_path: session.transcript_path,
|
|
499
|
+
tool_calls: session.tool_calls,
|
|
500
|
+
total_tool_calls: session.total_tool_calls,
|
|
501
|
+
bash_commands: session.bash_commands,
|
|
502
|
+
skills_triggered: session.skills_triggered,
|
|
503
|
+
assistant_turns: session.assistant_turns,
|
|
504
|
+
errors_encountered: session.errors_encountered,
|
|
505
|
+
transcript_chars: session.transcript_chars,
|
|
506
|
+
last_user_query: session.last_user_query,
|
|
507
|
+
source: session.source,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
for (const skillName of skills) {
|
|
511
|
+
const skillRecord: SkillUsageRecord = {
|
|
512
|
+
timestamp: session.timestamp,
|
|
513
|
+
session_id: sessionId,
|
|
514
|
+
skill_name: skillName,
|
|
515
|
+
skill_path: `(pi:${skillName})`,
|
|
516
|
+
query: prompt,
|
|
517
|
+
triggered: true,
|
|
518
|
+
source: session.source,
|
|
519
|
+
};
|
|
520
|
+
writeSkillUsageToDb(skillRecord);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// --- Canonical normalization records (additive) ---
|
|
524
|
+
const canonicalRecords = buildCanonicalRecordsFromPi(session);
|
|
525
|
+
appendCanonicalRecords(canonicalRecords);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Build canonical records from a parsed Pi session. */
|
|
529
|
+
export function buildCanonicalRecordsFromPi(session: ParsedSession): CanonicalRecord[] {
|
|
530
|
+
const records: CanonicalRecord[] = [];
|
|
531
|
+
const baseInput: CanonicalBaseInput = {
|
|
532
|
+
platform: "pi",
|
|
533
|
+
capture_mode: "batch_ingest",
|
|
534
|
+
source_session_kind: "replayed",
|
|
535
|
+
session_id: session.session_id,
|
|
536
|
+
raw_source_ref: {
|
|
537
|
+
path: session.transcript_path,
|
|
538
|
+
event_type: "pi",
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
records.push(
|
|
543
|
+
buildCanonicalSession({
|
|
544
|
+
...baseInput,
|
|
545
|
+
started_at: session.timestamp,
|
|
546
|
+
workspace_path: session.cwd || undefined,
|
|
547
|
+
provider: session.provider,
|
|
548
|
+
model: session.model,
|
|
549
|
+
completion_status: session.completion_status as
|
|
550
|
+
| "completed"
|
|
551
|
+
| "failed"
|
|
552
|
+
| "interrupted"
|
|
553
|
+
| "cancelled"
|
|
554
|
+
| "unknown"
|
|
555
|
+
| undefined,
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const promptEmitted = Boolean(session.query && session.query.length >= 4);
|
|
560
|
+
const promptId = promptEmitted ? derivePromptId(session.session_id, 0) : undefined;
|
|
561
|
+
|
|
562
|
+
if (promptId) {
|
|
563
|
+
records.push(
|
|
564
|
+
buildCanonicalPrompt({
|
|
565
|
+
...baseInput,
|
|
566
|
+
prompt_id: promptId,
|
|
567
|
+
occurred_at: session.timestamp,
|
|
568
|
+
prompt_text: session.query,
|
|
569
|
+
prompt_index: 0,
|
|
570
|
+
}),
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const skillDetections =
|
|
575
|
+
session.skill_detections ??
|
|
576
|
+
session.skills_triggered.map((skillName) => ({
|
|
577
|
+
skill_name: skillName,
|
|
578
|
+
has_skill_md_read: false,
|
|
579
|
+
}));
|
|
580
|
+
|
|
581
|
+
for (let i = 0; i < skillDetections.length; i++) {
|
|
582
|
+
const detection = skillDetections[i];
|
|
583
|
+
const skillName = detection.skill_name;
|
|
584
|
+
const { invocation_mode, confidence } = deriveInvocationMode({
|
|
585
|
+
has_skill_md_read: detection.has_skill_md_read,
|
|
586
|
+
is_text_mention_only: !detection.has_skill_md_read,
|
|
587
|
+
});
|
|
588
|
+
records.push(
|
|
589
|
+
buildCanonicalSkillInvocation({
|
|
590
|
+
...baseInput,
|
|
591
|
+
skill_invocation_id: deriveSkillInvocationId(session.session_id, skillName, i),
|
|
592
|
+
occurred_at: session.timestamp,
|
|
593
|
+
matched_prompt_id: promptId,
|
|
594
|
+
skill_name: skillName,
|
|
595
|
+
skill_path: `(pi:${skillName})`,
|
|
596
|
+
invocation_mode,
|
|
597
|
+
triggered: true,
|
|
598
|
+
confidence,
|
|
599
|
+
}),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
records.push(
|
|
604
|
+
buildCanonicalExecutionFact({
|
|
605
|
+
...baseInput,
|
|
606
|
+
occurred_at: session.timestamp,
|
|
607
|
+
prompt_id: promptId,
|
|
608
|
+
tool_calls_json: session.tool_calls,
|
|
609
|
+
total_tool_calls: session.total_tool_calls,
|
|
610
|
+
bash_commands_redacted: session.bash_commands,
|
|
611
|
+
assistant_turns: session.assistant_turns,
|
|
612
|
+
errors_encountered: session.errors_encountered,
|
|
613
|
+
input_tokens: session.input_tokens,
|
|
614
|
+
output_tokens: session.output_tokens,
|
|
615
|
+
}),
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
return records;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// Skill name discovery
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
/** Find skill names from common Pi skill directories. */
|
|
626
|
+
export function findPiSkillNames(): Set<string> {
|
|
627
|
+
const names = new Set<string>();
|
|
628
|
+
const skillDirs = [join(process.cwd(), ".agents", "skills"), join(process.cwd(), "skills")];
|
|
629
|
+
|
|
630
|
+
for (const dir of skillDirs) {
|
|
631
|
+
if (!existsSync(dir)) continue;
|
|
632
|
+
try {
|
|
633
|
+
for (const entry of readdirSync(dir)) {
|
|
634
|
+
const skillDir = join(dir, entry);
|
|
635
|
+
try {
|
|
636
|
+
if (statSync(skillDir).isDirectory() && existsSync(join(skillDir, "SKILL.md"))) {
|
|
637
|
+
names.add(entry);
|
|
638
|
+
}
|
|
639
|
+
} catch {
|
|
640
|
+
// skip
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} catch {
|
|
644
|
+
// skip
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return names;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
// CLI main
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
export function cliMain(): void {
|
|
655
|
+
const { values } = parseArgs({
|
|
656
|
+
options: {
|
|
657
|
+
"sessions-dir": { type: "string", default: PI_SESSIONS_DIR },
|
|
658
|
+
since: { type: "string" },
|
|
659
|
+
"dry-run": { type: "boolean", default: false },
|
|
660
|
+
force: { type: "boolean", default: false },
|
|
661
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
662
|
+
},
|
|
663
|
+
strict: true,
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const sessionsDir = values["sessions-dir"] ?? PI_SESSIONS_DIR;
|
|
667
|
+
|
|
668
|
+
if (!existsSync(sessionsDir)) {
|
|
669
|
+
console.log(`Pi sessions directory not found: ${sessionsDir}`);
|
|
670
|
+
console.log("Is Pi installed? Try --sessions-dir to specify a custom location.");
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let sinceTs: number | null = null;
|
|
675
|
+
if (values.since) {
|
|
676
|
+
const parsed = new Date(`${values.since}T00:00:00Z`);
|
|
677
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
678
|
+
console.error(`[ERROR] Invalid --since date: "${values.since}". Use YYYY-MM-DD format.`);
|
|
679
|
+
process.exit(1);
|
|
680
|
+
}
|
|
681
|
+
sinceTs = parsed.getTime();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const skillNames = findPiSkillNames();
|
|
685
|
+
const alreadyIngested = values.force ? new Set<string>() : loadMarker(PI_INGEST_MARKER);
|
|
686
|
+
const allSessions = findPiSessions(sessionsDir, sinceTs);
|
|
687
|
+
|
|
688
|
+
console.log(`Found ${allSessions.length} total sessions.`);
|
|
689
|
+
|
|
690
|
+
const pending = allSessions.filter((s) => !alreadyIngested.has(s.sessionId));
|
|
691
|
+
console.log(`${pending.length} not yet ingested.`);
|
|
692
|
+
|
|
693
|
+
const newIngested = new Set<string>();
|
|
694
|
+
let ingestedCount = 0;
|
|
695
|
+
|
|
696
|
+
for (const sf of pending) {
|
|
697
|
+
const session = parsePiSession(sf.filePath, skillNames);
|
|
698
|
+
|
|
699
|
+
if (!session.session_id || !session.timestamp) {
|
|
700
|
+
console.log(
|
|
701
|
+
` [WARN] Skipping session ${sf.sessionId.slice(0, 12)}...: missing session_id or timestamp after parsing`,
|
|
702
|
+
);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (values.verbose || values["dry-run"]) {
|
|
707
|
+
console.log(
|
|
708
|
+
` ${values["dry-run"] ? "[DRY] " : ""}Ingesting: ${sf.sessionId.slice(0, 12)}...`,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
writeSession(session, values["dry-run"]);
|
|
713
|
+
newIngested.add(sf.sessionId);
|
|
714
|
+
ingestedCount += 1;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (!values["dry-run"]) {
|
|
718
|
+
saveMarker(PI_INGEST_MARKER, new Set([...alreadyIngested, ...newIngested]));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
console.log(`\nDone. Ingested ${ingestedCount} sessions.`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (import.meta.main) {
|
|
725
|
+
cliMain();
|
|
726
|
+
}
|