pi-context-engine 0.1.4 → 0.3.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.
@@ -24,6 +24,10 @@ CREATE TABLE IF NOT EXISTS edges (
24
24
  CREATE INDEX IF NOT EXISTS idx_edges_source ON edges (source_id);
25
25
  CREATE INDEX IF NOT EXISTS idx_edges_target ON edges (target_id);
26
26
  CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes (file_path);
27
+ CREATE TABLE IF NOT EXISTS pagerank (
28
+ file_path TEXT PRIMARY KEY,
29
+ score REAL NOT NULL
30
+ );
27
31
  `;
28
32
 
29
33
  type Sqlite = SqliteHandle;
@@ -85,6 +89,64 @@ export class GraphStore {
85
89
  return rows.map(rowToNode);
86
90
  }
87
91
 
92
+ /**
93
+ * PR #7 review fix (incremental call graph): every function/class node
94
+ * across ALL indexed files, so calls-edge derivation on an incremental
95
+ * run still sees symbols owned by files that did not change this run.
96
+ */
97
+ listSymbolNodes(): GraphNode[] {
98
+ const rows = this.db
99
+ .prepare(
100
+ "SELECT id, node_type, name, file_path, properties FROM nodes WHERE node_type IN ('function','class')",
101
+ )
102
+ .all() as Record<string, unknown>[];
103
+ return rows.map(rowToNode);
104
+ }
105
+
106
+ /**
107
+ * R5: structured symbol lookup for the multi-path retriever. Matches a
108
+ * chunk-level node whose name EQUALS a queried identifier, ENDS with
109
+ * `.identifier` (so `charge` finds `PaymentGateway.charge`), or STARTS with
110
+ * `identifier.` (so `PaymentGateway` surfaces its methods). Exact matches
111
+ * rank first; results are deterministic (name length as the tiebreak) so
112
+ * RRF fusion is reproducible.
113
+ */
114
+ findSymbols(names: string[], limit = 20): GraphNode[] {
115
+ if (names.length === 0) return [];
116
+ const conds: string[] = [];
117
+ const params: unknown[] = [];
118
+ for (const raw of names) {
119
+ const name = raw.trim().toLowerCase();
120
+ if (name.length < 3) continue;
121
+ conds.push("(lower(name) = ? OR lower(name) LIKE ? OR lower(name) LIKE ?)");
122
+ params.push(name, `%.${name}`, `${name}.%`);
123
+ }
124
+ if (conds.length === 0) return [];
125
+ params.push(Math.max(limit * 3, 30)); // overfetch: JS ranks exact-first, then cuts
126
+ let rows: Record<string, unknown>[];
127
+ try {
128
+ rows = this.db
129
+ .prepare(
130
+ `SELECT id, node_type, name, file_path, properties FROM nodes
131
+ WHERE node_type IN ('function', 'class', 'module', 'doc')
132
+ AND (${conds.join(" OR ")})
133
+ LIMIT ?`,
134
+ )
135
+ .all(...params) as Record<string, unknown>[];
136
+ } catch {
137
+ return [];
138
+ }
139
+ const exact = new Set(names.map((n) => n.trim().toLowerCase()));
140
+ const nodes = rows.map(rowToNode);
141
+ nodes.sort((a, b) => {
142
+ const ax = exact.has(a.name.toLowerCase()) ? 0 : 1;
143
+ const bx = exact.has(b.name.toLowerCase()) ? 0 : 1;
144
+ if (ax !== bx) return ax - bx;
145
+ return a.name.length - b.name.length;
146
+ });
147
+ return nodes.slice(0, limit);
148
+ }
149
+
88
150
  neighborsForFiles(filePaths: string[], edgeTypes: EdgeType[], nodeTypes?: NodeType[]): GraphNode[] {
89
151
  if (filePaths.length === 0 || edgeTypes.length === 0) return [];
90
152
  const fpPh = filePaths.map(() => "?").join(",");
@@ -107,6 +169,86 @@ export class GraphStore {
107
169
  return rows.map(rowToNode);
108
170
  }
109
171
 
172
+ /**
173
+ * R7: PageRank over the file-level projection of the graph (imports +
174
+ * calls edges collapsed to file→file). Scores are normalised so the
175
+ * best-connected file is 1.0; the retriever applies them as a bounded
176
+ * multiplicative prior. Hub files genuinely central to a codebase —
177
+ * types, shared utils, the engine — consistently rank above leaves, which
178
+ * is exactly the tie-break signal fusion needs when lexical and vector
179
+ * evidence disagree.
180
+ */
181
+ computePageRank(): void {
182
+ const nodeFile = new Map<string, string>();
183
+ for (const row of this.db.prepare("SELECT id, file_path FROM nodes").all() as {
184
+ id: string;
185
+ file_path: string;
186
+ }[]) {
187
+ nodeFile.set(row.id, row.file_path);
188
+ }
189
+ const edgeRows = this.db
190
+ .prepare("SELECT source_id, target_id FROM edges WHERE edge_type IN ('imports', 'calls')")
191
+ .all() as { source_id: string; target_id: string }[];
192
+
193
+ const nodes = new Set<string>();
194
+ for (const fp of nodeFile.values()) if (fp) nodes.add(fp);
195
+ const out = new Map<string, Set<string>>();
196
+ for (const e of edgeRows) {
197
+ const s = nodeFile.get(e.source_id);
198
+ const tgt = nodeFile.get(e.target_id);
199
+ if (!s || !tgt || s === tgt) continue;
200
+ let set = out.get(s);
201
+ if (!set) out.set(s, (set = new Set()));
202
+ set.add(tgt);
203
+ }
204
+
205
+ const run = this.db.transaction(() => {
206
+ this.db.exec("DELETE FROM pagerank");
207
+ if (nodes.size === 0) return;
208
+ const d = 0.85;
209
+ const n = nodes.size;
210
+ const incoming = new Map<string, string[]>();
211
+ for (const [s, targets] of out) {
212
+ for (const tgt of targets) {
213
+ let arr = incoming.get(tgt);
214
+ if (!arr) incoming.set(tgt, (arr = []));
215
+ arr.push(s);
216
+ }
217
+ }
218
+ let rank = new Map<string, number>();
219
+ for (const node of nodes) rank.set(node, 1 / n);
220
+ for (let iter = 0; iter < 25; iter++) {
221
+ const next = new Map<string, number>();
222
+ let dangling = 0;
223
+ for (const node of nodes) {
224
+ const o = out.get(node);
225
+ if (!o || o.size === 0) dangling += rank.get(node) ?? 0;
226
+ }
227
+ for (const node of nodes) {
228
+ let s = (1 - d) / n + (d * dangling) / n;
229
+ for (const src of incoming.get(node) ?? []) {
230
+ s += (d * (rank.get(src) ?? 0)) / (out.get(src)?.size ?? 1);
231
+ }
232
+ next.set(node, s);
233
+ }
234
+ rank = next;
235
+ }
236
+ const max = Math.max(...rank.values(), 1e-12);
237
+ const ins = this.db.prepare("INSERT OR REPLACE INTO pagerank (file_path, score) VALUES (?, ?)");
238
+ for (const [fp, s] of rank) ins.run(fp, s / max);
239
+ });
240
+ run();
241
+ }
242
+
243
+ /** Read of the PageRank table (file_path → score in [0,1]). */
244
+ pageRanks(): Map<string, number> {
245
+ const rows = this.db.prepare("SELECT file_path, score FROM pagerank").all() as {
246
+ file_path: string;
247
+ score: number;
248
+ }[];
249
+ return new Map(rows.map((r) => [r.file_path, r.score]));
250
+ }
251
+
110
252
  getNodesByType(nodeType: NodeType): GraphNode[] {
111
253
  const rows = this.db
112
254
  .prepare("SELECT id, node_type, name, file_path, properties FROM nodes WHERE node_type = ?")
@@ -126,15 +268,28 @@ export class GraphStore {
126
268
  const nodeIds = (
127
269
  this.db.prepare(`SELECT id FROM nodes WHERE file_path IN (${ph})`).all(...batch) as {
128
270
  id: string;
129
- }[]
271
+ }[]
130
272
  ).map((r) => r.id);
273
+ // R7: file_* node ids are STABLE across re-ingest of the same
274
+ // file, so inbound imports edges from other files stay valid.
275
+ // Only delete edges pointing at this file's chunk-level nodes
276
+ // (their ids change when content changes). Without this,
277
+ // re-indexing b.ts silently deleted every a.ts→b.ts imports
278
+ // edge unless a.ts changed too.
279
+ const chunkNodeIds = nodeIds.filter((id) => !id.startsWith("file_"));
131
280
  for (const idBatch of batchedParams(nodeIds)) {
132
281
  const idPh = idBatch.map(() => "?").join(",");
133
282
  this.db
134
- .prepare(`DELETE FROM edges WHERE source_id IN (${idPh}) OR target_id IN (${idPh})`)
135
- .run(...idBatch, ...idBatch);
136
- this.db.prepare(`DELETE FROM nodes WHERE id IN (${idPh})`).run(...idBatch);
283
+ .prepare(`DELETE FROM nodes WHERE id IN (${idPh})`).run(...idBatch);
284
+ }
285
+ const srcPh = nodeIds.map(() => "?").join(",");
286
+ this.db.prepare("DELETE FROM edges WHERE source_id IN (" + srcPh + ")").run(...nodeIds);
287
+ if (chunkNodeIds.length > 0) {
288
+ const tgtPh = chunkNodeIds.map(() => "?").join(",");
289
+ this.db.prepare("DELETE FROM edges WHERE target_id IN (" + tgtPh + ")").run(...chunkNodeIds);
137
290
  }
291
+ const filePh = batch.map(() => "?").join(",");
292
+ this.db.prepare("DELETE FROM pagerank WHERE file_path IN (" + filePh + ")").run(...batch);
138
293
  }
139
294
  });
140
295
  run([...batchedParams(filePaths)]);
@@ -143,6 +298,7 @@ export class GraphStore {
143
298
  clear(): void {
144
299
  this.db.exec("DELETE FROM edges");
145
300
  this.db.exec("DELETE FROM nodes");
301
+ this.db.exec("DELETE FROM pagerank");
146
302
  }
147
303
 
148
304
  close(): void {
@@ -11,6 +11,8 @@ export class LocalBackend {
11
11
  private vectorStore: VectorStore;
12
12
  private ftsStore: FTSStore;
13
13
  private graphStore: GraphStore;
14
+ /** R7: cached PageRank table; refreshed by recomputePriors(). */
15
+ private pageRankCache: Map<string, number> | null = null;
14
16
 
15
17
  constructor(basePath: string) {
16
18
  this.vectorStore = new VectorStore(path.join(basePath, "vectors"));
@@ -18,10 +20,6 @@ export class LocalBackend {
18
20
  this.graphStore = new GraphStore(path.join(basePath, "graph"));
19
21
  }
20
22
 
21
- get metricRebuilt(): boolean {
22
- return this.vectorStore.metricRebuilt;
23
- }
24
-
25
23
  ingest(chunks: Chunk[], nodes: GraphNode[], edges: GraphEdge[]): void {
26
24
  this.vectorStore.ingest(chunks);
27
25
  this.ftsStore.ingest(chunks);
@@ -56,6 +54,38 @@ export class LocalBackend {
56
54
  return this.vectorStore.getById(chunkId);
57
55
  }
58
56
 
57
+ /** R7: recompute file PageRank after indexing; retrieval reads it as a prior. */
58
+ recomputePriors(): void {
59
+ this.graphStore.computePageRank();
60
+ this.pageRankCache = null;
61
+ }
62
+
63
+ /** R7: cached file→[0,1] PageRank (empty until first recomputePriors). */
64
+ pageRanks(): Map<string, number> {
65
+ if (!this.pageRankCache) this.pageRankCache = this.graphStore.pageRanks();
66
+ return this.pageRankCache;
67
+ }
68
+
69
+ /** R5: symbol lookup over graph node names (exact / `.suffix`). */
70
+ findSymbols(names: string[], limit = 20): GraphNode[] {
71
+ return this.graphStore.findSymbols(names, limit);
72
+ }
73
+
74
+ /** R9: graph nodes of one file (sibling-symbol hints). */
75
+ getNodesByFile(filePath: string): GraphNode[] {
76
+ return this.graphStore.getNodesByFile(filePath);
77
+ }
78
+
79
+ /** PR #7 review: all function/class nodes, for incremental call-graph derivation. */
80
+ listSymbolNodes(): GraphNode[] {
81
+ return this.graphStore.listSymbolNodes();
82
+ }
83
+
84
+ /** R5: chunks of one file in source order. */
85
+ chunksByFile(filePath: string, limit = 10): Chunk[] {
86
+ return this.vectorStore.byFile(filePath, limit);
87
+ }
88
+
59
89
  getChunksByIds(chunkIds: string[]): Chunk[] {
60
90
  return this.vectorStore.getChunksByIds(chunkIds);
61
91
  }
@@ -69,6 +99,7 @@ export class LocalBackend {
69
99
  this.vectorStore.deleteByFiles(filePaths);
70
100
  this.ftsStore.deleteByFiles(filePaths);
71
101
  this.graphStore.deleteByFiles(filePaths);
102
+ this.pageRankCache = null;
72
103
  }
73
104
 
74
105
  countChunks(): number {
@@ -91,6 +122,7 @@ export class LocalBackend {
91
122
  this.vectorStore.clear();
92
123
  this.ftsStore.clear();
93
124
  this.graphStore.clear();
125
+ this.pageRankCache = null;
94
126
  }
95
127
 
96
128
  close(): void {
@@ -13,6 +13,7 @@ import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { openDatabase, type SqliteHandle } from "../sqlite.js";
15
15
  import { batchedParams } from "../utils.js";
16
+ import { parseContextHeaderUnit } from "../indexer/context-header.js";
16
17
  import type { Chunk, ChunkType } from "../types.js";
17
18
 
18
19
  const MAX_CONTENT_CHARS = 5_000;
@@ -26,8 +27,6 @@ type Sqlite = SqliteHandle;
26
27
  export class VectorStore {
27
28
  private db: Sqlite;
28
29
  private dim: number | null = null;
29
- /** Set when a legacy L2 vec table was wiped — pipeline forces a full reindex. */
30
- metricRebuilt = false;
31
30
  constructor(dbPath: string) {
32
31
  fs.mkdirSync(dbPath, { recursive: true });
33
32
  // Without sqlite-vec the store still keeps chunk metadata; search()
@@ -36,10 +35,6 @@ export class VectorStore {
36
35
  this.ensureTables();
37
36
  }
38
37
 
39
- private get vecAvailable(): boolean {
40
- return this.dim !== null || this.vecTableExists();
41
- }
42
-
43
38
  private vecTableExists(): boolean {
44
39
  const row = this.db
45
40
  .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='chunks_vec'")
@@ -60,10 +55,6 @@ export class VectorStore {
60
55
  modified_ts REAL
61
56
  )
62
57
  `);
