micro-models-agent 0.51.1 → 0.52.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/cli/commands.js +162 -38
- package/dist/cli/completer.js +5 -5
- package/dist/cli/main.js +42 -54
- package/dist/cli/repl-commands.js +138 -38
- package/dist/cli/repl.js +175 -89
- package/dist/cli/run-result.js +11 -0
- package/dist/cli/security-commands.js +6 -6
- package/dist/cli/setup.js +21 -15
- package/dist/config/config.js +54 -27
- package/dist/config/defaults.js +17 -0
- package/dist/config/domains.js +179 -0
- package/dist/config/index.js +2 -1
- package/dist/config/security.js +28 -8
- package/dist/core/agent.js +162 -30
- package/dist/core/bootstrap.js +94 -17
- package/dist/core/crash-handler.js +51 -0
- package/dist/core/environment.js +199 -0
- package/dist/core/session-logger.js +60 -6
- package/dist/core/version.js +2 -0
- package/dist/i18n/en.json +120 -39
- package/dist/i18n/ru.json +91 -10
- package/dist/llm/openai-compat.js +191 -53
- package/dist/llm/orchestrator.js +5 -3
- package/dist/logger/app-logger.js +50 -4
- package/dist/main.js +1288 -635
- package/dist/modules/browser/session.js +4 -0
- package/dist/modules/certification/cli.js +58 -19
- package/dist/modules/certification/loader.js +2 -1
- package/dist/modules/certification/manifest.js +22 -14
- package/dist/modules/certification/runner.js +91 -5
- package/dist/modules/certification/scenarios.js +290 -7
- package/dist/modules/context/fact-extractor.js +6 -0
- package/dist/modules/context/manager.js +19 -2
- package/dist/modules/execution/audit-runners.js +61 -7
- package/dist/modules/execution/execution-plugin.js +219 -60
- package/dist/modules/execution/module.js +207 -18
- package/dist/modules/execution/moe-executor.js +33 -20
- package/dist/modules/execution/plan-store.js +39 -0
- package/dist/modules/execution/plan-tool.js +188 -19
- package/dist/modules/execution/planner.js +27 -23
- package/dist/modules/execution/stuck-detector.js +244 -8
- package/dist/modules/execution/tracker.js +8 -6
- package/dist/modules/execution/verifier.js +15 -2
- package/dist/modules/hallucination/detector.js +4 -0
- package/dist/modules/hallucination/factual.js +45 -5
- package/dist/modules/indexer/module.js +1 -0
- package/dist/modules/lsp/client.js +123 -12
- package/dist/modules/lsp/index.js +1 -1
- package/dist/modules/lsp/module.js +30 -2
- package/dist/modules/lsp/probe.js +11 -1
- package/dist/modules/lsp/startup-check.js +5 -2
- package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
- package/dist/modules/plugins/manager.js +57 -13
- package/dist/modules/pricing/index.js +61 -0
- package/dist/modules/pricing/prices.js +129 -0
- package/dist/modules/providers/create.js +22 -0
- package/dist/modules/providers/fallback.js +79 -0
- package/dist/modules/providers/health.js +46 -0
- package/dist/modules/providers/index.js +5 -0
- package/dist/modules/providers/manager.js +161 -0
- package/dist/modules/providers/presets.js +128 -0
- package/dist/modules/providers/registry.js +22 -0
- package/dist/modules/providers/types.js +1 -0
- package/dist/modules/registry.js +1 -0
- package/dist/modules/security/command-validator.js +14 -0
- package/dist/modules/security/encryption.js +6 -6
- package/dist/modules/security/network-validator.js +17 -0
- package/dist/modules/security/path-validator.js +22 -26
- package/dist/modules/session/store.js +10 -10
- package/dist/tools/approve.js +1 -0
- package/dist/tools/attach-image.js +12 -0
- package/dist/tools/bash.js +27 -4
- package/dist/tools/browser.js +1 -0
- package/dist/tools/chunk-query.js +1 -0
- package/dist/tools/create-dir.js +1 -0
- package/dist/tools/delete-file.js +1 -0
- package/dist/tools/download-file.js +1 -0
- package/dist/tools/edit-file.js +2 -1
- package/dist/tools/enable-tools.js +1 -0
- package/dist/tools/executor.js +17 -7
- package/dist/tools/file-info.js +1 -0
- package/dist/tools/glob-tool.js +1 -0
- package/dist/tools/grep-tool.js +54 -13
- package/dist/tools/list-dir.js +1 -0
- package/dist/tools/load-skill.js +1 -0
- package/dist/tools/mcp-call.js +1 -0
- package/dist/tools/move-file.js +1 -0
- package/dist/tools/path-utils.js +51 -1
- package/dist/tools/pipeline-run.js +1 -0
- package/dist/tools/process-kill.js +11 -0
- package/dist/tools/process-list.js +1 -0
- package/dist/tools/process-log.js +9 -0
- package/dist/tools/question.js +1 -0
- package/dist/tools/read-file.js +94 -6
- package/dist/tools/recall.js +1 -0
- package/dist/tools/remember.js +1 -0
- package/dist/tools/scope-check.js +7 -5
- package/dist/tools/search-history.js +1 -0
- package/dist/tools/subagent.js +4 -4
- package/dist/tools/web-browse.js +1 -0
- package/dist/tools/web-fetch.js +27 -6
- package/dist/tools/web-search.js +70 -43
- package/dist/tools/write-file.js +1 -0
- package/dist/ui/line-editor.js +142 -23
- package/dist/ui/line-math.js +8 -4
- package/dist/ui/renderer.js +57 -7
- package/package.json +50 -48
package/dist/tools/grep-tool.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
2
|
import { resolve } from "path";
|
|
3
3
|
import { t } from "../i18n/index";
|
|
4
4
|
import { logBashCommand } from "../modules/security/audit-log";
|
|
@@ -10,8 +10,50 @@ function truncateLines(output) {
|
|
|
10
10
|
return (lines.slice(0, MAX_PREVIEW_LINES).join("\n") +
|
|
11
11
|
`\n... (${lines.length - MAX_PREVIEW_LINES} more lines)`);
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Run a search command asynchronously with kill-on-timeout.
|
|
15
|
+
* Exit code 1 (no matches) resolves to an empty string, like execFileSync semantics.
|
|
16
|
+
*/
|
|
17
|
+
export function runSearch(bin, args, cwd, timeoutMs = 30000) {
|
|
18
|
+
return new Promise((resolvePromise, reject) => {
|
|
19
|
+
const child = spawn(bin, args, { cwd, windowsHide: true });
|
|
20
|
+
let out = "";
|
|
21
|
+
let err = "";
|
|
22
|
+
let timedOut = false;
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
timedOut = true;
|
|
25
|
+
child.kill();
|
|
26
|
+
}, timeoutMs);
|
|
27
|
+
child.stdout?.on("data", (d) => {
|
|
28
|
+
out += d;
|
|
29
|
+
});
|
|
30
|
+
child.stderr?.on("data", (d) => {
|
|
31
|
+
err += d;
|
|
32
|
+
});
|
|
33
|
+
child.on("error", (e) => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
reject(e);
|
|
36
|
+
});
|
|
37
|
+
child.on("close", (code) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
if (timedOut) {
|
|
40
|
+
reject(new Error(`search timed out after ${timeoutMs}ms`));
|
|
41
|
+
}
|
|
42
|
+
else if (code === 1) {
|
|
43
|
+
resolvePromise("");
|
|
44
|
+
}
|
|
45
|
+
else if (code === 0) {
|
|
46
|
+
resolvePromise(out);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
reject(new Error(err || `exit ${code}`));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
13
54
|
export const grepTool = {
|
|
14
55
|
name: "grep",
|
|
56
|
+
icon: "🔍",
|
|
15
57
|
description: `Search file contents using a regular expression. Shows up to ${MAX_PREVIEW_LINES} matching lines by default. Uses ripgrep (rg) if available, otherwise falls back to grep -r.`,
|
|
16
58
|
tags: ["file", "code", "research"],
|
|
17
59
|
parameters: {
|
|
@@ -41,31 +83,30 @@ export const grepTool = {
|
|
|
41
83
|
// Log the search (sanitized) for audit purposes
|
|
42
84
|
logBashCommand(ctx.sessionId, `rg ${rgArgs.join(" ")}`, false, "grep-tool");
|
|
43
85
|
try {
|
|
44
|
-
const output =
|
|
45
|
-
encoding: "utf-8",
|
|
46
|
-
maxBuffer: 1024 * 1024,
|
|
47
|
-
cwd: ctx.baseDir,
|
|
48
|
-
});
|
|
86
|
+
const output = await runSearch("rg", rgArgs, ctx.baseDir);
|
|
49
87
|
return {
|
|
50
88
|
success: true,
|
|
51
89
|
output: truncateLines(output || t("file.no_matches")),
|
|
52
90
|
};
|
|
53
91
|
}
|
|
54
92
|
catch (e) {
|
|
55
|
-
if (e.
|
|
93
|
+
if (e.code === "ENOENT") {
|
|
94
|
+
// fall through to grep fallback
|
|
95
|
+
}
|
|
96
|
+
else if (/timed out/.test(e.message)) {
|
|
97
|
+
return { success: false, output: t("error.grep_failed", { message: e.message }) };
|
|
98
|
+
}
|
|
99
|
+
else if (e.status === 1) {
|
|
56
100
|
return { success: true, output: t("file.no_matches") };
|
|
101
|
+
}
|
|
57
102
|
// Fall back to plain grep -r when rg is not available or fails.
|
|
58
|
-
// Using
|
|
103
|
+
// Using array args avoids shell injection.
|
|
59
104
|
try {
|
|
60
105
|
const grepArgs = ["-rn", pattern, searchPath];
|
|
61
106
|
if (args.include) {
|
|
62
107
|
grepArgs.push("--include", String(args.include));
|
|
63
108
|
}
|
|
64
|
-
const output =
|
|
65
|
-
encoding: "utf-8",
|
|
66
|
-
maxBuffer: 1024 * 1024,
|
|
67
|
-
cwd: ctx.baseDir,
|
|
68
|
-
});
|
|
109
|
+
const output = await runSearch("grep", grepArgs, ctx.baseDir);
|
|
69
110
|
return {
|
|
70
111
|
success: true,
|
|
71
112
|
output: truncateLines(output || t("file.no_matches")),
|
package/dist/tools/list-dir.js
CHANGED
|
@@ -6,6 +6,7 @@ import { safeResolvePath } from "./path-utils";
|
|
|
6
6
|
import { MAX_PREVIEW_LINES } from "./preview";
|
|
7
7
|
export const listDirTool = {
|
|
8
8
|
name: "list_dir",
|
|
9
|
+
icon: "📂",
|
|
9
10
|
description: `List files and directories in a given path. Shows up to ${MAX_PREVIEW_LINES} entries by default.`,
|
|
10
11
|
tags: ["file"],
|
|
11
12
|
boundedOutput: true,
|
package/dist/tools/load-skill.js
CHANGED
|
@@ -2,6 +2,7 @@ import { t } from "../i18n/index";
|
|
|
2
2
|
export function createLoadSkillTool(skillsModule) {
|
|
3
3
|
return {
|
|
4
4
|
name: "load_skill",
|
|
5
|
+
icon: "🎯",
|
|
5
6
|
tags: ["core"],
|
|
6
7
|
description: "Load a skill by name. Skills provide specialized instructions and workflows. When loaded, the skill content is injected into the system prompt (not conversation history) so it persists through context compaction. Only use when the task clearly requires domain-specific knowledge.",
|
|
7
8
|
parameters: {
|
package/dist/tools/mcp-call.js
CHANGED
|
@@ -24,6 +24,7 @@ function ensureInitialized(ctx) {
|
|
|
24
24
|
}
|
|
25
25
|
export const mcpCallTool = {
|
|
26
26
|
name: "mcp_call",
|
|
27
|
+
icon: "🔗",
|
|
27
28
|
description: "Call a tool on an MCP (Model Context Protocol) server. MCP servers provide external capabilities like databases, APIs, or specialized tools.",
|
|
28
29
|
tags: ["code", "research"],
|
|
29
30
|
parameters: {
|
package/dist/tools/move-file.js
CHANGED
package/dist/tools/path-utils.js
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
|
-
import { resolve, normalize, dirname, basename, sep } from "path";
|
|
1
|
+
import { resolve, normalize, dirname, basename, sep, relative, isAbsolute } from "path";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
|
+
/**
|
|
4
|
+
* True when targetPath equals dirPath or lies inside it.
|
|
5
|
+
*
|
|
6
|
+
* Cross-platform notes:
|
|
7
|
+
* - Uses path.relative() instead of raw startsWith, so a sibling directory
|
|
8
|
+
* sharing the string prefix ("src" vs "src-other") can never pass.
|
|
9
|
+
* - On Windows the filesystem is case-insensitive: both sides are lowercased
|
|
10
|
+
* before comparison so "E:\Proj" and "e:\proj" resolve to the same dir.
|
|
11
|
+
*/
|
|
12
|
+
export function isInsideDir(targetPath, dirPath) {
|
|
13
|
+
const norm = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
|
|
14
|
+
const t = norm(resolve(targetPath));
|
|
15
|
+
const d = norm(resolve(dirPath));
|
|
16
|
+
if (t === d)
|
|
17
|
+
return true;
|
|
18
|
+
const rel = relative(d, t);
|
|
19
|
+
return rel.length > 0 && !isAbsolute(rel) && !rel.startsWith("..");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* True when targetPath matches a scope entry: either the entry file itself or
|
|
23
|
+
* anything inside the entry directory. Separator- and case-safe (see
|
|
24
|
+
* isInsideDir).
|
|
25
|
+
*/
|
|
26
|
+
export function matchesScopeEntry(targetResolved, entryResolved) {
|
|
27
|
+
if (targetResolved === entryResolved)
|
|
28
|
+
return true;
|
|
29
|
+
return isInsideDir(targetResolved, entryResolved);
|
|
30
|
+
}
|
|
3
31
|
/**
|
|
4
32
|
* Resolve a user-provided path against baseDir.
|
|
5
33
|
*
|
|
@@ -12,7 +40,29 @@ import { existsSync } from "fs";
|
|
|
12
40
|
* the full path so write_file still works.
|
|
13
41
|
*/
|
|
14
42
|
export function safeResolvePath(baseDir, userPath) {
|
|
43
|
+
// Absolute path that actually exists — honor it as-is
|
|
44
|
+
const asIs = resolve(normalize(userPath));
|
|
45
|
+
if (userPath.startsWith("/") || userPath.startsWith("\\")) {
|
|
46
|
+
if (existsSync(asIs) || existsSync(dirname(asIs)))
|
|
47
|
+
return asIs;
|
|
48
|
+
}
|
|
15
49
|
const norm = normalize(userPath);
|
|
50
|
+
// Absolute paths are used verbatim — do NOT strip a leading slash and
|
|
51
|
+
// re-resolve against baseDir (that would turn /var/folders/... into
|
|
52
|
+
// baseDir/var/folders/... on POSIX). The leading-slash heuristic below
|
|
53
|
+
// only applies as a fallback when the absolute path does not resolve.
|
|
54
|
+
if (isAbsolute(norm)) {
|
|
55
|
+
if (existsSync(norm) || existsSync(dirname(norm)))
|
|
56
|
+
return norm;
|
|
57
|
+
// Absolute but not on disk yet (e.g. a new file whose dirs don't exist).
|
|
58
|
+
// Model mistake recovery: a leading slash may mean "relative to baseDir".
|
|
59
|
+
const stripped = norm.replace(/^[/\\]/, "");
|
|
60
|
+
const relativeCandidate = resolve(baseDir, stripped);
|
|
61
|
+
if (existsSync(relativeCandidate) || existsSync(dirname(relativeCandidate))) {
|
|
62
|
+
return relativeCandidate;
|
|
63
|
+
}
|
|
64
|
+
return norm;
|
|
65
|
+
}
|
|
16
66
|
const stripped = norm.replace(/^[/\\]/, "");
|
|
17
67
|
const resolved = resolve(baseDir, stripped);
|
|
18
68
|
// Fast path: if it exists (or parent does for new files), return it
|
|
@@ -53,6 +53,7 @@ async function runStep(ctx, step, params, outputs) {
|
|
|
53
53
|
}
|
|
54
54
|
export const pipelineRunTool = {
|
|
55
55
|
name: "pipeline_run",
|
|
56
|
+
icon: "▶️",
|
|
56
57
|
description: "Run a named pipeline with YAML definition. Creates a DAG of sub-agents that execute in dependency order.",
|
|
57
58
|
tags: ["shell", "code"],
|
|
58
59
|
parameters: {
|
|
@@ -2,6 +2,7 @@ import { processRegistry } from "../modules/processes";
|
|
|
2
2
|
import { t } from "../i18n/index";
|
|
3
3
|
export const processKillTool = {
|
|
4
4
|
name: "process_kill",
|
|
5
|
+
icon: "⛔",
|
|
5
6
|
description: "Stop a background process started via bash (dev server, watcher). Kills the whole process tree (children included). Use the id returned by bash or process_list.",
|
|
6
7
|
tags: ["shell"],
|
|
7
8
|
parameters: {
|
|
@@ -12,6 +13,16 @@ export const processKillTool = {
|
|
|
12
13
|
required: ["id"],
|
|
13
14
|
},
|
|
14
15
|
handler: async (ctx, args) => {
|
|
16
|
+
if (args.id === undefined || String(args.id).trim() === "") {
|
|
17
|
+
// ses_mt4fn58c: `process_kill -p=5173` produced "Process not found:
|
|
18
|
+
// undefined" — no hint about the correct format. Answer with usage.
|
|
19
|
+
const known = processRegistry.list();
|
|
20
|
+
const ids = known.map((p) => p.id);
|
|
21
|
+
const listPart = ids.length
|
|
22
|
+
? `\n${t("proc.list_header")}: ${ids.join(", ")}`
|
|
23
|
+
: `\n${t("proc.missing_id_list")}`;
|
|
24
|
+
return { success: false, output: `${t("proc.missing_id")}${listPart}` };
|
|
25
|
+
}
|
|
15
26
|
const id = String(args.id);
|
|
16
27
|
const entry = processRegistry.get(id);
|
|
17
28
|
if (!entry) {
|
|
@@ -2,6 +2,7 @@ import { processRegistry } from "../modules/processes";
|
|
|
2
2
|
import { t } from "../i18n/index";
|
|
3
3
|
export const processListTool = {
|
|
4
4
|
name: "process_list",
|
|
5
|
+
icon: "📊",
|
|
5
6
|
description: "List background processes started via the bash tool (dev servers, watchers, long-running commands). Shows id, pid, command, status, and recent output. Use with process_log and process_kill to inspect or stop them.",
|
|
6
7
|
tags: ["shell"],
|
|
7
8
|
parameters: {
|
|
@@ -4,6 +4,7 @@ import { MAX_PREVIEW_LINES } from "./preview";
|
|
|
4
4
|
const DEFAULT_TAIL = MAX_PREVIEW_LINES;
|
|
5
5
|
export const processLogTool = {
|
|
6
6
|
name: "process_log",
|
|
7
|
+
icon: "📜",
|
|
7
8
|
description: `Show the buffered output of a background process started via bash. Shows the last ${DEFAULT_TAIL} lines by default. Use after starting a dev server to verify it came up without errors, and while it runs to check its state.`,
|
|
8
9
|
tags: ["shell"],
|
|
9
10
|
boundedOutput: true,
|
|
@@ -19,6 +20,14 @@ export const processLogTool = {
|
|
|
19
20
|
required: ["id"],
|
|
20
21
|
},
|
|
21
22
|
handler: async (ctx, args) => {
|
|
23
|
+
if (args.id === undefined || String(args.id).trim() === "") {
|
|
24
|
+
const known = processRegistry.list();
|
|
25
|
+
const ids = known.map((p) => p.id);
|
|
26
|
+
const listPart = ids.length
|
|
27
|
+
? `\n${t("proc.list_header")}: ${ids.join(", ")}`
|
|
28
|
+
: `\n${t("proc.missing_id_list")}`;
|
|
29
|
+
return { success: false, output: `${t("proc.missing_id")}${listPart}` };
|
|
30
|
+
}
|
|
22
31
|
const id = String(args.id);
|
|
23
32
|
const entry = processRegistry.get(id);
|
|
24
33
|
if (!entry) {
|
package/dist/tools/question.js
CHANGED
package/dist/tools/read-file.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "fs";
|
|
1
|
+
import { readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "fs";
|
|
2
2
|
import { extname } from "path";
|
|
3
3
|
import { t } from "../i18n/index";
|
|
4
4
|
import { isPathInScope } from "../modules/security/path-validator";
|
|
@@ -14,8 +14,75 @@ import { safeResolvePath } from "./path-utils";
|
|
|
14
14
|
* covers typical component files in one call while still capping output.
|
|
15
15
|
*/
|
|
16
16
|
const DEFAULT_LIMIT = 300;
|
|
17
|
+
/**
|
|
18
|
+
* Files larger than this are read through a bounded streaming path instead of
|
|
19
|
+
* readFileSync (which loads the whole file into memory just to slice a few
|
|
20
|
+
* hundred lines).
|
|
21
|
+
*/
|
|
22
|
+
const LARGE_FILE_BYTES = 1024 * 1024;
|
|
23
|
+
const STREAM_CHUNK = 256 * 1024;
|
|
24
|
+
/**
|
|
25
|
+
* Bounded line-slice read for large files: streams the file in chunks,
|
|
26
|
+
* collects only the requested [offset, offset+limit) window, then counts the
|
|
27
|
+
* remaining newlines raw (no decode) to produce the total. Memory stays
|
|
28
|
+
* bounded regardless of file size.
|
|
29
|
+
*/
|
|
30
|
+
function readLineSlice(path, offset, limit) {
|
|
31
|
+
const fd = openSync(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
const buf = Buffer.alloc(STREAM_CHUNK);
|
|
34
|
+
let pos = 0;
|
|
35
|
+
let carry = "";
|
|
36
|
+
let lineNumber = 1;
|
|
37
|
+
let total = 0;
|
|
38
|
+
const collected = [];
|
|
39
|
+
const endLine = offset + limit; // exclusive
|
|
40
|
+
while (true) {
|
|
41
|
+
const bytes = readSync(fd, buf, 0, STREAM_CHUNK, pos);
|
|
42
|
+
if (bytes === 0)
|
|
43
|
+
break;
|
|
44
|
+
pos += bytes;
|
|
45
|
+
const chunk = carry + buf.toString("utf-8", 0, bytes);
|
|
46
|
+
const parts = chunk.split("\n");
|
|
47
|
+
carry = parts.pop() ?? "";
|
|
48
|
+
for (const line of parts) {
|
|
49
|
+
total++;
|
|
50
|
+
if (lineNumber >= offset && lineNumber < endLine)
|
|
51
|
+
collected.push(line);
|
|
52
|
+
lineNumber++;
|
|
53
|
+
}
|
|
54
|
+
if (lineNumber >= endLine && collected.length >= limit) {
|
|
55
|
+
// Window complete — count remaining newlines without decoding.
|
|
56
|
+
const rest = [];
|
|
57
|
+
while (true) {
|
|
58
|
+
const n = readSync(fd, buf, 0, STREAM_CHUNK, pos);
|
|
59
|
+
if (n === 0)
|
|
60
|
+
break;
|
|
61
|
+
pos += n;
|
|
62
|
+
rest.push(Buffer.from(buf.subarray(0, n)));
|
|
63
|
+
}
|
|
64
|
+
const tail = Buffer.concat([...rest, Buffer.from(carry, "utf-8")]);
|
|
65
|
+
for (let i = 0; i < tail.length; i++)
|
|
66
|
+
if (tail[i] === 0x0a)
|
|
67
|
+
total++;
|
|
68
|
+
total++; // last line has no trailing newline
|
|
69
|
+
return { selected: collected.join("\n"), total };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (carry.length > 0 || total === 0) {
|
|
73
|
+
total++;
|
|
74
|
+
if (lineNumber >= offset && lineNumber < endLine)
|
|
75
|
+
collected.push(carry);
|
|
76
|
+
}
|
|
77
|
+
return { selected: collected.join("\n"), total };
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
closeSync(fd);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
17
83
|
export const readFileTool = {
|
|
18
84
|
name: "read_file",
|
|
85
|
+
icon: "👀",
|
|
19
86
|
description: `Read a file from the filesystem. Reads up to ${DEFAULT_LIMIT} lines at a time by default; use offset to page through large files.`,
|
|
20
87
|
tags: ["file", "code"],
|
|
21
88
|
boundedOutput: true,
|
|
@@ -60,13 +127,24 @@ export const readFileTool = {
|
|
|
60
127
|
: t("file.notfound", { path });
|
|
61
128
|
return { success: false, output };
|
|
62
129
|
}
|
|
63
|
-
const content = readFileSync(resolved, "utf-8");
|
|
64
|
-
const lines = content.split("\n");
|
|
65
|
-
const total = lines.length;
|
|
66
130
|
const offset = args.offset || 1;
|
|
67
131
|
const limit = args.limit || DEFAULT_LIMIT;
|
|
132
|
+
let lines;
|
|
133
|
+
let total;
|
|
134
|
+
let selected;
|
|
135
|
+
if (statSync(resolved).size > LARGE_FILE_BYTES) {
|
|
136
|
+
const slice = readLineSlice(resolved, offset, limit);
|
|
137
|
+
total = slice.total;
|
|
138
|
+
selected = slice.selected;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const content = readFileSync(resolved, "utf-8");
|
|
142
|
+
lines = content.split("\n");
|
|
143
|
+
total = lines.length;
|
|
144
|
+
const end = Math.min(offset - 1 + limit, total);
|
|
145
|
+
selected = lines.slice(offset - 1, end).join("\n");
|
|
146
|
+
}
|
|
68
147
|
const end = Math.min(offset - 1 + limit, total);
|
|
69
|
-
const selected = lines.slice(offset - 1, end).join("\n");
|
|
70
148
|
const ext = extname(resolved) || "(no extension)";
|
|
71
149
|
const header = t("file.read_header", {
|
|
72
150
|
path,
|
|
@@ -81,7 +159,17 @@ export const readFileTool = {
|
|
|
81
159
|
next: String(end + 1),
|
|
82
160
|
})
|
|
83
161
|
: "";
|
|
84
|
-
|
|
162
|
+
// Compact user-facing display: the tool header already shows the path,
|
|
163
|
+
// so only a partial read needs extra info (the range within the file).
|
|
164
|
+
// A full read adds nothing — keeps the card to two lines.
|
|
165
|
+
const fullRead = offset === 1 && end >= total;
|
|
166
|
+
const display = fullRead
|
|
167
|
+
? ""
|
|
168
|
+
: t("file.read_range", {
|
|
169
|
+
from: String(offset),
|
|
170
|
+
to: String(end),
|
|
171
|
+
total: String(total),
|
|
172
|
+
});
|
|
85
173
|
return {
|
|
86
174
|
success: true,
|
|
87
175
|
output: `${header}\n${selected}${truncated}`,
|
package/dist/tools/recall.js
CHANGED
|
@@ -5,6 +5,7 @@ import { MemoryStore } from "../modules/memory/store";
|
|
|
5
5
|
const CATEGORIES = ["preferences", "conventions", "decisions", "errors", "facts"];
|
|
6
6
|
export const recallTool = {
|
|
7
7
|
name: "recall",
|
|
8
|
+
icon: "💾",
|
|
8
9
|
description: "Recall stored information from memory. Search across preferences, facts, conventions, decisions, errors.",
|
|
9
10
|
tags: ["memory"],
|
|
10
11
|
parameters: {
|
package/dist/tools/remember.js
CHANGED
|
@@ -5,6 +5,7 @@ import { MemoryStore } from "../modules/memory/store";
|
|
|
5
5
|
const CATEGORIES = ["preferences", "conventions", "decisions", "errors", "facts"];
|
|
6
6
|
export const rememberTool = {
|
|
7
7
|
name: "remember",
|
|
8
|
+
icon: "🧠",
|
|
8
9
|
description: "Remember information across sessions. Use for user preferences, facts, conventions, decisions, or errors.",
|
|
9
10
|
tags: ["memory"],
|
|
10
11
|
parameters: {
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { resolve, normalize } from "path";
|
|
2
|
+
import { isInsideDir, matchesScopeEntry } from "./path-utils";
|
|
2
3
|
export function isPathInScope(baseDir, targetPath, scope) {
|
|
3
4
|
const baseResolved = resolve(baseDir);
|
|
4
5
|
const targetResolved = resolve(baseDir, normalize(targetPath));
|
|
5
|
-
|
|
6
|
+
// Separator- and case-safe containment (sibling prefixes cannot pass).
|
|
7
|
+
if (!isInsideDir(targetResolved, baseResolved)) {
|
|
6
8
|
return { allowed: false, reason: "Path is outside the base working directory" };
|
|
7
9
|
}
|
|
8
10
|
if (!scope)
|
|
9
11
|
return { allowed: true };
|
|
10
|
-
const isRead = scope.read_only_files.some((f) => targetResolved
|
|
11
|
-
const isWrite = scope.allowed_files.some((f) => targetResolved
|
|
12
|
+
const isRead = scope.read_only_files.some((f) => matchesScopeEntry(targetResolved, resolve(baseDir, f)));
|
|
13
|
+
const isWrite = scope.allowed_files.some((f) => matchesScopeEntry(targetResolved, resolve(baseDir, f)));
|
|
12
14
|
if (isWrite)
|
|
13
15
|
return { allowed: true };
|
|
14
16
|
if (isRead)
|
|
@@ -18,12 +20,12 @@ export function isPathInScope(baseDir, targetPath, scope) {
|
|
|
18
20
|
export function isPathWritable(baseDir, targetPath, scope) {
|
|
19
21
|
const baseResolved = resolve(baseDir);
|
|
20
22
|
const targetResolved = resolve(baseDir, normalize(targetPath));
|
|
21
|
-
if (!targetResolved
|
|
23
|
+
if (!isInsideDir(targetResolved, baseResolved)) {
|
|
22
24
|
return { allowed: false, reason: "Path is outside the base working directory" };
|
|
23
25
|
}
|
|
24
26
|
if (!scope)
|
|
25
27
|
return { allowed: true };
|
|
26
|
-
const isWrite = scope.allowed_files.some((f) => targetResolved
|
|
28
|
+
const isWrite = scope.allowed_files.some((f) => matchesScopeEntry(targetResolved, resolve(baseDir, f)));
|
|
27
29
|
if (isWrite)
|
|
28
30
|
return { allowed: true };
|
|
29
31
|
return { allowed: false, reason: "Path is not within the writable scope for this sub-agent" };
|
|
@@ -22,6 +22,7 @@ function searchFile(filePath, query, maxResults, results) {
|
|
|
22
22
|
}
|
|
23
23
|
export const searchHistoryTool = {
|
|
24
24
|
name: "search_history",
|
|
25
|
+
icon: "🕵️",
|
|
25
26
|
description: "Search through session history for past interactions across all sessions.",
|
|
26
27
|
tags: ["research"],
|
|
27
28
|
parameters: {
|
package/dist/tools/subagent.js
CHANGED
|
@@ -21,6 +21,7 @@ function buildSubagentSystemPrompt(context, stable) {
|
|
|
21
21
|
}
|
|
22
22
|
export const subagentTool = {
|
|
23
23
|
name: "subagent",
|
|
24
|
+
icon: "🤖",
|
|
24
25
|
description: "Spawn an isolated sub-agent to work on a task independently. The sub-agent has its own context and executes autonomously. Use for parallel work or complex sub-tasks.",
|
|
25
26
|
tags: ["code"],
|
|
26
27
|
parameters: {
|
|
@@ -150,7 +151,6 @@ export const subagentTool = {
|
|
|
150
151
|
};
|
|
151
152
|
const subAgent = new Agent(subDeps);
|
|
152
153
|
const fullTask = context ? `${task}\n\nContext: ${context}` : task;
|
|
153
|
-
let scopeRestored = false;
|
|
154
154
|
try {
|
|
155
155
|
const result = await subAgent.run(fullTask);
|
|
156
156
|
if (!result.success) {
|
|
@@ -184,9 +184,9 @@ export const subagentTool = {
|
|
|
184
184
|
};
|
|
185
185
|
}
|
|
186
186
|
finally {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
// Restore the parent's exact scope: undefined means "no restrictions"
|
|
188
|
+
// and must not become an empty scope (which denies everything).
|
|
189
|
+
ctx.toolExecutor.setScope(parentScope);
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
catch (e) {
|
package/dist/tools/web-browse.js
CHANGED
|
@@ -6,6 +6,7 @@ import { MAX_PREVIEW_LINES } from "./preview";
|
|
|
6
6
|
const MAX_CHARS = 3000;
|
|
7
7
|
export const webBrowseTool = {
|
|
8
8
|
name: "web_browse",
|
|
9
|
+
icon: "🌐",
|
|
9
10
|
description: `Fetch and read a web page. Returns the page content as plain text (max ${MAX_PREVIEW_LINES} lines / ${MAX_CHARS} chars). This is a plain HTTP request — it does NOT execute JavaScript, so JS-rendered single-page apps (weather sites, dashboards, modern SPAs) return empty or near-empty content. For such pages use the "browser" tool instead.`,
|
|
10
11
|
tags: ["research"],
|
|
11
12
|
parameters: {
|
package/dist/tools/web-fetch.js
CHANGED
|
@@ -16,6 +16,7 @@ function stripHtml(html) {
|
|
|
16
16
|
}
|
|
17
17
|
export const webFetchTool = {
|
|
18
18
|
name: "web_fetch",
|
|
19
|
+
icon: "⬇️",
|
|
19
20
|
description: 'Fetch a URL and convert its content to markdown. Use for reading documentation, APIs, web pages. This is a plain HTTP request without a browser engine — it does NOT run JavaScript, so JS-rendered single-page apps (weather sites, dashboards, modern SPAs) return empty or near-empty content; use the "browser" tool for those.',
|
|
20
21
|
tags: ["research"],
|
|
21
22
|
parameters: {
|
|
@@ -43,20 +44,40 @@ export const webFetchTool = {
|
|
|
43
44
|
try {
|
|
44
45
|
const response = await fetch(url, {
|
|
45
46
|
signal: AbortSignal.timeout(securityConfig?.requestTimeout || 15000),
|
|
47
|
+
redirect: "manual",
|
|
46
48
|
});
|
|
47
|
-
|
|
49
|
+
let finalResponse = response;
|
|
50
|
+
let finalUrl = url;
|
|
51
|
+
// Follow one redirect level manually, re-validating the target (SSRF).
|
|
52
|
+
if (response.status >= 300 && response.status < 400) {
|
|
53
|
+
const loc = response.headers.get("location");
|
|
54
|
+
if (!loc) {
|
|
55
|
+
return { success: false, output: t("error.fetch_failed", { message: "Redirect without location" }) };
|
|
56
|
+
}
|
|
57
|
+
finalUrl = new URL(loc, url).toString();
|
|
58
|
+
const recheck = isUrlAllowed(finalUrl, securityConfig);
|
|
59
|
+
if (!recheck.allowed) {
|
|
60
|
+
logSecurityBlock(ctx.sessionId, "network_request", recheck.reason || "redirect blocked", sanitizeUrl(finalUrl));
|
|
61
|
+
return { success: false, output: `[SECURITY BLOCKED] redirect: ${recheck.reason}` };
|
|
62
|
+
}
|
|
63
|
+
finalResponse = await fetch(finalUrl, {
|
|
64
|
+
signal: AbortSignal.timeout(securityConfig?.requestTimeout || 15000),
|
|
65
|
+
redirect: "follow",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (!finalResponse.ok) {
|
|
48
69
|
return {
|
|
49
70
|
success: false,
|
|
50
71
|
output: t("error.http", {
|
|
51
|
-
status:
|
|
52
|
-
statusText:
|
|
72
|
+
status: finalResponse.status,
|
|
73
|
+
statusText: finalResponse.statusText,
|
|
53
74
|
}),
|
|
54
75
|
};
|
|
55
76
|
}
|
|
56
|
-
const contentType =
|
|
57
|
-
const text = await
|
|
77
|
+
const contentType = finalResponse.headers.get("content-type") || "";
|
|
78
|
+
const text = await finalResponse.text();
|
|
58
79
|
const cleaned = contentType.includes("html") ? stripHtml(text) : text;
|
|
59
|
-
logNetworkRequest(ctx.sessionId, sanitizeUrl(
|
|
80
|
+
logNetworkRequest(ctx.sessionId, sanitizeUrl(finalUrl), true, `Status: ${finalResponse.status}`);
|
|
60
81
|
const fullChars = cleaned.length;
|
|
61
82
|
const fullLines = cleaned.split("\n").length;
|
|
62
83
|
const maxChars = securityConfig?.maxResponseSize || MAX_CHARS;
|