@seangle/pi-remember 0.0.0-stage → 0.2.0

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,6 +1,51 @@
1
1
  {
2
2
  "name": "@seangle/pi-remember",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "description": "Durable, plain-file local and project memory for the Pi coding agent.",
4
+ "version": "0.2.0",
5
+ "license": "Apache-2.0",
6
+ "author": "Esensats",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Esensats/pi-remember.git"
10
+ },
11
+ "homepage": "https://github.com/Esensats/pi-remember#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Esensats/pi-remember/issues"
14
+ },
15
+ "keywords": ["pi-package", "pi-extension", "memory", "persistent-memory"],
16
+ "files": [
17
+ "index.ts",
18
+ "command.ts",
19
+ "inject.ts",
20
+ "migration.ts",
21
+ "notes.ts",
22
+ "search.ts",
23
+ "store.ts",
24
+ "summary.ts",
25
+ "workspace.ts",
26
+ "README.md",
27
+ "CHANGELOG.md",
28
+ "LICENSE"
29
+ ],
30
+ "pi": {
31
+ "extensions": ["./index.ts"]
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-ai": "*",
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "@earendil-works/pi-tui": "*",
37
+ "typebox": "*"
38
+ },
39
+ "engines": {
40
+ "node": ">=22.15.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org/"
45
+ },
46
+ "type": "module",
47
+ "scripts": {
48
+ "prepublishOnly": "npm test",
49
+ "test": "node --experimental-strip-types --test \"test/*.test.ts\""
50
+ }
51
+ }
package/search.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { sep } from "node:path";
4
+ import {
5
+ entryId, noteReference, parseEntries, readMemoryText, readNotes, resolveNotePath,
6
+ type MemoId, type Scope, type StorePaths,
7
+ } from "./store.ts";
8
+
9
+ const STOPWORDS = new Set([
10
+ "the", "a", "an", "of", "to", "in", "is", "are", "and", "or", "for", "on",
11
+ "with", "as", "at", "by", "it", "its", "this", "that", "these", "those",
12
+ "be", "was", "were", "been", "do", "does", "did", "i", "we", "you", "he",
13
+ "she", "they", "my", "your", "our", "their", "should", "would", "could",
14
+ "can", "will", "not", "no", "yes", "if", "then", "than", "so", "but",
15
+ "from", "into", "about", "what", "when", "where", "which", "who", "how",
16
+ ]);
17
+
18
+ export interface SearchHit {
19
+ scope: Scope;
20
+ file: string;
21
+ line: string;
22
+ meta?: string;
23
+ id?: MemoId;
24
+ score: number;
25
+ /** Matching body lines in the note; at least 1 for description-only hits. */
26
+ matchLines: number;
27
+ }
28
+
29
+ const normalize = (text: string): string => text.normalize("NFKC").toLowerCase();
30
+ const words = (text: string): string[] => normalize(text).match(/[\p{L}\p{N}][\p{L}\p{N}\p{M}]*/gu) ?? [];
31
+
32
+ export function tokenize(query: string): string[] {
33
+ return words(query).filter((t) => t.length >= 2 && !STOPWORDS.has(t));
34
+ }
35
+
36
+ interface Query {
37
+ tokens: string[];
38
+ phrases: string[][];
39
+ }
40
+
41
+ function parseQuery(query: string): Query {
42
+ const tokens = [...new Set(tokenize(query))];
43
+ // Quotes request a phrase boost, not a hard filter. Preserve stopwords and
44
+ // token boundaries in phrases ("proxy in docker" != "proxy docker").
45
+ const quoted = [...query.matchAll(/"([^"]+)"/g)].map((m) => words(m[1]));
46
+ const phrases = (quoted.length ? quoted : [words(query)])
47
+ .filter((p) => new Set(p.filter((word) => tokens.includes(word))).size > 1);
48
+ return { tokens, phrases };
49
+ }
50
+
51
+ function hasPhrase(tokens: string[], phrase: string[]): boolean {
52
+ return tokens.some((_, i) => phrase.every((word, j) => tokens[i + j] === word));
53
+ }
54
+
55
+ /** Never index managed YAML, including an incomplete frontmatter block. */
56
+ function stripFrontmatter(text: string): string {
57
+ const clean = text.replace(/^\uFEFF/, "");
58
+ const lines = clean.split(/\r?\n/);
59
+ if (lines[0]?.trim() === "---") {
60
+ const end = lines.findIndex((line, i) => i > 0 && /^(---|\.\.\.)\s*$/.test(line));
61
+ // Fail closed on incomplete frontmatter instead of exposing IDs/dates.
62
+ text = end < 0 ? "" : lines.slice(end + 1).join("\n");
63
+ } else text = clean;
64
+ return text;
65
+ }
66
+
67
+ function bodyText(text: string): string {
68
+ return stripFrontmatter(text).replace(/<!--[\s\S]*?-->/g, " ");
69
+ }
70
+
71
+ function markupTokens(text: string): string[] {
72
+ return [...text.matchAll(/#[\p{L}\p{N}][\p{L}\p{N}\p{M}_-]*|\[\[([^\]]+)\]\]/gu)]
73
+ .flatMap((m) => tokenize(m[1] ?? m[0]));
74
+ }
75
+
76
+ function frequencies(tokens: string[]): Map<string, number> {
77
+ const counts = new Map<string, number>();
78
+ for (const token of tokens) counts.set(token, (counts.get(token) ?? 0) + 1);
79
+ return counts;
80
+ }
81
+
82
+ interface SearchDocument {
83
+ scope: Scope;
84
+ file: string;
85
+ description: string;
86
+ body: string;
87
+ meta?: string;
88
+ id?: MemoId;
89
+ }
90
+
91
+ function compareText(a: string, b: string): number {
92
+ return a < b ? -1 : a > b ? 1 : 0;
93
+ }
94
+
95
+ /** A small, query-local BM25F corpus: no dependencies or persistent index. */
96
+ function rank(documents: SearchDocument[], query: Query): SearchHit[] {
97
+ if (!query.tokens.length || !documents.length) return [];
98
+ const prepared = documents.map((document) => {
99
+ const description = tokenize(document.description);
100
+ const body = tokenize(document.body);
101
+ const markup = markupTokens(`${document.description}\n${document.body}\n${document.meta ?? ""}`);
102
+ return {
103
+ document, description, body,
104
+ descFreq: frequencies(description), bodyFreq: frequencies(body), tagFreq: frequencies(markup),
105
+ phraseFields: [words(document.description), words(document.body)],
106
+ };
107
+ });
108
+ const avgBody = prepared.reduce((sum, p) => sum + p.body.length, 0) / prepared.length || 1;
109
+ const avgDesc = prepared.reduce((sum, p) => sum + p.description.length, 0) / prepared.length || 1;
110
+ const idf = new Map(query.tokens.map((token) => {
111
+ const df = prepared.filter((p) => p.descFreq.has(token) || p.bodyFreq.has(token) || p.tagFreq.has(token)).length;
112
+ return [token, Math.log(1 + (prepared.length - df + 0.5) / (df + 0.5))];
113
+ }));
114
+ const hits: SearchHit[] = [];
115
+ for (const p of prepared) {
116
+ let lexical = 0;
117
+ let matched = 0;
118
+ for (const token of query.tokens) {
119
+ // Separate field normalization prevents a long body diluting a good title.
120
+ const tf = (p.bodyFreq.get(token) ?? 0) / (0.25 + 0.75 * p.body.length / avgBody)
121
+ + 5 * (p.descFreq.get(token) ?? 0) / (0.7 + 0.3 * p.description.length / avgDesc)
122
+ + 2 * (p.tagFreq.get(token) ?? 0);
123
+ if (tf === 0) continue;
124
+ matched++;
125
+ lexical += idf.get(token)! * (2.2 * tf / (1.2 + tf));
126
+ }
127
+ if (!matched) continue;
128
+ const coverage = matched / query.tokens.length;
129
+ const phraseBonus = query.phrases.some((phrase) => p.phraseFields.some((field) => hasPhrase(field, phrase))) ? 1.5 : 0;
130
+ const { document } = p;
131
+ hits.push({
132
+ scope: document.scope, file: document.file, meta: document.meta, id: document.id,
133
+ line: bestPassage(document.body, document.description, query),
134
+ score: lexical * (0.2 + 0.8 * coverage ** 2) + 2 * coverage + phraseBonus * coverage,
135
+ matchLines: Math.max(1, document.body.split(/\r?\n/)
136
+ .filter((line) => tokenize(line).some((token) => query.tokens.includes(token))).length),
137
+ });
138
+ }
139
+ return hits.sort((a, b) => b.score - a.score || b.matchLines - a.matchLines
140
+ || compareText(a.scope, b.scope) || compareText(a.file, b.file) || compareText(a.line, b.line));
141
+ }
142
+
143
+ /** Choose a bounded token window, including matches deep inside a long line. */
144
+ function bestPassage(body: string, description: string, query: Query): string {
145
+ const source = body.trim();
146
+ const spans = [...source.matchAll(/[\p{L}\p{N}][\p{L}\p{N}\p{M}]*/gu)];
147
+ const matches = spans.flatMap((span, i) => query.tokens.includes(normalize(span[0])) ? [i] : []);
148
+ if (!matches.length) return clip(description || source);
149
+ let best = "";
150
+ let bestScore = -Infinity;
151
+ for (const i of matches) {
152
+ const first = Math.max(0, i - 8);
153
+ const start = spans[first].index!;
154
+ const maxEnd = start + 280;
155
+ let last = first;
156
+ while (last + 1 < spans.length && last - first < 39
157
+ && spans[last + 1].index! + spans[last + 1][0].length <= maxEnd) last++;
158
+ const end = spans[last].index! + spans[last][0].length;
159
+ const passage = source.slice(start, end);
160
+ const tokens = tokenize(passage);
161
+ const coverage = query.tokens.filter((token) => tokens.includes(token)).length;
162
+ const count = tokens.filter((token) => query.tokens.includes(token)).length;
163
+ const phrase = query.phrases.some((p) => hasPhrase(words(passage), p)) ? 3 : 0;
164
+ const score = 10 * coverage + phrase + count / (count + 1) + count / Math.max(1, tokens.length);
165
+ if (score > bestScore) {
166
+ bestScore = score;
167
+ best = `${start ? "… " : ""}${passage.replace(/\s+/g, " ")}${end < source.length ? " …" : ""}`;
168
+ }
169
+ }
170
+ return best;
171
+ }
172
+
173
+ function clip(text: string): string {
174
+ const clean = text.replace(/\s+/g, " ").trim();
175
+ return clean.length <= 280 ? clean : `${clean.slice(0, 277).replace(/\s+\S*$/, "")} …`;
176
+ }
177
+
178
+ /** Compatible single-entry helper, now using exact tokens and saturated TF. */
179
+ export function scoreLine(line: string, meta: string | undefined, tokens: string[], fullQuery: string): number {
180
+ return rank([{ scope: "local", file: "", description: line, body: "", meta }], {
181
+ tokens: [...new Set(tokens.flatMap(tokenize))], phrases: parseQuery(fullQuery).phrases,
182
+ })[0]?.score ?? 0;
183
+ }
184
+
185
+ export function searchInText(scope: Scope, file: string, text: string, query: string): SearchHit[] {
186
+ const isIndex = /(?:^|[/\\])MEMORY\.md$/.test(file);
187
+ return rank(parseEntries(stripFrontmatter(text)).map((entry) => ({
188
+ scope, file, description: entry.line, body: "", meta: entry.meta,
189
+ id: isIndex ? entryId(entry) ?? undefined : undefined,
190
+ })), parseQuery(query));
191
+ }
192
+
193
+ function referenceDescription(line: string): string {
194
+ return line.replace(/^#note\s+\[\[[^\]]+\]\]\s*/, "");
195
+ }
196
+
197
+ function isMissing(error: unknown): boolean {
198
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
199
+ }
200
+
201
+ export async function searchStores(
202
+ stores: Record<Scope, StorePaths>, query: string, scopes: Scope[], limit = 20,
203
+ ): Promise<SearchHit[]> {
204
+ const parsed = parseQuery(query);
205
+ if (!parsed.tokens.length || !Number.isFinite(limit) || limit <= 0) return [];
206
+ const documents: SearchDocument[] = [];
207
+ for (const scope of new Set(scopes)) {
208
+ const store = stores[scope];
209
+ const entries = parseEntries(await readMemoryText(store));
210
+ // readNotes supplies metadata only; body I/O belongs to search, not injection.
211
+ const records = await readNotes(store);
212
+ const byRel = new Map(records.map((record) => [record.rel, record]));
213
+ const files = new Set(byRel.keys());
214
+ const notesRoot = existsSync(store.notesDir) ? realpathSync(store.notesDir) + sep : undefined;
215
+ if (notesRoot) {
216
+ for (const file of await readdir(store.notesDir)) if (file.endsWith(".md")) files.add(`notes/${file}`);
217
+ }
218
+ for (const entry of entries) {
219
+ const rel = noteReference(entry);
220
+ if (rel && /^notes\/.+\.md$/.test(rel)) files.add(rel);
221
+ }
222
+ const loaded = new Set<string>();
223
+ for (const file of [...files].sort(compareText)) {
224
+ const resolved = resolveNotePath(stores, file, [scope]);
225
+ if (resolved.status === "error" || !notesRoot || !resolved.absPath.startsWith(notesRoot)) continue;
226
+ let text: string;
227
+ try { text = await readFile(resolved.absPath, "utf8"); }
228
+ catch (error) { if (isMissing(error)) continue; throw error; }
229
+ const record = byRel.get(file);
230
+ const references = entries.filter((entry) => noteReference(entry) === file);
231
+ const body = bodyText(text);
232
+ documents.push({
233
+ scope, file, body,
234
+ description: record?.metadata.description ?? references.map((entry) => referenceDescription(entry.line)).join("\n"),
235
+ id: record?.metadata.id ?? (references.length === 1 ? entryId(references[0]) ?? undefined : undefined),
236
+ meta: record?.entry.meta ?? (references.length === 1 ? references[0].meta : undefined),
237
+ });
238
+ loaded.add(file);
239
+ }
240
+ for (const entry of entries) {
241
+ const rel = noteReference(entry);
242
+ if (rel && loaded.has(rel)) continue; // Merge description and body, never two hits for one note.
243
+ documents.push({
244
+ scope, file: "MEMORY.md", description: rel ? referenceDescription(entry.line) : entry.line,
245
+ body: "", meta: entry.meta, id: entryId(entry) ?? undefined,
246
+ });
247
+ }
248
+ }
249
+ return rank(documents, parsed).slice(0, Math.floor(limit));
250
+ }