@oar-sh/oar 0.9.24 → 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 +142 -0
- package/README.md +12 -11
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- 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/api-client.js +58 -0
- 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 +85 -25
- 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/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/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/socket-handlers.js +26 -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 +383 -7
- 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/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1410 -360
- package/server/routes/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +19 -0
- package/server/services/database-file.mjs +6 -4
- 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/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/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/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -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 +96 -50
|
@@ -168,7 +168,9 @@ export function createReasoningStreamHandlers({
|
|
|
168
168
|
const reasoningId = reasoningIdFor(event);
|
|
169
169
|
const deltaContent = String(event?.data?.deltaContent ?? event?.data?.delta ?? "");
|
|
170
170
|
if (!deltaContent) return;
|
|
171
|
-
|
|
171
|
+
// Capped before the gate: past the cap the text stops changing, so the
|
|
172
|
+
// same 16 KiB is not posted again and the accumulator stops growing.
|
|
173
|
+
const accumulated = capThought((reasoningAccum.get(reasoningId) || "") + deltaContent);
|
|
172
174
|
reasoningAccum.set(reasoningId, accumulated);
|
|
173
175
|
const lastSent = reasoningLastSent.get(reasoningId) || "";
|
|
174
176
|
if (!shouldEmitDelta(accumulated, lastSent)) return;
|
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,148 @@
|
|
|
3
3
|
All notable changes to OAR are documented here. The format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
|
|
5
5
|
|
|
6
|
+
## [0.9.25] — 2026-11-09
|
|
7
|
+
|
|
8
|
+
Highlights: a media gallery, and agents that make images. Every picture,
|
|
9
|
+
video and file of a session is now in one place, the Media tab of the 🧠
|
|
10
|
+
modal, and 🖼️ Media in the ︙ menu shows those of every session of the
|
|
11
|
+
relay, with filters and a search; the file viewer moves through the whole
|
|
12
|
+
gallery. Agents drive OpenAI Image sessions through `relay_tool`: they send
|
|
13
|
+
pictures to edit and set the size, quality, count, format and background.
|
|
14
|
+
While gpt-image works, the reply shows partial previews; the composer gets
|
|
15
|
+
a custom size, a count, a format, a background and presets, and the
|
|
16
|
+
gpt-image-2.5 and 2.6 models take xhigh and max quality. Every image turn
|
|
17
|
+
shows what it cost, a daily cap of USD 5 per relay unless you change it
|
|
18
|
+
keeps image spend in check, and a failed image turn costs at most two calls
|
|
19
|
+
instead of up to five. The relay's memory no longer grows while several
|
|
20
|
+
agents stream long thinking at once.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Media gallery.** The 🧠 modal has two tabs, Context (where it opens)
|
|
25
|
+
and Media: the session's pictures and videos as a square grid under month
|
|
26
|
+
headers, three columns on a phone, with a Files tab for attached PDFs,
|
|
27
|
+
audio and documents. **🖼️ Media** in the ︙ menu shows the media of every
|
|
28
|
+
session of the relay, with filters (All, Mine, Agent, Generated, a
|
|
29
|
+
provider, a session, Hide archived) and a search. It collects uploads,
|
|
30
|
+
`@file:` pictures, generated images and the pictures agents put in their
|
|
31
|
+
replies; a picture shown in several messages appears once, at its newest
|
|
32
|
+
message, and a deleted local file is left out. A tap opens the file
|
|
33
|
+
viewer, which now moves through the whole gallery (it loads more at the
|
|
34
|
+
end) and offers **Open in session** and **Copy path / link**. The pictures
|
|
35
|
+
on screen that could not load while the relay was away load again once it
|
|
36
|
+
is back; one scrolled out of view waits for the next reconnect.
|
|
37
|
+
Owner only: not in shared views, and no agent reads it through
|
|
38
|
+
`relay_tool`, on this relay or a paired one.
|
|
39
|
+
- **Media index.** The relay keeps the gallery's index in its database
|
|
40
|
+
(migration 0039) and fills it for older messages in the background after
|
|
41
|
+
the upgrade, in short slices.
|
|
42
|
+
- **Agents drive OpenAI Image sessions.** `relay_tool`'s `send` and
|
|
43
|
+
`create_session` take `images`, up to 4 absolute paths of PNG, JPEG or
|
|
44
|
+
WebP files on the target relay, and `image`, the turn's size (any W×H
|
|
45
|
+
within the model's limits), quality, count (1 to 4), output format and
|
|
46
|
+
background; `update_session` with `image` sets a session's own defaults
|
|
47
|
+
(migration 0040). The target relay takes only files inside the session's
|
|
48
|
+
working directory or images one of its sessions generated, follows links
|
|
49
|
+
to the real file, and checks each file's first bytes. Replies list the
|
|
50
|
+
pictures as `images` with their paths there, and a reply without text
|
|
51
|
+
reads like `1 image · 1536x1024 · gpt-image-2`. A paired relay must run
|
|
52
|
+
this version (`imageInputs` in `relay_info`); an older one is refused
|
|
53
|
+
before anything is sent. A new image session without a model starts on
|
|
54
|
+
gpt-image-2.5-flare (else its dated snapshot, else gpt-image-2, else the
|
|
55
|
+
first gpt-image model listed), and is refused when none is listed.
|
|
56
|
+
|
|
57
|
+
- **Image previews.** While a gpt-image model makes an image, the reply's
|
|
58
|
+
bubble shows a timer with the size and quality, then two partial previews
|
|
59
|
+
as OpenAI streams them, then the final image. Previews reach your open
|
|
60
|
+
clients only, never the transcript, shared views, the media gallery or
|
|
61
|
+
agents, and their files go when the turn ends.
|
|
62
|
+
- **Image composer.** An image chat's composer has a Custom… size (any
|
|
63
|
+
W×H the model takes, checked as you type), a Count (1 to 4), a Format
|
|
64
|
+
and a Background for gpt-image models.
|
|
65
|
+
|
|
66
|
+
- **What image turns cost.** Every try of an OpenAI image turn is priced
|
|
67
|
+
as it ends, from the usage OpenAI reports and its published rates, and
|
|
68
|
+
estimated where OpenAI reports no usage for a try that may have been
|
|
69
|
+
billed; a model the relay has no price for counts at the dearest listed
|
|
70
|
+
price. The reply line ends with the cost, the chat menu shows what a
|
|
71
|
+
chat's images cost, and Check Usage has an OpenAI images card.
|
|
72
|
+
- **A daily cap for OpenAI images.** USD 5 per relay and day unless you
|
|
73
|
+
change it (Settings → Providers → OpenAI; 0 is no cap). A request that
|
|
74
|
+
would pass it is not sent, and once it is reached new image messages are
|
|
75
|
+
refused, from you and from agents alike; estimates count toward it. A
|
|
76
|
+
warning shows from 80 %. Allow for today lifts it until midnight.
|
|
77
|
+
- **Image presets.** Five built-in bundles of size, quality, format and
|
|
78
|
+
background, and your own (Save as preset… in the composer); agents name
|
|
79
|
+
one as image.preset.
|
|
80
|
+
- **xhigh and max image quality.** The gpt-image-2.5 and 2.6 models take
|
|
81
|
+
the two higher qualities, in the composer and for agents; other models
|
|
82
|
+
keep auto, low, medium and high. Sizes for gpt-image-2 and later need at
|
|
83
|
+
least 655,360 pixels (1024x640), as OpenAI requires.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- **OpenAI Image turns.** All attached images go to the edit, in order,
|
|
88
|
+
not only the first, and the format and background reach OpenAI. Quality
|
|
89
|
+
is checked per model family before the call (gpt-image: auto, low,
|
|
90
|
+
medium, high). OpenAI gets 3 minutes for a new image and 5 for
|
|
91
|
+
an edit (`openaiImageGenerateTimeoutMs`, `openaiImageEditTimeoutMs`).
|
|
92
|
+
- **Copilot hides image models.** Copilot's `gpt-image-*` and `dall-e-*`
|
|
93
|
+
ids, which its runtime cannot serve, are left out of its model list; a
|
|
94
|
+
Copilot chat on one is refused with a pointer to OpenAI Image.
|
|
95
|
+
|
|
96
|
+
### Fixed
|
|
97
|
+
|
|
98
|
+
- **An image turn costs at most two calls.** A timeout, a server error or
|
|
99
|
+
a broken connection is tried once more, then the turn ends with a note
|
|
100
|
+
that says whether the request may have been billed; before, the turn went
|
|
101
|
+
back into the queue up to five times and ended as "Relay timeout after 5
|
|
102
|
+
attempts", each attempt a new billed request. When the second try works,
|
|
103
|
+
the reply notes that the first may have been billed as well. A relay
|
|
104
|
+
restart during the call no longer repeats it either.
|
|
105
|
+
- **The relay's own image route is local only.** A paired relay's token can
|
|
106
|
+
no longer call it directly, past the queue.
|
|
107
|
+
- **An agent's prompt header no longer reaches the image model.**
|
|
108
|
+
- **Relay memory no longer grows while several agents stream long thinking
|
|
109
|
+
at the same time.** A few sessions thinking at length at once could make
|
|
110
|
+
the relay hold more and more memory until it ran out. Thinking still shows
|
|
111
|
+
live; a slow connection may skip some of the updates in between, and the
|
|
112
|
+
whole thought arrives when it ends and after a reconnect, as before.
|
|
113
|
+
|
|
114
|
+
### Upgrade notes
|
|
115
|
+
|
|
116
|
+
- **The first start takes a little longer on a big database.** Before it
|
|
117
|
+
listens, the relay copies its database to the backups folder, as before,
|
|
118
|
+
and migration 0039 creates the media index and queues every message for
|
|
119
|
+
it once: about 0.2 s per 200,000 messages on a small server. Migration
|
|
120
|
+
0040 adds two columns. Nothing else waits.
|
|
121
|
+
- **The gallery fills in the background.** After the start the relay
|
|
122
|
+
indexes the older messages in slices of about 20 ms every half second,
|
|
123
|
+
in no particular order: about 3 minutes per 200,000 messages on a small
|
|
124
|
+
server, longer on a phone or with long messages. Until it ends, the
|
|
125
|
+
gallery shows what is indexed and "Indexing older messages… (N left)". A
|
|
126
|
+
restart picks up where it stopped.
|
|
127
|
+
- **The daily cap for OpenAI images is on after the update**: USD 5 per
|
|
128
|
+
relay and day, in Settings → Providers → OpenAI (0 is no cap). Without an
|
|
129
|
+
OpenAI key nothing changes, and Check Usage shows no OpenAI images card
|
|
130
|
+
until something was booked in the month.
|
|
131
|
+
- **Sessions still on 0.9.24's worker wait one worker start.** A session
|
|
132
|
+
whose worker still runs the old code is given a new worker before its
|
|
133
|
+
next message, as in 0.9.24. A Claude session's agent gets `images` and
|
|
134
|
+
`image` in `relay_tool` with its new worker.
|
|
135
|
+
- **Mixed versions work together**, except for images across relays: a
|
|
136
|
+
0.9.25 relay refuses to send `images` or `image` to a paired 0.9.24 relay
|
|
137
|
+
and sends nothing, and a 0.9.24 relay cannot send them.
|
|
138
|
+
- **Going back to 0.9.24** needs no database step. Install it with
|
|
139
|
+
`npm install -g @oar-sh/oar@0.9.24` (in a git checkout:
|
|
140
|
+
`git checkout v0.9.24`, then a relay restart). 0.9.24 ignores the media
|
|
141
|
+
index and the image defaults and has no daily image cap, so image turns
|
|
142
|
+
are not limited there. The index's triggers stay in the database and
|
|
143
|
+
queue the messages written on 0.9.24, which a later 0.9.25 indexes. To
|
|
144
|
+
remove the index, drop its two triggers first, then its three tables, as
|
|
145
|
+
`server/migrations/0039-media-index.mjs` describes; a table dropped
|
|
146
|
+
before its trigger breaks every new message.
|
|
147
|
+
|
|
6
148
|
## [0.9.24] — 2026-11-08
|
|
7
149
|
|
|
8
150
|
Highlights: a much smaller install. The Claude and Cursor agent SDKs leave
|
package/README.md
CHANGED
|
@@ -45,29 +45,29 @@ flowchart LR
|
|
|
45
45
|
<table>
|
|
46
46
|
<tr>
|
|
47
47
|
<td width="42%" rowspan="4" valign="top">
|
|
48
|
-
<a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.
|
|
48
|
+
<a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.25" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
|
|
49
49
|
<br><sub>On a phone: the same conversations, installable as an app.</sub>
|
|
50
50
|
</td>
|
|
51
51
|
<td width="58%" valign="top">
|
|
52
|
-
<a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.
|
|
52
|
+
<a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.25" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
|
|
53
53
|
<br><sub>Agents working in parallel, each with live token counts and its own Stop.</sub>
|
|
54
54
|
</td>
|
|
55
55
|
</tr>
|
|
56
56
|
<tr>
|
|
57
57
|
<td valign="top">
|
|
58
|
-
<a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.
|
|
58
|
+
<a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.25" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
|
|
59
59
|
<br><sub>When the agent needs a decision, it asks, and waits for your answer.</sub>
|
|
60
60
|
</td>
|
|
61
61
|
</tr>
|
|
62
62
|
<tr>
|
|
63
63
|
<td valign="top">
|
|
64
|
-
<a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.
|
|
64
|
+
<a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.25" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
|
|
65
65
|
<br><sub>Read the reasoning, not just the answer.</sub>
|
|
66
66
|
</td>
|
|
67
67
|
</tr>
|
|
68
68
|
<tr>
|
|
69
69
|
<td valign="top">
|
|
70
|
-
<a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.
|
|
70
|
+
<a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.25" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
|
|
71
71
|
<br><sub>Agents that watch agents: woken by events, approving nothing.</sub>
|
|
72
72
|
</td>
|
|
73
73
|
</tr>
|
|
@@ -100,7 +100,7 @@ The runtime (the *provider* in the UI) is chosen in **New Chat** and stays fixed
|
|
|
100
100
|
| ------- | ---------- | ------------- |
|
|
101
101
|
| [**GitHub Copilot**](docs/providers.md#github-copilot--engine-choice) (default) | GitHub's coding agent, with every model your Copilot plan offers | The Copilot CLI, signed in on the relay host |
|
|
102
102
|
| [**OpenAI (BYOK)**](docs/providers.md#openai-byok-and-openai-image) | Copilot's agent against OpenAI or a compatible endpoint | Your own API key |
|
|
103
|
-
| [**OpenAI Image**](docs/providers.md#openai-byok-and-openai-image) | A chat whose replies are images | The same key |
|
|
103
|
+
| [**OpenAI Image**](docs/providers.md#openai-byok-and-openai-image) | A chat whose replies are images, with a daily spending cap; agents can use it too | The same key |
|
|
104
104
|
| [**Claude**](docs/providers.md#claude-agent-sdk) | Claude Code on your machine | A Claude login, made from **⚙️ Settings** |
|
|
105
105
|
| [**Claude API**](docs/providers.md#claude-api) | Claude Code on your machine, billed to a Claude Console API key; a Claude chat can switch between the two | A Claude Console API key |
|
|
106
106
|
| [**Claude Cloud**](docs/providers.md#claude-cloud) (experimental) | Claude Code in a sandbox at Anthropic, on a GitHub repository | A claude.ai account with GitHub connected |
|
|
@@ -118,11 +118,11 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
|
|
|
118
118
|
<table>
|
|
119
119
|
<tr>
|
|
120
120
|
<td width="62%" valign="top">
|
|
121
|
-
<a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.
|
|
121
|
+
<a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.25" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
|
|
122
122
|
<br><sub>Send a message while the agent works, and it steers the running turn.</sub>
|
|
123
123
|
</td>
|
|
124
124
|
<td width="38%" valign="top">
|
|
125
|
-
<a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.
|
|
125
|
+
<a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.25" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
|
|
126
126
|
<br><sub>What each plan has left, in one place.</sub>
|
|
127
127
|
</td>
|
|
128
128
|
</tr>
|
|
@@ -134,12 +134,13 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
|
|
|
134
134
|
- **Background work**: a task panel lists the commands and sub-agents that outlive a reply, each with its own **Stop** and its full record; a command's console shows its output live, says when it has gone quiet and counts a test run's passes and failures; the panel and its consoles come back after a relay restart, and a queued restart shows what it waits for and asks after a maximum wait ([Background tasks](docs/web-ui.md#background-tasks), [After a relay restart](docs/web-ui.md#after-a-relay-restart)).
|
|
135
135
|
- **Choose how the agent works**: `ask`, `plan`, `agent` or `autopilot`, plus model and reasoning effort, per message, from the composer's **⚙** ([The composer](docs/web-ui.md#the-composer), [Relay modes](docs/web-ui.md#relay-modes)).
|
|
136
136
|
- **Files and git**: browse the workspace, preview files, review git changes in a diff viewer, and publish a dev server as a preview link ([Files, git, and previews](docs/web-ui.md#files-git-and-previews), [preview servers](docs/preview-servers.md)).
|
|
137
|
-
- **
|
|
137
|
+
- **Media**: the pictures and videos of a session, or of every session, in one gallery, with a file viewer that moves through all of them ([Media](docs/web-ui.md#media)).
|
|
138
|
+
- **Usage and context**: plan limits and credits of every provider, the estimated spend of DeepSeek and Claude API keys and of OpenAI images, and what fills the model's context window ([Usage and context](docs/web-ui.md#usage-and-context)).
|
|
138
139
|
- **Your conversations everywhere**: the history lives on the relay, drafts follow you from device to device, and conversations can be searched, archived, grouped and pinned ([The conversation list](docs/web-ui.md#the-conversation-list), [drafts](docs/web-ui.md#drafts-across-devices), [session groups](docs/web-ui.md#session-groups), [pins](docs/web-ui.md#pinned-messages)).
|
|
139
140
|
- **Share** a conversation as a read-only link, hide single messages and the host's paths from it, and see when it was opened ([Sharing](docs/web-ui.md#sharing)).
|
|
140
141
|
- **On your phone**: install OAR as an app and get push notifications; back walks through the conversations you opened, a pinch zooms the whole app, and held sideways it keeps a phone's layout ([Notifications and the app](docs/web-ui.md#notifications-and-the-app), [back and forward](docs/web-ui.md#back-and-forward), [zoom](docs/web-ui.md#zoom), [a phone held sideways](docs/web-ui.md#a-phone-held-sideways)).
|
|
141
142
|
|
|
142
|
-
The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.
|
|
143
|
+
The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.25](CHANGELOG.md#0925--2026-11-09) as the latest.
|
|
143
144
|
|
|
144
145
|
## Remote access
|
|
145
146
|
|
|
@@ -190,7 +191,7 @@ flowchart LR
|
|
|
190
191
|
|
|
191
192
|
You stay in charge: per paired relay you decide whether agents may only read, also prompt, or do everything, and in the `ask` and `plan` modes every write asks you first. A subscription only notifies; it approves nothing.
|
|
192
193
|
|
|
193
|
-
<p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.
|
|
194
|
+
<p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.25" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
|
|
194
195
|
|
|
195
196
|
How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
|
|
196
197
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oar-sh/oar",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.25",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"server/",
|
|
26
26
|
"!server/**/*.test.mjs",
|
|
27
27
|
"!server/**/*-test-harness.mjs",
|
|
28
|
+
"!server/**/*-test-fixtures.mjs",
|
|
28
29
|
"!server/**/fixtures/",
|
|
29
30
|
"!server/README.md",
|
|
30
31
|
"!server/data/",
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"shared/",
|
|
37
38
|
"!shared/**/*.test.mjs",
|
|
38
39
|
"!shared/**/*-test-harness.mjs",
|
|
40
|
+
"!shared/**/*-test-fixtures.mjs",
|
|
39
41
|
"!shared/**/fixtures/",
|
|
40
42
|
".github/extensions/web-relay/",
|
|
41
43
|
"!.github/extensions/web-relay/**/*.test.mjs",
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
REMOTE_RELAY_THINKING_DISPLAYS,
|
|
16
16
|
} from '../../shared/remote-relay-contract.mjs';
|
|
17
17
|
import { RELAY_ASK_URGENCIES } from '../../shared/relay-asks.mjs';
|
|
18
|
+
import { OPENAI_IMAGE_BACKGROUNDS, OPENAI_IMAGE_OUTPUT_FORMATS } from '../../shared/openai-image-options.mjs';
|
|
18
19
|
import {
|
|
19
20
|
REMOTE_RELAY_CALL_FAILED,
|
|
20
21
|
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
@@ -80,6 +81,20 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
|
|
|
80
81
|
thinking_enabled: z.boolean().optional().describe(FIELDS.thinking_enabled.description),
|
|
81
82
|
planOnly: z.boolean().optional().describe(FIELDS.planOnly.description),
|
|
82
83
|
thinking_display: choice('thinking_display', REMOTE_RELAY_THINKING_DISPLAYS),
|
|
84
|
+
// A list, or one path as text, which the contract takes as well.
|
|
85
|
+
images: z.union([z.array(z.string()), z.string()]).optional().describe(FIELDS.images.description),
|
|
86
|
+
// Loose like quiet_hours: the contract's validator checks the values per
|
|
87
|
+
// model family and names an unknown key; the two fixed lists are enums as
|
|
88
|
+
// in the JSON schema.
|
|
89
|
+
image: z.looseObject({
|
|
90
|
+
size: z.string().optional().describe(FIELDS.image.properties.size.description),
|
|
91
|
+
quality: z.string().optional().describe(FIELDS.image.properties.quality.description),
|
|
92
|
+
count: z.number().optional().describe(FIELDS.image.properties.count.description),
|
|
93
|
+
output_format: z.enum([...OPENAI_IMAGE_OUTPUT_FORMATS]).optional().describe(FIELDS.image.properties.output_format.description),
|
|
94
|
+
background: z.enum([...OPENAI_IMAGE_BACKGROUNDS]).optional().describe(FIELDS.image.properties.background.description),
|
|
95
|
+
// Free text: the target relay knows its presets and resolves the name.
|
|
96
|
+
preset: z.string().optional().describe(FIELDS.image.properties.preset.description),
|
|
97
|
+
}).optional().describe(FIELDS.image.description),
|
|
83
98
|
question_id: text('question_id'),
|
|
84
99
|
// ask: the contract's validator refuses a question or detail that is too
|
|
85
100
|
// long, or more than eight options, with a message the model can act on.
|
|
@@ -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,11 +5027,11 @@ export function createCopilotSdkSessionRunner({
|
|
|
5025
5027
|
dbg('image turn', message.id, result.via, result.outcome);
|
|
5026
5028
|
} catch (error) {
|
|
5027
5029
|
if (isOpenAIImageTerminalError(error)) {
|
|
5028
|
-
//
|
|
5029
|
-
//
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
dbg(
|
|
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);
|
|
5033
5035
|
await publishResponse(message, {
|
|
5034
5036
|
text: error.message,
|
|
5035
5037
|
model: null,
|
|
@@ -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
|
+
}
|