gitnexus 1.6.9-rc.33 → 1.6.9-rc.35

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.
@@ -8,7 +8,7 @@
8
8
  * 4. Update LadybugDB with chunk-aware embeddings
9
9
  * 5. Create vector index for semantic search
10
10
  */
11
- import { type EmbeddingProgress, type EmbeddingConfig, type EmbeddableNode, type SemanticSearchResult, type EmbeddingContext } from './types.js';
11
+ import { type EmbeddingProgress, type EmbeddingConfig, type EmbeddableNode, type SemanticSearchResult } from './types.js';
12
12
  import type { ExtensionInstallPolicy } from '../lbug/extension-loader.js';
13
13
  /**
14
14
  * Resolve the extension-install policy for the embedding WRITE path (analyze).
@@ -28,7 +28,7 @@ export declare const resolveEmbeddingInstallPolicy: () => ExtensionInstallPolicy
28
28
  * invalidate existing vectors, such as metadata/header shape changes,
29
29
  * structural container context changes, or preceding-context formatting rules.
30
30
  */
31
- export declare const EMBEDDING_TEXT_VERSION = "v2";
31
+ export declare const EMBEDDING_TEXT_VERSION = "v4";
32
32
  /**
33
33
  * Compute a stable content fingerprint for an embeddable node.
34
34
  * Used to detect when the underlying text has changed so stale vectors
@@ -65,13 +65,11 @@ export interface EmbeddingPipelineResult {
65
65
  * @param onProgress - Callback for progress updates
66
66
  * @param config - Optional configuration override
67
67
  * @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode)
68
- * @param context - Optional repo/server context for metadata enrichment
69
68
  * @param existingEmbeddings - Optional map of nodeId → contentHash for incremental mode.
70
69
  * Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd
71
70
  * and re-embedded; nodes not in the map are embedded fresh.
72
-
73
71
  */
74
- 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>, context?: EmbeddingContext, existingEmbeddings?: Map<string, string>) => Promise<EmbeddingPipelineResult>;
72
+ 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>, existingEmbeddings?: Map<string, string>) => Promise<EmbeddingPipelineResult>;
75
73
  /**
76
74
  * Perform semantic search using the vector index with chunk deduplication
77
75
  */
