pi-weave 0.2.2 → 0.2.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-weave",
3
- "version": "0.2.2",
4
- "description": "An agent-native knowledge workspace for your life and your code. Smart notepad + repository exploration, readable by humans and agents alike.",
3
+ "version": "0.2.4",
4
+ "description": "Local-first Markdown memory and knowledge graph for Pi: preserve decisions, recall across sessions, and explore notes with code.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "publishConfig": {
@@ -9,10 +9,16 @@
9
9
  },
10
10
  "keywords": [
11
11
  "pi-package",
12
- "knowledge-graph",
13
- "agent",
12
+ "pi-coding-agent",
13
+ "agent-memory",
14
+ "persistent-memory",
15
+ "markdown",
14
16
  "notepad",
15
- "repository-index"
17
+ "local-first",
18
+ "knowledge-graph",
19
+ "codebase-memory",
20
+ "obsidian",
21
+ "developer-tools"
16
22
  ],
17
23
  "license": "MIT",
18
24
  "author": "Eran Yonai <yonai.eran@gmail.com>",
@@ -50,6 +56,7 @@
50
56
  "build:web": "node scripts/build-web.mjs",
51
57
  "build:web:check": "node scripts/build-web.mjs --check",
52
58
  "check": "npm run typecheck && npm run build:web:check && npm run coverage",
59
+ "eval:notepad": "node scripts/eval-notepad-search.mjs",
53
60
  "rewrap:md": "node scripts/rewrap-md.mjs",
54
61
  "rewrap:md:check": "node scripts/rewrap-md.mjs --check",
55
62
  "prepublishOnly": "npm run check"
@@ -114,8 +114,19 @@ The scan is opt-in and never runs on its own. Suggest it when the user asks why
114
114
 
115
115
  ## Retrieving knowledge
116
116
 
117
- Use `weave_note` action=search with the user's key terms, then `get` the best hits. When a note and the repository index disagree, trust the
118
- repository for facts about code and flag the discrepancy — the note may be stale intent.
117
+ When the slug is known, use `weave_note` action=get directly. Otherwise use one targeted `search` with the user's key terms. A search with
118
+ one result or one unique exact-title match returns the full note; that result is sufficient, so do not call `get` again. If several
119
+ plausible candidates remain, fetch at most the three strongest slugs together in the next tool round. If the answer is still ambiguous,
120
+ ask the user for another identifier instead of reformulating and repeating the search. Never list the vault to find a note.
121
+
122
+ Search accepts ordinary multi-term queries and automatically falls back from an exact phrase to ranked lexical terms. Results are
123
+ strongest-first and include match evidence, metadata, bounded note content, and connected notes found through links, backlinks, shared
124
+ tags, and shared distinctive terms. A body marked `complete` is sufficient; call `get` only for content explicitly marked as an excerpt.
125
+ Use that context before making another tool call. Connected notes are discovery context, not evidence that they match the query; include
126
+ them in an answer only when their stated relationship is relevant. Connections are lexical and explicit, not semantic.
127
+
128
+ When a note and the repository index disagree, trust the repository for facts about code and flag the discrepancy — the note may be stale
129
+ intent.
119
130
 
120
131
  ## Repairing stale links
121
132
 
package/src/core/index.ts CHANGED
@@ -64,8 +64,10 @@ export {
64
64
  type LinkRepairResult,
65
65
  } from "./vault";
66
66
  export {
67
+ relatedNotes,
67
68
  suggestLinks,
68
69
  type LinkSuggestion,
70
+ type RelatedNote,
69
71
  type SuggestOptions,
70
72
  type SuggestionReport,
71
73
  } from "./links/similar";
@@ -87,8 +87,9 @@ export interface SuggestOptions {
87
87
  evidence?: number;
88
88
  }
89
89
 
90
- /** Words too structural to carry meaning in any vault, in any domain. */
91
90
  const TERM_RE = /[\p{L}][\p{L}\p{N}_-]{2,}/gu;
91
+ /** Link prose too structural to connect otherwise unrelated notes. */
92
+ const STRUCTURAL_TERMS = new Set(["and", "see", "the"]);
92
93
 
