@inixiative/foundry-core 0.5.5 → 0.5.7

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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@inixiative/foundry-core",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
- "./delivery-evidence": "./src/delivery-evidence.ts"
9
+ "./delivery-evidence": "./src/delivery-evidence.ts",
10
+ "./archive": "./src/feedback/archive-turns.ts"
10
11
  },
11
12
  "scripts": {
12
13
  "test": "bun test tests/",
@@ -30,5 +31,16 @@
30
31
  },
31
32
  "engines": {
32
33
  "bun": ">=1.4.2"
34
+ },
35
+ "peerDependencies": {
36
+ "@inixiative/archive": "^0.23.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@inixiative/archive": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@inixiative/archive": "^0.23.0"
33
45
  }
34
46
  }
@@ -146,6 +146,11 @@ export interface CompositionSnapshot {
146
146
  readonly instructions: readonly OmittedSource[];
147
147
  readonly domainKnowledge: readonly OmittedSource[];
148
148
  };
149
+ /**
150
+ * The charter the call ran under, when it is not the Warden charter (a classify stage's own):
151
+ * its audited subject and text.
152
+ */
153
+ readonly charter?: { readonly subject: string; readonly text: string };
149
154
  }
150
155
 
151
156
  export interface OmittedSource {
package/src/executor.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  standingParts,
10
10
  } from './session-delivery';
11
11
  import type { ToolCallObservation } from './tools';
12
- import type { LLMMessage } from './types';
12
+ import { type ImageInput, type LLMMessage, withoutImageData } from './types';
13
13
 
14
14
  /** Runtime metadata passed to executor handlers at dispatch time. */
15
15
  export interface ExecuteMeta {
@@ -48,6 +48,8 @@ export interface ExecuteMeta {
48
48
  delivery?: SessionDelivery;
49
49
  /** The handler reports what a session provider delivered (CompletionResult.delivery). */
50
50
  recordDelivery?: (report: SessionDeliveryReport) => void;
51
+ /** Images sent with the payload; the handler puts them on the user message it sends. */
52
+ images?: readonly ImageInput[];
51
53
  }
52
54
 
53
55
  export type ExecuteHandler<TPayload, TResult> = (
@@ -228,7 +230,9 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
228
230
  observe(() => meta?.recordNative?.(evidence));
229
231
  }
230
232
  },
