@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/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 dirname4, join as join9 } from "node:path";
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 existsSync7, mkdirSync as mkdirSync3, realpathSync, rmSync, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
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(dirname(fileURLToPath(import.meta.url)), "..", "skills");
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 dirname2 } from "node:path";
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(dirname2(cfg.plistPath));
393
- deps.mkdirp(dirname2(cfg.logPath));
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(dirname2(cfg.unitPath));
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
- memories = [];
701
- dataDir;
702
- constructor(dataDir) {
703
- this.dataDir = dataDir;
704
- this.loadHistory();
705
- this.loadMemories();
706
- }
707
- /** Add a message to conversation history. */
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 conversation history. */
1033
+ /** Clear this process-local view; durable deletion belongs to the bridge. */
720
1034
  clear() {
721
1035
  this.history = [];
722
- this.saveHistory();
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((w) => w.length >= 3);
1040
+ const queryWords = query.toLowerCase().split(/\W+/).filter((word) => word.length >= 3);
732
1041
  if (queryWords.length === 0) return null;
733
- const scored = this.memories.map((m) => {
734
- const contentLower = m.content.toLowerCase();
1042
+ const scored = this.facts.map((content) => {
1043
+ const contentLower = content.toLowerCase();
735
1044
  const score = queryWords.reduce(
736
- (acc, w) => contentLower.includes(w) ? acc + 1 : acc,
1045
+ (acc, word) => contentLower.includes(word) ? acc + 1 : acc,
737
1046
  0
738
1047
  );
739
- return { memory: m, score };
740
- }).filter((s) => s.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);
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
- ${memories}`;
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 readFileSync6, readdirSync, existsSync as existsSync5, statSync } from "node:fs";
804
- import { join as join6 } from "node:path";
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 existsSync3, readFileSync as readFileSync2 } from "node:fs";
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 as existsSync2 } from "node:fs";
1236
+ import { existsSync } from "node:fs";
964
1237
  import { homedir as homedir2 } from "node:os";
965
- import { join as join4 } from "node:path";
1238
+ import { join as join3 } from "node:path";
966
1239
  function managedConfigDir(identityId, home = homedir2()) {
967
- return join4(home, ".securecoms", "runtime-config", identityId);
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 = join4(dir, name);
975
- if (existsSync2(path)) return path;
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 (!existsSync3(path)) return empty();
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
- console.warn(`[skills-config] ignoring ${path}: ${e instanceof Error ? e.message : String(e)}`);
1263
+ writeServiceLog("warn", "skills_config.parse_failed", {}, e);
991
1264
  return empty();
992
1265
  }
993
1266
  if (typeof raw !== "object" || raw === null) {
994
- console.warn(`[skills-config] ignoring ${path}: expected a JSON object`);
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
- console.warn(`[skills-config] endpoint "${name}" in ${path} has no baseUrl; skipped`);
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 console.warn(`[skills-config] database "${name}" in ${path} needs sqlite or postgresUrlFile; skipped`);
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
- console.warn(`[skills-config] search in ${path} has an unknown provider; skipped`);
1302
+ writeServiceLog("warn", "skills_config.search_provider_invalid");
1030
1303
  } else if (typeof s.keyFile !== "string" || s.keyFile === "") {
1031
- console.warn(`[skills-config] search in ${path} has no keyFile; skipped`);
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 { lookup } from "node:dns/promises";
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) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
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
- addresses = await resolve3(host);
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 timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1e4);
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 readFileSync5 } from "node:fs";
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
- function envelope(columns, rows, truncated) {
1418
- let kept = rows;
1419
- let cut = truncated;
1420
- let out = JSON.stringify({ columns, rows: kept, truncated: cut });
1421
- while (out.length > FETCH_TEXT_CAP && kept.length > 0) {
1422
- kept = kept.slice(0, Math.floor(kept.length / 2));
1423
- cut = true;
1424
- out = JSON.stringify({ columns, rows: kept, truncated: cut });
1425
- }
1426
- return capResult(out);
1427
- }
1428
- var sqlQuery = async (args, ctx) => {
1429
- const name = typeof args.name === "string" ? args.name : "";
1430
- const db = ctx.skillsConfig.databases[name];
1431
- if (!db) {
1432
- return toolError(
1433
- `unknown database "${name}": configured databases are ${Object.keys(ctx.skillsConfig.databases).join(", ") || "(none)"}`
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 { DatabaseSync } = await import("node:sqlite");
1445
- const handle = new DatabaseSync(db.sqlite, { readOnly: true, timeout: SQL_TIMEOUT_MS });
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: readFileSync5(db.postgresUrlFile, "utf8").trim(),
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
- await client.connect();
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
- await client.query("ROLLBACK").catch(() => {
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", `web_search via brave (${count} results)`);
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", `web_search via tavily (${count} results)`);
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
- return s.length <= limit ? s : s.slice(0, limit) + TRUNCATION_MARKER;
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
- return JSON.stringify({ error: "tool_failed", message });
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
- remember
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 (!existsSync5(this.skillsDir)) {
1604
- console.warn(`[SkillInvoker] Skills directory not found: ${this.skillsDir}`);
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(join6(this.skillsDir, entry.name));
2209
+ flat.push(join4(this.skillsDir, entry.name));
1611
2210
  } else if (entry.isDirectory()) {
1612
- const m = join6(this.skillsDir, entry.name, "manifest.json");
1613
- if (existsSync5(m) && statSync(m).isFile()) manifests.push(m);
1614
- const s = join6(this.skillsDir, entry.name, "SKILL.md");
1615
- if (existsSync5(s) && statSync(s).isFile()) skillMds.push(s);
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
- console.log(`[SkillInvoker] Loaded ${this.skills.size} skills`);
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) console.warn(`[SkillInvoker] Duplicate skill "${manifest.name}": ${path} overrides ${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(readFileSync6(path, "utf-8"), path), path);
2230
+ this.register(parseSkillMd(readFileSync5(path, "utf-8"), path), path);
1632
2231
  } catch (err) {
1633
- console.warn(`[SkillInvoker] Skipping ${path}: ${err instanceof Error ? err.message : String(err)}`);
2232
+ writeServiceLog("warn", "skills.skill_md_invalid", {}, err);
1634
2233
  }
1635
2234
  }
1636
2235
  loadManifestFile(path) {
1637
2236
  try {
1638
- const raw = readFileSync6(path, "utf-8");
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
- console.warn(
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
- console.warn(`[SkillInvoker] Skipping manifest ${path}: missing "${missing}"`);
2247
+ writeServiceLog("warn", "skills.manifest_field_missing", { name_missing: missing === "name" });
1651
2248
  }
1652
2249
  } catch (err) {
1653
- console.warn(`[SkillInvoker] Failed to load skill ${path}:`, err);
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
- const handler = skill.handler;
1696
- if (handler.startsWith("builtin:")) {
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 (handler.startsWith("script:")) {
1700
- return this.invokeScript(handler.slice(7), args);
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
- return JSON.stringify({ error: `Unknown handler type: ${handler}` });
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 capResult(await builtin(args, this.ctx));
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 JSON.stringify({ error: `Script not found or not executable: ${scriptPath}` });
2379
+ return toolError(`Script not found or not executable: ${scriptPath}`);
1734
2380
  }
1735
- const { execSync } = await import("node:child_process");
1736
- try {
1737
- const result = execSync(resolved, {
1738
- input: JSON.stringify(args),
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
- return result;
1746
- } catch (err) {
1747
- return JSON.stringify({
1748
- error: `Script execution failed: ${err instanceof Error ? err.message : String(err)}`
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 readFileSync7 } from "node:fs";
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 join7 } from "node:path";
1805
- var SEEN_MAX = 5e3;
1806
- function errText(e) {
1807
- return e instanceof Error ? e.message : String(e);
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 ?? join7(homedir3(), ".securecoms", "connections");
1848
- this.connectionPath = join7(dir, `${cfg.identityId}.json`);
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 ?? ((level, msg) => {
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", `error closing socket: ${errText(e)}`);
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", errText(e));
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(readFileSync7(this.connectionPath, "utf8"));
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 = readFileSync7(conn.token_file, "utf8").trim();
1954
- const health = await (await fetch(`${httpBase}/healthz`)).json();
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(`socket error: ${errText(e?.message ?? e)}`));
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", `attached to ${conn.bridge_url}`);
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", `frame handling failed: ${errText(e)}`);
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
- async handleFrame(ws, data) {
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", `ignoring unparseable frame: ${errText(e)}`);
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
- this.log("error", `daemon error frame: ${String(frame.error)}: ${String(frame.message)}`);
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", "ignoring message frame with no message_id");
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
- this.remember(messageId);
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
- ws.send(JSON.stringify({ type: "send", channel_id: channelId, text: String(reply) }));
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", `handler threw: ${errText(e)}`);
2939
+ this.log("error", "transport.handler_failed", {}, e);
2940
+ this.failTurn(ws, messageId, "turn_failed");
2037
2941
  } finally {
2038
- try {
2039
- ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
2040
- } catch (e) {
2041
- this.log("error", `could not ack ${messageId}: ${errText(e)}`);
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, context);
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 = JSON.stringify({
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
- return async (m) => {
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
- log("info", `message from ${m.senderId.slice(0, 8)}: ${text.slice(0, 80)}`);
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
- call,
2111
- verdict: gate.gate(call.function.name)
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", `tool call denied: ${v.call.function.name} \u2014 ${v.verdict.reason}`);
3220
+ log("warn", "turn.tool_call_denied");
2117
3221
  }
2118
3222
  }
2119
- const executed = await runToolCalls(allowed, skills, { roomId: m.channelId });
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: capResult(done.content), tool_call_id: done.tool_call_id });
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", `no final answer after ${maxIterations} iterations; dropping the turn`);
3251
+ log("warn", "turn.no_final_answer", { iterations: maxIterations });
2144
3252
  return null;
2145
3253
  }
2146
3254
  memory.addMessage({ role: "assistant", content: response });
2147
- if (response.length > 50) {
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 existsSync6, readFileSync as readFileSync8, renameSync, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
2164
- import { dirname as dirname3, join as join8 } from "node:path";
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 join8(dirname3(fileURLToPath2(import.meta.url)), "..", "persona");
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
- console.warn(`[persona] cannot read ${path}; ignoring it`);
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
- console.warn(`[persona] ${path} is larger than ${PERSONA_MAX_BYTES} bytes; truncating`);
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 (!existsSync6(managedDir)) return false;
3464
+ if (!existsSync5(managedDir)) return false;
2214
3465
  const persona = {};
2215
3466
  for (const { file } of PERSONA_SOURCES) {
2216
- const path2 = join8(dataDir, file);
2217
- if (!existsSync6(path2)) continue;
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/1",
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 = join8(managedDir, "local-config.json");
3484
+ const path = join7(managedDir, "local-config.json");
2231
3485
  const tmp = `${path}.${process.pid}.tmp`;
2232
3486
  try {
2233
- writeFileSync3(tmp, `${JSON.stringify(doc, null, 2)}
3487
+ writeFileSync2(tmp, `${JSON.stringify(doc, null, 2)}
2234
3488
  `, { mode: 384 });
2235
- renameSync(tmp, path);
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 = join8(dataDir, file);
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(join8(defaultsDir, file), "utf8"));
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
- if (lastWritten !== null && lastWritten.hasSearchKey === flags.hasSearchKey && lastWritten.hasLlmKey === flags.hasLlmKey) {
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) lastWritten = { ...flags };
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
- if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
2281
- console.error(w.stack ?? `${w.name}: ${w.message}`);
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(join9(dirname4(dirname4(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
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, msg) => {
3557
+ return (level, event, fields, error) => {
2295
3558
  if (LEVEL_RANK[level] < floor) return;
2296
- const line = `[runtime] ${level}: ${msg}`;
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) => existsSync7(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) => writeFileSync4(p, data, { mode: 420 }),
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) => existsSync7(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) => existsSync7(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) => writeFileSync4(p, d, { mode: 384 }),
2434
- (p) => existsSync7(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
- console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
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 llm = buildLlmShim(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps());
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(join9(cfg.dataDir, "channels", safeChannelSegment(channelId)));
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", `cannot read the ${which} key file ${path}`);
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 hasSearchKey = () => hasReadableKeyFile([keyFilePath("brave"), keyFilePath("tavily")], (p) => readFileSync9(p, "utf8"));
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
- return hasLlmKeyFor(resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()));
2492
- } catch {
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
- migrateMemoryIntoManaged(managedDir, cfg.dataDir);
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 skills = new SkillInvoker(cfg.skillsDir, builtinCtx);
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 offeredTools = () => {
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
- for (const held of skills.unavailable(requiresEnvFrom(currentSkillsConfig(), hasSearchKey()))) {
2526
- console.log(`unavailable: ${held.name} (missing ${held.missing.join(", ")})`);
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
- { identityId: cfg.identityId, log: (level, msg) => log(level, msg) },
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 new remembered fact, a
2537
- // key file dropped in, or configuration saved in the web app takes effect
2538
- // on the next message, with no restart.
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
- longTermMemory: () => readLongTermMemory(configDirs),
2541
- tools: offeredTools
3865
+ tools: installationEligibleTools
2542
3866
  })
2543
3867
  );
2544
- console.log(
2545
- `${bootLine(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps())}, ${offeredTools().length} skills, tool gate default=${cfg.toolGate.default}`
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", "shutting down");
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
+ });