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,1168 @@
|
|
|
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.NoteTooLargeError = exports.MAX_NOTE_BYTES = void 0;
|
|
7
|
+
exports.resolveWikilinks = resolveWikilinks;
|
|
8
|
+
exports.indexFile = indexFile;
|
|
9
|
+
exports.indexVault = indexVault;
|
|
10
|
+
exports.removeDailyHeaderFacts = removeDailyHeaderFacts;
|
|
11
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const fs_1 = require("fs");
|
|
14
|
+
const crypto_1 = require("crypto");
|
|
15
|
+
const buffer_1 = require("buffer");
|
|
16
|
+
const statements_1 = require("../db/statements");
|
|
17
|
+
const hasher_1 = require("./hasher");
|
|
18
|
+
const dailyEntries_1 = require("./dailyEntries");
|
|
19
|
+
const parser_1 = require("./parser");
|
|
20
|
+
const chunker_1 = require("./chunker");
|
|
21
|
+
const walker_1 = require("./walker");
|
|
22
|
+
const space_1 = require("../embeddings/space");
|
|
23
|
+
const writer_1 = require("../memory/writer");
|
|
24
|
+
const fs_2 = require("fs");
|
|
25
|
+
const dailyNote_1 = require("../memory/dailyNote");
|
|
26
|
+
// HARMONIZED write path (merge of feat/c2-facts-writepath × origin/main):
|
|
27
|
+
// the fleet's extractors (typedFacts/eventExtractor/preferenceExtractor) are
|
|
28
|
+
// the CANDIDATE GENERATION layer (RENOR_TYPED_FACTS / RENOR_CHRONICLE /
|
|
29
|
+
// RENOR_PREFS, all default off); this branch's DecisionModel is the DECISION
|
|
30
|
+
// layer (capture-gate / dedupe / sensitivity-guardrail adjudication before
|
|
31
|
+
// any row is written). Single write path: decisions before writes.
|
|
32
|
+
const factsWritePath_1 = require("../extraction/factsWritePath");
|
|
33
|
+
const writePathAdjudication_1 = require("../extraction/writePathAdjudication");
|
|
34
|
+
const typedFacts_1 = require("../extraction/typedFacts");
|
|
35
|
+
const eventExtractor_1 = require("../extraction/eventExtractor");
|
|
36
|
+
const preferenceExtractor_1 = require("../extraction/preferenceExtractor");
|
|
37
|
+
const rowStore_1 = require("../context/rowStore");
|
|
38
|
+
const laneRuns_1 = require("./laneRuns");
|
|
39
|
+
/** Texts per request the embedding service takes at once (what Vertex's predict accepts). */
|
|
40
|
+
const EMBED_REQUEST_TEXTS = 32;
|
|
41
|
+
/**
|
|
42
|
+
* The largest note the indexer reads. A note is held whole, parsed, serialised and chunked in memory; a flat front matter
|
|
43
|
+
* costs about 28x its size in RSS (8 MB -> 487 MB, 64 MB -> 1.9 GB), and nothing else bounds a file on disk. A note over
|
|
44
|
+
* it is reported, never read (IndexResult.skippedTooLarge); split it, or move it out of the memory folder.
|
|
45
|
+
*/
|
|
46
|
+
exports.MAX_NOTE_BYTES = 8 * 1024 * 1024;
|
|
47
|
+
/** A note over MAX_NOTE_BYTES. indexFile throws it before reading a byte; indexVault reports it instead of counting an error. */
|
|
48
|
+
class NoteTooLargeError extends Error {
|
|
49
|
+
filePath;
|
|
50
|
+
bytes;
|
|
51
|
+
constructor(filePath, bytes) {
|
|
52
|
+
super(`note is ${bytes} bytes; a note over ${exports.MAX_NOTE_BYTES / (1024 * 1024)} MiB (${exports.MAX_NOTE_BYTES} bytes) is not indexed. Split it or move it out of the memory folder.`);
|
|
53
|
+
this.filePath = filePath;
|
|
54
|
+
this.bytes = bytes;
|
|
55
|
+
this.name = 'NoteTooLargeError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.NoteTooLargeError = NoteTooLargeError;
|
|
59
|
+
const FACT_PREDICATES = ['owner', 'status', 'updated', 'tags'];
|
|
60
|
+
/** A front-matter value as fact text: a scalar as written, a mapping (or binary) as JSON. Never throws, never "[object Object]". */
|
|
61
|
+
const factText = (v) => (v !== null && typeof v === 'object' && !(v instanceof Date) ? JSON.stringify(v) : String(v));
|
|
62
|
+
/** The note is indexed anyway; say which front-matter value was not text, so a note that seems to ignore it can be explained. */
|
|
63
|
+
function warnFrontmatter(filePath, what) {
|
|
64
|
+
console.warn(`[index] ${filePath}: ${what}; the note is indexed without it`);
|
|
65
|
+
}
|
|
66
|
+
/** The note's `kind`: a string, else none. Anything else (a mapping, a list, a date, a number) is ignored with a warning. */
|
|
67
|
+
function noteKind(filePath, frontmatter) {
|
|
68
|
+
const kind = frontmatter.kind;
|
|
69
|
+
if (typeof kind === 'string')
|
|
70
|
+
return kind;
|
|
71
|
+
if (kind !== undefined && kind !== null)
|
|
72
|
+
warnFrontmatter(filePath, 'front matter "kind" is not text');
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The extraction version each structured-row lane writes today. This is the
|
|
77
|
+
* second half of the lane cache key, (source_hash, extraction_version), so it
|
|
78
|
+
* is also the test for "is this preserved row still this lane's current row?".
|
|
79
|
+
* One mapping, used by the in-transaction reattach and by the post-failure
|
|
80
|
+
* restore, so a version bump cannot be applied in one place and missed in the
|
|
81
|
+
* other. `row_kind` is CHECK-constrained to these three kinds.
|
|
82
|
+
*/
|
|
83
|
+
const LANE_EXTRACTION_VERSION = {
|
|
84
|
+
fact: typedFacts_1.FACTS_EXTRACTION_VERSION,
|
|
85
|
+
event: eventExtractor_1.EVENTS_EXTRACTION_VERSION,
|
|
86
|
+
preference: preferenceExtractor_1.PREFS_EXTRACTION_VERSION,
|
|
87
|
+
};
|
|
88
|
+
function currentVersion(kind) {
|
|
89
|
+
return LANE_EXTRACTION_VERSION[kind];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Whether the vectors this index holds are in the embedding space the provider writes, so a stored vector may
|
|
93
|
+
* stand in for a fresh one. A new index takes the provider's space; vectors recorded in a different one are
|
|
94
|
+
* never mixed with new ones (that fails the file, before anything changes, until the index is rebuilt); an
|
|
95
|
+
* index that cannot say, or a provider with no identity, reuses nothing.
|
|
96
|
+
*/
|
|
97
|
+
function storedVectorsMatch(options) {
|
|
98
|
+
const index = options.vectorIndex;
|
|
99
|
+
const wanted = (0, space_1.embeddingSpace)(options.embeddingProvider);
|
|
100
|
+
if (!wanted || !index.space || !index.setSpace || !index.hasVectors)
|
|
101
|
+
return false;
|
|
102
|
+
const stored = index.space();
|
|
103
|
+
if (stored === wanted)
|
|
104
|
+
return true;
|
|
105
|
+
if (stored !== null) {
|
|
106
|
+
throw new Error(`the index holds vectors from another embedding model (${stored}), not the configured one (${wanted}); `
|
|
107
|
+
+ 'rebuild the index rather than mix the two');
|
|
108
|
+
}
|
|
109
|
+
if (index.hasVectors())
|
|
110
|
+
return false; // built before spaces were recorded: it cannot be vouched for
|
|
111
|
+
index.setSpace(wanted);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
/** How many chunks, from the top of the note, are stored exactly as the note chunks now: same text, heading and place. */
|
|
115
|
+
function unchangedPrefix(stored, chunks) {
|
|
116
|
+
let n = 0;
|
|
117
|
+
while (n < stored.length && n < chunks.length) {
|
|
118
|
+
const s = stored[n];
|
|
119
|
+
const c = chunks[n];
|
|
120
|
+
if (s.text !== c.text || s.heading_path !== c.headingPath || s.start_line !== c.startLine
|
|
121
|
+
|| s.end_line !== c.endLine || s.token_count !== c.tokenCount)
|
|
122
|
+
break;
|
|
123
|
+
n++;
|
|
124
|
+
}
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
/** The note's row: written before its chunks, as every pass does (the chunks reference it). */
|
|
128
|
+
function upsertNoteRow(db, filePath, row) {
|
|
129
|
+
db.prepare(`
|
|
130
|
+
INSERT INTO notes (path, title, kind, note_hash, modified_at, frontmatter_json)
|
|
131
|
+
VALUES (@path, @title, @kind, @noteHash, @modifiedAt, @frontmatterJson)
|
|
132
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
133
|
+
title = excluded.title,
|
|
134
|
+
kind = excluded.kind,
|
|
135
|
+
note_hash = excluded.note_hash,
|
|
136
|
+
modified_at = excluded.modified_at,
|
|
137
|
+
frontmatter_json = excluded.frontmatter_json
|
|
138
|
+
`).run({ path: filePath, ...row });
|
|
139
|
+
}
|
|
140
|
+
/** SQLite's LIKE and COLLATE NOCASE fold the ASCII letters and nothing else; so does this. */
|
|
141
|
+
const asciiLower = (s) => s.replace(/[A-Z]/g, c => String.fromCharCode(c.charCodeAt(0) + 32));
|
|
142
|
+
/**
|
|
143
|
+
* The entity each wikilink points at, keyed by the link with its ASCII letters lowercased. A link names the entity whose
|
|
144
|
+
* note's path ends in `/<link>.md` or whose name is the link (ASCII case-insensitive), and where several do, the one with
|
|
145
|
+
* the lowest id. That is what a query per link with `path LIKE '%/<link>.md' OR name = ?` answered, but a leading wildcard
|
|
146
|
+
* cannot use an index, so that cost a scan of every note per distinct link: a 61 KB note of 7,000 links took 22 s in a
|
|
147
|
+
* 2,000-note vault. A link nothing names has no entry. The claims are kept (see claimsOf), so a note does not scan them.
|
|
148
|
+
*/
|
|
149
|
+
function resolveWikilinks(db, links) {
|
|
150
|
+
const wanted = new Set(links.map(asciiLower));
|
|
151
|
+
const found = new Map();
|
|
152
|
+
if (wanted.size === 0)
|
|
153
|
+
return found;
|
|
154
|
+
const byKey = claimsOf(db);
|
|
155
|
+
for (const key of wanted) {
|
|
156
|
+
const ids = byKey.get(key);
|
|
157
|
+
if (ids !== undefined)
|
|
158
|
+
found.set(key, ids[0]);
|
|
159
|
+
}
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
/** The keys an entity answers to: its name, and each path below its note's folder (see resolveWikilinks). */
|
|
163
|
+
function claimKeysOf(name, path) {
|
|
164
|
+
const keys = [asciiLower(name)];
|
|
165
|
+
const lowerPath = asciiLower(path);
|
|
166
|
+
if (lowerPath.endsWith('.md')) {
|
|
167
|
+
const stem = lowerPath.slice(0, -3);
|
|
168
|
+
for (let at = stem.indexOf('/'); at !== -1; at = stem.indexOf('/', at + 1))
|
|
169
|
+
keys.push(stem.slice(at + 1));
|
|
170
|
+
}
|
|
171
|
+
return keys;
|
|
172
|
+
}
|
|
173
|
+
const claimsByDb = new WeakMap();
|
|
174
|
+
function entityFingerprint(db) {
|
|
175
|
+
return (0, statements_1.prepareOnce)(db, "SELECT COALESCE(MAX(id), 0) || '|' || COUNT(*) FROM entities").pluck().get();
|
|
176
|
+
}
|
|
177
|
+
function addClaim(byKey, key, id) {
|
|
178
|
+
const ids = byKey.get(key);
|
|
179
|
+
// Ids arrive in ascending order (a new entity is the newest), so the last one is the only one this id can repeat.
|
|
180
|
+
if (ids === undefined)
|
|
181
|
+
byKey.set(key, [id]);
|
|
182
|
+
else if (ids[ids.length - 1] !== id)
|
|
183
|
+
ids.push(id);
|
|
184
|
+
}
|
|
185
|
+
function dropClaim(byKey, key, id) {
|
|
186
|
+
const ids = byKey.get(key);
|
|
187
|
+
if (ids === undefined)
|
|
188
|
+
return;
|
|
189
|
+
const at = ids.indexOf(id);
|
|
190
|
+
if (at !== -1)
|
|
191
|
+
ids.splice(at, 1);
|
|
192
|
+
if (ids.length === 0)
|
|
193
|
+
byKey.delete(key);
|
|
194
|
+
}
|
|
195
|
+
function claimsOf(db) {
|
|
196
|
+
const fingerprint = entityFingerprint(db);
|
|
197
|
+
const held = claimsByDb.get(db);
|
|
198
|
+
if (held !== undefined && held.fingerprint === fingerprint)
|
|
199
|
+
return held.byKey;
|
|
200
|
+
const byKey = new Map();
|
|
201
|
+
const rows = db.prepare('SELECT e.id AS id, e.canonical_name AS name, n.path AS path FROM entities e JOIN notes n ON e.source_note = n.path ORDER BY e.id').iterate();
|
|
202
|
+
for (const row of rows) {
|
|
203
|
+
for (const key of claimKeysOf(row.name, row.path))
|
|
204
|
+
addClaim(byKey, key, row.id);
|
|
205
|
+
}
|
|
206
|
+
claimsByDb.set(db, { fingerprint, byKey });
|
|
207
|
+
return byKey;
|
|
208
|
+
}
|
|
209
|
+
/** The entity rows of one note, as the claims read them (joined to their note, as resolveWikilinks joins them). */
|
|
210
|
+
function entityRowsOf(db, filePath) {
|
|
211
|
+
return (0, statements_1.prepareOnce)(db, 'SELECT e.id AS id, e.canonical_name AS name, n.path AS path FROM entities e JOIN notes n ON e.source_note = n.path WHERE e.source_note = ?')
|
|
212
|
+
.all(filePath);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Follows one note's entity change (rebuildNoteRows: its old entity deleted, its new one inserted): what the old rows claimed
|
|
216
|
+
* is dropped and what the new rows claim is added, if the claims were current before the change. If they were not, they are
|
|
217
|
+
* dropped and read again on the next resolve.
|
|
218
|
+
*/
|
|
219
|
+
function followEntityChange(db, fingerprintBefore, removed, added) {
|
|
220
|
+
const held = claimsByDb.get(db);
|
|
221
|
+
if (held === undefined)
|
|
222
|
+
return;
|
|
223
|
+
if (held.fingerprint !== fingerprintBefore) {
|
|
224
|
+
claimsByDb.delete(db);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
for (const row of removed)
|
|
228
|
+
for (const key of claimKeysOf(row.name, row.path))
|
|
229
|
+
dropClaim(held.byKey, key, row.id);
|
|
230
|
+
for (const row of added)
|
|
231
|
+
for (const key of claimKeysOf(row.name, row.path))
|
|
232
|
+
addClaim(held.byKey, key, row.id);
|
|
233
|
+
held.fingerprint = entityFingerprint(db);
|
|
234
|
+
}
|
|
235
|
+
/** A note's entity, frontmatter facts and wikilink relations, written again from its parse. Returns the entity's id. */
|
|
236
|
+
function rebuildNoteRows(db, filePath, note, kind, engineWrote, now) {
|
|
237
|
+
// Frontmatter/LLM note facts use this index instant (and the default global scope). Keep the workspace's
|
|
238
|
+
// observation even when a later pass replaces the rows after a backward clock step, or a note is retired.
|
|
239
|
+
// This shares the note-row transaction, so a rolled-back pass cannot advance the read clock.
|
|
240
|
+
db.prepare(`INSERT INTO note_index_clock (scope, observed_at) VALUES ('global', ?)
|
|
241
|
+
ON CONFLICT(scope) DO UPDATE SET observed_at = MAX(note_index_clock.observed_at, excluded.observed_at)`)
|
|
242
|
+
.run(now);
|
|
243
|
+
// 10. Extract entities — delete old, insert new (the wikilink claims follow the change, see followEntityChange)
|
|
244
|
+
const fingerprintBefore = entityFingerprint(db);
|
|
245
|
+
const removedEntities = entityRowsOf(db, filePath);
|
|
246
|
+
db.prepare('DELETE FROM entities WHERE source_note = ?').run(filePath);
|
|
247
|
+
const rawAliases = note.frontmatter.aliases;
|
|
248
|
+
const aliases = Array.isArray(rawAliases)
|
|
249
|
+
? rawAliases.filter((a) => typeof a === 'string')
|
|
250
|
+
: typeof rawAliases === 'string'
|
|
251
|
+
? [rawAliases]
|
|
252
|
+
: [];
|
|
253
|
+
if (rawAliases !== undefined && rawAliases !== null
|
|
254
|
+
&& (Array.isArray(rawAliases) ? aliases.length < rawAliases.length : typeof rawAliases !== 'string')) {
|
|
255
|
+
warnFrontmatter(filePath, 'front matter "aliases" has a value that is not text');
|
|
256
|
+
}
|
|
257
|
+
const entityRow = db.prepare(`
|
|
258
|
+
INSERT INTO entities (type, canonical_name, aliases_json, source_note, confidence, updated_at)
|
|
259
|
+
VALUES (@type, @canonicalName, @aliasesJson, @sourceNote, @confidence, @updatedAt)
|
|
260
|
+
RETURNING id
|
|
261
|
+
`).get({
|
|
262
|
+
type: kind ?? 'note',
|
|
263
|
+
canonicalName: note.title,
|
|
264
|
+
aliasesJson: JSON.stringify(aliases),
|
|
265
|
+
sourceNote: filePath,
|
|
266
|
+
confidence: 1.0,
|
|
267
|
+
updatedAt: now,
|
|
268
|
+
});
|
|
269
|
+
followEntityChange(db, fingerprintBefore, removedEntities, entityRowsOf(db, filePath));
|
|
270
|
+
// 11. Extract facts — delete old, insert new
|
|
271
|
+
db.prepare('DELETE FROM facts WHERE source_path = ?').run(filePath);
|
|
272
|
+
const insertFact = db.prepare(`
|
|
273
|
+
INSERT INTO facts (subject_entity_id, predicate, object_text, source_path, confidence, valid_from, updated_at)
|
|
274
|
+
VALUES (@subjectEntityId, @predicate, @objectText, @sourcePath, @confidence, @validFrom, @updatedAt)
|
|
275
|
+
`);
|
|
276
|
+
// The engine's own header in a daily note is bookkeeping, not something anyone said: the day the file is about, which
|
|
277
|
+
// its name already says. Only a note the engine wrote has it (`engineWrote`, told from its front matter alone, not
|
|
278
|
+
// its place, its name or its body), and only that value is dropped, never one a person put there (dailyNote.ts).
|
|
279
|
+
for (const predicate of FACT_PREDICATES) {
|
|
280
|
+
const value = note.frontmatter[predicate];
|
|
281
|
+
if (value === undefined || value === null)
|
|
282
|
+
continue;
|
|
283
|
+
if (predicate === dailyNote_1.DAILY_NOTE_HEADER_KEY && engineWrote && (0, dailyNote_1.isEngineHeaderDate)(filePath, value))
|
|
284
|
+
continue;
|
|
285
|
+
const objectText = Array.isArray(value) ? value.map(v => (v == null ? '' : factText(v))).join(', ') : factText(value);
|
|
286
|
+
insertFact.run({
|
|
287
|
+
subjectEntityId: entityRow.id,
|
|
288
|
+
predicate,
|
|
289
|
+
objectText,
|
|
290
|
+
sourcePath: filePath,
|
|
291
|
+
confidence: 1.0,
|
|
292
|
+
validFrom: now,
|
|
293
|
+
updatedAt: now,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
// 12. Save wikilink relations — delete old, insert new
|
|
297
|
+
db.prepare('DELETE FROM relations WHERE source_note = ?').run(filePath);
|
|
298
|
+
const insertRelation = db.prepare(`
|
|
299
|
+
INSERT INTO relations (source_entity_id, relation, target_entity_id, source_note, confidence)
|
|
300
|
+
VALUES (?, 'links_to', ?, ?, 1.0)
|
|
301
|
+
`);
|
|
302
|
+
const targets = resolveWikilinks(db, note.wikilinks);
|
|
303
|
+
for (const wikilink of note.wikilinks) {
|
|
304
|
+
const target = targets.get(asciiLower(wikilink));
|
|
305
|
+
if (target !== undefined)
|
|
306
|
+
insertRelation.run(entityRow.id, target, filePath);
|
|
307
|
+
}
|
|
308
|
+
return entityRow.id;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* After a pass's rows are committed: the written chunks take the vectors of replaced chunks with the same text, the
|
|
312
|
+
* replaced chunks' vectors go, the rest are embedded, and only then is the note's hash committed (two-phase).
|
|
313
|
+
*/
|
|
314
|
+
async function storeVectors(db, filePath, options, pass) {
|
|
315
|
+
// A chunk whose text did not change keeps the vector it already has: copy it to the replacement
|
|
316
|
+
// chunk before the old vectors go, and embed only the chunks with new text. An edited or
|
|
317
|
+
// appended file then costs what changed, not what it holds (a ledger is one chunk per event).
|
|
318
|
+
const { insertedChunks, kept, oldChunkIds, vectorSource, vectorsMatch, embedsHere, noteHash } = pass;
|
|
319
|
+
const reused = new Set();
|
|
320
|
+
if (vectorsMatch && options.vectorIndex?.copy) {
|
|
321
|
+
for (const [chunkId, fromId] of vectorSource) {
|
|
322
|
+
if (options.vectorIndex.copy(fromId, chunkId))
|
|
323
|
+
reused.add(chunkId);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (options.vectorIndex) {
|
|
327
|
+
for (const id of oldChunkIds) {
|
|
328
|
+
options.vectorIndex.delete(id);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// 9. Embed chunks if provider given (outside transaction — async)
|
|
332
|
+
if (embedsHere && options.embeddingProvider && options.vectorIndex) {
|
|
333
|
+
const { embeddingProvider, vectorIndex } = options;
|
|
334
|
+
const toEmbed = insertedChunks.slice(kept).filter(c => !reused.has(c.id));
|
|
335
|
+
const texts = toEmbed.map(c => c.text);
|
|
336
|
+
if (texts.length > 0) {
|
|
337
|
+
const embeddings = await embeddingProvider.embed(texts, options.embedBudgetMs === undefined ? undefined : { budgetMs: options.embedBudgetMs * Math.ceil(texts.length / EMBED_REQUEST_TEXTS) });
|
|
338
|
+
// Fix #3: Warn on embedding count mismatch instead of silently losing vectors
|
|
339
|
+
if (embeddings.length !== texts.length) {
|
|
340
|
+
console.error(`[pipeline] embedding count mismatch: expected ${texts.length}, got ${embeddings.length} for ${filePath}`);
|
|
341
|
+
}
|
|
342
|
+
for (let i = 0; i < toEmbed.length; i++) {
|
|
343
|
+
if (embeddings[i]) {
|
|
344
|
+
if (vectorIndex.insertNew)
|
|
345
|
+
vectorIndex.insertNew(toEmbed[i].id, embeddings[i]);
|
|
346
|
+
else
|
|
347
|
+
vectorIndex.upsert(toEmbed[i].id, embeddings[i]);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Fix #2 (cont): Commit the real note_hash only after embedding succeeds
|
|
352
|
+
db.prepare('UPDATE notes SET note_hash = ? WHERE path = ?').run(noteHash, filePath);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Per database: the last pass's AppendBase of each file, for at most this many files, the most recently indexed. */
|
|
356
|
+
const MAX_APPEND_BASES = 64;
|
|
357
|
+
const appendBases = new WeakMap();
|
|
358
|
+
/** The file's base, removed: a pass that fails leaves none, and one that succeeds records its own. */
|
|
359
|
+
function takeAppendBase(db, filePath) {
|
|
360
|
+
const bases = appendBases.get(db);
|
|
361
|
+
const base = bases?.get(filePath);
|
|
362
|
+
bases?.delete(filePath);
|
|
363
|
+
return base;
|
|
364
|
+
}
|
|
365
|
+
function recordAppendBase(db, filePath, base) {
|
|
366
|
+
let bases = appendBases.get(db);
|
|
367
|
+
if (!bases)
|
|
368
|
+
appendBases.set(db, bases = new Map());
|
|
369
|
+
bases.delete(filePath);
|
|
370
|
+
bases.set(filePath, base);
|
|
371
|
+
if (bases.size > MAX_APPEND_BASES)
|
|
372
|
+
bases.delete(bases.keys().next().value);
|
|
373
|
+
}
|
|
374
|
+
const chunkingOf = (options) => `${options.chunkOptions?.maxTokens ?? 400}/${options.chunkOptions?.overlapTokens ?? 0}`;
|
|
375
|
+
const hashKey = (chunkHash) => parseInt(chunkHash.slice(0, 12), 16);
|
|
376
|
+
/** Whether any extraction lane would read the note: each reads all of it, so such a pass is a full one. */
|
|
377
|
+
function laneReadsNote(options) {
|
|
378
|
+
return !!options.factExtractor
|
|
379
|
+
|| (0, factsWritePath_1.factsWritePathEnabled)()
|
|
380
|
+
|| !!(options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)())
|
|
381
|
+
|| !!(options.eventExtractor && (0, eventExtractor_1.chronicleEnabled)())
|
|
382
|
+
|| !!(options.preferenceExtractor && (0, preferenceExtractor_1.prefsEnabled)());
|
|
383
|
+
}
|
|
384
|
+
/** The base a full pass leaves, or null when an append to the note could not be indexed by its appended part. */
|
|
385
|
+
function appendBaseOf(filePath, raw, content, detailed, kind, cut, rows, noteHash, options) {
|
|
386
|
+
if ((0, dailyNote_1.isDailyNotePath)(filePath) || raw.length === 0 || raw[raw.length - 1] !== 0x0a || !(0, buffer_1.isUtf8)(raw))
|
|
387
|
+
return null;
|
|
388
|
+
if (!detailed.titleFromHeading || !cut.last || cut.lastFirst === undefined)
|
|
389
|
+
return null;
|
|
390
|
+
if (cut.lastFirst >= rows.ids.length)
|
|
391
|
+
return null;
|
|
392
|
+
if (!(0, parser_1.wikilinksSettled)(detailed.rawBody))
|
|
393
|
+
return null;
|
|
394
|
+
// Where the last section's first line starts in the file: the body is the text after the frontmatter, trimmed.
|
|
395
|
+
const lines = detailed.note.body.split('\n');
|
|
396
|
+
let at = content.length - detailed.rawBody.trimStart().length;
|
|
397
|
+
for (let i = 0; i < cut.last.line; i++)
|
|
398
|
+
at += lines[i].length + 1;
|
|
399
|
+
if (!content.startsWith(lines[cut.last.line], at))
|
|
400
|
+
return null;
|
|
401
|
+
return {
|
|
402
|
+
bytes: raw.length,
|
|
403
|
+
noteHash,
|
|
404
|
+
chunking: chunkingOf(options),
|
|
405
|
+
title: detailed.note.title,
|
|
406
|
+
kind,
|
|
407
|
+
frontmatter: detailed.note.frontmatter,
|
|
408
|
+
frontmatterJson: JSON.stringify(detailed.note.frontmatter),
|
|
409
|
+
wikilinks: detailed.note.wikilinks,
|
|
410
|
+
last: { ...cut.last, byte: Buffer.byteLength(content.slice(0, at)) },
|
|
411
|
+
tailFirstId: rows.ids[cut.lastFirst],
|
|
412
|
+
headCount: cut.lastFirst,
|
|
413
|
+
headHashes: new Set(rows.hashes.slice(0, cut.lastFirst).map(hashKey)),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Index `raw`, the file's bytes, by what was appended to the bytes `base` describes (see AppendBase). Returns false,
|
|
418
|
+
* having changed nothing, when it cannot; the caller then indexes the file in full.
|
|
419
|
+
*/
|
|
420
|
+
async function indexAppend(db, filePath, raw, options, base) {
|
|
421
|
+
if (raw.length <= base.bytes || chunkingOf(options) !== base.chunking || laneReadsNote(options))
|
|
422
|
+
return false;
|
|
423
|
+
// The bytes that were indexed, unchanged, then valid text: hashed on the way to the note's new hash.
|
|
424
|
+
const hash = (0, crypto_1.createHash)('sha256').update(raw.subarray(0, base.bytes));
|
|
425
|
+
if (hash.copy().digest('hex') !== base.noteHash)
|
|
426
|
+
return false;
|
|
427
|
+
const appended = raw.subarray(base.bytes);
|
|
428
|
+
if (!(0, buffer_1.isUtf8)(appended))
|
|
429
|
+
return false;
|
|
430
|
+
const noteHash = hash.update(appended).digest('hex');
|
|
431
|
+
// The note from its last section on: that section as it was indexed, then what was appended. The body is trimmed,
|
|
432
|
+
// and this text starts with a line that is not blank (a heading, or the body's first line), so trimming its end
|
|
433
|
+
// trims the body's.
|
|
434
|
+
const kept = raw.toString('utf8', base.last.byte, base.bytes);
|
|
435
|
+
const tail = kept + appended.toString('utf8');
|
|
436
|
+
const lines = tail.trimEnd().split('\n');
|
|
437
|
+
const cut = (0, chunker_1.chunkBodyFrom)(filePath, lines, base.last, options.chunkOptions);
|
|
438
|
+
if (!cut.last || cut.lastFirst === undefined || cut.lastFirst >= cut.chunks.length)
|
|
439
|
+
return false;
|
|
440
|
+
const wikilinks = [...new Set([...base.wikilinks, ...(0, parser_1.wikilinkTargets)(tail, kept.length)])];
|
|
441
|
+
const stat = await promises_1.default.stat(filePath);
|
|
442
|
+
const modifiedAt = stat.mtime.toISOString();
|
|
443
|
+
const now = new Date().toISOString();
|
|
444
|
+
const embeddingConfigured = !!(options.embeddingProvider && options.vectorIndex);
|
|
445
|
+
const vectorsMatch = embeddingConfigured && storedVectorsMatch(options);
|
|
446
|
+
const embedsHere = embeddingConfigured && !(vectorsMatch && options.vectorIndex.rebuilding?.() === true);
|
|
447
|
+
const write = db.transaction(() => {
|
|
448
|
+
const existing = db.prepare('SELECT note_hash FROM notes WHERE path = ?').pluck().get(filePath);
|
|
449
|
+
if (existing !== base.noteHash)
|
|
450
|
+
return null;
|
|
451
|
+
if (db.prepare('SELECT 1 FROM structured_rows WHERE source_path = ? LIMIT 1').get(filePath))
|
|
452
|
+
return null;
|
|
453
|
+
const oldTail = db.prepare(`SELECT id, heading_path, text, start_line, end_line, token_count, chunk_hash FROM chunks
|
|
454
|
+
WHERE note_path = ? AND id >= ? ORDER BY id`).all(filePath, base.tailFirstId);
|
|
455
|
+
const headIds = db.prepare('SELECT id FROM chunks WHERE note_path = ? AND id < ? ORDER BY id')
|
|
456
|
+
.pluck().all(filePath, base.tailFirstId);
|
|
457
|
+
if (headIds.length !== base.headCount)
|
|
458
|
+
return null;
|
|
459
|
+
// As a full pass does: the chunks from the top that are stored as they are now are kept (all of those before the
|
|
460
|
+
// last section, whose lines did not change), and only if each kept chunk has its vector when the pass embeds.
|
|
461
|
+
const keep = unchangedPrefix(oldTail, cut.chunks);
|
|
462
|
+
const keptIds = [...headIds, ...oldTail.slice(0, keep).map(c => c.id)];
|
|
463
|
+
if (keptIds.length > 0 && embedsHere
|
|
464
|
+
&& (!vectorsMatch || options.vectorIndex.countStored?.(keptIds) !== keptIds.length))
|
|
465
|
+
return null;
|
|
466
|
+
const added = cut.chunks.slice(keep).map(chunk => ({ chunk, chunkHash: (0, hasher_1.hashContent)(chunk.text) }));
|
|
467
|
+
if (added.some(a => base.headHashes.has(hashKey(a.chunkHash))))
|
|
468
|
+
return null;
|
|
469
|
+
upsertNoteRow(db, filePath, {
|
|
470
|
+
title: base.title, kind: base.kind, noteHash: embedsHere ? '' : noteHash, modifiedAt, frontmatterJson: base.frontmatterJson,
|
|
471
|
+
});
|
|
472
|
+
if (keep < oldTail.length)
|
|
473
|
+
db.prepare('DELETE FROM chunks WHERE note_path = ? AND id >= ?').run(filePath, oldTail[keep].id);
|
|
474
|
+
const oldIdByHash = new Map();
|
|
475
|
+
for (const old of oldTail)
|
|
476
|
+
if (!oldIdByHash.has(old.chunk_hash))
|
|
477
|
+
oldIdByHash.set(old.chunk_hash, old.id);
|
|
478
|
+
const insertChunk = db.prepare(`
|
|
479
|
+
INSERT INTO chunks (note_path, heading_path, text, start_line, end_line, token_count, chunk_hash)
|
|
480
|
+
VALUES (@notePath, @headingPath, @text, @startLine, @endLine, @tokenCount, @chunkHash)
|
|
481
|
+
RETURNING id
|
|
482
|
+
`);
|
|
483
|
+
const insertedChunks = oldTail.slice(0, keep).map((old, i) => ({ id: old.id, text: cut.chunks[i].text }));
|
|
484
|
+
const vectorSource = new Map();
|
|
485
|
+
for (const { chunk, chunkHash } of added) {
|
|
486
|
+
const row = insertChunk.get({
|
|
487
|
+
notePath: chunk.notePath,
|
|
488
|
+
headingPath: chunk.headingPath,
|
|
489
|
+
text: chunk.text,
|
|
490
|
+
startLine: chunk.startLine,
|
|
491
|
+
endLine: chunk.endLine,
|
|
492
|
+
tokenCount: chunk.tokenCount,
|
|
493
|
+
chunkHash,
|
|
494
|
+
});
|
|
495
|
+
insertedChunks.push({ id: row.id, text: chunk.text });
|
|
496
|
+
const sameText = oldIdByHash.get(chunkHash);
|
|
497
|
+
if (sameText !== undefined)
|
|
498
|
+
vectorSource.set(row.id, sameText);
|
|
499
|
+
}
|
|
500
|
+
rebuildNoteRows(db, filePath, { title: base.title, frontmatter: base.frontmatter, wikilinks }, base.kind, false, now);
|
|
501
|
+
return {
|
|
502
|
+
insertedChunks,
|
|
503
|
+
kept: keep,
|
|
504
|
+
oldChunkIds: oldTail.slice(keep).map(old => old.id),
|
|
505
|
+
vectorSource,
|
|
506
|
+
chunkHashes: [...oldTail.slice(0, keep).map(old => old.chunk_hash), ...added.map(a => a.chunkHash)],
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
const written = write();
|
|
510
|
+
if (!written)
|
|
511
|
+
return false;
|
|
512
|
+
await storeVectors(db, filePath, options, { ...written, vectorsMatch, embedsHere, noteHash });
|
|
513
|
+
// The next append starts from this note's new last section.
|
|
514
|
+
if (raw[raw.length - 1] === 0x0a && (0, parser_1.wikilinksSettled)(tail, kept.length)) {
|
|
515
|
+
let at = 0;
|
|
516
|
+
for (let i = base.last.line; i < cut.last.line; i++)
|
|
517
|
+
at += lines[i - base.last.line].length + 1;
|
|
518
|
+
for (const chunkHash of written.chunkHashes.slice(0, cut.lastFirst))
|
|
519
|
+
base.headHashes.add(hashKey(chunkHash));
|
|
520
|
+
recordAppendBase(db, filePath, {
|
|
521
|
+
...base,
|
|
522
|
+
bytes: raw.length,
|
|
523
|
+
noteHash,
|
|
524
|
+
wikilinks,
|
|
525
|
+
last: { ...cut.last, byte: base.last.byte + Buffer.byteLength(tail.slice(0, at)) },
|
|
526
|
+
tailFirstId: written.insertedChunks[cut.lastFirst].id,
|
|
527
|
+
headCount: base.headCount + cut.lastFirst,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
async function readRegularFile(filePath) {
|
|
533
|
+
try {
|
|
534
|
+
return await promises_1.default.readFile(filePath, { flag: fs_1.constants.O_RDONLY | fs_1.constants.O_NOFOLLOW | fs_1.constants.O_NONBLOCK });
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
if (err.code === 'ELOOP')
|
|
538
|
+
throw new Error(`refusing to index ${JSON.stringify(filePath)}: it is a symbolic link`);
|
|
539
|
+
throw err;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Index one file. Runs under the same per-file lock every writer of that file takes, so an index
|
|
544
|
+
* pass can never read a file, lose the race to a rewrite (memory retirement), and then commit the
|
|
545
|
+
* stale text over the newer index.
|
|
546
|
+
*/
|
|
547
|
+
function indexFile(db, filePath, options = {}) {
|
|
548
|
+
return (0, writer_1.withDailyFileLock)(filePath, () => indexFileUnlocked(db, filePath, options));
|
|
549
|
+
}
|
|
550
|
+
async function indexFileUnlocked(db, filePath, options = {}) {
|
|
551
|
+
// 0. A note over the cap is not read (a read allocates the file, and parsing it a multiple of that)
|
|
552
|
+
const { size } = await promises_1.default.stat(filePath);
|
|
553
|
+
if (size > exports.MAX_NOTE_BYTES)
|
|
554
|
+
throw new NoteTooLargeError(filePath, size);
|
|
555
|
+
// 1. Read file content. Never through a symbolic link: the walker does not list one, and a caller that names one (the
|
|
556
|
+
// watcher is told of a link to a file as the file) must not pull a file from outside the vault into recall.
|
|
557
|
+
const raw = await readRegularFile(filePath);
|
|
558
|
+
// A note this process indexed that has only been appended to since is indexed by what was appended (see
|
|
559
|
+
// indexAppend); anything else is read and indexed in full, below.
|
|
560
|
+
const base = takeAppendBase(db, filePath);
|
|
561
|
+
if (base && await indexAppend(db, filePath, raw, options, base))
|
|
562
|
+
return;
|
|
563
|
+
const content = raw.toString('utf8');
|
|
564
|
+
// 2. Hash content (a daily note's hash also names the layout its chunks are cut in, see dailyEntries.ts)
|
|
565
|
+
const noteHash = (0, dailyEntries_1.noteContentHash)(filePath, content);
|
|
566
|
+
// 3. Check if unchanged
|
|
567
|
+
const existing = db.prepare('SELECT note_hash FROM notes WHERE path = ?').get(filePath);
|
|
568
|
+
const noteUnchanged = !!(existing && existing.note_hash === noteHash);
|
|
569
|
+
if (noteUnchanged && base?.noteHash === noteHash)
|
|
570
|
+
recordAppendBase(db, filePath, base); // it still describes the note
|
|
571
|
+
if (noteUnchanged) {
|
|
572
|
+
// The facts of an unchanged note are not rebuilt below, so a row an earlier build made of the engine's own header
|
|
573
|
+
// would stay. Remove it here: one statement for a daily note, no chunk or extractor involved.
|
|
574
|
+
removeDailyHeaderFacts(db, filePath, content);
|
|
575
|
+
// C2: unchanged content skips extraction ONLY when current-version rows
|
|
576
|
+
// already exist (ADR §4.2 — the cache key is (source_hash,
|
|
577
|
+
// extraction_version)). An extraction_version change invalidates the cache
|
|
578
|
+
// and falls through to re-extract, still with zero model calls until the
|
|
579
|
+
// (re-)extraction itself.
|
|
580
|
+
if ((options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)()) ||
|
|
581
|
+
(options.eventExtractor && (0, eventExtractor_1.chronicleEnabled)()) ||
|
|
582
|
+
(options.preferenceExtractor && (0, preferenceExtractor_1.prefsEnabled)())) {
|
|
583
|
+
// The cache key is per lane: (source_hash, extraction_version). Skip
|
|
584
|
+
// only when EVERY enabled lane is already done for these bytes — an
|
|
585
|
+
// enabled lane without rows (first run, version bump) falls through.
|
|
586
|
+
// "Done" is either half of the key: a current row for this hash, or a
|
|
587
|
+
// completed zero-yield run (#189, lane_runs — a lane that produced
|
|
588
|
+
// nothing has no row to record it).
|
|
589
|
+
const factsEnabled = !!(options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)());
|
|
590
|
+
const eventsEnabled = !!(options.eventExtractor && (0, eventExtractor_1.chronicleEnabled)());
|
|
591
|
+
const prefsEnabledLane = !!(options.preferenceExtractor && (0, preferenceExtractor_1.prefsEnabled)());
|
|
592
|
+
const haveCurrentVersionRows = !factsEnabled || !!db.prepare(`SELECT id FROM structured_rows
|
|
593
|
+
WHERE source_path = ? AND row_kind = 'fact' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, typedFacts_1.FACTS_EXTRACTION_VERSION, noteHash) || !!(0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'fact', noteHash, typedFacts_1.FACTS_EXTRACTION_VERSION);
|
|
594
|
+
const haveCurrentEventRows = !eventsEnabled || !!db.prepare(`SELECT id FROM structured_rows
|
|
595
|
+
WHERE source_path = ? AND row_kind = 'event' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, eventExtractor_1.EVENTS_EXTRACTION_VERSION, noteHash) || !!(0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'event', noteHash, eventExtractor_1.EVENTS_EXTRACTION_VERSION);
|
|
596
|
+
const haveCurrentPrefRows = !prefsEnabledLane || !!db.prepare(`SELECT id FROM structured_rows
|
|
597
|
+
WHERE source_path = ? AND row_kind = 'preference' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, preferenceExtractor_1.PREFS_EXTRACTION_VERSION, noteHash) || !!(0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'preference', noteHash, preferenceExtractor_1.PREFS_EXTRACTION_VERSION);
|
|
598
|
+
if (haveCurrentVersionRows && haveCurrentEventRows && haveCurrentPrefRows) {
|
|
599
|
+
return; // unchanged content: zero model calls
|
|
600
|
+
}
|
|
601
|
+
// else: a lane lacks current rows → fall through, stale rows deleted below.
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
return; // unchanged — skip
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// 4. Parse note
|
|
608
|
+
const detailed = (0, parser_1.parseNoteDetailed)(filePath, content);
|
|
609
|
+
const parsed = detailed.note;
|
|
610
|
+
// 5. Get mtime
|
|
611
|
+
const stat = await promises_1.default.stat(filePath);
|
|
612
|
+
const modifiedAt = stat.mtime.toISOString();
|
|
613
|
+
const now = new Date().toISOString();
|
|
614
|
+
const kind = noteKind(filePath, parsed.frontmatter);
|
|
615
|
+
// HARMONIZATION: lane ownership — when the fleet's typed-facts lane is armed
|
|
616
|
+
// (extractor + RENOR_TYPED_FACTS), it is THE fact-lane candidate generator;
|
|
617
|
+
// this branch's local-rules capture lane (RENOR_FACTS_WRITE_PATH) stands
|
|
618
|
+
// down for that note so the same note is never fact-extracted twice (two
|
|
619
|
+
// generators would double-write the same identities under two extraction
|
|
620
|
+
// versions). With only RENOR_FACTS_WRITE_PATH on, behavior is unchanged
|
|
621
|
+
// from this branch (local rules + DecisionModel seam).
|
|
622
|
+
const typedFactsLaneArmed = !!(options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)());
|
|
623
|
+
const embeddingConfigured = !!(options.embeddingProvider && options.vectorIndex);
|
|
624
|
+
// Stored vectors may be kept or copied only if they are in the space this provider writes.
|
|
625
|
+
const vectorsMatch = embeddingConfigured && storedVectorsMatch(options);
|
|
626
|
+
// While the index is unfinished in this provider's space, the background job that completes it embeds every chunk
|
|
627
|
+
// that has no vector, this file's included. The file's chunks are then written without vectors and left to it, so
|
|
628
|
+
// no chunk is embedded twice and a save embeds nothing it did not change (a ledger appended to keeps its earlier
|
|
629
|
+
// events as they are). Decided in the same synchronous step as the write below, so the job cannot have finished
|
|
630
|
+
// in between: it ends only when no chunk lacks a vector.
|
|
631
|
+
const embedsHere = embeddingConfigured && !(vectorsMatch && options.vectorIndex.rebuilding?.() === true);
|
|
632
|
+
// Fix #2: Use two-phase note_hash commit. If this file embeds its chunks, store ''
|
|
633
|
+
// so that a failed embed() causes the file to be re-processed on the next run.
|
|
634
|
+
// Store the real hash only after embeddings succeed (or immediately if nothing is embedded here).
|
|
635
|
+
const transactionHash = embedsHere ? '' : noteHash;
|
|
636
|
+
// 6-12. Wrap all DB mutations in a transaction.
|
|
637
|
+
// Fix #1: Query old chunk IDs inside the transaction so they are only deleted
|
|
638
|
+
// from the vector index AFTER the transaction commits successfully.
|
|
639
|
+
const upsertAll = db.transaction(() => {
|
|
640
|
+
// The stored chunks, top to bottom (a note's chunk ids ascend with position). The ones that
|
|
641
|
+
// are replaced have their vectors deleted after commit.
|
|
642
|
+
const oldChunks = db.prepare('SELECT id, heading_path, text, start_line, end_line, token_count, chunk_hash FROM chunks WHERE note_path = ? ORDER BY id').all(filePath);
|
|
643
|
+
// The old chunk holding each text: a chunk whose text is unchanged keeps its vector.
|
|
644
|
+
const oldIdByHash = new Map();
|
|
645
|
+
for (const old of oldChunks)
|
|
646
|
+
if (!oldIdByHash.has(old.chunk_hash))
|
|
647
|
+
oldIdByHash.set(old.chunk_hash, old.id);
|
|
648
|
+
const cut = (0, chunker_1.chunkNoteCut)(parsed, options.chunkOptions);
|
|
649
|
+
const chunks = cut.chunks;
|
|
650
|
+
// 6. Upsert note row
|
|
651
|
+
upsertNoteRow(db, filePath, {
|
|
652
|
+
title: parsed.title, kind, noteHash: transactionHash, modifiedAt, frontmatterJson: JSON.stringify(parsed.frontmatter),
|
|
653
|
+
});
|
|
654
|
+
// 7. Delete old chunks. structured_rows.source_chunk_id cascades, and
|
|
655
|
+
// the per-lane cache check happens after this rewrite. Copy current rows
|
|
656
|
+
// out first and reattach them to the replacement chunk, or a current lane
|
|
657
|
+
// looks stale and is extracted again.
|
|
658
|
+
const preservedStructured = db.prepare(`SELECT r.row_kind, r.source_hash, r.scope, r.extraction_version, r.valid_from, r.valid_to,
|
|
659
|
+
r.payload_json, r.dedupe_key, r.confirm_count, r.created_at, c.text AS source_text
|
|
660
|
+
FROM structured_rows r
|
|
661
|
+
LEFT JOIN chunks c ON c.id = r.source_chunk_id
|
|
662
|
+
WHERE r.source_path = ?`).all(filePath);
|
|
663
|
+
const preservedByKind = new Map();
|
|
664
|
+
for (const saved of preservedStructured) {
|
|
665
|
+
const kind = String(saved.row_kind);
|
|
666
|
+
const bucket = preservedByKind.get(kind) ?? [];
|
|
667
|
+
bucket.push(saved);
|
|
668
|
+
preservedByKind.set(kind, bucket);
|
|
669
|
+
}
|
|
670
|
+
// The chunks from the top of an edited note that are already stored exactly as they are now
|
|
671
|
+
// keep their rows, search entries and vectors; only the rest is replaced (a ledger appended to
|
|
672
|
+
// replaces its last event and adds the new one). The whole note is replaced instead when the
|
|
673
|
+
// bytes are unchanged (a lane re-run), when the last pass over it did not finish (an empty
|
|
674
|
+
// hash), when structured rows hang off its chunks, or when a stored chunk lacks its vector,
|
|
675
|
+
// so a full pass heals it, as it always did.
|
|
676
|
+
let keep = 0;
|
|
677
|
+
if (existing && !noteUnchanged && existing.note_hash !== '' && preservedStructured.length === 0) {
|
|
678
|
+
keep = unchangedPrefix(oldChunks, chunks);
|
|
679
|
+
if (keep > 0 && embedsHere
|
|
680
|
+
&& (!vectorsMatch || options.vectorIndex.countStored?.(oldChunks.slice(0, keep).map(c => c.id)) !== keep)) {
|
|
681
|
+
keep = 0;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (keep === 0)
|
|
685
|
+
db.prepare('DELETE FROM chunks WHERE note_path = ?').run(filePath);
|
|
686
|
+
else
|
|
687
|
+
db.prepare('DELETE FROM chunks WHERE note_path = ? AND id > ?').run(filePath, oldChunks[keep - 1].id);
|
|
688
|
+
// 8. Insert the chunks that are not kept, collecting inserted IDs
|
|
689
|
+
const insertChunk = db.prepare(`
|
|
690
|
+
INSERT INTO chunks (note_path, heading_path, text, start_line, end_line, token_count, chunk_hash)
|
|
691
|
+
VALUES (@notePath, @headingPath, @text, @startLine, @endLine, @tokenCount, @chunkHash)
|
|
692
|
+
RETURNING id
|
|
693
|
+
`);
|
|
694
|
+
const insertedChunks = oldChunks.slice(0, keep).map((old, i) => ({ id: old.id, text: chunks[i].text }));
|
|
695
|
+
const chunkHashes = oldChunks.slice(0, keep).map(old => old.chunk_hash); // of every chunk, in order
|
|
696
|
+
const vectorSource = new Map(); // new chunk id -> old chunk id with the same text
|
|
697
|
+
for (const chunk of chunks.slice(keep)) {
|
|
698
|
+
const chunkHash = (0, hasher_1.hashContent)(chunk.text);
|
|
699
|
+
chunkHashes.push(chunkHash);
|
|
700
|
+
const row = insertChunk.get({
|
|
701
|
+
notePath: chunk.notePath,
|
|
702
|
+
headingPath: chunk.headingPath,
|
|
703
|
+
text: chunk.text,
|
|
704
|
+
startLine: chunk.startLine,
|
|
705
|
+
endLine: chunk.endLine,
|
|
706
|
+
tokenCount: chunk.tokenCount,
|
|
707
|
+
chunkHash,
|
|
708
|
+
});
|
|
709
|
+
insertedChunks.push({ id: row.id, text: chunk.text });
|
|
710
|
+
const sameText = oldIdByHash.get(chunkHash);
|
|
711
|
+
if (sameText !== undefined)
|
|
712
|
+
vectorSource.set(row.id, sameText);
|
|
713
|
+
}
|
|
714
|
+
// Reattach rows copied out before the chunk delete. A lane whose version
|
|
715
|
+
// and hash are still current keeps its row; a stale lane stays deleted so
|
|
716
|
+
// only that lane re-extracts. The row keeps the replacement chunk that
|
|
717
|
+
// still contains its source text. Chunk 0 is only the fallback when that
|
|
718
|
+
// text is gone; it is not the provenance of every preserved row.
|
|
719
|
+
if (preservedStructured.length > 0 && insertedChunks.length > 0) {
|
|
720
|
+
const reattach = db.prepare(`INSERT INTO structured_rows
|
|
721
|
+
(row_kind, source_path, source_hash, source_chunk_id, scope, extraction_version,
|
|
722
|
+
valid_from, valid_to, payload_json, dedupe_key, confirm_count, created_at)
|
|
723
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
724
|
+
for (const saved of preservedStructured) {
|
|
725
|
+
const kind = saved.row_kind;
|
|
726
|
+
const version = saved.extraction_version;
|
|
727
|
+
const hash = saved.source_hash;
|
|
728
|
+
const current = saved.valid_to == null
|
|
729
|
+
&& version === currentVersion(kind)
|
|
730
|
+
&& hash === noteHash;
|
|
731
|
+
if (!current)
|
|
732
|
+
continue;
|
|
733
|
+
const sourceText = typeof saved.source_text === 'string' ? saved.source_text : '';
|
|
734
|
+
const attributed = sourceText
|
|
735
|
+
? (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, { statement: sourceText })
|
|
736
|
+
: null;
|
|
737
|
+
const chunkId = attributed?.chunkId ?? insertedChunks[0].id;
|
|
738
|
+
reattach.run(kind, filePath, hash, chunkId, saved.scope, version, saved.valid_from, saved.valid_to, saved.payload_json, saved.dedupe_key, saved.confirm_count, saved.created_at);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// 10-12. Entities, frontmatter facts and wikilink relations: delete old, insert new
|
|
742
|
+
const entityId = rebuildNoteRows(db, filePath, parsed, kind, (0, dailyNote_1.isEngineDailyNote)(filePath, content), now);
|
|
743
|
+
return {
|
|
744
|
+
insertedChunks,
|
|
745
|
+
kept: keep,
|
|
746
|
+
oldChunkIds: oldChunks.slice(keep).map(old => old.id),
|
|
747
|
+
vectorSource,
|
|
748
|
+
entityId,
|
|
749
|
+
preservedByKind,
|
|
750
|
+
cut,
|
|
751
|
+
chunkHashes,
|
|
752
|
+
};
|
|
753
|
+
});
|
|
754
|
+
// Fix #1 (cont): Delete old vectors only after transaction commits successfully
|
|
755
|
+
const { insertedChunks, kept, oldChunkIds, vectorSource, entityId, preservedByKind, cut, chunkHashes } = upsertAll();
|
|
756
|
+
// These post-failure helpers and the in-transaction reattach above must
|
|
757
|
+
// agree on one thing: a preserved row only counts as that lane's current row
|
|
758
|
+
// when its extraction_version matches the version that lane writes today
|
|
759
|
+
// (currentVersion), which is the same key the lane cache-skip below uses.
|
|
760
|
+
function preservedLaneMatches(kind) {
|
|
761
|
+
const savedRows = preservedByKind.get(kind) ?? [];
|
|
762
|
+
return savedRows.some(saved => (saved.valid_to == null
|
|
763
|
+
&& saved.source_hash === noteHash
|
|
764
|
+
&& saved.extraction_version === currentVersion(kind)));
|
|
765
|
+
}
|
|
766
|
+
function restorePreservedLane(kind) {
|
|
767
|
+
const savedRows = (preservedByKind.get(kind) ?? []).filter(saved => (saved.valid_to == null
|
|
768
|
+
&& saved.source_hash === noteHash
|
|
769
|
+
&& saved.extraction_version === currentVersion(kind)));
|
|
770
|
+
if (savedRows.length === 0 || insertedChunks.length === 0)
|
|
771
|
+
return;
|
|
772
|
+
const restore = db.prepare(`INSERT INTO structured_rows
|
|
773
|
+
(row_kind, source_path, source_hash, source_chunk_id, scope, extraction_version,
|
|
774
|
+
valid_from, valid_to, payload_json, dedupe_key, confirm_count, created_at)
|
|
775
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
776
|
+
for (const saved of savedRows) {
|
|
777
|
+
const sourceText = typeof saved.source_text === 'string' ? saved.source_text : '';
|
|
778
|
+
const attributed = sourceText
|
|
779
|
+
? (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, { statement: sourceText })
|
|
780
|
+
: null;
|
|
781
|
+
restore.run(kind, filePath, saved.source_hash, attributed?.chunkId ?? insertedChunks[0].id, saved.scope, saved.extraction_version, saved.valid_from, saved.valid_to, saved.payload_json, saved.dedupe_key, saved.confirm_count, saved.created_at);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
await storeVectors(db, filePath, options, { insertedChunks, kept, oldChunkIds, vectorSource, vectorsMatch, embedsHere, noteHash });
|
|
785
|
+
// 11b. LLM fact extraction — runs outside transaction, after embeddings (async/network).
|
|
786
|
+
// Fix #5: On extraction failure, reset note_hash to '' so the next unchanged-file
|
|
787
|
+
// indexing pass re-processes this note and retries extraction. Otherwise the real
|
|
788
|
+
// hash committed above (embedding path) or in the transaction marks the note as
|
|
789
|
+
// fully current while its LLM facts are missing/stale — and the transaction has
|
|
790
|
+
// already deleted the prior facts for this source, making the loss sticky.
|
|
791
|
+
if (options.factExtractor) {
|
|
792
|
+
try {
|
|
793
|
+
const llmFacts = await options.factExtractor(parsed.title, parsed.body);
|
|
794
|
+
if (llmFacts.length > 0) {
|
|
795
|
+
const insertLlmFact = db.prepare(`
|
|
796
|
+
INSERT INTO facts (subject_entity_id, predicate, object_text, source_path, confidence, valid_from, updated_at)
|
|
797
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
798
|
+
`);
|
|
799
|
+
for (const fact of llmFacts) {
|
|
800
|
+
insertLlmFact.run(entityId, fact.predicate, `${fact.subject}: ${fact.object}`, filePath, fact.confidence, now, now);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
console.error(`[pipeline] LLM extraction failed for ${filePath}:`, err.message);
|
|
806
|
+
// #188: reset the hash so the next pass retries (Fix #5), AND rethrow.
|
|
807
|
+
// Swallowing it here left `indexVault` counting the note as INDEXED, so
|
|
808
|
+
// `index` reported 0 errors and exited 0 with this note's facts missing —
|
|
809
|
+
// the blanket catch #188 is about. The structured lanes below already
|
|
810
|
+
// rethrow; this lane now fails the same way, so a real failure reaches the
|
|
811
|
+
// process exit status. Every other note still indexes.
|
|
812
|
+
db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
|
|
813
|
+
throw err;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// C2 facts write-path (flag-gated, default OFF): save-time typed fact
|
|
817
|
+
// capture into the structured-row store through the real index pipeline.
|
|
818
|
+
// Flag off = zero decision calls, zero new writes, byte-identical shipped
|
|
819
|
+
// behavior. The bitemporal model is the rowStore's identity-keyed
|
|
820
|
+
// supersession (the markdown ledger stays `_mcp_`-lifecycle-only per
|
|
821
|
+
// inventory-facts). Fix #5 pattern: a capture failure resets note_hash so
|
|
822
|
+
// the next pass retries — failed capture never marks a note current.
|
|
823
|
+
// HARMONIZATION: stands down when the fleet's typed-facts lane is armed
|
|
824
|
+
// (single fact-lane candidate generator — see lane ownership above).
|
|
825
|
+
if (!typedFactsLaneArmed && (0, factsWritePath_1.factsWritePathEnabled)()) {
|
|
826
|
+
try {
|
|
827
|
+
await (0, factsWritePath_1.captureNoteFacts)(db, {
|
|
828
|
+
notePath: filePath,
|
|
829
|
+
noteTitle: parsed.title,
|
|
830
|
+
frontmatter: parsed.frontmatter,
|
|
831
|
+
sourceHash: noteHash,
|
|
832
|
+
chunks: insertedChunks,
|
|
833
|
+
}, options.decisionModel);
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
console.error(`[pipeline] facts write-path capture failed for ${filePath}:`, err.message);
|
|
837
|
+
// Same #188 contract as the legacy lane above: reset the hash so the next
|
|
838
|
+
// pass retries, then rethrow so the failure is counted and reaches the
|
|
839
|
+
// exit status instead of being hidden by a blanket catch.
|
|
840
|
+
db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
|
|
841
|
+
throw err;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// 11c. C2 typed numeric facts → structured_rows (RENOR_TYPED_FACTS, default
|
|
845
|
+
// off; ADR-0003 §4 provenance contract + §4.2 cache invalidation). The
|
|
846
|
+
// (source_hash, extraction_version) skip happens BEFORE any model call:
|
|
847
|
+
// unchanged content is either already covered by current-version rows or by
|
|
848
|
+
// a completed zero-yield run (lane_runs, #189) and costs zero extractor calls.
|
|
849
|
+
// Edit path: stale rows for this source are deleted and re-extracted. On
|
|
850
|
+
// extractor-parse-error the note hash resets so the next pass retries;
|
|
851
|
+
// parse failures are never cached and never silently [].
|
|
852
|
+
// HARMONIZATION: candidates flow through the DecisionModel seam
|
|
853
|
+
// (adjudicateAndWriteRows) BEFORE any row is written — capture-gate /
|
|
854
|
+
// sensitivity-guardrail (a `sensitive` decision blocks the candidate) and
|
|
855
|
+
// dedupe adjudication with the confirm-count + v11 unique-index semantics.
|
|
856
|
+
if (options.numericFactExtractor && (0, typedFacts_1.typedFactsEnabled)()) {
|
|
857
|
+
// One predicate, both halves of the lane cache: a current row for these
|
|
858
|
+
// bytes, OR a completed zero-yield run for them (#189). A lane that
|
|
859
|
+
// produced nothing must be recognised BEFORE any extraction work starts,
|
|
860
|
+
// or it is re-extracted on every unchanged pass forever.
|
|
861
|
+
const structured = db.prepare(`SELECT id FROM structured_rows
|
|
862
|
+
WHERE source_path = ? AND row_kind = 'fact' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, typedFacts_1.FACTS_EXTRACTION_VERSION, noteHash)
|
|
863
|
+
?? (0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'fact', noteHash, typedFacts_1.FACTS_EXTRACTION_VERSION);
|
|
864
|
+
if (!structured) {
|
|
865
|
+
// This lane is stale. Re-extract only fact rows; a current fact lane
|
|
866
|
+
// falls through so events and preferences still run. The previous rows
|
|
867
|
+
// stay until the new extraction returns, so an extractor that yields
|
|
868
|
+
// nothing does not empty the lane. An extractor that THROWS does: a
|
|
869
|
+
// stale-version lane is left empty on purpose (see the catch below).
|
|
870
|
+
try {
|
|
871
|
+
const extracted = await options.numericFactExtractor(parsed.title, parsed.body);
|
|
872
|
+
(0, rowStore_1.deleteStructuredRowsForSourceKind)(db, filePath, 'fact');
|
|
873
|
+
const validFrom = (0, typedFacts_1.contentDateFromFrontmatter)(parsed.frontmatter);
|
|
874
|
+
const firstChunkId = db.prepare('SELECT id FROM chunks WHERE note_path = ? ORDER BY id LIMIT 1').get(filePath)?.id;
|
|
875
|
+
const candidates = extracted.map((f) => {
|
|
876
|
+
const attributed = (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, f);
|
|
877
|
+
return {
|
|
878
|
+
payload: { value: f.value, unit: f.unit, verb: f.verb, object: f.object, event: f.event },
|
|
879
|
+
sourceChunkId: attributed?.chunkId ?? firstChunkId ?? 1,
|
|
880
|
+
sourceText: attributed?.text ?? `${f.value} ${f.object}`.trim(),
|
|
881
|
+
};
|
|
882
|
+
});
|
|
883
|
+
const outcome = await (0, writePathAdjudication_1.adjudicateAndWriteRows)(db, {
|
|
884
|
+
rowKind: 'fact',
|
|
885
|
+
extractionVersion: typedFacts_1.FACTS_EXTRACTION_VERSION,
|
|
886
|
+
notePath: filePath,
|
|
887
|
+
sourceHash: noteHash,
|
|
888
|
+
validFrom,
|
|
889
|
+
candidates,
|
|
890
|
+
}, options.decisionModel);
|
|
891
|
+
// #189: the zero-yield checkpoint. This lane used to be called again
|
|
892
|
+
// on every unchanged pass because a zero yield leaves no row to mark it
|
|
893
|
+
// current; the completed run is recorded here instead — after the
|
|
894
|
+
// extractor and the writes both succeeded, so the throw path below
|
|
895
|
+
// stays uncached (it resets the note hash and must retry). What is
|
|
896
|
+
// recorded is what THIS run's writes returned — a fact of the run, not a
|
|
897
|
+
// count re-read from structured_rows, which a concurrent writer could
|
|
898
|
+
// tear (review B1) — so a zero is exactly "left nothing current".
|
|
899
|
+
(0, laneRuns_1.recordLaneRun)(db, filePath, 'fact', noteHash, typedFacts_1.FACTS_EXTRACTION_VERSION, outcome.rowsWritten);
|
|
900
|
+
}
|
|
901
|
+
catch (err) {
|
|
902
|
+
console.error(`[pipeline] typed-fact extraction failed for ${filePath}:`, err.message);
|
|
903
|
+
// Never cached, never silent []: reset the note hash so the next pass
|
|
904
|
+
// retries, then rethrow the explicit error (extractor-parse-error or
|
|
905
|
+
// otherwise) — a note whose extraction failed is never reported current.
|
|
906
|
+
// A stale-version row was not reattached and is deliberately NOT put
|
|
907
|
+
// back: a bumped extraction_version invalidates the lane, so its old
|
|
908
|
+
// payload must never be served as current. The lane stays empty until
|
|
909
|
+
// the retry succeeds, which the reset hash guarantees it will attempt.
|
|
910
|
+
db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
|
|
911
|
+
// An edited note must not keep the previous assertion as current
|
|
912
|
+
// against the new text. Restore only when the preserved row is still
|
|
913
|
+
// this lane's current row for these bytes, including a retry after an
|
|
914
|
+
// earlier failure cleared the note hash.
|
|
915
|
+
if (noteUnchanged || preservedLaneMatches('fact'))
|
|
916
|
+
restorePreservedLane('fact');
|
|
917
|
+
throw err;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
// 11d. C3 chronicle events → structured_rows (RENOR_CHRONICLE, default off;
|
|
922
|
+
// ADR-0003 §4 event-lane payload + §4.1 identity-keyed supersession via the
|
|
923
|
+
// shared rowStore insert). The (source_hash, extraction_version) skip happens
|
|
924
|
+
// BEFORE any model call: unchanged content is either already covered by
|
|
925
|
+
// current-version event rows or by a completed zero-yield run (lane_runs,
|
|
926
|
+
// #189) and costs zero extractor calls. Edit path: stale rows for this source are
|
|
927
|
+
// deleted and re-extracted. `when` stays the user's own wording inside the
|
|
928
|
+
// payload; the row's valid_from comes only from the note's frontmatter date
|
|
929
|
+
// (NULL = unknown, never resolved/invented from the relative `when`). On
|
|
930
|
+
// extractor-parse-error the note hash resets so the next pass retries;
|
|
931
|
+
// parse failures are never cached and never silently [].
|
|
932
|
+
// HARMONIZATION: candidates flow through the DecisionModel seam
|
|
933
|
+
// (adjudicateAndWriteRows) BEFORE any row is written — capture-gate /
|
|
934
|
+
// sensitivity-guardrail (a `sensitive` decision blocks the candidate) and
|
|
935
|
+
// dedupe adjudication; the write keeps the fleet's identity-keyed
|
|
936
|
+
// supersession semantics (newer dated restatement supersedes; NULL dates
|
|
937
|
+
// never supersede).
|
|
938
|
+
if (options.eventExtractor && (0, eventExtractor_1.chronicleEnabled)()) {
|
|
939
|
+
const currentEvents = db.prepare(`SELECT id FROM structured_rows
|
|
940
|
+
WHERE source_path = ? AND row_kind = 'event' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, eventExtractor_1.EVENTS_EXTRACTION_VERSION, noteHash)
|
|
941
|
+
?? (0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'event', noteHash, eventExtractor_1.EVENTS_EXTRACTION_VERSION);
|
|
942
|
+
if (!currentEvents) {
|
|
943
|
+
// This lane is stale. Re-extract only event rows. The previous rows
|
|
944
|
+
// stay until the new extraction returns.
|
|
945
|
+
try {
|
|
946
|
+
const extracted = await options.eventExtractor(parsed.title, parsed.body);
|
|
947
|
+
(0, rowStore_1.deleteStructuredRowsForSourceKind)(db, filePath, 'event');
|
|
948
|
+
const validFrom = (0, typedFacts_1.contentDateFromFrontmatter)(parsed.frontmatter);
|
|
949
|
+
const firstChunkId = db.prepare('SELECT id FROM chunks WHERE note_path = ? ORDER BY id LIMIT 1').get(filePath)?.id;
|
|
950
|
+
const candidates = extracted.map((e) => {
|
|
951
|
+
const attributed = (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, e);
|
|
952
|
+
return {
|
|
953
|
+
payload: { when: e.when, event: e.event, entities: e.entities },
|
|
954
|
+
sourceChunkId: attributed?.chunkId ?? firstChunkId ?? 1,
|
|
955
|
+
sourceText: attributed?.text ?? `${e.when} ${e.event}`.trim(),
|
|
956
|
+
};
|
|
957
|
+
});
|
|
958
|
+
const outcome = await (0, writePathAdjudication_1.adjudicateAndWriteRows)(db, {
|
|
959
|
+
rowKind: 'event',
|
|
960
|
+
extractionVersion: eventExtractor_1.EVENTS_EXTRACTION_VERSION,
|
|
961
|
+
notePath: filePath,
|
|
962
|
+
sourceHash: noteHash,
|
|
963
|
+
validFrom,
|
|
964
|
+
candidates,
|
|
965
|
+
}, options.decisionModel);
|
|
966
|
+
// Zero-yield events: same checkpoint as the fact lane (#189).
|
|
967
|
+
(0, laneRuns_1.recordLaneRun)(db, filePath, 'event', noteHash, eventExtractor_1.EVENTS_EXTRACTION_VERSION, outcome.rowsWritten);
|
|
968
|
+
}
|
|
969
|
+
catch (err) {
|
|
970
|
+
console.error(`[pipeline] event extraction failed for ${filePath}:`, err.message);
|
|
971
|
+
// Never cached, never silent []: reset the note hash so the next pass
|
|
972
|
+
// retries, then rethrow the explicit error (extractor-parse-error or
|
|
973
|
+
// otherwise) — a note whose extraction failed is never reported current.
|
|
974
|
+
db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
|
|
975
|
+
if (noteUnchanged || preservedLaneMatches('event'))
|
|
976
|
+
restorePreservedLane('event');
|
|
977
|
+
throw err;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
// 11e. C4 standing preferences → structured_rows (RENOR_PREFS, default off;
|
|
982
|
+
// ADR-0003 §4 preference-lane payload + §4.1 identity-keyed supersession via
|
|
983
|
+
// the shared rowStore insert — contradiction on edit supersedes, never
|
|
984
|
+
// co-current, only via (row_kind, scope, norm(statement)) identity). The
|
|
985
|
+
// (source_hash, extraction_version) skip happens BEFORE any model call:
|
|
986
|
+
// unchanged content is either already covered by current-version pref rows
|
|
987
|
+
// or by a completed zero-yield run (lane_runs, #189) and costs zero
|
|
988
|
+
// extractor calls. `kind` is an unvalidated render-only enum, never in the
|
|
989
|
+
// dedupe key. One-off past-tense statements are dropped by the extractor's
|
|
990
|
+
// structural guard. On extractor-parse-error the note hash resets so the
|
|
991
|
+
// next pass retries; parse failures are never cached and never silently [].
|
|
992
|
+
// HARMONIZATION: candidates flow through the DecisionModel seam
|
|
993
|
+
// (adjudicateAndWriteRows) BEFORE any row is written — capture-gate /
|
|
994
|
+
// sensitivity-guardrail (a `sensitive` decision blocks the candidate) and
|
|
995
|
+
// dedupe adjudication (norm(statement) identity); the write keeps the
|
|
996
|
+
// fleet's identity-keyed supersession semantics (contradiction on edit →
|
|
997
|
+
// same-identity newer restatement supersedes, never co-current).
|
|
998
|
+
if (options.preferenceExtractor && (0, preferenceExtractor_1.prefsEnabled)()) {
|
|
999
|
+
const currentPrefs = db.prepare(`SELECT id FROM structured_rows
|
|
1000
|
+
WHERE source_path = ? AND row_kind = 'preference' AND extraction_version = ? AND source_hash = ? AND valid_to IS NULL LIMIT 1`).get(filePath, preferenceExtractor_1.PREFS_EXTRACTION_VERSION, noteHash)
|
|
1001
|
+
?? (0, laneRuns_1.laneZeroYieldMarker)(db, filePath, 'preference', noteHash, preferenceExtractor_1.PREFS_EXTRACTION_VERSION);
|
|
1002
|
+
if (!currentPrefs) {
|
|
1003
|
+
// This lane is stale. Re-extract only preference rows. The previous
|
|
1004
|
+
// rows stay until the new extraction returns.
|
|
1005
|
+
try {
|
|
1006
|
+
const extracted = await options.preferenceExtractor(parsed.title, parsed.body);
|
|
1007
|
+
(0, rowStore_1.deleteStructuredRowsForSourceKind)(db, filePath, 'preference');
|
|
1008
|
+
const validFrom = (0, typedFacts_1.contentDateFromFrontmatter)(parsed.frontmatter);
|
|
1009
|
+
const firstChunkId = db.prepare('SELECT id FROM chunks WHERE note_path = ? ORDER BY id LIMIT 1').get(filePath)?.id;
|
|
1010
|
+
const candidates = extracted.map((p) => {
|
|
1011
|
+
const attributed = (0, writePathAdjudication_1.chunkForCandidate)(insertedChunks, p);
|
|
1012
|
+
return {
|
|
1013
|
+
payload: { statement: p.statement, kind: p.kind },
|
|
1014
|
+
sourceChunkId: attributed?.chunkId ?? firstChunkId ?? 1,
|
|
1015
|
+
sourceText: attributed?.text ?? p.statement,
|
|
1016
|
+
};
|
|
1017
|
+
});
|
|
1018
|
+
const outcome = await (0, writePathAdjudication_1.adjudicateAndWriteRows)(db, {
|
|
1019
|
+
rowKind: 'preference',
|
|
1020
|
+
extractionVersion: preferenceExtractor_1.PREFS_EXTRACTION_VERSION,
|
|
1021
|
+
notePath: filePath,
|
|
1022
|
+
sourceHash: noteHash,
|
|
1023
|
+
validFrom,
|
|
1024
|
+
candidates,
|
|
1025
|
+
}, options.decisionModel);
|
|
1026
|
+
// Zero-yield preferences: same checkpoint as the fact lane (#189).
|
|
1027
|
+
(0, laneRuns_1.recordLaneRun)(db, filePath, 'preference', noteHash, preferenceExtractor_1.PREFS_EXTRACTION_VERSION, outcome.rowsWritten);
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
console.error(`[pipeline] preference extraction failed for ${filePath}:`, err.message);
|
|
1031
|
+
// Never cached, never silent []: reset the note hash so the next pass
|
|
1032
|
+
// retries, then rethrow the explicit error (extractor-parse-error or
|
|
1033
|
+
// otherwise) — a note whose extraction failed is never reported current.
|
|
1034
|
+
db.prepare("UPDATE notes SET note_hash = '' WHERE path = ?").run(filePath);
|
|
1035
|
+
if (noteUnchanged || preservedLaneMatches('preference'))
|
|
1036
|
+
restorePreservedLane('preference');
|
|
1037
|
+
throw err;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
const rows = { ids: insertedChunks.map(c => c.id), hashes: chunkHashes };
|
|
1042
|
+
const next = appendBaseOf(filePath, raw, content, detailed, kind, cut, rows, noteHash, options);
|
|
1043
|
+
if (next)
|
|
1044
|
+
recordAppendBase(db, filePath, next);
|
|
1045
|
+
}
|
|
1046
|
+
async function indexVault(db, vaultPath, options = {}, concurrency = 5) {
|
|
1047
|
+
// A name another OS or locale wrote can hold bytes that are not UTF-8. Read the directory as
|
|
1048
|
+
// bytes and name those files here instead of letting the walk return a path that exists
|
|
1049
|
+
// nowhere: for a FILE that path failed the first read and counted as an indexing error, and
|
|
1050
|
+
// for a FOLDER `scandir` threw inside the walk, so one such name aborted the whole pass and
|
|
1051
|
+
// not one note reached the index.
|
|
1052
|
+
const skippedNonUtf8Names = [];
|
|
1053
|
+
const files = await (0, walker_1.walkVault)(vaultPath, {
|
|
1054
|
+
onNonUtf8Markdown: relPath => skippedNonUtf8Names.push(relPath),
|
|
1055
|
+
});
|
|
1056
|
+
const walked = files.length;
|
|
1057
|
+
const skippedTooLarge = [];
|
|
1058
|
+
let indexed = 0;
|
|
1059
|
+
let errors = 0;
|
|
1060
|
+
let fileIdx = 0;
|
|
1061
|
+
let active = 0;
|
|
1062
|
+
// Skipped names are reported even when they are the only Markdown in the vault: "nothing to
|
|
1063
|
+
// index" and "nothing indexable" must not read the same.
|
|
1064
|
+
if (walked === 0)
|
|
1065
|
+
return { total: 0, indexed: 0, errors: 0, skippedNonUtf8Names: skippedNonUtf8Names.sort() };
|
|
1066
|
+
console.log(`[index] starting: ${walked} files, concurrency ${concurrency}`);
|
|
1067
|
+
return new Promise((resolve) => {
|
|
1068
|
+
function onComplete() {
|
|
1069
|
+
const done = indexed + errors + skippedTooLarge.length;
|
|
1070
|
+
if (done % 50 === 0 || done === walked) {
|
|
1071
|
+
console.log(`[index] ${done}/${walked} files (${errors} errors)`);
|
|
1072
|
+
}
|
|
1073
|
+
if (done === walked) {
|
|
1074
|
+
resolve({
|
|
1075
|
+
total: walked - skippedTooLarge.length,
|
|
1076
|
+
indexed,
|
|
1077
|
+
errors,
|
|
1078
|
+
skippedNonUtf8Names: skippedNonUtf8Names.sort(),
|
|
1079
|
+
...(skippedTooLarge.length > 0 ? { skippedTooLarge: skippedTooLarge.sort() } : {}),
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
next();
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
function next() {
|
|
1087
|
+
while (active < concurrency && fileIdx < walked) {
|
|
1088
|
+
const filePath = files[fileIdx++];
|
|
1089
|
+
active++;
|
|
1090
|
+
indexFile(db, filePath, options).then(() => {
|
|
1091
|
+
indexed++;
|
|
1092
|
+
active--;
|
|
1093
|
+
onComplete();
|
|
1094
|
+
}, (err) => {
|
|
1095
|
+
active--;
|
|
1096
|
+
if (err instanceof NoteTooLargeError) {
|
|
1097
|
+
skippedTooLarge.push(path_1.default.relative(vaultPath, filePath));
|
|
1098
|
+
console.warn(`[index] ${filePath}: ${err.message}`);
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
errors++;
|
|
1102
|
+
console.error(`[index] error indexing ${filePath}:`, err);
|
|
1103
|
+
}
|
|
1104
|
+
onComplete();
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
next();
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Remove the facts an earlier build made of the engine's own daily-note headers (dailyNote.ts), and say how many. An
|
|
1113
|
+
* index written then holds one for every daily note, and a note whose bytes did not change is never rebuilt, so the row
|
|
1114
|
+
* would stay and be served (retrieve_memory answers it as a current fact). The server calls this when it starts, the
|
|
1115
|
+
* indexer when it passes over an unchanged daily note (it hands over the text it has just read); `filePath` narrows it
|
|
1116
|
+
* to one note.
|
|
1117
|
+
*
|
|
1118
|
+
* Nothing a person wrote is touched. A row goes only when it looks like what that indexer made of the header (a fact
|
|
1119
|
+
* named `updated` that no ledger event made, whose text is the note's own day as a Date) AND the note is one the engine
|
|
1120
|
+
* wrote: the front matter at the start of the file, read here, carries the engine's marker or is the complete front
|
|
1121
|
+
* matter the engine wrote before it had one (isEngineDailyNote). A person's note in the same folder with a natural
|
|
1122
|
+
* `updated:`, a note whose file is gone, or one that cannot be read, is not told to be the engine's, and its row stays.
|
|
1123
|
+
*/
|
|
1124
|
+
function removeDailyHeaderFacts(db, filePath, content) {
|
|
1125
|
+
if (filePath !== undefined && !(0, dailyNote_1.isDailyNotePath)(filePath))
|
|
1126
|
+
return 0;
|
|
1127
|
+
const rows = (filePath === undefined
|
|
1128
|
+
? db.prepare('SELECT id, source_path, object_text FROM facts WHERE predicate = ? AND event_id IS NULL').all(dailyNote_1.DAILY_NOTE_HEADER_KEY)
|
|
1129
|
+
: db.prepare('SELECT id, source_path, object_text FROM facts WHERE predicate = ? AND event_id IS NULL AND source_path = ?').all(dailyNote_1.DAILY_NOTE_HEADER_KEY, filePath));
|
|
1130
|
+
const byNote = new Map();
|
|
1131
|
+
for (const row of rows) {
|
|
1132
|
+
if (typeof row.source_path !== 'string' || !(0, dailyNote_1.isEarlierHeaderText)(row.source_path, row.object_text))
|
|
1133
|
+
continue;
|
|
1134
|
+
byNote.set(row.source_path, [...(byNote.get(row.source_path) ?? []), row.id]);
|
|
1135
|
+
}
|
|
1136
|
+
const ids = [];
|
|
1137
|
+
for (const [note, noteIds] of byNote) {
|
|
1138
|
+
const head = filePath !== undefined && content !== undefined ? content : readHead(note);
|
|
1139
|
+
if (head !== null && (0, dailyNote_1.isEngineDailyNote)(note, head))
|
|
1140
|
+
ids.push(...noteIds);
|
|
1141
|
+
}
|
|
1142
|
+
if (ids.length === 0)
|
|
1143
|
+
return 0;
|
|
1144
|
+
const drop = db.prepare('DELETE FROM facts WHERE id = ?');
|
|
1145
|
+
db.transaction(() => {
|
|
1146
|
+
for (const id of ids)
|
|
1147
|
+
drop.run(id);
|
|
1148
|
+
})();
|
|
1149
|
+
return ids.length;
|
|
1150
|
+
}
|
|
1151
|
+
const HEAD_BYTES = 4096;
|
|
1152
|
+
/** The start of a file; null when it cannot be read (a note that is gone proves nothing). */
|
|
1153
|
+
function readHead(file) {
|
|
1154
|
+
let fd;
|
|
1155
|
+
try {
|
|
1156
|
+
fd = (0, fs_2.openSync)(file, 'r');
|
|
1157
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
1158
|
+
const read = (0, fs_2.readSync)(fd, buffer, 0, HEAD_BYTES, 0);
|
|
1159
|
+
return buffer.subarray(0, read).toString('utf8');
|
|
1160
|
+
}
|
|
1161
|
+
catch {
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
finally {
|
|
1165
|
+
if (fd !== undefined)
|
|
1166
|
+
(0, fs_2.closeSync)(fd);
|
|
1167
|
+
}
|
|
1168
|
+
}
|