@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.
- package/assets/python/{kaelio_ktx-0.1.0-py3-none-any.whl → kaelio_ktx-0.2.0-py3-none-any.whl} +0 -0
- package/assets/python/manifest.json +4 -4
- package/dist/admin-reindex.d.ts +15 -0
- package/dist/admin-reindex.js +168 -0
- package/dist/admin-reindex.test.js +116 -0
- package/dist/{dev.d.ts → admin.d.ts} +1 -1
- package/dist/{dev.js → admin.js} +14 -12
- package/dist/admin.test.d.ts +1 -0
- package/dist/{dev.test.js → admin.test.js} +36 -31
- package/dist/cli-program.js +7 -7
- package/dist/cli-program.test.js +1 -1
- package/dist/cli-runtime.d.ts +2 -0
- package/dist/commands/connection-commands.js +11 -10
- package/dist/commands/connection-selection.d.ts +11 -0
- package/dist/commands/connection-selection.js +9 -0
- package/dist/commands/ingest-commands.js +32 -26
- package/dist/commands/knowledge-commands.js +17 -28
- package/dist/commands/mcp-commands.js +17 -11
- package/dist/commands/setup-commands.js +14 -26
- package/dist/commands/sl-commands.js +27 -32
- package/dist/doctor.test.js +7 -8
- package/dist/example-smoke.test.js +3 -3
- package/dist/index.test.js +102 -70
- package/dist/ingest-depth.js +0 -1
- package/dist/ingest.test-utils.js +2 -2
- package/dist/ingest.test.js +4 -4
- package/dist/io/print-list.test.js +4 -4
- package/dist/knowledge.js +1 -1
- package/dist/managed-local-embeddings.d.ts +2 -0
- package/dist/managed-local-embeddings.js +2 -0
- package/dist/managed-local-embeddings.test.js +2 -0
- package/dist/managed-mcp-daemon.js +3 -2
- package/dist/managed-mcp-daemon.test.js +25 -0
- package/dist/managed-python-command.js +2 -2
- package/dist/managed-python-command.test.js +4 -3
- package/dist/managed-python-daemon.js +3 -2
- package/dist/managed-python-daemon.test.js +20 -0
- package/dist/managed-python-runtime.d.ts +5 -1
- package/dist/managed-python-runtime.js +50 -6
- package/dist/managed-python-runtime.test.js +53 -23
- package/dist/memory-flow-tui.test.js +2 -2
- package/dist/next-steps.d.ts +6 -6
- package/dist/next-steps.js +4 -4
- package/dist/next-steps.test.js +5 -5
- package/dist/print-command-tree.test.js +1 -1
- package/dist/proxy-env.d.ts +1 -0
- package/dist/proxy-env.js +23 -0
- package/dist/proxy-env.test.d.ts +1 -0
- package/dist/proxy-env.test.js +17 -0
- package/dist/public-ingest.js +3 -5
- package/dist/public-ingest.test.js +7 -3
- package/dist/runtime.test.js +2 -1
- package/dist/scan.test.js +2 -2
- package/dist/setup-agents.js +6 -4
- package/dist/setup-agents.test.js +35 -1
- package/dist/setup-embeddings.d.ts +1 -0
- package/dist/setup-embeddings.js +29 -7
- package/dist/setup-embeddings.test.js +49 -7
- package/dist/setup-models.d.ts +0 -1
- package/dist/setup-models.js +2 -3
- package/dist/setup-models.test.js +8 -10
- package/dist/setup-project.d.ts +9 -1
- package/dist/setup-project.js +52 -25
- package/dist/setup-project.test.js +8 -8
- package/dist/setup-runtime.test.js +4 -2
- package/dist/setup.d.ts +1 -2
- package/dist/setup.js +21 -5
- package/dist/setup.test.js +160 -43
- package/dist/sl.js +1 -1
- package/dist/sl.test.js +2 -1
- package/dist/standalone-smoke.test.js +8 -5
- package/dist/status-project.js +1 -10
- package/node_modules/@ktx/context/dist/index-sync/index.d.ts +2 -0
- package/node_modules/@ktx/context/dist/index-sync/index.js +1 -0
- package/node_modules/@ktx/context/dist/index-sync/reindex.d.ts +20 -0
- package/node_modules/@ktx/context/dist/index-sync/reindex.js +141 -0
- package/node_modules/@ktx/context/dist/index-sync/reindex.test.d.ts +1 -0
- package/node_modules/@ktx/context/dist/index-sync/reindex.test.js +139 -0
- package/node_modules/@ktx/context/dist/index-sync/types.d.ts +29 -0
- package/node_modules/@ktx/context/dist/index-sync/types.js +1 -0
- package/node_modules/@ktx/context/dist/index.d.ts +1 -0
- package/node_modules/@ktx/context/dist/index.js +1 -0
- package/node_modules/@ktx/context/dist/ingest/adapters/historic-sql/local-ingest-acceptance.test.js +1 -1
- package/node_modules/@ktx/context/dist/ingest/local-bundle-ingest.test.js +8 -8
- package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.js +4 -1
- package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.test.js +3 -3
- package/node_modules/@ktx/context/dist/ingest/local-embedding-provider.integration.test.js +9 -10
- package/node_modules/@ktx/context/dist/ingest/memory-flow/schema.d.ts +2 -2
- package/node_modules/@ktx/context/dist/ingest/report-snapshot.d.ts +2 -2
- package/node_modules/@ktx/context/dist/llm/local-config.js +2 -15
- package/node_modules/@ktx/context/dist/llm/local-config.test.js +3 -7
- package/node_modules/@ktx/context/dist/memory/local-memory.js +9 -3
- package/node_modules/@ktx/context/dist/project/config.d.ts +0 -5
- package/node_modules/@ktx/context/dist/project/config.js +5 -5
- package/node_modules/@ktx/context/dist/project/config.test.js +4 -7
- package/node_modules/@ktx/context/dist/scan/enrichment-state.test.js +4 -4
- package/node_modules/@ktx/context/dist/scan/index.d.ts +1 -1
- package/node_modules/@ktx/context/dist/scan/local-enrichment.d.ts +2 -6
- package/node_modules/@ktx/context/dist/scan/local-enrichment.js +31 -47
- package/node_modules/@ktx/context/dist/scan/local-enrichment.test.js +35 -18
- package/node_modules/@ktx/context/dist/scan/local-scan.test.js +2 -3
- package/node_modules/@ktx/context/dist/sl/ports.d.ts +3 -3
- package/node_modules/@ktx/context/dist/sl/sl-search.service.d.ts +3 -2
- package/node_modules/@ktx/context/dist/sl/sl-search.service.js +47 -45
- package/node_modules/@ktx/context/dist/sl/sl-search.service.test.js +61 -0
- package/node_modules/@ktx/context/dist/sl/sqlite-sl-sources-index.d.ts +4 -3
- package/node_modules/@ktx/context/dist/sl/sqlite-sl-sources-index.js +15 -5
- package/node_modules/@ktx/context/dist/sl/sqlite-sl-sources-index.test.js +24 -0
- package/node_modules/@ktx/context/dist/wiki/knowledge-wiki.service.d.ts +3 -2
- package/node_modules/@ktx/context/dist/wiki/knowledge-wiki.service.js +62 -51
- package/node_modules/@ktx/context/dist/wiki/knowledge-wiki.service.test.js +59 -3
- package/node_modules/@ktx/context/dist/wiki/ports.d.ts +3 -3
- package/node_modules/@ktx/context/dist/wiki/sqlite-knowledge-index.d.ts +33 -0
- package/node_modules/@ktx/context/dist/wiki/sqlite-knowledge-index.js +155 -2
- package/node_modules/@ktx/context/dist/wiki/sqlite-knowledge-index.test.js +26 -0
- package/node_modules/@ktx/context/package.json +5 -0
- package/node_modules/@ktx/llm/dist/embedding-provider.d.ts +0 -7
- package/node_modules/@ktx/llm/dist/embedding-provider.js +12 -138
- package/node_modules/@ktx/llm/dist/embedding-provider.test.js +10 -25
- package/node_modules/@ktx/llm/dist/types.d.ts +1 -1
- package/package.json +1 -1
- /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<
|
|
32
|
-
deleteByConnection(connectionId: string): Promise<
|
|
33
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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:
|
|
248
|
-
summary:
|
|
249
|
-
usageMode:
|
|
250
|
-
sortOrder:
|
|
251
|
-
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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(
|
|
7
|
-
deleteByScope: vi.fn().mockResolvedValue(
|
|
8
|
-
deleteStale: vi.fn().mockResolvedValue(
|
|
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<
|
|
38
|
-
deleteByScope(scope: string, scopeId: string | null): Promise<
|
|
39
|
-
deleteByKey(scope: string, scopeId: string | null, pageKey: string): Promise<
|
|
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
|
}
|