infernoflow 0.44.19 → 0.45.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/README.md +9 -5
- package/dist/bin/infernoflow.mjs +4 -4
- package/dist/lib/amp/io.mjs +20 -20
- package/dist/lib/cleanTree.mjs +7 -7
- package/dist/lib/commands/ai.mjs +6 -2
- package/dist/lib/commands/ask.mjs +2 -2
- package/dist/lib/commands/bookmark.mjs +12 -12
- package/dist/lib/commands/doctor.mjs +3 -2
- package/dist/lib/commands/log.mjs +13 -12
- package/dist/lib/commands/mcp.mjs +1 -0
- package/dist/lib/commands/recap.mjs +4 -4
- package/dist/lib/commands/setup.mjs +42 -18
- package/dist/lib/commands/status.mjs +4 -4
- package/dist/lib/commands/uninstall.mjs +11 -11
- package/dist/lib/cursorHooksInstall.mjs +1 -1
- package/dist/lib/mcpRegistration.mjs +6 -0
- package/dist/lib/security/redact.mjs +1 -0
- package/dist/lib/securityRefresh.mjs +2 -0
- package/dist/lib/upgradeCheck.mjs +7 -3
- package/dist/templates/cursor/hooks/inferno-session-draft.mjs +41 -9
- package/dist/templates/cursor/inferno-mcp-server.mjs +174 -66
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import readline from "node:readline";
|
|
@@ -37,6 +37,19 @@ function walkUpForInfernoflow(startFile) {
|
|
|
37
37
|
return null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Locate the global `infernoflow` launcher(s) on PATH WITHOUT a shell.
|
|
42
|
+
* `where` (Windows) / `which` (POSIX) are real executables, so execFileSync
|
|
43
|
+
* with an argument array is enough. Returns [] when nothing is found.
|
|
44
|
+
*/
|
|
45
|
+
function lookupOnPath(name) {
|
|
46
|
+
try {
|
|
47
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
48
|
+
const out = execFileSync(finder, [name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true, shell: false });
|
|
49
|
+
return out.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
50
|
+
} catch { return []; }
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
function findInfernoflowRoot() {
|
|
41
54
|
// 1. Walk up from this template's own location.
|
|
42
55
|
// Works when the template runs from inside infernoflow-pkg/ or from a
|
|
@@ -56,9 +69,7 @@ function findInfernoflowRoot() {
|
|
|
56
69
|
// and the user's project doesn't depend on infernoflow locally. Without
|
|
57
70
|
// this branch the MCP server boots with v0.0.0-unknown.
|
|
58
71
|
try {
|
|
59
|
-
const
|
|
60
|
-
const out = execSync(lookup, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
61
|
-
for (const candidate of out.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
|
|
72
|
+
for (const candidate of lookupOnPath("infernoflow")) {
|
|
62
73
|
if (!fs.existsSync(candidate)) continue;
|
|
63
74
|
const binDir = path.dirname(candidate);
|
|
64
75
|
// Windows layout: <npm-prefix>/infernoflow.cmd + <npm-prefix>/node_modules/infernoflow/
|
|
@@ -89,20 +100,40 @@ function sendError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code
|
|
|
89
100
|
// Resolve a deterministic location once at startup, in priority order:
|
|
90
101
|
// 1. infernoflow installed in the project's node_modules (npm i / npm link)
|
|
91
102
|
// 2. `where`/`which` the global binary
|
|
92
|
-
// Returns null if nothing is found;
|
|
103
|
+
// Returns null if nothing is found; runCli surfaces a clear error in that case.
|
|
104
|
+
// SECURITY: this must always resolve to the CLI's JavaScript entry point
|
|
105
|
+
// (infernoflow.mjs), which we run as `node <file> ...args` with NO shell.
|
|
106
|
+
// We never execute the npm `.cmd` / shell-script wrapper: running those needs
|
|
107
|
+
// a shell, and a shell turns tool arguments into commands.
|
|
93
108
|
function resolveInfernoflowBin() {
|
|
94
|
-
|
|
109
|
+
const fromRoot = (root) => {
|
|
95
110
|
for (const c of [
|
|
96
|
-
path.join(
|
|
97
|
-
path.join(
|
|
111
|
+
path.join(root, "dist", "bin", "infernoflow.mjs"),
|
|
112
|
+
path.join(root, "bin", "infernoflow.mjs"),
|
|
98
113
|
]) if (fs.existsSync(c)) return c;
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
116
|
+
if (INFERNOFLOW_ROOT) {
|
|
117
|
+
const hit = fromRoot(INFERNOFLOW_ROOT);
|
|
118
|
+
if (hit) return hit;
|
|
119
|
+
}
|
|
120
|
+
for (const candidate of lookupOnPath("infernoflow")) {
|
|
121
|
+
if (!fs.existsSync(candidate)) continue;
|
|
122
|
+
// POSIX: the bin entry is usually a symlink straight to infernoflow.mjs.
|
|
123
|
+
try {
|
|
124
|
+
const real = fs.realpathSync(candidate);
|
|
125
|
+
if (/\.m?js$/i.test(real)) return real;
|
|
126
|
+
} catch {}
|
|
127
|
+
// Windows / wrapper layouts: find the package next to the wrapper.
|
|
128
|
+
const binDir = path.dirname(candidate);
|
|
129
|
+
for (const layout of [
|
|
130
|
+
path.join(binDir, "node_modules", "infernoflow"),
|
|
131
|
+
path.join(binDir, "..", "lib", "node_modules", "infernoflow"),
|
|
132
|
+
]) {
|
|
133
|
+
const hit = fromRoot(layout);
|
|
134
|
+
if (hit) return hit;
|
|
135
|
+
}
|
|
99
136
|
}
|
|
100
|
-
try {
|
|
101
|
-
const lookup = process.platform === "win32" ? "where infernoflow" : "which infernoflow";
|
|
102
|
-
const out = execSync(lookup, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
103
|
-
const first = out.split(/\r?\n/)[0];
|
|
104
|
-
if (first && fs.existsSync(first)) return first;
|
|
105
|
-
} catch {}
|
|
106
137
|
return null;
|
|
107
138
|
}
|
|
108
139
|
|
|
@@ -110,7 +141,7 @@ const INFERNOFLOW_BIN = resolveInfernoflowBin();
|
|
|
110
141
|
|
|
111
142
|
// In-process AMP I/O loader. When available, amp_write / amp_read bypass the
|
|
112
143
|
// CLI entirely — no subprocess, no version skew, no flag-mapping field loss.
|
|
113
|
-
// Falls back to
|
|
144
|
+
// Falls back to the CLI via runCli() if the AMP layer can't be loaded.
|
|
114
145
|
let ampIo = null;
|
|
115
146
|
let refreshRuleFiles = null;
|
|
116
147
|
let harvestSnapshot = null;
|
|
@@ -211,8 +242,16 @@ try {
|
|
|
211
242
|
* Run the infernoflow CLI. Returns either the stdout string OR a structured
|
|
212
243
|
* error object so call sites can decide whether to surface it via JSON-RPC
|
|
213
244
|
* sendError() instead of returning gibberish text to the agent.
|
|
245
|
+
*
|
|
246
|
+
* SECURITY: `args` is an ARRAY of separate arguments, passed straight to
|
|
247
|
+
* `node infernoflow.mjs` with execFileSync and no shell. Tool input must never
|
|
248
|
+
* be concatenated into a command string — that allowed command injection
|
|
249
|
+
* through tool arguments (fixed in 0.44.20).
|
|
214
250
|
*/
|
|
215
|
-
function
|
|
251
|
+
function runCli(args, env = {}) {
|
|
252
|
+
if (!Array.isArray(args) || !args.every(a => typeof a === "string")) {
|
|
253
|
+
return { __error: true, message: "internal: runCli expects an array of strings", stderr: "", stdout: "", status: 1 };
|
|
254
|
+
}
|
|
216
255
|
if (!INFERNOFLOW_BIN) {
|
|
217
256
|
return {
|
|
218
257
|
__error: true,
|
|
@@ -223,15 +262,13 @@ function runCmd(args, env = {}) {
|
|
|
223
262
|
};
|
|
224
263
|
}
|
|
225
264
|
try {
|
|
226
|
-
|
|
227
|
-
const cmd = isMjs
|
|
228
|
-
? `"${process.execPath}" "${INFERNOFLOW_BIN}" ${args}`
|
|
229
|
-
: `"${INFERNOFLOW_BIN}" ${args}`;
|
|
230
|
-
return execSync(cmd, {
|
|
265
|
+
return execFileSync(process.execPath, [INFERNOFLOW_BIN, ...args], {
|
|
231
266
|
encoding: "utf8",
|
|
232
267
|
cwd: PROJECT_DIR,
|
|
233
268
|
timeout: 30000,
|
|
234
269
|
env: { ...process.env, ...env },
|
|
270
|
+
windowsHide: true,
|
|
271
|
+
shell: false,
|
|
235
272
|
});
|
|
236
273
|
} catch (err) {
|
|
237
274
|
return {
|
|
@@ -244,7 +281,7 @@ function runCmd(args, env = {}) {
|
|
|
244
281
|
}
|
|
245
282
|
}
|
|
246
283
|
|
|
247
|
-
/** True if a
|
|
284
|
+
/** True if a runCli() result is actually a structured error. */
|
|
248
285
|
function isCmdError(result) {
|
|
249
286
|
return typeof result === "object" && result !== null && result.__error === true;
|
|
250
287
|
}
|
|
@@ -259,36 +296,97 @@ function isCmdError(result) {
|
|
|
259
296
|
// helpers that pair cleanly with the kept CLI surface.
|
|
260
297
|
const TOOLS = [
|
|
261
298
|
// ── AMP-spec memory tools (the product) ──────────────────────────────────
|
|
262
|
-
{ name: "amp_read", description: "AMP: read session memory entries with optional filters.", inputSchema: { type: "object", properties: { file: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, query: { type: "string" }, limit: { type: "
|
|
263
|
-
{ name: "amp_write", description: "AMP: log a new entry. Required: type + msg (one sentence). Optional: file, line, tags, detail. Use 'detail' for a rich multi-paragraph body (repro steps, code, full reasoning, or a session snapshot) — it's stored in a sidecar and loaded on demand, so it never bloats the always-on memory index.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, msg: { type: "string" }, file: { type: "string" }, line: { type: "
|
|
264
|
-
{ name: "amp_search", description: "AMP: search entries by keyword. Optional type filter.", inputSchema: { type: "object", properties: { query: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] } }, required: ["query"] } },
|
|
265
|
-
{ name: "amp_bookmark", description: "AMP: drop a named session bookmark — a resume point. Required: label (short name). Optional: note. If note is OMITTED, the current session transcript is auto-captured as the bookmark's context (the 'save everything here' resume point). Use when the user says 'bookmark this' / 'mark this point', or before a risky change / when the context window is filling up, so the exact state can be recalled later and appears in the next session's handoff. Bookmarks are never auto-pruned.", inputSchema: { type: "object", properties: { label: { type: "string" }, note: { type: "string", description: "Optional explicit context. Omit to auto-capture the session transcript instead. Stored in a sidecar; not injected into rule files." } }, required: ["label"] } },
|
|
299
|
+
{ name: "amp_read", description: "AMP: read session memory entries with optional filters.", inputSchema: { type: "object", properties: { file: { type: "string", maxLength: 1000 }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, query: { type: "string", maxLength: 500 }, limit: { type: "integer", minimum: 1, maximum: 200 } } } },
|
|
300
|
+
{ name: "amp_write", description: "AMP: log a new entry. Required: type + msg (one sentence). Optional: file, line, tags, detail. Use 'detail' for a rich multi-paragraph body (repro steps, code, full reasoning, or a session snapshot) — it's stored in a sidecar and loaded on demand, so it never bloats the always-on memory index.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, msg: { type: "string", maxLength: 2000 }, file: { type: "string", maxLength: 1000 }, line: { type: "integer", minimum: 1, maximum: 10000000 }, tags: { type: "array", maxItems: 20, items: { type: "string", maxLength: 100 } }, detail: { type: "string", maxLength: 200000, description: "Optional rich body (Tier-2). Stored in the consolidated details store; NOT injected into rule files. Put the long-form context here; keep 'msg' to one summary sentence." } }, required: ["type","msg"] } },
|
|
301
|
+
{ name: "amp_search", description: "AMP: search entries by keyword. Optional type filter.", inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 500 }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] } }, required: ["query"] } },
|
|
302
|
+
{ name: "amp_bookmark", description: "AMP: drop a named session bookmark — a resume point. Required: label (short name). Optional: note. If note is OMITTED, the current session transcript is auto-captured as the bookmark's context (the 'save everything here' resume point). Use when the user says 'bookmark this' / 'mark this point', or before a risky change / when the context window is filling up, so the exact state can be recalled later and appears in the next session's handoff. Bookmarks are never auto-pruned.", inputSchema: { type: "object", properties: { label: { type: "string", maxLength: 200 }, note: { type: "string", maxLength: 200000, description: "Optional explicit context. Omit to auto-capture the session transcript instead. Stored in a sidecar; not injected into rule files." } }, required: ["label"] } },
|
|
266
303
|
{ name: "amp_handoff", description: "AMP: generate the handoff document for the next AI session. format=markdown|json (default: markdown).", inputSchema: { type: "object", properties: { format: { type: "string", enum: ["markdown","json"] } } } },
|
|
267
304
|
{ name: "amp_health", description: "AMP: get the session health score (0-100, A-F grade).", inputSchema: { type: "object", properties: {} } },
|
|
268
305
|
|
|
269
306
|
// ── Read-only contract helpers ───────────────────────────────────────────
|
|
270
307
|
{ name: "infernoflow_status", description: "Show project memory + contract health at a glance.", inputSchema: { type: "object", properties: {} } },
|
|
271
308
|
{ name: "infernoflow_check", description: "Validate the capability contract (read-only).", inputSchema: { type: "object", properties: {} } },
|
|
272
|
-
{ name: "infernoflow_context", description: "Generate AI-ready context for a task.", inputSchema: { type: "object", properties: { intent: { type: "string" }, working: { type: "string" } } } },
|
|
273
|
-
{ name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes — useful when memory needs branch-aware revalidation.", inputSchema: { type: "object", properties: { sinceCommits: { type: "
|
|
309
|
+
{ name: "infernoflow_context", description: "Generate AI-ready context for a task.", inputSchema: { type: "object", properties: { intent: { type: "string", maxLength: 1000 }, working: { type: "string", maxLength: 1000 } } } },
|
|
310
|
+
{ name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes — useful when memory needs branch-aware revalidation.", inputSchema: { type: "object", properties: { sinceCommits: { type: "integer", minimum: 1, maximum: 100, description: "How many commits back to check (default: 1, max: 100)" } } } },
|
|
274
311
|
];
|
|
275
312
|
|
|
313
|
+
// ── Input validation ─────────────────────────────────────────────────────────
|
|
314
|
+
// SECURITY: tool arguments come from the model, and the model can be steered by
|
|
315
|
+
// anything it reads (a README, an issue, a memory entry pulled from git). The
|
|
316
|
+
// inputSchema above is advisory to the client only — nothing enforces it — so
|
|
317
|
+
// we enforce it here, before any argument reaches the CLI, git or the disk.
|
|
318
|
+
// Unknown properties are dropped (not rejected) so a chatty client still works.
|
|
319
|
+
const TOOL_SCHEMAS = new Map(TOOLS.map(t => [t.name, t.inputSchema]));
|
|
320
|
+
|
|
321
|
+
function checkValue(key, v, spec) {
|
|
322
|
+
const t = spec.type;
|
|
323
|
+
if (t === "string") {
|
|
324
|
+
if (typeof v !== "string") return `'${key}' must be a string`;
|
|
325
|
+
if (spec.maxLength && v.length > spec.maxLength) return `'${key}' is too long (max ${spec.maxLength} characters)`;
|
|
326
|
+
if (v.includes("\u0000")) return `'${key}' must not contain NUL characters`;
|
|
327
|
+
} else if (t === "integer" || t === "number") {
|
|
328
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return `'${key}' must be a number`;
|
|
329
|
+
if (t === "integer" && !Number.isInteger(v)) return `'${key}' must be a whole number`;
|
|
330
|
+
if (spec.minimum !== undefined && v < spec.minimum) return `'${key}' must be >= ${spec.minimum}`;
|
|
331
|
+
if (spec.maximum !== undefined && v > spec.maximum) return `'${key}' must be <= ${spec.maximum}`;
|
|
332
|
+
} else if (t === "array") {
|
|
333
|
+
if (!Array.isArray(v)) return `'${key}' must be an array`;
|
|
334
|
+
if (spec.maxItems && v.length > spec.maxItems) return `'${key}' has too many items (max ${spec.maxItems})`;
|
|
335
|
+
for (const item of v) { const e = checkValue(`${key}[]`, item, spec.items || {}); if (e) return e; }
|
|
336
|
+
}
|
|
337
|
+
if (spec.enum && !spec.enum.includes(v)) return `'${key}' must be one of: ${spec.enum.join(", ")}`;
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Returns { ok: true, value } with only known, valid properties, or { ok: false, error }. */
|
|
342
|
+
function validateToolInput(name, input) {
|
|
343
|
+
const schema = TOOL_SCHEMAS.get(name);
|
|
344
|
+
if (!schema) return { ok: false, error: `Unknown tool: ${name}` };
|
|
345
|
+
if (input === undefined || input === null) input = {};
|
|
346
|
+
if (typeof input !== "object" || Array.isArray(input)) return { ok: false, error: "arguments must be an object" };
|
|
347
|
+
const props = schema.properties || {};
|
|
348
|
+
const value = {};
|
|
349
|
+
for (const [key, spec] of Object.entries(props)) {
|
|
350
|
+
const v = input[key];
|
|
351
|
+
if (v === undefined || v === null) continue;
|
|
352
|
+
const err = checkValue(key, v, spec);
|
|
353
|
+
if (err) return { ok: false, error: `Invalid arguments for ${name}: ${err}` };
|
|
354
|
+
value[key] = v;
|
|
355
|
+
}
|
|
356
|
+
for (const req of schema.required || []) {
|
|
357
|
+
if (value[req] === undefined || value[req] === "") return { ok: false, error: `Invalid arguments for ${name}: '${req}' is required` };
|
|
358
|
+
}
|
|
359
|
+
return { ok: true, value };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Free text that is handed to the CLI as an argument. The CLI's own parser
|
|
364
|
+
* treats any argument equal to a flag name (e.g. "--watch", "--auto-push") as
|
|
365
|
+
* that flag, even when it arrives as a separate argv entry. Strip leading
|
|
366
|
+
* dashes so tool text can never be read as a CLI option.
|
|
367
|
+
*/
|
|
368
|
+
function asCliText(v) {
|
|
369
|
+
return String(v ?? "").replace(/^[\s-]+/, "");
|
|
370
|
+
}
|
|
371
|
+
|
|
276
372
|
// ── git drift detection (inline — no external imports in this template file) ─
|
|
277
373
|
function detectGitDrift(sinceCommits) {
|
|
278
374
|
const cwd = PROJECT_DIR;
|
|
279
375
|
const infernoDir = path.join(cwd, "inferno");
|
|
280
376
|
|
|
281
|
-
|
|
282
|
-
|
|
377
|
+
// SECURITY: git is run with an argument array and no shell.
|
|
378
|
+
const runGit = (args) => {
|
|
379
|
+
try { return execFileSync("git", args, { cwd, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true, shell: false }); }
|
|
283
380
|
catch { return ""; }
|
|
284
381
|
};
|
|
382
|
+
const n = Number.isInteger(sinceCommits) && sinceCommits >= 1 && sinceCommits <= 100 ? sinceCommits : 1;
|
|
285
383
|
|
|
286
384
|
const changedSet = new Set();
|
|
287
385
|
const addLines = (out) => out.split("\n").map(l => l.trim()).filter(Boolean).forEach(f => changedSet.add(f));
|
|
288
386
|
|
|
289
|
-
addLines(runGit("
|
|
290
|
-
addLines(runGit(
|
|
291
|
-
addLines(runGit("
|
|
387
|
+
addLines(runGit(["diff", "--name-only", "HEAD"]));
|
|
388
|
+
addLines(runGit(["diff", "--name-only", `HEAD~${n}`, "HEAD"]));
|
|
389
|
+
addLines(runGit(["ls-files", "--others", "--exclude-standard"]));
|
|
292
390
|
|
|
293
391
|
const changedFiles = Array.from(changedSet).sort();
|
|
294
392
|
if (!changedFiles.length) return "No changed files detected since last commit.";
|
|
@@ -388,29 +486,35 @@ function detectGitDrift(sinceCommits) {
|
|
|
388
486
|
return lines.join("\n");
|
|
389
487
|
}
|
|
390
488
|
|
|
391
|
-
function handleTool(id, name,
|
|
489
|
+
function handleTool(id, name, rawInput) {
|
|
392
490
|
try {
|
|
491
|
+
const checked = validateToolInput(name, rawInput);
|
|
492
|
+
if (!checked.ok) {
|
|
493
|
+
const code = checked.error.startsWith("Unknown tool") ? -32601 : -32602;
|
|
494
|
+
return sendError(id, code, checked.error);
|
|
495
|
+
}
|
|
496
|
+
const input = checked.value;
|
|
393
497
|
let text = "";
|
|
394
498
|
// ── Read-only contract helpers ─────────────────────────────────────────
|
|
395
499
|
if (name === "infernoflow_check") {
|
|
396
|
-
text =
|
|
500
|
+
text = runCli(["check"]);
|
|
397
501
|
} else if (name === "infernoflow_status") {
|
|
398
|
-
text =
|
|
502
|
+
text = runCli(["status"]);
|
|
399
503
|
} else if (name === "infernoflow_context") {
|
|
400
|
-
const
|
|
401
|
-
if (input.intent)
|
|
402
|
-
if (input.working)
|
|
403
|
-
text =
|
|
504
|
+
const args = ["context"];
|
|
505
|
+
if (input.intent) args.push("--intent", asCliText(input.intent));
|
|
506
|
+
if (input.working) args.push("--working", asCliText(input.working));
|
|
507
|
+
text = runCli(args);
|
|
404
508
|
} else if (name === "infernoflow_git_drift") {
|
|
405
|
-
text = detectGitDrift(input.sinceCommits
|
|
509
|
+
text = detectGitDrift(input.sinceCommits ?? 1);
|
|
406
510
|
|
|
407
511
|
// ── AMP-spec memory tools ──────────────────────────────────────────────
|
|
408
512
|
} else if (name === "amp_read") {
|
|
409
|
-
const args = [];
|
|
410
|
-
if (input.query) args.push(
|
|
513
|
+
const args = ["ask"];
|
|
514
|
+
if (input.query) args.push(asCliText(input.query));
|
|
411
515
|
if (input.type) args.push("--type", input.type);
|
|
412
516
|
if (input.limit) args.push("--limit", String(input.limit));
|
|
413
|
-
text =
|
|
517
|
+
text = runCli(args);
|
|
414
518
|
} else if (name === "amp_write") {
|
|
415
519
|
// Prefer in-process write: no subprocess, no `npx` version skew, and
|
|
416
520
|
// file/line/tags reach disk unchanged. The CLI fallback below is only
|
|
@@ -437,7 +541,10 @@ function handleTool(id, name, input) {
|
|
|
437
541
|
// write dirties tracked files and blocks `git checkout`. Within a
|
|
438
542
|
// session, the agent uses amp_read for fresh queries; rule files
|
|
439
543
|
// are for cold-start injection of the *next* session.
|
|
440
|
-
|
|
544
|
+
// D3 (0.45.0): always say which store was written, so a wrong store is visible.
|
|
545
|
+
text = (typeof ampIo.describeStore === "function" ? ampIo.describeStore(PROJECT_DIR) + "\n" : "") +
|
|
546
|
+
`✔ Logged [${written.type}] ${written.id}\n msg: ${written.msg}` +
|
|
547
|
+
(written.meta && written.meta.redacted ? `\n ⚠ secrets redacted: ${written.meta.redacted.join(", ")}` : "") +
|
|
441
548
|
(written.file ? `\n file: ${written.file}${written.line ? ":" + written.line : ""}` : "") +
|
|
442
549
|
(written.tags ? `\n tags: ${written.tags.join(", ")}` : "") +
|
|
443
550
|
(written.meta && written.meta.detailRef ? `\n detail: ${written.meta.detailRef}` : "");
|
|
@@ -445,15 +552,13 @@ function handleTool(id, name, input) {
|
|
|
445
552
|
return sendError(id, -32000, `amp_write failed (in-process): ${err.message}`);
|
|
446
553
|
}
|
|
447
554
|
} else {
|
|
448
|
-
// Fallback:
|
|
449
|
-
// they're not silently dropped
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (input.
|
|
454
|
-
|
|
455
|
-
if (input.tags && input.tags.length) extras.push("--tags", JSON.stringify(input.tags.join(",")));
|
|
456
|
-
text = runCmd(`log ${m} --type ${t} ${extras.join(" ")}`);
|
|
555
|
+
// Fallback: run the CLI (argument array, no shell). Pass
|
|
556
|
+
// --file/--line/--tags through so they're not silently dropped.
|
|
557
|
+
const args = ["log", asCliText(input.msg), "--type", input.type || "note"];
|
|
558
|
+
if (input.file) args.push("--file", asCliText(input.file));
|
|
559
|
+
if (input.line) args.push("--line", String(input.line));
|
|
560
|
+
if (input.tags && input.tags.length) args.push("--tags", input.tags.map(asCliText).join(","));
|
|
561
|
+
text = runCli(args);
|
|
457
562
|
}
|
|
458
563
|
} else if (name === "amp_bookmark") {
|
|
459
564
|
// A bookmark is a `note` entry tagged "bookmark"; the optional `note`
|
|
@@ -475,23 +580,26 @@ function handleTool(id, name, input) {
|
|
|
475
580
|
if (input.note && String(input.note).trim()) {
|
|
476
581
|
entry.detail = String(input.note);
|
|
477
582
|
} else if (harvestSnapshot) {
|
|
478
|
-
|
|
583
|
+
// Automatic transcript snapshots stay on this machine (gitignored store).
|
|
584
|
+
try { const snap = harvestSnapshot(PROJECT_DIR); if (snap) { entry.detail = snap; entry.detailLocal = true; } } catch { /* best-effort */ }
|
|
479
585
|
}
|
|
480
586
|
try {
|
|
481
587
|
const written = ampIo.appendEntry(PROJECT_DIR, entry);
|
|
482
|
-
text =
|
|
588
|
+
text = (typeof ampIo.describeStore === "function" ? ampIo.describeStore(PROJECT_DIR) + "\n" : "") +
|
|
589
|
+
`🔖 Bookmark saved: ${written.msg} (${written.id})` +
|
|
590
|
+
(written.meta && written.meta.redacted ? `\n ⚠ secrets redacted: ${written.meta.redacted.join(", ")}` : "") +
|
|
483
591
|
(written.meta && written.meta.detailRef ? `\n context: ${written.meta.detailRef}` : "");
|
|
484
592
|
} catch (err) {
|
|
485
593
|
return sendError(id, -32000, `amp_bookmark failed (in-process): ${err.message}`);
|
|
486
594
|
}
|
|
487
595
|
} else {
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
text =
|
|
596
|
+
const args = ["bookmark", asCliText(input.label)];
|
|
597
|
+
if (input.note) args.push("--note", asCliText(input.note));
|
|
598
|
+
text = runCli(args);
|
|
491
599
|
}
|
|
492
600
|
} else if (name === "amp_handoff") {
|
|
493
601
|
// switch writes a file; we read it back to return the content
|
|
494
|
-
const switchResult =
|
|
602
|
+
const switchResult = runCli(["switch"]);
|
|
495
603
|
if (isCmdError(switchResult)) {
|
|
496
604
|
return sendError(id, -32000, `infernoflow switch failed: ${switchResult.message}\n${switchResult.stderr || switchResult.stdout || ""}`.trim());
|
|
497
605
|
}
|
|
@@ -508,20 +616,20 @@ function handleTool(id, name, input) {
|
|
|
508
616
|
text = "(handoff generated; could not read back: " + err.message + ")";
|
|
509
617
|
}
|
|
510
618
|
} else if (name === "amp_search") {
|
|
511
|
-
const args = [
|
|
619
|
+
const args = ["ask", asCliText(input.query)];
|
|
512
620
|
if (input.type) args.push("--type", input.type);
|
|
513
|
-
text =
|
|
621
|
+
text = runCli(args);
|
|
514
622
|
} else if (name === "amp_health") {
|
|
515
|
-
const recap =
|
|
623
|
+
const recap = runCli(["recap", "--json"]);
|
|
516
624
|
if (isCmdError(recap)) {
|
|
517
|
-
text =
|
|
625
|
+
text = runCli(["status"]);
|
|
518
626
|
} else {
|
|
519
|
-
text = recap.trim() ||
|
|
627
|
+
text = recap.trim() || runCli(["status"]);
|
|
520
628
|
}
|
|
521
629
|
|
|
522
630
|
} else { return sendError(id, -32601, `Unknown tool: ${name}`); }
|
|
523
631
|
|
|
524
|
-
// Central error check — if any
|
|
632
|
+
// Central error check — if any runCli() call produced a structured error,
|
|
525
633
|
// surface it as a real JSON-RPC error so the calling AI sees a proper
|
|
526
634
|
// failure instead of garbled stderr text mixed into a "successful" reply.
|
|
527
635
|
if (isCmdError(text)) {
|
package/package.json
CHANGED