@inflexa-ai/harness 0.41.1 → 0.42.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 (88) hide show
  1. package/dist/agents/conversation-agent.js +3 -0
  2. package/dist/agents/report-session-agent.d.ts +2 -2
  3. package/dist/agents/report-session-agent.js +4 -0
  4. package/dist/app/chat-turn.d.ts +0 -4
  5. package/dist/app/chat-turn.js +39 -25
  6. package/dist/app/message-assembly.d.ts +2 -2
  7. package/dist/app/message-assembly.js +1 -1
  8. package/dist/contracts/chat-events.d.ts +12 -5
  9. package/dist/contracts/chat-events.js +3 -3
  10. package/dist/contracts/chat-frame.d.ts +40 -0
  11. package/dist/contracts/chat-frame.js +166 -0
  12. package/dist/contracts/chat-parts.d.ts +15 -3
  13. package/dist/contracts/chat-parts.js +2 -2
  14. package/dist/contracts/index.d.ts +6 -4
  15. package/dist/contracts/index.js +1 -0
  16. package/dist/contracts/message.d.ts +7 -5
  17. package/dist/contracts/message.js +1 -1
  18. package/dist/contracts/part-registry.d.ts +6 -6
  19. package/dist/contracts/report-blocks.d.ts +10 -10
  20. package/dist/contracts/report-reference.d.ts +4 -4
  21. package/dist/contracts/schemas/chat-events.d.ts +2 -2
  22. package/dist/contracts/schemas/chat-events.js +2 -2
  23. package/dist/contracts/schemas/chat-parts.d.ts +34 -2
  24. package/dist/contracts/schemas/chat-parts.js +9 -3
  25. package/dist/contracts/schemas/index.d.ts +2 -2
  26. package/dist/contracts/schemas/index.js +2 -2
  27. package/dist/execution/run-event-parts.d.ts +4 -4
  28. package/dist/execution/run-event-parts.js +0 -0
  29. package/dist/execution/run-event-stream.d.ts +2 -2
  30. package/dist/index.d.ts +9 -6
  31. package/dist/index.js +3 -2
  32. package/dist/lib/page-capture.d.ts +27 -9
  33. package/dist/lib/page-capture.js +81 -33
  34. package/dist/loop/compaction.d.ts +8 -3
  35. package/dist/loop/run-agent.d.ts +11 -0
  36. package/dist/loop/run-agent.js +62 -35
  37. package/dist/loop/types.d.ts +3 -0
  38. package/dist/memory/ai-sdk-message-storage.d.ts +18 -3
  39. package/dist/memory/ai-sdk-message-storage.js +23 -1
  40. package/dist/memory/conversation-display-recorder.d.ts +7 -1
  41. package/dist/memory/conversation-display-recorder.js +59 -119
  42. package/dist/memory/conversation-display-replay.d.ts +3 -3
  43. package/dist/memory/conversation-display-replay.js +7 -21
  44. package/dist/memory/conversation-display-storage.d.ts +10 -10
  45. package/dist/memory/conversation-display-storage.js +9 -8
  46. package/dist/memory/conversation-view.d.ts +5 -4
  47. package/dist/memory/conversation-view.js +14 -18
  48. package/dist/memory/count-tokens.js +1 -0
  49. package/dist/memory/message-backfill.d.ts +8 -2
  50. package/dist/memory/thread-store.d.ts +9 -2
  51. package/dist/memory/thread-store.js +8 -2
  52. package/dist/prompts/compaction.d.ts +2 -2
  53. package/dist/prompts/compaction.js +2 -2
  54. package/dist/prompts/conversation.d.ts +1 -1
  55. package/dist/prompts/conversation.js +12 -0
  56. package/dist/prompts/report-session.d.ts +1 -1
  57. package/dist/prompts/report-session.js +40 -28
  58. package/dist/report-model/authoring-grammar.d.ts +4 -4
  59. package/dist/report-render/views/block-mark.d.ts +8 -0
  60. package/dist/report-render/views/block-mark.js +10 -0
  61. package/dist/report-render/views/chart-view.js +2 -1
  62. package/dist/report-render/views/prose.js +10 -6
  63. package/dist/report-render/views/values.js +5 -4
  64. package/dist/runtime/boot.d.ts +2 -2
  65. package/dist/runtime/boot.js +3 -3
  66. package/dist/state/init.d.ts +7 -6
  67. package/dist/state/init.js +51 -672
  68. package/dist/state/migrations/20260927120000_baseline.d.ts +11 -0
  69. package/dist/state/migrations/20260927120000_baseline.js +675 -0
  70. package/dist/state/migrations/20260927180000_thread_title_set_by_user.d.ts +6 -0
  71. package/dist/state/migrations/20260927180000_thread_title_set_by_user.js +10 -0
  72. package/dist/state/migrations/index.d.ts +10 -0
  73. package/dist/state/migrations/index.js +15 -0
  74. package/dist/tools/display/show-user.js +6 -4
  75. package/dist/tools/report-authoring/authoring-tools.d.ts +16 -5
  76. package/dist/tools/report-authoring/authoring-tools.js +52 -24
  77. package/dist/tools/report-session/examine-page.d.ts +29 -14
  78. package/dist/tools/report-session/examine-page.js +40 -16
  79. package/dist/tools/report-session/list-artifacts.d.ts +23 -10
  80. package/dist/tools/report-session/list-artifacts.js +51 -31
  81. package/dist/tools/report-session/page-faults.d.ts +29 -0
  82. package/dist/tools/report-session/page-faults.js +48 -0
  83. package/dist/tools/report-session/preview-report.d.ts +9 -2
  84. package/dist/tools/report-session/preview-report.js +13 -3
  85. package/dist/tools/report-session/record-version.d.ts +5 -1
  86. package/dist/tools/report-session/record-version.js +4 -3
  87. package/dist/tools/workspace/show-file.js +4 -3
  88. package/package.json +2 -1
