@procrastivity/clast 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -38
- package/bin/clast +31 -122
- package/bin/clast-plumbing +180 -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 +17 -17
- package/hooks/snapshot.sh +5 -5
- package/lib/clast/clast-classify-lib.bash +68 -0
- package/lib/clast/clast-dismissed-lib.bash +76 -5
- package/lib/clast/clast-lib.bash +93 -8
- package/lib/clast/clast-manifest-lib.bash +58 -5
- package/lib/clast/clast-porcelain-lib.bash +230 -0
- package/lib/clast/clast-porcelain-subcommands/brief.bash +236 -0
- package/lib/clast/clast-porcelain-subcommands/retro.bash +236 -0
- package/lib/clast/clast-porcelain-subcommands/undismiss.bash +27 -0
- package/lib/clast/clast-porcelain-subcommands/wake.bash +514 -0
- package/lib/clast/clast-registry-lib.bash +164 -41
- package/lib/clast/clast-retro-lib.bash +397 -0
- package/lib/clast/clast-subcommands/doctor.bash +29 -13
- package/lib/clast/clast-subcommands/entries.bash +40 -50
- package/lib/clast/clast-subcommands/projects.bash +16 -20
- package/lib/clast/clast-subcommands/registry.bash +26 -11
- package/lib/clast/clast-subcommands/retro.bash +217 -0
- package/lib/clast/clast-subcommands/sessions.bash +193 -48
- package/lib/clast/clast-subcommands/show.bash +69 -12
- package/lib/clast/clast-subcommands/snapshot.bash +29 -11
- package/lib/clast/clast-subcommands/stats.bash +7 -2
- package/lib/clast/prompts/brief-system.md +11 -5
- package/lib/clast/prompts/brief-user.md +4 -1
- package/lib/clast/prompts/retro-summary-system.md +14 -0
- package/lib/clast/prompts/retro-summary-user.md +7 -0
- package/lib/clast/prompts/{day-wakeup-draft-system.md → wake-draft-system.md} +1 -1
- package/package.json +3 -3
- package/{.claude-plugin/skills/wakeup → skills/brief}/SKILL.md +22 -20
- package/{.claude-plugin/skills/day-wakeup → skills/wake}/SKILL.md +54 -29
- package/bin/clast-brief +0 -305
- package/bin/clast-wake +0 -579
- /package/lib/clast/prompts/{day-wakeup-draft-user.md → wake-draft-user.md} +0 -0
package/hooks/snapshot.sh
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# hooks/snapshot.sh — SessionStart hook. Backgrounds `clast snapshot`.
|
|
2
|
+
# hooks/snapshot.sh — SessionStart hook. Backgrounds `clast-plumbing snapshot`.
|
|
3
3
|
# Idempotent. Best-effort: never propagates a non-zero exit.
|
|
4
4
|
# shellcheck shell=bash
|
|
5
5
|
|
|
@@ -11,10 +11,10 @@ _plugin_root="$(cd "$_snap_dir/.." && pwd)"
|
|
|
11
11
|
# Prefer the bundled binary (version-matched to this plugin).
|
|
12
12
|
# Fall back to a system-level install on PATH.
|
|
13
13
|
_clast=""
|
|
14
|
-
if [[ -x "$_plugin_root/bin/clast" ]]; then
|
|
15
|
-
_clast="$_plugin_root/bin/clast"
|
|
16
|
-
elif command -v clast >/dev/null 2>&1; then
|
|
17
|
-
_clast="clast"
|
|
14
|
+
if [[ -x "$_plugin_root/bin/clast-plumbing" ]]; then
|
|
15
|
+
_clast="$_plugin_root/bin/clast-plumbing"
|
|
16
|
+
elif command -v clast-plumbing >/dev/null 2>&1; then
|
|
17
|
+
_clast="clast-plumbing"
|
|
18
18
|
fi
|
|
19
19
|
|
|
20
20
|
if [[ -n "$_clast" ]]; then
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# clast-classify-lib.bash — deterministic session classification
|
|
2
|
+
#
|
|
3
|
+
# A "no-op" session is one Claude Code captured but that holds no real work:
|
|
4
|
+
# the user opened a session and only ran slash commands (`/clear`, `/model`,
|
|
5
|
+
# `/config`, …) then quit, or typed a prompt and quit before Claude replied.
|
|
6
|
+
# These are worthless to curate, so `wake` auto-dismisses them without ever
|
|
7
|
+
# calling the LLM (see docs/reference/plugin.md + the wake flows).
|
|
8
|
+
#
|
|
9
|
+
# The classification is two counts computed from the transcript JSONL:
|
|
10
|
+
# user_msg_count real user prompts — user-role messages that are not
|
|
11
|
+
# meta, are non-empty, and are NOT slash-command wrappers.
|
|
12
|
+
# assistant_msg_count assistant-role messages (presence only; a tool-only
|
|
13
|
+
# reply with no text still counts as real work).
|
|
14
|
+
#
|
|
15
|
+
# A session is *substantive* iff assistant_msg_count > 0 — i.e. Claude actually
|
|
16
|
+
# replied. That single test captures both no-op shapes: empty / slash-command-
|
|
17
|
+
# only sessions (/clear, /model, /config) and sessions where the user typed but
|
|
18
|
+
# quit before any response — both have assistant_msg_count == 0. It is
|
|
19
|
+
# deliberately NOT gated on user_msg_count: a custom slash command (e.g.
|
|
20
|
+
# /review) leaves zero prose prompts yet drives real assistant work, and must
|
|
21
|
+
# be kept. user_msg_count is still cached for diagnostics (show, sessions).
|
|
22
|
+
#
|
|
23
|
+
# Counts are computed once at snapshot time and cached on the manifest line
|
|
24
|
+
# (clast-manifest-lib.bash), mirroring msg_count/first_ts/last_ts, so readers
|
|
25
|
+
# never re-open the transcript.
|
|
26
|
+
# shellcheck shell=bash
|
|
27
|
+
|
|
28
|
+
if [[ -n "${_CLAST_CLASSIFY_LIB_SOURCED:-}" ]]; then
|
|
29
|
+
return 0
|
|
30
|
+
fi
|
|
31
|
+
_CLAST_CLASSIFY_LIB_SOURCED=1
|
|
32
|
+
|
|
33
|
+
# Slash-command / local-command marker set. A user-role message whose text
|
|
34
|
+
# contains any of these is Claude Code bookkeeping (e.g. `/clear`, `/model`),
|
|
35
|
+
# not a real prompt. Centralized here so the classifier and show.bash's
|
|
36
|
+
# first_prompt/last_prompt extraction share one definition.
|
|
37
|
+
CLAST_COMMAND_MARKER_RE='<command-name>|<command-message>|<command-args>|<local-command-stdout>|<local-command-stderr>|<local-command-caveat>'
|
|
38
|
+
export CLAST_COMMAND_MARKER_RE
|
|
39
|
+
|
|
40
|
+
# clast_session_msg_counts <transcript-path>
|
|
41
|
+
# Print "<user_msg_count>\t<assistant_msg_count>" (tab-separated) for the
|
|
42
|
+
# given JSONL transcript. Missing/unreadable file prints "0\t0".
|
|
43
|
+
#
|
|
44
|
+
# Streaming (`reduce inputs`), not slurp: a multi-megabyte transcript is
|
|
45
|
+
# read one line at a time, O(1) memory. `fromjson?` silently drops malformed
|
|
46
|
+
# lines, matching the tolerance in clast_manifest_iterate.
|
|
47
|
+
clast_session_msg_counts() {
|
|
48
|
+
local path="$1"
|
|
49
|
+
if [[ -z "$path" || ! -r "$path" ]]; then
|
|
50
|
+
printf '0\t0\n'
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
jq -n -R -r --arg cmd_re "$CLAST_COMMAND_MARKER_RE" '
|
|
54
|
+
def role: (.message.role // .role // .type);
|
|
55
|
+
def text_str:
|
|
56
|
+
(.message.content // .content // "")
|
|
57
|
+
| if type == "array" then (map(.text? // "") | join(" ")) else (. | tostring) end;
|
|
58
|
+
reduce (inputs | fromjson?) as $l ({u: 0, a: 0};
|
|
59
|
+
if ($l.isMeta // false) == true then .
|
|
60
|
+
elif ($l | role) == "user" then
|
|
61
|
+
( ($l | text_str) as $t
|
|
62
|
+
| if ($t | gsub("\\s"; "")) != "" and ($t | test($cmd_re) | not)
|
|
63
|
+
then .u += 1 else . end )
|
|
64
|
+
elif ($l | role) == "assistant" then .a += 1
|
|
65
|
+
else . end
|
|
66
|
+
) | "\(.u)\t\(.a)"
|
|
67
|
+
' "$path" 2>/dev/null || printf '0\t0\n'
|
|
68
|
+
}
|
|
@@ -51,12 +51,13 @@ clast_dismissed_set() {
|
|
|
51
51
|
return 0
|
|
52
52
|
fi
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
# Single jq pass over the whole file. Previously this forked one jq per
|
|
55
|
+
# line, which dominated when the dismissed log grew large. `fromjson?`
|
|
56
|
+
# silently drops malformed lines, matching the old per-line tolerance.
|
|
57
|
+
local sid
|
|
58
|
+
while IFS= read -r sid; do
|
|
58
59
|
[[ -n "$sid" ]] && _ref["$sid"]=1
|
|
59
|
-
done < "$dismissed_file"
|
|
60
|
+
done < <(jq -rR 'fromjson? | .session_id // empty' "$dismissed_file" 2>/dev/null)
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
# clast_dismissed_check <session-id> — exit 0 if dismissed, 1 if not.
|
|
@@ -71,3 +72,73 @@ clast_dismissed_check() {
|
|
|
71
72
|
|
|
72
73
|
grep -q "\"session_id\":\"$session_id\"" "$dismissed_file" 2>/dev/null
|
|
73
74
|
}
|
|
75
|
+
|
|
76
|
+
# clast_dismissed_remove <session-id> — drop all records for a session,
|
|
77
|
+
# reversing a dismissal. Prints the number of records removed to stdout.
|
|
78
|
+
# Returns 0 if anything was removed, 1 if the session was not dismissed.
|
|
79
|
+
clast_dismissed_remove() {
|
|
80
|
+
local session_id="$1"
|
|
81
|
+
if [[ -z "$session_id" ]]; then
|
|
82
|
+
clast_log_error "clast_dismissed_remove: empty session_id"
|
|
83
|
+
return 2
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
local dismissed_file
|
|
87
|
+
dismissed_file="$(clast_dismissed_path)"
|
|
88
|
+
|
|
89
|
+
if [[ ! -f "$dismissed_file" ]]; then
|
|
90
|
+
printf '0\n'
|
|
91
|
+
return 1
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
# Count matches, then rewrite the log without them. A match is a
|
|
95
|
+
# well-formed JSON *object* whose session_id equals the target;
|
|
96
|
+
# `try fromjson catch null` turns unparseable lines into null so they
|
|
97
|
+
# never match, and the `type == "object"` guard keeps non-object JSON
|
|
98
|
+
# (numbers, strings) from erroring on `.session_id`. This mirrors the
|
|
99
|
+
# tolerant read in clast_dismissed_set.
|
|
100
|
+
local removed
|
|
101
|
+
removed="$(jq -rRn --arg sid "$session_id" '
|
|
102
|
+
[ inputs
|
|
103
|
+
| (try fromjson catch null) as $obj
|
|
104
|
+
| select(($obj | type) == "object" and $obj.session_id == $sid)
|
|
105
|
+
] | length
|
|
106
|
+
' "$dismissed_file" 2>/dev/null)" || removed=0
|
|
107
|
+
[[ -z "$removed" ]] && removed=0
|
|
108
|
+
|
|
109
|
+
if (( removed == 0 )); then
|
|
110
|
+
printf '0\n'
|
|
111
|
+
return 1
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
local tmp
|
|
115
|
+
tmp="$(mktemp "${dismissed_file}.XXXXXX")" || {
|
|
116
|
+
clast_log_error "clast_dismissed_remove: failed to create temp file"
|
|
117
|
+
return 2
|
|
118
|
+
}
|
|
119
|
+
# Emit every line except well-formed objects matching the target id.
|
|
120
|
+
# Malformed lines parse to null (type != "object") and so are preserved
|
|
121
|
+
# verbatim rather than silently dropped. Rewrite goes through a temp file
|
|
122
|
+
# + mv so a crash mid-write can't truncate the log.
|
|
123
|
+
if jq -Rr --arg sid "$session_id" '
|
|
124
|
+
. as $line
|
|
125
|
+
| (try ($line | fromjson) catch null) as $obj
|
|
126
|
+
| if ($obj | type) == "object" and $obj.session_id == $sid
|
|
127
|
+
then empty
|
|
128
|
+
else $line
|
|
129
|
+
end
|
|
130
|
+
' "$dismissed_file" >"$tmp" 2>/dev/null; then
|
|
131
|
+
if ! mv "$tmp" "$dismissed_file"; then
|
|
132
|
+
rm -f "$tmp"
|
|
133
|
+
clast_log_error "clast_dismissed_remove: failed to replace log"
|
|
134
|
+
return 2
|
|
135
|
+
fi
|
|
136
|
+
else
|
|
137
|
+
rm -f "$tmp"
|
|
138
|
+
clast_log_error "clast_dismissed_remove: rewrite failed"
|
|
139
|
+
return 2
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
printf '%s\n' "$removed"
|
|
143
|
+
return 0
|
|
144
|
+
}
|
package/lib/clast/clast-lib.bash
CHANGED
|
@@ -55,6 +55,78 @@ clast_json_get() {
|
|
|
55
55
|
jq -r "$expr" <<<"$input"
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
# --- Front-matter / YAML -------------------------------------------------
|
|
59
|
+
#
|
|
60
|
+
# Curated journal entries are Markdown with a leading YAML front-matter block
|
|
61
|
+
# fenced by `---`. These two primitives are the single source of truth for
|
|
62
|
+
# reading that block; `entries.bash` and `clast-retro-lib.bash` both build on
|
|
63
|
+
# them.
|
|
64
|
+
|
|
65
|
+
# clast_read_frontmatter <path>
|
|
66
|
+
# Emit the raw front-matter lines (between the first two `---` fences) to
|
|
67
|
+
# stdout. Nothing is emitted for a file without a front-matter block.
|
|
68
|
+
clast_read_frontmatter() {
|
|
69
|
+
local path="$1"
|
|
70
|
+
awk '
|
|
71
|
+
BEGIN { in_fm = 0; seen = 0 }
|
|
72
|
+
/^---[[:space:]]*$/ {
|
|
73
|
+
if (!seen) { in_fm = 1; seen = 1; next }
|
|
74
|
+
if (in_fm) { exit }
|
|
75
|
+
}
|
|
76
|
+
in_fm { print }
|
|
77
|
+
' "$path"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# clast_yaml_unquote <string>
|
|
81
|
+
# Strip surrounding double quotes and unescape \", \\, \n on a YAML scalar.
|
|
82
|
+
# A bare (unquoted) value is returned unchanged.
|
|
83
|
+
clast_yaml_unquote() {
|
|
84
|
+
local v="$1"
|
|
85
|
+
if [[ "${v:0:1}" == '"' && "${v: -1}" == '"' && ${#v} -ge 2 ]]; then
|
|
86
|
+
v="${v:1:${#v}-2}"
|
|
87
|
+
# Process escapes in order: \\ → placeholder, \" → ", \n → LF, placeholder → \
|
|
88
|
+
v="${v//\\\\/$'\x01'}"
|
|
89
|
+
v="${v//\\\"/\"}"
|
|
90
|
+
v="${v//\\n/$'\n'}"
|
|
91
|
+
v="${v//$'\x01'/\\}"
|
|
92
|
+
fi
|
|
93
|
+
printf '%s' "$v"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# clast_entry_body <path>
|
|
97
|
+
# Emit the entry body — everything after the closing `---` of the
|
|
98
|
+
# front-matter block. A file with no front-matter is emitted whole.
|
|
99
|
+
clast_entry_body() {
|
|
100
|
+
awk '
|
|
101
|
+
BEGIN { in_fm = 0; seen = 0; past = 0 }
|
|
102
|
+
!past && /^---[[:space:]]*$/ {
|
|
103
|
+
if (!seen) { in_fm = 1; seen = 1; next }
|
|
104
|
+
if (in_fm) { in_fm = 0; past = 1; next }
|
|
105
|
+
}
|
|
106
|
+
past { print; next }
|
|
107
|
+
!seen { print } # no front-matter fence yet seen → plain file, echo through
|
|
108
|
+
' "$1"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# clast_entry_title <path>
|
|
112
|
+
# The session title from `# Session: <title>` (first non-blank body line).
|
|
113
|
+
# Empty if absent.
|
|
114
|
+
clast_entry_title() {
|
|
115
|
+
awk '
|
|
116
|
+
BEGIN { in_fm = 0; seen = 0; past = 0 }
|
|
117
|
+
/^---[[:space:]]*$/ {
|
|
118
|
+
if (!seen) { in_fm = 1; seen = 1; next }
|
|
119
|
+
if (in_fm) { in_fm = 0; past = 1; next }
|
|
120
|
+
}
|
|
121
|
+
in_fm { next }
|
|
122
|
+
past {
|
|
123
|
+
if ($0 ~ /^[[:space:]]*$/) next
|
|
124
|
+
if (sub(/^# Session: /, "")) { print; exit }
|
|
125
|
+
exit
|
|
126
|
+
}
|
|
127
|
+
' "$1"
|
|
128
|
+
}
|
|
129
|
+
|
|
58
130
|
# --- Date math -----------------------------------------------------------
|
|
59
131
|
#
|
|
60
132
|
# Uses GNU `date -d` for relative-date math. The nix dev shell pulls in
|
|
@@ -71,22 +143,31 @@ _clast_now_epoch() {
|
|
|
71
143
|
fi
|
|
72
144
|
}
|
|
73
145
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
146
|
+
# clast_day_bucket_for_epoch <epoch> — local YYYY-MM-DD for an epoch, adjusted
|
|
147
|
+
# by CLAST_DAY_CUTOFF (HH:MM, default 04:00). Work before the cutoff belongs to
|
|
148
|
+
# the previous day's bucket. Single source of truth for the day-bucket rule;
|
|
149
|
+
# clast_today, `clast snapshot`, and `clast retro` all build on it.
|
|
150
|
+
clast_day_bucket_for_epoch() {
|
|
151
|
+
local epoch="$1"
|
|
77
152
|
local cutoff="${CLAST_DAY_CUTOFF:-04:00}"
|
|
78
|
-
local cutoff_hours cutoff_mins cutoff_secs
|
|
153
|
+
local cutoff_hours cutoff_mins cutoff_secs adjusted
|
|
79
154
|
cutoff_hours="${cutoff%%:*}"
|
|
80
155
|
cutoff_mins="${cutoff##*:}"
|
|
81
156
|
# Strip leading zeros so bash arithmetic doesn't treat them as octal.
|
|
82
157
|
cutoff_hours=$((10#$cutoff_hours))
|
|
83
158
|
cutoff_mins=$((10#$cutoff_mins))
|
|
84
159
|
cutoff_secs=$((cutoff_hours * 3600 + cutoff_mins * 60))
|
|
85
|
-
|
|
86
|
-
|
|
160
|
+
adjusted=$((epoch - cutoff_secs))
|
|
161
|
+
# GNU `date -d` — BSD date not supported, per overview.md.
|
|
87
162
|
date -d "@$adjusted" +%Y-%m-%d
|
|
88
163
|
}
|
|
89
164
|
|
|
165
|
+
# clast_today — local YYYY-MM-DD, adjusted by CLAST_DAY_CUTOFF (HH:MM, default 04:00).
|
|
166
|
+
# A session starting before today's cutoff belongs to yesterday's bucket.
|
|
167
|
+
clast_today() {
|
|
168
|
+
clast_day_bucket_for_epoch "$(_clast_now_epoch)"
|
|
169
|
+
}
|
|
170
|
+
|
|
90
171
|
# clast_parse_date <input> — print YYYY-MM-DD on stdout, exit non-zero on bad input.
|
|
91
172
|
# Accepts:
|
|
92
173
|
# - ISO date: 2026-05-30
|
|
@@ -204,10 +285,10 @@ clast_version() {
|
|
|
204
285
|
# to stderr for usage errors.
|
|
205
286
|
clast_usage() {
|
|
206
287
|
cat <<'EOF'
|
|
207
|
-
clast — Claude Code session journal
|
|
288
|
+
clast-plumbing — Claude Code session journal (deterministic core)
|
|
208
289
|
|
|
209
290
|
Usage:
|
|
210
|
-
clast [GLOBAL FLAGS] <subcommand> [ARGS...]
|
|
291
|
+
clast-plumbing [GLOBAL FLAGS] <subcommand> [ARGS...]
|
|
211
292
|
|
|
212
293
|
Subcommands:
|
|
213
294
|
whereami Show current path, registry, and journal state
|
|
@@ -219,6 +300,7 @@ Subcommands:
|
|
|
219
300
|
breadcrumb Append a one-line in-flight hint
|
|
220
301
|
registry Manage the project registry
|
|
221
302
|
stats Token/duration/session-count stats
|
|
303
|
+
retro Work summary grouped by actual work day → project
|
|
222
304
|
doctor Sanity-check the journal
|
|
223
305
|
|
|
224
306
|
Global flags:
|
|
@@ -229,5 +311,8 @@ Global flags:
|
|
|
229
311
|
-q, --quiet Suppress informational stdout output
|
|
230
312
|
--journal-dir P Override ~/.claude/journal/ (env: CLAST_JOURNAL_DIR)
|
|
231
313
|
--projects-dir P Override ~/.claude/projects/ (env: CLAST_PROJECTS_DIR)
|
|
314
|
+
|
|
315
|
+
The user-facing porcelain (LLM-aware) is `clast`. Run `clast --help` for
|
|
316
|
+
the `wake` and `brief` subcommands.
|
|
232
317
|
EOF
|
|
233
318
|
}
|
|
@@ -13,6 +13,13 @@ if [[ -n "${_CLAST_MANIFEST_LIB_SOURCED:-}" ]]; then
|
|
|
13
13
|
fi
|
|
14
14
|
_CLAST_MANIFEST_LIB_SOURCED=1
|
|
15
15
|
|
|
16
|
+
# clast_manifest_rebuild_from_disk backfills the session-classification counts
|
|
17
|
+
# via clast_session_msg_counts. Source the classify lib relative to this file
|
|
18
|
+
# (not via CLAST_LIB) so callers that source manifest-lib directly — e.g. the
|
|
19
|
+
# test suite — get the dependency without extra setup.
|
|
20
|
+
# shellcheck source=lib/clast/clast-classify-lib.bash
|
|
21
|
+
source "${BASH_SOURCE[0]%/*}/clast-classify-lib.bash"
|
|
22
|
+
|
|
16
23
|
# clast_manifest_path — single chokepoint so CLAST_JOURNAL_DIR override
|
|
17
24
|
# (via clast_journal_dir) redirects every read/write in this file.
|
|
18
25
|
clast_manifest_path() {
|
|
@@ -26,13 +33,23 @@ _clast_manifest_now_iso() {
|
|
|
26
33
|
date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
# clast_manifest_append <session-id> <source> <snapshot> <source-mtime> <source-size> <day-bucket>
|
|
36
|
+
# clast_manifest_append <session-id> <source> <snapshot> <source-mtime> <source-size> <day-bucket> <msg-count> <first-ts> <last-ts> <user-msg-count> <assistant-msg-count>
|
|
30
37
|
clast_manifest_append() {
|
|
31
|
-
if [[ $# -ne
|
|
32
|
-
clast_log_error "clast_manifest_append: expected
|
|
38
|
+
if [[ $# -ne 11 ]]; then
|
|
39
|
+
clast_log_error "clast_manifest_append: expected 11 args, got $#"
|
|
33
40
|
return 2
|
|
34
41
|
fi
|
|
35
42
|
local session_id="$1" source="$2" snapshot="$3" source_mtime="$4" source_size="$5" day_bucket="$6"
|
|
43
|
+
# Cached per-session metadata (step 21): msg_count is an integer line
|
|
44
|
+
# count; first_ts/last_ts are the transcript's first/last line timestamps
|
|
45
|
+
# ("" → stored as JSON null). Readers prefer these over re-reading the
|
|
46
|
+
# snapshot file, falling back to the file when a line predates the cache.
|
|
47
|
+
local msg_count="$7" first_ts="$8" last_ts="$9"
|
|
48
|
+
# Session classification (clast-classify-lib.bash): counts of real user
|
|
49
|
+
# prompts and assistant replies. A session is a no-op (auto-dismissed by
|
|
50
|
+
# wake) when either is 0. Cached here so readers never re-open the
|
|
51
|
+
# transcript; legacy lines lack them and readers recompute on demand.
|
|
52
|
+
local user_msg_count="${10}" assistant_msg_count="${11}"
|
|
36
53
|
local field
|
|
37
54
|
for field in session_id source snapshot source_mtime source_size day_bucket; do
|
|
38
55
|
if [[ -z "${!field}" ]]; then
|
|
@@ -44,6 +61,18 @@ clast_manifest_append() {
|
|
|
44
61
|
clast_log_error "clast_manifest_append: source_size must be a non-negative integer, got '$source_size'"
|
|
45
62
|
return 2
|
|
46
63
|
fi
|
|
64
|
+
if ! [[ "$msg_count" =~ ^[0-9]+$ ]]; then
|
|
65
|
+
clast_log_error "clast_manifest_append: msg_count must be a non-negative integer, got '$msg_count'"
|
|
66
|
+
return 2
|
|
67
|
+
fi
|
|
68
|
+
if ! [[ "$user_msg_count" =~ ^[0-9]+$ ]]; then
|
|
69
|
+
clast_log_error "clast_manifest_append: user_msg_count must be a non-negative integer, got '$user_msg_count'"
|
|
70
|
+
return 2
|
|
71
|
+
fi
|
|
72
|
+
if ! [[ "$assistant_msg_count" =~ ^[0-9]+$ ]]; then
|
|
73
|
+
clast_log_error "clast_manifest_append: assistant_msg_count must be a non-negative integer, got '$assistant_msg_count'"
|
|
74
|
+
return 2
|
|
75
|
+
fi
|
|
47
76
|
|
|
48
77
|
local journal_dir
|
|
49
78
|
journal_dir="$(clast_journal_dir)"
|
|
@@ -62,7 +91,12 @@ clast_manifest_append() {
|
|
|
62
91
|
--arg source_mtime "$source_mtime" \
|
|
63
92
|
--argjson source_size "$source_size" \
|
|
64
93
|
--arg day_bucket "$day_bucket" \
|
|
65
|
-
|
|
94
|
+
--argjson msg_count "$msg_count" \
|
|
95
|
+
--arg first_ts "$first_ts" \
|
|
96
|
+
--arg last_ts "$last_ts" \
|
|
97
|
+
--argjson user_msg_count "$user_msg_count" \
|
|
98
|
+
--argjson assistant_msg_count "$assistant_msg_count" \
|
|
99
|
+
'{session_id: $session_id, source: $source, snapshot: $snapshot, captured_at: $captured_at, source_mtime: $source_mtime, source_size: $source_size, day_bucket: $day_bucket, msg_count: $msg_count, first_ts: (if $first_ts == "" then null else $first_ts end), last_ts: (if $last_ts == "" then null else $last_ts end), user_msg_count: $user_msg_count, assistant_msg_count: $assistant_msg_count}')" || {
|
|
66
100
|
clast_log_error "clast_manifest_append: jq failed to build manifest line"
|
|
67
101
|
return 1
|
|
68
102
|
}
|
|
@@ -168,6 +202,7 @@ clast_manifest_rebuild_from_disk() {
|
|
|
168
202
|
local count=0
|
|
169
203
|
if [[ -d "$transcripts_root" ]]; then
|
|
170
204
|
local snapshot day session_id mtime_epoch mtime_iso line
|
|
205
|
+
local msg_count first_ts last_ts
|
|
171
206
|
# source / source_size are unrecoverable: the snapshot file's size is
|
|
172
207
|
# the captured copy's size, not the original source's size at capture
|
|
173
208
|
# time, and the source path is not encoded in the snapshot itself.
|
|
@@ -185,13 +220,31 @@ clast_manifest_rebuild_from_disk() {
|
|
|
185
220
|
rm -f "$tmp_file"
|
|
186
221
|
return 1
|
|
187
222
|
fi
|
|
223
|
+
# source / source_size are unrecoverable, but the cached metadata
|
|
224
|
+
# (step 21) IS recoverable from the snapshot copy — compute it so
|
|
225
|
+
# doctor --fix backfills rather than emitting legacy-shaped lines.
|
|
226
|
+
first_ts="$(head -n1 "$snapshot" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
227
|
+
last_ts="$(tail -n1 "$snapshot" 2>/dev/null | jq -r '.timestamp // empty' 2>/dev/null || true)"
|
|
228
|
+
msg_count="$(wc -l <"$snapshot" 2>/dev/null | tr -d ' ')"
|
|
229
|
+
[[ "$msg_count" =~ ^[0-9]+$ ]] || msg_count=0
|
|
230
|
+
# Session classification counts are equally recoverable from the copy.
|
|
231
|
+
local user_msg_count assistant_msg_count
|
|
232
|
+
IFS=$'\t' read -r user_msg_count assistant_msg_count \
|
|
233
|
+
< <(clast_session_msg_counts "$snapshot")
|
|
234
|
+
[[ "$user_msg_count" =~ ^[0-9]+$ ]] || user_msg_count=0
|
|
235
|
+
[[ "$assistant_msg_count" =~ ^[0-9]+$ ]] || assistant_msg_count=0
|
|
188
236
|
line="$(jq -c -n \
|
|
189
237
|
--arg session_id "$session_id" \
|
|
190
238
|
--arg snapshot "${snapshot#"$journal_dir/"}" \
|
|
191
239
|
--arg captured_at "$mtime_iso" \
|
|
192
240
|
--arg source_mtime "$mtime_iso" \
|
|
193
241
|
--arg day_bucket "$day" \
|
|
194
|
-
|
|
242
|
+
--argjson msg_count "$msg_count" \
|
|
243
|
+
--arg first_ts "$first_ts" \
|
|
244
|
+
--arg last_ts "$last_ts" \
|
|
245
|
+
--argjson user_msg_count "$user_msg_count" \
|
|
246
|
+
--argjson assistant_msg_count "$assistant_msg_count" \
|
|
247
|
+
'{session_id: $session_id, source: null, snapshot: $snapshot, captured_at: $captured_at, source_mtime: $source_mtime, source_size: 0, day_bucket: $day_bucket, msg_count: $msg_count, first_ts: (if $first_ts == "" then null else $first_ts end), last_ts: (if $last_ts == "" then null else $last_ts end), user_msg_count: $user_msg_count, assistant_msg_count: $assistant_msg_count}')" || {
|
|
195
248
|
clast_log_error "clast_manifest_rebuild_from_disk: jq failed for '$snapshot'"
|
|
196
249
|
rm -f "$tmp_file"
|
|
197
250
|
return 1
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# clast-porcelain-lib.bash — shared helpers for the `clast` porcelain.
|
|
2
|
+
#
|
|
3
|
+
# The porcelain is the LLM-aware, user-facing layer that sits on top of
|
|
4
|
+
# `clast-plumbing`. This file holds helpers reused by every porcelain
|
|
5
|
+
# subcommand: error/info plumbing, an OpenAI-compatible chat call, and the
|
|
6
|
+
# prompt-template loader. Pure bash + jq + curl.
|
|
7
|
+
#
|
|
8
|
+
# Subcommand files live in $CLAST_LIB/clast-porcelain-subcommands/<name>.bash
|
|
9
|
+
# and each defines a single function `clast_cmd_<name>`.
|
|
10
|
+
# shellcheck shell=bash
|
|
11
|
+
|
|
12
|
+
# --- Helpers -----------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
clast_porcelain_die() {
|
|
15
|
+
printf 'clast: %s\n' "$1" >&2
|
|
16
|
+
exit "${2:-1}"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clast_porcelain_warn() { printf 'clast: warning: %s\n' "$1" >&2; }
|
|
20
|
+
clast_porcelain_info() { printf '%s\n' "$1"; }
|
|
21
|
+
|
|
22
|
+
clast_porcelain_log_error() { printf 'clast: error: %s\n' "$1" >&2; }
|
|
23
|
+
|
|
24
|
+
# --- Version / usage ---------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
# Reuses the plumbing's clast_version helper if available (loaded by sourcing
|
|
27
|
+
# clast-lib.bash). When the porcelain is invoked stand-alone we still want a
|
|
28
|
+
# version string, so fall back to reading package.json directly.
|
|
29
|
+
clast_porcelain_version() {
|
|
30
|
+
if declare -F clast_version >/dev/null 2>&1; then
|
|
31
|
+
clast_version
|
|
32
|
+
return
|
|
33
|
+
fi
|
|
34
|
+
local pkg_root pkg
|
|
35
|
+
pkg_root="${CLAST_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
36
|
+
if [[ -f "$pkg_root/package.json" ]]; then
|
|
37
|
+
pkg="$pkg_root/package.json"
|
|
38
|
+
elif [[ -f "$pkg_root/../../package.json" ]]; then
|
|
39
|
+
pkg="$pkg_root/../../package.json"
|
|
40
|
+
else
|
|
41
|
+
clast_porcelain_log_error "clast_porcelain_version: package.json not found"
|
|
42
|
+
return 1
|
|
43
|
+
fi
|
|
44
|
+
jq -r '.version' "$pkg"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clast_porcelain_usage() {
|
|
48
|
+
cat <<'EOF'
|
|
49
|
+
clast — Claude Code session porcelain (LLM-aware)
|
|
50
|
+
|
|
51
|
+
Usage:
|
|
52
|
+
clast [GLOBAL FLAGS] <subcommand> [ARGS...]
|
|
53
|
+
|
|
54
|
+
Subcommands:
|
|
55
|
+
wake Interactive day curation (standalone equivalent of /wake)
|
|
56
|
+
brief Project briefing (standalone equivalent of /brief)
|
|
57
|
+
retro Model-condensed work retrospective by work day → project
|
|
58
|
+
undismiss Restore session(s) dismissed in wake (by session id)
|
|
59
|
+
|
|
60
|
+
Global flags:
|
|
61
|
+
-h, --help Print this usage and exit
|
|
62
|
+
--version Print version and exit
|
|
63
|
+
|
|
64
|
+
Env (required by `wake` and `brief`):
|
|
65
|
+
CLAST_LLM_BASE_URL e.g. https://api.openai.com/v1
|
|
66
|
+
CLAST_LLM_API_KEY bearer token
|
|
67
|
+
CLAST_LLM_MODEL e.g. gpt-4o, llama3
|
|
68
|
+
|
|
69
|
+
The deterministic core (whereami / snapshot / sessions / entries / …)
|
|
70
|
+
lives in `clast-plumbing`. Run `clast-plumbing --help` for that surface.
|
|
71
|
+
EOF
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# --- Preflight ---------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
# clast_porcelain_preflight_llm — verify curl, jq, and the LLM env vars exist.
|
|
77
|
+
# Subcommands that don't call the LLM (none right now) can skip this.
|
|
78
|
+
clast_porcelain_preflight_llm() {
|
|
79
|
+
for tool in clast-plumbing curl jq; do
|
|
80
|
+
if ! command -v "$tool" >/dev/null 2>&1; then
|
|
81
|
+
clast_porcelain_die "required tool not found: $tool"
|
|
82
|
+
fi
|
|
83
|
+
done
|
|
84
|
+
|
|
85
|
+
local missing=0
|
|
86
|
+
if [[ -z "${CLAST_LLM_BASE_URL:-}" ]]; then
|
|
87
|
+
clast_porcelain_warn "CLAST_LLM_BASE_URL not set"; missing=1
|
|
88
|
+
fi
|
|
89
|
+
if [[ -z "${CLAST_LLM_API_KEY:-}" ]]; then
|
|
90
|
+
clast_porcelain_warn "CLAST_LLM_API_KEY not set"; missing=1
|
|
91
|
+
fi
|
|
92
|
+
if [[ -z "${CLAST_LLM_MODEL:-}" ]]; then
|
|
93
|
+
clast_porcelain_warn "CLAST_LLM_MODEL not set"; missing=1
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
if (( missing )); then
|
|
97
|
+
cat >&2 <<'EOF'
|
|
98
|
+
|
|
99
|
+
Set these env vars before running clast wake / clast brief:
|
|
100
|
+
|
|
101
|
+
export CLAST_LLM_BASE_URL="https://api.openai.com/v1"
|
|
102
|
+
export CLAST_LLM_API_KEY="sk-..."
|
|
103
|
+
export CLAST_LLM_MODEL="gpt-4o"
|
|
104
|
+
|
|
105
|
+
Or for a local model (ollama, vllm, etc.):
|
|
106
|
+
|
|
107
|
+
export CLAST_LLM_BASE_URL="http://localhost:11434/v1"
|
|
108
|
+
export CLAST_LLM_API_KEY="unused"
|
|
109
|
+
export CLAST_LLM_MODEL="llama3"
|
|
110
|
+
EOF
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# --- LLM call ----------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
# clast_porcelain_llm_chat <system-msg> <user-msg>
|
|
118
|
+
# POST a chat-completions request to $CLAST_LLM_BASE_URL/chat/completions
|
|
119
|
+
# and echo the assistant's text on stdout. Returns nonzero on HTTP error or
|
|
120
|
+
# empty response.
|
|
121
|
+
clast_porcelain_llm_chat() {
|
|
122
|
+
local system_msg="$1"
|
|
123
|
+
local user_msg="$2"
|
|
124
|
+
|
|
125
|
+
# Build and send the request without ever passing the (possibly hundreds of
|
|
126
|
+
# KB) prompt through argv: a single argument above MAX_ARG_STRLEN (128KB on
|
|
127
|
+
# Linux) fails with "Argument list too long", even well under total ARG_MAX.
|
|
128
|
+
# jq reads the strings via --rawfile (process substitution; printf is a
|
|
129
|
+
# builtin with no argv limit) and curl reads the body from a file with @.
|
|
130
|
+
local payload_file
|
|
131
|
+
payload_file="$(mktemp)" || { clast_porcelain_warn "failed to create temp file"; return 1; }
|
|
132
|
+
if ! jq -cn \
|
|
133
|
+
--arg model "$CLAST_LLM_MODEL" \
|
|
134
|
+
--rawfile system <(printf '%s' "$system_msg") \
|
|
135
|
+
--rawfile user <(printf '%s' "$user_msg") \
|
|
136
|
+
'{
|
|
137
|
+
model: $model,
|
|
138
|
+
messages: [
|
|
139
|
+
{role: "system", content: $system},
|
|
140
|
+
{role: "user", content: $user}
|
|
141
|
+
],
|
|
142
|
+
temperature: 0.3
|
|
143
|
+
}' >"$payload_file"; then
|
|
144
|
+
rm -f "$payload_file"
|
|
145
|
+
clast_porcelain_warn "failed to build LLM request payload"
|
|
146
|
+
return 1
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
local response http_code body
|
|
150
|
+
response="$(curl -s -w '\n%{http_code}' \
|
|
151
|
+
"${CLAST_LLM_BASE_URL}/chat/completions" \
|
|
152
|
+
-H "Authorization: Bearer $CLAST_LLM_API_KEY" \
|
|
153
|
+
-H "Content-Type: application/json" \
|
|
154
|
+
--data-binary @"$payload_file" 2>&1)" || true
|
|
155
|
+
rm -f "$payload_file"
|
|
156
|
+
|
|
157
|
+
http_code="$(tail -n1 <<<"$response")"
|
|
158
|
+
body="$(sed '$d' <<<"$response")"
|
|
159
|
+
|
|
160
|
+
if [[ "$http_code" != "200" ]]; then
|
|
161
|
+
clast_porcelain_warn "LLM API returned HTTP $http_code"
|
|
162
|
+
if [[ -n "$body" ]]; then
|
|
163
|
+
local err_msg
|
|
164
|
+
err_msg="$(jq -r '.error.message // .error // .' <<<"$body" 2>/dev/null || echo "$body")"
|
|
165
|
+
clast_porcelain_warn "$err_msg"
|
|
166
|
+
fi
|
|
167
|
+
return 1
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
local content
|
|
171
|
+
content="$(jq -r '.choices[0].message.content // empty' <<<"$body" 2>/dev/null)" || {
|
|
172
|
+
clast_porcelain_warn "failed to parse LLM response"
|
|
173
|
+
return 1
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if [[ -z "$content" ]]; then
|
|
177
|
+
clast_porcelain_warn "LLM returned empty content"
|
|
178
|
+
return 1
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
printf '%s' "$content"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# --- Prompt templates --------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
# clast_porcelain_resolve_prompt_dir — echo the prompts dir, search order:
|
|
187
|
+
# 1. $CLAST_LIB/prompts (in-repo or installed alongside the lib)
|
|
188
|
+
# 2. /usr/local/lib/clast/prompts
|
|
189
|
+
# 3. $HOME/.local/lib/clast/prompts
|
|
190
|
+
clast_porcelain_resolve_prompt_dir() {
|
|
191
|
+
if [[ -n "${CLAST_LIB:-}" && -d "$CLAST_LIB/prompts" ]]; then
|
|
192
|
+
printf '%s' "$CLAST_LIB/prompts"
|
|
193
|
+
return
|
|
194
|
+
fi
|
|
195
|
+
local installed
|
|
196
|
+
for installed in /usr/local/lib/clast/prompts "$HOME/.local/lib/clast/prompts"; do
|
|
197
|
+
if [[ -d "$installed" ]]; then
|
|
198
|
+
printf '%s' "$installed"
|
|
199
|
+
return
|
|
200
|
+
fi
|
|
201
|
+
done
|
|
202
|
+
clast_porcelain_die "cannot find prompts directory (checked \$CLAST_LIB/prompts and install paths)"
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# clast_porcelain_load_system_prompt <basename>
|
|
206
|
+
# Read the named system prompt (without extension) from the resolved
|
|
207
|
+
# prompts dir, e.g. clast_porcelain_load_system_prompt brief-system.
|
|
208
|
+
clast_porcelain_load_system_prompt() {
|
|
209
|
+
local name="$1"
|
|
210
|
+
local prompt_dir file
|
|
211
|
+
prompt_dir="$(clast_porcelain_resolve_prompt_dir)"
|
|
212
|
+
file="$prompt_dir/${name}.md"
|
|
213
|
+
if [[ ! -r "$file" ]]; then
|
|
214
|
+
clast_porcelain_die "system prompt not found: $file"
|
|
215
|
+
fi
|
|
216
|
+
cat "$file"
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# clast_porcelain_user_prompt_file <basename>
|
|
220
|
+
# Echo the absolute path to the named user-prompt template, or empty string
|
|
221
|
+
# if missing. Callers do their own placeholder substitution.
|
|
222
|
+
clast_porcelain_user_prompt_file() {
|
|
223
|
+
local name="$1"
|
|
224
|
+
local prompt_dir file
|
|
225
|
+
prompt_dir="$(clast_porcelain_resolve_prompt_dir)"
|
|
226
|
+
file="$prompt_dir/${name}.md"
|
|
227
|
+
if [[ -r "$file" ]]; then
|
|
228
|
+
printf '%s' "$file"
|
|
229
|
+
fi
|
|
230
|
+
}
|