portal-agent-cli 3.0.0 → 3.0.1
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/LICENSE +2 -0
- package/README.md +15 -7
- package/lib/args.mjs +3 -1
- package/lib/browser.mjs +1 -3
- package/lib/config.mjs +0 -1
- package/lib/deepthink.mjs +55 -0
- package/lib/exec.mjs +1 -30
- package/lib/instructions.mjs +1 -44
- package/lib/parse.mjs +1 -50
- package/lib/paste.mjs +0 -2
- package/lib/providers.mjs +4 -4
- package/lib/repl.mjs +1 -283
- package/lib/safety.mjs +1 -64
- package/lib/send.mjs +30 -14
- package/lib/sessions.mjs +1 -75
- package/lib/system.mjs +1 -35
- package/lib/theme.mjs +0 -7
- package/lib/tools/delete.mjs +2 -30
- package/lib/tools/edit.mjs +1 -51
- package/lib/tools/find.mjs +1 -42
- package/lib/tools/git.mjs +1 -48
- package/lib/tools/grep.mjs +1 -43
- package/lib/tools/index.mjs +1 -52
- package/lib/tools/list.mjs +1 -38
- package/lib/tools/path.mjs +1 -22
- package/lib/tools/read.mjs +1 -30
- package/lib/tools/shell.mjs +1 -41
- package/lib/tools/walk.mjs +1 -32
- package/lib/tools/write.mjs +1 -42
- package/lib/turn.mjs +1 -117
- package/package.json +3 -2
- package/portal.mjs +1 -2
package/lib/tools/shell.mjs
CHANGED
|
@@ -2,44 +2,4 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { getWorkspace } from "./path.mjs";
|
|
3
3
|
|
|
4
4
|
const MAX_OUTPUT = 65000;
|
|
5
|
-
const TIMEOUT_MS = 60000
|
|
6
|
-
|
|
7
|
-
export function shell(args) {
|
|
8
|
-
const cmd = String(args.command || "");
|
|
9
|
-
if (!cmd) return Promise.resolve("shell requires a command");
|
|
10
|
-
|
|
11
|
-
return new Promise(function (done) {
|
|
12
|
-
const ws = getWorkspace();
|
|
13
|
-
const isWin = process.platform === "win32";
|
|
14
|
-
|
|
15
|
-
const child = spawn(cmd, {
|
|
16
|
-
cwd: ws,
|
|
17
|
-
shell: isWin ? true : "/bin/sh",
|
|
18
|
-
windowsHide: true
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
let stdout = "";
|
|
22
|
-
let stderr = "";
|
|
23
|
-
|
|
24
|
-
if (child.stdout) child.stdout.on("data", function (d) { stdout += String(d); });
|
|
25
|
-
if (child.stderr) child.stderr.on("data", function (d) { stderr += String(d); });
|
|
26
|
-
|
|
27
|
-
const timer = setTimeout(function () {
|
|
28
|
-
child.kill();
|
|
29
|
-
done("timed out after " + (TIMEOUT_MS / 1000) + "s\n" + stderr);
|
|
30
|
-
}, TIMEOUT_MS);
|
|
31
|
-
|
|
32
|
-
child.on("error", function (err) {
|
|
33
|
-
clearTimeout(timer);
|
|
34
|
-
done("spawn error: " + err.message);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
child.on("close", function (code) {
|
|
38
|
-
clearTimeout(timer);
|
|
39
|
-
const both = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n");
|
|
40
|
-
let s = "exit " + (code === null ? "null" : code) + "\n" + (both || "(no output)");
|
|
41
|
-
if (s.length > MAX_OUTPUT) s = s.slice(0, MAX_OUTPUT) + "\n[truncated]";
|
|
42
|
-
done(s);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
}
|
|
5
|
+
const TIMEOUT_MS = 60000
|
package/lib/tools/walk.mjs
CHANGED
|
@@ -1,32 +1 @@
|
|
|
1
|
-
import { readdir } from "node
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
const DEFAULT_IGNORE = new Set([
|
|
5
|
-
"node_modules", ".git", ".hg", ".svn", "dist", "build", "out",
|
|
6
|
-
".next", ".nuxt", ".turbo", ".cache", "target", "__pycache__",
|
|
7
|
-
".venv", "venv", "env", "coverage", ".idea", ".vscode", ".DS_Store"
|
|
8
|
-
]);
|
|
9
|
-
|
|
10
|
-
let ignore = DEFAULT_IGNORE;
|
|
11
|
-
|
|
12
|
-
export function setIgnore(names) {
|
|
13
|
-
ignore = new Set(names || []);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// Depth-first file walker. Yields full absolute paths. Skips ignored dirs
|
|
17
|
-
// and hidden files (except .env.example which is often relevant).
|
|
18
|
-
export async function* walk(dir) {
|
|
19
|
-
let items;
|
|
20
|
-
try {
|
|
21
|
-
items = await readdir(dir, { withFileTypes: true });
|
|
22
|
-
} catch (e) {
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
for (const it of items) {
|
|
26
|
-
if (ignore.has(it.name)) continue;
|
|
27
|
-
if (it.name.startsWith(".") && it.name !== ".env.example") continue;
|
|
28
|
-
const full = join(dir, it.name);
|
|
29
|
-
if (it.isDirectory()) yield* walk(full);
|
|
30
|
-
else if (it.isFile()) yield full;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
1
|
+
import { readdir } from "node
|
package/lib/tools/write.mjs
CHANGED
|
@@ -2,45 +2,4 @@ import { writeFile, mkdir, readFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { safePath } from "./path.mjs";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
// exists, saves a backup first.
|
|
7
|
-
export async function write_file(args) {
|
|
8
|
-
const abs = safePath(args.path);
|
|
9
|
-
const content = args.content || "";
|
|
10
|
-
await mkdir(dirname(abs), { recursive: true });
|
|
11
|
-
|
|
12
|
-
// Backup existing file
|
|
13
|
-
try {
|
|
14
|
-
const existing = await readFile(abs, "utf8");
|
|
15
|
-
if (existing !== content) {
|
|
16
|
-
const backup = abs + ".portal-bak";
|
|
17
|
-
await writeFile(backup, existing, "utf8");
|
|
18
|
-
}
|
|
19
|
-
} catch (e) {
|
|
20
|
-
// new file, no backup needed
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
await writeFile(abs, content, "utf8");
|
|
24
|
-
const lines = content.length === 0 ? 0 : content.split("\n").length;
|
|
25
|
-
return "wrote " + content.length + " bytes (" + lines + " lines) to " + args.path;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Append to end of a file, creating it if missing.
|
|
29
|
-
export async function append_file(args) {
|
|
30
|
-
const abs = safePath(args.path);
|
|
31
|
-
await mkdir(dirname(abs), { recursive: true });
|
|
32
|
-
|
|
33
|
-
let existing = "";
|
|
34
|
-
try {
|
|
35
|
-
existing = await readFile(abs, "utf8");
|
|
36
|
-
} catch (e) {
|
|
37
|
-
existing = "";
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const content = args.content || "";
|
|
41
|
-
const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
42
|
-
const add = content.endsWith("\n") ? content : content + "\n";
|
|
43
|
-
|
|
44
|
-
await writeFile(abs, existing + sep + add, "utf8");
|
|
45
|
-
return "appended " + content.length + " bytes to " + args.path;
|
|
46
|
-
}
|
|
5
|
+
export async function write_file
|
package/lib/turn.mjs
CHANGED
|
@@ -1,118 +1,2 @@
|
|
|
1
1
|
import { send } from "./send.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { TOOLS, SAFE_TOOLS } from "./tools/index.mjs";
|
|
4
|
-
import { getWorkspace } from "./tools/path.mjs";
|
|
5
|
-
import { buildSystem } from "./system.mjs";
|
|
6
|
-
import { askApproval } from "./safety.mjs";
|
|
7
|
-
import { reply, tool as logTool, toolResult, status } from "./log.mjs";
|
|
8
|
-
import { theme } from "./theme.mjs";
|
|
9
|
-
|
|
10
|
-
const REPEAT_WARN = 2;
|
|
11
|
-
const REPEAT_STOP = 3;
|
|
12
|
-
|
|
13
|
-
export async function runTurn(page, provider, prompt, config, session) {
|
|
14
|
-
const instructions = config.instructionsText || null;
|
|
15
|
-
const sys = buildSystem(getWorkspace(), instructions);
|
|
16
|
-
|
|
17
|
-
const seen = new Map();
|
|
18
|
-
let next = sys + "\n\nTask: " + prompt;
|
|
19
|
-
let approvedAlways = new Set(session.approvedAlways || []);
|
|
20
|
-
const newTools = [];
|
|
21
|
-
|
|
22
|
-
for (let step = 0; step < config.maxSteps; step++) {
|
|
23
|
-
status("waiting for " + provider.label + "...");
|
|
24
|
-
const r = await send(page, provider, next, config.timeout);
|
|
25
|
-
const calls = parseCalls(r);
|
|
26
|
-
|
|
27
|
-
if (!calls.length) {
|
|
28
|
-
const prose = stripCalls(r);
|
|
29
|
-
reply(prose);
|
|
30
|
-
session.turns.push({ role: "user", text: prompt });
|
|
31
|
-
session.turns.push({ role: "assistant", text: prose });
|
|
32
|
-
session.approvedAlways = Array.from(approvedAlways);
|
|
33
|
-
session.updatedAt = Date.now();
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const results = [];
|
|
38
|
-
let stopped = false;
|
|
39
|
-
|
|
40
|
-
for (const c of calls) {
|
|
41
|
-
const sig = c.tool + ":" + JSON.stringify(c.args || {});
|
|
42
|
-
const n = (seen.get(sig) || 0) + 1;
|
|
43
|
-
seen.set(sig, n);
|
|
44
|
-
|
|
45
|
-
if (n >= REPEAT_STOP) {
|
|
46
|
-
process.stdout.write(theme.error("x loop detected: " + c.tool +
|
|
47
|
-
" called " + n + " times with same args. stopping.") + "\n");
|
|
48
|
-
stopped = true;
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (n === REPEAT_WARN) {
|
|
53
|
-
process.stdout.write(theme.warn("! " + c.tool + " repeated " + n +
|
|
54
|
-
" times. warning the model.") + "\n");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
logTool(c.tool, c.args);
|
|
58
|
-
|
|
59
|
-
// Approval gate for sensitive tools
|
|
60
|
-
const needsApproval = !config.yolo &&
|
|
61
|
-
!SAFE_TOOLS.has(c.tool) &&
|
|
62
|
-
!approvedAlways.has(c.tool);
|
|
63
|
-
|
|
64
|
-
if (needsApproval) {
|
|
65
|
-
const decision = await askApproval(c.tool, c.args);
|
|
66
|
-
if (decision === "no") {
|
|
67
|
-
const msg = "user denied " + c.tool;
|
|
68
|
-
toolResult(msg, true);
|
|
69
|
-
results.push("Tool: " + c.tool + "\nStatus: denied\n" + msg);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (decision === "always") {
|
|
73
|
-
approvedAlways.add(c.tool);
|
|
74
|
-
newTools.push(c.tool);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let out;
|
|
79
|
-
let isError = false;
|
|
80
|
-
try {
|
|
81
|
-
const fn = TOOLS[c.tool];
|
|
82
|
-
if (!fn) {
|
|
83
|
-
out = "unknown tool: " + c.tool;
|
|
84
|
-
isError = true;
|
|
85
|
-
} else {
|
|
86
|
-
out = await fn(c.args || {});
|
|
87
|
-
if (typeof out === "string" && out.indexOf("failed") === 0) isError = true;
|
|
88
|
-
}
|
|
89
|
-
} catch (e) {
|
|
90
|
-
out = "error: " + (e.message || e);
|
|
91
|
-
isError = true;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
toolResult(out, isError);
|
|
95
|
-
results.push("Tool: " + c.tool + "\nResult:\n" + out);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (stopped) {
|
|
99
|
-
session.turns.push({ role: "user", text: prompt });
|
|
100
|
-
session.turns.push({ role: "assistant", text: "(stopped: loop detected)" });
|
|
101
|
-
session.updatedAt = Date.now();
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const footer = [
|
|
106
|
-
"Results above.",
|
|
107
|
-
"Do not repeat any call with the same arguments.",
|
|
108
|
-
"If the task is complete, reply with plain prose and no tool calls."
|
|
109
|
-
].join("\n");
|
|
110
|
-
|
|
111
|
-
next = "Tool results:\n\n" + results.join("\n\n") + "\n\n" + footer;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
process.stdout.write(theme.warn("max steps reached for this turn.") + "\n");
|
|
115
|
-
session.turns.push({ role: "user", text: prompt });
|
|
116
|
-
session.turns.push({ role: "assistant", text: "(max steps reached)" });
|
|
117
|
-
session.updatedAt = Date.now();
|
|
118
|
-
}
|
|
2
|
+
import { parse
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-agent-cli",
|
|
3
|
-
"version": "3.0.
|
|
4
|
-
"description": "Drive web AI products from the terminal with local tools. Multi-provider,
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "Drive web AI products from the terminal with local tools. Multi-provider, approval gate, session persistence, undo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"portal-agent": "portal.mjs"
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"deepseek",
|
|
32
32
|
"chatgpt",
|
|
33
33
|
"gemini",
|
|
34
|
+
"grok",
|
|
34
35
|
"playwright"
|
|
35
36
|
],
|
|
36
37
|
"license": "MIT"
|
package/portal.mjs
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// portal-agent -- drive web AI products from the terminal with local tools.
|
|
4
4
|
//
|
|
5
|
-
// Entry point. Loads config, parses arguments,
|
|
6
|
-
// interactive REPL or the one-shot exec mode.
|
|
5
|
+
// Entry point. Loads config, parses arguments, dispatches to REPL or exec.
|
|
7
6
|
//
|
|
8
7
|
import { parseArgs } from "./lib/args.mjs";
|
|
9
8
|
import { loadConfig } from "./lib/config.mjs";
|