@jini-ai/chat 0.5.6 → 0.5.8

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 (127) hide show
  1. package/dist/core/ag-ui/entry.js +2 -0
  2. package/dist/core/assistant-client/durable-run-subscription.d.ts +4 -1
  3. package/dist/core/assistant-client/durable-run-subscription.js +13 -4
  4. package/dist/core/durable-projection.d.ts +3 -1
  5. package/dist/core/durable-projection.js +40 -7
  6. package/dist/core/events.d.ts +3 -0
  7. package/dist/core/history-tool-metadata.d.ts +23 -0
  8. package/dist/core/history-tool-metadata.js +13 -0
  9. package/dist/core/index.d.ts +4 -0
  10. package/dist/core/index.js +4 -0
  11. package/dist/core/persistence/title.d.ts +24 -0
  12. package/dist/core/persistence/title.js +60 -9
  13. package/dist/core/run-activity.d.ts +15 -2
  14. package/dist/core/run-activity.js +6 -4
  15. package/dist/core/run-events/entry.d.ts +12 -1
  16. package/dist/core/run-events/entry.js +19 -6
  17. package/dist/core/tool-call-detail.d.ts +51 -0
  18. package/dist/core/tool-call-detail.js +67 -0
  19. package/dist/core/turn-usage.d.ts +50 -0
  20. package/dist/core/turn-usage.js +114 -0
  21. package/dist/core/usage-format.d.ts +10 -0
  22. package/dist/core/usage-format.js +15 -0
  23. package/dist/embed-widget/browser/lazy-loader.d.ts +34 -0
  24. package/dist/embed-widget/browser/lazy-loader.js +116 -0
  25. package/dist/embed-widget/react/dialog.hooks.d.ts +28 -0
  26. package/dist/embed-widget/react/dialog.hooks.js +54 -0
  27. package/dist/embed-widget/react/embed.d.ts +1 -0
  28. package/dist/embed-widget/react/embed.js +5 -3
  29. package/dist/embed-widget/react/header.d.ts +3 -1
  30. package/dist/embed-widget/react/header.hooks.d.ts +13 -0
  31. package/dist/embed-widget/react/header.hooks.js +32 -0
  32. package/dist/embed-widget/react/header.js +4 -14
  33. package/dist/embed-widget/vanilla/index.d.ts +63 -0
  34. package/dist/embed-widget/vanilla/index.js +319 -0
  35. package/dist/embed-widget/vanilla/state.d.ts +48 -0
  36. package/dist/embed-widget/vanilla/state.js +120 -0
  37. package/dist/embed-widget/vanilla/style.css +29 -0
  38. package/dist/react/chat-pane.d.ts +2 -2
  39. package/dist/react/chat-pane.js +1 -1
  40. package/dist/react/components/A2uiSurfaceCard.d.ts +5 -1
  41. package/dist/react/components/A2uiSurfaceCard.js +9 -9
  42. package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +7 -0
  43. package/dist/react/components/AttachmentPreviewModal.hooks.js +1 -1
  44. package/dist/react/components/ChatFab.d.ts +4 -1
  45. package/dist/react/components/ChatFab.js +2 -2
  46. package/dist/react/components/Composer.d.ts +2 -0
  47. package/dist/react/components/Composer.js +5 -2
  48. package/dist/react/components/ConversationList.d.ts +8 -1
  49. package/dist/react/components/ConversationList.hooks.d.ts +18 -0
  50. package/dist/react/components/ConversationList.hooks.js +25 -0
  51. package/dist/react/components/ConversationList.js +12 -5
  52. package/dist/react/components/ConversationList.rules.d.ts +25 -0
  53. package/dist/react/components/ConversationList.rules.js +19 -0
  54. package/dist/react/components/Markdown.js +6 -6
  55. package/dist/react/components/McpUiSurfaceCard.d.ts +1 -1
  56. package/dist/react/components/McpUiSurfaceCard.js +8 -4
  57. package/dist/react/components/MessageList.d.ts +8 -13
  58. package/dist/react/components/MessageList.hooks.d.ts +4 -0
  59. package/dist/react/components/MessageList.hooks.js +6 -1
  60. package/dist/react/components/MessageList.js +23 -4
  61. package/dist/react/components/MessageRow.d.ts +23 -1
  62. package/dist/react/components/MessageRow.js +61 -36
  63. package/dist/react/components/MessageRow.usage.d.ts +48 -0
  64. package/dist/react/components/MessageRow.usage.js +47 -0
  65. package/dist/react/components/MidRunUserMessage.d.ts +17 -0
  66. package/dist/react/components/MidRunUserMessage.js +41 -0
  67. package/dist/react/components/SentAttachment.d.ts +19 -0
  68. package/dist/react/components/SentAttachment.hooks.d.ts +44 -0
  69. package/dist/react/components/SentAttachment.hooks.js +56 -0
  70. package/dist/react/components/SentAttachment.js +14 -0
  71. package/dist/react/components/ToolCallGroup.d.ts +12 -0
  72. package/dist/react/components/ToolCallGroup.hooks.d.ts +20 -0
  73. package/dist/react/components/ToolCallGroup.hooks.js +23 -0
  74. package/dist/react/components/ToolCallGroup.js +13 -0
  75. package/dist/react/components/ToolCard.js +41 -22
  76. package/dist/react/components/mcp-ui-surface-state.d.ts +21 -0
  77. package/dist/react/components/mcp-ui-surface-state.js +26 -0
  78. package/dist/react/ext-event-renderer-registry.d.ts +7 -0
  79. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  80. package/dist/react/features/chat-pane/components/ChatPane.js +20 -7
  81. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +44 -0
  82. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +69 -0
  83. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +4 -1
  84. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +65 -10
  85. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.d.ts +25 -0
  86. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +70 -0
  87. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +4 -0
  88. package/dist/react/features/chat-pane/hooks/useChatPaneSuggestion.hooks.d.ts +9 -0
  89. package/dist/react/features/chat-pane/hooks/useChatPaneSuggestion.hooks.js +11 -0
  90. package/dist/react/features/chat-pane/hooks/useChatPaneSurface.hooks.d.ts +17 -0
  91. package/dist/react/features/chat-pane/hooks/useChatPaneSurface.hooks.js +15 -0
  92. package/dist/react/features/chat-pane/index.d.ts +2 -1
  93. package/dist/react/features/chat-pane/index.js +1 -0
  94. package/dist/react/features/chat-pane/rules.d.ts +27 -0
  95. package/dist/react/features/chat-pane/rules.js +32 -0
  96. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  97. package/dist/react/features/chat-pane/styles.js +92 -1
  98. package/dist/react/features/chat-pane/types.d.ts +39 -1
  99. package/dist/react/hooks/composer-draft-cache.d.ts +15 -0
  100. package/dist/react/hooks/composer-draft-cache.js +20 -0
  101. package/dist/react/hooks/useAnsweredCards.d.ts +20 -0
  102. package/dist/react/hooks/useAnsweredCards.js +23 -0
  103. package/dist/react/hooks/useComposer.d.ts +14 -0
  104. package/dist/react/hooks/useComposer.js +78 -6
  105. package/dist/react/hooks/useConversation.d.ts +8 -0
  106. package/dist/react/hooks/useConversation.js +34 -0
  107. package/dist/react/hooks/useRunActivity.d.ts +5 -3
  108. package/dist/react/hooks/useRunActivity.js +4 -3
  109. package/dist/react/index.d.ts +8 -2
  110. package/dist/react/index.js +6 -1
  111. package/dist/react/message-blocks.d.ts +22 -3
  112. package/dist/react/message-blocks.js +25 -3
  113. package/dist/react/tool-call-display.d.ts +90 -0
  114. package/dist/react/tool-call-display.js +97 -0
  115. package/dist/server/durable-runs/recover.js +19 -11
  116. package/dist/server/run-finalizer/entry.js +2 -1
  117. package/dist/store/ports.d.ts +25 -2
  118. package/dist/store/runtime/durable-run-store.js +5 -2
  119. package/dist/store/runtime/entry.d.ts +1 -0
  120. package/dist/store/runtime/entry.js +1 -0
  121. package/dist/store/runtime/history-tools.d.ts +27 -0
  122. package/dist/store/runtime/history-tools.js +169 -0
  123. package/dist/store/runtime/run-ledger.js +5 -2
  124. package/dist/store/sql/store.js +52 -0
  125. package/dist/testing/index.d.ts +7 -0
  126. package/dist/testing/index.js +22 -0
  127. package/package.json +48 -18
