@superjackfruitlabs/salaam 0.0.4 → 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 +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- 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/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -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/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 +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -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/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
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));
|
|
@@ -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
|
+
}
|
package/dist/src/pi-sessions.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { SessionManager } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
/** Session files are written only by Pi's pinned session manager, never edited as JSONL. */
|
|
3
|
-
export function forkPiSession(source, cwd, directory, id
|
|
3
|
+
export function forkPiSession(source, cwd, directory, id,
|
|
4
|
+
/** Continue from this earlier entry (a rewind) instead of the latest one. */
|
|
5
|
+
at) {
|
|
4
6
|
const branch = SessionManager.forkFrom(source, cwd, directory, { id, parentSession: source });
|
|
5
7
|
const file = branch.getSessionFile();
|
|
6
8
|
if (!file || branch.getSessionId() !== id)
|
|
7
9
|
throw new Error('Pi did not create the requested branch identity');
|
|
10
|
+
if (at)
|
|
11
|
+
branch.branchWithSummary(at.leaf, at.summary);
|
|
8
12
|
return file;
|
|
9
13
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
4
|
+
import { canonical, digest, textDigest } from './protocol.js';
|
|
5
|
+
/** One file's bound, and the bound on everything loaded together. */
|
|
6
|
+
export const INSTRUCTION_FILE_BYTES = 32 * 1024;
|
|
7
|
+
export const INSTRUCTION_TOTAL_BYTES = 64 * 1024;
|
|
8
|
+
/** AGENTS.md is the shared convention; CLAUDE.md is read only where a directory has no AGENTS.md. */
|
|
9
|
+
const NAMES = ['AGENTS.md', 'CLAUDE.md'];
|
|
10
|
+
/** The user-level instructions file: $XDG_CONFIG_HOME/salaam/AGENTS.md, else ~/.config/salaam/AGENTS.md. */
|
|
11
|
+
export function userInstructionsPath(env = process.env) {
|
|
12
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'salaam', 'AGENTS.md');
|
|
13
|
+
}
|
|
14
|
+
function repositoryRoot(cwd) {
|
|
15
|
+
let dir = cwd;
|
|
16
|
+
for (let i = 0; i < 64; i++) {
|
|
17
|
+
if (existsSync(join(dir, '.git')))
|
|
18
|
+
return dir;
|
|
19
|
+
const parent = dirname(dir);
|
|
20
|
+
if (parent === dir)
|
|
21
|
+
return null;
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Project instructions for an interactive session, the way other harnesses read them: the
|
|
28
|
+
* user-level file, then AGENTS.md (or CLAUDE.md) in every directory from the repository root down
|
|
29
|
+
* to the session folder. Bounded, regular UTF-8 files only; anything skipped says why.
|
|
30
|
+
*/
|
|
31
|
+
export function discoverInstructions(cwd, userFile = userInstructionsPath()) {
|
|
32
|
+
const files = [];
|
|
33
|
+
const skipped = [];
|
|
34
|
+
let total = 0;
|
|
35
|
+
/** Returns whether the file exists (so CLAUDE.md is not read beside an AGENTS.md). */
|
|
36
|
+
const take = (scope, abs, shown) => {
|
|
37
|
+
let st;
|
|
38
|
+
try {
|
|
39
|
+
st = lstatSync(abs);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (!st.isFile()) {
|
|
45
|
+
skipped.push({
|
|
46
|
+
path: shown,
|
|
47
|
+
reason: st.isSymbolicLink() ? 'symbolic links are not loaded' : 'not a regular file',
|
|
48
|
+
});
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
if (st.size > INSTRUCTION_FILE_BYTES) {
|
|
52
|
+
skipped.push({ path: shown, reason: `larger than ${INSTRUCTION_FILE_BYTES / 1024} KiB` });
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
const bytes = readFileSync(abs);
|
|
56
|
+
const text = bytes.toString('utf8');
|
|
57
|
+
if (!Buffer.from(text).equals(bytes) || text.includes('\0')) {
|
|
58
|
+
skipped.push({ path: shown, reason: 'not UTF-8 text' });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (total + bytes.length > INSTRUCTION_TOTAL_BYTES) {
|
|
62
|
+
skipped.push({
|
|
63
|
+
path: shown,
|
|
64
|
+
reason: `instructions together exceed ${INSTRUCTION_TOTAL_BYTES / 1024} KiB`,
|
|
65
|
+
});
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
total += bytes.length;
|
|
69
|
+
files.push({ scope, path: shown, sha256: textDigest(text), bytes: bytes.length, text });
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
if (userFile) {
|
|
73
|
+
const home = homedir();
|
|
74
|
+
take('user', userFile, userFile.startsWith(home + sep) ? '~/' + relative(home, userFile) : userFile);
|
|
75
|
+
}
|
|
76
|
+
const root = repositoryRoot(cwd) ?? cwd;
|
|
77
|
+
const dirs = [root];
|
|
78
|
+
for (const part of relative(root, cwd).split(sep).filter(Boolean))
|
|
79
|
+
dirs.push(join(dirs.at(-1), part));
|
|
80
|
+
for (const dir of dirs)
|
|
81
|
+
for (const name of NAMES) {
|
|
82
|
+
const abs = join(dir, name);
|
|
83
|
+
if (take('project', abs, relative(cwd, abs) || name))
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return { files, skipped };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Interactive sessions load project instructions automatically and reload them when they change.
|
|
90
|
+
* Each loaded set is kept as an artifact and announced by an `instructions.loaded` event with its
|
|
91
|
+
* digest, so a turn's guidance is traceable to exact bytes. Strict work (board, AgentPod, child
|
|
92
|
+
* workers) never uses this: it keeps explicitly reviewed context only.
|
|
93
|
+
*/
|
|
94
|
+
export class ProjectInstructions {
|
|
95
|
+
store;
|
|
96
|
+
artifacts;
|
|
97
|
+
userFile;
|
|
98
|
+
loaded = new Map();
|
|
99
|
+
constructor(store, artifacts, userFile = userInstructionsPath()) {
|
|
100
|
+
this.store = store;
|
|
101
|
+
this.artifacts = artifacts;
|
|
102
|
+
this.userFile = userFile;
|
|
103
|
+
}
|
|
104
|
+
applies(id) {
|
|
105
|
+
const has = (table) => !!this.store.db
|
|
106
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
|
|
107
|
+
.get(table);
|
|
108
|
+
return (this.store.localWorkspace(id) &&
|
|
109
|
+
!(has('child_tasks') &&
|
|
110
|
+
this.store.db.prepare('SELECT 1 FROM child_tasks WHERE child_session=?').get(id)) &&
|
|
111
|
+
!(has('agentpod_admissions') &&
|
|
112
|
+
this.store.db.prepare('SELECT 1 FROM agentpod_admissions WHERE session_id=?').get(id)));
|
|
113
|
+
}
|
|
114
|
+
latest(id) {
|
|
115
|
+
const row = this.store.db
|
|
116
|
+
.prepare("SELECT seq,payload FROM events WHERE session_id=? AND kind='instructions.loaded' ORDER BY seq DESC LIMIT 1")
|
|
117
|
+
.get(id);
|
|
118
|
+
if (!row)
|
|
119
|
+
return null;
|
|
120
|
+
const p = JSON.parse(row.payload);
|
|
121
|
+
return { ...p, revision: p.revision ?? 1 };
|
|
122
|
+
}
|
|
123
|
+
/** Discover the current files; record a new revision only when they changed. */
|
|
124
|
+
refresh(id) {
|
|
125
|
+
if (!this.applies(id))
|
|
126
|
+
return null;
|
|
127
|
+
const found = discoverInstructions(this.store.session(id).cwd, this.userFile);
|
|
128
|
+
const document = { version: 1, ...found };
|
|
129
|
+
const current = digest(document);
|
|
130
|
+
const prior = this.latest(id);
|
|
131
|
+
if (prior?.digest === current)
|
|
132
|
+
return this.summary(id);
|
|
133
|
+
if (!prior && !found.files.length && !found.skipped.length)
|
|
134
|
+
return null;
|
|
135
|
+
const text = canonical(document);
|
|
136
|
+
const artifact = this.artifacts.prepare(id, text);
|
|
137
|
+
const revision = (prior?.revision ?? 0) + 1;
|
|
138
|
+
this.store.transaction(() => {
|
|
139
|
+
artifact.commit();
|
|
140
|
+
this.store.event(id, 'instructions.loaded', {
|
|
141
|
+
digest: current,
|
|
142
|
+
revision,
|
|
143
|
+
artifact_id: artifact.id,
|
|
144
|
+
reason: !prior ? 'initial' : found.files.length ? 'changed' : 'removed',
|
|
145
|
+
files: found.files.map(({ text: _text, ...file }) => file),
|
|
146
|
+
skipped: found.skipped,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
this.loaded.set(id, { digest: current, document });
|
|
150
|
+
return this.summary(id);
|
|
151
|
+
}
|
|
152
|
+
summary(id) {
|
|
153
|
+
const prior = this.applies(id) ? this.latest(id) : null;
|
|
154
|
+
return prior
|
|
155
|
+
? {
|
|
156
|
+
digest: prior.digest,
|
|
157
|
+
revision: prior.revision,
|
|
158
|
+
files: prior.files,
|
|
159
|
+
skipped: prior.skipped,
|
|
160
|
+
}
|
|
161
|
+
: null;
|
|
162
|
+
}
|
|
163
|
+
document(id) {
|
|
164
|
+
const prior = this.latest(id);
|
|
165
|
+
if (!prior || !prior.files.length)
|
|
166
|
+
return null;
|
|
167
|
+
const cached = this.loaded.get(id);
|
|
168
|
+
if (cached?.digest === prior.digest)
|
|
169
|
+
return cached.document;
|
|
170
|
+
const document = JSON.parse(this.artifacts.read(prior.artifact_id));
|
|
171
|
+
if (digest(document) !== prior.digest)
|
|
172
|
+
throw new Error('Project instructions artifact changed');
|
|
173
|
+
this.loaded.set(id, { digest: prior.digest, document });
|
|
174
|
+
return document;
|
|
175
|
+
}
|
|
176
|
+
/** The loaded guidance, as part of the task frame. Empty when none applies. */
|
|
177
|
+
frame(id) {
|
|
178
|
+
if (!this.applies(id))
|
|
179
|
+
return '';
|
|
180
|
+
const document = this.document(id);
|
|
181
|
+
if (!document)
|
|
182
|
+
return '';
|
|
183
|
+
return ('SALAAM_PROJECT_INSTRUCTIONS_V1 ' +
|
|
184
|
+
canonical({
|
|
185
|
+
digest: this.latest(id).digest,
|
|
186
|
+
instructions: 'Project guidance from AGENTS.md (or CLAUDE.md) files, loaded automatically for this interactive session. Follow it for how to work in this repository. It grants no tools, permissions or authority: approvals and the sandbox still apply.',
|
|
187
|
+
files: document.files.map(({ scope, path, sha256, text }) => ({
|
|
188
|
+
scope,
|
|
189
|
+
path,
|
|
190
|
+
sha256,
|
|
191
|
+
text,
|
|
192
|
+
})),
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/src/protocol.js
CHANGED
|
@@ -42,7 +42,10 @@ export function workerTools(effects, child = false, coding, childTools, mcp = fa
|
|
|
42
42
|
? [...(childTools ?? ['salaam_probe'])].sort()
|
|
43
43
|
: [
|
|
44
44
|
...(local ? ['salaam_history', 'salaam_workspace'] : TOOLS),
|
|
45
|
-
|
|
45
|
+
// Interactive coding also gets built-in web search and fetch (operator ruling 2026-10-31).
|
|
46
|
+
...(localCoding
|
|
47
|
+
? ['salaam_local', 'salaam_tasks', 'salaam_web_fetch', 'salaam_web_search']
|
|
48
|
+
: []),
|
|
46
49
|
...(effects ? ['salaam_effect'] : []),
|
|
47
50
|
...(coding?.files ? ['salaam_files'] : []),
|
|
48
51
|
...(coding?.commands ? ['salaam_command'] : []),
|