@jini-ai/daemon 0.6.8 → 0.6.9

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 (71) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
  3. package/dist/agent-executor/antigravity-permissions.js +64 -0
  4. package/dist/agent-executor/contracts.d.ts +31 -6
  5. package/dist/agent-executor/event-translation.d.ts +6 -1
  6. package/dist/agent-executor/event-translation.js +1 -1
  7. package/dist/agent-executor/events.d.ts +1 -0
  8. package/dist/agent-executor/events.js +85 -74
  9. package/dist/agent-executor/launch.d.ts +4 -2
  10. package/dist/agent-executor/launch.js +306 -217
  11. package/dist/agent-executor/mcp-bridge-probe.js +51 -35
  12. package/dist/agent-executor/mcp-bridge.d.ts +18 -8
  13. package/dist/agent-executor/mcp-bridge.js +105 -65
  14. package/dist/agent-executor/run-output.d.ts +20 -0
  15. package/dist/agent-executor/run-output.js +80 -0
  16. package/dist/attachment-content.js +18 -15
  17. package/dist/core/read-only-tools.d.ts +15 -0
  18. package/dist/core/read-only-tools.js +29 -1
  19. package/dist/core/tool-audit.d.ts +3 -1
  20. package/dist/core/tool-audit.js +35 -22
  21. package/dist/core/tool-gaps.d.ts +60 -0
  22. package/dist/core/tool-gaps.js +199 -0
  23. package/dist/delegated-tool-bridge.d.ts +1 -1
  24. package/dist/delegated-tool-bridge.js +161 -122
  25. package/dist/event-log.d.ts +45 -2
  26. package/dist/event-log.js +83 -4
  27. package/dist/http/attachments.d.ts +26 -1
  28. package/dist/http/attachments.js +129 -65
  29. package/dist/http/connectors.d.ts +1 -32
  30. package/dist/http/connectors.js +13 -68
  31. package/dist/http/delegated-tools.d.ts +5 -1
  32. package/dist/http/delegated-tools.js +29 -29
  33. package/dist/http/host-tools.d.ts +1 -0
  34. package/dist/http/host-tools.js +64 -59
  35. package/dist/http/index.d.ts +2 -2
  36. package/dist/http/index.js +1 -1
  37. package/dist/http/media.js +48 -69
  38. package/dist/http/memory.js +45 -36
  39. package/dist/http/model-proxy-fields.d.ts +23 -0
  40. package/dist/http/model-proxy-fields.js +29 -0
  41. package/dist/http/model-proxy.js +193 -395
  42. package/dist/http/research-tavily.adapter.js +72 -49
  43. package/dist/http/research.js +33 -23
  44. package/dist/http/resource-request.d.ts +37 -0
  45. package/dist/http/resource-request.js +59 -0
  46. package/dist/http/routines.js +49 -90
  47. package/dist/http/runs.d.ts +7 -5
  48. package/dist/http/runs.js +21 -7
  49. package/dist/http/terminals.js +23 -48
  50. package/dist/http/tool-catalog.d.ts +5 -0
  51. package/dist/http/xai.d.ts +11 -1
  52. package/dist/http/xai.js +125 -124
  53. package/dist/legacy-data-migration.js +41 -24
  54. package/dist/run/core/retry.js +51 -35
  55. package/dist/run/diagnostics/diagnostics.js +5 -17
  56. package/dist/run-credentials.js +49 -27
  57. package/dist/run-lifecycle.d.ts +12 -2
  58. package/dist/run-lifecycle.js +47 -26
  59. package/dist/store/agent-sessions/sqlite.d.ts +6 -0
  60. package/dist/store/agent-sessions/sqlite.js +6 -4
  61. package/dist/surface-exchanges.js +2 -1
  62. package/dist/terminal-session.d.ts +1 -1
  63. package/dist/terminal-session.js +26 -33
  64. package/dist/tool-audit.d.ts +1 -0
  65. package/dist/tool-audit.js +1 -0
  66. package/dist/tool-executor.d.ts +24 -3
  67. package/dist/tool-executor.js +276 -139
  68. package/dist/tool-recovery/recovery.js +64 -35
  69. package/dist/tool-recovery/stack.d.ts +2 -1
  70. package/dist/tool-recovery/stack.js +1 -1
  71. package/package.json +10 -10
@@ -33,15 +33,13 @@ import { guardSameOrigin } from '@jini-ai/http-kit';
33
33
  import { validationError } from '@jini-ai/http-kit';
34
34
  import { sendApiError, statusForError } from '@jini-ai/http-kit';
35
35
  import { createSseChannel, requestedAfterCursor } from '@jini-ai/http-kit';
