opencode-sesh 0.1.4 → 0.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 +12 -4
- package/CHANGELOG.md +48 -1
- package/README.md +5 -0
- package/bin/sesh-delete.sh +1 -1
- package/bin/sesh-list.sh +72 -32
- package/bin/sesh-preview.sh +16 -11
- package/install.sh +29 -1
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +156 -58
package/AGENTS.md
CHANGED
|
@@ -50,9 +50,12 @@ the schema below. Keystrokes never touch the database:
|
|
|
50
50
|
|
|
51
51
|
- `--refresh` (expensive): bulk-reads session metadata and per-session text
|
|
52
52
|
parts, validates the persistent extraction cache
|
|
53
|
-
(`$SESH_CACHE_DIR/extractions/<ses_*.json
|
|
54
|
-
count, re-extracts only changed sessions,
|
|
55
|
-
publishes `STATE_DIR/snapshot.jsonl
|
|
53
|
+
(`$SESH_CACHE_DIR/extractions/<ses_*.json>`, schema 2) against `time_updated`,
|
|
54
|
+
part count and the latest part timestamp, re-extracts only changed sessions,
|
|
55
|
+
prunes vanished ones, and atomically publishes `STATE_DIR/snapshot.jsonl`.
|
|
56
|
+
Extraction moves transcripts through files, never argv (no ARG_MAX limit); if
|
|
57
|
+
any session fails, the published status is `stale` and that session stays
|
|
58
|
+
uncached so the next refresh retries, otherwise `status = fresh`. Scope
|
|
56
59
|
(`--cwd`/toggle) and `--limit` apply at assembly, so metadata always covers
|
|
57
60
|
the whole DB and pruning is always safe.
|
|
58
61
|
- render (cheap, pure `jq`): reads the snapshot only. Filters on `searchText`
|
|
@@ -78,7 +81,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
78
81
|
- **Text parts only** in search/preview; reasoning and tool payloads are
|
|
79
82
|
excluded (asserted in tests).
|
|
80
83
|
- Validate session ids (`^ses_[A-Za-z0-9]+$`) before SQL interpolation — never
|
|
81
|
-
interpolate unvalidated input.
|
|
84
|
+
interpolate unvalidated input. Open SQLite read-only (`sqlite3 -readonly`).
|
|
82
85
|
- Prefer direct `sqlite3` over `opencode db` (startup latency). Never parse
|
|
83
86
|
`sqlite3 -json` in `-R` mode; it pretty-prints — parse as JSON.
|
|
84
87
|
- `jq -R` output stays JSON-quoted: use `-Rr` when a shell variable needs the
|
|
@@ -105,6 +108,11 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
105
108
|
`api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
|
|
106
109
|
Ctrl-X deletes via the opencode CLI and removes the row only after the server
|
|
107
110
|
confirms the deletion.
|
|
111
|
+
- **Picker search:** pages the global session list (`SESSION_PAGE_LIMIT`, capped
|
|
112
|
+
at `SESSION_MAX` and reported when truncated) instead of a fixed 500-row
|
|
113
|
+
window, then indexes transcript text for **every** session in batches
|
|
114
|
+
(`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
|
|
115
|
+
The header shows indexing progress/coverage. `tests/tui.mjs` locks this.
|
|
108
116
|
|
|
109
117
|
### Hard-won rules — do not regress
|
|
110
118
|
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.6] - 2026-09-13
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The TUI picker pages through the entire global session list instead of a
|
|
15
|
+
fixed 500-session window, indexes transcript text for every session (not just
|
|
16
|
+
the newest 150) in batches, bounds remote transcript fetching, and shows
|
|
17
|
+
indexing progress/coverage in the search header.
|
|
18
|
+
- Transcript extraction reads parts in batches and assembles all records in a
|
|
19
|
+
single `jq` pass. A 250-session cold index dropped from about 28 s to about
|
|
20
|
+
7 s locally (warm ~0.3 s).
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- TUI data-layer contract tests (pagination, full index coverage, bounded
|
|
25
|
+
concurrency, progress, and the truncated cap), run on Node 24 in CI.
|
|
26
|
+
|
|
27
|
+
## [0.1.5] - 2026-09-13
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Transcript indexing no longer puts the whole conversation on the command line,
|
|
32
|
+
so large sessions (over a megabyte of text) are indexed instead of silently
|
|
33
|
+
dropped. A failed extraction now reports the snapshot as `stale` and retries
|
|
34
|
+
on the next refresh rather than certifying an incomplete index as fresh.
|
|
35
|
+
- Transcript preview renders text parts even when newer tool parts outnumber
|
|
36
|
+
them, and a long message no longer blanks the preview when the output pipe
|
|
37
|
+
closes early.
|
|
38
|
+
- Warm extraction caches are invalidated on any part change (text or timestamp),
|
|
39
|
+
not only when the session timestamp or part count moves.
|
|
40
|
+
- The installer refuses to replace a symlinked `sesh` launcher that is not a
|
|
41
|
+
sesh install, and uninstall leaves foreign launcher symlinks untouched.
|
|
42
|
+
|
|
43
|
+
### Security
|
|
44
|
+
|
|
45
|
+
- Session ids are validated with the anchored `^ses_[A-Za-z0-9]+$` pattern at
|
|
46
|
+
the preview and deletion boundaries before any SQL, and the local SQLite store
|
|
47
|
+
is opened read-only.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- Regression coverage for large transcripts, part-only cache invalidation,
|
|
52
|
+
preview text/tool ordering and long previews, malformed session ids, empty
|
|
53
|
+
stores, and installer launcher ownership.
|
|
54
|
+
|
|
10
55
|
## [0.1.4] - 2026-09-13
|
|
11
56
|
|
|
12
57
|
### Fixed
|
|
@@ -74,7 +119,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
74
119
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
75
120
|
CI.
|
|
76
121
|
|
|
77
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.
|
|
122
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...HEAD
|
|
123
|
+
[0.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...v0.1.6
|
|
124
|
+
[0.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...v0.1.5
|
|
78
125
|
[0.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.3...v0.1.4
|
|
79
126
|
[0.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.2...v0.1.3
|
|
80
127
|
[0.1.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.1...v0.1.2
|
package/README.md
CHANGED
|
@@ -137,6 +137,11 @@ read -r id cwd < <(sesh --print --query "auth")
|
|
|
137
137
|
Click the sidebar's `search…` box to filter recent sessions in place; click
|
|
138
138
|
elsewhere or press Esc to leave search.
|
|
139
139
|
|
|
140
|
+
The full picker pages through your whole global session list (no fixed window)
|
|
141
|
+
and indexes transcript text for every session in the background, showing
|
|
142
|
+
indexing progress under the search box. It only stops at a safety cap of 5,000
|
|
143
|
+
sessions, and says so when it does.
|
|
144
|
+
|
|
140
145
|
## Configuration
|
|
141
146
|
|
|
142
147
|
All variables are optional.
|
package/bin/sesh-delete.sh
CHANGED
|
@@ -37,7 +37,7 @@ acquire_lock() {
|
|
|
37
37
|
# fzf passes an empty second field for headers and notices. They are never
|
|
38
38
|
# actions, and should not surface a misleading failure prompt.
|
|
39
39
|
[ -n "$session_id" ] || exit 0
|
|
40
|
-
|
|
40
|
+
[[ "$session_id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Invalid session identity."; pause; exit 0; }
|
|
41
41
|
[ -n "$state_dir" ] && [ -f "$snapshot" ] || { echo "No session snapshot available."; pause; exit 0; }
|
|
42
42
|
|
|
43
43
|
# Serialize this action with the sole snapshot publisher. A short collision
|
package/bin/sesh-list.sh
CHANGED
|
@@ -95,7 +95,9 @@ fi
|
|
|
95
95
|
# would make every preview keystroke laggy.
|
|
96
96
|
db_json() {
|
|
97
97
|
if [ -n "$SQLITE_BIN" ]; then
|
|
98
|
-
|
|
98
|
+
# Read-only: sesh never writes the store directly (deletes go through
|
|
99
|
+
# `opencode session delete`).
|
|
100
|
+
"$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"
|
|
99
101
|
else
|
|
100
102
|
"$OPENCODE_BIN" db "$1" --format json
|
|
101
103
|
fi
|
|
@@ -243,11 +245,11 @@ refresh() {
|
|
|
243
245
|
| ($parts[0] | map({key:.session_id, value:.}) | from_entries) as $stats
|
|
244
246
|
| [$sessions[0][] | . + {partCount: ($stats[.id].n // 0), partMax: ($stats[.id].m // 0)}] as $meta
|
|
245
247
|
| ($meta | map({key:.id, value:.}) | from_entries) as $byId
|
|
246
|
-
| [$caches[0][]? | select(.cacheSchema ==
|
|
247
|
-
| select($byId[.sessionId] as $m | $m != null and .cacheUpdated == $m.time_updated and .cacheParts == $m.partCount)] as $cached
|
|
248
|
-
| ($cached | map({key
|
|
248
|
+
| [$caches[0][]? | select(.cacheSchema == 2 and (.sessionId | type) == "string")
|
|
249
|
+
| select($byId[.sessionId] as $m | $m != null and .cacheUpdated == $m.time_updated and .cacheParts == $m.partCount and .cacheMax == $m.partMax)] as $cached
|
|
250
|
+
| ($cached | map({key: .sessionId, value:true}) | from_entries) as $cachedIds
|
|
249
251
|
| {cached: $cached,
|
|
250
|
-
stale: [$meta[] | select($cachedIds[.id] != true) | {id, directory, title, time_created, time_updated, archived, partCount}],
|
|
252
|
+
stale: [$meta[] | select($cachedIds[.id] != true) | {id, directory, title, time_created, time_updated, archived, partCount, partMax}],
|
|
251
253
|
prune: [$caches[0][]? | select(.sessionId? as $s | $byId[$s] == null) | .cachePath],
|
|
252
254
|
meta: $meta}
|
|
253
255
|
' "$cache_records" > "$cache_state" || {
|
|
@@ -258,45 +260,77 @@ refresh() {
|
|
|
258
260
|
"$JQ_BIN" -c '.stale[]?' "$cache_state" > "$needs_extract"
|
|
259
261
|
while IFS= read -r cache; do [ -n "$cache" ] && rm -f "$cache"; done < <("$JQ_BIN" -r '.prune[]?' "$cache_state")
|
|
260
262
|
|
|
261
|
-
local sid
|
|
263
|
+
local stale_ids=() sid chunk j i
|
|
262
264
|
while IFS= read -r row; do
|
|
263
265
|
[ -n "$row" ] || continue
|
|
264
266
|
sid=$(printf '%s' "$row" | "$JQ_BIN" -r '.id')
|
|
265
267
|
valid_session_id "$sid" || continue
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
268
|
+
stale_ids+=("$sid")
|
|
269
|
+
done < "$needs_extract"
|
|
270
|
+
|
|
271
|
+
# Batch the transcript read: one SQL query per chunk of sessions and a single
|
|
272
|
+
# jq pass to assemble every record, instead of a query plus three jq processes
|
|
273
|
+
# per session. Text still travels through files, never argv. Any failure leaves
|
|
274
|
+
# every stale session uncached, so the next refresh retries them.
|
|
275
|
+
local extract_failed=0 parts_raw="$scratch/parts.jsonl" records="$scratch/records.jsonl"
|
|
276
|
+
: > "$parts_raw"
|
|
277
|
+
j=0
|
|
278
|
+
while [ "$j" -lt "${#stale_ids[@]}" ]; do
|
|
279
|
+
chunk=''
|
|
280
|
+
for ((i = j; i < j + 200 && i < ${#stale_ids[@]}; i++)); do
|
|
281
|
+
chunk="${chunk:+$chunk,}'${stale_ids[$i]}'"
|
|
282
|
+
done
|
|
283
|
+
if ! db_json "SELECT session_id, data FROM part WHERE session_id IN ($chunk) AND json_extract(data, '\$.type') = 'text' ORDER BY session_id, time_created;" 2>/dev/null \
|
|
284
|
+
| "$JQ_BIN" -c '.[]?' >> "$parts_raw"; then
|
|
285
|
+
extract_failed=1
|
|
286
|
+
break
|
|
280
287
|
fi
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
j=$((j + 200))
|
|
289
|
+
done
|
|
290
|
+
|
|
291
|
+
if [ "$extract_failed" = 0 ]; then
|
|
292
|
+
"$JQ_BIN" -cnr --arg extractions "$EXTRACTIONS" --slurpfile stale "$needs_extract" --slurpfile parts "$parts_raw" '
|
|
293
|
+
($parts
|
|
294
|
+
| map({sid: .session_id,
|
|
295
|
+
text: ((.data | try fromjson catch null)
|
|
296
|
+
| if type == "object" and .type == "text" and (.text | type) == "string" then .text else empty end)})
|
|
297
|
+
| group_by(.sid)
|
|
298
|
+
| map({key: .[0].sid, value: ([.[].text] | join(" "))})
|
|
299
|
+
| from_entries) as $bySid
|
|
300
|
+
| $stale[]
|
|
301
|
+
| select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
|
|
302
|
+
| . as $s
|
|
303
|
+
| ($bySid[$s.id] // "") as $text
|
|
304
|
+
| (if ($s.title // "") == ""
|
|
305
|
+
then (($text | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end) | select(length > 0) // "(untitled)")
|
|
306
|
+
else $s.title end) as $title
|
|
307
|
+
| {sessionId: $s.id,
|
|
308
|
+
cachePath: ($extractions + "/" + $s.id + ".json"),
|
|
309
|
+
record: {cacheSchema: 2, cacheSession: $s.id,
|
|
310
|
+
cacheUpdated: ($s.time_updated // 0), cacheParts: ($s.partCount // 0), cacheMax: ($s.partMax // 0),
|
|
311
|
+
sessionId: $s.id, transcriptPath: "", source: "opencode",
|
|
312
|
+
cwd: ($s.directory // ""),
|
|
313
|
+
title: ($title | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end),
|
|
314
|
+
updatedEpoch: ((($s.time_updated // 0) / 1000 | floor) | if . > 0 then . else ((($s.time_created // 0) / 1000) | floor) end),
|
|
315
|
+
fulltextLower: (($title + " " + $text) | ascii_downcase)}}
|
|
316
|
+
| "\(.sessionId)\n\(.cachePath)\n\(.record | tojson)"' > "$records" || extract_failed=1
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
if [ "$extract_failed" = 0 ]; then
|
|
320
|
+
while IFS= read -r sid && IFS= read -r cache_path && IFS= read -r record; do
|
|
321
|
+
[ -n "$sid" ] || continue
|
|
289
322
|
cache_tmp=$(mktemp "$EXTRACTIONS/$sid.json.XXXXXX")
|
|
290
323
|
printf '%s\n' "$record" > "$cache_tmp"
|
|
291
|
-
mv "$cache_tmp" "$
|
|
324
|
+
mv "$cache_tmp" "$cache_path"
|
|
292
325
|
printf '%s\n' "$record" >> "$historical"
|
|
293
|
-
|
|
294
|
-
|
|
326
|
+
done < "$records"
|
|
327
|
+
fi
|
|
295
328
|
|
|
296
329
|
if ! "$JQ_BIN" -c --slurpfile hist "$historical" --arg scope "$SCOPE_CWD" --argjson limit "$LIMIT" --argjson archived "$INCLUDE_ARCHIVED" '
|
|
297
330
|
.meta as $meta
|
|
298
331
|
| ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
|
|
299
332
|
| [$meta[]
|
|
333
|
+
| select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
|
|
300
334
|
| select($archived == 1 or .archived == 0)
|
|
301
335
|
| select($scope == "" or .directory == $scope)
|
|
302
336
|
| . as $m | ($histById[$m.id] // {}) as $h
|
|
@@ -313,7 +347,13 @@ refresh() {
|
|
|
313
347
|
return 1
|
|
314
348
|
fi
|
|
315
349
|
mv -f "$out" "$SNAPSHOT"
|
|
316
|
-
|
|
350
|
+
if [ "$extract_failed" = 1 ]; then
|
|
351
|
+
# Publish what we have, but say the index is incomplete and keep the
|
|
352
|
+
# failed sessions uncached so the next refresh retries them.
|
|
353
|
+
set_status 'stale: transcript extraction incomplete; refresh to retry' || true
|
|
354
|
+
else
|
|
355
|
+
set_status fresh || true
|
|
356
|
+
fi
|
|
317
357
|
}
|
|
318
358
|
|
|
319
359
|
if [ "$REFRESH" = 1 ] || [ "$TOGGLE_SCOPE" = 1 ]; then refresh; fi
|
package/bin/sesh-preview.sh
CHANGED
|
@@ -10,7 +10,7 @@ JQ_BIN=${SESH_JQ:-jq}
|
|
|
10
10
|
|
|
11
11
|
[ -n "$state_dir" ] && [ -n "$session_id" ] || { echo "(no session selected)"; exit 0; }
|
|
12
12
|
[ -f "$snapshot" ] || { echo "(no session snapshot available)"; exit 0; }
|
|
13
|
-
|
|
13
|
+
[[ "$session_id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "(no transcript for this row)"; exit 0; }
|
|
14
14
|
"$JQ_BIN" -se --arg sid "$session_id" 'any(.sessionId == $sid)' "$snapshot" >/dev/null 2>&1 \
|
|
15
15
|
|| { echo "(selected session is no longer in this snapshot)"; exit 0; }
|
|
16
16
|
|
|
@@ -26,26 +26,31 @@ fi
|
|
|
26
26
|
|
|
27
27
|
SQLITE_BIN=${SESH_SQLITE:-}
|
|
28
28
|
if [ -z "$SQLITE_BIN" ] && command -v sqlite3 >/dev/null 2>&1; then SQLITE_BIN=sqlite3; fi
|
|
29
|
-
SQL="SELECT m.data AS msg, p.data AS part FROM part p JOIN message m ON m.id = p.message_id WHERE p.session_id = '$session_id' ORDER BY p.time_created DESC LIMIT 200;"
|
|
29
|
+
SQL="SELECT m.data AS msg, p.data AS part FROM part p JOIN message m ON m.id = p.message_id WHERE p.session_id = '$session_id' AND json_extract(p.data, '\$.type') = 'text' ORDER BY p.time_created DESC LIMIT 200;"
|
|
30
30
|
if [ -n "$SQLITE_BIN" ]; then
|
|
31
|
-
rows=$("$SQLITE_BIN" -json "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
|
|
31
|
+
rows=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
|
|
32
32
|
else
|
|
33
33
|
OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
34
34
|
rows=$("$OPENCODE_BIN" db "$SQL" --format json 2>/dev/null) || rows=''
|
|
35
35
|
fi
|
|
36
36
|
[ -n "$rows" ] || { echo "(no displayable transcript messages)"; exit 0; }
|
|
37
37
|
|
|
38
|
-
# Text parts only, newest first, with role headers.
|
|
39
|
-
#
|
|
38
|
+
# Text parts only, newest first, with role headers. The 120-block cap is applied
|
|
39
|
+
# inside jq so nothing closes the pipe early (a `head` here would trip pipefail
|
|
40
|
+
# with SIGPIPE and blank the preview). Control bytes are stripped with tr
|
|
41
|
+
# (byte-safe for UTF-8: only 0x00-0x1F and 0x7F are removed).
|
|
40
42
|
rendered=$(printf '%s' "$rows" | "$JQ_BIN" -r '
|
|
41
43
|
(if type == "array" then . else [] end)
|
|
44
|
+
| map(
|
|
45
|
+
((.msg | try fromjson catch {}) | .role? // "") as $role
|
|
46
|
+
| (.part | try fromjson catch {}) as $p
|
|
47
|
+
| select(($p.type? // "") == "text")
|
|
48
|
+
| ($p.text? // "") | select(type == "string" and length > 0)
|
|
49
|
+
| (if $role == "user" then "# You" elif $role == "assistant" then "# Opencode" else "# Message" end) + "\n\n" + .
|
|
50
|
+
)
|
|
51
|
+
| .[0:120]
|
|
42
52
|
| .[]
|
|
43
|
-
|
|
44
|
-
| (.part | try fromjson catch {}) as $p
|
|
45
|
-
| select(($p.type? // "") == "text")
|
|
46
|
-
| ($p.text? // "") | select(type == "string" and length > 0)
|
|
47
|
-
| (if $role == "user" then "# You" elif $role == "assistant" then "# Opencode" else "# Message" end) + "\n\n" + .
|
|
48
|
-
' 2>/dev/null | tr -d '\000-\010\013\014\016-\037\177' | head -120) || rendered=''
|
|
53
|
+
' 2>/dev/null | tr -d '\000-\010\013\014\016-\037\177') || rendered=''
|
|
49
54
|
[ -n "$rendered" ] || { echo "(no displayable transcript messages)"; exit 0; }
|
|
50
55
|
|
|
51
56
|
resolve_glow() {
|
package/install.sh
CHANGED
|
@@ -91,8 +91,33 @@ remove_tui_entry() {
|
|
|
91
91
|
fi
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
# The launcher is a symlink we create. Treat it as ours only when it resolves to
|
|
95
|
+
# a sesh launcher, so a foreign symlink is never replaced on install nor deleted
|
|
96
|
+
# on uninstall. Recognized by the exact source path, a `.../bin/sesh` target, or
|
|
97
|
+
# the marker in the target file (covers npm upgrades and a moved checkout).
|
|
98
|
+
launcher_is_ours() {
|
|
99
|
+
local link=$1 target
|
|
100
|
+
[ -L "$link" ] || return 1
|
|
101
|
+
target=$(readlink "$link" 2>/dev/null) || return 1
|
|
102
|
+
[ -n "$target" ] || return 1
|
|
103
|
+
case "$target" in
|
|
104
|
+
/*) ;;
|
|
105
|
+
*) target="$(cd "$(dirname "$link")" && pwd -P)/$target" ;;
|
|
106
|
+
esac
|
|
107
|
+
case "$target" in
|
|
108
|
+
"$SOURCE_DIR/bin/sesh"|*/bin/sesh) return 0 ;;
|
|
109
|
+
esac
|
|
110
|
+
[ -f "$target" ] && grep -qF 'Pick an opencode session' "$target" 2>/dev/null
|
|
111
|
+
}
|
|
112
|
+
|
|
94
113
|
if [ "$UNINSTALL" = 1 ]; then
|
|
95
|
-
[ -L "$LAUNCHER" ]
|
|
114
|
+
if [ -L "$LAUNCHER" ]; then
|
|
115
|
+
if launcher_is_ours "$LAUNCHER"; then
|
|
116
|
+
rm -f "$LAUNCHER" && note "removed $LAUNCHER"
|
|
117
|
+
else
|
|
118
|
+
note "left $LAUNCHER untouched (not a sesh launcher: $(readlink "$LAUNCHER" 2>/dev/null))"
|
|
119
|
+
fi
|
|
120
|
+
fi
|
|
96
121
|
remove_if_marker "$COMMAND_DST" "rich interactive picker" && note "removed $COMMAND_DST"
|
|
97
122
|
remove_if_marker "$TOOL_DST" "list opencode sessions for the agent" && note "removed $TOOL_DST"
|
|
98
123
|
remove_if_marker "$PANEL_DST" "@jsxImportSource @opentui/solid" && note "removed $PANEL_DST"
|
|
@@ -135,6 +160,9 @@ mkdir -p "$BIN_DIR"
|
|
|
135
160
|
if [ -e "$LAUNCHER" ] && [ ! -L "$LAUNCHER" ]; then
|
|
136
161
|
fail "$LAUNCHER already exists and is not a symlink. Move it aside, then retry."
|
|
137
162
|
fi
|
|
163
|
+
if [ -L "$LAUNCHER" ] && ! launcher_is_ours "$LAUNCHER"; then
|
|
164
|
+
fail "$LAUNCHER is a symlink to $(readlink "$LAUNCHER" 2>/dev/null), which is not a sesh install. Move it aside, then retry."
|
|
165
|
+
fi
|
|
138
166
|
ln -sfn "$SOURCE_DIR/bin/sesh" "$LAUNCHER"
|
|
139
167
|
note "linked $LAUNCHER -> $SOURCE_DIR/bin/sesh"
|
|
140
168
|
|
package/package.json
CHANGED
package/tui/sesh-panel.tsx
CHANGED
|
@@ -159,20 +159,47 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
type Entry = { id: string; title: string; dir: string; group: string; updated: number }
|
|
162
|
+
type EntryResult = { entries: Entry[]; truncated: boolean }
|
|
162
163
|
|
|
163
|
-
|
|
164
|
+
const SESSION_PAGE_LIMIT = 200
|
|
165
|
+
const SESSION_MAX = 5000
|
|
166
|
+
|
|
167
|
+
async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
|
|
164
168
|
const home = process.env.HOME ?? ""
|
|
165
|
-
const
|
|
166
|
-
api.client.experimental.session.list({ limit: 500, roots: true, directory: "" }),
|
|
167
|
-
api.client.project.list({}),
|
|
168
|
-
])
|
|
169
|
-
const sessions = ((sessionResult?.data ?? sessionResult) as Session[]) ?? []
|
|
169
|
+
const projectResult = await api.client.project.list({})
|
|
170
170
|
const projects = ((projectResult?.data ?? projectResult) as Project[]) ?? []
|
|
171
|
-
if (!Array.isArray(sessions)) return []
|
|
172
171
|
const projectName = new Map<string, string>(
|
|
173
172
|
projects.map((p) => [p.id, p.name?.trim() || shortDir(p.worktree ?? "", home)]),
|
|
174
173
|
)
|
|
175
|
-
|
|
174
|
+
const sessions: Session[] = []
|
|
175
|
+
let cursor: number | undefined
|
|
176
|
+
let truncated = false
|
|
177
|
+
// Page through the global list instead of one fixed window, so sessions in
|
|
178
|
+
// older directories stay searchable. `cursor` is the previous page's oldest
|
|
179
|
+
// `time.updated`; a repeated or missing cursor stops the walk.
|
|
180
|
+
for (;;) {
|
|
181
|
+
const result = await api.client.experimental.session.list({
|
|
182
|
+
limit: SESSION_PAGE_LIMIT,
|
|
183
|
+
roots: true,
|
|
184
|
+
directory: "",
|
|
185
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
186
|
+
})
|
|
187
|
+
const page = ((result?.data ?? result) as Session[]) ?? []
|
|
188
|
+
if (!Array.isArray(page) || page.length === 0) break
|
|
189
|
+
sessions.push(...page)
|
|
190
|
+
if (page.length < SESSION_PAGE_LIMIT) break
|
|
191
|
+
if (sessions.length >= SESSION_MAX) {
|
|
192
|
+
truncated = true
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
const next = page[page.length - 1]?.time?.updated
|
|
196
|
+
if (typeof next !== "number" || next === cursor) {
|
|
197
|
+
truncated = true
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
cursor = next
|
|
201
|
+
}
|
|
202
|
+
const entries = sessions
|
|
176
203
|
.filter((s) => !s.time?.archived && !s.parentID)
|
|
177
204
|
.sort((a, b) => b.time.updated - a.time.updated)
|
|
178
205
|
.map((s) => {
|
|
@@ -187,76 +214,118 @@ async function fetchEntries(api: TuiPluginApi): Promise<Entry[]> {
|
|
|
187
214
|
updated: s.time.updated,
|
|
188
215
|
}
|
|
189
216
|
})
|
|
217
|
+
return { entries, truncated }
|
|
190
218
|
}
|
|
191
219
|
|
|
192
|
-
const TRANSCRIPT_INDEX_LIMIT = 150
|
|
193
220
|
const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/
|
|
221
|
+
const TRANSCRIPT_BATCH = 64
|
|
222
|
+
const REMOTE_CONCURRENCY = 8
|
|
194
223
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
const ids = targets.map((entry) => entry.id).filter((id) => SESSION_ID_PATTERN.test(id))
|
|
202
|
-
if (ids.length === 0) return index
|
|
224
|
+
type IndexProgress = { indexed: number; total: number; complete: boolean }
|
|
225
|
+
|
|
226
|
+
const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
227
|
+
|
|
228
|
+
async function openTranscriptDb(): Promise<any | undefined> {
|
|
203
229
|
try {
|
|
204
230
|
const home = process.env.HOME ?? ""
|
|
205
231
|
const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
|
|
206
232
|
// @ts-ignore - bun:sqlite ships with the Bun runtime (verified); no type package installed
|
|
207
233
|
const sqlite: any = await import("bun:sqlite")
|
|
208
|
-
if (typeof sqlite?.Database !== "function")
|
|
209
|
-
|
|
234
|
+
if (typeof sqlite?.Database !== "function") return undefined
|
|
235
|
+
return new sqlite.Database(`${dataHome}/opencode/opencode.db`, { readonly: true })
|
|
236
|
+
} catch {
|
|
237
|
+
return undefined
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function addTranscript(index: Map<string, string>, sid: string, data: string): void {
|
|
242
|
+
try {
|
|
243
|
+
const part = JSON.parse(data) as { type?: unknown; text?: unknown }
|
|
244
|
+
if (part?.type !== "text" || typeof part.text !== "string") return
|
|
245
|
+
const base = index.get(sid) ?? ""
|
|
246
|
+
index.set(sid, `${base} ${part.text.toLowerCase()}`.trim())
|
|
247
|
+
} catch {
|
|
248
|
+
// ignore malformed parts
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Index titles/directories immediately, then add transcript text progressively
|
|
253
|
+
// in batches so the render thread can breathe. Covers every entry, not a fixed
|
|
254
|
+
// newest window; `onProgress` lets the picker show coverage.
|
|
255
|
+
async function buildSearchIndex(
|
|
256
|
+
api: TuiPluginApi,
|
|
257
|
+
entries: Entry[],
|
|
258
|
+
onProgress?: (progress: IndexProgress) => void,
|
|
259
|
+
): Promise<Map<string, string>> {
|
|
260
|
+
const index = new Map<string, string>()
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
index.set(entry.id, `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`)
|
|
263
|
+
}
|
|
264
|
+
const ids = entries.map((entry) => entry.id).filter((id) => SESSION_ID_PATTERN.test(id))
|
|
265
|
+
const remaining = new Set(ids)
|
|
266
|
+
const total = ids.length
|
|
267
|
+
let indexed = 0
|
|
268
|
+
const report = (complete: boolean) => onProgress?.({ indexed, total, complete })
|
|
269
|
+
report(false)
|
|
270
|
+
|
|
271
|
+
const db = await openTranscriptDb()
|
|
272
|
+
if (db) {
|
|
210
273
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
byId.set(row.sid, list)
|
|
225
|
-
}
|
|
226
|
-
} catch {}
|
|
227
|
-
}
|
|
228
|
-
for (const entry of targets) {
|
|
229
|
-
const text = (byId.get(entry.id) ?? []).join(" ").toLowerCase()
|
|
230
|
-
if (text) index.set(entry.id, `${index.get(entry.id)} ${text}`)
|
|
274
|
+
for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
|
|
275
|
+
const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
|
|
276
|
+
const placeholders = batch.map(() => "?").join(",")
|
|
277
|
+
const rows = (db
|
|
278
|
+
.query(
|
|
279
|
+
`SELECT session_id AS sid, data FROM part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.type') = 'text' ORDER BY time_created`,
|
|
280
|
+
)
|
|
281
|
+
.all(...batch) ?? []) as { sid: string; data: string }[]
|
|
282
|
+
for (const row of rows) addTranscript(index, row.sid, row.data)
|
|
283
|
+
for (const id of batch) remaining.delete(id)
|
|
284
|
+
indexed += batch.length
|
|
285
|
+
report(false)
|
|
286
|
+
await nextTick()
|
|
231
287
|
}
|
|
288
|
+
} catch {
|
|
289
|
+
// local store unavailable mid-walk; fall back to the remote path below
|
|
232
290
|
} finally {
|
|
233
291
|
db.close()
|
|
234
292
|
}
|
|
235
|
-
} catch {
|
|
236
|
-
await buildSearchIndexRemote(api, targets, index)
|
|
237
293
|
}
|
|
294
|
+
|
|
295
|
+
if (remaining.size > 0) {
|
|
296
|
+
await buildSearchIndexRemote(api, [...remaining], index, () => {
|
|
297
|
+
indexed += 1
|
|
298
|
+
report(false)
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
report(true)
|
|
238
302
|
return index
|
|
239
303
|
}
|
|
240
304
|
|
|
241
305
|
async function buildSearchIndexRemote(
|
|
242
306
|
api: TuiPluginApi,
|
|
243
|
-
|
|
307
|
+
ids: string[],
|
|
244
308
|
index: Map<string, string>,
|
|
309
|
+
onEach: () => void,
|
|
245
310
|
): Promise<void> {
|
|
246
311
|
const home = process.env.HOME ?? ""
|
|
247
312
|
const cacheDir = process.env.SESH_CACHE_DIR ?? `${home}/.cache/sesh`
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
313
|
+
let cursor = 0
|
|
314
|
+
// Bounded concurrency: a fixed worker pool instead of one request per session.
|
|
315
|
+
const worker = async () => {
|
|
316
|
+
for (;;) {
|
|
317
|
+
const i = cursor++
|
|
318
|
+
if (i >= ids.length) return
|
|
319
|
+
const id = ids[i]
|
|
320
|
+
const base = index.get(id) ?? ""
|
|
252
321
|
try {
|
|
253
|
-
const raw = await readFile(`${cacheDir}/extractions/${
|
|
322
|
+
const raw = await readFile(`${cacheDir}/extractions/${id}.json`, "utf8")
|
|
254
323
|
const record = JSON.parse(raw) as { fulltextLower?: unknown }
|
|
255
324
|
const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : ""
|
|
256
|
-
index.set(
|
|
325
|
+
index.set(id, `${base} ${fulltext}`.trim())
|
|
257
326
|
} catch {
|
|
258
327
|
try {
|
|
259
|
-
const result = await api.client.session.messages({ sessionID:
|
|
328
|
+
const result = await api.client.session.messages({ sessionID: id })
|
|
260
329
|
const messages = result.data ?? []
|
|
261
330
|
const text = messages
|
|
262
331
|
.flatMap((message) =>
|
|
@@ -266,11 +335,13 @@ async function buildSearchIndexRemote(
|
|
|
266
335
|
)
|
|
267
336
|
.join(" ")
|
|
268
337
|
.toLowerCase()
|
|
269
|
-
index.set(
|
|
338
|
+
index.set(id, `${base} ${text}`.trim())
|
|
270
339
|
} catch {}
|
|
271
340
|
}
|
|
272
|
-
|
|
273
|
-
|
|
341
|
+
onEach()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
await Promise.all(Array.from({ length: Math.min(REMOTE_CONCURRENCY, ids.length) }, worker))
|
|
274
345
|
}
|
|
275
346
|
|
|
276
347
|
const TRANSCRIPT_PREVIEW_TURNS = 8
|
|
@@ -397,7 +468,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
397
468
|
let alive = true
|
|
398
469
|
const load = async () => {
|
|
399
470
|
try {
|
|
400
|
-
const all = await fetchEntries(props.api)
|
|
471
|
+
const { entries: all } = await fetchEntries(props.api)
|
|
401
472
|
if (alive) setEntries(all)
|
|
402
473
|
} catch {
|
|
403
474
|
// leave the previous list in place rather than flashing to blank
|
|
@@ -469,7 +540,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
469
540
|
directory: entry.dir || undefined,
|
|
470
541
|
})
|
|
471
542
|
if (result && "error" in result && result.error) throw result.error
|
|
472
|
-
const remaining = await fetchEntries(props.api)
|
|
543
|
+
const { entries: remaining } = await fetchEntries(props.api)
|
|
473
544
|
if (remaining.some((item) => item.id === entry.id)) {
|
|
474
545
|
setEntries(remaining)
|
|
475
546
|
props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
|
|
@@ -723,7 +794,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
723
794
|
let alive = true
|
|
724
795
|
const load = async () => {
|
|
725
796
|
try {
|
|
726
|
-
const all = await fetchEntries(props.api)
|
|
797
|
+
const { entries: all } = await fetchEntries(props.api)
|
|
727
798
|
if (alive) setEntries(all)
|
|
728
799
|
} catch {}
|
|
729
800
|
}
|
|
@@ -827,13 +898,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
827
898
|
if (api.mode.current() !== BASE_MODE) return
|
|
828
899
|
if (api.renderer.currentFocusedEditor === null) return
|
|
829
900
|
|
|
830
|
-
let
|
|
901
|
+
let entriesResult: EntryResult
|
|
831
902
|
try {
|
|
832
|
-
|
|
903
|
+
entriesResult = await fetchEntries(api)
|
|
833
904
|
} catch {
|
|
834
905
|
api.ui.toast({ message: "Could not load sessions", variant: "error" })
|
|
835
906
|
return
|
|
836
907
|
}
|
|
908
|
+
const entries = entriesResult.entries
|
|
837
909
|
if (entries.length === 0) {
|
|
838
910
|
api.ui.toast({ message: "No sessions found", variant: "warning" })
|
|
839
911
|
return
|
|
@@ -844,7 +916,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
844
916
|
const [query, setQuery] = createSignal("")
|
|
845
917
|
const [cursor, setCursor] = createSignal(0)
|
|
846
918
|
const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
|
|
847
|
-
|
|
919
|
+
const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
|
|
920
|
+
indexed: 0,
|
|
921
|
+
total: 0,
|
|
922
|
+
complete: false,
|
|
923
|
+
})
|
|
924
|
+
void buildSearchIndex(api, entries, setIndexProgress).then(setSearchIndex)
|
|
848
925
|
let pickerInput: { value: string; isDestroyed?: boolean } | undefined
|
|
849
926
|
|
|
850
927
|
type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
|
|
@@ -868,6 +945,22 @@ const tui: TuiPlugin = async (api) => {
|
|
|
868
945
|
|
|
869
946
|
const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
|
|
870
947
|
|
|
948
|
+
// Visible bounding: say how much transcript text search actually covers and
|
|
949
|
+
// when the metadata walk hit its cap, instead of silently under-reporting.
|
|
950
|
+
const coverageLabel = createMemo(() => {
|
|
951
|
+
const progress = indexProgress()
|
|
952
|
+
const bits: string[] = []
|
|
953
|
+
if (progress.total > 0) {
|
|
954
|
+
bits.push(
|
|
955
|
+
progress.complete
|
|
956
|
+
? `transcripts ${progress.total}`
|
|
957
|
+
: `indexing transcripts ${progress.indexed}/${progress.total}`,
|
|
958
|
+
)
|
|
959
|
+
}
|
|
960
|
+
if (entriesResult.truncated) bits.push(`showing newest ${entries.length}`)
|
|
961
|
+
return bits.join(" · ")
|
|
962
|
+
})
|
|
963
|
+
|
|
871
964
|
createEffect(() => {
|
|
872
965
|
const count = selectableEntries().length
|
|
873
966
|
if (cursor() >= count) setCursor(Math.max(0, count - 1))
|
|
@@ -1041,6 +1134,11 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1041
1134
|
}}
|
|
1042
1135
|
/>
|
|
1043
1136
|
</box>
|
|
1137
|
+
<Show when={coverageLabel()}>
|
|
1138
|
+
<box paddingTop={1}>
|
|
1139
|
+
<text style={{ fg: api.theme.current.textMuted }}>{coverageLabel()}</text>
|
|
1140
|
+
</box>
|
|
1141
|
+
</Show>
|
|
1044
1142
|
</box>
|
|
1045
1143
|
<box
|
|
1046
1144
|
flexDirection="column"
|