63
- const cols = (this.db.pragma("table_info(chunks)") as { name: string }[]).map((c) => c.name);
64
- if (!cols.includes("modified_ts")) {
65
- this.db.exec("ALTER TABLE chunks ADD COLUMN modified_ts REAL");
66
- }
67
58
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_file_path ON chunks(file_path)");
68
59
  this.db.exec(`
69
60
  CREATE TABLE IF NOT EXISTS chunk_compressions (
@@ -74,25 +65,19 @@ export class VectorStore {
74
65
  )
75
66
  `);
76
67
 
77
- const row = this.db
78
- .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='chunks_vec'")
79
- .get() as { sql: string | null } | undefined;
80
- if (row) {
81
- if (!(row.sql ?? "").includes("distance_metric=cosine")) {
82
- this.db.exec("DROP TABLE IF EXISTS chunks_vec");
83
- this.db.exec("DELETE FROM chunks");
84
- this.db.exec("DELETE FROM chunk_compressions");
85
- this.metricRebuilt = true;
86
- } else {
87
- const r = this.db.prepare("SELECT rowid FROM chunks_vec LIMIT 1").get() as
88
- | { rowid: number }
89
- | undefined;
90
- if (r) {
91
- const len = this.db
92
- .prepare("SELECT vec_length(embedding) AS len FROM chunks_vec LIMIT 1")
93
- .get() as { len: number };
94
- this.dim = len.len;
95
- }
68
+ if (this.vecTableExists()) {
69
+ // The table exists — read its dimension. There is no migration path
70
+ // for tables this package did not write (older metric/column shapes):
71
+ // an unrecognized shape cannot occur from our own DDL, and foreign
72
+ // stores are unsupported by design.
73
+ const r = this.db.prepare("SELECT rowid FROM chunks_vec LIMIT 1").get() as
74
+ | { rowid: number }
75
+ | undefined;
76
+ if (r) {
77
+ const len = this.db
78
+ .prepare("SELECT vec_length(embedding) AS len FROM chunks_vec LIMIT 1")
79
+ .get() as { len: number };
80
+ this.dim = len.len;
96
81
  }
97
82
  }
98
83
  }
@@ -144,6 +129,12 @@ export class VectorStore {
144
129
  };
145
130
  if (row.modified_ts != null) chunk.metadata["modified_ts"] = row.modified_ts;
146
131
  if (distance !== undefined) chunk.metadata["_distance"] = distance;
132
+ // Restore `unit` from the R1 context header line so served chunks carry
133
+ // their symbol (refine hints filter siblings by it; the header is the
134
+ // single source of truth for indexed content). No header → no unit,
135
+ // same as before.
136
+ const unit = parseContextHeaderUnit(chunk.content);
137
+ if (unit) chunk.metadata["unit"] = unit;
147
138
  return chunk;
148
139
  }
