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/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,10 +48,13 @@ 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
|
|
|
56
|
+
// Poll the composer for emptiness. A thrown inputValue() means the composer
|
|
57
|
+
// is a contenteditable with no inputValue method, so treat that as sent.
|
|
53
58
|
export async function composerEmpty(input, ms) {
|
|
54
59
|
const end = Date.now() + ms;
|
|
55
60
|
while (Date.now() < end) {
|
|
@@ -57,7 +62,6 @@ export async function composerEmpty(input, ms) {
|
|
|
57
62
|
try {
|
|
58
63
|
v = await input.inputValue();
|
|
59
64
|
} catch (e) {
|
|
60
|
-
// contenteditable composers have no inputValue. Treat as sent.
|
|
61
65
|
return true;
|
|
62
66
|
}
|
|
63
67
|
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
|
+
notes: "DeepSeek renders the user prompt with the same class as the reply."
|
|
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
|
+
notes: "ChatGPT does not show a paste dialog."
|
|
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
|
+
notes: "Gemini uses a rich-textarea web component."
|
|
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
|
+
notes: "Grok selectors are best-effort and may need updating."
|
|
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 + "
|
|
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
|
@@ -2,118 +2,285 @@ import { createInterface } from "node:readline";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { launch } from "./browser.mjs";
|
|
5
|
-
import { getProvider } from "./providers.mjs";
|
|
5
|
+
import { getProvider, listProviders } from "./providers.mjs";
|
|
6
6
|
import { waitForComposer } from "./wait.mjs";
|
|
7
7
|
import { runTurn } from "./turn.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { setWorkspace, getWorkspace } from "./tools/path.mjs";
|
|
9
|
+
import { setIgnore } from "./tools/walk.mjs";
|
|
10
|
+
import { loadInstructions, activeInstructionFile } from "./instructions.mjs";
|
|
11
|
+
import {
|
|
12
|
+
newSessionId,
|
|
13
|
+
saveSession,
|
|
14
|
+
loadSession,
|
|
15
|
+
listSessions
|
|
16
|
+
} from "./sessions.mjs";
|
|
17
|
+
import { theme } from "./theme.mjs";
|
|
18
|
+
import { info, warn, error, ok } from "./log.mjs";
|
|
9
19
|
|
|
10
|
-
export async function runRepl(
|
|
11
|
-
const provider = getProvider(
|
|
12
|
-
|
|
20
|
+
export async function runRepl(config) {
|
|
21
|
+
const provider = getProvider(config.provider);
|
|
22
|
+
setWorkspace(config.ws);
|
|
23
|
+
setIgnore(config.ignored);
|
|
13
24
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
const instructionsText = await loadInstructions(config.ws);
|
|
26
|
+
config.instructionsText = instructionsText;
|
|
27
|
+
|
|
28
|
+
// Session handling
|
|
29
|
+
let session;
|
|
30
|
+
if (config.resume) {
|
|
31
|
+
session = await loadSession(config.resume);
|
|
32
|
+
if (!session) {
|
|
33
|
+
warn("session not found: " + config.resume + ". starting fresh.");
|
|
34
|
+
session = newSession(config);
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
session = newSession(config);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Banner
|
|
41
|
+
process.stdout.write("\n");
|
|
42
|
+
process.stdout.write(theme.bold(theme.cyan("Portal")) + theme.dim(" v2.0.0") + "\n");
|
|
43
|
+
process.stdout.write(theme.dim("workspace: ") + theme.accent(getWorkspace()) + "\n");
|
|
44
|
+
process.stdout.write(theme.dim("provider: ") + theme.accent(provider.label) + "\n");
|
|
45
|
+
process.stdout.write(theme.dim("session: ") + theme.accent(session.id) + "\n");
|
|
46
|
+
if (instructionsText) {
|
|
47
|
+
process.stdout.write(theme.dim("instructions: ") +
|
|
48
|
+
theme.accent(activeInstructionFile() || "?") + "\n");
|
|
49
|
+
}
|
|
50
|
+
if (config.yolo) {
|
|
51
|
+
process.stdout.write(theme.warn("yolo mode: all tool calls auto-approved") + "\n");
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write("\n");
|
|
19
54
|
|
|
20
55
|
const ctx = await launch();
|
|
21
56
|
const page = await ctx.newPage();
|
|
22
57
|
page.setDefaultTimeout(30000);
|
|
23
58
|
|
|
24
|
-
|
|
59
|
+
info("opening " + provider.label + "...");
|
|
25
60
|
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
61
|
|
|
31
|
-
|
|
62
|
+
process.stdout.write("\n");
|
|
63
|
+
process.stdout.write(theme.yellow("Log in to " + provider.label +
|
|
64
|
+
" in the Chrome window.") + "\n");
|
|
65
|
+
process.stdout.write(theme.dim("Waiting up to " +
|
|
66
|
+
Math.round(config.loginTimeout / 1000) + "s.") + "\n");
|
|
67
|
+
process.stdout.write("\n");
|
|
68
|
+
|
|
69
|
+
await waitForComposer(page, provider, config.loginTimeout);
|
|
32
70
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
71
|
+
ok(provider.label + " ready");
|
|
72
|
+
process.stdout.write(theme.dim("type /help for commands") + "\n");
|
|
73
|
+
process.stdout.write("\n");
|
|
36
74
|
|
|
75
|
+
// REPL
|
|
37
76
|
const rl = createInterface({
|
|
38
77
|
input: process.stdin,
|
|
39
78
|
output: process.stdout,
|
|
40
|
-
prompt: "> "
|
|
79
|
+
prompt: theme.user("> ")
|
|
41
80
|
});
|
|
42
81
|
rl.prompt();
|
|
43
82
|
|
|
44
83
|
let busy = false;
|
|
84
|
+
const queue = [];
|
|
85
|
+
|
|
86
|
+
async function drain() {
|
|
87
|
+
if (busy) return;
|
|
88
|
+
const next = queue.shift();
|
|
89
|
+
if (!next) return;
|
|
90
|
+
busy = true;
|
|
91
|
+
try {
|
|
92
|
+
await runTurn(page, provider, next, config, session);
|
|
93
|
+
await saveSession(session.id, session);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
error(e.message || String(e));
|
|
96
|
+
}
|
|
97
|
+
busy = false;
|
|
98
|
+
process.stdout.write("\n");
|
|
99
|
+
rl.prompt();
|
|
100
|
+
if (queue.length > 0) drain();
|
|
101
|
+
}
|
|
45
102
|
|
|
46
103
|
rl.on("line", async function (line) {
|
|
47
104
|
const t = line.trim();
|
|
105
|
+
if (!t) { rl.prompt(); return; }
|
|
48
106
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
if (
|
|
107
|
+
// Slash commands
|
|
108
|
+
if (t === "/quit" || t === "/exit") { rl.close(); return; }
|
|
109
|
+
if (t === "/help" || t === "/?") { showHelp(); rl.prompt(); return; }
|
|
110
|
+
if (t === "/pwd") { process.stdout.write(getWorkspace() + "\n"); rl.prompt(); return; }
|
|
111
|
+
if (t === "/queue") { showQueue(queue); rl.prompt(); return; }
|
|
112
|
+
if (t === "/status") { showStatus(config, session, queue, busy); rl.prompt(); return; }
|
|
113
|
+
if (t === "/history") { showHistory(session); rl.prompt(); return; }
|
|
114
|
+
if (t === "/clear") { clearScreen(); rl.prompt(); return; }
|
|
115
|
+
if (t === "/sessions") { await showSessions(); rl.prompt(); return; }
|
|
116
|
+
if (t === "/providers") { showProviders(); rl.prompt(); return; }
|
|
117
|
+
if (t.slice(0, 4) === "/cd ") { changeDir(t.slice(4).trim()); rl.prompt(); return; }
|
|
118
|
+
if (t.slice(0, 10) === "/provider ") {
|
|
119
|
+
await changeProvider(t.slice(10).trim(), page, config, provider);
|
|
54
120
|
rl.prompt();
|
|
55
121
|
return;
|
|
56
122
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
help();
|
|
63
|
-
rl.prompt();
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
if (t === "/pwd") {
|
|
67
|
-
console.log(getWs());
|
|
123
|
+
|
|
124
|
+
// Queue or execute
|
|
125
|
+
if (busy) {
|
|
126
|
+
queue.push(t);
|
|
127
|
+
process.stdout.write(theme.dim("queued (" + queue.length + " waiting)") + "\n");
|
|
68
128
|
rl.prompt();
|
|
69
129
|
return;
|
|
70
130
|
}
|
|
71
|
-
|
|
72
|
-
|
|
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();
|
|
131
|
+
queue.push(t);
|
|
132
|
+
drain();
|
|
85
133
|
});
|
|
86
134
|
|
|
87
135
|
rl.on("close", async function () {
|
|
88
|
-
|
|
89
|
-
|
|
136
|
+
process.stdout.write("\n");
|
|
137
|
+
process.stdout.write(theme.dim("saving session...") + "\n");
|
|
138
|
+
await saveSession(session.id, session).catch(function () {});
|
|
139
|
+
process.stdout.write(theme.dim("closing browser...") + "\n");
|
|
90
140
|
await ctx.close().catch(function () {});
|
|
141
|
+
process.stdout.write(theme.ok("+ done") + "\n");
|
|
91
142
|
process.exit(0);
|
|
92
143
|
});
|
|
93
144
|
}
|
|
94
145
|
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
146
|
+
function newSession(config) {
|
|
147
|
+
return {
|
|
148
|
+
id: newSessionId(),
|
|
149
|
+
ws: config.ws,
|
|
150
|
+
provider: config.provider,
|
|
151
|
+
turns: [],
|
|
152
|
+
approvedAlways: [],
|
|
153
|
+
startedAt: Date.now(),
|
|
154
|
+
updatedAt: Date.now()
|
|
155
|
+
};
|
|
100
156
|
}
|
|
101
157
|
|
|
102
|
-
function
|
|
158
|
+
function showHelp() {
|
|
159
|
+
const lines = [
|
|
160
|
+
"COMMANDS",
|
|
161
|
+
" /help show this",
|
|
162
|
+
" /cd <folder> change workspace",
|
|
163
|
+
" /pwd show current workspace",
|
|
164
|
+
" /provider <name> switch provider",
|
|
165
|
+
" /providers list providers",
|
|
166
|
+
" /sessions list saved sessions",
|
|
167
|
+
" /history show recent turns",
|
|
168
|
+
" /queue list tasks waiting to run",
|
|
169
|
+
" /status show session status",
|
|
170
|
+
" /clear clear the screen",
|
|
171
|
+
" /quit exit",
|
|
172
|
+
"",
|
|
173
|
+
"Anything else is sent to the model as a task.",
|
|
174
|
+
"Tasks typed while the model is busy are queued."
|
|
175
|
+
];
|
|
176
|
+
for (const l of lines) {
|
|
177
|
+
process.stdout.write((l.startsWith(" ") ? theme.dim(l) : theme.bold(l)) + "\n");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function showQueue(queue) {
|
|
182
|
+
if (!queue.length) {
|
|
183
|
+
process.stdout.write(theme.dim("(queue empty)") + "\n");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
process.stdout.write(theme.bold(queue.length + " task(s) waiting:") + "\n");
|
|
187
|
+
queue.forEach(function (x, i) {
|
|
188
|
+
process.stdout.write(theme.dim(" " + (i + 1) + ". ") + x + "\n");
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function showStatus(config, session, queue, busy) {
|
|
193
|
+
process.stdout.write(theme.bold("session status") + "\n");
|
|
194
|
+
process.stdout.write(theme.dim(" id: ") + session.id + "\n");
|
|
195
|
+
process.stdout.write(theme.dim(" workspace: ") + getWorkspace() + "\n");
|
|
196
|
+
process.stdout.write(theme.dim(" provider: ") + session.provider + "\n");
|
|
197
|
+
process.stdout.write(theme.dim(" turns: ") + session.turns.length + "\n");
|
|
198
|
+
process.stdout.write(theme.dim(" busy: ") + (busy ? "yes" : "no") + "\n");
|
|
199
|
+
process.stdout.write(theme.dim(" queued: ") + queue.length + "\n");
|
|
200
|
+
process.stdout.write(theme.dim(" yolo: ") + (config.yolo ? "on" : "off") + "\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function showHistory(session) {
|
|
204
|
+
if (!session.turns.length) {
|
|
205
|
+
process.stdout.write(theme.dim("(no turns yet)") + "\n");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const recent = session.turns.slice(-10);
|
|
209
|
+
for (const t of recent) {
|
|
210
|
+
const tag = t.role === "user" ? theme.user("you") : theme.cyan("ai");
|
|
211
|
+
const text = String(t.text || "").slice(0, 200);
|
|
212
|
+
process.stdout.write(tag + ": " + text + "\n");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function showSessions() {
|
|
217
|
+
const list = await listSessions();
|
|
218
|
+
if (!list.length) {
|
|
219
|
+
process.stdout.write(theme.dim("(no saved sessions)") + "\n");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
process.stdout.write(theme.bold("saved sessions:") + "\n");
|
|
223
|
+
for (const s of list.slice(0, 15)) {
|
|
224
|
+
const when = timeAgo(s.updatedAt);
|
|
225
|
+
process.stdout.write(theme.dim(" " + s.id + " " + when + " " +
|
|
226
|
+
s.provider + " " + s.turns + " turns") + "\n");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function showProviders() {
|
|
231
|
+
const list = listProviders();
|
|
232
|
+
process.stdout.write(theme.bold("providers:") + "\n");
|
|
233
|
+
for (const p of list) {
|
|
234
|
+
process.stdout.write(theme.dim(" " + p.id.padEnd(10)) + " " + p.label + "\n");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function changeDir(target) {
|
|
103
239
|
if (!target) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
rl.prompt();
|
|
240
|
+
process.stdout.write(theme.dim("usage: /cd <folder>") + "\n");
|
|
241
|
+
process.stdout.write(theme.dim("current: ") + getWorkspace() + "\n");
|
|
107
242
|
return;
|
|
108
243
|
}
|
|
109
244
|
const clean = target.replace(/^"|"$/g, "");
|
|
110
245
|
const abs = resolve(clean);
|
|
111
246
|
if (!existsSync(abs)) {
|
|
112
|
-
|
|
113
|
-
rl.prompt();
|
|
247
|
+
warn("not found: " + abs);
|
|
114
248
|
return;
|
|
115
249
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
250
|
+
setWorkspace(abs);
|
|
251
|
+
ok("workspace: " + getWorkspace());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function changeProvider(name, page, config, current) {
|
|
255
|
+
if (name === current.id) {
|
|
256
|
+
process.stdout.write(theme.dim("already on " + name) + "\n");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
let next;
|
|
260
|
+
try {
|
|
261
|
+
next = getProvider(name);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
error(e.message);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
info("switching to " + next.label + "...");
|
|
267
|
+
await page.goto(next.url);
|
|
268
|
+
await waitForComposer(page, next, 60000);
|
|
269
|
+
config.provider = name;
|
|
270
|
+
ok("switched to " + next.label);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function clearScreen() {
|
|
274
|
+
process.stdout.write(String.fromCharCode(27) + "[2J");
|
|
275
|
+
process.stdout.write(String.fromCharCode(27) + "[H");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function timeAgo(ms) {
|
|
279
|
+
const s = Math.floor((Date.now() - ms) / 1000);
|
|
280
|
+
if (s < 60) return s + "s ago";
|
|
281
|
+
const m = Math.floor(s / 60);
|
|
282
|
+
if (m < 60) return m + "m ago";
|
|
283
|
+
const h = Math.floor(m / 60);
|
|
284
|
+
if (h < 24) return h + "h ago";
|
|
285
|
+
return Math.floor(h / 24) + "d ago";
|
|
119
286
|
}
|
package/lib/safety.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { theme } from "./theme.mjs";
|
|
3
|
+
|
|
4
|
+
// Tools that touch the filesystem destructively or execute code. These
|
|
5
|
+
// require explicit approval unless --yolo is set.
|
|
6
|
+
export const SENSITIVE = new Set([
|
|
7
|
+
"shell",
|
|
8
|
+
"delete_file",
|
|
9
|
+
"move_file",
|
|
10
|
+
"write_file",
|
|
11
|
+
"edit_file"
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
// Read-only tools. Never prompt.
|
|
15
|
+
export const SAFE = new Set([
|
|
16
|
+
"read_file",
|
|
17
|
+
"list_files",
|
|
18
|
+
"find_files",
|
|
19
|
+
"grep_search",
|
|
20
|
+
"stat_file",
|
|
21
|
+
"git_status",
|
|
22
|
+
"git_diff"
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export function isSensitive(name) {
|
|
26
|
+
return SENSITIVE.has(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isSafe(name) {
|
|
30
|
+
return SAFE.has(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Approval prompt. Reads a single key from stdin.
|
|
34
|
+
export async function askApproval(toolName, args) {
|
|
35
|
+
const preview = JSON.stringify(args || {}, null, 2).slice(0, 500);
|
|
36
|
+
process.stdout.write("\n");
|
|
37
|
+
process.stdout.write(theme.warn("+ approval required") + "\n");
|
|
38
|
+
process.stdout.write(theme.dim(" tool: ") + theme.tool(toolName) + "\n");
|
|
39
|
+
const lines = preview.split("\n").slice(0, 12);
|
|
40
|
+
for (const l of lines) {
|
|
41
|
+
process.stdout.write(theme.dim(" " + l) + "\n");
|
|
42
|
+
}
|
|
43
|
+
if (preview.split("\n").length > 12) {
|
|
44
|
+
process.stdout.write(theme.dim(" ...") + "\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return new Promise(function (resolve) {
|
|
48
|
+
const rl = createInterface({
|
|
49
|
+
input: process.stdin,
|
|
50
|
+
output: process.stdout
|
|
51
|
+
});
|
|
52
|
+
rl.question(
|
|
53
|
+
theme.bold(" allow? ") + theme.ok("y") + "es / " +
|
|
54
|
+
theme.warn("a") + "lways / " + theme.error("n") + "o: ",
|
|
55
|
+
function (answer) {
|
|
56
|
+
rl.close();
|
|
57
|
+
const a = answer.trim().toLowerCase();
|
|
58
|
+
if (a === "y" || a === "yes") resolve("yes");
|
|
59
|
+
else if (a === "a" || a === "always") resolve("always");
|
|
60
|
+
else resolve("no");
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
}
|
package/lib/send.mjs
CHANGED
|
@@ -1,88 +1,92 @@
|
|
|
1
|
-
import { sleep, norm } from "./wait.mjs";
|
|
1
|
+
import { sleep, norm, findFirstVisible, countMatching } from "./wait.mjs";
|
|
2
2
|
import { dismissPaste, composerEmpty } from "./paste.mjs";
|
|
3
3
|
|
|
4
|
+
const SETTLE_MS = 2000;
|
|
5
|
+
|
|
4
6
|
export async function send(page, provider, prompt, timeoutMs) {
|
|
5
|
-
const input = page
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const input = await findFirstVisible(page, provider.input, 30000);
|
|
8
|
+
if (!input) throw new Error("composer not found");
|
|
9
|
+
|
|
10
|
+
const before = await countMatching(page, provider.response);
|
|
11
|
+
const want = norm(prompt);
|
|
8
12
|
|
|
9
13
|
await input.focus();
|
|
10
14
|
try {
|
|
11
15
|
await page.keyboard.press("Control+A");
|
|
12
16
|
await page.keyboard.press("Delete");
|
|
13
17
|
} catch (e) {
|
|
14
|
-
// composer
|
|
18
|
+
// empty composer
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
await page.keyboard.insertText(prompt);
|
|
18
22
|
await sleep(300);
|
|
19
|
-
|
|
20
23
|
if (provider.paste) await dismissPaste(page);
|
|
21
24
|
|
|
22
25
|
try {
|
|
23
26
|
await input.press("Enter");
|
|
24
27
|
} catch (e) {
|
|
25
|
-
// fall through
|
|
28
|
+
// fall through
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
let ok = await composerEmpty(input, 5000);
|
|
29
|
-
|
|
30
32
|
if (!ok) {
|
|
31
|
-
const btn = page
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} catch (e) {
|
|
35
|
-
// no button
|
|
33
|
+
const btn = await findFirstVisible(page, provider.submit, 1500);
|
|
34
|
+
if (btn) {
|
|
35
|
+
try { await btn.click({ timeout: 1500 }); } catch (e) {}
|
|
36
36
|
}
|
|
37
37
|
ok = await composerEmpty(input, 3000);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
if (!ok) {
|
|
41
41
|
throw new Error(
|
|
42
|
-
"Message stayed in composer
|
|
43
|
-
"Cancel/Send bar is showing, click Send
|
|
42
|
+
"Message stayed in composer.\n" +
|
|
43
|
+
"Check the Chrome window. If a Cancel/Send bar is showing, click Send " +
|
|
44
|
+
"once manually, then resend."
|
|
44
45
|
);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
return waitForReply(page, provider, want, timeoutMs);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function waitForReply(page, provider, want, timeoutMs) {
|
|
48
52
|
const end = Date.now() + timeoutMs;
|
|
49
|
-
let
|
|
53
|
+
let last = "";
|
|
54
|
+
let stab = Date.now();
|
|
55
|
+
let sawAnything = false;
|
|
56
|
+
let lastCount = 0;
|
|
50
57
|
|
|
51
58
|
while (Date.now() < end) {
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
appeared = true;
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
await sleep(300);
|
|
58
|
-
}
|
|
59
|
+
const count = await countMatching(page, provider.response);
|
|
60
|
+
if (count > lastCount) lastCount = count;
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
const node = await findFirstVisible(page, provider.response, 1000);
|
|
63
|
+
if (node) {
|
|
64
|
+
let tx = "";
|
|
65
|
+
try { tx = (await node.textContent()) || ""; } catch (e) { tx = ""; }
|
|
66
|
+
const n = norm(tx);
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
// Skip empty nodes and the echoed prompt.
|
|
69
|
+
if (!n || n === want) {
|
|
70
|
+
await sleep(300);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
64
73
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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;
|
|
74
|
+
if (tx !== last) {
|
|
75
|
+
last = tx;
|
|
76
|
+
stab = Date.now();
|
|
77
|
+
sawAnything = true;
|
|
78
|
+
} else if (sawAnything && Date.now() - stab > SETTLE_MS) {
|
|
79
|
+
return tx;
|
|
80
|
+
}
|
|
83
81
|
}
|
|
82
|
+
|
|
84
83
|
await sleep(300);
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
|
|
86
|
+
if (last) return last;
|
|
87
|
+
throw new Error(
|
|
88
|
+
"Timed out waiting for a reply.\n" +
|
|
89
|
+
"Check the Chrome window. If the model answered but Portal missed it, " +
|
|
90
|
+
"send the task again."
|
|
91
|
+
);
|
|
88
92
|
}
|