@lincoln504/pi-research 1.6.19 → 1.6.20
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/README.md +1 -1
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +343 -55
- package/dist/thread-worker.mjs +7 -0
- package/docs/CHANGELOG.md +16 -0
- package/docs/CONFIGURATION.md +3 -1
- package/docs/KNOWLEDGE-STORE.md +38 -4
- package/package.json +10 -10
- package/src/cli.ts +60 -7
- package/src/config.ts +35 -15
- package/src/core/pi-version.ts +12 -3
- package/src/healthcheck/index.ts +18 -0
- package/src/index.ts +3 -2
- package/src/infrastructure/browser/thread-worker.mjs +7 -0
- package/src/infrastructure/knowledge-store-service.ts +29 -4
- package/src/knowledge/availability.ts +33 -10
- package/src/knowledge/index.ts +17 -6
- package/src/knowledge/store-operations.ts +177 -0
- package/src/knowledge/store-schema.ts +58 -0
- package/src/knowledge/store.ts +118 -21
- package/src/research-config.ts +58 -18
package/src/knowledge/store.ts
CHANGED
|
@@ -21,8 +21,8 @@ import * as fsPromises from 'node:fs/promises';
|
|
|
21
21
|
import * as path from 'node:path';
|
|
22
22
|
import { MigrationStrategy, MigrationResult } from './migration.ts';
|
|
23
23
|
import { metrics } from '../utils/metrics.ts';
|
|
24
|
-
import { createStoreTable, CURRENT_SCHEMA_VERSION } from './store-schema.ts';
|
|
25
|
-
import { addDocumentsToStore, assertValidEmbeddingVectors, searchStore, findDocumentsByUrl, findRelevantUrls } from './store-operations.ts';
|
|
24
|
+
import { createStoreTable, createBm25StoreTable, BM25_TABLE_NAME, CURRENT_SCHEMA_VERSION } from './store-schema.ts';
|
|
25
|
+
import { addDocumentsToStore, assertValidEmbeddingVectors, addDocumentsBm25ToStore, searchStore, searchStoreBm25, findDocumentsByUrl, findRelevantUrls, findRelevantUrlsBm25 } from './store-operations.ts';
|
|
26
26
|
import type { AddDocumentsOutcome } from './store-operations.ts';
|
|
27
27
|
import { isEmbedderUnreachable } from './embedder-utils.ts';
|
|
28
28
|
import { ServiceLifecycle } from '../core/service-registry.ts';
|
|
@@ -54,8 +54,20 @@ export function isLanceCommitConflict(err: unknown): boolean {
|
|
|
54
54
|
|
|
55
55
|
export interface StoreOptions {
|
|
56
56
|
dbDir: string;
|
|
57
|
-
|
|
57
|
+
/** The embedding backend. REQUIRED in 'vector' (hybrid) retrieval mode; MUST be
|
|
58
|
+
* null in 'bm25' mode — this is the hard dependency boundary that keeps
|
|
59
|
+
* @huggingface/transformers out of the lexical path entirely (no resolution,
|
|
60
|
+
* no download, no initialization, no inference). */
|
|
61
|
+
embedder: IEmbedder | null;
|
|
58
62
|
modelName: string;
|
|
63
|
+
/** Retrieval strategy. 'vector' (default) = today's hybrid dense+sparse search
|
|
64
|
+
* (embedding similarity + BM25/FTS fused via RRF). 'bm25' = pure lexical BM25
|
|
65
|
+
* over the Tantivy FTS indexes — no embedding model anywhere; uses a separate
|
|
66
|
+
* lexical table (BM25_TABLE_NAME) in the same dbDir so existing vector stores
|
|
67
|
+
* are untouched and no migration is needed in either direction. Optional and
|
|
68
|
+
* defaulting to 'vector' so constructors predating the retrieval axis (tests,
|
|
69
|
+
* external embedders of the class) keep the exact historical behavior. */
|
|
70
|
+
retrieval?: 'vector' | 'bm25';
|
|
59
71
|
migrationStrategy?: MigrationStrategy;
|
|
60
72
|
/** Called when embedder connection fails — should return a fresh IEmbedder. */
|
|
61
73
|
reconnectFactory?: () => Promise<IEmbedder>;
|
|
@@ -83,7 +95,12 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
83
95
|
// Coalesces concurrent open() calls (see open()).
|
|
84
96
|
private _openPromise: Promise<void> | null = null;
|
|
85
97
|
private options: StoreOptions;
|
|
86
|
-
private tableName
|
|
98
|
+
private tableName: string;
|
|
99
|
+
// The OTHER retrieval mode's active table name, preserved across manifest
|
|
100
|
+
// save/load so the two modes can share one dbDir without clobbering each
|
|
101
|
+
// other's migration-renamed tables ('vector' owns activeTableName,
|
|
102
|
+
// 'bm25' owns activeBm25TableName).
|
|
103
|
+
private otherModeTableName: string | null = null;
|
|
87
104
|
private manifestPath: string;
|
|
88
105
|
private isClosing = false;
|
|
89
106
|
private activeWrites = new Set<Promise<any>>();
|
|
@@ -113,9 +130,26 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
113
130
|
|
|
114
131
|
constructor(options: StoreOptions) {
|
|
115
132
|
this.options = options;
|
|
133
|
+
this.tableName = this.retrieval === 'bm25' ? BM25_TABLE_NAME : 'knowledge';
|
|
116
134
|
this.manifestPath = path.join(this.options.dbDir, 'store-manifest.json');
|
|
117
135
|
}
|
|
118
136
|
|
|
137
|
+
/** Normalized retrieval strategy: an unset option means the historical
|
|
138
|
+
* 'vector' (hybrid) behavior, not bm25. Every mode check goes through this. */
|
|
139
|
+
private get retrieval(): 'vector' | 'bm25' {
|
|
140
|
+
return this.options.retrieval ?? 'vector';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The embedding backend. Only callable on the vector (hybrid) path — the
|
|
144
|
+
* single choke point that makes a null embedder in bm25 mode fail loud here
|
|
145
|
+
* instead of surfacing as a mysterious TypeError deep in an embed call. */
|
|
146
|
+
private get embedder(): IEmbedder {
|
|
147
|
+
if (this.retrieval === 'bm25' || !this.options.embedder) {
|
|
148
|
+
throw new Error('[store] embedder accessed in bm25 (lexical) mode — internal invariant violated');
|
|
149
|
+
}
|
|
150
|
+
return this.options.embedder;
|
|
151
|
+
}
|
|
152
|
+
|
|
119
153
|
private getScopeFilter(): string {
|
|
120
154
|
const ws = this.getWorkspace().replace(/'/g, "''");
|
|
121
155
|
|
|
@@ -145,10 +179,17 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
145
179
|
if (fs.existsSync(this.manifestPath)) {
|
|
146
180
|
const content = await fsPromises.readFile(this.manifestPath, 'utf-8');
|
|
147
181
|
const manifest = JSON.parse(content);
|
|
148
|
-
|
|
149
|
-
|
|
182
|
+
const savedVectorTable = typeof manifest.activeTableName === 'string' ? manifest.activeTableName : null;
|
|
183
|
+
const savedBm25Table = typeof manifest.activeBm25TableName === 'string' ? manifest.activeBm25TableName : null;
|
|
184
|
+
this.otherModeTableName = this.retrieval === 'bm25' ? savedVectorTable : savedBm25Table;
|
|
185
|
+
const savedOwnTable = this.retrieval === 'bm25' ? savedBm25Table : savedVectorTable;
|
|
186
|
+
if (savedOwnTable) {
|
|
187
|
+
this.tableName = savedOwnTable;
|
|
150
188
|
logger.debug(`[store] Loaded active table name from manifest: ${this.tableName}`);
|
|
151
189
|
}
|
|
190
|
+
// Migration history is vector-only (bm25 has no model to migrate); the
|
|
191
|
+
// values are loaded regardless and preserved verbatim by saveManifest so
|
|
192
|
+
// a bm25 open never erases the vector mode's alternation detector.
|
|
152
193
|
if (manifest.lastMigration && typeof manifest.lastMigration.model === 'string') {
|
|
153
194
|
this.lastMigration = { model: manifest.lastMigration.model, ts: Number(manifest.lastMigration.ts) || 0 };
|
|
154
195
|
}
|
|
@@ -166,7 +207,11 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
166
207
|
const tempPath = `${this.manifestPath}.tmp`;
|
|
167
208
|
try {
|
|
168
209
|
const content = JSON.stringify({
|
|
169
|
-
activeTableName: this.tableName,
|
|
210
|
+
activeTableName: this.retrieval === 'vector' ? this.tableName : this.otherModeTableName,
|
|
211
|
+
// bm25 keeps its OWN manifest key so a lexical store can share the dbDir
|
|
212
|
+
// with a vector store — neither mode's table renames (migrations,
|
|
213
|
+
// backups) can strand the other's manifest pointer.
|
|
214
|
+
activeBm25TableName: this.retrieval === 'bm25' ? this.tableName : this.otherModeTableName,
|
|
170
215
|
// Record of the two most recent model/schema migrations. Read back on open
|
|
171
216
|
// to detect ALTERNATION (two configurations sharing this dbDir each
|
|
172
217
|
// migrating the table back and forth) — previously that thrash was
|
|
@@ -189,6 +234,13 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
189
234
|
|
|
190
235
|
async open(): Promise<void> {
|
|
191
236
|
if (this.db) return;
|
|
237
|
+
// Load the manifest BEFORE anything reads this.tableName / otherModeTableName.
|
|
238
|
+
// The service path reaches here via initialize() (which already loaded it —
|
|
239
|
+
// this idempotent re-read is cheap), but direct open() callers previously
|
|
240
|
+
// skipped the manifest entirely: with per-mode manifest keys, saving without
|
|
241
|
+
// loading would overwrite the OTHER mode's table pointer with null and strand
|
|
242
|
+
// its store. Loading here makes both entry points safe.
|
|
243
|
+
await this.loadManifest();
|
|
192
244
|
// close() is irreversible in this class (isClosing latches the operation
|
|
193
245
|
// no-op path), so an open() after it must not "succeed" and hand back a
|
|
194
246
|
// zombie whose every operation silently no-ops — the service layer always
|
|
@@ -232,7 +284,17 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
232
284
|
this.db = await (await getLancedb()).connect(this.options.dbDir);
|
|
233
285
|
|
|
234
286
|
const tableNames = await this.db.tableNames();
|
|
235
|
-
if (
|
|
287
|
+
if (this.retrieval === 'bm25') {
|
|
288
|
+
// Lexical mode: there is no embedding model here — no model/schema
|
|
289
|
+
// migration machinery, no dimension seeding, no embedder warm-up. The
|
|
290
|
+
// table's schema carries no vector column at all (see store-schema.ts);
|
|
291
|
+
// lexical ranking comes entirely from the FTS indexes.
|
|
292
|
+
if (tableNames.includes(this.tableName)) {
|
|
293
|
+
this.table = await this.db.openTable(this.tableName);
|
|
294
|
+
} else {
|
|
295
|
+
this.table = await this.createTable();
|
|
296
|
+
}
|
|
297
|
+
} else if (tableNames.includes(this.tableName)) {
|
|
236
298
|
this.table = await this.db.openTable(this.tableName);
|
|
237
299
|
|
|
238
300
|
// Tracks whether we've moved past schema-reading into migration. A failure
|
|
@@ -271,8 +333,8 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
271
333
|
const vectorField = schema.fields.find(f => f.name === 'vector');
|
|
272
334
|
if (vectorField && (vectorField.type as any).constructor.name === 'FixedSizeList') {
|
|
273
335
|
const dim = (vectorField.type as any).listSize;
|
|
274
|
-
if (this.
|
|
275
|
-
this.
|
|
336
|
+
if (this.embedder.getDimension() === null) {
|
|
337
|
+
this.embedder.setDimension(dim);
|
|
276
338
|
logger.debug(`[store] Extracted dimension ${dim} from existing table schema`);
|
|
277
339
|
}
|
|
278
340
|
}
|
|
@@ -291,7 +353,7 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
291
353
|
// (local Embedder, EmbeddingServer, EmbeddingClient) and makes
|
|
292
354
|
// getDimension() report the correct new width. (re-embed needs the warm
|
|
293
355
|
// embedder regardless.)
|
|
294
|
-
if (this.
|
|
356
|
+
if (this.embedder.getDimension() === null) {
|
|
295
357
|
// Reconnect-wrapped: after a service re-scope this warm-up can be
|
|
296
358
|
// the FIRST embed against a stale cached instance (a stepped-down
|
|
297
359
|
// leader fast-fails with ECONNREFUSED); without the wrapper that
|
|
@@ -368,7 +430,7 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
368
430
|
// that when the embedding factory handed back its model-mismatch local
|
|
369
431
|
// fallback, which is constructed but never initialize()d: every one of
|
|
370
432
|
// the caller's init retries burned against the same cached instance.
|
|
371
|
-
if (this.
|
|
433
|
+
if (this.embedder.getDimension() === null) {
|
|
372
434
|
await this.withEmbedderReconnect(embedder => embedder.embedMany([' ']));
|
|
373
435
|
}
|
|
374
436
|
this.table = await this.createTable();
|
|
@@ -525,7 +587,7 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
525
587
|
const vectors = await this.withEmbedderReconnect(embedder => embedder.embedMany(texts));
|
|
526
588
|
// Same last-gate integrity check as addDocumentsToStore: this path
|
|
527
589
|
// writes vectors through its own table handle and previously had none.
|
|
528
|
-
assertValidEmbeddingVectors(vectors, batchDocs.length, this.
|
|
590
|
+
assertValidEmbeddingVectors(vectors, batchDocs.length, this.embedder.getDimension?.());
|
|
529
591
|
|
|
530
592
|
const records = batchDocs.map((doc, idx) => ({
|
|
531
593
|
vector: Array.from(vectors[idx]!),
|
|
@@ -689,12 +751,18 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
689
751
|
|
|
690
752
|
private async createTable(name: string = this.tableName): Promise<lancedb.Table> {
|
|
691
753
|
if (!this.db) throw new Error('Database not connected');
|
|
692
|
-
|
|
754
|
+
if (this.retrieval === 'bm25') {
|
|
755
|
+
return createBm25StoreTable(this.db, name);
|
|
756
|
+
}
|
|
757
|
+
const dim = this.embedder.getDimension();
|
|
693
758
|
if (dim === null) throw new Error('[store] Cannot create table: embedder dimension unknown (not yet initialized)');
|
|
694
759
|
return createStoreTable(this.db, name, dim, this.options.modelName);
|
|
695
760
|
}
|
|
696
761
|
|
|
697
762
|
private async withEmbedderReconnect<T>(fn: (embedder: IEmbedder) => Promise<T>): Promise<T> {
|
|
763
|
+
if (this.retrieval === 'bm25' || !this.options.embedder) {
|
|
764
|
+
throw new Error('[store] withEmbedderReconnect called without an embedder (bm25 mode?) — internal invariant violated');
|
|
765
|
+
}
|
|
698
766
|
try {
|
|
699
767
|
return await fn(this.options.embedder);
|
|
700
768
|
} catch (err) {
|
|
@@ -788,7 +856,10 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
788
856
|
let table = await this.getFreshTable();
|
|
789
857
|
const workspace = this.getWorkspace();
|
|
790
858
|
|
|
791
|
-
|
|
859
|
+
// The write attempt (with its Lance commit-conflict retry loop) is shared
|
|
860
|
+
// by both retrieval modes; only the embedding step differs. bm25 mode runs
|
|
861
|
+
// it WITHOUT the embedder-reconnect wrapper — there is no embedder there.
|
|
862
|
+
const runWriteAttempt = async (): Promise<AddDocumentsOutcome> => {
|
|
792
863
|
let retryCount = 0;
|
|
793
864
|
const MAX_RETRIES = 5;
|
|
794
865
|
const BASE_DELAY = 100;
|
|
@@ -796,10 +867,19 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
796
867
|
while (retryCount <= MAX_RETRIES) {
|
|
797
868
|
try {
|
|
798
869
|
const isGlobal = this.options.knowledgeMode === 'global';
|
|
870
|
+
if (this.retrieval === 'bm25') {
|
|
871
|
+
return await addDocumentsBm25ToStore(
|
|
872
|
+
table,
|
|
873
|
+
docs,
|
|
874
|
+
() => this.isClosing,
|
|
875
|
+
workspace,
|
|
876
|
+
isGlobal
|
|
877
|
+
);
|
|
878
|
+
}
|
|
799
879
|
return await addDocumentsToStore(
|
|
800
|
-
table,
|
|
801
|
-
docs,
|
|
802
|
-
embedder,
|
|
880
|
+
table,
|
|
881
|
+
docs,
|
|
882
|
+
this.embedder,
|
|
803
883
|
() => this.isClosing,
|
|
804
884
|
workspace,
|
|
805
885
|
isGlobal
|
|
@@ -829,7 +909,11 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
829
909
|
// Unreachable: the loop only exits via return or throw. Present so the
|
|
830
910
|
// outcome type stays non-optional for the accounting below.
|
|
831
911
|
throw new Error('[store] addDocuments retry loop exited without result');
|
|
832
|
-
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const outcome = this.retrieval === 'bm25'
|
|
915
|
+
? await runWriteAttempt()
|
|
916
|
+
: await this.withEmbedderReconnect<AddDocumentsOutcome>(runWriteAttempt);
|
|
833
917
|
|
|
834
918
|
// Exactly one outcome per logical addDocuments call. Attempts that failed and
|
|
835
919
|
// were then recovered by the reconnect/conflict retries are not counted here
|
|
@@ -864,6 +948,11 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
864
948
|
return this.trackOperation('search', [] as StoreDocument[], async () => {
|
|
865
949
|
const table = await this.getFreshTable();
|
|
866
950
|
const scopeFilter = this.getScopeFilter();
|
|
951
|
+
if (this.retrieval === 'bm25') {
|
|
952
|
+
// Pure lexical: BM25 over the FTS index — no embed, no RRF fusion, no
|
|
953
|
+
// embedder reconnect (there is no embedding signal to fuse with).
|
|
954
|
+
return this.circuitBreaker.execute(() => searchStoreBm25(table, query, options.limit ?? 5, scopeFilter));
|
|
955
|
+
}
|
|
867
956
|
return this.circuitBreaker.execute(() =>
|
|
868
957
|
this.withEmbedderReconnect(embedder =>
|
|
869
958
|
searchStore(table, embedder, query, this.getReranker.bind(this), options.limit ?? 5, scopeFilter)
|
|
@@ -1060,8 +1149,11 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1060
1149
|
url: r.url as string,
|
|
1061
1150
|
// The text is the summary/description
|
|
1062
1151
|
text: r.text as string,
|
|
1063
|
-
// The vector is the model-dimension embedding
|
|
1064
|
-
|
|
1152
|
+
// The vector is the model-dimension embedding — vector/hybrid mode only;
|
|
1153
|
+
// a bm25 store has no vector column, so the export omits it entirely.
|
|
1154
|
+
...(this.retrieval === 'vector'
|
|
1155
|
+
? { v: Array.from(r.vector as Float32Array) }
|
|
1156
|
+
: {}),
|
|
1065
1157
|
// Minimal metadata needed for the UI
|
|
1066
1158
|
m: {
|
|
1067
1159
|
d: metadata['description'] || '',
|
|
@@ -1194,6 +1286,11 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1194
1286
|
return this.trackOperation('findRelevantUrls', [] as { url: string; description: string; provenance?: string }[], async () => {
|
|
1195
1287
|
const table = await this.getFreshTable();
|
|
1196
1288
|
const scopeFilter = this.getScopeFilter();
|
|
1289
|
+
if (this.retrieval === 'bm25') {
|
|
1290
|
+
// Pure lexical: BM25 over the FTS index — no embed, no RRF fusion, no
|
|
1291
|
+
// embedder reconnect (there is no embedding signal to fuse with).
|
|
1292
|
+
return this.circuitBreaker.execute(() => findRelevantUrlsBm25(table, query, options.limit ?? 20, scopeFilter));
|
|
1293
|
+
}
|
|
1197
1294
|
return this.circuitBreaker.execute(() =>
|
|
1198
1295
|
this.withEmbedderReconnect(embedder =>
|
|
1199
1296
|
findRelevantUrls(table, embedder, query, this.getReranker.bind(this), options.limit ?? 20, scopeFilter)
|
package/src/research-config.ts
CHANGED
|
@@ -71,6 +71,7 @@ import * as path from 'node:path';
|
|
|
71
71
|
const ENV_KEY_BY_SETTING_ID: Record<string, string> = {
|
|
72
72
|
DEFAULT_RESEARCH_DEPTH: 'PI_RESEARCH_DEFAULT_RESEARCH_DEPTH',
|
|
73
73
|
KNOWLEDGE_STORE_MODE: 'PI_RESEARCH_KNOWLEDGE_STORE_MODE',
|
|
74
|
+
KNOWLEDGE_STORE_RETRIEVAL: 'PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL',
|
|
74
75
|
RESEARCHER_TIMEOUT_MS: 'PI_RESEARCH_TIMEOUT_MS',
|
|
75
76
|
MAX_CONCURRENT_RESEARCHERS: 'PI_RESEARCH_MAX_RESEARCHERS',
|
|
76
77
|
MAX_SCRAPE_BATCHES: 'PI_RESEARCH_MAX_SCRAPE_BATCHES',
|
|
@@ -171,8 +172,9 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
171
172
|
// (optional embedding dep skipped at install, broken lancedb). Surface the
|
|
172
173
|
// package-level OFF right in the setting, so the menu never advertises a
|
|
173
174
|
// scope the store cannot honor and the repair path is named where the
|
|
174
|
-
// user is looking.
|
|
175
|
-
|
|
175
|
+
// user is looking. The probe is retrieval-aware: bm25 does not need the
|
|
176
|
+
// optional embedding package at all.
|
|
177
|
+
const availability = probeKnowledgeStoreAvailability(config.KNOWLEDGE_STORE_RETRIEVAL);
|
|
176
178
|
if (!availability.available) {
|
|
177
179
|
return `${base}\n\n⚠ ${describeKnowledgeStoreUnavailability(availability)}\nThe store stays off — regardless of this setting — until that package is installed.`;
|
|
178
180
|
}
|
|
@@ -181,6 +183,25 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
181
183
|
currentValue: config.KNOWLEDGE_STORE_MODE,
|
|
182
184
|
values: ['none', 'project', 'global'],
|
|
183
185
|
},
|
|
186
|
+
...(anyKnowledgeStore ? [
|
|
187
|
+
{
|
|
188
|
+
id: 'KNOWLEDGE_STORE_RETRIEVAL',
|
|
189
|
+
label: 'Knowledge Retrieval [project]',
|
|
190
|
+
description: (() => {
|
|
191
|
+
const base = "Retrieval strategy for the knowledge store — 'vector' (hybrid: embedding similarity + BM25 fused, richest matching, needs the optional embedding model) or 'bm25' (pure lexical BM25 — no embedding model, nothing downloaded, lighter install).\n[project] means configured independently per directory.";
|
|
192
|
+
// Same honesty rule as Knowledge Mode: in vector mode, a missing
|
|
193
|
+
// optional embedding package means the configured strategy cannot run;
|
|
194
|
+
// point at the bm25 switch as the repair.
|
|
195
|
+
const availability = probeKnowledgeStoreAvailability(config.KNOWLEDGE_STORE_RETRIEVAL);
|
|
196
|
+
if (!availability.available && config.KNOWLEDGE_STORE_RETRIEVAL === 'vector') {
|
|
197
|
+
return `${base}\n\n⚠ ${describeKnowledgeStoreUnavailability(availability)}\nSwitching to 'bm25' runs the store without the embedding package.`;
|
|
198
|
+
}
|
|
199
|
+
return base;
|
|
200
|
+
})(),
|
|
201
|
+
currentValue: config.KNOWLEDGE_STORE_RETRIEVAL,
|
|
202
|
+
values: ['vector', 'bm25'],
|
|
203
|
+
},
|
|
204
|
+
] as SettingItem[] : []),
|
|
184
205
|
{
|
|
185
206
|
id: 'RESEARCHER_TIMEOUT_MS',
|
|
186
207
|
label: 'Researcher Timeout',
|
|
@@ -213,20 +234,24 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
213
234
|
// environment-only setting (PI_RESEARCH_WORKER_THREADS, default 4) so that
|
|
214
235
|
// CPU/RAM-sensitive concurrency is not casually changed from the menu.
|
|
215
236
|
...(anyKnowledgeStore ? [
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
237
|
+
// Embedding knobs are only meaningful in 'vector' (hybrid) retrieval mode —
|
|
238
|
+
// bm25 runs with NO embedding model, so there is nothing to select here.
|
|
239
|
+
...(config.KNOWLEDGE_STORE_RETRIEVAL !== 'bm25' ? [
|
|
240
|
+
{
|
|
241
|
+
id: 'EMBEDDING_MODEL',
|
|
242
|
+
label: 'Embedding Model',
|
|
243
|
+
description: 'Vector model for semantic search. Changing it clears the current knowledge store and starts fresh.',
|
|
244
|
+
currentValue: config.EMBEDDING_MODEL.split('/').pop()!,
|
|
245
|
+
values: SUPPORTED_MODELS.map(m => m.id.split('/').pop()!),
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
id: 'EMBEDDING_DEVICE',
|
|
249
|
+
label: 'Embedding Device',
|
|
250
|
+
description: 'Hardware backend for the embedding model. GPU probes whether WebGPU actually runs on this machine and automatically falls back to CPU if it does not (safe everywhere). CPU forces CPU-only inference. (Raw forced-GPU is available via the PI_RESEARCH_EMBEDDING_DEVICE=webgpu env var for benchmarking.)',
|
|
251
|
+
currentValue: config.EMBEDDING_DEVICE === 'cpu' ? 'CPU' : 'GPU',
|
|
252
|
+
values: ['GPU', 'CPU'],
|
|
253
|
+
},
|
|
254
|
+
] as SettingItem[] : []),
|
|
230
255
|
{
|
|
231
256
|
id: 'KNOWLEDGE_STORE_CACHE_TTL_DAYS',
|
|
232
257
|
label: 'Cache Retention',
|
|
@@ -398,6 +423,8 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
398
423
|
scope = 'user';
|
|
399
424
|
} else if (id === 'KNOWLEDGE_STORE_MODE') {
|
|
400
425
|
config.KNOWLEDGE_STORE_MODE = newValue as 'none' | 'project' | 'global';
|
|
426
|
+
} else if (id === 'KNOWLEDGE_STORE_RETRIEVAL') {
|
|
427
|
+
config.KNOWLEDGE_STORE_RETRIEVAL = newValue as 'vector' | 'bm25';
|
|
401
428
|
} else if (id === 'EMBEDDING_MODEL') {
|
|
402
429
|
config.EMBEDDING_MODEL = SUPPORTED_MODELS.find(m => m.id.split('/').pop() === newValue)?.id ?? newValue;
|
|
403
430
|
// DESTRUCTIVE + DEFERRED: changing the model clears the knowledge store, so it is
|
|
@@ -443,6 +470,16 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
|
|
|
443
470
|
.catch((e) => logger.debug('[research-config] knowledge-store warm-up failed:', e));
|
|
444
471
|
}
|
|
445
472
|
|
|
473
|
+
// A retrieval switch re-scopes the store onto the other table (lexical
|
|
474
|
+
// vs vector schema) — same background warm-up as a mode change, so the
|
|
475
|
+
// first search after the switch doesn't pay the init cost.
|
|
476
|
+
if (id === 'KNOWLEDGE_STORE_RETRIEVAL') {
|
|
477
|
+
const kmContainer = tryGetServiceContainerFromCtx(ctx);
|
|
478
|
+
void getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, kmContainer)
|
|
479
|
+
.then((s) => s.getStore())
|
|
480
|
+
.catch((e) => logger.debug('[research-config] retrieval-switch warm-up failed:', e));
|
|
481
|
+
}
|
|
482
|
+
|
|
446
483
|
// 2. Handle Actions
|
|
447
484
|
if (id === 'ACTION_HEALTH') {
|
|
448
485
|
wrappedDone({ type: 'action', action: 'health' });
|
|
@@ -825,10 +862,13 @@ async function showKnowledgeStatusAction(ctx: ExtensionContext, pi: ExtensionAPI
|
|
|
825
862
|
}
|
|
826
863
|
const counts = await store.countScoped(cwd);
|
|
827
864
|
const dbDir = getDbDir(config, cwd);
|
|
828
|
-
|
|
865
|
+
const retrievalLines = config.KNOWLEDGE_STORE_RETRIEVAL === 'bm25'
|
|
866
|
+
? `- Retrieval: bm25 (lexical — no embedding model)`
|
|
867
|
+
: `- Retrieval: vector (hybrid embedding + BM25)\n- Model: ${config.EMBEDDING_MODEL}\n- Device: ${config.EMBEDDING_DEVICE}`;
|
|
868
|
+
|
|
829
869
|
pi.sendMessage({
|
|
830
870
|
customType: 'knowledge-status',
|
|
831
|
-
content: `## Knowledge Store\n\n- Status: Operational\n- Project Entries: ${counts.local}\n- User Entries: ${counts.global}\n- Total Projects: ${counts.projects}\n
|
|
871
|
+
content: `## Knowledge Store\n\n- Status: Operational\n- Project Entries: ${counts.local}\n- User Entries: ${counts.global}\n- Total Projects: ${counts.projects}\n${retrievalLines}\n- Path: \`${dbDir}\``,
|
|
832
872
|
display: true,
|
|
833
873
|
});
|
|
834
874
|
} catch (error: unknown) {
|