@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,302 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { guardedRequest } from './web-guard.js';
|
|
3
|
+
import { decodeEntities, stripTags } from './web-fetch.js';
|
|
4
|
+
export const SEARCH_KINDS = ['general', 'code', 'package', 'paper', 'wiki'];
|
|
5
|
+
const KEYLESS = ['wikipedia', 'stackexchange', 'github', 'npm', 'pypi', 'arxiv', 'hackernews'];
|
|
6
|
+
const ROUTES = {
|
|
7
|
+
code: ['github', 'stackexchange'],
|
|
8
|
+
package: ['npm', 'pypi'],
|
|
9
|
+
paper: ['arxiv'],
|
|
10
|
+
wiki: ['wikipedia'],
|
|
11
|
+
};
|
|
12
|
+
const GENERAL_KEYLESS = ['wikipedia', 'stackexchange', 'hackernews'];
|
|
13
|
+
const CACHE_TTL_MS = 10 * 60_000;
|
|
14
|
+
const CACHE_MAX = 200;
|
|
15
|
+
const clean = (s, max = 400) => decodeEntities(stripTags(String(s ?? '')))
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim()
|
|
18
|
+
.slice(0, max);
|
|
19
|
+
const q = encodeURIComponent;
|
|
20
|
+
export class WebSearch {
|
|
21
|
+
transport;
|
|
22
|
+
providers;
|
|
23
|
+
now;
|
|
24
|
+
backends = new Map();
|
|
25
|
+
last = new Map();
|
|
26
|
+
cache = new Map();
|
|
27
|
+
constructor(opts = {}) {
|
|
28
|
+
this.transport =
|
|
29
|
+
opts.transport ??
|
|
30
|
+
((url, o = {}) => guardedRequest(url, { ...o, maxBytes: 1_000_000, timeoutMs: 12_000 }));
|
|
31
|
+
this.providers = opts.providers ?? [];
|
|
32
|
+
this.now = opts.now ?? Date.now;
|
|
33
|
+
const json = async (url, o) => {
|
|
34
|
+
const r = await this.transport(url, {
|
|
35
|
+
...o,
|
|
36
|
+
headers: { accept: 'application/json', ...o?.headers },
|
|
37
|
+
});
|
|
38
|
+
if (r.status < 200 || r.status >= 300)
|
|
39
|
+
throw new Error(`HTTP ${r.status}`);
|
|
40
|
+
return JSON.parse(r.body);
|
|
41
|
+
};
|
|
42
|
+
const add = (name, run, intervalMs = 1000) => this.backends.set(name, { name, run, intervalMs });
|
|
43
|
+
add('wikipedia', async (s, n) => ((await json(`https://en.wikipedia.org/w/api.php?action=query&list=search&format=json&utf8=1&srlimit=${n}&srsearch=${q(s)}`)).query?.search ?? []).map((h) => ({
|
|
44
|
+
title: clean(h.title, 200),
|
|
45
|
+
url: 'https://en.wikipedia.org/wiki/' + encodeURIComponent(String(h.title).replace(/ /g, '_')),
|
|
46
|
+
snippet: clean(h.snippet),
|
|
47
|
+
source: 'wikipedia',
|
|
48
|
+
})));
|
|
49
|
+
add('stackexchange', async (s, n) => ((await json(`https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&site=stackoverflow&pagesize=${n}&q=${q(s)}`)).items ?? []).map((h) => ({
|
|
50
|
+
title: clean(h.title, 200),
|
|
51
|
+
url: String(h.link),
|
|
52
|
+
snippet: `score ${h.score ?? 0}${h.is_answered ? ', answered' : ''}`,
|
|
53
|
+
source: 'stackexchange',
|
|
54
|
+
})));
|
|
55
|
+
add('github', async (s, n) => ((await json(`https://api.github.com/search/repositories?per_page=${n}&q=${q(s)}`, {
|
|
56
|
+
headers: {
|
|
57
|
+
accept: 'application/vnd.github+json',
|
|
58
|
+
...(opts.githubToken ? { authorization: 'Bearer ' + opts.githubToken } : {}),
|
|
59
|
+
},
|
|
60
|
+
})).items ?? []).map((h) => ({
|
|
61
|
+
title: clean(h.full_name, 200),
|
|
62
|
+
url: String(h.html_url),
|
|
63
|
+
snippet: `${clean(h.description)} (${h.stargazers_count ?? 0} stars)`.trim(),
|
|
64
|
+
source: 'github',
|
|
65
|
+
})),
|
|
66
|
+
// Unauthenticated repository search allows 10 requests a minute.
|
|
67
|
+
opts.githubToken ? 2000 : 6000);
|
|
68
|
+
add('npm', async (s, n) => ((await json(`https://registry.npmjs.org/-/v1/search?size=${n}&text=${q(s)}`)).objects ?? []).map((o) => ({
|
|
69
|
+
title: `${clean(o.package?.name, 200)}@${clean(o.package?.version, 40)}`,
|
|
70
|
+
url: String(o.package?.links?.npm ?? `https://www.npmjs.com/package/${o.package?.name}`),
|
|
71
|
+
snippet: clean(o.package?.description),
|
|
72
|
+
source: 'npm',
|
|
73
|
+
})));
|
|
74
|
+
// PyPI has no search API; its official JSON API answers an exact project name.
|
|
75
|
+
add('pypi', async (s) => {
|
|
76
|
+
const name = s.trim();
|
|
77
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name))
|
|
78
|
+
return [];
|
|
79
|
+
const r = await this.transport(`https://pypi.org/pypi/${q(name)}/json`, {
|
|
80
|
+
headers: { accept: 'application/json' },
|
|
81
|
+
});
|
|
82
|
+
if (r.status === 404)
|
|
83
|
+
return [];
|
|
84
|
+
if (r.status < 200 || r.status >= 300)
|
|
85
|
+
throw new Error(`HTTP ${r.status}`);
|
|
86
|
+
const info = JSON.parse(r.body).info ?? {};
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
title: `${clean(info.name, 200)} ${clean(info.version, 40)}`.trim(),
|
|
90
|
+
url: String(info.package_url ?? `https://pypi.org/project/${name}/`),
|
|
91
|
+
snippet: clean(info.summary),
|
|
92
|
+
source: 'pypi',
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
});
|
|
96
|
+
add('arxiv', async (s, n) => {
|
|
97
|
+
const r = await this.transport(`https://export.arxiv.org/api/query?max_results=${n}&search_query=all:${q(s)}`, { headers: { accept: 'application/atom+xml' } });
|
|
98
|
+
if (r.status < 200 || r.status >= 300)
|
|
99
|
+
throw new Error(`HTTP ${r.status}`);
|
|
100
|
+
return [...r.body.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map((m) => {
|
|
101
|
+
const tag = (t) => new RegExp(`<${t}[^>]*>([\\s\\S]*?)</${t}>`).exec(m[1])?.[1] ?? '';
|
|
102
|
+
return {
|
|
103
|
+
title: clean(tag('title'), 300),
|
|
104
|
+
url: clean(tag('id'), 300),
|
|
105
|
+
snippet: clean(tag('summary')),
|
|
106
|
+
source: 'arxiv',
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}, 3000);
|
|
110
|
+
add('hackernews', async (s, n) => ((await json(`https://hn.algolia.com/api/v1/search?hitsPerPage=${n}&query=${q(s)}`)).hits ??
|
|
111
|
+
[]).map((h) => ({
|
|
112
|
+
title: clean(h.title ?? h.story_title, 200),
|
|
113
|
+
url: String(h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`),
|
|
114
|
+
snippet: `${h.points ?? 0} points on Hacker News`,
|
|
115
|
+
source: 'hackernews',
|
|
116
|
+
})));
|
|
117
|
+
this.providers.forEach((p, i) => {
|
|
118
|
+
const name = this.providers.findIndex((o) => o.type === p.type) === i ? p.type : `${p.type}-${i}`;
|
|
119
|
+
if (p.type === 'searxng')
|
|
120
|
+
add(name, async (s, n) => ((await json(`${p.url.replace(/\/+$/, '')}/search?format=json&q=${q(s)}`)).results ?? [])
|
|
121
|
+
.slice(0, n)
|
|
122
|
+
.map((h) => ({
|
|
123
|
+
title: clean(h.title, 200),
|
|
124
|
+
url: String(h.url),
|
|
125
|
+
snippet: clean(h.content),
|
|
126
|
+
source: p.type,
|
|
127
|
+
})));
|
|
128
|
+
else if (p.type === 'brave')
|
|
129
|
+
add(name, async (s, n) => ((await json(`https://api.search.brave.com/res/v1/web/search?count=${n}&q=${q(s)}`, {
|
|
130
|
+
headers: { 'x-subscription-token': p.key },
|
|
131
|
+
})).web?.results ?? []).map((h) => ({
|
|
132
|
+
title: clean(h.title, 200),
|
|
133
|
+
url: String(h.url),
|
|
134
|
+
snippet: clean(h.description),
|
|
135
|
+
source: p.type,
|
|
136
|
+
})));
|
|
137
|
+
else if (p.type === 'tavily')
|
|
138
|
+
add(name, async (s, n) => ((await json('https://api.tavily.com/search', {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: { authorization: 'Bearer ' + p.key },
|
|
141
|
+
body: JSON.stringify({ query: s, max_results: n }),
|
|
142
|
+
})).results ?? []).map((h) => ({
|
|
143
|
+
title: clean(h.title, 200),
|
|
144
|
+
url: String(h.url),
|
|
145
|
+
snippet: clean(h.content),
|
|
146
|
+
source: p.type,
|
|
147
|
+
})));
|
|
148
|
+
else
|
|
149
|
+
add(name, async (s, n) => ((await json('https://api.exa.ai/search', {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'x-api-key': p.key },
|
|
152
|
+
body: JSON.stringify({ query: s, numResults: n }),
|
|
153
|
+
})).results ?? []).map((h) => ({
|
|
154
|
+
title: clean(h.title, 200),
|
|
155
|
+
url: String(h.url),
|
|
156
|
+
snippet: clean(h.text ?? h.summary),
|
|
157
|
+
source: p.type,
|
|
158
|
+
})));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/** What is enabled, for health and manifests: provider types only, never keys or URLs' secrets. */
|
|
162
|
+
describe() {
|
|
163
|
+
return { providers: this.providers.map((p) => p.type), keyless: [...KEYLESS] };
|
|
164
|
+
}
|
|
165
|
+
order(kind) {
|
|
166
|
+
if (kind !== 'general')
|
|
167
|
+
return ROUTES[kind];
|
|
168
|
+
const providerNames = [...this.backends.keys()].filter((n) => !KEYLESS.includes(n));
|
|
169
|
+
return [...providerNames, ...GENERAL_KEYLESS];
|
|
170
|
+
}
|
|
171
|
+
async search(input) {
|
|
172
|
+
const query = String(input.query ?? '')
|
|
173
|
+
.trim()
|
|
174
|
+
.slice(0, 300);
|
|
175
|
+
if (!query)
|
|
176
|
+
throw new Error('An empty query cannot be searched');
|
|
177
|
+
const kind = input.kind ?? 'general';
|
|
178
|
+
if (!SEARCH_KINDS.includes(kind))
|
|
179
|
+
throw new Error('Unknown search kind');
|
|
180
|
+
const limit = Math.max(1, Math.min(10, Math.floor(input.limit ?? 5)));
|
|
181
|
+
const key = `${kind}\u0000${query.toLowerCase()}\u0000${limit}`;
|
|
182
|
+
const hit = this.cache.get(key);
|
|
183
|
+
if (hit && this.now() - hit.at < CACHE_TTL_MS)
|
|
184
|
+
return { ...hit.value, cached: true };
|
|
185
|
+
const results = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
const tried = [];
|
|
188
|
+
for (const name of this.order(kind)) {
|
|
189
|
+
if (results.length >= limit)
|
|
190
|
+
break;
|
|
191
|
+
const b = this.backends.get(name);
|
|
192
|
+
const last = this.last.get(name);
|
|
193
|
+
if (last !== undefined && this.now() - last < b.intervalMs) {
|
|
194
|
+
tried.push({ backend: name, ok: false, ms: 0, error: 'rate-limited' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
this.last.set(name, this.now());
|
|
198
|
+
const started = Date.now();
|
|
199
|
+
try {
|
|
200
|
+
const got = await b.run(query, limit - results.length);
|
|
201
|
+
let count = 0;
|
|
202
|
+
for (const r of got) {
|
|
203
|
+
if (!/^https?:\/\//.test(r.url) || seen.has(r.url))
|
|
204
|
+
continue;
|
|
205
|
+
seen.add(r.url);
|
|
206
|
+
results.push(r);
|
|
207
|
+
count++;
|
|
208
|
+
if (results.length >= limit)
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
tried.push({ backend: name, ok: true, count, ms: Date.now() - started });
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
tried.push({
|
|
215
|
+
backend: name,
|
|
216
|
+
ok: false,
|
|
217
|
+
ms: Date.now() - started,
|
|
218
|
+
error: String(error.message ?? error).slice(0, 200),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const value = { query, kind, results, tried };
|
|
223
|
+
// Cache only a complete answer: one assembled around a skipped or failing backend would
|
|
224
|
+
// keep that gap for the cache's lifetime.
|
|
225
|
+
if (results.length && tried.every((t) => t.ok)) {
|
|
226
|
+
if (this.cache.size >= CACHE_MAX)
|
|
227
|
+
this.cache.delete(this.cache.keys().next().value);
|
|
228
|
+
this.cache.set(key, { at: this.now(), value });
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Reads the operator's web search config: providers in order, keys from env or a private file. */
|
|
234
|
+
export function loadWebSearchConfig(path, env = process.env) {
|
|
235
|
+
let raw;
|
|
236
|
+
try {
|
|
237
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
throw new Error(`Web search config ${path} is not readable JSON`);
|
|
241
|
+
}
|
|
242
|
+
const top = raw;
|
|
243
|
+
if (!top || typeof top !== 'object' || Array.isArray(top))
|
|
244
|
+
throw new Error('Web search config must be an object');
|
|
245
|
+
for (const k of Object.keys(top))
|
|
246
|
+
if (!['providers', 'github_token_env'].includes(k))
|
|
247
|
+
throw new Error(`Web search config: unknown field ${k}`);
|
|
248
|
+
const secret = (p, label) => {
|
|
249
|
+
const hasEnv = typeof p.key_env === 'string' && p.key_env;
|
|
250
|
+
const hasFile = typeof p.key_file === 'string' && p.key_file;
|
|
251
|
+
if ('key' in p || (!hasEnv && !hasFile) || (hasEnv && hasFile))
|
|
252
|
+
throw new Error(`${label} needs exactly one of key_env or key_file (never the key itself)`);
|
|
253
|
+
if (hasEnv) {
|
|
254
|
+
const v = env[p.key_env];
|
|
255
|
+
if (!v)
|
|
256
|
+
throw new Error(`${label}: ${p.key_env} is not set`);
|
|
257
|
+
return v.trim();
|
|
258
|
+
}
|
|
259
|
+
const file = p.key_file;
|
|
260
|
+
const st = statSync(file);
|
|
261
|
+
if (st.mode & 0o077)
|
|
262
|
+
throw new Error(`${label}: ${file} is readable by others; chmod 600 it`);
|
|
263
|
+
return readFileSync(file, 'utf8').trim();
|
|
264
|
+
};
|
|
265
|
+
const providers = [];
|
|
266
|
+
const list = top.providers ?? [];
|
|
267
|
+
if (!Array.isArray(list) || list.length > 8)
|
|
268
|
+
throw new Error('providers must be a list of up to 8');
|
|
269
|
+
list.forEach((entry, i) => {
|
|
270
|
+
const p = entry;
|
|
271
|
+
const label = `providers[${i}]`;
|
|
272
|
+
const type = p?.type;
|
|
273
|
+
if (type === 'searxng') {
|
|
274
|
+
for (const k of Object.keys(p))
|
|
275
|
+
if (!['type', 'url'].includes(k))
|
|
276
|
+
throw new Error(`${label}: unknown field ${k}`);
|
|
277
|
+
let u;
|
|
278
|
+
try {
|
|
279
|
+
u = new URL(String(p.url));
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
throw new Error(`${label}: url must be http or https`);
|
|
283
|
+
}
|
|
284
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:')
|
|
285
|
+
throw new Error(`${label}: url must be http or https`);
|
|
286
|
+
providers.push({ type, url: u.toString() });
|
|
287
|
+
}
|
|
288
|
+
else if (type === 'brave' || type === 'tavily' || type === 'exa') {
|
|
289
|
+
for (const k of Object.keys(p))
|
|
290
|
+
if (!['type', 'key_env', 'key_file', 'key'].includes(k))
|
|
291
|
+
throw new Error(`${label}: unknown field ${k}`);
|
|
292
|
+
providers.push({ type, key: secret(p, label) });
|
|
293
|
+
}
|
|
294
|
+
else
|
|
295
|
+
throw new Error(`${label}: unknown provider (searxng, brave, tavily or exa)`);
|
|
296
|
+
});
|
|
297
|
+
const ghEnv = top.github_token_env;
|
|
298
|
+
if (ghEnv !== undefined && typeof ghEnv !== 'string')
|
|
299
|
+
throw new Error('github_token_env must be a name');
|
|
300
|
+
const githubToken = ghEnv ? env[ghEnv] : undefined;
|
|
301
|
+
return { providers, ...(githubToken ? { githubToken } : {}) };
|
|
302
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@superjackfruitlabs/salaam",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@superjackfruitlabs/salaam",
|
|
9
|
-
"version": "0.0.
|
|
9
|
+
"version": "0.0.5",
|
|
10
10
|
"bundleDependencies": [
|
|
11
11
|
"@earendil-works/pi-ai",
|
|
12
12
|
"@earendil-works/pi-coding-agent",
|
package/package.json
CHANGED
package/src/acp-desk.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { isAbsolute, join } from 'node:path';
|
|
2
|
+
import type { PermissionMode, PermissionPolicy, SessionAccess } from './autonomy.js';
|
|
3
|
+
import type { LocalEdit } from './local-edits.js';
|
|
4
|
+
import type { TurnCheckpoint } from './turn-rewind.js';
|
|
5
|
+
import { relativeTime } from './session-pick.js';
|
|
6
|
+
|
|
7
|
+
/** What an editor shows for a desk session: the same choices as Shift+Tab, plus full access.
|
|
8
|
+
* YOLO is listed only while it is on, because turning it on needs "yolo" typed in the terminal. */
|
|
9
|
+
export type DeskMode = 'review' | 'accept-edits' | 'auto' | 'full-access' | 'yolo';
|
|
10
|
+
const MODES: { id: DeskMode; name: string; description: string }[] = [
|
|
11
|
+
{ id: 'review', name: 'Review', description: 'Asks before every edit and command' },
|
|
12
|
+
{
|
|
13
|
+
id: 'accept-edits',
|
|
14
|
+
name: 'Accept edits',
|
|
15
|
+
description: 'Edits in the project apply at once; commands ask',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'auto',
|
|
19
|
+
name: 'Auto',
|
|
20
|
+
description: 'Project edits and sandboxed commands run at once; the rest asks',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'full-access',
|
|
24
|
+
name: 'Full access',
|
|
25
|
+
description: 'No OS sandbox; only dangerous commands ask',
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
const YOLO = {
|
|
29
|
+
id: 'yolo' as const,
|
|
30
|
+
name: 'YOLO',
|
|
31
|
+
description: 'No approvals at all and no sandbox. Turn it off in the terminal with /yolo',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface DeskPermissions {
|
|
35
|
+
policy: PermissionPolicy;
|
|
36
|
+
full_access_allowed?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export function deskMode(policy: PermissionPolicy): DeskMode | PermissionMode {
|
|
39
|
+
if (policy.access === 'yolo') return 'yolo';
|
|
40
|
+
if (policy.access === 'full') return 'full-access';
|
|
41
|
+
return policy.mode;
|
|
42
|
+
}
|
|
43
|
+
/** `modes` and the matching `configOptions`, kept in step (ACP asks agents to send both). */
|
|
44
|
+
export function modeState(p: DeskPermissions) {
|
|
45
|
+
const current = deskMode(p.policy);
|
|
46
|
+
const list = MODES.filter((m) => m.id !== 'full-access' || p.full_access_allowed !== false);
|
|
47
|
+
if (current === 'yolo') list.push(YOLO);
|
|
48
|
+
else if (!list.some((m) => m.id === current))
|
|
49
|
+
list.unshift({ id: current as DeskMode, name: current, description: 'Set in the terminal' });
|
|
50
|
+
return {
|
|
51
|
+
modes: { currentModeId: current, availableModes: list },
|
|
52
|
+
configOptions: [
|
|
53
|
+
{
|
|
54
|
+
id: 'mode',
|
|
55
|
+
name: 'Mode',
|
|
56
|
+
category: 'mode',
|
|
57
|
+
type: 'select',
|
|
58
|
+
currentValue: current,
|
|
59
|
+
options: list.map((m) => ({ value: m.id, name: m.name, description: m.description })),
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** The policy a chosen mode asks for. Picking a mode turns full access off; full access runs in
|
|
65
|
+
* auto, so edits and commands go ahead and only dangerous commands ask. */
|
|
66
|
+
export function policyFor(
|
|
67
|
+
mode: string,
|
|
68
|
+
allowed: { full?: boolean } = {},
|
|
69
|
+
): { mode: PermissionMode; paths: string[]; access?: SessionAccess } {
|
|
70
|
+
if (mode === 'review' || mode === 'accept-edits' || mode === 'auto') return { mode, paths: [] };
|
|
71
|
+
if (mode === 'full-access') {
|
|
72
|
+
if (allowed.full === false)
|
|
73
|
+
throw new Error(
|
|
74
|
+
'The operator does not allow full access on this host. Commands stay in the OS sandbox.',
|
|
75
|
+
);
|
|
76
|
+
return { mode: 'auto', paths: [], access: 'full' };
|
|
77
|
+
}
|
|
78
|
+
if (mode === 'yolo')
|
|
79
|
+
throw new Error('YOLO is turned on only in the terminal: run /yolo there and type yolo.');
|
|
80
|
+
throw new Error('Unknown mode ' + JSON.stringify(mode));
|
|
81
|
+
}
|
|
82
|
+
export const FULL_ACCESS_WARNING =
|
|
83
|
+
'Later commands in this session run WITHOUT the OS sandbox: they can read and change any file your user can, and reach the network directly. They run without asking, except commands the danger check calls dangerous (force pushes, deletes, publishing, credentials, anything it cannot read), which still ask and say why. Pick another mode to turn it off.';
|
|
84
|
+
|
|
85
|
+
/** ACP diff content for an edit request: absolute paths, null old text for a new file. */
|
|
86
|
+
export function editDiffs(root: string, edits: readonly LocalEdit[]) {
|
|
87
|
+
return edits.map((e) => ({
|
|
88
|
+
type: 'diff' as const,
|
|
89
|
+
path: isAbsolute(e.path) ? e.path : join(root, e.path),
|
|
90
|
+
oldText: e.before,
|
|
91
|
+
newText: e.after,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Why an approval asks, from the reviewed text and the mode, in one line. */
|
|
96
|
+
export function askReason(value: string, mode: PermissionMode | undefined): string {
|
|
97
|
+
const danger = /^Dangerous \(([^)]+)\): (.*)$/m.exec(value);
|
|
98
|
+
if (danger) return `Asks because this command is dangerous (${danger[1]}): ${danger[2]}`;
|
|
99
|
+
const outside = /^Outside the sandbox: (.*?)\. This one command/m.exec(value);
|
|
100
|
+
if (outside) return `Asks because it wants to run outside the sandbox: ${outside[1]}`;
|
|
101
|
+
if (/^Publishes: /m.test(value))
|
|
102
|
+
return 'Asks because pushing and opening pull requests always ask.';
|
|
103
|
+
if (/^May listen: /m.test(value))
|
|
104
|
+
return 'Asks because this command may accept network connections; macOS cannot limit it to localhost.';
|
|
105
|
+
const edit = /^(---|\+\+\+) /m.test(value) && !/\nCommand:\n/.test(value);
|
|
106
|
+
if (mode === 'review') return 'Asks because review mode asks before every edit and command.';
|
|
107
|
+
if (mode === 'accept-edits' && !edit)
|
|
108
|
+
return 'Asks because accept edits still asks before commands.';
|
|
109
|
+
if (mode === 'auto')
|
|
110
|
+
return edit
|
|
111
|
+
? 'Asks because this edit is outside what auto mode approves on its own.'
|
|
112
|
+
: 'Asks because auto mode approves only sandboxed commands it can read.';
|
|
113
|
+
return 'Asks because nothing approves this request on its own.';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const DESK_COMMANDS = [
|
|
117
|
+
{
|
|
118
|
+
name: 'rewind',
|
|
119
|
+
description: 'Put the files back as they were before an earlier turn',
|
|
120
|
+
input: { hint: 'turn number (leave empty to list turns)' },
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
/** `/rewind`, `/rewind 3`. Anything else is not a desk command and goes to the model. */
|
|
124
|
+
export function parseDeskCommand(text: string): { command: 'rewind'; turn?: number } | null {
|
|
125
|
+
const match = /^\/rewind(?:\s+(\S+))?\s*$/.exec(text.trim());
|
|
126
|
+
if (!match) return null;
|
|
127
|
+
if (match[1] === undefined) return { command: 'rewind' };
|
|
128
|
+
const turn = Number(match[1]);
|
|
129
|
+
if (!Number.isSafeInteger(turn) || turn < 1)
|
|
130
|
+
throw new Error('Use /rewind with a turn number from the list, e.g. /rewind 3.');
|
|
131
|
+
return { command: 'rewind', turn };
|
|
132
|
+
}
|
|
133
|
+
export function checkpointList(points: readonly TurnCheckpoint[], now = Date.now()): string {
|
|
134
|
+
if (!points.length) return 'No turns to go back to yet.';
|
|
135
|
+
return [
|
|
136
|
+
'Turns you can go back to (the files as they were before each turn ran):',
|
|
137
|
+
'',
|
|
138
|
+
...points
|
|
139
|
+
.slice()
|
|
140
|
+
.reverse()
|
|
141
|
+
.map(
|
|
142
|
+
(p) =>
|
|
143
|
+
`- Turn ${p.turn} · ${relativeTime(p.at, now)} · ${p.text.split('\n')[0]!.slice(0, 80)}`,
|
|
144
|
+
),
|
|
145
|
+
'',
|
|
146
|
+
'Run /rewind followed by a turn number to review and restore its files.',
|
|
147
|
+
].join('\n');
|
|
148
|
+
}
|
|
149
|
+
/** Zed cannot follow a rewind into a new session, so from the editor only files are restored. */
|
|
150
|
+
export const REWIND_CHOICES = [
|
|
151
|
+
{ optionId: 'files', name: 'Restore files', kind: 'allow_once' },
|
|
152
|
+
{ optionId: 'cancel', name: 'Cancel', kind: 'reject_once' },
|
|
153
|
+
];
|
|
154
|
+
export const CONVERSATION_REWIND_NOTE =
|
|
155
|
+
'To go back in the conversation too, run /rewind in the terminal (salaam -c opens this session): it continues in a new session.';
|