selftune 0.2.0 → 0.2.2
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 +20 -10
- package/.claude/agents/evolution-reviewer.md +14 -1
- package/.claude/agents/integration-guide.md +18 -6
- package/.claude/agents/pattern-analyst.md +18 -5
- package/CHANGELOG.md +12 -4
- package/README.md +43 -35
- package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
- package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
- package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
- package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
- package/apps/local-dashboard/dist/favicon.png +0 -0
- package/apps/local-dashboard/dist/index.html +17 -0
- package/apps/local-dashboard/dist/logo.png +0 -0
- package/apps/local-dashboard/dist/logo.svg +9 -0
- package/cli/selftune/badge/badge-data.ts +1 -1
- package/cli/selftune/badge/badge.ts +4 -8
- package/cli/selftune/canonical-export.ts +183 -0
- package/cli/selftune/constants.ts +28 -0
- package/cli/selftune/contribute/contribute.ts +1 -1
- package/cli/selftune/cron/setup.ts +17 -17
- package/cli/selftune/dashboard-contract.ts +202 -0
- package/cli/selftune/dashboard-server.ts +653 -186
- package/cli/selftune/dashboard.ts +41 -176
- package/cli/selftune/eval/baseline.ts +5 -4
- package/cli/selftune/eval/composability-v2.ts +273 -0
- package/cli/selftune/eval/hooks-to-evals.ts +34 -15
- package/cli/selftune/eval/unit-test-cli.ts +1 -1
- package/cli/selftune/evolution/evidence.ts +26 -0
- package/cli/selftune/evolution/evolve-body.ts +105 -11
- package/cli/selftune/evolution/evolve.ts +371 -25
- package/cli/selftune/evolution/extract-patterns.ts +87 -29
- package/cli/selftune/evolution/rollback.ts +2 -2
- package/cli/selftune/grading/auto-grade.ts +200 -0
- package/cli/selftune/grading/grade-session.ts +448 -97
- package/cli/selftune/grading/results.ts +42 -0
- package/cli/selftune/hooks/prompt-log.ts +172 -2
- package/cli/selftune/hooks/session-stop.ts +123 -3
- package/cli/selftune/hooks/skill-eval.ts +119 -3
- package/cli/selftune/index.ts +395 -116
- package/cli/selftune/ingestors/claude-replay.ts +140 -114
- package/cli/selftune/ingestors/codex-rollout.ts +345 -46
- package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
- package/cli/selftune/ingestors/openclaw-ingest.ts +141 -8
- package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
- package/cli/selftune/init.ts +227 -14
- package/cli/selftune/last.ts +14 -5
- package/cli/selftune/localdb/db.ts +63 -0
- package/cli/selftune/localdb/materialize.ts +428 -0
- package/cli/selftune/localdb/queries.ts +376 -0
- package/cli/selftune/localdb/schema.ts +204 -0
- package/cli/selftune/monitoring/watch.ts +66 -15
- package/cli/selftune/normalization.ts +682 -0
- package/cli/selftune/observability.ts +19 -44
- package/cli/selftune/orchestrate.ts +1073 -0
- package/cli/selftune/quickstart.ts +203 -0
- package/cli/selftune/repair/skill-usage.ts +576 -0
- package/cli/selftune/schedule.ts +561 -0
- package/cli/selftune/status.ts +48 -26
- package/cli/selftune/sync.ts +627 -0
- package/cli/selftune/types.ts +148 -0
- package/cli/selftune/utils/canonical-log.ts +45 -0
- package/cli/selftune/utils/hooks.ts +41 -0
- package/cli/selftune/utils/html.ts +27 -0
- package/cli/selftune/utils/llm-call.ts +78 -20
- package/cli/selftune/utils/math.ts +10 -0
- package/cli/selftune/utils/query-filter.ts +139 -0
- package/cli/selftune/utils/skill-discovery.ts +340 -0
- package/cli/selftune/utils/skill-log.ts +68 -0
- package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
- package/cli/selftune/utils/transcript.ts +272 -26
- package/cli/selftune/workflows/discover.ts +254 -0
- package/cli/selftune/workflows/skill-md-writer.ts +288 -0
- package/cli/selftune/workflows/workflows.ts +188 -0
- package/package.json +21 -8
- package/packages/telemetry-contract/README.md +11 -0
- package/packages/telemetry-contract/fixtures/golden.json +87 -0
- package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
- package/packages/telemetry-contract/index.ts +1 -0
- package/packages/telemetry-contract/package.json +19 -0
- package/packages/telemetry-contract/src/index.ts +2 -0
- package/packages/telemetry-contract/src/types.ts +163 -0
- package/packages/telemetry-contract/src/validators.ts +109 -0
- package/skill/SKILL.md +84 -53
- package/skill/Workflows/AutoActivation.md +17 -16
- package/skill/Workflows/Badge.md +6 -0
- package/skill/Workflows/Baseline.md +46 -23
- package/skill/Workflows/Composability.md +12 -5
- package/skill/Workflows/Contribute.md +17 -14
- package/skill/Workflows/Cron.md +56 -79
- package/skill/Workflows/Dashboard.md +45 -34
- package/skill/Workflows/Doctor.md +30 -17
- package/skill/Workflows/Evals.md +64 -40
- package/skill/Workflows/EvolutionMemory.md +2 -0
- package/skill/Workflows/Evolve.md +102 -47
- package/skill/Workflows/EvolveBody.md +6 -6
- package/skill/Workflows/Grade.md +36 -31
- package/skill/Workflows/ImportSkillsBench.md +11 -5
- package/skill/Workflows/Ingest.md +43 -36
- package/skill/Workflows/Initialize.md +44 -30
- package/skill/Workflows/Orchestrate.md +139 -0
- package/skill/Workflows/Replay.md +39 -18
- package/skill/Workflows/Rollback.md +3 -3
- package/skill/Workflows/Schedule.md +61 -0
- package/skill/Workflows/Sync.md +88 -0
- package/skill/Workflows/UnitTest.md +34 -22
- package/skill/Workflows/Watch.md +14 -4
- package/skill/Workflows/Workflows.md +129 -0
- package/skill/assets/activation-rules-default.json +26 -0
- package/skill/assets/multi-skill-settings.json +63 -0
- package/skill/assets/single-skill-settings.json +57 -0
- package/skill/references/invocation-taxonomy.md +2 -2
- package/skill/references/logs.md +164 -2
- package/skill/references/setup-patterns.md +65 -0
- package/skill/references/version-history.md +40 -0
- package/skill/settings_snippet.json +1 -1
- package/templates/multi-skill-settings.json +7 -7
- package/templates/single-skill-settings.json +6 -6
- package/dashboard/index.html +0 -1680
|
@@ -25,8 +25,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";
|
|
28
|
-
import { QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "../constants.js";
|
|
29
|
-
import
|
|
28
|
+
import { CANONICAL_LOG, QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "../constants.js";
|
|
29
|
+
import {
|
|
30
|
+
appendCanonicalRecords,
|
|
31
|
+
buildCanonicalExecutionFact,
|
|
32
|
+
buildCanonicalPrompt,
|
|
33
|
+
buildCanonicalSession,
|
|
34
|
+
buildCanonicalSkillInvocation,
|
|
35
|
+
type CanonicalBaseInput,
|
|
36
|
+
deriveInvocationMode,
|
|
37
|
+
derivePromptId,
|
|
38
|
+
deriveSkillInvocationId,
|
|
39
|
+
} from "../normalization.js";
|
|
40
|
+
import type {
|
|
41
|
+
CanonicalRecord,
|
|
42
|
+
QueryLogRecord,
|
|
43
|
+
SessionTelemetryRecord,
|
|
44
|
+
SkillUsageRecord,
|
|
45
|
+
} from "../types.js";
|
|
30
46
|
import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
|
|
31
47
|
|
|
32
48
|
const XDG_DATA_HOME = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
|
|
@@ -48,6 +64,26 @@ const OPENCODE_SKILLS_DIRS = [
|
|
|
48
64
|
join(homedir(), ".config", "opencode", "skills"),
|
|
49
65
|
];
|
|
50
66
|
|
|
67
|
+
interface TriggeredSkillDetection {
|
|
68
|
+
skill_name: string;
|
|
69
|
+
has_skill_md_read: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function escapeRegExp(value: string): string {
|
|
73
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function containsWholeSkillMention(text: string, skillName: string): boolean {
|
|
77
|
+
const trimmedSkillName = skillName.trim();
|
|
78
|
+
if (!text || !trimmedSkillName) return false;
|
|
79
|
+
|
|
80
|
+
const pattern = new RegExp(
|
|
81
|
+
`(^|[^A-Za-z0-9_])${escapeRegExp(trimmedSkillName)}([^A-Za-z0-9_]|$)`,
|
|
82
|
+
"i",
|
|
83
|
+
);
|
|
84
|
+
return pattern.test(text);
|
|
85
|
+
}
|
|
86
|
+
|
|
51
87
|
/** Return skill names from OpenCode skill directories. */
|
|
52
88
|
export function findSkillNames(dirs: string[] = OPENCODE_SKILLS_DIRS): Set<string> {
|
|
53
89
|
const names = new Set<string>();
|
|
@@ -79,9 +115,12 @@ export interface ParsedSession {
|
|
|
79
115
|
total_tool_calls: number;
|
|
80
116
|
bash_commands: string[];
|
|
81
117
|
skills_triggered: string[];
|
|
118
|
+
skill_detections?: TriggeredSkillDetection[];
|
|
82
119
|
assistant_turns: number;
|
|
83
120
|
errors_encountered: number;
|
|
84
121
|
transcript_chars: number;
|
|
122
|
+
/** True when local session JSON is metadata-only (no embedded messages). */
|
|
123
|
+
is_metadata_only?: boolean;
|
|
85
124
|
}
|
|
86
125
|
|
|
87
126
|
/** Return a human-readable schema summary for --show-schema. */
|
|
@@ -202,10 +241,24 @@ export function readSessionsFromSqlite(
|
|
|
202
241
|
let firstUserQuery = "";
|
|
203
242
|
const toolCalls: Record<string, number> = {};
|
|
204
243
|
const bashCommands: string[] = [];
|
|
205
|
-
const
|
|
244
|
+
const skillDetections = new Map<string, TriggeredSkillDetection>();
|
|
206
245
|
let errors = 0;
|
|
207
246
|
let assistantTurns = 0;
|
|
208
247
|
|
|
248
|
+
const noteSkillDetection = (skillName: string, hasSkillMdRead: boolean): void => {
|
|
249
|
+
const normalizedSkillName = skillName.trim();
|
|
250
|
+
if (!normalizedSkillName) return;
|
|
251
|
+
const existing = skillDetections.get(normalizedSkillName);
|
|
252
|
+
if (existing) {
|
|
253
|
+
existing.has_skill_md_read = existing.has_skill_md_read || hasSkillMdRead;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
skillDetections.set(normalizedSkillName, {
|
|
257
|
+
skill_name: normalizedSkillName,
|
|
258
|
+
has_skill_md_read: hasSkillMdRead,
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
209
262
|
for (const msg of msgRows) {
|
|
210
263
|
const role = (msg.role as string) ?? "";
|
|
211
264
|
const blocks = normalizeContent(msg.content ?? "[]");
|
|
@@ -251,9 +304,7 @@ export function readSessionsFromSqlite(
|
|
|
251
304
|
const filePath = (inp.file_path as string) ?? (inp.path as string) ?? "";
|
|
252
305
|
if (basename(filePath).toUpperCase() === "SKILL.MD") {
|
|
253
306
|
const skillName = basename(join(filePath, ".."));
|
|
254
|
-
|
|
255
|
-
skillsTriggered.push(skillName);
|
|
256
|
-
}
|
|
307
|
+
noteSkillDetection(skillName, true);
|
|
257
308
|
}
|
|
258
309
|
}
|
|
259
310
|
}
|
|
@@ -271,8 +322,8 @@ export function readSessionsFromSqlite(
|
|
|
271
322
|
// Check text content for skill name mentions
|
|
272
323
|
const textContent = (block.text as string) ?? "";
|
|
273
324
|
for (const skillName of skillNames) {
|
|
274
|
-
if (textContent
|
|
275
|
-
|
|
325
|
+
if (containsWholeSkillMention(textContent, skillName)) {
|
|
326
|
+
noteSkillDetection(skillName, false);
|
|
276
327
|
}
|
|
277
328
|
}
|
|
278
329
|
}
|
|
@@ -299,7 +350,8 @@ export function readSessionsFromSqlite(
|
|
|
299
350
|
tool_calls: toolCalls,
|
|
300
351
|
total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
|
|
301
352
|
bash_commands: bashCommands,
|
|
302
|
-
skills_triggered:
|
|
353
|
+
skills_triggered: [...skillDetections.values()].map((entry) => entry.skill_name),
|
|
354
|
+
skill_detections: [...skillDetections.values()],
|
|
303
355
|
assistant_turns: assistantTurns,
|
|
304
356
|
errors_encountered: errors,
|
|
305
357
|
transcript_chars: 0,
|
|
@@ -349,13 +401,30 @@ export function readSessionsFromJsonFiles(
|
|
|
349
401
|
const timestamp = new Date(created * 1000).toISOString();
|
|
350
402
|
const messages = (data.messages as Array<Record<string, unknown>>) ?? [];
|
|
351
403
|
|
|
404
|
+
// Detect metadata-only session files (no message bodies)
|
|
405
|
+
const isMetadataOnly = messages.length === 0;
|
|
406
|
+
|
|
352
407
|
let firstUserQuery = "";
|
|
353
408
|
const toolCalls: Record<string, number> = {};
|
|
354
409
|
const bashCommands: string[] = [];
|
|
355
|
-
const
|
|
410
|
+
const skillDetections = new Map<string, TriggeredSkillDetection>();
|
|
356
411
|
let errors = 0;
|
|
357
412
|
let turns = 0;
|
|
358
413
|
|
|
414
|
+
const noteSkillDetection = (skillName: string, hasSkillMdRead: boolean): void => {
|
|
415
|
+
const normalizedSkillName = skillName.trim();
|
|
416
|
+
if (!normalizedSkillName) return;
|
|
417
|
+
const existing = skillDetections.get(normalizedSkillName);
|
|
418
|
+
if (existing) {
|
|
419
|
+
existing.has_skill_md_read = existing.has_skill_md_read || hasSkillMdRead;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
skillDetections.set(normalizedSkillName, {
|
|
423
|
+
skill_name: normalizedSkillName,
|
|
424
|
+
has_skill_md_read: hasSkillMdRead,
|
|
425
|
+
});
|
|
426
|
+
};
|
|
427
|
+
|
|
359
428
|
for (const msg of messages) {
|
|
360
429
|
const role = (msg.role as string) ?? "";
|
|
361
430
|
const blocks = normalizeContent(msg.content ?? []);
|
|
@@ -385,17 +454,15 @@ export function readSessionsFromJsonFiles(
|
|
|
385
454
|
const fp = (inp.file_path as string) ?? "";
|
|
386
455
|
if (basename(fp).toUpperCase() === "SKILL.MD") {
|
|
387
456
|
const sn = basename(join(fp, ".."));
|
|
388
|
-
|
|
389
|
-
skillsTriggered.push(sn);
|
|
390
|
-
}
|
|
457
|
+
noteSkillDetection(sn, true);
|
|
391
458
|
}
|
|
392
459
|
}
|
|
393
460
|
}
|
|
394
461
|
|
|
395
462
|
const text = (block.text as string) ?? "";
|
|
396
463
|
for (const skillName of skillNames) {
|
|
397
|
-
if (text
|
|
398
|
-
|
|
464
|
+
if (containsWholeSkillMention(text, skillName)) {
|
|
465
|
+
noteSkillDetection(skillName, false);
|
|
399
466
|
}
|
|
400
467
|
}
|
|
401
468
|
}
|
|
@@ -422,10 +489,12 @@ export function readSessionsFromJsonFiles(
|
|
|
422
489
|
tool_calls: toolCalls,
|
|
423
490
|
total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
|
|
424
491
|
bash_commands: bashCommands,
|
|
425
|
-
skills_triggered:
|
|
492
|
+
skills_triggered: [...skillDetections.values()].map((entry) => entry.skill_name),
|
|
493
|
+
skill_detections: [...skillDetections.values()],
|
|
426
494
|
assistant_turns: turns,
|
|
427
495
|
errors_encountered: errors,
|
|
428
496
|
transcript_chars: statSync(filePath).size,
|
|
497
|
+
is_metadata_only: isMetadataOnly,
|
|
429
498
|
});
|
|
430
499
|
}
|
|
431
500
|
|
|
@@ -439,6 +508,7 @@ export function writeSession(
|
|
|
439
508
|
queryLogPath: string = QUERY_LOG,
|
|
440
509
|
telemetryLogPath: string = TELEMETRY_LOG,
|
|
441
510
|
skillLogPath: string = SKILL_LOG,
|
|
511
|
+
canonicalLogPath: string = CANONICAL_LOG,
|
|
442
512
|
): void {
|
|
443
513
|
const { query: prompt, session_id: sessionId, skills_triggered: skills } = session;
|
|
444
514
|
|
|
@@ -460,7 +530,21 @@ export function writeSession(
|
|
|
460
530
|
appendJsonl(queryLogPath, queryRecord, "all_queries");
|
|
461
531
|
}
|
|
462
532
|
|
|
463
|
-
const
|
|
533
|
+
const telemetry: SessionTelemetryRecord = {
|
|
534
|
+
timestamp: session.timestamp,
|
|
535
|
+
session_id: session.session_id,
|
|
536
|
+
cwd: session.cwd,
|
|
537
|
+
transcript_path: session.transcript_path,
|
|
538
|
+
tool_calls: session.tool_calls,
|
|
539
|
+
total_tool_calls: session.total_tool_calls,
|
|
540
|
+
bash_commands: session.bash_commands,
|
|
541
|
+
skills_triggered: session.skills_triggered,
|
|
542
|
+
assistant_turns: session.assistant_turns,
|
|
543
|
+
errors_encountered: session.errors_encountered,
|
|
544
|
+
transcript_chars: session.transcript_chars,
|
|
545
|
+
last_user_query: session.last_user_query,
|
|
546
|
+
source: session.source,
|
|
547
|
+
};
|
|
464
548
|
appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
|
|
465
549
|
|
|
466
550
|
for (const skillName of skills) {
|
|
@@ -475,6 +559,98 @@ export function writeSession(
|
|
|
475
559
|
};
|
|
476
560
|
appendJsonl(skillLogPath, skillRecord, "skill_usage");
|
|
477
561
|
}
|
|
562
|
+
|
|
563
|
+
// --- Canonical normalization records (additive) ---
|
|
564
|
+
const canonicalRecords = buildCanonicalRecordsFromOpenCode(session);
|
|
565
|
+
appendCanonicalRecords(canonicalRecords, canonicalLogPath);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Build canonical records from a parsed OpenCode session. */
|
|
569
|
+
export function buildCanonicalRecordsFromOpenCode(session: ParsedSession): CanonicalRecord[] {
|
|
570
|
+
const records: CanonicalRecord[] = [];
|
|
571
|
+
const sourceKind = session.is_metadata_only ? ("replayed" as const) : ("replayed" as const);
|
|
572
|
+
const baseInput: CanonicalBaseInput = {
|
|
573
|
+
platform: "opencode",
|
|
574
|
+
capture_mode: "batch_ingest",
|
|
575
|
+
source_session_kind: sourceKind,
|
|
576
|
+
session_id: session.session_id,
|
|
577
|
+
raw_source_ref: {
|
|
578
|
+
path: session.transcript_path,
|
|
579
|
+
event_type: session.source,
|
|
580
|
+
metadata: session.is_metadata_only ? { metadata_only: true } : undefined,
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
records.push(
|
|
585
|
+
buildCanonicalSession({
|
|
586
|
+
...baseInput,
|
|
587
|
+
started_at: session.timestamp,
|
|
588
|
+
workspace_path: session.cwd || undefined,
|
|
589
|
+
}),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const promptEmitted = Boolean(
|
|
593
|
+
session.query && session.query.length >= 4 && !session.is_metadata_only,
|
|
594
|
+
);
|
|
595
|
+
const promptId = promptEmitted ? derivePromptId(session.session_id, 0) : undefined;
|
|
596
|
+
|
|
597
|
+
if (promptId) {
|
|
598
|
+
records.push(
|
|
599
|
+
buildCanonicalPrompt({
|
|
600
|
+
...baseInput,
|
|
601
|
+
prompt_id: promptId,
|
|
602
|
+
occurred_at: session.timestamp,
|
|
603
|
+
prompt_text: session.query,
|
|
604
|
+
prompt_index: 0,
|
|
605
|
+
}),
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const skillDetections =
|
|
610
|
+
session.skill_detections ??
|
|
611
|
+
session.skills_triggered.map((skillName) => ({
|
|
612
|
+
skill_name: skillName,
|
|
613
|
+
has_skill_md_read: false,
|
|
614
|
+
}));
|
|
615
|
+
|
|
616
|
+
for (let i = 0; i < skillDetections.length; i++) {
|
|
617
|
+
const detection = skillDetections[i];
|
|
618
|
+
const skillName = detection.skill_name;
|
|
619
|
+
const { invocation_mode, confidence } = deriveInvocationMode({
|
|
620
|
+
has_skill_md_read: detection.has_skill_md_read,
|
|
621
|
+
is_text_mention_only: !detection.has_skill_md_read,
|
|
622
|
+
});
|
|
623
|
+
records.push(
|
|
624
|
+
buildCanonicalSkillInvocation({
|
|
625
|
+
...baseInput,
|
|
626
|
+
skill_invocation_id: deriveSkillInvocationId(session.session_id, skillName, i),
|
|
627
|
+
occurred_at: session.timestamp,
|
|
628
|
+
matched_prompt_id: promptId,
|
|
629
|
+
skill_name: skillName,
|
|
630
|
+
skill_path: `(opencode:${skillName})`,
|
|
631
|
+
invocation_mode,
|
|
632
|
+
triggered: true,
|
|
633
|
+
confidence,
|
|
634
|
+
}),
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!session.is_metadata_only) {
|
|
639
|
+
records.push(
|
|
640
|
+
buildCanonicalExecutionFact({
|
|
641
|
+
...baseInput,
|
|
642
|
+
occurred_at: session.timestamp,
|
|
643
|
+
prompt_id: promptId,
|
|
644
|
+
tool_calls_json: session.tool_calls,
|
|
645
|
+
total_tool_calls: session.total_tool_calls,
|
|
646
|
+
bash_commands_redacted: session.bash_commands,
|
|
647
|
+
assistant_turns: session.assistant_turns,
|
|
648
|
+
errors_encountered: session.errors_encountered,
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return records;
|
|
478
654
|
}
|
|
479
655
|
|
|
480
656
|
// --- CLI main ---
|
package/cli/selftune/init.ts
CHANGED
|
@@ -8,16 +8,25 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* selftune init [--agent <type>] [--cli-path <path>] [--force]
|
|
11
|
+
* selftune init --enable-autonomy [--schedule-format cron|launchd|systemd]
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
copyFileSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
14
22
|
import { homedir } from "node:os";
|
|
15
23
|
import { dirname, join, resolve } from "node:path";
|
|
16
24
|
import { fileURLToPath } from "node:url";
|
|
17
25
|
import { parseArgs } from "node:util";
|
|
18
26
|
|
|
19
|
-
import { SELFTUNE_CONFIG_DIR, SELFTUNE_CONFIG_PATH } from "./constants.js";
|
|
27
|
+
import { CLAUDE_CODE_HOOK_KEYS, SELFTUNE_CONFIG_DIR, SELFTUNE_CONFIG_PATH } from "./constants.js";
|
|
20
28
|
import type { SelftuneConfig } from "./types.js";
|
|
29
|
+
import { hookKeyHasSelftuneEntry } from "./utils/hooks.js";
|
|
21
30
|
import { detectAgent } from "./utils/llm-call.js";
|
|
22
31
|
|
|
23
32
|
// ---------------------------------------------------------------------------
|
|
@@ -124,8 +133,6 @@ export function determineLlmMode(agentCli: string | null): {
|
|
|
124
133
|
// Hook detection (Claude Code only)
|
|
125
134
|
// ---------------------------------------------------------------------------
|
|
126
135
|
|
|
127
|
-
const REQUIRED_HOOK_KEYS = ["prompt-submit", "post-tool-use", "session-stop"] as const;
|
|
128
|
-
|
|
129
136
|
/**
|
|
130
137
|
* Check if the selftune hooks are configured in Claude Code settings.
|
|
131
138
|
*/
|
|
@@ -138,15 +145,10 @@ export function checkClaudeCodeHooks(settingsPath: string): boolean {
|
|
|
138
145
|
const hooks = settings?.hooks;
|
|
139
146
|
if (!hooks || typeof hooks !== "object") return false;
|
|
140
147
|
|
|
141
|
-
for (const key of
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const hasSelftune = entries.some(
|
|
146
|
-
(e: { command?: string }) =>
|
|
147
|
-
typeof e.command === "string" && e.command.includes("selftune"),
|
|
148
|
-
);
|
|
149
|
-
if (!hasSelftune) return false;
|
|
148
|
+
for (const key of CLAUDE_CODE_HOOK_KEYS) {
|
|
149
|
+
if (!hookKeyHasSelftuneEntry(hooks, key)) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
return true;
|
|
@@ -155,6 +157,155 @@ export function checkClaudeCodeHooks(settingsPath: string): boolean {
|
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Hook installation (Claude Code only)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/** Bundled settings snippet (ships with the npm package). */
|
|
165
|
+
const SETTINGS_SNIPPET_PATH = resolve(
|
|
166
|
+
dirname(import.meta.path),
|
|
167
|
+
"..",
|
|
168
|
+
"..",
|
|
169
|
+
"skill",
|
|
170
|
+
"settings_snippet.json",
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Install selftune hooks into ~/.claude/settings.json by merging entries
|
|
175
|
+
* from the bundled settings_snippet.json.
|
|
176
|
+
*
|
|
177
|
+
* - Creates settings.json if it does not exist
|
|
178
|
+
* - Creates the hooks section if it does not exist
|
|
179
|
+
* - Only adds hook entries for keys that don't already have a selftune entry
|
|
180
|
+
* - Never overwrites existing user hooks
|
|
181
|
+
*
|
|
182
|
+
* Returns the list of hook keys that were added.
|
|
183
|
+
*/
|
|
184
|
+
export function installClaudeCodeHooks(options?: {
|
|
185
|
+
settingsPath?: string;
|
|
186
|
+
snippetPath?: string;
|
|
187
|
+
cliPath?: string;
|
|
188
|
+
}): string[] {
|
|
189
|
+
const settingsPath = options?.settingsPath ?? join(homedir(), ".claude", "settings.json");
|
|
190
|
+
const snippetPath = options?.snippetPath ?? SETTINGS_SNIPPET_PATH;
|
|
191
|
+
|
|
192
|
+
// Read the snippet
|
|
193
|
+
if (!existsSync(snippetPath)) {
|
|
194
|
+
console.error(`[WARN] Hook snippet not found at ${snippetPath}, skipping hook installation`);
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let snippet: Record<string, unknown>;
|
|
199
|
+
try {
|
|
200
|
+
snippet = JSON.parse(readFileSync(snippetPath, "utf-8"));
|
|
201
|
+
} catch {
|
|
202
|
+
console.error(`[WARN] Failed to parse hook snippet at ${snippetPath}`);
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const snippetHooks = snippet.hooks as Record<string, unknown[]> | undefined;
|
|
207
|
+
if (!snippetHooks || typeof snippetHooks !== "object") {
|
|
208
|
+
console.error("[WARN] Hook snippet has no 'hooks' section");
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Read existing settings (or start with empty object)
|
|
213
|
+
let settings: Record<string, unknown> = {};
|
|
214
|
+
if (existsSync(settingsPath)) {
|
|
215
|
+
try {
|
|
216
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
217
|
+
} catch {
|
|
218
|
+
console.error(`[WARN] Failed to parse ${settingsPath}, starting with empty settings`);
|
|
219
|
+
settings = {};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Ensure hooks section exists
|
|
224
|
+
if (!settings.hooks || typeof settings.hooks !== "object") {
|
|
225
|
+
settings.hooks = {};
|
|
226
|
+
}
|
|
227
|
+
const existingHooks = settings.hooks as Record<string, unknown[]>;
|
|
228
|
+
|
|
229
|
+
// Resolve the CLI hooks directory for path substitution
|
|
230
|
+
const cliPath = options?.cliPath;
|
|
231
|
+
const hooksDir = cliPath ? `${dirname(cliPath)}/hooks` : null;
|
|
232
|
+
|
|
233
|
+
const addedKeys: string[] = [];
|
|
234
|
+
|
|
235
|
+
for (const key of Object.keys(snippetHooks)) {
|
|
236
|
+
// Skip if this key already has a selftune entry
|
|
237
|
+
if (hookKeyHasSelftuneEntry(existingHooks, key)) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Get the snippet entries for this key, replacing /PATH/TO/ with actual path
|
|
242
|
+
let entries = snippetHooks[key];
|
|
243
|
+
if (hooksDir) {
|
|
244
|
+
// Deep clone and substitute paths
|
|
245
|
+
const raw = JSON.stringify(entries).replace(/\/PATH\/TO\/cli\/selftune\/hooks/g, hooksDir);
|
|
246
|
+
entries = JSON.parse(raw);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Merge: append to existing array or create new one
|
|
250
|
+
if (Array.isArray(existingHooks[key])) {
|
|
251
|
+
existingHooks[key] = [...existingHooks[key], ...entries];
|
|
252
|
+
} else {
|
|
253
|
+
existingHooks[key] = entries;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
addedKeys.push(key);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (addedKeys.length > 0) {
|
|
260
|
+
// Ensure ~/.claude/ directory exists
|
|
261
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
262
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return addedKeys;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Agent file installation
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/** Bundled agent files directory (ships with the npm package). */
|
|
273
|
+
const BUNDLED_AGENTS_DIR = resolve(dirname(import.meta.path), "..", "..", ".claude", "agents");
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Copy bundled agent markdown files to ~/.claude/agents/.
|
|
277
|
+
* Returns a list of file names that were copied (skips files that already exist
|
|
278
|
+
* unless `force` is true).
|
|
279
|
+
*/
|
|
280
|
+
export function installAgentFiles(options?: { homeDir?: string; force?: boolean }): string[] {
|
|
281
|
+
const home = options?.homeDir ?? homedir();
|
|
282
|
+
const force = options?.force ?? false;
|
|
283
|
+
const targetDir = join(home, ".claude", "agents");
|
|
284
|
+
|
|
285
|
+
if (!existsSync(BUNDLED_AGENTS_DIR)) return [];
|
|
286
|
+
|
|
287
|
+
let sourceFiles: string[];
|
|
288
|
+
try {
|
|
289
|
+
sourceFiles = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
|
|
290
|
+
} catch {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (sourceFiles.length === 0) return [];
|
|
295
|
+
|
|
296
|
+
mkdirSync(targetDir, { recursive: true });
|
|
297
|
+
|
|
298
|
+
const copied: string[] = [];
|
|
299
|
+
for (const file of sourceFiles) {
|
|
300
|
+
const dest = join(targetDir, file);
|
|
301
|
+
if (!force && existsSync(dest)) continue;
|
|
302
|
+
copyFileSync(join(BUNDLED_AGENTS_DIR, file), dest);
|
|
303
|
+
copied.push(file);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return copied;
|
|
307
|
+
}
|
|
308
|
+
|
|
158
309
|
// ---------------------------------------------------------------------------
|
|
159
310
|
// Workspace type detection
|
|
160
311
|
// ---------------------------------------------------------------------------
|
|
@@ -346,6 +497,34 @@ export function runInit(opts: InitOptions): SelftuneConfig {
|
|
|
346
497
|
mkdirSync(configDir, { recursive: true });
|
|
347
498
|
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
348
499
|
|
|
500
|
+
// Install agent files to ~/.claude/agents/
|
|
501
|
+
const copiedAgents = installAgentFiles({ homeDir: home, force });
|
|
502
|
+
if (copiedAgents.length > 0) {
|
|
503
|
+
console.error(`[INFO] Installed agent files: ${copiedAgents.join(", ")}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Auto-install hooks into ~/.claude/settings.json (Claude Code only)
|
|
507
|
+
if (agentType === "claude_code") {
|
|
508
|
+
const addedHookKeys = installClaudeCodeHooks({
|
|
509
|
+
settingsPath,
|
|
510
|
+
cliPath,
|
|
511
|
+
});
|
|
512
|
+
if (addedHookKeys.length > 0) {
|
|
513
|
+
config.hooks_installed = true;
|
|
514
|
+
// Re-write config with updated hooks_installed flag
|
|
515
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
516
|
+
console.error(
|
|
517
|
+
`[INFO] Installed ${addedHookKeys.length} selftune hook(s) into ${settingsPath}: ${addedHookKeys.join(", ")}`,
|
|
518
|
+
);
|
|
519
|
+
} else if (!config.hooks_installed) {
|
|
520
|
+
// Re-check in case hooks were already present
|
|
521
|
+
config.hooks_installed = checkClaudeCodeHooks(settingsPath);
|
|
522
|
+
if (config.hooks_installed) {
|
|
523
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
349
528
|
return config;
|
|
350
529
|
}
|
|
351
530
|
|
|
@@ -359,6 +538,8 @@ export async function cliMain(): Promise<void> {
|
|
|
359
538
|
agent: { type: "string" },
|
|
360
539
|
"cli-path": { type: "string" },
|
|
361
540
|
force: { type: "boolean", default: false },
|
|
541
|
+
"enable-autonomy": { type: "boolean", default: false },
|
|
542
|
+
"schedule-format": { type: "string" },
|
|
362
543
|
},
|
|
363
544
|
strict: true,
|
|
364
545
|
});
|
|
@@ -366,9 +547,10 @@ export async function cliMain(): Promise<void> {
|
|
|
366
547
|
const configDir = SELFTUNE_CONFIG_DIR;
|
|
367
548
|
const configPath = SELFTUNE_CONFIG_PATH;
|
|
368
549
|
const force = values.force ?? false;
|
|
550
|
+
const enableAutonomy = values["enable-autonomy"] ?? false;
|
|
369
551
|
|
|
370
552
|
// Check for existing config without force
|
|
371
|
-
if (!force && existsSync(configPath)) {
|
|
553
|
+
if (!force && !enableAutonomy && existsSync(configPath)) {
|
|
372
554
|
try {
|
|
373
555
|
const raw = readFileSync(configPath, "utf-8");
|
|
374
556
|
const existing = JSON.parse(raw) as SelftuneConfig;
|
|
@@ -418,6 +600,37 @@ export async function cliMain(): Promise<void> {
|
|
|
418
600
|
total: doctorResult.summary.total,
|
|
419
601
|
}),
|
|
420
602
|
);
|
|
603
|
+
|
|
604
|
+
if (enableAutonomy) {
|
|
605
|
+
try {
|
|
606
|
+
const { installSchedule } = await import("./schedule.js");
|
|
607
|
+
const scheduleResult = installSchedule({
|
|
608
|
+
format: values["schedule-format"],
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
if (!scheduleResult.activated) {
|
|
612
|
+
console.error(
|
|
613
|
+
"Failed to activate the autonomous scheduler. Re-run with --schedule-format or use `selftune schedule --install --dry-run` to inspect the generated artifacts first.",
|
|
614
|
+
);
|
|
615
|
+
process.exit(1);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
console.log(
|
|
619
|
+
JSON.stringify({
|
|
620
|
+
level: "info",
|
|
621
|
+
code: "autonomy_enabled",
|
|
622
|
+
format: scheduleResult.format,
|
|
623
|
+
activated: scheduleResult.activated,
|
|
624
|
+
files: scheduleResult.artifacts.map((artifact) => artifact.path),
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
} catch (err) {
|
|
628
|
+
console.error(
|
|
629
|
+
`Failed to enable autonomy: ${err instanceof Error ? err.message : String(err)}`,
|
|
630
|
+
);
|
|
631
|
+
process.exit(1);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
421
634
|
}
|
|
422
635
|
|
|
423
636
|
// Guard: only run when invoked directly
|
package/cli/selftune/last.ts
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
* Lightweight, no LLM calls.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { QUERY_LOG,
|
|
7
|
+
import { QUERY_LOG, TELEMETRY_LOG } from "./constants.js";
|
|
8
8
|
import type { QueryLogRecord, SessionTelemetryRecord, SkillUsageRecord } from "./types.js";
|
|
9
9
|
import { readJsonl } from "./utils/jsonl.js";
|
|
10
|
+
import {
|
|
11
|
+
filterActionableQueryRecords,
|
|
12
|
+
filterActionableSkillUsageRecords,
|
|
13
|
+
} from "./utils/query-filter.js";
|
|
14
|
+
import { readEffectiveSkillUsageRecords } from "./utils/skill-log.js";
|
|
10
15
|
|
|
11
16
|
// ---------------------------------------------------------------------------
|
|
12
17
|
// Types
|
|
@@ -36,6 +41,8 @@ export function computeLastInsight(
|
|
|
36
41
|
queryRecords: QueryLogRecord[],
|
|
37
42
|
): LastSessionInsight | null {
|
|
38
43
|
if (telemetry.length === 0) return null;
|
|
44
|
+
const actionableSkillRecords = filterActionableSkillUsageRecords(skillRecords);
|
|
45
|
+
const actionableQueryRecords = filterActionableQueryRecords(queryRecords);
|
|
39
46
|
|
|
40
47
|
// Find most recent telemetry record
|
|
41
48
|
const sorted = [...telemetry].sort(
|
|
@@ -48,17 +55,19 @@ export function computeLastInsight(
|
|
|
48
55
|
const triggeredSkillQueries = new Set<string>();
|
|
49
56
|
const skillsTriggered = [
|
|
50
57
|
...new Set(
|
|
51
|
-
|
|
58
|
+
actionableSkillRecords
|
|
52
59
|
.filter((r) => r.session_id === sessionId && r.triggered)
|
|
53
60
|
.map((r) => {
|
|
54
|
-
|
|
61
|
+
if (typeof r.query === "string") {
|
|
62
|
+
triggeredSkillQueries.add(r.query.toLowerCase().trim());
|
|
63
|
+
}
|
|
55
64
|
return r.skill_name;
|
|
56
65
|
}),
|
|
57
66
|
),
|
|
58
67
|
];
|
|
59
68
|
|
|
60
69
|
// Unmatched queries: session queries whose text does NOT appear in any triggered skill record
|
|
61
|
-
const sessionQueries =
|
|
70
|
+
const sessionQueries = actionableQueryRecords.filter((r) => r.session_id === sessionId);
|
|
62
71
|
const unmatchedQueries = sessionQueries
|
|
63
72
|
.filter((q) => !triggeredSkillQueries.has(q.query.toLowerCase().trim()))
|
|
64
73
|
.map((q) => q.query);
|
|
@@ -124,7 +133,7 @@ export function formatInsight(insight: LastSessionInsight): string {
|
|
|
124
133
|
/** CLI main: reads logs, prints insight. */
|
|
125
134
|
export function cliMain(): void {
|
|
126
135
|
const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
|
|
127
|
-
const skillRecords =
|
|
136
|
+
const skillRecords = readEffectiveSkillUsageRecords();
|
|
128
137
|
const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
|
|
129
138
|
|
|
130
139
|
const insight = computeLastInsight(telemetry, skillRecords, queryRecords);
|