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,1756 @@
|
|
|
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.LegacyIdentityAmbiguityError = exports.LifecycleInputError = exports.FIELD_LIMITS = exports.DEFAULT_LEDGER_REL = exports.LIFECYCLE_NOTE_PATH = void 0;
|
|
7
|
+
exports.resolveLedgerPath = resolveLedgerPath;
|
|
8
|
+
exports.appendLedgerEvent = appendLedgerEvent;
|
|
9
|
+
exports.unterminatedFinalEventStart = unterminatedFinalEventStart;
|
|
10
|
+
exports.parseLedgerEvents = parseLedgerEvents;
|
|
11
|
+
exports.replayLedger = replayLedger;
|
|
12
|
+
exports.remember = remember;
|
|
13
|
+
exports.factEventId = factEventId;
|
|
14
|
+
exports.retireFact = retireFact;
|
|
15
|
+
exports.restoreFact = restoreFact;
|
|
16
|
+
exports.listRetiredFacts = listRetiredFacts;
|
|
17
|
+
exports.scopeFilterFor = scopeFilterFor;
|
|
18
|
+
exports.listLifecycleSubjects = listLifecycleSubjects;
|
|
19
|
+
exports.factsFor = factsFor;
|
|
20
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
21
|
+
const path_1 = __importDefault(require("path"));
|
|
22
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
23
|
+
const entityLookup_1 = require("../retrieval/entityLookup");
|
|
24
|
+
const queryHelpers_1 = require("../retrieval/queryHelpers");
|
|
25
|
+
const scope_1 = require("../mvp/scope");
|
|
26
|
+
const readCache_1 = require("../db/readCache");
|
|
27
|
+
const statements_1 = require("../db/statements");
|
|
28
|
+
const factTerms_1 = require("./factTerms");
|
|
29
|
+
const limits_1 = require("./limits");
|
|
30
|
+
const confinedWrite_1 = require("./confinedWrite");
|
|
31
|
+
const invisibleText_1 = require("./invisibleText");
|
|
32
|
+
const sourceName_1 = require("./sourceName");
|
|
33
|
+
const writerConflicts_1 = require("./writerConflicts");
|
|
34
|
+
const durableAppend_1 = require("./durableAppend");
|
|
35
|
+
const secretGuard_1 = require("../extraction/secretGuard");
|
|
36
|
+
const toolRefusal_1 = require("../toolRefusal");
|
|
37
|
+
/** Reserved synthetic note path owning lifecycle entities (never a real vault file). */
|
|
38
|
+
exports.LIFECYCLE_NOTE_PATH = '_mcp_';
|
|
39
|
+
/** Default relative ledger path inside the vault. */
|
|
40
|
+
exports.DEFAULT_LEDGER_REL = 'System/Memory Events Ledger.md';
|
|
41
|
+
const EVENT_ID_RE = /^mem_[A-Za-z0-9]+$/i;
|
|
42
|
+
const EVENT_HEADER_RE = /^## (mem_[A-Za-z0-9]+) · (.+?)[ \t]*$/i;
|
|
43
|
+
// A field value is the rest of its physical line, and lines are split on '\n' alone. dotAll, because
|
|
44
|
+
// `.` otherwise refuses U+2028/U+2029 (and a lone \r), which JavaScript calls line terminators and this
|
|
45
|
+
// ledger does not: one such character in a value used to make the whole ledger unreadable at startup.
|
|
46
|
+
const FIELD_RE = /^- ([A-Za-z][\w-]*): (.*)$/s;
|
|
47
|
+
const KNOWN_FIELDS = new Set(['kind', 'subject', 'predicate', 'object', 'closed_event', 'source', 'scope', 'statement', 'writer', 'target']);
|
|
48
|
+
const REQUIRED_FIELDS = ['subject', 'predicate', 'object'];
|
|
49
|
+
/**
|
|
50
|
+
* retire takes one fact out of recall and restore brings it back (retireFact, restoreFact). Each names the fact's own
|
|
51
|
+
* event as its target and repeats its subject and predicate so the ledger reads on its own, but never its value: the
|
|
52
|
+
* value is in the target event already, and a second copy would be one more place a value someone asked to forget
|
|
53
|
+
* is written.
|
|
54
|
+
*/
|
|
55
|
+
const RETIREMENT_KINDS = new Set(['retire', 'restore']);
|
|
56
|
+
const KNOWN_KINDS = new Set(['store', 'supersede', ...RETIREMENT_KINDS]);
|
|
57
|
+
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
|
|
58
|
+
exports.FIELD_LIMITS = { subject: 512, predicate: 256, object: 8192, source: 256, scope: 80, statement: limits_1.MAX_STATEMENT_LENGTH, writer: 256 };
|
|
59
|
+
/**
|
|
60
|
+
* Characters JSON.stringify leaves literal although a line-oriented reader may read them as a line
|
|
61
|
+
* end or a control: DEL, the C1 controls (U+0085 NEXT LINE among them), U+2028 LINE SEPARATOR and
|
|
62
|
+
* U+2029 PARAGRAPH SEPARATOR. JSON itself already escapes every C0 control and any unpaired surrogate.
|
|
63
|
+
*/
|
|
64
|
+
const JSON_LEAVES_LITERAL = /[\p{Cc}\p{Zl}\p{Zp}]/gu;
|
|
65
|
+
/**
|
|
66
|
+
* The user's statement is free text (newlines, '## mem_…', '- key: …' lines), and the ledger is
|
|
67
|
+
* line-framed, so it is written as ONE JSON string literal and nothing else. The value starts with a
|
|
68
|
+
* quote, so no statement can open a heading or a field line; between the quotes there is no control
|
|
69
|
+
* character and no line or paragraph separator, so it is one line to every reader (this parser, an
|
|
70
|
+
* editor, a script that splits on Unicode line rules). Every escape is plain JSON, so any JSON parser
|
|
71
|
+
* reads the value back exactly, while text in any script stays readable and searchable as written.
|
|
72
|
+
*/
|
|
73
|
+
function encodeStatement(statement) {
|
|
74
|
+
return JSON.stringify(statement)
|
|
75
|
+
.replace(JSON_LEAVES_LITERAL, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Strict on purpose: anything that is not exactly one JSON string literal is corruption. JSON allows
|
|
79
|
+
* the separators the encoder now escapes to appear literal inside the quotes, so a ledger written
|
|
80
|
+
* before they were escaped still reads back exactly.
|
|
81
|
+
*/
|
|
82
|
+
function decodeStatement(raw, id) {
|
|
83
|
+
let value;
|
|
84
|
+
try {
|
|
85
|
+
value = JSON.parse(raw);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
value = undefined;
|
|
89
|
+
}
|
|
90
|
+
if (typeof value !== 'string') {
|
|
91
|
+
throw new Error(`ledger corruption in event ${id}: statement is not a JSON string literal`);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
function newEventId() {
|
|
96
|
+
const t = Date.now().toString(36);
|
|
97
|
+
const r = Math.random().toString(36).slice(2, 8);
|
|
98
|
+
return `mem_${t}${r}`.toUpperCase();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A lifecycle field value was rejected by validation.
|
|
102
|
+
*
|
|
103
|
+
* On the write path every field is validated before the ledger is touched, so this class means
|
|
104
|
+
* "the request was wrong", never "the process is unhealthy": a caller-side type/length/character
|
|
105
|
+
* error must be answered 4xx by an HTTP surface, not 500. Reporting it as 500 misclassifies a
|
|
106
|
+
* client bug as a server fault and pages whoever watches the error budget for a request nobody
|
|
107
|
+
* can fix. Genuine engine faults — an unreadable ledger, a reused event id, a clock regression,
|
|
108
|
+
* a ledger path that escapes the vault — keep throwing plain Errors and stay 5xx.
|
|
109
|
+
*
|
|
110
|
+
* The message text is unchanged except for naming the received type, so log greps and existing
|
|
111
|
+
* assertions still match; only the class carries the new distinction.
|
|
112
|
+
*/
|
|
113
|
+
class LifecycleInputError extends Error {
|
|
114
|
+
field;
|
|
115
|
+
/**
|
|
116
|
+
* The refusal code telemetry carries, from the closed set in src/toolRefusal.ts, is recorded out of band
|
|
117
|
+
* (markErrorReason): the class gains no field, so its class, `name`, `message`, enumerable keys and
|
|
118
|
+
* `instanceof` are exactly what they were before. The third argument is optional, so an error built
|
|
119
|
+
* elsewhere keeps its two-argument shape.
|
|
120
|
+
*/
|
|
121
|
+
constructor(field, message, reason) {
|
|
122
|
+
super(message);
|
|
123
|
+
this.name = 'LifecycleInputError';
|
|
124
|
+
this.field = field;
|
|
125
|
+
if (reason !== undefined)
|
|
126
|
+
(0, toolRefusal_1.markErrorReason)(this, reason);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
exports.LifecycleInputError = LifecycleInputError;
|
|
130
|
+
/** The received type, spelled the way JSON Schema spells it, for error messages. */
|
|
131
|
+
function describeType(value) {
|
|
132
|
+
if (value === null)
|
|
133
|
+
return 'null';
|
|
134
|
+
if (Array.isArray(value))
|
|
135
|
+
return 'array';
|
|
136
|
+
return typeof value;
|
|
137
|
+
}
|
|
138
|
+
function validateField(field, value, required) {
|
|
139
|
+
if (typeof value !== 'string')
|
|
140
|
+
throw new LifecycleInputError(field, `lifecycle ${field} must be a string (received ${describeType(value)})`, 'invalid_argument');
|
|
141
|
+
if (required && value.trim() === '')
|
|
142
|
+
throw new LifecycleInputError(field, `lifecycle ${field} must be a non-empty string`, 'invalid_argument');
|
|
143
|
+
if (CONTROL_CHARS.test(value)) {
|
|
144
|
+
throw new LifecycleInputError(field, `lifecycle ${field} contains control characters (newlines/tabs are rejected to keep ledger framing strict): ${JSON.stringify(value.slice(0, 40))}`, 'invalid_text');
|
|
145
|
+
}
|
|
146
|
+
const limit = exports.FIELD_LIMITS[field];
|
|
147
|
+
if (limit !== undefined && value.length > limit) {
|
|
148
|
+
throw new LifecycleInputError(field, `lifecycle ${field} exceeds the maximum length of ${limit} (got ${value.length})`, 'field_too_long');
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
function validateLifecycleInput(args) {
|
|
153
|
+
validateField('subject', args.subject, true);
|
|
154
|
+
validateField('predicate', args.predicate, true);
|
|
155
|
+
validateField('object', args.object, true);
|
|
156
|
+
if (args.source !== undefined)
|
|
157
|
+
validateField('source', args.source, false);
|
|
158
|
+
if (args.statement !== undefined) {
|
|
159
|
+
// The user's own words are kept as given, so unlike the structured fields they may hold control
|
|
160
|
+
// characters (the encoding makes them safe); only the type and the size are checked. The encoding
|
|
161
|
+
// is the reason the control-character rule in memory/controls.ts does not apply here: a statement
|
|
162
|
+
// is written as ONE JSON string literal, so every C0 control is escaped on disk and no statement
|
|
163
|
+
// can break the ledger's framing — while the note writer, which stores text raw, does refuse them.
|
|
164
|
+
if (typeof args.statement !== 'string')
|
|
165
|
+
throw new LifecycleInputError('statement', `lifecycle statement must be a string (received ${describeType(args.statement)})`, 'invalid_argument');
|
|
166
|
+
const limit = exports.FIELD_LIMITS.statement;
|
|
167
|
+
if (args.statement.length > limit)
|
|
168
|
+
throw new LifecycleInputError('statement', `lifecycle statement exceeds the maximum length of ${limit} (got ${args.statement.length})`, 'field_too_long');
|
|
169
|
+
}
|
|
170
|
+
if (args.scope !== undefined) {
|
|
171
|
+
// Normalize throws on invalid; re-label it as the caller error it is, so every rejection
|
|
172
|
+
// from this function leaves as a LifecycleInputError and an HTTP surface can answer 4xx.
|
|
173
|
+
// Also bound via FIELD_LIMITS via validateField on the normalized form.
|
|
174
|
+
let n;
|
|
175
|
+
try {
|
|
176
|
+
n = (0, scope_1.normalizeScope)(args.scope);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
// normalizeScope names invalid_scope itself (src/mvp/scope.ts); the re-label keeps that code.
|
|
180
|
+
throw new LifecycleInputError('scope', err instanceof Error ? err.message : String(err), (0, toolRefusal_1.refusalReason)(err) ?? 'invalid_scope');
|
|
181
|
+
}
|
|
182
|
+
validateField('scope', n, false);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Write-only: replay runs validateField over every stored event, so a value written before this check must still load.
|
|
186
|
+
function refuseUnstorableFields(args) {
|
|
187
|
+
for (const field of ['subject', 'predicate', 'object']) {
|
|
188
|
+
const value = args[field];
|
|
189
|
+
if ((0, invisibleText_1.isBlankOrInvisible)(value))
|
|
190
|
+
throw new LifecycleInputError(field, `lifecycle ${field} must be a non-empty string`, 'invalid_argument');
|
|
191
|
+
if (/\p{Cs}/u.test(value))
|
|
192
|
+
throw new LifecycleInputError(field, `lifecycle ${field} contains an unpaired UTF-16 surrogate, which cannot be stored as text`, 'invalid_text');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* S1 canonical timestamp contract: ONE sortable format everywhere — the exact
|
|
197
|
+
* UTC-ISO representation the writer emits (new Date().toISOString():
|
|
198
|
+
* YYYY-MM-DDTHH:mm:ss.sssZ). With this single format, lexicographic string
|
|
199
|
+
* order == instant order, so the apply/live string comparisons, the
|
|
200
|
+
* reconciliation's instant comparisons and the SQL textual ordering behind
|
|
201
|
+
* asOf/current reads all agree without any format migration or digest change
|
|
202
|
+
* (canonical events keep byte-identical at strings, so digests are unchanged).
|
|
203
|
+
* Other representations — e.g. '2026-09-08T23:30:00.000-01:00', which is a
|
|
204
|
+
* LATER instant sorting EARLIER lexically — are an UNSUPPORTED legacy
|
|
205
|
+
* representation, not a malicious input: the engine writer has never emitted
|
|
206
|
+
* them, and they are rejected consistently, BEFORE any mutation, with a clear
|
|
207
|
+
* message naming the event. Recovery is repairing the ledger entry.
|
|
208
|
+
*/
|
|
209
|
+
const CANONICAL_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
210
|
+
function isCanonicalTimestamp(at) {
|
|
211
|
+
if (!CANONICAL_TS_RE.test(at))
|
|
212
|
+
return false;
|
|
213
|
+
// Exact writer format: the string IS the canonical serialization of its instant.
|
|
214
|
+
const d = new Date(at);
|
|
215
|
+
return !Number.isNaN(d.getTime()) && d.toISOString() === at;
|
|
216
|
+
}
|
|
217
|
+
/** Normalize any parseable ISO instant to the canonical format (query inputs only). */
|
|
218
|
+
// S1.1: datetime input must carry an explicit timezone (trailing Z or a
|
|
219
|
+
// numeric ±HH:MM offset). Timezone-less datetimes such as
|
|
220
|
+
// '2026-09-09T00:30:00' are parsed against the HOST timezone by Date, so the
|
|
221
|
+
// canonicalized instant silently depended on the host's TZ — ambiguous input
|
|
222
|
+
// is rejected clearly instead. Date-only 'YYYY-MM-DD' remains accepted: ISO
|
|
223
|
+
// 8601 defines date-only as UTC midnight, deterministically. Valid zoned
|
|
224
|
+
// instants normalize exactly as before.
|
|
225
|
+
const ZONED_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
226
|
+
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
227
|
+
function toCanonicalInstant(value) {
|
|
228
|
+
if (typeof value !== 'string'
|
|
229
|
+
|| (!ZONED_INSTANT_RE.test(value) && !DATE_ONLY_RE.test(value))) {
|
|
230
|
+
throw new Error(`invalid timestamp ${JSON.stringify(value)}: expected an ISO 8601 instant with an explicit timezone (e.g. 2026-09-09T00:30:00Z or 2026-09-09T00:30:00-03:00, or date-only YYYY-MM-DD which is UTC midnight); timezone-less datetimes are ambiguous and rejected`);
|
|
231
|
+
}
|
|
232
|
+
const d = new Date(value);
|
|
233
|
+
if (Number.isNaN(d.getTime())) {
|
|
234
|
+
throw new Error(`invalid timestamp ${JSON.stringify(value)}: expected a parseable ISO 8601 instant`);
|
|
235
|
+
}
|
|
236
|
+
return d.toISOString();
|
|
237
|
+
}
|
|
238
|
+
function validateLedgerEvent(ev) {
|
|
239
|
+
if (!EVENT_ID_RE.test(ev.id))
|
|
240
|
+
throw new Error(`ledger event id "${ev.id}" is malformed`);
|
|
241
|
+
if (!isCanonicalTimestamp(ev.at)) {
|
|
242
|
+
throw new Error(`ledger event ${ev.id} has an unsupported timestamp representation ${JSON.stringify(ev.at)}: the canonical format emitted by the writer is exact UTC ISO (YYYY-MM-DDTHH:mm:ss.sssZ, e.g. 2026-09-09T00:00:00.000Z); repair the ledger entry`);
|
|
243
|
+
}
|
|
244
|
+
if (!KNOWN_KINDS.has(ev.kind))
|
|
245
|
+
throw new Error(`ledger corruption in event ${ev.id}: unknown kind "${ev.kind}"`);
|
|
246
|
+
const retirement = RETIREMENT_KINDS.has(ev.kind);
|
|
247
|
+
validateField('subject', ev.subject, true);
|
|
248
|
+
validateField('predicate', ev.predicate, true);
|
|
249
|
+
// A retirement carries no value (RETIREMENT_KINDS); a fact event always does.
|
|
250
|
+
if (retirement) {
|
|
251
|
+
if (ev.object !== '')
|
|
252
|
+
throw new Error(`ledger corruption in event ${ev.id}: a ${ev.kind} event carries no object`);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
validateField('object', ev.object, true);
|
|
256
|
+
}
|
|
257
|
+
validateField('source', ev.source, false);
|
|
258
|
+
if (ev.writer !== undefined)
|
|
259
|
+
validateField('writer', ev.writer, true);
|
|
260
|
+
if (retirement) {
|
|
261
|
+
if (ev.target === undefined || !EVENT_ID_RE.test(ev.target)) {
|
|
262
|
+
throw new Error(`ledger corruption in event ${ev.id}: a ${ev.kind} event needs the id of the fact event it ${ev.kind}s (target)`);
|
|
263
|
+
}
|
|
264
|
+
if (ev.closedEvent != null || ev.statement !== undefined) {
|
|
265
|
+
throw new Error(`ledger corruption in event ${ev.id}: a ${ev.kind} event closes nothing and keeps no statement`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else if (ev.target !== undefined) {
|
|
269
|
+
throw new Error(`ledger corruption in event ${ev.id}: only a retire or restore event names a target`);
|
|
270
|
+
}
|
|
271
|
+
if (ev.statement !== undefined)
|
|
272
|
+
validateLifecycleInput({ subject: ev.subject, predicate: ev.predicate, object: ev.object, statement: ev.statement });
|
|
273
|
+
if (ev.scope !== undefined && ev.scope !== null && ev.scope !== '') {
|
|
274
|
+
const n = (0, scope_1.normalizeScope)(ev.scope);
|
|
275
|
+
validateField('scope', n, false);
|
|
276
|
+
ev.scope = n;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
ev.scope = 'global';
|
|
280
|
+
}
|
|
281
|
+
if (ev.closedEvent != null && !EVENT_ID_RE.test(ev.closedEvent)) {
|
|
282
|
+
throw new Error(`ledger corruption in event ${ev.id}: malformed closed_event id "${ev.closedEvent}"`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function eventDigest(ev) {
|
|
286
|
+
const parts = [
|
|
287
|
+
ev.id, ev.at, ev.kind, ev.subject, ev.predicate, ev.object, ev.closedEvent ?? '', ev.source ?? 'ledger',
|
|
288
|
+
];
|
|
289
|
+
// Digest-stable for legacy/global: only append scope when non-global so existing
|
|
290
|
+
// applied_ledger_events digests keep matching after migration 8.
|
|
291
|
+
const scope = (0, scope_1.normalizeScope)(ev.scope);
|
|
292
|
+
if (scope !== 'global')
|
|
293
|
+
parts.push(scope);
|
|
294
|
+
// Same rule for the statement: absent => digest identical to pre-statement events.
|
|
295
|
+
if (ev.statement !== undefined)
|
|
296
|
+
parts.push(`statement:${ev.statement}`);
|
|
297
|
+
if (ev.writer !== undefined)
|
|
298
|
+
parts.push(`writer:${ev.writer}`);
|
|
299
|
+
if (ev.target !== undefined)
|
|
300
|
+
parts.push(`target:${ev.target}`);
|
|
301
|
+
return crypto_1.default.createHash('sha256').update(JSON.stringify(parts)).digest('hex');
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* S2 shared ledger path resolver, rooted at the VAULT ROOT and used by BOTH
|
|
305
|
+
* append and replay (and the remember receipt). The old containment check
|
|
306
|
+
* rooted its base at dirname(ledgerRelPath), so '../outside/ledger' passed a
|
|
307
|
+
* check whose base was already outside the vault; replay had no confinement
|
|
308
|
+
* at all. Confinement is lexical (path.resolve + path.relative) and happens
|
|
309
|
+
* BEFORE any filesystem access — an escaping path never creates or reads
|
|
310
|
+
* anything outside the vault. Absolute ledger paths are rejected fail-closed
|
|
311
|
+
* (previously they silently double-joined through path.join). Normal relative
|
|
312
|
+
* paths and the '.md'-suffix behavior are unchanged. This is NOT symlink or
|
|
313
|
+
* concurrent-writer protection — hosted isolation remains Gate 1B scope.
|
|
314
|
+
*
|
|
315
|
+
* S2.1: escape detection matches an actual parent component ('..' exactly, or
|
|
316
|
+
* '..' followed by a path separator) — NOT a mere '../..' string prefix, which
|
|
317
|
+
* wrongly rejected harmless in-vault names like '..safe/ledger' or
|
|
318
|
+
* '..ledger.md'. All traversal defenses are preserved: any resolved path
|
|
319
|
+
* outside the vault root, and any absolute ledger path, still fails closed.
|
|
320
|
+
*/
|
|
321
|
+
function resolveLedgerPath(vaultPath, ledgerRelPath) {
|
|
322
|
+
const root = path_1.default.resolve(vaultPath);
|
|
323
|
+
let rel = ledgerRelPath;
|
|
324
|
+
if (typeof rel !== 'string' || rel.length === 0) {
|
|
325
|
+
throw new Error(`ledger path escapes the vault root: ${JSON.stringify(ledgerRelPath)}`);
|
|
326
|
+
}
|
|
327
|
+
if (path_1.default.isAbsolute(rel)) {
|
|
328
|
+
// The ledger is addressed RELATIVE to the vault; an absolute path is a
|
|
329
|
+
// configuration error (previously it silently double-joined through
|
|
330
|
+
// path.join) and is rejected fail-closed even if it points inside the vault.
|
|
331
|
+
throw new Error(`absolute ledger path is not vault-relative: ${JSON.stringify(ledgerRelPath)}`);
|
|
332
|
+
}
|
|
333
|
+
// S2.1: check the ORIGINAL request before any '.md' suffix mangling — the
|
|
334
|
+
// vault root itself ('.'/empty resolution) and any REAL parent component
|
|
335
|
+
// ('..' exactly, or '..' + separator) fail closed here, so '..' can never
|
|
336
|
+
// be laundered into a literal '..md' name by the suffix rule.
|
|
337
|
+
const originalRelFromRoot = path_1.default.relative(root, path_1.default.resolve(root, rel));
|
|
338
|
+
const originalEscapes = originalRelFromRoot === ''
|
|
339
|
+
|| originalRelFromRoot === '..'
|
|
340
|
+
|| originalRelFromRoot.startsWith('..' + path_1.default.sep)
|
|
341
|
+
|| path_1.default.isAbsolute(originalRelFromRoot);
|
|
342
|
+
if (originalEscapes) {
|
|
343
|
+
throw new Error(`ledger path escapes the vault root: ${JSON.stringify(ledgerRelPath)}`);
|
|
344
|
+
}
|
|
345
|
+
if (!rel.endsWith('.md'))
|
|
346
|
+
rel += '.md';
|
|
347
|
+
const resolved = path_1.default.resolve(root, rel);
|
|
348
|
+
const relFromRoot = path_1.default.relative(root, resolved);
|
|
349
|
+
const escapes = relFromRoot === ''
|
|
350
|
+
|| relFromRoot === '..'
|
|
351
|
+
|| relFromRoot.startsWith('..' + path_1.default.sep)
|
|
352
|
+
|| path_1.default.isAbsolute(relFromRoot);
|
|
353
|
+
if (escapes) {
|
|
354
|
+
throw new Error(`ledger path escapes the vault root: ${JSON.stringify(ledgerRelPath)}`);
|
|
355
|
+
}
|
|
356
|
+
return resolved;
|
|
357
|
+
}
|
|
358
|
+
async function appendLedgerEvent(vaultPath, ledgerRelPath, ev) {
|
|
359
|
+
validateLedgerEvent(ev);
|
|
360
|
+
const resolved = resolveLedgerPath(vaultPath, ledgerRelPath);
|
|
361
|
+
const lines = [
|
|
362
|
+
`\n## ${ev.id} · ${ev.at}`,
|
|
363
|
+
`- kind: ${ev.kind}`,
|
|
364
|
+
`- subject: ${ev.subject}`,
|
|
365
|
+
`- predicate: ${ev.predicate}`,
|
|
366
|
+
];
|
|
367
|
+
if (ev.target !== undefined)
|
|
368
|
+
lines.push(`- target: ${ev.target}`);
|
|
369
|
+
else
|
|
370
|
+
lines.push(`- object: ${ev.object}`);
|
|
371
|
+
if (ev.closedEvent)
|
|
372
|
+
lines.push(`- closed_event: ${ev.closedEvent}`);
|
|
373
|
+
const scope = (0, scope_1.normalizeScope)(ev.scope);
|
|
374
|
+
if (scope !== 'global')
|
|
375
|
+
lines.push(`- scope: ${scope}`);
|
|
376
|
+
if (ev.statement !== undefined)
|
|
377
|
+
lines.push(`- statement: ${encodeStatement(ev.statement)}`);
|
|
378
|
+
if (ev.writer !== undefined)
|
|
379
|
+
lines.push(`- writer: ${ev.writer}`);
|
|
380
|
+
lines.push(`- source: ${ev.source}`, ``);
|
|
381
|
+
// Confined and durable in one append (confinedWrite.ts): a link, a hard link or a FIFO planted in the vault is refused,
|
|
382
|
+
// never followed, and a failed write is cut back, so the ledger never keeps a fragment of an event whose save the caller
|
|
383
|
+
// was told failed. A new ledger starts with its header.
|
|
384
|
+
await (0, confinedWrite_1.appendConfined)(vaultPath, path_1.default.relative(path_1.default.resolve(vaultPath), resolved), lines.join('\n'), LEDGER_HEADER);
|
|
385
|
+
return resolved;
|
|
386
|
+
}
|
|
387
|
+
const LEDGER_HEADER = `---\ntype: memory-ledger\ntitle: Memory Events Ledger\n---\n\n# Memory Events Ledger\n\nAppend-only durable record of memory events. Replayed on rebuild.\n`;
|
|
388
|
+
/** A field line a complete event may carry (writer output): a known key, `- key: value`. */
|
|
389
|
+
function isLedgerFieldLine(line) {
|
|
390
|
+
const m = FIELD_RE.exec(line.replace(/\r$/, ''));
|
|
391
|
+
return m !== null && KNOWN_FIELDS.has(m[1]);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Where the FINAL ledger event starts when a write cut it short, or -1 when the ledger ends in whole events. The writer
|
|
395
|
+
* emits `- kind:` first and `- source:` last, so a final event that has a kind line but no terminated source line is
|
|
396
|
+
* unterminated: a crash or a full disk left it short, whether it was cut mid-line (no final newline) or after a line
|
|
397
|
+
* (missing its source). Such a block is well-formed up to the cut: its complete lines are a header and known fields, and
|
|
398
|
+
* its partial last line is a header or field prefix. Anything else is not reported here, so the strict parser refuses
|
|
399
|
+
* it. A final block with no kind line and a final newline (a legacy block replayed as store, or a malformed one) is left
|
|
400
|
+
* exactly as it was. Ordinary prose after a complete event never matches.
|
|
401
|
+
*/
|
|
402
|
+
function unterminatedFinalEventStart(text) {
|
|
403
|
+
const headers = [...text.matchAll(/^## mem_/gim)];
|
|
404
|
+
if (headers.length === 0)
|
|
405
|
+
return -1;
|
|
406
|
+
const start = headers[headers.length - 1].index;
|
|
407
|
+
const block = text.slice(start);
|
|
408
|
+
if (/^- source: [^\n]*\n/m.test(block))
|
|
409
|
+
return -1;
|
|
410
|
+
const lines = block.split('\n');
|
|
411
|
+
const partial = lines.pop(); // what follows the last newline: '' when the event ends in one
|
|
412
|
+
const hasKind = lines.some(line => /^- kind: /.test(line.replace(/\r$/, '')));
|
|
413
|
+
// A header with no field line after it is a cut too: the writer writes kind on the line after the header.
|
|
414
|
+
if (partial === '' && !hasKind && lines.length > 1)
|
|
415
|
+
return -1;
|
|
416
|
+
if (lines.length > 0) {
|
|
417
|
+
if (!EVENT_HEADER_RE.test(lines[0].replace(/\r$/, '')))
|
|
418
|
+
return -1;
|
|
419
|
+
if (!lines.slice(1).every(isLedgerFieldLine))
|
|
420
|
+
return -1;
|
|
421
|
+
if (partial !== '' && partial !== '-' && !partial.startsWith('- '))
|
|
422
|
+
return -1;
|
|
423
|
+
}
|
|
424
|
+
return start;
|
|
425
|
+
}
|
|
426
|
+
function finalEventState(bytes, isProjected) {
|
|
427
|
+
const valid = utf8PrefixLength(bytes);
|
|
428
|
+
const text = bytes.subarray(0, valid).toString('utf8');
|
|
429
|
+
if (!Buffer.from(text, 'utf8').equals(bytes.subarray(0, valid)))
|
|
430
|
+
return { kind: 'none' };
|
|
431
|
+
const start = unterminatedFinalEventStart(text);
|
|
432
|
+
if (start < 0)
|
|
433
|
+
return { kind: 'none' };
|
|
434
|
+
const id = /^## (\S+)/.exec(text.slice(start))?.[1];
|
|
435
|
+
if (id !== undefined && isProjected(id.toUpperCase()))
|
|
436
|
+
return { kind: 'known' };
|
|
437
|
+
return { kind: 'torn', offset: Buffer.byteLength(text.slice(0, start), 'utf8') };
|
|
438
|
+
}
|
|
439
|
+
/** How many leading bytes end on a complete UTF-8 character: a multi-byte character cut at the end is not counted. */
|
|
440
|
+
function utf8PrefixLength(bytes) {
|
|
441
|
+
for (let back = 1; back <= Math.min(3, bytes.length); back++) {
|
|
442
|
+
const b = bytes[bytes.length - back];
|
|
443
|
+
if ((b & 0xc0) === 0x80)
|
|
444
|
+
continue; // a continuation byte: its lead byte is further back
|
|
445
|
+
const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
|
|
446
|
+
return need > back ? bytes.length - back : bytes.length;
|
|
447
|
+
}
|
|
448
|
+
return bytes.length;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Moves an unterminated final event out of the ledger: its exact bytes go to `<ledger>.torn-<timestamp>` (created and synced
|
|
452
|
+
* by createConfined), then the ledger is cut back to the last whole event (synced). Only that tail moves; every complete
|
|
453
|
+
* event stays where it is. Both files are reached through confined handles (confinedWrite.ts), so a link planted at the
|
|
454
|
+
* ledger, a hard link to it or a link on its directory is refused before anything is copied or cut. Refuses when the ledger
|
|
455
|
+
* changed since it was read.
|
|
456
|
+
*/
|
|
457
|
+
async function moveTornTailAside(vaultPath, relFile, ledger, bytes, offset) {
|
|
458
|
+
const tornBytes = bytes.subarray(offset);
|
|
459
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
460
|
+
let tornPath = '';
|
|
461
|
+
for (let n = 0;; n++) {
|
|
462
|
+
try {
|
|
463
|
+
tornPath = await (0, confinedWrite_1.createConfined)(vaultPath, `${relFile}.torn-${stamp}${n ? `-${n}` : ''}`, tornBytes);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
if (err.code !== 'EEXIST' || n >= 99)
|
|
468
|
+
throw err;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// The cut is only right for the bytes it was decided on: a writer that appended since would lose its event.
|
|
472
|
+
const now = (await ledger.stat()).size;
|
|
473
|
+
if (now !== bytes.length) {
|
|
474
|
+
throw new Error(`ledger ${path_1.default.resolve(vaultPath, relFile)} changed while its unterminated tail was being set aside (${bytes.length} -> ${now} bytes); not cut. Restart the engine.`);
|
|
475
|
+
}
|
|
476
|
+
await ledger.truncate(offset);
|
|
477
|
+
await ledger.sync();
|
|
478
|
+
console.error(`[memory] ledger ${path_1.default.resolve(vaultPath, relFile)}: its final event was cut short (a crash or a full disk); moved its ${tornBytes.length} byte(s) to ${tornPath} and kept every complete event. If that file holds a hand-written event that was never replayed, restore it by adding its \`- source:\` line and a final newline (if it has none), appending its bytes to the ledger, and restarting; a block still without that line is set aside again.`);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* The ledger's text for replay, and whether its unterminated final event (if any) is one the index already holds. An
|
|
482
|
+
* unterminated final event is set aside only when `setTornTailAside` is set (engine start-up): a live process that is still
|
|
483
|
+
* appending (an import, a rebuild in another process) must never have its ledger cut from under it, so every other caller
|
|
484
|
+
* refuses and leaves the bytes alone. The decision is taken again under the append lock and through a confined handle, because
|
|
485
|
+
* an append may have finished since the first read, and a link planted at the ledger must be refused, not read or cut.
|
|
486
|
+
*/
|
|
487
|
+
async function readLedgerForReplay(vaultPath, filePath, isProjected, setTornTailAside) {
|
|
488
|
+
const bytes = await promises_1.default.readFile(filePath);
|
|
489
|
+
const first = finalEventState(bytes, isProjected);
|
|
490
|
+
if (first.kind !== 'torn' || !setTornTailAside) {
|
|
491
|
+
return { content: bytes.toString('utf8'), finalEventKnown: first.kind === 'known' };
|
|
492
|
+
}
|
|
493
|
+
const relFile = path_1.default.relative(path_1.default.resolve(vaultPath), filePath);
|
|
494
|
+
return (0, durableAppend_1.withAppendLock)(filePath, () => (0, confinedWrite_1.withConfinedFile)(vaultPath, relFile, async (ledger) => {
|
|
495
|
+
const again = await ledger.readFile();
|
|
496
|
+
const now = finalEventState(again, isProjected);
|
|
497
|
+
if (now.kind !== 'torn')
|
|
498
|
+
return { content: again.toString('utf8'), finalEventKnown: now.kind === 'known' };
|
|
499
|
+
await moveTornTailAside(vaultPath, relFile, ledger, again, now.offset);
|
|
500
|
+
return { content: again.subarray(0, now.offset).toString('utf8'), finalEventKnown: false };
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Strict line-oriented parser for the ledger format. Returns the events in
|
|
505
|
+
* append order. Throws (never silently skips) on event-shaped corruption:
|
|
506
|
+
* malformed headers, malformed/unknown/duplicate fields, missing required
|
|
507
|
+
* fields, unknown kinds, invalid timestamps. Ordinary Markdown prose (the
|
|
508
|
+
* frontmatter header, human sections) outside event blocks is tolerated.
|
|
509
|
+
*/
|
|
510
|
+
function parseLedgerEvents(content, options = {}) {
|
|
511
|
+
// A cut final event is refused, never read as whole: its last field (the object of a fact) may be a prefix of the value.
|
|
512
|
+
// Engine start-up sets such a tail aside before it parses (readLedgerForReplay); any other caller refuses.
|
|
513
|
+
if (options.finalEventKnown !== true && unterminatedFinalEventStart(content) >= 0) {
|
|
514
|
+
throw new Error('ledger corruption: its final event is unterminated (a write was cut short by a crash or a full disk); the next engine start sets that tail aside');
|
|
515
|
+
}
|
|
516
|
+
const events = [];
|
|
517
|
+
// Line-ending tolerance: a hand-edited CRLF ledger is valid content with a
|
|
518
|
+
// Windows editor's line endings, not corruption. Normalize \r\n to \n BEFORE
|
|
519
|
+
// splitting so field values never carry a stray trailing \r. Lone \r inside
|
|
520
|
+
// a line remains a control character and still fails loudly.
|
|
521
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
522
|
+
let i = 0;
|
|
523
|
+
while (i < lines.length) {
|
|
524
|
+
const line = lines[i];
|
|
525
|
+
if (!line.startsWith('## ')) {
|
|
526
|
+
i++;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const m = EVENT_HEADER_RE.exec(line);
|
|
530
|
+
if (!m) {
|
|
531
|
+
if (/^## mem_/i.test(line)) {
|
|
532
|
+
throw new Error(`ledger corruption at line ${i + 1}: malformed event header: ${JSON.stringify(line)}`);
|
|
533
|
+
}
|
|
534
|
+
i++;
|
|
535
|
+
continue; // ordinary Markdown heading — not a lifecycle event
|
|
536
|
+
}
|
|
537
|
+
const id = m[1].toUpperCase();
|
|
538
|
+
const at = m[2].trim();
|
|
539
|
+
if (!Number.isFinite(Date.parse(at))) {
|
|
540
|
+
throw new Error(`ledger corruption in event ${id} (line ${i + 1}): invalid timestamp ${JSON.stringify(at)}`);
|
|
541
|
+
}
|
|
542
|
+
i++;
|
|
543
|
+
const fields = {};
|
|
544
|
+
while (i < lines.length && lines[i].startsWith('- ')) {
|
|
545
|
+
const fm = FIELD_RE.exec(lines[i]);
|
|
546
|
+
if (!fm)
|
|
547
|
+
throw new Error(`ledger corruption in event ${id} at line ${i + 1}: malformed field line ${JSON.stringify(lines[i])}`);
|
|
548
|
+
const key = fm[1];
|
|
549
|
+
if (!KNOWN_FIELDS.has(key))
|
|
550
|
+
throw new Error(`ledger corruption in event ${id} at line ${i + 1}: unknown field "${key}"`);
|
|
551
|
+
if (Object.prototype.hasOwnProperty.call(fields, key))
|
|
552
|
+
throw new Error(`ledger corruption in event ${id} at line ${i + 1}: duplicate field "${key}"`);
|
|
553
|
+
fields[key] = fm[2];
|
|
554
|
+
i++;
|
|
555
|
+
}
|
|
556
|
+
const kind = fields.kind ?? 'store';
|
|
557
|
+
for (const req of REQUIRED_FIELDS) {
|
|
558
|
+
if (req === 'object' && RETIREMENT_KINDS.has(kind))
|
|
559
|
+
continue; // a retirement carries no value
|
|
560
|
+
if (fields[req] === undefined || fields[req] === '') {
|
|
561
|
+
throw new Error(`ledger corruption in event ${id}: missing required field "${req}"`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const ev = {
|
|
565
|
+
id, at,
|
|
566
|
+
...(fields.statement !== undefined ? { statement: decodeStatement(fields.statement, id) } : {}),
|
|
567
|
+
...(fields.target !== undefined ? { target: fields.target } : {}),
|
|
568
|
+
kind,
|
|
569
|
+
subject: fields.subject,
|
|
570
|
+
predicate: fields.predicate,
|
|
571
|
+
object: fields.object ?? '',
|
|
572
|
+
closedEvent: fields.closed_event ?? null,
|
|
573
|
+
source: fields.source ?? 'ledger',
|
|
574
|
+
scope: fields.scope ? (0, scope_1.normalizeScope)(fields.scope) : 'global',
|
|
575
|
+
...(fields.writer !== undefined ? { writer: fields.writer } : {}),
|
|
576
|
+
};
|
|
577
|
+
validateLedgerEvent(ev); // bounds, kind, id/timestamp/closed_event shape
|
|
578
|
+
events.push(ev);
|
|
579
|
+
}
|
|
580
|
+
return events;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* A subject whose identity key matches SEVERAL lifecycle entities and whose open
|
|
584
|
+
* current facts disagree. Two spellings of one person (`José` and `JOSÉ`, or an NFC
|
|
585
|
+
* and an NFD form) could be split by a pre-fix write path, and there is no unique
|
|
586
|
+
* index on `entities` and no merge path, so nothing at the DB level forbids it.
|
|
587
|
+
*
|
|
588
|
+
* On the live write path there is no canonical order to consult (row ids are
|
|
589
|
+
* disposable cache artifacts, never consulted), so picking one entity would silently
|
|
590
|
+
* decide which of two conflicting values is current. Fail closed instead.
|
|
591
|
+
*
|
|
592
|
+
* Recovery is documented, not guessed: replaying the canonical ledger in order into a
|
|
593
|
+
* fresh index reconciles duplicate identities by event position (the later event
|
|
594
|
+
* wins), which is exactly the fix's behavior on a rebuild. Stored rows and histories
|
|
595
|
+
* are never deleted or rewritten by this error.
|
|
596
|
+
*/
|
|
597
|
+
class LegacyIdentityAmbiguityError extends Error {
|
|
598
|
+
subject;
|
|
599
|
+
entityIds;
|
|
600
|
+
canonicalNames;
|
|
601
|
+
streams;
|
|
602
|
+
constructor(args) {
|
|
603
|
+
super(`legacy identity ambiguity for subject ${JSON.stringify(args.subject)}: `
|
|
604
|
+
+ `${args.entities.length} lifecycle entities share one identity `
|
|
605
|
+
+ `(${args.entities.map(e => `#${e.id} ${JSON.stringify(e.canonicalName)}`).join(', ')}) `
|
|
606
|
+
+ `and their current facts disagree on ${args.streams.join(', ')}. `
|
|
607
|
+
+ 'Refusing to guess which value is current; nothing was written. '
|
|
608
|
+
+ 'Recovery: rebuild the index from the canonical ledger (an in-order replay reconciles the duplicate identities by event position).');
|
|
609
|
+
this.name = 'LegacyIdentityAmbiguityError';
|
|
610
|
+
this.subject = args.subject;
|
|
611
|
+
this.entityIds = args.entities.map(e => e.id);
|
|
612
|
+
this.canonicalNames = args.entities.map(e => e.canonicalName);
|
|
613
|
+
this.streams = args.streams;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
exports.LegacyIdentityAmbiguityError = LegacyIdentityAmbiguityError;
|
|
617
|
+
/** One replay-local scan; buckets retain the legacy lowest-row-id ordering. */
|
|
618
|
+
function replayEntityIndex(db) {
|
|
619
|
+
const index = new Map();
|
|
620
|
+
const rows = (0, factTerms_1.prepareOnce)(db, `SELECT id, canonical_name FROM entities WHERE source_note = ? ORDER BY id`).all(exports.LIFECYCLE_NOTE_PATH);
|
|
621
|
+
for (const row of rows)
|
|
622
|
+
addEntityToIndex(index, row);
|
|
623
|
+
return index;
|
|
624
|
+
}
|
|
625
|
+
function addEntityToIndex(index, row) {
|
|
626
|
+
const key = (0, queryHelpers_1.identityKey)(row.canonical_name);
|
|
627
|
+
const matches = index.get(key) ?? [];
|
|
628
|
+
matches.push({ id: row.id, canonicalName: row.canonical_name });
|
|
629
|
+
index.set(key, matches);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Per database, for the reads and writes outside a replay: the same index, with how many lifecycle entities there
|
|
633
|
+
* were and the newest one's id when it was read. Lifecycle entity rows are only ever added, under ids that only grow
|
|
634
|
+
* (AUTOINCREMENT), and never renamed, so those two numbers say whether the table still holds what the index was read
|
|
635
|
+
* from: rows added since are read and added to it (by id, so each bucket keeps its lowest-id-first order), and any
|
|
636
|
+
* other change (a row gone) reads the table again. Only committed rows are read into it, so inside a transaction it
|
|
637
|
+
* is used only while it is current, and the table is scanned instead, as before.
|
|
638
|
+
*/
|
|
639
|
+
const liveEntityIndexes = new WeakMap();
|
|
640
|
+
function liveEntityIndex(db) {
|
|
641
|
+
const { count, newest } = (0, factTerms_1.prepareOnce)(db, `SELECT COUNT(*) AS count, COALESCE(MAX(id), 0) AS newest FROM entities WHERE source_note = ?`).get(exports.LIFECYCLE_NOTE_PATH);
|
|
642
|
+
const cached = liveEntityIndexes.get(db);
|
|
643
|
+
if (cached && cached.count === count && cached.newest === newest)
|
|
644
|
+
return cached.index;
|
|
645
|
+
if (db.inTransaction)
|
|
646
|
+
return null;
|
|
647
|
+
if (cached && count > cached.count && newest > cached.newest) {
|
|
648
|
+
const added = (0, factTerms_1.prepareOnce)(db, `SELECT id, canonical_name FROM entities WHERE source_note = ? AND id > ? ORDER BY id`).all(exports.LIFECYCLE_NOTE_PATH, cached.newest);
|
|
649
|
+
if (cached.count + added.length === count) {
|
|
650
|
+
for (const row of added)
|
|
651
|
+
addEntityToIndex(cached.index, row);
|
|
652
|
+
cached.count = count;
|
|
653
|
+
cached.newest = newest;
|
|
654
|
+
return cached.index;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const index = replayEntityIndex(db);
|
|
658
|
+
liveEntityIndexes.set(db, { count, newest, index });
|
|
659
|
+
return index;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Lifecycle-owned entities whose identity key matches `subject`, ordered by row id.
|
|
663
|
+
*
|
|
664
|
+
* `identityKey` is the single subject-identity primitive (NFC + case fold, explicit
|
|
665
|
+
* aliases unified), so the lifecycle no longer needs SQL `COLLATE NOCASE` — which
|
|
666
|
+
* folds ASCII A–Z only and therefore split `José`/`JOSÉ`, `Пётр`/`пётр` and every
|
|
667
|
+
* NFC/NFD pair into two entities with two open currents. The comparison happens in JS
|
|
668
|
+
* BEFORE the query result is used, so a normalization form can never leak into the
|
|
669
|
+
* equality test. The fold is not expressible in SQLite without ICU, so the
|
|
670
|
+
* matches come from an index keyed by identity: replay builds one for itself, and
|
|
671
|
+
* live reads and writes keep one per database (liveEntityIndex), instead of folding
|
|
672
|
+
* every `_mcp_` entity's name on every save.
|
|
673
|
+
*/
|
|
674
|
+
function lifecycleEntityMatches(db, subject, index) {
|
|
675
|
+
const key = (0, queryHelpers_1.identityKey)(subject);
|
|
676
|
+
if (index)
|
|
677
|
+
return index.get(key) ?? [];
|
|
678
|
+
const live = liveEntityIndex(db);
|
|
679
|
+
if (live)
|
|
680
|
+
return live.get(key) ?? [];
|
|
681
|
+
const rows = (0, factTerms_1.prepareOnce)(db, `SELECT id, canonical_name FROM entities WHERE source_note = ? ORDER BY id`).all(exports.LIFECYCLE_NOTE_PATH);
|
|
682
|
+
return rows
|
|
683
|
+
.filter(row => (0, queryHelpers_1.identityKey)(row.canonical_name) === key)
|
|
684
|
+
.map(row => ({ id: row.id, canonicalName: row.canonical_name }));
|
|
685
|
+
}
|
|
686
|
+
/** Streams (`predicate @ scope`) where the matched entities' open currents disagree. */
|
|
687
|
+
function conflictingLegacyStreams(db, entities) {
|
|
688
|
+
if (entities.length < 2)
|
|
689
|
+
return [];
|
|
690
|
+
const placeholders = entities.map(() => '?').join(',');
|
|
691
|
+
const rows = (0, factTerms_1.prepareOnce)(db, `SELECT predicate, COALESCE(scope, 'global') AS scope, object_text
|
|
692
|
+
FROM facts WHERE subject_entity_id IN (${placeholders}) AND valid_to IS NULL`).all(...entities.map(e => e.id));
|
|
693
|
+
const values = new Map();
|
|
694
|
+
for (const row of rows) {
|
|
695
|
+
const key = `${row.predicate}\u0000${row.scope}`;
|
|
696
|
+
const set = values.get(key) ?? new Set();
|
|
697
|
+
set.add(row.object_text);
|
|
698
|
+
values.set(key, set);
|
|
699
|
+
}
|
|
700
|
+
return [...values.entries()]
|
|
701
|
+
.filter(([, objects]) => objects.size > 1)
|
|
702
|
+
.map(([key]) => {
|
|
703
|
+
const [predicate, scope] = key.split('\u0000');
|
|
704
|
+
return `${predicate} @ ${scope}`;
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Resolve the lifecycle-owned entity for a subject (source_note '_mcp_').
|
|
709
|
+
*
|
|
710
|
+
* `preferEntityId` is used by the already-projected replay branch: the event's own
|
|
711
|
+
* projected owner is canonical for that event, so reconciliation targets it instead of
|
|
712
|
+
* re-deciding identity (which would either re-open the split or refuse a replay the
|
|
713
|
+
* service needs at startup). `strict` is used by the live write path, which fails
|
|
714
|
+
* closed on a conflicting legacy split before anything is published.
|
|
715
|
+
*/
|
|
716
|
+
function resolveLifecycleEntity(db, subject, at, create, opts = {}) {
|
|
717
|
+
const matches = lifecycleEntityMatches(db, subject, opts.index);
|
|
718
|
+
if (matches.length > 0) {
|
|
719
|
+
if (opts.preferEntityId !== undefined && matches.some(m => m.id === opts.preferEntityId)) {
|
|
720
|
+
return opts.preferEntityId;
|
|
721
|
+
}
|
|
722
|
+
if (matches.length === 1)
|
|
723
|
+
return matches[0].id;
|
|
724
|
+
if (opts.strict) {
|
|
725
|
+
const streams = conflictingLegacyStreams(db, matches);
|
|
726
|
+
if (streams.length > 0)
|
|
727
|
+
throw new LegacyIdentityAmbiguityError({ subject, entities: matches, streams });
|
|
728
|
+
}
|
|
729
|
+
// Several entities, no conflicting current value: deterministic lowest id, so a
|
|
730
|
+
// repeat write/replay never alternates between identities. Nothing is deleted.
|
|
731
|
+
return matches[0].id;
|
|
732
|
+
}
|
|
733
|
+
if (!create)
|
|
734
|
+
return null;
|
|
735
|
+
(0, factTerms_1.prepareOnce)(db, `INSERT OR IGNORE INTO notes (path, title, kind, note_hash, modified_at, frontmatter_json) VALUES (?, 'Memory Lifecycle', 'system', ?, ?, '{}')`).run(exports.LIFECYCLE_NOTE_PATH, exports.LIFECYCLE_NOTE_PATH, at);
|
|
736
|
+
const id = (0, factTerms_1.prepareOnce)(db, `INSERT INTO entities (type, canonical_name, aliases_json, source_note, confidence, updated_at) VALUES ('concept', ?, '[]', ?, 1.0, ?) RETURNING id`).get(subject, exports.LIFECYCLE_NOTE_PATH, at).id;
|
|
737
|
+
// New rows have increasing ids, and an empty bucket is the only creation
|
|
738
|
+
// path. On any failed event replay throws and discards this local index;
|
|
739
|
+
// rolled-back ids can never escape into a later replay/live write.
|
|
740
|
+
opts.index?.set((0, queryHelpers_1.identityKey)(subject), [{ id, canonicalName: subject }]);
|
|
741
|
+
return id;
|
|
742
|
+
}
|
|
743
|
+
function currentFact(db, entityId, predicate, scope = 'global') {
|
|
744
|
+
return (0, factTerms_1.prepareOnce)(db, `SELECT id, object_text, event_id, valid_from, scope, author FROM facts
|
|
745
|
+
WHERE subject_entity_id = ? AND predicate = ? AND scope = ? AND valid_to IS NULL
|
|
746
|
+
ORDER BY valid_from DESC, id DESC LIMIT 1`).get(entityId, predicate, scope);
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* One lifecycle stream per scope: a named scope is its slug (scopeSpellings), so the stream of (entity, predicate) in
|
|
750
|
+
* `scope` is its facts under every spelling of the slug, and a write closes the stream's current value whatever
|
|
751
|
+
* spelling either was saved with. These are that stream's current facts, newest first: one, or two when values were
|
|
752
|
+
* saved under two spellings before writes closed across them (each spelling holds at most one, idx_facts_current).
|
|
753
|
+
*/
|
|
754
|
+
function streamCurrentFacts(db, entityId, predicate, scope) {
|
|
755
|
+
return (0, scope_1.scopeSpellings)(scope)
|
|
756
|
+
.map(spelling => currentFact(db, entityId, predicate, spelling))
|
|
757
|
+
.filter((row) => row !== undefined)
|
|
758
|
+
.sort((a, b) => (a.valid_from === b.valid_from ? b.id - a.id : a.valid_from < b.valid_from ? 1 : -1));
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* The fact an event closes under the other spelling of its slug: the one its closed_event names, when that is the
|
|
762
|
+
* current value of the same entity and predicate saved under another spelling of the event's scope. A write closes
|
|
763
|
+
* across spellings only by naming the fact it closes, and before it did, closed_event only ever named the current
|
|
764
|
+
* value under the event's own spelling, so a ledger written then replays exactly as it did: values saved under two
|
|
765
|
+
* spellings stay current, and a read names them as a conflict until a write closes them.
|
|
766
|
+
*/
|
|
767
|
+
function currentFactClosedAcrossSpellings(db, ev, entityId, scope) {
|
|
768
|
+
if (!ev.closedEvent)
|
|
769
|
+
return undefined;
|
|
770
|
+
const named = (0, factTerms_1.prepareOnce)(db, `SELECT id, object_text, event_id, valid_from, scope, author FROM facts
|
|
771
|
+
WHERE event_id = ? AND subject_entity_id = ? AND predicate = ? AND valid_to IS NULL`).get(ev.closedEvent.toUpperCase(), entityId, ev.predicate);
|
|
772
|
+
return named && named.scope !== scope && (0, scope_1.savedScopeMatches)(named.scope, scope) ? named : undefined;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Re-home an already-projected legacy canonical fact from a disposable
|
|
776
|
+
* note-derived entity onto the stable lifecycle-owned entity (Astra A2 /
|
|
777
|
+
* Kimi R2, Kimi K9). The row keeps its event_id, timestamps and history —
|
|
778
|
+
* only ownership moves, and nothing is dropped or duplicated.
|
|
779
|
+
*
|
|
780
|
+
* Constraint-safe ordering (K9): idx_facts_current is a PARTIAL unique index
|
|
781
|
+
* and fires per STATEMENT. The naive move-then-close sequence made the moved
|
|
782
|
+
* row a second open current for one statement and threw UNIQUE before the
|
|
783
|
+
* reconciliation close could run, bricking upgrade replay on mixed-owner
|
|
784
|
+
* states. Instead, the winner is decided BEFORE any move and the statements
|
|
785
|
+
* are ordered so no intermediate state ever has two open currents:
|
|
786
|
+
*
|
|
787
|
+
* - no competing lifecycle current, or the moving row is historical
|
|
788
|
+
* -> plain move (never enters the partial index scope as a second open row);
|
|
789
|
+
* - the existing lifecycle current is the canonical winner -> the moving
|
|
790
|
+
* row is closed AND moved in ONE statement (valid_to set in the same
|
|
791
|
+
* UPDATE, so the index never sees it as an open duplicate);
|
|
792
|
+
* - the moving row is the canonical winner -> competing lifecycle currents
|
|
793
|
+
* are closed first, then the row moves in as the only open current.
|
|
794
|
+
*
|
|
795
|
+
* Winner rule (K12): the CANONICAL LEDGER position of each row's event decides
|
|
796
|
+
* — the event that appears later in the ledger wins, exactly matching what a
|
|
797
|
+
* fresh in-order replay would produce. SQLite row ids are disposable cache
|
|
798
|
+
* artifacts (insertion order of a legacy projection) and are NEVER consulted.
|
|
799
|
+
* A competing open row with an event_id that is ABSENT from the ledger is an
|
|
800
|
+
* index-vs-ledger contradiction: it fails explicitly rather than being ordered
|
|
801
|
+
* by guesswork. A NULL-event row (base vault_store_fact wrote those, bypassing
|
|
802
|
+
* the ledger) is non-canonical by construction and never wins against a
|
|
803
|
+
* ledger event. Residual interval flags are canonicalized afterwards by
|
|
804
|
+
* reconcileStreamCurrent, so this function only has to make the MOVE safe.
|
|
805
|
+
*/
|
|
806
|
+
function rehomeLifecycleFact(db, ev, factId, positions, lifecycleId) {
|
|
807
|
+
const tx = db.transaction(() => {
|
|
808
|
+
const moving = (0, factTerms_1.prepareOnce)(db, 'SELECT id, valid_from, valid_to FROM facts WHERE id = ?').get(factId);
|
|
809
|
+
const existing = currentFact(db, lifecycleId, ev.predicate, (0, scope_1.normalizeScope)(ev.scope));
|
|
810
|
+
if (existing && moving.valid_to === null) {
|
|
811
|
+
const movingPos = requireLedgerPosition(positions, ev.id);
|
|
812
|
+
let movingWins;
|
|
813
|
+
if (existing.event_id == null) {
|
|
814
|
+
// Non-event-backed row: never canonical against a ledger event.
|
|
815
|
+
movingWins = true;
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
const existingPos = requireLedgerPosition(positions, existing.event_id);
|
|
819
|
+
movingWins = movingPos > existingPos; // later canonical event wins (row ids never consulted)
|
|
820
|
+
}
|
|
821
|
+
if (movingWins) {
|
|
822
|
+
// Close the competing lifecycle current(s) BEFORE the move, so the
|
|
823
|
+
// moving row is never a second open current. The valid_to value here
|
|
824
|
+
// is provisional; reconcileStreamCurrent canonicalizes it afterwards.
|
|
825
|
+
(0, factTerms_1.prepareOnce)(db, `UPDATE facts SET valid_to = ?, updated_at = ? WHERE subject_entity_id = ? AND predicate = ? AND scope = ? AND valid_to IS NULL AND id != ?`).run(moving.valid_from, moving.valid_from, lifecycleId, ev.predicate, (0, scope_1.normalizeScope)(ev.scope), moving.id);
|
|
826
|
+
(0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET subject_entity_id = ? WHERE id = ?').run(lifecycleId, factId);
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
// The existing lifecycle current wins canonically: close AND move the
|
|
830
|
+
// row in one statement. With valid_to set in the same UPDATE, the
|
|
831
|
+
// partial index (valid_to IS NULL) never sees the moved row as open.
|
|
832
|
+
(0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET subject_entity_id = ?, valid_to = ?, updated_at = ? WHERE id = ?')
|
|
833
|
+
.run(lifecycleId, existing.valid_from, existing.valid_from, factId);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
// No competing open current, or the moving row is historical: a plain
|
|
838
|
+
// move cannot violate the partial index.
|
|
839
|
+
(0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET subject_entity_id = ? WHERE id = ?').run(lifecycleId, factId);
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
tx();
|
|
843
|
+
}
|
|
844
|
+
/** Later of two timestamps by instant comparison (raw strings preserved). */
|
|
845
|
+
function maxInstant(a, b) {
|
|
846
|
+
return Date.parse(a) >= Date.parse(b) ? a : b;
|
|
847
|
+
}
|
|
848
|
+
/** Canonical position of a ledger event, or an explicit refusal to guess. */
|
|
849
|
+
function requireLedgerPosition(positions, eventId) {
|
|
850
|
+
const pos = positions.get(eventId);
|
|
851
|
+
if (pos === undefined) {
|
|
852
|
+
throw new Error(`index holds a lifecycle fact for event ${eventId} which is absent from the ledger; refusing to guess canonical order — restore the ledger or deliberately rebuild the derived index`);
|
|
853
|
+
}
|
|
854
|
+
return pos;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Canonical stream reconciliation (K12): recompute the current/valid_to chain
|
|
858
|
+
* for every event-backed lifecycle fact of (lifecycle entity, predicate) from
|
|
859
|
+
* the CANONICAL ledger positions, exactly mirroring what a fresh in-order
|
|
860
|
+
* replay produces — the event that appears latest in the ledger is the
|
|
861
|
+
* current fact; each earlier event's row is closed at its canonical
|
|
862
|
+
* successor's valid_from (never before its own valid_from, so no negative
|
|
863
|
+
* intervals). Stale winner flags left behind by earlier candidate reconciliations
|
|
864
|
+
* (including migration 6's interim duplicate repair, which cannot read the
|
|
865
|
+
* ledger) are repaired here, before readiness. Non-event-backed open rows are
|
|
866
|
+
* non-canonical (base vault_store_fact bypassed the ledger): they are closed
|
|
867
|
+
* at the start of the canonical record and KEPT in history — never dropped.
|
|
868
|
+
* Event-backed rows whose event is absent from the ledger are an
|
|
869
|
+
* index-vs-ledger contradiction and fail explicitly. Unrelated (non '_mcp_')
|
|
870
|
+
* facts are not touched. Idempotent: re-running produces no further writes.
|
|
871
|
+
*
|
|
872
|
+
* The stream is the scope's slug (streamCurrentFacts): the rows of every
|
|
873
|
+
* spelling are replayed together, in ledger order, as applyEvent applies them.
|
|
874
|
+
* Each event closes the current row of its own spelling and the row its
|
|
875
|
+
* closed_event names under another spelling (currentFactClosedAcrossSpellings;
|
|
876
|
+
* `closedBy` holds each event's closed_event). Without such a link the
|
|
877
|
+
* spellings never touch, so each spelling's chain is exactly the per-spelling
|
|
878
|
+
* chain this function computed before scopes were one slug.
|
|
879
|
+
*/
|
|
880
|
+
function reconcileStreamCurrent(db, lifecycleId, predicate, positions, scope = 'global', closedBy = new Map()) {
|
|
881
|
+
(0, statements_1.transactionOnce)(db, reconcileStreamCurrentBody)(lifecycleId, predicate, positions, scope, closedBy);
|
|
882
|
+
}
|
|
883
|
+
/** The body of reconcileStreamCurrent's transaction: a module-level function so replay wraps it once per connection (transactionOnce). */
|
|
884
|
+
function reconcileStreamCurrentBody(db, lifecycleId, predicate, positions, scope, closedBy) {
|
|
885
|
+
const inStream = (0, scope_1.savedScopeSqlPredicate)(scope, 'scope');
|
|
886
|
+
// A retired fact keeps its place in the stream (retireFact): it is hidden, not undone, so the value it closed stays
|
|
887
|
+
// closed and the value saved after it closes it, exactly as if it had never been retired.
|
|
888
|
+
const rows = (0, factTerms_1.prepareOnce)(db, `SELECT id, event_id, valid_from, valid_to, scope, 0 AS retired FROM facts
|
|
889
|
+
WHERE subject_entity_id = ? AND predicate = ? AND ${inStream.sql} AND source_path = '_mcp_'
|
|
890
|
+
UNION ALL
|
|
891
|
+
SELECT id, event_id, valid_from, valid_to, scope, 1 AS retired FROM retired_facts
|
|
892
|
+
WHERE subject_entity_id = ? AND predicate = ? AND ${inStream.sql}`).all(lifecycleId, predicate, ...inStream.params, lifecycleId, predicate, ...inStream.params);
|
|
893
|
+
const table = (row) => (row.retired ? 'retired_facts' : 'facts');
|
|
894
|
+
const canonical = [];
|
|
895
|
+
for (const r of rows) {
|
|
896
|
+
if (r.event_id == null)
|
|
897
|
+
continue;
|
|
898
|
+
canonical.push({ ...r, event_id: r.event_id, pos: requireLedgerPosition(positions, r.event_id) });
|
|
899
|
+
}
|
|
900
|
+
canonical.sort((a, b) => a.pos - b.pos);
|
|
901
|
+
// The replay, in ledger order: the current row of each spelling, and the valid_to each closed row gets.
|
|
902
|
+
// Full equivalence with a fresh replay (K12): the fresh-apply path rejects
|
|
903
|
+
// an event whose time precedes the current fact it would close; the same
|
|
904
|
+
// rule applies to an already-projected chain, so an invalid ledger is
|
|
905
|
+
// rejected by BOTH upgrade replay and fresh rebuild, never repaired into
|
|
906
|
+
// a state a fresh replay could not produce. (Equal timestamps are legal.)
|
|
907
|
+
const current = new Map();
|
|
908
|
+
const closeAt = new Map();
|
|
909
|
+
for (const row of canonical) {
|
|
910
|
+
const named = closedBy.get(row.event_id);
|
|
911
|
+
const across = named === undefined ? undefined
|
|
912
|
+
: [...current.values()].find(r => r.scope !== row.scope && r.event_id === named);
|
|
913
|
+
for (const closed of [current.get(row.scope), across]) {
|
|
914
|
+
if (!closed)
|
|
915
|
+
continue;
|
|
916
|
+
if (Date.parse(row.valid_from) < Date.parse(closed.valid_from)) {
|
|
917
|
+
throw new Error(`ledger event ${row.event_id} (${row.valid_from}) is older than the current fact for the same subject+predicate (valid_from ${closed.valid_from}, event ${closed.event_id}); refusing to apply out-of-order`);
|
|
918
|
+
}
|
|
919
|
+
closeAt.set(closed.id, maxInstant(row.valid_from, closed.valid_from));
|
|
920
|
+
current.delete(closed.scope);
|
|
921
|
+
}
|
|
922
|
+
current.set(row.scope, row);
|
|
923
|
+
}
|
|
924
|
+
// Pass 1 (constraint-safe): apply all NON-NULL valid_to targets first, so
|
|
925
|
+
// at most one open row per spelling remains before the NULL (current) updates.
|
|
926
|
+
for (const row of canonical) {
|
|
927
|
+
const want = closeAt.get(row.id);
|
|
928
|
+
if (want !== undefined && (row.valid_to ?? null) !== want) {
|
|
929
|
+
(0, factTerms_1.prepareOnce)(db, `UPDATE ${table(row)} SET valid_to = ?, updated_at = ? WHERE id = ?`).run(want, want, row.id);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
for (const spelling of (0, scope_1.scopeSpellings)(scope)) {
|
|
933
|
+
const chain = canonical.filter(row => row.scope === spelling);
|
|
934
|
+
if (chain.length === 0)
|
|
935
|
+
continue;
|
|
936
|
+
const last = current.get(spelling);
|
|
937
|
+
if (last && last.valid_to !== null) {
|
|
938
|
+
(0, factTerms_1.prepareOnce)(db, `UPDATE ${table(last)} SET valid_to = NULL, updated_at = ? WHERE id = ?`).run(last.valid_from, last.id);
|
|
939
|
+
}
|
|
940
|
+
// Non-event-backed open rows never stay current against canonical events.
|
|
941
|
+
const firstCanonicalFrom = chain[0].valid_from;
|
|
942
|
+
for (const r of rows) {
|
|
943
|
+
if (r.scope === spelling && r.event_id == null && r.valid_to === null) {
|
|
944
|
+
const want = maxInstant(firstCanonicalFrom, r.valid_from);
|
|
945
|
+
(0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET valid_to = ?, updated_at = ? WHERE id = ?').run(want, want, r.id);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* The transaction applyEvent runs for an event that is already projected: re-home a fact still attached to a disposable entity,
|
|
952
|
+
* then reconcile its stream. A module-level function so replay wraps it once per connection (transactionOnce).
|
|
953
|
+
*/
|
|
954
|
+
function reconcileProjectedEvent(db, ev, factId, rehome, positions, lifecycleId, closedBy) {
|
|
955
|
+
if (rehome) {
|
|
956
|
+
rehomeLifecycleFact(db, ev, factId, positions, lifecycleId);
|
|
957
|
+
}
|
|
958
|
+
// Do not preserve stale winner/valid_to flags merely because the event
|
|
959
|
+
// is already projected: recompute the chain from canonical ledger order
|
|
960
|
+
// so the upgraded cache is equivalent to a fresh replay of the ledger.
|
|
961
|
+
reconcileStreamCurrent(db, lifecycleId, ev.predicate, positions, (0, scope_1.normalizeScope)(ev.scope), closedBy);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Apply one memory event to SQLite inside a single transaction:
|
|
965
|
+
* dedup check → entity resolution → close current → insert fact → receipt.
|
|
966
|
+
* Idempotent by event id + digest; a reused id with a conflicting payload is
|
|
967
|
+
* explicit corruption; a failed insert can never leave a partial supersession.
|
|
968
|
+
*/
|
|
969
|
+
function applyEvent(db, ev, positions, index, closedBy) {
|
|
970
|
+
validateLedgerEvent(ev);
|
|
971
|
+
const digest = eventDigest(ev);
|
|
972
|
+
const seen = (0, factTerms_1.prepareOnce)(db, 'SELECT digest FROM applied_ledger_events WHERE event_id = ?').get(ev.id);
|
|
973
|
+
if (seen && seen.digest !== digest) {
|
|
974
|
+
throw new Error(`ledger event id ${ev.id} was reused with a conflicting payload; refusing to apply (corruption)`);
|
|
975
|
+
}
|
|
976
|
+
if (RETIREMENT_KINDS.has(ev.kind)) {
|
|
977
|
+
applyRetirement(db, ev, digest, seen !== undefined);
|
|
978
|
+
return { applied: seen === undefined, factId: null };
|
|
979
|
+
}
|
|
980
|
+
// A retired fact is projected too: its row waits in retired_facts.
|
|
981
|
+
const liveRow = (0, factTerms_1.prepareOnce)(db, 'SELECT id, subject_entity_id FROM facts WHERE event_id = ?').get(ev.id);
|
|
982
|
+
const factRow = liveRow
|
|
983
|
+
?? (0, factTerms_1.prepareOnce)(db, 'SELECT id, subject_entity_id FROM retired_facts WHERE event_id = ?').get(ev.id);
|
|
984
|
+
if (factRow) {
|
|
985
|
+
// Event already projected — but a pre-Gate1A index can hold this canonical
|
|
986
|
+
// fact attached to a DISPOSABLE note-derived entity (the base engine
|
|
987
|
+
// resolved subjects via generic lookupEntity). Re-home it onto the stable
|
|
988
|
+
// lifecycle-owned entity BEFORE readiness and later writes, or note
|
|
989
|
+
// reindexing would cascade-delete it (Astra A2 / Kimi R2 loss window).
|
|
990
|
+
// K12: reconciliation of the already-projected stream is CANONICAL — it
|
|
991
|
+
// needs the ledger event positions and refuses to guess without them
|
|
992
|
+
// (this branch is unreachable from the live write path, whose event ids
|
|
993
|
+
// are always fresh).
|
|
994
|
+
if (!positions) {
|
|
995
|
+
throw new Error(`canonical ledger order is unavailable while reconciling already-projected event ${ev.id}; refusing to guess — reconciliation requires replay context`);
|
|
996
|
+
}
|
|
997
|
+
const lifecycleId = resolveLifecycleEntity(db, ev.subject, ev.at, true, { preferEntityId: factRow.subject_entity_id, index });
|
|
998
|
+
const owner = (0, factTerms_1.prepareOnce)(db, 'SELECT source_note FROM entities WHERE id = ?').get(factRow.subject_entity_id);
|
|
999
|
+
// Re-home and stream reconciliation are ONE atomic unit (nested
|
|
1000
|
+
// transactions become savepoints): a rejected ledger (out-of-order chain,
|
|
1001
|
+
// absent-from-ledger contradiction) rolls the reconciliation back and
|
|
1002
|
+
// leaves the cache exactly as it was held — never partially reconciled.
|
|
1003
|
+
(0, statements_1.transactionOnce)(db, reconcileProjectedEvent)(ev, factRow.id, liveRow !== undefined && owner?.source_note !== exports.LIFECYCLE_NOTE_PATH, positions, lifecycleId, closedBy);
|
|
1004
|
+
// If it predates the digest table (migration 6), backfill the digest so
|
|
1005
|
+
// future replays can tell identical dedup from conflicts (the legacy
|
|
1006
|
+
// payload itself can no longer be verified).
|
|
1007
|
+
if (!seen) {
|
|
1008
|
+
(0, factTerms_1.prepareOnce)(db, 'INSERT OR IGNORE INTO applied_ledger_events (event_id, digest, applied_at) VALUES (?, ?, ?)')
|
|
1009
|
+
.run(ev.id, digest, new Date().toISOString());
|
|
1010
|
+
}
|
|
1011
|
+
return { applied: false, factId: null }; // byte-identical event already committed — safe dedup
|
|
1012
|
+
}
|
|
1013
|
+
// If a digest row exists but the fact is missing, the index lost the
|
|
1014
|
+
// projection (e.g. a partial wipe): re-apply — replay repairs the index.
|
|
1015
|
+
const tx = db.transaction(() => {
|
|
1016
|
+
// create=true always resolves or creates — never null here.
|
|
1017
|
+
const entityId = resolveLifecycleEntity(db, ev.subject, ev.at, true, { index });
|
|
1018
|
+
const closedFactIds = [];
|
|
1019
|
+
// The value this write replaces (the newest one it closes): the writer displacements it leaves open are read from it.
|
|
1020
|
+
let replaced = null;
|
|
1021
|
+
// The current fact under the event's own spelling, then the one it names under the other spelling of its slug.
|
|
1022
|
+
const scope = (0, scope_1.normalizeScope)(ev.scope);
|
|
1023
|
+
for (const cur of [currentFact(db, entityId, ev.predicate, scope), currentFactClosedAcrossSpellings(db, ev, entityId, scope)]) {
|
|
1024
|
+
if (!cur)
|
|
1025
|
+
continue;
|
|
1026
|
+
// Monotonicity validation: an event older than the current fact it would
|
|
1027
|
+
// close is arbitrary corrupt ordering — fail loudly, never silently
|
|
1028
|
+
// last-write-wins mask it. (Equal timestamps are legal: base-engine race
|
|
1029
|
+
// chains share millisecond timestamps.)
|
|
1030
|
+
if (ev.at < cur.valid_from) {
|
|
1031
|
+
throw new Error(`ledger event ${ev.id} (${ev.at}) is older than the current fact for the same subject+predicate (valid_from ${cur.valid_from}, fact id ${cur.id}); refusing to apply out-of-order`);
|
|
1032
|
+
}
|
|
1033
|
+
// K12 (replay context only): a current row whose event id is absent from
|
|
1034
|
+
// the ledger is an index-vs-ledger contradiction — closing it silently
|
|
1035
|
+
// would order canonical truth by guesswork. Fail explicitly instead.
|
|
1036
|
+
// (The live write path passes no positions and keeps its existing
|
|
1037
|
+
// behavior; the next replay of the ledger fails closed on the same row.)
|
|
1038
|
+
if (positions && cur.event_id != null && !positions.has(cur.event_id)) {
|
|
1039
|
+
requireLedgerPosition(positions, cur.event_id); // throws the explicit contradiction error
|
|
1040
|
+
}
|
|
1041
|
+
// Explicit reconciliation policy: 'supersede' closes the current fact by
|
|
1042
|
+
// intent. A 'store' finding an open current is NOT normal modern flow —
|
|
1043
|
+
// remember() writes supersede whenever it can see the current value —
|
|
1044
|
+
// so it can only arise from pre-Gate1A legacy chains: base-engine B2
|
|
1045
|
+
// races appended concurrent kind:store events, and the legacy cascade
|
|
1046
|
+
// loss window (Kimi R4) let a user-repair store be appended while the
|
|
1047
|
+
// prior projection was invisible. The prior fact is CLOSED (timestamped
|
|
1048
|
+
// with this event's time) and full history is preserved — nothing is
|
|
1049
|
+
// dropped or duplicated, and the digest/id conflict checks above still
|
|
1050
|
+
// fail loudly on contradictory reuse.
|
|
1051
|
+
(0, factTerms_1.prepareOnce)(db, `UPDATE facts SET valid_to = ?, updated_at = ? WHERE id = ?`).run(ev.at, ev.at, cur.id);
|
|
1052
|
+
closedFactIds.push(cur.id);
|
|
1053
|
+
if (replaced === null || cur.valid_from > replaced.validFrom) {
|
|
1054
|
+
replaced = { eventId: cur.event_id, object: cur.object_text, validFrom: cur.valid_from, source: cur.author };
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// A retired value that was current under this spelling is closed as if it had never been retired
|
|
1058
|
+
// (reconcileStreamCurrent replays it the same way), so restoring it later brings it back as history.
|
|
1059
|
+
// The stream is the scope's slug, not one spelling of it (scopeSpellings): a value retired under scope:alpha is
|
|
1060
|
+
// closed by a save under project:alpha too, or the retired one stays open and a later restore leaves two currents.
|
|
1061
|
+
for (const hidden of streamOpenRetiredFacts(db, entityId, ev.predicate, scope)) {
|
|
1062
|
+
if (ev.at < hidden.valid_from) {
|
|
1063
|
+
throw new Error(`ledger event ${ev.id} (${ev.at}) is older than the retired fact it would close (valid_from ${hidden.valid_from}, event ${hidden.event_id}); refusing to apply out-of-order`);
|
|
1064
|
+
}
|
|
1065
|
+
(0, factTerms_1.prepareOnce)(db, 'UPDATE retired_facts SET valid_to = ?, updated_at = ? WHERE id = ?').run(ev.at, ev.at, hidden.id);
|
|
1066
|
+
}
|
|
1067
|
+
const row = (0, factTerms_1.prepareOnce)(db, `INSERT INTO facts (subject_entity_id, predicate, object_text, source_path, confidence, valid_from, valid_to, updated_at, event_id, scope, author, statement_words)
|
|
1068
|
+
VALUES (?, ?, ?, '_mcp_', 1.0, ?, NULL, ?, ?, ?, ?, ?) RETURNING id`).get(entityId, ev.predicate, ev.object, ev.at, ev.at, ev.id, scope, ev.source, (0, factTerms_1.statementTerms)(ev.statement));
|
|
1069
|
+
// Who saved it, and the writer displacements it leaves open (writerConflicts.ts); a replay writes the same row.
|
|
1070
|
+
const writer = ev.writer ?? ev.source;
|
|
1071
|
+
(0, factTerms_1.prepareOnce)(db, 'INSERT OR REPLACE INTO fact_writers (event_id, writer, displaced) VALUES (?, ?, ?)')
|
|
1072
|
+
.run(ev.id, writer, JSON.stringify((0, writerConflicts_1.displacementsAfter)(db, replaced, { eventId: ev.id, writer, object: ev.object })));
|
|
1073
|
+
// Searchable by its words from the moment it exists: the index row is written by this transaction,
|
|
1074
|
+
// under the subject name a read shows (the entity's, which may be an earlier spelling of ev.subject).
|
|
1075
|
+
(0, factTerms_1.indexFactTerms)(db, row.id, scope, { subject: (0, factTerms_1.canonicalSubject)(db, entityId), predicate: ev.predicate, object: ev.object });
|
|
1076
|
+
(0, factTerms_1.prepareOnce)(db, `INSERT INTO write_events (note_path, action, summary, created_at, author, scope) VALUES ('_memory_ledger_', ?, ?, ?, ?, ?)`)
|
|
1077
|
+
.run(ev.kind, `${ev.subject} ${ev.predicate} → ${ev.object}${closedFactIds.length ? ` (closed fact${closedFactIds.length > 1 ? 's' : ''} ${closedFactIds.join(', ')})` : ''}`, ev.at, ev.source, ev.scope ?? 'global');
|
|
1078
|
+
(0, factTerms_1.prepareOnce)(db, 'INSERT OR REPLACE INTO applied_ledger_events (event_id, digest, applied_at) VALUES (?, ?, ?)')
|
|
1079
|
+
.run(ev.id, digest, ev.at);
|
|
1080
|
+
return row.id;
|
|
1081
|
+
});
|
|
1082
|
+
const factId = tx();
|
|
1083
|
+
return { applied: true, factId };
|
|
1084
|
+
}
|
|
1085
|
+
/** The retired fact that was the current value of a stream spelling when it was retired, if one still is. */
|
|
1086
|
+
function openRetiredFact(db, entityId, predicate, scope) {
|
|
1087
|
+
return (0, factTerms_1.prepareOnce)(db, `SELECT id, event_id, valid_from, object_text, scope FROM retired_facts
|
|
1088
|
+
WHERE subject_entity_id = ? AND predicate = ? AND scope = ? AND valid_to IS NULL
|
|
1089
|
+
ORDER BY valid_from DESC, id DESC LIMIT 1`).get(entityId, predicate, scope);
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The retired facts that were the current value of a stream spelling when they were retired, one per spelling at most,
|
|
1093
|
+
* newest first. The stream is the scope's slug (scopeSpellings): a save closes the retired current whatever spelling it
|
|
1094
|
+
* was retired under, exactly as it closes the live current across spellings (streamCurrentFacts). Closing it at save
|
|
1095
|
+
* time is what lets a later restore bring it back as history instead of as a second current value.
|
|
1096
|
+
*/
|
|
1097
|
+
function streamOpenRetiredFacts(db, entityId, predicate, scope) {
|
|
1098
|
+
return (0, scope_1.scopeSpellings)(scope)
|
|
1099
|
+
.map(spelling => openRetiredFact(db, entityId, predicate, spelling))
|
|
1100
|
+
.filter((row) => row !== undefined)
|
|
1101
|
+
.sort((a, b) => (a.valid_from === b.valid_from ? b.id - a.id : a.valid_from < b.valid_from ? 1 : -1));
|
|
1102
|
+
}
|
|
1103
|
+
const FACT_COLUMNS = 'id, subject_entity_id, predicate, object_text, confidence, valid_from, valid_to, updated_at, event_id, scope, author, statement_words';
|
|
1104
|
+
/**
|
|
1105
|
+
* Moves a lifecycle fact's row out of `facts`, id and all, into retired_facts: no read of `facts` (current values,
|
|
1106
|
+
* history, as_of, search, the facts lane, memory_map, the subject lists) returns it after this, with nothing to filter.
|
|
1107
|
+
* False when no such row is in `facts` (it is already retired, or no fact has that event).
|
|
1108
|
+
*/
|
|
1109
|
+
function moveFactOut(db, eventId, retire) {
|
|
1110
|
+
const row = (0, factTerms_1.prepareOnce)(db, `SELECT ${FACT_COLUMNS} FROM facts WHERE event_id = ? AND source_path = ?`)
|
|
1111
|
+
.get(eventId, exports.LIFECYCLE_NOTE_PATH);
|
|
1112
|
+
if (!row)
|
|
1113
|
+
return false;
|
|
1114
|
+
(0, factTerms_1.prepareOnce)(db, `INSERT INTO retired_facts (${FACT_COLUMNS}, retire_event_id, retired_at, retired_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.subject_entity_id, row.predicate, row.object_text, row.confidence, row.valid_from, row.valid_to, row.updated_at, row.event_id, row.scope, row.author, row.statement_words, retire.id, retire.at, retire.source);
|
|
1115
|
+
(0, factTerms_1.unindexFactTerms)(db, row.id, row.scope, { subject: (0, factTerms_1.canonicalSubject)(db, row.subject_entity_id), predicate: row.predicate, object: row.object_text });
|
|
1116
|
+
(0, factTerms_1.prepareOnce)(db, 'DELETE FROM facts WHERE id = ?').run(row.id);
|
|
1117
|
+
return true;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* A restatement can supersede an identical value only because it kept different user words. When that current
|
|
1121
|
+
* restatement is retired, reopen the exact value it directly replaced. Different values remain historical: retiring a
|
|
1122
|
+
* correction is not itself another correction. This projection is derived from the save chain, so replay makes the
|
|
1123
|
+
* same choice without adding a second value to the retire event.
|
|
1124
|
+
*/
|
|
1125
|
+
function reviveIdenticalPredecessor(db, eventId) {
|
|
1126
|
+
const retired = (0, factTerms_1.prepareOnce)(db, `SELECT ${FACT_COLUMNS} FROM retired_facts WHERE event_id = ?`).get(eventId);
|
|
1127
|
+
if (!retired || retired.valid_to !== null)
|
|
1128
|
+
return; // Retiring history never changes the current value.
|
|
1129
|
+
const inStream = (0, scope_1.savedScopeSqlPredicate)((0, scope_1.normalizeScope)(retired.scope), 'scope');
|
|
1130
|
+
const prior = (0, factTerms_1.prepareOnce)(db, `SELECT ${FACT_COLUMNS} FROM facts
|
|
1131
|
+
WHERE subject_entity_id = ? AND predicate = ? AND object_text = ? AND valid_to = ?
|
|
1132
|
+
AND source_path = ? AND ${inStream.sql}
|
|
1133
|
+
ORDER BY valid_from DESC, id DESC LIMIT 1`).get(retired.subject_entity_id, retired.predicate, retired.object_text, retired.valid_from, exports.LIFECYCLE_NOTE_PATH, ...inStream.params);
|
|
1134
|
+
if (!prior)
|
|
1135
|
+
return;
|
|
1136
|
+
(0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET valid_to = NULL, updated_at = ? WHERE id = ?').run(retired.updated_at, prior.id);
|
|
1137
|
+
}
|
|
1138
|
+
/** Puts a retired fact's row back into `facts` as it was. False when it is not retired. */
|
|
1139
|
+
function moveFactBack(db, eventId) {
|
|
1140
|
+
const row = (0, factTerms_1.prepareOnce)(db, `SELECT ${FACT_COLUMNS}, retired_at FROM retired_facts WHERE event_id = ?`).get(eventId);
|
|
1141
|
+
if (!row)
|
|
1142
|
+
return false;
|
|
1143
|
+
let validTo = row.valid_to;
|
|
1144
|
+
if (validTo === null) {
|
|
1145
|
+
// A later value anywhere in the same stream closes the retired one when it is saved (applyEvent), so this finds one
|
|
1146
|
+
// only in an index written otherwise; it is resolved the same way, the later value staying current. The stream is
|
|
1147
|
+
// the scope's slug, not one spelling of it (scopeSpellings): a value saved under project:alpha replaces a value
|
|
1148
|
+
// retired under scope:alpha, so looking only at the row's own spelling left two current values in one stream.
|
|
1149
|
+
const open = streamCurrentFacts(db, row.subject_entity_id, row.predicate, (0, scope_1.normalizeScope)(row.scope))[0];
|
|
1150
|
+
if (open) {
|
|
1151
|
+
if (open.object_text === row.object_text && open.valid_from < row.valid_from) {
|
|
1152
|
+
// The older identical value was revived when this restatement was retired. Restoring the restatement keeps it
|
|
1153
|
+
// as history instead of manufacturing a second current value or replacing the explicitly revived one.
|
|
1154
|
+
validTo = row.retired_at;
|
|
1155
|
+
}
|
|
1156
|
+
else if (open.valid_from < row.valid_from) {
|
|
1157
|
+
throw new Error(`cannot restore fact event ${eventId}: the current value (event ${open.event_id}) is older than it; rebuild the index from the ledger`);
|
|
1158
|
+
}
|
|
1159
|
+
else
|
|
1160
|
+
validTo = open.valid_from;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
(0, factTerms_1.prepareOnce)(db, `INSERT INTO facts (${FACT_COLUMNS}, source_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.subject_entity_id, row.predicate, row.object_text, row.confidence, row.valid_from, validTo, validTo === row.valid_to ? row.updated_at : validTo, row.event_id, row.scope, row.author, row.statement_words, exports.LIFECYCLE_NOTE_PATH);
|
|
1164
|
+
(0, factTerms_1.indexFactTerms)(db, row.id, row.scope, { subject: (0, factTerms_1.canonicalSubject)(db, row.subject_entity_id), predicate: row.predicate, object: row.object_text });
|
|
1165
|
+
(0, factTerms_1.prepareOnce)(db, 'DELETE FROM retired_facts WHERE id = ?').run(row.id);
|
|
1166
|
+
return true;
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Applies a retire or restore event. Every one is listed in fact_retirements, so a search never shows its ledger text.
|
|
1170
|
+
* A replay leaves the ones it already applied to settleRetirements, which sets each fact to the state its LAST
|
|
1171
|
+
* retirement event left it in; anything else moves the fact now. Moving a fact that is already where the event puts
|
|
1172
|
+
* it changes nothing (a retry, or a replay over an index that already holds it); a target that is no fact is
|
|
1173
|
+
* corruption.
|
|
1174
|
+
*/
|
|
1175
|
+
function applyRetirement(db, ev, digest, alreadyApplied) {
|
|
1176
|
+
const target = ev.target.toUpperCase();
|
|
1177
|
+
db.transaction(() => {
|
|
1178
|
+
(0, factTerms_1.prepareOnce)(db, 'INSERT OR IGNORE INTO fact_retirements (event_id, kind, target, at, source) VALUES (?, ?, ?, ?, ?)')
|
|
1179
|
+
.run(ev.id, ev.kind, target, ev.at, ev.source);
|
|
1180
|
+
if (alreadyApplied)
|
|
1181
|
+
return;
|
|
1182
|
+
const moved = ev.kind === 'retire' ? moveFactOut(db, target, ev) : moveFactBack(db, target);
|
|
1183
|
+
if (ev.kind === 'retire')
|
|
1184
|
+
reviveIdenticalPredecessor(db, target);
|
|
1185
|
+
const already = ev.kind === 'retire'
|
|
1186
|
+
? (0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM retired_facts WHERE event_id = ?').get(target)
|
|
1187
|
+
: (0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM facts WHERE event_id = ?').get(target);
|
|
1188
|
+
if (!moved && !already)
|
|
1189
|
+
throw new Error(`ledger corruption in event ${ev.id}: its target ${target} is no saved fact`);
|
|
1190
|
+
// The value stays out of the log, as it stays out of the event: memory_map lists this event.
|
|
1191
|
+
(0, factTerms_1.prepareOnce)(db, 'INSERT INTO write_events (note_path, action, summary, created_at, author, scope) VALUES (?, ?, ?, ?, ?, ?)')
|
|
1192
|
+
.run('_memory_ledger_', ev.kind, JSON.stringify({ id: `fact:${target}`, title: `${ev.subject} ${ev.predicate}` }), ev.at, ev.source, ev.scope ?? 'global');
|
|
1193
|
+
(0, factTerms_1.prepareOnce)(db, 'INSERT OR REPLACE INTO applied_ledger_events (event_id, digest, applied_at) VALUES (?, ?, ?)')
|
|
1194
|
+
.run(ev.id, digest, ev.at);
|
|
1195
|
+
})();
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* After a replay: each fact the ledger retired or restored is where its last such event left it, and a fact retired in
|
|
1199
|
+
* the index that the ledger never retired (its event was removed by hand) is back. Facts move whole, so the chain a
|
|
1200
|
+
* replay just reconciled stays as it is.
|
|
1201
|
+
*/
|
|
1202
|
+
function settleRetirements(db, events) {
|
|
1203
|
+
const last = new Map();
|
|
1204
|
+
for (const ev of events)
|
|
1205
|
+
if (RETIREMENT_KINDS.has(ev.kind))
|
|
1206
|
+
last.set(ev.target.toUpperCase(), ev);
|
|
1207
|
+
const retired = (0, factTerms_1.prepareOnce)(db, 'SELECT event_id FROM retired_facts').all();
|
|
1208
|
+
db.transaction(() => {
|
|
1209
|
+
for (const [target, ev] of last) {
|
|
1210
|
+
if (ev.kind === 'retire') {
|
|
1211
|
+
moveFactOut(db, target, ev);
|
|
1212
|
+
reviveIdenticalPredecessor(db, target);
|
|
1213
|
+
}
|
|
1214
|
+
else
|
|
1215
|
+
moveFactBack(db, target);
|
|
1216
|
+
}
|
|
1217
|
+
for (const { event_id } of retired)
|
|
1218
|
+
if (!last.has(event_id))
|
|
1219
|
+
moveFactBack(db, event_id);
|
|
1220
|
+
})();
|
|
1221
|
+
}
|
|
1222
|
+
/** Replay the ledger into facts. Idempotent by event id + digest. Throws on corruption. */
|
|
1223
|
+
async function replayLedger(db, vaultPath, ledgerRelPath, options = {}) {
|
|
1224
|
+
const filePath = resolveLedgerPath(vaultPath, ledgerRelPath); // shared confinement, before any read
|
|
1225
|
+
let content;
|
|
1226
|
+
let finalEventKnown = false;
|
|
1227
|
+
try {
|
|
1228
|
+
({ content, finalEventKnown } = await readLedgerForReplay(vaultPath, filePath, eventId => (0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM applied_ledger_events WHERE event_id = ?').get(eventId) !== undefined, options.setTornTailAside === true));
|
|
1229
|
+
}
|
|
1230
|
+
catch (err) {
|
|
1231
|
+
if (err?.code === 'ENOENT') {
|
|
1232
|
+
// Only an absent ledger means "known-new empty vault: nothing recorded yet".
|
|
1233
|
+
// If the index already projects canonical events, the authoritative ledger
|
|
1234
|
+
// is GONE — silently treating that as an empty vault would let startup go
|
|
1235
|
+
// ready on a stale index whose source of truth no longer exists (and the
|
|
1236
|
+
// next rebuild would wipe every canonical fact). Fail closed instead;
|
|
1237
|
+
// recovery is restoring the ledger or deliberately wiping the derived
|
|
1238
|
+
// index.
|
|
1239
|
+
// ALL reliable evidence of prior canonical projection counts (Kimi K10 /
|
|
1240
|
+
// Astra A3.1): digest rows, event_id-backed facts (a base-v5 DB carries
|
|
1241
|
+
// these with an empty digest table — digests are only backfilled BY a
|
|
1242
|
+
// successful replay, which needs the ledger), and lifecycle write_events.
|
|
1243
|
+
const projected = (0, factTerms_1.prepareOnce)(db, 'SELECT COUNT(*) c FROM applied_ledger_events').get().c
|
|
1244
|
+
+ (0, factTerms_1.prepareOnce)(db, 'SELECT COUNT(*) c FROM facts WHERE event_id IS NOT NULL').get().c
|
|
1245
|
+
+ (0, factTerms_1.prepareOnce)(db, `SELECT COUNT(*) c FROM write_events WHERE note_path = '_memory_ledger_'`).get().c;
|
|
1246
|
+
if (projected > 0) {
|
|
1247
|
+
throw new Error(`ledger file is missing at ${filePath} but ${projected} canonical event(s) are already projected in the index; refusing to treat this as an empty vault — restore the ledger or deliberately rebuild from scratch`);
|
|
1248
|
+
}
|
|
1249
|
+
return 0;
|
|
1250
|
+
}
|
|
1251
|
+
// EISDIR / EACCES / EIO / anything else is a real I/O failure: block
|
|
1252
|
+
// readiness (B7 fail-closed), never "replayed 0 events".
|
|
1253
|
+
throw new Error(`ledger read failed (${err?.code ?? 'unknown'}) at ${filePath}: ${err?.message ?? err}`);
|
|
1254
|
+
}
|
|
1255
|
+
let applied = 0;
|
|
1256
|
+
const events = parseLedgerEvents(content, { finalEventKnown });
|
|
1257
|
+
// Canonical append order of the ledger (K12): the ONLY ordering input for
|
|
1258
|
+
// reconciliation of already-projected streams. Byte-identical duplicate ids
|
|
1259
|
+
// dedup and conflicting reuse throws in applyEvent; the first occurrence
|
|
1260
|
+
// fixes each event's canonical position.
|
|
1261
|
+
const positions = new Map();
|
|
1262
|
+
// Each event's closed_event: reconciling a stream replays the facts closed across spellings (reconcileStreamCurrent).
|
|
1263
|
+
const closedBy = new Map();
|
|
1264
|
+
for (let i = 0; i < events.length; i++) {
|
|
1265
|
+
if (positions.has(events[i].id))
|
|
1266
|
+
continue;
|
|
1267
|
+
positions.set(events[i].id, i);
|
|
1268
|
+
const closed = events[i].closedEvent;
|
|
1269
|
+
if (closed)
|
|
1270
|
+
closedBy.set(events[i].id, closed.toUpperCase());
|
|
1271
|
+
// A retirement names a fact event written before it.
|
|
1272
|
+
const target = events[i].target?.toUpperCase();
|
|
1273
|
+
if (target !== undefined) {
|
|
1274
|
+
const at = positions.get(target);
|
|
1275
|
+
if (at === undefined || RETIREMENT_KINDS.has(events[at].kind)) {
|
|
1276
|
+
throw new Error(`ledger corruption in event ${events[i].id}: its target ${target} is no fact event before it in the ledger`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
// GLOBAL preflight (K13): every event-backed canonical projection in the
|
|
1281
|
+
// index must be explainable by the COMPLETE parsed ledger — not just the
|
|
1282
|
+
// streams this replay happens to touch. The stream-scoped refusal (re-home
|
|
1283
|
+
// competition, per-stream reconcile) cannot see a stream whose ONLY event
|
|
1284
|
+
// was removed from the ledger; such an orphaned row would survive as a
|
|
1285
|
+
// served current fact with false ledger provenance, silently diverging from
|
|
1286
|
+
// a clean rebuild of the identical ledger. Fail closed BEFORE any cache
|
|
1287
|
+
// mutation, with the same explicit contradiction error as the stream-scoped
|
|
1288
|
+
// checks. Unrelated (non '_mcp_') and NULL-event rows are not touched; no
|
|
1289
|
+
// history is dropped or invented — the operator restores the ledger or
|
|
1290
|
+
// deliberately rebuilds the derived index.
|
|
1291
|
+
const projectedIds = (0, factTerms_1.prepareOnce)(db, `SELECT DISTINCT event_id FROM facts WHERE event_id IS NOT NULL AND source_path = '_mcp_'
|
|
1292
|
+
UNION SELECT event_id FROM retired_facts`).all();
|
|
1293
|
+
const orphaned = projectedIds
|
|
1294
|
+
.map(r => r.event_id)
|
|
1295
|
+
.filter(id => !positions.has(id.toUpperCase()));
|
|
1296
|
+
if (orphaned.length > 0) {
|
|
1297
|
+
const shown = orphaned.slice(0, 3).join(', ');
|
|
1298
|
+
throw new Error(`index holds lifecycle facts for ${orphaned.length} event(s) absent from the ledger (${shown}${orphaned.length > 3 ? ', …' : ''}); refusing to guess canonical order — restore the ledger or deliberately rebuild the derived index`);
|
|
1299
|
+
}
|
|
1300
|
+
const index = replayEntityIndex(db);
|
|
1301
|
+
for (const ev of events) {
|
|
1302
|
+
if (applyEvent(db, ev, positions, index, closedBy).applied)
|
|
1303
|
+
applied++;
|
|
1304
|
+
}
|
|
1305
|
+
settleRetirements(db, events);
|
|
1306
|
+
backfillStatementWords(db, events);
|
|
1307
|
+
return applied;
|
|
1308
|
+
}
|
|
1309
|
+
const STATEMENT_WORDS_BACKFILL = 'statement_words_backfilled';
|
|
1310
|
+
/**
|
|
1311
|
+
* Facts saved before migration 25 have no `statement_words`, and replay skips their events (applied_ledger_events holds
|
|
1312
|
+
* them), so the words are derived here once per database from the ledger event that saved each fact. This writes derived
|
|
1313
|
+
* index rows only; the ledger is read, never written. Facts saved without a statement stay null.
|
|
1314
|
+
*/
|
|
1315
|
+
function backfillStatementWords(db, events) {
|
|
1316
|
+
if ((0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM index_state WHERE key = ?').get(STATEMENT_WORDS_BACKFILL) !== undefined)
|
|
1317
|
+
return;
|
|
1318
|
+
const live = (0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET statement_words = ? WHERE event_id = ? AND statement_words IS NULL');
|
|
1319
|
+
const retired = (0, factTerms_1.prepareOnce)(db, 'UPDATE retired_facts SET statement_words = ? WHERE event_id = ? AND statement_words IS NULL');
|
|
1320
|
+
for (const ev of events) {
|
|
1321
|
+
const words = (0, factTerms_1.statementTerms)(ev.statement);
|
|
1322
|
+
if (words === null)
|
|
1323
|
+
continue;
|
|
1324
|
+
live.run(words, ev.id);
|
|
1325
|
+
retired.run(words, ev.id);
|
|
1326
|
+
}
|
|
1327
|
+
(0, factTerms_1.prepareOnce)(db, 'INSERT OR REPLACE INTO index_state (key, value) VALUES (?, ?)').run(STATEMENT_WORDS_BACKFILL, '1');
|
|
1328
|
+
}
|
|
1329
|
+
// In-process single-writer chain: serializes the full lifecycle decision +
|
|
1330
|
+
// ledger append + projection so concurrent remember() calls for the same (or
|
|
1331
|
+
// any) subject+predicate cannot interleave. Cross-process/crash durability
|
|
1332
|
+
// (OS-level writer lock, fsync barriers) is deliberately Gate 1B scope.
|
|
1333
|
+
let writeChain = Promise.resolve();
|
|
1334
|
+
function serializeLifecycleWrite(fn) {
|
|
1335
|
+
const result = writeChain.then(fn, fn);
|
|
1336
|
+
writeChain = result.then(() => undefined, () => undefined);
|
|
1337
|
+
return result;
|
|
1338
|
+
}
|
|
1339
|
+
/** The principal write verb. Ledger-first, then SQLite. Idempotent on identical current value. */
|
|
1340
|
+
async function remember(db, vaultPath, ledgerRelPath, args) {
|
|
1341
|
+
return serializeLifecycleWrite(() => rememberCritical(db, vaultPath, ledgerRelPath, args));
|
|
1342
|
+
}
|
|
1343
|
+
// A ledger larger than this is read and parsed whole on every repeat check, and nothing about it is kept.
|
|
1344
|
+
const MAX_REPEAT_LEDGER_BYTES = 256 * 1024;
|
|
1345
|
+
let repeatIndex;
|
|
1346
|
+
const sha256 = (bytes) => crypto_1.default.createHash('sha256').update(bytes).digest('hex');
|
|
1347
|
+
/** Keeps `events` as the index of the ledger's bytes, when a later append can be parsed on its own (the bytes end in a line break). */
|
|
1348
|
+
function keepRepeatIndex(file, content, events) {
|
|
1349
|
+
const endsInLine = content.byteLength === 0 || content[content.byteLength - 1] === 0x0a;
|
|
1350
|
+
repeatIndex = endsInLine ? { file, length: content.byteLength, digest: sha256(content), events } : undefined;
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* The first event with this id, as parseLedgerEvents over the whole ledger would give it. When the ledger still starts with
|
|
1354
|
+
* the bytes it was parsed from, a write has only appended whole events since, so only those are parsed. Any other change,
|
|
1355
|
+
* and any error in the appended part, parses the whole ledger, which raises what it always raised.
|
|
1356
|
+
*/
|
|
1357
|
+
function ledgerEventNamed(file, content, id) {
|
|
1358
|
+
if (content.byteLength > MAX_REPEAT_LEDGER_BYTES) {
|
|
1359
|
+
repeatIndex = undefined;
|
|
1360
|
+
return parseLedgerEvents(content.toString('utf8')).find(e => e.id === id);
|
|
1361
|
+
}
|
|
1362
|
+
const held = repeatIndex;
|
|
1363
|
+
if (held && held.file === file && held.length <= content.byteLength && sha256(content.subarray(0, held.length)) === held.digest) {
|
|
1364
|
+
if (held.length === content.byteLength)
|
|
1365
|
+
return held.events.get(id);
|
|
1366
|
+
const appended = content.subarray(held.length).toString('utf8');
|
|
1367
|
+
// The appended part must hold an event header, and must not open with a field line, which would belong to the last
|
|
1368
|
+
// event already held. Then it parses exactly as it would at the end of the whole ledger.
|
|
1369
|
+
if (/^## mem_/im.test(appended) && !appended.startsWith('- ')) {
|
|
1370
|
+
try {
|
|
1371
|
+
const events = held.events;
|
|
1372
|
+
for (const e of parseLedgerEvents(appended))
|
|
1373
|
+
if (!events.has(e.id))
|
|
1374
|
+
events.set(e.id, e);
|
|
1375
|
+
keepRepeatIndex(file, content, events);
|
|
1376
|
+
return events.get(id);
|
|
1377
|
+
}
|
|
1378
|
+
catch {
|
|
1379
|
+
// Parsed whole below, so the error is the one the whole ledger gives.
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
const events = new Map();
|
|
1384
|
+
for (const e of parseLedgerEvents(content.toString('utf8')))
|
|
1385
|
+
if (!events.has(e.id))
|
|
1386
|
+
events.set(e.id, e);
|
|
1387
|
+
keepRepeatIndex(file, content, events);
|
|
1388
|
+
return events.get(id);
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Does this write repeat the event behind the current fact, in everything the caller supplied?
|
|
1392
|
+
*
|
|
1393
|
+
* A caller that supplies the user's words must repeat them exactly as the current event holds them:
|
|
1394
|
+
* the same statement, and the subject spelled the same. The entity lookup that found the current fact
|
|
1395
|
+
* folds case and Unicode form, so on its own it would take `exactsubject` for a repeat of
|
|
1396
|
+
* `ExactSubject` and the new spelling would never reach the ledger. A different sentence or spelling
|
|
1397
|
+
* for the same value is still the user's own input, so it is written as a new event.
|
|
1398
|
+
*
|
|
1399
|
+
* A write without a statement (save_memory) has no words to keep and dedupes on the value alone.
|
|
1400
|
+
* When the event cannot be read (a legacy fact written without one, an absent ledger) nothing can be
|
|
1401
|
+
* confirmed, so the write goes ahead rather than assuming it is a repeat.
|
|
1402
|
+
*/
|
|
1403
|
+
async function repeatsCurrentEvent(vaultPath, ledgerRelPath, eventId, args) {
|
|
1404
|
+
if (args.statement === undefined)
|
|
1405
|
+
return true;
|
|
1406
|
+
if (!eventId)
|
|
1407
|
+
return false;
|
|
1408
|
+
const file = resolveLedgerPath(vaultPath, ledgerRelPath);
|
|
1409
|
+
let content;
|
|
1410
|
+
try {
|
|
1411
|
+
content = await promises_1.default.readFile(file);
|
|
1412
|
+
}
|
|
1413
|
+
catch (err) {
|
|
1414
|
+
if (err?.code === 'ENOENT')
|
|
1415
|
+
return false;
|
|
1416
|
+
throw err;
|
|
1417
|
+
}
|
|
1418
|
+
// Always read first: the bytes say whether the events held from the last check are still the ledger's.
|
|
1419
|
+
const event = ledgerEventNamed(file, content, eventId.toUpperCase());
|
|
1420
|
+
return event !== undefined && event.subject === args.subject && event.statement === args.statement;
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* RENOR_SECRET_GUARD (default ON): refuse a write whose text looks like a secret, before the ledger or the
|
|
1424
|
+
* index is touched. The subject, predicate and object are scanned as one line, "subject predicate: object",
|
|
1425
|
+
* because a credential is usually stated across them (predicate wifi_password, object the password) and
|
|
1426
|
+
* neither field carries the cue on its own.
|
|
1427
|
+
*/
|
|
1428
|
+
async function refuseSecrets(args) {
|
|
1429
|
+
try {
|
|
1430
|
+
await (0, secretGuard_1.assertStorable)([
|
|
1431
|
+
{ field: 'statement', text: args.statement },
|
|
1432
|
+
{ field: 'subject, predicate and object', text: `${args.subject} ${args.predicate}: ${args.object}` },
|
|
1433
|
+
{ field: 'source', text: args.source },
|
|
1434
|
+
]);
|
|
1435
|
+
}
|
|
1436
|
+
catch (err) {
|
|
1437
|
+
// A refusal is the caller's input, like every rejection above it, so it leaves as a LifecycleInputError (4xx).
|
|
1438
|
+
// A guard that could not decide is not: it stays the plain Error it is.
|
|
1439
|
+
if (err instanceof secretGuard_1.SecretRefusedError && err.reason !== 'unavailable')
|
|
1440
|
+
throw new LifecycleInputError(err.field, err.message);
|
|
1441
|
+
throw err;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
async function rememberCritical(db, vaultPath, ledgerRelPath, sent) {
|
|
1445
|
+
// What is stored is the source's name, not what was sent (sourceName.ts): a path or a URL is cut down to what it ends in,
|
|
1446
|
+
// a long name is cut and control characters are dropped, before anything is judged, so the checks below and the secret
|
|
1447
|
+
// guard see the form that is stored and neither a long path nor a stray newline loses the whole save. A source with no
|
|
1448
|
+
// name left in it (blank, or only separators or controls) is stored as the default, like one that was not sent. A source
|
|
1449
|
+
// that is not a string is left for validateField. The import prefix is an import's alone (savedSource).
|
|
1450
|
+
const args = typeof sent.source === 'string' ? { ...sent, source: (0, sourceName_1.savedSource)(sent.source) } : sent;
|
|
1451
|
+
validateLifecycleInput(args);
|
|
1452
|
+
refuseUnstorableFields(args);
|
|
1453
|
+
if ((0, secretGuard_1.secretGuardEnabled)())
|
|
1454
|
+
await refuseSecrets(args);
|
|
1455
|
+
const at = new Date().toISOString();
|
|
1456
|
+
const scope = (0, scope_1.normalizeScope)(args.scope);
|
|
1457
|
+
// The source the event stores, and the writer the save is recorded under: the authenticated identity when the caller
|
|
1458
|
+
// passed one, else the source itself. The event names the writer only where it differs from the source.
|
|
1459
|
+
const source = args.source || 'agent';
|
|
1460
|
+
const writer = args.writer ?? source;
|
|
1461
|
+
// Current-value decision is made against the lifecycle-owned entity only,
|
|
1462
|
+
// so a same-named disposable note entity can never influence or inherit it.
|
|
1463
|
+
// Strict identity: a legacy split whose open currents disagree fails closed HERE,
|
|
1464
|
+
// before the ledger append below, so the canonical bytes stay untouched.
|
|
1465
|
+
const entityId = resolveLifecycleEntity(db, args.subject, at, false, { strict: true });
|
|
1466
|
+
// The current value of the scope's stream, whatever spelling of the scope it was saved with (streamCurrentFacts).
|
|
1467
|
+
const currents = entityId ? streamCurrentFacts(db, entityId, args.predicate, scope) : [];
|
|
1468
|
+
// nochange only when nothing the caller said is new: the scope holds this value and no other AND, when they
|
|
1469
|
+
// supply the user's words, the same subject spelling and statement as the current event (see repeatsCurrentEvent).
|
|
1470
|
+
// A writer that owes a confirmation (its value was replaced) is recorded even when it repeats the current value: that
|
|
1471
|
+
// save is the confirmation, and only the ledger keeps it.
|
|
1472
|
+
if (currents.length > 0 && currents.every(c => c.object_text === args.object)
|
|
1473
|
+
&& !(currents[0].event_id !== null && (0, writerConflicts_1.hasOpenDisplacementBy)(db, currents[0].event_id, writer))
|
|
1474
|
+
&& await repeatsCurrentEvent(vaultPath, ledgerRelPath, currents[0].event_id, args)) {
|
|
1475
|
+
return { ok: true, eventId: 'none', action: 'nochange', subject: args.subject, predicate: args.predicate, closed: null,
|
|
1476
|
+
created: { factId: currents[0].id, object: args.object, validFrom: 'existing' }, ledger: resolveLedgerPath(vaultPath, ledgerRelPath) };
|
|
1477
|
+
}
|
|
1478
|
+
// What the write closes, as applyEvent closes it: the current value saved under the write's own spelling, and the
|
|
1479
|
+
// one saved under the other spelling of the slug, which the event names in closed_event (a value with no event,
|
|
1480
|
+
// written before the ledger, cannot be named: it stays current beside the new one, and a read names the conflict).
|
|
1481
|
+
// A retired value keeps its place in the stream, so the write closes the retired current of either spelling too; only
|
|
1482
|
+
// the one under the OTHER spelling is named in closed_event, because reconcileStreamCurrent closes the own-spelling
|
|
1483
|
+
// one by order but cannot close a cross-spelling one without the name. The own-spelling retired value stays unnamed
|
|
1484
|
+
// (the write is a 'store' when no live value was replaced), exactly as it was before scopes became one slug.
|
|
1485
|
+
const own = currents.find(c => c.scope === scope);
|
|
1486
|
+
const across = currents.find(c => c.scope !== scope && c.event_id != null);
|
|
1487
|
+
const hiddens = entityId ? streamOpenRetiredFacts(db, entityId, args.predicate, scope) : [];
|
|
1488
|
+
const named = across ?? hiddens.find(h => h.scope !== scope) ?? own;
|
|
1489
|
+
// Pre-publish monotonicity validation (Kimi K11): the SAME rule the replay
|
|
1490
|
+
// enforces, but checked BEFORE the ledger append. Without this, a transient
|
|
1491
|
+
// backward clock step (NTP correction, manual clock change) made the live
|
|
1492
|
+
// write throw AFTER the append — leaving the self-rejected event in the
|
|
1493
|
+
// ledger and poisoning every future replay/rebuild/startup with the
|
|
1494
|
+
// identical error. A write that would be rejected must never reach the
|
|
1495
|
+
// ledger: the canonical bytes stay byte-identical on rejection.
|
|
1496
|
+
// A retired value that was current under the write's spelling is closed by it too (applyEvent), by the same rule,
|
|
1497
|
+
// and the stream is the scope's slug, so the retired current of either spelling is checked (streamOpenRetiredFacts).
|
|
1498
|
+
// The refusal lasts while the clock stays behind the newest of them, so it names that instant: the wait the user has.
|
|
1499
|
+
const behind = [own, across, ...hiddens].filter((c) => c !== undefined && at < c.valid_from);
|
|
1500
|
+
if (behind.length > 0) {
|
|
1501
|
+
const wait = behind.reduce((latest, c) => (c.valid_from > latest.valid_from ? c : latest));
|
|
1502
|
+
throw new Error(`lifecycle write rejected without publishing: event time ${at} is older than the current fact (valid_from ${wait.valid_from}, fact id ${wait.id}) for the same subject+predicate. Writes are accepted again once the clock reaches ${wait.valid_from}. The ledger was not modified`);
|
|
1503
|
+
}
|
|
1504
|
+
const id = newEventId();
|
|
1505
|
+
const kind = named ? 'supersede' : 'store';
|
|
1506
|
+
const ev = {
|
|
1507
|
+
id, at, kind,
|
|
1508
|
+
subject: args.subject, predicate: args.predicate, object: args.object,
|
|
1509
|
+
closedEvent: named?.event_id ?? null,
|
|
1510
|
+
source,
|
|
1511
|
+
scope,
|
|
1512
|
+
...(args.statement !== undefined ? { statement: args.statement } : {}),
|
|
1513
|
+
...(writer !== source ? { writer } : {}),
|
|
1514
|
+
};
|
|
1515
|
+
// Durable first: if the SQLite apply fails, the event is still in the ledger
|
|
1516
|
+
// and the next replay repairs the index. The apply itself is one transaction,
|
|
1517
|
+
// so it either fully lands or leaves no partial supersession behind.
|
|
1518
|
+
const ledger = await appendLedgerEvent(vaultPath, ledgerRelPath, ev);
|
|
1519
|
+
const { factId } = applyEvent(db, ev);
|
|
1520
|
+
return {
|
|
1521
|
+
ok: true, eventId: id, action: kind, subject: args.subject, predicate: args.predicate,
|
|
1522
|
+
closed: named ? { factId: named.id, object: named.object_text, validTo: at } : null,
|
|
1523
|
+
...(across && own ? { alsoClosed: { factId: own.id, object: own.object_text, validTo: at } } : {}),
|
|
1524
|
+
created: { factId, object: args.object, validFrom: at }, ledger,
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
const FACT_ID_RE = /^fact:(mem_[A-Za-z0-9]+)$/i;
|
|
1528
|
+
/** The event id in a fact id (`fact:MEM_…`), or null for any other memory id. */
|
|
1529
|
+
function factEventId(id) {
|
|
1530
|
+
return FACT_ID_RE.exec(id)?.[1].toUpperCase() ?? null;
|
|
1531
|
+
}
|
|
1532
|
+
function factTitle(subject, predicate, object) {
|
|
1533
|
+
return `${subject} ${predicate}: ${object}`;
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Takes one saved fact out of recall, reversibly: a retire event goes into the ledger (who asked, when, and which fact),
|
|
1537
|
+
* then the fact's row leaves `facts` (moveFactOut). The ledger keeps the fact's own event, so its history is never lost
|
|
1538
|
+
* and restoreFact brings it back. Retiring the current value revives only the directly replaced value when its object is
|
|
1539
|
+
* identical (a differently worded restatement); a genuinely different replaced value stays historical.
|
|
1540
|
+
*/
|
|
1541
|
+
async function retireFact(db, vaultPath, ledgerRelPath, id, source) {
|
|
1542
|
+
return serializeLifecycleWrite(async () => {
|
|
1543
|
+
const target = factEventId(id);
|
|
1544
|
+
if (target === null)
|
|
1545
|
+
throw new LifecycleInputError('id', `not a fact id: ${JSON.stringify(id)} (a fact's id is fact: and its eventId)`, 'invalid_argument');
|
|
1546
|
+
const row = (0, factTerms_1.prepareOnce)(db, `SELECT f.predicate, f.object_text, f.valid_to, f.scope, e.canonical_name AS subject, e.source_note
|
|
1547
|
+
FROM facts f JOIN entities e ON e.id = f.subject_entity_id
|
|
1548
|
+
WHERE f.event_id = ? AND f.source_path = ?`).get(target, exports.LIFECYCLE_NOTE_PATH);
|
|
1549
|
+
if (!row) {
|
|
1550
|
+
if ((0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM retired_facts WHERE event_id = ?').get(target)) {
|
|
1551
|
+
throw new LifecycleInputError('id', 'that fact is already retired (restore_memory brings it back)', 'memory_already_retired');
|
|
1552
|
+
}
|
|
1553
|
+
throw new LifecycleInputError('id', 'no saved fact has that id', 'memory_not_found');
|
|
1554
|
+
}
|
|
1555
|
+
// A startup replay moves every lifecycle fact onto its lifecycle subject, so this only refuses a fact that has not been.
|
|
1556
|
+
if (row.source_note !== exports.LIFECYCLE_NOTE_PATH)
|
|
1557
|
+
throw new Error(`fact ${target} is not attached to its lifecycle subject yet; restart the engine to replay the ledger first`);
|
|
1558
|
+
const ev = {
|
|
1559
|
+
id: newEventId(), at: new Date().toISOString(), kind: 'retire',
|
|
1560
|
+
subject: row.subject, predicate: row.predicate, object: '', closedEvent: null,
|
|
1561
|
+
source: (source === undefined ? '' : (0, sourceName_1.savedSource)(source)) || 'agent', scope: row.scope, target,
|
|
1562
|
+
};
|
|
1563
|
+
await appendLedgerEvent(vaultPath, ledgerRelPath, ev);
|
|
1564
|
+
applyEvent(db, ev);
|
|
1565
|
+
return { ok: true, id: `fact:${target}`, action: 'retired', title: factTitle(row.subject, row.predicate, row.object_text), at: ev.at, eventId: ev.id, current: row.valid_to === null };
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Brings a retired fact back exactly as it was: the same row, value and dates. It is the current value again unless a
|
|
1570
|
+
* value saved for the same subject, predicate and scope after it replaced it, as that save would have replaced it had it
|
|
1571
|
+
* never been retired; it is then history. RENOR_SECRET_GUARD (default ON) screens it as a save.
|
|
1572
|
+
*/
|
|
1573
|
+
async function restoreFact(db, vaultPath, ledgerRelPath, id, source, validateAfterWrite) {
|
|
1574
|
+
return serializeLifecycleWrite(async () => {
|
|
1575
|
+
const target = factEventId(id);
|
|
1576
|
+
if (target === null)
|
|
1577
|
+
throw new LifecycleInputError('id', `not a fact id: ${JSON.stringify(id)} (a fact's id is fact: and its eventId)`, 'invalid_argument');
|
|
1578
|
+
const row = (0, factTerms_1.prepareOnce)(db, `SELECT r.predicate, r.object_text, r.scope, e.canonical_name AS subject
|
|
1579
|
+
FROM retired_facts r JOIN entities e ON e.id = r.subject_entity_id WHERE r.event_id = ?`).get(target);
|
|
1580
|
+
if (!row) {
|
|
1581
|
+
if ((0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM facts WHERE event_id = ?').get(target))
|
|
1582
|
+
throw new LifecycleInputError('id', 'that fact is not retired', 'memory_not_found');
|
|
1583
|
+
throw new LifecycleInputError('id', 'no retired fact has that id', 'memory_not_found');
|
|
1584
|
+
}
|
|
1585
|
+
if ((0, secretGuard_1.secretGuardEnabled)())
|
|
1586
|
+
await refuseSecrets({ subject: row.subject, predicate: row.predicate, object: row.object_text });
|
|
1587
|
+
const ev = {
|
|
1588
|
+
id: newEventId(), at: new Date().toISOString(), kind: 'restore',
|
|
1589
|
+
subject: row.subject, predicate: row.predicate, object: '', closedEvent: null,
|
|
1590
|
+
source: (source === undefined ? '' : (0, sourceName_1.savedSource)(source)) || 'agent', scope: row.scope, target,
|
|
1591
|
+
};
|
|
1592
|
+
const ledgerPath = resolveLedgerPath(vaultPath, ledgerRelPath);
|
|
1593
|
+
const priorBytes = await promises_1.default.stat(ledgerPath).then((stat) => stat.size, () => 0);
|
|
1594
|
+
await appendLedgerEvent(vaultPath, ledgerRelPath, ev);
|
|
1595
|
+
db.exec('SAVEPOINT restore_storage_limit');
|
|
1596
|
+
try {
|
|
1597
|
+
applyEvent(db, ev);
|
|
1598
|
+
await validateAfterWrite?.();
|
|
1599
|
+
db.exec('RELEASE SAVEPOINT restore_storage_limit');
|
|
1600
|
+
}
|
|
1601
|
+
catch (err) {
|
|
1602
|
+
db.exec('ROLLBACK TO SAVEPOINT restore_storage_limit; RELEASE SAVEPOINT restore_storage_limit');
|
|
1603
|
+
await promises_1.default.truncate(ledgerPath, priorBytes);
|
|
1604
|
+
throw err;
|
|
1605
|
+
}
|
|
1606
|
+
const back = (0, factTerms_1.prepareOnce)(db, 'SELECT valid_to FROM facts WHERE event_id = ?').get(target);
|
|
1607
|
+
return { ok: true, id: `fact:${target}`, action: 'restored', title: factTitle(row.subject, row.predicate, row.object_text), at: ev.at, eventId: ev.id, current: back.valid_to === null };
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
/** Every retired fact, newest retirement first. */
|
|
1611
|
+
function listRetiredFacts(db) {
|
|
1612
|
+
const rows = (0, factTerms_1.prepareOnce)(db, `SELECT r.event_id, e.canonical_name AS subject, r.predicate, r.object_text, r.scope, r.valid_from, r.retired_at, r.retired_by
|
|
1613
|
+
FROM retired_facts r JOIN entities e ON e.id = r.subject_entity_id
|
|
1614
|
+
ORDER BY r.retired_at DESC, r.id DESC`).all();
|
|
1615
|
+
return rows.map(r => ({
|
|
1616
|
+
id: `fact:${r.event_id}`, eventId: r.event_id, subject: r.subject, predicate: r.predicate, object: r.object_text,
|
|
1617
|
+
scope: r.scope, title: factTitle(r.subject, r.predicate, r.object_text), validFrom: r.valid_from,
|
|
1618
|
+
retiredAt: r.retired_at, retiredBy: r.retired_by,
|
|
1619
|
+
}));
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* The one scope a read is restricted to: the explicit scope, normalized (an empty one means global), or
|
|
1623
|
+
* null for every scope. Every read path derives its filter here, so none can answer for a wider scope than
|
|
1624
|
+
* it was asked about.
|
|
1625
|
+
*/
|
|
1626
|
+
function scopeFilterFor(scope) {
|
|
1627
|
+
return scope !== undefined ? (0, scope_1.normalizeScope)(scope) : null;
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Names of the subjects that lifecycle facts are about (current or superseded), alphabetical. Given a scope,
|
|
1631
|
+
* only subjects that have a fact in that scope (any spelling of it, savedScopeSqlPredicate): a scoped read must
|
|
1632
|
+
* not learn of subjects that live elsewhere.
|
|
1633
|
+
*/
|
|
1634
|
+
function listLifecycleSubjects(db, opts = {}) {
|
|
1635
|
+
const scope = scopeFilterFor(opts.scope);
|
|
1636
|
+
const inScope = scope === null ? null : (0, scope_1.savedScopeSqlPredicate)(scope, 'f.scope');
|
|
1637
|
+
const rows = (0, factTerms_1.prepareOnce)(db, `SELECT DISTINCT e.canonical_name AS name FROM entities e
|
|
1638
|
+
WHERE e.source_note = ?
|
|
1639
|
+
AND EXISTS (SELECT 1 FROM facts f WHERE f.subject_entity_id = e.id${inScope ? ` AND ${inScope.sql}` : ''})
|
|
1640
|
+
ORDER BY e.canonical_name COLLATE NOCASE`).all(exports.LIFECYCLE_NOTE_PATH, ...(inScope?.params ?? []));
|
|
1641
|
+
return rows.map(r => r.name);
|
|
1642
|
+
}
|
|
1643
|
+
// A partial or path match on a lifecycle row names another subject: only the identity match in factsFor may name one.
|
|
1644
|
+
const NOT_LIFECYCLE_ENTITY = { sql: 'entities.source_note IS NOT ?', params: [exports.LIFECYCLE_NOTE_PATH] };
|
|
1645
|
+
/**
|
|
1646
|
+
* The newest workspace observation in the scope being read. Lifecycle writes keep their stamps in facts and
|
|
1647
|
+
* retired_facts; note indexing keeps its clock in note_index_clock, independently of rows that reindexing or
|
|
1648
|
+
* retirement can delete. Their maximum survives retire/restore and backward-clock reindexing, including a
|
|
1649
|
+
* workspace with no lifecycle writes. Index observations are not content dates: a future-dated note still keeps
|
|
1650
|
+
* its valid_from/valid_to window. The migration seeds existing indexes from the indexer's updated_at stamps.
|
|
1651
|
+
* Read once until the next database write, including a write by another connection (readUntilWrite).
|
|
1652
|
+
*/
|
|
1653
|
+
function workspaceReadInstant(db, scope) {
|
|
1654
|
+
const cacheKey = `workspace-read-instant${scope === null ? '' : `:${scope}`}`;
|
|
1655
|
+
return (0, readCache_1.readUntilWrite)(db, cacheKey, () => {
|
|
1656
|
+
const inScope = scope === null ? null : (0, scope_1.savedScopeSqlPredicate)(scope, 'scope');
|
|
1657
|
+
// Each observation source carries its own scope predicate and parameter spellings.
|
|
1658
|
+
const scopeSql = inScope ? ` AND ${inScope.sql}` : '';
|
|
1659
|
+
const scopeParams = inScope ? inScope.params : [];
|
|
1660
|
+
return (0, factTerms_1.prepareOnce)(db, `SELECT MAX(newest) AS newest FROM (`
|
|
1661
|
+
+ ` SELECT valid_from AS newest FROM facts WHERE source_path = ?${scopeSql}`
|
|
1662
|
+
+ ` UNION ALL`
|
|
1663
|
+
+ ` SELECT valid_from AS newest FROM retired_facts${inScope ? ` WHERE ${inScope.sql}` : ''}`
|
|
1664
|
+
+ ` UNION ALL`
|
|
1665
|
+
+ ` SELECT observed_at AS newest FROM note_index_clock${inScope ? ` WHERE ${inScope.sql}` : ''}`
|
|
1666
|
+
+ `)`).get(exports.LIFECYCLE_NOTE_PATH, ...scopeParams, ...scopeParams, ...scopeParams)?.newest ?? null;
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* The instant a note-derived fact is judged at by the default read: the wall clock, or the newest workspace observation
|
|
1671
|
+
* when that is newer. The workspace has already observed that instant, so the read judges the content-date window
|
|
1672
|
+
* at the observation rather than losing the note to a regressed machine clock (workspaceReadInstant).
|
|
1673
|
+
*/
|
|
1674
|
+
function noteReadInstant(db, scope) {
|
|
1675
|
+
const at = new Date().toISOString();
|
|
1676
|
+
const newest = workspaceReadInstant(db, scope);
|
|
1677
|
+
return newest !== null && newest > at ? newest : at;
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* The default read's currency rule, keyed on the row's origin. A lifecycle fact
|
|
1681
|
+
* (source_path '_mcp_') is current exactly when it is open — the same rule the
|
|
1682
|
+
* write path uses (streamCurrentFacts), so reads and writes agree after a
|
|
1683
|
+
* backward clock step. A note-derived fact keeps its content-date window: a
|
|
1684
|
+
* date in the future still means "not yet", but the window is bounded below by
|
|
1685
|
+
* the newest workspace observation (workspaceReadInstant), so a machine clock that
|
|
1686
|
+
* stepped backwards cannot hide a note the vault already holds.
|
|
1687
|
+
* Two `?` parameters, the read instant, are the note-derived window bounds.
|
|
1688
|
+
*/
|
|
1689
|
+
const DEFAULT_CURRENT_SQL = `((source_path = '${exports.LIFECYCLE_NOTE_PATH}' AND valid_to IS NULL)`
|
|
1690
|
+
+ ` OR ((source_path IS NULL OR source_path <> '${exports.LIFECYCLE_NOTE_PATH}')`
|
|
1691
|
+
+ ` AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)))`;
|
|
1692
|
+
/**
|
|
1693
|
+
* Current facts only by default; asOf gives point-in-time; includeHistory gives
|
|
1694
|
+
* everything. The default read judges a lifecycle fact by open rows (see
|
|
1695
|
+
* DEFAULT_CURRENT_SQL), so a backward clock step cannot hide the current value;
|
|
1696
|
+
* a note-derived fact is judged at the wall clock clamped up to the newest
|
|
1697
|
+
* workspace observation (workspaceReadInstant), so the same step cannot hide it
|
|
1698
|
+
* either; asOf keeps the exact point-in-time window. Lifecycle-owned facts are
|
|
1699
|
+
* authoritative: if a lifecycle entity exists for the name, its facts are
|
|
1700
|
+
* returned; otherwise the generic (possibly note-derived) entity lookup is used.
|
|
1701
|
+
*
|
|
1702
|
+
* When a name still matches SEVERAL lifecycle entities (a split a pre-fix write path
|
|
1703
|
+
* could create, which nothing reconciles in place), facts from ALL of them are
|
|
1704
|
+
* returned. A read must not throw — a startup or dashboard read would become an outage
|
|
1705
|
+
* — and it must not hide half the memory behind a silent pick, so the ambiguity is
|
|
1706
|
+
* disclosed instead. New writes refuse the same state (LegacyIdentityAmbiguityError).
|
|
1707
|
+
*/
|
|
1708
|
+
function factsFor(db, entityName, opts = {}) {
|
|
1709
|
+
let entityIds = [];
|
|
1710
|
+
for (const alias of (0, queryHelpers_1.identityNames)(entityName)) {
|
|
1711
|
+
entityIds = lifecycleEntityMatches(db, alias).map(match => match.id);
|
|
1712
|
+
if (entityIds.length > 0)
|
|
1713
|
+
break;
|
|
1714
|
+
}
|
|
1715
|
+
if (entityIds.length === 0) {
|
|
1716
|
+
const entity = (0, entityLookup_1.lookupEntity)(db, entityName, NOT_LIFECYCLE_ENTITY);
|
|
1717
|
+
if (!entity)
|
|
1718
|
+
return [];
|
|
1719
|
+
entityIds = [entity.id];
|
|
1720
|
+
}
|
|
1721
|
+
const placeholders = entityIds.map(() => '?').join(',');
|
|
1722
|
+
// Without scope: return across all scopes (current MVP read behavior).
|
|
1723
|
+
// With scope: filter to that normalized scope, under any spelling of it (savedScopeSqlPredicate).
|
|
1724
|
+
const scopeFilter = scopeFilterFor(opts.scope);
|
|
1725
|
+
const inScope = scopeFilter ? (0, scope_1.savedScopeSqlPredicate)(scopeFilter, 'scope') : null;
|
|
1726
|
+
if (opts.includeHistory) {
|
|
1727
|
+
if (inScope) {
|
|
1728
|
+
return db.prepare(`SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND ${inScope.sql} ORDER BY valid_from DESC, id DESC`).all(...entityIds, ...inScope.params);
|
|
1729
|
+
}
|
|
1730
|
+
return db.prepare(`SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) ORDER BY valid_from DESC, id DESC`).all(...entityIds);
|
|
1731
|
+
}
|
|
1732
|
+
// S1: asOf is an INSTANT. It is normalized to the canonical format so the
|
|
1733
|
+
// SQL textual comparison is exactly the instant comparison for every
|
|
1734
|
+
// supported input (including offsets); garbage fails with a clear error
|
|
1735
|
+
// instead of a silently wrong answer. An explicit point-in-time read keeps
|
|
1736
|
+
// the window unchanged: it asks what was true AT that instant, not what is
|
|
1737
|
+
// true now.
|
|
1738
|
+
if (opts.asOf !== undefined) {
|
|
1739
|
+
const t = toCanonicalInstant(opts.asOf);
|
|
1740
|
+
if (inScope) {
|
|
1741
|
+
return (0, factTerms_1.prepareOnce)(db, `SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND ${inScope.sql} AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) ORDER BY valid_from DESC, id DESC`).all(...entityIds, ...inScope.params, t, t);
|
|
1742
|
+
}
|
|
1743
|
+
return (0, factTerms_1.prepareOnce)(db, `SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) ORDER BY valid_from DESC, id DESC`).all(...entityIds, t, t);
|
|
1744
|
+
}
|
|
1745
|
+
// The default read judges a lifecycle fact (source_path '_mcp_') the way the
|
|
1746
|
+
// write path does (streamCurrentFacts): current exactly when it is open, so a
|
|
1747
|
+
// backward clock step can never hide the value the user already gave — the
|
|
1748
|
+
// repeat-twice failure. Note-derived facts keep their content-date window — a
|
|
1749
|
+
// future date still means "not yet" — bounded below by the newest workspace
|
|
1750
|
+
// observation, so the same step cannot hide a note the vault already holds either.
|
|
1751
|
+
const readInstant = noteReadInstant(db, scopeFilter);
|
|
1752
|
+
if (inScope) {
|
|
1753
|
+
return (0, factTerms_1.prepareOnce)(db, `SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND ${inScope.sql} AND ${DEFAULT_CURRENT_SQL} ORDER BY valid_from DESC, id DESC`).all(...entityIds, ...inScope.params, readInstant, readInstant);
|
|
1754
|
+
}
|
|
1755
|
+
return (0, factTerms_1.prepareOnce)(db, `SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND ${DEFAULT_CURRENT_SQL} ORDER BY valid_from DESC, id DESC`).all(...entityIds, readInstant, readInstant);
|
|
1756
|
+
}
|