wtagent 0.1.0-alpha.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 +21 -0
- package/README.md +83 -0
- package/docs/technical-design.md +838 -0
- package/package.json +55 -0
- package/src/browser/cdp-browser.js +170 -0
- package/src/browser/chatgpt-web-adapter.js +717 -0
- package/src/browser/fake-web-model-adapter.js +86 -0
- package/src/browser/mode-selection.js +179 -0
- package/src/browser/native-login.js +49 -0
- package/src/cli/at-files.js +93 -0
- package/src/cli/main.js +629 -0
- package/src/cli/render-events.js +367 -0
- package/src/platform/chrome-discovery.js +94 -0
- package/src/platform/paths.js +73 -0
- package/src/policy/path-guard.js +114 -0
- package/src/policy/policy-engine.js +277 -0
- package/src/protocol/markers.js +64 -0
- package/src/protocol/prompt-builder.js +152 -0
- package/src/protocol/xml-protocol.js +333 -0
- package/src/runtime/agent-runtime.js +636 -0
- package/src/session/agent-session.js +569 -0
- package/src/session/canonical-transcript.js +145 -0
- package/src/session/session-export.js +202 -0
- package/src/session/task-session.js +5 -0
- package/src/shared/errors.js +52 -0
- package/src/shared/limits.js +31 -0
- package/src/tools/default-tools.js +555 -0
- package/src/tools/process-manager.js +116 -0
- package/src/tools/process-utils.js +31 -0
- package/src/tools/registry.js +129 -0
- package/src/tools/safe-env.js +67 -0
- package/src/tools/terminal-exec.js +118 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export class FakeWebModelAdapter {
|
|
2
|
+
constructor(responses = []) {
|
|
3
|
+
this.responses = [...responses];
|
|
4
|
+
this.sentMessages = [];
|
|
5
|
+
this.profileDir = "fake-profile";
|
|
6
|
+
this.launched = false;
|
|
7
|
+
this.mode = null;
|
|
8
|
+
this.conversationUrl = "https://chatgpt.com/";
|
|
9
|
+
this.lastAssistantMessageId = null;
|
|
10
|
+
this.responseNumber = 0;
|
|
11
|
+
this.startConversationCalls = [];
|
|
12
|
+
this.sentAttachments = [];
|
|
13
|
+
// Records window-state calls so tests can assert restore/minimize ordering.
|
|
14
|
+
this.windowStateCalls = [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async launch() {
|
|
18
|
+
this.launched = true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async close() {
|
|
22
|
+
this.launched = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async restoreWindow() {
|
|
26
|
+
this.windowStateCalls.push("restore");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async minimizeWindow() {
|
|
30
|
+
this.windowStateCalls.push("minimize");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getAuthState() {
|
|
34
|
+
return "authenticated";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async waitForManualLogin() {}
|
|
38
|
+
|
|
39
|
+
async startConversation(conversationUrl = null) {
|
|
40
|
+
this.startConversationCalls.push(conversationUrl);
|
|
41
|
+
if (conversationUrl) {
|
|
42
|
+
this.conversationUrl = conversationUrl;
|
|
43
|
+
} else {
|
|
44
|
+
this.conversationUrl = "https://chatgpt.com/";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async selectMode(mode) {
|
|
49
|
+
this.mode = mode;
|
|
50
|
+
return {
|
|
51
|
+
status: "select",
|
|
52
|
+
requested: mode,
|
|
53
|
+
selectedLabel: mode,
|
|
54
|
+
attempts: 1,
|
|
55
|
+
reason: `Selecting ${mode}.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async getConversationUrl() {
|
|
60
|
+
return this.conversationUrl;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async sendMessage(text, { files = [] } = {}) {
|
|
64
|
+
this.sentMessages.push(text);
|
|
65
|
+
this.sentAttachments.push(files);
|
|
66
|
+
if (this.conversationUrl === "https://chatgpt.com/") {
|
|
67
|
+
this.conversationUrl = "https://chatgpt.com/c/fake";
|
|
68
|
+
}
|
|
69
|
+
return { attachment: files.length ? { attached: files, failed: [] } : null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async waitForTurnComplete({ onDelta } = {}) {
|
|
73
|
+
if (this.responses.length === 0) {
|
|
74
|
+
throw new Error("Fake adapter has no more responses.");
|
|
75
|
+
}
|
|
76
|
+
const response = this.responses.shift();
|
|
77
|
+
this.responseNumber += 1;
|
|
78
|
+
this.lastAssistantMessageId = `assistant-${this.responseNumber}`;
|
|
79
|
+
await onDelta?.(response);
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getLastAssistantMessageId() {
|
|
84
|
+
return this.lastAssistantMessageId;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Language-independent model/mode selection logic for the ChatGPT web picker.
|
|
2
|
+
//
|
|
3
|
+
// The picker's visible labels are localized (e.g. "Pro", and reasoning tiers
|
|
4
|
+
// like "Extra high"), so we must NOT match options by their display text.
|
|
5
|
+
// Instead every option is identified by a stable attribute "slug" derived from
|
|
6
|
+
// data-testid / data-* / id, which does not change with the UI language.
|
|
7
|
+
//
|
|
8
|
+
// This module is pure and DOM-free so it can be unit-tested without a browser.
|
|
9
|
+
// The adapter supplies a small "port" of async DOM operations to
|
|
10
|
+
// `runModeSelection`; all the retry / fallback policy lives here.
|
|
11
|
+
|
|
12
|
+
export function slugTokens(slug) {
|
|
13
|
+
return String(slug ?? "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeToken(value) {
|
|
17
|
+
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Does the option's stable slug encode the requested mode token?
|
|
21
|
+
// `token` is a normalized (separator-free) string, e.g. "pro" or "extrahigh".
|
|
22
|
+
// Short tokens (< 4 chars, e.g. "pro") require an exact whole-segment match so
|
|
23
|
+
// "pro" does not match "provider". Longer tokens also allow a substring match
|
|
24
|
+
// against the separator-free slug, so a multi-word mode like "Extra high"
|
|
25
|
+
// (-> "extrahigh") matches a hyphenated slug like "o3-extra-high".
|
|
26
|
+
export function slugMatchesToken(slug, token) {
|
|
27
|
+
if (!token) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const tokens = slugTokens(slug);
|
|
31
|
+
if (tokens.includes(token)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (token.length >= 4) {
|
|
35
|
+
const joined = tokens.join("");
|
|
36
|
+
return joined.includes(token) || tokens.some((candidate) => candidate.includes(token));
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Given the enumerated menu options and the requested mode, decide what to do.
|
|
42
|
+
// options: [{ index, slug, label, disabled }] in DOM order.
|
|
43
|
+
// Returns { status, targetIndex, selectedLabel, reason }.
|
|
44
|
+
// status "select" -> requested mode is available; click it.
|
|
45
|
+
// status "fallback" -> requested mode is limited/disabled; click
|
|
46
|
+
// the option immediately before it.
|
|
47
|
+
// status "unavailable" -> requested mode is not present in the menu.
|
|
48
|
+
// status "unavailable_disabled"-> requested mode is disabled and the previous
|
|
49
|
+
// option is not selectable either.
|
|
50
|
+
export function chooseModeOption(options, requested) {
|
|
51
|
+
const token = normalizeToken(requested);
|
|
52
|
+
const proIndex = options.findIndex((option) => slugMatchesToken(option.slug, token));
|
|
53
|
+
|
|
54
|
+
if (proIndex === -1) {
|
|
55
|
+
return {
|
|
56
|
+
status: "unavailable",
|
|
57
|
+
targetIndex: null,
|
|
58
|
+
selectedLabel: null,
|
|
59
|
+
reason: `"${requested}" is not in the model menu.`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pro = options[proIndex];
|
|
64
|
+
if (!pro.disabled) {
|
|
65
|
+
return {
|
|
66
|
+
status: "select",
|
|
67
|
+
targetIndex: proIndex,
|
|
68
|
+
selectedLabel: pro.label || requested,
|
|
69
|
+
reason: `Selecting ${requested}.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Pro is present but limited. Fall back to the option immediately before it.
|
|
74
|
+
const previous = options[proIndex - 1];
|
|
75
|
+
if (previous && !previous.disabled) {
|
|
76
|
+
return {
|
|
77
|
+
status: "fallback",
|
|
78
|
+
targetIndex: proIndex - 1,
|
|
79
|
+
selectedLabel: previous.label || `option ${proIndex}`,
|
|
80
|
+
reason: `${requested} is limited; selecting the previous option `
|
|
81
|
+
+ `"${previous.label || proIndex - 1}" instead.`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
status: "unavailable_disabled",
|
|
87
|
+
targetIndex: null,
|
|
88
|
+
selectedLabel: null,
|
|
89
|
+
reason: previous
|
|
90
|
+
? `${requested} is limited and the previous option is also unavailable.`
|
|
91
|
+
: `${requested} is limited and has no previous option to fall back to.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Orchestrates selection against a DOM "port". Never throws for mode issues:
|
|
96
|
+
// it always resolves to a structured result the runtime can surface to the CLI,
|
|
97
|
+
// so a limited/absent Pro degrades gracefully instead of aborting the run.
|
|
98
|
+
//
|
|
99
|
+
// port: {
|
|
100
|
+
// alreadyOnMode(requested) -> bool,
|
|
101
|
+
// hasSwitcher() -> bool,
|
|
102
|
+
// openMenu() -> void,
|
|
103
|
+
// readOptions() -> [{index, slug, label, disabled}],
|
|
104
|
+
// clickOption(index) -> bool (click landed),
|
|
105
|
+
// waitClosed() -> bool (menu closed after click),
|
|
106
|
+
// closeMenu() -> void,
|
|
107
|
+
// writeDiagnostics(label) -> void,
|
|
108
|
+
// }
|
|
109
|
+
export async function runModeSelection(port, requested, { maxAttempts = 2 } = {}) {
|
|
110
|
+
if (!requested) {
|
|
111
|
+
return { status: "skipped", requested, attempts: 0, reason: "No mode requested." };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (await port.alreadyOnMode(requested)) {
|
|
115
|
+
return {
|
|
116
|
+
status: "already",
|
|
117
|
+
requested,
|
|
118
|
+
selectedLabel: requested,
|
|
119
|
+
attempts: 0,
|
|
120
|
+
reason: `Already using ${requested}.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!await port.hasSwitcher()) {
|
|
125
|
+
await port.writeDiagnostics("mode-switcher-not-found");
|
|
126
|
+
return {
|
|
127
|
+
status: "switcher_not_found",
|
|
128
|
+
requested,
|
|
129
|
+
attempts: 0,
|
|
130
|
+
reason: "Model switcher was not found.",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let lastReason = "";
|
|
135
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
136
|
+
await port.openMenu();
|
|
137
|
+
const options = await port.readOptions();
|
|
138
|
+
|
|
139
|
+
// The Radix menu populates asynchronously; an empty read is a race, retry.
|
|
140
|
+
if (!options || options.length === 0) {
|
|
141
|
+
lastReason = "Model menu did not populate in time.";
|
|
142
|
+
await port.closeMenu();
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const choice = chooseModeOption(options, requested);
|
|
147
|
+
|
|
148
|
+
if (choice.status === "select" || choice.status === "fallback") {
|
|
149
|
+
const clicked = await port.clickOption(choice.targetIndex);
|
|
150
|
+
const closed = clicked && await port.waitClosed();
|
|
151
|
+
if (closed) {
|
|
152
|
+
return { ...choice, requested, attempts: attempt };
|
|
153
|
+
}
|
|
154
|
+
lastReason = "The option click did not register.";
|
|
155
|
+
await port.closeMenu();
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await port.closeMenu();
|
|
160
|
+
|
|
161
|
+
// A genuinely absent Pro might still be populating on the first pass, so
|
|
162
|
+
// retry once; a disabled Pro will not change, so report immediately.
|
|
163
|
+
if (choice.status === "unavailable" && attempt < maxAttempts) {
|
|
164
|
+
lastReason = choice.reason;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await port.writeDiagnostics(`mode-${requested}-${choice.status}`);
|
|
169
|
+
return { ...choice, requested, attempts: attempt };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await port.writeDiagnostics(`mode-${requested}-unresolved`);
|
|
173
|
+
return {
|
|
174
|
+
status: "unresolved",
|
|
175
|
+
requested,
|
|
176
|
+
attempts: maxAttempts,
|
|
177
|
+
reason: lastReason || `Could not select ${requested}.`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { discoverChromeExecutable } from "../platform/chrome-discovery.js";
|
|
3
|
+
import { ensureDirectory } from "../platform/paths.js";
|
|
4
|
+
import { killProcessTree } from "../tools/process-utils.js";
|
|
5
|
+
|
|
6
|
+
export async function launchNativeLoginBrowser({
|
|
7
|
+
profileDir,
|
|
8
|
+
chromePath,
|
|
9
|
+
url = "https://chatgpt.com/",
|
|
10
|
+
}) {
|
|
11
|
+
await ensureDirectory(profileDir);
|
|
12
|
+
const executablePath = discoverChromeExecutable(chromePath);
|
|
13
|
+
const child = spawn(
|
|
14
|
+
executablePath,
|
|
15
|
+
[
|
|
16
|
+
`--user-data-dir=${profileDir}`,
|
|
17
|
+
"--profile-directory=Default",
|
|
18
|
+
"--no-first-run",
|
|
19
|
+
"--no-default-browser-check",
|
|
20
|
+
url,
|
|
21
|
+
],
|
|
22
|
+
{
|
|
23
|
+
detached: process.platform !== "win32",
|
|
24
|
+
stdio: "ignore",
|
|
25
|
+
windowsHide: false,
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
await new Promise((resolve, reject) => {
|
|
30
|
+
child.once("spawn", resolve);
|
|
31
|
+
child.once("error", reject);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
pid: child.pid,
|
|
36
|
+
async close() {
|
|
37
|
+
await killProcessTree(child.pid);
|
|
38
|
+
await new Promise((resolve) => {
|
|
39
|
+
if (child.exitCode != null || child.signalCode != null) {
|
|
40
|
+
resolve();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
child.once("close", resolve);
|
|
44
|
+
setTimeout(resolve, 3_000).unref?.();
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Parses `@file` mentions from an interactive message so the CLI can attach the
|
|
2
|
+
// referenced files to the ChatGPT web composer.
|
|
3
|
+
//
|
|
4
|
+
// Supported forms:
|
|
5
|
+
// @path/to/file.txt bare path (stops at whitespace)
|
|
6
|
+
// @/abs/path/to/file.jpeg absolute path
|
|
7
|
+
// @"my file.pdf" double-quoted path (may contain spaces)
|
|
8
|
+
// @'my file.pdf' single-quoted path
|
|
9
|
+
//
|
|
10
|
+
// A bare `@` not followed by a path-like character (e.g. an email local part
|
|
11
|
+
// like "foo@bar.com", or "@" alone) is left untouched.
|
|
12
|
+
//
|
|
13
|
+
// Attachments are NOT bounded to the project root: this is a web upload to the
|
|
14
|
+
// user's own ChatGPT session (not a local tool read), so the user may attach
|
|
15
|
+
// any file they can point at — an absolute path outside the project is valid.
|
|
16
|
+
// A relative path is resolved against the project root for convenience.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs/promises";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
// A mention is `@` that is at the start of the string or preceded by
|
|
23
|
+
// whitespace, followed by either a quoted path or a bare path token.
|
|
24
|
+
const MENTION = /(^|\s)@(?:"([^"]+)"|'([^']+)'|([^\s"']+))/g;
|
|
25
|
+
|
|
26
|
+
// Extracts @mentions and resolves them to existing files.
|
|
27
|
+
// Returns:
|
|
28
|
+
// mentions: [{ raw, requested }] every syntactic @token found
|
|
29
|
+
// files: [{ name, path, requested }] resolved, existing files (any location)
|
|
30
|
+
// missing: [{ requested, reason }] tokens that did not resolve to a file
|
|
31
|
+
export async function extractAtMentions(text, projectRoot) {
|
|
32
|
+
const source = String(text ?? "");
|
|
33
|
+
const mentions = [];
|
|
34
|
+
for (const match of source.matchAll(MENTION)) {
|
|
35
|
+
const requested = match[2] ?? match[3] ?? match[4] ?? "";
|
|
36
|
+
if (requested) {
|
|
37
|
+
mentions.push({ raw: `@${match[2] != null || match[3] != null ? `"${requested}"` : requested}`, requested });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const files = [];
|
|
42
|
+
const missing = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const mention of mentions) {
|
|
45
|
+
if (seen.has(mention.requested)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
seen.add(mention.requested);
|
|
49
|
+
|
|
50
|
+
// Absolute paths are used as-is; relative paths resolve against the project
|
|
51
|
+
// root. `~` is expanded to the user's home directory for convenience.
|
|
52
|
+
const expanded = expandHome(mention.requested);
|
|
53
|
+
const absPath = path.isAbsolute(expanded)
|
|
54
|
+
? path.resolve(expanded)
|
|
55
|
+
: path.resolve(projectRoot, expanded);
|
|
56
|
+
|
|
57
|
+
let stat;
|
|
58
|
+
try {
|
|
59
|
+
stat = await fs.stat(absPath);
|
|
60
|
+
} catch {
|
|
61
|
+
missing.push({ requested: mention.requested, reason: "not-found" });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!stat.isFile()) {
|
|
65
|
+
missing.push({ requested: mention.requested, reason: "not-a-file" });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
files.push({
|
|
70
|
+
name: basename(absPath),
|
|
71
|
+
path: absPath,
|
|
72
|
+
requested: mention.requested,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { mentions, files, missing };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function expandHome(rawPath) {
|
|
80
|
+
const value = String(rawPath ?? "");
|
|
81
|
+
if (value === "~") {
|
|
82
|
+
return os.homedir();
|
|
83
|
+
}
|
|
84
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
85
|
+
return path.join(os.homedir(), value.slice(2));
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function basename(filePath) {
|
|
91
|
+
const parts = String(filePath).split(/[/\\]/);
|
|
92
|
+
return parts[parts.length - 1] || filePath;
|
|
93
|
+
}
|