selftune 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/diagnosis-analyst.md +146 -0
- package/.claude/agents/evolution-reviewer.md +167 -0
- package/.claude/agents/integration-guide.md +200 -0
- package/.claude/agents/pattern-analyst.md +147 -0
- package/CHANGELOG.md +37 -0
- package/README.md +96 -256
- package/assets/BeforeAfter.gif +0 -0
- package/assets/FeedbackLoop.gif +0 -0
- package/assets/logo.svg +9 -0
- package/assets/skill-health-badge.svg +20 -0
- package/cli/selftune/activation-rules.ts +171 -0
- package/cli/selftune/badge/badge-data.ts +108 -0
- package/cli/selftune/badge/badge-svg.ts +212 -0
- package/cli/selftune/badge/badge.ts +103 -0
- package/cli/selftune/constants.ts +75 -1
- package/cli/selftune/contribute/bundle.ts +314 -0
- package/cli/selftune/contribute/contribute.ts +214 -0
- package/cli/selftune/contribute/sanitize.ts +162 -0
- package/cli/selftune/cron/setup.ts +266 -0
- package/cli/selftune/dashboard-server.ts +582 -0
- package/cli/selftune/dashboard.ts +25 -3
- package/cli/selftune/eval/baseline.ts +247 -0
- package/cli/selftune/eval/composability.ts +117 -0
- package/cli/selftune/eval/generate-unit-tests.ts +143 -0
- package/cli/selftune/eval/hooks-to-evals.ts +68 -2
- package/cli/selftune/eval/import-skillsbench.ts +221 -0
- package/cli/selftune/eval/synthetic-evals.ts +172 -0
- package/cli/selftune/eval/unit-test-cli.ts +152 -0
- package/cli/selftune/eval/unit-test.ts +196 -0
- package/cli/selftune/evolution/deploy-proposal.ts +142 -1
- package/cli/selftune/evolution/evolve-body.ts +492 -0
- package/cli/selftune/evolution/evolve.ts +466 -103
- package/cli/selftune/evolution/extract-patterns.ts +32 -1
- package/cli/selftune/evolution/pareto.ts +314 -0
- package/cli/selftune/evolution/propose-body.ts +171 -0
- package/cli/selftune/evolution/propose-description.ts +100 -2
- package/cli/selftune/evolution/propose-routing.ts +166 -0
- package/cli/selftune/evolution/refine-body.ts +141 -0
- package/cli/selftune/evolution/rollback.ts +19 -2
- package/cli/selftune/evolution/validate-body.ts +254 -0
- package/cli/selftune/evolution/validate-proposal.ts +257 -35
- package/cli/selftune/evolution/validate-routing.ts +177 -0
- package/cli/selftune/grading/grade-session.ts +138 -18
- package/cli/selftune/grading/pre-gates.ts +104 -0
- package/cli/selftune/hooks/auto-activate.ts +185 -0
- package/cli/selftune/hooks/evolution-guard.ts +165 -0
- package/cli/selftune/hooks/skill-change-guard.ts +112 -0
- package/cli/selftune/index.ts +88 -0
- package/cli/selftune/ingestors/claude-replay.ts +351 -0
- package/cli/selftune/ingestors/openclaw-ingest.ts +440 -0
- package/cli/selftune/init.ts +150 -3
- package/cli/selftune/memory/writer.ts +447 -0
- package/cli/selftune/monitoring/watch.ts +25 -2
- package/cli/selftune/status.ts +17 -13
- package/cli/selftune/types.ts +377 -5
- package/cli/selftune/utils/frontmatter.ts +217 -0
- package/cli/selftune/utils/llm-call.ts +29 -3
- package/cli/selftune/utils/transcript.ts +35 -0
- package/cli/selftune/utils/trigger-check.ts +89 -0
- package/cli/selftune/utils/tui.ts +156 -0
- package/dashboard/index.html +569 -8
- package/package.json +8 -4
- package/skill/SKILL.md +124 -8
- package/skill/Workflows/AutoActivation.md +144 -0
- package/skill/Workflows/Badge.md +118 -0
- package/skill/Workflows/Baseline.md +121 -0
- package/skill/Workflows/Composability.md +100 -0
- package/skill/Workflows/Contribute.md +91 -0
- package/skill/Workflows/Cron.md +155 -0
- package/skill/Workflows/Dashboard.md +203 -0
- package/skill/Workflows/Doctor.md +37 -1
- package/skill/Workflows/Evals.md +69 -1
- package/skill/Workflows/EvolutionMemory.md +152 -0
- package/skill/Workflows/Evolve.md +111 -6
- package/skill/Workflows/EvolveBody.md +159 -0
- package/skill/Workflows/ImportSkillsBench.md +111 -0
- package/skill/Workflows/Ingest.md +117 -3
- package/skill/Workflows/Initialize.md +57 -3
- package/skill/Workflows/Replay.md +70 -0
- package/skill/Workflows/Rollback.md +20 -1
- package/skill/Workflows/UnitTest.md +138 -0
- package/skill/Workflows/Watch.md +22 -0
- package/skill/settings_snippet.json +23 -0
- package/templates/activation-rules-default.json +27 -0
- package/templates/multi-skill-settings.json +64 -0
- package/templates/single-skill-settings.json +58 -0
|
@@ -0,0 +1,440 @@
|
|
|
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
|
+
OPENCLAW_AGENTS_DIR,
|
|
30
|
+
OPENCLAW_INGEST_MARKER,
|
|
31
|
+
QUERY_LOG,
|
|
32
|
+
SKILL_LOG,
|
|
33
|
+
TELEMETRY_LOG,
|
|
34
|
+
} from "../constants.js";
|
|
35
|
+
import type { QueryLogRecord, SkillUsageRecord } from "../types.js";
|
|
36
|
+
import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
|
|
37
|
+
|
|
38
|
+
export interface SessionFile {
|
|
39
|
+
agentId: string;
|
|
40
|
+
sessionId: string;
|
|
41
|
+
filePath: string;
|
|
42
|
+
timestamp: number; // epoch ms from file stat or header
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ParsedSession {
|
|
46
|
+
timestamp: string;
|
|
47
|
+
session_id: string;
|
|
48
|
+
source: string;
|
|
49
|
+
transcript_path: string;
|
|
50
|
+
cwd: string;
|
|
51
|
+
last_user_query: string;
|
|
52
|
+
query: string;
|
|
53
|
+
tool_calls: Record<string, number>;
|
|
54
|
+
total_tool_calls: number;
|
|
55
|
+
bash_commands: string[];
|
|
56
|
+
skills_triggered: string[];
|
|
57
|
+
assistant_turns: number;
|
|
58
|
+
errors_encountered: number;
|
|
59
|
+
transcript_chars: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Scan <agentsDir>/<agentId>/sessions/*.jsonl for OpenClaw session files.
|
|
64
|
+
* Reads line 1 of each file to get the session header with id and timestamp.
|
|
65
|
+
* If sinceTs (epoch ms) is provided, skips sessions older than that.
|
|
66
|
+
*/
|
|
67
|
+
export function findOpenClawSessions(agentsDir: string, sinceTs: number | null): SessionFile[] {
|
|
68
|
+
if (!existsSync(agentsDir)) return [];
|
|
69
|
+
|
|
70
|
+
const results: SessionFile[] = [];
|
|
71
|
+
let agentDirs: string[];
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
agentDirs = readdirSync(agentsDir);
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const agentId of agentDirs) {
|
|
80
|
+
const sessionsDir = join(agentsDir, agentId, "sessions");
|
|
81
|
+
if (!existsSync(sessionsDir)) continue;
|
|
82
|
+
|
|
83
|
+
let files: string[];
|
|
84
|
+
try {
|
|
85
|
+
files = readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
|
86
|
+
} catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const filePath = join(sessionsDir, file);
|
|
92
|
+
try {
|
|
93
|
+
const content = readFileSync(filePath, "utf-8");
|
|
94
|
+
const firstLine = content.split("\n")[0]?.trim();
|
|
95
|
+
if (!firstLine) continue;
|
|
96
|
+
|
|
97
|
+
const header = JSON.parse(firstLine);
|
|
98
|
+
if (header.type !== "session") continue;
|
|
99
|
+
|
|
100
|
+
const sessionId = header.id ?? basename(file, ".jsonl");
|
|
101
|
+
const headerTs = header.timestamp ? new Date(header.timestamp).getTime() : 0;
|
|
102
|
+
const fileTs = headerTs || statSync(filePath).mtimeMs;
|
|
103
|
+
|
|
104
|
+
if (sinceTs !== null && fileTs < sinceTs) continue;
|
|
105
|
+
|
|
106
|
+
results.push({
|
|
107
|
+
agentId,
|
|
108
|
+
sessionId,
|
|
109
|
+
filePath,
|
|
110
|
+
timestamp: fileTs,
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
// Skip files that can't be read or parsed
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse an OpenClaw session JSONL file into a ParsedSession.
|
|
123
|
+
*
|
|
124
|
+
* Line 1: session header with id, timestamp, cwd
|
|
125
|
+
* Lines 2+: messages with role user/assistant/toolResult
|
|
126
|
+
*/
|
|
127
|
+
export function parseOpenClawSession(filePath: string, skillNames: Set<string>): ParsedSession {
|
|
128
|
+
const empty: ParsedSession = {
|
|
129
|
+
timestamp: "",
|
|
130
|
+
session_id: "",
|
|
131
|
+
source: "openclaw",
|
|
132
|
+
transcript_path: filePath,
|
|
133
|
+
cwd: "",
|
|
134
|
+
last_user_query: "",
|
|
135
|
+
query: "",
|
|
136
|
+
tool_calls: {},
|
|
137
|
+
total_tool_calls: 0,
|
|
138
|
+
bash_commands: [],
|
|
139
|
+
skills_triggered: [],
|
|
140
|
+
assistant_turns: 0,
|
|
141
|
+
errors_encountered: 0,
|
|
142
|
+
transcript_chars: 0,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
let content: string;
|
|
146
|
+
try {
|
|
147
|
+
content = readFileSync(filePath, "utf-8");
|
|
148
|
+
} catch {
|
|
149
|
+
return empty;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
empty.transcript_chars = content.length;
|
|
153
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
154
|
+
|
|
155
|
+
if (lines.length === 0) return empty;
|
|
156
|
+
|
|
157
|
+
// Parse session header (line 1)
|
|
158
|
+
let header: Record<string, unknown>;
|
|
159
|
+
try {
|
|
160
|
+
header = JSON.parse(lines[0]);
|
|
161
|
+
} catch {
|
|
162
|
+
return empty;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (header.type !== "session") return empty;
|
|
166
|
+
|
|
167
|
+
const sessionId = (header.id as string) ?? "";
|
|
168
|
+
const timestamp = (header.timestamp as string) ?? "";
|
|
169
|
+
const cwd = (header.cwd as string) ?? "";
|
|
170
|
+
|
|
171
|
+
const toolCalls: Record<string, number> = {};
|
|
172
|
+
const bashCommands: string[] = [];
|
|
173
|
+
const skillsTriggered: string[] = [];
|
|
174
|
+
let firstUserQuery = "";
|
|
175
|
+
let lastUserQuery = "";
|
|
176
|
+
let assistantTurns = 0;
|
|
177
|
+
let errors = 0;
|
|
178
|
+
|
|
179
|
+
// Parse messages (lines 2+)
|
|
180
|
+
for (let i = 1; i < lines.length; i++) {
|
|
181
|
+
let msg: Record<string, unknown>;
|
|
182
|
+
try {
|
|
183
|
+
msg = JSON.parse(lines[i]);
|
|
184
|
+
} catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const role = (msg.role as string) ?? "";
|
|
189
|
+
const contentBlocks = normalizeContentBlocks(msg.content);
|
|
190
|
+
|
|
191
|
+
if (role === "user") {
|
|
192
|
+
// Extract text from user messages
|
|
193
|
+
for (const block of contentBlocks) {
|
|
194
|
+
if (block.type === "text") {
|
|
195
|
+
const text = ((block.text as string) ?? "").trim();
|
|
196
|
+
if (text) {
|
|
197
|
+
if (!firstUserQuery) firstUserQuery = text;
|
|
198
|
+
lastUserQuery = text;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} else if (role === "assistant") {
|
|
204
|
+
assistantTurns += 1;
|
|
205
|
+
|
|
206
|
+
for (const block of contentBlocks) {
|
|
207
|
+
const blockType = (block.type as string) ?? "";
|
|
208
|
+
|
|
209
|
+
// Handle toolCall and toolUse (alias)
|
|
210
|
+
if (blockType === "toolCall" || blockType === "toolUse") {
|
|
211
|
+
const toolName = (block.name as string) ?? "unknown";
|
|
212
|
+
toolCalls[toolName] = (toolCalls[toolName] ?? 0) + 1;
|
|
213
|
+
const inp = (block.input as Record<string, unknown>) ?? {};
|
|
214
|
+
|
|
215
|
+
// Extract bash commands
|
|
216
|
+
if (["Bash", "bash", "execute_bash"].includes(toolName)) {
|
|
217
|
+
const cmd = ((inp.command as string) ?? (inp.cmd as string) ?? "").trim();
|
|
218
|
+
if (cmd) bashCommands.push(cmd);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Skill detection: file reads of SKILL.md
|
|
222
|
+
if (["Read", "read_file"].includes(toolName)) {
|
|
223
|
+
const fp = (inp.file_path as string) ?? (inp.path as string) ?? "";
|
|
224
|
+
if (basename(fp).toUpperCase() === "SKILL.MD") {
|
|
225
|
+
const skillName = basename(join(fp, ".."));
|
|
226
|
+
if (!skillsTriggered.includes(skillName)) {
|
|
227
|
+
skillsTriggered.push(skillName);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check text content for skill name mentions
|
|
234
|
+
const textContent = (block.text as string) ?? "";
|
|
235
|
+
for (const skillName of skillNames) {
|
|
236
|
+
if (textContent.includes(skillName) && !skillsTriggered.includes(skillName)) {
|
|
237
|
+
skillsTriggered.push(skillName);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} else if (role === "toolResult") {
|
|
242
|
+
const blockHasError = contentBlocks.some(
|
|
243
|
+
(block) => block.isError === true || block.is_error === true,
|
|
244
|
+
);
|
|
245
|
+
if (msg.isError === true || blockHasError) {
|
|
246
|
+
errors += 1;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
timestamp,
|
|
253
|
+
session_id: sessionId,
|
|
254
|
+
source: "openclaw",
|
|
255
|
+
transcript_path: filePath,
|
|
256
|
+
cwd,
|
|
257
|
+
last_user_query: lastUserQuery || firstUserQuery,
|
|
258
|
+
query: firstUserQuery,
|
|
259
|
+
tool_calls: toolCalls,
|
|
260
|
+
total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
|
|
261
|
+
bash_commands: bashCommands,
|
|
262
|
+
skills_triggered: skillsTriggered,
|
|
263
|
+
assistant_turns: assistantTurns,
|
|
264
|
+
errors_encountered: errors,
|
|
265
|
+
transcript_chars: content.length,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Normalize message content into an array of content block objects. */
|
|
270
|
+
function normalizeContentBlocks(raw: unknown): Array<Record<string, unknown>> {
|
|
271
|
+
if (Array.isArray(raw)) {
|
|
272
|
+
return raw.filter((b): b is Record<string, unknown> => typeof b === "object" && b !== null);
|
|
273
|
+
}
|
|
274
|
+
if (typeof raw === "string") {
|
|
275
|
+
return [{ type: "text", text: raw }];
|
|
276
|
+
}
|
|
277
|
+
if (typeof raw === "object" && raw !== null) {
|
|
278
|
+
return [raw as Record<string, unknown>];
|
|
279
|
+
}
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const OPENCLAW_SKILL_DIRS = [
|
|
284
|
+
join(homedir(), ".openclaw", "skills"),
|
|
285
|
+
join(process.cwd(), ".agents", "skills"),
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Find OpenClaw skill names from skill directories.
|
|
290
|
+
* By default checks:
|
|
291
|
+
* <agentsDir>/../skills/ (managed skills)
|
|
292
|
+
* ~/.openclaw/skills/
|
|
293
|
+
* process.cwd()/.agents/skills/ (workspace skills)
|
|
294
|
+
*/
|
|
295
|
+
export function findOpenClawSkillNames(
|
|
296
|
+
agentsDir: string,
|
|
297
|
+
extraDirs: string[] = OPENCLAW_SKILL_DIRS,
|
|
298
|
+
): Set<string> {
|
|
299
|
+
const names = new Set<string>();
|
|
300
|
+
const skillDirs = [join(agentsDir, "..", "skills"), join(agentsDir, "skills"), ...extraDirs];
|
|
301
|
+
|
|
302
|
+
for (const dir of skillDirs) {
|
|
303
|
+
if (!existsSync(dir)) continue;
|
|
304
|
+
try {
|
|
305
|
+
for (const entry of readdirSync(dir)) {
|
|
306
|
+
const skillDir = join(dir, entry);
|
|
307
|
+
try {
|
|
308
|
+
if (statSync(skillDir).isDirectory() && existsSync(join(skillDir, "SKILL.md"))) {
|
|
309
|
+
names.add(entry);
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
// skip entries that can't be stat'd
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// skip dirs that can't be listed
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return names;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Write a parsed session to our shared logs. Same pattern as opencode-ingest. */
|
|
323
|
+
export function writeSession(
|
|
324
|
+
session: ParsedSession,
|
|
325
|
+
dryRun = false,
|
|
326
|
+
queryLogPath: string = QUERY_LOG,
|
|
327
|
+
telemetryLogPath: string = TELEMETRY_LOG,
|
|
328
|
+
skillLogPath: string = SKILL_LOG,
|
|
329
|
+
): void {
|
|
330
|
+
const { query: prompt, session_id: sessionId, skills_triggered: skills } = session;
|
|
331
|
+
|
|
332
|
+
if (dryRun) {
|
|
333
|
+
console.log(
|
|
334
|
+
` [DRY] session=${sessionId.slice(0, 12)}... turns=${session.assistant_turns} skills=${JSON.stringify(skills)}`,
|
|
335
|
+
);
|
|
336
|
+
if (prompt) console.log(` query: ${prompt.slice(0, 80)}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (prompt && prompt.length >= 4) {
|
|
341
|
+
const queryRecord: QueryLogRecord = {
|
|
342
|
+
timestamp: session.timestamp,
|
|
343
|
+
session_id: sessionId,
|
|
344
|
+
query: prompt,
|
|
345
|
+
source: session.source,
|
|
346
|
+
};
|
|
347
|
+
appendJsonl(queryLogPath, queryRecord, "all_queries");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const { query: _q, ...telemetry } = session;
|
|
351
|
+
appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
|
|
352
|
+
|
|
353
|
+
for (const skillName of skills) {
|
|
354
|
+
const skillRecord: SkillUsageRecord = {
|
|
355
|
+
timestamp: session.timestamp,
|
|
356
|
+
session_id: sessionId,
|
|
357
|
+
skill_name: skillName,
|
|
358
|
+
skill_path: `(openclaw:${skillName})`,
|
|
359
|
+
query: prompt,
|
|
360
|
+
triggered: true,
|
|
361
|
+
source: session.source,
|
|
362
|
+
};
|
|
363
|
+
appendJsonl(skillLogPath, skillRecord, "skill_usage");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// --- CLI main ---
|
|
368
|
+
export function cliMain(): void {
|
|
369
|
+
const { values } = parseArgs({
|
|
370
|
+
options: {
|
|
371
|
+
"agents-dir": { type: "string", default: OPENCLAW_AGENTS_DIR },
|
|
372
|
+
since: { type: "string" },
|
|
373
|
+
"dry-run": { type: "boolean", default: false },
|
|
374
|
+
force: { type: "boolean", default: false },
|
|
375
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
376
|
+
},
|
|
377
|
+
strict: true,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const agentsDir = values["agents-dir"] ?? OPENCLAW_AGENTS_DIR;
|
|
381
|
+
|
|
382
|
+
if (!existsSync(agentsDir)) {
|
|
383
|
+
console.log(`OpenClaw agents directory not found: ${agentsDir}`);
|
|
384
|
+
console.log("Is OpenClaw installed? Try --agents-dir to specify a custom location.");
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let sinceTs: number | null = null;
|
|
389
|
+
if (values.since) {
|
|
390
|
+
const parsed = new Date(`${values.since}T00:00:00Z`);
|
|
391
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
392
|
+
console.error(`[ERROR] Invalid --since date: "${values.since}". Use YYYY-MM-DD format.`);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
sinceTs = parsed.getTime();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const skillNames = findOpenClawSkillNames(agentsDir);
|
|
399
|
+
const alreadyIngested = values.force ? new Set<string>() : loadMarker(OPENCLAW_INGEST_MARKER);
|
|
400
|
+
const allSessions = findOpenClawSessions(agentsDir, sinceTs);
|
|
401
|
+
|
|
402
|
+
console.log(`Found ${allSessions.length} total sessions.`);
|
|
403
|
+
|
|
404
|
+
const pending = allSessions.filter((s) => !alreadyIngested.has(s.sessionId));
|
|
405
|
+
console.log(`${pending.length} not yet ingested.`);
|
|
406
|
+
|
|
407
|
+
const newIngested = new Set<string>();
|
|
408
|
+
let ingestedCount = 0;
|
|
409
|
+
|
|
410
|
+
for (const sf of pending) {
|
|
411
|
+
const session = parseOpenClawSession(sf.filePath, skillNames);
|
|
412
|
+
|
|
413
|
+
if (!session.session_id || !session.timestamp) {
|
|
414
|
+
console.log(
|
|
415
|
+
` [WARN] Skipping session ${sf.sessionId.slice(0, 12)}...: missing session_id or timestamp after parsing`,
|
|
416
|
+
);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (values.verbose || values["dry-run"]) {
|
|
421
|
+
console.log(
|
|
422
|
+
` ${values["dry-run"] ? "[DRY] " : ""}Ingesting: ${sf.sessionId.slice(0, 12)}...`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
writeSession(session, values["dry-run"]);
|
|
427
|
+
newIngested.add(sf.sessionId);
|
|
428
|
+
ingestedCount += 1;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!values["dry-run"]) {
|
|
432
|
+
saveMarker(OPENCLAW_INGEST_MARKER, new Set([...alreadyIngested, ...newIngested]));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.log(`\nDone. Ingested ${ingestedCount} sessions.`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (import.meta.main) {
|
|
439
|
+
cliMain();
|
|
440
|
+
}
|
package/cli/selftune/init.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* selftune init [--agent <type>] [--cli-path <path>] [--force]
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { dirname, join, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
@@ -37,6 +37,7 @@ const VALID_AGENT_TYPES: SelftuneConfig["agent_type"][] = [
|
|
|
37
37
|
"claude_code",
|
|
38
38
|
"codex",
|
|
39
39
|
"opencode",
|
|
40
|
+
"openclaw",
|
|
40
41
|
"unknown",
|
|
41
42
|
];
|
|
42
43
|
|
|
@@ -44,6 +45,7 @@ const AGENT_TYPE_CLI_MAP: Record<string, string> = {
|
|
|
44
45
|
claude_code: "claude",
|
|
45
46
|
codex: "codex",
|
|
46
47
|
opencode: "opencode",
|
|
48
|
+
openclaw: "openclaw",
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
function agentTypeToCli(agentType: string): string | null {
|
|
@@ -82,6 +84,12 @@ export function detectAgentType(
|
|
|
82
84
|
return "opencode";
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// OpenClaw: agents directory or binary
|
|
88
|
+
const openclawDir = join(home, ".openclaw", "agents");
|
|
89
|
+
if (existsSync(openclawDir) || Bun.which("openclaw")) {
|
|
90
|
+
return "openclaw";
|
|
91
|
+
}
|
|
92
|
+
|
|
85
93
|
return "unknown";
|
|
86
94
|
}
|
|
87
95
|
|
|
@@ -147,6 +155,125 @@ export function checkClaudeCodeHooks(settingsPath: string): boolean {
|
|
|
147
155
|
}
|
|
148
156
|
}
|
|
149
157
|
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Workspace type detection
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const IGNORE_DIRS = new Set(["node_modules", ".git", ".hg", "dist", "build", ".next", ".cache"]);
|
|
163
|
+
|
|
164
|
+
export interface WorkspaceInfo {
|
|
165
|
+
type: "single-skill" | "multi-skill" | "monorepo" | "unknown";
|
|
166
|
+
skillCount: number;
|
|
167
|
+
skillPaths: string[];
|
|
168
|
+
isMonorepo: boolean;
|
|
169
|
+
hasExistingHooks: boolean;
|
|
170
|
+
suggestedTemplate: "single-skill" | "multi-skill" | null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Recursively find SKILL.md files under a root directory,
|
|
175
|
+
* skipping ignored directories (node_modules, .git, etc.).
|
|
176
|
+
*/
|
|
177
|
+
function findSkillFiles(dir: string, maxDepth = 8, depth = 0): string[] {
|
|
178
|
+
if (depth > maxDepth) return [];
|
|
179
|
+
if (!existsSync(dir)) return [];
|
|
180
|
+
|
|
181
|
+
const results: string[] = [];
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
188
|
+
results.push(...findSkillFiles(join(dir, entry.name), maxDepth, depth + 1));
|
|
189
|
+
} else if (entry.name === "SKILL.md") {
|
|
190
|
+
results.push(join(dir, entry.name));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Permission errors, etc. — skip
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return results;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Detect whether the root directory is a monorepo by checking for
|
|
202
|
+
* package.json workspaces or pnpm-workspace.yaml.
|
|
203
|
+
*/
|
|
204
|
+
function detectMonorepo(rootDir: string): boolean {
|
|
205
|
+
// Check package.json workspaces field
|
|
206
|
+
const pkgPath = join(rootDir, "package.json");
|
|
207
|
+
if (existsSync(pkgPath)) {
|
|
208
|
+
try {
|
|
209
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
210
|
+
if (pkg.workspaces) return true;
|
|
211
|
+
} catch {
|
|
212
|
+
// invalid JSON — skip
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Check pnpm-workspace.yaml
|
|
217
|
+
if (existsSync(join(rootDir, "pnpm-workspace.yaml"))) return true;
|
|
218
|
+
|
|
219
|
+
// Check lerna.json
|
|
220
|
+
if (existsSync(join(rootDir, "lerna.json"))) return true;
|
|
221
|
+
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Detect whether the project has existing selftune hooks configured.
|
|
227
|
+
*/
|
|
228
|
+
function detectExistingHooks(rootDir: string): boolean {
|
|
229
|
+
const hooksDir = join(rootDir, "cli", "selftune", "hooks");
|
|
230
|
+
if (!existsSync(hooksDir)) return false;
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const entries = readdirSync(hooksDir);
|
|
234
|
+
return entries.some((e) => e.endsWith(".ts") || e.endsWith(".js"));
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Scan a project root and detect the workspace type, skill layout,
|
|
242
|
+
* and suggest an appropriate template.
|
|
243
|
+
*/
|
|
244
|
+
export function detectWorkspaceType(rootDir: string): WorkspaceInfo {
|
|
245
|
+
const skillPaths = findSkillFiles(rootDir);
|
|
246
|
+
const isMonorepo = detectMonorepo(rootDir);
|
|
247
|
+
const hasExistingHooks = detectExistingHooks(rootDir);
|
|
248
|
+
const skillCount = skillPaths.length;
|
|
249
|
+
|
|
250
|
+
let type: WorkspaceInfo["type"];
|
|
251
|
+
let suggestedTemplate: WorkspaceInfo["suggestedTemplate"];
|
|
252
|
+
|
|
253
|
+
if (isMonorepo) {
|
|
254
|
+
type = "monorepo";
|
|
255
|
+
suggestedTemplate = "multi-skill";
|
|
256
|
+
} else if (skillCount === 0) {
|
|
257
|
+
type = "unknown";
|
|
258
|
+
suggestedTemplate = null;
|
|
259
|
+
} else if (skillCount === 1) {
|
|
260
|
+
type = "single-skill";
|
|
261
|
+
suggestedTemplate = "single-skill";
|
|
262
|
+
} else {
|
|
263
|
+
type = "multi-skill";
|
|
264
|
+
suggestedTemplate = "multi-skill";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
type,
|
|
269
|
+
skillCount,
|
|
270
|
+
skillPaths,
|
|
271
|
+
isMonorepo,
|
|
272
|
+
hasExistingHooks,
|
|
273
|
+
suggestedTemplate,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
150
277
|
// ---------------------------------------------------------------------------
|
|
151
278
|
// Init options (for testability)
|
|
152
279
|
// ---------------------------------------------------------------------------
|
|
@@ -265,11 +392,31 @@ export async function cliMain(): Promise<void> {
|
|
|
265
392
|
|
|
266
393
|
console.log(JSON.stringify(config, null, 2));
|
|
267
394
|
|
|
395
|
+
// Detect workspace type and report
|
|
396
|
+
const workspace = detectWorkspaceType(process.cwd());
|
|
397
|
+
console.log(
|
|
398
|
+
JSON.stringify({
|
|
399
|
+
level: "info",
|
|
400
|
+
code: "workspace_detected",
|
|
401
|
+
type: workspace.type,
|
|
402
|
+
skills: workspace.skillCount,
|
|
403
|
+
monorepo: workspace.isMonorepo,
|
|
404
|
+
suggestedTemplate: workspace.suggestedTemplate
|
|
405
|
+
? `templates/${workspace.suggestedTemplate}-settings.json`
|
|
406
|
+
: null,
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
|
|
268
410
|
// Run doctor as post-check
|
|
269
411
|
const { doctor } = await import("./observability.js");
|
|
270
412
|
const doctorResult = doctor();
|
|
271
|
-
console.
|
|
272
|
-
|
|
413
|
+
console.log(
|
|
414
|
+
JSON.stringify({
|
|
415
|
+
level: "info",
|
|
416
|
+
code: "doctor_result",
|
|
417
|
+
pass: doctorResult.summary.pass,
|
|
418
|
+
total: doctorResult.summary.total,
|
|
419
|
+
}),
|
|
273
420
|
);
|
|
274
421
|
}
|
|
275
422
|
|