portal-agent-cli 1.0.1 → 3.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/LICENSE +1 -19
- package/README.md +32 -90
- package/lib/args.mjs +60 -26
- package/lib/browser.mjs +34 -10
- package/lib/config.mjs +78 -0
- package/lib/exec.mjs +20 -15
- package/lib/instructions.mjs +44 -0
- package/lib/log.mjs +41 -0
- package/lib/parse.mjs +32 -24
- package/lib/paste.mjs +7 -3
- package/lib/providers.mjs +35 -9
- package/lib/repl.mjs +233 -66
- package/lib/safety.mjs +64 -0
- package/lib/send.mjs +50 -46
- package/lib/sessions.mjs +75 -0
- package/lib/system.mjs +29 -15
- package/lib/theme.mjs +56 -0
- package/lib/tools/delete.mjs +30 -0
- package/lib/tools/edit.mjs +51 -0
- package/lib/tools/find.mjs +42 -0
- package/lib/tools/git.mjs +52 -0
- package/lib/tools/grep.mjs +44 -0
- package/lib/tools/index.mjs +53 -0
- package/lib/tools/list.mjs +38 -0
- package/lib/tools/path.mjs +22 -0
- package/lib/tools/read.mjs +33 -0
- package/lib/tools/shell.mjs +45 -0
- package/lib/tools/walk.mjs +32 -0
- package/lib/tools/write.mjs +46 -0
- package/lib/turn.mjs +81 -20
- package/lib/wait.mjs +44 -13
- package/package.json +5 -9
- package/portal.mjs +27 -4
- package/lib/tools.mjs +0 -151
package/lib/turn.mjs
CHANGED
|
@@ -1,57 +1,118 @@
|
|
|
1
1
|
import { send } from "./send.mjs";
|
|
2
2
|
import { parseCalls, stripCalls } from "./parse.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { TOOLS, SAFE_TOOLS } from "./tools/index.mjs";
|
|
4
|
+
import { getWorkspace } from "./tools/path.mjs";
|
|
4
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);
|
|
5
16
|
|
|
6
|
-
export async function runTurn(page, provider, prompt, opts) {
|
|
7
|
-
const sys = buildSystem(getWs());
|
|
8
17
|
const seen = new Map();
|
|
9
18
|
let next = sys + "\n\nTask: " + prompt;
|
|
19
|
+
let approvedAlways = new Set(session.approvedAlways || []);
|
|
20
|
+
const newTools = [];
|
|
10
21
|
|
|
11
|
-
for (let
|
|
12
|
-
|
|
13
|
-
const r = await send(page, provider, next,
|
|
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);
|
|
14
25
|
const calls = parseCalls(r);
|
|
15
26
|
|
|
16
27
|
if (!calls.length) {
|
|
17
28
|
const prose = stripCalls(r);
|
|
18
|
-
|
|
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();
|
|
19
34
|
return;
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
const results = [];
|
|
23
|
-
let
|
|
38
|
+
let stopped = false;
|
|
24
39
|
|
|
25
40
|
for (const c of calls) {
|
|
26
41
|
const sig = c.tool + ":" + JSON.stringify(c.args || {});
|
|
27
42
|
const n = (seen.get(sig) || 0) + 1;
|
|
28
43
|
seen.set(sig, n);
|
|
29
44
|
|
|
30
|
-
if (n >=
|
|
31
|
-
|
|
32
|
-
|
|
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;
|
|
33
49
|
break;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
|
-
|
|
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
|
+
}
|
|
37
77
|
|
|
38
78
|
let out;
|
|
79
|
+
let isError = false;
|
|
39
80
|
try {
|
|
40
|
-
const fn =
|
|
41
|
-
|
|
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
|
+
}
|
|
42
89
|
} catch (e) {
|
|
43
90
|
out = "error: " + (e.message || e);
|
|
91
|
+
isError = true;
|
|
44
92
|
}
|
|
45
93
|
|
|
46
|
-
|
|
47
|
-
results.push(c.tool + ":\n" + out);
|
|
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;
|
|
48
103
|
}
|
|
49
104
|
|
|
50
|
-
|
|
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");
|
|
51
110
|
|
|
52
|
-
next = "
|
|
53
|
-
"\n\nContinue. Do not repeat any call with the same arguments.";
|
|
111
|
+
next = "Tool results:\n\n" + results.join("\n\n") + "\n\n" + footer;
|
|
54
112
|
}
|
|
55
113
|
|
|
56
|
-
|
|
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();
|
|
57
118
|
}
|
package/lib/wait.mjs
CHANGED
|
@@ -6,27 +6,58 @@ export function norm(s) {
|
|
|
6
6
|
return String(s || "").replace(/\s+/g, " ").trim();
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export async function
|
|
9
|
+
export async function findFirstVisible(page, selectors, ms) {
|
|
10
10
|
const end = Date.now() + ms;
|
|
11
|
-
let hint = Date.now();
|
|
12
11
|
while (Date.now() < end) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
for (const sel of selectors) {
|
|
13
|
+
const loc = page.locator(sel).last();
|
|
14
|
+
try {
|
|
15
|
+
if (await loc.isVisible({ timeout: 200 })) return loc;
|
|
16
|
+
} catch (e) {
|
|
17
|
+
// try next selector
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
await sleep(200);
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function waitForComposer(page, provider, ms) {
|
|
26
|
+
const end = Date.now() + ms;
|
|
27
|
+
let lastHint = Date.now();
|
|
28
|
+
while (Date.now() < end) {
|
|
29
|
+
for (const sel of provider.input) {
|
|
30
|
+
const loc = page.locator(sel).last();
|
|
31
|
+
let ok = false;
|
|
32
|
+
try {
|
|
33
|
+
ok = await loc.isVisible({ timeout: 200 });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
ok = false;
|
|
36
|
+
}
|
|
37
|
+
if (ok) return loc;
|
|
19
38
|
}
|
|
20
|
-
if (
|
|
21
|
-
if (Date.now() - hint > 15000) {
|
|
39
|
+
if (Date.now() - lastHint > 15000) {
|
|
22
40
|
const left = Math.round((end - Date.now()) / 1000);
|
|
23
41
|
process.stdout.write(" waiting for login (" + left + "s left)\n");
|
|
24
|
-
|
|
42
|
+
lastHint = Date.now();
|
|
25
43
|
}
|
|
26
44
|
await sleep(1000);
|
|
27
45
|
}
|
|
28
46
|
throw new Error(
|
|
29
|
-
"Timed out waiting for the composer
|
|
30
|
-
"and try again,
|
|
47
|
+
"Timed out waiting for the composer.\n" +
|
|
48
|
+
"Log in to the provider in the Chrome window and try again, " +
|
|
49
|
+
"or raise --login-timeout."
|
|
31
50
|
);
|
|
32
51
|
}
|
|
52
|
+
|
|
53
|
+
export async function countMatching(page, selectors) {
|
|
54
|
+
for (const sel of selectors) {
|
|
55
|
+
try {
|
|
56
|
+
const n = await page.locator(sel).count();
|
|
57
|
+
if (n > 0) return n;
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// try next
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-agent-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Drive web AI products from the terminal with local tools.
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Drive web AI products from the terminal with local tools. Multi-provider, session persistence, approval gate, undo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"portal-agent": "portal.mjs"
|
|
@@ -17,15 +17,11 @@
|
|
|
17
17
|
"node": ">=20"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"start": "node portal.mjs"
|
|
21
|
-
"build": "pkg portal.mjs --targets node18-win-x64 --output portal.exe"
|
|
20
|
+
"start": "node portal.mjs"
|
|
22
21
|
},
|
|
23
22
|
"dependencies": {
|
|
24
23
|
"playwright": "^1.48.0"
|
|
25
24
|
},
|
|
26
|
-
"devDependencies": {
|
|
27
|
-
"pkg": "^5.8.1"
|
|
28
|
-
},
|
|
29
25
|
"keywords": [
|
|
30
26
|
"browser",
|
|
31
27
|
"automation",
|
|
@@ -34,8 +30,8 @@
|
|
|
34
30
|
"agent",
|
|
35
31
|
"deepseek",
|
|
36
32
|
"chatgpt",
|
|
33
|
+
"gemini",
|
|
37
34
|
"playwright"
|
|
38
35
|
],
|
|
39
|
-
"author": "kitadestin",
|
|
40
36
|
"license": "MIT"
|
|
41
|
-
}
|
|
37
|
+
}
|
package/portal.mjs
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// portal-agent -- drive web AI products from the terminal with local tools.
|
|
4
|
+
//
|
|
5
|
+
// Entry point. Loads config, parses arguments, and dispatches to either the
|
|
6
|
+
// interactive REPL or the one-shot exec mode.
|
|
7
|
+
//
|
|
2
8
|
import { parseArgs } from "./lib/args.mjs";
|
|
9
|
+
import { loadConfig } from "./lib/config.mjs";
|
|
3
10
|
import { runRepl } from "./lib/repl.mjs";
|
|
4
11
|
import { runExec } from "./lib/exec.mjs";
|
|
12
|
+
import { theme } from "./lib/theme.mjs";
|
|
5
13
|
|
|
6
|
-
|
|
14
|
+
async function main() {
|
|
15
|
+
const opts = parseArgs();
|
|
16
|
+
const config = await loadConfig(opts);
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
if (config.verbose) {
|
|
19
|
+
process.stdout.write(theme.dim("config: " + JSON.stringify(config, null, 2)) + "\n");
|
|
20
|
+
}
|
|
9
21
|
|
|
10
|
-
|
|
11
|
-
|
|
22
|
+
if (opts.exec) {
|
|
23
|
+
await runExec(config);
|
|
24
|
+
} else {
|
|
25
|
+
await runRepl(config);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
main().catch(function (e) {
|
|
30
|
+
const msg = e && e.message ? e.message : String(e);
|
|
31
|
+
process.stderr.write(theme.error("fatal: " + msg) + "\n");
|
|
32
|
+
if (process.env.PORTAL_DEBUG) {
|
|
33
|
+
process.stderr.write(e.stack + "\n");
|
|
34
|
+
}
|
|
12
35
|
process.exit(1);
|
|
13
36
|
});
|
package/lib/tools.mjs
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
2
|
-
import { dirname, resolve, relative, sep, join } from "node:path";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
|
|
5
|
-
let ws = process.cwd();
|
|
6
|
-
|
|
7
|
-
export function setWs(p) {
|
|
8
|
-
ws = resolve(p);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function getWs() {
|
|
12
|
-
return ws;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const IGNORE = new Set([
|
|
16
|
-
"node_modules", ".git", ".hg", ".svn", "dist", "build", "out",
|
|
17
|
-
".next", ".nuxt", ".turbo", ".cache", "target", "__pycache__",
|
|
18
|
-
".venv", "venv", "env", "coverage", ".idea", ".vscode"
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
function safe(p) {
|
|
22
|
-
const a = resolve(ws, p);
|
|
23
|
-
const r = relative(ws, a);
|
|
24
|
-
if (r === ".." || r.slice(0, 3) === "..\\" || r.slice(0, 3) === "../") {
|
|
25
|
-
throw new Error("path escapes workspace: " + p);
|
|
26
|
-
}
|
|
27
|
-
return a;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function* walk(dir) {
|
|
31
|
-
let items;
|
|
32
|
-
try {
|
|
33
|
-
items = await readdir(dir, { withFileTypes: true });
|
|
34
|
-
} catch (e) {
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
for (const it of items) {
|
|
38
|
-
if (IGNORE.has(it.name)) continue;
|
|
39
|
-
if (it.name.startsWith(".") && it.name !== ".env.example") continue;
|
|
40
|
-
const full = join(dir, it.name);
|
|
41
|
-
if (it.isDirectory()) yield* walk(full);
|
|
42
|
-
else if (it.isFile()) yield full;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export const tools = {
|
|
47
|
-
read_file: async function (a) {
|
|
48
|
-
const t = await readFile(safe(a.path), "utf8");
|
|
49
|
-
return t.length > 200000 ? t.slice(0, 200000) + "\n[truncated]" : t;
|
|
50
|
-
},
|
|
51
|
-
|
|
52
|
-
write_file: async function (a) {
|
|
53
|
-
const x = safe(a.path);
|
|
54
|
-
await mkdir(dirname(x), { recursive: true });
|
|
55
|
-
await writeFile(x, a.content || "", "utf8");
|
|
56
|
-
return "wrote " + (a.content || "").length + " bytes to " + a.path;
|
|
57
|
-
},
|
|
58
|
-
|
|
59
|
-
edit_file: async function (a) {
|
|
60
|
-
const x = safe(a.path);
|
|
61
|
-
const orig = await readFile(x, "utf8");
|
|
62
|
-
const n = orig.split(a.old_string).length - 1;
|
|
63
|
-
if (n === 0) return "old_string not found in " + a.path;
|
|
64
|
-
if (n > 1 && !a.replace_all) return "old_string appears " + n + " times";
|
|
65
|
-
const next = a.replace_all
|
|
66
|
-
? orig.split(a.old_string).join(a.new_string)
|
|
67
|
-
: orig.replace(a.old_string, a.new_string);
|
|
68
|
-
await writeFile(x, next, "utf8");
|
|
69
|
-
return "edited " + a.path;
|
|
70
|
-
},
|
|
71
|
-
|
|
72
|
-
list_files: async function (a) {
|
|
73
|
-
const e = await readdir(safe(a.path || "."), { withFileTypes: true });
|
|
74
|
-
if (!e.length) return "EMPTY. Do not call list_files again.";
|
|
75
|
-
const names = e.map(function (x) {
|
|
76
|
-
return (x.isDirectory() ? "[dir] " : " ") + x.name;
|
|
77
|
-
});
|
|
78
|
-
names.sort();
|
|
79
|
-
return names.join("\n");
|
|
80
|
-
},
|
|
81
|
-
|
|
82
|
-
grep_search: async function (a) {
|
|
83
|
-
let re;
|
|
84
|
-
try {
|
|
85
|
-
re = new RegExp(a.pattern, a.case_sensitive ? "" : "i");
|
|
86
|
-
} catch (e) {
|
|
87
|
-
return "bad regex: " + e.message;
|
|
88
|
-
}
|
|
89
|
-
const hits = [];
|
|
90
|
-
for await (const f of walk(ws)) {
|
|
91
|
-
if (hits.length >= 80) break;
|
|
92
|
-
let info;
|
|
93
|
-
try {
|
|
94
|
-
info = await stat(f);
|
|
95
|
-
} catch (e) {
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (info.size > 512000) continue;
|
|
99
|
-
let t;
|
|
100
|
-
try {
|
|
101
|
-
t = await readFile(f, "utf8");
|
|
102
|
-
} catch (e) {
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
const lines = t.split("\n");
|
|
106
|
-
for (let i = 0; i < lines.length && hits.length < 80; i++) {
|
|
107
|
-
if (re.test(lines[i])) {
|
|
108
|
-
const rel = relative(ws, f).split(sep).join("/");
|
|
109
|
-
hits.push(rel + ":" + (i + 1) + ":" + lines[i].trim().slice(0, 200));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return hits.length ? hits.join("\n") : "no matches";
|
|
114
|
-
},
|
|
115
|
-
|
|
116
|
-
shell: function (a) {
|
|
117
|
-
const cmd = String(a.command || "");
|
|
118
|
-
if (!cmd) return Promise.resolve("shell requires command");
|
|
119
|
-
return new Promise(function (done) {
|
|
120
|
-
const c = spawn(cmd, { cwd: ws, shell: true, windowsHide: true });
|
|
121
|
-
let o = "";
|
|
122
|
-
let e = "";
|
|
123
|
-
if (c.stdout) c.stdout.on("data", function (d) { o += String(d); });
|
|
124
|
-
if (c.stderr) c.stderr.on("data", function (d) { e += String(d); });
|
|
125
|
-
const to = setTimeout(function () {
|
|
126
|
-
c.kill();
|
|
127
|
-
done("timed out after 60s");
|
|
128
|
-
}, 60000);
|
|
129
|
-
c.on("error", function (err) {
|
|
130
|
-
clearTimeout(to);
|
|
131
|
-
done("error: " + err.message);
|
|
132
|
-
});
|
|
133
|
-
c.on("close", function (code) {
|
|
134
|
-
clearTimeout(to);
|
|
135
|
-
const both = [o.trim(), e.trim()].filter(Boolean).join("\n");
|
|
136
|
-
let s = "exit " + code + "\n" + (both || "(no output)");
|
|
137
|
-
if (s.length > 65000) s = s.slice(0, 65000) + "\n[truncated]";
|
|
138
|
-
done(s);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
export const CATALOG = [
|
|
145
|
-
"- read_file(path, start_line?, end_line?)",
|
|
146
|
-
"- write_file(path, content)",
|
|
147
|
-
"- edit_file(path, old_string, new_string, replace_all?)",
|
|
148
|
-
"- list_files(path?)",
|
|
149
|
-
"- grep_search(pattern, case_sensitive?)",
|
|
150
|
-
"- shell(command)"
|
|
151
|
-
].join("\n");
|