@procrastivity/clast 0.0.1

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.
@@ -0,0 +1,275 @@
1
+ # clast-subcommands/sessions.bash — `clast sessions`.
2
+ #
3
+ # Read-only view over .manifest.jsonl: list sessions in a date window,
4
+ # optionally filtered by registry slug. See
5
+ # docs/cli-contract.md#clast-sessions.
6
+ # shellcheck shell=bash
7
+ # shellcheck source=lib/clast/clast-lib.bash
8
+ # shellcheck source=lib/clast/clast-manifest-lib.bash
9
+ # shellcheck source=lib/clast/clast-registry-lib.bash
10
+ # shellcheck source=lib/clast/clast-decode-lib.bash
11
+
12
+ _clast_sessions_usage() {
13
+ cat <<'EOF'
14
+ Usage: clast sessions [--day DATE] [--since DATE] [--until DATE] [--project SLUG]
15
+
16
+ List sessions captured in a date window.
17
+
18
+ Flags:
19
+ --day DATE Single-day window (default: today). Mutually exclusive
20
+ with --since/--until.
21
+ --since DATE Start of range (inclusive).
22
+ --until DATE End of range (inclusive).
23
+ --project SLUG Filter to a single registry slug.
24
+ -h, --help Print this usage and exit.
25
+
26
+ DATE accepts ISO (YYYY-MM-DD), `today`, `yesterday`, `last-week`,
27
+ `-Nd`, or `-Nw`. See docs/cli-contract.md#date-parsing.
28
+ EOF
29
+ }
30
+
31
+ _clast_sessions_err() {
32
+ local msg="$1"
33
+ if [[ -n "${CLAST_JSON:-}" ]]; then
34
+ jq -cn --arg m "$msg" '{error:$m, code:2}'
35
+ else
36
+ clast_log_error "sessions: $msg"
37
+ fi
38
+ }
39
+
40
+ clast_cmd_sessions() {
41
+ local day_filter="" since_date="" until_date=""
42
+ local project_filter=""
43
+
44
+ while [[ $# -gt 0 ]]; do
45
+ case "$1" in
46
+ --day)
47
+ if [[ $# -lt 2 ]]; then _clast_sessions_err "--day requires a value"; return 2; fi
48
+ if ! day_filter="$(clast_parse_date "$2" 2>/dev/null)"; then
49
+ _clast_sessions_err "invalid date '$2'"; return 2
50
+ fi
51
+ shift 2 ;;
52
+ --day=*)
53
+ if ! day_filter="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
54
+ _clast_sessions_err "invalid date '${1#*=}'"; return 2
55
+ fi
56
+ shift ;;
57
+ --since)
58
+ if [[ $# -lt 2 ]]; then _clast_sessions_err "--since requires a value"; return 2; fi
59
+ if ! since_date="$(clast_parse_date "$2" 2>/dev/null)"; then
60
+ _clast_sessions_err "invalid date '$2'"; return 2
61
+ fi
62
+ shift 2 ;;
63
+ --since=*)
64
+ if ! since_date="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
65
+ _clast_sessions_err "invalid date '${1#*=}'"; return 2
66
+ fi
67
+ shift ;;
68
+ --until)
69
+ if [[ $# -lt 2 ]]; then _clast_sessions_err "--until requires a value"; return 2; fi
70
+ if ! until_date="$(clast_parse_date "$2" 2>/dev/null)"; then
71
+ _clast_sessions_err "invalid date '$2'"; return 2
72
+ fi
73
+ shift 2 ;;
74
+ --until=*)
75
+ if ! until_date="$(clast_parse_date "${1#*=}" 2>/dev/null)"; then
76
+ _clast_sessions_err "invalid date '${1#*=}'"; return 2
77
+ fi
78
+ shift ;;
79
+ --project)
80
+ if [[ $# -lt 2 ]]; then _clast_sessions_err "--project requires a value"; return 2; fi
81
+ project_filter="$2"; shift 2 ;;
82
+ --project=*)
83
+ project_filter="${1#*=}"; shift ;;
84
+ -h|--help)
85
+ _clast_sessions_usage; return 0 ;;
86
+ --)
87
+ shift; break ;;
88
+ -*)
89
+ _clast_sessions_err "unknown flag '$1'"; return 2 ;;
90
+ *)
91
+ _clast_sessions_err "unexpected positional '$1'"; return 2 ;;
92
+ esac
93
+ done
94
+
95
+ if [[ -n "$day_filter" && ( -n "$since_date" || -n "$until_date" ) ]]; then
96
+ _clast_sessions_err "--day is mutually exclusive with --since/--until"
97
+ return 2
98
+ fi
99
+
100
+ if [[ -z "$day_filter" && -z "$since_date" && -z "$until_date" ]]; then
101
+ day_filter="$(clast_today)"
102
+ fi
103
+
104
+ # `--until` defaults to `today` per docs/cli-contract.md#clast-sessions
105
+ # when `--since` is supplied without an explicit upper bound.
106
+ if [[ -n "$since_date" && -z "$until_date" ]]; then
107
+ until_date="$(clast_today)"
108
+ fi
109
+
110
+ local filter
111
+ filter="$(_clast_sessions_window_filter "$day_filter" "$since_date" "$until_date")"
112
+
113
+ local journal_dir
114
+ journal_dir="$(clast_journal_dir)"
115
+
116
+ # Build the project segment whitelist if --project was passed.
117
+ declare -A allowed_segs=()
118
+ local have_project_filter=0
119
+ if [[ -n "$project_filter" ]]; then
120
+ have_project_filter=1
121
+ local p seg_enc
122
+ while IFS= read -r p; do
123
+ [[ -z "$p" ]] && continue
124
+ seg_enc="$(clast_encode_path "$p")"
125
+ allowed_segs["$seg_enc"]=1
126
+ done < <(clast_registry_list_json \
127
+ | jq -r --arg s "$project_filter" '.[] | select(.slug == $s) | .path')
128
+ fi
129
+
130
+ # Most-recent manifest line per session_id within window.
131
+ declare -A latest_line=()
132
+ local line sid
133
+ while IFS= read -r line; do
134
+ [[ -z "$line" ]] && continue
135
+ sid="$(jq -r '.session_id' <<<"$line")"
136
+ [[ -z "$sid" || "$sid" == "null" ]] && continue
137
+ latest_line["$sid"]="$line"
138
+ done < <(clast_manifest_iterate "$filter")
139
+
140
+ local -a rows=()
141
+ local snapshot day_bucket mtime seg abs_path msgs
142
+ local first_ts last_ts start_ts end_ts curated branch slug
143
+ local entries_dir
144
+ entries_dir="$journal_dir/entries"
145
+
146
+ for sid in "${!latest_line[@]}"; do
147
+ line="${latest_line[$sid]}"
148
+ snapshot="$(jq -r '.snapshot' <<<"$line")"
149
+ day_bucket="$(jq -r '.day_bucket' <<<"$line")"
150
+ mtime="$(jq -r '.source_mtime' <<<"$line")"
151
+ seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot")"
152
+
153
+ if (( have_project_filter == 1 )); then
154
+ if [[ -z "${allowed_segs[$seg]:-}" ]]; then
155
+ continue
156
+ fi
157
+ fi
158
+
159
+ abs_path="$journal_dir/$snapshot"
160
+ if [[ -r "$abs_path" ]]; then
161
+ msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
162
+ [[ -z "$msgs" ]] && msgs=0
163
+ first_ts="$(head -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
164
+ last_ts="$(tail -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
165
+ else
166
+ if [[ -n "${CLAST_VERBOSE:-}" ]]; then
167
+ clast_log_warn "sessions: snapshot missing or unreadable: $abs_path"
168
+ fi
169
+ msgs=0
170
+ first_ts=""
171
+ last_ts=""
172
+ fi
173
+ start_ts="${first_ts:-$mtime}"
174
+ end_ts="${last_ts:-$mtime}"
175
+
176
+ if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
177
+ :
178
+ else
179
+ slug="$seg"
180
+ fi
181
+
182
+ # TODO(step-10): branch field is best-effort; revisit when stats command lands.
183
+ branch=""
184
+
185
+ curated=false
186
+ if [[ -d "$entries_dir" ]]; then
187
+ if grep -l "session_id: $sid" "$entries_dir"/*.md 2>/dev/null | head -n1 | grep -q .; then
188
+ curated=true
189
+ fi
190
+ fi
191
+
192
+ rows+=("$(jq -cn \
193
+ --arg session_id "$sid" \
194
+ --arg project "$slug" \
195
+ --arg segment "$seg" \
196
+ --arg branch "$branch" \
197
+ --arg start "$start_ts" \
198
+ --arg end "$end_ts" \
199
+ --argjson msg_count_approx "$msgs" \
200
+ --arg snapshot_path "$snapshot" \
201
+ --arg day_bucket "$day_bucket" \
202
+ --argjson curated "$curated" \
203
+ '{
204
+ session_id: $session_id,
205
+ project: $project,
206
+ segment: $segment,
207
+ branch: (if $branch == "" then null else $branch end),
208
+ start: $start,
209
+ end: $end,
210
+ msg_count_approx: $msg_count_approx,
211
+ snapshot_path: $snapshot_path,
212
+ day_bucket: $day_bucket,
213
+ curated: $curated
214
+ }')")
215
+ done
216
+
217
+ local rows_json='[]'
218
+ if (( ${#rows[@]} > 0 )); then
219
+ rows_json="$(printf '%s\n' "${rows[@]}" | jq -cs 'sort_by(.start)')"
220
+ fi
221
+
222
+ if [[ -n "${CLAST_JSON:-}" ]]; then
223
+ printf '%s\n' "$rows_json"
224
+ return 0
225
+ fi
226
+
227
+ if [[ -n "${CLAST_QUIET:-}" ]]; then
228
+ return 0
229
+ fi
230
+
231
+ printf '%-37s %-17s %-25s %5s %5s %s\n' \
232
+ "session_id" "project" "branch" "start" "end" "msgs"
233
+
234
+ local n i sj r_sid r_project r_branch r_start r_end r_msgs r_day disp_start disp_end
235
+ n="$(jq 'length' <<<"$rows_json")"
236
+ for (( i = 0; i < n; i++ )); do
237
+ sj="$(jq -c ".[$i]" <<<"$rows_json")"
238
+ r_sid="$(jq -r '.session_id' <<<"$sj")"
239
+ r_project="$(jq -r '.project' <<<"$sj")"
240
+ r_branch="$(jq -r '.branch // ""' <<<"$sj")"
241
+ r_start="$(jq -r '.start' <<<"$sj")"
242
+ r_end="$(jq -r '.end' <<<"$sj")"
243
+ r_msgs="$(jq -r '.msg_count_approx' <<<"$sj")"
244
+ r_day="$(jq -r '.day_bucket' <<<"$sj")"
245
+
246
+ if [[ "${r_start:0:10}" == "${r_end:0:10}" && "${r_start:0:10}" == "$r_day" ]]; then
247
+ disp_start="${r_start:11:5}"
248
+ disp_end="${r_end:11:5}"
249
+ else
250
+ disp_start="${r_start:0:10} ${r_start:11:5}"
251
+ disp_end="${r_end:0:10} ${r_end:11:5}"
252
+ fi
253
+
254
+ printf '%-37s %-17s %-25s %5s %5s %s\n' \
255
+ "$r_sid" "$r_project" "$r_branch" "$disp_start" "$disp_end" "$r_msgs"
256
+ done
257
+ }
258
+
259
+ # _clast_sessions_window_filter <day> <since> <until>
260
+ _clast_sessions_window_filter() {
261
+ local day="$1" since="$2" until="$3"
262
+ if [[ -n "$day" ]]; then
263
+ printf '.day_bucket == "%s"' "$day"
264
+ return 0
265
+ fi
266
+ local -a parts=()
267
+ if [[ -n "$since" ]]; then parts+=(".day_bucket >= \"$since\""); fi
268
+ if [[ -n "$until" ]]; then parts+=(".day_bucket <= \"$until\""); fi
269
+ if (( ${#parts[@]} == 0 )); then printf 'true'; return 0; fi
270
+ local joined="${parts[0]}" i
271
+ for (( i = 1; i < ${#parts[@]}; i++ )); do
272
+ joined+=" and ${parts[i]}"
273
+ done
274
+ printf '%s' "$joined"
275
+ }
@@ -0,0 +1,283 @@
1
+ # clast-subcommands/show.bash — `clast show <session-id>`.
2
+ #
3
+ # Dump metadata + (optionally) first/last turns of a single captured
4
+ # session. See docs/cli-contract.md#clast-show.
5
+ # shellcheck shell=bash
6
+ # shellcheck source=lib/clast/clast-lib.bash
7
+ # shellcheck source=lib/clast/clast-manifest-lib.bash
8
+ # shellcheck source=lib/clast/clast-registry-lib.bash
9
+ # shellcheck source=lib/clast/clast-decode-lib.bash
10
+
11
+ _clast_show_usage() {
12
+ cat <<'EOF'
13
+ Usage: clast show <session-id> [--full] [--turns N]
14
+
15
+ Print metadata for a captured session.
16
+
17
+ Flags:
18
+ --full Include first/last N turns (text only, no tool calls).
19
+ --turns N Number of turns at each end when --full is set (default 5).
20
+ -h, --help Print this usage and exit.
21
+ EOF
22
+ }
23
+
24
+ _clast_show_err() {
25
+ local msg="$1" code="${2:-2}"
26
+ if [[ -n "${CLAST_JSON:-}" ]]; then
27
+ jq -cn --arg m "$msg" --argjson c "$code" '{error:$m, code:$c}'
28
+ else
29
+ clast_log_error "show: $msg"
30
+ fi
31
+ }
32
+
33
+ clast_cmd_show() {
34
+ local session_id=""
35
+ local include_turns=0
36
+ local turn_count=5
37
+
38
+ while [[ $# -gt 0 ]]; do
39
+ case "$1" in
40
+ --full) include_turns=1; shift ;;
41
+ --turns)
42
+ if [[ $# -lt 2 ]]; then _clast_show_err "--turns requires a value"; return 2; fi
43
+ if ! [[ "$2" =~ ^[1-9][0-9]*$ ]]; then
44
+ _clast_show_err "--turns must be a positive integer"; return 2
45
+ fi
46
+ turn_count="$2"; shift 2 ;;
47
+ --turns=*)
48
+ local v="${1#*=}"
49
+ if ! [[ "$v" =~ ^[1-9][0-9]*$ ]]; then
50
+ _clast_show_err "--turns must be a positive integer"; return 2
51
+ fi
52
+ turn_count="$v"; shift ;;
53
+ -h|--help) _clast_show_usage; return 0 ;;
54
+ --) shift; break ;;
55
+ -*) _clast_show_err "unknown flag '$1'"; return 2 ;;
56
+ *)
57
+ if [[ -n "$session_id" ]]; then
58
+ _clast_show_err "unexpected positional '$1'"; return 2
59
+ fi
60
+ session_id="$1"; shift ;;
61
+ esac
62
+ done
63
+
64
+ if [[ -z "$session_id" ]]; then
65
+ _clast_show_err "missing <session-id>"; return 2
66
+ fi
67
+ if ! [[ "$session_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
68
+ _clast_show_err "'$session_id' is not a valid UUID"; return 2
69
+ fi
70
+
71
+ local line
72
+ if ! line="$(clast_manifest_lookup "$session_id" 2>/dev/null)" || [[ -z "$line" ]]; then
73
+ _clast_show_err "session '$session_id' not found in manifest" 1
74
+ return 1
75
+ fi
76
+
77
+ local snapshot day_bucket source_mtime journal_dir abs_path
78
+ snapshot="$(jq -r '.snapshot' <<<"$line")"
79
+ day_bucket="$(jq -r '.day_bucket' <<<"$line")"
80
+ source_mtime="$(jq -r '.source_mtime' <<<"$line")"
81
+ journal_dir="$(clast_journal_dir)"
82
+ abs_path="$journal_dir/$snapshot"
83
+
84
+ if [[ ! -r "$abs_path" ]]; then
85
+ _clast_show_err "snapshot file missing on disk (run 'clast doctor')" 1
86
+ return 1
87
+ fi
88
+
89
+ local seg slug
90
+ seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot")"
91
+ if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
92
+ :
93
+ else
94
+ slug="$seg"
95
+ fi
96
+
97
+ local msgs first_ts last_ts start_ts end_ts
98
+ msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
99
+ [[ -z "$msgs" ]] && msgs=0
100
+ first_ts="$(head -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
101
+ last_ts="$(tail -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
102
+ start_ts="${first_ts:-$source_mtime}"
103
+ end_ts="${last_ts:-$source_mtime}"
104
+
105
+ # first_prompt / last_prompt — best-effort scan of user messages.
106
+ # These pipelines must never abort the command: malformed JSONL lines are
107
+ # routine, and the fields are explicitly documented as best-effort.
108
+ local first_prompt last_prompt user_msgs=""
109
+ user_msgs="$(_clast_show_user_messages "$abs_path" 2>/dev/null || true)"
110
+ first_prompt="$(printf '%s\n' "$user_msgs" | head -n1)"
111
+ last_prompt="$(printf '%s\n' "$user_msgs" | tail -n1)"
112
+ # If the stream was empty the head/tail of '\n' is empty — keep as ''.
113
+ [[ -z "$user_msgs" ]] && first_prompt="" && last_prompt=""
114
+ first_prompt="$(_clast_show_truncate "$first_prompt")"
115
+ last_prompt="$(_clast_show_truncate "$last_prompt")"
116
+
117
+ # duration
118
+ local duration_str=""
119
+ if [[ -n "$first_ts" && -n "$last_ts" ]]; then
120
+ local s_epoch e_epoch delta
121
+ if s_epoch="$(date -d "$first_ts" +%s 2>/dev/null)" \
122
+ && e_epoch="$(date -d "$last_ts" +%s 2>/dev/null)" \
123
+ && [[ -n "$s_epoch" && -n "$e_epoch" ]]; then
124
+ delta=$((e_epoch - s_epoch))
125
+ duration_str="$(_clast_show_format_duration "$delta")"
126
+ fi
127
+ fi
128
+
129
+ local curated=false
130
+ if [[ -d "$journal_dir/entries" ]]; then
131
+ if grep -l "session_id: $session_id" "$journal_dir/entries"/*.md 2>/dev/null | head -n1 | grep -q .; then
132
+ curated=true
133
+ fi
134
+ fi
135
+
136
+ # TODO(step-10): branch field is best-effort; revisit when stats command lands.
137
+ local branch=""
138
+
139
+ # --- Build first_turns / last_turns only when --full is set ------------
140
+ # Collecting turns slurps the entire JSONL into jq; skip the cost when
141
+ # --full wasn't requested (the JSON object never emits these arrays
142
+ # without --full).
143
+ local turns_json='[]' first_turns_json='[]' last_turns_json='[]'
144
+ if (( include_turns == 1 )); then
145
+ turns_json="$(_clast_show_collect_turns "$abs_path")"
146
+ first_turns_json="$(jq -c --argjson n "$turn_count" '.[0:$n]' <<<"$turns_json")"
147
+ last_turns_json="$(jq -c --argjson n "$turn_count" '.[-$n:]' <<<"$turns_json")"
148
+ fi
149
+
150
+ if [[ -n "${CLAST_JSON:-}" ]]; then
151
+ local obj
152
+ obj="$(jq -cn \
153
+ --arg session_id "$session_id" \
154
+ --arg project "$slug" \
155
+ --arg segment "$seg" \
156
+ --arg branch "$branch" \
157
+ --arg start "$start_ts" \
158
+ --arg end "$end_ts" \
159
+ --arg duration "$duration_str" \
160
+ --argjson msg_count_approx "$msgs" \
161
+ --arg snapshot_path "$snapshot" \
162
+ --arg day_bucket "$day_bucket" \
163
+ --argjson curated "$curated" \
164
+ --arg first_prompt "$first_prompt" \
165
+ --arg last_prompt "$last_prompt" \
166
+ '{
167
+ session_id: $session_id,
168
+ project: $project,
169
+ segment: $segment,
170
+ branch: (if $branch == "" then null else $branch end),
171
+ start: $start,
172
+ end: $end,
173
+ duration: (if $duration == "" then null else $duration end),
174
+ msg_count_approx: $msg_count_approx,
175
+ snapshot_path: $snapshot_path,
176
+ day_bucket: $day_bucket,
177
+ curated: $curated,
178
+ first_prompt: (if $first_prompt == "" then null else $first_prompt end),
179
+ last_prompt: (if $last_prompt == "" then null else $last_prompt end)
180
+ }')"
181
+ if (( include_turns == 1 )); then
182
+ obj="$(jq -c --argjson f "$first_turns_json" --argjson l "$last_turns_json" \
183
+ '. + {first_turns:$f, last_turns:$l}' <<<"$obj")"
184
+ fi
185
+ printf '%s\n' "$obj"
186
+ return 0
187
+ fi
188
+
189
+ if [[ -z "${CLAST_QUIET:-}" ]]; then
190
+ printf 'session_id: %s\n' "$session_id"
191
+ printf 'project: %s\n' "$slug"
192
+ printf 'segment: %s\n' "$seg"
193
+ printf 'branch: %s\n' "$branch"
194
+ printf 'start: %s\n' "$(_clast_show_human_ts "$start_ts")"
195
+ printf 'end: %s\n' "$(_clast_show_human_ts "$end_ts")"
196
+ if [[ -n "$duration_str" ]]; then
197
+ printf 'duration: %s\n' "$duration_str"
198
+ fi
199
+ printf 'msg_count: %s (approx)\n' "$msgs"
200
+ printf 'snapshot: %s\n' "$abs_path"
201
+ if [[ "$curated" == "true" ]]; then
202
+ printf 'curated: yes\n'
203
+ else
204
+ printf 'curated: no\n'
205
+ fi
206
+ if [[ -n "$first_prompt" ]]; then
207
+ printf 'first_prompt: %s\n' "$first_prompt"
208
+ fi
209
+ if [[ -n "$last_prompt" ]]; then
210
+ printf 'last_prompt: %s\n' "$last_prompt"
211
+ fi
212
+
213
+ if (( include_turns == 1 )); then
214
+ printf '\n## First %s turns\n' "$turn_count"
215
+ jq -r '.[] | "[\(.role)] \(.text)"' <<<"$first_turns_json"
216
+ printf '\n## Last %s turns\n' "$turn_count"
217
+ jq -r '.[] | "[\(.role)] \(.text)"' <<<"$last_turns_json"
218
+ fi
219
+ fi
220
+ }
221
+
222
+ # _clast_show_user_messages <abs_path>
223
+ # Stream user-message text content, one per line. Empty lines dropped.
224
+ _clast_show_user_messages() {
225
+ local path="$1"
226
+ jq -r '
227
+ select((.role // .message.role) == "user")
228
+ | (.message.content // .content // empty)
229
+ | if type == "array" then map(.text? // "") | join(" ") else . end
230
+ | select(. != null and . != "")
231
+ ' "$path" 2>/dev/null
232
+ }
233
+
234
+ # _clast_show_collect_turns <abs_path>
235
+ # Emit a JSON array of {role, text} for user+assistant text messages.
236
+ _clast_show_collect_turns() {
237
+ local path="$1"
238
+ jq -sc '
239
+ map(
240
+ select((.role // .message.role) as $r | $r == "user" or $r == "assistant")
241
+ | {
242
+ role: (.role // .message.role),
243
+ text: ((.message.content // .content // "") | if type == "array" then map(.text? // "") | join(" ") else . end)
244
+ }
245
+ | select(.text != null and .text != "")
246
+ )
247
+ ' "$path" 2>/dev/null || printf '[]'
248
+ }
249
+
250
+ # _clast_show_truncate <text>
251
+ # Truncate to 120 chars + … if longer.
252
+ _clast_show_truncate() {
253
+ local t="$1"
254
+ if (( ${#t} > 120 )); then
255
+ printf '%s…' "${t:0:120}"
256
+ else
257
+ printf '%s' "$t"
258
+ fi
259
+ }
260
+
261
+ # _clast_show_human_ts <iso>
262
+ # "2026-05-30T14:30:30Z" → "2026-05-30 14:30:30"
263
+ _clast_show_human_ts() {
264
+ local ts="$1"
265
+ if [[ -z "$ts" ]]; then printf ''; return 0; fi
266
+ printf '%s %s' "${ts:0:10}" "${ts:11:8}"
267
+ }
268
+
269
+ # _clast_show_format_duration <seconds>
270
+ _clast_show_format_duration() {
271
+ local s="$1"
272
+ if (( s < 0 )); then s=0; fi
273
+ local h=$((s / 3600))
274
+ local m=$(((s % 3600) / 60))
275
+ local sec=$((s % 60))
276
+ if (( h > 0 )); then
277
+ printf '%dh %dm' "$h" "$m"
278
+ elif (( m > 0 )); then
279
+ printf '%dm %ds' "$m" "$sec"
280
+ else
281
+ printf '%ds' "$sec"
282
+ fi
283
+ }