@noesis-brain/mcp-server 2.1.3 → 2.3.1

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 (36) hide show
  1. package/dist/agent/identity.d.ts +33 -0
  2. package/dist/agent/identity.d.ts.map +1 -0
  3. package/dist/agent/identity.js +61 -0
  4. package/dist/agent/identity.js.map +1 -0
  5. package/dist/agent/runner.d.ts +194 -0
  6. package/dist/agent/runner.d.ts.map +1 -1
  7. package/dist/agent/runner.js +465 -50
  8. package/dist/agent/runner.js.map +1 -1
  9. package/dist/agent/tokenState.d.ts +81 -0
  10. package/dist/agent/tokenState.d.ts.map +1 -0
  11. package/dist/agent/tokenState.js +173 -0
  12. package/dist/agent/tokenState.js.map +1 -0
  13. package/dist/agent/webGuard.d.ts +48 -0
  14. package/dist/agent/webGuard.d.ts.map +1 -0
  15. package/dist/agent/webGuard.js +173 -0
  16. package/dist/agent/webGuard.js.map +1 -0
  17. package/dist/api/NoesisClient.d.ts +13 -1
  18. package/dist/api/NoesisClient.d.ts.map +1 -1
  19. package/dist/api/NoesisClient.js +18 -3
  20. package/dist/api/NoesisClient.js.map +1 -1
  21. package/dist/tools/claudeSessions.d.ts +72 -0
  22. package/dist/tools/claudeSessions.d.ts.map +1 -0
  23. package/dist/tools/claudeSessions.js +304 -0
  24. package/dist/tools/claudeSessions.js.map +1 -0
  25. package/dist/tools/index.d.ts +1 -6
  26. package/dist/tools/index.d.ts.map +1 -1
  27. package/dist/tools/index.js +188 -55
  28. package/dist/tools/index.js.map +1 -1
  29. package/dist/tools/sessionIndex.d.ts +66 -0
  30. package/dist/tools/sessionIndex.d.ts.map +1 -0
  31. package/dist/tools/sessionIndex.js +241 -0
  32. package/dist/tools/sessionIndex.js.map +1 -0
  33. package/package.json +1 -1
  34. package/scripts/noesis-capture-watcher.mjs +722 -107
  35. package/scripts/start-agent.sh +331 -0
  36. package/skill-templates/noesis-refine-relations.md +163 -0
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Start the Noesis Local Agent daemon, replacing any instance already running — and
4
+ # refuse to leave a daemon running that lacks the tool-boundary fix.
5
+ #
6
+ # Why this exists: the daemon is normally launched by hand
7
+ # (`npx @noesis-brain/mcp-server@latest agent`), and nothing stops a second one. On
8
+ # 2026-08-14 four were running at once, two rooted at $HOME. They all poll the same queue,
9
+ # so whichever won the race decided which build served the job — a stale daemon answered
10
+ # minutes after a fixed one started, which looked exactly like a fix that did not work.
11
+ #
12
+ # Killing is deliberately two passes because the launcher is TWO processes: an
13
+ # `npm exec @noesis-brain/mcp-server@<spec> agent` parent and a `node .../noesis-mcp agent`
14
+ # child. Killing only the child lets npm respawn it.
15
+ #
16
+ # WHY THE VERSION ASSERT: an earlier version of this script started `@latest` by default
17
+ # while the fix was still unpublished, so its no-argument path SIGKILLed the fixed daemon,
18
+ # started an UNFIXED one, and printed success — silently restoring the vulnerability it
19
+ # exists to prevent. Publishing lags code, so the script must verify what it actually
20
+ # started rather than trust the tag.
21
+ #
22
+ # Usage: ./scripts/start-agent.sh # published build via npx (default)
23
+ # ./scripts/start-agent.sh --local # this checkout's dist/ (after npm run build)
24
+ # ./scripts/start-agent.sh --status # report running daemons, exit 1 if any is unfixed
25
+ # ./scripts/start-agent.sh --stop # stop everything, start nothing
26
+ # ./scripts/start-agent.sh --grade <package-dir> # grade one build; touches no process
27
+
28
+ set -euo pipefail
29
+
30
+ # Resolve this script's own location BEFORE any cd — $BASH_SOURCE is relative when invoked
31
+ # as ./scripts/start-agent.sh, so reading it after the cd to $RUN_DIR resolves against the
32
+ # wrong directory and --local would fail.
33
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ PKG_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
35
+
36
+ # First release carrying `tools: []` + the canUseTool gate. A daemon below this exposes
37
+ # Claude Code's whole built-in tool set (Bash/Read/Write/Edit/Grep/Task) to every Navi.
38
+ MIN_FIXED_VERSION="2.1.5"
39
+
40
+ MODE="${1:-}"
41
+ case "$MODE" in
42
+ ''|--local|--stop|--status) ;;
43
+ --grade)
44
+ [ -n "${2:-}" ] || { echo "usage: $(basename "$0") --grade <package-dir>" >&2; exit 2; }
45
+ ;;
46
+ *)
47
+ echo "usage: $(basename "$0") [--local | --status | --stop | --grade <package-dir>]" >&2
48
+ # Deliberately NOT a silent fallback: a typo'd flag used to start the PUBLISHED build
49
+ # instead of the local one — the same "wrong build served the job" signature.
50
+ exit 2
51
+ ;;
52
+ esac
53
+
54
+ RUN_DIR="${NOESIS_AGENT_RUN_DIR:-$HOME/.noesis-agent}"
55
+ LOG_FILE="$RUN_DIR/agent.log"
56
+
57
+ # Every shape the daemon can appear as, matched against the full command line.
58
+ # `@noesis-brain/mcp-server(@[^ ]*)? agent` covers the npm exec parent for a pinned spec,
59
+ # a tag, AND no spec. A plain MCP-server invocation has no ` agent` argument and is never
60
+ # matched: that process is the user's Claude Code integration, not a daemon.
61
+ PATTERNS=(
62
+ 'noesis-mcp agent'
63
+ '@noesis-brain/mcp-server(@[^ ]*)? agent'
64
+ 'dist/index.js agent'
65
+ )
66
+
67
+ # Only ever our own processes. Unscoped pgrep also matches other users' and root's, which
68
+ # `kill` cannot touch (EPERM) — those would be counted as survivors forever, wedging the
69
+ # guard into "refusing to start" after having already killed the real daemon.
70
+ pids_matching() {
71
+ for pattern in "${PATTERNS[@]}"; do
72
+ pgrep -U "$(id -u)" -f "$pattern" 2>/dev/null || true
73
+ done | sort -un
74
+ }
75
+
76
+ # `|| true` inside the braces is load-bearing: pgrep exits 1 on no match, and with
77
+ # `set -e` + `pipefail` that would abort. Dedupe, or a process matching two patterns
78
+ # inflates the count and can trip the refuse-to-start guard.
79
+ count_matching() { pids_matching | wc -l | tr -d ' '; }
80
+
81
+ kill_matching() {
82
+ local signal="$1" pid
83
+ while read -r pid; do
84
+ [ -z "$pid" ] && continue
85
+ [ "$pid" = "$$" ] && continue
86
+ [ "$pid" = "${PPID:-0}" ] && continue
87
+ if kill "$signal" "$pid" 2>/dev/null; then
88
+ echo " ${signal#-} pid $pid"
89
+ elif ! kill -0 "$pid" 2>/dev/null; then
90
+ : # already exited from the previous pass — the normal case, not worth a line
91
+ else
92
+ # Still alive and we could not signal it (EPERM). Do NOT swallow this: a kill we
93
+ # cannot perform is exactly what leaves a survivor and wedges the guard below.
94
+ echo " ${signal#-} pid $pid FAILED — still running and not killable by us" >&2
95
+ fi
96
+ done < <(pids_matching)
97
+ return 0
98
+ }
99
+
100
+ # `$1 >= $2` for plain x.y.z versions.
101
+ version_ge() {
102
+ [ "$(printf '%s\n%s\n' "$2" "$1" | sort -t. -k1,1n -k2,2n -k3,3n | head -1)" = "$2" ]
103
+ }
104
+
105
+ # The package directory backing a running daemon. Extracted WITHOUT a `[^ ]*` path regex:
106
+ # that could not cross a space, so an install under "/Users/x/My Repos/..." resolved to the
107
+ # wrong directory, was graded UNKNOWN, and the assert below killed a perfectly good daemon.
108
+ pkg_dir_for_pid() {
109
+ local pid="$1" cmd path
110
+ cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)"
111
+ case "$cmd" in *" agent") cmd="${cmd% agent}" ;; *) ;; esac
112
+ path="${cmd#* }" # drop the interpreter, keep the (possibly spaced) path
113
+ case "$path" in
114
+ */dist/index.js) dirname "$(dirname "$path")"; return ;;
115
+ */node_modules/.bin/noesis-mcp)
116
+ printf '%s/@noesis-brain/mcp-server' "$(dirname "$(dirname "$path")")"; return ;;
117
+ esac
118
+ printf ''
119
+ }
120
+
121
+ # Comments are copied verbatim into dist (tsconfig sets no `removeComments`), and this
122
+ # codebase DOCUMENTS both markers heavily — `tools: []` occurs on 6 lines of the compiled
123
+ # output, only ONE of which is code. Grepping the raw file therefore certified a build with
124
+ # the boundary deleted as FIXED, which made the whole assurance layer theatre. Strip
125
+ # comments before looking for code.
126
+ strip_js_comments() { perl -0pe 's{/\*.*?\*/}{}gs; s{^[[:space:]]*//.*$}{}gm' "$1" 2>/dev/null || true; }
127
+
128
+ # True when compiled runner.js (comments already stripped, on stdin) restricts the SDK's
129
+ # built-in tools. Two accepted shapes:
130
+ # - `tools: []` — 2.1.5 to 2.1.x: no built-ins at all;
131
+ # - `tools: webTools` built by `clampWebTools(` over the literal two-name list
132
+ # — 2.2.0+: none, or at most WebFetch + WebSearch, which have
133
+ # no filesystem access (web Navis; see runner.ts).
134
+ # Recognising ONLY the first shape graded every 2.2.0 daemon UNFIXED and the assert at the
135
+ # bottom then killed it: a guaranteed outage on the first restart after publishing.
136
+ # The version floor stays 2.1.5 — the filesystem boundary is what this guards.
137
+ has_tool_boundary() {
138
+ local code; code="$(cat)"
139
+ # Here-strings, not `printf | grep -q`: under pipefail an early-exiting grep can SIGPIPE
140
+ # the writer once the file outgrows the pipe buffer, turning a match into a failure.
141
+ grep -q 'canUseTool:' <<<"$code" || return 1
142
+ grep -q 'tools: \[\]' <<<"$code" && return 0
143
+ grep -q 'tools: webTools,' <<<"$code" \
144
+ && grep -q 'const webTools = clampWebTools(' <<<"$code" \
145
+ && grep -q "ALLOWED_WEB_TOOLS = \['WebFetch', 'WebSearch'\]" <<<"$code"
146
+ }
147
+
148
+ # Prints "<version> <FIXED|UNFIXED(reason)>" for a package directory.
149
+ #
150
+ # TWO INDEPENDENT SIGNALS, both required:
151
+ # - the package version is >= MIN_FIXED_VERSION (the same signal the backend's
152
+ # MIN_TOOL_AGENT_VERSION gate uses), and
153
+ # - the compiled output actually contains both halves of the fix, comments removed.
154
+ # An earlier version compared neither: MIN_FIXED_VERSION was assigned and never used, and
155
+ # the content check matched documentation. "v2.1.5 FIXED" was two non-facts side by side.
156
+ describe_dir() {
157
+ local dir="$1" ver ver_ok=0 code_ok=0
158
+ ver="$(node -e "process.stdout.write(String(require('$dir/package.json').version||'0.0.0'))" 2>/dev/null || echo '0.0.0')"
159
+ version_ge "$ver" "$MIN_FIXED_VERSION" && ver_ok=1
160
+ if [ -f "$dir/dist/agent/runner.js" ]; then
161
+ if strip_js_comments "$dir/dist/agent/runner.js" | has_tool_boundary; then code_ok=1; fi
162
+ fi
163
+ if [ "$ver_ok" = 1 ] && [ "$code_ok" = 1 ]; then echo "$ver FIXED"
164
+ elif [ "$ver_ok" = 0 ]; then echo "$ver UNFIXED(v<$MIN_FIXED_VERSION)"
165
+ else echo "$ver UNFIXED(code)"; fi
166
+ }
167
+
168
+ # Prints "<version> <FIXED|UNFIXED(reason)|PARENT>" for a pid.
169
+ describe_pid() {
170
+ local dir
171
+ dir="$(pkg_dir_for_pid "$1")"
172
+ # No package dir = the `npm exec` wrapper, not a daemon. Its child is graded separately;
173
+ # counting the parent as "unknown, therefore exposed" cried wolf on every healthy npx
174
+ # launch, which is the mode that becomes correct once the fix is published.
175
+ if [ -z "$dir" ] || [ ! -f "$dir/package.json" ]; then echo "- PARENT"; return; fi
176
+ describe_dir "$dir"
177
+ }
178
+
179
+ # --grade <dir>: grade one package directory and touch no process. The test suite runs
180
+ # this against a fresh compile of runner.ts, so a change the check above stops recognising
181
+ # fails a test instead of killing every user's daemon on their next restart.
182
+ if [ "$MODE" = "--grade" ]; then
183
+ grade_dir="$(cd "$2" && pwd)"
184
+ read -r ver state <<<"$(describe_dir "$grade_dir")"
185
+ echo "$ver $state"
186
+ if [ "$state" = "FIXED" ]; then exit 0; fi
187
+ exit 1
188
+ fi
189
+
190
+ # --status: the one command that answers "is my machine exposed right now?".
191
+ if [ "$MODE" = "--status" ]; then
192
+ exposed=0 total=0
193
+ while read -r pid; do
194
+ [ -z "$pid" ] && continue
195
+ read -r ver state <<<"$(describe_pid "$pid")"
196
+ if [ "$state" = "PARENT" ]; then
197
+ printf ' pid %-7s %-10s (npm exec wrapper — its child is graded below)\n' "$pid" "$state"
198
+ continue
199
+ fi
200
+ total=$((total + 1))
201
+ printf ' pid %-7s v%-8s %s\n' "$pid" "$ver" "$state"
202
+ [ "$state" = "FIXED" ] || exposed=$((exposed + 1))
203
+ done < <(pids_matching)
204
+ [ "$total" -eq 0 ] && echo " no daemon running"
205
+ # Honest about what this can and cannot see, rather than implying a clean bill of health.
206
+ echo " (grades the package on disk, for this user's processes only; a daemon started"
207
+ echo " before a rebuild may still be running older code in memory)"
208
+ if [ "$exposed" -gt 0 ]; then
209
+ echo "[start-agent] EXPOSED: $exposed of $total daemon process(es) lack the tool boundary." >&2
210
+ echo " They can read your filesystem. Run: $(basename "$0") --local" >&2
211
+ exit 1
212
+ fi
213
+ echo "[start-agent] ok — $total daemon process(es), all carry the fix."
214
+ exit 0
215
+ fi
216
+
217
+ # CHECK BEFORE KILLING. The published build is what the default mode is about to install,
218
+ # so verify it is new enough BEFORE tearing down a working daemon. Without this the default
219
+ # path was a guaranteed outage while the fix was unpublished: kill everything (including a
220
+ # healthy fixed daemon) -> install an unfixed one -> assert fails -> kill it -> exit 1,
221
+ # leaving the machine with NO daemon and subscription chat dead.
222
+ if [ "$MODE" = "" ]; then
223
+ published="$(npm view @noesis-brain/mcp-server dist-tags.latest 2>/dev/null || true)"
224
+ if [ -z "$published" ]; then
225
+ echo "[start-agent] Could not reach npm to check the published version; refusing to" >&2
226
+ echo " tear down a running daemon on a guess. Use --local." >&2
227
+ exit 1
228
+ fi
229
+ if ! version_ge "$published" "$MIN_FIXED_VERSION"; then
230
+ echo "[start-agent] Published @latest is v$published, which is older than v$MIN_FIXED_VERSION" >&2
231
+ echo " and lacks the tool boundary. NOT touching the running daemon." >&2
232
+ echo " Publish $MIN_FIXED_VERSION, or start this checkout: $(basename "$0") --local" >&2
233
+ exit 1
234
+ fi
235
+ fi
236
+
237
+ echo "[start-agent] stopping existing daemons..."
238
+ [ "$(count_matching)" -eq 0 ] && echo " none running"
239
+ kill_matching -TERM
240
+ sleep 2 # a parent can respawn its child on SIGTERM
241
+ kill_matching -KILL
242
+ sleep 1
243
+
244
+ remaining="$(count_matching)"
245
+ if [ "$MODE" = "--stop" ]; then
246
+ if [ "$remaining" -gt 0 ]; then
247
+ echo "[start-agent] WARNING: $remaining daemon process(es) survived --stop." >&2
248
+ exit 1
249
+ fi
250
+ echo "[start-agent] all stopped."
251
+ exit 0
252
+ fi
253
+ if [ "$remaining" -gt 0 ]; then
254
+ echo "[start-agent] ERROR: $remaining daemon process(es) still alive; refusing to start another." >&2
255
+ pids_matching | sed 's/^/ surviving pid /' >&2
256
+ exit 1
257
+ fi
258
+
259
+ # Credentials come from the same place the MCP server registration uses, so the token never
260
+ # has to be pasted into a shell or a process argv.
261
+ CLAUDE_JSON="$HOME/.claude.json"
262
+ if ! node -e "
263
+ const e = (require('$CLAUDE_JSON').mcpServers || {}).noesis?.env;
264
+ if (!e?.NOESIS_API_URL || !e?.NOESIS_API_TOKEN) process.exit(1);
265
+ " 2>/dev/null; then
266
+ echo "[start-agent] No Noesis credentials in $CLAUDE_JSON (mcpServers.noesis.env)." >&2
267
+ echo " claude mcp add -s user noesis -- npx -y @noesis-brain/mcp-server" >&2
268
+ exit 1
269
+ fi
270
+ NOESIS_API_URL="$(node -e "console.log(require('$CLAUDE_JSON').mcpServers.noesis.env.NOESIS_API_URL)")"
271
+ NOESIS_API_TOKEN="$(node -e "console.log(require('$CLAUDE_JSON').mcpServers.noesis.env.NOESIS_API_TOKEN)")"
272
+ export NOESIS_API_URL NOESIS_API_TOKEN
273
+
274
+ mkdir -p "$RUN_DIR"
275
+ cd "$RUN_DIR"
276
+
277
+ if [ "$MODE" = "--local" ]; then
278
+ DIST="$PKG_DIR/dist/index.js"
279
+ [ -f "$DIST" ] || { echo "[start-agent] $DIST missing — run 'npm run build' first." >&2; exit 1; }
280
+ echo "[start-agent] starting LOCAL build: $DIST"
281
+ nohup node "$DIST" agent >> "$LOG_FILE" 2>&1 &
282
+ else
283
+ echo "[start-agent] starting published @noesis-brain/mcp-server@latest"
284
+ nohup npx -y @noesis-brain/mcp-server@latest agent >> "$LOG_FILE" 2>&1 &
285
+ fi
286
+
287
+ # Wait for the DAEMON, not the npx parent. A cold `npx -y` install can take 10-60s, and the
288
+ # parent alone used to satisfy the old fixed 6s check — so a failed install reported success
289
+ # with zero daemons running.
290
+ started=0
291
+ for _ in $(seq 1 60); do
292
+ sleep 1
293
+ while read -r pid; do
294
+ [ -z "$pid" ] && continue
295
+ case "$(ps -o command= -p "$pid" 2>/dev/null || true)" in
296
+ *dist/index.js\ agent*|*noesis-mcp\ agent*) started="$pid"; break ;;
297
+ esac
298
+ done < <(pids_matching)
299
+ [ "$started" != 0 ] && break
300
+ done
301
+
302
+ if [ "$started" = 0 ]; then
303
+ echo "[start-agent] ERROR: daemon did not come up within 60s. Tail of $LOG_FILE:" >&2
304
+ tail -5 "$LOG_FILE" >&2 2>/dev/null || true
305
+ exit 1
306
+ fi
307
+
308
+ # THE ASSERT. Never leave a daemon running that lacks the tool boundary, whichever mode got
309
+ # us here — including the published default while the fix is unpublished.
310
+ read -r ver state <<<"$(describe_pid "$started")"
311
+ if [ "$state" != "FIXED" ]; then
312
+ echo "[start-agent] ERROR: started daemon pid $started is v$ver ($state) — it lacks the" >&2
313
+ echo " tool boundary and can read your filesystem. Stopping it." >&2
314
+ # Both passes over the whole pattern set, not just this pid: killing the child alone
315
+ # leaves the `npm exec` parent to respawn it — which is the exact failure this script's
316
+ # own header describes, and it would have happened right after printing "Stopping it."
317
+ kill_matching -TERM >&2
318
+ sleep 2
319
+ kill_matching -KILL >&2
320
+ sleep 1
321
+ if [ "$(count_matching)" -gt 0 ]; then
322
+ echo " WARNING: $(count_matching) process(es) survived; run --status." >&2
323
+ fi
324
+ echo " Publish $MIN_FIXED_VERSION or newer, or start the local build:" >&2
325
+ echo " $(basename "$0") --local" >&2
326
+ exit 1
327
+ fi
328
+
329
+ echo "[start-agent] running: pid $started (v$ver, $state)"
330
+ echo "[start-agent] log: $LOG_FILE"
331
+ tail -3 "$LOG_FILE" 2>/dev/null | sed 's/^/ /'
@@ -0,0 +1,163 @@
1
+ ---
2
+ description: Refine the typed relations (Related Notes) of a SET of Noesis notes so the Graph view shows real structure. Scopes by timeframe, topic, catalog or signal — "last two weeks", "notes about BTC", "BTO printing in the last month", "points over 100". Proposes a diff per note and writes only on approval. Use when the user says 'refine relations', 'refine note relations', 'fix my graph', 'link my notes', 'these notes have no relations', or names a batch of notes to connect.
3
+ argument-hint: "[recent <N> days|weeks|months] [topic <words>] [catalog <NAME>] [points over <N>] [<note path | note ID | query>]"
4
+ ---
5
+
6
+ # /noesis-refine-relations — Batch relation refinement
7
+
8
+ You are executing `/noesis-refine-relations`. It selects a **set** of notes, works out which
9
+ other notes each one should formally link to, shows you the diff, and writes only what you
10
+ approve.
11
+
12
+ Relations live in the Noesis **database**, not in the markdown. This skill therefore never
13
+ edits a note body and never needs a sync.
14
+
15
+ ## What this is NOT
16
+
17
+ The app already has a per-note **"Suggest relations"** in the graph's node menu. It reasons
18
+ over ≤20 candidates carrying only a title and a short description — **it never reads note
19
+ bodies**, and it runs on one note at a time.
20
+
21
+ This skill is the complement: many notes at once, and it *reads the content*, so it can find
22
+ the links a note states in its own text (cited file paths, note URLs, quoted titles). Where
23
+ the in-app suggester is the right tool for one note, say so and stop.
24
+
25
+ ## Step 0 — Route on the argument
26
+
27
+ The raw arguments string is: `$ARGUMENTS`
28
+
29
+ First match wins:
30
+
31
+ | Argument looks like | Mode |
32
+ |---|---|
33
+ | "last N days/weeks/months", "recent", "this week" | **RECENT** |
34
+ | a topic phrase *and* a timeframe | **TOPIC ∩ RECENT** |
35
+ | a topic phrase ("about BTC", "BTO printing") | **TOPIC** |
36
+ | "points over N", "score above N", "favorite", "pinned", "starred" | **SIGNAL** |
37
+ | "catalog X" | **CATALOG** |
38
+ | a note ID, a `.md` path, or a bare query | **SINGLE** |
39
+ | empty | **ASK** — `AskUserQuestion` offering the modes above |
40
+
41
+ State the mode and the resolved parameters before doing anything.
42
+
43
+ ## Step 1 — Resolve the note set
44
+
45
+ - **RECENT** — `mcp__noesis__list_notes(recent: <N_DAYS>, limit: 100)`.
46
+ - **TOPIC** — `mcp__noesis__search_semantic(query, limit: 20)` **and**
47
+ `mcp__noesis__search_notes(query, limit: 50)`; union by ID.
48
+ - **TOPIC ∩ RECENT** — run both of the above and intersect.
49
+ - **SIGNAL** — `list_notes(limit: 100)`, then filter client-side on the `⭐` and `[N pts]`
50
+ markers in the output.
51
+ - **CATALOG** — `list_notes(catalog: <NAME>, limit: 100)`.
52
+ - **SINGLE** — resolve directly (`get_note` by id/path, or `search_notes` then pick).
53
+
54
+ Then **always**:
55
+
56
+ 1. **Drop capture notes** — anything under a `captures/` folder is an auto-pushed session
57
+ mirror, not a note the user authored. Never link them unless explicitly asked.
58
+ 2. **Cap the batch.** Default to 15 notes. If the scope resolved to more, take the 15 most
59
+ recently modified and **say how many you dropped** — never present a truncated sweep as
60
+ a complete one.
61
+ 3. Print the candidate table (ID, title, path, current relation count) and confirm the set
62
+ before moving on.
63
+
64
+ ### Selection limits you must respect
65
+
66
+ These are properties of the MCP tools, not preferences:
67
+
68
+ - `list_notes` caps `limit` at **100** server-side and exposes no offset. A scope wider than
69
+ 100 notes cannot be enumerated in one call — narrow it and say so.
70
+ - When `recent` is set, `root` and `catalog` are **silently ignored**. Combined filters must
71
+ be applied client-side.
72
+ - `list_notes` prints `⭐` and `[N pts]` but **not the note ID**, so resolve IDs from paths
73
+ before writing anything.
74
+ - `search_semantic` and `find_similar_notes` need embeddings and fail without a Gemini key.
75
+ Degrade to keyword-only and note it; never abort the run over it.
76
+
77
+ ## Step 2 — Per note: build the proposed set
78
+
79
+ For each note in the confirmed set, in order:
80
+
81
+ **2a. Read the current relations.** `mcp__noesis__get_note(id)`. This is the only reliable
82
+ read — use it every time, even when you expect none.
83
+
84
+ **2b. Gather candidates**, in one parallel batch:
85
+ - `mcp__noesis__find_similar_notes(note_id, limit: 15)`
86
+ - `mcp__noesis__search_semantic(query: title + description, limit: 10)`
87
+ - **The body scan — the highest-precision source, and the reason this skill exists.**
88
+ Read the note content and extract every note it already cites:
89
+ - file paths ending in `.md` (absolute, `~/Noesis/...`, or bare filenames)
90
+ - `noesisbrain.com/notes/<id>` URLs — the `<id>` is the target, use it directly
91
+ - quoted note titles
92
+ Resolve each to an ID with `mcp__noesis__search_notes`. An edge grounded in a line of the
93
+ note's own text is worth more than any similarity score; keep the line as evidence.
94
+
95
+ **2c. Filter.** Drop captures, drop the note itself (the MCP write path does **not** reject
96
+ self-links), drop anything that fails to resolve. Never invent an ID — if a cited note cannot
97
+ be resolved, report it as unresolved rather than guessing.
98
+
99
+ **2d. Choose a type** per edge, from exactly these five:
100
+ `related` · `references` · `implements` · `extends` · `supersedes`.
101
+ Default to `references` when the note merely cites the target, `related` when the connection
102
+ is thematic.
103
+
104
+ **2e. Union with the existing set from 2a.** Keep every existing entry unless the user has
105
+ explicitly asked to prune. This matters — see the write contract below.
106
+
107
+ **2f. Apply a cap.** 2–5 relations for a focused note; up to 8 for a comprehensive
108
+ architecture, workflow, or strategy note. **Tracker, sprint and index notes are exempt** —
109
+ being a hub is their job, and a tracker citing 25 notes should link to 25.
110
+
111
+ ## Step 3 — Propose
112
+
113
+ Print, per note:
114
+
115
+ ```
116
+ Note 2923 BTO 12.3 Aug Sprint 0 edges 0 -> 25
117
+ = keep references 4424 IDEA-1579 HTTPS support
118
+ + add references 3611 BPLAT-14016 UltraLite Simple Serialization
119
+ evidence: "my-git/issues/BPLAT-14016-phoenix-tsc-ultralite...md"
120
+ + add related 2924 Epsilon Branch Merge Convention
121
+ evidence: similar 0.81, both cover the Epsilon merge workflow
122
+ ! unresolved: "support-https-print-portal-investigation.md" (no match)
123
+ ```
124
+
125
+ Then ask once, for the whole batch, via `AskUserQuestion`: apply all / pick per note / cancel.
126
+ **Never write before this point.**
127
+
128
+ ## Step 4 — Write
129
+
130
+ For each approved note, issue **exactly one** `mcp__noesis__update_relations` call carrying
131
+ the **complete** desired set.
132
+
133
+ > **The write REPLACES the note's whole relation set.** It clears the column and stores what
134
+ > you send. One call per relation — a pattern that exists elsewhere in this repo's older
135
+ > commands — leaves the note with only the last relation written and silently deletes the
136
+ > rest. Send the union from 2e, always.
137
+
138
+ No inverse relation is created on the target, and you must not try to write one. Backlinks
139
+ are **derived**: the app scans every note's one-way array to build the graph, so a single
140
+ edge already shows up on both notes — as "linked to" on the source and "linked by" on the
141
+ target. Writing the mirror by hand would double-store derived data and turn every edge into
142
+ "mutual", destroying the direction the user can currently see.
143
+
144
+ Do **not** write a "Related Notes" section into the markdown body. Relations are database
145
+ metadata; a section in the file is a stale duplicate. If a previous run left one, remove it.
146
+
147
+ ## Step 5 — Report
148
+
149
+ A table of note · edges before → after · skipped-and-why, plus every unresolved citation.
150
+ Then suggest `mcp__noesis__get_relation_graph(note_id, depth: 1)` or the Graph view to
151
+ confirm the shape.
152
+
153
+ ## Implementation notes
154
+
155
+ - **Read before write, every time.** The write is a replace; skipping 2a loses relations.
156
+ - **The body scan is the differentiator.** If you skip it, this skill is just a slower
157
+ version of the in-app suggester.
158
+ - **Evidence or no edge.** Every proposed link needs either a quoted line from the note or a
159
+ similarity score. "These feel related" is not a reason.
160
+ - **Sequential per note.** Parallel `get_note` batches race the API rate limit; the
161
+ *candidate gathering* inside one note (2b) is the part to parallelize.
162
+ - **Report every cap.** A truncated batch, a dropped candidate, an unresolved citation — all
163
+ of it goes in the Step 5 report. A sweep that hides what it skipped is worse than no sweep.