@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,134 @@
1
+ import type { Signal, SignalBus } from "./signal";
2
+ import type { Trace, Span } from "./trace";
3
+ import { newId } from "./id";
4
+
5
+ /**
6
+ * An intervention — a manual override from a human operator.
7
+ *
8
+ * When the system forks erroneously, routes to the wrong place,
9
+ * or makes a bad classification, the operator can intervene.
10
+ * The intervention:
11
+ * 1. Records what happened (the trace/span that was wrong)
12
+ * 2. Records what should have happened (the correction)
13
+ * 3. Optionally replays with the correction
14
+ * 4. Emits a "correction" signal so the system learns
15
+ *
16
+ * Over time, corrections accumulate and shape the context layers —
17
+ * the system makes fewer mistakes on the same patterns.
18
+ */
19
+ export interface Intervention {
20
+ readonly id: string;
21
+ readonly timestamp: number;
22
+
23
+ /** What trace this intervention targets. */
24
+ readonly traceId: string;
25
+
26
+ /** Which span was wrong (the specific stage — route, classify, dispatch). */
27
+ readonly spanId: string;
28
+
29
+ /** What the system did. */
30
+ readonly actual: unknown;
31
+
32
+ /** What the operator says it should have done. */
33
+ readonly correction: unknown;
34
+
35
+ /** Free-form reason — becomes part of the correction signal's content. */
36
+ readonly reason?: string;
37
+
38
+ /** Who intervened. */
39
+ readonly operator: string;
40
+ }
41
+
42
+ /**
43
+ * Manages manual interventions.
44
+ *
45
+ * When an operator overrides a decision, the InterventionLog:
46
+ * - Records the intervention
47
+ * - Emits a correction signal on the signal bus
48
+ * - Provides the intervention history for the UI
49
+ *
50
+ * The correction signal flows through the normal signal pipeline —
51
+ * the Librarian classifies it, it gets routed to the right memory layer,
52
+ * and future decisions are shaped by it.
53
+ */
54
+ export class InterventionLog {
55
+ private _interventions: Intervention[] = [];
56
+ private _signals: SignalBus;
57
+ private _maxHistory: number;
58
+
59
+ constructor(signals: SignalBus, maxHistory: number = 1000) {
60
+ this._signals = signals;
61
+ this._maxHistory = maxHistory;
62
+ }
63
+
64
+ /**
65
+ * Record an intervention and emit a correction signal.
66
+ */
67
+ async intervene(
68
+ traceId: string,
69
+ spanId: string,
70
+ actual: unknown,
71
+ correction: unknown,
72
+ operator: string,
73
+ reason?: string
74
+ ): Promise<Intervention> {
75
+ const intervention: Intervention = {
76
+ id: newId("int"),
77
+ timestamp: Date.now(),
78
+ traceId,
79
+ spanId,
80
+ actual,
81
+ correction,
82
+ reason,
83
+ operator,
84
+ };
85
+
86
+ this._interventions.push(intervention);
87
+ if (this._interventions.length > this._maxHistory) {
88
+ this._interventions.shift();
89
+ }
90
+
91
+ // Emit as a correction signal so the system learns
92
+ await this._signals.emit({
93
+ id: `sig_${intervention.id}`,
94
+ kind: "correction",
95
+ source: `operator:${operator}`,
96
+ content: {
97
+ traceId,
98
+ spanId,
99
+ actual,
100
+ correction,
101
+ reason,
102
+ },
103
+ confidence: 1.0, // human corrections are high confidence
104
+ timestamp: Date.now(),
105
+ });
106
+
107
+ return intervention;
108
+ }
109
+
110
+ /** Get all interventions, most recent first. */
111
+ get history(): ReadonlyArray<Intervention> {
112
+ return [...this._interventions].reverse();
113
+ }
114
+
115
+ /** Get interventions for a specific trace. */
116
+ forTrace(traceId: string): Intervention[] {
117
+ return this._interventions.filter((i) => i.traceId === traceId);
118
+ }
119
+
120
+ /** Get recent corrections as signal content (for feeding into context layers). */
121
+ recentCorrections(limit: number = 20): Array<{
122
+ actual: unknown;
123
+ correction: unknown;
124
+ reason?: string;
125
+ timestamp: number;
126
+ }> {
127
+ return this._interventions.slice(-limit).map((i) => ({
128
+ actual: i.actual,
129
+ correction: i.correction,
130
+ reason: i.reason,
131
+ timestamp: i.timestamp,
132
+ }));
133
+ }
134
+ }
@@ -0,0 +1,315 @@
1
+ import type { AssembledContext, PromptBlock } from "./context-stack";
2
+ import { computeHash } from "./context-layer";
3
+ import type { LayerInstanceState, LogicalMessageIdentity } from "./context-layer";
4
+ import type { LLMMessage } from "./types";
5
+
6
+ export type InjectionSegmentKind =
7
+ | "instructions"
8
+ | "domain-knowledge"
9
+ | "thread-knowledge"
10
+ | "routing"
11
+ | "guard-findings";
12
+
13
+ export interface InjectionBlock {
14
+ readonly id: string;
15
+ readonly kind: InjectionSegmentKind;
16
+ readonly source: string;
17
+ readonly text: string;
18
+ readonly hash: string;
19
+ readonly tokens: number;
20
+ readonly freshness?: {
21
+ readonly state?: string;
22
+ readonly lastWarmed?: number | null;
23
+ };
24
+ }
25
+
26
+ /** One decorator's recorded participation in a turn. */
27
+ export interface DecorationParticipant {
28
+ readonly id: string;
29
+ /** contribute | abstain | error | timeout | omitted */
30
+ readonly decision: string;
31
+ readonly reason?: string;
32
+ /** The three named inputs the participant worked from, never inferred. */
33
+ readonly segments: {
34
+ readonly instructions: string;
35
+ readonly domainKnowledge: string;
36
+ readonly threadKnowledge: string;
37
+ };
38
+ readonly provenance: Record<string, unknown>;
39
+ /**
40
+ * The provider input this participant supplied at its phase, frozen at the call boundary, or
41
+ * that no call was made. Absent on records written before capture existed. Recording the input
42
+ * is evidence of what was supplied to the provider interface, not that a native model received it.
43
+ */
44
+ readonly request?: ParticipantRequest;
45
+ }
46
+
47
+ /** What one participant supplied to its provider at one phase, or why it made no call. */
48
+ export type ParticipantRequest =
49
+ | {
50
+ readonly status: "supplied";
51
+ readonly phase: string;
52
+ readonly providerId: string;
53
+ readonly messages: readonly LLMMessage[];
54
+ readonly capturedAt: number;
55
+ }
56
+ | { readonly status: "not-sent"; readonly phase: string; readonly reason: string }
57
+ /** A call may have been made, but its input was never observed (e.g. the caller threw first). Not "not-sent". */
58
+ | { readonly status: "unobserved"; readonly phase: string; readonly reason: string };
59
+
60
+ /**
61
+ * The composed, immutable decoration delivered alongside a user message:
62
+ * the blocks appended to the executor context plus who contributed them.
63
+ */
64
+ export interface MessageDecoration {
65
+ readonly input: { readonly hash: string; readonly capturedAt: number; readonly currentMessage?: LogicalMessageIdentity };
66
+ readonly blocks: PromptBlock[];
67
+ readonly participants: DecorationParticipant[];
68
+ readonly omissions: Array<{ readonly id: string; readonly reason: string }>;
69
+ readonly conflicts: Array<{ readonly kind: string; readonly id: string; readonly detail: string }>;
70
+ }
71
+
72
+ /**
73
+ * What a completed dispatch actually delivered, per included layer, against
74
+ * what the decorator assessed. Attached to `ExecutionResult.meta.delivery`
75
+ * by the runtime after the delivery ledger is committed.
76
+ */
77
+ export interface DeliveryRecord {
78
+ readonly layers: Array<{
79
+ readonly id: string;
80
+ readonly threadId?: string;
81
+ readonly projectId?: string;
82
+ /** Domain that owns this layer, when a participant assessed it. */
83
+ readonly domain?: string;
84
+ /** Hash the decorator assessed, when a domain owns this layer. */
85
+ readonly assessedHash?: string;
86
+ /** Owner revision the decorator assessed, for owner-versioned layers. */
87
+ readonly assessedRevision?: number;
88
+ /** Hash the executor assembled and sent. */
89
+ readonly deliveredHash: string;
90
+ /** Owner revision of the bytes actually assembled, from the layer's own version mark at assembly. */
91
+ readonly deliveredRevision?: number;
92
+ /**
93
+ * How the delivered snapshot relates to the assessed one: the same revision, a later revision committed
94
+ * between assessment and assembly, no owner mark on the delivered bytes, or an inconsistent pair.
95
+ */
96
+ readonly relation?: "assessed" | "advanced" | "unknown" | "inconsistent";
97
+ /** True when the layer changed between assessment and delivery. */
98
+ readonly drift: boolean;
99
+ }>;
100
+ /** Layer ids committed to the delivery ledger. */
101
+ readonly committed: string[];
102
+ /**
103
+ * How the turn treated learning still pending from earlier work:
104
+ * `pending` means preparation continued immediately with committed knowledge.
105
+ * `settled` and `timeout` are retained for historical artifacts.
106
+ */
107
+ readonly learningBarrier?: {
108
+ readonly outcome: "none" | "settled" | "timeout" | "pending" | "closed";
109
+ readonly closed?: ReadonlyArray<{ domain: string; reason: string }>;
110
+ readonly waitedMs: number;
111
+ readonly pending: Array<{ readonly domain: string; readonly reviews: number }>;
112
+ readonly stale: string[];
113
+ };
114
+ }
115
+
116
+ export interface InjectionArtifact {
117
+ readonly capturedAt?: number;
118
+ readonly threadId?: string;
119
+ readonly projectId?: string;
120
+ readonly messageId?: string;
121
+ /** Integrity-bound initial provider callback; never a native acknowledgment. */
122
+ readonly providerBoundary?: import("./delivery-evidence").ProviderBoundaryReceipt;
123
+ readonly userMessage: string;
124
+ readonly blocks: InjectionBlock[];
125
+ /** Decoration delivered with this turn, when a decorator ran. */
126
+ readonly decoration?: MessageDecoration;
127
+ /** The sealed pre-message plan that produced the decoration. */
128
+ readonly plan?: unknown;
129
+ /** Segmented reading view, not the provider's wire format. */
130
+ readonly text: string;
131
+ readonly tokens: number;
132
+ readonly executorContext?: string;
133
+ /** Initial provider messages, before native-runtime wrapping or tool followups. */
134
+ readonly providerMessages?: LLMMessage[];
135
+ readonly layers?: Array<LayerInstanceState & {
136
+ id: string;
137
+ prompt?: string;
138
+ sourceIds: string[];
139
+ included: boolean;
140
+ }>;
141
+ }
142
+
143
+ export interface BuildInjectionArtifactOpts {
144
+ readonly userMessage: string;
145
+ readonly assembled: AssembledContext;
146
+ readonly layerFreshness?: Record<string, { state?: string; lastWarmed?: number | null }>;
147
+ readonly routing?: string;
148
+ readonly guardFindings?: string;
149
+ /** Decoration blocks are appended after the assembled layers, in order. */
150
+ readonly decoration?: MessageDecoration;
151
+ }
152
+
153
+ function estimateTextTokens(text: string): number {
154
+ return Math.ceil(text.length / 4);
155
+ }
156
+
157
+ function segmentKindForBlock(block: PromptBlock): InjectionSegmentKind {
158
+ if (block.role === "system") return "instructions";
159
+ if (block.role === "layer") return "instructions";
160
+ if (block.id === "__thread-state" || block.id === "thread-state" || block.id === "memory") {
161
+ return "thread-knowledge";
162
+ }
163
+ return "domain-knowledge";
164
+ }
165
+
166
+ function blockTitle(kind: InjectionSegmentKind): string {
167
+ switch (kind) {
168
+ case "routing":
169
+ return "Tags / Routing";
170
+ case "instructions":
171
+ return "Instructions";
172
+ case "domain-knowledge":
173
+ return "Domain Knowledge";
174
+ case "thread-knowledge":
175
+ return "Thread State";
176
+ case "guard-findings":
177
+ return "Prior Guard Findings";
178
+ }
179
+ }
180
+
181
+ function makeBlock(
182
+ kind: InjectionSegmentKind,
183
+ source: string,
184
+ text: string,
185
+ freshness?: InjectionBlock["freshness"],
186
+ ): InjectionBlock {
187
+ return {
188
+ id: `${kind}:${source}:${computeHash(text)}`,
189
+ kind,
190
+ source,
191
+ text,
192
+ hash: computeHash(text),
193
+ tokens: estimateTextTokens(text),
194
+ freshness,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Build a segmented reading view of the context supplied to an executor.
200
+ *
201
+ * The artifact keeps Foundry's intervention boundaries visible: the user
202
+ * message remains separate from injected routing, instructions, domain
203
+ * knowledge, thread state, and guard findings.
204
+ */
205
+ export function buildInjectionArtifact(opts: BuildInjectionArtifactOpts): InjectionArtifact {
206
+ const blocks: InjectionBlock[] = [];
207
+
208
+ if (opts.routing?.trim()) {
209
+ blocks.push(makeBlock("routing", "route", opts.routing.trim()));
210
+ }
211
+
212
+ for (const block of [...opts.assembled.blocks, ...(opts.decoration?.blocks ?? [])]) {
213
+ if (!block.text.trim()) continue;
214
+ // An explicit segment always wins; the id heuristic only classifies plain layers.
215
+ const kind = (block.segment as InjectionSegmentKind | undefined) ?? segmentKindForBlock(block);
216
+ const source = block.source ?? block.id ?? block.role;
217
+ blocks.push(makeBlock(kind, source, block.text, block.id ? opts.layerFreshness?.[block.id] : undefined));
218
+ }
219
+
220
+ if (opts.guardFindings?.trim()) {
221
+ blocks.push(makeBlock("guard-findings", "guards", opts.guardFindings.trim()));
222
+ }
223
+
224
+ const sections = [
225
+ "# User Message",
226
+ opts.userMessage,
227
+ "# Foundry Injection",
228
+ ];
229
+
230
+ const byKind = new Map<InjectionSegmentKind, InjectionBlock[]>();
231
+ for (const block of blocks) {
232
+ const existing = byKind.get(block.kind) ?? [];
233
+ existing.push(block);
234
+ byKind.set(block.kind, existing);
235
+ }
236
+
237
+ const order: InjectionSegmentKind[] = [
238
+ "routing",
239
+ "instructions",
240
+ "domain-knowledge",
241
+ "thread-knowledge",
242
+ "guard-findings",
243
+ ];
244
+
245
+ for (const kind of order) {
246
+ const grouped = byKind.get(kind);
247
+ if (!grouped?.length) continue;
248
+ sections.push(`## ${blockTitle(kind)}`);
249
+ sections.push(grouped.map((block) => block.text).join("\n\n"));
250
+ }
251
+
252
+ const text = sections.join("\n\n");
253
+ return {
254
+ userMessage: opts.userMessage,
255
+ blocks,
256
+ text,
257
+ tokens: estimateTextTokens(text),
258
+ ...(opts.decoration ? { decoration: opts.decoration } : {}),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Convert an AssembledContext (from stack.assemble()) into LLMMessages
264
+ * ready for any provider.
265
+ */
266
+ export function assembledToMessages(
267
+ assembled: AssembledContext,
268
+ userPayload: string
269
+ ): LLMMessage[] {
270
+ const messages: LLMMessage[] = [];
271
+
272
+ if (assembled.blocks.length > 0) {
273
+ const systemParts: string[] = [];
274
+
275
+ for (const block of assembled.blocks) {
276
+ if (block.role === "system") {
277
+ systemParts.push(block.text);
278
+ } else if (block.role === "layer") {
279
+ systemParts.push(`[${block.id}]: ${block.text}`);
280
+ } else if (block.role === "content") {
281
+ systemParts.push(block.text);
282
+ }
283
+ }
284
+
285
+ messages.push({ role: "system", content: systemParts.join("\n\n") });
286
+ }
287
+
288
+ messages.push({ role: "user", content: userPayload });
289
+
290
+ return messages;
291
+ }
292
+
293
+ /**
294
+ * Split LLMMessages into provider-friendly parts.
295
+ */
296
+ export function splitSystemMessage(messages: LLMMessage[]): {
297
+ system: string | undefined;
298
+ turns: LLMMessage[];
299
+ } {
300
+ const systemParts: string[] = [];
301
+ const turns: LLMMessage[] = [];
302
+
303
+ for (const msg of messages) {
304
+ if (msg.role === "system") {
305
+ systemParts.push(msg.content);
306
+ } else {
307
+ turns.push(msg);
308
+ }
309
+ }
310
+
311
+ return {
312
+ system: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
313
+ turns,
314
+ };
315
+ }
@@ -0,0 +1,118 @@
1
+ import type { ExecutionResult } from "./base-agent";
2
+ import type { ContextStackView } from "./context-stack";
3
+
4
+ export interface DispatchContext<TPayload = unknown> {
5
+ readonly agentId: string;
6
+ readonly payload: TPayload;
7
+ readonly timestamp: number;
8
+
9
+ /** Mutable bag for middleware to annotate. */
10
+ annotations: Record<string, unknown>;
11
+
12
+ /** Read-only view of the thread's context stack. */
13
+ readonly stack?: ContextStackView;
14
+ /** Thread identity. */
15
+ readonly threadId?: string;
16
+ readonly projectId?: string;
17
+ /** Logical message being processed; absent for unidentified direct/background work. */
18
+ readonly messageId?: string;
19
+ /** Identity of this dispatch; tool observations for it carry the same id. */
20
+ readonly dispatchId?: string;
21
+ }
22
+
23
+ export interface DispatchOutcome<TResult = unknown> {
24
+ readonly context: DispatchContext;
25
+ readonly result: ExecutionResult<TResult>;
26
+ readonly durationMs: number;
27
+ }
28
+
29
+ export type MiddlewareNext = () => Promise<ExecutionResult>;
30
+
31
+ export type Middleware = (
32
+ ctx: DispatchContext,
33
+ next: MiddlewareNext
34
+ ) => Promise<ExecutionResult>;
35
+
36
+ /**
37
+ * When a middleware should run.
38
+ *
39
+ * - "always": runs on every dispatch (logging, tracing, metrics)
40
+ * - "conditional": runs only when its `when` predicate returns true
41
+ * (deep classification, expensive enrichment, guardrails)
42
+ */
43
+ export type MiddlewareTier = "always" | "conditional";
44
+
45
+ export interface MiddlewareEntry {
46
+ readonly id: string;
47
+ readonly tier: MiddlewareTier;
48
+ readonly fn: Middleware;
49
+ readonly when?: (ctx: DispatchContext) => boolean;
50
+ }
51
+
52
+ /**
53
+ * A composable middleware chain with tiered execution.
54
+ *
55
+ * "always" middleware runs on every request — lightweight, fast.
56
+ * "conditional" middleware runs only when its predicate matches —
57
+ * heavier, richer, only when needed.
58
+ *
59
+ * Both tiers compose in registration order on the way in,
60
+ * reverse order on the way out (Koa-style).
61
+ */
62
+ export class MiddlewareChain {
63
+ private _middleware: MiddlewareEntry[] = [];
64
+
65
+ /** Register always-on middleware. */
66
+ use(id: string, fn: Middleware): void {
67
+ this._middleware.push({ id, tier: "always", fn });
68
+ }
69
+
70
+ /** Register conditional middleware with a predicate. */
71
+ useWhen(
72
+ id: string,
73
+ when: (ctx: DispatchContext) => boolean,
74
+ fn: Middleware
75
+ ): void {
76
+ this._middleware.push({ id, tier: "conditional", fn, when });
77
+ }
78
+
79
+ remove(id: string): boolean {
80
+ const idx = this._middleware.findIndex((m) => m.id === id);
81
+ if (idx === -1) return false;
82
+ this._middleware.splice(idx, 1);
83
+ return true;
84
+ }
85
+
86
+ /** Execute the chain. Conditional middleware only runs when its predicate matches. */
87
+ async execute(
88
+ ctx: DispatchContext,
89
+ handler: MiddlewareNext
90
+ ): Promise<ExecutionResult> {
91
+ // Build the active stack for this request
92
+ const active = this._middleware.filter((m) => {
93
+ if (m.tier === "always") return true;
94
+ return m.when ? m.when(ctx) : false;
95
+ });
96
+
97
+ let idx = 0;
98
+
99
+ const run = async (): Promise<ExecutionResult> => {
100
+ if (idx >= active.length) {
101
+ return handler();
102
+ }
103
+ const current = active[idx++];
104
+ return current.fn(ctx, run);
105
+ };
106
+
107
+ return run();
108
+ }
109
+
110
+ get size(): number {
111
+ return this._middleware.length;
112
+ }
113
+
114
+ /** Get entries by tier (for UI display). */
115
+ byTier(tier: MiddlewareTier): ReadonlyArray<MiddlewareEntry> {
116
+ return this._middleware.filter((m) => m.tier === tier);
117
+ }
118
+ }