pi-weave 0.2.3 → 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 +12 -5
- package/skills/weave-notepad/SKILL.md +13 -2
- package/src/core/index.ts +2 -0
- package/src/core/links/similar.ts +97 -30
- package/src/core/vault.ts +78 -6
- package/src/pi/tools/noteTool.ts +117 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-weave",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
13
|
-
"agent",
|
|
12
|
+
"pi-coding-agent",
|
|
13
|
+
"agent-memory",
|
|
14
|
+
"persistent-memory",
|
|
15
|
+
"markdown",
|
|
14
16
|
"notepad",
|
|
15
|
-
"
|
|
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
|
-
|
|
118
|
-
|
|
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
|
@@ -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))
|
|
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 =
|
|
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
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
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
|
|
1019
|
+
for (const note of notes) {
|
|
993
1020
|
let score = 0;
|
|
994
|
-
|
|
995
|
-
|
|
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
|
|
package/src/pi/tools/noteTool.ts
CHANGED
|
@@ -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;
|
|
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
|
|
284
|
-
|
|
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: [{
|
|
288
|
-
|
|
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
|
|