@kaelio/ktx 0.1.0 → 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.
Files changed (122) hide show
  1. package/assets/python/{kaelio_ktx-0.1.0-py3-none-any.whl → kaelio_ktx-0.2.0-py3-none-any.whl} +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/admin-reindex.d.ts +15 -0
  4. package/dist/admin-reindex.js +168 -0
  5. package/dist/admin-reindex.test.js +116 -0
  6. package/dist/{dev.d.ts → admin.d.ts} +1 -1
  7. package/dist/{dev.js → admin.js} +14 -12
  8. package/dist/admin.test.d.ts +1 -0
  9. package/dist/{dev.test.js → admin.test.js} +36 -31
  10. package/dist/cli-program.js +7 -7
  11. package/dist/cli-program.test.js +1 -1
  12. package/dist/cli-runtime.d.ts +2 -0
  13. package/dist/commands/connection-commands.js +11 -10
  14. package/dist/commands/connection-selection.d.ts +11 -0
  15. package/dist/commands/connection-selection.js +9 -0
  16. package/dist/commands/ingest-commands.js +32 -26
  17. package/dist/commands/knowledge-commands.js +17 -28
  18. package/dist/commands/mcp-commands.js +17 -11
  19. package/dist/commands/setup-commands.js +14 -26
  20. package/dist/commands/sl-commands.js +27 -32
  21. package/dist/doctor.test.js +7 -8
  22. package/dist/example-smoke.test.js +3 -3
  23. package/dist/index.test.js +102 -70
  24. package/dist/ingest-depth.js +0 -1
  25. package/dist/ingest.test-utils.js +2 -2
  26. package/dist/ingest.test.js +4 -4
  27. package/dist/io/print-list.test.js +4 -4
  28. package/dist/knowledge.js +1 -1
  29. package/dist/managed-local-embeddings.d.ts +2 -0
  30. package/dist/managed-local-embeddings.js +2 -0
  31. package/dist/managed-local-embeddings.test.js +2 -0
  32. package/dist/managed-mcp-daemon.js +3 -2
  33. package/dist/managed-mcp-daemon.test.js +25 -0
  34. package/dist/managed-python-command.js +2 -2
  35. package/dist/managed-python-command.test.js +4 -3
  36. package/dist/managed-python-daemon.js +3 -2
  37. package/dist/managed-python-daemon.test.js +20 -0
  38. package/dist/managed-python-runtime.d.ts +5 -1
  39. package/dist/managed-python-runtime.js +50 -6
  40. package/dist/managed-python-runtime.test.js +53 -23
  41. package/dist/memory-flow-tui.test.js +2 -2
  42. package/dist/next-steps.d.ts +6 -6
  43. package/dist/next-steps.js +4 -4
  44. package/dist/next-steps.test.js +5 -5
  45. package/dist/print-command-tree.test.js +1 -1
  46. package/dist/proxy-env.d.ts +1 -0
  47. package/dist/proxy-env.js +23 -0
  48. package/dist/proxy-env.test.d.ts +1 -0
  49. package/dist/proxy-env.test.js +17 -0
  50. package/dist/public-ingest.js +3 -5
  51. package/dist/public-ingest.test.js +7 -3
  52. package/dist/runtime.test.js +2 -1
  53. package/dist/scan.test.js +2 -2
  54. package/dist/setup-agents.js +6 -4
  55. package/dist/setup-agents.test.js +35 -1
  56. package/dist/setup-embeddings.d.ts +1 -0
  57. package/dist/setup-embeddings.js +29 -7
  58. package/dist/setup-embeddings.test.js +49 -7
  59. package/dist/setup-models.d.ts +0 -1
  60. package/dist/setup-models.js +2 -3
  61. package/dist/setup-models.test.js +8 -10
  62. package/dist/setup-project.d.ts +9 -1
  63. package/dist/setup-project.js +52 -25
  64. package/dist/setup-project.test.js +8 -8
  65. package/dist/setup-runtime.test.js +4 -2
  66. package/dist/setup.d.ts +1 -2
  67. package/dist/setup.js +21 -5
  68. package/dist/setup.test.js +160 -43
  69. package/dist/sl.js +1 -1
  70. package/dist/sl.test.js +2 -1
  71. package/dist/standalone-smoke.test.js +8 -5
  72. package/dist/status-project.js +1 -10
  73. package/node_modules/@ktx/context/dist/index-sync/index.d.ts +2 -0
  74. package/node_modules/@ktx/context/dist/index-sync/index.js +1 -0
  75. package/node_modules/@ktx/context/dist/index-sync/reindex.d.ts +20 -0
  76. package/node_modules/@ktx/context/dist/index-sync/reindex.js +141 -0
  77. package/node_modules/@ktx/context/dist/index-sync/reindex.test.d.ts +1 -0
  78. package/node_modules/@ktx/context/dist/index-sync/reindex.test.js +139 -0
  79. package/node_modules/@ktx/context/dist/index-sync/types.d.ts +29 -0
  80. package/node_modules/@ktx/context/dist/index-sync/types.js +1 -0
  81. package/node_modules/@ktx/context/dist/index.d.ts +1 -0
  82. package/node_modules/@ktx/context/dist/index.js +1 -0
  83. package/node_modules/@ktx/context/dist/ingest/adapters/historic-sql/local-ingest-acceptance.test.js +1 -1
  84. package/node_modules/@ktx/context/dist/ingest/local-bundle-ingest.test.js +8 -8
  85. package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.js +4 -1
  86. package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.test.js +3 -3
  87. package/node_modules/@ktx/context/dist/ingest/local-embedding-provider.integration.test.js +9 -10
  88. package/node_modules/@ktx/context/dist/ingest/memory-flow/schema.d.ts +2 -2
  89. package/node_modules/@ktx/context/dist/ingest/report-snapshot.d.ts +2 -2
  90. package/node_modules/@ktx/context/dist/llm/local-config.js +2 -15
  91. package/node_modules/@ktx/context/dist/llm/local-config.test.js +3 -7
  92. package/node_modules/@ktx/context/dist/memory/local-memory.js +9 -3
  93. package/node_modules/@ktx/context/dist/project/config.d.ts +0 -5
  94. package/node_modules/@ktx/context/dist/project/config.js +5 -5
  95. package/node_modules/@ktx/context/dist/project/config.test.js +4 -7
  96. package/node_modules/@ktx/context/dist/scan/enrichment-state.test.js +4 -4
  97. package/node_modules/@ktx/context/dist/scan/index.d.ts +1 -1
  98. package/node_modules/@ktx/context/dist/scan/local-enrichment.d.ts +2 -6
  99. package/node_modules/@ktx/context/dist/scan/local-enrichment.js +31 -47
  100. package/node_modules/@ktx/context/dist/scan/local-enrichment.test.js +35 -18
  101. package/node_modules/@ktx/context/dist/scan/local-scan.test.js +2 -3
  102. package/node_modules/@ktx/context/dist/sl/ports.d.ts +3 -3
  103. package/node_modules/@ktx/context/dist/sl/sl-search.service.d.ts +3 -2
  104. package/node_modules/@ktx/context/dist/sl/sl-search.service.js +47 -45
  105. package/node_modules/@ktx/context/dist/sl/sl-search.service.test.js +61 -0
  106. package/node_modules/@ktx/context/dist/sl/sqlite-sl-sources-index.d.ts +4 -3
  107. package/node_modules/@ktx/context/dist/sl/sqlite-sl-sources-index.js +15 -5
  108. package/node_modules/@ktx/context/dist/sl/sqlite-sl-sources-index.test.js +24 -0
  109. package/node_modules/@ktx/context/dist/wiki/knowledge-wiki.service.d.ts +3 -2
  110. package/node_modules/@ktx/context/dist/wiki/knowledge-wiki.service.js +62 -51
  111. package/node_modules/@ktx/context/dist/wiki/knowledge-wiki.service.test.js +59 -3
  112. package/node_modules/@ktx/context/dist/wiki/ports.d.ts +3 -3
  113. package/node_modules/@ktx/context/dist/wiki/sqlite-knowledge-index.d.ts +33 -0
  114. package/node_modules/@ktx/context/dist/wiki/sqlite-knowledge-index.js +155 -2
  115. package/node_modules/@ktx/context/dist/wiki/sqlite-knowledge-index.test.js +26 -0
  116. package/node_modules/@ktx/context/package.json +5 -0
  117. package/node_modules/@ktx/llm/dist/embedding-provider.d.ts +0 -7
  118. package/node_modules/@ktx/llm/dist/embedding-provider.js +12 -138
  119. package/node_modules/@ktx/llm/dist/embedding-provider.test.js +10 -25
  120. package/node_modules/@ktx/llm/dist/types.d.ts +1 -1
  121. package/package.json +1 -1
  122. /package/dist/{dev.test.d.ts → admin-reindex.test.d.ts} +0 -0