@@ -6,7 +6,7 @@ export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE
6
6
  /** Retryability is independent of session resumability. Inspect executor diagnostics, never
7
7
  * answer text or tool results: those can quote unrelated failures from completed work. Old
8
8
  * attempt diagnostics remain visible but cannot decide the fate of their continuation. */
9
- function attemptFailure(run) {
9
+ function attemptDiagnostics({ run }, _optional = {}) {
10
10
  const events = run.message.events ?? [];
11
11
  // Checkpoints merge adjacent text, so a base's event count is not a durable offset. The
12
12
  // coordinator's recovery marker separates attempts and survives that compaction.
@@ -19,7 +19,10 @@ function attemptFailure(run) {
19
19
  break;
20
20
  }
21
21
  }
22
- const diagnostics = events.slice(marker >= 0 ? marker + 1 : run.attemptBase.length).flatMap((event) => event.kind === "status" ? [event.label, event.detail ?? ""] : event.kind === "raw" ? [event.line] : []).join("\n");
22
+ return events.slice(marker >= 0 ? marker + 1 : run.attemptBase.length).flatMap((event) => event.kind === "status" ? [event.label, event.detail ?? ""] : event.kind === "raw" ? [event.line] : []).join("\n");
23
+ }
24
+ function attemptFailure(run) {
25
+ const diagnostics = attemptDiagnostics({ run }, {});
23
26
  if (/inactivity[_ -]?(timeout|watchdog)|watchdog.*(kill|stop)/i.test(diagnostics))
24
27
  return "inactivity-watchdog";
25
28
  const service = classifyAgentServiceFailure({ text: diagnostics });
@@ -34,13 +37,11 @@ function attemptFailure(run) {
34
37
  return "permanent";
35
38
  return "retryable";
36
39
  }
37
- function exclusion(run, failure) {
40
+ function exclusion(run) {
38
41
  if (run.cancelReason)
39
42
  return run.cancelReason;
40
43
  if (!run.principalId || run.engine !== "daemon")
41
44
  return "excluded";
42
- if (failure === "auth-failure" || failure === "inactivity-watchdog")
43
- return failure;
44
45
  return null;
45
46
  }
46
47
  function budgetNotice(run, now) {
@@ -71,12 +72,15 @@ export function createDurableRecovery(ports, _optional = {}) {
71
72
  }
72
73
  async function continueDead(run) {
73
74
  await ports.cancelAttempt(run, {}).catch(() => undefined);
74
- const native = await nativeAllowed(run);
75
+ // A locator captured before a hard cancel can outlive its rollout. Reuse reconstruction,
76
+ // including prior turns/tool outcomes, instead of retrying a known missing native session.
77
+ const missingResume = run.request.agentId === 'codex' && /no rollout found for thread id/.test(attemptDiagnostics({ run }, {}));
78
+ const native = !missingResume && await nativeAllowed(run);
75
79
  const nextRunId = ports.mintRunId();
76
80
  const saved = [...(run.message.events ?? [])];
77
81
  if (!saved.some((event) => event.kind === "text") && run.message.content)
78
82
  saved.unshift({ kind: "text", text: run.message.content });
79
- const events = recoveredRunEvents({ saved }, {});
83
+ const events = recoveredRunEvents({ saved }, { notice: missingResume ? 'silent' : 'visible' });
80
84
  if (!await ports.store.advance({ run, nextRunId, now: ports.now(), events }, {}))
81
85
  return "superseded";
82
86
  const next = await ports.store.load({ messageId: run.messageId }, {});
@@ -98,10 +102,8 @@ export function createDurableRecovery(ports, _optional = {}) {
98
102
  return "continued";
99
103
  }
100
104
  async function decide(run, trigger, liveRunId) {
101
- const failure = attemptFailure(run);
102
- const reason = exclusion(run, failure);
103
- if (reason)
104
- return finalize(run, reason === "auth-failure" ? "Not logged in. Saved work is above." : "Stopped. Saved work is above.", true);
105
+ if (exclusion(run))
106
+ return finalize(run, "Stopped. Saved work is above.", true);
105
107
  // A terminal executor end confirms this attempt is dead even when the daemon retains its
106
108
  // replayable record (HTTP 200). Other triggers must obtain fresh liveness evidence.
107
109
  const probe = trigger === "attempt-failed" ? "dead" : liveRunId === run.runId ? "live" : await ports.probe(run, {});
@@ -110,6 +112,12 @@ export function createDurableRecovery(ports, _optional = {}) {
110
112
  ports.attach(run, {});
111
113
  return "reattached";
112
114
  }
115
+ // Diagnostics decide only the fate of an attempt that is no longer running. Read before the
116
+ // probe, raw stdout quoting a tool description ("OAuth-authenticated") canceled a live turn
117
+ // as "Not logged in" (luvira, 2026-10-08).
118
+ const failure = attemptFailure(run);
119
+ if (failure === "auth-failure" || failure === "inactivity-watchdog")
120
+ return finalize(run, failure === "auth-failure" ? "Not logged in. Saved work is above." : "Stopped. Saved work is above.", true);
113
121
  if (failure === "permanent")
114
122
  return finalize(run, EXHAUSTED_NOTICE);
115
123
  return resolveMissing(run, probe, trigger);
@@ -86,8 +86,9 @@ export function createAssistantRunFinalizer(args, options = {}) {
86
86
  // Each connection replays the complete history, rather than appending duplicate deltas.
87
87
  watch.events = [];
88
88
  watch.failed = false;
89
+ const carry = {};
89
90
  for await (const frame of readSseFrames({ body: response.body })) {
90
- const outcome = translateRunFrame({ kind: frame.event, raw: frame.data, notices: args.notices });
91
+ const outcome = translateRunFrame({ kind: frame.event, raw: frame.data, notices: args.notices }, { carry });
91
92
  watch.events.push(...outcome.events);
92
93
  if (outcome.error)
93
94
  watch.failed = true;
@@ -63,6 +63,8 @@ export interface ChatSearchCapability {
63
63
  * @example store.rename({ id, title }, { source: 'generated' }); store.pageConversations({}, { limit: 50 })
64
64
  */
65
65
  export interface ChatStore {
66
+ /** Bounded history discovery/read capability; always uses this store's immutable owner scope. */
67
+ readonly history?: ChatHistoryReader;
66
68
  /** Compatibility read: all owned conversations, updatedAt descending. */
67
69
  list(): Promise<ChatConversation[]>;
68
70
  /** Unknown and other-owned IDs both return null. */
@@ -97,8 +99,29 @@ export interface ChatStore {
97
99
  pageMessages(input: ChatMessagePageInput, options?: ChatPageOptions): Promise<ChatPage<ChatMessage>>;
98
100
  readonly search?: ChatSearchCapability;
99
101
  }
100
- /** Eight-method subset for hosts/fakes without paging; shares the object call contract. */
101
- export type ChatHistoryStore = Pick<ChatStore, 'list' | 'get' | 'create' | 'rename' | 'touch' | 'delete' | 'messages' | 'appendMessage'>;
102
+ /** Eight-method compatibility subset plus optional bounded history; old hosts/fakes still fit. */
103
+ export type ChatHistoryStore = Pick<ChatStore, 'list' | 'get' | 'create' | 'rename' | 'touch' | 'delete' | 'messages' | 'appendMessage' | 'history'>;
104
+ /** Internal persistence projections. Model-facing tools redact and cap these before returning. */
105
+ export interface ChatHistoryReader {
106
+ list(required: Record<string, never>, optional?: {
107
+ query?: string;
108
+ limit?: number;
109
+ }): Promise<readonly (ChatConversation & {
110
+ firstUserLine: string;
111
+ })[]>;
112
+ read(required: {
113
+ chatId: string;
114
+ }, optional?: {
115
+ fromPosition?: number;
116
+ limit?: number;
117
+ includeToolEvents?: boolean;
118
+ }): Promise<{
119
+ messages: readonly (ChatMessage & {
120
+ position: number;
121
+ })[];
122
+ hasMore: boolean;
123
+ }>;
124
+ }
102
125
  /** Authority for cross-owner retention must never reach ordinary request handlers. */
103
126
  export interface ChatHistoryMaintenance {
104
127
  /** Delete at most limit conversations; default 500, integer 1..500. */
@@ -88,8 +88,11 @@ function continuationEvents({ current, incoming }, _optional = {}) {
88
88
  break;
89
89
  }
90
90
  }
91
- if (marker >= 0)
92
- return [...recoveredRunEvents({ saved }, {}), ...incoming.slice(marker + 1)];
91
+ if (marker >= 0) {
92
+ const event = incoming[marker];
93
+ const notice = event.kind === 'status' && event.label === '' ? 'silent' : 'visible';
94
+ return [...recoveredRunEvents({ saved }, { notice }), ...incoming.slice(marker + 1)];
95
+ }
93
96
  return runContentFromEvents({ events: incoming }).length >= current.message.content.length ? incoming : saved;
94
97
  }
95
98
  /** Same kernel and message lock as checkpoint/settle. Claim changes run_id before any spawn;
@@ -4,4 +4,5 @@ export * from "./chat-history-store.js";
4
4
  export * from "./run-guarded-chat-store.js";
5
5
  export type { ChatKernel, ChatDatabase } from "./kernel.js";
6
6
  export * from "./chat-expiry-sweep.js";
7
+ export * from "./history-tools.js";
7
8
  //# sourceMappingURL=entry.d.ts.map
@@ -3,4 +3,5 @@ export * from "./run-ledger.js";
3
3
  export * from "./chat-history-store.js";
4
4
  export * from "./run-guarded-chat-store.js";
5
5
  export * from "./chat-expiry-sweep.js";
6
+ export * from "./history-tools.js";
6
7
  //# sourceMappingURL=entry.js.map
@@ -0,0 +1,27 @@
1
+ /** Model-facing history tools over an authenticated owner-scoped store; no database handles. */
2
+ import { type AgentToolDefinition, type DerivedRiskByToolId, type ToolRegistration } from '@jini-ai/core';
3
+ import { type RedactUserText } from '../../core/user-text-redaction.js';
4
+ import type { ChatHistoryStore } from '../ports.js';
5
+ export declare const HISTORY_MESSAGE_CHAR_CAP = 8000;
6
+ export declare const HISTORY_TOTAL_CHAR_CAP = 40000;
7
+ export declare const assistantHistoryDerivedRisk: DerivedRiskByToolId;
8
+ /** Exported so host contract tests can pin each registration to its catalog entry. */
9
+ export declare const assistantHistoryToolCatalog: ReadonlyMap<string, AgentToolDefinition>;
10
+ /** Auth is resolved by the host using execution identity, never from model input. */
11
+ export interface AssistantHistoryToolPorts {
12
+ getStore(required: {
13
+ principalId: string;
14
+ }, optional?: Record<string, never>): Promise<ChatHistoryStore>;
15
+ }
16
+ /** Build both history registrations, using the existing catalog/risk/metadata contract.
17
+ * @param ports Authenticated scoped-store port; denied/missing history must fail closed.
18
+ * @param options Optional stricter redaction policy (the secret-card policy is the default).
19
+ * @returns Read-only tool registrations. @throws ToolInputError for malformed or refused reads.
20
+ * @complexity Bounded projections; persistence owns search cost and message ordering.
21
+ */
22
+ export declare function buildAssistantHistoryRegistrations({ ports }: {
23
+ ports: AssistantHistoryToolPorts;
24
+ }, { redact }?: {
25
+ redact?: RedactUserText;
26
+ }): ToolRegistration[];
27
+ //# sourceMappingURL=history-tools.d.ts.map
@@ -0,0 +1,169 @@
1
+ /** Model-facing history tools over an authenticated owner-scoped store; no database handles. */
2
+ import { buildDomainRegistrations, indexCatalogById, requireInputRecord, ToolInputError } from '@jini-ai/core';
3
+ import { redactUserText } from '../../core/user-text-redaction.js';
4
+ import { assistantHistoryToolMetadata } from '../../core/history-tool-metadata.js';
5
+ export const HISTORY_MESSAGE_CHAR_CAP = 8_000;
6
+ export const HISTORY_TOTAL_CHAR_CAP = 40_000;
7
+ const TOOL_EVENT_CAP = 50;
8
+ export const assistantHistoryDerivedRisk = new Map([
9
+ ['assistant_chats_list', 'none'], ['assistant_chat_read', 'none'],
10
+ ]);
11
+ /** Exported so host contract tests can pin each registration to its catalog entry. */
12
+ export const assistantHistoryToolCatalog = indexCatalogById({ catalog: [
13
+ { name: 'assistant_chats_list', sideEffects: 'none', authorization: { permission: 'admin.assistant.use' },
14
+ description: "Lists the CURRENT user's own conversations on this site, newest first, optionally searching titles and message content. Returns id, title, updatedAt, messageCount and firstUserLine, with redacted, capped text. When the user refers to an earlier or interrupted chat ('continue what we were doing'), list first, then assistant_chat_read to read its tail, summarise the state and continue. No other user's chats are accessible; unavailable history is an error.",
15
+ inputSchema: { type: 'object', additionalProperties: false, properties: {
16
+ query: { type: 'string', maxLength: 200 }, limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 },
17
+ } } },
18
+ { name: 'assistant_chat_read', sideEffects: 'none', authorization: { permission: 'admin.assistant.use' },
19
+ description: "Reads one of the CURRENT user's own chats in stored message order. Omit fromPosition to read the latest tail (default 50 messages); use fromPosition and nextPosition for forward pagination. List chats first when resuming earlier work, read the tail, summarise the state, then continue. Returns redacted messages, positions, continuation and truncated flags; each serialized message is capped at 8,000 characters and the result at 40,000 characters. Tool events, when requested, contain only tool name and outcome, never payloads. Attachments are omitted. Missing and other-user chats receive the same refusal.",
20
+ inputSchema: { type: 'object', additionalProperties: false, required: ['chatId'], properties: {
21
+ chatId: { type: 'string', minLength: 1, maxLength: 128 },
22
+ fromPosition: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
23
+ includeToolEvents: { type: 'boolean', default: false },
24
+ } } },
25
+ ] });
26
+ /** Cap a fresh projection including JSON escapes and tool summaries, before the total-page cap.
27
+ * The projection is local to this call, so trimming it cannot mutate stored messages or events.
28
+ * @complexity Bounded content/summary serialization; no I/O or unbounded output accumulation.
29
+ */
30
+ function capMessage({ view }, _options = {}) {
31
+ while (JSON.stringify(view).length > HISTORY_MESSAGE_CHAR_CAP) {
32
+ view.truncated = true;
33
+ if (view.content.length > 0)
34
+ view.content = view.content.slice(0, Math.floor(view.content.length / 2));
35
+ else
36
+ view.toolEvents?.pop();
37
+ }
38
+ return view;
39
+ }
40
+ /** Only bounded names and status cross this boundary; malformed historical events are ignored.
41
+ * @complexity O(e) time/space in persisted events; output is capped at 50 summaries.
42
+ */
43
+ function summarizeTools({ events, redact }, _options = {}) {
44
+ if (!Array.isArray(events))
45
+ return { items: [], truncated: false };
46
+ const outcomes = new Map();
47
+ for (const raw of events) {
48
+ const event = raw;
49
+ if (event?.kind === 'tool_result' && typeof event.toolUseId === 'string') {
50
+ outcomes.set(event.toolUseId, event.isError === true ? 'failed' : event.isError === false ? 'succeeded' : 'unknown');
51
+ }
52
+ }
53
+ const items = [];
54
+ let truncated = false;
55
+ for (const raw of events) {
56
+ const event = raw;
57
+ if (event?.kind !== 'tool_use' || typeof event.name !== 'string')
58
+ continue;
59
+ if (items.length === TOOL_EVENT_CAP) {
60
+ truncated = true;
61
+ break;
62
+ }
63
+ const name = redact({ text: event.name }).text;
64
+ if (name.length > 120)
65
+ truncated = true;
66
+ items.push({ name: name.slice(0, 120), outcome: outcomes.get(event.id ?? '') ?? 'unknown' });
67
+ }
68
+ return { items, truncated };
69
+ }
70
+ /** Validate advertised integer bounds even when a transport bypasses JSON schema validation. */
71
+ function integerOption({ input, key, fallback, max, min = 1 }, _options = {}) {
72
+ const value = input[key] === undefined ? fallback : input[key];
73
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max) {
74
+ throw new ToolInputError({ message: `${key} must be an integer from ${min} through ${max}.` });
75
+ }
76
+ return value;
77
+ }
78
+ /** Strict allowlist prevents model-supplied identity from ever reaching the host scope factory. */
79
+ function inputRecord({ input, keys }, _options = {}) {
80
+ const record = requireInputRecord({ input });
81
+ if (Object.keys(record).some(key => !keys.includes(key)))
82
+ throw new ToolInputError({ message: 'Unexpected chat history input field. Use the published schema.' });
83
+ return record;
84
+ }
85
+ /** Build both history registrations, using the existing catalog/risk/metadata contract.
86
+ * @param ports Authenticated scoped-store port; denied/missing history must fail closed.
87
+ * @param options Optional stricter redaction policy (the secret-card policy is the default).
88
+ * @returns Read-only tool registrations. @throws ToolInputError for malformed or refused reads.
89
+ * @complexity Bounded projections; persistence owns search cost and message ordering.
90
+ */
91
+ export function buildAssistantHistoryRegistrations({ ports }, { redact = redactUserText } = {}) {
92
+ return buildDomainRegistrations({ domain: 'assistant-history', catalogModule: 'chat/store/runtime/history-tools', catalog: assistantHistoryToolCatalog,
93
+ derivedRisk: assistantHistoryDerivedRisk, metadata: assistantHistoryToolMetadata,
94
+ handlers: {
95
+ assistant_chats_list: async (ctx) => {
96
+ const input = inputRecord({ input: ctx.input, keys: ['query', 'limit'] });
97
+ if (input.query !== undefined && (typeof input.query !== 'string' || input.query.length > 200))
98
+ throw new ToolInputError({ message: 'query must be a string of at most 200 characters.' });
99
+ const limit = integerOption({ input, key: 'limit', fallback: 20, max: 50 });
100
+ const store = await ports.getStore({ principalId: ctx.principal.id }, {});
101
+ if (!store.history)
102
+ throw new ToolInputError({ message: 'Chat history is unavailable on this site.' });
103
+ const rows = await store.history.list({}, { ...(input.query === undefined ? {} : { query: input.query }), limit });
104
+ let truncated = false;
105
+ const chats = rows.map(row => {
106
+ // Redact BEFORE slicing: a cap must not turn a credential into an undetectable fragment.
107
+ const title = redact({ text: row.title ?? '' }).text;
108
+ const first = redact({ text: row.firstUserLine }).text.split(/\r?\n/, 1)[0];
109
+ if (title.length > 300 || first.length > 500 || row.id.length > 128)
110
+ truncated = true;
111
+ return { id: redact({ text: row.id }).text.slice(0, 128), title: title.slice(0, 300), updatedAt: row.updatedAt,
112
+ messageCount: row.messageCount, firstUserLine: first.slice(0, 500) };
113
+ });
114
+ // List is bounded too; serialized escaping can make text larger than its character count.
115
+ while (JSON.stringify({ chats, truncated }).length > HISTORY_TOTAL_CHAR_CAP) {
116
+ chats.pop();
117
+ truncated = true;
118
+ }
119
+ return { chats, truncated };
120
+ },
121
+ assistant_chat_read: async (ctx) => {
122
+ const input = inputRecord({ input: ctx.input, keys: ['chatId', 'fromPosition', 'limit', 'includeToolEvents'] });
123
+ if (typeof input.chatId !== 'string' || !input.chatId.trim() || input.chatId.length > 128)
124
+ throw new ToolInputError({ message: 'chatId must be an id returned by assistant_chats_list.' });
125
+ const limit = integerOption({ input, key: 'limit', fallback: 50, max: 100 });
126
+ const fromPosition = input.fromPosition === undefined ? undefined : integerOption({ input, key: 'fromPosition', fallback: 0, max: Number.MAX_SAFE_INTEGER, min: 0 });
127
+ if (input.includeToolEvents !== undefined && typeof input.includeToolEvents !== 'boolean')
128
+ throw new ToolInputError({ message: 'includeToolEvents must be boolean.' });
129
+ const store = await ports.getStore({ principalId: ctx.principal.id }, {});
130
+ if (!store.history)
131
+ throw new ToolInputError({ message: 'Chat history is unavailable on this site.' });
132
+ if (!await store.get({ id: input.chatId }, {}))
133
+ throw new ToolInputError({ message: 'Chat is unavailable to this caller. Choose an id from assistant_chats_list.' });
134
+ const page = await store.history.read({ chatId: input.chatId }, { ...(fromPosition === undefined ? {} : { fromPosition }), limit, includeToolEvents: input.includeToolEvents === true });
135
+ const messages = [];
136
+ let truncated = page.hasMore;
137
+ // Reserve result envelope/continuation space; measure serialized messages, including escapes.
138
+ let remaining = HISTORY_TOTAL_CHAR_CAP - 512;
139
+ const candidates = fromPosition === undefined ? [...page.messages].reverse() : page.messages;
140
+ for (const row of candidates) {
141
+ const content = redact({ text: row.content }).text;
142
+ const tools = input.includeToolEvents === true ? summarizeTools({ events: row.events, redact }) : undefined;
143
+ const view = capMessage({ view: { id: redact({ text: row.id }).text.slice(0, 128), role: row.role === 'user' ? 'user' : 'assistant', position: row.position,
144
+ content: content.slice(0, HISTORY_MESSAGE_CHAR_CAP), truncated: content.length > HISTORY_MESSAGE_CHAR_CAP || row.id.length > 128 || tools?.truncated === true,
145
+ ...(tools ? { toolEvents: tools.items } : {}) } });
146
+ if (JSON.stringify(view).length > remaining && messages.length > 0) {
147
+ truncated = true;
148
+ break;
149
+ }
150
+ // Advance only over returned positions; the next page must include the omitted row.
151
+ messages.push(view);
152
+ remaining -= JSON.stringify(view).length + 1;
153
+ if (view.truncated)
154
+ truncated = true;
155
+ }
156
+ if (fromPosition === undefined)
157
+ messages.reverse();
158
+ const last = messages.at(-1), first = messages.at(0);
159
+ const omitted = messages.length < page.messages.length;
160
+ const hasMore = fromPosition !== undefined && (omitted || page.hasMore);
161
+ return { chatId: redact({ text: input.chatId }).text, messages, truncated, hasMore,
162
+ ...(last ? { nextPosition: last.position + 1 } : {}),
163
+ ...(first ? { firstPosition: first.position } : {}),
164
+ hasMoreBefore: fromPosition === undefined && (omitted || page.hasMore) };
165
+ },
166
+ },
167
+ });
168
+ }
169
+ //# sourceMappingURL=history-tools.js.map
@@ -126,15 +126,18 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, _opti
126
126
  async checkpoint(progress, _optional = {}) {
127
127
  return kernel.transaction(async () => {
128
128
  await kernel.lockKey(runLockKey(progress));
129
- const current = await kernel.run((db) => db.selectFrom("ai_chat_messages").select(["content", "events_json"])
129
+ const current = await kernel.run((db) => db.selectFrom("ai_chat_messages").select(["content", "events_json", "run_status"])
130
130
  .where((eb) => eb.and([isRunRow(eb, progress), notTerminal(eb)])).executeTakeFirst());
131
131
  if (!current || progress.content.length < current.content.length)
132
132
  return false;
133
133
  const events = keepToolCheckpoints(readEvents(current.events_json), progress.events);
134
134
  const grown = progress.content.length > current.content.length || hasNewToolProgress(readEvents(current.events_json), events);
135
+ // A checkpoint is the run producing output, so an accepted (`queued`) row is now `running`;
136
+ // nothing else moves it there, and a streaming answer must not read as still waiting.
137
+ const runStatus = current.run_status === "queued" ? "running" : current.run_status;
135
138
  const result = await kernel.run((db) => db
136
139
  .updateTable("ai_chat_messages")
137
- .set({ content: progress.content, events_json: JSON.stringify(events) })
140
+ .set({ content: progress.content, events_json: JSON.stringify(events), run_status: runStatus })
138
141
  .where((eb) => eb.and([isRunRow(eb, progress), notTerminal(eb)]))
139
142
  .executeTakeFirst());
140
143
  if (Number(result.numUpdatedRows) > 0 && grown) {
@@ -212,6 +212,54 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
212
212
  return row?.max === null || row?.max === undefined ? 0 : Number(row.max) + 1;
213
213
  }
214
214
  const store = {
215
+ history: {
216
+ /** Literal search uses two scoped projections, not a transcript fetch per conversation.
217
+ * @complexity The database scans owned content for search; only limit rows cross the port.
218
+ */
219
+ async list(_required, { query, limit = 20 } = {}) {
220
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50 || (query !== undefined && (typeof query !== 'string' || query.length > 200))) {
221
+ throw new ChatStoreError({ code: 'invalid-input' });
222
+ }
223
+ const rows = await run(db => {
224
+ let statement = ownedConversations(db).select(eb => eb.selectFrom('ai_chat_messages as first')
225
+ .select('first.content').whereRef('first.conversation_id', '=', 'c.id').where('first.role', '=', 'user')
226
+ .orderBy('first.position').limit(1).as('first_user_line'));
227
+ if (query?.trim()) {
228
+ // Escape LIKE wildcards: user search is literal text, never an SQL/FTS expression.
229
+ const pattern = '%' + query.trim().toLowerCase().replace(/[!%_]/g, char => '!' + char) + '%';
230
+ statement = statement.where(eb => eb.or([
231
+ sql `lower(c.title) like ${pattern} escape '!'`,
232
+ eb.exists(eb.selectFrom('ai_chat_messages as found').select('found.id')
233
+ .whereRef('found.conversation_id', '=', 'c.id')
234
+ .where(sql `lower(found.content) like ${pattern} escape '!'`)),
235
+ ]));
236
+ }
237
+ return statement.orderBy('c.updated_at', 'desc').orderBy(ordinalId(kernel.dialect, 'c.id')).limit(limit).execute();
238
+ });
239
+ return rows.map(row => ({ ...toConversation(row), firstUserLine: row.first_user_line ?? '' }));
240
+ },
241
+ /** No attachment column is read. O(limit) rows; default tail is reversed into position order. */
242
+ async read({ chatId }, { fromPosition, limit = 50, includeToolEvents = false } = {}) {
243
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100 || (fromPosition !== undefined && (!Number.isSafeInteger(fromPosition) || fromPosition < 0))) {
244
+ throw new ChatStoreError({ code: 'invalid-input' });
245
+ }
246
+ const rows = await run(db => {
247
+ let statement = ownedMessages(db, chatId).clearSelect().select([
248
+ 'm.id', 'm.role', 'm.content', 'm.position', 'm.created_at',
249
+ ]);
250
+ if (fromPosition !== undefined)
251
+ statement = statement.where('m.position', '>=', fromPosition);
252
+ return statement.select(includeToolEvents ? sql `m.events_json`.as('events_json') : sql `null`.as('events_json'))
253
+ .orderBy('m.position', fromPosition === undefined ? 'desc' : 'asc').limit(limit + 1).execute();
254
+ });
255
+ const page = rows.slice(0, limit);
256
+ if (fromPosition === undefined)
257
+ page.reverse();
258
+ return { messages: page.map(row => ({ id: row.id, role: row.role, content: row.content,
259
+ position: Number(row.position), createdAt: Number(row.created_at),
260
+ ...(includeToolEvents ? { events: row.events_json ? safeParse(row.events_json) : undefined } : {}), })), hasMore: rows.length > limit };
261
+ },
262
+ },
215
263
  async list() {
216
264
  const rows = await run((db) => ownedConversations(db).orderBy("c.updated_at", "desc").execute());
217
265
  return rows.map((row) => toConversation(row));
@@ -332,6 +380,10 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
332
380
  },
333
381
  };
334
382
  return {
383
+ history: {
384
+ list: (required, optional = {}) => atStoreBoundary({ operation: () => store.history.list(required, optional) }),
385
+ read: (required, optional = {}) => atStoreBoundary({ operation: () => store.history.read(required, optional) }),
386
+ },
335
387
  list: () => atStoreBoundary({ operation: () => store.list() }),
336
388
  get: (required, optional = {}) => atStoreBoundary({ operation: () => store.get(required, optional) }),
337
389
  create: (required, optional = {}) => atStoreBoundary({ operation: () => store.create(required, optional) }),
@@ -0,0 +1,7 @@
1
+ import type { ChatMessage } from '../core/index.js';
2
+ /** `turns` user/assistant pairs; each reply carries `toolRowsPerTurn` finished Bash calls with output. */
3
+ export declare function createLongTranscriptFixture({ turns, toolRowsPerTurn }: {
4
+ turns: number;
5
+ toolRowsPerTurn: number;
6
+ }, _options?: Record<string, never>): ChatMessage[];
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,22 @@
1
+ /** `turns` user/assistant pairs; each reply carries `toolRowsPerTurn` finished Bash calls with output. */
2
+ export function createLongTranscriptFixture({ turns, toolRowsPerTurn }, _options = {}) {
3
+ return Array.from({ length: turns }, (_, turn) => [
4
+ { id: `u${turn}`, role: 'user', content: `Step ${turn}`, createdAt: turn * 2 },
5
+ {
6
+ id: `a${turn}`,
7
+ role: 'assistant',
8
+ content: `Done with step ${turn}.`,
9
+ runStatus: 'succeeded',
10
+ createdAt: turn * 2 + 1,
11
+ events: [
12
+ ...Array.from({ length: toolRowsPerTurn }, (_, i) => [
13
+ { kind: 'tool_use', id: `t${turn}-${i}`, name: 'Bash', input: { command: `ls ${i}` } },
14
+ { kind: 'tool_result', toolUseId: `t${turn}-${i}`, content: 'file\n'.repeat(50), isError: false },
15
+ ]).flat(),
16
+ { kind: 'text', text: `Done with step ${turn}.` },
17
+ { kind: 'ext', name: 'render-probe', data: { turn } },
18
+ ],
19
+ },
20
+ ]).flat();
21
+ }
22
+ //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/chat",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -14,6 +14,7 @@
14
14
  "runtime": "universal",
15
15
  "entries": {
16
16
  ".": "universal",
17
+ "./testing": "universal",
17
18
  "./core": "universal",
18
19
  "./react": "browser",
19
20
  "./react/chat-pane": "browser",
@@ -34,7 +35,10 @@
34
35
  "./browser/session-store": "browser",
35
36
  "./browser/page-actions": "browser",
36
37
  "./server/durable-runs": "node",
37
- "./store/runtime": "node"
38
+ "./store/runtime": "node",
39
+ "./vanilla": "browser",
40
+ "./browser/lazy-loader": "browser",
41
+ "./vanilla/style.css": "browser"
38
42
  },
39
43
  "neutralEntries": [
40
44
  "src/store/contracts"
@@ -54,6 +58,11 @@
54
58
  "main": "./dist/core/index.js",
55
59
  "types": "./dist/core/index.d.ts",
56
60
  "exports": {
61
+ "./testing": {
62
+ "types": "./dist/testing/index.d.ts",
63
+ "import": "./dist/testing/index.js",
64
+ "default": "./dist/testing/index.js"
65
+ },
57
66
  ".": {
58
67
  "types": "./dist/core/index.d.ts",
59
68
  "import": "./dist/core/index.js",
@@ -155,7 +164,18 @@
155
164
  "types": "./dist/store/runtime/entry.d.ts",
156
165
  "import": "./dist/store/runtime/entry.js",
157
166
  "default": "./dist/store/runtime/entry.js"
158
- }
167
+ },
168
+ "./vanilla": {
169
+ "types": "./dist/embed-widget/vanilla/index.d.ts",
170
+ "import": "./dist/embed-widget/vanilla/index.js",
171
+ "default": "./dist/embed-widget/vanilla/index.js"
172
+ },
173
+ "./browser/lazy-loader": {
174
+ "types": "./dist/embed-widget/browser/lazy-loader.d.ts",
175
+ "import": "./dist/embed-widget/browser/lazy-loader.js",
176
+ "default": "./dist/embed-widget/browser/lazy-loader.js"
177
+ },
178
+ "./vanilla/style.css": "./dist/embed-widget/vanilla/style.css"
159
179
  },
160
180
  "files": [
161
181
  "dist",
@@ -176,12 +196,12 @@
176
196
  "registry": "https://registry.npmjs.org"
177
197
  },
178
198
  "dependencies": {
179
- "@jini-ai/core": "^0.4.2"
199
+ "@jini-ai/core": "^0.4.3"
180
200
  },
181
201
  "peerDependencies": {
182
202
  "react": "^18.3.0 || ^19.0.0",
183
203
  "react-dom": "^18.3.0 || ^19.0.0",
184
- "@jini-ai/db": "^0.2.0 || ^0.3.0",
204
+ "@jini-ai/db": "^0.3.2",
185
205
  "kysely": "^0.29.6",
186
206
  "better-sqlite3": "^13.0.0",
187
207
  "@electric-sql/pglite": "0.5.8",
@@ -194,11 +214,11 @@
194
214
  "@radix-ui/react-select": "^2.3.7",
195
215
  "@radix-ui/react-slot": "^1.3.3",
196
216
  "recharts": "^3.10.1",
197
- "@jini-ai/agent-runtime": "^0.4.1",
198
- "@jini-ai/agentic": "^0.4.2",
199
- "@jini-ai/protocol": "^0.4.1",
200
- "@jini-ai/ui": "^0.4.5",
201
- "@jini-ai/diagnostics": "^0.5.0"
217
+ "@jini-ai/agent-runtime": "^0.4.5",
218
+ "@jini-ai/agentic": "^0.4.4",
219
+ "@jini-ai/protocol": "^0.4.4",
220
+ "@jini-ai/ui": "^0.4.8",
221
+ "@jini-ai/diagnostics": "^0.5.2"
202
222
  },
203
223
  "peerDependenciesMeta": {
204
224
  "react": {
@@ -277,6 +297,7 @@
277
297
  "@types/react": "^19.2.0",
278
298
  "@types/react-dom": "^19.2.0",
279
299
  "@vitest/coverage-v8": "^2.1.9",
300
+ "zod": "^3.25.76",
280
301
  "jsdom": "^25.0.1",
281
302
  "react": "^19.2.0",
282
303
  "react-dom": "^19.2.0",
@@ -286,16 +307,19 @@
286
307
  "@electric-sql/pglite": "0.5.8",
287
308
  "pg": "8.23.0",
288
309
  "@ag-ui/core": "0.0.58",
289
- "@jini-ai/protocol": "^0.4.2",
290
- "@jini-ai/agent-runtime": "0.4.3",
291
- "@jini-ai/ui": "^0.4.6",
292
- "@jini-ai/db": "0.3.1",
293
- "@jini-ai/agentic": "^0.4.3",
294
- "@jini-ai/diagnostics": "^0.5.1",
295
- "@jini-ai/core": "^0.4.2"
310
+ "@jini-ai/db": "0.3.2",
311
+ "@jini-ai/agentic": "^0.4.4",
312
+ "@jini-ai/protocol": "^0.4.4",
313
+ "@jini-ai/diagnostics": "^0.5.2",
314
+ "@jini-ai/agent-runtime": "0.4.5",
315
+ "@jini-ai/core": "^0.4.3",
316
+ "@jini-ai/ui": "^0.4.8"
296
317
  },
297
318
  "typesVersions": {
298
319
  "*": {
320
+ "testing": [
321
+ "./dist/testing/index.d.ts"
322
+ ],
299
323
  "core": [
300
324
  "./dist/core/index.d.ts"
301
325
  ],
@@ -349,11 +373,17 @@
349
373
  ],
350
374
  "store/runtime": [
351
375
  "./dist/store/runtime/entry.d.ts"
376
+ ],
377
+ "vanilla": [
378
+ "./dist/embed-widget/vanilla/index.d.ts"
379
+ ],
380
+ "browser/lazy-loader": [
381
+ "./dist/embed-widget/browser/lazy-loader.d.ts"
352
382
  ]
353
383
  }
354
384
  },
355
385
  "scripts": {
356
- "build": "tsc -p tsconfig.json && mkdir -p dist/react/styles && cp src/react/styles/reference.css dist/react/styles/",
386
+ "build": "tsc -p tsconfig.json && mkdir -p dist/react/styles && cp src/react/styles/reference.css dist/react/styles/ && mkdir -p dist/embed-widget/vanilla && node --input-type=module -e \"import {writeFileSync,readFileSync} from 'node:fs'; import {CHAT_BUBBLE_STYLE} from './dist/embed-widget/browser/lazy-loader.js'; writeFileSync('dist/embed-widget/vanilla/style.css',CHAT_BUBBLE_STYLE+'\\n'+readFileSync('src/embed-widget/vanilla/style.css','utf8'));\"",
357
387
  "typecheck": "tsc -p tsconfig.json --noEmit",
358
388
  "test": "vitest run",
359
389
  "test:coverage": "vitest run --coverage",