@jini-ai/daemon 0.6.7 → 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.
- package/CHANGELOG.md +8 -0
- package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
- package/dist/agent-executor/antigravity-permissions.js +64 -0
- package/dist/agent-executor/contracts.d.ts +31 -6
- package/dist/agent-executor/event-translation.d.ts +6 -1
- package/dist/agent-executor/event-translation.js +1 -1
- package/dist/agent-executor/events.d.ts +1 -0
- package/dist/agent-executor/events.js +85 -74
- package/dist/agent-executor/launch.d.ts +4 -2
- package/dist/agent-executor/launch.js +306 -217
- package/dist/agent-executor/mcp-bridge-probe.js +51 -35
- package/dist/agent-executor/mcp-bridge.d.ts +18 -8
- package/dist/agent-executor/mcp-bridge.js +105 -65
- package/dist/agent-executor/run-output.d.ts +20 -0
- package/dist/agent-executor/run-output.js +80 -0
- package/dist/attachment-content.js +18 -15
- package/dist/core/read-only-tools.d.ts +26 -3
- package/dist/core/read-only-tools.js +38 -5
- package/dist/core/tool-audit.d.ts +3 -1
- package/dist/core/tool-audit.js +35 -22
- package/dist/core/tool-candidates.d.ts +8 -0
- package/dist/core/tool-candidates.js +6 -0
- package/dist/core/tool-gaps.d.ts +60 -0
- package/dist/core/tool-gaps.js +199 -0
- package/dist/delegated-tool-bridge.d.ts +1 -1
- package/dist/delegated-tool-bridge.js +161 -122
- package/dist/event-log.d.ts +45 -2
- package/dist/event-log.js +83 -4
- package/dist/http/attachments.d.ts +26 -1
- package/dist/http/attachments.js +129 -65
- package/dist/http/connectors.d.ts +1 -32
- package/dist/http/connectors.js +13 -68
- package/dist/http/delegated-tools.d.ts +5 -1
- package/dist/http/delegated-tools.js +29 -29
- package/dist/http/host-tools.d.ts +1 -0
- package/dist/http/host-tools.js +64 -59
- package/dist/http/index.d.ts +2 -2
- package/dist/http/index.js +1 -1
- package/dist/http/media.js +48 -69
- package/dist/http/memory.js +45 -36
- package/dist/http/model-proxy-fields.d.ts +23 -0
- package/dist/http/model-proxy-fields.js +29 -0
- package/dist/http/model-proxy.js +193 -395
- package/dist/http/research-tavily.adapter.js +72 -49
- package/dist/http/research.js +33 -23
- package/dist/http/resource-request.d.ts +37 -0
- package/dist/http/resource-request.js +59 -0
- package/dist/http/routines.js +49 -90
- package/dist/http/runs.d.ts +7 -5
- package/dist/http/runs.js +21 -7
- package/dist/http/terminals.js +23 -48
- package/dist/http/tool-catalog.d.ts +5 -0
- package/dist/http/xai.d.ts +11 -1
- package/dist/http/xai.js +125 -124
- package/dist/legacy-data-migration.js +41 -24
- package/dist/run/core/retry.js +51 -35
- package/dist/run/diagnostics/diagnostics.js +5 -17
- package/dist/run-credentials.js +49 -27
- package/dist/run-lifecycle.d.ts +12 -2
- package/dist/run-lifecycle.js +47 -26
- package/dist/store/agent-sessions/sqlite.d.ts +6 -0
- package/dist/store/agent-sessions/sqlite.js +6 -4
- package/dist/surface-exchanges.js +2 -1
- package/dist/terminal-session.d.ts +1 -1
- package/dist/terminal-session.js +26 -33
- package/dist/tool-audit.d.ts +1 -0
- package/dist/tool-audit.js +1 -0
- package/dist/tool-executor.d.ts +24 -3
- package/dist/tool-executor.js +277 -139
- package/dist/tool-recovery/recovery.js +64 -35
- package/dist/tool-recovery/stack.d.ts +2 -1
- package/dist/tool-recovery/stack.js +1 -1
- package/package.json +10 -10
package/dist/http/terminals.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const cols =
|
|
79
|
-
if (cols
|
|
80
|
-
return
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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;
|
package/dist/http/xai.d.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
/**
|
|
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
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
/**
|
|
546
|
-
|
|
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: [
|
|
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
|
|
602
|
+
const guard = {
|
|
603
|
+
signal: timeoutController.signal,
|
|
604
|
+
timedOutMessage: `xAI request timed out after ${resolved.searchTimeoutMs}ms`,
|
|
605
|
+
accessToken,
|
|
606
|
+
};
|
|
579
607
|
try {
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
220
|
+
copied = stagePayload(legacyDir, stagingDir, payloadEntries, fs);
|
|
228
221
|
promoted = promoteStaged({ stagingDir: stagingDir, dataDir: dataDir, entries: copied }, { filesystem: fs });
|
|
229
|
-
if (
|
|
230
|
-
|
|
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
|
}
|