@modelprofile.com/flexharness 9.10.0 → 10.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.
Files changed (54) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.d.ts +2 -0
  3. package/dist_ts/classes.flexharness.js +101 -7
  4. package/dist_ts/classes.stores.js +11 -119
  5. package/dist_ts/errors.d.ts +7 -2
  6. package/dist_ts/errors.js +1 -1
  7. package/dist_ts/interfaces.d.ts +1 -0
  8. package/dist_ts/utils.harnessoptions.js +2 -1
  9. package/dist_ts/utils.storerules.d.ts +28 -0
  10. package/dist_ts/utils.storerules.js +107 -0
  11. package/dist_ts_chat_harness/classes.transcript.d.ts +88 -0
  12. package/dist_ts_chat_harness/classes.transcript.js +268 -0
  13. package/dist_ts_chat_harness/functions.intents.d.ts +62 -0
  14. package/dist_ts_chat_harness/functions.intents.js +96 -0
  15. package/dist_ts_chat_harness/functions.mapping.d.ts +33 -0
  16. package/dist_ts_chat_harness/functions.mapping.js +220 -0
  17. package/dist_ts_chat_harness/index.d.ts +3 -0
  18. package/dist_ts_chat_harness/index.js +4 -0
  19. package/dist_ts_chat_harness/plugins.d.ts +1 -0
  20. package/dist_ts_chat_harness/plugins.js +2 -0
  21. package/dist_ts_kvm/classes.browserkvm.js +3 -3
  22. package/dist_ts_kvm/plugins.d.ts +1 -1
  23. package/dist_ts_kvm/plugins.js +2 -2
  24. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +31 -0
  25. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +312 -0
  26. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +110 -0
  27. package/dist_ts_stores_nosqldb/classes.recordstore.js +437 -0
  28. package/dist_ts_stores_nosqldb/index.d.ts +2 -0
  29. package/dist_ts_stores_nosqldb/index.js +3 -0
  30. package/dist_ts_stores_nosqldb/plugins.d.ts +7 -0
  31. package/dist_ts_stores_nosqldb/plugins.js +10 -0
  32. package/package.json +27 -7
  33. package/readme.md +14 -6
  34. package/ts/00_commitinfo_data.ts +1 -1
  35. package/ts/classes.flexharness.ts +102 -7
  36. package/ts/classes.stores.ts +28 -172
  37. package/ts/errors.ts +8 -2
  38. package/ts/interfaces.ts +1 -0
  39. package/ts/readme.md +14 -6
  40. package/ts/utils.harnessoptions.ts +1 -0
  41. package/ts/utils.storerules.ts +177 -0
  42. package/ts_chat_harness/classes.transcript.ts +365 -0
  43. package/ts_chat_harness/functions.intents.ts +169 -0
  44. package/ts_chat_harness/functions.mapping.ts +267 -0
  45. package/ts_chat_harness/index.ts +33 -0
  46. package/ts_chat_harness/plugins.ts +18 -0
  47. package/ts_chat_harness/readme.md +114 -0
  48. package/ts_kvm/classes.browserkvm.ts +2 -2
  49. package/ts_kvm/plugins.ts +1 -1
  50. package/ts_stores_nosqldb/classes.nosqldbstores.ts +465 -0
  51. package/ts_stores_nosqldb/classes.recordstore.ts +560 -0
  52. package/ts_stores_nosqldb/index.ts +9 -0
  53. package/ts_stores_nosqldb/plugins.ts +38 -0
  54. package/ts_stores_nosqldb/readme.md +53 -0
package/ts/readme.md CHANGED
@@ -61,10 +61,12 @@ or provider SDKs.
61
61
  | `/chat` | Portable streaming `ChatSession`, history and usage | None |
62
62
  | `/chat/cli` | Ink/React terminal chat | `ink ink-text-input react`; TypeScript: `@types/react` |
63
63
  | `/chat/web` | Lit `flexchat-window`, `flexchat-message`, `flexchat-input` | `lit` |
64
+ | `/chat/harness` | `FlexHarnessTranscript` and intents for the `@design.estate/dees-catalog` harness components, browser-safe | `@design.estate/dees-catalog` (types only) |
64
65
  | `/kvm` | Browser control, terminal framing and OCR observation | `puppeteer` |
65
66
  | `/documents` | PDF and image documents, `extractTextFromPdf` | `@push.rocks/smartpdf`; for image documents `sharp` |
66
67
  | `/mcp` | MCP clients and AI SDK tool conversion | `@modelcontextprotocol/sdk` |
67
68
  | `/migration` | Existing versioned harness migrations | None |
69
+ | `/stores/nosqldb` | `NoSqlFlexHarnessStores`: cross-process CAS stores on `@lossless.org/client/nosqldb` | `@lossless.org/client` |
68
70
 
69
71
  The integrations in the last column use optional peer dependencies. Install them
70
72
  only for the entrypoints your project imports, for example:
