doc2vec 2.11.0 → 2.13.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/dist/database.js CHANGED
@@ -71,6 +71,7 @@ class DatabaseManager {
71
71
  total_chunks INTEGER
72
72
  );
73
73
  `);
74
+ this.ensureChunkDatesTable(db);
74
75
  logger.info(`SQLite database initialized successfully`);
75
76
  return { db, type: 'sqlite' };
76
77
  }
@@ -307,9 +308,24 @@ class DatabaseManager {
307
308
  logger.error(`Failed to update last run date for ${repo}:`, error);
308
309
  }
309
310
  }
311
+ /**
312
+ * Chunk creation timestamps live in a companion table because vec_items is
313
+ * a vec0 virtual table that cannot be ALTERed to add a column. Rows are
314
+ * written on every chunk insert/update; stale rows for deleted chunks are
315
+ * harmless (lookups always join against vec_items).
316
+ */
317
+ static ensureChunkDatesTable(db) {
318
+ db.exec(`
319
+ CREATE TABLE IF NOT EXISTS vec_chunk_dates (
320
+ chunk_id TEXT PRIMARY KEY,
321
+ created_at TEXT NOT NULL
322
+ );
323
+ `);
324
+ }
310
325
  static prepareSQLiteStatements(db) {
311
326
  let cached = this.columnCache.get(db);
312
327
  if (!cached) {
328
+ this.ensureChunkDatesTable(db);
313
329
  cached = {
314
330
  hasBranch: this.hasColumn(db, 'branch'),
315
331
  hasRepo: this.hasColumn(db, 'repo')
@@ -360,12 +376,16 @@ class DatabaseManager {
360
376
  `),
361
377
  getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
362
378
  deleteChunkStmt: db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`),
379
+ touchDateStmt: db.prepare(`
380
+ INSERT INTO vec_chunk_dates (chunk_id, created_at) VALUES (?, ?)
381
+ ON CONFLICT(chunk_id) DO UPDATE SET created_at = excluded.created_at
382
+ `),
363
383
  hasBranchColumn,
364
384
  hasRepoColumn
365
385
  };
366
386
  }
367
387
  static insertVectorsSQLite(db, chunk, embedding, logger, chunkHash) {
368
- const { insertStmt, updateStmt, hasBranchColumn, hasRepoColumn } = this.prepareSQLiteStatements(db);
388
+ const { insertStmt, updateStmt, touchDateStmt, hasBranchColumn, hasRepoColumn } = this.prepareSQLiteStatements(db);
369
389
  const hash = chunkHash || utils_1.Utils.generateHash(chunk.content);
370
390
  const transaction = db.transaction(() => {
371
391
  // Use BigInt for true integer representation in SQLite vec0
@@ -401,6 +421,7 @@ class DatabaseManager {
401
421
  updateValues.push(JSON.stringify(chunk.metadata.heading_hierarchy), chunk.metadata.section, chunk.content, chunk.metadata.url, hash, chunkIndex, totalChunks, chunk.metadata.chunk_id);
402
422
  updateStmt.run(...updateValues);
403
423
  }
424
+ touchDateStmt.run(chunk.metadata.chunk_id, new Date().toISOString());
404
425
  });
405
426
  transaction();
406
427
  }
@@ -434,6 +455,7 @@ class DatabaseManager {
434
455
  original_chunk_id: chunk.metadata.chunk_id,
435
456
  chunk_index: chunk.metadata.chunk_index,
436
457
  total_chunks: chunk.metadata.total_chunks,
458
+ created_at: new Date().toISOString(),
437
459
  },
438
460
  };
439
461
  await client.upsert(collectionName, {
@@ -457,17 +479,20 @@ class DatabaseManager {
457
479
  const deleteChunkStmt = db.prepare(`DELETE FROM vec_items WHERE chunk_id = ?`);
458
480
  const existingChunks = getChunksForUrlStmt.all(urlPrefix);
459
481
  let deletedCount = 0;
482
+ const deletedUrls = new Set();
460
483
  const transaction = db.transaction(() => {
461
484
  for (const { chunk_id, url } of existingChunks) {
462
485
  if (!visitedUrls.has(url)) {
463
486
  logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (URL not visited)`);
