opencode-sesh 2.1.5 → 2.1.6

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
@@ -198,7 +198,10 @@ when opencode exits, the picker reopens with the query preserved.
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
200
  (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
201
- The header shows indexing progress/coverage. Visible transcript hits get
201
+ Local extraction is one SQL statement per batch in the DB worker
202
+ (`transcriptTextQuery`, JSON-guarded) that returns only `{sid, text}` rows,
203
+ so tool payloads never reach the UI thread; if the worker rejects, sessions
204
+ not yet indexed fall back to the remote path. The header shows indexing progress/coverage. Visible transcript hits get
202
205
  short excerpts from the text-only index (never tool or reasoning payloads).
203
206
  Project, needs-input (Option-W), and pinned-session (Option-S) filters compose
204
207
  with the query; the picker remembers query/scope/filters until plugin restart.
@@ -273,7 +276,8 @@ when opencode exits, the picker reopens with the query preserved.
273
276
  (also run by `bun run test`).
274
277
  - `bun run test:tui-worker` — starts the real blob Worker against a bun:sqlite
275
278
  fixture; asserts results round-trip and the main thread keeps ticking during
276
- a slow query (also run by `bun run test`).
279
+ a slow query and during transcript indexing of large tool payloads (also run
280
+ by `bun run test`).
277
281
  - `bun run typecheck` — `tsc` over `tui-plugins/` and `plugins/`.
278
282
  - `bun run lint:sh` — `bash -n` on every script.
279
283
  - `HOME=/tmp/fakehome bash install.sh` — installer smoke test.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ 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.6] - 2026-11-02
