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,57 @@
|
|
|
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.syncDirectory = syncDirectory;
|
|
7
|
+
exports.withAppendLock = withAppendLock;
|
|
8
|
+
exports.markUnrecoverable = markUnrecoverable;
|
|
9
|
+
exports.unrecoverableReason = unrecoverableReason;
|
|
10
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
/**
|
|
13
|
+
* What the canonical Markdown and ledger appends and the start-up replay share (adv-crashsafe-1012, F1-F3). The append
|
|
14
|
+
* itself is `appendConfined` (confinedWrite.ts): one function that keeps a write inside the vault AND makes it durable, so
|
|
15
|
+
* a failed write leaves no bytes behind and an acknowledged write survives a power cut.
|
|
16
|
+
*
|
|
17
|
+
* Appends to one file run one at a time (withAppendLock), so the length a failed write is cut back to is still the length
|
|
18
|
+
* its own bytes started at: no other append can land in between and be removed with it. A cut-back that itself fails
|
|
19
|
+
* leaves the file refused for the rest of this process (markUnrecoverable), so no later append can make the fragment look
|
|
20
|
+
* whole; a restart sets that tail aside (replay).
|
|
21
|
+
*/
|
|
22
|
+
/** Flush a directory so that a file created in it is still there after a power cut. */
|
|
23
|
+
async function syncDirectory(dir) {
|
|
24
|
+
const handle = await promises_1.default.open(dir, 'r');
|
|
25
|
+
try {
|
|
26
|
+
await handle.sync();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await handle.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const queues = new Map();
|
|
33
|
+
/**
|
|
34
|
+
* Runs `fn` after every earlier call for the same file has finished, and before any later one starts. In-process only:
|
|
35
|
+
* two engine processes on one workspace are not serialized by it (not covered by adv-crashsafe-1012 F6 either).
|
|
36
|
+
*/
|
|
37
|
+
function withAppendLock(file, fn) {
|
|
38
|
+
const key = path_1.default.resolve(file);
|
|
39
|
+
const run = (queues.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
40
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
41
|
+
queues.set(key, settled);
|
|
42
|
+
void settled.then(() => {
|
|
43
|
+
if (queues.get(key) === settled)
|
|
44
|
+
queues.delete(key);
|
|
45
|
+
});
|
|
46
|
+
return run;
|
|
47
|
+
}
|
|
48
|
+
/** Files whose last failed write could not be cut back: refused until the process restarts (replay then sets it aside). */
|
|
49
|
+
const unrecoverable = new Map();
|
|
50
|
+
/** Record that `file` holds the bytes of a failed write that could not be cut back. */
|
|
51
|
+
function markUnrecoverable(file, reason) {
|
|
52
|
+
unrecoverable.set(path_1.default.resolve(file), reason);
|
|
53
|
+
}
|
|
54
|
+
/** Why `file` is refused for appends in this process, or `undefined` when it is not. */
|
|
55
|
+
function unrecoverableReason(file) {
|
|
56
|
+
return unrecoverable.get(path_1.default.resolve(file));
|
|
57
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_DRIFT_VALUES = void 0;
|
|
4
|
+
exports.possibleDuplicates = possibleDuplicates;
|
|
5
|
+
exports.namedConflicts = namedConflicts;
|
|
6
|
+
const rowStore_1 = require("../context/rowStore");
|
|
7
|
+
const queryHelpers_1 = require("../retrieval/queryHelpers");
|
|
8
|
+
const factTerms_1 = require("./factTerms");
|
|
9
|
+
const scope_1 = require("../mvp/scope");
|
|
10
|
+
const sourceName_1 = require("./sourceName");
|
|
11
|
+
/**
|
|
12
|
+
* Multi-agent drift: current lifecycle facts that may be one thing, named, never merged.
|
|
13
|
+
*
|
|
14
|
+
* A correction closes only the current fact of the same subject (one identity: case and Unicode form folded),
|
|
15
|
+
* predicate and scope (any spelling of it, scopeSpellings). When several agents write, the same thing gets saved under another name (Codex writes
|
|
16
|
+
* "Atlas project", Claude Code writes "Atlas") or in another scope (a correction saved without the project's scope
|
|
17
|
+
* goes to global), and then both values stay current. This module supersedes nothing across subjects or scopes.
|
|
18
|
+
* It names what may be one thing, so the agent or the user can decide:
|
|
19
|
+
*
|
|
20
|
+
* - `possibleDuplicates`, on a fact save's receipt: the other current facts related to the one just written;
|
|
21
|
+
* - `conflicts`, on a read: groups of related current facts whose values disagree, for the facts the read returns.
|
|
22
|
+
*
|
|
23
|
+
* Two facts are related when they have the same predicate and their subjects are one identity (`identityKey`), or
|
|
24
|
+
* one name is the other with one more word before or after it: "Atlas" and "Atlas project", "staging database" and
|
|
25
|
+
* "Atlas staging database". A name longer by more words ("Atlas service shipments") or a possessive ("Ravi's
|
|
26
|
+
* manager", "user's mother") names another thing. Values are compared after `normDedupeValue`, so "Sao Paulo" and
|
|
27
|
+
* "São Paulo" do not disagree.
|
|
28
|
+
*
|
|
29
|
+
* Scope stays an isolation boundary. A call made in a scope sees that scope's facts only, as its reads do
|
|
30
|
+
* (`scopeFilterFor`), so it is never told what another scope holds. A call without a scope sees every scope, so it
|
|
31
|
+
* is the one told that a subject and predicate have another current value elsewhere.
|
|
32
|
+
*
|
|
33
|
+
* Cost: one pass over the current lifecycle facts that reads the subject names of those with an asked predicate, then
|
|
34
|
+
* the facts whose name passes a word test in full. Measured with 12 asked facts, 6 of them sharing their predicate with
|
|
35
|
+
* a quarter of the facts (host load about 45): 0.4 ms at 150 current facts, 2.4 ms at 2,000, 17 ms at 20,000. Nothing
|
|
36
|
+
* is written and nothing is embedded.
|
|
37
|
+
*/
|
|
38
|
+
/** The most values a conflict, or a receipt's duplicates, lists: the newest first, with the total when there are more. */
|
|
39
|
+
exports.MAX_DRIFT_VALUES = 8;
|
|
40
|
+
/** A folded word that ends in an apostrophe and s ("ravi's", "ravi’s") or in s and an apostrophe ("users'"). */
|
|
41
|
+
const POSSESSIVE = /[\p{L}\p{N}](?:['’]s|s['’])(?=\s|$)/u;
|
|
42
|
+
/** The fold identityKey applies before its alias table: NFC, trimmed, lower-cased. */
|
|
43
|
+
function fold(name) {
|
|
44
|
+
return name.normalize('NFC').trim().toLowerCase().normalize('NFC');
|
|
45
|
+
}
|
|
46
|
+
/** What separates two words of a folded name. A word keeps its hyphens and underscores: "atl-412" is one word. */
|
|
47
|
+
const NOT_A_WORD = /[^\p{L}\p{N}\p{M}_-]+/u;
|
|
48
|
+
/** The words of a subject name, by name: a read reads the name of every current fact with an asked predicate, and the names are the same read after read. */
|
|
49
|
+
const wordsOfName = new Map();
|
|
50
|
+
const MAX_WORDS_OF_NAME = 8192;
|
|
51
|
+
function wordsOf(subject) {
|
|
52
|
+
let words = wordsOfName.get(subject);
|
|
53
|
+
if (words === undefined) {
|
|
54
|
+
const folded = fold(subject);
|
|
55
|
+
const split = POSSESSIVE.test(folded) ? [] : folded.split(NOT_A_WORD).filter(Boolean);
|
|
56
|
+
words = split.length > 0 ? split : null;
|
|
57
|
+
if (wordsOfName.size >= MAX_WORDS_OF_NAME)
|
|
58
|
+
wordsOfName.clear();
|
|
59
|
+
wordsOfName.set(subject, words);
|
|
60
|
+
}
|
|
61
|
+
return words;
|
|
62
|
+
}
|
|
63
|
+
function nameOf(subject, names) {
|
|
64
|
+
let name = names.get(subject);
|
|
65
|
+
if (!name) {
|
|
66
|
+
name = { subject, words: wordsOf(subject) };
|
|
67
|
+
names.set(subject, name);
|
|
68
|
+
}
|
|
69
|
+
return name;
|
|
70
|
+
}
|
|
71
|
+
/** Whether `longer` is `shorter` plus one word at its start or its end. */
|
|
72
|
+
function oneWordLonger(longer, shorter) {
|
|
73
|
+
if (longer.length !== shorter.length + 1)
|
|
74
|
+
return false;
|
|
75
|
+
const same = (offset) => shorter.every((word, i) => longer[i + offset] === word);
|
|
76
|
+
return same(0) || same(1);
|
|
77
|
+
}
|
|
78
|
+
function related(a, b) {
|
|
79
|
+
if ((a.key ??= (0, queryHelpers_1.identityKey)(a.subject)) === (b.key ??= (0, queryHelpers_1.identityKey)(b.subject)))
|
|
80
|
+
return true;
|
|
81
|
+
if (!a.words || !b.words)
|
|
82
|
+
return false;
|
|
83
|
+
return oneWordLonger(a.words, b.words) || oneWordLonger(b.words, a.words);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The current lifecycle facts, in one scope or (null) every scope, newest first, that have the predicate of an asked
|
|
87
|
+
* fact and a subject that may be related to its subject; `related` then decides. A related name has the asked name's
|
|
88
|
+
* first word first, or its last word last: it is the same words, or one more, or one fewer at its start or end. So
|
|
89
|
+
* one pass reads the subject name of every current fact with an asked predicate, and only the facts whose name passes
|
|
90
|
+
* that test are read in full. A possessive asked name is related by identity alone, so it is looked for whole; an
|
|
91
|
+
* alias is looked for by each of its spellings. The partial index of current lifecycle facts holds the predicate, so
|
|
92
|
+
* superseded facts are never visited: `INDEXED BY` states it, because the planner would otherwise take the
|
|
93
|
+
* source_path index, which holds every lifecycle fact ever saved.
|
|
94
|
+
*/
|
|
95
|
+
function candidates(db, asked, scope, names) {
|
|
96
|
+
const firsts = new Set();
|
|
97
|
+
const lasts = new Set();
|
|
98
|
+
const wholes = new Set();
|
|
99
|
+
for (const { subject } of asked) {
|
|
100
|
+
for (const spelling of (0, queryHelpers_1.identityNames)(subject)) {
|
|
101
|
+
const { words } = nameOf(spelling, names);
|
|
102
|
+
if (words) {
|
|
103
|
+
firsts.add(words[0]);
|
|
104
|
+
lasts.add(words[words.length - 1]);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
wholes.add(fold(spelling));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const inScope = scope === null ? null : (0, scope_1.savedScopeSqlPredicate)((0, scope_1.normalizeScope)(scope), 'f.scope'); // any spelling of it
|
|
112
|
+
const named = (0, factTerms_1.prepareOnce)(db, `SELECT f.id, e.canonical_name
|
|
113
|
+
FROM facts f INDEXED BY idx_facts_current
|
|
114
|
+
JOIN entities e ON e.id = f.subject_entity_id
|
|
115
|
+
WHERE f.valid_to IS NULL AND f.source_path = '_mcp_'
|
|
116
|
+
AND f.predicate IN (SELECT value FROM json_each(?))${inScope ? `
|
|
117
|
+
AND ${inScope.sql}` : ''}`).raw().all(JSON.stringify([...new Set(asked.map(fact => fact.predicate))]), ...(inScope?.params ?? []));
|
|
118
|
+
const ids = named.filter(([, subject]) => {
|
|
119
|
+
const { words } = nameOf(subject, names);
|
|
120
|
+
return words ? firsts.has(words[0]) || lasts.has(words[words.length - 1]) : wholes.has(fold(subject));
|
|
121
|
+
}).map(([id]) => id);
|
|
122
|
+
if (ids.length === 0)
|
|
123
|
+
return [];
|
|
124
|
+
return (0, factTerms_1.prepareOnce)(db, `SELECT f.id AS id, e.canonical_name AS subject, f.predicate AS predicate, COALESCE(f.object_text, '') AS object,
|
|
125
|
+
f.scope AS scope, f.valid_from AS validFrom, f.author AS author
|
|
126
|
+
FROM json_each(?) c
|
|
127
|
+
CROSS JOIN facts f ON f.id = c.value
|
|
128
|
+
JOIN entities e ON e.id = f.subject_entity_id
|
|
129
|
+
ORDER BY f.valid_from DESC, f.id DESC`).all(JSON.stringify(ids));
|
|
130
|
+
}
|
|
131
|
+
/** A value as a read shows it: who saved it, when it is another agent's or an import's (memory/sourceName.ts). */
|
|
132
|
+
function shown(row, reader) {
|
|
133
|
+
return { subject: row.subject, object: row.object, scope: row.scope, validFrom: row.validFrom, ...(0, sourceName_1.authorMarker)(row.author, reader) };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The receipt of a fact save: the other current facts with its predicate whose subject is related to the one written,
|
|
137
|
+
* newest first. `factId` is the fact the save left current (it is not its own duplicate); `scope` is the save's scope
|
|
138
|
+
* filter (null when the call named no scope).
|
|
139
|
+
*/
|
|
140
|
+
function possibleDuplicates(db, written, factId, scope, reader) {
|
|
141
|
+
const names = new Map();
|
|
142
|
+
const asked = nameOf(written.subject, names);
|
|
143
|
+
const found = candidates(db, [written], scope, names)
|
|
144
|
+
.filter(row => row.id !== factId && related(asked, nameOf(row.subject, names)));
|
|
145
|
+
return { values: found.slice(0, exports.MAX_DRIFT_VALUES).map(row => shown(row, reader)), total: found.length };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The conflicts behind the current lifecycle facts a read returns: for each of them, the current facts related to it
|
|
149
|
+
* (its own row included); every group that holds two different values is one conflict. Groups that share a fact are
|
|
150
|
+
* one conflict. Conflicts come in the order of the first fact that names them. `scope` is the read's scope filter
|
|
151
|
+
* (null when the read named no scope).
|
|
152
|
+
*/
|
|
153
|
+
function namedConflicts(db, facts, scope, reader) {
|
|
154
|
+
if (facts.length === 0)
|
|
155
|
+
return [];
|
|
156
|
+
const names = new Map();
|
|
157
|
+
const rows = candidates(db, facts, scope, names);
|
|
158
|
+
if (rows.length < 2)
|
|
159
|
+
return [];
|
|
160
|
+
const parent = rows.map((_, i) => i);
|
|
161
|
+
const root = (i) => {
|
|
162
|
+
while (parent[i] !== i)
|
|
163
|
+
i = parent[i] = parent[parent[i]];
|
|
164
|
+
return i;
|
|
165
|
+
};
|
|
166
|
+
const matched = new Set(); // rows related to a fact the read returned; only these are ever joined
|
|
167
|
+
const firstOfEach = []; // one row of each returned fact's group, in the order the read returned the facts
|
|
168
|
+
for (const fact of facts) {
|
|
169
|
+
const asked = nameOf(fact.subject, names);
|
|
170
|
+
let first = -1;
|
|
171
|
+
rows.forEach((row, i) => {
|
|
172
|
+
if (row.predicate !== fact.predicate || !related(asked, nameOf(row.subject, names)))
|
|
173
|
+
return;
|
|
174
|
+
matched.add(i);
|
|
175
|
+
if (first < 0)
|
|
176
|
+
first = i;
|
|
177
|
+
else
|
|
178
|
+
parent[root(i)] = root(first);
|
|
179
|
+
});
|
|
180
|
+
if (first >= 0)
|
|
181
|
+
firstOfEach.push(first);
|
|
182
|
+
}
|
|
183
|
+
const groups = new Map();
|
|
184
|
+
for (const i of [...matched].sort((a, b) => a - b)) { // row order: newest first
|
|
185
|
+
const group = groups.get(root(i));
|
|
186
|
+
if (group)
|
|
187
|
+
group.push(rows[i]);
|
|
188
|
+
else
|
|
189
|
+
groups.set(root(i), [rows[i]]);
|
|
190
|
+
}
|
|
191
|
+
const conflicts = [];
|
|
192
|
+
for (const r of new Set(firstOfEach.map(root))) {
|
|
193
|
+
const group = groups.get(r);
|
|
194
|
+
if (new Set(group.map(row => (0, rowStore_1.normDedupeValue)(row.object))).size < 2)
|
|
195
|
+
continue;
|
|
196
|
+
conflicts.push({
|
|
197
|
+
predicate: group[0].predicate,
|
|
198
|
+
values: group.slice(0, exports.MAX_DRIFT_VALUES).map(row => shown(row, reader)),
|
|
199
|
+
...(group.length > exports.MAX_DRIFT_VALUES ? { total: group.length } : {}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return conflicts;
|
|
203
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_COMMON_WORDS = exports.RECENT_WINDOW = exports.COMPLETE_POSTINGS = exports.prepareOnce = void 0;
|
|
4
|
+
exports.words = words;
|
|
5
|
+
exports.atoms = atoms;
|
|
6
|
+
exports.atomSequence = atomSequence;
|
|
7
|
+
exports.canonicalSubject = canonicalSubject;
|
|
8
|
+
exports.factVocabulary = factVocabulary;
|
|
9
|
+
exports.indexFactTerms = indexFactTerms;
|
|
10
|
+
exports.unindexFactTerms = unindexFactTerms;
|
|
11
|
+
exports.ensureFactTerms = ensureFactTerms;
|
|
12
|
+
exports.statementTerms = statementTerms;
|
|
13
|
+
exports.findCurrentFacts = findCurrentFacts;
|
|
14
|
+
exports.newestFactEvent = newestFactEvent;
|
|
15
|
+
exports.currentFactById = currentFactById;
|
|
16
|
+
exports.recentCurrentFacts = recentCurrentFacts;
|
|
17
|
+
const statements_1 = require("../db/statements");
|
|
18
|
+
Object.defineProperty(exports, "prepareOnce", { enumerable: true, get: function () { return statements_1.prepareOnce; } });
|
|
19
|
+
const scope_1 = require("../mvp/scope");
|
|
20
|
+
/**
|
|
21
|
+
* The words a lifecycle fact is found by, and the index that finds facts by them.
|
|
22
|
+
*
|
|
23
|
+
* `fact_terms` is an inverted index over the facts table: one row per (word, scope, fact) for every
|
|
24
|
+
* lifecycle fact, current or superseded, written by the transaction that writes the fact. A search
|
|
25
|
+
* reads the rows of the query's words and joins only the facts they name, so a fact is found by its
|
|
26
|
+
* words however old it is, and the work a read does is bounded (below), not a function of the table.
|
|
27
|
+
*
|
|
28
|
+
* How far a read goes down a word's rows. A word is *selective* when at most COMPLETE_POSTINGS rows
|
|
29
|
+
* of the scope read have it (the rows of superseded facts count): its rows are read completely, so
|
|
30
|
+
* every current fact that has it is found, however old, and is counted exactly, however many other
|
|
31
|
+
* selective words the query has. A word with more rows is *common*: its rows are not read, and the
|
|
32
|
+
* facts it finds are those among the RECENT_WINDOW newest current facts of the scope. So a table of
|
|
33
|
+
* at most COMPLETE_POSTINGS facts is always read exactly, and so is any query with a selective word.
|
|
34
|
+
* What is given up, and all of it: a fact that has none of the query's selective words and is older
|
|
35
|
+
* than the newest RECENT_WINDOW facts is not found through common words alone, and an any-words read
|
|
36
|
+
* ranks by at most MAX_COMMON_WORDS common words. Every candidate is ranked by the number of the
|
|
37
|
+
* query's words it has.
|
|
38
|
+
*
|
|
39
|
+
* The work is bounded by the query, not by the table: a word costs at most COMPLETE_POSTINGS + 1
|
|
40
|
+
* rows to classify, and a read collects at most COMPLETE_POSTINGS facts per selective word, so at
|
|
41
|
+
* most (words in the query) x COMPLETE_POSTINGS candidates, plus RECENT_WINDOW; the tools cap a query
|
|
42
|
+
* at 1,000 characters. Nothing is dropped to keep it so.
|
|
43
|
+
*
|
|
44
|
+
* It is derived data, like the rest of index.db: `ensureFactTerms` indexes whatever is missing (a
|
|
45
|
+
* database from before the index, or one a release without it has written to). Rows are never
|
|
46
|
+
* removed: a superseded fact keeps its rows, and the join to `facts` filters it out as current.
|
|
47
|
+
*
|
|
48
|
+
* No code deletes a lifecycle fact (a correction closes it). Code that ever does must delete its
|
|
49
|
+
* rows here too, or the words of an erased fact stay in index.db. The table has no foreign key to
|
|
50
|
+
* `facts` on purpose: deleting a note's facts would then scan this whole table once per fact.
|
|
51
|
+
*/
|
|
52
|
+
/** The words of `value` in the order they occur, repeats included. */
|
|
53
|
+
function tokens(value) {
|
|
54
|
+
return value
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^\p{L}\p{N}\p{M}_-]+/gu, ' ')
|
|
57
|
+
.split(/\s+/)
|
|
58
|
+
.map(w => w.trim())
|
|
59
|
+
.filter(w => w.length >= 2);
|
|
60
|
+
}
|
|
61
|
+
function words(value) {
|
|
62
|
+
return Array.from(new Set(tokens(value)));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Like `words`, but slugs and snake_case split into their parts, the way the full-text index
|
|
66
|
+
* reads them: `renor-memory-1002` is renor, memory and 1002.
|
|
67
|
+
*/
|
|
68
|
+
function atoms(value) {
|
|
69
|
+
return Array.from(new Set(atomSequence(value)));
|
|
70
|
+
}
|
|
71
|
+
/** `atoms` in the order they occur, repeats included: which words are next to each other. */
|
|
72
|
+
function atomSequence(value) {
|
|
73
|
+
return tokens(value.replace(/[-_]+/g, ' '));
|
|
74
|
+
}
|
|
75
|
+
function predicateWords(predicate) {
|
|
76
|
+
const out = new Set();
|
|
77
|
+
const irregularBases = {
|
|
78
|
+
does: 'do',
|
|
79
|
+
has: 'have',
|
|
80
|
+
};
|
|
81
|
+
for (const word of atoms(predicate)) {
|
|
82
|
+
out.add(word);
|
|
83
|
+
// Relation keys commonly use third-person singular verbs while users ask
|
|
84
|
+
// with the base form. Keep this intentionally bounded so predicate nouns
|
|
85
|
+
// such as "status" do not get arbitrary stems.
|
|
86
|
+
const irregular = irregularBases[word];
|
|
87
|
+
if (irregular) {
|
|
88
|
+
out.add(irregular);
|
|
89
|
+
}
|
|
90
|
+
else if (/^[a-z]+ies$/.test(word) && word.length >= 5) {
|
|
91
|
+
out.add(`${word.slice(0, -3)}y`);
|
|
92
|
+
}
|
|
93
|
+
else if (/^[a-z]+(ches|shes|xes|zes|oes|sses)$/.test(word)) {
|
|
94
|
+
out.add(word.slice(0, -2));
|
|
95
|
+
}
|
|
96
|
+
else if (/^[a-z]+s$/.test(word)
|
|
97
|
+
&& word.length >= 4
|
|
98
|
+
&& !word.endsWith('ss')
|
|
99
|
+
&& !word.endsWith('us')
|
|
100
|
+
&& !word.endsWith('is')) {
|
|
101
|
+
out.add(word.slice(0, -1));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return Array.from(out);
|
|
105
|
+
}
|
|
106
|
+
/** The name a read shows for a subject: the entity's, which may be an earlier spelling of an event's. */
|
|
107
|
+
function canonicalSubject(db, entityId) {
|
|
108
|
+
return (0, statements_1.prepareOnce)(db, 'SELECT canonical_name FROM entities WHERE id = ?').get(entityId).canonical_name;
|
|
109
|
+
}
|
|
110
|
+
/** Every word a fact is found by: its subject and object, and its predicate with common verb forms. */
|
|
111
|
+
function factVocabulary(fact) {
|
|
112
|
+
return new Set([...atoms(`${fact.subject} ${fact.object}`), ...predicateWords(fact.predicate)]);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Indexes one fact. The empty term is a marker, not a word (a query word is never empty): it says
|
|
116
|
+
* the fact has been indexed, so one whose text has no words is not indexed again and again.
|
|
117
|
+
*/
|
|
118
|
+
function indexFactTerms(db, factId, scope, fact) {
|
|
119
|
+
(0, statements_1.prepareOnce)(db, `INSERT OR IGNORE INTO fact_terms (term, scope, fact_id) SELECT value, ?, ? FROM json_each(?)`)
|
|
120
|
+
.run(scope, factId, JSON.stringify(['', ...factVocabulary(fact)]));
|
|
121
|
+
}
|
|
122
|
+
/** Removes what indexFactTerms wrote for one fact (a retired fact: memory/lifecycle.ts). */
|
|
123
|
+
function unindexFactTerms(db, factId, scope, fact) {
|
|
124
|
+
(0, statements_1.prepareOnce)(db, `DELETE FROM fact_terms WHERE scope = ? AND fact_id = ? AND term IN (SELECT value FROM json_each(?))`)
|
|
125
|
+
.run(scope, factId, JSON.stringify(['', ...factVocabulary(fact)]));
|
|
126
|
+
}
|
|
127
|
+
/** Indexes every lifecycle fact that is not indexed yet, in one transaction; returns how many. */
|
|
128
|
+
function ensureFactTerms(db) {
|
|
129
|
+
const unindexed = db.prepare(`SELECT f.id AS id, f.scope AS scope, e.canonical_name AS subject, f.predicate AS predicate, f.object_text AS object
|
|
130
|
+
FROM facts f JOIN entities e ON e.id = f.subject_entity_id
|
|
131
|
+
WHERE f.source_path = '_mcp_'
|
|
132
|
+
AND NOT EXISTS (SELECT 1 FROM fact_terms t WHERE t.term = '' AND t.scope = f.scope AND t.fact_id = f.id)`).all();
|
|
133
|
+
if (unindexed.length === 0)
|
|
134
|
+
return 0;
|
|
135
|
+
db.transaction(() => {
|
|
136
|
+
for (const fact of unindexed)
|
|
137
|
+
indexFactTerms(db, fact.id, fact.scope, { ...fact, object: fact.object ?? '' });
|
|
138
|
+
})();
|
|
139
|
+
return unindexed.length;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The words of a fact's saved statement, kept with the fact (`facts.statement_words`) so the facts lane can count them
|
|
143
|
+
* toward a question's words (agentMemory.ts `rankCandidateFacts`). The atoms in the order they occur, space-joined: an
|
|
144
|
+
* atom never holds a space. Null when the fact was saved with no statement (save_memory) or has no word in it.
|
|
145
|
+
*/
|
|
146
|
+
function statementTerms(statement) {
|
|
147
|
+
if (statement === undefined)
|
|
148
|
+
return null;
|
|
149
|
+
const terms = atoms(statement);
|
|
150
|
+
return terms.length > 0 ? terms.join(' ') : null;
|
|
151
|
+
}
|
|
152
|
+
const FACT_COLUMNS = `f.event_id AS eventId, e.canonical_name AS subject, f.predicate AS predicate,
|
|
153
|
+
f.object_text AS object, f.valid_from AS validFrom, f.scope AS scope, f.author AS author,
|
|
154
|
+
f.statement_words AS statementWords`;
|
|
155
|
+
const FACT_MATCH_COLUMNS = `f.id AS id, ${FACT_COLUMNS}`;
|
|
156
|
+
/** A word with at most this many rows (in the scope read) is selective: its rows are read completely. */
|
|
157
|
+
exports.COMPLETE_POSTINGS = 1_000;
|
|
158
|
+
/** A common word is matched among this many of the newest current facts of the scope. */
|
|
159
|
+
exports.RECENT_WINDOW = 500;
|
|
160
|
+
/** An any-words read ranks by at most this many common words (the first in the query); selective words all count. */
|
|
161
|
+
exports.MAX_COMMON_WORDS = 8;
|
|
162
|
+
/**
|
|
163
|
+
* The query's words that some fact has, selective ones first and the rarest of them first. Reads at
|
|
164
|
+
* most COMPLETE_POSTINGS + 1 rows of a word, which is enough to know it is common. With `needAll`,
|
|
165
|
+
* a word no fact has ends the read: nothing can have every word.
|
|
166
|
+
*/
|
|
167
|
+
function readWords(db, terms, scope, needAll) {
|
|
168
|
+
const inScope = scope === undefined ? undefined : (0, scope_1.savedScopeSqlPredicate)((0, scope_1.normalizeScope)(scope), 'scope');
|
|
169
|
+
const read = (0, statements_1.prepareOnce)(db, `SELECT fact_id FROM fact_terms WHERE term = ? ${inScope === undefined ? '' : `AND ${inScope.sql}`} LIMIT ?`).pluck();
|
|
170
|
+
const found = [];
|
|
171
|
+
for (const term of terms) {
|
|
172
|
+
const ids = read.all(term, ...(inScope?.params ?? []), exports.COMPLETE_POSTINGS + 1);
|
|
173
|
+
if (ids.length === 0) {
|
|
174
|
+
if (needAll)
|
|
175
|
+
return null;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const common = ids.length > exports.COMPLETE_POSTINGS;
|
|
179
|
+
found.push({ term, ids: common ? [] : ids, common });
|
|
180
|
+
}
|
|
181
|
+
return found.sort((a, b) => Number(a.common) - Number(b.common) || a.ids.length - b.ids.length);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The rows `read` returns for each spelling of `scope` (scopeSpellings), merged newest first and cut to `limit`.
|
|
185
|
+
* `read` takes a spelling and a limit and returns at most that many rows of a partial index, newest first, so the
|
|
186
|
+
* merge never sorts more than one `limit` per spelling, and a scope with one spelling (global) is returned as read.
|
|
187
|
+
*/
|
|
188
|
+
function newestOfSpellings(read, scope, limit) {
|
|
189
|
+
const spellings = (0, scope_1.scopeSpellings)((0, scope_1.normalizeScope)(scope));
|
|
190
|
+
const rows = spellings.flatMap(spelling => read.all(spelling, limit));
|
|
191
|
+
if (spellings.length === 1)
|
|
192
|
+
return rows;
|
|
193
|
+
return rows
|
|
194
|
+
.sort((a, b) => (a.validFrom === b.validFrom ? b.id - a.id : a.validFrom < b.validFrom ? 1 : -1))
|
|
195
|
+
.slice(0, limit);
|
|
196
|
+
}
|
|
197
|
+
/** The ids of the newest current facts of the scope: `RECENT_WINDOW` rows of a partial index per spelling, in order. */
|
|
198
|
+
function recentFactIds(db, scope) {
|
|
199
|
+
const read = (0, statements_1.prepareOnce)(db, `SELECT f.id AS id, f.valid_from AS validFrom FROM facts f INDEXED BY ${scope === undefined ? 'idx_facts_lifecycle_recent' : 'idx_facts_lifecycle_scope_recent'}
|
|
200
|
+
WHERE f.valid_to IS NULL AND f.source_path = '_mcp_' ${scope === undefined ? '' : 'AND f.scope = ?'}
|
|
201
|
+
ORDER BY f.valid_from DESC, f.id DESC LIMIT ?`);
|
|
202
|
+
const rows = scope === undefined
|
|
203
|
+
? read.all(exports.RECENT_WINDOW)
|
|
204
|
+
: newestOfSpellings(read, scope, exports.RECENT_WINDOW);
|
|
205
|
+
return rows.map(row => row.id);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The current facts found by `terms`, best first: more of the words, then newest. `scope` is one
|
|
209
|
+
* scope when given (a normalized scope, read under every spelling of it: scopeSpellings) and every
|
|
210
|
+
* scope when omitted. Only current lifecycle facts count.
|
|
211
|
+
*
|
|
212
|
+
* `any` ranks the facts that have at least one word; `all` needs every word. How much of a word's
|
|
213
|
+
* rows is read, and what that gives up, is in the header of this file.
|
|
214
|
+
*/
|
|
215
|
+
function findCurrentFacts(db, args) {
|
|
216
|
+
const { scope, match, limit } = args;
|
|
217
|
+
const terms = [...new Set(args.terms)].filter(term => term !== ''); // '' is the marker row, never a word
|
|
218
|
+
if (terms.length === 0 || limit < 1)
|
|
219
|
+
return [];
|
|
220
|
+
const words = readWords(db, terms, scope, match === 'all');
|
|
221
|
+
if (!words || words.length === 0)
|
|
222
|
+
return [];
|
|
223
|
+
const scoped = scope === undefined ? undefined : (0, scope_1.savedScopeSqlPredicate)((0, scope_1.normalizeScope)(scope), 'f.scope');
|
|
224
|
+
const inScope = scoped === undefined ? '' : `AND ${scoped.sql}`;
|
|
225
|
+
const scopeArgs = scoped?.params ?? [];
|
|
226
|
+
if (match === 'any') {
|
|
227
|
+
// Every selective word is read completely; only the common words are matched through the newest
|
|
228
|
+
// facts, and at most MAX_COMMON_WORDS of them are probed.
|
|
229
|
+
const probed = words.filter(word => word.common).slice(0, exports.MAX_COMMON_WORDS);
|
|
230
|
+
// Fact id -> how many selective words it has, counted from the rows already read.
|
|
231
|
+
const candidates = new Map();
|
|
232
|
+
for (const word of words)
|
|
233
|
+
if (!word.common)
|
|
234
|
+
for (const id of word.ids)
|
|
235
|
+
candidates.set(id, (candidates.get(id) ?? 0) + 1);
|
|
236
|
+
if (probed.length > 0)
|
|
237
|
+
for (const id of recentFactIds(db, scope))
|
|
238
|
+
if (!candidates.has(id))
|
|
239
|
+
candidates.set(id, 0);
|
|
240
|
+
// The candidates are ranked on their id, date and count alone; only the `limit` best are joined to
|
|
241
|
+
// their entity and read in full. A window fact that has none of the probed words scores 0 and is dropped.
|
|
242
|
+
return (0, statements_1.prepareOnce)(db, `WITH scored AS MATERIALIZED (
|
|
243
|
+
SELECT f.id AS id, f.valid_from AS validFrom,
|
|
244
|
+
json_extract(c.value, '$[1]')${probed.length === 0 ? '' : `
|
|
245
|
+
+ (SELECT COUNT(*) FROM fact_terms p
|
|
246
|
+
WHERE p.term IN (${probed.map(() => '?').join(', ')}) AND p.scope = f.scope AND p.fact_id = f.id)`} AS shared
|
|
247
|
+
FROM json_each(?) c
|
|
248
|
+
CROSS JOIN facts f ON f.id = json_extract(c.value, '$[0]')
|
|
249
|
+
WHERE f.valid_to IS NULL AND f.source_path = '_mcp_' ${inScope}
|
|
250
|
+
), best AS (
|
|
251
|
+
SELECT id, validFrom, shared FROM scored WHERE shared > 0
|
|
252
|
+
ORDER BY shared DESC, validFrom DESC, id DESC
|
|
253
|
+
LIMIT ?
|
|
254
|
+
)
|
|
255
|
+
SELECT ${FACT_MATCH_COLUMNS}, b.shared AS shared
|
|
256
|
+
FROM best b
|
|
257
|
+
JOIN facts f ON f.id = b.id
|
|
258
|
+
JOIN entities e ON e.id = f.subject_entity_id
|
|
259
|
+
ORDER BY b.shared DESC, b.validFrom DESC, b.id DESC`).all(...probed.map(word => word.term), JSON.stringify([...candidates]), ...scopeArgs, limit);
|
|
260
|
+
}
|
|
261
|
+
// `all`: every fact that has every word has the rarest selective word, so its rows are the
|
|
262
|
+
// candidates and the other words are probed, one candidate at a time. With no selective word, the
|
|
263
|
+
// candidates are the newest facts and every word is probed.
|
|
264
|
+
const driver = words.find(word => !word.common);
|
|
265
|
+
const candidates = driver ? driver.ids : recentFactIds(db, scope);
|
|
266
|
+
const probed = words.filter(word => word !== driver).map(word => word.term);
|
|
267
|
+
return (0, statements_1.prepareOnce)(db, `SELECT ${FACT_MATCH_COLUMNS}, ${terms.length} AS shared
|
|
268
|
+
FROM json_each(?) c
|
|
269
|
+
CROSS JOIN facts f ON f.id = c.value
|
|
270
|
+
JOIN entities e ON e.id = f.subject_entity_id
|
|
271
|
+
WHERE f.valid_to IS NULL AND f.source_path = '_mcp_' ${inScope}
|
|
272
|
+
${probed.map(() => 'AND EXISTS (SELECT 1 FROM fact_terms p WHERE p.term = ? AND p.scope = f.scope AND p.fact_id = f.id)').join('\n ')}
|
|
273
|
+
ORDER BY f.valid_from DESC, f.id DESC
|
|
274
|
+
LIMIT ?`).all(JSON.stringify(candidates), ...scopeArgs, ...probed, limit);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The ledger event that wrote the newest lifecycle fact (current or superseded) of one scope, or of
|
|
278
|
+
* every scope when none is given. A scope's newest fact is the last row of its marker in the term
|
|
279
|
+
* index, one seek per spelling of the scope (scopeSpellings); without a scope it is the last row of
|
|
280
|
+
* the facts table.
|
|
281
|
+
*/
|
|
282
|
+
function newestFactEvent(db, scope) {
|
|
283
|
+
if (scope === undefined) {
|
|
284
|
+
const row = (0, statements_1.prepareOnce)(db, `SELECT event_id AS eventId FROM facts WHERE source_path = '_mcp_' AND event_id IS NOT NULL ORDER BY id DESC LIMIT 1`).get();
|
|
285
|
+
return row?.eventId;
|
|
286
|
+
}
|
|
287
|
+
const read = (0, statements_1.prepareOnce)(db, `SELECT t.fact_id AS id, f.event_id AS eventId
|
|
288
|
+
FROM fact_terms t CROSS JOIN facts f ON f.id = t.fact_id
|
|
289
|
+
WHERE t.term = '' AND t.scope = ? AND f.source_path = '_mcp_' AND f.event_id IS NOT NULL
|
|
290
|
+
ORDER BY t.fact_id DESC LIMIT 1`);
|
|
291
|
+
let newest;
|
|
292
|
+
for (const spelling of (0, scope_1.scopeSpellings)((0, scope_1.normalizeScope)(scope))) {
|
|
293
|
+
const row = read.get(spelling);
|
|
294
|
+
if (row && (!newest || row.id > newest.id))
|
|
295
|
+
newest = row;
|
|
296
|
+
}
|
|
297
|
+
return newest?.eventId;
|
|
298
|
+
}
|
|
299
|
+
/** One current lifecycle fact by its row id; undefined once it is superseded (or was never a lifecycle fact). */
|
|
300
|
+
function currentFactById(db, id) {
|
|
301
|
+
return (0, statements_1.prepareOnce)(db, `SELECT ${FACT_COLUMNS}
|
|
302
|
+
FROM facts f JOIN entities e ON e.id = f.subject_entity_id
|
|
303
|
+
WHERE f.id = ? AND f.valid_to IS NULL AND f.source_path = '_mcp_'`).get(id);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The newest current facts, for a read with no query. The partial indexes (migration 14) keep them
|
|
307
|
+
* in order, so this reads `limit` rows however many facts there are; `INDEXED BY` states that
|
|
308
|
+
* intent, because the planner would otherwise pick the source_path index and sort every fact.
|
|
309
|
+
*/
|
|
310
|
+
function recentCurrentFacts(db, scope, limit) {
|
|
311
|
+
const read = db.prepare(`SELECT ${FACT_COLUMNS}, f.id AS id
|
|
312
|
+
FROM facts f INDEXED BY ${scope === undefined ? 'idx_facts_lifecycle_recent' : 'idx_facts_lifecycle_scope_recent'}
|
|
313
|
+
JOIN entities e ON e.id = f.subject_entity_id
|
|
314
|
+
WHERE f.valid_to IS NULL AND f.source_path = '_mcp_' ${scope === undefined ? '' : 'AND f.scope = ?'}
|
|
315
|
+
ORDER BY f.valid_from DESC, f.id DESC
|
|
316
|
+
LIMIT ?`);
|
|
317
|
+
const rows = scope === undefined
|
|
318
|
+
? read.all(limit)
|
|
319
|
+
: newestOfSpellings(read, scope, limit);
|
|
320
|
+
return rows.map(({ id: _id, ...fact }) => fact); // the row id only orders the merge
|
|
321
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const { createHash } = require('node:crypto');
|
|
2
|
+
|
|
3
|
+
/** The shared, HTTP-header-safe first stage of every connection's hive name. */
|
|
4
|
+
exports.hiveNameHeader = function hiveNameHeader(label) {
|
|
5
|
+
const raw = typeof label === 'string' ? label.normalize('NFC').replace(/\s+/g, ' ').trim() : '';
|
|
6
|
+
if (raw === '') return 'unnamed';
|
|
7
|
+
const text = raw.replace(/[^\t\x20-\x7e\xa0-\xff]/g, '').replace(/\s+/g, ' ').trim();
|
|
8
|
+
if (text === '') return `agent-${createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 8)}`;
|
|
9
|
+
return text.slice(0, 80);
|
|
10
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ledgerNotePath = ledgerNotePath;
|
|
4
|
+
exports.countEntities = countEntities;
|
|
5
|
+
exports.countInventory = countInventory;
|
|
6
|
+
const lifecycle_1 = require("./lifecycle");
|
|
7
|
+
const entityLookup_1 = require("../retrieval/entityLookup");
|
|
8
|
+
/**
|
|
9
|
+
* The ledger file as the index stores its path (the vault walk joins from the same resolved directory), or null when
|
|
10
|
+
* the configured path cannot name a file. Null excludes nothing: the SQL below reads it as `IS NOT NULL`.
|
|
11
|
+
*/
|
|
12
|
+
function ledgerNotePath(vault) {
|
|
13
|
+
try {
|
|
14
|
+
return (0, lifecycle_1.resolveLedgerPath)(vault.memoryDir, vault.ledgerRelPath);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function scalar(db, sql, ...params) {
|
|
21
|
+
return db.prepare(sql).get(...params).c;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Entities without the one the index registers for the ledger file. The subjects of lifecycle facts (source note
|
|
25
|
+
* `_mcp_`), while one of their facts is not retired, and the entity of every other note count. `filter` narrows the count to one scope (entityScopeFilter), so a
|
|
26
|
+
* count handed to a caller who may read only that scope is never workspace-wide. lookup_entity reports this same count
|
|
27
|
+
* when it finds nothing, so it cannot name a different number of entities than memory_status.
|
|
28
|
+
*/
|
|
29
|
+
function countEntities(db, vault, filter) {
|
|
30
|
+
const where = ['source_note IS NOT ?'];
|
|
31
|
+
const params = [ledgerNotePath(vault)];
|
|
32
|
+
// Without a scope, every scope's (liveEntityFilter): the count lookup_entity reports with no scope.
|
|
33
|
+
const narrowed = filter ?? (0, entityLookup_1.liveEntityFilter)();
|
|
34
|
+
where.push(narrowed.sql);
|
|
35
|
+
params.push(...narrowed.params);
|
|
36
|
+
return scalar(db, `SELECT COUNT(*) AS c FROM entities WHERE ${where.join(' AND ')}`, ...params);
|
|
37
|
+
}
|
|
38
|
+
function countInventory(db, vault) {
|
|
39
|
+
// A table that cannot be read counts as empty, as memory_status always has.
|
|
40
|
+
const orZero = (count) => {
|
|
41
|
+
try {
|
|
42
|
+
return count();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
notes: orZero(() => scalar(db, 'SELECT COUNT(*) AS c FROM notes WHERE path IS NOT ? AND path IS NOT ?', lifecycle_1.LIFECYCLE_NOTE_PATH, ledgerNotePath(vault))),
|
|
50
|
+
chunks: orZero(() => scalar(db, 'SELECT COUNT(*) AS c FROM chunks')),
|
|
51
|
+
entities: orZero(() => countEntities(db, vault)),
|
|
52
|
+
facts: orZero(() => scalar(db, 'SELECT COUNT(*) AS c FROM facts')),
|
|
53
|
+
relations: orZero(() => scalar(db, 'SELECT COUNT(*) AS c FROM relations')),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isBlankOrInvisible = isBlankOrInvisible;
|
|
4
|
+
/**
|
|
5
|
+
* True when `text` holds nothing a reader can see: only whitespace, the Unicode default-ignorable code
|
|
6
|
+
* points, the Unicode format controls, and the two blank symbols — U+2800 BRAILLE PATTERN BLANK and
|
|
7
|
+
* U+FFFC OBJECT REPLACEMENT CHARACTER — which render as nothing but are neither whitespace nor
|
|
8
|
+
* default-ignorable.
|
|
9
|
+
*
|
|
10
|
+
* A subject, predicate, object, note or statement made only of these is refused on write, so a memory
|
|
11
|
+
* that looks empty is never stored and no user has to correct one they cannot see. One rule for every
|
|
12
|
+
* write path: the ledger's structured fields (`refuseUnstorableFields`), the daily-note writer
|
|
13
|
+
* (`checkedNoteText`) and the import preview all read it here, so a blank a reader cannot see is blank
|
|
14
|
+
* everywhere. Whitespace and default-ignorable alone left U+2800, U+FFFC and the interlinear-annotation
|
|
15
|
+
* controls U+FFF9..U+FFFB accepted (breaker run 2).
|
|
16
|
+
*/
|
|
17
|
+
const BLANK_OR_INVISIBLE = /^[\s\p{Default_Ignorable_Code_Point}\p{Cf}\u2800\uFFFC]*$/u;
|
|
18
|
+
function isBlankOrInvisible(text) {
|
|
19
|
+
return BLANK_OR_INVISIBLE.test(text);
|
|
20
|
+
}
|