pi-supernova 0.0.8 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/snap.js CHANGED
@@ -1,7 +1,8 @@
1
1
 
2
2
  import * as path from "node:path";
3
3
  import { isString } from "./decode.js";
4
- import { extractStructuralSurface } from "./surface.js";
4
+ import { WorkspaceIndex } from "./repo-index.js";
5
+ import { isTestPath } from "./workspace.js";
5
6
 
6
7
  const STOP_WORDS = new Set([
7
8
  "the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
@@ -30,72 +31,73 @@ export function tokenizeQuery(query) {
30
31
  };
31
32
  }
32
33
 
33
- export function scorePathTopology(filePath, tokens, { wantsTest, wantsDoc, wantsType }) {
34
- const norm = filePath.replace(/\\/g, "/").toLowerCase();
35
- const basename = path.basename(norm);
36
- const ext = path.extname(norm);
37
-
38
- const isTest = norm.includes("test") || norm.includes("spec") || norm.includes("__tests__");
39
- if (isTest && !wantsTest) return -50;
40
- if (!isTest && wantsTest) return -20;
34
+ const SOURCE_EXT = new Set([".ts", ".js", ".mjs", ".rs", ".py", ".go"]);
35
+ const TYPED_EXT = new Set([".ts", ".d.ts", ".rs", ".go"]);
36
+ const VENDOR_SEGMENTS = ["node_modules/", "dist/", "target/"];
41
37
 
42
- if (norm.includes("node_modules/") || norm.includes("dist/") || norm.includes("target/")) {
43
- return -100;
44
- }
45
-
46
- let score = 0;
47
- const pathParts = norm.split(/[^a-zA-Z0-9]+/);
38
+ function tokenPathScore(token, basename, pathParts, norm) {
39
+ if (basename === token || basename.startsWith(token + ".")) return 60;
40
+ if (basename.includes(token)) return 30;
41
+ if (pathParts.includes(token)) return 15;
42
+ if (norm.includes(token)) return 5;
43
+ return 0;
44
+ }
48
45
 
49
- for (const token of tokens) {
50
- if (basename === token || basename.startsWith(token + ".")) score += 60;
51
- else if (basename.includes(token)) score += 30;
52
- else if (pathParts.includes(token)) score += 15;
53
- else if (norm.includes(token)) score += 5;
54
- }
46
+ function extensionBonus(ext, { wantsDoc, wantsType }) {
47
+ let bonus = 0;
48
+ if (SOURCE_EXT.has(ext) && !wantsDoc) bonus += 5;
49
+ if (wantsType && TYPED_EXT.has(ext)) bonus += 10;
50
+ return bonus;
51
+ }
55
52
 
56
- if ([".ts", ".js", ".mjs", ".rs", ".py", ".go"].includes(ext) && !wantsDoc) {
57
- score += 5;
58
- }
59
- if (wantsType && [".ts", ".d.ts", ".rs", ".go"].includes(ext)) {
60
- score += 10;
61
- }
53
+ export function scorePathTopology(filePath, tokens, flags) {
54
+ const norm = filePath.replaceAll("\\", "/").toLowerCase();
55
+ const isTest = norm.includes("test") || norm.includes("spec") || norm.includes("__tests__");
56
+ if (isTest && !flags.wantsTest) return -50;
57
+ if (!isTest && flags.wantsTest) return -20;
58
+ if (VENDOR_SEGMENTS.some((segment) => norm.includes(segment))) return -100;
62
59
 
60
+ const basename = path.basename(norm);
61
+ const pathParts = norm.split(/[^a-zA-Z0-9]+/);
62
+ let score = extensionBonus(path.extname(norm), flags);
63
+ for (const token of tokens) score += tokenPathScore(token, basename, pathParts, norm);
63
64
  return score;
64
65
  }
65
66
 
66
- function isSkippableLine(line) {
67
- return !line || line.startsWith("//") || line.startsWith("#") || line.startsWith("*");
67
+ function isSkippableLine(lower) {
68
+ return !lower || lower.startsWith("//") || lower.startsWith("#") || lower.startsWith("*");
68
69
  }
69
70
 
70
- function lineScoreFor(line, tokens, isDef) {
71
- const lower = line.toLowerCase();
71
+ /** A line defines a token only when the declared name contains it; `const x = foo(token)` is a mention. */
72
+ function lineScoreFor(lower, tokens, definedName) {
72
73
  let lineScore = 0;
73
74
  for (const token of tokens) {
74
- if (lower.includes(token)) lineScore += isDef ? 40 : 5;
75
+ if (!lower.includes(token)) continue;
76
+ lineScore += definedName.includes(token) ? 40 : 5;
75
77
  }
76
78
  return lineScore;
77
79
  }
78
80
 
79
- function accumulateContentScore(lines, tokens, defPattern) {
80
- let score = 0;
81
+ // Mentions are capped so a file that calls a symbol many times cannot outrank the file that defines it.
82
+ const MAX_MENTION_SCORE = 60;
83
+
84
+ function scoreContentDefinitions(entry, tokens) {
85
+ const { lower, defNames } = WorkspaceIndex.linesOf(entry);
86
+ let defScore = 0;
87
+ let mentionScore = 0;
81
88
  let bestLine = 1;
82
89
  let bestLineScore = 0;
83
- for (let i = 0; i < lines.length; i++) {
84
- const line = lines[i].trim();
85
- if (isSkippableLine(line)) continue;
86
- const lineScore = lineScoreFor(line, tokens, defPattern.test(line));
90
+ for (let i = 0; i < lower.length; i++) {
91
+ if (isSkippableLine(lower[i])) continue;
92
+ const lineScore = lineScoreFor(lower[i], tokens, defNames[i]);
87
93
  if (lineScore > bestLineScore) {
88
94
  bestLineScore = lineScore;
89
95
  bestLine = i + 1;
90
96
  }
91
- score += lineScore;
97
+ if (defNames[i]) defScore += lineScore;
98
+ else mentionScore += lineScore;
92
99
  }
93
- return { totalScore: score, bestLine, bestLineScore };
94
- }
95
-
96
- function scoreContentDefinitions(content, tokens) {
97
- const defRegex = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
98
- return accumulateContentScore(content.split("\n"), tokens, defRegex);
100
+ return { totalScore: defScore + Math.min(mentionScore, MAX_MENTION_SCORE), bestLine, bestLineScore };
99
101
  }
100
102
 
101
103
  function relativeHasSegment(relativePath, segmentName) {
@@ -106,17 +108,8 @@ function relativeHasHiddenSegment(relativePath) {
106
108
  return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
107
109
  }
108
110
 
109
- async function listCandidateFiles(dir, includeHidden, runCommand) {
110
- const rgArgs = ["rg", "--files"];
111
- if (includeHidden) rgArgs.push("--hidden");
112
- rgArgs.push("-g", "!.git/**", "-g", "!**/.git/**", dir);
113
- try {
114
- const res = await runCommand(rgArgs, { timeoutMs: 15_000 });
115
- // rg --files is multithreaded and emits in nondeterministic order; ties must rank stably.
116
- return res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).sort();
117
- } catch {
118
- return [];
119
- }
111
+ async function listCandidateFiles(dir, includeHidden, index) {
112
+ return (await index.files(dir, includeHidden)).slice();
120
113
  }
121
114
 
122
115
  function mergePendingPaths(fileList, pendingPaths, dir, includeHidden = false) {
@@ -145,53 +138,42 @@ function mergeGrepHits(candidates, grepHits) {
145
138
  return candidates;
146
139
  }
147
140
 
148
- async function expandCandidatesWithGrep(candidates, fileList, tokens, flags, dir, includeHidden, runCommand) {
141
+ function expandCandidatesWithGrep(candidates, fileList, tokens, flags, index) {
149
142
  if (candidates.length >= 5) return candidates;
150
- try {
151
- // Tokens are lowercased; identifiers are not.
152
- const grepArgs = ["-l", "-i", "--max-count=1", "-g", "!.git/**", "-g", "!**/.git/**"];
153
- if (includeHidden) grepArgs.push("--hidden");
154
- if (!flags.wantsTest) {
155
- grepArgs.push("-g", "!test/**", "-g", "!tests/**", "-g", "!*.test.*", "-g", "!*.spec.*");
156
- }
157
- const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
158
- for (const t of salient) grepArgs.push("-e", t);
159
- const res = await runCommand(["rg", ...grepArgs, dir], { timeoutMs: 15_000 });
160
- mergeGrepHits(candidates, res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).sort());
161
- } catch {
162
- if (candidates.length === 0) candidates = fileList.slice(0, 5);
163
- }
143
+ const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
144
+ const scope = flags.wantsTest ? fileList : fileList.filter((f) => !isTestPath(f));
145
+ const hits = index.filesContaining(scope, salient, true);
146
+ mergeGrepHits(candidates, hits);
147
+ if (candidates.length === 0) return fileList.slice(0, 5);
164
148
  return candidates;
165
149
  }
166
150
 
167
151
  function scoreSurfaceItems(items, tokens, fallbackLine) {
168
152
  let bonus = 0;
169
- let signature = "";
170
- let anchorLine = fallbackLine;
153
+ let best = null;
154
+ let bestMatches = 0;
171
155
  for (const item of items) {
172
156
  const nameLower = item.name.toLowerCase();
173
- for (const token of tokens) {
174
- if (!nameLower.includes(token)) continue;
175
- bonus += item.isExport ? 80 : 50;
176
- if (signature) continue;
177
- signature = item.signature;
178
- anchorLine = item.line;
157
+ const matches = tokens.filter((token) => nameLower.includes(token)).length;
158
+ if (matches === 0) continue;
159
+ bonus += matches * (item.isExport ? 80 : 50);
160
+ if (matches > bestMatches) {
161
+ bestMatches = matches;
162
+ best = item;
179
163
  }
180
164
  }
181
- return { bonus, signature, anchorLine };
165
+ return { bonus, signature: best?.signature ?? "", anchorLine: best?.line ?? fallbackLine };
182
166
  }
183
167
 
184
- async function scoreCandidateContents(candidates, tokens, flags, vfs) {
168
+ function scoreCandidateContents(candidates, tokens, flags, index, overlayText) {
185
169
  const candidateScores = [];
186
170
  for (const filePath of candidates) {
187
- let content = "";
188
- try {
189
- content = await vfs.read(filePath);
190
- } catch {
191
- continue;
192
- }
193
- const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(content, tokens);
194
- const surface = extractStructuralSurface(content, path.extname(filePath));
171
+ const pending = overlayText(filePath);
172
+ const entry = pending === undefined ? index.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
173
+ if (!entry) continue;
174
+ const content = entry.text;
175
+ const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(entry, tokens);
176
+ const surface = WorkspaceIndex.surfaceOf(entry);
195
177
  const { bonus: surfaceBonus, signature, anchorLine } = scoreSurfaceItems(surface.items, tokens, bestLine);
196
178
  const lowerPath = filePath.toLowerCase();
197
179
  const isTestFile = lowerPath.includes("test") || lowerPath.includes("spec");
@@ -208,7 +190,7 @@ async function scoreCandidateContents(candidates, tokens, flags, vfs) {
208
190
  return candidateScores;
209
191
  }
210
192
 
211
- async function rankCandidates(fileList, tokens, flags, dir, includeHidden, runCommand, vfs) {
193
+ function rankCandidates(fileList, tokens, flags, index, overlayText) {
212
194
  const scoredPaths = [];
213
195
  for (const f of fileList) {
214
196
  const score = scorePathTopology(f, tokens, flags);
@@ -216,33 +198,29 @@ async function rankCandidates(fileList, tokens, flags, dir, includeHidden, runCo
216
198
  }
217
199
  scoredPaths.sort((a, b) => b.score - a.score);
218
200
  const selected = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
219
- const candidates = await expandCandidatesWithGrep(selected, fileList, tokens, flags, dir, includeHidden, runCommand);
220
- const candidateScores = await scoreCandidateContents(candidates, tokens, flags, vfs);
201
+ const candidates = expandCandidatesWithGrep(selected, fileList, tokens, flags, index);
202
+ const candidateScores = scoreCandidateContents(candidates, tokens, flags, index, overlayText);
221
203
  return { candidates, candidateScores };
222
204
  }
223
205
 
224
- function buildSnapResult(candidates, candidateScores, fileList) {
206
+ function buildSnapResult(candidates, candidateScores, fileList, root) {
207
+ const relative = (p) => path.relative(root, p) || p;
225
208
  if (candidateScores.length === 0 || candidateScores[0].score <= 0) {
226
- return {
227
- path: candidates[0] || fileList[0],
228
- line: 1,
229
- signature: "",
230
- confidence: 0.3,
231
- context: [],
232
- };
209
+ return { path: relative(candidates[0] || fileList[0]), line: 1, signature: "", confidence: 0.3, context: [] };
233
210
  }
234
211
  const best = candidateScores[0];
235
212
  const lines = best.content.split("\n");
236
- const startLine = Math.max(1, best.anchorLine - 3);
237
- const endLine = Math.min(lines.length, best.anchorLine + 8);
213
+ // Two lines before and four after: enough to confirm the hit; read() is the tool for more.
214
+ const startLine = Math.max(1, best.anchorLine - 2);
215
+ const endLine = Math.min(lines.length, best.anchorLine + 4);
238
216
  const context = [];
239
217
  for (let l = startLine; l <= endLine; l++) {
240
218
  const marker = l === best.anchorLine ? "►" : " ";
241
- context.push(`${marker} ${String(l).padStart(4)} │ ${lines[l - 1]}`);
219
+ context.push(marker + l + " " + lines[l - 1]);
242
220
  }
243
221
  const confidence = Math.min(0.98, Math.max(0.65, best.score / 150));
244
222
  return {
245
- path: best.path,
223
+ path: relative(best.path),
246
224
  line: best.anchorLine,
247
225
  signature: best.signature,
248
226
  confidence: Number(confidence.toFixed(2)),
@@ -250,7 +228,7 @@ function buildSnapResult(candidates, candidateScores, fileList) {
250
228
  };
251
229
  }
252
230
 
253
- export async function executeSnap({ query, searchDir, includeHidden = false, vfs, runCommand, pendingPaths = [] }) {
231
+ export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [] }) {
254
232
  const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
255
233
  if (tokens.length === 0) {
256
234
  throw new Error("snap requires at least one searchable concept keyword");
@@ -259,12 +237,12 @@ export async function executeSnap({ query, searchDir, includeHidden = false, vfs
259
237
  if (path.resolve(dir).split(path.sep).includes(".git")) {
260
238
  throw new Error("snap cannot search Git metadata");
261
239
  }
262
- const fileList = await listCandidateFiles(dir, includeHidden, runCommand);
240
+ const fileList = await listCandidateFiles(dir, includeHidden, index);
263
241
  mergePendingPaths(fileList, pendingPaths, dir, includeHidden);
264
242
  if (fileList.length === 0) {
265
243
  throw new Error(`no files found to search in ${dir}`);
266
244
  }
267
245
  const flags = { wantsTest, wantsDoc, wantsType };
268
- const { candidates, candidateScores } = await rankCandidates(fileList, tokens, flags, dir, includeHidden, runCommand, vfs);
269
- return buildSnapResult(candidates, candidateScores, fileList);
246
+ const { candidates, candidateScores } = rankCandidates(fileList, tokens, flags, index, overlayText);
247
+ return buildSnapResult(candidates, candidateScores, fileList, root ?? dir);
270
248
  }
package/surface.js CHANGED
@@ -66,24 +66,36 @@ const JS_DECL_PATTERNS = [
66
66
  [/^(?:async\s+)?(function\*?|class)\s+([a-zA-Z0-9_$]+)/, false],
67
67
  [/^(interface|type)\s+([a-zA-Z0-9_$]+)/, false],
68
68
  ];
69
+ // Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
70
+ const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
71
+ // Indented methods (object-literal adapters, class members) that open a block on the same line.
72
+ const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{$/;
73
+ const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{$/;
74
+ const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
75
+
76
+ function methodItem(line, lineNumber, depth) {
77
+ const match = JS_METHOD.exec(line) || JS_ARROW_PROPERTY.exec(line);
78
+ if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
79
+ return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{$/, ""), line: lineNumber, depth };
80
+ }
81
+
82
+ function declarationItem(line, rawLine, lineNumber) {
83
+ const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
84
+ for (const [pattern, isExport] of patterns) {
85
+ const match = pattern.exec(line);
86
+ if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth: 0 };
87
+ }
88
+ return null;
89
+ }
69
90
 
70
91
  function scanJavaScript(lines) {
71
92
  const items = [];
72
93
  for (let i = 0; i < lines.length; i++) {
73
94
  const line = lines[i].trim();
74
95
  if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
75
- for (const [pattern, isExport] of JS_DECL_PATTERNS) {
76
- const match = pattern.exec(line);
77
- if (!match) continue;
78
- items.push({
79
- kind: match[1],
80
- name: match[2],
81
- isExport,
82
- signature: line.replace(/\{.*$/, "").trim(),
83
- line: i + 1,
84
- });
85
- break;
86
- }
96
+ const indent = lines[i].length - lines[i].trimStart().length;
97
+ const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
98
+ if (item) items.push(item);
87
99
  }
88
100
  return items;
89
101
  }
package/vfs.js CHANGED
@@ -4,9 +4,10 @@ import * as path from "node:path";
4
4
  const VFS_CACHE_MAX = 1024;
5
5
 
6
6
  export class CausalVfs {
7
- constructor() {
7
+ constructor(onNewFile) {
8
8
  this.cache = new Map();
9
9
  this.overlays = [];
10
+ this.onNewFile = onNewFile;
10
11
  }
11
12
 
12
13
  setCache(target, content) {
@@ -62,6 +63,7 @@ export class CausalVfs {
62
63
  return { speculative: true };
63
64
  }
64
65
 
66
+ let existed = true;
65
67
  try {
66
68
  const stat = await fs.stat(target);
67
69
  if (stat.isDirectory()) {
@@ -69,11 +71,13 @@ export class CausalVfs {
69
71
  }
70
72
  } catch (err) {
71
73
  if (err.code !== "ENOENT") throw err;
74
+ existed = false;
72
75
  }
73
76
 
74
77
  await fs.mkdir(path.dirname(target), { recursive: true });
75
78
  await fs.writeFile(target, content, "utf8");
76
79
  this.setCache(target, content);
80
+ if (!existed) this.onNewFile?.(target);
77
81
  return { speculative: false };
78
82
  }
79
83
 
@@ -95,6 +99,7 @@ export class CausalVfs {
95
99
  await fs.writeFile(filePath, fileContent, "utf8");
96
100
  this.setCache(filePath, fileContent);
97
101
  }
102
+ if (top.size > 0) this.onNewFile?.();
98
103
  return { committed: top.size, depth: 0 };
99
104
  }
100
105
 
@@ -115,6 +120,7 @@ export class CausalVfs {
115
120
  await fs.writeFile(filePath, fileContent, "utf8");
116
121
  this.setCache(filePath, fileContent);
117
122
  }
123
+ if (pending.size > 0) this.onNewFile?.();
118
124
  this.overlays[0] = new Map();
119
125
  return pending.size > 0;
120
126
  }
@@ -123,11 +129,6 @@ export class CausalVfs {
123
129
  this.cache.clear();
124
130
  }
125
131
 
126
- clear() {
127
- this.invalidateCache();
128
- this.overlays.length = 0;
129
- }
130
-
131
132
  getCacheSize() {
132
133
  return this.cache.size;
133
134
  }
package/workspace.js CHANGED
@@ -5,6 +5,14 @@ import { isString } from "./decode.js";
5
5
 
6
6
  let cachedCwd = null;
7
7
  let cachedResolvedCwd = null;
8
+ // realpath results per program: two syscalls per call otherwise dominate a cached read.
9
+ const realRoots = new Map();
10
+ const realNearest = new Map();
11
+ const PATH_CACHE_MAX = 2048;
12
+
13
+ export function clearPathCache() {
14
+ realNearest.clear();
15
+ }
8
16
 
9
17
  function getResolvedCwd(cwd) {
10
18
  if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
@@ -33,6 +41,20 @@ async function realpathNearest(target) {
33
41
  }
34
42
  }
35
43
 
44
+ /** Workspace-relative path with "/" separators, the form every model-facing surface uses. */
45
+ export function relativeSlash(root, absolute) {
46
+ return (path.relative(root, absolute) || absolute).split(path.sep).join("/");
47
+ }
48
+
49
+ const TEST_SEGMENTS = new Set(["test", "tests", "__tests__", "spec"]);
50
+
51
+ /** One rule for "is this a test file" across snap, evidence, and outlines. */
52
+ export function isTestPath(filePath) {
53
+ const segments = filePath.split(/[\\/]/);
54
+ const base = segments[segments.length - 1];
55
+ return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
56
+ }
57
+
36
58
  export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
37
59
  if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
38
60
  throw new Error(`${opName} requires path`);
@@ -43,8 +65,17 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
43
65
  if (!allowRoot && target === resolvedCwd) {
44
66
  throw new Error(`${opName} path cannot be the workspace root directory`);
45
67
  }
46
- const realRoot = await fs.realpath(resolvedCwd);
47
- const probe = await realpathNearest(target);
68
+ let realRoot = realRoots.get(resolvedCwd);
69
+ if (!realRoot) {
70
+ realRoot = await fs.realpath(resolvedCwd);
71
+ realRoots.set(resolvedCwd, realRoot);
72
+ }
73
+ let probe = realNearest.get(target);
74
+ if (!probe) {
75
+ probe = await realpathNearest(target);
76
+ if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
77
+ realNearest.set(target, probe);
78
+ }
48
79
  assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
49
80
  return target;
50
81
  }