93
94
  /**
94
95
  * Smallest document-frequency ceiling, whatever `maxDocFrequency` computes.
@@ -114,7 +115,10 @@ export const MIN_TERM_CEILING = 10;
114
115
  function terms(note: Pick<Note, "title" | "tags" | "body">): Set<string> {
115
116
  const text = `${note.title} ${note.tags.join(" ")} ${note.body}`;
116
117
  const out = new Set<string>();
117
- for (const match of text.matchAll(TERM_RE)) out.add(match[0].toLowerCase());
118
+ for (const match of text.matchAll(TERM_RE)) {
119
+ const term = match[0].toLowerCase();
120
+ if (!STRUCTURAL_TERMS.has(term)) out.add(term);
121
+ }
118
122
  return out;
119
123
  }
120
124
 
@@ -123,6 +127,49 @@ export interface SuggestInput {
123
127
  notes: readonly Pick<Note, "slug" | "title" | "tags" | "body">[];
124
128
  }
125
129
 
130
+ /** One deterministic neighbour of a note, with reviewable evidence. */
131
+ export interface RelatedNote {
132
+ slug: string;
133
+ score: number;
134
+ reasons: string[];
135
+ }
136
+
137
+ function resolvedLinks(notes: SuggestInput["notes"]): {
138
+ pairs: Set<string>;
139
+ outgoing: Map<string, Set<string>>;
140
+ } {
141
+ const pairs = new Set<string>();
142
+ const outgoing = new Map<string, Set<string>>();
143
+ const bySlug = new Set(notes.map((note) => note.slug));
144
+ const byBasename = new Map<string, string[]>();
145
+ const byTitle = new Map<string, string[]>();
146
+ for (const note of notes) {
147
+ const base = note.slug.split("/").pop()!;
148
+ byBasename.set(base, [...(byBasename.get(base) ?? []), note.slug]);
149
+ const title = slugify(note.title);
150
+ if (title.length > 0) byTitle.set(title, [...(byTitle.get(title) ?? []), note.slug]);
151
+ }
152
+ for (const note of notes) {
153
+ for (const link of scanLinks(note.body)) {
154
+ const byBase = byBasename.get(link.target) ?? [];
155
+ const byTtl = byTitle.get(link.target) ?? [];
156
+ const target = bySlug.has(link.target)
157
+ ? link.target
158
+ : byBase.length === 1
159
+ ? byBase[0]!
160
+ : byTtl.length === 1
161
+ ? byTtl[0]!
162
+ : null;
163
+ if (target === null) continue;
164
+ pairs.add(pairKey(note.slug, target));
165
+ const targets = outgoing.get(note.slug);
166
+ if (targets) targets.add(target);
167
+ else outgoing.set(note.slug, new Set([target]));
168
+ }
169
+ }
170
+ return { pairs, outgoing };
171
+ }
172
+
126
173
  /**
127
174
  * Rank pairs of notes that share distinctive vocabulary but no link.
128
175
  *
@@ -175,34 +222,7 @@ export function suggestLinks(input: SuggestInput, options: SuggestOptions = {}):
175
222
 
176
223
  // Existing links, both directions, so a connection the user already made
177
224
  // is never offered back to them.
178
- const linked = new Set<string>();
179
- const bySlug = new Set(notes.map((note) => note.slug));
180
- const byBasename = new Map<string, string[]>();
181
- const byTitle = new Map<string, string[]>();
182
- for (const note of notes) {
183
- const base = note.slug.split("/").pop()!;
184
- byBasename.set(base, [...(byBasename.get(base) ?? []), note.slug]);
185
- const title = slugify(note.title);
186
- if (title.length > 0) byTitle.set(title, [...(byTitle.get(title) ?? []), note.slug]);
187
- }
188
- for (const note of notes) {
189
- for (const link of scanLinks(note.body)) {
190
- // The same three-rung ladder `auditLinks` walks — exact slug, unique
191
- // basename, unique title. It has to be all three: a link repair would
192
- // resolve counts as a connection that already exists, so resolving a
193
- // rung short here would suggest a pair the vault has already linked.
194
- const byBase = byBasename.get(link.target) ?? [];
195
- const byTtl = byTitle.get(link.target) ?? [];
196
- const target = bySlug.has(link.target)
197
- ? link.target
198
- : byBase.length === 1
199
- ? byBase[0]!
200
- : byTtl.length === 1
201
- ? byTtl[0]!
202
- : null;
203
- if (target !== null) linked.add(pairKey(note.slug, target));
204
- }
205
- }
225
+ const { pairs: linked } = resolvedLinks(notes);
206
226
 
207
227
  // Candidate generation through an inverted index on distinctive terms
208
228
  // only. Comparing every pair is O(n²) and mostly compares notes with
@@ -252,6 +272,53 @@ export function suggestLinks(input: SuggestInput, options: SuggestOptions = {}):
252
272
  return { considered: vectors.size, suggestions: suggestions.slice(0, limit) };
253
273
  }
254
274
 
275
+ /**
276
+ * Rank the explicit and lexical neighbours of one note.
277
+ *
278
+ * This is deliberately not semantic search: every result cites a link,
279
+ * backlink, shared tag, or shared term already present in the vault.
280
+ */
281
+ export function relatedNotes(input: SuggestInput, slug: string, limit = 20): RelatedNote[] {
282
+ if (limit <= 0) return [];
283
+ const focus = input.notes.find((note) => note.slug === slug);
284
+ if (!focus) return [];
285
+
286
+ const related = new Map<string, RelatedNote>();
287
+ const add = (target: string, score: number, reason: string) => {
288
+ if (target === slug) return;
289
+ const existing = related.get(target);
290
+ if (existing) {
291
+ existing.score = Math.max(existing.score, score);
292
+ if (!existing.reasons.includes(reason)) existing.reasons.push(reason);
293
+ } else {
294
+ related.set(target, { slug: target, score, reasons: [reason] });
295
+ }
296
+ };
297
+
298
+ const { outgoing } = resolvedLinks(input.notes);
299
+ for (const target of outgoing.get(slug) ?? []) add(target, 400, `linked from ${slug}`);
300
+ for (const [source, targets] of outgoing) {
301
+ if (targets.has(slug)) add(source, 400, `links to ${slug}`);
302
+ }
303
+
304
+ const focusTags = new Map(focus.tags.map((tag) => [tag.toLowerCase(), tag]));
305
+ for (const note of input.notes) {
306
+ const shared = [...new Set(note.tags.map((tag) => tag.toLowerCase()))]
307
+ .filter((tag) => focusTags.has(tag))
308
+ .map((tag) => focusTags.get(tag)!);
309
+ if (shared.length > 0) add(note.slug, 300 + shared.length, `shared tags: ${shared.join(", ")}`);
310
+ }
311
+
312
+ for (const suggestion of suggestLinks(input, { slug, limit: Math.max(limit, 20) }).suggestions) {
313
+ const target = suggestion.a === slug ? suggestion.b : suggestion.a;
314
+ add(target, 200 + suggestion.score, `shared terms: ${suggestion.shared.join(", ")}`);
315
+ }
316
+
317
+ return [...related.values()]
318
+ .sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug))
319
+ .slice(0, limit);
320
+ }
321
+
255
322
  /** Order-independent key for an unordered pair. */
