@onlooker-community/ecosystem 0.31.1 → 0.31.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Observability substrate for Claude Code. Provides the shared $ONLOOKER_DIR storage root (default $HOME/.onlooker), canonical schema-validated event emission, session and tool tracking hooks, and prompt rules. Required by all other Onlooker plugins.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,5 @@
1
1
  {
2
- ".": "0.31.1",
2
+ ".": "0.31.2",
3
3
  "plugins/archivist": "0.3.1",
4
4
  "plugins/tribunal": "1.1.1",
5
5
  "plugins/echo": "0.3.1",
@@ -11,7 +11,7 @@
11
11
  "plugins/warden": "0.3.1",
12
12
  "plugins/librarian": "0.4.1",
13
13
  "plugins/curator": "0.2.1",
14
- "plugins/historian": "0.3.1",
14
+ "plugins/historian": "0.3.2",
15
15
  "plugins/assayer": "1.1.1",
16
16
  "plugins/bursar": "0.2.1",
17
17
  "plugins/lineage": "0.2.1",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.31.2](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.31.1...ecosystem-v0.31.2) (2026-08-01)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **historian:** export CLAUDE_PLUGIN_ROOT so config loads correctly :bug: ([#112](https://github.com/onlooker-community/ecosystem/issues/112)) ([f24b0b4](https://github.com/onlooker-community/ecosystem/commit/f24b0b4375c64385fefd329cd07d8874785002e9))
9
+
3
10
  ## [0.31.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.31.0...ecosystem-v0.31.1) (2026-08-01)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
4
4
  "description": "Agents, skills, hooks, commands, rules, and MCP configurations that power [Onlooker](https://onlooker.dev)",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "historian",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Episodic memory layer. At SessionEnd, chunks and sanitizes the session transcript and stores chunks under $ONLOOKER_DIR/historian/<project-key>/sessions/ (default $HOME/.onlooker). On UserPromptSubmit, embeds the prompt and performs similarity retrieval over stored chunks to surface relevant past context. Builds on the Onlooker ecosystem plugin.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2](https://github.com/onlooker-community/ecosystem/compare/historian-v0.3.1...historian-v0.3.2) (2026-08-01)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **historian:** export CLAUDE_PLUGIN_ROOT so config loads correctly :bug: ([#112](https://github.com/onlooker-community/ecosystem/issues/112)) ([f24b0b4](https://github.com/onlooker-community/ecosystem/commit/f24b0b4375c64385fefd329cd07d8874785002e9))
9
+
3
10
  ## [0.3.1](https://github.com/onlooker-community/ecosystem/compare/historian-v0.3.0...historian-v0.3.1) (2026-08-01)
4
11
 
5
12
 
@@ -50,7 +50,8 @@ if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
50
50
  fi
51
51
  if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
52
52
  # shellcheck disable=SC1091
53
- CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
53
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
54
+ source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
54
55
  fi
55
56
 
56
57
  # shellcheck source=../lib/historian-config.sh
@@ -37,7 +37,8 @@ if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
37
37
  fi
38
38
  if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
39
39
  # shellcheck disable=SC1091
40
- CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
40
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
41
+ source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
41
42
  fi
42
43
 
43
44
  # shellcheck source=../lib/historian-config.sh
@@ -57,6 +57,8 @@ source "${PLUGIN_ROOT}/scripts/lib/librarian-archivist-reader.sh"
57
57
  source "${PLUGIN_ROOT}/scripts/lib/librarian-durability.sh"
58
58
  # shellcheck source=../lib/librarian-classifier.sh
59
59
  source "${PLUGIN_ROOT}/scripts/lib/librarian-classifier.sh"
60
+ # shellcheck source=../lib/librarian-conflict-detector.sh
61
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-conflict-detector.sh"
60
62
 
61
63
  INPUT=$(cat 2>/dev/null || true)
62
64
  CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
@@ -232,14 +234,75 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
232
234
  continue
233
235
  fi
234
236
 
235
- # Build and persist the proposal. Conflict detection against the user's
236
- # memory store is deferred to a follow-up commit; everything ships as
237
- # conflict_state: "none" for now.
237
+ # Build and persist the proposal. Detect conflicts against the user's
238
+ # memory store before writing.
238
239
  PROPOSAL_ID=$(librarian_ulid)
239
240
  FILENAME=$(librarian_classifier_filename "$MEMORY_TYPE" "$TITLE")
240
241
  ARTIFACT_ID=$(printf '%s' "$ARTIFACT" | jq -r '.id // ""')
241
242
  ARTIFACT_SESSION=$(printf '%s' "$ARTIFACT" | jq -r '.session_id // ""')
242
243
 
244
+ # Resolve the typed memory store path for this project.
245
+ MEMORY_STORE_PATH=$(librarian_config_get '.librarian.memory_store_path')
246
+ [[ -z "$MEMORY_STORE_PATH" || "$MEMORY_STORE_PATH" == "null" ]] && \
247
+ MEMORY_STORE_PATH='${HOME}/.claude/projects/${CLAUDE_PROJECT_ENCODED}/memory'
248
+
249
+ # Expand env vars in the path.
250
+ MEMORY_STORE_PATH=$(eval echo "$MEMORY_STORE_PATH")
251
+
252
+ # Initialize conflict state; will scan if memory dir exists.
253
+ CONFLICT_STATE="none"
254
+ CONFLICT_WITH="[]"
255
+
256
+ # Detect conflicts against existing memories only if the directory exists.
257
+ if [[ -d "$MEMORY_STORE_PATH" ]]; then
258
+ # Build a temporary proposal for conflict detection.
259
+ TEMP_PROPOSAL=$(jq -n \
260
+ --arg id "$PROPOSAL_ID" \
261
+ --arg memory_type "$MEMORY_TYPE" \
262
+ --arg filename "$FILENAME" \
263
+ --arg title "$TITLE" \
264
+ --arg body "$BODY" \
265
+ --argjson classifier_confidence "$CONFIDENCE" \
266
+ '{
267
+ id: $id,
268
+ proposed: {
269
+ type: $memory_type,
270
+ filename: $filename,
271
+ title: $title,
272
+ body: $body,
273
+ classifier_confidence: $classifier_confidence
274
+ }
275
+ }')
276
+
277
+ # Detect conflicts against existing memories.
278
+ DUP_THRESHOLD=$(librarian_config_get '.librarian.conflict.duplicate_threshold')
279
+ [[ -z "$DUP_THRESHOLD" || "$DUP_THRESHOLD" == "null" ]] && DUP_THRESHOLD="0.7"
280
+ MERGE_THRESHOLD=$(librarian_config_get '.librarian.conflict.merge_candidate_threshold')
281
+ [[ -z "$MERGE_THRESHOLD" || "$MERGE_THRESHOLD" == "null" ]] && MERGE_THRESHOLD="0.45"
282
+
283
+ CONFLICT_RESULT=$(librarian_conflict_scan "$TEMP_PROPOSAL" "$MEMORY_STORE_PATH" \
284
+ "$DUP_THRESHOLD" "$MERGE_THRESHOLD" 2>/dev/null) || CONFLICT_RESULT=""
285
+
286
+ # Ensure we have valid JSON; fall back to "none" if scan fails.
287
+ if [[ -z "$CONFLICT_RESULT" ]] || ! printf '%s' "$CONFLICT_RESULT" | jq -e '.' >/dev/null 2>&1; then
288
+ CONFLICT_RESULT='{"conflict_state":"none","conflict_with":[]}'
289
+ fi
290
+
291
+ CONFLICT_STATE=$(printf '%s' "$CONFLICT_RESULT" | jq -r '.conflict_state // "none"' 2>/dev/null) || CONFLICT_STATE="none"
292
+ CONFLICT_WITH=$(printf '%s' "$CONFLICT_RESULT" | jq -c '.conflict_with // []' 2>/dev/null) || CONFLICT_WITH="[]"
293
+
294
+ # Silently drop duplicates — no proposal written.
295
+ if [[ "$CONFLICT_STATE" == "duplicate" ]]; then
296
+ POST_CLASSIFIER_DROPPED=$((POST_CLASSIFIER_DROPPED + 1))
297
+ librarian_emit "librarian.candidate.dropped" "$SESSION_ID" "$(jq -cn \
298
+ --arg reason "duplicate" \
299
+ --arg src "$ARTIFACT_ID" \
300
+ '{ reason: $reason, source_artifact_id: (if $src == "" then null else $src end) }
301
+ | with_entries(select(.value != null))')"
302
+ continue
303
+ fi
304
+ fi
305
+
243
306
  PROPOSAL_JSON=$(jq -n \
244
307
  --arg id "$PROPOSAL_ID" \
245
308
  --arg created_at "$NOW_TS" \
@@ -248,7 +311,8 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
248
311
  --arg title "$TITLE" \
249
312
  --arg body "$BODY" \
250
313
  --argjson classifier_confidence "$CONFIDENCE" \
251
- --arg conflict_state "none" \
314
+ --arg conflict_state "$CONFLICT_STATE" \
315
+ --argjson conflict_with "$CONFLICT_WITH" \
252
316
  --arg artifact_id "$ARTIFACT_ID" \
253
317
  --arg artifact_session "$ARTIFACT_SESSION" \
254
318
  '{
@@ -264,7 +328,7 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
264
328
  classifier_confidence: $classifier_confidence
265
329
  },
266
330
  conflict_state: $conflict_state,
267
- conflict_with: [],
331
+ conflict_with: $conflict_with,
268
332
  status: "pending"
269
333
  }')
270
334
 
@@ -314,7 +378,7 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
314
378
  --arg proposal_id "$PROPOSAL_ID" \
315
379
  --arg memory_type "$MEMORY_TYPE" \
316
380
  --argjson classifier_confidence "$CONFIDENCE" \
317
- --arg conflict_state "none" \
381
+ --arg conflict_state "$CONFLICT_STATE" \
318
382
  --arg src "$ARTIFACT_ID" \
319
383
  '{
320
384
  proposal_id: $proposal_id,
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env bash
2
+ # Conflict and duplicate detection for librarian proposals.
3
+ #
4
+ # Compares proposed memories against existing memories in the user's
5
+ # typed memory store (~/.claude/projects/<encoded>/memory/) and classifies
6
+ # each proposal into one of:
7
+ # - none (no similarity, safe to propose)
8
+ # - duplicate (similarity >= duplicate_threshold; drop silently)
9
+ # - merge_candidate (merge_threshold <= similarity < duplicate_threshold)
10
+ # - conflict_candidate (opposing sentiment on overlapping content)
11
+ #
12
+ # Uses deterministic token-set Jaccard similarity (no LLM call) with
13
+ # configurable thresholds.
14
+
15
+ # Extract and normalize tokens from text for similarity comparison.
16
+ # Lowercases, removes stopwords, splits on non-alphanumeric.
17
+ _librarian_conflict_normalize() {
18
+ local text="$1"
19
+ printf '%s' "$text" \
20
+ | tr '[:upper:]' '[:lower:]' \
21
+ | tr -cs '[:alnum:]' '\n' \
22
+ | grep -v '^$' \
23
+ | sort -u \
24
+ | paste -sd ' ' -
25
+ }
26
+
27
+ # Compute Jaccard similarity (intersection / union) of two token sets.
28
+ # Input: two space-separated token strings.
29
+ # Output: float 0-1.
30
+ _librarian_conflict_jaccard() {
31
+ local tokens1="$1"
32
+ local tokens2="$2"
33
+
34
+ [[ -z "$tokens1" || -z "$tokens2" ]] && { printf '0'; return; }
35
+
36
+ TOKENS1="$tokens1" TOKENS2="$tokens2" python3 -c '
37
+ import os
38
+ tokens1 = set(os.environ.get("TOKENS1", "").split())
39
+ tokens2 = set(os.environ.get("TOKENS2", "").split())
40
+ if not tokens1 and not tokens2:
41
+ print(0)
42
+ else:
43
+ intersection = len(tokens1 & tokens2)
44
+ union = len(tokens1 | tokens2)
45
+ similarity = intersection / union if union > 0 else 0
46
+ print(f"{similarity:.2f}")
47
+ '
48
+ }
49
+
50
+ # Check for opposing sentiment markers (do/don't, always/never, etc.).
51
+ # Returns 0 if opposing markers found, 1 otherwise.
52
+ _librarian_conflict_opposing_sentiment() {
53
+ local text1="$1"
54
+ local text2="$2"
55
+
56
+ # Normalize to lowercase.
57
+ text1=$(printf '%s' "$text1" | tr '[:upper:]' '[:lower:]')
58
+ text2=$(printf '%s' "$text2" | tr '[:upper:]' '[:lower:]')
59
+
60
+ # Define opposing pairs.
61
+ local -a pairs=("always:never" "never:always" "do:don't" "don't:do" "prefer:avoid"
62
+ "avoid:prefer" "enable:disable" "disable:enable" "keep:remove"
63
+ "remove:keep" "use:avoid" "avoid:use")
64
+
65
+ for pair in "${pairs[@]}"; do
66
+ local a="${pair%%:*}"
67
+ local b="${pair##*:}"
68
+
69
+ # Check if text1 has 'a' and text2 has 'b', or vice versa.
70
+ if [[ "$text1" == *"$a"* && "$text2" == *"$b"* ]]; then
71
+ return 0 # opposing
72
+ fi
73
+ if [[ "$text1" == *"$b"* && "$text2" == *"$a"* ]]; then
74
+ return 0 # opposing
75
+ fi
76
+ done
77
+
78
+ return 1 # not opposing
79
+ }
80
+
81
+ # Detect conflict state for a single proposal against a single existing memory.
82
+ # Returns conflict classification and (optionally) the conflicting memory filename.
83
+ #
84
+ # Usage: librarian_conflict_check <proposal_body> <existing_memory_path> \
85
+ # <duplicate_threshold> <merge_threshold>
86
+ #
87
+ # Output: JSON object { conflict_state, conflicting_file (optional) }
88
+ librarian_conflict_check() {
89
+ local proposal_body="$1"
90
+ local memory_path="$2"
91
+ local dup_threshold="${3:-0.7}"
92
+ local merge_threshold="${4:-0.45}"
93
+
94
+ [[ -z "$proposal_body" || -z "$memory_path" ]] && {
95
+ printf '%s' '{"conflict_state":"none"}'
96
+ return 0
97
+ }
98
+ [[ ! -f "$memory_path" ]] && {
99
+ printf '%s' '{"conflict_state":"none"}'
100
+ return 0
101
+ }
102
+
103
+ # Extract the body of the existing memory (skip frontmatter).
104
+ local existing_body
105
+ existing_body=$(awk '/^---$/{ if (++count == 2) { in_body = 1; next } }
106
+ in_body { print }' "$memory_path")
107
+
108
+ [[ -z "$existing_body" ]] && {
109
+ printf '%s' '{"conflict_state":"none"}'
110
+ return 0
111
+ }
112
+
113
+ # Normalize both bodies to token sets.
114
+ local prop_tokens existing_tokens
115
+ prop_tokens=$(_librarian_conflict_normalize "$proposal_body")
116
+ existing_tokens=$(_librarian_conflict_normalize "$existing_body")
117
+
118
+ # Compute Jaccard similarity.
119
+ local similarity
120
+ similarity=$(_librarian_conflict_jaccard "$prop_tokens" "$existing_tokens")
121
+
122
+ # Classify based on similarity + optional sentiment check.
123
+ if awk "BEGIN { exit !($similarity >= $dup_threshold) }"; then
124
+ # Definitely a duplicate.
125
+ jq -cn --arg file "$(basename "$memory_path")" \
126
+ '{ conflict_state: "duplicate", conflicting_file: $file }'
127
+ elif awk "BEGIN { exit !($similarity >= $merge_threshold) }"; then
128
+ # Merge candidate — significant overlap but not identical.
129
+ jq -cn --arg file "$(basename "$memory_path")" \
130
+ '{ conflict_state: "merge_candidate", conflicting_file: $file }'
131
+ elif awk "BEGIN { exit !($similarity >= $merge_threshold * 0.8) }"; then
132
+ # Check for opposing sentiment in the overlap zone.
133
+ if _librarian_conflict_opposing_sentiment "$proposal_body" "$existing_body"; then
134
+ jq -cn --arg file "$(basename "$memory_path")" \
135
+ '{ conflict_state: "conflict_candidate", conflicting_file: $file }'
136
+ else
137
+ printf '%s' '{"conflict_state":"none"}'
138
+ fi
139
+ else
140
+ # No meaningful overlap.
141
+ printf '%s' '{"conflict_state":"none"}'
142
+ fi
143
+ }
144
+
145
+ # Scan a proposal against all existing memories in the project.
146
+ # Returns the highest-priority conflict found (duplicate > conflict > merge).
147
+ #
148
+ # Usage: librarian_conflict_scan <proposal_json> <memory_dir> \
149
+ # <duplicate_threshold> <merge_threshold>
150
+ #
151
+ # Output: JSON object { conflict_state, conflict_with: [...] }
152
+ librarian_conflict_scan() {
153
+ local proposal="$1"
154
+ local memory_dir="$2"
155
+ local dup_threshold="${3:-0.7}"
156
+ local merge_threshold="${4:-0.45}"
157
+
158
+ [[ -z "$proposal" || -z "$memory_dir" ]] && {
159
+ printf '%s' '{"conflict_state":"none","conflict_with":[]}'
160
+ return 0
161
+ }
162
+ [[ ! -d "$memory_dir" ]] && {
163
+ printf '%s' '{"conflict_state":"none","conflict_with":[]}'
164
+ return 0
165
+ }
166
+
167
+ local proposal_body
168
+ proposal_body=$(printf '%s' "$proposal" | jq -r '.proposed.body // ""')
169
+ [[ -z "$proposal_body" ]] && {
170
+ printf '%s' '{"conflict_state":"none","conflict_with":[]}'
171
+ return 0
172
+ }
173
+
174
+ local worst_state=""
175
+ local conflicts='[]'
176
+
177
+ # Scan all memory files in the project.
178
+ for memory_file in "$memory_dir"/*.md; do
179
+ [[ ! -f "$memory_file" ]] && continue
180
+
181
+ local check
182
+ check=$(librarian_conflict_check "$proposal_body" "$memory_file" \
183
+ "$dup_threshold" "$merge_threshold")
184
+ [[ -z "$check" ]] && continue
185
+
186
+ local state
187
+ state=$(printf '%s' "$check" | jq -r '.conflict_state // "none"')
188
+ local filename
189
+ filename=$(printf '%s' "$check" | jq -r '.conflicting_file // ""')
190
+
191
+ # Track conflicts; prioritize by severity: duplicate > conflict > merge.
192
+ case "$state" in
193
+ duplicate)
194
+ worst_state="duplicate"
195
+ conflicts=$(printf '%s' "$conflicts" | jq --arg f "$filename" '. + [$f]')
196
+ ;;
197
+ conflict_candidate)
198
+ if [[ "$worst_state" != "duplicate" ]]; then
199
+ worst_state="conflict_candidate"
200
+ conflicts=$(printf '%s' "$conflicts" | jq --arg f "$filename" '. + [$f]')
201
+ fi
202
+ ;;
203
+ merge_candidate)
204
+ if [[ -z "$worst_state" ]]; then
205
+ worst_state="merge_candidate"
206
+ conflicts=$(printf '%s' "$conflicts" | jq --arg f "$filename" '. + [$f]')
207
+ fi
208
+ ;;
209
+ esac
210
+ done
211
+
212
+ [[ -z "$worst_state" ]] && worst_state="none"
213
+ printf '%s' "$(jq -cn --arg state "$worst_state" --argjson files "$conflicts" \
214
+ '{ conflict_state: $state, conflict_with: $files }')"
215
+ }