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,142 @@
1
+ "use strict";
2
+ /**
3
+ * Agent-facing query helpers: FTS5 quoting and identity aliases.
4
+ * Keep this free of I/O so tests stay in-process.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.ownerNames = ownerNames;
8
+ exports.identityKey = identityKey;
9
+ exports.identityNames = identityNames;
10
+ exports.quoteFtsQuery = quoteFtsQuery;
11
+ exports.shownPage = shownPage;
12
+ exports.pickTopK = pickTopK;
13
+ exports.pickEntityName = pickEntityName;
14
+ /**
15
+ * The workspace owner's names, from RENOR_OWNER_NAMES: a comma-separated list, the first name canonical, every
16
+ * other name an alias of it. Unset means no alias group: no name is built in, so the same name is one identity
17
+ * only by case and Unicode form. Read per call (cheap) so tests and hosted config can change it.
18
+ */
19
+ function ownerNames() {
20
+ return parsedOwnerNames().slice();
21
+ }
22
+ // Identity is on the replay hot path (one call per fact), so parse the variable once per value, not per call.
23
+ let ownerSource;
24
+ let ownerParsed = [];
25
+ function parsedOwnerNames() {
26
+ const raw = process.env.RENOR_OWNER_NAMES ?? '';
27
+ if (raw !== ownerSource) {
28
+ ownerSource = raw;
29
+ ownerParsed = raw.split(',').map(n => n.trim()).filter(Boolean);
30
+ }
31
+ return ownerParsed;
32
+ }
33
+ /** The alias groups the identity primitive folds together: the owner's names, when configured. */
34
+ function aliasGroups() {
35
+ const owner = parsedOwnerNames();
36
+ return owner.length ? [owner] : [];
37
+ }
38
+ /**
39
+ * Conservative Unicode fold: NFC + plain `toLowerCase()`, marks preserved.
40
+ *
41
+ * Deliberately NOT `normDedupeValue` (context/rowStore.ts), which is NFKD and
42
+ * strips Latin combining marks: that fold merges `José`/`Jose`, `İstanbul`/`Istanbul`
43
+ * and `file`/`file`, which conflates distinct names. This fold closes only the
44
+ * case axis and the Unicode normalization-form axis (NFC vs NFD), so
45
+ * `José`/`JOSÉ`/NFD-`José` share one identity while `José`/`Jose`,
46
+ * `Αθήνα`/`Αθηνα` (Greek tonos), `Пётр`/`Петр` (ё/е) and `Straße`/`STRASSE` stay
47
+ * distinct. `toLocaleLowerCase('tr')` is never used: it would map `İ`→`i` and
48
+ * collapse a real distinction. Re-normalizing after `toLowerCase()` matters,
49
+ * because lowercasing can decompose (`İ` → `i` + U+0307).
50
+ */
51
+ function fold(s) {
52
+ return s.normalize('NFC').trim().toLowerCase().normalize('NFC');
53
+ }
54
+ /**
55
+ * One identity key for a subject name: the repo's single subject-identity primitive.
56
+ *
57
+ * The import planner and the lifecycle layer must never answer "is this the same
58
+ * subject?" differently, so both call this. It is I/O-free and DB-free, so the
59
+ * planner path stays pure. Explicit aliases collapse to one key
60
+ * (the owner's names from RENOR_OWNER_NAMES); nothing else merges
61
+ * except case and Unicode normalization form.
62
+ *
63
+ * Empty-fold guard: a key that normalizes to nothing (only reachable for an empty /
64
+ * whitespace-only name, since no character is deleted) falls back to the raw name so
65
+ * distinct inputs can never collide on `''`.
66
+ */
67
+ function identityKey(name) {
68
+ const folded = fold(name);
69
+ if (folded === '')
70
+ return `\u0000raw:${name}`;
71
+ for (const names of aliasGroups()) {
72
+ if (names.some(n => fold(n) === folded))
73
+ return fold(names[0]);
74
+ }
75
+ return folded;
76
+ }
77
+ /** All names that should share one lifecycle identity with `name`. */
78
+ function identityNames(name) {
79
+ const key = fold(name);
80
+ for (const names of aliasGroups()) {
81
+ if (names.some(n => fold(n) === key)) {
82
+ const extra = names.includes(name) ? names : [name, ...names];
83
+ return [...new Set(extra)];
84
+ }
85
+ }
86
+ return [name];
87
+ }
88
+ /**
89
+ * Quote a user string for FTS5 MATCH so hyphens/colons cannot be parsed as
90
+ * operators (`tensaku-capture` must not become `capture` as a column).
91
+ * Empty after trim → empty string (caller should not MATCH).
92
+ */
93
+ function quoteFtsQuery(raw) {
94
+ const q = (raw ?? '').trim();
95
+ if (!q)
96
+ return '';
97
+ if (q.length > 200)
98
+ return '';
99
+ const escaped = q.replace(/"/g, '""');
100
+ return `"${escaped}"`;
101
+ }
102
+ /**
103
+ * A page of a ranked read, cut after its rows have been judged and never before.
104
+ *
105
+ * `read(n)` is the best `n` rows (fewer only when there are no more). `show` judges the rows it is given, each on its
106
+ * own, and returns the ones that are shown, in the order given. Cutting the read at `limit` rows and judging them
107
+ * afterwards lets the rows that are turned away take the places of the ones that are kept: when enough of the best
108
+ * rows are turned away, the page comes up short, or empty, with what belonged on it a little further down. So when the
109
+ * rows read do not fill the page the read is made longer and judged again, until the page is full or every row has been
110
+ * read. The first read is of `first` rows (never fewer than `limit`): a reader that has always read a wider window
111
+ * than its page keeps reading it, so a search whose window holds a full page of rows that are shown, or every row there
112
+ * is, makes that one read of the same rows, as it always did.
113
+ *
114
+ * The rows are judged from the best, a few at a time, and no row below the one that fills the page is judged: the page
115
+ * is the first `limit` rows that are shown, and `show` judges each row on its own, so judging a prefix is judging the
116
+ * whole read, as far as the page goes. A keyword search reads a window of 60 rows to show 6: the other 54 were judged
117
+ * for nothing.
118
+ */
119
+ function shownPage(read, show, limit, first = limit) {
120
+ if (!(limit > 0))
121
+ return [];
122
+ const batch = Math.max(limit, 8);
123
+ for (let rows = first > limit ? first : limit;; rows *= 4) {
124
+ const ranked = read(rows);
125
+ const shown = [];
126
+ for (let at = 0; at < ranked.length && shown.length < limit; at += batch)
127
+ shown.push(...show(ranked.slice(at, at + batch)));
128
+ if (shown.length >= limit || ranked.length < rows)
129
+ return shown.slice(0, limit);
130
+ }
131
+ }
132
+ function pickTopK(args, fallback = 20, max = 100) {
133
+ const raw = args.topK ?? args.top_k ?? fallback;
134
+ const n = Number(raw);
135
+ if (!Number.isFinite(n))
136
+ return fallback;
137
+ return Math.min(max, Math.max(1, Math.floor(n)));
138
+ }
139
+ function pickEntityName(args) {
140
+ const v = args.entityName ?? args.name ?? args.subject;
141
+ return typeof v === 'string' ? v.trim() : '';
142
+ }
@@ -0,0 +1,475 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readerDurationEnabled = readerDurationEnabled;
4
+ exports.readerDurationNamed = readerDurationNamed;
5
+ exports.durationQuestion = durationQuestion;
6
+ exports.questionEvents = questionEvents;
7
+ exports.questionSpan = questionSpan;
8
+ exports.calendarMonths = calendarMonths;
9
+ exports.questionSpanLine = questionSpanLine;
10
+ exports.withQuestionSpan = withQuestionSpan;
11
+ const conversationShape_1 = require("./conversationShape");
12
+ const orchestrator_1 = require("./orchestrator");
13
+ /**
14
+ * The duration line: for a question that asks how many days, weeks or months lie between two events, one line above the
15
+ * excerpts that quotes the date the user wrote for each event and counts the days between them. A reader that has both
16
+ * dates in front of it still often answers "I don't know" or miscounts; this does that one step and leaves the excerpts,
17
+ * and every other kind of question, alone.
18
+ *
19
+ * What it will not do, on purpose:
20
+ * - It never subtracts session headers. A header is when a conversation took place, not when its events did, and a
21
+ * benchmark may date every session on the question's own day. Only a date the user wrote in a turn counts: "on
22
+ * January 10th", "on 2/15", "on the 22nd of April", "since 3/27", behind "on" or "since", the two words that date the
23
+ * event itself ("before", "by", "until" give a bound, "from" opens a range). "The March 15th issue" is a title, not a
24
+ * date, and gets no line. A relative phrase ("yesterday", "three weeks ago") is not a written date either;
25
+ * relativeTimeHints.ts resolves those below their excerpt and nothing here counts them.
26
+ * - It guesses nothing. Each of the two events in the question must find one dated statement of its own among the user
27
+ * turns of the page, clearly better than any other dated statement. A statement is a sentence with exactly one written
28
+ * date of any kind, and only the clause that holds the date speaks for it ("I went to the festival, and I joined the
29
+ * workshop on February 25th" dates the workshop). A sentence with a second date or a range ("from May 2nd to May 9th")
30
+ * is not used. An event named by one short word, two events that find the same statement, and a tie between two
31
+ * different dates give no line, and then the page is exactly what it was. The session date completes a date written
32
+ * without its year (the year nearest to the session) and is never subtracted from anything.
33
+ * - It reads the user, not the assistant. A chunk's speaker is known only from a turn heading in that same chunk: the
34
+ * continuation of a long turn, which has no heading, is not read.
35
+ * - It touches no other question. "Which came first", "what is the order of", a count of things, a frequency, a single
36
+ * "how many days ago" and a question whose two events it cannot name stay byte-identical.
37
+ *
38
+ * Default on (`RENOR_READER_DURATION=0` switches it off), and only on top of the reader layout.
39
+ */
40
+ /** Default on. `0` / `false` / `no` / `off` removes the duration line from a layout-on page. */
41
+ function readerDurationEnabled(env = process.env) {
42
+ const v = (env.RENOR_READER_DURATION ?? '').trim().toLowerCase();
43
+ return !(v === '0' || v === 'false' || v === 'no' || v === 'off');
44
+ }
45
+ /**
46
+ * Is the switch set to an on value, written out (`1` / `true` / `yes` / `on`)? Only then does the search_memory tool description
47
+ * name the line: with the switch unset the line still leads the page and says what it is in its own first words, but the
48
+ * published description (kept under 1,200 characters, and equal to the gateway's server card) stays as it was.
49
+ */
50
+ function readerDurationNamed(env = process.env) {
51
+ const v = (env.RENOR_READER_DURATION ?? '').trim().toLowerCase();
52
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
53
+ }
54
+ /** A question is one line of a few hundred characters; a longer one is a pasted text and is left alone. */
55
+ const MAX_QUESTION_CHARS = 1_000;
56
+ /** The question itself: the last line of the query, since a caller may put "Question date: ..." above it. */
57
+ function questionLine(query) {
58
+ const lines = query.split('\n').map(line => line.trim()).filter(line => line !== '');
59
+ const last = lines[lines.length - 1] ?? '';
60
+ return last.length > MAX_QUESTION_CHARS ? '' : last;
61
+ }
62
+ // "How many days a week" and "how many days per month" ask for a rate, not for the time between two events.
63
+ const UNIT_COUNT = /\bhow\s+(?:many|much)\s+(day|week|month|year)s?\b(?!\s+(?:a|an|per|each|every)\s+(?:day|week|month|year)\b)/i;
64
+ // "How long is my commute" asks for a length, not for the time between two events.
65
+ const HOW_LONG = /\bhow\s+long\s+(?:ago|have|has|had|did|does|do|since|until|passed|before|after)\b|\bhow\s+much\s+time\s+(?:passed|had|have|has|elapsed|between)\b/i;
66
+ /**
67
+ * Is this a duration question, and in which unit? `{ unit: null }` is a "how long" or "how much time" question that names
68
+ * no unit. Null for anything else: an order, a count of things, a frequency, a clock time, a length.
69
+ */
70
+ function durationQuestion(query) {
71
+ const question = questionLine(query);
72
+ const counted = UNIT_COUNT.exec(question);
73
+ if (counted)
74
+ return { unit: counted[1].toLowerCase() };
75
+ return HOW_LONG.test(question) ? { unit: null } : null;
76
+ }
77
+ const AND_THE_DAY = /\s+and\s+(?:the\s+(?:day|time|moment|date)\b|when\b)/i;
78
+ const AND_NEXT_EVENT = /\s+and\s+(?=(?:the|my|our|i|we|a|an|when)\b|['"‘“])/i;
79
+ /** The two events of a question that names two, in the order the question names them; null when it names fewer. */
80
+ function questionEvents(query) {
81
+ const question = questionLine(query).replace(/\s*\?+\s*$/, '');
82
+ const pair = (a, b) => {
83
+ const first = a?.trim() ?? '';
84
+ const second = b?.trim() ?? '';
85
+ return first !== '' && second !== '' ? [first, second] : null;
86
+ };
87
+ const between = /\bbetween\s+(.+)$/i.exec(question);
88
+ if (between) {
89
+ const body = between[1];
90
+ const split = AND_THE_DAY.exec(body) ?? AND_NEXT_EVENT.exec(body);
91
+ return split ? pair(body.slice(0, split.index), body.slice(split.index + split[0].length)) : null;
92
+ }
93
+ const sinceWhen = /\bsince\s+(.+?)\s+when\s+(.+)$/i.exec(question);
94
+ if (sinceWhen)
95
+ return pair(sinceWhen[1], sinceWhen[2]);
96
+ const agoWhen = /\bago\s+(?:did|do)\s+(.+?)\s+when\s+(.+)$/i.exec(question);
97
+ if (agoWhen)
98
+ return pair(agoWhen[1], agoWhen[2]);
99
+ const beforeAfter = /\b(?:before|after)\s+(.+?)\s+(?:did|do|does)\s+(.+)$/i.exec(question);
100
+ if (beforeAfter)
101
+ return pair(beforeAfter[1], beforeAfter[2]);
102
+ const beenWhen = /\b(?:been|being)\s+(.+?)\s+(?:when|before|until)\s+(.+)$/i.exec(question);
103
+ if (beenWhen)
104
+ return pair(beenWhen[1], beenWhen[2]);
105
+ const takeAfter = /\btake\s+(?:me\s+|for\s+me\s+)?to\s+(.+?)\s+after\s+(.+)$/i.exec(question);
106
+ if (takeAfter)
107
+ return pair(takeAfter[1], takeAfter[2]);
108
+ return null;
109
+ }
110
+ const STOP = new Set([
111
+ 'the', 'and', 'for', 'with', 'from', 'into', 'onto', 'over', 'that', 'this', 'these', 'those', 'than', 'then', 'them',
112
+ 'they', 'their', 'there', 'here', 'what', 'when', 'where', 'which', 'who', 'whom', 'how', 'did', 'does', 'was', 'were',
113
+ 'been', 'being', 'have', 'has', 'had', 'will', 'would', 'could', 'should', 'can', 'may', 'might', 'must', 'not', 'but',
114
+ 'you', 'your', 'our', 'his', 'her', 'its', 'she', 'him', 'also', 'just', 'very', 'really', 'about', 'after', 'before',
115
+ 'again', 'some', 'any', 'one', 'out', 'got', 'get', 'day', 'days', 'time', 'week', 'weeks', 'month', 'months',
116
+ 'year', 'years', 'event', 'events', 'ago', 'passed', 'between', 'since', 'until', 'take', 'took', 'decide', 'decided',
117
+ ]);
118
+ /** A crude stem, enough to make "started" and "starting", "loved" and "love", "planted" and "plant" the same word. */
119
+ function stem(word) {
120
+ let w = word.toLowerCase().replace(/['’]s$/, '');
121
+ if (w.length > 4 && w.endsWith('ies'))
122
+ w = `${w.slice(0, -3)}y`;
123
+ else if (w.length > 5 && w.endsWith('ing'))
124
+ w = w.slice(0, -3);
125
+ else if (w.length > 4 && w.endsWith('ed'))
126
+ w = w.slice(0, -2);
127
+ else if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss'))
128
+ w = w.slice(0, -1);
129
+ if (w.length > 3 && w.endsWith('e'))
130
+ w = w.slice(0, -1);
131
+ return w;
132
+ }
133
+ /** The words of a text that tell one thing from another. A number is one whatever its length: the iPhone 13 is not the iPhone 12. */
134
+ function contentStems(text) {
135
+ const out = new Set();
136
+ for (const word of text.toLowerCase().match(/[a-z0-9][a-z0-9'’-]*/g) ?? []) {
137
+ const bare = word.replace(/^['’-]+|['’-]+$/g, '');
138
+ if (STOP.has(bare) || (bare.length < 3 && !/^\d+$/.test(bare)))
139
+ continue;
140
+ out.add(stem(bare));
141
+ }
142
+ return out;
143
+ }
144
+ /** How many of an event's words a statement must hold: all of a short event, about 60% of a long one, never fewer than two when it has two. */
145
+ function neededOverlap(words) {
146
+ return Math.min(words, Math.max(2, Math.ceil(words * 0.6)));
147
+ }
148
+ /** An event named by one short word ("the move", "my order") matches any sentence that says the word: it needs a longer one ("my anniversary"). */
149
+ const MIN_LONE_WORD = 6;
150
+ const MONTHS = {
151
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4, may: 5, jun: 6, june: 6, jul: 7, july: 7,
152
+ aug: 8, august: 8, sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12,
153
+ };
154
+ const MONTH_NAMES = 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?';
155
+ const MONTH = `(${MONTH_NAMES})\\.?`;
156
+ const DAY = '(\\d{1,2})(?:st|nd|rd|th)?';
157
+ // "January 10th", "Jan. 5, 2023" | "the 22nd of April", "22 April 2023" | "3/27", "2/15/23" | "2023-03-05", "2023/03/05"
158
+ const WRITTEN_DATE = new RegExp([
159
+ `\\b${MONTH}\\s+${DAY}\\b(?:,?\\s+(\\d{4})\\b)?`,
160
+ `\\b(?:the\\s+)?${DAY}\\s+(?:of\\s+)?${MONTH}(?![a-z])(?:,?\\s+(\\d{4})\\b)?`,
161
+ '\\b(\\d{1,2})/(\\d{1,2})(?:/(\\d{4}|\\d{2}))?(?![\\d/])',
162
+ '\\b(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})\\b',
163
+ ].join('|'), 'gi');
164
+ // The two words that date the event itself: "attended it on January 10th", "every Friday since 3/27".
165
+ const CUE = /\b(?:on|since)\s+(?:the\s+)?(?:(?:mon|tues?|wed(?:nes)?|thu(?:rs?)?|fri|sat(?:ur)?|sun)(?:day)?\.?,?\s+)?$/i;
166
+ // A date that opens a range ("May 2nd to May 9th", "May 2-9", "3/27 - 3/30") is not the day of one event.
167
+ const RANGE_AFTER = new RegExp(`^\\s*(?:[-–—]\\s*|(?:to|through|thru|until|till)\\s+)(?:the\\s+)?(?:\\d{1,2}(?:st|nd|rd|th)?\\b|(?:${MONTH_NAMES})\\.?\\s+\\d)`, 'i');
168
+ // Month and weekday abbreviations end in a full stop that ends no sentence; so do titles and initials ("I." is a word, not an initial).
169
+ const ABBREVIATION = /(?:\b(?:St|Mr|Mrs|Ms|Dr|Jr|Sr|vs|etc|Inc|Ltd|Co|No|Mt|Ft|approx|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec|Mon|Tue|Tues|Wed|Thu|Thur|Thurs|Fri|Sat|Sun)|\b[A-HJ-Z])\.$/;
170
+ // Where one clause of a sentence ends and the next begins: what comes before it is about another event.
171
+ const CLAUSE_BREAK = /[;—–]|,?\s+(?:and then|and|but|so|then|while|because|although|though|whereas|which)\s+/gi;
172
+ function validDay(year, month, day) {
173
+ if (month < 1 || month > 12 || day < 1 || day > 31)
174
+ return false;
175
+ const date = new Date(Date.UTC(year, month - 1, day));
176
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
177
+ }
178
+ /**
179
+ * A date further than this from the session it was written in has no year the session can tell: half a year away it is as
180
+ * near to one year as to the other, and 29 February may have one candidate only.
181
+ */
182
+ const YEAR_REACH_DAYS = 130;
183
+ /** The year of a date written without one: the year that puts it nearest to the session it was written in (the earlier on a tie); null when that is further than YEAR_REACH_DAYS. */
184
+ function nearestYear(month, day, sessionMs) {
185
+ if (!Number.isFinite(sessionMs))
186
+ return null;
187
+ const sessionYear = new Date(sessionMs).getUTCFullYear();
188
+ let best = null;
189
+ for (const year of [sessionYear - 1, sessionYear, sessionYear + 1]) {
190
+ if (!validDay(year, month, day))
191
+ continue;
192
+ const gap = Math.abs(Date.UTC(year, month - 1, day) - sessionMs);
193
+ if (best === null || gap < best.gap)
194
+ best = { year, gap };
195
+ }
196
+ return best !== null && best.gap <= YEAR_REACH_DAYS * 86_400_000 ? best.year : null;
197
+ }
198
+ /** A year written with two digits: the century that puts it nearest to the session ("99" in 2023 is 1999), 2000 + n without one. */
199
+ function twoDigitYear(raw, sessionMs) {
200
+ const n = Number(raw);
201
+ if (raw.length !== 2)
202
+ return n;
203
+ if (!Number.isFinite(sessionMs))
204
+ return 2000 + n;
205
+ const sessionYear = new Date(sessionMs).getUTCFullYear();
206
+ return Math.abs(1900 + n - sessionYear) < Math.abs(2000 + n - sessionYear) ? 1900 + n : 2000 + n;
207
+ }
208
+ /** Every written date of a sentence, of any kind; `usable` are those that date their event (a cue before, no range after). */
209
+ function writtenDates(sentence, sessionMs) {
210
+ const usable = [];
211
+ let count = 0;
212
+ for (const m of sentence.matchAll(WRITTEN_DATE)) {
213
+ count += 1;
214
+ let month;
215
+ let day;
216
+ let year;
217
+ if (m[1] !== undefined) {
218
+ month = MONTHS[m[1].toLowerCase()];
219
+ day = Number(m[2]);
220
+ year = m[3] === undefined ? null : Number(m[3]);
221
+ }
222
+ else if (m[5] !== undefined) {
223
+ day = Number(m[4]);
224
+ month = MONTHS[m[5].toLowerCase()];
225
+ year = m[6] === undefined ? null : Number(m[6]);
226
+ }
227
+ else if (m[7] !== undefined) {
228
+ month = Number(m[7]);
229
+ day = Number(m[8]);
230
+ year = m[9] === undefined ? null : twoDigitYear(m[9], sessionMs);
231
+ }
232
+ else {
233
+ year = Number(m[10]);
234
+ month = Number(m[11]);
235
+ day = Number(m[12]);
236
+ }
237
+ const at = m.index ?? 0;
238
+ const end = at + m[0].length;
239
+ if (!CUE.test(sentence.slice(Math.max(0, at - 30), at)))
240
+ continue;
241
+ if (RANGE_AFTER.test(sentence.slice(end, end + 24)))
242
+ continue;
243
+ const resolvedYear = year ?? nearestYear(month, day, sessionMs);
244
+ usable.push({ at, end, day: resolvedYear !== null && validDay(resolvedYear, month, day) ? { year: resolvedYear, month, day } : null });
245
+ }
246
+ return { count, usable };
247
+ }
248
+ function splitSentences(body) {
249
+ const pieces = body.split(/(?<=[.!?])\s+(?=[A-Z0-9"“'‘(\[])/);
250
+ const out = [];
251
+ for (const piece of pieces) {
252
+ if (out.length > 0 && ABBREVIATION.test(out[out.length - 1]))
253
+ out[out.length - 1] += ` ${piece}`;
254
+ else
255
+ out.push(piece);
256
+ }
257
+ return out;
258
+ }
259
+ /** The words of the sentence that belong to the date: from the last clause break before it. */
260
+ function clauseBefore(sentence, at) {
261
+ const head = sentence.slice(0, at);
262
+ let from = 0;
263
+ for (const m of head.matchAll(CLAUSE_BREAK))
264
+ from = (m.index ?? 0) + m[0].length;
265
+ return head.slice(from);
266
+ }
267
+ /**
268
+ * The user's own turns of one hit, one string per turn, without the lines the layout adds. Assistant turns are not read,
269
+ * and neither is text whose speaker this hit does not name: the continuation of a long turn comes as a chunk with no
270
+ * heading, and joined to a chunk of the other speaker it would be read as that speaker's.
271
+ */
272
+ function userTurnBodies(text) {
273
+ const bodies = [];
274
+ let speaker = null;
275
+ let buffer = [];
276
+ const flush = () => {
277
+ const body = buffer.join(' ').replace(/\s+/g, ' ').trim();
278
+ if (speaker === 'user' && body !== '')
279
+ bodies.push(body);
280
+ buffer = [];
281
+ };
282
+ for (const line of text.split(/\r?\n/)) {
283
+ const heading = /^#{1,6}\s+(.*)$/.exec(line.trim());
284
+ if (heading) {
285
+ flush();
286
+ if ((0, conversationShape_1.isTurnLabel)(heading[1].trim())) {
287
+ const words = heading[1].toLowerCase().replace(/[\p{N}\p{P}\p{S}\p{Z}]+/gu, ' ').trim().split(' ');
288
+ speaker = words.includes('user') || words.includes('human') ? 'user' : 'other';
289
+ }
290
+ else
291
+ speaker = null;
292
+ continue;
293
+ }
294
+ const trimmed = line.trim();
295
+ if (trimmed.startsWith('[Counted from this session\'s date') || trimmed.startsWith('[Session date:'))
296
+ continue;
297
+ buffer.push(line);
298
+ }
299
+ flush();
300
+ return bodies;
301
+ }
302
+ /** The session date a hit carries, or the one its own text opens with (`Timestamp:` or `[Session date: ...]`). */
303
+ function hitSessionMs(hit) {
304
+ const stated = (0, orchestrator_1.sessionDateMs)(hit.session_date);
305
+ if (Number.isFinite(stated))
306
+ return stated;
307
+ const inText = /(?:Timestamp:|\[Session date:)\s*([0-9]{4}[/-][0-9]{2}[/-][0-9]{2}(?: \([A-Za-z]{3}\))?(?: [0-9]{2}:[0-9]{2})?)/.exec(hit.text);
308
+ return inText ? (0, orchestrator_1.sessionDateMs)(inText[1].replace(/-/g, '/')) : Number.NEGATIVE_INFINITY;
309
+ }
310
+ function dayNumber(day) {
311
+ return Math.round(Date.UTC(day.year, day.month - 1, day.day) / 86_400_000);
312
+ }
313
+ function iso(day) {
314
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
315
+ return `${pad(day.year, 4)}-${pad(day.month)}-${pad(day.day)}`;
316
+ }
317
+ function quoteAround(sentence, at, end) {
318
+ const max = 220;
319
+ if (sentence.length <= max)
320
+ return sentence;
321
+ const start = Math.max(0, at - 150);
322
+ const stop = Math.min(sentence.length, Math.max(end + 40, start + max));
323
+ const cutStart = start === 0 ? 0 : sentence.indexOf(' ', start) + 1 || start;
324
+ const lastSpace = stop >= sentence.length ? -1 : sentence.lastIndexOf(' ', stop);
325
+ const cutStop = stop >= sentence.length ? sentence.length : lastSpace > end ? lastSpace : stop;
326
+ return `${cutStart > 0 ? '…' : ''}${sentence.slice(cutStart, cutStop).trim()}${cutStop < sentence.length ? '…' : ''}`;
327
+ }
328
+ /**
329
+ * Every dated statement in the user turns of the page: a sentence with exactly one written date, and the words of its
330
+ * clause before the date. A sentence with a second date, cued or not, is left out. "I applied to the program on February
331
+ * 10th and got accepted on March 20th" shares its words between two dates, and which words belong to which date would be
332
+ * a guess.
333
+ */
334
+ function datedStatements(hits) {
335
+ const out = [];
336
+ const seen = new Set();
337
+ hits.forEach((hit, hitIndex) => {
338
+ if (hit.notePath === '' && hit.reason === 'span')
339
+ return;
340
+ const sessionMs = hitSessionMs(hit);
341
+ userTurnBodies(hit.text).forEach((body, turnIndex) => {
342
+ splitSentences(body).forEach((sentence, sentenceIndex) => {
343
+ const { count, usable } = writtenDates(sentence, sessionMs);
344
+ const [date] = usable;
345
+ if (count !== 1 || usable.length !== 1 || date.day === null)
346
+ return;
347
+ const quote = quoteAround(sentence, date.at, date.end);
348
+ const identity = `${iso(date.day)}\0${quote}`;
349
+ if (seen.has(identity))
350
+ return;
351
+ seen.add(identity);
352
+ out.push({ day: date.day, before: contentStems(clauseBefore(sentence, date.at)), quote, key: `${hitIndex}:${turnIndex}:${sentenceIndex}` });
353
+ });
354
+ });
355
+ });
356
+ return out;
357
+ }
358
+ /** The one statement that is the date of this event, or null when none is, or when two different dates are equally good. */
359
+ function dateOfEvent(event, statements) {
360
+ const words = contentStems(event);
361
+ if (words.size === 0)
362
+ return null;
363
+ if (words.size === 1 && [...words][0].length < MIN_LONE_WORD)
364
+ return null;
365
+ const needed = neededOverlap(words.size);
366
+ // A number names one thing: the iPhone 13 is not a statement about the iPhone 12, whatever else the two share.
367
+ const numbers = [...words].filter(word => /\d/.test(word));
368
+ let best = 0;
369
+ let leaders = [];
370
+ for (const statement of statements) {
371
+ let score = 0;
372
+ for (const word of words)
373
+ if (statement.before.has(word))
374
+ score += 1;
375
+ if (score < needed || score < best || numbers.some(number => !statement.before.has(number)))
376
+ continue;
377
+ if (score > best) {
378
+ best = score;
379
+ leaders = [];
380
+ }
381
+ leaders.push(statement);
382
+ }
383
+ if (leaders.length === 0)
384
+ return null;
385
+ const days = new Set(leaders.map(statement => dayNumber(statement.day)));
386
+ return days.size === 1 ? leaders[0] : null;
387
+ }
388
+ /**
389
+ * The span the question asks about, from the dates the user wrote for its two events. Null when the question is no duration
390
+ * question, does not name two events, or the page does not hold a written date for each of them beyond doubt.
391
+ */
392
+ function questionSpan(hits, query) {
393
+ const kind = durationQuestion(query);
394
+ if (kind === null)
395
+ return null;
396
+ const events = questionEvents(query);
397
+ if (events === null)
398
+ return null;
399
+ const statements = datedStatements(hits);
400
+ if (statements.length < 2)
401
+ return null;
402
+ const first = dateOfEvent(events[0], statements);
403
+ const second = dateOfEvent(events[1], statements);
404
+ if (first === null || second === null || first.key === second.key)
405
+ return null;
406
+ const [from, to] = dayNumber(first.day) <= dayNumber(second.day) ? [first, second] : [second, first];
407
+ const days = dayNumber(to.day) - dayNumber(from.day);
408
+ if (days > 365 * 20)
409
+ return null;
410
+ return { unit: kind.unit, from: { date: iso(from.day), quote: from.quote }, to: { date: iso(to.day), quote: to.quote }, days };
411
+ }
412
+ function plural(n, noun) {
413
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
414
+ }
415
+ /** A day `months` calendar months on, in ms: the same day of that month, or its last day when it is shorter (31 January plus one month is 28 February). */
416
+ function monthsAfter(year, month, day, months) {
417
+ const first = new Date(Date.UTC(year, month - 1 + months, 1));
418
+ const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
419
+ return Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), Math.min(day, lastDay));
420
+ }
421
+ /** Whole calendar months from one day to the next, and the days left over (a month is the same day of the next month, or the last day of a shorter one). */
422
+ function calendarMonths(from, to) {
423
+ const [fy, fm, fd] = from.split('-').map(Number);
424
+ const [ty, tm, td] = to.split('-').map(Number);
425
+ const target = Date.UTC(ty, tm - 1, td);
426
+ // The later day's own month is the most there can be, and it is reached unless the earlier day, cut to that month, falls after the later one.
427
+ let months = Math.max(0, (ty - fy) * 12 + (tm - fm));
428
+ if (months > 0 && monthsAfter(fy, fm, fd, months) > target)
429
+ months -= 1;
430
+ return { months, days: Math.round((target - monthsAfter(fy, fm, fd, months)) / 86_400_000) };
431
+ }
432
+ function describe(days, from, to, unit) {
433
+ const base = plural(days, 'day');
434
+ const weeks = () => (days % 7 === 0 ? plural(days / 7, 'week') : `${plural(Math.floor(days / 7), 'week')} and ${plural(days % 7, 'day')}`);
435
+ const months = () => {
436
+ const m = calendarMonths(from, to);
437
+ return m.days === 0 ? plural(m.months, 'month') : `${plural(m.months, 'month')} and ${plural(m.days, 'day')}`;
438
+ };
439
+ if (unit === 'week')
440
+ return `${base} (${weeks()})`;
441
+ if (unit === 'month')
442
+ return `${base} (${months()})`;
443
+ if (unit === 'year') {
444
+ const m = calendarMonths(from, to);
445
+ const years = Math.floor(m.months / 12);
446
+ const rest = m.months % 12;
447
+ return `${base} (${plural(years, 'year')}${rest > 0 ? ` and ${plural(rest, 'month')}` : ''})`;
448
+ }
449
+ if (unit === null && days >= 14)
450
+ return `${base} (${days >= 60 ? `${weeks()}; ${months()}` : weeks()})`;
451
+ return base;
452
+ }
453
+ /** The line, as it is placed above the excerpts. */
454
+ function questionSpanLine(span) {
455
+ return [
456
+ 'Dates the user wrote for the two events in this question, quoted from the excerpts below, earlier first. They are counted from the written dates alone, never from a session date:',
457
+ `- ${span.from.date}: "${span.from.quote}"`,
458
+ `- ${span.to.date}: "${span.to.quote}"`,
459
+ `From the first to the second: ${describe(span.days, span.from.date, span.to.date, span.unit)}.`,
460
+ ].join('\n');
461
+ }
462
+ /**
463
+ * The page with the duration line first, when RENOR_READER_DURATION is on and the question is one it can answer from
464
+ * written dates; otherwise the page itself (the same array). `source` are the hits the statements are read from when they
465
+ * are not the page: the layout joins the chunks of a session into one text, which loses the chunk a turn heading belongs
466
+ * to, so it hands over the chunks as they were found.
467
+ */
468
+ function withQuestionSpan(hits, query, options = {}) {
469
+ if (!readerDurationEnabled(options.env) || query === undefined || query.trim() === '')
470
+ return hits;
471
+ const span = questionSpan(options.source ?? hits, query);
472
+ if (span === null)
473
+ return hits;
474
+ return [{ notePath: '', text: questionSpanLine(span), score: 0, reason: 'span' }, ...hits];
475
+ }