@tianmucreations/jeeves 0.2.1 → 0.3.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.
Files changed (57) hide show
  1. package/LICENSE +37 -17
  2. package/README.md +82 -18
  3. package/bin/jeeves +8 -1
  4. package/dist/agent/auto-ids.js +66 -0
  5. package/dist/agent/auto.js +178 -0
  6. package/dist/agent/context.js +55 -13
  7. package/dist/agent/errors.js +83 -22
  8. package/dist/agent/expert-chat.js +33 -0
  9. package/dist/agent/housekeeping.js +55 -0
  10. package/dist/agent/loop.js +174 -12
  11. package/dist/agent/permissions.js +186 -3
  12. package/dist/agent/research-gate.js +267 -0
  13. package/dist/agent/review.js +135 -0
  14. package/dist/agent/spending.js +73 -0
  15. package/dist/agent/systemPrompt.js +112 -0
  16. package/dist/agent/trust.js +29 -0
  17. package/dist/app.js +31 -11
  18. package/dist/checkpoints/index.js +103 -0
  19. package/dist/checkpoints/store.js +239 -0
  20. package/dist/commands/address.js +5 -0
  21. package/dist/commands/clear.js +2 -0
  22. package/dist/commands/help.js +8 -4
  23. package/dist/commands/keys.js +1 -1
  24. package/dist/commands/verbose.js +1 -1
  25. package/dist/components/AddressPrompt.js +31 -0
  26. package/dist/components/Footer.js +74 -102
  27. package/dist/components/Input.js +115 -29
  28. package/dist/components/KeysManager.js +65 -20
  29. package/dist/components/ModelPicker.js +348 -75
  30. package/dist/components/ProjectPicker.js +4 -1
  31. package/dist/components/Transcript.js +29 -14
  32. package/dist/components/input-layout.js +92 -0
  33. package/dist/components/transcript-layout.js +27 -19
  34. package/dist/index.js +25 -7
  35. package/dist/ink/AlternateScreen.js +33 -16
  36. package/dist/ink/cursor.js +18 -0
  37. package/dist/ink/mouse.js +48 -0
  38. package/dist/keys/store.js +2 -1
  39. package/dist/models/registry.js +18 -2
  40. package/dist/platform/config.js +71 -7
  41. package/dist/providers/catalogue.js +293 -0
  42. package/dist/providers/direct-services.js +65 -0
  43. package/dist/providers/direct.js +145 -0
  44. package/dist/providers/index.js +123 -13
  45. package/dist/providers/models-snapshot.js +1037 -0
  46. package/dist/providers/ollama.js +21 -4
  47. package/dist/providers/openrouter.js +39 -4
  48. package/dist/providers/step-control.js +28 -0
  49. package/dist/providers/zai.js +31 -11
  50. package/dist/state/session.js +110 -36
  51. package/dist/state/today-spend.js +26 -0
  52. package/dist/tools/index.js +123 -11
  53. package/dist/tools/runBash.js +58 -11
  54. package/dist/tools/web/htmlToText.js +32 -0
  55. package/dist/tools/web/openrouterChat.js +31 -0
  56. package/dist/tools/web/research.js +191 -0
  57. package/package.json +33 -7
@@ -1,22 +1,205 @@
1
1
  import { session } from '../state/session.js';
