@obtoai/agent-bridge 0.1.0-beta.22 → 0.1.0-beta.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/README.md +9 -1
- package/package.json +1 -1
- package/src/bridge-http.js +24 -2
- package/src/capabilities.js +10 -6
- package/src/claude-driver.js +109 -5
- package/src/codex-driver.js +19 -3
- package/src/daemon.js +37 -3
- package/src/external-scanner.js +74 -1
- package/src/history.js +170 -0
- package/src/opencode-driver.js +19 -3
- package/src/opencode-sqlite-scanner.js +209 -0
package/README.md
CHANGED
|
@@ -14,7 +14,15 @@ obto-bridge start # daemon connects, you're live
|
|
|
14
14
|
|
|
15
15
|
## Status
|
|
16
16
|
|
|
17
|
-
**Public beta.** Self-serve
|
|
17
|
+
**Public beta.** Self-serve. `obto-bridge init` creates an account inline — no waiting on an invite, no support email loop.
|
|
18
|
+
|
|
19
|
+
## Pricing
|
|
20
|
+
|
|
21
|
+
**$10/month, flat per account** — with a **14-day free trial** (card required at subscribe; cancel anytime during the trial at no charge). The subscription covers the relay service: the web UI, the secure message relay, and durable thread storage across all your machines.
|
|
22
|
+
|
|
23
|
+
**Model usage is never included or marked up** — your agents run on your machine under your own Anthropic/OpenAI/provider credentials, billed directly to you. The bridge doesn't proxy model traffic.
|
|
24
|
+
|
|
25
|
+
Invite accounts provisioned before billing activation keep full access with no subscription. Manage your subscription at `https://agent-bridge.obto.co/api/bridge/billing`.
|
|
18
26
|
|
|
19
27
|
## What you'll need
|
|
20
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obtoai/agent-bridge",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.25",
|
|
4
4
|
"description": "Local consumer for the OBTO Agent Bridge. Receives bridge events over SSE and drives a coding agent (Claude Code or OpenAI Codex) on your machine.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "OBTO Inc.",
|
package/src/bridge-http.js
CHANGED
|
@@ -89,8 +89,28 @@ const claimThread = (threadId, agentId) =>
|
|
|
89
89
|
// filesystem scanner so the bridge UI can render them alongside bridge-owned
|
|
90
90
|
// threads. Fire-and-forget; the bridge tolerates partial payloads and
|
|
91
91
|
// re-observes on the next 30s tick.
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
// Phase 6.7 — `presentSessionIds` is the FULL on-disk inventory (external +
|
|
93
|
+
// bridge-owned). The bridge marks rows missing from it as vanished so dead
|
|
94
|
+
// sessions stop being adoptable, and restores them if the file reappears.
|
|
95
|
+
const postExternalSync = (agentId, sessions, presentSessionIds) =>
|
|
96
|
+
postJson('/api/bridge/external/sync', { agentId, sessions, presentSessionIds });
|
|
97
|
+
|
|
98
|
+
// Phase 6.6 — ship the FULL local session history for an adopted thread.
|
|
99
|
+
// The route replaces the adoption-time partial backfill and is idempotent
|
|
100
|
+
// (hist:<sessionId>:<idx> clientMsgIds), so retries are safe.
|
|
101
|
+
const postExternalBackfill = (threadId, sessionId, messages) =>
|
|
102
|
+
postJson('/api/bridge/external/backfill', { threadId, sessionId, messages });
|
|
103
|
+
|
|
104
|
+
// Phase 5a — read the newest messages on a thread (backward page ending at
|
|
105
|
+
// `before`). Used to build the first-touch history block when an engine
|
|
106
|
+
// takes over a thread that already has conversation.
|
|
107
|
+
const getMessagesBefore = (threadId, beforeIso, limit) => {
|
|
108
|
+
const qs =
|
|
109
|
+
'threadId=' + encodeURIComponent(String(threadId || '')) +
|
|
110
|
+
'&before=' + encodeURIComponent(String(beforeIso || new Date().toISOString())) +
|
|
111
|
+
(limit ? '&limit=' + encodeURIComponent(String(limit)) : '');
|
|
112
|
+
return getJson('/api/messages?' + qs);
|
|
113
|
+
};
|
|
94
114
|
|
|
95
115
|
// Phase 6.4 — download an attachment's raw bytes for use as a Claude SDK
|
|
96
116
|
// image content block. The serve route streams the file with its stored
|
|
@@ -119,8 +139,10 @@ module.exports = {
|
|
|
119
139
|
buildHeaders,
|
|
120
140
|
postMessage,
|
|
121
141
|
getMessages,
|
|
142
|
+
getMessagesBefore,
|
|
122
143
|
postAgentActivity,
|
|
123
144
|
claimThread,
|
|
124
145
|
postExternalSync,
|
|
146
|
+
postExternalBackfill,
|
|
125
147
|
getAttachmentBytes,
|
|
126
148
|
};
|
package/src/capabilities.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Phase 2b — what this machine can drive.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Phase 2b — what this machine can drive.
|
|
4
|
+
//
|
|
5
|
+
// `claude` and `opencode` are bundled SDKs (declared in package.json) and
|
|
6
|
+
// self-contained: claude uses the Claude Agent SDK; opencode uses
|
|
7
|
+
// @opencode-ai/sdk's createOpencode() which spawns its own local HTTP server.
|
|
8
|
+
// Neither needs a CLI on PATH — they're always advertised.
|
|
9
|
+
//
|
|
10
|
+
// `codex` uses @openai/codex-sdk which delegates to the user's `codex` CLI
|
|
11
|
+
// for auth/config, so we still probe PATH for it.
|
|
7
12
|
//
|
|
8
13
|
// Sent to the bridge as `?capabilities=claude,codex,...` on SSE connect; the
|
|
9
14
|
// bridge records them in `agent_bridge_daemons` so the UI picker can offer
|
|
@@ -22,9 +27,8 @@ const onPath = (cmd) => {
|
|
|
22
27
|
};
|
|
23
28
|
|
|
24
29
|
const detect = () => {
|
|
25
|
-
const out = ['claude']; // bundled
|
|
30
|
+
const out = ['claude', 'opencode']; // bundled SDKs; always advertised
|
|
26
31
|
if (onPath('codex')) out.push('codex');
|
|
27
|
-
if (onPath('opencode')) out.push('opencode');
|
|
28
32
|
return out;
|
|
29
33
|
};
|
|
30
34
|
|
package/src/claude-driver.js
CHANGED
|
@@ -7,6 +7,10 @@ const os = require('os');
|
|
|
7
7
|
const { encodeProjectDir } = require('./session-scanner');
|
|
8
8
|
const bridgeHttp = require('./bridge-http');
|
|
9
9
|
const { buildBridgeMcpServer } = require('./bridge-mcp-server');
|
|
10
|
+
// Phase 5a — when this engine first touches a thread that already has bridge
|
|
11
|
+
// history (provider switch / adopted thread), inject that history into the
|
|
12
|
+
// first prompt so the session starts with context instead of amnesia.
|
|
13
|
+
const { buildHistoryBlock } = require('./history');
|
|
10
14
|
|
|
11
15
|
// Per-thread promise queue. Concurrent AMQP messages targeting the same thread
|
|
12
16
|
// are serialized so first-touch session creation completes before any resume,
|
|
@@ -336,7 +340,18 @@ const consumeQuery = async (q) => {
|
|
|
336
340
|
const driveFirstTouch = async ({ threadId, projectDir, payload, log }) => {
|
|
337
341
|
const sdk = await import('@anthropic-ai/claude-agent-sdk');
|
|
338
342
|
const bridgeServer = await buildBridgeMcpServer({ log });
|
|
339
|
-
|
|
343
|
+
// Phase 5a — prior thread history (empty string for brand-new threads).
|
|
344
|
+
const historyBlock = await buildHistoryBlock({
|
|
345
|
+
threadId,
|
|
346
|
+
currentMessageId: payload.messageId,
|
|
347
|
+
engineName: 'Claude',
|
|
348
|
+
log,
|
|
349
|
+
});
|
|
350
|
+
const prompt = await buildPromptForSdk(
|
|
351
|
+
payload,
|
|
352
|
+
historyBlock + buildBootstrapPrompt(payload),
|
|
353
|
+
log,
|
|
354
|
+
);
|
|
340
355
|
const options = Object.assign(
|
|
341
356
|
{
|
|
342
357
|
cwd: projectDir,
|
|
@@ -439,13 +454,41 @@ const driveResume = async ({ threadId, sessionId, projectDir, jsonlPath, lastJso
|
|
|
439
454
|
return { stopReason, assistantTextChars, lastJsonlMtimeMs: newMtime };
|
|
440
455
|
};
|
|
441
456
|
|
|
457
|
+
// A turn that errored before producing ANY assistant output never called
|
|
458
|
+
// bridge_post — from the human's perspective that is pure dead air. Claude is
|
|
459
|
+
// the one engine whose driver doesn't relay output itself, so failures here
|
|
460
|
+
// MUST be surfaced explicitly.
|
|
461
|
+
const turnProducedNothing = (result) =>
|
|
462
|
+
!!result &&
|
|
463
|
+
!result.skipped &&
|
|
464
|
+
String(result.stopReason || '').indexOf('error') !== -1 &&
|
|
465
|
+
!(result.assistantTextChars > 0);
|
|
466
|
+
|
|
467
|
+
const postBridgeNotice = async ({ threadId, kind, body, log }) => {
|
|
468
|
+
try {
|
|
469
|
+
const r = await bridgeHttp.postMessage({
|
|
470
|
+
threadId,
|
|
471
|
+
author: 'claude-bridge',
|
|
472
|
+
role: 'agent',
|
|
473
|
+
kind: kind || 'error',
|
|
474
|
+
body,
|
|
475
|
+
});
|
|
476
|
+
if (!r.ok) log('error', 'bridge notice post failed', { threadId, status: r.status });
|
|
477
|
+
} catch (e) {
|
|
478
|
+
log('error', 'bridge notice post threw', {
|
|
479
|
+
threadId,
|
|
480
|
+
error: e && e.message ? e.message : String(e),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
442
485
|
const drive = (params) => {
|
|
443
486
|
const key = params.threadId;
|
|
444
487
|
const prev = queues.get(key) || Promise.resolve();
|
|
445
488
|
const next = prev
|
|
446
|
-
.then(() => {
|
|
489
|
+
.then(async () => {
|
|
447
490
|
if (params.binding && params.binding.sessionId) {
|
|
448
|
-
|
|
491
|
+
const result = await driveResume({
|
|
449
492
|
threadId: params.threadId,
|
|
450
493
|
sessionId: params.binding.sessionId,
|
|
451
494
|
projectDir: params.binding.projectDir,
|
|
@@ -454,19 +497,80 @@ const drive = (params) => {
|
|
|
454
497
|
payload: params.payload,
|
|
455
498
|
log: params.log,
|
|
456
499
|
});
|
|
500
|
+
|
|
501
|
+
// Resume produced zero output and errored — the original engine
|
|
502
|
+
// session is unusable (moved/deleted JSONL, corrupt state, wrong
|
|
503
|
+
// machine). Fall back to a FRESH session: the Phase 5a history block
|
|
504
|
+
// in driveFirstTouch carries the thread context across, so the user
|
|
505
|
+
// gets a real answer instead of silence. Tell them what happened
|
|
506
|
+
// first — honest context loss beats quiet failure.
|
|
507
|
+
if (turnProducedNothing(result)) {
|
|
508
|
+
params.log('warn', 'resume produced no output — falling back to fresh session with thread history', {
|
|
509
|
+
threadId: params.threadId,
|
|
510
|
+
sessionId: params.binding.sessionId,
|
|
511
|
+
stopReason: result.stopReason,
|
|
512
|
+
});
|
|
513
|
+
await postBridgeNotice({
|
|
514
|
+
threadId: params.threadId,
|
|
515
|
+
kind: 'status',
|
|
516
|
+
body: '⚠️ Could not resume the original local session (`' +
|
|
517
|
+
params.binding.sessionId + '`) — its session file appears to be ' +
|
|
518
|
+
'missing or unusable. Starting a fresh session seeded with this ' +
|
|
519
|
+
'thread\'s history…',
|
|
520
|
+
log: params.log,
|
|
521
|
+
});
|
|
522
|
+
const fresh = await driveFirstTouch({
|
|
523
|
+
threadId: params.threadId,
|
|
524
|
+
projectDir: (params.binding && params.binding.projectDir) || params.projectDir,
|
|
525
|
+
payload: params.payload,
|
|
526
|
+
log: params.log,
|
|
527
|
+
});
|
|
528
|
+
if (turnProducedNothing(fresh)) {
|
|
529
|
+
await postBridgeNotice({
|
|
530
|
+
threadId: params.threadId,
|
|
531
|
+
kind: 'error',
|
|
532
|
+
body: 'The fresh Claude session also failed (' + fresh.stopReason +
|
|
533
|
+
') before producing any output. Check the daemon log on the ' +
|
|
534
|
+
'machine for the underlying SDK error.',
|
|
535
|
+
log: params.log,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
return fresh;
|
|
539
|
+
}
|
|
540
|
+
return result;
|
|
457
541
|
}
|
|
458
|
-
|
|
542
|
+
|
|
543
|
+
const result = await driveFirstTouch({
|
|
459
544
|
threadId: params.threadId,
|
|
460
545
|
projectDir: params.projectDir,
|
|
461
546
|
payload: params.payload,
|
|
462
547
|
log: params.log,
|
|
463
548
|
});
|
|
549
|
+
if (turnProducedNothing(result)) {
|
|
550
|
+
await postBridgeNotice({
|
|
551
|
+
threadId: params.threadId,
|
|
552
|
+
kind: 'error',
|
|
553
|
+
body: 'The Claude session failed (' + result.stopReason + ') before ' +
|
|
554
|
+
'producing any output. Check the daemon log on the machine for ' +
|
|
555
|
+
'the underlying SDK error.',
|
|
556
|
+
log: params.log,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return result;
|
|
464
560
|
})
|
|
465
|
-
.catch((err) => {
|
|
561
|
+
.catch(async (err) => {
|
|
466
562
|
params.log('error', 'drive failed', {
|
|
467
563
|
threadId: params.threadId,
|
|
468
564
|
error: err && err.message ? err.message : String(err),
|
|
469
565
|
});
|
|
566
|
+
// Even hard throws must not be silent on the thread.
|
|
567
|
+
await postBridgeNotice({
|
|
568
|
+
threadId: params.threadId,
|
|
569
|
+
kind: 'error',
|
|
570
|
+
body: 'Claude turn failed on the daemon: ' +
|
|
571
|
+
(err && err.message ? err.message : String(err)),
|
|
572
|
+
log: params.log,
|
|
573
|
+
});
|
|
470
574
|
throw err;
|
|
471
575
|
});
|
|
472
576
|
queues.set(key, next);
|
package/src/codex-driver.js
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
const { loadConfig } = require('./config');
|
|
26
26
|
const { buildEnvelope } = require('./claude-driver');
|
|
27
27
|
const bridgeHttp = require('./bridge-http');
|
|
28
|
+
// Phase 5a — Codex has no MCP thread_read; its only shot at prior thread
|
|
29
|
+
// context is inline injection into the first prompt.
|
|
30
|
+
const { buildHistoryBlock } = require('./history');
|
|
28
31
|
|
|
29
32
|
// Per-thread promise queue — concurrent replies on one thread are serialized
|
|
30
33
|
// so first-touch completes before any resume. Mirrors claude-driver.
|
|
@@ -48,8 +51,8 @@ const attachmentDropNote = (payload) => {
|
|
|
48
51
|
'image in words if you need its content.]\n\n';
|
|
49
52
|
};
|
|
50
53
|
|
|
51
|
-
const buildCodexPrompt = (payload, isFirst) => {
|
|
52
|
-
const head = attachmentDropNote(payload) + buildEnvelope(payload);
|
|
54
|
+
const buildCodexPrompt = (payload, isFirst, historyBlock) => {
|
|
55
|
+
const head = (historyBlock || '') + attachmentDropNote(payload) + buildEnvelope(payload);
|
|
53
56
|
if (!isFirst) return head;
|
|
54
57
|
return head +
|
|
55
58
|
'\n\n---\n' +
|
|
@@ -137,9 +140,22 @@ const driveTurn = async ({ threadId, projectDir, resumeId, payload, log }) => {
|
|
|
137
140
|
let finalResponse = '';
|
|
138
141
|
let failure = null;
|
|
139
142
|
|
|
143
|
+
// Phase 5a — on first touch of a thread with prior bridge history (provider
|
|
144
|
+
// switch or adopted thread), give Codex that history inline. Resumes don't
|
|
145
|
+
// need it: the engine session already holds its own context.
|
|
146
|
+
let historyBlock = '';
|
|
147
|
+
if (isFirst) {
|
|
148
|
+
historyBlock = await buildHistoryBlock({
|
|
149
|
+
threadId,
|
|
150
|
+
currentMessageId: payload.messageId,
|
|
151
|
+
engineName: 'Codex',
|
|
152
|
+
log,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
140
156
|
try {
|
|
141
157
|
const res = await runCodex({
|
|
142
|
-
prompt: buildCodexPrompt(payload, isFirst),
|
|
158
|
+
prompt: buildCodexPrompt(payload, isFirst, historyBlock),
|
|
143
159
|
projectDir,
|
|
144
160
|
resumeId,
|
|
145
161
|
});
|
package/src/daemon.js
CHANGED
|
@@ -7,6 +7,12 @@ const { drive, tryResolvePermission, agentFor } = require('./driver');
|
|
|
7
7
|
const { postAgentActivity, claimThread, postExternalSync } = require('./bridge-http');
|
|
8
8
|
const { detect: detectCapabilities } = require('./capabilities');
|
|
9
9
|
const { scanAll: scanExternalSessions } = require('./external-scanner');
|
|
10
|
+
// Phase 6.5 — OpenCode desktop/CLI uses SQLite instead of JSONL; separate
|
|
11
|
+
// scanner reads ~/.local/share/opencode/opencode.db read-only via the
|
|
12
|
+
// sqlite3 CLI subprocess. Empty array when SQLite or the DB isn't present.
|
|
13
|
+
const { scanAll: scanOpencodeSessions } = require('./opencode-sqlite-scanner');
|
|
14
|
+
// Phase 6.6 — full-history backfill for adopted threads (fire-and-forget).
|
|
15
|
+
const { backfillFullHistory } = require('./history');
|
|
10
16
|
|
|
11
17
|
const log = (level, msg, data) => {
|
|
12
18
|
const line = { ts: new Date().toISOString(), level, msg };
|
|
@@ -160,6 +166,12 @@ const handleEvent = async (sseEvent) => {
|
|
|
160
166
|
source: ea.source,
|
|
161
167
|
});
|
|
162
168
|
}
|
|
169
|
+
// Phase 6.6 — ship the FULL local session history to the bridge so the
|
|
170
|
+
// web thread shows the whole conversation, not the ~10-turn adoption
|
|
171
|
+
// preview. Runs off the hot path; idempotent server-side. Fired even
|
|
172
|
+
// when the cwd guard above declined the resume binding — history is
|
|
173
|
+
// about what the user SEES, independent of whether we can resume.
|
|
174
|
+
backfillFullHistory({ threadId, externalAdoption: ea, log });
|
|
163
175
|
}
|
|
164
176
|
|
|
165
177
|
log('event', 'reply received', {
|
|
@@ -295,11 +307,31 @@ const ownedSessionIdsFromState = () => {
|
|
|
295
307
|
};
|
|
296
308
|
const externalScanTick = async () => {
|
|
297
309
|
try {
|
|
298
|
-
|
|
310
|
+
// Phase 6.5 — fold opencode SQLite sessions into the same external sync
|
|
311
|
+
// payload. Both scanners are best-effort and return [] on failure, so a
|
|
312
|
+
// dead SQLite CLI or missing DB never breaks the JSONL path.
|
|
313
|
+
const fromJsonl = scanExternalSessions();
|
|
314
|
+
let fromOpencode = [];
|
|
315
|
+
let scanDegraded = false;
|
|
316
|
+
try {
|
|
317
|
+
fromOpencode = scanOpencodeSessions();
|
|
318
|
+
} catch (e) {
|
|
319
|
+
// Phase 6.7 — a failed sub-scan means our inventory is INCOMPLETE this
|
|
320
|
+
// tick; sending it would falsely mark that source's sessions vanished.
|
|
321
|
+
scanDegraded = true;
|
|
322
|
+
log('warn', 'opencode sqlite scan failed', { error: e && e.message ? e.message : String(e) });
|
|
323
|
+
}
|
|
324
|
+
const all = fromJsonl.concat(fromOpencode);
|
|
299
325
|
const owned = ownedSessionIdsFromState();
|
|
300
326
|
const external = all.filter((s) => s && s.sessionId && !owned.has(String(s.sessionId)));
|
|
301
|
-
|
|
302
|
-
|
|
327
|
+
// Phase 6.7 — full inventory (external + owned) so the bridge can mark
|
|
328
|
+
// rows whose local file vanished. Sent even when `external` is empty;
|
|
329
|
+
// the bridge ignores empty inventories, so a glitched scan is harmless.
|
|
330
|
+
const presentSessionIds = scanDegraded
|
|
331
|
+
? [] // incomplete inventory — skip reconciliation this tick
|
|
332
|
+
: all.filter((s) => s && s.sessionId).map((s) => String(s.sessionId));
|
|
333
|
+
if (external.length === 0 && presentSessionIds.length === 0) return;
|
|
334
|
+
const r = await postExternalSync(cfg.agentId, external, presentSessionIds);
|
|
303
335
|
if (!r || !r.ok) {
|
|
304
336
|
log('warn', 'external sync rejected', {
|
|
305
337
|
status: r && r.status,
|
|
@@ -310,6 +342,8 @@ const externalScanTick = async () => {
|
|
|
310
342
|
log('debug', 'external sync ok', {
|
|
311
343
|
sent: external.length,
|
|
312
344
|
upserted: (r.data && r.data.count) || 0,
|
|
345
|
+
vanished: (r.data && r.data.vanished) || 0,
|
|
346
|
+
restored: (r.data && r.data.restored) || 0,
|
|
313
347
|
});
|
|
314
348
|
}
|
|
315
349
|
} catch (e) {
|
package/src/external-scanner.js
CHANGED
|
@@ -543,4 +543,77 @@ const scanAll = () => {
|
|
|
543
543
|
return claude.concat(codex);
|
|
544
544
|
};
|
|
545
545
|
|
|
546
|
-
|
|
546
|
+
// ── Phase 6.6 — full-history reads for adoption backfill ──────────────────
|
|
547
|
+
// Unlike the 30s discovery scan (tail-bounded by design), the one-time
|
|
548
|
+
// backfill on adoption wants the WHOLE session. Same parsing pipeline as
|
|
549
|
+
// extractRecentMessages — coalescing, injection filtering, markdown-safe
|
|
550
|
+
// whitespace — just fed the entire file instead of a 512KB tail.
|
|
551
|
+
|
|
552
|
+
const FULL_READ_MAX_BYTES = 50 * 1024 * 1024; // refuse to slurp >50MB
|
|
553
|
+
|
|
554
|
+
// Locate the JSONL file for a (source, sessionId). Walks the same roots the
|
|
555
|
+
// discovery scan walks. Returns the absolute path or null.
|
|
556
|
+
const findSessionFile = (source, sessionId) => {
|
|
557
|
+
const sid = String(sessionId || '').trim();
|
|
558
|
+
if (!sid) return null;
|
|
559
|
+
|
|
560
|
+
if (source === 'codex') {
|
|
561
|
+
let years;
|
|
562
|
+
try { years = fs.readdirSync(CODEX_DIR); } catch (_) { return null; }
|
|
563
|
+
for (const y of years) {
|
|
564
|
+
if (!/^\d{4}$/.test(y)) continue;
|
|
565
|
+
let months; try { months = fs.readdirSync(path.join(CODEX_DIR, y)); } catch (_) { continue; }
|
|
566
|
+
for (const m of months) {
|
|
567
|
+
if (!/^\d{2}$/.test(m)) continue;
|
|
568
|
+
let days; try { days = fs.readdirSync(path.join(CODEX_DIR, y, m)); } catch (_) { continue; }
|
|
569
|
+
for (const d of days) {
|
|
570
|
+
if (!/^\d{2}$/.test(d)) continue;
|
|
571
|
+
const dPath = path.join(CODEX_DIR, y, m, d);
|
|
572
|
+
let files; try { files = fs.readdirSync(dPath); } catch (_) { continue; }
|
|
573
|
+
for (const f of files) {
|
|
574
|
+
if (f.startsWith('rollout-') && f.endsWith('-' + sid + '.jsonl')) {
|
|
575
|
+
return path.join(dPath, f);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// claude — CLI/extension root + every Desktop local-agent root.
|
|
585
|
+
const roots = [CLAUDE_DIR].concat(findClaudeDesktopProjectRoots());
|
|
586
|
+
for (const root of roots) {
|
|
587
|
+
let projects;
|
|
588
|
+
try { projects = fs.readdirSync(root); } catch (_) { continue; }
|
|
589
|
+
for (const p of projects) {
|
|
590
|
+
const candidate = path.join(root, p, sid + '.jsonl');
|
|
591
|
+
try { if (fs.statSync(candidate).isFile()) return candidate; } catch (_) {}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// Read and parse the ENTIRE session file into normalized turns, oldest-first:
|
|
598
|
+
// [{role, body, ts}]. Caps at maxTurns (keeping the newest). Returns [] on
|
|
599
|
+
// any failure or when the file exceeds the slurp cap.
|
|
600
|
+
const extractAllMessages = (filePath, maxTurns = 1000) => {
|
|
601
|
+
try {
|
|
602
|
+
const stat = fs.statSync(filePath);
|
|
603
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > FULL_READ_MAX_BYTES) return [];
|
|
604
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
605
|
+
return extractRecentMessages(text, maxTurns);
|
|
606
|
+
} catch (_) {
|
|
607
|
+
return [];
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
module.exports = {
|
|
612
|
+
scanAll,
|
|
613
|
+
scanClaude,
|
|
614
|
+
scanCodex,
|
|
615
|
+
extractLastMessage,
|
|
616
|
+
decodeClaudeProjectDir,
|
|
617
|
+
findSessionFile,
|
|
618
|
+
extractAllMessages,
|
|
619
|
+
};
|
package/src/history.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 6.6 (daemon half) + Phase 5a.
|
|
4
|
+
//
|
|
5
|
+
// Two related capabilities, one module:
|
|
6
|
+
//
|
|
7
|
+
// 1. FULL-HISTORY BACKFILL (Phase 6.6). At adopt time the bridge only has the
|
|
8
|
+
// scanner's ~10 recent turns. When the daemon first drives an adopted
|
|
9
|
+
// thread it calls backfillFullHistory(), which reads the COMPLETE local
|
|
10
|
+
// session (Claude/Codex JSONL or OpenCode SQLite) and POSTs it to
|
|
11
|
+
// /api/bridge/external/backfill. The bridge swaps the partial for the full
|
|
12
|
+
// history with original timestamps. Fire-and-forget: a failed backfill
|
|
13
|
+
// never blocks the actual turn, and the route's hist:<sid>:<idx>
|
|
14
|
+
// clientMsgIds make retries idempotent.
|
|
15
|
+
//
|
|
16
|
+
// 2. HISTORY INJECTION (Phase 5a — provider switching, stage 1). When an
|
|
17
|
+
// engine FIRST touches a thread that already has bridge messages (the
|
|
18
|
+
// thread was switched from another provider, or adopted), the new engine
|
|
19
|
+
// knows nothing. buildHistoryBlock() pulls the thread's recent messages
|
|
20
|
+
// from the bridge and renders a bounded context block the drivers prepend
|
|
21
|
+
// to the engine's first prompt. This is the pre-Hindsight handoff: cheap,
|
|
22
|
+
// lossy beyond the caps, but immediately testable. Hindsight summarization
|
|
23
|
+
// can later replace the raw transcript with a distilled one without
|
|
24
|
+
// touching the drivers — only this function changes.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
|
|
30
|
+
const bridgeHttp = require('./bridge-http');
|
|
31
|
+
const { findSessionFile, extractAllMessages } = require('./external-scanner');
|
|
32
|
+
const { fullMessagesFor } = require('./opencode-sqlite-scanner');
|
|
33
|
+
|
|
34
|
+
// ── Full-history backfill ─────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const BACKFILL_MAX_TURNS = 1000;
|
|
37
|
+
|
|
38
|
+
// Read the complete message history of a local session, normalized to
|
|
39
|
+
// [{role:'user'|'assistant', body, ts}]. Returns [] when the session can't
|
|
40
|
+
// be found or parsed — caller treats that as "nothing to backfill".
|
|
41
|
+
const readFullSessionHistory = (source, sessionId) => {
|
|
42
|
+
try {
|
|
43
|
+
if (source === 'opencode') {
|
|
44
|
+
return fullMessagesFor(sessionId, BACKFILL_MAX_TURNS);
|
|
45
|
+
}
|
|
46
|
+
// claude + codex are both JSONL on disk.
|
|
47
|
+
const filePath = findSessionFile(source, sessionId);
|
|
48
|
+
if (!filePath) return [];
|
|
49
|
+
return extractAllMessages(filePath, BACKFILL_MAX_TURNS);
|
|
50
|
+
} catch (_) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Threads we've already backfilled this daemon lifetime. The server route is
|
|
56
|
+
// idempotent anyway (clientMsgId dedupe), so this is purely a noise guard.
|
|
57
|
+
const backfilledThreads = new Set();
|
|
58
|
+
|
|
59
|
+
// Fire-and-forget. Reads the full session and ships it to the bridge.
|
|
60
|
+
const backfillFullHistory = ({ threadId, externalAdoption, log }) => {
|
|
61
|
+
const ea = externalAdoption || {};
|
|
62
|
+
const sessionId = String(ea.sessionId || '').trim();
|
|
63
|
+
const source = String(ea.source || 'claude');
|
|
64
|
+
if (!threadId || !sessionId) return;
|
|
65
|
+
if (backfilledThreads.has(threadId)) return;
|
|
66
|
+
backfilledThreads.add(threadId);
|
|
67
|
+
|
|
68
|
+
// setImmediate keeps file IO + the POST entirely off the reply hot path.
|
|
69
|
+
setImmediate(async () => {
|
|
70
|
+
try {
|
|
71
|
+
const messages = readFullSessionHistory(source, sessionId);
|
|
72
|
+
// The adopt route already inserted ~10 turns; a full read that isn't
|
|
73
|
+
// meaningfully bigger adds nothing but churn.
|
|
74
|
+
if (!messages || messages.length === 0) {
|
|
75
|
+
log('info', 'history backfill: no local history found', { threadId, source, sessionId });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const r = await bridgeHttp.postExternalBackfill(threadId, sessionId, messages);
|
|
79
|
+
if (r && r.ok) {
|
|
80
|
+
log('info', 'history backfill complete', {
|
|
81
|
+
threadId,
|
|
82
|
+
source,
|
|
83
|
+
sessionId,
|
|
84
|
+
sent: messages.length,
|
|
85
|
+
inserted: r.data && r.data.inserted,
|
|
86
|
+
deduped: r.data && r.data.deduped,
|
|
87
|
+
removedPartial: r.data && r.data.removedPartial,
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
backfilledThreads.delete(threadId); // retry on the next reply
|
|
91
|
+
log('warn', 'history backfill rejected', {
|
|
92
|
+
threadId,
|
|
93
|
+
status: r && r.status,
|
|
94
|
+
body: r && r.data,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
backfilledThreads.delete(threadId);
|
|
99
|
+
log('warn', 'history backfill failed', {
|
|
100
|
+
threadId,
|
|
101
|
+
error: e && e.message ? e.message : String(e),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// ── Phase 5a — history injection on first touch ───────────────────────────
|
|
108
|
+
|
|
109
|
+
const HISTORY_MAX_MESSAGES = 40;
|
|
110
|
+
const HISTORY_MSG_CHARS = 3000;
|
|
111
|
+
const HISTORY_TOTAL_CHARS = 24000;
|
|
112
|
+
|
|
113
|
+
const trimBody = (s) => {
|
|
114
|
+
const t = String(s || '').trim();
|
|
115
|
+
return t.length > HISTORY_MSG_CHARS ? t.slice(0, HISTORY_MSG_CHARS) + ' …[truncated]' : t;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Pull the newest messages on a bridge thread and render them as a bounded
|
|
119
|
+
// context block for a first-touch prompt. Returns '' when the thread has no
|
|
120
|
+
// prior conversation (brand-new thread — nothing to inject), so callers can
|
|
121
|
+
// unconditionally prepend the result.
|
|
122
|
+
//
|
|
123
|
+
// currentMessageId: the reply that triggered this turn — excluded, since the
|
|
124
|
+
// envelope already carries it.
|
|
125
|
+
const buildHistoryBlock = async ({ threadId, currentMessageId, engineName, log }) => {
|
|
126
|
+
let rows = [];
|
|
127
|
+
try {
|
|
128
|
+
const r = await bridgeHttp.getMessagesBefore(threadId, new Date().toISOString(), HISTORY_MAX_MESSAGES);
|
|
129
|
+
if (r && r.ok && r.data && Array.isArray(r.data.messages)) {
|
|
130
|
+
rows = r.data.messages;
|
|
131
|
+
}
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (log) log('warn', 'history block fetch failed', {
|
|
134
|
+
threadId,
|
|
135
|
+
error: e && e.message ? e.message : String(e),
|
|
136
|
+
});
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const lines = [];
|
|
141
|
+
let total = 0;
|
|
142
|
+
for (const m of rows) {
|
|
143
|
+
if (!m || !m.body) continue;
|
|
144
|
+
if (currentMessageId && m.messageId === currentMessageId) continue;
|
|
145
|
+
// Skip permission-relay questions — transient plumbing, not conversation.
|
|
146
|
+
if (m.author === 'claude-bridge-perm') continue;
|
|
147
|
+
const who = m.role === 'human' ? 'human' : (m.author || 'agent');
|
|
148
|
+
const body = trimBody(m.body);
|
|
149
|
+
const line = '[' + (m.createdAt || '?') + ' | ' + who + ']\n' + body;
|
|
150
|
+
if (total + line.length > HISTORY_TOTAL_CHARS) break;
|
|
151
|
+
total += line.length;
|
|
152
|
+
lines.push(line);
|
|
153
|
+
}
|
|
154
|
+
if (lines.length === 0) return '';
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
'[OBTO Agent Bridge — prior conversation on this thread]\n' +
|
|
158
|
+
'This thread already has history: the human (and possibly other AI ' +
|
|
159
|
+
'engines) exchanged the messages below before you' +
|
|
160
|
+
(engineName ? ' (' + engineName + ')' : '') +
|
|
161
|
+
' were brought in. Treat it as authoritative context — do not re-ask ' +
|
|
162
|
+
'for information it already contains. The newest message (your actual ' +
|
|
163
|
+
'task) follows after the block.\n\n' +
|
|
164
|
+
'--- thread history start ---\n' +
|
|
165
|
+
lines.join('\n\n') +
|
|
166
|
+
'\n--- thread history end ---\n\n'
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
module.exports = { backfillFullHistory, buildHistoryBlock, readFullSessionHistory };
|
package/src/opencode-driver.js
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
const { loadConfig } = require('./config');
|
|
20
20
|
const { buildEnvelope } = require('./claude-driver');
|
|
21
21
|
const bridgeHttp = require('./bridge-http');
|
|
22
|
+
// Phase 5a — opencode has no MCP thread_read; prior thread context arrives
|
|
23
|
+
// via inline injection into the first prompt, same as the Codex driver.
|
|
24
|
+
const { buildHistoryBlock } = require('./history');
|
|
22
25
|
|
|
23
26
|
// Per-thread promise queue — concurrent replies on one thread are serialized
|
|
24
27
|
// so first-touch completes before any resume. Mirrors codex-driver.
|
|
@@ -45,8 +48,8 @@ const attachmentDropNote = (payload) => {
|
|
|
45
48
|
'image in words if you need its content.]\n\n';
|
|
46
49
|
};
|
|
47
50
|
|
|
48
|
-
const buildOpencodePrompt = (payload, isFirst) => {
|
|
49
|
-
const head = attachmentDropNote(payload) + buildEnvelope(payload);
|
|
51
|
+
const buildOpencodePrompt = (payload, isFirst, historyBlock) => {
|
|
52
|
+
const head = (historyBlock || '') + attachmentDropNote(payload) + buildEnvelope(payload);
|
|
50
53
|
if (!isFirst) return head;
|
|
51
54
|
return head +
|
|
52
55
|
'\n\n---\n' +
|
|
@@ -153,9 +156,22 @@ const driveTurn = async ({ threadId, projectDir, resumeId, payload, log }) => {
|
|
|
153
156
|
let finalResponse = '';
|
|
154
157
|
let failure = null;
|
|
155
158
|
|
|
159
|
+
// Phase 5a — on first touch of a thread with prior bridge history (provider
|
|
160
|
+
// switch or adopted thread), give opencode that history inline. Resumes
|
|
161
|
+
// don't need it: the engine session already holds its own context.
|
|
162
|
+
let historyBlock = '';
|
|
163
|
+
if (isFirst) {
|
|
164
|
+
historyBlock = await buildHistoryBlock({
|
|
165
|
+
threadId,
|
|
166
|
+
currentMessageId: payload.messageId,
|
|
167
|
+
engineName: 'opencode',
|
|
168
|
+
log,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
156
172
|
try {
|
|
157
173
|
const res = await runOpencode({
|
|
158
|
-
prompt: buildOpencodePrompt(payload, isFirst),
|
|
174
|
+
prompt: buildOpencodePrompt(payload, isFirst, historyBlock),
|
|
159
175
|
projectDir,
|
|
160
176
|
resumeId,
|
|
161
177
|
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 6.5 — surface OpenCode desktop/CLI conversations to the bridge.
|
|
4
|
+
//
|
|
5
|
+
// OpenCode stores sessions in SQLite at ~/.local/share/opencode/opencode.db
|
|
6
|
+
// (shared between the CLI and the Electron desktop app). Schema (relevant
|
|
7
|
+
// subset, captured 2026-06-07):
|
|
8
|
+
//
|
|
9
|
+
// session(id, project_id, parent_id, directory, title, time_created,
|
|
10
|
+
// time_updated, agent, model, ...)
|
|
11
|
+
// message(id, session_id, time_created, data JSON) -- data.role: user|assistant|...
|
|
12
|
+
// part(id, message_id, session_id, time_created, data JSON) -- data.type: text|reasoning|step-start|...
|
|
13
|
+
// project(id, worktree, name, ...)
|
|
14
|
+
//
|
|
15
|
+
// We read via the `sqlite3` CLI subprocess (ships on macOS, standard on
|
|
16
|
+
// Linux) rather than adding a native dependency (better-sqlite3) to the
|
|
17
|
+
// daemon's install footprint. Opens in `-readonly` mode so live writes from
|
|
18
|
+
// the desktop app are safe — SQLite WAL allows concurrent reads.
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { spawnSync, execFileSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const OPENCODE_DB = path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
|
|
26
|
+
|
|
27
|
+
// Match the limits the Claude/Codex scanners use so the bridge UI's
|
|
28
|
+
// preview/title rendering looks consistent across sources.
|
|
29
|
+
const SESSION_LIMIT = 500;
|
|
30
|
+
const RECENT_TURN_COUNT = 10;
|
|
31
|
+
const RECENT_MESSAGE_BODY_MAX = 4000;
|
|
32
|
+
const PREVIEW_MAX_CHARS = 240;
|
|
33
|
+
const QUERY_TIMEOUT_MS = 8000;
|
|
34
|
+
|
|
35
|
+
let sqliteAvailableCached = null;
|
|
36
|
+
const sqliteAvailable = () => {
|
|
37
|
+
if (sqliteAvailableCached !== null) return sqliteAvailableCached;
|
|
38
|
+
try {
|
|
39
|
+
const r = spawnSync('sqlite3', ['-version'], { encoding: 'utf8' });
|
|
40
|
+
sqliteAvailableCached = r.status === 0;
|
|
41
|
+
} catch (_) {
|
|
42
|
+
sqliteAvailableCached = false;
|
|
43
|
+
}
|
|
44
|
+
return sqliteAvailableCached;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const dbExists = () => {
|
|
48
|
+
try { return fs.existsSync(OPENCODE_DB); } catch (_) { return false; }
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Run a single SQL query against the OpenCode DB and parse `-json` output.
|
|
52
|
+
// Returns [] on any failure (missing CLI, locked DB beyond timeout, bad SQL).
|
|
53
|
+
// The daemon's external scan is fire-and-forget and runs every 30s, so we
|
|
54
|
+
// MUST NOT throw out — at worst we miss this tick.
|
|
55
|
+
const queryJson = (sql) => {
|
|
56
|
+
try {
|
|
57
|
+
const out = execFileSync('sqlite3', ['-readonly', '-json', OPENCODE_DB, sql], {
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
timeout: QUERY_TIMEOUT_MS,
|
|
60
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
61
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
62
|
+
});
|
|
63
|
+
if (!out || !out.trim()) return [];
|
|
64
|
+
return JSON.parse(out);
|
|
65
|
+
} catch (_) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Take a string and slice/trim to PREVIEW_MAX_CHARS for the sidebar preview.
|
|
71
|
+
const previewOf = (s) => {
|
|
72
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
73
|
+
return t.length > PREVIEW_MAX_CHARS ? t.slice(0, PREVIEW_MAX_CHARS) : t;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Trim a recentMessages body to the same RECENT_MESSAGE_BODY_MAX cap as the
|
|
77
|
+
// other scanners so the bridge's per-row payload stays bounded.
|
|
78
|
+
const bodyOf = (s) => {
|
|
79
|
+
const t = String(s || '');
|
|
80
|
+
return t.length > RECENT_MESSAGE_BODY_MAX ? t.slice(0, RECENT_MESSAGE_BODY_MAX) : t;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Pull the last N text-bearing turns for one session. Skip control rows
|
|
84
|
+
// (step-start, reasoning, tool-use) so the preview matches what the human
|
|
85
|
+
// actually said and what the assistant actually replied.
|
|
86
|
+
const recentMessagesFor = (sessionId) => {
|
|
87
|
+
const safeId = String(sessionId).replace(/'/g, "''");
|
|
88
|
+
// Last N text parts in order. We take 2*N from the tail then sort because
|
|
89
|
+
// SQLite's LIMIT is fastest with DESC + ascending re-sort in JS.
|
|
90
|
+
const rows = queryJson(
|
|
91
|
+
"SELECT p.time_created AS ts, " +
|
|
92
|
+
"json_extract(m.data, '$.role') AS role, " +
|
|
93
|
+
"json_extract(p.data, '$.text') AS text " +
|
|
94
|
+
"FROM part p JOIN message m ON p.message_id = m.id " +
|
|
95
|
+
"WHERE p.session_id = '" + safeId + "' " +
|
|
96
|
+
"AND json_extract(p.data, '$.type') = 'text' " +
|
|
97
|
+
"ORDER BY p.time_created DESC LIMIT " + (RECENT_TURN_COUNT * 2),
|
|
98
|
+
);
|
|
99
|
+
rows.reverse();
|
|
100
|
+
// Coalesce consecutive same-role rows (assistant turn can be split across
|
|
101
|
+
// parts) and tail to N.
|
|
102
|
+
const coalesced = [];
|
|
103
|
+
for (const r of rows) {
|
|
104
|
+
if (!r || !r.text) continue;
|
|
105
|
+
const role = r.role === 'user' ? 'user' : 'assistant';
|
|
106
|
+
const last = coalesced[coalesced.length - 1];
|
|
107
|
+
if (last && last.role === role) {
|
|
108
|
+
last.text += '\n\n' + r.text;
|
|
109
|
+
last.ts = r.ts;
|
|
110
|
+
} else {
|
|
111
|
+
coalesced.push({ role, text: r.text, ts: r.ts });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const sliced = coalesced.slice(-RECENT_TURN_COUNT);
|
|
115
|
+
return sliced.map((m) => ({ role: m.role, body: bodyOf(m.text), ts: m.ts }));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const lastMessageFor = (sessionId) => {
|
|
119
|
+
const safeId = String(sessionId).replace(/'/g, "''");
|
|
120
|
+
const rows = queryJson(
|
|
121
|
+
"SELECT json_extract(m.data, '$.role') AS role, json_extract(p.data, '$.text') AS text " +
|
|
122
|
+
"FROM part p JOIN message m ON p.message_id = m.id " +
|
|
123
|
+
"WHERE p.session_id = '" + safeId + "' " +
|
|
124
|
+
"AND json_extract(p.data, '$.type') = 'text' " +
|
|
125
|
+
"ORDER BY p.time_created DESC LIMIT 1",
|
|
126
|
+
);
|
|
127
|
+
if (!rows.length) return null;
|
|
128
|
+
const r = rows[0];
|
|
129
|
+
return {
|
|
130
|
+
author: r.role === 'user' ? 'user' : 'assistant',
|
|
131
|
+
preview: previewOf(r.text),
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Public API. Returns the same shape that postExternalSync expects (same as
|
|
136
|
+
// claude/codex rows). Best-effort: returns [] if SQLite/DB unavailable.
|
|
137
|
+
const scanAll = () => {
|
|
138
|
+
if (!dbExists() || !sqliteAvailable()) return [];
|
|
139
|
+
|
|
140
|
+
// Top-level sessions only — skip sub-sessions (parent_id non-null), they
|
|
141
|
+
// belong to a parent and showing them as standalone rows would clutter
|
|
142
|
+
// the sidebar with duplicate-looking conversations.
|
|
143
|
+
const sessions = queryJson(
|
|
144
|
+
"SELECT s.id AS sessionId, s.title, s.directory, " +
|
|
145
|
+
"s.time_created AS createdMs, s.time_updated AS updatedMs, " +
|
|
146
|
+
"s.agent, s.model, " +
|
|
147
|
+
"p.name AS projectName, p.worktree AS projectWorktree " +
|
|
148
|
+
"FROM session s LEFT JOIN project p ON s.project_id = p.id " +
|
|
149
|
+
"WHERE (s.parent_id IS NULL OR s.parent_id = '') " +
|
|
150
|
+
"ORDER BY s.time_updated DESC LIMIT " + SESSION_LIMIT,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const s of sessions) {
|
|
155
|
+
if (!s || !s.sessionId) continue;
|
|
156
|
+
const dir = s.directory || s.projectWorktree || '';
|
|
157
|
+
const recentMessages = recentMessagesFor(s.sessionId);
|
|
158
|
+
const lastMsg = lastMessageFor(s.sessionId);
|
|
159
|
+
out.push({
|
|
160
|
+
source: 'opencode',
|
|
161
|
+
sessionId: String(s.sessionId),
|
|
162
|
+
// The bridge's adoption path uses projectDir for resume cwd. OpenCode
|
|
163
|
+
// stores the absolute path in `directory` (or project.worktree as
|
|
164
|
+
// backup); pass it through as both projectDir and projectName so the
|
|
165
|
+
// daemon's "looksAbsolute" guard in daemon.js handleEvent accepts it.
|
|
166
|
+
projectDir: dir,
|
|
167
|
+
projectName: dir,
|
|
168
|
+
title: String(s.title || '').trim() || null,
|
|
169
|
+
recentMessages: recentMessages,
|
|
170
|
+
lastActivityAt: typeof s.updatedMs === 'number' ? s.updatedMs : Number(s.updatedMs) || 0,
|
|
171
|
+
lastMessagePreview: lastMsg ? lastMsg.preview : '',
|
|
172
|
+
lastMessageAuthor: lastMsg ? lastMsg.author : null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Phase 6.6 — full-history read for adoption backfill. Same shape as
|
|
179
|
+
// recentMessagesFor but unbounded by RECENT_TURN_COUNT (capped at maxTurns,
|
|
180
|
+
// keeping the newest). Returns [] when SQLite/DB unavailable.
|
|
181
|
+
const fullMessagesFor = (sessionId, maxTurns = 1000) => {
|
|
182
|
+
if (!dbExists() || !sqliteAvailable()) return [];
|
|
183
|
+
const safeId = String(sessionId).replace(/'/g, "''");
|
|
184
|
+
const rows = queryJson(
|
|
185
|
+
"SELECT p.time_created AS ts, " +
|
|
186
|
+
"json_extract(m.data, '$.role') AS role, " +
|
|
187
|
+
"json_extract(p.data, '$.text') AS text " +
|
|
188
|
+
"FROM part p JOIN message m ON p.message_id = m.id " +
|
|
189
|
+
"WHERE p.session_id = '" + safeId + "' " +
|
|
190
|
+
"AND json_extract(p.data, '$.type') = 'text' " +
|
|
191
|
+
"ORDER BY p.time_created ASC LIMIT " + (Math.max(1, maxTurns) * 4),
|
|
192
|
+
);
|
|
193
|
+
const coalesced = [];
|
|
194
|
+
for (const r of rows) {
|
|
195
|
+
if (!r || !r.text) continue;
|
|
196
|
+
const role = r.role === 'user' ? 'user' : 'assistant';
|
|
197
|
+
const last = coalesced[coalesced.length - 1];
|
|
198
|
+
if (last && last.role === role) {
|
|
199
|
+
last.text += '\n\n' + r.text;
|
|
200
|
+
last.ts = r.ts;
|
|
201
|
+
} else {
|
|
202
|
+
coalesced.push({ role, text: r.text, ts: r.ts });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const sliced = coalesced.slice(-Math.max(1, maxTurns));
|
|
206
|
+
return sliced.map((m) => ({ role: m.role, body: bodyOf(m.text), ts: m.ts }));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
module.exports = { scanAll, fullMessagesFor, OPENCODE_DB };
|