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,2089 @@
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.MVP_VERSION_PATH = exports.MVP_MCP_PATH = exports.SEARCH_REFUSAL_REASONS = exports.sanitizeErrorMessage = exports.DIRECTORY_HEADER = exports.DASHBOARD_HEADER = void 0;
7
+ exports.createMvpApp = createMvpApp;
8
+ exports.toolError = toolError;
9
+ exports.startMvpService = startMvpService;
10
+ const express_1 = __importDefault(require("express"));
11
+ const crypto_1 = __importDefault(require("crypto"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
14
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
15
+ const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
16
+ const zod_1 = require("zod");
17
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
18
+ const client_1 = require("../db/client");
19
+ const schema_1 = require("../db/schema");
20
+ const pipeline_1 = require("../indexer/pipeline");
21
+ const outsideRoot_1 = require("../indexer/outsideRoot");
22
+ const lifecycle_1 = require("../memory/lifecycle");
23
+ const factTerms_1 = require("../memory/factTerms");
24
+ const factConflicts_1 = require("../memory/factConflicts");
25
+ const writerConflicts_1 = require("../memory/writerConflicts");
26
+ const limits_1 = require("../memory/limits");
27
+ const sourceName_1 = require("../memory/sourceName");
28
+ const secretGuard_1 = require("../extraction/secretGuard");
29
+ const whatsNew_1 = require("../memory/whatsNew");
30
+ const requests_1 = require("../memory/requests");
31
+ const history_1 = require("./history");
32
+ const identity_1 = require("./identity");
33
+ const serverCard_1 = require("./serverCard");
34
+ const requestErrors_1 = require("../mcp/requestErrors");
35
+ const auth_1 = require("./auth");
36
+ const engine_1 = require("./engine");
37
+ const scope_1 = require("./scope");
38
+ const queryHelpers_1 = require("../retrieval/queryHelpers");
39
+ const ledgerPresentation_1 = require("../retrieval/ledgerPresentation");
40
+ const telemetry_1 = require("./telemetry");
41
+ const readUsefulness_1 = require("./readUsefulness");
42
+ const posthogMcp_1 = require("./posthogMcp");
43
+ const schemaRejections_1 = require("./schemaRejections");
44
+ const agentMemory_1 = require("./agentMemory");
45
+ const factsLane_1 = require("./factsLane");
46
+ const factMarkers_1 = require("./factMarkers");
47
+ const hitDates_1 = require("./hitDates");
48
+ const replacements_1 = require("../memorymap/replacements");
49
+ const contextRelevance_1 = require("./contextRelevance");
50
+ const hitFloor_1 = require("./hitFloor");
51
+ const readerLayout_1 = require("../retrieval/readerLayout");
52
+ const questionSpan_1 = require("../retrieval/questionSpan");
53
+ const readerEvents_1 = require("../retrieval/readerEvents");
54
+ const orchestrator_1 = require("../retrieval/orchestrator");
55
+ const buildIdentity_1 = require("../buildIdentity");
56
+ const hostHeaderValidation_js_1 = require("@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js");
57
+ const apiAuth_1 = require("../api/apiAuth");
58
+ const memoryDir_1 = require("./memoryDir");
59
+ const hosted_1 = require("../importing/hosted");
60
+ const exportAll_1 = require("./exportAll");
61
+ const memoryMap_1 = require("../memorymap/memoryMap");
62
+ const idle_1 = require("./idle");
63
+ const activity_1 = require("./activity");
64
+ const storedSize_1 = require("./storedSize");
65
+ const storedSize_2 = require("./storedSize");
66
+ const sanitize_1 = require("./sanitize");
67
+ Object.defineProperty(exports, "sanitizeErrorMessage", { enumerable: true, get: function () { return sanitize_1.sanitizeErrorMessage; } });
68
+ const controls_1 = require("../memory/controls");
69
+ const hiddenSpans_1 = require("./hiddenSpans");
70
+ const connectionStatus_1 = require("./connectionStatus");
71
+ const toolRefusal_1 = require("../toolRefusal");
72
+ /**
73
+ * Single-user ChatGPT MVP remote MCP service.
74
+ *
75
+ * - Transport: Streamable HTTP (stateless), one of the two transports ChatGPT
76
+ * developer mode supports.
77
+ * - Authorization: owner-only OAuth 2.1 via the SDK auth router (dynamic
78
+ * client registration + PKCE + token revocation) and a Bearer-token check on
79
+ * the MCP endpoint. Wrong/expired/revoked credentials never read or write.
80
+ * - The legacy unauthenticated HTTP API (src/api/server.ts) is NOT mounted.
81
+ * - Tools: lifecycle save/retrieve/export/search_history plus engine retrieval
82
+ * (remember, get_context, search_memory, search_text, lookup_entity,
83
+ * remember_note, index_memory, memory_status). Semantic search is optional (MVP_EMBEDDING_PROVIDER); mixing
84
+ * embedding dimensions with an existing index fails closed (ADR-0003).
85
+ * - Every durable write goes through the repaired lifecycle (ledger-first,
86
+ * serialized, validated, honest receipts, restart replay), so persistence,
87
+ * correction and restart recovery inherit the Gate1A guarantees.
88
+ */
89
+ /**
90
+ * What search_memory says of the digest table, only where write-time digests are switched on (RENOR_DIGESTS), so a
91
+ * workspace without them has the description it always had.
92
+ */
93
+ /**
94
+ * What search_memory says of the event lines, only where RENOR_READER_EVENTS is set to an on value (`1`, `true`, `yes`, `on`).
95
+ * With it unset the lines still lead the page (the default) and say what they are in their own first words, but the tool list is
96
+ * the one it was before the lines: the description stays under the 1,200 characters a directory client takes, and equal to the
97
+ * gateway's server card.
98
+ */
99
+ const EVENTS_HITS = ' When the question asks for the order or the date of something the user did, the first hit is a list of the user\'s own statements that match it, each under the day it is about, oldest first, and the excerpts stay in full below it (reason "events"). A question about a duration has no such list.';
100
+ const DIGEST_HITS = ' This workspace writes a dated digest of each saved note in the background: when the notes matched have digests, one table of dated items drawn from them leads the hits (after any evidence table), outside the rankedBy order and top_k, with reason "digest". It is read from stored digests, no model is called to make it, and a note saved a moment ago may not have its digest yet. Use the table when it holds the value needed, and the excerpts when it does not.';
101
+ /**
102
+ * What search_memory says of the duration line, only where RENOR_READER_DURATION is set to an on value (`1`, `true`, `yes`, `on`)
103
+ * and the reader layout it rides on is on. With it unset the line still leads the page (the default) and says what it is in its own
104
+ * first words, but the tool list is the one it was before the line (under 1,200 characters, equal to the gateway's server card).
105
+ */
106
+ const SPAN_HITS = ' When the question asks how many days, weeks or months lie between two events and the user wrote the date of each in their own words, the first hit is one short line that quotes those two dates and counts the days between them (reason "span"), outside the rankedBy order and top_k; the excerpts stay in full below it. It counts written dates only, never a session date.';
107
+ /**
108
+ * What search_memory says of the current-value timeline, only where RENOR_READER_TIMELINE is on. Default off, so the
109
+ * tool list a deployment ships is unchanged: the timeline hit (reason "timeline") exists only on that flag.
110
+ */
111
+ const TIMELINE_HITS = ' When the question asks what a value is now, the first hit is a timeline of the user\'s own statements of that value, oldest first, the last line marked latest, and the excerpts stay in full below it (reason "timeline"). A question about a duration or an order has no timeline.';
112
+ const MCP_PATH = '/mcp';
113
+ /** True when any message is a tools/call that omitted `id`. An explicit `id: null` still has the key. */
114
+ /**
115
+ * A request body must be UTF-8. Node decodes an invalid byte as U+FFFD, and the tool would store that, so the
116
+ * body is refused before it is parsed. The error carries a 400 status, which body-parser keeps.
117
+ */
118
+ function requireUtf8Body(_req, _res, body) {
119
+ try {
120
+ new TextDecoder('utf-8', { fatal: true }).decode(body);
121
+ }
122
+ catch {
123
+ throw Object.assign(new Error('request body is not valid UTF-8; nothing was run'), { status: 400, expose: true });
124
+ }
125
+ }
126
+ function bodyHasToolsCallNotification(body) {
127
+ const messages = Array.isArray(body) ? body : [body];
128
+ return messages.some((message) => {
129
+ if (!message || typeof message !== 'object' || Array.isArray(message))
130
+ return false;
131
+ const record = message;
132
+ return record.method === 'tools/call' && !Object.prototype.hasOwnProperty.call(record, 'id');
133
+ });
134
+ }
135
+ /** How many memories a memory_map query returns unless asked for more, and the most any memory_map limit ever returns. */
136
+ const DEFAULT_LOOKUP_LIMIT = 10;
137
+ const MAX_LOOKUP_LIMIT = 200;
138
+ /** Loopback-only build-identity probe for deploy verification (no credential); see the route below. */
139
+ const VERSION_PATH = '/version';
140
+ /**
141
+ * Headers a reverse proxy adds when it forwards a request — Caddy's
142
+ * `reverse_proxy` appends X-Forwarded-For/-Proto/-Host to every forwarded
143
+ * request. This service never trusts these headers as identity input (they are
144
+ * spoofable client input; the middleware in createMvpApp deletes them), so
145
+ * their mere PRESENCE means the request reached the socket through a hop, not
146
+ * from a local caller. Checked against req.rawHeaders, which that middleware
147
+ * does not touch.
148
+ */
149
+ const FORWARDING_HEADERS = [
150
+ 'forwarded',
151
+ 'x-forwarded-for',
152
+ 'x-forwarded-host',
153
+ 'x-forwarded-proto',
154
+ 'x-forwarded-port',
155
+ 'x-real-ip',
156
+ ];
157
+ /** True for 127.0.0.0/8 (including IPv4-mapped ::ffff:127.0.0.1) and ::1. */
158
+ function isLoopbackAddress(address) {
159
+ if (!address)
160
+ return false;
161
+ const normalized = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address;
162
+ return normalized === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized);
163
+ }
164
+ /** True when the Host header names the loopback interface rather than a public tenant host. */
165
+ function isLoopbackHost(hostHeader) {
166
+ if (!hostHeader)
167
+ return false;
168
+ const end = hostHeader.startsWith('[') ? hostHeader.indexOf(']') : -1;
169
+ const host = end > 0 ? hostHeader.slice(1, end) : hostHeader.split(':')[0];
170
+ return host === 'localhost' || isLoopbackAddress(host);
171
+ }
172
+ /**
173
+ * Whether a request reached this process the way an operator doing deploy
174
+ * verification over SSH does: through the loopback interface, without a proxy
175
+ * hop, addressing the instance by its loopback name.
176
+ *
177
+ * All three have to hold, because the deployed topology puts a tenant-site
178
+ * Caddy in front (`reverse_proxy 127.0.0.1:<port>`), so a public caller's
179
+ * socket peer IS loopback — the peer check alone would not block it. What does
180
+ * block it: that Caddy hop forwards the client's Host (a public tenant host)
181
+ * and appends X-Forwarded-For, so the loopback-Host and no-forwarding-hop
182
+ * checks both fail for anything arriving from the public path.
183
+ */
184
+ function isLocalDirectCall(req) {
185
+ if (!isLoopbackAddress(req.socket.remoteAddress))
186
+ return false;
187
+ if (!isLoopbackHost(req.headers.host))
188
+ return false;
189
+ const raw = req.rawHeaders;
190
+ for (let i = 0; i < raw.length; i += 2) {
191
+ if (FORWARDING_HEADERS.includes(raw[i].toLowerCase()))
192
+ return false;
193
+ }
194
+ return true;
195
+ }
196
+ /**
197
+ * search_memory's description names every evidence state. Bounded-wait evidence (RENOR_EVIDENCE_ASYNC, default off)
198
+ * adds two, so they are described only where they can occur.
199
+ */
200
+ const EVIDENCE_WAIT_STATES = ' This deployment waits for the evidence model only a bounded time, so two more states mean that this response alone has no table: "pending" (the extraction did not finish in time; it continues, and once its table is cached the same request over the same notes returns it) and "busy" (as many extractions were already waiting as the deployment allows, so none was started).';
201
+ const EVIDENCE_DAILY_CAP_STATE = ' This workspace has a daily allowance of evidence-model calls, so one more state means that this response alone has no table: "capped" (the allowance is used up until 00:00 UTC; retrieval is unaffected and the hits are the whole result).';
202
+ /**
203
+ * Every tool answers in the same shape: its JSON on one line. Indentation is whitespace an agent pays for in tokens on
204
+ * every call and reads nothing from; indented, a get_context answer was about a fifth larger.
205
+ */
206
+ function jsonResult(body) {
207
+ return { content: [{ type: 'text', text: (0, sanitize_1.escapeInvisibleControls)(JSON.stringify(body)) }] };
208
+ }
209
+ /**
210
+ * An optional argument that a client may send as JSON null, which is how some clients spell an argument they leave out:
211
+ * null reads as omitted. Only the check of the value changes; the schema the tool lists is the wrapped one's.
212
+ */
213
+ function nullAsOmitted(schema) {
214
+ return zod_1.z.preprocess((value) => (value === null ? undefined : value), schema);
215
+ }
216
+ /**
217
+ * An optional text argument a client may leave out in any of the ways clients do: omit it, send JSON null (strict
218
+ * function-calling clients fill every property, and null is how they say "none"), or send an empty string. All three
219
+ * read as omitted, so a save never needs the exact set of keys. Only the check of the value changes; the schema the
220
+ * tool lists is the wrapped one's, a string.
221
+ */
222
+ function omittedWhenBlank(schema) {
223
+ return zod_1.z.preprocess((value) => (value === null || (typeof value === 'string' && value.trim() === '') ? undefined : value), schema);
224
+ }
225
+ /** The user's time zone, as a client may know it; a note save is then filed under the user's day (memory/writer.ts). */
226
+ const TIMEZONE_ARG = omittedWhenBlank(zod_1.z.string().min(1).max(64).optional()).describe('Optional. The user\'s time zone: an IANA name ("America/Sao_Paulo") or a UTC offset ("-03:00"). A note is then filed under the user\'s own day and stamped with their local time; without it, under the UTC day and time. A fact keeps its exact moment either way.');
227
+ /**
228
+ * Optional on every save. What the save builds on: notePaths or memory ids a read returned, or the subject of a fact it
229
+ * returned. Renor uses it, with what the engine sees for itself, to tell whether reading helped; nothing breaks without it.
230
+ */
231
+ const CITES_ARG = nullAsOmitted(zod_1.z.array(zod_1.z.string().min(1).max(1024)).max(20).optional()).describe('Optional, never required. What this save builds on: the notePath (or memory id) of a note a read returned, or the subject of a fact it returned. Lets Renor tell that reading helped.');
232
+ /**
233
+ * The earlier memory a plain-words save corrects. Optional: only for a correction of one specific note or log entry, whose
234
+ * id memory_map (query) or an earlier save's receipt gives. See memorymap/replacements.ts.
235
+ */
236
+ const REPLACES_ARG = omittedWhenBlank(zod_1.z.string().max(1024).optional()).describe('Optional. The id (entry:<path>#<hash> or note:<path>) of the earlier memory this save corrects, from memory_map with a query or an earlier save\'s receipt. Renor then marks that memory replaced and points to this one, so a later recall puts this first. Nothing is deleted: retire this save to undo it. For a fact, save it again with the same subject, predicate and scope instead.');
237
+ /** The one scope spelling every save and read takes. */
238
+ const SCOPE_ARG = omittedWhenBlank(zod_1.z.string().optional()).describe('"global" (default) or project:<slug>, e.g. project:atlas.');
239
+ /** What a client is told a `source` is: the name of the app or agent saving, never a path (memory/sourceName.ts). */
240
+ const SOURCE_ARG_DESCRIPTION = 'Name of the app or agent saving this, for example claude-code. Leave it out to use the connecting client. Never a file path or document name.';
241
+ const RESERVED_SOURCE_IDENTITIES = new Set(['system', 'developer', 'assistant', 'renor', 'admin', 'owner', 'tool']);
242
+ const CALLER_SOURCE_ASCII = /^[A-Za-z0-9 _.:/@-]+$/;
243
+ /** A User-Agent product keeps its punctuation (cleanSource folds it) but may hold no character outside printable ASCII. */
244
+ const USER_AGENT_PRODUCT_ASCII = /^[\x20-\x7e]+$/;
245
+ const SOURCE_IDENTITY_SEPARATORS = /[ ._-]/g;
246
+ /** Return the reserved identity a writer spelling claims, including the import-style prefix form. */
247
+ function reservedSourceIdentity(source) {
248
+ const lower = source.toLowerCase();
249
+ const identity = lower.replace(SOURCE_IDENTITY_SEPARATORS, '');
250
+ const colon = lower.indexOf(':');
251
+ const authorityPrefix = colon < 0 ? null : lower.slice(0, colon).replace(SOURCE_IDENTITY_SEPARATORS, '');
252
+ return RESERVED_SOURCE_IDENTITIES.has(identity)
253
+ ? identity
254
+ : authorityPrefix !== null && RESERVED_SOURCE_IDENTITIES.has(authorityPrefix) ? authorityPrefix : null;
255
+ }
256
+ /**
257
+ * The reserved identity a writer name would store as, under any spelling a write persists: the typed one, the fact's
258
+ * cleanSource and the note's headerSource. Paths, URLs, truncation and punctuation folding cannot manufacture one past this.
259
+ */
260
+ function reservedSourceIn(source) {
261
+ for (const form of [source, (0, sourceName_1.cleanSource)(source), (0, sourceName_1.headerSource)(source)]) {
262
+ const reserved = reservedSourceIdentity(form);
263
+ if (reserved)
264
+ return reserved;
265
+ }
266
+ return null;
267
+ }
268
+ /** Validate an untrusted writer name and every spelling either writer can persist. */
269
+ function callerSource(source) {
270
+ // Check what the caller actually supplied. Normalizing first would let fullwidth and other compatibility characters
271
+ // become ASCII before this boundary and defeat the rule that typed writer names are plain ASCII.
272
+ if (!CALLER_SOURCE_ASCII.test(source)) {
273
+ throw (0, toolRefusal_1.refusal)('invalid_argument', 'writer names must be plain ASCII (letters, digits, spaces, and - _ . : / @ only)');
274
+ }
275
+ const reserved = reservedSourceIn(source);
276
+ if (reserved)
277
+ throw (0, toolRefusal_1.refusal)('invalid_argument', `source names a reserved identity (${reserved}); use an ordinary app or agent name, or leave source out`);
278
+ return source;
279
+ }
280
+ function markImportedOrigin(item, pathKey) {
281
+ return String(item[pathKey] ?? '').startsWith('Imported/') ? { ...item, origin: 'import' } : item;
282
+ }
283
+ /**
284
+ * The source a hosted write stores. The connection's name (`x-renor-agent` behind the gateway, the
285
+ * verified OAuth client's name on self-hosted) as its hive identity, or "unnamed" when that connection
286
+ * has no name a header can render — an unnamed connection never takes a name the client typed;
287
+ * otherwise the one the client sent when it names someone; otherwise the product in the User-Agent
288
+ * (`claude-code/2.1.289` is `claude-code`), so an agent never has to pass one;
289
+ * otherwise undefined, which leaves the tool's own default. A source with no letter or digit in it (blank, a bare path,
290
+ * punctuation) names no one (memory/sourceName.ts).
291
+ */
292
+ function headerText(headers, name) {
293
+ const raw = headers?.[name];
294
+ const value = Array.isArray(raw) ? raw[0] : raw;
295
+ return typeof value === 'string' ? value : undefined;
296
+ }
297
+ /** The gateway header, or the OAuth client's rendered name. Never a client-sent header on self-hosted HTTP. */
298
+ function trustedAgentRaw(extra, config) {
299
+ const agent = config.gatewaySecret
300
+ ? headerText(extra?.requestInfo?.headers, 'x-renor-agent')
301
+ : extra?.authInfo?.extra?.connectionName;
302
+ return typeof agent === 'string' ? agent : undefined;
303
+ }
304
+ /**
305
+ * The hive identity of a trusted connection name, or "unnamed". A name that is a reserved identity is no name: a key label
306
+ * or a self-hosted OAuth client_name (which the client registers itself) must not make its saves read as `owner` or
307
+ * `system`, as a typed source or a User-Agent cannot. A hosted OAuth sign-in is `OAuth-<client>`, which never is one.
308
+ */
309
+ function trustedIdentity(raw) {
310
+ const identity = (0, sourceName_1.hiveIdentity)(raw);
311
+ return identity === null || reservedSourceIn(identity) !== null ? 'unnamed' : identity;
312
+ }
313
+ function sourceOf(sent, extra, config) {
314
+ // The gateway sets this from the connection key's name and overwrites a client-sent copy, so on a named
315
+ // connection it is the author: a `source` the client types cannot write as another agent.
316
+ // A present name that canonicalizes to no identity (punctuation-only, or `unnamed`) cannot take a typed source.
317
+ const raw = trustedAgentRaw(extra, config);
318
+ if (typeof raw === 'string' && raw.trim() !== '')
319
+ return trustedIdentity(raw);
320
+ // Self-hosted always resolves a name for the verified client (the sign-in middleware sets the key, even
321
+ // when the stored name renders empty). That connection is unnamed, and an unnamed connection cannot
322
+ // choose an author, exactly as an unnamed stdio connection cannot.
323
+ if (!config.gatewaySecret && extra?.authInfo?.extra && 'connectionName' in extra.authInfo.extra)
324
+ return 'unnamed';
325
+ if (sent !== undefined) {
326
+ const normalized = callerSource(sent);
327
+ if ((0, sourceName_1.headerSource)(normalized) !== '')
328
+ return normalized;
329
+ }
330
+ // The User-Agent is the client's own text, never checked, so it takes the same ASCII-only and reserved-identity tests as a typed source (punctuation is still folded by cleanSource, not refused).
331
+ // A reserved product (`system/1.0`, `owner/2 (x)`, `https:system`) or a non-ASCII one (a lookalike such as `SYSTEΜ/1.0`) falls back
332
+ // to the tool's default instead of storing it.
333
+ const product = (0, sourceName_1.productFromUserAgent)(extra?.requestInfo?.headers?.['user-agent']);
334
+ return product !== undefined && USER_AGENT_PRODUCT_ASCII.test(product) && reservedSourceIn(product) === null ? product : undefined;
335
+ }
336
+ /**
337
+ * Hosted names come only from the trusted gateway. Self-hosted names come only from
338
+ * the persisted OAuth client selected by the verified bearer, never a client header.
339
+ * The hive name is the header form, the same string stdio publishes.
340
+ */
341
+ function agentOf(extra, config) {
342
+ const raw = trustedAgentRaw(extra, config);
343
+ if (typeof raw !== 'string' || raw.trim() === '')
344
+ return 'unnamed';
345
+ return trustedIdentity(raw);
346
+ }
347
+ /** Nameless connections must not share a feed, mark or inbox. */
348
+ function hiveAgentOf(extra, tool, config) {
349
+ const agent = agentOf(extra, config);
350
+ if (agent === 'unnamed') {
351
+ const fix = config.gatewaySecret
352
+ ? 'give this connection a name in the dashboard (any name works, emoji and non-Latin names included), then reconnect'
353
+ : 'register an OAuth client with a client_name for this agent, authorize it with the owner, then reconnect';
354
+ throw new Error(`${tool} is refused: this connection has no name, and the hive tools (whats_new, ask_agent, list_requests, answer_request, close_request) tell agents apart by that name only. To fix it, ${fix}.`);
355
+ }
356
+ return agent;
357
+ }
358
+ /**
359
+ * The gateway's mark on a call the platform made for the signed-in owner (the dashboard's internal connection key),
360
+ * never an agent's. The gateway deletes a client-sent copy and sets it only for an internal key, so it is trusted only
361
+ * behind the gateway (gatewaySecret); an engine reached without one never treats a call as the dashboard's.
362
+ */
363
+ exports.DASHBOARD_HEADER = 'x-renor-internal';
364
+ function isDashboardCall(headers, config) {
365
+ return Boolean(config.gatewaySecret) && headerText(headers, exports.DASHBOARD_HEADER) === '1';
366
+ }
367
+ /**
368
+ * The gateway's mark on a call from a directory client: a claude.ai or chatgpt.com connector, whose token the gateway marks
369
+ * (oauth.ts DIRECTORY_TOKEN_MARK). Like DASHBOARD_HEADER it is trusted only behind the gateway, and it only narrows the tools
370
+ * listed (no hive tool); it changes nothing a call may do.
371
+ */
372
+ exports.DIRECTORY_HEADER = 'x-renor-directory';
373
+ function isDirectoryCall(headers, config) {
374
+ return Boolean(config.gatewaySecret) && headerText(headers, exports.DIRECTORY_HEADER) === '1';
375
+ }
376
+ /**
377
+ * What a directory client is told about the memory. It says nothing about call order or behaviour: the directory rules forbid
378
+ * that for other tools. Named agent keys keep the instructions built below.
379
+ */
380
+ const DIRECTORY_INSTRUCTIONS = 'Renor single-owner memory: the owner\'s own saved memory, kept in their private workspace. get_context returns the current facts and the saved memories that match a request. remember saves one statement, and save_memory saves a value with its subject, predicate and object.';
381
+ /**
382
+ * The one tool only the dashboard may list or run: it erases retired memories for good. Its registerTool call below spells the name
383
+ * as a literal, because the docs binding and the hosted-tool tests read the registry off the literal call sites; if the two ever
384
+ * differ, registeredToolEntry throws when an agent's server is built.
385
+ */
386
+ const OWNER_ONLY_TOOL = 'delete_retired_memory';
387
+ /** The tools agents use to reach each other. A directory client is offered none of them; registeredToolEntry throws if one is renamed. */
388
+ const HIVE_TOOLS = ['whats_new', 'ask_agent', 'list_requests', 'answer_request', 'close_request'];
389
+ /**
390
+ * The entry the SDK lists and runs a tool from. It is the object registerTool returned unless something replaced it:
391
+ * @posthog/mcp, which wraps every server of a workspace that has RENOR_POSTHOG_KEY (posthogMcp.ts), swaps the registry for a
392
+ * proxy that stores a COPY of each tool, and from then on the returned handle's disable() and `enabled` change nothing the SDK reads.
393
+ */
394
+ function registeredToolEntry(server, name) {
395
+ const entry = server._registeredTools?.[name];
396
+ if (!entry)
397
+ throw new Error(`${name} is missing from the SDK's tool registry`);
398
+ return entry;
399
+ }
400
+ /** Builds a fresh McpServer with all MVP tools registered. The SDK
401
+ * binds one transport per Protocol instance, so SSE sessions each get
402
+ * their own server (bounded, in-process); JSON mode shares one.
403
+ */
404
+ function buildMcpServer(db, config, engine, idle, digests, usefulness, connection, announceFirst, telemetrySession = null, dashboard = false, directory = false) {
405
+ // Which Renor this is, on the first reply of a session and on every error (connectionStatus.ts); nothing in a workspace
406
+ // that is not hosted. A server that serves no session of its own (the stateless one, or one built for a session this
407
+ // process forgot) has no first reply to announce on.
408
+ const announcer = new connectionStatus_1.ConnectionAnnouncer(connection, announceFirst);
409
+ const evidence = (0, engine_1.resolveEngineEvidence)(config, process.env, idle);
410
+ // The reading agent's name as a save records it (remember's cleanSource and default), so its own facts carry no source.
411
+ const readerOf = (extra) => (0, sourceName_1.cleanSource)(sourceOf(undefined, extra, config) ?? '') || 'agent';
412
+ // Only told to agents while get_context can actually return the signal.
413
+ const relevanceGuidance = (0, contextRelevance_1.contextRelevanceArmed)(engine) ? contextRelevance_1.CONTEXT_RELEVANCE_GUIDANCE : '';
414
+ const relevanceKeepsMatch = relevanceGuidance ? contextRelevance_1.CONTEXT_RELEVANCE_KEEPS_MATCH : '';
415
+ const budgetKeepsNewest = relevanceGuidance ? contextRelevance_1.CONTEXT_BUDGET_KEEPS_NEWEST : ''; // where there is a bar, the budget reads it
416
+ const server = new mcp_js_1.McpServer({ name: serverCard_1.SERVER_NAME, version: serverCard_1.SERVER_VERSION }, { instructions: directory ? DIRECTORY_INSTRUCTIONS : 'Renor single-owner memory. At the start of every task, call whats_new before anything else. Open requests addressed to you are first in that reply; answering or closing one records the reply and never runs anything by itself. For substantive user requests, call get_context with the current request before answering so relevant prior context can follow the user across agents. '
417
+ + 'Call it again before you decide something an earlier session may have settled and before you review another agent\'s work, with that choice or topic as the query: reading once at the start is not enough. '
418
+ + 'To save anything, call remember with one argument, statement: the thing to remember, in plain words. When the user says to remember or save something, they mean this tool, not your own memory. '
419
+ + 'Renor is the place for the why, next to your board or tracker, not a copy of it: when you save on your own, save decisions with their reason, root causes and corrections, not status, progress or event logs. '
420
+ + 'Cite an earlier entry by its subject or task instead of restating it, and save nothing that only repeats one. '
421
+ + 'Only for a value that stays current until it changes (a city, a database, a deadline, a preference), also pass subject, predicate and object together, never guessed: saving the same subject, predicate and scope again replaces the value and keeps the old one in history. save_memory is that structured form alone; remember_note appends a line to a day\'s log.' + relevanceGuidance });
422
+ // PostHog MCP Analytics: per-server (SSE builds a fresh McpServer per session).
423
+ (0, posthogMcp_1.instrumentMcpServer)(server);
424
+ // Only the server of one SSE session is handed a telemetrySession: the JSON-mode server is shared by every client, so
425
+ // "its first call" would be the first call of the process, not of a session. The flag is per server, so per session.
426
+ let sessionCalled = false;
427
+ // A session's first tool call, whatever its outcome and whether the schema refused it or a handler ran (telemetry.ts
428
+ // `first_call`): the one signal for the recall share.
429
+ const noteFirstCall = (tool) => {
430
+ if (telemetrySession === null || sessionCalled)
431
+ return;
432
+ sessionCalled = true;
433
+ (0, telemetry_1.emitTelemetry)({ event: 'first_call', tool, recall: (0, telemetry_1.isRecallTool)(tool), session: telemetrySession });
434
+ };
435
+ // A call the tool's input schema refuses never reaches tracedTool below: count it here (schemaRejections.ts).
436
+ (0, schemaRejections_1.countSchemaRejections)(server, noteFirstCall);
437
+ // The errors the SDK answers itself (a call its input schema refuses, an unknown tool, a handler that threw) never pass
438
+ // through tracedTool either; they are built by one method, and carry the connection status like every other error.
439
+ const sdkErrors = server;
440
+ const createToolError = sdkErrors.createToolError;
441
+ if (typeof createToolError === 'function') {
442
+ sdkErrors.createToolError = (message) => announcer.decorate(createToolError.call(server, message), false);
443
+ }
444
+ function toolResultOk(result) {
445
+ return !(result && result.isError);
446
+ }
447
+ /** Privacy-safe: tool name / ok / ms and, on a refusal, its code from the closed set — never args or memory content. */
448
+ function tracedTool(tool, handler) {
449
+ // On-demand: a running tool call (a write, an index job) holds the engine awake
450
+ // even after its client disconnects.
451
+ const run = idle ? (...args) => idle.track(() => handler(...args)) : handler;
452
+ return async (...args) => {
453
+ const started = Date.now();
454
+ const first = announcer.takeFirst();
455
+ noteFirstCall(tool);
456
+ try {
457
+ const result = await run(...args);
458
+ const ok = toolResultOk(result);
459
+ (0, telemetry_1.emitTelemetry)({
460
+ event: 'tool_use',
461
+ tool,
462
+ ok,
463
+ ms: Date.now() - started,
464
+ // error_kind is unchanged (dashboards read it); error_reason is what the refusal was: the code the
465
+ // refusal site named, or the generic `refused` for one that has no better code in the closed set.
466
+ ...(ok ? {} : { error_kind: 'other', error_reason: (0, toolRefusal_1.toolRefusalOf)(result) ?? 'refused' }),
467
+ });
468
+ return announcer.decorate(result, first);
469
+ }
470
+ catch (err) {
471
+ (0, telemetry_1.emitTelemetry)({
472
+ event: 'tool_use',
473
+ tool,
474
+ ok: false,
475
+ ms: Date.now() - started,
476
+ error_kind: 'internal',
477
+ error_reason: 'internal',
478
+ });
479
+ throw err;
480
+ }
481
+ };
482
+ }
483
+ const registerTool = server.registerTool.bind(server);
484
+ server.registerTool = ((name, config, cb) => registerTool(name, config, tracedTool(String(name), cb)));
485
+ server.registerTool('remember', {
486
+ title: 'Remember something naturally',
487
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
488
+ description: 'Save something to memory: pass `statement`, in plain words, and nothing else. Do not save passwords, API keys or tokens. Example: remember({"statement": "We moved the Fjord database to SQLite because it needs no server."}). It is stored whole and indexed at once, so get_context finds it right away; the receipt\'s id is what read_memory and retire_memory take. `scope` ("project:<slug>") keeps it with one project. '
489
+ + 'Only for a value that stays current until it changes (a city, a project\'s database, a deadline, a preference), also pass subject, predicate and object together: "subject": "Fjord", "predicate": "database", "object": "SQLite". Saving the same subject, predicate and scope again replaces the value: the old one leaves current reads and stays in history. A save by a different agent than the one that saved the current value is shown in reads as a CONFLICT line until that agent saves it again (see save_memory). Spell them the same every time. Decisions, events and notes need none of the three: leave them out, never guess them. '
490
+ + `The same statement for the same fact again changes nothing. A statement over ${limits_1.MAX_STATEMENT_LENGTH} characters is refused, never cut, in either form. Saved on its own as a note, one that is not valid Unicode or holds a control character (U+0000 to U+001F except tab, newline and carriage return, or U+007F) is refused and nothing is written; with subject, predicate and object it is saved as a fact with its control characters escaped in the ledger, and only those three fields refuse them. A fact receipt may list possibleDuplicates (the same predicate under a name one word longer or shorter, or in another scope): nothing is merged; to correct one, save again with its subject and scope. A save is compared within the scope it names.`
491
+ + ' To correct one specific earlier note or log entry (not a fact), also pass `replaces`, its id (from memory_map with a query, or an earlier save\'s receipt): that memory is then marked replaced, with a pointer to this save, in every recall, and this save is listed before it. Nothing is deleted; retire_memory on this save undoes it, and the receipt says `replaced` (or `replacesIgnored` when linking would make a loop, as when the save is the same line as the memory it replaces).',
492
+ inputSchema: {
493
+ statement: zod_1.z.string().min(1).max(limits_1.MAX_STATEMENT_LENGTH).describe('The thing to remember, in plain words.'),
494
+ subject: omittedWhenBlank(zod_1.z.string().optional()).describe('With predicate and object: who or what the value is about, e.g. "Fjord".'),
495
+ predicate: omittedWhenBlank(zod_1.z.string().optional()).describe('With subject and object: the attribute, snake_case, spelled the same every time, e.g. "database".'),
496
+ object: omittedWhenBlank(zod_1.z.string().optional()).describe('With subject and predicate: the current value, on one line, e.g. "SQLite".'),
497
+ source: omittedWhenBlank(zod_1.z.string().optional()).describe(SOURCE_ARG_DESCRIPTION),
498
+ scope: SCOPE_ARG,
499
+ timezone: TIMEZONE_ARG,
500
+ cites: CITES_ARG,
501
+ replaces: REPLACES_ARG,
502
+ },
503
+ }, async (args, extra) => {
504
+ try {
505
+ const structured = [args.subject, args.predicate, args.object];
506
+ const present = structured.filter(v => typeof v === 'string' && v.trim().length > 0).length;
507
+ if (present !== 0 && present !== 3) {
508
+ throw (0, toolRefusal_1.refusal)('invalid_argument', 'remember requires subject, predicate, and object together, or none of them: to save plain words pass only statement');
509
+ }
510
+ if (args.replaces !== undefined) {
511
+ if (present === 3) {
512
+ throw (0, toolRefusal_1.refusal)('invalid_argument', 'replaces is for a plain-words correction: a fact is corrected by saving it again with the same subject, predicate and scope');
513
+ }
514
+ await (0, replacements_1.assertReplaceable)(mapContext(), args.replaces, (0, scope_1.normalizeScope)(args.scope));
515
+ }
516
+ if (present === 3) {
517
+ const receipt = await (0, lifecycle_1.remember)(db, config.memoryDir, config.ledgerRelPath, {
518
+ subject: args.subject,
519
+ predicate: args.predicate,
520
+ object: args.object,
521
+ source: sourceOf(args.source, extra, config),
522
+ writer: agentOf(extra, config),
523
+ scope: args.scope,
524
+ statement: args.statement,
525
+ });
526
+ usefulness.cited(args.cites);
527
+ if (receipt.action !== 'nochange')
528
+ usefulness.updated(receipt.subject, receipt.predicate);
529
+ // The ledger holds the user's own words now, so index it: recall by
530
+ // their phrasing (semantic or keyword) then reaches the fact. The fact
531
+ // is already durable, so a failed refresh must not fail the receipt; the
532
+ // receipt says it failed, and why.
533
+ // Only the ledger, which this save appended to, is checked: its new event is what changed, so a save costs the
534
+ // same however many events and notes the workspace holds. The vault is walked only once every
535
+ // FULL_VAULT_CHECK_MS (see engineIndexChanged).
536
+ let refresh = {};
537
+ if (receipt.action !== 'nochange') {
538
+ try {
539
+ const pass = await (0, engine_1.engineIndexChanged)(db, config, engine, undefined, {
540
+ interactive: true,
541
+ touched: [(0, lifecycle_1.resolveLedgerPath)(config.memoryDir, config.ledgerRelPath)],
542
+ });
543
+ if (pass.errors > 0)
544
+ refresh = unfinishedPass(pass);
545
+ }
546
+ catch {
547
+ refresh = { warning: 'memory-saved-but-index-refresh-failed' };
548
+ }
549
+ }
550
+ // Other current facts that may be this one under another name or scope (memory/factConflicts.ts). Advisory:
551
+ // the fact is saved whatever this read does, so its failure leaves the hint out instead of failing the save.
552
+ let duplicates = { values: [], total: 0 };
553
+ try {
554
+ duplicates = (0, factConflicts_1.possibleDuplicates)(db, receipt, receipt.created?.factId ?? null, (0, lifecycle_1.scopeFilterFor)(args.scope), readerOf(extra));
555
+ }
556
+ catch { /* no hint */ }
557
+ return jsonResult({
558
+ ok: receipt.ok,
559
+ mode: 'fact',
560
+ statement: args.statement,
561
+ action: receipt.action,
562
+ eventId: receipt.eventId,
563
+ subject: receipt.subject,
564
+ predicate: receipt.predicate,
565
+ closed: receipt.closed,
566
+ ...(receipt.alsoClosed ? { alsoClosed: receipt.alsoClosed } : {}),
567
+ created: receipt.created,
568
+ scope: (0, scope_1.normalizeScope)(args.scope),
569
+ ...(duplicates.values.length > 0 ? { possibleDuplicates: duplicates.values } : {}),
570
+ ...(duplicates.total > duplicates.values.length ? { possibleDuplicatesTotal: duplicates.total } : {}),
571
+ ...refresh,
572
+ });
573
+ }
574
+ const note = await (0, engine_1.engineRememberNote)(config, args.statement, undefined, sourceOf(args.source, extra, config) ?? 'mcp:remember', args.scope, args.timezone);
575
+ usefulness.cited(args.cites);
576
+ const replaced = args.replaces !== undefined && note.id
577
+ // The source the entry was written with (the default of this write, when none), so the link names the same one.
578
+ ? await replacementReceipt(args.replaces, note.id, sourceOf(args.source, extra, config) ?? 'mcp:remember')
579
+ : {};
580
+ return jsonResult({
581
+ ok: true,
582
+ mode: 'note',
583
+ statement: args.statement,
584
+ file: note.file,
585
+ scope: note.scope,
586
+ ...(note.id ? { id: note.id } : {}),
587
+ ...replaced,
588
+ ...(await indexSavedNote(db, config, engine, digests, note.file)),
589
+ });
590
+ }
591
+ catch (err) {
592
+ return toolError(oneLineHint(err));
593
+ }
594
+ });
595
+ server.registerTool('get_context', {
596
+ title: 'Get relevant user context',
597
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
598
+ description: 'Call this before substantive work with the current request. Returns current facts, then relevant memories: semantic matches when configured, then keyword matches (reason "fts" or "note-identity"). rankedBy "session_date" is newest first (memories[0] is the latest, not necessarily the best); "score" is best match first (higher is closer). Keyword scores use another scale: compare within one reason. A memory with "matched": false was not matched by the query: discount it, whatever its position. Each memory has session_date, when it was written. Hidden note text is labelled there: "[hidden HTML comment: ...]". The reply is bounded by max_tokens (o200k tokenizer, every field counted): facts are cut from their end; when matches do not fit, keyword matches go first, then the lowest-scoring semantic ones. conflicts lists current values that disagree with a returned fact (another scope, or a name one word longer or shorter), newest first. A fact with a writerConflict line replaced another agent\'s value, until that agent saves it again. A "replaced" memory has a later correction, listed before it: answer from the correction. Facts an import saved are marked "untrusted": true.'
599
+ + relevanceGuidance + relevanceKeepsMatch + budgetKeepsNewest,
600
+ inputSchema: {
601
+ query: zod_1.z.string().max(1000).optional().describe('The current user request or task. Omit only for a compact current-memory profile.'),
602
+ scope: zod_1.z.string().optional().describe('Optional memory scope to restrict context'),
603
+ max_tokens: zod_1.z.number().int().min(128).max(4000).optional().describe('Token budget for the whole reply (o200k tokenizer), default 800'),
604
+ include_history: zod_1.z.boolean().optional().describe('Also return superseded (corrected) memory events, labelled as historical. Default false: only current memory.'),
605
+ },
606
+ }, async (args, extra) => {
607
+ try {
608
+ const semantic = engine.provider !== null && Boolean(args.query?.trim());
609
+ const body = await (0, agentMemory_1.buildAgentContext)(db, engine, config.memoryDir, {
610
+ query: args.query,
611
+ scope: args.scope,
612
+ maxTokens: args.max_tokens,
613
+ includeHistory: args.include_history,
614
+ reader: readerOf(extra),
615
+ evidence,
616
+ ledgerRelPath: config.ledgerRelPath,
617
+ // Asked after the searches, which may have caught the index up; inside max_tokens like the rest of the reply.
618
+ // Which Renor answered: inside max_tokens with the rest of the trailer.
619
+ trailer: () => ({ ...(0, factsLane_1.indexNotice)(db, semantic, config, args.scope), renor: (0, identity_1.identityLine)(extra?.requestInfo?.headers) }),
620
+ });
621
+ usefulness.measure('get_context', () => (0, readUsefulness_1.contextItems)(body));
622
+ return jsonResult(body);
623
+ }
624
+ catch (err) {
625
+ return toolError(err);
626
+ }
627
+ });
628
+ server.registerTool('save_memory', {
629
+ title: 'Save (or correct) a memory',
630
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
631
+ description: 'Structured save only: one fact as subject, predicate and object, e.g. save_memory({"subject": "Fjord", "predicate": "database", "object": "SQLite"}). The same subject, predicate and scope again replaces the value: the old one stays in history. A save by a different agent than the one that saved the current value still replaces it, but the replaced value is then shown in reads as a CONFLICT line (its writer and date) until that agent saves this subject and predicate again. The writer is the connection\'s authenticated agent name, never the source you pass. The receipt says store, supersede or nochange, and may list possibleDuplicates (see remember). All three fields are required and no sentence of yours is kept: to save plain words, or a fact with your words, call remember.',
632
+ inputSchema: {
633
+ // Optional in the schema so a call that lacks them reaches the handler and is told what to do (a plain save belongs
634
+ // to remember), instead of the SDK's raw validation error. The handler still requires all three.
635
+ subject: omittedWhenBlank(zod_1.z.string().optional()).describe('Who/what the fact is about, e.g. "user"'),
636
+ predicate: omittedWhenBlank(zod_1.z.string().optional()).describe('The relationship, e.g. "works_at"'),
637
+ object: omittedWhenBlank(zod_1.z.string().optional()).describe('The value, e.g. "Memory Company"'),
638
+ source: omittedWhenBlank(zod_1.z.string().optional()).describe(SOURCE_ARG_DESCRIPTION),
639
+ scope: SCOPE_ARG,
640
+ cites: CITES_ARG,
641
+ },
642
+ }, async (args, extra) => {
643
+ try {
644
+ const { subject, predicate, object } = args;
645
+ if (subject === undefined || predicate === undefined || object === undefined) {
646
+ const missing = ['subject', 'predicate', 'object'].filter(name => args[name] === undefined);
647
+ throw (0, toolRefusal_1.refusal)('missing_argument', `save_memory saves one fact and needs subject, predicate and object (missing: ${missing.join(', ')}). To save plain words, call remember with only statement, e.g. remember({"statement": "We moved the Fjord database to SQLite."}).`);
648
+ }
649
+ const receipt = await (0, lifecycle_1.remember)(db, config.memoryDir, config.ledgerRelPath, {
650
+ subject,
651
+ predicate,
652
+ object,
653
+ source: sourceOf(args.source, extra, config),
654
+ writer: agentOf(extra, config),
655
+ scope: args.scope,
656
+ });
657
+ usefulness.cited(args.cites);
658
+ if (receipt.action !== 'nochange')
659
+ usefulness.updated(receipt.subject, receipt.predicate);
660
+ // The same advisory hint `remember` gives a fact receipt (memory/factConflicts.ts): the fact is saved whatever
661
+ // this read does, so its failure leaves the hint out instead of failing the save.
662
+ let duplicates = { values: [], total: 0 };
663
+ try {
664
+ duplicates = (0, factConflicts_1.possibleDuplicates)(db, receipt, receipt.created?.factId ?? null, (0, lifecycle_1.scopeFilterFor)(args.scope), readerOf(extra));
665
+ }
666
+ catch { /* no hint */ }
667
+ return jsonResult({
668
+ ok: receipt.ok,
669
+ action: receipt.action,
670
+ eventId: receipt.eventId,
671
+ subject: receipt.subject,
672
+ predicate: receipt.predicate,
673
+ closed: receipt.closed,
674
+ ...(receipt.alsoClosed ? { alsoClosed: receipt.alsoClosed } : {}),
675
+ created: receipt.created,
676
+ ...(duplicates.values.length > 0 ? { possibleDuplicates: duplicates.values } : {}),
677
+ ...(duplicates.total > duplicates.values.length ? { possibleDuplicatesTotal: duplicates.total } : {}),
678
+ ledger: config.ledgerRelPath, // relative: no host paths leak to the client
679
+ });
680
+ }
681
+ catch (err) {
682
+ return toolError(oneLineHint(err));
683
+ }
684
+ });
685
+ server.registerTool('retrieve_memory', {
686
+ title: 'Retrieve memories',
687
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
688
+ description: 'Returns the current value(s) for a subject. Optionally filter by predicate, or include the full superseded history (include_history) or a point-in-time view (as_of). Every fact says whether it is the live value (current:true) or a superseded one, so a history read still shows which value is current, and which scope it belongs to. conflicts, when present, names current values that disagree, as get_context does. A current value that replaced a value another agent saved carries a writerConflict line: the replaced value, its writer and date, which stays until that agent saves this subject and predicate again; read it as an open disagreement, not as the settled answer. An empty result says whether the subject exists: known:false means no such subject (closestSubjects lists near matches), known:true means it exists but the predicate or as_of matched nothing. Calling without a subject returns an error that lists the known subjects. With a scope, subjects, suggestions and existence are all judged within that scope only: a subject saved only in another scope is unknown here.',
689
+ inputSchema: {
690
+ // Optional in the schema so a missing value reaches the handler and gets an actionable error
691
+ // instead of the SDK's raw validation error. A null is a missing value too.
692
+ subject: nullAsOmitted(zod_1.z.string().max(lifecycle_1.FIELD_LIMITS.subject, `subject is longer than ${lifecycle_1.FIELD_LIMITS.subject} characters: pass the name of what the facts are about, e.g. "user", not the text itself`).optional()).describe('Required. The subject to look up, e.g. "user"'),
693
+ predicate: nullAsOmitted(zod_1.z.string().optional()).describe('Only this predicate, e.g. "works_at"'),
694
+ include_history: nullAsOmitted(zod_1.z.boolean().optional()).describe('Include superseded values (default: false)'),
695
+ as_of: nullAsOmitted(zod_1.z.string().optional()).describe('ISO instant: values valid at that time (with include_history: full history)'),
696
+ scope: nullAsOmitted(zod_1.z.string().optional()).describe('Memory scope: "global" (default) or scope:<slug> / project:<slug>'),
697
+ },
698
+ }, async (args, extra) => {
699
+ try {
700
+ // The one scope everything below is judged in (none = every scope). Facts, existence, suggestions and
701
+ // the subject list all use it, so a scoped call never learns of a subject that lives in another scope.
702
+ const scope = args.scope === undefined ? undefined : (0, scope_1.normalizeScope)(args.scope);
703
+ const where = scope === undefined ? '' : ` in scope ${JSON.stringify(scope)}`;
704
+ const subject = args.subject;
705
+ if (subject === undefined || subject.trim() === '') {
706
+ const subjects = (0, lifecycle_1.listLifecycleSubjects)(db, { scope });
707
+ const shown = subjects.slice(0, 20).map(s => JSON.stringify(s)).join(', ');
708
+ const more = subjects.length > 20 ? ` (+${subjects.length - 20} more; memory_map lists them)` : '';
709
+ return (0, toolRefusal_1.markToolRefusal)({
710
+ content: [{
711
+ type: 'text',
712
+ text: (0, sanitize_1.escapeInvisibleControls)('Error: retrieve_memory needs the "subject" argument (what the facts are about, e.g. "user"). '
713
+ + (subjects.length ? `Known subjects${where}: ${shown}${more}.` : `No subjects are stored${where} yet.`)),
714
+ }],
715
+ isError: true,
716
+ }, 'missing_argument');
717
+ }
718
+ let facts = (0, lifecycle_1.factsFor)(db, subject, {
719
+ includeHistory: args.include_history,
720
+ asOf: args.as_of,
721
+ scope,
722
+ });
723
+ if (args.predicate)
724
+ facts = facts.filter(f => f.predicate === args.predicate);
725
+ // The values another writer replaced that stay open against a current value (memory/writerConflicts.ts). A point-in-time
726
+ // view is not about now, so only a read of the current values carries them.
727
+ const displaced = args.as_of === undefined
728
+ ? (0, writerConflicts_1.writerConflictLines)(db, facts.map(f => (f.valid_to == null ? f.event_id : null)))
729
+ : new Map();
730
+ const rows = facts.map(f => ({
731
+ predicate: f.predicate,
732
+ object: f.object_text,
733
+ current: f.valid_to == null,
734
+ scope: f.scope ?? 'global',
735
+ validFrom: f.valid_from,
736
+ validTo: f.valid_to,
737
+ eventId: f.event_id,
738
+ ...(typeof f.author === 'string' && f.author !== '' ? { source: f.author } : {}),
739
+ // An import's text came from a file, not from the user's own statement: marked the way other untrusted text is.
740
+ ...((0, sourceName_1.isImportSource)(f.author) ? { untrusted: true } : {}),
741
+ ...(f.valid_to == null && displaced.has(f.event_id) ? { writerConflict: displaced.get(f.event_id) } : {}),
742
+ }));
743
+ const body = { subject, facts: rows };
744
+ // Current values that disagree with another current value of the same predicate under this subject in another
745
+ // scope, or under a related name (memory/factConflicts.ts). A point-in-time view (as_of) is not about now.
746
+ if (args.as_of === undefined) {
747
+ const live = facts.filter(f => f.valid_to == null && f.source_path === lifecycle_1.LIFECYCLE_NOTE_PATH);
748
+ const conflicts = (0, factConflicts_1.namedConflicts)(db, live.map(f => ({ subject, predicate: f.predicate })), scope ?? null, readerOf(extra));
749
+ if (conflicts.length > 0)
750
+ body.conflicts = conflicts;
751
+ }
752
+ if (rows.length === 0) {
753
+ // Empty is ambiguous: say whether the subject exists at all, under any predicate or time, within the
754
+ // same scope. Asked of factsFor itself so it resolves the name exactly as the read above did (including
755
+ // its partial and path matches); the subject list below is only for suggestions, never for this answer.
756
+ const known = (0, lifecycle_1.factsFor)(db, subject, { includeHistory: true, scope }).length > 0;
757
+ body.known = known;
758
+ if (!known)
759
+ body.closestSubjects = closestSubjects(subject, (0, lifecycle_1.listLifecycleSubjects)(db, { scope }));
760
+ }
761
+ usefulness.measure('retrieve_memory', () => (0, readUsefulness_1.retrieveItems)(db, facts));
762
+ return jsonResult(body);
763
+ }
764
+ catch (err) {
765
+ return toolError(err);
766
+ }
767
+ });
768
+ server.registerTool('export_memory', {
769
+ title: 'Export everything this workspace holds',
770
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
771
+ description: 'Returns everything the workspace holds, as portable Markdown (format renor-export/v1): the fact ledger exactly as stored (every fact with its subject, predicate, object, your words, source, scope, timestamps and event id, retired ones included), every daily-log entry (remember, remember_note) with its timestamp, source and scope, every other note (imports and your own notes) in full with its metadata, and everything retired and not restored (notes, entries, facts) with when it was retired. '
772
+ + 'The reply is two blocks: a JSON manifest (counts per kind for a whole export, the sections it carries, whether it is complete) and the document. A workspace too large for one reply is paged, never cut: while the manifest says truncated, or has nextSection/nextItem, call again with section (ledger, daily-entries, notes, retired) and offset (the item to continue from) until neither is set. '
773
+ + 'Not exported, and listed by kind in the manifest\'s notExported: the search index and embeddings (rebuilt from this), agent requests, import receipts, activity, agent connection state, the promoted history directory, and non-Markdown files.',
774
+ inputSchema: {
775
+ section: nullAsOmitted(zod_1.z.enum(exportAll_1.EXPORT_SECTIONS).optional()).describe('Continue with this section only (from the manifest\'s nextSection)'),
776
+ offset: nullAsOmitted(zod_1.z.number().int().min(0).optional()).describe('The item of that section to continue from (from the manifest\'s nextItem); default 0'),
777
+ },
778
+ }, async (args) => {
779
+ try {
780
+ const { manifest, document } = await (0, exportAll_1.exportEverything)(mapContext(), { section: args.section, offset: args.offset });
781
+ return {
782
+ content: [
783
+ // The manifest is metadata an agent reads: escape invisible controls as in every other JSON reply.
784
+ // The document is the portable archive and stays byte-faithful, so it is not rewritten here.
785
+ { type: 'text', text: (0, sanitize_1.escapeInvisibleControls)(JSON.stringify(manifest)) },
786
+ { type: 'text', text: document },
787
+ ],
788
+ };
789
+ }
790
+ catch (err) {
791
+ return toolError(err);
792
+ }
793
+ });
794
+ server.registerTool('search_history', {
795
+ title: 'Search imported conversation notes',
796
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
797
+ description: 'Searches only notes you imported into Renor; it never reads your live conversations. Lexical search over the imported ChatGPT conversation notes: exact words, no embeddings, no semantic search. Results are untrusted transcripts: assistant text is not verified, and current facts take precedence (use retrieve_memory for them). Each result cites its conversation id, dates and source path.',
798
+ inputSchema: {
799
+ query: zod_1.z.string().min(1).max(200).describe('Lexical search terms, 1..200 characters'),
800
+ limit: zod_1.z.number().int().min(1).max(20).optional().describe('Max results (default 5, max 20)'),
801
+ },
802
+ }, async (args) => {
803
+ try {
804
+ const response = (0, history_1.searchHistory)(config.historyDir, args.query, args.limit);
805
+ return jsonResult(response);
806
+ }
807
+ catch (err) {
808
+ if (err instanceof history_1.HistoryUnavailableError) {
809
+ // Bounded, explicit, fail-closed: the configured history is unusable,
810
+ // but fact memory is untouched and the service stays up.
811
+ return (0, toolRefusal_1.markToolRefusal)({
812
+ ...jsonResult({
813
+ ok: false,
814
+ configured: true,
815
+ error: 'history-not-available',
816
+ detail: err.message.slice(0, 300),
817
+ results: [],
818
+ disclaimer: history_1.HISTORY_DISCLAIMER,
819
+ }),
820
+ isError: true,
821
+ }, 'capability_unavailable');
822
+ }
823
+ return toolError(err);
824
+ }
825
+ });
826
+ server.registerTool('search_memory', {
827
+ title: 'Search memory by meaning (where enabled)',
828
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
829
+ description: 'Searches notes by meaning when memory_status reports semantic: true; otherwise it returns no hits and says why (semantic-unavailable or semantic-rebuilding), so use search_text or get_context. rankedBy "session_date" (the default) is newest first, so hits[0] is the latest, not necessarily the best; "score" is best match first. Judge hits by score (higher is closer), not position. One conversation\'s hits on one date come as one hit, opening with its date; a time phrase in it is resolved below, counted from that session. A hit without session_date comes last; a fact\'s hit has no notePath. There is no `evidence` object when this deployment has no evidence model configured and the pass is not switched off: no second pass runs, and the hits are the whole result. When there is one, its items come first, outside rankedBy, with reason "evidence", and its state is "ok", "off", "not-applied" or "failed". Facts saved with remember are added outside rankedBy, within top_k; a writerConflict line on one means it replaced another agent\'s value. index.stale: the semantic index lags the saved facts. A "replaced" hit\'s correction is listed before it (by: its id, source: who saved it).' + (evidence?.options.background ? EVIDENCE_WAIT_STATES : '') + (evidence?.options.dailyCap !== undefined ? EVIDENCE_DAILY_CAP_STATE : '') + (digests ? DIGEST_HITS : '') + ((0, readerLayout_1.readerLayoutEnabled)() && (0, questionSpan_1.readerDurationNamed)() ? SPAN_HITS : '') + ((0, readerEvents_1.readerEventsNamed)() ? EVENTS_HITS : '') + ((0, readerLayout_1.readerTimelineEnabled)() ? TIMELINE_HITS : '') + (relevanceGuidance ? contextRelevance_1.SEARCH_RELEVANCE_GUIDANCE : ''),
830
+ inputSchema: {
831
+ query: zod_1.z.string().min(1).max(500).describe('Natural language query'),
832
+ top_k: zod_1.z.number().int().min(1).max(50).optional().describe('Max matches (default 20), not counting the short statements the session expansion adds to them'),
833
+ scope: zod_1.z.string().optional().describe('Memory scope: "global" (default) or scope:<slug> / project:<slug>'),
834
+ include_history: zod_1.z.boolean().optional().describe('Also return superseded (corrected) memory events, labelled as historical. Default false: only current memory.'),
835
+ },
836
+ }, async (args, extra) => {
837
+ try {
838
+ const topK = args.top_k ?? 20;
839
+ const floor = (0, hitFloor_1.searchHitFloor)(engine.modelKey);
840
+ const probe = {};
841
+ const laidOut = (0, readerLayout_1.readerLayoutEnabledFor)(args.query);
842
+ const body = await (0, engine_1.engineSearchMemory)(db, engine, config.memoryDir, args.query, topK, args.scope, evidence, args.include_history === true, config.ledgerRelPath, probe, digests, undefined, floor > 0, laidOut, (0, orchestrator_1.searchBestFirstEnabled)());
843
+ let result = body;
844
+ if (body.ok) {
845
+ // Who saved each fact among the hits, marked before anything reads them (the verdict, the page, the floor).
846
+ (0, factMarkers_1.markFactHits)(db, body.hits, readerOf(extra), (0, ledgerPresentation_1.ledgerNoteMatcher)(config));
847
+ // Judged over the index's own hits, before the facts lane adds any, with top_k: the fact the verdict is judged
848
+ // on is then listed by that lane when the page has room for it, so a relevant verdict always names a match the
849
+ // response holds, and names it in relevance.fact when top_k leaves no room (agentMemory.ts).
850
+ const verdict = (0, agentMemory_1.searchRelevance)(db, engine, config.memoryDir, {
851
+ query: args.query, scope: args.scope, includeHistory: args.include_history === true, ledgerRelPath: config.ledgerRelPath,
852
+ }, probe, body.hits, topK);
853
+ const hits = (0, factsLane_1.withFactHits)(db, config, body.hits, { query: args.query, scope: args.scope, match: 'any', limit: topK, dated: true, reader: readerOf(extra) }, verdict.listFact ? [verdict.listFact] : []);
854
+ // Filter the completed page without backfilling. If the operator raises the floor above the relevance
855
+ // bar and removes the closest chunk, withhold its verdict: the unchanged tool guidance promises the match.
856
+ const floored = floor === 0 ? null : (0, hitFloor_1.applyHitFloor)(hits, floor);
857
+ const page = floored ? floored.hits : hits;
858
+ let shownVerdict = verdict;
859
+ // At the model's calibrated floor a relevant closest match already clears the floor, so the normal path
860
+ // needs no second fact-lane assessment. Only a floor above its reported cosine could have removed it.
861
+ if (floored && floored.withheld > 0 && verdict.relevance?.relevant
862
+ && (verdict.relevance.similarity === null || floor > verdict.relevance.similarity)) {
863
+ const filteredVerdict = (0, agentMemory_1.searchRelevance)(db, engine, config.memoryDir, {
864
+ query: args.query, scope: args.scope, includeHistory: args.include_history === true, ledgerRelPath: config.ledgerRelPath,
865
+ }, probe, page, topK);
866
+ // Only a removed chunk loses its verdict. A fact already listed or named must stay as it was: freed slots
867
+ // are not backfilled, so re-judging with that extra room must not drop relevance.fact for an unlisted fact.
868
+ if (!filteredVerdict.relevance)
869
+ shownVerdict = {};
870
+ }
871
+ // What the read served is the page the caller is shown, floor included: a hit the floor left out was not served, and
872
+ // a page it left empty served nothing, whatever the verdict judged over every hit.
873
+ usefulness.measure('search_memory', () => {
874
+ const served = (0, readUsefulness_1.searchItems)(db, (0, ledgerPresentation_1.ledgerNoteMatcher)(config), page, shownVerdict.relevance);
875
+ return floored && served.items.length === 0 ? { ...served, relevant: false } : served;
876
+ });
877
+ // The event of a fact is read from the ledger's heading, which is taken off below.
878
+ result = {
879
+ ...body,
880
+ hits: (0, readerLayout_1.readerLayoutOf)(body.rankedBy, (await (0, replacements_1.withReplacements)(mapContext(), (0, ledgerPresentation_1.withoutLedgerMetadata)(page, (0, ledgerPresentation_1.ledgerNoteMatcher)(config)), { limit: Math.max(page.length, topK) })).map(hiddenSpans_1.labelMemoryText), (0, ledgerPresentation_1.ledgerNoteMatcher)(config), { query: args.query, turnsOf: (0, readerEvents_1.readerEventsEnabled)() ? (0, readerEvents_1.noteTurnsLoader)(db) : undefined, scoreTurns: (0, readerEvents_1.readerEventsEnabled)() && (0, readerEvents_1.vectorsMeanSomething)(engine.provider?.identity?.kind) && probe.queryVector && engine.vectorIndex ? (0, readerEvents_1.turnScorer)(engine.vectorIndex, probe.queryVector) : undefined }, laidOut).map(hit => markImportedOrigin(hit, 'notePath')),
881
+ ...(0, factsLane_1.indexNotice)(db, true, config, args.scope),
882
+ ...(shownVerdict.relevance ? { relevance: shownVerdict.relevance } : {}),
883
+ ...(floored ? { floor: { similarity: floor, withheld: floored.withheld } } : {}),
884
+ };
885
+ }
886
+ return searchResult(body, result);
887
+ }
888
+ catch (err) {
889
+ return toolError(err);
890
+ }
891
+ });
892
+ server.registerTool('search_text', {
893
+ title: 'Search memory by keywords',
894
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
895
+ description: 'BM25/FTS keyword search over indexed note chunks: the query is matched as one exact phrase, and OR, AND, NEAR and * are plain words, not operators. Makes no call to an embedding service. When the term appears in no chunk text it is matched against note paths and frontmatter too, so a task id, slug or file name finds the note that carries it (reason: "note-identity"). Current facts saved with remember are found at once, before the next index_memory (reason: "fact"), in the places the other hits leave under top_k. A fact hit with a writerConflict line replaced a value another agent saved, and the line stays until that agent saves the subject and predicate again (see get_context). A hit of a remembered fact names no note (empty notePath and headingPath). Every hit carries session_date, when that excerpt was written (the moment a daily-log entry or a remembered fact was saved, otherwise its note\'s date), the same date search_memory and get_context give it: read it before trusting an old hit. A hit with `replaced` ({by, at, source, note?}) was corrected by a later save: the correction is listed before it, and `source` says who saved it.',
896
+ inputSchema: {
897
+ // A control character (NUL above all) has no place in words to search for: SQLite answers it with its own error text.
898
+ query: zod_1.z.string().min(1).max(200).superRefine((query, ctx) => {
899
+ const codes = (0, controls_1.unstorableControls)(query);
900
+ if (codes.length > 0)
901
+ ctx.addIssue({ code: 'custom', message: `query contains control characters (${codes.join(', ')}): remove them and search again` });
902
+ }).describe('Words to find, matched as one exact phrase: OR, AND, NEAR and * are plain words, not operators'),
903
+ top_k: zod_1.z.number().int().min(1).max(50).optional().describe('Max hits (default 20)'),
904
+ scope: zod_1.z.string().optional().describe('Memory scope: "global" (default) or scope:<slug> / project:<slug>'),
905
+ },
906
+ }, async (args, extra) => {
907
+ try {
908
+ const topK = args.top_k ?? 20;
909
+ const body = (0, engine_1.engineSearchText)(db, config.memoryDir, args.query, topK, args.scope, false, config.ledgerRelPath);
910
+ if (body.ok)
911
+ (0, factMarkers_1.markFactHits)(db, body.results, readerOf(extra), (0, ledgerPresentation_1.ledgerNoteMatcher)(config));
912
+ const result = body.ok
913
+ ? {
914
+ ...body,
915
+ // An added correction takes the place of a hit, so the page is no longer than top_k (or than it was, if it was longer).
916
+ results: (await (async (dated) => (0, replacements_1.withReplacements)(mapContext(), dated, { limit: Math.max(dated.length, topK) }))(await (0, hitDates_1.withHitDates)(db, config.memoryDir, (0, ledgerPresentation_1.withoutLedgerMetadata)((0, factsLane_1.withFactHits)(db, config, body.results, { query: args.query, scope: args.scope, match: 'all', limit: topK, dated: true, reader: readerOf(extra) }), (0, ledgerPresentation_1.ledgerNoteMatcher)(config))))).map(hit => markImportedOrigin(hit, 'notePath')),
917
+ }
918
+ : body;
919
+ return searchResult(body, result);
920
+ }
921
+ catch (err) {
922
+ return toolError(err);
923
+ }
924
+ });
925
+ server.registerTool('lookup_entity', {
926
+ title: 'Look up an entity',
927
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
928
+ description: 'Find a person/project/concept by name or alias. Does not call embeddings. Matches in order: exact canonical name, frontmatter alias, substring of a canonical name, substring of a source note path — never words inside note text, so a word that appears only inside a saved note is not an entity. A miss returns reason "no-entity-match" plus the entity count and the tool that would find the word. With scope set, the search and that count are restricted to the scope, so "no such entity" and "exists in another scope" return the same answer.',
929
+ inputSchema: {
930
+ name: zod_1.z.string().min(1).max(200).describe('Entity name or alias'),
931
+ scope: zod_1.z.string().optional().describe('Memory scope: "global" (default) or scope:<slug> / project:<slug>; restricts to entities whose source note belongs to the scope'),
932
+ },
933
+ }, async (args) => {
934
+ try {
935
+ const body = (0, engine_1.engineLookupEntity)(db, config, args.name, args.scope);
936
+ return jsonResult(body);
937
+ }
938
+ catch (err) {
939
+ return toolError(err);
940
+ }
941
+ });
942
+ server.registerTool('remember_note', {
943
+ title: 'Append a daily note',
944
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
945
+ description: 'Appends one line to a day\'s log: `text` is the line, e.g. remember_note({"text": "Ran the nightly build; two flaky tests in the importer."}). Do not save passwords, API keys or tokens. Timestamped, filed under today or `date`, and indexed at once, so search finds it before any index_memory (the receipt\'s indexed says whether that worked; if not, the line is still saved and keyword search finds it). '
946
+ + 'The receipt\'s id is what read_memory, retire_memory and restore_memory take. A line over 4,000 characters, one that is not valid Unicode, or one holding a control character (U+0000 to U+001F except tab, newline and carriage return, or U+007F) is refused, never cut, and nothing is written. `remember` saves a statement on its own the same way and is the default; use this when the line belongs to another day. `replaces` (the id of an earlier note or entry this line corrects) works as in remember.',
947
+ inputSchema: {
948
+ text: zod_1.z.string().min(1).max(limits_1.MAX_STATEMENT_LENGTH).describe('The line to log, in plain words.'),
949
+ date: omittedWhenBlank(zod_1.z.string().optional()).describe('YYYY-MM-DD (default today: in timezone when it is given, else in UTC)'),
950
+ source: omittedWhenBlank(zod_1.z.string().optional()).describe(SOURCE_ARG_DESCRIPTION),
951
+ scope: SCOPE_ARG,
952
+ timezone: TIMEZONE_ARG,
953
+ cites: CITES_ARG,
954
+ replaces: REPLACES_ARG,
955
+ },
956
+ }, async (args, extra) => {
957
+ try {
958
+ const source = sourceOf(args.source, extra, config);
959
+ if (args.replaces !== undefined)
960
+ await (0, replacements_1.assertReplaceable)(mapContext(), args.replaces, (0, scope_1.normalizeScope)(args.scope));
961
+ const body = await (0, engine_1.engineRememberNote)(config, args.text, args.date, source, args.scope, args.timezone);
962
+ // The daily line keeps the engine's own default (mcp); only whats_new's author falls back to unnamed.
963
+ db.prepare('INSERT INTO write_events (note_path, action, summary, created_at, author, scope) VALUES (?, ?, ?, ?, ?, ?)')
964
+ .run(body.file, 'note', args.text.slice(0, 240), new Date().toISOString(), source ?? 'unnamed', body.scope);
965
+ usefulness.cited(args.cites);
966
+ const replaced = args.replaces !== undefined && body.id
967
+ // The source the entry was written with (engineRememberNote's default when none), so the link names the same one.
968
+ ? await replacementReceipt(args.replaces, body.id, source ?? 'mcp')
969
+ : {};
970
+ return jsonResult({ ...body, ...replaced, ...(await indexSavedNote(db, config, engine, digests, body.file)) });
971
+ }
972
+ catch (err) {
973
+ return toolError(err);
974
+ }
975
+ });
976
+ server.registerTool('index_memory', {
977
+ title: 'Index the memory folder',
978
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
979
+ description: 'Walks the workspace\'s Markdown notes into the search index (chunks, keyword index, entities). If an embedding provider is configured, stamps new and changed notes with it; the provider must match the existing index dimensions. Run it when search_memory or get_context report index.stale; saved facts are keyword-searchable without it. It does not wait for a slow or refusing embedding service: such files are reported (warning "index-unfinished") and finished in the background.',
980
+ inputSchema: {},
981
+ }, async () => {
982
+ try {
983
+ const stats = await (0, engine_1.engineIndex)(db, config, engine, { interactive: true });
984
+ digests?.worker.notify();
985
+ // A file the embedding service would not take in time is saved and found by keyword; a background job embeds it.
986
+ return jsonResult({
987
+ ok: true,
988
+ ...stats,
989
+ ...(stats.errors > 0
990
+ ? { warning: 'index-unfinished', detail: `${stats.errors} file(s) are indexed for keyword search but not yet for search by meaning: the embedding service did not answer in time, and they are being finished in the background (memory_status shows chunksWithoutVectors until they are).` }
991
+ : {}),
992
+ });
993
+ }
994
+ catch (err) {
995
+ return toolError(err);
996
+ }
997
+ });
998
+ const mapContext = () => ({
999
+ db,
1000
+ memoryDir: config.memoryDir,
1001
+ ledgerRelPath: config.ledgerRelPath,
1002
+ indexOptions: engine.provider && engine.vectorIndex
1003
+ ? { embeddingProvider: engine.provider, vectorIndex: engine.vectorIndex }
1004
+ : {},
1005
+ // Where the evidence pass caches (evidence/options.ts), also when it is switched off now: rows written while it was on
1006
+ // still hold text a permanent deletion must take.
1007
+ evidenceCachePath: evidence?.options.cachePath
1008
+ ?? (process.env.RENOR_EVIDENCE_CACHE?.trim() || path_1.default.join(path_1.default.dirname(config.dbPath), 'evidence-cache.db')),
1009
+ });
1010
+ // The part of a save's receipt that says what `replaces` did: the link, or why none was made (a loop is never recorded).
1011
+ const replacementReceipt = async (old, by, source) => {
1012
+ const record = await (0, replacements_1.recordReplacement)(mapContext(), old, by, source);
1013
+ return record
1014
+ ? { replaced: { id: record.old, by: record.by } }
1015
+ : { replacesIgnored: 'nothing was linked: this save is the memory it replaces, or is already replaced by it, and a replacement loop would hide both' };
1016
+ };
1017
+ const memoryIdSchema = zod_1.z.string().min(1).max(1024).describe('A memory id (note:<path> or entry:<path>#<hash>) from memory_map; memory_map with a query finds one by its words without loading the whole map');
1018
+ // A fact is retired and restored by its event: retire_memory and restore_memory take `fact:<eventId>` too.
1019
+ const retirableIdSchema = zod_1.z.string().min(1).max(1024).describe('A memory id (note:<path>, entry:<path>#<hash> or fact:<eventId>) from memory_map; memory_map with a query finds one by its words without loading the whole map');
1020
+ server.registerTool('preview_import', {
1021
+ title: 'Preview a Markdown vault batch',
1022
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1023
+ description: 'Dry run for an agent-led import of notes into Imported/<label>/. The server cannot read your disk: read each file yourself and send its text as files: [{ relativePath, markdown }], relativePath being the path inside the folder you import (notes/idea.md: / separators, no leading / or ./ or ..). Only .md files: save a ChatGPT or Claude memory list, JSON or other text as Markdown first and leave attachments out. Names starting with "." are skipped. Returns counts, conflicts (a different file already at the destination is kept, not overwritten; use a new label to import a changed copy), skipped files with reasons, and an expiring batchId; writes no memory. Max 100 files, 64 KiB/file and 128 KiB per call including paths; for more, call again per group. Up to 8 previews can wait at once: apply one, or wait 15 minutes. Refuses unsafe or oversize inputs. Selection: include/exclude path prefixes. stage: false only checks (no batchId, nothing held). Show preview before apply_import.',
1024
+ inputSchema: hosted_1.importPreviewSchema,
1025
+ }, async (args) => {
1026
+ try {
1027
+ return jsonResult(await (0, hosted_1.previewImport)(mapContext(), args));
1028
+ }
1029
+ catch (err) {
1030
+ return toolError(err);
1031
+ }
1032
+ });
1033
+ const storageLimitOf = (headers) => {
1034
+ if (!headers || typeof headers !== 'object')
1035
+ return undefined;
1036
+ const raw = headers['x-renor-storage-limit'];
1037
+ const value = Array.isArray(raw) ? raw[0] : raw;
1038
+ const parsed = typeof value === 'string' ? Number(value) : NaN;
1039
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
1040
+ };
1041
+ server.registerTool('apply_import', {
1042
+ title: 'Apply a previewed Markdown batch',
1043
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1044
+ description: 'Apply preview_import batchId within 15 minutes (same tenant process). Additive: preserves existing different files and reports conflicts. Immediately keyword searchable; on a workspace with semantic search the new notes are then embedded in the background (memory_status says semantic: false until they are). No fact extraction. Returns batchId for undo_import; retries are idempotent.',
1045
+ inputSchema: hosted_1.importBatchSchema,
1046
+ }, async ({ batchId }, extra) => {
1047
+ try {
1048
+ return jsonResult(await (0, hosted_1.applyHostedImport)(mapContext(), batchId, storageLimitOf(extra?.requestInfo?.headers)));
1049
+ }
1050
+ catch (err) {
1051
+ return toolError(err);
1052
+ }
1053
+ // The import wrote its chunks without vectors and marked the index unfinished (so does a run that failed after its
1054
+ // commit): the background job embeds them, once, now. It returns null when there is nothing to embed.
1055
+ finally {
1056
+ // Digests are written for the imported notes once the index holds them whole (a note is listed only when finished).
1057
+ const rebuilding = (0, engine_1.engineRebuildEmbeddings)(db, engine);
1058
+ if (digests)
1059
+ void (rebuilding?.done ?? Promise.resolve()).then(() => digests.worker.notify());
1060
+ }
1061
+ });
1062
+ server.registerTool('undo_import', {
1063
+ title: 'Undo one vault import batch',
1064
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
1065
+ description: 'Undo this batch\'s claim on imported notes. A created note stays live while a later batch still asserts the same bytes; undoing the last such batch retires it. Preserves pre-existing files, conflicts and later edits; reports preserved paths and the re-assertion reason. Uses existing retirement journal and trash. Batch receipts persist across restarts and are tenant-local.',
1066
+ inputSchema: hosted_1.importBatchSchema,
1067
+ }, async ({ batchId }) => {
1068
+ try {
1069
+ return jsonResult(await (0, hosted_1.undoHostedImport)(mapContext(), batchId));
1070
+ }
1071
+ catch (err) {
1072
+ return toolError(err);
1073
+ }
1074
+ });
1075
+ server.registerTool('memory_map', {
1076
+ title: 'Map everything this workspace holds',
1077
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1078
+ description: 'Read-only overview for browsing, not for answering questions: every memory (notes and daily-log entries), the things lifecycle facts are about, topics (tags), lifecycle facts with their full history, grounded links between them (tag, wikilink, literal name mention, fact naming another thing), recent write events, and retired memories. A current fact with a writerConflict line replaced a value another agent saved, and the line stays until that agent saves the subject and predicate again. For context while working, use get_context instead. '
1079
+ + `The limit bounds every list (memories, things, facts, links, events, retired), newest first (things by their newest fact; links shared out across tag, wikilink, mention and fact): default ${memoryMap_1.DEFAULT_MAP_LIMIT}, at most ${MAX_LOOKUP_LIMIT}. total gives how many each list holds, and truncated says which lists were cut; to see more, raise limit, or pass query to find one memory. `
1080
+ + 'To find one memory to read, retire or restore, pass query (words it contains, e.g. "landlord"), and scope to look in one scope only: only the memories and retired memories that have one of them are returned, those with the rarest of the words first, then the newest, up to limit. A note save (remember without a fact, remember_note) returns the id of what it saved, so forgetting it needs no lookup.',
1081
+ inputSchema: {
1082
+ query: zod_1.z.string().min(1).max(500).optional().describe('Words the memory contains. Matched at the start of words in each memory\'s text, title and path.'),
1083
+ scope: zod_1.z.string().optional().describe('With query only: look in this memory scope ("global", or scope:<slug> / project:<slug>, which name the same folder for notes).'),
1084
+ limit: zod_1.z.number().int().min(1).max(MAX_LOOKUP_LIMIT).optional().describe(`At most this many of each list the map shows (memories, things, facts, links, events, retired). Without a query, default ${memoryMap_1.DEFAULT_MAP_LIMIT}. With a query it bounds the memories and retired memories found, default ${DEFAULT_LOOKUP_LIMIT}.`),
1085
+ },
1086
+ }, async (args) => {
1087
+ try {
1088
+ if (args.query === undefined) {
1089
+ // The whole map holds things, facts, links and events too, and none of them is cut by scope: ignoring it would
1090
+ // answer for every scope.
1091
+ if (args.scope !== undefined)
1092
+ throw (0, toolRefusal_1.refusal)('invalid_argument', 'memory_map takes a scope only with a query (words the memories contain): the whole map is not cut by scope');
1093
+ const map = await (0, memoryMap_1.buildMemoryMap)(mapContext(), { limit: args.limit });
1094
+ return jsonResult({ ...map, memories: map.memories.map(memory => markImportedOrigin(memory, 'path')) });
1095
+ }
1096
+ // Read the way the facts lane reads a query: stop words count only when nothing else is left.
1097
+ const terms = (0, agentMemory_1.factQueryTerms)(args.query);
1098
+ if (terms.length === 0)
1099
+ throw (0, toolRefusal_1.refusal)('invalid_argument', 'query has no word to look for (two or more letters or digits)');
1100
+ const scope = args.scope === undefined ? undefined : (0, scope_1.normalizeScope)(args.scope);
1101
+ const found = await (0, memoryMap_1.findMemories)(mapContext(), { query: args.query, terms, limit: args.limit ?? DEFAULT_LOOKUP_LIMIT, scope });
1102
+ return jsonResult({ ...found, memories: found.memories.map(memory => markImportedOrigin(memory, 'path')) });
1103
+ }
1104
+ catch (err) {
1105
+ return toolError(err);
1106
+ }
1107
+ });
1108
+ server.registerTool('read_memory', {
1109
+ title: 'Read one memory in full',
1110
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1111
+ description: 'Returns one memory from memory_map by id: its full text, its note metadata (status, tags, dates), and facts extracted from it. memory.session_date is when it was written, the date get_context and search_memory give the same text (memory.at is the entry\'s moment or the note\'s last write).',
1112
+ inputSchema: { id: memoryIdSchema },
1113
+ }, async (args) => {
1114
+ try {
1115
+ const memory = await (0, memoryMap_1.readMemory)(mapContext(), args.id);
1116
+ usefulness.readInFull(args.id);
1117
+ const index = await (0, replacements_1.replacementIndex)(mapContext());
1118
+ const replaced = index.replacedBy(args.id);
1119
+ const replaces = index.replaces(args.id);
1120
+ const labelled = (0, hiddenSpans_1.labelMemoryText)(memory);
1121
+ return jsonResult({
1122
+ ...labelled,
1123
+ memory: markImportedOrigin(labelled.memory, 'path'),
1124
+ ...(replaced ? { replaced } : {}),
1125
+ ...(replaces.length > 0 ? { replaces } : {}),
1126
+ });
1127
+ }
1128
+ catch (err) {
1129
+ return toolError(err);
1130
+ }
1131
+ });
1132
+ server.registerTool('retire_memory', {
1133
+ title: 'Retire a memory (reversible)',
1134
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
1135
+ description: 'Takes one memory out of recall without deleting it: a note file moves to the retired area, a daily-log entry moves out of its file, a fact leaves every read, and all leave search immediately. Use only when the owner asks to forget something; the receipt of the save that wrote it, or memory_map with a query (words of that memory), gives its id. restore_memory undoes it. A wrong fact is corrected with remember, not retired; a wrong or outdated note is corrected with remember and replaces. Retiring a correction undoes its replaces: the memory it replaced reads as current again.',
1136
+ inputSchema: { id: retirableIdSchema },
1137
+ }, async (args, extra) => {
1138
+ try {
1139
+ return jsonResult(await (0, memoryMap_1.retireMemory)({ ...mapContext(), source: sourceOf(undefined, extra, config) }, args.id));
1140
+ }
1141
+ catch (err) {
1142
+ return toolError(err);
1143
+ }
1144
+ });
1145
+ server.registerTool('restore_memory', {
1146
+ title: 'Restore a retired memory',
1147
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1148
+ description: 'Puts a retired memory back exactly as it was and re-indexes it. Refuses to overwrite a file that now occupies the same path.',
1149
+ inputSchema: { id: retirableIdSchema },
1150
+ }, async (args, extra) => {
1151
+ try {
1152
+ const storageLimit = storageLimitOf(extra?.requestInfo?.headers);
1153
+ return jsonResult(await (0, memoryMap_1.restoreMemory)({ ...mapContext(), source: sourceOf(undefined, extra, config) }, args.id, storageLimit === undefined ? {} : { validateAfterWrite: async () => {
1154
+ const bytes = await (0, storedSize_2.measureMarkdownBytes)(config.memoryDir);
1155
+ if (bytes > storageLimit) {
1156
+ throw (0, toolRefusal_1.refusal)('storage_limit', `Renor: this write would need ${bytes.toLocaleString('en-US')} bytes of Markdown storage and would not fit; the storage limit is ${storageLimit.toLocaleString('en-US')} bytes. This memory write was not run.`);
1157
+ }
1158
+ } }));
1159
+ }
1160
+ catch (err) {
1161
+ return toolError(err);
1162
+ }
1163
+ });
1164
+ // Only for the owner, from the dashboard. Irreversible, so an agent never holds it: retire_memory is how an agent forgets, and
1165
+ // the owner decides what is erased. Only the gateway can set the mark this reads, and it sets it only for the dashboard's own
1166
+ // short-lived read key; it strips a client-sent copy (apps/gateway/src/dashboard-mark.ts). Three layers, none relying on another:
1167
+ // 1. tools/list (installed once every tool is registered) drops the tool from its answer unless the request being answered carries
1168
+ // the mark. An SSE session keeps one server, so what was decided when it was built would keep listing the tool after the mark was gone;
1169
+ // 2. a server built without the mark has the tool disabled in the SDK's registry, so a call is refused before it reaches the handler;
1170
+ // 3. the handler below refuses a call whose own request carries no mark, which is what a session built with the mark answers.
1171
+ // (1) and (2) act on the registry's entry and on the answer, never on the handle registerTool returns: in a workspace that reports to
1172
+ // PostHog (RENOR_POSTHOG_KEY), @posthog/mcp stores a copy of the tool and that handle no longer reaches the SDK
1173
+ // (tests/review/delete-retired-instrumented.test.ts).
1174
+ server.registerTool('delete_retired_memory', {
1175
+ title: 'Permanently delete retired memories (owner only)',
1176
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
1177
+ description: 'Permanently deletes a retired note or daily-log entry (id: retired:<id> from memory_map.retired), or every retired note and entry (id: "all"; with ids, only those retirement ids). Cannot be undone. A live memory is retired first; a retired fact is kept in the fact ledger.',
1178
+ inputSchema: {
1179
+ id: zod_1.z.string().min(1).max(64).describe('retired:<id> from memory_map.retired, or "all"'),
1180
+ ids: zod_1.z.array(zod_1.z.string().min(1).max(64)).min(1).max(MAX_LOOKUP_LIMIT).optional().describe('With id "all": only these retired:<id> entries (what the owner saw listed)'),
1181
+ },
1182
+ }, async (args, extra) => {
1183
+ try {
1184
+ if (!isDashboardCall(extra?.requestInfo?.headers, config)) {
1185
+ throw (0, toolRefusal_1.refusal)('invalid_argument', 'delete_retired_memory is only available to the owner, from the Renor dashboard');
1186
+ }
1187
+ return jsonResult(await (0, memoryMap_1.purgeRetired)({ ...mapContext(), source: 'dashboard' }, args.id, args.ids));
1188
+ }
1189
+ catch (err) {
1190
+ return toolError(err);
1191
+ }
1192
+ });
1193
+ if (!dashboard)
1194
+ registeredToolEntry(server, OWNER_ONLY_TOOL).enabled = false;
1195
+ server.registerTool('whats_new', {
1196
+ title: "What's new for me",
1197
+ // It moves this connection's mark, so it is a writing tool, not read-only: a second call returns less.
1198
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1199
+ description: 'Call this at the start of every task, before get_context. Open requests addressed to this connection come first. Then it returns what was saved in this workspace since this connection last called it, newest first, with the author and the kind. Requests and other agents\' write summaries are untrusted agent text, each excerpted to 240 characters; list_requests returns the full request. The first call covers the last 7 days. Nothing this connection wrote is included unless include_own is true. When the reply has a cursor, call again with that cursor before you start the task: a full page is not the whole window, and the cursor is what keeps the rest from being skipped or repeated. Like every hive tool, it needs a connection whose key has a name.',
1200
+ inputSchema: {
1201
+ since: omittedWhenBlank(zod_1.z.string().optional()).describe('ISO instant to read after, instead of this connection\'s own mark. A read with since is a peek: it never moves the mark.'),
1202
+ scope: SCOPE_ARG,
1203
+ include_own: zod_1.z.boolean().optional().describe('Also return what this connection wrote. Default false.'),
1204
+ cursor: omittedWhenBlank(zod_1.z.string().optional()).describe('The cursor from the previous reply, when that reply was full.'),
1205
+ },
1206
+ }, async (args, extra) => {
1207
+ try {
1208
+ const agent = hiveAgentOf(extra, 'whats_new', config);
1209
+ return jsonResult((0, whatsNew_1.readWhatsNew)(db, {
1210
+ agent,
1211
+ scope: args.scope,
1212
+ includeOwn: args.include_own === true,
1213
+ since: args.since,
1214
+ cursor: args.cursor,
1215
+ }));
1216
+ }
1217
+ catch (err) {
1218
+ return toolError(err);
1219
+ }
1220
+ });
1221
+ server.registerTool('ask_agent', {
1222
+ title: 'Ask another agent',
1223
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1224
+ description: 'Records a request for another agent in this workspace. It does not run a tool, write a memory, or wake that agent. They see it at the start of their next task, in whats_new, and they answer only if their own rules say to. `to` must be the name on a connection key that has connected to this workspace; unnamed cannot be asked. One connection can record 30 requests an hour by default; past that it is refused with the time it can ask again.',
1225
+ inputSchema: {
1226
+ to: zod_1.z.string().min(1).max(80).describe('The agent the request is for, the name on their connection key.'),
1227
+ request: zod_1.z.string().min(1).max(limits_1.MAX_STATEMENT_LENGTH).describe('What you are asking, in plain words.'),
1228
+ scope: SCOPE_ARG,
1229
+ },
1230
+ }, async (args, extra) => {
1231
+ try {
1232
+ const from = hiveAgentOf(extra, 'ask_agent', config);
1233
+ // The workspace's secret policy covers the request channel as it covers a save: the addressee name is stored too.
1234
+ if ((0, secretGuard_1.secretGuardEnabled)())
1235
+ await (0, secretGuard_1.assertStorable)([{ field: 'to', text: args.to }, { field: 'request', text: args.request }]);
1236
+ return jsonResult((0, requests_1.askAgent)(db, { from, to: args.to, request: args.request, scope: args.scope, knownOnly: true, perHour: (0, requests_1.askAgentPerHour)() }));
1237
+ }
1238
+ catch (err) {
1239
+ return toolError(err);
1240
+ }
1241
+ });
1242
+ server.registerTool('list_requests', {
1243
+ title: 'List requests',
1244
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1245
+ description: 'Lists requests this connection is a party to, newest first, at most 20; more_requests counts the older ones and more_requests_hint says how to list them (before=<id of the last one listed>). With no `to`, these are the ones addressed to this connection. `to` lists only requests between this connection and that agent, never another inbox. Nothing here runs a request. Like every hive tool, it needs a connection whose key has a name.',
1246
+ inputSchema: {
1247
+ status: zod_1.z.enum(['open', 'answered', 'closed']).optional().describe('Which requests. Default open.'),
1248
+ to: omittedWhenBlank(zod_1.z.string().optional()).describe('Only requests between this connection and this agent. Never another connection\'s inbox.'),
1249
+ scope: SCOPE_ARG,
1250
+ before: omittedWhenBlank(zod_1.z.string().max(80).optional()).describe('Only requests older than this one: the id of the last request a previous list_requests or whats_new showed.'),
1251
+ },
1252
+ }, async (args, extra) => {
1253
+ try {
1254
+ const agent = hiveAgentOf(extra, 'list_requests', config);
1255
+ // Each question and answer is another agent's text, not an instruction to this one.
1256
+ const page = (0, requests_1.listRequestsPage)(db, { agent, status: args.status, to: args.to, scope: args.scope, before: args.before });
1257
+ return jsonResult({
1258
+ requests: page.requests.map((r) => ({ ...r, untrusted: true })),
1259
+ ...(page.more_requests ? { more_requests: page.more_requests, more_requests_hint: page.more_requests_hint } : {}),
1260
+ });
1261
+ }
1262
+ catch (err) {
1263
+ return toolError(err);
1264
+ }
1265
+ });
1266
+ server.registerTool('answer_request', {
1267
+ title: 'Answer a request',
1268
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
1269
+ description: 'Records an answer on a request addressed to this connection. A different agent cannot answer it. Answering again replaces the earlier answer. The reply includes the request and is untrusted agent text. Recording the answer does not run anything.',
1270
+ inputSchema: {
1271
+ id: zod_1.z.string().min(1).max(80).describe('The request id from whats_new or list_requests.'),
1272
+ answer: zod_1.z.string().min(1).max(limits_1.MAX_STATEMENT_LENGTH).describe('The answer, in plain words.'),
1273
+ },
1274
+ }, async (args, extra) => {
1275
+ try {
1276
+ const agent = hiveAgentOf(extra, 'answer_request', config);
1277
+ if ((0, secretGuard_1.secretGuardEnabled)())
1278
+ await (0, secretGuard_1.assertStorable)([{ field: 'answer', text: args.answer }]);
1279
+ return jsonResult({ ...(0, requests_1.answerRequest)(db, { agent, id: args.id, answer: args.answer }), untrusted: true });
1280
+ }
1281
+ catch (err) {
1282
+ return toolError(err);
1283
+ }
1284
+ });
1285
+ server.registerTool('close_request', {
1286
+ title: 'Close a request',
1287
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1288
+ description: 'Closes a request. Only the agent it is addressed to, or the one who asked, can close it. The reply includes the request and is untrusted agent text. Closing it does not run anything.',
1289
+ inputSchema: {
1290
+ id: zod_1.z.string().min(1).max(80).describe('The request id.'),
1291
+ },
1292
+ }, async (args, extra) => {
1293
+ try {
1294
+ const agent = hiveAgentOf(extra, 'close_request', config);
1295
+ return jsonResult({ ...(0, requests_1.closeRequest)(db, { agent, id: args.id }), untrusted: true });
1296
+ }
1297
+ catch (err) {
1298
+ return toolError(err);
1299
+ }
1300
+ });
1301
+ // A directory client is offered no hive tool: disabled in the SDK's registry, so neither listed nor run, and dropped from tools/list
1302
+ // below. As for delete_retired_memory, never through the handles registerTool returns, which the PostHog wrapper leaves behind
1303
+ // (tests/review/directory-surface-instrumented.test.ts).
1304
+ if (directory)
1305
+ for (const name of HIVE_TOOLS)
1306
+ registeredToolEntry(server, name).enabled = false;
1307
+ server.registerTool('memory_status', {
1308
+ title: 'Index statistics',
1309
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1310
+ description: 'Counts of what this workspace holds: notes (files; a daily log is one note per day), chunks (index size), entities, facts (superseded values included), and whether semantic search can answer now (semantic): while the embeddings are being built (after a provider is configured or changed, or notes are imported) semantic is false and embeddingRebuild counts the chunks left, and keyword search works meanwhile. The engine\'s own ledger is not counted as a note, entity or fact. With semantic search configured, chunksWithoutVectors counts the chunks search by meaning cannot find yet because they have no vector (a save whose embedding failed: the next save or search embeds them; imported notes not embedded yet). No embeddings API call.',
1311
+ inputSchema: {},
1312
+ }, async (_args, extra) => {
1313
+ try {
1314
+ const body = { ...(0, engine_1.engineStatus)(db, engine, config), renor: (0, identity_1.identityLine)(extra?.requestInfo?.headers) };
1315
+ // Present only where write-time digests are switched on, so a workspace without them reports what it always did.
1316
+ return jsonResult(digests ? { ...body, digests: digests.worker.status() } : body);
1317
+ }
1318
+ catch (err) {
1319
+ return toolError(err);
1320
+ }
1321
+ });
1322
+ // Every tool is registered above; the SDK's own handlers (initialize included) are mapped in the same pass.
1323
+ (0, requestErrors_1.mapInvalidParams)(server.server);
1324
+ // Layer 1 above: the owner-only tool is in the answer only when this request carries the mark. It is decided on the answer, after the
1325
+ // SDK and whatever wraps it have built the list: a flag flipped around this call would be put back before PostHog's listing wrapper,
1326
+ // which awaits first, reached the SDK. The hive tools leave the answer the same way for a directory client.
1327
+ const listHandlers = server.server._requestHandlers;
1328
+ const listTools = listHandlers.get('tools/list');
1329
+ if (!listTools)
1330
+ throw new Error('tools/list handler missing');
1331
+ listHandlers.set('tools/list', async (request, extra) => {
1332
+ const answer = await listTools(request, extra);
1333
+ const headers = extra?.requestInfo?.headers;
1334
+ const hidden = new Set();
1335
+ if (!isDashboardCall(headers, config))
1336
+ hidden.add(OWNER_ONLY_TOOL);
1337
+ if (directory || isDirectoryCall(headers, config))
1338
+ for (const name of HIVE_TOOLS)
1339
+ hidden.add(name);
1340
+ if (hidden.size === 0)
1341
+ return answer;
1342
+ // An answer this cannot read is not passed on unfiltered.
1343
+ if (!Array.isArray(answer?.tools))
1344
+ throw new Error('tools/list answered without a tools array');
1345
+ return { ...answer, tools: answer.tools.filter((tool) => !hidden.has(tool.name)) };
1346
+ });
1347
+ return server;
1348
+ }
1349
+ function createMvpApp(config, engineOverride) {
1350
+ // Before anything is opened: the memory directory and the index are owner-only. A readable one is tightened (and said
1351
+ // once); one that cannot be, because someone else owns it, stops the start as an unsafe client store does. openIndexDb
1352
+ // opens through openDb, so a damaged index that is set aside and rebuilt is created owner-only as well.
1353
+ (0, memoryDir_1.ensureOwnerOnlyMemoryDir)(config.memoryDir);
1354
+ (0, memoryDir_1.ensureOwnerOnlyFile)(config.dbPath);
1355
+ const db = (0, client_1.openIndexDb)(config.dbPath, (0, lifecycle_1.resolveLedgerPath)(config.memoryDir, config.ledgerRelPath));
1356
+ (0, schema_1.runMigrations)(db);
1357
+ // Facts saved before the term index existed, or by a release without it, are indexed before the first search.
1358
+ (0, factTerms_1.ensureFactTerms)(db);
1359
+ // An index written for another folder (this one was moved or restored) names its notes by the old paths: those rows
1360
+ // go before anything reads the index, so no note is served from a place it no longer is. Fails the start if it cannot.
1361
+ const rowsOutsideRoot = (0, outsideRoot_1.removeRowsOutsideRoot)(db, config.memoryDir).total;
1362
+ if (rowsOutsideRoot > 0)
1363
+ console.log(`[startup] removed ${rowsOutsideRoot} index row(s) for files outside ${config.memoryDir}; the folder is indexed again before serving`);
1364
+ const pendingPass = (0, outsideRoot_1.reindexPending)(db);
1365
+ const engine = (0, engine_1.attachEngine)(db, config, engineOverride);
1366
+ // An index whose vectors are in another model's space, or in none anyone can vouch for, is cleared and embedded
1367
+ // again in the background, and one whose chunks have no vectors yet (it was written without a provider) is
1368
+ // completed the same way; semantic search is off until that is done (keyword search is not).
1369
+ const rebuild = (0, engine_1.engineRebuildEmbeddings)(db, engine);
1370
+ // What an earlier process could not embed in time (its service was down when it was saved, or it exited first) is
1371
+ // finished now, in the background, once the service answers.
1372
+ if ((0, engine_1.hasIndexWorkToFinish)(db, engine))
1373
+ (0, engine_1.engineFinishIndexInBackground)(db, config, engine);
1374
+ const provider = new auth_1.OwnerOAuthProvider(config);
1375
+ const idle = config.idleExitMs ? new idle_1.IdleMonitor(config.idleExitMs) : null;
1376
+ // What a release asks before it restarts this engine (GET /internal/activity): the work a restart would cut and when the
1377
+ // last of it ended. It tracks the same work the idle monitor does and passes it on, so every place that held the engine
1378
+ // awake goes through it; it also counts each MCP request until its response has ended (below) and the background jobs a
1379
+ // call starts and does not await (the embedding rebuild and the background index, while they are working).
1380
+ const activity = new activity_1.ActivityGauge(idle, Date.now, () => [(0, engine_1.runningEmbeddingRebuild)(engine), (0, engine_1.runningBackgroundIndex)(db)]);
1381
+ // Markdown bytes: cached background measurements for reporting, fresh awaited walks for enforced write admission.
1382
+ // Both travel on the token mint the gateway already makes for every call. See storedSize.ts.
1383
+ const storedSize = new storedSize_1.StoredSizeGauge(config.memoryDir);
1384
+ // Write-time digests: null unless RENOR_DIGESTS is on (it is off by default). The worker writes them after a save; it
1385
+ // starts here so notes an earlier run or an import left without one are caught up, and closes with the app.
1386
+ const digests = (0, engine_1.resolveEngineDigests)(db, config, engine, activity);
1387
+ digests?.worker.start();
1388
+ const usefulness = new readUsefulness_1.ReadUsefulness();
1389
+ const connection = (0, connectionStatus_1.connectionStatusFromEnv)(process.env);
1390
+ const server = buildMcpServer(db, config, engine, activity, digests, usefulness, connection, false);
1391
+ // An index an earlier build wrote holds the engine's own daily-note headers as facts, and a note that has not changed
1392
+ // is never rebuilt, so they are removed here, before anything is read. A failure must not keep a workspace from serving.
1393
+ try {
1394
+ const removed = (0, pipeline_1.removeDailyHeaderFacts)(db);
1395
+ if (removed > 0)
1396
+ console.log(`[startup] removed ${removed} daily-note header facts an earlier build indexed`);
1397
+ }
1398
+ catch (err) {
1399
+ console.error('[startup] removing daily-note header facts failed:', err instanceof Error ? err.message : String(err));
1400
+ }
1401
+ const app = (0, express_1.default)();
1402
+ // Express's default error page prints the stack, with absolute paths, unless the app is in production mode.
1403
+ app.set('env', 'production');
1404
+ // No `X-Powered-By: Express`: it names the stack to a scanner and serves no client, and the gateway
1405
+ // passes this engine's answers through to mcp.renor.cloud as they are (live-security F5).
1406
+ app.disable('x-powered-by');
1407
+ // ACCESS LOG (diagnostics only): one metadata-only line per HTTP outcome
1408
+ // covering every status class, so server-side connector calls (ChatGPT)
1409
+ // become visible without any sensitive material. Fields: method, path
1410
+ // PREFIX, status, elapsed ms. NEVER bodies, query strings, Authorization
1411
+ // headers, codes, tickets, or secrets. The raw query is dropped for ALL
1412
+ // paths; /authorize additionally logs only an 8-char client_id prefix.
1413
+ const ACCESS_PATH_PREFIXES = ['/register', '/authorize', '/token', '/mvp-login', '/mcp', '/.well-known'];
1414
+ const SAFE_PREFIX_RE = /^[A-Za-z0-9_-]{1,8}$/;
1415
+ app.use((req, res, next) => {
1416
+ const startedAt = Date.now();
1417
+ // Parse NOW: express routers rewrite req.url while matching, so the
1418
+ // original path must be captured before routing begins.
1419
+ let pathname = '';
1420
+ let clientPrefix;
1421
+ try {
1422
+ const u = new URL(req.url, 'http://x');
1423
+ pathname = u.pathname;
1424
+ if (pathname === '/authorize') {
1425
+ const raw = u.searchParams.get('client_id') ?? '';
1426
+ if (SAFE_PREFIX_RE.test(raw.slice(0, 8)))
1427
+ clientPrefix = raw.slice(0, 8);
1428
+ }
1429
+ }
1430
+ catch {
1431
+ next();
1432
+ return; // malformed request line: nothing safe to log
1433
+ }
1434
+ let logged = false;
1435
+ const emit = (closedWithoutFinish) => {
1436
+ if (logged)
1437
+ return;
1438
+ logged = true;
1439
+ if (!/^\/[A-Za-z0-9._~/-]*$/.test(pathname))
1440
+ return; // avoid log injection
1441
+ const path = ACCESS_PATH_PREFIXES.find(p => pathname === p || pathname.startsWith(p + '/'))
1442
+ ?? (ACCESS_PATH_PREFIXES.find(p => pathname.startsWith(p)) ?? pathname);
1443
+ const fields = {
1444
+ method: req.method,
1445
+ path,
1446
+ status: res.statusCode || 0, // 0 = closed before any status was sent
1447
+ ms: Date.now() - startedAt,
1448
+ instance: (process.env.MVP_INSTANCE_ID ?? 'unknown').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 32) || 'unknown',
1449
+ };
1450
+ if (clientPrefix !== undefined)
1451
+ fields.client_prefix = clientPrefix;
1452
+ if (closedWithoutFinish)
1453
+ fields.outcome = 'closed'; // client abort / mid-stream disconnect
1454
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: 'http_access', ...fields }));
1455
+ };
1456
+ res.on('finish', () => emit(false));
1457
+ res.on('close', () => emit(true)); // aborted or disconnected responses
1458
+ next();
1459
+ });
1460
+ // On-demand: count every request while it is open, refuse new ones once draining.
1461
+ // After the access log, so the draining 503s are logged like any other outcome.
1462
+ if (idle)
1463
+ app.use(idle.middleware);
1464
+ // The activity report counts a POST or DELETE on /mcp from its start until its HTTP response has finished or closed, not
1465
+ // until the tool handler returns: a streamed response can still be in delivery then, and a restart would truncate it. A GET
1466
+ // is the idle stream a connected agent keeps open, which is not work.
1467
+ app.use((req, res, next) => {
1468
+ if ((req.method === 'POST' || req.method === 'DELETE') && /^\/mcp\/?$/i.test(req.path))
1469
+ activity.request(res);
1470
+ next();
1471
+ });
1472
+ // Proxy hygiene (FUNNEL): this service NEVER trusts X-Forwarded-* headers
1473
+ // (AUTH-B1 stance — they are spoofable client input). Public-test proxies
1474
+ // (e.g. the tailscale funnel) append X-Forwarded-For, which makes SDK
1475
+ // routes' express-rate-limit throw ERR_ERL_UNEXPECTED_X_FORWARDED_FOR
1476
+ // (500s on /register, /token) behind them. Stripping the headers keeps the
1477
+ // long-standing no-trust stance and makes rate limiting key on the actual
1478
+ // socket peer, as before.
1479
+ app.use((req, _res, next) => {
1480
+ delete req.headers['x-forwarded-for'];
1481
+ delete req.headers['x-forwarded-host'];
1482
+ delete req.headers['x-forwarded-proto'];
1483
+ delete req.headers['x-forwarded-port'];
1484
+ next();
1485
+ });
1486
+ // DNS-rebinding guard: a request whose Host is not this service's own name (MVP_BASE_URL) or a loopback name is
1487
+ // refused before any route runs. A browser page that rebinds its domain to 127.0.0.1 sends its own Host.
1488
+ // Skipped behind the gateway (MVP_GATEWAY_SECRET set): there the tenant's public Host arrives through Caddy by design.
1489
+ if (!config.gatewaySecret)
1490
+ app.use((0, hostHeaderValidation_js_1.hostHeaderValidation)([config.baseUrl.hostname, ...apiAuth_1.LOCAL_API_HOSTNAMES]));
1491
+ // Build identity for deploy verification: which source commit is THIS tenant
1492
+ // engine running? No credential is required — the whole purpose is that the
1493
+ // operator can verify the deployed revision over SSH without one — but the
1494
+ // route is LOOPBACK-ONLY: a request that arrived from a proxy hop or from a
1495
+ // non-loopback peer or under a public Host gets the same 404 as any unknown
1496
+ // path. Version checks are a deploy instrument, not a product endpoint, so
1497
+ // they are not exposed on the public tenant hosts.
1498
+ //
1499
+ // It returns build metadata ONLY, read through the shared non-throwing helper
1500
+ // (src/buildIdentity.ts) that the librarian's GET /api/health also uses: no
1501
+ // tenant slug, no memory content, no filesystem or vault paths, no tokens, no
1502
+ // counts — and every value has been shape-validated there, so a manifest
1503
+ // populated from environment variables cannot reflect through it. `ok` marks
1504
+ // the probe itself, not the manifest: an unstamped deployment answers ok with
1505
+ // commit "unknown" (never a throw).
1506
+ app.get(VERSION_PATH, (req, res) => {
1507
+ // Revalidating probe: a cached answer would fake deploy parity, which is
1508
+ // the single thing this route exists to report truthfully.
1509
+ res.set('Cache-Control', 'no-store');
1510
+ if (!isLocalDirectCall(req)) {
1511
+ res.status(404).json({ error: 'not found' });
1512
+ return;
1513
+ }
1514
+ res.json({ ok: true, ...(0, buildIdentity_1.readBuildIdentity)() });
1515
+ });
1516
+ // What the release judge reads before it restarts this engine: how many tool calls are running and how long ago the
1517
+ // last one ended. Loopback-only like /version (a proxied or public caller gets the same 404 as any unknown path), no
1518
+ // credential, and counts only: no tool name, argument, tenant or path. An open MCP stream is not a tool call, so a
1519
+ // connected agent that is not calling anything does not show here.
1520
+ app.get(activity_1.ACTIVITY_PATH, (req, res) => {
1521
+ res.set('Cache-Control', 'no-store');
1522
+ if (!isLocalDirectCall(req)) {
1523
+ res.status(404).json({ error: 'not found' });
1524
+ return;
1525
+ }
1526
+ res.json(activity.report());
1527
+ });
1528
+ // Dynamic client registration is for MCP clients, which are native programs and send no Origin. The SDK answers
1529
+ // /register with `Access-Control-Allow-Origin: *`, which lets any website register a client from the owner's browser
1530
+ // (and squat a client name for good, or burn the hourly limit). A request that names a foreign Origin is refused, and
1531
+ // no response from this route carries a CORS header, so a browser blocks the preflight and the page never sees an answer.
1532
+ const CORS_RESPONSE_HEADER = /^access-control-/i;
1533
+ app.use('/register', (req, res, next) => {
1534
+ const origin = req.headers.origin;
1535
+ if (origin !== undefined && origin !== config.baseUrl.origin) {
1536
+ res.status(403).json({ error: 'forbidden_origin', error_description: 'client registration does not accept requests from a web page' });
1537
+ return;
1538
+ }
1539
+ const writeHead = res.writeHead;
1540
+ res.writeHead = function (...args) {
1541
+ for (const name of res.getHeaderNames())
1542
+ if (CORS_RESPONSE_HEADER.test(name))
1543
+ res.removeHeader(name);
1544
+ return writeHead.apply(this, args);
1545
+ };
1546
+ next();
1547
+ });
1548
+ // OAuth 2.1 authorization server endpoints (discovery, /authorize, /token,
1549
+ // /register, /revocation) — SDK-supported components.
1550
+ app.use((0, router_js_1.mcpAuthRouter)({
1551
+ provider,
1552
+ issuerUrl: config.baseUrl,
1553
+ baseUrl: config.baseUrl,
1554
+ scopesSupported: ['memory'],
1555
+ resourceServerUrl: new URL(MCP_PATH, config.baseUrl),
1556
+ resourceName: 'Memory Company (single-owner MVP)',
1557
+ // 0 = a confidential client's secret does not lapse after the SDK's 30 days; the persisted registry has no renewal.
1558
+ clientRegistrationOptions: { clientSecretExpirySeconds: 0 },
1559
+ }));
1560
+ // Owner login form submit.
1561
+ app.use('/mvp-login', (0, auth_1.ownerLoginRouter)(provider, config));
1562
+ const sseSessions = new Map();
1563
+ // Ids a client ended with DELETE. The spec answers a terminated session with 404, so a POST that names one is refused
1564
+ // instead of served statelessly. Process-local and bounded: the oldest id goes first. After a restart the id is
1565
+ // forgotten like any other, and the stateless path serves it.
1566
+ const ENDED_SESSION_LIMIT = 1_000;
1567
+ const endedSessions = new Set();
1568
+ const markSessionEnded = (sid) => {
1569
+ endedSessions.delete(sid);
1570
+ endedSessions.add(sid);
1571
+ if (endedSessions.size > ENDED_SESSION_LIMIT)
1572
+ endedSessions.delete(endedSessions.values().next().value);
1573
+ };
1574
+ // Each authenticated connection name is recorded once, so ask_agent can
1575
+ // address it. A process remembers what it already wrote, so a name costs one insert per process, not one per call.
1576
+ const namesSeen = new Set();
1577
+ const noteConnectionName = (raw) => {
1578
+ const name = Array.isArray(raw) ? raw[0] : raw;
1579
+ const identity = typeof name === 'string' ? trustedIdentity(name) : 'unnamed';
1580
+ if (identity === 'unnamed' || namesSeen.has(identity))
1581
+ return;
1582
+ try {
1583
+ (0, requests_1.noteKnownAgent)(db, identity);
1584
+ namesSeen.add(identity);
1585
+ }
1586
+ catch {
1587
+ // Recording a name must never fail the call it came with.
1588
+ }
1589
+ };
1590
+ const holdOpen = (entry, res) => {
1591
+ if (res.destroyed)
1592
+ return; // the client is already gone: nothing is open
1593
+ entry.open++;
1594
+ res.once('close', () => { entry.open--; });
1595
+ };
1596
+ // TTL eviction runs on every MCP request (idle sessions die on their own),
1597
+ // except a session that still has a response open: its client is there.
1598
+ const evictExpiredSseSessions = (now) => {
1599
+ for (const [id, entry] of sseSessions) {
1600
+ if (entry.open === 0 && now - entry.lastActivity > config.mcpSessionTtlMs) {
1601
+ sseSessions.delete(id);
1602
+ void entry.transport.close();
1603
+ }
1604
+ }
1605
+ };
1606
+ // Bound eviction runs ONLY when a NEW session is about to be minted: make
1607
+ // room by closing the least-recently-active sessions, those with no open
1608
+ // response first (a session with a stream goes only when every session has
1609
+ // one). (Evicting on every request would wrongly churn live sessions while
1610
+ // the map is at the cap.)
1611
+ const makeRoomForSseSession = () => {
1612
+ const held = (entry) => (entry.open > 0 ? 1 : 0);
1613
+ while (sseSessions.size >= config.mcpMaxSessions) {
1614
+ let victimId = null;
1615
+ let victim = null;
1616
+ for (const [id, entry] of sseSessions) {
1617
+ if (victim === null || held(entry) < held(victim)
1618
+ || (held(entry) === held(victim) && entry.lastActivity < victim.lastActivity)) {
1619
+ victimId = id;
1620
+ victim = entry;
1621
+ }
1622
+ }
1623
+ if (victimId === null || victim === null)
1624
+ break;
1625
+ sseSessions.delete(victimId);
1626
+ void victim.transport.close();
1627
+ }
1628
+ };
1629
+ const requireBearer = async (req, res) => {
1630
+ const auth = req.headers.authorization;
1631
+ if (!auth || !auth.startsWith('Bearer ')) {
1632
+ res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"').status(401).json({
1633
+ jsonrpc: '2.0', error: { code: -32001, message: 'Missing or invalid Authorization bearer token' }, id: null,
1634
+ });
1635
+ return null;
1636
+ }
1637
+ try {
1638
+ const verified = await provider.verifyAccessToken(auth.slice('Bearer '.length).trim());
1639
+ // The SDK forwards req.auth to each call's authInfo, including SSE and forgotten sessions.
1640
+ // Resolve the name for THIS bearer on THIS request; a session id is never an identity.
1641
+ if (!config.gatewaySecret) {
1642
+ delete req.headers['x-renor-agent'];
1643
+ verified.extra = { ...verified.extra, connectionName: provider.hiveConnectionName(verified.clientId) };
1644
+ }
1645
+ req.auth = verified;
1646
+ return verified;
1647
+ }
1648
+ catch {
1649
+ res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"').status(401).json({
1650
+ jsonrpc: '2.0', error: { code: -32001, message: 'Missing or invalid Authorization bearer token' }, id: null,
1651
+ });
1652
+ return null;
1653
+ }
1654
+ };
1655
+ const mcpSessionEntry = (req, res, now) => {
1656
+ const sid = req.headers['mcp-session-id'];
1657
+ if (typeof sid !== 'string' || sid.length === 0) {
1658
+ res.status(400).json({
1659
+ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' }, id: null,
1660
+ });
1661
+ return null;
1662
+ }
1663
+ const entry = sseSessions.get(sid);
1664
+ if (!entry || entry.directory !== isDirectoryCall(req.headers, config)) {
1665
+ res.status(404).json({
1666
+ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null,
1667
+ });
1668
+ return null;
1669
+ }
1670
+ entry.lastActivity = now;
1671
+ return entry;
1672
+ };
1673
+ // A POST that names a session this process does not know: the engine restarted or slept, the idle TTL swept the
1674
+ // session, or the cap evicted it. Nothing the engine does depends on the session (the bearer, verified before this,
1675
+ // is the credential), so the call is answered on a transport of its own instead of with a 404 that ends the
1676
+ // client's conversation. Nothing is registered, so this cannot grow the registry and the cap still holds. A GET
1677
+ // or DELETE for the same id stays a 404: the standalone stream is the one thing that needs the session, and the 404
1678
+ // is how a client learns it has none and opens a new one.
1679
+ const serveForgottenSession = async (req, res) => {
1680
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
1681
+ sessionIdGenerator: undefined, // stateless: this transport serves this one request
1682
+ enableJsonResponse: false,
1683
+ });
1684
+ const sessionServer = buildMcpServer(db, config, engine, activity, digests, usefulness, connection, false, null, isDashboardCall(req.headers, config), isDirectoryCall(req.headers, config));
1685
+ res.once('close', () => { void transport.close(); void sessionServer.close(); });
1686
+ await sessionServer.connect(transport);
1687
+ await transport.handleRequest(req, res, req.body);
1688
+ };
1689
+ // Trusted universal gateway → short-lived MCP bearer (users never see owner passwords).
1690
+ // LOOPBACK-ONLY, like /version: the gateway calls http://127.0.0.1:<port> directly, and whatever a
1691
+ // tenant-site Caddy forwards gets a 404 before the body is read or the secret compared. Without it,
1692
+ // a host whose Caddy block lacked the internal-path deny answered the internet: an unlimited oracle
1693
+ // for the one secret that mints a bearer for every tenant (live-security F3).
1694
+ app.post('/internal/gateway-token', (req, res, next) => {
1695
+ if (!isLocalDirectCall(req)) {
1696
+ res.status(404).json({ error: 'not found' });
1697
+ return;
1698
+ }
1699
+ next();
1700
+ }, express_1.default.json({ limit: '4kb' }), async (req, res) => {
1701
+ try {
1702
+ if (!config.gatewaySecret) {
1703
+ res.status(404).json({ error: 'gateway token disabled' });
1704
+ return;
1705
+ }
1706
+ const secret = String(req.headers['x-renor-gateway-secret'] ?? req.body?.secret ?? '');
1707
+ const tokens = provider.issueGatewayAccessToken(secret);
1708
+ // Beside the bearer, the bytes of Markdown this workspace stores, when known: the gateway records it for the
1709
+ // plan's storage meter. This route is loopback-only, so the figure never leaves the host's own gateway.
1710
+ const fresh = req.body?.fresh_storage === true;
1711
+ const storedBytes = fresh ? await storedSize.readFresh() : storedSize.read();
1712
+ res.json(storedBytes === null ? tokens : { ...tokens, stored_bytes: storedBytes, ...(fresh ? { stored_bytes_fresh: true } : {}) });
1713
+ }
1714
+ catch {
1715
+ res.status(401).json({ error: 'unauthorized' });
1716
+ }
1717
+ });
1718
+ // The bearer check runs before the body is read, so an unauthenticated caller gets the same 401 whatever it sent,
1719
+ // a malformed or oversized body included. The parser runs for authenticated callers only, and its UTF-8 check
1720
+ // (requireUtf8Body) refuses a non-UTF-8 body as a JSON-RPC parse error before anything is run.
1721
+ const mcpBearer = async (req, res, next) => {
1722
+ if (await requireBearer(req, res))
1723
+ next();
1724
+ };
1725
+ app.post(MCP_PATH, mcpBearer, express_1.default.json({ limit: '1mb', verify: requireUtf8Body }), async (req, res) => {
1726
+ const authInfo = req.auth;
1727
+ // A tools/call that omits its id is a JSON-RPC notification. The SDK answers 202 and runs the tool when
1728
+ // that notification shares a batch with a real request. Refuse the whole body before any handler, so the
1729
+ // tool does not run. An explicit id of null is invalid JSON-RPC and is left to the SDK (-32700).
1730
+ if (bodyHasToolsCallNotification(req.body)) {
1731
+ res.status(200).json({
1732
+ jsonrpc: '2.0',
1733
+ id: null,
1734
+ error: { code: -32601, message: 'tools/call requires a request id' },
1735
+ });
1736
+ return;
1737
+ }
1738
+ // A directory client is not a known agent: nothing can ask it, so its name is not recorded.
1739
+ if (!isDirectoryCall(req.headers, config))
1740
+ noteConnectionName(agentOf({ authInfo, requestInfo: { headers: req.headers } }, config));
1741
+ try {
1742
+ if (config.mcpJsonResponse) {
1743
+ // Stateless streamable HTTP: one transport and one McpServer per request. The SDK binds a server to one
1744
+ // transport at a time, so a shared server answered an overlapping request with "Already connected" (500).
1745
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
1746
+ sessionIdGenerator: undefined, // stateless: one transport per request
1747
+ enableJsonResponse: true,
1748
+ });
1749
+ const requestServer = buildMcpServer(db, config, engine, activity, digests, usefulness, connection, false, null, isDashboardCall(req.headers, config), isDirectoryCall(req.headers, config));
1750
+ res.on('close', () => { void transport.close(); void requestServer.close(); });
1751
+ await requestServer.connect(transport);
1752
+ await transport.handleRequest(req, res, req.body);
1753
+ return;
1754
+ }
1755
+ // SSE session mode (bounded, in-process)
1756
+ const now = Date.now();
1757
+ evictExpiredSseSessions(now);
1758
+ const sid = req.headers['mcp-session-id'];
1759
+ const known = typeof sid === 'string' ? sseSessions.get(sid) : undefined;
1760
+ // A session opened under the other surface (see SseSession.directory) is unknown to this request: a 404 makes the
1761
+ // client start a new session, whose tools match the token it now holds.
1762
+ if (known && known.directory !== isDirectoryCall(req.headers, config)) {
1763
+ res.status(404).json({
1764
+ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null,
1765
+ });
1766
+ return;
1767
+ }
1768
+ if (known) {
1769
+ known.lastActivity = now;
1770
+ holdOpen(known, res);
1771
+ await known.transport.handleRequest(req, res, req.body);
1772
+ return;
1773
+ }
1774
+ // Only an initialize request may mint a session, whatever id it still carries (an id this process lost).
1775
+ if (!(0, types_js_1.isInitializeRequest)(req.body)) {
1776
+ if (typeof sid === 'string' && endedSessions.has(sid)) {
1777
+ res.status(404).json({
1778
+ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null,
1779
+ });
1780
+ return;
1781
+ }
1782
+ if (typeof sid === 'string' && sid.length > 0) {
1783
+ await serveForgottenSession(req, res);
1784
+ return;
1785
+ }
1786
+ res.status(400).json({
1787
+ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' }, id: null,
1788
+ });
1789
+ return;
1790
+ }
1791
+ makeRoomForSseSession();
1792
+ const sessionId = crypto_1.default.randomBytes(32).toString('base64url');
1793
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
1794
+ sessionIdGenerator: () => sessionId, // fixed per-transport id (minted above)
1795
+ enableJsonResponse: false, // SSE responses on POST
1796
+ });
1797
+ // one McpServer per session (the SDK binds one transport per Protocol). Its telemetry id is random and unrelated to the
1798
+ // session's bearer and transport id, so no log line can be matched to either. A platform session (the dashboard's own
1799
+ // calls, isPlatformInitialize) gets no id: it still counts as a connection, but it writes no first call and its mcp_init
1800
+ // carries no id for the recall share to join, so the share counts agents only.
1801
+ const telemetrySession = (0, telemetry_1.isPlatformInitialize)(req.body) ? null : crypto_1.default.randomBytes(8).toString('hex');
1802
+ const sessionServer = buildMcpServer(db, config, engine, activity, digests, usefulness, connection, true, telemetrySession, isDashboardCall(req.headers, config), isDirectoryCall(req.headers, config));
1803
+ transport.onclose = () => {
1804
+ sseSessions.delete(sessionId);
1805
+ (0, telemetry_1.emitTelemetry)({ event: 'session', kind: 'mcp_close' });
1806
+ void sessionServer.close();
1807
+ };
1808
+ const minted = { transport, lastActivity: now, open: 0, directory: isDirectoryCall(req.headers, config) };
1809
+ sseSessions.set(sessionId, minted);
1810
+ holdOpen(minted, res);
1811
+ (0, telemetry_1.emitTelemetry)({ event: 'session', kind: 'mcp_init', ...(telemetrySession ? { session: telemetrySession } : {}) });
1812
+ await sessionServer.connect(transport);
1813
+ await transport.handleRequest(req, res, req.body);
1814
+ }
1815
+ catch (err) {
1816
+ res.status(500).json({
1817
+ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null,
1818
+ });
1819
+ }
1820
+ });
1821
+ if (!config.mcpJsonResponse) {
1822
+ // GET /mcp: standalone SSE stream on an existing session (the SDK keeps
1823
+ // the stream open on the session's persistent transport; the 409 single-
1824
+ // stream rule and Accept validation are enforced by the SDK itself).
1825
+ app.get(MCP_PATH, async (req, res) => {
1826
+ if (!(await requireBearer(req, res)))
1827
+ return;
1828
+ evictExpiredSseSessions(Date.now());
1829
+ const entry = mcpSessionEntry(req, res, Date.now());
1830
+ if (!entry)
1831
+ return;
1832
+ holdOpen(entry, res);
1833
+ try {
1834
+ await entry.transport.handleRequest(req, res);
1835
+ }
1836
+ catch {
1837
+ if (!res.headersSent)
1838
+ res.status(500).end();
1839
+ }
1840
+ });
1841
+ // DELETE /mcp: explicit session termination (SDK closes the transport;
1842
+ // onclose removes it from the bounded registry).
1843
+ app.delete(MCP_PATH, async (req, res) => {
1844
+ if (!(await requireBearer(req, res)))
1845
+ return;
1846
+ evictExpiredSseSessions(Date.now());
1847
+ const entry = mcpSessionEntry(req, res, Date.now());
1848
+ if (!entry)
1849
+ return;
1850
+ const sid = req.headers['mcp-session-id'];
1851
+ try {
1852
+ await entry.transport.handleRequest(req, res);
1853
+ if (res.statusCode === 200)
1854
+ markSessionEnded(sid);
1855
+ }
1856
+ catch {
1857
+ if (!res.headersSent)
1858
+ res.status(500).end();
1859
+ }
1860
+ });
1861
+ }
1862
+ // The last handler: a body the parser refuses, one over its limit, or a bug. It never renders a stack trace or a file
1863
+ // path (self-hosters do not set NODE_ENV=production, and an unauthenticated caller must not see either). The MCP route
1864
+ // gets a JSON-RPC body; the stack goes to the server's own log.
1865
+ app.use((err, req, res, next) => {
1866
+ if (res.headersSent)
1867
+ return next(err);
1868
+ const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 600 ? err.status : 500;
1869
+ const message = status === 400 ? 'Parse error'
1870
+ : status === 413 ? 'Request body too large'
1871
+ : status === 415 ? 'Unsupported media type'
1872
+ : status >= 500 ? 'Internal error' : 'Invalid Request';
1873
+ if (status >= 500)
1874
+ console.error('[http] unhandled error:', err);
1875
+ if (req.path === MCP_PATH) {
1876
+ const code = status === 400 ? -32700 : status >= 500 ? -32603 : -32600;
1877
+ res.status(status).json({ jsonrpc: '2.0', error: { code, message }, id: null });
1878
+ return;
1879
+ }
1880
+ res.status(status).json({ error: message });
1881
+ });
1882
+ // Idempotent: an idle exit and a SIGTERM can both ask for it.
1883
+ let closing = null;
1884
+ const mcpApp = {
1885
+ app,
1886
+ server,
1887
+ provider,
1888
+ db,
1889
+ engine,
1890
+ embeddingRebuild: rebuild?.done ?? Promise.resolve(),
1891
+ idle,
1892
+ digests,
1893
+ reindexPending: pendingPass,
1894
+ close: () => {
1895
+ closing ??= (async () => {
1896
+ // First: it is background work, and what it has started it finishes before the database closes.
1897
+ (0, engine_1.runningEmbeddingRebuild)(engine)?.abort();
1898
+ (0, engine_1.runningBackgroundIndex)(db)?.abort();
1899
+ // The digest worker uses the database too: its sweep in flight ends before the database closes.
1900
+ await digests?.worker.close();
1901
+ for (const [, entry] of sseSessions)
1902
+ void entry.transport.close();
1903
+ sseSessions.clear();
1904
+ await server.close();
1905
+ // A rebuild uses the database, so each one still running ends first, including one an import started meanwhile.
1906
+ for (let running = (0, engine_1.runningEmbeddingRebuild)(engine); running; running = (0, engine_1.runningEmbeddingRebuild)(engine)) {
1907
+ running.abort();
1908
+ await running.done;
1909
+ }
1910
+ // The background index uses the database too; a save that was still running may start another, so look again.
1911
+ for (let running = (0, engine_1.runningBackgroundIndex)(db); running; running = (0, engine_1.runningBackgroundIndex)(db)) {
1912
+ running.abort();
1913
+ await running.done;
1914
+ }
1915
+ db.close();
1916
+ // The query cache this process opened (an injected provider is its owner's to close).
1917
+ if (engineOverride === undefined)
1918
+ engine.provider?.close?.();
1919
+ })();
1920
+ return closing;
1921
+ },
1922
+ };
1923
+ return mcpApp;
1924
+ }
1925
+ function editDistance(a, b) {
1926
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
1927
+ for (let i = 1; i <= a.length; i++) {
1928
+ const cur = [i];
1929
+ for (let j = 1; j <= b.length; j++) {
1930
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1931
+ }
1932
+ prev = cur;
1933
+ }
1934
+ return prev[b.length];
1935
+ }
1936
+ // The longest subject lifecycle ever stores (FIELD_LIMITS.subject). A longer query is cut to this: the edit
1937
+ // distance below is quadratic, so one oversized argument would otherwise stall the whole single-threaded service.
1938
+ const MAX_SUBJECT_CHARS = 512;
1939
+ const SUBJECT_WORD_BREAK = /[^\p{L}\p{N}]+/u;
1940
+ /**
1941
+ * Up to 5 known subjects the caller plausibly meant, closest first. A subject that the query names, that
1942
+ * contains the query, or that shares a word with it always qualifies; anything else must be a typo (a
1943
+ * normalized edit distance of at most 0.4). Names shorter than 3 characters are never matched by containment.
1944
+ */
1945
+ function closestSubjects(query, subjects) {
1946
+ const q = (0, queryHelpers_1.identityKey)(query.slice(0, MAX_SUBJECT_CHARS));
1947
+ const queryWords = new Set(q.split(SUBJECT_WORD_BREAK).filter(w => w.length >= 3));
1948
+ const close = [];
1949
+ for (const name of subjects) {
1950
+ const n = (0, queryHelpers_1.identityKey)(name);
1951
+ const longest = Math.max(q.length, n.length);
1952
+ const related = (n.length >= 3 && q.includes(n)) || (q.length >= 3 && n.includes(q))
1953
+ || n.split(SUBJECT_WORD_BREAK).some(w => queryWords.has(w));
1954
+ // The distance is at least the length difference, so an unrelated name that far away cannot qualify.
1955
+ if (!related && Math.abs(q.length - n.length) / longest > 0.4)
1956
+ continue;
1957
+ const dist = editDistance(q, n) / longest;
1958
+ const score = related ? dist * 0.4 : dist;
1959
+ if (score <= 0.4)
1960
+ close.push({ name, score });
1961
+ }
1962
+ return close.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name)).slice(0, 5).map(c => c.name);
1963
+ }
1964
+ /**
1965
+ * What a save's receipt says when the index pass after it left a file unfinished (its embedding failed): the pass's
1966
+ * counts, a warning, and why. The memory is saved, and keyword search finds it; search by meaning does once the next
1967
+ * save, or a search, finishes the file.
1968
+ */
1969
+ function unfinishedPass(pass) {
1970
+ const { failure, ...index } = pass;
1971
+ return { index, warning: 'memory-saved-but-index-refresh-failed', ...(failure === undefined ? {} : { detail: failure }) };
1972
+ }
1973
+ /**
1974
+ * What a note save's receipt says of the index pass run right after it (remember in note mode, remember_note): the
1975
+ * day's note the save wrote and the ledger are checked, so search finds the line at once, and a pass that failed is
1976
+ * said, and why. The note is saved either way.
1977
+ */
1978
+ async function indexSavedNote(db, config, engine, digests = null, noteFile) {
1979
+ try {
1980
+ const touched = [path_1.default.join(config.memoryDir, noteFile), (0, lifecycle_1.resolveLedgerPath)(config.memoryDir, config.ledgerRelPath)];
1981
+ const pass = await (0, engine_1.engineIndexChanged)(db, config, engine, undefined, { interactive: true, touched });
1982
+ // The digest of what was just saved is written in the background (a timer; the save does not wait for it).
1983
+ digests?.worker.notify();
1984
+ return { indexed: pass.errors === 0, ...(pass.errors === 0 ? { index: pass } : unfinishedPass(pass)) };
1985
+ }
1986
+ catch (indexErr) {
1987
+ const detail = indexErr instanceof Error ? indexErr.message : String(indexErr);
1988
+ return { indexed: false, warning: 'memory-saved-but-index-refresh-failed', detail: detail.slice(0, 300) };
1989
+ }
1990
+ }
1991
+ /**
1992
+ * The ledger keeps each structured field on one line and refuses a line break in it, which an agent that put a longer
1993
+ * value there cannot act on. The rule and its message are the lifecycle's; this says where the longer text goes.
1994
+ */
1995
+ function oneLineHint(err) {
1996
+ if (err instanceof lifecycle_1.LifecycleInputError && ['subject', 'predicate', 'object'].includes(err.field) && /control characters/.test(err.message)) {
1997
+ // The re-labelled message is the lifecycle's plus where the longer text goes; the refusal code the
1998
+ // lifecycle named (invalid_text) is carried out of band, so the re-label does not lose it and the error
1999
+ // stays a plain Error (same class, name and keys as before this vocabulary existed).
2000
+ const hinted = new Error(`${err.message}. Keep ${err.field} to one line: longer text belongs in the statement of a remember call.`);
2001
+ const reason = (0, toolRefusal_1.refusalReason)(err);
2002
+ return reason === undefined ? hinted : (0, toolRefusal_1.markErrorReason)(hinted, reason);
2003
+ }
2004
+ return err;
2005
+ }
2006
+ function toolError(err) {
2007
+ const message = (0, sanitize_1.sanitizeErrorMessage)(err);
2008
+ // Privacy-safe: bounded message, no stack, no host paths. A message can echo a stored value back
2009
+ // (e.g. the field a save refused), so invisible controls are escaped here too.
2010
+ return (0, toolRefusal_1.markToolRefusal)({
2011
+ content: [{ type: 'text', text: (0, sanitize_1.escapeInvisibleControls)(`Error: ${message.slice(0, 400)}`) }],
2012
+ isError: true,
2013
+ }, (0, toolRefusal_1.refusalReason)(err) ?? 'refused');
2014
+ }
2015
+ /**
2016
+ * Why a search answered without searching: its own `error` field, named once, so the code telemetry carries
2017
+ * says which of the four it was (a workspace with no embeddings, one mid-rebuild, one with no keyword index,
2018
+ * and a query the index could not read) instead of one `other`.
2019
+ */
2020
+ exports.SEARCH_REFUSAL_REASONS = {
2021
+ 'semantic-unavailable': 'capability_unavailable',
2022
+ 'semantic-rebuilding': 'capability_rebuilding',
2023
+ 'fts-unavailable': 'capability_unavailable',
2024
+ 'fts-query': 'invalid_query',
2025
+ };
2026
+ /** A search reply: ok as the search answered it, and its refusal code beside it when it did not search. */
2027
+ function searchResult(body, result) {
2028
+ const response = { ...jsonResult(result), isError: !body.ok };
2029
+ return body.ok ? response : (0, toolRefusal_1.markToolRefusal)(response, exports.SEARCH_REFUSAL_REASONS[String(body.error)] ?? 'refused');
2030
+ }
2031
+ /**
2032
+ * Startup for the MVP service: open the derived index, AWAIT the ledger replay
2033
+ * (fail closed on corruption — never serve stale/absent memories), then listen.
2034
+ *
2035
+ * With on-demand on, `idleExit` resolves once the engine has been idle for the
2036
+ * configured period and has stopped listening; the caller then closes and exits.
2037
+ */
2038
+ async function startMvpService(config, engineOverride) {
2039
+ const mvp = createMvpApp(config, engineOverride);
2040
+ let httpServer;
2041
+ try {
2042
+ // Restart recovery: replay the authoritative ledger before readiness.
2043
+ // Start-up is the one place a torn final event is set aside: nothing else is appending to this ledger yet.
2044
+ await (0, lifecycle_1.replayLedger)(mvp.db, config.memoryDir, config.ledgerRelPath, { setTornTailAside: true });
2045
+ // The notes of a folder that moved were removed from the index with their old paths: they are indexed at their
2046
+ // current paths before the service listens, so the first answer has them. The mark is cleared only by a pass with no
2047
+ // failed file. A file whose embedding the provider still refuses after its retries counts as failed: it keeps its
2048
+ // chunks (keyword search finds them), stays unfinished, and the next start runs the pass again.
2049
+ if (mvp.reindexPending) {
2050
+ const pass = await (0, engine_1.engineIndex)(mvp.db, config, mvp.engine);
2051
+ if (pass.errors === 0)
2052
+ (0, outsideRoot_1.clearReindexPending)(mvp.db);
2053
+ else
2054
+ console.error(`[startup] ${pass.errors} file(s) did not index at start; the folder stays owed a pass and the next start runs it again`);
2055
+ }
2056
+ // Bind by listening, not by the callback Express gives: `app.listen` routes a bind failure to
2057
+ // that same callback as a success, so a promise waiting on it resolves with a server that never
2058
+ // listened — the caller is told it started while another process answers on the port. Owning
2059
+ // both events here makes a port that is taken a start failure, with the listen error intact.
2060
+ const srv = mvp.app.listen(config.port, '127.0.0.1');
2061
+ httpServer = await new Promise((resolve, reject) => {
2062
+ srv.once('listening', () => resolve(srv));
2063
+ srv.once('error', reject);
2064
+ });
2065
+ }
2066
+ catch (err) {
2067
+ // A start that failed owns nothing. `createMvpApp` already opened the database and may have started the
2068
+ // boot rebuild, and the caller never receives `mvp` — a rejection cannot be caught and retried on another
2069
+ // port without leaking them. Close them here, which also waits for the rebuild the way every close does,
2070
+ // then report why the start failed rather than a cleanup failure.
2071
+ await mvp.close().catch(() => { });
2072
+ throw err;
2073
+ }
2074
+ const idleExit = mvp.idle ? mvp.idle.start().then(() => stopListening(httpServer)) : null;
2075
+ return { ...mvp, httpServer, idleExit };
2076
+ }
2077
+ /**
2078
+ * Stops accepting connections and closes the open ones. Draining only begins with no
2079
+ * request in flight (every request is counted before any handler runs), so what is left
2080
+ * is idle keep-alive sockets or ones still sending headers: closing them cuts no work.
2081
+ */
2082
+ function stopListening(httpServer) {
2083
+ return new Promise((resolve) => {
2084
+ httpServer.close(() => resolve());
2085
+ httpServer.closeAllConnections();
2086
+ });
2087
+ }
2088
+ exports.MVP_MCP_PATH = MCP_PATH;
2089
+ exports.MVP_VERSION_PATH = VERSION_PATH;