@securecoms/agent-runtime 0.6.0 → 0.9.0
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/README.md +120 -30
- package/dist/bin.js +1781 -452
- package/package.json +2 -2
- package/skills/remember/SKILL.md +0 -13
package/dist/bin.js
CHANGED
|
@@ -3,15 +3,373 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { readFileSync as readFileSync9 } from "node:fs";
|
|
5
5
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
6
|
-
import { dirname as
|
|
6
|
+
import { dirname as dirname6, join as join8 } from "node:path";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import { accessSync, constants, existsSync as
|
|
8
|
+
import { accessSync, constants, existsSync as existsSync6, mkdirSync as mkdirSync3, realpathSync, rmSync, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
9
9
|
import { homedir as homedir4, userInfo } from "node:os";
|
|
10
10
|
|
|
11
11
|
// src/config.ts
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { dirname as dirname2, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
// src/service-log.ts
|
|
17
|
+
var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
18
|
+
var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
|
|
19
|
+
function errorClass(error) {
|
|
20
|
+
if (error === null) return "NullThrown";
|
|
21
|
+
if (typeof error !== "object" && typeof error !== "function") {
|
|
22
|
+
const primitive = {
|
|
23
|
+
string: "StringThrown",
|
|
24
|
+
number: "NumberThrown",
|
|
25
|
+
boolean: "BooleanThrown",
|
|
26
|
+
bigint: "BigIntThrown",
|
|
27
|
+
symbol: "SymbolThrown",
|
|
28
|
+
undefined: "UndefinedThrown"
|
|
29
|
+
};
|
|
30
|
+
return primitive[typeof error] ?? "UnknownThrown";
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const name = Object.getPrototypeOf(error)?.constructor?.name;
|
|
34
|
+
return typeof name === "string" && CLASS.test(name) ? name : "Error";
|
|
35
|
+
} catch {
|
|
36
|
+
return "Error";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function formatServiceLog(level, event, fields = {}, error) {
|
|
40
|
+
const safeEvent = TOKEN.test(event) ? event : "invalid_event";
|
|
41
|
+
const parts = [`[runtime] ${level} ${safeEvent}`];
|
|
42
|
+
for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
|
|
43
|
+
if (!TOKEN.test(key)) continue;
|
|
44
|
+
if (typeof value === "boolean") parts.push(`${key}=${value}`);
|
|
45
|
+
else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
|
|
46
|
+
}
|
|
47
|
+
if (error !== void 0) parts.push(`error_class=${errorClass(error)}`);
|
|
48
|
+
return parts.join(" ");
|
|
49
|
+
}
|
|
50
|
+
function failureEpisodeReporter(warn) {
|
|
51
|
+
let lastClass = null;
|
|
52
|
+
return {
|
|
53
|
+
failed(error) {
|
|
54
|
+
const cls = errorClass(error);
|
|
55
|
+
if (cls === lastClass) return;
|
|
56
|
+
lastClass = cls;
|
|
57
|
+
try {
|
|
58
|
+
warn(error);
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
succeeded() {
|
|
63
|
+
lastClass = null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function writeServiceLog(level, event, fields, error) {
|
|
68
|
+
const line = formatServiceLog(level, event, fields, error);
|
|
69
|
+
if (level === "error") console.error(line);
|
|
70
|
+
else if (level === "warn") console.warn(line);
|
|
71
|
+
else console.log(line);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/llm-dispatch.ts
|
|
75
|
+
var LLM_REQUEST_MAX_BYTES = 128 * 1024;
|
|
76
|
+
var LLM_RESPONSE_MAX_BYTES = 128 * 1024;
|
|
77
|
+
var LLM_REQUEST_TIMEOUT_MS = 18e4;
|
|
78
|
+
var DEFAULT_MAX_OUTPUT_TOKENS = 8192;
|
|
79
|
+
var CAP_REJECTED_MAX = 256;
|
|
80
|
+
var capRejectedModels = /* @__PURE__ */ new Set();
|
|
81
|
+
var CAP_ERROR_SCAN_BYTES = 16 * 1024;
|
|
82
|
+
var CAP_FIELD_NAMED = /max_(?:completion_|output_)?tokens/i;
|
|
83
|
+
var CONTEXT_OVERFLOW_NAMED = /prompt|context|input[\s_-]*(?:length|tokens)/i;
|
|
84
|
+
async function errorNamesOutputCap(resp) {
|
|
85
|
+
const reader = resp.body?.getReader();
|
|
86
|
+
if (!reader) return false;
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let total = 0;
|
|
89
|
+
try {
|
|
90
|
+
while (total < CAP_ERROR_SCAN_BYTES) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done) break;
|
|
93
|
+
if (!value) continue;
|
|
94
|
+
chunks.push(value);
|
|
95
|
+
total += value.byteLength;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
} finally {
|
|
100
|
+
await reader.cancel().catch(() => {
|
|
101
|
+
});
|
|
102
|
+
reader.releaseLock();
|
|
103
|
+
}
|
|
104
|
+
const text = new TextDecoder().decode(Buffer.concat(chunks).subarray(0, CAP_ERROR_SCAN_BYTES));
|
|
105
|
+
return CAP_FIELD_NAMED.test(text) && !CONTEXT_OVERFLOW_NAMED.test(text);
|
|
106
|
+
}
|
|
107
|
+
function outputCapField(endpoint) {
|
|
108
|
+
try {
|
|
109
|
+
return new URL(endpoint).hostname === "api.openai.com" ? "max_completion_tokens" : "max_tokens";
|
|
110
|
+
} catch {
|
|
111
|
+
return "max_tokens";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function normalizeEndpoint(endpoint) {
|
|
115
|
+
return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
|
|
116
|
+
}
|
|
117
|
+
var LLMDispatcher = class {
|
|
118
|
+
endpoint;
|
|
119
|
+
model;
|
|
120
|
+
apiKey;
|
|
121
|
+
chatPath;
|
|
122
|
+
chatAuth;
|
|
123
|
+
authHeaderName;
|
|
124
|
+
fetch;
|
|
125
|
+
timeoutMs;
|
|
126
|
+
requestMaxBytes;
|
|
127
|
+
responseMaxBytes;
|
|
128
|
+
maxOutputTokens;
|
|
129
|
+
log;
|
|
130
|
+
constructor(endpoint, model, opts = {}) {
|
|
131
|
+
this.endpoint = normalizeEndpoint(endpoint);
|
|
132
|
+
this.model = model;
|
|
133
|
+
this.apiKey = opts.apiKey;
|
|
134
|
+
this.chatPath = opts.chatPath ?? "/v1/chat/completions";
|
|
135
|
+
this.chatAuth = opts.chatAuth ?? "bearer";
|
|
136
|
+
this.authHeaderName = opts.authHeaderName ?? "x-api-key";
|
|
137
|
+
this.fetch = opts.fetch ?? fetch;
|
|
138
|
+
this.timeoutMs = opts.timeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
|
|
139
|
+
this.requestMaxBytes = opts.requestMaxBytes ?? LLM_REQUEST_MAX_BYTES;
|
|
140
|
+
this.responseMaxBytes = opts.responseMaxBytes ?? LLM_RESPONSE_MAX_BYTES;
|
|
141
|
+
this.maxOutputTokens = opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
142
|
+
this.log = opts.log ?? writeServiceLog;
|
|
143
|
+
}
|
|
144
|
+
async complete(messages, tools, options = {}) {
|
|
145
|
+
const body = {
|
|
146
|
+
model: this.model,
|
|
147
|
+
messages,
|
|
148
|
+
stream: false
|
|
149
|
+
};
|
|
150
|
+
const capKey = `${this.endpoint}\0${this.model}`;
|
|
151
|
+
const capField = outputCapField(this.endpoint);
|
|
152
|
+
if (tools && tools.length > 0) {
|
|
153
|
+
body.tools = tools;
|
|
154
|
+
}
|
|
155
|
+
const url = `${this.endpoint}${this.chatPath}`;
|
|
156
|
+
const headers = { "Content-Type": "application/json" };
|
|
157
|
+
if (this.apiKey && this.chatAuth === "bearer") {
|
|
158
|
+
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
159
|
+
} else if (this.apiKey && this.chatAuth === "x-api-key") {
|
|
160
|
+
headers[this.authHeaderName] = this.apiKey;
|
|
161
|
+
}
|
|
162
|
+
const encode = (withCap2) => {
|
|
163
|
+
const encoded = JSON.stringify(withCap2 ? { ...body, [capField]: this.maxOutputTokens } : body);
|
|
164
|
+
if (Buffer.byteLength(encoded, "utf8") > this.requestMaxBytes) {
|
|
165
|
+
throw new Error("LLM request exceeded the size limit");
|
|
166
|
+
}
|
|
167
|
+
return encoded;
|
|
168
|
+
};
|
|
169
|
+
let withCap = !capRejectedModels.has(capKey);
|
|
170
|
+
let encodedBody = encode(withCap);
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
let timedOut = false;
|
|
173
|
+
const cancel = () => controller.abort(options.signal?.reason);
|
|
174
|
+
if (options.signal?.aborted) cancel();
|
|
175
|
+
else options.signal?.addEventListener("abort", cancel, { once: true });
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
timedOut = true;
|
|
178
|
+
controller.abort(new Error("LLM request deadline exceeded"));
|
|
179
|
+
}, this.timeoutMs);
|
|
180
|
+
try {
|
|
181
|
+
const post = () => this.fetch(url, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers,
|
|
184
|
+
body: encodedBody,
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
redirect: "error"
|
|
187
|
+
});
|
|
188
|
+
let resp = await post();
|
|
189
|
+
if (resp.status === 400 && withCap && await errorNamesOutputCap(resp)) {
|
|
190
|
+
if (capRejectedModels.size >= CAP_REJECTED_MAX) {
|
|
191
|
+
const oldest = capRejectedModels.values().next();
|
|
192
|
+
if (!oldest.done) capRejectedModels.delete(oldest.value);
|
|
193
|
+
}
|
|
194
|
+
capRejectedModels.add(capKey);
|
|
195
|
+
this.log("warn", "llm.output_cap_rejected", {});
|
|
196
|
+
withCap = false;
|
|
197
|
+
encodedBody = encode(false);
|
|
198
|
+
resp = await post();
|
|
199
|
+
}
|
|
200
|
+
if (!resp.ok) {
|
|
201
|
+
await resp.body?.cancel();
|
|
202
|
+
throw new Error(`LLM request failed (${resp.status})`);
|
|
203
|
+
}
|
|
204
|
+
const data = await readJsonCapped(resp, this.responseMaxBytes);
|
|
205
|
+
const choice = data.choices?.[0];
|
|
206
|
+
if (!choice) throw new Error("LLM returned no choices");
|
|
207
|
+
const totalTokens = data.usage?.total_tokens;
|
|
208
|
+
return {
|
|
209
|
+
content: choice.message.content,
|
|
210
|
+
toolCalls: choice.message.tool_calls || [],
|
|
211
|
+
finishReason: choice.finish_reason,
|
|
212
|
+
...typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens >= 0 ? { usage: { totalTokens } } : {}
|
|
213
|
+
};
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (timedOut) throw new Error(`LLM request timed out after ${this.timeoutMs}ms`);
|
|
216
|
+
if (options.signal?.aborted) throw new Error("LLM request cancelled");
|
|
217
|
+
throw error;
|
|
218
|
+
} finally {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
async function readJsonCapped(resp, maxBytes) {
|
|
225
|
+
const reader = resp.body?.getReader();
|
|
226
|
+
if (!reader) throw new Error("LLM returned an empty response");
|
|
227
|
+
const chunks = [];
|
|
228
|
+
let total = 0;
|
|
229
|
+
try {
|
|
230
|
+
while (true) {
|
|
231
|
+
const { done, value } = await reader.read();
|
|
232
|
+
if (done) break;
|
|
233
|
+
if (!value) continue;
|
|
234
|
+
total += value.byteLength;
|
|
235
|
+
if (total > maxBytes) {
|
|
236
|
+
await reader.cancel().catch(() => {
|
|
237
|
+
});
|
|
238
|
+
throw new Error("LLM response exceeded the size limit");
|
|
239
|
+
}
|
|
240
|
+
chunks.push(value);
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
await reader.cancel().catch(() => {
|
|
244
|
+
});
|
|
245
|
+
throw error;
|
|
246
|
+
} finally {
|
|
247
|
+
reader.releaseLock();
|
|
248
|
+
}
|
|
249
|
+
const joined = new Uint8Array(total);
|
|
250
|
+
let offset = 0;
|
|
251
|
+
for (const chunk of chunks) {
|
|
252
|
+
joined.set(chunk, offset);
|
|
253
|
+
offset += chunk.byteLength;
|
|
254
|
+
}
|
|
255
|
+
return JSON.parse(new TextDecoder().decode(joined));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/llm-budget.ts
|
|
259
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
260
|
+
import { dirname } from "node:path";
|
|
261
|
+
var DEFAULT_DAILY_CALL_LIMIT = 2e3;
|
|
262
|
+
var DEFAULT_DAILY_TOKEN_LIMIT = 5e6;
|
|
263
|
+
var DailyLimitReachedError = class extends Error {
|
|
264
|
+
constructor(day) {
|
|
265
|
+
super("daily LLM budget reached");
|
|
266
|
+
this.day = day;
|
|
267
|
+
this.name = "DailyLimitReachedError";
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
function utcDay(d) {
|
|
271
|
+
return d.toISOString().slice(0, 10);
|
|
272
|
+
}
|
|
273
|
+
var DailyLlmBudget = class {
|
|
274
|
+
callLimit;
|
|
275
|
+
tokenLimit;
|
|
276
|
+
statePath;
|
|
277
|
+
now;
|
|
278
|
+
log;
|
|
279
|
+
state;
|
|
280
|
+
exhaustionLoggedFor = null;
|
|
281
|
+
constructor(opts) {
|
|
282
|
+
this.callLimit = opts.callLimit;
|
|
283
|
+
this.tokenLimit = opts.tokenLimit;
|
|
284
|
+
this.statePath = opts.statePath;
|
|
285
|
+
this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
286
|
+
this.log = opts.log ?? (() => {
|
|
287
|
+
});
|
|
288
|
+
this.state = this.load();
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Reserve one provider call against today's budget, or throw
|
|
292
|
+
* {@link DailyLimitReachedError} without reserving anything.
|
|
293
|
+
*/
|
|
294
|
+
beginCall() {
|
|
295
|
+
this.roll();
|
|
296
|
+
const { calls, tokens, day } = this.state;
|
|
297
|
+
const callsSpent = this.callLimit > 0 && calls >= this.callLimit;
|
|
298
|
+
const tokensSpent = this.tokenLimit > 0 && tokens >= this.tokenLimit;
|
|
299
|
+
if (callsSpent || tokensSpent) {
|
|
300
|
+
if (this.exhaustionLoggedFor !== day) {
|
|
301
|
+
this.exhaustionLoggedFor = day;
|
|
302
|
+
this.log("warn", "llm.daily_budget_exhausted", {
|
|
303
|
+
calls,
|
|
304
|
+
tokens,
|
|
305
|
+
call_limit: this.callLimit,
|
|
306
|
+
token_limit: this.tokenLimit
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
throw new DailyLimitReachedError(day);
|
|
310
|
+
}
|
|
311
|
+
this.state.calls++;
|
|
312
|
+
this.save();
|
|
313
|
+
}
|
|
314
|
+
/** Add provider-reported tokens to today's total. */
|
|
315
|
+
recordTokens(totalTokens) {
|
|
316
|
+
if (!Number.isFinite(totalTokens) || totalTokens <= 0) return;
|
|
317
|
+
this.roll();
|
|
318
|
+
this.state.tokens += Math.floor(totalTokens);
|
|
319
|
+
this.save();
|
|
320
|
+
}
|
|
321
|
+
snapshot() {
|
|
322
|
+
this.roll();
|
|
323
|
+
return { ...this.state };
|
|
324
|
+
}
|
|
325
|
+
roll() {
|
|
326
|
+
const today = utcDay(this.now());
|
|
327
|
+
if (this.state.day !== today) this.state = { day: today, calls: 0, tokens: 0 };
|
|
328
|
+
}
|
|
329
|
+
load() {
|
|
330
|
+
const fresh = { day: utcDay(this.now()), calls: 0, tokens: 0 };
|
|
331
|
+
if (!this.statePath) return fresh;
|
|
332
|
+
let raw;
|
|
333
|
+
try {
|
|
334
|
+
raw = readFileSync(this.statePath, "utf8");
|
|
335
|
+
} catch {
|
|
336
|
+
return fresh;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
const parsed = JSON.parse(raw);
|
|
340
|
+
if (parsed.day === fresh.day && Number.isInteger(parsed.calls) && parsed.calls >= 0 && Number.isInteger(parsed.tokens) && parsed.tokens >= 0) {
|
|
341
|
+
return { day: fresh.day, calls: parsed.calls, tokens: parsed.tokens };
|
|
342
|
+
}
|
|
343
|
+
return fresh;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
this.log("warn", "llm.budget_state_unreadable", {}, error);
|
|
346
|
+
return fresh;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
save() {
|
|
350
|
+
if (!this.statePath) return;
|
|
351
|
+
try {
|
|
352
|
+
mkdirSync(dirname(this.statePath), { recursive: true });
|
|
353
|
+
const tmp = `${this.statePath}.tmp`;
|
|
354
|
+
writeFileSync(tmp, JSON.stringify(this.state), { mode: 384 });
|
|
355
|
+
renameSync(tmp, this.statePath);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
this.log("warn", "llm.budget_state_write_failed", {}, error);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
function withDailyBudget(llm, budget) {
|
|
362
|
+
return {
|
|
363
|
+
async complete(messages, tools, options) {
|
|
364
|
+
budget.beginCall();
|
|
365
|
+
const result = await llm.complete(messages, tools, options);
|
|
366
|
+
if (result.usage) budget.recordTokens(result.usage.totalTokens);
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/config.ts
|
|
15
373
|
var MISSING_IDENTITY_MESSAGE = "Missing agent identity: pass --identity <id> or set SECURECOMS_IDENTITY";
|
|
16
374
|
var LOG_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
|
|
17
375
|
var TOOL_DEFAULTS = /* @__PURE__ */ new Set(["allow", "deny"]);
|
|
@@ -48,8 +406,16 @@ function parseList(value) {
|
|
|
48
406
|
const items = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
49
407
|
return items.length > 0 ? items : void 0;
|
|
50
408
|
}
|
|
409
|
+
function parseIntSetting(env, name, fallback, min) {
|
|
410
|
+
const raw = env[name]?.trim();
|
|
411
|
+
if (!raw) return fallback;
|
|
412
|
+
if (!/^\d+$/.test(raw) || Number(raw) < min || !Number.isSafeInteger(Number(raw))) {
|
|
413
|
+
throw new Error(`Invalid ${name} "${raw}": expected a whole number >= ${min}`);
|
|
414
|
+
}
|
|
415
|
+
return Number(raw);
|
|
416
|
+
}
|
|
51
417
|
function defaultSkillsDir() {
|
|
52
|
-
return join(
|
|
418
|
+
return join(dirname2(fileURLToPath(import.meta.url)), "..", "skills");
|
|
53
419
|
}
|
|
54
420
|
var DEFAULT_TOOL_ALLOW = [
|
|
55
421
|
"echo",
|
|
@@ -57,8 +423,7 @@ var DEFAULT_TOOL_ALLOW = [
|
|
|
57
423
|
"fetch_url",
|
|
58
424
|
"http_request",
|
|
59
425
|
"sql_query",
|
|
60
|
-
"current_time"
|
|
61
|
-
"remember"
|
|
426
|
+
"current_time"
|
|
62
427
|
];
|
|
63
428
|
function loadConfig(argv, env) {
|
|
64
429
|
const identityId = parseIdentity(argv, env);
|
|
@@ -100,7 +465,10 @@ function loadConfig(argv, env) {
|
|
|
100
465
|
toolGateEnv: {
|
|
101
466
|
...parsedAllow !== void 0 ? { allow: parsedAllow } : {},
|
|
102
467
|
...deny !== void 0 ? { deny } : {}
|
|
103
|
-
}
|
|
468
|
+
},
|
|
469
|
+
llmMaxOutputTokens: parseIntSetting(env, "SECURECOMS_LLM_MAX_OUTPUT_TOKENS", DEFAULT_MAX_OUTPUT_TOKENS, 1),
|
|
470
|
+
llmDailyCallLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_CALL_LIMIT", DEFAULT_DAILY_CALL_LIMIT, 0),
|
|
471
|
+
llmDailyTokenLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_TOKEN_LIMIT", DEFAULT_DAILY_TOKEN_LIMIT, 0)
|
|
104
472
|
};
|
|
105
473
|
}
|
|
106
474
|
|
|
@@ -155,9 +523,23 @@ function parsePersonaVerb(argv) {
|
|
|
155
523
|
const sub = argv[3];
|
|
156
524
|
return sub === "init" || sub === "show" ? sub : null;
|
|
157
525
|
}
|
|
526
|
+
function parseSideEffectsVerb(argv) {
|
|
527
|
+
if (argv[2] !== "side-effects") return null;
|
|
528
|
+
const sub = argv[3];
|
|
529
|
+
return sub === "list" || sub === "resolve" ? sub : null;
|
|
530
|
+
}
|
|
531
|
+
function parseSideEffectResolveFlags(argv) {
|
|
532
|
+
const outcome = flagOrEnv(argv, "--outcome", {});
|
|
533
|
+
return {
|
|
534
|
+
idempotencyKey: flagOrEnv(argv, "--idempotency-key", {}),
|
|
535
|
+
operationSha256: flagOrEnv(argv, "--operation-sha256", {}),
|
|
536
|
+
confirmationId: flagOrEnv(argv, "--confirmation-id", {}),
|
|
537
|
+
outcome: outcome === "completed" || outcome === "not_applied" ? outcome : void 0
|
|
538
|
+
};
|
|
539
|
+
}
|
|
158
540
|
|
|
159
541
|
// src/service/install.ts
|
|
160
|
-
import { dirname as
|
|
542
|
+
import { dirname as dirname3 } from "node:path";
|
|
161
543
|
|
|
162
544
|
// src/service/config.ts
|
|
163
545
|
import { isAbsolute, resolve } from "node:path";
|
|
@@ -389,8 +771,8 @@ function installService(input, deps) {
|
|
|
389
771
|
const plan = prepareInstall(input, deps.platform, deps);
|
|
390
772
|
const keyFile = plan.keyFile;
|
|
391
773
|
if (deps.platform === "darwin") {
|
|
392
|
-
deps.mkdirp(
|
|
393
|
-
deps.mkdirp(
|
|
774
|
+
deps.mkdirp(dirname3(cfg.plistPath));
|
|
775
|
+
deps.mkdirp(dirname3(cfg.logPath));
|
|
394
776
|
deps.writeFile(cfg.plistPath, plan.contents);
|
|
395
777
|
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
396
778
|
requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
|
|
@@ -398,7 +780,7 @@ function installService(input, deps) {
|
|
|
398
780
|
return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, ...keyFile ? { keyFile } : {} };
|
|
399
781
|
}
|
|
400
782
|
if (deps.platform === "linux") {
|
|
401
|
-
deps.mkdirp(
|
|
783
|
+
deps.mkdirp(dirname3(cfg.unitPath));
|
|
402
784
|
deps.writeFile(cfg.unitPath, plan.contents);
|
|
403
785
|
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
404
786
|
deps.run("loginctl", ["enable-linger", deps.user]);
|
|
@@ -432,63 +814,6 @@ function uninstallService(input, deps) {
|
|
|
432
814
|
throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
|
|
433
815
|
}
|
|
434
816
|
|
|
435
|
-
// src/llm-dispatch.ts
|
|
436
|
-
function normalizeEndpoint(endpoint) {
|
|
437
|
-
return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
|
|
438
|
-
}
|
|
439
|
-
var LLMDispatcher = class {
|
|
440
|
-
endpoint;
|
|
441
|
-
model;
|
|
442
|
-
apiKey;
|
|
443
|
-
chatPath;
|
|
444
|
-
chatAuth;
|
|
445
|
-
authHeaderName;
|
|
446
|
-
constructor(endpoint, model, opts = {}) {
|
|
447
|
-
this.endpoint = normalizeEndpoint(endpoint);
|
|
448
|
-
this.model = model;
|
|
449
|
-
this.apiKey = opts.apiKey;
|
|
450
|
-
this.chatPath = opts.chatPath ?? "/v1/chat/completions";
|
|
451
|
-
this.chatAuth = opts.chatAuth ?? "bearer";
|
|
452
|
-
this.authHeaderName = opts.authHeaderName ?? "x-api-key";
|
|
453
|
-
}
|
|
454
|
-
async complete(messages, tools) {
|
|
455
|
-
const body = {
|
|
456
|
-
model: this.model,
|
|
457
|
-
messages,
|
|
458
|
-
stream: false
|
|
459
|
-
};
|
|
460
|
-
if (tools && tools.length > 0) {
|
|
461
|
-
body.tools = tools;
|
|
462
|
-
}
|
|
463
|
-
const url = `${this.endpoint}${this.chatPath}`;
|
|
464
|
-
const headers = { "Content-Type": "application/json" };
|
|
465
|
-
if (this.apiKey && this.chatAuth === "bearer") {
|
|
466
|
-
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
467
|
-
} else if (this.apiKey && this.chatAuth === "x-api-key") {
|
|
468
|
-
headers[this.authHeaderName] = this.apiKey;
|
|
469
|
-
}
|
|
470
|
-
const resp = await fetch(url, {
|
|
471
|
-
method: "POST",
|
|
472
|
-
headers,
|
|
473
|
-
body: JSON.stringify(body)
|
|
474
|
-
});
|
|
475
|
-
if (!resp.ok) {
|
|
476
|
-
const text = await resp.text();
|
|
477
|
-
throw new Error(`LLM request failed (${resp.status}): ${text}`);
|
|
478
|
-
}
|
|
479
|
-
const data = await resp.json();
|
|
480
|
-
const choice = data.choices?.[0];
|
|
481
|
-
if (!choice) {
|
|
482
|
-
throw new Error("LLM returned no choices");
|
|
483
|
-
}
|
|
484
|
-
return {
|
|
485
|
-
content: choice.message.content,
|
|
486
|
-
toolCalls: choice.message.tool_calls || [],
|
|
487
|
-
finishReason: choice.finish_reason
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
};
|
|
491
|
-
|
|
492
817
|
// src/llm-source.ts
|
|
493
818
|
import { join as join2 } from "node:path";
|
|
494
819
|
|
|
@@ -662,146 +987,75 @@ function resolveLlmSource(cfg, explicit, deps) {
|
|
|
662
987
|
}
|
|
663
988
|
|
|
664
989
|
// src/llm-shim.ts
|
|
665
|
-
function endpointHost(endpoint) {
|
|
666
|
-
try {
|
|
667
|
-
return new URL(endpoint).host;
|
|
668
|
-
} catch {
|
|
669
|
-
return "(unparseable endpoint)";
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
990
|
function buildLlmShim(cfg, explicit, deps) {
|
|
673
991
|
return {
|
|
674
|
-
async complete(messages, tools) {
|
|
992
|
+
async complete(messages, tools, options) {
|
|
675
993
|
const src = resolveLlmSource(cfg, explicit, deps);
|
|
676
994
|
const dispatcher = new LLMDispatcher(src.endpoint, src.model, {
|
|
677
995
|
apiKey: src.apiKey,
|
|
678
996
|
chatPath: src.chatPath,
|
|
679
|
-
chatAuth: src.chatAuth
|
|
997
|
+
chatAuth: src.chatAuth,
|
|
998
|
+
maxOutputTokens: cfg.llmMaxOutputTokens
|
|
680
999
|
});
|
|
681
|
-
return dispatcher.complete(messages, tools);
|
|
1000
|
+
return dispatcher.complete(messages, tools, options);
|
|
682
1001
|
}
|
|
683
1002
|
};
|
|
684
1003
|
}
|
|
685
|
-
function bootLine(cfg, explicit, deps) {
|
|
686
|
-
try {
|
|
687
|
-
const src = resolveLlmSource(cfg, explicit, deps);
|
|
688
|
-
return `securecoms-agent ${cfg.identityId.slice(0, 8)} \u2192 llm source=${src.origin} ${endpointHost(src.endpoint)} (${src.model})`;
|
|
689
|
-
} catch (e) {
|
|
690
|
-
return `llm: source unavailable \u2014 ${e instanceof Error ? e.message : String(e)}`;
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
1004
|
|
|
694
1005
|
// src/memory.ts
|
|
695
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
696
|
-
import { join as join3 } from "node:path";
|
|
697
1006
|
var MAX_HISTORY = 50;
|
|
1007
|
+
var MAX_FACTS = 200;
|
|
698
1008
|
var AgentMemory = class {
|
|
699
1009
|
history = [];
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1010
|
+
facts = [];
|
|
1011
|
+
constructor(snapshot) {
|
|
1012
|
+
if (snapshot) this.replace(snapshot);
|
|
1013
|
+
}
|
|
1014
|
+
/** Replace the working view at the start of every authenticated turn. */
|
|
1015
|
+
replace(snapshot) {
|
|
1016
|
+
this.history = snapshot.recent.slice(-MAX_HISTORY).map((turn) => ({
|
|
1017
|
+
role: turn.role === "peer" ? "user" : "assistant",
|
|
1018
|
+
content: turn.text
|
|
1019
|
+
}));
|
|
1020
|
+
this.facts = snapshot.facts.slice(-MAX_FACTS);
|
|
1021
|
+
}
|
|
1022
|
+
/** Add a message to this turn's in-process history only. */
|
|
708
1023
|
addMessage(msg) {
|
|
709
1024
|
this.history.push(msg);
|
|
710
1025
|
if (this.history.length > MAX_HISTORY * 2) {
|
|
711
1026
|
this.history = this.history.slice(-MAX_HISTORY);
|
|
712
1027
|
}
|
|
713
|
-
this.saveHistory();
|
|
714
1028
|
}
|
|
715
1029
|
/** Get recent conversation history for LLM context. */
|
|
716
1030
|
getHistory() {
|
|
717
1031
|
return this.history.slice(-MAX_HISTORY);
|
|
718
1032
|
}
|
|
719
|
-
/** Clear
|
|
1033
|
+
/** Clear this process-local view; durable deletion belongs to the bridge. */
|
|
720
1034
|
clear() {
|
|
721
1035
|
this.history = [];
|
|
722
|
-
this.
|
|
1036
|
+
this.facts = [];
|
|
723
1037
|
}
|
|
724
|
-
/**
|
|
725
|
-
* Recall relevant memories to prepend to the LLM context.
|
|
726
|
-
* Case-insensitive keyword-overlap match over captured entries: query is
|
|
727
|
-
* split into words of length >= 3, entries are scored by number of
|
|
728
|
-
* matching words, and the top 5 with score > 0 are returned.
|
|
729
|
-
*/
|
|
1038
|
+
/** Case-insensitive keyword-overlap recall over protected channel facts. */
|
|
730
1039
|
async recall(query) {
|
|
731
|
-
const queryWords = query.toLowerCase().split(/\W+/).filter((
|
|
1040
|
+
const queryWords = query.toLowerCase().split(/\W+/).filter((word) => word.length >= 3);
|
|
732
1041
|
if (queryWords.length === 0) return null;
|
|
733
|
-
const scored = this.
|
|
734
|
-
const contentLower =
|
|
1042
|
+
const scored = this.facts.map((content) => {
|
|
1043
|
+
const contentLower = content.toLowerCase();
|
|
735
1044
|
const score = queryWords.reduce(
|
|
736
|
-
(acc,
|
|
1045
|
+
(acc, word) => contentLower.includes(word) ? acc + 1 : acc,
|
|
737
1046
|
0
|
|
738
1047
|
);
|
|
739
|
-
return {
|
|
740
|
-
}).filter((
|
|
1048
|
+
return { content, score };
|
|
1049
|
+
}).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);
|
|
741
1050
|
if (scored.length === 0) return null;
|
|
742
|
-
const memories = scored.map((s) => `- ${s.memory.content}`).join("\n");
|
|
743
1051
|
return `Relevant context from memory:
|
|
744
|
-
${
|
|
745
|
-
}
|
|
746
|
-
/**
|
|
747
|
-
* Capture a notable fact to local memory (best-effort).
|
|
748
|
-
*/
|
|
749
|
-
async capture(content, kind) {
|
|
750
|
-
try {
|
|
751
|
-
this.memories.push({
|
|
752
|
-
content,
|
|
753
|
-
category: kind,
|
|
754
|
-
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
755
|
-
});
|
|
756
|
-
this.saveMemories();
|
|
757
|
-
} catch {
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
get historyPath() {
|
|
761
|
-
return join3(this.dataDir, "conversation-history.json");
|
|
762
|
-
}
|
|
763
|
-
get memoriesPath() {
|
|
764
|
-
return join3(this.dataDir, "memories.json");
|
|
765
|
-
}
|
|
766
|
-
loadHistory() {
|
|
767
|
-
try {
|
|
768
|
-
if (existsSync(this.historyPath)) {
|
|
769
|
-
const raw = readFileSync(this.historyPath, "utf-8");
|
|
770
|
-
this.history = JSON.parse(raw);
|
|
771
|
-
}
|
|
772
|
-
} catch {
|
|
773
|
-
this.history = [];
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
saveHistory() {
|
|
777
|
-
try {
|
|
778
|
-
mkdirSync(this.dataDir, { recursive: true });
|
|
779
|
-
writeFileSync(this.historyPath, JSON.stringify(this.history));
|
|
780
|
-
} catch {
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
loadMemories() {
|
|
784
|
-
try {
|
|
785
|
-
if (existsSync(this.memoriesPath)) {
|
|
786
|
-
const raw = readFileSync(this.memoriesPath, "utf-8");
|
|
787
|
-
this.memories = JSON.parse(raw);
|
|
788
|
-
}
|
|
789
|
-
} catch {
|
|
790
|
-
this.memories = [];
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
saveMemories() {
|
|
794
|
-
try {
|
|
795
|
-
mkdirSync(this.dataDir, { recursive: true });
|
|
796
|
-
writeFileSync(this.memoriesPath, JSON.stringify(this.memories));
|
|
797
|
-
} catch {
|
|
798
|
-
}
|
|
1052
|
+
${scored.map((entry) => `- ${entry.content}`).join("\n")}`;
|
|
799
1053
|
}
|
|
800
1054
|
};
|
|
801
1055
|
|
|
802
1056
|
// src/skill-invoker.ts
|
|
803
|
-
import { readFileSync as
|
|
804
|
-
import { join as
|
|
1057
|
+
import { readFileSync as readFileSync5, readdirSync, existsSync as existsSync3, statSync } from "node:fs";
|
|
1058
|
+
import { join as join4 } from "node:path";
|
|
805
1059
|
|
|
806
1060
|
// src/skill-md.ts
|
|
807
1061
|
var SkillMdError = class extends Error {
|
|
@@ -924,16 +1178,35 @@ ${guidance}` : description,
|
|
|
924
1178
|
}
|
|
925
1179
|
|
|
926
1180
|
// src/requires.ts
|
|
927
|
-
function hasReadableKeyFile(paths, read) {
|
|
1181
|
+
function hasReadableKeyFile(paths, read, onReadError) {
|
|
928
1182
|
return paths.some((path) => {
|
|
929
1183
|
if (!path) return false;
|
|
930
1184
|
try {
|
|
931
1185
|
return read(path).trim() !== "";
|
|
932
|
-
} catch {
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
try {
|
|
1188
|
+
onReadError?.(path, error);
|
|
1189
|
+
} catch {
|
|
1190
|
+
}
|
|
933
1191
|
return false;
|
|
934
1192
|
}
|
|
935
1193
|
});
|
|
936
1194
|
}
|
|
1195
|
+
function searchKeyGateReporter(log, isBravePath) {
|
|
1196
|
+
const logged = /* @__PURE__ */ new Set();
|
|
1197
|
+
return (path, error) => {
|
|
1198
|
+
if (error?.code === "ENOENT") return;
|
|
1199
|
+
const signature = `${path}|${errorClass(error)}`;
|
|
1200
|
+
if (logged.has(signature)) return;
|
|
1201
|
+
logged.add(signature);
|
|
1202
|
+
log(
|
|
1203
|
+
"warn",
|
|
1204
|
+
"runtime.search_key_unreadable",
|
|
1205
|
+
{ at_gate: true, provider_brave: isBravePath(path) },
|
|
1206
|
+
error
|
|
1207
|
+
);
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
937
1210
|
function requiresEnvFrom(cfg, hasSearchKey) {
|
|
938
1211
|
return {
|
|
939
1212
|
hasSearchKey,
|
|
@@ -956,23 +1229,23 @@ function resolveRequires(req, env) {
|
|
|
956
1229
|
}
|
|
957
1230
|
|
|
958
1231
|
// src/skills-config.ts
|
|
959
|
-
import { existsSync as
|
|
1232
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
960
1233
|
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
|
|
961
1234
|
|
|
962
1235
|
// src/config-dirs.ts
|
|
963
|
-
import { existsSync
|
|
1236
|
+
import { existsSync } from "node:fs";
|
|
964
1237
|
import { homedir as homedir2 } from "node:os";
|
|
965
|
-
import { join as
|
|
1238
|
+
import { join as join3 } from "node:path";
|
|
966
1239
|
function managedConfigDir(identityId, home = homedir2()) {
|
|
967
|
-
return
|
|
1240
|
+
return join3(home, ".securecoms", "runtime-config", identityId);
|
|
968
1241
|
}
|
|
969
1242
|
function configSearchPath(identityId, dataDir, home = homedir2()) {
|
|
970
1243
|
return [managedConfigDir(identityId, home), dataDir];
|
|
971
1244
|
}
|
|
972
1245
|
function findConfigFile(name, dirs) {
|
|
973
1246
|
for (const dir of dirs) {
|
|
974
|
-
const path =
|
|
975
|
-
if (
|
|
1247
|
+
const path = join3(dir, name);
|
|
1248
|
+
if (existsSync(path)) return path;
|
|
976
1249
|
}
|
|
977
1250
|
return null;
|
|
978
1251
|
}
|
|
@@ -982,16 +1255,16 @@ var EMPTY_SKILLS_CONFIG = { endpoints: {}, databases: {}, flags: [] };
|
|
|
982
1255
|
var empty = () => ({ endpoints: {}, databases: {}, flags: [] });
|
|
983
1256
|
var abs2 = (p, dataDir) => isAbsolute2(p) ? p : resolve2(dataDir, p);
|
|
984
1257
|
function loadSkillsConfigFile(path, dataDir) {
|
|
985
|
-
if (!
|
|
1258
|
+
if (!existsSync2(path)) return empty();
|
|
986
1259
|
let raw;
|
|
987
1260
|
try {
|
|
988
1261
|
raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
989
1262
|
} catch (e) {
|
|
990
|
-
|
|
1263
|
+
writeServiceLog("warn", "skills_config.parse_failed", {}, e);
|
|
991
1264
|
return empty();
|
|
992
1265
|
}
|
|
993
1266
|
if (typeof raw !== "object" || raw === null) {
|
|
994
|
-
|
|
1267
|
+
writeServiceLog("warn", "skills_config.root_invalid");
|
|
995
1268
|
return empty();
|
|
996
1269
|
}
|
|
997
1270
|
const src = raw;
|
|
@@ -999,7 +1272,7 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
999
1272
|
for (const [name, v] of Object.entries(src.endpoints ?? {})) {
|
|
1000
1273
|
const e = v;
|
|
1001
1274
|
if (typeof e?.baseUrl !== "string" || e.baseUrl === "") {
|
|
1002
|
-
|
|
1275
|
+
writeServiceLog("warn", "skills_config.endpoint_invalid");
|
|
1003
1276
|
continue;
|
|
1004
1277
|
}
|
|
1005
1278
|
endpoints[name] = {
|
|
@@ -1013,7 +1286,7 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
1013
1286
|
if (typeof d?.sqlite === "string" && d.sqlite !== "") databases[name] = { sqlite: abs2(d.sqlite, dataDir) };
|
|
1014
1287
|
else if (typeof d?.postgresUrlFile === "string" && d.postgresUrlFile !== "") {
|
|
1015
1288
|
databases[name] = { postgresUrlFile: abs2(d.postgresUrlFile, dataDir) };
|
|
1016
|
-
} else
|
|
1289
|
+
} else writeServiceLog("warn", "skills_config.database_invalid");
|
|
1017
1290
|
}
|
|
1018
1291
|
const flags = Array.isArray(src.flags) ? src.flags.filter((f) => typeof f === "string") : [];
|
|
1019
1292
|
const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : void 0;
|
|
@@ -1026,9 +1299,9 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
1026
1299
|
const s = src.search;
|
|
1027
1300
|
if (s && typeof s === "object") {
|
|
1028
1301
|
if (s.provider !== "brave" && s.provider !== "tavily") {
|
|
1029
|
-
|
|
1302
|
+
writeServiceLog("warn", "skills_config.search_provider_invalid");
|
|
1030
1303
|
} else if (typeof s.keyFile !== "string" || s.keyFile === "") {
|
|
1031
|
-
|
|
1304
|
+
writeServiceLog("warn", "skills_config.search_key_file_missing");
|
|
1032
1305
|
} else {
|
|
1033
1306
|
search = { provider: s.provider, keyFile: abs2(s.keyFile, dataDir) };
|
|
1034
1307
|
}
|
|
@@ -1060,12 +1333,30 @@ function htmlToText(html) {
|
|
|
1060
1333
|
}
|
|
1061
1334
|
|
|
1062
1335
|
// src/net-guard.ts
|
|
1063
|
-
import {
|
|
1336
|
+
import { Resolver as DnsResolver } from "node:dns/promises";
|
|
1064
1337
|
import { isIPv4, isIPv6 } from "node:net";
|
|
1065
1338
|
var BlockedAddressError = class extends Error {
|
|
1066
1339
|
};
|
|
1067
1340
|
function systemResolver() {
|
|
1068
|
-
return async (host
|
|
1341
|
+
return async (host, signal) => {
|
|
1342
|
+
if (isIPv4(host) || isIPv6(host)) return [host];
|
|
1343
|
+
if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
|
|
1344
|
+
const resolver = new DnsResolver();
|
|
1345
|
+
const cancel = () => resolver.cancel();
|
|
1346
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
1347
|
+
try {
|
|
1348
|
+
const results = await Promise.allSettled([resolver.resolve4(host), resolver.resolve6(host)]);
|
|
1349
|
+
if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
|
|
1350
|
+
const addresses = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
1351
|
+
if (addresses.length === 0) {
|
|
1352
|
+
const rejected = results.find((result) => result.status === "rejected");
|
|
1353
|
+
throw rejected?.reason ?? new Error("DNS lookup returned no addresses");
|
|
1354
|
+
}
|
|
1355
|
+
return addresses;
|
|
1356
|
+
} finally {
|
|
1357
|
+
signal?.removeEventListener("abort", cancel);
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1069
1360
|
}
|
|
1070
1361
|
function v4Parts(ip) {
|
|
1071
1362
|
if (!isIPv4(ip)) return null;
|
|
@@ -1112,7 +1403,7 @@ function isPrivateAddress(ip) {
|
|
|
1112
1403
|
}
|
|
1113
1404
|
return (v6[0] & 65024) === 64512 || (v6[0] & 65472) === 65152;
|
|
1114
1405
|
}
|
|
1115
|
-
async function assertPublicHost(url, resolve3) {
|
|
1406
|
+
async function assertPublicHost(url, resolve3, signal) {
|
|
1116
1407
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1117
1408
|
throw new BlockedAddressError(`refused ${url.protocol}//: only http and https are allowed`);
|
|
1118
1409
|
}
|
|
@@ -1122,7 +1413,9 @@ async function assertPublicHost(url, resolve3) {
|
|
|
1122
1413
|
}
|
|
1123
1414
|
let addresses;
|
|
1124
1415
|
try {
|
|
1125
|
-
|
|
1416
|
+
if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
|
|
1417
|
+
addresses = await resolve3(host, signal);
|
|
1418
|
+
if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
|
|
1126
1419
|
} catch (e) {
|
|
1127
1420
|
throw new BlockedAddressError(`refused ${url.hostname}: cannot resolve (${e instanceof Error ? e.message : String(e)})`);
|
|
1128
1421
|
}
|
|
@@ -1140,14 +1433,20 @@ async function safeFetch(url, opts) {
|
|
|
1140
1433
|
const maxRedirects = opts.maxRedirects ?? 3;
|
|
1141
1434
|
const maxBytes = opts.maxBytes ?? 2e6;
|
|
1142
1435
|
const controller = new AbortController();
|
|
1143
|
-
const
|
|
1436
|
+
const cancel = () => controller.abort(opts.signal?.reason);
|
|
1437
|
+
if (opts.signal?.aborted) cancel();
|
|
1438
|
+
else opts.signal?.addEventListener("abort", cancel, { once: true });
|
|
1439
|
+
const timer = setTimeout(
|
|
1440
|
+
() => controller.abort(new Error("network request deadline exceeded")),
|
|
1441
|
+
opts.timeoutMs ?? 1e4
|
|
1442
|
+
);
|
|
1144
1443
|
try {
|
|
1145
1444
|
let current = new URL(url);
|
|
1146
1445
|
let method = opts.method ?? "GET";
|
|
1147
1446
|
let headers = opts.headers;
|
|
1148
1447
|
let body = opts.body;
|
|
1149
1448
|
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1150
|
-
await assertPublicHost(current, opts.resolve);
|
|
1449
|
+
await assertPublicHost(current, opts.resolve, controller.signal);
|
|
1151
1450
|
const res = await opts.fetch(current.toString(), {
|
|
1152
1451
|
method,
|
|
1153
1452
|
headers,
|
|
@@ -1180,6 +1479,7 @@ async function safeFetch(url, opts) {
|
|
|
1180
1479
|
throw new BlockedAddressError(`too many redirects (>${maxRedirects})`);
|
|
1181
1480
|
} finally {
|
|
1182
1481
|
clearTimeout(timer);
|
|
1482
|
+
opts.signal?.removeEventListener("abort", cancel);
|
|
1183
1483
|
}
|
|
1184
1484
|
}
|
|
1185
1485
|
function headerMap(res) {
|
|
@@ -1229,7 +1529,8 @@ var fetchUrl = async (args, ctx) => {
|
|
|
1229
1529
|
fetch: ctx.fetch,
|
|
1230
1530
|
maxRedirects: 3,
|
|
1231
1531
|
timeoutMs: 1e4,
|
|
1232
|
-
maxBytes: 2e6
|
|
1532
|
+
maxBytes: 2e6,
|
|
1533
|
+
signal: ctx.signal
|
|
1233
1534
|
});
|
|
1234
1535
|
const type = res.headers["content-type"] ?? "";
|
|
1235
1536
|
const text = /html|xml/i.test(type) ? htmlToText(res.text) : res.text.replace(/\s+/g, " ").trim();
|
|
@@ -1300,7 +1601,8 @@ var httpRequest = async (args, ctx) => {
|
|
|
1300
1601
|
headers,
|
|
1301
1602
|
body,
|
|
1302
1603
|
timeoutMs: 1e4,
|
|
1303
|
-
maxBytes: 2e6
|
|
1604
|
+
maxBytes: 2e6,
|
|
1605
|
+
signal: ctx.signal
|
|
1304
1606
|
});
|
|
1305
1607
|
return JSON.stringify({ status: res.status, body: capTo(res.text, FETCH_TEXT_CAP) });
|
|
1306
1608
|
} catch (e) {
|
|
@@ -1308,59 +1610,9 @@ var httpRequest = async (args, ctx) => {
|
|
|
1308
1610
|
}
|
|
1309
1611
|
};
|
|
1310
1612
|
|
|
1311
|
-
// src/long-term-memory.ts
|
|
1312
|
-
import { copyFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1313
|
-
import { join as join5 } from "node:path";
|
|
1314
|
-
var MEMORY_FILE = "MEMORY.md";
|
|
1315
|
-
var MEMORY_MAX_BYTES = 8192;
|
|
1316
|
-
var FACT_MAX_CHARS = 300;
|
|
1317
|
-
function readLongTermMemory(dirs) {
|
|
1318
|
-
const p = findConfigFile(MEMORY_FILE, dirs);
|
|
1319
|
-
if (!p) return null;
|
|
1320
|
-
const body = readFileSync4(p, "utf8").trim();
|
|
1321
|
-
return body || null;
|
|
1322
|
-
}
|
|
1323
|
-
function memoryTarget(dirs) {
|
|
1324
|
-
for (const d of dirs) if (existsSync4(join5(d, MEMORY_FILE))) return d;
|
|
1325
|
-
for (const d of dirs) if (existsSync4(d)) return d;
|
|
1326
|
-
return dirs[dirs.length - 1];
|
|
1327
|
-
}
|
|
1328
|
-
function migrateMemoryIntoManaged(managedDir, dataDir) {
|
|
1329
|
-
const managed = join5(managedDir, MEMORY_FILE);
|
|
1330
|
-
const local = join5(dataDir, MEMORY_FILE);
|
|
1331
|
-
if (!existsSync4(managedDir) || existsSync4(managed) || !existsSync4(local)) return false;
|
|
1332
|
-
copyFileSync(local, managed);
|
|
1333
|
-
return true;
|
|
1334
|
-
}
|
|
1335
|
-
function rememberFact(dirs, fact, now) {
|
|
1336
|
-
const clean = fact.replace(/\s+/g, " ").trim().slice(0, FACT_MAX_CHARS);
|
|
1337
|
-
if (!clean) throw new Error("remember needs a non-empty fact");
|
|
1338
|
-
const line = `- ${now.toISOString().slice(0, 10)} ${clean}`;
|
|
1339
|
-
const dir = memoryTarget(dirs);
|
|
1340
|
-
mkdirSync2(dir, { recursive: true });
|
|
1341
|
-
const existing = readLongTermMemory(dirs);
|
|
1342
|
-
let lines = existing ? existing.split("\n") : [];
|
|
1343
|
-
lines.push(line);
|
|
1344
|
-
while (Buffer.byteLength(`${lines.join("\n")}
|
|
1345
|
-
`, "utf8") > MEMORY_MAX_BYTES && lines.length > 1) {
|
|
1346
|
-
lines = lines.slice(1);
|
|
1347
|
-
}
|
|
1348
|
-
writeFileSync2(join5(dir, MEMORY_FILE), `${lines.join("\n")}
|
|
1349
|
-
`, { mode: 384 });
|
|
1350
|
-
return line;
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
// src/builtins/remember.ts
|
|
1354
|
-
var remember = async (args, ctx) => {
|
|
1355
|
-
try {
|
|
1356
|
-
return JSON.stringify({ remembered: rememberFact(ctx.configDirs, String(args.fact ?? ""), ctx.now()) });
|
|
1357
|
-
} catch (e) {
|
|
1358
|
-
return toolError(e instanceof Error ? e.message : String(e));
|
|
1359
|
-
}
|
|
1360
|
-
};
|
|
1361
|
-
|
|
1362
1613
|
// src/builtins/sql-query.ts
|
|
1363
|
-
import { readFileSync as
|
|
1614
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
1615
|
+
import { spawn } from "node:child_process";
|
|
1364
1616
|
|
|
1365
1617
|
// src/sql-guard.ts
|
|
1366
1618
|
var SqlNotReadOnlyError = class extends Error {
|
|
@@ -1414,24 +1666,168 @@ function assertReadOnlySelect(sql) {
|
|
|
1414
1666
|
}
|
|
1415
1667
|
|
|
1416
1668
|
// src/builtins/sql-query.ts
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
let
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1669
|
+
var SQLITE_PROCESS_SOURCE = String.raw`
|
|
1670
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
1671
|
+
|
|
1672
|
+
let input = "";
|
|
1673
|
+
process.stdin.setEncoding("utf8");
|
|
1674
|
+
process.stdin.on("data", (chunk) => { input += chunk; });
|
|
1675
|
+
process.stdin.on("end", () => {
|
|
1676
|
+
let handle;
|
|
1677
|
+
try {
|
|
1678
|
+
const request = JSON.parse(input);
|
|
1679
|
+
handle = new DatabaseSync(request.path, {
|
|
1680
|
+
readOnly: true,
|
|
1681
|
+
timeout: request.busyTimeoutMs,
|
|
1682
|
+
});
|
|
1683
|
+
// Keep SQLite-owned allocations bounded too, not only V8's heap. Temp
|
|
1684
|
+
// work stays in the limited heap instead of consuming unbounded disk,
|
|
1685
|
+
// and mmap is disabled so reads cannot bypass the allocation ceiling.
|
|
1686
|
+
const heapLimit = handle.prepare(
|
|
1687
|
+
"PRAGMA hard_heap_limit = " + request.sqliteHeapLimitBytes
|
|
1688
|
+
).get();
|
|
1689
|
+
const effectiveHeapLimit = Number(Object.values(heapLimit ?? {})[0]);
|
|
1690
|
+
if (!(effectiveHeapLimit > 0 && effectiveHeapLimit <= request.sqliteHeapLimitBytes)) {
|
|
1691
|
+
throw new Error("SQLite heap limit could not be enforced");
|
|
1692
|
+
}
|
|
1693
|
+
handle.exec("PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 0");
|
|
1694
|
+
const statement = handle.prepare(request.sql);
|
|
1695
|
+
const rows = [];
|
|
1696
|
+
let columns = [];
|
|
1697
|
+
let truncated = false;
|
|
1698
|
+
|
|
1699
|
+
for (const record of statement.iterate()) {
|
|
1700
|
+
if (rows.length === request.rowCap) {
|
|
1701
|
+
truncated = true;
|
|
1702
|
+
break;
|
|
1703
|
+
}
|
|
1704
|
+
if (rows.length === 0) columns = Object.keys(record);
|
|
1705
|
+
rows.push(columns.map((column) => record[column]));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
process.stdout.write(JSON.stringify({
|
|
1709
|
+
ok: true,
|
|
1710
|
+
result: { columns, rows, truncated },
|
|
1711
|
+
}));
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
process.stdout.write(JSON.stringify({
|
|
1714
|
+
ok: false,
|
|
1715
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1716
|
+
}));
|
|
1717
|
+
} finally {
|
|
1718
|
+
if (handle) handle.close();
|
|
1719
|
+
}
|
|
1720
|
+
});
|
|
1721
|
+
`;
|
|
1722
|
+
var SQLITE_IPC_CAP_BYTES = 1024 * 1024;
|
|
1723
|
+
var SQLITE_HEAP_CAP_BYTES = 32 * 1024 * 1024;
|
|
1724
|
+
function executeSqliteQuery(path, sql, options = {}) {
|
|
1725
|
+
const timeoutMs = options.timeoutMs ?? SQL_TIMEOUT_MS;
|
|
1726
|
+
if (options.signal?.aborted) return Promise.reject(new Error("SQLite query cancelled"));
|
|
1727
|
+
return new Promise((resolve3, reject) => {
|
|
1728
|
+
const child = spawn(process.execPath, [
|
|
1729
|
+
"--no-warnings",
|
|
1730
|
+
"--max-old-space-size=32",
|
|
1731
|
+
"--input-type=commonjs",
|
|
1732
|
+
"--eval",
|
|
1733
|
+
SQLITE_PROCESS_SOURCE
|
|
1734
|
+
], {
|
|
1735
|
+
// The query process needs no credentials or runtime configuration.
|
|
1736
|
+
// In particular, do not let NODE_OPTIONS weaken its heap boundary.
|
|
1737
|
+
env: {},
|
|
1738
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
1739
|
+
windowsHide: true
|
|
1740
|
+
});
|
|
1741
|
+
let settled = false;
|
|
1742
|
+
let stopError;
|
|
1743
|
+
let stdout = "";
|
|
1744
|
+
let stdoutBytes = 0;
|
|
1745
|
+
const cleanup = () => {
|
|
1746
|
+
clearTimeout(timer);
|
|
1747
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
1748
|
+
};
|
|
1749
|
+
const stop = (error) => {
|
|
1750
|
+
if (settled || stopError) return;
|
|
1751
|
+
stopError = error;
|
|
1752
|
+
cleanup();
|
|
1753
|
+
child.kill("SIGKILL");
|
|
1754
|
+
};
|
|
1755
|
+
const onAbort = () => stop(new Error("SQLite query cancelled"));
|
|
1756
|
+
const timer = setTimeout(
|
|
1757
|
+
() => stop(new Error(`SQLite query timed out after ${timeoutMs}ms`)),
|
|
1758
|
+
timeoutMs
|
|
1759
|
+
);
|
|
1760
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1761
|
+
child.stdout.setEncoding("utf8");
|
|
1762
|
+
child.stdout.on("data", (chunk) => {
|
|
1763
|
+
if (stopError) return;
|
|
1764
|
+
stdoutBytes += Buffer.byteLength(chunk);
|
|
1765
|
+
if (stdoutBytes > SQLITE_IPC_CAP_BYTES) {
|
|
1766
|
+
stop(new Error("SQLite query result exceeded the materialization limit"));
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
stdout += chunk;
|
|
1770
|
+
});
|
|
1771
|
+
child.once("error", (error) => {
|
|
1772
|
+
if (settled) return;
|
|
1773
|
+
settled = true;
|
|
1774
|
+
cleanup();
|
|
1775
|
+
reject(error);
|
|
1776
|
+
});
|
|
1777
|
+
child.once("close", (code) => {
|
|
1778
|
+
if (settled) return;
|
|
1779
|
+
settled = true;
|
|
1780
|
+
cleanup();
|
|
1781
|
+
if (stopError) {
|
|
1782
|
+
reject(stopError);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
if (code !== 0) {
|
|
1786
|
+
reject(new Error(`SQLite query process exited before returning a result (code ${code})`));
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
try {
|
|
1790
|
+
const message = JSON.parse(stdout);
|
|
1791
|
+
if (message.ok && message.result) resolve3(message.result);
|
|
1792
|
+
else reject(new Error(message.error ?? "SQLite query process failed"));
|
|
1793
|
+
} catch {
|
|
1794
|
+
reject(new Error("SQLite query process returned an invalid result"));
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
child.stdin.on("error", () => {
|
|
1798
|
+
});
|
|
1799
|
+
child.stdin.end(JSON.stringify({
|
|
1800
|
+
path,
|
|
1801
|
+
sql,
|
|
1802
|
+
rowCap: SQL_ROW_CAP,
|
|
1803
|
+
busyTimeoutMs: timeoutMs,
|
|
1804
|
+
sqliteHeapLimitBytes: SQLITE_HEAP_CAP_BYTES
|
|
1805
|
+
}));
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
function envelope(columns, rows, truncated) {
|
|
1809
|
+
let kept = rows;
|
|
1810
|
+
let cut = truncated;
|
|
1811
|
+
try {
|
|
1812
|
+
let out = JSON.stringify({ columns, rows: kept, truncated: cut });
|
|
1813
|
+
while (resultBytes(out) > RESULT_CAP && kept.length > 0) {
|
|
1814
|
+
kept = kept.slice(0, Math.floor(kept.length / 2));
|
|
1815
|
+
cut = true;
|
|
1816
|
+
out = JSON.stringify({ columns, rows: kept, truncated: cut });
|
|
1817
|
+
}
|
|
1818
|
+
if (resultBytes(out) <= RESULT_CAP) return out;
|
|
1819
|
+
} catch {
|
|
1820
|
+
return toolError("database result could not be serialized");
|
|
1821
|
+
}
|
|
1822
|
+
return toolError("database result metadata exceeded the response limit");
|
|
1823
|
+
}
|
|
1824
|
+
var sqlQuery = async (args, ctx) => {
|
|
1825
|
+
const name = typeof args.name === "string" ? args.name : "";
|
|
1826
|
+
const db = ctx.skillsConfig.databases[name];
|
|
1827
|
+
if (!db) {
|
|
1828
|
+
return toolError(
|
|
1829
|
+
`unknown database "${name}": configured databases are ${Object.keys(ctx.skillsConfig.databases).join(", ") || "(none)"}`
|
|
1830
|
+
);
|
|
1435
1831
|
}
|
|
1436
1832
|
let sql;
|
|
1437
1833
|
try {
|
|
@@ -1441,16 +1837,8 @@ var sqlQuery = async (args, ctx) => {
|
|
|
1441
1837
|
}
|
|
1442
1838
|
try {
|
|
1443
1839
|
if ("sqlite" in db) {
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
-
try {
|
|
1447
|
-
const rows = handle.prepare(sql).all();
|
|
1448
|
-
const capped = rows.slice(0, SQL_ROW_CAP);
|
|
1449
|
-
const columns = capped.length ? Object.keys(capped[0]) : [];
|
|
1450
|
-
return envelope(columns, capped.map((r) => columns.map((c) => r[c])), rows.length > SQL_ROW_CAP);
|
|
1451
|
-
} finally {
|
|
1452
|
-
handle.close();
|
|
1453
|
-
}
|
|
1840
|
+
const result = await executeSqliteQuery(db.sqlite, sql, { signal: ctx.signal });
|
|
1841
|
+
return envelope(result.columns, result.rows, result.truncated);
|
|
1454
1842
|
}
|
|
1455
1843
|
let pg;
|
|
1456
1844
|
try {
|
|
@@ -1461,19 +1849,32 @@ var sqlQuery = async (args, ctx) => {
|
|
|
1461
1849
|
const Client = pg.Client ?? pg.default?.Client;
|
|
1462
1850
|
if (!Client) return toolError(`the installed "pg" module does not export a Client`);
|
|
1463
1851
|
const client = new Client({
|
|
1464
|
-
connectionString:
|
|
1852
|
+
connectionString: readFileSync4(db.postgresUrlFile, "utf8").trim(),
|
|
1465
1853
|
// never logged (D4)
|
|
1466
|
-
statement_timeout: SQL_TIMEOUT_MS
|
|
1854
|
+
statement_timeout: SQL_TIMEOUT_MS,
|
|
1855
|
+
connectionTimeoutMillis: SQL_TIMEOUT_MS,
|
|
1856
|
+
query_timeout: SQL_TIMEOUT_MS
|
|
1467
1857
|
});
|
|
1468
|
-
|
|
1858
|
+
let cancelled = ctx.signal?.aborted ?? false;
|
|
1859
|
+
const cancel = () => {
|
|
1860
|
+
cancelled = true;
|
|
1861
|
+
void client.end().catch(() => {
|
|
1862
|
+
});
|
|
1863
|
+
};
|
|
1864
|
+
if (cancelled) throw new Error("Postgres query cancelled");
|
|
1865
|
+
ctx.signal?.addEventListener("abort", cancel, { once: true });
|
|
1469
1866
|
try {
|
|
1867
|
+
await client.connect();
|
|
1868
|
+
if (cancelled) throw new Error("Postgres query cancelled");
|
|
1470
1869
|
await client.query("BEGIN; SET TRANSACTION READ ONLY;");
|
|
1471
1870
|
const res = await client.query({ text: sql, rowMode: "array" });
|
|
1871
|
+
if (cancelled) throw new Error("Postgres query cancelled");
|
|
1472
1872
|
const columns = res.fields.map((f) => f.name);
|
|
1473
1873
|
const rows = res.rows.slice(0, SQL_ROW_CAP);
|
|
1474
1874
|
return envelope(columns, rows, res.rows.length > SQL_ROW_CAP);
|
|
1475
1875
|
} finally {
|
|
1476
|
-
|
|
1876
|
+
ctx.signal?.removeEventListener("abort", cancel);
|
|
1877
|
+
if (!cancelled) await client.query("ROLLBACK").catch(() => {
|
|
1477
1878
|
});
|
|
1478
1879
|
await client.end().catch(() => {
|
|
1479
1880
|
});
|
|
@@ -1496,13 +1897,14 @@ var webSearch = async (args, ctx) => {
|
|
|
1496
1897
|
try {
|
|
1497
1898
|
let hits;
|
|
1498
1899
|
if (brave) {
|
|
1499
|
-
ctx.log("debug",
|
|
1900
|
+
ctx.log("debug", "tool.web_search_requested", { count, provider_brave: true });
|
|
1500
1901
|
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
|
|
1501
1902
|
const res = await safeFetch(url, {
|
|
1502
1903
|
resolve: ctx.resolve,
|
|
1503
1904
|
fetch: ctx.fetch,
|
|
1504
1905
|
timeoutMs: 1e4,
|
|
1505
1906
|
maxBytes: 2e6,
|
|
1907
|
+
signal: ctx.signal,
|
|
1506
1908
|
headers: { accept: "application/json", "X-Subscription-Token": brave }
|
|
1507
1909
|
});
|
|
1508
1910
|
if (res.status !== 200) return toolError(`brave search returned HTTP ${res.status}`);
|
|
@@ -1513,12 +1915,13 @@ var webSearch = async (args, ctx) => {
|
|
|
1513
1915
|
snippet: r.description ?? ""
|
|
1514
1916
|
}));
|
|
1515
1917
|
} else {
|
|
1516
|
-
ctx.log("debug",
|
|
1918
|
+
ctx.log("debug", "tool.web_search_requested", { count, provider_brave: false });
|
|
1517
1919
|
const res = await safeFetch("https://api.tavily.com/search", {
|
|
1518
1920
|
resolve: ctx.resolve,
|
|
1519
1921
|
fetch: ctx.fetch,
|
|
1520
1922
|
timeoutMs: 1e4,
|
|
1521
1923
|
maxBytes: 2e6,
|
|
1924
|
+
signal: ctx.signal,
|
|
1522
1925
|
method: "POST",
|
|
1523
1926
|
headers: { "content-type": "application/json" },
|
|
1524
1927
|
body: JSON.stringify({ api_key: tavily, query, max_results: count })
|
|
@@ -1541,14 +1944,70 @@ var webSearch = async (args, ctx) => {
|
|
|
1541
1944
|
var RESULT_CAP = 2e4;
|
|
1542
1945
|
var TRUNCATION_MARKER = "\u2026[truncated]";
|
|
1543
1946
|
var FETCH_TEXT_CAP = RESULT_CAP - 4096;
|
|
1947
|
+
function resultBytes(s) {
|
|
1948
|
+
return Buffer.byteLength(s, "utf8");
|
|
1949
|
+
}
|
|
1950
|
+
function utf8Prefix(s, maxBytes) {
|
|
1951
|
+
if (maxBytes <= 0) return "";
|
|
1952
|
+
const bytes = Buffer.from(s, "utf8");
|
|
1953
|
+
if (bytes.length <= maxBytes) return s;
|
|
1954
|
+
let end = maxBytes;
|
|
1955
|
+
while (end > 0) {
|
|
1956
|
+
const candidate = bytes.subarray(0, end).toString("utf8");
|
|
1957
|
+
if (!candidate.endsWith("\uFFFD") && resultBytes(candidate) <= maxBytes) return candidate;
|
|
1958
|
+
end--;
|
|
1959
|
+
}
|
|
1960
|
+
return "";
|
|
1961
|
+
}
|
|
1962
|
+
function fitJsonString(value, build) {
|
|
1963
|
+
const codePoints = Array.from(value);
|
|
1964
|
+
let low = 0;
|
|
1965
|
+
let high = codePoints.length;
|
|
1966
|
+
let best = build("");
|
|
1967
|
+
while (low <= high) {
|
|
1968
|
+
const middle = Math.floor((low + high) / 2);
|
|
1969
|
+
const candidate = build(codePoints.slice(0, middle).join(""));
|
|
1970
|
+
if (resultBytes(candidate) <= RESULT_CAP) {
|
|
1971
|
+
best = candidate;
|
|
1972
|
+
low = middle + 1;
|
|
1973
|
+
} else {
|
|
1974
|
+
high = middle - 1;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
return best;
|
|
1978
|
+
}
|
|
1544
1979
|
function capTo(s, limit) {
|
|
1545
|
-
|
|
1980
|
+
if (resultBytes(s) <= limit) return s;
|
|
1981
|
+
const markerBytes = resultBytes(TRUNCATION_MARKER);
|
|
1982
|
+
return utf8Prefix(s, Math.max(0, limit - markerBytes)) + TRUNCATION_MARKER;
|
|
1546
1983
|
}
|
|
1547
1984
|
function capResult(s) {
|
|
1548
1985
|
return capTo(s, RESULT_CAP);
|
|
1549
1986
|
}
|
|
1987
|
+
function boundToolResult(content) {
|
|
1988
|
+
if (resultBytes(content) <= RESULT_CAP) return content;
|
|
1989
|
+
try {
|
|
1990
|
+
const parsed = JSON.parse(content);
|
|
1991
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1992
|
+
const record = parsed;
|
|
1993
|
+
if (typeof record.error === "string") {
|
|
1994
|
+
const message = typeof record.message === "string" ? record.message : record.error;
|
|
1995
|
+
return toolError(message);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
return fitJsonString(content, (result) => JSON.stringify({ result, truncated: true }));
|
|
1999
|
+
} catch {
|
|
2000
|
+
return capResult(content);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
1550
2003
|
function toolError(message) {
|
|
1551
|
-
|
|
2004
|
+
const plain = JSON.stringify({ error: "tool_failed", message });
|
|
2005
|
+
if (resultBytes(plain) <= RESULT_CAP) return plain;
|
|
2006
|
+
return fitJsonString(message, (prefix) => JSON.stringify({
|
|
2007
|
+
error: "tool_failed",
|
|
2008
|
+
message: prefix,
|
|
2009
|
+
truncated: true
|
|
2010
|
+
}));
|
|
1552
2011
|
}
|
|
1553
2012
|
var NO_CONTEXT = "no runtime context: this built-in needs the runtime's network context";
|
|
1554
2013
|
function offlineContext() {
|
|
@@ -1573,16 +2032,151 @@ var BUILTINS = {
|
|
|
1573
2032
|
fetch_url: fetchUrl,
|
|
1574
2033
|
web_search: webSearch,
|
|
1575
2034
|
http_request: httpRequest,
|
|
1576
|
-
sql_query: sqlQuery
|
|
1577
|
-
|
|
2035
|
+
sql_query: sqlQuery
|
|
2036
|
+
};
|
|
2037
|
+
|
|
2038
|
+
// src/side-effect-ledger.ts
|
|
2039
|
+
import { createHash } from "node:crypto";
|
|
2040
|
+
import { chmodSync, mkdirSync as mkdirSync2 } from "node:fs";
|
|
2041
|
+
import { dirname as dirname4 } from "node:path";
|
|
2042
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2043
|
+
var DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES = 1e4;
|
|
2044
|
+
function canonicalJson(value) {
|
|
2045
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
2046
|
+
return JSON.stringify(value);
|
|
2047
|
+
}
|
|
2048
|
+
if (typeof value === "number") {
|
|
2049
|
+
if (!Number.isFinite(value)) throw new Error("operation arguments must be finite JSON values");
|
|
2050
|
+
return JSON.stringify(value);
|
|
2051
|
+
}
|
|
2052
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
2053
|
+
if (typeof value === "object") {
|
|
2054
|
+
const record = value;
|
|
2055
|
+
return `{${Object.keys(record).sort().map(
|
|
2056
|
+
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
|
2057
|
+
).join(",")}}`;
|
|
2058
|
+
}
|
|
2059
|
+
throw new Error("operation arguments must be JSON values");
|
|
2060
|
+
}
|
|
2061
|
+
function operationSha256(tool, args) {
|
|
2062
|
+
const normalized = tool === "http_request" && typeof args.method === "string" ? { ...args, method: args.method.toUpperCase() } : args;
|
|
2063
|
+
return createHash("sha256").update(`${tool}
|
|
2064
|
+
${canonicalJson(normalized)}`, "utf8").digest("hex");
|
|
2065
|
+
}
|
|
2066
|
+
var SideEffectLedger = class {
|
|
2067
|
+
constructor(path, maxEntries = DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES) {
|
|
2068
|
+
this.maxEntries = maxEntries;
|
|
2069
|
+
mkdirSync2(dirname4(path), { recursive: true, mode: 448 });
|
|
2070
|
+
this.db = new DatabaseSync(path);
|
|
2071
|
+
chmodSync(path, 384);
|
|
2072
|
+
this.db.exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA busy_timeout=1000;");
|
|
2073
|
+
this.db.exec(`
|
|
2074
|
+
CREATE TABLE IF NOT EXISTS side_effect_claims (
|
|
2075
|
+
idempotency_key TEXT PRIMARY KEY,
|
|
2076
|
+
operation_sha256 TEXT NOT NULL,
|
|
2077
|
+
confirmation_id TEXT NOT NULL UNIQUE,
|
|
2078
|
+
expires_at TEXT NOT NULL,
|
|
2079
|
+
state TEXT NOT NULL CHECK (state IN ('in_progress', 'completed')),
|
|
2080
|
+
created_at TEXT NOT NULL,
|
|
2081
|
+
completed_at TEXT
|
|
2082
|
+
) STRICT
|
|
2083
|
+
`);
|
|
2084
|
+
}
|
|
2085
|
+
db;
|
|
2086
|
+
claim(grant, now) {
|
|
2087
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2088
|
+
try {
|
|
2089
|
+
const prior = this.db.prepare(`
|
|
2090
|
+
SELECT idempotency_key, operation_sha256, confirmation_id, state
|
|
2091
|
+
FROM side_effect_claims WHERE idempotency_key = ? OR confirmation_id = ?
|
|
2092
|
+
`).get(grant.idempotency_key, grant.confirmation_id);
|
|
2093
|
+
if (prior) {
|
|
2094
|
+
this.db.exec("COMMIT");
|
|
2095
|
+
if (prior.operation_sha256 !== grant.operation_sha256 || prior.confirmation_id !== grant.confirmation_id || prior.idempotency_key !== grant.idempotency_key) return { kind: "conflict" };
|
|
2096
|
+
return { kind: prior.state === "completed" ? "completed" : "ambiguous" };
|
|
2097
|
+
}
|
|
2098
|
+
const count = this.db.prepare("SELECT COUNT(*) AS count FROM side_effect_claims").get();
|
|
2099
|
+
if (count.count >= this.maxEntries) {
|
|
2100
|
+
this.db.exec("COMMIT");
|
|
2101
|
+
return { kind: "full" };
|
|
2102
|
+
}
|
|
2103
|
+
this.db.prepare(`
|
|
2104
|
+
INSERT INTO side_effect_claims
|
|
2105
|
+
(idempotency_key, operation_sha256, confirmation_id, expires_at, state, created_at)
|
|
2106
|
+
VALUES (?, ?, ?, ?, 'in_progress', ?)
|
|
2107
|
+
`).run(
|
|
2108
|
+
grant.idempotency_key,
|
|
2109
|
+
grant.operation_sha256,
|
|
2110
|
+
grant.confirmation_id,
|
|
2111
|
+
grant.expires_at,
|
|
2112
|
+
now.toISOString()
|
|
2113
|
+
);
|
|
2114
|
+
this.db.exec("COMMIT");
|
|
2115
|
+
return { kind: "execute" };
|
|
2116
|
+
} catch (error) {
|
|
2117
|
+
try {
|
|
2118
|
+
this.db.exec("ROLLBACK");
|
|
2119
|
+
} catch {
|
|
2120
|
+
}
|
|
2121
|
+
throw error;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
complete(grant, now) {
|
|
2125
|
+
const result = this.db.prepare(`
|
|
2126
|
+
UPDATE side_effect_claims SET state = 'completed', completed_at = ?
|
|
2127
|
+
WHERE idempotency_key = ? AND operation_sha256 = ? AND confirmation_id = ?
|
|
2128
|
+
AND state = 'in_progress'
|
|
2129
|
+
`).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2130
|
+
if (result.changes !== 1) throw new Error("side-effect claim could not be completed");
|
|
2131
|
+
}
|
|
2132
|
+
/** Content-free inventory for an operator investigating an interrupted effect. */
|
|
2133
|
+
listAmbiguous() {
|
|
2134
|
+
return this.db.prepare(`
|
|
2135
|
+
SELECT idempotency_key, operation_sha256, confirmation_id, expires_at, created_at
|
|
2136
|
+
FROM side_effect_claims WHERE state='in_progress' ORDER BY created_at, idempotency_key
|
|
2137
|
+
`).all();
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Record the operator's external reconciliation result. `completed` prevents
|
|
2141
|
+
* replay; `not_applied` removes the pre-effect claim so the exact confirmed
|
|
2142
|
+
* operation may be retried. Every binding field must match the durable row.
|
|
2143
|
+
*/
|
|
2144
|
+
reconcile(grant, outcome, now) {
|
|
2145
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2146
|
+
try {
|
|
2147
|
+
const row = this.db.prepare(`
|
|
2148
|
+
SELECT state FROM side_effect_claims
|
|
2149
|
+
WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=?
|
|
2150
|
+
`).get(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2151
|
+
if (!row) throw new Error("ambiguous side-effect claim not found or binding mismatch");
|
|
2152
|
+
if (row.state !== "in_progress") throw new Error("side-effect claim is already completed");
|
|
2153
|
+
if (outcome === "completed") {
|
|
2154
|
+
this.db.prepare(`
|
|
2155
|
+
UPDATE side_effect_claims SET state='completed', completed_at=?
|
|
2156
|
+
WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
|
|
2157
|
+
`).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2158
|
+
} else {
|
|
2159
|
+
this.db.prepare(`
|
|
2160
|
+
DELETE FROM side_effect_claims
|
|
2161
|
+
WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
|
|
2162
|
+
`).run(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2163
|
+
}
|
|
2164
|
+
this.db.exec("COMMIT");
|
|
2165
|
+
} catch (error) {
|
|
2166
|
+
try {
|
|
2167
|
+
this.db.exec("ROLLBACK");
|
|
2168
|
+
} catch {
|
|
2169
|
+
}
|
|
2170
|
+
throw error;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
close() {
|
|
2174
|
+
this.db.close();
|
|
2175
|
+
}
|
|
1578
2176
|
};
|
|
1579
2177
|
|
|
1580
2178
|
// src/skill-invoker.ts
|
|
1581
2179
|
var SkillInvoker = class {
|
|
1582
|
-
skills = /* @__PURE__ */ new Map();
|
|
1583
|
-
sources = /* @__PURE__ */ new Map();
|
|
1584
|
-
skillsDir;
|
|
1585
|
-
ctx;
|
|
1586
2180
|
/**
|
|
1587
2181
|
* `ctx` carries everything a built-in may touch: the network, DNS, the clock
|
|
1588
2182
|
* and the key files. A caller that has no runtime (the loader tests, and any
|
|
@@ -1590,67 +2184,70 @@ var SkillInvoker = class {
|
|
|
1590
2184
|
* that need nothing still run and the ones that need the network fail as a
|
|
1591
2185
|
* tool_failed result instead of throwing.
|
|
1592
2186
|
*/
|
|
1593
|
-
constructor(skillsDir, ctx) {
|
|
2187
|
+
constructor(skillsDir, ctx, sideEffectLedger) {
|
|
2188
|
+
this.sideEffectLedger = sideEffectLedger;
|
|
1594
2189
|
this.skillsDir = skillsDir;
|
|
1595
2190
|
this.ctx = ctx ?? offlineContext();
|
|
1596
2191
|
}
|
|
2192
|
+
skills = /* @__PURE__ */ new Map();
|
|
2193
|
+
sources = /* @__PURE__ */ new Map();
|
|
2194
|
+
skillsDir;
|
|
2195
|
+
ctx;
|
|
1597
2196
|
/**
|
|
1598
2197
|
* Load all skill manifests from the skills directory. Supports two
|
|
1599
2198
|
* layouts: flat `<skillsDir>/*.json` files, and one level of nesting via
|
|
1600
2199
|
* `<skillsDir>/<subdir>/manifest.json`.
|
|
1601
2200
|
*/
|
|
1602
2201
|
loadSkills() {
|
|
1603
|
-
if (!
|
|
1604
|
-
|
|
2202
|
+
if (!existsSync3(this.skillsDir)) {
|
|
2203
|
+
writeServiceLog("warn", "skills.directory_missing");
|
|
1605
2204
|
return;
|
|
1606
2205
|
}
|
|
1607
2206
|
const flat = [], manifests = [], skillMds = [];
|
|
1608
2207
|
for (const entry of readdirSync(this.skillsDir, { withFileTypes: true })) {
|
|
1609
2208
|
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
1610
|
-
flat.push(
|
|
2209
|
+
flat.push(join4(this.skillsDir, entry.name));
|
|
1611
2210
|
} else if (entry.isDirectory()) {
|
|
1612
|
-
const m =
|
|
1613
|
-
if (
|
|
1614
|
-
const s =
|
|
1615
|
-
if (
|
|
2211
|
+
const m = join4(this.skillsDir, entry.name, "manifest.json");
|
|
2212
|
+
if (existsSync3(m) && statSync(m).isFile()) manifests.push(m);
|
|
2213
|
+
const s = join4(this.skillsDir, entry.name, "SKILL.md");
|
|
2214
|
+
if (existsSync3(s) && statSync(s).isFile()) skillMds.push(s);
|
|
1616
2215
|
}
|
|
1617
2216
|
}
|
|
1618
2217
|
for (const p of [...flat.sort(), ...manifests.sort()]) this.loadManifestFile(p);
|
|
1619
2218
|
for (const p of skillMds.sort()) this.loadSkillMdFile(p);
|
|
1620
|
-
|
|
2219
|
+
writeServiceLog("info", "skills.loaded", { count: this.skills.size });
|
|
1621
2220
|
}
|
|
1622
2221
|
/** Later file wins, with a warning naming both paths (spec §3). */
|
|
1623
2222
|
register(manifest, path) {
|
|
1624
2223
|
const prior = this.sources.get(manifest.name);
|
|
1625
|
-
if (prior)
|
|
2224
|
+
if (prior) writeServiceLog("warn", "skills.duplicate_replaced");
|
|
1626
2225
|
this.skills.set(manifest.name, manifest);
|
|
1627
2226
|
this.sources.set(manifest.name, path);
|
|
1628
2227
|
}
|
|
1629
2228
|
loadSkillMdFile(path) {
|
|
1630
2229
|
try {
|
|
1631
|
-
this.register(parseSkillMd(
|
|
2230
|
+
this.register(parseSkillMd(readFileSync5(path, "utf-8"), path), path);
|
|
1632
2231
|
} catch (err) {
|
|
1633
|
-
|
|
2232
|
+
writeServiceLog("warn", "skills.skill_md_invalid", {}, err);
|
|
1634
2233
|
}
|
|
1635
2234
|
}
|
|
1636
2235
|
loadManifestFile(path) {
|
|
1637
2236
|
try {
|
|
1638
|
-
const raw =
|
|
2237
|
+
const raw = readFileSync5(path, "utf-8");
|
|
1639
2238
|
const manifest = JSON.parse(raw);
|
|
1640
2239
|
if (typeof manifest.handler !== "string" || !(manifest.handler.startsWith("builtin:") || manifest.handler.startsWith("script:"))) {
|
|
1641
|
-
|
|
1642
|
-
`[SkillInvoker] Skipping manifest with invalid handler ${path}: expected "builtin:<name>" or "script:<path>"`
|
|
1643
|
-
);
|
|
2240
|
+
writeServiceLog("warn", "skills.manifest_handler_invalid");
|
|
1644
2241
|
return;
|
|
1645
2242
|
}
|
|
1646
2243
|
if (manifest.name && manifest.description) {
|
|
1647
2244
|
this.register(manifest, path);
|
|
1648
2245
|
} else {
|
|
1649
2246
|
const missing = !manifest.name ? "name" : "description";
|
|
1650
|
-
|
|
2247
|
+
writeServiceLog("warn", "skills.manifest_field_missing", { name_missing: missing === "name" });
|
|
1651
2248
|
}
|
|
1652
2249
|
} catch (err) {
|
|
1653
|
-
|
|
2250
|
+
writeServiceLog("warn", "skills.manifest_load_failed", {}, err);
|
|
1654
2251
|
}
|
|
1655
2252
|
}
|
|
1656
2253
|
/** Convert loaded skills to OpenAI tool definitions for LLM dispatch. */
|
|
@@ -1690,27 +2287,76 @@ var SkillInvoker = class {
|
|
|
1690
2287
|
async invoke(name, args, context) {
|
|
1691
2288
|
const skill = this.skills.get(name);
|
|
1692
2289
|
if (!skill) {
|
|
1693
|
-
return JSON.stringify({ error: `Unknown skill: ${name}` });
|
|
2290
|
+
return boundToolResult(JSON.stringify({ error: `Unknown skill: ${name}` }));
|
|
2291
|
+
}
|
|
2292
|
+
const execute = async () => {
|
|
2293
|
+
const handler = skill.handler;
|
|
2294
|
+
if (handler.startsWith("builtin:")) {
|
|
2295
|
+
return this.invokeBuiltin(handler.slice(8), args, context?.signal);
|
|
2296
|
+
}
|
|
2297
|
+
if (handler.startsWith("script:")) {
|
|
2298
|
+
return this.invokeScript(handler.slice(7), args, context?.signal);
|
|
2299
|
+
}
|
|
2300
|
+
return boundToolResult(JSON.stringify({ error: `Unknown handler type: ${handler}` }));
|
|
2301
|
+
};
|
|
2302
|
+
return context?.sideEffect ? this.invokeSideEffect(name, args, context.sideEffect, execute) : execute();
|
|
2303
|
+
}
|
|
2304
|
+
async invokeSideEffect(name, args, grant, execute) {
|
|
2305
|
+
const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
|
|
2306
|
+
if (name !== "http_request" || !(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
|
|
2307
|
+
return toolError("This tool has no supported side-effect replay contract");
|
|
2308
|
+
}
|
|
2309
|
+
if (grant.tool !== name || operationSha256(name, args) !== grant.operation_sha256) {
|
|
2310
|
+
return toolError("Confirmed operation digest does not match this tool call");
|
|
2311
|
+
}
|
|
2312
|
+
const now = this.ctx.now();
|
|
2313
|
+
const expiresAt = Date.parse(grant.expires_at);
|
|
2314
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
|
|
2315
|
+
return toolError("Confirmed operation grant is expired or invalid");
|
|
2316
|
+
}
|
|
2317
|
+
if (!this.sideEffectLedger) {
|
|
2318
|
+
return toolError("Side-effect replay protection is unavailable");
|
|
2319
|
+
}
|
|
2320
|
+
let claim;
|
|
2321
|
+
try {
|
|
2322
|
+
claim = this.sideEffectLedger.claim(grant, now);
|
|
2323
|
+
} catch {
|
|
2324
|
+
return toolError("Side-effect replay claim could not be persisted");
|
|
2325
|
+
}
|
|
2326
|
+
if (claim.kind === "completed") return JSON.stringify({ status: "already_applied" });
|
|
2327
|
+
if (claim.kind === "ambiguous") {
|
|
2328
|
+
return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
|
|
1694
2329
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
return this.invokeBuiltin(handler.slice(8), args);
|
|
2330
|
+
if (claim.kind === "conflict") {
|
|
2331
|
+
return toolError("Idempotency key was already bound to a different confirmed operation");
|
|
1698
2332
|
}
|
|
1699
|
-
if (
|
|
1700
|
-
return
|
|
2333
|
+
if (claim.kind === "full") {
|
|
2334
|
+
return toolError("Side-effect replay ledger is full; reconcile it before running new operations");
|
|
2335
|
+
}
|
|
2336
|
+
let result;
|
|
2337
|
+
try {
|
|
2338
|
+
result = await execute();
|
|
2339
|
+
} catch {
|
|
2340
|
+
return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
|
|
1701
2341
|
}
|
|
1702
|
-
|
|
2342
|
+
if (isToolFailure(result)) return result;
|
|
2343
|
+
try {
|
|
2344
|
+
this.sideEffectLedger.complete(grant, this.ctx.now());
|
|
2345
|
+
} catch {
|
|
2346
|
+
return toolError("Side-effect completed but replay state is ambiguous; reconcile before retrying");
|
|
2347
|
+
}
|
|
2348
|
+
return result;
|
|
1703
2349
|
}
|
|
1704
|
-
async invokeBuiltin(name, args) {
|
|
2350
|
+
async invokeBuiltin(name, args, signal) {
|
|
1705
2351
|
const builtin = BUILTINS[name];
|
|
1706
2352
|
if (!builtin) return JSON.stringify({ error: `Unknown builtin: ${name}` });
|
|
1707
2353
|
try {
|
|
1708
|
-
return
|
|
2354
|
+
return boundToolResult(await builtin(args, { ...this.ctx, signal }));
|
|
1709
2355
|
} catch (e) {
|
|
1710
2356
|
return toolError(e instanceof Error ? e.message : String(e));
|
|
1711
2357
|
}
|
|
1712
2358
|
}
|
|
1713
|
-
async invokeScript(scriptPath, args) {
|
|
2359
|
+
async invokeScript(scriptPath, args, signal) {
|
|
1714
2360
|
const { resolve: resolve3, relative } = await import("node:path");
|
|
1715
2361
|
const resolved = resolve3(this.skillsDir, scriptPath);
|
|
1716
2362
|
const rel = relative(this.skillsDir, resolved);
|
|
@@ -1730,26 +2376,82 @@ var SkillInvoker = class {
|
|
|
1730
2376
|
try {
|
|
1731
2377
|
accessSync2(resolved, constants2.X_OK);
|
|
1732
2378
|
} catch {
|
|
1733
|
-
return
|
|
2379
|
+
return toolError(`Script not found or not executable: ${scriptPath}`);
|
|
1734
2380
|
}
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
timeout: 3e4,
|
|
1740
|
-
maxBuffer: 1024 * 1024,
|
|
1741
|
-
encoding: "utf-8",
|
|
2381
|
+
if (signal?.aborted) return toolError("Script execution cancelled");
|
|
2382
|
+
const { spawn: spawn2 } = await import("node:child_process");
|
|
2383
|
+
return new Promise((resolveResult) => {
|
|
2384
|
+
const child = spawn2(resolved, [], {
|
|
1742
2385
|
cwd: this.skillsDir,
|
|
1743
|
-
env: { PATH: "/usr/bin:/bin", HOME: "/tmp" }
|
|
2386
|
+
env: { PATH: "/usr/bin:/bin", HOME: "/tmp" },
|
|
2387
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
2388
|
+
windowsHide: true,
|
|
2389
|
+
// A script may launch children. On POSIX, make one process group so
|
|
2390
|
+
// cancellation kills the whole operation rather than orphaning them.
|
|
2391
|
+
detached: process.platform !== "win32"
|
|
1744
2392
|
});
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
2393
|
+
let stdout = "";
|
|
2394
|
+
let stdoutBytes = 0;
|
|
2395
|
+
let stopMessage = null;
|
|
2396
|
+
let settled = false;
|
|
2397
|
+
const cleanup = () => {
|
|
2398
|
+
clearTimeout(timer);
|
|
2399
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2400
|
+
};
|
|
2401
|
+
const stop = (message) => {
|
|
2402
|
+
if (settled || stopMessage) return;
|
|
2403
|
+
stopMessage = message;
|
|
2404
|
+
if (process.platform !== "win32" && child.pid) {
|
|
2405
|
+
try {
|
|
2406
|
+
process.kill(-child.pid, "SIGKILL");
|
|
2407
|
+
} catch {
|
|
2408
|
+
child.kill("SIGKILL");
|
|
2409
|
+
}
|
|
2410
|
+
} else {
|
|
2411
|
+
child.kill("SIGKILL");
|
|
2412
|
+
}
|
|
2413
|
+
};
|
|
2414
|
+
const onAbort = () => stop("Script execution cancelled");
|
|
2415
|
+
const timer = setTimeout(() => stop("Script execution timed out after 30000ms"), 3e4);
|
|
2416
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2417
|
+
child.stdout.setEncoding("utf8");
|
|
2418
|
+
child.stdout.on("data", (chunk) => {
|
|
2419
|
+
if (stopMessage) return;
|
|
2420
|
+
stdoutBytes += Buffer.byteLength(chunk);
|
|
2421
|
+
if (stdoutBytes > 1024 * 1024) {
|
|
2422
|
+
stop("Script output exceeded the materialization limit");
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
stdout += chunk;
|
|
1749
2426
|
});
|
|
1750
|
-
|
|
2427
|
+
child.once("error", () => {
|
|
2428
|
+
if (settled) return;
|
|
2429
|
+
settled = true;
|
|
2430
|
+
cleanup();
|
|
2431
|
+
resolveResult(toolError("Script execution failed"));
|
|
2432
|
+
});
|
|
2433
|
+
child.once("close", (code) => {
|
|
2434
|
+
if (settled) return;
|
|
2435
|
+
settled = true;
|
|
2436
|
+
cleanup();
|
|
2437
|
+
if (stopMessage) resolveResult(toolError(stopMessage));
|
|
2438
|
+
else if (code !== 0) resolveResult(toolError(`Script execution failed (code ${code ?? "unknown"})`));
|
|
2439
|
+
else resolveResult(boundToolResult(stdout));
|
|
2440
|
+
});
|
|
2441
|
+
child.stdin.on("error", () => {
|
|
2442
|
+
});
|
|
2443
|
+
child.stdin.end(JSON.stringify(args));
|
|
2444
|
+
});
|
|
1751
2445
|
}
|
|
1752
2446
|
};
|
|
2447
|
+
function isToolFailure(result) {
|
|
2448
|
+
try {
|
|
2449
|
+
const parsed = JSON.parse(result);
|
|
2450
|
+
return parsed.error === "tool_failed";
|
|
2451
|
+
} catch {
|
|
2452
|
+
return false;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
1753
2455
|
|
|
1754
2456
|
// src/tool-gate.ts
|
|
1755
2457
|
function createLocalToolGate(policy) {
|
|
@@ -1798,14 +2500,80 @@ function createDynamicToolGate(policyFor) {
|
|
|
1798
2500
|
}
|
|
1799
2501
|
|
|
1800
2502
|
// src/data-plane.ts
|
|
1801
|
-
import { readFileSync as
|
|
2503
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
1802
2504
|
import { WebSocket } from "node:http";
|
|
1803
2505
|
import { homedir as homedir3 } from "node:os";
|
|
1804
|
-
import { join as
|
|
1805
|
-
var
|
|
1806
|
-
|
|
1807
|
-
|
|
2506
|
+
import { join as join5 } from "node:path";
|
|
2507
|
+
var NO_TURN_MEMORY = Object.freeze({
|
|
2508
|
+
recent: Object.freeze([]),
|
|
2509
|
+
facts: Object.freeze([])
|
|
2510
|
+
});
|
|
2511
|
+
function parseTurnMemory(value) {
|
|
2512
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_MEMORY;
|
|
2513
|
+
const raw = value;
|
|
2514
|
+
if (!Array.isArray(raw.recent) || !Array.isArray(raw.facts)) return NO_TURN_MEMORY;
|
|
2515
|
+
const recent = [];
|
|
2516
|
+
for (const value2 of raw.recent.slice(-50)) {
|
|
2517
|
+
if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_MEMORY;
|
|
2518
|
+
const turn = value2;
|
|
2519
|
+
if (turn.role !== "peer" && turn.role !== "agent" || typeof turn.text !== "string") {
|
|
2520
|
+
return NO_TURN_MEMORY;
|
|
2521
|
+
}
|
|
2522
|
+
recent.push(Object.freeze({ role: turn.role, text: turn.text }));
|
|
2523
|
+
}
|
|
2524
|
+
const facts = [];
|
|
2525
|
+
for (const fact of raw.facts.slice(-200)) {
|
|
2526
|
+
if (typeof fact !== "string") return NO_TURN_MEMORY;
|
|
2527
|
+
facts.push(fact);
|
|
2528
|
+
}
|
|
2529
|
+
return Object.freeze({ recent: Object.freeze(recent), facts: Object.freeze(facts) });
|
|
2530
|
+
}
|
|
2531
|
+
var AUDIENCES = /* @__PURE__ */ new Set([
|
|
2532
|
+
"owner_dm",
|
|
2533
|
+
"team_room",
|
|
2534
|
+
"partner_room",
|
|
2535
|
+
"other_human_dm",
|
|
2536
|
+
"agent_to_agent",
|
|
2537
|
+
"unknown"
|
|
2538
|
+
]);
|
|
2539
|
+
var NO_TURN_AUTHORIZATION = Object.freeze({
|
|
2540
|
+
version: 1,
|
|
2541
|
+
audience: "unknown",
|
|
2542
|
+
sender_membership: "unknown",
|
|
2543
|
+
tool_grants: Object.freeze([])
|
|
2544
|
+
});
|
|
2545
|
+
function parseTurnAuthorization(value) {
|
|
2546
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_AUTHORIZATION;
|
|
2547
|
+
const raw = value;
|
|
2548
|
+
if (raw.version !== 1 || typeof raw.audience !== "string" || !AUDIENCES.has(raw.audience) || raw.sender_membership !== "active" && raw.sender_membership !== "unknown" || !Array.isArray(raw.tool_grants) || raw.tool_grants.length > 100) return NO_TURN_AUTHORIZATION;
|
|
2549
|
+
const grants = [];
|
|
2550
|
+
for (const value2 of raw.tool_grants) {
|
|
2551
|
+
if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_AUTHORIZATION;
|
|
2552
|
+
const grant = value2;
|
|
2553
|
+
if (typeof grant.tool !== "string" || !grant.tool || grant.tool.length > 100) return NO_TURN_AUTHORIZATION;
|
|
2554
|
+
if (grant.mode === "read") {
|
|
2555
|
+
grants.push(Object.freeze({ tool: grant.tool, mode: "read" }));
|
|
2556
|
+
continue;
|
|
2557
|
+
}
|
|
2558
|
+
if (grant.mode !== "side_effect" || typeof grant.operation_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(grant.operation_sha256) || typeof grant.confirmation_id !== "string" || !grant.confirmation_id || grant.confirmation_id.length > 200 || typeof grant.idempotency_key !== "string" || !grant.idempotency_key || grant.idempotency_key.length > 200 || typeof grant.expires_at !== "string" || !grant.expires_at || grant.expires_at.length > 40) return NO_TURN_AUTHORIZATION;
|
|
2559
|
+
grants.push(Object.freeze({
|
|
2560
|
+
tool: grant.tool,
|
|
2561
|
+
mode: "side_effect",
|
|
2562
|
+
operation_sha256: grant.operation_sha256,
|
|
2563
|
+
confirmation_id: grant.confirmation_id,
|
|
2564
|
+
idempotency_key: grant.idempotency_key,
|
|
2565
|
+
expires_at: grant.expires_at
|
|
2566
|
+
}));
|
|
2567
|
+
}
|
|
2568
|
+
return Object.freeze({
|
|
2569
|
+
version: 1,
|
|
2570
|
+
audience: raw.audience,
|
|
2571
|
+
sender_membership: raw.sender_membership,
|
|
2572
|
+
tool_grants: Object.freeze(grants)
|
|
2573
|
+
});
|
|
1808
2574
|
}
|
|
2575
|
+
var SEEN_MAX = 5e3;
|
|
2576
|
+
var TERMINAL_REPLY_ERRORS = /* @__PURE__ */ new Set(["agent_paused", "send_forbidden"]);
|
|
1809
2577
|
var SecureComsDataPlane = class {
|
|
1810
2578
|
identityId;
|
|
1811
2579
|
connectionPath;
|
|
@@ -1815,7 +2583,18 @@ var SecureComsDataPlane = class {
|
|
|
1815
2583
|
log;
|
|
1816
2584
|
sleep;
|
|
1817
2585
|
random;
|
|
2586
|
+
healthTimeoutMs;
|
|
1818
2587
|
onMessage;
|
|
2588
|
+
noticeReplies;
|
|
2589
|
+
/**
|
|
2590
|
+
* Final-wave I1(b): sockets whose daemon advertised the `nack` feature. On
|
|
2591
|
+
* those a failed turn is NACKED — only that message is retried, behind the
|
|
2592
|
+
* daemon's backoff, and the socket (with every other channel's turn) stays
|
|
2593
|
+
* up. Anywhere else a failed turn still closes the socket, as before.
|
|
2594
|
+
*/
|
|
2595
|
+
nackCapable = /* @__PURE__ */ new WeakSet();
|
|
2596
|
+
/** Sockets whose hello is unanswered: a refusal there is expected, not an error. */
|
|
2597
|
+
helloPending = /* @__PURE__ */ new WeakSet();
|
|
1819
2598
|
seen = /* @__PURE__ */ new Set();
|
|
1820
2599
|
stopped = false;
|
|
1821
2600
|
running = false;
|
|
@@ -1842,17 +2621,24 @@ var SecureComsDataPlane = class {
|
|
|
1842
2621
|
* generation is current, so a restart can never end up with two live loops.
|
|
1843
2622
|
*/
|
|
1844
2623
|
generation = 0;
|
|
2624
|
+
/** Turn controllers grouped by the socket whose result would receive them. */
|
|
2625
|
+
turns = /* @__PURE__ */ new Map();
|
|
2626
|
+
/**
|
|
2627
|
+
* Phase 3 F2: per-channel processing chains. A channel's messages are
|
|
2628
|
+
* handled strictly one after another in arrival order (the receipt lookup,
|
|
2629
|
+
* the turn and the reply), never concurrently — even when a daemon flushes a
|
|
2630
|
+
* whole backlog at once. Different channels still run alongside each other.
|
|
2631
|
+
*/
|
|
2632
|
+
channelChains = /* @__PURE__ */ new Map();
|
|
2633
|
+
healthController = null;
|
|
1845
2634
|
constructor(cfg, onMessage) {
|
|
1846
2635
|
this.identityId = cfg.identityId;
|
|
1847
|
-
const dir = cfg.connectionsDir ??
|
|
1848
|
-
this.connectionPath =
|
|
2636
|
+
const dir = cfg.connectionsDir ?? join5(homedir3(), ".securecoms", "connections");
|
|
2637
|
+
this.connectionPath = join5(dir, `${cfg.identityId}.json`);
|
|
1849
2638
|
this.establishedMs = cfg.establishedMs ?? 5e3;
|
|
1850
2639
|
this.initialDelayMs = cfg.initialDelayMs ?? 250;
|
|
1851
2640
|
this.maxDelayMs = cfg.maxDelayMs ?? 3e4;
|
|
1852
|
-
this.log = cfg.log ??
|
|
1853
|
-
if (level === "info") console.log(msg);
|
|
1854
|
-
else console.error(msg);
|
|
1855
|
-
});
|
|
2641
|
+
this.log = cfg.log ?? writeServiceLog;
|
|
1856
2642
|
this.sleep = cfg.sleep ?? ((ms) => new Promise((resolve3) => {
|
|
1857
2643
|
this.sleepTimer = setTimeout(() => {
|
|
1858
2644
|
this.sleepTimer = null;
|
|
@@ -1860,7 +2646,9 @@ var SecureComsDataPlane = class {
|
|
|
1860
2646
|
}, ms);
|
|
1861
2647
|
}));
|
|
1862
2648
|
this.random = cfg.random ?? Math.random;
|
|
2649
|
+
this.healthTimeoutMs = cfg.healthTimeoutMs ?? 2e3;
|
|
1863
2650
|
this.onMessage = onMessage;
|
|
2651
|
+
this.noticeReplies = cfg.noticeReplies ?? /* @__PURE__ */ new Set();
|
|
1864
2652
|
}
|
|
1865
2653
|
/**
|
|
1866
2654
|
* Resolves once the reconnect loop is running — NOT once attached. The loop
|
|
@@ -1879,13 +2667,19 @@ var SecureComsDataPlane = class {
|
|
|
1879
2667
|
this.generation++;
|
|
1880
2668
|
this.clearSettle();
|
|
1881
2669
|
this.cancelSleep();
|
|
2670
|
+
this.healthController?.abort(new Error("transport stopped"));
|
|
2671
|
+
this.healthController = null;
|
|
2672
|
+
for (const controllers of this.turns.values()) {
|
|
2673
|
+
for (const controller of controllers) controller.abort(new Error("transport stopped"));
|
|
2674
|
+
}
|
|
2675
|
+
this.turns.clear();
|
|
1882
2676
|
const ws = this.socket;
|
|
1883
2677
|
this.socket = null;
|
|
1884
2678
|
if (ws) {
|
|
1885
2679
|
try {
|
|
1886
2680
|
ws.close();
|
|
1887
2681
|
} catch (e) {
|
|
1888
|
-
this.log("warn",
|
|
2682
|
+
this.log("warn", "transport.socket_close_failed", {}, e);
|
|
1889
2683
|
}
|
|
1890
2684
|
}
|
|
1891
2685
|
}
|
|
@@ -1939,7 +2733,7 @@ var SecureComsDataPlane = class {
|
|
|
1939
2733
|
await this.attemptOnce();
|
|
1940
2734
|
delay = this.initialDelayMs;
|
|
1941
2735
|
} catch (e) {
|
|
1942
|
-
this.log("error",
|
|
2736
|
+
this.log("error", "transport.connection_attempt_failed", {}, e);
|
|
1943
2737
|
}
|
|
1944
2738
|
if (!live()) break;
|
|
1945
2739
|
await this.wait(this.random() * delay);
|
|
@@ -1948,10 +2742,22 @@ var SecureComsDataPlane = class {
|
|
|
1948
2742
|
if (this.generation === generation) this.running = false;
|
|
1949
2743
|
}
|
|
1950
2744
|
async attemptOnce() {
|
|
1951
|
-
const conn = JSON.parse(
|
|
2745
|
+
const conn = JSON.parse(readFileSync6(this.connectionPath, "utf8"));
|
|
1952
2746
|
const httpBase = conn.bridge_url.replace(/^ws/, "http").replace(/\/v1\/ws$/, "");
|
|
1953
|
-
const token =
|
|
1954
|
-
const
|
|
2747
|
+
const token = readFileSync6(conn.token_file, "utf8").trim();
|
|
2748
|
+
const healthController = new AbortController();
|
|
2749
|
+
this.healthController = healthController;
|
|
2750
|
+
const healthTimer = setTimeout(
|
|
2751
|
+
() => healthController.abort(new Error("health gate deadline exceeded")),
|
|
2752
|
+
this.healthTimeoutMs
|
|
2753
|
+
);
|
|
2754
|
+
let health;
|
|
2755
|
+
try {
|
|
2756
|
+
health = await (await fetch(`${httpBase}/healthz`, { signal: healthController.signal })).json();
|
|
2757
|
+
} finally {
|
|
2758
|
+
clearTimeout(healthTimer);
|
|
2759
|
+
if (this.healthController === healthController) this.healthController = null;
|
|
2760
|
+
}
|
|
1955
2761
|
if (conn.identity_id !== health.identity_id || this.identityId !== health.identity_id) {
|
|
1956
2762
|
throw new Error(
|
|
1957
2763
|
`gate refused: expected ${this.identityId}, connection file says ${conn.identity_id}, daemon on this port is ${health.identity_id}`
|
|
@@ -1965,7 +2771,7 @@ var SecureComsDataPlane = class {
|
|
|
1965
2771
|
try {
|
|
1966
2772
|
await new Promise((resolve3, reject) => {
|
|
1967
2773
|
ws.addEventListener("error", (e) => {
|
|
1968
|
-
reject(new Error(
|
|
2774
|
+
reject(Object.assign(new Error("socket_error"), { cause: e }));
|
|
1969
2775
|
});
|
|
1970
2776
|
ws.addEventListener("close", (e) => {
|
|
1971
2777
|
if (established) resolve3();
|
|
@@ -1978,19 +2784,29 @@ var SecureComsDataPlane = class {
|
|
|
1978
2784
|
}
|
|
1979
2785
|
});
|
|
1980
2786
|
ws.addEventListener("open", () => {
|
|
2787
|
+
this.helloPending.add(ws);
|
|
2788
|
+
try {
|
|
2789
|
+
ws.send(JSON.stringify({ type: "hello", features: ["nack"] }));
|
|
2790
|
+
} catch {
|
|
2791
|
+
}
|
|
1981
2792
|
settle = setTimeout(() => {
|
|
1982
2793
|
established = true;
|
|
1983
|
-
this.log("info",
|
|
2794
|
+
this.log("info", "transport.attached");
|
|
1984
2795
|
}, this.establishedMs);
|
|
1985
2796
|
this.settle = settle;
|
|
1986
2797
|
});
|
|
1987
2798
|
ws.addEventListener("message", (ev) => {
|
|
1988
|
-
void this.handleFrame(ws, ev.data).catch((e) => {
|
|
1989
|
-
this.log("error",
|
|
2799
|
+
void this.handleFrame(ws, ev.data, httpBase, token).catch((e) => {
|
|
2800
|
+
this.log("error", "transport.frame_handling_failed", {}, e);
|
|
1990
2801
|
});
|
|
1991
2802
|
});
|
|
1992
2803
|
});
|
|
1993
2804
|
} finally {
|
|
2805
|
+
const controllers = this.turns.get(ws);
|
|
2806
|
+
if (controllers) {
|
|
2807
|
+
for (const controller of controllers) controller.abort(new Error("transport disconnected"));
|
|
2808
|
+
this.turns.delete(ws);
|
|
2809
|
+
}
|
|
1994
2810
|
if (settle !== null) clearTimeout(settle);
|
|
1995
2811
|
if (this.settle === settle) this.settle = null;
|
|
1996
2812
|
try {
|
|
@@ -2000,45 +2816,133 @@ var SecureComsDataPlane = class {
|
|
|
2000
2816
|
if (this.socket === ws) this.socket = null;
|
|
2001
2817
|
}
|
|
2002
2818
|
}
|
|
2003
|
-
|
|
2819
|
+
/**
|
|
2820
|
+
* Final-wave I1(b): fail ONE message. Against a nack-capable daemon, send a
|
|
2821
|
+
* `nack` — only this message is retried and the socket stays up for every
|
|
2822
|
+
* other channel's turn. Otherwise close the socket, the only failure
|
|
2823
|
+
* signal an older daemon understands (it re-buffers what was in flight).
|
|
2824
|
+
*/
|
|
2825
|
+
failTurn(ws, messageId, reason) {
|
|
2826
|
+
if (this.nackCapable.has(ws) && ws.readyState === ws.OPEN) {
|
|
2827
|
+
try {
|
|
2828
|
+
ws.send(JSON.stringify({ type: "nack", message_id: messageId, reason }));
|
|
2829
|
+
return;
|
|
2830
|
+
} catch {
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
try {
|
|
2834
|
+
ws.close();
|
|
2835
|
+
} catch {
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
async handleFrame(ws, data, httpBase, token) {
|
|
2004
2839
|
let frame;
|
|
2005
2840
|
try {
|
|
2006
2841
|
frame = JSON.parse(String(data));
|
|
2007
2842
|
} catch (e) {
|
|
2008
|
-
this.log("error",
|
|
2843
|
+
this.log("error", "transport.frame_parse_failed", {}, e);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
if (frame?.type === "hello") {
|
|
2847
|
+
this.helloPending.delete(ws);
|
|
2848
|
+
if (Array.isArray(frame.features) && frame.features.includes("nack")) this.nackCapable.add(ws);
|
|
2009
2849
|
return;
|
|
2010
2850
|
}
|
|
2011
2851
|
if (frame?.type === "error") {
|
|
2012
|
-
|
|
2852
|
+
if (frame.in_reply_to === void 0 && this.helloPending.has(ws)) {
|
|
2853
|
+
this.helloPending.delete(ws);
|
|
2854
|
+
this.log("info", "transport.daemon_without_nack");
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
if (typeof frame.in_reply_to === "string" && TERMINAL_REPLY_ERRORS.has(String(frame.error))) {
|
|
2858
|
+
this.log("warn", "transport.reply_terminal", { paused: frame.error === "agent_paused" });
|
|
2859
|
+
this.remember(frame.in_reply_to);
|
|
2860
|
+
ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
this.log("error", "transport.daemon_error_frame");
|
|
2864
|
+
if (typeof frame.in_reply_to === "string") this.failTurn(ws, frame.in_reply_to, "reply_failed");
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
if (frame?.type === "sent" && typeof frame.in_reply_to === "string") {
|
|
2868
|
+
this.remember(frame.in_reply_to);
|
|
2869
|
+
ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
|
|
2013
2870
|
return;
|
|
2014
2871
|
}
|
|
2015
2872
|
if (frame?.type !== "message") return;
|
|
2016
2873
|
const messageId = frame.message_id;
|
|
2017
2874
|
if (typeof messageId !== "string") {
|
|
2018
|
-
this.log("error", "
|
|
2875
|
+
this.log("error", "transport.message_id_missing");
|
|
2019
2876
|
return;
|
|
2020
2877
|
}
|
|
2021
2878
|
const channelId = String(frame.channel_id);
|
|
2879
|
+
const previous = this.channelChains.get(channelId) ?? Promise.resolve();
|
|
2880
|
+
const run = () => this.handleMessage(ws, frame, messageId, channelId, httpBase, token);
|
|
2881
|
+
const mine = previous.then(run, run);
|
|
2882
|
+
this.channelChains.set(channelId, mine);
|
|
2883
|
+
try {
|
|
2884
|
+
await mine;
|
|
2885
|
+
} finally {
|
|
2886
|
+
if (this.channelChains.get(channelId) === mine) this.channelChains.delete(channelId);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
async handleMessage(ws, frame, messageId, channelId, httpBase, token) {
|
|
2890
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
2891
|
+
let turnController = null;
|
|
2022
2892
|
try {
|
|
2023
2893
|
if (!this.seen.has(messageId)) {
|
|
2024
|
-
|
|
2894
|
+
const receipt = await fetch(
|
|
2895
|
+
`${httpBase}/v1/reply-receipt?in_reply_to=${encodeURIComponent(messageId)}`,
|
|
2896
|
+
{
|
|
2897
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2898
|
+
signal: AbortSignal.timeout(this.healthTimeoutMs)
|
|
2899
|
+
}
|
|
2900
|
+
);
|
|
2901
|
+
if (!receipt.ok) throw new Error(`reply receipt lookup failed: HTTP ${receipt.status}`);
|
|
2902
|
+
if ((await receipt.json()).found === true) {
|
|
2903
|
+
this.remember(messageId);
|
|
2904
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
turnController = new AbortController();
|
|
2908
|
+
let socketTurns = this.turns.get(ws);
|
|
2909
|
+
if (!socketTurns) {
|
|
2910
|
+
socketTurns = /* @__PURE__ */ new Set();
|
|
2911
|
+
this.turns.set(ws, socketTurns);
|
|
2912
|
+
}
|
|
2913
|
+
socketTurns.add(turnController);
|
|
2025
2914
|
const reply = await this.onMessage({
|
|
2026
2915
|
text: String(frame.text ?? ""),
|
|
2027
2916
|
senderId: String(frame.sender_id ?? ""),
|
|
2028
2917
|
channelId,
|
|
2029
|
-
messageId
|
|
2030
|
-
|
|
2918
|
+
messageId,
|
|
2919
|
+
authorization: parseTurnAuthorization(frame.authorization),
|
|
2920
|
+
memory: parseTurnMemory(frame.memory)
|
|
2921
|
+
}, turnController.signal);
|
|
2031
2922
|
if (reply != null) {
|
|
2032
|
-
|
|
2923
|
+
const text = String(reply);
|
|
2924
|
+
ws.send(JSON.stringify({
|
|
2925
|
+
type: "send",
|
|
2926
|
+
channel_id: channelId,
|
|
2927
|
+
text,
|
|
2928
|
+
in_reply_to: messageId,
|
|
2929
|
+
...this.noticeReplies.has(text) ? { notice: true } : {}
|
|
2930
|
+
}));
|
|
2931
|
+
return;
|
|
2033
2932
|
}
|
|
2933
|
+
this.remember(messageId);
|
|
2934
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
2935
|
+
} else {
|
|
2936
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
2034
2937
|
}
|
|
2035
2938
|
} catch (e) {
|
|
2036
|
-
this.log("error",
|
|
2939
|
+
this.log("error", "transport.handler_failed", {}, e);
|
|
2940
|
+
this.failTurn(ws, messageId, "turn_failed");
|
|
2037
2941
|
} finally {
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2942
|
+
if (turnController) {
|
|
2943
|
+
const socketTurns = this.turns.get(ws);
|
|
2944
|
+
socketTurns?.delete(turnController);
|
|
2945
|
+
if (socketTurns?.size === 0) this.turns.delete(ws);
|
|
2042
2946
|
}
|
|
2043
2947
|
}
|
|
2044
2948
|
}
|
|
@@ -2051,6 +2955,14 @@ function createSecureComsDataPlane(cfg, onMessage) {
|
|
|
2051
2955
|
async function runToolCalls(toolCalls, skills, context) {
|
|
2052
2956
|
const out = [];
|
|
2053
2957
|
for (const call of toolCalls) {
|
|
2958
|
+
if (context?.signal?.aborted) {
|
|
2959
|
+
out.push({
|
|
2960
|
+
role: "tool",
|
|
2961
|
+
content: toolError("Tool execution cancelled"),
|
|
2962
|
+
tool_call_id: call.id
|
|
2963
|
+
});
|
|
2964
|
+
continue;
|
|
2965
|
+
}
|
|
2054
2966
|
let args;
|
|
2055
2967
|
try {
|
|
2056
2968
|
args = JSON.parse(call.function.arguments);
|
|
@@ -2059,12 +2971,14 @@ async function runToolCalls(toolCalls, skills, context) {
|
|
|
2059
2971
|
}
|
|
2060
2972
|
let content;
|
|
2061
2973
|
try {
|
|
2062
|
-
content = await skills.invoke(call.function.name, args,
|
|
2974
|
+
content = boundToolResult(await skills.invoke(call.function.name, args, {
|
|
2975
|
+
roomId: context?.roomId,
|
|
2976
|
+
groupId: context?.groupId,
|
|
2977
|
+
signal: context?.signal,
|
|
2978
|
+
sideEffect: context?.sideEffects?.get(call.id)
|
|
2979
|
+
}));
|
|
2063
2980
|
} catch (err) {
|
|
2064
|
-
content =
|
|
2065
|
-
error: "tool_failed",
|
|
2066
|
-
message: String(err instanceof Error ? err.message : err)
|
|
2067
|
-
});
|
|
2981
|
+
content = toolError(String(err instanceof Error ? err.message : err));
|
|
2068
2982
|
}
|
|
2069
2983
|
out.push({ role: "tool", content, tool_call_id: call.id });
|
|
2070
2984
|
}
|
|
@@ -2074,29 +2988,203 @@ async function runToolCalls(toolCalls, skills, context) {
|
|
|
2074
2988
|
// src/turn-loop.ts
|
|
2075
2989
|
var DEFAULT_SYSTEM_PROMPT = "You are a SecureComs agent. Respond helpfully and concisely.";
|
|
2076
2990
|
var DEFAULT_MAX_ITERATIONS = 5;
|
|
2991
|
+
var DEFAULT_MAX_CONCURRENT_TURNS = 4;
|
|
2992
|
+
var DEFAULT_TURN_TIMEOUT_MS = 3e5;
|
|
2993
|
+
var DAILY_LIMIT_RESPONSE = "This agent has reached its daily usage limit and will respond again after 00:00 UTC.";
|
|
2994
|
+
var READ_ONLY_TOOLS = /* @__PURE__ */ new Set(["echo", "web_search", "fetch_url", "sql_query", "current_time"]);
|
|
2995
|
+
function hasReadGrant(auth, toolName) {
|
|
2996
|
+
return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "read");
|
|
2997
|
+
}
|
|
2998
|
+
function hasSideEffectGrant(auth, toolName) {
|
|
2999
|
+
return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "side_effect");
|
|
3000
|
+
}
|
|
3001
|
+
function toolIsOffered(auth, toolName) {
|
|
3002
|
+
if (READ_ONLY_TOOLS.has(toolName)) return hasReadGrant(auth, toolName);
|
|
3003
|
+
if (toolName === "http_request") {
|
|
3004
|
+
return hasReadGrant(auth, toolName) || hasSideEffectGrant(auth, toolName);
|
|
3005
|
+
}
|
|
3006
|
+
return false;
|
|
3007
|
+
}
|
|
3008
|
+
function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames, now = /* @__PURE__ */ new Date()) {
|
|
3009
|
+
if (auth.sender_membership !== "active") {
|
|
3010
|
+
return { allowed: false, reason: "Sender membership is not active" };
|
|
3011
|
+
}
|
|
3012
|
+
if (!offeredNames.has(toolName) || !toolIsOffered(auth, toolName)) {
|
|
3013
|
+
return { allowed: false, reason: `Tool "${toolName}" has no active channel read grant` };
|
|
3014
|
+
}
|
|
3015
|
+
if (toolName === "http_request") {
|
|
3016
|
+
try {
|
|
3017
|
+
const args = JSON.parse(argumentsJson);
|
|
3018
|
+
const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
|
|
3019
|
+
if (method === "GET") {
|
|
3020
|
+
if (!hasReadGrant(auth, toolName)) {
|
|
3021
|
+
return { allowed: false, reason: `Tool "${toolName}" has no active channel read grant` };
|
|
3022
|
+
}
|
|
3023
|
+
} else {
|
|
3024
|
+
if (!(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
|
|
3025
|
+
return { allowed: false, reason: `HTTP method "${method}" is not supported` };
|
|
3026
|
+
}
|
|
3027
|
+
const digest = operationSha256(toolName, args);
|
|
3028
|
+
const grant = auth.tool_grants.find(
|
|
3029
|
+
(candidate) => candidate.mode === "side_effect" && candidate.tool === toolName && candidate.operation_sha256 === digest
|
|
3030
|
+
);
|
|
3031
|
+
if (!grant) {
|
|
3032
|
+
return { allowed: false, reason: "State-changing HTTP calls require an exact confirmed operation grant" };
|
|
3033
|
+
}
|
|
3034
|
+
const expiresAt = Date.parse(grant.expires_at);
|
|
3035
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
|
|
3036
|
+
return { allowed: false, reason: "The confirmed operation grant is expired or invalid" };
|
|
3037
|
+
}
|
|
3038
|
+
return { allowed: true, reason: `Tool "${toolName}" has an exact confirmed operation grant`, sideEffect: grant };
|
|
3039
|
+
}
|
|
3040
|
+
} catch {
|
|
3041
|
+
return { allowed: false, reason: "Tool arguments are not valid JSON" };
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
return { allowed: true, reason: `Tool "${toolName}" has an active channel read grant` };
|
|
3045
|
+
}
|
|
2077
3046
|
function buildHandler(deps) {
|
|
2078
3047
|
const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
|
3048
|
+
const maxConcurrentTurns = deps.maxConcurrentTurns ?? DEFAULT_MAX_CONCURRENT_TURNS;
|
|
3049
|
+
const turnTimeoutMs = deps.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
2079
3050
|
const log = deps.log ?? (() => {
|
|
2080
3051
|
});
|
|
3052
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
2081
3053
|
const { llm, memoryFor, skills, gate } = deps;
|
|
2082
|
-
|
|
3054
|
+
const channelTails = /* @__PURE__ */ new Map();
|
|
3055
|
+
const slotWaiters = [];
|
|
3056
|
+
let activeTurns = 0;
|
|
3057
|
+
const limitNotified = /* @__PURE__ */ new Map();
|
|
3058
|
+
function cancelledError() {
|
|
3059
|
+
return new Error("Agent turn cancelled");
|
|
3060
|
+
}
|
|
3061
|
+
function waitUnlessAborted(gate2, signal) {
|
|
3062
|
+
if (!signal) return gate2;
|
|
3063
|
+
if (signal.aborted) return Promise.reject(cancelledError());
|
|
3064
|
+
return new Promise((resolve3, reject) => {
|
|
3065
|
+
const onAbort = () => reject(cancelledError());
|
|
3066
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3067
|
+
void gate2.then(() => {
|
|
3068
|
+
signal.removeEventListener("abort", onAbort);
|
|
3069
|
+
resolve3();
|
|
3070
|
+
});
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
async function lockChannel(channelId, signal) {
|
|
3074
|
+
const previous = channelTails.get(channelId) ?? Promise.resolve();
|
|
3075
|
+
let release;
|
|
3076
|
+
const mine = new Promise((resolve3) => {
|
|
3077
|
+
release = resolve3;
|
|
3078
|
+
});
|
|
3079
|
+
const tail = previous.then(() => mine);
|
|
3080
|
+
channelTails.set(channelId, tail);
|
|
3081
|
+
let released = false;
|
|
3082
|
+
const done = () => {
|
|
3083
|
+
if (released) return;
|
|
3084
|
+
released = true;
|
|
3085
|
+
release();
|
|
3086
|
+
if (channelTails.get(channelId) === tail) channelTails.delete(channelId);
|
|
3087
|
+
};
|
|
3088
|
+
try {
|
|
3089
|
+
await waitUnlessAborted(previous, signal);
|
|
3090
|
+
} catch (error) {
|
|
3091
|
+
done();
|
|
3092
|
+
throw error;
|
|
3093
|
+
}
|
|
3094
|
+
return done;
|
|
3095
|
+
}
|
|
3096
|
+
function acquireSlot(signal) {
|
|
3097
|
+
let released = false;
|
|
3098
|
+
const release = () => {
|
|
3099
|
+
if (released) return;
|
|
3100
|
+
released = true;
|
|
3101
|
+
activeTurns--;
|
|
3102
|
+
slotWaiters.shift()?.();
|
|
3103
|
+
};
|
|
3104
|
+
if (activeTurns < maxConcurrentTurns) {
|
|
3105
|
+
activeTurns++;
|
|
3106
|
+
return Promise.resolve(release);
|
|
3107
|
+
}
|
|
3108
|
+
if (signal?.aborted) return Promise.reject(cancelledError());
|
|
3109
|
+
return new Promise((resolve3, reject) => {
|
|
3110
|
+
const grant = () => {
|
|
3111
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3112
|
+
activeTurns++;
|
|
3113
|
+
resolve3(release);
|
|
3114
|
+
};
|
|
3115
|
+
const onAbort = () => {
|
|
3116
|
+
const i = slotWaiters.indexOf(grant);
|
|
3117
|
+
if (i >= 0) slotWaiters.splice(i, 1);
|
|
3118
|
+
reject(cancelledError());
|
|
3119
|
+
};
|
|
3120
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3121
|
+
slotWaiters.push(grant);
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
function dailyLimitReply(channelId, day) {
|
|
3125
|
+
if (limitNotified.get(channelId) === day) return null;
|
|
3126
|
+
for (const [ch, d] of limitNotified) if (d !== day) limitNotified.delete(ch);
|
|
3127
|
+
limitNotified.set(channelId, day);
|
|
3128
|
+
log("warn", "turn.daily_limit_reply");
|
|
3129
|
+
return DAILY_LIMIT_RESPONSE;
|
|
3130
|
+
}
|
|
3131
|
+
return async (m, upstreamSignal) => {
|
|
2083
3132
|
const text = m.text?.trim() ? m.text : "";
|
|
2084
3133
|
if (!text) return null;
|
|
2085
|
-
|
|
3134
|
+
const mustWait = channelTails.has(m.channelId) || activeTurns >= maxConcurrentTurns;
|
|
3135
|
+
if (mustWait) {
|
|
3136
|
+
log("info", "turn.queued", {
|
|
3137
|
+
same_channel: channelTails.has(m.channelId),
|
|
3138
|
+
active_turns: activeTurns
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
const releaseChannel = await lockChannel(m.channelId, upstreamSignal);
|
|
3142
|
+
let releaseSlot;
|
|
3143
|
+
try {
|
|
3144
|
+
releaseSlot = await acquireSlot(upstreamSignal);
|
|
3145
|
+
} catch (error) {
|
|
3146
|
+
releaseChannel();
|
|
3147
|
+
throw error;
|
|
3148
|
+
}
|
|
3149
|
+
const controller = new AbortController();
|
|
3150
|
+
let timedOut = false;
|
|
3151
|
+
const cancel = () => controller.abort(upstreamSignal?.reason);
|
|
3152
|
+
if (upstreamSignal?.aborted) cancel();
|
|
3153
|
+
else upstreamSignal?.addEventListener("abort", cancel, { once: true });
|
|
3154
|
+
const timer = setTimeout(() => {
|
|
3155
|
+
timedOut = true;
|
|
3156
|
+
controller.abort(new Error("turn deadline exceeded"));
|
|
3157
|
+
}, turnTimeoutMs);
|
|
3158
|
+
try {
|
|
3159
|
+
return await runTurn(m, controller.signal);
|
|
3160
|
+
} catch (error) {
|
|
3161
|
+
if (error instanceof DailyLimitReachedError) return dailyLimitReply(m.channelId, error.day);
|
|
3162
|
+
if (timedOut) throw new Error(`Agent turn timed out after ${turnTimeoutMs}ms`);
|
|
3163
|
+
if (upstreamSignal?.aborted) throw cancelledError();
|
|
3164
|
+
throw error;
|
|
3165
|
+
} finally {
|
|
3166
|
+
clearTimeout(timer);
|
|
3167
|
+
upstreamSignal?.removeEventListener("abort", cancel);
|
|
3168
|
+
releaseSlot();
|
|
3169
|
+
releaseChannel();
|
|
3170
|
+
}
|
|
3171
|
+
};
|
|
3172
|
+
async function runTurn(m, signal) {
|
|
3173
|
+
const text = m.text;
|
|
3174
|
+
log("info", "turn.message_received", { chars: text.length });
|
|
2086
3175
|
const memory = memoryFor(m.channelId);
|
|
3176
|
+
memory.replace(m.memory);
|
|
2087
3177
|
const messages = [{ role: "system", content: deps.systemPromptFor() }];
|
|
2088
3178
|
const recalled = await memory.recall(text);
|
|
2089
3179
|
if (recalled) messages.push({ role: "system", content: recalled });
|
|
2090
|
-
const longTerm = deps.longTermMemory?.() ?? null;
|
|
2091
|
-
if (longTerm) messages.push({ role: "system", content: `Things you have remembered:
|
|
2092
|
-
${longTerm}` });
|
|
2093
3180
|
messages.push(...memory.getHistory());
|
|
2094
3181
|
messages.push({ role: "user", content: text });
|
|
2095
3182
|
memory.addMessage({ role: "user", content: text });
|
|
2096
|
-
const tools = deps.tools();
|
|
3183
|
+
const tools = deps.tools().filter((tool) => toolIsOffered(m.authorization, tool.function.name));
|
|
3184
|
+
const offeredNames = new Set(tools.map((tool) => tool.function.name));
|
|
2097
3185
|
let response = null;
|
|
2098
3186
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
2099
|
-
const result = await llm.complete(messages, tools.length > 0 ? tools : void 0);
|
|
3187
|
+
const result = await llm.complete(messages, tools.length > 0 ? tools : void 0, { signal });
|
|
2100
3188
|
if (result.toolCalls.length === 0) {
|
|
2101
3189
|
response = result.content;
|
|
2102
3190
|
break;
|
|
@@ -2106,23 +3194,43 @@ ${longTerm}` });
|
|
|
2106
3194
|
content: result.content ?? "",
|
|
2107
3195
|
tool_calls: result.toolCalls
|
|
2108
3196
|
});
|
|
2109
|
-
const verdicts = result.toolCalls.map((call) =>
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
3197
|
+
const verdicts = result.toolCalls.map((call) => {
|
|
3198
|
+
const channelVerdict = authorizeToolExecution(
|
|
3199
|
+
m.authorization,
|
|
3200
|
+
call.function.name,
|
|
3201
|
+
call.function.arguments,
|
|
3202
|
+
offeredNames,
|
|
3203
|
+
now()
|
|
3204
|
+
);
|
|
3205
|
+
const localVerdict = channelVerdict.allowed ? gate.gate(call.function.name) : channelVerdict;
|
|
3206
|
+
return {
|
|
3207
|
+
call,
|
|
3208
|
+
verdict: {
|
|
3209
|
+
...localVerdict,
|
|
3210
|
+
...localVerdict.allowed && channelVerdict.sideEffect ? { sideEffect: channelVerdict.sideEffect } : {}
|
|
3211
|
+
}
|
|
3212
|
+
};
|
|
3213
|
+
});
|
|
2113
3214
|
const allowed = verdicts.filter((v) => v.verdict.allowed).map((v) => v.call);
|
|
3215
|
+
const sideEffects = new Map(
|
|
3216
|
+
verdicts.flatMap(({ call, verdict }) => verdict.allowed && verdict.sideEffect ? [[call.id, verdict.sideEffect]] : [])
|
|
3217
|
+
);
|
|
2114
3218
|
for (const v of verdicts) {
|
|
2115
3219
|
if (!v.verdict.allowed) {
|
|
2116
|
-
log("warn",
|
|
3220
|
+
log("warn", "turn.tool_call_denied");
|
|
2117
3221
|
}
|
|
2118
3222
|
}
|
|
2119
|
-
const executed = await runToolCalls(allowed, skills, {
|
|
3223
|
+
const executed = await runToolCalls(allowed, skills, {
|
|
3224
|
+
roomId: m.channelId,
|
|
3225
|
+
signal,
|
|
3226
|
+
sideEffects
|
|
3227
|
+
});
|
|
2120
3228
|
const byId = new Map(executed.map((r) => [r.tool_call_id, r]));
|
|
2121
3229
|
for (const { call, verdict } of verdicts) {
|
|
2122
3230
|
if (verdict.allowed) {
|
|
2123
3231
|
const done = byId.get(call.id);
|
|
2124
3232
|
if (done) {
|
|
2125
|
-
messages.push({ role: "tool", content:
|
|
3233
|
+
messages.push({ role: "tool", content: done.content, tool_call_id: done.tool_call_id });
|
|
2126
3234
|
continue;
|
|
2127
3235
|
}
|
|
2128
3236
|
messages.push({
|
|
@@ -2140,29 +3248,172 @@ ${longTerm}` });
|
|
|
2140
3248
|
}
|
|
2141
3249
|
}
|
|
2142
3250
|
if (!response) {
|
|
2143
|
-
log("warn",
|
|
3251
|
+
log("warn", "turn.no_final_answer", { iterations: maxIterations });
|
|
2144
3252
|
return null;
|
|
2145
3253
|
}
|
|
2146
3254
|
memory.addMessage({ role: "assistant", content: response });
|
|
2147
|
-
|
|
2148
|
-
try {
|
|
2149
|
-
await memory.capture(
|
|
2150
|
-
`User asked: "${text.slice(0, 100)}". Agent responded: "${response.slice(0, 200)}"`,
|
|
2151
|
-
"conversation"
|
|
2152
|
-
);
|
|
2153
|
-
} catch (e) {
|
|
2154
|
-
log("warn", `memory capture failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
log("info", `response: ${response.slice(0, 80)}`);
|
|
3255
|
+
log("info", "turn.response_ready", { chars: response.length });
|
|
2158
3256
|
return response;
|
|
2159
|
-
}
|
|
3257
|
+
}
|
|
2160
3258
|
}
|
|
2161
3259
|
|
|
2162
3260
|
// src/persona.ts
|
|
2163
|
-
import { existsSync as
|
|
2164
|
-
import { dirname as
|
|
3261
|
+
import { existsSync as existsSync5, readFileSync as readFileSync8, renameSync as renameSync3, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
3262
|
+
import { dirname as dirname5, join as join7 } from "node:path";
|
|
2165
3263
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3264
|
+
|
|
3265
|
+
// src/long-term-memory.ts
|
|
3266
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
3267
|
+
import { chmodSync as chmodSync2, existsSync as existsSync4, lstatSync, readFileSync as readFileSync7, readdirSync as readdirSync2, renameSync as renameSync2 } from "node:fs";
|
|
3268
|
+
import { join as join6 } from "node:path";
|
|
3269
|
+
var MEMORY_FILE = "MEMORY.md";
|
|
3270
|
+
var QUARANTINE_MEMORY_FILE = "MEMORY.quarantined.md";
|
|
3271
|
+
var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
|
|
3272
|
+
var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
|
|
3273
|
+
var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
|
|
3274
|
+
function quarantineOrder(a, b) {
|
|
3275
|
+
const index = (name) => {
|
|
3276
|
+
const match = /^MEMORY\.quarantined(?:\.(\d+))?\.md$/.exec(name);
|
|
3277
|
+
return match?.[1] ? Number(match[1]) + 1 : 0;
|
|
3278
|
+
};
|
|
3279
|
+
return index(a) - index(b) || a.localeCompare(b);
|
|
3280
|
+
}
|
|
3281
|
+
function sourceId(kind, channelId, path, bytes) {
|
|
3282
|
+
return createHash2("sha256").update(kind).update("\0").update(channelId ?? "").update("\0").update(path).update("\0").update(bytes).digest("hex");
|
|
3283
|
+
}
|
|
3284
|
+
function readLegacySource(path, kind, contentBudget, channelId) {
|
|
3285
|
+
try {
|
|
3286
|
+
let st = lstatSync(path);
|
|
3287
|
+
if (st.isSymbolicLink() || !st.isFile()) return null;
|
|
3288
|
+
if ((st.mode & 511) !== 384) {
|
|
3289
|
+
chmodSync2(path, 384);
|
|
3290
|
+
st = lstatSync(path);
|
|
3291
|
+
}
|
|
3292
|
+
if (st.size > LEGACY_MEMORY_SOURCE_MAX_BYTES) {
|
|
3293
|
+
const marker = Buffer.from(`oversize:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, "utf8");
|
|
3294
|
+
return {
|
|
3295
|
+
id: sourceId(kind, channelId, path, marker),
|
|
3296
|
+
kind,
|
|
3297
|
+
...channelId ? { channelId } : {},
|
|
3298
|
+
path,
|
|
3299
|
+
content: null,
|
|
3300
|
+
bytes: st.size,
|
|
3301
|
+
exportable: false
|
|
3302
|
+
};
|
|
3303
|
+
}
|
|
3304
|
+
if (st.size > contentBudget) return null;
|
|
3305
|
+
const bytes = readFileSync7(path);
|
|
3306
|
+
if (bytes.byteLength > LEGACY_MEMORY_SOURCE_MAX_BYTES || bytes.byteLength > contentBudget) return null;
|
|
3307
|
+
return {
|
|
3308
|
+
id: sourceId(kind, channelId, path, bytes),
|
|
3309
|
+
kind,
|
|
3310
|
+
...channelId ? { channelId } : {},
|
|
3311
|
+
path,
|
|
3312
|
+
content: bytes.toString("utf8"),
|
|
3313
|
+
bytes: bytes.byteLength,
|
|
3314
|
+
exportable: true
|
|
3315
|
+
};
|
|
3316
|
+
} catch {
|
|
3317
|
+
return null;
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
function collectLegacyMemorySources(managedDir, dataDir) {
|
|
3321
|
+
const sources = [];
|
|
3322
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3323
|
+
let contentBudget = LEGACY_MEMORY_REPORT_MAX_BYTES;
|
|
3324
|
+
const add = (path, kind, channelId) => {
|
|
3325
|
+
if (sources.length >= LEGACY_MEMORY_REPORT_MAX_SOURCES) return;
|
|
3326
|
+
const source = readLegacySource(path, kind, contentBudget, channelId);
|
|
3327
|
+
if (!source) return;
|
|
3328
|
+
sources.push(source);
|
|
3329
|
+
if (source.exportable) contentBudget -= source.bytes;
|
|
3330
|
+
};
|
|
3331
|
+
for (const dir of /* @__PURE__ */ new Set([managedDir, dataDir])) {
|
|
3332
|
+
let names;
|
|
3333
|
+
try {
|
|
3334
|
+
names = readdirSync2(dir);
|
|
3335
|
+
} catch {
|
|
3336
|
+
continue;
|
|
3337
|
+
}
|
|
3338
|
+
for (const name of names.sort(quarantineOrder)) {
|
|
3339
|
+
if (!/^MEMORY\.quarantined(?:\.\d+)?\.md$/.test(name)) continue;
|
|
3340
|
+
const path = join6(dir, name);
|
|
3341
|
+
if (seen.has(path)) continue;
|
|
3342
|
+
seen.add(path);
|
|
3343
|
+
add(path, "global-memory");
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
const channelsDir = join6(dataDir, "channels");
|
|
3347
|
+
let channelNames;
|
|
3348
|
+
try {
|
|
3349
|
+
channelNames = readdirSync2(channelsDir);
|
|
3350
|
+
} catch {
|
|
3351
|
+
channelNames = [];
|
|
3352
|
+
}
|
|
3353
|
+
for (const channelId of channelNames.sort()) {
|
|
3354
|
+
const channelDir = join6(channelsDir, channelId);
|
|
3355
|
+
for (const [name, kind] of [
|
|
3356
|
+
["conversation-history.json", "conversation-history"],
|
|
3357
|
+
["memories.json", "captured-memory"]
|
|
3358
|
+
]) {
|
|
3359
|
+
add(join6(channelDir, name), kind, channelId);
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
return sources;
|
|
3363
|
+
}
|
|
3364
|
+
function legacyMemoryFingerprint(managedDir, dataDir) {
|
|
3365
|
+
const metadata = [];
|
|
3366
|
+
const add = (path) => {
|
|
3367
|
+
try {
|
|
3368
|
+
const st = lstatSync(path);
|
|
3369
|
+
if (!st.isFile() || st.isSymbolicLink()) return;
|
|
3370
|
+
metadata.push(`${path}\0${st.size}\0${st.mtimeMs}\0${st.ctimeMs}`);
|
|
3371
|
+
} catch {
|
|
3372
|
+
}
|
|
3373
|
+
};
|
|
3374
|
+
for (const dir of /* @__PURE__ */ new Set([managedDir, dataDir])) {
|
|
3375
|
+
try {
|
|
3376
|
+
for (const name of readdirSync2(dir).sort(quarantineOrder)) {
|
|
3377
|
+
if (/^MEMORY\.quarantined(?:\.\d+)?\.md$/.test(name)) add(join6(dir, name));
|
|
3378
|
+
}
|
|
3379
|
+
} catch {
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
try {
|
|
3383
|
+
const channelsDir = join6(dataDir, "channels");
|
|
3384
|
+
for (const channelId of readdirSync2(channelsDir).sort()) {
|
|
3385
|
+
add(join6(channelsDir, channelId, "conversation-history.json"));
|
|
3386
|
+
add(join6(channelsDir, channelId, "memories.json"));
|
|
3387
|
+
}
|
|
3388
|
+
} catch {
|
|
3389
|
+
}
|
|
3390
|
+
return createHash2("sha256").update(metadata.join("\n")).digest("hex");
|
|
3391
|
+
}
|
|
3392
|
+
function nextQuarantinePath(dir) {
|
|
3393
|
+
const first = join6(dir, QUARANTINE_MEMORY_FILE);
|
|
3394
|
+
if (!existsSync4(first)) return first;
|
|
3395
|
+
for (let n = 1; ; n++) {
|
|
3396
|
+
const candidate = join6(dir, `MEMORY.quarantined.${n}.md`);
|
|
3397
|
+
if (!existsSync4(candidate)) return candidate;
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
function quarantineLegacyMemory(managedDir, dataDir) {
|
|
3401
|
+
const moved = [];
|
|
3402
|
+
for (const dir of /* @__PURE__ */ new Set([managedDir, dataDir])) {
|
|
3403
|
+
const source = join6(dir, MEMORY_FILE);
|
|
3404
|
+
if (!existsSync4(source)) continue;
|
|
3405
|
+
if (lstatSync(source).isSymbolicLink()) {
|
|
3406
|
+
throw new Error(`refusing to quarantine symlinked legacy memory: ${source}`);
|
|
3407
|
+
}
|
|
3408
|
+
const target = nextQuarantinePath(dir);
|
|
3409
|
+
renameSync2(source, target);
|
|
3410
|
+
chmodSync2(target, 384);
|
|
3411
|
+
moved.push(target);
|
|
3412
|
+
}
|
|
3413
|
+
return moved;
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
// src/persona.ts
|
|
2166
3417
|
var PERSONA_MAX_BYTES = 8192;
|
|
2167
3418
|
var PERSONA_SOURCES = [
|
|
2168
3419
|
{ file: "IDENTITY.md", heading: "## Identity" },
|
|
@@ -2177,7 +3428,7 @@ var SAFETY_PREAMBLE = [
|
|
|
2177
3428
|
"- Only use the tools you were given; do not invent capabilities."
|
|
2178
3429
|
].join("\n");
|
|
2179
3430
|
function defaultPersonaDir() {
|
|
2180
|
-
return
|
|
3431
|
+
return join7(dirname5(fileURLToPath2(import.meta.url)), "..", "persona");
|
|
2181
3432
|
}
|
|
2182
3433
|
function assemblePrompt(parts) {
|
|
2183
3434
|
return parts.map((p) => `${p.heading}
|
|
@@ -2191,12 +3442,12 @@ function loadPersona(dirs) {
|
|
|
2191
3442
|
let body;
|
|
2192
3443
|
try {
|
|
2193
3444
|
body = readFileSync8(path, "utf8");
|
|
2194
|
-
} catch {
|
|
2195
|
-
|
|
3445
|
+
} catch (error) {
|
|
3446
|
+
writeServiceLog("warn", "persona.read_failed", {}, error);
|
|
2196
3447
|
continue;
|
|
2197
3448
|
}
|
|
2198
3449
|
if (Buffer.byteLength(body, "utf8") > PERSONA_MAX_BYTES) {
|
|
2199
|
-
|
|
3450
|
+
writeServiceLog("warn", "persona.truncated", { max_bytes: PERSONA_MAX_BYTES });
|
|
2200
3451
|
body = body.slice(0, PERSONA_MAX_BYTES);
|
|
2201
3452
|
}
|
|
2202
3453
|
const trimmed = body.trim();
|
|
@@ -2210,29 +3461,32 @@ function systemPromptFor(dirs, fallback) {
|
|
|
2210
3461
|
${SAFETY_PREAMBLE}`;
|
|
2211
3462
|
}
|
|
2212
3463
|
function writeLocalConfigReport(managedDir, dataDir, now, flags) {
|
|
2213
|
-
if (!
|
|
3464
|
+
if (!existsSync5(managedDir)) return false;
|
|
2214
3465
|
const persona = {};
|
|
2215
3466
|
for (const { file } of PERSONA_SOURCES) {
|
|
2216
|
-
const path2 =
|
|
2217
|
-
if (!
|
|
3467
|
+
const path2 = join7(dataDir, file);
|
|
3468
|
+
if (!existsSync5(path2)) continue;
|
|
2218
3469
|
try {
|
|
2219
3470
|
persona[file] = readFileSync8(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
|
|
2220
3471
|
} catch {
|
|
2221
3472
|
}
|
|
2222
3473
|
}
|
|
2223
3474
|
const doc = {
|
|
2224
|
-
schema: "securecoms.local-config/
|
|
3475
|
+
schema: "securecoms.local-config/2",
|
|
2225
3476
|
written_at: now.toISOString(),
|
|
2226
3477
|
has_search_key: flags.hasSearchKey,
|
|
2227
3478
|
has_llm_key: flags.hasLlmKey,
|
|
2228
|
-
persona
|
|
3479
|
+
persona,
|
|
3480
|
+
// Paths stay in this owner-only local handoff file. The daemon validates
|
|
3481
|
+
// each source again and strips `path` before reporting anything upstream.
|
|
3482
|
+
legacy_memory: collectLegacyMemorySources(managedDir, dataDir)
|
|
2229
3483
|
};
|
|
2230
|
-
const path =
|
|
3484
|
+
const path = join7(managedDir, "local-config.json");
|
|
2231
3485
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
2232
3486
|
try {
|
|
2233
|
-
|
|
3487
|
+
writeFileSync2(tmp, `${JSON.stringify(doc, null, 2)}
|
|
2234
3488
|
`, { mode: 384 });
|
|
2235
|
-
|
|
3489
|
+
renameSync3(tmp, path);
|
|
2236
3490
|
} catch (e) {
|
|
2237
3491
|
try {
|
|
2238
3492
|
unlinkSync(tmp);
|
|
@@ -2246,12 +3500,12 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
|
|
|
2246
3500
|
const written = [];
|
|
2247
3501
|
const skipped = [];
|
|
2248
3502
|
for (const { file } of PERSONA_SOURCES) {
|
|
2249
|
-
const target =
|
|
3503
|
+
const target = join7(dataDir, file);
|
|
2250
3504
|
if (exists(target)) {
|
|
2251
3505
|
skipped.push(target);
|
|
2252
3506
|
continue;
|
|
2253
3507
|
}
|
|
2254
|
-
writeFile(target, readFileSync8(
|
|
3508
|
+
writeFile(target, readFileSync8(join7(defaultsDir, file), "utf8"));
|
|
2255
3509
|
written.push(target);
|
|
2256
3510
|
}
|
|
2257
3511
|
return { written, skipped };
|
|
@@ -2259,11 +3513,17 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
|
|
|
2259
3513
|
function createLocalConfigReporter(managedDir, dataDir, now) {
|
|
2260
3514
|
let lastWritten = null;
|
|
2261
3515
|
return (flags) => {
|
|
2262
|
-
|
|
3516
|
+
const legacyFingerprint = legacyMemoryFingerprint(managedDir, dataDir);
|
|
3517
|
+
if (lastWritten !== null && lastWritten.hasSearchKey === flags.hasSearchKey && lastWritten.hasLlmKey === flags.hasLlmKey && lastWritten.legacyFingerprint === legacyFingerprint) {
|
|
2263
3518
|
return false;
|
|
2264
3519
|
}
|
|
2265
3520
|
const wrote = writeLocalConfigReport(managedDir, dataDir, now(), flags);
|
|
2266
|
-
if (wrote)
|
|
3521
|
+
if (wrote) {
|
|
3522
|
+
lastWritten = {
|
|
3523
|
+
...flags,
|
|
3524
|
+
legacyFingerprint: legacyMemoryFingerprint(managedDir, dataDir)
|
|
3525
|
+
};
|
|
3526
|
+
}
|
|
2267
3527
|
return wrote;
|
|
2268
3528
|
};
|
|
2269
3529
|
}
|
|
@@ -2277,36 +3537,34 @@ function installWarningFilter(proc = process) {
|
|
|
2277
3537
|
proc.removeListener("warning", listener);
|
|
2278
3538
|
}
|
|
2279
3539
|
proc.on("warning", (w) => {
|
|
2280
|
-
|
|
2281
|
-
|
|
3540
|
+
try {
|
|
3541
|
+
if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
|
|
3542
|
+
} catch {
|
|
3543
|
+
}
|
|
3544
|
+
writeServiceLog("warn", "runtime.node_warning", {}, w);
|
|
2282
3545
|
});
|
|
2283
3546
|
}
|
|
2284
3547
|
|
|
2285
3548
|
// src/index.ts
|
|
2286
3549
|
if (process.argv.includes("--version")) {
|
|
2287
|
-
const pkg = JSON.parse(readFileSync9(
|
|
3550
|
+
const pkg = JSON.parse(readFileSync9(join8(dirname6(dirname6(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
|
|
2288
3551
|
console.log(pkg.version);
|
|
2289
3552
|
process.exit(0);
|
|
2290
3553
|
}
|
|
2291
3554
|
var LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
2292
3555
|
function makeLogger(cfg) {
|
|
2293
3556
|
const floor = LEVEL_RANK[cfg.logLevel];
|
|
2294
|
-
return (level,
|
|
3557
|
+
return (level, event, fields, error) => {
|
|
2295
3558
|
if (LEVEL_RANK[level] < floor) return;
|
|
2296
|
-
|
|
2297
|
-
if (level === "warn" || level === "error") console.error(line);
|
|
2298
|
-
else console.log(line);
|
|
3559
|
+
writeServiceLog(level, event, fields, error);
|
|
2299
3560
|
};
|
|
2300
3561
|
}
|
|
2301
|
-
function safeChannelSegment(channelId) {
|
|
2302
|
-
return channelId.replace(/[^A-Za-z0-9_-]/g, "_") || "_";
|
|
2303
|
-
}
|
|
2304
3562
|
function realLlmSourceDeps() {
|
|
2305
3563
|
return {
|
|
2306
3564
|
home: homedir4(),
|
|
2307
3565
|
readFile: (p) => readFileSync9(p, "utf8"),
|
|
2308
3566
|
statFile: (p) => ({ mode: statSync2(p).mode }),
|
|
2309
|
-
exists: (p) =>
|
|
3567
|
+
exists: (p) => existsSync6(p)
|
|
2310
3568
|
};
|
|
2311
3569
|
}
|
|
2312
3570
|
function realServiceDeps() {
|
|
@@ -2316,10 +3574,10 @@ function realServiceDeps() {
|
|
|
2316
3574
|
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
2317
3575
|
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
2318
3576
|
},
|
|
2319
|
-
writeFile: (p, data) =>
|
|
3577
|
+
writeFile: (p, data) => writeFileSync3(p, data, { mode: 420 }),
|
|
2320
3578
|
removeFile: (p) => rmSync(p, { force: true }),
|
|
2321
3579
|
mkdirp: (p) => mkdirSync3(p, { recursive: true }),
|
|
2322
|
-
fileExists: (p) =>
|
|
3580
|
+
fileExists: (p) => existsSync6(p),
|
|
2323
3581
|
canRead: (p) => {
|
|
2324
3582
|
try {
|
|
2325
3583
|
accessSync(p, constants.R_OK);
|
|
@@ -2334,7 +3592,7 @@ function realServiceDeps() {
|
|
|
2334
3592
|
function realResolveNodeDeps() {
|
|
2335
3593
|
return {
|
|
2336
3594
|
realpath: (p) => realpathSync(p),
|
|
2337
|
-
fileExists: (p) =>
|
|
3595
|
+
fileExists: (p) => existsSync6(p),
|
|
2338
3596
|
nodeMajorOf: (p) => {
|
|
2339
3597
|
const r = spawnSync(p, ["--version"], { encoding: "utf8" });
|
|
2340
3598
|
if (r.status !== 0) return null;
|
|
@@ -2415,6 +3673,46 @@ async function main() {
|
|
|
2415
3673
|
installWarningFilter();
|
|
2416
3674
|
const verb = parseVerb(process.argv);
|
|
2417
3675
|
if (verb) runServiceVerb(verb);
|
|
3676
|
+
const sideEffectsVerb = parseSideEffectsVerb(process.argv);
|
|
3677
|
+
if (process.argv[2] === "side-effects" && !sideEffectsVerb) {
|
|
3678
|
+
console.error("securecoms-agent: side-effects requires list or resolve");
|
|
3679
|
+
process.exit(2);
|
|
3680
|
+
}
|
|
3681
|
+
if (sideEffectsVerb) {
|
|
3682
|
+
let cfgForLedger;
|
|
3683
|
+
try {
|
|
3684
|
+
cfgForLedger = loadConfig(process.argv.slice(2), process.env);
|
|
3685
|
+
} catch (e) {
|
|
3686
|
+
console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
|
|
3687
|
+
process.exit(2);
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
const ledger = new SideEffectLedger(join8(cfgForLedger.dataDir, "side-effect-ledger.sqlite"));
|
|
3691
|
+
try {
|
|
3692
|
+
if (sideEffectsVerb === "list") {
|
|
3693
|
+
console.log(JSON.stringify({ ambiguous: ledger.listAmbiguous() }, null, 2));
|
|
3694
|
+
} else {
|
|
3695
|
+
const flags = parseSideEffectResolveFlags(process.argv);
|
|
3696
|
+
if (!flags.idempotencyKey || !flags.operationSha256 || !/^[0-9a-f]{64}$/.test(flags.operationSha256) || !flags.confirmationId || !flags.outcome) {
|
|
3697
|
+
throw new Error(
|
|
3698
|
+
"resolve requires --idempotency-key, --operation-sha256, --confirmation-id, and --outcome completed|not_applied"
|
|
3699
|
+
);
|
|
3700
|
+
}
|
|
3701
|
+
ledger.reconcile({
|
|
3702
|
+
idempotency_key: flags.idempotencyKey,
|
|
3703
|
+
operation_sha256: flags.operationSha256,
|
|
3704
|
+
confirmation_id: flags.confirmationId
|
|
3705
|
+
}, flags.outcome, /* @__PURE__ */ new Date());
|
|
3706
|
+
console.log(JSON.stringify({ reconciled: true, outcome: flags.outcome }));
|
|
3707
|
+
}
|
|
3708
|
+
} catch (e) {
|
|
3709
|
+
console.error(`securecoms-agent: side-effects ${sideEffectsVerb} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3710
|
+
process.exitCode = 1;
|
|
3711
|
+
} finally {
|
|
3712
|
+
ledger.close();
|
|
3713
|
+
}
|
|
3714
|
+
process.exit(process.exitCode ?? 0);
|
|
3715
|
+
}
|
|
2418
3716
|
const personaVerb = parsePersonaVerb(process.argv);
|
|
2419
3717
|
if (personaVerb) {
|
|
2420
3718
|
let cfgForPersona;
|
|
@@ -2430,8 +3728,8 @@ async function main() {
|
|
|
2430
3728
|
const r = personaInit(
|
|
2431
3729
|
cfgForPersona.dataDir,
|
|
2432
3730
|
defaultPersonaDir(),
|
|
2433
|
-
(p, d) =>
|
|
2434
|
-
(p) =>
|
|
3731
|
+
(p, d) => writeFileSync3(p, d, { mode: 384 }),
|
|
3732
|
+
(p) => existsSync6(p)
|
|
2435
3733
|
);
|
|
2436
3734
|
for (const p of r.written) console.log(`wrote ${p}`);
|
|
2437
3735
|
for (const p of r.skipped) console.log(`kept ${p} (already exists)`);
|
|
@@ -2449,17 +3747,32 @@ async function main() {
|
|
|
2449
3747
|
try {
|
|
2450
3748
|
cfg = loadConfig(process.argv.slice(2), process.env);
|
|
2451
3749
|
} catch (e) {
|
|
2452
|
-
|
|
3750
|
+
const argv = process.argv.slice(2);
|
|
3751
|
+
const hasIdentityArg = argv.some(
|
|
3752
|
+
(arg, index) => arg === "--identity" && !!argv[index + 1] || arg.startsWith("--identity=") && arg.length > 11
|
|
3753
|
+
);
|
|
3754
|
+
writeServiceLog(
|
|
3755
|
+
"error",
|
|
3756
|
+
!hasIdentityArg && !process.env.SECURECOMS_IDENTITY ? "runtime.identity_required.use_--identity" : "runtime.config_invalid",
|
|
3757
|
+
{},
|
|
3758
|
+
e
|
|
3759
|
+
);
|
|
2453
3760
|
process.exit(2);
|
|
2454
3761
|
return;
|
|
2455
3762
|
}
|
|
2456
3763
|
const log = makeLogger(cfg);
|
|
2457
|
-
const
|
|
3764
|
+
const budget = new DailyLlmBudget({
|
|
3765
|
+
callLimit: cfg.llmDailyCallLimit ?? DEFAULT_DAILY_CALL_LIMIT,
|
|
3766
|
+
tokenLimit: cfg.llmDailyTokenLimit ?? DEFAULT_DAILY_TOKEN_LIMIT,
|
|
3767
|
+
statePath: join8(cfg.dataDir, `llm-budget-${cfg.identityId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`),
|
|
3768
|
+
log
|
|
3769
|
+
});
|
|
3770
|
+
const llm = withDailyBudget(buildLlmShim(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()), budget);
|
|
2458
3771
|
const memories = /* @__PURE__ */ new Map();
|
|
2459
3772
|
const memoryFor = (channelId) => {
|
|
2460
3773
|
let mem = memories.get(channelId);
|
|
2461
3774
|
if (!mem) {
|
|
2462
|
-
mem = new AgentMemory(
|
|
3775
|
+
mem = new AgentMemory();
|
|
2463
3776
|
memories.set(channelId, mem);
|
|
2464
3777
|
}
|
|
2465
3778
|
return mem;
|
|
@@ -2479,21 +3792,32 @@ async function main() {
|
|
|
2479
3792
|
if (!path) return null;
|
|
2480
3793
|
try {
|
|
2481
3794
|
return readFileSync9(path, "utf8").trim() || null;
|
|
2482
|
-
} catch {
|
|
2483
|
-
log("warn",
|
|
3795
|
+
} catch (e) {
|
|
3796
|
+
log("warn", "runtime.search_key_unreadable", { provider_brave: which === "brave" }, e);
|
|
2484
3797
|
return null;
|
|
2485
3798
|
}
|
|
2486
3799
|
};
|
|
2487
3800
|
const configDirs = configSearchPath(cfg.identityId, cfg.dataDir);
|
|
2488
|
-
const
|
|
3801
|
+
const reportSearchKeyGate = searchKeyGateReporter(log, (p) => p === keyFilePath("brave"));
|
|
3802
|
+
const hasSearchKey = () => hasReadableKeyFile(
|
|
3803
|
+
[keyFilePath("brave"), keyFilePath("tavily")],
|
|
3804
|
+
(p) => readFileSync9(p, "utf8"),
|
|
3805
|
+
reportSearchKeyGate
|
|
3806
|
+
);
|
|
3807
|
+
const reportLlmSourceUnresolved = failureEpisodeReporter(
|
|
3808
|
+
(e) => log("warn", "runtime.llm_source_unresolved", {}, e)
|
|
3809
|
+
);
|
|
2489
3810
|
const hasLlmKey = () => {
|
|
2490
3811
|
try {
|
|
2491
|
-
|
|
2492
|
-
|
|
3812
|
+
const source = resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps());
|
|
3813
|
+
reportLlmSourceUnresolved.succeeded();
|
|
3814
|
+
return hasLlmKeyFor(source);
|
|
3815
|
+
} catch (e) {
|
|
3816
|
+
reportLlmSourceUnresolved.failed(e);
|
|
2493
3817
|
return false;
|
|
2494
3818
|
}
|
|
2495
3819
|
};
|
|
2496
|
-
|
|
3820
|
+
quarantineLegacyMemory(managedDir, cfg.dataDir);
|
|
2497
3821
|
const reportLocalConfig = createLocalConfigReporter(managedDir, cfg.dataDir, () => /* @__PURE__ */ new Date());
|
|
2498
3822
|
reportLocalConfig({ hasSearchKey: hasSearchKey(), hasLlmKey: hasLlmKey() });
|
|
2499
3823
|
const builtinCtx = {
|
|
@@ -2508,49 +3832,54 @@ async function main() {
|
|
|
2508
3832
|
now: () => /* @__PURE__ */ new Date(),
|
|
2509
3833
|
log
|
|
2510
3834
|
};
|
|
2511
|
-
const
|
|
3835
|
+
const sideEffectLedger = new SideEffectLedger(join8(cfg.dataDir, "side-effect-ledger.sqlite"));
|
|
3836
|
+
const skills = new SkillInvoker(cfg.skillsDir, builtinCtx, sideEffectLedger);
|
|
2512
3837
|
skills.loadSkills();
|
|
2513
3838
|
const policyFor = () => resolveToolPolicy(cfg.toolGateEnv, currentSkillsConfig().tools, {
|
|
2514
3839
|
default: cfg.toolGate.default,
|
|
2515
3840
|
allow: DEFAULT_TOOL_ALLOW
|
|
2516
3841
|
});
|
|
2517
3842
|
const gate = createDynamicToolGate(policyFor);
|
|
2518
|
-
const
|
|
3843
|
+
const installationEligibleTools = () => {
|
|
2519
3844
|
const sc = currentSkillsConfig();
|
|
2520
3845
|
const g = createLocalToolGate(policyFor());
|
|
2521
3846
|
const searchKey = hasSearchKey();
|
|
2522
3847
|
reportLocalConfig({ hasSearchKey: searchKey, hasLlmKey: hasLlmKey() });
|
|
2523
3848
|
return skills.available(requiresEnvFrom(sc, searchKey)).filter((t) => g.gate(t.function.name).allowed);
|
|
2524
3849
|
};
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
}
|
|
3850
|
+
const unavailableCount = skills.unavailable(requiresEnvFrom(currentSkillsConfig(), hasSearchKey())).length;
|
|
3851
|
+
if (unavailableCount > 0) log("info", "runtime.skills_unavailable", { count: unavailableCount });
|
|
2528
3852
|
const plane = createSecureComsDataPlane(
|
|
2529
|
-
|
|
3853
|
+
// Final-wave I2: the daily-limit reply is a notice, not an agent turn.
|
|
3854
|
+
{ identityId: cfg.identityId, log, noticeReplies: /* @__PURE__ */ new Set([DAILY_LIMIT_RESPONSE]) },
|
|
2530
3855
|
buildHandler({
|
|
2531
3856
|
llm,
|
|
2532
3857
|
memoryFor,
|
|
2533
3858
|
skills,
|
|
2534
3859
|
gate,
|
|
2535
3860
|
log,
|
|
2536
|
-
// All of these are read PER TURN: a persona edit, a
|
|
2537
|
-
//
|
|
2538
|
-
//
|
|
3861
|
+
// All of these are read PER TURN: a persona edit, a key file dropped in,
|
|
3862
|
+
// or configuration saved in the web app takes effect on the next message,
|
|
3863
|
+
// with no restart.
|
|
2539
3864
|
systemPromptFor: () => systemPromptFor(configDirs, DEFAULT_SYSTEM_PROMPT),
|
|
2540
|
-
|
|
2541
|
-
tools: offeredTools
|
|
3865
|
+
tools: installationEligibleTools
|
|
2542
3866
|
})
|
|
2543
3867
|
);
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
3868
|
+
log("info", "runtime.started", {
|
|
3869
|
+
configured_skills: installationEligibleTools().length,
|
|
3870
|
+
tool_gate_default_deny: cfg.toolGate.default === "deny"
|
|
3871
|
+
});
|
|
2547
3872
|
await plane.start();
|
|
2548
3873
|
const shutdown = () => {
|
|
2549
|
-
log("info", "
|
|
3874
|
+
log("info", "runtime.shutting_down");
|
|
2550
3875
|
plane.stop();
|
|
3876
|
+
sideEffectLedger.close();
|
|
2551
3877
|
process.exit(0);
|
|
2552
3878
|
};
|
|
2553
3879
|
process.on("SIGINT", shutdown);
|
|
2554
3880
|
process.on("SIGTERM", shutdown);
|
|
2555
3881
|
}
|
|
2556
|
-
void main()
|
|
3882
|
+
void main().catch((error) => {
|
|
3883
|
+
writeServiceLog("error", "runtime.fatal", {}, error);
|
|
3884
|
+
process.exit(1);
|
|
3885
|
+
});
|