pi-supernova 0.0.11 → 0.1.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/CHANGELOG.md +74 -3
- package/README.md +72 -21
- package/bottleneck.js +98 -97
- package/catalog.js +12 -36
- package/check.js +166 -0
- package/config.default.json +1 -0
- package/config.js +1 -2
- package/decode.js +57 -6
- package/evidence.js +27 -35
- package/format.js +16 -17
- package/fuzzy.js +182 -0
- package/guest-worker.js +38 -157
- package/host-bridge.js +246 -124
- package/index.js +95 -123
- package/ledger.js +150 -0
- package/omp-frame.js +7 -23
- package/outline.js +80 -0
- package/package.json +11 -2
- package/parallel.js +42 -34
- package/patch.js +62 -72
- package/render-measure.js +46 -144
- package/render.js +43 -158
- package/repo-index.js +106 -11
- package/runtime.js +204 -227
- package/search.js +141 -0
- package/snap.js +1 -6
- package/surface.js +22 -13
- package/vfs.js +114 -75
- package/workspace.js +51 -33
package/check.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Quick structural check after an edit, not a parser. Catches the edit failures models make
|
|
2
|
+
// most: an unbalanced brace/bracket/paren or an unterminated string, with the line it happened
|
|
3
|
+
// on, so a broken edit is known now instead of after a test run. JSON is checked exactly.
|
|
4
|
+
|
|
5
|
+
const OPEN = { "{": "}", "[": "]", "(": ")" };
|
|
6
|
+
const CLOSE = new Set(["}", "]", ")"]);
|
|
7
|
+
const REGEX_PRECEDERS = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+", "-", "*", "%", "<", ">", "~", "^", "return", "typeof", "case", "do", "else", "in", "of"]);
|
|
8
|
+
|
|
9
|
+
function skipString(text, i, quote) {
|
|
10
|
+
for (let j = i + 1; j < text.length; j++) {
|
|
11
|
+
if (text[j] === "\\") {
|
|
12
|
+
j++;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (text[j] === quote) return j + 1;
|
|
16
|
+
if (quote !== "`" && text[j] === "\n") return -1;
|
|
17
|
+
}
|
|
18
|
+
return -1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function skipTemplate(text, i, stack) {
|
|
22
|
+
for (let j = i + 1; j < text.length; j++) {
|
|
23
|
+
if (text[j] === "\\") {
|
|
24
|
+
j++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (text[j] === "`") return j + 1;
|
|
28
|
+
if (text[j] === "$" && text[j + 1] === "{") {
|
|
29
|
+
const end = balancedEnd(text, j + 1, stack);
|
|
30
|
+
if (end < 0) return -1;
|
|
31
|
+
j = end - 1; // loop increment lands on the char after "}"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Index just past the "}" matching the "{" at i, scanning nested code; −1 when unbalanced. */
|
|
38
|
+
function balancedEnd(text, i, stack) {
|
|
39
|
+
const depth = stack.length;
|
|
40
|
+
const r = scan(text, i, stack, depth);
|
|
41
|
+
return r.error ? -1 : r.end;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function skipComment(text, i) {
|
|
45
|
+
if (text[i + 1] === "/") {
|
|
46
|
+
const nl = text.indexOf("\n", i);
|
|
47
|
+
return nl < 0 ? text.length : nl;
|
|
48
|
+
}
|
|
49
|
+
const end = text.indexOf("*/", i + 2);
|
|
50
|
+
return end < 0 ? text.length : end + 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function skipRegex(text, i) {
|
|
54
|
+
let inClass = false;
|
|
55
|
+
for (let j = i + 1; j < text.length; j++) {
|
|
56
|
+
const c = text[j];
|
|
57
|
+
if (c === "\\") {
|
|
58
|
+
j++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (c === "\n") return -1;
|
|
62
|
+
if (c === "[") inClass = true;
|
|
63
|
+
else if (c === "]") inClass = false;
|
|
64
|
+
else if (c === "/" && !inClass) return j + 1;
|
|
65
|
+
}
|
|
66
|
+
return -1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function lineOf(text, i) {
|
|
70
|
+
let n = 1;
|
|
71
|
+
for (let j = 0; j < i && j < text.length; j++) if (text[j] === "\n") n++;
|
|
72
|
+
return n;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const IDENT_START = /[A-Za-z_$]/;
|
|
76
|
+
const IDENT_PART = /[\w$]/;
|
|
77
|
+
|
|
78
|
+
function readIdentifier(text, i) {
|
|
79
|
+
let j = i + 1;
|
|
80
|
+
while (j < text.length && IDENT_PART.test(text[j])) j++;
|
|
81
|
+
return j;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function consumeQuoted(text, i, stack) {
|
|
85
|
+
const c = text[i];
|
|
86
|
+
if (c === "`") {
|
|
87
|
+
const end = skipTemplate(text, i, stack);
|
|
88
|
+
return end < 0 ? { error: "unterminated template literal", at: i } : { end, prev: "value" };
|
|
89
|
+
}
|
|
90
|
+
const end = skipString(text, i, c);
|
|
91
|
+
return end < 0 ? { error: "unterminated string", at: i } : { end, prev: "value" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Try to consume a comment, string, template, or regex at i. Returns { end, prev } | { error, at } | null. */
|
|
95
|
+
function consumeLiteral(text, i, stack, prev) {
|
|
96
|
+
const c = text[i];
|
|
97
|
+
if (c === '"' || c === "'" || c === "`") return consumeQuoted(text, i, stack);
|
|
98
|
+
if (c !== "/") return null;
|
|
99
|
+
if (text[i + 1] === "/" || text[i + 1] === "*") return { end: skipComment(text, i), prev };
|
|
100
|
+
if (prev !== "" && !REGEX_PRECEDERS.has(prev)) return null;
|
|
101
|
+
const end = skipRegex(text, i);
|
|
102
|
+
return end > 0 ? { end, prev: "value" } : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Push/pop a bracket; returns an error, a stop, or null to continue. */
|
|
106
|
+
function bracket(c, i, stack, stopDepth) {
|
|
107
|
+
if (OPEN[c]) {
|
|
108
|
+
stack.push({ c, at: i });
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!CLOSE.has(c)) return null;
|
|
112
|
+
const top = stack.pop();
|
|
113
|
+
if (!top || OPEN[top.c] !== c) return { error: "unexpected '" + c + "'", at: i };
|
|
114
|
+
if (stopDepth !== undefined && stack.length <= stopDepth) return { end: i + 1 };
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Skips comments, strings, templates and regex literals; `prev` is the last code token, which decides regex-vs-division. */
|
|
119
|
+
function scan(text, start, stack, stopDepth) {
|
|
120
|
+
let i = start;
|
|
121
|
+
let prev = "";
|
|
122
|
+
while (i < text.length) {
|
|
123
|
+
const c = text[i];
|
|
124
|
+
const literal = consumeLiteral(text, i, stack, prev);
|
|
125
|
+
if (literal) {
|
|
126
|
+
if (literal.error) return literal;
|
|
127
|
+
i = literal.end;
|
|
128
|
+
prev = literal.prev;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (IDENT_START.test(c)) {
|
|
132
|
+
const j = readIdentifier(text, i);
|
|
133
|
+
prev = text.slice(i, j);
|
|
134
|
+
i = j;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const outcome = bracket(c, i, stack, stopDepth);
|
|
138
|
+
if (outcome) return outcome;
|
|
139
|
+
if (!/\s/.test(c)) prev = c;
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
return { end: i };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const CODE_EXT = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts", ".java", ".kt", ".c", ".cc", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".swift", ".css", ".scss"]);
|
|
146
|
+
|
|
147
|
+
/** { ok: true } | { ok: false, message }; message names the problem and line. */
|
|
148
|
+
export function quickCheck(text, ext) {
|
|
149
|
+
if (ext === ".json") {
|
|
150
|
+
try {
|
|
151
|
+
JSON.parse(text);
|
|
152
|
+
return { ok: true, kind: "json" };
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return { ok: false, kind: "json", message: String(err.message).replace(/^JSON\.parse: /, "") };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!CODE_EXT.has(ext)) return null;
|
|
158
|
+
const stack = [];
|
|
159
|
+
const r = scan(text, 0, stack);
|
|
160
|
+
if (r.error) return { ok: false, kind: "balance", message: r.error + " at line " + lineOf(text, r.at) };
|
|
161
|
+
if (stack.length) {
|
|
162
|
+
const top = stack[stack.length - 1];
|
|
163
|
+
return { ok: false, kind: "balance", message: "unclosed '" + top.c + "' opened at line " + lineOf(text, top.at) };
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, kind: "balance" };
|
|
166
|
+
}
|
package/config.default.json
CHANGED
package/config.js
CHANGED
|
@@ -8,7 +8,7 @@ const require = createRequire(import.meta.url);
|
|
|
8
8
|
const DEFAULTS = require("./config.default.json");
|
|
9
9
|
|
|
10
10
|
const KNOWN_KEYS = new Set(Object.keys(DEFAULTS));
|
|
11
|
-
const NONNEGATIVE_INTEGER_KEYS = new Set(["maxLogLines"]);
|
|
11
|
+
const NONNEGATIVE_INTEGER_KEYS = new Set(["maxLogLines", "seenWindow"]);
|
|
12
12
|
const POSITIVE_INTEGER_KEYS = new Set([
|
|
13
13
|
"timeoutMs",
|
|
14
14
|
"maxCodeChars",
|
|
@@ -81,4 +81,3 @@ export function loadConfig() {
|
|
|
81
81
|
return mergeConfig(packageDefaults(), user ?? null);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
export { userConfigPath, KNOWN_KEYS };
|
package/decode.js
CHANGED
|
@@ -1,16 +1,67 @@
|
|
|
1
|
-
|
|
2
1
|
const toStr = Object.prototype.toString;
|
|
3
2
|
|
|
4
3
|
export const isString = (v) => toStr.call(v) === "[object String]";
|
|
5
4
|
export const isObject = (v) => toStr.call(v) === "[object Object]";
|
|
6
5
|
export const isFunction = (v) => toStr.call(v) === "[object Function]" || v instanceof Function;
|
|
7
6
|
export const isNumber = (v) => toStr.call(v) === "[object Number]" && Number.isFinite(v);
|
|
8
|
-
export const isRecord = (v) => v !== null && isObject(v);
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const MAX_DEPTH = 64;
|
|
9
|
+
const MAX_TYPED_ARRAY = 4096;
|
|
10
|
+
|
|
11
|
+
function plainFromBinary(value) {
|
|
12
|
+
const bytes = value.byteLength;
|
|
13
|
+
if (value instanceof ArrayBuffer) value = new Uint8Array(value);
|
|
14
|
+
else if (value instanceof DataView) value = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
15
|
+
if (value.length > MAX_TYPED_ARRAY) return "[" + value.constructor.name + " " + bytes + " bytes]";
|
|
16
|
+
return value instanceof BigInt64Array || value instanceof BigUint64Array
|
|
17
|
+
? Array.from(value, x => x.toString() + "n")
|
|
18
|
+
: Array.from(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function plainFromMap(value, seen, depth) {
|
|
22
|
+
const allStringKeys = [...value.keys()].every(isString);
|
|
23
|
+
if (!allStringKeys) return [...value].map(([k, v]) => [toPlain(k, seen, depth + 1), toPlain(v, seen, depth + 1)]);
|
|
24
|
+
const out = Object.create(null);
|
|
25
|
+
for (const [k, v] of value) out[k] = toPlain(v, seen, depth + 1);
|
|
26
|
+
return out;
|
|
12
27
|
}
|
|
13
28
|
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
function plainFromCollection(value, seen, depth) {
|
|
30
|
+
if (Array.isArray(value)) return value.map((x) => toPlain(x, seen, depth + 1));
|
|
31
|
+
if (value instanceof Set) return [...value].map((x) => toPlain(x, seen, depth + 1));
|
|
32
|
+
if (value instanceof Map) return plainFromMap(value, seen, depth);
|
|
33
|
+
const out = Object.create(null);
|
|
34
|
+
for (const k of Object.keys(value)) out[k] = toPlain(value[k], seen, depth + 1);
|
|
35
|
+
return out;
|
|
16
36
|
}
|
|
37
|
+
|
|
38
|
+
/** Convert any guest value to structured-clone-safe, JSON-shaped data. */
|
|
39
|
+
export function toPlain(value, seen = new Set(), depth = 0) {
|
|
40
|
+
if (value === null || value === undefined) return value;
|
|
41
|
+
const tag = toStr.call(value);
|
|
42
|
+
if (tag === "[object String]" || tag === "[object Number]" || tag === "[object Boolean]") return value.valueOf();
|
|
43
|
+
if (tag === "[object BigInt]") return value.toString() + "n";
|
|
44
|
+
if (isFunction(value)) return "[Function" + (value.name ? " " + value.name : "") + "]";
|
|
45
|
+
if (tag === "[object Symbol]") return value.toString();
|
|
46
|
+
if (depth > MAX_DEPTH) return "[Depth]";
|
|
47
|
+
if (seen.has(value)) return "[Circular]";
|
|
48
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
|
|
49
|
+
if (value instanceof RegExp) return value.toString();
|
|
50
|
+
if (value instanceof Error) {
|
|
51
|
+
const out = { name: value.name, message: value.message };
|
|
52
|
+
if (value.cause !== undefined) out.cause = toPlain(value.cause, seen, depth + 1);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
if (value instanceof Promise) return "[Promise]";
|
|
56
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return plainFromBinary(value);
|
|
57
|
+
if (isFunction(value.toJSON)) return toPlain(value.toJSON(), seen, depth + 1);
|
|
58
|
+
seen.add(value);
|
|
59
|
+
try {
|
|
60
|
+
return plainFromCollection(value, seen, depth);
|
|
61
|
+
} finally {
|
|
62
|
+
seen.delete(value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- RPC to the host thread ----
|
|
67
|
+
|
package/evidence.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { WorkspaceIndex } from "./repo-index.js";
|
|
3
3
|
import { tokenizeQuery, scorePathTopology } from "./snap.js";
|
|
4
|
+
import { isTestPath } from "./workspace.js";
|
|
4
5
|
|
|
5
6
|
// Zero-token evidence selection over source code, after Zero-Mem (arXiv:2607.29377).
|
|
6
7
|
// The codebase is the interaction history H; declared spans are the context units;
|
|
7
|
-
// identifiers are the entities. Every step below is deterministic
|
|
8
|
+
// identifiers are the entities. Every step below is deterministic (no model call)
|
|
8
9
|
// and every returned unit carries provenance (path, lines) back to the raw source.
|
|
9
10
|
//
|
|
10
11
|
// eq.3 G = (Vd ∪ Ve, Ede ∪ Edd) span/identifier nodes, co-occurrence + adjacency edges
|
|
@@ -21,7 +22,7 @@ import { tokenizeQuery, scorePathTopology } from "./snap.js";
|
|
|
21
22
|
// eq.14 C(q) = Dedup(M ∪ Ng(M) ∪ Nh(M)) closure: bridges + neighbours
|
|
22
23
|
// eq.15 R(q) = Rank_ϕ(Filter(C, ϕ)) deterministic calibration
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
const EVIDENCE_DEFAULTS = {
|
|
25
26
|
k: 5, // paper: Top-5 within 0.65 F1 of Top-10 at half the candidates
|
|
26
27
|
rho: 0.7, // primary-view weight
|
|
27
28
|
gamma: 0.85, // PPR damping
|
|
@@ -36,12 +37,6 @@ const IDENT = /[A-Za-z_$][\w$]*/g;
|
|
|
36
37
|
const RELATION_WORDS = new Set(["calls", "caller", "callers", "uses", "usages", "used", "using", "imports", "imported", "depends", "references", "referenced", "invokes", "invoked"]);
|
|
37
38
|
const HUB_FRACTION = 0.25;
|
|
38
39
|
const HUB_MIN = 8;
|
|
39
|
-
const TYPE_CUES = [
|
|
40
|
-
["test", /\b(test|tests|spec)\b/],
|
|
41
|
-
["doc", /\b(doc|docs|readme|documentation)\b/],
|
|
42
|
-
["config", /\b(config|configuration|settings|option|options|default|defaults)\b/],
|
|
43
|
-
["type", /\b(type|types|interface|schema|struct)\b/],
|
|
44
|
-
];
|
|
45
40
|
|
|
46
41
|
/** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
|
|
47
42
|
export function stem(token) {
|
|
@@ -53,23 +48,20 @@ function splitIdentifier(name) {
|
|
|
53
48
|
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
54
49
|
}
|
|
55
50
|
|
|
56
|
-
/** eq.6
|
|
57
|
-
export function profileQuery(query
|
|
51
|
+
/** eq.6: query profile with subjects, keywords/stems, answer type, test/doc flags, route. */
|
|
52
|
+
export function profileQuery(query) {
|
|
58
53
|
const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
|
|
59
54
|
const words = query.match(IDENT) || [];
|
|
60
55
|
// Subjects are identifier-shaped words (camelCase / snake_case): they anchor the graph view.
|
|
61
56
|
const subjects = words.filter((w) => /[a-z][A-Z]|_/.test(w));
|
|
62
57
|
const usage = words.some((w) => RELATION_WORDS.has(w.toLowerCase()));
|
|
63
58
|
const relational = usage || subjects.length > 0;
|
|
64
|
-
let answerType = usage ? "usage" : "definition";
|
|
65
|
-
for (const [type, re] of TYPE_CUES) if (re.test(query.toLowerCase())) answerType = type;
|
|
66
59
|
return {
|
|
67
60
|
subjects: [...new Set(subjects)],
|
|
68
61
|
keywords: tokens,
|
|
69
62
|
stems: [...new Set(tokens.map(stem))],
|
|
70
|
-
answerType,
|
|
63
|
+
answerType: usage ? "usage" : "definition",
|
|
71
64
|
flags: { wantsTest, wantsType, wantsDoc },
|
|
72
|
-
boundary: root,
|
|
73
65
|
route: relational ? "relational" : "local", // eq.7
|
|
74
66
|
};
|
|
75
67
|
}
|
|
@@ -77,21 +69,13 @@ export function profileQuery(query, root) {
|
|
|
77
69
|
// ---- substrate: spans (context units) from the structural surface ----
|
|
78
70
|
|
|
79
71
|
function spansOf(entry, filePath, maxSpanLines) {
|
|
80
|
-
const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
|
|
81
72
|
const lines = WorkspaceIndex.linesOf(entry);
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
73
|
+
const base = { path: filePath, entry, lower: lines.lower, lines };
|
|
74
|
+
const declared = WorkspaceIndex.spansOf(entry);
|
|
75
|
+
if (declared.length === 0) {
|
|
76
|
+
return [{ ...base, id: filePath + ":1", start: 1, end: Math.min(lines.raw.length, maxSpanLines), name: path.basename(filePath), kind: "file" }];
|
|
85
77
|
}
|
|
86
|
-
|
|
87
|
-
for (let i = 0; i < items.length; i++) {
|
|
88
|
-
const start = items[i].line;
|
|
89
|
-
const nextStart = i + 1 < items.length ? items[i + 1].line : lineCount + 1;
|
|
90
|
-
let end = Math.min(nextStart - 1, start + maxSpanLines - 1, lineCount);
|
|
91
|
-
while (end > start && lower[end - 1] === "") end--;
|
|
92
|
-
spans.push({ id: filePath + ":" + start, path: filePath, start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true, entry, lower, lines, index: i });
|
|
93
|
-
}
|
|
94
|
-
return spans;
|
|
78
|
+
return declared.map((s, i) => ({ ...base, ...s, id: filePath + ":" + s.start, end: Math.min(s.end, s.start + maxSpanLines - 1), index: i }));
|
|
95
79
|
}
|
|
96
80
|
|
|
97
81
|
function spanLines(span) {
|
|
@@ -325,7 +309,7 @@ function normalize(scores) {
|
|
|
325
309
|
|
|
326
310
|
// ---- candidate files (boundary + topology + entity hits) ----
|
|
327
311
|
|
|
328
|
-
function candidateFiles(files, profile, index, limit) {
|
|
312
|
+
function candidateFiles(files, profile, index, limit, overlayText) {
|
|
329
313
|
const scored = [];
|
|
330
314
|
for (const f of files) {
|
|
331
315
|
const s = scorePathTopology(f, profile.keywords, profile.flags);
|
|
@@ -334,7 +318,11 @@ function candidateFiles(files, profile, index, limit) {
|
|
|
334
318
|
scored.sort((a, b) => b.s - a.s);
|
|
335
319
|
const chosen = new Set(scored.slice(0, limit).map(({ f }) => f));
|
|
336
320
|
const anchors = (profile.subjects.length ? profile.subjects : profile.keywords).map((a) => a.toLowerCase()).filter((a) => a.length > 2);
|
|
337
|
-
const
|
|
321
|
+
const pendingHits = files.filter(file => {
|
|
322
|
+
const pending = overlayText(file);
|
|
323
|
+
return pending !== undefined && anchors.some(anchor => pending.toLowerCase().includes(anchor));
|
|
324
|
+
});
|
|
325
|
+
const hits = anchors.length ? [...new Set([...pendingHits, ...index.filesContaining(files, anchors, true)])] : [];
|
|
338
326
|
for (const f of hits) {
|
|
339
327
|
if (chosen.size >= limit) break;
|
|
340
328
|
if (profile.flags.wantsTest || scorePathTopology(f, profile.keywords, profile.flags) > -50) chosen.add(f);
|
|
@@ -401,14 +389,19 @@ function render(spans, picks, fused, opts, root) {
|
|
|
401
389
|
* R(q): top-K provenance-bearing source spans for a concept query, selected without any model call.
|
|
402
390
|
* @returns {{ route: string, spans: Array<{path, lines, name, kind, why, text}> }}
|
|
403
391
|
*/
|
|
404
|
-
export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, options = {} }) {
|
|
392
|
+
export async function selectEvidence({ query, root, searchDir, index, overlayText = () => undefined, pendingPaths = [], options = {} }) {
|
|
405
393
|
const opts = { ...EVIDENCE_DEFAULTS, ...options };
|
|
406
|
-
const profile = profileQuery(query
|
|
394
|
+
const profile = profileQuery(query);
|
|
407
395
|
if (profile.keywords.length === 0) throw new Error("evidence requires at least one searchable concept keyword");
|
|
408
|
-
const
|
|
396
|
+
const searchRoot = path.resolve(searchDir || root);
|
|
397
|
+
const staged = pendingPaths.filter(file => {
|
|
398
|
+
const relative = path.relative(searchRoot, file);
|
|
399
|
+
return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
|
|
400
|
+
});
|
|
401
|
+
const files = [...new Set([...await index.files(searchRoot), ...staged])];
|
|
409
402
|
if (files.length === 0) throw new Error(`no files found to search in ${searchDir || root}`);
|
|
410
403
|
|
|
411
|
-
const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles);
|
|
404
|
+
const { files: chosenFiles, fileScores } = candidateFiles(files, profile, index, opts.maxCandidateFiles, overlayText);
|
|
412
405
|
const spans = [];
|
|
413
406
|
for (const f of chosenFiles) {
|
|
414
407
|
const pending = overlayText(f);
|
|
@@ -432,9 +425,8 @@ export async function selectEvidence({ query, root, searchDir, index, overlayTex
|
|
|
432
425
|
const usage = profile.answerType === "usage";
|
|
433
426
|
const admissible = spans.map((s, i) => i).filter((i) => {
|
|
434
427
|
const p = spans[i].path;
|
|
435
|
-
const isTestSpan = /(^|[\\/])(test|tests)[\\/]|\.(test|spec)\./.test(p);
|
|
436
428
|
const isDoc = /\.(md|mdx|rst|txt)$/i.test(p);
|
|
437
|
-
return spans[i].support > 0 && (profile.flags.wantsTest || !
|
|
429
|
+
return spans[i].support > 0 && (profile.flags.wantsTest || !isTestPath(p)) && (profile.flags.wantsDoc || !isDoc);
|
|
438
430
|
});
|
|
439
431
|
for (const i of admissible) {
|
|
440
432
|
if (usage && profile.subjects.includes(spans[i].name)) fused[i] *= 0.5; // a usage question is answered by callers
|
package/format.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isString } from "./decode.js";
|
|
1
|
+
import { isString, isObject } from "./decode.js";
|
|
2
2
|
|
|
3
3
|
export function truncateChars(text, maxChars, label = "value") {
|
|
4
4
|
const normalized = isString(text) ? text : String(text ?? "");
|
|
@@ -12,22 +12,21 @@ export function truncateChars(text, maxChars, label = "value") {
|
|
|
12
12
|
originalChars: normalized.length,
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
let tail =
|
|
15
|
+
let head = headEnd(normalized, Math.floor(limit * 0.7));
|
|
16
|
+
let tail = 0;
|
|
17
17
|
let marker = "";
|
|
18
|
-
|
|
19
|
-
while (tail !== previousTail) {
|
|
20
|
-
previousTail = tail;
|
|
18
|
+
for (;;) {
|
|
21
19
|
const omitted = normalized.length - head - tail;
|
|
22
|
-
marker =
|
|
23
|
-
|
|
20
|
+
marker = "\n…[" + label + " truncated " + omitted + " chars]…\n";
|
|
21
|
+
if (marker.length > limit) return { text: normalized.slice(0, headEnd(normalized, limit)), truncated: true, originalChars: normalized.length };
|
|
22
|
+
const budget = limit - marker.length;
|
|
23
|
+
const nextHead = headEnd(normalized, Math.min(head, budget));
|
|
24
|
+
const nextTail = normalized.length - tailStartIndex(normalized, Math.max(0, budget - nextHead));
|
|
25
|
+
if (nextHead === head && nextTail === tail) break;
|
|
26
|
+
head = nextHead;
|
|
27
|
+
tail = nextTail;
|
|
24
28
|
}
|
|
25
|
-
|
|
26
|
-
return {
|
|
27
|
-
text: normalized.slice(0, head) + marker + normalized.slice(tailStart),
|
|
28
|
-
truncated: true,
|
|
29
|
-
originalChars: normalized.length,
|
|
30
|
-
};
|
|
29
|
+
return { text: normalized.slice(0, head) + marker + normalized.slice(normalized.length - tail), truncated: true, originalChars: normalized.length };
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
// Lone surrogates in a tool result make the message invalid UTF-8 at the API
|
|
@@ -53,7 +52,7 @@ function formatKey(key) {
|
|
|
53
52
|
|
|
54
53
|
function formatPrimitive(value) {
|
|
55
54
|
if (value === undefined) return "undefined";
|
|
56
|
-
if (
|
|
55
|
+
if (Number.isNaN(value) || value === Infinity || value === -Infinity) return String(value);
|
|
57
56
|
return JSON.stringify(value) ?? String(value);
|
|
58
57
|
}
|
|
59
58
|
|
|
@@ -65,7 +64,7 @@ function formatFlatList(value) {
|
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
function formatFlat(value) {
|
|
68
|
-
if (value
|
|
67
|
+
if ((!isObject(value) && !Array.isArray(value))) return formatPrimitive(value);
|
|
69
68
|
if (Array.isArray(value)) return formatFlatList(value);
|
|
70
69
|
let out = "";
|
|
71
70
|
for (const key of Object.keys(value)) {
|
|
@@ -83,7 +82,7 @@ function formatFlat(value) {
|
|
|
83
82
|
*/
|
|
84
83
|
export function formatValue(value, indent = "", width = FORMAT_WIDTH) {
|
|
85
84
|
const flat = formatFlat(value);
|
|
86
|
-
if (value
|
|
85
|
+
if ((!isObject(value) && !Array.isArray(value)) || flat.length + indent.length <= width) return flat;
|
|
87
86
|
const pad = indent + " ";
|
|
88
87
|
if (Array.isArray(value)) {
|
|
89
88
|
if (value.length === 0) return "[]";
|
package/fuzzy.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Typo-resistant fuzzy path matching and frecency, ported from fff (dmtrKovalenko/fff)
|
|
2
|
+
// to plain JS so path search stays in-process: no binary, no spawn.
|
|
3
|
+
//
|
|
4
|
+
// fff pieces reproduced here:
|
|
5
|
+
// - frizbee-style fuzzy match with max_typos (skipped needle chars), boundary / consecutive /
|
|
6
|
+
// capitalization bonuses, smart-case (uppercase in query ⇒ case-sensitive)
|
|
7
|
+
// - filename bonus: exact filename +40% of base, filename match +20%
|
|
8
|
+
// - frecency boost: base × frecency / 100, AI-mode decay (3-day half-life, 7-day window)
|
|
9
|
+
// plus modification-recency boosts (30s/5m/15m/1h/4h thresholds)
|
|
10
|
+
// - git-modified boost: +15% of base
|
|
11
|
+
// - distance penalty from the current (last touched) file: −1 per directory hop, floor −20
|
|
12
|
+
|
|
13
|
+
const AI_DECAY = Math.LN2 / 3; // per day
|
|
14
|
+
const AI_MAX_HISTORY_DAYS = 7;
|
|
15
|
+
const MAX_TIMESTAMPS_PER_FILE = 128;
|
|
16
|
+
const AI_MODIFICATION_THRESHOLDS = [[16, 30], [8, 300], [4, 900], [2, 3600], [1, 14400]]; // [boost, seconds]
|
|
17
|
+
|
|
18
|
+
export class Frecency {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.access = new Map(); // path → number[] (epoch seconds, newest last)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
record(filePath, at = Date.now() / 1000) {
|
|
24
|
+
let list = this.access.get(filePath);
|
|
25
|
+
if (!list) this.access.set(filePath, (list = []));
|
|
26
|
+
list.push(at);
|
|
27
|
+
if (list.length > MAX_TIMESTAMPS_PER_FILE) list.splice(0, list.length - MAX_TIMESTAMPS_PER_FILE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Σ exp(−λ·age) over accesses in the window, plus a step boost for a recently modified file. */
|
|
31
|
+
score(filePath, mtimeSec, now = Date.now() / 1000) {
|
|
32
|
+
let total = 0;
|
|
33
|
+
const cutoff = now - AI_MAX_HISTORY_DAYS * 86400;
|
|
34
|
+
for (const t of this.access.get(filePath) || []) {
|
|
35
|
+
if (t < cutoff) continue;
|
|
36
|
+
total += Math.exp(-AI_DECAY * ((now - t) / 86400));
|
|
37
|
+
}
|
|
38
|
+
if (mtimeSec) {
|
|
39
|
+
const age = now - mtimeSec;
|
|
40
|
+
for (const [boost, seconds] of AI_MODIFICATION_THRESHOLDS) {
|
|
41
|
+
if (age <= seconds) {
|
|
42
|
+
total += boost;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return total;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SEPARATORS = new Set(["/", "\\", "_", "-", ".", " "]);
|
|
52
|
+
|
|
53
|
+
function isBoundary(hay, i) {
|
|
54
|
+
if (i === 0) return true;
|
|
55
|
+
const prev = hay[i - 1];
|
|
56
|
+
if (SEPARATORS.has(prev)) return true;
|
|
57
|
+
const c = hay[i];
|
|
58
|
+
return c >= "A" && c <= "Z" && !(prev >= "A" && prev <= "Z");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Greedy forward match with backward tightening (fzf v1). Returns null or
|
|
63
|
+
* { score, start, end }. Score: +16 boundary, +8 consecutive, +4 case match, −1 per gap char.
|
|
64
|
+
*/
|
|
65
|
+
function matchOnce(needle, hay, caseSensitive) {
|
|
66
|
+
const hayCmp = caseSensitive ? hay : hay.toLowerCase();
|
|
67
|
+
const nCmp = caseSensitive ? needle : needle.toLowerCase();
|
|
68
|
+
let hi = 0;
|
|
69
|
+
let firstAt = -1;
|
|
70
|
+
for (let ni = 0; ni < nCmp.length; ni++) {
|
|
71
|
+
hi = hayCmp.indexOf(nCmp[ni], hi);
|
|
72
|
+
if (hi < 0) return null;
|
|
73
|
+
if (firstAt < 0) firstAt = hi;
|
|
74
|
+
hi++;
|
|
75
|
+
}
|
|
76
|
+
const end = hi;
|
|
77
|
+
// Tighten: walk backwards from end to find the latest possible start.
|
|
78
|
+
let start = end;
|
|
79
|
+
for (let ni = nCmp.length - 1; ni >= 0; ni--) {
|
|
80
|
+
start = hayCmp.lastIndexOf(nCmp[ni], start - 1);
|
|
81
|
+
}
|
|
82
|
+
return { score: scoreAlignment(needle, nCmp, hay, hayCmp, start), start, end };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** +16 boundary, +8 consecutive, +4 exact-case, −1 per skipped haystack char. */
|
|
86
|
+
function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
|
|
87
|
+
let score = 0;
|
|
88
|
+
let prev = -2;
|
|
89
|
+
let cursor = start;
|
|
90
|
+
for (let ni = 0; ni < nCmp.length; ni++) {
|
|
91
|
+
const at = hayCmp.indexOf(nCmp[ni], cursor);
|
|
92
|
+
score += isBoundary(hay, at) ? 16 : 0;
|
|
93
|
+
score += at === prev + 1 ? 8 : 0;
|
|
94
|
+
score += hay[at] === needle[ni] ? 4 : 0;
|
|
95
|
+
score -= prev >= 0 ? at - prev - 1 : 0;
|
|
96
|
+
prev = at;
|
|
97
|
+
cursor = at + 1;
|
|
98
|
+
}
|
|
99
|
+
return score;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Best match allowing up to maxTypos skipped needle characters. */
|
|
103
|
+
export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
|
|
104
|
+
const direct = matchOnce(needle, hay, caseSensitive);
|
|
105
|
+
if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
|
|
106
|
+
if (maxTypos <= 0 || needle.length < 3) return null;
|
|
107
|
+
let best = null;
|
|
108
|
+
for (let i = 0; i < needle.length; i++) {
|
|
109
|
+
const shorter = needle.slice(0, i) + needle.slice(i + 1);
|
|
110
|
+
const m = fuzzyMatch(shorter, hay, { maxTypos: maxTypos - 1, caseSensitive });
|
|
111
|
+
if (!m) continue;
|
|
112
|
+
const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
|
|
113
|
+
if (!best || scored.score > best.score) best = scored;
|
|
114
|
+
}
|
|
115
|
+
return best;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function smartCase(query) {
|
|
119
|
+
return /[A-Z]/.test(query);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** fff distance penalty: directory hops from the current file's directory, floor −20. */
|
|
123
|
+
function distancePenalty(currentDir, candidateDir) {
|
|
124
|
+
if (!currentDir) return 0;
|
|
125
|
+
const a = currentDir.split("/").filter(Boolean);
|
|
126
|
+
const b = candidateDir.split("/").filter(Boolean);
|
|
127
|
+
let common = 0;
|
|
128
|
+
while (common < a.length && common < b.length && a[common] === b[common]) common++;
|
|
129
|
+
const depth = a.length - common;
|
|
130
|
+
return Math.max(-20, -depth);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Rank file paths for a query the fff way. paths are workspace-relative "/"-joined.
|
|
135
|
+
* ctx: { frecency: Frecency, mtimeOf: (path) => sec, modified: Set(path), currentFile?: string, maxTypos }
|
|
136
|
+
*/
|
|
137
|
+
export function rankPaths(query, paths, ctx = {}) {
|
|
138
|
+
const parts = query.trim().split(/\s+/).filter((p) => p.length >= 2);
|
|
139
|
+
if (parts.length === 0) return [];
|
|
140
|
+
const caseSensitive = smartCase(query);
|
|
141
|
+
const maxTypos = ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
|
|
142
|
+
const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const rel of paths) {
|
|
145
|
+
const matched = matchParts(parts, rel, maxTypos, caseSensitive);
|
|
146
|
+
if (!matched) continue;
|
|
147
|
+
const { base, first, exact } = matched;
|
|
148
|
+
const filenameStart = rel.lastIndexOf("/") + 1;
|
|
149
|
+
const boosts = filenameBonus(base, rel, filenameStart, first, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
|
|
150
|
+
out.push({ path: rel, score: base + boosts, exact, typos: first.typos });
|
|
151
|
+
}
|
|
152
|
+
out.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path));
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Every query part must match; later parts get at most one typo (fff narrows per part). Score is the average. */
|
|
157
|
+
function matchParts(parts, rel, maxTypos, caseSensitive) {
|
|
158
|
+
let sum = 0;
|
|
159
|
+
let first = null;
|
|
160
|
+
let exact = true;
|
|
161
|
+
for (let pi = 0; pi < parts.length; pi++) {
|
|
162
|
+
const m = fuzzyMatch(parts[pi], rel, { maxTypos: pi === 0 ? maxTypos : Math.min(maxTypos, 1), caseSensitive });
|
|
163
|
+
if (!m) return null;
|
|
164
|
+
first ??= m;
|
|
165
|
+
sum += m.score;
|
|
166
|
+
exact = exact && m.exact;
|
|
167
|
+
}
|
|
168
|
+
return { base: Math.max(1, Math.round(sum / parts.length)), first, exact };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** fff: exact filename +40% of base, any filename match +20%. */
|
|
172
|
+
function filenameBonus(base, rel, filenameStart, first, needle) {
|
|
173
|
+
if (first.start < filenameStart) return 0;
|
|
174
|
+
return rel.slice(filenameStart).toLowerCase() === needle.toLowerCase() ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** fff: frecency boost base·f/100 and +15% for git-modified files. */
|
|
178
|
+
function contextBoost(base, rel, ctx) {
|
|
179
|
+
const frecency = ctx.frecency ? ctx.frecency.score(rel, ctx.mtimeOf?.(rel)) : 0;
|
|
180
|
+
const gitBoost = ctx.modified?.has(rel) ? Math.floor((base * 15) / 100) : 0;
|
|
181
|
+
return Math.floor((base * frecency) / 100) + gitBoost;
|
|
182
|
+
}
|