@lincoln504/pi-research 1.1.0 → 1.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/README.md +4 -4
- package/dist/cli.mjs +1404 -600
- package/dist/thread-worker.mjs +40 -11
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/CHANGELOG.md +107 -1
- package/docs/CONFIGURATION.md +10 -8
- package/docs/KNOWLEDGE-STORE.md +5 -4
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +63 -5
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/build.cjs +23 -5
- package/scripts/cleanup.cjs +50 -10
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +8 -5
- package/skills/pi-research/references/configuration.md +7 -3
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cli.ts +161 -21
- package/src/config.ts +56 -15
- package/src/constants.ts +31 -0
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/state-manager-interfaces.ts +1 -1
- package/src/core/llm/model-registry-factory.ts +40 -3
- package/src/core/llm/research-model-resolver.ts +2 -2
- package/src/core/pi-version.ts +136 -0
- package/src/core/service-registry.ts +117 -8
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +16 -4
- package/src/infrastructure/browser/browser-error-utils.ts +12 -1
- package/src/infrastructure/browser/browser-server.ts +17 -5
- package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
- package/src/infrastructure/browser/cleanup-utils.ts +45 -7
- package/src/infrastructure/browser/client-agent.ts +4 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +26 -1
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker.mjs +40 -11
- package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +28 -11
- package/src/infrastructure/embedding/embedding-server.ts +65 -4
- package/src/infrastructure/file-lock-service.ts +223 -26
- package/src/infrastructure/process-lifecycle-service.ts +108 -31
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-manager-service.ts +1 -1
- package/src/infrastructure/state/state-manager.ts +19 -20
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +52 -3
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +35 -9
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/orchestration/deep-research-orchestrator.ts +13 -2
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +2 -4
- package/src/orchestration/research-synthesis-service.ts +34 -92
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/nvd-client.ts +17 -6
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +135 -9
- package/src/stackexchange/rest-client.ts +6 -2
- package/src/tools/research-knowledge-search.ts +23 -2
- package/src/tools/search.ts +6 -1
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +20 -0
- package/src/utils/citation-utils.ts +106 -8
- package/src/utils/http-body.ts +176 -0
- package/src/utils/llm-usage.ts +37 -0
- package/src/utils/metrics-summary.ts +25 -2
- package/src/utils/research-export.ts +9 -2
- package/src/web-research/browser-search.ts +50 -4
- package/src/web-research/retry-utils.ts +13 -1
- package/src/web-research/search.ts +20 -5
- package/src/web-research/types.ts +17 -4
- package/src/web-research/web-scraper.ts +7 -50
- package/src/youtube/transcript-client.ts +5 -1
|
@@ -79,6 +79,15 @@ export const SingletonStateSchema = Type.Object({
|
|
|
79
79
|
// in-process embedder instead. Optional for entries written by older
|
|
80
80
|
// processes, which are tolerated as matching.
|
|
81
81
|
model: Type.Optional(Type.String()),
|
|
82
|
+
// Shared secret a follower must present on every embed request. Mirrors
|
|
83
|
+
// browserServer.authSecret: the server listens on loopback, but loopback is
|
|
84
|
+
// not an authorization boundary — any local process could otherwise enqueue
|
|
85
|
+
// embedding work, and because the serial queue steps the leader down when
|
|
86
|
+
// poisoned, sustained abuse denies the knowledge store rather than merely
|
|
87
|
+
// burning GPU. Optional so an entry written by an older leader still parses;
|
|
88
|
+
// a client that finds none simply sends no header, and such a leader does not
|
|
89
|
+
// check one either.
|
|
90
|
+
authSecret: Type.Optional(Type.String()),
|
|
82
91
|
})),
|
|
83
92
|
});
|
|
84
93
|
|
|
@@ -252,7 +252,7 @@ export async function purgeModelCache(model: string): Promise<void> {
|
|
|
252
252
|
logger.warn(`[embedder] Refusing to purge unsafe model-cache path for model id ${JSON.stringify(model)}`);
|
|
253
253
|
return;
|
|
254
254
|
}
|
|
255
|
-
await rm(modelDir, { recursive: true, force: true });
|
|
255
|
+
await rm(modelDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
256
256
|
logger.warn(`[embedder] Purged corrupt model cache at ${modelDir}`);
|
|
257
257
|
} catch (err) {
|
|
258
258
|
logger.warn('[embedder] Failed to purge model cache:', err);
|
|
@@ -68,6 +68,14 @@ export class Embedder {
|
|
|
68
68
|
private idleTimer: NodeJS.Timeout | null = null;
|
|
69
69
|
private readonly IDLE_TIMEOUT_MS = 60 * 1000;
|
|
70
70
|
private activeEmbeddings = 0;
|
|
71
|
+
/**
|
|
72
|
+
* Why the in-flight dispose was started. An IDLE dispose is a memory-reclaim
|
|
73
|
+
* pause that a later embed is expected to undo, so initialize() waits it out and
|
|
74
|
+
* re-initializes. A TERMINAL dispose (shutdown, config change, explicit
|
|
75
|
+
* disposal) must never be undone — re-initializing there would resurrect the
|
|
76
|
+
* ONNX pipeline during teardown. Only meaningful while state === 'disposing'.
|
|
77
|
+
*/
|
|
78
|
+
private disposeReason: 'idle' | 'terminal' = 'terminal';
|
|
71
79
|
|
|
72
80
|
constructor(options: EmbedderOptions) {
|
|
73
81
|
this.model = options.model;
|
|
@@ -113,7 +121,7 @@ export class Embedder {
|
|
|
113
121
|
}
|
|
114
122
|
if (this.state === 'ready') {
|
|
115
123
|
logger.info(`[embedder] Idle timeout reached (${this.IDLE_TIMEOUT_MS}ms), releasing GPU memory...`);
|
|
116
|
-
this.dispose().catch(err => logger.warn('[embedder] Failed to dispose on idle:', err));
|
|
124
|
+
this.dispose('idle').catch(err => logger.warn('[embedder] Failed to dispose on idle:', err));
|
|
117
125
|
}
|
|
118
126
|
}, this.IDLE_TIMEOUT_MS);
|
|
119
127
|
if (this.idleTimer) safeUnref(this.idleTimer);
|
|
@@ -129,7 +137,36 @@ export class Embedder {
|
|
|
129
137
|
async initialize(): Promise<void> {
|
|
130
138
|
if (this.state === 'ready') return;
|
|
131
139
|
if (this.state === 'disposing') {
|
|
132
|
-
|
|
140
|
+
// An IDLE dispose is a pause, not an end: the pipeline was released purely
|
|
141
|
+
// to reclaim GPU/host memory and the next embed is meant to bring it back.
|
|
142
|
+
// Throwing here lost that race — the idle timer fires, dispose() flips the
|
|
143
|
+
// state, and a document that arrives during the (up to ~5s) teardown got
|
|
144
|
+
// "Cannot initialize while disposing", which nothing classifies as
|
|
145
|
+
// transient, so the writer queue dropped it with no retry. Wait the dispose
|
|
146
|
+
// out and re-initialize instead.
|
|
147
|
+
if (this.disposeReason === 'idle') {
|
|
148
|
+
logger.debug('[embedder] initialize() raced an idle dispose — waiting for it to finish, then re-initializing.');
|
|
149
|
+
await this.disposePromise?.catch(() => { /* a failed idle dispose must not block the revive */ });
|
|
150
|
+
// Re-check: another caller may have revived (or terminally disposed) the
|
|
151
|
+
// embedder while we waited.
|
|
152
|
+
if ((this.state as EmbedderState) === 'ready') return;
|
|
153
|
+
// A terminal dispose landed as a DOWNGRADE onto the idle dispose we just
|
|
154
|
+
// awaited: state is 'idle', but reviving is forbidden — pre-fix this fell
|
|
155
|
+
// through to a full model load during process teardown. (A LATER, fresh
|
|
156
|
+
// initialize()/embed() after the completed dispose never enters this
|
|
157
|
+
// branch and stays revivable — that re-use is pinned behaviour.) The cast
|
|
158
|
+
// defeats control-flow narrowing, which cannot see the awaited dispose
|
|
159
|
+
// mutating the field — same idiom as the state re-checks around it.
|
|
160
|
+
if ((this.disposeReason as 'idle' | 'terminal') === 'terminal') {
|
|
161
|
+
throw new Error('Cannot initialize while disposing');
|
|
162
|
+
}
|
|
163
|
+
if ((this.state as EmbedderState) === 'disposing') {
|
|
164
|
+
throw new Error('Cannot initialize while disposing');
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
// Terminal dispose (shutdown / config change): must NOT be revived.
|
|
168
|
+
throw new Error('Cannot initialize while disposing');
|
|
169
|
+
}
|
|
133
170
|
}
|
|
134
171
|
|
|
135
172
|
if (this.initializingPromise) {
|
|
@@ -529,14 +566,26 @@ export class Embedder {
|
|
|
529
566
|
}
|
|
530
567
|
}
|
|
531
568
|
|
|
532
|
-
|
|
569
|
+
/**
|
|
570
|
+
* @param reason `'idle'` for the memory-reclaim pause driven by the idle timer —
|
|
571
|
+
* a later embed is allowed to revive the embedder. Anything else is terminal
|
|
572
|
+
* (shutdown, config change, explicit disposal) and must never be revived, so
|
|
573
|
+
* the default is deliberately the safe one: callers that do not opt in get
|
|
574
|
+
* terminal semantics.
|
|
575
|
+
*/
|
|
576
|
+
async dispose(reason: 'idle' | 'terminal' = 'terminal'): Promise<void> {
|
|
533
577
|
if (this.state === 'idle') return;
|
|
534
578
|
this.stopIdleTimer();
|
|
535
579
|
|
|
536
580
|
if (this.state === 'disposing' && this.disposePromise) {
|
|
581
|
+
// A terminal dispose landing on top of an in-flight idle dispose must
|
|
582
|
+
// downgrade the reason, so a concurrent initialize() cannot revive what is
|
|
583
|
+
// now a shutdown. Never the reverse.
|
|
584
|
+
if (reason === 'terminal') this.disposeReason = 'terminal';
|
|
537
585
|
return this.disposePromise;
|
|
538
586
|
}
|
|
539
587
|
|
|
588
|
+
this.disposeReason = reason;
|
|
540
589
|
this.state = 'disposing';
|
|
541
590
|
this.disposePromise = (async () => {
|
|
542
591
|
// Wait for all active embeddings to complete
|
package/src/knowledge/index.ts
CHANGED
|
@@ -115,7 +115,7 @@ export async function forceDeleteKnowledgeStore(config?: Config, workspace?: str
|
|
|
115
115
|
const dbDir = getDbDir(config, workspace);
|
|
116
116
|
if (fs.existsSync(dbDir)) {
|
|
117
117
|
try {
|
|
118
|
-
fs.rmSync(dbDir, { recursive: true, force: true });
|
|
118
|
+
fs.rmSync(dbDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
119
119
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
120
120
|
logger.info(`[knowledge] Knowledge store at ${dbDir} forcefully deleted and recreated.`);
|
|
121
121
|
} catch (err) {
|
package/src/knowledge/store.ts
CHANGED
|
@@ -721,12 +721,28 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
721
721
|
}
|
|
722
722
|
}
|
|
723
723
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
724
|
+
/**
|
|
725
|
+
* @param olderThan When set, only rows with `timestamp < olderThan` are removed.
|
|
726
|
+
* This is what lets a refresh write the replacement chunks FIRST and prune the
|
|
727
|
+
* superseded ones after, instead of deleting first and losing the entry
|
|
728
|
+
* outright if the insert then fails. `timestamp` is used as the discriminator
|
|
729
|
+
* because it is a real (indexed) column — `contentHash` lives inside the
|
|
730
|
+
* JSON-encoded `metadata` blob and cannot be filtered on.
|
|
731
|
+
*/
|
|
732
|
+
async deleteByUrlAndType(url: string, ingestionType: string, olderThan?: number): Promise<void> {
|
|
733
|
+
// Coordinate with close() — see deleteByUrl. The closing-skip is surfaced
|
|
734
|
+
// rather than silently resolving: a prune skipped because the store is
|
|
735
|
+
// closing (a post-run background ingest racing a Ctrl+C close) leaves
|
|
736
|
+
// duplicates exactly like a failed prune does, but resolved cleanly, so the
|
|
737
|
+
// writer-queue's failed-prune accounting never saw its most common cause.
|
|
738
|
+
if (this.isClosing) {
|
|
739
|
+
logger.warn(`[store] Prune of ${ingestionType} chunks for ${url} skipped — store is closing; superseded duplicates remain until the next refresh.`);
|
|
740
|
+
metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'skipped_closing' });
|
|
741
|
+
}
|
|
742
|
+
return this.trackOperation('deleteByUrlAndType', undefined, () => this.deleteByUrlAndTypeInner(url, ingestionType, olderThan));
|
|
727
743
|
}
|
|
728
744
|
|
|
729
|
-
private async deleteByUrlAndTypeInner(url: string, ingestionType: string): Promise<void> {
|
|
745
|
+
private async deleteByUrlAndTypeInner(url: string, ingestionType: string, olderThan?: number): Promise<void> {
|
|
730
746
|
let retryCount = 0;
|
|
731
747
|
const MAX_RETRIES = 5;
|
|
732
748
|
const BASE_DELAY = 100;
|
|
@@ -738,11 +754,15 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
738
754
|
try {
|
|
739
755
|
const escapedUrl = url.replace(/'/g, "''");
|
|
740
756
|
const escapedType = ingestionType.replace(/'/g, "''");
|
|
741
|
-
|
|
757
|
+
// Integer-coerced so the predicate can never carry anything but digits.
|
|
758
|
+
const ageClause = Number.isFinite(olderThan as number)
|
|
759
|
+
? ` AND timestamp < ${Math.floor(olderThan as number)}`
|
|
760
|
+
: '';
|
|
761
|
+
await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})${ageClause}`);
|
|
742
762
|
const duration = Date.now() - startTime;
|
|
743
763
|
metrics.observe('knowledge_store_delete_duration_ms', duration, { operation: 'by_url_and_type' });
|
|
744
764
|
metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'success' });
|
|
745
|
-
logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)`);
|
|
765
|
+
logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)${ageClause ? ' older than the replacement' : ''}`);
|
|
746
766
|
return;
|
|
747
767
|
} catch (err) {
|
|
748
768
|
if (isLanceCommitConflict(err)) {
|
|
@@ -842,10 +862,14 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
842
862
|
const startTime = Date.now();
|
|
843
863
|
const escapedUrl = url.replace(/'/g, "''");
|
|
844
864
|
|
|
865
|
+
// limit > 1 deliberately: after a failed prune two generations of this URL can
|
|
866
|
+
// coexist, and LanceDB's fragment-scan order typically returns the OLDEST row
|
|
867
|
+
// first — limit(1) would serve the superseded content (and feed its older
|
|
868
|
+
// timestamp to the serve-age gate) until the next successful ingest.
|
|
845
869
|
const results = await table
|
|
846
870
|
.query()
|
|
847
871
|
.where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`)
|
|
848
|
-
.limit(
|
|
872
|
+
.limit(16)
|
|
849
873
|
.toArray();
|
|
850
874
|
|
|
851
875
|
const duration = Date.now() - startTime;
|
|
@@ -856,6 +880,8 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
856
880
|
metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'miss' });
|
|
857
881
|
return null;
|
|
858
882
|
}
|
|
883
|
+
// Newest generation wins, never row order.
|
|
884
|
+
results.sort((a: any, b: any) => Number(b.timestamp) - Number(a.timestamp));
|
|
859
885
|
|
|
860
886
|
const r = results[0];
|
|
861
887
|
// Read-time freshness. The stored timestamp (ms) is otherwise discarded here;
|
|
@@ -1081,7 +1107,7 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1081
1107
|
for (const name of migrationDirs) {
|
|
1082
1108
|
const fullPath = path.join(this.options.dbDir, name);
|
|
1083
1109
|
logger.info(`[store] Removing orphaned migration temp directory: ${name}`);
|
|
1084
|
-
await fsPromises.rm(fullPath, { recursive: true, force: true });
|
|
1110
|
+
await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
1085
1111
|
}
|
|
1086
1112
|
|
|
1087
1113
|
// `_backup_` dirs are intentional, user-recoverable snapshots from the
|
|
@@ -1094,7 +1120,7 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1094
1120
|
for (const name of backupDirs.slice(0, -1)) {
|
|
1095
1121
|
const fullPath = path.join(this.options.dbDir, name);
|
|
1096
1122
|
logger.info(`[store] Removing superseded backup directory: ${name}`);
|
|
1097
|
-
await fsPromises.rm(fullPath, { recursive: true, force: true });
|
|
1123
|
+
await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
1098
1124
|
}
|
|
1099
1125
|
}
|
|
1100
1126
|
} catch (err) {
|
|
@@ -150,10 +150,13 @@ function runProbe(model: string, cacheDir: string, timeoutMs: number): Promise<b
|
|
|
150
150
|
}, timeoutMs);
|
|
151
151
|
if (typeof timer.unref === 'function') timer.unref();
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
// Buffered and decoded once at 'close': per-chunk String(d) mangles a
|
|
154
|
+
// multi-byte character straddling a pipe-chunk boundary (the PROBE_OK
|
|
155
|
+
// sentinel is ASCII, but the logged stderr detail line is not guaranteed to be).
|
|
156
|
+
const stdoutChunks: Buffer[] = [];
|
|
157
|
+
const stderrChunks: Buffer[] = [];
|
|
158
|
+
child.stdout?.on('data', (d) => { stdoutChunks.push(Buffer.from(d)); });
|
|
159
|
+
child.stderr?.on('data', (d) => { stderrChunks.push(Buffer.from(d)); });
|
|
157
160
|
|
|
158
161
|
child.on('error', (err) => {
|
|
159
162
|
logger.warn('[embedder] WebGPU probe process error — defaulting to CPU:', err);
|
|
@@ -161,6 +164,8 @@ function runProbe(model: string, cacheDir: string, timeoutMs: number): Promise<b
|
|
|
161
164
|
});
|
|
162
165
|
|
|
163
166
|
child.on('close', (code, signal) => {
|
|
167
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
|
168
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
164
169
|
// Viability is the sentinel, not the exit code (teardown may abort post-success).
|
|
165
170
|
if (stdout.includes('PROBE_OK')) {
|
|
166
171
|
finish(true);
|
|
@@ -16,7 +16,7 @@ interface WriterQueueOptions {
|
|
|
16
16
|
store: {
|
|
17
17
|
isStoreClosed?: () => boolean;
|
|
18
18
|
findByUrl: (url: string) => Promise<StoreDocument[]>;
|
|
19
|
-
deleteByUrlAndType: (url: string, type: string) => Promise<void>;
|
|
19
|
+
deleteByUrlAndType: (url: string, type: string, olderThan?: number) => Promise<void>;
|
|
20
20
|
addDocuments: (docs: StoreDocument[]) => Promise<void>;
|
|
21
21
|
};
|
|
22
22
|
chunker: Chunker | null;
|
|
@@ -73,50 +73,58 @@ export class WriterQueue implements IWriterQueue {
|
|
|
73
73
|
if (this.processing) return;
|
|
74
74
|
this.processing = true;
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
this.inflightByUrl.set(urlKey, inflight);
|
|
76
|
+
// try/finally around the whole drain: the latch and the drain notification used
|
|
77
|
+
// to sit on the normal exit path only. Anything escaping the loop — including
|
|
78
|
+
// from the catch block below, which itself awaits and logs — left `processing`
|
|
79
|
+
// stuck true forever: no further item would ever be ingested, drain() would never
|
|
80
|
+
// resolve, and dispose() would burn its full timeout on every later shutdown.
|
|
81
|
+
// enqueue() swallows this promise's rejection, so the wedge was silent.
|
|
82
|
+
try {
|
|
83
|
+
while (this.queue.length > 0) {
|
|
84
|
+
const item = this.queue.shift()!;
|
|
86
85
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (isEmbedderUnreachable(err)) {
|
|
95
|
-
logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
|
|
96
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
86
|
+
// FIX (#2): Chain per-URL to serialize concurrent ingests of the same URL.
|
|
87
|
+
// process() already runs sequentially, so we chain only for the race case
|
|
88
|
+
// where two enqueue() calls trigger concurrent process() calls.
|
|
89
|
+
const urlKey = normalizeUrl(item.url);
|
|
90
|
+
const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
|
|
91
|
+
const inflight = prev.then(() => this._ingestInner(item));
|
|
92
|
+
this.inflightByUrl.set(urlKey, inflight);
|
|
97
93
|
try {
|
|
98
|
-
await
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
94
|
+
await inflight;
|
|
95
|
+
} finally {
|
|
96
|
+
if (this.inflightByUrl.get(urlKey) === inflight) {
|
|
97
|
+
this.inflightByUrl.delete(urlKey);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (isEmbedderUnreachable(err)) {
|
|
102
|
+
logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
|
|
103
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
104
|
+
try {
|
|
105
|
+
await this.ingest(item);
|
|
106
|
+
} catch (retryErr) {
|
|
107
|
+
logger.error(`[writer-queue] Retry failed for ${item.url}, dropping:`, retryErr);
|
|
108
|
+
metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'embedder_unreachable' });
|
|
109
|
+
}
|
|
110
|
+
} else if (isNoSpace(err)) {
|
|
111
|
+
logger.error(`[writer-queue] ENOSPC: disk full — dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
|
|
112
|
+
metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'enospc' });
|
|
113
|
+
} else {
|
|
114
|
+
logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
|
|
115
|
+
metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'ingest_error' });
|
|
102
116
|
}
|
|
103
|
-
} else if (isNoSpace(err)) {
|
|
104
|
-
logger.error(`[writer-queue] ENOSPC: disk full — dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
|
|
105
|
-
metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'enospc' });
|
|
106
|
-
} else {
|
|
107
|
-
logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
|
|
108
|
-
metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'ingest_error' });
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
|
-
}
|
|
119
|
+
} finally {
|
|
120
|
+
this.processing = false;
|
|
112
121
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
resolve();
|
|
122
|
+
// Notify all drain callers
|
|
123
|
+
const resolvers = [...this.drainResolvers];
|
|
124
|
+
this.drainResolvers = [];
|
|
125
|
+
for (const resolve of resolvers) {
|
|
126
|
+
resolve();
|
|
127
|
+
}
|
|
120
128
|
}
|
|
121
129
|
}
|
|
122
130
|
|
|
@@ -172,15 +180,28 @@ export class WriterQueue implements IWriterQueue {
|
|
|
172
180
|
// Dedup: check if we already have this exact URL+type with identical content.
|
|
173
181
|
// findByUrl applies scope filtering, so this only dedupes within the
|
|
174
182
|
// current project's visible scope (local workspace + global entries).
|
|
183
|
+
// Compared against the NEWEST generation, not row order: after a failed prune
|
|
184
|
+
// two generations coexist and findByUrl's order is arbitrary — matching the
|
|
185
|
+
// OLD generation's hash would skip the refresh with the duplicates in place.
|
|
175
186
|
const existing = await this.options.store.findByUrl(normalizedUrl);
|
|
176
187
|
const sameType = existing.filter(c => c.metadata['ingestionType'] === incomingType);
|
|
177
|
-
if (sameType.length > 0 && sameType[0]!.metadata['contentHash'] === hash) {
|
|
178
|
-
logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
188
|
if (sameType.length > 0) {
|
|
183
|
-
|
|
189
|
+
const newestTs = Math.max(...sameType.map(d => Number(d.timestamp) || 0));
|
|
190
|
+
const newest = sameType.find(d => (Number(d.timestamp) || 0) === newestTs)!;
|
|
191
|
+
if (newest.metadata['contentHash'] === hash) {
|
|
192
|
+
// Content unchanged vs the current generation — but a prior failed prune
|
|
193
|
+
// may still have left older generations behind, and with stable content
|
|
194
|
+
// this skip is the only code that ever looks at this URL again. Collect
|
|
195
|
+
// them now instead of preserving the duplicates forever.
|
|
196
|
+
if (sameType.some(d => (Number(d.timestamp) || 0) < newestTs)) {
|
|
197
|
+
await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, newestTs).catch((err) => {
|
|
198
|
+
logger.warn(`[writer-queue] Sweeping superseded duplicates for ${normalizedUrl} (${incomingType}) failed:`, err);
|
|
199
|
+
metrics.increment('knowledge_ingest_stale_prune_failed_total', 1);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
184
205
|
}
|
|
185
206
|
|
|
186
207
|
const rawChunks = this.options.chunker
|
|
@@ -189,6 +210,10 @@ export class WriterQueue implements IWriterQueue {
|
|
|
189
210
|
|
|
190
211
|
if (rawChunks.length === 0) return;
|
|
191
212
|
|
|
213
|
+
// Single timestamp shared by every chunk of this write: it is both the row's
|
|
214
|
+
// recency value and the boundary used to prune the rows this write replaces.
|
|
215
|
+
const insertedAt = Date.now();
|
|
216
|
+
|
|
192
217
|
const docs: StoreDocument[] = rawChunks.map((chunk, i) => ({
|
|
193
218
|
url: normalizedUrl,
|
|
194
219
|
text: chunk.text,
|
|
@@ -203,10 +228,27 @@ export class WriterQueue implements IWriterQueue {
|
|
|
203
228
|
chunkIndex: i,
|
|
204
229
|
totalChunks: rawChunks.length,
|
|
205
230
|
},
|
|
206
|
-
timestamp:
|
|
231
|
+
timestamp: insertedAt,
|
|
207
232
|
}));
|
|
208
233
|
|
|
234
|
+
// Write the replacement BEFORE removing what it replaces. Deleting first made
|
|
235
|
+
// the refresh destructive: addDocuments can fail (embedder unreachable, ENOSPC,
|
|
236
|
+
// store closing mid-write) and the perfectly good previous entry was already
|
|
237
|
+
// gone, so a failed refresh left the URL with no entry at all rather than a
|
|
238
|
+
// stale one. In this order a failure leaves the old entry intact and the retry
|
|
239
|
+
// simply tries again.
|
|
209
240
|
await this.options.store.addDocuments(docs);
|
|
241
|
+
|
|
242
|
+
if (sameType.length > 0) {
|
|
243
|
+
// Prune only rows older than this write, so the delete cannot take the rows
|
|
244
|
+
// just inserted. Failing here leaves duplicate chunks — visible but harmless,
|
|
245
|
+
// and cleaned up by the next successful ingest of this URL — which is a far
|
|
246
|
+
// better outcome than throwing away a write that already succeeded.
|
|
247
|
+
await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, insertedAt).catch((err) => {
|
|
248
|
+
logger.warn(`[writer-queue] Replacement for ${normalizedUrl} (${incomingType}) is stored, but pruning the superseded chunks failed; duplicates remain until the next refresh:`, err);
|
|
249
|
+
metrics.increment('knowledge_ingest_stale_prune_failed_total', 1);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
210
252
|
}
|
|
211
253
|
|
|
212
254
|
async drain(timeoutMs?: number): Promise<void> {
|
|
@@ -10,7 +10,7 @@ import { logger } from '../logger.ts';
|
|
|
10
10
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
11
11
|
import { metrics } from '../utils/metrics.ts';
|
|
12
12
|
import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages, getFailedResearchers, getResearcherFailureReasons } from './session-state.ts';
|
|
13
|
-
import { MAX_EXTRA_ROUNDS_WITH_STEERING } from '../constants.ts';
|
|
13
|
+
import { MAX_EXTRA_ROUNDS_WITH_STEERING, resolveExcludedTools } from '../constants.ts';
|
|
14
14
|
import {
|
|
15
15
|
getMaxRounds,
|
|
16
16
|
} from '../core/planning-utils.ts';
|
|
@@ -218,6 +218,17 @@ export class DeepResearchOrchestrator {
|
|
|
218
218
|
excludeTools: this.options.excludeTools,
|
|
219
219
|
steeringMessages: steeringTexts,
|
|
220
220
|
});
|
|
221
|
+
} else if (this.currentRound >= maxRounds) {
|
|
222
|
+
// Last iteration: the `plan.action === 'synthesize' || currentRound >=
|
|
223
|
+
// maxRounds` break below fires no matter what the evaluator decides, and
|
|
224
|
+
// the forced-synthesis path after the loop runs updatePlanForRound again
|
|
225
|
+
// with mustSynthesize. So on every capped run whose evaluator would have
|
|
226
|
+
// said `delegate` — the common case, since it is being cut off mid-plan —
|
|
227
|
+
// this call is a second full-context coordinator request whose result is
|
|
228
|
+
// discarded. Break straight to the forced synthesis: exactly one
|
|
229
|
+
// coordinator call either way, and the same outcome.
|
|
230
|
+
logger.log(`[DeepOrchestrator] Round cap (${maxRounds}) reached — skipping the evaluator and going straight to final synthesis ${this.elapsed()}`);
|
|
231
|
+
break;
|
|
221
232
|
} else {
|
|
222
233
|
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
223
234
|
observer?.onEvaluationStart?.(this.currentRound);
|
|
@@ -382,7 +393,7 @@ export class DeepResearchOrchestrator {
|
|
|
382
393
|
options: {
|
|
383
394
|
...this.options,
|
|
384
395
|
config: this.config,
|
|
385
|
-
excludeTools: this.options.excludeTools
|
|
396
|
+
excludeTools: resolveExcludedTools(this.options.excludeTools),
|
|
386
397
|
observer: this.observer,
|
|
387
398
|
},
|
|
388
399
|
currentRound: this.currentRound,
|
|
@@ -18,7 +18,7 @@ import { logger } from '../logger.ts';
|
|
|
18
18
|
import { getConfig, type Config } from '../config.ts';
|
|
19
19
|
import { createResearcherSession } from './researcher.ts';
|
|
20
20
|
import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
|
|
21
|
-
import { getMaxScrapeBatches } from '../constants.ts';
|
|
21
|
+
import { getMaxScrapeBatches, resolveExcludedTools } from '../constants.ts';
|
|
22
22
|
import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
|
|
23
23
|
import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
24
24
|
import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
|
|
@@ -180,7 +180,7 @@ export class QuickResearchOrchestrator {
|
|
|
180
180
|
modelRegistry: ctx.modelRegistry,
|
|
181
181
|
systemPrompt: prompt,
|
|
182
182
|
extensionCtx: ctx,
|
|
183
|
-
excludeTools: this.options.excludeTools
|
|
183
|
+
excludeTools: resolveExcludedTools(this.options.excludeTools),
|
|
184
184
|
config: this.config,
|
|
185
185
|
getGlobalState: (): SystemResearchState => ({
|
|
186
186
|
version: 1,
|
|
@@ -14,7 +14,7 @@ import type { Model } from '@earendil-works/pi-ai';
|
|
|
14
14
|
import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
|
|
15
15
|
import type { QueryResultWithError } from '../web-research/types.ts';
|
|
16
16
|
import type { RunResearchersOptions } from './orchestration-types.ts';
|
|
17
|
-
import { RESEARCHER_LAUNCH_DELAY_MS } from '../constants.ts';
|
|
17
|
+
import { RESEARCHER_LAUNCH_DELAY_MS, resolveExcludedTools } from '../constants.ts';
|
|
18
18
|
import { search } from '../web-research/search.ts';
|
|
19
19
|
import { parseCitations } from '../utils/text-utils.ts';
|
|
20
20
|
import { logger, resetLogger } from '../logger.ts';
|
|
@@ -98,9 +98,7 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
98
98
|
const configDisabledTools = researchConfig.DISABLED_TOOLS
|
|
99
99
|
? researchConfig.DISABLED_TOOLS.split(',').map((s) => s.trim()).filter(Boolean)
|
|
100
100
|
: [];
|
|
101
|
-
const mergedExcludeTools = configDisabledTools
|
|
102
|
-
? [...new Set([...(excludeTools ?? []), ...configDisabledTools])]
|
|
103
|
-
: excludeTools;
|
|
101
|
+
const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
|
|
104
102
|
|
|
105
103
|
// Resolve model using centralized priority logic
|
|
106
104
|
const selectedModel = await this.resolveResearchModel(options);
|