464
487
  deleteChunkStmt.run(chunk_id);
465
488
  deletedCount++;
489
+ deletedUrls.add(url);
466
490
  }
467
491
  }
468
492
  });
469
493
  transaction();
470
494
  logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL ${urlPrefix}`);
495
+ return { items: deletedUrls.size, chunks: deletedCount };
471
496
  }
472
497
  static async removeObsoleteChunksQdrant(db, visitedUrls, urlPrefix, logger) {
473
498
  const { client, collectionName } = db;
@@ -496,6 +521,7 @@ class DatabaseManager {
496
521
  ]
497
522
  };
498
523
  const obsoletePointIds = [];
524
+ const obsoleteUrls = new Set();
499
525
  let offset = undefined;
500
526
  do {
501
527
  const response = await client.scroll(collectionName, {
@@ -513,6 +539,7 @@ class DatabaseManager {
513
539
  }
514
540
  if (url && !visitedUrls.has(url)) {
515
541
  obsoletePointIds.push(point.id);
542
+ obsoleteUrls.add(url);
516
543
  }
517
544
  }
518
545
  offset = response.next_page_offset;
@@ -526,6 +553,7 @@ class DatabaseManager {
526
553
  else {
527
554
  logger.info(`No obsolete chunks to delete from Qdrant for URL ${urlPrefix}`);
528
555
  }
556
+ return { items: obsoleteUrls.size, chunks: obsoletePointIds.length };
529
557
  }
530
558
  catch (error) {
531
559
  // Rethrow so a failed cleanup fails the job instead of silently
@@ -548,31 +576,42 @@ class DatabaseManager {
548
576
  const deleteStmt = db.prepare(`DELETE FROM vec_items WHERE url = ?`);
549
577
  const result = deleteStmt.run(url);
550
578
  logger.info(`Deleted ${result.changes} chunks from SQLite for URL ${url}`);
579
+ return result.changes;
551
580
  }
552
581
  static async removeChunksByUrlQdrant(db, url, logger) {
553
582
  const { client, collectionName } = db;
554
583
  try {
555
- await client.delete(collectionName, {
556
- filter: {
557
- must: [
558
- {
559
- key: 'url',
560
- match: {
561
- value: url // Exact match — must match getChunkHashesByUrlQdrant's filter
562
- }
584
+ const filter = {
585
+ must: [
586
+ {
587
+ key: 'url',
588
+ match: {
589
+ value: url // Exact match — must match getChunkHashesByUrlQdrant's filter
563
590
  }
564
- ],
565
- must_not: [
566
- {
567
- key: 'is_metadata',
568
- match: {
569
- value: true
570
- }
591
+ }
592
+ ],
593
+ must_not: [
594
+ {
595
+ key: 'is_metadata',
596
+ match: {
597
+ value: true
571
598
  }
572
- ]
573
- }
574
- });
599
+ }
600
+ ]
601
+ };
602
+ // Count first so callers can report how many chunks the delete removed
603
+ // (Qdrant's delete-by-filter response doesn't include a count).
604
+ let deletedCount = 0;
605
+ try {
606
+ const countResult = await client.count(collectionName, { filter, exact: true });
607
+ deletedCount = countResult?.count ?? 0;
608
+ }
609
+ catch {
610
+ // Count is informational only — never fail the delete over it
611
+ }
612
+ await client.delete(collectionName, { filter });
575
613
  logger.info(`Deleted chunks from Qdrant for URL ${url}`);
614
+ return deletedCount;
576
615
  }
577
616
  catch (error) {
578
617
  // Rethrow so the failure propagates and the job exits non-zero.
@@ -697,6 +736,7 @@ class DatabaseManager {
697
736
  logger.debug(`Searching for chunks with URL prefix: ${urlPrefix}`);
698
737
  const existingChunks = getChunksForPathStmt.all(urlPrefix);
699
738
  let deletedCount = 0;
739
+ const deletedUrls = new Set();
700
740
  const transaction = db.transaction(() => {
701
741
  for (const { chunk_id, url } of existingChunks) {
702
742
  // Skip if it's not from our URL prefix (safety check)
@@ -720,11 +760,13 @@ class DatabaseManager {
720
760
  logger.debug(`Deleting obsolete chunk from SQLite: ${chunk_id.substring(0, 8)}... (File not processed: ${filePath})`);
721
761
  deleteChunkStmt.run(chunk_id);
722
762
  deletedCount++;
763
+ deletedUrls.add(url);
723
764
  }
724
765
  }
725
766
  });
726
767
  transaction();
727
768
  logger.info(`Deleted ${deletedCount} obsolete chunks from SQLite for URL prefix ${urlPrefix}`);
769
+ return { items: deletedUrls.size, chunks: deletedCount };
728
770
  }
729
771
  static async removeObsoleteFilesQdrant(db, processedFiles, pathConfig, logger) {
730
772
  const { client, collectionName } = db;
@@ -768,6 +810,7 @@ class DatabaseManager {
768
810
  ]
769
811
  };
770
812
  const obsoletePointIds = [];
813
+ const obsoleteUrls = new Set();
771
814
  let offset = undefined;
772
815
  do {
773
816
  const response = await client.scroll(collectionName, {
@@ -799,6 +842,7 @@ class DatabaseManager {
799
842
  }
800
843
  if (filePath && !processedFiles.has(filePath)) {
801
844
  obsoletePointIds.push(point.id);
845
+ obsoleteUrls.add(url);
802
846
  }
803
847
  }
804
848
  offset = response.next_page_offset;
@@ -812,6 +856,7 @@ class DatabaseManager {
812
856
  else {
813
857
  logger.info(`No obsolete chunks to delete from Qdrant for URL prefix ${urlPrefix}`);
814
858
  }
859
+ return { items: obsoleteUrls.size, chunks: obsoletePointIds.length };
815
860
  }
816
861
  catch (error) {
817
862
  // Rethrow so a failed cleanup fails the job instead of silently
package/dist/doc2vec.js CHANGED
@@ -53,6 +53,7 @@ const logger_1 = require("./logger");
53
53
  const utils_1 = require("./utils");
54
54
  const database_1 = require("./database");
55
55
  const content_processor_1 = require("./content-processor");
56
+ const types_1 = require("./types");
56
57
  const client_s3_1 = require("@aws-sdk/client-s3");
57
58
  const markdown_store_1 = require("./markdown-store");
58
59
  const GITHUB_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
@@ -61,6 +62,9 @@ dotenv.config();
61
62
  class Doc2Vec {
62
63
  constructor(configPath) {
63
64
  this.brokenLinksByWebsite = {};
65
+ // Change counters for the source currently being processed by run().
66
+ // Sources are processed sequentially, so a single slot is sufficient.
67
+ this.counters = (0, types_1.newSourceRunCounters)('items');
64
68
  this.logger = new logger_1.Logger('Doc2Vec', {
65
69
  level: logger_1.LogLevel.DEBUG,
66
70
  useTimestamp: true,
@@ -183,6 +187,15 @@ class Doc2Vec {
183
187
  const startTime = Date.now();
184
188
  let ok = true;
185
189
  let errorMessage;
190
+ const itemsKindByType = {
191
+ website: 'pages',
192
+ github: 'issues',
193
+ local_directory: 'files',
194
+ code: 'files',
195
+ s3: 'objects',
196
+ zendesk: 'items',
197
+ };
198
+ this.counters = (0, types_1.newSourceRunCounters)(itemsKindByType[sourceConfig.type] ?? 'items');
186
199
  try {
187
200
  if (sourceConfig.type === 'github') {
188
201
  await this.processGithubRepo(sourceConfig, sourceLogger);
@@ -219,7 +232,8 @@ class Doc2Vec {
219
232
  version: sourceConfig.version,
220
233
  duration_ms: Date.now() - startTime,
221
234
  ok,
222
- ...(errorMessage && { error: errorMessage })
235
+ ...(errorMessage && { error: errorMessage }),
236
+ counters: this.counters
223
237
  });
224
238
  }
225
239
  // Close the Postgres markdown store connection pool
@@ -240,22 +254,31 @@ class Doc2Vec {
240
254
  // Get the last run date from the database
241
255
  const startDate = sourceConfig.start_date || '2025-01-01';
242
256
  const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, repo, `${startDate}T00:00:00Z`, logger);
243
- const fetchWithRetry = async (url, params = {}, retries = 5, delay = 5000) => {
257
+ // Retryable: 5xx, 429, rate-limited 403s, and network/timeout errors.
258
+ // Everything else is deterministic — the same request fails identically,
259
+ // so retrying only burns wall-clock. In particular a 422 from deep
260
+ // page-number pagination used to cost ~75s of backoff before failing.
261
+ const isRetryableStatus = (status) => status === undefined || status >= 500 || status === 429;
262
+ const requestWithRetry = async (url, params, retries = 5, delay = 5000) => {
244
263
  for (let attempt = 0; attempt < retries; attempt++) {
245
264
  try {
246
265
  // Only log on retries to reduce noise during pagination
247
266
  if (attempt > 0) {
248
267
  logger.debug(`GitHub API retry: ${url} (attempt ${attempt + 1}/${retries})`);
249
268
  }
269
+ // Callers need the response headers (Link) for cursor
270
+ // pagination, so the whole response is returned.
250
271
  const response = await axios_1.default.get(url, {
251
272
  headers: {
252
273
  Authorization: `token ${GITHUB_TOKEN}`,
253
274
  Accept: 'application/vnd.github.v3+json',
254
275
  },
255
- params,
276
+ // Subsequent pages come from a Link URL that already
277
+ // carries its query string — adding params would duplicate it
278
+ ...(params && { params }),
256
279
  timeout: 30000, // 30 second timeout
257
280
  });
258
- return response.data;
281
+ return response;
259
282
  }
260
283
  catch (error) {
261
284
  // Enhanced error logging for debugging
@@ -290,6 +313,13 @@ class Doc2Vec {
290
313
  throw error;
291
314
  }
292
315
  }
316
+ else if (!isRetryableStatus(error.response?.status)) {
317
+ // Deterministic failure (422, 404, 401, ...) — surface the
318
+ // API's own message, which explains what to do instead.
319
+ const apiMessage = error.response?.data?.message;
320
+ logger.error(`GitHub API returned ${error.response.status} (not retryable): ${apiMessage || error.message}`);
321
+ throw error;
322
+ }
293
323
  else {
294
324
  // For non-403 errors, wait before retrying (exponential backoff)
295
325
  if (attempt < retries - 1) {
@@ -306,23 +336,45 @@ class Doc2Vec {
306
336
  }
307
337
  throw new Error('Max retries reached');
308
338
  };
339
+ /**
340
+ * Walk every issue updated since `sinceDate` by following GitHub's
341
+ * `Link: rel="next"` cursor URLs.
342
+ *
343
+ * Page-number pagination (`?page=N`) cannot be used here: GitHub caps it
344
+ * on this endpoint and answers HTTP 422 ("Pagination with the page
345
+ * parameter is not supported for large datasets, please use cursor based
346
+ * pagination") once the offset grows past its limit. That limit is well
347
+ * below the size of a full-window rebuild on a busy repo, so page-based
348
+ * paging aborted the whole source and left the collection empty. The
349
+ * Link URL already contains the opaque `after=` cursor and is passed
350
+ * through verbatim.
351
+ */
309
352
  const fetchAllIssues = async (sinceDate) => {
310
353
  let issues = [];
311
- let page = 1;
312
- const perPage = 100;
313
- while (true) {
354
+ // sort=updated&direction=asc makes the walk stable: results are
355
+ // ordered by the same field `since` filters on, so a page boundary
356
+ // can't shuffle items in or out mid-walk.
357
+ let url = GITHUB_API_URL;
358
+ let params = {
359
+ per_page: 100,
360
+ state: 'all',
361
+ since: sinceDate,
362
+ sort: 'updated',
363
+ direction: 'asc',
364
+ };
365
+ let page = 0;
366
+ while (url) {
367
+ page++;
314
368
  // Log progress every 10 pages to reduce noise
315
369
  if (page === 1 || page % 10 === 0) {
316
370
  logger.debug(`Fetching issues page ${page}... (${issues.length} issues so far)`);
317
371
  }
318
- const data = await fetchWithRetry(GITHUB_API_URL, {
319
- per_page: perPage,
320
- page,
321
- state: 'all',
322
- since: sinceDate,
323
- });
324
- if (data.length === 0)
372
+ const response = await requestWithRetry(url, params);
373
+ const data = response.data;
374
+ if (!Array.isArray(data)) {
375
+ logger.warn(`Unexpected non-array response while paginating issues for ${repo}; stopping the walk`);
325
376
  break;
377
+ }
326
378
  // The `since` param already scopes results to issues whose
327
379
  // updated_at is after sinceDate — i.e. anything created, closed,
328
380
  // reopened, edited, or newly commented since the last run. Do NOT
@@ -330,19 +382,45 @@ class Doc2Vec {
330
382
  // received a new comment has an old created_at and would be
331
383
  // dropped, leaving its status and comments permanently stale.
332
384
  issues = issues.concat(data);
333
- if (data.length < perPage)
385
+ const nextUrl = utils_1.Utils.parseNextLink(response.headers?.link ?? response.headers?.Link);
386
+ // Guard against a server echoing the same cursor forever
387
+ if (!nextUrl || nextUrl === url)
334
388
  break;
335
- page++;
389
+ url = nextUrl;
390
+ params = undefined; // the cursor URL is already fully-formed
336
391
  }
392
+ logger.info(`Fetched ${issues.length} issues/PRs for ${repo} across ${page} page(s)`);
337
393
  return issues;
338
394
  };
339
395
  const fetchIssueComments = async (issueNumber) => {
340
- const url = `${GITHUB_API_URL}/${issueNumber}/comments`;
341
- return await fetchWithRetry(url);
396
+ // GitHub returns 30 comments per page by default and paginates the
397
+ // rest behind Link headers; requesting the first page only silently
398
+ // truncated long discussions, so the stored chunks were missing the
399
+ // newest comments.
400
+ const comments = [];
401
+ let url = `${GITHUB_API_URL}/${issueNumber}/comments`;
402
+ let params = { per_page: 100 };
403
+ while (url) {
404
+ const response = await requestWithRetry(url, params);
405
+ if (!Array.isArray(response.data))
406
+ break;
407
+ comments.push(...response.data);
408
+ const nextUrl = utils_1.Utils.parseNextLink(response.headers?.link ?? response.headers?.Link);
409
+ if (!nextUrl || nextUrl === url)
410
+ break;
411
+ url = nextUrl;
412
+ params = undefined;
413
+ }
414
+ return comments;
342
415
  };
343
416
  const generateMarkdownForIssue = async (issue) => {
344
417
  const comments = await fetchIssueComments(issue.number);
345
- let md = `# Issue #${issue.number}: ${issue.title}\n\n`;
418
+ // This endpoint returns both issues and pull requests; only PRs carry
419
+ // a `pull_request` object. Labelling every item "Issue #N" made
420
+ // retrieved PR chunks read as issues, so use the real kind.
421
+ const itemLabel = issue.pull_request ? 'PR' : 'Issue';
422
+ let md = `# ${itemLabel} #${issue.number}: ${issue.title}\n\n`;
423
+ md += `- **Type:** ${issue.pull_request ? 'Pull request' : 'Issue'}\n`;
346
424
  md += `- **Author:** ${issue.user.login}\n`;
