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.
- package/LICENSE +661 -0
- package/NOTICE +73 -0
- package/README.md +105 -2
- package/dist/BUILD.json +6 -0
- package/dist/api/apiAuth.js +43 -0
- package/dist/api/server.js +172 -0
- package/dist/buildIdentity.js +132 -0
- package/dist/cli-extractors.js +46 -0
- package/dist/cli.js +491 -0
- package/dist/config.js +65 -0
- package/dist/context/composer.js +320 -0
- package/dist/context/readHonesty.js +478 -0
- package/dist/context/rowStore.js +238 -0
- package/dist/conversation/formatter.js +81 -0
- package/dist/conversation/parser.js +189 -0
- package/dist/conversation/types.js +2 -0
- package/dist/conversation/writer.js +42 -0
- package/dist/db/client.js +147 -0
- package/dist/db/indexSalvage.js +81 -0
- package/dist/db/migrations.js +642 -0
- package/dist/db/readCache.js +109 -0
- package/dist/db/schema.js +5 -0
- package/dist/db/statements.js +89 -0
- package/dist/digests/digestCache.js +142 -0
- package/dist/digests/digests.js +597 -0
- package/dist/digests/worker.js +348 -0
- package/dist/embeddings/caching.js +337 -0
- package/dist/embeddings/coalescing.js +177 -0
- package/dist/embeddings/interactive.js +103 -0
- package/dist/embeddings/local.js +68 -0
- package/dist/embeddings/openai.js +105 -0
- package/dist/embeddings/rebuild.js +166 -0
- package/dist/embeddings/simdScan.js +258 -0
- package/dist/embeddings/space.js +21 -0
- package/dist/embeddings/types.js +14 -0
- package/dist/embeddings/vectorIndex.js +675 -0
- package/dist/embeddings/vertex.js +224 -0
- package/dist/eval/artifacts.js +42 -0
- package/dist/eval/cacheReport.js +139 -0
- package/dist/eval/comparison.js +182 -0
- package/dist/eval/context700-candidate.js +145 -0
- package/dist/eval/evalCache.js +525 -0
- package/dist/eval/hashEmbedding.js +35 -0
- package/dist/eval/longmemeval.js +56 -0
- package/dist/eval/metrics.js +181 -0
- package/dist/eval/renorbenchBaseline.js +486 -0
- package/dist/eval/renorbenchDocuments.js +134 -0
- package/dist/eval/renorbenchProduct.js +182 -0
- package/dist/eval/runLedger.js +279 -0
- package/dist/eval/runManifest.js +479 -0
- package/dist/eval/scoring.js +197 -0
- package/dist/evidence/attach.js +267 -0
- package/dist/evidence/background.js +147 -0
- package/dist/evidence/evidenceCache.js +182 -0
- package/dist/evidence/gate.js +37 -0
- package/dist/evidence/keyPool.js +85 -0
- package/dist/evidence/ollamaChat.js +84 -0
- package/dist/evidence/optin.js +165 -0
- package/dist/evidence/options.js +130 -0
- package/dist/evidence/secondPass.js +456 -0
- package/dist/extraction/classifyCapture.js +34 -0
- package/dist/extraction/decision-model.js +174 -0
- package/dist/extraction/eventExtractor.js +113 -0
- package/dist/extraction/extractionClient.js +122 -0
- package/dist/extraction/factExtractor.js +46 -0
- package/dist/extraction/factsWritePath.js +179 -0
- package/dist/extraction/preferenceExtractor.js +153 -0
- package/dist/extraction/secretGuard.js +176 -0
- package/dist/extraction/secretScanner.js +665 -0
- package/dist/extraction/typedFacts.js +224 -0
- package/dist/extraction/writePathAdjudication.js +110 -0
- package/dist/importing/apply.js +644 -0
- package/dist/importing/bundle-write.js +80 -0
- package/dist/importing/cli.js +407 -0
- package/dist/importing/existing.js +49 -0
- package/dist/importing/facts.js +123 -0
- package/dist/importing/forgetImported.js +79 -0
- package/dist/importing/hosted.js +467 -0
- package/dist/importing/index.js +29 -0
- package/dist/importing/journalLog.js +96 -0
- package/dist/importing/markdown.js +98 -0
- package/dist/importing/plan.js +206 -0
- package/dist/importing/secretScreen.js +113 -0
- package/dist/importing/serialize.js +188 -0
- package/dist/importing/source.js +463 -0
- package/dist/importing/types.js +6 -0
- package/dist/indexer/chunker.js +319 -0
- package/dist/indexer/dailyEntries.js +63 -0
- package/dist/indexer/hasher.js +10 -0
- package/dist/indexer/laneRuns.js +48 -0
- package/dist/indexer/o200k.js +481 -0
- package/dist/indexer/o200k.json +8 -0
- package/dist/indexer/outsideRoot.js +77 -0
- package/dist/indexer/parser.js +167 -0
- package/dist/indexer/pipeline.js +1168 -0
- package/dist/indexer/staleness.js +201 -0
- package/dist/indexer/tokenizer.js +77 -0
- package/dist/indexer/walker.js +184 -0
- package/dist/mcp/hive.js +88 -0
- package/dist/mcp/requestErrors.js +47 -0
- package/dist/mcp/server.js +396 -0
- package/dist/memory/agentLease.js +43 -0
- package/dist/memory/confinedWrite.js +315 -0
- package/dist/memory/controls.js +73 -0
- package/dist/memory/dailyNote.js +265 -0
- package/dist/memory/durableAppend.js +57 -0
- package/dist/memory/factConflicts.js +203 -0
- package/dist/memory/factTerms.js +321 -0
- package/dist/memory/hiveNameHeader.cjs +10 -0
- package/dist/memory/inventory.js +55 -0
- package/dist/memory/invisibleText.js +20 -0
- package/dist/memory/lifecycle.js +1756 -0
- package/dist/memory/limits.js +13 -0
- package/dist/memory/requests.js +183 -0
- package/dist/memory/sourceName.js +200 -0
- package/dist/memory/whatsNew.js +158 -0
- package/dist/memory/writer.js +175 -0
- package/dist/memory/writerConflicts.js +85 -0
- package/dist/memorymap/memoryMap.js +1568 -0
- package/dist/memorymap/replacedJournal.js +58 -0
- package/dist/memorymap/replacements.js +405 -0
- package/dist/mvp/activity.js +86 -0
- package/dist/mvp/agentMemory.js +1744 -0
- package/dist/mvp/auth.js +531 -0
- package/dist/mvp/cli.js +73 -0
- package/dist/mvp/client-store.js +462 -0
- package/dist/mvp/compileCache.js +249 -0
- package/dist/mvp/compileSeed.js +27 -0
- package/dist/mvp/config.js +288 -0
- package/dist/mvp/connectionStatus.js +80 -0
- package/dist/mvp/contextRelevance.js +413 -0
- package/dist/mvp/engine.js +1065 -0
- package/dist/mvp/exportAll.js +359 -0
- package/dist/mvp/factLanePack.js +77 -0
- package/dist/mvp/factMarkers.js +49 -0
- package/dist/mvp/factsLane.js +203 -0
- package/dist/mvp/hiddenSpans.js +585 -0
- package/dist/mvp/history.js +413 -0
- package/dist/mvp/hitDates.js +31 -0
- package/dist/mvp/hitFloor.js +36 -0
- package/dist/mvp/identity.js +18 -0
- package/dist/mvp/idle.js +113 -0
- package/dist/mvp/memoryDir.js +98 -0
- package/dist/mvp/posthogMcp.js +210 -0
- package/dist/mvp/readScope.js +104 -0
- package/dist/mvp/readUsefulness.js +222 -0
- package/dist/mvp/sanitize.js +48 -0
- package/dist/mvp/schemaRejections.js +91 -0
- package/dist/mvp/scope.js +127 -0
- package/dist/mvp/server.js +2089 -0
- package/dist/mvp/serverCard.js +29 -0
- package/dist/mvp/storedSize.js +139 -0
- package/dist/mvp/telemetry.js +441 -0
- package/dist/retrieval/adviceLines.js +195 -0
- package/dist/retrieval/adviceQuestion.js +44 -0
- package/dist/retrieval/conversationShape.js +44 -0
- package/dist/retrieval/dailyEntryTime.js +55 -0
- package/dist/retrieval/entityLookup.js +113 -0
- package/dist/retrieval/factsFirst.js +188 -0
- package/dist/retrieval/keyword.js +84 -0
- package/dist/retrieval/ledgerEventTime.js +51 -0
- package/dist/retrieval/ledgerLiveness.js +83 -0
- package/dist/retrieval/ledgerPresentation.js +161 -0
- package/dist/retrieval/noteIdentity.js +57 -0
- package/dist/retrieval/orchestrator.js +1086 -0
- package/dist/retrieval/queryHelpers.js +142 -0
- package/dist/retrieval/questionSpan.js +475 -0
- package/dist/retrieval/readerEvents.js +690 -0
- package/dist/retrieval/readerLayout.js +503 -0
- package/dist/retrieval/relativeTimeHints.js +113 -0
- package/dist/retrieval/semantic.js +67 -0
- package/dist/router/routerCache.js +106 -0
- package/dist/router/sessionRouter.js +726 -0
- package/dist/toolRefusal.js +120 -0
- package/dist/watcher/watcher.js +75 -0
- package/package.json +120 -4
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ADVICE_LINES_HEADER = exports.MAX_ADVICE_CANDIDATES = exports.MAX_ADVICE_LINES_PER_SESSION = exports.MAX_ADVICE_TOKENS = exports.MAX_ADVICE_LINES = void 0;
|
|
4
|
+
exports.readerAdviceLinesEnabled = readerAdviceLinesEnabled;
|
|
5
|
+
exports.isSelfStatement = isSelfStatement;
|
|
6
|
+
exports.adviceLines = adviceLines;
|
|
7
|
+
exports.withAdviceLines = withAdviceLines;
|
|
8
|
+
const tokenizer_1 = require("../indexer/tokenizer");
|
|
9
|
+
const adviceQuestion_1 = require("./adviceQuestion");
|
|
10
|
+
const orchestrator_1 = require("./orchestrator");
|
|
11
|
+
const readerEvents_1 = require("./readerEvents");
|
|
12
|
+
const relativeTimeHints_1 = require("./relativeTimeHints");
|
|
13
|
+
/**
|
|
14
|
+
* Advice lines: for a query that asks to be advised ("Can you recommend ...", "Any tips ...", "What should I ..."; adviceQuestion.ts),
|
|
15
|
+
* one block first with what the user has said about their own situation, tastes and habits that shares words with the request,
|
|
16
|
+
* quoted as they wrote it and dated by its conversation, oldest first. Opt-in (`RENOR_READER_ADVICE_LINES`, default off): with it
|
|
17
|
+
* unset, or set to anything but an on value, a reply is byte-identical, and so is the reply to any query that does not ask for advice.
|
|
18
|
+
*
|
|
19
|
+
* Why. On the 19 Oct best set with a Claude Haiku 5.5 reader (docs/evals/results/LEDGER.md section AG), 30 of the 61 misses are
|
|
20
|
+
* "I don't know" on a question that has an answer; 28 of those 30 pages hold every gold evidence turn, and the largest group of them
|
|
21
|
+
* is 10 requests for advice (preference questions), each wrong in all six arms on record whatever the page's layout or digests
|
|
22
|
+
* (docs/evals/results/idk-misses-20261019/). A request for advice has no stored answer: what memory holds is the user's earlier words
|
|
23
|
+
* about themselves ("I've been using Premiere Pro for my edits"), one sentence inside a long turn among dozens of excerpts. These
|
|
24
|
+
* lines put those sentences where a reader looks first and say what they are.
|
|
25
|
+
*
|
|
26
|
+
* - A line is `<day>: "<sentence>"`, the sentence exactly as the user wrote it (one over MAX_QUOTE_CHARS is cut around its first
|
|
27
|
+
* matching word, with "…" where it is cut), under the date of its conversation. Assistant turns are not quoted, and neither is a
|
|
28
|
+
* question.
|
|
29
|
+
* - Picked by the nouns and names the request holds, not by its advice words ("recommend", "tips") or its verbs: one of them is
|
|
30
|
+
* enough. Every thing the request names gets its own line first, then the best-scoring of the rest (rarer words weigh more).
|
|
31
|
+
* - Bounded: at most MAX_ADVICE_LINES lines, two per conversation, MAX_ADVICE_TOKENS tokens of lines; only conversations the page
|
|
32
|
+
* already holds are read; no model call, no embedding.
|
|
33
|
+
* - The excerpts stay in full below the lines, in the order the page gave them.
|
|
34
|
+
*/
|
|
35
|
+
/** Default off. `1` / `true` / `yes` / `on` places the advice lines first on the reply to a query that asks to be advised. */
|
|
36
|
+
function readerAdviceLinesEnabled(env = process.env) {
|
|
37
|
+
const v = (env.RENOR_READER_ADVICE_LINES ?? '').trim().toLowerCase();
|
|
38
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
39
|
+
}
|
|
40
|
+
/** Most lines a reply carries. */
|
|
41
|
+
exports.MAX_ADVICE_LINES = 6;
|
|
42
|
+
/** Most tokens of lines (o200k) a reply carries, the header sentence excluded. */
|
|
43
|
+
exports.MAX_ADVICE_TOKENS = 400;
|
|
44
|
+
/** Most lines taken from one conversation. */
|
|
45
|
+
exports.MAX_ADVICE_LINES_PER_SESSION = 2;
|
|
46
|
+
/** Most sentences considered for one query: the cost of a reply is bounded whatever the page holds. */
|
|
47
|
+
exports.MAX_ADVICE_CANDIDATES = 4000;
|
|
48
|
+
exports.ADVICE_LINES_HEADER = 'Statements by the user about their own situation, tastes and habits that share words with this request, oldest first, each under the date of its conversation. The excerpts below are unchanged.';
|
|
49
|
+
// The words a request for advice is framed with, not what it is about: they never select a sentence.
|
|
50
|
+
const ADVICE_WORDS = new Set([
|
|
51
|
+
'recommend', 'recommendation', 'suggest', 'suggestion', 'advise', 'advice', 'tip', 'idea', 'thought', 'pointer', 'opinion', 'think',
|
|
52
|
+
'option', 'resource', 'good', 'great', 'best', 'better', 'nice', 'new', 'like', 'something', 'anything', 'thing', 'way', 'please',
|
|
53
|
+
'should', 'want', 'need', 'looking', 'help', 'know', 'let',
|
|
54
|
+
].map(w => (0, readerEvents_1.stem)(w)));
|
|
55
|
+
// A question to someone, not something the user says about themselves.
|
|
56
|
+
const ASKS = /^(?:can|could|would|will|do|does|did|is|are|what|how|why|where|which|who|any)\b/i;
|
|
57
|
+
/** Something the user says about themselves: first person, and not a question. */
|
|
58
|
+
function isSelfStatement(sentence) {
|
|
59
|
+
const s = sentence.replace(/^["'“(\[]+/, '');
|
|
60
|
+
return !sentence.trimEnd().endsWith('?') && !ASKS.test(s) && readerEvents_1.FIRST_PERSON.test(s);
|
|
61
|
+
}
|
|
62
|
+
/** The conversation's own day, as its date is written (`2023/05/30 (Tue)` or `2023-05-30`), or null when it has none. */
|
|
63
|
+
function dayOf(sessionDate) {
|
|
64
|
+
const ms = (0, orchestrator_1.sessionDateMs)(sessionDate);
|
|
65
|
+
if (!Number.isFinite(ms) || sessionDate === undefined)
|
|
66
|
+
return null;
|
|
67
|
+
return (0, relativeTimeHints_1.formatDay)(new Date(Math.floor(ms / 86_400_000) * 86_400_000), /^\d{4}\//.test(sessionDate));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The advice lines for one query, or null when the query does not ask to be advised or no statement of the user's shares a
|
|
71
|
+
* word with it. `hits` are the conversation hits of the page; `turnsOf` returns the stored chunks of a conversation note.
|
|
72
|
+
*/
|
|
73
|
+
function adviceLines(hits, query, turnsOf) {
|
|
74
|
+
if (!(0, adviceQuestion_1.asksForAdvice)(query))
|
|
75
|
+
return null;
|
|
76
|
+
const terms = new Set([...(0, readerEvents_1.questionTerms)(query)].filter(t => !ADVICE_WORDS.has(t)));
|
|
77
|
+
const aboutTerms = new Set([...terms].filter(t => !readerEvents_1.GENERIC_VERBS.has(t)));
|
|
78
|
+
if (aboutTerms.size === 0)
|
|
79
|
+
return null;
|
|
80
|
+
// One entry per conversation, in the order the page holds them: its date and a chunk of it, from whichever of its hits has them.
|
|
81
|
+
const sources = new Map();
|
|
82
|
+
for (const hit of hits) {
|
|
83
|
+
const had = sources.get(hit.notePath) ?? {};
|
|
84
|
+
sources.set(hit.notePath, { chunkId: had.chunkId ?? (hit.chunkId !== undefined && hit.chunkId > 0 ? hit.chunkId : undefined), sessionDate: had.sessionDate ?? hit.session_date });
|
|
85
|
+
}
|
|
86
|
+
const candidates = [];
|
|
87
|
+
let position = 0;
|
|
88
|
+
for (const [notePath, { chunkId, sessionDate }] of sources) {
|
|
89
|
+
if (notePath === '' || candidates.length >= exports.MAX_ADVICE_CANDIDATES)
|
|
90
|
+
continue;
|
|
91
|
+
for (const turn of turnsOf({ notePath, chunkId })) {
|
|
92
|
+
if (candidates.length >= exports.MAX_ADVICE_CANDIDATES)
|
|
93
|
+
break;
|
|
94
|
+
if (!(0, readerEvents_1.isUserTurn)(turn.headingPath))
|
|
95
|
+
continue;
|
|
96
|
+
for (const sentence of (0, readerEvents_1.sentencesOf)((0, readerEvents_1.stripHeading)(turn.text))) {
|
|
97
|
+
if (!isSelfStatement(sentence))
|
|
98
|
+
continue;
|
|
99
|
+
candidates.push({ sentence, stems: new Set((0, readerEvents_1.wordsOf)(sentence).map(w => (0, readerEvents_1.stem)(w.replace(/['’]s$/, '')))), notePath, sessionDate, position: position++ });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (candidates.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
// A term every sentence holds says little; one a few hold says what the request is about.
|
|
106
|
+
const df = new Map();
|
|
107
|
+
for (const c of candidates)
|
|
108
|
+
for (const t of aboutTerms)
|
|
109
|
+
if (c.stems.has(t))
|
|
110
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
111
|
+
const idf = (t) => Math.log(1 + candidates.length / (df.get(t) ?? 1));
|
|
112
|
+
// A preference is usually stated with one word of the request ("I like hotels near the beach" for "a hotel for my trip to Miami"),
|
|
113
|
+
// so one shared word is enough to be a candidate; the score and the bounds below decide which candidates are shown.
|
|
114
|
+
const scored = candidates.flatMap(candidate => {
|
|
115
|
+
let score = 0;
|
|
116
|
+
for (const t of aboutTerms)
|
|
117
|
+
if (candidate.stems.has(t))
|
|
118
|
+
score += idf(t);
|
|
119
|
+
return score > 0 ? [{ candidate, score }] : [];
|
|
120
|
+
});
|
|
121
|
+
if (scored.length === 0)
|
|
122
|
+
return null;
|
|
123
|
+
const chosen = [];
|
|
124
|
+
const perSession = new Map();
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
let tokens = 0;
|
|
127
|
+
const take = (c) => {
|
|
128
|
+
const key = `${c.notePath}\0${c.sentence.toLowerCase()}`;
|
|
129
|
+
if (seen.has(key) || (perSession.get(c.notePath) ?? 0) >= exports.MAX_ADVICE_LINES_PER_SESSION)
|
|
130
|
+
return false;
|
|
131
|
+
const day = dayOf(c.sessionDate);
|
|
132
|
+
const quote = (0, readerEvents_1.quoteOf)(c.sentence, terms);
|
|
133
|
+
const line = day ? `- ${day}: "${quote}"` : `- "${quote}"`;
|
|
134
|
+
const cost = (0, tokenizer_1.encodeTokens)(line) + 1;
|
|
135
|
+
if (tokens + cost > exports.MAX_ADVICE_TOKENS)
|
|
136
|
+
return false;
|
|
137
|
+
tokens += cost;
|
|
138
|
+
seen.add(key);
|
|
139
|
+
perSession.set(c.notePath, (perSession.get(c.notePath) ?? 0) + 1);
|
|
140
|
+
chosen.push({ candidate: c, line });
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
143
|
+
// First every thing the request names gets its best line, the sentence that says most of what is still unsaid first; then the
|
|
144
|
+
// best-scoring sentences that are left, until a bound is reached.
|
|
145
|
+
const covered = new Set();
|
|
146
|
+
const refused = new Set();
|
|
147
|
+
while (chosen.length < exports.MAX_ADVICE_LINES) {
|
|
148
|
+
let pick = null;
|
|
149
|
+
let pickGain = 0;
|
|
150
|
+
for (const s of scored) {
|
|
151
|
+
if (refused.has(s.candidate))
|
|
152
|
+
continue;
|
|
153
|
+
let gain = 0;
|
|
154
|
+
for (const t of aboutTerms)
|
|
155
|
+
if (s.candidate.stems.has(t) && !covered.has(t))
|
|
156
|
+
gain += idf(t);
|
|
157
|
+
if (gain > pickGain || (gain === pickGain && gain > 0 && pick !== null && s.score > pick.score)) {
|
|
158
|
+
pick = s;
|
|
159
|
+
pickGain = gain;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (pick === null || pickGain === 0)
|
|
163
|
+
break;
|
|
164
|
+
if (take(pick.candidate))
|
|
165
|
+
for (const t of aboutTerms) {
|
|
166
|
+
if (pick.candidate.stems.has(t))
|
|
167
|
+
covered.add(t);
|
|
168
|
+
}
|
|
169
|
+
refused.add(pick.candidate);
|
|
170
|
+
}
|
|
171
|
+
const rest = scored.filter(s => !refused.has(s.candidate))
|
|
172
|
+
.sort((a, b) => b.score - a.score || a.candidate.sentence.length - b.candidate.sentence.length || a.candidate.position - b.candidate.position);
|
|
173
|
+
for (const s of rest) {
|
|
174
|
+
if (chosen.length >= exports.MAX_ADVICE_LINES)
|
|
175
|
+
break;
|
|
176
|
+
take(s.candidate);
|
|
177
|
+
}
|
|
178
|
+
if (chosen.length === 0)
|
|
179
|
+
return null;
|
|
180
|
+
// Oldest first: the conversation's date, then the order of reading.
|
|
181
|
+
chosen.sort((a, b) => ((0, orchestrator_1.sessionDateMs)(a.candidate.sessionDate) - (0, orchestrator_1.sessionDateMs)(b.candidate.sessionDate)) || (a.candidate.position - b.candidate.position));
|
|
182
|
+
return [exports.ADVICE_LINES_HEADER, ...chosen.map(c => c.line)].join('\n');
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The page with the advice lines first (reason "advice"), when the switch is on and the query asks to be advised. Otherwise the
|
|
186
|
+
* very array it was given.
|
|
187
|
+
*/
|
|
188
|
+
function withAdviceLines(hits, input, env = process.env) {
|
|
189
|
+
if (!readerAdviceLinesEnabled(env) || !(0, adviceQuestion_1.asksForAdvice)(input.query))
|
|
190
|
+
return hits;
|
|
191
|
+
const text = adviceLines(hits.filter(input.isSource), input.query, input.turnsOf());
|
|
192
|
+
if (text === null)
|
|
193
|
+
return hits;
|
|
194
|
+
return [{ notePath: '', text, score: 0, reason: 'advice' }, ...hits];
|
|
195
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Does a search query ask to be advised: for a recommendation, a suggestion, a tip, an idea or an opinion on what to do?
|
|
4
|
+
* Told from the query's own words and nothing else (never a label of the question), because the query is all a search sees.
|
|
5
|
+
* Its one use is the opt-in exception to the reader layout (readerLayout.ts, RENOR_READER_LAYOUT_SKIP_ADVICE).
|
|
6
|
+
*
|
|
7
|
+
* Narrow on purpose, since a wrong yes takes the layout from a question that wants it:
|
|
8
|
+
*
|
|
9
|
+
* - a request is one of a few plain frames ("Can you recommend ...", "Any tips ...", "What should I ...", "What do you
|
|
10
|
+
* think"); a word such as "recommendations" inside a phrase that looks something up is not a request;
|
|
11
|
+
* - a query that points back at what was said before ("what did you recommend last time", "remind me", "our previous
|
|
12
|
+
* conversation") asks to recall an answer, not to be given one, and is not a request wherever the frame stands;
|
|
13
|
+
* - only the last line is read for the frame: the benchmark runner puts "Question date: ..." above the question.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.asksForAdvice = asksForAdvice;
|
|
17
|
+
// "any" opens a clause ("Any tips?", "..., any suggestions?") or follows "do you have": "did I get any tips" looks something up.
|
|
18
|
+
const ANY_ADVICE = String.raw `any\s+(?:[\w'’-]+\s+){0,3}(?:tips?|advice|suggestions?|recommendations?|ideas|thoughts|pointers)\b`;
|
|
19
|
+
const REQUEST_FRAMES = [
|
|
20
|
+
/\b(?:can|could|would|will) you (?:please )?(?:recommend|suggest|advise)\b/i,
|
|
21
|
+
new RegExp(String.raw `(?:^|[,;:.!?]\s*|\bdo you have\s+)${ANY_ADVICE}`, 'i'),
|
|
22
|
+
/\bwhat should i\b/i,
|
|
23
|
+
/\b(?:what do you think|do you think|what would you (?:do|recommend|suggest))\b/i,
|
|
24
|
+
];
|
|
25
|
+
const POINTS_AT_THE_PAST = new RegExp([
|
|
26
|
+
String.raw `\byou (?:recommended|suggested|advised|told|said|mentioned|gave|provided|listed|named)\b`,
|
|
27
|
+
String.raw `\blast time\b`,
|
|
28
|
+
String.raw `\bearlier\b`,
|
|
29
|
+
String.raw `\bremind me\b`,
|
|
30
|
+
String.raw `\bwe (?:discussed|talked|chatted|spoke)\b`,
|
|
31
|
+
String.raw `\bour (?:previous|last|earlier|past) (?:chat|conversation|discussion)s?\b`,
|
|
32
|
+
String.raw `\bprevious(?:ly)?\b`,
|
|
33
|
+
String.raw `\bdid you\b`,
|
|
34
|
+
].join('|'), 'i');
|
|
35
|
+
function lastLine(query) {
|
|
36
|
+
const lines = query.split(/\r?\n/).map(line => line.trim()).filter(line => line !== '');
|
|
37
|
+
return lines.length > 0 ? lines[lines.length - 1] : '';
|
|
38
|
+
}
|
|
39
|
+
function asksForAdvice(query) {
|
|
40
|
+
if (POINTS_AT_THE_PAST.test(query))
|
|
41
|
+
return false;
|
|
42
|
+
const question = lastLine(query);
|
|
43
|
+
return REQUEST_FRAMES.some(frame => frame.test(question));
|
|
44
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CONVERSATION_MIN_TURNS = void 0;
|
|
4
|
+
exports.isTurnLabel = isTurnLabel;
|
|
5
|
+
exports.isConversationShaped = isConversationShaped;
|
|
6
|
+
/**
|
|
7
|
+
* Is a note shaped like a conversation? Told from the note's own sections, never from a front-matter `kind`, a file
|
|
8
|
+
* name or a source: a chat somebody exported and a benchmark's sessions look the same, and a note the user wrote as a
|
|
9
|
+
* document does not.
|
|
10
|
+
*
|
|
11
|
+
* A conversation written as Markdown gives each turn its own heading, and the heading says who spoke or which turn it
|
|
12
|
+
* is and nothing else ("Turn 7 - User", "User", "### Assistant:", "Q3"). A document's headings name what the section
|
|
13
|
+
* is about. So a note is conversation-shaped when at least half of its distinct heading paths end in such a label.
|
|
14
|
+
*
|
|
15
|
+
* Why the session expansion asks (orchestrator.ts): its statement limit was measured on conversations only. A turn
|
|
16
|
+
* over 100 tokens is mostly an assistant's long answer, and the answer-bearing remark is short; in a document the
|
|
17
|
+
* paragraph that answers is often 100 to 200 tokens, which the same limit would leave out.
|
|
18
|
+
*
|
|
19
|
+
* The rule leans to "document". A note taken for a document keeps the former limit, which costs a saving and nothing
|
|
20
|
+
* else; a document taken for a conversation would lose paragraphs the limit was never measured on. So a label must be
|
|
21
|
+
* made of role words alone, a note needs at least `CONVERSATION_MIN_TURNS` of them, and they must be at least half of its paths.
|
|
22
|
+
*/
|
|
23
|
+
const TURN_WORDS = new Set([
|
|
24
|
+
'turn', 'user', 'assistant', 'human', 'ai', 'bot', 'me', 'you', 'system', 'claude', 'chatgpt', 'gpt', 'gemini',
|
|
25
|
+
'model', 'speaker', 'message', 'question', 'answer', 'reply', 'response', 'prompt', 'q', 'a',
|
|
26
|
+
]);
|
|
27
|
+
/** The fewest turn headings a note needs to count as a conversation (a one-exchange note is a document here). */
|
|
28
|
+
exports.CONVERSATION_MIN_TURNS = 4;
|
|
29
|
+
/** True when the heading is only a speaker or turn label: role words, digits, punctuation, symbols and spaces. */
|
|
30
|
+
function isTurnLabel(heading) {
|
|
31
|
+
const words = heading.toLowerCase().replace(/[\p{N}\p{P}\p{S}\p{Z}]+/gu, ' ').trim().split(' ').filter(Boolean);
|
|
32
|
+
return words.length > 0 && words.every(word => TURN_WORDS.has(word));
|
|
33
|
+
}
|
|
34
|
+
/** `headingPaths`: the `heading_path` of every chunk of one note (a path is its headings joined by " > "). */
|
|
35
|
+
function isConversationShaped(headingPaths) {
|
|
36
|
+
const distinct = new Set(headingPaths);
|
|
37
|
+
let turns = 0;
|
|
38
|
+
for (const headingPath of distinct) {
|
|
39
|
+
const last = headingPath.split(' > ').pop() ?? '';
|
|
40
|
+
if (isTurnLabel(last))
|
|
41
|
+
turns += 1;
|
|
42
|
+
}
|
|
43
|
+
return turns >= exports.CONVERSATION_MIN_TURNS && turns * 2 >= distinct.size;
|
|
44
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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.createDailyEntryClock = createDailyEntryClock;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const dailyNote_1 = require("../memory/dailyNote");
|
|
9
|
+
/**
|
|
10
|
+
* When the daily-note entry a hit is part of was saved.
|
|
11
|
+
*
|
|
12
|
+
* A daily note is ONE file holding one entry per statement, so the date of the note (when the file was last written)
|
|
13
|
+
* is the same for every entry of the day: a correction said later tied with what it corrects, and newest first could
|
|
14
|
+
* put the stale one on top (live dogfood, D3). Each entry is a memory of its own (the chunker cuts one per entry) and
|
|
15
|
+
* its header says when it was said: the day of its file, the time, and the offset of the clock it was stamped on. That
|
|
16
|
+
* is `entryInstant`, the moment the memory map shows for the entry; an entry filed under an earlier day is dated by
|
|
17
|
+
* that day.
|
|
18
|
+
*
|
|
19
|
+
* The offset is in the file only (readers get the user's own day and time without it), so the file is read, once per
|
|
20
|
+
* note however many of its entries are hits, and only for a hit in a daily note. A hit is the entry whose text holds
|
|
21
|
+
* it, as the index cut it. A hit that no entry holds, or that entries saved at different moments all hold (the file
|
|
22
|
+
* changed since it was indexed, text above the first entry, a passage repeated across entries), keeps its note's date.
|
|
23
|
+
*/
|
|
24
|
+
function createDailyEntryClock() {
|
|
25
|
+
const notes = new Map();
|
|
26
|
+
return async (hit) => {
|
|
27
|
+
const day = (0, dailyNote_1.dailyNoteDayName)(hit.notePath);
|
|
28
|
+
if (day === null)
|
|
29
|
+
return undefined;
|
|
30
|
+
let entries = notes.get(hit.notePath);
|
|
31
|
+
if (!entries)
|
|
32
|
+
notes.set(hit.notePath, (entries = entriesOf(hit.notePath, day)));
|
|
33
|
+
const at = new Set((await entries).filter(entry => entry.text.includes(hit.text)).map(entry => entry.at));
|
|
34
|
+
return at.size === 1 ? [...at][0] : undefined;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Every entry of a daily note, as readers get its text (its lines up to the next entry's), with the moment it was saved. */
|
|
38
|
+
async function entriesOf(notePath, day) {
|
|
39
|
+
let stored;
|
|
40
|
+
try {
|
|
41
|
+
stored = await fs_1.default.promises.readFile(notePath, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return []; // gone since it was indexed: the hit keeps its note's date
|
|
45
|
+
}
|
|
46
|
+
// An entry starts where the stored text says one does (dailyEntryStarts), and its words are the decoded lines, which
|
|
47
|
+
// the decode changes in place, never adding or removing one.
|
|
48
|
+
const storedLines = stored.split('\n');
|
|
49
|
+
const lines = (0, dailyNote_1.decodeDailyNoteText)(stored).split('\n');
|
|
50
|
+
const starts = storedLines.flatMap((line, i) => ((0, dailyNote_1.parseEntryHeader)(line) ? [i] : []));
|
|
51
|
+
return starts.map((start, k) => ({
|
|
52
|
+
text: lines.slice(start, starts[k + 1] ?? lines.length).join('\n'),
|
|
53
|
+
at: (0, dailyNote_1.entryInstant)(day, (0, dailyNote_1.parseEntryHeader)(storedLines[start])),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.entityScopeFilter = entityScopeFilter;
|
|
4
|
+
exports.liveEntityFilter = liveEntityFilter;
|
|
5
|
+
exports.lookupEntity = lookupEntity;
|
|
6
|
+
const readCache_1 = require("../db/readCache");
|
|
7
|
+
const statements_1 = require("../db/statements");
|
|
8
|
+
const scope_1 = require("../mvp/scope");
|
|
9
|
+
/** The note that owns the lifecycle entities, the subjects remembered facts are about (mirrors lifecycle.ts). */
|
|
10
|
+
const LIFECYCLE_NOTE_PATH = '_mcp_';
|
|
11
|
+
/**
|
|
12
|
+
* The entities that belong to `scope`, as a filter for `lookupEntity` and for counting entities. A lifecycle subject
|
|
13
|
+
* belongs to every scope one of its facts was saved in (under any spelling of it: savedScopeSqlPredicate), current or
|
|
14
|
+
* superseded, the rule retrieve_memory judges a subject's existence by: its source note is the reserved name `_mcp_`,
|
|
15
|
+
* not a path, and says nothing about scope (read as a path it would be global, so a global lookup would name subjects
|
|
16
|
+
* saved only in a named scope, and a named scope would find none of its own). Every other entity belongs to the scope
|
|
17
|
+
* its source note's path names.
|
|
18
|
+
*/
|
|
19
|
+
function entityScopeFilter(scope) {
|
|
20
|
+
const saved = (0, scope_1.savedScopeSqlPredicate)(scope, 'f.scope');
|
|
21
|
+
const path = (0, scope_1.scopeSqlPredicate)(scope, 'entities.source_note');
|
|
22
|
+
return {
|
|
23
|
+
sql: `CASE WHEN entities.source_note = ? THEN EXISTS (SELECT 1 FROM facts f WHERE f.subject_entity_id = entities.id AND ${saved.sql}) ELSE ${path.sql} END`,
|
|
24
|
+
params: [LIFECYCLE_NOTE_PATH, ...saved.params, ...path.params],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The entities of every scope, as a filter for `lookupEntity` and for counting entities: a lifecycle subject only while
|
|
29
|
+
* it has a fact that is not retired (memory/lifecycle.ts moves a retired fact out of `facts`), the rule
|
|
30
|
+
* entityScopeFilter applies within one scope; every other entity.
|
|
31
|
+
*/
|
|
32
|
+
function liveEntityFilter() {
|
|
33
|
+
return {
|
|
34
|
+
sql: `CASE WHEN entities.source_note = ? THEN EXISTS (SELECT 1 FROM facts f WHERE f.subject_entity_id = entities.id) ELSE 1 END`,
|
|
35
|
+
params: [LIFECYCLE_NOTE_PATH],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Of entities that match a name equally well, which one answers: a remembered fact's subject first, then by source
|
|
40
|
+
* note path, then the oldest. Left to SQLite, the first row read answers, which is the note indexing happened to
|
|
41
|
+
* write first, and concurrent indexing writes notes in no fixed order.
|
|
42
|
+
*/
|
|
43
|
+
function tieOrder(table = '') {
|
|
44
|
+
return `(${table}source_note = '${LIFECYCLE_NOTE_PATH}') DESC, ${table}source_note, ${table}id`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The longest canonical name, alias and source path, in SQLite characters (one code point, not a JavaScript UTF-16
|
|
48
|
+
* unit). Read once until the next write. An alias list that cannot be read counts as unbounded, so that query still runs.
|
|
49
|
+
*/
|
|
50
|
+
function entityNameBounds(db) {
|
|
51
|
+
return (0, readCache_1.readUntilWrite)(db, 'entityLookupBounds', () => {
|
|
52
|
+
const row = (0, statements_1.prepareOnce)(db, `SELECT COALESCE(MAX(length(canonical_name)), 0) AS name, COALESCE(MAX(length(source_note)), 0) AS note FROM entities`).get();
|
|
53
|
+
let alias = Number.POSITIVE_INFINITY;
|
|
54
|
+
try {
|
|
55
|
+
alias = (0, statements_1.prepareOnce)(db, `SELECT COALESCE(MAX(length(alias.value)), 0) AS n FROM entities AS e, json_each(e.aliases_json) AS alias`).pluck().get();
|
|
56
|
+
}
|
|
57
|
+
catch { /* malformed aliases: do not skip the alias query */ }
|
|
58
|
+
return { name: Number(row.name), alias: Number(alias), note: Number(row.note) };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve an entity by name or alias, then by substring of a canonical name,
|
|
63
|
+
* then by substring of its source note path.
|
|
64
|
+
*
|
|
65
|
+
* `filter`, when given, is applied inside every candidate query — the scope has
|
|
66
|
+
* to constrain the search, not the answer, or the search still reads the rows
|
|
67
|
+
* the caller may not see.
|
|
68
|
+
*/
|
|
69
|
+
function lookupEntity(db, name, filter) {
|
|
70
|
+
const where = filter ? ` AND (${filter.sql})` : '';
|
|
71
|
+
const params = filter ? filter.params : [];
|
|
72
|
+
// `lower` and NOCASE keep a string's length, and `instr` cannot match a needle longer than the haystack. The length
|
|
73
|
+
// is SQLite's: a JavaScript `.length` counts one code point above U+FFFF as two and would skip a real name.
|
|
74
|
+
const bounds = entityNameBounds(db);
|
|
75
|
+
const qlen = (0, statements_1.prepareOnce)(db, 'SELECT length(?) AS n').get(name).n;
|
|
76
|
+
const fits = (max) => qlen === null || qlen <= max;
|
|
77
|
+
if (!fits(bounds.name) && !fits(bounds.alias) && !fits(bounds.note))
|
|
78
|
+
return null;
|
|
79
|
+
if (fits(bounds.name)) {
|
|
80
|
+
const byName = (0, statements_1.prepareOnce)(db, `SELECT * FROM entities WHERE canonical_name = ? COLLATE NOCASE${where} ORDER BY ${tieOrder()} LIMIT 1`)
|
|
81
|
+
.get(name, ...params);
|
|
82
|
+
if (byName)
|
|
83
|
+
return toRow(byName);
|
|
84
|
+
}
|
|
85
|
+
// Unaliased, as in the other three queries, so a filter can name the row's own columns as `entities.<column>`.
|
|
86
|
+
if (fits(bounds.alias)) {
|
|
87
|
+
const byAlias = (0, statements_1.prepareOnce)(db, `SELECT entities.* FROM entities, json_each(entities.aliases_json) AS alias
|
|
88
|
+
WHERE lower(alias.value) = lower(?)${where}
|
|
89
|
+
ORDER BY ${tieOrder('entities.')}
|
|
90
|
+
LIMIT 1`)
|
|
91
|
+
.get(name, ...params);
|
|
92
|
+
if (byAlias)
|
|
93
|
+
return toRow(byAlias);
|
|
94
|
+
}
|
|
95
|
+
// Partial match: canonical_name contains the search term (e.g. "User" → "USER.md — About User")
|
|
96
|
+
if (fits(bounds.name)) {
|
|
97
|
+
const byPartial = (0, statements_1.prepareOnce)(db, `SELECT * FROM entities WHERE instr(lower(canonical_name), lower(?)) > 0${where} ORDER BY length(canonical_name) ASC, ${tieOrder()} LIMIT 1`)
|
|
98
|
+
.get(name, ...params);
|
|
99
|
+
if (byPartial)
|
|
100
|
+
return toRow(byPartial);
|
|
101
|
+
}
|
|
102
|
+
// Path match: source note path contains the search term (e.g. "Cora" → notes in .../Cora/...)
|
|
103
|
+
if (fits(bounds.note)) {
|
|
104
|
+
const byPath = (0, statements_1.prepareOnce)(db, `SELECT * FROM entities WHERE instr(lower(source_note), lower(?)) > 0${where} ORDER BY length(canonical_name) ASC, ${tieOrder()} LIMIT 1`)
|
|
105
|
+
.get(name, ...params);
|
|
106
|
+
if (byPath)
|
|
107
|
+
return toRow(byPath);
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
function toRow(r) {
|
|
112
|
+
return { id: r.id, type: r.type, canonicalName: r.canonical_name, aliases: JSON.parse(r.aliases_json ?? '[]'), sourceNote: r.source_note, confidence: r.confidence };
|
|
113
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.subjectsInQuery = subjectsInQuery;
|
|
4
|
+
exports.factsFirstHits = factsFirstHits;
|
|
5
|
+
exports.matchingCurrentFactHits = matchingCurrentFactHits;
|
|
6
|
+
const lifecycle_1 = require("../memory/lifecycle");
|
|
7
|
+
const queryHelpers_1 = require("./queryHelpers");
|
|
8
|
+
const factTerms_1 = require("../memory/factTerms");
|
|
9
|
+
const PREDICATE_CUES = {
|
|
10
|
+
current_project: [
|
|
11
|
+
'current project',
|
|
12
|
+
'currently building',
|
|
13
|
+
'currently working',
|
|
14
|
+
'working on',
|
|
15
|
+
'building now',
|
|
16
|
+
'what are you building',
|
|
17
|
+
'what am i building',
|
|
18
|
+
],
|
|
19
|
+
dog_name: ['dog name', "dog's name", 'dog'],
|
|
20
|
+
full_name: ['full name', 'legal name', 'real name'],
|
|
21
|
+
preferred_name: ['preferred name', 'go by', 'called', "what's my name", 'what is my name'],
|
|
22
|
+
location: ['where do i live', 'where does he live', 'lives in', 'live in', 'based in'],
|
|
23
|
+
college_degree: ['college', 'degree', 'university'],
|
|
24
|
+
hackathon_count: ['hackathon'],
|
|
25
|
+
primary_goal: ['primary goal', 'main goal', 'his mission', 'my mission'],
|
|
26
|
+
passport_italian_received: ['passport', 'citizenship'],
|
|
27
|
+
cofounder: ['cofounder', 'co-founder', 'co founder'],
|
|
28
|
+
};
|
|
29
|
+
/** Owner-named question cues: `{n}` is each configured owner name, so the same phrasings work for any user. */
|
|
30
|
+
const OWNER_CUES = {
|
|
31
|
+
current_project: ['what is {n} building', 'what is {n} currently'],
|
|
32
|
+
location: ['where does {n} live'],
|
|
33
|
+
preferred_name: ["{n}'s name"],
|
|
34
|
+
};
|
|
35
|
+
/** Letters and digits in any script: the boundary around a name, so `Al` is not found inside `also`. */
|
|
36
|
+
const NAME_EDGE_BEFORE = '(?<![\\p{L}\\p{N}])';
|
|
37
|
+
const NAME_EDGE_AFTER = '(?![\\p{L}\\p{N}])';
|
|
38
|
+
function escapeRegExp(s) {
|
|
39
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
40
|
+
}
|
|
41
|
+
/** `phrase` as a whole word or phrase in `q`, in any script. */
|
|
42
|
+
function hasPhrase(q, phrase) {
|
|
43
|
+
return new RegExp(`${NAME_EDGE_BEFORE}${escapeRegExp(phrase)}${NAME_EDGE_AFTER}`, 'u').test(q);
|
|
44
|
+
}
|
|
45
|
+
/** Broad "who is <owner> / who am I" — return a small current-fact card, not every fact. */
|
|
46
|
+
const IDENTITY_CARD = new Set([
|
|
47
|
+
'current_project', 'preferred_name', 'full_name', 'location', 'primary_goal', 'dog_name',
|
|
48
|
+
]);
|
|
49
|
+
function fold(s) {
|
|
50
|
+
return s.trim().toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
function firstPerson(query) {
|
|
53
|
+
return /\b(my|i'm|i am|i've|i'd|i|me)\b/i.test(query);
|
|
54
|
+
}
|
|
55
|
+
/** Subjects whose lifecycle facts should be considered for this query. */
|
|
56
|
+
function subjectsInQuery(query) {
|
|
57
|
+
const q = fold(query);
|
|
58
|
+
const out = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const add = (name) => {
|
|
61
|
+
const key = fold(name);
|
|
62
|
+
if (seen.has(key))
|
|
63
|
+
return;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
out.push(name);
|
|
66
|
+
};
|
|
67
|
+
const owner = (0, queryHelpers_1.ownerNames)();
|
|
68
|
+
if (owner.length) {
|
|
69
|
+
if (owner.some(n => hasPhrase(q, fold(n))))
|
|
70
|
+
add(owner[0]);
|
|
71
|
+
if (firstPerson(query))
|
|
72
|
+
add(owner[0]);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function isIdentityCardQuery(query) {
|
|
77
|
+
const q = fold(query);
|
|
78
|
+
const owner = (0, queryHelpers_1.ownerNames)();
|
|
79
|
+
const named = owner.length ? owner.map(n => escapeRegExp(fold(n))).join('|') : '';
|
|
80
|
+
if (named && new RegExp(`${NAME_EDGE_BEFORE}who is (${named})['’]s${NAME_EDGE_AFTER}`, 'u').test(q))
|
|
81
|
+
return false;
|
|
82
|
+
if (hasPhrase(q, 'who am i') || hasPhrase(q, 'tell me about me'))
|
|
83
|
+
return true;
|
|
84
|
+
return !!named && new RegExp(`${NAME_EDGE_BEFORE}(who is|who['’]s|tell me about|what should i know about) (${named})${NAME_EDGE_AFTER}`, 'u').test(q);
|
|
85
|
+
}
|
|
86
|
+
function queryMatchesFact(query, predicate) {
|
|
87
|
+
const q = fold(query);
|
|
88
|
+
if (isIdentityCardQuery(query) && IDENTITY_CARD.has(predicate))
|
|
89
|
+
return true;
|
|
90
|
+
const cues = PREDICATE_CUES[predicate] ?? [];
|
|
91
|
+
if (cues.some(c => q.includes(c)))
|
|
92
|
+
return true;
|
|
93
|
+
const names = (0, queryHelpers_1.ownerNames)().map(fold);
|
|
94
|
+
if ((OWNER_CUES[predicate] ?? []).some(t => names.some(n => hasPhrase(q, t.replace('{n}', n)))))
|
|
95
|
+
return true;
|
|
96
|
+
const pred = fold(predicate).replace(/_/g, ' ');
|
|
97
|
+
if (pred.length >= 3 && q.includes(pred))
|
|
98
|
+
return true;
|
|
99
|
+
const words = pred.split(/\s+/).filter(w => w.length >= 4);
|
|
100
|
+
if (words.length >= 2 && words.filter(w => q.includes(w)).length >= 2)
|
|
101
|
+
return true;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
function factText(subject, predicate, object) {
|
|
105
|
+
return `${subject}'s ${predicate} is ${object}.`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Lifecycle facts that answer a who/what identity question, as retrieval hits.
|
|
109
|
+
* Empty when the query is not about a known subject or no fact matches.
|
|
110
|
+
* Given `keep`, only the hits it shows, judged before the limit.
|
|
111
|
+
*/
|
|
112
|
+
function factsFirstHits(db, query, limit = 3, keep) {
|
|
113
|
+
const subjects = subjectsInQuery(query);
|
|
114
|
+
if (subjects.length === 0)
|
|
115
|
+
return [];
|
|
116
|
+
const hits = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const subject of subjects) {
|
|
119
|
+
const canonical = (0, queryHelpers_1.identityNames)(subject)[0] ?? subject;
|
|
120
|
+
const facts = (0, lifecycle_1.factsFor)(db, subject);
|
|
121
|
+
for (const f of facts) {
|
|
122
|
+
if (seen.has(f.id))
|
|
123
|
+
continue;
|
|
124
|
+
if (!queryMatchesFact(query, f.predicate))
|
|
125
|
+
continue;
|
|
126
|
+
seen.add(f.id);
|
|
127
|
+
hits.push({
|
|
128
|
+
notePath: f.source_path || '_mcp_',
|
|
129
|
+
// No chunk backs a fact: a negative id is the facts row, which mvp/readScope judges a scoped read by.
|
|
130
|
+
chunkId: -f.id,
|
|
131
|
+
headingPath: `${canonical} > ${f.predicate}`,
|
|
132
|
+
text: factText(canonical, f.predicate, f.object_text ?? ''),
|
|
133
|
+
score: 1.15,
|
|
134
|
+
reason: 'fact',
|
|
135
|
+
// Who saved the fact: the reader's view marks it (agentMemory, search tools), as it marks a fact read by its words.
|
|
136
|
+
author: f.author ?? null,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return (keep ? hits.filter(keep) : hits).slice(0, limit);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Query words that carry no subject or object, so a fact is never found by them alone: `what`, `is` and `the` are in
|
|
144
|
+
* nearly every fact's predicate or object, and a fact that shares only one of them is not an answer to the question.
|
|
145
|
+
* Deliberately small and English; a word is a stopword here only when no fact is about it.
|
|
146
|
+
*/
|
|
147
|
+
const FACT_STOPWORDS = new Set([
|
|
148
|
+
'a', 'about', 'all', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'been', 'but', 'by', 'can', 'could', 'did', 'do',
|
|
149
|
+
'does', 'for', 'from', 'had', 'has', 'have', 'how', 'i', 'if', 'in', 'into', 'is', 'it', 'its', 'me', 'my', 'no', 'not',
|
|
150
|
+
'of', 'on', 'or', 'our', 'out', 'so', 'some', 'tell', 'that', 'the', 'their', 'them', 'then', 'there', 'they', 'this',
|
|
151
|
+
'to', 'was', 'we', 'were', 'what', 'when', 'where', 'which', 'who', 'why', 'will', 'with', 'would', 'you', 'your',
|
|
152
|
+
]);
|
|
153
|
+
/** The fact-finding words of a query: its atoms without the stopwords. */
|
|
154
|
+
function contentAtoms(query) {
|
|
155
|
+
return (0, factTerms_1.atoms)(query).filter(word => !FACT_STOPWORDS.has(word));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Current facts matched by the query's content words, best first. A fact is admitted only when a content word is one of
|
|
159
|
+
* its subject's or object's words: a predicate word alone (`dog` in `pet: dog`'s predicate) is not enough. Its score is
|
|
160
|
+
* the share of the query's content words it has, from 0.5 to 1, so a fact ranks among the note hits by how well it
|
|
161
|
+
* answers the query, and the caller merges the two lists by score.
|
|
162
|
+
*/
|
|
163
|
+
function matchingCurrentFactHits(db, query, limit = 3) {
|
|
164
|
+
const terms = contentAtoms(query);
|
|
165
|
+
if (terms.length === 0 || limit < 1)
|
|
166
|
+
return [];
|
|
167
|
+
// Predicate words are in the index with the subject and object, so the index can rank a fact by a predicate word
|
|
168
|
+
// alone and crowd out a real match: read more candidates than the cap, then keep the admitted ones.
|
|
169
|
+
const matched = (0, factTerms_1.findCurrentFacts)(db, { terms, match: 'any', limit: Math.max(limit * 20, 60) });
|
|
170
|
+
const hits = [];
|
|
171
|
+
for (const f of matched) {
|
|
172
|
+
const subjectObject = new Set((0, factTerms_1.atoms)(`${f.subject} ${f.object ?? ''}`));
|
|
173
|
+
if (!terms.some(term => subjectObject.has(term)))
|
|
174
|
+
continue;
|
|
175
|
+
hits.push({
|
|
176
|
+
notePath: '_mcp_',
|
|
177
|
+
chunkId: -f.id,
|
|
178
|
+
headingPath: `${f.subject} > ${f.predicate}`,
|
|
179
|
+
text: factText(f.subject, f.predicate, f.object ?? ''),
|
|
180
|
+
score: 0.5 + 0.5 * (f.shared / terms.length),
|
|
181
|
+
reason: 'fact',
|
|
182
|
+
author: f.author ?? null,
|
|
183
|
+
});
|
|
184
|
+
if (hits.length === limit)
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
return hits;
|
|
188
|
+
}
|