@inixiative/foundry-core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/capability.ts CHANGED
@@ -133,6 +133,41 @@ export interface GateContext {
133
133
  meta?: Record<string, unknown>;
134
134
  }
135
135
 
136
+ /** The level a policy gives a capability, without prompting: what CapabilityGate checks. */
137
+ export function permissionLevel(
138
+ policy: PermissionPolicy,
139
+ capability: Capability,
140
+ ctx?: Partial<GateContext>,
141
+ ): PermissionLevel {
142
+ const explicit = policy.capabilities[capability];
143
+
144
+ // Protected paths always hold: an explicit allow for the capability never switches them off.
145
+ if (
146
+ explicit !== 'deny' &&
147
+ (capability === 'file:write' || capability === 'file:delete') &&
148
+ policy.protectedPaths?.length &&
149
+ typeof ctx?.meta?.path === 'string'
150
+ ) {
151
+ const path = ctx.meta.path;
152
+ if (policy.protectedPaths.some((p) => path.includes(p))) return 'prompt';
153
+ }
154
+
155
+ if (explicit) return explicit;
156
+
157
+ // Cost threshold check for LLM calls
158
+ if (
159
+ capability === 'llm:call' &&
160
+ policy.costThreshold != null &&
161
+ ctx?.meta?.estimatedCost != null
162
+ ) {
163
+ if ((ctx.meta.estimatedCost as number) > policy.costThreshold) {
164
+ return 'prompt';
165
+ }
166
+ }
167
+
168
+ return policy.defaults;
169
+ }
170
+
136
171
  export class CapabilityGate {
137
172
  private _policy: PermissionPolicy;
138
173
  private _queue: ActionQueue;
@@ -201,34 +236,7 @@ export class CapabilityGate {
201
236
  // -- Internal --
202
237
 
203
238
  private _resolve(capability: Capability, ctx?: GateContext): PermissionLevel {
204
- // Explicit capability override
205
- const explicit = this._policy.capabilities[capability];
206
- if (explicit) return explicit;
207
-
208
- // Cost threshold check for LLM calls
209
- if (
210
- capability === 'llm:call' &&
211
- this._policy.costThreshold != null &&
212
- ctx?.meta?.estimatedCost != null
213
- ) {
214
- if ((ctx.meta.estimatedCost as number) > this._policy.costThreshold) {
215
- return 'prompt';
216
- }
217
- }
218
-
219
- // Protected paths check for file ops
220
- if (
221
- (capability === 'file:write' || capability === 'file:delete') &&
222
- this._policy.protectedPaths?.length &&
223
- ctx?.meta?.path
224
- ) {
225
- const path = ctx.meta.path as string;
226
- if (this._policy.protectedPaths.some((p) => path.includes(p))) {
227
- return 'prompt';
228
- }
229
- }
230
-
231
- return this._policy.defaults;
239
+ return permissionLevel(this._policy, capability, ctx);
232
240
  }
233
241
 
234
242
  private _buildMessage(capability: Capability, ctx: GateContext): string {
@@ -53,6 +53,8 @@ export interface SourceLoadHint {
53
53
  * automatic selection, never a substitute for the retained log.
54
54
  */
55
55
  export interface SourceSelectionReport {
56
+ /** What the records are. Absent: memory records. */
57
+ readonly subject?: 'memory' | 'instructions';
56
58
  readonly selected: Array<{
57
59
  readonly id: string;
58
60
  readonly reason: string;
@@ -69,6 +71,8 @@ export interface SourceSelectionReport {
69
71
  readonly chars: number;
70
72
  readonly kind?: string;
71
73
  readonly excludedFor?: LogicalMessageIdentity;
74
+ /** The copy kept in its place, for a record left out as a duplicate. */
75
+ readonly duplicateOf?: string;
72
76
  }>;
73
77
  /** Records the reader was allowed to see. */
74
78
  readonly considered: number;
@@ -99,6 +103,12 @@ export interface ContextSource {
99
103
  readonly focusable?: boolean;
100
104
  /** The selection report of the last load, when this source selects rather than dumps. */
101
105
  report?(): SourceSelectionReport | undefined;
106
+ /**
107
+ * True when what the last load read has changed since (an edited or added file). The owning warm
108
+ * layer then turns stale on its next staleness check, without waiting out its staleness window.
109
+ * Must be cheap: it runs before every assembly.
110
+ */
111
+ changed?(): boolean;
102
112
  }
103
113
 
104
114
  /** Selection provenance carried on a layer instance for inspection. */
@@ -265,6 +275,11 @@ export interface ContextLayerConfig {
265
275
  prompt?: string;
266
276
  /** Semantic segment of the content; overrides the definition's segment when given. */
267
277
  segment?: LayerSegment;
278
+ /**
279
+ * Content that changes nearly every turn (the Librarian's thread state). A session provider
280
+ * sends it with each turn and never holds it in a session's standing context.
281
+ */
282
+ perTurn?: boolean;
268
283
  }
269
284
 
270
285
  export type LayerState = 'cold' | 'warming' | 'warm' | 'stale' | 'compressing';
@@ -301,6 +316,9 @@ export class ContextLayer {
301
316
  */
302
317
  readonly segment: LayerSegment | undefined;
303
318
 
319
+ /** Sent with each turn, never part of a session's standing context (`ContextLayerConfig.perTurn`). */
320
+ readonly perTurn: boolean;
321
+
304
322
  private _content: string = '';
305
323
  private _state: LayerState = 'cold';
306
324
  private _hash: string = '';
@@ -338,6 +356,7 @@ export class ContextLayer {
338
356
  if (segment !== undefined && !isLayerSegment(segment))
339
357
  throw new Error(`Unknown layer segment "${String(segment)}" for layer "${config.id}"`);
340
358
  this.segment = segment;
359
+ this.perTurn = config.perTurn ?? false;
341
360
  }
342
361
 
343
362
  // -- Factory --
@@ -397,6 +416,7 @@ export class ContextLayer {
397
416
  maxTokens: this._maxTokens,
398
417
  prompt: this._prompt,
399
418
  segment: this.segment,
419
+ perTurn: this.perTurn,
400
420
  });
401
421
  copy._focus = this._focus;
402
422
  copy._currentMessage = this._currentMessage;
@@ -485,19 +505,37 @@ export class ContextLayer {
485
505
  return this._state;
486
506
  }
487
507
 
488
- /** Check if this layer has become stale. Triggers state transition if so. */
508
+ /**
509
+ * Check if this layer has become stale: its staleness window passed, or a source reports that
510
+ * what it read has changed. Triggers the state transition if so.
511
+ */
489
512
  checkStaleness(): LayerState {
490
513
  if (
491
514
  this._state === 'warm' &&
492
- this._staleness !== undefined &&
493
- this._lastWarmed !== null &&
494
- Date.now() - this._lastWarmed > this._staleness
515
+ ((this._staleness !== undefined &&
516
+ this._lastWarmed !== null &&
517
+ Date.now() - this._lastWarmed > this._staleness) ||
518
+ this._sources.some((source) => source.changed?.()))
495
519
  ) {
496
520
  this._setState('stale');
497
521
  }
498
522
  return this._state;
499
523
  }
500
524
 
525
+ /**
526
+ * Whether assembly includes this layer: warm, or stale or re-warming with content that does not
527
+ * depend on the message (no focusable source). A standing layer keeps its last content until a
528
+ * re-warm completes, so it never drops out of context while one is slow or in flight.
529
+ */
530
+ get assemblable(): boolean {
531
+ return (
532
+ this._state === 'warm' ||
533
+ ((this._state === 'stale' || this._state === 'warming') &&
534
+ this._lastWarmed !== null &&
535
+ !this._sources.some((source) => source.focusable))
536
+ );
537
+ }
538
+
501
539
  get hash(): string {
502
540
  return this._hash;
503
541
  }
@@ -36,6 +36,13 @@ export interface PromptBlock {
36
36
  readonly segment?: string;
37
37
  /** Who produced this block (a domain, the router, a layer id). */
38
38
  readonly source?: string;
39
+ /**
40
+ * "after-message": a per-turn note appended to the end of the user's message rather than placed
41
+ * in the context (a Warden chiming in). Default: in the context.
42
+ */
43
+ readonly placement?: 'after-message';
44
+ /** From a per-turn layer: sent with each turn, never held as standing context. */
45
+ readonly perTurn?: true;
39
46
  }
40
47
 
41
48
  /** Structured prompt assembled from agent prompt + layer prompts + layer content. */
@@ -141,7 +148,7 @@ export class ContextStack {
141
148
  merge(filter?: LayerFilter): string {
142
149
  const layers = filter ? this._layers.filter(filter) : this._layers;
143
150
  return layers
144
- .filter((l) => l.isWarm && l.content.length > 0)
151
+ .filter((l) => l.assemblable && l.content.length > 0)
145
152
  .map((l) => l.content)
146
153
  .join('\n\n');
147
154
  }
@@ -171,11 +178,12 @@ export class ContextStack {
171
178
  const layers = filter ? this._layers.filter(filter) : this._layers;
172
179
 
173
180
  for (const layer of layers) {
174
- if (!layer.isWarm || layer.content.length === 0) continue;
181
+ if (!layer.assemblable || layer.content.length === 0) continue;
182
+ const perTurn = layer.perTurn ? { perTurn: true as const } : {};
175
183
 
176
184
  if (layer.prompt) {
177
185
  // A layer prompt is always an instruction; it never inherits the content segment.
178
- blocks.push({ role: 'layer', id: layer.id, text: layer.prompt });
186
+ blocks.push({ role: 'layer', id: layer.id, text: layer.prompt, ...perTurn });
179
187
  }
180
188
  // The content block carries the layer's declared segment when it has one; legacy
181
189
  // layers leave it unset and the artifact builder falls back to its id heuristic.
@@ -184,6 +192,7 @@ export class ContextStack {
184
192
  id: layer.id,
185
193
  text: layer.content,
186
194
  ...(layer.segment ? { segment: layer.segment } : {}),
195
+ ...perTurn,
187
196
  });
188
197
  }
189
198
 
@@ -197,7 +206,7 @@ export class ContextStack {
197
206
  const content = this.merge();
198
207
  const layerHashes: Record<string, string> = {};
199
208
  for (const layer of this._layers) {
200
- if (layer.isWarm) {
209
+ if (layer.assemblable) {
201
210
  layerHashes[layer.id] = layer.hash;
202
211
  }
203
212
  }
package/src/executor.ts CHANGED
@@ -3,6 +3,11 @@ import { computeHash, REQUIRED_CONTEXT_BLOCKED } from './context-layer';
3
3
  import type { AssembledContext, ContextStack, LayerFilter } from './context-stack';
4
4
  import { buildInjectionArtifact, type InjectionArtifact, type MessageDecoration } from './messages';
5
5
  import type { NativeEvidence, NativeObservation } from './native-evidence';
6
+ import {
7
+ type SessionDelivery,
8
+ type SessionDeliveryReport,
9
+ standingParts,
10
+ } from './session-delivery';
6
11
  import type { ToolCallObservation } from './tools';
7
12
  import type { LLMMessage } from './types';
8
13
 
@@ -39,6 +44,10 @@ export interface ExecuteMeta {
39
44
  recordProviderInput?: (messages: LLMMessage[]) => void;
40
45
  /** Capture prepared input even on failure; this is not delivery acknowledgment. */
41
46
  recordInjection?: (artifact: InjectionArtifact) => void;
47
+ /** The context split for a session provider; set by the executor for its handler. */
48
+ delivery?: SessionDelivery;
49
+ /** The handler reports what a session provider delivered (CompletionResult.delivery). */
50
+ recordDelivery?: (report: SessionDeliveryReport) => void;
42
51
  }
43
52
 
44
53
  export type ExecuteHandler<TPayload, TResult> = (
@@ -77,8 +86,17 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
77
86
  const decoration = meta?.annotations?.decoration as MessageDecoration | undefined;
78
87
  const plan = meta?.annotations?.injectionPlan;
79
88
  const base = this.assembleContext(filterOverride);
80
- const blocks = decoration?.blocks.length ? [...base.blocks, ...decoration.blocks] : base.blocks;
81
- const assembled: AssembledContext = decoration?.blocks.length
89
+ // Notes placed after the message (Wardens chiming in) end the user's turn; the rest is context.
90
+ // A payload that is not text has no end to append to; its notes stay in the context.
91
+ const appendable = typeof payload === 'string';
92
+ const contextual = (decoration?.blocks ?? []).filter(
93
+ (b) => !appendable || b.placement !== 'after-message',
94
+ );
95
+ const notes = appendable
96
+ ? (decoration?.blocks ?? []).filter((b) => b.placement === 'after-message')
97
+ : [];
98
+ const blocks = contextual.length ? [...base.blocks, ...contextual] : base.blocks;
99
+ const assembled: AssembledContext = contextual.length
82
100
  ? { blocks, text: blocks.map((block) => block.text).join('\n\n') }
83
101
  : base;
84
102
  const context = assembled.text;
@@ -107,7 +125,18 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
107
125
  ]),
108
126
  ),
109
127
  });
128
+ // The same context split for session providers: standing parts (the assembled layers) and the
129
+ // per-turn text (per-turn layers, then the decoration). `heldLayers` is the thread's delivery
130
+ // ledger, set by the flow middleware.
131
+ const delivery: SessionDelivery = {
132
+ standing: standingParts(base.blocks.filter((block) => !block.perTurn)),
133
+ turn: [...base.blocks.filter((block) => block.perTurn), ...contextual]
134
+ .map((block) => block.text)
135
+ .join('\n\n'),
136
+ held: { ...((meta?.annotations?.heldLayers as Record<string, string> | undefined) ?? {}) },
137
+ };
110
138
  let providerMessages: LLMMessage[] | undefined;
139
+ let sessionDelivery: SessionDeliveryReport | undefined;
111
140
  let providerBoundary: ProviderBoundaryReceipt | undefined;
112
141
  const capture = (): InjectionArtifact => ({
113
142
  ...injection,
@@ -118,6 +147,8 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
118
147
  providerBoundary,
119
148
  executorContext: context,
120
149
  providerMessages,
150
+ ...(sessionDelivery ? { sessionDelivery } : {}),
151
+ standingParts: delivery.standing.map(({ id, hash }) => ({ id, hash })),
121
152
  layers,
122
153
  ...(plan !== undefined ? { plan } : {}),
123
154
  });
@@ -137,6 +168,9 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
137
168
  // selection. No retry/replay is implied by refusing this provider call.
138
169
  const mismatched = layers.filter((layer) => {
139
170
  if (!layer.included || !layer.selection) return false;
171
+ // A composition report (instruction files) is not a per-message selection.
172
+ if (layer.selection.sources.every((source) => source.report.subject === 'instructions'))
173
+ return false;
140
174
  const selectedFor = layer.selection.currentMessage;
141
175
  // Standalone legacy sources have no request-identity contract. A flow
142
176
  // plan does: even its unidentified loads must not replace identified work.
@@ -177,8 +211,17 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
177
211
  let native: NativeEvidence | undefined;
178
212
  let recording = true;
179
213
  try {
180
- output = await this._handler(context, payload, {
214
+ const sent = (
215
+ notes.length && typeof payload === 'string'
216
+ ? `${payload}\n\n---\n\n${notes.map((note) => note.text).join('\n\n')}`
217
+ : payload
218
+ ) as TPayload;
219
+ output = await this._handler(context, sent, {
181
220
  ...meta,
221
+ delivery,
222
+ recordDelivery: (report) => {
223
+ sessionDelivery = structuredClone(report);
224
+ },
182
225
  recordNative: (evidence) => {
183
226
  if (recording) {
184
227
  native = evidence;