@oar-sh/oar 0.9.23 → 0.9.25
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/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +307 -0
- package/README.md +12 -11
- package/bin/oar.js +54 -4
- package/package.json +11 -7
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +149 -8
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +289 -94
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +171 -0
- package/server/public/app/socket-handlers.js +47 -0
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +478 -9
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1467 -343
- package/server/routes/sessions-routes.mjs +596 -123
- package/server/server-runtime.mjs +414 -81
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +23 -5
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +4 -2
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/oar-cli-helpers.mjs +72 -9
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +386 -22
- package/server/services/remote-relay-registry-service.mjs +3 -1
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/token-usage-service.mjs +15 -0
- package/server/services/update-install-service.mjs +2 -2
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/image-header.mjs +90 -0
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- package/shared/worker-runtime/direct-openai-image.mjs +98 -48
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadAgentSdk } from '../agent-sdk-loader.mjs';
|
|
2
2
|
|
|
3
3
|
import { formatClaudeBackgroundCommandLimitLabel } from '../../shared/claude-background-command-limit.mjs';
|
|
4
4
|
import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
|
|
@@ -9,6 +9,9 @@ import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
|
|
|
9
9
|
import { createRemoteRelayToolDefinition } from './claude-remote-relay-tool.mjs';
|
|
10
10
|
import { CLAUDE_LIVE_FLAG_SETTINGS_CODE, CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE } from './claude-turn-failure.mjs';
|
|
11
11
|
|
|
12
|
+
// The SDK the relay installed for this worker (agent-sdk-loader.mjs).
|
|
13
|
+
const { createSdkMcpServer, query } = await loadAgentSdk('claude');
|
|
14
|
+
|
|
12
15
|
export { CLAUDE_LIVE_FLAG_SETTINGS_CODE };
|
|
13
16
|
|
|
14
17
|
// The name the in-process MCP server is registered under; it prefixes every
|
|
@@ -555,6 +555,11 @@ export function createSdkMessageNormalizer() {
|
|
|
555
555
|
const thinking = state.thinking.get(index);
|
|
556
556
|
if (thinking) {
|
|
557
557
|
thinking.text = capThought(thinking.text + String(event?.delta?.thinking || ''));
|
|
558
|
+
// Gated as stream text: a delta can be one token, and every update
|
|
559
|
+
// carries the whole thought. Past the cap the text stops changing,
|
|
560
|
+
// so nothing more goes out until the block's final frame.
|
|
561
|
+
if (!shouldEmitStreamUpdate(thinking.text, thinking.lastEmittedText)) return actions;
|
|
562
|
+
thinking.lastEmittedText = thinking.text;
|
|
558
563
|
actions.push({
|
|
559
564
|
channel: 'thought',
|
|
560
565
|
payload: {
|
|
@@ -715,7 +720,7 @@ export function createSdkMessageNormalizer() {
|
|
|
715
720
|
const assistantError = lastAssistantError;
|
|
716
721
|
lastAssistantError = '';
|
|
717
722
|
const apiErrorStatus = Number(sdkMessage.api_error_status ?? NaN);
|
|
718
|
-
return [{
|
|
723
|
+
return [...flushOpenThoughts(), {
|
|
719
724
|
channel: 'result',
|
|
720
725
|
payload: {
|
|
721
726
|
text: String(sdkMessage.result || '').trim(),
|
|
@@ -745,6 +750,29 @@ export function createSdkMessageNormalizer() {
|
|
|
745
750
|
return [];
|
|
746
751
|
}
|
|
747
752
|
|
|
753
|
+
// A thinking block the turn left open (aborted, failed) never gets its
|
|
754
|
+
// final frame: what the delta gating held back goes out with the result,
|
|
755
|
+
// so the stored thought ends with the last delta.
|
|
756
|
+
function flushOpenThoughts() {
|
|
757
|
+
const actions = [];
|
|
758
|
+
for (const state of threads.values()) {
|
|
759
|
+
for (const thinking of state.thinking.values()) {
|
|
760
|
+
if (!thinking.text.trim() || thinking.text === thinking.lastEmittedText) continue;
|
|
761
|
+
thinking.lastEmittedText = thinking.text;
|
|
762
|
+
actions.push({
|
|
763
|
+
channel: 'thought',
|
|
764
|
+
payload: {
|
|
765
|
+
reasoningId: thinking.reasoningId,
|
|
766
|
+
text: thinking.text,
|
|
767
|
+
done: false,
|
|
768
|
+
subagentRunId: subagentRunIdFor(state.key === 'main' ? null : state.key),
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return actions;
|
|
774
|
+
}
|
|
775
|
+
|
|
748
776
|
function finalStreamText() {
|
|
749
777
|
return threadState(null).streamText;
|
|
750
778
|
}
|
|
@@ -5005,10 +5005,12 @@ export function createCopilotSdkSessionRunner({
|
|
|
5005
5005
|
|
|
5006
5006
|
/**
|
|
5007
5007
|
* One image-model row (shared/worker-runtime/direct-openai-image.mjs). With
|
|
5008
|
-
* an image operation the relay's execute route publishes the reply
|
|
5009
|
-
* failure
|
|
5010
|
-
*
|
|
5011
|
-
*
|
|
5008
|
+
* an image operation the relay's execute route publishes the reply or the
|
|
5009
|
+
* failure itself; without one the shared helper publishes the reply, and
|
|
5010
|
+
* any failure of the request is published here as the row's failure, never
|
|
5011
|
+
* requeued: the relay already retried it once, and a second run is billed
|
|
5012
|
+
* again. Only an execute call that did not get through requeues the row
|
|
5013
|
+
* (the route refuses to send an operation's request twice).
|
|
5012
5014
|
*/
|
|
5013
5015
|
async function runDirectImageTurn(message) {
|
|
5014
5016
|
const id = String(message.id || '');
|
|
@@ -5025,15 +5027,18 @@ export function createCopilotSdkSessionRunner({
|
|
|
5025
5027
|
dbg('image turn', message.id, result.via, result.outcome);
|
|
5026
5028
|
} catch (error) {
|
|
5027
5029
|
if (isOpenAIImageTerminalError(error)) {
|
|
5028
|
-
|
|
5030
|
+
// The relay's note and code (the ones its image operation uses for
|
|
5031
|
+
// the same cases); an older relay names no code.
|
|
5032
|
+
const code = error.imageCode
|
|
5033
|
+
|| (error.relayProcessing === true ? 'image-reply-unprocessed' : 'image-provider-rejected');
|
|
5034
|
+
dbg('image turn failed', message.id, code, error.message);
|
|
5029
5035
|
await publishResponse(message, {
|
|
5030
5036
|
text: error.message,
|
|
5031
5037
|
model: null,
|
|
5032
5038
|
terminalError: {
|
|
5033
5039
|
kind: 'openai-image-failed',
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
stableCode: 'image-provider-rejected',
|
|
5040
|
+
code,
|
|
5041
|
+
stableCode: code,
|
|
5037
5042
|
message: error.message,
|
|
5038
5043
|
...(error.detail ? { detail: String(error.detail) } : {}),
|
|
5039
5044
|
failedAt: new Date().toISOString(),
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
|
|
4
|
+
import { loadAgentSdk } from '../agent-sdk-loader.mjs';
|
|
5
|
+
|
|
4
6
|
import {
|
|
5
7
|
createTurnLiveness,
|
|
6
8
|
createTurnStalledError,
|
|
@@ -18,7 +20,7 @@ import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '
|
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
async function defaultAgentFactory() {
|
|
21
|
-
const { Agent } = await
|
|
23
|
+
const { Agent } = await loadAgentSdk('cursor');
|
|
22
24
|
return {
|
|
23
25
|
create: (options) => Agent.create(options),
|
|
24
26
|
resume: (agentId, options) => Agent.resume(agentId, options),
|
|
@@ -30,7 +32,7 @@ async function defaultAgentFactory() {
|
|
|
30
32
|
// `storeFactoryImpl(storePath, { cwd })` with the real store rooted in the
|
|
31
33
|
// storePath's directory.
|
|
32
34
|
async function defaultStoreFactory() {
|
|
33
|
-
const { SqliteLocalAgentStore } = await
|
|
35
|
+
const { SqliteLocalAgentStore } = await loadAgentSdk('cursor', { entry: 'sqlite' });
|
|
34
36
|
return (storePath, { cwd } = {}) =>
|
|
35
37
|
SqliteLocalAgentStore.open({
|
|
36
38
|
workspaceRef: cwd || path.dirname(storePath),
|
|
@@ -497,7 +499,7 @@ async function defaultModelsList({ apiKey }) {
|
|
|
497
499
|
// The namespace form is the only working call shape: `Cursor` has a private
|
|
498
500
|
// constructor and `models` is a static member, so the old client-instance
|
|
499
501
|
// fallback could only ever throw a TypeError that masked the real error.
|
|
500
|
-
const sdk = await
|
|
502
|
+
const sdk = await loadAgentSdk('cursor');
|
|
501
503
|
return sdk.Cursor.models.list({ apiKey });
|
|
502
504
|
}
|
|
503
505
|
|
|
@@ -170,6 +170,9 @@ export function createSdkMessageNormalizer() {
|
|
|
170
170
|
deltaOwnsThinking = true;
|
|
171
171
|
if (!openThought) openThought = { reasoningId: allocateThoughtId(), text: '' };
|
|
172
172
|
openThought.text = capThought(openThought.text + String(update?.text || ''));
|
|
173
|
+
// Gated as stream text; the thought's close sends the whole text.
|
|
174
|
+
if (!shouldEmitStreamUpdate(openThought.text, openThought.lastEmittedText)) return actions;
|
|
175
|
+
openThought.lastEmittedText = openThought.text;
|
|
173
176
|
actions.push({
|
|
174
177
|
channel: 'thought',
|
|
175
178
|
payload: {
|
|
@@ -201,6 +204,14 @@ export function createSdkMessageNormalizer() {
|
|
|
201
204
|
|
|
202
205
|
if (type === 'turn-ended') {
|
|
203
206
|
if (update?.usage && typeof update.usage === 'object') lastUsage = update.usage;
|
|
207
|
+
// A thought the turn left open: what the delta gating held back.
|
|
208
|
+
if (openThought?.text.trim() && openThought.text !== openThought.lastEmittedText) {
|
|
209
|
+
openThought.lastEmittedText = openThought.text;
|
|
210
|
+
actions.push({
|
|
211
|
+
channel: 'thought',
|
|
212
|
+
payload: { reasoningId: openThought.reasoningId, text: openThought.text, done: false, subagentRunId: null },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
204
215
|
return actions;
|
|
205
216
|
}
|
|
206
217
|
|
|
@@ -231,6 +242,8 @@ export function createSdkMessageNormalizer() {
|
|
|
231
242
|
state.openThought = { reasoningId: `cursor-thought-${runId}-${state.thoughtIndex++}`, text: '' };
|
|
232
243
|
}
|
|
233
244
|
state.openThought.text = capThought(state.openThought.text + String(nested.text || ''));
|
|
245
|
+
if (!shouldEmitStreamUpdate(state.openThought.text, state.openThought.lastEmittedText)) return actions;
|
|
246
|
+
state.openThought.lastEmittedText = state.openThought.text;
|
|
234
247
|
actions.push({
|
|
235
248
|
channel: 'thought',
|
|
236
249
|
payload: {
|
package/server/db-schema.mjs
CHANGED
|
@@ -44,6 +44,8 @@ import { migrateConversationPlanOnly } from './migrations/0035-conversation-plan
|
|
|
44
44
|
import { migrateTokenUsage } from './migrations/0036-token-usage.mjs';
|
|
45
45
|
import { migrateRelayAsksQueueId } from './migrations/0037-relay-asks-queue-id.mjs';
|
|
46
46
|
import { migrateTurnSeq } from './migrations/0038-turn-seq.mjs';
|
|
47
|
+
import { migrateMediaIndex } from './migrations/0039-media-index.mjs';
|
|
48
|
+
import { migrateImageSessionDefaults } from './migrations/0040-image-session-defaults.mjs';
|
|
47
49
|
import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
|
|
48
50
|
|
|
49
51
|
// Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
|
|
@@ -1096,6 +1098,23 @@ try {
|
|
|
1096
1098
|
console.warn(`[turn-seq] migration 0038 failed; retrying on next boot: ${error?.message || error}`);
|
|
1097
1099
|
}
|
|
1098
1100
|
|
|
1101
|
+
// The media gallery index: tables, the two queue triggers on messages and a
|
|
1102
|
+
// one-time seed of the queue. The gallery works without it (empty), so a
|
|
1103
|
+
// failure is logged and retried next boot.
|
|
1104
|
+
try {
|
|
1105
|
+
migrateMediaIndex(db);
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
console.warn(`[media-index] migration 0039 failed; retrying on next boot: ${error?.message || error}`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// An image session's own size/quality/count defaults. Image turns run on the
|
|
1111
|
+
// built-in defaults without it, so a failure is logged and retried next boot.
|
|
1112
|
+
try {
|
|
1113
|
+
migrateImageSessionDefaults(db);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
console.warn(`[image-sessions] migration 0040 failed; retrying on next boot: ${error?.message || error}`);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1099
1118
|
const conversationColumns = db.prepare(`PRAGMA table_info(conversations)`).all().map((c) => c.name);
|
|
1100
1119
|
if (!conversationColumns.includes('archived')) {
|
|
1101
1120
|
db.exec(`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`);
|
|
@@ -148,6 +148,9 @@ export function createSdkMessageNormalizer() {
|
|
|
148
148
|
openThought = { reasoningId: allocateThoughtId(), text: '' };
|
|
149
149
|
}
|
|
150
150
|
openThought.text = capThought(openThought.text + chunk);
|
|
151
|
+
// Gated as stream text; the thought's close sends the whole text.
|
|
152
|
+
if (!shouldEmitStreamUpdate(openThought.text, openThought.lastEmittedText)) return actions;
|
|
153
|
+
openThought.lastEmittedText = openThought.text;
|
|
151
154
|
actions.push({
|
|
152
155
|
channel: 'thought',
|
|
153
156
|
payload: {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: the media gallery index.
|
|
5
|
+
*
|
|
6
|
+
* `media_items`: one row per picture, video or attached file of a message
|
|
7
|
+
* (services/media-extract.mjs decides what counts). Rows go with their
|
|
8
|
+
* message or conversation (foreign keys, ON DELETE CASCADE). `message_ts`
|
|
9
|
+
* copies the message time so the gallery pages by an index.
|
|
10
|
+
*
|
|
11
|
+
* `media_index_queue`: the ids of messages to (re)index. Two triggers on
|
|
12
|
+
* `messages` put a row id here on insert and when a column the index reads
|
|
13
|
+
* changes; they never parse anything, so the write path pays one small
|
|
14
|
+
* insert. The indexer (services/media-index-service.mjs) drains the queue in
|
|
15
|
+
* batches. `seq` grows on every re-queue, so a drain only removes the queue
|
|
16
|
+
* row it actually indexed.
|
|
17
|
+
*
|
|
18
|
+
* `media_index_meta`: the extractor version the queue was seeded for. When
|
|
19
|
+
* it is missing (first run) or older than MEDIA_EXTRACTOR_VERSION, every
|
|
20
|
+
* message is queued once and the version is stored; later boots seed nothing.
|
|
21
|
+
*
|
|
22
|
+
* Not in SCHEMA_SQL (db-schema.mjs): the triggers name columns that older
|
|
23
|
+
* migrations add. The triggers are checked on every boot and re-created when
|
|
24
|
+
* missing or different, like the FTS triggers.
|
|
25
|
+
*
|
|
26
|
+
* Additive and idempotent. Rolling back: drop the triggers first, then the
|
|
27
|
+
* tables (a trigger left pointing at a dropped table breaks message inserts):
|
|
28
|
+
* DROP TRIGGER IF EXISTS media_index_on_insert;
|
|
29
|
+
* DROP TRIGGER IF EXISTS media_index_on_update;
|
|
30
|
+
* DROP TABLE IF EXISTS media_index_queue; DROP TABLE IF EXISTS media_items;
|
|
31
|
+
* DROP TABLE IF EXISTS media_index_meta;
|
|
32
|
+
*
|
|
33
|
+
* Usage: node server/migrations/0039-media-index.mjs [path/to/oar.db]
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { fileURLToPath } from 'url';
|
|
37
|
+
import path from 'path';
|
|
38
|
+
import process from 'process';
|
|
39
|
+
import Database from 'better-sqlite3';
|
|
40
|
+
import { cliDbPath } from '../services/database-file.mjs';
|
|
41
|
+
import { MEDIA_EXTRACTOR_VERSION } from '../services/media-extract.mjs';
|
|
42
|
+
|
|
43
|
+
export const MEDIA_INDEX_TRIGGERS = Object.freeze(['media_index_on_insert', 'media_index_on_update']);
|
|
44
|
+
// Columns the index reads; a change to one re-queues the message.
|
|
45
|
+
const WATCHED_COLUMNS = Object.freeze(['text', 'attachments', 'timestamp', 'role', 'kind', 'origin_json', 'conversation_id']);
|
|
46
|
+
|
|
47
|
+
const QUEUE_UPSERT = `INSERT INTO media_index_queue (message_id, seq) VALUES (NEW.id, 0)
|
|
48
|
+
ON CONFLICT(message_id) DO UPDATE SET seq = seq + 1;`;
|
|
49
|
+
|
|
50
|
+
function tableExists(db, name) {
|
|
51
|
+
return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function columnsOf(db, table) {
|
|
55
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mediaIndexTriggerSql(messageColumns) {
|
|
59
|
+
const watched = WATCHED_COLUMNS.filter((column) => messageColumns.includes(column));
|
|
60
|
+
return {
|
|
61
|
+
media_index_on_insert: `CREATE TRIGGER media_index_on_insert AFTER INSERT ON messages BEGIN
|
|
62
|
+
${QUEUE_UPSERT}
|
|
63
|
+
END`,
|
|
64
|
+
media_index_on_update: `CREATE TRIGGER media_index_on_update AFTER UPDATE OF ${watched.join(', ')} ON messages BEGIN
|
|
65
|
+
${QUEUE_UPSERT}
|
|
66
|
+
END`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function migrateMediaIndex(db) {
|
|
71
|
+
if (!tableExists(db, 'messages') || !tableExists(db, 'conversations')) {
|
|
72
|
+
return { applied: false, tablesCreated: 0, triggersCreated: 0, seeded: 0 };
|
|
73
|
+
}
|
|
74
|
+
let tablesCreated = 0;
|
|
75
|
+
let triggersCreated = 0;
|
|
76
|
+
let seeded = 0;
|
|
77
|
+
const run = db.transaction(() => {
|
|
78
|
+
if (!tableExists(db, 'media_items')) {
|
|
79
|
+
db.exec(`
|
|
80
|
+
CREATE TABLE media_items (
|
|
81
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
83
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
84
|
+
ordinal INTEGER NOT NULL,
|
|
85
|
+
role TEXT NOT NULL,
|
|
86
|
+
sender TEXT NOT NULL,
|
|
87
|
+
origin TEXT NOT NULL,
|
|
88
|
+
locator_kind TEXT NOT NULL,
|
|
89
|
+
locator TEXT NOT NULL,
|
|
90
|
+
identity_key TEXT NOT NULL,
|
|
91
|
+
category TEXT NOT NULL,
|
|
92
|
+
media_kind TEXT NOT NULL,
|
|
93
|
+
mime TEXT,
|
|
94
|
+
name TEXT,
|
|
95
|
+
alt TEXT,
|
|
96
|
+
size_bytes INTEGER,
|
|
97
|
+
message_ts TEXT NOT NULL,
|
|
98
|
+
UNIQUE (message_id, ordinal)
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX idx_media_items_conv ON media_items(conversation_id, category, message_ts DESC, id DESC);
|
|
101
|
+
CREATE INDEX idx_media_items_all ON media_items(category, message_ts DESC, id DESC);
|
|
102
|
+
CREATE INDEX idx_media_items_identity ON media_items(identity_key);
|
|
103
|
+
`);
|
|
104
|
+
tablesCreated += 1;
|
|
105
|
+
}
|
|
106
|
+
if (!tableExists(db, 'media_index_queue')) {
|
|
107
|
+
db.exec(`
|
|
108
|
+
CREATE TABLE media_index_queue (
|
|
109
|
+
message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
|
|
110
|
+
seq INTEGER NOT NULL DEFAULT 0
|
|
111
|
+
)
|
|
112
|
+
`);
|
|
113
|
+
tablesCreated += 1;
|
|
114
|
+
}
|
|
115
|
+
if (!tableExists(db, 'media_index_meta')) {
|
|
116
|
+
db.exec(`CREATE TABLE media_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
117
|
+
tablesCreated += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const expected = mediaIndexTriggerSql(columnsOf(db, 'messages'));
|
|
121
|
+
for (const name of MEDIA_INDEX_TRIGGERS) {
|
|
122
|
+
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?`).get(name);
|
|
123
|
+
if (row?.sql === expected[name]) continue;
|
|
124
|
+
if (row) db.exec(`DROP TRIGGER ${name}`);
|
|
125
|
+
db.exec(expected[name]);
|
|
126
|
+
triggersCreated += 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const version = db.prepare(`SELECT value FROM media_index_meta WHERE key = 'extractor_version'`).get()?.value;
|
|
130
|
+
if (String(version || '') !== String(MEDIA_EXTRACTOR_VERSION)) {
|
|
131
|
+
seeded = db.prepare(`
|
|
132
|
+
INSERT INTO media_index_queue (message_id, seq)
|
|
133
|
+
SELECT id, 0 FROM messages WHERE true
|
|
134
|
+
ON CONFLICT(message_id) DO UPDATE SET seq = seq + 1
|
|
135
|
+
`).run().changes;
|
|
136
|
+
db.prepare(`INSERT INTO media_index_meta (key, value) VALUES ('extractor_version', ?)
|
|
137
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(MEDIA_EXTRACTOR_VERSION));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
run();
|
|
141
|
+
return { applied: tablesCreated + triggersCreated + seeded > 0, tablesCreated, triggersCreated, seeded };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function migrate(dbPath, options = {}) {
|
|
145
|
+
const db = new Database(dbPath, options);
|
|
146
|
+
try {
|
|
147
|
+
return migrateMediaIndex(db);
|
|
148
|
+
} finally {
|
|
149
|
+
db.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
154
|
+
if (isMain) {
|
|
155
|
+
try {
|
|
156
|
+
const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
|
|
157
|
+
const result = migrate(dbPath);
|
|
158
|
+
console.log(`Migration 0039 on ${dbPath}`);
|
|
159
|
+
console.log(result.applied
|
|
160
|
+
? ` media index: tables created ${result.tablesCreated}, triggers created ${result.triggersCreated}, messages queued ${result.seeded}`
|
|
161
|
+
: ' skipped: nothing to change');
|
|
162
|
+
process.exit(0);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(`Migration 0039 failed: ${err?.message || err}`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: an image session's own defaults, and a turn's options.
|
|
5
|
+
*
|
|
6
|
+
* `image_sessions.defaults_json`: the size, quality, count, output format and
|
|
7
|
+
* background an image session's turns use unless a turn names its own
|
|
8
|
+
* (relay_tool update_session `image`, the conversation settings route).
|
|
9
|
+
* JSON as shared/openai-image-options.mjs validates it; NULL means none.
|
|
10
|
+
*
|
|
11
|
+
* `queue.image_options_json`: the options of one image turn as the send
|
|
12
|
+
* settled them, for a turn without an image operation (the worker sends them
|
|
13
|
+
* with its direct request). NULL for every other turn.
|
|
14
|
+
*
|
|
15
|
+
* The image tables are created by migrateImageConversationSchema
|
|
16
|
+
* (repositories/image-conversation-repository.mjs), which this migration
|
|
17
|
+
* calls first, additively, so a database whose tables are missing gets them
|
|
18
|
+
* before the column. A database without the queue table (no relay's) is
|
|
19
|
+
* left alone.
|
|
20
|
+
*
|
|
21
|
+
* Additive and idempotent. Safe to run on every boot. Rolling back needs no
|
|
22
|
+
* data change: older code ignores the column.
|
|
23
|
+
*
|
|
24
|
+
* Usage: node server/migrations/0040-image-session-defaults.mjs [path/to/oar.db]
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { fileURLToPath } from 'url';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import process from 'process';
|
|
30
|
+
import Database from 'better-sqlite3';
|
|
31
|
+
import { cliDbPath } from '../services/database-file.mjs';
|
|
32
|
+
import { migrateImageConversationSchema } from '../repositories/image-conversation-repository.mjs';
|
|
33
|
+
|
|
34
|
+
export function hasImageSessionDefaultsColumn(db) {
|
|
35
|
+
try {
|
|
36
|
+
return db.prepare(`PRAGMA table_info(image_sessions)`).all().some((column) => column.name === 'defaults_json');
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tableExists(db, name) {
|
|
43
|
+
return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasQueueImageOptionsColumn(db) {
|
|
47
|
+
try {
|
|
48
|
+
return db.prepare(`PRAGMA table_info(queue)`).all().some((column) => column.name === 'image_options_json');
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function migrateImageSessionDefaults(db) {
|
|
55
|
+
// The image schema reads the queue; a database without one is no relay's.
|
|
56
|
+
if (!tableExists(db, 'queue')) return { applied: false, columnsAdded: 0 };
|
|
57
|
+
migrateImageConversationSchema(db);
|
|
58
|
+
let columnsAdded = 0;
|
|
59
|
+
if (!hasImageSessionDefaultsColumn(db)) {
|
|
60
|
+
db.exec(`ALTER TABLE image_sessions ADD COLUMN defaults_json TEXT`);
|
|
61
|
+
columnsAdded += 1;
|
|
62
|
+
}
|
|
63
|
+
if (!hasQueueImageOptionsColumn(db)) {
|
|
64
|
+
db.exec(`ALTER TABLE queue ADD COLUMN image_options_json TEXT`);
|
|
65
|
+
columnsAdded += 1;
|
|
66
|
+
}
|
|
67
|
+
return { applied: columnsAdded > 0, columnsAdded };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function migrate(dbPath, options = {}) {
|
|
71
|
+
const db = new Database(dbPath, options);
|
|
72
|
+
try {
|
|
73
|
+
return migrateImageSessionDefaults(db);
|
|
74
|
+
} finally {
|
|
75
|
+
db.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
80
|
+
if (isMain) {
|
|
81
|
+
try {
|
|
82
|
+
// The given path, else the database in server/data (oar.db, else the
|
|
83
|
+
// name before 0.9.20); never a new one.
|
|
84
|
+
const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
|
|
85
|
+
const result = migrate(dbPath);
|
|
86
|
+
console.log(`Migration 0040 on ${dbPath}`);
|
|
87
|
+
console.log(result.applied ? ` image session defaults, turn options: columns added ${result.columnsAdded}` : ' skipped: nothing to change');
|
|
88
|
+
process.exit(0);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error(`Migration 0040 failed: ${err?.message || err}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|