create-egregore 0.19.12 → 0.19.15

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.
@@ -0,0 +1,240 @@
1
+ #!/bin/bash
2
+ # Hybrid search over shared memory — powered by qmd (BM25 + vector + rerank, fully local).
3
+ # Usage:
4
+ # bash bin/search.sh query "text" [-n N] [--fast|--semantic] [--all]
5
+ # bash bin/search.sh reindex [--embed]
6
+ # bash bin/search.sh status
7
+ #
8
+ # The memory repo is registered as a qmd collection named "{slug}-memory".
9
+ # Every call is scoped to that collection with -c, so other qmd collections
10
+ # on the machine are never read or modified.
11
+ #
12
+ # Modes:
13
+ # (default) qmd query — hybrid BM25 + vector + rerank (needs local models, ~2GB one-time download)
14
+ # --fast qmd search — BM25 keyword only, instant, no models
15
+ # --semantic qmd vsearch — vector similarity only (needs embeddings: reindex --embed)
16
+ # Hybrid/semantic fall back to BM25 automatically if models or embeddings are missing.
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
19
+ export NODE_NO_WARNINGS=1
20
+ # Do NOT set CI=1 here — it disables qmd's LLM ops (query expansion, rerank).
21
+
22
+ # qmd's progress spinner can leak into stdout when it misdetects a TTY;
23
+ # strip ANSI escapes and spinner frames from anything we return.
24
+ _clean() {
25
+ sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' | grep -v 'Gathering information' || true
26
+ }
27
+
28
+ SLUG=$(jq -r '.slug // "egregore"' "$SCRIPT_DIR/egregore.json" 2>/dev/null || echo "egregore")
29
+ COLLECTION="${SLUG}-memory"
30
+ MEM_PATH="$(readlink -f "$SCRIPT_DIR/memory" 2>/dev/null || true)"
31
+
32
+ QMD_VERSION="2.5.3" # pinned — bump deliberately, never float @latest
33
+
34
+ _qmd() {
35
+ if command -v qmd >/dev/null 2>&1; then
36
+ qmd "$@"
37
+ else
38
+ npx -y "@tobilu/qmd@${QMD_VERSION}" "$@"
39
+ fi
40
+ }
41
+
42
+ # --- Weight ladder ----------------------------------------------------------
43
+ # Three tiers, chosen by what is already on disk — no mode ever triggers a
44
+ # model download in a session's critical path:
45
+ # keyword 0 MB BM25, works at install, forever
46
+ # semantic ~334 MB embed model, fetched silently by the background warm
47
+ # hybrid ~2.2 GB rerank + expansion — org opt-in (features.search_hybrid),
48
+ # prefetched by the background warm, never inline
49
+ MODELS_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/qmd/models"
50
+ _have_embed() { ls "$MODELS_DIR"/*embedding* >/dev/null 2>&1; }
51
+ _have_hybrid() { ls "$MODELS_DIR"/*reranker* >/dev/null 2>&1 && ls "$MODELS_DIR"/*expansion* >/dev/null 2>&1; }
52
+ _hybrid_opted() { [ "$(jq -r '.features.search_hybrid // false' "$SCRIPT_DIR/egregore.json" 2>/dev/null)" = "true" ]; }
53
+
54
+ _ensure() {
55
+ if [ -z "$MEM_PATH" ] || [ ! -d "$MEM_PATH" ]; then
56
+ echo "memory/ not found — run bin/search.sh from an Egregore instance with a memory symlink" >&2
57
+ exit 1
58
+ fi
59
+ if ! _qmd collection list 2>/dev/null | grep -q "^${COLLECTION} "; then
60
+ _qmd collection add "$MEM_PATH" --name "$COLLECTION" >/dev/null 2>&1 || true
61
+ fi
62
+ # Incremental re-index so new handoffs/decisions are searchable immediately.
63
+ # NEVER swallow a failure silently: a dead path in ANY registered collection
64
+ # (e.g. a deleted side-project dir) crashes `qmd update` for every collection,
65
+ # and the index quietly stops absorbing new documents. Caught in dogfooding
66
+ # 2026-07-07 — results looked fine while missing three days of handoffs.
67
+ if ! _qmd update >/dev/null 2>&1; then
68
+ echo "(search: index update FAILED — results may be missing recent documents. A registered qmd collection likely points at a deleted path: run 'qmd collection list', remove the dead one, retry.)" >&2
69
+ fi
70
+ _embed_bg
71
+ }
72
+
73
+ # Keep vector embeddings warm without anyone running a manual step: fire a
74
+ # detached, lock-guarded `qmd embed` on every query. Nothing pending → exits
75
+ # in ~1s. First run on a machine → downloads models + embeds in the
76
+ # background while queries fall back to keyword mode.
77
+ _embed_bg() {
78
+ [ "${EGREGORE_SEARCH_NO_WARM:-0}" = "1" ] && return 0
79
+ local lock="${TMPDIR:-/tmp}/egregore-qmd-embed-${COLLECTION}.lock"
80
+ if [ -f "$lock" ]; then
81
+ # A crashed embed shouldn't block warming forever — expire the lock after 60 min.
82
+ if [ -n "$(find "$lock" -mmin +60 2>/dev/null)" ]; then rm -f "$lock"; else return 0; fi
83
+ fi
84
+ touch "$lock"
85
+ if ! _have_embed; then
86
+ echo "(first run: building the semantic index in the background — searches use keyword mode until it finishes)" >&2
87
+ fi
88
+ # Background fetch policy: `qmd embed` pulls only the ~334MB embed model
89
+ # (the semantic tier). The ~1.9GB hybrid pack is prefetched here ONLY when
90
+ # the org opted in — a session never waits on either.
91
+ ( ( _qmd embed >/dev/null 2>&1
92
+ if _hybrid_opted && ! _have_hybrid; then
93
+ _qmd query "warmup" -c "$COLLECTION" -n 1 >/dev/null 2>&1
94
+ fi
95
+ rm -f "$lock" ) & ) >/dev/null 2>&1
96
+ }
97
+
98
+ # Annotate results with live graph state (connected mode): each hit that the
99
+ # graph knows (Session by filePath = handoffs; Artifact by filePath = knowledge)
100
+ # gets one "↳ graph:" line — search finds the artifact, the graph tells you its
101
+ # state, in a single call. Fails soft everywhere: local mode / graph offline /
102
+ # unknown paths simply produce no annotations.
103
+ _enrich() {
104
+ local results paths params out
105
+ results=$(cat)
106
+ echo "$results"
107
+ paths=$(echo "$results" | grep -o "qmd://${COLLECTION}/[^: ]*\.md" | sed "s|qmd://${COLLECTION}/||" | sort -u)
108
+ [ -z "$paths" ] && return 0
109
+ params=$(printf '%s\n' "$paths" | jq -R . | jq -sc '{paths: .}')
110
+ out=$(bash "$SCRIPT_DIR/bin/graph.sh" query "UNWIND \$paths AS fp
111
+ OPTIONAL MATCH (s:Session {filePath: fp})
112
+ OPTIONAL MATCH (a:Artifact {filePath: fp})
113
+ WITH fp, s, a WHERE s IS NOT NULL OR a IS NOT NULL
114
+ RETURN fp,
115
+ CASE WHEN s IS NOT NULL THEN coalesce(s.handoffStatus, s.status) ELSE null END AS sessionStatus,
116
+ s.handedTo AS handedTo, a.type AS artifactType,
117
+ CASE WHEN a IS NOT NULL THEN [q IN [(a)-[:PART_OF]->(qq:Quest) | qq.id] | q][0] ELSE null END AS quest" \
118
+ "$params" 2>/dev/null)
119
+ echo "$out" | jq -r '.values[]? | @tsv' 2>/dev/null | while IFS=$'\t' read -r fp sstat sto atype quest; do
120
+ local ann=""
121
+ [ -n "$sstat" ] && [ "$sstat" != "null" ] && ann="status: $sstat"
122
+ [ -n "$sto" ] && [ "$sto" != "null" ] && ann="$ann${ann:+ · }to: $sto"
123
+ [ -n "$atype" ] && [ "$atype" != "null" ] && ann="$ann${ann:+ · }artifact: $atype"
124
+ [ -n "$quest" ] && [ "$quest" != "null" ] && ann="$ann${ann:+ · }quest: $quest"
125
+ [ -n "$ann" ] && echo "↳ graph · $fp — $ann"
126
+ done
127
+ }
128
+
129
+ cmd_query() {
130
+ local mode="query" n=6 enrich=auto all=()
131
+ local -a words=()
132
+ while [ $# -gt 0 ]; do
133
+ case "$1" in
134
+ --fast) mode="search" ;;
135
+ --semantic) mode="vsearch" ;;
136
+ --enrich) enrich=1 ;;
137
+ --no-enrich) enrich=0 ;;
138
+ --all) all=(--all) ;;
139
+ -n) n="$2"; shift ;;
140
+ *) words+=("$1") ;;
141
+ esac
142
+ shift
143
+ done
144
+ # Default mode rides the weight ladder: use the heaviest tier whose models
145
+ # are already local. Explicit flags always win; nothing downloads inline.
146
+ if [ "$mode" = "query" ] && ! _have_hybrid; then
147
+ if _have_embed; then mode="vsearch"; else mode="search"; fi
148
+ fi
149
+ # Enrichment is managed, not a user choice: on wherever a graph exists
150
+ # (connected mode), silently off everywhere else. Flags only override.
151
+ if [ "$enrich" = "auto" ]; then
152
+ if [ "$(jq -r '.mode // "connected"' "$SCRIPT_DIR/egregore.json" 2>/dev/null)" != "local" ] && \
153
+ [ -n "$(jq -r '.api_url // empty' "$SCRIPT_DIR/egregore.json" 2>/dev/null)" ]; then
154
+ enrich=1
155
+ else
156
+ enrich=0
157
+ fi
158
+ fi
159
+ local q="${words[*]}"
160
+ if [ -z "$q" ]; then
161
+ echo "usage: bash bin/search.sh query \"text\" [-n N] [--fast|--semantic] [--all]" >&2
162
+ exit 1
163
+ fi
164
+ _ensure
165
+ local emit="cat"
166
+ [ "$enrich" = 1 ] && emit="_enrich"
167
+ local t0 out label
168
+ t0=$(date +%s)
169
+ if [ "$mode" != "search" ]; then
170
+ out=$(_qmd "$mode" "$q" -c "$COLLECTION" -n "$n" --format md --full-path "${all[@]}" 2>/dev/null | _clean)
171
+ if [ -n "$out" ] && [ "$out" != "No results found." ]; then
172
+ [ "$mode" = "query" ] && label="hybrid" || label="semantic"
173
+ _banner_and_emit "$label" "$t0" "$emit" "$q" "$out"
174
+ return 0
175
+ fi
176
+ echo "_(hybrid/semantic returned nothing — showing keyword results. If this persists, run: bash bin/search.sh reindex --embed)_"
177
+ echo ""
178
+ fi
179
+ out=$(_qmd search "$q" -c "$COLLECTION" -n "$n" --format md --full-path "${all[@]}" | _clean)
180
+ _banner_and_emit "keyword" "$t0" "$emit" "$q" "$out"
181
+ }
182
+
183
+ # One visible product-attribution line per search — the tell that Egregore's
184
+ # organizational retrieval layer (not an improvised agent grep loop) answered.
185
+ # It names the graph only when enrichment actually ran, then reports mode,
186
+ # hit count, and latency. Colored when stdout is a terminal; plain glyph in
187
+ # harness transcripts. Also appends to ~/.egregore/search.log (local only,
188
+ # never uploaded) so dogfooding can measure search-first rate and latency.
189
+ _banner_and_emit() {
190
+ local label="$1" t0="$2" emit="$3" q="$4" out="$5"
191
+ local hits dur modef product surface plural=""
192
+ hits=$(printf '%s\n' "$out" | grep -c '^qmd://' 2>/dev/null | tr -d ' ')
193
+ dur=$(( $(date +%s) - t0 ))
194
+ modef="$label"
195
+ product="Egregore"
196
+ surface="searching your organization’s memory"
197
+ if [ "$emit" = "_enrich" ] && [ "$hits" -gt 0 ]; then
198
+ modef="${label}+graph"
199
+ product="Egregore Connect"
200
+ surface="${surface} and relationships"
201
+ fi
202
+ [ "$hits" != "1" ] && plural="s"
203
+ local line="⌕ ${product} · ${surface} · ${label} · ${hits} hit${plural} · ${dur}s"
204
+ if [ -t 1 ]; then
205
+ printf '\033[1;38;5;30m%s\033[0m\n\n' "$line"
206
+ else
207
+ printf '%s\n\n' "$line"
208
+ fi
209
+ printf '%s\n' "$out" | $emit
210
+ mkdir -p "$HOME/.egregore" 2>/dev/null
211
+ echo "$(date '+%Y-%m-%d %H:%M:%S') mode=${modef} hits=${hits} dur=${dur}s q=\"$(printf '%.80s' "$q")\"" >> "$HOME/.egregore/search.log" 2>/dev/null || true
212
+ }
213
+
214
+ cmd_reindex() {
215
+ _ensure
216
+ _qmd update
217
+ if [ "${1:-}" = "--embed" ]; then
218
+ _qmd embed
219
+ # Hybrid pack prefetch only when the org opted in (features.search_hybrid).
220
+ if _hybrid_opted && ! _have_hybrid; then
221
+ _qmd query "warmup" -c "$COLLECTION" -n 1 >/dev/null 2>&1 || true
222
+ fi
223
+ fi
224
+ }
225
+
226
+ cmd_status() {
227
+ _qmd status 2>/dev/null
228
+ echo ""
229
+ echo "collection: $COLLECTION → $MEM_PATH"
230
+ }
231
+
232
+ case "${1:-}" in
233
+ query) shift; cmd_query "$@" ;;
234
+ reindex) shift; cmd_reindex "$@" ;;
235
+ status) shift; cmd_status ;;
236
+ *)
237
+ echo "usage: bash bin/search.sh {query|reindex|status}" >&2
238
+ exit 1
239
+ ;;
240
+ esac
@@ -184,6 +184,7 @@
184
184
  "bin/publish-artifact.sh",
185
185
  "bin/publish-references.sh",
186
186
  "bin/repo-state.sh",
187
+ "bin/search.sh",
187
188
  "bin/session-autosave.sh",
188
189
  "bin/session-log.sh",
189
190
  "bin/settings.sh",