9
+
10
+ ### Changed
11
+
12
+ - **tui:** Extract transcript text in the DB worker (#35)
13
+
8
14
  ## [2.1.5] - 2026-11-02
9
15
 
10
16
  ### Fixed
@@ -542,6 +548,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
542
548
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
543
549
  CI.
544
550
 
551
+ [2.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.5...v2.1.6
545
552
  [2.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.4...v2.1.5
546
553
  [2.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.3...v2.1.4
547
554
  [2.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.1.2...v2.1.3
package/dist/tui.js CHANGED
@@ -920,29 +920,39 @@ GROUP BY live.session_id`, batch);
920
920
  }
921
921
  return details;
922
922
  }
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
- }
923
+
924
+ // Text parts only, extracted inside SQLite so tool and reasoning payloads never
925
+ // reach the caller. The CASE guards keep json_extract away from malformed rows
926
+ // and non-object parts, which SQL AND ordering alone would not.
927
+ function transcriptTextQuery(count) {
928
+ const placeholders = Array.from({
929
+ length: count
930
+ }, () => "?").join(",");
931
+ return `WITH msg AS (
932
+ SELECT session_id AS sid, seq, rowid AS rid, CASE WHEN json_valid(data) THEN data END AS doc
933
+ FROM session_message
934
+ WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
935
+ ), parts AS (
936
+ SELECT sid, seq, rid, 0 AS part,
937
+ CASE WHEN json_type(doc, '$.text') = 'text' THEN json_extract(doc, '$.text') END AS text
938
+ FROM msg
939
+ UNION ALL
940
+ SELECT msg.sid, msg.seq, msg.rid, 1 + item.key AS part,
941
+ CASE WHEN item.type = 'object' THEN
942
+ CASE WHEN json_extract(item.value, '$.type') = 'text' AND json_type(item.value, '$.text') = 'text'
943
+ THEN json_extract(item.value, '$.text') END
944
+ END AS text
945
+ FROM msg, json_each(msg.doc, '$.content') AS item
946
+ WHERE json_type(msg.doc, '$.content') = 'array'
947
+ )
948
+ SELECT sid, text FROM parts WHERE text IS NOT NULL AND text <> '' ORDER BY sid, seq, rid, part`;
940
949
  }
941
950
 
942
951
  // Index titles/directories immediately, then add transcript text progressively
943
952
  // 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) {
953
+ // newest window; `onProgress` lets the picker show coverage. Local extraction
954
+ // runs through `run` (the worker client) and returns text only.
955
+ async function buildSearchIndex(api, entries, onProgress, active = () => true, run = dbClient.run) {
946
956
  const index = new Map();
947
957
  for (const entry of entries) {
948
958
  index.set(entry.id, `${entry.title} ${entry.dir}`);
@@ -960,25 +970,25 @@ async function buildSearchIndex(api, entries, onProgress, active = () => true) {
960
970
  };
961
971
  report(false);
962
972
  if (!active()) return index;
963
- const db = await openTranscriptDb();
964
- if (db) {
965
- 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();
973
+ try {
974
+ for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
975
+ if (!active()) break;
976
+ const batch = ids.slice(i, i + TRANSCRIPT_BATCH);
977
+ const rows = await run(transcriptTextQuery(batch.length), batch);
978
+ if (!active()) break;
979
+ const texts = new Map();
980
+ for (const row of rows) {
981
+ const list = texts.get(row.sid);
982
+ if (list) list.push(row.text);else texts.set(row.sid, [row.text]);
976
983
  }
977
- } catch {
978
- // local store unavailable mid-walk; fall back to the remote path below
979
- } finally {
980
- db.close();
984
+ for (const [sid, list] of texts) index.set(sid, [index.get(sid) ?? "", ...list].join(" ").trim());
985
+ for (const id of batch) remaining.delete(id);
986
+ indexed += batch.length;
987
+ report(false);
988
+ await nextTick();
981
989
  }
990
+ } catch {
991
+ // local store unavailable mid-walk; fall back to the remote path below
982
992
  }
983
993
  if (active() && remaining.size > 0) {
984
994
  await buildSearchIndexRemote(api, [...remaining], index, () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "2.1.5",
3
+ "version": "2.1.6",
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",
@@ -805,33 +805,41 @@ GROUP BY live.session_id`, batch)) as { id: string; question_since: number | nul
805
805
  return details
806
806
  }
807
807
 
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
- }
808
+ // Text parts only, extracted inside SQLite so tool and reasoning payloads never
809
+ // reach the caller. The CASE guards keep json_extract away from malformed rows
810
+ // and non-object parts, which SQL AND ordering alone would not.
811
+ function transcriptTextQuery(count: number): string {
812
+ const placeholders = Array.from({ length: count }, () => "?").join(",")
813
+ return `WITH msg AS (
814
+ SELECT session_id AS sid, seq, rowid AS rid, CASE WHEN json_valid(data) THEN data END AS doc
815
+ FROM session_message
816
+ WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant')
817
+ ), parts AS (
818
+ SELECT sid, seq, rid, 0 AS part,
819
+ CASE WHEN json_type(doc, '$.text') = 'text' THEN json_extract(doc, '$.text') END AS text
820
+ FROM msg
821
+ UNION ALL
822
+ SELECT msg.sid, msg.seq, msg.rid, 1 + item.key AS part,
823
+ CASE WHEN item.type = 'object' THEN
824
+ CASE WHEN json_extract(item.value, '$.type') = 'text' AND json_type(item.value, '$.text') = 'text'
825
+ THEN json_extract(item.value, '$.text') END
826
+ END AS text
827
+ FROM msg, json_each(msg.doc, '$.content') AS item
828
+ WHERE json_type(msg.doc, '$.content') = 'array'
829
+ )
830
+ SELECT sid, text FROM parts WHERE text IS NOT NULL AND text <> '' ORDER BY sid, seq, rid, part`
825
831
  }
826
832
 
827
833
  // Index titles/directories immediately, then add transcript text progressively
828
834
  // in batches so the render thread can breathe. Covers every entry, not a fixed
829
- // newest window; `onProgress` lets the picker show coverage.
835
+ // newest window; `onProgress` lets the picker show coverage. Local extraction
836
+ // runs through `run` (the worker client) and returns text only.
830
837
  async function buildSearchIndex(
831
838
  api: TuiPluginApi,
832
839
  entries: Entry[],
833
840
  onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
834
841
  active = () => true,
842
+ run: DbRun = dbClient.run,
835
843
  ): Promise<Map<string, string>> {
836
844
  const index = new Map<string, string>()
837
845
  for (const entry of entries) {
@@ -845,29 +853,26 @@ async function buildSearchIndex(
845
853
  report(false)
846
854
 
847
855
  if (!active()) return index
848
- const db = await openTranscriptDb()
849
- if (db) {
850
- 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()
856
+ try {
857
+ for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
858
+ if (!active()) break
859
+ const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
860
+ const rows = (await run(transcriptTextQuery(batch.length), batch)) as { sid: string; text: string }[]
861
+ if (!active()) break
862
+ const texts = new Map<string, string[]>()
863
+ for (const row of rows) {
864
+ const list = texts.get(row.sid)
865
+ if (list) list.push(row.text)
866
+ else texts.set(row.sid, [row.text])
865
867
  }
866
- } catch {
867
- // local store unavailable mid-walk; fall back to the remote path below
868
- } finally {
869
- db.close()
868
+ for (const [sid, list] of texts) index.set(sid, [index.get(sid) ?? "", ...list].join(" ").trim())
869
+ for (const id of batch) remaining.delete(id)
870
+ indexed += batch.length
871
+ report(false)
872
+ await nextTick()
870
873
  }
874
+ } catch {
875
+ // local store unavailable mid-walk; fall back to the remote path below
871
876
  }
872
877
 
873
878
  if (active() && remaining.size > 0) {