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/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 { tools, getWs } from "./tools.mjs";
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 i = 0; i < opts.maxSteps; i++) {
12
- console.log(" waiting for " + provider.label + "...");
13
- const r = await send(page, provider, next, opts.timeout);
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
- if (prose) console.log("\n" + prose + "\n");
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 stop = false;
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 >= 3) {
31
- console.log("loop detected: " + c.tool + " repeated. stopping.");
32
- stop = true;
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
- console.log("> " + c.tool + " " + JSON.stringify(c.args || {}).slice(0, 120));
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 = tools[c.tool];
41
- out = fn ? await fn(c.args || {}) : "unknown tool: " + c.tool;
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
- console.log(" " + String(out).slice(0, 240).split("\n").join("\n "));
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
- if (stop) return;
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 = "Results:\n\n" + results.join("\n\n") +
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
- console.log("max steps reached for this turn.");
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 waitForComposer(page, p, ms) {
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 loc = page.locator(p.input).last();
14
- let ok = false;
15
- try {
16
- ok = await loc.isVisible({ timeout: 250 });
17
- } catch (e) {
18
- ok = false;
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 (ok) return loc;
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
- hint = Date.now();
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. Log in to the provider " +
30
- "and try again, or raise --login-timeout."
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": "1.0.1",
4
- "description": "Drive web AI products from the terminal with local tools. Enter-first submit, paste-safe insertion, prompt-echo skip, loop breaker.",
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
- const opts = parseArgs();
14
+ async function main() {
15
+ const opts = parseArgs();
16
+ const config = await loadConfig(opts);
7
17
 
8
- const task = opts.exec ? runExec(opts) : runRepl(opts);
18
+ if (config.verbose) {
19
+ process.stdout.write(theme.dim("config: " + JSON.stringify(config, null, 2)) + "\n");
20
+ }
9
21
 
10
- task.catch(function (e) {
11
- console.error(e.message || e);
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");