@@ -51,7 +51,7 @@ const ensureVectorExtensionAvailable = async () => {
51
51
  * invalidate existing vectors, such as metadata/header shape changes,
52
52
  * structural container context changes, or preceding-context formatting rules.
53
53
  */
54
- export const EMBEDDING_TEXT_VERSION = 'v2';
54
+ export const EMBEDDING_TEXT_VERSION = 'v4';
55
55
  /**
56
56
  * Compute a stable content fingerprint for an embeddable node.
57
57
  * Used to detect when the underlying text has changed so stale vectors
@@ -185,6 +185,32 @@ const buildVectorIndex = async () => {
185
185
  return false;
186
186
  }
187
187
  };
188
+ /**
189
+ * DELETE stale embedding rows for the given nodeIds so they can be re-inserted.
190
+ *
191
+ * Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the
192
+ * sanctioned pattern. A `"does not exist"` error means the rows are already gone
193
+ * (safe to proceed); any other error risks vector-index corruption, so it
194
+ * propagates and aborts the pipeline.
195
+ *
196
+ * Called per-batch (just before each batch's INSERT), not once up front — see
197
+ * the caller comment / KTD7: an up-front bulk delete of every stale row leaves
198
+ * the whole index deleted-not-reinserted if the re-embed is interrupted. Per-batch
199
+ * interleaving bounds that window to a single batch.
200
+ */
201
+ const deleteStaleEmbeddingRows = async (executeWithReusedStatement, nodeIds) => {
202
+ if (nodeIds.length === 0)
203
+ return;
204
+ try {
205
+ await executeWithReusedStatement(`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`, nodeIds.map((nodeId) => ({ nodeId })));
206
+ }
207
+ catch (err) {
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ if (!msg.includes('does not exist')) {
210
+ throw new Error(`[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`);
211
+ }
212
+ }
213
+ };
188
214
  /**
189
215
  * Run the embedding pipeline
190
216
  *
@@ -193,13 +219,11 @@ const buildVectorIndex = async () => {
193
219
  * @param onProgress - Callback for progress updates
194
220
  * @param config - Optional configuration override
195
221
  * @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode)
196
- * @param context - Optional repo/server context for metadata enrichment
197
222
  * @param existingEmbeddings - Optional map of nodeId → contentHash for incremental mode.
198
223
  * Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd
199
224
  * and re-embedded; nodes not in the map are embedded fresh.
200
-
201
225
  */
202
- export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatement, onProgress, config = {}, skipNodeIds, context, existingEmbeddings) => {
226
+ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatement, onProgress, config = {}, skipNodeIds, existingEmbeddings) => {
203
227
  const finalConfig = resolveEmbeddingConfig(config);
204
228
  let totalChunks = 0;
205
229
  try {
@@ -233,20 +257,16 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
233
257
  }
234
258
  // Phase 2: Query embeddable nodes
235
259
  let nodes = await queryEmbeddableNodes(executeQuery);
236
- // Apply context metadata
237
- if (context?.repoName) {
238
- for (const node of nodes) {
239
- node.repoName = context.repoName;
240
- node.serverName = context.serverName;
241
- }
242
- }
243
260
  // Incremental mode: compare content hashes, delete stale rows, skip fresh ones.
244
261
  // Computed hashes for stale nodes are cached so batchInsertEmbeddings can reuse them
245
262
  // (avoids double computation).
246
263
  const computedStaleHashes = new Map();
264
+ // Stale rows are DELETE'd per-batch (just before each batch's INSERT) rather
265
+ // than all up front — see U6 / KTD7. `staleNodeIds` is consulted inside the
266
+ // batch loop; it stays empty in full (non-incremental) mode so no deletes fire.
267
+ const staleNodeIds = new Set();
247
268
  if (existingEmbeddings && existingEmbeddings.size > 0) {
248
269
  const beforeCount = nodes.length;
249
- const staleNodeIds = [];
250
270
  nodes = nodes.filter((n) => {
251
271
  const existingHash = existingEmbeddings.get(n.id);
252
272
  if (existingHash === undefined) {
@@ -257,33 +277,14 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
257
277
  if (currentHash !== existingHash) {
258
278
  // Content changed — cache hash for reuse during insert, mark for DELETE + re-embed
259
279
  computedStaleHashes.set(n.id, currentHash);
260
- staleNodeIds.push(n.id);
280
+ staleNodeIds.add(n.id);
261
281
  return true;
262
282
  }
263
283
  // Hash matches — skip (fresh); no need to cache hash for skipped nodes
264
284
  return false;
265
285
  });
266
- // DELETE stale embedding rows so they can be re-inserted
267
- // (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
268
- if (staleNodeIds.length > 0) {
269
- if (isDev) {
270
- logger.info(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
271
- }
272
- try {
273
- await executeWithReusedStatement(`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`, staleNodeIds.map((nodeId) => ({ nodeId })));
274
- }
275
- catch (err) {
276
- // "does not exist" = rows already gone — safe to proceed.
277
- // All other errors risk vector-index corruption (Kuzu requires DELETE-before-INSERT
278
- // for vector-indexed properties) — propagate so the pipeline aborts cleanly.
279
- const msg = err instanceof Error ? err.message : String(err);
280
- if (!msg.includes('does not exist')) {
281
- throw new Error(`[embed] Failed to delete stale embedding rows — aborting to prevent vector-index corruption: ${msg}`);
282
- }
283
- }
284
- }
285
286
  if (isDev) {
286
- logger.info(`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`);
287
+ logger.info(`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.size} stale, ${nodes.length} to embed`);
287
288
  }
288
289
  }
289
290
  const totalNodes = nodes.length;
@@ -373,6 +374,11 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
373
374
  prevTail = overlap > 0 ? chunk.text.slice(-overlap) : '';
374
375
  }
375
376
  }
377
+ // U6 / KTD7: delete this batch's stale rows immediately before its inserts,
378
+ // so an interrupted re-embed loses at most one batch (not the whole index).
379
+ // Preserves Kuzu's required DELETE-before-INSERT for vector-indexed rows.
380
+ const batchStaleIds = batch.filter((n) => staleNodeIds.has(n.id)).map((n) => n.id);
381
+ await deleteStaleEmbeddingRows(executeWithReusedStatement, batchStaleIds);
376
382
  // Embed chunk texts in sub-batches to control memory
377
383
  const EMBED_SUB_BATCH = finalConfig.subBatchSize;
378
384
  for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Text Generator Module
3
3
  *
4
- * Generates enriched embedding text from code nodes with metadata.
4
+ * Generates compact, description-forward embedding text from code nodes.
5
5
  * Supports chunkable labels (Function/Method with AST chunking),
6
6
  * Class-specific structural text, and short-node direct embed.
7
7
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Text Generator Module
3
3
  *
4
- * Generates enriched embedding text from code nodes with metadata.
4
+ * Generates compact, description-forward embedding text from code nodes.
5
5
  * Supports chunkable labels (Function/Method with AST chunking),
6
6
  * Class-specific structural text, and short-node direct embed.
7
7
  *
@@ -41,27 +41,48 @@ const cleanContent = (content) => {
41
41
  .trim();
42
42
  };
43
43
  /**
44
- * Build metadata header for a node
44
+ * Compact location signal for the embedding header: the last 1-2 path segments
45
+ * (immediate parent dir + basename), never the full deep path.
46
+ *
47
+ * #2333 / PR #2334 tri-review: U1 dropped the location entirely, which regressed
48
+ * path/service-qualified semantic search (e.g. `billing/handler` vs
49
+ * `identity/handler` in a monorepo) — and FTS indexes only name/content/description,
50
+ * never `filePath`, so there is no keyword backfill. The bounded form restores the
51
+ * discriminating tokens (service dir + filename-concept) at a fraction of the
52
+ * dilution the full path caused.
45
53
  */
46
- const buildMetadataHeader = (node, config) => {
54
+ const boundedLocation = (filePath) => {
55
+ const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean);
56
+ return segments.slice(-2).join('/');
57
+ };
58
+ /**
59
+ * Build a compact, description-forward header for embedding text.
60
+ *
61
+ * Issue #2333 (sub-issue of #2326), Option A: lead the embedding text with the
62
+ * symbol name + doc-comment description and drop the low-signal metadata lines
63
+ * (`Repo`/`Server`/`Export` and the verbose full `Path`). For short doc comments
64
+ * those lines used to be ~25-30% of the embedding text, diluting the description's
65
+ * semantic weight in the vector and weakening description-shaped search — worst
66
+ * for CJK, where a complete concept is often 4-20 characters.
67
+ *
68
+ * A *bounded* location signal (last 1-2 path segments) is kept after the
69
+ * description — see `boundedLocation` for why the full path drop was reversed.
70
+ *
71
+ * Full metadata is unaffected: it lives on the graph node properties, which is
72
+ * what display/context tools read. Only the embedding text changes here.
73
+ *
74
+ * Option B (reorder only, keep metadata) was rejected — mean-pooled embeddings
75
+ * weight by token proportion, not position, so reordering alone barely moves the
76
+ * signal. Option C (a separate description-only embedding + hybrid merge) is
77
+ * deferred to follow-up; build it only if Option A proves insufficient against
78
+ * real measurement. Any change to this template MUST bump EMBEDDING_TEXT_VERSION.
79
+ */
80
+ const buildEmbeddingHeader = (node, config) => {
47
81
  const parts = [];
48
82
  // Label + name
49
83
  parts.push(`${node.label}: ${node.name}`);
50
- // Repo name
51
- if (node.repoName) {
52
- parts.push(`Repo: ${node.repoName}`);
53
- }
54
- // Server name (optional)
55
- if (node.serverName) {
56
- parts.push(`Server: ${node.serverName}`);
57
- }
58
- // Full file path
59
- parts.push(`Path: ${node.filePath}`);
60
- // Export status
61
- if (node.isExported !== undefined) {
62
- parts.push(`Export: ${node.isExported}`);
63
- }
64
- // Description (truncated)
84
+ // Description hoisted above everything else so its semantic signal dominates
85
+ // the embedding vector and is never the part lost to token-limit truncation.
65
86
  if (node.description) {
66
87
  const maxLen = config.maxDescriptionLength ?? DEFAULT_EMBEDDING_CONFIG.maxDescriptionLength;
67
88
  const truncated = truncateDescription(node.description, maxLen);
@@ -69,10 +90,19 @@ const buildMetadataHeader = (node, config) => {
69
90
  parts.push(truncated);
70
91
  }
71
92
  }
93
+ // Bounded location signal — placed after the description so the description
94
+ // still leads the vector. Restores path/service disambiguation lost when the
95
+ // full Path line was dropped (FTS does not index filePath to backfill it).
96
+ if (node.filePath) {
97
+ const loc = boundedLocation(node.filePath);
98
+ if (loc) {
99
+ parts.push(`Loc: ${loc}`);
100
+ }
101
+ }
72
102
  return parts.join('\n');
73
103
  };
74
104
  const generateCodeBodyText = (node, codeBody, config, prevTail) => {
75
- const header = buildMetadataHeader(node, config);
105
+ const header = buildEmbeddingHeader(node, config);
76
106
  const parts = [header];
77
107
  if (prevTail) {
78
108
  parts.push(`[preceding context]: ...${cleanContent(prevTail)}`);
@@ -87,7 +117,7 @@ const getCompactContainerContext = (cleanedContent, declarationOnly) => {
87
117
  return firstLine ? `Container: ${firstLine}` : undefined;
88
118
  };
89
119
  const generateStructuralTypeText = (node, codeBody, config, chunkIndex, prevTail) => {
90
- const header = buildMetadataHeader(node, config);
120
+ const header = buildEmbeddingHeader(node, config);
91
121
  const parts = [header];
92
122
  const isFirstChunk = chunkIndex === undefined || chunkIndex === 0;
93
123
  const cleanedContent = cleanContent(node.content);
@@ -192,7 +222,7 @@ export const extractDeclarationOnly = (content) => {
192
222
  */
193
223
  export const generateEmbeddingText = (node, codeBody, config = {}, chunkIndex, prevTail) => {
194
224
  if (isShortLabel(node.label)) {
195
- const header = buildMetadataHeader(node, config);
225
+ const header = buildEmbeddingHeader(node, config);
196
226
  const cleaned = cleanContent(node.content);
197
227
  return `${header}\n\n${cleaned}`;
198
228
  }
@@ -168,13 +168,6 @@ export interface CachedEmbedding {
168
168
  embedding: number[];
169
169
  contentHash?: string;
170
170
  }
171
- /**
172
- * Context info for embedding pipeline (repo/server metadata enrichment)
173
- */
174
- export interface EmbeddingContext {
175
- repoName?: string;
176
- serverName?: string;
177
- }
178
171
  /**
179
172
  * Model download progress from transformers.js
180
173
  */
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Streams CSV rows directly to disk files in a single pass over graph nodes.
5
5
  * File contents are lazy-read from disk per-node to avoid holding the entire
6
- * repo in RAM. Rows are buffered (FLUSH_EVERY) before writing to minimize
6
+ * repo in RAM. Rows are buffered (FLUSH_BYTES) before writing to minimize
7
7
  * per-row Promise overhead.
8
8
  *
9
9
  * RFC 4180 Compliant:
@@ -14,6 +14,32 @@
14
14
  import type { GraphNode, GraphRelationship } from '../../_shared/index.js';
15
15
  import { KnowledgeGraph } from '../graph/types.js';
16
16
  import { NodeTableName } from './schema.js';
17
+ /**
18
+ * Flush buffered rows to disk once the buffered chunk reaches this many bytes.
19
+ * Byte-bounded rather than row-count-bounded: row size ranges from a few dozen
20
+ * bytes (typical symbol/relationship rows) up to a full File's content
21
+ * (#2317/#2323), so a row-count-only cap lets a handful of huge rows build an
22
+ * unbounded `buffer.join('\n')` string before ever tripping it.
23
+ *
24
+ * Not an env knob — fixed by a safety margin, not a preference. The one worst
25
+ * case that matters: one more oversized row lands right after the buffer was
26
+ * just under this threshold, before the flush fires. That row is capped at
27
+ * TREE_SITTER_MAX_BUFFER (32MB, hard-clamped — GITNEXUS_MAX_FILE_SIZE cannot
28
+ * raise it), and escapeCSVField's quote-doubling can at most double it. So the
29
+ * peak joined-string size is bounded by
30
+ * FLUSH_BYTES + 2 * TREE_SITTER_MAX_BUFFER ≈ 8MB + 64MB = 72MB,
31
+ * versus Node's `buffer.constants.MAX_STRING_LENGTH` (~512MB) that throws
32
+ * `RangeError: Invalid string length` past it — a >7x margin (see the
33
+ * `shouldFlushCSVBuffer stays within the V8 string-length ceiling` test,
34
+ * which fails loudly if either constant ever moves this margin the wrong
35
+ * way). Raising FLUSH_BYTES trades fewer/larger flushes for less margin;
36
+ * lowering it trades the reverse for lower peak transient memory. Change the
37
+ * constant directly if a real workload needs a different point on that
38
+ * curve — a per-host env var would let the margin get silently reintroduced
39
+ * by an operator with no way to know why 512MB is dangerous.
40
+ */
41
+ export declare const FLUSH_BYTES: number;
42
+ export declare const shouldFlushCSVBuffer: (byteCount: number) => boolean;
17
43
  export declare const sanitizeUTF8: (str: string) => string;
18
44
  export declare const escapeCSVField: (value: string | number | undefined | null) => string;
19
45
  export declare const escapeCSVNumber: (value: number | undefined | null, defaultValue?: number) => string;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Streams CSV rows directly to disk files in a single pass over graph nodes.
5
5
  * File contents are lazy-read from disk per-node to avoid holding the entire
6
- * repo in RAM. Rows are buffered (FLUSH_EVERY) before writing to minimize
6
+ * repo in RAM. Rows are buffered (FLUSH_BYTES) before writing to minimize
7
7
  * per-row Promise overhead.
8
8
  *
9
9
  * RFC 4180 Compliant:
@@ -31,8 +31,32 @@ import { parseTruthyEnv } from '../ingestion/utils/env.js';
31
31
  const byGraphId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
32
32
  const orderedNodes = (graph, sorted) => sorted ? [...graph.iterNodes()].sort(byGraphId) : graph.iterNodes();
33
33
  const orderedRelationships = (graph, sorted) => sorted ? [...graph.iterRelationships()].sort(byGraphId) : graph.iterRelationships();
34
- /** Flush buffered rows to disk every N rows */
35
- const FLUSH_EVERY = 500;
34
+ /**
35
+ * Flush buffered rows to disk once the buffered chunk reaches this many bytes.
36
+ * Byte-bounded rather than row-count-bounded: row size ranges from a few dozen
37
+ * bytes (typical symbol/relationship rows) up to a full File's content
38
+ * (#2317/#2323), so a row-count-only cap lets a handful of huge rows build an
39
+ * unbounded `buffer.join('\n')` string before ever tripping it.
40
+ *
41
+ * Not an env knob — fixed by a safety margin, not a preference. The one worst
42
+ * case that matters: one more oversized row lands right after the buffer was
43
+ * just under this threshold, before the flush fires. That row is capped at
44
+ * TREE_SITTER_MAX_BUFFER (32MB, hard-clamped — GITNEXUS_MAX_FILE_SIZE cannot
45
+ * raise it), and escapeCSVField's quote-doubling can at most double it. So the
46
+ * peak joined-string size is bounded by
47
+ * FLUSH_BYTES + 2 * TREE_SITTER_MAX_BUFFER ≈ 8MB + 64MB = 72MB,
48
+ * versus Node's `buffer.constants.MAX_STRING_LENGTH` (~512MB) that throws
49
+ * `RangeError: Invalid string length` past it — a >7x margin (see the
50
+ * `shouldFlushCSVBuffer stays within the V8 string-length ceiling` test,
51
+ * which fails loudly if either constant ever moves this margin the wrong
52
+ * way). Raising FLUSH_BYTES trades fewer/larger flushes for less margin;
53
+ * lowering it trades the reverse for lower peak transient memory. Change the
54
+ * constant directly if a real workload needs a different point on that
55
+ * curve — a per-host env var would let the margin get silently reintroduced
56
+ * by an operator with no way to know why 512MB is dangerous.
57
+ */
58
+ export const FLUSH_BYTES = 8 * 1024 * 1024;
59
+ export const shouldFlushCSVBuffer = (byteCount) => byteCount >= FLUSH_BYTES;
36
60
  /**
37
61
  * Yield the event loop every N relationship rows during the emit pass (#2226 F4)
38
62
  * so a concurrent node COPY (the overlap in loadGraphToLbug) and write-stream
@@ -127,6 +151,23 @@ class FileContentCache {
127
151
  this.accessOrder.push(key);
128
152
  }
129
153
  }
154
+ /**
155
+ * Flatten newlines and tabs to single spaces for FTS-indexed text columns
156
+ * (`content`, `description`) — the real fix for #2317.
157
+ *
158
+ * Ladybug's full-text-search tokenizer splits ONLY on the space character —
159
+ * `\n`, `\r`, and `\t` are NOT token delimiters. So multiline text indexes as
160
+ * a handful of giant tokens (each whole line, joined across lines), and a
161
+ * word query matches none of them: `searchFTSFromLbug('foo')` misses a file
162
+ * whose content is `... \nfoo\n ...`. Removing the 10KB cap (#2333/#2317)
163
+ * stores the full body but leaves it unsearchable; collapsing intra-text
164
+ * whitespace to spaces is what actually makes every word searchable.
165
+ *
166
+ * This rewrites the STORED column too (the same value is COPYed in), so File
167
+ * content returned via the graph API is space-flattened — an accepted trade
168
+ * for making file/symbol text searchable. Leading/trailing/empty are no-ops.
169
+ */
170
+ const normalizeFtsText = (text) => text.replace(/[\r\n\t]+/g, ' ');
130
171
  const extractContent = async (node, contentCache) => {
131
172
  const filePath = node.properties.filePath;
132
173
  const content = await contentCache.get(filePath);
@@ -136,11 +177,13 @@ const extractContent = async (node, contentCache) => {
136
177
  return '';
137
178
  if (isBinaryContent(content))
138
179
  return '[Binary file - content not stored]';
180
+ // File content is stored in full — intentionally NOT length-capped here, so
181
+ // text past the old 10KB cutoff stays FTS-searchable (#2317). It is already
182
+ // bounded upstream by the walker's max-file-size cap (512KB default / 32MB),
183
+ // and only whitespace-normalized for the tokenizer. The symbol snippet path
184
+ // below, by contrast, deliberately stays capped at MAX_SNIPPET.
139
185
  if (node.label === 'File') {
140
- const MAX_FILE_CONTENT = 10000;
141
- return content.length > MAX_FILE_CONTENT
142
- ? content.slice(0, MAX_FILE_CONTENT) + '\n... [truncated]'
143
- : content;
186
+ return normalizeFtsText(content);
144
187
  }
145
188
  const startLine = node.properties.startLine;
146
189
  const endLine = node.properties.endLine;
@@ -151,9 +194,8 @@ const extractContent = async (node, contentCache) => {
151
194
  const end = Math.min(lines.length - 1, endLine + 2);
152
195
  const snippet = lines.slice(start, end + 1).join('\n');
153
196
  const MAX_SNIPPET = 5000;
154
- return snippet.length > MAX_SNIPPET
155
- ? snippet.slice(0, MAX_SNIPPET) + '\n... [truncated]'
156
- : snippet;
197
+ const capped = snippet.length > MAX_SNIPPET ? snippet.slice(0, MAX_SNIPPET) + '\n... [truncated]' : snippet;
198
+ return normalizeFtsText(capped);
157
199
  };
158
200
  // ============================================================================
159
201
  // BUFFERED CSV WRITER
@@ -161,15 +203,17 @@ const extractContent = async (node, contentCache) => {
161
203
  class BufferedCSVWriter {
162
204
  ws;
163
205
  buffer = [];
206
+ bufferedBytes = 0;
164
207
  rows = 0;
165
208
  constructor(filePath, header) {
166
209
  this.ws = createWriteStream(filePath, 'utf-8');
167
210
  // Large repos flush many times — raise listener cap to avoid MaxListenersExceededWarning
168
211
  this.ws.setMaxListeners(50);
169
212
  this.buffer.push(header);
213
+ this.bufferedBytes = Buffer.byteLength(header) + 1;
170
214
  }
171
215
  /**
172
- * Buffer a row. Returns a promise ONLY when the buffer crossed FLUSH_EVERY
216
+ * Buffer a row. Returns a promise ONLY when the buffer crossed FLUSH_BYTES
173
217
  * and a disk write was issued; otherwise returns `undefined` so the caller
174
218
  * can skip awaiting (#2203 U3) — avoiding a microtask tick on every buffered
175
219
  * row (millions at scale). The flush promise still resolves on drain, so
@@ -177,8 +221,9 @@ class BufferedCSVWriter {
177
221
  */
178
222
  addRow(row) {
179
223
  this.buffer.push(row);
224
+ this.bufferedBytes += Buffer.byteLength(row) + 1;
180
225
  this.rows++;
181
- if (this.buffer.length >= FLUSH_EVERY) {
226
+ if (shouldFlushCSVBuffer(this.bufferedBytes)) {
182
227
  return this.flush();
183
228
  }
184
229
  return undefined;
@@ -188,6 +233,7 @@ class BufferedCSVWriter {
188
233
  return Promise.resolve();
189
234
  const chunk = this.buffer.join('\n') + '\n';
190
235
  this.buffer.length = 0;
236
+ this.bufferedBytes = 0;
191
237
  return new Promise((resolve, reject) => {
192
238
  this.ws.once('error', reject);
193
239
  const ok = this.ws.write(chunk);
@@ -355,7 +401,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir, onNodePhaseCo
355
401
  seenNodeIds.add(node.id);
356
402
  // addRow returns a promise only when it flushes; awaiting it once after the
357
403
  // switch (instead of `await`-ing every addRow) skips a per-row microtask
358
- // tick on the ~FLUSH_EVERY-1 buffered rows between flushes (#2203 U3).
404
+ // tick on the rows buffered between byte-bounded flushes (#2203 U3).
359
405
  let pending;
360
406
  switch (node.label) {
361
407
  case 'File': {
@@ -383,7 +429,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir, onNodePhaseCo
383
429
  escapeCSVField(node.properties.name || ''),
384
430
  escapeCSVField(node.properties.heuristicLabel || ''),
385
431
  keywordsStr,
386
- escapeCSVField(node.properties.description || ''),
432
+ escapeCSVField(normalizeFtsText(node.properties.description || '')),
387
433
  escapeCSVField(node.properties.enrichedBy || 'heuristic'),
388
434
  escapeCSVNumber(node.properties.cohesion, 0),
389
435
  escapeCSVNumber(node.properties.symbolCount, 0),
@@ -415,7 +461,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir, onNodePhaseCo
415
461
  escapeCSVNumber(node.properties.endLine, -1),
416
462
  node.properties.isExported ? 'true' : 'false',
417
463
  escapeCSVField(content),
418
- escapeCSVField(node.properties.description || ''),
464
+ escapeCSVField(normalizeFtsText(node.properties.description || '')),
419
465
  escapeCSVNumber(node.properties.parameterCount, 0),
420
466
  escapeCSVField(node.properties.returnType || ''),
421
467
  ].join(','));
@@ -431,7 +477,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir, onNodePhaseCo
431
477
  escapeCSVNumber(node.properties.endLine, -1),
432
478
  escapeCSVNumber(node.properties.level, 1),
433
479
  escapeCSVField(content),
434
- escapeCSVField(node.properties.description || ''),
480
+ escapeCSVField(normalizeFtsText(node.properties.description || '')),
435
481
  ].join(','));
436
482
  break;
437
483
  }
@@ -461,7 +507,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir, onNodePhaseCo
461
507
  escapeCSVField(node.id),
462
508
  escapeCSVField(node.properties.name || ''),
463
509
  escapeCSVField(node.properties.filePath || ''),
464
- escapeCSVField(node.properties.description || ''),
510
+ escapeCSVField(normalizeFtsText(node.properties.description || '')),
465
511
  ].join(','));
466
512
  break;
467
513
  case 'BasicBlock':
@@ -480,7 +526,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir, onNodePhaseCo
480
526
  escapeCSVNumber(node.properties.endLine, -1),
481
527
  node.properties.isExported ? 'true' : 'false',
482
528
  escapeCSVField(content),
483
- escapeCSVField(node.properties.description || ''),
529
+ escapeCSVField(normalizeFtsText(node.properties.description || '')),
484
530
  ].join(','));
485
531
  }
486
532
  else {
@@ -495,7 +541,7 @@ export const streamAllCSVsToDisk = async (graph, repoPath, csvDir, onNodePhaseCo
495
541
  escapeCSVNumber(node.properties.startLine, -1),
496
542
  escapeCSVNumber(node.properties.endLine, -1),
497
543
  escapeCSVField(content),
498
- escapeCSVField(node.properties.description || ''),
544
+ escapeCSVField(normalizeFtsText(node.properties.description || '')),
499
545
  ...(node.label === 'Property'
500
546
  ? [escapeCSVField(node.properties.declaredType || '')]
501
547
  : []),
@@ -982,20 +982,6 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
982
982
  existingEmbeddings.set(e.nodeId, e.contentHash ?? STALE_HASH_SENTINEL);
983
983
  }
984
984
  }
985
- const { readServerMapping } = await import('./embeddings/server-mapping.js');
986
- // Mirror the registry's name-resolution chain so the server-mapping
987
- // lookup key stays aligned with the final registry name (#1259):
988
- // --name → remote-derived → canonical-root basename
989
- // (preserved-alias is intentionally NOT consulted here — server
990
- // mappings are addressed by the operationally-meaningful name the
991
- // user configures, not by a sticky registry-only alias they may not
992
- // know about. The previous canonical-only logic ignored both --name
993
- // and remote-derived names, silently breaking server-mapping for
994
- // anyone with a `--name` alias or remote-named repo.)
995
- const projectName = options.registryName ??
996
- getInferredRepoName(repoPath) ??
997
- path.basename(resolveRepoIdentityRoot(repoPath));
998
- const serverName = await readServerMapping(projectName);
999
985
  const embeddingResult = await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, (p) => {
1000
986
  const scaled = 90 + Math.round((p.percent / 100) * 8);
1001
987
  const label = p.phase === 'loading-model'
@@ -1004,7 +990,7 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
1004
990
  : 'Loading embedding model...'
1005
991
  : `Embedding ${p.nodesProcessed || 0}/${p.totalNodes || '?'}`;
1006
992
  progress('embeddings', scaled, label);
1007
- }, {}, cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined, { repoName: projectName, serverName }, existingEmbeddings);
993
+ }, {}, cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined, existingEmbeddings);
1008
994
  if (embeddingResult.semanticMode === 'exact-scan') {
1009
995
  semanticMode = 'exact-scan';
1010
996
  log('Semantic embeddings were generated without a VECTOR index; ' +
@@ -1520,7 +1520,6 @@ export const createServer = async (port, host = '127.0.0.1') => {
1520
1520
  });
1521
1521
  }, {}, // config: use defaults
1522
1522
  undefined, // skipNodeIds
1523
- undefined, // context
1524
1523
  existingEmbeddings);
1525
1524
  // Flush WAL so subsequent /api/search requests see the new
1526
1525
  // embeddings immediately (#1149). In the CLI path closeLbug()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9-rc.33",
3
+ "version": "1.6.9-rc.35",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",