@@ -1,33 +1,36 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { applyChatFrame, checkChatPart, isRootFrame, toChatFrame } from "../contracts/chat-frame.js";
2
3
  import { PART_REGISTRY } from "../contracts/part-registry.js";
3
- import { CortexChatPartSchema } from "../contracts/schemas/chat-parts.js";
4
4
  import { compactionExchangeOf, compactionMarkerOf } from "./ai-sdk-message-storage.js";
5
5
  import { conversationDisplayPart } from "./conversation-display-storage.js";
6
6
  function jsonCopy(value) {
7
7
  return JSON.parse(JSON.stringify(value));
8
8
  }
9
- function isSubAgent(event, topLevelCallPath) {
10
- if (!("source" in event) || event.source === undefined)
11
- return false;
12
- const path = event.source.callPath;
13
- return path.length > topLevelCallPath.length && topLevelCallPath.every((entry, index) => path[index] === entry);
14
- }
15
9
  function durableConversationType(type) {
16
10
  if (!Object.hasOwn(PART_REGISTRY, type))
17
11
  return false;
18
12
  const descriptor = PART_REGISTRY[type];
19
13
  return descriptor.emitter === "conversation" && descriptor.consumer === "conversation" && !descriptor.transient;
20
14
  }
21
- function dataPart(event) {
22
- const parsed = CortexChatPartSchema.safeParse({ type: event.type, ...event.data });
23
- if (!parsed.success)
24
- throw new Error(`Invalid emitted conversation part ${event.type}: ${parsed.error.message}`);
25
- const { type, ...data } = parsed.data;
26
- return {
27
- type,
28
- id: "id" in data && typeof data.id === "string" ? data.id : undefined,
29
- data: jsonCopy(data),
30
- };
15
+ /** The checked part of a data frame, copied at receipt, because the emitter keeps its own reference to the payload. */
16
+ function recordedPart(frame) {
17
+ const checked = checkChatPart(frame);
18
+ if (!checked.ok)
19
+ throw new Error(`Invalid emitted conversation part ${frame.type}: ${checked.error}`);
20
+ return jsonCopy(checked.frame);
21
+ }
22
+ /**
23
+ * The stored form of the parts of one round. A round that closes ends each question that it asked, thus a
24
+ * pending ask is stored `aborted`, and a call with no outcome is stored `incomplete` ({@link conversationDisplayPart}).
25
+ */
26
+ function displayParts(parts) {
27
+ return parts.flatMap((part) => {
28
+ if (part.type === "text")
29
+ return part.text.length === 0 ? [] : [{ type: "text", text: part.text, state: "done" }];
30
+ if (part.type === "data-ask" && part.status === "pending")
31
+ return [conversationDisplayPart({ ...part, status: "aborted" })];
32
+ return [conversationDisplayPart(part)];
33
+ });
31
34
  }
32
35
  /** The text of the assistant messages of a round. The text of an exchange is a summary, and no reply. */
33
36
  function assistantText(messages) {
@@ -48,148 +51,85 @@ function compactionDivider(marker) {
48
51
  id: marker.id,
49
52
  status: marker.kind === "summary" ? "done" : "failed",
50
53
  tokensBefore: marker.tokensBefore,
51
- tokensAfter: marker.tokensAfter,
52
54
  durationMs: marker.durationMs,
55
+ ...(marker.trigger === undefined ? {} : { trigger: marker.trigger }),
53
56
  });
54
57
  return { id: marker.id, role: "system", parts: [part] };
55
58
  }
