opencode-sesh 2.1.5 → 2.1.7
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/AGENTS.md +16 -3
- package/CHANGELOG.md +18 -0
- package/dist/tui.js +146 -41
- package/package.json +1 -1
- package/tui-plugins/sesh-panel/tui.tsx +135 -44
package/AGENTS.md
CHANGED
|
@@ -197,8 +197,20 @@ when opencode exits, the picker reopens with the query preserved.
|
|
|
197
197
|
- **Picker search:** pages the global session list (`SESSION_PAGE_LIMIT`, capped
|
|
198
198
|
at `SESSION_MAX` and reported when truncated) rather than a fixed-size
|
|
199
199
|
window, then indexes transcript text for **every** session in batches
|
|
200
|
-
(`TRANSCRIPT_BATCH
|
|
201
|
-
|
|
200
|
+
(`TRANSCRIPT_BATCH`, also capped by `TRANSCRIPT_BATCH_BYTES` of message
|
|
201
|
+
payload; a single larger session forms its own batch) with bounded remote
|
|
202
|
+
concurrency (`REMOTE_CONCURRENCY`). Transcript text is cached in memory for
|
|
203
|
+
the plugin lifetime (never on disk, cleared on dispose); a worker signature
|
|
204
|
+
query (session timestamp, message count, latest message timestamp, payload
|
|
205
|
+
size; chunked by `SIGNATURE_CHUNK`) lets reopened pickers reuse unchanged
|
|
206
|
+
sessions, extract only new or changed ones, and prune vanished ones. Sessions
|
|
207
|
+
indexed through the remote path are not cached. If the signature query fails
|
|
208
|
+
(`octet_length` needs SQLite ≥ 3.43; macOS Bun uses the system SQLite),
|
|
209
|
+
extraction still runs locally, batched by count only, and caches nothing.
|
|
210
|
+
Local extraction is one SQL statement per batch in the DB worker
|
|
211
|
+
(`transcriptTextQuery`, JSON-guarded) that returns only `{sid, text}` rows,
|
|
212
|
+
so tool payloads never reach the UI thread; if the worker rejects, sessions
|
|
213
|
+
not yet indexed fall back to the remote path. The header shows indexing progress/coverage. Visible transcript hits get
|
|
202
214
|
short excerpts from the text-only index (never tool or reasoning payloads).
|
|
203
215
|
Project, needs-input (Option-W), and pinned-session (Option-S) filters compose
|
|
204
216
|
with the query; the picker remembers query/scope/filters until plugin restart.
|
|
@@ -273,7 +285,8 @@ when opencode exits, the picker reopens with the query preserved.
|
|
|
273
285
|
(also run by `bun run test`).
|
|
274
286
|
- `bun run test:tui-worker` — starts the real blob Worker against a bun:sqlite
|
|
275
287
|
fixture; asserts results round-trip and the main thread keeps ticking during
|
|
276
|
-
a slow query
|
|
288
|
+
a slow query and during transcript indexing of large tool payloads (also run
|
|
289
|
+
by `bun run test`).
|
|
277
290
|
- `bun run typecheck` — `tsc` over `tui-plugins/` and `plugins/`.
|
|
278
291
|
- `bun run lint:sh` — `bash -n` on every script.
|
|
279
292
|
- `HOME=/tmp/fakehome bash install.sh` — installer smoke test.
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.1.7] - 2026-11-02
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **tui:** Reuse unchanged transcript text and bound index batches by bytes (#37)
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **tui:** Stop the DB worker and sidebar polls on plugin dispose (#36)
|
|
17
|
+
|
|
18
|
+
## [2.1.6] - 2026-11-02
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **tui:** Extract transcript text in the DB worker (#35)
|
|
23
|
+
|
|
8
24
|
## [2.1.5] - 2026-11-02
|
|
9
25
|
|
|
10
26
|
### Fixed
|
|
@@ -542,6 +558,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
542
558
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
543
559
|
CI.
|
|
544
560
|
|
|
561
|
+
[2.1.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.6...v2.1.7
|
|
562
|
+
[2.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.5...v2.1.6
|
|
545
563
|
[2.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.4...v2.1.5
|
|
546
564
|
[2.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.3...v2.1.4
|
|
547
565
|
[2.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.2...v2.1.3
|
package/dist/tui.js
CHANGED
|
@@ -605,6 +605,8 @@ async function fetchEntries(api, active = () => true) {
|
|
|
605
605
|
}
|
|
606
606
|
const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/;
|
|
607
607
|
const TRANSCRIPT_BATCH = 64;
|
|
608
|
+
const TRANSCRIPT_BATCH_BYTES = 8 * 1024 * 1024;
|
|
609
|
+
const SIGNATURE_CHUNK = 500;
|
|
608
610
|
const REMOTE_CONCURRENCY = 8;
|
|
609
611
|
const nextTick = () => new Promise(resolve => setTimeout(resolve, 0));
|
|
610
612
|
let resolvedDbPath;
|
|
@@ -663,9 +665,11 @@ function createDbWorker() {
|
|
|
663
665
|
// Read-only queries against the local store, served by one lazily started
|
|
664
666
|
// worker. Any failure (no worker, no bun:sqlite, query error, timeout) rejects;
|
|
665
667
|
// a timeout or worker error discards the worker so the next call starts afresh.
|
|
668
|
+
// `close` is final: later calls reject and never start a worker.
|
|
666
669
|
function createDbClient(createWorker = createDbWorker, timeoutMs = DB_QUERY_TIMEOUT_MS) {
|
|
667
670
|
let worker;
|
|
668
671
|
let nextId = 0;
|
|
672
|
+
let closed = false;
|
|
669
673
|
const pending = new Map();
|
|
670
674
|
const reset = error => {
|
|
671
675
|
const dead = worker;
|
|
@@ -702,6 +706,10 @@ function createDbClient(createWorker = createDbWorker, timeoutMs = DB_QUERY_TIME
|
|
|
702
706
|
return created;
|
|
703
707
|
};
|
|
704
708
|
const run = (sql, params = []) => new Promise((resolve, reject) => {
|
|
709
|
+
if (closed) {
|
|
710
|
+
reject(new Error("local store closed"));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
705
713
|
try {
|
|
706
714
|
worker ??= start();
|
|
707
715
|
} catch (error) {
|
|
@@ -729,10 +737,16 @@ function createDbClient(createWorker = createDbWorker, timeoutMs = DB_QUERY_TIME
|
|
|
729
737
|
});
|
|
730
738
|
return {
|
|
731
739
|
run,
|
|
732
|
-
close: () =>
|
|
740
|
+
close: () => {
|
|
741
|
+
closed = true;
|
|
742
|
+
reset(new Error("local store closed"));
|
|
743
|
+
}
|
|
733
744
|
};
|
|
734
745
|
}
|
|
735
|
-
|
|
746
|
+
|
|
747
|
+
// Each plugin setup installs its own client and closes only that one on
|
|
748
|
+
// dispose: a reload reuses this module instance.
|
|
749
|
+
let dbClient = createDbClient();
|
|
736
750
|
function createSessionPoll(api, refreshPins) {
|
|
737
751
|
// Listener -> whether it reads `snapshot.waiting`. The needs-input query
|
|
738
752
|
// costs worker CPU on every poll, so it only runs while such a listener exists.
|
|
@@ -920,29 +934,61 @@ GROUP BY live.session_id`, batch);
|
|
|
920
934
|
}
|
|
921
935
|
return details;
|
|
922
936
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
937
|
+
|
|
938
|
+
// Text parts only, extracted inside SQLite so tool and reasoning payloads never
|
|
939
|
+
// reach the caller. The CASE guards keep json_extract away from malformed rows
|
|
940
|
+
// and non-object parts, which SQL AND ordering alone would not.
|
|
941
|
+
function transcriptTextQuery(count) {
|
|
942
|
+
const placeholders = Array.from({
|
|
943
|
+
length: count
|
|
944
|
+
}, () => "?").join(",");
|
|
945
|
+
return `WITH msg AS (
|
|
946
|
+
SELECT session_id AS sid, seq, rowid AS rid, CASE WHEN json_valid(data) THEN data END AS doc
|
|
947
|
+
FROM session_message
|
|
948
|
+
WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
|
|
949
|
+
), parts AS (
|
|
950
|
+
SELECT sid, seq, rid, 0 AS part,
|
|
951
|
+
CASE WHEN json_type(doc, '$.text') = 'text' THEN json_extract(doc, '$.text') END AS text
|
|
952
|
+
FROM msg
|
|
953
|
+
UNION ALL
|
|
954
|
+
SELECT msg.sid, msg.seq, msg.rid, 1 + item.key AS part,
|
|
955
|
+
CASE WHEN item.type = 'object' THEN
|
|
956
|
+
CASE WHEN json_extract(item.value, '$.type') = 'text' AND json_type(item.value, '$.text') = 'text'
|
|
957
|
+
THEN json_extract(item.value, '$.text') END
|
|
958
|
+
END AS text
|
|
959
|
+
FROM msg, json_each(msg.doc, '$.content') AS item
|
|
960
|
+
WHERE json_type(msg.doc, '$.content') = 'array'
|
|
961
|
+
)
|
|
962
|
+
SELECT sid, text FROM parts WHERE text IS NOT NULL AND text <> '' ORDER BY sid, seq, rid, part`;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Transcript text per session for the plugin lifetime; memory only, so
|
|
966
|
+
// conversation text is never written anywhere. `sig` identifies the extracted
|
|
967
|
+
// state of the session (see transcriptSignatureQuery).
|
|
968
|
+
const transcriptCache = new Map();
|
|
969
|
+
const clearTranscriptCache = () => transcriptCache.clear();
|
|
970
|
+
|
|
971
|
+
// Cheap change detector mirroring the fzf extraction cache: message count and
|
|
972
|
+
// latest message timestamp, plus the payload size that bounds extraction
|
|
973
|
+
// batches. octet_length() reads the stored size, never the payload; length()
|
|
974
|
+
// would read every payload to count characters.
|
|
975
|
+
function transcriptSignatureQuery(count) {
|
|
976
|
+
const placeholders = Array.from({
|
|
977
|
+
length: count
|
|
978
|
+
}, () => "?").join(",");
|
|
979
|
+
return `SELECT session_id AS sid, COUNT(*) AS n, MAX(time_updated) AS m, SUM(octet_length(data)) AS bytes
|
|
980
|
+
FROM session_message
|
|
981
|
+
WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
|
|
982
|
+
GROUP BY session_id`;
|
|
940
983
|
}
|
|
941
984
|
|
|
942
985
|
// Index titles/directories immediately, then add transcript text progressively
|
|
943
986
|
// in batches so the render thread can breathe. Covers every entry, not a fixed
|
|
944
|
-
// newest window; `onProgress` lets the picker show coverage.
|
|
945
|
-
|
|
987
|
+
// newest window; `onProgress` lets the picker show coverage. Local extraction
|
|
988
|
+
// runs through `run` (the worker client) and returns text only; sessions whose
|
|
989
|
+
// signature is unchanged reuse their cached text, and extraction batches are
|
|
990
|
+
// bounded by session count and payload bytes.
|
|
991
|
+
async function buildSearchIndex(api, entries, onProgress, active = () => true, run = dbClient.run, cache = transcriptCache) {
|
|
946
992
|
const index = new Map();
|
|
947
993
|
for (const entry of entries) {
|
|
948
994
|
index.set(entry.id, `${entry.title} ${entry.dir}`);
|
|
@@ -960,25 +1006,77 @@ async function buildSearchIndex(api, entries, onProgress, active = () => true) {
|
|
|
960
1006
|
};
|
|
961
1007
|
report(false);
|
|
962
1008
|
if (!active()) return index;
|
|
963
|
-
|
|
964
|
-
|
|
1009
|
+
try {
|
|
1010
|
+
// `entries` must be the full session list: sessions missing from it are pruned.
|
|
1011
|
+
const idSet = new Set(ids);
|
|
1012
|
+
for (const sid of cache.keys()) if (!idSet.has(sid)) cache.delete(sid);
|
|
1013
|
+
// Undefined when the signature query fails (octet_length needs SQLite
|
|
1014
|
+
// >= 3.43, and macOS Bun uses the system SQLite): extract everything
|
|
1015
|
+
// locally, batched by count only, and cache nothing.
|
|
1016
|
+
let signatures = new Map();
|
|
965
1017
|
try {
|
|
966
|
-
for (let i = 0; i < ids.length; i +=
|
|
967
|
-
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
const rows
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
report(false);
|
|
975
|
-
await nextTick();
|
|
1018
|
+
for (let i = 0; i < ids.length; i += SIGNATURE_CHUNK) {
|
|
1019
|
+
const chunk = ids.slice(i, i + SIGNATURE_CHUNK);
|
|
1020
|
+
const rows = await run(transcriptSignatureQuery(chunk.length), chunk);
|
|
1021
|
+
if (!active()) return index;
|
|
1022
|
+
for (const row of rows) signatures.set(row.sid, {
|
|
1023
|
+
sig: `${row.n}:${row.m}:${row.bytes}`,
|
|
1024
|
+
bytes: row.bytes ?? 0
|
|
1025
|
+
});
|
|
976
1026
|
}
|
|
977
1027
|
} catch {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1028
|
+
if (!active()) return index;
|
|
1029
|
+
signatures = undefined;
|
|
1030
|
+
}
|
|
1031
|
+
const sigs = signatures && new Map(entries.map(entry => [entry.id, `${entry.updated}:${signatures.get(entry.id)?.sig ?? "0:0:0"}`]));
|
|
1032
|
+
const stale = [];
|
|
1033
|
+
for (const id of ids) {
|
|
1034
|
+
const hit = cache.get(id);
|
|
1035
|
+
if (hit && sigs && hit.sig === sigs.get(id)) {
|
|
1036
|
+
if (hit.text) index.set(id, `${index.get(id) ?? ""} ${hit.text}`.trim());
|
|
1037
|
+
remaining.delete(id);
|
|
1038
|
+
indexed += 1;
|
|
1039
|
+
} else stale.push(id);
|
|
1040
|
+
}
|
|
1041
|
+
if (indexed > 0) report(false);
|
|
1042
|
+
const batches = [];
|
|
1043
|
+
let current = [];
|
|
1044
|
+
let currentBytes = 0;
|
|
1045
|
+
for (const id of stale) {
|
|
1046
|
+
const bytes = signatures?.get(id)?.bytes ?? 0;
|
|
1047
|
+
if (current.length > 0 && (current.length >= TRANSCRIPT_BATCH || currentBytes + bytes > TRANSCRIPT_BATCH_BYTES)) {
|
|
1048
|
+
batches.push(current);
|
|
1049
|
+
current = [];
|
|
1050
|
+
currentBytes = 0;
|
|
1051
|
+
}
|
|
1052
|
+
current.push(id);
|
|
1053
|
+
currentBytes += bytes;
|
|
981
1054
|
}
|
|
1055
|
+
if (current.length > 0) batches.push(current);
|
|
1056
|
+
for (const batch of batches) {
|
|
1057
|
+
if (!active()) break;
|
|
1058
|
+
const rows = await run(transcriptTextQuery(batch.length), batch);
|
|
1059
|
+
if (!active()) break;
|
|
1060
|
+
const texts = new Map();
|
|
1061
|
+
for (const row of rows) {
|
|
1062
|
+
const list = texts.get(row.sid);
|
|
1063
|
+
if (list) list.push(row.text);else texts.set(row.sid, [row.text]);
|
|
1064
|
+
}
|
|
1065
|
+
for (const id of batch) {
|
|
1066
|
+
const list = texts.get(id) ?? [];
|
|
1067
|
+
if (sigs) cache.set(id, {
|
|
1068
|
+
sig: sigs.get(id),
|
|
1069
|
+
text: list.join(" ").trim()
|
|
1070
|
+
});
|
|
1071
|
+
if (list.length > 0) index.set(id, [index.get(id) ?? "", ...list].join(" ").trim());
|
|
1072
|
+
remaining.delete(id);
|
|
1073
|
+
}
|
|
1074
|
+
indexed += batch.length;
|
|
1075
|
+
report(false);
|
|
1076
|
+
await nextTick();
|
|
1077
|
+
}
|
|
1078
|
+
} catch {
|
|
1079
|
+
// local store unavailable mid-walk; fall back to the remote path below
|
|
982
1080
|
}
|
|
983
1081
|
if (active() && remaining.size > 0) {
|
|
984
1082
|
await buildSearchIndexRemote(api, [...remaining], index, () => {
|
|
@@ -4296,7 +4394,11 @@ function createApi(ctx) {
|
|
|
4296
4394
|
const [dialogOpen, setDialogOpen] = createSignal(false);
|
|
4297
4395
|
let sourceTheme;
|
|
4298
4396
|
let currentTheme;
|
|
4397
|
+
const slotDisposers = [];
|
|
4299
4398
|
return {
|
|
4399
|
+
dispose() {
|
|
4400
|
+
for (const dispose of slotDisposers.splice(0)) dispose();
|
|
4401
|
+
},
|
|
4300
4402
|
theme: {
|
|
4301
4403
|
get current() {
|
|
4302
4404
|
if (sourceTheme !== ctx.theme) {
|
|
@@ -4431,20 +4533,20 @@ function createApi(ctx) {
|
|
|
4431
4533
|
slots: {
|
|
4432
4534
|
register(cfg) {
|
|
4433
4535
|
if (cfg.slots.sidebar_content && ctx.options?.showSidebar !== false) {
|
|
4434
|
-
ctx.ui.slot({
|
|
4536
|
+
slotDisposers.push(ctx.ui.slot({
|
|
4435
4537
|
...(ctx.options?.sidebarPlacement === "prepend" ? {
|
|
4436
4538
|
prepend: "sidebar.content"
|
|
4437
4539
|
} : {
|
|
4438
4540
|
append: "sidebar.content"
|
|
4439
4541
|
}),
|
|
4440
4542
|
render: cfg.slots.sidebar_content
|
|
4441
|
-
});
|
|
4543
|
+
}));
|
|
4442
4544
|
}
|
|
4443
4545
|
if (cfg.slots.home_bottom) {
|
|
4444
|
-
ctx.ui.slot({
|
|
4546
|
+
slotDisposers.push(ctx.ui.slot({
|
|
4445
4547
|
prepend: "home.footer",
|
|
4446
4548
|
render: cfg.slots.home_bottom
|
|
4447
|
-
});
|
|
4549
|
+
}));
|
|
4448
4550
|
}
|
|
4449
4551
|
}
|
|
4450
4552
|
},
|
|
@@ -4558,6 +4660,7 @@ function createApi(ctx) {
|
|
|
4558
4660
|
export default Plugin.define({
|
|
4559
4661
|
id: "sesh-panel",
|
|
4560
4662
|
async setup(ctx) {
|
|
4663
|
+
const client = dbClient = createDbClient();
|
|
4561
4664
|
const api = createApi(ctx);
|
|
4562
4665
|
let initialized = false;
|
|
4563
4666
|
const disposeSlot = ctx.ui.slot({
|
|
@@ -4574,8 +4677,10 @@ export default Plugin.define({
|
|
|
4574
4677
|
return () => {
|
|
4575
4678
|
if (api.ui.dialog.open) api.ui.dialog.clear();
|
|
4576
4679
|
disposeSlot();
|
|
4680
|
+
api.dispose();
|
|
4577
4681
|
removeRootMouse?.();
|
|
4578
|
-
|
|
4682
|
+
clearTranscriptCache();
|
|
4683
|
+
client.close();
|
|
4579
4684
|
};
|
|
4580
4685
|
}
|
|
4581
4686
|
});
|
package/package.json
CHANGED
|
@@ -503,6 +503,8 @@ async function fetchEntries(api: TuiPluginApi, active = () => true): Promise<Ent
|
|
|
503
503
|
|
|
504
504
|
const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/
|
|
505
505
|
const TRANSCRIPT_BATCH = 64
|
|
506
|
+
const TRANSCRIPT_BATCH_BYTES = 8 * 1024 * 1024
|
|
507
|
+
const SIGNATURE_CHUNK = 500
|
|
506
508
|
const REMOTE_CONCURRENCY = 8
|
|
507
509
|
|
|
508
510
|
type IndexProgress = { indexed: number; total: number; complete: boolean }
|
|
@@ -565,10 +567,12 @@ function createDbWorker(): any {
|
|
|
565
567
|
// Read-only queries against the local store, served by one lazily started
|
|
566
568
|
// worker. Any failure (no worker, no bun:sqlite, query error, timeout) rejects;
|
|
567
569
|
// a timeout or worker error discards the worker so the next call starts afresh.
|
|
570
|
+
// `close` is final: later calls reject and never start a worker.
|
|
568
571
|
function createDbClient(createWorker: () => any = createDbWorker, timeoutMs = DB_QUERY_TIMEOUT_MS) {
|
|
569
572
|
type Pending = { resolve: (rows: unknown[]) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
570
573
|
let worker: any
|
|
571
574
|
let nextId = 0
|
|
575
|
+
let closed = false
|
|
572
576
|
const pending = new Map<number, Pending>()
|
|
573
577
|
const reset = (error: Error) => {
|
|
574
578
|
const dead = worker
|
|
@@ -601,6 +605,10 @@ function createDbClient(createWorker: () => any = createDbWorker, timeoutMs = DB
|
|
|
601
605
|
}
|
|
602
606
|
const run: DbRun = (sql, params = []) =>
|
|
603
607
|
new Promise((resolve, reject) => {
|
|
608
|
+
if (closed) {
|
|
609
|
+
reject(new Error("local store closed"))
|
|
610
|
+
return
|
|
611
|
+
}
|
|
604
612
|
try {
|
|
605
613
|
worker ??= start()
|
|
606
614
|
} catch (error) {
|
|
@@ -617,10 +625,12 @@ function createDbClient(createWorker: () => any = createDbWorker, timeoutMs = DB
|
|
|
617
625
|
reset(error as Error)
|
|
618
626
|
}
|
|
619
627
|
})
|
|
620
|
-
return { run, close: () => reset(new Error("local store closed")) }
|
|
628
|
+
return { run, close: () => { closed = true; reset(new Error("local store closed")) } }
|
|
621
629
|
}
|
|
622
630
|
|
|
623
|
-
|
|
631
|
+
// Each plugin setup installs its own client and closes only that one on
|
|
632
|
+
// dispose: a reload reuses this module instance.
|
|
633
|
+
let dbClient = createDbClient()
|
|
624
634
|
|
|
625
635
|
type SessionSnapshot = { entries: Entry[]; waiting: Map<string, string>; failed: boolean }
|
|
626
636
|
|
|
@@ -805,33 +815,62 @@ GROUP BY live.session_id`, batch)) as { id: string; question_since: number | nul
|
|
|
805
815
|
return details
|
|
806
816
|
}
|
|
807
817
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
818
|
+
// Text parts only, extracted inside SQLite so tool and reasoning payloads never
|
|
819
|
+
// reach the caller. The CASE guards keep json_extract away from malformed rows
|
|
820
|
+
// and non-object parts, which SQL AND ordering alone would not.
|
|
821
|
+
function transcriptTextQuery(count: number): string {
|
|
822
|
+
const placeholders = Array.from({ length: count }, () => "?").join(",")
|
|
823
|
+
return `WITH msg AS (
|
|
824
|
+
SELECT session_id AS sid, seq, rowid AS rid, CASE WHEN json_valid(data) THEN data END AS doc
|
|
825
|
+
FROM session_message
|
|
826
|
+
WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
|
|
827
|
+
), parts AS (
|
|
828
|
+
SELECT sid, seq, rid, 0 AS part,
|
|
829
|
+
CASE WHEN json_type(doc, '$.text') = 'text' THEN json_extract(doc, '$.text') END AS text
|
|
830
|
+
FROM msg
|
|
831
|
+
UNION ALL
|
|
832
|
+
SELECT msg.sid, msg.seq, msg.rid, 1 + item.key AS part,
|
|
833
|
+
CASE WHEN item.type = 'object' THEN
|
|
834
|
+
CASE WHEN json_extract(item.value, '$.type') = 'text' AND json_type(item.value, '$.text') = 'text'
|
|
835
|
+
THEN json_extract(item.value, '$.text') END
|
|
836
|
+
END AS text
|
|
837
|
+
FROM msg, json_each(msg.doc, '$.content') AS item
|
|
838
|
+
WHERE json_type(msg.doc, '$.content') = 'array'
|
|
839
|
+
)
|
|
840
|
+
SELECT sid, text FROM parts WHERE text IS NOT NULL AND text <> '' ORDER BY sid, seq, rid, part`
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Transcript text per session for the plugin lifetime; memory only, so
|
|
844
|
+
// conversation text is never written anywhere. `sig` identifies the extracted
|
|
845
|
+
// state of the session (see transcriptSignatureQuery).
|
|
846
|
+
const transcriptCache = new Map<string, { sig: string; text: string }>()
|
|
847
|
+
const clearTranscriptCache = () => transcriptCache.clear()
|
|
848
|
+
|
|
849
|
+
// Cheap change detector mirroring the fzf extraction cache: message count and
|
|
850
|
+
// latest message timestamp, plus the payload size that bounds extraction
|
|
851
|
+
// batches. octet_length() reads the stored size, never the payload; length()
|
|
852
|
+
// would read every payload to count characters.
|
|
853
|
+
function transcriptSignatureQuery(count: number): string {
|
|
854
|
+
const placeholders = Array.from({ length: count }, () => "?").join(",")
|
|
855
|
+
return `SELECT session_id AS sid, COUNT(*) AS n, MAX(time_updated) AS m, SUM(octet_length(data)) AS bytes
|
|
856
|
+
FROM session_message
|
|
857
|
+
WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
|
|
858
|
+
GROUP BY session_id`
|
|
825
859
|
}
|
|
826
860
|
|
|
827
861
|
// Index titles/directories immediately, then add transcript text progressively
|
|
828
862
|
// in batches so the render thread can breathe. Covers every entry, not a fixed
|
|
829
|
-
// newest window; `onProgress` lets the picker show coverage.
|
|
863
|
+
// newest window; `onProgress` lets the picker show coverage. Local extraction
|
|
864
|
+
// runs through `run` (the worker client) and returns text only; sessions whose
|
|
865
|
+
// signature is unchanged reuse their cached text, and extraction batches are
|
|
866
|
+
// bounded by session count and payload bytes.
|
|
830
867
|
async function buildSearchIndex(
|
|
831
868
|
api: TuiPluginApi,
|
|
832
869
|
entries: Entry[],
|
|
833
870
|
onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
|
|
834
871
|
active = () => true,
|
|
872
|
+
run: DbRun = dbClient.run,
|
|
873
|
+
cache = transcriptCache,
|
|
835
874
|
): Promise<Map<string, string>> {
|
|
836
875
|
const index = new Map<string, string>()
|
|
837
876
|
for (const entry of entries) {
|
|
@@ -845,29 +884,74 @@ async function buildSearchIndex(
|
|
|
845
884
|
report(false)
|
|
846
885
|
|
|
847
886
|
if (!active()) return index
|
|
848
|
-
|
|
849
|
-
|
|
887
|
+
try {
|
|
888
|
+
// `entries` must be the full session list: sessions missing from it are pruned.
|
|
889
|
+
const idSet = new Set(ids)
|
|
890
|
+
for (const sid of cache.keys()) if (!idSet.has(sid)) cache.delete(sid)
|
|
891
|
+
// Undefined when the signature query fails (octet_length needs SQLite
|
|
892
|
+
// >= 3.43, and macOS Bun uses the system SQLite): extract everything
|
|
893
|
+
// locally, batched by count only, and cache nothing.
|
|
894
|
+
let signatures: Map<string, { sig: string; bytes: number }> | undefined = new Map()
|
|
850
895
|
try {
|
|
851
|
-
for (let i = 0; i < ids.length; i +=
|
|
852
|
-
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
const rows
|
|
856
|
-
.query(
|
|
857
|
-
`SELECT session_id AS sid, data FROM session_message WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant') ORDER BY seq`,
|
|
858
|
-
)
|
|
859
|
-
.all(...batch) ?? []) as { sid: string; data: string }[]
|
|
860
|
-
for (const row of rows) addTranscript(index, row.sid, row.data)
|
|
861
|
-
for (const id of batch) remaining.delete(id)
|
|
862
|
-
indexed += batch.length
|
|
863
|
-
report(false)
|
|
864
|
-
await nextTick()
|
|
896
|
+
for (let i = 0; i < ids.length; i += SIGNATURE_CHUNK) {
|
|
897
|
+
const chunk = ids.slice(i, i + SIGNATURE_CHUNK)
|
|
898
|
+
const rows = (await run(transcriptSignatureQuery(chunk.length), chunk)) as { sid: string; n: number; m: number; bytes: number }[]
|
|
899
|
+
if (!active()) return index
|
|
900
|
+
for (const row of rows) signatures.set(row.sid, { sig: `${row.n}:${row.m}:${row.bytes}`, bytes: row.bytes ?? 0 })
|
|
865
901
|
}
|
|
866
902
|
} catch {
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
db.close()
|
|
903
|
+
if (!active()) return index
|
|
904
|
+
signatures = undefined
|
|
870
905
|
}
|
|
906
|
+
const sigs = signatures && new Map(entries.map((entry) => [entry.id, `${entry.updated}:${signatures!.get(entry.id)?.sig ?? "0:0:0"}`]))
|
|
907
|
+
const stale: string[] = []
|
|
908
|
+
for (const id of ids) {
|
|
909
|
+
const hit = cache.get(id)
|
|
910
|
+
if (hit && sigs && hit.sig === sigs.get(id)) {
|
|
911
|
+
if (hit.text) index.set(id, `${index.get(id) ?? ""} ${hit.text}`.trim())
|
|
912
|
+
remaining.delete(id)
|
|
913
|
+
indexed += 1
|
|
914
|
+
} else stale.push(id)
|
|
915
|
+
}
|
|
916
|
+
if (indexed > 0) report(false)
|
|
917
|
+
|
|
918
|
+
const batches: string[][] = []
|
|
919
|
+
let current: string[] = []
|
|
920
|
+
let currentBytes = 0
|
|
921
|
+
for (const id of stale) {
|
|
922
|
+
const bytes = signatures?.get(id)?.bytes ?? 0
|
|
923
|
+
if (current.length > 0 && (current.length >= TRANSCRIPT_BATCH || currentBytes + bytes > TRANSCRIPT_BATCH_BYTES)) {
|
|
924
|
+
batches.push(current)
|
|
925
|
+
current = []
|
|
926
|
+
currentBytes = 0
|
|
927
|
+
}
|
|
928
|
+
current.push(id)
|
|
929
|
+
currentBytes += bytes
|
|
930
|
+
}
|
|
931
|
+
if (current.length > 0) batches.push(current)
|
|
932
|
+
|
|
933
|
+
for (const batch of batches) {
|
|
934
|
+
if (!active()) break
|
|
935
|
+
const rows = (await run(transcriptTextQuery(batch.length), batch)) as { sid: string; text: string }[]
|
|
936
|
+
if (!active()) break
|
|
937
|
+
const texts = new Map<string, string[]>()
|
|
938
|
+
for (const row of rows) {
|
|
939
|
+
const list = texts.get(row.sid)
|
|
940
|
+
if (list) list.push(row.text)
|
|
941
|
+
else texts.set(row.sid, [row.text])
|
|
942
|
+
}
|
|
943
|
+
for (const id of batch) {
|
|
944
|
+
const list = texts.get(id) ?? []
|
|
945
|
+
if (sigs) cache.set(id, { sig: sigs.get(id)!, text: list.join(" ").trim() })
|
|
946
|
+
if (list.length > 0) index.set(id, [index.get(id) ?? "", ...list].join(" ").trim())
|
|
947
|
+
remaining.delete(id)
|
|
948
|
+
}
|
|
949
|
+
indexed += batch.length
|
|
950
|
+
report(false)
|
|
951
|
+
await nextTick()
|
|
952
|
+
}
|
|
953
|
+
} catch {
|
|
954
|
+
// local store unavailable mid-walk; fall back to the remote path below
|
|
871
955
|
}
|
|
872
956
|
|
|
873
957
|
if (active() && remaining.size > 0) {
|
|
@@ -3347,7 +3431,11 @@ function createApi(ctx: Plugin.Context) {
|
|
|
3347
3431
|
const [dialogOpen, setDialogOpen] = createSignal(false)
|
|
3348
3432
|
let sourceTheme: Plugin.Context["theme"] | undefined
|
|
3349
3433
|
let currentTheme: ReturnType<typeof adaptTheme>
|
|
3434
|
+
const slotDisposers: Array<() => void> = []
|
|
3350
3435
|
return {
|
|
3436
|
+
dispose() {
|
|
3437
|
+
for (const dispose of slotDisposers.splice(0)) dispose()
|
|
3438
|
+
},
|
|
3351
3439
|
theme: {
|
|
3352
3440
|
get current() {
|
|
3353
3441
|
if (sourceTheme !== ctx.theme) {
|
|
@@ -3471,18 +3559,18 @@ function createApi(ctx: Plugin.Context) {
|
|
|
3471
3559
|
slots: {
|
|
3472
3560
|
register(cfg: { order?: number; slots: Record<string, () => any> }) {
|
|
3473
3561
|
if (cfg.slots.sidebar_content && ctx.options?.showSidebar !== false) {
|
|
3474
|
-
ctx.ui.slot({
|
|
3562
|
+
slotDisposers.push(ctx.ui.slot({
|
|
3475
3563
|
...(ctx.options?.sidebarPlacement === "prepend"
|
|
3476
3564
|
? { prepend: "sidebar.content" as const }
|
|
3477
3565
|
: { append: "sidebar.content" as const }),
|
|
3478
3566
|
render: cfg.slots.sidebar_content as any,
|
|
3479
|
-
})
|
|
3567
|
+
}))
|
|
3480
3568
|
}
|
|
3481
3569
|
if (cfg.slots.home_bottom) {
|
|
3482
|
-
ctx.ui.slot({
|
|
3570
|
+
slotDisposers.push(ctx.ui.slot({
|
|
3483
3571
|
prepend: "home.footer",
|
|
3484
3572
|
render: cfg.slots.home_bottom as any,
|
|
3485
|
-
})
|
|
3573
|
+
}))
|
|
3486
3574
|
}
|
|
3487
3575
|
},
|
|
3488
3576
|
},
|
|
@@ -3576,6 +3664,7 @@ function createApi(ctx: Plugin.Context) {
|
|
|
3576
3664
|
export default Plugin.define({
|
|
3577
3665
|
id: "sesh-panel",
|
|
3578
3666
|
async setup(ctx) {
|
|
3667
|
+
const client = dbClient = createDbClient()
|
|
3579
3668
|
const api = createApi(ctx)
|
|
3580
3669
|
let initialized = false
|
|
3581
3670
|
const disposeSlot = ctx.ui.slot({
|
|
@@ -3592,8 +3681,10 @@ export default Plugin.define({
|
|
|
3592
3681
|
return () => {
|
|
3593
3682
|
if (api.ui.dialog.open) api.ui.dialog.clear()
|
|
3594
3683
|
disposeSlot()
|
|
3684
|
+
api.dispose()
|
|
3595
3685
|
removeRootMouse?.()
|
|
3596
|
-
|
|
3686
|
+
clearTranscriptCache()
|
|
3687
|
+
client.close()
|
|
3597
3688
|
}
|
|
3598
3689
|
},
|
|
3599
3690
|
})
|