@jini-ai/chat 0.6.19 → 0.6.22

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 (46) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/core/accepted-input.d.ts +10 -2
  3. package/dist/core/accepted-input.js +87 -33
  4. package/dist/core/assistant-client/durable-run-subscription.js +6 -0
  5. package/dist/core/assistant-client/entry.d.ts +1 -1
  6. package/dist/core/assistant-client/entry.js +1 -1
  7. package/dist/core/assistant-client/messages.d.ts +21 -0
  8. package/dist/core/assistant-client/messages.js +31 -0
  9. package/dist/core/checkpoint-interval.d.ts +25 -0
  10. package/dist/core/checkpoint-interval.js +38 -0
  11. package/dist/core/durable-runs/ledger-contracts.d.ts +27 -1
  12. package/dist/core/durable-runs/ports.d.ts +25 -1
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/index.js +1 -0
  15. package/dist/core/tool-events.d.ts +5 -2
  16. package/dist/core/tool-events.js +1 -16
  17. package/dist/react/components/Composer.d.ts +3 -3
  18. package/dist/react/components/Composer.js +3 -3
  19. package/dist/react/components/MessageList.js +8 -3
  20. package/dist/react/components/MessageRow.d.ts +2 -0
  21. package/dist/react/components/MessageRow.js +8 -5
  22. package/dist/react/components/MidRunUserMessage.js +1 -1
  23. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  24. package/dist/react/ext-event-renderer-registry.js +13 -2
  25. package/dist/react/features/chat-pane/components/ChatPane.js +1 -1
  26. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +5 -4
  27. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +3 -9
  28. package/dist/react/hooks/useConversation.js +13 -2
  29. package/dist/server/durable-runs/recover.js +3 -3
  30. package/dist/store/ports.d.ts +17 -3
  31. package/dist/store/runtime/chat-history-store.js +6 -0
  32. package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
  33. package/dist/store/runtime/checkpoint-delta.js +75 -0
  34. package/dist/store/runtime/durable-run-store.js +92 -25
  35. package/dist/store/runtime/events-json.d.ts +17 -0
  36. package/dist/store/runtime/events-json.js +19 -0
  37. package/dist/store/runtime/run-guarded-chat-store.js +8 -3
  38. package/dist/store/runtime/run-ledger.d.ts +2 -2
  39. package/dist/store/runtime/run-ledger.js +120 -45
  40. package/dist/store/sql/read-version.d.ts +15 -0
  41. package/dist/store/sql/read-version.js +21 -0
  42. package/dist/store/sql/store.d.ts +9 -0
  43. package/dist/store/sql/store.js +89 -4
  44. package/dist/store/sqlite/schema.d.ts +2 -2
  45. package/dist/store/sqlite/schema.js +4 -0
  46. package/package.json +9 -9
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## 0.6.22
2
+
3
+ - The full CLI access notice shows once per conversation, also after replay or remount.
4
+ - A validated extension renderer can declare that it shows a failed turn's explanation (`terminalNotice`); the generic notice stays when the renderer is absent or refuses the payload.
5
+
6
+ ## 0.6.21
7
+
8
+ - Per-chat memory churn stopped: run checkpoints are size-scaled (`checkpointIntervalMs({ bytes })`: 1 s, 3 s from 1 MB, 10 s from 5 MB) and written as guarded SQL deltas (append or text-tail splice under a length+tail receipt), falling back to a full checkpoint on mismatch. A completed checkpoint is never retried unchanged.
9
+ - Leaner reads: message-scoped run heads, lean transcripts for acceptance and recovery, a conditional history read keyed on the connection-wide version, and `hasActiveRuns()` as a single-row probe.
10
+ - Mid-run sends are deduplicated by accepted-input id (two sends with equal text are both kept); Cmd/Ctrl+Enter sends instead of cancelling; "Sending…" clears once the send is accepted; the queued label uses the status pill.
11
+
1
12
  ## 0.6.19
2
13
 
3
14
  - A direct MCP call (`mcp__<server>__X`, any CLI) shows as one row even when the MCP server normalized its arguments before the bridge saw them: once the CLI's own result has arrived, the call pairs by name with the bridge's `X` row between the two. Equal-input pairing still runs first across the turn; refusals and open calls never pair by name.
@@ -18,7 +18,11 @@ export interface AcceptedChatInput {
18
18
  }
19
19
  /** Display queue constituents using their accepted identities; storage and delivery retain one turn.
20
20
  * MOVE: Receipts travel in events_json, so separate bubbles survive a history reload without another store.
21
- * @complexity O(m + e + i) time/space for m messages, e receipt events and i accepted queue inputs.
21
+ * The earlier in-run receipt owns the bubble when an unconsumed fallback also became a user row.
22
+ * @param messages The already loaded transcript; never fetched or mutated here.
23
+ * @returns Display rows with queued fallback copies removed by accepted id and aliases, never text.
24
+ * @complexity O(m + e + i + a) time/space for messages, receipt events, inputs and aliases; no I/O.
25
+ * @example projectAcceptedInputMessages({ messages });
22
26
  */