2
+ import { trustProject } from './trust.js';
3
+ import { getAddress } from '../platform/config.js';
4
+ // Pure-output commands that never require permission (the rule: harmless
5
+ // output must never prompt). A command is auto-approved only when every stage of
6
+ // it - across pipes, && and || - is one of the commands below, and the only
7
+ // redirection anywhere is to /dev/null. Anything that writes, deletes, installs,
8
+ // or reaches the network still prompts.
9
+ const READ_ONLY_COMMANDS = new Set([
10
+ 'yes',
11
+ 'head',
12
+ 'tail',
13
+ 'cat',
14
+ 'ls',
15
+ 'pwd',
16
+ 'wc',
17
+ 'file',
18
+ 'which',
19
+ 'whoami',
20
+ 'date',
21
+ 'echo',
22
+ 'printf',
23
+ 'seq',
24
+ 'sort',
25
+ 'uniq',
26
+ 'tr',
27
+ 'grep',
28
+ 'awk',
29
+ 'sed',
30
+ 'cut',
31
+ 'fold',
32
+ 'column',
33
+ ]);
34
+ // Multi-word commands where the words themselves are the read-only form.
35
+ const READ_ONLY_TWO_WORD_COMMANDS = new Set([
36
+ 'git status',
37
+ 'git log',
38
+ 'git diff',
39
+ 'node --version',
40
+ 'npm --version',
41
+ ]);
42
+ // git branch lists branches when nothing but flags follows; any name argument
43
+ // would create, delete, or modify a branch, so only the listing form is allowed.
44
+ function isReadOnlyGitBranch(rest) {
45
+ return rest.every((token) => token.startsWith('-'));
46
+ }
47
+ // sed with -n only prints (the p command); -i edits files in place, and the w
48
+ // command writes files. The whole argument string is checked for a 'w' because
49
+ // scanning sed script syntax reliably is not worth the risk - a false "ask" is
50
+ // safe, a false "allow" is not.
51
+ function isReadOnlySed(rest) {
52
+ return rest.includes('-n') && !rest.some((token) => token === '-i' || token.startsWith('-i')) && !rest.join(' ').includes('w');
53
+ }
54
+ // awk reads input unless the program itself writes a file or runs a command;
55
+ // a | inside the program pipes to a command, so it disqualifies too.
56
+ function isReadOnlyAwk(rest) {
57
+ const program = rest.join(' ');
58
+ return !program.includes('system(') && !program.includes('>') && !program.includes('|') && !program.includes('getline');
59
+ }
60
+ function isReadOnlyStage(tokens, isFinalStage) {
61
+ if (tokens.length === 0)
62
+ return false;
63
+ const command = tokens[0];
64
+ const rest = tokens.slice(1);
65
+ if (command === 'git') {
66
+ if (rest.length === 0)
67
+ return false;
68
+ if (READ_ONLY_TWO_WORD_COMMANDS.has(`git ${rest[0]}`))
69
+ return true;
70
+ if (rest[0] === 'branch')
71
+ return isReadOnlyGitBranch(rest.slice(1));
72
+ return false;
73
+ }
74
+ if (command === 'node' || command === 'npm') {
75
+ return rest.length === 1 && rest[0] === '--version';
76
+ }
77
+ if (command === 'sed')
78
+ return isReadOnlySed(rest);
79
+ if (command === 'awk')
80
+ return isReadOnlyAwk(rest);
81
+ if (command === 'yes') {
82
+ // yes on its own streams forever; it qualifies only feeding a pipe that ends.
83
+ return !isFinalStage;
84
+ }
85
+ return READ_ONLY_COMMANDS.has(command);
86
+ }
87
+ // One pipe stage: tokens with any /dev/null redirection stripped. Redirection to
88
+ // any other target disqualifies the whole command.
89
+ function parseStage(stage) {
90
+ const tokens = [];
91
+ const parts = stage.trim().split(/\s+/).filter((part) => part.length > 0);
92
+ for (let i = 0; i < parts.length; i++) {
93
+ const part = parts[i];
94
+ // Forms: > /dev/null, 2> /dev/null, &> /dev/null, < /dev/null, and the
95
+ // no-space variants. Redirection to any other target disqualifies.
96
+ if (/^(\d*)>$|^&>$|^<$/.test(part) || /^(?:\d*>|&>|<)\/dev\/null$/.test(part)) {
97
+ const target = /^(?:\d*>|&>|<)\/dev\/null$/.test(part) ? part.replace(/^(?:\d*>|&>|<)/, '') : parts[++i];
98
+ if (target !== '/dev/null')
99
+ return null;
100
+ continue;
101
+ }
102
+ // Any other redirect or input form is a write or an unknown - disqualify.
103
+ if (/[<>]/.test(part))
104
+ return null;
105
+ tokens.push(part);
106
+ }
107
+ return { tokens };
108
+ }
109
+ // Splits on a separator character, ignoring quoted text, so a pipe inside quotes
110
+ // (awk '{print $1 | "sort"}') never counts as a shell pipe.
111
+ function splitOutsideQuotes(text, separator) {
112
+ const parts = [];
113
+ let current = '';
114
+ let quote = null;
115
+ for (let i = 0; i < text.length; i++) {
116
+ const char = text[i];
117
+ if (quote) {
118
+ current += char;
119
+ if (char === quote)
120
+ quote = null;
121
+ continue;
122
+ }
123
+ if (char === '"' || char === "'") {
124
+ quote = char;
125
+ current += char;
126
+ continue;
127
+ }
128
+ if (text.startsWith(separator, i)) {
129
+ parts.push(current);
130
+ current = '';
131
+ i += separator.length - 1;
132
+ continue;
133
+ }
134
+ current += char;
135
+ }
136
+ parts.push(current);
137
+ return parts;
138
+ }
139
+ // True when the command is composed entirely of read-only stages: pipes, && and
140
+ // || chains of allowlisted commands, with redirection to /dev/null only. Anything
141
+ // else - writes, deletes, installs, network, substitutions, semicolons - is false.
142
+ export function isReadOnlyBashCommand(command) {
143
+ const trimmed = command.trim();
144
+ if (trimmed.length === 0)
145
+ return false;
146
+ // Substitution constructs can turn any read into a write - and "$(...)" inside
147
+ // double quotes still executes - so any of these disqualifies outright.
148
+ if (trimmed.includes('`') || trimmed.includes('$('))
149
+ return false;
150
+ if (/[;\n]/.test(stripQuotes(trimmed)))
151
+ return false;
152
+ for (const chain of splitOutsideQuotes(trimmed, '&&')) {
153
+ for (const alternative of splitOutsideQuotes(chain, '||')) {
154
+ const stages = splitOutsideQuotes(alternative, '|');
155
+ for (let i = 0; i < stages.length; i++) {
156
+ const parsed = parseStage(stages[i]);
157
+ if (parsed === null)
158
+ return false;
159
+ if (!isReadOnlyStage(parsed.tokens, i === stages.length - 1))
160
+ return false;
161
+ }
162
+ }
163
+ }
164
+ return true;
165
+ }
166
+ // Replaces quoted spans with empty quoted strings, so metacharacter checks see
167
+ // only what the shell will actually interpret. Shared with the runBash tool.
168
+ export function stripQuotes(text) {
169
+ return text.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''");
170
+ }
2
171
  // Approvals are queued so that parallel tool calls never overwrite each other's prompt.
