opencode-sesh 0.1.4 → 0.1.5
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 +7 -4
- package/CHANGELOG.md +30 -1
- package/bin/sesh-delete.sh +1 -1
- package/bin/sesh-list.sh +38 -16
- package/bin/sesh-preview.sh +16 -11
- package/install.sh +29 -1
- package/package.json +1 -1
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
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.5] - 2026-09-13
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Transcript indexing no longer puts the whole conversation on the command line,
|
|
15
|
+
so large sessions (over a megabyte of text) are indexed instead of silently
|
|
16
|
+
dropped. A failed extraction now reports the snapshot as `stale` and retries
|
|
17
|
+
on the next refresh rather than certifying an incomplete index as fresh.
|
|
18
|
+
- Transcript preview renders text parts even when newer tool parts outnumber
|
|
19
|
+
them, and a long message no longer blanks the preview when the output pipe
|
|
20
|
+
closes early.
|
|
21
|
+
- Warm extraction caches are invalidated on any part change (text or timestamp),
|
|
22
|
+
not only when the session timestamp or part count moves.
|
|
23
|
+
- The installer refuses to replace a symlinked `sesh` launcher that is not a
|
|
24
|
+
sesh install, and uninstall leaves foreign launcher symlinks untouched.
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
- Session ids are validated with the anchored `^ses_[A-Za-z0-9]+$` pattern at
|
|
29
|
+
the preview and deletion boundaries before any SQL, and the local SQLite store
|
|
30
|
+
is opened read-only.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- Regression coverage for large transcripts, part-only cache invalidation,
|
|
35
|
+
preview text/tool ordering and long previews, malformed session ids, empty
|
|
36
|
+
stores, and installer launcher ownership.
|
|
37
|
+
|
|
10
38
|
## [0.1.4] - 2026-09-13
|
|
11
39
|
|
|
12
40
|
### Fixed
|
|
@@ -74,7 +102,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
74
102
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
75
103
|
CI.
|
|
76
104
|
|
|
77
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.
|
|
105
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...HEAD
|
|
106
|
+
[0.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...v0.1.5
|
|
78
107
|
[0.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.3...v0.1.4
|
|
79
108
|
[0.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.2...v0.1.3
|
|
80
109
|
[0.1.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.1...v0.1.2
|
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,7 +260,8 @@ 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 dir title created updated
|
|
263
|
+
local sid dir title created updated partCount partMax raw_file text_file record cache
|
|
264
|
+
local extract_failed=0
|
|
262
265
|
while IFS= read -r row; do
|
|
263
266
|
[ -n "$row" ] || continue
|
|
264
267
|
sid=$(printf '%s' "$row" | "$JQ_BIN" -r '.id')
|
|
@@ -268,28 +271,40 @@ refresh() {
|
|
|
268
271
|
created=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_created // 0')
|
|
269
272
|
updated=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_updated // 0')
|
|
270
273
|
partCount=$(printf '%s' "$row" | "$JQ_BIN" -r '.partCount // 0')
|
|
274
|
+
partMax=$(printf '%s' "$row" | "$JQ_BIN" -r '.partMax // 0')
|
|
271
275
|
cache="$EXTRACTIONS/$sid.json"
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
276
|
+
raw_file="$scratch/$sid.parts.json"
|
|
277
|
+
text_file="$scratch/$sid.text"
|
|
278
|
+
# Text parts only: reasoning blobs and tool payloads would bloat the search
|
|
279
|
+
# index. The transcript travels through files, never argv, so a large
|
|
280
|
+
# conversation cannot hit ARG_MAX; a failure leaves the session uncached so
|
|
281
|
+
# the next refresh retries instead of certifying it fresh.
|
|
282
|
+
if db_json "SELECT data FROM part WHERE session_id = '$sid' AND json_extract(data, '\$.type') = 'text' ORDER BY time_created;" > "$raw_file" 2>/dev/null \
|
|
283
|
+
&& "$JQ_BIN" -r '[.[]? | (.data? // empty) | (try fromjson catch empty) | select(type == "object" and .type == "text") | .text // empty] | join(" ")' "$raw_file" > "$text_file" 2>/dev/null; then
|
|
284
|
+
:
|
|
285
|
+
else
|
|
286
|
+
extract_failed=1
|
|
287
|
+
continue
|
|
288
|
+
fi
|
|
277
289
|
if [ -z "$title" ]; then
|
|
278
|
-
title=$(
|
|
290
|
+
title=$("$JQ_BIN" -Rrs 'gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end | select(length > 0) // "(untitled)"' "$text_file" 2>/dev/null) || title='(untitled)'
|
|
279
291
|
[ -n "$title" ] || title='(untitled)'
|
|
280
292
|
fi
|
|
281
293
|
record=$("$JQ_BIN" -cn --arg sid "$sid" --arg cwd "$dir" --arg title "$title" \
|
|
282
|
-
--argjson created "$created" --argjson updated "$updated" --argjson parts "$partCount" --
|
|
283
|
-
|
|
294
|
+
--argjson created "$created" --argjson updated "$updated" --argjson parts "$partCount" --argjson maxpart "$partMax" \
|
|
295
|
+
--rawfile text "$text_file" '
|
|
296
|
+
{cacheSchema: 2, cacheSession: $sid, cacheUpdated: $updated, cacheParts: $parts, cacheMax: $maxpart,
|
|
284
297
|
sessionId: $sid, transcriptPath: "", source: "opencode",
|
|
285
298
|
cwd: $cwd, title: ($title | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end),
|
|
286
299
|
updatedEpoch: (($updated / 1000 | floor) | if . > 0 then . else ($created / 1000 | floor) end),
|
|
287
|
-
fulltextLower: $
|
|
300
|
+
fulltextLower: (($title + " " + $text) | sub("\n$"; "") | ascii_downcase)}') || record=''
|
|
288
301
|
if [ -n "$record" ]; then
|
|
289
302
|
cache_tmp=$(mktemp "$EXTRACTIONS/$sid.json.XXXXXX")
|
|
290
303
|
printf '%s\n' "$record" > "$cache_tmp"
|
|
291
304
|
mv "$cache_tmp" "$cache"
|
|
292
305
|
printf '%s\n' "$record" >> "$historical"
|
|
306
|
+
else
|
|
307
|
+
extract_failed=1
|
|
293
308
|
fi
|
|
294
309
|
done < "$needs_extract"
|
|
295
310
|
|
|
@@ -297,6 +312,7 @@ refresh() {
|
|
|
297
312
|
.meta as $meta
|
|
298
313
|
| ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
|
|
299
314
|
| [$meta[]
|
|
315
|
+
| select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
|
|
300
316
|
| select($archived == 1 or .archived == 0)
|
|
301
317
|
| select($scope == "" or .directory == $scope)
|
|
302
318
|
| . as $m | ($histById[$m.id] // {}) as $h
|
|
@@ -313,7 +329,13 @@ refresh() {
|
|
|
313
329
|
return 1
|
|
314
330
|
fi
|
|
315
331
|
mv -f "$out" "$SNAPSHOT"
|
|
316
|
-
|
|
332
|
+
if [ "$extract_failed" = 1 ]; then
|
|
333
|
+
# Publish what we have, but say the index is incomplete and keep the
|
|
334
|
+
# failed sessions uncached so the next refresh retries them.
|
|
335
|
+
set_status 'stale: transcript extraction incomplete; refresh to retry' || true
|
|
336
|
+
else
|
|
337
|
+
set_status fresh || true
|
|
338
|
+
fi
|
|
317
339
|
}
|
|
318
340
|
|
|
319
341
|
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
|
|