@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.
- package/.claude-plugin/plugin.json +11 -0
- package/.claude-plugin/skills/day-wakeup/SKILL.md +194 -0
- package/.claude-plugin/skills/wakeup/SKILL.md +115 -0
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/bin/.gitkeep +0 -0
- package/bin/clast +172 -0
- package/examples/config/config.toml.sample +32 -0
- package/examples/cron/clast-snapshot.service +10 -0
- package/examples/cron/clast-snapshot.timer +15 -0
- package/examples/cron/crontab.sample +16 -0
- package/examples/workflows/morning-briefing.md +137 -0
- package/hooks/hooks.json +8 -0
- package/hooks/snapshot.sh +15 -0
- package/lib/clast/.gitkeep +0 -0
- package/lib/clast/clast-decode-lib.bash +184 -0
- package/lib/clast/clast-lib.bash +233 -0
- package/lib/clast/clast-manifest-lib.bash +232 -0
- package/lib/clast/clast-registry-lib.bash +252 -0
- package/lib/clast/clast-subcommands/.gitkeep +0 -0
- package/lib/clast/clast-subcommands/breadcrumb.bash +454 -0
- package/lib/clast/clast-subcommands/doctor.bash +607 -0
- package/lib/clast/clast-subcommands/entries.bash +697 -0
- package/lib/clast/clast-subcommands/projects.bash +309 -0
- package/lib/clast/clast-subcommands/registry.bash +207 -0
- package/lib/clast/clast-subcommands/sessions.bash +275 -0
- package/lib/clast/clast-subcommands/show.bash +283 -0
- package/lib/clast/clast-subcommands/snapshot.bash +294 -0
- package/lib/clast/clast-subcommands/stats.bash +354 -0
- package/lib/clast/clast-subcommands/whereami.bash +108 -0
- package/package.json +39 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# clast-subcommands/snapshot.bash — `clast snapshot`.
|
|
2
|
+
#
|
|
3
|
+
# Walks $(clast_projects_dir)/<segment>/<uuid>.jsonl, copies each new or
|
|
4
|
+
# modified session into $(clast_journal_dir)/transcripts/<day>/<segment>/,
|
|
5
|
+
# and appends one manifest line per capture. Idempotent: re-running a file
|
|
6
|
+
# whose (session_id, source_mtime) is already in the manifest is a no-op.
|
|
7
|
+
# shellcheck shell=bash
|
|
8
|
+
# shellcheck source=lib/clast/clast-lib.bash
|
|
9
|
+
# shellcheck source=lib/clast/clast-manifest-lib.bash
|
|
10
|
+
# shellcheck source=lib/clast/clast-decode-lib.bash
|
|
11
|
+
# shellcheck source=lib/clast/clast-registry-lib.bash
|
|
12
|
+
|
|
13
|
+
_clast_snapshot_usage() {
|
|
14
|
+
cat <<'EOF'
|
|
15
|
+
Usage: clast snapshot [--dry-run] [--since TIMESTAMP] [--include-segment SEG]
|
|
16
|
+
|
|
17
|
+
Capture new Claude Code transcripts into the journal.
|
|
18
|
+
|
|
19
|
+
Flags:
|
|
20
|
+
--dry-run Preview only; do not copy or write manifest.
|
|
21
|
+
--since TIMESTAMP Skip sources with mtime < TIMESTAMP (ISO 8601 or
|
|
22
|
+
any string GNU `date -d` understands; e.g. -1d).
|
|
23
|
+
--include-segment SEG Limit scan to SEG (repeatable). Value must start
|
|
24
|
+
with '-' (segments always do).
|
|
25
|
+
-h, --help Print this usage and exit.
|
|
26
|
+
EOF
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# _clast_snapshot_bucket_for_epoch <epoch>
|
|
30
|
+
# Mirror of clast_today's cutoff math against an arbitrary epoch. Local
|
|
31
|
+
# time per docs/overview.md#conventions.
|
|
32
|
+
_clast_snapshot_bucket_for_epoch() {
|
|
33
|
+
local epoch="$1"
|
|
34
|
+
local cutoff="${CLAST_DAY_CUTOFF:-04:00}"
|
|
35
|
+
local h="${cutoff%%:*}" m="${cutoff##*:}"
|
|
36
|
+
h=$((10#$h))
|
|
37
|
+
m=$((10#$m))
|
|
38
|
+
local off=$((h * 3600 + m * 60))
|
|
39
|
+
# GNU `date -d` — BSD date not supported, per overview.md.
|
|
40
|
+
date -d "@$((epoch - off))" +%Y-%m-%d
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clast_cmd_snapshot() {
|
|
44
|
+
local dry_run=0
|
|
45
|
+
local since_epoch=""
|
|
46
|
+
local -a include_segments=()
|
|
47
|
+
|
|
48
|
+
while [[ $# -gt 0 ]]; do
|
|
49
|
+
case "$1" in
|
|
50
|
+
--dry-run)
|
|
51
|
+
dry_run=1; shift ;;
|
|
52
|
+
--since)
|
|
53
|
+
if [[ $# -lt 2 ]]; then
|
|
54
|
+
clast_log_error "snapshot: --since requires a value"; return 2
|
|
55
|
+
fi
|
|
56
|
+
if ! since_epoch="$(date -d "$2" +%s 2>/dev/null)" || [[ -z "$since_epoch" ]]; then
|
|
57
|
+
clast_log_error "snapshot: cannot parse --since '$2'"; return 2
|
|
58
|
+
fi
|
|
59
|
+
shift 2 ;;
|
|
60
|
+
--since=*)
|
|
61
|
+
local _v="${1#*=}"
|
|
62
|
+
if ! since_epoch="$(date -d "$_v" +%s 2>/dev/null)" || [[ -z "$since_epoch" ]]; then
|
|
63
|
+
clast_log_error "snapshot: cannot parse --since '$_v'"; return 2
|
|
64
|
+
fi
|
|
65
|
+
shift ;;
|
|
66
|
+
--include-segment)
|
|
67
|
+
if [[ $# -lt 2 ]]; then
|
|
68
|
+
clast_log_error "snapshot: --include-segment requires a value"; return 2
|
|
69
|
+
fi
|
|
70
|
+
if [[ "$2" != -* ]]; then
|
|
71
|
+
clast_log_error "snapshot: --include-segment value must start with '-' (got '$2')"; return 2
|
|
72
|
+
fi
|
|
73
|
+
include_segments+=("$2"); shift 2 ;;
|
|
74
|
+
--include-segment=*)
|
|
75
|
+
local _v="${1#*=}"
|
|
76
|
+
if [[ "$_v" != -* ]]; then
|
|
77
|
+
clast_log_error "snapshot: --include-segment value must start with '-' (got '$_v')"; return 2
|
|
78
|
+
fi
|
|
79
|
+
include_segments+=("$_v"); shift ;;
|
|
80
|
+
--json)
|
|
81
|
+
# Accept the global flag positionally as well, mirroring registry.
|
|
82
|
+
# Lets `clast snapshot --json` work alongside `clast --json snapshot`.
|
|
83
|
+
export CLAST_JSON=1; shift ;;
|
|
84
|
+
-h|--help)
|
|
85
|
+
_clast_snapshot_usage; return 0 ;;
|
|
86
|
+
*)
|
|
87
|
+
clast_log_error "snapshot: unknown flag '$1'"; return 2 ;;
|
|
88
|
+
esac
|
|
89
|
+
done
|
|
90
|
+
|
|
91
|
+
# Manifest precondition: cron-mode safety. clast_manifest_iterate uses
|
|
92
|
+
# fromjson? today, so most corruption is swallowed silently — this guard
|
|
93
|
+
# is the documented hook for a future strict-iterate contract (step plan
|
|
94
|
+
# task 3). Leaving it in keeps the exit-4 path wired the moment iterate
|
|
95
|
+
# learns to surface corruption.
|
|
96
|
+
local _it_rc=0
|
|
97
|
+
clast_manifest_iterate 'true' >/dev/null 2>&1 || _it_rc=$?
|
|
98
|
+
if (( _it_rc != 0 )); then
|
|
99
|
+
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
100
|
+
printf '{"error":"manifest is corrupt","code":4}\n'
|
|
101
|
+
else
|
|
102
|
+
clast_log_error "snapshot: manifest is corrupt; refusing to write"
|
|
103
|
+
fi
|
|
104
|
+
return 4
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
local projects_dir journal_dir
|
|
108
|
+
projects_dir="$(clast_projects_dir)"
|
|
109
|
+
journal_dir="$(clast_journal_dir)"
|
|
110
|
+
|
|
111
|
+
local skipped=0
|
|
112
|
+
local total_bytes=0
|
|
113
|
+
local -a captured_lines=()
|
|
114
|
+
local -a error_lines=()
|
|
115
|
+
local -a captured_segments=()
|
|
116
|
+
|
|
117
|
+
# TODO(v1.1): parallel capture across segments.
|
|
118
|
+
if [[ -d "$projects_dir" ]]; then
|
|
119
|
+
local source segment session_id mtime_epoch mtime_iso source_size
|
|
120
|
+
local first_ts ts_epoch day_bucket
|
|
121
|
+
local dest_rel dest dest_dir tmp
|
|
122
|
+
while IFS= read -r -d '' source; do
|
|
123
|
+
segment="$(basename "$(dirname "$source")")"
|
|
124
|
+
|
|
125
|
+
if (( ${#include_segments[@]} > 0 )); then
|
|
126
|
+
local _hit=0 _seg
|
|
127
|
+
for _seg in "${include_segments[@]}"; do
|
|
128
|
+
if [[ "$_seg" == "$segment" ]]; then _hit=1; break; fi
|
|
129
|
+
done
|
|
130
|
+
if (( _hit == 0 )); then
|
|
131
|
+
skipped=$((skipped + 1))
|
|
132
|
+
continue
|
|
133
|
+
fi
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
if ! mtime_epoch="$(stat -c %Y "$source" 2>/dev/null)" || [[ -z "$mtime_epoch" ]]; then
|
|
137
|
+
error_lines+=("$(jq -cn --arg f "$source" --arg r "stat failed" '{file:$f,reason:$r}')")
|
|
138
|
+
continue
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
if [[ -n "$since_epoch" ]] && (( mtime_epoch < since_epoch )); then
|
|
142
|
+
skipped=$((skipped + 1))
|
|
143
|
+
continue
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
session_id="$(basename "$source" .jsonl)"
|
|
147
|
+
if ! [[ "$session_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
|
148
|
+
error_lines+=("$(jq -cn --arg f "$source" --arg r "non-uuid filename" '{file:$f,reason:$r}')")
|
|
149
|
+
continue
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
mtime_iso="$(date -u -d "@$mtime_epoch" +%Y-%m-%dT%H:%M:%SZ)"
|
|
153
|
+
source_size="$(stat -c %s "$source" 2>/dev/null || echo 0)"
|
|
154
|
+
|
|
155
|
+
first_ts="$(head -n1 "$source" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
156
|
+
if [[ -n "$first_ts" ]] && ts_epoch="$(date -d "$first_ts" +%s 2>/dev/null)" && [[ -n "$ts_epoch" ]]; then
|
|
157
|
+
day_bucket="$(_clast_snapshot_bucket_for_epoch "$ts_epoch")"
|
|
158
|
+
else
|
|
159
|
+
# --verbose only: a malformed first line shouldn't spam cron/hook logs.
|
|
160
|
+
if [[ -n "${CLAST_VERBOSE:-}" ]]; then
|
|
161
|
+
clast_log_warn "snapshot: '$source' missing first-line timestamp; falling back to mtime"
|
|
162
|
+
fi
|
|
163
|
+
day_bucket="$(_clast_snapshot_bucket_for_epoch "$mtime_epoch")"
|
|
164
|
+
fi
|
|
165
|
+
|
|
166
|
+
# Dedup on (session_id, source_mtime). Mtime — not ctime or size —
|
|
167
|
+
# is the manifest's "most recent line wins" key; Claude Code's
|
|
168
|
+
# writer bumps mtime whenever a session grows.
|
|
169
|
+
if clast_manifest_has_capture "$session_id" "$mtime_iso"; then
|
|
170
|
+
skipped=$((skipped + 1))
|
|
171
|
+
continue
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
dest_rel="transcripts/$day_bucket/$segment/$session_id.jsonl"
|
|
175
|
+
dest="$journal_dir/$dest_rel"
|
|
176
|
+
|
|
177
|
+
if (( dry_run == 0 )); then
|
|
178
|
+
dest_dir="$(dirname "$dest")"
|
|
179
|
+
if ! mkdir -p "$dest_dir"; then
|
|
180
|
+
error_lines+=("$(jq -cn --arg f "$source" --arg r "mkdir failed" '{file:$f,reason:$r}')")
|
|
181
|
+
continue
|
|
182
|
+
fi
|
|
183
|
+
if ! tmp="$(mktemp "$dest.copy.XXXXXX" 2>/dev/null)"; then
|
|
184
|
+
error_lines+=("$(jq -cn --arg f "$source" --arg r "mktemp failed" '{file:$f,reason:$r}')")
|
|
185
|
+
continue
|
|
186
|
+
fi
|
|
187
|
+
# cp+mv, not clast_atomic_write: the helper takes content as a
|
|
188
|
+
# string and would slurp a multi-megabyte session into a bash var.
|
|
189
|
+
if ! cp "$source" "$tmp" 2>/dev/null; then
|
|
190
|
+
rm -f "$tmp"
|
|
191
|
+
error_lines+=("$(jq -cn --arg f "$source" --arg r "copy failed" '{file:$f,reason:$r}')")
|
|
192
|
+
continue
|
|
193
|
+
fi
|
|
194
|
+
if ! mv -f "$tmp" "$dest" 2>/dev/null; then
|
|
195
|
+
rm -f "$tmp"
|
|
196
|
+
error_lines+=("$(jq -cn --arg f "$source" --arg r "rename failed" '{file:$f,reason:$r}')")
|
|
197
|
+
continue
|
|
198
|
+
fi
|
|
199
|
+
# Invariant: a manifest line implies the dest file exists. If the
|
|
200
|
+
# append fails the dest is an orphan; doctor (step 10) will reap
|
|
201
|
+
# 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
|
|
203
|
+
error_lines+=("$(jq -cn --arg f "$source" --arg r "manifest append failed" '{file:$f,reason:$r}')")
|
|
204
|
+
continue
|
|
205
|
+
fi
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
captured_lines+=("$(jq -cn \
|
|
209
|
+
--arg sid "$session_id" \
|
|
210
|
+
--arg src "$source" \
|
|
211
|
+
--arg snap "$dest_rel" \
|
|
212
|
+
--argjson b "$source_size" \
|
|
213
|
+
--arg day "$day_bucket" \
|
|
214
|
+
'{session_id:$sid, source:$src, snapshot:$snap, bytes:$b, day_bucket:$day}')")
|
|
215
|
+
captured_segments+=("$segment")
|
|
216
|
+
total_bytes=$((total_bytes + source_size))
|
|
217
|
+
done < <(find "$projects_dir" -mindepth 2 -maxdepth 2 -type f -name '*.jsonl' -print0 2>/dev/null | sort -z)
|
|
218
|
+
fi
|
|
219
|
+
|
|
220
|
+
_clast_snapshot_emit_summary "$dry_run" "$skipped" "$total_bytes" \
|
|
221
|
+
captured_lines error_lines captured_segments
|
|
222
|
+
|
|
223
|
+
if (( ${#error_lines[@]} > 0 )); then
|
|
224
|
+
return 1
|
|
225
|
+
fi
|
|
226
|
+
return 0
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# _clast_snapshot_emit_summary <dry_run> <skipped> <total_bytes> <caps-arr-name> <errs-arr-name> <segs-arr-name>
|
|
230
|
+
_clast_snapshot_emit_summary() {
|
|
231
|
+
local dry_run="$1" skipped="$2" total_bytes="$3"
|
|
232
|
+
local -n _caps="$4"
|
|
233
|
+
local -n _errs="$5"
|
|
234
|
+
local -n _segs="$6"
|
|
235
|
+
|
|
236
|
+
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
237
|
+
local caps_json='[]' errs_json='[]'
|
|
238
|
+
if (( ${#_caps[@]} > 0 )); then
|
|
239
|
+
caps_json="$(printf '%s\n' "${_caps[@]}" | jq -cs '.')"
|
|
240
|
+
fi
|
|
241
|
+
if (( ${#_errs[@]} > 0 )); then
|
|
242
|
+
errs_json="$(printf '%s\n' "${_errs[@]}" | jq -cs '.')"
|
|
243
|
+
fi
|
|
244
|
+
jq -cn --argjson c "$caps_json" --argjson e "$errs_json" --argjson s "$skipped" \
|
|
245
|
+
'{captured:$c, skipped:$s, errors:$e}'
|
|
246
|
+
return 0
|
|
247
|
+
fi
|
|
248
|
+
|
|
249
|
+
# Silent no-op: load-bearing for the SessionStart hook (step 11). Re-read
|
|
250
|
+
# docs/cli-contract.md#clast-snapshot before changing this.
|
|
251
|
+
if (( ${#_caps[@]} == 0 && ${#_errs[@]} == 0 )); then
|
|
252
|
+
return 0
|
|
253
|
+
fi
|
|
254
|
+
|
|
255
|
+
if (( dry_run == 1 )); then
|
|
256
|
+
local i seg sid day
|
|
257
|
+
for (( i = 0; i < ${#_caps[@]}; i++ )); do
|
|
258
|
+
seg="${_segs[$i]}"
|
|
259
|
+
sid="$(jq -r '.session_id' <<<"${_caps[$i]}")"
|
|
260
|
+
day="$(jq -r '.day_bucket' <<<"${_caps[$i]}")"
|
|
261
|
+
printf 'would capture: %s/%s → %s\n' "$seg" "$sid" "$day" >&2
|
|
262
|
+
done
|
|
263
|
+
elif (( ${#_caps[@]} > 0 )) && [[ -z "${CLAST_QUIET:-}" ]]; then
|
|
264
|
+
declare -A counts=()
|
|
265
|
+
local i seg label slug
|
|
266
|
+
for (( i = 0; i < ${#_caps[@]}; i++ )); do
|
|
267
|
+
seg="${_segs[$i]}"
|
|
268
|
+
if slug="$(clast_registry_resolve "$seg" 2>/dev/null)" && [[ -n "$slug" ]]; then
|
|
269
|
+
label="$slug"
|
|
270
|
+
else
|
|
271
|
+
label="$seg"
|
|
272
|
+
fi
|
|
273
|
+
counts["$label"]=$(( ${counts["$label"]:-0} + 1 ))
|
|
274
|
+
done
|
|
275
|
+
|
|
276
|
+
local mb
|
|
277
|
+
mb="$(awk -v b="$total_bytes" 'BEGIN{printf "%.1f", b/1048576}')"
|
|
278
|
+
printf 'Captured %d session(s) across %d project(s) (%s MB).\n' \
|
|
279
|
+
"${#_caps[@]}" "${#counts[@]}" "$mb"
|
|
280
|
+
|
|
281
|
+
local key cnt
|
|
282
|
+
while IFS=$'\t' read -r key cnt; do
|
|
283
|
+
printf ' %s: %s session(s)\n' "$key" "$cnt"
|
|
284
|
+
done < <(
|
|
285
|
+
for key in "${!counts[@]}"; do
|
|
286
|
+
printf '%s\t%s\n' "$key" "${counts[$key]}"
|
|
287
|
+
done | sort -t$'\t' -k2,2nr -k1,1
|
|
288
|
+
)
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
if (( ${#_errs[@]} > 0 )); then
|
|
292
|
+
printf '%d error(s); see --json for details.\n' "${#_errs[@]}" >&2
|
|
293
|
+
fi
|
|
294
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# clast-subcommands/stats.bash — `clast stats`.
|
|
2
|
+
#
|
|
3
|
+
# Read-only summary of journal activity over a date window using manifest
|
|
4
|
+
# + filesystem stat only. No JSONL body parsing. See
|
|
5
|
+
# docs/cli-contract.md#clast-stats.
|
|
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_stats_usage() {
|
|
13
|
+
cat <<'EOF'
|
|
14
|
+
Usage: clast stats [--day DATE] [--since DATE] [--until DATE] [--project SLUG]
|
|
15
|
+
|
|
16
|
+
Summarize journal activity over a date window (default: today).
|
|
17
|
+
|
|
18
|
+
Flags:
|
|
19
|
+
--day DATE Single-day window. Mutually exclusive with --since/--until.
|
|
20
|
+
--since DATE Start of range (inclusive).
|
|
21
|
+
--until DATE End of range (inclusive).
|
|
22
|
+
--project SLUG Filter to one registered project slug.
|
|
23
|
+
-h, --help Print this usage and exit.
|
|
24
|
+
|
|
25
|
+
DATE accepts ISO (YYYY-MM-DD), `today`, `yesterday`, `last-week`,
|
|
26
|
+
`-Nd`, or `-Nw`. See docs/cli-contract.md#date-parsing.
|
|
27
|
+
EOF
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_clast_stats_err() {
|
|
31
|
+
local msg="$1" code="${2:-2}"
|
|
32
|
+
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
33
|
+
jq -cn --arg m "$msg" --argjson c "$code" '{error:$m, code:$c}'
|
|
34
|
+
else
|
|
35
|
+
clast_log_error "stats: $msg"
|
|
36
|
+
fi
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# _clast_stats_human_bytes <int>
|
|
40
|
+
# Render bytes as "<n> B" / "<x.x> KB" / "<x.x> MB" / "<x.x> GB".
|
|
41
|
+
# Base-2 math, base-10 labels — matches snapshot summary from step 06.
|
|
42
|
+
_clast_stats_human_bytes() {
|
|
43
|
+
local b="$1"
|
|
44
|
+
if (( b < 1024 )); then
|
|
45
|
+
printf '%s B' "$b"
|
|
46
|
+
return 0
|
|
47
|
+
fi
|
|
48
|
+
awk -v b="$b" 'BEGIN {
|
|
49
|
+
units[0] = "KB"; units[1] = "MB"; units[2] = "GB"; units[3] = "TB"
|
|
50
|
+
i = 0
|
|
51
|
+
v = b / 1024
|
|
52
|
+
while (v >= 1024 && i < 3) { v = v / 1024; i++ }
|
|
53
|
+
printf "%.1f %s", v, units[i]
|
|
54
|
+
}'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
clast_cmd_stats() {
|
|
58
|
+
local day_input="" since_input="" until_input=""
|
|
59
|
+
local day_filter="" since_date="" until_date=""
|
|
60
|
+
local project_filter=""
|
|
61
|
+
|
|
62
|
+
while [[ $# -gt 0 ]]; do
|
|
63
|
+
case "$1" in
|
|
64
|
+
--day)
|
|
65
|
+
if [[ $# -lt 2 ]]; then _clast_stats_err "--day requires a value"; return 2; fi
|
|
66
|
+
day_input="$2"
|
|
67
|
+
if ! day_filter="$(clast_parse_date "$2" 2>/dev/null)"; then
|
|
68
|
+
_clast_stats_err "invalid date '$2'"; return 2
|
|
69
|
+
fi
|
|
70
|
+
shift 2 ;;
|
|
71
|
+
--day=*)
|
|
72
|
+
day_input="${1#*=}"
|
|
73
|
+
if ! day_filter="$(clast_parse_date "$day_input" 2>/dev/null)"; then
|
|
74
|
+
_clast_stats_err "invalid date '$day_input'"; return 2
|
|
75
|
+
fi
|
|
76
|
+
shift ;;
|
|
77
|
+
--since)
|
|
78
|
+
if [[ $# -lt 2 ]]; then _clast_stats_err "--since requires a value"; return 2; fi
|
|
79
|
+
since_input="$2"
|
|
80
|
+
if ! since_date="$(clast_parse_date "$2" 2>/dev/null)"; then
|
|
81
|
+
_clast_stats_err "invalid date '$2'"; return 2
|
|
82
|
+
fi
|
|
83
|
+
shift 2 ;;
|
|
84
|
+
--since=*)
|
|
85
|
+
since_input="${1#*=}"
|
|
86
|
+
if ! since_date="$(clast_parse_date "$since_input" 2>/dev/null)"; then
|
|
87
|
+
_clast_stats_err "invalid date '$since_input'"; return 2
|
|
88
|
+
fi
|
|
89
|
+
shift ;;
|
|
90
|
+
--until)
|
|
91
|
+
if [[ $# -lt 2 ]]; then _clast_stats_err "--until requires a value"; return 2; fi
|
|
92
|
+
until_input="$2"
|
|
93
|
+
if ! until_date="$(clast_parse_date "$2" 2>/dev/null)"; then
|
|
94
|
+
_clast_stats_err "invalid date '$2'"; return 2
|
|
95
|
+
fi
|
|
96
|
+
shift 2 ;;
|
|
97
|
+
--until=*)
|
|
98
|
+
until_input="${1#*=}"
|
|
99
|
+
if ! until_date="$(clast_parse_date "$until_input" 2>/dev/null)"; then
|
|
100
|
+
_clast_stats_err "invalid date '$until_input'"; return 2
|
|
101
|
+
fi
|
|
102
|
+
shift ;;
|
|
103
|
+
--project)
|
|
104
|
+
if [[ $# -lt 2 ]]; then _clast_stats_err "--project requires a value"; return 2; fi
|
|
105
|
+
project_filter="$2"; shift 2 ;;
|
|
106
|
+
--project=*)
|
|
107
|
+
project_filter="${1#*=}"; shift ;;
|
|
108
|
+
-h|--help)
|
|
109
|
+
_clast_stats_usage; return 0 ;;
|
|
110
|
+
--) shift; break ;;
|
|
111
|
+
-*) _clast_stats_err "unknown flag '$1'"; return 2 ;;
|
|
112
|
+
*) _clast_stats_err "unexpected positional '$1'"; return 2 ;;
|
|
113
|
+
esac
|
|
114
|
+
done
|
|
115
|
+
|
|
116
|
+
if [[ -n "$day_filter" && ( -n "$since_date" || -n "$until_date" ) ]]; then
|
|
117
|
+
_clast_stats_err "--day cannot be combined with --since or --until"
|
|
118
|
+
return 2
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
local today
|
|
122
|
+
today="$(clast_today)"
|
|
123
|
+
local label_suffix="" json_label=""
|
|
124
|
+
local window_start window_end
|
|
125
|
+
if [[ -n "$day_filter" ]]; then
|
|
126
|
+
window_start="$day_filter"
|
|
127
|
+
window_end="$day_filter"
|
|
128
|
+
if [[ "$day_filter" == "$today" ]]; then
|
|
129
|
+
label_suffix=" (today)"; json_label="today"
|
|
130
|
+
else
|
|
131
|
+
local yest
|
|
132
|
+
yest="$(clast_parse_date yesterday)"
|
|
133
|
+
if [[ "$day_filter" == "$yest" ]]; then
|
|
134
|
+
label_suffix=" (yesterday)"; json_label="yesterday"
|
|
135
|
+
fi
|
|
136
|
+
fi
|
|
137
|
+
elif [[ -z "$since_date" && -z "$until_date" ]]; then
|
|
138
|
+
window_start="$today"
|
|
139
|
+
window_end="$today"
|
|
140
|
+
label_suffix=" (today)"; json_label="today"
|
|
141
|
+
else
|
|
142
|
+
if [[ -z "$since_date" ]]; then
|
|
143
|
+
since_date="1970-01-01"
|
|
144
|
+
fi
|
|
145
|
+
local until_was_default=0
|
|
146
|
+
if [[ -z "$until_date" ]]; then
|
|
147
|
+
until_date="$today"
|
|
148
|
+
until_was_default=1
|
|
149
|
+
fi
|
|
150
|
+
window_start="$since_date"
|
|
151
|
+
window_end="$until_date"
|
|
152
|
+
if (( until_was_default == 1 )); then
|
|
153
|
+
label_suffix=" (through today)"; json_label="through_today"
|
|
154
|
+
fi
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
if ! [[ "$window_start" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
|
158
|
+
_clast_stats_err "invalid date '$window_start'"; return 2
|
|
159
|
+
fi
|
|
160
|
+
if ! [[ "$window_end" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
|
161
|
+
_clast_stats_err "invalid date '$window_end'"; return 2
|
|
162
|
+
fi
|
|
163
|
+
if [[ "$window_start" > "$window_end" ]]; then
|
|
164
|
+
_clast_stats_err "--since must be <= --until"
|
|
165
|
+
return 2
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
# Validate --project slug exists in registry.
|
|
169
|
+
if [[ -n "$project_filter" ]]; then
|
|
170
|
+
local reg_json slug_match
|
|
171
|
+
reg_json="$(clast_registry_list_json)"
|
|
172
|
+
slug_match="$(jq -r --arg s "$project_filter" \
|
|
173
|
+
'map(select(.slug == $s)) | .[0].slug // empty' <<<"$reg_json")"
|
|
174
|
+
if [[ -z "$slug_match" ]]; then
|
|
175
|
+
_clast_stats_err "unknown project slug '$project_filter'" 1
|
|
176
|
+
return 1
|
|
177
|
+
fi
|
|
178
|
+
fi
|
|
179
|
+
|
|
180
|
+
if [[ -n "${CLAST_VERBOSE:-}" ]]; then
|
|
181
|
+
clast_log_info "stats: window $window_start..$window_end"
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
# Stream manifest rows in window, reduce to most-recent per session_id.
|
|
185
|
+
local journal_dir
|
|
186
|
+
journal_dir="$(clast_journal_dir)"
|
|
187
|
+
|
|
188
|
+
local filter
|
|
189
|
+
if [[ "$window_start" == "$window_end" ]]; then
|
|
190
|
+
filter='.day_bucket == "'"$window_start"'"'
|
|
191
|
+
else
|
|
192
|
+
filter='.day_bucket >= "'"$window_start"'" and .day_bucket <= "'"$window_end"'"'
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
local rows_json='[]'
|
|
196
|
+
local raw
|
|
197
|
+
raw="$(clast_manifest_iterate "$filter" | jq -cs \
|
|
198
|
+
'group_by(.session_id) | map(max_by(.captured_at))')"
|
|
199
|
+
if [[ -n "$raw" ]]; then
|
|
200
|
+
rows_json="$raw"
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
local n
|
|
204
|
+
n="$(jq 'length' <<<"$rows_json")"
|
|
205
|
+
|
|
206
|
+
# Resolve project slug per row, apply --project filter.
|
|
207
|
+
local -A slug_counts=()
|
|
208
|
+
local i row source snapshot seg slug bytes_sum=0 msgs_sum=0
|
|
209
|
+
local n_sessions=0
|
|
210
|
+
for (( i = 0; i < n; i++ )); do
|
|
211
|
+
row="$(jq -c ".[$i]" <<<"$rows_json")"
|
|
212
|
+
source="$(jq -r '.source' <<<"$row")"
|
|
213
|
+
snapshot="$(jq -r '.snapshot' <<<"$row")"
|
|
214
|
+
seg="$(awk -F/ 'NR==1{print $3}' <<<"$snapshot")"
|
|
215
|
+
|
|
216
|
+
slug=""
|
|
217
|
+
if [[ "$source" != "null" && -n "$source" ]]; then
|
|
218
|
+
# Prefer the manifest source (authoritative under path moves) — try
|
|
219
|
+
# dirname(source) since the registry stores project directories while
|
|
220
|
+
# source carries the .jsonl filename. Fall back to the snapshot
|
|
221
|
+
# segment when the source path is not registered.
|
|
222
|
+
local source_dir
|
|
223
|
+
source_dir="$(dirname "$source")"
|
|
224
|
+
if ! slug="$(clast_registry_resolve "$source_dir" 2>/dev/null)" || [[ -z "$slug" ]]; then
|
|
225
|
+
if ! slug="$(clast_registry_resolve "$seg" 2>/dev/null)" || [[ -z "$slug" ]]; then
|
|
226
|
+
slug="$seg"
|
|
227
|
+
fi
|
|
228
|
+
fi
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
if [[ -n "$project_filter" ]]; then
|
|
232
|
+
if [[ "$slug" != "$project_filter" ]]; then
|
|
233
|
+
continue
|
|
234
|
+
fi
|
|
235
|
+
fi
|
|
236
|
+
|
|
237
|
+
n_sessions=$((n_sessions + 1))
|
|
238
|
+
if [[ -n "$slug" ]]; then
|
|
239
|
+
slug_counts["$slug"]=1
|
|
240
|
+
fi
|
|
241
|
+
|
|
242
|
+
local size
|
|
243
|
+
size="$(jq -r '.source_size // 0' <<<"$row")"
|
|
244
|
+
[[ "$size" =~ ^[0-9]+$ ]] || size=0
|
|
245
|
+
bytes_sum=$((bytes_sum + size))
|
|
246
|
+
|
|
247
|
+
local abs="$journal_dir/$snapshot" m=0
|
|
248
|
+
if [[ -r "$abs" ]]; then
|
|
249
|
+
m="$(wc -l <"$abs" 2>/dev/null | tr -d ' ')"
|
|
250
|
+
[[ -z "$m" ]] && m=0
|
|
251
|
+
fi
|
|
252
|
+
msgs_sum=$((msgs_sum + m))
|
|
253
|
+
done
|
|
254
|
+
|
|
255
|
+
local n_projects=${#slug_counts[@]}
|
|
256
|
+
|
|
257
|
+
# Curated count: entries/*.md files whose date prefix is in window.
|
|
258
|
+
local curated=0
|
|
259
|
+
local entries_dir="$journal_dir/entries"
|
|
260
|
+
if [[ -d "$entries_dir" ]]; then
|
|
261
|
+
local f base prefix
|
|
262
|
+
while IFS= read -r f; do
|
|
263
|
+
[[ -z "$f" ]] && continue
|
|
264
|
+
base="$(basename "$f")"
|
|
265
|
+
prefix="${base:0:10}"
|
|
266
|
+
if [[ "$prefix" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \
|
|
267
|
+
&& ! [[ "$prefix" < "$window_start" ]] \
|
|
268
|
+
&& ! [[ "$prefix" > "$window_end" ]]; then
|
|
269
|
+
curated=$((curated + 1))
|
|
270
|
+
fi
|
|
271
|
+
done < <(find "$entries_dir" -mindepth 1 -maxdepth 1 -type f -name '*.md' 2>/dev/null)
|
|
272
|
+
fi
|
|
273
|
+
|
|
274
|
+
local curated_pct=0
|
|
275
|
+
if (( n_sessions > 0 )); then
|
|
276
|
+
curated_pct=$(( (curated * 100 + n_sessions / 2) / n_sessions ))
|
|
277
|
+
if (( curated_pct > 100 )); then curated_pct=100; fi
|
|
278
|
+
fi
|
|
279
|
+
|
|
280
|
+
# Breadcrumb count: breadcrumbs/<YYYY-MM-DD>-<slug>.md in window.
|
|
281
|
+
local breadcrumbs=0
|
|
282
|
+
local -A breadcrumb_slugs=()
|
|
283
|
+
local breadcrumbs_dir="$journal_dir/breadcrumbs"
|
|
284
|
+
if [[ -d "$breadcrumbs_dir" ]]; then
|
|
285
|
+
local bf bname bprefix brest bslug
|
|
286
|
+
while IFS= read -r bf; do
|
|
287
|
+
[[ -z "$bf" ]] && continue
|
|
288
|
+
bname="$(basename "$bf")"
|
|
289
|
+
bprefix="${bname:0:10}"
|
|
290
|
+
if [[ "$bprefix" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] \
|
|
291
|
+
&& ! [[ "$bprefix" < "$window_start" ]] \
|
|
292
|
+
&& ! [[ "$bprefix" > "$window_end" ]]; then
|
|
293
|
+
breadcrumbs=$((breadcrumbs + 1))
|
|
294
|
+
brest="${bname:11}"
|
|
295
|
+
bslug="${brest%.md}"
|
|
296
|
+
if [[ -n "$bslug" ]]; then
|
|
297
|
+
breadcrumb_slugs["$bslug"]=1
|
|
298
|
+
fi
|
|
299
|
+
fi
|
|
300
|
+
done < <(find "$breadcrumbs_dir" -mindepth 1 -maxdepth 1 -type f -name '*.md' 2>/dev/null)
|
|
301
|
+
fi
|
|
302
|
+
local breadcrumb_projects=${#breadcrumb_slugs[@]}
|
|
303
|
+
|
|
304
|
+
local bytes_human
|
|
305
|
+
bytes_human="$(_clast_stats_human_bytes "$bytes_sum")"
|
|
306
|
+
|
|
307
|
+
if [[ -n "${CLAST_JSON:-}" ]]; then
|
|
308
|
+
jq -cn \
|
|
309
|
+
--arg start "$window_start" \
|
|
310
|
+
--arg end "$window_end" \
|
|
311
|
+
--arg label "$json_label" \
|
|
312
|
+
--argjson projects "$n_projects" \
|
|
313
|
+
--argjson sessions "$n_sessions" \
|
|
314
|
+
--argjson messages_approx "$msgs_sum" \
|
|
315
|
+
--argjson bytes "$bytes_sum" \
|
|
316
|
+
--arg bytes_human "$bytes_human" \
|
|
317
|
+
--argjson curated "$curated" \
|
|
318
|
+
--argjson curated_pct "$curated_pct" \
|
|
319
|
+
--argjson breadcrumbs "$breadcrumbs" \
|
|
320
|
+
--argjson breadcrumb_projects "$breadcrumb_projects" \
|
|
321
|
+
'{
|
|
322
|
+
window: {start:$start, end:$end, label:$label},
|
|
323
|
+
projects: $projects,
|
|
324
|
+
sessions: $sessions,
|
|
325
|
+
messages_approx: $messages_approx,
|
|
326
|
+
bytes: $bytes,
|
|
327
|
+
bytes_human: $bytes_human,
|
|
328
|
+
curated: $curated,
|
|
329
|
+
curated_pct: $curated_pct,
|
|
330
|
+
breadcrumbs: $breadcrumbs,
|
|
331
|
+
breadcrumb_projects: $breadcrumb_projects
|
|
332
|
+
}'
|
|
333
|
+
return 0
|
|
334
|
+
fi
|
|
335
|
+
|
|
336
|
+
if [[ -n "${CLAST_QUIET:-}" ]]; then
|
|
337
|
+
return 0
|
|
338
|
+
fi
|
|
339
|
+
|
|
340
|
+
local window_text
|
|
341
|
+
if [[ "$window_start" == "$window_end" ]]; then
|
|
342
|
+
window_text="$window_start"
|
|
343
|
+
else
|
|
344
|
+
window_text="$window_start..$window_end"
|
|
345
|
+
fi
|
|
346
|
+
|
|
347
|
+
printf '%-12s %s%s\n' "Window:" "$window_text" "$label_suffix"
|
|
348
|
+
printf '%-12s %d\n' "Projects:" "$n_projects"
|
|
349
|
+
printf '%-12s %d\n' "Sessions:" "$n_sessions"
|
|
350
|
+
printf '%-12s %d (approx)\n' "Messages:" "$msgs_sum"
|
|
351
|
+
printf '%-12s %s\n' "Bytes:" "$bytes_human"
|
|
352
|
+
printf '%-12s %d of %d sessions (%d%%)\n' "Curated:" "$curated" "$n_sessions" "$curated_pct"
|
|
353
|
+
printf '%-12s %d across %d projects\n' "Breadcrumbs:" "$breadcrumbs" "$breadcrumb_projects"
|
|
354
|
+
}
|