@jini-ai/chat 0.6.15-rc.2 → 0.6.15

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 (91) hide show
  1. package/dist/core/accepted-input.js +20 -11
  2. package/dist/core/ag-ui/entry.js +3 -1
  3. package/dist/core/agent-plugin-chat-state.js +3 -1
  4. package/dist/core/assistant-client/ag-ui-transport.js +1 -1
  5. package/dist/core/assistant-client/attachment-liveness.js +1 -1
  6. package/dist/core/assistant-client/durable-run-subscription.js +2 -1
  7. package/dist/core/assistant-client/transport.js +13 -7
  8. package/dist/core/assistant-content.js +2 -1
  9. package/dist/core/composer-history.js +2 -1
  10. package/dist/core/durable-projection.js +2 -1
  11. package/dist/core/durable-runs/continuation.js +2 -1
  12. package/dist/core/messages.d.ts +5 -1
  13. package/dist/core/partial-json.js +18 -9
  14. package/dist/core/persistence/title.d.ts +1 -1
  15. package/dist/core/persistence/title.js +25 -7
  16. package/dist/core/question-form/normalize.js +1 -1
  17. package/dist/core/question-form/scan.js +25 -15
  18. package/dist/core/question-form/secret-answers.js +2 -1
  19. package/dist/core/run-activity.js +8 -4
  20. package/dist/core/run-events/entry.js +6 -2
  21. package/dist/core/transcript.js +13 -6
  22. package/dist/core/turn-usage.js +7 -2
  23. package/dist/core/user-text-redaction.d.ts +2 -5
  24. package/dist/core/user-text-redaction.js +3 -3
  25. package/dist/core/util/manifest.js +13 -11
  26. package/dist/core/util/markdown-context.d.ts +14 -0
  27. package/dist/core/util/markdown-context.js +42 -4
  28. package/dist/core/util/markup-attributes.js +1 -1
  29. package/dist/core/util/parser.js +7 -7
  30. package/dist/core/util/pointer.js +22 -6
  31. package/dist/core/util/recover.js +5 -8
  32. package/dist/core/util/strip.js +12 -15
  33. package/dist/core/util/validate.js +30 -18
  34. package/dist/embed-widget/browser/lazy-loader.js +16 -12
  35. package/dist/embed-widget/react/dialog.hooks.js +2 -2
  36. package/dist/embed-widget/react/embed.js +1 -1
  37. package/dist/embed-widget/react/header.hooks.js +1 -1
  38. package/dist/embed-widget/transports/fetch-sse.js +2 -1
  39. package/dist/embed-widget/vanilla/index.js +19 -12
  40. package/dist/embed-widget/vanilla/state.js +4 -2
  41. package/dist/react/components/AttachmentPreviewModal.hooks.js +2 -1
  42. package/dist/react/components/ChatSettingsDialog.js +18 -9
  43. package/dist/react/components/Composer.js +5 -4
  44. package/dist/react/components/ConversationList.js +5 -4
  45. package/dist/react/components/Markdown.js +29 -26
  46. package/dist/react/components/McpUiSurfaceCard.js +2 -1
  47. package/dist/react/components/MessageList.hooks.js +3 -2
  48. package/dist/react/components/MessageRow.js +23 -7
  49. package/dist/react/components/MessageRow.usage.js +3 -1
  50. package/dist/react/components/QuestionForm.js +4 -5
  51. package/dist/react/components/SentAttachment.js +4 -1
  52. package/dist/react/components/TodoCard.js +9 -1
  53. package/dist/react/components/ToolCard.js +8 -7
  54. package/dist/react/components/composer-discovery.js +8 -8
  55. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +1 -2
  56. package/dist/react/features/chat-pane/components/AgentRuntimePicker.d.ts +1 -1
  57. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +15 -5
  58. package/dist/react/features/chat-pane/components/ChatPane.js +4 -2
  59. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +1 -1
  60. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +1 -1
  61. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +2 -2
  62. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +4 -2
  63. package/dist/react/features/chat-pane/hooks/useChatPaneAgentControl.hooks.js +2 -2
  64. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +1 -1
  65. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +2 -2
  66. package/dist/react/features/chat-pane/hooks/useChatPaneWorkingDirectory.hooks.js +2 -2
  67. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +2 -2
  68. package/dist/react/features/chat-pane/model-options.js +4 -2
  69. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  70. package/dist/react/features/chat-pane/styles.js +26 -3
  71. package/dist/react/features/chat-pane/types.d.ts +8 -0
  72. package/dist/react/hooks/assistant-chats.js +22 -8
  73. package/dist/react/hooks/useArtifactStream.js +2 -1
  74. package/dist/react/hooks/useComposer.js +2 -2
  75. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +2 -2
  76. package/dist/react/hooks/useConversation.js +9 -4
  77. package/dist/react/hooks/useExtEventGroups.js +4 -2
  78. package/dist/react/hooks/useLatestOperation.js +2 -1
  79. package/dist/react/hooks/useQuestionForms.js +32 -22
  80. package/dist/react/hooks/useRunStream.js +4 -2
  81. package/dist/react/message-blocks.js +4 -2
  82. package/dist/react/styles/reference.css +9 -0
  83. package/dist/server/durable-runs/recover.js +13 -5
  84. package/dist/server/run-finalizer/entry.js +1 -0
  85. package/dist/store/runtime/chat-history-store.js +9 -3
  86. package/dist/store/runtime/durable-run-store.js +34 -24
  87. package/dist/store/runtime/run-ledger.js +7 -4
  88. package/dist/store/sql/boundary.js +3 -1
  89. package/dist/store/sql/cursor.js +8 -3
  90. package/dist/store/sql/store.js +36 -28
  91. package/package.json +15 -15
