@mirrowel/opencode-openshare 0.1.0 → 0.2.0-dev.1
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 +36 -30
- package/ci/openshare.config.json +7 -2
- package/ci/openshare.js +625 -197
- package/dist/config.d.ts +5 -1
- package/dist/config.js +9 -2
- package/dist/contract.d.ts +156 -0
- package/dist/contract.js +263 -0
- package/dist/core.js +411 -55
- package/dist/dialogs.js +38 -0
- package/dist/share-flow.js +2 -2
- package/dist/sidebar.d.ts +3 -1
- package/dist/sidebar.js +3 -17
- package/dist/tui.js +73 -121
- package/dist/v2-server.d.ts +0 -27
- package/dist/v2-server.js +6 -121
- package/openshare.config.json +1 -1
- package/package.json +4 -2
- package/src/banner.txt +7 -4
- package/src/config.ts +8 -2
- package/src/contract.ts +320 -0
- package/src/core.ts +403 -56
- package/src/dialogs.tsx +34 -0
- package/src/share-flow.ts +2 -2
- package/src/sidebar.tsx +4 -21
- package/src/tui.tsx +1 -1
- package/src/v2-server.ts +7 -110
package/dist/core.js
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
45
45
|
import { join } from "node:path";
|
|
46
46
|
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
|
|
47
|
+
import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
|
|
47
48
|
const DEFAULT_POLICY = {
|
|
48
49
|
mode: "persistent",
|
|
49
50
|
turnGraceMs: 120000,
|
|
@@ -93,11 +94,12 @@ function itemFor(type, data, sessionID) {
|
|
|
93
94
|
if (type === "prefs")
|
|
94
95
|
return { key: "prefs", item: { type, data } };
|
|
95
96
|
if (type === "session")
|
|
96
|
-
return { key: `session/${data?.id ?? ""}`, item: { type, data } };
|
|
97
|
+
return { key: `session/${data?.id ?? ""}`, item: { type, sessionID, data } };
|
|
98
|
+
// v2 messages carry no sessionID inside data (v1 infos did); the item
|
|
99
|
+
// envelope stamps it so multi-session folds (parent + subagents) can
|
|
100
|
+
// group without guessing
|
|
97
101
|
if (type === "message")
|
|
98
|
-
return { key: `message/${data?.id ?? ""}`, item: { type, data } };
|
|
99
|
-
if (type === "part")
|
|
100
|
-
return { key: `part/${data?.messageID ?? ""}/${data?.id ?? ""}`, item: { type, data } };
|
|
102
|
+
return { key: `message/${data?.id ?? ""}`, item: { type, sessionID, data } };
|
|
101
103
|
if (type === "session_diff")
|
|
102
104
|
return { key: `session_diff/${sessionID}`, item: { type, sessionID, data } };
|
|
103
105
|
return undefined;
|
|
@@ -380,6 +382,7 @@ export function createEngine(deps) {
|
|
|
380
382
|
activated: false,
|
|
381
383
|
wroteBinding: false,
|
|
382
384
|
everConnected: false,
|
|
385
|
+
merged: new Map(),
|
|
383
386
|
});
|
|
384
387
|
const refreshConfig = () => {
|
|
385
388
|
// settings may change between activations (the TUI writes the config
|
|
@@ -638,6 +641,16 @@ export function createEngine(deps) {
|
|
|
638
641
|
logError("spool save failed", e?.message || e);
|
|
639
642
|
}
|
|
640
643
|
};
|
|
644
|
+
// hot path: appending one pending frame must not re-serialize the whole
|
|
645
|
+
// queue (O(n^2) during backfill bursts) — append the line instead
|
|
646
|
+
const spoolAppend = (st, r, f) => {
|
|
647
|
+
try {
|
|
648
|
+
appendFileSync(queuePath(st, r.url), JSON.stringify(f) + "\n");
|
|
649
|
+
}
|
|
650
|
+
catch (e) {
|
|
651
|
+
logError("spool append failed", e?.message || e);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
641
654
|
const spoolLoad = (st, r) => {
|
|
642
655
|
try {
|
|
643
656
|
const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
|
|
@@ -648,10 +661,20 @@ export function createEngine(deps) {
|
|
|
648
661
|
r.ackedRev = meta.ackedRev ?? 0;
|
|
649
662
|
}
|
|
650
663
|
if (existsSync(queuePath(st, r.url))) {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
664
|
+
// append-only spool: a crash mid-append can tear the LAST line —
|
|
665
|
+
// keep the valid prefix instead of dropping the whole queue
|
|
666
|
+
r.pending = [];
|
|
667
|
+
for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
|
|
668
|
+
if (!line.trim())
|
|
669
|
+
continue;
|
|
670
|
+
try {
|
|
671
|
+
r.pending.push(JSON.parse(line));
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`);
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
655
678
|
}
|
|
656
679
|
}
|
|
657
680
|
catch {
|
|
@@ -696,15 +719,37 @@ export function createEngine(deps) {
|
|
|
696
719
|
}
|
|
697
720
|
return true;
|
|
698
721
|
};
|
|
699
|
-
/** Sync frames are chunked
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
|
|
722
|
+
/** Sync frames are chunked by BOTH item count and serialized bytes.
|
|
723
|
+
* Count-only chunking could pack 200 large tool outputs into a frame
|
|
724
|
+
* that exceeds the server's WS receive cap (CF 32 MiB, Bun 16 MB
|
|
725
|
+
* default) and die wholesale. Budget keeps every frame cheap to apply
|
|
726
|
+
* and rebroadcast; worker apply is keyed-idempotent, so chunks just
|
|
727
|
+
* bump rev. An item larger than the whole budget rides alone in its
|
|
728
|
+
* own frame (the worker fits oversize items to its storage cap).
|
|
729
|
+
* Status rides each chunk; last write wins. */
|
|
730
|
+
const chunkItems = (items, maxItems, maxBytes) => {
|
|
731
|
+
const chunks = [];
|
|
732
|
+
let cur = [];
|
|
733
|
+
let curBytes = 0;
|
|
734
|
+
for (const it of items) {
|
|
735
|
+
const n = (JSON.stringify(it) ?? "null").length;
|
|
736
|
+
if (cur.length && (cur.length >= maxItems || curBytes + n > maxBytes)) {
|
|
737
|
+
chunks.push(cur);
|
|
738
|
+
cur = [];
|
|
739
|
+
curBytes = 0;
|
|
740
|
+
}
|
|
741
|
+
cur.push(it);
|
|
742
|
+
curBytes += n;
|
|
743
|
+
}
|
|
744
|
+
if (cur.length)
|
|
745
|
+
chunks.push(cur);
|
|
746
|
+
return chunks;
|
|
747
|
+
};
|
|
703
748
|
const sendSync = (r, f) => {
|
|
704
749
|
if (!f.items.length)
|
|
705
750
|
return wsSend(r, { op: "sync", items: [], status: f.status });
|
|
706
|
-
for (
|
|
707
|
-
wsSend(r, { op: "sync", items:
|
|
751
|
+
for (const chunk of chunkItems(f.items, 200, 512 * 1024)) {
|
|
752
|
+
wsSend(r, { op: "sync", items: chunk, status: f.status });
|
|
708
753
|
}
|
|
709
754
|
return true;
|
|
710
755
|
};
|
|
@@ -1227,6 +1272,10 @@ export function createEngine(deps) {
|
|
|
1227
1272
|
};
|
|
1228
1273
|
// ---- flush -------------------------------------------------------------
|
|
1229
1274
|
const frameFrom = (st) => {
|
|
1275
|
+
// stamps run at frame time: the whole family's data is indexed by now
|
|
1276
|
+
// (child prompts are created during the task call, well inside the
|
|
1277
|
+
// flush debounce), and any re-enqueued message lands in this frame
|
|
1278
|
+
stampsPass();
|
|
1230
1279
|
const items = Array.from(st.queue.values());
|
|
1231
1280
|
st.queue.clear();
|
|
1232
1281
|
const statusChanged = st.status !== st.lastSentStatus;
|
|
@@ -1258,7 +1307,7 @@ export function createEngine(deps) {
|
|
|
1258
1307
|
// sync dies as "unknown op" (live-caught: post-drop reconnects
|
|
1259
1308
|
// lost whole turns). The init-ok handler flushes pending itself.
|
|
1260
1309
|
r.pending.push(f);
|
|
1261
|
-
|
|
1310
|
+
spoolAppend(st, r, f);
|
|
1262
1311
|
if (r.ws && !r.wsReady)
|
|
1263
1312
|
continue;
|
|
1264
1313
|
if (r.wsReady) {
|
|
@@ -1289,18 +1338,24 @@ export function createEngine(deps) {
|
|
|
1289
1338
|
status = f.status;
|
|
1290
1339
|
}
|
|
1291
1340
|
const cred = credOf(r.url);
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1341
|
+
// chunked like WS frames: one unbounded body is a whole-backlog
|
|
1342
|
+
// bomb after a mid-backfill socket drop (backfill = full history).
|
|
1343
|
+
// Failure stops the loop with pending intact — replaying already-
|
|
1344
|
+
// applied chunks is idempotent.
|
|
1345
|
+
for (const chunk of chunkItems(items, 500, 4 * 1024 * 1024)) {
|
|
1346
|
+
const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
|
|
1347
|
+
method: "POST",
|
|
1348
|
+
headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
|
|
1349
|
+
body: JSON.stringify({ secret: r.secret ?? "", data: chunk, status }),
|
|
1350
|
+
signal: AbortSignal.timeout(15000),
|
|
1351
|
+
}).catch(() => null);
|
|
1352
|
+
if (!res || !res.ok)
|
|
1353
|
+
return;
|
|
1299
1354
|
const body = (await res.json().catch(() => ({})));
|
|
1300
1355
|
r.ackedRev = body.rev ?? r.ackedRev;
|
|
1301
|
-
r.pending = [];
|
|
1302
|
-
spoolSave(st, r);
|
|
1303
1356
|
}
|
|
1357
|
+
r.pending = [];
|
|
1358
|
+
spoolSave(st, r);
|
|
1304
1359
|
};
|
|
1305
1360
|
const scheduleFlush = (st) => {
|
|
1306
1361
|
if (st.timer)
|
|
@@ -1323,12 +1378,288 @@ export function createEngine(deps) {
|
|
|
1323
1378
|
if (!st || !entry)
|
|
1324
1379
|
return;
|
|
1325
1380
|
st.queue.set(entry.key, entry.item);
|
|
1381
|
+
indexMessage(entry);
|
|
1326
1382
|
if (debugEvents)
|
|
1327
1383
|
log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`);
|
|
1328
1384
|
touch(st);
|
|
1329
1385
|
ensureConnected(st);
|
|
1330
1386
|
scheduleFlush(st);
|
|
1331
1387
|
};
|
|
1388
|
+
const msgIndex = new Map(); // `${sid}:${messageID}`
|
|
1389
|
+
const indexMessage = (entry) => {
|
|
1390
|
+
if (entry.item.type !== "message")
|
|
1391
|
+
return;
|
|
1392
|
+
const d = entry.item.data;
|
|
1393
|
+
if (!d || typeof d.id !== "string")
|
|
1394
|
+
return;
|
|
1395
|
+
const role = String(d.type ?? "");
|
|
1396
|
+
if (role !== "user" && role !== "assistant")
|
|
1397
|
+
return;
|
|
1398
|
+
const key = `${entry.item.sessionID}:${d.id}`;
|
|
1399
|
+
const prev = msgIndex.get(key);
|
|
1400
|
+
msgIndex.set(key, {
|
|
1401
|
+
sid: String(entry.item.sessionID ?? ""),
|
|
1402
|
+
role: role,
|
|
1403
|
+
agent: role === "assistant"
|
|
1404
|
+
? typeof d.agent === "string"
|
|
1405
|
+
? d.agent
|
|
1406
|
+
: prev?.agent
|
|
1407
|
+
: typeof d.metadata?.agent === "string"
|
|
1408
|
+
? d.metadata.agent
|
|
1409
|
+
: prev?.agent,
|
|
1410
|
+
created: Number(d.time?.created ?? prev?.created ?? 0),
|
|
1411
|
+
data: d,
|
|
1412
|
+
});
|
|
1413
|
+
};
|
|
1414
|
+
const sessionStateOf = (sid) => sessions.get(childToParent.get(sid) ?? sid);
|
|
1415
|
+
const reEnqueueMessage = (m) => {
|
|
1416
|
+
const st = sessionStateOf(m.sid);
|
|
1417
|
+
if (st)
|
|
1418
|
+
enqueue(st, itemFor("message", m.data, m.sid));
|
|
1419
|
+
};
|
|
1420
|
+
/** segment agent for a user prompt: the prompt's own recorded agent (v1
|
|
1421
|
+
* hosts stamp it on every user message), else the first assistant
|
|
1422
|
+
* response inside the segment (v2 hosts), else carry-forward. */
|
|
1423
|
+
const segmentAgent = (ordered, at) => {
|
|
1424
|
+
const own = ordered[at]?.agent;
|
|
1425
|
+
if (own)
|
|
1426
|
+
return own;
|
|
1427
|
+
for (let i = at + 1; i < ordered.length; i++) {
|
|
1428
|
+
if (ordered[i].role === "assistant" && ordered[i].agent)
|
|
1429
|
+
return ordered[i].agent;
|
|
1430
|
+
if (ordered[i].role === "user")
|
|
1431
|
+
break; // next segment already began
|
|
1432
|
+
}
|
|
1433
|
+
return undefined;
|
|
1434
|
+
};
|
|
1435
|
+
/** user messages whose segment runs a DIFFERENT agent than the previous
|
|
1436
|
+
* segment carry metadata.agentSwitch = <agent> - the viewer renders the
|
|
1437
|
+
* transition chip from the stamp alone. */
|
|
1438
|
+
const stampAgentSwitches = () => {
|
|
1439
|
+
const bySession = new Map();
|
|
1440
|
+
for (const m of msgIndex.values()) {
|
|
1441
|
+
const list = bySession.get(m.sid);
|
|
1442
|
+
if (list)
|
|
1443
|
+
list.push(m);
|
|
1444
|
+
else
|
|
1445
|
+
bySession.set(m.sid, [m]);
|
|
1446
|
+
}
|
|
1447
|
+
for (const [, list] of bySession) {
|
|
1448
|
+
list.sort((a, b) => a.created - b.created);
|
|
1449
|
+
const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0);
|
|
1450
|
+
let prev;
|
|
1451
|
+
for (let k = 0; k < users.length; k++) {
|
|
1452
|
+
const agent = segmentAgent(list, users[k]);
|
|
1453
|
+
const d = list[users[k]].data;
|
|
1454
|
+
const meta = (d.metadata ??= {});
|
|
1455
|
+
const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
|
|
1456
|
+
if (want !== meta.agentSwitch) {
|
|
1457
|
+
if (want)
|
|
1458
|
+
meta.agentSwitch = want;
|
|
1459
|
+
else
|
|
1460
|
+
delete meta.agentSwitch;
|
|
1461
|
+
reEnqueueMessage(list[users[k]]);
|
|
1462
|
+
}
|
|
1463
|
+
if (agent)
|
|
1464
|
+
prev = agent;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
/** task/subagent tool calls get metadata.agent = the agent that actually
|
|
1469
|
+
* ran for THAT call: the child's first prompt created at/after the call
|
|
1470
|
+
* started (v1 prompts carry the agent; v2 resolves via the response).
|
|
1471
|
+
* Rank-free pairing - a resumed child's second call lands on its second
|
|
1472
|
+
* prompt by construction time. */
|
|
1473
|
+
const stampTaskAgents = () => {
|
|
1474
|
+
const bySession = new Map();
|
|
1475
|
+
for (const m of msgIndex.values()) {
|
|
1476
|
+
const list = bySession.get(m.sid);
|
|
1477
|
+
if (list)
|
|
1478
|
+
list.push(m);
|
|
1479
|
+
else
|
|
1480
|
+
bySession.set(m.sid, [m]);
|
|
1481
|
+
}
|
|
1482
|
+
for (const list of bySession.values())
|
|
1483
|
+
list.sort((a, b) => a.created - b.created);
|
|
1484
|
+
for (const m of msgIndex.values()) {
|
|
1485
|
+
if (m.role !== "assistant")
|
|
1486
|
+
continue;
|
|
1487
|
+
const content = m.data.content;
|
|
1488
|
+
if (!Array.isArray(content))
|
|
1489
|
+
continue;
|
|
1490
|
+
let mutated = false;
|
|
1491
|
+
for (const part of content) {
|
|
1492
|
+
if (!part || part.type !== "tool")
|
|
1493
|
+
continue;
|
|
1494
|
+
if (part.name !== "task" && part.name !== "subagent")
|
|
1495
|
+
continue;
|
|
1496
|
+
const state = part.state;
|
|
1497
|
+
const meta = state?.metadata;
|
|
1498
|
+
const childSid = typeof meta?.sessionID === "string" ? meta.sessionID : undefined;
|
|
1499
|
+
if (!childSid || typeof meta.agent === "string")
|
|
1500
|
+
continue;
|
|
1501
|
+
const partStart = Number(part.time?.created ?? 0);
|
|
1502
|
+
const child = bySession.get(childSid);
|
|
1503
|
+
if (!child)
|
|
1504
|
+
continue;
|
|
1505
|
+
let agent;
|
|
1506
|
+
for (const c of child) {
|
|
1507
|
+
if (c.created < partStart)
|
|
1508
|
+
continue;
|
|
1509
|
+
if (c.role === "user" && c.agent) {
|
|
1510
|
+
agent = c.agent;
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
if (!agent) {
|
|
1515
|
+
for (const c of child) {
|
|
1516
|
+
if (c.created < partStart)
|
|
1517
|
+
continue;
|
|
1518
|
+
if (c.role === "assistant" && c.agent) {
|
|
1519
|
+
agent = c.agent;
|
|
1520
|
+
break;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
if (agent) {
|
|
1525
|
+
meta.agent = agent;
|
|
1526
|
+
mutated = true;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
if (mutated)
|
|
1530
|
+
reEnqueueMessage(m);
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
/** file tools get metadata.language (shiki lang id, from the path the
|
|
1534
|
+
* call touched) - the viewer highlights from the stamp and never guesses
|
|
1535
|
+
* (owner directive; the viewer's extension map is an old-share fallback) */
|
|
1536
|
+
const LANG_BY_EXT = {
|
|
1537
|
+
ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
|
|
1538
|
+
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
1539
|
+
json: "json", jsonc: "json", md: "markdown", mdx: "mdx",
|
|
1540
|
+
py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
|
|
1541
|
+
css: "css", scss: "scss", html: "html", xml: "xml", yaml: "yaml", yml: "yaml",
|
|
1542
|
+
toml: "toml", sh: "shellscript", bash: "shellscript", ps1: "powershell",
|
|
1543
|
+
sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
|
|
1544
|
+
swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
|
|
1545
|
+
};
|
|
1546
|
+
const FILE_TOOLS = new Set(["read", "write", "edit", "patch"]);
|
|
1547
|
+
const stampFileLanguages = () => {
|
|
1548
|
+
for (const m of msgIndex.values()) {
|
|
1549
|
+
if (m.role !== "assistant")
|
|
1550
|
+
continue;
|
|
1551
|
+
const content = m.data.content;
|
|
1552
|
+
if (!Array.isArray(content))
|
|
1553
|
+
continue;
|
|
1554
|
+
let mutated = false;
|
|
1555
|
+
for (const part of content) {
|
|
1556
|
+
if (!part || part.type !== "tool")
|
|
1557
|
+
continue;
|
|
1558
|
+
if (!FILE_TOOLS.has(part.name))
|
|
1559
|
+
continue;
|
|
1560
|
+
const state = part.state;
|
|
1561
|
+
const meta = state?.metadata;
|
|
1562
|
+
if (!meta)
|
|
1563
|
+
continue;
|
|
1564
|
+
const raw = state.input?.path ?? state.input?.filePath;
|
|
1565
|
+
// language stamp (shiki id from the path)
|
|
1566
|
+
if (typeof meta.language !== "string" && typeof raw === "string") {
|
|
1567
|
+
const ext = raw.split(".").pop()?.toLowerCase() ?? "";
|
|
1568
|
+
const lang = LANG_BY_EXT[ext];
|
|
1569
|
+
if (lang) {
|
|
1570
|
+
meta.language = lang;
|
|
1571
|
+
mutated = true;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
// lineStart stamp: reads sliced from an offset keep REAL file line
|
|
1575
|
+
// numbers in the viewer's unified file card (pierre numbers from 1;
|
|
1576
|
+
// the viewer shifts by this stamp, authoritatively)
|
|
1577
|
+
if (typeof meta.lineStart !== "number" && part.name === "read") {
|
|
1578
|
+
const off = state.input?.offset;
|
|
1579
|
+
if (typeof off === "number" && off >= 0 && off > 0) {
|
|
1580
|
+
meta.lineStart = off + 1;
|
|
1581
|
+
mutated = true;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
if (mutated)
|
|
1586
|
+
reEnqueueMessage(m);
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
const stampsPass = () => {
|
|
1590
|
+
stampAgentSwitches();
|
|
1591
|
+
stampTaskAgents();
|
|
1592
|
+
stampFileLanguages();
|
|
1593
|
+
};
|
|
1594
|
+
// ---- contract v2 message assembly --------------------------------------
|
|
1595
|
+
// v1 hosts stream parts as separate events; the v2 item embeds content.
|
|
1596
|
+
// The engine keeps the assembled view per message and re-emits the
|
|
1597
|
+
// normalized whole (the keyed queue dedupes across a debounce window,
|
|
1598
|
+
// so intermediate re-emissions collapse).
|
|
1599
|
+
const mergeEntry = (st, messageID) => {
|
|
1600
|
+
let e = st.merged.get(messageID);
|
|
1601
|
+
if (!e) {
|
|
1602
|
+
e = { parts: new Map() };
|
|
1603
|
+
st.merged.set(messageID, e);
|
|
1604
|
+
}
|
|
1605
|
+
return e;
|
|
1606
|
+
};
|
|
1607
|
+
/** (Re-)emit the v2 message item for a merged v1 message. */
|
|
1608
|
+
const emitMerged = (st, sid, messageID) => {
|
|
1609
|
+
const e = st.merged.get(messageID);
|
|
1610
|
+
if (!e?.info)
|
|
1611
|
+
return; // parts arriving before their message: wait
|
|
1612
|
+
const res = normalizeV1Message(e.info, [...e.parts.values()]);
|
|
1613
|
+
if (!res)
|
|
1614
|
+
return;
|
|
1615
|
+
if ("refill" in res) {
|
|
1616
|
+
// v1 compaction summary assistant → rewrite the parent compaction
|
|
1617
|
+
// message with the summary text; the summary message itself is not
|
|
1618
|
+
// emitted as its own item
|
|
1619
|
+
const parent = st.merged.get(res.refill);
|
|
1620
|
+
if (parent?.info) {
|
|
1621
|
+
const compaction = normalizeV1Message(parent.info, [...parent.parts.values()]);
|
|
1622
|
+
if (compaction && "message" in compaction) {
|
|
1623
|
+
const msg = compaction.message;
|
|
1624
|
+
msg.summary = res.summary;
|
|
1625
|
+
enqueue(st, itemFor("message", msg, sid));
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
enqueue(st, itemFor("message", res.message, sid));
|
|
1631
|
+
};
|
|
1632
|
+
/** message.updated / part.updated ingestion for v1 hosts; v2 hosts call
|
|
1633
|
+
* with v2-native messages (pass-through, no merge). */
|
|
1634
|
+
const ingestMessageInfo = (st, sid, info) => {
|
|
1635
|
+
if (isV2NativeMessage(info)) {
|
|
1636
|
+
enqueue(st, itemFor("message", info, sid));
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
if (typeof info?.id !== "string")
|
|
1640
|
+
return;
|
|
1641
|
+
const e = mergeEntry(st, info.id);
|
|
1642
|
+
e.info = info;
|
|
1643
|
+
emitMerged(st, sid, info.id);
|
|
1644
|
+
};
|
|
1645
|
+
const ingestPart = (st, sid, part) => {
|
|
1646
|
+
if (typeof part?.id !== "string" || typeof part.messageID !== "string")
|
|
1647
|
+
return;
|
|
1648
|
+
const e = mergeEntry(st, part.messageID);
|
|
1649
|
+
e.parts.set(part.id, part);
|
|
1650
|
+
emitMerged(st, sid, part.messageID);
|
|
1651
|
+
};
|
|
1652
|
+
const dropMergedMessage = (st, messageID) => {
|
|
1653
|
+
st.merged.delete(messageID);
|
|
1654
|
+
};
|
|
1655
|
+
const dropMergedPart = (st, sid, messageID, partID) => {
|
|
1656
|
+
const e = st.merged.get(messageID);
|
|
1657
|
+
if (!e)
|
|
1658
|
+
return;
|
|
1659
|
+
e.parts.delete(partID);
|
|
1660
|
+
if (e.info)
|
|
1661
|
+
emitMerged(st, sid, messageID);
|
|
1662
|
+
};
|
|
1332
1663
|
const remove = (st) => {
|
|
1333
1664
|
st.closed = true;
|
|
1334
1665
|
st.activated = false;
|
|
@@ -1413,7 +1744,7 @@ export function createEngine(deps) {
|
|
|
1413
1744
|
const PAGE_SIZE = 100;
|
|
1414
1745
|
const MAX_PAGES = 50;
|
|
1415
1746
|
const query = () => (deps.directory ? { directory: deps.directory } : {});
|
|
1416
|
-
async function fetchMessages(client, sid) {
|
|
1747
|
+
async function fetchMessages(client, sid, onPage) {
|
|
1417
1748
|
if (typeof client.session.messages !== "function")
|
|
1418
1749
|
return [];
|
|
1419
1750
|
const out = [];
|
|
@@ -1429,11 +1760,29 @@ export function createEngine(deps) {
|
|
|
1429
1760
|
if (!Array.isArray(result) || result.length === 0)
|
|
1430
1761
|
break;
|
|
1431
1762
|
out.push(...result);
|
|
1763
|
+
// stream pages as they arrive: enqueue (and the rate-limited flush)
|
|
1764
|
+
// starts with page 1 while later pages are still loading — a huge
|
|
1765
|
+
// session backfills progressively instead of after a full collect
|
|
1766
|
+
if (onPage) {
|
|
1767
|
+
try {
|
|
1768
|
+
onPage(result);
|
|
1769
|
+
}
|
|
1770
|
+
catch { }
|
|
1771
|
+
}
|
|
1432
1772
|
if (result.length < PAGE_SIZE)
|
|
1433
1773
|
break;
|
|
1434
1774
|
const last = result[result.length - 1];
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1775
|
+
const lastInfo = (last?.info ?? last);
|
|
1776
|
+
const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined;
|
|
1777
|
+
// `before` is an OPAQUE cursor: base64url({id, time}) of the page
|
|
1778
|
+
// tail (message-v2.ts codec). A bare message id 400s on hosts with
|
|
1779
|
+
// cursor pagination — every v1 backfill silently truncated to one
|
|
1780
|
+
// 100-message page (live-caught).
|
|
1781
|
+
const lastTime = lastInfo?.time?.created;
|
|
1782
|
+
if (!lastID || typeof lastTime !== "number")
|
|
1783
|
+
break;
|
|
1784
|
+
const nextBefore = cursorFor(lastID, lastTime);
|
|
1785
|
+
if (nextBefore === before)
|
|
1437
1786
|
break;
|
|
1438
1787
|
before = nextBefore;
|
|
1439
1788
|
}
|
|
@@ -1443,27 +1792,32 @@ export function createEngine(deps) {
|
|
|
1443
1792
|
const sessionInfoValue = info ?? (await sessionInfo(client, sid));
|
|
1444
1793
|
if (sessionInfoValue)
|
|
1445
1794
|
enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
|
|
1446
|
-
const messages = await fetchMessages(client, sid);
|
|
1447
1795
|
let modelSeen = false;
|
|
1448
|
-
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
enqueue(st, itemFor("message", m, sid));
|
|
1453
|
-
if (m.role === "user" && m.model) {
|
|
1454
|
-
modelSeen = true;
|
|
1455
|
-
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
|
|
1456
|
-
enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
|
|
1457
|
-
}
|
|
1458
|
-
for (const part of message.parts ?? []) {
|
|
1459
|
-
const p = part;
|
|
1460
|
-
if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
|
|
1796
|
+
const consume = (messages) => {
|
|
1797
|
+
for (const message of messages) {
|
|
1798
|
+
const m = (message.info ?? message);
|
|
1799
|
+
if (!m || typeof m.id !== "string")
|
|
1461
1800
|
continue;
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1801
|
+
// seed the merge state, then emit the assembled v2 message
|
|
1802
|
+
const e = mergeEntry(st, m.id);
|
|
1803
|
+
e.info = m;
|
|
1804
|
+
for (const rawPart of (message.parts ?? [])) {
|
|
1805
|
+
const p = rawPart;
|
|
1806
|
+
if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
|
|
1807
|
+
continue;
|
|
1808
|
+
if (p.state?.status === "error")
|
|
1809
|
+
st.badSignal = true;
|
|
1810
|
+
e.parts.set(p.id, p);
|
|
1811
|
+
}
|
|
1812
|
+
emitMerged(st, sid, m.id);
|
|
1813
|
+
if (m.role === "user" && m.model) {
|
|
1814
|
+
modelSeen = true;
|
|
1815
|
+
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
|
|
1816
|
+
enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
|
|
1817
|
+
}
|
|
1465
1818
|
}
|
|
1466
|
-
}
|
|
1819
|
+
};
|
|
1820
|
+
const messages = await fetchMessages(client, sid, (page) => consume(page));
|
|
1467
1821
|
if (!modelSeen && sessionInfoValue?.model) {
|
|
1468
1822
|
// hosts whose user messages carry no model (v2): label from the
|
|
1469
1823
|
// session's own model
|
|
@@ -1773,9 +2127,9 @@ export function createEngine(deps) {
|
|
|
1773
2127
|
// opencode undo: revert() only marks the session (the viewer
|
|
1774
2128
|
// hides the range); the NEXT prompt hard-deletes the reverted
|
|
1775
2129
|
// messages/parts via cleanup() and publishes these events.
|
|
1776
|
-
//
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
2130
|
+
// A message tombstone removes the whole v2 message (content is
|
|
2131
|
+
// embedded); a part removal re-emits the merged message minus
|
|
2132
|
+
// the part.
|
|
1779
2133
|
const st = stFor(sid);
|
|
1780
2134
|
if (!st || !st.activated || st.closed)
|
|
1781
2135
|
return;
|
|
@@ -1783,13 +2137,14 @@ export function createEngine(deps) {
|
|
|
1783
2137
|
if (!messageID)
|
|
1784
2138
|
return;
|
|
1785
2139
|
if (t === "message.removed") {
|
|
1786
|
-
|
|
2140
|
+
dropMergedMessage(st, messageID);
|
|
2141
|
+
enqueue(st, { key: `message/${messageID}`, item: messageTombstone(st.sid, messageID) });
|
|
1787
2142
|
}
|
|
1788
2143
|
else {
|
|
1789
2144
|
const partID = p.partID;
|
|
1790
2145
|
if (!partID)
|
|
1791
2146
|
return;
|
|
1792
|
-
|
|
2147
|
+
dropMergedPart(st, sid, messageID, partID);
|
|
1793
2148
|
}
|
|
1794
2149
|
}
|
|
1795
2150
|
else if (t === "message.updated") {
|
|
@@ -1844,7 +2199,7 @@ export function createEngine(deps) {
|
|
|
1844
2199
|
}
|
|
1845
2200
|
if (!st || !st.activated || st.closed)
|
|
1846
2201
|
return;
|
|
1847
|
-
|
|
2202
|
+
ingestMessageInfo(st, sid, p.info);
|
|
1848
2203
|
const info = p.info;
|
|
1849
2204
|
if (info?.role === "user" && info?.model) {
|
|
1850
2205
|
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
|
|
@@ -1853,8 +2208,9 @@ export function createEngine(deps) {
|
|
|
1853
2208
|
// an assistant message that DIED (finish:"error") — e.g. all
|
|
1854
2209
|
// retries exhausted — surfaces as a 200 response with an errored
|
|
1855
2210
|
// message, NOT as session.error (live-caught in the suite); the
|
|
1856
|
-
// share must still say error
|
|
1857
|
-
|
|
2211
|
+
// share must still say error. Works for v2-native messages too
|
|
2212
|
+
// (finish lives on the message itself).
|
|
2213
|
+
if (info?.finish === "error") {
|
|
1858
2214
|
st.badSignal = true;
|
|
1859
2215
|
}
|
|
1860
2216
|
}
|
|
@@ -1865,13 +2221,13 @@ export function createEngine(deps) {
|
|
|
1865
2221
|
const part = p.part;
|
|
1866
2222
|
if (part?.state?.status === "error")
|
|
1867
2223
|
st.badSignal = true;
|
|
1868
|
-
|
|
2224
|
+
ingestPart(st, sid, part);
|
|
1869
2225
|
}
|
|
1870
2226
|
else if (t === "session.diff") {
|
|
1871
2227
|
const st = stFor(sid);
|
|
1872
2228
|
if (!st || !st.activated || st.closed)
|
|
1873
2229
|
return;
|
|
1874
|
-
enqueue(st, itemFor("session_diff", p.diff, sid));
|
|
2230
|
+
enqueue(st, itemFor("session_diff", normalizeV1Diff(p.diff), sid));
|
|
1875
2231
|
}
|
|
1876
2232
|
else if (t === "session.deleted") {
|
|
1877
2233
|
// a CHILD session ending (task done, gc) must NEVER take the
|