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,1086 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SESSION_EXPAND_DEFAULT_NOTES = exports.SESSION_EXPAND_REASON = exports.SESSION_EXPAND_RANKED_TOKENS = exports.SESSION_EXPAND_DEFAULT_TOKENS = exports.SESSION_EXPAND_DOCUMENT_MAX_CHUNK_TOKENS = exports.SESSION_EXPAND_MAX_CHUNK_TOKENS = exports.SESSION_DIVERSITY_DEFAULT_CAP = exports.WRITE_CLOCK_TOLERANCE_MS = void 0;
4
+ exports.searchBestFirstEnabled = searchBestFirstEnabled;
5
+ exports.parseQueryDateRef = parseQueryDateRef;
6
+ exports.queryDateRefMs = queryDateRefMs;
7
+ exports.hasNowOverride = hasNowOverride;
8
+ exports.asofPastLike = asofPastLike;
9
+ exports.asofDatedRef = asofDatedRef;
10
+ exports.hasPastIntent = hasPastIntent;
11
+ exports.hasStrictPastIntent = hasStrictPastIntent;
12
+ exports.frontmatterMarksHistorical = frontmatterMarksHistorical;
13
+ exports.sessionDateMs = sessionDateMs;
14
+ exports.resolveSessionDate = resolveSessionDate;
15
+ exports.clampSessionDate = clampSessionDate;
16
+ exports.boundContentDate = boundContentDate;
17
+ exports.createNoteTimeBound = createNoteTimeBound;
18
+ exports.createNoteDater = createNoteDater;
19
+ exports.orderHits = orderHits;
20
+ exports.contentfulText = contentfulText;
21
+ exports.isSpeakerStub = isSpeakerStub;
22
+ exports.resolveSessionDiversityCap = resolveSessionDiversityCap;
23
+ exports.resolveSessionExpand = resolveSessionExpand;
24
+ exports.resolveRankedExpansion = resolveRankedExpansion;
25
+ exports.calibratedModelKey = calibratedModelKey;
26
+ exports.retrieveContext = retrieveContext;
27
+ const ledgerLiveness_1 = require("./ledgerLiveness");
28
+ const ledgerPresentation_1 = require("./ledgerPresentation");
29
+ const ledgerEventTime_1 = require("./ledgerEventTime");
30
+ const dailyEntryTime_1 = require("./dailyEntryTime");
31
+ const dailyNote_1 = require("../memory/dailyNote");
32
+ const readCache_1 = require("../db/readCache");
33
+ const statements_1 = require("../db/statements");
34
+ const semantic_1 = require("./semantic");
35
+ const entityLookup_1 = require("./entityLookup");
36
+ const factsFirst_1 = require("./factsFirst");
37
+ const conversationShape_1 = require("./conversationShape");
38
+ const contextRelevance_1 = require("../mvp/contextRelevance");
39
+ const keyword_1 = require("./keyword");
40
+ const queryHelpers_1 = require("./queryHelpers");
41
+ const ledgerPresentation_2 = require("./ledgerPresentation");
42
+ function envFlagOn(name) {
43
+ const v = (process.env[name] ?? '').trim().toLowerCase();
44
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
45
+ }
46
+ /** Newest-first is product default (S0 KEEP). Opt out with RENOR_NEWEST_FIRST=0/false/off. */
47
+ function newestFirstEnabled() {
48
+ const v = (process.env.RENOR_NEWEST_FIRST ?? '').trim().toLowerCase();
49
+ if (v === '0' || v === 'false' || v === 'no' || v === 'off')
50
+ return false;
51
+ if (v === '')
52
+ return true;
53
+ return envFlagOn('RENOR_NEWEST_FIRST');
54
+ }
55
+ /**
56
+ * RENOR_SEARCH_BEST_FIRST (default off; `1` / `true` / `yes` / `on` turns it on): search_memory lays its window out best
57
+ * match first (`rankedBy: "score"`) instead of newest session first. It reaches only the reads that ask for it
58
+ * (`RetrieveOptions.bestFirst`, which only the search_memory tool sets): get_context, the CLI and RENOR_NEWEST_FIRST are
59
+ * untouched, and so is which chunks are selected. Every hit keeps its session_date. Measured on the agent-workflow eval.
60
+ */
61
+ function searchBestFirstEnabled(env = process.env) {
62
+ const v = (env.RENOR_SEARCH_BEST_FIRST ?? '').trim().toLowerCase();
63
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
64
+ }
65
+ /**
66
+ * Temporal ranking is product default (RenorBench prefer_current/prefer_historical
67
+ * fix, 2026-09-17). Opt out with RENOR_TEMPORAL_RANKING=0/false/off — a kill
68
+ * switch that reverts to pre-temporal ordering without a redeploy.
69
+ */
70
+ function temporalRankingEnabled() {
71
+ const v = (process.env.RENOR_TEMPORAL_RANKING ?? '').trim().toLowerCase();
72
+ if (v === '0' || v === 'false' || v === 'no' || v === 'off')
73
+ return false;
74
+ if (v === '')
75
+ return true;
76
+ return envFlagOn('RENOR_TEMPORAL_RANKING');
77
+ }
78
+ /**
79
+ * Explicit-date as-of lane (RENOR_TEMPORAL_ASOF, default OFF).
80
+ * Opt in with RENOR_TEMPORAL_ASOF=1/true/yes/on — a kill switch that restores
81
+ * pre-lane behavior without a redeploy.
82
+ */
83
+ function asofEnabled() {
84
+ return envFlagOn('RENOR_TEMPORAL_ASOF');
85
+ }
86
+ const ASOF_MONTHS = {
87
+ january: 1, february: 2, march: 3, april: 4, may: 5, june: 6,
88
+ july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
89
+ };
90
+ /**
91
+ * Extract an explicit past-date reference from a natural-language query.
92
+ * General (gold-blind): matches any month+year, season+year, or bare year —
93
+ * never fixture-specific dates. Returns undefined when the query carries no
94
+ * calendar reference or origin marker.
95
+ */
96
+ function parseQueryDateRef(query) {
97
+ const q = query.toLowerCase();
98
+ const monthNames = Object.keys(ASOF_MONTHS).join('|');
99
+ const my = q.match(new RegExp(`\\b(${monthNames})\\s+(\\d{4})\\b`));
100
+ if (my)
101
+ return { year: Number(my[2]), month: ASOF_MONTHS[my[1]], originMarker: false };
102
+ const season = q.match(/\b(early|mid|late)\s+(\d{4})\b/);
103
+ if (season) {
104
+ const month = season[1] === 'early' ? 3 : season[1] === 'mid' ? 6 : 9;
105
+ return { year: Number(season[2]), month, originMarker: false };
106
+ }
107
+ const part = q.match(/\b(end|start|beginning|middle)\s+of\s+(\d{4})\b/);
108
+ if (part) {
109
+ const month = part[1] === 'end' ? 12 : part[1] === 'middle' ? 6 : 1;
110
+ return { year: Number(part[2]), month, originMarker: false };
111
+ }
112
+ const iso = q.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
113
+ if (iso)
114
+ return { year: Number(iso[1]), month: Number(iso[2]), originMarker: false };
115
+ const bareYear = q.match(/\b((?:19|20)\d{2})\b/);
116
+ if (bareYear)
117
+ return { year: Number(bareYear[1]), originMarker: false };
118
+ if (/\boriginal(ly)?\b|\bv1\b|first version/.test(q))
119
+ return { year: 0, originMarker: true };
120
+ return undefined;
121
+ }
122
+ /** Resolve a date ref to an end-of-period timestamp for past/future tests. */
123
+ function queryDateRefMs(ref) {
124
+ if (ref.originMarker)
125
+ return Number.NEGATIVE_INFINITY; // always "the past"
126
+ const month = ref.month ?? 12;
127
+ // End of the referenced month (Date.UTC month is 0-based; day 0 = last of prev).
128
+ return Date.UTC(ref.year, month, 0, 23, 59, 59);
129
+ }
130
+ function hasNowOverride(query) {
131
+ return /\b(now|currently|current)\b/i.test(query);
132
+ }
133
+ /**
134
+ * Past-like via the as-of lane: an explicit past calendar reference (or an
135
+ * origin marker) with no present-tense override. Independent of the temporal
136
+ * flag — the lane has its own kill switch.
137
+ */
138
+ function asofPastLike(query, nowMs) {
139
+ if (!asofEnabled())
140
+ return false;
141
+ const ref = parseQueryDateRef(query);
142
+ if (!ref)
143
+ return false;
144
+ if (hasNowOverride(query))
145
+ return false;
146
+ return queryDateRefMs(ref) < nowMs - 86_400_000;
147
+ }
148
+ /** Explicit past-date refs (no origin markers) for the date-overlap boost. */
149
+ function asofDatedRef(query, nowMs) {
150
+ if (!asofEnabled())
151
+ return undefined;
152
+ const ref = parseQueryDateRef(query);
153
+ if (!ref || ref.originMarker)
154
+ return undefined;
155
+ if (hasNowOverride(query))
156
+ return undefined;
157
+ if (!(queryDateRefMs(ref) < nowMs - 86_400_000))
158
+ return undefined;
159
+ return ref;
160
+ }
161
+ /** Explicit past-intent query: "what was it before / previously / used to be". */
162
+ function hasPastIntent(query) {
163
+ return /\b(previously|before|used to|former|formerly|earlier|in the past|history)\b/i.test(query);
164
+ }
165
+ /**
166
+ * STRICT past intent: the temporal word must signal that the QUESTION TARGET is
167
+ * the past state, not merely appear in a query whose answer is current
168
+ * ("Which FORMER vegetarian NOW eats fish?" asks for the present).
169
+ */
170
+ function hasStrictPastIntent(query) {
171
+ if (!hasPastIntent(query))
172
+ return false;
173
+ return !/\b(now|currently|current)\b/i.test(query);
174
+ }
175
+ /**
176
+ * Corpus-level historical marker in note frontmatter. Deliberately conservative:
177
+ * only explicit declarations count, so ordinary notes are never demoted.
178
+ */
179
+ function frontmatterMarksHistorical(frontmatterJson) {
180
+ if (!frontmatterJson)
181
+ return false;
182
+ let fm = {};
183
+ try {
184
+ const parsed = JSON.parse(frontmatterJson);
185
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
186
+ fm = parsed;
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ const historicalFlag = fm['historical'] === true || fm['archived'] === true;
192
+ const kind = typeof fm['kind'] === 'string' ? fm['kind'].toLowerCase() : '';
193
+ const type = typeof fm['type'] === 'string' ? fm['type'].toLowerCase() : '';
194
+ return historicalFlag || kind === 'history' || kind === 'historical' || type === 'history' || type === 'historical';
195
+ }
196
+ function asDateString(value) {
197
+ if (typeof value === 'string' && value.trim())
198
+ return value.trim();
199
+ if (value instanceof Date && !Number.isNaN(value.getTime()))
200
+ return value.toISOString();
201
+ if (typeof value === 'number' && Number.isFinite(value)) {
202
+ const d = new Date(value);
203
+ if (!Number.isNaN(d.getTime()))
204
+ return d.toISOString();
205
+ }
206
+ return undefined;
207
+ }
208
+ /**
209
+ * Parse LME `YYYY/MM/DD (Dow) HH:MM` as UTC and ISO date-only strings as UTC midnight.
210
+ * ISO datetimes require `Z` or an explicit numeric offset; never interpret them in host-local time.
211
+ */
212
+ function sessionDateMs(value) {
213
+ if (!value)
214
+ return Number.NEGATIVE_INFINITY;
215
+ const official = /^(\d{4})\/(\d{2})\/(\d{2})(?: \([A-Za-z]{3}\))?(?: (\d{2}):(\d{2}))?$/.exec(value);
216
+ const isoDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value);
217
+ const zonedIsoDateTime = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/i.test(value);
218
+ const iso = official
219
+ ? `${official[1]}-${official[2]}-${official[3]}T${official[4] ?? '00'}:${official[5] ?? '00'}:00Z`
220
+ : isoDateOnly
221
+ ? `${value}T00:00:00Z`
222
+ : zonedIsoDateTime
223
+ ? value
224
+ : undefined;
225
+ const ms = iso === undefined ? Number.NaN : Date.parse(iso);
226
+ return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY;
227
+ }
228
+ function resolveSessionDate(frontmatterJson, modifiedAt) {
229
+ let fm = {};
230
+ if (frontmatterJson) {
231
+ try {
232
+ const parsed = JSON.parse(frontmatterJson);
233
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
234
+ fm = parsed;
235
+ }
236
+ catch {
237
+ fm = {};
238
+ }
239
+ }
240
+ const modified = asDateString(modifiedAt);
241
+ const asserted = asDateString(fm.session_date) ?? asDateString(fm.date);
242
+ if (asserted === undefined)
243
+ return modified;
244
+ return clampSessionDate(asserted, modified);
245
+ }
246
+ // Absorbs coarse kernel file timestamps and 1–2 s filesystem resolution.
247
+ exports.WRITE_CLOCK_TOLERANCE_MS = 2000;
248
+ /** Every content-derived clock, including entry/event overrides, obeys the write-time bound plus clock tolerance. */
249
+ function clampSessionDate(asserted, modifiedAt) {
250
+ const modified = asDateString(modifiedAt);
251
+ const modifiedMs = sessionDateMs(modified);
252
+ // With no trusted timestamp, untrusted content cannot gain newest-session priority.
253
+ if (!Number.isFinite(modifiedMs))
254
+ return undefined;
255
+ if (asserted === undefined)
256
+ return modified;
257
+ const assertedMs = sessionDateMs(asserted);
258
+ // A claim that is not an instant sorts as undated (sessionDateMs is -Infinity), so it is shown as undated, never verbatim.
259
+ if (!Number.isFinite(assertedMs))
260
+ return undefined;
261
+ return assertedMs > modifiedMs + exports.WRITE_CLOCK_TOLERANCE_MS ? modified : asserted;
262
+ }
263
+ /**
264
+ * Bound a content-derived date that is stored (structured-row valid_from) by the source file's write time, with the same
265
+ * tolerance as `clampSessionDate`. Day-precision dates stay day-precision, and a clamped one floors to the write day, so
266
+ * mixed formats still compare consistently. With no trusted write time, or a date that cannot be read as an instant, no
267
+ * content date is kept (unknown stays unknown).
268
+ */
269
+ function boundContentDate(asserted, modifiedAt) {
270
+ if (asserted === null)
271
+ return null;
272
+ const modified = asDateString(modifiedAt ?? undefined);
273
+ if (!Number.isFinite(sessionDateMs(modified)))
274
+ return null;
275
+ const trimmed = asserted.trim();
276
+ // A zone-less date-time reads as UTC, the way every other date in the index does; sessionDateMs refuses it otherwise.
277
+ const comparable = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(trimmed) ? `${trimmed}Z` : trimmed;
278
+ if (!Number.isFinite(sessionDateMs(comparable)))
279
+ return null;
280
+ if (clampSessionDate(comparable, modified) === comparable)
281
+ return trimmed;
282
+ return /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? modified.slice(0, 10) : modified;
283
+ }
284
+ /** Bound an excerpt clock using its indexed file timestamp, rather than its (possibly historical) front matter. */
285
+ function createNoteTimeBound(db) {
286
+ const row = onceStatement(db, 'SELECT modified_at FROM notes WHERE path = ?');
287
+ return (notePath, asserted) => {
288
+ const note = row().get(notePath);
289
+ return clampSessionDate(asserted, note?.modified_at);
290
+ };
291
+ }
292
+ /**
293
+ * A note's date as retrieval dates the excerpts of it (front-matter session_date, else date, capped at the file's
294
+ * modification time; absent front matter falls back to that time). For a caller that works on whole notes, such as
295
+ * evidence hydration, and so needs the date of the note rather than of one excerpt: for a ledger event those differ.
296
+ * One prepared statement per dater.
297
+ */
298
+ function createNoteDater(db) {
299
+ const row = onceStatement(db, 'SELECT modified_at, frontmatter_json FROM notes WHERE path = ?');
300
+ return notePath => {
301
+ const note = row().get(notePath);
302
+ return resolveSessionDate(note?.frontmatter_json, note?.modified_at);
303
+ };
304
+ }
305
+ /**
306
+ * Lay hits out by the key they are said to be in order of, so the claim is true of the list and not only of
307
+ * the code that usually produces it. Stable, and the input is not touched.
308
+ * `session_date`: newest first; hits on one date, and hits with no date (which come last), by score.
309
+ * `score`: highest first.
310
+ */
311
+ function orderHits(hits, by) {
312
+ if (by === 'score')
313
+ return [...hits].sort((a, b) => b.score - a.score);
314
+ // A date is read once for each hit, not once for each comparison the sort makes (a few hundred for a window).
315
+ return hits.map(hit => ({ hit, ms: sessionDateMs(hit.session_date) })).sort((a, b) => {
316
+ const delta = b.ms - a.ms;
317
+ return delta !== 0 && !Number.isNaN(delta) ? delta : b.hit.score - a.hit.score; // NaN: both undated
318
+ }).map(entry => entry.hit);
319
+ }
320
+ /**
321
+ * The visible text of each hit text the current read has met. A read asks it of the same text several times: whether the
322
+ * hit is a stub, the key that drops a repeat, the boost for a hit with something to say, and again for a hit the session
323
+ * expansion brings back. The text is the whole input, so the answer is kept until the read ends (`retrieveContext`
324
+ * forgets it as it ends and again as the next one starts, which also covers a read that failed) and no longer: nothing
325
+ * outlives the read it was worked out for. A text over the size limit, or a read that meets more texts than the count
326
+ * limit, is simply worked out again.
327
+ */
328
+ const CONTENTFUL_HELD_MAX = 256;
329
+ const CONTENTFUL_HELD_MAX_CHARS = 8 * 1024;
330
+ const contentfulHeld = new Map();
331
+ /** Visible text after markdown/speaker-label strip — used to bury transcript stubs. */
332
+ function contentfulText(text) {
333
+ const raw = text ?? '';
334
+ const held = contentfulHeld.get(raw);
335
+ if (held !== undefined)
336
+ return held;
337
+ const visible = stripToContentful(raw);
338
+ if (raw.length <= CONTENTFUL_HELD_MAX_CHARS) {
339
+ if (contentfulHeld.size >= CONTENTFUL_HELD_MAX)
340
+ contentfulHeld.delete(contentfulHeld.keys().next().value);
341
+ contentfulHeld.set(raw, visible);
342
+ }
343
+ return visible;
344
+ }
345
+ function stripToContentful(raw) {
346
+ // Each pass below but the heading rule can only change a text that holds the character it looks for, and most hit texts hold
347
+ // few of them, so a pass whose character is absent is not run (a replace that finds nothing still reads the whole text).
348
+ let t = raw.includes('\r') ? raw.replace(/\r/g, '') : raw;
349
+ if (t.includes('*'))
350
+ t = t.replace(/\*+/g, '');
351
+ // Whole ATX heading line is structure, not answer text (chunker prefixes
352
+ // section buffers with the heading — Kimi R-O1).
353
+ t = t.replace(/^#{1,6}\s+.*$/gm, '');
354
+ // Speaker-only lines: **Rowan:** / Rowan: / **Gabriel:** / 李雷: — any script and
355
+ // either colon. Cue detection was Latin-1 only (`[A-Za-zÀ-ÿ]`), so labels in
356
+ // non-Latin scripts were counted as content by contentfulText, which feeds both
357
+ // contentBoost and dedupeHits.
358
+ if (t.includes(':') || t.includes(':'))
359
+ t = t.replace(/^[\s>*-]*[\p{L}\p{M}][\p{L}\p{M} .'-]{0,40}[::]\s*$/gmu, '');
360
+ if (t.includes('\n\n'))
361
+ t = t.replace(/\n{2,}/g, '\n');
362
+ return t.trim();
363
+ }
364
+ function isSpeakerStub(text) {
365
+ const raw = (text ?? '').replace(/\r/g, '').trim();
366
+ // Whole chunk is only a speaker cue (optional markdown/list markers) — any
367
+ // script, either colon.
368
+ if (/^(?:[\s>*-]*\*?\*?[\p{L}\p{M}][\p{L}\p{M} .'-]{0,40}\*?\*?[::]\s*)+$/u.test(raw) && contentfulText(raw).length === 0) {
369
+ return true;
370
+ }
371
+ return contentfulText(raw).length === 0;
372
+ }
373
+ function contentBoost(text) {
374
+ const n = contentfulText(text).length;
375
+ if (n < 40)
376
+ return 0;
377
+ return Math.min(0.12, (n / 800) * 0.12);
378
+ }
379
+ /** Two hits of one note whose visible text opens the same way are one hit: the key dedupeHits keeps the first of. */
380
+ function hitKey(h) {
381
+ return `${h.notePath}\0${contentfulText(h.text).slice(0, 80)}`;
382
+ }
383
+ function dedupeHits(hits) {
384
+ const seen = new Set();
385
+ const out = [];
386
+ for (const h of hits) {
387
+ const key = hitKey(h);
388
+ if (seen.has(key))
389
+ continue;
390
+ seen.add(key);
391
+ out.push(h);
392
+ }
393
+ return out;
394
+ }
395
+ /** The search cap when RENOR_SESSION_DIVERSITY is unset or empty; get_context uses 0. */
396
+ exports.SESSION_DIVERSITY_DEFAULT_CAP = 2;
397
+ /**
398
+ * RENOR_SESSION_DIVERSITY (search default 2, get_context default 0; explicit 0 turns it off): max chunks per note in the top-K
399
+ * window. Capped chunks do not vanish — every hit in the candidate pool is
400
+ * scanned, so chunks of notes that have not reached the cap still take their
401
+ * slots in rank order, and distinct notes that outrank the surplus are kept.
402
+ *
403
+ * MEASURED LIMIT, stated because the previous revision of this comment claimed
404
+ * the opposite ("so the window keeps topK entries"): the window is NOT
405
+ * guaranteed to be full. When the candidate pool is concentrated in fewer than
406
+ * topK/cap distinct notes there is nothing else to promote — every surplus hit
407
+ * belongs to a note already at its cap by construction — so the window shrinks
408
+ * below topK. That is exactly the chunk-heavy corpus this flag targets:
409
+ * measured on a 4-notes x 5-chunks pool, topK=10, cap=1 -> 4 slots, cap=2 -> 8
410
+ * slots, and on the 90-case screen the delivered context fell 3,791 -> 2,221
411
+ * tokens (-41.4%). A true backfill would have to retrieve BEYOND the current
412
+ * candidate pool, which this function cannot do and does not claim to.
413
+ *
414
+ * THE CAP IS AUTHORITATIVE WHENEVER IT IS ARMED, including when the candidate pool is no
415
+ * deeper than the window. This function used to early-return on `hits.length <= topK`,
416
+ * which skipped the cap entirely for a small or single-note vault (measured: cap=1 over ten
417
+ * same-note chunks returned all ten, `kept=10`, while the headline above promises a maximum
418
+ * per note). The window then shrinks rather than silently ignoring the setting, which is the
419
+ * same trade the concentrated-pool case above already makes: the cap is not a backfill
420
+ * guarantee, but it is not optional either. Only `cap < 1` (0, or a value that names no cap)
421
+ * short-circuits, so the off path is untouched.
422
+ *
423
+ * Rationale (measured on full500-count-contract results.jsonl, 2026-09-28):
424
+ * the top-20 window carries a mean 9.4 repeated-note slots (all different
425
+ * chunks of the same note — dedupeHits only matches identical prefixes), and
426
+ * cap=2 frees 6.9 slots on 453/500 cases.
427
+ *
428
+ * DEFAULT 2 since 2026-10-07 (the captain's call), from the held-out answer confirmation of 2026-10-06: GLM-5.3-Flash
429
+ * reading and gemma4:31b judging through the shipped CLI, the 395 held-out LongMemEval-S questions the base answered.
430
+ * No cap got 340 right and a cap of 2 got 350 (16 wins, 6 losses, +2.5 points, interval +0.3 to +4.8, p = 0.0525),
431
+ * with 6.3% fewer context tokens and no category worse by more than 2 questions. A cap of 1 looked best on dev and lost
432
+ * evidence on held-out, which is why 1 is not the default. The record: docs/evals/results/SESSION-DIVERSITY-DEFAULT-20261007.md.
433
+ *
434
+ * WHAT THE CAP COUNTS, because the evidence covers one kind of note. The cap is about the chunks of one conversation or
435
+ * one document crowding out the others, so it counts only those. Three kinds of hit share a note path without being parts
436
+ * of one thing said, and are left alone (not counted, not cut): a fact row (a negative chunkId, no chunk of a note; the
437
+ * facts-first rows all carry the pseudo-path `_mcp_`), a ledger event (every fact-mode save is an event of the one ledger
438
+ * file) and an entry of a daily log (one file per day, one memory per entry: dailyEntryTime.ts). Counted by path they
439
+ * were capped at `cap` each, and a saved statement stopped coming back from search_memory once `cap` others of the
440
+ * ledger, or of its day, outranked it. A benchmark vault holds none of the three, so what was measured is unchanged;
441
+ * docs/evals/results/SESSION-DIVERSITY-DEFAULT-20261007.md has the tests that showed it.
442
+ *
443
+ * Unset (or empty) is the default. 0, or a value that names no cap (junk, negative, below 1), is off, and off is the
444
+ * window as it was before the default existed, byte for byte. Searches default to 2 (search_memory, the CLI search and
445
+ * the older API/MCP search servers). get_context passes noteCapWhenUnset=0, preserving its existing window and budget
446
+ * assembly. An explicit environment value still wins on every read, as it did before this default changed.
447
+ */
448
+ function resolveSessionDiversityCap(env = process.env, unsetCap = exports.SESSION_DIVERSITY_DEFAULT_CAP) {
449
+ const rawCap = (env.RENOR_SESSION_DIVERSITY ?? '').trim();
450
+ const cap = rawCap === '' ? unsetCap : Number(rawCap);
451
+ if (!Number.isFinite(cap) || cap < 1)
452
+ return 0; // off: 0, or a value that names no cap
453
+ return Math.floor(cap);
454
+ }
455
+ /**
456
+ * Whether a hit counts toward its note's cap: a chunk of a conversation or a document. A fact row (a negative chunkId)
457
+ * is no chunk of a note and a ledger event is no session's chunk (the two tests sessionExpansionHits ranks notes by),
458
+ * and an entry of a daily log is a memory of its own.
459
+ */
460
+ function countsTowardNoteCap(h) {
461
+ return h.chunkId > 0 && (0, ledgerLiveness_1.ledgerEventIds)(h).length === 0 && (0, dailyNote_1.dailyNoteDayName)(h.notePath) === null;
462
+ }
463
+ function applySessionDiversity(hits, cap, topK) {
464
+ // `cap < 1` is the flag-OFF short-circuit and must stay: 0 restores the window exactly as it was
465
+ // before the default existed. The former `|| hits.length <= topK` here was a bypass, not an optimization — it
466
+ // skipped the configured cap entirely whenever the pool was no deeper than the window.
467
+ if (cap < 1)
468
+ return hits;
469
+ const kept = [];
470
+ const counts = new Map();
471
+ // Single pass over the WHOLE pool, in rank order: a hit is kept while its note
472
+ // has fewer than `cap` slots already taken (a hit that is no chunk of a note is kept and takes none).
473
+ // This is the entire mechanism —
474
+ // the "backfill" loop that used to follow it was dead code (every item it
475
+ // could reach came from a note already at cap, and counts never decrease, so
476
+ // its `continue` was always taken and it never appended anything). It is
477
+ // deleted rather than kept, because a loop that cannot run cannot be a
478
+ // guarantee, and the docstring above used to rest on it.
479
+ for (const h of hits) {
480
+ if (!countsTowardNoteCap(h)) {
481
+ kept.push(h);
482
+ continue;
483
+ }
484
+ const n = counts.get(h.notePath) ?? 0;
485
+ if (n < cap) {
486
+ counts.set(h.notePath, n + 1);
487
+ kept.push(h);
488
+ }
489
+ }
490
+ return kept;
491
+ }
492
+ /**
493
+ * RENOR_SESSION_EXPAND (default 5 notes; 0 turns it off): add the short statements of the notes retrieval already found.
494
+ *
495
+ * What it fixes, measured (PageIndex scout, 2026-10-03, on the frozen product-path top-20 of LongMemEval-S): the
496
+ * window holds every gold SESSION for 98.3% of the 470 answerable questions but every evidence TURN for only 79.4%.
497
+ * The miss is inside a session it found: the note comes back through several of its chunks, but not through the one
498
+ * short sentence ("by the way, I ...") that answers the question, because that sentence is not what the query is
499
+ * about. Adding the short turns of the five best sessions to the top-20 delivered every evidence turn for 93.6%, with
500
+ * no model call. Measured through the shipped CLI with this code (results/SESSION-EXPAND-DELIVERY-20261003.md): off
501
+ * 373/470 = 79.4%; five notes 440/470 = 93.6% unbounded and 439/470 = 93.4% under the default 2,000-token budget, at
502
+ * 3,430 mean / 5,162 p95 tokens of client context against 1,978 / 4,033 off.
503
+ *
504
+ * What it does: rank the notes in the window by their best hit, take the best `RENOR_SESSION_EXPAND` of them, and add
505
+ * their statements that the window does not already hold, best note first and each note in document order, while
506
+ * they fit in `RENOR_SESSION_EXPAND_TOKENS` (default SESSION_EXPAND_DEFAULT_TOKENS). The expansion is the longest run
507
+ * of that list that fits: it stops at the first statement that does not. Two bounded queries over `chunks`, no embedding and no
508
+ * model call.
509
+ *
510
+ * - A statement is a whole section (one chunk under a unique heading path), skipped whole when over its limit,
511
+ * never cut. Conversation-shaped notes use `RENOR_SESSION_EXPAND_STATEMENT_TOKENS` (default 120); every other
512
+ * note keeps the former 200-token limit (`RENOR_SESSION_EXPAND_DOCUMENT_STATEMENT_TOKENS`). The shape comes
513
+ * only from the note's own headings (conversationShape.ts), never benchmark labels or front matter.
514
+ * The document screen found that applying 100 to all notes lost 186 delivered answer statements. Keeping
515
+ * documents at 200 preserved every document reply and 96.2% of the conversation token saving (11.7% overall).
516
+ * See docs/evals/results/SESSION-EXPAND-STATEMENT-LIMIT-20261007.md for the measurement and its limits.
517
+ * - Role-agnostic within a conversation: both user and assistant statements qualify, regardless of speaker.
518
+ * - A union, never a replacement: the window is returned as it was, and the expansion only adds. Replacing the
519
+ * window with the expansion lost the questions whose evidence is what the assistant said (56/56 -> 5/56).
520
+ * - An added hit was not matched by the query, and says so: `reason` 'session-expand', `score` 0 and no `similarity`
521
+ * (get_context's relevance signal stays the window's). get_context surfaces that as a first-class field on the
522
+ * item it lists (`matched: false`, mvp/agentMemory.ts normalizeMemory); the search lane is unchanged. It carries its
523
+ * note's date, so the layout (`orderHits`) places it with its session, after the chunks that matched; by score it
524
+ * comes after the whole window.
525
+ * - It never adds a note: every added hit belongs to a note the window holds, so a read scoped by note path keeps or
526
+ * drops it with that note. And it never adds a ledger event: the ledger is no session, its events are judged for
527
+ * liveness and scope one by one, and an event added here would skip both (a superseded event would come back as
528
+ * live). An event in the window does not rank the ledger either.
529
+ *
530
+ * RANKED (default, for an embedding model with a calibrated bar; RENOR_SESSION_EXPAND_RANK=0 restores the above): the
531
+ * statements of EVERY note in the window compete by their cosine to the query (one `VectorIndex.search` among their
532
+ * chunk ids: no embedding, no model call), and the best are added while they fit in `SESSION_EXPAND_RANKED_TOKENS`, each
533
+ * note's laid out in document order. The statement limits are the former expansion's (120 for a conversation, 200 for a
534
+ * document), and only the cosine ranks: nothing the note's author writes (a heading, a speaker label) adds to it. Why:
535
+ * the expansion above spends its budget on every short statement of five notes whatever the query, and the gold
536
+ * evidence of a multi-session question sits in a session ranked below five. Measured on the held-out 400 in
537
+ * docs/evals/results/RANKED-EXPANSION-20261011.md. Armed only for a calibrated model because it relies on the
538
+ * cosine's order being meaningful: the lexical eval stub and an unmeasured model keep the expansion above, byte for byte.
539
+ * An OpenAI-compatible endpoint that returns a calibrated model's vectors is armed by the operator's declaration
540
+ * (RENOR_EMBEDDING_SERVED_BY, see calibratedModelKey); docs/evals/results/EXPANSION-PATH-ALIGNMENT-20261018.md.
541
+ *
542
+ * Unset is five notes. 0, a value that names no note, a budget that allows nothing or both statement limits admitting
543
+ * nothing is off, and off costs nothing: no statement is prepared and the result is what it was before the expansion
544
+ * existed, byte for byte. The budget bounds what is added; nothing in the window is cut to make room. A caller that
545
+ * cuts the result by position cuts what the layout puts last: by score, the added hits; newest first, the oldest
546
+ * session's hits, matched or added. Measured with the benchmark client's 6,000-token cap: five notes lost no question
547
+ * at any budget, seven notes unbounded lost 3 whose evidence the window had delivered. And a caller with a small
548
+ * budget of its own spends it on added hits too, so it must not let them take a match's room: get_context (800 tokens
549
+ * by default) gives them only what its facts and its matches leave (mvp/agentMemory.ts applyBudget).
550
+ */
551
+ /**
552
+ * The longest statement of a conversation-shaped note the expansion adds.
553
+ *
554
+ * 120, not 100: the evidence turns of the questions that still miss at 100 sit just over it. Measured on LongMemEval-S
555
+ * 2026-10-10 (cache-only retrieval ablation, practice split and held-out 400): the practice split's remaining
556
+ * multi-session misses hold statements of 101-117 tokens, and every one of them is under 120. The held-out curve at the
557
+ * shipped defaults, 376 answerable questions: a cap of 100 delivers every gold evidence turn on 341 questions in 2,822
558
+ * mean client tokens, 120 on 349 in 2,921 (+8 questions, +3.5%), 150 on 351 in 3,047 (+10, +8.0%); the 120-to-150 step is
559
+ * 2 questions inside the noise (exact McNemar p = 0.500). The 2026-10-07 record
560
+ * (docs/evals/results/SESSION-EXPAND-STATEMENT-LIMIT-20261007.md) adds that the answer-level effect of the limit is flat
561
+ * from 120 to 200 while the delivered context keeps growing, so 120 is the knee of the curve. Documents keep 200: their
562
+ * statements were measured separately and a flat 100 there lost 186 delivered answer statements.
563
+ */
564
+ exports.SESSION_EXPAND_MAX_CHUNK_TOKENS = 120;
565
+ /** The longest statement of a note that is not shaped like a conversation (a document): the limit before the cap. */
566
+ exports.SESSION_EXPAND_DOCUMENT_MAX_CHUNK_TOKENS = 200;
567
+ exports.SESSION_EXPAND_DEFAULT_TOKENS = 2000;
568
+ /** The ranked expansion's budget. Its statement limits are the former expansion's (120 for a conversation, 200 for a document). */
569
+ exports.SESSION_EXPAND_RANKED_TOKENS = 1300;
570
+ /** The `reason` of a hit the expansion added: no word of the query matched it, and it has no score of its own. */
571
+ exports.SESSION_EXPAND_REASON = 'session-expand';
572
+ /** How many of the window's best notes are expanded when RENOR_SESSION_EXPAND is unset. */
573
+ exports.SESSION_EXPAND_DEFAULT_NOTES = 5;
574
+ function resolveSessionExpand(env = process.env) {
575
+ const rawNotes = (env.RENOR_SESSION_EXPAND ?? '').trim();
576
+ const notes = rawNotes === '' ? exports.SESSION_EXPAND_DEFAULT_NOTES : Number(rawNotes);
577
+ if (!Number.isFinite(notes) || notes < 1)
578
+ return null; // off: 0, or a value that names no note
579
+ const rawBudget = (env.RENOR_SESSION_EXPAND_TOKENS ?? '').trim();
580
+ const budget = rawBudget === '' ? exports.SESSION_EXPAND_DEFAULT_TOKENS : Number(rawBudget);
581
+ if (!Number.isFinite(budget) || budget < 1)
582
+ return null; // a budget that allows nothing adds nothing
583
+ // Each class has its own limit; one that admits no statement adds nothing for that class, both of them add nothing at all.
584
+ const limit = (raw, fallback) => {
585
+ const text = (raw ?? '').trim();
586
+ const value = text === '' ? fallback : Number(text);
587
+ return Number.isFinite(value) && value >= 1 ? Math.floor(value) : 0;
588
+ };
589
+ const statementTokens = limit(env.RENOR_SESSION_EXPAND_STATEMENT_TOKENS, exports.SESSION_EXPAND_MAX_CHUNK_TOKENS);
590
+ const documentStatementTokens = limit(env.RENOR_SESSION_EXPAND_DOCUMENT_STATEMENT_TOKENS, exports.SESSION_EXPAND_DOCUMENT_MAX_CHUNK_TOKENS);
591
+ if (statementTokens === 0 && documentStatementTokens === 0)
592
+ return null;
593
+ return { notes: Math.floor(notes), tokenBudget: Math.floor(budget), statementTokens, documentStatementTokens };
594
+ }
595
+ function resolveRankedExpansion(env = process.env) {
596
+ if ((env.RENOR_SESSION_EXPAND_RANK ?? '').trim() === '0')
597
+ return null;
598
+ const legacy = resolveSessionExpand(env);
599
+ if (!legacy)
600
+ return null;
601
+ const rawNotes = (env.RENOR_SESSION_EXPAND ?? '').trim();
602
+ const rawBudget = (env.RENOR_SESSION_EXPAND_TOKENS ?? '').trim();
603
+ return {
604
+ notes: rawNotes === '' ? Number.POSITIVE_INFINITY : legacy.notes,
605
+ tokenBudget: rawBudget === '' ? exports.SESSION_EXPAND_RANKED_TOKENS : legacy.tokenBudget,
606
+ statementTokens: legacy.statementTokens,
607
+ documentStatementTokens: legacy.documentStatementTokens,
608
+ };
609
+ }
610
+ /**
611
+ * The calibrated key (CALIBRATED_BARS) of the embedding model behind a provider, or null when there is none.
612
+ *
613
+ * A key names the provider that made the vectors. An OpenAI-compatible endpoint that returns Vertex's gemini-embedding-001
614
+ * vectors (the benchmark's embedding cache proxy replays them) declares itself `openai:gemini-embedding-001:768`, which is
615
+ * not calibrated, so it keeps the former expansion where a hosted workspace, which calls Vertex itself, runs the ranked one.
616
+ * RENOR_EMBEDDING_SERVED_BY (default unset) is the operator's declaration of whose vectors the endpoint returns: `vertex`
617
+ * there counts the provider as the calibrated model, but only when the key that makes (the declared kind with the provider's
618
+ * own model and dimensions) is in CALIBRATED_BARS. Any other value, model or size changes nothing, and the declaration is the
619
+ * operator's word: nothing here checks the vectors. Read here and nowhere else; a hosted workspace is unaffected.
620
+ */
621
+ function calibratedModelKey(identity, dimensions, env = process.env) {
622
+ if (!identity)
623
+ return null;
624
+ const own = (0, contextRelevance_1.embeddingModelKey)(identity.kind, identity.model, dimensions);
625
+ if (own in contextRelevance_1.CALIBRATED_BARS)
626
+ return own;
627
+ const servedBy = (env.RENOR_EMBEDDING_SERVED_BY ?? '').trim().toLowerCase();
628
+ if (servedBy === '')
629
+ return null;
630
+ const declared = (0, contextRelevance_1.embeddingModelKey)(servedBy, identity.model, dimensions);
631
+ return declared in contextRelevance_1.CALIBRATED_BARS ? declared : null;
632
+ }
633
+ function sessionExpansionHits(db, window, expansion, ranking) {
634
+ // A note is ranked by its best chunk in the window. A fact row (chunkId < 0) is no chunk of a note, and a ledger
635
+ // event is no session's chunk: neither ranks a note.
636
+ const best = new Map();
637
+ for (const hit of window) {
638
+ if (hit.chunkId > 0 && (0, ledgerLiveness_1.ledgerEventIds)(hit).length === 0 && hit.score > (best.get(hit.notePath)?.score ?? Number.NEGATIVE_INFINITY)) {
639
+ best.set(hit.notePath, hit);
640
+ }
641
+ }
642
+ const ranked = ranking?.ranked;
643
+ if (ranked)
644
+ expansion = { ...expansion, ...ranked };
645
+ const notes = [...best.values()].sort((a, b) => b.score - a.score).slice(0, expansion.notes);
646
+ if (notes.length === 0)
647
+ return [];
648
+ const rank = new Map(notes.map((hit, i) => [hit.notePath, i]));
649
+ // A statement is a whole section: a chunk that no other chunk of its note shares a heading path with. The chunker
650
+ // cuts a section of over 400 tokens into paragraphs under one heading path, and such a piece is a fragment of a long
651
+ // answer, not something said in a few words.
652
+ const paths = notes.map(hit => hit.notePath);
653
+ // What a note is shaped like decides its limit: every heading path of its chunks, not only the statements'.
654
+ const headings = new Map();
655
+ for (const row of (0, statements_1.listStatement)(db, paths.length, marks => `SELECT note_path AS notePath, heading_path AS headingPath FROM chunks WHERE note_path IN (${marks})`).all(...(0, statements_1.paddedList)(paths))) {
656
+ const list = headings.get(row.notePath) ?? [];
657
+ list.push(row.headingPath);
658
+ headings.set(row.notePath, list);
659
+ }
660
+ const limitOf = new Map(paths.map(notePath => [
661
+ notePath,
662
+ (0, conversationShape_1.isConversationShaped)(headings.get(notePath) ?? []) ? expansion.statementTokens : expansion.documentStatementTokens,
663
+ ]));
664
+ const rows = db.prepare(`SELECT chunkId, notePath, headingPath, text, tokens FROM (
665
+ SELECT id AS chunkId, note_path AS notePath, heading_path AS headingPath, text, token_count AS tokens,
666
+ start_line AS startLine, COUNT(*) OVER (PARTITION BY note_path, heading_path) AS parts
667
+ FROM chunks WHERE note_path IN (${paths.map(() => '?').join(',')})
668
+ ) WHERE parts = 1 AND tokens <= ?
669
+ ORDER BY startLine, chunkId`).all(...paths, Math.max(expansion.statementTokens, expansion.documentStatementTokens)).filter(row => row.tokens <= limitOf.get(row.notePath))
670
+ .sort((a, b) => rank.get(a.notePath) - rank.get(b.notePath)); // stable: document order inside a note
671
+ const inWindow = new Set(window.map(hit => hit.chunkId));
672
+ const seen = new Set(window.map(hitKey));
673
+ const added = [];
674
+ let spent = 0;
675
+ let candidates = rows;
676
+ if (ranking) {
677
+ const eligible = rows.filter(row => !inWindow.has(row.chunkId) && !isSpeakerStub(row.text) && (0, ledgerLiveness_1.ledgerEventIds)(row).length === 0 && !seen.has(hitKey(row)));
678
+ const cosine = new Map(ranking.vectorIndex.search(ranking.queryVector, Math.max(1, eligible.length), eligible.map(r => r.chunkId)).map(h => [h.chunkId, h.score]));
679
+ const picked = [];
680
+ let room = expansion.tokenBudget;
681
+ // Cosine and nothing else: a heading, a speaker label or a front-matter field is text the note's author wrote, and
682
+ // a ranking signal taken from it would be one more way for a planted note to steer what the expansion delivers.
683
+ const cosineOf = (row) => cosine.get(row.chunkId) ?? -1;
684
+ for (const row of [...eligible].sort((a, b) => cosineOf(b) - cosineOf(a) || a.chunkId - b.chunkId)) {
685
+ if (row.tokens > room)
686
+ break;
687
+ room -= row.tokens;
688
+ picked.push(row);
689
+ }
690
+ const chosen = new Set(picked.map(r => r.chunkId));
691
+ candidates = rows.filter(r => chosen.has(r.chunkId)); // note rank, then document order, as the unranked expansion lays them out
692
+ }
693
+ for (const row of candidates) {
694
+ // A ledger event is never added: liveness (closed events) and scope (per event) are judged on the window only.
695
+ if (inWindow.has(row.chunkId) || isSpeakerStub(row.text) || (0, ledgerLiveness_1.ledgerEventIds)(row).length > 0)
696
+ continue;
697
+ const key = hitKey(row);
698
+ if (seen.has(key))
699
+ continue;
700
+ if (spent + row.tokens > expansion.tokenBudget)
701
+ break;
702
+ spent += row.tokens;
703
+ seen.add(key);
704
+ const sessionDate = best.get(row.notePath).session_date;
705
+ added.push({
706
+ notePath: row.notePath,
707
+ chunkId: row.chunkId,
708
+ headingPath: row.headingPath,
709
+ text: row.text,
710
+ score: 0,
711
+ reason: exports.SESSION_EXPAND_REASON,
712
+ ...(sessionDate ? { session_date: sessionDate } : {}),
713
+ });
714
+ }
715
+ return added;
716
+ }
717
+ /** Prepares `sql` on first use, and once per connection: for lookups that run once per hit, and on every read. */
718
+ function onceStatement(db, sql) {
719
+ let statement;
720
+ return () => (statement ??= (0, statements_1.prepareOnce)(db, sql));
721
+ }
722
+ function getEntityIdsForNotes(db, notePaths) {
723
+ if (notePaths.length === 0)
724
+ return [];
725
+ return (0, statements_1.listStatement)(db, notePaths.length, marks => `SELECT id, source_note as notePath FROM entities WHERE source_note IN (${marks})`).all(...(0, statements_1.paddedList)(notePaths)).map(r => ({ id: r.id, notePath: r.notePath }));
726
+ }
727
+ function getRelatedEntityIds(db, entityIds) {
728
+ if (entityIds.length === 0)
729
+ return [];
730
+ const ids = entityIds.map(e => e.id);
731
+ const rows = (0, statements_1.listStatement)(db, ids.length, marks => `SELECT DISTINCT target_entity_id as id FROM relations WHERE source_entity_id IN (${marks})
732
+ UNION
733
+ SELECT DISTINCT source_entity_id as id FROM relations WHERE target_entity_id IN (${marks})`).all(...(0, statements_1.paddedList)(ids), ...(0, statements_1.paddedList)(ids));
734
+ const existing = new Set(ids);
735
+ return rows.map(r => r.id).filter(id => !existing.has(id));
736
+ }
737
+ function getNotesForEntities(db, entityIds) {
738
+ if (entityIds.length === 0)
739
+ return [];
740
+ return (0, statements_1.listStatement)(db, entityIds.length, marks => `SELECT DISTINCT source_note FROM entities WHERE id IN (${marks})`).all(...(0, statements_1.paddedList)(entityIds)).map(r => r.source_note);
741
+ }
742
+ function getBestChunksForNotes(db, notePaths, limit, keep) {
743
+ if (notePaths.length === 0)
744
+ return [];
745
+ // The longest chunk of each note, the first of equally long ones; the notes in path order. Neither is left to row
746
+ // order, which follows the order indexing wrote the notes in.
747
+ const chunks = (0, statements_1.listStatement)(db, notePaths.length, marks => `SELECT chunkId, notePath, headingPath, text, startLine, endLine FROM (
748
+ SELECT id as chunkId, note_path as notePath, heading_path as headingPath, text, start_line as startLine, end_line as endLine,
749
+ ROW_NUMBER() OVER (PARTITION BY note_path ORDER BY token_count DESC, id) as rn
750
+ FROM chunks WHERE note_path IN (${marks})
751
+ ) WHERE rn = 1
752
+ ORDER BY notePath
753
+ LIMIT ?`).all(...(0, statements_1.paddedList)(notePaths), keep ? -1 : limit);
754
+ // Given `keep`, the chunks it shows are judged before the limit (LIMIT -1 is none).
755
+ return keep ? chunks.filter(keep).slice(0, limit) : chunks;
756
+ }
757
+ async function retrieveContext(db, vectorIndex, provider, query, topK = 20, priorityPaths = [], clock = {}, opts = {}) {
758
+ contentfulHeld.clear(); // what another read worked out is not this read's
759
+ const factHits = (0, factsFirst_1.factsFirstHits)(db, query, Math.min(3, topK), opts.scope?.shows);
760
+ const queryProbe = opts.probe ?? {}; // the query vector, for the expansion's ranking
761
+ const candidateLimit = Math.min(40, topK * 4);
762
+ const semanticHits = vectorIndex && provider
763
+ ? await (0, semantic_1.semanticSearch)(db, vectorIndex, provider, query, candidateLimit, queryProbe, opts.scope)
764
+ : (() => {
765
+ const match = (0, queryHelpers_1.quoteFtsQuery)(query);
766
+ if (!match)
767
+ return [];
768
+ // No embedding provider: the keyword index stands in for the semantic lane. Its rows are judged (ledger
769
+ // header and events the words are not in, then the scope) before the page is cut, as every keyword reader does.
770
+ return (0, queryHelpers_1.shownPage)(rows => (0, keyword_1.keywordSearch)(db, match, rows), rows => {
771
+ const live = (0, ledgerPresentation_2.keywordLedgerHits)(rows.map(r => ({ ...r, score: 1 / (1 + Math.abs(r.score)) })), opts.isLedgerNote, query);
772
+ return opts.scope ? live.filter(hit => opts.scope.shows(hit)) : live;
773
+ }, candidateLimit);
774
+ })();
775
+ // The window is ranked without waiting for anything, so it is read in one transaction (inReadSnapshot).
776
+ const window = (0, readCache_1.inReadSnapshot)(db, () => rankWindow({ db, query, topK, priorityPaths, clock, opts, factHits, semanticHits }));
777
+ return finishContext({ db, vectorIndex, provider, query, queryProbe, window, bestFirst: opts.bestFirst === true });
778
+ }
779
+ /** The window of hits of a read: scored, with the graph of its notes, their facts and dates, live and capped per note. Waits for nothing. */
780
+ function rankWindow(read) {
781
+ const { db, query, topK, priorityPaths, clock, opts, factHits, semanticHits } = read;
782
+ // Raw cosines, kept only for a caller that asked to see them (get_context's relevance signal).
783
+ const cosineByChunk = opts.probe ? new Map(semanticHits.map(h => [h.chunkId, h.score])) : null;
784
+ const entityHit = (0, entityLookup_1.lookupEntity)(db, query.trim());
785
+ // As-of lane pre-compute: explicit past-date queries must not receive the
786
+ // "now" recency boost below — the newest note describes the current world,
787
+ // not the asked-about past. Gated by its own kill switch.
788
+ const asofPreNowMs = clock.nowMs ?? Date.now();
789
+ const asofPrePast = asofPastLike(query, asofPreNowMs);
790
+ // Prepared once per call, not once per hit: each of these runs for every semantic hit (dozens per call).
791
+ const noteRowStmt = onceStatement(db, 'SELECT modified_at, frontmatter_json FROM notes WHERE path = ?');
792
+ const ownFactsStmt = onceStatement(db, 'SELECT predicate, object_text, valid_to FROM facts WHERE subject_entity_id = ?');
793
+ const noteRows = new Map();
794
+ const noteRow = (notePath) => {
795
+ if (!noteRows.has(notePath))
796
+ noteRows.set(notePath, noteRowStmt().get(notePath));
797
+ return noteRows.get(notePath);
798
+ };
799
+ const ownFactRows = new Map();
800
+ const scored = semanticHits.map(h => {
801
+ let score = h.score;
802
+ let reason = 'semantic';
803
+ if (isSpeakerStub(h.text)) {
804
+ score -= 0.5;
805
+ reason = 'semantic+stub';
806
+ }
807
+ else {
808
+ const boost = contentBoost(h.text);
809
+ if (boost > 0) {
810
+ score += boost;
811
+ reason += '+content';
812
+ }
813
+ }
814
+ if (priorityPaths.some(p => h.notePath.includes('/' + p) || h.notePath.startsWith(p))) {
815
+ score += 0.1;
816
+ reason += '+priority';
817
+ }
818
+ if (entityHit?.sourceNote === h.notePath) {
819
+ score += 0.2;
820
+ reason += '+entity';
821
+ }
822
+ // Time-decay recency: +0.1 for today, linearly decaying to 0 over 30 days.
823
+ // Skipped for explicit past-date (as-of) queries: recency answers "now",
824
+ // the as-of query asks about "then".
825
+ const row = noteRow(h.notePath);
826
+ const session_date = resolveSessionDate(row?.frontmatter_json, row?.modified_at);
827
+ if (row && !asofPrePast) {
828
+ const daysSince = ((clock.nowMs ?? Date.now()) - new Date(row.modified_at).getTime()) / 86400000;
829
+ const recencyBoost = Math.max(0, 0.1 * (1 - daysSince / 30));
830
+ if (recencyBoost > 0.001) {
831
+ score += recencyBoost;
832
+ reason += '+recency';
833
+ }
834
+ }
835
+ return { ...h, score, reason, session_date };
836
+ });
837
+ // Graph expansion
838
+ const hitNotePaths = [...new Set(semanticHits.map(h => h.notePath))];
839
+ const hitEntityIds = getEntityIdsForNotes(db, hitNotePaths);
840
+ const relatedEntityIds = getRelatedEntityIds(db, hitEntityIds);
841
+ const relatedNotePaths = getNotesForEntities(db, relatedEntityIds);
842
+ const newPaths = relatedNotePaths.filter(p => !hitNotePaths.includes(p));
843
+ if (newPaths.length > 0) {
844
+ const relatedChunks = getBestChunksForNotes(db, newPaths, topK, opts.scope?.shows);
845
+ for (const chunk of relatedChunks) {
846
+ const row = noteRow(chunk.notePath);
847
+ scored.push({
848
+ ...chunk,
849
+ score: 0.3,
850
+ reason: 'graph',
851
+ session_date: resolveSessionDate(row?.frontmatter_json, row?.modified_at),
852
+ });
853
+ }
854
+ }
855
+ // Fact-aware boost + temporal ranking. Two complementary signals, both general:
856
+ //
857
+ // 1. Lifecycle facts (facts table, incl. MCP-written facts resolved by
858
+ // canonical_name across entity rows — not just per-note entities): chunks
859
+ // carrying a superseded value are demoted, chunks carrying the current
860
+ // value are lifted, and explicit past-intent queries invert both.
861
+ // 2. Corpus signal (works without an LLM fact extractor — RenorBench, and
862
+ // real vaults with a History/ convention): notes whose frontmatter marks
863
+ // them historical (kind/type: history|historical, historical: true,
864
+ // archived: true) are demoted for present-tense queries and lifted for
865
+ // past-intent ones.
866
+ // No hardcoded subject or cue table. Kill switch: RENOR_TEMPORAL_RANKING=0.
867
+ const temporal = temporalRankingEnabled();
868
+ const pastIntent = temporal && hasStrictPastIntent(query);
869
+ // As-of lane: explicit past-date queries behave past-like for presentation
870
+ // ranking even without past-intent cue words ("in April 2025" has none).
871
+ const asofNowMs = clock.nowMs ?? Date.now();
872
+ const asofPast = asofPastLike(query, asofNowMs);
873
+ const asofRef = asofDatedRef(query, asofNowMs);
874
+ const historicalNotes = new Set();
875
+ const factsByNote = new Map();
876
+ if (temporal) {
877
+ // Frontmatter historical flags for all hit notes (one batched query).
878
+ if (hitNotePaths.length > 0) {
879
+ const noteRows = (0, statements_1.listStatement)(db, hitNotePaths.length, marks => `SELECT path, frontmatter_json FROM notes WHERE path IN (${marks})`).all(...(0, statements_1.paddedList)(hitNotePaths));
880
+ for (const row of noteRows) {
881
+ if (frontmatterMarksHistorical(row.frontmatter_json))
882
+ historicalNotes.add(row.path);
883
+ }
884
+ }
885
+ // Lifecycle facts: a note's entities share canonical names with MCP-written
886
+ // entities (source_note '_mcp_'), so expand by canonical_name — otherwise
887
+ // MCP facts would never match corpus hits.
888
+ if (hitEntityIds.length > 0) {
889
+ const ids = hitEntityIds.map(e => e.id);
890
+ const nameRows = (0, statements_1.listStatement)(db, ids.length, marks => `SELECT DISTINCT canonical_name FROM entities WHERE id IN (${marks})`).all(...(0, statements_1.paddedList)(ids));
891
+ const names = nameRows.map(r => String(r.canonical_name)).filter(Boolean);
892
+ if (names.length > 0) {
893
+ const entityRows = (0, statements_1.listStatement)(db, names.length, marks => `SELECT id, canonical_name, source_note FROM entities WHERE canonical_name COLLATE NOCASE IN (${marks})`).all(...(0, statements_1.paddedList)(names));
894
+ const nameByEntityId = new Map(entityRows.map(r => [r.id, String(r.canonical_name).toLowerCase()]));
895
+ const factRows = (0, statements_1.listStatement)(db, entityRows.length, marks => `SELECT subject_entity_id, predicate, object_text, valid_to FROM facts WHERE subject_entity_id IN (${marks})`).all(...(0, statements_1.paddedList)(entityRows.map(r => r.id)));
896
+ for (const hitEntity of hitEntityIds) {
897
+ const ownName = nameByEntityId.get(hitEntity.id);
898
+ if (ownName === undefined)
899
+ continue;
900
+ const merged = factsByNote.get(hitEntity.notePath) ?? [];
901
+ for (const f of factRows) {
902
+ if (nameByEntityId.get(f.subject_entity_id) === ownName)
903
+ merged.push(f);
904
+ }
905
+ factsByNote.set(hitEntity.notePath, merged);
906
+ }
907
+ }
908
+ }
909
+ }
910
+ // Distinctive query words (not pure temporal/function markers): used to anchor
911
+ // the past-intent lift to content — an unconditional lift rockets OTHER
912
+ // subjects' historical notes above multi-constraint gold (RenorBench hop-009).
913
+ const TEMPORAL_STOPWORDS = new Set(['previously', 'before', 'used', 'earlier', 'former', 'formerly', 'past', 'history', 'was', 'were', 'did', 'does', 'the', 'and', 'what', 'where', 'who', 'when', 'from']);
914
+ const contentfulQueryWords = (query.toLowerCase().match(/[\p{L}\p{M}]{3,}/gu) ?? [])
915
+ .filter(w => !TEMPORAL_STOPWORDS.has(w));
916
+ const hitText = (h) => h.text.toLowerCase();
917
+ const queryWords = query.toLowerCase().split(/\s+/);
918
+ for (const hit of scored) {
919
+ // Legacy fact-aware boost: scoped to the hit note's OWN entity facts exactly
920
+ // as before this change — the canonical-name expansion below exists for
921
+ // temporal decisions only and must not perturb the legacy boost distribution.
922
+ const ownEntityFacts = (() => {
923
+ const own = hitEntityIds.find(e => e.notePath === hit.notePath);
924
+ if (!own)
925
+ return [];
926
+ let rows = ownFactRows.get(own.id);
927
+ if (!rows)
928
+ ownFactRows.set(own.id, rows = ownFactsStmt().all(own.id));
929
+ return rows;
930
+ })();
931
+ const facts = factsByNote.get(hit.notePath) ?? [];
932
+ const factMatch = ownEntityFacts.some(f => queryWords.some(w => (f.object_text ?? '').toLowerCase().includes(w) ||
933
+ (f.predicate ?? '').toLowerCase().includes(w)));
934
+ if (factMatch) {
935
+ hit.score += 0.15;
936
+ hit.reason += '+fact';
937
+ }
938
+ // As-of lane: the query names a past date, so hits carrying the same
939
+ // calendar reference are likelier to hold the as-of answer than the
940
+ // newest note (which describes the current world, not the asked one).
941
+ if (asofRef) {
942
+ const yearToken = String(asofRef.year);
943
+ const lowerText = hit.text.toLowerCase();
944
+ if (lowerText.includes(yearToken)) {
945
+ hit.score += 0.08;
946
+ hit.reason += '+asof-year';
947
+ if (asofRef.month !== undefined) {
948
+ const monthName = Object.keys(ASOF_MONTHS).find(m => ASOF_MONTHS[m] === asofRef.month);
949
+ if (monthName && lowerText.includes(monthName)) {
950
+ hit.score += 0.04;
951
+ hit.reason += '+asof-month';
952
+ }
953
+ }
954
+ }
955
+ }
956
+ if (temporal) {
957
+ const text = hitText(hit);
958
+ // Historical-note signal is lift-only, on strict past-intent queries:
959
+ // a blanket present-tense demotion pushed gold historical notes out of the
960
+ // window for queries like "which tea drinker grew up in Sacramento?" —
961
+ // present-tense phrasing, past answer. The lifecycle-fact staleness
962
+ // demotion below remains the general present-tense mechanism.
963
+ const isHistoricalNote = historicalNotes.has(hit.notePath);
964
+ if (pastIntent && isHistoricalNote) {
965
+ // Content-anchored lift: the historical note must itself match the query's
966
+ // distinctive words (≥2), so an unrelated person's history note — however
967
+ // semantically adjacent — does not ride the intent signal past the gold.
968
+ const contentOverlap = contentfulQueryWords.filter(w => text.includes(w)).length;
969
+ if (contentOverlap >= 2) {
970
+ // Must clear recency (+0.1 max) + fact boost (+0.15) the current note
971
+ // typically carries, or past-intent queries can never win.
972
+ hit.score += 0.3;
973
+ hit.reason += '+historical-intent';
974
+ }
975
+ }
976
+ const currentPredicates = new Set(facts.filter(f => f.valid_to === null).map(f => String(f.predicate)));
977
+ // Demote: chunk carries a value the lifecycle has since superseded.
978
+ for (const f of facts) {
979
+ if (f.valid_to === null || !currentPredicates.has(String(f.predicate)))
980
+ continue;
981
+ const stale = (f.object_text ?? '').trim().toLowerCase();
982
+ if (stale.length >= 3 && text.includes(stale)) {
983
+ if (pastIntent) {
984
+ hit.score += 0.1;
985
+ hit.reason += '+historical-intent';
986
+ }
987
+ else {
988
+ hit.score -= 0.15;
989
+ hit.reason += '+stale-fact';
990
+ }
991
+ break; // one demotion per hit; stale signal already applied
992
+ }
993
+ }
994
+ // Lift: chunk carries the currently-valid value for a superseded predicate.
995
+ if (!pastIntent) {
996
+ for (const f of facts) {
997
+ if (f.valid_to !== null)
998
+ continue;
999
+ const wasSuperseded = facts.some(g => g.valid_to !== null && String(g.predicate) === String(f.predicate));
1000
+ if (!wasSuperseded)
1001
+ continue; // plain current facts already get the fact boost above
1002
+ const cur = (f.object_text ?? '').trim().toLowerCase();
1003
+ if (cur.length >= 3 && text.includes(cur)) {
1004
+ hit.score += 0.1;
1005
+ hit.reason += '+current-fact';
1006
+ break;
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+ const ranked = dedupeHits(scored.sort(semantic_1.byScoreThenPlace));
1013
+ // Fail-open floor. The stub filter must never empty the window: a zero-hit
1014
+ // retrieval is a hard abort on the product path, because the benchmark client
1015
+ // refuses to score a case with no hits ("Retrieval precondition failed: public
1016
+ // CLI search returned zero hits"). That is strictly worse than surfacing a stub.
1017
+ // Measured 2026-09-29: an arm carrying a metadata-strip variant of this filter
1018
+ // died at case 122/500 on exactly this precondition. Fall back to the unfiltered
1019
+ // ranking when filtering would leave nothing.
1020
+ const filtered = ranked.filter(h => !isSpeakerStub(h.text));
1021
+ const contentful = filtered.length > 0 ? filtered : ranked;
1022
+ const datedFactHits = factHits.map(h => {
1023
+ const row = noteRow(h.notePath);
1024
+ return { ...h, session_date: resolveSessionDate(row?.frontmatter_json, row?.modified_at) };
1025
+ });
1026
+ const seen = new Set(datedFactHits.map(h => h.text));
1027
+ // The ledger's own header is no memory: it comes off with the closed events, before the page is cut.
1028
+ const mixed = (0, ledgerPresentation_1.dropLedgerPreamble)((0, ledgerLiveness_1.resolveLedgerHits)([...datedFactHits, ...contentful.filter(h => !seen.has(h.text))], (0, ledgerLiveness_1.closedLedgerEvents)(db), opts.includeHistory === true), opts.isLedgerNote);
1029
+ const diversityCap = resolveSessionDiversityCap(process.env, opts.noteCapWhenUnset);
1030
+ let hits = applySessionDiversity(mixed, diversityCap, topK).slice(0, topK).map(h => ({
1031
+ notePath: h.notePath,
1032
+ chunkId: h.chunkId,
1033
+ headingPath: h.headingPath,
1034
+ text: h.text,
1035
+ score: h.score,
1036
+ reason: h.reason,
1037
+ ...(h.session_date ? { session_date: h.session_date } : {}),
1038
+ ...(cosineByChunk?.has(h.chunkId) ? { similarity: cosineByChunk.get(h.chunkId) } : {}),
1039
+ // A fact an identity question read names its author, so the reader's view can mark it (mvp/factMarkers.ts).
1040
+ ...('author' in h && h.author !== undefined ? { author: h.author } : {}),
1041
+ }));
1042
+ return { hits, noteRow, temporal, pastIntent, asofPast };
1043
+ }
1044
+ /** The hits dated, joined by the statements of their sessions and laid out. The first wait is for the date of an entry of a daily note. */
1045
+ async function finishContext(read) {
1046
+ const { db, vectorIndex, provider, query, queryProbe, window, bestFirst } = read;
1047
+ const { noteRow, temporal, pastIntent, asofPast } = window;
1048
+ let { hits } = window;
1049
+ // A hit that is a ledger event is dated by the event, and one that is an entry of a daily note by the entry: each
1050
+ // is one note, so the note's date is one date for every event, or every entry of the day, in it. Decided on the
1051
+ // text a hit still has after liveness filtering, and only for the hits that are returned. Everything else keeps its
1052
+ // note's date; no score is touched. Entry headers, filenames and replayed event times are content too: none may
1053
+ // override the indexed file's write-time bound, even on already indexed notes.
1054
+ const eventTime = (0, ledgerEventTime_1.createLedgerEventClock)(db);
1055
+ const entryTime = (0, dailyEntryTime_1.createDailyEntryClock)();
1056
+ // The dates of events are read as the hits are mapped, before any of them waits: in one transaction (inReadSnapshot).
1057
+ hits = await Promise.all((0, readCache_1.inReadSnapshot)(db, () => hits.map(async (h) => {
1058
+ const derived = eventTime(h) ?? await entryTime(h);
1059
+ if (derived === undefined)
1060
+ return h;
1061
+ const session_date = clampSessionDate(derived, noteRow(h.notePath)?.modified_at);
1062
+ return { ...h, session_date };
1063
+ })));
1064
+ // RENOR_SESSION_EXPAND (default 5 notes, 0 is off): the short statements of the best notes in the window join it,
1065
+ // under a token budget. The window itself is not touched; see sessionExpansionHits.
1066
+ const expansion = resolveSessionExpand();
1067
+ // Ranking by cosine is armed only for an embedding model the product has calibrated (CALIBRATED_BARS): the order
1068
+ // of cosines was measured for that model. Any other provider (the lexical eval stub, an unmeasured model) keeps the
1069
+ // former expansion, unless the operator declares that its endpoint serves a calibrated model (calibratedModelKey).
1070
+ const rankedExpansion = calibratedModelKey(provider?.identity, provider?.dimensions) !== null ? resolveRankedExpansion() : null;
1071
+ if (expansion)
1072
+ hits = [...hits, ...(0, readCache_1.inReadSnapshot)(db, () => sessionExpansionHits(db, hits, expansion, rankedExpansion && queryProbe.queryVector && vectorIndex ? { vectorIndex, queryVector: queryProbe.queryVector, ranked: rankedExpansion } : undefined))];
1073
+ // Default-on (S0 78.4%): lay the already-selected window out newest-session-first.
1074
+ // Does not replace scoring or change which chunks enter top-K. Opt out: RENOR_NEWEST_FIRST=0.
1075
+ // An explicit past-intent query overrides this presentation rerank — "what was
1076
+ // it before" is precisely the case where newest-first is the wrong order.
1077
+ // The as-of lane extends the override to explicit past-date queries, which
1078
+ // carry no past-intent cue words but ask about the old world all the same.
1079
+ // Either way the result says which key it is in order of (`rankedBy`): the order is a presentation
1080
+ // choice the agent cannot see from the hits alone, and hits[0] is not "the answer" unless that key is score.
1081
+ // A read that asks for best match first (search_memory under RENOR_SEARCH_BEST_FIRST) is laid out by score.
1082
+ const newestFirst = !bestFirst && newestFirstEnabled() && !(temporal && pastIntent) && !asofPast;
1083
+ const rankedBy = newestFirst ? 'session_date' : 'score';
1084
+ contentfulHeld.clear(); // nothing outlives the read it was worked out for
1085
+ return { query, hits: orderHits(hits, rankedBy), rankedBy };
1086
+ }