@@ -52,7 +52,7 @@ export function useConversation(options) {
52
52
  useEffect(() => {
53
53
  const source = options.refreshSignal;
54
54
  if (!source || run.isStreaming)
55
- return;
55
+ return undefined;
56
56
  let disposed = false, pending = false, timer;
57
57
  const refresh = async () => {
58
58
  if (pending)
@@ -61,7 +61,7 @@ export function useConversation(options) {
61
61
  try {
62
62
  const saved = await source.loadMessages();
63
63
  if (!disposed)
64
- setMessagesState(prev => [...new Map([...prev, ...saved].map(message => [message.id, message])).values()]);
64
+ setMessagesState(prev => mergeSavedMessages({ previous: prev, saved }));
65
65
  }
66
66
  catch (error) {
67
67
  if (!disposed)
@@ -143,14 +143,14 @@ export function useConversation(options) {
143
143
  return m;
144
144
  // A plain if/else chain (not a nested ternary) — deliberately, so
145
145
  // each of the four mapped statuses is its own independently
146
- // trackable branch under coverage instrumentation. The initial
146
+ // trackable branch under coverage instrumentation. The fallback
147
147
  // value is asserted non-null: this reconciliation effect only ever
148
148
  // runs after `sendMessage`/`retry` has called `run.start()`/
149
149
  // `reattach` (both set the message's `runStatus: 'queued'` via
150
150
  // `activeAssistantIdRef` first), so `m.runStatus` is always defined
151
151
  // and `run.status` is never `'idle'` by the time this callback
152
152
  // fires — one of the four branches below always reassigns it.
153
- let runStatus = m.runStatus;
153
+ let runStatus;
154
154
  if (run.status === 'streaming')
155
155
  runStatus = 'running';
156
156
  else if (run.status === 'done')
@@ -159,6 +159,8 @@ export function useConversation(options) {
159
159
  runStatus = 'failed';
160
160
  else if (run.status === 'canceled')
161
161
  runStatus = 'canceled';
162
+ else
163
+ runStatus = m.runStatus;
162
164
  const nextRunId = run.runId ?? m.runId;
163
165
  const events = projectAcceptedInputEvents({ events: mergeRunEvents({ saved: m.events, incoming: run.events }, {}) });
164
166
  return {
@@ -274,4 +276,7 @@ export function useConversation(options) {
274
276
  suppressScroll,
275
277
  }), [messages, conversationId, run.isStreaming, run.error, sendRefused, scrollIntent, sendMessage, cancel, retry, setMessages, acknowledgeScroll, suppressScroll]);
276
278
  }
279
+ function mergeSavedMessages({ previous, saved }) {
280
+ return [...new Map([...previous, ...saved].map(message => [message.id, message])).values()];
281
+ }
277
282
  //# sourceMappingURL=useConversation.js.map
@@ -26,12 +26,14 @@ function addExtEvent(state, { name, data }) {
26
26
  function visitEvent(state, ev) {
27
27
  if (ev.kind === 'tool_use') {
28
28
  state.openCalls.set(ev.id, { name: ev.name, input: ev.input });
29
+ return;
29
30
  }
30
- else if (ev.kind === 'tool_result') {
31
+ if (ev.kind === 'tool_result') {
31
32
  state.openCalls.delete(ev.toolUseId);
32
33
  state.results.set(ev.toolUseId, { content: ev.content, isError: ev.isError });
34
+ return;
33
35
  }
34
- else if (ev.kind === 'ext') {
36
+ if (ev.kind === 'ext') {
35
37
  addExtEvent(state, ev);
36
38
  }
37
39
  }
@@ -48,7 +48,8 @@ export function useLatestOperation(_requiredArgs) {
48
48
  generationRef.current += 1;
49
49
  }, []);
50
50
  const run = useCallback(async ({ body, onError }) => {
51
- const generation = ++generationRef.current;
51
+ generationRef.current += 1;
52
+ const generation = generationRef.current;
52
53
  const isCurrent = () => mountedRef.current && generation === generationRef.current;
53
54
  const token = {
54
55
  isCurrent,
@@ -66,23 +66,17 @@ export function parseSubmittedAnswers({ form, userMessageContent }) {
66
66
  // `i < lines.length` is the loop condition, so this index is always
67
67
  // in-bounds and defined — same reasoning as `header` above.
68
68
  const line = lines[i];
69
- const m = /^[-*]\s*([^:]+):\s*(.*)$/.exec(line);
70
- if (!m)
69
+ const parsed = submittedAnswerLine({ line });
70
+ if (!parsed)
71
71
  continue;
72
- // Both capture groups are required (non-optional) in this pattern, so
73
- // they're always defined once `m` is non-null — TS just can't express
74
- // that for regex capture groups.
75
- const labelKey = m[1].trim().toLowerCase();
76
- const value = m[2].trim();
77
- const id = labelToId.get(labelKey);
78
- if (!id)
79
- continue;
80
- // `id` only ever comes from `labelToId`, which was populated from this
81
- // same `form.questions` array immediately above, so a question with
82
- // this exact `id` is always found — the `!` reflects that invariant
83
- // rather than gaming an untestable defensive branch.
84
- const question = form.questions.find((x) => x.id === id);
85
- answers[id] = decodeAnswerValue(question, value);
72
+ const id = labelToId.get(parsed.labelKey);
73
+ if (id) {
74
+ // `id` only ever comes from `labelToId`, populated from this same `form.questions` array,
75
+ // so this exact question exists; the assertion reflects that invariant rather than adding
76
+ // an untestable defensive branch.
77
+ const question = form.questions.find((x) => x.id === id);
78
+ answers[id] = decodeAnswerValue(question, parsed.value);
79
+ }
86
80
  }
87
81
  return Object.keys(answers).length > 0 ? answers : null;
88
82
  }
@@ -97,12 +91,28 @@ function decodeAnswerValue(question, value) {
97
91
  return value.toLowerCase() === '(skipped)' ? '' : formOptionValueForLabel({ question: question, labelOrValue: parseSubmittedOptionToken(value) });
98
92
  }
99
93
  function parseSubmittedOptionToken(raw) {
100
- const match = /\s+\[value:\s*([^\]]+)\]\s*$/i.exec(raw);
101
- if (!match)
94
+ const trimmed = raw.trimEnd();
95
+ if (!trimmed.endsWith(']'))
96
+ return raw.trim();
97
+ const end = trimmed.length - 1;
98
+ // The value cannot cross an earlier ']'; search the remaining suffix once, from the start
99
+ // of each whitespace run, retaining the first eligible (possibly nested) value marker.
100
+ const base = trimmed.lastIndexOf(']', end - 1) + 1;
101
+ const marker = /(?<!\s)\s+\[value:/i.exec(trimmed.slice(base, end));
102
+ if (!marker)
102
103
  return raw.trim();
103
- // The single capture group is non-optional in this pattern, so it's
104
- // always defined once `match` is non-null — same regex-capture-group
105
- // caveat as `parseSubmittedAnswers` above.
106
- return match[1].trim();
104
+ const value = trimmed.slice(base + marker.index + marker[0].length, end);
105
+ return value.length > 0 ? value.trim() : raw.trim();
106
+ }
107
+ function submittedAnswerLine({ line }) {
108
+ const label = /^[-*]([^:]+):/.exec(line);
109
+ if (!label)
110
+ return null;
111
+ const value = /^(.*)$/.exec(line.slice(label[0].length).trimStart());
112
+ if (!value)
113
+ return null;
114
+ // Each capture is required in its own pattern and is defined after a successful match;
115
+ // TypeScript cannot express that invariant for RegExpExecArray's numeric capture slots.
116
+ return { labelKey: label[1].trim().toLowerCase(), value: value[1].trim() };
107
117
  }
108
118
  //# sourceMappingURL=useQuestionForms.js.map
@@ -118,7 +118,8 @@ export function useRunStream({ transport }) {
118
118
  }, []);
119
119
  const start = useCallback(async (input) => {
120
120
  teardownSubscription();
121
- const generation = ++generationRef.current;
121
+ generationRef.current += 1;
122
+ const generation = generationRef.current;
122
123
  runIdRef.current = null;
123
124
  const subscriptionAbort = new AbortController();
124
125
  const cancelAbort = new AbortController();
@@ -150,7 +151,8 @@ export function useRunStream({ transport }) {
150
151
  }, [makeHandlers, teardownSubscription, transport]);
151
152
  const reattach = useCallback(async (runId, initialEvents = []) => {
152
153
  teardownSubscription();
153
- const generation = ++generationRef.current;
154
+ generationRef.current += 1;
155
+ const generation = generationRef.current;
154
156
  runIdRef.current = runId;
155
157
  const subscriptionAbort = new AbortController();
156
158
  const cancelAbort = new AbortController();
@@ -100,9 +100,11 @@ function placeEvent({ state, ev }, { slotOf, includeThinking }) {
100
100
  // Emitted per boundary, before any slot dedupe below, so every reply gets its own end marker.
101
101
  if (isReplyBoundary({ event: ev }))
102
102
  placeReplyEnd({ state });
103
- if (ev.kind === 'ext')
103
+ if (ev.kind === 'ext') {
104
104
  placeExt({ state, ev }, { slotOf });
105
- else if (ev.kind === 'tool_use')
105
+ return;
106
+ }
107
+ if (ev.kind === 'tool_use')
106
108
  placeToolUse({ state, id: ev.id });
107
109
  }
108
110
  /**
@@ -43,6 +43,15 @@
43
43
  min-width: 0;
44
44
  }
45
45
 
46
+ /* The picker, plus menu and send share this <div>; flex keeps them on one line, and it wraps
47
+ whole to its own line when the footer runs out of room. */
48
+ .jini-composer-footer-trailing {
49
+ display: flex;
50
+ align-items: center;
51
+ min-width: 0;
52
+ margin-left: auto;
53
+ }
54
+
46
55
  /* The grouped add menu and slash autocomplete anchor to the composer rather than escaping the
47
56
  host's layout. Visual tokens remain the host's responsibility. */
48
57
  .jini-composer,
@@ -2,7 +2,7 @@ import { recoveredRunEvents } from "../../core/index.js";
2
2
  import { classifyAgentServiceFailure } from "@jini-ai/agent-runtime";
3
3
  import { continuationRequest } from "../../core/durable-runs/continuation.js";
4
4
  import { isAwaitingAnswer } from "../../core/run-activity.js";
5
- import { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
5
+ import { RECOVERY_LIMIT, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
6
6
  export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
7
7
  // Older saved run error frames hold only their message: the daemon's MCP_BRIDGE_UNAVAILABLE
8
8
  // code was dropped, so the bridge failure is still matched by its text.
@@ -57,10 +57,8 @@ function attemptDiagnostics({ run }, _optional = {}) {
57
57
  /** Quota/billing and bad requests need human repair. A 429 for rate throttling and a 5xx
58
58
  * overload remain retryable; the provider's coarse RATE_LIMITED class also includes billing. */
59
59
  function needsHumanRepair({ diagnostics }, _optional = {}) {
60
- const requestStatuses = diagnostics.matchAll(/\b(?:http(?:[ /]?\d(?:\.\d)?)?|(?:status|error|response)(?:[ _-]?code)?|code(?=\s*[:=#]))[\s:=#-]*(4\d\d)\b/gi);
61
- if ([...requestStatuses].some((match) => match[1] !== "408" && match[1] !== "429"))
62
- return true;
63
- return /another answer in this chat is still running|invalid[_ -]?request|context[_ -](length|window)|maximum context|model.*(?:not[_ -]found|does not exist|not supported)|unsupported|does not support|permission denied|forbidden|insufficient[_ -]?(quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|quota[_ -]?exceeded|usage limit|billing|payment required/i.test(diagnostics);
60
+ const hasRejectedStatus = REQUEST_STATUS_PATTERNS.some(pattern => [...diagnostics.matchAll(pattern)].some(match => match[1] !== "408" && match[1] !== "429"));
61
+ return hasRejectedStatus || HUMAN_REPAIR_PATTERNS.some(pattern => pattern.test(diagnostics));
64
62
  }
65
63
  function attemptFailure({ diagnostics }, _optional = {}) {
66
64
  // MCP_BRIDGE_UNAVAILABLE needs bridge repair; another attempt repeats the same failure.
@@ -282,4 +280,14 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
282
280
  },
283
281
  };
284
282
  }
283
+ const REQUEST_STATUS_PATTERNS = [
284
+ /\bhttp(?:[ /]?\d(?:\.\d)?)?[\s:=#-]*(4\d\d)\b/gi,
285
+ /\b(?:status|error|response)(?:[ _-]?code)?[\s:=#-]*(4\d\d)\b/gi,
286
+ /\bcode(?=\s*[:=#])[\s:=#-]*(4\d\d)\b/gi,
287
+ ];
288
+ const HUMAN_REPAIR_PATTERNS = [
289
+ /another answer in this chat is still running|invalid[_ -]?request|context[_ -](length|window)|maximum context/i,
290
+ /model.*(?:not[_ -]found|does not exist|not supported)|unsupported|does not support|permission denied|forbidden/i,
291
+ /insufficient[_ -]?(quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|quota[_ -]?exceeded|usage limit|billing|payment required/i,
292
+ ];
285
293
  //# sourceMappingURL=recover.js.map
@@ -124,6 +124,7 @@ export function createAssistantRunFinalizer(args, options = {}) {
124
124
  await scheduler.sleep({ delayMs: reconnectDelayMs });
125
125
  }
126
126
  // Exhaustion alone is not evidence of a terminal run; the host can reattach or repair at boot.
127
+ return undefined;
127
128
  }
128
129
  return {
129
130
  watch({ principalId, conversationId, message }) {
@@ -42,9 +42,8 @@ export function createChatHistoryStore({ kernel: hostKernel, scope, isDaemonRunI
42
42
  const kernel = chatKernelTables({ kernel: hostKernel }, {});
43
43
  const required = { kernel, scope };
44
44
  const optional = { clock: { nowMs: now } };
45
- const store = kernel.transport === "better-sqlite3" ? createSqliteChatStore(required, optional)
46
- : kernel.transport === "pglite" || kernel.transport === "pglite-socket" ? createPgliteChatStore(required, optional)
47
- : createPostgresChatStore(required, optional);
45
+ const createStore = storeFactory(kernel.transport);
46
+ const store = createStore(required, optional);
48
47
  return { ...store,
49
48
  // Admin reload and recovery use this full-transcript compatibility read. Preserve stored
50
49
  // positions (and Jini's position-based page cursors); existing user databases need no migration.
@@ -53,4 +52,11 @@ export function createChatHistoryStore({ kernel: hostKernel, scope, isDaemonRunI
53
52
  },
54
53
  };
55
54
  }
55
+ function storeFactory(transport) {
56
+ if (transport === "better-sqlite3")
57
+ return createSqliteChatStore;
58
+ if (transport === "pglite" || transport === "pglite-socket")
59
+ return createPgliteChatStore;
60
+ return createPostgresChatStore;
61
+ }
56
62
  //# sourceMappingURL=chat-history-store.js.map
@@ -12,12 +12,18 @@ export class RunSlotBusyError extends Error {
12
12
  constructor() { super(RUN_SLOT_BUSY); }
13
13
  }
14
14
  function isAcceptedUserShape({ message }, _optional = {}) {
15
- return Boolean(message) && typeof message.id === "string" && Boolean(message.id) && message.role === "user" && typeof message.content === "string"
16
- && (message.createdAt === undefined || Number.isFinite(message.createdAt));
15
+ if (!message || typeof message.id !== "string" || !message.id)
16
+ return false;
17
+ if (message.role !== "user" || typeof message.content !== "string")
18
+ return false;
19
+ return message.createdAt === undefined || Number.isFinite(message.createdAt);
17
20
  }
18
21
  function areAcceptedAttachments({ attachments }, _optional = {}) {
19
- return Array.isArray(attachments) && attachments.every((attachment) => attachment && typeof attachment.path === "string" && typeof attachment.name === "string"
20
- && (attachment.kind === "file" || attachment.kind === "image"));
22
+ return Array.isArray(attachments) && attachments.every((attachment) => {
23
+ if (!attachment || typeof attachment.path !== "string" || typeof attachment.name !== "string")
24
+ return false;
25
+ return attachment.kind === "file" || attachment.kind === "image";
26
+ });
21
27
  }
22
28
  function acceptedUserMessage({ request, now }, _optional = {}) {
23
29
  const context = JSON.parse(request.contextRef);
@@ -247,6 +253,10 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
247
253
  return load({ messageId: required.messageId }, {});
248
254
  });
249
255
  }
256
+ async function writeAttemptMetadata({ values, updates }) {
257
+ await kernel.run(db => db.insertInto("assistant_run_attempts").values(values)
258
+ .onConflict(oc => oc.column("message_id").doUpdateSet(updates)).execute());
259
+ }
250
260
  async function advance({ run, nextRunId, now, events }, _options = {}) {
251
261
  return kernel.transaction(async () => {
252
262
  await kernel.lockKey(runLockKey(run));
@@ -261,17 +271,17 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
261
271
  .where("run_status", "in", ["queued", "running"]).executeTakeFirst());
262
272
  if (Number(result.numUpdatedRows) === 0)
263
273
  return false;
264
- await kernel.run((db) => db.insertInto("assistant_run_attempts").values({
265
- message_id: run.messageId, engine: run.engine, accepted_json: JSON.stringify(run.request),
266
- recovery_count: current.recoveryCount + 1, recovery_deadline: current.recoveryDeadline ?? now + RECOVERY_WINDOW_MS,
267
- recovery_elapsed_ms: 0, attempt_started_at: now,
268
- last_progress_at: current.lastProgressAt, cancel_reason: null, session_id: null,
269
- session_confirmed: 0, child_pid: null, child_started_at: null, attempt_base_json: JSON.stringify(projection),
270
- }).onConflict((oc) => oc.column("message_id").doUpdateSet({
271
- recovery_count: current.recoveryCount + 1, recovery_deadline: current.recoveryDeadline ?? now + RECOVERY_WINDOW_MS,
272
- attempt_started_at: now,
273
- session_id: null, session_confirmed: 0, child_pid: null, child_started_at: null, attempt_base_json: JSON.stringify(projection),
274
- })).execute());
274
+ await writeAttemptMetadata({ values: {
275
+ message_id: run.messageId, engine: run.engine, accepted_json: JSON.stringify(run.request),
276
+ recovery_count: current.recoveryCount + 1, recovery_deadline: current.recoveryDeadline ?? now + RECOVERY_WINDOW_MS,
277
+ recovery_elapsed_ms: 0, attempt_started_at: now,
278
+ last_progress_at: current.lastProgressAt, cancel_reason: null, session_id: null,
279
+ session_confirmed: 0, child_pid: null, child_started_at: null, attempt_base_json: JSON.stringify(projection),
280
+ }, updates: {
281
+ recovery_count: current.recoveryCount + 1, recovery_deadline: current.recoveryDeadline ?? now + RECOVERY_WINDOW_MS,
282
+ attempt_started_at: now,
283
+ session_id: null, session_confirmed: 0, child_pid: null, child_started_at: null, attempt_base_json: JSON.stringify(projection),
284
+ } });
275
285
  return true;
276
286
  });
277
287
  }
@@ -290,8 +300,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
290
300
  const run = await lockedRun({ runId });
291
301
  if (!run)
292
302
  return;
293
- await kernel.run((db) => db.insertInto("assistant_run_attempts").values({ ...metadataForRun({ run }, {}), cancel_reason: reason })
294
- .onConflict((oc) => oc.column("message_id").doUpdateSet({ cancel_reason: reason })).execute());
303
+ await writeAttemptMetadata({ values: { ...metadataForRun({ run }, {}), cancel_reason: reason }, updates: { cancel_reason: reason } });
295
304
  });
296
305
  }
297
306
  async function cancelPending(required, _options = {}) {
@@ -313,19 +322,21 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
313
322
  return load({ messageId: required.messageId }, {});
314
323
  });
315
324
  }
325
+ async function saveAgentSession({ run, sessionId }) {
326
+ await kernel.run((db) => db.insertInto("assistant_agent_sessions").values({ conversation_id: run.conversationId,
327
+ agent_id: run.request.agentId ?? "claude", session_id: sessionId, updated_at: Date.now() })
328
+ .onConflict((oc) => oc.columns(["conversation_id", "agent_id"]).doUpdateSet({ session_id: sessionId, updated_at: Date.now() })).execute());
329
+ }
316
330
  async function captureSession({ runId, sessionId, child, confirmed = true }, _options = {}) {
317
331
  return kernel.transaction(async () => {
318
332
  const run = await lockedRun({ runId });
319
333
  if (!run || !attemptCanExecute({ run }, {}))
320
334
  return false;
321
335
  const fields = { session_id: sessionId, session_confirmed: confirmed ? 1 : 0, ...(child ? { child_pid: child.pid, child_started_at: child.startedAt } : {}) };
322
- await kernel.run((db) => db.insertInto("assistant_run_attempts").values({ ...metadataForRun({ run }, {}), ...fields })
323
- .onConflict((oc) => oc.column("message_id").doUpdateSet(fields)).execute());
336
+ await writeAttemptMetadata({ values: { ...metadataForRun({ run }, {}), ...fields }, updates: fields });
324
337
  // Session and generation share the same lock/transaction; an old callback cannot write
325
338
  // the conversation's locator in the gap between a successful guard and a later CAS.
326
- await kernel.run((db) => db.insertInto("assistant_agent_sessions").values({ conversation_id: run.conversationId,
327
- agent_id: run.request.agentId ?? "claude", session_id: sessionId, updated_at: Date.now() })
328
- .onConflict((oc) => oc.columns(["conversation_id", "agent_id"]).doUpdateSet({ session_id: sessionId, updated_at: Date.now() })).execute());
339
+ await saveAgentSession({ run, sessionId });
329
340
  return true;
330
341
  });
331
342
  }
@@ -384,8 +395,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
384
395
  elapsed = Math.max(0, RECOVERY_WINDOW_MS - (deadline - now));
385
396
  deadline = null;
386
397
  }
387
- await kernel.run((db) => db.insertInto("assistant_run_attempts").values({ ...row, recovery_deadline: deadline, recovery_elapsed_ms: elapsed })
388
- .onConflict((oc) => oc.column("message_id").doUpdateSet({ recovery_deadline: deadline, recovery_elapsed_ms: elapsed })).execute());
398
+ await writeAttemptMetadata({ values: { ...row, recovery_deadline: deadline, recovery_elapsed_ms: elapsed }, updates: { recovery_deadline: deadline, recovery_elapsed_ms: elapsed } });
389
399
  });
390
400
  }
391
401
  return { load, find, accept, advance, cancel, cancelPending, captureSession, clearSession, guardTool, completeTool, recoveryClock };
@@ -131,7 +131,7 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now
131
131
  return kernel.transaction(async () => {
132
132
  await kernel.lockKey(runLockKey(settlement));
133
133
  const saved = await kernel.run((db) => db.selectFrom("ai_chat_messages").select(["content", "events_json"])
134
- .where((eb) => eb.and([isRunRow(eb, settlement), notTerminal(eb)])).executeTakeFirst());
134
+ .where(activeRunPredicate({ run: settlement })).executeTakeFirst());
135
135
  if (!saved)
136
136
  return false;
137
137
  const content = settlement.content.length >= saved.content.length ? settlement.content : saved.content;
@@ -149,7 +149,7 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now
149
149
  run_status: settlement.status,
150
150
  ended_at: settlement.endedAt,
151
151
  })
152
- .where((eb) => eb.and([isRunRow(eb, settlement), notTerminal(eb)]))
152
+ .where(activeRunPredicate({ run: settlement }))
153
153
  .executeTakeFirst());
154
154
  if (Number(result.numUpdatedRows) === 0)
155
155
  return false;
@@ -165,7 +165,7 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now
165
165
  return kernel.transaction(async () => {
166
166
  await kernel.lockKey(runLockKey(progress));
167
167
  const current = await kernel.run((db) => db.selectFrom("ai_chat_messages").select(["content", "events_json", "run_status"])
168
- .where((eb) => eb.and([isRunRow(eb, progress), notTerminal(eb)])).executeTakeFirst());
168
+ .where(activeRunPredicate({ run: progress })).executeTakeFirst());
169
169
  if (!current || progress.content.length < current.content.length)
170
170
  return false;
171
171
  const { eventsJson, runStatus, grown, unchanged } = planCheckpoint({ current, progress });
@@ -178,7 +178,7 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now
178
178
  const result = await kernel.run((db) => db
179
179
  .updateTable("ai_chat_messages")
180
180
  .set({ content: progress.content, events_json: eventsJson, run_status: runStatus })
181
- .where((eb) => eb.and([isRunRow(eb, progress), notTerminal(eb)]))
181
+ .where(activeRunPredicate({ run: progress }))
182
182
  .executeTakeFirst());
183
183
  if (Number(result.numUpdatedRows) > 0 && grown) {
184
184
  // Progress earns a fresh recovery budget. Replayed prefixes and the coordinator's
@@ -246,4 +246,7 @@ export function createChatRunLedger({ kernel: hostKernel, isDaemonRunId }, { now
246
246
  },
247
247
  };
248
248
  }
249
+ function activeRunPredicate({ run }) {
250
+ return (eb) => eb.and([isRunRow(eb, run), notTerminal(eb)]);
251
+ }
249
252
  //# sourceMappingURL=run-ledger.js.map
@@ -6,7 +6,9 @@ export function validateKernel({ kernel, transports, dialect }) {
6
6
  }
7
7
  /** Copy primitive identity fields so subsequent caller mutation cannot rebind the store. */
8
8
  export function copyScope({ scope }) {
9
- if (!scope || typeof scope.scopeId !== 'string' || !scope.scopeId || typeof scope.ownerId !== 'string' || !scope.ownerId || !['user', 'guest'].includes(scope.ownerKind)) {
9
+ if (!scope || typeof scope.scopeId !== 'string' || !scope.scopeId)
10
+ throw new ChatStoreError({ code: 'invalid-input' });
11
+ if (typeof scope.ownerId !== 'string' || !scope.ownerId || !['user', 'guest'].includes(scope.ownerKind)) {
10
12
  throw new ChatStoreError({ code: 'invalid-input' });
11
13
  }
12
14
  return { scopeId: scope.scopeId, ownerKind: scope.ownerKind, ownerId: scope.ownerId };
@@ -1,3 +1,4 @@
1
+ import { trimCharsEnd } from '@jini-ai/core/primitives';
1
2
  import { ChatStoreError } from '../errors.js';
2
3
  /** Reject bad limits before accessing any storage. */
3
4
  export function pageLimit({ options }) {
@@ -14,7 +15,7 @@ function ownerTuple(scope) {
14
15
  export function encodeCursor({ kind, scope, conversation, anchor }) {
15
16
  const bytes = new TextEncoder().encode(JSON.stringify([1, kind, ownerTuple(scope), conversation, anchor]));
16
17
  const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
17
- return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
18
+ return trimCharsEnd({ text: btoa(binary).replaceAll('+', '-').replaceAll('/', '_'), chars: '=' });
18
19
  }
19
20
  /** Every structural failure is the same refusal; callers never learn which check rejected it. */
20
21
  function invalidCursor() {
@@ -38,7 +39,9 @@ function decodeBody({ cursor }) {
38
39
  }
39
40
  /** Version, exact length, read kind and conversation binding. */
40
41
  function readEnvelope({ value, kind, conversation }) {
41
- if (!Array.isArray(value) || value.length !== 5 || value[0] !== 1 || value[1] !== kind || value[3] !== conversation)
42
+ if (!Array.isArray(value) || value.length !== 5)
43
+ invalidCursor();
44
+ if (value[0] !== 1 || value[1] !== kind || value[3] !== conversation)
42
45
  invalidCursor();
43
46
  return value;
44
47
  }
@@ -50,7 +53,9 @@ function assertOwner({ owner, scope }) {
50
53
  }
51
54
  /** A [finite number, string] pair; message positions must also be non-negative safe integers. */
52
55
  function readAnchor({ anchor, kind }) {
53
- if (!Array.isArray(anchor) || anchor.length !== 2 || typeof anchor[0] !== 'number' || !Number.isFinite(anchor[0]) || typeof anchor[1] !== 'string') {
56
+ if (!Array.isArray(anchor) || anchor.length !== 2)
57
+ invalidCursor();
58
+ if (typeof anchor[0] !== 'number' || !Number.isFinite(anchor[0]) || typeof anchor[1] !== 'string') {
54
59
  invalidCursor();
55
60
  }
56
61
  if (kind === 'messages' && (!Number.isSafeInteger(anchor[0]) || anchor[0] < 0))
@@ -1,3 +1,5 @@
1
+ const MESSAGE_POSITION = 'm.position';
2
+ const CONVERSATION_UPDATED_AT = 'c.updated_at';
1
3
  /** One SQL query body upstreamed from the existing host adapter; no resource/schema ownership. */
2
4
  // Legacy project/conversation CRUD trusts access to a local database file; this owner-bound store
3
5
  // serves shared databases instead. Choose by trust model: borrowing a handle must not grant access
@@ -96,7 +98,9 @@ function runFields(row) {
96
98
  }
97
99
  /** Reject an unbounded or malformed history read before any query runs. */
98
100
  function assertHistoryReadBounds({ limit, fromPosition }) {
99
- if (!Number.isInteger(limit) || limit < 1 || limit > 100 || (fromPosition !== undefined && (!Number.isSafeInteger(fromPosition) || fromPosition < 0))) {
101
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
102
+ throw new ChatStoreError({ code: 'invalid-input' });
103
+ if (fromPosition !== undefined && (!Number.isSafeInteger(fromPosition) || fromPosition < 0)) {
100
104
  throw new ChatStoreError({ code: 'invalid-input' });
101
105
  }
102
106
  }
@@ -139,7 +143,7 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
139
143
  "c.title",
140
144
  "c.title_source",
141
145
  "c.created_at",
142
- "c.updated_at",
146
+ CONVERSATION_UPDATED_AT,
143
147
  "c.expires_at",
144
148
  eb
145
149
  .selectFrom("ai_chat_messages as m")
@@ -182,7 +186,7 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
182
186
  }
183
187
  /** Read owned messages in persisted position order; foreign IDs produce no rows. */
184
188
  async function messages({ conversationId }, _optional = {}) {
185
- const rows = await run(db => ownedMessages(db, conversationId).orderBy('m.position').execute());
189
+ const rows = await run(db => ownedMessages(db, conversationId).orderBy(MESSAGE_POSITION).execute());
186
190
  return rows.map(toMessage);
187
191
  }
188
192
  /** Writes and projects one message inside the borrowed transaction; null for absent/foreign IDs. */
@@ -226,18 +230,7 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
226
230
  started_at: m.startedAt ?? null,
227
231
  ended_at: m.endedAt ?? null,
228
232
  })
229
- .onConflict((oc) => oc
230
- .column("id")
231
- .doUpdateSet((eb) => ({
232
- content: eb.ref("excluded.content"),
233
- events_json: eb.ref("excluded.events_json"),
234
- attachments_json: eb.ref("excluded.attachments_json"),
235
- run_id: eb.ref("excluded.run_id"),
236
- run_status: eb.ref("excluded.run_status"),
237
- started_at: eb.ref("excluded.started_at"),
238
- ended_at: eb.ref("excluded.ended_at"),
239
- }))
240
- .where((eb) => eb("ai_chat_messages.conversation_id", "=", eb.ref("excluded.conversation_id"))))
233
+ .onConflict(updateOwnedMessageConflict)
241
234
  .execute());
242
235
  // A new message is activity: the list orders by `updated_at`.
243
236
  await run((db) => db.updateTable("ai_chats").set({ updated_at: clock.nowMs() }).where("id", "=", conversationId).execute());
@@ -260,7 +253,9 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
260
253
  * @complexity The database scans owned content for search; only limit rows cross the port.
261
254
  */
262
255
  async list(_required, { query, limit = 20 } = {}) {
263
- if (!Number.isInteger(limit) || limit < 1 || limit > 50 || (query !== undefined && (typeof query !== 'string' || query.length > 200))) {
256
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50)
257
+ throw new ChatStoreError({ code: 'invalid-input' });
258
+ if (query !== undefined && (typeof query !== 'string' || query.length > 200)) {
264
259
  throw new ChatStoreError({ code: 'invalid-input' });
265
260
  }
266
261
  const rows = await run(db => {
@@ -277,7 +272,7 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
277
272
  .where(sql `lower(found.content) like ${pattern} escape '!'`)),
278
273
  ]));
279
274
  }
280
- return statement.orderBy('c.updated_at', 'desc').orderBy(ordinalId(kernel.dialect, 'c.id')).limit(limit).execute();
275
+ return statement.orderBy(CONVERSATION_UPDATED_AT, 'desc').orderBy(ordinalId(kernel.dialect, 'c.id')).limit(limit).execute();
281
276
  });
282
277
  return rows.map(row => ({ ...toConversation(row), firstUserLine: row.first_user_line ?? '' }));
283
278
  },
@@ -286,12 +281,12 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
286
281
  assertHistoryReadBounds({ limit, fromPosition });
287
282
  const rows = await run(db => {
288
283
  let statement = ownedMessages(db, chatId).clearSelect().select([
289
- 'm.id', 'm.role', 'm.content', 'm.position', 'm.created_at',
284
+ 'm.id', 'm.role', 'm.content', MESSAGE_POSITION, 'm.created_at',
290
285
  ]);
291
286
  if (fromPosition !== undefined)
292
- statement = statement.where('m.position', '>=', fromPosition);
287
+ statement = statement.where(MESSAGE_POSITION, '>=', fromPosition);
293
288
  return statement.select(includeToolEvents ? sql `m.events_json`.as('events_json') : sql `null`.as('events_json'))
294
- .orderBy('m.position', fromPosition === undefined ? 'desc' : 'asc').limit(limit + 1).execute();
289
+ .orderBy(MESSAGE_POSITION, fromPosition === undefined ? 'desc' : 'asc').limit(limit + 1).execute();
295
290
  });
296
291
  const page = rows.slice(0, limit);
297
292
  if (fromPosition === undefined)
@@ -300,7 +295,7 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
300
295
  },
301
296
  },
302
297
  async list() {
303
- const rows = await run((db) => ownedConversations(db).orderBy("c.updated_at", "desc").execute());
298
+ const rows = await run((db) => ownedConversations(db).orderBy(CONVERSATION_UPDATED_AT, "desc").execute());
304
299
  return rows.map((row) => toConversation(row));
305
300
  },
306
301
  get,
@@ -387,10 +382,10 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
387
382
  let query = ownedConversations(db);
388
383
  if (anchor)
389
384
  query = query.where(eb => eb.or([
390
- eb('c.updated_at', '<', anchor[0]),
391
- eb.and([eb('c.updated_at', '=', anchor[0]), eb(id, '>', anchor[1])]),
385
+ eb(CONVERSATION_UPDATED_AT, '<', anchor[0]),
386
+ eb.and([eb(CONVERSATION_UPDATED_AT, '=', anchor[0]), eb(id, '>', anchor[1])]),
392
387
  ]));
393
- return query.orderBy('c.updated_at', 'desc').orderBy(id, 'asc').limit(limit + 1).execute();
388
+ return query.orderBy(CONVERSATION_UPDATED_AT, 'desc').orderBy(id, 'asc').limit(limit + 1).execute();
394
389
  });
395
390
  const items = rows.slice(0, limit).map(row => toConversation(row));
396
391
  const last = items.at(-1);
@@ -404,13 +399,13 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
404
399
  const id = ordinalId(kernel.dialect, 'm.id');
405
400
  // One join, at most limit+1 rows, including the ownership check; no parent pre-read.
406
401
  const rows = await run(db => {
407
- let query = ownedMessages(db, input.conversationId).select('m.position');
402
+ let query = ownedMessages(db, input.conversationId).select(MESSAGE_POSITION);
408
403
  if (anchor)
409
404
  query = query.where(eb => eb.or([
410
- eb('m.position', '>', anchor[0]),
411
- eb.and([eb('m.position', '=', anchor[0]), eb(id, '>', anchor[1])]),
405
+ eb(MESSAGE_POSITION, '>', anchor[0]),
406
+ eb.and([eb(MESSAGE_POSITION, '=', anchor[0]), eb(id, '>', anchor[1])]),
412
407
  ]));
413
- return query.orderBy('m.position', 'asc').orderBy(id, 'asc').limit(limit + 1).execute();
408
+ return query.orderBy(MESSAGE_POSITION, 'asc').orderBy(id, 'asc').limit(limit + 1).execute();
414
409
  });
415
410
  const page = rows.slice(0, limit), last = page.at(-1);
416
411
  return { items: page.map(toMessage), ...(rows.length > limit && last ? {
@@ -435,4 +430,17 @@ export function createSqlChatStore({ kernel, scope: inputScope }, { clock = crea
435
430
  pageMessages: (required, optional = {}) => atStoreBoundary({ operation: () => store.pageMessages(required, optional) }),
436
431
  };
437
432
  }
433
+ function updateOwnedMessageConflict(oc) {
434
+ return oc.column("id")
435
+ .doUpdateSet((eb) => ({
436
+ content: eb.ref("excluded.content"),
437
+ events_json: eb.ref("excluded.events_json"),
438
+ attachments_json: eb.ref("excluded.attachments_json"),
439
+ run_id: eb.ref("excluded.run_id"),
440
+ run_status: eb.ref("excluded.run_status"),
441
+ started_at: eb.ref("excluded.started_at"),
442
+ ended_at: eb.ref("excluded.ended_at"),
443
+ }))
444
+ .where((eb) => eb("ai_chat_messages.conversation_id", "=", eb.ref("excluded.conversation_id")));
445
+ }
438
446
  //# sourceMappingURL=store.js.map