@remnic/core 9.3.746 → 9.3.747
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/dist/access-boundary.d.ts +2 -2
- package/dist/access-cli.js +1 -1
- package/dist/access-http.d.ts +2 -2
- package/dist/access-mcp.d.ts +2 -2
- package/dist/access-operations.d.ts +2 -2
- package/dist/{access-service-Cx16nmCv.d.ts → access-service-CH1aqqb1.d.ts} +1 -1
- package/dist/access-service.d.ts +2 -2
- package/dist/access-surface-catalog.d.ts +2 -2
- package/dist/bootstrap.d.ts +1 -1
- package/dist/{chunk-PJE6D3NH.js → chunk-ZWGXZ7ID.js} +11555 -10846
- package/dist/chunk-ZWGXZ7ID.js.map +1 -0
- package/dist/{cli-D1y3vooB.d.ts → cli-CE5olss2.d.ts} +2 -2
- package/dist/cli.d.ts +3 -3
- package/dist/explicit-capture.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +2 -2
- package/dist/{orchestrator-CnCE3Hvq.d.ts → orchestrator-CIOqdNzF.d.ts} +77 -85
- package/dist/orchestrator.d.ts +1 -1
- package/dist/orchestrator.js +27 -3
- package/package.json +2 -2
- package/src/orchestration/orchestrator-init.ts +712 -0
- package/src/orchestration/recall-internal.ts +5479 -0
- package/src/orchestration/recall-introspection.ts +709 -0
- package/src/orchestration/recall-search-pipeline.ts +1710 -0
- package/src/orchestration/turn-ingestion.ts +792 -0
- package/src/orchestrator.ts +423 -8077
- package/dist/chunk-PJE6D3NH.js.map +0 -1
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator-init coordinator — extracted from the orchestrator
|
|
3
|
+
* (issue #1526, seam 22).
|
|
4
|
+
*
|
|
5
|
+
* Owns the startup lifecycle:
|
|
6
|
+
* - initialize(): directory/storage/alias/policy bring-up and the
|
|
7
|
+
* init gate that recall() awaits
|
|
8
|
+
* - deferredInitialize(): background QMD probe, warmup, caches, cron
|
|
9
|
+
* wiring, and the deferredReady gate
|
|
10
|
+
* - startupSearchSync(): the initial search-index reconciliation
|
|
11
|
+
*
|
|
12
|
+
* Behavior-preserving move from orchestrator.ts. The async init ORDERING
|
|
13
|
+
* is part of the gateway_start contract — the move keeps every await in
|
|
14
|
+
* place and mutates the orchestrator's own gate fields (initPromise,
|
|
15
|
+
* deferredReady, resolveDeferredReady, deferredInitAbort,
|
|
16
|
+
* deferredSyncSucceeded, …) through live get/set accessors, so
|
|
17
|
+
* stop/start reuse of one Orchestrator instance behaves identically.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readdir, stat, unlink } from "node:fs/promises";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { SmartBuffer } from "../buffer.js";
|
|
23
|
+
import { resolveIndexingCapabilities, resolveLocalLlmCapabilities, resolveMemoryLifecycleCapabilities, resolveNamespaceCapabilities, resolveQmdCapabilities, resolveRecallAuxiliaryCapabilities, resolveUtilityLearningCapabilities } from "../capabilities.js";
|
|
24
|
+
import { CompoundingEngine } from "../compounding/engine.js";
|
|
25
|
+
import type { ConversationIndexBackend } from "../conversation-index/backend.js";
|
|
26
|
+
import type { CorrectionService } from "../correction/correction-service.js";
|
|
27
|
+
import { EmbeddingFallback } from "../embedding-fallback.js";
|
|
28
|
+
import { ContentHashIndex, StorageManager } from "../index.js";
|
|
29
|
+
import { log } from "../logger.js";
|
|
30
|
+
import { migrateFromEngram } from "../migrate/from-engram.js";
|
|
31
|
+
import { NamespaceCatalog } from "../namespaces/catalog.js";
|
|
32
|
+
import { NamespaceSearchRouter } from "../namespaces/search.js";
|
|
33
|
+
import { NamespaceStorageRouter } from "../namespaces/storage.js";
|
|
34
|
+
import { NegativeExampleStore } from "../negative.js";
|
|
35
|
+
import { MaintenanceScheduler } from "./maintenance.js";
|
|
36
|
+
import { PolicyRuntimeManager, type RuntimePolicyValues } from "../policy-runtime.js";
|
|
37
|
+
import { LastRecallStore, RecallHandleHistoryStore, TierMigrationStatusStore } from "../recall-state.js";
|
|
38
|
+
import { RelevanceStore } from "../relevance.js";
|
|
39
|
+
import { NoopSearchBackend } from "../search/noop-backend.js";
|
|
40
|
+
import type { SearchBackend } from "../search/port.js";
|
|
41
|
+
import { SessionObserverState } from "../session-observer-state.js";
|
|
42
|
+
import { SharedContextManager } from "../shared-context/manager.js";
|
|
43
|
+
import { HourlySummarizer } from "../summarizer.js";
|
|
44
|
+
import { TranscriptManager } from "../transcript.js";
|
|
45
|
+
import type { PluginConfig } from "../types.js";
|
|
46
|
+
import { type UtilityRuntimeValues, loadUtilityRuntimeValues } from "../utility-runtime.js";
|
|
47
|
+
import { WearablesService } from "../wearables/service.js";
|
|
48
|
+
import {
|
|
49
|
+
COMPACTION_SIGNAL_MAX_AGE_MS,
|
|
50
|
+
defaultWorkspaceDir,
|
|
51
|
+
qmdStartupCollectionCheckWithTimeout,
|
|
52
|
+
} from "../orchestrator.js";
|
|
53
|
+
|
|
54
|
+
export interface OrchestratorInitDeps {
|
|
55
|
+
readonly buffer: SmartBuffer;
|
|
56
|
+
readonly compounding?: CompoundingEngine;
|
|
57
|
+
readonly config: PluginConfig;
|
|
58
|
+
configuredNamespaceList(): string[];
|
|
59
|
+
contentHashIndex: ContentHashIndex | null;
|
|
60
|
+
readonly conversationIndexBackend?: ConversationIndexBackend;
|
|
61
|
+
deferredInitAbort: AbortController | null;
|
|
62
|
+
deferredInitialize(signal: AbortSignal): Promise<void>;
|
|
63
|
+
deferredReady: Promise<void>;
|
|
64
|
+
deferredSyncSucceeded: boolean;
|
|
65
|
+
disposeSearchBackendIfNeeded(): Promise<void>;
|
|
66
|
+
readonly embeddingFallback: EmbeddingFallback;
|
|
67
|
+
getWearablesService(): WearablesService;
|
|
68
|
+
readonly handleHistory: RecallHandleHistoryStore;
|
|
69
|
+
readonly lastRecall: LastRecallStore;
|
|
70
|
+
maintenanceNamespaces(
|
|
71
|
+
jobName?: string,
|
|
72
|
+
budgetMode?: "cycle" | "unbounded",
|
|
73
|
+
): Promise<string[]>;
|
|
74
|
+
readonly maintenanceScheduler: MaintenanceScheduler;
|
|
75
|
+
readonly namespaceCatalog: NamespaceCatalog;
|
|
76
|
+
readonly namespaceSearchRouter: NamespaceSearchRouter;
|
|
77
|
+
readonly negatives: NegativeExampleStore;
|
|
78
|
+
passiveCorrectionService(): CorrectionService;
|
|
79
|
+
readonly policyRuntime: PolicyRuntimeManager;
|
|
80
|
+
qmd: SearchBackend;
|
|
81
|
+
readonly relevance: RelevanceStore;
|
|
82
|
+
resolveDeferredReady: (() => void) | null;
|
|
83
|
+
resolveInit: (() => void) | null;
|
|
84
|
+
runtimePolicyValues: RuntimePolicyValues | null;
|
|
85
|
+
readonly sessionObserver: SessionObserverState;
|
|
86
|
+
readonly sharedContext?: SharedContextManager;
|
|
87
|
+
readonly storage: StorageManager;
|
|
88
|
+
readonly storageRouter: NamespaceStorageRouter;
|
|
89
|
+
readonly summarizer: HourlySummarizer;
|
|
90
|
+
readonly tierMigrationStatus: TierMigrationStatusStore;
|
|
91
|
+
readonly transcript: TranscriptManager;
|
|
92
|
+
utilityRuntimeValues: UtilityRuntimeValues | null;
|
|
93
|
+
validateLocalLlmModel(): Promise<void>;
|
|
94
|
+
wearablesAutoSyncHandle: { stop(): Promise<void> } | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class OrchestratorInitCoordinator {
|
|
98
|
+
constructor(
|
|
99
|
+
private readonly deps: OrchestratorInitDeps,
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
async initialize(): Promise<void> {
|
|
103
|
+
// Recreate the deferred-ready gate on every initialize() call.
|
|
104
|
+
// The same Orchestrator instance may be reused across stop/start cycles
|
|
105
|
+
// (src/index.ts does this). Without this reset, the second cycle's
|
|
106
|
+
// `await orchestrator.deferredReady` resolves immediately (already settled
|
|
107
|
+
// from the first cycle) while the new deferredInitialize() is still running.
|
|
108
|
+
this.deps.deferredReady = new Promise<void>((resolve) => {
|
|
109
|
+
this.deps.resolveDeferredReady = resolve;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await migrateFromEngram({
|
|
114
|
+
quiet: true,
|
|
115
|
+
logger: (message) => log.info(message),
|
|
116
|
+
});
|
|
117
|
+
await this.deps.storage.ensureDirectories();
|
|
118
|
+
await this.deps.storage.loadAliases();
|
|
119
|
+
if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
|
|
120
|
+
const namespaces = new Set<string>([
|
|
121
|
+
this.deps.config.defaultNamespace,
|
|
122
|
+
this.deps.config.sharedNamespace,
|
|
123
|
+
...this.deps.config.namespacePolicies.map((p) => p.name),
|
|
124
|
+
]);
|
|
125
|
+
for (const ns of namespaces) {
|
|
126
|
+
const sm = await this.deps.storageRouter.storageFor(ns);
|
|
127
|
+
await sm.ensureDirectories();
|
|
128
|
+
await sm.loadAliases().catch(() => undefined);
|
|
129
|
+
}
|
|
130
|
+
// Explicitly seed the catalog with all configured namespaces at startup
|
|
131
|
+
// (round 6, cursor Medium — NBLlR). The storageFor loop above fires the
|
|
132
|
+
// router's onResolve hook, but a warm router cache (reused instance
|
|
133
|
+
// across stop/start) can skip onResolve, leaving policy namespaces absent
|
|
134
|
+
// from the live catalog until an operator runs `rebuild --apply`. This
|
|
135
|
+
// call is cheap, idempotent, and best-effort: a catalog failure must
|
|
136
|
+
// never break initialization (rule #13, #40).
|
|
137
|
+
await this.deps.namespaceCatalog.registerConfiguredNamespaces().catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
// #1713 Item 2: recover stale `applying` correction plans left behind
|
|
140
|
+
// by a process that died mid-apply. Best-effort — a failure here must
|
|
141
|
+
// never block initialization (rule 13). Runs for every configured
|
|
142
|
+
// namespace since correction plans can exist in any of them.
|
|
143
|
+
try {
|
|
144
|
+
// #1713 Item 2 + P2 (cursor): sweep ALL known namespaces — configured
|
|
145
|
+
// + catalog-discovered — so stale applying plans in derived namespaces
|
|
146
|
+
// (coding-scoped, session-derived) are also recovered.
|
|
147
|
+
const correctionNamespaces = new Set(this.deps.configuredNamespaceList());
|
|
148
|
+
if (this.deps.namespaceCatalog.enabled) {
|
|
149
|
+
try {
|
|
150
|
+
for (const rec of await this.deps.namespaceCatalog.listNamespaces()) {
|
|
151
|
+
correctionNamespaces.add(rec.namespace);
|
|
152
|
+
}
|
|
153
|
+
} catch { /* best-effort */ }
|
|
154
|
+
}
|
|
155
|
+
const recovered = await this.deps.passiveCorrectionService().recoverStaleApplyingPlans(
|
|
156
|
+
[...correctionNamespaces],
|
|
157
|
+
);
|
|
158
|
+
if (recovered > 0) {
|
|
159
|
+
log.info(`correction: recovered ${recovered} stale applying plan(s) on startup`);
|
|
160
|
+
}
|
|
161
|
+
} catch (staleErr) {
|
|
162
|
+
log.debug(`correction: stale-plan recovery skipped: ${staleErr instanceof Error ? staleErr.message : String(staleErr)}`);
|
|
163
|
+
}
|
|
164
|
+
await this.deps.relevance.load();
|
|
165
|
+
await this.deps.negatives.load();
|
|
166
|
+
await this.deps.lastRecall.load();
|
|
167
|
+
await this.deps.handleHistory.load();
|
|
168
|
+
await this.deps.tierMigrationStatus.load();
|
|
169
|
+
await this.deps.sessionObserver.load();
|
|
170
|
+
this.deps.runtimePolicyValues = await this.deps.policyRuntime.loadRuntimeValues();
|
|
171
|
+
this.deps.utilityRuntimeValues = await loadUtilityRuntimeValues({
|
|
172
|
+
memoryDir: this.deps.config.memoryDir,
|
|
173
|
+
memoryUtilityLearningEnabled: resolveUtilityLearningCapabilities(this.deps.config).memoryUtilityLearning,
|
|
174
|
+
promotionByOutcomeEnabled: resolveUtilityLearningCapabilities(this.deps.config).promotionByOutcome,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Initialize content-hash dedup index
|
|
178
|
+
if (resolveRecallAuxiliaryCapabilities(this.deps.config).factDeduplication) {
|
|
179
|
+
this.deps.contentHashIndex = this.deps.storage.createContentHashIndex();
|
|
180
|
+
await this.deps.contentHashIndex.load();
|
|
181
|
+
log.info(
|
|
182
|
+
`content-hash dedup: loaded ${this.deps.contentHashIndex.size} hashes`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
await this.deps.transcript.initialize();
|
|
186
|
+
await this.deps.summarizer.initialize();
|
|
187
|
+
if (this.deps.sharedContext) {
|
|
188
|
+
await this.deps.sharedContext.ensureStructure();
|
|
189
|
+
}
|
|
190
|
+
if (this.deps.compounding) {
|
|
191
|
+
await this.deps.compounding.ensureDirs();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Buffer and compaction cleanup are fast and needed for basic operation —
|
|
195
|
+
// load them before the init gate so turn buffering works immediately.
|
|
196
|
+
try {
|
|
197
|
+
await this.deps.buffer.load();
|
|
198
|
+
} catch (bufErr) {
|
|
199
|
+
log.error(
|
|
200
|
+
`buffer.load() failed (init gate will still open): ${bufErr}`,
|
|
201
|
+
);
|
|
202
|
+
this.deps.buffer.resetToEmpty();
|
|
203
|
+
}
|
|
204
|
+
if (resolveRecallAuxiliaryCapabilities(this.deps.config).compactionReset) {
|
|
205
|
+
try {
|
|
206
|
+
const wsDir = this.deps.config.workspaceDir || defaultWorkspaceDir();
|
|
207
|
+
const files = await readdir(wsDir).catch(() => [] as string[]);
|
|
208
|
+
for (const f of files) {
|
|
209
|
+
if (!f.startsWith(".compaction-reset-signal-")) continue;
|
|
210
|
+
const fp = path.join(wsDir, f);
|
|
211
|
+
const s = await stat(fp).catch(() => null);
|
|
212
|
+
if (s && Date.now() - s.mtimeMs >= COMPACTION_SIGNAL_MAX_AGE_MS) {
|
|
213
|
+
await unlink(fp).catch(() => {});
|
|
214
|
+
log.debug(`initialize: removed stale compaction signal ${f}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
log.debug("initialize: stale signal sweep failed:", err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// QMD probe + collection check: determines the final QMD state (real
|
|
223
|
+
// client vs NoopSearchBackend). Must complete BEFORE the init gate opens
|
|
224
|
+
// so that recall() — which awaits initPromise — always observes the final
|
|
225
|
+
// QMD state. Without this ordering, a concurrent recall() could read
|
|
226
|
+
// this.deps.qmd while it's still the real client, then get errors when
|
|
227
|
+
// deferredInitialize() swaps it to NoopSearchBackend mid-query.
|
|
228
|
+
try {
|
|
229
|
+
const available = await this.deps.qmd.probe();
|
|
230
|
+
if (available) {
|
|
231
|
+
log.info(`Search backend: available ${this.deps.qmd.debugStatus()}`);
|
|
232
|
+
// Ensure collections at startup for the catalog-union namespace set, not
|
|
233
|
+
// just the configured set (issue #1499 sweep, same class as NHZEV): a
|
|
234
|
+
// dynamic namespace that exists only in the persisted catalog must have
|
|
235
|
+
// its QMD collection checked/ensured on boot so recall against it works
|
|
236
|
+
// after a restart. `registerConfiguredNamespaces()` already seeded the
|
|
237
|
+
// catalog above, so `maintenanceNamespaces()` is readable here; it falls
|
|
238
|
+
// back to the configured set on any catalog read failure.
|
|
239
|
+
const namespaces = resolveNamespaceCapabilities(this.deps.config).namespaces
|
|
240
|
+
? await this.deps.maintenanceNamespaces()
|
|
241
|
+
: [this.deps.config.defaultNamespace];
|
|
242
|
+
const states = await Promise.all(
|
|
243
|
+
namespaces.map(async (namespace) => {
|
|
244
|
+
const collectionCheckAbort = new AbortController();
|
|
245
|
+
const state = await qmdStartupCollectionCheckWithTimeout(
|
|
246
|
+
resolveNamespaceCapabilities(this.deps.config).namespaces
|
|
247
|
+
? this.deps.namespaceSearchRouter.ensureNamespaceCollection(
|
|
248
|
+
namespace,
|
|
249
|
+
{ signal: collectionCheckAbort.signal },
|
|
250
|
+
)
|
|
251
|
+
: this.deps.qmd.ensureCollection(
|
|
252
|
+
this.deps.config.memoryDir,
|
|
253
|
+
this.deps.config.qmdCollection,
|
|
254
|
+
{ signal: collectionCheckAbort.signal },
|
|
255
|
+
),
|
|
256
|
+
collectionCheckAbort,
|
|
257
|
+
namespace,
|
|
258
|
+
);
|
|
259
|
+
return { namespace, state };
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
const defaultState =
|
|
263
|
+
states.find(
|
|
264
|
+
(entry) => entry.namespace === this.deps.config.defaultNamespace,
|
|
265
|
+
)?.state ?? "unknown";
|
|
266
|
+
if (defaultState === "missing") {
|
|
267
|
+
await this.deps.disposeSearchBackendIfNeeded();
|
|
268
|
+
this.deps.qmd = new NoopSearchBackend();
|
|
269
|
+
log.warn(
|
|
270
|
+
"Search collection missing for Remnic memory store; disabling search retrieval for this runtime (fallback retrieval remains enabled)",
|
|
271
|
+
);
|
|
272
|
+
} else if (defaultState === "unknown") {
|
|
273
|
+
log.warn(
|
|
274
|
+
"Search collection check unavailable; keeping search retrieval enabled for fail-open behavior",
|
|
275
|
+
);
|
|
276
|
+
} else if (defaultState === "skipped") {
|
|
277
|
+
log.debug(
|
|
278
|
+
"Search collection check skipped (remote or daemon-only mode)",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
for (const entry of states) {
|
|
282
|
+
if (entry.namespace === this.deps.config.defaultNamespace) continue;
|
|
283
|
+
if (entry.state === "missing") {
|
|
284
|
+
log.warn(
|
|
285
|
+
`Search collection missing for namespace '${entry.namespace}'; namespace retrieval will fail open to non-search paths`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} else if (this.deps.qmd instanceof NoopSearchBackend) {
|
|
290
|
+
log.debug(`Search backend: noop (search intentionally disabled)`);
|
|
291
|
+
} else {
|
|
292
|
+
log.warn(`Search backend: not available ${this.deps.qmd.debugStatus()}`);
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
log.error(`QMD probe/collection check failed (non-fatal): ${err}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Open the init gate — essential state (storage, aliases, relevance,
|
|
299
|
+
// transcript, summarizer, buffer) is loaded AND QMD state is finalized
|
|
300
|
+
// (probe + collection check complete, NoopSearchBackend swap done if
|
|
301
|
+
// needed). Warmup, sync, caches, and remaining heavy operations run in
|
|
302
|
+
// the background after this point via deferredInitialize().
|
|
303
|
+
if (this.deps.resolveInit) {
|
|
304
|
+
this.deps.resolveInit();
|
|
305
|
+
this.deps.resolveInit = null;
|
|
306
|
+
log.info("init gate opened (essential state + QMD state loaded)");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Deferred init: QMD sync, warmup, conversation index, caches, cron.
|
|
310
|
+
// Runs in background so gateway_start returns fast. On low-power hardware
|
|
311
|
+
// (Umbrel, RPi) QMD warmup/sync alone can take 30-60s and cause gateway
|
|
312
|
+
// restart loops when they block the startup path. See issue #462.
|
|
313
|
+
// Note: QMD probe + collection check (including NoopSearchBackend swap)
|
|
314
|
+
// already ran above before the init gate, so this.deps.qmd is finalized.
|
|
315
|
+
//
|
|
316
|
+
// Capture the resolver by value so a concurrent re-initialize() cannot
|
|
317
|
+
// overwrite this.deps.resolveDeferredReady before .finally() runs — that would
|
|
318
|
+
// cause the first cycle's .finally() to resolve the *second* cycle's
|
|
319
|
+
// promise prematurely while leaving the first cycle's promise pending.
|
|
320
|
+
const resolveDeferred = this.deps.resolveDeferredReady;
|
|
321
|
+
this.deps.resolveDeferredReady = null;
|
|
322
|
+
this.deps.deferredInitAbort = new AbortController();
|
|
323
|
+
this.deps.deferredInitialize(this.deps.deferredInitAbort.signal)
|
|
324
|
+
.catch((err) => {
|
|
325
|
+
log.error(`deferred initialization failed (non-fatal): ${err}`);
|
|
326
|
+
})
|
|
327
|
+
.finally(() => {
|
|
328
|
+
resolveDeferred?.();
|
|
329
|
+
});
|
|
330
|
+
} catch (err) {
|
|
331
|
+
// Resolve both gates so callers never hang on permanently-pending promises
|
|
332
|
+
// after catching the initialize() error:
|
|
333
|
+
//
|
|
334
|
+
// - initPromise: recall(), generateDaySummary(), etc. await this as a
|
|
335
|
+
// readiness gate with a 15s timeout. Leaving it pending means every
|
|
336
|
+
// subsequent call pays that timeout penalty.
|
|
337
|
+
//
|
|
338
|
+
// - deferredReady: CLI callers await this for full QMD readiness. Without
|
|
339
|
+
// resolution it hangs forever since deferredInitialize() never ran.
|
|
340
|
+
if (this.deps.resolveInit) {
|
|
341
|
+
this.deps.resolveInit();
|
|
342
|
+
this.deps.resolveInit = null;
|
|
343
|
+
}
|
|
344
|
+
if (this.deps.resolveDeferredReady) {
|
|
345
|
+
this.deps.resolveDeferredReady();
|
|
346
|
+
this.deps.resolveDeferredReady = null;
|
|
347
|
+
}
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async deferredInitialize(signal: AbortSignal): Promise<void> {
|
|
353
|
+
const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.deps.config);
|
|
354
|
+
|
|
355
|
+
// Sync QMD index with current disk state so recall finds recently-written
|
|
356
|
+
// facts. Without this, the index stays stale from the last extraction-
|
|
357
|
+
// triggered update — which can be days ago if the daemon restarted without
|
|
358
|
+
// new extractions. This is the root cause of "0 memories" recall results
|
|
359
|
+
// despite thousands of facts on disk.
|
|
360
|
+
if (this.deps.qmd.isAvailable() && resolveQmdCapabilities(this.deps.config).qmdMaintenance) {
|
|
361
|
+
try {
|
|
362
|
+
log.info("QMD startup sync: updating index to match current disk state");
|
|
363
|
+
if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
|
|
364
|
+
// Cover cataloged dynamic namespaces at startup too (NHZEV, codex P2):
|
|
365
|
+
// a dynamic namespace written before a daemon restart must be synced on
|
|
366
|
+
// boot, not only by the debounced runQmdMaintenance() path. Same union +
|
|
367
|
+
// catalog-read-failure fallback as runQmdMaintenance.
|
|
368
|
+
await this.deps.namespaceSearchRouter.updateNamespaces(
|
|
369
|
+
await this.deps.maintenanceNamespaces(),
|
|
370
|
+
{ signal },
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
await this.deps.qmd.update({ signal });
|
|
374
|
+
}
|
|
375
|
+
log.info("QMD startup sync: complete");
|
|
376
|
+
this.deps.deferredSyncSucceeded = true;
|
|
377
|
+
} catch (err) {
|
|
378
|
+
log.warn(`QMD startup sync failed (non-fatal): ${err}`);
|
|
379
|
+
// deferredSyncSucceeded stays false — server retry will attempt sync
|
|
380
|
+
}
|
|
381
|
+
} else if (!(this.deps.qmd.isAvailable())) {
|
|
382
|
+
// QMD not available at deferred init time — server retry will handle it
|
|
383
|
+
} else {
|
|
384
|
+
// QMD available but maintenance disabled — consider sync not needed
|
|
385
|
+
this.deps.deferredSyncSucceeded = true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (signal.aborted) return;
|
|
389
|
+
|
|
390
|
+
// Warmup: run cheap searches to pre-load QMD embedding models and the
|
|
391
|
+
// embedding-fallback JSON index so the first real recall is fast.
|
|
392
|
+
const warmupPromises: Promise<void>[] = [];
|
|
393
|
+
if (this.deps.qmd.isAvailable()) {
|
|
394
|
+
const warmupNs = this.deps.config.defaultNamespace;
|
|
395
|
+
log.info("QMD warmup: pre-loading models with a test search");
|
|
396
|
+
warmupPromises.push(
|
|
397
|
+
this.deps.qmd
|
|
398
|
+
.search("warmup", warmupNs, 1, undefined, { signal })
|
|
399
|
+
.then(() => {
|
|
400
|
+
log.info("QMD warmup: complete");
|
|
401
|
+
})
|
|
402
|
+
.catch((err) => {
|
|
403
|
+
log.debug(`QMD warmup search failed (non-fatal): ${err}`);
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (resolveMemoryLifecycleCapabilities(this.deps.config).embeddingFallback) {
|
|
408
|
+
warmupPromises.push(
|
|
409
|
+
this.deps.embeddingFallback
|
|
410
|
+
.isAvailable()
|
|
411
|
+
.then((ok) => {
|
|
412
|
+
log.info(
|
|
413
|
+
`Embedding fallback warmup: ${ok ? "available" : "unavailable (no provider)"}`,
|
|
414
|
+
);
|
|
415
|
+
})
|
|
416
|
+
.catch((err) => {
|
|
417
|
+
log.debug(`Embedding fallback warmup failed (non-fatal): ${err}`);
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
await Promise.all(warmupPromises);
|
|
422
|
+
if (signal.aborted) return;
|
|
423
|
+
|
|
424
|
+
// Pre-warm knowledge index, memory, and entity caches.
|
|
425
|
+
// Awaited so callers of `deferredReady` can rely on warmups being complete
|
|
426
|
+
// and shutdown sequencing does not race with in-flight cache builds.
|
|
427
|
+
const cacheWarmups: Promise<void>[] = [];
|
|
428
|
+
if (resolveRecallAuxiliaryCapabilities(this.deps.config).knowledgeIndex) {
|
|
429
|
+
cacheWarmups.push(
|
|
430
|
+
(async () => {
|
|
431
|
+
try {
|
|
432
|
+
const t0 = Date.now();
|
|
433
|
+
await this.deps.storage.buildKnowledgeIndex(this.deps.config);
|
|
434
|
+
log.info(`Knowledge Index warmup: complete in ${Date.now() - t0}ms`);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
log.debug(`Knowledge Index warmup failed (non-fatal): ${err}`);
|
|
437
|
+
}
|
|
438
|
+
})(),
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
cacheWarmups.push(this.deps.storage.readAllMemories().then(() => {}).catch(() => {}));
|
|
442
|
+
cacheWarmups.push(this.deps.storage.readAllEntityFiles().then(() => {}).catch(() => {}));
|
|
443
|
+
await Promise.all(cacheWarmups);
|
|
444
|
+
if (signal.aborted) return;
|
|
445
|
+
|
|
446
|
+
if (resolveIndexingCapabilities(this.deps.config).conversationIndex && this.deps.conversationIndexBackend) {
|
|
447
|
+
try {
|
|
448
|
+
const init = await this.deps.conversationIndexBackend.initialize();
|
|
449
|
+
if (!init.enabled) {
|
|
450
|
+
this.deps.config.conversationIndexEnabled = false;
|
|
451
|
+
}
|
|
452
|
+
if (init.logLevel === "info") {
|
|
453
|
+
log.info(init.message);
|
|
454
|
+
} else if (init.logLevel === "warn") {
|
|
455
|
+
log.warn(init.message);
|
|
456
|
+
} else {
|
|
457
|
+
log.debug(init.message);
|
|
458
|
+
}
|
|
459
|
+
} catch (err) {
|
|
460
|
+
log.error(`Conversation index initialization failed (non-fatal): ${err}`);
|
|
461
|
+
this.deps.config.conversationIndexEnabled = false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (signal.aborted) return;
|
|
466
|
+
|
|
467
|
+
if (resolveLocalLlmCapabilities(this.deps.config).localLlm) {
|
|
468
|
+
try {
|
|
469
|
+
await this.deps.validateLocalLlmModel();
|
|
470
|
+
} catch (err) {
|
|
471
|
+
log.error(`Local LLM validation failed (non-fatal): ${err}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (signal.aborted) return;
|
|
476
|
+
|
|
477
|
+
// Await cron auto-registration so callers that `await deferredReady` can
|
|
478
|
+
// rely on cron jobs being registered when it resolves. Without this, the
|
|
479
|
+
// fire-and-forget pattern lets deferredReady settle while cron writes are
|
|
480
|
+
// still in flight. Errors are non-fatal — catch individually.
|
|
481
|
+
// Auto-register every cron job in one pass. Each registration is gated
|
|
482
|
+
// by its config flag inside the scheduler and individually non-fatal
|
|
483
|
+
// (issue #1526 PR1 — moved to MaintenanceScheduler).
|
|
484
|
+
await this.deps.maintenanceScheduler.autoRegisterCrons(signal);
|
|
485
|
+
|
|
486
|
+
// First-start lifecycle migration (issue #686 retention-completion).
|
|
487
|
+
// When lifecyclePolicyEnabled is true and the memoryDir has never been
|
|
488
|
+
// touched by the lifecycle policy, run a one-time rate-limited demotion
|
|
489
|
+
// sweep (capped at 50 demotions) so the hot tier isn't flooded on the
|
|
490
|
+
// first real cron pass. Non-fatal — a failure here must not break init.
|
|
491
|
+
if (signal.aborted) return;
|
|
492
|
+
if (lifecycleCaps.lifecyclePolicy && resolveQmdCapabilities(this.deps.config).qmdTierMigration) {
|
|
493
|
+
try {
|
|
494
|
+
const { runFirstStartMigration } = await import("../maintenance/first-start-migration.js"
|
|
495
|
+
);
|
|
496
|
+
const result = await runFirstStartMigration({
|
|
497
|
+
storage: this.deps.storage,
|
|
498
|
+
config: this.deps.config,
|
|
499
|
+
qmd: this.deps.qmd,
|
|
500
|
+
hotCollection: this.deps.config.qmdCollection,
|
|
501
|
+
coldCollection: this.deps.config.qmdColdCollection,
|
|
502
|
+
signal,
|
|
503
|
+
});
|
|
504
|
+
if (!result.skipped) {
|
|
505
|
+
log.info(
|
|
506
|
+
`first-start lifecycle migration: demoted ${result.demotedCount} of ${result.candidateCount} candidates (cap=${result.cappedAt})`,
|
|
507
|
+
);
|
|
508
|
+
} else {
|
|
509
|
+
log.debug(`first-start lifecycle migration skipped: ${result.skipReason}`);
|
|
510
|
+
}
|
|
511
|
+
} catch (err) {
|
|
512
|
+
log.warn(`first-start lifecycle migration failed (non-fatal): ${err}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Wearables auto-sync: in-process periodic transcript refresh for
|
|
517
|
+
// long-lived hosts (default on). Today's transcript keeps growing
|
|
518
|
+
// while the wearable records; a once-per-local-day deep pass picks
|
|
519
|
+
// up late uploads and provider re-processing. Static config gate —
|
|
520
|
+
// sources can't appear at runtime, so checking once here is safe.
|
|
521
|
+
// The timer is unref'd, so one-shot CLI runs exit naturally without
|
|
522
|
+
// ever ticking; idempotent across stop/start cycles via the handle
|
|
523
|
+
// guard. Non-fatal: a failure to start must not break init.
|
|
524
|
+
if (signal.aborted) return;
|
|
525
|
+
if (
|
|
526
|
+
!this.deps.wearablesAutoSyncHandle &&
|
|
527
|
+
this.deps.config.wearables.enabled &&
|
|
528
|
+
this.deps.config.wearables.autoSyncEnabled &&
|
|
529
|
+
Object.values(this.deps.config.wearables.sources).some((source) => source.enabled)
|
|
530
|
+
) {
|
|
531
|
+
try {
|
|
532
|
+
const { startWearablesAutoSync } = await import("../wearables/auto-sync.js");
|
|
533
|
+
// Re-check after the await: destroy() may have aborted while
|
|
534
|
+
// the import was in flight, having found no handle to stop —
|
|
535
|
+
// starting now would leave a live interval on a destroyed
|
|
536
|
+
// orchestrator (Cursor review on PR #1464). Handle creation
|
|
537
|
+
// below is synchronous, so no further window exists.
|
|
538
|
+
if (signal.aborted) return;
|
|
539
|
+
this.deps.wearablesAutoSyncHandle = startWearablesAutoSync(
|
|
540
|
+
{
|
|
541
|
+
intervalMinutes: this.deps.config.wearables.autoSyncIntervalMinutes,
|
|
542
|
+
days: this.deps.config.wearables.autoSyncDays,
|
|
543
|
+
deepDays: this.deps.config.wearables.autoSyncDeepDays,
|
|
544
|
+
...(this.deps.config.wearables.timezone !== undefined
|
|
545
|
+
? { timezone: this.deps.config.wearables.timezone }
|
|
546
|
+
: {}),
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
sync: (options) => this.deps.getWearablesService().sync(options),
|
|
550
|
+
log: {
|
|
551
|
+
info: (message) => log.info(message),
|
|
552
|
+
warn: (message) => log.warn(message),
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
);
|
|
556
|
+
log.info(
|
|
557
|
+
`wearables auto-sync started: every ${this.deps.config.wearables.autoSyncIntervalMinutes}m over ${this.deps.config.wearables.autoSyncDays}d (deep ${this.deps.config.wearables.autoSyncDeepDays}d daily)`,
|
|
558
|
+
);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
const { displayErrorDetail } = await import("../runtime/better-sqlite.js");
|
|
561
|
+
log.warn(
|
|
562
|
+
`wearables auto-sync failed to start (non-fatal): ${displayErrorDetail(err)}`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
log.info("orchestrator initialized (full — deferred steps complete)");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Namespace-aware startup search sync. Re-probes QMD, ensures collections
|
|
572
|
+
* (namespace-aware when namespacesEnabled), runs update, and warms up search.
|
|
573
|
+
* Designed for server retry paths that run after the deferred init completes
|
|
574
|
+
* when QMD was not available during initial startup.
|
|
575
|
+
*
|
|
576
|
+
* Accepts an optional AbortSignal so callers can interrupt the sync during
|
|
577
|
+
* shutdown. The signal is checked between phases and forwarded into the QMD
|
|
578
|
+
* update and warmup search calls so a long-running `qmd update` subprocess
|
|
579
|
+
* is killed promptly rather than left in flight after `httpServer.stop()`.
|
|
580
|
+
*
|
|
581
|
+
* Returns true if the sync succeeded (QMD now available), false otherwise.
|
|
582
|
+
*/
|
|
583
|
+
async startupSearchSync(signal?: AbortSignal): Promise<boolean> {
|
|
584
|
+
if (signal?.aborted) return false;
|
|
585
|
+
|
|
586
|
+
const available = await this.deps.qmd.probe();
|
|
587
|
+
if (!available) return false;
|
|
588
|
+
if (signal?.aborted) {
|
|
589
|
+
log.debug("startupSearchSync: aborted after probe");
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
log.info(`startupSearchSync: backend now available ${this.deps.qmd.debugStatus()}`);
|
|
594
|
+
|
|
595
|
+
// Clear namespace router cache so re-probe picks up newly available backends
|
|
596
|
+
if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
|
|
597
|
+
this.deps.namespaceSearchRouter.clearCache();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Ensure collections — namespace-aware when enabled.
|
|
601
|
+
// Use the catalog-union namespace set (issue #1499 sweep, same class as
|
|
602
|
+
// NHZEV): this is the QMD startup-recovery sync that ensures collections AND
|
|
603
|
+
// runs `updateNamespaces(...)` below over the SAME `namespaces` set. A dynamic
|
|
604
|
+
// namespace that exists only in the persisted catalog must be ensured and
|
|
605
|
+
// re-synced here too, otherwise after a backend-was-unavailable-at-boot
|
|
606
|
+
// recovery its collection stays stale. Falls back to the configured set on any
|
|
607
|
+
// catalog read failure.
|
|
608
|
+
const namespaces = resolveNamespaceCapabilities(this.deps.config).namespaces
|
|
609
|
+
? await this.deps.maintenanceNamespaces()
|
|
610
|
+
: [this.deps.config.defaultNamespace];
|
|
611
|
+
|
|
612
|
+
const states = await Promise.all(
|
|
613
|
+
namespaces.map(async (namespace) => ({
|
|
614
|
+
namespace,
|
|
615
|
+
state: resolveNamespaceCapabilities(this.deps.config).namespaces
|
|
616
|
+
? await this.deps.namespaceSearchRouter.ensureNamespaceCollection(namespace, { signal })
|
|
617
|
+
: await this.deps.qmd.ensureCollection(this.deps.config.memoryDir, this.deps.config.qmdCollection, { signal }),
|
|
618
|
+
})),
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
if (signal?.aborted) {
|
|
622
|
+
log.debug("startupSearchSync: aborted after ensureCollection");
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const defaultState =
|
|
627
|
+
states.find((e) => e.namespace === this.deps.config.defaultNamespace)?.state ?? "unknown";
|
|
628
|
+
if (defaultState === "missing") {
|
|
629
|
+
// Reset the real backend's available flag before replacing it with noop.
|
|
630
|
+
// probe() set available=true earlier in this call; without this reset,
|
|
631
|
+
// any code that captured a reference to the old backend (e.g. a concurrent
|
|
632
|
+
// recall() that read this.deps.qmd before the reassignment) would observe
|
|
633
|
+
// isAvailable()===true against a backend with a missing collection.
|
|
634
|
+
if ("available" in this.deps.qmd) {
|
|
635
|
+
(this.deps.qmd as any).available = false;
|
|
636
|
+
}
|
|
637
|
+
await this.deps.disposeSearchBackendIfNeeded();
|
|
638
|
+
this.deps.qmd = new NoopSearchBackend();
|
|
639
|
+
log.warn("startupSearchSync: search collection missing; disabling search (fallback retrieval remains enabled)");
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Run index update — namespace-aware when enabled.
|
|
644
|
+
// qmd.update() swallows errors internally, so we: (1) snapshot fail/run
|
|
645
|
+
// timestamps, (2) reset throttles so the update isn't skipped by stale
|
|
646
|
+
// backoff, and (3) verify timestamps after update to confirm it executed
|
|
647
|
+
// and didn't fail silently.
|
|
648
|
+
// The abort signal is forwarded into the QMD subprocess call so the
|
|
649
|
+
// long-running `qmd update` process is killed promptly on shutdown.
|
|
650
|
+
if (resolveQmdCapabilities(this.deps.config).qmdMaintenance) {
|
|
651
|
+
try {
|
|
652
|
+
const failTsBefore = "lastUpdateFailedAtMs" in this.deps.qmd
|
|
653
|
+
? (this.deps.qmd as any).lastUpdateFailedAtMs as number | null
|
|
654
|
+
: null;
|
|
655
|
+
const hasRunTs = "lastUpdateRanAtMs" in this.deps.qmd;
|
|
656
|
+
if ("resetUpdateThrottles" in this.deps.qmd) {
|
|
657
|
+
(this.deps.qmd as any).resetUpdateThrottles();
|
|
658
|
+
}
|
|
659
|
+
log.info("startupSearchSync: updating index to match current disk state");
|
|
660
|
+
let namespacesUpdated = 0;
|
|
661
|
+
if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
|
|
662
|
+
namespacesUpdated = await this.deps.namespaceSearchRouter.updateNamespaces(
|
|
663
|
+
namespaces,
|
|
664
|
+
{ signal },
|
|
665
|
+
);
|
|
666
|
+
} else {
|
|
667
|
+
await this.deps.qmd.update({ signal });
|
|
668
|
+
}
|
|
669
|
+
if (signal?.aborted) {
|
|
670
|
+
log.debug("startupSearchSync: aborted after update");
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
const failTsAfter = "lastUpdateFailedAtMs" in this.deps.qmd
|
|
674
|
+
? (this.deps.qmd as any).lastUpdateFailedAtMs as number | null
|
|
675
|
+
: null;
|
|
676
|
+
const runTsAfter = hasRunTs
|
|
677
|
+
? (this.deps.qmd as any).lastUpdateRanAtMs as number | null
|
|
678
|
+
: null;
|
|
679
|
+
if (failTsAfter !== null && failTsAfter !== failTsBefore) {
|
|
680
|
+
log.warn("startupSearchSync: update silently failed (detected via fail timestamp)");
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
|
|
684
|
+
if (namespacesUpdated === 0) {
|
|
685
|
+
log.warn("startupSearchSync: no namespace backends were eligible for update (all unavailable or collections missing)");
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
log.info(`startupSearchSync: namespace updates succeeded (${namespacesUpdated}/${namespaces.length} namespaces updated)`);
|
|
689
|
+
} else if (hasRunTs && runTsAfter === null) {
|
|
690
|
+
log.warn("startupSearchSync: update was throttled/skipped (run timestamp is null after reset + update)");
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
log.info("startupSearchSync: sync complete");
|
|
694
|
+
} catch (err) {
|
|
695
|
+
log.warn(`startupSearchSync: update failed: ${err}`);
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Warmup search to pre-load embedding models
|
|
701
|
+
if (!signal?.aborted) {
|
|
702
|
+
try {
|
|
703
|
+
await this.deps.qmd.search("warmup", this.deps.config.defaultNamespace, 1, undefined, { signal });
|
|
704
|
+
log.info("startupSearchSync: warmup complete");
|
|
705
|
+
} catch (err) {
|
|
706
|
+
log.debug(`startupSearchSync: warmup search failed (non-fatal): ${err}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
}
|