infernoflow 0.10.28 → 0.11.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/dist/bin/infernoflow.mjs +7 -0
- package/dist/lib/commands/agent.mjs +191 -0
- package/dist/lib/commands/synthesize.mjs +228 -0
- package/dist/lib/learning/observe.mjs +6 -1
- package/dist/lib/learning/patternDetector.mjs +298 -0
- package/dist/lib/learning/profile.mjs +69 -2
- package/dist/lib/learning/skillSynthesizer.mjs +173 -0
- package/dist/templates/cursor/inferno-mcp-server.mjs +69 -0
- package/package.json +1 -1
package/dist/bin/infernoflow.mjs
CHANGED
|
@@ -5,6 +5,9 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { bold, gray, cyan, red } from "../lib/ui/output.mjs";
|
|
6
6
|
|
|
7
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
// When installed globally: __dirname = .../infernoflow/dist/bin
|
|
9
|
+
// Root package.json lives two levels up at .../infernoflow/package.json
|
|
10
|
+
// npm always includes the root package.json, so this path is always reliable.
|
|
8
11
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf8"));
|
|
9
12
|
const VERSION = pkg.version || "0.0.0";
|
|
10
13
|
const COMMAND_DESCRIPTIONS = {
|
|
@@ -26,6 +29,8 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
26
29
|
implement: "Generate code-agent implementation prompt(s)",
|
|
27
30
|
context: "Generate AI-ready context for new sessions",
|
|
28
31
|
"generate-skills": "Generate personalised Cursor rules + skill files from your developer profile",
|
|
32
|
+
synthesize: "Auto-detect workflow patterns and synthesize reusable skills + agents",
|
|
33
|
+
agent: "Manage and run auto-synthesized agents (list | run | show | delete)",
|
|
29
34
|
};
|
|
30
35
|
|
|
31
36
|
const COMMAND_HANDLERS = {
|
|
@@ -48,6 +53,8 @@ const COMMAND_HANDLERS = {
|
|
|
48
53
|
context: async (args) => (await import("../lib/commands/context.mjs")).contextCommand(args),
|
|
49
54
|
"doc-gate": async (args) => (await import("../lib/commands/docGate.mjs")).docGateCommand(args),
|
|
50
55
|
"generate-skills": async (args) => (await import("../lib/commands/generateSkills.mjs")).generateSkillsCommand(args),
|
|
56
|
+
synthesize: async (args) => (await import("../lib/commands/synthesize.mjs")).synthesizeCommand(args),
|
|
57
|
+
agent: async (args) => (await import("../lib/commands/agent.mjs")).agentCommand(args),
|
|
51
58
|
};
|
|
52
59
|
|
|
53
60
|
function formatCommandsHelp() {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow agent
|
|
3
|
+
*
|
|
4
|
+
* Manage and run auto-synthesized agents.
|
|
5
|
+
*
|
|
6
|
+
* Sub-commands:
|
|
7
|
+
* infernoflow agent list — list all saved agents
|
|
8
|
+
* infernoflow agent run <name> — execute an agent's command sequence
|
|
9
|
+
* infernoflow agent show <name> — print agent definition
|
|
10
|
+
* infernoflow agent delete <name> — remove an agent
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { execSync } from "node:child_process";
|
|
16
|
+
import { header, ok, warn, info, done, bold, cyan, green, gray, red } from "../ui/output.mjs";
|
|
17
|
+
import { observeCommandStart } from "../learning/observe.mjs";
|
|
18
|
+
|
|
19
|
+
function findInfernoDir(cwd) {
|
|
20
|
+
const d = path.join(cwd, "inferno");
|
|
21
|
+
return fs.existsSync(d) ? d : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function agentsDir(infernoDir) {
|
|
25
|
+
return path.join(infernoDir, "agents");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadAgents(infernoDir) {
|
|
29
|
+
const dir = agentsDir(infernoDir);
|
|
30
|
+
if (!fs.existsSync(dir)) return [];
|
|
31
|
+
return fs.readdirSync(dir)
|
|
32
|
+
.filter(f => f.endsWith(".json"))
|
|
33
|
+
.map(f => {
|
|
34
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
|
|
35
|
+
catch { return null; }
|
|
36
|
+
})
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findAgent(infernoDir, name) {
|
|
41
|
+
const agents = loadAgents(infernoDir);
|
|
42
|
+
return agents.find(a => a.name === name || a.id === name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runStep(cmd, args, cwd) {
|
|
46
|
+
const fullCmd = `npx infernoflow ${cmd} ${args.join(" ")}`.trim();
|
|
47
|
+
try {
|
|
48
|
+
const out = execSync(fullCmd, {
|
|
49
|
+
cwd,
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
timeout: 60_000,
|
|
52
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
53
|
+
});
|
|
54
|
+
return { ok: true, output: out };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { ok: false, output: err.stdout || err.message };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Sub-commands ──────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function listAgents(infernoDir) {
|
|
63
|
+
const agents = loadAgents(infernoDir);
|
|
64
|
+
if (agents.length === 0) {
|
|
65
|
+
info("No agents saved yet.");
|
|
66
|
+
info(`Run ${cyan("infernoflow synthesize")} to auto-generate agents from your workflow patterns.`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
header("infernoflow agents");
|
|
71
|
+
console.log(`\n ${bold(String(agents.length))} saved agent${agents.length !== 1 ? "s" : ""}:\n`);
|
|
72
|
+
for (const agent of agents) {
|
|
73
|
+
const steps = (agent.steps || []).map(s => s.command).join(" → ");
|
|
74
|
+
const conf = agent.confidence ? gray(`${Math.round(agent.confidence * 100)}% confidence`) : "";
|
|
75
|
+
console.log(` ${green("▶")} ${bold(agent.name)} ${conf}`);
|
|
76
|
+
console.log(` ${gray(agent.description || steps)}`);
|
|
77
|
+
console.log(` Steps: ${cyan(steps)}`);
|
|
78
|
+
console.log();
|
|
79
|
+
}
|
|
80
|
+
console.log(` Run: ${cyan("infernoflow agent run <name>")}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function runAgent(infernoDir, cwd, name) {
|
|
84
|
+
const agent = findAgent(infernoDir, name);
|
|
85
|
+
if (!agent) {
|
|
86
|
+
warn(`Agent "${name}" not found.`);
|
|
87
|
+
warn(`Run ${cyan("infernoflow agent list")} to see available agents.`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
header(`infernoflow agent run: ${agent.name}`);
|
|
92
|
+
info(agent.description || agent.name);
|
|
93
|
+
console.log();
|
|
94
|
+
|
|
95
|
+
const steps = agent.steps || [];
|
|
96
|
+
let allOk = true;
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < steps.length; i++) {
|
|
99
|
+
const step = steps[i];
|
|
100
|
+
const cmd = typeof step === "string" ? step : step.command;
|
|
101
|
+
const args = typeof step === "string" ? [] : (step.args || []);
|
|
102
|
+
|
|
103
|
+
console.log(` ${bold(`[${i + 1}/${steps.length}]`)} ${cyan(`infernoflow ${cmd} ${args.join(" ")}`.trim())}`);
|
|
104
|
+
|
|
105
|
+
const result = runStep(cmd, args, cwd);
|
|
106
|
+
if (result.output) {
|
|
107
|
+
const lines = result.output.trim().split("\n");
|
|
108
|
+
for (const line of lines) console.log(` ${gray(line)}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
warn(`Step "${cmd}" exited with an error — stopping agent.`);
|
|
113
|
+
allOk = false;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
ok(`Step ${i + 1} complete`);
|
|
118
|
+
console.log();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (allOk) {
|
|
122
|
+
done(`Agent "${agent.name}" completed all ${steps.length} steps`);
|
|
123
|
+
} else {
|
|
124
|
+
console.log(`\n ${red("✘")} Agent stopped early. Fix the error above and re-run.`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function showAgent(infernoDir, name) {
|
|
130
|
+
const agent = findAgent(infernoDir, name);
|
|
131
|
+
if (!agent) {
|
|
132
|
+
warn(`Agent "${name}" not found.`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
console.log(JSON.stringify(agent, null, 2));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function deleteAgent(infernoDir, name) {
|
|
139
|
+
const agent = findAgent(infernoDir, name);
|
|
140
|
+
if (!agent) {
|
|
141
|
+
warn(`Agent "${name}" not found.`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
const filePath = path.join(agentsDir(infernoDir), `${agent.name}.json`);
|
|
145
|
+
fs.unlinkSync(filePath);
|
|
146
|
+
ok(`Deleted agent: ${agent.name}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export async function agentCommand(args) {
|
|
152
|
+
const cwd = process.cwd();
|
|
153
|
+
const infernoDir = findInfernoDir(cwd);
|
|
154
|
+
|
|
155
|
+
if (!infernoDir) {
|
|
156
|
+
warn("inferno/ not found — run infernoflow init first");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
observeCommandStart(infernoDir, "agent");
|
|
161
|
+
|
|
162
|
+
const sub = args[0];
|
|
163
|
+
const name = args[1];
|
|
164
|
+
|
|
165
|
+
switch (sub) {
|
|
166
|
+
case "list":
|
|
167
|
+
case undefined:
|
|
168
|
+
listAgents(infernoDir);
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case "run":
|
|
172
|
+
if (!name) { warn("Usage: infernoflow agent run <name>"); process.exit(1); }
|
|
173
|
+
await runAgent(infernoDir, cwd, name);
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
case "show":
|
|
177
|
+
if (!name) { warn("Usage: infernoflow agent show <name>"); process.exit(1); }
|
|
178
|
+
showAgent(infernoDir, name);
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
case "delete":
|
|
182
|
+
case "remove":
|
|
183
|
+
if (!name) { warn("Usage: infernoflow agent delete <name>"); process.exit(1); }
|
|
184
|
+
deleteAgent(infernoDir, name);
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
default:
|
|
188
|
+
// Treat unknown arg as agent name to run (convenience shortcut)
|
|
189
|
+
await runAgent(infernoDir, cwd, sub);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow synthesize
|
|
3
|
+
*
|
|
4
|
+
* Analyzes your observed development sessions and auto-proposes reusable
|
|
5
|
+
* skills and agents based on repeated patterns.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* infernoflow synthesize — interactive review of candidates
|
|
9
|
+
* infernoflow synthesize --auto — auto-approve high-confidence (≥0.8)
|
|
10
|
+
* infernoflow synthesize --json — print candidates as JSON, exit
|
|
11
|
+
* infernoflow synthesize --threshold 2 — surface patterns seen 2+ times
|
|
12
|
+
* infernoflow synthesize --watch — re-run every 60s, surface new candidates
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import * as readline from "node:readline";
|
|
18
|
+
import { header, ok, warn, info, done, bold, cyan, yellow, green, gray } from "../ui/output.mjs";
|
|
19
|
+
import { readProfile, writeProfile } from "../learning/profile.mjs";
|
|
20
|
+
import { detectPatterns, mergeCandidates, pendingCandidates } from "../learning/patternDetector.mjs";
|
|
21
|
+
import { synthesizeCandidate } from "../learning/skillSynthesizer.mjs";
|
|
22
|
+
import { observeCommandStart } from "../learning/observe.mjs";
|
|
23
|
+
|
|
24
|
+
function findInfernoDir(cwd) {
|
|
25
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
26
|
+
if (!fs.existsSync(infernoDir)) return null;
|
|
27
|
+
return infernoDir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ask(rl, question) {
|
|
31
|
+
return new Promise(resolve => rl.question(question, a => resolve(a.trim().toLowerCase())));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderCandidate(c, index) {
|
|
35
|
+
const type = c.type === "agent" ? cyan("agent") : green("skill");
|
|
36
|
+
const conf = Math.round(c.confidence * 100);
|
|
37
|
+
const confStr = conf >= 80 ? green(`${conf}%`) : conf >= 60 ? yellow(`${conf}%`) : gray(`${conf}%`);
|
|
38
|
+
const freq = gray(`seen ${c.frequency}×`);
|
|
39
|
+
|
|
40
|
+
console.log(`\n ${bold(`[${index + 1}]`)} ${type} ${bold(c.name)} ${confStr} confidence ${freq}`);
|
|
41
|
+
console.log(` Pattern: ${cyan(c.trigger)}`);
|
|
42
|
+
if (c.steps) console.log(` Steps: ${c.steps.join(" → ")}`);
|
|
43
|
+
if (c.examples?.length) {
|
|
44
|
+
console.log(` Examples: ${c.examples.slice(0, 2).map(e => `"${e}"`).join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function reviewInteractive(cwd, infernoDir, profile, candidates) {
|
|
49
|
+
if (candidates.length === 0) {
|
|
50
|
+
info("No new candidates to review.");
|
|
51
|
+
return { approved: 0, rejected: 0 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
55
|
+
let approved = 0, rejected = 0;
|
|
56
|
+
|
|
57
|
+
console.log(`\n ${bold("Candidates found:")} ${candidates.length}\n`);
|
|
58
|
+
console.log(gray(" For each: [y] approve [n] reject [s] skip [q] quit\n"));
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
61
|
+
const c = candidates[i];
|
|
62
|
+
renderCandidate(c, i);
|
|
63
|
+
|
|
64
|
+
const ans = await ask(rl, `\n Approve? [y/n/s/q]: `);
|
|
65
|
+
|
|
66
|
+
if (ans === "q") break;
|
|
67
|
+
if (ans === "s") continue;
|
|
68
|
+
|
|
69
|
+
const all = [...(profile.agentCandidates || []), ...(profile.skillCandidates || [])];
|
|
70
|
+
const entry = all.find(x => x.id === c.id);
|
|
71
|
+
if (!entry) continue;
|
|
72
|
+
|
|
73
|
+
if (ans === "y") {
|
|
74
|
+
entry.status = "approved";
|
|
75
|
+
try {
|
|
76
|
+
const { filePaths } = synthesizeCandidate(cwd, infernoDir, c);
|
|
77
|
+
const approvedList = c.type === "agent" ? "approvedAgents" : "approvedSkills";
|
|
78
|
+
if (!profile[approvedList]) profile[approvedList] = [];
|
|
79
|
+
profile[approvedList].push({
|
|
80
|
+
id: c.id,
|
|
81
|
+
name: c.name,
|
|
82
|
+
filePaths,
|
|
83
|
+
approvedAt: new Date().toISOString(),
|
|
84
|
+
});
|
|
85
|
+
ok(` ${c.type === "agent" ? "Agent" : "Skill"} created:`);
|
|
86
|
+
for (const fp of filePaths) console.log(` ${green("→")} ${fp}`);
|
|
87
|
+
approved++;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
warn(` Could not write files: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
entry.status = "rejected";
|
|
93
|
+
rejected++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
rl.close();
|
|
98
|
+
return { approved, rejected };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function autoApprove(cwd, infernoDir, profile, candidates, threshold = 0.8) {
|
|
102
|
+
let approved = 0;
|
|
103
|
+
for (const c of candidates) {
|
|
104
|
+
if (c.confidence < threshold) continue;
|
|
105
|
+
|
|
106
|
+
const all = [...(profile.agentCandidates || []), ...(profile.skillCandidates || [])];
|
|
107
|
+
const entry = all.find(x => x.id === c.id);
|
|
108
|
+
if (!entry) continue;
|
|
109
|
+
|
|
110
|
+
entry.status = "approved";
|
|
111
|
+
try {
|
|
112
|
+
const { filePaths } = synthesizeCandidate(cwd, infernoDir, c);
|
|
113
|
+
const approvedList = c.type === "agent" ? "approvedAgents" : "approvedSkills";
|
|
114
|
+
if (!profile[approvedList]) profile[approvedList] = [];
|
|
115
|
+
profile[approvedList].push({
|
|
116
|
+
id: c.id,
|
|
117
|
+
name: c.name,
|
|
118
|
+
filePaths,
|
|
119
|
+
approvedAt: new Date().toISOString(),
|
|
120
|
+
});
|
|
121
|
+
ok(`Auto-approved ${c.type}: ${bold(c.name)} (${Math.round(c.confidence * 100)}% confidence)`);
|
|
122
|
+
for (const fp of filePaths) console.log(` ${green("→")} ${fp}`);
|
|
123
|
+
approved++;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
warn(`Could not write ${c.name}: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return approved;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function synthesizeCommand(args) {
|
|
132
|
+
const cwd = process.cwd();
|
|
133
|
+
const isAuto = args.includes("--auto");
|
|
134
|
+
const isJson = args.includes("--json");
|
|
135
|
+
const isWatch = args.includes("--watch");
|
|
136
|
+
const threshold = (() => {
|
|
137
|
+
const idx = args.indexOf("--threshold");
|
|
138
|
+
return idx !== -1 ? Number(args[idx + 1]) || 3 : 3;
|
|
139
|
+
})();
|
|
140
|
+
|
|
141
|
+
const infernoDir = findInfernoDir(cwd);
|
|
142
|
+
if (!infernoDir) {
|
|
143
|
+
if (isJson) {
|
|
144
|
+
process.stdout.write(JSON.stringify({ ok: false, error: "inferno/ not found" }) + "\n");
|
|
145
|
+
} else {
|
|
146
|
+
warn("inferno/ not found — run infernoflow init first");
|
|
147
|
+
}
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
observeCommandStart(infernoDir, "synthesize");
|
|
152
|
+
|
|
153
|
+
async function runOnce() {
|
|
154
|
+
const profile = readProfile(infernoDir);
|
|
155
|
+
|
|
156
|
+
// ── Detect patterns ──────────────────────────────────────────────────────
|
|
157
|
+
const { agentCandidates, skillCandidates } = detectPatterns(profile, { minFreq: threshold });
|
|
158
|
+
mergeCandidates(profile, { agentCandidates, skillCandidates });
|
|
159
|
+
|
|
160
|
+
const pending = pendingCandidates(profile);
|
|
161
|
+
|
|
162
|
+
// ── JSON mode ────────────────────────────────────────────────────────────
|
|
163
|
+
if (isJson) {
|
|
164
|
+
writeProfile(infernoDir, profile);
|
|
165
|
+
process.stdout.write(JSON.stringify({
|
|
166
|
+
ok: true,
|
|
167
|
+
pendingCount: pending.length,
|
|
168
|
+
candidates: pending,
|
|
169
|
+
sessions: profile.recentSessions?.length || 0,
|
|
170
|
+
}, null, 2) + "\n");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
header("infernoflow synthesize");
|
|
175
|
+
info(`Sessions analyzed: ${bold(String(profile.recentSessions?.length || 0))}`);
|
|
176
|
+
info(`Minimum frequency: ${bold(String(threshold))}×`);
|
|
177
|
+
|
|
178
|
+
if (pending.length === 0) {
|
|
179
|
+
console.log();
|
|
180
|
+
if ((profile.recentSessions?.length || 0) < threshold) {
|
|
181
|
+
warn(`Not enough session data yet — need at least ${threshold} sessions with similar commands.`);
|
|
182
|
+
warn(`Keep using infernoflow and run synthesize again soon.`);
|
|
183
|
+
} else {
|
|
184
|
+
ok("No new patterns detected — your workflow is already well captured.");
|
|
185
|
+
}
|
|
186
|
+
writeProfile(infernoDir, profile);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
info(`New patterns detected: ${bold(String(pending.length))}`);
|
|
191
|
+
|
|
192
|
+
// ── Auto mode ────────────────────────────────────────────────────────────
|
|
193
|
+
let approved;
|
|
194
|
+
if (isAuto) {
|
|
195
|
+
info(`Auto-approving candidates with ≥80% confidence...`);
|
|
196
|
+
approved = autoApprove(cwd, infernoDir, profile, pending);
|
|
197
|
+
} else {
|
|
198
|
+
const result = await reviewInteractive(cwd, infernoDir, profile, pending);
|
|
199
|
+
approved = result.approved;
|
|
200
|
+
if (result.rejected > 0) info(`Rejected: ${result.rejected}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
writeProfile(infernoDir, profile);
|
|
204
|
+
|
|
205
|
+
if (approved > 0) {
|
|
206
|
+
console.log();
|
|
207
|
+
done(`${approved} skill${approved !== 1 ? "s/agents" : "/agent"} synthesized`);
|
|
208
|
+
console.log(`\n ${bold("What was created:")}`);
|
|
209
|
+
for (const item of [...(profile.approvedSkills || []), ...(profile.approvedAgents || [])].slice(-approved)) {
|
|
210
|
+
const type = profile.approvedAgents?.find(a => a.id === item.id) ? "agent" : "skill";
|
|
211
|
+
console.log(` ${green("✔")} ${type}: ${bold(item.name)}`);
|
|
212
|
+
for (const fp of item.filePaths || []) console.log(` ${gray(fp)}`);
|
|
213
|
+
}
|
|
214
|
+
console.log(`\n ${bold("Next:")} Run ${cyan("infernoflow agent list")} to see your agents`);
|
|
215
|
+
console.log(` or ${cyan("infernoflow agent run <name>")} to execute one`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (isWatch) {
|
|
220
|
+
info("Watching for new patterns — press Ctrl+C to stop");
|
|
221
|
+
const INTERVAL = 60_000;
|
|
222
|
+
await runOnce();
|
|
223
|
+
setInterval(runOnce, INTERVAL);
|
|
224
|
+
await new Promise(() => {}); // keep alive
|
|
225
|
+
} else {
|
|
226
|
+
await runOnce();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
readProfile,
|
|
16
16
|
writeProfile,
|
|
17
17
|
recordCommandUse,
|
|
18
|
+
recordSessionCommand,
|
|
18
19
|
detectNamingStyle,
|
|
19
20
|
detectPreferredVerbs,
|
|
20
21
|
recordCapabilityCluster,
|
|
@@ -27,8 +28,9 @@ const SESSION_GAP_MS = 30 * 60 * 1000; // 30 minutes = new session
|
|
|
27
28
|
*
|
|
28
29
|
* @param {string} infernoDir — path to the inferno/ directory
|
|
29
30
|
* @param {string} command — the CLI command name (e.g. "suggest", "run", "check")
|
|
31
|
+
* @param {object} [extras] — optional: { task: string } for suggest/implement/run
|
|
30
32
|
*/
|
|
31
|
-
export function observeCommandStart(infernoDir, command) {
|
|
33
|
+
export function observeCommandStart(infernoDir, command, extras = {}) {
|
|
32
34
|
try {
|
|
33
35
|
const profile = readProfile(infernoDir);
|
|
34
36
|
|
|
@@ -43,6 +45,9 @@ export function observeCommandStart(infernoDir, command) {
|
|
|
43
45
|
}
|
|
44
46
|
profile._lastCommandTs = now;
|
|
45
47
|
|
|
48
|
+
// Sprint 5: record rich session event
|
|
49
|
+
recordSessionCommand(profile, command, extras);
|
|
50
|
+
|
|
46
51
|
writeProfile(infernoDir, profile);
|
|
47
52
|
} catch {
|
|
48
53
|
// Silent — never break the real command
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/learning/patternDetector.mjs
|
|
3
|
+
*
|
|
4
|
+
* Analyzes recentSessions from developer-profile.json and surfaces two kinds
|
|
5
|
+
* of candidates:
|
|
6
|
+
*
|
|
7
|
+
* 1. AGENT candidates — command sequences repeated 3+ times in the same order
|
|
8
|
+
* e.g. ["suggest","implement","check","changelog"] → "release-feature" agent
|
|
9
|
+
*
|
|
10
|
+
* 2. SKILL candidates — task descriptions that share a template pattern
|
|
11
|
+
* e.g. "add X filter", "add Y filter", "add Z filter"
|
|
12
|
+
* → skill: "When adding filters, follow the FilterBy* naming convention"
|
|
13
|
+
*
|
|
14
|
+
* Returns: { agentCandidates: Candidate[], skillCandidates: Candidate[] }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── Sequence analysis ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract all command sequences from sessions.
|
|
21
|
+
* Returns an array of string[] — one per session.
|
|
22
|
+
*/
|
|
23
|
+
function extractSequences(sessions) {
|
|
24
|
+
return (sessions || [])
|
|
25
|
+
.map(s => (s.commands || []).map(c => c.cmd))
|
|
26
|
+
.filter(seq => seq.length >= 2);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Find subsequences that repeat across sessions.
|
|
31
|
+
* A subsequence is a contiguous run of 2+ commands.
|
|
32
|
+
* Returns Map<sequenceKey, { seq, count, sessions[] }>
|
|
33
|
+
*/
|
|
34
|
+
function findRepeatedSubsequences(sequences, minLen = 2, minFreq = 2) {
|
|
35
|
+
const counts = new Map();
|
|
36
|
+
|
|
37
|
+
for (let si = 0; si < sequences.length; si++) {
|
|
38
|
+
const seq = sequences[si];
|
|
39
|
+
const seen = new Set(); // deduplicate within one session
|
|
40
|
+
for (let start = 0; start < seq.length; start++) {
|
|
41
|
+
for (let end = start + minLen; end <= seq.length; end++) {
|
|
42
|
+
const sub = seq.slice(start, end);
|
|
43
|
+
const key = sub.join("→");
|
|
44
|
+
if (seen.has(key)) continue;
|
|
45
|
+
seen.add(key);
|
|
46
|
+
if (!counts.has(key)) counts.set(key, { seq: sub, count: 0, sessionIndexes: [] });
|
|
47
|
+
const entry = counts.get(key);
|
|
48
|
+
entry.count++;
|
|
49
|
+
entry.sessionIndexes.push(si);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Filter by minimum frequency and return sorted by count desc
|
|
55
|
+
return Array.from(counts.values())
|
|
56
|
+
.filter(e => e.count >= minFreq)
|
|
57
|
+
.sort((a, b) => b.count - a.count || b.seq.length - a.seq.length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Remove subsequences that are strict subsets of a longer one with equal count.
|
|
62
|
+
* e.g. if "suggest→implement→check" appears 4x and "suggest→implement" appears 4x,
|
|
63
|
+
* drop the shorter one.
|
|
64
|
+
*/
|
|
65
|
+
function deduplicateSubsequences(entries) {
|
|
66
|
+
return entries.filter(e => {
|
|
67
|
+
const eKey = e.seq.join("→");
|
|
68
|
+
// Keep if no longer sequence contains this one with same or higher count
|
|
69
|
+
return !entries.some(other => {
|
|
70
|
+
if (other === e) return false;
|
|
71
|
+
const otherKey = other.seq.join("→");
|
|
72
|
+
return other.seq.length > e.seq.length &&
|
|
73
|
+
other.count >= e.count &&
|
|
74
|
+
otherKey.includes(eKey);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Task template analysis ────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract all task strings from sessions.
|
|
83
|
+
*/
|
|
84
|
+
function extractTasks(sessions) {
|
|
85
|
+
const tasks = [];
|
|
86
|
+
for (const session of sessions || []) {
|
|
87
|
+
for (const cmd of session.commands || []) {
|
|
88
|
+
if (cmd.task && typeof cmd.task === "string") {
|
|
89
|
+
tasks.push({ task: cmd.task, cmd: cmd.cmd, sessionId: session.id });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return tasks;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Tokenize a task string into [verb, ...rest].
|
|
98
|
+
* "add due date filter" → ["add", "due date filter"]
|
|
99
|
+
*/
|
|
100
|
+
function tokenize(task) {
|
|
101
|
+
const words = task.toLowerCase().trim().split(/\s+/);
|
|
102
|
+
return words;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find the longest common prefix between two word arrays.
|
|
107
|
+
*/
|
|
108
|
+
function commonPrefixLen(a, b) {
|
|
109
|
+
let i = 0;
|
|
110
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
111
|
+
return i;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Group tasks by their verb (first word) and detect template patterns.
|
|
116
|
+
* Returns Map<verb, { verb, tasks, template, frequency }>
|
|
117
|
+
*/
|
|
118
|
+
function detectTaskTemplates(taskEntries, minFreq = 2) {
|
|
119
|
+
// Group by first word (verb)
|
|
120
|
+
const byVerb = new Map();
|
|
121
|
+
for (const { task, cmd } of taskEntries) {
|
|
122
|
+
const words = tokenize(task);
|
|
123
|
+
if (words.length < 2) continue;
|
|
124
|
+
const verb = words[0];
|
|
125
|
+
if (!byVerb.has(verb)) byVerb.set(verb, []);
|
|
126
|
+
byVerb.get(verb).push({ words, original: task, cmd });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const templates = [];
|
|
130
|
+
for (const [verb, entries] of byVerb) {
|
|
131
|
+
if (entries.length < minFreq) continue;
|
|
132
|
+
|
|
133
|
+
// Find common prefix across all tasks with this verb
|
|
134
|
+
let prefix = entries[0].words;
|
|
135
|
+
for (const e of entries.slice(1)) {
|
|
136
|
+
const len = commonPrefixLen(prefix, e.words);
|
|
137
|
+
prefix = prefix.slice(0, Math.max(1, len));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const prefixStr = prefix.join(" ");
|
|
141
|
+
const templateStr = prefixStr + (prefix.length < entries[0].words.length ? " {feature}" : "");
|
|
142
|
+
const commands = [...new Set(entries.map(e => e.cmd))];
|
|
143
|
+
|
|
144
|
+
templates.push({
|
|
145
|
+
verb,
|
|
146
|
+
template: templateStr,
|
|
147
|
+
examples: entries.slice(0, 3).map(e => e.original),
|
|
148
|
+
frequency: entries.length,
|
|
149
|
+
commands,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return templates.sort((a, b) => b.frequency - a.frequency);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Candidate builders ────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
function agentName(seq) {
|
|
159
|
+
// Generate a readable name from the sequence
|
|
160
|
+
const known = {
|
|
161
|
+
"suggest→implement→check": "feature-workflow",
|
|
162
|
+
"suggest→check": "quick-suggest",
|
|
163
|
+
"check→changelog→publish": "release-workflow",
|
|
164
|
+
"suggest→implement→check→changelog": "full-feature-cycle",
|
|
165
|
+
"diff→changelog": "changelog-from-diff",
|
|
166
|
+
"context→implement": "context-first-implement",
|
|
167
|
+
"check→diff": "health-check",
|
|
168
|
+
};
|
|
169
|
+
const key = seq.join("→");
|
|
170
|
+
if (known[key]) return known[key];
|
|
171
|
+
return seq.join("-");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function agentDescription(seq) {
|
|
175
|
+
const labels = {
|
|
176
|
+
suggest: "generate contract update",
|
|
177
|
+
implement: "generate implementation prompt",
|
|
178
|
+
check: "validate contract health",
|
|
179
|
+
changelog: "draft changelog entry",
|
|
180
|
+
diff: "show capability diff",
|
|
181
|
+
context: "refresh AI context",
|
|
182
|
+
run: "run full task flow",
|
|
183
|
+
publish: "publish new version",
|
|
184
|
+
setup: "set up project",
|
|
185
|
+
status: "show status",
|
|
186
|
+
};
|
|
187
|
+
return seq.map(cmd => labels[cmd] || cmd).join(" → ");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function skillName(template) {
|
|
191
|
+
return template.replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").toLowerCase().slice(0, 40);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function confidence(frequency, totalSessions) {
|
|
195
|
+
// Sigmoid-like: saturates at 1.0 around 8+ occurrences
|
|
196
|
+
const raw = Math.min(frequency / Math.max(totalSessions * 0.4, 4), 1);
|
|
197
|
+
return Math.round(raw * 100) / 100;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Analyze sessions and return skill + agent candidates.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} profile — the full developer profile
|
|
206
|
+
* @param {object} options
|
|
207
|
+
* @param {number} [options.minFreq=3] — minimum repetitions to surface
|
|
208
|
+
* @param {number} [options.minSeqLen=2] — minimum sequence length for agents
|
|
209
|
+
* @returns {{ agentCandidates: Candidate[], skillCandidates: Candidate[] }}
|
|
210
|
+
*/
|
|
211
|
+
export function detectPatterns(profile, { minFreq = 3, minSeqLen = 2 } = {}) {
|
|
212
|
+
const sessions = profile.recentSessions || [];
|
|
213
|
+
const totalSessions = Math.max(sessions.length, 1);
|
|
214
|
+
|
|
215
|
+
// ── Agent candidates (command sequences) ─────────────────────────────────
|
|
216
|
+
const sequences = extractSequences(sessions);
|
|
217
|
+
const repeated = findRepeatedSubsequences(sequences, minSeqLen, minFreq);
|
|
218
|
+
const deduped = deduplicateSubsequences(repeated);
|
|
219
|
+
|
|
220
|
+
const existingAgentIds = new Set([
|
|
221
|
+
...(profile.agentCandidates || []).map(c => c.id),
|
|
222
|
+
...(profile.approvedAgents || []).map(c => c.id),
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
const agentCandidates = deduped.map(entry => {
|
|
226
|
+
const id = `agent_${entry.seq.join("_")}`;
|
|
227
|
+
if (existingAgentIds.has(id)) return null;
|
|
228
|
+
return {
|
|
229
|
+
id,
|
|
230
|
+
type: "agent",
|
|
231
|
+
name: agentName(entry.seq),
|
|
232
|
+
description: agentDescription(entry.seq),
|
|
233
|
+
trigger: `infernoflow agent run ${agentName(entry.seq)}`,
|
|
234
|
+
steps: entry.seq,
|
|
235
|
+
frequency: entry.count,
|
|
236
|
+
confidence: confidence(entry.count, totalSessions),
|
|
237
|
+
status: "pending",
|
|
238
|
+
detectedAt: new Date().toISOString(),
|
|
239
|
+
};
|
|
240
|
+
}).filter(Boolean);
|
|
241
|
+
|
|
242
|
+
// ── Skill candidates (task templates) ────────────────────────────────────
|
|
243
|
+
const taskEntries = extractTasks(sessions);
|
|
244
|
+
const templates = detectTaskTemplates(taskEntries, minFreq);
|
|
245
|
+
|
|
246
|
+
const existingSkillIds = new Set([
|
|
247
|
+
...(profile.skillCandidates || []).map(c => c.id),
|
|
248
|
+
...(profile.approvedSkills || []).map(c => c.id),
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
const skillCandidates = templates.map(t => {
|
|
252
|
+
const id = `skill_${skillName(t.template)}`;
|
|
253
|
+
if (existingSkillIds.has(id)) return null;
|
|
254
|
+
return {
|
|
255
|
+
id,
|
|
256
|
+
type: "skill",
|
|
257
|
+
name: skillName(t.template),
|
|
258
|
+
description: `Auto-skill: "${t.template}" pattern`,
|
|
259
|
+
trigger: t.template,
|
|
260
|
+
steps: t.commands,
|
|
261
|
+
examples: t.examples,
|
|
262
|
+
frequency: t.frequency,
|
|
263
|
+
confidence: confidence(t.frequency, totalSessions),
|
|
264
|
+
status: "pending",
|
|
265
|
+
detectedAt: new Date().toISOString(),
|
|
266
|
+
};
|
|
267
|
+
}).filter(Boolean);
|
|
268
|
+
|
|
269
|
+
return { agentCandidates, skillCandidates };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Merge newly detected candidates into the profile without duplicating
|
|
274
|
+
* already-known (pending/approved/rejected) entries.
|
|
275
|
+
*/
|
|
276
|
+
export function mergeCandidates(profile, { agentCandidates, skillCandidates }) {
|
|
277
|
+
const existingAgentIds = new Set((profile.agentCandidates || []).map(c => c.id));
|
|
278
|
+
const existingSkillIds = new Set((profile.skillCandidates || []).map(c => c.id));
|
|
279
|
+
|
|
280
|
+
for (const c of agentCandidates) {
|
|
281
|
+
if (!existingAgentIds.has(c.id)) {
|
|
282
|
+
profile.agentCandidates = [...(profile.agentCandidates || []), c];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
for (const c of skillCandidates) {
|
|
286
|
+
if (!existingSkillIds.has(c.id)) {
|
|
287
|
+
profile.skillCandidates = [...(profile.skillCandidates || []), c];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return profile;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Return all pending candidates (not yet approved or rejected). */
|
|
294
|
+
export function pendingCandidates(profile) {
|
|
295
|
+
const agents = (profile.agentCandidates || []).filter(c => c.status === "pending");
|
|
296
|
+
const skills = (profile.skillCandidates || []).filter(c => c.status === "pending");
|
|
297
|
+
return [...agents, ...skills].sort((a, b) => b.confidence - a.confidence);
|
|
298
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Schema:
|
|
11
11
|
* {
|
|
12
|
-
* schemaVersion:
|
|
12
|
+
* schemaVersion: 2,
|
|
13
13
|
* createdAt: ISO string,
|
|
14
14
|
* updatedAt: ISO string,
|
|
15
15
|
* sessionCount: number,
|
|
@@ -35,13 +35,41 @@
|
|
|
35
35
|
* framework: string,
|
|
36
36
|
* projectType: string,
|
|
37
37
|
* },
|
|
38
|
+
*
|
|
39
|
+
* // Sprint 5: Session sequence tracking for auto-skill synthesis
|
|
40
|
+
* recentSessions: SessionRecord[], // last 100 sessions
|
|
41
|
+
* skillCandidates: Candidate[], // pending skill proposals
|
|
42
|
+
* agentCandidates: Candidate[], // pending agent proposals
|
|
43
|
+
* approvedSkills: ApprovedItem[], // approved + written to disk
|
|
44
|
+
* approvedAgents: ApprovedItem[], // approved + written to disk
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* SessionRecord = {
|
|
48
|
+
* id: string,
|
|
49
|
+
* startedAt: ISO string,
|
|
50
|
+
* commands: Array<{ cmd, task?, caps?, ts }>,
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* Candidate = {
|
|
54
|
+
* id: string,
|
|
55
|
+
* type: "skill" | "agent",
|
|
56
|
+
* name: string,
|
|
57
|
+
* trigger: string, // e.g. "add {feature} filter"
|
|
58
|
+
* steps: string[], // e.g. ["suggest", "implement", "check"]
|
|
59
|
+
* frequency: number,
|
|
60
|
+
* confidence: number, // 0–1
|
|
61
|
+
* status: "pending" | "approved" | "rejected",
|
|
62
|
+
* detectedAt: ISO string,
|
|
38
63
|
* }
|
|
64
|
+
*
|
|
65
|
+
* ApprovedItem = { id, name, filePath, approvedAt }
|
|
39
66
|
*/
|
|
40
67
|
|
|
41
68
|
import * as fs from "node:fs";
|
|
42
69
|
import * as path from "node:path";
|
|
43
70
|
|
|
44
|
-
export const PROFILE_SCHEMA_VERSION =
|
|
71
|
+
export const PROFILE_SCHEMA_VERSION = 2;
|
|
72
|
+
export const MAX_RECENT_SESSIONS = 100;
|
|
45
73
|
|
|
46
74
|
export function profilePath(infernoDir) {
|
|
47
75
|
return path.join(infernoDir, "developer-profile.json");
|
|
@@ -67,7 +95,46 @@ export function blankProfile() {
|
|
|
67
95
|
framework: "unknown",
|
|
68
96
|
projectType: "unknown",
|
|
69
97
|
},
|
|
98
|
+
// Sprint 5 fields
|
|
99
|
+
recentSessions: [],
|
|
100
|
+
skillCandidates: [],
|
|
101
|
+
agentCandidates: [],
|
|
102
|
+
approvedSkills: [],
|
|
103
|
+
approvedAgents: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Session helpers ───────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** Open or continue the current session in the profile. */
|
|
110
|
+
export function openSession(profile) {
|
|
111
|
+
const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const last = profile.recentSessions?.[profile.recentSessions.length - 1];
|
|
114
|
+
|
|
115
|
+
if (last && now - new Date(last.startedAt).getTime() < SESSION_GAP_MS) {
|
|
116
|
+
return last; // continue existing session
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Start new session
|
|
120
|
+
const session = {
|
|
121
|
+
id: `s${profile.sessionCount + 1}_${now}`,
|
|
122
|
+
startedAt: new Date(now).toISOString(),
|
|
123
|
+
commands: [],
|
|
70
124
|
};
|
|
125
|
+
if (!profile.recentSessions) profile.recentSessions = [];
|
|
126
|
+
profile.recentSessions.push(session);
|
|
127
|
+
// Keep only last MAX_RECENT_SESSIONS
|
|
128
|
+
if (profile.recentSessions.length > MAX_RECENT_SESSIONS) {
|
|
129
|
+
profile.recentSessions = profile.recentSessions.slice(-MAX_RECENT_SESSIONS);
|
|
130
|
+
}
|
|
131
|
+
return session;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Append a command event to the current session. */
|
|
135
|
+
export function recordSessionCommand(profile, cmd, extras = {}) {
|
|
136
|
+
const session = openSession(profile);
|
|
137
|
+
session.commands.push({ cmd, ts: Date.now(), ...extras });
|
|
71
138
|
}
|
|
72
139
|
|
|
73
140
|
/** Read the profile, returning a blank one if it doesn't exist yet. */
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/learning/skillSynthesizer.mjs
|
|
3
|
+
*
|
|
4
|
+
* Converts approved candidates into actual files on disk:
|
|
5
|
+
*
|
|
6
|
+
* AGENTS → inferno/agents/<name>.json
|
|
7
|
+
* (runnable via `infernoflow agent run <name>`)
|
|
8
|
+
*
|
|
9
|
+
* SKILLS → inferno/skills/<name>.md
|
|
10
|
+
* + appends to .cursor/rules/infernoflow-auto.mdc
|
|
11
|
+
* + appends to CLAUDE.md (if it exists)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
// ── Agent file writer ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Write an agent definition to inferno/agents/<name>.json.
|
|
21
|
+
* Returns the file path written.
|
|
22
|
+
*/
|
|
23
|
+
export function writeAgentFile(infernoDir, candidate) {
|
|
24
|
+
const agentsDir = path.join(infernoDir, "agents");
|
|
25
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
const agentDef = {
|
|
28
|
+
id: candidate.id,
|
|
29
|
+
name: candidate.name,
|
|
30
|
+
description: candidate.description,
|
|
31
|
+
steps: candidate.steps.map(cmd => ({ command: cmd, args: [] })),
|
|
32
|
+
createdAt: new Date().toISOString(),
|
|
33
|
+
source: "auto-synthesized",
|
|
34
|
+
frequency: candidate.frequency,
|
|
35
|
+
confidence: candidate.confidence,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const filePath = path.join(agentsDir, `${candidate.name}.json`);
|
|
39
|
+
fs.writeFileSync(filePath, JSON.stringify(agentDef, null, 2) + "\n", "utf8");
|
|
40
|
+
return filePath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Skill file writers ────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Write a skill markdown file to inferno/skills/<name>.md.
|
|
47
|
+
*/
|
|
48
|
+
export function writeSkillFile(infernoDir, candidate) {
|
|
49
|
+
const skillsDir = path.join(infernoDir, "skills");
|
|
50
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
const examples = (candidate.examples || [])
|
|
53
|
+
.map(e => ` - "${e}"`)
|
|
54
|
+
.join("\n");
|
|
55
|
+
|
|
56
|
+
const steps = (candidate.steps || [])
|
|
57
|
+
.map((s, i) => `${i + 1}. Run \`infernoflow ${s}\``)
|
|
58
|
+
.join("\n");
|
|
59
|
+
|
|
60
|
+
const content = `# Skill: ${candidate.name}
|
|
61
|
+
|
|
62
|
+
> Auto-synthesized from ${candidate.frequency} observed sessions (confidence: ${Math.round(candidate.confidence * 100)}%)
|
|
63
|
+
|
|
64
|
+
## Trigger pattern
|
|
65
|
+
|
|
66
|
+
\`${candidate.trigger}\`
|
|
67
|
+
|
|
68
|
+
## Example tasks this applies to
|
|
69
|
+
|
|
70
|
+
${examples || " (none recorded)"}
|
|
71
|
+
|
|
72
|
+
## Recommended workflow
|
|
73
|
+
|
|
74
|
+
When working on a task matching the pattern above:
|
|
75
|
+
|
|
76
|
+
${steps}
|
|
77
|
+
|
|
78
|
+
## Notes
|
|
79
|
+
|
|
80
|
+
- This skill was automatically generated by \`infernoflow synthesize\`
|
|
81
|
+
- Review and edit as needed — it is yours to own
|
|
82
|
+
- Re-run \`infernoflow synthesize\` to update as your patterns evolve
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
const filePath = path.join(skillsDir, `${candidate.name}.md`);
|
|
86
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
87
|
+
return filePath;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Append a skill rule to .cursor/rules/infernoflow-auto.mdc.
|
|
92
|
+
* Creates the file if it doesn't exist.
|
|
93
|
+
*/
|
|
94
|
+
export function appendToCursorRules(cwd, candidate) {
|
|
95
|
+
const rulesDir = path.join(cwd, ".cursor", "rules");
|
|
96
|
+
fs.mkdirSync(rulesDir, { recursive: true });
|
|
97
|
+
|
|
98
|
+
const filePath = path.join(rulesDir, "infernoflow-auto.mdc");
|
|
99
|
+
|
|
100
|
+
const header = fs.existsSync(filePath) ? "" :
|
|
101
|
+
`---
|
|
102
|
+
description: Auto-generated infernoflow workflow rules
|
|
103
|
+
globs: ["**/*"]
|
|
104
|
+
alwaysApply: true
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
# infernoflow Auto-Generated Workflow Rules
|
|
108
|
+
|
|
109
|
+
These rules are synthesized from your observed development patterns.
|
|
110
|
+
Do not edit manually — re-run \`infernoflow synthesize\` to regenerate.
|
|
111
|
+
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const rule = `
|
|
115
|
+
## ${candidate.name}
|
|
116
|
+
|
|
117
|
+
Pattern: \`${candidate.trigger}\`
|
|
118
|
+
|
|
119
|
+
When working on tasks matching this pattern:
|
|
120
|
+
${(candidate.steps || []).map((s, i) => `${i + 1}. Run \`infernoflow ${s}\``).join("\n")}
|
|
121
|
+
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
fs.appendFileSync(filePath, header + rule, "utf8");
|
|
125
|
+
return filePath;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Append a skill tip to CLAUDE.md if it exists.
|
|
130
|
+
*/
|
|
131
|
+
export function appendToClaudeMd(cwd, candidate) {
|
|
132
|
+
const claudeMdPath = path.join(cwd, "CLAUDE.md");
|
|
133
|
+
if (!fs.existsSync(claudeMdPath)) return null;
|
|
134
|
+
|
|
135
|
+
const existing = fs.readFileSync(claudeMdPath, "utf8");
|
|
136
|
+
const marker = "## infernoflow Auto-Skills";
|
|
137
|
+
|
|
138
|
+
const tip = `\n- **${candidate.name}**: For tasks like \`${candidate.trigger}\`, run: ${(candidate.steps || []).map(s => `\`infernoflow ${s}\``).join(" → ")}`;
|
|
139
|
+
|
|
140
|
+
if (!existing.includes(marker)) {
|
|
141
|
+
fs.appendFileSync(claudeMdPath, `\n\n${marker}\n${tip}\n`, "utf8");
|
|
142
|
+
} else {
|
|
143
|
+
// Append after the marker section
|
|
144
|
+
const updated = existing.replace(marker, marker + tip);
|
|
145
|
+
fs.writeFileSync(claudeMdPath, updated, "utf8");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return claudeMdPath;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Main synthesize function ──────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Write all files for an approved candidate.
|
|
155
|
+
* Returns { filePaths: string[] } — list of files written.
|
|
156
|
+
*/
|
|
157
|
+
export function synthesizeCandidate(cwd, infernoDir, candidate) {
|
|
158
|
+
const filePaths = [];
|
|
159
|
+
|
|
160
|
+
if (candidate.type === "agent") {
|
|
161
|
+
filePaths.push(writeAgentFile(infernoDir, candidate));
|
|
162
|
+
} else {
|
|
163
|
+
// skill
|
|
164
|
+
filePaths.push(writeSkillFile(infernoDir, candidate));
|
|
165
|
+
try { filePaths.push(appendToCursorRules(cwd, candidate)); } catch {}
|
|
166
|
+
try {
|
|
167
|
+
const p = appendToClaudeMd(cwd, candidate);
|
|
168
|
+
if (p) filePaths.push(p);
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { filePaths };
|
|
173
|
+
}
|
|
@@ -22,6 +22,9 @@ const TOOLS = [
|
|
|
22
22
|
{ name: "infernoflow_implement", description: "Generate a structured code implementation prompt for a task. Uses the contract and stack context to produce step-by-step coding instructions for the agent.", inputSchema: { type: "object", properties: { task: { type: "string", description: "What to implement" }, mode: { type: "string", enum: ["cursor", "generic", "both"], description: "Prompt style (default: both)" } }, required: ["task"] } },
|
|
23
23
|
{ name: "infernoflow_scan_ui", description: "Scan components and styles for UI changes vs the stored contract. Returns new/changed components, design token changes, and suggested contract updates.", inputSchema: { type: "object", properties: {} } },
|
|
24
24
|
{ name: "infernoflow_review", description: "Pre-merge capability drift check. Compares all changed files in the current branch against the capability contract and reports drift risk before you merge.", inputSchema: { type: "object", properties: { branch: { type: "string", description: "Branch to compare against (default: main)" } } } },
|
|
25
|
+
{ name: "infernoflow_synthesize", description: "Detect repeated workflow patterns in this developer's sessions and surface skill/agent candidates. Call this proactively to help the developer automate their recurring workflows.", inputSchema: { type: "object", properties: { threshold: { type: "number", description: "Minimum times a pattern must repeat to surface it (default: 2)" }, autoApprove: { type: "boolean", description: "Auto-approve high-confidence (≥80%) candidates without asking" } } } },
|
|
26
|
+
{ name: "infernoflow_agent_run", description: "Execute a saved infernoflow agent (a named sequence of commands). Use infernoflow_synthesize first to see available agents.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Agent name to run" } }, required: ["name"] } },
|
|
27
|
+
{ name: "infernoflow_agent_list", description: "List all saved infernoflow agents available in this project.", inputSchema: { type: "object", properties: {} } },
|
|
25
28
|
];
|
|
26
29
|
|
|
27
30
|
// ── git drift detection (inline — no external imports in this template file) ─
|
|
@@ -226,6 +229,66 @@ function scanUi() {
|
|
|
226
229
|
return lines.join("\n");
|
|
227
230
|
}
|
|
228
231
|
|
|
232
|
+
// ── infernoflow_synthesize / agent tools ──────────────────────────────────
|
|
233
|
+
function synthesizePatterns(threshold, autoApprove) {
|
|
234
|
+
const args = `synthesize --json --threshold ${threshold}`;
|
|
235
|
+
const out = runCmd(args);
|
|
236
|
+
try {
|
|
237
|
+
const data = JSON.parse(out);
|
|
238
|
+
if (!data.ok) return `synthesize error: ${data.error}`;
|
|
239
|
+
const pending = data.candidates || [];
|
|
240
|
+
if (pending.length === 0) {
|
|
241
|
+
return `No new workflow patterns detected yet (${data.sessions} sessions analyzed).\nKeep using infernoflow and run this tool again after a few more sessions.`;
|
|
242
|
+
}
|
|
243
|
+
const lines = [
|
|
244
|
+
`📊 ${data.sessions} sessions analyzed — ${pending.length} new pattern${pending.length !== 1 ? "s" : ""} found:\n`
|
|
245
|
+
];
|
|
246
|
+
for (const c of pending) {
|
|
247
|
+
const conf = Math.round(c.confidence * 100);
|
|
248
|
+
lines.push(` [${c.type.toUpperCase()}] ${c.name} (${conf}% confidence, seen ${c.frequency}×)`);
|
|
249
|
+
lines.push(` Pattern: "${c.trigger}"`);
|
|
250
|
+
if (c.steps) lines.push(` Steps: ${c.steps.join(" → ")}`);
|
|
251
|
+
if (c.examples?.length) lines.push(` Examples: ${c.examples.slice(0, 2).map(e => `"${e}"`).join(", ")}`);
|
|
252
|
+
lines.push("");
|
|
253
|
+
}
|
|
254
|
+
if (autoApprove) {
|
|
255
|
+
runCmd(`synthesize --auto --threshold ${threshold}`);
|
|
256
|
+
lines.push(`\n✅ High-confidence candidates auto-approved. Run infernoflow agent list to see new agents.`);
|
|
257
|
+
} else {
|
|
258
|
+
lines.push(`💡 To approve these, run: infernoflow synthesize`);
|
|
259
|
+
lines.push(` Or auto-approve all: infernoflow synthesize --auto`);
|
|
260
|
+
}
|
|
261
|
+
return lines.join("\n");
|
|
262
|
+
} catch {
|
|
263
|
+
return out || "synthesize returned no output";
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function listAgents() {
|
|
268
|
+
const cwd = process.cwd();
|
|
269
|
+
const agentsDir = path.join(cwd, "inferno", "agents");
|
|
270
|
+
if (!fs.existsSync(agentsDir)) return "No agents saved yet. Run infernoflow synthesize to auto-generate agents from your workflow patterns.";
|
|
271
|
+
const files = fs.readdirSync(agentsDir).filter(f => f.endsWith(".json"));
|
|
272
|
+
if (!files.length) return "No agents saved yet. Run infernoflow synthesize to auto-generate agents.";
|
|
273
|
+
const agents = files.map(f => {
|
|
274
|
+
try { return JSON.parse(fs.readFileSync(path.join(agentsDir, f), "utf8")); }
|
|
275
|
+
catch { return null; }
|
|
276
|
+
}).filter(Boolean);
|
|
277
|
+
const lines = [`${agents.length} saved agent${agents.length !== 1 ? "s" : ""}:\n`];
|
|
278
|
+
for (const a of agents) {
|
|
279
|
+
const steps = (a.steps || []).map(s => typeof s === "string" ? s : s.command).join(" → ");
|
|
280
|
+
lines.push(` ▶ ${a.name}`);
|
|
281
|
+
lines.push(` ${a.description || steps}`);
|
|
282
|
+
lines.push(` Run: infernoflow agent run ${a.name}\n`);
|
|
283
|
+
}
|
|
284
|
+
return lines.join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function runAgent(name) {
|
|
288
|
+
if (!name) return "Usage: provide an agent name";
|
|
289
|
+
return runCmd(`agent run ${name}`) || `Agent "${name}" completed.`;
|
|
290
|
+
}
|
|
291
|
+
|
|
229
292
|
// ── infernoflow_review ─────────────────────────────────────────────────────
|
|
230
293
|
function reviewDrift(baseBranch) {
|
|
231
294
|
const cwd = process.cwd();
|
|
@@ -454,6 +517,12 @@ function handleTool(id, name, input) {
|
|
|
454
517
|
text = scanUi();
|
|
455
518
|
} else if (name === "infernoflow_review") {
|
|
456
519
|
text = reviewDrift(input.branch || "main");
|
|
520
|
+
} else if (name === "infernoflow_synthesize") {
|
|
521
|
+
text = synthesizePatterns(input.threshold || 2, input.autoApprove || false);
|
|
522
|
+
} else if (name === "infernoflow_agent_list") {
|
|
523
|
+
text = listAgents();
|
|
524
|
+
} else if (name === "infernoflow_agent_run") {
|
|
525
|
+
text = runAgent(input.name);
|
|
457
526
|
} else { return sendError(id, -32601, `Unknown tool: ${name}`); }
|
|
458
527
|
sendResult(id, { content: [{ type: "text", text: text || "(no output)" }] });
|
|
459
528
|
} catch (err) { sendError(id, -32000, err.message); }
|