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.
Files changed (176) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +73 -0
  3. package/README.md +105 -2
  4. package/dist/BUILD.json +6 -0
  5. package/dist/api/apiAuth.js +43 -0
  6. package/dist/api/server.js +172 -0
  7. package/dist/buildIdentity.js +132 -0
  8. package/dist/cli-extractors.js +46 -0
  9. package/dist/cli.js +491 -0
  10. package/dist/config.js +65 -0
  11. package/dist/context/composer.js +320 -0
  12. package/dist/context/readHonesty.js +478 -0
  13. package/dist/context/rowStore.js +238 -0
  14. package/dist/conversation/formatter.js +81 -0
  15. package/dist/conversation/parser.js +189 -0
  16. package/dist/conversation/types.js +2 -0
  17. package/dist/conversation/writer.js +42 -0
  18. package/dist/db/client.js +147 -0
  19. package/dist/db/indexSalvage.js +81 -0
  20. package/dist/db/migrations.js +642 -0
  21. package/dist/db/readCache.js +109 -0
  22. package/dist/db/schema.js +5 -0
  23. package/dist/db/statements.js +89 -0
  24. package/dist/digests/digestCache.js +142 -0
  25. package/dist/digests/digests.js +597 -0
  26. package/dist/digests/worker.js +348 -0
  27. package/dist/embeddings/caching.js +337 -0
  28. package/dist/embeddings/coalescing.js +177 -0
  29. package/dist/embeddings/interactive.js +103 -0
  30. package/dist/embeddings/local.js +68 -0
  31. package/dist/embeddings/openai.js +105 -0
  32. package/dist/embeddings/rebuild.js +166 -0
  33. package/dist/embeddings/simdScan.js +258 -0
  34. package/dist/embeddings/space.js +21 -0
  35. package/dist/embeddings/types.js +14 -0
  36. package/dist/embeddings/vectorIndex.js +675 -0
  37. package/dist/embeddings/vertex.js +224 -0
  38. package/dist/eval/artifacts.js +42 -0
  39. package/dist/eval/cacheReport.js +139 -0
  40. package/dist/eval/comparison.js +182 -0
  41. package/dist/eval/context700-candidate.js +145 -0
  42. package/dist/eval/evalCache.js +525 -0
  43. package/dist/eval/hashEmbedding.js +35 -0
  44. package/dist/eval/longmemeval.js +56 -0
  45. package/dist/eval/metrics.js +181 -0
  46. package/dist/eval/renorbenchBaseline.js +486 -0
  47. package/dist/eval/renorbenchDocuments.js +134 -0
  48. package/dist/eval/renorbenchProduct.js +182 -0
  49. package/dist/eval/runLedger.js +279 -0
  50. package/dist/eval/runManifest.js +479 -0
  51. package/dist/eval/scoring.js +197 -0
  52. package/dist/evidence/attach.js +267 -0
  53. package/dist/evidence/background.js +147 -0
  54. package/dist/evidence/evidenceCache.js +182 -0
  55. package/dist/evidence/gate.js +37 -0
  56. package/dist/evidence/keyPool.js +85 -0
  57. package/dist/evidence/ollamaChat.js +84 -0
  58. package/dist/evidence/optin.js +165 -0
  59. package/dist/evidence/options.js +130 -0
  60. package/dist/evidence/secondPass.js +456 -0
  61. package/dist/extraction/classifyCapture.js +34 -0
  62. package/dist/extraction/decision-model.js +174 -0
  63. package/dist/extraction/eventExtractor.js +113 -0
  64. package/dist/extraction/extractionClient.js +122 -0
  65. package/dist/extraction/factExtractor.js +46 -0
  66. package/dist/extraction/factsWritePath.js +179 -0
  67. package/dist/extraction/preferenceExtractor.js +153 -0
  68. package/dist/extraction/secretGuard.js +176 -0
  69. package/dist/extraction/secretScanner.js +665 -0
  70. package/dist/extraction/typedFacts.js +224 -0
  71. package/dist/extraction/writePathAdjudication.js +110 -0
  72. package/dist/importing/apply.js +644 -0
  73. package/dist/importing/bundle-write.js +80 -0
  74. package/dist/importing/cli.js +407 -0
  75. package/dist/importing/existing.js +49 -0
  76. package/dist/importing/facts.js +123 -0
  77. package/dist/importing/forgetImported.js +79 -0
  78. package/dist/importing/hosted.js +467 -0
  79. package/dist/importing/index.js +29 -0
  80. package/dist/importing/journalLog.js +96 -0
  81. package/dist/importing/markdown.js +98 -0
  82. package/dist/importing/plan.js +206 -0
  83. package/dist/importing/secretScreen.js +113 -0
  84. package/dist/importing/serialize.js +188 -0
  85. package/dist/importing/source.js +463 -0
  86. package/dist/importing/types.js +6 -0
  87. package/dist/indexer/chunker.js +319 -0
  88. package/dist/indexer/dailyEntries.js +63 -0
  89. package/dist/indexer/hasher.js +10 -0
  90. package/dist/indexer/laneRuns.js +48 -0
  91. package/dist/indexer/o200k.js +481 -0
  92. package/dist/indexer/o200k.json +8 -0
  93. package/dist/indexer/outsideRoot.js +77 -0
  94. package/dist/indexer/parser.js +167 -0
  95. package/dist/indexer/pipeline.js +1168 -0
  96. package/dist/indexer/staleness.js +201 -0
  97. package/dist/indexer/tokenizer.js +77 -0
  98. package/dist/indexer/walker.js +184 -0
  99. package/dist/mcp/hive.js +88 -0
  100. package/dist/mcp/requestErrors.js +47 -0
  101. package/dist/mcp/server.js +396 -0
  102. package/dist/memory/agentLease.js +43 -0
  103. package/dist/memory/confinedWrite.js +315 -0
  104. package/dist/memory/controls.js +73 -0
  105. package/dist/memory/dailyNote.js +265 -0
  106. package/dist/memory/durableAppend.js +57 -0
  107. package/dist/memory/factConflicts.js +203 -0
  108. package/dist/memory/factTerms.js +321 -0
  109. package/dist/memory/hiveNameHeader.cjs +10 -0
  110. package/dist/memory/inventory.js +55 -0
  111. package/dist/memory/invisibleText.js +20 -0
  112. package/dist/memory/lifecycle.js +1756 -0
  113. package/dist/memory/limits.js +13 -0
  114. package/dist/memory/requests.js +183 -0
  115. package/dist/memory/sourceName.js +200 -0
  116. package/dist/memory/whatsNew.js +158 -0
  117. package/dist/memory/writer.js +175 -0
  118. package/dist/memory/writerConflicts.js +85 -0
  119. package/dist/memorymap/memoryMap.js +1568 -0
  120. package/dist/memorymap/replacedJournal.js +58 -0
  121. package/dist/memorymap/replacements.js +405 -0
  122. package/dist/mvp/activity.js +86 -0
  123. package/dist/mvp/agentMemory.js +1744 -0
  124. package/dist/mvp/auth.js +531 -0
  125. package/dist/mvp/cli.js +73 -0
  126. package/dist/mvp/client-store.js +462 -0
  127. package/dist/mvp/compileCache.js +249 -0
  128. package/dist/mvp/compileSeed.js +27 -0
  129. package/dist/mvp/config.js +288 -0
  130. package/dist/mvp/connectionStatus.js +80 -0
  131. package/dist/mvp/contextRelevance.js +413 -0
  132. package/dist/mvp/engine.js +1065 -0
  133. package/dist/mvp/exportAll.js +359 -0
  134. package/dist/mvp/factLanePack.js +77 -0
  135. package/dist/mvp/factMarkers.js +49 -0
  136. package/dist/mvp/factsLane.js +203 -0
  137. package/dist/mvp/hiddenSpans.js +585 -0
  138. package/dist/mvp/history.js +413 -0
  139. package/dist/mvp/hitDates.js +31 -0
  140. package/dist/mvp/hitFloor.js +36 -0
  141. package/dist/mvp/identity.js +18 -0
  142. package/dist/mvp/idle.js +113 -0
  143. package/dist/mvp/memoryDir.js +98 -0
  144. package/dist/mvp/posthogMcp.js +210 -0
  145. package/dist/mvp/readScope.js +104 -0
  146. package/dist/mvp/readUsefulness.js +222 -0
  147. package/dist/mvp/sanitize.js +48 -0
  148. package/dist/mvp/schemaRejections.js +91 -0
  149. package/dist/mvp/scope.js +127 -0
  150. package/dist/mvp/server.js +2089 -0
  151. package/dist/mvp/serverCard.js +29 -0
  152. package/dist/mvp/storedSize.js +139 -0
  153. package/dist/mvp/telemetry.js +441 -0
  154. package/dist/retrieval/adviceLines.js +195 -0
  155. package/dist/retrieval/adviceQuestion.js +44 -0
  156. package/dist/retrieval/conversationShape.js +44 -0
  157. package/dist/retrieval/dailyEntryTime.js +55 -0
  158. package/dist/retrieval/entityLookup.js +113 -0
  159. package/dist/retrieval/factsFirst.js +188 -0
  160. package/dist/retrieval/keyword.js +84 -0
  161. package/dist/retrieval/ledgerEventTime.js +51 -0
  162. package/dist/retrieval/ledgerLiveness.js +83 -0
  163. package/dist/retrieval/ledgerPresentation.js +161 -0
  164. package/dist/retrieval/noteIdentity.js +57 -0
  165. package/dist/retrieval/orchestrator.js +1086 -0
  166. package/dist/retrieval/queryHelpers.js +142 -0
  167. package/dist/retrieval/questionSpan.js +475 -0
  168. package/dist/retrieval/readerEvents.js +690 -0
  169. package/dist/retrieval/readerLayout.js +503 -0
  170. package/dist/retrieval/relativeTimeHints.js +113 -0
  171. package/dist/retrieval/semantic.js +67 -0
  172. package/dist/router/routerCache.js +106 -0
  173. package/dist/router/sessionRouter.js +726 -0
  174. package/dist/toolRefusal.js +120 -0
  175. package/dist/watcher/watcher.js +75 -0
  176. 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
+ }