@superjackfruitlabs/salaam 0.0.3 → 0.0.5
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/CHANGELOG.md +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/usage.js +1 -1
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +174 -20
- package/src/acp.ts +386 -30
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Runs as the tools user: applies reviewed local edits that the service has recorded and verified.
|
|
2
|
+
import { closeSync, constants, fsyncSync, openSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { applyLocalEdits, localPath, verifyLocalEdits } from './local-edits.js';
|
|
5
|
+
import type { ToolEdit } from './tool-identity.js';
|
|
6
|
+
|
|
7
|
+
const root = process.argv[2];
|
|
8
|
+
if (!root) throw new Error('usage: local-edit-helper <root>');
|
|
9
|
+
const edits = JSON.parse(readFileSync(0, 'utf8')) as ToolEdit[];
|
|
10
|
+
for (const { remove, ...edit } of edits) {
|
|
11
|
+
if (remove) {
|
|
12
|
+
verifyLocalEdits(root, [edit]);
|
|
13
|
+
const path = localPath(root, edit.path);
|
|
14
|
+
rmSync(path);
|
|
15
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
16
|
+
try {
|
|
17
|
+
fsyncSync(directory);
|
|
18
|
+
} finally {
|
|
19
|
+
closeSync(directory);
|
|
20
|
+
}
|
|
21
|
+
process.stdout.write('applied ' + JSON.stringify(edit.path) + '\n');
|
|
22
|
+
} else
|
|
23
|
+
applyLocalEdits(root, [edit], (path) =>
|
|
24
|
+
process.stdout.write('applied ' + JSON.stringify(path) + '\n'),
|
|
25
|
+
);
|
|
26
|
+
}
|
package/src/local-edits.ts
CHANGED
|
@@ -106,21 +106,204 @@ export function prepareLocalEdits(root: string, input: unknown): LocalEdit[] {
|
|
|
106
106
|
'workspace_changed',
|
|
107
107
|
'Read the file first and supply its current sha256; null is only for a new file',
|
|
108
108
|
);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
// An existing file changes by exact replacements; whole content is for new files (or a
|
|
110
|
+
// deliberate rewrite). Either way the edit resolves to the exact before and after texts, so
|
|
111
|
+
// the approval binding, apply, verify and undo paths are unchanged.
|
|
112
|
+
let after: string;
|
|
113
|
+
if (item.replacements !== undefined) {
|
|
114
|
+
if (item.content !== undefined)
|
|
115
|
+
throw new ServiceError('invalid_field', 'Use replacements or content for a file, not both');
|
|
116
|
+
if (original.text === null)
|
|
117
|
+
throw new ServiceError(
|
|
118
|
+
'invalid_field',
|
|
119
|
+
'Replacements edit an existing file; give a new file its whole content',
|
|
120
|
+
);
|
|
121
|
+
after = applyReplacements(path, original.text, item.replacements);
|
|
122
|
+
} else after = item.content as string;
|
|
123
|
+
if (typeof after !== 'string' || Buffer.byteLength(after) > 262144 || after.includes('\0'))
|
|
124
|
+
throw new ServiceError('invalid_field', 'Provide UTF-8 file content up to 256 KiB');
|
|
125
|
+
return { path, before: original.text, after, mode: original.mode };
|
|
116
126
|
});
|
|
117
|
-
|
|
127
|
+
// What a person reviews is the change, so the change is what is bounded, not the files.
|
|
128
|
+
if (Buffer.byteLength(reviewLocalEdits(edits, Infinity)) > 32768)
|
|
118
129
|
throw new ServiceError(
|
|
119
130
|
'workspace_limit',
|
|
120
131
|
'Split this edit into smaller reviewable changes (32 KiB per review)',
|
|
121
132
|
);
|
|
122
133
|
return edits;
|
|
123
134
|
}
|
|
135
|
+
|
|
136
|
+
/** Applies exact replacements in order. Each `old` must occur exactly once in the text as it is
|
|
137
|
+
* at that point; the message tells the model how to fix a miss. */
|
|
138
|
+
function applyReplacements(path: string, text: string, input: unknown): string {
|
|
139
|
+
if (!Array.isArray(input) || !input.length || input.length > 20)
|
|
140
|
+
throw new ServiceError('invalid_field', 'Provide one to twenty replacements');
|
|
141
|
+
input.forEach((raw, i) => {
|
|
142
|
+
const r = object(raw),
|
|
143
|
+
n = `Replacement ${i + 1} in ${path}`;
|
|
144
|
+
if (typeof r.old !== 'string' || typeof r.new !== 'string')
|
|
145
|
+
throw new ServiceError('invalid_field', `${n}: give old and new as text`);
|
|
146
|
+
if (!r.old)
|
|
147
|
+
throw new ServiceError(
|
|
148
|
+
'invalid_field',
|
|
149
|
+
`${n}: old is empty; quote the exact lines to change`,
|
|
150
|
+
);
|
|
151
|
+
let count = 0;
|
|
152
|
+
for (let at = text.indexOf(r.old); at !== -1; at = text.indexOf(r.old, at + r.old.length))
|
|
153
|
+
count++;
|
|
154
|
+
if (count === 0)
|
|
155
|
+
throw new ServiceError(
|
|
156
|
+
'edit_mismatch',
|
|
157
|
+
`${n}: old text not found. Read the file again and copy the exact lines, including whitespace`,
|
|
158
|
+
);
|
|
159
|
+
if (count > 1)
|
|
160
|
+
throw new ServiceError(
|
|
161
|
+
'edit_mismatch',
|
|
162
|
+
`${n}: old text occurs ${count} times. Include more surrounding lines so it matches exactly once`,
|
|
163
|
+
);
|
|
164
|
+
if (r.old === r.new) throw new ServiceError('invalid_field', `${n}: changes nothing`);
|
|
165
|
+
text = text.replace(r.old, () => r.new as string);
|
|
166
|
+
});
|
|
167
|
+
return text;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const CONTEXT = 3,
|
|
171
|
+
REVIEW_LINES = 200;
|
|
172
|
+
|
|
173
|
+
/** A unified diff of each file's change with a few lines of context: what an approval shows.
|
|
174
|
+
* Long reviews stop at `limit` lines and say how much they left out; the approval still binds
|
|
175
|
+
* the exact full change. */
|
|
176
|
+
export function reviewLocalEdits(edits: LocalEdit[], limit = REVIEW_LINES): string {
|
|
177
|
+
const out: string[] = [];
|
|
178
|
+
let hidden = 0;
|
|
179
|
+
for (const edit of edits) {
|
|
180
|
+
const lines =
|
|
181
|
+
edit.before === null
|
|
182
|
+
? [`+++ ${edit.path} (new file)`, ...split(edit.after).map((l) => '+' + l)]
|
|
183
|
+
: [`--- ${edit.path}`, `+++ ${edit.path}`, ...hunks(split(edit.before), split(edit.after))];
|
|
184
|
+
for (const line of lines) {
|
|
185
|
+
if (out.length < limit) out.push(line);
|
|
186
|
+
else if (/^[+-]/.test(line) && !/^(\+\+\+|---) /.test(line)) hidden++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (hidden)
|
|
190
|
+
out.push(`… ${hidden} more changed lines not shown; the approval covers the exact full change`);
|
|
191
|
+
return out.join('\n');
|
|
192
|
+
}
|
|
193
|
+
function split(text: string): string[] {
|
|
194
|
+
const lines = text.split('\n');
|
|
195
|
+
if (lines.at(-1) === '') lines.pop();
|
|
196
|
+
return lines;
|
|
197
|
+
}
|
|
198
|
+
/** Unified-diff hunks from a line-level Myers diff (prefix and suffix trimmed first, so a small
|
|
199
|
+
* change to a big file stays cheap). A diff too large to compute in budget is shown whole. */
|
|
200
|
+
function hunks(a: string[], b: string[]): string[] {
|
|
201
|
+
let pre = 0;
|
|
202
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
|
|
203
|
+
let suf = 0;
|
|
204
|
+
while (
|
|
205
|
+
suf < a.length - pre &&
|
|
206
|
+
suf < b.length - pre &&
|
|
207
|
+
a[a.length - 1 - suf] === b[b.length - 1 - suf]
|
|
208
|
+
)
|
|
209
|
+
suf++;
|
|
210
|
+
const ops = myers(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
|
211
|
+
// Full op list over the whole file: equal prefix, the diffed middle, equal suffix.
|
|
212
|
+
const all: { t: ' ' | '-' | '+'; s: string; ai: number; bi: number }[] = [];
|
|
213
|
+
for (let i = 0; i < pre; i++) all.push({ t: ' ', s: a[i]!, ai: i, bi: i });
|
|
214
|
+
let ai = pre,
|
|
215
|
+
bi = pre;
|
|
216
|
+
for (const o of ops) {
|
|
217
|
+
all.push({ ...o, ai, bi });
|
|
218
|
+
if (o.t !== '+') ai++;
|
|
219
|
+
if (o.t !== '-') bi++;
|
|
220
|
+
}
|
|
221
|
+
for (let i = 0; i < suf; i++) all.push({ t: ' ', s: a[ai + i]!, ai: ai + i, bi: bi + i });
|
|
222
|
+
const out: string[] = [];
|
|
223
|
+
let i = 0;
|
|
224
|
+
while (i < all.length) {
|
|
225
|
+
if (all[i]!.t === ' ') {
|
|
226
|
+
i++;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
let start = Math.max(0, i - CONTEXT),
|
|
230
|
+
end = i;
|
|
231
|
+
// Extend while the next change is within 2*CONTEXT equal lines.
|
|
232
|
+
for (;;) {
|
|
233
|
+
while (end < all.length && all[end]!.t !== ' ') end++;
|
|
234
|
+
let gap = end;
|
|
235
|
+
while (gap < all.length && all[gap]!.t === ' ' && gap - end < 2 * CONTEXT) gap++;
|
|
236
|
+
if (gap < all.length && all[gap]!.t !== ' ' && gap - end < 2 * CONTEXT) end = gap;
|
|
237
|
+
else break;
|
|
238
|
+
}
|
|
239
|
+
const stop = Math.min(all.length, end + CONTEXT),
|
|
240
|
+
part = all.slice(start, stop),
|
|
241
|
+
aStart = part.find((o) => o.t !== '+')?.ai ?? part[0]!.ai,
|
|
242
|
+
bStart = part.find((o) => o.t !== '-')?.bi ?? part[0]!.bi,
|
|
243
|
+
aLen = part.filter((o) => o.t !== '+').length,
|
|
244
|
+
bLen = part.filter((o) => o.t !== '-').length;
|
|
245
|
+
out.push(`@@ -${aStart + 1},${aLen} +${bStart + 1},${bLen} @@`, ...part.map((o) => o.t + o.s));
|
|
246
|
+
i = stop;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
function myers(a: string[], b: string[]): { t: ' ' | '-' | '+'; s: string }[] {
|
|
251
|
+
const n = a.length,
|
|
252
|
+
m = b.length,
|
|
253
|
+
max = n + m;
|
|
254
|
+
if (max > 20000)
|
|
255
|
+
return [...a.map((s) => ({ t: '-' as const, s })), ...b.map((s) => ({ t: '+' as const, s }))];
|
|
256
|
+
const v = new Map<number, number>([[1, 0]]),
|
|
257
|
+
trace: Map<number, number>[] = [];
|
|
258
|
+
for (let d = 0; d <= max; d++) {
|
|
259
|
+
trace.push(new Map(v));
|
|
260
|
+
for (let k = -d; k <= d; k += 2) {
|
|
261
|
+
let x =
|
|
262
|
+
k === -d || (k !== d && (v.get(k - 1) ?? -1) < (v.get(k + 1) ?? -1))
|
|
263
|
+
? v.get(k + 1)!
|
|
264
|
+
: v.get(k - 1)! + 1;
|
|
265
|
+
let y = x - k;
|
|
266
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
267
|
+
x++;
|
|
268
|
+
y++;
|
|
269
|
+
}
|
|
270
|
+
v.set(k, x);
|
|
271
|
+
if (x >= n && y >= m) return backtrack(a, b, trace, n, m);
|
|
272
|
+
}
|
|
273
|
+
if (d > 2000) break;
|
|
274
|
+
}
|
|
275
|
+
return [...a.map((s) => ({ t: '-' as const, s })), ...b.map((s) => ({ t: '+' as const, s }))];
|
|
276
|
+
}
|
|
277
|
+
function backtrack(
|
|
278
|
+
a: string[],
|
|
279
|
+
b: string[],
|
|
280
|
+
trace: Map<number, number>[],
|
|
281
|
+
n: number,
|
|
282
|
+
m: number,
|
|
283
|
+
): { t: ' ' | '-' | '+'; s: string }[] {
|
|
284
|
+
const ops: { t: ' ' | '-' | '+'; s: string }[] = [];
|
|
285
|
+
let x = n,
|
|
286
|
+
y = m;
|
|
287
|
+
for (let d = trace.length - 1; d >= 0; d--) {
|
|
288
|
+
const v = trace[d]!,
|
|
289
|
+
k = x - y,
|
|
290
|
+
prevK = k === -d || (k !== d && (v.get(k - 1) ?? -1) < (v.get(k + 1) ?? -1)) ? k + 1 : k - 1,
|
|
291
|
+
prevX = v.get(prevK) ?? 0,
|
|
292
|
+
prevY = prevX - prevK;
|
|
293
|
+
while (x > prevX && y > prevY) {
|
|
294
|
+
ops.push({ t: ' ', s: a[x - 1]! });
|
|
295
|
+
x--;
|
|
296
|
+
y--;
|
|
297
|
+
}
|
|
298
|
+
if (d > 0) {
|
|
299
|
+
if (x === prevX) ops.push({ t: '+', s: b[y - 1]! });
|
|
300
|
+
else ops.push({ t: '-', s: a[x - 1]! });
|
|
301
|
+
}
|
|
302
|
+
x = prevX;
|
|
303
|
+
y = prevY;
|
|
304
|
+
}
|
|
305
|
+
return ops.reverse();
|
|
306
|
+
}
|
|
124
307
|
export function verifyLocalEdits(root: string, edits: LocalEdit[]): void {
|
|
125
308
|
for (const edit of edits) {
|
|
126
309
|
const actual = contents(localPath(root, edit.path));
|
package/src/local-undo.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { rmSync, openSync, closeSync, fsyncSync, constants } from 'node:fs';
|
|
|
3
3
|
import { assertPermissionBinding } from './autonomy.js';
|
|
4
4
|
import type { Store } from './store.js';
|
|
5
5
|
import { digest, ServiceError, type Controller } from './protocol.js';
|
|
6
|
+
import { applyEditsAsTools, type ToolIdentity } from './tool-identity.js';
|
|
6
7
|
import { applyLocalEdits, localPath, verifyLocalEdits, type LocalEdit } from './local-edits.js';
|
|
7
8
|
|
|
8
9
|
export interface UndoReview {
|
|
@@ -53,6 +54,7 @@ export function applyUndo(
|
|
|
53
54
|
reviewed: string,
|
|
54
55
|
controller: Controller,
|
|
55
56
|
idle: () => void,
|
|
57
|
+
tools?: ToolIdentity,
|
|
56
58
|
) {
|
|
57
59
|
const input = { kind: 'local-undo', id, reviewed };
|
|
58
60
|
const saved = store.cachedRequest<{ state: string }>('local-operator', request, input);
|
|
@@ -79,20 +81,33 @@ export function applyUndo(
|
|
|
79
81
|
return { state: 'outcome_unknown', ...review };
|
|
80
82
|
});
|
|
81
83
|
// Intent is durable before touching files. A lost process never repeats this mutation.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
84
|
+
if (tools?.mode === 'tools-user')
|
|
85
|
+
applyEditsAsTools(
|
|
86
|
+
tools,
|
|
87
|
+
root,
|
|
88
|
+
work.edits.map((edit: LocalEdit) => ({
|
|
89
|
+
...edit,
|
|
90
|
+
before: edit.after,
|
|
91
|
+
after: edit.before ?? '',
|
|
92
|
+
...(edit.before === null ? { remove: true } : {}),
|
|
93
|
+
})),
|
|
94
|
+
() => {},
|
|
95
|
+
);
|
|
96
|
+
else
|
|
97
|
+
for (const edit of work.edits) {
|
|
98
|
+
const inverse = { ...edit, before: edit.after, after: edit.before ?? '' };
|
|
99
|
+
if (edit.before === null) {
|
|
100
|
+
verifyLocalEdits(root, [inverse]);
|
|
101
|
+
const path = localPath(root, edit.path);
|
|
102
|
+
rmSync(path);
|
|
103
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
104
|
+
try {
|
|
105
|
+
fsyncSync(directory);
|
|
106
|
+
} finally {
|
|
107
|
+
closeSync(directory);
|
|
108
|
+
}
|
|
109
|
+
} else applyLocalEdits(root, [inverse], () => {});
|
|
110
|
+
}
|
|
96
111
|
verifyLocalEdits(root, work.edits);
|
|
97
112
|
return store.transaction(() => {
|
|
98
113
|
const result = { state: 'completed', call_id: work.call_id };
|
package/src/managed.ts
CHANGED
|
@@ -503,7 +503,7 @@ export class ManagedWork {
|
|
|
503
503
|
revision: number,
|
|
504
504
|
binding: string,
|
|
505
505
|
decision: 'approve' | 'deny',
|
|
506
|
-
scoped?: { receipt: Record<string, unknown>; authorize: () => void },
|
|
506
|
+
scoped?: { receipt: Record<string, unknown>; authorize: () => void; decidedBy?: string },
|
|
507
507
|
): Approval {
|
|
508
508
|
const requestWork = this.store.db.isTransaction
|
|
509
509
|
? this.store.requestInTransaction.bind(this.store)
|
|
@@ -553,7 +553,7 @@ export class ManagedWork {
|
|
|
553
553
|
.prepare(
|
|
554
554
|
"UPDATE approvals SET state='decided',decision=?,decided_by=?,revision=revision+1 WHERE id=?",
|
|
555
555
|
)
|
|
556
|
-
.run(decision, scoped ? 'scoped-policy' : 'local-operator', id);
|
|
556
|
+
.run(decision, scoped ? (scoped.decidedBy ?? 'scoped-policy') : 'local-operator', id);
|
|
557
557
|
if (decision === 'deny')
|
|
558
558
|
this.transition(op, 'cancelled_before_dispatch', 'Denied by operator');
|
|
559
559
|
this.store.event(
|
package/src/mentions.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { ServiceError } from './protocol.js';
|
|
7
|
+
|
|
8
|
+
/** `@path` mentions attach workspace files to a desk prompt (Phase 3b). */
|
|
9
|
+
export const MENTION_LIMITS = {
|
|
10
|
+
perFile: 64 * 1024,
|
|
11
|
+
total: 256 * 1024,
|
|
12
|
+
image: 512 * 1024,
|
|
13
|
+
listing: 5000,
|
|
14
|
+
entries: 200,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export interface Attachment {
|
|
18
|
+
path: string;
|
|
19
|
+
kind: 'text' | 'binary' | 'image' | 'listing' | 'missing' | 'refused' | 'skipped';
|
|
20
|
+
content?: string;
|
|
21
|
+
data?: string;
|
|
22
|
+
mime?: string;
|
|
23
|
+
sha256?: string;
|
|
24
|
+
bytes?: number;
|
|
25
|
+
truncated?: boolean;
|
|
26
|
+
sensitive?: boolean;
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SENSITIVE = [
|
|
31
|
+
/(^|\/)\.env(\..+)?$/,
|
|
32
|
+
/\.(pem|key|p12|pfx|keystore|jks)$/i,
|
|
33
|
+
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
|
|
34
|
+
/(^|\/)\.(netrc|npmrc|pypirc)$/,
|
|
35
|
+
/(^|\/)(credentials|secrets?)(\.[a-z]+)?$/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function isSensitivePath(path: string): boolean {
|
|
39
|
+
return SENSITIVE.some((pattern) => pattern.test(path));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `@token` at a word start; `@"a b"` may hold spaces; trailing punctuation is dropped. */
|
|
43
|
+
export function extractMentions(text: string): string[] {
|
|
44
|
+
const found: string[] = [];
|
|
45
|
+
const pattern = /(^|[\s([{])@(?:"([^"]+)"|([^\s"]+))/g;
|
|
46
|
+
for (const match of text.matchAll(pattern)) {
|
|
47
|
+
const value = match[2] ?? match[3]!.replace(/[.,;:!?)\]}]+$/, '');
|
|
48
|
+
if (value && !value.startsWith('@') && !found.includes(value)) found.push(value);
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function inside(root: string, path: string): boolean {
|
|
54
|
+
const rel = relative(root, path);
|
|
55
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Tracked and untracked-but-not-ignored files; a symlink is listed only if it stays inside. */
|
|
59
|
+
export function listWorkspaceFiles(root: string): string[] {
|
|
60
|
+
let names: string[];
|
|
61
|
+
try {
|
|
62
|
+
names = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
|
|
63
|
+
cwd: root,
|
|
64
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
65
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
66
|
+
})
|
|
67
|
+
.toString('utf8')
|
|
68
|
+
.split('\0')
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
} catch {
|
|
71
|
+
names = walk(root, root);
|
|
72
|
+
}
|
|
73
|
+
const real = realpathSync(root);
|
|
74
|
+
const kept: string[] = [];
|
|
75
|
+
for (const name of names) {
|
|
76
|
+
if (kept.length >= MENTION_LIMITS.listing) break;
|
|
77
|
+
try {
|
|
78
|
+
if (
|
|
79
|
+
lstatSync(resolve(root, name)).isSymbolicLink() &&
|
|
80
|
+
!inside(real, realpathSync(resolve(root, name)))
|
|
81
|
+
)
|
|
82
|
+
continue;
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
kept.push(name);
|
|
87
|
+
}
|
|
88
|
+
return kept;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function walk(root: string, dir: string, out: string[] = []): string[] {
|
|
92
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
93
|
+
if (out.length >= MENTION_LIMITS.listing) break;
|
|
94
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
95
|
+
const full = resolve(dir, entry.name);
|
|
96
|
+
if (entry.isDirectory()) walk(root, full, out);
|
|
97
|
+
else out.push(relative(root, full).split(sep).join('/'));
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Basename hits first, then path substrings, then in-order subsequences. */
|
|
103
|
+
export function rankFiles(files: string[], query: string): string[] {
|
|
104
|
+
if (!query) return files;
|
|
105
|
+
const q = query.toLowerCase();
|
|
106
|
+
const score = (file: string) => {
|
|
107
|
+
const f = file.toLowerCase();
|
|
108
|
+
const base = basename(f);
|
|
109
|
+
if (base.startsWith(q)) return 4;
|
|
110
|
+
if (base.includes(q)) return 3;
|
|
111
|
+
if (f.includes(q)) return 2;
|
|
112
|
+
let i = 0;
|
|
113
|
+
for (const ch of f) if (ch === q[i]) i++;
|
|
114
|
+
return i === q.length ? 1 : 0;
|
|
115
|
+
};
|
|
116
|
+
return files
|
|
117
|
+
.map((file) => ({ file, s: score(file) }))
|
|
118
|
+
.filter((x) => x.s > 0)
|
|
119
|
+
.sort((a, b) => b.s - a.s || a.file.length - b.file.length || a.file.localeCompare(b.file))
|
|
120
|
+
.map((x) => x.file);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function imageMime(bytes: Buffer): string | undefined {
|
|
124
|
+
if (bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png';
|
|
125
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
|
|
126
|
+
if (bytes.subarray(0, 4).toString('latin1') === 'GIF8') return 'image/gif';
|
|
127
|
+
if (
|
|
128
|
+
bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
|
|
129
|
+
bytes.subarray(8, 12).toString('latin1') === 'WEBP'
|
|
130
|
+
)
|
|
131
|
+
return 'image/webp';
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function truncate(text: string, limit: number): string {
|
|
136
|
+
const buf = Buffer.from(text, 'utf8');
|
|
137
|
+
if (buf.length <= limit) return text;
|
|
138
|
+
// Cut on a character boundary so the byte bound holds.
|
|
139
|
+
return buf.subarray(0, limit).toString('utf8').replace(/�$/, '');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Read each mention under the workspace; nothing outside it, through a symlink or not. */
|
|
143
|
+
export function resolveMentions(root: string, mentions: string[]): Attachment[] {
|
|
144
|
+
const real = realpathSync(root);
|
|
145
|
+
let budget: number = MENTION_LIMITS.total;
|
|
146
|
+
return mentions.map((path): Attachment => {
|
|
147
|
+
const target = resolve(real, path);
|
|
148
|
+
if (isAbsolute(path) || !inside(real, target))
|
|
149
|
+
return { path, kind: 'refused', reason: 'outside the workspace' };
|
|
150
|
+
let resolved: string;
|
|
151
|
+
try {
|
|
152
|
+
resolved = realpathSync(target);
|
|
153
|
+
} catch {
|
|
154
|
+
return { path, kind: 'missing' };
|
|
155
|
+
}
|
|
156
|
+
if (!inside(real, resolved))
|
|
157
|
+
return { path, kind: 'refused', reason: 'links outside the workspace' };
|
|
158
|
+
const sensitive = isSensitivePath(path) || undefined;
|
|
159
|
+
const stat = lstatSync(resolved);
|
|
160
|
+
if (stat.isDirectory()) {
|
|
161
|
+
const names = readdirSync(resolved, { withFileTypes: true })
|
|
162
|
+
.filter((e) => e.name !== '.git')
|
|
163
|
+
.slice(0, MENTION_LIMITS.entries)
|
|
164
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
165
|
+
const content = truncate(names.join('\n'), Math.min(MENTION_LIMITS.perFile, budget));
|
|
166
|
+
budget -= Buffer.byteLength(content);
|
|
167
|
+
return { path, kind: 'listing', content };
|
|
168
|
+
}
|
|
169
|
+
if (!stat.isFile()) return { path, kind: 'refused', reason: 'not a regular file' };
|
|
170
|
+
const bytes = readFileSync(resolved);
|
|
171
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
172
|
+
const mime = imageMime(bytes);
|
|
173
|
+
if (mime) {
|
|
174
|
+
if (bytes.length > MENTION_LIMITS.image)
|
|
175
|
+
return {
|
|
176
|
+
path,
|
|
177
|
+
kind: 'skipped',
|
|
178
|
+
reason: 'image larger than 512 KiB',
|
|
179
|
+
bytes: bytes.length,
|
|
180
|
+
sha256,
|
|
181
|
+
};
|
|
182
|
+
return {
|
|
183
|
+
path,
|
|
184
|
+
kind: 'image',
|
|
185
|
+
mime,
|
|
186
|
+
data: bytes.toString('base64'),
|
|
187
|
+
sha256,
|
|
188
|
+
bytes: bytes.length,
|
|
189
|
+
sensitive,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (bytes.subarray(0, 8192).includes(0))
|
|
193
|
+
return { path, kind: 'binary', sha256, bytes: bytes.length, sensitive };
|
|
194
|
+
if (budget <= 0)
|
|
195
|
+
return {
|
|
196
|
+
path,
|
|
197
|
+
kind: 'skipped',
|
|
198
|
+
reason: 'attachment budget used up',
|
|
199
|
+
sha256,
|
|
200
|
+
bytes: bytes.length,
|
|
201
|
+
sensitive,
|
|
202
|
+
};
|
|
203
|
+
const limit = Math.min(MENTION_LIMITS.perFile, budget);
|
|
204
|
+
const content = truncate(bytes.toString('utf8'), limit);
|
|
205
|
+
budget -= Buffer.byteLength(content);
|
|
206
|
+
return {
|
|
207
|
+
path,
|
|
208
|
+
kind: 'text',
|
|
209
|
+
content,
|
|
210
|
+
sha256,
|
|
211
|
+
bytes: bytes.length,
|
|
212
|
+
...(Buffer.byteLength(content) < bytes.length ? { truncated: true } : {}),
|
|
213
|
+
...(sensitive ? { sensitive } : {}),
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The text the model reads; image bytes travel separately as image content. */
|
|
219
|
+
export function attachmentContext(attachments: Attachment[]): string {
|
|
220
|
+
if (!attachments.length) return '';
|
|
221
|
+
const parts = attachments.map((a) => {
|
|
222
|
+
const digest = a.sha256 ? `, sha256 ${a.sha256.slice(0, 12)}` : '';
|
|
223
|
+
switch (a.kind) {
|
|
224
|
+
case 'text':
|
|
225
|
+
return `@${a.path} (${a.bytes} bytes${digest}${a.truncated ? ', truncated' : ''})\n\`\`\`\n${a.content}\n\`\`\``;
|
|
226
|
+
case 'listing':
|
|
227
|
+
return `@${a.path} (directory)\n\`\`\`\n${a.content}\n\`\`\``;
|
|
228
|
+
case 'image':
|
|
229
|
+
return `@${a.path} (image, attached)${digest ? ` [${digest.slice(2)}]` : ''}`;
|
|
230
|
+
case 'binary':
|
|
231
|
+
return `@${a.path} (binary, ${a.bytes} bytes${digest}; not attached)`;
|
|
232
|
+
default:
|
|
233
|
+
return `@${a.path} (${a.kind}${a.reason ? `: ${a.reason}` : ''})`;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return `<attached-files>\n${parts.join('\n\n')}\n</attached-files>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface PromptImage {
|
|
240
|
+
type: 'image';
|
|
241
|
+
data: string;
|
|
242
|
+
mimeType: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const MAX_IMAGES = 4;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Expand a desk prompt's mentions. A secret-looking file is attached only after the user
|
|
249
|
+
* confirms it; the refusal names the files so the client can ask.
|
|
250
|
+
*/
|
|
251
|
+
export function expandPrompt(
|
|
252
|
+
root: string,
|
|
253
|
+
text: string,
|
|
254
|
+
confirmSensitive: boolean,
|
|
255
|
+
): { text: string; images: PromptImage[]; attachments: Attachment[] } {
|
|
256
|
+
const mentions = extractMentions(text);
|
|
257
|
+
if (!mentions.length) return { text, images: [], attachments: [] };
|
|
258
|
+
const attachments = resolveMentions(root, mentions);
|
|
259
|
+
const sensitive = attachments
|
|
260
|
+
.filter((a) => a.sensitive && a.kind !== 'binary')
|
|
261
|
+
.map((a) => a.path);
|
|
262
|
+
if (sensitive.length && !confirmSensitive)
|
|
263
|
+
throw new ServiceError(
|
|
264
|
+
'sensitive_attachment',
|
|
265
|
+
`Confirm attaching secret-looking files: ${sensitive.join(', ')}`,
|
|
266
|
+
409,
|
|
267
|
+
);
|
|
268
|
+
const images: PromptImage[] = [];
|
|
269
|
+
for (const a of attachments) {
|
|
270
|
+
if (a.kind !== 'image') continue;
|
|
271
|
+
if (images.length >= MAX_IMAGES) {
|
|
272
|
+
a.kind = 'skipped';
|
|
273
|
+
a.reason = `more than ${MAX_IMAGES} images`;
|
|
274
|
+
delete a.data;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
images.push({ type: 'image', data: a.data!, mimeType: a.mime! });
|
|
278
|
+
}
|
|
279
|
+
return { text: `${text}\n\n${attachmentContext(attachments)}`, images, attachments };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function imageTable(db: DatabaseSync): void {
|
|
283
|
+
db.exec(
|
|
284
|
+
'CREATE TABLE IF NOT EXISTS submission_images(submission_id TEXT NOT NULL,position INTEGER NOT NULL,mime TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(submission_id,position))',
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function storeImages(db: DatabaseSync, submissionId: string, images: PromptImage[]): void {
|
|
289
|
+
if (!images.length) return;
|
|
290
|
+
imageTable(db);
|
|
291
|
+
const insert = db.prepare('INSERT INTO submission_images VALUES (?,?,?,?)');
|
|
292
|
+
images.forEach((image, i) => insert.run(submissionId, i, image.mimeType, image.data));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Images ride with their submission once; they are dropped after dispatch. */
|
|
296
|
+
export function takeImages(db: DatabaseSync, submissionId: string): PromptImage[] {
|
|
297
|
+
imageTable(db);
|
|
298
|
+
const rows = db
|
|
299
|
+
.prepare('SELECT mime,data FROM submission_images WHERE submission_id=? ORDER BY position')
|
|
300
|
+
.all(submissionId) as { mime: string; data: string }[];
|
|
301
|
+
if (rows.length)
|
|
302
|
+
db.prepare('DELETE FROM submission_images WHERE submission_id=?').run(submissionId);
|
|
303
|
+
return rows.map((r) => ({ type: 'image', data: r.data, mimeType: r.mime }));
|
|
304
|
+
}
|