@@ -403,26 +405,29 @@ Normal Flex session cleanup always waits in-flight local project operations, the
403
405
 
404
406
  `InMemoryFlexProjectManagementStore` is the standalone in-memory implementation. `InMemoryFlexHarnessStores` and `JsonFileFlexHarnessStores` include `projectManagement` as a required bundle member. `assertFlexProjectManagementSnapshot()` validates live records, `assertFlexProjectManagementTombstone()` validates tombstones, and `assertFlexProjectManagementRecord()` validates the union. `createEmptyFlexProjectManagementSnapshot(sessionGenerationId, sessionGenerationSequence)` returns a revision-0 live state for the supplied current generation.
405
407
 
406
- ## Foreground Subagents
408
+ ## Subagents
407
409
 
408
- `subagents` enables a harness-owned built-in tool named `delegate`. It is available only when at least one definition exists and the current session depth is below `maxSubagentDepth`. An application or resource `toolProvider` must not return its own `delegate` tool while the built-in is enabled for that run. The built-in is foreground-only: the parent tool call does not complete until the child prompt reaches a terminal outcome.
410
+ `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 arriving while inference is still active enters the parent's next model context as untrusted task data.
409
411
 
410
412
  The model calls it with this exact input shape:
411
413
 
414
+ 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.
415
+
412
416
  ```typescript
413
417
  interface IDelegateInput {
414
418
  description: string;
415
419
  prompt: string;
416
420
  subagentType: string;
417
421
  taskId?: string;
422
+ foreground?: boolean;
418
423
  }
419
424
  ```
420
425
 
421
426
  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`.
422
427
 
423
- 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 foreground `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`.
428
+ 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`.
424
429
 
425
- The parent tool part receives `childSessionId` in a cumulative `part.updated` event as soon as the child is acquired. If child model resolution completes, a later cumulative update adds `model`; failures before model resolution leave it absent. The terminal tool part retains every value that became available. A successful delegate call always has model identity and returns bounded JSON:
430
+ 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:
426
431
 
427
432
  ```typescript