347
425
  md += `- **State:** ${issue.state}\n`;
348
426
  md += `- **Created on:** ${new Date(issue.created_at).toDateString()}\n`;
@@ -359,6 +437,9 @@ class Doc2Vec {
359
437
  }
360
438
  return md;
361
439
  };
440
+ // Chunks that could not be stored (e.g. a rejected Qdrant upsert).
441
+ // Tallied across all issues and escalated once the walk completes.
442
+ let failedChunks = 0;
362
443
  // Process a single issue and store its chunks
363
444
  const processIssue = async (issue) => {
364
445
  const issueNumber = issue.number;
@@ -380,11 +461,19 @@ class Doc2Vec {
380
461
  // ones would otherwise linger — leaving stale state ("open") and missing
381
462
  // the latest comments in search results. All chunks for an issue share
382
463
  // its unique url, so delete-by-url reconciles them exactly.
464
+ let removedChunks = 0;
383
465
  if (dbConnection.type === 'sqlite') {
384
- database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
466
+ removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
385
467
  }
386
468
  else if (dbConnection.type === 'qdrant') {
387
- await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
469
+ removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
470
+ }
471
+ if (removedChunks > 0) {
472
+ this.counters.items_updated++;
473
+ this.counters.chunks_deleted += removedChunks;
474
+ }
475
+ else {
476
+ this.counters.items_new++;
388
477
  }
389
478
  // Process and store each chunk immediately
390
479
  for (const chunk of chunks) {
@@ -400,6 +489,7 @@ class Doc2Vec {
400
489
  const embeddings = await this.createEmbeddings([chunk.content]);
401
490
  if (embeddings.length) {
402
491
  database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
492
+ this.counters.chunks_added++;
403
493
  logger.debug(`Stored chunk ${chunkId} in SQLite`);
404
494
  }
405
495
  else {
@@ -430,6 +520,7 @@ class Doc2Vec {
430
520
  const embeddings = await this.createEmbeddings([chunk.content]);
431
521
  if (embeddings.length) {
432
522
  await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
523
+ this.counters.chunks_added++;
433
524
  logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
434
525
  }
435
526
  else {
@@ -437,7 +528,11 @@ class Doc2Vec {
437
528
  }
438
529
  }
439
530
  catch (error) {
440
- logger.error(`Error processing chunk in Qdrant:`, error);
531
+ // Keep going so one bad chunk can't cost the whole repo,
532
+ // but remember it: the count is escalated below so the run
533
+ // fails and the last-run date is not advanced.
534
+ failedChunks++;
535
+ logger.error(`Error processing chunk ${chunkId} for ${url} in Qdrant:`, error);
441
536
  }
442
537
  }
443
538
  }
@@ -450,6 +545,12 @@ class Doc2Vec {
450
545
  logger.info(`Processing issue ${i + 1}/${issues.length}`);
451
546
  await processIssue(issues[i]);
452
547
  }
548
+ // A chunk that failed to store is silent data loss, so refuse to advance
549
+ // the last-run date: the next run reprocesses this window instead of
550
+ // treating the gap as synced. Everything that did store is kept.
551
+ if (failedChunks > 0) {
552
+ throw new Error(`${failedChunks} chunk(s) failed to store for ${repo}; not advancing the last-run date so the next run retries them`);
553
+ }
453
554
  // Update the last run date in the database after processing all issues
454
555
  await database_1.DatabaseManager.updateLastRunDate(dbConnection, repo, logger, this.embeddingDimension, syncStartDate);
455
556
  logger.info(`Successfully processed ${issues.length} issues`);
@@ -579,11 +680,16 @@ class Doc2Vec {
579
680
  }
580
681
  // Vector DB chunks
581
682
  try {
683
+ let removedChunks = 0;
582
684
  if (dbConnection.type === 'sqlite') {
583
- database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
685
+ removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
584
686
  }
585
687
  else if (dbConnection.type === 'qdrant') {
586
- await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
688
+ removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
689
+ }
690
+ if (removedChunks > 0) {
691
+ this.counters.items_deleted++;
692
+ this.counters.chunks_deleted += removedChunks;
587
693
  }
588
694
  }
589
695
  catch (dbError) {
@@ -603,14 +709,17 @@ class Doc2Vec {
603
709
  await database_1.DatabaseManager.setMetadataValue(dbConnection, syncCompleteKey, 'true', logger, this.embeddingDimension);
604
710
  logger.info('Full sync completed successfully — subsequent runs will use normal caching');
605
711
  }
712
+ let removed = { items: 0, chunks: 0 };
606
713
  if (dbConnection.type === 'sqlite') {
607
714
  logger.info(`Running SQLite cleanup for ${urlPrefix}`);
608
- database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
715
+ removed = database_1.DatabaseManager.removeObsoleteChunksSQLite(dbConnection.db, visitedUrls, urlPrefix, logger);
609
716
  }
610
717
  else if (dbConnection.type === 'qdrant') {
611
718
  logger.info(`Running Qdrant cleanup for ${urlPrefix} in collection ${dbConnection.collectionName}`);
612
- await database_1.DatabaseManager.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
719
+ removed = await database_1.DatabaseManager.removeObsoleteChunksQdrant(dbConnection, visitedUrls, urlPrefix, logger);
613
720
  }
721
+ this.counters.items_deleted += removed.items;
722
+ this.counters.chunks_deleted += removed.chunks;
614
723
  }
615
724
  logger.info(`Finished processing website: ${config.url}`);
616
725
  }
@@ -698,14 +807,17 @@ class Doc2Vec {
698
807
  }
699
808
  }, logger);
700
809
  logger.section('CLEANUP');
810
+ let removed = { items: 0, chunks: 0 };
701
811
  if (dbConnection.type === 'sqlite') {
702
812
  logger.info(`Running SQLite cleanup for local directory ${config.path}`);
703
- database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
813
+ removed = database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, config, logger);
704
814
  }
