@inixiative/foundry-core 0.1.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.
@@ -0,0 +1,718 @@
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";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Bounded selection — the retained log is complete; the injected text is not
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * How a memory source chooses what to inject automatically. Every visible
17
+ * record stays retrievable; this only governs the automatic message input.
18
+ */
19
+ export interface MemorySelectionPolicy {
20
+ /** Character budget for automatically selected content, excluding the omission summary. */
21
+ readonly budgetChars: number;
22
+ /** Kinds that are always injected, oldest first, and never dropped for budget. */
23
+ readonly pinnedKinds: readonly string[];
24
+ /** Runtime audit kinds retained in the log but never injected unless relevant to the message. */
25
+ readonly auditOnlyKinds: readonly string[];
26
+ /** Newest non-pinned, non-audit captures to consider. */
27
+ readonly recentLimit: number;
28
+ /** Longest single record injected verbatim; longer records are excerpted with a marker. */
29
+ readonly maxEntryChars: number;
30
+ /** Pinned records larger than this are named but not injected: a rule without its ending is not the rule. */
31
+ readonly pinnedHardCapChars: number;
32
+ /**
33
+ * What happens to a pinned record above the hard cap. "inject": supply it in
34
+ * full anyway (default; no scoped retrieval is assumed to exist). "block":
35
+ * refuse to prepare model input and surface a `required-context-blocked`
36
+ * conflict that the executor turns into an explicit error before any
37
+ * provider call. Neither mode ever names a retrieval tool that may not exist.
38
+ */
39
+ readonly oversizedPinned: "inject" | "block";
40
+ /** Most records retrieved for relevance to the current message. */
41
+ readonly retrievalLimit: number;
42
+ /** Distinct focus terms an audit record must match to be retrieved. Other kinds need one. */
43
+ readonly auditRelevanceTerms: number;
44
+ }
45
+
46
+ export const DEFAULT_MEMORY_SELECTION: MemorySelectionPolicy = Object.freeze({
47
+ 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"]),
51
+ recentLimit: 8,
52
+ maxEntryChars: 1200,
53
+ pinnedHardCapChars: 24_000,
54
+ oversizedPinned: "inject",
55
+ retrievalLimit: 6,
56
+ auditRelevanceTerms: 2,
57
+ });
58
+
59
+ 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],
62
+ };
63
+ const KIND_LISTS = new Set(["pinnedKinds", "auditOnlyKinds"]);
64
+
65
+ /**
66
+ * Runtime validation for a selection policy override coming from persisted or
67
+ * operator JSON, where the TypeScript interface gives no protection. Throws a
68
+ * descriptive Error naming the offending field; returns a defensive copy.
69
+ */
70
+ 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}`);
73
+ }
74
+ const out: Record<string, unknown> = {};
75
+ for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
76
+ if (value === undefined) continue;
77
+ // Own-property lookup: inherited names such as constructor or __proto__ are not policy fields.
78
+ const bounds = Object.hasOwn(NUMERIC_BOUNDS, key) ? NUMERIC_BOUNDS[key] : undefined;
79
+ 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)}`);
82
+ }
83
+ out[key] = value;
84
+ } 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)}`);
88
+ }
89
+ 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)}`);
92
+ out[key] = value;
93
+ } else {
94
+ throw new Error(`memory selection policy has unknown field ${JSON.stringify(key)}`);
95
+ }
96
+ }
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})`);
99
+ }
100
+ return out as Partial<MemorySelectionPolicy>;
101
+ }
102
+
103
+ export type MemorySelectionReport = SourceSelectionReport;
104
+
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"]);
108
+
109
+ /** Deterministic terms from the current message: lowercase words of four or more letters, no stopwords. */
110
+ export function focusTerms(focus: string, max = 48): string[] {
111
+ const seen = new Set<string>();
112
+ for (const raw of focus.toLowerCase().split(/[^a-z0-9_.-]+/)) {
113
+ const term = raw.replace(/^[._-]+|[._-]+$/g, "");
114
+ if (term.length < 4 || STOPWORDS.has(term) || seen.has(term)) continue;
115
+ seen.add(term);
116
+ if (seen.size >= max) break;
117
+ }
118
+ return [...seen];
119
+ }
120
+
121
+ type CharRange = readonly [number, number];
122
+ type Excerpt = { text: string; truncated: boolean; ranges?: CharRange[] };
123
+
124
+ /** Render the named character ranges of a record; every excerpt states exactly which parts of the record it carries. */
125
+ function renderRanges(entry: MemoryEntry, ranges: CharRange[]): string {
126
+ 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]`;
129
+ }
130
+
131
+ /** Leading excerpt for records selected by recency, where no particular passage is the point. */
132
+ function prefixExcerpt(entry: MemoryEntry, max: number): Excerpt {
133
+ if (entry.content.length <= max) return { text: entry.content, truncated: false };
134
+ const ranges: CharRange[] = [[0, max]];
135
+ return { text: renderRanges(entry, ranges), truncated: true, ranges };
136
+ }
137
+
138
+ /**
139
+ * Excerpt for records selected by relevance: windows around the first
140
+ * occurrence of each matched term, merged and capped at `max` chars in total,
141
+ * so the injected text carries the evidence that made the record relevant
142
+ * rather than an unrelated prefix.
143
+ */
144
+ function matchExcerpt(entry: MemoryEntry, matched: readonly string[], max: number): Excerpt {
145
+ const total = entry.content.length;
146
+ if (total <= max) return { text: entry.content, truncated: false };
147
+ const lower = entry.content.toLowerCase();
148
+ const hits = matched.map((t) => lower.indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b);
149
+ if (!hits.length) return prefixExcerpt(entry, max);
150
+ const width = Math.max(1, Math.floor(max / hits.length));
151
+ const ranges: CharRange[] = [];
152
+ for (const hit of hits) {
153
+ const end = Math.min(total, Math.max(0, hit - Math.floor(width / 2)) + width);
154
+ const start = Math.max(0, end - width);
155
+ const last = ranges[ranges.length - 1];
156
+ if (last && start <= last[1]) ranges[ranges.length - 1] = [last[0], Math.max(last[1], end)];
157
+ else ranges.push([start, end]);
158
+ }
159
+ return { text: renderRanges(entry, ranges), truncated: true, ranges };
160
+ }
161
+
162
+ 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}`;
165
+ }
166
+
167
+ /**
168
+ * Select what to inject from the records a reader may see. Pure and
169
+ * deterministic: the same records, policy and focus always yield the same
170
+ * text and report. Never mutates or drops a record from the log. Every
171
+ * considered record ends in exactly one of `selected` or `omitted`.
172
+ */
173
+ export function selectMemory(
174
+ visible: readonly MemoryEntry[],
175
+ policy: MemorySelectionPolicy = DEFAULT_MEMORY_SELECTION,
176
+ focus?: string,
177
+ currentMessage?: LogicalMessageIdentity,
178
+ ): { text: string; report: MemorySelectionReport } {
179
+ const identity = copyMessageIdentity(currentMessage);
180
+ const entries = [...visible].sort((a, b) => a.timestamp - b.timestamp || a.id.localeCompare(b.id));
181
+ const pinned = new Set(policy.pinnedKinds);
182
+ 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][] = [];
186
+ const taken = new Set<string>();
187
+ 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]> = {}) => {
190
+ used += rendered.length;
191
+ taken.add(e.id);
192
+ omitted.delete(e.id);
193
+ selected.push({ id: e.id, reason, chars: rendered.length, kind: e.kind, timestamp: e.timestamp, ...extra });
194
+ };
195
+ const withExcerpt = (x: Excerpt) => (x.truncated ? { truncated: true, ranges: x.ranges } : {});
196
+
197
+ // 1. Pinned: explicit instructions, conventions, corrections, decisions. Injected in full, never
198
+ // excerpted. A record beyond the hard cap is named, not paraphrased, so the model knows a rule
199
+ // exists that it must read before acting instead of receiving its introduction as the rule.
200
+ const pinnedLines: string[] = [];
201
+ const overBudget: string[] = [];
202
+ const oversized: string[] = [];
203
+ for (const entry of entries) {
204
+ if (!pinned.has(entry.kind)) continue;
205
+ if (entry.content.length > policy.pinnedHardCapChars) {
206
+ oversized.push(entry.id);
207
+ if (policy.oversizedPinned === "block") {
208
+ // A mandatory record that cannot be carried is a reason to stop, not a
209
+ // 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.`);
211
+ used += notice.length;
212
+ pinnedLines.push(notice);
213
+ taken.add(entry.id);
214
+ omit(entry, REQUIRED_CONTEXT_BLOCKED);
215
+ continue;
216
+ }
217
+ }
218
+ // Pinned records are carried in full: a rule without its ending is not the rule.
219
+ const rendered = line(entry, entry.content);
220
+ if (used + rendered.length > policy.budgetChars) overBudget.push(entry.id);
221
+ pinnedLines.push(rendered);
222
+ select(entry, "pinned", rendered);
223
+ }
224
+ 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.` });
227
+ }
228
+ 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.` });
234
+ }
235
+
236
+ // The current request's own audit is not independent evidence for it.
237
+ // Pinning wins above; owner and explicit message identity must both match.
238
+ // Text matches, nested IDs and legacy records without an owner prove nothing.
239
+ for (const entry of entries) {
240
+ if (!identity || taken.has(entry.id) || !audit.has(entry.kind) || !sameOwner(entry.owner, identity)) continue;
241
+ 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;
245
+ 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 });
248
+ }
249
+
250
+ // 2. Relevant to this message: retrieved across every visible kind, audit kinds need a stronger match.
251
+ const terms = focus ? focusTerms(focus) : [];
252
+ const relevantLines: string[] = [];
253
+ if (terms.length) {
254
+ const scored = entries
255
+ .filter((e) => !taken.has(e.id))
256
+ .map((e) => {
257
+ const lower = e.content.toLowerCase();
258
+ const matched = terms.filter((t) => lower.includes(t));
259
+ return { e, matched };
260
+ })
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));
263
+ for (const { e, matched } of scored) {
264
+ if (relevantLines.length >= policy.retrievalLimit) { omit(e, "retrieval-limit"); continue; }
265
+ 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; }
268
+ relevantLines.push(rendered);
269
+ select(e, "relevant", rendered, { matched, ...withExcerpt(x) });
270
+ }
271
+ }
272
+
273
+ // 3. Recent captures: newest non-audit records within the budget. A record left out above may
274
+ // still be selected here; `select` clears its earlier omission so the sets stay disjoint.
275
+ const recentLines: string[] = [];
276
+ let recentSeen = 0;
277
+ for (const entry of [...entries].reverse()) {
278
+ if (taken.has(entry.id) || pinned.has(entry.kind)) continue;
279
+ if (audit.has(entry.kind)) { omit(entry, "audit-only"); continue; }
280
+ recentSeen += 1;
281
+ if (recentSeen > policy.recentLimit) { omit(entry, "recent-limit"); continue; }
282
+ const x = prefixExcerpt(entry, policy.maxEntryChars);
283
+ const rendered = line(entry, x.text);
284
+ if (used + rendered.length > policy.budgetChars) { omit(entry, "budget"); continue; }
285
+ recentLines.push(rendered);
286
+ select(entry, "recent", rendered, withExcerpt(x));
287
+ }
288
+
289
+ // 4. Omission summary in the injected text itself, so the model and the operator see the same account.
290
+ 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;
293
+ const otherOmitted = omittedList.length - auditOmitted.length;
294
+ 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(", ");
297
+ const retainedChars = entries.reduce((sum, e) => sum + e.content.length, 0);
298
+ const summary = [
299
+ `${auditOmitted.length} audit records (${auditOmitted.reduce((s, o) => s + o.chars, 0)} chars${kinds ? `; ${kinds}` : ""})` +
300
+ ` and ${otherOmitted} other captures were not injected.`,
301
+ `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(" ");
305
+
306
+ 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")}`);
310
+ sections.push(`## Not injected\n${summary}`);
311
+
312
+ return {
313
+ text: sections.join("\n\n"),
314
+ report: {
315
+ selected, omitted: omittedList, considered: entries.length,
316
+ retained: { count: entries.length, chars: retainedChars },
317
+ budget: { chars: policy.budgetChars, used, exceeded: used > policy.budgetChars },
318
+ conflicts,
319
+ ...(identity ? { currentMessage: identity } : {}),
320
+ ...(focus ? { focus: { hash: computeHash(focus), terms: terms.length } } : {}),
321
+ },
322
+ };
323
+ }
324
+
325
+ /**
326
+ * What a reader may see. Thread/project identify the reader; `includeUnowned`
327
+ * is the explicit opt-in for legacy records written before ownership existed.
328
+ */
329
+ export interface MemoryReadScope extends OwnershipScope {
330
+ readonly includeUnowned?: boolean;
331
+ }
332
+
333
+ /** Options for a memory-backed ContextSource. */
334
+ export interface MemorySourceOpts {
335
+ /** Only entries of this kind. */
336
+ kind?: string;
337
+ /**
338
+ * Widest visibility the source exposes once bound to a thread:
339
+ * - "thread" (default): the thread's own entries plus project/global publications.
340
+ * - "project": project and global publications only (never another thread's captures).
341
+ * - "global": global publications only, identical for every thread and project.
342
+ */
343
+ scope?: MemoryVisibility;
344
+ /** Expose unowned legacy records too. Off by default; a deliberate compatibility choice. */
345
+ includeUnowned?: boolean;
346
+ /**
347
+ * Bounded automatic selection instead of the full formatted log. An object
348
+ * overrides the default policy; `false` keeps the legacy full dump. Absent
349
+ * means legacy behaviour so existing callers are unchanged.
350
+ */
351
+ selection?: Partial<MemorySelectionPolicy> | false;
352
+ }
353
+
354
+ /** Whether one entry is visible to a reader. */
355
+ export function entryVisibleTo(entry: MemoryEntry, scope: MemoryReadScope): boolean {
356
+ switch (entry.visibility) {
357
+ case "global":
358
+ return true;
359
+ case "project":
360
+ return !!scope.projectId && entry.owner?.projectId === scope.projectId;
361
+ case "thread":
362
+ // A thread id alone is not identity: the owner's project must match too,
363
+ // so a same-named thread in another project cannot read it.
364
+ return (
365
+ !!scope.threadId &&
366
+ entry.owner?.threadId === scope.threadId &&
367
+ (!entry.owner?.projectId || entry.owner.projectId === scope.projectId)
368
+ );
369
+ default:
370
+ return scope.includeUnowned === true;
371
+ }
372
+ }
373
+
374
+ /** Whether two owners are the same thread and project. */
375
+ function sameOwner(a: OwnershipScope | undefined, b: OwnershipScope | undefined): boolean {
376
+ const x = normalizeScope(a);
377
+ const y = normalizeScope(b);
378
+ return x.threadId === y.threadId && x.projectId === y.projectId;
379
+ }
380
+
381
+ /** Readers get copies: a caller can never mutate the stored record by reference. */
382
+ function copyEntry(entry: MemoryEntry): MemoryEntry {
383
+ return structuredClone(entry);
384
+ }
385
+
386
+ function formatEntries(entries: MemoryEntry[]): string {
387
+ return entries
388
+ .sort((a, b) => b.timestamp - a.timestamp)
389
+ .map((e) => `[${e.kind}] ${e.id}: ${e.content}`)
390
+ .join("\n");
391
+ }
392
+
393
+ /**
394
+ * A simple file-based memory system.
395
+ *
396
+ * Stores entries as JSON files in a directory. Each entry has an id,
397
+ * content, kind, timestamp, and (for anything captured after ownership
398
+ * existed) an owner and visibility. No external deps — just the filesystem.
399
+ *
400
+ * Reads for a thread go through `view(scope)`; the unscoped methods below
401
+ * are the operator's whole-store view and must not be handed to a thread.
402
+ *
403
+ * Use this as the default built-in memory. Swap for pgvector, Redis,
404
+ * MuninnDB, etc. in production.
405
+ */
406
+ export class FileMemory {
407
+ readonly dir: string;
408
+ private _entries: Map<string, MemoryEntry> = new Map();
409
+ private _loaded = false;
410
+
411
+ constructor(dir: string) {
412
+ this.dir = resolve(dir);
413
+ if (!existsSync(this.dir)) {
414
+ mkdirSync(this.dir, { recursive: true });
415
+ }
416
+ }
417
+
418
+ /** Resolve an entry path safely — prevents path traversal. */
419
+ private _safePath(id: string): string {
420
+ const safe = id.replace(/[\/\\\.]+/g, "_");
421
+ const path = join(this.dir, `${safe}.json`);
422
+ const rel = relative(this.dir, resolve(path));
423
+ if (rel.startsWith("..") || rel.includes("/..")) {
424
+ throw new Error(`Invalid entry id: ${id}`);
425
+ }
426
+ return path;
427
+ }
428
+
429
+ /** Load all entries from disk. */
430
+ async load(): Promise<void> {
431
+ const files = new Bun.Glob("*.json").scanSync(this.dir);
432
+ for (const file of files) {
433
+ const path = join(this.dir, file);
434
+ const data = await Bun.file(path).json();
435
+ this._entries.set(data.id, data as MemoryEntry);
436
+ }
437
+ this._loaded = true;
438
+ }
439
+
440
+ /** Ensure entries are loaded (idempotent). */
441
+ async ensureLoaded(): Promise<void> {
442
+ if (!this._loaded) await this.load();
443
+ }
444
+
445
+ /** Write an entry to memory exactly as given (owner/visibility included). */
446
+ async write(entry: MemoryEntry): Promise<void> {
447
+ this._entries.set(entry.id, entry);
448
+ const path = this._safePath(entry.id);
449
+ await Bun.write(path, JSON.stringify(entry, null, 2));
450
+ }
451
+
452
+ /**
453
+ * Change who may read an existing entry. Publishing to a project requires
454
+ * the entry to have a project owner; the owner itself never changes.
455
+ */
456
+ async publish(id: string, visibility: MemoryVisibility): Promise<MemoryEntry> {
457
+ const entry = this._entries.get(id);
458
+ if (!entry) throw new Error(`Memory entry not found: ${id}`);
459
+ if (visibility === "project" && !entry.owner?.projectId) {
460
+ throw new Error(`Cannot publish "${id}" to a project: it has no project owner`);
461
+ }
462
+ if (visibility === "thread" && !entry.owner?.threadId) {
463
+ throw new Error(`Cannot restrict "${id}" to a thread: it has no thread owner`);
464
+ }
465
+ const updated = { ...entry, visibility };
466
+ await this.write(updated);
467
+ return updated;
468
+ }
469
+
470
+ /** Read an entry by id. */
471
+ get(id: string): MemoryEntry | undefined {
472
+ return this._entries.get(id);
473
+ }
474
+
475
+ /** Get all entries, optionally filtered by kind. */
476
+ all(kind?: string): MemoryEntry[] {
477
+ const entries = [...this._entries.values()];
478
+ return kind ? entries.filter((e) => e.kind === kind) : entries;
479
+ }
480
+
481
+ /** Search entries by content substring. */
482
+ search(query: string): MemoryEntry[] {
483
+ const lower = query.toLowerCase();
484
+ return [...this._entries.values()].filter((e) =>
485
+ e.content.toLowerCase().includes(lower)
486
+ );
487
+ }
488
+
489
+ /** Delete an entry. Removes from disk first to prevent desync. */
490
+ async delete(id: string): Promise<boolean> {
491
+ if (!this._entries.has(id)) return false;
492
+ const path = this._safePath(id);
493
+ try {
494
+ const { unlinkSync } = await import("node:fs");
495
+ unlinkSync(path);
496
+ } catch (err) {
497
+ console.warn(`[FileMemory] unlink failed for "${id}":`, (err as Error).message);
498
+ }
499
+ this._entries.delete(id);
500
+ return true;
501
+ }
502
+
503
+ /** Get all entries as a formatted string (for use as context). */
504
+ toContext(): string {
505
+ const entries = [...this._entries.values()];
506
+ if (entries.length === 0) return "(no entries)";
507
+ return formatEntries(entries);
508
+ }
509
+
510
+ /** A reader/writer restricted to what one thread or project may see. */
511
+ view(scope: MemoryReadScope): MemoryView {
512
+ return new MemoryView(this, scope);
513
+ }
514
+
515
+ /**
516
+ * Create a ContextSource over this memory. Unbound, it exposes only
517
+ * globally published entries; `bind(scope)` narrows or widens per the
518
+ * source's declared scope for the owning thread/project.
519
+ */
520
+ asSource(id: string, opts?: string | MemorySourceOpts): ContextSource {
521
+ const options: MemorySourceOpts = typeof opts === "string" ? { kind: opts } : opts ?? {};
522
+ const mem = this;
523
+ const policy: MemorySelectionPolicy | undefined = options.selection === undefined || options.selection === false
524
+ ? undefined
525
+ : { ...DEFAULT_MEMORY_SELECTION, ...validateMemorySelection(options.selection) };
526
+
527
+ const make = (bound: OwnershipScope): ContextSource => {
528
+ let lastReport: MemorySelectionReport | undefined;
529
+ const readScope = (): MemoryReadScope => ({
530
+ // Resolve the bound scope at load time: a thread's project may be
531
+ // assigned after creation, and every refresh must honor it.
532
+ ...(options.scope === "global"
533
+ ? {}
534
+ : options.scope === "project"
535
+ ? { projectId: bound.projectId }
536
+ : { threadId: bound.threadId, projectId: bound.projectId }),
537
+ includeUnowned: options.includeUnowned,
538
+ });
539
+ const source: ContextSource = {
540
+ id,
541
+ async load(hint?: SourceLoadHint) {
542
+ await mem.ensureLoaded();
543
+ const entries = mem.view(readScope()).all(options.kind);
544
+ if (!policy) {
545
+ if (entries.length === 0) return "";
546
+ return formatEntries(entries);
547
+ }
548
+ // A load hint cannot impersonate another visible record's owner.
549
+ const identity = hint?.currentMessage && sameOwner(bound, hint.currentMessage) ? hint.currentMessage : undefined;
550
+ const { text, report } = selectMemory(entries, policy, hint?.focus, identity);
551
+ lastReport = report;
552
+ return text;
553
+ },
554
+ bind: (scope) => make(scope),
555
+ };
556
+ if (policy) {
557
+ return { ...source, focusable: true, report: () => lastReport };
558
+ }
559
+ return source;
560
+ };
561
+
562
+ return make({});
563
+ }
564
+
565
+ /** Create a HydrationAdapter for this memory system. */
566
+ asAdapter(): HydrationAdapter {
567
+ const mem = this;
568
+ return {
569
+ system: "file-memory",
570
+ async hydrate(ref: ContextRef): Promise<string> {
571
+ await mem.ensureLoaded();
572
+ const entry = mem.get(ref.locator);
573
+ return entry ? entry.content : "";
574
+ },
575
+ async hydrateBatch(refs: ContextRef[]): Promise<string[]> {
576
+ await mem.ensureLoaded();
577
+ return refs.map((r) => {
578
+ const entry = mem.get(r.locator);
579
+ return entry ? entry.content : "";
580
+ });
581
+ },
582
+ };
583
+ }
584
+
585
+ /**
586
+ * Create a signal handler that writes signals to this memory.
587
+ * Wire this into a thread's bus via the runtime, which supplies the owner;
588
+ * captured signals are private to that owner. Without an owner the entry
589
+ * is written unowned and stays hidden from every scoped read.
590
+ */
591
+ signalWriter() {
592
+ const mem = this;
593
+ return async (signal: Signal, owner?: OwnershipScope): Promise<void> => {
594
+ const scope = normalizeScope(owner);
595
+ const entry: MemoryEntry = {
596
+ id: signal.id,
597
+ kind: signal.kind,
598
+ content:
599
+ typeof signal.content === "string"
600
+ ? signal.content
601
+ : JSON.stringify(signal.content),
602
+ source: signal.source,
603
+ timestamp: signal.timestamp,
604
+ meta: { confidence: signal.confidence, refs: signal.refs },
605
+ ...(scope.threadId
606
+ ? { owner: scope, visibility: "thread" as const }
607
+ : scope.projectId
608
+ ? { owner: scope, visibility: "project" as const }
609
+ : {}),
610
+ };
611
+ await mem.write(entry);
612
+ };
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Scoped reader/writer over a FileMemory. Reads never return entries the
618
+ * scope may not see; writes are owned by the scope and private by default.
619
+ * Publishing is explicit: pass `visibility: "project"` or `"global"`.
620
+ */
621
+ export class MemoryView {
622
+ readonly scope: MemoryReadScope;
623
+ private _memory: FileMemory;
624
+
625
+ constructor(memory: FileMemory, scope: MemoryReadScope) {
626
+ this._memory = memory;
627
+ this.scope = scope;
628
+ }
629
+
630
+ private _visible(entry: MemoryEntry | undefined): entry is MemoryEntry {
631
+ return !!entry && entryVisibleTo(entry, this.scope);
632
+ }
633
+
634
+ get(id: string): MemoryEntry | undefined {
635
+ const entry = this._memory.get(id);
636
+ return this._visible(entry) ? copyEntry(entry) : undefined;
637
+ }
638
+
639
+ all(kind?: string): MemoryEntry[] {
640
+ return this._memory.all(kind).filter((e) => this._visible(e)).map(copyEntry);
641
+ }
642
+
643
+ search(query: string, limit?: number): MemoryEntry[] {
644
+ const hits = this._memory.search(query).filter((e) => this._visible(e)).map(copyEntry);
645
+ return limit === undefined ? hits : hits.slice(0, limit);
646
+ }
647
+
648
+ recent(limit = 20, kind?: string): MemoryEntry[] {
649
+ return this.all(kind)
650
+ .sort((a, b) => b.timestamp - a.timestamp)
651
+ .slice(0, limit);
652
+ }
653
+
654
+ /**
655
+ * Write an entry owned by this scope. Private unless a wider visibility is
656
+ * declared. An existing id may only be rewritten by its owner: another
657
+ * scope cannot replace a record it cannot see, nor one it can merely read,
658
+ * and unowned legacy records are never overwritten through a scoped view.
659
+ */
660
+ async write(entry: MemoryEntry): Promise<void> {
661
+ const owner = normalizeScope(this.scope);
662
+ const visibility = entry.visibility ?? "thread";
663
+ if (visibility === "thread" && !owner.threadId) {
664
+ throw new Error(`Cannot write thread-private entry "${entry.id}" without a thread scope`);
665
+ }
666
+ if (visibility === "project" && !owner.projectId) {
667
+ throw new Error(`Cannot publish "${entry.id}" to a project without a project scope`);
668
+ }
669
+ const existing = this._memory.get(entry.id);
670
+ if (existing) {
671
+ if (!existing.owner) {
672
+ throw new Error(`Cannot overwrite unowned legacy record "${entry.id}" through a scoped view`);
673
+ }
674
+ if (!sameOwner(existing.owner, owner)) {
675
+ throw new Error(`Cannot overwrite "${entry.id}": it is owned by another thread or project`);
676
+ }
677
+ }
678
+ await this._memory.write({ ...entry, owner, visibility });
679
+ }
680
+
681
+ /**
682
+ * Delete an entry this scope owns. Invisible entries and records merely
683
+ * published to this scope are reported as not found; reading never grants
684
+ * deletion.
685
+ */
686
+ async delete(id: string): Promise<boolean> {
687
+ const entry = this._memory.get(id);
688
+ if (!this._visible(entry)) return false;
689
+ if (!sameOwner(entry.owner, this.scope)) return false;
690
+ return this._memory.delete(id);
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Simple file-based doc source — reads a file and returns its content.
696
+ */
697
+ export function fileSource(id: string, path: string): ContextSource {
698
+ return {
699
+ id,
700
+ async load() {
701
+ const file = Bun.file(path);
702
+ if (!(await file.exists())) return "";
703
+ return file.text();
704
+ },
705
+ };
706
+ }
707
+
708
+ /**
709
+ * Inline source — just returns a static string. Useful for testing.
710
+ */
711
+ export function inlineSource(id: string, content: string): ContextSource {
712
+ return {
713
+ id,
714
+ async load() {
715
+ return content;
716
+ },
717
+ };
718
+ }