@modelprofile.com/flexharness 10.10.0 → 10.12.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/ts/errors.ts CHANGED
@@ -118,6 +118,14 @@ export class FlexHarnessAbortError extends FlexHarnessError {
118
118
  }
119
119
  }
120
120
 
121
+ /** The conversational run exhausted its model steps before producing a final answer. */
122
+ export class FlexHarnessStepLimitError extends FlexHarnessError {
123
+ constructor(public readonly steps: number) {
124
+ super('FLEX_STEP_LIMIT', `The run exhausted its ${steps} model steps before completing its answer.`);
125
+ this.name = 'FlexHarnessStepLimitError';
126
+ }
127
+ }
128
+
121
129
  export class FlexHarnessCallbackOverflowError extends FlexHarnessError {
122
130
  constructor(message: string) {
123
131
  super('FLEX_CALLBACK_OVERFLOW', message);
package/ts/interfaces.ts CHANGED
@@ -62,6 +62,34 @@ export interface IFlexUsage {
62
62
  cacheWriteTokens: number;
63
63
  }
64
64
 
65
+ /** Durable fixed-size model-call accounting, independent of transcript visibility. */
66
+ export interface IFlexSessionMetricsState {
67
+ reportedLifetimeUsedTokens: number;
68
+ reportedLifetimeUsedTokensExact: boolean;
69
+ lifetimeUsageComplete: boolean;
70
+ pendingGenerationId?: string;
71
+ pendingCompactionId?: string;
72
+ lastObservedInputTokens?: number;
73
+ lastObservedInputTokensAt?: string;
74
+ }
75
+
76
+ /** No member estimates current context size or includes child-session / tool-owned model calls. */
77
+ export interface IFlexSessionMetrics {
78
+ /** Sum of durably settled per-call reports; a lower bound when incomplete. */
79
+ reportedLifetimeUsedTokens: number;
80
+ /** False if invalid reports or safe-integer overflow prevented an exact reported sum. */
81
+ reportedLifetimeUsedTokensExact: boolean;
82
+ lifetimeUsageComplete: boolean;
83
+ /** Present only when lifetimeUsageComplete is true. */
84
+ lifetimeUsedTokens?: number;
85
+ inFlight: boolean;
86
+ /** Input of the last durably settled generation call, not its compaction summary call. */
87
+ lastObservedInputTokens?: number;
88
+ lastObservedInputTokensAt?: string;
89
+ /** Always historical: output, steering, compaction and reversion can change context. */
90
+ lastObservedInputTokensFreshness?: 'historical';
91
+ }
92
+
65
93
  export interface IFlexModelIdentity {
66
94
  provider: string;
67
95
  model: string;
@@ -775,6 +803,8 @@ export interface IFlexProjectionSnapshotV2 {
775
803
 
776
804
  export interface IFlexProjectionSnapshotV3 {
777
805
  schemaVersion: 3;
806
+ /** Optional for existing schema-3 stores. Absence means lifetime usage is unknown. */
807
+ metrics?: IFlexSessionMetricsState;
778
808
  revision: number;
779
809
  messages: IFlexMessage[];
780
810
  stagedTerminals: IFlexTerminalProjection[];
@@ -1390,6 +1420,7 @@ export interface IFlexHarnessOptions<TScope> {
1390
1420
  subagents?: IFlexSubagentDefinition[];
1391
1421
  builtInTools?: IFlexBuiltInToolsOptions;
1392
1422
  slashCommands?: readonly TFlexSlashCommandRegistration<TScope>[];
1423
+ /** Maximum child depth (root is 0). Defaults to 6; configurable from 1 through 8. */
1393
1424
  maxSubagentDepth?: number;
1394
1425
  maxSubagentCallsPerRun?: number;
1395
1426
  }
package/ts/plugins.ts CHANGED
@@ -44,6 +44,7 @@ export {
44
44
  };
45
45
  export type {
46
46
  IAgentAppliedInput,
47
+ ISmartAiCacheDispatch,
47
48
  IAgentEventArchiveV2,
48
49
  IAgentContextCompactionOptions,
49
50
  IAgentEventSnapshotV2,
@@ -77,7 +78,7 @@ export type {
77
78
  TAgentToolExecutionReconciliationOptions,
78
79
  } from '@modelprofile.com/flexharness-agent';
79
80
 
80
- import { isModelLimitError, isModelLimitInfo } from '@modelprofile.com/flexharness-models';
81
+ import { getSmartAiCacheWaitMs, isModelLimitError, isModelLimitInfo } from '@modelprofile.com/flexharness-models';
81
82
 
82
- export { isModelLimitError, isModelLimitInfo };
83
+ export { getSmartAiCacheWaitMs, isModelLimitError, isModelLimitInfo };
83
84
  export type { IModelLimitInfo } from '@modelprofile.com/flexharness-models';
package/ts/readme.md CHANGED
@@ -62,7 +62,7 @@ or provider SDKs.
62
62
  | `/chat` | Portable streaming `ChatSession`, history and usage | None |
63
63
  | `/chat/cli` | Ink/React terminal chat | `ink ink-text-input react`; TypeScript: `@types/react` |
64
64
  | `/chat/web` | Lit `flexchat-window`, `flexchat-message`, `flexchat-input` | `lit` |
65
- | `/chat/harness` | `FlexHarnessTranscript` and intents for the `@design.estate/dees-catalog` harness components, browser-safe | `@design.estate/dees-catalog` `^17.0.0 \|\| ^19.5.0` (types only) |
65
+ | `/chat/harness` | `FlexHarnessTranscript` and intents for the `@design.estate/dees-catalog` harness components, browser-safe | `@design.estate/dees-catalog` `^17.0.0 \|\| ^19.5.0 \|\| ^20.0.0 \|\| ^21.0.0` (types only) |
66
66
  | `/kvm` | Browser control, terminal framing and OCR observation | `puppeteer` |
67
67
  | `/documents` | PDF and image documents, `extractTextFromPdf` | `@push.rocks/smartpdf`; for image documents `sharp` |
68
68
  | `/mcp` | MCP clients and AI SDK tool conversion | `@modelcontextprotocol/sdk` |
@@ -92,6 +92,41 @@ a model backed by its own server transport, or supply `run` in
92
92
  `IChatSessionOptions`. The web element accepts an `IChatSession` and maintains its
93
93
  own subscription. Full Node `AgentSession` remains at the agent package root.
94
94
 
95
+ ## Durable session token metrics
96
+
97
+ Read `await harness.getSessionMetrics(scopeId, sessionId)` for bounded, durable
98
+ accounting independent of transcript visibility. It reads fixed-size projection
99
+ state rather than summing assistant messages or scanning event/archive/child history.
100
+ A warm read joins outstanding projection writes and surfaces accounting failures.
101
+
102
+ | Field | Meaning |
103
+ | --- | --- |
104
+ | `reportedLifetimeUsedTokens` | Sum of durably settled per-call reports. Incomplete accounting makes this a lower bound. |
105
+ | `reportedLifetimeUsedTokensExact` | Whether the sum of those reports is exact; invalid reports and safe-integer overflow make it inexact. |
106
+ | `lifetimeUsageComplete` | Every owned call is accounted for and no accounting operation remains in flight. |
107
+ | `lifetimeUsedTokens` | Present only when the lifetime total is complete. Zero is a valid total. |
108
+ | `inFlight` | A generation or actual compactor invocation has not settled its accounting yet. |
109
+ | `lastObservedInputTokens`, `lastObservedInputTokensAt` | Input count and observation time of the last settled generation call with complete input/output totals. |
110
+ | `lastObservedInputTokensFreshness` | Always `historical`. This is **not** current context size: output, steering, compaction and reversion can change context. |
111
+
112
+ Accounting covers this session's generation calls and reported compaction calls,
113
+ including failure, cancellation and retries. Child sessions own their own spend;
114
+ tool-owned model calls are not included. Do not sum cumulative assistant-message
115
+ usage to reconstruct lifetime spend. Missing/partial reports remain incomplete;
116
+ unsafe totals saturate at `Number.MAX_SAFE_INTEGER` without claiming exactness.
117
+ A compactor that reports no calls cannot prove zero spend.
118
+
119
+ New sessions start complete at zero. Existing/imported sessions with unknown prior
120
+ spend start incomplete. Write-ahead operation markers make interrupted recovery
121
+ conservative: unresolved operations are cleared without certifying their unknown
122
+ spend. Undo, redo, compaction and transcript archiving never subtract lifetime
123
+ spend. Importing visible history does not reconstruct historical billing.
124
+
125
+ Conversational runs that exhaust `maxSteps` with tool work or unseen input remaining
126
+ fail with `FlexHarnessStepLimitError` (`FLEX_STEP_LIMIT`), rather than being marked
127
+ completed with an empty answer. Standalone Agent callers retain tool-only results
128
+ and can inspect `terminationReason: 'model' | 'step-limit'`.
129
+
95
130
  ## Provider subpaths
96
131
 
97
132
  Install `@modelprofile.com/flexharness-providers` once. Its root exports all provider
@@ -260,7 +295,7 @@ const harness = new FlexHarness<IProjectScope>({
260
295
  maxSteps: 8,
261
296
  },
262
297
  ],
263
- maxSubagentDepth: 1,
298
+ maxSubagentDepth: 6,
264
299
  maxSubagentCallsPerRun: 32,
265
300
  externalErrorProjector: (_error, context) => ({
266
301
  name: 'ModelOperationError',
@@ -414,12 +449,10 @@ Normal Flex session cleanup always waits in-flight local project operations, the
414
449
 
415
450
  ## Subagents
416
451
 
417
- `subagents` enables the harness-owned `delegate` and `delegate_result` tools when at least one definition exists and the session depth is below `maxSubagentDepth`. A tool provider cannot replace either enabled built-in. Delegation is asynchronous by default: `delegate` returns `{ taskId, status: 'running', runId }` after durable child admission, so the parent can continue independent work. `delegate_result({ taskId, waitMs? })` reads live status and, once settled, bounded final text and model identity; its optional wait is limited to 240 seconds and observes parent cancellation. Set `foreground: true` to await the final answer in `delegate` itself. Children remain owned by the parent turn: Stop cancels its exact child runs, and the parent joins them before releasing authority and finalizing. A child result enters the parent's next model context as untrusted task data. When the parent's model ends its turn while background children still run, the turn waits for the next child to settle and takes its result in with another step (while steps remain), so a delegated result is never lost because the parent finished first.
452
+ `subagents` enables the harness-owned `delegate`, `delegate_result`, and `delegate_send` tools when at least one definition exists and the session depth is below `maxSubagentDepth`. A tool provider cannot replace any enabled built-in; global and agent tool allowlists still intersect to control model-facing exposure. Delegation is asynchronous by default: `delegate` returns `{ taskId, status: 'running', runId }` after durable child admission, so the parent can continue independent work. `delegate_result({ taskId, waitMs? })` reads live status and, once settled, bounded final text and model identity; its optional wait is limited to 240 seconds and observes parent cancellation. Set `foreground: true` to await the final answer in `delegate` itself. Children remain owned by the parent turn: Stop cancels its exact child runs, and the parent joins them before releasing authority and finalizing. A child result enters the parent's next model context as untrusted task data. When the parent's model ends its turn while background children still run, the turn waits for the next child to settle and takes its result in with another step (while steps remain), so a delegated result is never lost because the parent finished first.
418
453
 
419
454
  The model calls it with this exact input shape:
420
455
 
421
- When upgrading from 9.x, callers that require the previous synchronous delegate result must pass `foreground: true`. Tool providers must also leave the newly reserved `delegate_result` name available when subagents are enabled.
422
-
423
456
  ```typescript
424
457
  interface IDelegateInput {
425
458
  description: string;
@@ -430,9 +463,24 @@ interface IDelegateInput {
430
463
  }
431
464
  ```
432
465
 
466
+ When upgrading from 9.x, callers that require the previous synchronous delegate result must pass `foreground: true`. Tool providers must also leave the reserved `delegate_result` and `delegate_send` names available when subagents are enabled.
467
+
468
+ `delegate_send({ taskId, prompt })` sends follow-up instructions to a running direct child owned by this exact parent turn. Both fields are required and extra fields are rejected; `taskId` is non-empty and at most 512 UTF-8 bytes, and `prompt` is non-empty and at most 64 KiB. It returns `{ steerId, queueId, runId }` on admission, with a stable steer identity derived from the parent run and tool call. The child takes the input at its next inference boundary, without interrupting a running tool or permission wait. A child waiting to finish while its own descendants run can take the follow-up and continue inference; those descendants keep running. Use `delegate_result` to inspect progress and collect the final answer.
469
+
470
+ Controllers can use the same admission path explicitly:
471
+
472
+ ```typescript
473
+ await harness.sendSubagentInput(scopeId, parentSessionId, parentRunId, taskId, {
474
+ steerId: 'follow-up-1',
475
+ prompt: 'Prefer primary sources and include the publication date.',
476
+ });
477
+ ```
478
+
479
+ Sending requires the exact active parent run, its currently delegated direct child, matching captured configuration and parent/child generations, and live writable ancestors. It does not authorize a root to skip levels, or infer ownership from immutable creation-origin metadata: a resumed child belongs to the current delegate invocation. Foreign, unowned, stale, cancelled, settled, or ending runs reject; sending never starts an idle generation, restarts a child, or resumes one. General public `steerPrompt()` still forbids child sessions. Child sends share root steering's duplicate rejection, pending count/byte bounds, accepted/applied events, durable user-message application, and terminal unapplied-input accounting.
480
+
433
481
  Before creating or resuming a child, FlexHarness requests permission on the parent run with `kind: 'subagent.start'`, the parent `toolCallId`, and separately bounded harness-owned agent/task metadata. Its metadata includes `childSessionId`, the exact ID reserved for this call: FlexHarness derives it deterministically when `taskId` is omitted and copies the supplied candidate when `taskId` is present. A resume also retains that candidate as `taskId`. This block is not truncated by `toolOutputLimits`. The reserved ID binds permission handling before child creation but does not prove that the child exists or is owned: applications must treat the later delegated admission context as authoritative. The controller answers through the normal permission APIs. This request has no `rememberKey`, so `always` is invalid; controllers use `once` or `reject`.
434
482
 
435
- Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` remains limited to `sessionId`, `sessionGenerationId`, and `title`. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the `delegate` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
483
+ Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` accepts `sessionId`, `sessionGenerationId`, `configurationRef`, and `title`. A child inherits its parent's `configurationRef` and cannot resume under a different reference. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the `delegate` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
436
484
 
437
485
  The parent tool part receives `childSessionId` as soon as the child is acquired, so a host can offer an authorized live transcript. A foreground call also adds the resolved child model to its part and returns bounded JSON:
438
486
 
@@ -485,7 +533,7 @@ Acquisition completes before generation-side branch reversion, context compactio
485
533
 
486
534
  For a normally acquired lease, FlexHarness gives `close()` an awaited attempt after AgentSession generation and before canonical accepted, rejected, or interrupted finalization and the terminal `prompt.finished` event. If an abort detaches an acquisition that ignores its signal, the run may settle before acquisition returns; FlexHarness retains that owner and closes any late lease. `close()` must be idempotent and safe to retry after rejection. A close failure prevents successful child acceptance and remains owned by the exact child session generation for retry by later exact-session deletion, scope retirement, or disposal. Retirement and disposal truthfully wait for late acquisition and lease cleanup.
487
535
 
488
- Limits are validated and frozen at construction: at most 32 unique definitions; names are non-empty and at most 128 UTF-8 bytes; descriptions 2048 bytes; optional model hints 512 bytes; optional system prompts 64 KiB; and optional `maxSteps` a positive safe integer. `maxSubagentDepth` defaults to 1 and must be a positive safe integer at most 8. `maxSubagentCallsPerRun` defaults to 32 and must be a positive safe integer at most 128. A call slot is consumed synchronously at the start of every schema-valid delegate execution, before semantic bounds, subagent type/depth validation, permission, or child work. Inputs rejected by the tool schema never start delegate execution and do not consume a slot. After successful semantic validation, the child ID candidate is reserved for the rest of the parent run, including after permission rejection or later failure. Permission rejection creates no child session. Omitting `taskId` reserves a deterministic new child ID; supplying `taskId` reserves that unverified candidate and attempts resume after permission only if it identifies a resumable child. Delegate descriptions are non-empty and at most 256 UTF-8 bytes, prompts non-empty and at most 64 KiB, subagent types at most 128 bytes, and task IDs at most 512 bytes.
536
+ Limits are validated and frozen at construction: at most 32 unique definitions; names are non-empty and at most 128 UTF-8 bytes; descriptions 2048 bytes; optional model hints 512 bytes; optional system prompts 64 KiB; and optional `maxSteps` a positive safe integer. `maxSubagentDepth` defaults to 6 and must be a positive safe integer at most 8. The root is depth 0, so the default permits six child levels (depths 1 through 6); delegation tools are absent at the configured maximum. `maxSubagentCallsPerRun` defaults to 32 and must be a positive safe integer at most 128. A call slot is consumed synchronously at the start of every schema-valid delegate execution, before semantic bounds, subagent type/depth validation, permission, or child work. Inputs rejected by the tool schema never start delegate execution and do not consume a slot. After successful semantic validation, the child ID candidate is reserved for the rest of the parent run, including after permission rejection or later failure. Permission rejection creates no child session. Omitting `taskId` reserves a deterministic new child ID; supplying `taskId` reserves that unverified candidate and attempts resume after permission only if it identifies a resumable child. Delegate descriptions are non-empty and at most 256 UTF-8 bytes, prompts non-empty and at most 64 KiB, subagent types at most 128 bytes, and task IDs at most 512 bytes.
489
537
 
490
538
  Scope snapshots remain schema 1 and legacy child tombstones without `subagent` provenance continue to load. New child tombstones write the atomic provenance block and reject partial, parent-generation-mismatched, depth-mismatched, or duplicate-origin records. FlexHarness versions before this provenance addition reject that new optional key under their strict reader, so downgrading or mixing old readers with newly written scope snapshots is unsupported.
491
539
 
@@ -645,7 +693,7 @@ console.log(result.unappliedSteerIds); // [] once the run took the steer in
645
693
 
646
694
  The run takes a steer in at its next step boundary: after the tool results of its current model step are recorded and before its next model call. A running tool call or a pending permission is never interrupted; the steer waits for the boundary that follows it. A steer taken in is a user message of the run (`steerId` set) that the model sees in its next call and every later turn. If the run had already answered, the assistant message that answered is completed and the run answers on in a new assistant message after the steer; a steer taken in before the run answered anything is placed before its still empty assistant message. `IFlexPromptResult.userMessage` stays the prompt and `assistantMessage` is the run's last assistant message. Undo and redo treat the run's messages as one turn.
647
695
 
648
- `steerPrompt()` resolves with `{ steerId, queueId, runId }` once the run accepted the steer and `prompt.steer.accepted` was emitted; `prompt.steer.applied` carries the `messageId` of the user message the steer became. Every accepted steer ends in exactly one of two ways: it is in the model context of later turns, or it is listed in `unappliedSteerIds`, in arrival order, on `IFlexPromptResult` and `run.finished`, and on the finished queue entry when that list is not empty. A steer is listed when the run did not take it in before it ended, because its last model step was already running or because it was stopped or failed, and also when the run itself does not stay in context. `keptInContext` on `run.finished` and on the finished queue entry says whether it does: it is `false` for a failed run and for a run stopped before any model output, and such a run lists every steer it accepted, including the ones it took in and reported with `prompt.steer.applied`. Send its prompt and its unapplied steers again to have them answered. The run stops taking steers atomically when its generation ends or `abort()`/`cancelPrompt()` stops it. A steer for a prompt without a running run, or whose run is ending, is refused with `FlexHarnessSteerRejectedError` reason `not-running`; the caller sends it as the next turn instead. Reusing a `steerId` within a run is refused with reason `duplicate`, an unknown `queueId` throws `FlexHarnessNotFoundError`, and subagent sessions are not steerable. A run holds at most `maxOutstandingPromptsPerSession` pending steers, and their bytes count against the session's outstanding prompt byte limit until the run takes them in or ends, so `startPrompt()` and `enqueuePrompt()` are refused with `FlexHarnessQueueFullError` while pending steers fill it. They are process-local like queue entries: a process restart ends the run and drops its pending steers, while steers it took in are durable parts of the turn.
696
+ `steerPrompt()` resolves with `{ steerId, queueId, runId }` once the run accepted the steer and `prompt.steer.accepted` was emitted; `prompt.steer.applied` carries the `messageId` of the user message the steer became. Every accepted steer ends in exactly one of two ways: it is in the model context of later turns, or it is listed in `unappliedSteerIds`, in arrival order, on `IFlexPromptResult` and `run.finished`, and on the finished queue entry when that list is not empty. A steer is listed when the run did not take it in before it ended, because its last model step was already running or because it was stopped or failed, and also when the run itself does not stay in context. `keptInContext` on `run.finished` and on the finished queue entry says whether it does: it is `false` for a failed run and for a run stopped before any model output, and such a run lists every steer it accepted, including the ones it took in and reported with `prompt.steer.applied`. Send its prompt and its unapplied steers again to have them answered. The run stops taking steers atomically when its generation ends or `abort()`/`cancelPrompt()` stops it. A steer for a prompt without a running run, or whose run is ending, is refused with `FlexHarnessSteerRejectedError` reason `not-running`; the caller sends it as the next turn instead. Reusing a `steerId` within a run is refused with reason `duplicate`, an unknown `queueId` throws `FlexHarnessNotFoundError`, and child sessions reject this general API (their exact parent run uses `sendSubagentInput()` or `delegate_send` instead). A run holds at most `maxOutstandingPromptsPerSession` pending steers, and their bytes count against the session's outstanding prompt byte limit until the run takes them in or ends, so `startPrompt()` and `enqueuePrompt()` are refused with `FlexHarnessQueueFullError` while pending steers fill it. They are process-local like queue entries: a process restart ends the run and drops its pending steers, while steers it took in are durable parts of the turn.
649
697
 
650
698
  ## Slash Commands
651
699
 
@@ -1089,6 +1137,8 @@ Each domain serializes its own mutations. A successful run first persists a hidd
1089
1137
 
1090
1138
  Projection stores accept schema version 1, 2, or 3 from `load()`, but every `save()` receives the current schema-3 shape. Schema 3 records explicit reversion protocol, transcript/workspace provenance, and V2 disposition. A terminal V2 segment must be conclusively `revertible`, `no-change`, or `nonrevertible`; a pending V2 capture remains owned by its capture WAL and is never treated as transcript history.
1091
1139
 
1140
+ Current schema-3 projections also persist optional `metrics` state. Older strict readers that do not recognize this key cannot reopen a projection after metrics have been written; downgrade or mixed-version access to that session is unsupported.
1141
+
1092
1142
  A loaded schema-1 projection has no reversion state. Schema-2 `workspaceCaptured` segments migrate as protocol-1 workspace/revertible history without discarding references; transcript-only segments migrate as protocol-1 transcript provenance. Under `workspace-required`, that legacy transcript history is a barrier. The next projection mutation writes schema 3. Custom stores must preserve strict compare-and-swap revisions across schema-1 and schema-2 read-upgrade-write cycles, including uncertain-save reconciliation.
1093
1143
 
1094
1144
  ## Migrating From 2.x
package/ts/utils.json.ts CHANGED
@@ -1506,6 +1506,35 @@ function validateAffectedWorkspaces(value: unknown, path: string): void {
1506
1506
  }
1507
1507
  }
1508
1508
 
1509
+ function assertSessionMetrics(value: unknown): void {
1510
+ const path = '$snapshot.metrics';
1511
+ const metrics = requireRecord(value, path);
1512
+ requireOnlyKeys(metrics, [
1513
+ 'reportedLifetimeUsedTokens', 'reportedLifetimeUsedTokensExact', 'lifetimeUsageComplete',
1514
+ 'pendingGenerationId', 'pendingCompactionId', 'lastObservedInputTokens', 'lastObservedInputTokensAt',
1515
+ ], path);
1516
+ requireNonNegativeInteger(metrics.reportedLifetimeUsedTokens, `${path}.reportedLifetimeUsedTokens`);
1517
+ for (const key of ['reportedLifetimeUsedTokensExact', 'lifetimeUsageComplete']) {
1518
+ if (typeof metrics[key] !== 'boolean') throw new FlexHarnessStoreFormatError(`${path}.${key} must be boolean.`);
1519
+ }
1520
+ if (!metrics.reportedLifetimeUsedTokensExact && metrics.lifetimeUsageComplete) {
1521
+ throw new FlexHarnessStoreFormatError(`${path} cannot certify an inexact total.`);
1522
+ }
1523
+ for (const key of ['pendingGenerationId', 'pendingCompactionId']) {
1524
+ if (Object.hasOwn(metrics, key)) requireBoundedString(metrics[key], `${path}.${key}`, 512);
1525
+ }
1526
+ const hasInput = Object.hasOwn(metrics, 'lastObservedInputTokens');
1527
+ const hasTime = Object.hasOwn(metrics, 'lastObservedInputTokensAt');
1528
+ if (hasInput !== hasTime) throw new FlexHarnessStoreFormatError(`${path} has a partial input observation.`);
1529
+ if (hasInput) {
1530
+ requireNonNegativeInteger(metrics.lastObservedInputTokens, `${path}.lastObservedInputTokens`);
1531
+ const timestamp = requireString(metrics.lastObservedInputTokensAt, `${path}.lastObservedInputTokensAt`);
1532
+ if (!Number.isFinite(Date.parse(timestamp)) || new Date(timestamp).toISOString() !== timestamp) {
1533
+ throw new FlexHarnessStoreFormatError(`${path}.lastObservedInputTokensAt must be an ISO timestamp.`);
1534
+ }
1535
+ }
1536
+ }
1537
+
1509
1538
  export function assertFlexProjectionSnapshotV3(
1510
1539
  value: unknown,
1511
1540
  ): asserts value is IFlexProjectionSnapshotV3 {
@@ -1514,6 +1543,7 @@ export function assertFlexProjectionSnapshotV3(
1514
1543
  requireOnlyKeys(snapshot, [
1515
1544
  'schemaVersion',
1516
1545
  'revision',
1546
+ 'metrics',
1517
1547
  'messages',
1518
1548
  'stagedTerminals',
1519
1549
  'reversionSegments',
@@ -1522,6 +1552,7 @@ export function assertFlexProjectionSnapshotV3(
1522
1552
  'pendingReversion',
1523
1553
  'pendingReversionReleases',
1524
1554
  ], '$snapshot');
1555
+ if (Object.hasOwn(snapshot, 'metrics')) assertSessionMetrics(snapshot.metrics);
1525
1556
  if (snapshot.schemaVersion !== 3) {
1526
1557
  throw new FlexHarnessStoreFormatError('Projection snapshot schemaVersion must be 3.');
1527
1558
  }
@@ -1673,8 +1704,9 @@ export function assertFlexProjectionSnapshotV3(
1673
1704
  }
1674
1705
  releaseCaptureIds.add(captureId);
1675
1706
  }
1707
+ const { metrics: _metrics, ...compatibleSnapshot } = snapshot;
1676
1708
  const compatibleV2 = {
1677
- ...snapshot,
1709
+ ...compatibleSnapshot,
1678
1710
  schemaVersion: 2,
1679
1711
  reversionSegments: snapshot.reversionSegments.map((entry) => {
1680
1712
  const segment = entry as Record<string, unknown>;
@@ -0,0 +1,104 @@
1
+ import type {
2
+ IFlexSessionMetrics,
3
+ IFlexSessionMetricsState,
4
+ TFlexModelCallUsage,
5
+ } from './interfaces.js';
6
+
7
+ /** Fixed-size accumulator. One owner per generation / actual compactor invocation. */
8
+ export interface ISessionMetricsOperation {
9
+ id: string;
10
+ source: 'generation' | 'compaction';
11
+ reportedTokens: number;
12
+ reportedTokensExact: boolean;
13
+ complete: boolean;
14
+ sawCall: boolean;
15
+ lastObservedInputTokens?: number;
16
+ lastObservedInputTokensAt?: string;
17
+ }
18
+
19
+ export const createSessionMetricsState = (complete: boolean): IFlexSessionMetricsState => ({
20
+ reportedLifetimeUsedTokens: 0,
21
+ reportedLifetimeUsedTokensExact: true,
22
+ lifetimeUsageComplete: complete,
23
+ });
24
+
25
+ export const createSessionMetricsOperation = (
26
+ id: string,
27
+ source: ISessionMetricsOperation['source'],
28
+ ): ISessionMetricsOperation => ({
29
+ id, source, reportedTokens: 0, reportedTokensExact: true, complete: true, sawCall: false,
30
+ });
31
+
32
+ const isTokenCount = (value: unknown): value is number =>
33
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
34
+
35
+ /** Saturation is a safe lower bound, never a rounded purported exact total. */
36
+ const addTokens = (left: number, right: number): { tokens: number; exact: boolean } =>
37
+ right > Number.MAX_SAFE_INTEGER - left
38
+ ? { tokens: Number.MAX_SAFE_INTEGER, exact: false }
39
+ : { tokens: left + right, exact: true };
40
+
41
+ export function observeSessionMetricsCall(
42
+ operation: ISessionMetricsOperation,
43
+ call: TFlexModelCallUsage,
44
+ ): void {
45
+ operation.sawCall = true;
46
+ if (call.status === 'unreported') {
47
+ operation.complete = false;
48
+ return;
49
+ }
50
+ const { usage } = call;
51
+ if (call.usageComplete === false) operation.complete = false;
52
+ if (!Object.values(usage).every(isTokenCount)
53
+ || !isTokenCount(usage.totalTokens)
54
+ || usage.inputTokens > Number.MAX_SAFE_INTEGER - usage.outputTokens
55
+ || usage.totalTokens !== usage.inputTokens + usage.outputTokens) {
56
+ operation.complete = false;
57
+ operation.reportedTokensExact = false;
58
+ return;
59
+ }
60
+ const sum = addTokens(operation.reportedTokens, usage.totalTokens);
61
+ operation.reportedTokens = sum.tokens;
62
+ operation.reportedTokensExact &&= sum.exact;
63
+ if (operation.source === 'generation' && call.usageComplete !== false) {
64
+ operation.lastObservedInputTokens = usage.inputTokens;
65
+ operation.lastObservedInputTokensAt = new Date().toISOString();
66
+ }
67
+ }
68
+
69
+ export function settleSessionMetricsOperation(
70
+ metrics: IFlexSessionMetricsState,
71
+ operation: ISessionMetricsOperation,
72
+ ): void {
73
+ const key = operation.source === 'generation' ? 'pendingGenerationId' : 'pendingCompactionId';
74
+ if (metrics[key] !== operation.id) throw new Error('Session metrics operation ownership changed.');
75
+ const sum = addTokens(metrics.reportedLifetimeUsedTokens, operation.reportedTokens);
76
+ metrics.reportedLifetimeUsedTokens = sum.tokens;
77
+ metrics.reportedLifetimeUsedTokensExact &&= sum.exact && operation.reportedTokensExact;
78
+ metrics.lifetimeUsageComplete &&= operation.complete
79
+ && metrics.reportedLifetimeUsedTokensExact
80
+ // An arbitrary compactor may make uninstrumented calls. No report is not proof of zero spend.
81
+ && (operation.source === 'generation' || operation.sawCall);
82
+ if (operation.lastObservedInputTokens !== undefined) {
83
+ metrics.lastObservedInputTokens = operation.lastObservedInputTokens;
84
+ metrics.lastObservedInputTokensAt = operation.lastObservedInputTokensAt;
85
+ }
86
+ delete metrics[key];
87
+ }
88
+
89
+ export function sessionMetricsView(metrics: IFlexSessionMetricsState): IFlexSessionMetrics {
90
+ const inFlight = metrics.pendingGenerationId !== undefined || metrics.pendingCompactionId !== undefined;
91
+ const lifetimeUsageComplete = metrics.lifetimeUsageComplete && !inFlight;
92
+ return {
93
+ reportedLifetimeUsedTokens: metrics.reportedLifetimeUsedTokens,
94
+ reportedLifetimeUsedTokensExact: metrics.reportedLifetimeUsedTokensExact,
95
+ lifetimeUsageComplete,
96
+ inFlight,
97
+ ...(lifetimeUsageComplete ? { lifetimeUsedTokens: metrics.reportedLifetimeUsedTokens } : {}),
98
+ ...(metrics.lastObservedInputTokens === undefined ? {} : {
99
+ lastObservedInputTokens: metrics.lastObservedInputTokens,
100
+ lastObservedInputTokensAt: metrics.lastObservedInputTokensAt,
101
+ lastObservedInputTokensFreshness: 'historical' as const,
102
+ }),
103
+ };
104
+ }
@@ -4,7 +4,7 @@ A FlexHarness session as the inputs of the `@design.estate/dees-catalog` harness
4
4
  pnpm add @modelprofile.com/flexharness @design.estate/dees-catalog
5
5
  ```
6
6
 
7
- The optional peer range is `^17.0.0 || ^19.5.0`; the package builds and is tested against 19.5.
7
+ The optional peer range is `^17.0.0 || ^19.5.0 || ^20.0.0 || ^21.0.0`; the package builds and is tested against 21.
8
8
 
9
9
  The subpath imports only types from `@design.estate/dees-catalog` and no runtime code at all, so it runs in the browser beside the components as well as on the server beside the harness.
10
10