@onlooker-community/ecosystem 0.19.0 → 0.20.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/plugin.json +1 -1
- package/.release-please-manifest.json +3 -2
- package/CHANGELOG.md +7 -0
- package/docs/memory-architecture.md +102 -0
- package/package.json +3 -3
- package/plugins/curator/docs/adr/001-staleness-tiers.md +100 -0
- package/plugins/curator/docs/design.md +311 -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 +16 -0
- package/test/bats/librarian-session-end.bats +182 -0
- package/test/bats/librarian-session-start.bats +136 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Librarian SessionStart surfacer.
|
|
3
|
+
#
|
|
4
|
+
# Counts pending proposals in the project's queue and injects a one-line
|
|
5
|
+
# `additionalContext` pointer if any exist. The full proposal bodies live
|
|
6
|
+
# in ~/.onlooker/librarian/<project-key>/proposals/ and are reviewed via
|
|
7
|
+
# the /librarian review skill rather than inlined here — SessionStart
|
|
8
|
+
# context is precious, and a queue of 20 distilled-but-unreviewed memories
|
|
9
|
+
# isn't where it should go.
|
|
10
|
+
#
|
|
11
|
+
# Hook contract:
|
|
12
|
+
# - Always exits 0. Never blocks session start.
|
|
13
|
+
# - Emits valid hookSpecificOutput JSON, even when nothing to say.
|
|
14
|
+
# - No-ops when librarian.enabled is not true.
|
|
15
|
+
# - No-ops when there is no project key (no git context).
|
|
16
|
+
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
|
|
19
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
20
|
+
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|
21
|
+
|
|
22
|
+
_ECOSYSTEM_ROOT="${ONLOOKER_ECOSYSTEM_ROOT:-}"
|
|
23
|
+
if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
|
|
24
|
+
_candidate="$(cd "${PLUGIN_ROOT}/../.." 2>/dev/null && pwd)"
|
|
25
|
+
if [[ -f "${_candidate}/scripts/lib/validate-path.sh" ]]; then
|
|
26
|
+
_ECOSYSTEM_ROOT="$_candidate"
|
|
27
|
+
fi
|
|
28
|
+
fi
|
|
29
|
+
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
30
|
+
# shellcheck disable=SC1091
|
|
31
|
+
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# shellcheck source=../lib/librarian-config.sh
|
|
35
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
|
|
36
|
+
# shellcheck source=../lib/librarian-project-key.sh
|
|
37
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
|
|
38
|
+
# shellcheck source=../lib/librarian-storage.sh
|
|
39
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
|
|
40
|
+
|
|
41
|
+
# Emit hookSpecificOutput with the given additionalContext string. An
|
|
42
|
+
# empty string is fine — the harness sees "nothing to say".
|
|
43
|
+
_emit() {
|
|
44
|
+
local context="${1:-}"
|
|
45
|
+
jq -cn --arg ctx "$context" '
|
|
46
|
+
{
|
|
47
|
+
hookSpecificOutput: {
|
|
48
|
+
hookEventName: "SessionStart",
|
|
49
|
+
additionalContext: $ctx
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
56
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
|
|
57
|
+
[[ -z "$CWD" ]] && CWD="$(pwd)"
|
|
58
|
+
|
|
59
|
+
REPO_ROOT=$(librarian_project_repo_root "$CWD")
|
|
60
|
+
librarian_config_load "$REPO_ROOT"
|
|
61
|
+
|
|
62
|
+
if ! librarian_config_enabled; then
|
|
63
|
+
_emit ""
|
|
64
|
+
exit 0
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
PROJECT_KEY=$(librarian_project_key "$CWD")
|
|
68
|
+
if [[ -z "$PROJECT_KEY" ]]; then
|
|
69
|
+
_emit ""
|
|
70
|
+
exit 0
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
SKIP_WHEN_ZERO=$(librarian_config_get '.librarian.surfacer.skip_inject_when_zero')
|
|
74
|
+
[[ -z "$SKIP_WHEN_ZERO" || "$SKIP_WHEN_ZERO" == "null" ]] && SKIP_WHEN_ZERO="true"
|
|
75
|
+
|
|
76
|
+
MAX_PENDING=$(librarian_config_get '.librarian.surfacer.max_pending_for_inject')
|
|
77
|
+
[[ -z "$MAX_PENDING" || "$MAX_PENDING" == "null" ]] && MAX_PENDING=20
|
|
78
|
+
|
|
79
|
+
PENDING=$(librarian_storage_count_pending "$PROJECT_KEY")
|
|
80
|
+
[[ -z "$PENDING" || "$PENDING" == "null" ]] && PENDING=0
|
|
81
|
+
|
|
82
|
+
if [[ "$PENDING" -eq 0 && "$SKIP_WHEN_ZERO" == "true" ]]; then
|
|
83
|
+
_emit ""
|
|
84
|
+
exit 0
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# Cap the surfaced number so a runaway queue doesn't make the pointer
|
|
88
|
+
# itself look alarming. Users still see the truthful count in
|
|
89
|
+
# /librarian review.
|
|
90
|
+
if [[ "$PENDING" -gt "$MAX_PENDING" ]]; then
|
|
91
|
+
DISPLAY_COUNT="${MAX_PENDING}+"
|
|
92
|
+
else
|
|
93
|
+
DISPLAY_COUNT="$PENDING"
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
NOUN="proposals"
|
|
97
|
+
[[ "$PENDING" -eq 1 ]] && NOUN="proposal"
|
|
98
|
+
|
|
99
|
+
CONTEXT=$(printf 'Librarian has %s pending memory promotion %s. Review with `/librarian review`.' \
|
|
100
|
+
"$DISPLAY_COUNT" "$NOUN")
|
|
101
|
+
|
|
102
|
+
_emit "$CONTEXT"
|
|
103
|
+
exit 0
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Reads archivist artifacts for the librarian scan pipeline.
|
|
3
|
+
#
|
|
4
|
+
# Archivist stores per-session artifacts under:
|
|
5
|
+
# $ONLOOKER_DIR/archivist/<project-key>/{decisions,dead_ends,open_questions}/<ulid>.json
|
|
6
|
+
#
|
|
7
|
+
# Each artifact has the shape (see archivist's storage.sh):
|
|
8
|
+
# { id, kind, project_key, source, created_at, updated_at, summary,
|
|
9
|
+
# detail, files, session_id, trigger }
|
|
10
|
+
#
|
|
11
|
+
# Librarian reads the same project-key directory and filters by created_at,
|
|
12
|
+
# returning candidates newer than the watermark.
|
|
13
|
+
|
|
14
|
+
# Resolve the archivist project dir for a given project key.
|
|
15
|
+
# Returns empty if archivist artifacts are not present.
|
|
16
|
+
librarian_archivist_project_dir() {
|
|
17
|
+
local project_key="$1"
|
|
18
|
+
[[ -z "$project_key" ]] && return 0
|
|
19
|
+
local base="${ONLOOKER_DIR:-$HOME/.onlooker}"
|
|
20
|
+
local dir="${base}/archivist/${project_key}"
|
|
21
|
+
[[ -d "$dir" ]] || return 0
|
|
22
|
+
printf '%s' "$dir"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Load archivist artifacts created since the given watermark.
|
|
26
|
+
#
|
|
27
|
+
# Usage: librarian_archivist_load_since <project_key> <watermark_iso>
|
|
28
|
+
#
|
|
29
|
+
# Watermark format: ISO-8601 (e.g., "2026-06-01T12:34:56Z"). When the
|
|
30
|
+
# watermark is empty, all artifacts are returned (used on first scan).
|
|
31
|
+
#
|
|
32
|
+
# Output: JSON array, one element per artifact, in chronological order.
|
|
33
|
+
librarian_archivist_load_since() {
|
|
34
|
+
local project_key="$1"
|
|
35
|
+
local watermark="${2:-}"
|
|
36
|
+
|
|
37
|
+
local project_dir
|
|
38
|
+
project_dir=$(librarian_archivist_project_dir "$project_key")
|
|
39
|
+
[[ -z "$project_dir" ]] && { echo '[]'; return 0; }
|
|
40
|
+
|
|
41
|
+
local kind file all='[]'
|
|
42
|
+
for kind in decisions dead_ends open_questions; do
|
|
43
|
+
[[ -d "${project_dir}/${kind}" ]] || continue
|
|
44
|
+
for file in "${project_dir}/${kind}"/*.json; do
|
|
45
|
+
[[ -f "$file" ]] || continue
|
|
46
|
+
local item created_at
|
|
47
|
+
item=$(jq '.' "$file" 2>/dev/null) || continue
|
|
48
|
+
[[ -z "$item" || "$item" == "null" ]] && continue
|
|
49
|
+
|
|
50
|
+
# Filter by watermark when provided.
|
|
51
|
+
if [[ -n "$watermark" ]]; then
|
|
52
|
+
created_at=$(printf '%s' "$item" | jq -r '.created_at // .updated_at // ""' 2>/dev/null)
|
|
53
|
+
[[ -z "$created_at" ]] && continue
|
|
54
|
+
# Lexicographic compare works for ISO-8601 UTC strings.
|
|
55
|
+
if [[ "$created_at" < "$watermark" || "$created_at" == "$watermark" ]]; then
|
|
56
|
+
continue
|
|
57
|
+
fi
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
all=$(printf '%s' "$all" | jq --argjson item "$item" '. + [$item]')
|
|
61
|
+
done
|
|
62
|
+
done
|
|
63
|
+
|
|
64
|
+
# Sort chronologically; downstream classifier groups by session_id and
|
|
65
|
+
# benefits from stable order.
|
|
66
|
+
printf '%s' "$all" | jq 'sort_by(.created_at // .updated_at // "")'
|
|
67
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Type classifier for librarian candidates.
|
|
3
|
+
#
|
|
4
|
+
# Calls `claude -p` with a structured prompt that maps a single archivist
|
|
5
|
+
# artifact to one of the four memory types (user, feedback, project,
|
|
6
|
+
# reference) or null when the artifact is interesting but session-only.
|
|
7
|
+
#
|
|
8
|
+
# Returns the model's JSON response on stdout, or empty string on any
|
|
9
|
+
# error (timeout, missing CLI, invalid JSON, low confidence). Callers
|
|
10
|
+
# treat empty as "drop this candidate".
|
|
11
|
+
#
|
|
12
|
+
# Config inputs (read via librarian_config_get from the caller):
|
|
13
|
+
# librarian.classifier.model Anthropic model id
|
|
14
|
+
# librarian.classifier.temperature Sampling temperature
|
|
15
|
+
# librarian.classifier.max_output_tokens Output cap
|
|
16
|
+
# librarian.classifier.min_classifier_confidence Drop below this
|
|
17
|
+
|
|
18
|
+
# Hard wall-clock ceiling for a single classifier call. We never want a
|
|
19
|
+
# hung LLM to delay SessionEnd more than this.
|
|
20
|
+
_LIBRARIAN_CLASSIFIER_TIMEOUT_SECONDS=20
|
|
21
|
+
|
|
22
|
+
# Build the classifier prompt for a single artifact.
|
|
23
|
+
# Usage: librarian_classifier_build_prompt <artifact_json>
|
|
24
|
+
librarian_classifier_build_prompt() {
|
|
25
|
+
local artifact="$1"
|
|
26
|
+
local kind summary detail files_list session_id created_at
|
|
27
|
+
|
|
28
|
+
kind=$(printf '%s' "$artifact" | jq -r '.kind // ""')
|
|
29
|
+
summary=$(printf '%s' "$artifact" | jq -r '.summary // ""')
|
|
30
|
+
detail=$(printf '%s' "$artifact" | jq -r '.detail // ""')
|
|
31
|
+
files_list=$(printf '%s' "$artifact" | jq -r '(.files // []) | join(", ")')
|
|
32
|
+
session_id=$(printf '%s' "$artifact" | jq -r '.session_id // ""')
|
|
33
|
+
created_at=$(printf '%s' "$artifact" | jq -r '.created_at // ""')
|
|
34
|
+
|
|
35
|
+
cat <<EOF
|
|
36
|
+
You are classifying a session artifact for promotion into a long-term memory store.
|
|
37
|
+
|
|
38
|
+
The store has four types:
|
|
39
|
+
- user: durable facts about the user's role, expertise, or working style
|
|
40
|
+
- feedback: corrections or validated preferences ("don't do X", "yes, keep doing Y")
|
|
41
|
+
- project: ongoing work facts, decisions, constraints not derivable from the code
|
|
42
|
+
- reference: pointers to external systems (issue trackers, dashboards, channels)
|
|
43
|
+
|
|
44
|
+
RULES:
|
|
45
|
+
- Output ONLY a single JSON object on one line, no markdown fences, no prose.
|
|
46
|
+
- Schema: { "type": "<user|feedback|project|reference|null>",
|
|
47
|
+
"title": "<<=60 chars>",
|
|
48
|
+
"body": "<the memory content; structure per type>",
|
|
49
|
+
"confidence": <float 0-1> }
|
|
50
|
+
- Use "type": null when the artifact is interesting but session-only (a
|
|
51
|
+
specific bug fix, a one-off question that got answered, an exploration
|
|
52
|
+
that didn't change anything).
|
|
53
|
+
- For feedback and project types, include **Why:** and **How to apply:**
|
|
54
|
+
lines inside the body.
|
|
55
|
+
|
|
56
|
+
<artifact>
|
|
57
|
+
kind: ${kind}
|
|
58
|
+
summary: ${summary}
|
|
59
|
+
detail: ${detail}
|
|
60
|
+
files: ${files_list}
|
|
61
|
+
session_id: ${session_id}
|
|
62
|
+
created_at: ${created_at}
|
|
63
|
+
</artifact>
|
|
64
|
+
EOF
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Call the classifier for one artifact. Prints the model's JSON output or
|
|
68
|
+
# empty string on error.
|
|
69
|
+
#
|
|
70
|
+
# Usage: librarian_classifier_call <artifact_json> <model> <temperature>
|
|
71
|
+
# <max_output_tokens>
|
|
72
|
+
librarian_classifier_call() {
|
|
73
|
+
local artifact="$1"
|
|
74
|
+
local model="${2:-}"
|
|
75
|
+
local temperature="${3:-0.2}"
|
|
76
|
+
local max_tokens="${4:-256}"
|
|
77
|
+
|
|
78
|
+
command -v claude >/dev/null 2>&1 || return 0
|
|
79
|
+
[[ -z "$artifact" ]] && return 0
|
|
80
|
+
|
|
81
|
+
local prompt_file
|
|
82
|
+
prompt_file=$(mktemp -t librarian-classify.XXXXXX 2>/dev/null) \
|
|
83
|
+
|| prompt_file="/tmp/librarian-classify.$$"
|
|
84
|
+
# shellcheck disable=SC2064
|
|
85
|
+
trap "rm -f '$prompt_file'" EXIT
|
|
86
|
+
|
|
87
|
+
librarian_classifier_build_prompt "$artifact" > "$prompt_file" || return 0
|
|
88
|
+
|
|
89
|
+
local args=(-p --max-turns 1)
|
|
90
|
+
[[ -n "$model" ]] && args+=(--model "$model")
|
|
91
|
+
|
|
92
|
+
local response=""
|
|
93
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
94
|
+
response=$(timeout "$_LIBRARIAN_CLASSIFIER_TIMEOUT_SECONDS" \
|
|
95
|
+
claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
|
|
96
|
+
elif command -v gtimeout >/dev/null 2>&1; then
|
|
97
|
+
response=$(gtimeout "$_LIBRARIAN_CLASSIFIER_TIMEOUT_SECONDS" \
|
|
98
|
+
claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
|
|
99
|
+
else
|
|
100
|
+
response=$(claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
rm -f "$prompt_file"
|
|
104
|
+
trap - EXIT
|
|
105
|
+
|
|
106
|
+
[[ -z "$response" ]] && return 0
|
|
107
|
+
|
|
108
|
+
# Strip accidental markdown fences before parsing.
|
|
109
|
+
local clean
|
|
110
|
+
clean=$(printf '%s' "$response" | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//')
|
|
111
|
+
|
|
112
|
+
# Validate the response shape before passing it back.
|
|
113
|
+
if ! printf '%s' "$clean" | jq -e '
|
|
114
|
+
(.type == null or (.type | IN("user", "feedback", "project", "reference")))
|
|
115
|
+
and (.title | type) == "string"
|
|
116
|
+
and (.body | type) == "string"
|
|
117
|
+
and (.confidence | type) == "number"
|
|
118
|
+
' >/dev/null 2>&1; then
|
|
119
|
+
return 0
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
printf '%s' "$clean"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Synthesize a deterministic filename from a classifier result.
|
|
126
|
+
# Used when writing accepted promotions into the typed memory store.
|
|
127
|
+
# Format: <type>_<slugified-title>.md
|
|
128
|
+
#
|
|
129
|
+
# Usage: librarian_classifier_filename <type> <title>
|
|
130
|
+
librarian_classifier_filename() {
|
|
131
|
+
local type="$1"
|
|
132
|
+
local title="$2"
|
|
133
|
+
local slug
|
|
134
|
+
slug=$(printf '%s' "$title" | tr '[:upper:]' '[:lower:]' \
|
|
135
|
+
| sed -E 's/[^a-z0-9]+/_/g; s/^_+|_+$//g' \
|
|
136
|
+
| cut -c1-60)
|
|
137
|
+
[[ -z "$slug" ]] && slug="memory"
|
|
138
|
+
printf '%s_%s.md' "$type" "$slug"
|
|
139
|
+
}
|
|
@@ -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
|
+
}
|