705
815
  else if (dbConnection.type === 'qdrant') {
706
816
  logger.info(`Running Qdrant cleanup for local directory ${config.path} in collection ${dbConnection.collectionName}`);
707
- await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
817
+ removed = await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, config, logger);
708
818
  }
819
+ this.counters.items_deleted += removed.items;
820
+ this.counters.chunks_deleted += removed.chunks;
709
821
  logger.info(`Finished processing local directory: ${config.path}`);
710
822
  }
711
823
  async processS3(config, parentLogger) {
@@ -886,11 +998,16 @@ class Doc2Vec {
886
998
  else {
887
999
  fileUrl = `s3://${config.bucket}/${deletedKey}`;
888
1000
  }
1001
+ let removedChunks = 0;
889
1002
  if (dbConnection.type === 'sqlite') {
890
- database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, fileUrl, logger);
1003
+ removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, fileUrl, logger);
891
1004
  }
892
1005
  else if (dbConnection.type === 'qdrant') {
893
- await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, fileUrl, logger);
1006
+ removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, fileUrl, logger);
1007
+ }
1008
+ if (removedChunks > 0) {
1009
+ this.counters.items_deleted++;
1010
+ this.counters.chunks_deleted += removedChunks;
894
1011
  }
895
1012
  }
896
1013
  }