36
- import { denyAllWorkspaceRoots, resolveWorkspaceRoot, WorkspaceRootDeniedError } from './workspace-root.js';
36
+ import { denyAllWorkspaceRoots } from './workspace-root.js';
37
+ import { isRecord, readOptionalNonEmptyString, readOptionalNumber, resolveResourceRoot, withoutUndefined } from './resource-request.js';
37
38
  import { err, ok } from '@jini-ai/http-kit';
38
39
  function defaultTerminalsInternalErrorSink(context) {
39
40
  // eslint-disable-next-line no-console
40
41
  console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
41
42
  }
42
- function isRecord(value) {
43
- return typeof value === 'object' && value !== null && !Array.isArray(value);
44
- }
45
43
  /** Every one-off `terminal.create` tool call gets its own opaque `RunRef` — matching `db-ops.ts`'s `freshRun()`: these are single requests, not steps of a longer-lived agent run. */
46
44
  function freshRun() {
47
45
  return { id: randomUUID() };
@@ -65,37 +63,27 @@ function toolResultToApiResult(deps, result) {
65
63
  }
66
64
  }
67
65
  function parseTerminalCreate(input) {
68
- if (!isRecord(input.body))
66
+ const body = input.body;
67
+ if (!isRecord(body))
69
68
  return err({ error: validationError({ message: 'body must be a JSON object' }) });
70
- const resourceRef = input.body.resourceRef;
69
+ const resourceRef = body.resourceRef;
71
70
  if (typeof resourceRef !== 'string' || resourceRef.length === 0) {
72
71
  return err({ error: validationError({ message: 'resourceRef is required' }, { issues: [{ path: 'resourceRef', message: 'required non-empty string' }] }) });
73
72
  }
74
- const detail = input.body.detail;
75
- if (detail !== undefined && (typeof detail !== 'string' || detail.length === 0)) {
76
- return err({ error: validationError({ message: 'detail must be a non-empty string when provided' }) });
77
- }
78
- const cols = input.body.cols;
79
- if (cols !== undefined && typeof cols !== 'number') {
80
- return err({ error: validationError({ message: 'cols must be a number when provided' }) });
81
- }
82
- const rows = input.body.rows;
83
- if (rows !== undefined && typeof rows !== 'number') {
84
- return err({ error: validationError({ message: 'rows must be a number when provided' }) });
85
- }
86
- const shell = input.body.shell;
87
- if (shell !== undefined && (typeof shell !== 'string' || shell.length === 0)) {
88
- return err({ error: validationError({ message: 'shell must be a non-empty string when provided' }) });
89
- }
90
- return ok({
91
- value: {
92
- resourceRef,
93
- ...(detail === undefined ? {} : { detail }),
94
- ...(cols === undefined ? {} : { cols }),
95
- ...(rows === undefined ? {} : { rows }),
96
- ...(shell === undefined ? {} : { shell }),
97
- }
98
- });
73
+ // Checked in this fixed order: the first invalid field is the one reported.
74
+ const detail = readOptionalNonEmptyString({ body, field: 'detail' });
75
+ if (!detail.ok)
76
+ return detail;
77
+ const cols = readOptionalNumber({ body, field: 'cols' });
78
+ if (!cols.ok)
79
+ return cols;
80
+ const rows = readOptionalNumber({ body, field: 'rows' });
81
+ if (!rows.ok)
82
+ return rows;
83
+ const shell = readOptionalNonEmptyString({ body, field: 'shell' });
84
+ if (!shell.ok)
85
+ return shell;
86
+ return ok({ value: { resourceRef, ...withoutUndefined({ detail: detail.value, cols: cols.value, rows: rows.value, shell: shell.value }) } });
99
87
  }
100
88
  /**
101
89
  * `POST /api/terminals` — the one gated call (see module doc). Resolves
@@ -105,23 +93,10 @@ function parseTerminalCreate(input) {
105
93
  */
