@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.
- package/.claude-plugin/marketplace.json +26 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +4 -2
- package/CHANGELOG.md +14 -0
- package/docs/memory-architecture.md +102 -0
- package/package.json +3 -3
- package/plugins/curator/.claude-plugin/plugin.json +14 -0
- package/plugins/curator/CHANGELOG.md +10 -0
- package/plugins/curator/README.md +55 -0
- package/plugins/curator/config.json +41 -0
- package/plugins/curator/docs/adr/001-staleness-tiers.md +100 -0
- package/plugins/curator/docs/design.md +311 -0
- package/plugins/curator/hooks/hooks.json +15 -0
- package/plugins/curator/scripts/hooks/curator-session-start.sh +343 -0
- package/plugins/curator/scripts/lib/curator-checks.sh +155 -0
- package/plugins/curator/scripts/lib/curator-config.sh +67 -0
- package/plugins/curator/scripts/lib/curator-emit.sh +61 -0
- package/plugins/curator/scripts/lib/curator-memory-reader.sh +225 -0
- package/plugins/curator/scripts/lib/curator-project-key.sh +82 -0
- package/plugins/curator/scripts/lib/curator-storage.sh +176 -0
- package/plugins/curator/scripts/lib/curator-ulid.sh +43 -0
- package/plugins/historian/docs/adr/001-local-embeddings-only.md +96 -0
- package/plugins/historian/docs/design.md +317 -0
- package/plugins/librarian/.claude-plugin/plugin.json +14 -0
- package/plugins/librarian/CHANGELOG.md +10 -0
- package/plugins/librarian/README.md +51 -0
- package/plugins/librarian/config.json +52 -0
- package/plugins/librarian/docs/adr/001-propose-dont-auto-write.md +87 -0
- package/plugins/librarian/docs/design.md +301 -0
- package/plugins/librarian/hooks/hooks.json +26 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +312 -0
- package/plugins/librarian/scripts/hooks/librarian-session-start.sh +103 -0
- package/plugins/librarian/scripts/lib/librarian-archivist-reader.sh +67 -0
- package/plugins/librarian/scripts/lib/librarian-classifier.sh +139 -0
- package/plugins/librarian/scripts/lib/librarian-config.sh +74 -0
- package/plugins/librarian/scripts/lib/librarian-durability.sh +77 -0
- package/plugins/librarian/scripts/lib/librarian-emit.sh +72 -0
- package/plugins/librarian/scripts/lib/librarian-project-key.sh +83 -0
- package/plugins/librarian/scripts/lib/librarian-storage.sh +222 -0
- package/plugins/librarian/scripts/lib/librarian-ulid.sh +50 -0
- package/release-please-config.json +32 -0
- package/test/bats/curator-session-start.bats +316 -0
- package/test/bats/librarian-session-end.bats +182 -0
- 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"
|