59
+ /**
60
+ * Record the display of a turn through the same translation path as a live surface: each event becomes a
61
+ * frame (`toChatFrame`), and the frames build the messages of the round (`applyChatFrame`). Only a part that
62
+ * the conversation keeps enters the record.
63
+ */
56
64
  export function createConversationDisplayRecorder(options) {
57
65
  // One id for each message of the turn, thus the rounds of a turn replay as one assistant message.
58
66
  const userMessageId = options.userMessageId ?? randomUUID();
59
67
  let assistantMessageId = options.assistantMessageId ?? randomUUID();
60
- const assistantParts = [];
61
- // Where a reconciling part lives, keyed by `type:id`. Type-qualified because a
62
- // stable id is only unique within its own family — a plan and a run card may
63
- // legitimately share one.
64
- const reconcileIndexes = new Map();
68
+ const rootSource = { agentId: options.agentId, callPath: [options.agentId] };
69
+ // The messages of the open round. A later update of a part lands in the next round, and the replay replaces the earlier copy.
70
+ let round = [];
65
71
  let finished = false;
66
- /**
67
- * Place a part, replacing the one it reconciles with when it has a stable id.
68
- * A tool call reaching its `finished` state and an `ask` reaching its resolved
69
- * status are the same operation: latest-wins, in the position the part first
70
- * took, so a later part never jumps the ordering the user saw.
71
- */
72
- function upsert(part, reconciling) {
73
- if (!reconciling || part.id === undefined) {
74
- assistantParts.push(part);
75
- return;
76
- }
77
- const key = `${part.type}:${part.id}`;
78
- const index = reconcileIndexes.get(key);
79
- if (index !== undefined) {
80
- assistantParts[index] = part;
81
- return;
82
- }
83
- reconcileIndexes.set(key, assistantParts.length);
84
- assistantParts.push(part);
72
+ function apply(frame) {
73
+ round = applyChatFrame(round, frame, assistantMessageId).messages;
74
+ }
75
+ function roundParts() {
76
+ return round.find((message) => message.id === assistantMessageId)?.parts ?? [];
85
77
  }
86
- /**
87
- * Place or update a call's recorded state.
88
- *
89
- * A call is recorded `incomplete` the moment it is dispatched and overwritten with
90
- * its real outcome when it finishes. The record is therefore honest at every
91
- * instant, and a turn that ends mid-call needs no closing pass — the calls that
92
- * never finished are already saying so.
93
- */
94
- function recordToolCall(toolCallId, toolName, outcome, detail) {
95
- upsert(conversationDisplayPart({
96
- type: "tool-call",
97
- toolCallId,
98
- toolName,
99
- outcome,
100
- ...(detail === undefined ? {} : { detail }),
101
- }), true);
78
+ function hasText() {
79
+ return roundParts().some((part) => part.type === "text" && part.text.length > 0);
102
80
  }
103
81
  function appendText(text) {
104
- if (text.length === 0)
82
+ if (text.length > 0)
83
+ apply({ type: "text-delta", text, source: rootSource });
84
+ }
85
+ function record(event) {
86
+ const frame = toChatFrame(event, rootSource);
87
+ if (frame === null || !isRootFrame(frame))
105
88
  return;
106
- const previous = assistantParts.at(-1);
107
- if (previous?.type === "text") {
108
- previous.text += text;
89
+ if (frame.type.startsWith("data-")) {
90
+ // Each chat event type is named without the `data-` prefix, thus a `data-` frame is a part frame.
91
+ if (durableConversationType(frame.type))
92
+ apply(recordedPart(frame));
109
93
  return;
110
94
  }
111
- assistantParts.push({ type: "text", text, state: "streaming" });
112
- }
113
- function recordData(event) {
114
- if (!durableConversationType(event.type))
115
- return;
116
- upsert(dataPart(event), PART_REGISTRY[event.type].reconciling);
95
+ apply(frame);
117
96
  }
118
97
  const emit = (event) => {
119
- if (finished)
120
- return options.sink(event);
121
- if (!isSubAgent(event, options.topLevelCallPath)) {
122
- switch (event.type) {
123
- case "text-delta":
124
- appendText(event.text);
125
- break;
126
- // The detail the loop computed at dispatch is recorded as shown. It is
127
- // NOT re-derived on reload: doing that would need the tool's schema and
128
- // the raw input, which is the tool-name coupling the display projection
129
- // exists to remove.
130
- case "tool-started":
131
- recordToolCall(event.toolUseId, event.name, "incomplete", event.detail);
132
- break;
133
- case "tool-finished":
134
- recordToolCall(event.toolUseId, event.name, event.outcome, event.detail);
135
- break;
136
- case "done":
137
- case "iteration":
138
- break;
139
- default:
140
- recordData(event);
141
- break;
142
- }
143
- }
98
+ if (!finished)
99
+ record(event);
144
100
  return options.sink(event);
145
101
  };
146
- function closeParts() {
147
- // Calls need no closing pass: one that never reached `tool-finished` still holds
148
- // the `incomplete` it was recorded with at dispatch, which is the whole of what
149
- // the harness observed. An approval does need one — `pending` is a question, and
150
- // a turn that ends without answering it aborted it.
151
- for (const part of assistantParts) {
152
- if (part.type === "data-ask" && part.data.status === "pending")
153
- part.data.status = "aborted";
154
- if (part.type === "text")
155
- part.state = "done";
156
- }
157
- }
158
102
  function takeOpening() {
159
103
  return [{ id: userMessageId, role: "user", parts: [{ type: "text", text: options.userText, state: "done" }] }];
160
104
  }
161
105
  function takeRound(messages) {
162
- if (!assistantParts.some((part) => part.type === "text"))
106
+ if (!hasText())
163
107
  appendText(assistantText(messages));
164
- // Only a round that a throw ends can hold a pending approval.
165
- closeParts();
166
- const round = assistantParts.length === 0 ? [] : [{ id: assistantMessageId, role: "assistant", parts: jsonCopy(assistantParts) }];
167
- // A later update of a part lands in the next round, and the replay replaces the earlier copy.
168
- assistantParts.length = 0;
169
- reconcileIndexes.clear();
108
+ const parts = displayParts(roundParts());
109
+ const taken = parts.length === 0 ? [] : [{ id: assistantMessageId, role: "assistant", parts: jsonCopy(parts) }];
110
+ round = [];
170
111
  const marker = messages.map(compactionMarkerOf).find((found) => found !== undefined);
171
112
  if (marker !== undefined) {
172
- round.push(compactionDivider(marker));
113
+ taken.push(compactionDivider(marker));
173
114
  // The divider splits the turn into two assistant messages of the replay, and two messages must not share an id.
174
115
  assistantMessageId = randomUUID();
175
116
  }
176
- return round;
117
+ return taken;
177
118
  }
178
119
  function finish(finishOptions) {
179
120
  if (!finished) {
180
121
  finished = true;
181
- if (!assistantParts.some((part) => part.type === "text") && finishOptions?.fallbackText) {
122
+ if (!hasText() && finishOptions?.fallbackText)
182
123
  appendText(finishOptions.fallbackText);
183
- }
184
- closeParts();
185
124
  }
186
125
  const messages = takeOpening();
187
- if (assistantParts.length > 0) {
126
+ const parts = displayParts(roundParts());
127
+ if (parts.length > 0) {
188
128
  messages.push({
189
129
  id: assistantMessageId,
190
130
  role: "assistant",
191
131
  ...(finishOptions?.interrupted ? { metadata: { interrupted: true } } : {}),
192
- parts: jsonCopy(assistantParts),
132
+ parts: jsonCopy(parts),
193
133
  });
194
134
  }
195
135
  return messages;
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The runtime transcript read: stored display projections → `CortexMessage[]`. It walks the rows in
2
+ * The runtime transcript read: stored display projections → `ChatMessage[]`. It walks the rows in
3
3
  * `seq` order, merges the rounds of a turn into one assistant message, and folds the turn record onto
4
4
  * that message. It consults no tool name, no card builder, and no workspace or database.
5
5
  *
6
6
  * A row with no envelope predates the display projection and is skipped. A display rebuilt from the
7
7
  * model transcript would let each later change of a tool or a card rewrite history.
8
8
  */
9
- import type { CortexMessage } from "../contracts/message.js";
9
+ import type { ChatMessage } from "../contracts/message.js";
10
10
  import type { StoredMessage } from "./thread-history.js";
11
- export declare function storedMessagesToCortex(messages: readonly StoredMessage[]): CortexMessage[];
11
+ export declare function storedMessagesToChat(messages: readonly StoredMessage[]): ChatMessage[];
@@ -1,32 +1,18 @@
1
1
  /**
2
- * The runtime transcript read: stored display projections → `CortexMessage[]`. It walks the rows in
2
+ * The runtime transcript read: stored display projections → `ChatMessage[]`. It walks the rows in
3
3
  * `seq` order, merges the rounds of a turn into one assistant message, and folds the turn record onto
4
4
  * that message. It consults no tool name, no card builder, and no workspace or database.
5
5
  *
6
6
  * A row with no envelope predates the display projection and is skipped. A display rebuilt from the
7
7
  * model transcript would let each later change of a tool or a card rewrite history.
8
8
  */
9
- import { isReconciling } from "../contracts/part-registry.js";
9
+ import { upsertPart } from "../contracts/chat-frame.js";
10
10
  import { isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
11
- import { conversationUIToCortexMessages } from "./conversation-display-storage.js";
12
- /** The key that a part reconciles on, or `undefined` for a part that each round adds again. */
13
- function reconcileKey(part) {
14
- if (part.type === "tool-call")
15
- return `tool-call:${part.toolCallId}`;
16
- if (part.type === "text" || !isReconciling(part.type))
17
- return undefined;
18
- return "id" in part && typeof part.id === "string" ? `${part.type}:${part.id}` : undefined;
19
- }
11
+ import { conversationUIToChatMessages } from "./conversation-display-storage.js";
20
12
  /** Add the parts of a later round to the assistant message of its turn. */
21
13
  function mergeRound(target, round) {
22
- for (const part of round.parts) {
23
- const key = reconcileKey(part);
24
- const earlier = key === undefined ? -1 : target.parts.findIndex((candidate) => reconcileKey(candidate) === key);
25
- if (earlier >= 0)
26
- target.parts[earlier] = part;
27
- else
28
- target.parts.push(part);
29
- }
14
+ for (const part of round.parts)
15
+ target.parts = upsertPart(target.parts, part);
30
16
  }
31
17
  function foldTurnRecord(message, record) {
32
18
  if (message === undefined || record === undefined)
@@ -38,7 +24,7 @@ function foldTurnRecord(message, record) {
38
24
  if (record.status === "aborted")
39
25
  message.interrupted = true;
40
26
  }
41
- export function storedMessagesToCortex(messages) {
27
+ export function storedMessagesToChat(messages) {
42
28
  const out = [];
43
29
  // The last message of the group being walked, as `out` holds it.
44
30
  let groupLast;
@@ -62,7 +48,7 @@ export function storedMessagesToCortex(messages) {
62
48
  const createdAt = row.createdAt === undefined ? {} : { createdAt: row.createdAt.toISOString() };
63
49
  const author = row.author === undefined ? {} : { author: row.author };
64
50
  groupLast = undefined;
65
- for (const message of conversationUIToCortexMessages(row.displayEnvelope.messages)) {
51
+ for (const message of conversationUIToChatMessages(row.displayEnvelope.messages)) {
66
52
  const stamped = { ...message, ...createdAt, ...(message.role === "user" ? author : {}) };
67
53
  const previous = out.at(-1);
68
54
  // The merged message keeps the creation time of its first round.
@@ -4,20 +4,20 @@
4
4
  *
5
5
  * AI SDK `UIMessage` is the CONTAINER: it supplies message identity, role,
6
6
  * ordering, metadata, and `validateUIMessages` for runtime validation. The PARTS
7
- * vocabulary is Cortex's own, because ours is the richer one — a `CortexPart`
7
+ * vocabulary is the harness's own, because ours is the richer one — a `MessagePart`
8
8
  * carries a tool call's four-way outcome and its one-line detail, and AI SDK's
9
9
  * `dynamic-tool` part has nowhere to put either. Mapping through `dynamic-tool`
10
10
  * would silently drop both on every reload, so every non-text part rides as a
11
- * typed `DataUIPart` whose payload IS the Cortex part minus its discriminant.
11
+ * typed `DataUIPart` whose payload IS the message part minus its discriminant.
12
12
  *
13
13
  * The projection is therefore lossless in both directions, and the read path
14
- * ({@link conversationUIToCortexMessages}) is a near-identity rather than a
14
+ * ({@link conversationUIToChatMessages}) is a near-identity rather than a
15
15
  * reconstruction.
16
16
  */
17
17
  import { type DataUIPart, type UIMessage } from "ai";
18
18
  import type { AskPart, ChildSessionStartedPart, CompactionPart, FileReferencePart, PlanPart, PresentationPart, ReportRenderedPart, RunCardPart } from "../contracts/chat-parts.js";
19
19
  import type { ToolCallOutcome } from "../contracts/chat-events.js";
20
- import type { CortexMessage, CortexPart, ToolCallPart } from "../contracts/message.js";
20
+ import type { ChatMessage, MessagePart, ToolCallPart } from "../contracts/message.js";
21
21
  import type { Logger } from "../lib/logger.js";
22
22
  export declare const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
23
23
  type Payload<T extends {
@@ -26,7 +26,7 @@ type Payload<T extends {
26
26
  /**
27
27
  * The AI SDK custom-data vocabulary persisted for one conversation display.
28
28
  *
29
- * Each key `k` becomes a `data-${k}` part whose payload is the matching Cortex
29
+ * Each key `k` becomes a `data-${k}` part whose payload is the matching message
30
30
  * part without its `type` field, so a stored part and a wire part differ only in
31
31
  * where the discriminant lives.
32
32
  */
@@ -56,24 +56,24 @@ export interface StoredDisplayEnvelope {
56
56
  export declare function envelopeDisplayMessages(messages: readonly ConversationUIMessage[]): StoredDisplayEnvelope;
57
57
  export declare function parseStoredDisplayEnvelope(value: unknown, identity: string, logger?: Logger): Promise<StoredDisplayEnvelope>;
58
58
  /**
59
- * Build the stored form of one non-text Cortex part.
59
+ * Build the stored form of one non-text message part.
60
60
  *
61
- * A tool call is the one part whose stored payload is not simply the Cortex part
61
+ * A tool call is the one part whose stored payload is not simply the message part
62
62
  * minus its discriminant: `outcome` is optional on the live part (absent means in
63
63
  * flight) and required here, because a record is of a call that is no longer
64
64
  * running. An in-flight call being recorded is one the turn never saw finish, so
65
65
  * absence resolves to `incomplete` at exactly this boundary — once, where the
66
66
  * lifetime changes — rather than at each reader.
67
67
  */
68
- export declare function conversationDisplayPart(part: Exclude<CortexPart, {
68
+ export declare function conversationDisplayPart(part: Exclude<MessagePart, {
69
69
  type: "text";
70
70
  }>): DataUIPart<ConversationUIData>;
71
71
  /**
72
- * Read the durable representation back as Cortex display messages.
72
+ * Read the durable representation back as chat messages.
73
73
  *
74
74
  * Every part round-trips by moving its discriminant back out of the part type,
75
75
  * so nothing here consults a tool name, a registry, or the filesystem. A message
76
76
  * whose parts all render empty is dropped so the transcript has no empty bubbles.
77
77
  */
78
- export declare function conversationUIToCortexMessages(messages: readonly ConversationUIMessage[]): CortexMessage[];
78
+ export declare function conversationUIToChatMessages(messages: readonly ConversationUIMessage[]): ChatMessage[];
79
79
  export {};
@@ -4,14 +4,14 @@
4
4
  *
5
5
  * AI SDK `UIMessage` is the CONTAINER: it supplies message identity, role,
6
6
  * ordering, metadata, and `validateUIMessages` for runtime validation. The PARTS
7
- * vocabulary is Cortex's own, because ours is the richer one — a `CortexPart`
7
+ * vocabulary is the harness's own, because ours is the richer one — a `MessagePart`
8
8
  * carries a tool call's four-way outcome and its one-line detail, and AI SDK's
9
9
  * `dynamic-tool` part has nowhere to put either. Mapping through `dynamic-tool`
10
10
  * would silently drop both on every reload, so every non-text part rides as a
11
- * typed `DataUIPart` whose payload IS the Cortex part minus its discriminant.
11
+ * typed `DataUIPart` whose payload IS the message part minus its discriminant.
12
12
  *
13
13
  * The projection is therefore lossless in both directions, and the read path
14
- * ({@link conversationUIToCortexMessages}) is a near-identity rather than a
14
+ * ({@link conversationUIToChatMessages}) is a near-identity rather than a
15
15
  * reconstruction.
16
16
  */
17
17
  import { validateUIMessages } from "ai";
@@ -44,6 +44,7 @@ const ToolCallDisplaySchema = z.object({
44
44
  toolName: z.string(),
45
45
  outcome: ToolCallOutcomeSchema,
46
46
  detail: z.string().optional(),
47
+ durationMs: z.number().optional(),
47
48
  });
48
49
  /**
49
50
  * The schema behind each stored part key.
@@ -152,7 +153,7 @@ export async function parseStoredDisplayEnvelope(value, identity, logger) {
152
153
  throw new Error(`Invalid stored conversation display envelope at ${identity}: ${message}`, { cause: error });
153
154
  }
154
155
  }
155
- /** The data-part key a Cortex part is stored under — its `type` minus the `data-` prefix. */
156
+ /** The data-part key a message part is stored under — its `type` minus the `data-` prefix. */
156
157
  function displayPartType(part) {
157
158
  return (part.type === "tool-call" ? "data-tool-call" : part.type);
158
159
  }
@@ -163,9 +164,9 @@ function displayPartId(part) {
163
164
  return "id" in part && typeof part.id === "string" ? part.id : undefined;
164
165
  }
165
166
  /**
166
- * Build the stored form of one non-text Cortex part.
167
+ * Build the stored form of one non-text message part.
167
168
  *
168
- * A tool call is the one part whose stored payload is not simply the Cortex part
169
+ * A tool call is the one part whose stored payload is not simply the message part
169
170
  * minus its discriminant: `outcome` is optional on the live part (absent means in
170
171
  * flight) and required here, because a record is of a call that is no longer
171
172
  * running. An in-flight call being recorded is one the turn never saw finish, so
@@ -178,13 +179,13 @@ export function conversationDisplayPart(part) {
178
179
  return { type: displayPartType(part), id: displayPartId(part), data };
179
180
  }
180
181
  /**
181
- * Read the durable representation back as Cortex display messages.
182
+ * Read the durable representation back as chat messages.
182
183
  *
183
184
  * Every part round-trips by moving its discriminant back out of the part type,
184
185
  * so nothing here consults a tool name, a registry, or the filesystem. A message
185
186
  * whose parts all render empty is dropped so the transcript has no empty bubbles.
186
187
  */
187
- export function conversationUIToCortexMessages(messages) {
188
+ export function conversationUIToChatMessages(messages) {
188
189
  const out = [];
189
190
  for (const message of messages) {
190
191
  const parts = [];
@@ -47,7 +47,8 @@ export interface ConversationView {
47
47
  export declare function conversationView(messages: readonly ModelMessage[], options: ConversationViewOptions): ConversationView;
48
48
  /** The message with no reasoning part, or `undefined` for an assistant message with no part left. */
49
49
  export declare function withoutReasoning(message: ModelMessage): ModelMessage | undefined;
50
- /** The estimate of the messages, by the count that the store writes to the `tokens` column. */
51
- export declare function viewTokens(messages: readonly ModelMessage[]): number;
52
- /** The count of the turns that a drop keeps: the newest turn, and each older turn while the view stays within `budget`. */
53
- export declare function keptTurnsForDrop(messages: readonly ModelMessage[], options: ConversationViewOptions, budget: number): number;
50
+ /**
51
+ * The input tokens that the provider reported for the last request of the conversation. A marker changes the
52
+ * conversation, thus a request before the latest marker gives `undefined`.
53
+ */
54
+ export declare function lastRequestInputTokens(messages: readonly ModelMessage[]): number | undefined;
@@ -4,8 +4,7 @@
4
4
  * turns of the body without their reasoning. The loop and the reader call this one function, thus the
5
5
  * next turn reads back the prefix that the last request sent.
6
6
  */
7
- import { compactionExchangeOf, compactionMarkerOf, isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
8
- import { countTokens } from "./count-tokens.js";
7
+ import { compactionExchangeOf, compactionMarkerOf, isSyntheticUserMessage, requestInputTokensOf } from "./ai-sdk-message-storage.js";
9
8
  /**
10
9
  * A turn starts on a `user` message that a human actually sent. Two kinds of
11
10
  * message carry the `user` role without opening a turn, and both are excluded
@@ -110,21 +109,18 @@ export function withoutReasoning(message) {
110
109
  return message;
111
110
  return content.length === 0 ? undefined : { ...message, content };
112
111
  }
113
- /** The estimate of the messages, by the count that the store writes to the `tokens` column. */
114
- export function viewTokens(messages) {
115
- return messages.reduce((sum, message) => sum + countTokens(message.content), 0);
116
- }
117
- /** The count of the turns that a drop keeps: the newest turn, and each older turn while the view stays within `budget`. */
118
- export function keptTurnsForDrop(messages, options, budget) {
119
- const { head, front, body } = viewParts(messages, options);
120
- const turnTokens = groupTurns(body, startsTurn).map((turn) => viewTokens(withoutReasoningEntries(turn).map((entry) => entry.message)));
121
- let total = viewTokens([...head, ...(front === undefined ? [] : [front])].map((entry) => entry.message));
122
- let kept = 0;
123
- for (let t = turnTokens.length - 1; t >= 0; t--) {
124
- if (kept > 0 && total + turnTokens[t] > budget)
125
- break;
126
- total += turnTokens[t];
127
- kept++;
112
+ /**
113
+ * The input tokens that the provider reported for the last request of the conversation. A marker changes the
114
+ * conversation, thus a request before the latest marker gives `undefined`.
115
+ */
116
+ export function lastRequestInputTokens(messages) {
117
+ for (let at = messages.length - 1; at >= 0; at--) {
118
+ const message = messages[at];
119
+ if (compactionMarkerOf(message) !== undefined)
120
+ return undefined;
121
+ const tokens = requestInputTokensOf(message);
122
+ if (tokens !== undefined)
123
+ return tokens;
128
124
  }
129
- return kept;
125
+ return undefined;
130
126
  }
@@ -63,6 +63,7 @@ function tokenizableText(block) {
63
63
  case "reasoning-file":
64
64
  case "custom":
65
65
  case "file":
66
+ case "image":
66
67
  return "";
67
68
  case "tool-call":
68
69
  return `${String(part.toolName ?? "")} ${JSON.stringify(part.input ?? {})}`;
@@ -1,2 +1,8 @@
1
- import type { PoolClient } from "pg";
2
- export declare function backfillAiSdkMessageEnvelopes(client: PoolClient): Promise<void>;
1
+ import type { QueryResultRow } from "pg";
2
+ /** The query method of a `pg` client, which the migration transaction also supplies. */
3
+ export interface BackfillClient {
4
+ query<R extends QueryResultRow>(text: string, values?: unknown[]): Promise<{
5
+ rows: R[];
6
+ }>;
7
+ }
8
+ export declare function backfillAiSdkMessageEnvelopes(client: BackfillClient): Promise<void>;
@@ -193,10 +193,17 @@ export interface ThreadStore {
193
193
  /** The live thread by id, or `null` if absent or archived. */
194
194
  getThread(threadId: string): ResultAsync<Thread | null, DbError>;
195
195
  /**
196
- * Set only the title, bumping `updated_at` forward — never behind the stamp
197
- * the row already carries. No-op on a missing/archived row.
196
+ * A rename by a person. Set the title and `title_set_by_user`, thus no
197
+ * automatic title replaces it. Bumps `updated_at` forward — never behind the
198
+ * stamp the row already carries. No-op on a missing/archived row.
198
199
  */
199
200
  updateTitle(threadId: string, title: string): ResultAsync<Thread | null, DbError>;
201
+ /**
202
+ * An automatic title. Set the title only while no person set one, with the
203
+ * same forward-only bump as `updateTitle`. `null` when the row is missing,
204
+ * archived, or titled by a person.
205
+ */
206
+ setAutoTitle(threadId: string, title: string): ResultAsync<Thread | null, DbError>;
200
207
  /**
201
208
  * Soft-delete the subtree: stamp `deleted_at` on the named thread and on
202
209
  * every descendant reachable through `parent_thread_id`, at any depth, so a
@@ -220,10 +220,16 @@ export function createThreadStore(pool) {
220
220
  // give that: it reads this transaction's START time, not the moment the
221
221
  // row is actually written.
222
222
  `UPDATE cortex_analysis_threads
223
- SET title = $2, updated_at = GREATEST(updated_at, clock_timestamp())
223
+ SET title = $2, title_set_by_user = true, updated_at = GREATEST(updated_at, clock_timestamp())
224
224
  WHERE thread_id = $1 AND deleted_at IS NULL
225
225
  RETURNING ${THREAD_COLUMNS}`, [threadId, title])).map(({ rows }) => (rows[0] ? toThread(rows[0]) : null));
226
226
  }
227
+ function setAutoTitle(threadId, title) {
228
+ return tryMutation("thread-store.setAutoTitle", () => pool.query(`UPDATE cortex_analysis_threads
229
+ SET title = $2, updated_at = GREATEST(updated_at, clock_timestamp())
230
+ WHERE thread_id = $1 AND deleted_at IS NULL AND NOT title_set_by_user
231
+ RETURNING ${THREAD_COLUMNS}`, [threadId, title])).map(({ rows }) => (rows[0] ? toThread(rows[0]) : null));
232
+ }
227
233
  function archiveThread(threadId) {
228
234
  return tryMutation("thread-store.archiveThread", () => pool.query(
229
235
  // The stamp reaches the whole subtree because a hidden thread must
@@ -360,5 +366,5 @@ export function createThreadStore(pool) {
360
366
  }));
361
367
  });
362
368
  }
363
- return { createThread, getThread, updateTitle, archiveThread, unarchiveThread, purgeThread, listThreads };
369
+ return { createThread, getThread, updateTitle, setAutoTitle, archiveThread, unarchiveThread, purgeThread, listThreads };
364
370
  }
@@ -1,4 +1,4 @@
1
1
  /** The compaction request of a thread whose agent maintains the working memory. */
2
- export declare const MEMORY_COMPACTION_REQUEST = "The conversation is too long for the context window. The harness replaces the conversation above with your summary.\n\nFirst, move each lasting fact into the working memory with update_working_memory: the goal, each constraint, each hypothesis, and each finding with its run id. Keep each constraint that the user gave as it is, because the user set it. No other tool can run now. Send all the memory edits in at most 3 replies, and put many calls in one reply. The reply after the edits must be the summary: if it calls a tool, the harness gets no summary.\n\nThen reply in plain text with the summary. Leave out each fact that a memory edit accepted. If the memory refused an edit, put that fact in the summary. Give:\n- the current request of the user in its exact words\n- the decisions, with their reasons\n- the approaches that failed or that the user ruled out, with the reasons\n- the open questions\n- the file paths, the run ids, and the other exact values that the work uses\n- the state of the work in progress\n\nThe conversation continues from your summary, the working memory, and the messages after them.";
2
+ export declare const MEMORY_COMPACTION_REQUEST = "The conversation is too long for the context window. The harness replaces the conversation above with your summary.\n\nFirst, move each lasting fact into the working memory with update_working_memory: the goal, each constraint, each hypothesis, and each finding with its run id. Keep each constraint that the user gave as it is, because the user set it. No other tool can run now. Send all the memory edits in at most 3 replies, and put many calls in one reply. The reply after the edits must be the summary: if it calls a tool, the harness gets no summary.\n\nThen reply in plain text with the summary. Leave out each fact that a memory edit accepted. If the memory refused an edit, put that fact in the summary. Give:\n- the last request of the user in its exact words\n- the decisions, with their reasons\n- the approaches that failed or that the user ruled out, with the reasons\n- the open questions\n- the file paths, the run ids, and the other exact values that the work uses\n- the state of the work in progress\n\nThe conversation continues from your summary, the working memory, and the messages after them.";
3
3
  /** The compaction request of a thread that reads a frozen copy of the working memory, for example a report thread. */
4
- export declare const SUMMARY_COMPACTION_REQUEST = "The conversation is too long for the context window. The harness replaces the conversation above with your summary. No tool can run now.\n\nReply in plain text with the summary. Leave out what the working memory holds. Give:\n- the goals, and the current request of the user in its exact words\n- each change that the user made to the brief\n- the decisions, with their reasons\n- the approaches that failed or that the user ruled out, with the reasons\n- the open questions\n- the file paths, the run ids, and the other exact values that the work uses\n- the state of the work in progress\n\nThe conversation continues from your summary, the working memory, and the messages after them.";
4
+ export declare const SUMMARY_COMPACTION_REQUEST = "The conversation is too long for the context window. The harness replaces the conversation above with your summary. No tool can run now.\n\nReply in plain text with the summary. Leave out what the working memory holds. Give:\n- the goals, and the last request of the user in its exact words\n- each change that the user made to the brief\n- the decisions, with their reasons\n- the approaches that failed or that the user ruled out, with the reasons\n- the open questions\n- the file paths, the run ids, and the other exact values that the work uses\n- the state of the work in progress\n\nThe conversation continues from your summary, the working memory, and the messages after them.";
@@ -5,7 +5,7 @@ export const MEMORY_COMPACTION_REQUEST = `The conversation is too long for the c
5
5
  First, move each lasting fact into the working memory with update_working_memory: the goal, each constraint, each hypothesis, and each finding with its run id. Keep each constraint that the user gave as it is, because the user set it. No other tool can run now. Send all the memory edits in at most ${COMPACTION_MAX_REQUESTS - 1} replies, and put many calls in one reply. The reply after the edits must be the summary: if it calls a tool, the harness gets no summary.
6
6
 
7
7
  Then reply in plain text with the summary. Leave out each fact that a memory edit accepted. If the memory refused an edit, put that fact in the summary. Give:
8
- - the current request of the user in its exact words
8
+ - the last request of the user in its exact words
9
9
  - the decisions, with their reasons
10
10
  - the approaches that failed or that the user ruled out, with the reasons
11
11
  - the open questions
@@ -17,7 +17,7 @@ The conversation continues from your summary, the working memory, and the messag
17
17
  export const SUMMARY_COMPACTION_REQUEST = `The conversation is too long for the context window. The harness replaces the conversation above with your summary. No tool can run now.
18
18
 
19
19
  Reply in plain text with the summary. Leave out what the working memory holds. Give:
20
- - the goals, and the current request of the user in its exact words
20
+ - the goals, and the last request of the user in its exact words
21
21
  - each change that the user made to the brief
22
22
  - the decisions, with their reasons
23
23
  - the approaches that failed or that the user ruled out, with the reasons