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,80 @@
|
|
|
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.writeBundle = writeBundle;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const types_1 = require("./types");
|
|
10
|
+
/**
|
|
11
|
+
* Write a bundle as `renor-memory-export/v1` JSONL.
|
|
12
|
+
*
|
|
13
|
+
* This is the seam that lets an import run where the workspace lives: a source that is
|
|
14
|
+
* only reachable read-only can be exported once, and the resulting file is the whole
|
|
15
|
+
* input the importer needs. It is also the documented interface a third-party provider
|
|
16
|
+
* can implement directly instead of shipping an adapter.
|
|
17
|
+
*
|
|
18
|
+
* The file contains memory content, so it is written `0600` and must be treated as
|
|
19
|
+
* private material — never committed, never a fixture.
|
|
20
|
+
*/
|
|
21
|
+
function writeBundle(bundle, filePath) {
|
|
22
|
+
const dir = path_1.default.dirname(path_1.default.resolve(filePath));
|
|
23
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
24
|
+
const lines = [];
|
|
25
|
+
lines.push(JSON.stringify({
|
|
26
|
+
type: 'meta',
|
|
27
|
+
format: types_1.BUNDLE_FORMAT,
|
|
28
|
+
source: bundle.sourceLabel,
|
|
29
|
+
notes: bundle.notes.length,
|
|
30
|
+
facts: bundle.facts.length,
|
|
31
|
+
}));
|
|
32
|
+
for (const note of bundle.notes) {
|
|
33
|
+
lines.push(JSON.stringify({
|
|
34
|
+
type: 'note',
|
|
35
|
+
path: note.sourcePath,
|
|
36
|
+
relPath: note.relPath,
|
|
37
|
+
title: note.title,
|
|
38
|
+
kind: note.kind,
|
|
39
|
+
modifiedAt: note.modifiedAt,
|
|
40
|
+
frontmatter: note.frontmatter,
|
|
41
|
+
chunks: note.chunks.map(chunk => ({
|
|
42
|
+
headingPath: chunk.headingPath,
|
|
43
|
+
text: chunk.text,
|
|
44
|
+
tokenCount: chunk.tokenCount,
|
|
45
|
+
startLine: chunk.startLine,
|
|
46
|
+
endLine: chunk.endLine,
|
|
47
|
+
})),
|
|
48
|
+
entities: note.entities,
|
|
49
|
+
// Carried when present so the importer can prefer the original bytes.
|
|
50
|
+
originalContent: note.originalContent,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
for (const fact of bundle.facts) {
|
|
54
|
+
lines.push(JSON.stringify({
|
|
55
|
+
type: 'fact',
|
|
56
|
+
subject: fact.subject,
|
|
57
|
+
predicate: fact.predicate,
|
|
58
|
+
object: fact.object,
|
|
59
|
+
scope: fact.scope,
|
|
60
|
+
validFrom: fact.validFrom,
|
|
61
|
+
sourcePath: fact.sourcePath,
|
|
62
|
+
origin: fact.origin,
|
|
63
|
+
ledgerSource: fact.ledgerSource,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
const body = lines.join('\n') + '\n';
|
|
67
|
+
// `mode` is only honoured when the file is created, so an export over an existing
|
|
68
|
+
// path (or one a shell redirect created) would keep whatever permissions it already
|
|
69
|
+
// had — while this command claims the bundle is 0600. Create, write and chmod so the
|
|
70
|
+
// guarantee holds on overwrite too.
|
|
71
|
+
const fd = fs_1.default.openSync(filePath, 'w', 0o600);
|
|
72
|
+
try {
|
|
73
|
+
fs_1.default.writeFileSync(fd, body, { encoding: 'utf8' });
|
|
74
|
+
fs_1.default.fchmodSync(fd, 0o600);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
fs_1.default.closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
return { bytes: Buffer.byteLength(body, 'utf8'), records: lines.length };
|
|
80
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
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.parseArgv = parseArgv;
|
|
7
|
+
exports.run = run;
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const plan_1 = require("./plan");
|
|
10
|
+
const existing_1 = require("./existing");
|
|
11
|
+
const apply_1 = require("./apply");
|
|
12
|
+
const source_1 = require("./source");
|
|
13
|
+
const bundle_write_1 = require("./bundle-write");
|
|
14
|
+
const markdown_1 = require("./markdown");
|
|
15
|
+
const USAGE = `renor-import — import existing memory into a Renor workspace
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
renor-import --from <source> --to <workspace-memory-dir> [--apply] [options]
|
|
19
|
+
|
|
20
|
+
Source spec:
|
|
21
|
+
markdown:<folder> an Obsidian vault or Markdown folder; no source index needed.
|
|
22
|
+
Uses the normal vault ignore rules and .semanticignore.
|
|
23
|
+
Only lowercase .md files; no attachments or symlinks.
|
|
24
|
+
A file or folder name that is not valid UTF-8 cannot be imported:
|
|
25
|
+
such files are listed, not imported; every other note still is.
|
|
26
|
+
renor-index:<path-to-index.db> a Renor-shaped index (another workspace, or a personal
|
|
27
|
+
brain). Opened READ-ONLY. Combine with --source-root.
|
|
28
|
+
bundle:<path-to.jsonl> a renor-memory-export/v1 bundle, so any provider can
|
|
29
|
+
produce the input for this command.
|
|
30
|
+
|
|
31
|
+
Destination:
|
|
32
|
+
--source-root <dir> directory the index's stored absolute paths live under
|
|
33
|
+
(used to build the relative path).
|
|
34
|
+
--content-root <dir> tree holding the source's original Markdown (defaults to
|
|
35
|
+
--source-root). Originals are carried byte-for-byte when
|
|
36
|
+
present; a missing file falls back to reconstruction.
|
|
37
|
+
|
|
38
|
+
--to <memory-dir> the workspace's memory directory (its canonical store).
|
|
39
|
+
--db <path> derived index path (default <memory-dir>/.memory-index/index.db)
|
|
40
|
+
--ledger-rel <rel> ledger path relative to the memory dir
|
|
41
|
+
(default System/Memory Events Ledger.md)
|
|
42
|
+
|
|
43
|
+
Selection (both repeatable; applied to every source kind, by each record's own
|
|
44
|
+
relative path — for renor-index: that is relative to --source-root, for bundle: it is
|
|
45
|
+
the record's relPath):
|
|
46
|
+
--include <prefix> include only this prefix (paths are relative to the source
|
|
47
|
+
root and must be matched by the note to be carried).
|
|
48
|
+
--exclude <prefix> exclude this prefix. Applied after --include.
|
|
49
|
+
|
|
50
|
+
Ledger-derived facts (the owner-stated memory, source path '_mcp_') are carried by default
|
|
51
|
+
because they are the smallest and highest-value set. Exclude them with --exclude _mcp_.
|
|
52
|
+
--limit <n> cap the number of selected notes.
|
|
53
|
+
|
|
54
|
+
Writing:
|
|
55
|
+
--namespace <rel> additive destination subdirectory under Imported/
|
|
56
|
+
(default Imported/<source>)
|
|
57
|
+
--apply perform the writes (default: plan only)
|
|
58
|
+
--canonical-only write Markdown + ledger events only; copy no index rows
|
|
59
|
+
|
|
60
|
+
Other:
|
|
61
|
+
--event-source <label> provenance recorded in the ledger (default import:<source>)
|
|
62
|
+
--emit-bundle <file> write the selected source as a renor-memory-export/v1
|
|
63
|
+
JSONL bundle and exit. This is how an import runs where
|
|
64
|
+
the workspace lives: export once from a source you can
|
|
65
|
+
only read, then import the file next to the workspace.
|
|
66
|
+
--json emit the machine-readable plan/summary only
|
|
67
|
+
--help show this message
|
|
68
|
+
`;
|
|
69
|
+
function parseArgv(argv) {
|
|
70
|
+
const options = {
|
|
71
|
+
namespace: '',
|
|
72
|
+
ledgerRel: 'System/Memory Events Ledger.md',
|
|
73
|
+
apply: false,
|
|
74
|
+
canonicalOnly: false,
|
|
75
|
+
json: false,
|
|
76
|
+
};
|
|
77
|
+
const include = [];
|
|
78
|
+
const exclude = [];
|
|
79
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
80
|
+
const arg = argv[i];
|
|
81
|
+
const value = () => {
|
|
82
|
+
const next = argv[i + 1];
|
|
83
|
+
if (next === undefined)
|
|
84
|
+
throw new Error(`${arg} requires a value`);
|
|
85
|
+
i += 1;
|
|
86
|
+
return next;
|
|
87
|
+
};
|
|
88
|
+
switch (arg) {
|
|
89
|
+
case '--help':
|
|
90
|
+
case '-h': return { help: true };
|
|
91
|
+
case '--from':
|
|
92
|
+
options.from = value();
|
|
93
|
+
break;
|
|
94
|
+
case '--to':
|
|
95
|
+
options.to = value();
|
|
96
|
+
break;
|
|
97
|
+
case '--source-root':
|
|
98
|
+
options.sourceRoot = value();
|
|
99
|
+
break;
|
|
100
|
+
case '--content-root':
|
|
101
|
+
options.contentRoot = value();
|
|
102
|
+
break;
|
|
103
|
+
case '--namespace':
|
|
104
|
+
options.namespace = value();
|
|
105
|
+
break;
|
|
106
|
+
case '--include':
|
|
107
|
+
include.push(value());
|
|
108
|
+
break;
|
|
109
|
+
case '--exclude':
|
|
110
|
+
exclude.push(value());
|
|
111
|
+
break;
|
|
112
|
+
case '--limit':
|
|
113
|
+
options.limit = Number(value());
|
|
114
|
+
break;
|
|
115
|
+
case '--ledger-rel':
|
|
116
|
+
options.ledgerRel = value();
|
|
117
|
+
break;
|
|
118
|
+
case '--db':
|
|
119
|
+
options.dbPath = value();
|
|
120
|
+
break;
|
|
121
|
+
case '--event-source':
|
|
122
|
+
options.eventSource = value();
|
|
123
|
+
break;
|
|
124
|
+
case '--apply':
|
|
125
|
+
options.apply = true;
|
|
126
|
+
break;
|
|
127
|
+
case '--canonical-only':
|
|
128
|
+
options.canonicalOnly = true;
|
|
129
|
+
break;
|
|
130
|
+
case '--json':
|
|
131
|
+
options.json = true;
|
|
132
|
+
break;
|
|
133
|
+
case '--emit-bundle':
|
|
134
|
+
options.emitBundle = value();
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
throw new Error(`unknown argument ${arg}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (options.namespace)
|
|
141
|
+
(0, plan_1.assertImportNamespace)(options.namespace);
|
|
142
|
+
if (!options.from)
|
|
143
|
+
throw new Error('--from is required (see --help)');
|
|
144
|
+
if (!options.to)
|
|
145
|
+
throw new Error('--to is required (see --help)');
|
|
146
|
+
if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 0)) {
|
|
147
|
+
throw new Error('--limit must be a non-negative integer');
|
|
148
|
+
}
|
|
149
|
+
return { ...options, include, exclude };
|
|
150
|
+
}
|
|
151
|
+
/** Source label used in the default namespace and provenance label. */
|
|
152
|
+
function sourceKindAndLabel(from) {
|
|
153
|
+
const separator = from.indexOf(':');
|
|
154
|
+
const kind = separator < 0 ? from : from.slice(0, separator);
|
|
155
|
+
const target = separator < 0 ? '' : from.slice(separator + 1);
|
|
156
|
+
const base = path_1.default.basename(target || kind).replace(/\.(db|jsonl|json)$/i, '');
|
|
157
|
+
return { kind, label: base || kind };
|
|
158
|
+
}
|
|
159
|
+
/** The Markdown files a source could not carry because a file or folder name is not valid UTF-8. */
|
|
160
|
+
function printNotImported(names, labelWidth) {
|
|
161
|
+
if (names.length === 0)
|
|
162
|
+
return;
|
|
163
|
+
console.log(` ${'not imported'.padEnd(labelWidth)}: ${names.length} Markdown file(s) whose file or folder name is not valid UTF-8`
|
|
164
|
+
+ ' (bad bytes are shown as \\xNN); rename them to UTF-8 names, then run the import again');
|
|
165
|
+
for (const name of names.slice(0, 10))
|
|
166
|
+
console.log(` ! ${name}`);
|
|
167
|
+
if (names.length > 10)
|
|
168
|
+
console.log(` ! …and ${names.length - 10} more (--json lists them all)`);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* What the secret guard left out of the plan (RENOR_SECRET_GUARD is on): a path or a fact id and the field that
|
|
172
|
+
* looked like a secret, never text. A path that itself looks like a secret is withheld.
|
|
173
|
+
*/
|
|
174
|
+
function printGuardSkips(skips, labelWidth) {
|
|
175
|
+
if (!skips || skips.notes.length + skips.events.length === 0)
|
|
176
|
+
return;
|
|
177
|
+
console.log(` ${'secret guard'.padEnd(labelWidth)}: ${skips.notes.length} note(s) and ${skips.events.length} fact(s) not imported: they appear to`
|
|
178
|
+
+ ' contain a credential or personal identifier (RENOR_SECRET_GUARD is on); every other note still is');
|
|
179
|
+
const lines = [
|
|
180
|
+
...skips.notes.map(skip => `${skip.ref ?? '(path withheld)'} (${skip.field}: ${skip.reason})`),
|
|
181
|
+
...skips.events.map(skip => `fact ${skip.ref} (${skip.field}: ${skip.reason})`),
|
|
182
|
+
];
|
|
183
|
+
for (const line of lines.slice(0, 10))
|
|
184
|
+
console.log(` ! ${line}`);
|
|
185
|
+
if (lines.length > 10)
|
|
186
|
+
console.log(` ! …and ${lines.length - 10} more (--json lists them all)`);
|
|
187
|
+
}
|
|
188
|
+
async function run(argv) {
|
|
189
|
+
let options;
|
|
190
|
+
try {
|
|
191
|
+
options = parseArgv(argv);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
195
|
+
console.error(USAGE);
|
|
196
|
+
return 2;
|
|
197
|
+
}
|
|
198
|
+
if ('help' in options) {
|
|
199
|
+
console.log(USAGE);
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
const { label } = sourceKindAndLabel(options.from);
|
|
203
|
+
const include = options.include ?? [];
|
|
204
|
+
const exclude = options.exclude ?? [];
|
|
205
|
+
const namespace = options.namespace || path_1.default.posix.join('Imported', label);
|
|
206
|
+
const memoryDir = path_1.default.resolve(options.to);
|
|
207
|
+
const dbPath = options.dbPath ?? path_1.default.join(memoryDir, '.memory-index', 'index.db');
|
|
208
|
+
const ledgerPath = path_1.default.join(memoryDir, ...options.ledgerRel.split('/'));
|
|
209
|
+
const eventSource = options.eventSource || `import:${label}`;
|
|
210
|
+
let bundle;
|
|
211
|
+
try {
|
|
212
|
+
const filter = {
|
|
213
|
+
include,
|
|
214
|
+
exclude,
|
|
215
|
+
limit: options.limit,
|
|
216
|
+
sourceRoot: options.sourceRoot,
|
|
217
|
+
contentRoot: options.contentRoot,
|
|
218
|
+
label,
|
|
219
|
+
};
|
|
220
|
+
if (options.from.startsWith('markdown:')) {
|
|
221
|
+
const root = path_1.default.resolve(options.from.slice('markdown:'.length));
|
|
222
|
+
// Do not recursively import the destination on a later run. Use real paths
|
|
223
|
+
// for the source and nearest existing destination ancestor (including links).
|
|
224
|
+
const fs = await import('fs');
|
|
225
|
+
let ancestor = memoryDir;
|
|
226
|
+
while (!fs.existsSync(ancestor) && path_1.default.dirname(ancestor) !== ancestor)
|
|
227
|
+
ancestor = path_1.default.dirname(ancestor);
|
|
228
|
+
const destination = path_1.default.resolve(fs.realpathSync(ancestor), path_1.default.relative(ancestor, memoryDir));
|
|
229
|
+
const source = fs.realpathSync(root);
|
|
230
|
+
const contains = (parent, child) => {
|
|
231
|
+
const rel = path_1.default.relative(parent, child);
|
|
232
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${path_1.default.sep}`) && !path_1.default.isAbsolute(rel));
|
|
233
|
+
};
|
|
234
|
+
if (contains(source, destination) || contains(destination, source)) {
|
|
235
|
+
throw new Error('Markdown source and destination must be separate folders');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
bundle = options.from.startsWith('markdown:')
|
|
239
|
+
? await (0, markdown_1.readMarkdownSource)(options.from.slice('markdown:'.length), filter)
|
|
240
|
+
: (0, source_1.readSource)(options.from, filter);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
console.error(`source read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
const nonUtf8Names = bundle.nonUtf8Names ?? [];
|
|
247
|
+
if (options.emitBundle) {
|
|
248
|
+
const written = (0, bundle_write_1.writeBundle)(bundle, options.emitBundle);
|
|
249
|
+
if (options.json) {
|
|
250
|
+
console.log(JSON.stringify({
|
|
251
|
+
mode: 'emit-bundle',
|
|
252
|
+
source: { spec: options.from, label: bundle.sourceLabel, format: bundle.format },
|
|
253
|
+
bundle: { file: options.emitBundle, bytes: written.bytes, records: written.records },
|
|
254
|
+
counts: {
|
|
255
|
+
notes: bundle.notes.length,
|
|
256
|
+
chunks: bundle.stats.chunksSelected,
|
|
257
|
+
facts: bundle.facts.length,
|
|
258
|
+
notesWithOriginalBytes: bundle.stats.notesWithOriginalBytes,
|
|
259
|
+
notesSkippedNonUtf8Name: nonUtf8Names.length,
|
|
260
|
+
},
|
|
261
|
+
skipped: { nonUtf8Names },
|
|
262
|
+
warning: 'the bundle contains memory content; it is written 0600 and must not be committed',
|
|
263
|
+
}, null, 2));
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
console.log('renor-import: EMIT BUNDLE');
|
|
267
|
+
console.log(` source : ${options.from}`);
|
|
268
|
+
console.log(` bundle : ${options.emitBundle} (${written.bytes} bytes, ${written.records} records)`);
|
|
269
|
+
console.log(` counts : notes ${bundle.notes.length} (${bundle.stats.notesWithOriginalBytes} with original bytes), chunks ${bundle.stats.chunksSelected}, facts ${bundle.facts.length}`);
|
|
270
|
+
printNotImported(nonUtf8Names, 12);
|
|
271
|
+
console.log(' note : the bundle contains memory content; it is written 0600 and must not be committed');
|
|
272
|
+
}
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
const plan = (0, plan_1.buildImportPlan)(bundle, {
|
|
276
|
+
namespace,
|
|
277
|
+
label,
|
|
278
|
+
eventSource,
|
|
279
|
+
existingStreams: (0, existing_1.readExistingStreams)(dbPath, ledgerPath),
|
|
280
|
+
});
|
|
281
|
+
const warnings = [];
|
|
282
|
+
if (options.from.startsWith('markdown:')) {
|
|
283
|
+
warnings.push('Only lowercase .md files are imported. Attachments and symlinks are not imported; '
|
|
284
|
+
+ 'vault ignore rules and .semanticignore apply. Markdown is preserved, but this import '
|
|
285
|
+
+ 'creates no extracted entities or structured facts, and makes no embedding call itself. '
|
|
286
|
+
+ 'Use search_text with an exact phrase from a note to verify recall.');
|
|
287
|
+
}
|
|
288
|
+
if (plan.counts.multiValuedFactsDropped > 0) {
|
|
289
|
+
warnings.push(`${plan.counts.multiValuedFactsDropped} fact row(s) belong to multi-valued streams: the `
|
|
290
|
+
+ `lifecycle layer holds at most one current value per subject+predicate, so they are `
|
|
291
|
+
+ `carried as notes only and not as current facts.`);
|
|
292
|
+
}
|
|
293
|
+
warnings.push('Embeddings are not transferred: a vector is bound to the model that produced it, so a vector from '
|
|
294
|
+
+ 'another model would be silent garbage rather than a saving. Imported notes are lexically '
|
|
295
|
+
+ '(full-text) searchable at once. Applied to a workspace with semantic search, the import marks '
|
|
296
|
+
+ 'its index unfinished: the workspace service embeds the imported notes with its own model, once, '
|
|
297
|
+
+ 'when it next starts, and its semantic search is off until they are embedded.');
|
|
298
|
+
warnings.push('Each imported note row carries the hash of the exact bytes written, which is the indexer\'s own '
|
|
299
|
+
+ '"unchanged" condition. With no extraction lane armed (this workspace\'s configuration) a later '
|
|
300
|
+
+ 'index pass is therefore a no-op for imported notes: no re-chunk, no re-embed, no re-extract. '
|
|
301
|
+
+ 'If a fact/chronicle/preference extraction lane is armed later, its cache key is that same hash '
|
|
302
|
+
+ 'and the lane has no rows for these notes yet, so that pass would re-extract and re-embed them. '
|
|
303
|
+
+ 'Content is durable in Markdown and the ledger; the indexed rows are derived and a rebuild would '
|
|
304
|
+
+ 're-derive them.');
|
|
305
|
+
const summary = {
|
|
306
|
+
mode: options.apply ? 'apply' : 'plan',
|
|
307
|
+
source: { spec: options.from, label: bundle.sourceLabel, format: bundle.format },
|
|
308
|
+
destination: { memoryDir, dbPath, ledgerRel: options.ledgerRel, namespace },
|
|
309
|
+
selection: {
|
|
310
|
+
include,
|
|
311
|
+
exclude,
|
|
312
|
+
limit: options.limit ?? null,
|
|
313
|
+
},
|
|
314
|
+
counts: {
|
|
315
|
+
...plan.counts,
|
|
316
|
+
// What the selection actually removed, measured by the adapter rather than echoed
|
|
317
|
+
// back from the flags. A filter that is silently ignored is indistinguishable from
|
|
318
|
+
// a filter that matched nothing, and `--exclude` failing quietly is a privacy bug.
|
|
319
|
+
notesFilteredOut: bundle.stats.notesFilteredOut,
|
|
320
|
+
factsFilteredOut: Math.max(0, bundle.stats.factsAvailable - bundle.stats.factsSelected),
|
|
321
|
+
eventsAlreadyInDestination: 0,
|
|
322
|
+
notesSkippedNonUtf8Name: nonUtf8Names.length,
|
|
323
|
+
},
|
|
324
|
+
skipped: { ...plan.skipped, nonUtf8Names },
|
|
325
|
+
warnings,
|
|
326
|
+
};
|
|
327
|
+
let exitCode = 0;
|
|
328
|
+
if (options.apply) {
|
|
329
|
+
const result = await (0, apply_1.applyImportPlan)(plan, bundle, {
|
|
330
|
+
memoryDir,
|
|
331
|
+
dbPath,
|
|
332
|
+
ledgerRelPath: options.ledgerRel,
|
|
333
|
+
canonicalOnly: options.canonicalOnly,
|
|
334
|
+
});
|
|
335
|
+
summary.applied = result;
|
|
336
|
+
summary.counts = {
|
|
337
|
+
...summary.counts,
|
|
338
|
+
eventsAlreadyInDestination: result.eventsAlreadyPresent,
|
|
339
|
+
};
|
|
340
|
+
summary.after = result.destination;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
summary.after = null;
|
|
344
|
+
}
|
|
345
|
+
if (options.json) {
|
|
346
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const mode = options.apply ? 'APPLIED' : 'PLAN (dry run — pass --apply to write)';
|
|
350
|
+
console.log(`renor-import: ${mode}`);
|
|
351
|
+
console.log(` source : ${options.from} (label ${bundle.sourceLabel})`);
|
|
352
|
+
console.log(` destination : ${memoryDir}`);
|
|
353
|
+
console.log(` namespace : ${namespace}`);
|
|
354
|
+
console.log(` notes : ${plan.counts.selectedNotes} selected of ${plan.counts.notesInBundle} in bundle`
|
|
355
|
+
+ ` (${plan.skipped.duplicateNotePath} duplicate path, ${plan.skipped.emptyNoteBody} empty)`);
|
|
356
|
+
if (bundle.stats.notesFilteredOut > 0 || bundle.stats.factsAvailable > bundle.stats.factsSelected) {
|
|
357
|
+
console.log(` selection : ${bundle.stats.notesFilteredOut} note(s) and `
|
|
358
|
+
+ `${bundle.stats.factsAvailable - bundle.stats.factsSelected} fact(s) left out by --include/--exclude/--limit`);
|
|
359
|
+
}
|
|
360
|
+
printNotImported(nonUtf8Names, 14);
|
|
361
|
+
printGuardSkips(plan.skipped.secretGuard, 14);
|
|
362
|
+
console.log(` chunks : ${plan.counts.selectedChunks} from the source parse (no re-chunking)`);
|
|
363
|
+
console.log(` note bytes : ${plan.counts.notesFromOriginalBytes} carried verbatim, ${plan.counts.notesReconstructed} reconstructed from chunks`);
|
|
364
|
+
if (plan.counts.chunksNotLocated > 0) {
|
|
365
|
+
console.log(` ! ${plan.counts.chunksNotLocated} chunk(s) not locatable in the carried bytes; ${plan.counts.chunksSpanInherited} inherited the source span, the rest have an unknown span`);
|
|
366
|
+
}
|
|
367
|
+
console.log(` ledger events : ${plan.counts.eventsToAppend} new`
|
|
368
|
+
+ (options.apply ? ` (${summary.applied.eventsAppended} appended, ${summary.applied.eventsAlreadyPresent} already present)` : ''));
|
|
369
|
+
console.log(` facts : ${plan.counts.factsInBundle} current in source, ${plan.counts.eventsToAppend} carried, `
|
|
370
|
+
+ `${plan.counts.multiValuedFactsDropped} multi-valued dropped, ${plan.counts.factsSkipped} other skips`);
|
|
371
|
+
for (const skip of plan.skipped.events) {
|
|
372
|
+
console.log(` - ${skip.count} × ${skip.reason}`);
|
|
373
|
+
}
|
|
374
|
+
if (options.apply) {
|
|
375
|
+
const applied = summary.applied;
|
|
376
|
+
console.log(` files : ${applied.notesWritten} written, ${applied.notesUnchanged} already identical, ${applied.notesConflicting} conflicting (never overwritten or indexed)`);
|
|
377
|
+
for (const conflict of (applied.conflicts ?? []).slice(0, 10)) {
|
|
378
|
+
console.log(` ! conflict (left as-is, not indexed): ${conflict}`);
|
|
379
|
+
}
|
|
380
|
+
if ((applied.conflicts ?? []).length > 10) {
|
|
381
|
+
console.log(` ! …and ${applied.conflicts.length - 10} more`);
|
|
382
|
+
}
|
|
383
|
+
console.log(` index rows : ${applied.indexRows.notesInserted} notes, ${applied.indexRows.chunksInserted} chunks, ${applied.indexRows.entitiesInserted} entities inserted`);
|
|
384
|
+
console.log(` replayed : ${applied.ledgerReplayed} ledger event(s) applied to the index`);
|
|
385
|
+
console.log(` workspace now : notes ${applied.destination.notes} · chunks ${applied.destination.chunks} · entities ${applied.destination.entities} · facts ${applied.destination.facts} (${applied.destination.currentFacts} current)`);
|
|
386
|
+
}
|
|
387
|
+
for (const warning of warnings)
|
|
388
|
+
console.log(` note : ${warning}`);
|
|
389
|
+
}
|
|
390
|
+
if (options.apply && summary.applied.notesConflicting > 0)
|
|
391
|
+
exitCode = 3;
|
|
392
|
+
// Like a conflict, a note the guard left out is something the operator asked for that was not imported.
|
|
393
|
+
const guardSkips = plan.skipped.secretGuard;
|
|
394
|
+
if (options.apply && guardSkips && guardSkips.notes.length + guardSkips.events.length > 0)
|
|
395
|
+
exitCode = 3;
|
|
396
|
+
return exitCode;
|
|
397
|
+
}
|
|
398
|
+
/* c8 ignore start — process wiring; `run` above holds the testable behavior. */
|
|
399
|
+
if (require.main === module) {
|
|
400
|
+
run(process.argv.slice(2))
|
|
401
|
+
.then(code => { process.exitCode = code; })
|
|
402
|
+
.catch(err => {
|
|
403
|
+
console.error(err instanceof Error ? err.stack ?? err.message : String(err));
|
|
404
|
+
process.exitCode = 1;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/* c8 ignore stop */
|
|
@@ -0,0 +1,49 @@
|
|
|
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.readExistingStreams = readExistingStreams;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const client_1 = require("../db/client");
|
|
9
|
+
const lifecycle_1 = require("../memory/lifecycle");
|
|
10
|
+
const plan_1 = require("./plan");
|
|
11
|
+
/**
|
|
12
|
+
* Fact streams a destination already represents.
|
|
13
|
+
*
|
|
14
|
+
* Two sources, because they disagree in a way that matters: the **ledger** is canonical
|
|
15
|
+
* and the index is derived. A stream a ledger event already declares is represented even
|
|
16
|
+
* when the projection has not caught up — and importing an older value for it would not
|
|
17
|
+
* merely duplicate, it would fail the engine's per-stream monotonicity check during
|
|
18
|
+
* replay, which runs at service STARTUP and refuses to serve. Skipping it is the only
|
|
19
|
+
* safe move, and the skip is counted and reported rather than silent.
|
|
20
|
+
*/
|
|
21
|
+
function readExistingStreams(dbPath, ledgerPath) {
|
|
22
|
+
const streams = new Set();
|
|
23
|
+
let db = null;
|
|
24
|
+
try {
|
|
25
|
+
db = (0, client_1.openReadOnlyDb)(dbPath);
|
|
26
|
+
const rows = db.prepare(`SELECT e.canonical_name AS subject, f.predicate AS predicate, f.scope AS scope
|
|
27
|
+
FROM facts f JOIN entities e ON e.id = f.subject_entity_id
|
|
28
|
+
WHERE f.valid_to IS NULL AND f.source_path = '_mcp_'`).all();
|
|
29
|
+
for (const row of rows)
|
|
30
|
+
streams.add((0, plan_1.streamKey)(row.subject, row.predicate, row.scope ?? 'global'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// A destination with no index yet simply has no indexed streams.
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
db?.close();
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const content = fs_1.default.readFileSync(ledgerPath, 'utf8');
|
|
40
|
+
for (const event of (0, lifecycle_1.parseLedgerEvents)(content)) {
|
|
41
|
+
streams.add((0, plan_1.streamKey)(event.subject, event.predicate, event.scope ?? 'global'));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// No ledger yet, or an unreadable one: nothing declared that this read can vouch for.
|
|
46
|
+
// Replay still fails closed on the real content, so this cannot launder a bad ledger.
|
|
47
|
+
}
|
|
48
|
+
return streams;
|
|
49
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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.EVENT_ID_RE = void 0;
|
|
7
|
+
exports.deterministicEventId = deterministicEventId;
|
|
8
|
+
exports.toCanonicalInstant = toCanonicalInstant;
|
|
9
|
+
exports.tryNormalizeScope = tryNormalizeScope;
|
|
10
|
+
exports.validateCarriedFact = validateCarriedFact;
|
|
11
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
12
|
+
const scope_1 = require("../mvp/scope");
|
|
13
|
+
/**
|
|
14
|
+
* Identity and validation rules for carried facts.
|
|
15
|
+
*
|
|
16
|
+
* A carried fact becomes a canonical ledger event, because the engine's lifecycle
|
|
17
|
+
* layer (and therefore the dashboard's fact list and graph) reads only facts whose
|
|
18
|
+
* `source_path` is the lifecycle sentinel — facts derived from notes are invisible
|
|
19
|
+
* to those readers.
|
|
20
|
+
*
|
|
21
|
+
* Two hard requirements come from `engine/src/memory/lifecycle.ts`:
|
|
22
|
+
* - event `id` and `at` decide replay idempotency (`applied_ledger_events`), so a
|
|
23
|
+
* re-run must produce a byte-identical event or replay either duplicates it or
|
|
24
|
+
* fails with "reused with a conflicting payload" — and replay runs at service
|
|
25
|
+
* STARTUP, so a mutated payload would break the workspace's readiness gate;
|
|
26
|
+
* - `at` must be the canonical instant form and ids must match `^mem_[A-Za-z0-9]+$`.
|
|
27
|
+
*
|
|
28
|
+
* Scope and field limits are therefore imported from the engine rather than mirrored
|
|
29
|
+
* here: a validator that drifts looser fails at boot, and one that drifts stricter
|
|
30
|
+
* silently loses memory.
|
|
31
|
+
*/
|
|
32
|
+
exports.EVENT_ID_RE = /^mem_[A-Za-z0-9]+$/;
|
|
33
|
+
/** Field limits as enforced by `lifecycle.ts` `validateField`. */
|
|
34
|
+
const FIELD_LIMITS = { subject: 512, predicate: 256, object: 8192, source: 256 };
|
|
35
|
+
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
|
|
36
|
+
const CANONICAL_TS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
37
|
+
/** The instant forms the engine's own normalizer accepts: an explicit zone, or date-only. */
|
|
38
|
+
const ZONED_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
39
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
40
|
+
/**
|
|
41
|
+
* Deterministic event id for a carried fact.
|
|
42
|
+
*
|
|
43
|
+
* It is a function of **everything replay compares** — source label, subject,
|
|
44
|
+
* predicate, scope, object and `at` — because the id's only job is to make a re-run a
|
|
45
|
+
* no-op. A narrower key would mint the same id for a different payload when the source
|
|
46
|
+
* changed between runs, which replay rejects as corruption.
|
|
47
|
+
*/
|
|
48
|
+
function deterministicEventId(input) {
|
|
49
|
+
const digest = crypto_1.default
|
|
50
|
+
.createHash('sha256')
|
|
51
|
+
.update([
|
|
52
|
+
'renor-memory-import/v1',
|
|
53
|
+
input.label,
|
|
54
|
+
input.subject,
|
|
55
|
+
input.predicate,
|
|
56
|
+
input.scope,
|
|
57
|
+
input.object,
|
|
58
|
+
input.at,
|
|
59
|
+
].join('\u0000'))
|
|
60
|
+
.digest('hex')
|
|
61
|
+
.slice(0, 40);
|
|
62
|
+
return `mem_imp${digest}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Canonical instant, or null when the input cannot be one. Mirrors the engine's
|
|
66
|
+
* accepted inputs (an ISO instant with an explicit timezone, or date-only) and rejects
|
|
67
|
+
* timezone-less datetimes, which the engine treats as ambiguous.
|
|
68
|
+
*/
|
|
69
|
+
function toCanonicalInstant(value) {
|
|
70
|
+
if (typeof value !== 'string')
|
|
71
|
+
return null;
|
|
72
|
+
const trimmed = value.trim();
|
|
73
|
+
if (CANONICAL_TS.test(trimmed) || ZONED_INSTANT.test(trimmed) || DATE_ONLY.test(trimmed)) {
|
|
74
|
+
const parsed = new Date(trimmed);
|
|
75
|
+
if (Number.isNaN(parsed.getTime()))
|
|
76
|
+
return null;
|
|
77
|
+
const year = parsed.getUTCFullYear();
|
|
78
|
+
if (year < 1970 || year > 2999)
|
|
79
|
+
return null;
|
|
80
|
+
return parsed.toISOString();
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/** `normalizeScope` from the engine, or null when it rejects the value. */
|
|
85
|
+
function tryNormalizeScope(raw) {
|
|
86
|
+
try {
|
|
87
|
+
return (0, scope_1.normalizeScope)(raw);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Validate a fact once, before anything is written, and normalize its scope and
|
|
95
|
+
* instant. A rejection is counted by reason rather than surfacing at the destination's
|
|
96
|
+
* readiness gate.
|
|
97
|
+
*/
|
|
98
|
+
function validateCarriedFact(fact) {
|
|
99
|
+
if (!fact.subject.trim())
|
|
100
|
+
return { ok: false, reason: 'empty subject' };
|
|
101
|
+
if (!fact.predicate.trim())
|
|
102
|
+
return { ok: false, reason: 'empty predicate' };
|
|
103
|
+
if (!fact.object.trim())
|
|
104
|
+
return { ok: false, reason: 'empty object' };
|
|
105
|
+
if (CONTROL_CHARS.test(fact.subject) || CONTROL_CHARS.test(fact.predicate)
|
|
106
|
+
|| CONTROL_CHARS.test(fact.object) || CONTROL_CHARS.test(fact.scope)) {
|
|
107
|
+
return { ok: false, reason: 'control character in a lifecycle field' };
|
|
108
|
+
}
|
|
109
|
+
if (fact.subject.length > FIELD_LIMITS.subject)
|
|
110
|
+
return { ok: false, reason: 'subject exceeds 512 characters' };
|
|
111
|
+
if (fact.predicate.length > FIELD_LIMITS.predicate)
|
|
112
|
+
return { ok: false, reason: 'predicate exceeds 256 characters' };
|
|
113
|
+
if (fact.object.length > FIELD_LIMITS.object)
|
|
114
|
+
return { ok: false, reason: 'object exceeds 8192 characters' };
|
|
115
|
+
const scope = tryNormalizeScope(fact.scope);
|
|
116
|
+
if (scope === null)
|
|
117
|
+
return { ok: false, reason: 'scope is not a valid scope, scope:<slug> or project:<slug>' };
|
|
118
|
+
const at = toCanonicalInstant(fact.validFrom);
|
|
119
|
+
if (at === null) {
|
|
120
|
+
return { ok: false, reason: 'valid_from is not an ISO instant with an explicit timezone' };
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, scope, at };
|
|
123
|
+
}
|