@@ -1025,11 +1142,16 @@ class Doc2Vec {
1025
1142
  if (deleteUrls.length > 0) {
1026
1143
  logger.info(`Cleaning up ${deleteUrls.length} deleted/renamed files`);
1027
1144
  for (const url of deleteUrls) {
1145
+ let removedChunks = 0;
1028
1146
  if (dbConnection.type === 'sqlite') {
1029
- database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1147
+ removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1030
1148
  }
1031
1149
  else if (dbConnection.type === 'qdrant') {
1032
- await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1150
+ removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1151
+ }
1152
+ if (removedChunks > 0) {
1153
+ this.counters.items_deleted++;
1154
+ this.counters.chunks_deleted += removedChunks;
1033
1155
  }
1034
1156
  }
1035
1157
  }
@@ -1043,11 +1165,16 @@ class Doc2Vec {
1043
1165
  const deletedFiles = previousList.filter((filePath) => !trackedFiles?.has(filePath));
1044
1166
  for (const deletedFile of deletedFiles) {
1045
1167
  const url = this.buildCodeFileUrl(deletedFile, basePath, config, repoUrlPrefix);
1168
+ let removedChunks = 0;
1046
1169
  if (dbConnection.type === 'sqlite') {
1047
- database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1170
+ removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1048
1171
  }
1049
1172
  else if (dbConnection.type === 'qdrant') {
1050
- await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1173
+ removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1174
+ }
1175
+ if (removedChunks > 0) {
1176
+ this.counters.items_deleted++;
1177
+ this.counters.chunks_deleted += removedChunks;
1051
1178
  }
1052
1179
  }
1053
1180
  await database_1.DatabaseManager.setMetadataValue(dbConnection, fileListKey, JSON.stringify(currentList), logger, this.embeddingDimension);
@@ -1058,14 +1185,17 @@ class Doc2Vec {
1058
1185
  }
1059
1186
  }
1060
1187
  else {
1188
+ let removed = { items: 0, chunks: 0 };
1061
1189
  if (dbConnection.type === 'sqlite') {
1062
1190
  logger.info(`Running SQLite cleanup for code source ${basePath}`);
1063
- database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, cleanupPathConfig, logger);
1191
+ removed = database_1.DatabaseManager.removeObsoleteFilesSQLite(dbConnection.db, processedFiles, cleanupPathConfig, logger);
1064
1192
  }
1065
1193
  else if (dbConnection.type === 'qdrant') {
1066
1194
  logger.info(`Running Qdrant cleanup for code source ${basePath} in collection ${dbConnection.collectionName}`);
1067
- await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, cleanupPathConfig, logger);
1195
+ removed = await database_1.DatabaseManager.removeObsoleteFilesQdrant(dbConnection, processedFiles, cleanupPathConfig, logger);
1068
1196
  }
