@procrastivity/clast 0.0.2 → 0.0.4
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/.claude-plugin/skills/day-wakeup/SKILL.md +21 -19
- package/.claude-plugin/skills/wakeup/SKILL.md +15 -13
- package/README.md +36 -31
- package/bin/clast +22 -124
- package/bin/clast-plumbing +173 -0
- package/examples/cron/clast-snapshot.service +2 -2
- package/examples/cron/clast-snapshot.timer +1 -1
- package/examples/cron/crontab.sample +4 -4
- package/examples/workflows/morning-briefing.md +11 -11
- package/hooks/snapshot.sh +5 -5
- package/lib/clast/clast-dismissed-lib.bash +6 -5
- package/lib/clast/clast-lib.bash +5 -2
- package/lib/clast/clast-manifest-lib.bash +28 -5
- package/lib/clast/clast-porcelain-lib.bash +217 -0
- package/lib/clast/clast-porcelain-subcommands/brief.bash +167 -0
- package/{bin/clast-wake → lib/clast/clast-porcelain-subcommands/wake.bash} +87 -217
- package/lib/clast/clast-registry-lib.bash +32 -14
- package/lib/clast/clast-subcommands/entries.bash +9 -15
- package/lib/clast/clast-subcommands/projects.bash +7 -1
- package/lib/clast/clast-subcommands/sessions.bash +92 -47
- package/lib/clast/clast-subcommands/show.bash +18 -7
- package/lib/clast/clast-subcommands/snapshot.bash +12 -2
- package/lib/clast/clast-subcommands/stats.bash +7 -2
- package/lib/clast/prompts/day-wakeup-draft-system.md +1 -0
- package/package.json +3 -2
- package/bin/clast-brief +0 -305
|
@@ -145,8 +145,14 @@ clast_cmd_projects() {
|
|
|
145
145
|
|
|
146
146
|
seg_session_count["$seg"]=$(( ${seg_session_count["$seg"]:-0} + 1 ))
|
|
147
147
|
|
|
148
|
+
# Prefer cached msg_count on the manifest line (step 21); fall back to
|
|
149
|
+
# counting transcript lines for legacy lines that predate the cache.
|
|
148
150
|
abs_path="$journal_dir/$snapshot"
|
|
149
|
-
|
|
151
|
+
local cached_msgs
|
|
152
|
+
cached_msgs="$(jq -r '.msg_count // empty' <<<"$line")"
|
|
153
|
+
if [[ -n "$cached_msgs" ]]; then
|
|
154
|
+
msgs="$cached_msgs"
|
|
155
|
+
elif [[ -r "$abs_path" ]]; then
|
|
150
156
|
msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
|
|
151
157
|
[[ -z "$msgs" ]] && msgs=0
|
|
152
158
|
else
|
|
@@ -207,32 +207,60 @@ clast_cmd_sessions() {
|
|
|
207
207
|
| jq -r --arg s "$project_filter" '.[] | select(.slug == $s) | .path')
|
|
208
208
|
fi
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
local line sid
|
|
213
|
-
while IFS= read -r line; do
|
|
214
|
-
[[ -z "$line" ]] && continue
|
|
215
|
-
sid="$(jq -r '.session_id' <<<"$line")"
|
|
216
|
-
[[ -z "$sid" || "$sid" == "null" ]] && continue
|
|
217
|
-
# Skip dismissed sessions early to avoid unnecessary work.
|
|
218
|
-
if [[ -n "${dismissed_ids[$sid]:-}" ]]; then
|
|
219
|
-
continue
|
|
220
|
-
fi
|
|
221
|
-
latest_line["$sid"]="$line"
|
|
222
|
-
done < <(clast_manifest_iterate "$filter")
|
|
210
|
+
local manifest_path
|
|
211
|
+
manifest_path="$(clast_manifest_path)"
|
|
223
212
|
|
|
224
|
-
local -a rows=()
|
|
225
|
-
local snapshot day_bucket mtime seg abs_path msgs
|
|
226
|
-
local first_ts last_ts start_ts end_ts curated branch slug
|
|
227
213
|
local entries_dir
|
|
228
214
|
entries_dir="$journal_dir/entries"
|
|
229
215
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
216
|
+
# Curated/stale index: scan every entry file ONCE up front instead of
|
|
217
|
+
# grepping the entries dir per session (the old approach was
|
|
218
|
+
# O(sessions × entries)). `curated_seen[sid]` marks a session as curated;
|
|
219
|
+
# `curated_mtime[sid]` holds the max curated_source_mtime across its
|
|
220
|
+
# entries ("" when none carry one — legacy entries then stay non-stale).
|
|
221
|
+
declare -A curated_seen=() curated_mtime=()
|
|
222
|
+
if [[ -d "$entries_dir" ]] && compgen -G "$entries_dir/*.md" >/dev/null 2>&1; then
|
|
223
|
+
local idx_sid idx_cm
|
|
224
|
+
while IFS=$'\t' read -r idx_sid idx_cm; do
|
|
225
|
+
[[ -z "$idx_sid" ]] && continue
|
|
226
|
+
curated_seen["$idx_sid"]=1
|
|
227
|
+
if [[ -n "$idx_cm" ]]; then
|
|
228
|
+
if [[ -z "${curated_mtime[$idx_sid]:-}" || "$idx_cm" > "${curated_mtime[$idx_sid]}" ]]; then
|
|
229
|
+
curated_mtime["$idx_sid"]="$idx_cm"
|
|
230
|
+
fi
|
|
231
|
+
fi
|
|
232
|
+
done < <(awk '
|
|
233
|
+
function flush() { if (sid != "") { print sid "\t" cm } sid=""; cm="" }
|
|
234
|
+
FNR==1 && NR>1 { flush() }
|
|
235
|
+
/^session_id:/ { v=$0; sub(/^session_id:[[:space:]]*/, "", v); sid=v }
|
|
236
|
+
/^curated_source_mtime:/ { v=$0; sub(/^curated_source_mtime:[[:space:]]*/, "", v); gsub(/"/, "", v); cm=v }
|
|
237
|
+
END { flush() }
|
|
238
|
+
' "$entries_dir"/*.md 2>/dev/null)
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
# Most-recent manifest line per session_id within window, in a SINGLE jq
|
|
242
|
+
# pass. Previously this forked one jq per manifest line plus several more
|
|
243
|
+
# per session; for large manifests that dominated runtime. The reduce keeps
|
|
244
|
+
# the last line seen per session_id (manifest is append-only, so last =
|
|
245
|
+
# most recent). Emits @tsv columns: session_id, snapshot, day_bucket,
|
|
246
|
+
# source_mtime.
|
|
247
|
+
local -a rows=()
|
|
248
|
+
local sid snapshot day_bucket mtime seg abs_path msgs
|
|
249
|
+
local cached_msgs cached_first cached_last
|
|
250
|
+
local first_ts last_ts start_ts end_ts curated stale branch slug
|
|
251
|
+
declare -A seg_slug=()
|
|
252
|
+
|
|
253
|
+
while IFS=$'\t' read -r sid snapshot day_bucket mtime cached_msgs cached_first cached_last; do
|
|
254
|
+
[[ -z "$sid" ]] && continue
|
|
255
|
+
# Skip dismissed sessions early, before any per-session file work.
|
|
256
|
+
if [[ -n "${dismissed_ids[$sid]:-}" ]]; then
|
|
257
|
+
continue
|
|
258
|
+
fi
|
|
259
|
+
|
|
260
|
+
# segment = third path component of transcripts/<day>/<segment>/<uuid>.
|
|
261
|
+
seg="${snapshot#transcripts/}" # <day>/<segment>/<uuid>.jsonl
|
|
262
|
+
seg="${seg#*/}" # <segment>/<uuid>.jsonl
|
|
263
|
+
seg="${seg%%/*}" # <segment>
|
|
236
264
|
|
|
237
265
|
if (( have_project_filter == 1 )); then
|
|
238
266
|
if [[ -z "${allowed_segs[$seg]:-}" ]]; then
|
|
@@ -241,7 +269,15 @@ clast_cmd_sessions() {
|
|
|
241
269
|
fi
|
|
242
270
|
|
|
243
271
|
abs_path="$journal_dir/$snapshot"
|
|
244
|
-
if [[ -
|
|
272
|
+
if [[ -n "$cached_msgs" ]]; then
|
|
273
|
+
# Cache hit (step 21): the manifest line carries msg_count/first_ts/
|
|
274
|
+
# last_ts, so we never open the transcript. Empty cached_first/last
|
|
275
|
+
# means the cached value was JSON null (no first/last timestamp).
|
|
276
|
+
msgs="$cached_msgs"
|
|
277
|
+
first_ts="$cached_first"
|
|
278
|
+
last_ts="$cached_last"
|
|
279
|
+
elif [[ -r "$abs_path" ]]; then
|
|
280
|
+
# Legacy line (pre-cache): fall back to reading the transcript.
|
|
245
281
|
msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
|
|
246
282
|
[[ -z "$msgs" ]] && msgs=0
|
|
247
283
|
first_ts="$(head -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
@@ -257,35 +293,30 @@ clast_cmd_sessions() {
|
|
|
257
293
|
start_ts="${first_ts:-$mtime}"
|
|
258
294
|
end_ts="${last_ts:-$mtime}"
|
|
259
295
|
|
|
260
|
-
|
|
261
|
-
|
|
296
|
+
# Resolve the slug once per unique segment (segments repeat heavily
|
|
297
|
+
# across sessions; each resolve forks several jq calls).
|
|
298
|
+
if [[ -n "${seg_slug[$seg]+x}" ]]; then
|
|
299
|
+
slug="${seg_slug[$seg]}"
|
|
262
300
|
else
|
|
263
|
-
slug="$seg"
|
|
301
|
+
if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
|
|
302
|
+
:
|
|
303
|
+
else
|
|
304
|
+
slug="$seg"
|
|
305
|
+
fi
|
|
306
|
+
seg_slug["$seg"]="$slug"
|
|
264
307
|
fi
|
|
265
308
|
|
|
266
309
|
# TODO(step-10): branch field is best-effort; revisit when stats command lands.
|
|
267
310
|
branch=""
|
|
268
311
|
|
|
312
|
+
# Curated/stale from the prebuilt index (no per-session grep).
|
|
269
313
|
curated=false
|
|
270
|
-
|
|
271
|
-
if [[ -
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if [[ -n "$
|
|
275
|
-
|
|
276
|
-
local best_curated_mtime="" ef cm
|
|
277
|
-
while IFS= read -r ef; do
|
|
278
|
-
[[ -z "$ef" ]] && continue
|
|
279
|
-
cm="$(grep '^curated_source_mtime:' "$ef" 2>/dev/null | head -n1 | sed 's/^curated_source_mtime:[[:space:]]*//')" || true
|
|
280
|
-
cm="${cm#\"}"
|
|
281
|
-
cm="${cm%\"}"
|
|
282
|
-
if [[ -n "$cm" && ( -z "$best_curated_mtime" || "$cm" > "$best_curated_mtime" ) ]]; then
|
|
283
|
-
best_curated_mtime="$cm"
|
|
284
|
-
fi
|
|
285
|
-
done <<<"$entry_matches"
|
|
286
|
-
if [[ -n "$best_curated_mtime" && "$best_curated_mtime" != "$mtime" ]]; then
|
|
287
|
-
stale=true
|
|
288
|
-
fi
|
|
314
|
+
stale=false
|
|
315
|
+
if [[ -n "${curated_seen[$sid]:-}" ]]; then
|
|
316
|
+
curated=true
|
|
317
|
+
local best_curated_mtime="${curated_mtime[$sid]:-}"
|
|
318
|
+
if [[ -n "$best_curated_mtime" && "$best_curated_mtime" != "$mtime" ]]; then
|
|
319
|
+
stale=true
|
|
289
320
|
fi
|
|
290
321
|
fi
|
|
291
322
|
|
|
@@ -321,11 +352,25 @@ clast_cmd_sessions() {
|
|
|
321
352
|
stale: $stale,
|
|
322
353
|
dismissed: $dismissed
|
|
323
354
|
}')")
|
|
324
|
-
done
|
|
355
|
+
done < <(
|
|
356
|
+
if [[ -f "$manifest_path" ]]; then
|
|
357
|
+
jq -rRn '
|
|
358
|
+
reduce (inputs | fromjson?
|
|
359
|
+
| select('"$filter"')
|
|
360
|
+
| select(.session_id != null and .session_id != "")) as $l
|
|
361
|
+
({}; .[$l.session_id] = $l)
|
|
362
|
+
| to_entries[] | .value
|
|
363
|
+
| [.session_id, .snapshot, .day_bucket, .source_mtime,
|
|
364
|
+
.msg_count, .first_ts, .last_ts] | @tsv
|
|
365
|
+
' "$manifest_path"
|
|
366
|
+
fi
|
|
367
|
+
)
|
|
325
368
|
|
|
326
369
|
local rows_json='[]'
|
|
327
370
|
if (( ${#rows[@]} > 0 )); then
|
|
328
|
-
|
|
371
|
+
# Tiebreak on session_id so the order is deterministic when sessions
|
|
372
|
+
# share a start timestamp (the old assoc-array iteration order was not).
|
|
373
|
+
rows_json="$(printf '%s\n' "${rows[@]}" | jq -cs 'sort_by(.start, .session_id)')"
|
|
329
374
|
fi
|
|
330
375
|
|
|
331
376
|
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
@@ -94,11 +94,22 @@ clast_cmd_show() {
|
|
|
94
94
|
slug="$seg"
|
|
95
95
|
fi
|
|
96
96
|
|
|
97
|
+
# Prefer the cached metadata on the manifest line (step 21); fall back to
|
|
98
|
+
# reading the transcript for legacy lines that predate the cache.
|
|
97
99
|
local msgs first_ts last_ts start_ts end_ts
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
local cached_msgs cached_first cached_last
|
|
101
|
+
IFS=$'\t' read -r cached_msgs cached_first cached_last \
|
|
102
|
+
< <(jq -r '[(.msg_count // ""), (.first_ts // ""), (.last_ts // "")] | @tsv' <<<"$line")
|
|
103
|
+
if [[ -n "$cached_msgs" ]]; then
|
|
104
|
+
msgs="$cached_msgs"
|
|
105
|
+
first_ts="$cached_first"
|
|
106
|
+
last_ts="$cached_last"
|
|
107
|
+
else
|
|
108
|
+
msgs="$(wc -l <"$abs_path" 2>/dev/null | tr -d ' ')"
|
|
109
|
+
[[ -z "$msgs" ]] && msgs=0
|
|
110
|
+
first_ts="$(head -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
111
|
+
last_ts="$(tail -n1 "$abs_path" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
112
|
+
fi
|
|
102
113
|
start_ts="${first_ts:-$source_mtime}"
|
|
103
114
|
end_ts="${last_ts:-$source_mtime}"
|
|
104
115
|
|
|
@@ -224,7 +235,7 @@ clast_cmd_show() {
|
|
|
224
235
|
_clast_show_user_messages() {
|
|
225
236
|
local path="$1"
|
|
226
237
|
jq -r '
|
|
227
|
-
select((.role // .message.role) == "user")
|
|
238
|
+
select((.role // .message.role // .type) == "user")
|
|
228
239
|
| (.message.content // .content // empty)
|
|
229
240
|
| if type == "array" then map(.text? // "") | join(" ") else . end
|
|
230
241
|
| select(. != null and . != "")
|
|
@@ -237,9 +248,9 @@ _clast_show_collect_turns() {
|
|
|
237
248
|
local path="$1"
|
|
238
249
|
jq -sc '
|
|
239
250
|
map(
|
|
240
|
-
select((.role // .message.role) as $r | $r == "user" or $r == "assistant")
|
|
251
|
+
select((.role // .message.role // .type) as $r | $r == "user" or $r == "assistant")
|
|
241
252
|
| {
|
|
242
|
-
role: (.role // .message.role),
|
|
253
|
+
role: (.role // .message.role // .type),
|
|
243
254
|
text: ((.message.content // .content // "") | if type == "array" then map(.text? // "") | join(" ") else . end)
|
|
244
255
|
}
|
|
245
256
|
| select(.text != null and .text != "")
|
|
@@ -117,7 +117,7 @@ clast_cmd_snapshot() {
|
|
|
117
117
|
# TODO(v1.1): parallel capture across segments.
|
|
118
118
|
if [[ -d "$projects_dir" ]]; then
|
|
119
119
|
local source segment session_id mtime_epoch mtime_iso source_size
|
|
120
|
-
local first_ts ts_epoch day_bucket
|
|
120
|
+
local first_ts ts_epoch day_bucket last_ts msg_count
|
|
121
121
|
local dest_rel dest dest_dir tmp
|
|
122
122
|
while IFS= read -r -d '' source; do
|
|
123
123
|
segment="$(basename "$(dirname "$source")")"
|
|
@@ -163,6 +163,16 @@ clast_cmd_snapshot() {
|
|
|
163
163
|
day_bucket="$(_clast_snapshot_bucket_for_epoch "$mtime_epoch")"
|
|
164
164
|
fi
|
|
165
165
|
|
|
166
|
+
# Cache per-session metadata in the manifest (step 21) so readers
|
|
167
|
+
# don't re-open the transcript. msg_count uses wc -l semantics to
|
|
168
|
+
# match the readers' file-read fallback exactly; last_ts mirrors the
|
|
169
|
+
# existing first_ts extraction. Kept as separate reads (not a fused
|
|
170
|
+
# awk pass) to avoid the trailing-newline parsing hazard and because
|
|
171
|
+
# snapshot is a batch path where the extra forks are negligible.
|
|
172
|
+
last_ts="$(tail -n1 "$source" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
173
|
+
msg_count="$(wc -l <"$source" 2>/dev/null | tr -d ' ')"
|
|
174
|
+
[[ "$msg_count" =~ ^[0-9]+$ ]] || msg_count=0
|
|
175
|
+
|
|
166
176
|
# Dedup on (session_id, source_mtime). Mtime — not ctime or size —
|
|
167
177
|
# is the manifest's "most recent line wins" key; Claude Code's
|
|
168
178
|
# writer bumps mtime whenever a session grows.
|
|
@@ -199,7 +209,7 @@ clast_cmd_snapshot() {
|
|
|
199
209
|
# Invariant: a manifest line implies the dest file exists. If the
|
|
200
210
|
# append fails the dest is an orphan; doctor (step 10) will reap
|
|
201
211
|
# it, and a re-run of snapshot overwrites + retries the append.
|
|
202
|
-
if ! clast_manifest_append "$session_id" "$source" "$dest_rel" "$mtime_iso" "$source_size" "$day_bucket"; then
|
|
212
|
+
if ! clast_manifest_append "$session_id" "$source" "$dest_rel" "$mtime_iso" "$source_size" "$day_bucket" "$msg_count" "$first_ts" "$last_ts"; then
|
|
203
213
|
error_lines+=("$(jq -cn --arg f "$source" --arg r "manifest append failed" '{file:$f,reason:$r}')")
|
|
204
214
|
continue
|
|
205
215
|
fi
|
|
@@ -244,8 +244,13 @@ clast_cmd_stats() {
|
|
|
244
244
|
[[ "$size" =~ ^[0-9]+$ ]] || size=0
|
|
245
245
|
bytes_sum=$((bytes_sum + size))
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
# Prefer cached msg_count on the manifest line (step 21); fall back to
|
|
248
|
+
# counting transcript lines for legacy lines that predate the cache.
|
|
249
|
+
local abs="$journal_dir/$snapshot" m=0 cached_m
|
|
250
|
+
cached_m="$(jq -r '.msg_count // empty' <<<"$row")"
|
|
251
|
+
if [[ -n "$cached_m" ]]; then
|
|
252
|
+
m="$cached_m"
|
|
253
|
+
elif [[ -r "$abs" ]]; then
|
|
249
254
|
m="$(wc -l <"$abs" 2>/dev/null | tr -d ' ')"
|
|
250
255
|
[[ -z "$m" ]] && m=0
|
|
251
256
|
fi
|
|
@@ -26,3 +26,4 @@ One sentence describing what this session was trying to accomplish.
|
|
|
26
26
|
Be concise. Prefer bullets over paragraphs. Use the user's terminology (project-specific names, file paths). Do not invent details. If you are uncertain about something, omit it rather than guess.
|
|
27
27
|
|
|
28
28
|
After the entry, add a blank line and then: "Suggested tags: tag1, tag2, tag3"
|
|
29
|
+
Tags must be lowercase kebab-case (regex: `^[a-z0-9][a-z0-9-]{0,31}$`). Examples: `adrs`, `symfony-bot`, `mr-umbrella`, `phase-0`. Never use uppercase letters.
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@procrastivity/clast",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Capture, curate, and surface Claude Code session history across all your projects.",
|
|
5
5
|
"homepage": "https://github.com/procrastivity/clast#readme",
|
|
6
6
|
"bin": {
|
|
7
|
-
"clast": "bin/clast"
|
|
7
|
+
"clast": "bin/clast",
|
|
8
|
+
"clast-plumbing": "bin/clast-plumbing"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"bin/",
|
package/bin/clast-brief
DELETED
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# clast-brief — LLM-powered project briefing without Claude Code.
|
|
3
|
-
#
|
|
4
|
-
# Replicates the /wakeup plugin skill using an OpenAI-compatible
|
|
5
|
-
# chat completions endpoint. Reads curated entries, breadcrumbs,
|
|
6
|
-
# and today's sessions for a project, then synthesizes a briefing.
|
|
7
|
-
#
|
|
8
|
-
# Usage: clast-brief [<project-slug>]
|
|
9
|
-
#
|
|
10
|
-
# If no slug is given, resolves from the current working directory.
|
|
11
|
-
#
|
|
12
|
-
# Required env vars:
|
|
13
|
-
# CLAST_LLM_BASE_URL — e.g. https://api.openai.com/v1
|
|
14
|
-
# CLAST_LLM_API_KEY — bearer token
|
|
15
|
-
# CLAST_LLM_MODEL — e.g. gpt-4o, llama3
|
|
16
|
-
set -euo pipefail
|
|
17
|
-
|
|
18
|
-
CLAST_BRIEF_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
19
|
-
|
|
20
|
-
# ── Helpers ──────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
die() { printf 'clast-brief: %s\n' "$1" >&2; exit "${2:-1}"; }
|
|
23
|
-
warn() { printf 'clast-brief: warning: %s\n' "$1" >&2; }
|
|
24
|
-
info() { printf '%s\n' "$1"; }
|
|
25
|
-
|
|
26
|
-
# ── Preflight ────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
preflight() {
|
|
29
|
-
for tool in clast curl jq; do
|
|
30
|
-
if ! command -v "$tool" >/dev/null 2>&1; then
|
|
31
|
-
die "required tool not found: $tool"
|
|
32
|
-
fi
|
|
33
|
-
done
|
|
34
|
-
|
|
35
|
-
local missing=0
|
|
36
|
-
if [[ -z "${CLAST_LLM_BASE_URL:-}" ]]; then
|
|
37
|
-
warn "CLAST_LLM_BASE_URL not set"; missing=1
|
|
38
|
-
fi
|
|
39
|
-
if [[ -z "${CLAST_LLM_API_KEY:-}" ]]; then
|
|
40
|
-
warn "CLAST_LLM_API_KEY not set"; missing=1
|
|
41
|
-
fi
|
|
42
|
-
if [[ -z "${CLAST_LLM_MODEL:-}" ]]; then
|
|
43
|
-
warn "CLAST_LLM_MODEL not set"; missing=1
|
|
44
|
-
fi
|
|
45
|
-
|
|
46
|
-
if (( missing )); then
|
|
47
|
-
cat >&2 <<'EOF'
|
|
48
|
-
|
|
49
|
-
Set these env vars before running clast-brief:
|
|
50
|
-
|
|
51
|
-
export CLAST_LLM_BASE_URL="https://api.openai.com/v1"
|
|
52
|
-
export CLAST_LLM_API_KEY="sk-..."
|
|
53
|
-
export CLAST_LLM_MODEL="gpt-4o"
|
|
54
|
-
|
|
55
|
-
Or for a local model (ollama, vllm, etc.):
|
|
56
|
-
|
|
57
|
-
export CLAST_LLM_BASE_URL="http://localhost:11434/v1"
|
|
58
|
-
export CLAST_LLM_API_KEY="unused"
|
|
59
|
-
export CLAST_LLM_MODEL="llama3"
|
|
60
|
-
EOF
|
|
61
|
-
exit 1
|
|
62
|
-
fi
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
# ── LLM call ─────────────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
llm_chat() {
|
|
68
|
-
local system_msg="$1"
|
|
69
|
-
local user_msg="$2"
|
|
70
|
-
|
|
71
|
-
local payload
|
|
72
|
-
payload="$(jq -cn \
|
|
73
|
-
--arg model "$CLAST_LLM_MODEL" \
|
|
74
|
-
--arg system "$system_msg" \
|
|
75
|
-
--arg user "$user_msg" \
|
|
76
|
-
'{
|
|
77
|
-
model: $model,
|
|
78
|
-
messages: [
|
|
79
|
-
{role: "system", content: $system},
|
|
80
|
-
{role: "user", content: $user}
|
|
81
|
-
],
|
|
82
|
-
temperature: 0.3
|
|
83
|
-
}')"
|
|
84
|
-
|
|
85
|
-
local response http_code body
|
|
86
|
-
response="$(curl -s -w '\n%{http_code}' \
|
|
87
|
-
"${CLAST_LLM_BASE_URL}/chat/completions" \
|
|
88
|
-
-H "Authorization: Bearer $CLAST_LLM_API_KEY" \
|
|
89
|
-
-H "Content-Type: application/json" \
|
|
90
|
-
-d "$payload" 2>&1)" || true
|
|
91
|
-
|
|
92
|
-
http_code="$(tail -n1 <<<"$response")"
|
|
93
|
-
body="$(sed '$d' <<<"$response")"
|
|
94
|
-
|
|
95
|
-
if [[ "$http_code" != "200" ]]; then
|
|
96
|
-
warn "LLM API returned HTTP $http_code"
|
|
97
|
-
if [[ -n "$body" ]]; then
|
|
98
|
-
local err_msg
|
|
99
|
-
err_msg="$(jq -r '.error.message // .error // .' <<<"$body" 2>/dev/null || echo "$body")"
|
|
100
|
-
warn "$err_msg"
|
|
101
|
-
fi
|
|
102
|
-
return 1
|
|
103
|
-
fi
|
|
104
|
-
|
|
105
|
-
local content
|
|
106
|
-
content="$(jq -r '.choices[0].message.content // empty' <<<"$body" 2>/dev/null)" || {
|
|
107
|
-
warn "failed to parse LLM response"
|
|
108
|
-
return 1
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if [[ -z "$content" ]]; then
|
|
112
|
-
warn "LLM returned empty content"
|
|
113
|
-
return 1
|
|
114
|
-
fi
|
|
115
|
-
|
|
116
|
-
printf '%s' "$content"
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
# ── Prompt template ──────────────────────────────────────────────────
|
|
120
|
-
|
|
121
|
-
resolve_prompt_dir() {
|
|
122
|
-
local dir="$CLAST_BRIEF_DIR/lib/clast/prompts"
|
|
123
|
-
if [[ -d "$dir" ]]; then
|
|
124
|
-
printf '%s' "$dir"
|
|
125
|
-
return
|
|
126
|
-
fi
|
|
127
|
-
local installed
|
|
128
|
-
for installed in /usr/local/lib/clast/prompts "$HOME/.local/lib/clast/prompts"; do
|
|
129
|
-
if [[ -d "$installed" ]]; then
|
|
130
|
-
printf '%s' "$installed"
|
|
131
|
-
return
|
|
132
|
-
fi
|
|
133
|
-
done
|
|
134
|
-
die "cannot find prompts directory (checked $dir and install paths)"
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
load_system_prompt() {
|
|
138
|
-
local prompt_dir
|
|
139
|
-
prompt_dir="$(resolve_prompt_dir)"
|
|
140
|
-
local file="$prompt_dir/brief-system.md"
|
|
141
|
-
if [[ ! -r "$file" ]]; then
|
|
142
|
-
die "system prompt not found: $file"
|
|
143
|
-
fi
|
|
144
|
-
cat "$file"
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
build_user_prompt() {
|
|
148
|
-
local project="$1" entries="$2" breadcrumbs="$3" sessions="$4"
|
|
149
|
-
|
|
150
|
-
local prompt_dir
|
|
151
|
-
prompt_dir="$(resolve_prompt_dir)"
|
|
152
|
-
local template_file="$prompt_dir/brief-user.md"
|
|
153
|
-
|
|
154
|
-
if [[ -r "$template_file" ]]; then
|
|
155
|
-
local template
|
|
156
|
-
template="$(cat "$template_file")"
|
|
157
|
-
template="${template//\{\{project\}\}/${project}}"
|
|
158
|
-
template="${template//\{\{entries\}\}/${entries:-None.}}"
|
|
159
|
-
template="${template//\{\{breadcrumbs\}\}/${breadcrumbs:-None.}}"
|
|
160
|
-
template="${template//\{\{sessions\}\}/${sessions:-None.}}"
|
|
161
|
-
printf '%s' "$template"
|
|
162
|
-
else
|
|
163
|
-
warn "user prompt template not found: $template_file — using inline fallback"
|
|
164
|
-
cat <<EOF
|
|
165
|
-
Project: ${project}
|
|
166
|
-
|
|
167
|
-
Recent curated entries (newest first):
|
|
168
|
-
${entries:-None.}
|
|
169
|
-
|
|
170
|
-
Today's breadcrumbs for this project:
|
|
171
|
-
${breadcrumbs:-None.}
|
|
172
|
-
|
|
173
|
-
Today's session activity for this project:
|
|
174
|
-
${sessions:-None.}
|
|
175
|
-
EOF
|
|
176
|
-
fi
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
# ── Resolve project ──────────────────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
resolve_project() {
|
|
182
|
-
local slug="${1:-}"
|
|
183
|
-
|
|
184
|
-
if [[ -n "$slug" ]]; then
|
|
185
|
-
printf '%s' "$slug"
|
|
186
|
-
return
|
|
187
|
-
fi
|
|
188
|
-
|
|
189
|
-
local resolved
|
|
190
|
-
resolved="$(clast registry resolve "$(pwd)" 2>/dev/null)" || true
|
|
191
|
-
if [[ -n "$resolved" ]]; then
|
|
192
|
-
printf '%s' "$resolved"
|
|
193
|
-
return
|
|
194
|
-
fi
|
|
195
|
-
|
|
196
|
-
die "Not in a registered project. Run \`clast registry add .\` first, or invoke as \`clast-brief <slug>\`."
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
# ── Gather data ──────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
gather_entries() {
|
|
202
|
-
local project="$1"
|
|
203
|
-
local entries_json
|
|
204
|
-
entries_json="$(clast --json entries --project "$project" --limit 5 2>/dev/null)" || true
|
|
205
|
-
|
|
206
|
-
if [[ -z "$entries_json" ]] || [[ "$(jq 'length' <<<"$entries_json" 2>/dev/null)" == "0" ]]; then
|
|
207
|
-
return
|
|
208
|
-
fi
|
|
209
|
-
|
|
210
|
-
local n i entry_meta entry_path entry_body result=""
|
|
211
|
-
n="$(jq 'length' <<<"$entries_json")"
|
|
212
|
-
|
|
213
|
-
for (( i = 0; i < n; i++ )); do
|
|
214
|
-
entry_meta="$(jq -c ".[$i]" <<<"$entries_json")"
|
|
215
|
-
entry_path="$(jq -r '.path' <<<"$entry_meta")"
|
|
216
|
-
|
|
217
|
-
entry_body="$(clast entries read "$entry_path" 2>/dev/null)" || true
|
|
218
|
-
if [[ -z "$entry_body" ]]; then
|
|
219
|
-
local title date
|
|
220
|
-
title="$(jq -r '.title // "untitled"' <<<"$entry_meta")"
|
|
221
|
-
date="$(jq -r '.date' <<<"$entry_meta")"
|
|
222
|
-
entry_body="# $date — $title (body not available)"
|
|
223
|
-
fi
|
|
224
|
-
|
|
225
|
-
if [[ -n "$result" ]]; then
|
|
226
|
-
result="${result}
|
|
227
|
-
|
|
228
|
-
---
|
|
229
|
-
|
|
230
|
-
"
|
|
231
|
-
fi
|
|
232
|
-
result="${result}${entry_body}"
|
|
233
|
-
done
|
|
234
|
-
|
|
235
|
-
printf '%s' "$result"
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
gather_breadcrumbs() {
|
|
239
|
-
local project="$1"
|
|
240
|
-
clast breadcrumb --read --project "$project" --day today 2>/dev/null || true
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
gather_sessions() {
|
|
244
|
-
local project="$1"
|
|
245
|
-
local sessions_json
|
|
246
|
-
sessions_json="$(clast --json sessions --day today --project "$project" 2>/dev/null)" || true
|
|
247
|
-
|
|
248
|
-
if [[ -z "$sessions_json" ]] || [[ "$(jq 'length' <<<"$sessions_json" 2>/dev/null)" == "0" ]]; then
|
|
249
|
-
return
|
|
250
|
-
fi
|
|
251
|
-
|
|
252
|
-
local n
|
|
253
|
-
n="$(jq 'length' <<<"$sessions_json")"
|
|
254
|
-
|
|
255
|
-
if (( n > 5 )); then
|
|
256
|
-
local latest_start
|
|
257
|
-
latest_start="$(jq -r 'sort_by(.start) | last | .start // ""' <<<"$sessions_json")"
|
|
258
|
-
printf 'Worked %d sessions today, most recent at %s.' "$n" "${latest_start:11:5}"
|
|
259
|
-
return
|
|
260
|
-
fi
|
|
261
|
-
|
|
262
|
-
jq -r '
|
|
263
|
-
sort_by(.start) | .[] |
|
|
264
|
-
"\(.start[11:16]) start: \(if .branch and .branch != "null" then .branch else "no branch" end), \(.msg_count_approx) messages"
|
|
265
|
-
' <<<"$sessions_json" 2>/dev/null || true
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
# ── Main ─────────────────────────────────────────────────────────────
|
|
269
|
-
|
|
270
|
-
main() {
|
|
271
|
-
preflight
|
|
272
|
-
|
|
273
|
-
local project
|
|
274
|
-
project="$(resolve_project "${1:-}")"
|
|
275
|
-
info "Briefing for project: $project"
|
|
276
|
-
|
|
277
|
-
info "Gathering context..."
|
|
278
|
-
|
|
279
|
-
local entries breadcrumbs sessions
|
|
280
|
-
entries="$(gather_entries "$project")"
|
|
281
|
-
breadcrumbs="$(gather_breadcrumbs "$project")"
|
|
282
|
-
sessions="$(gather_sessions "$project")"
|
|
283
|
-
|
|
284
|
-
if [[ -z "$entries" && -z "$breadcrumbs" && -z "$sessions" ]]; then
|
|
285
|
-
info "No curated entries, breadcrumbs, or sessions for \`$project\`."
|
|
286
|
-
info "Run \`clast-wake\` to curate recent sessions first, or run \`clast sessions --project $project\` to see what's available."
|
|
287
|
-
exit 0
|
|
288
|
-
fi
|
|
289
|
-
|
|
290
|
-
local system_prompt user_prompt
|
|
291
|
-
system_prompt="$(load_system_prompt)"
|
|
292
|
-
user_prompt="$(build_user_prompt "$project" "$entries" "$breadcrumbs" "$sessions")"
|
|
293
|
-
|
|
294
|
-
info "Synthesizing briefing..."
|
|
295
|
-
printf '\n'
|
|
296
|
-
|
|
297
|
-
local briefing
|
|
298
|
-
if ! briefing="$(llm_chat "$system_prompt" "$user_prompt")"; then
|
|
299
|
-
die "LLM call failed"
|
|
300
|
-
fi
|
|
301
|
-
|
|
302
|
-
printf '%s\n' "$briefing"
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
main "$@"
|