@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,706 @@
1
+ import type { OwnershipScope } from "./scope";
2
+
3
+ /**
4
+ * A source that can provide content to a layer.
5
+ * Intentionally minimal — could be docs, memory, corpus, API, whatever.
6
+ */
7
+ /** Per-load hint a layer may pass to sources that select content for a message. */
8
+ /**
9
+ * Conflict kind a selecting source reports when required context (a pinned
10
+ * record) cannot be carried and no retrieval exists. The executor refuses
11
+ * provider execution while any included layer reports it.
12
+ */
13
+ export const REQUIRED_CONTEXT_BLOCKED = "required-context-blocked";
14
+
15
+ /** Logical request identity, independent of dispatches and native sessions. */
16
+ export interface LogicalMessageIdentity extends OwnershipScope {
17
+ readonly messageId: string;
18
+ readonly threadId: string;
19
+ }
20
+
21
+ /** Own only the identity fields; callers cannot change a pending load by reference. */
22
+ export function copyMessageIdentity(identity: LogicalMessageIdentity | undefined): LogicalMessageIdentity | undefined {
23
+ return identity?.messageId && identity.threadId ? Object.freeze({
24
+ messageId: identity.messageId, threadId: identity.threadId,
25
+ ...(identity.projectId ? { projectId: identity.projectId } : {}),
26
+ }) : undefined;
27
+ }
28
+
29
+ function sameMessage(a: LogicalMessageIdentity | undefined, b: LogicalMessageIdentity | undefined): boolean {
30
+ return a?.messageId === b?.messageId && a?.threadId === b?.threadId && a?.projectId === b?.projectId;
31
+ }
32
+
33
+ export interface SourceLoadHint {
34
+ /** The current message, so a selecting source can retrieve what is relevant to it. */
35
+ readonly focus?: string;
36
+ /** Exclude only this owned logical message's automatic audit context, when known. */
37
+ readonly currentMessage?: LogicalMessageIdentity;
38
+ }
39
+
40
+ /**
41
+ * What a selecting source chose and left out on its last load. Complete
42
+ * records stay in the source; this is the inspectable account of the
43
+ * automatic selection, never a substitute for the retained log.
44
+ */
45
+ export interface SourceSelectionReport {
46
+ readonly selected: Array<{
47
+ readonly id: string;
48
+ readonly reason: string;
49
+ readonly chars: number;
50
+ readonly kind?: string;
51
+ readonly timestamp?: number;
52
+ readonly truncated?: boolean; ranges?: Array<readonly [number, number]>;
53
+ readonly matched?: string[];
54
+ }>;
55
+ readonly omitted: Array<{ readonly id: string; readonly reason: string; readonly chars: number; readonly kind?: string;
56
+ readonly excludedFor?: LogicalMessageIdentity }>;
57
+ /** Records the reader was allowed to see. */
58
+ readonly considered: number;
59
+ /** Everything retained in the source for this reader, injected or not. */
60
+ readonly retained: { readonly count: number; readonly chars: number };
61
+ readonly budget: { readonly chars: number; readonly used: number; readonly exceeded: boolean };
62
+ readonly conflicts: Array<{ readonly kind: string; readonly ids: string[]; readonly detail: string }>;
63
+ readonly focus?: { readonly hash: string; readonly terms: number };
64
+ readonly currentMessage?: LogicalMessageIdentity;
65
+ }
66
+
67
+ export interface ContextSource {
68
+ readonly id: string;
69
+ /** Load content. Sources that select for a message may use the hint; others ignore it. */
70
+ load(hint?: SourceLoadHint): Promise<string>;
71
+ /**
72
+ * Return a copy of this source bound to an ownership scope. Sources whose
73
+ * data is owned per thread or project (memory, signal logs) implement this
74
+ * so a thread's layer only loads what that thread may see. Unbound sources
75
+ * expose only knowledge published to everyone.
76
+ */
77
+ bind?(scope: OwnershipScope): ContextSource;
78
+ /** True when a changed focus should make the owning layer reload this source. */
79
+ readonly focusable?: boolean;
80
+ /** The selection report of the last load, when this source selects rather than dumps. */
81
+ report?(): SourceSelectionReport | undefined;
82
+ }
83
+
84
+ /** Selection provenance carried on a layer instance for inspection. */
85
+ export interface LayerSelectionState {
86
+ readonly focusHash?: string;
87
+ readonly currentMessage?: LogicalMessageIdentity;
88
+ readonly sources: Array<{ readonly sourceId: string; readonly report: SourceSelectionReport }>;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Layer Definition — the blueprint (shared across threads, lives in config)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Semantic segment of a layer's CONTENT in the reading view: where the content
97
+ * originates, independent of the layer's id or prompt. The layer prompt is always
98
+ * an instruction. "domain-knowledge" is a configured/published domain cache;
99
+ * "thread-knowledge" is generated, thread-private state. Undefined means the
100
+ * layer predates this metadata or was never classified; consumers then fall back
101
+ * to their legacy id heuristic and must not present that as verified provenance.
102
+ */
103
+ export type LayerSegment = "domain-knowledge" | "thread-knowledge";
104
+ export const LAYER_SEGMENTS: readonly LayerSegment[] = Object.freeze(["domain-knowledge", "thread-knowledge"]);
105
+ export function isLayerSegment(value: unknown): value is LayerSegment {
106
+ return typeof value === "string" && (LAYER_SEGMENTS as readonly string[]).includes(value);
107
+ }
108
+
109
+ /**
110
+ * A layer definition is the blueprint for a domain's context layer.
111
+ * It describes the POLICY — what sources feed it, how it ages, its token
112
+ * budget. Definitions live in settings.json and are shared.
113
+ */
114
+ export interface LayerDefinition {
115
+ /** Unique identifier (e.g., "conventions", "security", "thread-state"). */
116
+ readonly id: string;
117
+ /** Which domain this layer belongs to. Agents reference this to find their layers. */
118
+ domain?: string;
119
+ /** Semantic segment of this layer's content. Optional for backward compatibility. */
120
+ segment?: LayerSegment;
121
+ /** Source IDs that feed this layer (resolved at instantiation). */
122
+ sourceIds?: string[];
123
+ /** Staleness threshold in ms. After this, the instance is considered stale. */
124
+ staleness?: number;
125
+ /** Token budget ceiling. */
126
+ maxTokens?: number;
127
+ /** Instruction explaining how this layer's content should be used. */
128
+ prompt?: string;
129
+ /** When this layer is activated: always, on-demand, or conditionally. */
130
+ activation?: "always" | "on-demand" | "conditional";
131
+ /** Who can write to instances of this layer. Undefined = anyone. */
132
+ writers?: string[];
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Layer Instance State — runtime snapshot (per-thread, mutable)
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Serializable snapshot of a layer instance's runtime state.
141
+ * Used for persistence, crash recovery, and thread restart from Session Store.
142
+ *
143
+ * Feedback that changes an instance affects only this thread:
144
+ * "this convention cache is stale" → instance change.
145
+ */
146
+ export interface LayerInstanceState {
147
+ definitionId: string;
148
+ threadId?: string;
149
+ projectId?: string;
150
+ /**
151
+ * Content segment of the instance that produced this snapshot, when it carried
152
+ * one. Absent on legacy snapshots; readers must not infer it from the id and
153
+ * present the inference as recorded provenance.
154
+ */
155
+ segment?: LayerSegment;
156
+ content: string;
157
+ hash: string;
158
+ state: LayerState;
159
+ lastWarmed: number | null;
160
+ lastAccessed: number | null;
161
+ /** What selecting sources chose and omitted for this content, when any did. */
162
+ selection?: LayerSelectionState;
163
+ /**
164
+ * The owner's version mark for exactly this content, when the owner supplied one. A revision is
165
+ * never inferred from the content hash; consumers must check `version.hash === hash`.
166
+ */
167
+ version?: LayerVersionMark;
168
+ }
169
+
170
+ /** An owner-declared version of a layer's content: revision number and the hash of the bytes it marks. */
171
+ export interface LayerVersionMark {
172
+ readonly revision: number;
173
+ readonly hash: string;
174
+ readonly author?: string;
175
+ readonly threadId?: string;
176
+ readonly projectId?: string;
177
+ readonly domain?: string;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Version History — tracking changes to definitions and instances
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /**
185
+ * A versioned snapshot of a definition or instance.
186
+ *
187
+ * Two use cases, very different data profiles:
188
+ *
189
+ * DEFINITION VERSIONS (low volume, long-term value):
190
+ * Agent prompts change, model selections shift.
191
+ * "We changed the Convention Librarian's prompt — did violations go down?"
192
+ * Connects to Oracle eval: config version A scored 0.72, version B scored 0.81.
193
+ * Keep indefinitely.
194
+ *
195
+ * INSTANCE VERSIONS (high volume, short-term debug value):
196
+ * Thread-state changes every message.
197
+ * "Why did security guard fire on message 23 but not 22?"
198
+ * These are events in the Session Store — the store IS the instance history.
199
+ * Discard after retention window.
200
+ */
201
+ export interface VersionEntry<T = unknown> {
202
+ /** Content hash of the versioned payload. */
203
+ hash: string;
204
+ /** ISO timestamp. */
205
+ timestamp: string;
206
+ /** What caused this version: "manual", "writeback", "warm", "feedback", etc. */
207
+ trigger: string;
208
+ /** Who made the change: agent ID, "user", "system". */
209
+ author: string;
210
+ /** Optional human-readable note. */
211
+ note?: string;
212
+ /** The actual snapshot. For definitions: the full definition. For instances: LayerInstanceState. */
213
+ snapshot: T;
214
+ }
215
+
216
+ /**
217
+ * Version log for a single definition or instance.
218
+ * Append-only. Entries are ordered by timestamp.
219
+ */
220
+ export interface VersionLog<T = unknown> {
221
+ /** What is being versioned: "layer:conventions", "agent:librarian", etc. */
222
+ subject: string;
223
+ /** "definition" or "instance". */
224
+ scope: "definition" | "instance";
225
+ /** Ordered version entries, newest last. */
226
+ entries: VersionEntry<T>[];
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Backwards-compatible config (still works for direct construction)
231
+ // ---------------------------------------------------------------------------
232
+
233
+ export interface ContextLayerConfig {
234
+ readonly id: string;
235
+ readonly owner?: OwnershipScope;
236
+ /** Optional definition this instance was created from. */
237
+ definition?: LayerDefinition;
238
+ sources?: ContextSource[];
239
+ staleness?: number;
240
+ maxTokens?: number;
241
+ /** Instruction explaining how this layer's content should be used. */
242
+ prompt?: string;
243
+ /** Semantic segment of the content; overrides the definition's segment when given. */
244
+ segment?: LayerSegment;
245
+ }
246
+
247
+ export type LayerState = "cold" | "warming" | "warm" | "stale" | "compressing";
248
+
249
+ /** Emitted on any write to a layer instance. The Session Store captures these. */
250
+ export interface LayerMutationEvent {
251
+ layerId: string;
252
+ /** What changed: "content", "state", "staleness", "prompt". */
253
+ field: string;
254
+ /** Previous value (serializable). */
255
+ previous: unknown;
256
+ /** New value (serializable). */
257
+ current: unknown;
258
+ /** Who caused the mutation: agent ID, "system", "warm", etc. */
259
+ author: string;
260
+ timestamp: number;
261
+ }
262
+
263
+ export function computeHash(content: string): string {
264
+ return Bun.hash(content).toString(16).slice(0, 16);
265
+ }
266
+
267
+ export class ContextLayer {
268
+ readonly id: string;
269
+ readonly owner?: OwnershipScope;
270
+
271
+ /** The definition this instance was created from (if any). */
272
+ readonly definition: LayerDefinition | undefined;
273
+
274
+ /**
275
+ * Construction metadata, not runtime state: where this layer's content
276
+ * originates. Immutable for the instance's lifetime; copied by clone(),
277
+ * recorded by snapshotInstance(), never rewritten by restoreInstance().
278
+ */
279
+ readonly segment: LayerSegment | undefined;
280
+
281
+ private _content: string = "";
282
+ private _state: LayerState = "cold";
283
+ private _hash: string = "";
284
+ private _lastWarmed: number | null = null;
285
+ private _lastAccessed: number | null = null;
286
+ private _sources: ContextSource[];
287
+ private _staleness: number | undefined;
288
+ private _maxTokens: number | undefined;
289
+ private _prompt: string | undefined;
290
+ private _focus: string | undefined;
291
+ private _currentMessage: LogicalMessageIdentity | undefined;
292
+ private _selection: LayerSelectionState | undefined;
293
+ private _version?: LayerVersionMark;
294
+ private _warmingPromise: Promise<void> | null = null;
295
+
296
+ private _listeners: Array<(state: LayerState, layer: ContextLayer) => void> =
297
+ [];
298
+
299
+ /**
300
+ * Mutation listeners — called on any write to this layer (content, state).
301
+ * Used by the Session Store to capture instance history as events.
302
+ * Unlike onStateChange (which only fires on state transitions), this fires
303
+ * on content changes and any other mutation.
304
+ */
305
+ private _mutationListeners: Array<
306
+ (event: LayerMutationEvent) => void
307
+ > = [];
308
+
309
+ constructor(config: ContextLayerConfig) {
310
+ this.id = config.id;
311
+ this.owner = config.owner ? Object.freeze({ ...config.owner }) : undefined;
312
+ this.definition = config.definition;
313
+ this._sources = config.sources ?? [];
314
+ this._staleness = config.staleness ?? config.definition?.staleness;
315
+ this._maxTokens = config.maxTokens ?? config.definition?.maxTokens;
316
+ this._prompt = config.prompt ?? config.definition?.prompt;
317
+ const segment = config.segment ?? config.definition?.segment;
318
+ if (segment !== undefined && !isLayerSegment(segment)) throw new Error(`Unknown layer segment "${String(segment)}" for layer "${config.id}"`);
319
+ this.segment = segment;
320
+ }
321
+
322
+ // -- Factory --
323
+
324
+ /** Create a layer instance from a definition + resolved sources. */
325
+ static fromDefinition(
326
+ definition: LayerDefinition,
327
+ sources: ContextSource[],
328
+ overrides?: Partial<ContextLayerConfig>,
329
+ ): ContextLayer {
330
+ return new ContextLayer({
331
+ id: definition.id,
332
+ definition,
333
+ sources,
334
+ owner: overrides?.owner,
335
+ staleness: overrides?.staleness ?? definition.staleness,
336
+ maxTokens: overrides?.maxTokens ?? definition.maxTokens,
337
+ prompt: overrides?.prompt ?? definition.prompt,
338
+ ...(overrides?.segment !== undefined ? { segment: overrides.segment } : {}),
339
+ });
340
+ }
341
+
342
+ /** Snapshot the current instance state for persistence/recovery. */
343
+ snapshotInstance(threadId?: string): LayerInstanceState {
344
+ return {
345
+ definitionId: this.definition?.id ?? this.id,
346
+ threadId: this.owner?.threadId ?? threadId,
347
+ ...(this.owner?.projectId ? { projectId: this.owner.projectId } : {}),
348
+ // Present only when the instance carries a segment, so legacy snapshots and
349
+ // their consumers keep their exact prior shape.
350
+ ...(this.segment !== undefined ? { segment: this.segment } : {}),
351
+ content: this._content,
352
+ hash: this._hash,
353
+ state: this._state,
354
+ lastWarmed: this._lastWarmed,
355
+ lastAccessed: this._lastAccessed,
356
+ ...(this._selection ? { selection: structuredClone(this._selection) } : {}),
357
+ ...(this._version ? { version: { ...this._version } } : {}),
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Create an independent instance with the same definition, sources, and
363
+ * current warm state. Listeners are not copied; sources are shared because
364
+ * they are loaders, not state. Used to give each thread its own layer.
365
+ *
366
+ * When a scope is given, sources that support binding are bound to it so
367
+ * the clone loads only what that thread/project owns or was published to.
368
+ */
369
+ clone(scope?: OwnershipScope): ContextLayer {
370
+ const copy = new ContextLayer({
371
+ id: this.id,
372
+ owner: scope ?? this.owner,
373
+ definition: this.definition,
374
+ sources: this._sources.map((source) => (scope && source.bind ? source.bind(scope) : source)),
375
+ staleness: this._staleness,
376
+ maxTokens: this._maxTokens,
377
+ prompt: this._prompt,
378
+ segment: this.segment,
379
+ });
380
+ copy._focus = this._focus;
381
+ copy._currentMessage = this._currentMessage;
382
+ if (this._state !== "cold") {
383
+ copy.restoreInstance(this.snapshotInstance());
384
+ }
385
+ return copy;
386
+ }
387
+
388
+ /** Restore instance state (e.g., from Session Store replay). */
389
+ restoreInstance(snapshot: LayerInstanceState): void {
390
+ this._content = snapshot.content;
391
+ this._hash = snapshot.hash;
392
+ this._lastWarmed = snapshot.lastWarmed;
393
+ this._lastAccessed = snapshot.lastAccessed;
394
+ this._selection = snapshot.selection ? structuredClone(snapshot.selection) : undefined;
395
+ this._version = undefined;
396
+ if (snapshot.definitionId === (this.definition?.id ?? this.id)
397
+ && (!this.owner || snapshot.threadId === this.owner.threadId && snapshot.projectId === this.owner.projectId)
398
+ && snapshot.version && this._validVersion(snapshot.version)) this._version = Object.freeze({ ...snapshot.version });
399
+ const state = snapshot.state === "warming" ? "warm" : snapshot.state;
400
+ // Restored content carries the focus it was selected for. If this layer is now focused
401
+ // elsewhere, the content is real but answers another message: stale, not warm.
402
+ this._setState(state === "warm" && this._focusMismatch() ? "stale" : state);
403
+ }
404
+
405
+ /** True when a focusable source's recorded selection focus differs from the current focus. */
406
+ private _focusMismatch(): boolean {
407
+ if (!this._sources.some((source) => source.focusable)) return false;
408
+ const current = this._focus ? computeHash(this._focus) : undefined;
409
+ return (this._selection?.focusHash ?? undefined) !== current ||
410
+ !sameMessage(this._selection?.currentMessage, this._currentMessage);
411
+ }
412
+
413
+ // -- Focus --
414
+
415
+ /** The message this layer's selecting sources are focused on, if any. */
416
+ get focus(): string | undefined {
417
+ return this._focus;
418
+ }
419
+
420
+ /**
421
+ * Point selecting sources at the current message. Changed text or identity makes a
422
+ * warm layer stale only when a source declares it focusable; other layers
423
+ * keep their content and never reload for focus alone.
424
+ */
425
+ setFocus(focus: string | undefined, currentMessage?: LogicalMessageIdentity): void {
426
+ const next = focus?.trim() ? focus : undefined;
427
+ const identity = copyMessageIdentity(currentMessage);
428
+ if (next === this._focus && sameMessage(identity, this._currentMessage)) return;
429
+ this._focus = next;
430
+ // Omitting identity deliberately clears it: direct/background work must
431
+ // never inherit the preceding request just because its text is identical.
432
+ this._currentMessage = identity;
433
+ if (this._sources.some((source) => source.focusable) && this._state === "warm") {
434
+ this._setState("stale");
435
+ }
436
+ }
437
+
438
+ /** Selection provenance from the last warm, when any source reported one. */
439
+ get selection(): LayerSelectionState | undefined {
440
+ return this._selection ? structuredClone(this._selection) : undefined;
441
+ }
442
+
443
+ // -- Accessors --
444
+
445
+ get content(): string {
446
+ this._lastAccessed = Date.now();
447
+ return this._content;
448
+ }
449
+
450
+ get lastAccessed(): number | null {
451
+ return this._lastAccessed;
452
+ }
453
+
454
+ /** Returns current state. Call checkStaleness() explicitly to trigger stale transitions. */
455
+ get state(): LayerState {
456
+ return this._state;
457
+ }
458
+
459
+ /** Check if this layer has become stale. Triggers state transition if so. */
460
+ checkStaleness(): LayerState {
461
+ if (
462
+ this._state === "warm" &&
463
+ this._staleness !== undefined &&
464
+ this._lastWarmed !== null &&
465
+ Date.now() - this._lastWarmed > this._staleness
466
+ ) {
467
+ this._setState("stale");
468
+ }
469
+ return this._state;
470
+ }
471
+
472
+ get hash(): string {
473
+ return this._hash;
474
+ }
475
+
476
+ get lastWarmed(): number | null {
477
+ return this._lastWarmed;
478
+ }
479
+
480
+ get isWarm(): boolean {
481
+ return this._state === "warm";
482
+ }
483
+
484
+ get isStale(): boolean {
485
+ this.checkStaleness();
486
+ return this._state === "stale";
487
+ }
488
+
489
+ // -- Lifecycle --
490
+
491
+ /** Load content from all sources. Re-entrant safe — concurrent calls coalesce. */
492
+ async warm(): Promise<void> {
493
+ if (this._warmingPromise) return this._warmingPromise;
494
+
495
+ this._warmingPromise = this._doWarm();
496
+ try {
497
+ await this._warmingPromise;
498
+ } finally {
499
+ this._warmingPromise = null;
500
+ }
501
+ }
502
+
503
+ private async _doWarm(): Promise<void> {
504
+ const previousState = this._state;
505
+ this._setState("warming");
506
+
507
+ try {
508
+ // A load is labelled with the focus it was asked for. If a focusable source's focus moves
509
+ // while it is loading, that result answers the previous message: reload for the current
510
+ // one (bounded), and if the focus keeps moving leave the layer visibly stale rather than
511
+ // labelling old input as fresh.
512
+ for (let attempt = 0; ; attempt++) {
513
+ const focus = this._focus;
514
+ const currentMessage = this._currentMessage;
515
+ const hint: SourceLoadHint | undefined = focus || currentMessage
516
+ ? Object.freeze({ ...(focus ? { focus } : {}), ...(currentMessage ? { currentMessage } : {}) }) : undefined;
517
+ const parts: string[] = [];
518
+ const reports: LayerSelectionState["sources"] = [];
519
+ for (const source of this._sources) {
520
+ parts.push(await source.load(hint));
521
+ const report = source.report?.();
522
+ if (report) reports.push({ sourceId: source.id, report: structuredClone(report) });
523
+ }
524
+
525
+ this._content = parts.join("\n\n");
526
+ this._hash = computeHash(this._content);
527
+ this._version = undefined; // even identical unversioned source bytes are a new write
528
+ this._selection = reports.length
529
+ ? { ...(focus ? { focusHash: computeHash(focus) } : {}), ...(currentMessage ? { currentMessage } : {}), sources: reports }
530
+ : undefined;
531
+ this._lastWarmed = Date.now();
532
+
533
+ const focusMoved = (this._focus !== focus || !sameMessage(this._currentMessage, currentMessage)) &&
534
+ this._sources.some((source) => source.focusable);
535
+ if (!focusMoved) { this._setState("warm"); return; }
536
+ if (attempt >= 2) { this._setState("stale"); return; }
537
+ }
538
+ } catch (err) {
539
+ // Revert to previous state so the layer isn't stuck in "warming". Content and selection
540
+ // provenance are whatever the last successful load left; a stale label stays stale.
541
+ const reverted = previousState === "warming" ? "cold" : previousState;
542
+ // Content from the last good load stays, but it answers the focus it was
543
+ // selected for. If the focus has moved since, warm would be a lie.
544
+ this._setState(reverted === "warm" && this._focusMismatch() ? "stale" : reverted);
545
+ throw err;
546
+ }
547
+ }
548
+
549
+ set(content: string, author: string = "system"): void {
550
+ const previous = this._content;
551
+ this._content = content;
552
+ this._hash = computeHash(content);
553
+ this._selection = undefined; // manual content has no selection provenance
554
+ this._version = undefined; // a new write carries no version until its owner marks it
555
+ this._lastWarmed = Date.now();
556
+ this._setState("warm");
557
+ if (previous !== content) {
558
+ this._emitMutation("content", previous, content, author);
559
+ }
560
+ }
561
+
562
+ invalidate(): void {
563
+ if (this._state !== "cold") {
564
+ this._setState("stale");
565
+ }
566
+ }
567
+
568
+ private _validVersion(mark: LayerVersionMark): boolean {
569
+ return Number.isSafeInteger(mark.revision) && mark.revision >= 0 && mark.hash === this._hash && mark.hash === computeHash(this._content)
570
+ && (mark.domain === undefined || typeof mark.domain === "string" && mark.domain.length > 0)
571
+ && [mark.threadId, mark.projectId, mark.author].every(value => value === undefined || typeof value === "string" && value.length > 0)
572
+ && (!this.owner || mark.threadId === this.owner.threadId && mark.projectId === this.owner.projectId);
573
+ }
574
+
575
+ /** A rejected mark never replaces the current valid owner mark. */
576
+ markVersion(mark: LayerVersionMark): void {
577
+ if (!mark || !this._validVersion(mark)) throw new Error("Layer version mark does not describe the current content and owner");
578
+ this._version = Object.freeze({ ...mark });
579
+ }
580
+
581
+ get version(): LayerVersionMark | undefined {
582
+ return this._version;
583
+ }
584
+
585
+ clear(): void {
586
+ this._content = "";
587
+ this._hash = "";
588
+ this._lastWarmed = null;
589
+ this._selection = undefined;
590
+ this._version = undefined;
591
+ this._setState("cold");
592
+ }
593
+
594
+ addSource(source: ContextSource): void {
595
+ this._sources.push(source);
596
+ }
597
+
598
+ removeSource(id: string): boolean {
599
+ const idx = this._sources.findIndex((s) => s.id === id);
600
+ if (idx === -1) return false;
601
+ this._sources.splice(idx, 1);
602
+ return true;
603
+ }
604
+
605
+ // -- Observation --
606
+
607
+ onStateChange(
608
+ listener: (state: LayerState, layer: ContextLayer) => void
609
+ ): () => void {
610
+ this._listeners.push(listener);
611
+ return () => {
612
+ const idx = this._listeners.indexOf(listener);
613
+ if (idx !== -1) this._listeners.splice(idx, 1);
614
+ };
615
+ }
616
+
617
+ // -- Configuration --
618
+
619
+ get staleness(): number | undefined {
620
+ return this._staleness;
621
+ }
622
+
623
+ set staleness(value: number | undefined) {
624
+ const previous = this._staleness;
625
+ this._staleness = value;
626
+ if (previous !== value) {
627
+ this._emitMutation("staleness", previous, value, "system");
628
+ }
629
+ }
630
+
631
+ get maxTokens(): number | undefined {
632
+ return this._maxTokens;
633
+ }
634
+
635
+ set maxTokens(value: number | undefined) {
636
+ this._maxTokens = value;
637
+ }
638
+
639
+ get prompt(): string | undefined {
640
+ return this._prompt;
641
+ }
642
+
643
+ set prompt(value: string | undefined) {
644
+ const previous = this._prompt;
645
+ this._prompt = value;
646
+ if (previous !== value) {
647
+ this._emitMutation("prompt", previous, value, "system");
648
+ }
649
+ }
650
+
651
+ /** Read-only view of the sources feeding this layer. */
652
+ get sources(): ReadonlyArray<ContextSource> {
653
+ return this._sources;
654
+ }
655
+
656
+ // -- Mutation observation --
657
+
658
+ /**
659
+ * Subscribe to all mutations on this layer instance.
660
+ * The Session Store hooks into this to build instance history.
661
+ * Returns an unsubscribe function.
662
+ */
663
+ onMutation(listener: (event: LayerMutationEvent) => void): () => void {
664
+ this._mutationListeners.push(listener);
665
+ return () => {
666
+ const idx = this._mutationListeners.indexOf(listener);
667
+ if (idx !== -1) this._mutationListeners.splice(idx, 1);
668
+ };
669
+ }
670
+
671
+ // -- Internal --
672
+
673
+ private _emitMutation(
674
+ field: string,
675
+ previous: unknown,
676
+ current: unknown,
677
+ author: string,
678
+ ): void {
679
+ if (this._mutationListeners.length === 0) return;
680
+ const event: LayerMutationEvent = {
681
+ layerId: this.id,
682
+ field,
683
+ previous,
684
+ current,
685
+ author,
686
+ timestamp: Date.now(),
687
+ };
688
+ const snapshot = [...this._mutationListeners];
689
+ for (const listener of snapshot) {
690
+ listener(event);
691
+ }
692
+ }
693
+
694
+ private _setState(state: LayerState): void {
695
+ if (this._state === state) return;
696
+ const previous = this._state;
697
+ this._state = state;
698
+ // Snapshot listeners to avoid mutation during iteration
699
+ const snapshot = [...this._listeners];
700
+ for (const listener of snapshot) {
701
+ listener(state, this);
702
+ }
703
+ // Also emit as mutation for instance history
704
+ this._emitMutation("state", previous, state, "system");
705
+ }
706
+ }