@@ -192,4 +192,65 @@ describe('SlSearchService', () => {
192
192
  },
193
193
  ]);
194
194
  });
195
+ it('indexSources reports stats and supports lexical-only indexing', async () => {
196
+ const repository = {
197
+ upsertSources: vi.fn().mockResolvedValue(undefined),
198
+ getExistingSearchTexts: vi.fn().mockResolvedValue(new Map([
199
+ ['old_source', { searchText: 'old source', hasEmbedding: true }],
200
+ ])),
201
+ deleteStale: vi.fn().mockResolvedValue(1),
202
+ deleteByConnection: vi.fn().mockResolvedValue(0),
203
+ deleteByConnectionAndName: vi.fn(),
204
+ search: vi.fn(),
205
+ };
206
+ const service = new SlSearchService(null, repository);
207
+ const source = {
208
+ name: 'orders',
209
+ table: 'public.orders',
210
+ grain: ['id'],
211
+ columns: [{ name: 'id', type: 'number' }],
212
+ joins: [],
213
+ measures: [],
214
+ };
215
+ await expect(service.indexSources('warehouse', [source])).resolves.toEqual({
216
+ scanned: 1,
217
+ updated: 1,
218
+ deleted: 1,
219
+ embeddingsRecomputed: 0,
220
+ embeddingsFailed: 0,
221
+ });
222
+ expect(repository.upsertSources).toHaveBeenCalledWith('warehouse', [
223
+ expect.objectContaining({ sourceName: 'orders', embedding: null }),
224
+ ]);
225
+ });
226
+ it('does not update unchanged lexical-only SL rows on repeated sync', async () => {
227
+ const repository = {
228
+ upsertSources: vi.fn().mockResolvedValue(undefined),
229
+ getExistingSearchTexts: vi.fn().mockResolvedValue(new Map([
230
+ ['orders', { searchText: 'orders. table: public.orders. id (number)', hasEmbedding: false }],
231
+ ])),
232
+ deleteStale: vi.fn().mockResolvedValue(0),
233
+ deleteByConnection: vi.fn().mockResolvedValue(0),
234
+ deleteByConnectionAndName: vi.fn(),
235
+ search: vi.fn(),
236
+ };
237
+ const service = new SlSearchService(null, repository);
238
+ const source = {
239
+ name: 'orders',
240
+ table: 'public.orders',
241
+ grain: ['id'],
242
+ columns: [{ name: 'id', type: 'number' }],
243
+ joins: [],
244
+ measures: [],
245
+ };
246
+ await expect(service.indexSources('warehouse', [source])).resolves.toEqual({
247
+ scanned: 1,
248
+ updated: 0,
249
+ deleted: 0,
250
+ embeddingsRecomputed: 0,
251
+ embeddingsFailed: 0,
252
+ });
253
+ expect(repository.upsertSources).toHaveBeenCalledWith('warehouse', []);
254
+ expect(repository.deleteStale).toHaveBeenCalledWith('warehouse', ['orders']);
255
+ });
195
256
  });
