pi-context-engine 0.1.4 → 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.
@@ -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"));
@@ -56,6 +58,38 @@ export class LocalBackend {
56
58
  return this.vectorStore.getById(chunkId);
57
59
  }
58
60
 
61
+ /** R7: recompute file PageRank after indexing; retrieval reads it as a prior. */
62
+ recomputePriors(): void {
63
+ this.graphStore.computePageRank();
64
+ this.pageRankCache = null;
65
+ }
66
+
67
+ /** R7: cached file→[0,1] PageRank (empty until first recomputePriors). */
68
+ pageRanks(): Map<string, number> {
69
+ if (!this.pageRankCache) this.pageRankCache = this.graphStore.pageRanks();
70
+ return this.pageRankCache;
71
+ }
72
+
73
+ /** R5: symbol lookup over graph node names (exact / `.suffix`). */
74
+ findSymbols(names: string[], limit = 20): GraphNode[] {
75
+ return this.graphStore.findSymbols(names, limit);
76
+ }
77
+
78
+ /** R9: graph nodes of one file (sibling-symbol hints). */
79
+ getNodesByFile(filePath: string): GraphNode[] {
80
+ return this.graphStore.getNodesByFile(filePath);
81
+ }
82
+
83
+ /** PR #7 review: all function/class nodes, for incremental call-graph derivation. */
84
+ listSymbolNodes(): GraphNode[] {
85
+ return this.graphStore.listSymbolNodes();
86
+ }
87
+
88
+ /** R5: chunks of one file in source order. */
89
+ chunksByFile(filePath: string, limit = 10): Chunk[] {
90
+ return this.vectorStore.byFile(filePath, limit);
91
+ }
92
+
59
93
  getChunksByIds(chunkIds: string[]): Chunk[] {
60
94
  return this.vectorStore.getChunksByIds(chunkIds);
61
95
  }
@@ -69,6 +103,7 @@ export class LocalBackend {
69
103
  this.vectorStore.deleteByFiles(filePaths);
70
104
  this.ftsStore.deleteByFiles(filePaths);
71
105
  this.graphStore.deleteByFiles(filePaths);
106
+ this.pageRankCache = null;
72
107
  }
73
108
 
74
109
  countChunks(): number {
@@ -91,6 +126,7 @@ export class LocalBackend {
91
126
  this.vectorStore.clear();
92
127
  this.ftsStore.clear();
93
128
  this.graphStore.clear();
129
+ this.pageRankCache = null;
94
130
  }
95
131
 
96
132
  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;
@@ -144,6 +145,12 @@ export class VectorStore {
144
145
  };
145
146
  if (row.modified_ts != null) chunk.metadata["modified_ts"] = row.modified_ts;
146
147
  if (distance !== undefined) chunk.metadata["_distance"] = distance;
148
+ // Restore `unit` from the R1 context header line so served chunks carry
149
+ // their symbol (refine hints filter siblings by it; the header is the
150
+ // single source of truth for indexed content). No header → no unit,
151
+ // same as before.
152
+ const unit = parseContextHeaderUnit(chunk.content);
153
+ if (unit) chunk.metadata["unit"] = unit;
147
154
  return chunk;
148
155
  }
149
156
 
@@ -327,6 +334,21 @@ export class VectorStore {
327
334
  }
328
335
  }
329
336
 
337
+ /** R5: chunks of one file in source order (file-hint retrieval path). */
338
+ byFile(filePath: string, limit = 10): Chunk[] {
339
+ try {
340
+ const rows = this.db
341
+ .prepare(
342
+ `SELECT id, content, chunk_type, file_path, start_line, end_line, language, modified_ts
343
+ FROM chunks WHERE file_path = ? ORDER BY start_line ASC LIMIT ?`,
344
+ )
345
+ .all(filePath, limit) as Record<string, unknown>[];
346
+ return rows.map((r) => this.rowToChunk(r));
347
+ } catch {
348
+ return [];
349
+ }
350
+ }
351
+
330
352
  close(): void {
331
353
  this.db.close();
332
354
  }