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,98 @@
|
|
|
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.ensureOwnerOnlyMemoryDir = ensureOwnerOnlyMemoryDir;
|
|
7
|
+
exports.ensureOwnerOnlyFile = ensureOwnerOnlyFile;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
/**
|
|
11
|
+
* The memory directory holds the user's statements in plain text (daily notes, the ledger) and the index built
|
|
12
|
+
* from them, so it is owner-only, as the OAuth client store already is. A directory the running user owns that
|
|
13
|
+
* group or others can reach is tightened at start (0700 for directories, 0600 for files, one log line); one that
|
|
14
|
+
* cannot be tightened, because someone else owns it or the change is refused, stops the start.
|
|
15
|
+
*/
|
|
16
|
+
function failClosed(dir, reason) {
|
|
17
|
+
throw new Error(`Memory directory is not usable: ${reason}. `
|
|
18
|
+
+ 'Refusing to start with a memory directory other users can read (fail closed). '
|
|
19
|
+
+ `Run: chmod -R go-rwx ${JSON.stringify(dir)} as its owner, then start again.`);
|
|
20
|
+
}
|
|
21
|
+
const accessibleByOthers = (mode) => (mode & 0o077) !== 0;
|
|
22
|
+
/** Tightens what is owned by `uid` under `dir`, without following symlinks; what someone else owns is left alone. */
|
|
23
|
+
function tightenTree(dir, uid) {
|
|
24
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
+
const full = path_1.default.join(dir, entry.name);
|
|
26
|
+
if (entry.isSymbolicLink())
|
|
27
|
+
continue;
|
|
28
|
+
const stat = fs_1.default.lstatSync(full);
|
|
29
|
+
if (stat.uid !== uid)
|
|
30
|
+
continue;
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
if (accessibleByOthers(stat.mode))
|
|
33
|
+
fs_1.default.chmodSync(full, 0o700);
|
|
34
|
+
tightenTree(full, uid);
|
|
35
|
+
}
|
|
36
|
+
else if (entry.isFile() && accessibleByOthers(stat.mode)) {
|
|
37
|
+
fs_1.default.chmodSync(full, 0o600);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Creates the directory owner-only (0700) when it does not exist. One that exists must be a directory owned by the
|
|
43
|
+
* running user; if group or others can access it, it is tightened (everything under it that the user owns too) and
|
|
44
|
+
* `log` is told once. Symlinked paths are followed (the directory may live on another disk).
|
|
45
|
+
*/
|
|
46
|
+
function ensureOwnerOnlyMemoryDir(dir, log = console.log) {
|
|
47
|
+
try {
|
|
48
|
+
fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
// A file at the path is reported below as what it is.
|
|
52
|
+
if (err.code !== 'EEXIST')
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
const uid = process.getuid?.();
|
|
56
|
+
// No POSIX owner to compare on this platform; the client store refuses to run there as well.
|
|
57
|
+
if (uid === undefined)
|
|
58
|
+
return;
|
|
59
|
+
let stat;
|
|
60
|
+
try {
|
|
61
|
+
stat = fs_1.default.statSync(dir);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
failClosed(dir, `it is unreadable (${err.code})`);
|
|
65
|
+
}
|
|
66
|
+
if (!stat.isDirectory())
|
|
67
|
+
failClosed(dir, 'the path is not a directory');
|
|
68
|
+
if (stat.uid !== uid)
|
|
69
|
+
failClosed(dir, 'it is not owned by the service user, so it cannot be tightened to owner-only (0700)');
|
|
70
|
+
if (!accessibleByOthers(stat.mode))
|
|
71
|
+
return;
|
|
72
|
+
const was = (stat.mode & 0o777).toString(8);
|
|
73
|
+
try {
|
|
74
|
+
fs_1.default.chmodSync(dir, 0o700);
|
|
75
|
+
tightenTree(dir, uid);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
failClosed(dir, `it is accessible by group or others (mode ${was}) and cannot be tightened to owner-only (0700) (${err.code})`);
|
|
79
|
+
}
|
|
80
|
+
log(`[mvp] memory directory ${dir} was accessible by group or others (mode ${was}); tightened to owner-only: directories 0700, files 0600`);
|
|
81
|
+
}
|
|
82
|
+
/** A database file that exists and group or others can reach is tightened to 0600, with its -wal and -shm files. */
|
|
83
|
+
function ensureOwnerOnlyFile(file) {
|
|
84
|
+
const uid = process.getuid?.();
|
|
85
|
+
if (uid === undefined)
|
|
86
|
+
return;
|
|
87
|
+
for (const candidate of [file, `${file}-wal`, `${file}-shm`]) {
|
|
88
|
+
let stat;
|
|
89
|
+
try {
|
|
90
|
+
stat = fs_1.default.lstatSync(candidate);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (stat.isFile() && stat.uid === uid && accessibleByOthers(stat.mode))
|
|
96
|
+
fs_1.default.chmodSync(candidate, 0o600);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.redactMcpAnalyticsEvent = redactMcpAnalyticsEvent;
|
|
4
|
+
exports.instrumentMcpServer = instrumentMcpServer;
|
|
5
|
+
exports.shutdownPosthogMcp = shutdownPosthogMcp;
|
|
6
|
+
exports.resetPosthogMcpForTests = resetPosthogMcpForTests;
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const telemetry_1 = require("./telemetry");
|
|
9
|
+
/**
|
|
10
|
+
* PostHog MCP Analytics (@posthog/mcp) for the production MVP server.
|
|
11
|
+
*
|
|
12
|
+
* Privacy defaults match Renor's contract: never capture tool args, results,
|
|
13
|
+
* memory content, queries, or model/conversation text. Instrumentation is a
|
|
14
|
+
* no-op when RENOR_POSTHOG_KEY is missing or RENOR_TELEMETRY_DISABLED=true.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_HOST = 'https://us.i.posthog.com';
|
|
17
|
+
// The two packages are ~130 modules and ~110 ms of a cold start (measured with scripts/bench/cold-start.ts), and a
|
|
18
|
+
// workspace without RENOR_POSTHOG_KEY, or with remote telemetry off, never calls into them (instrumentMcpServer returns
|
|
19
|
+
// first). They are loaded here, on the first call that needs them, which is the same moment a configured workspace
|
|
20
|
+
// loaded them before: createMvpApp builds its first server before the service listens.
|
|
21
|
+
const loadPosthogNode = () => require('posthog-node');
|
|
22
|
+
const loadPosthogMcp = () => require('@posthog/mcp');
|
|
23
|
+
/** Property keys that may carry args/results/content — always stripped. */
|
|
24
|
+
const STRIP_EXACT = new Set([
|
|
25
|
+
'$mcp_parameters',
|
|
26
|
+
'$mcp_response',
|
|
27
|
+
'$mcp_intent',
|
|
28
|
+
'$mcp_error_message',
|
|
29
|
+
'$mcp_feedback_details',
|
|
30
|
+
'$mcp_feedback_summary',
|
|
31
|
+
'$mcp_feedback_suggested_improvement',
|
|
32
|
+
'$mcp_feedback_friction_points',
|
|
33
|
+
'$mcp_tool_description',
|
|
34
|
+
'args',
|
|
35
|
+
'arguments',
|
|
36
|
+
'params',
|
|
37
|
+
'parameters',
|
|
38
|
+
'result',
|
|
39
|
+
'response',
|
|
40
|
+
'content',
|
|
41
|
+
'query',
|
|
42
|
+
'statement',
|
|
43
|
+
'text',
|
|
44
|
+
'body',
|
|
45
|
+
'message',
|
|
46
|
+
]);
|
|
47
|
+
const STRIP_SUBSTRING = [
|
|
48
|
+
'parameter',
|
|
49
|
+
'argument',
|
|
50
|
+
'response',
|
|
51
|
+
'content',
|
|
52
|
+
'query',
|
|
53
|
+
'statement',
|
|
54
|
+
'memory',
|
|
55
|
+
'prompt',
|
|
56
|
+
'exception_message',
|
|
57
|
+
'exception_stack',
|
|
58
|
+
];
|
|
59
|
+
let client;
|
|
60
|
+
let shutdownHookInstalled = false;
|
|
61
|
+
function env(name) {
|
|
62
|
+
return (process.env[name] ?? '').trim();
|
|
63
|
+
}
|
|
64
|
+
function remoteDisabled() {
|
|
65
|
+
return env('RENOR_TELEMETRY_DISABLED').toLowerCase() === 'true';
|
|
66
|
+
}
|
|
67
|
+
function pseudonymousDistinctId(instance) {
|
|
68
|
+
const salt = env('RENOR_TELEMETRY_HASH_SALT');
|
|
69
|
+
return (0, node_crypto_1.createHash)('sha256')
|
|
70
|
+
.update(`renor-telemetry:\0${salt}\0${instance}`)
|
|
71
|
+
.digest('hex')
|
|
72
|
+
.slice(0, 24);
|
|
73
|
+
}
|
|
74
|
+
/** Testable privacy filter — strips args/results/content; keeps tool/ok/duration. */
|
|
75
|
+
function redactMcpAnalyticsEvent(event) {
|
|
76
|
+
if (!event || typeof event !== 'object')
|
|
77
|
+
return null;
|
|
78
|
+
// Exception siblings can embed stack/message text — drop entirely.
|
|
79
|
+
if (event.event === '$exception')
|
|
80
|
+
return null;
|
|
81
|
+
const properties = { ...(event.properties ?? {}) };
|
|
82
|
+
for (const key of Object.keys(properties)) {
|
|
83
|
+
const lower = key.toLowerCase();
|
|
84
|
+
if (STRIP_EXACT.has(key) || STRIP_EXACT.has(lower)) {
|
|
85
|
+
delete properties[key];
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (STRIP_SUBSTRING.some((needle) => lower.includes(needle))) {
|
|
89
|
+
// Keep known-safe metadata keys even if they contain a substring match.
|
|
90
|
+
if (key === '$mcp_is_error' ||
|
|
91
|
+
key === '$mcp_duration_ms' ||
|
|
92
|
+
key === '$mcp_tool_name' ||
|
|
93
|
+
key === '$mcp_client_name' ||
|
|
94
|
+
key === '$mcp_client_version' ||
|
|
95
|
+
key === '$mcp_server_name' ||
|
|
96
|
+
key === '$mcp_server_version' ||
|
|
97
|
+
key === '$mcp_protocol_version' ||
|
|
98
|
+
key === '$mcp_listed_tool_names' ||
|
|
99
|
+
key === '$mcp_source' ||
|
|
100
|
+
key === '$groups' ||
|
|
101
|
+
key === '$process_person_profile' ||
|
|
102
|
+
key === 'service') {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
delete properties[key];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
distinct_id: event.distinct_id,
|
|
110
|
+
event: event.event,
|
|
111
|
+
timestamp: event.timestamp,
|
|
112
|
+
type: 'capture',
|
|
113
|
+
properties,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function getOrCreateClient() {
|
|
117
|
+
if (client !== undefined)
|
|
118
|
+
return client;
|
|
119
|
+
if (remoteDisabled()) {
|
|
120
|
+
client = null;
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const apiKey = env('RENOR_POSTHOG_KEY');
|
|
124
|
+
if (!apiKey) {
|
|
125
|
+
client = null;
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const host = env('RENOR_POSTHOG_HOST') || DEFAULT_HOST;
|
|
129
|
+
try {
|
|
130
|
+
client = new (loadPosthogNode().PostHog)(apiKey, {
|
|
131
|
+
host,
|
|
132
|
+
// Privacy: do not capture IP / personal profiles by default.
|
|
133
|
+
disableGeoip: true,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
client = null;
|
|
138
|
+
}
|
|
139
|
+
return client;
|
|
140
|
+
}
|
|
141
|
+
function installShutdownHook() {
|
|
142
|
+
if (shutdownHookInstalled)
|
|
143
|
+
return;
|
|
144
|
+
shutdownHookInstalled = true;
|
|
145
|
+
// Best-effort: cli.ts also awaits shutdownPosthogMcp on SIGINT/SIGTERM.
|
|
146
|
+
// This covers process paths that skip the CLI handler.
|
|
147
|
+
const onSignal = () => {
|
|
148
|
+
void shutdownPosthogMcp();
|
|
149
|
+
};
|
|
150
|
+
process.once('beforeExit', onSignal);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Instrument one McpServer instance (call inside buildMcpServer — SSE builds
|
|
154
|
+
* a fresh server per session). No-ops when PostHog is disabled/unconfigured.
|
|
155
|
+
*/
|
|
156
|
+
function instrumentMcpServer(server) {
|
|
157
|
+
try {
|
|
158
|
+
const posthog = getOrCreateClient();
|
|
159
|
+
if (!posthog)
|
|
160
|
+
return;
|
|
161
|
+
const tenant = (0, telemetry_1.telemetryInstanceId)();
|
|
162
|
+
const distinctId = pseudonymousDistinctId(tenant);
|
|
163
|
+
loadPosthogMcp().instrument(server, posthog, {
|
|
164
|
+
context: false,
|
|
165
|
+
enableConversationId: false,
|
|
166
|
+
captureModel: false,
|
|
167
|
+
enableExceptionAutocapture: false,
|
|
168
|
+
identify: async () => ({
|
|
169
|
+
distinctId,
|
|
170
|
+
// The workspace as PostHog's `tenant` group, filed under the same salted hash as the distinct id and
|
|
171
|
+
// never under the slug: a slug made before opaque workspace names is the owner's email local part.
|
|
172
|
+
groups: tenant && tenant !== 'unknown' ? { tenant: distinctId } : undefined,
|
|
173
|
+
properties: {
|
|
174
|
+
service: 'renor-core',
|
|
175
|
+
$process_person_profile: false,
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
eventProperties: async () => ({
|
|
179
|
+
service: 'renor-core',
|
|
180
|
+
$process_person_profile: false,
|
|
181
|
+
}),
|
|
182
|
+
beforeSend: redactMcpAnalyticsEvent,
|
|
183
|
+
logger: () => {
|
|
184
|
+
// STDIO-safe: discard SDK warnings (HTTP MVP uses stderr only if needed).
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
installShutdownHook();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Observability must never break the product.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Flush + stop the shared posthog-node client (SIGINT/SIGTERM). */
|
|
194
|
+
async function shutdownPosthogMcp() {
|
|
195
|
+
const current = client;
|
|
196
|
+
client = null;
|
|
197
|
+
if (!current)
|
|
198
|
+
return;
|
|
199
|
+
try {
|
|
200
|
+
await current.shutdown();
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// ignore
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Test helper: reset singleton between hermetic tests. */
|
|
207
|
+
function resetPosthogMcpForTests() {
|
|
208
|
+
client = undefined;
|
|
209
|
+
shutdownHookInstalled = false;
|
|
210
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createReadScope = createReadScope;
|
|
4
|
+
const readCache_1 = require("../db/readCache");
|
|
5
|
+
const statements_1 = require("../db/statements");
|
|
6
|
+
const lifecycle_1 = require("../memory/lifecycle");
|
|
7
|
+
const ledgerLiveness_1 = require("../retrieval/ledgerLiveness");
|
|
8
|
+
const scope_1 = require("./scope");
|
|
9
|
+
/** How many chunks naming an event make reading every event's scope at once cheaper than asking about each. */
|
|
10
|
+
const PRELOAD_EVENTS_FROM = 64;
|
|
11
|
+
function createReadScope(db, scope) {
|
|
12
|
+
const projected = (0, readCache_1.tableHasColumns)(db, 'facts', ['event_id', 'scope']);
|
|
13
|
+
const eventRow = projected
|
|
14
|
+
? (0, statements_1.prepareOnce)(db, `SELECT COALESCE(scope, 'global') AS scope FROM facts WHERE event_id = ? AND source_path = ?`)
|
|
15
|
+
: null;
|
|
16
|
+
const factRow = projected
|
|
17
|
+
? (0, statements_1.prepareOnce)(db, `SELECT COALESCE(scope, 'global') AS scope FROM facts WHERE id = ? AND source_path = ?`)
|
|
18
|
+
: null;
|
|
19
|
+
const own = (0, scope_1.savedScopeSqlPredicate)(scope, `COALESCE(scope, 'global')`);
|
|
20
|
+
const otherScopeRows = projected
|
|
21
|
+
? (0, statements_1.prepareOnce)(db, `SELECT event_id FROM facts WHERE source_path = ? AND event_id IS NOT NULL AND NOT (${own.sql})`)
|
|
22
|
+
: null;
|
|
23
|
+
// Event ids are stored upper-case, so the unique index on facts(event_id) answers each lookup.
|
|
24
|
+
const events = new Map();
|
|
25
|
+
/** The scope an event was saved in; undefined when the lifecycle layer knows no such event. */
|
|
26
|
+
const eventScope = (id) => {
|
|
27
|
+
const key = id.toUpperCase();
|
|
28
|
+
if (!events.has(key))
|
|
29
|
+
events.set(key, eventRow?.get(key, lifecycle_1.LIFECYCLE_NOTE_PATH)?.scope);
|
|
30
|
+
return events.get(key);
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Every event's scope in one read, for the pass over the index (`chunkIds`) that asks about the event of each of its
|
|
34
|
+
* thousands of ledger chunks: one statement each was most of a scoped read's cost, growing with every fact saved. What
|
|
35
|
+
* is kept is what `eventScope` would find: the event of that upper-case id, in the lifecycle layer. An id that is not
|
|
36
|
+
* kept (not upper-case, or not an event) is asked about one by one, as before.
|
|
37
|
+
*/
|
|
38
|
+
const preloadEvents = () => {
|
|
39
|
+
if (!projected)
|
|
40
|
+
return;
|
|
41
|
+
const rows = (0, statements_1.prepareOnce)(db, `SELECT event_id AS id, COALESCE(scope, 'global') AS scope FROM facts WHERE source_path = ? AND event_id IS NOT NULL`).all(lifecycle_1.LIFECYCLE_NOTE_PATH);
|
|
42
|
+
for (const row of rows)
|
|
43
|
+
if (row.id === row.id.toUpperCase() && !events.has(row.id))
|
|
44
|
+
events.set(row.id, row.scope);
|
|
45
|
+
};
|
|
46
|
+
let elsewhere; // ids (lower-case) of the events saved in other scopes, read on first use
|
|
47
|
+
const has = (hit, notePath) => {
|
|
48
|
+
// A fact has no note path to judge it by: the scope on its row decides, and a fact whose row is gone is dropped.
|
|
49
|
+
if (hit.notePath === lifecycle_1.LIFECYCLE_NOTE_PATH && typeof hit.chunkId === 'number' && hit.chunkId < 0) {
|
|
50
|
+
const row = factRow?.get(-hit.chunkId, lifecycle_1.LIFECYCLE_NOTE_PATH);
|
|
51
|
+
return row !== undefined && (0, scope_1.savedScopeMatches)(row.scope, scope);
|
|
52
|
+
}
|
|
53
|
+
// A hit is one chunk, so one event (the chunker splits at every event heading): shown whole, or not at all.
|
|
54
|
+
// Anything that only looks like an event id (an ordinary heading) is no event, and its note path decides.
|
|
55
|
+
const saved = (0, ledgerLiveness_1.ledgerEventIds)(hit).map(eventScope).filter((s) => s !== undefined);
|
|
56
|
+
if (saved.length)
|
|
57
|
+
return saved.every(s => (0, scope_1.savedScopeMatches)(s, scope));
|
|
58
|
+
return (0, scope_1.notePathMatchesScope)(notePath, scope);
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
has,
|
|
62
|
+
hits(hits, notePath) {
|
|
63
|
+
return hits.filter(hit => has(hit, notePath(hit)));
|
|
64
|
+
},
|
|
65
|
+
body(text) {
|
|
66
|
+
// Only the ledger holds events: any other note is returned as it is, without asking the database anything.
|
|
67
|
+
if (!(0, ledgerLiveness_1.ledgerEventIds)({ text }).length)
|
|
68
|
+
return text;
|
|
69
|
+
// A body holds every event, so the ids of other scopes' events are read in one query, once per read, rather than
|
|
70
|
+
// looked up event by event (a 3,000-event ledger: body() 19 ms, now 6 ms).
|
|
71
|
+
elsewhere ??= new Set((otherScopeRows?.all(lifecycle_1.LIFECYCLE_NOTE_PATH, ...own.params) ?? [])
|
|
72
|
+
.map(r => r.event_id.toLowerCase()));
|
|
73
|
+
if (!elsewhere.size)
|
|
74
|
+
return text;
|
|
75
|
+
// The note is cut block by block. Another scope's events go the way closed ones go without history: never
|
|
76
|
+
// shown, and never labelled as history either.
|
|
77
|
+
return (0, ledgerLiveness_1.resolveLedgerHits)([{ text, reason: 'other-scope' }], elsewhere, false)[0]?.text ?? '';
|
|
78
|
+
},
|
|
79
|
+
chunkIds(notePath) {
|
|
80
|
+
// `has` reads a chunk's text only for a line that opens an event (`## MEM_… · `, in any case): every other text is
|
|
81
|
+
// read here as empty, so the pass reads paths and headings, and the text of the few chunks that could hold one. A
|
|
82
|
+
// named scope holds no chunk that names no event outside its own directory (scopeSqlPredicate, the SQL twin of
|
|
83
|
+
// notePathMatchesScope), so only the chunks under it or naming an event are judged; the global scope, every chunk.
|
|
84
|
+
const namesEvent = "text GLOB '*## [Mm][Ee][Mm]_*'";
|
|
85
|
+
const under = scope === 'global' ? null : (0, scope_1.scopeSqlPredicate)(scope, 'note_path');
|
|
86
|
+
const rows = (0, statements_1.prepareOnce)(db, `SELECT id AS chunkId, note_path AS notePath, heading_path AS headingPath,
|
|
87
|
+
CASE WHEN ${namesEvent} THEN text ELSE '' END AS text
|
|
88
|
+
FROM chunks${under ? ` WHERE ${under.sql} OR ${namesEvent} OR heading_path GLOB '*[Mm][Ee][Mm]_*'` : ''}`).all(...(under?.params ?? []));
|
|
89
|
+
// A chunk that names an event is the only one that asks the lifecycle layer anything: a few ask one by one, a
|
|
90
|
+
// ledger's thousands ask all at once.
|
|
91
|
+
const namesOne = (row) => row.text !== '' || (row.headingPath ?? '').toUpperCase().includes('MEM_');
|
|
92
|
+
if (rows.reduce((n, row) => n + (namesOne(row) ? 1 : 0), 0) > PRELOAD_EVENTS_FROM)
|
|
93
|
+
preloadEvents();
|
|
94
|
+
const shown = new Map(); // a note's chunks share its path (the ledger is one note)
|
|
95
|
+
const pathOf = (stored) => {
|
|
96
|
+
let judged = shown.get(stored);
|
|
97
|
+
if (judged === undefined)
|
|
98
|
+
shown.set(stored, (judged = notePath(stored)));
|
|
99
|
+
return judged;
|
|
100
|
+
};
|
|
101
|
+
return rows.filter(row => has(row, pathOf(row.notePath))).map(row => row.chunkId);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ReadUsefulness = exports.DEFAULT_USE_WINDOW_MINUTES = void 0;
|
|
4
|
+
exports.noteItem = noteItem;
|
|
5
|
+
exports.factItem = factItem;
|
|
6
|
+
exports.contextItems = contextItems;
|
|
7
|
+
exports.searchItems = searchItems;
|
|
8
|
+
exports.retrieveItems = retrieveItems;
|
|
9
|
+
const node_crypto_1 = require("node:crypto");
|
|
10
|
+
const statements_1 = require("../db/statements");
|
|
11
|
+
const ledgerLiveness_1 = require("../retrieval/ledgerLiveness");
|
|
12
|
+
const telemetry_1 = require("./telemetry");
|
|
13
|
+
/** How long after a read a use still counts. Long enough for a multi-step task, short enough to stay one working session. */
|
|
14
|
+
exports.DEFAULT_USE_WINDOW_MINUTES = 30;
|
|
15
|
+
const MAX_WINDOW_MINUTES = 240;
|
|
16
|
+
/** Reads kept to be matched, so a busy workspace cannot grow the tracker without bound. */
|
|
17
|
+
const MAX_TRACKED_READS = 300;
|
|
18
|
+
/** Ids an event lists; `items` still counts them all. */
|
|
19
|
+
const MAX_EVENT_IDS = 20;
|
|
20
|
+
const norm = (value) => value.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
21
|
+
function noteItem(notePath) {
|
|
22
|
+
return { key: `note:${notePath}` };
|
|
23
|
+
}
|
|
24
|
+
function factItem(subject, predicate) {
|
|
25
|
+
return { key: `fact:${norm(subject)}\0${norm(predicate)}`, handles: [`subject:${norm(subject)}`] };
|
|
26
|
+
}
|
|
27
|
+
/** The note a memory id names (`note:<path>`, `entry:<path>#<hash>`), or the text itself when it names none. */
|
|
28
|
+
function noteOfId(id) {
|
|
29
|
+
if (id.startsWith('note:'))
|
|
30
|
+
return id.slice(5);
|
|
31
|
+
if (id.startsWith('entry:')) {
|
|
32
|
+
const hashAt = id.lastIndexOf('#');
|
|
33
|
+
return hashAt > 6 ? id.slice(6, hashAt) : id.slice(6);
|
|
34
|
+
}
|
|
35
|
+
return id;
|
|
36
|
+
}
|
|
37
|
+
function windowMs(env) {
|
|
38
|
+
const parsed = Number((env.RENOR_READ_USE_WINDOW_MINUTES ?? '').trim());
|
|
39
|
+
const minutes = Number.isFinite(parsed) && parsed >= 1 ? Math.min(MAX_WINDOW_MINUTES, Math.trunc(parsed)) : exports.DEFAULT_USE_WINDOW_MINUTES;
|
|
40
|
+
return minutes * 60_000;
|
|
41
|
+
}
|
|
42
|
+
class ReadUsefulness {
|
|
43
|
+
emit;
|
|
44
|
+
now;
|
|
45
|
+
reads = [];
|
|
46
|
+
secret = (0, node_crypto_1.randomBytes)(32);
|
|
47
|
+
windowMs;
|
|
48
|
+
constructor(emit = telemetry_1.emitTelemetry, now = Date.now, env = process.env) {
|
|
49
|
+
this.emit = emit;
|
|
50
|
+
this.now = now;
|
|
51
|
+
this.windowMs = windowMs(env);
|
|
52
|
+
}
|
|
53
|
+
opaque(key) {
|
|
54
|
+
return (0, node_crypto_1.createHmac)('sha256', this.secret).update(key).digest('hex').slice(0, 8);
|
|
55
|
+
}
|
|
56
|
+
/** Builds what a reply served and records it. Building reads the database, so it is inside the guard too. */
|
|
57
|
+
measure(tool, build) {
|
|
58
|
+
try {
|
|
59
|
+
const served = build();
|
|
60
|
+
this.served(tool, served.items, served.relevant, served.judged);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Measuring must never break a read.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** A reply was served. `items` are its returned items; `relevant` and `judged` as described above. */
|
|
67
|
+
served(tool, items, relevant, judged) {
|
|
68
|
+
try {
|
|
69
|
+
const at = this.now();
|
|
70
|
+
const unique = new Map();
|
|
71
|
+
for (const item of items)
|
|
72
|
+
if (!unique.has(item.key))
|
|
73
|
+
unique.set(item.key, item);
|
|
74
|
+
const id = (0, node_crypto_1.randomBytes)(4).toString('hex');
|
|
75
|
+
this.emit({
|
|
76
|
+
event: 'read',
|
|
77
|
+
tool,
|
|
78
|
+
read: id,
|
|
79
|
+
relevant,
|
|
80
|
+
judged,
|
|
81
|
+
items: unique.size,
|
|
82
|
+
ids: [...unique.keys()].slice(0, MAX_EVENT_IDS).map(key => this.opaque(key)),
|
|
83
|
+
});
|
|
84
|
+
if (!relevant || unique.size === 0)
|
|
85
|
+
return;
|
|
86
|
+
this.prune(at);
|
|
87
|
+
this.reads.push({
|
|
88
|
+
id,
|
|
89
|
+
tool,
|
|
90
|
+
at,
|
|
91
|
+
keys: new Set(unique.keys()),
|
|
92
|
+
handles: new Set([...unique.values()].flatMap(item => item.handles ?? [])),
|
|
93
|
+
used: false,
|
|
94
|
+
});
|
|
95
|
+
if (this.reads.length > MAX_TRACKED_READS)
|
|
96
|
+
this.reads.splice(0, this.reads.length - MAX_TRACKED_READS);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Measuring must never break a read.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** The agent read one memory in full (read_memory). */
|
|
103
|
+
readInFull(memoryId) {
|
|
104
|
+
this.use('read', read => read.keys.has(`note:${noteOfId(memoryId)}`));
|
|
105
|
+
}
|
|
106
|
+
/** A save named what it builds on. Each cite is a notePath or memory id a read returned, or a fact's subject. */
|
|
107
|
+
cited(cites) {
|
|
108
|
+
if (!cites?.length)
|
|
109
|
+
return;
|
|
110
|
+
const wanted = cites.slice(0, 20).map(cite => cite.trim()).filter(Boolean);
|
|
111
|
+
this.use('cite', read => wanted.some(cite => read.keys.has(`note:${noteOfId(cite)}`) || read.handles.has(`subject:${norm(cite)}`)));
|
|
112
|
+
}
|
|
113
|
+
/** A fact was saved with a new value for a subject and predicate (a correction or an update). */
|
|
114
|
+
updated(subject, predicate) {
|
|
115
|
+
const key = factItem(subject, predicate).key;
|
|
116
|
+
this.use('update', read => read.keys.has(key));
|
|
117
|
+
}
|
|
118
|
+
use(via, matches) {
|
|
119
|
+
try {
|
|
120
|
+
const at = this.now();
|
|
121
|
+
this.prune(at);
|
|
122
|
+
for (const read of this.reads) {
|
|
123
|
+
if (read.used || !matches(read))
|
|
124
|
+
continue;
|
|
125
|
+
read.used = true;
|
|
126
|
+
this.emit({
|
|
127
|
+
event: 'read_used',
|
|
128
|
+
tool: read.tool,
|
|
129
|
+
read: read.id,
|
|
130
|
+
via,
|
|
131
|
+
after_s: Math.max(0, Math.round((at - read.at) / 1000)),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Measuring must never break a save.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
prune(at) {
|
|
140
|
+
const cutoff = at - this.windowMs;
|
|
141
|
+
let drop = 0;
|
|
142
|
+
while (drop < this.reads.length && this.reads[drop].at < cutoff)
|
|
143
|
+
drop++;
|
|
144
|
+
if (drop > 0)
|
|
145
|
+
this.reads.splice(0, drop);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
exports.ReadUsefulness = ReadUsefulness;
|
|
149
|
+
// ---- What each tool's reply serves ---------------------------------------------------------------------------------
|
|
150
|
+
/** get_context: its facts by subject and predicate, its memories by note. A memory of a saved fact names no note. */
|
|
151
|
+
function contextItems(body) {
|
|
152
|
+
const items = [];
|
|
153
|
+
for (const fact of Array.isArray(body.facts) ? body.facts : []) {
|
|
154
|
+
if (typeof fact.subject === 'string' && typeof fact.predicate === 'string')
|
|
155
|
+
items.push(factItem(fact.subject, fact.predicate));
|
|
156
|
+
}
|
|
157
|
+
for (const memory of Array.isArray(body.memories) ? body.memories : []) {
|
|
158
|
+
if (typeof memory.notePath === 'string' && memory.notePath !== '')
|
|
159
|
+
items.push(noteItem(memory.notePath));
|
|
160
|
+
else if (typeof memory.text === 'string')
|
|
161
|
+
items.push({ key: `text:${memory.text}` });
|
|
162
|
+
}
|
|
163
|
+
return verdictOf(body.relevance, items);
|
|
164
|
+
}
|
|
165
|
+
function verdictOf(relevance, items) {
|
|
166
|
+
const judged = typeof relevance === 'object' && relevance !== null && typeof relevance.relevant === 'boolean';
|
|
167
|
+
return { items, relevant: judged ? relevance.relevant : items.length > 0, judged };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* search_memory: its hits as they are before the ledger's markup is taken off, so a fact is named by what it claims. A hit
|
|
171
|
+
* of the ledger names the facts of its events (the facts table knows each event's subject and predicate); anything else is
|
|
172
|
+
* a note.
|
|
173
|
+
*/
|
|
174
|
+
function searchItems(db, isLedger, hits, relevance) {
|
|
175
|
+
const events = new Set();
|
|
176
|
+
for (const hit of hits)
|
|
177
|
+
if (isLedger(hit.notePath))
|
|
178
|
+
for (const id of (0, ledgerLiveness_1.ledgerEventIds)(hit))
|
|
179
|
+
events.add(id);
|
|
180
|
+
const named = new Map();
|
|
181
|
+
if (events.size > 0) {
|
|
182
|
+
const ids = [...events];
|
|
183
|
+
const rows = (0, statements_1.listStatement)(db, ids.length, marks => `SELECT lower(f.event_id) AS event, e.canonical_name AS subject, f.predicate AS predicate
|
|
184
|
+
FROM facts f JOIN entities e ON e.id = f.subject_entity_id
|
|
185
|
+
WHERE lower(f.event_id) IN (${marks})`).all(...(0, statements_1.paddedList)(ids));
|
|
186
|
+
for (const row of rows)
|
|
187
|
+
named.set(row.event, row);
|
|
188
|
+
}
|
|
189
|
+
const items = [];
|
|
190
|
+
for (const hit of hits) {
|
|
191
|
+
if (!isLedger(hit.notePath)) {
|
|
192
|
+
items.push(hit.notePath ? noteItem(hit.notePath) : { key: `text:${hit.text}` });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const facts = (0, ledgerLiveness_1.ledgerEventIds)(hit).map(id => named.get(id)).filter((fact) => fact !== undefined);
|
|
196
|
+
if (facts.length > 0) {
|
|
197
|
+
for (const fact of facts)
|
|
198
|
+
items.push(factItem(fact.subject, fact.predicate));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
// A fact the index has not caught up with is headed `subject > predicate`.
|
|
202
|
+
const [subject, predicate] = (hit.headingPath ?? '').split(' > ');
|
|
203
|
+
items.push(subject && predicate ? factItem(subject, predicate) : { key: `text:${hit.text}` });
|
|
204
|
+
}
|
|
205
|
+
return verdictOf(relevance, items);
|
|
206
|
+
}
|
|
207
|
+
/** retrieve_memory: the facts of the subject it was asked for, by the subject the facts table names them under. */
|
|
208
|
+
function retrieveItems(db, facts) {
|
|
209
|
+
const ids = [...new Set(facts.map(f => f.subject_entity_id).filter((id) => typeof id === 'number'))];
|
|
210
|
+
const names = new Map();
|
|
211
|
+
if (ids.length > 0) {
|
|
212
|
+
const rows = db.prepare(`SELECT id, canonical_name AS name FROM entities WHERE id IN (${ids.map(() => '?').join(',')})`)
|
|
213
|
+
.all(...ids);
|
|
214
|
+
for (const row of rows)
|
|
215
|
+
names.set(row.id, row.name);
|
|
216
|
+
}
|
|
217
|
+
const items = facts.flatMap(f => {
|
|
218
|
+
const subject = f.subject_entity_id === undefined ? undefined : names.get(f.subject_entity_id);
|
|
219
|
+
return subject === undefined ? [] : [factItem(subject, f.predicate)];
|
|
220
|
+
});
|
|
221
|
+
return { items, relevant: items.length > 0, judged: false };
|
|
222
|
+
}
|