3
172
  // The amber transcript prompt itself is rendered by the tool line in 'awaiting' state.
4
173
  const queue = [];
5
174
  export function hasPendingApproval() {
6
175
  return queue.length > 0;
7
176
  }
8
- export function requestApproval() {
177
+ export function requestApproval(options = {}) {
9
178
  return new Promise((resolve) => {
10
- queue.push({ resolve });
179
+ queue.push({ resolve, trustable: options.trustable === true });
11
180
  if (queue.length === 1) {
12
181
  session.setActiveApproval();
13
182
  }
14
183
  });
15
184
  }
16
- export function answerApproval(approved) {
185
+ // Whether the question now on screen may be answered with "always allow".
186
+ export function currentApprovalTrustable() {
187
+ return queue[0]?.trustable === true;
188
+ }
189
+ // always: "always allow in this project" - approves this change, every other change
190
+ // inside the project already waiting, and all future ones in this folder.
191
+ export function answerApproval(approved, always = false) {
17
192
  const current = queue.shift();
18
193
  if (!current)
19
194
  return;
195
+ if (always && approved && current.trustable) {
196
+ trustProject();
197
+ session.addNotice(`From now on I won't ask before changing things in this project folder, ${getAddress() ?? 'Sir'} - every change is still backed up, so /undo puts it back. I'll still ask about anything outside it. Type /ask to have me ask every time again.`);
198
+ for (let i = queue.length - 1; i >= 0; i--) {
199
+ if (queue[i].trustable)
200
+ queue.splice(i, 1)[0].resolve(true);
201
+ }
202
+ }
20
203
  current.resolve(approved);
21
204
  if (queue.length > 0) {
22
205
  session.setActiveApproval();
@@ -0,0 +1,267 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import fg from 'fast-glob';
4
+ import { z } from 'zod';
5
+ // Research before building, research before patching - enforced in code, because the
6
+ // cheap model skipped a written "check first" rule in every test run (17 Sept).
7
+ //
8
+ // Copied from Claude Code's file-writing tool (src/tools/FileWriteTool/FileWriteTool.ts,
9
+ // validateInput): before a write is even offered for permission, the tool checks what
10
+ // has happened in the conversation and refuses with a message saying what to do first
11
+ // ("File has not been read yet. Read it first before writing to it."). Its plan mode
12
+ // likewise unlocks editing only through a tool the model must call; here that is
13
+ // noteResearch.
14
+ //
15
+ // Two gates:
16
+ // - Building: creating a program file in a folder that has none yet (a new program,
17
+ // site, app, tool or script), or running a command that starts a new project, is
18
+ // held until a research note is recorded. Adding a file to an existing program, and
19
+ // everyday files (letters, spreadsheets, notes), are never held.
20
+ // - Patching: when the same command fails the same way twice, changing existing files
21
+ // is held until the cause and a fix have been researched.
22
+ // The person can say "skip the research"; it is honoured and noted. Code can make
23
+ // research happen, not make it good - so every note is shown on screen.
24
+ export const HELD_PREFIX = 'Held for research:';
25
+ // Program files: code, web pages, styles and scripts. Everyday files are not.
26
+ export const PROGRAM_EXTENSIONS = [
27
+ 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'vue', 'svelte', 'html', 'htm', 'css', 'scss', 'py', 'rb', 'php', 'go', 'rs',
28
+ 'java', 'kt', 'swift', 'c', 'h', 'cpp', 'cs', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'sql', 'lua', 'pl', 'r', 'dart', 'scala',
29
+ ];
30
+ export function isProgramFile(file) {
31
+ const ext = path.extname(file).slice(1).toLowerCase();
32
+ return PROGRAM_EXTENSIONS.includes(ext);
33
+ }
34
+ // Commands that start a new project from a template or copy one.
35
+ const NEW_PROJECT_COMMAND = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:init|create)\b|npx\s+(?:--yes\s+|-y\s+)?create-|cargo\s+(?:new|init)\b|rails\s+new\b|django-admin\s+startproject\b|flutter\s+create\b|dotnet\s+new\b|go\s+mod\s+init\b|git\s+clone\b|composer\s+create-project\b|uv\s+init\b|poetry\s+new\b)/i;
36
+ export function startsNewProject(command) {
37
+ return NEW_PROJECT_COMMAND.test(command);
38
+ }
39
+ // Commands that change an existing file in place: sed/perl editing, or output sent
40
+ // into a file (">" or ">>", but not "2>&1" or "> /dev/null").
41
+ export function commandEditsFiles(command) {
42
+ if (/\bsed\s+(?:-[a-zA-Z]*i|--in-place)|\bperl\s+-[a-zA-Z]*i/.test(command))
43
+ return true;
44
+ return /(?:^|[^0-9&>])>{1,2}\s*(?!&|\/dev\/null)[^\s|;&]+/.test(command);
45
+ }
46
+ // The open licences that allow reuse (with credit, on their terms).
47
+ // A version may be joined on ("GPLv2", "LGPLv3").
48
+ const OPEN_LICENCE = /\b(mit|apache|bsd|isc|mpl|mozilla|gpl|lgpl|agpl|unlicense|cc0|cc[- ]by|creative commons|public domain|zlib|eclipse|epl|wtfpl|0bsd|artistic|python software foundation|psf)(?:v?\d[\d.]*)?\b/i;
49
+ export function isOpenLicence(licence) {
50
+ return OPEN_LICENCE.test(licence) && !/\b(no licen[cs]e|unknown|none|proprietary|all rights reserved)\b/i.test(licence);
51
+ }
52
+ // What the person said that switches research off for this conversation.
53
+ const SKIP_PHRASE = /\b(?:skip|no|without|don'?t do|do not do|don'?t need|do not need)\s+(?:the\s+|any\s+)?research\b|\bdon'?t research\b|\bdo not research\b/i;
54
+ export function asksToSkipResearch(text) {
55
+ return SKIP_PHRASE.test(text);
56
+ }
57
+ // The state of one conversation; /clear starts a fresh one.
58
+ class GateState {
59
+ // Pages Jeeves opened with readWebPage, and pages webSearch listed.
60
+ opened = new Set();
61
+ listed = new Set();
62
+ // Set when a web tool could not work (no key, service down).
63
+ webUnavailable = false;
64
+ // How many web pages had been opened when patching was held - research for the
65
+ // problem must come after it.
66
+ openedAtHold = 0;
67
+ openedCount = 0;
68
+ buildNote = null;
69
+ problems = new Map();
70
+ // The failure that held patching, until its research is recorded.
71
+ heldProblem = null;
72
+ skipped = false;
73
+ }
74
+ let state = new GateState();
75
+ export function resetResearchGate() {
76
+ state = new GateState();
77
+ }
78
+ export function gateState() {
79
+ return state;
80
+ }
81
+ // Called by the web tools, so a note can only cite pages really found or opened.
82
+ export function recordPageOpened(url) {
83
+ const key = normaliseUrl(url);
84
+ if (!state.opened.has(key))
85
+ state.openedCount += 1;
86
+ state.opened.add(key);
87
+ }
88
+ export function recordSearchResults(urls) {
89
+ for (const url of urls)
90
+ state.listed.add(normaliseUrl(url));
91
+ }
92
+ export function recordWebUnavailable() {
93
+ state.webUnavailable = true;
94
+ }
95
+ export function normaliseUrl(url) {
96
+ try {
97
+ const parsed = new URL(url.trim());
98
+ parsed.hash = '';
99
+ return `${parsed.hostname.replace(/^www\./, '')}${parsed.pathname.replace(/\/+$/, '')}${parsed.search}`.toLowerCase();
100
+ }
101
+ catch {
102
+ return url.trim().toLowerCase();
103
+ }
104
+ }
105
+ // The person's own words switch research off - never the model's.
106
+ export function noteSkipRequest(userText) {
107
+ if (state.skipped || !asksToSkipResearch(userText))
108
+ return null;
109
+ state.skipped = true;
110
+ return 'Research skipped for this conversation, as you asked.';
111
+ }
112
+ // A problem's fingerprint: the command, and the first line of its output that names
113
+ // an error, with numbers taken out (line numbers and times change between runs).
114
+ export function problemSignature(command, output) {
115
+ const lines = output.split('\n').map((line) => line.trim()).filter(Boolean);
116
+ const errorLine = lines.find((line) => /error|failed|failure|exception|cannot|can't|not found|undefined|denied|traceback|assert/i.test(line) && !/^exit code:/i.test(line)) ??
117
+ lines.filter((line) => !/^(exit code:|stdout:|stderr:|\$ )/i.test(line)).pop() ??
118
+ '';
119
+ const clean = (text) => text.toLowerCase().replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
120
+ return `${clean(command).slice(0, 120)} => ${clean(errorLine).slice(0, 160)}`;
121
+ }
122
+ // Called after every command. A command that succeeds clears its record; the same
123
+ // failure a second time holds patching until it has been researched.
124
+ export function recordCommandResult(command, exitCode, output) {
125
+ const commandKey = problemSignature(command, '').split(' => ')[0];
126
+ if (exitCode === 0) {
127
+ for (const key of [...state.problems.keys()])
128
+ if (key.startsWith(`${commandKey} => `))
129
+ state.problems.delete(key);
130
+ return null;
131
+ }
132
+ const signature = problemSignature(command, output);
133
+ const record = state.problems.get(signature) ?? { count: 0 };
134
+ record.count += 1;
135
+ state.problems.set(signature, record);
136
+ if (record.count >= 2 && state.heldProblem === null && !state.skipped) {
137
+ state.heldProblem = signature;
138
+ state.openedAtHold = state.openedCount;
139
+ return (`\n\n${HELD_PREFIX} this is the same failure a second time. Changing files is now held until you research it: ` +
140
+ 'find the cause with webSearch and readWebPage (the tool\'s own documentation, its issue tracker, how others fixed it), ' +
141
+ 'then record it with noteResearch (kind "problem", with the cause, the fix and the pages you opened). Do not guess another patch.');
142
+ }
143
+ return null;
144
+ }
145
+ async function folderHasProgramFiles(folder) {
146
+ if (!existsSync(folder))
147
+ return false;
148
+ const found = await fg(`**/*.{${PROGRAM_EXTENSIONS.join(',')}}`, {
149
+ cwd: folder,
150
+ deep: 4,
151
+ onlyFiles: true,
152
+ ignore: ['**/node_modules/**', '**/.git/**', '**/.venv/**', '**/venv/**', '**/dist/**', '**/build/**'],
153
+ suppressErrors: true,
154
+ });
155
+ return found.length > 0;
156
+ }
157
+ // The project a new file belongs to: the project folder when the file is inside it,
158
+ // otherwise the file's own folder.
159
+ function projectFolderFor(resolved, projectRoot) {
160
+ const relative = path.relative(projectRoot, resolved);
161
+ const inside = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
162
+ return inside ? projectRoot : path.dirname(resolved);
163
+ }
164
+ const BUILD_HOLD = `${HELD_PREFIX} this starts something new (a program, site, app, tool or script), and Jeeves researches before building. ` +
165
+ 'First look for what already exists: use webSearch and readWebPage to find existing tools, open-source projects and how others have built this. ' +
166
+ 'Then call noteResearch (kind "build") with the pages you opened and your decision - use an existing tool as is, adapt one with credit if its licence allows, or build new and why. Then try again.';
167
+ const PATCH_HOLD = () => `${HELD_PREFIX} the same failure has happened twice (${state.heldProblem}), so changing files is held until it is researched. ` +
168
+ 'Find the cause with webSearch and readWebPage, then call noteResearch (kind "problem") with the cause, the fix and the pages you opened. Then try again.';
169
+ // Checked before writeFile is offered for permission. Returns why it is held, or null.
170
+ export async function holdForWrite(file, resolved, projectRoot) {
171
+ if (state.skipped)
172
+ return null;
173
+ const exists = existsSync(resolved);
174
+ if (exists && state.heldProblem !== null)
175
+ return PATCH_HOLD();
176
+ if (exists || state.buildNote || !isProgramFile(file))
177
+ return null;
178
+ const folder = projectFolderFor(resolved, projectRoot);
179
+ if (await folderHasProgramFiles(folder))
180
+ return null;
181
+ return BUILD_HOLD;
182
+ }
183
+ // Checked before a command that changes things is offered for permission.
184
+ export function holdForCommand(command) {
185
+ if (state.skipped)
186
+ return null;
187
+ if (state.heldProblem !== null && commandEditsFiles(command))
188
+ return PATCH_HOLD();
189
+ if (!state.buildNote && startsNewProject(command))
190
+ return BUILD_HOLD;
191
+ return null;
192
+ }
193
+ export const noteResearchSchema = z.object({
194
+ kind: z.enum(['build', 'problem']).describe('"build" before making something new; "problem" after the same failure twice'),
195
+ subject: z.string().min(3).describe('What is being built, or the problem'),
196
+ sources: z.array(z.string()).describe('Addresses of the pages you opened with readWebPage in this conversation'),
197
+ decision: z
198
+ .enum(['use-existing', 'adapt', 'build-new'])
199
+ .optional()
200
+ .describe('For "build": use an existing tool as is, adapt one (licence permitting), or build new'),
201
+ licence: z.string().optional().describe('For use-existing or adapt: the licence of what is reused, exactly as its page states'),
202
+ reason: z.string().optional().describe('For "build": why this decision, in one or two sentences'),
203
+ cause: z.string().optional().describe('For "problem": the cause, as the sources explain it'),
204
+ fix: z.string().optional().describe('For "problem": the fix the sources support'),
205
+ noWebAccess: z.boolean().optional().describe('True only if the web tools could not be used in this conversation'),
206
+ });
207
+ function domain(url) {
208
+ try {
209
+ return new URL(url).hostname.replace(/^www\./, '');
210
+ }
211
+ catch {
212
+ return url;
213
+ }
214
+ }
215
+ const DECISION_WORDS = {
216
+ 'use-existing': 'use an existing tool as is',
217
+ adapt: 'adapt existing work, with credit',
218
+ 'build-new': 'build new',
219
+ };
220
+ // Records a note, or explains what is missing. Returns the reply for the model and,
221
+ // when accepted, the line shown to the person.
222
+ export function recordNote(input) {
223
+ const refuse = (reply) => ({ ok: false, reply: `Not recorded: ${reply}` });
224
+ const sources = input.sources.map((url) => url.trim()).filter(Boolean);
225
+ if (input.noWebAccess) {
226
+ if (!state.webUnavailable)
227
+ return refuse('the web tools have not failed in this conversation - research with webSearch and readWebPage first.');
228
+ }
229
+ else {
230
+ if (sources.length === 0)
231
+ return refuse('list the pages you opened with readWebPage.');
232
+ const unknown = sources.filter((url) => !state.opened.has(normaliseUrl(url)) && !state.listed.has(normaliseUrl(url)));
233
+ if (unknown.length > 0)
234
+ return refuse(`only list pages found or opened in this conversation - ${unknown.join(', ')} ${unknown.length === 1 ? 'was' : 'were'} not.`);
235
+ if (!sources.some((url) => state.opened.has(normaliseUrl(url))))
236
+ return refuse('open at least one of these pages with readWebPage first - search results alone are not research.');
237
+ }
238
+ const web = input.noWebAccess ? 'no web access, so from general knowledge only' : `${sources.length} source${sources.length === 1 ? '' : 's'}: ${[...new Set(sources.map(domain))].join(', ')}`;
239
+ if (input.kind === 'build') {
240
+ if (!input.decision)
241
+ return refuse('give a decision: use-existing, adapt or build-new.');
242
+ if (!input.reason || input.reason.trim().length < 10)
243
+ return refuse('give the reason for the decision.');
244
+ if (input.decision !== 'build-new') {
245
+ if (!input.licence?.trim())
246
+ return refuse('state the licence of what you would reuse, as its page states it.');
247
+ if (input.decision === 'adapt' && !isOpenLicence(input.licence)) {
248
+ return refuse(`"${input.licence}" does not allow reuse - learn the approach only, and record the decision as build-new.`);
249
+ }
250
+ }
251
+ state.buildNote = { kind: 'build', subject: input.subject };
252
+ const licence = input.decision !== 'build-new' && input.licence ? ` (licence: ${input.licence.trim()})` : '';
253
+ const shown = `Research — ${input.subject}: ${web}. Decision: ${DECISION_WORDS[input.decision]}${licence} — ${input.reason.trim()}`;
254
+ return { ok: true, reply: 'Recorded. Building is no longer held for this conversation.', shown };
255
+ }
256
+ if (!input.cause?.trim() || !input.fix?.trim())
257
+ return refuse('give the cause and the fix, as the sources explain them.');
258
+ if (state.heldProblem !== null && !input.noWebAccess && state.openedCount <= state.openedAtHold) {
259
+ return refuse('open at least one page about this problem with readWebPage after it happened the second time.');
260
+ }
261
+ const wasHeld = state.heldProblem;
262
+ state.heldProblem = null;
263
+ if (wasHeld)
264
+ state.problems.delete(wasHeld);
265
+ const shown = `Research — ${input.subject}: ${web}. Cause: ${input.cause.trim()} Fix: ${input.fix.trim()}`;
266
+ return { ok: true, reply: wasHeld ? 'Recorded. Changing files is no longer held.' : 'Recorded.', shown };
267
+ }
@@ -0,0 +1,135 @@
1
+ import path from 'node:path';
2
+ import { session } from '../state/session.js';
3
+ import { AUTO_PROFILES, autoProfile } from './auto-ids.js';
4
+ import { conversationForExpert, workerModel, autoCatalogue } from './auto.js';
5
+ import { expertChat } from './expert-chat.js';
6
+ import { isProgramFile } from './research-gate.js';
7
+ import { isReadOnlyBashCommand } from './permissions.js';
8
+ // Auto's double-check of finished work (plan step 3).
9
+ //
10
+ // When: measured on 18 Sept (bench, 61 everyday runs + the 17 Sept hard jobs), the cheap
11
+ // worker's mistakes were in writing for someone else to read (invented details, a gap
12
+ // left to fill in) and in hard logic from a written specification - never in data files,
13
+ // sums, dates, moving files, or jobs touching several files. So the check runs when a
14
+ // job changed a program file or wrote a document, and not otherwise.
15
+ //
16
+ // How: a reviewer on 17 Sept broke a correct job by "fixing" a problem that wasn't there.
17
+ // So every problem must come with a concrete example, problems without one are dropped,
18
+ // and the worker must reproduce each before changing anything (the approach of Agentless,
19
+ // OpenAutoCoder/Agentless: reproduce the issue before accepting a fix) - enforced by
20
+ // holding file changes until it has looked or run something after the review.
21
+ // Documents written for people: prose, not data.
22
+ const DOCUMENT_EXTENSIONS = ['txt', 'md', 'rtf', 'doc', 'docx', 'odt', 'eml', 'tex'];
23
+ export function isDocumentFile(file) {
24
+ return DOCUMENT_EXTENSIONS.includes(path.extname(file).slice(1).toLowerCase());
25
+ }
26
+ // The file a writeFile line is about: its summary is "path (N characters)".
27
+ function writtenPath(summary) {
28
+ return summary.replace(/\s+\(\d+ characters\)$/, '');
29
+ }
30
+ // Whether this job's actions call for the double-check.
31
+ export function jobNeedsReview(entries) {
32
+ return entries.some((entry) => {
33
+ if (entry.kind !== 'tool' || entry.data.state !== 'done')
34
+ return false;
35
+ if (entry.data.tool === 'writeFile') {
36
+ const file = writtenPath(entry.data.summary);
37
+ return isProgramFile(file) || isDocumentFile(file);
38
+ }
39
+ if (entry.data.tool === 'runBash' && !isReadOnlyBashCommand(entry.data.summary)) {
40
+ // A command that names a program file it may change (an edit, a redirect).
41
+ return /\.(js|mjs|cjs|ts|tsx|jsx|py|rb|php|go|rs|java|sh|html?|css)\b/i.test(entry.data.summary) && /(>|sed\s|perl\s|tee\s|cp\s|mv\s)/.test(entry.data.summary);
42
+ }
43
+ return false;
44
+ });
45
+ }
46
+ export const REVIEW_INSTRUCTIONS = `You are the expert reviewer for an assistant doing a job on a computer for someone with no technical background. The assistant believes the job is finished. You see the whole conversation: the request, every action, every file written, and every result.
47
+ Check the work against what the person asked for - including cases the request clearly implies but does not list. For writing meant for someone else, check that it uses only the facts the person gave and leaves nothing to fill in.
48
+ If nothing is wrong, reply with exactly: OK
49
+ Otherwise list each problem in exactly this form, and nothing else:
50
+ PROBLEM: what is wrong, in one sentence
51
+ EXAMPLE: a concrete case that shows it - an input to try, or the exact words quoted from the file
52
+ EXPECTED: what should happen or be written
53
+ ACTUAL: what the work does or says now
54
+ Only report a problem the conversation shows. A problem without a concrete EXAMPLE will be ignored. Never guess.`;
55
+ // Reads the reviewer's reply. Problems without a concrete example are dropped, so a
56
+ // vague "this might be wrong" can never send the worker off changing correct work.
57
+ export function parseReview(text) {
58
+ if (/^\s*OK\s*\.?\s*$/i.test(text))
59
+ return { ok: true, problems: [] };
60
+ const problems = [];
61
+ for (const block of text.split(/(?=^\s*(?:\d+[.)]\s*)?\**PROBLEM\**\s*:)/im)) {
62
+ const field = (name) => block.match(new RegExp(`\\**${name}\\**\\s*:\\s*([\\s\\S]*?)(?=\\n\\s*\\**(?:PROBLEM|EXAMPLE|EXPECTED|ACTUAL)\\**\\s*:|$)`, 'i'))?.[1].trim() ?? '';
63
+ const item = { problem: field('PROBLEM'), example: field('EXAMPLE'), expected: field('EXPECTED'), actual: field('ACTUAL') };
64
+ if (item.problem && item.example.length >= 3)
65
+ problems.push(item);
66
+ }
67
+ return { ok: problems.length === 0, problems };
68
+ }
69
+ export function fixRequest(problems) {
70
+ const list = problems
71
+ .map((p, i) => `${i + 1}. ${p.problem}\n Example: ${p.example}\n Expected: ${p.expected || '(not given)'}\n Actual: ${p.actual || '(not given)'}`)
72
+ .join('\n');
73
+ return `An expert reviewed your work and reported these problems:\n${list}\n\nThe expert can be wrong. For each one, first reproduce it: run the example, or read the file and find the exact words. Only fix a problem you have reproduced; if you cannot reproduce one, leave the work as it is for that one. Then check the result again. Tell the person, briefly, that the job is done, then in one sentence what the double-check caught and what you changed (or that its concern did not hold). They never saw the earlier version, so do not describe your reply as a fix to something they saw.`;
74
+ }
75
+ // The reviewers, in order: the service's expert models still in its catalogue.
76
+ export function reviewers(catalogue = autoCatalogue(), providerId = session.providerId) {
77
+ const experts = (autoProfile(providerId) ?? AUTO_PROFILES.openrouter).experts;
78
+ if (catalogue.length === 0)
79
+ return experts.slice();
80
+ return experts.filter((id) => {
81
+ const params = catalogue.find((model) => model.id === id)?.supportedParameters ?? [];
82
+ return params.includes('tools') || params.includes('tool_choice');
83
+ });
84
+ }
85
+ // Asks each reviewer in turn until one answers; says so plainly when none can.
86
+ export async function reviewJob(messages, chat = expertChat, candidates = reviewers()) {
87
+ const lineId = session.addToolLine('askExpert', 'final check of the work', 'running');
88
+ try {
89
+ for (const reviewer of candidates) {
90
+ session.setActiveModel(reviewer);
91
+ try {
92
+ const reply = await chat(reviewer, [
93
+ { role: 'system', content: REVIEW_INSTRUCTIONS },
94
+ { role: 'user', content: `Conversation:\n${conversationForExpert(messages, 80_000, 6_000)}` },
95
+ ], 1500);
96
+ const parsed = parseReview(reply);
97
+ session.updateToolLine(lineId, { state: 'done', label: parsed.ok ? 'Expert checked the work' : 'Expert found something to check' });
98
+ return parsed.ok ? { kind: 'ok', reviewer } : { kind: 'problems', reviewer, problems: parsed.problems };
99
+ }
100
+ catch {
101
+ // This reviewer is unavailable - try the next one.
102
+ }
103
+ }
104
+ session.updateToolLine(lineId, { state: 'failed', label: 'no expert was available' });
105
+ return { kind: 'unavailable' };
106
+ }
107
+ finally {
108
+ session.setActiveModel(workerModel());
109
+ }
110
+ }
111
+ export const UNCHECKED_NOTICE = "I couldn't have this work double-checked just now - the checking service wasn't reachable. Please look it over before relying on it.";
112
+ // While the worker fixes reviewed work, changing files waits until it has reproduced a
113
+ // problem: read a file or run something after the review.
114
+ let reproducing = false;
115
+ export function startReproducing() {
116
+ reproducing = true;
117
+ }
118
+ export function stopReproducing() {
119
+ reproducing = false;
120
+ }
121
+ export const REPRODUCE_HOLD = 'Held for research: reproduce the reported problem before changing anything - run the example or read the file to find the exact words, then make the fix.';
122
+ // Called before a tool runs: a look (readFile, listDir) or a command that is not
123
+ // itself an edit counts as reproducing; a change before that is held.
124
+ export function holdUntilReproduced(tool, detail, editsFiles) {
125
+ if (!reproducing)
126
+ return null;
127
+ if (tool === 'readFile' || tool === 'listDir' || (tool === 'runBash' && !editsFiles)) {
128
+ reproducing = false;
129
+ return null;
130
+ }
131
+ if (tool === 'writeFile' || (tool === 'runBash' && editsFiles))
132
+ return REPRODUCE_HOLD;
133
+ void detail;
134
+ return null;
135
+ }