256
323
  function pairKey(a: string, b: string): string {
257
324
  return a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
package/src/core/vault.ts CHANGED
@@ -978,21 +978,51 @@ export async function noteCount(root: string): Promise<number> {
978
978
  return (await listNoteFiles(root)).filter(isMarkdown).length;
979
979
  }
980
980
 
981
+ const SEARCH_TERM_RE = /[\p{L}\p{N}]+(?:[._/:\-][\p{L}\p{N}]+)*/gu;
982
+
983
+ /** Terms models naturally put in a search query, including code-ish tokens. */
984
+ function searchTerms(value: string): string[] {
985
+ const terms = new Set<string>();
986
+ for (const match of value.toLowerCase().matchAll(SEARCH_TERM_RE)) {
987
+ const term = match[0];
988
+ if (term.length > 1) terms.add(term);
989
+ for (const part of term.split(/[._/:\-]+/)) {
990
+ if (part.length > 1) terms.add(part);
991
+ }
992
+ }
993
+ return [...terms];
994
+ }
995
+
996
+ function matchingTerm(terms: ReadonlySet<string>, query: string): string | undefined {
997
+ if (terms.has(query)) return query;
998
+ if (query.length < 5 || !/^\p{L}+$/u.test(query)) return undefined;
999
+ // ponytail: cheap inflection tolerance; use stemming only if false matches become measurable.
1000
+ const prefix = query.slice(0, 4);
1001
+ return [...terms].find((term) => term.length >= 5 && /^\p{L}+$/u.test(term) && term.startsWith(prefix));
1002
+ }
1003
+
981
1004
  /**
982
- * Substring search over title, tags, and body.
983
- * Score: title match = 3, tag match = 2, body match = 1 each (capped).
984
- * Case-insensitive. Deterministic ordering: score desc, then slug asc.
1005
+ * Search slug, title, tags, and body.
1006
+ *
1007
+ * Exact substring behaviour stays first and unchanged. When a natural
1008
+ * multi-term query has no literal match, an IDF-weighted lexical fallback
1009
+ * ranks notes by term coverage: title/slug above tags above body. This makes
1010
+ * one model-style query useful without adding a second search mode.
985
1011
  */
986
1012
  export async function searchNotes(root: string, query: string): Promise<NoteSearchHit[]> {
987
1013
  const q = query.toLowerCase().trim();
988
1014
  if (q.length === 0) return [];
989
1015
 
1016
+ const notes = (await readVault(root)).notes;
990
1017
  const hits: NoteSearchHit[] = [];
991
1018
  // One pass: `listNotes` + a `getNote` per slug would read every file twice.
992
- for (const note of (await readVault(root)).notes) {
1019
+ for (const note of notes) {
993
1020
  let score = 0;
994
- if (note.title.toLowerCase().includes(q)) score += 3;
995
- if (note.tags.some((t) => t.toLowerCase().includes(q))) score += 2;
1021
+ const title = note.title.toLowerCase();
1022
+ const slug = note.slug.toLowerCase();
1023
+ if (title === q || slug === q) score += 100;
1024
+ else if (title.includes(q) || slug.includes(q)) score += 30;
1025
+ if (note.tags.some((t) => t.toLowerCase().includes(q))) score += 20;
996
1026
 
997
1027
  const bodyLower = note.body.toLowerCase();
998
1028
  let bodyMatches = 0;
@@ -1006,6 +1036,48 @@ export async function searchNotes(root: string, query: string): Promise<NoteSear
1006
1036
  if (score === 0) continue;
1007
1037
  hits.push({ summary: summarizeNote(note), score, snippet: makeSnippet(note.body, q) });
1008
1038
  }
1039
+ if (hits.length > 0) return hits.sort((a, b) => b.score - a.score || a.summary.slug.localeCompare(b.summary.slug));
1040
+
1041
+ const indexed = notes.map((note) => {
1042
+ const title = new Set(searchTerms(note.title));
1043
+ const slug = new Set(searchTerms(note.slug));
1044
+ const tags = new Set(note.tags.flatMap(searchTerms));
1045
+ const body = new Set(searchTerms(note.body));
1046
+ return { note, title, slug, tags, body, all: new Set([...title, ...slug, ...tags, ...body]) };
1047
+ });
1048
+ const terms = searchTerms(q).flatMap((term) => {
1049
+ const frequency = indexed.filter((entry) => matchingTerm(entry.all, term) !== undefined).length;
1050
+ return frequency === 0 ? [] : [{ term, frequency }];
1051
+ });
1052
+ if (terms.length === 0) return [];
1053
+
1054
+ for (const entry of indexed) {
1055
+ let score = 0;
1056
+ let matched = 0;
1057
+ let snippetTerm = "";
1058
+ for (const { term, frequency } of terms) {
1059
+ const idf = Math.log((indexed.length + 1) / (frequency + 1)) + 1;
1060
+ const bodyTerm = matchingTerm(entry.body, term);
1061
+ const fieldScore = matchingTerm(entry.title, term) !== undefined || matchingTerm(entry.slug, term) !== undefined
1062
+ ? 30
1063
+ : matchingTerm(entry.tags, term) !== undefined
1064
+ ? 20
1065
+ : bodyTerm !== undefined
1066
+ ? 5
1067
+ : 0;
1068
+ if (fieldScore === 0) continue;
1069
+ score += fieldScore * idf;
1070
+ matched++;
1071
+ if (snippetTerm === "" && bodyTerm !== undefined) snippetTerm = bodyTerm;
1072
+ }
1073
+ if (matched === 0) continue;
1074
+ score += 20 * matched / terms.length;
1075
+ hits.push({
1076
+ summary: summarizeNote(entry.note),
1077
+ score,
1078
+ snippet: makeSnippet(entry.note.body, snippetTerm || q),
1079
+ });
1080
+ }
1009
1081
  return hits.sort((a, b) => b.score - a.score || a.summary.slug.localeCompare(b.summary.slug));
1010
1082
  }
1011
1083
 
@@ -12,6 +12,7 @@ import {
12
12
  getNote,
13
13
  listNotes,
14
14
  NOTES_DIR,
15
+ relatedNotes,
15
16
  repairVaultLinks,
16
17
  resolveNotePath,
17
18
  withMutationQueue,
@@ -20,8 +21,10 @@ import {
20
21
  suggestLinks,
21
22
  readVault,
22
23
  type LinkRepairResult,
24
+ type RelatedNote,
23
25
  type SuggestionReport,
24
26
  } from "../../core";
27
+ import type { Note, NoteSearchHit } from "../../core/types";
25
28
 
26
29
  /** Cap on how many rows of each link-audit category get printed. */
27
30
  const LINK_REPORT_CAP = 20;
@@ -29,6 +32,12 @@ const LINK_REPORT_CAP = 20;
29
32
  /** Cap on notes printed by `list`; `details.notes` still carries them all. */
30
33
  const LIST_CAP = 50;
31
34
 
35
+ /** Keep search useful without flooding the model on a broad query. */
36
+ const SEARCH_REPORT_CAP = 10;
37
+ const RELATED_REPORT_CAP = 5;
38
+ const COMPLETE_BODY_CAP = 2_000;
39
+ const COMPLETE_BODY_RESULTS = 3;
40
+
32
41
  function capped<T>(items: readonly T[], render: (item: T) => string): string[] {
33
42
  const lines = items.slice(0, LINK_REPORT_CAP).map(render);
34
43
  if (items.length > LINK_REPORT_CAP) lines.push(` … and ${items.length - LINK_REPORT_CAP} more`);
@@ -63,6 +72,77 @@ function formatSuggestions(report: SuggestionReport, focus: string | undefined):
63
72
  ].join("\n");
64
73
  }
65
74
 
75
+ function searchReasons(hit: NoteSearchHit, query: string): string[] {
76
+ const q = query.trim().toLowerCase();
77
+ const title = hit.summary.title.toLowerCase();
78
+ const slug = hit.summary.slug.toLowerCase();
79
+ const reasons: string[] = [];
80
+ if (title === q) reasons.push("exact title");
81
+ else if (slug === q) reasons.push("exact slug");
82
+ else if (title.includes(q)) reasons.push("title");
83
+ else if (slug.includes(q)) reasons.push("slug");
84
+ const tags = hit.summary.tags.filter((tag) => tag.toLowerCase().includes(q));
85
+ if (tags.length > 0) reasons.push(`tags: ${tags.join(", ")}`);
86
+ if (hit.snippet.toLowerCase().includes(q)) reasons.push("body");
87
+ if (reasons.length === 0) {
88
+ const haystack = `${title} ${slug} ${hit.summary.tags.join(" ").toLowerCase()} ${hit.snippet.toLowerCase()}`;
89
+ const terms = [...new Set(q.match(/[\p{L}\p{N}_-]{2,}/gu) ?? [])].filter((term) => haystack.includes(term));
90
+ if (terms.length > 0) reasons.push(`query terms: ${terms.join(", ")}`);
91
+ }
92
+ return reasons;
93
+ }
94
+
95
+ function searchContent(note: Note | undefined, snippet: string, complete: boolean): string {
96
+ const body = note?.body.trim() ?? "";
97
+ if (complete && body.length <= COMPLETE_BODY_CAP) return `[complete body]\n ${body}`;
98
+ return `[excerpt${body.length > COMPLETE_BODY_CAP ? "; use get for the full note" : ""}]\n ${snippet}`;
99
+ }
100
+
101
+ function formatSearchHits(
102
+ hits: readonly NoteSearchHit[],
103
+ query: string,
104
+ relations: ReadonlyMap<string, RelatedNote>,
105
+ notes: ReadonlyMap<string, Note>,
106
+ ): string {
107
+ const shown = hits.slice(0, SEARCH_REPORT_CAP);
108
+ const lines = shown.map((hit, index) => {
109
+ const tags = hit.summary.tags.length > 0 ? `; tags: ${hit.summary.tags.join(", ")}` : "";
110
+ const relation = relations.get(hit.summary.slug);
111
+ const connected = relation ? `; connected: ${relation.reasons.join("; ")}` : "";
112
+ const content = searchContent(notes.get(hit.summary.slug), hit.snippet, index < COMPLETE_BODY_RESULTS);
113
+ return `- ${hit.summary.slug}: ${hit.summary.title}\n matched: ${searchReasons(hit, query).join(", ")}${connected}; source: ${hit.summary.source}; updated: ${hit.summary.updated}${tags}\n ${content}`;
114
+ });
115
+ if (hits.length > shown.length) lines.push(`… and ${hits.length - shown.length} more direct match(es).`);
116
+ return lines.join("\n");
117
+ }
118
+
119
+ function preview(body: string): string {
120
+ const flat = body.trim().replace(/\s+/g, " ");
121
+ return flat.length > 180 ? `${flat.slice(0, 180)}…` : flat;
122
+ }
123
+
124
+ function formatRelatedNotes(
125
+ relations: readonly RelatedNote[],
126
+ notes: ReadonlyMap<string, Note>,
127
+ direct: ReadonlySet<string>,
128
+ anchor: Note,
129
+ ): string {
130
+ const shown = relations
131
+ .filter((relation) => !direct.has(relation.slug))
132
+ .flatMap((relation) => {
133
+ const note = notes.get(relation.slug);
134
+ return note ? [{ relation, note }] : [];
135
+ })
136
+ .slice(0, RELATED_REPORT_CAP);
137
+ if (shown.length === 0) return "";
138
+ const lines = shown.map(({ relation, note }, index) => {
139
+ const tags = note.tags.length > 0 ? `; tags: ${note.tags.join(", ")}` : "";
140
+ const content = searchContent(note, preview(note.body), index < COMPLETE_BODY_RESULTS);
141
+ return `- ${note.slug}: ${note.title}\n connected: ${relation.reasons.join("; ")}; source: ${note.source}; updated: ${note.updated}${tags}\n ${content}`;
142
+ });
143
+ return `Connected notes to '${anchor.title}' (discovery context only — not direct query matches or evidence about the query):\n${lines.join("\n")}`;
144
+ }
145
+
66
146
  /** Render a link audit (and any repair) as the text the model reads. */
67
147
  function formatLinkReport(result: LinkRepairResult, applied: boolean): string {
68
148
  const { audit } = result;
@@ -120,14 +200,14 @@ export function registerNoteTool(pi: ExtensionAPI): void {
120
200
  "Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
121
201
  "of Markdown notes. Actions: list (all notes — avoid on large vaults, prefer search), get (one note by slug), add (new note), " +
122
202
  "append (extend a note; raw=true appends verbatim dictation into the ## Raw tail), " +
123
- "finalize (restructure a note above its raw tail), search (title/tags/body), " +
203
+ "finalize (restructure a note above its raw tail), search (ranked slug/title/tags/body matches plus linked, tagged, and lexically related notes; returns the full note when one result or one exact identity resolves), " +
124
204
  "links (audit stale [[wiki-links]]; fix=true repairs the unambiguous ones), " +
125
205
  "suggest (rank unlinked notes that share distinctive vocabulary; reports only, never writes). " +
126
206
  "Use it to remember decisions, facts, and user preferences across sessions.",
127
207
  promptSnippet: "Remember and retrieve durable knowledge in the pi-weave vault",
128
208
  promptGuidelines: [
129
209
  "Use weave_note to store durable knowledge (decisions, preferences, key facts) that should survive the session, marking source as agent-written knowledge.",
130
- "Use weave_note with action=search before answering questions about past decisions, people, or projects; generated notes under sessions/ carry takeaways from earlier sessions.",
210
+ "Use weave_note with action=get when the slug is known; otherwise use one targeted action=search before answering questions about past decisions, people, or projects. A resolved search already contains the full note; do not fetch it again or retry with reformulated queries.",
131
211
  "Use weave_note with action=links to find and repair stale [[wiki-links]] deterministically instead of rereading the vault to reconnect notes by hand; add fix=true to apply the unambiguous repairs.",
132
212
  "Use weave_note with action=suggest to discover notes that belong together but are not linked (optionally scoped to one slug); it only reports — propose the links to the user rather than writing them.",
133
213
  ],
@@ -280,12 +360,43 @@ export function registerNoteTool(pi: ExtensionAPI): void {
280
360
  details: { action: "search", hits: [] },
281
361
  };
282
362
  }
283
- const lines = hits.map(
284
- (h) => `- ${h.summary.slug}: ${h.summary.title} (score ${h.score})\n ${h.snippet}`,
363
+ const { notes } = await readVault(vault);
364
+ const bySlug = new Map(notes.map((note) => [note.slug, note]));
365
+ const direct = new Set(hits.map((hit) => hit.summary.slug));
366
+ const anchor = bySlug.get(hits[0]!.summary.slug);
367
+ const related = anchor ? relatedNotes({ notes }, anchor.slug) : [];
368
+ const relatedBySlug = new Map(related.map((relation) => [relation.slug, relation]));
369
+ const connected = anchor ? formatRelatedNotes(related, bySlug, direct, anchor) : "";
370
+ const query = params.query.trim().toLowerCase();
371
+ const exact = hits.filter((hit) =>
372
+ hit.summary.title.trim().toLowerCase() === query || hit.summary.slug.toLowerCase() === query
285
373
  );
374
+ const resolved = hits.length === 1 ? hits[0] : exact.length === 1 ? exact[0] : undefined;
375
+ if (resolved) {
376
+ const note = bySlug.get(resolved.summary.slug);
377
+ if (note) {
378
+ const others = hits.filter((hit) => hit.summary.slug !== note.slug);
379
+ const otherMatches = others.length > 0
380
+ ? `Other direct matches (${others.length}):\n${formatSearchHits(others, params.query, relatedBySlug, bySlug)}`
381
+ : "";
382
+ return {
383
+ content: [{
384
+ type: "text",
385
+ text: [formatNote(note).trimEnd(), otherMatches, connected].filter(Boolean).join("\n\n") + "\n",
386
+ }],
387
+ details: { action: "search", hits, resolved: note, related },
388
+ };
389
+ }
390
+ }
286
391
  return {
287
- content: [{ type: "text", text: `${hits.length} hit(s) for '${params.query}':\n${lines.join("\n")}` }],
288
- details: { action: "search", hits },
392
+ content: [{
393
+ type: "text",
394
+ text: [
395
+ `${hits.length} direct match(es) for '${params.query}', strongest first:\n${formatSearchHits(hits, params.query, relatedBySlug, bySlug)}`,
396
+ connected,
397
+ ].filter(Boolean).join("\n\n"),
398
+ }],
399
+ details: { action: "search", hits, related },
289
400
  };
290
401
  }
291
402