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 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`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
201
- The header shows indexing progress/coverage. Visible transcript hits get
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 (also run by `bun run test`).
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: () => reset(new Error("local store closed"))
740
+ close: () => {
741
+ closed = true;
742
+ reset(new Error("local store closed"));
743
+ }
733
744
  };
734
745
  }
735
- const dbClient = createDbClient();
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
- function addTranscript(index, sid, data) {
924
- try {
925
- const message = JSON.parse(data);
926
- const texts = [];
927
- if (typeof message?.text === "string") texts.push(message.text);
928
- if (Array.isArray(message?.content)) {
929
- for (const part of message.content) {
930
- if (part?.type === "text" && typeof part.text === "string") texts.push(part.text);
931
- }
932
- }
933
- for (const text of texts) {
934
- const base = index.get(sid) ?? "";
935
- index.set(sid, `${base} ${text}`.trim());
936
- }
937
- } catch {
938
- // ignore malformed messages
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
- async function buildSearchIndex(api, entries, onProgress, active = () => true) {
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
- const db = await openTranscriptDb();
964
- if (db) {
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 += TRANSCRIPT_BATCH) {
967
- if (!active()) break;
968
- const batch = ids.slice(i, i + TRANSCRIPT_BATCH);
969
- const placeholders = batch.map(() => "?").join(",");
970
- const rows = db.query(`SELECT session_id AS sid, data FROM session_message WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant') ORDER BY seq`).all(...batch) ?? [];
971
- for (const row of rows) addTranscript(index, row.sid, row.data);
972
- for (const id of batch) remaining.delete(id);
973
- indexed += batch.length;
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
- // local store unavailable mid-walk; fall back to the remote path below
979
- } finally {
980
- db.close();
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
- dbClient.close();
4682
+ clearTranscriptCache();
4683
+ client.close();
4579
4684
  };
4580
4685
  }
4581
4686
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "2.1.5",
3
+ "version": "2.1.7",
4
4
  "description": "OpenCode session browser: search, preview, and resume every session across every project from the terminal or TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- const dbClient = createDbClient()
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
- function addTranscript(index: Map<string, string>, sid: string, data: string): void {
809
- try {
810
- const message = JSON.parse(data) as { text?: unknown; content?: unknown }
811
- const texts: string[] = []
812
- if (typeof message?.text === "string") texts.push(message.text)
813
- if (Array.isArray(message?.content)) {
814
- for (const part of message.content as { type?: unknown; text?: unknown }[]) {
815
- if (part?.type === "text" && typeof part.text === "string") texts.push(part.text)
816
- }
817
- }
818
- for (const text of texts) {
819
- const base = index.get(sid) ?? ""
820
- index.set(sid, `${base} ${text}`.trim())
821
- }
822
- } catch {
823
- // ignore malformed messages
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
- const db = await openTranscriptDb()
849
- if (db) {
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 += TRANSCRIPT_BATCH) {
852
- if (!active()) break
853
- const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
854
- const placeholders = batch.map(() => "?").join(",")
855
- const rows = (db
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
- // local store unavailable mid-walk; fall back to the remote path below
868
- } finally {
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
- dbClient.close()
3686
+ clearTranscriptCache()
3687
+ client.close()
3597
3688
  }
3598
3689
  },
3599
3690
  })