23
27
  export declare function projectAcceptedInputMessages({ messages }: {
24
28
  messages: readonly ChatMessage[];
@@ -69,7 +73,11 @@ export declare function acceptedInputUserEvent({ input, inputs, toolUseId, after
69
73
  export declare function projectAcceptedInputEvents({ events }: {
70
74
  events: readonly AgentEvent[];
71
75
  }, options?: AcceptedInputProjectionOptions): AgentEvent[];
72
- /** The sole transcript-to-recall projection. A merged queued turn retains its original inputs. */
76
+ /** The sole transcript-to-recall projection. A merged queued turn retains its original inputs.
77
+ * @returns Accepted inputs once by id/alias, in send order; pending input is excluded.
78
+ * @complexity O(m + e + i + a) time/space for messages, events, inputs and aliases; no I/O.
79
+ * @example acceptedInputsFromMessages({ messages });
80
+ */
73
81
  export declare function acceptedInputsFromMessages({ messages }: {
74
82
  messages: readonly ChatMessage[];
75
83
  }, _options?: {}): AcceptedChatInput[];
@@ -3,28 +3,65 @@ import { questionAnswerFromResult, questionAnswerLabel } from './question-answer
3
3
  import { MID_RUN_USER_MESSAGE_EVENT_NAME } from './turn-usage.js';
4
4
  /** Display queue constituents using their accepted identities; storage and delivery retain one turn.
5
5
  * MOVE: Receipts travel in events_json, so separate bubbles survive a history reload without another store.
6
- * @complexity O(m + e + i) time/space for m messages, e receipt events and i accepted queue inputs.
6
+ * The earlier in-run receipt owns the bubble when an unconsumed fallback also became a user row.
7
+ * @param messages The already loaded transcript; never fetched or mutated here.
8
+ * @returns Display rows with queued fallback copies removed by accepted id and aliases, never text.
9
+ * @complexity O(m + e + i + a) time/space for messages, receipt events, inputs and aliases; no I/O.
10
+ * @example projectAcceptedInputMessages({ messages });
7
11
  */
8
12
  export function projectAcceptedInputMessages({ messages }, _options = {}) {
13
+ const seen = new Set();
9
14
  return messages.flatMap(message => {
10
- if (message.role !== 'user')
11
- return [message];
12
- const inputs = (message.events ?? []).flatMap(event => {
13
- const input = acceptedInputEvent({ event });
14
- return input ? [input.data] : [];
15
- });
16
- if (inputs.length === 0)
15
+ if (message.role !== 'user') {
16
+ for (const input of inputsFromMessage({ message }))
17
+ claimAcceptedInput({ input, seen });
17
18
  return [message];
18
- const turn = { ...message };
19
- delete turn.attachments;
20
- delete turn.folders;
21
- delete turn.secretRedaction;
19
+ }
20
+ return expandUserInputMessage({ message, seen });
21
+ });
22
+ }
23
+ /** Keep the original row fields and per-input files when splitting a batched fallback.
24
+ * @complexity O(e + i) time/space for the row's events and accepted inputs; no effects.
25
+ */
26
+ function expandUserInputMessage({ message, seen }, _options = {}) {
27
+ const inputs = inputsFromMessage({ message: { ...message, inputPending: false } });
28
+ // Ordinary user rows have no receipt metadata to split.
29
+ if (!(message.events ?? []).some(event => acceptedInputEvent({ event }))) {
30
+ return claimAcceptedInput({ input: inputs[0], seen }) ? [message] : [];
31
+ }
32
+ return inputs.flatMap((input, index) => {
33
+ if (!claimAcceptedInput({ input, seen }))
34
+ return [];
22
35
  // MOVE: Older receipts have no per-input files; retain those legacy files on the final bubble.
23
- return inputs.map((input, index) => ({ ...turn, id: input.id, content: input.text,
24
- folders: input.folders ?? (index === inputs.length - 1 ? message.folders ?? [] : []),
25
- attachments: input.attachments ?? (index === inputs.length - 1 ? message.attachments ?? [] : []), ...(input.secretRedaction ? { secretRedaction: input.secretRedaction } : {}) }));
36
+ const legacyFiles = index === inputs.length - 1 ? message : {};
37
+ return [userInputRow({ message, input, legacyFiles })];
26
38
  });
27
39
  }
40
+ /** Expand one accepted input without copying another input's files or redaction metadata.
41
+ * @returns A display-only row; stored messages and their receipt events remain unchanged.
42
+ * @complexity O(1) time/space, retaining attachment and folder arrays by reference.
43
+ */
44
+ function userInputRow({ message, input, legacyFiles }, _options = {}) {
45
+ const turn = { ...message };
46
+ delete turn.attachments;
47
+ delete turn.folders;
48
+ delete turn.secretRedaction;
49
+ return { ...turn, id: input.id, content: input.text,
50
+ folders: input.folders ?? legacyFiles.folders ?? [],
51
+ attachments: input.attachments ?? legacyFiles.attachments ?? [],
52
+ ...(input.secretRedaction ? { secretRedaction: input.secretRedaction } : {}) };
53
+ }
54
+ /** Claim the stable identity and all aliases even on a duplicate, so later aliases stay deduplicated.
55
+ * @returns Whether this is the first occurrence; mutates only the projection's local identity set.
56
+ * @complexity O(a) time/space for one input's aliases.
57
+ */
58
+ function claimAcceptedInput({ input, seen }, _options = {}) {
59
+ const ids = [input.id, ...(input.aliases ?? [])];
60
+ const duplicate = ids.some(id => seen.has(id));
61
+ for (const id of ids)
62
+ seen.add(id);
63
+ return !duplicate;
64
+ }
28
65
  /** Reads the existing event-backed user bubble without depending on a host renderer. */
29
66
  export function acceptedInputEvent({ event }, _options = {}) {
30
67
  if (event.kind !== 'ext' || event.name !== MID_RUN_USER_MESSAGE_EVENT_NAME)
@@ -237,26 +274,43 @@ function projectEvents({ events }, options) {
237
274
  }
238
275
  return projected;
239
276
  }
240
- /** The sole transcript-to-recall projection. A merged queued turn retains its original inputs. */
277
+ /** The sole transcript-to-recall projection. A merged queued turn retains its original inputs.
278
+ * @returns Accepted inputs once by id/alias, in send order; pending input is excluded.
279
+ * @complexity O(m + e + i + a) time/space for messages, events, inputs and aliases; no I/O.
280
+ * @example acceptedInputsFromMessages({ messages });
281
+ */
241
282
  export function acceptedInputsFromMessages({ messages }, _options = {}) {
242
- return messages.flatMap(message => {
243
- if (message.role === 'user') {
244
- if (message.inputPending)
245
- return [];
246
- // Queue constituents travel in events_json, already preserved by the existing chat store.
247
- const receipts = (message.events ?? []).flatMap(event => {
248
- const input = acceptedInputEvent({ event });
249
- return input ? [input.data] : [];
250
- });
251
- return receipts.length > 0 ? receipts : [{ id: message.id, text: message.content, ...(message.folders ? { folders: message.folders } : {}) }];
252
- }
253
- return projectAcceptedInputEvents({ events: message.events ?? [] }).flatMap(event => {
254
- const input = acceptedInputEvent({ event });
255
- if (!input)
256
- return [];
257
- return input.data.inputs?.map((receipt, index) => index === 0 ? { ...receipt, aliases: input.data.aliases ?? [] } : receipt) ?? [input.data];
258
- });
283
+ const seen = new Set();
284
+ return messages.flatMap(message => inputsFromMessage({ message }))
285
+ .filter(input => claimAcceptedInput({ input, seen }));
286
+ }
287
+ /** Read one message's accepted identities, retaining queue constituents and wire aliases.
288
+ * @complexity O(e + i) time/space for the message's events and expanded inputs; no effects.
289
+ */
290
+ function inputsFromMessage({ message }, _options = {}) {
291
+ if (message.inputPending)
292
+ return [];
293
+ // Queue constituents travel in events_json, already preserved by the existing chat store.
294
+ const receipts = projectAcceptedInputEvents({ events: message.events ?? [] }).flatMap(event => {
295
+ const input = acceptedInputEvent({ event });
296
+ if (!input)
297
+ return [];
298
+ return receiptInputs({ receipt: input });
259
299
  });
300
+ if (message.role !== 'user' || receipts.length > 0)
301
+ return receipts;
302
+ return [{ id: message.id, text: message.content, ...(message.folders ? { folders: message.folders } : {}) }];
303
+ }
304
+ /** Expand a batched receipt while retaining the first input's local and transport aliases.
305
+ * @returns The accepted constituents, or the single receipt; does not mutate either.
306
+ * @complexity O(i + a) time/space for constituents and first-input aliases.
307
+ */
308
+ function receiptInputs({ receipt }, _options = {}) {
309
+ if (!receipt.data.inputs)
310
+ return [receipt.data];
311
+ return receipt.data.inputs.map((input, index) => index === 0 ? {
312
+ ...input, aliases: [...(input.aliases ?? []), ...(receipt.data.aliases ?? [])],
313
+ } : input);
260
314
  }
261
315
  /** Preserve folder metadata through existing event JSON storage; no attachment lifecycle or schema.
262
316
  * Existing per-input receipts already own queued variants and must retain their identities.
@@ -87,12 +87,14 @@ export function followDurableRun({ runId: initialRunId, handlers, signal, bindin
87
87
  stopTimer?.();
88
88
  stopTimer = undefined;
89
89
  const recover = recoveryPending;
90
+ let retryRecovery = recover;
90
91
  // Consume only when a read starts: faults during its await latch one follow-up recovery.
91
92
  recoveryPending = false;
92
93
  try {
93
94
  const message = await ports.read({ runId, ...(messageId ? { messageId } : {}) }, { recover });
94
95
  if (stopped || !message)
95
96
  return;
97
+ retryRecovery = false;
96
98
  if (message === "gone") {
97
99
  stop();
98
100
  handlers.onDone([]);
@@ -101,6 +103,10 @@ export function followDurableRun({ runId: initialRunId, handlers, signal, bindin
101
103
  applySavedMessage(message);
102
104
  }
103
105
  finally {
106
+ // Recovery must return a body before conditional polls resume: a failed recovery followed
107
+ // by unchanged (304) polls would otherwise leave a quiet run's dropped stream closed.
108
+ if (retryRecovery)
109
+ recoveryPending = true;
104
110
  reading = false;
105
111
  if (!stopped) {
106
112
  // Always schedule: an error arriving during read must not create a tight recovery loop.
@@ -4,7 +4,7 @@ export type { AssistantTransportPorts, AssistantTransportOptions, AssistantTrans
4
4
  export type { AgUiClient, AgUiClientPorts, AgUiRunClient, AgUiEventContext } from "./ag-ui-transport.js";
5
5
  export { followDurableRun } from "./durable-run-subscription.js";
6
6
  export type { DurableRunBinding, DurableSubscriptionPorts } from "./durable-run-subscription.js";
7
- export { activeRunStub, messageWriteKey, persistableMessages } from "./messages.js";
7
+ export { activeRunStub, messageWriteKey, persistableMessages, createConditionalHistoryRead } from "./messages.js";
8
8
  export type { AssistantConversation } from "./messages.js";
9
9
  export { createChatAttachmentValidator } from "./attachment-liveness.js";
10
10
  export type { ChatAttachmentValidatorInput, ChatAttachmentValidatorOptions } from "./attachment-liveness.js";
@@ -1,7 +1,7 @@
1
1
  /** Daemon/BYOK/AG-UI chat client lifecycles. Host routes, identity and authenticated I/O are ports. */
2
2
  export { createAssistantTransportClient } from "./transport.js";
3
3
  export { followDurableRun } from "./durable-run-subscription.js";
4
- export { activeRunStub, messageWriteKey, persistableMessages } from "./messages.js";
4
+ export { activeRunStub, messageWriteKey, persistableMessages, createConditionalHistoryRead } from "./messages.js";
5
5
  export { createChatAttachmentValidator } from "./attachment-liveness.js";
6
6
  export { createA2uiActionPoster } from "./a2ui-action-poster.js";
7
7
  //# sourceMappingURL=entry.js.map
@@ -70,4 +70,25 @@ export declare function messageWriteKey({ message }: {
70
70
  export declare function activeRunStub({ messages }: {
71
71
  messages: ChatMessage[];
72
72
  }, _options?: Record<string, never>): ChatMessage | null;
73
+ /** Retain only the last history response by reference, while reauthorizing every refresh at the
74
+ * server. An unchanged reply reuses it without reading/parsing the whole conversation again.
75
+ * @param required.read Host HTTP adapter; version is opaque and scoped to one conversation.
76
+ * @returns An async history reader. Port failures clear its snapshot and propagate.
77
+ * @throws If an adapter returns unchanged without an earlier snapshot (a port contract violation).
78
+ * @complexity O(1) local work; one retained snapshot reference, with no message/event copies.
79
+ * @example const load = createConditionalHistoryRead({ read }); await load({ conversationId });
80
+ */
81
+ export declare function createConditionalHistoryRead({ read }: {
82
+ read: (required: {
83
+ conversationId: string;
84
+ }, optional: {
85
+ version?: string | undefined;
86
+ }) => Promise<{
87
+ messages?: ChatMessage[];
88
+ version?: string | undefined;
89
+ unchanged?: boolean;
90
+ }>;
91
+ }, _options?: {}): ({ conversationId }: {
92
+ conversationId: string;
93
+ }, _optional?: {}) => Promise<ChatMessage[]>;
73
94
  //# sourceMappingURL=messages.d.ts.map
@@ -65,4 +65,35 @@ export function activeRunStub({ messages }, _options = {}) {
65
65
  return null;
66
66
  return isTerminalRunStatus({ status: last.runStatus }) ? null : last;
67
67
  }
68
+ /** Retain only the last history response by reference, while reauthorizing every refresh at the
69
+ * server. An unchanged reply reuses it without reading/parsing the whole conversation again.
70
+ * @param required.read Host HTTP adapter; version is opaque and scoped to one conversation.
71
+ * @returns An async history reader. Port failures clear its snapshot and propagate.
72
+ * @throws If an adapter returns unchanged without an earlier snapshot (a port contract violation).
73
+ * @complexity O(1) local work; one retained snapshot reference, with no message/event copies.
74
+ * @example const load = createConditionalHistoryRead({ read }); await load({ conversationId });
75
+ */
76
+ export function createConditionalHistoryRead({ read }, _options = {}) {
77
+ let saved;
78
+ return async ({ conversationId }, _optional = {}) => {
79
+ const previous = saved?.conversationId === conversationId ? saved : undefined;
80
+ try {
81
+ const result = await read({ conversationId }, { version: previous?.version });
82
+ if (result.unchanged) {
83
+ if (!previous)
84
+ throw new Error('Unchanged history requires an earlier snapshot');
85
+ return previous.messages;
86
+ }
87
+ // Same coercion/reason as a conversation list: initialMessages must never be undefined.
88
+ // A successful transport guarantees JSON syntax, not the response object's field shape.
89
+ const messages = Array.isArray(result.messages) ? result.messages : [];
90
+ saved = { conversationId, messages, version: result.version };
91
+ return messages;
92
+ }
93
+ catch (error) {
94
+ saved = undefined;
95
+ throw error;
96
+ }
97
+ };
98
+ }
68
99
  //# sourceMappingURL=messages.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Select the checkpoint delay for an approximate saved-projection size.
3
+ * @param required.bytes Approximate payload size in bytes; thresholds use decimal MB.
4
+ * @returns 1 s below 1 MB, 3 s from 1 MB, and 10 s from 5 MB. Pure; no side effects.
5
+ * @complexity O(1) time and space.
6
+ */
7
+ export declare function checkpointIntervalMs({ bytes }: {
8
+ bytes: number;
9
+ }, _options?: {}): number;
10
+ /** Count durable progress without materializing the saved projection. Thinking and extension
11
+ * receipts must survive a crash even before the next answer/tool. Status chatter and duplicate
12
+ * tool echoes must not trigger another multi-MB compaction.
13
+ * @param required.events Current renderable projection; never mutated.
14
+ * @returns Text/thinking characters, extension count and unique tool identities.
15
+ * @complexity O(events) time and O(unique tool identities) space.
16
+ * @example checkpointProgress({ events: [{ kind: 'thinking', text: 'Working' }] });
17
+ */
18
+ export declare function checkpointProgress({ events }: {
19
+ events: readonly import('./events.js').AgentEvent[];
20
+ }, _options?: {}): {
21
+ textCharacters: number;
22
+ extensionEvents: number;
23
+ tools: Set<string>;
24
+ };
25
+ //# sourceMappingURL=checkpoint-interval.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Select the checkpoint delay for an approximate saved-projection size.
3
+ * @param required.bytes Approximate payload size in bytes; thresholds use decimal MB.
4
+ * @returns 1 s below 1 MB, 3 s from 1 MB, and 10 s from 5 MB. Pure; no side effects.
5
+ * @complexity O(1) time and space.
6
+ */
7
+ export function checkpointIntervalMs({ bytes }, _options = {}) {
8
+ if (bytes >= 5_000_000)
9
+ return 10_000;
10
+ if (bytes >= 1_000_000)
11
+ return 3_000;
12
+ return 1_000;
13
+ }
14
+ /** Count durable progress without materializing the saved projection. Thinking and extension
15
+ * receipts must survive a crash even before the next answer/tool. Status chatter and duplicate
16
+ * tool echoes must not trigger another multi-MB compaction.
17
+ * @param required.events Current renderable projection; never mutated.
18
+ * @returns Text/thinking characters, extension count and unique tool identities.
19
+ * @complexity O(events) time and O(unique tool identities) space.
20
+ * @example checkpointProgress({ events: [{ kind: 'thinking', text: 'Working' }] });
21
+ */
22
+ export function checkpointProgress({ events }, _options = {}) {
23
+ let textCharacters = 0;
24
+ let extensionEvents = 0;
25
+ const tools = new Set();
26
+ for (const event of events) {
27
+ if (event.kind === 'text' || event.kind === 'thinking')
28
+ textCharacters += event.text.length;
29
+ if (event.kind === 'ext')
30
+ extensionEvents++;
31
+ if (event.kind === 'tool_use')
32
+ tools.add(`tool_use:${event.id}`);
33
+ if (event.kind === 'tool_result')
34
+ tools.add(`tool_result:${event.toolUseId}`);
35
+ }
36
+ return { textCharacters, extensionEvents, tools };
37
+ }
38
+ //# sourceMappingURL=checkpoint-interval.js.map
@@ -20,8 +20,34 @@ export interface ChatRunRecoveryOptions {
20
20
  * Called outside storage transactions so a daemon request cannot hold the database lock. */
21
21
  readonly recover?: (input: InterruptedChatTurn) => Promise<boolean>;
22
22
  }
23
+ /** Watch-owned receipt for the last exact incoming projection. SQL lengths count characters,
24
+ * not UTF-16 code units. A saved-only tool barrier invalidates this receipt. */
25
+ export interface CheckpointState {
26
+ /** Native row/connection version guards equal-length changes outside the bounded suffix. */
27
+ readonly version: string;
28
+ readonly length: number;
29
+ /** SQL character count, avoiding a second full content parameter in CAS. */
30
+ readonly contentLength: number;
31
+ /** SQL characters in the complete last serialized event plus closing array bracket. */
32
+ readonly tailLength: number;
33
+ readonly content: string;
34
+ readonly events: readonly import("../events.js").AgentEvent[];
35
+ /** At most 256 UTF-16 units of the saved suffix; never the whole last tool result. */
36
+ readonly tail: string;
37
+ }
38
+ export interface CheckpointOptions {
39
+ readonly expected?: number | undefined;
40
+ readonly previous?: CheckpointState | undefined;
41
+ /** Undefined means the authoritative merge retained saved-only events: use the full path next. */
42
+ readonly onPersisted?: (state: CheckpointState | undefined) => void;
43
+ }
23
44
  export interface ChatRunLedger {
24
45
  readonly durable?: DurableRunStore;
46
+ /** Read-only existence probe across all owners. Only queued/running assistant rows block idle
47
+ * shutdown; no transcript payloads, recovery or writes. Rejects on storage failure (host stays busy).
48
+ * @example if (await ledger.hasActiveRuns({}, {})) return;
49
+ */
50
+ hasActiveRuns(required?: {}, optional?: {}): Promise<boolean>;
25
51
  /**
26
52
  * Runs `write` only while `run`'s row holds no terminal status (an unknown row, another run id, or
27
53
  * a row still `queued`/`running` all count as unsettled), with no settle able to land in between.
@@ -43,7 +69,7 @@ export interface ChatRunLedger {
43
69
  * A no-op once the row is terminal or belongs to another run. Resolves `true` when it wrote, or
44
70
  * when the row already held exactly this checkpoint (accepted without rewriting the row).
45
71
  */
46
- checkpoint(progress: RunProgress, optional?: {}): Promise<boolean>;
72
+ checkpoint(progress: RunProgress, optional?: CheckpointOptions): Promise<boolean>;
47
73
  /** Serving-only discovery of unfinished rows. The injected recovery coordinator owns adopted
48
74
  * runs; rows it cannot adopt are canceled, preserving saved work. Returns handled rows.
49
75
  * Export apps must not invoke discovery while a serving daemon is still alive. */
@@ -27,10 +27,34 @@ export interface DurableRun extends RunRef {
27
27
  /** A run without its conversation transcript: what a fence, lock or tool guard reads. `message`
28
28
  * still carries the run's own saved events; only `load`/`accept`/`cancelPending` read every message. */
29
29
  export type DurableRunHead = Omit<DurableRun, "transcript">;
30
+ /** Raw current-message projection. An absent messageJson means the cursor is unchanged. */
31
+ export interface DurableRunPoll {
32
+ readonly cursor: string;
33
+ readonly conversationId?: string;
34
+ readonly messageJson?: string;
35
+ }
30
36
  export interface DurableRunStore {
37
+ /** Owner-scoped, unlocked poll. No transcript read or event parse; unchanged omits payloads. */
38
+ poll?(required: {
39
+ messageId: string;
40
+ principalId: string;
41
+ workspaceId: string;
42
+ }, optional: {
43
+ cursor?: string;
44
+ }): Promise<DurableRunPoll | null>;
45
+ /** `includeTranscriptEvents:false` omits prior assistant payloads, keeping current-run events
46
+ * and user receipts that preserve queued inputs and folder references. Defaults to a full read. */
31
47
  load(required: {
32
48
  messageId: string;
33
- }, optional: {}): Promise<DurableRun | null>;
49
+ }, optional: {
50
+ includeTranscriptEvents?: boolean;
51
+ }): Promise<DurableRun | null>;
52
+ /** Follow the current attempt by message id without loading its conversation. */
53
+ head?(required: {
54
+ messageId: string;
55
+ }, optional: {
56
+ events?: boolean;
57
+ }): Promise<DurableRunHead | null>;
34
58
  /** Reads only this run's message and attempt rows; `load` when the transcript is needed. */
35
59
  find(required: {
36
60
  runId: string;
@@ -39,4 +39,5 @@ export { turnUsage, conversationUsage, replySegments, isReplyBoundary, MID_RUN_U
39
39
  export { formatUsageCost } from './usage-format.js';
40
40
  export * from './accepted-input.js';
41
41
  export * from './question-answer.js';
42
+ export { checkpointIntervalMs, checkpointProgress } from './checkpoint-interval.js';
42
43
  //# sourceMappingURL=index.d.ts.map
@@ -38,4 +38,5 @@ export { turnUsage, conversationUsage, replySegments, isReplyBoundary, MID_RUN_U
38
38
  export { formatUsageCost } from './usage-format.js';
39
39
  export * from './accepted-input.js';
40
40
  export * from './question-answer.js';
41
+ export { checkpointIntervalMs, checkpointProgress } from './checkpoint-interval.js';
41
42
  //# sourceMappingURL=index.js.map
@@ -14,9 +14,12 @@ import type { AgentEvent } from './events.js';
14
14
  * `k` is the number of distinct `tool_use` ids seen — one linear pass with
15
15
  * a `Set` membership check.
16
16
  */
17
- export declare function dedupeToolUsesById({ events }: {
17
+ export declare function dedupeToolUsesById(required: {
18
18
  events: AgentEvent[] | undefined;
19
- }): AgentEvent[];
19
+ }, optional?: {}): AgentEvent[];
20
+ export declare function dedupeToolUsesById(required: {
21
+ events: readonly AgentEvent[] | undefined;
22
+ }, optional?: {}): readonly AgentEvent[];
20
23
  /**
21
24
  * Whether `name` is a delegated-tool transport wrapper — `execute_delegated_tool` or
22
25
  * `execute_readonly_delegated_tool`, bare or as an MCP client names it (`mcp__<server>__…`). Its real
@@ -1,21 +1,6 @@
1
1
  import { canonicalJson } from '@jini-ai/core/records';
2
2
  import { toolPresentation } from './tool-presentation.js';
3
- /**
4
- * Drop repeated `tool_use` events that share an `id`. A retried/duplicated
5
- * stream can replay the same tool-call id (e.g. reconnect-and-replay landing
6
- * an event twice); rendering it twice would show a phantom second call.
7
- * Non-`tool_use` events (and the first occurrence of each id) pass through
8
- * unchanged and in original order.
9
- *
10
- * @param events - The event list to dedupe, or `undefined` for an empty conversation turn.
11
- * @returns A new array with later duplicate `tool_use` events removed, or the
12
- * original array reference when nothing needed to change (cheap no-op for
13
- * the common case).
14
- * @complexity O(n) time / O(k) extra space, where `n` is `events.length` and
15
- * `k` is the number of distinct `tool_use` ids seen — one linear pass with
16
- * a `Set` membership check.
17
- */
18
- export function dedupeToolUsesById({ events }) {
3
+ export function dedupeToolUsesById({ events }, _optional) {
19
4
  if (!events || events.length === 0)
20
5
  return [];
21
6
  const seen = new Set();
@@ -30,9 +30,9 @@ export interface ComposerProps {
30
30
  /** Cancels the in-flight run. Required when `running` is true; ignored otherwise. */
31
31
  onCancel?: () => void;
32
32
  /**
33
- * Cmd/Ctrl+Enter: end the run in flight and send this draft next. Optional — without it the
34
- * modifier falls through to the ordinary Enter path, so a host that has not opted in keeps
35
- * exactly its previous behavior.
33
+ * Cmd/Ctrl+Enter: optional host send shortcut. ChatPane shares the ordinary mid-run delivery
34
+ * path so sending keeps the current run going. Without this callback the modifier falls
35
+ * through to ordinary Enter, so a host that has not opted in keeps its previous behavior.
36
36
  */
37
37
  onInterrupt?: () => void;
38
38
  /**
@@ -273,9 +273,9 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
273
273
  function submitFromEnter(event) {
274
274
  if (disabled || !composer.canSubmit)
275
275
  return;
276
- // Cmd/Ctrl+Enter ends the current run and sends this next; plain Enter queues behind it.
277
- // `sendDisabled` is deliberately NOT consulted on the interrupt path — while a run streams it
278
- // is set precisely BECAUSE of that run, which is the one case this modifier exists to act on.
276
+ // Cmd/Ctrl+Enter uses the host shortcut; ChatPane routes it through the ordinary send guards.
277
+ // `sendDisabled` is deliberately NOT consulted here: hosts can offer a shortcut while a run
278
+ // itself blocks ordinary sends. ChatPane queues or delivers live without cancelling the run.
279
279
  if ((event.metaKey || event.ctrlKey) && onInterrupt) {
280
280
  onInterrupt();
281
281
  return;
@@ -20,9 +20,9 @@ import { MessageRow } from './MessageRow.js';
20
20
  import { SentAttachmentButton } from './SentAttachment.js';
21
21
  import { AttachmentPreviewModal } from './AttachmentPreviewModal.js';
22
22
  import { useAttachmentGallery } from './AttachmentGallery.hooks.js';
23
- function renderMessageRow({ message, isLast, isStreaming, usageSummary, sending }, { activeQuestionFormMessageId, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, ...rowProps } = {}) {
23
+ function renderMessageRow({ message, isLast, isStreaming, usageSummary, sending, showAccessNotice }, { activeQuestionFormMessageId, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, ...rowProps } = {}) {
24
24
  const runStreaming = isLast && isStreaming && !isTerminalRunStatus({ status: message.runStatus });
25
- return (_jsx(MessageRow, { message: message, ...rowProps, ...(usageSummary === undefined ? {} : { usageSummary }), runStreaming: runStreaming, runSending: sending, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}) }, message.id));
25
+ return (_jsx(MessageRow, { message: message, ...rowProps, showAccessNotice: showAccessNotice, ...(usageSummary === undefined ? {} : { usageSummary }), runStreaming: runStreaming, runSending: sending, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}) }, message.id));
26
26
  }
27
27
  /**
28
28
  * Memoized: the pane re-renders on every composer keystroke, and re-rendering a long transcript
@@ -33,12 +33,17 @@ function renderMessageRow({ message, isLast, isStreaming, usageSummary, sending
33
33
  export const MessageList = memo(function MessageList({ messages: sourceMessages, isStreaming = false, scrollIntent = false, onScrolled, pendingPrompt, pendingPrompts, ...rowProps }) {
34
34
  const messages = useMemo(() => projectAcceptedInputMessages({ messages: sourceMessages }), [sourceMessages]);
35
35
  const usageByMessage = useConversationUsage({ messages });
36
+ // Derive from the conversation's saved events, so replay/remount does not repeat the notice.
37
+ const accessNoticeId = useMemo(() => messages.find(message => message.role === 'assistant'
38
+ && message.events?.some(event => event.kind === 'status' && event.label === 'full_cli_access' && event.detail))?.id, [messages]);
36
39
  const { containerRef, onScroll, awayFromLatest, jumpToLatest } = useMessageListAutoScroll({ messages, scrollIntent }, onScrolled !== undefined ? { onScrolled } : {});
37
40
  return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: onScroll, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message, index) => renderMessageRow({
38
41
  message,
42
+ showAccessNotice: message.id === accessNoticeId,
39
43
  isLast: message.id === messages[messages.length - 1]?.id,
40
44
  // A reply's start is accepted together with its user turn (`inputPending` clears then).
41
- sending: messages[index - 1]?.inputPending === true,
45
+ // A run id also proves acceptance after reload, when a legacy pending flag can survive.
46
+ sending: message.runId === undefined && messages[index - 1]?.inputPending === true,
42
47
  isStreaming,
43
48
  usageSummary: usageByMessage.get(message.id),
44
49
  }, rowProps)), (pendingPrompts ?? (pendingPrompt ? [pendingPrompt] : [])).map((prompt, index) => (_jsx(QueuedMessage, { prompt: prompt, renderAttachment: rowProps.renderAttachment }, prompt.id ?? index))), awayFromLatest && _jsx(JumpToLatest, { onJump: jumpToLatest })] }));
@@ -54,6 +54,8 @@ import type { ChatAttachment, ChatMessage } from '../../core/index.js';
54
54
  import { type ToolCallDisplay } from '../tool-call-display.js';
55
55
  export interface MessageRowProps {
56
56
  message: ChatMessage;
57
+ /** Conversation hosts show the first access status only; standalone rows retain it by default. */
58
+ showAccessNotice?: boolean;
57
59
  /** Per-turn charge and prefix total derived from the complete conversation's persisted events. */
58
60
  usageSummary?: ChatUsageSummary;
59
61
  /** Append the chat's running total as of each reply ("· total $3.95") to its Done line. On by default
@@ -147,7 +147,7 @@ function RunAccessNotice({ events }) {
147
147
  const notice = events?.find(event => event.kind === 'status' && event.label === 'full_cli_access');
148
148
  if (notice?.kind !== 'status' || !notice.detail)
149
149
  return null;
150
- return _jsx("div", { className: "jini-chat-pane__notice", role: "note", children: t(notice.detail) });
150
+ return _jsx("div", { className: "jini-chat-pane__notice", role: "status", children: t(notice.detail) });
151
151
  }
152
152
  function ThinkingBlock({ text }) {
153
153
  const t = useT();
@@ -187,13 +187,16 @@ function UserMessageRow({ message, renderAttachment }) {
187
187
  const gallery = useAttachmentGallery({ attachments: message.attachments ?? NO_ATTACHMENTS });
188
188
  return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [(message.attachments?.length || message.folders?.length) ? (_jsxs("div", { className: "jini-message-attachments", children: [_jsx(FolderChips, { folders: message.folders }), (message.attachments ?? []).map((a) => (_jsx(SentAttachmentButton, { attachment: a, onOpen: gallery.open, renderAttachment: renderAttachment }, a.path)))] })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), gallery.opened ? (_jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })) : null] }));
189
189
  }
190
- function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageLine, toolCallDisplay, showThinking, runStreaming, runSending, sendingLabel, runSucceeded, questionFormInteractive, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, }) {
190
+ function AssistantMessageRow({ showAccessNotice = true, message, usageSummary, showUsageTotal, showUsageLine, toolCallDisplay, showThinking, runStreaming, runSending, sendingLabel, runSucceeded, questionFormInteractive, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, }) {
191
191
  const registry = useExtEventRendererRegistry();
192
192
  const messageUsage = useMessageUsage({ message, ...(usageSummary === undefined ? {} : { summary: usageSummary }) });
193
193
  const timeline = useToolTimeline({ events: message.events }, { runStreaming, runSucceeded });
194
194
  const extGroups = useExtEventGroups({ events: message.events }, { slotOf: (name, data) => registry.slot({ name, data }) });
195
195
  const answeredCards = useAnsweredCards({ slotOf: registry.slot, events: message.events ?? [], groups: extGroups, runStreaming, runStatus: message.runStatus });
196
- const terminalNotice = terminalMessageNotice({ message }, {});
196
+ // A validated extension may already show the explanation. Keep the generic fallback when its
197
+ // renderer is absent or refuses the payload, so malformed history never hides a failed turn.
198
+ const extensionExplainsFailure = !runStreaming && !runSucceeded && extGroups.some(group => registry.hasTerminalNotice(group));
199
+ const terminalNotice = extensionExplainsFailure ? null : terminalMessageNotice({ message }, {});
197
200
  const visibleContent = stripArtifact({ content: message.content });
198
201
  const displayBlocks = messageBlocks({ message, rows: timeline.rows, registry }, { toolCallDisplay, includeThinking: showThinking });
199
202
  // Artifact stripping trims/replaces text, invalidating the event offsets for rendering. Keep the
@@ -286,11 +289,11 @@ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageL
286
289
  // in their original receipt order, outside the assistant's actions and usage footer.
287
290
  // Keep the same child-array nesting as the interleaved path so React preserves the footer.
288
291
  if (blocks === null)
289
- return _jsxs(_Fragment, { children: [_jsx(RunAccessNotice, { events: message.events }), [renderAssistantSection({ section: null, key: message.id, footer: true })], extGroups.filter(group => group.name === MID_RUN_USER_MESSAGE_EXT_EVENT_NAME).map(renderExtGroup)] });
292
+ return _jsxs(_Fragment, { children: [showAccessNotice && _jsx(RunAccessNotice, { events: message.events }), [renderAssistantSection({ section: null, key: message.id, footer: true })], extGroups.filter(group => group.name === MID_RUN_USER_MESSAGE_EXT_EVENT_NAME).map(renderExtGroup)] });
290
293
  // A reply boundary without reported usage renders nothing; it must not create an empty row's gap.
291
294
  const visibleBlocks = blocks.filter(block => block.kind !== 'reply-end' || (showUsageLine && usage.replies[block.index] !== undefined));
292
295
  const { sections, footerIndex } = transcriptSections({ blocks: visibleBlocks, runInProgress });
293
- return _jsxs(_Fragment, { children: [_jsx(RunAccessNotice, { events: message.events }), sections.map((section, index) => isUserSection({ section })
296
+ return _jsxs(_Fragment, { children: [showAccessNotice && _jsx(RunAccessNotice, { events: message.events }), sections.map((section, index) => isUserSection({ section })
294
297
  ? _jsx(React.Fragment, { children: section.map(renderBlock) }, section[0].key)
295
298
  : renderAssistantSection({ section, key: section[0]?.key ?? message.id, footer: index === footerIndex }))] });
296
299
  }