@inixiative/foundry-core 0.2.0 → 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.
@@ -1,12 +1,20 @@
1
- import { mkdirSync, existsSync } from "node:fs";
2
- import { join, resolve, relative } from "node:path";
3
- import { computeHash, copyMessageIdentity, type LogicalMessageIdentity, type ContextSource, type SourceLoadHint, type SourceSelectionReport, REQUIRED_CONTEXT_BLOCKED } from "../context-layer";
4
- import type { HydrationAdapter, ContextRef } from "../hydrator";
5
- import { normalizeScope, type OwnershipScope } from "../scope";
6
- import type { Signal } from "../signal";
7
- import type { MemoryEntry, MemoryVisibility } from "../tools";
8
-
9
- export type { MemoryEntry } from "../tools";
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import {
4
+ type ContextSource,
5
+ computeHash,
6
+ copyMessageIdentity,
7
+ type LogicalMessageIdentity,
8
+ REQUIRED_CONTEXT_BLOCKED,
9
+ type SourceLoadHint,
10
+ type SourceSelectionReport,
11
+ } from '../context-layer';
12
+ import type { ContextRef, HydrationAdapter } from '../hydrator';
13
+ import { normalizeScope, type OwnershipScope } from '../scope';
14
+ import type { Signal } from '../signal';
15
+ import type { MemoryEntry, MemoryVisibility } from '../tools';
16
+
17
+ export type { MemoryEntry } from '../tools';
10
18
 
11
19
  // ---------------------------------------------------------------------------
12
20
  // Bounded selection — the retained log is complete; the injected text is not
