@onlooker-community/ecosystem 0.19.0 → 0.21.0

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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +26 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -2
  4. package/CHANGELOG.md +14 -0
  5. package/docs/memory-architecture.md +102 -0
  6. package/package.json +3 -3
  7. package/plugins/curator/.claude-plugin/plugin.json +14 -0
  8. package/plugins/curator/CHANGELOG.md +10 -0
  9. package/plugins/curator/README.md +55 -0
  10. package/plugins/curator/config.json +41 -0
  11. package/plugins/curator/docs/adr/001-staleness-tiers.md +100 -0
  12. package/plugins/curator/docs/design.md +311 -0
  13. package/plugins/curator/hooks/hooks.json +15 -0
  14. package/plugins/curator/scripts/hooks/curator-session-start.sh +343 -0
  15. package/plugins/curator/scripts/lib/curator-checks.sh +155 -0
  16. package/plugins/curator/scripts/lib/curator-config.sh +67 -0
  17. package/plugins/curator/scripts/lib/curator-emit.sh +61 -0
  18. package/plugins/curator/scripts/lib/curator-memory-reader.sh +225 -0
  19. package/plugins/curator/scripts/lib/curator-project-key.sh +82 -0
  20. package/plugins/curator/scripts/lib/curator-storage.sh +176 -0
  21. package/plugins/curator/scripts/lib/curator-ulid.sh +43 -0
  22. package/plugins/historian/docs/adr/001-local-embeddings-only.md +96 -0
  23. package/plugins/historian/docs/design.md +317 -0
  24. package/plugins/librarian/.claude-plugin/plugin.json +14 -0
  25. package/plugins/librarian/CHANGELOG.md +10 -0
  26. package/plugins/librarian/README.md +51 -0
  27. package/plugins/librarian/config.json +52 -0
  28. package/plugins/librarian/docs/adr/001-propose-dont-auto-write.md +87 -0
  29. package/plugins/librarian/docs/design.md +301 -0
  30. package/plugins/librarian/hooks/hooks.json +26 -0
  31. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +312 -0
  32. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +103 -0
  33. package/plugins/librarian/scripts/lib/librarian-archivist-reader.sh +67 -0
  34. package/plugins/librarian/scripts/lib/librarian-classifier.sh +139 -0
  35. package/plugins/librarian/scripts/lib/librarian-config.sh +74 -0
  36. package/plugins/librarian/scripts/lib/librarian-durability.sh +77 -0
  37. package/plugins/librarian/scripts/lib/librarian-emit.sh +72 -0
  38. package/plugins/librarian/scripts/lib/librarian-project-key.sh +83 -0
  39. package/plugins/librarian/scripts/lib/librarian-storage.sh +222 -0
  40. package/plugins/librarian/scripts/lib/librarian-ulid.sh +50 -0
  41. package/release-please-config.json +32 -0
  42. package/test/bats/curator-session-start.bats +316 -0
  43. package/test/bats/librarian-session-end.bats +182 -0
  44. package/test/bats/librarian-session-start.bats +136 -0
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # Config resolution for Librarian.
3
+ #
4
+ # Reads three layers, latest wins:
5
+ # 1. plugins/librarian/config.json (defaults shipped with the plugin)
6
+ # 2. ~/.claude/settings.json
7
+ # 3. <repo>/.claude/settings.json
8
+ #
9
+ # Exposes:
10
+ # librarian_config_load <repo_root> # populates _LIBRARIAN_CONFIG (JSON)
11
+ # librarian_config_get <jq-path> # echoes string value (empty if unset)
12
+ # librarian_config_enabled # 0 if librarian.enabled is true
13
+ # librarian_config_auto_promote # 0 if librarian.auto_promote is true
14
+ #
15
+ # Settings overlay only touches the `librarian.*` subtree of settings.json so
16
+ # it coexists with other plugins' configuration.
17
+
18
+ _LIBRARIAN_CONFIG="{}"
19
+
20
+ librarian_config_load() {
21
+ local repo_root="${1:-}"
22
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
23
+ local home_dir="${HOME:-}"
24
+
25
+ local merged="{}"
26
+ local file
27
+
28
+ file="${plugin_root}/config.json"
29
+ if [[ -f "$file" ]]; then
30
+ local defaults
31
+ defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
32
+ merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
33
+ || merged="$defaults"
34
+ fi
35
+
36
+ for file in "${home_dir}/.claude/settings.json" "${repo_root}/.claude/settings.json"; do
37
+ [[ -n "$file" && -f "$file" ]] || continue
38
+ local overlay
39
+ overlay=$(jq '{ librarian: (.librarian // {}) }' "$file" 2>/dev/null) || continue
40
+ [[ -z "$overlay" ]] && continue
41
+ merged=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
42
+ def deepmerge($a; $b):
43
+ if ($a|type) == "object" and ($b|type) == "object" then
44
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
45
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
46
+ elif $b == null then $a
47
+ else $b end;
48
+ deepmerge($a; $b)
49
+ ' 2>/dev/null) || true
50
+ done
51
+
52
+ _LIBRARIAN_CONFIG="$merged"
53
+ }
54
+
55
+ # Read a value from the loaded config. Usage:
56
+ # librarian_config_get '.librarian.surfacer.max_pending_for_inject'
57
+ librarian_config_get() {
58
+ local path="$1"
59
+ printf '%s' "$_LIBRARIAN_CONFIG" | jq -r "${path} // empty" 2>/dev/null
60
+ }
61
+
62
+ # Returns 0 if librarian.enabled is true, 1 otherwise.
63
+ librarian_config_enabled() {
64
+ local v
65
+ v=$(librarian_config_get '.librarian.enabled')
66
+ [[ "$v" == "true" ]]
67
+ }
68
+
69
+ # Returns 0 if librarian.auto_promote is true, 1 otherwise.
70
+ librarian_config_auto_promote() {
71
+ local v
72
+ v=$(librarian_config_get '.librarian.auto_promote')
73
+ [[ "$v" == "true" ]]
74
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # Cheap pre-LLM durability filter for librarian candidates.
3
+ #
4
+ # Drops obvious session-only items before paying for classification:
5
+ # - Drop if detail.length < min_detail_chars
6
+ # - Drop if matches a drop-list phrase
7
+ # - Keep if matches a marker phrase ("always", "never", "remember", ...)
8
+ # - Keep if files[] contains a path that still exists in the repo
9
+ # (caller-supplied flag — we don't shell out to git here)
10
+ #
11
+ # Output: JSON array of surviving candidates plus a drop-reason record for
12
+ # each rejected item so the scan-complete event can report counts.
13
+
14
+ # Default drop-list patterns: terse meta-conversation that almost never
15
+ # promotes well. Matched case-insensitively against summary+detail.
16
+ _LIBRARIAN_DURABILITY_DROP_PATTERNS=(
17
+ "the test is failing"
18
+ "let me check"
19
+ "i'll come back to this"
20
+ "i will come back to this"
21
+ "working on it"
22
+ "investigating"
23
+ )
24
+
25
+ # Apply the durability filter to a JSON array of candidates.
26
+ #
27
+ # Usage: librarian_durability_filter <candidates_json> <markers_json> \
28
+ # <min_detail_chars>
29
+ #
30
+ # Args:
31
+ # candidates_json Array of archivist artifacts (from
32
+ # librarian_archivist_load_since).
33
+ # markers_json JSON array of marker phrases (from config).
34
+ # min_detail_chars Minimum detail length to keep an artifact.
35
+ #
36
+ # Output: JSON object with two keys:
37
+ # { "kept": [<artifact>, ...],
38
+ # "dropped": [{ "artifact_id": "...", "reason": "..." }, ...] }
39
+ librarian_durability_filter() {
40
+ local candidates="${1:-[]}"
41
+ local markers_json="${2:-[]}"
42
+ local min_detail="${3:-40}"
43
+
44
+ local drops
45
+ drops=$(printf '%s\n' "${_LIBRARIAN_DURABILITY_DROP_PATTERNS[@]}" \
46
+ | jq -R . | jq -s .)
47
+
48
+ # All filtering happens in one jq expression to avoid bash-side iteration
49
+ # for what's a pure data transform.
50
+ printf '%s' "$candidates" | jq \
51
+ --argjson markers "$markers_json" \
52
+ --argjson drops "$drops" \
53
+ --argjson min_detail "$min_detail" \
54
+ '
55
+ def normalized: ((.summary // "") + " " + (.detail // "")) | ascii_downcase;
56
+ def matches_any($patterns): . as $text | $patterns | any(. as $p | ($text | contains($p)));
57
+
58
+ def classify(c):
59
+ c | normalized as $text |
60
+ (c.detail // "") as $detail |
61
+ if ($detail | length) < $min_detail then
62
+ { kept: false, reason: "detail_too_short" }
63
+ elif ($text | matches_any($drops)) then
64
+ { kept: false, reason: "filter_drop_pattern" }
65
+ elif ($text | matches_any($markers)) then
66
+ { kept: true, reason: "marker_phrase_match" }
67
+ else
68
+ { kept: false, reason: "filter_marker_missing" }
69
+ end;
70
+
71
+ map(. as $c | classify($c) as $r | $c + { _filter: $r })
72
+ | {
73
+ kept: [.[] | select(._filter.kept) | del(._filter)],
74
+ dropped: [.[] | select(._filter.kept | not) | { artifact_id: .id, reason: ._filter.reason }]
75
+ }
76
+ '
77
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bash
2
+ # Event emission helpers for Librarian.
3
+ #
4
+ # Wraps the canonical onlooker-event.mjs `emit` mode so hook scripts can
5
+ # build a librarian.* event without touching node directly. Events are
6
+ # schema-validated by onlooker-event.mjs; if validation fails the line is
7
+ # silently dropped (fail-soft) rather than blocking the hook.
8
+
9
+ # Resolve the ecosystem helper path. The substrate is in the sibling
10
+ # ecosystem plugin at ../../../.. relative to plugins/librarian/scripts/hooks.
11
+ # Honor ONLOOKER_ECOSYSTEM_ROOT when set (test isolation).
12
+ _librarian_resolve_event_js() {
13
+ local script_dir plugin_root ecosystem_root candidate
14
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ plugin_root="$(cd "${script_dir}/../.." && pwd)"
16
+
17
+ ecosystem_root="${ONLOOKER_ECOSYSTEM_ROOT:-}"
18
+ if [[ -z "$ecosystem_root" ]]; then
19
+ candidate="$(cd "${plugin_root}/../.." 2>/dev/null && pwd)"
20
+ if [[ -f "${candidate}/scripts/lib/onlooker-event.mjs" ]]; then
21
+ ecosystem_root="$candidate"
22
+ fi
23
+ fi
24
+
25
+ if [[ -n "$ecosystem_root" ]]; then
26
+ printf '%s/scripts/lib/onlooker-event.mjs' "$ecosystem_root"
27
+ fi
28
+ }
29
+
30
+ _LIBRARIAN_EVENT_JS="${_LIBRARIAN_EVENT_JS:-$(_librarian_resolve_event_js)}"
31
+
32
+ # Emit a librarian.* event. Fail-soft: returns 0 on success or when the
33
+ # substrate is unavailable; the event is dropped silently.
34
+ #
35
+ # Usage: librarian_emit <event_type> <session_id> <payload_json>
36
+ #
37
+ # Example:
38
+ # librarian_emit "librarian.scan.started" "$SID" "$(jq -n \
39
+ # --arg trigger "session_end" '{ trigger: $trigger }')"
40
+ librarian_emit() {
41
+ local event_type="${1:-}"
42
+ local session_id="${2:-}"
43
+ local payload="${3:-{\}}"
44
+
45
+ [[ -z "$event_type" || -z "$session_id" ]] && return 0
46
+ [[ -z "$_LIBRARIAN_EVENT_JS" || ! -f "$_LIBRARIAN_EVENT_JS" ]] && return 0
47
+ command -v node >/dev/null 2>&1 || return 0
48
+ [[ -z "${ONLOOKER_EVENTS_LOG:-}" ]] && return 0
49
+
50
+ local params event_json
51
+ params=$(jq -cn \
52
+ --arg plugin "librarian" \
53
+ --arg session_id "$session_id" \
54
+ --arg event_type "$event_type" \
55
+ --argjson payload "$payload" \
56
+ '{
57
+ plugin: $plugin,
58
+ session_id: $session_id,
59
+ event_type: $event_type,
60
+ payload: $payload
61
+ }') || return 0
62
+
63
+ event_json=$(
64
+ ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
65
+ ONLOOKER_PLUGIN_NAME="librarian" \
66
+ printf '%s' "$params" | node "$_LIBRARIAN_EVENT_JS" emit 2>/dev/null
67
+ ) || return 0
68
+ [[ -z "$event_json" ]] && return 0
69
+
70
+ mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")" 2>/dev/null
71
+ printf '%s\n' "$event_json" >> "$ONLOOKER_EVENTS_LOG" 2>/dev/null
72
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env bash
2
+ # Project key derivation for Librarian.
3
+ #
4
+ # Librarian writes its proposal queue and tombstones under the ecosystem-wide
5
+ # 12-char hex project key so a single project's state is shared across clones.
6
+ # (The typed memory store the user maintains lives at a different path keyed by
7
+ # the Claude Code per-checkout encoding; that path is resolved separately at
8
+ # write time.)
9
+ #
10
+ # Resolution order:
11
+ # 1. SHA256(`git remote get-url origin`) — preferred, machine-portable
12
+ # 2. SHA256(realpath of `git rev-parse --show-toplevel`) — fallback for repos
13
+ # without an origin remote (greenfield local-only work)
14
+ #
15
+ # Returns the first 12 hex chars. Returns empty string if neither resolution
16
+ # path works (caller decides whether to skip or treat as a non-repo session).
17
+
18
+ _librarian_sha256_first12() {
19
+ local input="$1"
20
+ if command -v shasum >/dev/null 2>&1; then
21
+ printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
22
+ elif command -v sha256sum >/dev/null 2>&1; then
23
+ printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
24
+ else
25
+ return 1
26
+ fi
27
+ }
28
+
29
+ librarian_project_remote_url() {
30
+ local cwd="${1:-}"
31
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
32
+ git -C "$cwd" remote get-url origin 2>/dev/null || true
33
+ }
34
+
35
+ librarian_project_repo_root() {
36
+ local cwd="${1:-}"
37
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
38
+
39
+ if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
40
+ return 0
41
+ fi
42
+
43
+ local common_dir toplevel
44
+ common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
45
+
46
+ if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
47
+ common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
48
+ fi
49
+
50
+ if [[ -n "$common_dir" && -d "$common_dir" ]]; then
51
+ toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
52
+ fi
53
+
54
+ if [[ -z "$toplevel" ]]; then
55
+ toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
56
+ [[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
57
+ fi
58
+
59
+ printf '%s' "$toplevel"
60
+ }
61
+
62
+ # Compute the project key for the given cwd. Prints the key or empty string.
63
+ # Usage: key=$(librarian_project_key "$CWD")
64
+ librarian_project_key() {
65
+ local cwd="${1:-}"
66
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
67
+
68
+ local remote
69
+ remote=$(librarian_project_remote_url "$cwd")
70
+ if [[ -n "$remote" ]]; then
71
+ _librarian_sha256_first12 "remote:$remote"
72
+ return 0
73
+ fi
74
+
75
+ local root
76
+ root=$(librarian_project_repo_root "$cwd")
77
+ if [[ -n "$root" ]]; then
78
+ _librarian_sha256_first12 "root:$root"
79
+ return 0
80
+ fi
81
+
82
+ return 0
83
+ }
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env bash
2
+ # Storage layout helpers for Librarian.
3
+ #
4
+ # Layout (under $ONLOOKER_DIR/librarian/<project-key>/):
5
+ # manifest.json project metadata: remote_url, repo_root, last_scan_at
6
+ # last_scan.json { "scanned_at": ISO-8601 } — watermark for incremental scans
7
+ # proposals/<ulid>.json one pending/resolved proposal per file
8
+ # tombstones/<body_hash>.json one tombstone per rejected/pruned body
9
+ #
10
+ # All paths inside proposals are stored relative to the repo root where they
11
+ # originated. The typed memory store the user maintains lives elsewhere
12
+ # (~/.claude/projects/<encoded>/memory/) and is resolved at promotion time.
13
+
14
+ # ============================================================================
15
+ # Path helpers
16
+ # ============================================================================
17
+
18
+ librarian_storage_root() {
19
+ local base="${ONLOOKER_DIR:-$HOME/.onlooker}"
20
+ printf '%s/librarian' "$base"
21
+ }
22
+
23
+ librarian_project_dir() {
24
+ local key="$1"
25
+ printf '%s/%s' "$(librarian_storage_root)" "$key"
26
+ }
27
+
28
+ librarian_proposals_dir() {
29
+ local key="$1"
30
+ printf '%s/proposals' "$(librarian_project_dir "$key")"
31
+ }
32
+
33
+ librarian_tombstones_dir() {
34
+ local key="$1"
35
+ printf '%s/tombstones' "$(librarian_project_dir "$key")"
36
+ }
37
+
38
+ librarian_storage_init() {
39
+ local key="$1"
40
+ [[ -z "$key" ]] && return 1
41
+ local project_dir
42
+ project_dir=$(librarian_project_dir "$key")
43
+ mkdir -p \
44
+ "$project_dir/proposals" \
45
+ "$project_dir/tombstones" 2>/dev/null
46
+ }
47
+
48
+ # ============================================================================
49
+ # Manifest
50
+ # ============================================================================
51
+
52
+ # Usage: librarian_storage_write_manifest <key> <remote_url> <repo_root>
53
+ librarian_storage_write_manifest() {
54
+ local key="$1"
55
+ local remote_url="$2"
56
+ local repo_root="$3"
57
+ [[ -z "$key" ]] && return 1
58
+
59
+ librarian_storage_init "$key" || return 1
60
+
61
+ local manifest_path
62
+ manifest_path="$(librarian_project_dir "$key")/manifest.json"
63
+ local now
64
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
65
+
66
+ jq -n \
67
+ --arg key "$key" \
68
+ --arg remote "$remote_url" \
69
+ --arg root "$repo_root" \
70
+ --arg now "$now" \
71
+ '{
72
+ project_key: $key,
73
+ remote_url: (if $remote == "" then null else $remote end),
74
+ repo_root: (if $root == "" then null else $root end),
75
+ last_seen_at: $now
76
+ }' > "$manifest_path" 2>/dev/null
77
+ }
78
+
79
+ # ============================================================================
80
+ # Scan watermark
81
+ # ============================================================================
82
+
83
+ librarian_last_scan_path() {
84
+ local key="$1"
85
+ printf '%s/last_scan.json' "$(librarian_project_dir "$key")"
86
+ }
87
+
88
+ # Read the last scan time as ISO-8601, or empty if never scanned.
89
+ librarian_storage_read_last_scan() {
90
+ local key="$1"
91
+ local path
92
+ path=$(librarian_last_scan_path "$key")
93
+ [[ -f "$path" ]] || return 0
94
+ jq -r '.scanned_at // empty' "$path" 2>/dev/null
95
+ }
96
+
97
+ # Write the current time as the new watermark.
98
+ librarian_storage_write_last_scan() {
99
+ local key="$1"
100
+ [[ -z "$key" ]] && return 1
101
+ librarian_storage_init "$key" || return 1
102
+ local now path
103
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
104
+ path=$(librarian_last_scan_path "$key")
105
+ jq -n --arg t "$now" '{ scanned_at: $t }' > "$path" 2>/dev/null
106
+ }
107
+
108
+ # ============================================================================
109
+ # Proposal storage
110
+ # ============================================================================
111
+
112
+ # Write a single proposal file. Usage:
113
+ # librarian_storage_write_proposal <key> <ulid> <json>
114
+ librarian_storage_write_proposal() {
115
+ local key="$1"
116
+ local id="$2"
117
+ local json="$3"
118
+ [[ -z "$key" || -z "$id" || -z "$json" ]] && return 1
119
+
120
+ librarian_storage_init "$key" || return 1
121
+ local out_path
122
+ out_path="$(librarian_proposals_dir "$key")/${id}.json"
123
+ printf '%s\n' "$json" > "$out_path" 2>/dev/null && printf '%s' "$out_path"
124
+ }
125
+
126
+ # Read all proposals for a project key as a JSON array. Each entry is the raw
127
+ # proposal JSON. Order is unspecified; callers sort/filter as needed.
128
+ librarian_storage_load_proposals() {
129
+ local key="$1"
130
+ [[ -z "$key" ]] && { echo '[]'; return 0; }
131
+
132
+ local dir
133
+ dir=$(librarian_proposals_dir "$key")
134
+ [[ -d "$dir" ]] || { echo '[]'; return 0; }
135
+
136
+ local file all='[]'
137
+ for file in "$dir"/*.json; do
138
+ [[ -f "$file" ]] || continue
139
+ local item
140
+ item=$(jq '.' "$file" 2>/dev/null) || continue
141
+ all=$(printf '%s' "$all" | jq --argjson item "$item" '. + [$item]')
142
+ done
143
+ printf '%s' "$all"
144
+ }
145
+
146
+ # Count pending proposals (status == "pending").
147
+ librarian_storage_count_pending() {
148
+ local key="$1"
149
+ local all
150
+ all=$(librarian_storage_load_proposals "$key")
151
+ printf '%s' "$all" | jq '[.[] | select((.status // "pending") == "pending")] | length' 2>/dev/null
152
+ }
153
+
154
+ # ============================================================================
155
+ # Tombstone storage
156
+ # ============================================================================
157
+
158
+ # Write a tombstone keyed by body hash. Usage:
159
+ # librarian_storage_write_tombstone <key> <body_hash> <original_filename>
160
+ librarian_storage_write_tombstone() {
161
+ local key="$1"
162
+ local body_hash="$2"
163
+ local original_filename="${3:-}"
164
+ [[ -z "$key" || -z "$body_hash" ]] && return 1
165
+
166
+ librarian_storage_init "$key" || return 1
167
+ local out_path now
168
+ out_path="$(librarian_tombstones_dir "$key")/${body_hash}.json"
169
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
170
+
171
+ jq -n \
172
+ --arg body_hash "$body_hash" \
173
+ --arg original "$original_filename" \
174
+ --arg created "$now" \
175
+ '{
176
+ body_hash: $body_hash,
177
+ original_filename: (if $original == "" then null else $original end),
178
+ created_at: $created
179
+ }' > "$out_path" 2>/dev/null
180
+ }
181
+
182
+ # Returns 0 if a tombstone exists for this body hash (and is not expired).
183
+ # Usage: librarian_storage_has_tombstone <key> <body_hash> <ttl_days>
184
+ librarian_storage_has_tombstone() {
185
+ local key="$1"
186
+ local body_hash="$2"
187
+ local ttl_days="${3:-180}"
188
+ [[ -z "$key" || -z "$body_hash" ]] && return 1
189
+
190
+ local path
191
+ path="$(librarian_tombstones_dir "$key")/${body_hash}.json"
192
+ [[ -f "$path" ]] || return 1
193
+
194
+ local created_at age_days
195
+ created_at=$(jq -r '.created_at // empty' "$path" 2>/dev/null)
196
+ [[ -z "$created_at" ]] && return 0
197
+
198
+ # Age check via python3 for portable date math.
199
+ age_days=$(python3 -c "
200
+ import sys, datetime
201
+ created = datetime.datetime.strptime(sys.argv[1], '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=datetime.timezone.utc)
202
+ now = datetime.datetime.now(datetime.timezone.utc)
203
+ print(int((now - created).days))
204
+ " "$created_at" 2>/dev/null) || age_days=0
205
+
206
+ (( age_days <= ttl_days ))
207
+ }
208
+
209
+ # Compute a stable hash of a normalized memory body. Used for tombstone keys
210
+ # and conflict-state dedup. Strips whitespace runs and lowercases.
211
+ librarian_body_hash() {
212
+ local body="$1"
213
+ local normalized
214
+ normalized=$(printf '%s' "$body" | tr '[:upper:]' '[:lower:]' | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//')
215
+ if command -v shasum >/dev/null 2>&1; then
216
+ printf '%s' "$normalized" | shasum -a 256 2>/dev/null | cut -c1-16
217
+ elif command -v sha256sum >/dev/null 2>&1; then
218
+ printf '%s' "$normalized" | sha256sum 2>/dev/null | cut -c1-16
219
+ else
220
+ return 1
221
+ fi
222
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # Minimal ULID generator for Librarian proposal and tombstone IDs.
3
+ #
4
+ # Spec: https://github.com/ulid/spec
5
+ # - 48-bit timestamp (ms since epoch) → 10 chars Crockford Base32
6
+ # - 80-bit randomness → 16 chars Crockford Base32
7
+ # - lexicographically sortable, time-ordered
8
+ #
9
+ # Monotonicity across rapid bursts inside a single ms is not required; librarian
10
+ # writes proposals at SessionEnd and SessionStart cadence, never in tight loops.
11
+
12
+ _LIBRARIAN_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
13
+
14
+ # Encode a decimal integer to a fixed-length Crockford Base32 string (uppercase).
15
+ # Usage: _librarian_ulid_encode <integer> <length>
16
+ _librarian_ulid_encode() {
17
+ local n="$1"
18
+ local len="$2"
19
+ local out=""
20
+ local i
21
+ for ((i = 0; i < len; i++)); do
22
+ out="${_LIBRARIAN_ULID_ALPHABET:$((n % 32)):1}${out}"
23
+ n=$((n / 32))
24
+ done
25
+ printf '%s' "$out"
26
+ }
27
+
28
+ # Generate one ULID. Prints 26 chars (timestamp + randomness).
29
+ librarian_ulid() {
30
+ local now_ms
31
+ if [[ "$(uname)" == "Darwin" ]]; then
32
+ now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
33
+ || now_ms=$(($(date +%s) * 1000))
34
+ else
35
+ now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
36
+ fi
37
+
38
+ local rand_hi rand_lo
39
+ rand_hi=$((RANDOM * 32768 + RANDOM))
40
+ rand_lo=$((RANDOM * 32768 + RANDOM))
41
+ rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
42
+ rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
43
+
44
+ local ts_part hi_part lo_part
45
+ ts_part=$(_librarian_ulid_encode "$now_ms" 10)
46
+ hi_part=$(_librarian_ulid_encode "$rand_hi" 8)
47
+ lo_part=$(_librarian_ulid_encode "$rand_lo" 8)
48
+
49
+ printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
50
+ }
@@ -158,6 +158,38 @@
158
158
  "jsonpath": "$.version"
159
159
  }
160
160
  ]
161
+ },
162
+ "plugins/librarian": {
163
+ "changelog-path": "CHANGELOG.md",
164
+ "release-type": "simple",
165
+ "bump-minor-pre-major": true,
166
+ "bump-patch-for-minor-pre-major": false,
167
+ "component": "librarian",
168
+ "draft": false,
169
+ "prerelease": false,
170
+ "extra-files": [
171
+ {
172
+ "type": "json",
173
+ "path": ".claude-plugin/plugin.json",
174
+ "jsonpath": "$.version"
175
+ }
176
+ ]
177
+ },
178
+ "plugins/curator": {
179
+ "changelog-path": "CHANGELOG.md",
180
+ "release-type": "simple",
181
+ "bump-minor-pre-major": true,
182
+ "bump-patch-for-minor-pre-major": false,
183
+ "component": "curator",
184
+ "draft": false,
185
+ "prerelease": false,
186
+ "extra-files": [
187
+ {
188
+ "type": "json",
189
+ "path": ".claude-plugin/plugin.json",
190
+ "jsonpath": "$.version"
191
+ }
192
+ ]
161
193
  }
162
194
  },
163
195
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"