@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.
@@ -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
- if [[ -r "$abs_path" ]]; then
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
- # Most-recent manifest line per session_id within window.
211
- declare -A latest_line=()
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
- for sid in "${!latest_line[@]}"; do
231
- line="${latest_line[$sid]}"
232
- snapshot="$(jq -r '.snapshot' <<<"$line")"
233
- day_bucket="$(jq -r '.day_bucket' <<<"$line")"
234
- mtime="$(jq -r '.source_mtime' <<<"$line")"
235
- seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot")"
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 [[ -r "$abs_path" ]]; then
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
- if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
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
- local stale=false
271
- if [[ -d "$entries_dir" ]]; then
272
- local entry_matches
273
- entry_matches="$(grep -l "session_id: $sid" "$entries_dir"/*.md 2>/dev/null)" || true
274
- if [[ -n "$entry_matches" ]]; then
275
- curated=true
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
- rows_json="$(printf '%s\n' "${rows[@]}" | jq -cs 'sort_by(.start)')"
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
- 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)"
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
- local abs="$journal_dir/$snapshot" m=0
248
- if [[ -r "$abs" ]]; then
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.2",
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 "$@"