@@ -36,7 +44,7 @@ export interface MemorySelectionPolicy {
36
44
  * conflict that the executor turns into an explicit error before any
37
45
  * provider call. Neither mode ever names a retrieval tool that may not exist.
38
46
  */
39
- readonly oversizedPinned: "inject" | "block";
47
+ readonly oversizedPinned: 'inject' | 'block';
40
48
  /** Most records retrieved for relevance to the current message. */
41
49
  readonly retrievalLimit: number;
42
50
  /** Distinct focus terms an audit record must match to be retrieved. Other kinds need one. */
@@ -45,22 +53,43 @@ export interface MemorySelectionPolicy {
45
53
 
46
54
  export const DEFAULT_MEMORY_SELECTION: MemorySelectionPolicy = Object.freeze({
47
55
  budgetChars: 6000,
48
- pinnedKinds: Object.freeze(["instruction", "pin", "pinned", "convention", "correction", "decision", "requirement"]),
49
- auditOnlyKinds: Object.freeze(["dispatch", "classification", "context_loaded", "session_compacted",
50
- "auxiliary_session_compacted", "info", "domain_learning", "tool_observation"]),
56
+ pinnedKinds: Object.freeze([
57
+ 'instruction',
58
+ 'pin',
59
+ 'pinned',
60
+ 'convention',
61
+ 'correction',
62
+ 'decision',
63
+ 'requirement',
64
+ ]),
65
+ auditOnlyKinds: Object.freeze([
66
+ 'dispatch',
67
+ 'classification',
68
+ 'context_loaded',
69
+ 'session_compacted',
70
+ 'auxiliary_session_compacted',
71
+ 'info',
72
+ 'domain_learning',
73
+ 'tool_observation',
74
+ 'guard_finding',
75
+ ]),
51
76
  recentLimit: 8,
52
77
  maxEntryChars: 1200,
53
78
  pinnedHardCapChars: 24_000,
54
- oversizedPinned: "inject",
79
+ oversizedPinned: 'inject',
55
80
  retrievalLimit: 6,
56
81
  auditRelevanceTerms: 2,
57
82
  });
58
83
 
59
84
  const NUMERIC_BOUNDS: Record<string, readonly [number, number]> = {
60
- budgetChars: [200, 5_000_000], maxEntryChars: [50, 5_000_000], pinnedHardCapChars: [100, 50_000_000],
61
- recentLimit: [0, 10_000], retrievalLimit: [0, 10_000], auditRelevanceTerms: [1, 48],
85
+ budgetChars: [200, 5_000_000],
86
+ maxEntryChars: [50, 5_000_000],
87
+ pinnedHardCapChars: [100, 50_000_000],
88
+ recentLimit: [0, 10_000],
89
+ retrievalLimit: [0, 10_000],
90
+ auditRelevanceTerms: [1, 48],
62
91
  };
63
- const KIND_LISTS = new Set(["pinnedKinds", "auditOnlyKinds"]);
92
+ const KIND_LISTS = new Set(['pinnedKinds', 'auditOnlyKinds']);
64
93
 
65
94
  /**
66
95
  * Runtime validation for a selection policy override coming from persisted or
@@ -68,8 +97,10 @@ const KIND_LISTS = new Set(["pinnedKinds", "auditOnlyKinds"]);
68
97
  * descriptive Error naming the offending field; returns a defensive copy.
69
98
  */
70
99
  export function validateMemorySelection(input: unknown): Partial<MemorySelectionPolicy> {
71
- if (input === null || typeof input !== "object" || Array.isArray(input)) {
72
- throw new Error(`memory selection policy must be an object, got ${input === null ? "null" : Array.isArray(input) ? "array" : typeof input}`);
100
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
101
+ throw new Error(
102
+ `memory selection policy must be an object, got ${input === null ? 'null' : Array.isArray(input) ? 'array' : typeof input}`,
103
+ );
73
104
  }
74
105
  const out: Record<string, unknown> = {};
75
106
  for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
@@ -77,40 +108,98 @@ export function validateMemorySelection(input: unknown): Partial<MemorySelection
77
108
  // Own-property lookup: inherited names such as constructor or __proto__ are not policy fields.
78
109
  const bounds = Object.hasOwn(NUMERIC_BOUNDS, key) ? NUMERIC_BOUNDS[key] : undefined;
79
110
  if (bounds) {
80
- if (typeof value !== "number" || !Number.isInteger(value) || value < bounds[0] || value > bounds[1]) {
81
- throw new Error(`memory selection policy ${key} must be an integer between ${bounds[0]} and ${bounds[1]}, got ${JSON.stringify(value)}`);
111
+ if (
112
+ typeof value !== 'number' ||
113
+ !Number.isInteger(value) ||
114
+ value < bounds[0] ||
115
+ value > bounds[1]
116
+ ) {
117
+ throw new Error(
118
+ `memory selection policy ${key} must be an integer between ${bounds[0]} and ${bounds[1]}, got ${JSON.stringify(value)}`,
119
+ );
82
120
  }
83
121
  out[key] = value;
84
122
  } else if (KIND_LISTS.has(key)) {
85
- if (!Array.isArray(value) || value.length > 256 || value.some((k) => typeof k !== "string" || !k.trim() || k.length > 64)
86
- || new Set(value).size !== value.length) {
87
- throw new Error(`memory selection policy ${key} must be an array of unique non-empty strings, got ${JSON.stringify(value)}`);
123
+ if (
124
+ !Array.isArray(value) ||
125
+ value.length > 256 ||
126
+ value.some((k) => typeof k !== 'string' || !k.trim() || k.length > 64) ||
127
+ new Set(value).size !== value.length
128
+ ) {
129
+ throw new Error(
130
+ `memory selection policy ${key} must be an array of unique non-empty strings, got ${JSON.stringify(value)}`,
131
+ );
88
132
  }
89
133
  out[key] = [...value];
90
- } else if (key === "oversizedPinned") {
91
- if (value !== "inject" && value !== "block") throw new Error(`memory selection policy oversizedPinned must be "inject" or "block", got ${JSON.stringify(value)}`);
134
+ } else if (key === 'oversizedPinned') {
135
+ if (value !== 'inject' && value !== 'block')
136
+ throw new Error(
137
+ `memory selection policy oversizedPinned must be "inject" or "block", got ${JSON.stringify(value)}`,
138
+ );
92
139
  out[key] = value;
93
140
  } else {
94
141
  throw new Error(`memory selection policy has unknown field ${JSON.stringify(key)}`);
95
142
  }
96
143
  }
97
- if (typeof out.maxEntryChars === "number" && typeof out.budgetChars === "number" && out.maxEntryChars > out.budgetChars) {
98
- throw new Error(`memory selection policy maxEntryChars (${out.maxEntryChars}) cannot exceed budgetChars (${out.budgetChars})`);
144
+ if (
145
+ typeof out.maxEntryChars === 'number' &&
146
+ typeof out.budgetChars === 'number' &&
147
+ out.maxEntryChars > out.budgetChars
148
+ ) {
149
+ throw new Error(
150
+ `memory selection policy maxEntryChars (${out.maxEntryChars}) cannot exceed budgetChars (${out.budgetChars})`,
151
+ );
99
152
  }
100
153
  return out as Partial<MemorySelectionPolicy>;
101
154
  }
102
155
 
103
156
  export type MemorySelectionReport = SourceSelectionReport;
104
157
 
105
- const STOPWORDS = new Set(["that", "this", "with", "from", "have", "what", "when", "where", "which", "will", "would",
106
- "should", "could", "about", "there", "their", "these", "those", "does", "into", "only", "also", "than", "then",
107
- "them", "they", "your", "just", "like", "make", "made", "over", "under", "after", "before", "while", "please"]);
158
+ const STOPWORDS = new Set([
159
+ 'that',
160
+ 'this',
161
+ 'with',
162
+ 'from',
163
+ 'have',
164
+ 'what',
165
+ 'when',
166
+ 'where',
167
+ 'which',
168
+ 'will',
169
+ 'would',
170
+ 'should',
171
+ 'could',
172
+ 'about',
173
+ 'there',
174
+ 'their',
175
+ 'these',
176
+ 'those',
177
+ 'does',
178
+ 'into',
179
+ 'only',
180
+ 'also',
181
+ 'than',
182
+ 'then',
183
+ 'them',
184
+ 'they',
185
+ 'your',
186
+ 'just',
187
+ 'like',
188
+ 'make',
189
+ 'made',
190
+ 'over',
191
+ 'under',
192
+ 'after',
193
+ 'before',
194
+ 'while',
195
+ 'please',
196
+ ]);
108
197
 
109
198
  /** Deterministic terms from the current message: lowercase words of four or more letters, no stopwords. */
110
199
  export function focusTerms(focus: string, max = 48): string[] {
111
200
  const seen = new Set<string>();
112
201
  for (const raw of focus.toLowerCase().split(/[^a-z0-9_.-]+/)) {
113
- const term = raw.replace(/^[._-]+|[._-]+$/g, "");
202
+ const term = raw.replace(/^[._-]+|[._-]+$/g, '');
114
203
  if (term.length < 4 || STOPWORDS.has(term) || seen.has(term)) continue;
115
204
  seen.add(term);
116
205
  if (seen.size >= max) break;
@@ -124,8 +213,10 @@ type Excerpt = { text: string; truncated: boolean; ranges?: CharRange[] };
124
213
  /** Render the named character ranges of a record; every excerpt states exactly which parts of the record it carries. */
125
214
  function renderRanges(entry: MemoryEntry, ranges: CharRange[]): string {
126
215
  const total = entry.content.length;
127
- const pieces = ranges.map(([a, b]) => `${a > 0 ? "… " : ""}${entry.content.slice(a, b)}${b < total ? " …" : ""}`);
128
- return `${pieces.join(" ")} [excerpt: chars ${ranges.map(([a, b]) => `${a}-${b}`).join(", ")} of ${total}; full record ${entry.id} retained in the owned log]`;
216
+ const pieces = ranges.map(
217
+ ([a, b]) => `${a > 0 ? '… ' : ''}${entry.content.slice(a, b)}${b < total ? ' …' : ''}`,
218
+ );
219
+ return `${pieces.join(' ')} [excerpt: chars ${ranges.map(([a, b]) => `${a}-${b}`).join(', ')} of ${total}; full record ${entry.id} retained in the owned log]`;
129
220
  }
130
221
 
131
222
  /** Leading excerpt for records selected by recency, where no particular passage is the point. */
@@ -145,7 +236,10 @@ function matchExcerpt(entry: MemoryEntry, matched: readonly string[], max: numbe
145
236
  const total = entry.content.length;
146
237
  if (total <= max) return { text: entry.content, truncated: false };
147
238
  const lower = entry.content.toLowerCase();
148
- const hits = matched.map((t) => lower.indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b);
239
+ const hits = matched
240
+ .map((t) => lower.indexOf(t))
241
+ .filter((i) => i >= 0)
242
+ .sort((a, b) => a - b);
149
243
  if (!hits.length) return prefixExcerpt(entry, max);
150
244
  const width = Math.max(1, Math.floor(max / hits.length));
151
245
  const ranges: CharRange[] = [];
@@ -160,8 +254,10 @@ function matchExcerpt(entry: MemoryEntry, matched: readonly string[], max: numbe
160
254
  }
161
255
 
162
256
  function line(entry: MemoryEntry, text: string, note?: string): string {
163
- const when = Number.isFinite(entry.timestamp) ? new Date(entry.timestamp).toISOString() : "unknown time";
164
- return `[${entry.kind}] ${entry.id} (${when}${note ? `; ${note}` : ""}): ${text}`;
257
+ const when = Number.isFinite(entry.timestamp)
258
+ ? new Date(entry.timestamp).toISOString()
259
+ : 'unknown time';
260
+ return `[${entry.kind}] ${entry.id} (${when}${note ? `; ${note}` : ''}): ${text}`;
165
261
  }
166
262
 
167
263
  /**
@@ -177,20 +273,35 @@ export function selectMemory(
177
273
  currentMessage?: LogicalMessageIdentity,
178
274
  ): { text: string; report: MemorySelectionReport } {
179
275
  const identity = copyMessageIdentity(currentMessage);
180
- const entries = [...visible].sort((a, b) => a.timestamp - b.timestamp || a.id.localeCompare(b.id));
276
+ const entries = [...visible].sort(
277
+ (a, b) => a.timestamp - b.timestamp || a.id.localeCompare(b.id),
278
+ );
181
279
  const pinned = new Set(policy.pinnedKinds);
182
280
  const audit = new Set(policy.auditOnlyKinds);
183
- const selected: MemorySelectionReport["selected"][number][] = [];
184
- const omitted = new Map<string, MemorySelectionReport["omitted"][number]>();
185
- const conflicts: MemorySelectionReport["conflicts"][number][] = [];
281
+ const selected: MemorySelectionReport['selected'][number][] = [];
282
+ const omitted = new Map<string, MemorySelectionReport['omitted'][number]>();
283
+ const conflicts: MemorySelectionReport['conflicts'][number][] = [];
186
284
  const taken = new Set<string>();
187
285
  let used = 0;
188
- const omit = (e: MemoryEntry, reason: string) => omitted.set(e.id, { id: e.id, reason, chars: e.content.length, kind: e.kind });
189
- const select = (e: MemoryEntry, reason: string, rendered: string, extra: Partial<MemorySelectionReport["selected"][number]> = {}) => {
286
+ const omit = (e: MemoryEntry, reason: string) =>
287
+ omitted.set(e.id, { id: e.id, reason, chars: e.content.length, kind: e.kind });
288
+ const select = (
289
+ e: MemoryEntry,
290
+ reason: string,
291
+ rendered: string,
292
+ extra: Partial<MemorySelectionReport['selected'][number]> = {},
293
+ ) => {
190
294
  used += rendered.length;
191
295
  taken.add(e.id);
192
296
  omitted.delete(e.id);
193
- selected.push({ id: e.id, reason, chars: rendered.length, kind: e.kind, timestamp: e.timestamp, ...extra });
297
+ selected.push({
298
+ id: e.id,
299
+ reason,
300
+ chars: rendered.length,
301
+ kind: e.kind,
302
+ timestamp: e.timestamp,
303
+ ...extra,
304
+ });
194
305
  };
195
306
  const withExcerpt = (x: Excerpt) => (x.truncated ? { truncated: true, ranges: x.ranges } : {});
196
307
 
@@ -204,10 +315,13 @@ export function selectMemory(
204
315
  if (!pinned.has(entry.kind)) continue;
205
316
  if (entry.content.length > policy.pinnedHardCapChars) {
206
317
  oversized.push(entry.id);
207
- if (policy.oversizedPinned === "block") {
318
+ if (policy.oversizedPinned === 'block') {
208
319
  // A mandatory record that cannot be carried is a reason to stop, not a
209
320
  // note to the model. The executor refuses provider execution on this conflict.
210
- const notice = line(entry, `BLOCKED: ${entry.content.length} chars exceeds the ${policy.pinnedHardCapChars}-char pinned cap and no scoped retrieval is available. Execution must not proceed without this record.`);
321
+ const notice = line(
322
+ entry,
323
+ `BLOCKED: ${entry.content.length} chars exceeds the ${policy.pinnedHardCapChars}-char pinned cap and no scoped retrieval is available. Execution must not proceed without this record.`,
324
+ );
211
325
  used += notice.length;
212
326
  pinnedLines.push(notice);
213
327
  taken.add(entry.id);
@@ -219,32 +333,64 @@ export function selectMemory(
219
333
  const rendered = line(entry, entry.content);
220
334
  if (used + rendered.length > policy.budgetChars) overBudget.push(entry.id);
221
335
  pinnedLines.push(rendered);
222
- select(entry, "pinned", rendered);
336
+ select(entry, 'pinned', rendered);
223
337
  }
224
338
  if (overBudget.length) {
225
- conflicts.push({ kind: "pinned-over-budget", ids: overBudget,
226
- detail: `Pinned records exceed the ${policy.budgetChars}-char budget; they are injected in full anyway and the overrun is visible here.` });
339
+ conflicts.push({
340
+ kind: 'pinned-over-budget',
341
+ ids: overBudget,
342
+ detail: `Pinned records exceed the ${policy.budgetChars}-char budget; they are injected in full anyway and the overrun is visible here.`,
343
+ });
227
344
  }
228
345
  if (oversized.length) {
229
- conflicts.push(policy.oversizedPinned === "block"
230
- ? { kind: REQUIRED_CONTEXT_BLOCKED, ids: oversized,
231
- detail: `Pinned records larger than the ${policy.pinnedHardCapChars}-char cap cannot be carried and no scoped retrieval is available; provider execution is refused until an operator raises the cap or supplies retrieval.` }
232
- : { kind: "pinned-oversized", ids: oversized,
233
- detail: `Pinned records larger than the ${policy.pinnedHardCapChars}-char cap were injected in full because no scoped retrieval is available; the overrun is visible here.` });
346
+ conflicts.push(
347
+ policy.oversizedPinned === 'block'
348
+ ? {
349
+ kind: REQUIRED_CONTEXT_BLOCKED,
350
+ ids: oversized,
351
+ detail: `Pinned records larger than the ${policy.pinnedHardCapChars}-char cap cannot be carried and no scoped retrieval is available; provider execution is refused until an operator raises the cap or supplies retrieval.`,
352
+ }
353
+ : {
354
+ kind: 'pinned-oversized',
355
+ ids: oversized,
356
+ detail: `Pinned records larger than the ${policy.pinnedHardCapChars}-char cap were injected in full because no scoped retrieval is available; the overrun is visible here.`,
357
+ },
358
+ );
234
359
  }
235
360
 
236
361
  // The current request's own audit is not independent evidence for it.
237
362
  // Pinning wins above; owner and explicit message identity must both match.
238
363
  // Text matches, nested IDs and legacy records without an owner prove nothing.
239
364
  for (const entry of entries) {
240
- if (!identity || taken.has(entry.id) || !audit.has(entry.kind) || !sameOwner(entry.owner, identity)) continue;
365
+ if (
366
+ !identity ||
367
+ taken.has(entry.id) ||
368
+ !audit.has(entry.kind) ||
369
+ !sameOwner(entry.owner, identity)
370
+ )
371
+ continue;
241
372
  let content: unknown;
242
- try { content = JSON.parse(entry.content); } catch { continue; }
243
- if (!content || typeof content !== "object" || Array.isArray(content) ||
244
- !("messageId" in content) || content.messageId !== identity.messageId) continue;
373
+ try {
374
+ content = JSON.parse(entry.content);
375
+ } catch {
376
+ continue;
377
+ }
378
+ if (
379
+ !content ||
380
+ typeof content !== 'object' ||
381
+ Array.isArray(content) ||
382
+ !('messageId' in content) ||
383
+ content.messageId !== identity.messageId
384
+ )
385
+ continue;
245
386
  taken.add(entry.id);
246
- omitted.set(entry.id, { id: entry.id, kind: entry.kind, chars: entry.content.length,
247
- reason: "current-message-audit", excludedFor: identity });
387
+ omitted.set(entry.id, {
388
+ id: entry.id,
389
+ kind: entry.kind,
390
+ chars: entry.content.length,
391
+ reason: 'current-message-audit',
392
+ excludedFor: identity,
393
+ });
248
394
  }
249
395
 
250
396
  // 2. Relevant to this message: retrieved across every visible kind, audit kinds need a stronger match.
@@ -258,15 +404,28 @@ export function selectMemory(
258
404
  const matched = terms.filter((t) => lower.includes(t));
259
405
  return { e, matched };
260
406
  })
261
- .filter(({ e, matched }) => matched.length >= (audit.has(e.kind) ? policy.auditRelevanceTerms : 1))
262
- .sort((a, b) => b.matched.length - a.matched.length || b.e.timestamp - a.e.timestamp || a.e.id.localeCompare(b.e.id));
407
+ .filter(
408
+ ({ e, matched }) => matched.length >= (audit.has(e.kind) ? policy.auditRelevanceTerms : 1),
409
+ )
410
+ .sort(
411
+ (a, b) =>
412
+ b.matched.length - a.matched.length ||
413
+ b.e.timestamp - a.e.timestamp ||
414
+ a.e.id.localeCompare(b.e.id),
415
+ );
263
416
  for (const { e, matched } of scored) {
264
- if (relevantLines.length >= policy.retrievalLimit) { omit(e, "retrieval-limit"); continue; }
417
+ if (relevantLines.length >= policy.retrievalLimit) {
418
+ omit(e, 'retrieval-limit');
419
+ continue;
420
+ }
265
421
  const x = matchExcerpt(e, matched, policy.maxEntryChars);
266
- const rendered = line(e, x.text, `matched: ${matched.join(", ")}`);
267
- if (used + rendered.length > policy.budgetChars) { omit(e, "budget"); continue; }
422
+ const rendered = line(e, x.text, `matched: ${matched.join(', ')}`);
423
+ if (used + rendered.length > policy.budgetChars) {
424
+ omit(e, 'budget');
425
+ continue;
426
+ }
268
427
  relevantLines.push(rendered);
269
- select(e, "relevant", rendered, { matched, ...withExcerpt(x) });
428
+ select(e, 'relevant', rendered, { matched, ...withExcerpt(x) });
270
429
  }
271
430
  }
272
431
 
@@ -276,43 +435,69 @@ export function selectMemory(
276
435
  let recentSeen = 0;
277
436
  for (const entry of [...entries].reverse()) {
278
437
  if (taken.has(entry.id) || pinned.has(entry.kind)) continue;
279
- if (audit.has(entry.kind)) { omit(entry, "audit-only"); continue; }
438
+ if (audit.has(entry.kind)) {
439
+ omit(entry, 'audit-only');
440
+ continue;
441
+ }
280
442
  recentSeen += 1;
281
- if (recentSeen > policy.recentLimit) { omit(entry, "recent-limit"); continue; }
443
+ if (recentSeen > policy.recentLimit) {
444
+ omit(entry, 'recent-limit');
445
+ continue;
446
+ }
282
447
  const x = prefixExcerpt(entry, policy.maxEntryChars);
283
448
  const rendered = line(entry, x.text);
284
- if (used + rendered.length > policy.budgetChars) { omit(entry, "budget"); continue; }
449
+ if (used + rendered.length > policy.budgetChars) {
450
+ omit(entry, 'budget');
451
+ continue;
452
+ }
285
453
  recentLines.push(rendered);
286
- select(entry, "recent", rendered, withExcerpt(x));
454
+ select(entry, 'recent', rendered, withExcerpt(x));
287
455
  }
288
456
 
289
457
  // 4. Omission summary in the injected text itself, so the model and the operator see the same account.
290
458
  const omittedList = [...omitted.values()];
291
- const auditOmitted = omittedList.filter((o) => o.reason === "audit-only" || o.reason === "current-message-audit");
292
- const currentAuditCount = omittedList.filter((o) => o.reason === "current-message-audit").length;
459
+ const auditOmitted = omittedList.filter(
460
+ (o) => o.reason === 'audit-only' || o.reason === 'current-message-audit',
461
+ );
462
+ const currentAuditCount = omittedList.filter((o) => o.reason === 'current-message-audit').length;
293
463
  const otherOmitted = omittedList.length - auditOmitted.length;
294
464
  const byKind = new Map<string, number>();
295
- for (const o of auditOmitted) byKind.set(o.kind ?? "unknown", (byKind.get(o.kind ?? "unknown") ?? 0) + 1);
296
- const kinds = [...byKind.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([k, n]) => `${k} ${n}`).join(", ");
465
+ for (const o of auditOmitted)
466
+ byKind.set(o.kind ?? 'unknown', (byKind.get(o.kind ?? 'unknown') ?? 0) + 1);
467
+ const kinds = [...byKind.entries()]
468
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
469
+ .map(([k, n]) => `${k} ${n}`)
470
+ .join(', ');
297
471
  const retainedChars = entries.reduce((sum, e) => sum + e.content.length, 0);
298
472
  const summary = [
299
- `${auditOmitted.length} audit records (${auditOmitted.reduce((s, o) => s + o.chars, 0)} chars${kinds ? `; ${kinds}` : ""})` +
473
+ `${auditOmitted.length} audit records (${auditOmitted.reduce((s, o) => s + o.chars, 0)} chars${kinds ? `; ${kinds}` : ''})` +
300
474
  ` and ${otherOmitted} other captures were not injected.`,
301
475
  `The complete owned log (${entries.length} records, ${retainedChars} chars) is retained unchanged; records not shown here were not supplied to this turn.`,
302
- ...(currentAuditCount ? [`${currentAuditCount} records were excluded as the current message's own audit (matched logical message, thread and project identity).`] : []),
303
- `Selection budget ${policy.budgetChars} chars, used ${used}${used > policy.budgetChars ? " (exceeded by pinned records)" : ""}.`,
304
- ].join(" ");
476
+ ...(currentAuditCount
477
+ ? [
478
+ `${currentAuditCount} records were excluded as the current message's own audit (matched logical message, thread and project identity).`,
479
+ ]
480
+ : []),
481
+ `Selection budget ${policy.budgetChars} chars, used ${used}${used > policy.budgetChars ? ' (exceeded by pinned records)' : ''}.`,
482
+ ].join(' ');
305
483
 
306
484
  const sections: string[] = [];
307
- if (pinnedLines.length) sections.push(`## Pinned (${pinnedLines.length})\n${pinnedLines.join("\n")}`);
308
- if (relevantLines.length) sections.push(`## Relevant to this message (${relevantLines.length})\n${relevantLines.join("\n")}`);
309
- if (recentLines.length) sections.push(`## Recent captures (${recentLines.length})\n${recentLines.join("\n")}`);
485
+ if (pinnedLines.length)
486
+ sections.push(`## Pinned (${pinnedLines.length})\n${pinnedLines.join('\n')}`);
487
+ if (relevantLines.length)
488
+ sections.push(
489
+ `## Relevant to this message (${relevantLines.length})\n${relevantLines.join('\n')}`,
490
+ );
491
+ if (recentLines.length)
492
+ sections.push(`## Recent captures (${recentLines.length})\n${recentLines.join('\n')}`);
310
493
  sections.push(`## Not injected\n${summary}`);
311
494
 
312
495
  return {
313
- text: sections.join("\n\n"),
496
+ text: sections.join('\n\n'),
314
497
  report: {
315
- selected, omitted: omittedList, considered: entries.length,
498
+ selected,
499
+ omitted: omittedList,
500
+ considered: entries.length,
316
501
  retained: { count: entries.length, chars: retainedChars },
317
502
  budget: { chars: policy.budgetChars, used, exceeded: used > policy.budgetChars },
318
503
  conflicts,
@@ -354,11 +539,11 @@ export interface MemorySourceOpts {
354
539
  /** Whether one entry is visible to a reader. */
355
540
  export function entryVisibleTo(entry: MemoryEntry, scope: MemoryReadScope): boolean {
356
541
  switch (entry.visibility) {
357
- case "global":
542
+ case 'global':
358
543
  return true;
359
- case "project":
544
+ case 'project':
360
545
  return !!scope.projectId && entry.owner?.projectId === scope.projectId;
361
- case "thread":
546
+ case 'thread':
362
547
  // A thread id alone is not identity: the owner's project must match too,
363
548
  // so a same-named thread in another project cannot read it.
364
549
  return (
@@ -387,7 +572,7 @@ function formatEntries(entries: MemoryEntry[]): string {
387
572
  return entries
388
573
  .sort((a, b) => b.timestamp - a.timestamp)
389
574
  .map((e) => `[${e.kind}] ${e.id}: ${e.content}`)
390
- .join("\n");
575
+ .join('\n');
391
576
  }
392
577
 
393
578
  /**
@@ -417,10 +602,10 @@ export class FileMemory {
417
602
 
418
603
  /** Resolve an entry path safely — prevents path traversal. */
419
604
  private _safePath(id: string): string {
420
- const safe = id.replace(/[\/\\\.]+/g, "_");
605
+ const safe = id.replace(/[/\\.]+/g, '_');
421
606
  const path = join(this.dir, `${safe}.json`);
422
607
  const rel = relative(this.dir, resolve(path));
423
- if (rel.startsWith("..") || rel.includes("/..")) {
608
+ if (rel.startsWith('..') || rel.includes('/..')) {
424
609
  throw new Error(`Invalid entry id: ${id}`);
425
610
  }
426
611
  return path;
@@ -428,7 +613,7 @@ export class FileMemory {
428
613
 
429
614
  /** Load all entries from disk. */
430
615
  async load(): Promise<void> {
431
- const files = new Bun.Glob("*.json").scanSync(this.dir);
616
+ const files = new Bun.Glob('*.json').scanSync(this.dir);
432
617
  for (const file of files) {
433
618
  const path = join(this.dir, file);
434
619
  const data = await Bun.file(path).json();
@@ -456,10 +641,10 @@ export class FileMemory {
456
641
  async publish(id: string, visibility: MemoryVisibility): Promise<MemoryEntry> {
457
642
  const entry = this._entries.get(id);
458
643
  if (!entry) throw new Error(`Memory entry not found: ${id}`);
459
- if (visibility === "project" && !entry.owner?.projectId) {
644
+ if (visibility === 'project' && !entry.owner?.projectId) {
460
645
  throw new Error(`Cannot publish "${id}" to a project: it has no project owner`);
461
646
  }
462
- if (visibility === "thread" && !entry.owner?.threadId) {
647
+ if (visibility === 'thread' && !entry.owner?.threadId) {
463
648
  throw new Error(`Cannot restrict "${id}" to a thread: it has no thread owner`);
464
649
  }
465
650
  const updated = { ...entry, visibility };
@@ -481,9 +666,7 @@ export class FileMemory {
481
666
  /** Search entries by content substring. */
482
667
  search(query: string): MemoryEntry[] {
483
668
  const lower = query.toLowerCase();
484
- return [...this._entries.values()].filter((e) =>
485
- e.content.toLowerCase().includes(lower)
486
- );
669
+ return [...this._entries.values()].filter((e) => e.content.toLowerCase().includes(lower));
487
670
  }
488
671
 
489
672
  /** Delete an entry. Removes from disk first to prevent desync. */
@@ -491,7 +674,7 @@ export class FileMemory {
491
674
  if (!this._entries.has(id)) return false;
492
675
  const path = this._safePath(id);
493
676
  try {
494
- const { unlinkSync } = await import("node:fs");
677
+ const { unlinkSync } = await import('node:fs');
495
678
  unlinkSync(path);
496
679
  } catch (err) {
497
680
  console.warn(`[FileMemory] unlink failed for "${id}":`, (err as Error).message);
@@ -503,7 +686,7 @@ export class FileMemory {
503
686
  /** Get all entries as a formatted string (for use as context). */
504
687
  toContext(): string {
505
688
  const entries = [...this._entries.values()];
506
- if (entries.length === 0) return "(no entries)";
689
+ if (entries.length === 0) return '(no entries)';
507
690
  return formatEntries(entries);
508
691
  }
509
692
 
@@ -518,20 +701,21 @@ export class FileMemory {
518
701
  * source's declared scope for the owning thread/project.
519
702
  */
520
703
  asSource(id: string, opts?: string | MemorySourceOpts): ContextSource {
521
- const options: MemorySourceOpts = typeof opts === "string" ? { kind: opts } : opts ?? {};
704
+ const options: MemorySourceOpts = typeof opts === 'string' ? { kind: opts } : (opts ?? {});
522
705
  const mem = this;
523
- const policy: MemorySelectionPolicy | undefined = options.selection === undefined || options.selection === false
524
- ? undefined
525
- : { ...DEFAULT_MEMORY_SELECTION, ...validateMemorySelection(options.selection) };
706
+ const policy: MemorySelectionPolicy | undefined =
707
+ options.selection === undefined || options.selection === false
708
+ ? undefined
709
+ : { ...DEFAULT_MEMORY_SELECTION, ...validateMemorySelection(options.selection) };
526
710
 
527
711
  const make = (bound: OwnershipScope): ContextSource => {
528
712
  let lastReport: MemorySelectionReport | undefined;
529
713
  const readScope = (): MemoryReadScope => ({
530
714
  // Resolve the bound scope at load time: a thread's project may be
531
715
  // assigned after creation, and every refresh must honor it.
532
- ...(options.scope === "global"
716
+ ...(options.scope === 'global'
533
717
  ? {}
534
- : options.scope === "project"
718
+ : options.scope === 'project'
535
719
  ? { projectId: bound.projectId }
536
720
  : { threadId: bound.threadId, projectId: bound.projectId }),
537
721
  includeUnowned: options.includeUnowned,
@@ -542,11 +726,14 @@ export class FileMemory {
542
726
  await mem.ensureLoaded();
543
727
  const entries = mem.view(readScope()).all(options.kind);
544
728
  if (!policy) {
545
- if (entries.length === 0) return "";
729
+ if (entries.length === 0) return '';
546
730
  return formatEntries(entries);
547
731
  }
548
732
  // A load hint cannot impersonate another visible record's owner.
549
- const identity = hint?.currentMessage && sameOwner(bound, hint.currentMessage) ? hint.currentMessage : undefined;
733
+ const identity =
734
+ hint?.currentMessage && sameOwner(bound, hint.currentMessage)
735
+ ? hint.currentMessage
736
+ : undefined;
550
737
  const { text, report } = selectMemory(entries, policy, hint?.focus, identity);
551
738
  lastReport = report;
552
739
  return text;
@@ -566,17 +753,17 @@ export class FileMemory {
566
753
  asAdapter(): HydrationAdapter {
567
754
  const mem = this;
568
755
  return {
569
- system: "file-memory",
756
+ system: 'file-memory',
570
757
  async hydrate(ref: ContextRef): Promise<string> {
571
758
  await mem.ensureLoaded();
572
759
  const entry = mem.get(ref.locator);
573
- return entry ? entry.content : "";
760
+ return entry ? entry.content : '';
574
761
  },
575
762
  async hydrateBatch(refs: ContextRef[]): Promise<string[]> {
576
763
  await mem.ensureLoaded();
577
764
  return refs.map((r) => {
578
765
  const entry = mem.get(r.locator);
579
- return entry ? entry.content : "";
766
+ return entry ? entry.content : '';
580
767
  });
581
768
  },
582
769
  };
@@ -589,26 +776,23 @@ export class FileMemory {
589
776
  * is written unowned and stays hidden from every scoped read.
590
777
  */
591
778
  signalWriter() {
592
- const mem = this;
593
779
  return async (signal: Signal, owner?: OwnershipScope): Promise<void> => {
594
780
  const scope = normalizeScope(owner);
595
781
  const entry: MemoryEntry = {
596
782
  id: signal.id,
597
783
  kind: signal.kind,
598
784
  content:
599
- typeof signal.content === "string"
600
- ? signal.content
601
- : JSON.stringify(signal.content),
785
+ typeof signal.content === 'string' ? signal.content : JSON.stringify(signal.content),
602
786
  source: signal.source,
603
787
  timestamp: signal.timestamp,
604
788
  meta: { confidence: signal.confidence, refs: signal.refs },
605
789
  ...(scope.threadId
606
- ? { owner: scope, visibility: "thread" as const }
790
+ ? { owner: scope, visibility: 'thread' as const }
607
791
  : scope.projectId
608
- ? { owner: scope, visibility: "project" as const }
792
+ ? { owner: scope, visibility: 'project' as const }
609
793
  : {}),
610
794
  };
611
- await mem.write(entry);
795
+ await this.write(entry);
612
796
  };
613
797
  }
614
798
  }
@@ -637,11 +821,17 @@ export class MemoryView {
637
821
  }
638
822
 
639
823
  all(kind?: string): MemoryEntry[] {
640
- return this._memory.all(kind).filter((e) => this._visible(e)).map(copyEntry);
824
+ return this._memory
825
+ .all(kind)
826
+ .filter((e) => this._visible(e))
827
+ .map(copyEntry);
641
828
  }
642
829
 
643
830
  search(query: string, limit?: number): MemoryEntry[] {
644
- const hits = this._memory.search(query).filter((e) => this._visible(e)).map(copyEntry);
831
+ const hits = this._memory
832
+ .search(query)
833
+ .filter((e) => this._visible(e))
834
+ .map(copyEntry);
645
835
  return limit === undefined ? hits : hits.slice(0, limit);
646
836
  }
647
837
 
@@ -659,17 +849,19 @@ export class MemoryView {
659
849
  */
660
850
  async write(entry: MemoryEntry): Promise<void> {
661
851
  const owner = normalizeScope(this.scope);
662
- const visibility = entry.visibility ?? "thread";
663
- if (visibility === "thread" && !owner.threadId) {
852
+ const visibility = entry.visibility ?? 'thread';
853
+ if (visibility === 'thread' && !owner.threadId) {
664
854
  throw new Error(`Cannot write thread-private entry "${entry.id}" without a thread scope`);
665
855
  }
666
- if (visibility === "project" && !owner.projectId) {
856
+ if (visibility === 'project' && !owner.projectId) {
667
857
  throw new Error(`Cannot publish "${entry.id}" to a project without a project scope`);
668
858
  }
669
859
  const existing = this._memory.get(entry.id);
670
860
  if (existing) {
671
861
  if (!existing.owner) {
672
- throw new Error(`Cannot overwrite unowned legacy record "${entry.id}" through a scoped view`);
862
+ throw new Error(
863
+ `Cannot overwrite unowned legacy record "${entry.id}" through a scoped view`,
864
+ );
673
865
  }
674
866
  if (!sameOwner(existing.owner, owner)) {
675
867
  throw new Error(`Cannot overwrite "${entry.id}": it is owned by another thread or project`);
@@ -699,7 +891,7 @@ export function fileSource(id: string, path: string): ContextSource {
699
891
  id,
700
892
  async load() {
701
893
  const file = Bun.file(path);
702
- if (!(await file.exists())) return "";
894
+ if (!(await file.exists())) return '';
703
895
  return file.text();
704
896
  },
705
897
  };