renor-engine 0.0.0-stage → 1.0.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/LICENSE +661 -0
- package/NOTICE +73 -0
- package/README.md +105 -2
- package/dist/BUILD.json +6 -0
- package/dist/api/apiAuth.js +43 -0
- package/dist/api/server.js +172 -0
- package/dist/buildIdentity.js +132 -0
- package/dist/cli-extractors.js +46 -0
- package/dist/cli.js +491 -0
- package/dist/config.js +65 -0
- package/dist/context/composer.js +320 -0
- package/dist/context/readHonesty.js +478 -0
- package/dist/context/rowStore.js +238 -0
- package/dist/conversation/formatter.js +81 -0
- package/dist/conversation/parser.js +189 -0
- package/dist/conversation/types.js +2 -0
- package/dist/conversation/writer.js +42 -0
- package/dist/db/client.js +147 -0
- package/dist/db/indexSalvage.js +81 -0
- package/dist/db/migrations.js +642 -0
- package/dist/db/readCache.js +109 -0
- package/dist/db/schema.js +5 -0
- package/dist/db/statements.js +89 -0
- package/dist/digests/digestCache.js +142 -0
- package/dist/digests/digests.js +597 -0
- package/dist/digests/worker.js +348 -0
- package/dist/embeddings/caching.js +337 -0
- package/dist/embeddings/coalescing.js +177 -0
- package/dist/embeddings/interactive.js +103 -0
- package/dist/embeddings/local.js +68 -0
- package/dist/embeddings/openai.js +105 -0
- package/dist/embeddings/rebuild.js +166 -0
- package/dist/embeddings/simdScan.js +258 -0
- package/dist/embeddings/space.js +21 -0
- package/dist/embeddings/types.js +14 -0
- package/dist/embeddings/vectorIndex.js +675 -0
- package/dist/embeddings/vertex.js +224 -0
- package/dist/eval/artifacts.js +42 -0
- package/dist/eval/cacheReport.js +139 -0
- package/dist/eval/comparison.js +182 -0
- package/dist/eval/context700-candidate.js +145 -0
- package/dist/eval/evalCache.js +525 -0
- package/dist/eval/hashEmbedding.js +35 -0
- package/dist/eval/longmemeval.js +56 -0
- package/dist/eval/metrics.js +181 -0
- package/dist/eval/renorbenchBaseline.js +486 -0
- package/dist/eval/renorbenchDocuments.js +134 -0
- package/dist/eval/renorbenchProduct.js +182 -0
- package/dist/eval/runLedger.js +279 -0
- package/dist/eval/runManifest.js +479 -0
- package/dist/eval/scoring.js +197 -0
- package/dist/evidence/attach.js +267 -0
- package/dist/evidence/background.js +147 -0
- package/dist/evidence/evidenceCache.js +182 -0
- package/dist/evidence/gate.js +37 -0
- package/dist/evidence/keyPool.js +85 -0
- package/dist/evidence/ollamaChat.js +84 -0
- package/dist/evidence/optin.js +165 -0
- package/dist/evidence/options.js +130 -0
- package/dist/evidence/secondPass.js +456 -0
- package/dist/extraction/classifyCapture.js +34 -0
- package/dist/extraction/decision-model.js +174 -0
- package/dist/extraction/eventExtractor.js +113 -0
- package/dist/extraction/extractionClient.js +122 -0
- package/dist/extraction/factExtractor.js +46 -0
- package/dist/extraction/factsWritePath.js +179 -0
- package/dist/extraction/preferenceExtractor.js +153 -0
- package/dist/extraction/secretGuard.js +176 -0
- package/dist/extraction/secretScanner.js +665 -0
- package/dist/extraction/typedFacts.js +224 -0
- package/dist/extraction/writePathAdjudication.js +110 -0
- package/dist/importing/apply.js +644 -0
- package/dist/importing/bundle-write.js +80 -0
- package/dist/importing/cli.js +407 -0
- package/dist/importing/existing.js +49 -0
- package/dist/importing/facts.js +123 -0
- package/dist/importing/forgetImported.js +79 -0
- package/dist/importing/hosted.js +467 -0
- package/dist/importing/index.js +29 -0
- package/dist/importing/journalLog.js +96 -0
- package/dist/importing/markdown.js +98 -0
- package/dist/importing/plan.js +206 -0
- package/dist/importing/secretScreen.js +113 -0
- package/dist/importing/serialize.js +188 -0
- package/dist/importing/source.js +463 -0
- package/dist/importing/types.js +6 -0
- package/dist/indexer/chunker.js +319 -0
- package/dist/indexer/dailyEntries.js +63 -0
- package/dist/indexer/hasher.js +10 -0
- package/dist/indexer/laneRuns.js +48 -0
- package/dist/indexer/o200k.js +481 -0
- package/dist/indexer/o200k.json +8 -0
- package/dist/indexer/outsideRoot.js +77 -0
- package/dist/indexer/parser.js +167 -0
- package/dist/indexer/pipeline.js +1168 -0
- package/dist/indexer/staleness.js +201 -0
- package/dist/indexer/tokenizer.js +77 -0
- package/dist/indexer/walker.js +184 -0
- package/dist/mcp/hive.js +88 -0
- package/dist/mcp/requestErrors.js +47 -0
- package/dist/mcp/server.js +396 -0
- package/dist/memory/agentLease.js +43 -0
- package/dist/memory/confinedWrite.js +315 -0
- package/dist/memory/controls.js +73 -0
- package/dist/memory/dailyNote.js +265 -0
- package/dist/memory/durableAppend.js +57 -0
- package/dist/memory/factConflicts.js +203 -0
- package/dist/memory/factTerms.js +321 -0
- package/dist/memory/hiveNameHeader.cjs +10 -0
- package/dist/memory/inventory.js +55 -0
- package/dist/memory/invisibleText.js +20 -0
- package/dist/memory/lifecycle.js +1756 -0
- package/dist/memory/limits.js +13 -0
- package/dist/memory/requests.js +183 -0
- package/dist/memory/sourceName.js +200 -0
- package/dist/memory/whatsNew.js +158 -0
- package/dist/memory/writer.js +175 -0
- package/dist/memory/writerConflicts.js +85 -0
- package/dist/memorymap/memoryMap.js +1568 -0
- package/dist/memorymap/replacedJournal.js +58 -0
- package/dist/memorymap/replacements.js +405 -0
- package/dist/mvp/activity.js +86 -0
- package/dist/mvp/agentMemory.js +1744 -0
- package/dist/mvp/auth.js +531 -0
- package/dist/mvp/cli.js +73 -0
- package/dist/mvp/client-store.js +462 -0
- package/dist/mvp/compileCache.js +249 -0
- package/dist/mvp/compileSeed.js +27 -0
- package/dist/mvp/config.js +288 -0
- package/dist/mvp/connectionStatus.js +80 -0
- package/dist/mvp/contextRelevance.js +413 -0
- package/dist/mvp/engine.js +1065 -0
- package/dist/mvp/exportAll.js +359 -0
- package/dist/mvp/factLanePack.js +77 -0
- package/dist/mvp/factMarkers.js +49 -0
- package/dist/mvp/factsLane.js +203 -0
- package/dist/mvp/hiddenSpans.js +585 -0
- package/dist/mvp/history.js +413 -0
- package/dist/mvp/hitDates.js +31 -0
- package/dist/mvp/hitFloor.js +36 -0
- package/dist/mvp/identity.js +18 -0
- package/dist/mvp/idle.js +113 -0
- package/dist/mvp/memoryDir.js +98 -0
- package/dist/mvp/posthogMcp.js +210 -0
- package/dist/mvp/readScope.js +104 -0
- package/dist/mvp/readUsefulness.js +222 -0
- package/dist/mvp/sanitize.js +48 -0
- package/dist/mvp/schemaRejections.js +91 -0
- package/dist/mvp/scope.js +127 -0
- package/dist/mvp/server.js +2089 -0
- package/dist/mvp/serverCard.js +29 -0
- package/dist/mvp/storedSize.js +139 -0
- package/dist/mvp/telemetry.js +441 -0
- package/dist/retrieval/adviceLines.js +195 -0
- package/dist/retrieval/adviceQuestion.js +44 -0
- package/dist/retrieval/conversationShape.js +44 -0
- package/dist/retrieval/dailyEntryTime.js +55 -0
- package/dist/retrieval/entityLookup.js +113 -0
- package/dist/retrieval/factsFirst.js +188 -0
- package/dist/retrieval/keyword.js +84 -0
- package/dist/retrieval/ledgerEventTime.js +51 -0
- package/dist/retrieval/ledgerLiveness.js +83 -0
- package/dist/retrieval/ledgerPresentation.js +161 -0
- package/dist/retrieval/noteIdentity.js +57 -0
- package/dist/retrieval/orchestrator.js +1086 -0
- package/dist/retrieval/queryHelpers.js +142 -0
- package/dist/retrieval/questionSpan.js +475 -0
- package/dist/retrieval/readerEvents.js +690 -0
- package/dist/retrieval/readerLayout.js +503 -0
- package/dist/retrieval/relativeTimeHints.js +113 -0
- package/dist/retrieval/semantic.js +67 -0
- package/dist/router/routerCache.js +106 -0
- package/dist/router/sessionRouter.js +726 -0
- package/dist/toolRefusal.js +120 -0
- package/dist/watcher/watcher.js +75 -0
- package/package.json +120 -4
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.BACKGROUND_INDEX_FIRST_DELAY_MS = exports.UNFINISHED_RETRY_MS = exports.UNFINISHED_FILES = exports.TIMESTAMP_TICK_MS = exports.FULL_VAULT_CHECK_MS = exports.INDEX_FILE_CONCURRENCY = exports.ENTITY_LOOKUP_SURFACE = exports.QUERY_CACHE_MAX_BYTES = exports.QUERY_CACHE_MAX_ENTRIES = exports.DEFAULT_EMBED_BUDGETS = void 0;
|
|
7
|
+
exports.resolveEmbedBudgets = resolveEmbedBudgets;
|
|
8
|
+
exports.readEmbeddingTableDims = readEmbeddingTableDims;
|
|
9
|
+
exports.queryEmbeddingCachePath = queryEmbeddingCachePath;
|
|
10
|
+
exports.createEmbeddingProvider = createEmbeddingProvider;
|
|
11
|
+
exports.attachEngine = attachEngine;
|
|
12
|
+
exports.semanticReady = semanticReady;
|
|
13
|
+
exports.keywordIndexReady = keywordIndexReady;
|
|
14
|
+
exports.engineRebuildEmbeddings = engineRebuildEmbeddings;
|
|
15
|
+
exports.runningEmbeddingRebuild = runningEmbeddingRebuild;
|
|
16
|
+
exports.clientPath = clientPath;
|
|
17
|
+
exports.resolveEngineEvidence = resolveEngineEvidence;
|
|
18
|
+
exports.resolveEngineDigests = resolveEngineDigests;
|
|
19
|
+
exports.engineSearchMemory = engineSearchMemory;
|
|
20
|
+
exports.engineSearchText = engineSearchText;
|
|
21
|
+
exports.engineLookupEntity = engineLookupEntity;
|
|
22
|
+
exports.engineStatus = engineStatus;
|
|
23
|
+
exports.engineIndex = engineIndex;
|
|
24
|
+
exports.engineIndexChanged = engineIndexChanged;
|
|
25
|
+
exports.engineFinishIndexInBackground = engineFinishIndexInBackground;
|
|
26
|
+
exports.runningBackgroundIndex = runningBackgroundIndex;
|
|
27
|
+
exports.hasIndexWorkToFinish = hasIndexWorkToFinish;
|
|
28
|
+
exports.engineRememberNote = engineRememberNote;
|
|
29
|
+
const ledgerLiveness_1 = require("../retrieval/ledgerLiveness");
|
|
30
|
+
const invisibleText_1 = require("../memory/invisibleText");
|
|
31
|
+
const fs_1 = __importDefault(require("fs"));
|
|
32
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
33
|
+
const path_1 = __importDefault(require("path"));
|
|
34
|
+
const readCache_1 = require("../db/readCache");
|
|
35
|
+
const statements_1 = require("../db/statements");
|
|
36
|
+
const vectorIndex_1 = require("../embeddings/vectorIndex");
|
|
37
|
+
const space_1 = require("../embeddings/space");
|
|
38
|
+
const rebuild_1 = require("../embeddings/rebuild");
|
|
39
|
+
const openai_1 = require("../embeddings/openai");
|
|
40
|
+
const local_1 = require("../embeddings/local");
|
|
41
|
+
const vertex_1 = require("../embeddings/vertex");
|
|
42
|
+
const caching_1 = require("../embeddings/caching");
|
|
43
|
+
const interactive_1 = require("../embeddings/interactive");
|
|
44
|
+
const coalescing_1 = require("../embeddings/coalescing");
|
|
45
|
+
const readerLayout_1 = require("../retrieval/readerLayout");
|
|
46
|
+
const orchestrator_1 = require("../retrieval/orchestrator");
|
|
47
|
+
const ledgerEventTime_1 = require("../retrieval/ledgerEventTime");
|
|
48
|
+
const entityLookup_1 = require("../retrieval/entityLookup");
|
|
49
|
+
const writer_1 = require("../memory/writer");
|
|
50
|
+
const memoryMap_1 = require("../memorymap/memoryMap");
|
|
51
|
+
const pipeline_1 = require("../indexer/pipeline");
|
|
52
|
+
const walker_1 = require("../indexer/walker");
|
|
53
|
+
const queryHelpers_1 = require("../retrieval/queryHelpers");
|
|
54
|
+
const keyword_1 = require("../retrieval/keyword");
|
|
55
|
+
const noteIdentity_1 = require("../retrieval/noteIdentity");
|
|
56
|
+
const ledgerPresentation_1 = require("../retrieval/ledgerPresentation");
|
|
57
|
+
const scope_1 = require("./scope");
|
|
58
|
+
const lifecycle_1 = require("../memory/lifecycle");
|
|
59
|
+
const inventory_1 = require("../memory/inventory");
|
|
60
|
+
const readScope_1 = require("./readScope");
|
|
61
|
+
const attach_1 = require("../evidence/attach");
|
|
62
|
+
const options_1 = require("../evidence/options");
|
|
63
|
+
const background_1 = require("../evidence/background");
|
|
64
|
+
const contextRelevance_1 = require("./contextRelevance");
|
|
65
|
+
const sanitize_1 = require("./sanitize");
|
|
66
|
+
const digests_1 = require("../digests/digests");
|
|
67
|
+
const worker_1 = require("../digests/worker");
|
|
68
|
+
exports.DEFAULT_EMBED_BUDGETS = { writeMs: 5_000, readMs: 3_000 };
|
|
69
|
+
/**
|
|
70
|
+
* `RENOR_EMBED_WRITE_BUDGET_MS` and `RENOR_EMBED_READ_BUDGET_MS` set the two budgets; `0` for either (or both) lets
|
|
71
|
+
* that kind of call wait as long as the provider's retries take, which is what every call did before they existed.
|
|
72
|
+
*/
|
|
73
|
+
function resolveEmbedBudgets(env = process.env) {
|
|
74
|
+
const read = (name, fallback) => {
|
|
75
|
+
const raw = env[name]?.trim();
|
|
76
|
+
if (raw === undefined || raw === '')
|
|
77
|
+
return fallback;
|
|
78
|
+
const value = Number(raw);
|
|
79
|
+
if (!Number.isInteger(value) || value < 0 || (value > 0 && value < 100) || value > 600_000) {
|
|
80
|
+
throw new Error(`${name} must be 0 (no limit) or a number of milliseconds between 100 and 600000`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
};
|
|
84
|
+
const writeMs = read('RENOR_EMBED_WRITE_BUDGET_MS', exports.DEFAULT_EMBED_BUDGETS.writeMs);
|
|
85
|
+
const readMs = read('RENOR_EMBED_READ_BUDGET_MS', exports.DEFAULT_EMBED_BUDGETS.readMs);
|
|
86
|
+
return writeMs === 0 && readMs === 0 ? undefined : { writeMs: writeMs || Infinity, readMs: readMs || Infinity };
|
|
87
|
+
}
|
|
88
|
+
/** `provider` as one call that has a budget sees it: every call it makes ends within `budgetMs` (see EmbedOptions). */
|
|
89
|
+
function withBudget(provider, budgetMs) {
|
|
90
|
+
if (budgetMs === undefined || !Number.isFinite(budgetMs))
|
|
91
|
+
return provider;
|
|
92
|
+
return {
|
|
93
|
+
dimensions: provider.dimensions,
|
|
94
|
+
identity: provider.identity,
|
|
95
|
+
embed: texts => provider.embed(texts, { budgetMs }),
|
|
96
|
+
embedQuery: text => provider.embedQuery ? provider.embedQuery(text, { budgetMs }) : provider.embed([text], { budgetMs }).then(vectors => vectors[0]),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** sqlite-vec table dimension, or null if the table does not exist. */
|
|
100
|
+
function readEmbeddingTableDims(db) {
|
|
101
|
+
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'chunk_embeddings'`).get();
|
|
102
|
+
if (!row?.sql)
|
|
103
|
+
return null;
|
|
104
|
+
const m = row.sql.match(/FLOAT\[(\d+)\]/i);
|
|
105
|
+
return m ? Number(m[1]) : null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Where a workspace keeps the vectors of the queries it has embedded: its own private state directory, the one
|
|
109
|
+
* that holds the OAuth client registry (`MVP_AUTH_STATE_PATH`), which config validation keeps out of the memory
|
|
110
|
+
* and history trees. So export, import and memory_status never see it, and it is one file per workspace.
|
|
111
|
+
*/
|
|
112
|
+
function queryEmbeddingCachePath(config) {
|
|
113
|
+
return path_1.default.join(path_1.default.dirname(config.authStatePath), 'query-embeddings.db');
|
|
114
|
+
}
|
|
115
|
+
/** Rows and vector bytes the read-path cache may hold: a few thousand distinct queries (3 KiB each at 768 dims). */
|
|
116
|
+
exports.QUERY_CACHE_MAX_ENTRIES = 4096;
|
|
117
|
+
exports.QUERY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
118
|
+
/**
|
|
119
|
+
* The vector of a query already asked costs no second provider call. It is a cache: if it cannot be opened, the
|
|
120
|
+
* file is derived and is removed once and started again, and if it still cannot be opened the provider is
|
|
121
|
+
* returned as it is.
|
|
122
|
+
*/
|
|
123
|
+
function withQueryCache(provider, config, scope) {
|
|
124
|
+
const cachePath = queryEmbeddingCachePath(config);
|
|
125
|
+
const open = () => new caching_1.CachingEmbeddingProvider(provider, {
|
|
126
|
+
cachePath,
|
|
127
|
+
serving: true,
|
|
128
|
+
scope,
|
|
129
|
+
maxEntries: exports.QUERY_CACHE_MAX_ENTRIES,
|
|
130
|
+
maxBytes: exports.QUERY_CACHE_MAX_BYTES,
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
return open();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
try {
|
|
137
|
+
for (const suffix of ['', '-wal', '-shm'])
|
|
138
|
+
fs_1.default.rmSync(cachePath + suffix, { force: true });
|
|
139
|
+
return open();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return provider;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function createEmbeddingProvider(config,
|
|
147
|
+
/** A test points the Vertex endpoint at a local stub and shortens its retry waits; production passes nothing. */
|
|
148
|
+
vertexSeam) {
|
|
149
|
+
if (config.embeddingProvider === 'none')
|
|
150
|
+
return null;
|
|
151
|
+
if (config.embeddingProvider === 'local') {
|
|
152
|
+
return new local_1.LocalEmbeddingProvider(config.embeddingModel);
|
|
153
|
+
}
|
|
154
|
+
if (config.embeddingProvider === 'vertex') {
|
|
155
|
+
// The provider's identity is its kind and model; the project and location it is served from are not in it. The
|
|
156
|
+
// endpoint has no setting of its own (it is built from the location), so these two name it.
|
|
157
|
+
const served = crypto_1.default.createHash('sha256').update(`${config.vertexProject ?? ''}\0${config.vertexLocation ?? ''}`).digest('hex').slice(0, 12);
|
|
158
|
+
// The query cache is outermost: a query already asked is answered from it with the service down, and only a query
|
|
159
|
+
// that must go to the service meets the interactive budget and the failure memory in front of it.
|
|
160
|
+
// Document embeddings from concurrent index passes share requests (coalescing.ts): below the budget, so each caller's
|
|
161
|
+
// budget still ends its own call, and above the service, so a request holds at most the usual four in flight.
|
|
162
|
+
return withQueryCache(new interactive_1.InteractiveEmbeddingProvider(new coalescing_1.CoalescingEmbeddingProvider(new vertex_1.VertexEmbeddingProvider({
|
|
163
|
+
keyFile: config.vertexKeyFile,
|
|
164
|
+
project: config.vertexProject,
|
|
165
|
+
location: config.vertexLocation,
|
|
166
|
+
model: config.embeddingModel,
|
|
167
|
+
dimensions: config.embeddingDims,
|
|
168
|
+
...vertexSeam,
|
|
169
|
+
}))), config, `p-${served}`);
|
|
170
|
+
}
|
|
171
|
+
const keys = (config.embeddingApiKey ?? '').split(',').map(k => k.trim()).filter(Boolean);
|
|
172
|
+
return new openai_1.OpenAIEmbeddingProvider(keys, config.embeddingModel, config.embeddingBaseURL, config.embeddingDims);
|
|
173
|
+
}
|
|
174
|
+
function attachEngine(db, config, override) {
|
|
175
|
+
const provider = override === undefined ? createEmbeddingProvider(config) : override;
|
|
176
|
+
if (!provider) {
|
|
177
|
+
// What this process saves gets no vector: an index that has a model's vectors is marked unfinished first.
|
|
178
|
+
if ((0, vectorIndex_1.markVectorsUnfinished)(db)) {
|
|
179
|
+
console.log('[embeddings] no embedding provider is configured: what is saved now gets no vector, so the index is marked unfinished; '
|
|
180
|
+
+ 'the next start with its model embeds those chunks before semantic search reads the index again');
|
|
181
|
+
}
|
|
182
|
+
return { provider: null, vectorIndex: null, dims: readEmbeddingTableDims(db) };
|
|
183
|
+
}
|
|
184
|
+
const existing = readEmbeddingTableDims(db);
|
|
185
|
+
if (existing !== null && existing !== provider.dimensions) {
|
|
186
|
+
throw new Error(`Embedding dimension mismatch: index.db chunk_embeddings is FLOAT[${existing}] but the configured provider is ${provider.dimensions}-d. `
|
|
187
|
+
+ `Do not mix Voyage/e5/other models in one book (ADR-0003). Set MVP_EMBEDDING_DIMS and MVP_EMBEDDING_MODEL to match the index, or use a separate MVP_DB_PATH.`);
|
|
188
|
+
}
|
|
189
|
+
const vectorIndex = new vectorIndex_1.VectorIndex(db, provider.dimensions);
|
|
190
|
+
vectorIndex.initTable();
|
|
191
|
+
claimEmbeddingSpace(db, vectorIndex, provider);
|
|
192
|
+
const modelKey = override === undefined
|
|
193
|
+
? (0, contextRelevance_1.embeddingModelKey)(config.embeddingProvider, config.embeddingModel, config.embeddingDims)
|
|
194
|
+
: injectedModelKey(provider);
|
|
195
|
+
const embedBudgets = resolveEmbedBudgets();
|
|
196
|
+
return {
|
|
197
|
+
provider,
|
|
198
|
+
vectorIndex,
|
|
199
|
+
dims: provider.dimensions,
|
|
200
|
+
...(modelKey ? { modelKey } : {}),
|
|
201
|
+
...(embedBudgets ? { embedBudgets } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The model key of an injected provider, from the identity it declares and the size it embeds at: exactly what the
|
|
206
|
+
* configured path composes, so a caller that brings its own provider (a bench, a test, an in-process harness) keeps
|
|
207
|
+
* the calibrated relevance bar its model has instead of losing the verdict altogether. An injected provider that
|
|
208
|
+
* declares no identity names no model, so it has no bar: `undefined`, and the signal stays silent rather than
|
|
209
|
+
* guessing (the eval-hash stub is exactly this case — lexical vectors, and nothing calibrated to compare them to).
|
|
210
|
+
*/
|
|
211
|
+
function injectedModelKey(provider) {
|
|
212
|
+
const identity = provider.identity;
|
|
213
|
+
return identity ? (0, contextRelevance_1.embeddingModelKey)(identity.kind, identity.model, provider.dimensions) : undefined;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Dimensions alone cannot tell two embedding models apart, so the index records the space its vectors are in
|
|
217
|
+
* (provider, model, dimensions). A new index takes the configured model's space. An index whose vectors are in
|
|
218
|
+
* another space, or in none anyone can vouch for (it holds vectors and records none: it was built before spaces
|
|
219
|
+
* were recorded), is not named and is not read: search would compare numbers from different models. It is rebuilt
|
|
220
|
+
* instead (`engineRebuildEmbeddings`, which the service starts), and until that finishes semantic search is off
|
|
221
|
+
* (`semanticReady`). An index that holds chunks and no vector at all was written without an embedding provider (a
|
|
222
|
+
* workspace that searched by keyword until now): it is named and marked unfinished, so the same background job
|
|
223
|
+
* embeds every one of its chunks, once, before semantic search reads it. A provider that declares no identity claims
|
|
224
|
+
* nothing, and nothing is held back for it.
|
|
225
|
+
*/
|
|
226
|
+
function claimEmbeddingSpace(db, vectorIndex, provider) {
|
|
227
|
+
const wanted = (0, space_1.embeddingSpace)(provider);
|
|
228
|
+
if (!wanted)
|
|
229
|
+
return;
|
|
230
|
+
const stored = vectorIndex.space();
|
|
231
|
+
if (stored === wanted)
|
|
232
|
+
return;
|
|
233
|
+
if (stored === null && !vectorIndex.hasVectors()) {
|
|
234
|
+
const chunks = db.prepare('SELECT COUNT(*) FROM chunks').pluck().get();
|
|
235
|
+
if (chunks === 0) {
|
|
236
|
+
vectorIndex.setSpace(wanted); // a new index: nothing to embed, so it is whole
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
vectorIndex.beginRebuild(wanted); // there is no vector to clear: this names the index and marks it unfinished
|
|
240
|
+
console.log(`[embeddings] index.db holds ${chunks} chunk(s) and no vectors (it was written without an embedding provider): `
|
|
241
|
+
+ `every chunk is embedded with ${wanted} in the background, and until that finishes semantic search is off and keyword search keeps working.`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
console.warn(stored === null
|
|
245
|
+
? '[embeddings] index.db holds vectors but no record of the model that wrote them (it was built before models were recorded). '
|
|
246
|
+
: `[embeddings] index.db holds vectors from ${stored}, but the configured embedding is ${wanted}. `, `Search cannot use vectors from another model: a rebuild embeds every chunk again with ${wanted}, and until it finishes `
|
|
247
|
+
+ 'semantic search is off and keyword search keeps working.');
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Whether a semantic search may read this engine's index: every vector in it is in the configured model's space and
|
|
251
|
+
* none is still to come. An index in another space, in none anyone can vouch for, or part way through a rebuild is
|
|
252
|
+
* not read: search would compare numbers from different models, or find only part of the index. A provider that
|
|
253
|
+
* declares no identity makes no claim, so it is never held back.
|
|
254
|
+
*/
|
|
255
|
+
function semanticReady(engine) {
|
|
256
|
+
if (!engine.provider || !engine.vectorIndex)
|
|
257
|
+
return false;
|
|
258
|
+
const wanted = (0, space_1.embeddingSpace)(engine.provider);
|
|
259
|
+
if (!wanted)
|
|
260
|
+
return true;
|
|
261
|
+
return engine.vectorIndex.space() === wanted && !engine.vectorIndex.rebuilding();
|
|
262
|
+
}
|
|
263
|
+
/** What a caller is told while semantic search is off for a rebuild. */
|
|
264
|
+
function rebuildNotice(db, engine) {
|
|
265
|
+
const chunks = db.prepare('SELECT COUNT(*) FROM chunks').pluck().get();
|
|
266
|
+
const left = engine.vectorIndex.space() === (0, space_1.embeddingSpace)(engine.provider) ? (0, rebuild_1.chunksWithoutVectors)(db) : chunks;
|
|
267
|
+
return `Semantic search is off while this workspace's embeddings are built with the configured model (${left} of ${chunks} chunks left). Keyword search works meanwhile.`;
|
|
268
|
+
}
|
|
269
|
+
/** Why something failed, on one line a client may be shown (host paths redacted). */
|
|
270
|
+
function failureReason(err, max) {
|
|
271
|
+
return (0, sanitize_1.sanitizeErrorMessage)(err).replace(/\s+/g, ' ').trim().slice(0, max);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The last search by meaning that failed, per engine, until one succeeds: memory_status reports it, so a workspace whose
|
|
275
|
+
* embedding service stopped answering is not reported healthy (it makes no embeddings call of its own).
|
|
276
|
+
*/
|
|
277
|
+
const semanticFailures = new WeakMap();
|
|
278
|
+
/** Whether keyword search can run: its index table exists. A damaged or half-migrated index file has none. */
|
|
279
|
+
function keywordIndexReady(db) {
|
|
280
|
+
return (0, statements_1.prepareOnce)(db, `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chunks_fts'`).get() !== undefined;
|
|
281
|
+
}
|
|
282
|
+
/** What a caller is told when a search by meaning failed: why, and what still answers. */
|
|
283
|
+
function semanticFailureNotice(err) {
|
|
284
|
+
return `Semantic search is unavailable right now (${failureReason(err, 200)}), so nothing here was found by meaning; `
|
|
285
|
+
+ 'the saved facts and keyword search do not need it and still answer. The next request tries semantic search again.';
|
|
286
|
+
}
|
|
287
|
+
const embeddingRebuilds = new WeakMap();
|
|
288
|
+
/**
|
|
289
|
+
* Rebuild, in the background, every vector this engine's index holds with the configured model, when its index is
|
|
290
|
+
* not whole in that model's space (see `semanticReady`), and record the space once it is. The clearing and naming
|
|
291
|
+
* happen before this returns; the embedding happens after, a batch at a time. The index is whole only when every
|
|
292
|
+
* chunk has a vector. A provider that is down, or a chunk it will not embed, interrupts the rebuild; it is resumed
|
|
293
|
+
* after a wait that grows (30 s, doubling, up to 5 min), semantic search stays off, and the log says how many chunks
|
|
294
|
+
* are left and which one was refused. So it ends only when the index is whole or `abort` is called. Returns null
|
|
295
|
+
* when there is nothing to do; a rebuild already running for this index is returned rather than started twice.
|
|
296
|
+
*/
|
|
297
|
+
function engineRebuildEmbeddings(db, engine, options = {}) {
|
|
298
|
+
const { provider, vectorIndex } = engine;
|
|
299
|
+
if (!provider || !vectorIndex)
|
|
300
|
+
return null;
|
|
301
|
+
const space = (0, space_1.embeddingSpace)(provider);
|
|
302
|
+
if (!space || semanticReady(engine))
|
|
303
|
+
return null;
|
|
304
|
+
const running = embeddingRebuilds.get(vectorIndex);
|
|
305
|
+
if (running)
|
|
306
|
+
return running;
|
|
307
|
+
const { outageDelayMs = (outage) => Math.min(300_000, 30_000 * 2 ** outage), ...rebuildOptions } = options;
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
const startedAt = Date.now();
|
|
310
|
+
let waiting = false;
|
|
311
|
+
const wait = async (ms) => {
|
|
312
|
+
waiting = true;
|
|
313
|
+
try {
|
|
314
|
+
await (0, rebuild_1.sleep)(ms, controller.signal);
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
waiting = false;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const run = async () => {
|
|
321
|
+
const chunks = () => db.prepare('SELECT COUNT(*) FROM chunks').pluck().get();
|
|
322
|
+
console.log(`[embeddings] rebuilding the vectors of ${chunks()} chunk(s) with ${space}; semantic search is off until it finishes`);
|
|
323
|
+
for (let outage = 0; !controller.signal.aborted; outage++) {
|
|
324
|
+
try {
|
|
325
|
+
const result = await (0, rebuild_1.rebuildEmbeddings)(db, vectorIndex, provider, space, { ...rebuildOptions, signal: controller.signal });
|
|
326
|
+
if (result.done) {
|
|
327
|
+
console.log(`[embeddings] rebuild finished in ${((Date.now() - startedAt) / 1000).toFixed(1)} s; semantic search is on`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
const delay = outageDelayMs(outage);
|
|
333
|
+
console.error(`[embeddings] rebuild interrupted with ${(0, rebuild_1.chunksWithoutVectors)(db)} of ${chunks()} chunk(s) still to embed; semantic search stays off; trying again in ${Math.round(delay / 1000)} s:`, err);
|
|
334
|
+
await wait(delay);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
// The clearing and naming run now, in the synchronous start of the first call, so the index is never left
|
|
339
|
+
// looking whole while it holds another model's vectors.
|
|
340
|
+
const rebuild = {
|
|
341
|
+
abort: () => controller.abort(),
|
|
342
|
+
waiting: () => waiting,
|
|
343
|
+
done: run().catch((err) => console.error('[embeddings] rebuild failed:', err)).finally(() => embeddingRebuilds.delete(vectorIndex)),
|
|
344
|
+
};
|
|
345
|
+
embeddingRebuilds.set(vectorIndex, rebuild);
|
|
346
|
+
return rebuild;
|
|
347
|
+
}
|
|
348
|
+
/** The rebuild `engineRebuildEmbeddings` started for this engine's index that has not ended yet, or null; starts none. */
|
|
349
|
+
function runningEmbeddingRebuild(engine) {
|
|
350
|
+
return (engine.vectorIndex && embeddingRebuilds.get(engine.vectorIndex)) ?? null;
|
|
351
|
+
}
|
|
352
|
+
/** A search shows the path of every hit it judges, and the notes it finds are the same ones read after read. */
|
|
353
|
+
const clientPaths = new Map();
|
|
354
|
+
const MAX_CLIENT_PATHS = 4096;
|
|
355
|
+
function clientPath(memoryDir, stored) {
|
|
356
|
+
// A relative path resolves against the working directory, so it is part of the key then, and only then (as in
|
|
357
|
+
// path.resolve, which asks for it only when it has to).
|
|
358
|
+
const key = path_1.default.isAbsolute(stored) && path_1.default.isAbsolute(memoryDir) ? `${memoryDir}\0${stored}` : `${process.cwd()}\0${memoryDir}\0${stored}`;
|
|
359
|
+
let shown = clientPaths.get(key);
|
|
360
|
+
if (shown === undefined) {
|
|
361
|
+
const resolved = path_1.default.resolve(stored);
|
|
362
|
+
const root = path_1.default.resolve(memoryDir);
|
|
363
|
+
shown = resolved === root || resolved.startsWith(root + path_1.default.sep) ? path_1.default.relative(root, resolved) || stored : path_1.default.basename(stored);
|
|
364
|
+
if (clientPaths.size >= MAX_CLIENT_PATHS)
|
|
365
|
+
clientPaths.clear();
|
|
366
|
+
clientPaths.set(key, shown);
|
|
367
|
+
}
|
|
368
|
+
return shown;
|
|
369
|
+
}
|
|
370
|
+
function resolveEngineEvidence(config, env = process.env, idle = null) {
|
|
371
|
+
if (!(0, attach_1.searchEvidenceEnabled)(env))
|
|
372
|
+
return undefined;
|
|
373
|
+
const options = (0, options_1.resolveEvidenceToolOptions)({ dbPath: config.dbPath }, env);
|
|
374
|
+
// Bounded-wait evidence (RENOR_EVIDENCE_ASYNC, default off) is read here and nowhere else: a background extraction
|
|
375
|
+
// needs a long-lived server to finish in, and the CLI, the benchmark's entry point, must always wait for its table.
|
|
376
|
+
const background = (0, background_1.resolveEvidenceBackground)(env);
|
|
377
|
+
if (background && idle) {
|
|
378
|
+
background.hold = flight => {
|
|
379
|
+
idle.track(() => flight).catch(() => undefined); // its failure is the flight's to report
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
return { options: background ? { ...options, background } : options, extractor: (0, options_1.createEvidenceExtractor)(options) };
|
|
383
|
+
}
|
|
384
|
+
function resolveEngineDigests(db, config, engine, idle = null, env = process.env) {
|
|
385
|
+
const options = (0, digests_1.resolveDigestOptions)({ dbPath: config.dbPath }, env);
|
|
386
|
+
if (!options)
|
|
387
|
+
return null;
|
|
388
|
+
if (!engine.provider) {
|
|
389
|
+
console.log('[digests] RENOR_DIGESTS is on, but this workspace has no embedding provider, so digests are off for it');
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
const worker = new worker_1.DigestWorker({
|
|
393
|
+
db,
|
|
394
|
+
config: { memoryDir: config.memoryDir, ledgerRelPath: config.ledgerRelPath },
|
|
395
|
+
options,
|
|
396
|
+
provider: engine.provider,
|
|
397
|
+
track: idle ? work => idle.track(work) : undefined,
|
|
398
|
+
});
|
|
399
|
+
console.log(`[digests] write-time digests are on (model ${options.model}, at most ${options.maxUsdPerDay} USD a day)`);
|
|
400
|
+
return { options, worker };
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* What search_memory answers on a workspace without semantic search. It searched nothing, and the error code
|
|
404
|
+
* alone left the agent without a next step, so `note` names the search every workspace has.
|
|
405
|
+
*/
|
|
406
|
+
const SEMANTIC_UNAVAILABLE_NOTE = 'This workspace has no semantic search, so nothing was searched by meaning. Use search_text instead: it matches exact words, so search one distinctive word (a name, a project, a term).';
|
|
407
|
+
function semanticUnavailable() {
|
|
408
|
+
return { ok: false, error: 'semantic-unavailable', note: SEMANTIC_UNAVAILABLE_NOTE, hits: [] };
|
|
409
|
+
}
|
|
410
|
+
async function engineSearchMemory(db, engine, memoryDir, query, topK, scope, evidence, includeHistory = false, ledgerRelPath = lifecycle_1.DEFAULT_LEDGER_REL,
|
|
411
|
+
/** Out-param for get_context: the query embedding and the best raw similarity among the returned (scoped, live) semantic hits. */
|
|
412
|
+
probe,
|
|
413
|
+
/** Write-time digests (RENOR_DIGESTS): when present, the digests of the window's notes lead the hits as one table. No model call. */
|
|
414
|
+
digests,
|
|
415
|
+
/** Per-read default: get_context passes 0; searches leave this unset for the default cap of 2. */
|
|
416
|
+
noteCapWhenUnset,
|
|
417
|
+
/** Internal floor input: expose note similarities so the MCP search can filter its completed page. */
|
|
418
|
+
showSimilarity = false,
|
|
419
|
+
/** search_memory's reader layout (readerLayout.ts) needs each chunk's place in its note: the ids ride on the hits until it has used them. */
|
|
420
|
+
withChunkIds = false,
|
|
421
|
+
/** search_memory under RENOR_SEARCH_BEST_FIRST: the window best match first (retrieval/orchestrator.ts). */
|
|
422
|
+
bestFirst = false) {
|
|
423
|
+
// A query with no searchable characters can never truthfully match stored data. Refuse before
|
|
424
|
+
// any embedding call (eval-hash or provider) so whitespace-only input yields no hits instead
|
|
425
|
+
// of an arbitrary semantic match, matching search_history's blank-query handling.
|
|
426
|
+
if ((0, invisibleText_1.isBlankOrInvisible)(query)) {
|
|
427
|
+
return { ok: true, hits: [] };
|
|
428
|
+
}
|
|
429
|
+
if (!engine.provider || !engine.vectorIndex) {
|
|
430
|
+
return semanticUnavailable();
|
|
431
|
+
}
|
|
432
|
+
// Never search a mixed or unfinished space: the caller falls back to keyword search and is told why.
|
|
433
|
+
if (!semanticReady(engine)) {
|
|
434
|
+
return { ok: false, error: 'semantic-rebuilding', notice: rebuildNotice(db, engine), hits: [] };
|
|
435
|
+
}
|
|
436
|
+
const isLedger = (0, ledgerPresentation_1.ledgerNoteMatcher)({ memoryDir, ledgerRelPath });
|
|
437
|
+
// The query is embedded within the read budget: past it the search says so (below) and the caller answers from
|
|
438
|
+
// the facts and keyword search, as it does for any failure of the provider.
|
|
439
|
+
const provider = withBudget(engine.provider, engine.embedBudgets?.readMs);
|
|
440
|
+
const vectorIndex = engine.vectorIndex;
|
|
441
|
+
const asked = probe ?? {};
|
|
442
|
+
const scopeNorm = (0, lifecycle_1.scopeFilterFor)(scope);
|
|
443
|
+
const inScope = scopeNorm ? (0, readScope_1.createReadScope)(db, scopeNorm) : null;
|
|
444
|
+
// The scope is judged before the window is cut, so a scope whose memories rank below other scopes' still gets its
|
|
445
|
+
// best ones (memory scout, finding 7: judged only after the cut, they were all gone); and again on what comes back.
|
|
446
|
+
// Which chunks the scope holds is read from every chunk of the index (and the scope of every ledger event), so it is
|
|
447
|
+
// read once and kept until something is written; reads that follow one another, as an agent's do, share it.
|
|
448
|
+
const scoped = inScope
|
|
449
|
+
? {
|
|
450
|
+
shows: h => inScope.has(h, clientPath(memoryDir, h.notePath)),
|
|
451
|
+
chunkIds: () => (0, readCache_1.readUntilWrite)(db, `scopedChunkIds\0${scopeNorm}\0${memoryDir}`, () => inScope.chunkIds(stored => clientPath(memoryDir, stored))),
|
|
452
|
+
}
|
|
453
|
+
: undefined;
|
|
454
|
+
const search = () => (0, orchestrator_1.retrieveContext)(db, vectorIndex, provider, query, topK, [], {}, { includeHistory, isLedgerNote: isLedger, probe: asked, scope: scoped, noteCapWhenUnset, ...(bestFirst ? { bestFirst } : {}) });
|
|
455
|
+
let result;
|
|
456
|
+
try {
|
|
457
|
+
result = await search();
|
|
458
|
+
// What a save could not embed is embedded now, once the provider answers, and searched with the rest.
|
|
459
|
+
if (await finishUnfinishedFiles(db, memoryDir, engine, provider, query, asked))
|
|
460
|
+
result = await search();
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
// Search by meaning failed: the embedding provider is down, or refuses the key or the quota (or the semantic lane
|
|
464
|
+
// broke, which the log shows). The facts and keyword search do not need it, so a caller answers with those and
|
|
465
|
+
// says why nothing was found by meaning, instead of failing whole.
|
|
466
|
+
console.error('[search] semantic search failed:', err);
|
|
467
|
+
semanticFailures.set(engine, { at: new Date().toISOString(), reason: failureReason(err, 120) });
|
|
468
|
+
return { ok: false, error: 'semantic-unavailable', notice: semanticFailureNotice(err), hits: [] };
|
|
469
|
+
}
|
|
470
|
+
semanticFailures.delete(engine);
|
|
471
|
+
// Scope must be enforced before any full-note hydration/evidence extraction.
|
|
472
|
+
// Otherwise a scoped query could derive evidence from notes outside its scope
|
|
473
|
+
// even if those ranked hits are filtered from the final response later.
|
|
474
|
+
const scopedRetrievalHits = inScope
|
|
475
|
+
? inScope.hits(result.hits, h => clientPath(memoryDir, h.notePath))
|
|
476
|
+
: result.hits;
|
|
477
|
+
if (probe) {
|
|
478
|
+
let top;
|
|
479
|
+
for (const h of scopedRetrievalHits)
|
|
480
|
+
if (typeof h.similarity === 'number' && (top === undefined || h.similarity > top.similarity))
|
|
481
|
+
top = h;
|
|
482
|
+
probe.topSimilarity = top ? top.similarity : null;
|
|
483
|
+
// The same hit as the hits below show it: a ledger event as what it says (presentLedgerEvents judges each hit alone).
|
|
484
|
+
const shown = top && (0, ledgerPresentation_1.presentLedgerEvents)([top], isLedger)[0];
|
|
485
|
+
probe.topHit = shown ? { notePath: clientPath(memoryDir, shown.notePath), headingPath: shown.headingPath, text: shown.text } : undefined;
|
|
486
|
+
// Every returned semantic hit as the memories show it, with its raw cosine: a budget reads which of them are above the bar.
|
|
487
|
+
probe.similarities = scopedRetrievalHits.flatMap(h => {
|
|
488
|
+
const asShown = typeof h.similarity === 'number' ? (0, ledgerPresentation_1.presentLedgerEvents)([h], isLedger)[0] : undefined;
|
|
489
|
+
return asShown ? [{ notePath: clientPath(memoryDir, asShown.notePath), headingPath: asShown.headingPath, text: asShown.text, similarity: h.similarity }] : [];
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const closedEvents = (0, ledgerLiveness_1.closedLedgerEvents)(db);
|
|
493
|
+
const noteDate = (0, orchestrator_1.createNoteDater)(db);
|
|
494
|
+
// Without an evidence model no second pass runs, and nothing is said about one: on every search of such a workspace
|
|
495
|
+
// (every hosted one) a report that nothing was missing told the agent nothing. With a model, every state is reported,
|
|
496
|
+
// and so is the kill switch ("off").
|
|
497
|
+
const attached = evidence?.extractor
|
|
498
|
+
? await (0, attach_1.attachSearchEvidence)({
|
|
499
|
+
// The ledger note holds every scope's events, so hydration drops other scopes' before closed ones.
|
|
500
|
+
transformBody: text => (0, ledgerLiveness_1.resolveLedgerHits)([{ text: inScope ? inScope.body(text) : text, reason: 'evidence-source' }], closedEvents, includeHistory)[0]?.text ?? '',
|
|
501
|
+
query,
|
|
502
|
+
// Evidence hydrates whole notes and prints one "Session dated" line above each: the note's date. A hit's own
|
|
503
|
+
// date is its excerpt's, and one ledger event's would date every other event in the same body as well.
|
|
504
|
+
hits: scopedRetrievalHits.map(h => ({ ...h, session_date: noteDate(h.notePath) })),
|
|
505
|
+
vaultPath: memoryDir,
|
|
506
|
+
options: evidence.options,
|
|
507
|
+
extractor: evidence.extractor,
|
|
508
|
+
enabled: true,
|
|
509
|
+
})
|
|
510
|
+
: evidence
|
|
511
|
+
? null
|
|
512
|
+
: { hits: scopedRetrievalHits, evidence: { status: 'skipped', reason: 'flag-off' } };
|
|
513
|
+
const shown = (0, ledgerPresentation_1.presentLedgerEvents)(scopedRetrievalHits, isLedger);
|
|
514
|
+
// The evidence table, when there is one, leads the hits the agent is shown; they keep their own dates.
|
|
515
|
+
let led = attached ? (0, attach_1.prependEvidenceHit)(shown, attached.evidence) : shown;
|
|
516
|
+
// The digest table follows it. Only notes of the window the scope already allowed are read, so a scoped read never sees
|
|
517
|
+
// another scope's digests, and no model is called: it reads the digest cache and the query embedding the search made.
|
|
518
|
+
if (digests) {
|
|
519
|
+
try {
|
|
520
|
+
const table = await (0, digests_1.digestTable)({
|
|
521
|
+
db, query, hits: scopedRetrievalHits, options: digests.options, provider, queryVector: asked.queryVector, isLedgerNote: isLedger,
|
|
522
|
+
});
|
|
523
|
+
if (table.items.length > 0) {
|
|
524
|
+
const lead = (0, digests_1.leadingSummaryHits)(led);
|
|
525
|
+
led = [...led.slice(0, lead), (0, digests_1.digestItemsToHit)(table.items), ...led.slice(lead)];
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
// Optional augmentation: a fault here never fails the search.
|
|
530
|
+
console.error('[digests] reading the digests failed:', failureReason(err, 200));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const isConversation = withChunkIds ? (0, readerLayout_1.conversationNoteMatcher)(db) : () => false;
|
|
534
|
+
const hits = led.map(h => ({
|
|
535
|
+
notePath: clientPath(memoryDir, h.notePath),
|
|
536
|
+
headingPath: h.headingPath,
|
|
537
|
+
text: h.text,
|
|
538
|
+
score: h.score,
|
|
539
|
+
reason: h.reason,
|
|
540
|
+
...(h.session_date ? { session_date: h.session_date } : {}),
|
|
541
|
+
// Only a conversation's chunks: the layout merges those and no others.
|
|
542
|
+
...(withChunkIds && 'chunkId' in h && typeof h.chunkId === 'number' && h.chunkId > 0 && isConversation(h.notePath) ? { chunkId: h.chunkId } : {}),
|
|
543
|
+
// The author of a fact an identity question read: the reader's view marks it (mvp/factMarkers.ts).
|
|
544
|
+
...('author' in h && h.author !== undefined ? { author: h.author } : {}),
|
|
545
|
+
// Raw cosine before boosts, rounded as in the checked floor arm. A ledger event is no note:
|
|
546
|
+
// its chunk is event markup, and its cosine says little about what it holds (contextRelevance.ts).
|
|
547
|
+
...(showSimilarity && typeof h.similarity === 'number' && !isLedger(h.notePath) ? { similarity: Math.round(h.similarity * 1000) / 1000 } : {}),
|
|
548
|
+
}));
|
|
549
|
+
return {
|
|
550
|
+
ok: true,
|
|
551
|
+
rankedBy: result.rankedBy,
|
|
552
|
+
hits,
|
|
553
|
+
// Present whenever an evidence model is configured, and when the kill switch is off: `state` is how a caller tells
|
|
554
|
+
// an intended "off" from a pass that failed or did not apply.
|
|
555
|
+
...(attached ? { evidence: (0, attach_1.productEvidenceReport)(attached.evidence) } : {}),
|
|
556
|
+
...(scopeNorm ? { scope: scopeNorm } : {}),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function engineSearchText(db, memoryDir, query, topK, scope, includeHistory = false, ledgerRelPath = lifecycle_1.DEFAULT_LEDGER_REL, ledgerRecords) {
|
|
560
|
+
const fts = (0, statements_1.prepareOnce)(db, `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chunks_fts'`).get();
|
|
561
|
+
if (!fts)
|
|
562
|
+
return { ok: false, error: 'fts-unavailable', results: [] };
|
|
563
|
+
const match = (0, queryHelpers_1.quoteFtsQuery)(query);
|
|
564
|
+
if (!match)
|
|
565
|
+
return { ok: true, results: [] };
|
|
566
|
+
const scopeNorm = (0, lifecycle_1.scopeFilterFor)(scope);
|
|
567
|
+
const inScope = scopeNorm ? (0, readScope_1.createReadScope)(db, scopeNorm) : null;
|
|
568
|
+
const isLedger = (0, ledgerPresentation_1.ledgerNoteMatcher)({ memoryDir, ledgerRelPath });
|
|
569
|
+
// A search may judge thousands of rows of one note (the ledger is one note), so each stored path is made the path a
|
|
570
|
+
// client is shown once per search, not once per row.
|
|
571
|
+
const shownPaths = new Map();
|
|
572
|
+
const shownPath = (stored) => {
|
|
573
|
+
let shown = shownPaths.get(stored);
|
|
574
|
+
if (shown === undefined)
|
|
575
|
+
shownPaths.set(stored, (shown = clientPath(memoryDir, stored)));
|
|
576
|
+
return shown;
|
|
577
|
+
};
|
|
578
|
+
const toResult = (r) => ({
|
|
579
|
+
notePath: shownPath(r.notePath),
|
|
580
|
+
headingPath: r.headingPath,
|
|
581
|
+
text: r.text,
|
|
582
|
+
score: r.score,
|
|
583
|
+
reason: r.reason,
|
|
584
|
+
});
|
|
585
|
+
const closedEvents = (0, ledgerLiveness_1.closedLedgerEvents)(db);
|
|
586
|
+
let prose;
|
|
587
|
+
try {
|
|
588
|
+
// A row is shown when its event is live (liveness is resolved first), it is not the ledger's own markup, and the
|
|
589
|
+
// scope read holds it. All three are judged before the page is cut, from a read that goes on until the page is full
|
|
590
|
+
// or the index has no more rows: a closed event, a record of the ledger or another scope's row never takes a place
|
|
591
|
+
// from a row that is shown, however many of them rank above it. The first read is the window of the best
|
|
592
|
+
// `topK * 10` rows (at most 200) this search has always read.
|
|
593
|
+
prose = (0, queryHelpers_1.shownPage)(n => (0, keyword_1.keywordSearch)(db, match, n), rows => {
|
|
594
|
+
const live = (0, ledgerPresentation_1.keywordLedgerHits)((0, ledgerLiveness_1.resolveLedgerHits)(rows.map(r => toResult({ ...r, score: Math.abs(r.score), reason: 'fts' })), closedEvents, includeHistory), isLedger, query, ledgerRecords);
|
|
595
|
+
return inScope ? inScope.hits(live, r => r.notePath) : live;
|
|
596
|
+
}, topK, Math.min(topK * 10, 200));
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
600
|
+
return { ok: false, error: 'fts-query', detail: message.slice(0, 200), results: [] };
|
|
601
|
+
}
|
|
602
|
+
// Only the prose hits actually returned count as "already have"; scope is applied
|
|
603
|
+
// before the identity limit.
|
|
604
|
+
const have = new Set(prose.map(h => h.notePath));
|
|
605
|
+
// The ledger is a note too (`ledger.md` names it), so its opening chunk obeys the same liveness and scope rules:
|
|
606
|
+
// judged by the scope of the event it opens with, not by the ledger's path, and before the identity limit.
|
|
607
|
+
const named = (0, ledgerLiveness_1.resolveLedgerHits)((0, noteIdentity_1.selectNoteIdentityHits)(db, query, noteIdentity_1.NOTE_IDENTITY_EXTRA, new Set(), (p, opening) => {
|
|
608
|
+
const client = clientPath(memoryDir, p);
|
|
609
|
+
return !have.has(client) && !(0, ledgerPresentation_1.isLedgerPreamble)(opening, isLedger) && (!inScope || inScope.has(opening, client));
|
|
610
|
+
}).map(toResult), closedEvents, includeHistory);
|
|
611
|
+
// An event is dated by when it was saved, as search_memory dates it: its time was in the record's heading alone, which
|
|
612
|
+
// a client is not shown (ledgerPresentation.ts). Read from the stored record, before it is shown as the user's words,
|
|
613
|
+
// which may hold a line that looks like a record's heading.
|
|
614
|
+
const eventTime = (0, ledgerEventTime_1.createLedgerEventClock)(db);
|
|
615
|
+
const boundTime = (0, orchestrator_1.createNoteTimeBound)(db);
|
|
616
|
+
const results = (0, ledgerPresentation_1.presentLedgerEvents)([...prose, ...named].map(r => {
|
|
617
|
+
const derived = isLedger(r.notePath) ? eventTime(r) : undefined;
|
|
618
|
+
if (derived === undefined)
|
|
619
|
+
return r;
|
|
620
|
+
const session_date = boundTime(path_1.default.resolve(memoryDir, r.notePath), derived);
|
|
621
|
+
return { ...r, session_date };
|
|
622
|
+
}), isLedger, ledgerRecords);
|
|
623
|
+
return {
|
|
624
|
+
ok: true,
|
|
625
|
+
results,
|
|
626
|
+
...(scopeNorm ? { scope: scopeNorm } : {}),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* What lookup_entity actually searches — all four mechanisms, in order, because
|
|
631
|
+
* a narrower description would be a false contract: a term can resolve on a
|
|
632
|
+
* substring of a canonical name or on a substring of a source note path, and
|
|
633
|
+
* none of them see words inside note text.
|
|
634
|
+
*/
|
|
635
|
+
exports.ENTITY_LOOKUP_SURFACE = 'registered entities, matched in order by exact canonical name, frontmatter alias, substring of a canonical name, or substring of a source note path — never words inside note text';
|
|
636
|
+
function engineLookupEntity(db, vault, name, scope) {
|
|
637
|
+
const scopeNorm = scope === undefined ? undefined : (0, scope_1.normalizeScope)(scope);
|
|
638
|
+
// The scope constrains the SEARCH, not just the answer: the same restriction
|
|
639
|
+
// narrows the candidate queries and the count, so a scoped lookup cannot
|
|
640
|
+
// distinguish "no such entity" from "exists in another scope" — the two must
|
|
641
|
+
// be byte-identical responses or the miss reason is a cross-scope oracle.
|
|
642
|
+
// A note's entity is judged by its note's path, a remembered fact's subject by
|
|
643
|
+
// the scopes its facts were saved in (entityScopeFilter), and with no scope while it has a fact (liveEntityFilter): a
|
|
644
|
+
// subject whose every fact is retired is named nowhere.
|
|
645
|
+
const filter = scopeNorm === undefined ? (0, entityLookup_1.liveEntityFilter)() : (0, entityLookup_1.entityScopeFilter)(scopeNorm);
|
|
646
|
+
const row = (0, entityLookup_1.lookupEntity)(db, name, filter);
|
|
647
|
+
const miss = () => {
|
|
648
|
+
return {
|
|
649
|
+
ok: true,
|
|
650
|
+
entity: null,
|
|
651
|
+
reason: 'no-entity-match',
|
|
652
|
+
searched: exports.ENTITY_LOOKUP_SURFACE,
|
|
653
|
+
// Scoped to the same restriction as the lookup: never a workspace-wide
|
|
654
|
+
// count handed to a caller that may only read one scope. The same count
|
|
655
|
+
// memory_status reports, so the two never name different numbers.
|
|
656
|
+
entitiesIndexed: (0, inventory_1.countEntities)(db, vault, filter),
|
|
657
|
+
note: `Nothing in ${scopeNorm ?? 'this workspace'} is registered as an entity matching "${name}". lookup_entity searches ${exports.ENTITY_LOOKUP_SURFACE}, so a word that appears only inside saved note text (a daily note, a saved fact body) is not an entity. Use search_text for that word, or search_memory for meaning where the workspace has semantic search.`,
|
|
658
|
+
...(scopeNorm === undefined ? {} : { scope: scopeNorm }),
|
|
659
|
+
};
|
|
660
|
+
};
|
|
661
|
+
if (!row)
|
|
662
|
+
return miss();
|
|
663
|
+
const sourceNote = clientPath(vault.memoryDir, row.sourceNote);
|
|
664
|
+
if (scopeNorm !== undefined) {
|
|
665
|
+
return {
|
|
666
|
+
ok: true,
|
|
667
|
+
entity: { canonicalName: row.canonicalName, type: row.type, aliases: row.aliases, sourceNote },
|
|
668
|
+
...(scopeNorm === 'global' ? {} : { scope: scopeNorm }),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
ok: true,
|
|
673
|
+
entity: {
|
|
674
|
+
canonicalName: row.canonicalName,
|
|
675
|
+
type: row.type,
|
|
676
|
+
aliases: row.aliases,
|
|
677
|
+
sourceNote,
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function engineStatus(db, engine, vault) {
|
|
682
|
+
const counts = (0, inventory_1.countInventory)(db, vault);
|
|
683
|
+
// What cannot answer right now, said rather than left for the agent to infer from empty results. Absent when healthy.
|
|
684
|
+
const failed = semanticFailures.get(engine);
|
|
685
|
+
const degraded = {
|
|
686
|
+
...(keywordIndexReady(db) ? {} : { keyword: 'keyword search cannot run: its index is missing' }),
|
|
687
|
+
...(failed ? { semantic: `the last search by meaning failed at ${failed.at}: ${failed.reason}` } : {}),
|
|
688
|
+
};
|
|
689
|
+
return {
|
|
690
|
+
...counts,
|
|
691
|
+
embeddingDims: engine.dims,
|
|
692
|
+
// Whether semantic search can answer now: false with no provider, and false until the embeddings are whole.
|
|
693
|
+
semantic: semanticReady(engine),
|
|
694
|
+
...(engine.provider && engine.vectorIndex && !semanticReady(engine)
|
|
695
|
+
? { embeddingRebuild: { chunksLeft: engine.vectorIndex.space() === (0, space_1.embeddingSpace)(engine.provider) ? (0, rebuild_1.chunksWithoutVectors)(db) : counts.chunks, chunks: counts.chunks } }
|
|
696
|
+
: {}),
|
|
697
|
+
// With an embedding provider, the chunks search by meaning cannot find, because they have no vector: a save whose
|
|
698
|
+
// embedding failed (the next save, or a search, embeds them), notes an import wrote (the background job embeds
|
|
699
|
+
// them), or a rebuild that is not done.
|
|
700
|
+
...(engine.vectorIndex ? { chunksWithoutVectors: (0, rebuild_1.chunksWithoutVectors)(db) } : {}),
|
|
701
|
+
...(Object.keys(degraded).length > 0 ? { degraded } : {}),
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/** `interactive`: an agent is waiting on this pass, so embedding is held to the write budget (see EmbedBudgets). */
|
|
705
|
+
function indexOptionsFor(engine, interactive = false) {
|
|
706
|
+
return engine.provider && engine.vectorIndex
|
|
707
|
+
? {
|
|
708
|
+
embeddingProvider: engine.provider,
|
|
709
|
+
vectorIndex: engine.vectorIndex,
|
|
710
|
+
...(interactive && engine.embedBudgets && Number.isFinite(engine.embedBudgets.writeMs) ? { embedBudgetMs: engine.embedBudgets.writeMs } : {}),
|
|
711
|
+
}
|
|
712
|
+
: {};
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Files an index pass has in flight at once. A file holds its slot while its embedding request is out, so the slot count is
|
|
716
|
+
* how many requests an index pass can batch: with the coalescing provider (coalescing.ts) the requests stay at four in
|
|
717
|
+
* flight whatever this is. Value set by the measurements in engine/docs/perf (see the note there).
|
|
718
|
+
*/
|
|
719
|
+
exports.INDEX_FILE_CONCURRENCY = 48;
|
|
720
|
+
/**
|
|
721
|
+
* `interactive`: an agent waits on this pass (the `index_memory` tool), so a file whose embedding the service will not give
|
|
722
|
+
* within the write budget is left unfinished and the pass returns; a background job then embeds it
|
|
723
|
+
* (`engineFinishIndexInBackground`), however long the service takes. Unset: the provider waits as long as its own retries take.
|
|
724
|
+
*/
|
|
725
|
+
async function engineIndex(db, config, engine, pass = {}) {
|
|
726
|
+
const stats = await (0, pipeline_1.indexVault)(db, config.memoryDir, indexOptionsFor(engine, pass.interactive === true), exports.INDEX_FILE_CONCURRENCY);
|
|
727
|
+
if (stats.errors > 0 && pass.interactive)
|
|
728
|
+
engineFinishIndexInBackground(db, config, engine);
|
|
729
|
+
return stats;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* What this process last reconciled each file with the index at: the file's stamp when `indexFile` last
|
|
733
|
+
* succeeded on it. Kept per database, since another index has reconciled nothing.
|
|
734
|
+
*/
|
|
735
|
+
const reconciledStamps = new WeakMap();
|
|
736
|
+
/** Per database: the stamp each note over the size cap had when it was last named, so it is said once per change and not on every save. */
|
|
737
|
+
const tooLargeStamps = new WeakMap();
|
|
738
|
+
/**
|
|
739
|
+
* Per database: the files a pass left unsettled, whose stamp this process does not record as reconciled (a file changed
|
|
740
|
+
* within a clock tick of its scan, or whose pass failed or was not reached). A save checks them again, as a full scan
|
|
741
|
+
* would, because it cannot tell from the files it wrote that they are done.
|
|
742
|
+
*/
|
|
743
|
+
const unsettledFiles = new WeakMap();
|
|
744
|
+
/**
|
|
745
|
+
* Per database: when this process last checked every file of the vault (a full pass). Until one has run, a save walks the
|
|
746
|
+
* vault (the first pass in a process); after that a save walks it again once FULL_VAULT_CHECK_MS has passed.
|
|
747
|
+
*/
|
|
748
|
+
const walkedAt = new WeakMap();
|
|
749
|
+
/**
|
|
750
|
+
* How long note saves check only the files they wrote before every file of the vault is checked again. An edit made
|
|
751
|
+
* outside the engine (in Obsidian, say) is therefore seen by a save once this long has passed since the last full check;
|
|
752
|
+
* index_memory sees it at once.
|
|
753
|
+
*/
|
|
754
|
+
exports.FULL_VAULT_CHECK_MS = 30_000;
|
|
755
|
+
/** Every stat field a change shows in. The change time is what catches a rewrite that puts the modification time back. */
|
|
756
|
+
const stampOf = (f) => `${f.mtimeMs}:${f.ctimeMs}:${f.size}:${f.ino}`;
|
|
757
|
+
/**
|
|
758
|
+
* A timestamp is only as fine as the file system's clock tick (two seconds on the coarsest), so a file written,
|
|
759
|
+
* scanned and written again within one tick, to the same size, carries the same stamp both times; the same goes
|
|
760
|
+
* for a rewrite that puts the modification time back, within a tick of the first change. A file whose
|
|
761
|
+
* modification or change time is this close to a scan is therefore not trusted by its stamp: the next scan
|
|
762
|
+
* reads it again.
|
|
763
|
+
*/
|
|
764
|
+
exports.TIMESTAMP_TICK_MS = 2_000;
|
|
765
|
+
const stampIsTrusted = (f, scanStart) => Math.min(Math.abs(scanStart - f.mtimeMs), Math.abs(scanStart - f.ctimeMs)) >= exports.TIMESTAMP_TICK_MS; // false for a failed stat (NaN)
|
|
766
|
+
/** Run `work` over `items`, `limit` at a time. */
|
|
767
|
+
async function inPool(items, limit, work) {
|
|
768
|
+
let next = 0;
|
|
769
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
770
|
+
while (next < items.length)
|
|
771
|
+
await work(items[next++]);
|
|
772
|
+
}));
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Bring the index up to date with the vault as a full `indexVault` would, reading only what changed. A file goes
|
|
776
|
+
* through the same `indexFile` a full pass runs when the index has no finished row for it, or its stamp
|
|
777
|
+
* (modification time, change time, size, inode) differs from the one recorded when this process last reconciled
|
|
778
|
+
* it; an unchanged file costs a stat, not a read and a hash, and `indexFile` skips an unchanged file by its hash.
|
|
779
|
+
*
|
|
780
|
+
* Which files a pass checks depends on who runs it. A save passes `touched`, the files it wrote (the day's note, the
|
|
781
|
+
* ledger): then only those, the files still owed (unsettled, see `unsettledFiles`) and the ones the index has no
|
|
782
|
+
* finished row for are stat'ed, so the vault is not walked and no other note's path is loaded, and a save costs the
|
|
783
|
+
* same however many notes and events the workspace holds. Without `touched` the vault is walked and every file
|
|
784
|
+
* stat'ed: that is what a search that finishes a save's unfinished files and a background job run, and what starts
|
|
785
|
+
* each process. A save walks it too when the last full check is FULL_VAULT_CHECK_MS or more old, so an edit, a new
|
|
786
|
+
* file, a rename or a delete made outside the engine is seen by a save within that long (and at once by
|
|
787
|
+
* `index_memory`, a full pass that reads every file). A file changed within a clock tick of its scan is not recorded
|
|
788
|
+
* as reconciled (see TIMESTAMP_TICK_MS), so the next check reads it again. Like a full pass it leaves a deleted or
|
|
789
|
+
* renamed-away file's rows in the index. The one change a stamp cannot show is a rewrite that leaves all four fields
|
|
790
|
+
* as they were, which takes setting the system clock back.
|
|
791
|
+
*/
|
|
792
|
+
async function engineIndexChanged(db, config, engine,
|
|
793
|
+
/** When the scan starts, on the clock the files' times are read against; a test passes a later one to age its files. */
|
|
794
|
+
scanStart = Date.now(),
|
|
795
|
+
/**
|
|
796
|
+
* `interactive`: an agent waits on this pass (a save's, or a read that finishes what a save left), so a file whose
|
|
797
|
+
* embedding the service will not give within the write budget is left unfinished and the pass returns; a background
|
|
798
|
+
* job then embeds it (`engineFinishIndexInBackground`), however long the service takes.
|
|
799
|
+
*
|
|
800
|
+
* `touched`: the absolute paths of the files the calling save wrote. Given, the pass checks those and the files it
|
|
801
|
+
* still owes, and walks no vault, unless the last full check is FULL_VAULT_CHECK_MS old or there has been none in this
|
|
802
|
+
* process.
|
|
803
|
+
*/
|
|
804
|
+
pass = {}) {
|
|
805
|
+
const options = indexOptionsFor(engine, pass.interactive === true);
|
|
806
|
+
let reconciled = reconciledStamps.get(db);
|
|
807
|
+
if (!reconciled) {
|
|
808
|
+
reconciled = new Map();
|
|
809
|
+
reconciledStamps.set(db, reconciled);
|
|
810
|
+
}
|
|
811
|
+
const known = reconciled;
|
|
812
|
+
let named = tooLargeStamps.get(db);
|
|
813
|
+
if (!named) {
|
|
814
|
+
named = new Map();
|
|
815
|
+
tooLargeStamps.set(db, named);
|
|
816
|
+
}
|
|
817
|
+
const tooLarge = named;
|
|
818
|
+
let unsettled = unsettledFiles.get(db);
|
|
819
|
+
if (!unsettled) {
|
|
820
|
+
unsettled = new Set();
|
|
821
|
+
unsettledFiles.set(db, unsettled);
|
|
822
|
+
}
|
|
823
|
+
let candidates;
|
|
824
|
+
let isFinished;
|
|
825
|
+
const now = Date.now();
|
|
826
|
+
const lastCheck = walkedAt.get(db);
|
|
827
|
+
const checkedRecently = lastCheck !== undefined && now >= lastCheck && now - lastCheck < exports.FULL_VAULT_CHECK_MS;
|
|
828
|
+
if (pass.touched !== undefined && checkedRecently) {
|
|
829
|
+
// What the vault's rules leave out is never checked: the walk never reaches it, so neither does a save.
|
|
830
|
+
const ignored = await (0, walker_1.vaultIgnores)(config.memoryDir);
|
|
831
|
+
const owed = [...pass.touched, ...unsettled];
|
|
832
|
+
if ((0, statements_1.prepareOnce)(db, exports.UNFINISHED_FILES).pluck().get() > 0) {
|
|
833
|
+
owed.push(...(0, statements_1.prepareOnce)(db, "SELECT path FROM notes WHERE note_hash = ''").pluck().all());
|
|
834
|
+
}
|
|
835
|
+
candidates = (0, walker_1.statVaultFiles)([...new Set(owed)].filter(file => !ignored(file)));
|
|
836
|
+
// A file that is gone or ignored has nothing left to settle: a later pass finds nothing there either.
|
|
837
|
+
const present = new Set(candidates.map(f => f.file));
|
|
838
|
+
for (const file of unsettled)
|
|
839
|
+
if (!present.has(file))
|
|
840
|
+
unsettled.delete(file);
|
|
841
|
+
const hash = (0, statements_1.prepareOnce)(db, "SELECT note_hash FROM notes WHERE path = ?").pluck();
|
|
842
|
+
isFinished = (file) => {
|
|
843
|
+
const h = hash.get(file);
|
|
844
|
+
return typeof h === 'string' && h !== '';
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
candidates = await (0, walker_1.walkVaultStats)(config.memoryDir);
|
|
849
|
+
const finished = new Set(db.prepare("SELECT path FROM notes WHERE note_hash != ''").pluck().all());
|
|
850
|
+
isFinished = (file) => finished.has(file);
|
|
851
|
+
walkedAt.set(db, now);
|
|
852
|
+
unsettled.clear(); // rebuilt below from every file's stamp
|
|
853
|
+
}
|
|
854
|
+
const changed = candidates.filter(f => tooLarge.get(f.file) !== stampOf(f) && (!isFinished(f.file) || known.get(f.file) !== stampOf(f)));
|
|
855
|
+
let indexed = 0;
|
|
856
|
+
let errors = 0;
|
|
857
|
+
let failure;
|
|
858
|
+
// Together: each file that embeds waits on the provider for a round trip, and a save that changed the
|
|
859
|
+
// daily note and the ledger should wait for one round trip, not two.
|
|
860
|
+
await inPool(changed, exports.INDEX_FILE_CONCURRENCY, async (f) => {
|
|
861
|
+
try {
|
|
862
|
+
await (0, pipeline_1.indexFile)(db, f.file, options);
|
|
863
|
+
if (stampIsTrusted(f, scanStart))
|
|
864
|
+
known.set(f.file, stampOf(f));
|
|
865
|
+
else
|
|
866
|
+
known.delete(f.file);
|
|
867
|
+
tooLarge.delete(f.file);
|
|
868
|
+
indexed++;
|
|
869
|
+
}
|
|
870
|
+
catch (err) {
|
|
871
|
+
if (err instanceof pipeline_1.NoteTooLargeError) {
|
|
872
|
+
// Not a failure to retry: it stays out of the index until the file changes, and is said once per change.
|
|
873
|
+
known.delete(f.file);
|
|
874
|
+
tooLarge.set(f.file, stampOf(f));
|
|
875
|
+
console.warn(`[index] ${f.file}: ${err.message}`);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
known.delete(f.file);
|
|
879
|
+
if (err.code === 'ENOENT')
|
|
880
|
+
return;
|
|
881
|
+
errors++;
|
|
882
|
+
failure ??= failureReason(err, 300);
|
|
883
|
+
console.error(`[index] error indexing ${f.file}:`, err);
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
// Settle what was checked: a file whose stamp is recorded (or that is named too large for its stamp) is done; any
|
|
887
|
+
// other is owed, and the next save checks it again.
|
|
888
|
+
for (const f of candidates) {
|
|
889
|
+
if (known.get(f.file) === stampOf(f) || tooLarge.get(f.file) === stampOf(f))
|
|
890
|
+
unsettled.delete(f.file);
|
|
891
|
+
else
|
|
892
|
+
unsettled.add(f.file);
|
|
893
|
+
}
|
|
894
|
+
// What the pass could not finish is not dropped: it is finished in the background, once the service answers.
|
|
895
|
+
if (errors > 0 && pass.interactive)
|
|
896
|
+
engineFinishIndexInBackground(db, config, engine);
|
|
897
|
+
return { total: changed.length, indexed, errors, ...(failure === undefined ? {} : { failure }) };
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* The files whose last index pass did not finish: their embedding failed, so keyword search finds their chunks and
|
|
901
|
+
* search by meaning does not, until a later pass embeds them (see indexFile). A partial index answers it (migration
|
|
902
|
+
* 15), so asking costs those few rows, usually none, however many notes there are.
|
|
903
|
+
*/
|
|
904
|
+
exports.UNFINISHED_FILES = "SELECT COUNT(*) FROM notes WHERE note_hash = ''";
|
|
905
|
+
/** After a search could not finish them, how long searches leave the unfinished files before trying again. */
|
|
906
|
+
exports.UNFINISHED_RETRY_MS = 30_000;
|
|
907
|
+
/** Per index: the epoch ms before which a search does not try the unfinished files again. */
|
|
908
|
+
const unfinishedRetryAfter = new WeakMap();
|
|
909
|
+
/**
|
|
910
|
+
* Finish, for a search, the files a save left unfinished. A save whose embedding failed (the provider was down)
|
|
911
|
+
* keeps its chunks, which keyword search finds, and leaves its file unfinished for a later pass to embed: the next
|
|
912
|
+
* save's, or this one, which a search runs once the provider has embedded its query (`asked`: the search did, or, on
|
|
913
|
+
* an index with no vector yet, which a search does not ask, it is asked here). So what was saved during an outage is
|
|
914
|
+
* found by meaning as soon as the provider is back, not only after the next save; and a search waits on a provider
|
|
915
|
+
* that is down no longer than for its own query, which then fails, so the search says semantic search failed rather
|
|
916
|
+
* than that nothing matched. It is engineIndexChanged's full pass (no `touched`: every file is checked, as a save's first
|
|
917
|
+
* pass in a process does). When it leaves a file unfinished although
|
|
918
|
+
* the provider answers (a chunk it refuses), searches leave the files for UNFINISHED_RETRY_MS, so that costs a search a
|
|
919
|
+
* provider call at most that often; saves go on retrying them as before. Throws only what embedding the query throws.
|
|
920
|
+
* True when a file was finished, so the search runs again and finds its chunks.
|
|
921
|
+
*/
|
|
922
|
+
async function finishUnfinishedFiles(db, memoryDir, engine,
|
|
923
|
+
/** The provider as a read sees it: its calls end within the read budget. */
|
|
924
|
+
provider, query, asked) {
|
|
925
|
+
const unfinished = (0, statements_1.prepareOnce)(db, exports.UNFINISHED_FILES).pluck();
|
|
926
|
+
const before = unfinished.get();
|
|
927
|
+
if (before === 0 || Date.now() < (unfinishedRetryAfter.get(db) ?? 0))
|
|
928
|
+
return false;
|
|
929
|
+
// Asked here only to learn that it answers: the search that follows embeds the query and reports it as before.
|
|
930
|
+
if (!asked.queryVector)
|
|
931
|
+
await (provider.embedQuery ? provider.embedQuery(query) : provider.embed([query]));
|
|
932
|
+
const finish = async () => {
|
|
933
|
+
try {
|
|
934
|
+
await engineIndexChanged(db, { memoryDir }, engine, undefined, { interactive: true });
|
|
935
|
+
}
|
|
936
|
+
catch (err) {
|
|
937
|
+
console.error('[index] finishing the unfinished files failed:', err);
|
|
938
|
+
}
|
|
939
|
+
const after = unfinished.get();
|
|
940
|
+
if (after > 0)
|
|
941
|
+
unfinishedRetryAfter.set(db, Date.now() + exports.UNFINISHED_RETRY_MS);
|
|
942
|
+
else
|
|
943
|
+
unfinishedRetryAfter.delete(db);
|
|
944
|
+
return after < before;
|
|
945
|
+
};
|
|
946
|
+
// The pass takes each file's lock, which a save that is embedding holds; a read does not wait for it beyond its own
|
|
947
|
+
// budget. A pass that is still running then goes on without it, and the read is answered with what is indexed now.
|
|
948
|
+
const budgetMs = engine.embedBudgets?.readMs;
|
|
949
|
+
if (budgetMs === undefined || !Number.isFinite(budgetMs))
|
|
950
|
+
return finish();
|
|
951
|
+
const running = finish();
|
|
952
|
+
let timer;
|
|
953
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(false), budgetMs); });
|
|
954
|
+
try {
|
|
955
|
+
const finished = await Promise.race([running, late]);
|
|
956
|
+
if (!finished)
|
|
957
|
+
unfinishedRetryAfter.set(db, Date.now() + exports.UNFINISHED_RETRY_MS);
|
|
958
|
+
return finished;
|
|
959
|
+
}
|
|
960
|
+
finally {
|
|
961
|
+
clearTimeout(timer);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
const backgroundIndexes = new WeakMap();
|
|
965
|
+
/** The first wait is short: a save that just failed is usually a service that is already back. */
|
|
966
|
+
exports.BACKGROUND_INDEX_FIRST_DELAY_MS = 1_000;
|
|
967
|
+
/** After an attempt that failed: 30 s, doubling, up to 5 min (the wait a rebuild has). */
|
|
968
|
+
const backgroundIndexRetryDelayMs = (outage) => Math.min(300_000, 30_000 * 2 ** outage);
|
|
969
|
+
/**
|
|
970
|
+
* Finish, in the background, what a save could not: the chunks it kept for keyword search that have no vector, and the
|
|
971
|
+
* files left unfinished for them. The embedding is the provider's patient one (its own retries and backoff, however long
|
|
972
|
+
* they take) and holds no lock, so a service that is down costs only this task's time; the pass that follows takes each
|
|
973
|
+
* file's lock for as long as it takes to record that its chunks have their vectors. When an attempt fails it waits
|
|
974
|
+
* (30 s, doubling, up to 5 min) and tries again, so a failed index is queued and never dropped, and it ends only when
|
|
975
|
+
* nothing is left or `abort` is called. A job already running for this index is returned rather than started twice.
|
|
976
|
+
* An index whose vectors are not whole (a rebuild is embedding all of them) is left to that rebuild.
|
|
977
|
+
*/
|
|
978
|
+
function engineFinishIndexInBackground(db, config, engine, options = {}) {
|
|
979
|
+
const { provider, vectorIndex } = engine;
|
|
980
|
+
if (!provider || !vectorIndex)
|
|
981
|
+
return null;
|
|
982
|
+
const running = backgroundIndexes.get(db);
|
|
983
|
+
if (running)
|
|
984
|
+
return running;
|
|
985
|
+
const { firstDelayMs = exports.BACKGROUND_INDEX_FIRST_DELAY_MS, retryDelayMs = backgroundIndexRetryDelayMs } = options;
|
|
986
|
+
const controller = new AbortController();
|
|
987
|
+
const unfinished = (0, statements_1.prepareOnce)(db, exports.UNFINISHED_FILES).pluck();
|
|
988
|
+
let waiting = false;
|
|
989
|
+
const wait = async (ms) => {
|
|
990
|
+
waiting = true;
|
|
991
|
+
try {
|
|
992
|
+
await (0, rebuild_1.sleep)(ms, controller.signal);
|
|
993
|
+
}
|
|
994
|
+
finally {
|
|
995
|
+
waiting = false;
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
const run = async () => {
|
|
999
|
+
await wait(firstDelayMs);
|
|
1000
|
+
for (let outage = 0; !controller.signal.aborted; outage++) {
|
|
1001
|
+
try {
|
|
1002
|
+
if (semanticReady(engine)) {
|
|
1003
|
+
await (0, rebuild_1.embedMissingVectors)(db, vectorIndex, provider, { signal: controller.signal });
|
|
1004
|
+
if (controller.signal.aborted)
|
|
1005
|
+
return;
|
|
1006
|
+
// Their vectors are in: this records it, and embeds only what a save has changed since (within the write budget).
|
|
1007
|
+
await engineIndexChanged(db, config, engine, undefined, { interactive: true });
|
|
1008
|
+
if (unfinished.get() === 0 && (0, rebuild_1.chunksWithoutVectors)(db) === 0) {
|
|
1009
|
+
if (outage > 0)
|
|
1010
|
+
console.log('[index] the embedding service answers again; what was saved while it did not is findable by meaning now');
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
else if (!vectorIndex.rebuilding()) {
|
|
1015
|
+
return; // another model's space: a rebuild is the only thing that can make it whole, and it is not this job's
|
|
1016
|
+
}
|
|
1017
|
+
throw new Error('the index is not finished yet');
|
|
1018
|
+
}
|
|
1019
|
+
catch (err) {
|
|
1020
|
+
if (controller.signal.aborted)
|
|
1021
|
+
return;
|
|
1022
|
+
const delay = retryDelayMs(outage);
|
|
1023
|
+
console.error(`[index] finishing the index in the background failed (${failureReason(err, 200)}); ${(0, rebuild_1.chunksWithoutVectors)(db)} chunk(s) have no vector yet; trying again in ${Math.round(delay / 1000)} s`);
|
|
1024
|
+
await wait(delay);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
const job = {
|
|
1029
|
+
abort: () => controller.abort(),
|
|
1030
|
+
waiting: () => waiting,
|
|
1031
|
+
done: run().catch((err) => console.error('[index] background indexing failed:', err)).finally(() => {
|
|
1032
|
+
if (backgroundIndexes.get(db) === job)
|
|
1033
|
+
backgroundIndexes.delete(db);
|
|
1034
|
+
}),
|
|
1035
|
+
};
|
|
1036
|
+
backgroundIndexes.set(db, job);
|
|
1037
|
+
return job;
|
|
1038
|
+
}
|
|
1039
|
+
/** The job `engineFinishIndexInBackground` started for this index that has not ended yet, or null; starts none. */
|
|
1040
|
+
function runningBackgroundIndex(db) {
|
|
1041
|
+
return backgroundIndexes.get(db) ?? null;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Whether anything is waiting for the embedding service: a file left unfinished, or a chunk with no vector in an index
|
|
1045
|
+
* that is otherwise whole. Asked at start, so what an earlier process (or an idle exit) left is finished.
|
|
1046
|
+
*/
|
|
1047
|
+
function hasIndexWorkToFinish(db, engine) {
|
|
1048
|
+
if (!engine.provider || !engine.vectorIndex || !semanticReady(engine))
|
|
1049
|
+
return false;
|
|
1050
|
+
return (0, statements_1.prepareOnce)(db, exports.UNFINISHED_FILES).pluck().get() > 0 || (0, rebuild_1.chunksWithoutVectors)(db) > 0;
|
|
1051
|
+
}
|
|
1052
|
+
async function engineRememberNote(config, text, date, source, scope,
|
|
1053
|
+
/** The user's time zone, when the client names it: the note is then filed under their day, at their time. */
|
|
1054
|
+
timeZone) {
|
|
1055
|
+
// One reading of the clock gives the entry both its day and its time.
|
|
1056
|
+
const clock = (0, writer_1.entryClock)(new Date(), timeZone);
|
|
1057
|
+
const day = date ?? clock.date;
|
|
1058
|
+
const dir = (0, scope_1.dailyDirForScope)((0, scope_1.normalizeScope)(scope));
|
|
1059
|
+
const written = await (0, writer_1.appendDailyEntry)(config.memoryDir, day, { text, source: source ?? 'mcp' }, dir, clock);
|
|
1060
|
+
const file = clientPath(config.memoryDir, written.filePath);
|
|
1061
|
+
// The id that reads, retires or restores this entry (memory_map's), so forgetting it needs no lookup (live dogfood
|
|
1062
|
+
// 2026-10-03, D12).
|
|
1063
|
+
const id = (0, memoryMap_1.appendedEntryId)(file.split(path_1.default.sep).join('/'), written.entry);
|
|
1064
|
+
return { ok: true, file, scope: (0, scope_1.normalizeScope)(scope), ...(id ? { id } : {}) };
|
|
1065
|
+
}
|