149
140
 
@@ -327,6 +318,21 @@ export class VectorStore {
327
318
  }
328
319
  }
329
320
 
321
+ /** R5: chunks of one file in source order (file-hint retrieval path). */
322
+ byFile(filePath: string, limit = 10): Chunk[] {
323
+ try {
324
+ const rows = this.db
325
+ .prepare(
326
+ `SELECT id, content, chunk_type, file_path, start_line, end_line, language, modified_ts
327
+ FROM chunks WHERE file_path = ? ORDER BY start_line ASC LIMIT ?`,
328
+ )
329
+ .all(filePath, limit) as Record<string, unknown>[];
330
+ return rows.map((r) => this.rowToChunk(r));
331
+ } catch {
332
+ return [];
333
+ }
334
+ }
335
+
330
336
  close(): void {
331
337
  this.db.close();
332
338
  }
package/src/utils.ts CHANGED
@@ -79,6 +79,19 @@ export function epochSeconds(): number {
79
79
  return Math.floor(Date.now() / 1000);
80
80
  }
81
81
 
82
+ /**
83
+ * Cooperative macrotask yield for long-running indexing work. ONNX
84
+ * inference, tokenization and tree-sitter parsing all run on the main
85
+ * thread, so a long stretch of synchronous work starves pi's UI loop
86
+ * (measured by scripts/bench-event-loop.mts: ~0.5-0.9 s stalls per 64-text
87
+ * embed batch). setImmediate keeps the yield cheap — same loop turn's check
88
+ * phase — while still letting pending timers and I/O (keystrokes, renders)
89
+ * run between units of work.
90
+ */
91
+ export function yieldToEventLoop(): Promise<void> {
92
+ return new Promise((resolve) => setImmediate(resolve));
93
+ }
94
+
82
95
  /**
83
96
  * Fractional seconds. Session-capture ordering needs sub-second resolution:
84
97
  * several decisions are commonly recorded within the same wall-clock second,