231
- recordProviderInput: (messages) => {
233
+ recordProviderInput: (sentMessages) => {
234
+ // Evidence holds each image by identity; its bytes stay with the attachment.
235
+ const messages = withoutImageData(sentMessages);
232
236
  // The initial boundary input must not be replaced by tool followups.
233
237
  if (providerMessages === undefined) {
234
238
  providerMessages = structuredClone(messages);
@@ -0,0 +1,52 @@
1
+ import type { ArchiveSnapshot } from '@inixiative/archive';
2
+ import { typedPrompt } from '@inixiative/archive/import';
3
+ import type { TranscriptTurn } from './correction';
4
+
5
+ // An Archive session as transcript turns, the input `extractCorrections` reads. Shared by Foundry's
6
+ // `foundry repeats` and Oracle's session miner; its own export, so the core root never loads Archive.
7
+
8
+ const INTERRUPTED = /^\s*\[Request interrupted by user/;
9
+ const COMMIT_BRACKET = /\[[^[\]\n]+\s([0-9a-f]{7,40})\]/g;
10
+ const COMMIT_LINE = /^commit ([0-9a-f]{40})/gm;
11
+
12
+ /** Commit SHAs a tool result printed: `git commit`'s `[branch sha]` line, `git log`'s `commit <sha>`. */
13
+ export const commitsIn = (text: string) => [
14
+ ...[...text.matchAll(COMMIT_BRACKET)].map((m) => m[1]!),
15
+ ...[...text.matchAll(COMMIT_LINE)].map((m) => m[1]!),
16
+ ];
17
+
18
+ export interface SnapshotTurnsOptions {
19
+ /** Also a turn per tool result that printed commits (`commits`): where the work landed. */
20
+ readonly commits?: boolean;
21
+ }
22
+
23
+ /** The transcript as turns: what the person typed, the agent's prose, and interruptions. */
24
+ export function snapshotTurns(
25
+ snapshot: Pick<ArchiveSnapshot, 'entries'>,
26
+ options: SnapshotTurnsOptions = {},
27
+ ): TranscriptTurn[] {
28
+ const turns: TranscriptTurn[] = [];
29
+ for (const entry of snapshot.entries) {
30
+ const timestamp = entry.timestamp === null ? '' : new Date(entry.timestamp).toISOString();
31
+ if (entry.kind === 'assistant') turns.push({ role: 'assistant', text: entry.text, timestamp });
32
+ else if ((entry.kind === 'user' || entry.kind === 'event') && INTERRUPTED.test(entry.text))
33
+ turns.push({ role: 'user', text: '', timestamp, interrupted: true });
34
+ else if (entry.kind === 'user') {
35
+ const text = typedPrompt(entry.text);
36
+ if (text)
37
+ turns.push({ role: 'user', id: entry.id, text, timestamp, sourceRef: entry.sourceRef });
38
+ } else if (options.commits && entry.kind === 'tool-result') {
39
+ const commits = commitsIn(entry.text);
40
+ if (commits.length)
41
+ turns.push({
42
+ role: 'assistant',
43
+ id: entry.id,
44
+ text: '',
45
+ timestamp,
46
+ sourceRef: entry.sourceRef,
47
+ commits,
48
+ });
49
+ }
50
+ }
51
+ return turns;
52
+ }
@@ -20,6 +20,8 @@ export interface TranscriptTurn {
20
20
  interrupted?: boolean;
21
21
  /** Where the turn sits in its source, e.g. `line:42`. */
22
22
  sourceRef?: string;
23
+ /** Commit SHAs a tool result printed in this turn (`snapshotTurns` with `commits`). */
24
+ commits?: string[];
23
25
  }
24
26
 
25
27
  export interface CorrectionEvent {
@@ -14,7 +14,12 @@ export interface WardenLabel {
14
14
  readonly shouldKnow?: string;
15
15
  }
16
16
 
17
- export type WardenTriggerKind = 'user-message' | 'agent-action' | 'agent-plan' | 'subagent-spawn';
17
+ export type WardenTriggerKind =
18
+ | 'user-message'
19
+ | 'consumed'
20
+ | 'agent-action'
21
+ | 'agent-plan'
22
+ | 'subagent-spawn';
18
23
 
19
24
  /**
20
25
  * A labelled moment for the Warden prefilter: which Warden domains the trigger touches. Never commit
@@ -173,6 +173,7 @@ export function prefilterQuestion(description: string) {
173
173
  export function prefilterState(trigger: PrefilterTrigger, text: string, context?: string): string {
174
174
  const label = {
175
175
  'user-message': "The user's message",
176
+ consumed: 'A work item the agent took in (a ticket, spec, issue or page)',
176
177
  'agent-action': "The agent's actions this turn",
177
178
  'agent-plan': "The agent's plan",
178
179
  'subagent-spawn': 'The task the agent is handing to a subagent',
@@ -66,6 +66,55 @@ export function probeTriggers(probe: WardenProbe): WardenProbeTrigger[] {
66
66
  ];
67
67
  }
68
68
 
69
+ /** A classification's answer: in the region (`yes`), possibly (`unsure`, ternary stages only), or not. */
70
+ export type ClassifyOutcome = 'yes' | 'unsure' | 'no';
71
+
72
+ /**
73
+ * One stage of a classification run (Foundry's `classify`), as answered: what it read, what it said
74
+ * and what it cost. A stage that failed or answered out of shape carries `error` and no outcome:
75
+ * counted, never guessed.
76
+ */
77
+ export interface ClassifyStageTrace {
78
+ /** 0-based, in run order. */
79
+ readonly stage: number;
80
+ readonly task: 'recognise' | 'point';
81
+ /** The region doc it read (e.g. `knowledge.md`) and the content hash of what it read. */
82
+ readonly doc: string;
83
+ readonly docHash?: string;
84
+ /**
85
+ * sha256 of the full system message the stage sent (its charter above the Warden's instructions),
86
+ * so runs that change any prompt section, the region line included, are told apart per call.
87
+ */
88
+ readonly systemHash?: string;
89
+ /** The model and effort that answered, as the provider reported them. */
90
+ readonly model?: string;
91
+ readonly effort?: string;
92
+ readonly outcome?: ClassifyOutcome;
93
+ /** The why, when the stage was asked for one (`reason: inline | followup`). */
94
+ readonly why?: string;
95
+ /** `reason: followup`: whether the stage, asked why, kept its answer or names the one it now holds. */
96
+ readonly revised?: 'keep' | ClassifyOutcome;
97
+ /** `reason: followup`: the follow-up call failed (the stage's own answer stands). */
98
+ readonly followupError?: string;
99
+ /** Its remarks in order (advice snippets or guard finding descriptions). */
100
+ readonly remarks?: readonly string[];
101
+ /** A recognise stage: the regions it named, with their line and outcome. */
102
+ readonly regions?: readonly {
103
+ readonly regionId: string;
104
+ readonly why: string;
105
+ readonly outcome: ClassifyOutcome;
106
+ }[];
107
+ readonly tokens?: Omit<TokenCounts, 'providerUsage'>;
108
+ readonly elapsedMs?: number;
109
+ /** The answer was out of protocol (counted apart from failures of the call). */
110
+ readonly malformed?: true;
111
+ readonly error?: string;
112
+ /** No answer within the stage's share of the trigger's deadline: the run kept the previous stage's outcome. */
113
+ readonly timedOut?: true;
114
+ /** Region ids a recogniser named that it does not hold: counted, never followed. */
115
+ readonly unknownRegions?: readonly string[];
116
+ }
117
+
69
118
  /** One Warden's answer on one probe, and the judge's verdict when it spoke. */
70
119
  export interface WardenProbeAnswer {
71
120
  readonly probeId: string;
@@ -79,9 +128,15 @@ export interface WardenProbeAnswer {
79
128
  readonly elapsedMs?: number;
80
129
  /** What the call used, as the provider reported it (cache reads show a warm, primed prefix). */
81
130
  readonly tokens?: Omit<TokenCounts, 'providerUsage'>;
131
+ /** An unstaged call: sha256 of its full system message (a staged row has it on each stage's trace). */
132
+ readonly systemHash?: string;
82
133
  /** Transport failures of earlier attempts at this call, re-asked before this answer. */
83
134
  readonly retried?: readonly string[];
84
135
  readonly judged?: WardenJudgement;
136
+ /** The run's final outcome; a final `unsure` is delivered as nothing (`noComment`). */
137
+ readonly outcome?: ClassifyOutcome;
138
+ /** Every stage the run asked, in order. */
139
+ readonly stages?: readonly ClassifyStageTrace[];
85
140
  }
86
141
 
87
142
  /** One probe run as a turn: every Warden asked at once, as the live chorus is. */
@@ -144,6 +199,8 @@ export interface CharterScore {
144
199
  >;
145
200
  readonly remarks: number;
146
201
  readonly silences: number;
202
+ /** Silences whose final outcome was `unsure` (a ternary last stage): delivered as nothing, counted apart. */
203
+ readonly unsure: number;
147
204
  /** Remarks the judge could not grade: counted as unmatched, reported apart. */
148
205
  readonly unjudged: number;
149
206
  /** Per-turn load at roster scale: calls and wall clock, when the run recorded turns. */
@@ -192,6 +249,7 @@ export function scoreCharterRun(
192
249
  remarksRight = 0,
193
250
  scope = 0,
194
251
  silences = 0,
252
+ unsure = 0,
195
253
  unjudged = 0;
196
254
  const recalled = new Set<string>();
197
255
  const anticipated = new Set<string>();
@@ -220,8 +278,10 @@ export function scoreCharterRun(
220
278
  };
221
279
  perTrigger.set(trigger, tally);
222
280
  tally.probes.add(probe.id);
223
- if (a.noComment) silences++;
224
- else {
281
+ if (a.noComment) {
282
+ silences++;
283
+ if (a.outcome === 'unsure') unsure++;
284
+ } else {
225
285
  remarks++;
226
286
  tally.remarks++;
227
287
  if (!a.judged) unjudged++;
@@ -284,6 +344,7 @@ export function scoreCharterRun(
284
344
  byTrigger,
285
345
  remarks,
286
346
  silences,
347
+ unsure,
287
348
  unjudged,
288
349
  ...(turns.length
289
350
  ? {
package/src/harness.ts CHANGED
@@ -144,6 +144,8 @@ interface SendOptions {
144
144
  onExecutorInput?: () => void;
145
145
  /** The payload the execute stage's agent receives instead of `message.payload` (see DispatchOptions.agentPayload). */
146
146
  executorPayload?: unknown;
147
+ /** Images sent with the message; only the execute stage's agent receives them. */
148
+ images?: readonly import('./types').ImageInput[];
147
149
  }
148
150
 
149
151
  /**
@@ -670,6 +672,7 @@ export class Harness {
670
672
  ...(stage.role === 'execute' && opts?.executorPayload !== undefined
671
673
  ? { agentPayload: opts.executorPayload }
672
674
  : {}),
675
+ ...(stage.role === 'execute' && opts?.images?.length ? { images: opts.images } : {}),
673
676
  recordInjection: (artifact) => {
674
677
  stageSpan.annotations.injection = artifact;
675
678
  if (stage.role === 'execute' && !executorInput) {
package/src/index.ts CHANGED
@@ -251,6 +251,8 @@ export {
251
251
  export {
252
252
  type Authorship,
253
253
  type CharterScore,
254
+ type ClassifyOutcome,
255
+ type ClassifyStageTrace,
254
256
  CONCEPT_CLUSTERS,
255
257
  CORRECTION_TARGET_PROMPT,
256
258
  type CorrectionTarget,
@@ -346,16 +348,19 @@ export {
346
348
  MODEL_CAPABILITIES,
347
349
  MODEL_REGISTRY,
348
350
  MODEL_REGISTRY_UPDATED_AT,
351
+ MODEL_VENDORS,
349
352
  type ModelCapability,
350
353
  type ModelLab,
351
354
  type ModelReasoning,
352
355
  type ModelSweepOption,
353
356
  type ModelTier,
357
+ type ModelVendor,
354
358
  mainThreadLab,
355
359
  modelCapabilities,
356
360
  modelHasCapability,
357
361
  modelOptionsByCapability,
358
362
  type ProviderCredential,
363
+ type ProviderMode,
359
364
  type ProviderType,
360
365
  providersWithCapability,
361
366
  type ReasoningEffort,
@@ -363,6 +368,8 @@ export {
363
368
  registryModel,
364
369
  resolveDecisionModel,
365
370
  SUBSCRIPTION_DECISION_MODELS,
371
+ vendorModeProvider,
372
+ vendorOfProvider,
366
373
  } from './model-registry';
367
374
  export * from './native-evidence';
368
375
  // Retry middleware
@@ -410,9 +417,15 @@ export {
410
417
  type ModelPricing,
411
418
  TokenTracker,
412
419
  type TokenUsage,
420
+ type UsageAttribution,
413
421
  type UsageBreakdown,
414
422
  type UsageEntry,
423
+ type UsageFilter,
424
+ type UsageRecord,
415
425
  type UsageSummary,
426
+ type UsageWindow,
427
+ type WindowReading,
428
+ windowDelta,
416
429
  } from './token-tracker';
417
430
  // Tools — typed execution interfaces
418
431
  export {
@@ -458,6 +471,8 @@ export type {
458
471
  CompletionResult,
459
472
  EmbeddingProvider,
460
473
  EmbeddingResult,
474
+ ImageInput,
475
+ ImageMediaType,
461
476
  InvocationCondition,
462
477
  LayerModeConfig,
463
478
  LLMMessage,
@@ -467,3 +482,4 @@ export type {
467
482
  ToolCallResult,
468
483
  ToolDefinition,
469
484
  } from './types';
485
+ export { IMAGE_MEDIA_TYPES, withoutImageData } from './types';
package/src/messages.ts CHANGED
@@ -61,6 +61,8 @@ export type ParticipantRequest =
61
61
  readonly served?: ServedModel;
62
62
  /** What the call's instructions and domain knowledge were composed of; the journal stores version ids for it. */
63
63
  readonly composition?: CompositionSnapshot;
64
+ /** A staged classification (Foundry's classify): which stage, 0-based, this call was. */
65
+ readonly stage?: number;
64
66
  }
65
67
  | { readonly status: 'not-sent'; readonly phase: string; readonly reason: string }
66
68
  /** A call may have been made, but its input was never observed (e.g. the caller threw first). Not "not-sent". */
@@ -234,7 +234,7 @@ export const DECISION_MODEL = 'claude-haiku-5-5';
234
234
  /** Subscription decision default per native runtime; settings choose the runtime. */
235
235
  export const SUBSCRIPTION_DECISION_MODELS = {
236
236
  claude: DECISION_MODEL,
237
- codex: 'gpt-6-luna',
237
+ codex: 'gpt-5.6-luna',
238
238
  } as const;
239
239
 
240
240
  /** Tag rule: every model judges. Cheap models review and route; capable models execute and advise. */
@@ -490,6 +490,10 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
490
490
  // Slugs, effort ladders and the 272k session window come from the local
491
491
  // Codex catalogue (~/.codex/models_cache.json); the same models carry a
492
492
  // 1,050,000 window on the platform API, which the openai entry records.
493
+ // Only models a ChatGPT login serves are listed: Codex refuses gpt-5.5 on a
494
+ // ChatGPT account ("not supported when using Codex with a ChatGPT account"). The
495
+ // GPT-6 models need a current Codex: 0.153.4 is refused them, 0.163.0 is served
496
+ // them (2026-10-16); the GPT-5.6 models run on both.
493
497
  {
494
498
  id: 'gpt-6-astra',
495
499
  label: 'GPT-6 Astra',
@@ -521,7 +525,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
521
525
  runtimeKind: 'native-harness',
522
526
  capabilities: FAST_REASONING,
523
527
  reasoning: CODEX_REASONING,
524
- notes: "Foundry's decision model.",
528
+ notes: 'Needs a current Codex CLI on a ChatGPT login (0.153.4 is refused it).',
525
529
  },
526
530
  {
527
531
  id: 'gpt-5.6-sol',
@@ -552,20 +556,8 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
552
556
  runtimeKind: 'native-harness',
553
557
  capabilities: FAST_REASONING,
554
558
  reasoning: CODEX_REASONING,
555
- },
556
- {
557
- id: 'gpt-5.5',
558
- label: 'GPT-5.5',
559
- tier: 'standard',
560
- costTier: 'medium',
561
- contextWindow: 272_000,
562
- runtimeKind: 'native-harness',
563
- capabilities: STANDARD_REASONING,
564
- reasoning: {
565
- ...CODEX_REASONING,
566
- efforts: ['low', 'medium', 'high', 'xhigh'],
567
- fallback: 'xhigh',
568
- },
559
+ notes:
560
+ "Foundry's Codex decision model: fast, and served to a ChatGPT login on every Codex CLI.",
569
561
  },
570
562
  ],
571
563
  },
@@ -1631,6 +1623,95 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
1631
1623
  },
1632
1624
  };
1633
1625
 
1626
+ /**
1627
+ * A vendor, the way a person thinks of who serves their models: one row for Anthropic, not one for
1628
+ * the Claude Code subscription and another for the API key. A vendor reaches its models in up to two
1629
+ * modes, and a person may have either or both:
1630
+ *
1631
+ * - subscription: the person's own subscription login, through a native CLI transport (Claude Code,
1632
+ * Codex). `comingSoon` marks a vendor that sells a subscription Foundry has no transport for yet.
1633
+ * - api: an API key (or, for a local server, none), through the registry provider named here.
1634
+ *
1635
+ * Each mode names the registry provider that serves it, so the provider table stays the one source
1636
+ * of models, transports and credentials; the vendor only groups them.
1637
+ */
1638
+ export type ProviderMode = 'subscription' | 'api';
1639
+ export interface ModelVendor {
1640
+ id: string;
1641
+ label: string;
1642
+ /** Absent for gateways and local hosts. */
1643
+ lab?: ModelLab;
1644
+ subscription?: { provider: string } | { comingSoon: true };
1645
+ api?: { provider: string };
1646
+ }
1647
+
1648
+ /** The five main-thread labs first, in this order, then every other vendor alphabetically. */
1649
+ export const MODEL_VENDORS: readonly ModelVendor[] = [
1650
+ {
1651
+ id: 'anthropic',
1652
+ label: 'Anthropic (Claude)',
1653
+ lab: 'anthropic',
1654
+ subscription: { provider: 'claude-code' },
1655
+ api: { provider: 'anthropic' },
1656
+ },
1657
+ {
1658
+ id: 'openai',
1659
+ label: 'OpenAI (Codex, GPT)',
1660
+ lab: 'openai',
1661
+ subscription: { provider: 'codex' },
1662
+ api: { provider: 'openai' },
1663
+ },
1664
+ {
1665
+ id: 'google',
1666
+ label: 'Google (Gemini)',
1667
+ lab: 'google',
1668
+ subscription: { comingSoon: true },
1669
+ api: { provider: 'gemini' },
1670
+ },
1671
+ {
1672
+ id: 'meta',
1673
+ label: 'Meta (Muse)',
1674
+ lab: 'meta',
1675
+ subscription: { comingSoon: true },
1676
+ api: { provider: 'meta' },
1677
+ },
1678
+ {
1679
+ id: 'xai',
1680
+ label: 'xAI (Grok)',
1681
+ lab: 'xai',
1682
+ subscription: { comingSoon: true },
1683
+ api: { provider: 'xai' },
1684
+ },
1685
+ { id: 'alibaba', label: 'Alibaba (Qwen)', lab: 'alibaba', api: { provider: 'qwen' } },
1686
+ { id: 'deepseek', label: 'DeepSeek', lab: 'deepseek', api: { provider: 'deepseek' } },
1687
+ { id: 'fireworks', label: 'Fireworks AI', api: { provider: 'fireworks' } },
1688
+ { id: 'groq', label: 'Groq', api: { provider: 'groq' } },
1689
+ { id: 'mistral', label: 'Mistral AI', lab: 'mistral', api: { provider: 'mistral' } },
1690
+ { id: 'moonshot', label: 'Moonshot AI (Kimi)', lab: 'moonshot', api: { provider: 'kimi' } },
1691
+ { id: 'ollama', label: 'Ollama (local)', api: { provider: 'ollama' } },
1692
+ { id: 'openrouter', label: 'OpenRouter', api: { provider: 'openrouter' } },
1693
+ { id: 'together', label: 'Together AI', api: { provider: 'together' } },
1694
+ { id: 'typesafe', label: 'TypeSafe (Jev)', lab: 'typesafe', api: { provider: 'typesafe' } },
1695
+ { id: 'vllm', label: 'vLLM (local)', api: { provider: 'vllm' } },
1696
+ { id: 'zhipu', label: 'Zhipu (GLM)', lab: 'zhipu', api: { provider: 'glm' } },
1697
+ ];
1698
+
1699
+ /** The registry provider serving a vendor's mode, or undefined when the mode has no transport. */
1700
+ export function vendorModeProvider(vendor: ModelVendor, mode: ProviderMode): string | undefined {
1701
+ const entry = vendor[mode];
1702
+ return entry && 'provider' in entry ? entry.provider : undefined;
1703
+ }
1704
+
1705
+ /** The vendor and mode a registry provider serves. Every registered provider serves exactly one. */
1706
+ export function vendorOfProvider(
1707
+ providerId: string,
1708
+ ): { vendor: ModelVendor; mode: ProviderMode } | undefined {
1709
+ for (const vendor of MODEL_VENDORS)
1710
+ for (const mode of ['subscription', 'api'] as const)
1711
+ if (vendorModeProvider(vendor, mode) === providerId) return { vendor, mode };
1712
+ return undefined;
1713
+ }
1714
+
1634
1715
  /** Every registered model carrying all of the requested capabilities. */
1635
1716
  export function modelOptionsByCapability(...capabilities: ModelCapability[]): ModelSweepOption[] {
1636
1717
  return Object.values(MODEL_REGISTRY).flatMap((provider) =>
package/src/thread.ts CHANGED
@@ -90,7 +90,7 @@ export interface ThreadMeta {
90
90
  /** The parent's turn that spawned this thread (a subagent). */
91
91
  parentTurnId?: string;
92
92
 
93
- /** How this subagent thread was spawned (docs/reference/SUBAGENTS.md §4.1). */
93
+ /** How this subagent thread was spawned. */
94
94
  spawn?: ThreadSpawn;
95
95
 
96
96
  /** 1 for a child of a main thread. */
@@ -170,6 +170,8 @@ export interface DispatchOptions {
170
170
  * everything that learns from the message sees only the user's words.
171
171
  */
172
172
  agentPayload?: unknown;
173
+ /** Images sent with the payload, for the agent alone (`ExecuteMeta.images`). */
174
+ images?: readonly import('./types').ImageInput[];
173
175
  /** Lets this dispatch's middleware include an on-demand layer in its assembly (`RequestContext.requestLayer`). */
174
176
  requestLayer?: (layerId: string) => void;
175
177
  }
@@ -437,6 +439,7 @@ export class Thread {
437
439
  },
438
440
  annotations: ctx.annotations,
439
441
  onDelta: opts?.onDelta,
442
+ ...(opts?.images?.length ? { images: opts.images } : {}),
440
443
  recordInjection: opts?.recordInjection,
441
444
  recordNative: opts?.recordNative,
442
445
  recordCompleted: opts?.recordCompleted,
@@ -15,15 +15,28 @@ export interface TokenUsage extends TokenCounts {
15
15
  }
16
16
 
17
17
  /**
18
- * A single recorded usage entry — one LLM call.
18
+ * What a usage entry or window reading is for, beyond the built-in dimensions: e.g. a replay's
19
+ * `fixtureId`, `arm`, `runId` and `role`. Filters match on these keys exactly.
20
+ */
21
+ export type UsageAttribution = Readonly<Record<string, string>>;
22
+
23
+ /**
24
+ * A single recorded usage entry — one LLM call (for a native session, one turn).
19
25
  */
20
26
  export interface UsageEntry {
21
27
  readonly timestamp: number;
22
28
  readonly provider: string;
23
29
  readonly model: string;
30
+ readonly effort?: string;
24
31
  readonly agentId?: string;
25
32
  readonly threadId?: string;
26
33
  readonly spanId?: string;
34
+ /**
35
+ * The subscription login the call ran on, by a non-secret id: a Kingdom Integration id, or a
36
+ * local label (`local`, `setup-token:<sha256 prefix>`). Never the credential itself.
37
+ */
38
+ readonly integrationId?: string;
39
+ readonly attribution?: UsageAttribution;
27
40
  readonly tokens: Readonly<TokenCounts>;
28
41
  readonly cost: number;
29
42
  /** False means cost is an unpriced subtotal, not a zero-price acknowledgment. */
@@ -31,6 +44,96 @@ export interface UsageEntry {
31
44
  readonly cached?: boolean;
32
45
  }
33
46
 
47
+ /**
48
+ * One reading of a subscription login's usage windows (percent used per window id, e.g.
49
+ * `five_hour`), taken over the provider's control channel, not a model call. A window covers the
50
+ * whole login, so its growth between two readings is an upper bound on what the calls between them
51
+ * spent.
52
+ */
53
+ /** One usage window in a reading. */
54
+ export interface UsageWindow {
55
+ /** 0–100. */
56
+ readonly usedPercent: number;
57
+ /** When the window resets (epoch ms); a different value in a later reading means it reset. */
58
+ readonly resetsAt?: number;
59
+ readonly windowMinutes?: number;
60
+ }
61
+
62
+ export interface WindowReading {
63
+ /** When the provider observed it (epoch ms). */
64
+ readonly timestamp: number;
65
+ readonly provider: string;
66
+ readonly integrationId?: string;
67
+ /** Where in the work it was taken, e.g. `before`, `after`, `gate`. */
68
+ readonly phase: string;
69
+ /** How it was read, e.g. `session` (the work's own session) or `probe` (a separate one). */
70
+ readonly source: string;
71
+ readonly windows: Readonly<Record<string, UsageWindow>>;
72
+ /** The provider reports ordinary usage blocked. Absent means not reported. */
73
+ readonly blocked?: boolean;
74
+ /** The provider's reason label when a limit is reached. */
75
+ readonly reachedType?: string;
76
+ readonly plan?: string;
77
+ readonly attribution?: UsageAttribution;
78
+ }
79
+
80
+ /**
81
+ * Selects entries and readings: every given field must match, and every given attribution key.
82
+ * Window readings carry no model, effort, agent, thread or span, so only `provider`,
83
+ * `integrationId` and `attribution` select them.
84
+ */
85
+ export interface UsageFilter {
86
+ readonly provider?: string;
87
+ readonly model?: string;
88
+ readonly effort?: string;
89
+ readonly agentId?: string;
90
+ readonly threadId?: string;
91
+ readonly spanId?: string;
92
+ readonly integrationId?: string;
93
+ readonly attribution?: UsageAttribution;
94
+ }
95
+
96
+ /** The usage a filter selects: its calls, their totals and the window readings, as recorded. */
97
+ export interface UsageRecord {
98
+ readonly calls: number;
99
+ readonly totals: TokenUsage;
100
+ readonly entries: ReadonlyArray<UsageEntry>;
101
+ readonly windowReadings: ReadonlyArray<WindowReading>;
102
+ }
103
+
104
+ /** Two readings of one reset time this far apart are different windows (reset times jitter). */
105
+ const RESET_TOLERANCE_MS = 60_000;
106
+
107
+ /**
108
+ * Growth per window id present in both readings, in percentage points (2 decimals). A window
109
+ * whose reset time moved between the readings reset in between: its growth is unknown, so it is
110
+ * listed under `reset` instead of `delta`.
111
+ */
112
+ export function windowDelta(
113
+ before: WindowReading['windows'],
114
+ after: WindowReading['windows'],
115
+ ): { delta: Record<string, number>; reset: string[] } {
116
+ const delta: Record<string, number> = {};
117
+ const reset: string[] = [];
118
+ for (const [id, a] of Object.entries(after)) {
119
+ const b = before[id];
120
+ if (!b) continue;
121
+ if (
122
+ b.resetsAt !== undefined &&
123
+ a.resetsAt !== undefined &&
124
+ Math.abs(a.resetsAt - b.resetsAt) > RESET_TOLERANCE_MS
125
+ )
126
+ reset.push(id);
127
+ else delta[id] = Number((a.usedPercent - b.usedPercent).toFixed(2));
128
+ }
129
+ return { delta, reset };
130
+ }
131
+
132
+ const attributionMatches = (
133
+ attribution: UsageAttribution | undefined,
134
+ want: UsageAttribution | undefined,
135
+ ): boolean => !want || Object.entries(want).every(([key, value]) => attribution?.[key] === value);
136
+
34
137
  /**
35
138
  * Per-model pricing: cost in dollars per 1 million tokens.
36
139
  */
@@ -188,6 +291,7 @@ type EventHandler = (status: BudgetStatus) => void;
188
291
 
189
292
  export class TokenTracker {
190
293
  private readonly _entries: UsageEntry[] = [];
294
+ private readonly _windowReadings: WindowReading[] = [];
191
295
  private readonly _costTable: CostTable;
192
296
  private readonly _budget: BudgetConfig;
193
297
  private readonly _warningHandlers: EventHandler[] = [];
@@ -232,10 +336,56 @@ export class TokenTracker {
232
336
  return full;
233
337
  }
234
338
 
339
+ /**
340
+ * Record a reading of a login's usage windows. It spends nothing, so no budget is checked.
341
+ * `timestamp` defaults to now.
342
+ */
343
+ recordWindows(reading: Omit<WindowReading, 'timestamp'> & { timestamp?: number }): WindowReading {
344
+ const full: WindowReading = { ...reading, timestamp: reading.timestamp ?? Date.now() };
345
+ this._windowReadings.push(full);
346
+ return full;
347
+ }
348
+
235
349
  // -----------------------------------------------------------------------
236
350
  // Queries
237
351
  // -----------------------------------------------------------------------
238
352
 
353
+ /** Every entry the filter selects, in recording order. */
354
+ entries(filter: UsageFilter = {}): ReadonlyArray<UsageEntry> {
355
+ return this._entries.filter(
356
+ (e) =>
357
+ (filter.provider === undefined || e.provider === filter.provider) &&
358
+ (filter.model === undefined || e.model === filter.model) &&
359
+ (filter.effort === undefined || e.effort === filter.effort) &&
360
+ (filter.agentId === undefined || e.agentId === filter.agentId) &&
361
+ (filter.threadId === undefined || e.threadId === filter.threadId) &&
362
+ (filter.spanId === undefined || e.spanId === filter.spanId) &&
363
+ (filter.integrationId === undefined || e.integrationId === filter.integrationId) &&
364
+ attributionMatches(e.attribution, filter.attribution),
365
+ );
366
+ }
367
+
368
+ /** Every window reading the filter selects (by provider, integrationId and attribution). */
369
+ windowReadings(filter: UsageFilter = {}): ReadonlyArray<WindowReading> {
370
+ return this._windowReadings.filter(
371
+ (r) =>
372
+ (filter.provider === undefined || r.provider === filter.provider) &&
373
+ (filter.integrationId === undefined || r.integrationId === filter.integrationId) &&
374
+ attributionMatches(r.attribution, filter.attribution),
375
+ );
376
+ }
377
+
378
+ /** The calls, totals and window readings a filter selects, as one record. */
379
+ usageRecord(filter: UsageFilter = {}): UsageRecord {
380
+ const entries = this.entries(filter);
381
+ return {
382
+ calls: entries.length,
383
+ totals: this._aggregate(entries),
384
+ entries,
385
+ windowReadings: this.windowReadings(filter),
386
+ };
387
+ }
388
+
239
389
  get totalTokens(): TokenUsage {
240
390
  return this._aggregate(this._entries);
241
391
  }
@@ -291,9 +441,10 @@ export class TokenTracker {
291
441
  };
292
442
  }
293
443
 
294
- /** Clear all recorded entries and reset warning state. */
444
+ /** Clear all recorded entries and window readings, and reset warning state. */
295
445
  reset(): void {
296
446
  this._entries.length = 0;
447
+ this._windowReadings.length = 0;
297
448
  this._warningEmitted = false;
298
449
  }
299
450
 
@@ -340,7 +491,7 @@ export class TokenTracker {
340
491
  );
341
492
  }
342
493
 
343
- private _aggregate(entries: UsageEntry[]): TokenUsage {
494
+ private _aggregate(entries: ReadonlyArray<UsageEntry>): TokenUsage {
344
495
  const tokens = sumTokenCounts(entries.map((e) => e.tokens));
345
496
  return {
346
497
  ...tokens,
package/src/types.ts CHANGED
@@ -38,9 +38,34 @@ export interface LayerModeConfig {
38
38
  // LLM provider interface — the abstraction, not implementations
39
39
  // ---------------------------------------------------------------------------
40
40
 
41
+ export const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] as const;
42
+ export type ImageMediaType = (typeof IMAGE_MEDIA_TYPES)[number];
43
+
44
+ /** An image sent with a user message. */
45
+ export interface ImageInput {
46
+ /** Identity of the stored image; what evidence records in place of the bytes. */
47
+ readonly id: string;
48
+ readonly mediaType: ImageMediaType;
49
+ /** Size of the image in bytes. */
50
+ readonly bytes: number;
51
+ /** The image, base64. Present at the provider boundary, dropped from recorded evidence. */
52
+ readonly data?: string;
53
+ }
54
+
41
55
  export interface LLMMessage {
42
56
  readonly role: 'system' | 'user' | 'assistant';
43
57
  readonly content: string;
58
+ /** Images sent with this (user) message, for a provider that declares `imageInput`. */
59
+ readonly images?: readonly ImageInput[];
60
+ }
61
+
62
+ /** Messages as evidence records them: each image by identity, without its bytes. */
63
+ export function withoutImageData(messages: readonly LLMMessage[]): LLMMessage[] {
64
+ return messages.map((message) =>
65
+ message.images
66
+ ? { ...message, images: message.images.map(({ data: _data, ...image }) => image) }
67
+ : message,
68
+ );
44
69
  }
45
70
 
46
71
  /** Tool definition sent to the LLM so it can call tools. */
@@ -155,6 +180,8 @@ export interface LLMStreamEvent {
155
180
 
156
181
  export interface LLMProvider {
157
182
  readonly id: string;
183
+ /** Accepts `LLMMessage.images`. A provider without it refuses a message that carries images. */
184
+ readonly imageInput?: boolean;
158
185
  /** Native callback support, distinct from stateless/legacy serializable options. */
159
186
  readonly nativeOwnership?: 'required-prewrite';
160
187
  /** Optional owned-call evidence. A rejected waiter alone never proves remote settlement. */