notoken-core 1.0.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/config/file-hints.json +255 -0
- package/config/hosts.json +14 -0
- package/config/intents.json +3920 -0
- package/config/playbooks.json +112 -0
- package/config/rules.json +100 -0
- package/dist/agents/agentSpawner.d.ts +56 -0
- package/dist/agents/agentSpawner.js +180 -0
- package/dist/agents/planner.d.ts +40 -0
- package/dist/agents/planner.js +175 -0
- package/dist/agents/playbookRunner.d.ts +45 -0
- package/dist/agents/playbookRunner.js +120 -0
- package/dist/agents/taskRunner.d.ts +61 -0
- package/dist/agents/taskRunner.js +142 -0
- package/dist/context/history.d.ts +36 -0
- package/dist/context/history.js +115 -0
- package/dist/conversation/coreference.d.ts +27 -0
- package/dist/conversation/coreference.js +147 -0
- package/dist/conversation/secrets.d.ts +43 -0
- package/dist/conversation/secrets.js +129 -0
- package/dist/conversation/store.d.ts +94 -0
- package/dist/conversation/store.js +184 -0
- package/dist/execution/git.d.ts +11 -0
- package/dist/execution/git.js +146 -0
- package/dist/execution/ssh.d.ts +2 -0
- package/dist/execution/ssh.js +17 -0
- package/dist/handlers/executor.d.ts +8 -0
- package/dist/handlers/executor.js +216 -0
- package/dist/healing/claudeHealer.d.ts +17 -0
- package/dist/healing/claudeHealer.js +300 -0
- package/dist/healing/patchPromoter.d.ts +25 -0
- package/dist/healing/patchPromoter.js +118 -0
- package/dist/healing/ruleBuilder.d.ts +5 -0
- package/dist/healing/ruleBuilder.js +111 -0
- package/dist/healing/ruleRepairer.d.ts +8 -0
- package/dist/healing/ruleRepairer.js +29 -0
- package/dist/healing/ruleValidator.d.ts +22 -0
- package/dist/healing/ruleValidator.js +145 -0
- package/dist/healing/runHealer.d.ts +11 -0
- package/dist/healing/runHealer.js +74 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +62 -0
- package/dist/intents/catalog.d.ts +4 -0
- package/dist/intents/catalog.js +7 -0
- package/dist/nlp/disambiguate.d.ts +2 -0
- package/dist/nlp/disambiguate.js +46 -0
- package/dist/nlp/fuzzyResolver.d.ts +14 -0
- package/dist/nlp/fuzzyResolver.js +108 -0
- package/dist/nlp/llmFallback.d.ts +63 -0
- package/dist/nlp/llmFallback.js +338 -0
- package/dist/nlp/llmParser.d.ts +8 -0
- package/dist/nlp/llmParser.js +118 -0
- package/dist/nlp/multiClassifier.d.ts +39 -0
- package/dist/nlp/multiClassifier.js +181 -0
- package/dist/nlp/parseIntent.d.ts +2 -0
- package/dist/nlp/parseIntent.js +34 -0
- package/dist/nlp/ruleParser.d.ts +2 -0
- package/dist/nlp/ruleParser.js +234 -0
- package/dist/nlp/semantic.d.ts +104 -0
- package/dist/nlp/semantic.js +419 -0
- package/dist/nlp/uncertainty.d.ts +42 -0
- package/dist/nlp/uncertainty.js +103 -0
- package/dist/parsers/apacheParser.d.ts +50 -0
- package/dist/parsers/apacheParser.js +152 -0
- package/dist/parsers/bindParser.d.ts +40 -0
- package/dist/parsers/bindParser.js +189 -0
- package/dist/parsers/envFile.d.ts +39 -0
- package/dist/parsers/envFile.js +128 -0
- package/dist/parsers/fileFinder.d.ts +30 -0
- package/dist/parsers/fileFinder.js +226 -0
- package/dist/parsers/index.d.ts +27 -0
- package/dist/parsers/index.js +193 -0
- package/dist/parsers/jsonParser.d.ts +16 -0
- package/dist/parsers/jsonParser.js +57 -0
- package/dist/parsers/nginxParser.d.ts +47 -0
- package/dist/parsers/nginxParser.js +161 -0
- package/dist/parsers/passwd.d.ts +25 -0
- package/dist/parsers/passwd.js +41 -0
- package/dist/parsers/shadow.d.ts +23 -0
- package/dist/parsers/shadow.js +50 -0
- package/dist/parsers/yamlParser.d.ts +13 -0
- package/dist/parsers/yamlParser.js +54 -0
- package/dist/policy/confirm.d.ts +2 -0
- package/dist/policy/confirm.js +29 -0
- package/dist/policy/safety.d.ts +4 -0
- package/dist/policy/safety.js +32 -0
- package/dist/types/intent.d.ts +205 -0
- package/dist/types/intent.js +32 -0
- package/dist/types/rules.d.ts +237 -0
- package/dist/types/rules.js +50 -0
- package/dist/utils/analysis.d.ts +25 -0
- package/dist/utils/analysis.js +307 -0
- package/dist/utils/autoBackup.d.ts +43 -0
- package/dist/utils/autoBackup.js +144 -0
- package/dist/utils/config.d.ts +11 -0
- package/dist/utils/config.js +32 -0
- package/dist/utils/dirAnalysis.d.ts +23 -0
- package/dist/utils/dirAnalysis.js +192 -0
- package/dist/utils/explain.d.ts +8 -0
- package/dist/utils/explain.js +145 -0
- package/dist/utils/logger.d.ts +5 -0
- package/dist/utils/logger.js +29 -0
- package/dist/utils/output.d.ts +2 -0
- package/dist/utils/output.js +26 -0
- package/dist/utils/paths.d.ts +26 -0
- package/dist/utils/paths.js +47 -0
- package/dist/utils/permissions.d.ts +64 -0
- package/dist/utils/permissions.js +298 -0
- package/dist/utils/platform.d.ts +53 -0
- package/dist/utils/platform.js +253 -0
- package/dist/utils/smartFile.d.ts +29 -0
- package/dist/utils/smartFile.js +188 -0
- package/dist/utils/spinner.d.ts +53 -0
- package/dist/utils/spinner.js +140 -0
- package/dist/utils/verbose.d.ts +27 -0
- package/dist/utils/verbose.js +131 -0
- package/dist/utils/wslPaths.d.ts +31 -0
- package/dist/utils/wslPaths.js +145 -0
- package/package.json +39 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { getIntentDef, loadHosts } from "../utils/config.js";
|
|
2
|
+
import { runRemoteCommand, runLocalCommand } from "../execution/ssh.js";
|
|
3
|
+
import { gitStatus, gitLog, gitDiff, gitPull, gitPush, gitBranch, gitCheckout, gitCommit, gitAdd, gitStash, gitReset, } from "../execution/git.js";
|
|
4
|
+
import { resolveFuzzyFields } from "../nlp/fuzzyResolver.js";
|
|
5
|
+
import { recordHistory } from "../context/history.js";
|
|
6
|
+
import { createBackup, getRemoteBackupCommand } from "../utils/autoBackup.js";
|
|
7
|
+
import { detectLocalPlatform, getPackageForCommand, getInstallCommand } from "../utils/platform.js";
|
|
8
|
+
import { withSpinner } from "../utils/spinner.js";
|
|
9
|
+
import { analyzeOutput } from "../utils/analysis.js";
|
|
10
|
+
import { smartRead, smartSearch } from "../utils/smartFile.js";
|
|
11
|
+
/**
|
|
12
|
+
* Generic command executor.
|
|
13
|
+
*
|
|
14
|
+
* For git.* intents, uses simple-git for richer programmatic output.
|
|
15
|
+
* For everything else, interpolates command templates and runs via shell.
|
|
16
|
+
*/
|
|
17
|
+
export async function executeIntent(intent) {
|
|
18
|
+
const def = getIntentDef(intent.intent);
|
|
19
|
+
if (!def) {
|
|
20
|
+
throw new Error(`No intent definition found for: ${intent.intent}`);
|
|
21
|
+
}
|
|
22
|
+
// Fuzzy resolve file paths if needed
|
|
23
|
+
const resolved = await resolveFuzzyFields(intent);
|
|
24
|
+
const fields = resolved.fields;
|
|
25
|
+
const environment = fields.environment ?? "local";
|
|
26
|
+
// "local" environment means run on this machine, not SSH
|
|
27
|
+
// Also run locally if no real hosts are configured (placeholder hosts)
|
|
28
|
+
const isLocal = def.execution === "local"
|
|
29
|
+
|| environment === "local"
|
|
30
|
+
|| environment === "localhost"
|
|
31
|
+
|| !hasRealHost(environment);
|
|
32
|
+
let result;
|
|
33
|
+
let command;
|
|
34
|
+
// Auto-backup before destructive file operations
|
|
35
|
+
const destructiveIntents = ["files.copy", "files.move", "files.remove", "env.set"];
|
|
36
|
+
if (destructiveIntents.includes(intent.intent)) {
|
|
37
|
+
const targetFile = (fields.source ?? fields.target ?? fields.path);
|
|
38
|
+
if (targetFile) {
|
|
39
|
+
if (def.execution === "local") {
|
|
40
|
+
const backup = createBackup(targetFile, intent.intent);
|
|
41
|
+
if (backup) {
|
|
42
|
+
console.error(`\x1b[2m[auto-backup] ${backup.originalPath} → ${backup.backupPath}\x1b[0m`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// For remote: prepend backup command
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Smart file reading — size check, sampling, context search
|
|
49
|
+
if (intent.intent === "file.read" || intent.intent === "file.parse") {
|
|
50
|
+
const filePath = fields.path ?? "";
|
|
51
|
+
if (filePath) {
|
|
52
|
+
command = `[smart-read] ${filePath}`;
|
|
53
|
+
result = await withSpinner(`Reading ${filePath}...`, () => smartRead(filePath, !isLocal, isLocal ? undefined : environment));
|
|
54
|
+
recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (intent.intent === "file.search_in") {
|
|
59
|
+
const filePath = fields.path ?? "";
|
|
60
|
+
const query = fields.query ?? "";
|
|
61
|
+
if (filePath && query) {
|
|
62
|
+
command = `[smart-search] ${query} in ${filePath}`;
|
|
63
|
+
result = await withSpinner(`Searching "${query}" in ${filePath}...`, () => smartSearch(filePath, query, !isLocal, isLocal ? undefined : environment));
|
|
64
|
+
recordHistory({ timestamp: new Date().toISOString(), rawText: intent.rawText, intent: intent.intent, fields, command, environment, success: true });
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Route git intents through simple-git for better output
|
|
69
|
+
if (intent.intent.startsWith("git.")) {
|
|
70
|
+
command = `[simple-git] ${intent.intent}`;
|
|
71
|
+
result = await withSpinner(`${intent.intent}...`, () => executeGitIntent(intent.intent, fields));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
command = interpolateCommand(def, fields);
|
|
75
|
+
// For remote destructive ops, prepend a backup command
|
|
76
|
+
if (destructiveIntents.includes(intent.intent) && !isLocal) {
|
|
77
|
+
const targetFile = (fields.source ?? fields.target ?? fields.path);
|
|
78
|
+
if (targetFile) {
|
|
79
|
+
command = getRemoteBackupCommand(targetFile) + command;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const spinnerMsg = isLocal
|
|
83
|
+
? `${intent.intent}...`
|
|
84
|
+
: `${intent.intent} on ${environment}...`;
|
|
85
|
+
try {
|
|
86
|
+
result = await withSpinner(spinnerMsg, async () => {
|
|
87
|
+
if (isLocal) {
|
|
88
|
+
return runLocalCommand(command);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
return runRemoteCommand(environment, command);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// Auto-detect missing commands and suggest install
|
|
97
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
98
|
+
if (msg.includes("command not found") || msg.includes("not found")) {
|
|
99
|
+
const missingCmd = extractMissingCommand(msg);
|
|
100
|
+
if (missingCmd) {
|
|
101
|
+
const platform = detectLocalPlatform();
|
|
102
|
+
const pkg = getPackageForCommand(missingCmd, platform);
|
|
103
|
+
const installCmd = getInstallCommand(pkg ?? missingCmd, platform);
|
|
104
|
+
throw new Error(`${msg}\n\nMissing command: ${missingCmd}\nInstall with: ${installCmd}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
recordHistory({
|
|
111
|
+
timestamp: new Date().toISOString(),
|
|
112
|
+
rawText: intent.rawText,
|
|
113
|
+
intent: intent.intent,
|
|
114
|
+
fields,
|
|
115
|
+
command,
|
|
116
|
+
environment,
|
|
117
|
+
success: true,
|
|
118
|
+
});
|
|
119
|
+
// Append intelligent analysis if applicable
|
|
120
|
+
const analysis = analyzeOutput(intent.intent, result, fields);
|
|
121
|
+
if (analysis) {
|
|
122
|
+
result += "\n" + analysis;
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
async function executeGitIntent(intentName, fields) {
|
|
127
|
+
const path = fields.path ?? ".";
|
|
128
|
+
switch (intentName) {
|
|
129
|
+
case "git.status":
|
|
130
|
+
return gitStatus(path);
|
|
131
|
+
case "git.log":
|
|
132
|
+
return gitLog(path, fields.count ?? 10);
|
|
133
|
+
case "git.diff":
|
|
134
|
+
return gitDiff(path, fields.target);
|
|
135
|
+
case "git.pull":
|
|
136
|
+
return gitPull(path, fields.remote ?? "origin", fields.branch);
|
|
137
|
+
case "git.push":
|
|
138
|
+
return gitPush(path, fields.remote ?? "origin", fields.branch);
|
|
139
|
+
case "git.branch":
|
|
140
|
+
return gitBranch(path);
|
|
141
|
+
case "git.checkout":
|
|
142
|
+
return gitCheckout(fields.branch, path);
|
|
143
|
+
case "git.commit":
|
|
144
|
+
return gitCommit(fields.message, path);
|
|
145
|
+
case "git.add":
|
|
146
|
+
return gitAdd(fields.target ?? ".", path);
|
|
147
|
+
case "git.stash":
|
|
148
|
+
return gitStash(fields.action ?? "push", path);
|
|
149
|
+
case "git.reset":
|
|
150
|
+
return gitReset(fields.target ?? "HEAD", path);
|
|
151
|
+
default:
|
|
152
|
+
throw new Error(`Unknown git intent: ${intentName}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function interpolateCommand(def, fields) {
|
|
156
|
+
let cmd = def.command;
|
|
157
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
158
|
+
if (value !== undefined && value !== null) {
|
|
159
|
+
const safe = sanitize(String(value));
|
|
160
|
+
cmd = cmd.replaceAll(`{{${key}}}`, safe);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const [fieldName, fieldDef] of Object.entries(def.fields)) {
|
|
164
|
+
if (fieldDef.default !== undefined) {
|
|
165
|
+
const safe = sanitize(String(fieldDef.default));
|
|
166
|
+
cmd = cmd.replaceAll(`{{${fieldName}}}`, safe);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
cmd = cmd.replace(/\{\{[a-zA-Z_]+\}\}/g, "");
|
|
170
|
+
return cmd.trim();
|
|
171
|
+
}
|
|
172
|
+
function sanitize(value) {
|
|
173
|
+
if (value === "")
|
|
174
|
+
return "";
|
|
175
|
+
if (!/^[a-zA-Z0-9_.\/\\\- :@~]+$/.test(value)) {
|
|
176
|
+
throw new Error(`Unsafe field value rejected: "${value}"`);
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
function extractMissingCommand(errorMsg) {
|
|
181
|
+
const match1 = errorMsg.match(/bash: (\w+): command not found/);
|
|
182
|
+
if (match1)
|
|
183
|
+
return match1[1];
|
|
184
|
+
const match2 = errorMsg.match(/sh: \d+: (\w+): not found/);
|
|
185
|
+
if (match2)
|
|
186
|
+
return match2[1];
|
|
187
|
+
const match3 = errorMsg.match(/\/bin\/\w+: (\w+): not found/);
|
|
188
|
+
if (match3)
|
|
189
|
+
return match3[1];
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Check if a real (non-placeholder) SSH host is configured for an environment.
|
|
194
|
+
* Placeholder hosts like "user@dev-server" are detected and treated as unconfigured.
|
|
195
|
+
*/
|
|
196
|
+
function hasRealHost(environment) {
|
|
197
|
+
try {
|
|
198
|
+
const hosts = loadHosts();
|
|
199
|
+
const entry = hosts[environment];
|
|
200
|
+
if (!entry)
|
|
201
|
+
return false;
|
|
202
|
+
const host = entry.host;
|
|
203
|
+
// Detect common placeholder patterns
|
|
204
|
+
const placeholders = [
|
|
205
|
+
/user@(dev|staging|prod|test)-server$/,
|
|
206
|
+
/user@(dev|staging|prod|test)$/,
|
|
207
|
+
/example\.com$/,
|
|
208
|
+
/localhost$/,
|
|
209
|
+
/127\.0\.0\.1$/,
|
|
210
|
+
];
|
|
211
|
+
return !placeholders.some((p) => p.test(host));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Claude-powered auto-learning.
|
|
4
|
+
*
|
|
5
|
+
* Uses Claude CLI to:
|
|
6
|
+
* 1. Read the current rules.json and intents.json structure
|
|
7
|
+
* 2. Read the failure log (phrases that didn't match)
|
|
8
|
+
* 3. Read the uncertainty log (phrases with low confidence)
|
|
9
|
+
* 4. Analyze gaps and propose structured changes
|
|
10
|
+
* 5. Let Claude request to see/grep specific files
|
|
11
|
+
* 6. Validate and apply the changes
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* npx tsx src/healing/claudeHealer.ts [--promote] [--dry-run]
|
|
15
|
+
* MYCLI_LLM_CLI=claude npm run heal:claude
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Claude-powered auto-learning.
|
|
4
|
+
*
|
|
5
|
+
* Uses Claude CLI to:
|
|
6
|
+
* 1. Read the current rules.json and intents.json structure
|
|
7
|
+
* 2. Read the failure log (phrases that didn't match)
|
|
8
|
+
* 3. Read the uncertainty log (phrases with low confidence)
|
|
9
|
+
* 4. Analyze gaps and propose structured changes
|
|
10
|
+
* 5. Let Claude request to see/grep specific files
|
|
11
|
+
* 6. Validate and apply the changes
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* npx tsx src/healing/claudeHealer.ts [--promote] [--dry-run]
|
|
15
|
+
* MYCLI_LLM_CLI=claude npm run heal:claude
|
|
16
|
+
*/
|
|
17
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
18
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
import { validatePatch } from "./ruleValidator.js";
|
|
21
|
+
import { promotePatch } from "./patchPromoter.js";
|
|
22
|
+
import { CONFIG_DIR, PACKAGE_ROOT } from "../utils/paths.js";
|
|
23
|
+
import { clearFailures, loadFailures } from "../utils/logger.js";
|
|
24
|
+
import { loadUncertaintyLog } from "../nlp/uncertainty.js";
|
|
25
|
+
const c = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m" };
|
|
26
|
+
async function main() {
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const shouldPromote = args.includes("--promote");
|
|
29
|
+
const dryRun = args.includes("--dry-run");
|
|
30
|
+
const force = args.includes("--force");
|
|
31
|
+
console.log(`${c.bold}${c.cyan}=== Claude Auto-Learning ====${c.reset}\n`);
|
|
32
|
+
// Check Claude CLI
|
|
33
|
+
try {
|
|
34
|
+
execSync("command -v claude", { stdio: "pipe" });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
console.error(`${c.red}Claude CLI not found. Install it or set MYCLI_LLM_CLI=claude.${c.reset}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
// Gather context
|
|
41
|
+
const failures = loadFailures();
|
|
42
|
+
const uncertainty = loadUncertaintyLog();
|
|
43
|
+
const rulesJson = readFileSync(resolve(CONFIG_DIR, "rules.json"), "utf-8");
|
|
44
|
+
const intentsJson = readFileSync(resolve(CONFIG_DIR, "intents.json"), "utf-8");
|
|
45
|
+
// Summarize intents (don't send the whole 70KB file)
|
|
46
|
+
const intents = JSON.parse(intentsJson);
|
|
47
|
+
const intentSummary = intents.intents.map((i) => ({
|
|
48
|
+
name: i.name,
|
|
49
|
+
synonyms: i.synonyms,
|
|
50
|
+
description: i.description,
|
|
51
|
+
}));
|
|
52
|
+
if (failures.length === 0 && uncertainty.length === 0) {
|
|
53
|
+
console.log(`${c.green}✓ No failures or uncertainty to fix.${c.reset}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.log(`${c.dim}Failures: ${failures.length} | Uncertain phrases: ${uncertainty.length}${c.reset}\n`);
|
|
57
|
+
// Build the prompt for Claude
|
|
58
|
+
const prompt = buildHealerPrompt(failures, uncertainty, rulesJson, intentSummary);
|
|
59
|
+
console.log(`${c.dim}Asking Claude to analyze...${c.reset}\n`);
|
|
60
|
+
// Call Claude CLI
|
|
61
|
+
const response = callClaude(prompt);
|
|
62
|
+
if (!response) {
|
|
63
|
+
console.error(`${c.red}Claude returned no response.${c.reset}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Extract the JSON patch from Claude's response
|
|
67
|
+
const patch = extractPatch(response);
|
|
68
|
+
if (!patch) {
|
|
69
|
+
// Claude might want to see a file first — check for requests
|
|
70
|
+
const fileRequest = extractFileRequest(response);
|
|
71
|
+
if (fileRequest) {
|
|
72
|
+
console.log(`${c.cyan}Claude wants to see: ${fileRequest}${c.reset}`);
|
|
73
|
+
const fileContent = readRequestedFile(fileRequest);
|
|
74
|
+
// Follow up with the file content
|
|
75
|
+
const followUp = `Here is the content of ${fileRequest}:\n\n${fileContent}\n\nNow based on this, provide the structured JSON patch as described.`;
|
|
76
|
+
const response2 = callClaude(followUp);
|
|
77
|
+
const patch2 = response2 ? extractPatch(response2) : null;
|
|
78
|
+
if (patch2) {
|
|
79
|
+
applyPatch(patch2, shouldPromote, dryRun, force);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
console.log(`\n${c.bold}Claude's analysis:${c.reset}`);
|
|
83
|
+
console.log(response2 ?? response);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// Just show Claude's analysis
|
|
88
|
+
console.log(`\n${c.bold}Claude's analysis:${c.reset}`);
|
|
89
|
+
console.log(response);
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
applyPatch(patch, shouldPromote, dryRun, force);
|
|
94
|
+
}
|
|
95
|
+
function buildHealerPrompt(failures, uncertainty, rulesJson, intentSummary) {
|
|
96
|
+
const failureList = failures.slice(-20).map((f) => ` - "${f.rawText}"`).join("\n");
|
|
97
|
+
const uncertainList = uncertainty.slice(-15).map((u) => ` - "${u.rawText}" (conf: ${(u.overallConfidence * 100).toFixed(0)}%, unknown: ${u.unknownTokens.join(", ") || "none"})`).join("\n");
|
|
98
|
+
return `You are analyzing an NLP-based CLI tool that parses natural language into server operation commands.
|
|
99
|
+
|
|
100
|
+
## HOW THE SYSTEM WORKS
|
|
101
|
+
|
|
102
|
+
The CLI has two config files:
|
|
103
|
+
|
|
104
|
+
1. **rules.json** — contains environment aliases (prod, staging, dev) and service aliases (nginx, redis, api, etc.)
|
|
105
|
+
These are used to extract entities from user input.
|
|
106
|
+
|
|
107
|
+
2. **intents.json** — each intent has:
|
|
108
|
+
- "name": the intent identifier (e.g., "service.restart")
|
|
109
|
+
- "synonyms": an array of phrases that trigger this intent via substring matching
|
|
110
|
+
- "fields": what gets extracted (service, environment, path, etc.)
|
|
111
|
+
- "command": the shell command template with {{field}} placeholders
|
|
112
|
+
|
|
113
|
+
**The parser matches by finding the LONGEST synonym substring in the user's input.**
|
|
114
|
+
So if user says "restart nginx on prod", and "restart" (7 chars) is a synonym for service.restart,
|
|
115
|
+
it matches. If another intent had "restart nginx" (13 chars), that would win.
|
|
116
|
+
|
|
117
|
+
## CURRENT RULES.JSON
|
|
118
|
+
${rulesJson}
|
|
119
|
+
|
|
120
|
+
## CURRENT INTENTS (${intentSummary.length} total)
|
|
121
|
+
${JSON.stringify(intentSummary, null, 2)}
|
|
122
|
+
|
|
123
|
+
## FAILED PHRASES (these didn't match any intent)
|
|
124
|
+
${failureList || " (none)"}
|
|
125
|
+
|
|
126
|
+
## UNCERTAIN PHRASES (matched but with low confidence or unknown tokens)
|
|
127
|
+
${uncertainList || " (none)"}
|
|
128
|
+
|
|
129
|
+
## YOUR TASK
|
|
130
|
+
|
|
131
|
+
Analyze the failures and uncertainties. Propose a JSON patch to fix them.
|
|
132
|
+
|
|
133
|
+
You can:
|
|
134
|
+
1. Add new synonyms to existing intents (most common fix)
|
|
135
|
+
2. Add new service/environment aliases to rules.json
|
|
136
|
+
3. Suggest new intents if truly needed
|
|
137
|
+
|
|
138
|
+
**IMPORTANT**: Synonyms work by substring matching. A synonym like "check" will match ANY text containing "check".
|
|
139
|
+
Short synonyms can cause false positives. Prefer longer, more specific synonyms.
|
|
140
|
+
|
|
141
|
+
If you need to see a specific file to understand the system better, say:
|
|
142
|
+
"I need to see: <filepath>"
|
|
143
|
+
|
|
144
|
+
Otherwise, return a JSON patch in this format:
|
|
145
|
+
\`\`\`json
|
|
146
|
+
{
|
|
147
|
+
"summary": "what this patch does",
|
|
148
|
+
"confidence": 0.0-1.0,
|
|
149
|
+
"changes": [
|
|
150
|
+
{ "type": "add_intent_synonym", "intent": "service.restart", "phrase": "recycle" },
|
|
151
|
+
{ "type": "add_env_alias", "canonical": "prod", "alias": "production-server" },
|
|
152
|
+
{ "type": "add_service_alias", "canonical": "nginx", "alias": "webserver" }
|
|
153
|
+
],
|
|
154
|
+
"tests": [
|
|
155
|
+
{ "input": "recycle nginx on prod", "expectedIntent": "service.restart" },
|
|
156
|
+
{ "input": "random unrelated phrase", "shouldReject": true }
|
|
157
|
+
],
|
|
158
|
+
"warnings": ["any concerns about these changes"]
|
|
159
|
+
}
|
|
160
|
+
\`\`\`
|
|
161
|
+
|
|
162
|
+
Be conservative. Only add changes that clearly fix the reported failures.`;
|
|
163
|
+
}
|
|
164
|
+
function callClaude(prompt) {
|
|
165
|
+
try {
|
|
166
|
+
const result = execFileSync("claude", ["-p", prompt, "--no-session-persistence", "--max-turns", "2",
|
|
167
|
+
"--append-system-prompt", "IMPORTANT: Do NOT use any tools. Do NOT read files. All the context you need is in the prompt. Respond with ONLY the JSON patch object. No explanation, just JSON."], {
|
|
168
|
+
encoding: "utf-8",
|
|
169
|
+
timeout: 180_000,
|
|
170
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
171
|
+
cwd: PACKAGE_ROOT,
|
|
172
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
173
|
+
});
|
|
174
|
+
return result.trim();
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
const e = err;
|
|
178
|
+
if (e.stdout && e.stdout.trim())
|
|
179
|
+
return e.stdout.trim();
|
|
180
|
+
const msg = e.stderr ?? e.message ?? String(err);
|
|
181
|
+
console.error(`${c.red}Claude error: ${msg.split("\n")[0]}${c.reset}`);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function extractPatch(response) {
|
|
186
|
+
// Try JSON code block
|
|
187
|
+
const jsonMatch = response.match(/```json\s*([\s\S]*?)```/);
|
|
188
|
+
if (jsonMatch) {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(jsonMatch[1].trim());
|
|
191
|
+
if (parsed.changes && Array.isArray(parsed.changes)) {
|
|
192
|
+
return { ...parsed, warnings: parsed.warnings ?? [] };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch { }
|
|
196
|
+
}
|
|
197
|
+
// Try raw JSON
|
|
198
|
+
const rawMatch = response.match(/\{[\s\S]*"changes"[\s\S]*\}/);
|
|
199
|
+
if (rawMatch) {
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(rawMatch[0]);
|
|
202
|
+
if (parsed.changes)
|
|
203
|
+
return { ...parsed, warnings: parsed.warnings ?? [] };
|
|
204
|
+
}
|
|
205
|
+
catch { }
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
function extractFileRequest(response) {
|
|
210
|
+
const match = response.match(/I need to see:\s*(\S+)/i);
|
|
211
|
+
if (match)
|
|
212
|
+
return match[1];
|
|
213
|
+
const match2 = response.match(/(?:show me|let me see|can I see|read)\s+(\S+\.\w+)/i);
|
|
214
|
+
if (match2)
|
|
215
|
+
return match2[1];
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
function readRequestedFile(request) {
|
|
219
|
+
// Try relative to config dir, then project root, then absolute
|
|
220
|
+
const candidates = [
|
|
221
|
+
resolve(CONFIG_DIR, request),
|
|
222
|
+
resolve(CONFIG_DIR, "..", request),
|
|
223
|
+
request,
|
|
224
|
+
];
|
|
225
|
+
for (const path of candidates) {
|
|
226
|
+
if (existsSync(path)) {
|
|
227
|
+
const content = readFileSync(path, "utf-8");
|
|
228
|
+
// Truncate if huge
|
|
229
|
+
if (content.length > 10000) {
|
|
230
|
+
return content.slice(0, 10000) + `\n\n... (truncated, ${content.length} total chars)`;
|
|
231
|
+
}
|
|
232
|
+
return content;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return `File not found: ${request}`;
|
|
236
|
+
}
|
|
237
|
+
function applyPatch(patch, shouldPromote, dryRun, force = false) {
|
|
238
|
+
console.log(`\n${c.bold}--- Proposed Patch ---${c.reset}`);
|
|
239
|
+
console.log(`${c.cyan}Summary:${c.reset} ${patch.summary}`);
|
|
240
|
+
console.log(`${c.cyan}Confidence:${c.reset} ${(patch.confidence * 100).toFixed(0)}%`);
|
|
241
|
+
console.log(`${c.cyan}Changes:${c.reset} ${patch.changes.length}`);
|
|
242
|
+
for (const change of patch.changes) {
|
|
243
|
+
switch (change.type) {
|
|
244
|
+
case "add_intent_synonym":
|
|
245
|
+
console.log(` ${c.green}+${c.reset} synonym "${change.phrase}" → ${change.intent}`);
|
|
246
|
+
break;
|
|
247
|
+
case "add_env_alias":
|
|
248
|
+
console.log(` ${c.green}+${c.reset} env alias "${change.alias}" → ${change.canonical}`);
|
|
249
|
+
break;
|
|
250
|
+
case "add_service_alias":
|
|
251
|
+
console.log(` ${c.green}+${c.reset} service alias "${change.alias}" → ${change.canonical}`);
|
|
252
|
+
break;
|
|
253
|
+
case "remove_intent_synonym":
|
|
254
|
+
console.log(` ${c.red}-${c.reset} remove synonym "${change.phrase}" from ${change.intent}`);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (patch.tests.length > 0) {
|
|
259
|
+
console.log(`\n${c.cyan}Tests:${c.reset} ${patch.tests.length}`);
|
|
260
|
+
for (const t of patch.tests) {
|
|
261
|
+
const label = t.shouldReject ? `${c.red}REJECT${c.reset}` : `${c.green}${t.expectedIntent}${c.reset}`;
|
|
262
|
+
console.log(` "${t.input}" → ${label}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (patch.warnings.length > 0) {
|
|
266
|
+
console.log(`\n${c.yellow}Warnings:${c.reset}`);
|
|
267
|
+
for (const w of patch.warnings)
|
|
268
|
+
console.log(` - ${w}`);
|
|
269
|
+
}
|
|
270
|
+
// Validate
|
|
271
|
+
console.log(`\n${c.bold}--- Validation ---${c.reset}`);
|
|
272
|
+
const validation = validatePatch(patch);
|
|
273
|
+
console.log(`Valid: ${validation.valid ? `${c.green}yes${c.reset}` : `${c.red}no${c.reset}`}`);
|
|
274
|
+
if (validation.errors.length > 0) {
|
|
275
|
+
for (const e of validation.errors)
|
|
276
|
+
console.log(` ${c.red}✗ ${e}${c.reset}`);
|
|
277
|
+
}
|
|
278
|
+
for (const t of validation.testResults) {
|
|
279
|
+
console.log(` ${t.passed ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`} "${t.input}"${t.reason ? ` (${t.reason})` : ""}`);
|
|
280
|
+
}
|
|
281
|
+
if (shouldPromote) {
|
|
282
|
+
console.log(`\n${c.bold}--- Promoting ---${c.reset}`);
|
|
283
|
+
const result = promotePatch(patch, { force, dryRun });
|
|
284
|
+
if (result.promoted) {
|
|
285
|
+
console.log(`${c.green}✓ Patch applied. Rules updated to v${result.newVersion}${c.reset}`);
|
|
286
|
+
clearFailures();
|
|
287
|
+
console.log(`${c.green}✓ Failure log cleared.${c.reset}`);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
console.log(`${c.yellow}Patch not promoted.${c.reset}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
console.log(`\n${c.dim}Run with --promote to apply. Add --dry-run for safe preview.${c.reset}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
main().catch((err) => {
|
|
298
|
+
console.error(`${c.red}Healer error:${c.reset}`, err.message ?? err);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RulePatch } from "../types/rules.js";
|
|
2
|
+
import { type ValidationResult } from "./ruleValidator.js";
|
|
3
|
+
export interface PromotionResult {
|
|
4
|
+
promoted: boolean;
|
|
5
|
+
validation: ValidationResult;
|
|
6
|
+
backupPath?: string;
|
|
7
|
+
newVersion?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* PatchPromoter: validates and applies a rule patch to the live config.
|
|
11
|
+
*
|
|
12
|
+
* Flow:
|
|
13
|
+
* 1. Validate the patch
|
|
14
|
+
* 2. If valid, back up current rules
|
|
15
|
+
* 3. Apply changes to rules.json
|
|
16
|
+
* 4. Bump version
|
|
17
|
+
*
|
|
18
|
+
* Options:
|
|
19
|
+
* - force: skip validation (not recommended)
|
|
20
|
+
* - dryRun: validate but don't write
|
|
21
|
+
*/
|
|
22
|
+
export declare function promotePatch(patch: RulePatch, options?: {
|
|
23
|
+
force?: boolean;
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
}): PromotionResult;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { RulesConfig } from "../types/rules.js";
|
|
4
|
+
import { getConfigDir } from "../utils/config.js";
|
|
5
|
+
import { validatePatch } from "./ruleValidator.js";
|
|
6
|
+
/**
|
|
7
|
+
* PatchPromoter: validates and applies a rule patch to the live config.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. Validate the patch
|
|
11
|
+
* 2. If valid, back up current rules
|
|
12
|
+
* 3. Apply changes to rules.json
|
|
13
|
+
* 4. Bump version
|
|
14
|
+
*
|
|
15
|
+
* Options:
|
|
16
|
+
* - force: skip validation (not recommended)
|
|
17
|
+
* - dryRun: validate but don't write
|
|
18
|
+
*/
|
|
19
|
+
export function promotePatch(patch, options = {}) {
|
|
20
|
+
const validation = validatePatch(patch);
|
|
21
|
+
if (!validation.valid && !options.force) {
|
|
22
|
+
return { promoted: false, validation };
|
|
23
|
+
}
|
|
24
|
+
if (validation.warnings.length > 0) {
|
|
25
|
+
console.log("Warnings:");
|
|
26
|
+
for (const w of validation.warnings)
|
|
27
|
+
console.log(` - ${w}`);
|
|
28
|
+
}
|
|
29
|
+
if (options.dryRun) {
|
|
30
|
+
console.log("Dry run — patch is valid but not applied.");
|
|
31
|
+
return { promoted: false, validation };
|
|
32
|
+
}
|
|
33
|
+
const configDir = getConfigDir();
|
|
34
|
+
const rulesPath = resolve(configDir, "rules.json");
|
|
35
|
+
const raw = readFileSync(rulesPath, "utf-8");
|
|
36
|
+
const rules = RulesConfig.parse(JSON.parse(raw));
|
|
37
|
+
// Backup
|
|
38
|
+
const backupPath = resolve(configDir, `rules.backup.${Date.now()}.json`);
|
|
39
|
+
copyFileSync(rulesPath, backupPath);
|
|
40
|
+
// Also load intents.json for synonym changes (primary source now)
|
|
41
|
+
const intentsPath = resolve(configDir, "intents.json");
|
|
42
|
+
const intentsRaw = JSON.parse(readFileSync(intentsPath, "utf-8"));
|
|
43
|
+
const intentsBackup = resolve(configDir, `intents.backup.${Date.now()}.json`);
|
|
44
|
+
copyFileSync(intentsPath, intentsBackup);
|
|
45
|
+
let intentsChanged = false;
|
|
46
|
+
// Apply changes
|
|
47
|
+
for (const change of patch.changes) {
|
|
48
|
+
switch (change.type) {
|
|
49
|
+
case "add_intent_synonym": {
|
|
50
|
+
// Add to intents.json (primary)
|
|
51
|
+
const intentDef = intentsRaw.intents?.find((i) => i.name === change.intent);
|
|
52
|
+
if (intentDef && Array.isArray(intentDef.synonyms)) {
|
|
53
|
+
if (!intentDef.synonyms.includes(change.phrase)) {
|
|
54
|
+
intentDef.synonyms.push(change.phrase);
|
|
55
|
+
intentsChanged = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Also add to rules.json if the intent exists there (backward compat)
|
|
59
|
+
if (rules.intentSynonyms[change.intent]) {
|
|
60
|
+
if (!rules.intentSynonyms[change.intent].includes(change.phrase)) {
|
|
61
|
+
rules.intentSynonyms[change.intent].push(change.phrase);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case "add_env_alias":
|
|
67
|
+
if (rules.environmentAliases[change.canonical]) {
|
|
68
|
+
if (!rules.environmentAliases[change.canonical].includes(change.alias)) {
|
|
69
|
+
rules.environmentAliases[change.canonical].push(change.alias);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
case "add_service_alias":
|
|
74
|
+
if (rules.serviceAliases[change.canonical]) {
|
|
75
|
+
if (!rules.serviceAliases[change.canonical].includes(change.alias)) {
|
|
76
|
+
rules.serviceAliases[change.canonical].push(change.alias);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
case "remove_intent_synonym": {
|
|
81
|
+
// Remove from intents.json
|
|
82
|
+
const rmDef = intentsRaw.intents?.find((i) => i.name === change.intent);
|
|
83
|
+
if (rmDef && Array.isArray(rmDef.synonyms)) {
|
|
84
|
+
rmDef.synonyms = rmDef.synonyms.filter((s) => s !== change.phrase);
|
|
85
|
+
intentsChanged = true;
|
|
86
|
+
}
|
|
87
|
+
// Also remove from rules.json
|
|
88
|
+
if (rules.intentSynonyms[change.intent]) {
|
|
89
|
+
rules.intentSynonyms[change.intent] = rules.intentSynonyms[change.intent].filter((p) => p !== change.phrase);
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Write intents.json if changed
|
|
96
|
+
if (intentsChanged) {
|
|
97
|
+
writeFileSync(intentsPath, JSON.stringify(intentsRaw, null, 2) + "\n");
|
|
98
|
+
console.log(`Intents updated. Backup: ${intentsBackup}`);
|
|
99
|
+
}
|
|
100
|
+
// Bump version
|
|
101
|
+
const newVersion = bumpVersion(rules.version);
|
|
102
|
+
rules.version = newVersion;
|
|
103
|
+
// Write
|
|
104
|
+
writeFileSync(rulesPath, JSON.stringify(rules, null, 2) + "\n");
|
|
105
|
+
console.log(`Patch promoted. Rules updated to v${newVersion}`);
|
|
106
|
+
console.log(`Backup saved: ${backupPath}`);
|
|
107
|
+
return {
|
|
108
|
+
promoted: true,
|
|
109
|
+
validation,
|
|
110
|
+
backupPath,
|
|
111
|
+
newVersion,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function bumpVersion(version) {
|
|
115
|
+
const parts = version.split(".").map(Number);
|
|
116
|
+
parts[2] = (parts[2] ?? 0) + 1;
|
|
117
|
+
return parts.join(".");
|
|
118
|
+
}
|