@@ -28,9 +28,10 @@ export declare class SqliteSlSourcesIndex implements SlSourcesIndexPort {
28
28
  searchText: string;
29
29
  hasEmbedding: boolean;
30
30
  }>>;
31
- deleteStale(connectionId: string, keepNames: string[]): Promise<void>;
32
- deleteByConnection(connectionId: string): Promise<void>;
33
- deleteByConnectionAndName(connectionId: string, sourceName: string): Promise<void>;
31
+ deleteStale(connectionId: string, keepNames: string[]): Promise<number>;
32
+ deleteByConnection(connectionId: string): Promise<number>;
33
+ clear(connectionId: string): Promise<number>;
34
+ deleteByConnectionAndName(connectionId: string, sourceName: string): Promise<number>;
34
35
  replaceDictionaryEntries(connectionId: string, entries: SlDictionaryEntry[]): Promise<void>;
35
36
  searchLexicalCandidates(input: {
36
37
  connectionIds?: readonly string[];
@@ -144,8 +144,7 @@ export class SqliteSlSourcesIndex {
144
144
  }
145
145
  async deleteStale(connectionId, keepNames) {
146
146
  if (keepNames.length === 0) {
147
- await this.deleteByConnection(connectionId);
148
- return;
147
+ return this.deleteByConnection(connectionId);
149
148
  }
150
149
  const placeholders = keepNames.map(() => '?').join(', ');
151
150
  const stale = this.db
@@ -173,16 +172,26 @@ export class SqliteSlSourcesIndex {
173
172
  }
174
173
  });
175
174
  remove(stale.map((row) => row.source_name));
175
+ return stale.length;
176
176
  }
