@kaelio/ktx 0.1.1 → 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.1-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/sl-commands.js +27 -32
- package/dist/doctor.test.js +4 -4
- package/dist/example-smoke.test.js +3 -3
- package/dist/index.test.js +76 -60
- package/dist/io/print-list.test.js +4 -4
- package/dist/knowledge.js +1 -1
- package/dist/managed-python-command.js +2 -2
- package/dist/managed-python-command.test.js +3 -3
- package/dist/managed-python-runtime.d.ts +1 -1
- package/dist/managed-python-runtime.js +3 -3
- package/dist/managed-python-runtime.test.js +2 -2
- 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/public-ingest.js +3 -5
- package/dist/public-ingest.test.js +7 -3
- package/dist/runtime.test.js +1 -1
- package/dist/scan.test.js +2 -2
- package/dist/setup-agents.js +3 -3
- package/dist/setup-agents.test.js +1 -1
- package/dist/setup-embeddings.js +1 -1
- package/dist/setup-embeddings.test.js +3 -3
- package/dist/setup-runtime.test.js +2 -2
- package/dist/sl.js +1 -1
- package/dist/standalone-smoke.test.js +6 -2
- 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/local-bundle-runtime.js +3 -0
- 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/memory/local-memory.js +9 -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/package.json +1 -1
- /package/dist/{dev.test.d.ts → admin-reindex.test.d.ts} +0 -0
|
@@ -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
|
}
|
|
@@ -58,6 +58,7 @@ export class SqliteKnowledgeIndex {
|
|
|
58
58
|
path TEXT PRIMARY KEY,
|
|
59
59
|
key TEXT NOT NULL,
|
|
60
60
|
scope TEXT NOT NULL,
|
|
61
|
+
scope_id TEXT,
|
|
61
62
|
summary TEXT NOT NULL,
|
|
62
63
|
content TEXT NOT NULL,
|
|
63
64
|
tags TEXT NOT NULL,
|
|
@@ -81,6 +82,9 @@ export class SqliteKnowledgeIndex {
|
|
|
81
82
|
if (!columnNames.has('embedding_json')) {
|
|
82
83
|
this.db.exec('ALTER TABLE knowledge_pages ADD COLUMN embedding_json TEXT');
|
|
83
84
|
}
|
|
85
|
+
if (!columnNames.has('scope_id')) {
|
|
86
|
+
this.db.exec('ALTER TABLE knowledge_pages ADD COLUMN scope_id TEXT');
|
|
87
|
+
}
|
|
84
88
|
}
|
|
85
89
|
sync(pages) {
|
|
86
90
|
const keepPaths = pages.map((page) => page.path);
|
|
@@ -91,11 +95,12 @@ export class SqliteKnowledgeIndex {
|
|
|
91
95
|
? this.db.prepare('DELETE FROM knowledge_pages_fts')
|
|
92
96
|
: this.db.prepare(`DELETE FROM knowledge_pages_fts WHERE path NOT IN (${keepPaths.map(() => '?').join(', ')})`);
|
|
93
97
|
const upsertPage = this.db.prepare(`
|
|
94
|
-
INSERT INTO knowledge_pages (path, key, scope, summary, content, tags, search_text, embedding_json)
|
|
95
|
-
VALUES (@path, @key, @scope, @summary, @content, @tags, @searchText, @embeddingJson)
|
|
98
|
+
INSERT INTO knowledge_pages (path, key, scope, scope_id, summary, content, tags, search_text, embedding_json)
|
|
99
|
+
VALUES (@path, @key, @scope, @scopeId, @summary, @content, @tags, @searchText, @embeddingJson)
|
|
96
100
|
ON CONFLICT(path) DO UPDATE SET
|
|
97
101
|
key = excluded.key,
|
|
98
102
|
scope = excluded.scope,
|
|
103
|
+
scope_id = excluded.scope_id,
|
|
99
104
|
summary = excluded.summary,
|
|
100
105
|
content = excluded.content,
|
|
101
106
|
tags = excluded.tags,
|
|
@@ -116,6 +121,7 @@ export class SqliteKnowledgeIndex {
|
|
|
116
121
|
path: page.path,
|
|
117
122
|
key: page.key,
|
|
118
123
|
scope: page.scope,
|
|
124
|
+
scopeId: page.scopeId ?? null,
|
|
119
125
|
summary: page.summary,
|
|
120
126
|
content: searchText,
|
|
121
127
|
tags: page.tags.join(' '),
|
|
@@ -205,4 +211,151 @@ export class SqliteKnowledgeIndex {
|
|
|
205
211
|
score: scoreFromRank(row.rawScore),
|
|
206
212
|
}));
|
|
207
213
|
}
|
|
214
|
+
pathForPage(scope, scopeId, pageKey) {
|
|
215
|
+
return scope === 'GLOBAL' ? `wiki/global/${pageKey}.md` : `wiki/user/${scopeId ?? 'local'}/${pageKey}.md`;
|
|
216
|
+
}
|
|
217
|
+
async upsertPage(params) {
|
|
218
|
+
const path = this.pathForPage(params.scope, params.scopeId, params.pageKey);
|
|
219
|
+
const row = {
|
|
220
|
+
path,
|
|
221
|
+
key: params.pageKey,
|
|
222
|
+
scope: params.scope,
|
|
223
|
+
scopeId: params.scopeId,
|
|
224
|
+
summary: params.summary,
|
|
225
|
+
content: params.searchText,
|
|
226
|
+
tags: '',
|
|
227
|
+
searchText: params.searchText,
|
|
228
|
+
embeddingJson: params.embedding && params.embedding.length > 0 ? JSON.stringify(params.embedding) : null,
|
|
229
|
+
};
|
|
230
|
+
const write = this.db.transaction(() => {
|
|
231
|
+
this.db
|
|
232
|
+
.prepare(`
|
|
233
|
+
INSERT INTO knowledge_pages (path, key, scope, scope_id, summary, content, tags, search_text, embedding_json)
|
|
234
|
+
VALUES (@path, @key, @scope, @scopeId, @summary, @content, @tags, @searchText, @embeddingJson)
|
|
235
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
236
|
+
key = excluded.key,
|
|
237
|
+
scope = excluded.scope,
|
|
238
|
+
scope_id = excluded.scope_id,
|
|
239
|
+
summary = excluded.summary,
|
|
240
|
+
content = excluded.content,
|
|
241
|
+
tags = excluded.tags,
|
|
242
|
+
search_text = excluded.search_text,
|
|
243
|
+
embedding_json = excluded.embedding_json
|
|
244
|
+
`)
|
|
245
|
+
.run(row);
|
|
246
|
+
this.db.prepare('DELETE FROM knowledge_pages_fts WHERE path = @path').run(row);
|
|
247
|
+
this.db
|
|
248
|
+
.prepare(`
|
|
249
|
+
INSERT INTO knowledge_pages_fts (path, key, summary, content, tags)
|
|
250
|
+
VALUES (@path, @key, @summary, @content, @tags)
|
|
251
|
+
`)
|
|
252
|
+
.run(row);
|
|
253
|
+
});
|
|
254
|
+
write();
|
|
255
|
+
}
|
|
256
|
+
async getExistingSearchTexts(scope, scopeId) {
|
|
257
|
+
const rows = this.db
|
|
258
|
+
.prepare(`
|
|
259
|
+
SELECT key, search_text, embedding_json
|
|
260
|
+
FROM knowledge_pages
|
|
261
|
+
WHERE scope = ?
|
|
262
|
+
AND scope_id IS ?
|
|
263
|
+
ORDER BY key ASC
|
|
264
|
+
`)
|
|
265
|
+
.all(scope, scopeId);
|
|
266
|
+
return new Map(rows.map((row) => [row.key, { searchText: row.search_text, hasEmbedding: row.embedding_json !== null }]));
|
|
267
|
+
}
|
|
268
|
+
async deleteStale(scope, scopeId, keepKeys) {
|
|
269
|
+
if (keepKeys.length === 0) {
|
|
270
|
+
return this.deleteByScope(scope, scopeId);
|
|
271
|
+
}
|
|
272
|
+
const placeholders = keepKeys.map(() => '?').join(', ');
|
|
273
|
+
const stale = this.db
|
|
274
|
+
.prepare(`
|
|
275
|
+
SELECT key
|
|
276
|
+
FROM knowledge_pages
|
|
277
|
+
WHERE scope = ?
|
|
278
|
+
AND scope_id IS ?
|
|
279
|
+
AND key NOT IN (${placeholders})
|
|
280
|
+
`)
|
|
281
|
+
.all(scope, scopeId, ...keepKeys);
|
|
282
|
+
for (const row of stale) {
|
|
283
|
+
await this.deleteByKey(scope, scopeId, row.key);
|
|
284
|
+
}
|
|
285
|
+
return stale.length;
|
|
286
|
+
}
|
|
287
|
+
async deleteByScope(scope, scopeId) {
|
|
288
|
+
return this.clear(scope, scopeId);
|
|
289
|
+
}
|
|
290
|
+
async deleteByKey(scope, scopeId, pageKey) {
|
|
291
|
+
const path = this.pathForPage(scope, scopeId, pageKey);
|
|
292
|
+
const remove = this.db.transaction(() => {
|
|
293
|
+
this.db.prepare('DELETE FROM knowledge_pages_fts WHERE path = ?').run(path);
|
|
294
|
+
const result = this.db.prepare('DELETE FROM knowledge_pages WHERE path = ?').run(path);
|
|
295
|
+
return Number(result.changes);
|
|
296
|
+
});
|
|
297
|
+
return remove();
|
|
298
|
+
}
|
|
299
|
+
clear(scope, scopeId) {
|
|
300
|
+
const rows = this.db
|
|
301
|
+
.prepare('SELECT path FROM knowledge_pages WHERE scope = ? AND scope_id IS ?')
|
|
302
|
+
.all(scope, scopeId);
|
|
303
|
+
const remove = this.db.transaction((paths) => {
|
|
304
|
+
for (const path of paths) {
|
|
305
|
+
this.db.prepare('DELETE FROM knowledge_pages_fts WHERE path = ?').run(path);
|
|
306
|
+
this.db.prepare('DELETE FROM knowledge_pages WHERE path = ?').run(path);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
remove(rows.map((row) => row.path));
|
|
310
|
+
return rows.length;
|
|
311
|
+
}
|
|
312
|
+
async applyDiffTransactional(params) {
|
|
313
|
+
void params.runId;
|
|
314
|
+
for (const page of params.upserts) {
|
|
315
|
+
await this.upsertPage(page);
|
|
316
|
+
}
|
|
317
|
+
for (const page of params.deletes) {
|
|
318
|
+
await this.deleteByKey(page.scope, page.scopeId, page.pageKey);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async findPageByKey(scope, scopeId, pageKey) {
|
|
322
|
+
const path = this.pathForPage(scope, scopeId, pageKey);
|
|
323
|
+
const row = this.db.prepare('SELECT path, key FROM knowledge_pages WHERE path = ?').get(path);
|
|
324
|
+
return row ? { id: row.path, page_key: row.key } : null;
|
|
325
|
+
}
|
|
326
|
+
async listPagesForUser(userId) {
|
|
327
|
+
const rows = this.db
|
|
328
|
+
.prepare(`
|
|
329
|
+
SELECT path, key, scope, scope_id, summary, tags
|
|
330
|
+
FROM knowledge_pages
|
|
331
|
+
WHERE scope = 'GLOBAL'
|
|
332
|
+
OR (scope = 'USER' AND scope_id = ?)
|
|
333
|
+
ORDER BY scope ASC, key ASC
|
|
334
|
+
`)
|
|
335
|
+
.all(userId);
|
|
336
|
+
return rows.map((row) => ({
|
|
337
|
+
id: row.path,
|
|
338
|
+
page_key: row.key,
|
|
339
|
+
summary: row.summary,
|
|
340
|
+
scope: row.scope,
|
|
341
|
+
scope_id: row.scope_id,
|
|
342
|
+
tags: row.tags.split(/\s+/).filter(Boolean),
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
async getUserPageCount(userId) {
|
|
346
|
+
const row = this.db
|
|
347
|
+
.prepare("SELECT COUNT(*) AS count FROM knowledge_pages WHERE scope = 'USER' AND scope_id = ?")
|
|
348
|
+
.get(userId);
|
|
349
|
+
return row.count;
|
|
350
|
+
}
|
|
351
|
+
async incrementUsageCount() { }
|
|
352
|
+
async searchRRF(userId, _embedding, queryText, limit) {
|
|
353
|
+
const allowedPages = new Map((await this.listPagesForUser(userId)).map((page) => [page.id, page]));
|
|
354
|
+
return this.search(queryText, limit)
|
|
355
|
+
.map((row) => {
|
|
356
|
+
const page = allowedPages.get(row.path);
|
|
357
|
+
return page ? { pageKey: page.page_key, summary: page.summary, rrfScore: row.score } : null;
|
|
358
|
+
})
|
|
359
|
+
.filter((row) => row !== null);
|
|
360
|
+
}
|
|
208
361
|
}
|
|
@@ -54,6 +54,32 @@ describe('SqliteKnowledgeIndex', () => {
|
|
|
54
54
|
index.rebuild([page()]);
|
|
55
55
|
expect(index.search('churn', 10)).toEqual([]);
|
|
56
56
|
});
|
|
57
|
+
it('clear removes one wiki scope and leaves other scopes intact', async () => {
|
|
58
|
+
const index = new SqliteKnowledgeIndex({ dbPath });
|
|
59
|
+
index.sync([
|
|
60
|
+
page({ path: 'wiki/global/revenue.md', key: 'revenue', scope: 'GLOBAL', scopeId: null }),
|
|
61
|
+
page({
|
|
62
|
+
path: 'wiki/user/local/revenue.md',
|
|
63
|
+
key: 'revenue',
|
|
64
|
+
scope: 'USER',
|
|
65
|
+
scopeId: 'local',
|
|
66
|
+
summary: 'Local revenue',
|
|
67
|
+
content: 'Local revenue notes.',
|
|
68
|
+
}),
|
|
69
|
+
page({
|
|
70
|
+
path: 'wiki/user/alex/revenue.md',
|
|
71
|
+
key: 'revenue',
|
|
72
|
+
scope: 'USER',
|
|
73
|
+
scopeId: 'alex',
|
|
74
|
+
summary: 'Alex revenue',
|
|
75
|
+
content: 'Alex revenue notes.',
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
expect(index.clear('USER', 'local')).toBe(1);
|
|
79
|
+
expect(index.search('Local', 10)).toEqual([]);
|
|
80
|
+
expect(index.search('Alex', 10)).toEqual([expect.objectContaining({ path: 'wiki/user/alex/revenue.md' })]);
|
|
81
|
+
expect(index.search('definition', 10)).toEqual([expect.objectContaining({ path: 'wiki/global/revenue.md' })]);
|
|
82
|
+
});
|
|
57
83
|
it('exposes existing search text and embedding state for incremental refresh', () => {
|
|
58
84
|
const index = new SqliteKnowledgeIndex({ dbPath });
|
|
59
85
|
index.sync([page({ path: 'wiki/global/revenue.md', key: 'revenue', embedding: [1, 0] })]);
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
"import": "./dist/ingest/metabase-mapping.js",
|
|
47
47
|
"default": "./dist/ingest/metabase-mapping.js"
|
|
48
48
|
},
|
|
49
|
+
"./index-sync": {
|
|
50
|
+
"types": "./dist/index-sync/index.d.ts",
|
|
51
|
+
"import": "./dist/index-sync/index.js",
|
|
52
|
+
"default": "./dist/index-sync/index.js"
|
|
53
|
+
},
|
|
49
54
|
"./scan": {
|
|
50
55
|
"types": "./dist/scan/index.d.ts",
|
|
51
56
|
"import": "./dist/scan/index.js",
|
package/package.json
CHANGED
|
File without changes
|