428
433
  {
@@ -687,7 +692,7 @@ At most 128 custom commands may be registered. Every registration must be a plai
687
692
 
688
693
  `/undo` and `/redo`, plus `undoSession()` and `redoSession()`, move a durable history cursor. The direct methods return `{ revertedRunId }` and `{ restoredRunId }`; the slash forms return `{ type: 'operation', name: 'undo' | 'redo' }`. Each committed cursor move emits one `session.history.changed` event with `direction`, `runId`, and the selected session identity. A committed branch emits the same event with `direction: 'branch'` and no `runId`, so controllers should refresh the complete selected session. Capture finalization, cleanup, and metadata-only changes do not emit this event.
689
694
 
690
- A completed root-session turn defines an operation-group boundary. Failed and cancelled turns after it belong to that group; leading failed or cancelled turns belong to the first completed group. No completed boundary means there is nothing to undo. Undo applies selected segments in reverse order and redo applies them in forward order. Without `turnReversionProvider`, only transcript and future model context move. Hidden messages disappear from `getMessages()`, message pages, exact message lookup, and future model context. Starting a new prompt, template, handler, or compaction from an undone position commits a branch: hidden messages and segments are removed durably and cannot be redone. Successful event archival also commits hidden redo history; a missing compaction or failed archive leaves it intact.
695
+ A completed root-session turn defines an operation-group boundary. Failed and cancelled turns after it belong to that group; leading failed or cancelled turns belong to the first completed group. No completed boundary means there is nothing to undo. Undo applies selected segments in reverse order and redo applies them in forward order. Without `turnReversionProvider`, only transcript and future model context move. Hidden messages disappear from `getMessages()`, message pages, exact message lookup, and future model context. Starting a new prompt, template, handler, or compaction from an undone position commits a branch: hidden messages and segments are removed durably and cannot be redone. Successful event archival also commits hidden redo history; a missing compaction or failed archive leaves it intact. Automatic budget compaction archives covered events after generation finalization and advances the same undo horizon; set `contextCompactionBytes: false` to disable that automatic policy.
691
696
 
692
697
  Two horizons bound undo. Retention pruning removes the oldest complete visible units when `reversionLimits` is exceeded. Explicit event archival marks covered turns context-unavailable and prunes complete prefixes that can no longer be rebuilt; FlexHarness never crosses that archive horizon. Manual compaction without archival retains the original events and remains undoable. Schema-1 projection history and sessions migrated from `2.x` have no reversion segments, so historical turns are not retroactively undoable; newly written turns are tracked normally.
693
698
 
@@ -790,7 +795,7 @@ The protocol is durable and inspectable:
790
795
 
791
796
  1. FlexHarness persists a `preparing` capture intent before calling `prepare()`, before model or tool execution. The provider must establish exclusive capture ownership for that `storageKey` and retain it until `release()` succeeds.
792
797
  2. `inspectCapture()` returns `missing`, `prepared`, `finalized`, or `unknown`. A finalized V1 result includes `reference`; a finalized V2 result includes the complete tagged `outcome`. `missing` is safe only while the durable state is still `preparing`; a missing prepared or finalizing capture, an `unknown` result, or an inspection failure fences the namespace.
793
- 3. `finalize()` closes the capture and returns its JSON-safe V1 reference or complete V2 outcome. FlexHarness may call it during normal finalization or recovery after `inspectCapture()` reports `prepared`. Failed and cancelled root turns are captured too. A root capture spans foreground subagent effects, although child transcript records remain separate.
798
+ 3. `finalize()` closes the capture and returns its JSON-safe V1 reference or complete V2 outcome. FlexHarness may call it during normal finalization or recovery after `inspectCapture()` reports `prepared`. Failed and cancelled root turns are captured too. A root capture spans its admitted subagent effects, including asynchronous children joined before finalization, although child transcript records remain separate. A failed parent cancels its outstanding children before joining them.
794
799
  4. Before undo or redo, FlexHarness persists an apply write-ahead record. `inspectApply()` must report the exact durable outcome for the supplied `operationId`: `not-applied` means no effect occurred, `applied` means the complete effect occurred, and `unknown` means the provider cannot prove either result. FlexHarness calls `apply()` only for `not-applied`; after an apply error it inspects again, and an `unknown` result or inspection failure fences the namespace. Progress is persisted after each segment, and the transcript cursor moves only after the complete unit succeeds. Known zero-progress failures leave the cursor unchanged and can be retried; partial progress retains the write-ahead record and resumes after restart. Caller cancellation is honored until the first workspace segment makes progress; recovery then continues with fresh bounded maintenance signals until the unit and cursor commit. Providers must treat `operationId` idempotently.
795
800
  5. `release()` relinquishes the capture after no-change or nonrevertible V2 finalization, branch commitment, retention or archive pruning, session deletion, or other durable removal. It must be idempotent: an unacknowledged release remains persisted and is retried before FlexHarness discards the reference.
796
801
 
@@ -960,6 +965,7 @@ A `contextCompactor` states which model to compact with from the model the compa
960
965
  | `reason` | Compaction | Model members |
961
966
  | --- | --- | --- |
962
967
  | `context-overflow` | A run's context overflowed, including a delegated run's | `resolvedModel`: the `IFlexResolvedModel` the model resolver returned for that run, frozen; a delegated run's overflow carries the delegated run's own model |
968
+ | `context-budget` | Settled history exceeds `contextCompactionBytes` before a generation's first inference (256 KiB by default with a compactor) | `resolvedModel`: the exact model resolved for that run, including its delegated model |
963
969
  | `manual` | `/compact` or `compactSession()` | `modelHint`: the `modelHint` of the `/compact` execution options or of `compactSession(scopeId, sessionId, { modelHint })`, when one was passed |
964
970
  | `retention` | Event retention | none |
965
971
 
@@ -1064,6 +1070,8 @@ After every harness using a `JsonFileFlexHarnessStores` instance has been dispos
1064
1070
 
1065
1071
  The JSON stores are explicitly not cross-process safe. When several processes can access the same storage namespace, every core `IFlexHarnessStores` domain and `stores.projectManagement` must use database-backed or equivalent cross-process CAS. Process-local CAS for the core stores or for PM alone is insufficient: session generation creation, cleanup tombstones, PM replacement, and stale-writer rejection must all retain their respective atomic preconditions across processes.
1066
1072
 
1073
+ `NoSqlFlexHarnessStores` from `/stores/nosqldb` is that database-backed bundle for every domain. Pass it an initialized `SmartdataDb` and `await stores.prepare()` at startup, before the first harness uses it; the subpath readme describes chunked snapshots and failure reporting.
1074
+
1067
1075
  Direct store operations and non-run session mutations surface conflicts as `FlexHarnessStoreConflictError` or the corresponding AgentSession store conflict. Malformed, wrong-schema, or non-JSON snapshots are surfaced as `FlexHarnessStoreFormatError`. A write or deletion that changed its target but cannot confirm parent-directory durability surfaces `FlexHarnessStoreCommitUncertainError` with the affected path, operation, and cause. Run persistence failures cross the external error boundary and therefore become `FlexHarnessExternalError`. FlexHarness does not merge conflicts.
1068
1076
 
1069
1077
  Each domain serializes its own mutations. A successful run first persists a hidden completed projection, then finalizes the canonical Agent generation as `accepted`, then promotes the hidden projection publicly. Recovery uses the canonical generation outcome to promote an accepted stage or publish a failed/cancelled projection. Failed and cancelled generations remain auditable but never enter future model context.
@@ -380,6 +380,7 @@ export function normalizeAgentSessionPolicy<TScope>(
380
380
  return {
381
381
  ...(policy.contextBuilder === undefined ? {} : { contextBuilder: policy.contextBuilder }),
382
382
  ...(policy.contextCompactor === undefined ? {} : { contextCompactor: policy.contextCompactor }),
383
+ ...(policy.contextCompactionBytes === undefined ? {} : { contextCompactionBytes: policy.contextCompactionBytes }),
383
384
  ...(policy.eventRetention === undefined ? {} : { eventRetention: policy.eventRetention }),
384
385
  ...(policy.changeListenerTimeoutMs === undefined
385
386
  ? {}
@@ -0,0 +1,177 @@
1
+ import type * as plugins from './plugins.js';
2
+ import {
3
+ FlexHarnessStoreConflictError,
4
+ FlexHarnessStoreFormatError,
5
+ FlexHarnessValidationError,
6
+ } from './errors.js';
7
+ import type {
8
+ IFlexPermissionSnapshot,
9
+ IFlexProjectManagementSnapshot,
10
+ IFlexProjectManagementTombstone,
11
+ IFlexProjectManagementWriteContext,
12
+ IFlexScopeSnapshot,
13
+ TFlexProjectionSnapshot,
14
+ TFlexProjectManagementRecord,
15
+ } from './interfaces.js';
16
+ import { assertJsonSerializable, cloneSerializable } from './utils.json.js';
17
+ import { assertExactKeys, assertRecord } from './utils.storecodecs.js';
18
+
19
+ /**
20
+ * Store-independent admission rules every `IFlexHarnessStores` implementation applies: argument
21
+ * validation, the next-revision rule, exact session keys, and the project-management generation
22
+ * fence. Backends own only how they read, compare and write.
23
+ */
24
+
25
+ type TSnapshot =
26
+ | IFlexScopeSnapshot
27
+ | TFlexProjectionSnapshot
28
+ | IFlexPermissionSnapshot
29
+ | TFlexProjectManagementRecord;
30
+ type TSnapshotValidator<TSnapshotValue extends TSnapshot> = (
31
+ value: unknown,
32
+ ) => asserts value is TSnapshotValue;
33
+
34
+ export function validateIdentifier(value: string, name: string): void {
35
+ if (typeof value !== 'string' || !value.trim()) {
36
+ throw new FlexHarnessValidationError(`${name} must be a non-empty string.`);
37
+ }
38
+ }
39
+
40
+ export function validateExpectedRevision(expectedRevision: number): void {
41
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
42
+ throw new FlexHarnessValidationError('expectedRevision must be a non-negative integer.');
43
+ }
44
+ }
45
+
46
+ export function validateProjectManagementWriteContext(
47
+ writeContext: IFlexProjectManagementWriteContext,
48
+ ): void {
49
+ assertJsonSerializable(writeContext, '$writeContext');
50
+ assertRecord(writeContext, 'writeContext');
51
+ assertExactKeys(writeContext, ['actor', 'runId', 'agent', 'toolCallId'], 'writeContext');
52
+ if (writeContext.actor !== 'agent' && writeContext.actor !== 'application') {
53
+ throw new FlexHarnessValidationError('writeContext.actor must be agent or application.');
54
+ }
55
+ for (const key of ['runId', 'agent', 'toolCallId'] as const) {
56
+ if (writeContext[key] !== undefined) validateIdentifier(writeContext[key], `writeContext.${key}`);
57
+ }
58
+ }
59
+
60
+ export function validateSnapshotSave<TSnapshotValue extends TSnapshot>(
61
+ snapshot: TSnapshotValue,
62
+ expectedRevision: number,
63
+ validator: TSnapshotValidator<TSnapshotValue>,
64
+ ): TSnapshotValue {
65
+ validator(snapshot);
66
+ validateExpectedRevision(expectedRevision);
67
+ if (snapshot.revision !== expectedRevision + 1) {
68
+ throw new FlexHarnessStoreFormatError(
69
+ `Snapshot revision ${snapshot.revision} must equal expected revision ${expectedRevision} plus one.`,
70
+ );
71
+ }
72
+ return cloneSerializable(snapshot);
73
+ }
74
+
75
+ export function providerKey(storageKey: string, sessionId: string): string {
76
+ validateIdentifier(storageKey, 'storageKey');
77
+ validateIdentifier(sessionId, 'sessionId');
78
+ return JSON.stringify([storageKey, sessionId]);
79
+ }
80
+
81
+ export function assertProjectionSession(snapshot: TFlexProjectionSnapshot, sessionId: string): void {
82
+ for (const message of snapshot.messages) {
83
+ if (message.sessionId !== sessionId) {
84
+ throw new FlexHarnessStoreFormatError(
85
+ `Projection message "${message.messageId}" does not belong to session "${sessionId}".`,
86
+ );
87
+ }
88
+ }
89
+ for (const terminal of snapshot.stagedTerminals) {
90
+ if (
91
+ terminal.userMessage.sessionId !== sessionId
92
+ || terminal.assistantMessage.sessionId !== sessionId
93
+ ) {
94
+ throw new FlexHarnessStoreFormatError(
95
+ `Staged terminal "${terminal.runId}" does not belong to session "${sessionId}".`,
96
+ );
97
+ }
98
+ }
99
+ }
100
+
101
+ function sameProjectManagementGeneration(
102
+ left: TFlexProjectManagementRecord,
103
+ right: TFlexProjectManagementRecord,
104
+ ): boolean {
105
+ return left.sessionGenerationId === right.sessionGenerationId
106
+ && left.sessionGenerationSequence === right.sessionGenerationSequence;
107
+ }
108
+
109
+ function canInitializeProjectManagementGeneration(
110
+ current: TFlexProjectManagementRecord,
111
+ next: TFlexProjectManagementRecord,
112
+ expectedRevision: number,
113
+ ): boolean {
114
+ return 'deletedAt' in current
115
+ && expectedRevision === 0
116
+ && next.sessionGenerationId !== current.sessionGenerationId
117
+ && next.sessionGenerationSequence > current.sessionGenerationSequence;
118
+ }
119
+
120
+ /**
121
+ * Admits a live project-management save over the stored record: a CAS save within the stored
122
+ * generation, or a newer generation that replaces a tombstone at expected revision 0.
123
+ */
124
+ export function assertProjectManagementSaveAdmitted(
125
+ key: string,
126
+ current: TFlexProjectManagementRecord | undefined,
127
+ snapshot: IFlexProjectManagementSnapshot,
128
+ expectedRevision: number,
129
+ ): void {
130
+ const actualRevision = current?.revision ?? 0;
131
+ if (current && !sameProjectManagementGeneration(current, snapshot)) {
132
+ if (!canInitializeProjectManagementGeneration(current, snapshot, expectedRevision)) {
133
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
134
+ }
135
+ } else if ((current && 'deletedAt' in current) || actualRevision !== expectedRevision) {
136
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Admits a project-management tombstone over the stored record. Returns false when the same
142
+ * generation is already tombstoned, which makes a repeated cleanup idempotent.
143
+ */
144
+ export function isProjectManagementTombstoneAdmitted(
145
+ key: string,
146
+ current: TFlexProjectManagementRecord | undefined,
147
+ tombstone: IFlexProjectManagementTombstone,
148
+ expectedRevision: number,
149
+ ): boolean {
150
+ const actualRevision = current?.revision ?? 0;
151
+ if (current && sameProjectManagementGeneration(current, tombstone)) {
152
+ if ('deletedAt' in current) return false;
153
+ if (actualRevision !== expectedRevision) {
154
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
155
+ }
156
+ } else if (current) {
157
+ if (!canInitializeProjectManagementGeneration(current, tombstone, expectedRevision)) {
158
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
159
+ }
160
+ } else if (actualRevision !== expectedRevision) {
161
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
162
+ }
163
+ return true;
164
+ }
165
+
166
+ /** An archive is written once; repeating it with the same events is a no-op. */
167
+ export function assertSameArchiveEvents(
168
+ existing: plugins.IAgentEventArchiveV2,
169
+ next: plugins.IAgentEventArchiveV2,
170
+ ): void {
171
+ if (
172
+ JSON.stringify(existing.events.map((event) => event.id))
173
+ !== JSON.stringify(next.events.map((event) => event.id))
174
+ ) {
175
+ throw new Error(`Archive "${next.archiveId}" already exists with different events.`);
176
+ }
177
+ }
@@ -0,0 +1,365 @@
1
+ import type * as plugins from './plugins.js';
2
+ import type {
3
+ IFlexMessage,
4
+ IFlexPermissionRequest,
5
+ IFlexProjectStateResult,
6
+ IFlexSession,
7
+ TFlexHarnessEvent,
8
+ TFlexMessagePart,
9
+ } from '../ts/interfaces.js';
10
+ import {
11
+ flexMessageToHarnessRows,
12
+ flexPartRowId,
13
+ flexPartTimingKey,
14
+ flexPermissionToHarness,
15
+ flexProjectToHarnessScratchpad,
16
+ flexProjectToHarnessTodos,
17
+ flexSessionToHarnessStatus,
18
+ flexToolPartToHarness,
19
+ flexUsageToHarness,
20
+ type IFlexPartTiming,
21
+ } from './functions.mapping.js';
22
+
23
+ export interface IFlexHarnessTranscriptOptions {
24
+ scopeId: string;
25
+ sessionId: string;
26
+ }
27
+
28
+ /** What `load()` reads: the session's authoritative state, as the harness APIs return it. */
29
+ export interface IFlexHarnessTranscriptSnapshot {
30
+ /** `getMessages()` of the session. */
31
+ messages: readonly IFlexMessage[];
32
+ /** `listPendingPermissions()` of the session. */
33
+ permissions?: readonly IFlexPermissionRequest[];
34
+ /** `getSession()`; states whether a run is active or failed. */
35
+ session?: IFlexSession;
36
+ /** `getProjectState()`, when project management is enabled. */
37
+ project?: IFlexProjectStateResult;
38
+ }
39
+
40
+ /** The inputs of `dees-harness-chat`, as plain data the transcript does not share. */
41
+ export interface IFlexHarnessTranscriptView {
42
+ messages: plugins.IHarnessMessage[];
43
+ permissions: plugins.IHarnessPermissionRequest[];
44
+ /** FlexHarness asks no questions; always empty. */
45
+ questions: plugins.IHarnessQuestionRequest[];
46
+ status: plugins.IHarnessStatus;
47
+ usage: plugins.IHarnessUsage;
48
+ todos: plugins.IHarnessTodoItem[];
49
+ scratchpad?: plugins.IHarnessScratchpad;
50
+ }
51
+
52
+ /** Where an event stands in its harness instance's event stream, as the host received it. */
53
+ export interface IFlexHarnessEventDelivery {
54
+ /**
55
+ * The `sequence` of the event of the same harness instance the host received just before this
56
+ * one, of any session; `undefined` for the first event after `load()`. The host forwards every
57
+ * event of the instance in order, and calls `load()` after a gap or a reconnect.
58
+ */
59
+ precedingSequence: number | undefined;
60
+ }
61
+
62
+ /**
63
+ * What one event changed. Apply `deltas` with `applyDelta()` in order; when `messages` is true,
64
+ * assign `transcript.view().messages` instead, which already holds the deltas. `reload` means
65
+ * the transcript can no longer follow the session incrementally: read the session again and
66
+ * pass it to `load()`.
67
+ */
68
+ export interface IFlexHarnessTranscriptChange {
69
+ messages: boolean;
70
+ deltas: plugins.THarnessStreamDelta[];
71
+ permissions: boolean;
72
+ status: boolean;
73
+ usage: boolean;
74
+ reload: boolean;
75
+ }
76
+
77
+ const utf8 = new TextEncoder();
78
+
79
+ /** UTF-8 bytes `delta` adds to `text`, counted the way FlexHarness counts them. */
80
+ function appendedUtf8Bytes(text: string, delta: string): number {
81
+ const trailing = text.charCodeAt(text.length - 1);
82
+ const leading = delta.charCodeAt(0);
83
+ const joinsSurrogatePair = trailing >= 0xd800 && trailing <= 0xdbff
84
+ && leading >= 0xdc00 && leading <= 0xdfff;
85
+ return utf8.encode(delta).length - (joinsSurrogatePair ? 2 : 0);
86
+ }
87
+
88
+ function sumUsage(
89
+ total: plugins.IHarnessUsage,
90
+ usage: plugins.IHarnessUsage,
91
+ ): plugins.IHarnessUsage {
92
+ const sum = (key: keyof plugins.IHarnessUsage) => (total[key] ?? 0) + (usage[key] ?? 0);
93
+ return {
94
+ inputTokens: sum('inputTokens'),
95
+ outputTokens: sum('outputTokens'),
96
+ totalTokens: sum('totalTokens'),
97
+ cacheReadTokens: sum('cacheReadTokens'),
98
+ cacheWriteTokens: sum('cacheWriteTokens'),
99
+ };
100
+ }
101
+
102
+ const noChange = (): IFlexHarnessTranscriptChange => ({
103
+ messages: false,
104
+ deltas: [],
105
+ permissions: false,
106
+ status: false,
107
+ usage: false,
108
+ reload: false,
109
+ });
110
+
111
+ /**
112
+ * Follows one FlexHarness session and keeps the `dees-harness-chat` inputs for it. It is a pure
113
+ * reducer over the session's public messages and events: it imports no runtime code, so it runs
114
+ * in the browser beside the components as well as on the server beside the harness.
115
+ */
116
+ export class FlexHarnessTranscript {
117
+ public readonly scopeId: string;
118
+ public readonly sessionId: string;
119
+ private flexMessages: IFlexMessage[] = [];
120
+ private readonly timings = new Map<string, IFlexPartTiming>();
121
+ private readonly partBytes = new Map<string, number>();
122
+ private readonly permissionRows = new Map<string, plugins.IHarnessPermissionRequest>();
123
+ private status: plugins.IHarnessStatus = { type: 'idle' };
124
+ private usage: plugins.IHarnessUsage = {};
125
+ private todos: plugins.IHarnessTodoItem[] = [];
126
+ private scratchpad?: plugins.IHarnessScratchpad;
127
+ private runningQueueId?: string;
128
+
129
+ constructor(options: IFlexHarnessTranscriptOptions) {
130
+ this.scopeId = options.scopeId;
131
+ this.sessionId = options.sessionId;
132
+ }
133
+
134
+ /** The queue entry whose run is running; a steer joins it. */
135
+ public get activeQueueId(): string | undefined {
136
+ return this.runningQueueId;
137
+ }
138
+
139
+ /** Replaces everything with the session's authoritative state. */
140
+ public load(snapshot: IFlexHarnessTranscriptSnapshot): void {
141
+ this.flexMessages = [];
142
+ this.todos = [];
143
+ this.scratchpad = undefined;
144
+ this.runningQueueId = undefined;
145
+ this.timings.clear();
146
+ this.partBytes.clear();
147
+ for (const message of snapshot.messages) this.upsertMessage(message);
148
+ this.permissionRows.clear();
149
+ for (const request of snapshot.permissions ?? []) {
150
+ this.permissionRows.set(request.permissionId, flexPermissionToHarness(request));
151
+ }
152
+ this.status = snapshot.session ? flexSessionToHarnessStatus(snapshot.session) : { type: 'idle' };
153
+ this.usage = this.flexMessages.reduce<plugins.IHarnessUsage>(
154
+ (total, message) => message.usage ? sumUsage(total, flexUsageToHarness(message.usage)) : total,
155
+ {},
156
+ );
157
+ if (snapshot.project) this.applyProjectState(snapshot.project);
158
+ }
159
+
160
+ /** Takes in the session's task list and scratchpad after `getProjectState()`. */
161
+ public applyProjectState(project: IFlexProjectStateResult): void {
162
+ this.todos = flexProjectToHarnessTodos(project);
163
+ this.scratchpad = flexProjectToHarnessScratchpad(this.sessionId, project);
164
+ }
165
+
166
+ /** A copy of the component inputs; the transcript keeps its own objects. */
167
+ public view(): IFlexHarnessTranscriptView {
168
+ return structuredClone({
169
+ messages: this.flexMessages.flatMap((message, index) =>
170
+ flexMessageToHarnessRows(message, index, this.timings)),
171
+ permissions: [...this.permissionRows.values()],
172
+ questions: [],
173
+ status: this.status,
174
+ usage: this.usage,
175
+ todos: this.todos,
176
+ ...(this.scratchpad === undefined ? {} : { scratchpad: this.scratchpad }),
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Takes one event of the harness instance. An event that does not directly follow
182
+ * `delivery.precedingSequence` means events were lost, possibly of this session, so the change
183
+ * asks for a reload.
184
+ */
185
+ public applyEvent(
186
+ event: TFlexHarnessEvent,
187
+ delivery: IFlexHarnessEventDelivery,
188
+ ): IFlexHarnessTranscriptChange {
189
+ const change = noChange();
190
+ if (
191
+ delivery.precedingSequence !== undefined
192
+ && event.sequence !== delivery.precedingSequence + 1
193
+ ) {
194
+ change.reload = true;
195
+ return change;
196
+ }
197
+ if (event.scopeId !== this.scopeId || event.sessionId !== this.sessionId) return change;
198
+ const timestamp = Date.parse(event.timestamp);
199
+ switch (event.type) {
200
+ case 'session.created':
201
+ case 'session.updated':
202
+ this.setStatus(change, flexSessionToHarnessStatus(event.session));
203
+ break;
204
+ case 'session.deleted':
205
+ this.load({ messages: [] });
206
+ change.messages = change.permissions = change.status = change.usage = true;
207
+ break;
208
+ case 'run.started':
209
+ this.setStatus(change, { type: 'busy' });
210
+ break;
211
+ case 'message.created':
212
+ case 'message.updated':
213
+ this.upsertMessage(event.message);
214
+ change.messages = true;
215
+ break;
216
+ case 'part.started':
217
+ this.timings.set(flexPartTimingKey(event.messageId, event.partId), { startedAt: timestamp });
218
+ this.applyPartSnapshot(change, event.messageId, event.partIndex, event.part);
219
+ break;
220
+ case 'part.updated':
221
+ this.applyPartSnapshot(change, event.messageId, event.partIndex, event.part);
222
+ break;
223
+ case 'part.completed': {
224
+ const key = flexPartTimingKey(event.messageId, event.partId);
225
+ this.timings.set(key, { ...this.timings.get(key), endedAt: timestamp });
226
+ this.applyPartSnapshot(change, event.messageId, event.partIndex, event.part);
227
+ break;
228
+ }
229
+ case 'part.delta':
230
+ this.applyPartDelta(change, event);
231
+ break;
232
+ case 'permission.requested':
233
+ this.permissionRows.set(event.request.permissionId, flexPermissionToHarness(event.request));
234
+ change.permissions = true;
235
+ break;
236
+ case 'permission.resolved': {
237
+ const row = this.permissionRows.get(event.request.permissionId);
238
+ if (!row) break;
239
+ row.response = event.decision;
240
+ row.respondedAt = timestamp;
241
+ change.permissions = true;
242
+ break;
243
+ }
244
+ case 'run.finished':
245
+ this.upsertMessage(event.message);
246
+ change.messages = true;
247
+ if (event.message.usage) {
248
+ this.usage = sumUsage(this.usage, flexUsageToHarness(event.message.usage));
249
+ change.usage = true;
250
+ }
251
+ this.setStatus(change, event.status === 'failed'
252
+ ? { type: 'error', ...(event.error ? { message: event.error.message } : {}) }
253
+ : { type: 'idle' });
254
+ break;
255
+ case 'prompt.started':
256
+ case 'prompt.running':
257
+ this.runningQueueId = event.queueId;
258
+ break;
259
+ case 'prompt.finished':
260
+ if (this.runningQueueId === event.queueId) this.runningQueueId = undefined;
261
+ break;
262
+ case 'session.history.changed':
263
+ change.reload = true;
264
+ break;
265
+ default:
266
+ break;
267
+ }
268
+ return change;
269
+ }
270
+
271
+ private setStatus(change: IFlexHarnessTranscriptChange, status: plugins.IHarnessStatus): void {
272
+ if (status.type === this.status.type && status.message === this.status.message) return;
273
+ this.status = status;
274
+ change.status = true;
275
+ }
276
+
277
+ private upsertMessage(message: IFlexMessage): void {
278
+ const index = this.flexMessages.findIndex((entry) => entry.messageId === message.messageId);
279
+ const copy = structuredClone(message);
280
+ if (index === -1) this.flexMessages.push(copy);
281
+ else this.flexMessages[index] = copy;
282
+ for (const part of copy.parts) {
283
+ if (part.type === 'text' || part.type === 'reasoning') {
284
+ this.partBytes.set(flexPartTimingKey(copy.messageId, part.partId), utf8.encode(part.text).length);
285
+ }
286
+ }
287
+ }
288
+
289
+ private applyPartSnapshot(
290
+ change: IFlexHarnessTranscriptChange,
291
+ messageId: string,
292
+ partIndex: number,
293
+ part: TFlexMessagePart,
294
+ ): void {
295
+ const message = this.flexMessages.find((entry) => entry.messageId === messageId);
296
+ if (!message || partIndex > message.parts.length) {
297
+ change.reload = true;
298
+ return;
299
+ }
300
+ const previous = message.parts[partIndex];
301
+ message.parts[partIndex] = structuredClone(part);
302
+ const key = flexPartTimingKey(messageId, part.partId);
303
+ if (part.type === 'text' || part.type === 'reasoning') {
304
+ this.partBytes.set(key, utf8.encode(part.text).length);
305
+ }
306
+ const rowId = flexPartRowId(messageId, part.partId);
307
+ if (previous?.partId !== part.partId || previous.type !== part.type) {
308
+ change.messages = true;
309
+ return;
310
+ }
311
+ if (part.type === 'tool') {
312
+ change.deltas.push({
313
+ type: 'tool-update',
314
+ messageId: rowId,
315
+ tool: flexToolPartToHarness(part, this.timings.get(key)),
316
+ });
317
+ } else if (part.type === 'text' && previous.type === 'text') {
318
+ if (previous.text !== part.text) change.messages = true;
319
+ else if (message.status !== 'streaming' || partIndex !== message.parts.length - 1) {
320
+ change.deltas.push({ type: 'message-end', messageId: rowId });
321
+ }
322
+ } else if (part.type === 'reasoning' && previous.type === 'reasoning') {
323
+ if (previous.text !== part.text) change.messages = true;
324
+ else if (part.status !== 'running') {
325
+ change.deltas.push({
326
+ type: 'reasoning-end',
327
+ messageId: rowId,
328
+ partId: part.partId,
329
+ endedAt: this.timings.get(key)?.endedAt,
330
+ });
331
+ }
332
+ } else {
333
+ change.messages = true;
334
+ }
335
+ }
336
+
337
+ private applyPartDelta(
338
+ change: IFlexHarnessTranscriptChange,
339
+ event: Extract<TFlexHarnessEvent, { type: 'part.delta' }>,
340
+ ): void {
341
+ const message = this.flexMessages.find((entry) => entry.messageId === event.messageId);
342
+ const part = message?.parts[event.partIndex];
343
+ const key = flexPartTimingKey(event.messageId, event.partId);
344
+ if (
345
+ !part
346
+ || part.partId !== event.partId
347
+ || part.type !== event.partType
348
+ || this.partBytes.get(key) !== event.baseTextUtf8Bytes
349
+ ) {
350
+ change.reload = true;
351
+ return;
352
+ }
353
+ const bytes = event.baseTextUtf8Bytes + appendedUtf8Bytes(part.text, event.delta);
354
+ if (bytes !== event.textUtf8Bytes) {
355
+ change.reload = true;
356
+ return;
357
+ }
358
+ part.text += event.delta;
359
+ this.partBytes.set(key, bytes);
360
+ const rowId = flexPartRowId(event.messageId, event.partId);
361
+ change.deltas.push(event.partType === 'text'
362
+ ? { type: 'text', messageId: rowId, delta: event.delta }
363
+ : { type: 'reasoning', messageId: rowId, partId: event.partId, delta: event.delta });
364
+ }
365
+ }