177
177
  async deleteByConnection(connectionId) {
178
+ return this.clear(connectionId);
179
+ }
180
+ async clear(connectionId) {
181
+ const rows = this.db
182
+ .prepare('SELECT source_name FROM local_sl_sources WHERE connection_id = ?')
183
+ .all(connectionId);
178
184
  const remove = this.db.transaction(() => {
179
185
  this.db.prepare('DELETE FROM local_sl_sources_fts WHERE connection_id = ?').run(connectionId);
180
186
  this.db.prepare('DELETE FROM local_sl_sources WHERE connection_id = ?').run(connectionId);
187
+ this.db.prepare('DELETE FROM local_sl_dictionary_values_fts WHERE connection_id = ?').run(connectionId);
188
+ this.db.prepare('DELETE FROM local_sl_dictionary_values WHERE connection_id = ?').run(connectionId);
181
189
  });
182
190
  remove();
191
+ return rows.length;
183
192
  }
184
193
  async deleteByConnectionAndName(connectionId, sourceName) {
185
- this.deleteByConnectionAndNameSync(connectionId, sourceName);
194
+ return this.deleteByConnectionAndNameSync(connectionId, sourceName);
186
195
  }
187
196
  async replaceDictionaryEntries(connectionId, entries) {
188
197
  const remove = this.db.transaction(() => {
@@ -406,14 +415,15 @@ export class SqliteSlSourcesIndex {
406
415
  AND source_name = ?
407
416
  `)
408
417
  .run(connectionId, sourceName);
409
- this.db
418
+ const result = this.db
410
419
  .prepare(`
411
420
  DELETE FROM local_sl_sources
412
421
  WHERE connection_id = ?
413
422
  AND source_name = ?
414
423
  `)
415
424
  .run(connectionId, sourceName);
425
+ return Number(result.changes);
416
426
  });
417
- remove();
427
+ return remove();
418
428
  }
419
429
  }
@@ -86,6 +86,30 @@ describe('SqliteSlSourcesIndex', () => {
86
86
  await index.deleteByConnection('finance');
87
87
  expect(await index.search('finance', null, 'revenue', 10)).toEqual([]);
88
88
  });
89
+ it('clear removes sources and dictionary rows for one connection only', async () => {
90
+ const index = new SqliteSlSourcesIndex({ dbPath });
91
+ await index.upsertSources('warehouse', [
92
+ { sourceName: 'orders', searchText: 'orders revenue paid', embedding: null },
93
+ ]);
94
+ await index.upsertSources('finance', [
95
+ { sourceName: 'invoices', searchText: 'invoices revenue paid', embedding: null },
96
+ ]);
97
+ await index.replaceDictionaryEntries('warehouse', [
98
+ { connectionId: 'warehouse', sourceName: 'orders', columnName: 'status', value: 'paid', cardinality: 1 },
99
+ ]);
100
+ await index.replaceDictionaryEntries('finance', [
101
+ { connectionId: 'finance', sourceName: 'invoices', columnName: 'status', value: 'paid', cardinality: 1 },
102
+ ]);
103
+ await expect(index.clear('warehouse')).resolves.toBe(1);
104
+ expect(await index.search('warehouse', null, 'revenue', 10)).toEqual([]);
105
+ expect(await index.search('finance', null, 'revenue', 10)).toEqual([
106
+ expect.objectContaining({ sourceName: 'invoices' }),
107
+ ]);
108
+ await expect(index.searchDictionaryCandidates({ connectionIds: ['warehouse'], queryText: 'paid', limit: 10 }))
109
+ .resolves.toEqual([]);
110
+ await expect(index.searchDictionaryCandidates({ connectionIds: ['finance'], queryText: 'paid', limit: 10 }))
111
+ .resolves.toEqual([expect.objectContaining({ connectionId: 'finance', sourceName: 'invoices' })]);
112
+ });
89
113
  it('returns lane candidates with stable connection-scoped IDs', async () => {
90
114
  const index = new SqliteSlSourcesIndex({ dbPath });
91
115
  await index.upsertSources('warehouse', [
@@ -1,4 +1,5 @@
1
1
  import type { KtxEmbeddingPort, KtxFileStorePort, KtxLogger } from '../core/index.js';
2
+ import type { ReindexWorkResult } from '../index-sync/types.js';
2
3
  import type { KnowledgeGitDiffPort, KnowledgeIndexPort } from './ports.js';
3
4
  import type { WikiFrontmatter, WikiPage, WikiPageWithScope } from './types.js';
4
5
  export type { WikiFrontmatter };
@@ -9,7 +10,7 @@ export declare class KnowledgeWikiService {
9
10
  private readonly gitService;
10
11
  private readonly logger;
11
12
  private isWorktreeScoped;
12
- constructor(configService: KtxFileStorePort, embeddingService: KtxEmbeddingPort, pagesRepository: KnowledgeIndexPort, gitService: KnowledgeGitDiffPort, logger?: KtxLogger);
13
+ constructor(configService: KtxFileStorePort, embeddingService: KtxEmbeddingPort | null, pagesRepository: KnowledgeIndexPort, gitService: KnowledgeGitDiffPort, logger?: KtxLogger);
13
14
  /**
14
15
  * Return a clone of this service whose disk writes go through a worktree-scoped
15
16
  * ConfigService AND whose DB-index writes are no-ops. Used by memory-agent
@@ -56,7 +57,7 @@ export declare class KnowledgeWikiService {
56
57
  * Full sync: load all pages from disk for a scope, reindex changed pages, clean stale entries.
57
58
  * Mirrors SlSearchService.indexSources() pattern.
58
59
  */
59
- syncIndex(scope: string, scopeId?: string | null): Promise<void>;
60
+ syncIndex(scope: string, scopeId?: string | null): Promise<ReindexWorkResult>;
60
61
  /**
61
62
  * Delete a page from the DB index (after file deletion).
62
63
  */
@@ -172,11 +172,13 @@ export class KnowledgeWikiService {
172
172
  }
173
173
  const searchText = buildKnowledgeSearchText(pageKey, frontmatter.summary, content, frontmatter.tags);
174
174
  let embedding = null;
175
- try {
176
- embedding = await this.embeddingService.computeEmbedding(searchText);
177
- }
178
- catch (err) {
179
- this.logger.warn(`Embedding failed for page "${pageKey}": ${err instanceof Error ? err.message : String(err)}`);
175
+ if (this.embeddingService) {
176
+ try {
177
+ embedding = await this.embeddingService.computeEmbedding(searchText);
178
+ }
179
+ catch (err) {
180
+ this.logger.warn(`Embedding failed for page "${pageKey}": ${err instanceof Error ? err.message : String(err)}`);
181
+ }
180
182
  }
181
183
  await this.pagesRepository.upsertPage({
182
184
  scope,
@@ -196,11 +198,17 @@ export class KnowledgeWikiService {
196
198
  */
197
199
  async syncIndex(scope, scopeId) {
198
200
  const pageKeys = await this.listPageKeys(scope, scopeId);
201
+ const existing = await this.pagesRepository.getExistingSearchTexts(scope, scopeId ?? null);
199
202
  if (pageKeys.length === 0) {
200
- await this.pagesRepository.deleteByScope(scope, scopeId ?? null);
201
- return;
203
+ const deleted = await this.pagesRepository.deleteByScope(scope, scopeId ?? null);
204
+ return {
205
+ scanned: 0,
206
+ updated: 0,
207
+ deleted,
208
+ embeddingsRecomputed: 0,
209
+ embeddingsFailed: 0,
210
+ };
202
211
  }
203
- // Load and parse all pages
204
212
  const pages = [];
205
213
  for (const key of pageKeys) {
206
214
  const page = await this.readPage(scope, scopeId, key);
@@ -209,52 +217,53 @@ export class KnowledgeWikiService {
209
217
  pages.push({ pageKey: key, frontmatter: page.frontmatter, content: page.content, searchText });
210
218
  }
211
219
  }
212
- // Detect changes
213
- const existing = await this.pagesRepository.getExistingSearchTexts(scope, scopeId ?? null);
214
- const changedPages = pages.filter((p) => {
215
- const ex = existing.get(p.pageKey);
216
- return !ex || ex.searchText !== p.searchText || !ex.hasEmbedding;
220
+ const embeddingService = this.embeddingService;
221
+ const changedPages = pages.filter((page) => {
222
+ const previous = existing.get(page.pageKey);
223
+ return (!previous ||
224
+ previous.searchText !== page.searchText ||
225
+ (embeddingService !== null && !previous.hasEmbedding));
217
226
  });
218
- if (changedPages.length === 0) {
219
- // Still clean up stale
220
- await this.pagesRepository.deleteStale(scope, scopeId ?? null, pageKeys);
221
- this.logger.log(`Wiki sync ${scope}: all ${pages.length} pages up to date`);
222
- return;
223
- }
224
- // Compute embeddings for changed pages (batched)
225
- const changedTexts = changedPages.map((p) => p.searchText);
226
- let embeddings;
227
- try {
228
- const batchSize = this.embeddingService.maxBatchSize;
229
- const all = [];
230
- for (let i = 0; i < changedTexts.length; i += batchSize) {
231
- const batch = changedTexts.slice(i, i + batchSize);
232
- const batchEmb = await this.embeddingService.computeEmbeddingsBulk(batch);
233
- all.push(...batchEmb);
227
+ let embeddings = changedPages.map(() => null);
228
+ let embeddingsRecomputed = 0;
229
+ let embeddingsFailed = 0;
230
+ if (embeddingService && changedPages.length > 0) {
231
+ try {
232
+ const changedTexts = changedPages.map((page) => page.searchText);
233
+ const all = [];
234
+ for (let i = 0; i < changedTexts.length; i += embeddingService.maxBatchSize) {
235
+ const batch = changedTexts.slice(i, i + embeddingService.maxBatchSize);
236
+ all.push(...(await embeddingService.computeEmbeddingsBulk(batch)));
237
+ }
238
+ embeddings = all;
239
+ embeddingsRecomputed = all.length;
240
+ }
241
+ catch (err) {
242
+ this.logger.warn(`Embedding batch failed during sync: ${err instanceof Error ? err.message : String(err)}`);
243
+ embeddingsFailed = changedPages.length;
234
244
  }
235
- embeddings = all;
236
- }
237
- catch (err) {
238
- this.logger.warn(`Embedding batch failed during sync: ${err instanceof Error ? err.message : String(err)}`);
239
- embeddings = changedPages.map(() => null);
240
245
  }
241
- // Upsert changed pages
242
- for (let i = 0; i < changedPages.length; i++) {
243
- const p = changedPages[i];
246
+ for (let i = 0; i < changedPages.length; i += 1) {
247
+ const page = changedPages[i];
244
248
  await this.pagesRepository.upsertPage({
245
249
  scope,
246
250
  scopeId: scopeId ?? null,
247
- pageKey: p.pageKey,
248
- summary: p.frontmatter.summary,
249
- usageMode: p.frontmatter.usage_mode,
250
- sortOrder: p.frontmatter.sort_order ?? 0,
251
- searchText: p.searchText,
252
- embedding: embeddings[i],
251
+ pageKey: page.pageKey,
252
+ summary: page.frontmatter.summary,
253
+ usageMode: page.frontmatter.usage_mode,
254
+ sortOrder: page.frontmatter.sort_order ?? 0,
255
+ searchText: page.searchText,
256
+ embedding: embeddings[i] ?? null,
253
257
  });
254
258
  }
255
- // Clean stale entries
256
- await this.pagesRepository.deleteStale(scope, scopeId ?? null, pageKeys);
257
- this.logger.log(`Wiki sync ${scope}: ${changedPages.length}/${pages.length} reindexed, ${pages.length - changedPages.length} unchanged`);
259
+ const deleted = await this.pagesRepository.deleteStale(scope, scopeId ?? null, pageKeys);
260
+ return {
261
+ scanned: pages.length,
262
+ updated: changedPages.length,
263
+ deleted,
264
+ embeddingsRecomputed,
265
+ embeddingsFailed,
266
+ };
258
267
  }
259
268
  /**
260
269
  * Delete a page from the DB index (after file deletion).
@@ -297,11 +306,13 @@ export class KnowledgeWikiService {
297
306
  const parsed = this.parsePage(content);
298
307
  const searchText = buildKnowledgeSearchText(parsedPath.pageKey, parsed.frontmatter.summary, parsed.content, parsed.frontmatter.tags);
299
308
  let embedding = null;
300
- try {
301
- embedding = await this.embeddingService.computeEmbedding(searchText);
302
- }
303
- catch (err) {
304
- this.logger.warn(`[wiki.sync] embedding failed for ${parsedPath.pageKey}: ${err instanceof Error ? err.message : String(err)}`);
309
+ if (this.embeddingService) {
310
+ try {
311
+ embedding = await this.embeddingService.computeEmbedding(searchText);
312
+ }
313
+ catch (err) {
314
+ this.logger.warn(`[wiki.sync] embedding failed for ${parsedPath.pageKey}: ${err instanceof Error ? err.message : String(err)}`);
315
+ }
305
316
  }
306
317
  const contentHash = createHash('sha256').update(content).digest('hex');
307
318
  upserts.push({
@@ -3,9 +3,9 @@ import { KnowledgeWikiService } from './knowledge-wiki.service.js';
3
3
  function makeService() {
4
4
  const pagesRepository = {
5
5
  upsertPage: vi.fn().mockResolvedValue(undefined),
6
- deleteByKey: vi.fn().mockResolvedValue(undefined),
7
- deleteByScope: vi.fn().mockResolvedValue(undefined),
8
- deleteStale: vi.fn().mockResolvedValue(undefined),
6
+ deleteByKey: vi.fn().mockResolvedValue(0),
7
+ deleteByScope: vi.fn().mockResolvedValue(0),
8
+ deleteStale: vi.fn().mockResolvedValue(0),
9
9
  getExistingSearchTexts: vi.fn().mockResolvedValue(new Map()),
10
10
  applyDiffTransactional: vi.fn().mockResolvedValue(undefined),
11
11
  };
@@ -41,6 +41,62 @@ function makeService() {
41
41
  return { service, pagesRepository, embeddingService, configService, gitService, logger };
42
42
  }
43
43
  const fm = { summary: 'sum', usage_mode: 'auto' };
44
+ describe('KnowledgeWikiService.syncIndex result stats', () => {
45
+ it('reports scanned, updated, deleted, and embedding counts', async () => {
46
+ const { service, pagesRepository, embeddingService, configService } = makeService();
47
+ configService.listFiles.mockResolvedValue({ files: ['wiki/global/revenue.md'] });
48
+ configService.readFile.mockResolvedValue({
49
+ content: '---\nsummary: Revenue\nusage_mode: auto\ntags:\n - finance\n---\n\nPaid orders.\n',
50
+ });
51
+ pagesRepository.getExistingSearchTexts.mockResolvedValue(new Map([
52
+ ['old-page', { searchText: 'old', hasEmbedding: true }],
53
+ ]));
54
+ embeddingService.computeEmbeddingsBulk.mockResolvedValue([[0.1, 0.2, 0.3]]);
55
+ pagesRepository.deleteStale.mockResolvedValue(1);
56
+ await expect(service.syncIndex('GLOBAL', null)).resolves.toEqual({
57
+ scanned: 1,
58
+ updated: 1,
59
+ deleted: 1,
60
+ embeddingsRecomputed: 1,
61
+ embeddingsFailed: 0,
62
+ });
63
+ });
64
+ it('indexes lexical rows when embeddings are not configured', async () => {
65
+ const { pagesRepository, configService, gitService, logger } = makeService();
66
+ const service = new KnowledgeWikiService(configService, null, pagesRepository, gitService, logger);
67
+ configService.listFiles.mockResolvedValue({ files: ['wiki/global/revenue.md'] });
68
+ configService.readFile.mockResolvedValue({
69
+ content: '---\nsummary: Revenue\nusage_mode: auto\n---\n\nPaid orders.\n',
70
+ });
71
+ pagesRepository.getExistingSearchTexts.mockResolvedValue(new Map());
72
+ pagesRepository.deleteStale.mockResolvedValue(0);
73
+ const result = await service.syncIndex('GLOBAL', null);
74
+ expect(result.embeddingsRecomputed).toBe(0);
75
+ expect(result.embeddingsFailed).toBe(0);
76
+ expect(pagesRepository.upsertPage).toHaveBeenCalledWith(expect.objectContaining({ pageKey: 'revenue', embedding: null }));
77
+ });
78
+ it('does not update unchanged lexical-only wiki rows on repeated sync', async () => {
79
+ const { pagesRepository, configService, gitService, logger } = makeService();
80
+ const service = new KnowledgeWikiService(configService, null, pagesRepository, gitService, logger);
81
+ configService.listFiles.mockResolvedValue({ files: ['wiki/global/revenue.md'] });
82
+ configService.readFile.mockResolvedValue({
83
+ content: '---\nsummary: Revenue\nusage_mode: auto\n---\n\nPaid orders.\n',
84
+ });
85
+ pagesRepository.getExistingSearchTexts.mockResolvedValue(new Map([
86
+ ['revenue', { searchText: 'revenue\nRevenue\nPaid orders.', hasEmbedding: false }],
87
+ ]));
88
+ pagesRepository.deleteStale.mockResolvedValue(0);
89
+ await expect(service.syncIndex('GLOBAL', null)).resolves.toEqual({
90
+ scanned: 1,
91
+ updated: 0,
92
+ deleted: 0,
93
+ embeddingsRecomputed: 0,
94
+ embeddingsFailed: 0,
95
+ });
96
+ expect(pagesRepository.upsertPage).not.toHaveBeenCalled();
97
+ expect(pagesRepository.deleteStale).toHaveBeenCalledWith('GLOBAL', null, ['revenue']);
98
+ });
99
+ });
44
100
  describe('KnowledgeWikiService.forWorktree isolation', () => {
45
101
  it('syncSinglePage in worktree scope does not call pagesRepository.upsertPage', async () => {
46
102
  const { service, pagesRepository, embeddingService } = makeService();
@@ -34,9 +34,9 @@ export interface KnowledgeIndexPort {
34
34
  searchText: string;
35
35
  hasEmbedding: boolean;
36
36
  }>>;
37
- deleteStale(scope: string, scopeId: string | null, keepKeys: string[]): Promise<void>;
38
- deleteByScope(scope: string, scopeId: string | null): Promise<void>;
39
- deleteByKey(scope: string, scopeId: string | null, pageKey: string): Promise<void>;
37
+ deleteStale(scope: string, scopeId: string | null, keepKeys: string[]): Promise<number>;
38
+ deleteByScope(scope: string, scopeId: string | null): Promise<number>;
39
+ deleteByKey(scope: string, scopeId: string | null, pageKey: string): Promise<number>;
40
40
  findPageByKey(scope: string, scopeId: string | null, pageKey: string): Promise<{
41
41
  id?: string;
42
42
  page_key: string;
@@ -1,4 +1,5 @@
1
1
  import type { LocalKnowledgeScope } from './local-knowledge.js';
2
+ import type { KnowledgeIndexPageListing, UpsertPageParams } from './ports.js';
2
3
  export interface SqliteKnowledgeIndexOptions {
3
4
  dbPath: string;
4
5
  }
@@ -6,6 +7,7 @@ export interface SqliteKnowledgeIndexPage {
6
7
  path: string;
7
8
  key: string;
8
9
  scope: LocalKnowledgeScope;
10
+ scopeId?: string | null;
9
11
  summary: string;
10
12
  content: string;
11
13
  tags: string[];
@@ -40,4 +42,35 @@ export declare class SqliteKnowledgeIndex {
40
42
  limit: number;
41
43
  }): WikiSqliteLaneCandidate[];
42
44
  search(query: string, limit: number): SqliteKnowledgeIndexSearchResult[];
45
+ private pathForPage;
46
+ upsertPage(params: UpsertPageParams): Promise<void>;
47
+ getExistingSearchTexts(scope: string, scopeId: string | null): Promise<Map<string, {
48
+ searchText: string;
49
+ hasEmbedding: boolean;
50
+ }>>;
51
+ deleteStale(scope: string, scopeId: string | null, keepKeys: string[]): Promise<number>;
52
+ deleteByScope(scope: string, scopeId: string | null): Promise<number>;
53
+ deleteByKey(scope: string, scopeId: string | null, pageKey: string): Promise<number>;
54
+ clear(scope: string, scopeId: string | null): number;
55
+ applyDiffTransactional(params: {
56
+ runId: string;
57
+ upserts: UpsertPageParams[];
58
+ deletes: Array<{
59
+ scope: string;
60
+ scopeId: string | null;
61
+ pageKey: string;
62
+ }>;
63
+ }): Promise<void>;
64
+ findPageByKey(scope: string, scopeId: string | null, pageKey: string): Promise<{
65
+ id?: string;
66
+ page_key: string;
67
+ } | null>;
68
+ listPagesForUser(userId: string): Promise<KnowledgeIndexPageListing[]>;
69
+ getUserPageCount(userId: string): Promise<number>;
70
+ incrementUsageCount(): Promise<void>;
71
+ searchRRF(userId: string, _embedding: number[] | null, queryText: string, limit: number): Promise<Array<{
72
+ pageKey: string;
73
+ summary: string;
74
+ rrfScore: number;
75
+ }>>;
43
76
  }