@ethosagent/core 0.2.6 → 0.3.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/index.js CHANGED
@@ -1,10 +1,50 @@
1
1
  // src/agent-loop.ts
2
+ import { createHash as createHash3, randomUUID } from "crypto";
3
+ import { homedir as homedir2 } from "os";
4
+ import { join as join3 } from "path";
5
+ import {
6
+ DOWNGRADE_REJECTION_MESSAGE,
7
+ INJECTION_DEFENSE_PRELUDE,
8
+ resolveDowngradedTools,
9
+ shortPatternCheck,
10
+ wrapUntrusted
11
+ } from "@ethosagent/safety-injection";
12
+
13
+ // ../storage-fs/src/default-deny.ts
2
14
  import { homedir } from "os";
3
- import { join } from "path";
15
+ function defaultAlwaysDeny() {
16
+ const home = homedir();
17
+ return [
18
+ `${home}/.ssh`,
19
+ `${home}/.aws/credentials`,
20
+ `${home}/.aws/config`,
21
+ `${home}/.gnupg`,
22
+ `${home}/.netrc`,
23
+ `${home}/.bash_history`,
24
+ `${home}/.zsh_history`,
25
+ `${home}/.psql_history`,
26
+ `${home}/.mysql_history`,
27
+ `${home}/.npmrc`,
28
+ `${home}/Library/Keychains`,
29
+ "/etc/passwd",
30
+ "/etc/shadow",
31
+ "/etc/sudoers",
32
+ "/etc/sudoers.d",
33
+ "/root",
34
+ "/boot",
35
+ "/sys",
36
+ "/proc/sys"
37
+ ];
38
+ }
39
+
40
+ // ../storage-fs/src/fs-attachment-cache.ts
41
+ import { createHash } from "crypto";
42
+ import { join, resolve, sep } from "path";
4
43
 
5
44
  // ../storage-fs/src/fs-storage.ts
