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,726 @@
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.SUMMARY_PROMPT_TEMPLATE = exports.SUMMARY_RETRIES_DEFAULT = exports.SUMMARY_TIMEOUT_MS = exports.SUMMARY_MAX_OUTPUT_TOKENS = exports.SUMMARY_NOTE_MAX_CHARS = exports.SUMMARY_BATCH_MAX_CHARS = exports.SUMMARY_BATCH_MAX_NOTES = exports.ROUTE_ATTEMPTS = exports.ROUTER_RETRIES_DEFAULT = exports.ROUTER_TIMEOUT_MS_DEFAULT = exports.ROUTER_MAX_OUTPUT_TOKENS = exports.STATEMENT_MAX_CHARS = exports.ROUTER_MAX_DELIVERED_TOKENS = exports.ROUTER_MAX_SESSIONS = exports.ROUTER_MAX_NODES = void 0;
7
+ exports.resolveSessionRouterMode = resolveSessionRouterMode;
8
+ exports.resolveRouterProvider = resolveRouterProvider;
9
+ exports.resolveSessionRouterOptions = resolveSessionRouterOptions;
10
+ exports.listRouterNotes = listRouterNotes;
11
+ exports.summaryInputOf = summaryInputOf;
12
+ exports.renderSummaryPrompt = renderSummaryPrompt;
13
+ exports.parseSummaryNodes = parseSummaryNodes;
14
+ exports.buildSessionSummaries = buildSessionSummaries;
15
+ exports.splitQuestionDate = splitQuestionDate;
16
+ exports.billedOutputTokens = billedOutputTokens;
17
+ exports.renderRouterPrompt = renderRouterPrompt;
18
+ exports.parseRouterAnswer = parseRouterAnswer;
19
+ exports.noteStatements = noteStatements;
20
+ exports.applySessionRouter = applySessionRouter;
21
+ exports.routeSearch = routeSearch;
22
+ exports.summarizeForRouter = summarizeForRouter;
23
+ const promises_1 = __importDefault(require("node:fs/promises"));
24
+ const node_path_1 = __importDefault(require("node:path"));
25
+ const o200k_1 = require("../indexer/o200k");
26
+ const parser_1 = require("../indexer/parser");
27
+ const chunker_1 = require("../indexer/chunker");
28
+ const dailyEntries_1 = require("../indexer/dailyEntries");
29
+ const ollamaChat_1 = require("../evidence/ollamaChat");
30
+ const keyPool_1 = require("../evidence/keyPool");
31
+ const options_1 = require("../evidence/options");
32
+ const secondPass_1 = require("../evidence/secondPass");
33
+ const orchestrator_1 = require("../retrieval/orchestrator");
34
+ const routerCache_1 = require("./routerCache");
35
+ /** At most this many notes are shown to the router (~102 prompt tokens per note measured, so ~20k tokens). */
36
+ exports.ROUTER_MAX_NODES = 200;
37
+ /** At most this many routed notes are delivered (the study's cap). */
38
+ exports.ROUTER_MAX_SESSIONS = 15;
39
+ /** Delivered statements, in gpt-tokenizer tokens. Leaves room under a 6,000-token client cap for excerpt labels. */
40
+ exports.ROUTER_MAX_DELIVERED_TOKENS = 5_500;
41
+ /** A heading section whose content is shorter than this many characters is a statement. */
42
+ exports.STATEMENT_MAX_CHARS = 800;
43
+ /** Output ceiling of one routing call (the cheap router measured ~240 output tokens). */
44
+ exports.ROUTER_MAX_OUTPUT_TOKENS = 4_096;
45
+ exports.ROUTER_TIMEOUT_MS_DEFAULT = 10_000;
46
+ /** Transport retries of a routing call when RENOR_ROUTER_RETRIES is unset (each retry backs off 1 s, 2 s, ...). */
47
+ exports.ROUTER_RETRIES_DEFAULT = 1;
48
+ /** One unparseable routing answer is asked once more; a second one is a fallback. */
49
+ exports.ROUTE_ATTEMPTS = 2;
50
+ const NODE_TITLE_MAX_CHARS = 200;
51
+ const NODE_SUMMARY_MAX_CHARS = 1_500;
52
+ /** Summary batching, the study's: up to 8 notes or 56,000 characters per call. */
53
+ exports.SUMMARY_BATCH_MAX_NOTES = 8;
54
+ exports.SUMMARY_BATCH_MAX_CHARS = 56_000;
55
+ /** A note longer than this is summarised from its first 14,000 and last 6,000 characters. */
56
+ exports.SUMMARY_NOTE_MAX_CHARS = 20_000;
57
+ exports.SUMMARY_MAX_OUTPUT_TOKENS = 6_000;
58
+ exports.SUMMARY_TIMEOUT_MS = 180_000;
59
+ /** Transport retries of a summary call when RENOR_ROUTER_RETRIES is unset: index time is not latency-bound. */
60
+ exports.SUMMARY_RETRIES_DEFAULT = 3;
61
+ /** A batch whose answer misses some notes is asked once more. */
62
+ const SUMMARY_ATTEMPTS = 2;
63
+ /**
64
+ * The session-summary prompt, verbatim from the study (adapted there from PageIndex's generate_node_summary). Its
65
+ * hash is part of every summary's cache key, so editing it re-summarises every note.
66
+ */
67
+ exports.SUMMARY_PROMPT_TEMPLATE = `You are building the table of contents for a person's long-term conversation memory. Below are {n} past conversations between the person ("User") and an AI assistant. Each starts with an id and a date.
68
+
69
+ For EACH conversation write a node for the table of contents:
70
+ - "title": a specific title of 4-10 words naming what the conversation is about.
71
+ - "summary": 2-4 sentences describing the main points covered. Also state every concrete personal fact, event, plan, preference, purchase, number or date that the User says about their own life (things they did, own, bought, plan, like, or need to do). Do not include generic advice given by the assistant.
72
+
73
+ Reply with ONLY this JSON, one entry per conversation, using the ids given:
74
+ {"nodes": [{"id": "<id>", "title": "...", "summary": "..."}]}
75
+
76
+ {conversations}
77
+ `;
78
+ /**
79
+ * RENOR_SESSION_ROUTER.
80
+ * unset / 0 / off / false / no off: index and search are exactly what they were (no summaries, no cache file,
81
+ * no `router` field in `search --json`)
82
+ * 1 / on / true / yes / replace the routed statements replace the retrieved chunks (the study's Tu arm)
83
+ * union the retrieved chunks, then the routed statements they do not already hold (BTu)
84
+ * Anything else is refused, like RENOR_EVIDENCE_MODE: a typo must not quietly measure the control arm twice.
85
+ */
86
+ function resolveSessionRouterMode(env = process.env) {
87
+ const raw = (env.RENOR_SESSION_ROUTER ?? '').trim().toLowerCase();
88
+ if (raw === '' || raw === '0' || raw === 'off' || raw === 'false' || raw === 'no')
89
+ return 'off';
90
+ if (raw === '1' || raw === 'on' || raw === 'true' || raw === 'yes' || raw === 'replace')
91
+ return 'replace';
92
+ if (raw === 'union')
93
+ return 'union';
94
+ throw new Error(`Invalid RENOR_SESSION_ROUTER "${raw}": must be off | replace | union`);
95
+ }
96
+ /**
97
+ * The router's OpenAI-compatible provider. A dedicated slot first: RENOR_ROUTER_API_KEY(S) with RENOR_ROUTER_BASE_URL
98
+ * (else the evidence base URL), so the router's model can live on another endpoint than the evidence extractor (say
99
+ * the shipped evidence pass on Ollama and the router on Gemini). Otherwise the evidence pass's provider, as a unit:
100
+ * RENOR_ROUTER_BASE_URL is never combined with the evidence keys, so a key is only ever sent to the endpoint it was
101
+ * configured for. Values are never logged.
102
+ */
103
+ function resolveRouterProvider(env = process.env) {
104
+ const keys = [...(0, keyPool_1.splitKeyList)(env.RENOR_ROUTER_API_KEYS), ...(0, keyPool_1.splitKeyList)(env.RENOR_ROUTER_API_KEY)];
105
+ if (keys.length > 0) {
106
+ return { baseUrl: env.RENOR_ROUTER_BASE_URL?.trim() || env.RENOR_EVIDENCE_BASE_URL, keys, slot: 'router' };
107
+ }
108
+ return { ...(0, options_1.resolveEvidenceProvider)(env), slot: 'evidence' };
109
+ }
110
+ function boundedInt(raw, fallback, min, max) {
111
+ const value = Number((raw ?? '').trim());
112
+ if ((raw ?? '').trim() === '' || !Number.isFinite(value))
113
+ return fallback;
114
+ return Math.min(max, Math.max(min, Math.floor(value)));
115
+ }
116
+ /**
117
+ * Env -> options, or null when the flag is off. The provider is the evidence pass's OpenAI-compatible one
118
+ * (RENOR_EVIDENCE_BASE_URL + RENOR_EVIDENCE_API_KEY(S) or the Ollama pool, read by the same function), so the router
119
+ * model is one more model name on the endpoint the deployment already configured; RENOR_ROUTER_API_KEY(S) +
120
+ * RENOR_ROUTER_BASE_URL give it an endpoint of its own (resolveRouterProvider).
121
+ */
122
+ function resolveSessionRouterOptions(config, env = process.env) {
123
+ const mode = resolveSessionRouterMode(env);
124
+ if (mode === 'off')
125
+ return null;
126
+ const routerModel = env.RENOR_ROUTER_MODEL?.trim() || env.RENOR_EVIDENCE_MODEL?.trim() || secondPass_1.EVIDENCE_MODEL_DEFAULT;
127
+ const summaryModel = env.RENOR_ROUTER_SUMMARY_MODEL?.trim() || routerModel;
128
+ const cachePath = env.RENOR_ROUTER_CACHE?.trim() || node_path_1.default.join(node_path_1.default.dirname(config.dbPath), 'router-cache.db');
129
+ const timeoutMs = boundedInt(env.RENOR_ROUTER_TIMEOUT_MS, exports.ROUTER_TIMEOUT_MS_DEFAULT, 1_000, 120_000);
130
+ const retries = boundedInt(env.RENOR_ROUTER_RETRIES, exports.ROUTER_RETRIES_DEFAULT, 0, 8);
131
+ const summaryRetries = boundedInt(env.RENOR_ROUTER_RETRIES, exports.SUMMARY_RETRIES_DEFAULT, 0, 8);
132
+ const { baseUrl, keys } = resolveRouterProvider(env);
133
+ return {
134
+ mode,
135
+ routerModel,
136
+ summaryModel,
137
+ cachePath,
138
+ timeoutMs,
139
+ retries,
140
+ summaryRetries,
141
+ ...(keys.length > 0
142
+ ? {
143
+ routeChat: (0, ollamaChat_1.createOllamaChat)({ baseUrl, keys, timeoutMs, maxRetries: retries }),
144
+ summaryChat: (0, ollamaChat_1.createOllamaChat)({ baseUrl, keys, timeoutMs: exports.SUMMARY_TIMEOUT_MS, maxRetries: summaryRetries }),
145
+ }
146
+ : {}),
147
+ };
148
+ }
149
+ /**
150
+ * Every indexed note inside the vault except the memory ledger, oldest first (then by path). A note whose index
151
+ * entry is unfinished (`note_hash` '') is left out: it is not searchable either.
152
+ */
153
+ function listRouterNotes(db, vaultPath, ledgerRelPath) {
154
+ const root = node_path_1.default.resolve(vaultPath);
155
+ const ledger = ledgerRelPath ? node_path_1.default.resolve(root, ledgerRelPath) : null;
156
+ const rows = db.prepare("SELECT path, note_hash, frontmatter_json, modified_at FROM notes WHERE note_hash != ''")
157
+ .all();
158
+ const notes = [];
159
+ for (const row of rows) {
160
+ const file = node_path_1.default.isAbsolute(row.path) ? node_path_1.default.resolve(row.path) : node_path_1.default.resolve(root, row.path);
161
+ const rel = node_path_1.default.relative(root, file);
162
+ if (rel === '' || rel === '..' || rel.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(rel))
163
+ continue;
164
+ if (ledger !== null && file === ledger)
165
+ continue;
166
+ const date = (0, orchestrator_1.resolveSessionDate)(row.frontmatter_json, row.modified_at);
167
+ notes.push({ path: row.path, file, noteHash: row.note_hash, ...(date ? { date } : {}), dateMs: (0, orchestrator_1.sessionDateMs)(date) });
168
+ }
169
+ notes.sort((a, b) => {
170
+ const delta = a.dateMs - b.dateMs;
171
+ if (delta !== 0 && !Number.isNaN(delta))
172
+ return delta;
173
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
174
+ });
175
+ return notes.map(({ dateMs: _dateMs, ...note }) => note);
176
+ }
177
+ /** The text a note is summarised from: its body, or its head and tail when it is very long. */
178
+ function summaryInputOf(body) {
179
+ if (body.length <= exports.SUMMARY_NOTE_MAX_CHARS)
180
+ return body;
181
+ return `${body.slice(0, 14_000)}\n\n[... middle of conversation omitted ...]\n\n${body.slice(-6_000)}`;
182
+ }
183
+ function renderSummaryPrompt(batch) {
184
+ const conversations = batch.map(item => `=== CONVERSATION id=${item.id} date=${item.date} ===\n${item.text}`).join('\n\n');
185
+ return exports.SUMMARY_PROMPT_TEMPLATE.replace('{n}', () => String(batch.length)).replace('{conversations}', () => conversations);
186
+ }
187
+ /** A batch id as the model may write it back: case and leading zeros do not matter (`C1` is `c01`). */
188
+ const batchId = (id) => id.trim().toLowerCase().replace(/^c0*(\d+)$/, 'c$1');
189
+ /**
190
+ * `{"nodes": [{id, title, summary}]}` -> id (normalised by batchId) -> node. Entries without an id or without any
191
+ * text are dropped.
192
+ */
193
+ function parseSummaryNodes(text) {
194
+ const out = new Map();
195
+ const trimmed = text.trim().replace(/^```(?:json)?\s*|\s*```$/g, '');
196
+ let payload;
197
+ try {
198
+ payload = JSON.parse(trimmed);
199
+ }
200
+ catch {
201
+ const match = trimmed.match(/\{[\s\S]*\}/);
202
+ if (!match)
203
+ return out;
204
+ try {
205
+ payload = JSON.parse(match[0]);
206
+ }
207
+ catch {
208
+ return out;
209
+ }
210
+ }
211
+ const nodes = payload && typeof payload === 'object' ? payload.nodes : undefined;
212
+ if (!Array.isArray(nodes))
213
+ return out;
214
+ for (const node of nodes) {
215
+ if (!node || typeof node !== 'object' || Array.isArray(node))
216
+ continue;
217
+ const record = node;
218
+ const id = batchId(String(record.id ?? ''));
219
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
220
+ const summary = typeof record.summary === 'string' ? record.summary.trim() : '';
221
+ if (!id || (!title && !summary))
222
+ continue;
223
+ out.set(id, { title: title || 'Untitled conversation', summary });
224
+ }
225
+ return out;
226
+ }
227
+ function batchesOf(items) {
228
+ const batches = [];
229
+ let current = [];
230
+ let chars = 0;
231
+ for (const item of items) {
232
+ if (current.length > 0 && (chars + item.text.length > exports.SUMMARY_BATCH_MAX_CHARS || current.length >= exports.SUMMARY_BATCH_MAX_NOTES)) {
233
+ batches.push(current);
234
+ current = [];
235
+ chars = 0;
236
+ }
237
+ current.push(item);
238
+ chars += item.text.length;
239
+ }
240
+ if (current.length > 0)
241
+ batches.push(current);
242
+ return batches;
243
+ }
244
+ /**
245
+ * Give every note the router will be shown a title + summary, calling the summary model only for notes the cache
246
+ * does not already hold. Batches run one after another (no burst against a per-minute provider limit).
247
+ */
248
+ async function buildSessionSummaries(args) {
249
+ const started = Date.now();
250
+ const { options, cache } = args;
251
+ const model = options.summaryModel;
252
+ const notes = listRouterNotes(args.db, args.vaultPath, args.ledgerRelPath);
253
+ const stats = {
254
+ model, notes: notes.length, cached: 0, summarized: 0, failed: 0, calls: 0, promptTokens: 0, completionTokens: 0, ms: 0,
255
+ };
256
+ const pending = [];
257
+ for (const note of notes) {
258
+ const key = routerCache_1.RouterCache.summaryKey(model, exports.SUMMARY_PROMPT_TEMPLATE, note.noteHash);
259
+ if (cache.getSummary(key)) {
260
+ stats.cached++;
261
+ continue;
262
+ }
263
+ let raw;
264
+ try {
265
+ raw = await promises_1.default.readFile(note.file, 'utf8');
266
+ }
267
+ catch (err) {
268
+ stats.failed++;
269
+ stats.error ??= `unreadable note: ${err instanceof Error ? err.message : String(err)}`.slice(0, 200);
270
+ continue;
271
+ }
272
+ if ((0, dailyEntries_1.noteContentHash)(note.file, raw) !== note.noteHash) {
273
+ // Edited after it was indexed: its summary must describe what search will find, so it waits for the next index.
274
+ stats.failed++;
275
+ stats.error ??= 'a note changed after it was indexed; index again';
276
+ continue;
277
+ }
278
+ const parsed = (0, parser_1.parseNote)(note.file, raw);
279
+ if (!parsed.body) {
280
+ cache.putSummary(key, note.noteHash, { title: parsed.title, summary: '', model });
281
+ stats.summarized++;
282
+ continue;
283
+ }
284
+ pending.push({ key, noteHash: note.noteHash, date: note.date ?? '', text: summaryInputOf(parsed.body) });
285
+ }
286
+ if (pending.length > 0 && !options.summaryChat) {
287
+ stats.failed += pending.length;
288
+ stats.error ??= 'no summary model configured';
289
+ stats.ms = Date.now() - started;
290
+ return stats;
291
+ }
292
+ const batches = batchesOf(pending);
293
+ for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
294
+ const items = batches[batchIndex].map((entry, index) => ({ ...entry, id: `c${String(index + 1).padStart(2, '0')}` }));
295
+ let prompt = renderSummaryPrompt(items);
296
+ const left = new Map(items.map(item => [batchId(item.id), item]));
297
+ let transportFailed = false;
298
+ for (let attempt = 0; attempt < SUMMARY_ATTEMPTS && left.size > 0; attempt++) {
299
+ let content;
300
+ try {
301
+ const res = await options.summaryChat({
302
+ model,
303
+ messages: [{ role: 'user', content: prompt }],
304
+ temperature: 0,
305
+ response_format: { type: 'json_object' },
306
+ max_tokens: exports.SUMMARY_MAX_OUTPUT_TOKENS,
307
+ });
308
+ stats.calls++;
309
+ stats.promptTokens += res.promptTokens ?? 0;
310
+ stats.completionTokens += billedOutputTokens(res);
311
+ content = res.content;
312
+ }
313
+ catch (err) {
314
+ stats.calls++;
315
+ stats.error ??= `summary call failed: ${err instanceof Error ? err.message : String(err)}`.slice(0, 200);
316
+ transportFailed = true;
317
+ break;
318
+ }
319
+ const nodes = parseSummaryNodes(content);
320
+ for (const [id, item] of [...left]) {
321
+ const node = nodes.get(id);
322
+ if (!node)
323
+ continue;
324
+ cache.putSummary(item.key, item.noteHash, { title: node.title, summary: node.summary, model });
325
+ stats.summarized++;
326
+ left.delete(id);
327
+ }
328
+ // A different request, so a provider-side cache cannot replay the incomplete answer.
329
+ prompt += '\n';
330
+ }
331
+ if (left.size > 0) {
332
+ stats.failed += left.size;
333
+ stats.error ??= 'the summary model left some notes without a node';
334
+ }
335
+ if (transportFailed) {
336
+ // The transport already retried: a provider that is down or out of quota fails the next batch the same way, so
337
+ // the rest waits for the next index instead of spending requests against the same wall.
338
+ for (const rest of batches.slice(batchIndex + 1))
339
+ stats.failed += rest.length;
340
+ break;
341
+ }
342
+ }
343
+ stats.ms = Date.now() - started;
344
+ return stats;
345
+ }
346
+ /**
347
+ * `Question date: <d>\n<question>` -> its parts; any other query is the question and today is its date. The
348
+ * benchmark client and agents that stamp the date they ask on use the first form; the router needs the date to
349
+ * read "last week" or "four weeks ago".
350
+ */
351
+ function splitQuestionDate(query, nowMs) {
352
+ const match = /^Question date:[ \t]*([^\n]+)\n([\s\S]+)$/.exec(query.trim());
353
+ if (match && match[2].trim())
354
+ return { questionDate: match[1].trim(), question: match[2].trim() };
355
+ return { questionDate: new Date(nowMs).toISOString().slice(0, 10), question: query.trim() };
356
+ }
357
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max)}…` : text);
358
+ /**
359
+ * Output tokens as billed: the provider's total minus the prompt when it reports a total (Gemini's completion count
360
+ * leaves out hidden thinking, which is billed as output), else the completion count.
361
+ */
362
+ function billedOutputTokens(res) {
363
+ const completion = res.completionTokens ?? 0;
364
+ if (typeof res.totalTokens === 'number' && typeof res.promptTokens === 'number') {
365
+ return Math.max(completion, res.totalTokens - res.promptTokens);
366
+ }
367
+ return completion;
368
+ }
369
+ /** One node as a JSON line, keys in the study's order and spacing. */
370
+ function nodeLine(node) {
371
+ return `{"node_id": ${JSON.stringify(node.id)}, "date": ${JSON.stringify(node.date)}, "title": ${JSON.stringify(node.title)}, "summary": ${JSON.stringify(node.summary)}}`;
372
+ }
373
+ /** The routing prompt: the study's stage-1 prompt (PageIndex's tree-search prompt, "document" -> "memory"). */
374
+ function renderRouterPrompt(questionDate, question, nodes) {
375
+ return `You are given a question and the tree structure of a person's long-term conversation memory.
376
+ Each node is one past conversation with a node id, its date, a title and a summary.
377
+ Your task is to find all tree nodes that are likely to contain the answer to the question.
378
+
379
+ Question date: ${questionDate}
380
+ Question: ${question}
381
+
382
+ Memory tree structure:
383
+ ${nodes.map(nodeLine).join('\n')}
384
+
385
+ Please reply in the following JSON format:
386
+ {
387
+ "thinking": "<Your thinking process on which nodes are relevant to the question>",
388
+ "node_list": ["node_id_1", "node_id_2", ..., "node_id_n"]
389
+ }
390
+ Directly return the final JSON structure. Do not output anything else.`;
391
+ }
392
+ class RouterParseError extends Error {
393
+ }
394
+ /** The last JSON object in the text that has a `node_list` (tolerates prose, code fences and leaked thoughts). */
395
+ function lastObjectWithNodeList(text) {
396
+ const at = text.lastIndexOf('"node_list"');
397
+ if (at < 0)
398
+ throw new RouterParseError('router answer has no node_list');
399
+ let start = text.lastIndexOf('{', at);
400
+ while (start >= 0) {
401
+ let depth = 0;
402
+ for (let end = start; end < text.length; end++) {
403
+ if (text[end] === '{')
404
+ depth++;
405
+ else if (text[end] === '}') {
406
+ depth--;
407
+ if (depth === 0) {
408
+ try {
409
+ const candidate = JSON.parse(text.slice(start, end + 1));
410
+ if (candidate && typeof candidate === 'object' && 'node_list' in candidate)
411
+ return candidate;
412
+ }
413
+ catch {
414
+ // not this brace; try an earlier one
415
+ }
416
+ break;
417
+ }
418
+ }
419
+ }
420
+ start = start > 0 ? text.lastIndexOf('{', start - 1) : -1;
421
+ }
422
+ throw new RouterParseError('router answer has an unparseable node_list object');
423
+ }
424
+ function parseRouterAnswer(text) {
425
+ let body = text.replace(/<thought>[\s\S]*?<\/thought>/g, '');
426
+ if (body.trimStart().startsWith('<thought>')) {
427
+ const at = body.lastIndexOf('{"');
428
+ if (at >= 0)
429
+ body = body.slice(at);
430
+ }
431
+ body = body.trim().replace(/^```(?:json)?\s*|\s*```$/g, '');
432
+ let payload;
433
+ try {
434
+ const parsed = JSON.parse(body);
435
+ payload = parsed && typeof parsed === 'object' && 'node_list' in parsed
436
+ ? parsed
437
+ : lastObjectWithNodeList(body);
438
+ }
439
+ catch (err) {
440
+ if (err instanceof RouterParseError)
441
+ throw err;
442
+ payload = lastObjectWithNodeList(body);
443
+ }
444
+ const list = payload.node_list;
445
+ if (!Array.isArray(list))
446
+ throw new RouterParseError('router node_list is not a list');
447
+ return {
448
+ nodeIds: list.map(id => String(id).trim()).filter(Boolean),
449
+ thinking: typeof payload.thinking === 'string' ? payload.thinking : '',
450
+ };
451
+ }
452
+ async function askRouter(prompt, options, cache) {
453
+ const key = routerCache_1.RouterCache.routeKey(options.routerModel, prompt);
454
+ const cached = cache.getRoute(key);
455
+ if (cached) {
456
+ return {
457
+ nodeIds: cached.nodeIds,
458
+ usage: { promptTokens: cached.promptTokens, completionTokens: cached.completionTokens },
459
+ latencyMs: cached.latencyMs,
460
+ fromCache: true,
461
+ };
462
+ }
463
+ let promptTokens = 0;
464
+ let completionTokens = 0;
465
+ let latencyMs = 0;
466
+ let lastError = new RouterParseError('router answered nothing');
467
+ for (let attempt = 0; attempt < exports.ROUTE_ATTEMPTS; attempt++) {
468
+ const res = await options.routeChat({
469
+ model: options.routerModel,
470
+ // A different request on the retry, so a provider-side cache cannot replay the unparseable answer.
471
+ messages: [{ role: 'user', content: prompt + '\n'.repeat(attempt) }],
472
+ temperature: 0,
473
+ response_format: { type: 'json_object' },
474
+ max_tokens: exports.ROUTER_MAX_OUTPUT_TOKENS,
475
+ });
476
+ promptTokens += res.promptTokens ?? 0;
477
+ completionTokens += billedOutputTokens(res);
478
+ latencyMs += res.latencyMs;
479
+ try {
480
+ const parsed = parseRouterAnswer(res.content);
481
+ cache.putRoute(key, {
482
+ nodeIds: parsed.nodeIds,
483
+ thinking: parsed.thinking.slice(0, 4_000),
484
+ model: options.routerModel,
485
+ promptTokens,
486
+ completionTokens,
487
+ latencyMs,
488
+ });
489
+ return { nodeIds: parsed.nodeIds, usage: { promptTokens, completionTokens }, latencyMs, fromCache: false };
490
+ }
491
+ catch (err) {
492
+ lastError = err instanceof Error ? err : new Error(String(err));
493
+ }
494
+ }
495
+ throw lastError;
496
+ }
497
+ /**
498
+ * A note's statements: the pieces the chunker cuts it into, never split for size (a heading section, with its heading
499
+ * line; a daily note's entry, with its time and source), whose content, a leading heading line aside, is non-empty
500
+ * and shorter than STATEMENT_MAX_CHARS. On a chat import a piece's heading names the speaker (`### Turn 3 — User`).
501
+ * Cutting with the chunker itself keeps the two in step: a statement is byte-identical to the chunk the index holds
502
+ * for it (a piece this short is never split there either), which is what lets union mode skip what retrieval already
503
+ * returned. Document order.
504
+ */
505
+ function noteStatements(note) {
506
+ const statements = [];
507
+ for (const piece of (0, chunker_1.chunkNote)(note, { maxTokens: Number.POSITIVE_INFINITY, overlapTokens: 0 })) {
508
+ const lines = piece.text.split('\n');
509
+ const headed = /^#{1,3}\s+/.test(lines[0] ?? '');
510
+ const content = (headed ? lines.slice(1) : lines).join('\n').trim();
511
+ if (content.length === 0 || content.length >= exports.STATEMENT_MAX_CHARS)
512
+ continue;
513
+ statements.push(piece.text);
514
+ }
515
+ return statements;
516
+ }
517
+ const tokensOf = (text) => (0, o200k_1.countO200k)(text);
518
+ /**
519
+ * One entry per routed note, in routing order: its statements in document order, while the budget lasts (a note
520
+ * after the budget ran out is not read and delivers nothing). `truncated`: the budget stopped the delivery.
521
+ */
522
+ async function deliverStatements(notes, exclude, budget) {
523
+ const delivered = notes.map(note => ({ note, statements: [], tokens: 0, alreadyRetrieved: 0 }));
524
+ let used = 0;
525
+ let truncated = false;
526
+ for (const entry of delivered) {
527
+ let raw;
528
+ try {
529
+ raw = await promises_1.default.readFile(entry.note.file, 'utf8');
530
+ }
531
+ catch {
532
+ continue;
533
+ }
534
+ let full = false;
535
+ for (const statement of noteStatements((0, parser_1.parseNote)(entry.note.file, raw))) {
536
+ if (exclude.has(statement)) {
537
+ entry.alreadyRetrieved++;
538
+ continue;
539
+ }
540
+ const cost = tokensOf(statement) + (entry.statements.length > 0 ? 1 : 0);
541
+ if (used + cost > budget) {
542
+ full = true;
543
+ truncated = true;
544
+ break;
545
+ }
546
+ entry.statements.push(statement);
547
+ used += cost;
548
+ }
549
+ entry.tokens = entry.statements.length > 0 ? tokensOf(entry.statements.join('\n\n')) : 0;
550
+ if (full)
551
+ break;
552
+ }
553
+ // The running sum counts each statement plus one token per separator, which can differ from the joined text's
554
+ // count by a token at a boundary; trim from the end until the delivered texts themselves fit, so the bound is exact.
555
+ let total = delivered.reduce((sum, entry) => sum + entry.tokens, 0);
556
+ for (let index = delivered.length - 1; index >= 0 && total > budget;) {
557
+ const entry = delivered[index];
558
+ if (entry.statements.length === 0) {
559
+ index--;
560
+ continue;
561
+ }
562
+ total -= entry.tokens;
563
+ entry.statements.pop();
564
+ truncated = true;
565
+ entry.tokens = entry.statements.length > 0 ? tokensOf(entry.statements.join('\n\n')) : 0;
566
+ total += entry.tokens;
567
+ }
568
+ return { delivered, truncated };
569
+ }
570
+ /**
571
+ * Route one search. Never throws and never fails the search: every path that cannot route returns the retrieved
572
+ * hits unchanged with a report that says why.
573
+ */
574
+ async function applySessionRouter(args) {
575
+ const { options, cache, retrieved } = args;
576
+ const base = { mode: options.mode, model: options.routerModel, summaryModel: options.summaryModel };
577
+ const skip = (reason, extra = {}) => ({
578
+ hits: retrieved.hits,
579
+ report: { status: 'skipped', reason, ...base, ...extra },
580
+ });
581
+ try {
582
+ if (!options.routeChat)
583
+ return skip('no-model');
584
+ const notes = listRouterNotes(args.db, args.vaultPath, args.ledgerRelPath);
585
+ if (notes.length === 0)
586
+ return skip('no-notes', { nodes: 0 });
587
+ if (notes.length > exports.ROUTER_MAX_NODES)
588
+ return skip('too-many-notes', { nodes: notes.length });
589
+ const summaries = notes.map(note => cache.getSummary(routerCache_1.RouterCache.summaryKey(options.summaryModel, exports.SUMMARY_PROMPT_TEMPLATE, note.noteHash)));
590
+ const missingSummaries = summaries.filter(summary => !summary).length;
591
+ if (missingSummaries > 0)
592
+ return skip('summaries-incomplete', { nodes: notes.length, missingSummaries });
593
+ const width = Math.max(4, String(notes.length).length);
594
+ const nodes = notes.map((note, index) => ({
595
+ id: String(index + 1).padStart(width, '0'),
596
+ date: note.date ?? '',
597
+ title: clip(summaries[index].title, NODE_TITLE_MAX_CHARS),
598
+ summary: clip(summaries[index].summary, NODE_SUMMARY_MAX_CHARS),
599
+ note,
600
+ }));
601
+ const { questionDate, question } = splitQuestionDate(args.query, args.nowMs ?? Date.now());
602
+ const prompt = renderRouterPrompt(questionDate, question, nodes);
603
+ let answer;
604
+ try {
605
+ answer = await askRouter(prompt, options, cache);
606
+ }
607
+ catch (err) {
608
+ const reason = err instanceof RouterParseError ? 'router-parse-error' : 'router-error';
609
+ return skip(reason, { nodes: nodes.length, error: (err instanceof Error ? err.message : String(err)).slice(0, 200) });
610
+ }
611
+ const spent = { nodes: nodes.length, fromCache: answer.fromCache, usage: answer.usage, latencyMs: answer.latencyMs };
612
+ const byId = new Map(nodes.map(node => [node.id, node]));
613
+ const routed = [...new Set(answer.nodeIds.map(id => (/^\d+$/.test(id) ? id.padStart(width, '0') : id)))]
614
+ .filter(id => byId.has(id))
615
+ .slice(0, exports.ROUTER_MAX_SESSIONS)
616
+ .map(id => byId.get(id).note);
617
+ if (routed.length === 0)
618
+ return skip('empty-route', spent);
619
+ // union: the retrieved window stays as it is; the routed statements fill what is left of the budget and never
620
+ // repeat a chunk the window already holds (a short section is one chunk of the same text).
621
+ const union = options.mode === 'union';
622
+ const exclude = union ? new Set(retrieved.hits.map(hit => hit.text)) : new Set();
623
+ const budget = union
624
+ ? exports.ROUTER_MAX_DELIVERED_TOKENS - retrieved.hits.reduce((sum, hit) => sum + tokensOf(hit.text), 0)
625
+ : exports.ROUTER_MAX_DELIVERED_TOKENS;
626
+ const { delivered, truncated } = await deliverStatements(routed, exclude, Math.max(0, budget));
627
+ const withText = delivered.filter(entry => entry.statements.length > 0);
628
+ // Nothing to deliver is a fallback, except in union mode when the window already held what the router pointed
629
+ // at and nothing was cut for room: then the union IS the window, and the router agreed with retrieval.
630
+ const agreed = union && !truncated && delivered.some(entry => entry.alreadyRetrieved > 0);
631
+ if (withText.length === 0 && !agreed)
632
+ return skip('no-statements', { ...spent, ...(truncated ? { truncated } : {}) });
633
+ const routerHits = withText.map((entry, rank) => ({
634
+ notePath: entry.note.path,
635
+ chunkId: -1,
636
+ headingPath: '',
637
+ text: entry.statements.join('\n\n'),
638
+ // Routing order as a score (first routed = 1), so a score-ordered result keeps the router's order.
639
+ score: (withText.length - rank) / withText.length,
640
+ reason: 'router',
641
+ ...(entry.note.date ? { session_date: entry.note.date } : {}),
642
+ }));
643
+ // Same presentation rule as retrieval: newest first unless the retrieval result is in score order (an explicit
644
+ // past-intent or as-of query); in score order a union keeps the retrieved window first.
645
+ const hits = options.mode === 'replace'
646
+ ? (0, orchestrator_1.orderHits)(routerHits, retrieved.rankedBy)
647
+ : retrieved.rankedBy === 'session_date'
648
+ ? (0, orchestrator_1.orderHits)([...retrieved.hits, ...routerHits], 'session_date')
649
+ : [...retrieved.hits, ...routerHits];
650
+ return {
651
+ hits,
652
+ report: {
653
+ status: 'ok',
654
+ ...base,
655
+ ...spent,
656
+ routed: delivered.map(entry => ({
657
+ notePath: entry.note.path,
658
+ ...(entry.note.date ? { session_date: entry.note.date } : {}),
659
+ statements: entry.statements.length,
660
+ tokens: entry.tokens,
661
+ ...(union ? { alreadyRetrieved: entry.alreadyRetrieved } : {}),
662
+ })),
663
+ deliveredTokens: delivered.reduce((sum, entry) => sum + entry.tokens, 0),
664
+ ...(truncated ? { truncated } : {}),
665
+ },
666
+ };
667
+ }
668
+ catch (err) {
669
+ return skip('router-error', { error: (err instanceof Error ? err.message : String(err)).slice(0, 200) });
670
+ }
671
+ }
672
+ // ─── CLI entry points ───
673
+ /**
674
+ * `renor search`: the routed hits and the report, or null when the flag is off (then nothing here runs and no
675
+ * cache file is created).
676
+ */
677
+ async function routeSearch(args) {
678
+ const options = resolveSessionRouterOptions(args.config, args.env);
679
+ if (!options)
680
+ return null;
681
+ let cache;
682
+ try {
683
+ cache = new routerCache_1.RouterCache(options.cachePath);
684
+ }
685
+ catch (err) {
686
+ return {
687
+ hits: args.retrieved.hits,
688
+ report: {
689
+ status: 'skipped', reason: 'router-error', mode: options.mode, model: options.routerModel,
690
+ summaryModel: options.summaryModel,
691
+ error: `router cache unavailable: ${err instanceof Error ? err.message : String(err)}`.slice(0, 200),
692
+ },
693
+ };
694
+ }
695
+ try {
696
+ return await applySessionRouter({
697
+ db: args.db,
698
+ vaultPath: args.config.vaultPath,
699
+ ledgerRelPath: args.config.memoryLedger,
700
+ query: args.query,
701
+ retrieved: args.retrieved,
702
+ options,
703
+ cache,
704
+ nowMs: args.nowMs,
705
+ });
706
+ }
707
+ finally {
708
+ cache.close();
709
+ }
710
+ }
711
+ /** `renor index` / `renor rebuild`: build the summaries the router reads. The caller has refused a missing model. */
712
+ async function summarizeForRouter(args) {
713
+ const cache = new routerCache_1.RouterCache(args.options.cachePath);
714
+ try {
715
+ return await buildSessionSummaries({
716
+ db: args.db,
717
+ vaultPath: args.config.vaultPath,
718
+ ledgerRelPath: args.config.memoryLedger,
719
+ options: args.options,
720
+ cache,
721
+ });
722
+ }
723
+ finally {
724
+ cache.close();
725
+ }
726
+ }