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,456 @@
|
|
|
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.EvidenceExtractor = exports.EvidenceReplyError = exports.EVIDENCE_REASONING_EFFORTS = exports.EVIDENCE_SESSION_CHAR_CAP = exports.MAX_EVIDENCE_SESSIONS = exports.EVIDENCE_MODEL_DEFAULT = exports.EVIDENCE_PROMPT_TEMPLATE_SCOPED = exports.EVIDENCE_PROMPT_TEMPLATE_EXHAUSTIVE = exports.EVIDENCE_PROMPT_TEMPLATE = void 0;
|
|
7
|
+
exports.evidenceEnumerateMode = evidenceEnumerateMode;
|
|
8
|
+
exports.evidenceEnumerateEnabled = evidenceEnumerateEnabled;
|
|
9
|
+
exports.scoreBodyOverlap = scoreBodyOverlap;
|
|
10
|
+
exports.bodyRankEnabled = bodyRankEnabled;
|
|
11
|
+
exports.loadEvidenceSessionsFromVault = loadEvidenceSessionsFromVault;
|
|
12
|
+
exports.renderEvidencePrompt = renderEvidencePrompt;
|
|
13
|
+
exports.parseEvidenceItems = parseEvidenceItems;
|
|
14
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const parser_1 = require("../indexer/parser");
|
|
17
|
+
const evidenceCache_1 = require("./evidenceCache");
|
|
18
|
+
/**
|
|
19
|
+
* H10-v4 second-pass, question-conditioned evidence extraction (product path).
|
|
20
|
+
*
|
|
21
|
+
* Frozen mechanism (champion = 453/500 = 90.6%, docs/evals/results/
|
|
22
|
+
* H10-V4-FULL500-20260919.md): given a question and the sessions retrieval
|
|
23
|
+
* already selected, the extractor (gemma4:31b) lists every distinct relevant
|
|
24
|
+
* item/event/amount with dates. It never computes totals and never sees gold.
|
|
25
|
+
*
|
|
26
|
+
* Productization adds:
|
|
27
|
+
* - content-addressed caching (exact rendered prompt -> parsed items, and,
|
|
28
|
+
* for a day, a reply that was not a table: EVIDENCE_FAILURE_TTL_MS);
|
|
29
|
+
* - session selection matching the promoted arm (20 sessions, 6,000 chars/session);
|
|
30
|
+
* - gating (off | auto | always), default off (src/evidence/gate.ts).
|
|
31
|
+
*/
|
|
32
|
+
exports.EVIDENCE_PROMPT_TEMPLATE = `You extract evidence items from a user's dated conversation history.
|
|
33
|
+
|
|
34
|
+
QUESTION: {question}
|
|
35
|
+
|
|
36
|
+
Below are the sessions (with dates) that a memory system retrieved for this question.
|
|
37
|
+
|
|
38
|
+
TASK: List EVERY distinct item, event, or amount mentioned in the sessions that could be
|
|
39
|
+
relevant to answering the question. Rules:
|
|
40
|
+
- One entry per distinct item (e.g., each product, each trip, each device, each course, each amount).
|
|
41
|
+
- "action" = what happened (bought / visited / completed / raised / inherited / ...).
|
|
42
|
+
- "amount" = numeric value + unit when stated (e.g., "3 days", "$120", "100 points"); else "".
|
|
43
|
+
- "date" = the session/mention date when available; else "".
|
|
44
|
+
- Do NOT compute any totals. Do NOT merge different items. Include every candidate even if uncertain.
|
|
45
|
+
- Include items the question might count, even when the sessions mention them in passing.
|
|
46
|
+
|
|
47
|
+
SESSIONS:
|
|
48
|
+
{sessions}
|
|
49
|
+
|
|
50
|
+
Respond with ONLY minified JSON, no markdown:
|
|
51
|
+
{{"items":[{{"item":"...","action":"...","amount":"...","date":"..."}}]}}`;
|
|
52
|
+
/**
|
|
53
|
+
* EXHAUSTIVE enumeration variant of the evidence prompt (RENOR_EVIDENCE_ENUMERATE,
|
|
54
|
+
* default OFF — opt in with 1/true/yes/on).
|
|
55
|
+
*
|
|
56
|
+
* Why this exists, measured: the shipped prompt asks for items "that could be
|
|
57
|
+
* relevant to answering the question" — a relevance FILTER applied to an
|
|
58
|
+
* enumeration task. On the champion's 23 count/aggregate wrongs that filter is
|
|
59
|
+
* the binding constraint: the evidence table holds a mean of 5.3 items, and 13 of
|
|
60
|
+
* the 23 have FEWER items than the gold count requires. The count-contract arm
|
|
61
|
+
* proved the reader's arithmetic is correct — it lists 2 items and answers "2"
|
|
62
|
+
* for gold 3 — so the items are simply absent from the table it counts.
|
|
63
|
+
*
|
|
64
|
+
* Prompt-variant A/B on one case (gpt4_31ff4165, "How many health-related devices
|
|
65
|
+
* do I use in a day?", gold 4) at temperature 0:
|
|
66
|
+
*
|
|
67
|
+
* shipped prompt : 4 items, 134 completion tokens
|
|
68
|
+
* this variant : 27 items, 936 completion tokens
|
|
69
|
+
*
|
|
70
|
+
* The variant asks for everything mentioned and states that completeness is the
|
|
71
|
+
* only goal, that missing an item is a failure, and that including an irrelevant
|
|
72
|
+
* item is NOT a failure — the exact inverse of the shipped relevance filter.
|
|
73
|
+
*
|
|
74
|
+
* Scope discipline: this changes the EVIDENCE prompt only. The frozen
|
|
75
|
+
* judge/extractor model, the retrieval ranking, the schema, the item payload and
|
|
76
|
+
* the fact/chronicle/preference lanes are untouched. Default OFF, so shipped
|
|
77
|
+
* behaviour is byte-identical unless an eval arm opts in.
|
|
78
|
+
*/
|
|
79
|
+
exports.EVIDENCE_PROMPT_TEMPLATE_EXHAUSTIVE = `You extract a COMPLETE inventory of items from a user's dated conversation history.
|
|
80
|
+
|
|
81
|
+
QUESTION: {question}
|
|
82
|
+
|
|
83
|
+
Below are the sessions (with dates) that a memory system retrieved for this question.
|
|
84
|
+
|
|
85
|
+
TASK: List EVERY distinct item, device, object, purchase, place, person, activity, event or amount mentioned ANYWHERE in the sessions below — whether or not it appears relevant to the question. Completeness is the ONLY goal.
|
|
86
|
+
Rules:
|
|
87
|
+
- COMPLETENESS OVER BREVITY. If the sessions mention ten things, list all ten. Do not summarise, do not select, do not rank, do not judge relevance.
|
|
88
|
+
- Missing an item is a failure. Including an item that turns out to be irrelevant is NOT a failure.
|
|
89
|
+
- One entry per distinct item (e.g., each product, each device, each trip, each course, each amount).
|
|
90
|
+
- Do NOT merge different items. Do NOT compute any totals.
|
|
91
|
+
- "action" = what happened (bought / visited / completed / raised / inherited / used / ...).
|
|
92
|
+
- "amount" = numeric value + unit when stated (e.g., "3 days", "$120", "100 points"); else "".
|
|
93
|
+
- "date" = the session/mention date when available; else "".
|
|
94
|
+
|
|
95
|
+
SESSIONS:
|
|
96
|
+
{sessions}
|
|
97
|
+
|
|
98
|
+
Respond with ONLY minified JSON, no markdown:
|
|
99
|
+
{{"items":[{{"item":"...","action":"...","amount":"...","date":"..."}}]}}`;
|
|
100
|
+
function evidenceEnumerateMode(env = process.env) {
|
|
101
|
+
const raw = (env.RENOR_EVIDENCE_ENUMERATE ?? '').trim().toLowerCase();
|
|
102
|
+
if (raw === 'scoped')
|
|
103
|
+
return 'scoped';
|
|
104
|
+
if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on')
|
|
105
|
+
return 'all';
|
|
106
|
+
return 'off';
|
|
107
|
+
}
|
|
108
|
+
/** Back-compat boolean form (true for either enumeration mode). */
|
|
109
|
+
function evidenceEnumerateEnabled(env = process.env) {
|
|
110
|
+
return evidenceEnumerateMode(env) !== 'off';
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* SCOPED enumeration template: the model first names the entity type the question
|
|
114
|
+
* counts, then lists every instance of that type. Deliberately instructs the model
|
|
115
|
+
* to EXCLUDE the incidental magnitudes it is not being asked to count (steps per
|
|
116
|
+
* day, hours of sleep, battery pack sizes) — the exact mass that made `all`
|
|
117
|
+
* unusable. Gold-blind: the only input is the user's question and the retrieved
|
|
118
|
+
* sessions.
|
|
119
|
+
*/
|
|
120
|
+
exports.EVIDENCE_PROMPT_TEMPLATE_SCOPED = `You extract a COMPLETE inventory of one specific kind of entity from a user's dated conversation history.
|
|
121
|
+
|
|
122
|
+
QUESTION: {question}
|
|
123
|
+
|
|
124
|
+
Below are the sessions (with dates) that a memory system retrieved for this question.
|
|
125
|
+
|
|
126
|
+
STEP 1 — From the QUESTION, decide the single kind of thing being counted or totalled. Examples: "how many items of clothing" -> garments; "how many devices do I use" -> devices; "how many albums did I buy" -> albums; "how much did I spend in total" -> purchases with an amount; "how many properties did I view" -> properties.
|
|
127
|
+
|
|
128
|
+
STEP 2 — List EVERY instance of THAT kind found ANYWHERE in the sessions below. Completeness is the only goal.
|
|
129
|
+
Rules:
|
|
130
|
+
- Do NOT list incidental magnitudes of the same sentences. If counting devices, do NOT list "10,000 steps", "7-9 hours of sleep", "size 13 batteries" or "8-10 glasses of fluid" — those are not devices.
|
|
131
|
+
- Do NOT summarise, do NOT select, do NOT rank, do NOT judge relevance.
|
|
132
|
+
- Missing an instance of the counted kind is a failure. Including one that turns out to be irrelevant is NOT a failure.
|
|
133
|
+
- Keep each entry short: the entity itself, plus the amount/detail if the question is about an amount.
|
|
134
|
+
- Do NOT merge different instances. Do NOT compute any totals.
|
|
135
|
+
|
|
136
|
+
SESSIONS:
|
|
137
|
+
{sessions}
|
|
138
|
+
|
|
139
|
+
Respond with ONLY minified JSON, no markdown:
|
|
140
|
+
{{"items":[{{"item":"...","action":"...","amount":"...","date":"..."}}]}}`;
|
|
141
|
+
exports.EVIDENCE_MODEL_DEFAULT = 'gemma4:31b';
|
|
142
|
+
exports.MAX_EVIDENCE_SESSIONS = 20;
|
|
143
|
+
exports.EVIDENCE_SESSION_CHAR_CAP = 6000;
|
|
144
|
+
/** The `reasoning_effort` values OpenAI-compatible providers share. Which ones a model accepts is the provider's call. */
|
|
145
|
+
exports.EVIDENCE_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high'];
|
|
146
|
+
/**
|
|
147
|
+
* H9-family stop list (same shape as mvp/agentMemory.ts FTS_STOP_WORDS).
|
|
148
|
+
*/
|
|
149
|
+
const BODY_RANK_STOP_WORDS = new Set([
|
|
150
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'what', 'which', 'who', 'where', 'when',
|
|
151
|
+
'why', 'how', 'do', 'does', 'did', 'to', 'for', 'of', 'in', 'on', 'at', 'with', 'and',
|
|
152
|
+
'or', 'my', 'your', 'our', 'their', 'his', 'her', 'it', 'this', 'that', 'about',
|
|
153
|
+
]);
|
|
154
|
+
/** Unicode-aware lexical tokens: letter/number runs, 2..64 chars (H9 family). */
|
|
155
|
+
function bodyRankTokens(text) {
|
|
156
|
+
return (text.toLowerCase().match(/[\p{L}\p{N}]{2,64}/gu) ?? []).slice(0, 20000);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Deterministic, gold-blind lexical query-term overlap between a query and a
|
|
160
|
+
* candidate note body: total frequency of each distinct non-stop query term in
|
|
161
|
+
* the body. Zero model calls and zero embeddings at query time.
|
|
162
|
+
*/
|
|
163
|
+
function scoreBodyOverlap(query, body) {
|
|
164
|
+
const queryTerms = new Set(bodyRankTokens(query).filter(term => !BODY_RANK_STOP_WORDS.has(term)));
|
|
165
|
+
if (queryTerms.size === 0)
|
|
166
|
+
return 0;
|
|
167
|
+
const counts = new Map();
|
|
168
|
+
for (const token of bodyRankTokens(body))
|
|
169
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
170
|
+
let score = 0;
|
|
171
|
+
for (const term of queryTerms)
|
|
172
|
+
score += counts.get(term) ?? 0;
|
|
173
|
+
return score;
|
|
174
|
+
}
|
|
175
|
+
/** Opt-in kill-switch for body-relevance selection; default off. */
|
|
176
|
+
function bodyRankEnabled(env = process.env) {
|
|
177
|
+
const raw = (env.RENOR_EVIDENCE_BODY_RANK ?? '').trim().toLowerCase();
|
|
178
|
+
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Walk ranked hits as session identities, then hydrate each selected session
|
|
182
|
+
* from the complete source note. Retrieved chunk text is never extractor input.
|
|
183
|
+
* Unreadable/virtual/out-of-vault paths are skipped and later hits backfill.
|
|
184
|
+
*
|
|
185
|
+
* Default (RENOR_EVIDENCE_BODY_RANK off): the first maxSessions unique notes by
|
|
186
|
+
* encounter rank. With RENOR_EVIDENCE_BODY_RANK=1: every unique candidate note
|
|
187
|
+
* among the hits is hydrated (same ≤charCap truncation), scored by
|
|
188
|
+
* `scoreBodyOverlap`, and the top maxSessions are selected by
|
|
189
|
+
* (score desc, hit rank asc, notePath asc). Deterministic and gold-blind;
|
|
190
|
+
* per-session hydrated text and therefore extraction prompt bytes are
|
|
191
|
+
* unchanged, so the evidence content-addressed cache stays valid.
|
|
192
|
+
*/
|
|
193
|
+
async function loadEvidenceSessionsFromVault(hits, vaultPath, opts = {}) {
|
|
194
|
+
const maxSessions = opts.maxSessions ?? exports.MAX_EVIDENCE_SESSIONS;
|
|
195
|
+
const charCap = opts.charCap ?? exports.EVIDENCE_SESSION_CHAR_CAP;
|
|
196
|
+
if (maxSessions <= 0)
|
|
197
|
+
throw new Error('maxSessions must be > 0');
|
|
198
|
+
if (charCap <= 0)
|
|
199
|
+
throw new Error('charCap must be > 0');
|
|
200
|
+
const bodyRank = opts.bodyRank ?? bodyRankEnabled();
|
|
201
|
+
const root = node_path_1.default.resolve(vaultPath);
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
const candidates = [];
|
|
204
|
+
for (let hitRank = 0; hitRank < hits.length; hitRank++) {
|
|
205
|
+
const hit = hits[hitRank];
|
|
206
|
+
if (!hit.notePath || seen.has(hit.notePath))
|
|
207
|
+
continue;
|
|
208
|
+
seen.add(hit.notePath);
|
|
209
|
+
const candidate = node_path_1.default.isAbsolute(hit.notePath)
|
|
210
|
+
? node_path_1.default.resolve(hit.notePath)
|
|
211
|
+
: node_path_1.default.resolve(root, hit.notePath);
|
|
212
|
+
const rel = node_path_1.default.relative(root, candidate);
|
|
213
|
+
if (rel === '..' || rel.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(rel))
|
|
214
|
+
continue;
|
|
215
|
+
let raw;
|
|
216
|
+
try {
|
|
217
|
+
raw = await promises_1.default.readFile(candidate, 'utf8');
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const parsedBody = (0, parser_1.parseNote)(candidate, raw).body;
|
|
223
|
+
const body = opts.transformBody ? opts.transformBody(parsedBody) : parsedBody;
|
|
224
|
+
if (!body)
|
|
225
|
+
continue;
|
|
226
|
+
const text = body.length > charCap ? `${body.slice(0, charCap)} …[truncated]` : body;
|
|
227
|
+
candidates.push({ stem: hit.notePath, date: hit.session_date, text, hitRank });
|
|
228
|
+
if (!bodyRank && candidates.length >= maxSessions)
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
// Flag-off path must be byte-identical to shipped behavior — strip the
|
|
232
|
+
// internal hitRank before returning (review finding P2-1).
|
|
233
|
+
if (!bodyRank)
|
|
234
|
+
return candidates.map(({ hitRank, ...session }) => session);
|
|
235
|
+
return selectSessionsByBodyOverlap(opts.query ?? '', candidates).slice(0, maxSessions);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Body-relevance selection: score desc, then hit rank asc, then notePath asc.
|
|
239
|
+
* Fully deterministic; a zero-score query degrades to encounter order.
|
|
240
|
+
*/
|
|
241
|
+
function selectSessionsByBodyOverlap(query, candidates) {
|
|
242
|
+
const scored = candidates.map(candidate => ({
|
|
243
|
+
candidate,
|
|
244
|
+
score: scoreBodyOverlap(query, candidate.text),
|
|
245
|
+
}));
|
|
246
|
+
scored.sort((a, b) => b.score - a.score
|
|
247
|
+
|| a.candidate.hitRank - b.candidate.hitRank
|
|
248
|
+
|| (a.candidate.stem < b.candidate.stem ? -1 : a.candidate.stem > b.candidate.stem ? 1 : 0));
|
|
249
|
+
return scored.map(({ candidate }) => ({
|
|
250
|
+
stem: candidate.stem,
|
|
251
|
+
date: candidate.date,
|
|
252
|
+
text: candidate.text,
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
function sessionBlock(session) {
|
|
256
|
+
const date = session.date ?? '';
|
|
257
|
+
return `--- Session dated ${date} ---\n${session.text}`;
|
|
258
|
+
}
|
|
259
|
+
/** Exact prompt the extractor receives; the cache keys on this string. */
|
|
260
|
+
function renderEvidencePrompt(query, sessions) {
|
|
261
|
+
const mode = evidenceEnumerateMode();
|
|
262
|
+
const template = mode === 'scoped' ? exports.EVIDENCE_PROMPT_TEMPLATE_SCOPED
|
|
263
|
+
: mode === 'all' ? exports.EVIDENCE_PROMPT_TEMPLATE_EXHAUSTIVE
|
|
264
|
+
: exports.EVIDENCE_PROMPT_TEMPLATE;
|
|
265
|
+
return template
|
|
266
|
+
.replace('{question}', query)
|
|
267
|
+
.replace('{sessions}', sessions.map(sessionBlock).join('\n\n'));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The extractor answered, and its answer is not a table: it did not parse, or it stopped at the token limit. This is
|
|
271
|
+
* the one failure a search remembers (EVIDENCE_FAILURE_TTL_MS); an error before any answer is not. The message is the
|
|
272
|
+
* parse error's, so a caller that reads messages reads what it read before.
|
|
273
|
+
*/
|
|
274
|
+
class EvidenceReplyError extends Error {
|
|
275
|
+
}
|
|
276
|
+
exports.EvidenceReplyError = EvidenceReplyError;
|
|
277
|
+
/**
|
|
278
|
+
* Fail-fast parse: malformed items JSON throws; it is never cached.
|
|
279
|
+
*
|
|
280
|
+
* A reply the strict parse rejects is repaired for each malformed shape extractors were seen to write (2026-10-03
|
|
281
|
+
* benchmark runs; tests/fixtures/evidence-replies.json), and the first repair whose result is, whole, a JSON object
|
|
282
|
+
* with an items list gives the table. A well-formed object is found inside surrounding text, as it always was; a
|
|
283
|
+
* repaired one is not, because a sentence that only quotes malformed JSON (a note's copy of the prompt's example) is no
|
|
284
|
+
* table. A repair changes structure only, never a character inside a string, so no value is guessed. Any other reply
|
|
285
|
+
* still throws the strict parse's own error.
|
|
286
|
+
*/
|
|
287
|
+
function parseEvidenceItems(raw) {
|
|
288
|
+
const s = raw.trim().replace(/^```(?:json)?|```$/gm, '').trim();
|
|
289
|
+
try {
|
|
290
|
+
return parseItemsObject(s);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
for (const repaired of repairedReplies(s)) {
|
|
294
|
+
try {
|
|
295
|
+
return itemsFromPayload(JSON.parse(repaired));
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Not this shape.
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** A JSON string (or the one the reply ends inside), a run of `{` or a run of `}`: a brace inside a string is text. */
|
|
305
|
+
const STRING_OR_BRACE_RUN = /"(?:[^"\\]|\\[\s\S])*(?:"|$)|\{+|\}+/g;
|
|
306
|
+
function* repairedReplies(s) {
|
|
307
|
+
// The rows without the object around them: [{"item":...},...].
|
|
308
|
+
if (s.startsWith('['))
|
|
309
|
+
yield `{"items":${s}}`;
|
|
310
|
+
// The prompt's own example has doubled braces ({{"items":[{{...}}]}}), and models copy them, on every object or on
|
|
311
|
+
// the outer one only: a run of 2n braces outside strings stands for n.
|
|
312
|
+
const halved = s.replace(STRING_OR_BRACE_RUN, run => (run[0] === '"' || run.length % 2 === 1 ? run : run.slice(run.length / 2)));
|
|
313
|
+
if (halved !== s)
|
|
314
|
+
yield halved;
|
|
315
|
+
// A reply cut off inside the list (at the token limit): the rows before the cut, then the list and object closed.
|
|
316
|
+
// The cut row is dropped, never completed.
|
|
317
|
+
let end = -1;
|
|
318
|
+
for (const run of s.matchAll(STRING_OR_BRACE_RUN))
|
|
319
|
+
if (run[0][0] === '}')
|
|
320
|
+
end = run.index + run[0].length;
|
|
321
|
+
if (end > 0)
|
|
322
|
+
yield `${s.slice(0, end)}]}`;
|
|
323
|
+
}
|
|
324
|
+
function parseItemsObject(s) {
|
|
325
|
+
const m = s.match(/\{[\s\S]*\}/);
|
|
326
|
+
if (!m)
|
|
327
|
+
throw new Error('extractor response did not contain a JSON object');
|
|
328
|
+
let payload;
|
|
329
|
+
try {
|
|
330
|
+
payload = JSON.parse(m[0]);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
334
|
+
throw new Error(`extractor returned malformed JSON: ${detail}`);
|
|
335
|
+
}
|
|
336
|
+
return itemsFromPayload(payload);
|
|
337
|
+
}
|
|
338
|
+
function itemsFromPayload(payload) {
|
|
339
|
+
const items = payload.items;
|
|
340
|
+
if (!Array.isArray(items))
|
|
341
|
+
throw new Error('extractor JSON must contain an items list');
|
|
342
|
+
return items.map((rawItem, index) => {
|
|
343
|
+
if (!rawItem || typeof rawItem !== 'object' || Array.isArray(rawItem)) {
|
|
344
|
+
throw new Error(`extractor item ${index} must be an object`);
|
|
345
|
+
}
|
|
346
|
+
const it = rawItem;
|
|
347
|
+
const item = typeof it.item === 'string' ? it.item.trim() : '';
|
|
348
|
+
if (!item)
|
|
349
|
+
throw new Error(`extractor item ${index} must contain a non-empty item string`);
|
|
350
|
+
return {
|
|
351
|
+
item,
|
|
352
|
+
...(it.action !== undefined ? { action: String(it.action) } : {}),
|
|
353
|
+
...(it.amount !== undefined ? { amount: String(it.amount) } : {}),
|
|
354
|
+
...(it.date !== undefined ? { date: String(it.date) } : {}),
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Runs (or replays) the second pass. A cache hit performs no model call.
|
|
360
|
+
*/
|
|
361
|
+
class EvidenceExtractor {
|
|
362
|
+
chat;
|
|
363
|
+
cache;
|
|
364
|
+
model;
|
|
365
|
+
bounds;
|
|
366
|
+
constructor(opts) {
|
|
367
|
+
this.chat = opts.chat;
|
|
368
|
+
this.cache = opts.cache ?? null;
|
|
369
|
+
this.model = opts.model ?? exports.EVIDENCE_MODEL_DEFAULT;
|
|
370
|
+
this.bounds = opts.bounds ?? {};
|
|
371
|
+
}
|
|
372
|
+
/** The exact prompt the extractor receives for `query` over `sessions`, and the key its table is cached under. */
|
|
373
|
+
promptAndKey(query, sessions) {
|
|
374
|
+
const prompt = renderEvidencePrompt(query, sessions);
|
|
375
|
+
// A bound changes what the provider returns, so it is part of the cache
|
|
376
|
+
// identity: a bounded arm never replays an unbounded arm's table.
|
|
377
|
+
return { prompt, key: evidenceCache_1.EvidenceCache.computeKey(this.model, prompt, this.bounds) };
|
|
378
|
+
}
|
|
379
|
+
/** The key extract() caches `query` over `sessions` under, computed the one way extract() computes it. */
|
|
380
|
+
cacheKey(query, sessions) {
|
|
381
|
+
return this.promptAndKey(query, sessions).key;
|
|
382
|
+
}
|
|
383
|
+
/** The failure remembered under `key` (EVIDENCE_FAILURE_TTL_MS), or null. Never calls the model. */
|
|
384
|
+
replayFailure(key) {
|
|
385
|
+
return this.cache?.getFailure(key) ?? null;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Remembers that the extraction under `key` failed with `error`, so the same request does not pay for it again.
|
|
389
|
+
* A failure the cache cannot store is still reported, as it always was; only the next search pays again.
|
|
390
|
+
*/
|
|
391
|
+
rememberFailure(key, error) {
|
|
392
|
+
try {
|
|
393
|
+
this.cache?.putFailure(key, { model: this.model, error, failedAt: Date.now() });
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
process.stderr.write(`[evidence] could not remember a failed extraction: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** The extraction cached under `key`, or null. Never calls the model. */
|
|
400
|
+
replay(key) {
|
|
401
|
+
const cached = this.cache?.get(key) ?? null;
|
|
402
|
+
if (!cached)
|
|
403
|
+
return null;
|
|
404
|
+
return {
|
|
405
|
+
items: cached.items,
|
|
406
|
+
sessions: cached.sessions,
|
|
407
|
+
fromCache: true,
|
|
408
|
+
model: cached.model,
|
|
409
|
+
usage: { promptTokens: cached.promptTokens, completionTokens: cached.completionTokens },
|
|
410
|
+
latencyMs: 0,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
async extract(query, sessions) {
|
|
414
|
+
if (sessions.length === 0)
|
|
415
|
+
throw new Error('evidence extraction requires at least one session');
|
|
416
|
+
const { prompt, key } = this.promptAndKey(query, sessions);
|
|
417
|
+
const cached = this.replay(key);
|
|
418
|
+
if (cached)
|
|
419
|
+
return cached;
|
|
420
|
+
const res = await this.chat({
|
|
421
|
+
model: this.model,
|
|
422
|
+
messages: [{ role: 'user', content: prompt }],
|
|
423
|
+
temperature: 0,
|
|
424
|
+
...(this.bounds.maxTokens !== undefined ? { max_tokens: this.bounds.maxTokens } : {}),
|
|
425
|
+
...(this.bounds.reasoningEffort !== undefined ? { reasoning_effort: this.bounds.reasoningEffort } : {}),
|
|
426
|
+
});
|
|
427
|
+
let items;
|
|
428
|
+
try {
|
|
429
|
+
items = parseEvidenceItems(res.content);
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
433
|
+
// Same parse failure (same retry policy), but a reply cut at a token
|
|
434
|
+
// limit says so rather than reading as malformed by the model.
|
|
435
|
+
throw new EvidenceReplyError(res.finishReason === 'length'
|
|
436
|
+
? `extractor reply stopped at the token limit (finish_reason=length): ${detail}`
|
|
437
|
+
: detail);
|
|
438
|
+
}
|
|
439
|
+
const sessionMeta = sessions.map(s => ({ stem: s.stem, ...(s.date ? { date: s.date } : {}), chars: s.text.length }));
|
|
440
|
+
const usage = {
|
|
441
|
+
promptTokens: res.promptTokens ?? Math.ceil(prompt.length / 4),
|
|
442
|
+
completionTokens: res.completionTokens ?? Math.ceil(res.content.length / 4),
|
|
443
|
+
};
|
|
444
|
+
this.cache?.put(key, {
|
|
445
|
+
items,
|
|
446
|
+
sessions: sessionMeta,
|
|
447
|
+
model: this.model,
|
|
448
|
+
question: query,
|
|
449
|
+
promptTokens: usage.promptTokens,
|
|
450
|
+
completionTokens: usage.completionTokens,
|
|
451
|
+
createdAt: new Date().toISOString(),
|
|
452
|
+
});
|
|
453
|
+
return { items, sessions: sessionMeta, fromCache: false, model: this.model, usage, latencyMs: res.latencyMs };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
exports.EvidenceExtractor = EvidenceExtractor;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classifyCaptureEnabled = classifyCaptureEnabled;
|
|
4
|
+
exports.planCapture = planCapture;
|
|
5
|
+
/**
|
|
6
|
+
* What to do with a saved note, from the local-rules classifier only.
|
|
7
|
+
*
|
|
8
|
+
* RENOR_CLASSIFY_CAPTURE defaults OFF. Off means the live save path is unchanged: the note
|
|
9
|
+
* stays a raw note and nothing is classified. There is no model call, no new provider, and
|
|
10
|
+
* no API key. The local rules cannot honestly name a subject, predicate and object, so even
|
|
11
|
+
* when the flag is on this module only returns a draft. It does not write a fact.
|
|
12
|
+
*/
|
|
13
|
+
const decision_model_1 = require("./decision-model");
|
|
14
|
+
const DURABLE = new Set(['decision', 'preference', 'fact', 'result']);
|
|
15
|
+
function classifyCaptureEnabled(env = process.env) {
|
|
16
|
+
const raw = (env.RENOR_CLASSIFY_CAPTURE ?? '').trim().toLowerCase();
|
|
17
|
+
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
|
18
|
+
}
|
|
19
|
+
/** A draft only. `author` is the connection name plus "-auto", and nothing is written. */
|
|
20
|
+
function planCapture(text, author, env = process.env) {
|
|
21
|
+
if (!classifyCaptureEnabled(env))
|
|
22
|
+
return { disposition: 'off' };
|
|
23
|
+
const kind = (0, decision_model_1.classifyNote)(text);
|
|
24
|
+
if (kind === 'sensitive')
|
|
25
|
+
return { disposition: 'drop', kind };
|
|
26
|
+
if (!DURABLE.has(kind))
|
|
27
|
+
return { disposition: 'raw', kind: kind === 'request' ? 'request' : 'noise' };
|
|
28
|
+
return {
|
|
29
|
+
disposition: 'structured',
|
|
30
|
+
kind: kind,
|
|
31
|
+
statement: text.trim(),
|
|
32
|
+
author: `${author}-auto`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* DecisionModel adapter (Lane Jev write-path plan §3): the single seam for
|
|
4
|
+
* extraction-time typed decisions — capture-gate classification and dedupe
|
|
5
|
+
* adjudication. The ledger never depends on a vendor: the default TODAY is the
|
|
6
|
+
* deterministic local-rules implementation (`LocalRulesDecisionModel`, zero
|
|
7
|
+
* model calls, zero cost); a model-backed adapter (gemma4:31b, jev, ...) plugs
|
|
8
|
+
* in later by implementing this interface. No vendor is hardcoded anywhere in
|
|
9
|
+
* the write path.
|
|
10
|
+
*
|
|
11
|
+
* Contract shape (docs/evals/LANE-JEV-WRITE-PATH.md §3):
|
|
12
|
+
* decide(task, input) -> { decision: TypedEnum, confidence, latencyMs, costUSDCents }
|
|
13
|
+
*
|
|
14
|
+
* Privacy mode is part of the interface: the local implementation never sends
|
|
15
|
+
* text off-host, and costUSDCents is 0 for every local decision.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.LocalRulesDecisionModel = exports.STATIVE_CUE_RE = exports.NUMERIC_FACT_RE = void 0;
|
|
19
|
+
exports.classifyNote = classifyNote;
|
|
20
|
+
exports.defaultDecisionModel = defaultDecisionModel;
|
|
21
|
+
const secretScanner_1 = require("./secretScanner");
|
|
22
|
+
const rowStore_1 = require("../context/rowStore");
|
|
23
|
+
/**
|
|
24
|
+
* Lexical identity normalization for the dedupe verdict, shared with the
|
|
25
|
+
* rowStore dedupe key — this must be the SAME function the key is built from.
|
|
26
|
+
* It used to be a byte-copy of `normDedupeValue`, which is how it drifted into
|
|
27
|
+
* deleting every non-Latin character independently of the key pipeline; the two
|
|
28
|
+
* are now one import so a key and its adjudication verdict cannot disagree.
|
|
29
|
+
*/
|
|
30
|
+
const norm = rowStore_1.normDedupeValue;
|
|
31
|
+
/**
|
|
32
|
+
* v1 numeric-fact capture cues: a number (with optional unit) anchored to a
|
|
33
|
+
* stative or eventive predicate. Conservative and gold-blind — the rules are
|
|
34
|
+
* the current-baseline classification, not a quality claim (the Lane Jev
|
|
35
|
+
* adapter is the seam where a stronger classifier plugs in). Exported so the
|
|
36
|
+
* capture-gate rules and the candidate extractor share one definition.
|
|
37
|
+
*/
|
|
38
|
+
exports.NUMERIC_FACT_RE = /\b(\d+:\d{2}(?::\d{2})?|\d+(?:[.,]\d+)?)\s*(km|kg|mi|miles?|minutes?|mins?|hours?|hrs?|days?|weeks?|years?|%|percent|dollars?|usd|\$|€|£|reps?|lbs?|pounds?)?\b/i;
|
|
39
|
+
exports.STATIVE_CUE_RE = /\b(is|are|was|were|has|have|had|weighs?|costs?|paid|earned|runs?|ran|completed|finished|measures?|lasted|took|scored|deadline|birthday|anniversary|scheduled|due)\b/i;
|
|
40
|
+
/**
|
|
41
|
+
* Local rules DecisionModel — the default TODAY. Deterministic (same input →
|
|
42
|
+
* same decision), zero network, zero cost. Capture gate: a span is offered to
|
|
43
|
+
* fact extraction only when it carries a numeric value anchored by a cue
|
|
44
|
+
* verb/phrase; everything else gates to `none` in v1 (events/preferences are
|
|
45
|
+
* C3/C4 lanes). Dedupe: exact normalized (value, object) equality is
|
|
46
|
+
* `identical`; same object with a changed value is `updated`; otherwise
|
|
47
|
+
* `distinct`. Supersede is identity-keyed only — never blanket newest-wins
|
|
48
|
+
* (N18 KILL).
|
|
49
|
+
*
|
|
50
|
+
* Harmonization: the same adjudication semantics generalize per lane on the
|
|
51
|
+
* lane's own payload identity — event: same event + same `when` is
|
|
52
|
+
* `identical`, same event with a changed `when` is `updated`; preference:
|
|
53
|
+
* same normalized statement is `identical` (statement IS the identity),
|
|
54
|
+
* anything else `distinct`. The rowKind default keeps the v1 fact-lane
|
|
55
|
+
* callers (and their tests) byte-identical.
|
|
56
|
+
*/
|
|
57
|
+
const DECISION_RE = /\b(decided|decision|we will|we'll|going with|chose|chosen|the call is|agreed to)\b/i;
|
|
58
|
+
const PREFERENCE_RE = /\b(prefer|preference|rather|always use|never use|don't use|do not use|instead of)\b/i;
|
|
59
|
+
const RESULT_RE = /\b(passed|failed|shipped|the result|finished|deployed|merged)\b/i;
|
|
60
|
+
const REQUEST_RE = /\b(please|can you|could you|would you|need you to|let me know|ask [a-z0-9_-]+ to)\b/i;
|
|
61
|
+
const NOISE_LEAD_RE = /^(ok|okay|thanks|thank you|lol|hey|on it|looking|got it|sure|yep|nope)\b/i;
|
|
62
|
+
const NOISE_CHATTER_RE = /\b(thanks|thank you|lol)\b/i;
|
|
63
|
+
const COPULA_RE = /\b(is|are|was|were)\b/i;
|
|
64
|
+
/**
|
|
65
|
+
* Cue order is fixed: a decision, preference or result is named before a question mark can
|
|
66
|
+
* steal it, and a numeric fact (the existing capture gate) is named before a broad "is".
|
|
67
|
+
* Anything else is noise. Sensitive text is refused before any of those.
|
|
68
|
+
*/
|
|
69
|
+
function classifyNote(text) {
|
|
70
|
+
const trimmed = text.trim();
|
|
71
|
+
if (!trimmed)
|
|
72
|
+
return 'noise';
|
|
73
|
+
if ((0, secretScanner_1.scanSecrets)(trimmed))
|
|
74
|
+
return 'sensitive';
|
|
75
|
+
if (DECISION_RE.test(trimmed))
|
|
76
|
+
return 'decision';
|
|
77
|
+
if (PREFERENCE_RE.test(trimmed))
|
|
78
|
+
return 'preference';
|
|
79
|
+
if (RESULT_RE.test(trimmed))
|
|
80
|
+
return 'result';
|
|
81
|
+
if (LocalRulesDecisionModel.captureGate(trimmed) === 'fact')
|
|
82
|
+
return 'fact';
|
|
83
|
+
if (REQUEST_RE.test(trimmed) || /\?\s*$/.test(trimmed))
|
|
84
|
+
return 'request';
|
|
85
|
+
if (NOISE_LEAD_RE.test(trimmed) || NOISE_CHATTER_RE.test(trimmed))
|
|
86
|
+
return 'noise';
|
|
87
|
+
const words = trimmed.split(/\s+/).length;
|
|
88
|
+
if (COPULA_RE.test(trimmed) && words >= 4)
|
|
89
|
+
return 'fact';
|
|
90
|
+
return 'noise';
|
|
91
|
+
}
|
|
92
|
+
class LocalRulesDecisionModel {
|
|
93
|
+
async decide(request) {
|
|
94
|
+
const start = Date.now();
|
|
95
|
+
let result;
|
|
96
|
+
if (request.task === 'capture_gate') {
|
|
97
|
+
result = {
|
|
98
|
+
task: 'capture_gate',
|
|
99
|
+
decision: LocalRulesDecisionModel.captureGate(request.text),
|
|
100
|
+
confidence: 1,
|
|
101
|
+
latencyMs: 0,
|
|
102
|
+
costUSDCents: 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
else if (request.task === 'classify_capture') {
|
|
106
|
+
result = {
|
|
107
|
+
task: 'classify_capture',
|
|
108
|
+
decision: LocalRulesDecisionModel.classifyNote(request.text),
|
|
109
|
+
confidence: 1,
|
|
110
|
+
latencyMs: 0,
|
|
111
|
+
costUSDCents: 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
result = {
|
|
116
|
+
task: 'dedupe_adjudication',
|
|
117
|
+
decision: LocalRulesDecisionModel.adjudicate(request.candidate, request.existing, request.rowKind ?? 'fact'),
|
|
118
|
+
confidence: 1,
|
|
119
|
+
latencyMs: 0,
|
|
120
|
+
costUSDCents: 0,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// latencyMs is reported (measured wall time), never simulated.
|
|
124
|
+
const latencyMs = Date.now() - start;
|
|
125
|
+
return { ...result, latencyMs };
|
|
126
|
+
}
|
|
127
|
+
static captureGate(text) {
|
|
128
|
+
if (!text || !text.trim())
|
|
129
|
+
return 'none';
|
|
130
|
+
const hasValue = exports.NUMERIC_FACT_RE.test(text);
|
|
131
|
+
const hasCue = exports.STATIVE_CUE_RE.test(text);
|
|
132
|
+
if (hasValue && hasCue)
|
|
133
|
+
return 'fact';
|
|
134
|
+
return 'none';
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Local rules for a saved note. Same constraints as the rest of this class: deterministic,
|
|
138
|
+
* no network, no key, cost 0. This is not a model. It does not invent a subject, predicate
|
|
139
|
+
* or object, and nothing here writes a memory.
|
|
140
|
+
*/
|
|
141
|
+
static classifyNote(text) {
|
|
142
|
+
return classifyNote(text);
|
|
143
|
+
}
|
|
144
|
+
static adjudicate(candidate, existing, rowKind = 'fact') {
|
|
145
|
+
if (!existing)
|
|
146
|
+
return 'distinct';
|
|
147
|
+
if (rowKind === 'event') {
|
|
148
|
+
const c = candidate;
|
|
149
|
+
const e = existing;
|
|
150
|
+
if (norm(c.event) !== norm(e.event))
|
|
151
|
+
return 'distinct';
|
|
152
|
+
return norm(c.when) === norm(e.when) ? 'identical' : 'updated';
|
|
153
|
+
}
|
|
154
|
+
if (rowKind === 'preference') {
|
|
155
|
+
const c = candidate;
|
|
156
|
+
const e = existing;
|
|
157
|
+
return norm(c.statement) === norm(e.statement) ? 'identical' : 'distinct';
|
|
158
|
+
}
|
|
159
|
+
const c = candidate;
|
|
160
|
+
const e = existing;
|
|
161
|
+
const sameObject = norm(c.object) === norm(e.object);
|
|
162
|
+
const sameValue = norm(c.value) === norm(e.value);
|
|
163
|
+
if (sameObject && sameValue)
|
|
164
|
+
return 'identical';
|
|
165
|
+
if (sameObject && !sameValue)
|
|
166
|
+
return 'updated';
|
|
167
|
+
return 'distinct';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
exports.LocalRulesDecisionModel = LocalRulesDecisionModel;
|
|
171
|
+
/** The write path's default decision point — local rules today, vendor later. */
|
|
172
|
+
function defaultDecisionModel() {
|
|
173
|
+
return new LocalRulesDecisionModel();
|
|
174
|
+
}
|