gitnexus 1.6.2-rc.1 → 1.6.2-rc.11

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.
@@ -131,6 +131,11 @@ export const initEmbedder = async (onProgress, config = {}, forceDevice) => {
131
131
  try {
132
132
  // Configure transformers.js environment
133
133
  env.allowLocalModels = false;
134
+ // Default cache to user-writable location. transformers.js defaults to
135
+ // ./node_modules/.cache inside its own install dir, which is unwritable
136
+ // when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
137
+ // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
138
+ env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`;
134
139
  const isDev = process.env.NODE_ENV === 'development';
135
140
  if (isDev) {
136
141
  console.log(`🧠 Loading embedding model: ${finalConfig.modelId}`);
@@ -8,7 +8,14 @@
8
8
  * 4. Update LadybugDB with embeddings
9
9
  * 5. Create vector index for semantic search
10
10
  */
11
- import { type EmbeddingProgress, type EmbeddingConfig, type SemanticSearchResult } from './types.js';
11
+ import { type EmbeddingProgress, type EmbeddingConfig, type EmbeddableNode, type SemanticSearchResult } from './types.js';
12
+ /**
13
+ * Compute a stable content fingerprint for an embeddable node.
14
+ * Used to detect when the underlying text has changed so stale vectors
15
+ * can be replaced (DELETE-then-INSERT, the Kuzu-sanctioned pattern for
16
+ * vector-indexed rows).
17
+ */
18
+ export declare const contentHashForNode: (node: EmbeddableNode, config?: Partial<EmbeddingConfig>) => string;
12
19
  /**
13
20
  * Progress callback type
14
21
  */
@@ -20,9 +27,11 @@ export type EmbeddingProgressCallback = (progress: EmbeddingProgress) => void;
20
27
  * @param executeWithReusedStatement - Function to execute with reused prepared statement
21
28
  * @param onProgress - Callback for progress updates
22
29
  * @param config - Optional configuration override
23
- * @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode)
30
+ * @param existingEmbeddings - Optional map of nodeId contentHash for incremental mode.
31
+ * Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd
32
+ * and re-embedded; nodes not in the map are embedded fresh.
24
33
  */
25
- export declare const runEmbeddingPipeline: (executeQuery: (cypher: string) => Promise<any[]>, executeWithReusedStatement: (cypher: string, paramsList: Array<Record<string, any>>) => Promise<void>, onProgress: EmbeddingProgressCallback, config?: Partial<EmbeddingConfig>, skipNodeIds?: Set<string>) => Promise<void>;
34
+ export declare const runEmbeddingPipeline: (executeQuery: (cypher: string) => Promise<any[]>, executeWithReusedStatement: (cypher: string, paramsList: Array<Record<string, any>>) => Promise<void>, onProgress: EmbeddingProgressCallback, config?: Partial<EmbeddingConfig>, existingEmbeddings?: Map<string, string>) => Promise<void>;
26
35
  /**
27
36
  * Perform semantic search using the vector index
28
37
  *
@@ -8,10 +8,23 @@
8
8
  * 4. Update LadybugDB with embeddings
9
9
  * 5. Create vector index for semantic search
10
10
  */
11
+ import { createHash } from 'crypto';
11
12
  import { initEmbedder, embedBatch, embedText, embeddingToArray, isEmbedderReady, } from './embedder.js';
12
- import { generateBatchEmbeddingTexts } from './text-generator.js';
13
+ import { generateEmbeddingText, generateBatchEmbeddingTexts } from './text-generator.js';
13
14
  import { DEFAULT_EMBEDDING_CONFIG, EMBEDDABLE_LABELS, } from './types.js';
15
+ import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, CREATE_VECTOR_INDEX_QUERY, } from '../lbug/schema.js';
16
+ import { loadVectorExtension } from '../lbug/lbug-adapter.js';
14
17
  const isDev = process.env.NODE_ENV === 'development';
18
+ /**
19
+ * Compute a stable content fingerprint for an embeddable node.
20
+ * Used to detect when the underlying text has changed so stale vectors
21
+ * can be replaced (DELETE-then-INSERT, the Kuzu-sanctioned pattern for
22
+ * vector-indexed rows).
23
+ */
24
+ export const contentHashForNode = (node, config = {}) => {
25
+ const text = generateEmbeddingText(node, config);
26
+ return createHash('sha1').update(text).digest('hex');
27
+ };
15
28
  /**
16
29
  * Query all embeddable nodes from LadybugDB
17
30
  * Uses table-specific queries (File has different schema than code elements)
@@ -67,34 +80,26 @@ const queryEmbeddableNodes = async (executeQuery) => {
67
80
  * that occurs when UPDATEing nodes with large content fields
68
81
  */
69
82
  const batchInsertEmbeddings = async (executeWithReusedStatement, updates) => {
70
- // INSERT into separate embedding table - much more memory efficient!
71
- const cypher = `CREATE (e:CodeEmbedding {nodeId: $nodeId, embedding: $embedding})`;
72
- const paramsList = updates.map((u) => ({ nodeId: u.id, embedding: u.embedding }));
83
+ // MERGE instead of CREATE idempotent, handles concurrent analyzes and partial prior runs
84
+ const cypher = `MERGE (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) SET e.embedding = $embedding, e.contentHash = $contentHash`;
85
+ const paramsList = updates.map((u) => ({
86
+ nodeId: u.id,
87
+ embedding: u.embedding,
88
+ contentHash: u.contentHash,
89
+ }));
73
90
  await executeWithReusedStatement(cypher, paramsList);
74
91
  };
75
92
  /**
76
93
  * Create the vector index for semantic search
77
- * Now indexes the separate CodeEmbedding table
94
+ * Now indexes the separate CodeEmbedding table.
95
+ * Delegates extension loading to lbug-adapter's loadVectorExtension(),
96
+ * which owns the VECTOR extension lifecycle and state tracking.
78
97
  */
79
- let vectorExtensionLoaded = false;
80
98
  const createVectorIndex = async (executeQuery) => {
81
- // LadybugDB v0.15+ requires explicit VECTOR extension loading (once per session)
82
- if (!vectorExtensionLoaded) {
83
- try {
84
- await executeQuery('INSTALL VECTOR');
85
- await executeQuery('LOAD EXTENSION VECTOR');
86
- vectorExtensionLoaded = true;
87
- }
88
- catch {
89
- // Extension may already be loaded — CREATE_VECTOR_INDEX will fail clearly if not
90
- vectorExtensionLoaded = true;
91
- }
92
- }
93
- const cypher = `
94
- CALL CREATE_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', 'embedding', metric := 'cosine')
95
- `;
99
+ // Delegate to the adapter which tracks loaded state and handles DB reconnect resets
100
+ await loadVectorExtension();
96
101
  try {
97
- await executeQuery(cypher);
102
+ await executeQuery(CREATE_VECTOR_INDEX_QUERY);
98
103
  }
99
104
  catch (error) {
100
105
  // Index might already exist
@@ -110,9 +115,11 @@ const createVectorIndex = async (executeQuery) => {
110
115
  * @param executeWithReusedStatement - Function to execute with reused prepared statement
111
116
  * @param onProgress - Callback for progress updates
112
117
  * @param config - Optional configuration override
113
- * @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode)
118
+ * @param existingEmbeddings - Optional map of nodeId contentHash for incremental mode.
119
+ * Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd
120
+ * and re-embedded; nodes not in the map are embedded fresh.
114
121
  */
115
- export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatement, onProgress, config = {}, skipNodeIds) => {
122
+ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatement, onProgress, config = {}, existingEmbeddings) => {
116
123
  const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config };
117
124
  try {
118
125
  // Phase 1: Load embedding model
@@ -141,12 +148,50 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
141
148
  }
142
149
  // Phase 2: Query embeddable nodes
143
150
  let nodes = await queryEmbeddableNodes(executeQuery);
144
- // Incremental mode: filter out nodes that already have embeddings
145
- if (skipNodeIds && skipNodeIds.size > 0) {
151
+ // Incremental mode: compare content hashes, delete stale rows, skip fresh ones.
152
+ // Computed hashes for stale nodes are cached so batchInsertEmbeddings can reuse them
153
+ // (avoids double computation).
154
+ const computedStaleHashes = new Map();
155
+ if (existingEmbeddings && existingEmbeddings.size > 0) {
146
156
  const beforeCount = nodes.length;
147
- nodes = nodes.filter((n) => !skipNodeIds.has(n.id));
157
+ const staleNodeIds = [];
158
+ nodes = nodes.filter((n) => {
159
+ const existingHash = existingEmbeddings.get(n.id);
160
+ if (existingHash === undefined) {
161
+ // New node — needs embedding
162
+ return true;
163
+ }
164
+ const currentHash = contentHashForNode(n, finalConfig);
165
+ if (currentHash !== existingHash) {
166
+ // Content changed — cache hash for reuse during insert, mark for DELETE + re-embed
167
+ computedStaleHashes.set(n.id, currentHash);
168
+ staleNodeIds.push(n.id);
169
+ return true;
170
+ }
171
+ // Hash matches — skip (fresh); no need to cache hash for skipped nodes
172
+ return false;
173
+ });
174
+ // DELETE stale embedding rows so they can be re-inserted
175
+ // (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
176
+ if (staleNodeIds.length > 0) {
177
+ if (isDev) {
178
+ console.log(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
179
+ }
180
+ try {
181
+ await executeWithReusedStatement(`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`, staleNodeIds.map((nodeId) => ({ nodeId })));
182
+ }
183
+ catch (err) {
184
+ // "does not exist" = rows already gone — safe to proceed.
185
+ // All other errors risk vector-index corruption (Kuzu requires DELETE-before-INSERT
186
+ // for vector-indexed properties) — propagate so the pipeline aborts cleanly.
187
+ const msg = err instanceof Error ? err.message : String(err);
188
+ if (!msg.includes('does not exist')) {
189
+ throw new Error(`[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`);
190
+ }
191
+ }
192
+ }
148
193
  if (isDev) {
149
- console.log(`📦 Incremental embeddings: ${beforeCount} total, ${skipNodeIds.size} cached, ${nodes.length} to embed`);
194
+ console.log(`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`);
150
195
  }
151
196
  }
152
197
  const totalNodes = nodes.length;
@@ -154,6 +199,10 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
154
199
  console.log(`📊 Found ${totalNodes} embeddable nodes`);
155
200
  }
156
201
  if (totalNodes === 0) {
202
+ // Ensure the vector index exists even when no new nodes need embedding.
203
+ // A prior crash or first-time incremental run may have left CodeEmbedding
204
+ // rows without ever reaching index creation.
205
+ await createVectorIndex(executeQuery);
157
206
  onProgress({
158
207
  phase: 'ready',
159
208
  percent: 100,
@@ -186,6 +235,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
186
235
  const updates = batch.map((node, i) => ({
187
236
  id: node.id,
188
237
  embedding: embeddingToArray(embeddings[i]),
238
+ contentHash: computedStaleHashes.get(node.id) ?? contentHashForNode(node, finalConfig),
189
239
  }));
190
240
  await batchInsertEmbeddings(executeWithReusedStatement, updates);
191
241
  processedNodes += batch.length;
@@ -256,7 +306,7 @@ export const semanticSearch = async (executeQuery, query, k = 10, maxDistance =
256
306
  const queryVecStr = `[${queryVec.join(',')}]`;
257
307
  // Query the vector index on CodeEmbedding to get nodeIds and distances
258
308
  const vectorQuery = `
259
- CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
309
+ CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
260
310
  CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${k})
261
311
  YIELD node AS emb, distance
262
312
  WITH emb, distance
@@ -68,14 +68,29 @@ export function manifestSymbolUid(repo, contractId) {
68
68
  }
69
69
  export class ManifestExtractor {
70
70
  async extractFromManifest(links, dbExecutors) {
71
- const contracts = [];
72
- const crossLinks = [];
73
- for (const link of links) {
71
+ const resolveCache = new Map();
72
+ const resolveOnce = (repo, link) => {
73
+ const key = `${repo}\u0000${link.type}\u0000${link.contract}`;
74
+ let pending = resolveCache.get(key);
75
+ if (!pending) {
76
+ pending = this.resolveSymbol(repo, link, dbExecutors);
77
+ resolveCache.set(key, pending);
78
+ }
79
+ return pending;
80
+ };
81
+ const perLink = await Promise.all(links.map(async (link) => {
74
82
  const contractId = this.buildContractId(link.type, link.contract);
75
83
  const providerRepo = link.role === 'provider' ? link.from : link.to;
76
84
  const consumerRepo = link.role === 'provider' ? link.to : link.from;
77
- const providerSymbol = await this.resolveSymbol(providerRepo, link, dbExecutors);
78
- const consumerSymbol = await this.resolveSymbol(consumerRepo, link, dbExecutors);
85
+ const [providerSymbol, consumerSymbol] = await Promise.all([
86
+ resolveOnce(providerRepo, link),
87
+ resolveOnce(consumerRepo, link),
88
+ ]);
89
+ return { link, contractId, providerRepo, consumerRepo, providerSymbol, consumerSymbol };
90
+ }));
91
+ const contracts = [];
92
+ const crossLinks = [];
93
+ for (const { link, contractId, providerRepo, consumerRepo, providerSymbol, consumerSymbol, } of perLink) {
79
94
  const providerRef = providerSymbol || { filePath: '', name: link.contract };
80
95
  const consumerRef = consumerSymbol || { filePath: '', name: link.contract };
81
96
  // When the resolver finds a real graph symbol we keep its uid, otherwise
@@ -6,6 +6,7 @@ import { readRegistry } from '../../storage/repo-manager.js';
6
6
  import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
7
7
  import { GrpcExtractor } from './extractors/grpc-extractor.js';
8
8
  import { TopicExtractor } from './extractors/topic-extractor.js';
9
+ import { ManifestExtractor } from './extractors/manifest-extractor.js';
9
10
  import { runExactMatch } from './matching.js';
10
11
  import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
11
12
  import { writeContractRegistry } from './storage.js';
@@ -34,10 +35,28 @@ function defaultResolveHandle(allEntries) {
34
35
  };
35
36
  };
36
37
  }
38
+ /**
39
+ * Dedupe cross-links that point from the same consumer endpoint to the same
40
+ * provider endpoint for the same contract. Preserves first-seen order so the
41
+ * caller controls precedence (e.g., pass manifest links first).
42
+ */
43
+ function dedupeCrossLinks(links) {
44
+ const seen = new Set();
45
+ const out = [];
46
+ for (const link of links) {
47
+ const key = `${link.from.repo}::${link.from.symbolUid}|${link.to.repo}::${link.to.symbolUid}|${link.type}|${link.contractId}`;
48
+ if (seen.has(key))
49
+ continue;
50
+ seen.add(key);
51
+ out.push(link);
52
+ }
53
+ return out;
54
+ }
37
55
  export async function syncGroup(config, opts) {
38
56
  const missingRepos = [];
39
57
  const repoSnapshots = {};
40
58
  let autoContracts = [];
59
+ let manifestCrossLinks = [];
41
60
  let dbExecutors;
42
61
  const eo = opts?.extractorOverride;
43
62
  if (eo && eo.length === 0) {
@@ -124,8 +143,37 @@ export async function syncGroup(config, opts) {
124
143
  }
125
144
  }
126
145
  }
146
+ // Process manifest links declared in group.yaml.
147
+ // ManifestExtractor is fully implemented but was never wired into this
148
+ // pipeline — config.links were parsed and validated but silently dropped.
149
+ // Placed after the DB try/finally: resolveSymbol falls back to synthetic
150
+ // UIDs when dbExecutors is undefined or a pool is closed, so cross-links
151
+ // are always generated regardless of whether real DB executors are available.
152
+ if (config.links.length > 0) {
153
+ // Warn about dangling links that reference repos not declared in config.repos.
154
+ // They still generate cross-links via synthetic UIDs (determinism is preserved),
155
+ // but the operator probably meant something that now silently does nothing useful.
156
+ const knownRepos = new Set(Object.keys(config.repos));
157
+ for (const link of config.links) {
158
+ const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
159
+ if (dangling.length > 0) {
160
+ console.warn(`[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`);
161
+ }
162
+ }
163
+ const manifestEx = new ManifestExtractor();
164
+ const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
165
+ autoContracts.push(...manifestResult.contracts);
166
+ manifestCrossLinks = manifestResult.crossLinks;
167
+ if (opts?.verbose) {
168
+ console.log(` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`);
169
+ }
170
+ }
127
171
  const { matched, unmatched } = runExactMatch(autoContracts);
128
- const crossLinks = matched;
172
+ // Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
173
+ // manifest-declared link can also emit a matchType:'exact' CrossLink with the
174
+ // same endpoints. Prefer the manifest version — it reflects operator intent
175
+ // and carries matchType:'manifest' which downstream consumers may rely on.
176
+ const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]);
129
177
  const allContracts = autoContracts;
130
178
  const registry = {
131
179
  version: 1,
@@ -246,14 +246,17 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir) => {
246
246
  Interface: interfaceWriter,
247
247
  CodeElement: codeElemWriter,
248
248
  };
249
- const seenFileIds = new Set();
249
+ // Deduplicate all node types — the pipeline can produce duplicate IDs across
250
+ // all symbol types (Class, Method, Function, etc.), not just File nodes.
251
+ // A single Set covering every label prevents PK violations on COPY.
252
+ const seenNodeIds = new Set();
250
253
  // --- SINGLE PASS over all nodes ---
251
254
  for (const node of graph.iterNodes()) {
255
+ if (seenNodeIds.has(node.id))
256
+ continue;
257
+ seenNodeIds.add(node.id);
252
258
  switch (node.label) {
253
259
  case 'File': {
254
- if (seenFileIds.has(node.id))
255
- break;
256
- seenFileIds.add(node.id);
257
260
  const content = await extractContent(node, contentCache);
258
261
  await fileWriter.addRow([
259
262
  escapeCSVField(node.id),
@@ -98,8 +98,18 @@ export declare const loadCachedEmbeddings: () => Promise<{
98
98
  embeddings: Array<{
99
99
  nodeId: string;
100
100
  embedding: number[];
101
+ contentHash?: string;
101
102
  }>;
102
103
  }>;
104
+ /**
105
+ * Fetch existing embedding hashes from CodeEmbedding table for incremental embedding.
106
+ * Returns a Map<nodeId, contentHash> suitable for passing to `runEmbeddingPipeline`.
107
+ * Handles legacy DBs without the `contentHash` column (all rows treated as stale with empty hash).
108
+ * Returns undefined if the CodeEmbedding table does not exist.
109
+ *
110
+ * @param execQuery - Cypher query executor (typically pool-adapter's `executeQuery`)
111
+ */
112
+ export declare const fetchExistingEmbeddingHashes: (execQuery: (cypher: string) => Promise<any[]>) => Promise<Map<string, string> | undefined>;
103
113
  export declare const closeLbug: () => Promise<void>;
104
114
  export declare const isLbugReady: () => boolean;
105
115
  /**