106
94
  export const terminalCreateRoute = defineJsonRoute({
107
95
  method: 'post', path: '/api/terminals', parse: parseTerminalCreate, handle: async ({ input, deps }) => {
108
- let cwd;
109
- try {
110
- cwd = await resolveWorkspaceRoot({ request: { resourceRef: input.resourceRef, ...(input.detail === undefined ? {} : { detail: input.detail }) } }, { resolver: deps.resolveRoot ?? denyAllWorkspaceRoots });
111
- }
112
- catch (error) {
113
- if (error instanceof WorkspaceRootDeniedError) {
114
- return err({ error: createApiError({ code: 'NOT_FOUND', message: `resource "${input.resourceRef}" was not found` }) });
115
- }
116
- throw error;
117
- }
118
- const toolInput = {
119
- resourceRef: input.resourceRef,
120
- cwd,
121
- ...(input.cols === undefined ? {} : { cols: input.cols }),
122
- ...(input.rows === undefined ? {} : { rows: input.rows }),
123
- ...(input.shell === undefined ? {} : { shell: input.shell }),
124
- };
96
+ const root = await resolveResourceRoot({ resourceRef: input.resourceRef, detail: input.detail }, { resolver: deps.resolveRoot ?? denyAllWorkspaceRoots });
97
+ if (!root.ok)
98
+ return root;
99
+ const toolInput = { resourceRef: input.resourceRef, cwd: root.value, ...withoutUndefined({ cols: input.cols, rows: input.rows, shell: input.shell }) };
125
100
  const result = await deps.toolExecutor.execute({ principal: deps.principal, run: freshRun(), toolId: TERMINAL_CREATE_TOOL_ID, input: toolInput });
126
101
  return toolResultToApiResult(deps, result);
127
102
  }
@@ -18,15 +18,20 @@
18
18
  * gate is still `ToolExecutor`, reached only via `execute_delegated_tool`
19
19
  * (`packages/daemon/src/delegated-tool-bridge.ts`) — finding a tool id here grants nothing.
20
20
  */
21
+ import type { AgentToolSideEffect } from '@jini-ai/core';
21
22
  import type { Express } from 'express';
22
23
  import { type AdapterContext } from '@jini-ai/http-kit';
23
24
  export interface ToolCatalogSearchHit {
25
+ readonly readOnly?: boolean;
26
+ readonly sideEffects?: AgentToolSideEffect | 'unknown';
24
27
  readonly id: string;
25
28
  readonly description: string;
26
29
  readonly source: string;
27
30
  readonly score: number;
28
31
  }
29
32
  export interface ToolCatalogEntry {
33
+ readonly readOnly?: boolean;
34
+ readonly sideEffects?: AgentToolSideEffect | 'unknown';
30
35
  readonly id: string;
31
36
  readonly description: string;
32
37
  readonly inputSchema?: unknown;
@@ -1,5 +1,5 @@
1
1
  import type { Express } from 'express';
2
- import { PendingAuthCache, startOAuthCallbackListener, type OAuthCallbackListener, type OAuthPkceProviderConfig } from '@jini-ai/agent-runtime';
2
+ import { PendingAuthCache, startOAuthCallbackListener, type OAuthCallbackListener, type OAuthPkceProviderConfig, type OAuthTokenResponse, type StoredOAuthToken } from '@jini-ai/agent-runtime';
3
3
  import { type AdapterContext } from '@jini-ai/http-kit';
4
4
  export interface XaiInternalErrorContext {
5
5
  readonly source: 'oauth-start' | 'oauth-complete' | 'oauth-disconnect' | 'auth-status' | 'search';
@@ -37,6 +37,16 @@ export interface XaiHttpDeps {
37
37
  /** Host-owned sink for the real exception behind a generic `INTERNAL_ERROR` response (SEC-005). Defaults to `console.error`. */
38
38
  readonly onInternalError?: (context: XaiInternalErrorContext) => void;
39
39
  }
40
+ /**
41
+ * Maps a token-endpoint response onto the persisted record. Pure (the save instant is a parameter)
42
+ * so the mapping is testable on its own. `token_type` is optional on the wire type; today's
43
+ * exchange path always supplies it (openid-client rejects a response without one), and an absent
44
+ * value is stored as RFC 6750's `Bearer`. Exported for this module's tests; not re-exported.
45
+ */
46
+ export declare function toStoredOAuthToken({ tokenResp, nowMs }: {
47
+ tokenResp: OAuthTokenResponse;
48
+ nowMs: number;
49
+ }): StoredOAuthToken;
40
50
  export interface XaiOauthStartResponse {
41
51
  readonly authorizeUrl: string;
42
52
  readonly state: string;
package/dist/http/xai.js CHANGED
@@ -138,9 +138,11 @@ function defaultInternalErrorSink(context) {
138
138
  }
139
139
  /** Resolves every optional `XaiHttpDeps` field to a concrete value. Idempotent — calling this again on an already-resolved object (e.g. `registerXaiRoutes`'s own single resolution, passed through to each route's `handle`) is a harmless pass-through. See module doc on why stateful fields (`pending`/`listenerRef`) must be resolved once per registration, not once per request. */
140
140
  function resolveXaiHttpDeps(deps) {
141
+ return { ...resolveOAuthFlowDeps(deps), ...resolveStorageAndSearchDeps(deps) };
142
+ }
143
+ /** The OAuth dance's provider, loopback-callback and in-flight state defaults. */
144
+ function resolveOAuthFlowDeps(deps) {
141
145
  return {
142
- dataDir: deps.dataDir ?? process.cwd(),
143
- tokenFileName: deps.tokenFileName ?? DEFAULT_TOKEN_FILE_NAME,
144
146
  providerConfig: deps.providerConfig ?? XAI_OAUTH_PROVIDER_CONFIG,
145
147
  callbackHost: deps.callbackHost ?? XAI_OAUTH_REDIRECT_HOST,
146
148
  callbackPort: deps.callbackPort ?? XAI_OAUTH_REDIRECT_PORT,
@@ -148,6 +150,13 @@ function resolveXaiHttpDeps(deps) {
148
150
  pending: deps.pending ?? new PendingAuthCache({}, { ttlMs: DEFAULT_PENDING_AUTH_TTL_MS }),
149
151
  listenerRef: deps.listenerRef ?? { current: null },
150
152
  startCallbackListener: deps.startCallbackListener ?? startOAuthCallbackListener,
153
+ };
154
+ }
155
+ /** Token storage, search transport and error-sink defaults. */
156
+ function resolveStorageAndSearchDeps(deps) {
157
+ return {
158
+ dataDir: deps.dataDir ?? process.cwd(),
159
+ tokenFileName: deps.tokenFileName ?? DEFAULT_TOKEN_FILE_NAME,
151
160
  searchBaseUrl: deps.searchBaseUrl ?? DEFAULT_SEARCH_BASE_URL,
152
161
  searchDefaultModel: deps.searchDefaultModel ?? DEFAULT_SEARCH_MODEL,
153
162
  fetchImpl: deps.fetchImpl ?? fetch,
@@ -201,19 +210,29 @@ function redactError(error) {
201
210
  const message = error instanceof Error ? error.message : String(error);
202
211
  return new Error(redactSecrets({ input: message }));
203
212
  }
204
- async function persistToken(resolved, tokenResp) {
213
+ /**
214
+ * Maps a token-endpoint response onto the persisted record. Pure (the save instant is a parameter)
215
+ * so the mapping is testable on its own. `token_type` is optional on the wire type; today's
216
+ * exchange path always supplies it (openid-client rejects a response without one), and an absent
217
+ * value is stored as RFC 6750's `Bearer`. Exported for this module's tests; not re-exported.
218
+ */
219
+ export function toStoredOAuthToken({ tokenResp, nowMs }) {
205
220
  const stored = {
206
221
  accessToken: tokenResp.access_token,
207
222
  tokenType: tokenResp.token_type ?? 'Bearer',
208
- savedAt: Date.now(),
223
+ savedAt: nowMs,
209
224
  };
210
225
  if (tokenResp.refresh_token)
211
226
  stored.refreshToken = tokenResp.refresh_token;
212
227
  if (tokenResp.scope)
213
228
  stored.scope = tokenResp.scope;
214
229
  if (typeof tokenResp.expires_in === 'number') {
215
- stored.expiresAt = Date.now() + tokenResp.expires_in * 1000;
230
+ stored.expiresAt = nowMs + tokenResp.expires_in * 1000;
216
231
  }
232
+ return stored;
233
+ }
234
+ async function persistToken(resolved, tokenResp) {
235
+ const stored = toStoredOAuthToken({ tokenResp, nowMs: Date.now() });
217
236
  await setStoredOAuthToken({ dataDir: resolved.dataDir, fileName: resolved.tokenFileName, token: stored });
218
237
  }
219
238
  /** Invoked when the loopback listener actually receives xAI's redirect (the real-browser path, as opposed to `oauth/complete`'s manual paste-back path — xAI's own docs note the loopback redirect commonly shows the user a code to paste rather than following the redirect). No HTTP response to write here; failures are only reported through the internal-error sink. */
@@ -448,107 +467,69 @@ function parseXaiSearch(input) {
448
467
  const query = nonEmptyString(body.query);
449
468
  if (!query)
450
469
  return err({ error: validationError({ message: 'query must be a non-empty string' }, { issues: [{ path: 'query', message: 'required non-empty string' }] }) });
451
- const allowedXHandles = parseOptionalStringArray(body, 'allowedXHandles');
452
- if (!allowedXHandles.ok)
453
- return allowedXHandles;
454
- const excludedXHandles = parseOptionalStringArray(body, 'excludedXHandles');
455
- if (!excludedXHandles.ok)
456
- return excludedXHandles;
457
- const fromDate = parseOptionalString(body, 'fromDate');
458
- if (!fromDate.ok)
459
- return fromDate;
460
- const toDate = parseOptionalString(body, 'toDate');
461
- if (!toDate.ok)
462
- return toDate;
463
- const enableImageUnderstanding = parseOptionalBoolean(body, 'enableImageUnderstanding');
464
- if (!enableImageUnderstanding.ok)
465
- return enableImageUnderstanding;
466
- const enableVideoUnderstanding = parseOptionalBoolean(body, 'enableVideoUnderstanding');
467
- if (!enableVideoUnderstanding.ok)
468
- return enableVideoUnderstanding;
469
- const model = parseOptionalString(body, 'model');
470
- if (!model.ok)
471
- return model;
472
- return ok({
473
- value: {
474
- query,
475
- ...(allowedXHandles.value?.length ? { allowedXHandles: allowedXHandles.value } : {}),
476
- ...(excludedXHandles.value?.length ? { excludedXHandles: excludedXHandles.value } : {}),
477
- ...(fromDate.value ? { fromDate: fromDate.value } : {}),
478
- ...(toDate.value ? { toDate: toDate.value } : {}),
479
- ...(enableImageUnderstanding.value !== undefined ? { enableImageUnderstanding: enableImageUnderstanding.value } : {}),
480
- ...(enableVideoUnderstanding.value !== undefined ? { enableVideoUnderstanding: enableVideoUnderstanding.value } : {}),
481
- ...(model.value ? { model: model.value } : {}),
482
- }
483
- });
470
+ const fields = {};
471
+ for (const [key, parse] of XAI_SEARCH_OPTIONAL_FIELDS) {
472
+ const parsed = parse(body, key);
473
+ if (!parsed.ok)
474
+ return parsed;
475
+ if (isProvidedSearchValue(parsed.value))
476
+ fields[key] = parsed.value;
477
+ }
478
+ return ok({ value: { query, ...fields } });
479
+ }
480
+ /** Table order is the validation order: the first failing field is the one a caller sees. */
481
+ const XAI_SEARCH_OPTIONAL_FIELDS = [
482
+ ['allowedXHandles', parseOptionalStringArray],
483
+ ['excludedXHandles', parseOptionalStringArray],
484
+ ['fromDate', parseOptionalString],
485
+ ['toDate', parseOptionalString],
486
+ ['enableImageUnderstanding', parseOptionalBoolean],
487
+ ['enableVideoUnderstanding', parseOptionalBoolean],
488
+ ['model', parseOptionalString],
489
+ ];
490
+ /** An empty string or empty handle list counts as not provided (dropped from the request); `false` is kept. */
491
+ function isProvidedSearchValue(value) {
492
+ if (Array.isArray(value))
493
+ return value.length > 0;
494
+ return value !== undefined && value !== '';
495
+ }
496
+ /** Every well-formed `output[].content[]` block of a Responses-API payload, skipping malformed items and blocks. */
497
+ function outputContentBlocks(data) {
498
+ const output = data.output;
499
+ if (!Array.isArray(output))
500
+ return [];
501
+ return output.filter(isRecord).flatMap((item) => (Array.isArray(item.content) ? item.content.filter(isRecord) : []));
484
502
  }
485
- /** Pulls the assistant's main text out of an xAI Responses-API payload — the convenience `output_text` field, or the structured `output[].content[].text` form. Ported verbatim from OD's `extractAnswerText`. */
503
+ /** Pulls the assistant's main text out of an xAI Responses-API payload — the convenience `output_text` field, or the structured `output[].content[].text` form. Ported from OD's `extractAnswerText` (same behaviour; the walk is shared with `extractUrlCitations` via `outputContentBlocks`). */
486
504
  function extractAnswerText(data) {
487
505
  if (!isRecord(data))
488
506
  return '';
489
507
  const direct = data.output_text;
490
508
  if (typeof direct === 'string' && direct.trim())
491
509
  return direct;
492
- const output = data.output;
493
- if (!Array.isArray(output))
494
- return '';
495
- const chunks = [];
496
- for (const item of output) {
497
- if (!isRecord(item))
498
- continue;
499
- const content = item.content;
500
- if (!Array.isArray(content))
501
- continue;
502
- for (const block of content) {
503
- if (!isRecord(block))
504
- continue;
505
- const t = block.text;
506
- if (typeof t === 'string' && t)
507
- chunks.push(t);
508
- }
509
- }
510
+ const chunks = outputContentBlocks(data)
511
+ .map((block) => block.text)
512
+ .filter((text) => typeof text === 'string' && text.length > 0);
510
513
  return chunks.join('\n').trim();
511
514
  }
512
- /** Walks every annotation in the Responses-API output and returns the unique URL citations (`type: 'url_citation'`). Ported verbatim from OD's `extractUrlCitations`. */
515
+ /** The trimmed URL of one `url_citation` annotation, or `''` for anything else. */
516
+ function urlCitationOf(annotation) {
517
+ if (!isRecord(annotation) || annotation.type !== 'url_citation')
518
+ return '';
519
+ return typeof annotation.url === 'string' ? annotation.url.trim() : '';
520
+ }
521
+ /** Walks every annotation in the Responses-API output and returns the unique URL citations (`type: 'url_citation'`). Ported from OD's `extractUrlCitations` (same behaviour, first-seen order). */
513
522
  function extractUrlCitations(data) {
514
523
  if (!isRecord(data))
515
524
  return [];
516
- const output = data.output;
517
- if (!Array.isArray(output))
518
- return [];
519
- const urls = new Set();
520
- for (const item of output) {
521
- if (!isRecord(item))
522
- continue;
523
- const content = item.content;
524
- if (!Array.isArray(content))
525
- continue;
526
- for (const block of content) {
527
- if (!isRecord(block))
528
- continue;
529
- const annotations = block.annotations;
530
- if (!Array.isArray(annotations))
531
- continue;
532
- for (const ann of annotations) {
533
- if (!isRecord(ann))
534
- continue;
535
- if (ann.type !== 'url_citation')
536
- continue;
537
- const url = typeof ann.url === 'string' ? ann.url.trim() : '';
538
- if (url)
539
- urls.add(url);
540
- }
541
- }
542
- }
543
- return [...urls];
525
+ const urls = outputContentBlocks(data)
526
+ .flatMap((block) => (Array.isArray(block.annotations) ? block.annotations : []))
527
+ .map(urlCitationOf)
528
+ .filter((url) => url.length > 0);
529
+ return [...new Set(urls)];
544
530
  }
545
- /** Calls xAI's real `POST {searchBaseUrl}/responses` endpoint with the `x_search` native tool, using `accessToken` as the bearer credential. Throws (never a `Result`) on any failure — `xaiSearchRoute.handle` is the one place that converts a thrown error into this package's SEC-005 response shape. Any thrown message is pre-redacted of `accessToken` here so the sink never even logs it unredacted. */
546
- async function callXaiSearch(resolved, accessToken, request) {
547
- const baseUrlCheck = validateBaseUrl({ baseUrl: resolved.searchBaseUrl });
548
- if (baseUrlCheck.error)
549
- throw new Error(baseUrlCheck.error);
550
- const base = resolved.searchBaseUrl.replace(/\/+$/, '');
551
- const model = request.model ?? resolved.searchDefaultModel;
531
+ /** Builds the `x_search` native tool payload, translating only the provided fields to xAI's snake_case names. */
532
+ function buildXSearchTool(request) {
552
533
  const xSearchTool = { type: 'x_search' };
553
534
  if (request.allowedXHandles?.length)
554
535
  xSearchTool.allowed_x_handles = request.allowedXHandles;
@@ -562,10 +543,53 @@ async function callXaiSearch(resolved, accessToken, request) {
562
543
  xSearchTool.enable_image_understanding = true;
563
544
  if (request.enableVideoUnderstanding === true)
564
545
  xSearchTool.enable_video_understanding = true;
546
+ return xSearchTool;
547
+ }
548
+ /** A thrown fetch/body-read failure as the timeout message when the deadline fired, else as a `prefix: message` with `accessToken` redacted. */
549
+ function xaiCallFailure(guard, prefix, error) {
550
+ if (guard.signal.aborted)
551
+ return new Error(guard.timedOutMessage);
552
+ const message = error instanceof Error ? error.message : String(error);
553
+ return new Error(redactSecrets({ input: `${prefix}: ${message}` }, { exactSecrets: [guard.accessToken] }));
554
+ }
555
+ async function postXaiResponses({ fetchImpl, url, requestBody, guard }) {
556
+ let response;
557
+ try {
558
+ response = await fetchImpl(url, {
559
+ method: 'POST',
560
+ headers: { authorization: `Bearer ${guard.accessToken}`, 'content-type': 'application/json' },
561
+ body: JSON.stringify(requestBody),
562
+ signal: guard.signal,
563
+ });
564
+ }
565
+ catch (error) {
566
+ throw xaiCallFailure(guard, 'xAI request failed', error);
567
+ }
568
+ if (!response.ok) {
569
+ const text = await response.text().catch(() => '');
570
+ throw new Error(redactSecrets({ input: `xAI ${response.status}: ${text.slice(0, 240) || 'no body'}` }, { exactSecrets: [guard.accessToken] }));
571
+ }
572
+ return response;
573
+ }
574
+ async function readXaiJson(response, guard) {
575
+ try {
576
+ return await response.json();
577
+ }
578
+ catch (error) {
579
+ throw xaiCallFailure(guard, 'xAI response could not be read', error);
580
+ }
581
+ }
582
+ /** Calls xAI's real `POST {searchBaseUrl}/responses` endpoint with the `x_search` native tool, using `accessToken` as the bearer credential. Throws (never a `Result`) on any failure — `xaiSearchRoute.handle` is the one place that converts a thrown error into this package's SEC-005 response shape. Any thrown message is pre-redacted of `accessToken` here so the sink never even logs it unredacted. */
583
+ async function callXaiSearch(resolved, accessToken, request) {
584
+ const baseUrlCheck = validateBaseUrl({ baseUrl: resolved.searchBaseUrl });
585
+ if (baseUrlCheck.error)
586
+ throw new Error(baseUrlCheck.error);
587
+ const base = resolved.searchBaseUrl.replace(/\/+$/, '');
588
+ const model = request.model ?? resolved.searchDefaultModel;
565
589
  const requestBody = {
566
590
  model,
567
591
  input: [{ role: 'user', content: request.query }],
568
- tools: [xSearchTool],
592
+ tools: [buildXSearchTool(request)],
569
593
  store: false,
570
594
  };
571
595
  // One deadline covering the request *and* the body read, mirroring `research.ts`'s Tavily call.
@@ -575,37 +599,14 @@ async function callXaiSearch(resolved, accessToken, request) {
575
599
  // does not leave one armed.
576
600
  const timeoutController = new AbortController();
577
601
  const timeoutHandle = setTimeout(() => timeoutController.abort(), resolved.searchTimeoutMs);
578
- const timedOutMessage = `xAI request timed out after ${resolved.searchTimeoutMs}ms`;
602
+ const guard = {
603
+ signal: timeoutController.signal,
604
+ timedOutMessage: `xAI request timed out after ${resolved.searchTimeoutMs}ms`,
605
+ accessToken,
606
+ };
579
607
  try {
580
- let response;
581
- try {
582
- response = await resolved.fetchImpl(`${base}/responses`, {
583
- method: 'POST',
584
- headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
585
- body: JSON.stringify(requestBody),
586
- signal: timeoutController.signal,
587
- });
588
- }
589
- catch (error) {
590
- if (timeoutController.signal.aborted)
591
- throw new Error(timedOutMessage);
592
- const message = error instanceof Error ? error.message : String(error);
593
- throw new Error(redactSecrets({ input: `xAI request failed: ${message}` }, { exactSecrets: [accessToken] }));
594
- }
595
- if (!response.ok) {
596
- const text = await response.text().catch(() => '');
597
- throw new Error(redactSecrets({ input: `xAI ${response.status}: ${text.slice(0, 240) || 'no body'}` }, { exactSecrets: [accessToken] }));
598
- }
599
- let data;
600
- try {
601
- data = await response.json();
602
- }
603
- catch (error) {
604
- if (timeoutController.signal.aborted)
605
- throw new Error(timedOutMessage);
606
- const message = error instanceof Error ? error.message : String(error);
607
- throw new Error(redactSecrets({ input: `xAI response could not be read: ${message}` }, { exactSecrets: [accessToken] }));
608
- }
608
+ const response = await postXaiResponses({ fetchImpl: resolved.fetchImpl, url: `${base}/responses`, requestBody, guard });
609
+ const data = await readXaiJson(response, guard);
609
610
  return { answer: extractAnswerText(data), citations: extractUrlCitations(data), model };
610
611
  }
611
612
  finally {
@@ -180,27 +180,13 @@ function writeMarkerDefault({ dataDir, legacyDir, markerFile }, fs) {
180
180
  throw err;
181
181
  }
182
182
  }
183
- /**
184
- * One-shot, idempotent legacy data migrator. Synchronous so it can run at
185
- * module import time before a database connection opens. Throws
186
- * {@link LegacyMigrationError} on operator-actionable failures (env set but
187
- * path invalid; `dataDir` already populated; symlink in payload).
188
- *
189
- * @param options - Legacy/current dir paths plus the host's payload config.
190
- * @returns The migration outcome (`noop` when unconfigured, `skipped` when
191
- * already migrated, `migrated` with the copied entry list otherwise).
192
- */
193
- export function migrateLegacyDataDirSync(requiredArgs, optionalArgs = {}) {
194
- const options = { ...requiredArgs, ...optionalArgs };
195
- const fs = options.filesystem ?? nodeFilesystem;
196
- const log = options.logger ?? { info: ({ message }) => console.log(`[jini-migrate] ${message}`) };
197
- const markerFile = options.markerFile ?? DEFAULT_MARKER_FILE;
198
- const raw = options.legacyDir;
183
+ /** Resolve both roots and decide the no-op/skip outcomes; a plan means migration must proceed. */
184
+ function planMigration({ legacyDir: raw, dataDir: rawDataDir, markerFile }, fs) {
199
185
  if (raw === undefined || raw.length === 0) {
200
186
  return { status: 'noop', reason: 'legacy data dir not configured' };
201
187
  }
202
188
  const legacyDir = path.resolve(raw);
203
- const dataDir = path.resolve(options.dataDir);
189
+ const dataDir = path.resolve(rawDataDir);
204
190
  if (legacyDir === dataDir) {
205
191
  return { status: 'noop', reason: 'legacy data dir equals current data dir' };
206
192
  }
@@ -211,23 +197,30 @@ export function migrateLegacyDataDirSync(requiredArgs, optionalArgs = {}) {
211
197
  if (fs.existsSync(markerPath)) {
212
198
  return { status: 'skipped', reason: 'migration marker already present' };
213
199
  }
214
- if (!legacyDirHasPayload({ legacyDir: legacyDir, config: options }, { filesystem: fs })) {
215
- throw new LegacyMigrationError({ code: 'legacy_dir_invalid', message: `legacy data dir "${legacyDir}" is not a usable legacy data dir (expected "${options.proofEntry}" directly inside it).` });
200
+ return { legacyDir, dataDir, markerPath };
201
+ }
202
+ /** Refuse loudly when the legacy source is unusable or the destination already holds payload. */
203
+ function assertMigratable({ legacyDir, dataDir, config }, fs) {
204
+ if (!legacyDirHasPayload({ legacyDir: legacyDir, config }, { filesystem: fs })) {
205
+ throw new LegacyMigrationError({ code: 'legacy_dir_invalid', message: `legacy data dir "${legacyDir}" is not a usable legacy data dir (expected "${config.proofEntry}" directly inside it).` });
216
206
  }
217
- const existing = dataDirHasExistingPayload({ dataDir: dataDir, config: options }, { filesystem: fs });
207
+ const existing = dataDirHasExistingPayload({ dataDir: dataDir, config }, { filesystem: fs });
218
208
  if (existing.length > 0) {
219
209
  throw new LegacyMigrationError({ code: 'data_dir_not_empty', message: `data dir "${dataDir}" already contains payload entries (${existing.join(', ')}); refusing to merge legacy data on top. Move the existing data aside or pick a fresh data root before retrying.` });
220
210
  }
221
- log.info({ message: `migrating legacy data from "${legacyDir}" to "${dataDir}"` });
211
+ }
212
+ /** Stage, promote and mark; any failure removes promoted entries, the marker and the staging dir. */
213
+ function stagePromoteAndMark({ plan, markerFile, payloadEntries, writeMarker }, fs) {
214
+ const { legacyDir, dataDir, markerPath } = plan;
222
215
  fs.mkdirSync(path.dirname(dataDir), { recursive: true });
223
216
  const stagingDir = path.join(path.dirname(dataDir), `${path.basename(dataDir)}.migrate-${process.pid}-${Date.now()}`);
224
217
  let copied;
225
218
  let promoted = [];
226
219
  try {
227
- copied = stagePayload(legacyDir, stagingDir, options.payloadEntries, fs);
220
+ copied = stagePayload(legacyDir, stagingDir, payloadEntries, fs);
228
221
  promoted = promoteStaged({ stagingDir: stagingDir, dataDir: dataDir, entries: copied }, { filesystem: fs });
229
- if (options.writeMarker)
230
- options.writeMarker({ dataDir, legacyDir, markerFile });
222
+ if (writeMarker)
223
+ writeMarker({ dataDir, legacyDir, markerFile });
231
224
  else
232
225
  writeMarkerDefault({ dataDir, legacyDir, markerFile }, fs);
233
226
  }
@@ -238,6 +231,30 @@ export function migrateLegacyDataDirSync(requiredArgs, optionalArgs = {}) {
238
231
  throw err;
239
232
  }
240
233
  fs.rmSync(stagingDir, { recursive: true, force: true });
234
+ return copied;
235
+ }
236
+ /**
237
+ * One-shot, idempotent legacy data migrator. Synchronous so it can run at
238
+ * module import time before a database connection opens. Throws
239
+ * {@link LegacyMigrationError} on operator-actionable failures (env set but
240
+ * path invalid; `dataDir` already populated; symlink in payload).
241
+ *
242
+ * @param options - Legacy/current dir paths plus the host's payload config.
243
+ * @returns The migration outcome (`noop` when unconfigured, `skipped` when
244
+ * already migrated, `migrated` with the copied entry list otherwise).
245
+ */
246
+ export function migrateLegacyDataDirSync(requiredArgs, optionalArgs = {}) {
247
+ const options = { ...requiredArgs, ...optionalArgs };
248
+ const fs = options.filesystem ?? nodeFilesystem;
249
+ const log = options.logger ?? { info: ({ message }) => console.log(`[jini-migrate] ${message}`) };
250
+ const markerFile = options.markerFile ?? DEFAULT_MARKER_FILE;
251
+ const plan = planMigration({ legacyDir: options.legacyDir, dataDir: options.dataDir, markerFile }, fs);
252
+ if ('status' in plan)
253
+ return plan;
254
+ const { legacyDir, dataDir } = plan;
255
+ assertMigratable({ legacyDir, dataDir, config: options }, fs);
256
+ log.info({ message: `migrating legacy data from "${legacyDir}" to "${dataDir}"` });
257
+ const copied = stagePromoteAndMark({ plan, markerFile, payloadEntries: options.payloadEntries, writeMarker: options.writeMarker }, fs);
241
258
  log.info({ message: `migration complete: copied ${copied.length} entr${copied.length === 1 ? 'y' : 'ies'} (${copied.join(', ')})` });
242
259
  return { status: 'migrated', reason: 'copied legacy payload', copied };
243
260
  }