@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,25 @@
|
|
|
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
|
+
const root = process.argv[2];
|
|
6
|
+
if (!root)
|
|
7
|
+
throw new Error('usage: local-edit-helper <root>');
|
|
8
|
+
const edits = JSON.parse(readFileSync(0, 'utf8'));
|
|
9
|
+
for (const { remove, ...edit } of edits) {
|
|
10
|
+
if (remove) {
|
|
11
|
+
verifyLocalEdits(root, [edit]);
|
|
12
|
+
const path = localPath(root, edit.path);
|
|
13
|
+
rmSync(path);
|
|
14
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
15
|
+
try {
|
|
16
|
+
fsyncSync(directory);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
closeSync(directory);
|
|
20
|
+
}
|
|
21
|
+
process.stdout.write('applied ' + JSON.stringify(edit.path) + '\n');
|
|
22
|
+
}
|
|
23
|
+
else
|
|
24
|
+
applyLocalEdits(root, [edit], (path) => process.stdout.write('applied ' + JSON.stringify(path) + '\n'));
|
|
25
|
+
}
|
package/dist/src/local-edits.js
CHANGED
|
@@ -78,16 +78,178 @@ export function prepareLocalEdits(root, input) {
|
|
|
78
78
|
const expected = item.sha256;
|
|
79
79
|
if (expected !== (original.text === null ? null : localHash(original.text)))
|
|
80
80
|
throw new ServiceError('workspace_changed', 'Read the file first and supply its current sha256; null is only for a new file');
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
// An existing file changes by exact replacements; whole content is for new files (or a
|
|
82
|
+
// deliberate rewrite). Either way the edit resolves to the exact before and after texts, so
|
|
83
|
+
// the approval binding, apply, verify and undo paths are unchanged.
|
|
84
|
+
let after;
|
|
85
|
+
if (item.replacements !== undefined) {
|
|
86
|
+
if (item.content !== undefined)
|
|
87
|
+
throw new ServiceError('invalid_field', 'Use replacements or content for a file, not both');
|
|
88
|
+
if (original.text === null)
|
|
89
|
+
throw new ServiceError('invalid_field', 'Replacements edit an existing file; give a new file its whole content');
|
|
90
|
+
after = applyReplacements(path, original.text, item.replacements);
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
after = item.content;
|
|
94
|
+
if (typeof after !== 'string' || Buffer.byteLength(after) > 262144 || after.includes('\0'))
|
|
95
|
+
throw new ServiceError('invalid_field', 'Provide UTF-8 file content up to 256 KiB');
|
|
96
|
+
return { path, before: original.text, after, mode: original.mode };
|
|
86
97
|
});
|
|
87
|
-
|
|
98
|
+
// What a person reviews is the change, so the change is what is bounded, not the files.
|
|
99
|
+
if (Buffer.byteLength(reviewLocalEdits(edits, Infinity)) > 32768)
|
|
88
100
|
throw new ServiceError('workspace_limit', 'Split this edit into smaller reviewable changes (32 KiB per review)');
|
|
89
101
|
return edits;
|
|
90
102
|
}
|
|
103
|
+
/** Applies exact replacements in order. Each `old` must occur exactly once in the text as it is
|
|
104
|
+
* at that point; the message tells the model how to fix a miss. */
|
|
105
|
+
function applyReplacements(path, text, input) {
|
|
106
|
+
if (!Array.isArray(input) || !input.length || input.length > 20)
|
|
107
|
+
throw new ServiceError('invalid_field', 'Provide one to twenty replacements');
|
|
108
|
+
input.forEach((raw, i) => {
|
|
109
|
+
const r = object(raw), n = `Replacement ${i + 1} in ${path}`;
|
|
110
|
+
if (typeof r.old !== 'string' || typeof r.new !== 'string')
|
|
111
|
+
throw new ServiceError('invalid_field', `${n}: give old and new as text`);
|
|
112
|
+
if (!r.old)
|
|
113
|
+
throw new ServiceError('invalid_field', `${n}: old is empty; quote the exact lines to change`);
|
|
114
|
+
let count = 0;
|
|
115
|
+
for (let at = text.indexOf(r.old); at !== -1; at = text.indexOf(r.old, at + r.old.length))
|
|
116
|
+
count++;
|
|
117
|
+
if (count === 0)
|
|
118
|
+
throw new ServiceError('edit_mismatch', `${n}: old text not found. Read the file again and copy the exact lines, including whitespace`);
|
|
119
|
+
if (count > 1)
|
|
120
|
+
throw new ServiceError('edit_mismatch', `${n}: old text occurs ${count} times. Include more surrounding lines so it matches exactly once`);
|
|
121
|
+
if (r.old === r.new)
|
|
122
|
+
throw new ServiceError('invalid_field', `${n}: changes nothing`);
|
|
123
|
+
text = text.replace(r.old, () => r.new);
|
|
124
|
+
});
|
|
125
|
+
return text;
|
|
126
|
+
}
|
|
127
|
+
const CONTEXT = 3, REVIEW_LINES = 200;
|
|
128
|
+
/** A unified diff of each file's change with a few lines of context: what an approval shows.
|
|
129
|
+
* Long reviews stop at `limit` lines and say how much they left out; the approval still binds
|
|
130
|
+
* the exact full change. */
|
|
131
|
+
export function reviewLocalEdits(edits, limit = REVIEW_LINES) {
|
|
132
|
+
const out = [];
|
|
133
|
+
let hidden = 0;
|
|
134
|
+
for (const edit of edits) {
|
|
135
|
+
const lines = edit.before === null
|
|
136
|
+
? [`+++ ${edit.path} (new file)`, ...split(edit.after).map((l) => '+' + l)]
|
|
137
|
+
: [`--- ${edit.path}`, `+++ ${edit.path}`, ...hunks(split(edit.before), split(edit.after))];
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
if (out.length < limit)
|
|
140
|
+
out.push(line);
|
|
141
|
+
else if (/^[+-]/.test(line) && !/^(\+\+\+|---) /.test(line))
|
|
142
|
+
hidden++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (hidden)
|
|
146
|
+
out.push(`… ${hidden} more changed lines not shown; the approval covers the exact full change`);
|
|
147
|
+
return out.join('\n');
|
|
148
|
+
}
|
|
149
|
+
function split(text) {
|
|
150
|
+
const lines = text.split('\n');
|
|
151
|
+
if (lines.at(-1) === '')
|
|
152
|
+
lines.pop();
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
/** Unified-diff hunks from a line-level Myers diff (prefix and suffix trimmed first, so a small
|
|
156
|
+
* change to a big file stays cheap). A diff too large to compute in budget is shown whole. */
|
|
157
|
+
function hunks(a, b) {
|
|
158
|
+
let pre = 0;
|
|
159
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre])
|
|
160
|
+
pre++;
|
|
161
|
+
let suf = 0;
|
|
162
|
+
while (suf < a.length - pre &&
|
|
163
|
+
suf < b.length - pre &&
|
|
164
|
+
a[a.length - 1 - suf] === b[b.length - 1 - suf])
|
|
165
|
+
suf++;
|
|
166
|
+
const ops = myers(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
|
167
|
+
// Full op list over the whole file: equal prefix, the diffed middle, equal suffix.
|
|
168
|
+
const all = [];
|
|
169
|
+
for (let i = 0; i < pre; i++)
|
|
170
|
+
all.push({ t: ' ', s: a[i], ai: i, bi: i });
|
|
171
|
+
let ai = pre, bi = pre;
|
|
172
|
+
for (const o of ops) {
|
|
173
|
+
all.push({ ...o, ai, bi });
|
|
174
|
+
if (o.t !== '+')
|
|
175
|
+
ai++;
|
|
176
|
+
if (o.t !== '-')
|
|
177
|
+
bi++;
|
|
178
|
+
}
|
|
179
|
+
for (let i = 0; i < suf; i++)
|
|
180
|
+
all.push({ t: ' ', s: a[ai + i], ai: ai + i, bi: bi + i });
|
|
181
|
+
const out = [];
|
|
182
|
+
let i = 0;
|
|
183
|
+
while (i < all.length) {
|
|
184
|
+
if (all[i].t === ' ') {
|
|
185
|
+
i++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
let start = Math.max(0, i - CONTEXT), end = i;
|
|
189
|
+
// Extend while the next change is within 2*CONTEXT equal lines.
|
|
190
|
+
for (;;) {
|
|
191
|
+
while (end < all.length && all[end].t !== ' ')
|
|
192
|
+
end++;
|
|
193
|
+
let gap = end;
|
|
194
|
+
while (gap < all.length && all[gap].t === ' ' && gap - end < 2 * CONTEXT)
|
|
195
|
+
gap++;
|
|
196
|
+
if (gap < all.length && all[gap].t !== ' ' && gap - end < 2 * CONTEXT)
|
|
197
|
+
end = gap;
|
|
198
|
+
else
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
const stop = Math.min(all.length, end + CONTEXT), part = all.slice(start, stop), aStart = part.find((o) => o.t !== '+')?.ai ?? part[0].ai, bStart = part.find((o) => o.t !== '-')?.bi ?? part[0].bi, aLen = part.filter((o) => o.t !== '+').length, bLen = part.filter((o) => o.t !== '-').length;
|
|
202
|
+
out.push(`@@ -${aStart + 1},${aLen} +${bStart + 1},${bLen} @@`, ...part.map((o) => o.t + o.s));
|
|
203
|
+
i = stop;
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
function myers(a, b) {
|
|
208
|
+
const n = a.length, m = b.length, max = n + m;
|
|
209
|
+
if (max > 20000)
|
|
210
|
+
return [...a.map((s) => ({ t: '-', s })), ...b.map((s) => ({ t: '+', s }))];
|
|
211
|
+
const v = new Map([[1, 0]]), trace = [];
|
|
212
|
+
for (let d = 0; d <= max; d++) {
|
|
213
|
+
trace.push(new Map(v));
|
|
214
|
+
for (let k = -d; k <= d; k += 2) {
|
|
215
|
+
let x = k === -d || (k !== d && (v.get(k - 1) ?? -1) < (v.get(k + 1) ?? -1))
|
|
216
|
+
? v.get(k + 1)
|
|
217
|
+
: v.get(k - 1) + 1;
|
|
218
|
+
let y = x - k;
|
|
219
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
220
|
+
x++;
|
|
221
|
+
y++;
|
|
222
|
+
}
|
|
223
|
+
v.set(k, x);
|
|
224
|
+
if (x >= n && y >= m)
|
|
225
|
+
return backtrack(a, b, trace, n, m);
|
|
226
|
+
}
|
|
227
|
+
if (d > 2000)
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
return [...a.map((s) => ({ t: '-', s })), ...b.map((s) => ({ t: '+', s }))];
|
|
231
|
+
}
|
|
232
|
+
function backtrack(a, b, trace, n, m) {
|
|
233
|
+
const ops = [];
|
|
234
|
+
let x = n, y = m;
|
|
235
|
+
for (let d = trace.length - 1; d >= 0; d--) {
|
|
236
|
+
const v = trace[d], k = x - y, prevK = k === -d || (k !== d && (v.get(k - 1) ?? -1) < (v.get(k + 1) ?? -1)) ? k + 1 : k - 1, prevX = v.get(prevK) ?? 0, prevY = prevX - prevK;
|
|
237
|
+
while (x > prevX && y > prevY) {
|
|
238
|
+
ops.push({ t: ' ', s: a[x - 1] });
|
|
239
|
+
x--;
|
|
240
|
+
y--;
|
|
241
|
+
}
|
|
242
|
+
if (d > 0) {
|
|
243
|
+
if (x === prevX)
|
|
244
|
+
ops.push({ t: '+', s: b[y - 1] });
|
|
245
|
+
else
|
|
246
|
+
ops.push({ t: '-', s: a[x - 1] });
|
|
247
|
+
}
|
|
248
|
+
x = prevX;
|
|
249
|
+
y = prevY;
|
|
250
|
+
}
|
|
251
|
+
return ops.reverse();
|
|
252
|
+
}
|
|
91
253
|
export function verifyLocalEdits(root, edits) {
|
|
92
254
|
for (const edit of edits) {
|
|
93
255
|
const actual = contents(localPath(root, edit.path));
|
package/dist/src/local-undo.js
CHANGED
|
@@ -2,6 +2,7 @@ import { dirname } from 'node:path';
|
|
|
2
2
|
import { rmSync, openSync, closeSync, fsyncSync, constants } from 'node:fs';
|
|
3
3
|
import { assertPermissionBinding } from './autonomy.js';
|
|
4
4
|
import { digest, ServiceError } from './protocol.js';
|
|
5
|
+
import { applyEditsAsTools } from './tool-identity.js';
|
|
5
6
|
import { applyLocalEdits, localPath, verifyLocalEdits } from './local-edits.js';
|
|
6
7
|
/** Unfinished inverse edits fence every local session sharing the root, including after a crash. */
|
|
7
8
|
export function assertUndoSettled(store, root) {
|
|
@@ -24,7 +25,7 @@ export function reviewUndo(store, id) {
|
|
|
24
25
|
verifyLocalEdits(session.cwd, edits.map((e) => ({ ...e, before: e.after })));
|
|
25
26
|
return { call_id: row.id, digest: digest({ root: session.cwd, call: row.id, edits }), edits };
|
|
26
27
|
}
|
|
27
|
-
export function applyUndo(store, id, request, reviewed, controller, idle) {
|
|
28
|
+
export function applyUndo(store, id, request, reviewed, controller, idle, tools) {
|
|
28
29
|
const input = { kind: 'local-undo', id, reviewed };
|
|
29
30
|
const saved = store.cachedRequest('local-operator', request, input);
|
|
30
31
|
if (saved)
|
|
@@ -47,23 +48,31 @@ export function applyUndo(store, id, request, reviewed, controller, idle) {
|
|
|
47
48
|
return { state: 'outcome_unknown', ...review };
|
|
48
49
|
});
|
|
49
50
|
// Intent is durable before touching files. A lost process never repeats this mutation.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
if (tools?.mode === 'tools-user')
|
|
52
|
+
applyEditsAsTools(tools, root, work.edits.map((edit) => ({
|
|
53
|
+
...edit,
|
|
54
|
+
before: edit.after,
|
|
55
|
+
after: edit.before ?? '',
|
|
56
|
+
...(edit.before === null ? { remove: true } : {}),
|
|
57
|
+
})), () => { });
|
|
58
|
+
else
|
|
59
|
+
for (const edit of work.edits) {
|
|
60
|
+
const inverse = { ...edit, before: edit.after, after: edit.before ?? '' };
|
|
61
|
+
if (edit.before === null) {
|
|
62
|
+
verifyLocalEdits(root, [inverse]);
|
|
63
|
+
const path = localPath(root, edit.path);
|
|
64
|
+
rmSync(path);
|
|
65
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
66
|
+
try {
|
|
67
|
+
fsyncSync(directory);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
closeSync(directory);
|
|
71
|
+
}
|
|
62
72
|
}
|
|
73
|
+
else
|
|
74
|
+
applyLocalEdits(root, [inverse], () => { });
|
|
63
75
|
}
|
|
64
|
-
else
|
|
65
|
-
applyLocalEdits(root, [inverse], () => { });
|
|
66
|
-
}
|
|
67
76
|
verifyLocalEdits(root, work.edits);
|
|
68
77
|
return store.transaction(() => {
|
|
69
78
|
const result = { state: 'completed', call_id: work.call_id };
|
package/dist/src/managed.js
CHANGED
|
@@ -383,7 +383,7 @@ export class ManagedWork {
|
|
|
383
383
|
this.fresh(op);
|
|
384
384
|
this.store.db
|
|
385
385
|
.prepare("UPDATE approvals SET state='decided',decision=?,decided_by=?,revision=revision+1 WHERE id=?")
|
|
386
|
-
.run(decision, scoped ? 'scoped-policy' : 'local-operator', id);
|
|
386
|
+
.run(decision, scoped ? (scoped.decidedBy ?? 'scoped-policy') : 'local-operator', id);
|
|
387
387
|
if (decision === 'deny')
|
|
388
388
|
this.transition(op, 'cancelled_before_dispatch', 'Denied by operator');
|
|
389
389
|
this.store.event(approval.session_id, 'approval.decided', { approval_id: id, operation_id: op.id, decision }, request);
|
|
@@ -0,0 +1,277 @@
|
|
|
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 { ServiceError } from './protocol.js';
|
|
6
|
+
/** `@path` mentions attach workspace files to a desk prompt (Phase 3b). */
|
|
7
|
+
export const MENTION_LIMITS = {
|
|
8
|
+
perFile: 64 * 1024,
|
|
9
|
+
total: 256 * 1024,
|
|
10
|
+
image: 512 * 1024,
|
|
11
|
+
listing: 5000,
|
|
12
|
+
entries: 200,
|
|
13
|
+
};
|
|
14
|
+
const SENSITIVE = [
|
|
15
|
+
/(^|\/)\.env(\..+)?$/,
|
|
16
|
+
/\.(pem|key|p12|pfx|keystore|jks)$/i,
|
|
17
|
+
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
|
|
18
|
+
/(^|\/)\.(netrc|npmrc|pypirc)$/,
|
|
19
|
+
/(^|\/)(credentials|secrets?)(\.[a-z]+)?$/i,
|
|
20
|
+
];
|
|
21
|
+
export function isSensitivePath(path) {
|
|
22
|
+
return SENSITIVE.some((pattern) => pattern.test(path));
|
|
23
|
+
}
|
|
24
|
+
/** `@token` at a word start; `@"a b"` may hold spaces; trailing punctuation is dropped. */
|
|
25
|
+
export function extractMentions(text) {
|
|
26
|
+
const found = [];
|
|
27
|
+
const pattern = /(^|[\s([{])@(?:"([^"]+)"|([^\s"]+))/g;
|
|
28
|
+
for (const match of text.matchAll(pattern)) {
|
|
29
|
+
const value = match[2] ?? match[3].replace(/[.,;:!?)\]}]+$/, '');
|
|
30
|
+
if (value && !value.startsWith('@') && !found.includes(value))
|
|
31
|
+
found.push(value);
|
|
32
|
+
}
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
function inside(root, path) {
|
|
36
|
+
const rel = relative(root, path);
|
|
37
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
38
|
+
}
|
|
39
|
+
/** Tracked and untracked-but-not-ignored files; a symlink is listed only if it stays inside. */
|
|
40
|
+
export function listWorkspaceFiles(root) {
|
|
41
|
+
let names;
|
|
42
|
+
try {
|
|
43
|
+
names = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
|
|
44
|
+
cwd: root,
|
|
45
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
46
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
47
|
+
})
|
|
48
|
+
.toString('utf8')
|
|
49
|
+
.split('\0')
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
names = walk(root, root);
|
|
54
|
+
}
|
|
55
|
+
const real = realpathSync(root);
|
|
56
|
+
const kept = [];
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
if (kept.length >= MENTION_LIMITS.listing)
|
|
59
|
+
break;
|
|
60
|
+
try {
|
|
61
|
+
if (lstatSync(resolve(root, name)).isSymbolicLink() &&
|
|
62
|
+
!inside(real, realpathSync(resolve(root, name))))
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
kept.push(name);
|
|
69
|
+
}
|
|
70
|
+
return kept;
|
|
71
|
+
}
|
|
72
|
+
function walk(root, dir, out = []) {
|
|
73
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
74
|
+
if (out.length >= MENTION_LIMITS.listing)
|
|
75
|
+
break;
|
|
76
|
+
if (entry.name === '.git' || entry.name === 'node_modules')
|
|
77
|
+
continue;
|
|
78
|
+
const full = resolve(dir, entry.name);
|
|
79
|
+
if (entry.isDirectory())
|
|
80
|
+
walk(root, full, out);
|
|
81
|
+
else
|
|
82
|
+
out.push(relative(root, full).split(sep).join('/'));
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** Basename hits first, then path substrings, then in-order subsequences. */
|
|
87
|
+
export function rankFiles(files, query) {
|
|
88
|
+
if (!query)
|
|
89
|
+
return files;
|
|
90
|
+
const q = query.toLowerCase();
|
|
91
|
+
const score = (file) => {
|
|
92
|
+
const f = file.toLowerCase();
|
|
93
|
+
const base = basename(f);
|
|
94
|
+
if (base.startsWith(q))
|
|
95
|
+
return 4;
|
|
96
|
+
if (base.includes(q))
|
|
97
|
+
return 3;
|
|
98
|
+
if (f.includes(q))
|
|
99
|
+
return 2;
|
|
100
|
+
let i = 0;
|
|
101
|
+
for (const ch of f)
|
|
102
|
+
if (ch === q[i])
|
|
103
|
+
i++;
|
|
104
|
+
return i === q.length ? 1 : 0;
|
|
105
|
+
};
|
|
106
|
+
return files
|
|
107
|
+
.map((file) => ({ file, s: score(file) }))
|
|
108
|
+
.filter((x) => x.s > 0)
|
|
109
|
+
.sort((a, b) => b.s - a.s || a.file.length - b.file.length || a.file.localeCompare(b.file))
|
|
110
|
+
.map((x) => x.file);
|
|
111
|
+
}
|
|
112
|
+
function imageMime(bytes) {
|
|
113
|
+
if (bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')))
|
|
114
|
+
return 'image/png';
|
|
115
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
116
|
+
return 'image/jpeg';
|
|
117
|
+
if (bytes.subarray(0, 4).toString('latin1') === 'GIF8')
|
|
118
|
+
return 'image/gif';
|
|
119
|
+
if (bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
|
|
120
|
+
bytes.subarray(8, 12).toString('latin1') === 'WEBP')
|
|
121
|
+
return 'image/webp';
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
function truncate(text, limit) {
|
|
125
|
+
const buf = Buffer.from(text, 'utf8');
|
|
126
|
+
if (buf.length <= limit)
|
|
127
|
+
return text;
|
|
128
|
+
// Cut on a character boundary so the byte bound holds.
|
|
129
|
+
return buf.subarray(0, limit).toString('utf8').replace(/�$/, '');
|
|
130
|
+
}
|
|
131
|
+
/** Read each mention under the workspace; nothing outside it, through a symlink or not. */
|
|
132
|
+
export function resolveMentions(root, mentions) {
|
|
133
|
+
const real = realpathSync(root);
|
|
134
|
+
let budget = MENTION_LIMITS.total;
|
|
135
|
+
return mentions.map((path) => {
|
|
136
|
+
const target = resolve(real, path);
|
|
137
|
+
if (isAbsolute(path) || !inside(real, target))
|
|
138
|
+
return { path, kind: 'refused', reason: 'outside the workspace' };
|
|
139
|
+
let resolved;
|
|
140
|
+
try {
|
|
141
|
+
resolved = realpathSync(target);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return { path, kind: 'missing' };
|
|
145
|
+
}
|
|
146
|
+
if (!inside(real, resolved))
|
|
147
|
+
return { path, kind: 'refused', reason: 'links outside the workspace' };
|
|
148
|
+
const sensitive = isSensitivePath(path) || undefined;
|
|
149
|
+
const stat = lstatSync(resolved);
|
|
150
|
+
if (stat.isDirectory()) {
|
|
151
|
+
const names = readdirSync(resolved, { withFileTypes: true })
|
|
152
|
+
.filter((e) => e.name !== '.git')
|
|
153
|
+
.slice(0, MENTION_LIMITS.entries)
|
|
154
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
155
|
+
const content = truncate(names.join('\n'), Math.min(MENTION_LIMITS.perFile, budget));
|
|
156
|
+
budget -= Buffer.byteLength(content);
|
|
157
|
+
return { path, kind: 'listing', content };
|
|
158
|
+
}
|
|
159
|
+
if (!stat.isFile())
|
|
160
|
+
return { path, kind: 'refused', reason: 'not a regular file' };
|
|
161
|
+
const bytes = readFileSync(resolved);
|
|
162
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
163
|
+
const mime = imageMime(bytes);
|
|
164
|
+
if (mime) {
|
|
165
|
+
if (bytes.length > MENTION_LIMITS.image)
|
|
166
|
+
return {
|
|
167
|
+
path,
|
|
168
|
+
kind: 'skipped',
|
|
169
|
+
reason: 'image larger than 512 KiB',
|
|
170
|
+
bytes: bytes.length,
|
|
171
|
+
sha256,
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
path,
|
|
175
|
+
kind: 'image',
|
|
176
|
+
mime,
|
|
177
|
+
data: bytes.toString('base64'),
|
|
178
|
+
sha256,
|
|
179
|
+
bytes: bytes.length,
|
|
180
|
+
sensitive,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (bytes.subarray(0, 8192).includes(0))
|
|
184
|
+
return { path, kind: 'binary', sha256, bytes: bytes.length, sensitive };
|
|
185
|
+
if (budget <= 0)
|
|
186
|
+
return {
|
|
187
|
+
path,
|
|
188
|
+
kind: 'skipped',
|
|
189
|
+
reason: 'attachment budget used up',
|
|
190
|
+
sha256,
|
|
191
|
+
bytes: bytes.length,
|
|
192
|
+
sensitive,
|
|
193
|
+
};
|
|
194
|
+
const limit = Math.min(MENTION_LIMITS.perFile, budget);
|
|
195
|
+
const content = truncate(bytes.toString('utf8'), limit);
|
|
196
|
+
budget -= Buffer.byteLength(content);
|
|
197
|
+
return {
|
|
198
|
+
path,
|
|
199
|
+
kind: 'text',
|
|
200
|
+
content,
|
|
201
|
+
sha256,
|
|
202
|
+
bytes: bytes.length,
|
|
203
|
+
...(Buffer.byteLength(content) < bytes.length ? { truncated: true } : {}),
|
|
204
|
+
...(sensitive ? { sensitive } : {}),
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** The text the model reads; image bytes travel separately as image content. */
|
|
209
|
+
export function attachmentContext(attachments) {
|
|
210
|
+
if (!attachments.length)
|
|
211
|
+
return '';
|
|
212
|
+
const parts = attachments.map((a) => {
|
|
213
|
+
const digest = a.sha256 ? `, sha256 ${a.sha256.slice(0, 12)}` : '';
|
|
214
|
+
switch (a.kind) {
|
|
215
|
+
case 'text':
|
|
216
|
+
return `@${a.path} (${a.bytes} bytes${digest}${a.truncated ? ', truncated' : ''})\n\`\`\`\n${a.content}\n\`\`\``;
|
|
217
|
+
case 'listing':
|
|
218
|
+
return `@${a.path} (directory)\n\`\`\`\n${a.content}\n\`\`\``;
|
|
219
|
+
case 'image':
|
|
220
|
+
return `@${a.path} (image, attached)${digest ? ` [${digest.slice(2)}]` : ''}`;
|
|
221
|
+
case 'binary':
|
|
222
|
+
return `@${a.path} (binary, ${a.bytes} bytes${digest}; not attached)`;
|
|
223
|
+
default:
|
|
224
|
+
return `@${a.path} (${a.kind}${a.reason ? `: ${a.reason}` : ''})`;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
return `<attached-files>\n${parts.join('\n\n')}\n</attached-files>`;
|
|
228
|
+
}
|
|
229
|
+
export const MAX_IMAGES = 4;
|
|
230
|
+
/**
|
|
231
|
+
* Expand a desk prompt's mentions. A secret-looking file is attached only after the user
|
|
232
|
+
* confirms it; the refusal names the files so the client can ask.
|
|
233
|
+
*/
|
|
234
|
+
export function expandPrompt(root, text, confirmSensitive) {
|
|
235
|
+
const mentions = extractMentions(text);
|
|
236
|
+
if (!mentions.length)
|
|
237
|
+
return { text, images: [], attachments: [] };
|
|
238
|
+
const attachments = resolveMentions(root, mentions);
|
|
239
|
+
const sensitive = attachments
|
|
240
|
+
.filter((a) => a.sensitive && a.kind !== 'binary')
|
|
241
|
+
.map((a) => a.path);
|
|
242
|
+
if (sensitive.length && !confirmSensitive)
|
|
243
|
+
throw new ServiceError('sensitive_attachment', `Confirm attaching secret-looking files: ${sensitive.join(', ')}`, 409);
|
|
244
|
+
const images = [];
|
|
245
|
+
for (const a of attachments) {
|
|
246
|
+
if (a.kind !== 'image')
|
|
247
|
+
continue;
|
|
248
|
+
if (images.length >= MAX_IMAGES) {
|
|
249
|
+
a.kind = 'skipped';
|
|
250
|
+
a.reason = `more than ${MAX_IMAGES} images`;
|
|
251
|
+
delete a.data;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
images.push({ type: 'image', data: a.data, mimeType: a.mime });
|
|
255
|
+
}
|
|
256
|
+
return { text: `${text}\n\n${attachmentContext(attachments)}`, images, attachments };
|
|
257
|
+
}
|
|
258
|
+
function imageTable(db) {
|
|
259
|
+
db.exec('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))');
|
|
260
|
+
}
|
|
261
|
+
export function storeImages(db, submissionId, images) {
|
|
262
|
+
if (!images.length)
|
|
263
|
+
return;
|
|
264
|
+
imageTable(db);
|
|
265
|
+
const insert = db.prepare('INSERT INTO submission_images VALUES (?,?,?,?)');
|
|
266
|
+
images.forEach((image, i) => insert.run(submissionId, i, image.mimeType, image.data));
|
|
267
|
+
}
|
|
268
|
+
/** Images ride with their submission once; they are dropped after dispatch. */
|
|
269
|
+
export function takeImages(db, submissionId) {
|
|
270
|
+
imageTable(db);
|
|
271
|
+
const rows = db
|
|
272
|
+
.prepare('SELECT mime,data FROM submission_images WHERE submission_id=? ORDER BY position')
|
|
273
|
+
.all(submissionId);
|
|
274
|
+
if (rows.length)
|
|
275
|
+
db.prepare('DELETE FROM submission_images WHERE submission_id=?').run(submissionId);
|
|
276
|
+
return rows.map((r) => ({ type: 'image', data: r.data, mimeType: r.mime }));
|
|
277
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { declarationDigest, operatorMcpPath, readOperatorMcpFile, validateDeclaration, writeOperatorMcpFile, } from './operator-mcp.js';
|
|
2
|
+
export const MCP_HELP = `salaam mcp: operator-declared MCP servers
|
|
3
|
+
|
|
4
|
+
salaam mcp add <name> [--env K='\${VAR}']... -- <command> [args...]
|
|
5
|
+
salaam mcp add <name> --url <https-url> [--header K='Bearer \${VAR}']...
|
|
6
|
+
salaam mcp list
|
|
7
|
+
salaam mcp rm <name>
|
|
8
|
+
|
|
9
|
+
Options: --config <path> (default $XDG_CONFIG_HOME/salaam/mcp.json)
|
|
10
|
+
Values in --env and --header must reference environment variables as \${VAR};
|
|
11
|
+
inline secrets are refused. Changes apply at the next salaam service start.`;
|
|
12
|
+
function pairs(values, flag) {
|
|
13
|
+
if (!values.length)
|
|
14
|
+
return undefined;
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const value of values) {
|
|
17
|
+
const i = value.indexOf('=');
|
|
18
|
+
if (i < 1)
|
|
19
|
+
throw new Error(`${flag} expects K=V`);
|
|
20
|
+
const key = value.slice(0, i);
|
|
21
|
+
if (Object.hasOwn(out, key))
|
|
22
|
+
throw new Error(`${flag} ${key} given twice`);
|
|
23
|
+
out[key] = value.slice(i + 1);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/** `salaam mcp ...`; argv excludes the leading "mcp". Returns text for stdout. */
|
|
28
|
+
export function mcpCli(argv, env = process.env) {
|
|
29
|
+
const dash = argv.indexOf('--');
|
|
30
|
+
const head = dash < 0 ? [...argv] : argv.slice(0, dash);
|
|
31
|
+
const command = dash < 0 ? [] : argv.slice(dash + 1);
|
|
32
|
+
const take = (flag, many = false) => {
|
|
33
|
+
const found = [];
|
|
34
|
+
for (let i = head.indexOf(flag); i >= 0; i = head.indexOf(flag)) {
|
|
35
|
+
const value = head[i + 1];
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
throw new Error(`Missing value for ${flag}`);
|
|
38
|
+
found.push(value);
|
|
39
|
+
head.splice(i, 2);
|
|
40
|
+
if (!many)
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
};
|
|
45
|
+
const path = take('--config')[0] ?? operatorMcpPath(env);
|
|
46
|
+
const url = take('--url')[0];
|
|
47
|
+
const headers = pairs(take('--header', true), '--header');
|
|
48
|
+
const envs = pairs(take('--env', true), '--env');
|
|
49
|
+
const [verb, name, ...rest] = head;
|
|
50
|
+
if (rest.length || head.some((x) => x.startsWith('--')))
|
|
51
|
+
throw new Error('Unexpected arguments; run salaam mcp --help');
|
|
52
|
+
const file = readOperatorMcpFile(path);
|
|
53
|
+
if (verb === 'list') {
|
|
54
|
+
if (dash >= 0 || name)
|
|
55
|
+
throw new Error('salaam mcp list takes no arguments');
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
config: path,
|
|
58
|
+
servers: file.servers.map((s) => ({ ...s, declaration_digest: declarationDigest(s) })),
|
|
59
|
+
}, null, 2);
|
|
60
|
+
}
|
|
61
|
+
if (!name)
|
|
62
|
+
throw new Error('A server name is required');
|
|
63
|
+
if (verb === 'rm') {
|
|
64
|
+
if (dash >= 0 || url || headers || envs)
|
|
65
|
+
throw new Error('salaam mcp rm takes only a name');
|
|
66
|
+
const servers = file.servers.filter((s) => s.name !== name);
|
|
67
|
+
if (servers.length === file.servers.length)
|
|
68
|
+
throw new Error(`No declared server "${name}"`);
|
|
69
|
+
writeOperatorMcpFile(path, { version: 1, servers });
|
|
70
|
+
return JSON.stringify({ removed: name, config: path });
|
|
71
|
+
}
|
|
72
|
+
if (verb === 'add') {
|
|
73
|
+
if (file.servers.some((s) => s.name === name))
|
|
74
|
+
throw new Error(`Server "${name}" is already declared; rm it first`);
|
|
75
|
+
let declaration;
|
|
76
|
+
if (url) {
|
|
77
|
+
if (command.length || envs)
|
|
78
|
+
throw new Error('Use either --url or -- <command>, not both');
|
|
79
|
+
declaration = validateDeclaration({
|
|
80
|
+
name,
|
|
81
|
+
transport: 'http',
|
|
82
|
+
url,
|
|
83
|
+
...(headers ? { headers } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
if (!command.length)
|
|
88
|
+
throw new Error('Supply -- <command> [args...] or --url <url>');
|
|
89
|
+
if (headers)
|
|
90
|
+
throw new Error('--header applies to --url servers');
|
|
91
|
+
declaration = validateDeclaration({
|
|
92
|
+
name,
|
|
93
|
+
transport: 'stdio',
|
|
94
|
+
command: command[0],
|
|
95
|
+
args: command.slice(1),
|
|
96
|
+
...(envs ? { env: envs } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
writeOperatorMcpFile(path, { version: 1, servers: [...file.servers, declaration] });
|
|
100
|
+
return JSON.stringify({
|
|
101
|
+
added: name,
|
|
102
|
+
config: path,
|
|
103
|
+
declaration_digest: declarationDigest(declaration),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
throw new Error('Use salaam mcp add, list or rm');
|
|
107
|
+
}
|