6
45
  import {
7
46
  appendFile,
47
+ chmod,
8
48
  mkdir,
9
49
  readdir,
10
50
  readFile,
@@ -14,6 +54,9 @@ import {
14
54
  writeFile
15
55
  } from "fs/promises";
16
56
 
57
+ // ../storage-fs/src/in-memory-attachment-cache.ts
58
+ import { createHash as createHash2 } from "crypto";
59
+
17
60
  // ../storage-fs/src/in-memory-storage.ts
18
61
  import { dirname } from "path";
19
62
 
@@ -26,11 +69,16 @@ var ScopedStorage = class {
26
69
  this.inner = inner;
27
70
  this.readPrefixes = scope.read.map(normalizePrefix);
28
71
  this.writePrefixes = scope.write.map(normalizePrefix);
72
+ this.denyPrefixes = (scope.alwaysDeny ?? []).map(normalizePrefix);
29
73
  }
30
74
  inner;
31
75
  readPrefixes;
32
76
  writePrefixes;
77
+ denyPrefixes;
33
78
  check(path, kind) {
79
+ if (isPathAllowed(path, this.denyPrefixes)) {
80
+ throw new BoundaryError(kind, path, this.denyPrefixes, "always-deny floor");
81
+ }
34
82
  const allowed = kind === "read" ? this.readPrefixes : this.writePrefixes;
35
83
  if (!isPathAllowed(path, allowed)) {
36
84
  throw new BoundaryError(kind, path, allowed);
@@ -40,6 +88,10 @@ var ScopedStorage = class {
40
88
  this.check(path, "read");
41
89
  return this.inner.read(path);
42
90
  }
91
+ async readBytes(path) {
92
+ this.check(path, "read");
93
+ return this.inner.readBytes(path);
94
+ }
43
95
  async exists(path) {
44
96
  this.check(path, "read");
45
97
  return this.inner.exists(path);
@@ -81,6 +133,10 @@ var ScopedStorage = class {
81
133
  this.check(to, "write");
82
134
  return this.inner.rename(from, to);
83
135
  }
136
+ async chmod(path, mode) {
137
+ this.check(path, "write");
138
+ return this.inner.chmod(path, mode);
139
+ }
84
140
  };
85
141
  function normalizePrefix(prefix) {
86
142
  return prefix;
@@ -96,10 +152,206 @@ function isPathAllowed(path, prefixes) {
96
152
  return false;
97
153
  }
98
154
 
155
+ // ../storage-fs/src/secrets.ts
156
+ import { dirname as dirname2, join as join2 } from "path";
157
+
158
+ // src/attachment-annotation.ts
159
+ function formatSize(bytes) {
160
+ if (bytes === void 0) return "";
161
+ if (bytes < 1024) return `${bytes}B`;
162
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
163
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
164
+ }
165
+ function escapeXmlAttr(s) {
166
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
167
+ }
168
+ function buildAttachmentAnnotation(attachments) {
169
+ if (attachments.length === 0) return "";
170
+ const lines = attachments.map((a) => {
171
+ const parts = [`ref="${escapeXmlAttr(a.ref)}"`, `mime="${escapeXmlAttr(a.mimeType)}"`];
172
+ if (a.sizeBytes !== void 0) parts.push(`size="${formatSize(a.sizeBytes)}"`);
173
+ if (a.filename) parts.push(`filename="${escapeXmlAttr(a.filename)}"`);
174
+ return ` <file ${parts.join(" ")} />`;
175
+ });
176
+ return `<attachments>
177
+ ${lines.join("\n")}
178
+ </attachments>`;
179
+ }
180
+
181
+ // src/context-engines/token-estimator.ts
182
+ var CHARS_PER_TOKEN = 4;
183
+ function estimateTokens(text) {
184
+ if (!text) return 0;
185
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
186
+ }
187
+ function messageContentChars(content) {
188
+ if (typeof content === "string") return content.length;
189
+ let total = 0;
190
+ for (const block of content) {
191
+ if (block.type === "text") total += block.text.length;
192
+ else if (block.type === "tool_use")
193
+ total += JSON.stringify(block.input).length + block.name.length;
194
+ else if (block.type === "tool_result") total += block.content.length;
195
+ }
196
+ return total;
197
+ }
198
+ function estimateMessageTokens(message) {
199
+ return Math.ceil(messageContentChars(message.content) / CHARS_PER_TOKEN);
200
+ }
201
+ function estimateMessagesTokens(input) {
202
+ if (typeof input === "string") return estimateTokens(input);
203
+ if (Array.isArray(input)) {
204
+ let total = 0;
205
+ for (const m of input) total += estimateMessageTokens(m);
206
+ return total;
207
+ }
208
+ return estimateMessageTokens(input);
209
+ }
210
+
211
+ // src/context-engines/drop-oldest.ts
212
+ var DropOldestEngine = class {
213
+ name = "drop_oldest";
214
+ async compact(opts) {
215
+ const options = opts.personality.context_engine_options ?? {};
216
+ const preserveFront = Math.max(0, options.preserve_first_n_turns ?? 0);
217
+ const target = opts.targetTokens;
218
+ const head = opts.messages.slice(0, preserveFront);
219
+ const tail = opts.messages.slice(preserveFront);
220
+ let total = estimateMessagesTokens(opts.currentSystem) + estimateMessagesTokens(head) + estimateMessagesTokens(tail);
221
+ let dropped = 0;
222
+ while (total > target && tail.length > 0) {
223
+ const removed = tail.shift();
224
+ if (!removed) break;
225
+ total -= estimateMessagesTokens(removed);
226
+ dropped++;
227
+ }
228
+ return {
229
+ messages: [...head, ...tail],
230
+ notes: dropped === 0 ? "no compaction needed" : `dropped ${dropped} oldest message(s)`
231
+ };
232
+ }
233
+ };
234
+
235
+ // src/context-engines/reference-preserving.ts
236
+ var REFERENCE_PATTERN = /[A-Za-z_][\w./-]*\.(?:ts|tsx|js|jsx|py|go|rs|java|md|yaml|yml|json)\b|\b[A-Z][A-Za-z0-9]+(?:\.[A-Za-z0-9_]+)+\b/;
237
+ function messageText(content) {
238
+ if (typeof content === "string") return content;
239
+ return content.map((b) => {
240
+ if (b.type === "text") return b.text;
241
+ if (b.type === "tool_result") return b.content;
242
+ if (b.type === "tool_use") return `${b.name} ${JSON.stringify(b.input)}`;
243
+ return "";
244
+ }).join(" ");
245
+ }
246
+ function carriesReference(message) {
247
+ return REFERENCE_PATTERN.test(messageText(message.content));
248
+ }
249
+ var ReferencePreservingEngine = class {
250
+ name = "reference_preserving";
251
+ async compact(opts) {
252
+ const target = opts.targetTokens;
253
+ const systemTokens = estimateMessagesTokens(opts.currentSystem);
254
+ const tailKeep = Math.min(4, opts.messages.length);
255
+ const head = opts.messages.slice(0, opts.messages.length - tailKeep);
256
+ const tail = opts.messages.slice(opts.messages.length - tailKeep);
257
+ let total = systemTokens + estimateMessagesTokens([...head, ...tail]);
258
+ const kept = [];
259
+ let droppedProse = 0;
260
+ for (const m of head) {
261
+ if (total <= target) {
262
+ kept.push(m);
263
+ continue;
264
+ }
265
+ if (carriesReference(m)) {
266
+ kept.push(m);
267
+ } else {
268
+ total -= estimateMessageTokens(m);
269
+ droppedProse++;
270
+ }
271
+ }
272
+ while (total > target && kept.length > 0) {
273
+ const removed = kept.shift();
274
+ if (!removed) break;
275
+ total -= estimateMessageTokens(removed);
276
+ }
277
+ const note = droppedProse > 0 ? `dropped ${droppedProse} prose message(s); kept ${kept.length} reference-bearing` : "no prose messages to drop";
278
+ return { messages: [...kept, ...tail], notes: note };
279
+ }
280
+ };
281
+
282
+ // src/context-engines/semantic-summary.ts
283
+ var SemanticSummaryEngine = class {
284
+ name = "semantic_summary";
285
+ summarize;
286
+ constructor(opts = {}) {
287
+ if (opts.summarize) this.summarize = opts.summarize;
288
+ }
289
+ async compact(opts) {
290
+ const options = opts.personality.context_engine_options ?? {};
291
+ const preserveFront = Math.max(0, options.preserve_first_n_turns ?? 1);
292
+ const summaryTarget = options.summary_target_tokens ?? 800;
293
+ const target = opts.targetTokens;
294
+ const front = opts.messages.slice(0, preserveFront);
295
+ const tailKeep = Math.min(6, Math.max(0, opts.messages.length - preserveFront));
296
+ const middle = opts.messages.slice(preserveFront, opts.messages.length - tailKeep);
297
+ const tail = opts.messages.slice(opts.messages.length - tailKeep);
298
+ if (middle.length === 0) {
299
+ return { messages: opts.messages, notes: "nothing to summarize" };
300
+ }
301
+ const middleTokens = estimateMessagesTokens(middle);
302
+ const totalNow = estimateMessagesTokens(opts.currentSystem) + estimateMessagesTokens([...front, ...tail]) + middleTokens;
303
+ if (totalNow <= target) {
304
+ return { messages: opts.messages, notes: "no compaction needed" };
305
+ }
306
+ let summaryText;
307
+ if (this.summarize) {
308
+ summaryText = await this.summarize(middle, summaryTarget);
309
+ } else {
310
+ summaryText = `[summary] ${middle.length} earlier message(s) elided to fit context budget.`;
311
+ }
312
+ const summaryMessage = {
313
+ role: "assistant",
314
+ content: [{ type: "text", text: summaryText }]
315
+ };
316
+ const cacheBreakpoints = [];
317
+ if (front.length > 0) cacheBreakpoints.push(front.length - 1);
318
+ cacheBreakpoints.push(front.length);
319
+ return {
320
+ messages: [...front, summaryMessage, ...tail],
321
+ notes: `summarized ${middle.length} message(s) \u2192 ${estimateMessageTokens(summaryMessage)} tokens`,
322
+ summaryText,
323
+ cacheBreakpoints
324
+ };
325
+ }
326
+ };
327
+
328
+ // src/context-engines/registry.ts
329
+ var DefaultContextEngineRegistry = class {
330
+ engines = /* @__PURE__ */ new Map();
331
+ constructor(opts = {}) {
332
+ this.register(new DropOldestEngine());
333
+ this.register(
334
+ opts.summarize ? new SemanticSummaryEngine({ summarize: opts.summarize }) : new SemanticSummaryEngine()
335
+ );
336
+ this.register(new ReferencePreservingEngine());
337
+ }
338
+ register(engine) {
339
+ this.engines.set(engine.name, engine);
340
+ }
341
+ get(name) {
342
+ return this.engines.get(name);
343
+ }
344
+ names() {
345
+ return [...this.engines.keys()];
346
+ }
347
+ };
348
+
99
349
  // src/defaults/in-memory-session.ts
100
350
  var InMemorySessionStore = class {
101
351
  sessions = /* @__PURE__ */ new Map();
102
352
  messages = /* @__PURE__ */ new Map();
353
+ compressions = /* @__PURE__ */ new Map();
354
+ turnState = /* @__PURE__ */ new Map();
103
355
  idCounter = 0;
104
356
  async createSession(data) {
105
357
  const session = {
@@ -129,6 +381,8 @@ var InMemorySessionStore = class {
129
381
  async deleteSession(id) {
130
382
  this.sessions.delete(id);
131
383
  this.messages.delete(id);
384
+ this.compressions.delete(id);
385
+ this.turnState.delete(id);
132
386
  }
133
387
  async listSessions(filter) {
134
388
  let results = [...this.sessions.values()];
@@ -193,6 +447,31 @@ var InMemorySessionStore = class {
193
447
  results.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
194
448
  return results.slice(0, options?.limit ?? 20);
195
449
  }
450
+ async recordCompression(event) {
451
+ const full = {
452
+ ...event,
453
+ id: `compression_${++this.idCounter}`,
454
+ createdAt: /* @__PURE__ */ new Date()
455
+ };
456
+ const list = this.compressions.get(event.sessionId) ?? [];
457
+ list.push(full);
458
+ this.compressions.set(event.sessionId, list);
459
+ return full;
460
+ }
461
+ async listCompressions(sessionId) {
462
+ return [...this.compressions.get(sessionId) ?? []];
463
+ }
464
+ async recordTurnStart(sessionId) {
465
+ const state = this.turnState.get(sessionId) ?? { turnCount: 0, lastCompactionTurn: 0 };
466
+ state.turnCount += 1;
467
+ this.turnState.set(sessionId, state);
468
+ return { turnNumber: state.turnCount, lastCompactionTurn: state.lastCompactionTurn };
469
+ }
470
+ async recordCompactionTurn(sessionId, turnNumber) {
471
+ const state = this.turnState.get(sessionId) ?? { turnCount: 0, lastCompactionTurn: 0 };
472
+ state.lastCompactionTurn = turnNumber;
473
+ this.turnState.set(sessionId, state);
474
+ }
196
475
  async pruneOldSessions(olderThan) {
197
476
  let count = 0;
198
477
  for (const [id, session] of this.sessions.entries()) {
@@ -213,7 +492,16 @@ var NoopMemoryProvider = class {
213
492
  async prefetch(_ctx) {
214
493
  return null;
215
494
  }
216
- async sync(_ctx, _updates) {
495
+ async read(_key, _ctx) {
496
+ return null;
497
+ }
498
+ async search(_query, _ctx, _opts) {
499
+ return [];
500
+ }
501
+ async sync(_updates, _ctx) {
502
+ }
503
+ async list(_ctx, _opts) {
504
+ return [];
217
505
  }
218
506
  };
219
507
 
@@ -361,7 +649,345 @@ var DefaultHookRegistry = class {
361
649
  }
362
650
  };
363
651
 
652
+ // src/scoped/scoped-attachments.ts
653
+ var ScopedAttachmentsImpl = class {
654
+ attachments;
655
+ cache;
656
+ constructor(allAttachments, kinds, cache) {
657
+ this.cache = cache;
658
+ this.attachments = kinds === "*" ? allAttachments : allAttachments.filter((a) => kinds.includes(a.type));
659
+ }
660
+ list() {
661
+ return this.attachments;
662
+ }
663
+ async open(att) {
664
+ if (!this.attachments.some((a) => a.ref === att.ref)) {
665
+ throw new Error(`Attachment ref "${att.ref}" is not in the scoped list for this tool`);
666
+ }
667
+ if (att.url.startsWith("file://")) {
668
+ return { path: this.cache.resolveLocalPath(att.url) };
669
+ }
670
+ throw new Error(`Unsupported URL scheme in attachment: ${att.url}`);
671
+ }
672
+ async openByRef(ref) {
673
+ const att = this.attachments.find((a) => a.ref === ref);
674
+ if (!att) throw new Error(`No attachment with ref "${ref}"`);
675
+ return this.open(att);
676
+ }
677
+ };
678
+
679
+ // src/scoped/scoped-fetch.ts
680
+ import { safeFetch } from "@ethosagent/safety-network";
681
+ var ScopedFetchImpl = class {
682
+ constructor(allowedHosts, policy, testSeam = {}) {
683
+ this.allowedHosts = allowedHosts;
684
+ this.policy = policy;
685
+ this.testSeam = testSeam;
686
+ }
687
+ allowedHosts;
688
+ policy;
689
+ testSeam;
690
+ async fetch(url, init) {
691
+ const parsed = new URL(url);
692
+ if (!this.isHostAllowed(parsed.hostname)) {
693
+ throw new Error(`HOST_NOT_ALLOWED: ${parsed.hostname} is not in the declared allowedHosts`);
694
+ }
695
+ const { redirect: _redirect, ...rest } = init ?? {};
696
+ const result = await safeFetch(parsed.toString(), {
697
+ policy: this.policy,
698
+ init: rest,
699
+ fetchImpl: this.testSeam.fetchImpl,
700
+ resolveHost: this.testSeam.resolveHost
701
+ });
702
+ if (!result.ok) {
703
+ throw new Error(`HOST_NOT_ALLOWED: ${result.reason}`);
704
+ }
705
+ return result.response;
706
+ }
707
+ isHostAllowed(hostname) {
708
+ if (this.allowedHosts.has("*")) return true;
709
+ if (this.allowedHosts.has(hostname)) return true;
710
+ for (const pattern of this.allowedHosts) {
711
+ if (pattern.startsWith("*.")) {
712
+ const suffix = pattern.slice(1);
713
+ if (hostname.endsWith(suffix) && hostname.length > suffix.length) return true;
714
+ }
715
+ }
716
+ return false;
717
+ }
718
+ };
719
+
720
+ // src/scoped/scoped-fs.ts
721
+ import { normalize, resolve as resolve2 } from "path";
722
+ var ScopedFsImpl = class {
723
+ constructor(storage, readPaths, writePaths) {
724
+ this.storage = storage;
725
+ this.readPaths = readPaths;
726
+ this.writePaths = writePaths;
727
+ this.denyPaths = defaultAlwaysDeny().map((p) => normalize(resolve2(p)));
728
+ }
729
+ storage;
730
+ readPaths;
731
+ writePaths;
732
+ denyPaths;
733
+ async read(path) {
734
+ this.checkReach(path, this.readPaths, "read");
735
+ const content = await this.storage.read(path);
736
+ if (content === null) throw new Error(`File not found: ${path}`);
737
+ return content;
738
+ }
739
+ async readBytes(path) {
740
+ this.checkReach(path, this.readPaths, "read");
741
+ const bytes = await this.storage.readBytes(path);
742
+ if (bytes === null) throw new Error(`File not found: ${path}`);
743
+ return bytes;
744
+ }
745
+ async write(path, content) {
746
+ this.checkReach(path, this.writePaths, "write");
747
+ await this.storage.write(path, content);
748
+ }
749
+ async exists(path) {
750
+ this.checkReach(path, this.readPaths, "read");
751
+ return this.storage.exists(path);
752
+ }
753
+ async list(path) {
754
+ this.checkReach(path, this.readPaths, "read");
755
+ return this.storage.list(path);
756
+ }
757
+ async mtime(path) {
758
+ this.checkReach(path, this.readPaths, "read");
759
+ return this.storage.mtime(path);
760
+ }
761
+ async mkdir(dir) {
762
+ this.checkReach(dir, this.writePaths, "write");
763
+ await this.storage.mkdir(dir);
764
+ }
765
+ async listEntries(dir) {
766
+ this.checkReach(dir, this.readPaths, "read");
767
+ return this.storage.listEntries(dir);
768
+ }
769
+ checkReach(path, allowed, kind) {
770
+ const canonical = normalize(resolve2(path));
771
+ for (const deny of this.denyPaths) {
772
+ if (canonical === deny || canonical.startsWith(deny.endsWith("/") ? deny : `${deny}/`)) {
773
+ throw new Error(`PATH_NOT_REACHABLE: ${kind} of "${path}" hits the always-deny floor`);
774
+ }
775
+ }
776
+ for (const prefix of allowed) {
777
+ const canonicalPrefix = normalize(resolve2(prefix));
778
+ if (canonical === canonicalPrefix || canonical.startsWith(
779
+ canonicalPrefix.endsWith("/") ? canonicalPrefix : `${canonicalPrefix}/`
780
+ ))
781
+ return;
782
+ }
783
+ throw new Error(`PATH_NOT_REACHABLE: ${kind} not permitted for ${path}`);
784
+ }
785
+ };
786
+
787
+ // src/scoped/scoped-process.ts
788
+ import { spawn as nodeSpawn } from "child_process";
789
+ var ScopedProcessImpl = class {
790
+ constructor(allowedBinaries) {
791
+ this.allowedBinaries = allowedBinaries;
792
+ }
793
+ allowedBinaries;
794
+ async spawn(binary, args, opts) {
795
+ if (!this.allowedBinaries.has("*") && !this.allowedBinaries.has(binary)) {
796
+ throw new Error(`BINARY_NOT_ALLOWED: ${binary} is not in the declared allowedBinaries`);
797
+ }
798
+ return new Promise((resolve4, reject) => {
799
+ const child = nodeSpawn(binary, args, {
800
+ cwd: opts?.cwd,
801
+ env: opts?.env ? { ...process.env, ...opts.env } : process.env,
802
+ timeout: opts?.timeout,
803
+ stdio: ["ignore", "pipe", "pipe"]
804
+ });
805
+ const stdout = [];
806
+ const stderr = [];
807
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
808
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
809
+ child.on("error", reject);
810
+ child.on("close", (exitCode) => {
811
+ resolve4({
812
+ exitCode: exitCode ?? 1,
813
+ stdout: Buffer.concat(stdout).toString(),
814
+ stderr: Buffer.concat(stderr).toString()
815
+ });
816
+ });
817
+ });
818
+ }
819
+ };
820
+
821
+ // src/scoped/scoped-secrets.ts
822
+ var ScopedSecretsImpl = class {
823
+ constructor(declaredRefs, backend) {
824
+ this.declaredRefs = declaredRefs;
825
+ this.backend = backend;
826
+ }
827
+ declaredRefs;
828
+ backend;
829
+ async get(ref) {
830
+ if (!this.declaredRefs.has(ref)) {
831
+ throw new Error(`SECRET_NOT_DECLARED: ${ref} is not in the tool's declared secrets`);
832
+ }
833
+ return this.backend(ref);
834
+ }
835
+ };
836
+
837
+ // src/capability-resolver.ts
838
+ function resolveCapabilities(toolName, capabilities, scopeIds, backends) {
839
+ if (!capabilities) return {};
840
+ const result = {};
841
+ if (capabilities.network) {
842
+ const declaredHosts = capabilities.network.allowedHosts;
843
+ const policy = backends.personalityNetworkPolicy ?? {};
844
+ const personalityAllow = policy.allow;
845
+ let resolvedHosts;
846
+ if (declaredHosts.includes("*")) {
847
+ resolvedHosts = new Set(personalityAllow ?? []);
848
+ } else if (personalityAllow) {
849
+ resolvedHosts = new Set(
850
+ declaredHosts.filter(
851
+ (host) => personalityAllow.some((pattern) => {
852
+ if (pattern === host || pattern === "*") return true;
853
+ if (pattern.startsWith("*.")) {
854
+ const suffix = pattern.slice(1);
855
+ return host.endsWith(suffix) && host.length > suffix.length;
856
+ }
857
+ return false;
858
+ })
859
+ )
860
+ );
861
+ } else {
862
+ resolvedHosts = new Set(declaredHosts);
863
+ }
864
+ result.scopedFetch = new ScopedFetchImpl(resolvedHosts, policy);
865
+ }
866
+ if (capabilities.secrets && backends.secretsBackend) {
867
+ result.secretsResolver = new ScopedSecretsImpl(
868
+ new Set(capabilities.secrets),
869
+ backends.secretsBackend
870
+ );
871
+ }
872
+ if (capabilities.storage && backends.kvStoreFactory) {
873
+ const scope = capabilities.storage.scope;
874
+ let resolvedScopeId;
875
+ if (scope === "tool-private") {
876
+ resolvedScopeId = `tool:${toolName}`;
877
+ } else if (scope === "session") {
878
+ resolvedScopeId = `session:${scopeIds.sessionId}`;
879
+ } else {
880
+ resolvedScopeId = `personality:${scopeIds.personalityId ?? scopeIds.sessionId}`;
881
+ }
882
+ result.kvStore = backends.kvStoreFactory(toolName, resolvedScopeId);
883
+ }
884
+ if (capabilities.fs_reach && backends.storage) {
885
+ const readDecl = capabilities.fs_reach.read;
886
+ const writeDecl = capabilities.fs_reach.write;
887
+ const readPaths = readDecl === "from-personality" ? backends.personalityFsReach?.read ?? [] : readDecl ?? [];
888
+ const writePaths = writeDecl === "from-personality" ? backends.personalityFsReach?.write ?? [] : writeDecl ?? [];
889
+ result.scopedFs = new ScopedFsImpl(backends.storage, new Set(readPaths), new Set(writePaths));
890
+ }
891
+ if (capabilities.process) {
892
+ result.scopedProcess = new ScopedProcessImpl(new Set(capabilities.process.allowedBinaries));
893
+ }
894
+ if (capabilities.attachments && backends.attachmentCache && backends.inboundAttachments) {
895
+ result.attachments = new ScopedAttachmentsImpl(
896
+ backends.inboundAttachments,
897
+ capabilities.attachments.kinds,
898
+ backends.attachmentCache
899
+ );
900
+ const attachmentDirs = /* @__PURE__ */ new Set();
901
+ for (const att of backends.inboundAttachments) {
902
+ if (att.url.startsWith("file://")) {
903
+ const localPath = backends.attachmentCache.resolveLocalPath(att.url);
904
+ const dir = localPath.slice(0, localPath.lastIndexOf("/"));
905
+ if (dir) attachmentDirs.add(dir);
906
+ }
907
+ }
908
+ if (attachmentDirs.size > 0) {
909
+ if (result.scopedFs && backends.storage) {
910
+ const readDecl = capabilities.fs_reach?.read;
911
+ const readPaths = readDecl === "from-personality" ? backends.personalityFsReach?.read ?? [] : readDecl ?? [];
912
+ const writeDecl = capabilities.fs_reach?.write;
913
+ const writePaths = writeDecl === "from-personality" ? backends.personalityFsReach?.write ?? [] : writeDecl ?? [];
914
+ const mergedRead = /* @__PURE__ */ new Set([...readPaths, ...attachmentDirs]);
915
+ result.scopedFs = new ScopedFsImpl(backends.storage, mergedRead, new Set(writePaths));
916
+ } else if (!result.scopedFs && backends.storage) {
917
+ result.scopedFs = new ScopedFsImpl(backends.storage, attachmentDirs, /* @__PURE__ */ new Set());
918
+ }
919
+ }
920
+ }
921
+ return result;
922
+ }
923
+
924
+ // src/capability-validator.ts
925
+ function hostMatchesPattern(host, pattern) {
926
+ if (pattern === host) return true;
927
+ if (pattern === "*") return true;
928
+ if (pattern.startsWith("*.")) {
929
+ const suffix = pattern.slice(1);
930
+ return host.endsWith(suffix) && host.length > suffix.length;
931
+ }
932
+ return false;
933
+ }
934
+ function validateRegistration(tool, personality) {
935
+ const caps = tool.capabilities;
936
+ if (!caps) return [];
937
+ const errors = [];
938
+ if (caps.network) {
939
+ const allowed = personality.safety?.network?.allow;
940
+ if (allowed && allowed.length > 0) {
941
+ for (const host of caps.network.allowedHosts) {
942
+ if (host === "*") continue;
943
+ const covered = allowed.some((pattern) => hostMatchesPattern(host, pattern));
944
+ if (!covered) {
945
+ errors.push({
946
+ tool: tool.name,
947
+ capability: "network",
948
+ message: `host "${host}" is not in personality network allow list`
949
+ });
950
+ }
951
+ }
952
+ }
953
+ }
954
+ if (caps.fs_reach) {
955
+ const personalityRead = personality.fs_reach?.read ?? [];
956
+ const personalityWrite = personality.fs_reach?.write ?? [];
957
+ if (caps.fs_reach.read && caps.fs_reach.read !== "from-personality") {
958
+ for (const toolPath of caps.fs_reach.read) {
959
+ const covered = personalityRead.some((p) => toolPath === p || toolPath.startsWith(`${p}/`));
960
+ if (!covered) {
961
+ errors.push({
962
+ tool: tool.name,
963
+ capability: "fs_reach.read",
964
+ message: `path "${toolPath}" is not covered by personality fs_reach.read`
965
+ });
966
+ }
967
+ }
968
+ }
969
+ if (caps.fs_reach.write && caps.fs_reach.write !== "from-personality") {
970
+ for (const toolPath of caps.fs_reach.write) {
971
+ const covered = personalityWrite.some(
972
+ (p) => toolPath === p || toolPath.startsWith(`${p}/`)
973
+ );
974
+ if (!covered) {
975
+ errors.push({
976
+ tool: tool.name,
977
+ capability: "fs_reach.write",
978
+ message: `path "${toolPath}" is not covered by personality fs_reach.write`
979
+ });
980
+ }
981
+ }
982
+ }
983
+ }
984
+ return errors;
985
+ }
986
+
364
987
  // src/tool-registry.ts
988
+ function needsBackends(caps) {
989
+ return !!(caps.network || caps.secrets || caps.storage || caps.fs_reach || caps.process || caps.attachments);
990
+ }
365
991
  function mcpServerName(toolName) {
366
992
  if (!toolName.startsWith("mcp__")) return void 0;
367
993
  return toolName.split("__")[1];
@@ -380,9 +1006,30 @@ function passesFilter(entry, filterOpts) {
380
1006
  }
381
1007
  var DefaultToolRegistry = class {
382
1008
  tools = /* @__PURE__ */ new Map();
1009
+ backends;
1010
+ constructor(backends) {
1011
+ this.backends = backends;
1012
+ }
383
1013
  register(tool, opts) {
384
1014
  this.tools.set(tool.name, { tool, pluginId: opts?.pluginId });
385
1015
  }
1016
+ /**
1017
+ * Validate every tool reachable for this personality (per
1018
+ * `toolNamesForPersonality`) against the personality's policy. Only
1019
+ * the tools the personality could actually call are checked — a
1020
+ * personality that doesn't list `web_search` in its toolset does not
1021
+ * fail because `web_search` declared `api.exa.ai` that's missing from
1022
+ * `network.allow`.
1023
+ */
1024
+ validateToolsForPersonality(personality) {
1025
+ const reach = this.toolNamesForPersonality(personality);
1026
+ const errors = [];
1027
+ for (const entry of this.tools.values()) {
1028
+ if (!reach.has(entry.tool.name)) continue;
1029
+ errors.push(...validateRegistration(entry.tool, personality));
1030
+ }
1031
+ return errors;
1032
+ }
386
1033
  registerAll(tools) {
387
1034
  for (const tool of tools) {
388
1035
  this.register(tool);
@@ -406,7 +1053,7 @@ var DefaultToolRegistry = class {
406
1053
  );
407
1054
  const filtered = entries.filter((e) => {
408
1055
  const isMcpOrPluginTool = e.tool.name.startsWith("mcp__") || e.pluginId !== void 0;
409
- if (!isMcpOrPluginTool && !e.tool.alwaysInclude && allowedTools && allowedTools.length > 0 && !allowedTools.includes(e.tool.name))
1056
+ if (!isMcpOrPluginTool && !e.tool.alwaysInclude && allowedTools && !allowedTools.includes(e.tool.name))
410
1057
  return false;
411
1058
  return passesFilter(e, filterOpts);
412
1059
  });
@@ -431,7 +1078,7 @@ var DefaultToolRegistry = class {
431
1078
  const isPlugin = entry.pluginId !== void 0;
432
1079
  if (!isMcp && !isPlugin) {
433
1080
  const toolset = personality.toolset;
434
- if (!toolset || toolset.length === 0 || toolset.includes(name)) {
1081
+ if (!toolset || toolset.includes(name)) {
435
1082
  reach.add(name);
436
1083
  }
437
1084
  } else if (isMcp) {
@@ -452,7 +1099,7 @@ var DefaultToolRegistry = class {
452
1099
  // Runs all tool calls in parallel. Results are returned in input order.
453
1100
  // Budget is split evenly across parallel calls; each result is post-trimmed to budget.
454
1101
  // allowedTools + filterOpts enforce tool access at execution time (belt-and-suspenders).
455
- async executeParallel(calls, ctx, allowedTools, filterOpts) {
1102
+ async executeParallel(calls, ctx, allowedTools, filterOpts, turnAttachments) {
456
1103
  const perCallBudget = Math.floor(ctx.resultBudgetChars / Math.max(calls.length, 1));
457
1104
  const results = await Promise.allSettled(
458
1105
  calls.map(async (call) => {
@@ -469,7 +1116,7 @@ var DefaultToolRegistry = class {
469
1116
  };
470
1117
  }
471
1118
  const isMcpOrPluginTool = call.name.startsWith("mcp__") || entry.pluginId !== void 0;
472
- if (!isMcpOrPluginTool && allowedTools && allowedTools.length > 0 && !allowedTools.includes(call.name)) {
1119
+ if (!isMcpOrPluginTool && allowedTools && !allowedTools.includes(call.name)) {
473
1120
  return {
474
1121
  toolCallId: call.toolCallId,
475
1122
  name: call.name,
@@ -502,9 +1149,29 @@ var DefaultToolRegistry = class {
502
1149
  }
503
1150
  };
504
1151
  }
1152
+ if (needsBackends(entry.tool.capabilities) && !this.backends) {
1153
+ return {
1154
+ toolCallId: call.toolCallId,
1155
+ name: call.name,
1156
+ result: {
1157
+ ok: false,
1158
+ error: `Tool ${call.name} declares capabilities but no capability backends are configured`,
1159
+ code: "not_available"
1160
+ }
1161
+ };
1162
+ }
505
1163
  const budget = Math.min(perCallBudget, entry.tool.maxResultChars ?? perCallBudget);
506
1164
  const toolCtx = { ...ctx, resultBudgetChars: budget };
507
1165
  try {
1166
+ if (needsBackends(entry.tool.capabilities) && this.backends) {
1167
+ const resolved = resolveCapabilities(
1168
+ entry.tool.name,
1169
+ entry.tool.capabilities,
1170
+ { sessionId: ctx.sessionId, personalityId: ctx.personalityId },
1171
+ { ...this.backends, inboundAttachments: turnAttachments }
1172
+ );
1173
+ Object.assign(toolCtx, resolved);
1174
+ }
508
1175
  const result = await entry.tool.execute(call.args, toolCtx);
509
1176
  if (result.ok && result.value.length > budget) {
510
1177
  return {
@@ -548,6 +1215,21 @@ var DefaultToolRegistry = class {
548
1215
  };
549
1216
 
550
1217
  // src/agent-loop.ts
1218
+ var KNOWN_AGENT_EVENT_TYPES = [
1219
+ "text_delta",
1220
+ "thinking_delta",
1221
+ "tool_start",
1222
+ "tool_progress",
1223
+ "tool_end",
1224
+ "usage",
1225
+ "error",
1226
+ "done",
1227
+ "context_meta",
1228
+ "run_start"
1229
+ ];
1230
+ function isKnownAgentEvent(event) {
1231
+ return KNOWN_AGENT_EVENT_TYPES.includes(event.type);
1232
+ }
551
1233
  var AgentLoop = class {
552
1234
  llm;
553
1235
  tools;
@@ -570,10 +1252,23 @@ var AgentLoop = class {
570
1252
  maxIdenticalToolCalls;
571
1253
  streamingTimeoutMs;
572
1254
  modelRouting;
1255
+ memoryProviders;
573
1256
  storage;
574
1257
  dataDir;
1258
+ observability;
1259
+ injectionClassifier;
1260
+ watcher;
1261
+ contextEngines;
1262
+ /** Bridge for the `clarify` tool; undefined when no interactive surface is wired. */
1263
+ clarifyBridge;
1264
+ /** Optional request dump store for full LLM request/response recording. */
1265
+ requestDumpStore;
1266
+ /** Phase 3 — team id stamped onto ToolContext when loop runs inside a team. */
1267
+ teamId;
575
1268
  /** Per-session accumulated spend in USD. Keyed by sessionKey. Reset via resetSessionCost(). */
576
1269
  sessionCosts = /* @__PURE__ */ new Map();
1270
+ /** FW-28 — per-session mtime registry. Keyed by sessionKey → (absPath → record). */
1271
+ sessionReadMtimes = /* @__PURE__ */ new Map();
577
1272
  constructor(config) {
578
1273
  this.llm = config.llm;
579
1274
  this.tools = config.tools ?? new DefaultToolRegistry();
@@ -592,8 +1287,24 @@ var AgentLoop = class {
592
1287
  this.maxIdenticalToolCalls = config.options?.maxIdenticalToolCalls ?? 5;
593
1288
  this.streamingTimeoutMs = config.options?.streamingTimeoutMs ?? 12e4;
594
1289
  this.modelRouting = config.modelRouting ?? {};
1290
+ this.memoryProviders = config.memoryProviders ?? /* @__PURE__ */ new Map();
595
1291
  if (config.storage) this.storage = config.storage;
596
1292
  if (config.dataDir) this.dataDir = config.dataDir;
1293
+ if (config.observability) this.observability = config.observability;
1294
+ if (config.teamId) this.teamId = config.teamId;
1295
+ if (config.injectionClassifier) this.injectionClassifier = config.injectionClassifier;
1296
+ if (config.watcher) this.watcher = config.watcher;
1297
+ if (config.clarifyBridge) this.clarifyBridge = config.clarifyBridge;
1298
+ if (config.requestDumpStore) this.requestDumpStore = config.requestDumpStore;
1299
+ this.contextEngines = config.contextEngines ?? new DefaultContextEngineRegistry();
1300
+ }
1301
+ /**
1302
+ * Resolve a pending clarify request — called by an interactive surface when
1303
+ * the user answers or cancels. No-op when no clarify bridge is wired or the
1304
+ * request id is unknown (already resolved / timed out).
1305
+ */
1306
+ async respondToClarify(response) {
1307
+ await this.clarifyBridge?.respond(response);
597
1308
  }
598
1309
  /** Returns all available tools for inventory display (e.g. TUI splash screen). */
599
1310
  getAvailableTools() {
@@ -616,6 +1327,20 @@ var AgentLoop = class {
616
1327
  resetSessionCost(sessionKey) {
617
1328
  this.sessionCosts.delete(sessionKey);
618
1329
  }
1330
+ /**
1331
+ * Resolve the effective model for an LLM call, respecting tier config.
1332
+ * Returns the model string to pass as modelOverride, and the tier name used.
1333
+ */
1334
+ resolveModelWithTier(personality, tier) {
1335
+ const personalityOverride = this.modelRouting[personality.id];
1336
+ if (personalityOverride) return { model: personalityOverride, source: "personality" };
1337
+ const modelConfig = personality.model;
1338
+ if (modelConfig && typeof modelConfig === "object" && personality.provider === this.llm.name) {
1339
+ const tierModel = modelConfig[tier] ?? modelConfig.default;
1340
+ if (tierModel) return { model: tierModel, source: "personality" };
1341
+ }
1342
+ return { model: this.llm.model, source: "global" };
1343
+ }
619
1344
  async *run(text, opts = {}) {
620
1345
  const abortSignal = opts.abortSignal ?? new AbortController().signal;
621
1346
  const sessionKey = opts.sessionKey ?? `${this.platform}:default`;
@@ -638,8 +1363,16 @@ var AgentLoop = class {
638
1363
  });
639
1364
  const sessionId = ethosSession.id;
640
1365
  const personality = (opts.personalityId ? this.personalities.get(opts.personalityId) : null) ?? this.personalities.getDefault();
1366
+ const obsConfig = personality?.safety?.observability;
1367
+ const traceId = this.observability?.startTurnTrace({
1368
+ sessionId,
1369
+ personalityId: personality?.id,
1370
+ obsConfig
1371
+ });
641
1372
  const currentSpend = this.sessionCosts.get(sessionKey) ?? 0;
642
1373
  if (personality.budgetCapUsd != null && currentSpend >= personality.budgetCapUsd) {
1374
+ if (traceId) this.observability?.endTrace(traceId, "error");
1375
+ this.observability?.flush();
643
1376
  yield {
644
1377
  type: "error",
645
1378
  error: `Budget cap of $${personality.budgetCapUsd.toFixed(2)} exceeded for this session ($${currentSpend.toFixed(4)} spent). Use /budget reset to start a new budget window.`,
@@ -648,16 +1381,30 @@ var AgentLoop = class {
648
1381
  yield { type: "done", text: "", turnCount: 0 };
649
1382
  return;
650
1383
  }
651
- const personalityOverride = this.modelRouting[personality.id];
652
- const effectiveModel = personalityOverride ?? this.llm.model;
1384
+ const { turnNumber, lastCompactionTurn } = await this.session.recordTurnStart(sessionId);
1385
+ const turnTierOverride = opts.tierOverride;
1386
+ if (turnTierOverride) {
1387
+ this.observability?.recordTierOverride({
1388
+ traceId: traceId ?? "",
1389
+ actor: "user",
1390
+ tier: turnTierOverride,
1391
+ personalityId: personality.id
1392
+ });
1393
+ }
1394
+ const activeTier = turnTierOverride ?? "default";
1395
+ let pendingTierEscalation;
1396
+ const { model: effectiveModel, source: modelSource } = this.resolveModelWithTier(
1397
+ personality,
1398
+ activeTier
1399
+ );
653
1400
  const modelOverride = effectiveModel !== this.llm.model ? effectiveModel : void 0;
654
1401
  yield {
655
1402
  type: "run_start",
656
1403
  provider: this.llm.name,
657
1404
  model: effectiveModel,
658
- source: personalityOverride ? "personality" : "global"
1405
+ source: modelSource
659
1406
  };
660
- const allowedTools = personality.toolset?.length ? personality.toolset : void 0;
1407
+ const allowedTools = personality.toolset ?? void 0;
661
1408
  const allowedPlugins = personality.plugins ?? [];
662
1409
  const filterOpts = {
663
1410
  allowedMcpServers: personality.mcp_servers ?? [],
@@ -673,22 +1420,34 @@ var AgentLoop = class {
673
1420
  },
674
1421
  allowedPlugins
675
1422
  );
1423
+ const attachmentAnnotation = buildAttachmentAnnotation(opts.attachments ?? []);
1424
+ const annotatedText = attachmentAnnotation ? `${attachmentAnnotation}
1425
+ ${text}` : text;
676
1426
  await this.session.appendMessage({
677
1427
  sessionId,
678
1428
  role: "user",
679
- content: text
1429
+ content: annotatedText
680
1430
  });
681
1431
  const allMessages = await this.session.getMessages(sessionId, { limit: this.historyLimit });
682
1432
  const history = allMessages.filter((m) => m.role !== "system");
683
- const memCtx = await this.memory.prefetch({
1433
+ const activeMemory = personality.memory?.provider ? await this.memoryProviders.get(personality.memory.provider)?.(
1434
+ personality.memory.options
1435
+ ) ?? this.memory : this.memory;
1436
+ const memScopeId = personality.memoryScope === "per-personality" ? `personality:${personality.id}` : "global";
1437
+ const memCtx = {
1438
+ scopeId: memScopeId,
684
1439
  sessionId,
685
1440
  sessionKey,
686
1441
  platform: this.platform,
687
- workingDir: this.workingDir,
688
- personalityId: personality.id,
689
- memoryScope: personality.memoryScope,
690
- query: text
691
- });
1442
+ workingDir: this.workingDir
1443
+ };
1444
+ let memSnapshot = await activeMemory.prefetch(memCtx);
1445
+ if (!memSnapshot && text.trim()) {
1446
+ const hits = await activeMemory.search(text, memCtx, { limit: 5 });
1447
+ if (hits.length > 0) {
1448
+ memSnapshot = { entries: hits.map((h) => ({ key: h.key, content: h.content })) };
1449
+ }
1450
+ }
692
1451
  const promptCtx = {
693
1452
  sessionId,
694
1453
  sessionKey,
@@ -701,6 +1460,10 @@ var AgentLoop = class {
701
1460
  personalityId: personality.id
702
1461
  };
703
1462
  const systemParts = [];
1463
+ const injectionDefenseEnabled = personality.safety?.injectionDefense?.enabled !== false;
1464
+ if (injectionDefenseEnabled) {
1465
+ systemParts.push(INJECTION_DEFENSE_PRELUDE);
1466
+ }
704
1467
  if (personality.ethosFile && this.storage) {
705
1468
  const identity = await this.storage.read(personality.ethosFile);
706
1469
  if (identity) systemParts.push(identity.trim());
@@ -721,10 +1484,34 @@ var AgentLoop = class {
721
1484
  if (promptCtx.meta && Object.keys(promptCtx.meta).length > 0) {
722
1485
  yield { type: "context_meta", data: promptCtx.meta };
723
1486
  }
724
- if (memCtx) {
725
- systemParts.push(`## Memory
1487
+ if (memSnapshot && memSnapshot.entries.length > 0) {
1488
+ const blocks = [];
1489
+ const orderHints = {
1490
+ "USER.md": "About You",
1491
+ "MEMORY.md": "Memory"
1492
+ };
1493
+ const sorted = [...memSnapshot.entries].sort((a, b) => {
1494
+ const rank = (k) => k === "USER.md" ? 0 : k === "MEMORY.md" ? 1 : 2;
1495
+ return rank(a.key) - rank(b.key);
1496
+ });
1497
+ for (const e of sorted) {
1498
+ const heading = orderHints[e.key] ?? e.key;
1499
+ blocks.push(`## ${heading}
1500
+
1501
+ ${e.content.trim()}`);
1502
+ }
1503
+ if (blocks.length > 0) {
1504
+ let rendered = `## Memory
726
1505
 
727
- ${memCtx.content}`);
1506
+ ${blocks.join("\n\n")}`;
1507
+ const MEMORY_MAX_CHARS = 2e4;
1508
+ if (rendered.length > MEMORY_MAX_CHARS) {
1509
+ rendered = `[...truncated]
1510
+
1511
+ ${rendered.slice(-MEMORY_MAX_CHARS)}`;
1512
+ }
1513
+ systemParts.push(rendered);
1514
+ }
728
1515
  }
729
1516
  const buildResult = await this.hooks.fireModifying(
730
1517
  "before_prompt_build",
@@ -743,16 +1530,74 @@ ${memCtx.content}`);
743
1530
  if (buildResult.appendSystem) systemParts.push(buildResult.appendSystem);
744
1531
  }
745
1532
  const systemPrompt = systemParts.join("\n\n").trim() || void 0;
746
- const llmMessages = this.toLLMMessages(history);
1533
+ let llmMessages = this.toLLMMessages(this.dedupHistory(history));
1534
+ const compacted = await this.maybeCompact(llmMessages, systemPrompt ?? "", personality, {
1535
+ sessionId,
1536
+ sessionKey,
1537
+ turnNumber,
1538
+ lastCompactionTurn
1539
+ });
1540
+ llmMessages = compacted.messages;
1541
+ const cacheBreakpoints = compacted.cacheBreakpoints;
1542
+ if (compacted.notice) {
1543
+ const n = compacted.notice;
1544
+ const tok = n.summaryTokens > 0 ? `, ${n.summaryTokens} tok` : "";
1545
+ yield {
1546
+ type: "tool_progress",
1547
+ toolName: "_compaction",
1548
+ message: `compressed ${n.droppedCount} earlier message(s) (${n.engineName}${tok})`,
1549
+ audience: "user"
1550
+ };
1551
+ }
747
1552
  let fullText = "";
748
1553
  let turnCount = 0;
749
1554
  let totalToolCalls = 0;
1555
+ let successfulToolCalls = 0;
750
1556
  const toolNameCounts = /* @__PURE__ */ new Map();
1557
+ const dgConfig = personality.safety?.injectionDefense?.postReadDowngrade;
1558
+ const dgEnabled = injectionDefenseEnabled && dgConfig?.enabled !== false;
1559
+ const dgTurns = dgConfig?.turns ?? 2;
1560
+ const dgTools = resolveDowngradedTools(dgConfig?.tools);
1561
+ let dgRemaining = 0;
1562
+ this.watcher?.resetTurn();
1563
+ let watcherHaltState = null;
1564
+ const observe = (event) => {
1565
+ if (!this.watcher) return;
1566
+ const d = this.watcher.observe(event);
1567
+ if (d.action !== "allow") watcherHaltState = d;
1568
+ };
1569
+ const getHalt = () => watcherHaltState;
751
1570
  for (let iteration = 0; iteration < this.maxIterations; iteration++) {
752
1571
  if (abortSignal.aborted) {
753
1572
  yield { type: "error", error: "Aborted", code: "aborted" };
1573
+ if (traceId) {
1574
+ this.observability?.endTrace(traceId, "aborted");
1575
+ this.observability?.flush();
1576
+ }
754
1577
  return;
755
1578
  }
1579
+ const halt = getHalt();
1580
+ if (halt) {
1581
+ if (halt.action === "terminate") {
1582
+ yield {
1583
+ type: "error",
1584
+ error: `Watcher: ${halt.reason}`,
1585
+ code: `watcher_${halt.rule}`
1586
+ };
1587
+ if (traceId) {
1588
+ this.observability?.endTrace(traceId, "aborted");
1589
+ this.observability?.flush();
1590
+ }
1591
+ return;
1592
+ }
1593
+ yield {
1594
+ type: "tool_progress",
1595
+ toolName: "_watcher",
1596
+ message: `\u26A0 ${halt.rule}: ${halt.reason}`,
1597
+ audience: "user"
1598
+ };
1599
+ break;
1600
+ }
756
1601
  if (totalToolCalls >= this.maxToolCallsPerTurn) {
757
1602
  yield {
758
1603
  type: "tool_progress",
@@ -774,12 +1619,17 @@ ${memCtx.content}`);
774
1619
  };
775
1620
  break;
776
1621
  }
1622
+ const toolDefs = this.tools.toDefinitions(allowedTools, filterOpts);
1623
+ const requestId = randomUUID();
1624
+ const includeContent = obsConfig?.storeLlmPayloads === "full";
777
1625
  await this.hooks.fireVoid(
778
1626
  "before_llm_call",
779
1627
  {
780
1628
  sessionId,
781
1629
  model: this.llm.model,
782
- turnNumber: turnCount
1630
+ turnNumber: turnCount,
1631
+ requestId,
1632
+ ...includeContent ? { system: systemPrompt, tools: toolDefs, messages: llmMessages } : {}
783
1633
  },
784
1634
  allowedPlugins
785
1635
  );
@@ -797,22 +1647,53 @@ ${memCtx.content}`);
797
1647
  if (watchdogTimer) clearTimeout(watchdogTimer);
798
1648
  watchdogTimer = void 0;
799
1649
  };
1650
+ let iterModelOverride = modelOverride;
1651
+ if (pendingTierEscalation && typeof personality.model === "object") {
1652
+ const tier = pendingTierEscalation;
1653
+ pendingTierEscalation = void 0;
1654
+ const { model: tierModel } = this.resolveModelWithTier(personality, tier);
1655
+ iterModelOverride = tierModel !== this.llm.model ? tierModel : void 0;
1656
+ this.observability?.recordTierEscalation({
1657
+ traceId: traceId ?? "",
1658
+ from: activeTier,
1659
+ to: tier,
1660
+ reason: "tool_escalation",
1661
+ personalityId: personality.id
1662
+ });
1663
+ }
1664
+ const llmSpanId = this.observability?.startSpan({
1665
+ traceId: traceId ?? "",
1666
+ kind: "llm_call",
1667
+ name: iterModelOverride ?? this.llm.model ?? "unknown"
1668
+ });
1669
+ let llmInputTokens = 0;
1670
+ let llmOutputTokens = 0;
1671
+ let llmCacheReadTokens = 0;
1672
+ let llmCacheCreationTokens = 0;
1673
+ let llmEstimatedCostUsd = 0;
1674
+ let llmRequestTokens;
1675
+ let llmFinishReason;
1676
+ const llmStartTs = Date.now();
800
1677
  try {
801
1678
  armWatchdog();
802
- const stream = this.llm.complete(
803
- llmMessages,
804
- this.tools.toDefinitions(allowedTools, filterOpts),
805
- {
806
- system: systemPrompt,
807
- cacheSystemPrompt: true,
808
- abortSignal: combinedSignal,
809
- ...modelOverride ? { modelOverride } : {}
810
- }
811
- );
1679
+ const stream = this.llm.complete(llmMessages, toolDefs, {
1680
+ system: systemPrompt,
1681
+ cacheSystemPrompt: true,
1682
+ abortSignal: combinedSignal,
1683
+ ...iterModelOverride ? { modelOverride: iterModelOverride } : {},
1684
+ ...cacheBreakpoints ? { cacheBreakpoints } : {}
1685
+ });
812
1686
  for await (const chunk of stream) {
813
1687
  if (abortSignal.aborted) break;
814
1688
  if (watchdogController.signal.aborted) break;
815
1689
  armWatchdog();
1690
+ if (chunk.type === "done") llmFinishReason = chunk.finishReason;
1691
+ if (chunk.type === "usage") {
1692
+ llmCacheReadTokens += chunk.usage.cacheReadTokens;
1693
+ llmCacheCreationTokens += chunk.usage.cacheCreationTokens;
1694
+ llmEstimatedCostUsd += chunk.usage.estimatedCostUsd;
1695
+ if (chunk.usage.requestTokens) llmRequestTokens = chunk.usage.requestTokens;
1696
+ }
816
1697
  for (const event of this.handleChunk(chunk, pendingToolCalls, (t) => {
817
1698
  chunkText += t;
818
1699
  fullText += t;
@@ -822,12 +1703,25 @@ ${memCtx.content}`);
822
1703
  sessionKey,
823
1704
  (this.sessionCosts.get(sessionKey) ?? 0) + event.estimatedCostUsd
824
1705
  );
1706
+ llmInputTokens += event.inputTokens;
1707
+ llmOutputTokens += event.outputTokens;
1708
+ observe({
1709
+ type: "usage",
1710
+ inputTokens: event.inputTokens,
1711
+ outputTokens: event.outputTokens
1712
+ });
825
1713
  }
826
1714
  yield event;
827
1715
  }
828
1716
  }
829
1717
  disarmWatchdog();
1718
+ this.observability?.endSpan(llmSpanId ?? "", "ok", {
1719
+ inputTokens: llmInputTokens,
1720
+ outputTokens: llmOutputTokens
1721
+ });
830
1722
  if (watchdogController.signal.aborted && !abortSignal.aborted) {
1723
+ this.observability?.endTrace(traceId ?? "", "error");
1724
+ this.observability?.flush();
831
1725
  yield {
832
1726
  type: "error",
833
1727
  error: `LLM stream stalled \u2014 no chunk for ${watchdogMs}ms`,
@@ -837,7 +1731,10 @@ ${memCtx.content}`);
837
1731
  }
838
1732
  } catch (err) {
839
1733
  disarmWatchdog();
1734
+ this.observability?.endSpan(llmSpanId ?? "", "error");
840
1735
  if (watchdogController.signal.aborted && !abortSignal.aborted) {
1736
+ this.observability?.endTrace(traceId ?? "", "error");
1737
+ this.observability?.flush();
841
1738
  yield {
842
1739
  type: "error",
843
1740
  error: `LLM stream stalled \u2014 no chunk for ${watchdogMs}ms`,
@@ -846,6 +1743,8 @@ ${memCtx.content}`);
846
1743
  return;
847
1744
  }
848
1745
  const msg = err instanceof Error ? err.message : String(err);
1746
+ this.observability?.endTrace(traceId ?? "", "error");
1747
+ this.observability?.flush();
849
1748
  yield { type: "error", error: msg, code: "llm_error" };
850
1749
  return;
851
1750
  }
@@ -867,15 +1766,50 @@ ${memCtx.content}`);
867
1766
  }))
868
1767
  }
869
1768
  });
1769
+ const llmDurationMs = Date.now() - llmStartTs;
870
1770
  await this.hooks.fireVoid(
871
1771
  "after_llm_call",
872
1772
  {
873
1773
  sessionId,
874
1774
  text: chunkText,
875
- usage: { inputTokens: 0, outputTokens: 0 }
1775
+ usage: {
1776
+ inputTokens: llmInputTokens,
1777
+ outputTokens: llmOutputTokens,
1778
+ ...llmCacheReadTokens ? { cacheReadTokens: llmCacheReadTokens } : {},
1779
+ ...llmCacheCreationTokens ? { cacheCreationTokens: llmCacheCreationTokens } : {},
1780
+ ...llmEstimatedCostUsd ? { estimatedCostUsd: llmEstimatedCostUsd } : {},
1781
+ ...llmRequestTokens ? { requestTokens: llmRequestTokens } : {}
1782
+ },
1783
+ requestId,
1784
+ finishReason: llmFinishReason,
1785
+ durationMs: llmDurationMs,
1786
+ ...includeContent ? { system: systemPrompt, tools: toolDefs, messages: llmMessages } : {}
876
1787
  },
877
1788
  allowedPlugins
878
1789
  );
1790
+ if (this.requestDumpStore) {
1791
+ await this.requestDumpStore.append({
1792
+ requestId,
1793
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1794
+ sessionId,
1795
+ personalityId: personality.id,
1796
+ turnNumber: turnCount,
1797
+ model: iterModelOverride ?? this.llm.model,
1798
+ durationMs: llmDurationMs,
1799
+ requestTokens: llmRequestTokens,
1800
+ responseTokens: llmOutputTokens || void 0,
1801
+ cacheReadTokens: llmCacheReadTokens || void 0,
1802
+ cacheCreationTokens: llmCacheCreationTokens || void 0,
1803
+ estimatedCostUsd: llmEstimatedCostUsd || void 0,
1804
+ finishReason: llmFinishReason,
1805
+ ...includeContent ? {
1806
+ system: systemPrompt,
1807
+ tools: toolDefs,
1808
+ messages: llmMessages,
1809
+ responseText: chunkText
1810
+ } : {}
1811
+ });
1812
+ }
879
1813
  if (completedToolCalls.length > 0) {
880
1814
  const assistantContent = [];
881
1815
  if (chunkText) assistantContent.push({ type: "text", text: chunkText });
@@ -894,6 +1828,11 @@ ${memCtx.content}`);
894
1828
  }
895
1829
  const progressBuffer = [];
896
1830
  const scopedStorage = this.buildScopedStorage(personality);
1831
+ let sessionMtimes = this.sessionReadMtimes.get(sessionKey);
1832
+ if (!sessionMtimes) {
1833
+ sessionMtimes = /* @__PURE__ */ new Map();
1834
+ this.sessionReadMtimes.set(sessionKey, sessionMtimes);
1835
+ }
897
1836
  const toolCtxBase = {
898
1837
  sessionId,
899
1838
  sessionKey,
@@ -902,6 +1841,8 @@ ${memCtx.content}`);
902
1841
  agentId: opts.agentId,
903
1842
  personalityId: personality.id,
904
1843
  memoryScope: personality.memoryScope,
1844
+ memoryScopeId: memScopeId,
1845
+ ...this.teamId !== void 0 && { teamId: this.teamId },
905
1846
  currentTurn: turnCount,
906
1847
  messageCount: allMessages.length + turnCount,
907
1848
  abortSignal,
@@ -914,10 +1855,36 @@ ${memCtx.content}`);
914
1855
  });
915
1856
  },
916
1857
  resultBudgetChars: this.resultBudgetChars,
917
- ...scopedStorage ? { storage: scopedStorage } : {}
1858
+ readMtimes: sessionMtimes,
1859
+ ...scopedStorage ? { storage: scopedStorage } : {},
1860
+ ...personality.safety?.network ? { networkPolicy: personality.safety.network } : {}
918
1861
  };
919
1862
  const prepped = [];
1863
+ const spanIds = /* @__PURE__ */ new Map();
920
1864
  for (const tc of completedToolCalls) {
1865
+ if (dgEnabled && dgRemaining > 0 && dgTools.has(tc.toolName)) {
1866
+ this.observability?.recordSafetyBlock({
1867
+ traceId,
1868
+ code: "tool_downgraded_post_untrusted_read",
1869
+ cause: tc.toolName
1870
+ });
1871
+ observe({ type: "tool_end", toolName: tc.toolName, ok: false });
1872
+ yield {
1873
+ type: "tool_end",
1874
+ toolCallId: tc.toolCallId,
1875
+ toolName: tc.toolName,
1876
+ ok: false,
1877
+ durationMs: 0,
1878
+ result: DOWNGRADE_REJECTION_MESSAGE
1879
+ };
1880
+ prepped.push({
1881
+ toolCallId: tc.toolCallId,
1882
+ name: tc.toolName,
1883
+ args: tc.args,
1884
+ rejected: DOWNGRADE_REJECTION_MESSAGE
1885
+ });
1886
+ continue;
1887
+ }
921
1888
  const beforeResult = await this.hooks.fireModifying(
922
1889
  "before_tool_call",
923
1890
  {
@@ -929,6 +1896,12 @@ ${memCtx.content}`);
929
1896
  allowedPlugins
930
1897
  );
931
1898
  if (beforeResult.error) {
1899
+ this.observability?.recordSafetyBlock({
1900
+ traceId,
1901
+ code: "tool_blocked",
1902
+ cause: beforeResult.error
1903
+ });
1904
+ observe({ type: "tool_end", toolName: tc.toolName, ok: false });
932
1905
  yield {
933
1906
  type: "tool_end",
934
1907
  toolCallId: tc.toolCallId,
@@ -946,6 +1919,15 @@ ${memCtx.content}`);
946
1919
  continue;
947
1920
  }
948
1921
  const effectiveArgs = beforeResult.args ?? tc.args;
1922
+ const spanId = this.observability?.startSpan({
1923
+ traceId: traceId ?? "",
1924
+ kind: "tool_call",
1925
+ name: tc.toolName,
1926
+ attrs: { args: JSON.stringify(effectiveArgs).slice(0, 4096) },
1927
+ obsConfig
1928
+ });
1929
+ spanIds.set(tc.toolCallId, spanId ?? "");
1930
+ observe({ type: "tool_start", toolName: tc.toolName, args: effectiveArgs });
949
1931
  yield {
950
1932
  type: "tool_start",
951
1933
  toolCallId: tc.toolCallId,
@@ -954,20 +1936,45 @@ ${memCtx.content}`);
954
1936
  };
955
1937
  prepped.push({ toolCallId: tc.toolCallId, name: tc.toolName, args: effectiveArgs });
956
1938
  }
1939
+ const haltDuringBatch = getHalt();
1940
+ if (haltDuringBatch) {
1941
+ for (const p of prepped) {
1942
+ if (p.rejected === void 0) {
1943
+ p.rejected = `Watcher halted before execution: ${haltDuringBatch.reason}`;
1944
+ }
1945
+ }
1946
+ }
957
1947
  const execInputs = prepped.filter((p) => p.rejected === void 0).map((p) => ({ toolCallId: p.toolCallId, name: p.name, args: p.args }));
958
1948
  const startedAt = Date.now();
959
- const execResults = execInputs.length > 0 ? await this.tools.executeParallel(execInputs, toolCtxBase, allowedTools, filterOpts) : [];
1949
+ const execResults = execInputs.length > 0 ? await this.tools.executeParallel(
1950
+ execInputs,
1951
+ toolCtxBase,
1952
+ allowedTools,
1953
+ filterOpts,
1954
+ opts.attachments
1955
+ ) : [];
960
1956
  const execResultMap = new Map(execResults.map((r) => [r.toolCallId, r]));
1957
+ if (typeof personality.model === "object" && personality.provider === this.llm.name) {
1958
+ for (const r of execResults) {
1959
+ if (r.name === "think_deeper" && r.result.ok) {
1960
+ pendingTierEscalation = "deep";
1961
+ break;
1962
+ }
1963
+ }
1964
+ }
961
1965
  for (const ev of progressBuffer) {
962
1966
  yield { type: "tool_progress", ...ev };
963
1967
  }
964
1968
  progressBuffer.length = 0;
965
1969
  const toolResultContent = [];
1970
+ let untrustedReadThisIteration = false;
966
1971
  for (const p of prepped) {
967
1972
  const durationMs = Date.now() - startedAt;
968
1973
  let result;
1974
+ let llmContent;
969
1975
  if (p.rejected !== void 0) {
970
1976
  result = { ok: false, error: p.rejected, code: "execution_failed" };
1977
+ llmContent = p.rejected;
971
1978
  } else {
972
1979
  const execResult = execResultMap.get(p.toolCallId);
973
1980
  result = execResult?.result ?? {
@@ -975,6 +1982,15 @@ ${memCtx.content}`);
975
1982
  error: "Tool result missing",
976
1983
  code: "execution_failed"
977
1984
  };
1985
+ const sid = spanIds.get(p.toolCallId);
1986
+ if (sid) {
1987
+ this.observability?.endSpan(sid, result.ok ? "ok" : "error", {
1988
+ result_size_bytes: result.ok ? result.value.length : void 0,
1989
+ durationMs
1990
+ });
1991
+ }
1992
+ observe({ type: "tool_end", toolName: p.name, ok: result.ok });
1993
+ if (result.ok) successfulToolCalls++;
978
1994
  yield {
979
1995
  type: "tool_end",
980
1996
  toolCallId: p.toolCallId,
@@ -983,6 +1999,18 @@ ${memCtx.content}`);
983
1999
  durationMs,
984
2000
  result: result.ok ? result.value : result.error
985
2001
  };
2002
+ if (result.ok && result.cost_usd) {
2003
+ this.sessionCosts.set(
2004
+ sessionKey,
2005
+ (this.sessionCosts.get(sessionKey) ?? 0) + result.cost_usd
2006
+ );
2007
+ yield {
2008
+ type: "usage",
2009
+ inputTokens: 0,
2010
+ outputTokens: 0,
2011
+ estimatedCostUsd: result.cost_usd
2012
+ };
2013
+ }
986
2014
  await this.hooks.fireVoid(
987
2015
  "after_tool_call",
988
2016
  {
@@ -993,29 +2021,97 @@ ${memCtx.content}`);
993
2021
  },
994
2022
  allowedPlugins
995
2023
  );
2024
+ const touchedPath = extractFilePath(p.args);
2025
+ if (touchedPath !== void 0) {
2026
+ await this.hooks.fireVoid(
2027
+ "tool_end_with_path",
2028
+ {
2029
+ sessionId,
2030
+ personalityId: personality.id,
2031
+ toolName: p.name,
2032
+ filePath: touchedPath,
2033
+ workingDir: this.workingDir
2034
+ },
2035
+ allowedPlugins
2036
+ );
2037
+ }
2038
+ llmContent = result.ok ? result.value : result.error;
2039
+ if (injectionDefenseEnabled && result.ok) {
2040
+ const tool = this.tools.get(p.name);
2041
+ if (tool?.outputIsUntrusted) {
2042
+ const verdict = await this.handleUntrustedResult(
2043
+ p.name,
2044
+ p.args,
2045
+ result.value,
2046
+ personality,
2047
+ traceId
2048
+ );
2049
+ llmContent = verdict.wrappedContent;
2050
+ if (verdict.containsInstructions) {
2051
+ this.observability?.recordSafetyBlock({
2052
+ traceId,
2053
+ code: "injection_detected",
2054
+ cause: verdict.reason ?? "pattern-hit"
2055
+ });
2056
+ yield {
2057
+ type: "tool_progress",
2058
+ toolName: p.name,
2059
+ message: `\u26A0 external content may contain instructions${verdict.reason ? ` (${verdict.reason})` : ""}`,
2060
+ audience: "user"
2061
+ };
2062
+ }
2063
+ untrustedReadThisIteration = true;
2064
+ }
2065
+ }
996
2066
  }
997
2067
  await this.session.appendMessage({
998
2068
  sessionId,
999
2069
  role: "tool_result",
1000
- content: result.ok ? result.value : result.error,
2070
+ content: llmContent,
1001
2071
  toolCallId: p.toolCallId,
1002
2072
  toolName: p.name
1003
2073
  });
1004
2074
  toolResultContent.push({
1005
2075
  type: "tool_result",
1006
2076
  tool_use_id: p.toolCallId,
1007
- content: result.ok ? result.value : result.error,
2077
+ content: llmContent,
1008
2078
  is_error: !result.ok
1009
2079
  });
1010
2080
  }
2081
+ if (dgRemaining > 0) dgRemaining--;
2082
+ if (dgEnabled && untrustedReadThisIteration) {
2083
+ dgRemaining = dgTurns;
2084
+ }
2085
+ if (opts.steerSink) {
2086
+ const steers = opts.steerSink.drain();
2087
+ for (const steerText of steers) {
2088
+ toolResultContent.push({ type: "text", text: `[USER STEER]: ${steerText}` });
2089
+ await this.session.appendMessage({
2090
+ sessionId,
2091
+ role: "user_steer",
2092
+ content: steerText
2093
+ });
2094
+ }
2095
+ }
1011
2096
  llmMessages.push({ role: "user", content: toolResultContent });
1012
2097
  }
1013
2098
  await this.session.updateUsage(sessionId, { apiCallCount: turnCount });
1014
2099
  await this.hooks.fireVoid(
1015
2100
  "agent_done",
1016
- { sessionId, text: fullText, turnCount },
2101
+ {
2102
+ sessionId,
2103
+ text: fullText,
2104
+ turnCount,
2105
+ personalityId: personality.id,
2106
+ successfulToolCalls,
2107
+ totalToolCalls,
2108
+ toolNames: [...toolNameCounts.keys()],
2109
+ initialPrompt: text
2110
+ },
1017
2111
  allowedPlugins
1018
2112
  );
2113
+ if (traceId) this.observability?.endTrace(traceId, "ok");
2114
+ this.observability?.flush();
1019
2115
  yield { type: "done", text: fullText, turnCount };
1020
2116
  }
1021
2117
  *handleChunk(chunk, pendingToolCalls, onText) {
@@ -1062,6 +2158,53 @@ ${memCtx.content}`);
1062
2158
  break;
1063
2159
  }
1064
2160
  }
2161
+ // Q1 — tool-result dedup. A coordinator that re-reads the same file across
2162
+ // turns stores one tool_result per read; over a long session that is pure
2163
+ // token waste. Before building the LLM-facing history, collapse exact-
2164
+ // duplicate tool results — same tool, same args, same output — keeping the
2165
+ // FIRST (oldest) copy intact and replacing later ones with a placeholder
2166
+ // that points BACKWARD at it. Pointing backward preserves causality: the
2167
+ // assistant turn that followed a later read can still see the content
2168
+ // earlier in the transcript. The tool_result row stays attached to its
2169
+ // tool_use (Anthropic contract); only the content string changes.
2170
+ dedupHistory(history) {
2171
+ const argsByToolCallId = /* @__PURE__ */ new Map();
2172
+ for (const msg of history) {
2173
+ if (msg.role === "assistant" && msg.toolCalls) {
2174
+ for (const tc of msg.toolCalls) {
2175
+ argsByToolCallId.set(tc.id, JSON.stringify(tc.input ?? null));
2176
+ }
2177
+ }
2178
+ }
2179
+ const occurrences = /* @__PURE__ */ new Map();
2180
+ history.forEach((msg, idx) => {
2181
+ if (msg.role !== "tool_result") return;
2182
+ const toolName = msg.toolName ?? "";
2183
+ const argsHash = msg.toolCallId ? argsByToolCallId.get(msg.toolCallId) ?? "" : "";
2184
+ const fingerprint = createHash3("sha256").update(`${toolName}\0${argsHash}\0${msg.content.trim()}`).digest("hex");
2185
+ const list = occurrences.get(fingerprint);
2186
+ if (list) list.push(idx);
2187
+ else occurrences.set(fingerprint, [idx]);
2188
+ });
2189
+ const replacement = /* @__PURE__ */ new Map();
2190
+ for (const indices of occurrences.values()) {
2191
+ if (indices.length < 2) continue;
2192
+ const oldest = indices[0];
2193
+ if (oldest === void 0) continue;
2194
+ const oldestId = history[oldest]?.toolCallId ?? String(oldest);
2195
+ for (const idx of indices.slice(1)) {
2196
+ replacement.set(
2197
+ idx,
2198
+ `[deduped \u2014 identical to earlier result, see tool_use id ${oldestId}]`
2199
+ );
2200
+ }
2201
+ }
2202
+ if (replacement.size === 0) return history;
2203
+ return history.map((msg, idx) => {
2204
+ const placeholder = replacement.get(idx);
2205
+ return placeholder !== void 0 ? { ...msg, content: placeholder } : msg;
2206
+ });
2207
+ }
1065
2208
  // Reconstruct LLM-ready messages from stored history.
1066
2209
  // Assistant messages with tool calls produce proper tool_use content blocks.
1067
2210
  // Consecutive tool_result rows are grouped into a single user message.
@@ -1095,6 +2238,7 @@ ${memCtx.content}`);
1095
2238
  } else {
1096
2239
  messages.push({ role: "user", content: [resultBlock] });
1097
2240
  }
2241
+ } else if (msg.role === "user_steer") {
1098
2242
  }
1099
2243
  }
1100
2244
  return messages;
@@ -1110,21 +2254,637 @@ ${memCtx.content}`);
1110
2254
  // read: [<ethosHome>/personalities/<self>/, <ethosHome>/skills/, <cwd>]
1111
2255
  // write: [<ethosHome>/personalities/<self>/, <cwd>]
1112
2256
  // ---------------------------------------------------------------------------
2257
+ // ---------------------------------------------------------------------------
2258
+ // Ch.3a + 3c — provenance wrap + injection classification
2259
+ // ---------------------------------------------------------------------------
2260
+ //
2261
+ // Returns the wrapped content (always — wrap is the floor) plus whether
2262
+ // any defense layer flagged the payload. Tier-1 is always evaluated;
2263
+ // Tier-2 (LLM classifier) fires when Tier-1 hit, content is > 500 chars,
2264
+ // or `injectionDefense.classifier.alwaysCallLLM` is true.
2265
+ async handleUntrustedResult(toolName, args, rawValue, personality, traceId) {
2266
+ const source = describeSource(toolName, args);
2267
+ const wrapped = wrapUntrusted({
2268
+ content: rawValue,
2269
+ toolName,
2270
+ ...source ? { source } : {}
2271
+ });
2272
+ const tier1 = shortPatternCheck(rawValue);
2273
+ const tier1Hit = tier1.containsInstructions || wrapped.strippedTokens > 0;
2274
+ const classifierConfig = personality.safety?.injectionDefense?.classifier;
2275
+ const shouldCallLLM = this.injectionClassifier !== void 0 && (classifierConfig?.alwaysCallLLM === true || tier1Hit || rawValue.length > 500);
2276
+ let verdict = null;
2277
+ if (shouldCallLLM && this.injectionClassifier) {
2278
+ try {
2279
+ verdict = await this.injectionClassifier({ content: rawValue });
2280
+ } catch (err) {
2281
+ this.observability?.recordSafetyBlock({
2282
+ traceId,
2283
+ code: "injection_classifier_failed",
2284
+ cause: err instanceof Error ? err.message : String(err)
2285
+ });
2286
+ verdict = null;
2287
+ }
2288
+ }
2289
+ const containsInstructions = tier1Hit || (verdict?.containsInstructions ?? false);
2290
+ const reason = tier1Hit ? wrapped.strippedTokens > 0 ? `stripped ${wrapped.strippedTokens} template token${wrapped.strippedTokens === 1 ? "" : "s"}` : tier1.hits[0]?.rule ?? "pattern-hit" : verdict?.reason;
2291
+ return {
2292
+ wrappedContent: wrapped.content,
2293
+ containsInstructions,
2294
+ ...reason ? { reason } : {}
2295
+ };
2296
+ }
2297
+ // ---------------------------------------------------------------------------
2298
+ // E4 — pre-LLM compaction. Resolves the personality's context engine and
2299
+ // calls into it when estimated context usage exceeds the pressure
2300
+ // threshold (80% of the model's window). When the personality declares no
2301
+ // engine, we still resolve to `drop_oldest` — but the engine is only
2302
+ // *invoked* when there is real pressure, so static configs see no change.
2303
+ // ---------------------------------------------------------------------------
2304
+ async maybeCompact(messages, systemPrompt, personality, sessionMetadata) {
2305
+ const window = this.llm.maxContextTokens || 2e5;
2306
+ const target = Math.floor(window * 0.7);
2307
+ const pressureGate = Math.floor(window * 0.8);
2308
+ const current = estimateTokens(systemPrompt) + estimateMessagesTokens(messages);
2309
+ if (current <= pressureGate) return { messages };
2310
+ const cooldownTurns = 5;
2311
+ const hardOverflowGate = Math.floor(window * 0.95);
2312
+ const inCooldown = sessionMetadata.lastCompactionTurn > 0 && sessionMetadata.turnNumber - sessionMetadata.lastCompactionTurn < cooldownTurns;
2313
+ if (inCooldown && current <= hardOverflowGate) {
2314
+ return { messages };
2315
+ }
2316
+ const engineName = personality.context_engine ?? "drop_oldest";
2317
+ const engine = this.contextEngines.get(engineName) ?? this.contextEngines.get("drop_oldest");
2318
+ if (!engine) return { messages };
2319
+ try {
2320
+ const startedAt = Date.now();
2321
+ const result = await engine.compact({
2322
+ messages,
2323
+ currentSystem: systemPrompt,
2324
+ targetTokens: target,
2325
+ personality,
2326
+ sessionMetadata
2327
+ });
2328
+ const durationMs = Date.now() - startedAt;
2329
+ this.observability?.recordCompaction({
2330
+ code: "context_compacted",
2331
+ cause: `${engine.name}: ${result.notes}`
2332
+ });
2333
+ const changed = result.messages.length !== messages.length || result.summaryText !== void 0;
2334
+ const summaryTokens = result.summaryText ? estimateTokens(result.summaryText) : 0;
2335
+ if (changed) {
2336
+ try {
2337
+ await this.session.recordCompression({
2338
+ sessionId: sessionMetadata.sessionId,
2339
+ engineName: engine.name,
2340
+ originalCount: messages.length,
2341
+ keptCount: result.messages.length,
2342
+ ...result.summaryText !== void 0 ? { summaryText: result.summaryText } : {},
2343
+ summaryTokens,
2344
+ preTotalTokens: current,
2345
+ postTotalTokens: estimateTokens(systemPrompt) + estimateMessagesTokens(result.messages),
2346
+ durationMs
2347
+ });
2348
+ await this.session.updateUsage(sessionMetadata.sessionId, { compactionCount: 1 });
2349
+ await this.session.recordCompactionTurn(
2350
+ sessionMetadata.sessionId,
2351
+ sessionMetadata.turnNumber
2352
+ );
2353
+ } catch (persistErr) {
2354
+ this.observability?.recordCompaction({
2355
+ severity: "warn",
2356
+ code: "compaction_persist_failed",
2357
+ cause: persistErr instanceof Error ? persistErr.message : String(persistErr)
2358
+ });
2359
+ }
2360
+ }
2361
+ return {
2362
+ messages: result.messages,
2363
+ ...changed && result.cacheBreakpoints ? { cacheBreakpoints: result.cacheBreakpoints } : {},
2364
+ ...changed ? {
2365
+ notice: {
2366
+ engineName: engine.name,
2367
+ droppedCount: messages.length - result.messages.length,
2368
+ summaryTokens
2369
+ }
2370
+ } : {}
2371
+ };
2372
+ } catch (err) {
2373
+ this.observability?.recordCompaction({
2374
+ severity: "warn",
2375
+ code: "context_engine_failed",
2376
+ cause: err instanceof Error ? err.message : String(err)
2377
+ });
2378
+ return { messages };
2379
+ }
2380
+ }
1113
2381
  buildScopedStorage(personality) {
1114
2382
  if (!this.storage) return void 0;
1115
- const ethosHome = this.dataDir ?? join(homedir(), ".ethos");
2383
+ const ethosHome = this.dataDir ?? join3(homedir2(), ".ethos");
1116
2384
  const cwd = this.workingDir;
1117
2385
  const self = personality.id;
1118
- const ownDir = `${join(ethosHome, "personalities", self)}/`;
2386
+ const ownDir = `${join3(ethosHome, "personalities", self)}/`;
1119
2387
  const fsReach = personality.fs_reach;
1120
- const readPrefixes = fsReach?.read && fsReach.read.length > 0 ? fsReach.read.map((p) => substitute(p, { ethosHome, self, cwd })) : [ownDir, `${join(ethosHome, "skills")}/`, cwd];
2388
+ const readPrefixes = fsReach?.read && fsReach.read.length > 0 ? fsReach.read.map((p) => substitute(p, { ethosHome, self, cwd })) : [ownDir, `${join3(ethosHome, "skills")}/`, cwd];
1121
2389
  const writePrefixes = fsReach?.write && fsReach.write.length > 0 ? fsReach.write.map((p) => substitute(p, { ethosHome, self, cwd })) : [ownDir, cwd];
1122
- return new ScopedStorage(this.storage, { read: readPrefixes, write: writePrefixes });
2390
+ return new ScopedStorage(this.storage, {
2391
+ read: readPrefixes,
2392
+ write: writePrefixes,
2393
+ alwaysDeny: defaultAlwaysDeny()
2394
+ });
1123
2395
  }
1124
2396
  };
1125
2397
  function substitute(template, vars) {
1126
2398
  return template.replace(/\$\{ETHOS_HOME\}/g, vars.ethosHome).replace(/\$\{self\}/g, vars.self).replace(/\$\{CWD\}/g, vars.cwd);
1127
2399
  }
2400
+ function extractFilePath(args) {
2401
+ if (!args || typeof args !== "object") return void 0;
2402
+ const a = args;
2403
+ if (typeof a.path === "string" && a.path.length > 0) return a.path;
2404
+ if (typeof a.file_path === "string" && a.file_path.length > 0) return a.file_path;
2405
+ if (typeof a.filePath === "string" && a.filePath.length > 0) return a.filePath;
2406
+ if (typeof a.cwd === "string" && a.cwd.length > 0) return a.cwd;
2407
+ return void 0;
2408
+ }
2409
+ function describeSource(toolName, args) {
2410
+ if (!args || typeof args !== "object") return void 0;
2411
+ const a = args;
2412
+ if (typeof a.path === "string") return `${toolName === "read_file" ? "file:" : ""}${a.path}`;
2413
+ if (typeof a.url === "string") return a.url;
2414
+ if (typeof a.command === "string") return `cmd:${a.command}`;
2415
+ if (typeof a.query === "string") return `query:${a.query}`;
2416
+ return void 0;
2417
+ }
2418
+
2419
+ // src/clarify/clarify-bridge.ts
2420
+ import { randomUUID as randomUUID2 } from "crypto";
2421
+ var ClarifyBusyError = class extends Error {
2422
+ code = "CLARIFY_BUSY";
2423
+ constructor() {
2424
+ super("Another clarify is already pending for this session");
2425
+ this.name = "ClarifyBusyError";
2426
+ }
2427
+ };
2428
+ var ClarifyTimedOutNoDefaultError = class extends Error {
2429
+ code = "CLARIFY_TIMED_OUT_NO_DEFAULT";
2430
+ constructor() {
2431
+ super("Clarify timed out and no default was provided");
2432
+ this.name = "ClarifyTimedOutNoDefaultError";
2433
+ }
2434
+ };
2435
+ var ClarifyNoSurfaceError = class extends Error {
2436
+ code = "CLARIFY_NO_SURFACE";
2437
+ constructor() {
2438
+ super("No interactive surface is available to present the clarify request");
2439
+ this.name = "ClarifyNoSurfaceError";
2440
+ }
2441
+ };
2442
+ var ClarifyBridge = class {
2443
+ /**
2444
+ * `store` is exposed read-only so a surface (e.g. TelegramClarifySurface)
2445
+ * can patch `surfaceContext` after presenting the prompt and look up rows
2446
+ * by id without proxying every call through the bridge.
2447
+ */
2448
+ constructor(store) {
2449
+ this.store = store;
2450
+ }
2451
+ store;
2452
+ pending = /* @__PURE__ */ new Map();
2453
+ presenter;
2454
+ resolvedListeners = /* @__PURE__ */ new Set();
2455
+ /** A surface registers how it presents a pending clarify to the user. */
2456
+ setPresenter(presenter) {
2457
+ this.presenter = presenter;
2458
+ }
2459
+ /**
2460
+ * Subscribe to clarify resolutions so a surface can tear down its prompt
2461
+ * when the request is answered, times out, or is cancelled. Returns an
2462
+ * unsubscribe function.
2463
+ */
2464
+ onResolved(listener) {
2465
+ this.resolvedListeners.add(listener);
2466
+ return () => this.resolvedListeners.delete(listener);
2467
+ }
2468
+ /** True iff a clarify is currently pending for the given session. */
2469
+ hasPending(sessionId) {
2470
+ for (const entry of this.pending.values()) {
2471
+ if (entry.row.sessionId === sessionId) return true;
2472
+ }
2473
+ return false;
2474
+ }
2475
+ /** Pending rows still awaiting an answer — for SSE reconnect re-presentation. */
2476
+ listPending(sessionId) {
2477
+ const rows = [];
2478
+ for (const entry of this.pending.values()) {
2479
+ if (sessionId === void 0 || entry.row.sessionId === sessionId) rows.push(entry.row);
2480
+ }
2481
+ return rows;
2482
+ }
2483
+ /**
2484
+ * Persisted pending rows from the store — for boot-time hydration (a surface
2485
+ * that outlives a single process needs to find rows that survived a
2486
+ * restart). `listPending()` only sees in-memory rows; this is the source of
2487
+ * truth across restarts.
2488
+ */
2489
+ async listPersisted(filter) {
2490
+ return this.store.list(filter);
2491
+ }
2492
+ /**
2493
+ * Issue a clarify request. Resolves when the user answers, the timeout fires
2494
+ * (with `default`), or the turn aborts (as cancelled). Rejects with
2495
+ * `ClarifyBusyError` if one is already pending for the session, or with
2496
+ * `ClarifyTimedOutNoDefaultError` on timeout when no `default` was given.
2497
+ */
2498
+ async request(input) {
2499
+ if (!this.presenter) throw new ClarifyNoSurfaceError();
2500
+ if (this.hasPending(input.sessionId)) throw new ClarifyBusyError();
2501
+ const requestId = randomUUID2();
2502
+ const createdAt = /* @__PURE__ */ new Date();
2503
+ const deadline = new Date(createdAt.getTime() + input.timeoutMs);
2504
+ const row = {
2505
+ requestId,
2506
+ sessionId: input.sessionId,
2507
+ surfaceType: input.surfaceType,
2508
+ surfaceContext: input.surfaceContext ?? {},
2509
+ question: input.question,
2510
+ ...input.options !== void 0 ? { options: input.options } : {},
2511
+ ...input.default !== void 0 ? { default: input.default } : {},
2512
+ answerableBy: input.answerableBy,
2513
+ createdAt: createdAt.toISOString(),
2514
+ defaultDeadlineAt: deadline.toISOString()
2515
+ };
2516
+ await this.store.add(row);
2517
+ return new Promise((resolve4, reject) => {
2518
+ const timer = setTimeout(() => {
2519
+ void this.fireTimeout(requestId);
2520
+ }, input.timeoutMs);
2521
+ this.pending.set(requestId, { row, resolve: resolve4, reject, timer });
2522
+ if (input.abortSignal) {
2523
+ if (input.abortSignal.aborted) {
2524
+ void this.respond({ requestId, answer: "", source: "cancel" });
2525
+ } else {
2526
+ input.abortSignal.addEventListener(
2527
+ "abort",
2528
+ () => void this.respond({ requestId, answer: "", source: "cancel" }),
2529
+ { once: true }
2530
+ );
2531
+ }
2532
+ }
2533
+ Promise.resolve(this.presenter?.(row)).catch(() => {
2534
+ });
2535
+ });
2536
+ }
2537
+ /**
2538
+ * Resolve a pending clarify. Called by a surface when the user answers or
2539
+ * cancels, and internally on timeout. Unknown / already-resolved ids are
2540
+ * swallowed (another surface or the timeout beat this one).
2541
+ *
2542
+ * Degraded-mode fallback: when no in-process entry exists but the row is
2543
+ * still persisted (gateway crashed mid-clarify, then the user tapped the
2544
+ * button after restart), still clear the row and notify listeners so the
2545
+ * surface can edit its UI to the resolved state. The original `request()`
2546
+ * promise is gone — the agent waiting on it died with the process — so
2547
+ * the answer can't reach the LLM, but at least the visible prompt updates.
2548
+ */
2549
+ async respond(response) {
2550
+ const entry = this.pending.get(response.requestId);
2551
+ if (!entry) {
2552
+ const persisted = await this.store.get(response.requestId);
2553
+ if (!persisted) return;
2554
+ await this.store.remove(response.requestId);
2555
+ const notify = response.source === "timeout-no-default" ? null : response;
2556
+ this.notifyResolved(persisted, notify);
2557
+ return;
2558
+ }
2559
+ clearTimeout(entry.timer);
2560
+ this.pending.delete(response.requestId);
2561
+ await this.store.remove(response.requestId);
2562
+ if (response.source === "timeout-no-default") {
2563
+ entry.reject(new ClarifyTimedOutNoDefaultError());
2564
+ this.notifyResolved(entry.row, null);
2565
+ return;
2566
+ }
2567
+ entry.resolve(response);
2568
+ this.notifyResolved(entry.row, response);
2569
+ }
2570
+ notifyResolved(row, response) {
2571
+ for (const listener of this.resolvedListeners) {
2572
+ try {
2573
+ listener(row, response);
2574
+ } catch {
2575
+ }
2576
+ }
2577
+ }
2578
+ /**
2579
+ * Restart recovery: fire timeout responses for any persisted rows that have
2580
+ * already passed their deadline. Called on boot and on an interval by
2581
+ * surfaces that outlive a single turn (web-api, gateway).
2582
+ *
2583
+ * Listeners are notified for swept rows so surfaces can edit their UI in
2584
+ * place — a card whose prompt timed out while the process was down should
2585
+ * still update to the "timed out" state instead of hanging on buttons.
2586
+ */
2587
+ async sweep(now = /* @__PURE__ */ new Date()) {
2588
+ const expired = await this.store.expired(now);
2589
+ for (const row of expired) {
2590
+ if (this.pending.has(row.requestId)) continue;
2591
+ await this.store.remove(row.requestId);
2592
+ const source = row.default !== void 0 ? "timeout-default" : "timeout-no-default";
2593
+ const notify = source === "timeout-default" ? { requestId: row.requestId, answer: row.default ?? "", source } : null;
2594
+ this.notifyResolved(row, notify);
2595
+ }
2596
+ }
2597
+ async fireTimeout(requestId) {
2598
+ const entry = this.pending.get(requestId);
2599
+ if (!entry) return;
2600
+ const def = entry.row.default;
2601
+ await this.respond({
2602
+ requestId,
2603
+ answer: def ?? "",
2604
+ source: def !== void 0 ? "timeout-default" : "timeout-no-default"
2605
+ });
2606
+ }
2607
+ };
2608
+
2609
+ // src/clarify/file-clarify-store.ts
2610
+ var FileClarifyStore = class {
2611
+ /** `root` is the absolute `~/.ethos/clarify` directory (caller-resolved). */
2612
+ constructor(storage, root) {
2613
+ this.storage = storage;
2614
+ this.root = root;
2615
+ this.pendingPath = `${root}/pending.json`;
2616
+ }
2617
+ storage;
2618
+ root;
2619
+ pendingPath;
2620
+ /** Serializes the read-modify-write cycle within this process. */
2621
+ mutex = Promise.resolve();
2622
+ async add(req) {
2623
+ await this.mutate((rows) => {
2624
+ const without = rows.filter((r) => r.requestId !== req.requestId);
2625
+ without.push(req);
2626
+ return without;
2627
+ });
2628
+ }
2629
+ async get(requestId) {
2630
+ const rows = await this.readAll();
2631
+ return rows.find((r) => r.requestId === requestId) ?? null;
2632
+ }
2633
+ async list(filter) {
2634
+ const rows = await this.readAll();
2635
+ return rows.filter(
2636
+ (r) => (filter?.surfaceType === void 0 || r.surfaceType === filter.surfaceType) && (filter?.sessionId === void 0 || r.sessionId === filter.sessionId)
2637
+ );
2638
+ }
2639
+ async remove(requestId) {
2640
+ await this.mutate((rows) => rows.filter((r) => r.requestId !== requestId));
2641
+ }
2642
+ async update(requestId, patch) {
2643
+ await this.mutate((rows) => {
2644
+ const idx = rows.findIndex((r) => r.requestId === requestId);
2645
+ if (idx < 0) return rows;
2646
+ const target = rows[idx];
2647
+ if (!target) return rows;
2648
+ const next = [...rows];
2649
+ next[idx] = { ...target, ...patch, requestId: target.requestId };
2650
+ return next;
2651
+ });
2652
+ }
2653
+ async expired(now) {
2654
+ const rows = await this.readAll();
2655
+ const cutoff = now.getTime();
2656
+ return rows.filter((r) => new Date(r.defaultDeadlineAt).getTime() <= cutoff);
2657
+ }
2658
+ // ---------------------------------------------------------------------------
2659
+ async readAll() {
2660
+ const raw = await this.storage.read(this.pendingPath);
2661
+ if (!raw) return [];
2662
+ try {
2663
+ const parsed = JSON.parse(raw);
2664
+ return Array.isArray(parsed) ? parsed : [];
2665
+ } catch {
2666
+ return [];
2667
+ }
2668
+ }
2669
+ /** Run a read-modify-write under the per-process mutex with an atomic write. */
2670
+ async mutate(fn) {
2671
+ const run = this.mutex.then(async () => {
2672
+ const rows = await this.readAll();
2673
+ const next = fn(rows);
2674
+ await this.storage.mkdir(this.root);
2675
+ await this.storage.writeAtomic(this.pendingPath, `${JSON.stringify(next, null, 2)}
2676
+ `);
2677
+ });
2678
+ this.mutex = run.then(
2679
+ () => void 0,
2680
+ () => void 0
2681
+ );
2682
+ return run;
2683
+ }
2684
+ };
2685
+
2686
+ // src/defaults/in-memory-tool-context.ts
2687
+ var InMemoryKeyValueStore = class {
2688
+ data = /* @__PURE__ */ new Map();
2689
+ async get(key) {
2690
+ const entry = this.data.get(key);
2691
+ if (!entry) return null;
2692
+ if (entry.expiresAt !== void 0 && Date.now() >= entry.expiresAt) {
2693
+ this.data.delete(key);
2694
+ return null;
2695
+ }
2696
+ return entry.value;
2697
+ }
2698
+ async set(key, value, opts) {
2699
+ const expiresAt = opts?.ttlSeconds ? Date.now() + opts.ttlSeconds * 1e3 : void 0;
2700
+ this.data.set(key, { value, expiresAt });
2701
+ }
2702
+ async delete(key) {
2703
+ this.data.delete(key);
2704
+ }
2705
+ async list(prefix) {
2706
+ return [...this.data.keys()].filter((k) => k.startsWith(prefix));
2707
+ }
2708
+ };
2709
+ function makeTestToolContext(opts) {
2710
+ const ctx = {
2711
+ sessionId: opts?.sessionId ?? "test-session",
2712
+ sessionKey: opts?.sessionKey ?? "cli:test",
2713
+ platform: opts?.platform ?? "cli",
2714
+ workingDir: opts?.workingDir ?? "/tmp",
2715
+ personalityId: opts?.personalityId,
2716
+ currentTurn: opts?.currentTurn ?? 1,
2717
+ messageCount: opts?.messageCount ?? 1,
2718
+ abortSignal: new AbortController().signal,
2719
+ emit: () => {
2720
+ },
2721
+ resultBudgetChars: opts?.resultBudgetChars ?? 8e4
2722
+ };
2723
+ if (opts?.withStorage) {
2724
+ ctx.kvStore = new InMemoryKeyValueStore();
2725
+ }
2726
+ return ctx;
2727
+ }
2728
+
2729
+ // src/memory-policies.ts
2730
+ import { MemoryConflictError } from "@ethosagent/types";
2731
+ import { MemoryConflictError as MemoryConflictError2 } from "@ethosagent/types";
2732
+ var EagerPrefetchPolicy = class {
2733
+ constructor(inner) {
2734
+ this.inner = inner;
2735
+ }
2736
+ inner;
2737
+ prefetch(ctx) {
2738
+ return this.inner.prefetch(ctx);
2739
+ }
2740
+ read(key, ctx) {
2741
+ return this.inner.read(key, ctx);
2742
+ }
2743
+ search(query, ctx, opts) {
2744
+ return this.inner.search(query, ctx, opts);
2745
+ }
2746
+ sync(updates, ctx) {
2747
+ return this.inner.sync(updates, ctx);
2748
+ }
2749
+ list(ctx, opts) {
2750
+ return this.inner.list(ctx, opts);
2751
+ }
2752
+ };
2753
+ var LazyOnDemandPolicy = class {
2754
+ constructor(inner) {
2755
+ this.inner = inner;
2756
+ }
2757
+ inner;
2758
+ async prefetch(_ctx) {
2759
+ return null;
2760
+ }
2761
+ read(key, ctx) {
2762
+ return this.inner.read(key, ctx);
2763
+ }
2764
+ search(query, ctx, opts) {
2765
+ return this.inner.search(query, ctx, opts);
2766
+ }
2767
+ sync(updates, ctx) {
2768
+ return this.inner.sync(updates, ctx);
2769
+ }
2770
+ list(ctx, opts) {
2771
+ return this.inner.list(ctx, opts);
2772
+ }
2773
+ };
2774
+ var LastWriteWinsPolicy = class {
2775
+ constructor(inner) {
2776
+ this.inner = inner;
2777
+ }
2778
+ inner;
2779
+ /** scopeId:key → mtime at last read (ms). */
2780
+ lastReadAt = /* @__PURE__ */ new Map();
2781
+ // Snapshot entries from prefetch() are not added to the conflict tracker.
2782
+ // Callers that rely on conflict detection must use read() before sync().
2783
+ // In current wiring, LazyOnDemandPolicy suppresses prefetch() before it
2784
+ // reaches this layer, so this is safe.
2785
+ prefetch(ctx) {
2786
+ return this.inner.prefetch(ctx);
2787
+ }
2788
+ /** Records the entry's mtime so sync() can detect concurrent modifications. */
2789
+ async read(key, ctx) {
2790
+ const entry = await this.inner.read(key, ctx);
2791
+ if (entry?.metadata?.lastUpdatedAt !== void 0) {
2792
+ this.lastReadAt.set(`${ctx.scopeId}:${key}`, entry.metadata.lastUpdatedAt);
2793
+ }
2794
+ return entry;
2795
+ }
2796
+ /**
2797
+ * Records mtimes for entries returned by search so that a write based on
2798
+ * search results also benefits from conflict detection.
2799
+ *
2800
+ * Only sets the mtime when no prior timestamp is recorded for the key.
2801
+ * Overwriting an existing timestamp from a search result would allow a
2802
+ * stale write to pass: caller reads at mtime 1, external writer bumps to
2803
+ * mtime 2, caller searches and the result records mtime 2, caller syncs
2804
+ * stale content — conflict check passes because currentAt === recordedAt.
2805
+ * Preserving the oldest (first-read) mtime ensures that risk does not apply.
2806
+ */
2807
+ async search(query, ctx, opts) {
2808
+ const results = await this.inner.search(query, ctx, opts);
2809
+ for (const entry of results) {
2810
+ const mapKey = `${ctx.scopeId}:${entry.key}`;
2811
+ if (entry.metadata?.lastUpdatedAt !== void 0 && !this.lastReadAt.has(mapKey)) {
2812
+ this.lastReadAt.set(mapKey, entry.metadata.lastUpdatedAt);
2813
+ }
2814
+ }
2815
+ return results;
2816
+ }
2817
+ /**
2818
+ * For each key that was previously read, check the current mtime against
2819
+ * the recorded read-timestamp. Rejects the entire call if any key has been
2820
+ * modified by another writer since the caller last read it.
2821
+ *
2822
+ * After a successful write, updates `lastReadAt` baselines so that a second
2823
+ * `sync()` call on the same key does not spuriously fail. Keys that were
2824
+ * deleted are removed from the tracker so they can be re-added later.
2825
+ */
2826
+ async sync(updates, ctx) {
2827
+ const keysToCheck = /* @__PURE__ */ new Set();
2828
+ for (const u of updates) {
2829
+ const readAt = this.lastReadAt.get(`${ctx.scopeId}:${u.key}`);
2830
+ if (readAt !== void 0) {
2831
+ keysToCheck.add(u.key);
2832
+ }
2833
+ }
2834
+ const fetchedMtimes = /* @__PURE__ */ new Map();
2835
+ await Promise.all(
2836
+ [...keysToCheck].map(async (key) => {
2837
+ const current = await this.inner.read(key, ctx);
2838
+ const currentMtime = current?.metadata?.lastUpdatedAt;
2839
+ fetchedMtimes.set(key, currentMtime);
2840
+ const readAt = this.lastReadAt.get(`${ctx.scopeId}:${key}`);
2841
+ if (readAt !== void 0 && currentMtime !== void 0 && currentMtime > readAt) {
2842
+ throw new MemoryConflictError({
2843
+ key,
2844
+ scopeId: ctx.scopeId,
2845
+ recordedAt: readAt,
2846
+ currentAt: currentMtime
2847
+ });
2848
+ }
2849
+ })
2850
+ );
2851
+ await this.inner.sync(updates, ctx);
2852
+ for (const u of updates) {
2853
+ const mapKey = `${ctx.scopeId}:${u.key}`;
2854
+ if (u.action === "delete") {
2855
+ this.lastReadAt.delete(mapKey);
2856
+ } else if (this.lastReadAt.has(mapKey)) {
2857
+ const baseline = fetchedMtimes.get(u.key) ?? Date.now();
2858
+ this.lastReadAt.set(mapKey, baseline);
2859
+ }
2860
+ }
2861
+ }
2862
+ list(ctx, opts) {
2863
+ return this.inner.list(ctx, opts);
2864
+ }
2865
+ };
2866
+
2867
+ // src/path-boundary.ts
2868
+ import { resolve as resolve3, sep as sep2 } from "path";
2869
+ function assertWithinBase(base, target) {
2870
+ const resolvedBase = resolve3(base);
2871
+ const resolvedTarget = resolve3(target);
2872
+ if (resolvedTarget === resolvedBase) return;
2873
+ if (!resolvedTarget.startsWith(resolvedBase + sep2)) {
2874
+ throw new BoundaryEscapeError(resolvedBase, resolvedTarget);
2875
+ }
2876
+ }
2877
+ var BoundaryEscapeError = class extends Error {
2878
+ code = "path-boundary-escape";
2879
+ base;
2880
+ resolved;
2881
+ constructor(base, resolved) {
2882
+ super(`Path "${resolved}" escapes boundary "${base}"`);
2883
+ this.name = "BoundaryEscapeError";
2884
+ this.base = base;
2885
+ this.resolved = resolved;
2886
+ }
2887
+ };
1128
2888
 
1129
2889
  // src/plugin-registry.ts
1130
2890
  var PluginRegistry = class {
@@ -1253,14 +3013,230 @@ var ChainedProvider = class {
1253
3013
  return this.entries.find((e) => e.cooldownUntil <= now);
1254
3014
  }
1255
3015
  };
3016
+
3017
+ // src/providers/llm-registry.ts
3018
+ var DefaultLLMProviderRegistry = class {
3019
+ factories = /* @__PURE__ */ new Map();
3020
+ register(name, factory) {
3021
+ if (this.factories.has(name)) {
3022
+ throw new Error(`LLM provider "${name}" is already registered`);
3023
+ }
3024
+ this.factories.set(name, factory);
3025
+ }
3026
+ get(name) {
3027
+ return this.factories.get(name);
3028
+ }
3029
+ list() {
3030
+ return [...this.factories.keys()];
3031
+ }
3032
+ };
3033
+
3034
+ // src/providers/memory-registry.ts
3035
+ var DefaultMemoryProviderRegistry = class {
3036
+ factories = /* @__PURE__ */ new Map();
3037
+ register(name, factory) {
3038
+ if (this.factories.has(name)) {
3039
+ throw new Error(`Memory provider "${name}" is already registered`);
3040
+ }
3041
+ this.factories.set(name, factory);
3042
+ }
3043
+ get(name) {
3044
+ return this.factories.get(name);
3045
+ }
3046
+ list() {
3047
+ return [...this.factories.keys()];
3048
+ }
3049
+ };
3050
+
3051
+ // src/request-dump-store.ts
3052
+ var InMemoryRequestDumpStore = class {
3053
+ records = [];
3054
+ maxRecords;
3055
+ constructor(opts) {
3056
+ this.maxRecords = opts?.maxRecords ?? 1e3;
3057
+ }
3058
+ async append(record) {
3059
+ this.records.push(record);
3060
+ if (this.records.length > this.maxRecords) {
3061
+ this.records = this.records.slice(-this.maxRecords);
3062
+ }
3063
+ }
3064
+ async recent(opts) {
3065
+ let results = [...this.records].reverse();
3066
+ if (opts.sessionId) results = results.filter((r) => r.sessionId === opts.sessionId);
3067
+ if (opts.since) {
3068
+ const sinceTs = opts.since.getTime();
3069
+ results = results.filter((r) => new Date(r.timestamp).getTime() >= sinceTs);
3070
+ }
3071
+ results = results.slice(0, opts.limit);
3072
+ if (!opts.includeContent) {
3073
+ results = results.map((r) => {
3074
+ const { system, tools, messages, responseText, ...meta } = r;
3075
+ return meta;
3076
+ });
3077
+ }
3078
+ return results;
3079
+ }
3080
+ async close() {
3081
+ }
3082
+ getAll() {
3083
+ return this.records;
3084
+ }
3085
+ };
3086
+
3087
+ // src/sanitize-output.ts
3088
+ var ESC = "\x1B";
3089
+ var BEL = "\x07";
3090
+ var ANSI_RE = new RegExp(
3091
+ `${ESC}\\[[?!>]?[0-9;]*[A-Za-z~]|${ESC}\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)|${ESC}\\([A-B0-2]|${ESC}[DME78HNO=>cfn]`,
3092
+ "g"
3093
+ );
3094
+ function stripAnsiEscapes(input) {
3095
+ return input.replace(ANSI_RE, "");
3096
+ }
3097
+
3098
+ // src/url-validator.ts
3099
+ import { isIP } from "net";
3100
+ var IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
3101
+ function ip4ToInt(ip) {
3102
+ return ip.split(".").reduce((acc, octet) => acc << 8 | Number.parseInt(octet, 10), 0) >>> 0;
3103
+ }
3104
+ var PRIVATE_RANGES_V4 = [
3105
+ { start: ip4ToInt("0.0.0.0"), end: ip4ToInt("0.255.255.255") },
3106
+ { start: ip4ToInt("10.0.0.0"), end: ip4ToInt("10.255.255.255") },
3107
+ { start: ip4ToInt("100.64.0.0"), end: ip4ToInt("100.127.255.255") },
3108
+ { start: ip4ToInt("127.0.0.0"), end: ip4ToInt("127.255.255.255") },
3109
+ { start: ip4ToInt("169.254.0.0"), end: ip4ToInt("169.254.255.255") },
3110
+ { start: ip4ToInt("172.16.0.0"), end: ip4ToInt("172.31.255.255") },
3111
+ { start: ip4ToInt("192.168.0.0"), end: ip4ToInt("192.168.255.255") },
3112
+ { start: ip4ToInt("224.0.0.0"), end: ip4ToInt("239.255.255.255") },
3113
+ { start: ip4ToInt("240.0.0.0"), end: ip4ToInt("255.255.255.255") }
3114
+ ];
3115
+ function isValidIpv4(s) {
3116
+ const m = s.match(IPV4_RE);
3117
+ return m?.slice(1).every((octet) => Number(octet) <= 255) ?? false;
3118
+ }
3119
+ function isPrivateIpv4(ip) {
3120
+ if (!isValidIpv4(ip)) return false;
3121
+ const n = ip4ToInt(ip);
3122
+ return PRIVATE_RANGES_V4.some(({ start, end }) => n >= start && n <= end);
3123
+ }
3124
+ function isPrivateIpv6(ip) {
3125
+ const lower = ip.toLowerCase();
3126
+ if (lower === "::1" || lower === "::") return true;
3127
+ if (lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd")) return true;
3128
+ if (lower.startsWith("ff")) return true;
3129
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
3130
+ if (mapped) return isPrivateIpv4(mapped[1]);
3131
+ return false;
3132
+ }
3133
+ function isPrivateIp(ip) {
3134
+ return isPrivateIpv4(ip) || ip.includes(":") && isPrivateIpv6(ip);
3135
+ }
3136
+ function isLoopbackIp(ip) {
3137
+ if (ip.startsWith("127.")) return true;
3138
+ if (ip === "::1") return true;
3139
+ const mapped = ip.toLowerCase().match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
3140
+ if (mapped?.[1].startsWith("127.")) return true;
3141
+ return false;
3142
+ }
3143
+ var CLOUD_METADATA_HOSTS = /* @__PURE__ */ new Set([
3144
+ "169.254.169.254",
3145
+ "metadata.google.internal",
3146
+ "metadata",
3147
+ "metadata.azure.com",
3148
+ "metadata.aws.amazon.com",
3149
+ "fd00:ec2::254",
3150
+ "100.100.100.200",
3151
+ "169.254.0.23"
3152
+ ]);
3153
+ var SsrfError = class extends Error {
3154
+ constructor(url, reason) {
3155
+ super(`SSRF blocked: ${reason} (${url})`);
3156
+ this.name = "SsrfError";
3157
+ }
3158
+ };
3159
+ function validateUrl(urlStr, opts) {
3160
+ let url;
3161
+ try {
3162
+ url = new URL(urlStr);
3163
+ } catch {
3164
+ throw new SsrfError(urlStr, "invalid URL");
3165
+ }
3166
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
3167
+ throw new SsrfError(urlStr, `scheme "${url.protocol.replace(":", "")}" not allowed`);
3168
+ }
3169
+ if (url.username || url.password) {
3170
+ throw new SsrfError(urlStr, "URLs with embedded credentials are not allowed");
3171
+ }
3172
+ const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
3173
+ if (opts?.trustedHosts?.includes(hostname)) {
3174
+ return url;
3175
+ }
3176
+ if (CLOUD_METADATA_HOSTS.has(hostname)) {
3177
+ throw new SsrfError(urlStr, `cloud-metadata host "${hostname}" is always denied`);
3178
+ }
3179
+ if (isIP(hostname)) {
3180
+ if (opts?.allowLocalhost && isLoopbackIp(hostname)) {
3181
+ return url;
3182
+ }
3183
+ if (isPrivateIp(hostname)) {
3184
+ throw new SsrfError(urlStr, "private/internal IP address");
3185
+ }
3186
+ } else {
3187
+ if (!opts?.allowLocalhost) {
3188
+ if (hostname === "localhost" || hostname.endsWith(".local")) {
3189
+ throw new SsrfError(urlStr, "localhost not allowed");
3190
+ }
3191
+ }
3192
+ if (hostname.endsWith(".internal")) {
3193
+ throw new SsrfError(urlStr, "internal hostname not allowed");
3194
+ }
3195
+ }
3196
+ return url;
3197
+ }
1256
3198
  export {
1257
3199
  AgentLoop,
3200
+ BoundaryEscapeError,
1258
3201
  ChainedProvider,
3202
+ ClarifyBridge,
3203
+ ClarifyBusyError,
3204
+ ClarifyNoSurfaceError,
3205
+ ClarifyTimedOutNoDefaultError,
3206
+ DefaultContextEngineRegistry,
1259
3207
  DefaultHookRegistry,
3208
+ DefaultLLMProviderRegistry,
3209
+ DefaultMemoryProviderRegistry,
1260
3210
  DefaultPersonalityRegistry,
1261
3211
  DefaultToolRegistry,
3212
+ DropOldestEngine,
3213
+ EagerPrefetchPolicy,
3214
+ FileClarifyStore,
3215
+ InMemoryRequestDumpStore,
1262
3216
  InMemorySessionStore,
3217
+ KNOWN_AGENT_EVENT_TYPES,
3218
+ LastWriteWinsPolicy,
3219
+ LazyOnDemandPolicy,
3220
+ MemoryConflictError2 as MemoryConflictError,
1263
3221
  NoopMemoryProvider,
1264
- PluginRegistry
3222
+ PluginRegistry,
3223
+ ReferencePreservingEngine,
3224
+ ScopedFetchImpl,
3225
+ ScopedFsImpl,
3226
+ ScopedProcessImpl,
3227
+ ScopedSecretsImpl,
3228
+ SemanticSummaryEngine,
3229
+ SsrfError,
3230
+ assertWithinBase,
3231
+ buildAttachmentAnnotation,
3232
+ estimateMessageTokens,
3233
+ estimateMessagesTokens,
3234
+ estimateTokens,
3235
+ isKnownAgentEvent,
3236
+ makeTestToolContext,
3237
+ resolveCapabilities,
3238
+ stripAnsiEscapes,
3239
+ validateRegistration,
3240
+ validateUrl
1265
3241
  };
1266
3242
  //# sourceMappingURL=index.js.map