portal-agent-cli 1.0.1 → 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/lib/paste.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { sleep } from "./wait.mjs";
2
2
 
3
+ // DeepSeek shows a Cancel/Send confirmation bar when a large paste is
4
+ // detected. Find the Cancel button, locate its Send sibling, click that.
3
5
  export async function dismissPaste(page) {
4
6
  const btns = page.locator("button, [role=button]");
5
7
  let total = 0;
@@ -33,7 +35,7 @@ export async function dismissPaste(page) {
33
35
  try {
34
36
  await sib.click({ timeout: 1500 });
35
37
  await sleep(200);
36
- return;
38
+ return true;
37
39
  } catch (e) {
38
40
  // fall through
39
41
  }
@@ -46,8 +48,9 @@ export async function dismissPaste(page) {
46
48
  } catch (e) {
47
49
  // ignore
48
50
  }
49
- return;
51
+ return true;
50
52
  }
53
+ return false;
51
54
  }
52
55
 
53
56
  export async function composerEmpty(input, ms) {
@@ -57,7 +60,6 @@ export async function composerEmpty(input, ms) {
57
60
  try {
58
61
  v = await input.inputValue();
59
62
  } catch (e) {
60
- // contenteditable composers have no inputValue. Treat as sent.
61
63
  return true;
62
64
  }
63
65
  if (!v || v.length === 0) return true;
package/lib/providers.mjs CHANGED
@@ -3,19 +3,41 @@ export const PROVIDERS = {
3
3
  id: "deepseek",
4
4
  label: "DeepSeek",
5
5
  url: "https://chat.deepseek.com/",
6
- input: "textarea",
7
- submit: "button[aria-label=Send]",
8
- response: "div.ds-markdown",
9
- paste: true
6
+ input: ["textarea#chat-input", "textarea", "[contenteditable=true]"],
7
+ submit: ["button[aria-label=Send]", "button[data-testid=send-button]"],
8
+ response: ["div.ds-markdown", "div[class*=message]"],
9
+ paste: true,
10
+ deepthink: true
10
11
  },
11
12
  chatgpt: {
12
13
  id: "chatgpt",
13
14
  label: "ChatGPT",
14
15
  url: "https://chatgpt.com/",
15
- input: "#prompt-textarea",
16
- submit: "button[data-testid=send-button]",
17
- response: "[data-message-author-role=assistant]",
18
- paste: false
16
+ input: ["#prompt-textarea", "div[contenteditable=true][id=prompt-textarea]"],
17
+ submit: ["button[data-testid=send-button]", "button[aria-label*=Send]"],
18
+ response: ["[data-message-author-role=assistant]", "article[data-testid^=conversation-turn]"],
19
+ paste: false,
20
+ deepthink: false
21
+ },
22
+ gemini: {
23
+ id: "gemini",
24
+ label: "Gemini",
25
+ url: "https://gemini.google.com/app",
26
+ input: ["div[contenteditable=true][role=textbox]", "rich-textarea div[contenteditable=true]"],
27
+ submit: ["button[aria-label*=Send]", "button[aria-label=Send message]"],
28
+ response: ["message-content", "div[class*=model-response-text]"],
29
+ paste: false,
30
+ deepthink: false
31
+ },
32
+ grok: {
33
+ id: "grok",
34
+ label: "Grok",
35
+ url: "https://grok.com/",
36
+ input: ["textarea", "div[contenteditable=true]"],
37
+ submit: ["button[type=submit]", "button[aria-label*=Send]"],
38
+ response: ["div[class*=message]", "div[class*=response]"],
39
+ paste: false,
40
+ deepthink: false
19
41
  }
20
42
  };
21
43
 
@@ -23,7 +45,11 @@ export function getProvider(id) {
23
45
  const p = PROVIDERS[id];
24
46
  if (!p) {
25
47
  const names = Object.keys(PROVIDERS).join(", ");
26
- throw new Error("unknown provider: " + id + ". Available: " + names);
48
+ throw new Error("unknown provider: " + id + "\nAvailable: " + names);
27
49
  }
28
50
  return p;
29
51
  }
52
+
53
+ export function listProviders() {
54
+ return Object.keys(PROVIDERS).map(function (k) { return PROVIDERS[k]; });
55
+ }
package/lib/repl.mjs CHANGED
@@ -1,119 +1,4 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { existsSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
- import { launch } from "./browser.mjs";
5
- import { getProvider } from "./providers.mjs";
6
- import { waitForComposer } from "./wait.mjs";
7
- import { runTurn } from "./turn.mjs";
8
- import { setWs, getWs } from "./tools.mjs";
9
-
10
- export async function runRepl(opts) {
11
- const provider = getProvider(opts.provider);
12
- setWs(opts.ws);
13
-
14
- console.log("");
15
- console.log("Portal -- web AI in your terminal");
16
- console.log("workspace: " + getWs());
17
- console.log("provider: " + provider.label);
18
- console.log("");
19
-
20
- const ctx = await launch();
21
- const page = await ctx.newPage();
22
- page.setDefaultTimeout(30000);
23
-
24
- console.log("opening " + provider.label + "...");
25
- await page.goto(provider.url);
26
- console.log("");
27
- console.log("Log in to " + provider.label + " in the Chrome window.");
28
- console.log("Waiting up to " + Math.round(opts.loginTimeout / 1000) + "s.");
29
- console.log("");
30
-
31
- await waitForComposer(page, provider, opts.loginTimeout);
32
-
33
- console.log("+ " + provider.label + " ready");
34
- console.log("type /help for commands");
35
- console.log("");
36
-
37
- const rl = createInterface({
38
- input: process.stdin,
39
- output: process.stdout,
40
- prompt: "> "
41
- });
42
- rl.prompt();
43
-
44
- let busy = false;
45
-
46
- rl.on("line", async function (line) {
47
- const t = line.trim();
48
-
49
- if (busy) {
50
- rl.prompt();
51
- return;
52
- }
53
- if (!t) {
54
- rl.prompt();
55
- return;
56
- }
57
- if (t === "/quit" || t === "/exit") {
58
- rl.close();
59
- return;
60
- }
61
- if (t === "/help") {
62
- help();
63
- rl.prompt();
64
- return;
65
- }
66
- if (t === "/pwd") {
67
- console.log(getWs());
68
- rl.prompt();
69
- return;
70
- }
71
- if (t.slice(0, 4) === "/cd ") {
72
- changeDir(t.slice(4).trim(), rl);
73
- return;
74
- }
75
-
76
- busy = true;
77
- try {
78
- await runTurn(page, provider, t, opts);
79
- } catch (e) {
80
- console.log("x " + (e.message || e));
81
- }
82
- busy = false;
83
- console.log("");
84
- rl.prompt();
85
- });
86
-
87
- rl.on("close", async function () {
88
- console.log("");
89
- console.log("closing browser...");
90
- await ctx.close().catch(function () {});
91
- process.exit(0);
92
- });
93
- }
94
-
95
- function help() {
96
- console.log(" /cd <folder> change workspace");
97
- console.log(" /pwd show current workspace");
98
- console.log(" /help show this help");
99
- console.log(" /quit exit");
100
- }
101
-
102
- function changeDir(target, rl) {
103
- if (!target) {
104
- console.log("usage: /cd <folder>");
105
- console.log("current: " + getWs());
106
- rl.prompt();
107
- return;
108
- }
109
- const clean = target.replace(/^"|"$/g, "");
110
- const abs = resolve(clean);
111
- if (!existsSync(abs)) {
112
- console.log("not found: " + abs);
113
- rl.prompt();
114
- return;
115
- }
116
- setWs(abs);
117
- console.log("workspace: " + getWs());
118
- rl.prompt();
119
- }
4
+ import { launch } from "./
package/lib/safety.mjs ADDED
@@ -0,0 +1 @@
1
+ import
package/lib/send.mjs CHANGED
@@ -1,88 +1,108 @@
1
- import { sleep, norm } from "./wait.mjs";
1
+ import { sleep, norm, findFirstVisible } from "./wait.mjs";
2
2
  import { dismissPaste, composerEmpty } from "./paste.mjs";
3
+ import { theme } from "./theme.mjs";
4
+
5
+ const SETTLE_MS = 1500;
6
+ const POLL_MS = 150;
7
+
8
+ function makeStreamer() {
9
+ let printed = 0;
10
+ let lastLength = 0;
11
+ return function (text) {
12
+ if (!text) return;
13
+ const t = String(text);
14
+ if (t.length > lastLength) {
15
+ const delta = t.slice(printed);
16
+ process.stdout.write(delta);
17
+ printed = t.length;
18
+ lastLength = t.length;
19
+ }
20
+ };
21
+ }
3
22
 
4
23
  export async function send(page, provider, prompt, timeoutMs) {
5
- const input = page.locator(provider.input).last();
6
- await input.waitFor({ state: "visible", timeout: 30000 });
7
- const before = await page.locator(provider.response).count();
24
+ const input = await findFirstVisible(page, provider.input, 30000);
25
+ if (!input) throw new Error("composer not found");
26
+
27
+ const want = norm(prompt);
8
28
 
9
29
  await input.focus();
10
30
  try {
11
31
  await page.keyboard.press("Control+A");
12
32
  await page.keyboard.press("Delete");
13
33
  } catch (e) {
14
- // composer may be empty
34
+ // empty composer
15
35
  }
16
36
 
17
37
  await page.keyboard.insertText(prompt);
18
38
  await sleep(300);
19
-
20
39
  if (provider.paste) await dismissPaste(page);
21
40
 
22
41
  try {
23
42
  await input.press("Enter");
24
43
  } catch (e) {
25
- // fall through to button
44
+ // fall through
26
45
  }
27
46
 
28
47
  let ok = await composerEmpty(input, 5000);
29
-
30
48
  if (!ok) {
31
- const btn = page.locator(provider.submit).last();
32
- try {
33
- if (await btn.isVisible({ timeout: 1500 })) await btn.click();
34
- } catch (e) {
35
- // no button
49
+ const btn = await findFirstVisible(page, provider.submit, 1500);
50
+ if (btn) {
51
+ try { await btn.click({ timeout: 1500 }); } catch (e) {}
36
52
  }
37
53
  ok = await composerEmpty(input, 3000);
38
54
  }
39
55
 
40
56
  if (!ok) {
41
57
  throw new Error(
42
- "Message stayed in composer. Look at the Chrome window: if a " +
43
- "Cancel/Send bar is showing, click Send manually once, then resend."
58
+ "Message stayed in composer.\n" +
59
+ "Check Chrome: if a Cancel/Send bar is showing, click Send manually, " +
60
+ "then resend."
44
61
  );
45
62
  }
46
63
 
47
- const want = norm(prompt);
48
- const end = Date.now() + timeoutMs;
49
- let appeared = false;
50
-
51
- while (Date.now() < end) {
52
- const c = await page.locator(provider.response).count();
53
- if (c > before) {
54
- appeared = true;
55
- break;
56
- }
57
- await sleep(300);
58
- }
59
-
60
- if (!appeared) throw new Error("No response appeared within the timeout.");
64
+ return waitForReply(page, provider, want, timeoutMs);
65
+ }
61
66
 
67
+ async function waitForReply(page, provider, want, timeoutMs) {
68
+ const end = Date.now() + timeoutMs;
62
69
  let last = "";
63
70
  let stab = Date.now();
71
+ let sawAnything = false;
72
+ const stream = makeStreamer();
73
+
74
+ process.stdout.write("\n" + theme.cyan("* ") + " ");
64
75
 
65
76
  while (Date.now() < end) {
66
- const node = page.locator(provider.response).last();
67
- let tx = "";
68
- try {
69
- tx = (await node.textContent()) || "";
70
- } catch (e) {
71
- tx = "";
72
- }
73
- const n = norm(tx);
74
- if (!n || n === want) {
75
- await sleep(300);
76
- continue;
77
- }
78
- if (tx !== last) {
79
- last = tx;
80
- stab = Date.now();
81
- } else if (Date.now() - stab > 1500) {
82
- return tx;
77
+ const node = await findFirstVisible(page, provider.response, 1000);
78
+ if (node) {
79
+ let tx = "";
80
+ try { tx = (await node.textContent()) || ""; } catch (e) { tx = ""; }
81
+ const n = norm(tx);
82
+
83
+ if (!n || n === want) {
84
+ await sleep(POLL_MS);
85
+ continue;
86
+ }
87
+
88
+ if (tx !== last) {
89
+ stream(tx);
90
+ last = tx;
91
+ stab = Date.now();
92
+ sawAnything = true;
93
+ } else if (sawAnything && Date.now() - stab > SETTLE_MS) {
94
+ process.stdout.write("\n\n");
95
+ return tx;
96
+ }
83
97
  }
84
- await sleep(300);
98
+ await sleep(POLL_MS);
85
99
  }
86
100
 
87
- return last || "(timeout)";
101
+ process.stdout.write("\n\n");
102
+ if (last) return last;
103
+ throw new Error(
104
+ "Timed out waiting for a reply.\n" +
105
+ "Check the Chrome window. If the model answered but Portal missed it, " +
106
+ "send the task again."
107
+ );
88
108
  }
@@ -0,0 +1 @@
1
+ import { mk
package/lib/system.mjs CHANGED
@@ -1,24 +1,4 @@
1
- import { CATALOG } from "./tools.mjs";
1
+ import { CATALOG } from "./tools/index.mjs";
2
2
 
3
- export function buildSystem(ws) {
4
- const shown = ws.split("\\").join("/");
5
- return [
6
- "You are a coding agent working in a terminal.",
7
- "Workspace: " + shown,
8
- "Use forward slashes in every path, even on Windows.",
9
- "",
10
- "To call a tool, write on its own line:",
11
- "portal-tool: {\"tool\":\"name\",\"args\":{\"key\":\"value\"}}",
12
- "",
13
- "The JSON must use double quotes around keys and values.",
14
- "Plain text only. Do not wrap the call in a code block.",
15
- "One call per action. Never repeat a call with the same arguments.",
16
- "",
17
- "If a tool returns an empty result, that is final. Report it.",
18
- "If unsure what to do, reply with plain prose and ask.",
19
- "When done, reply with plain prose and no tool calls.",
20
- "",
21
- "Available tools:",
22
- CATALOG
23
- ].join("\n");
24
- }
3
+ export function buildSystem(ws, instructions) {
4
+ const shown = ws.split("\\
package/lib/theme.mjs ADDED
@@ -0,0 +1,49 @@
1
+ const E = String.fromCharCode(27);
2
+
3
+ const MODE = process.env.NO_COLOR ? "plain" : "color";
4
+
5
+ function c(code) {
6
+ if (MODE === "plain") return "";
7
+ return E + "[" + code + "m";
8
+ }
9
+
10
+ const R = c(0);
11
+ const B = c(1);
12
+ const D = c(90);
13
+ const RED = c(31);
14
+ const GRN = c(32);
15
+ const YEL = c(33);
16
+ const BLU = c(34);
17
+ const MAG = c(35);
18
+ const CYN = c(36);
19
+ const BGRN = c(92);
20
+ const BRED = c(91);
21
+ const BCYN = c(96);
22
+
23
+ function wrap(prefix, s) {
24
+ return prefix + String(s) + R;
25
+ }
26
+
27
+ export const theme = {
28
+ reset: R,
29
+ bold: function (s) { return wrap(B, s); },
30
+ dim: function (s) { return wrap(D, s); },
31
+ red: function (s) { return wrap(RED, s); },
32
+ error: function (s) { return wrap(BRED, s); },
33
+ green: function (s) { return wrap(GRN, s); },
34
+ ok: function (s) { return wrap(BGRN, s); },
35
+ yellow: function (s) { return wrap(YEL, s); },
36
+ warn: function (s) { return wrap(YEL, s); },
37
+ blue: function (s) { return wrap(BLU, s); },
38
+ magenta: function (s) { return wrap(MAG, s); },
39
+ cyan: function (s) { return wrap(BCYN, s); },
40
+ accent: function (s) { return wrap(CYN, s); },
41
+ user: function (s) { return wrap(BLU, s); },
42
+ tool: function (s) { return wrap(MAG, s); },
43
+ italic: function (s) { return c(3) + String(s) + R; },
44
+ underline: function (s) { return c(4) + String(s) + R; }
45
+ };
46
+
47
+ export function colorEnabled() {
48
+ return MODE === "color";
49
+ }
@@ -0,0 +1,2 @@
1
+ import { stat, rename } from "node:fs/promises";
2
+ import
@@ -0,0 +1 @@
1
+ import
@@ -0,0 +1 @@
1
+ import { relative, sep } from "
@@ -0,0 +1,5 @@
1
+ import { spawn } from "node:child_process";
2
+ import { getWorkspace } from "./path.mjs";
3
+
4
+ function run(args) {
5
+ return new
@@ -0,0 +1,2 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { relative }
@@ -0,0 +1,2 @@
1
+ import { read_file } from "./read.mjs";
2
+ import { write_file, append_file }
@@ -0,0 +1 @@
1
+ import { readdir, stat } from "node:fs/prom
@@ -0,0 +1 @@
1
+ import
@@ -0,0 +1,4 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { safePath } from "./path.mjs";
3
+
4
+ const MAX_BYTES =
@@ -0,0 +1,5 @@
1
+ import { spawn } from "node:child_process";
2
+ import { getWorkspace } from "./path.mjs";
3
+
4
+ const MAX_OUTPUT = 65000;
5
+ const TIMEOUT_MS = 60000
@@ -0,0 +1 @@
1
+ import { readdir } from "node
@@ -0,0 +1,5 @@
1
+ import { writeFile, mkdir, readFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { safePath } from "./path.mjs";
4
+
5
+ export async function write_file
package/lib/turn.mjs CHANGED
@@ -1,57 +1,2 @@
1
1
  import { send } from "./send.mjs";
2
- import { parseCalls, stripCalls } from "./parse.mjs";
3
- import { tools, getWs } from "./tools.mjs";
4
- import { buildSystem } from "./system.mjs";
5
-
6
- export async function runTurn(page, provider, prompt, opts) {
7
- const sys = buildSystem(getWs());
8
- const seen = new Map();
9
- let next = sys + "\n\nTask: " + prompt;
10
-
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);
14
- const calls = parseCalls(r);
15
-
16
- if (!calls.length) {
17
- const prose = stripCalls(r);
18
- if (prose) console.log("\n" + prose + "\n");
19
- return;
20
- }
21
-
22
- const results = [];
23
- let stop = false;
24
-
25
- for (const c of calls) {
26
- const sig = c.tool + ":" + JSON.stringify(c.args || {});
27
- const n = (seen.get(sig) || 0) + 1;
28
- seen.set(sig, n);
29
-
30
- if (n >= 3) {
31
- console.log("loop detected: " + c.tool + " repeated. stopping.");
32
- stop = true;
33
- break;
34
- }
35
-
36
- console.log("> " + c.tool + " " + JSON.stringify(c.args || {}).slice(0, 120));
37
-
38
- let out;
39
- try {
40
- const fn = tools[c.tool];
41
- out = fn ? await fn(c.args || {}) : "unknown tool: " + c.tool;
42
- } catch (e) {
43
- out = "error: " + (e.message || e);
44
- }
45
-
46
- console.log(" " + String(out).slice(0, 240).split("\n").join("\n "));
47
- results.push(c.tool + ":\n" + out);
48
- }
49
-
50
- if (stop) return;
51
-
52
- next = "Results:\n\n" + results.join("\n\n") +
53
- "\n\nContinue. Do not repeat any call with the same arguments.";
54
- }
55
-
56
- console.log("max steps reached for this turn.");
57
- }
2
+ import { parse
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
+ }