1197
+ this.counters.items_deleted += removed.items;
1198
+ this.counters.chunks_deleted += removed.chunks;
1069
1199
  }
1070
1200
  if (config.source === 'github' && basePath && repoBranch) {
1071
1201
  const headSha = await this.getRepoHeadSha(basePath, logger);
@@ -1333,11 +1463,16 @@ class Doc2Vec {
1333
1463
  // Deleted tickets — remove their chunks and stop
1334
1464
  if (ticket.status === 'deleted') {
1335
1465
  logger.info(`Ticket #${ticketId} was deleted in Zendesk — removing its chunks`);
1466
+ let removedChunks = 0;
1336
1467
  if (dbConnection.type === 'sqlite') {
1337
- database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1468
+ removedChunks = database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1338
1469
  }
1339
1470
  else {
1340
- await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1471
+ removedChunks = await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1472
+ }
1473
+ if (removedChunks > 0) {
1474
+ this.counters.items_deleted++;
1475
+ this.counters.chunks_deleted += removedChunks;
1341
1476
  }
1342
1477
  return;
1343
1478
  }
@@ -1599,6 +1734,7 @@ class Doc2Vec {
1599
1734
  newHashesSorted.every((h, i) => h === existingHashesSorted[i]);
1600
1735
  if (unchanged) {
1601
1736
  logger.info(`Skipping unchanged URL (${chunks.length} chunks): ${url}`);
1737
+ this.counters.items_unchanged++;
1602
1738
  return 0;
1603
1739
  }
1604
1740
  // 4. Content changed — delete all existing chunks for this URL
@@ -1610,6 +1746,11 @@ class Doc2Vec {
1610
1746
  else {
1611
1747
  await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1612
1748
  }
1749
+ this.counters.items_updated++;
1750
+ this.counters.chunks_deleted += existingHashesSorted.length;
1751
+ }
1752
+ else {
1753
+ this.counters.items_new++;
1613
1754
  }
1614
1755
  // 5. Embed and insert all new chunks
1615
1756
  let embeddedCount = 0;
@@ -1637,6 +1778,7 @@ class Doc2Vec {
1637
1778
  }
1638
1779
  }
1639
1780
  chunkProgress.complete();
1781
+ this.counters.chunks_added += embeddedCount;
1640
1782
  return embeddedCount;
1641
1783
  }
1642
1784
  async createEmbeddings(texts) {