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,315 @@
|
|
|
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.assertHandleInside = assertHandleInside;
|
|
7
|
+
exports.appendConfined = appendConfined;
|
|
8
|
+
exports.createConfined = createConfined;
|
|
9
|
+
exports.withConfinedFile = withConfinedFile;
|
|
10
|
+
exports.writeConfined = writeConfined;
|
|
11
|
+
exports.assertConfinedTarget = assertConfinedTarget;
|
|
12
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
13
|
+
const fs_1 = require("fs");
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const durableAppend_1 = require("./durableAppend");
|
|
16
|
+
/**
|
|
17
|
+
* Writes into the vault that cannot be turned into a write somewhere else.
|
|
18
|
+
*
|
|
19
|
+
* The read side (memorymap's resolveVaultFile / ensureRealDir / openConfined) already refuses a symbolic link at any
|
|
20
|
+
* level. These helpers give the write side the same rule, for a vault someone else put files in (a cloned or shared
|
|
21
|
+
* notes repository, a synced folder, an archive): every directory between the vault's real root and the file is a real
|
|
22
|
+
* directory (a link is refused, never followed), the file itself is opened with O_NOFOLLOW and O_NONBLOCK (so a link is
|
|
23
|
+
* an error and a FIFO with no reader is an error, not a hang), and what was opened must be a regular file with one name
|
|
24
|
+
* (a hard link would write through to the file it shares). The vault root itself may be a link: it is where the user put
|
|
25
|
+
* their vault.
|
|
26
|
+
*
|
|
27
|
+
* The walk and the open are two steps, so a process that can write inside the vault could swap a directory for a link
|
|
28
|
+
* between them. After the open and before the first byte, `assertHandleInside` asks the kernel where the open file really
|
|
29
|
+
* is (/proc/self/fd/<fd>) and refuses unless that is under the vault's real root. Node has no openat2(RESOLVE_BENEATH) and
|
|
30
|
+
* /proc exists on Linux only; elsewhere that race stays open (engine/docs/self-hosting.md says so).
|
|
31
|
+
*
|
|
32
|
+
* Files are created 0600 and directories 0700: the vault holds the owner's statements in plain text.
|
|
33
|
+
*
|
|
34
|
+
* Durable as well (adv-crashsafe-1012 F1-F3): `appendConfined` is the one append for the daily log and the ledger. A write
|
|
35
|
+
* that fails part-way is cut back to the length the file had before the call, or removed when the call created it, so a
|
|
36
|
+
* caller told the save failed leaves no fragment; a successful write is fsynced before it returns, and so is the directory
|
|
37
|
+
* entry of a file it created. The cut and the append share one per-file lock (durableAppend.ts).
|
|
38
|
+
*/
|
|
39
|
+
function refusal(relFile, why) {
|
|
40
|
+
return new Error(`refusing to write ${JSON.stringify(relFile)}: ${why}`);
|
|
41
|
+
}
|
|
42
|
+
/** The vault-relative parts of `relFile`, refusing a path that is empty or leaves the vault. */
|
|
43
|
+
function partsOf(relFile) {
|
|
44
|
+
const parts = relFile.split(/[\\/]/).filter(p => p && p !== '.');
|
|
45
|
+
const name = parts.pop();
|
|
46
|
+
if (!name || parts.includes('..') || name === '..' || path_1.default.isAbsolute(relFile)) {
|
|
47
|
+
throw refusal(relFile, 'it is not a path inside the vault');
|
|
48
|
+
}
|
|
49
|
+
return { dirs: parts, name };
|
|
50
|
+
}
|
|
51
|
+
/** The vault's real root; a vault that does not exist yet is created (the writers always did). */
|
|
52
|
+
async function realRoot(vaultRoot) {
|
|
53
|
+
try {
|
|
54
|
+
return await promises_1.default.realpath(vaultRoot);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err.code !== 'ENOENT')
|
|
58
|
+
throw err;
|
|
59
|
+
await promises_1.default.mkdir(vaultRoot, { recursive: true });
|
|
60
|
+
return promises_1.default.realpath(vaultRoot);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Walk the directories below the root, one lstat at a time. `create` makes a missing one (as `mkdir -p` did); without it a
|
|
65
|
+
* missing directory ends the walk with `null`.
|
|
66
|
+
*/
|
|
67
|
+
async function walkDirs(root, relFile, dirs, create) {
|
|
68
|
+
let dir = root;
|
|
69
|
+
for (const part of dirs) {
|
|
70
|
+
dir = path_1.default.join(dir, part);
|
|
71
|
+
let st;
|
|
72
|
+
try {
|
|
73
|
+
st = await promises_1.default.lstat(dir);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err.code !== 'ENOENT')
|
|
77
|
+
throw err;
|
|
78
|
+
if (!create)
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
await promises_1.default.mkdir(dir, { mode: 0o700 });
|
|
82
|
+
}
|
|
83
|
+
catch (e2) {
|
|
84
|
+
if (e2.code !== 'EEXIST')
|
|
85
|
+
throw e2;
|
|
86
|
+
}
|
|
87
|
+
st = await promises_1.default.lstat(dir);
|
|
88
|
+
}
|
|
89
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
90
|
+
throw refusal(relFile, `${JSON.stringify(dirs.join('/'))} is a symbolic link or not a plain directory inside the vault`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Refuse an open file that is not under the vault's real root, whatever path was used to reach it. A directory swapped for
|
|
97
|
+
* a link after the walk makes the open land elsewhere; the kernel's own answer for the descriptor says where. Without
|
|
98
|
+
* /proc (not Linux) there is nothing to ask and the check passes: the residual is documented. A file this call just
|
|
99
|
+
* created outside is left where it landed, empty: a later step never removes it by name, because the name may by then
|
|
100
|
+
* resolve to some other file.
|
|
101
|
+
*/
|
|
102
|
+
async function assertHandleInside(handle, realVaultRoot, relFile) {
|
|
103
|
+
let where;
|
|
104
|
+
try {
|
|
105
|
+
where = await promises_1.default.readlink(`/proc/self/fd/${handle.fd}`);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const code = err.code;
|
|
109
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
110
|
+
return;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
const rel = path_1.default.relative(realVaultRoot, where);
|
|
114
|
+
// '..safe' is a plain name below the root; only a '..' component leaves it.
|
|
115
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${path_1.default.sep}`) || path_1.default.isAbsolute(rel)) {
|
|
116
|
+
throw refusal(relFile, 'the file opened is outside the vault (a directory was replaced by a link while it was written)');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function checkHandle(handle, root, relFile) {
|
|
120
|
+
await assertHandleInside(handle, root, relFile);
|
|
121
|
+
const st = await handle.stat();
|
|
122
|
+
if (!st.isFile() || st.nlink !== 1)
|
|
123
|
+
throw refusal(relFile, 'it is not a regular file with a single name');
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Append to a vault file, creating it (and its directories) when absent. `header` goes in first, and only when this call
|
|
127
|
+
* created the file. Resolves once the bytes are on disk; rejects with the original error after undoing whatever landed.
|
|
128
|
+
* Returns the file's path.
|
|
129
|
+
*/
|
|
130
|
+
async function appendConfined(vaultRoot, relFile, content, header = '') {
|
|
131
|
+
const { dirs, name } = partsOf(relFile);
|
|
132
|
+
const root = await realRoot(vaultRoot);
|
|
133
|
+
const dir = (await walkDirs(root, relFile, dirs, true));
|
|
134
|
+
const file = path_1.default.join(dir, name);
|
|
135
|
+
const key = path_1.default.resolve(vaultRoot, relFile);
|
|
136
|
+
return (0, durableAppend_1.withAppendLock)(key, async () => {
|
|
137
|
+
const refused = (0, durableAppend_1.unrecoverableReason)(key);
|
|
138
|
+
if (refused)
|
|
139
|
+
throw refusal(relFile, `it holds the bytes of a failed write that could not be cut back (${refused}); refusing further appends until the engine restarts, which sets that tail aside`);
|
|
140
|
+
const OPEN = fs_1.constants.O_WRONLY | fs_1.constants.O_APPEND | fs_1.constants.O_NOFOLLOW | fs_1.constants.O_NONBLOCK;
|
|
141
|
+
let handle;
|
|
142
|
+
let created = false;
|
|
143
|
+
try {
|
|
144
|
+
// O_EXCL does not follow a link at the final component: a link there, dangling or not, is EEXIST.
|
|
145
|
+
handle = await promises_1.default.open(file, OPEN | fs_1.constants.O_CREAT | fs_1.constants.O_EXCL, 0o600);
|
|
146
|
+
created = true;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err.code !== 'EEXIST')
|
|
150
|
+
throw err;
|
|
151
|
+
try {
|
|
152
|
+
handle = await promises_1.default.open(file, OPEN);
|
|
153
|
+
}
|
|
154
|
+
catch (e2) {
|
|
155
|
+
const code = e2.code;
|
|
156
|
+
if (code === 'ELOOP' || code === 'ENXIO')
|
|
157
|
+
throw refusal(relFile, 'it is a symbolic link or not a regular file');
|
|
158
|
+
throw e2;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
let before = null;
|
|
162
|
+
let inside = false;
|
|
163
|
+
let failure = null;
|
|
164
|
+
try {
|
|
165
|
+
await assertHandleInside(handle, root, relFile);
|
|
166
|
+
inside = true;
|
|
167
|
+
await checkHandle(handle, root, relFile);
|
|
168
|
+
before = (await handle.stat()).size;
|
|
169
|
+
await handle.appendFile(created ? header + content : content, 'utf8');
|
|
170
|
+
await handle.sync();
|
|
171
|
+
if (created)
|
|
172
|
+
await (0, durableAppend_1.syncDirectory)(dir);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
failure = { error };
|
|
176
|
+
// Cut the partial bytes back. Only a known length is ever truncated to: a failed stat must not be read as zero.
|
|
177
|
+
if (!created && before !== null) {
|
|
178
|
+
await handle.truncate(before).then(() => handle.sync()).catch((cutError) => {
|
|
179
|
+
(0, durableAppend_1.markUnrecoverable)(key, cutError instanceof Error ? cutError.message : String(cutError));
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await handle.close().catch(() => undefined);
|
|
185
|
+
}
|
|
186
|
+
if (failure) {
|
|
187
|
+
// Removed by name only when the open landed inside the vault (adv-crashsafe-1012 review P3-3): when it did not, the
|
|
188
|
+
// name may now resolve to some other file, so the empty file this call made stays where it landed.
|
|
189
|
+
if (created && inside)
|
|
190
|
+
await promises_1.default.rm(file, { force: true }).catch(() => undefined);
|
|
191
|
+
throw failure.error;
|
|
192
|
+
}
|
|
193
|
+
return file;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Create a new vault file, durably: `content` is synced and so is the directory entry. It never replaces anything: a name
|
|
198
|
+
* that is taken (a file, or a link at it, dangling or not) fails with EEXIST, and the caller picks another name.
|
|
199
|
+
* Confined like every other write here. Returns the file's path.
|
|
200
|
+
*/
|
|
201
|
+
async function createConfined(vaultRoot, relFile, content) {
|
|
202
|
+
const { dirs, name } = partsOf(relFile);
|
|
203
|
+
const root = await realRoot(vaultRoot);
|
|
204
|
+
const dir = (await walkDirs(root, relFile, dirs, true));
|
|
205
|
+
const file = path_1.default.join(dir, name);
|
|
206
|
+
const handle = await promises_1.default.open(file, fs_1.constants.O_WRONLY | fs_1.constants.O_CREAT | fs_1.constants.O_EXCL | fs_1.constants.O_NOFOLLOW | fs_1.constants.O_NONBLOCK, 0o600);
|
|
207
|
+
let inside = false;
|
|
208
|
+
let failure = null;
|
|
209
|
+
try {
|
|
210
|
+
await assertHandleInside(handle, root, relFile);
|
|
211
|
+
inside = true;
|
|
212
|
+
await checkHandle(handle, root, relFile);
|
|
213
|
+
await handle.writeFile(content, 'utf8');
|
|
214
|
+
await handle.sync();
|
|
215
|
+
await (0, durableAppend_1.syncDirectory)(dir);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
failure = { error };
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await handle.close().catch(() => undefined);
|
|
222
|
+
}
|
|
223
|
+
if (failure) {
|
|
224
|
+
if (inside)
|
|
225
|
+
await promises_1.default.rm(file, { force: true }).catch(() => undefined);
|
|
226
|
+
throw failure.error;
|
|
227
|
+
}
|
|
228
|
+
return file;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Runs `fn` with an open handle on an existing vault file, opened the way a confined write opens it: the directories on
|
|
232
|
+
* the way are real, the file is opened with O_NOFOLLOW and O_NONBLOCK, and what was opened must be a regular file with one
|
|
233
|
+
* name inside the vault. The handle is read and written by `fn`, and closed after it.
|
|
234
|
+
*/
|
|
235
|
+
async function withConfinedFile(vaultRoot, relFile, fn) {
|
|
236
|
+
const { dirs, name } = partsOf(relFile);
|
|
237
|
+
const root = await realRoot(vaultRoot);
|
|
238
|
+
const dir = await walkDirs(root, relFile, dirs, false);
|
|
239
|
+
if (dir === null)
|
|
240
|
+
throw Object.assign(new Error(`ENOENT: no such file or directory, ${relFile}`), { code: 'ENOENT' });
|
|
241
|
+
let handle;
|
|
242
|
+
try {
|
|
243
|
+
handle = await promises_1.default.open(path_1.default.join(dir, name), fs_1.constants.O_RDWR | fs_1.constants.O_NOFOLLOW | fs_1.constants.O_NONBLOCK);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
const code = err.code;
|
|
247
|
+
if (code === 'ELOOP' || code === 'ENXIO')
|
|
248
|
+
throw refusal(relFile, 'it is a symbolic link or not a regular file');
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
await checkHandle(handle, root, relFile);
|
|
253
|
+
return await fn(handle);
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
await handle.close().catch(() => undefined);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Create a vault file, or replace the content of the regular file already there. Returns the file's path. */
|
|
260
|
+
async function writeConfined(vaultRoot, relFile, content) {
|
|
261
|
+
const { dirs, name } = partsOf(relFile);
|
|
262
|
+
const root = await realRoot(vaultRoot);
|
|
263
|
+
const dir = (await walkDirs(root, relFile, dirs, true));
|
|
264
|
+
const file = path_1.default.join(dir, name);
|
|
265
|
+
let handle;
|
|
266
|
+
try {
|
|
267
|
+
// No O_TRUNC: the file is only emptied once it is known to be a regular file with a single name.
|
|
268
|
+
handle = await promises_1.default.open(file, fs_1.constants.O_WRONLY | fs_1.constants.O_CREAT | fs_1.constants.O_NOFOLLOW | fs_1.constants.O_NONBLOCK, 0o600);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
const code = err.code;
|
|
272
|
+
if (code === 'ELOOP' || code === 'ENXIO')
|
|
273
|
+
throw refusal(relFile, 'it is a symbolic link or not a regular file');
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
await checkHandle(handle, root, relFile);
|
|
278
|
+
await handle.truncate(0);
|
|
279
|
+
await handle.writeFile(content, 'utf8');
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
await handle.close().catch(() => undefined);
|
|
283
|
+
}
|
|
284
|
+
return file;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Refuse, before anything is read or written, a vault file that a confined write would refuse: a link or a directory on
|
|
288
|
+
* the way, or an existing file that is not a regular file with one name. An absent file or directory is fine.
|
|
289
|
+
*/
|
|
290
|
+
async function assertConfinedTarget(vaultRoot, relFile) {
|
|
291
|
+
const { dirs, name } = partsOf(relFile);
|
|
292
|
+
let root;
|
|
293
|
+
try {
|
|
294
|
+
root = await promises_1.default.realpath(vaultRoot);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
if (err.code === 'ENOENT')
|
|
298
|
+
return;
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
const dir = await walkDirs(root, relFile, dirs, false);
|
|
302
|
+
if (dir === null)
|
|
303
|
+
return;
|
|
304
|
+
let st;
|
|
305
|
+
try {
|
|
306
|
+
st = await promises_1.default.lstat(path_1.default.join(dir, name));
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
if (err.code === 'ENOENT')
|
|
310
|
+
return;
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
if (!st.isFile() || st.nlink !== 1)
|
|
314
|
+
throw refusal(relFile, 'it is a symbolic link or not a regular file with a single name');
|
|
315
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.unstorableControls = unstorableControls;
|
|
4
|
+
exports.assertNoUnstorableControls = assertNoUnstorableControls;
|
|
5
|
+
const toolRefusal_1 = require("../toolRefusal");
|
|
6
|
+
/**
|
|
7
|
+
* The control characters a stored memory cannot keep.
|
|
8
|
+
*
|
|
9
|
+
* A memory is text that is read back verbatim — by an agent, by a log, by an editor, by a script that
|
|
10
|
+
* splits on lines — so anything that is not printable text is REFUSED rather than written. The sharp
|
|
11
|
+
* case is NUL (U+0000): both the filesystem and SQLite accept it, so a note carrying one is written,
|
|
12
|
+
* indexed and served as though it were ordinary text, while every C-string boundary in a downstream
|
|
13
|
+
* reader ends the line early. The rest of the class is the same argument: ESC and the other C0
|
|
14
|
+
* controls carry no words, arrive by accident (a binary blob pasted as a note) or on purpose (a
|
|
15
|
+
* terminal escape sequence that rewrites what a transcript shows), and are invisible in a diff.
|
|
16
|
+
*
|
|
17
|
+
* The set is the one this engine already uses for its structured fields (lifecycle.ts CONTROL_CHARS,
|
|
18
|
+
* importing/facts.ts CONTROL_CHARS): every C0 control and DEL. Three are kept, because they are
|
|
19
|
+
* whitespace a note legitimately holds:
|
|
20
|
+
*
|
|
21
|
+
* TAB (U+0009), LF (U+000A) and CR (U+000D) — the carriage return of a Windows line ending. The
|
|
22
|
+
* chunker already splits a daily note on all three, and an entry a Windows user pastes arrives
|
|
23
|
+
* CRLF-terminated, so refusing CR would refuse real memories to fix none. VT and FF (U+000B,
|
|
24
|
+
* U+000C) are kept out: they are not the whitespace anyone types.
|
|
25
|
+
*
|
|
26
|
+
* Where this rule does NOT apply, and why:
|
|
27
|
+
*
|
|
28
|
+
* - The ledger's `statement` field (memory/lifecycle.ts). It is written as ONE JSON string literal,
|
|
29
|
+
* which escapes every C0 control, so a statement keeps any character sequence without being able
|
|
30
|
+
* to break the ledger's framing — the property src/memory/statement-encoding.test.ts proves with
|
|
31
|
+
* a NUL, 4000 NULs, forged heading lines and JSON that tries to close the string. Refusing there
|
|
32
|
+
* would also make a ledger written by an earlier build un-replayable-by-resent (that test loads a
|
|
33
|
+
* literal separator and expects the same words to be a nochange resend).
|
|
34
|
+
* - The structured lifecycle fields (subject, predicate, object, source) in the same file: they
|
|
35
|
+
* already refuse this same set, and tab/newline too, because the ledger framing needs it.
|
|
36
|
+
* - REPLY text. Nothing stored is rewritten, and what a reader sees is made safe on the way out:
|
|
37
|
+
* escapeInvisibleControls (mvp/sanitize.ts) renders every C0 control, C1 control and format
|
|
38
|
+
* character as a `\uXXXX` escape in every JSON reply, so a stored value never applies a hidden
|
|
39
|
+
* character to a transcript while the archive keeps every character.
|
|
40
|
+
*
|
|
41
|
+
* One rule for every path that stores text RAW — the daily note, an imported Markdown note, a request
|
|
42
|
+
* another agent reads — so the answer is the same whichever write tool the caller used.
|
|
43
|
+
*/
|
|
44
|
+
// An unpaired surrogate is refused too: SQLite and the files keep it as CESU-style bytes, and every reader
|
|
45
|
+
// shows it as three replacement characters. A valid pair is one code point, so emoji are not matched.
|
|
46
|
+
const UNSTORABLE_CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]|\p{Cs}/gu;
|
|
47
|
+
/** The distinct unstorable control characters `text` holds, as `U+XXXX`, in the order first seen. */
|
|
48
|
+
function unstorableControls(text) {
|
|
49
|
+
// One pass over the text, and a second only over the offenders: a 64 KiB import note is matched once
|
|
50
|
+
// here rather than tested character by character, which is what a per-code-point loop costs.
|
|
51
|
+
const found = text.match(UNSTORABLE_CONTROL);
|
|
52
|
+
if (found === null)
|
|
53
|
+
return [];
|
|
54
|
+
const codes = [];
|
|
55
|
+
for (const ch of found) {
|
|
56
|
+
const code = `U+${ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`;
|
|
57
|
+
if (!codes.includes(code))
|
|
58
|
+
codes.push(code);
|
|
59
|
+
}
|
|
60
|
+
return codes;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Refuse `text` when it holds a control character a memory cannot keep, naming the code points so the
|
|
64
|
+
* caller sees which one arrived. Nothing was written by any caller when this throws: every write path
|
|
65
|
+
* checks before it opens the file or the ledger.
|
|
66
|
+
*/
|
|
67
|
+
function assertNoUnstorableControls(field, text) {
|
|
68
|
+
const codes = unstorableControls(text);
|
|
69
|
+
if (codes.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
const one = codes.length === 1;
|
|
72
|
+
throw (0, toolRefusal_1.refusal)('invalid_text', `${field} contains ${one ? 'a control character' : 'control characters'} that cannot be stored as text (${codes.join(', ')}); the only C0 controls kept are tab, newline and carriage return, so remove ${one ? 'it' : 'them'} and send the text again. Nothing was written.`);
|
|
73
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The daily-note format, in one place: how a day's file is recognised, how an entry starts, and how the
|
|
4
|
+
* user's own words are kept from being mistaken for an entry.
|
|
5
|
+
*
|
|
6
|
+
* A day's file (`.../Daily/YYYY-MM-DD.md`) holds one entry per statement: a line that starts at column 0
|
|
7
|
+
* with its time and source (`- [08:30:00] (claude) first line`), then the statement's other lines. Those
|
|
8
|
+
* other lines are the user's words and may look like anything, including another entry. The file is read
|
|
9
|
+
* line by line and an entry is whatever starts at column 0 like that, so such a line would forge an entry
|
|
10
|
+
* and split the statement it belongs to.
|
|
11
|
+
*
|
|
12
|
+
* The words are therefore kept behind a reversible escape instead of being altered or refused: a later
|
|
13
|
+
* line that starts like an entry gets one backslash in front (`\- [08:30:00] (alice) secret`, which a
|
|
14
|
+
* Markdown reader shows as the text it was). To keep the escape unambiguous, a line that already starts
|
|
15
|
+
* with backslashes before an entry-shaped start gets one more.
|
|
16
|
+
*
|
|
17
|
+
* The escape is told apart from the user's own text by a mark on the entry that carries it, not by the
|
|
18
|
+
* shape of a line. An entry whose statement was escaped says so in its own header, after the source name
|
|
19
|
+
* (`- [08:30:00] (claude+esc) first line`); only inside such an entry is a backslash line decoded. A note
|
|
20
|
+
* written before the mark existed has no marked entry, so every line of it reads back exactly as stored,
|
|
21
|
+
* including a line whose backslash was the user's own. The mark cannot be forged: the writer keeps a
|
|
22
|
+
* source to letters, digits and hyphens, so no earlier entry has a `+` there, and a statement cannot
|
|
23
|
+
* write a header because its own entry-shaped lines are exactly the ones that get escaped.
|
|
24
|
+
*
|
|
25
|
+
* Reading undoes the escape at the two places that turn a daily file into text (`parseNote` for
|
|
26
|
+
* everything built from the file's text, and the memory map's entry parser), so every reader gets back
|
|
27
|
+
* exactly what was said, and neither the backslash nor the mark (nor the offset an entry on the user's
|
|
28
|
+
* clock carries). Only the raw Markdown file shows them.
|
|
29
|
+
*
|
|
30
|
+
* Both directions split on '\n' alone, exactly as the entry parser does. A JavaScript `m`-flag `^` would
|
|
31
|
+
* also start a line after a lone CR, U+2028 or U+2029, which are not line breaks in this format.
|
|
32
|
+
*/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.DAILY_NOTE_MARKER = exports.DAILY_NOTE_HEADER_KEY = exports.ESCAPE_MARK = exports.ENTRY_LINE_RE = exports.DAILY_FILE_RE = void 0;
|
|
35
|
+
exports.isDailyNotePath = isDailyNotePath;
|
|
36
|
+
exports.dailyNoteDayName = dailyNoteDayName;
|
|
37
|
+
exports.engineDailyHeader = engineDailyHeader;
|
|
38
|
+
exports.isEngineDailyNote = isEngineDailyNote;
|
|
39
|
+
exports.isEmptyEngineDailyNote = isEmptyEngineDailyNote;
|
|
40
|
+
exports.isEngineHeaderDate = isEngineHeaderDate;
|
|
41
|
+
exports.isEarlierHeaderText = isEarlierHeaderText;
|
|
42
|
+
exports.parseEntryHeader = parseEntryHeader;
|
|
43
|
+
exports.entryInstant = entryInstant;
|
|
44
|
+
exports.encodeEntryText = encodeEntryText;
|
|
45
|
+
exports.decodeEntryLine = decodeEntryLine;
|
|
46
|
+
exports.formatEntry = formatEntry;
|
|
47
|
+
exports.decodeDailyNoteText = decodeDailyNoteText;
|
|
48
|
+
/**
|
|
49
|
+
* A daily note: `.../Daily/YYYY-MM-DD.md`. Group 2 is the date.
|
|
50
|
+
*
|
|
51
|
+
* A path under the import namespace (`Imported/<label>/…`) is never a daily note, whatever it is called: the
|
|
52
|
+
* import tool writes untrusted source files as ordinary notes (`Imported/`, importing/hosted.ts), so a source
|
|
53
|
+
* folder called `Daily/` must not turn an imported file into the engine's own daily log. Without this, a file
|
|
54
|
+
* imported at `Daily/YYYY-MM-DD.md` was read as a daily log: its `- [HH:MM:SS] (source) text` lines became
|
|
55
|
+
* entries attributed to a source the importer chose, and a prose file there vanished from memory_map and the
|
|
56
|
+
* export. The engine's own daily logs live under `Memory/Daily/` or `Memory/Scopes/<slug>/Daily/`, never under
|
|
57
|
+
* `Imported/`.
|
|
58
|
+
*/
|
|
59
|
+
exports.DAILY_FILE_RE = /^(?!Imported\/)(?:(.*)\/)?Daily\/(\d{4}-\d{2}-\d{2})\.md$/;
|
|
60
|
+
/**
|
|
61
|
+
* An entry's first line is everything after its prefix, and lines are split on '\n' alone. dotAll, because
|
|
62
|
+
* `.` otherwise refuses U+2028, U+2029 and a lone \r in the text, and an entry whose first line holds one of
|
|
63
|
+
* them stopped being an entry: it was folded into the one above it, so retiring that neighbour took it too.
|
|
64
|
+
*/
|
|
65
|
+
exports.ENTRY_LINE_RE = /^- \[(\d{2}:\d{2}:\d{2})\] \(([^)]*)\) (.*)$/s;
|
|
66
|
+
/** Follows the source name in the header of an entry whose statement carries the escape. */
|
|
67
|
+
exports.ESCAPE_MARK = '+esc';
|
|
68
|
+
/**
|
|
69
|
+
* Follows the source name in the header of an entry stamped on the user's own clock rather than in UTC: that clock's
|
|
70
|
+
* offset from UTC (`- [21:00:01] (cursor, UTC-03:00) ...`). The entry's day and time are then the user's, and the
|
|
71
|
+
* offset keeps the moment exact. An entry without it was stamped in UTC, as every entry was before it existed. A
|
|
72
|
+
* source is kept to letters, digits and hyphens, so no source name can write it; it comes before the escape mark,
|
|
73
|
+
* so a release that does not know it still finds the escape. Like the escape mark it is the file's, not the
|
|
74
|
+
* reader's: readers get the user's day and time without it (so it is never searched for, nor paid for in tokens),
|
|
75
|
+
* and the memory map reads it from the file for the exact moment.
|
|
76
|
+
*/
|
|
77
|
+
const OFFSET_MARK_RE = /, UTC([+-]\d{2}:\d{2})$/;
|
|
78
|
+
const offsetMark = (offset) => (offset ? `, UTC${offset}` : '');
|
|
79
|
+
/** A line that starts like an entry (whatever follows the parenthesis), behind any number of backslashes. */
|
|
80
|
+
const ENTRY_SHAPED = /^\\*- \[\d{2}:\d{2}:\d{2}\] \(/;
|
|
81
|
+
/** The same, with at least one backslash: a line the writer escaped. */
|
|
82
|
+
const ESCAPED_ENTRY_SHAPED = /^\\+- \[\d{2}:\d{2}:\d{2}\] \(/;
|
|
83
|
+
/** Is this file one of the engine's daily notes? Separators are normalised so a Windows path matches too. */
|
|
84
|
+
function isDailyNotePath(filePath) {
|
|
85
|
+
return exports.DAILY_FILE_RE.test(filePath.replace(/\\/g, '/'));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The front-matter key the engine writes into every daily note it creates (`updated: YYYY-MM-DD`, in writer.ts and
|
|
89
|
+
* where the memory map restores an entry into a day that has no file). It is the day the file is about.
|
|
90
|
+
*/
|
|
91
|
+
exports.DAILY_NOTE_HEADER_KEY = 'updated';
|
|
92
|
+
/** The day a daily note's own name gives (`.../Daily/2026-10-03.md` is `2026-10-03`), or null when the path is no daily note or the name is no real day. */
|
|
93
|
+
function dailyNoteDayName(filePath) {
|
|
94
|
+
const match = exports.DAILY_FILE_RE.exec(filePath.replace(/\\/g, '/'));
|
|
95
|
+
if (!match)
|
|
96
|
+
return null;
|
|
97
|
+
const at = Date.parse(`${match[2]}T00:00:00Z`);
|
|
98
|
+
// `2026-02-31` parses (as March 3rd): only a name that survives the round trip is a day.
|
|
99
|
+
return !Number.isNaN(at) && new Date(at).toISOString().slice(0, 10) === match[2] ? match[2] : null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The line the engine writes into the front matter of every daily note it creates. It is how a note the engine wrote is
|
|
103
|
+
* told from a person's note in the same folder: not by where the note is, what it is called or what it says (a person
|
|
104
|
+
* writes `updated: 2026-03-30` and timestamped lines too), but by a stamp that nothing but the engine's writers puts
|
|
105
|
+
* there; a person would have to copy it on purpose. Bump the version, never the key, if the layout changes.
|
|
106
|
+
*/
|
|
107
|
+
exports.DAILY_NOTE_MARKER = 'renor: daily-v1';
|
|
108
|
+
/**
|
|
109
|
+
* What the engine writes at the top of every daily note it creates (writer.ts, one write with the first entry after it,
|
|
110
|
+
* and the memory map when it restores an entry into a day that has no file): its `kind: note`, the day as a bare YAML
|
|
111
|
+
* date, the marker, and the heading. A test pins both writers to this function.
|
|
112
|
+
*
|
|
113
|
+
* Before the marker existed the engine wrote the same without its line, and those notes are still on disk (see
|
|
114
|
+
* `isEngineDailyNote`).
|
|
115
|
+
*/
|
|
116
|
+
function engineDailyHeader(day) {
|
|
117
|
+
return `---\nkind: note\nupdated: ${day}\n${exports.DAILY_NOTE_MARKER}\n---\n# ${day}\n`;
|
|
118
|
+
}
|
|
119
|
+
/** The text between the fences of the front matter that opens `head`, or null when it does not open with one that closes in it. */
|
|
120
|
+
function frontMatterBlock(head) {
|
|
121
|
+
if (!head.startsWith('---\n'))
|
|
122
|
+
return null;
|
|
123
|
+
const end = head.indexOf('\n---\n', 3);
|
|
124
|
+
return end === -1 ? null : head.slice(4, Math.max(4, end));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Did the engine write this daily note? One rule, over the front matter alone, never the body (a person can write
|
|
128
|
+
* anything in the body, timestamped lines in the engine's own format included). The note must be in the daily layout
|
|
129
|
+
* with a real day for its name, and then:
|
|
130
|
+
*
|
|
131
|
+
* - a note the engine has written since it stamps its notes carries the marker line, `renor: daily-v1`, in its front
|
|
132
|
+
* matter;
|
|
133
|
+
* - a note written before that has none, and is told only by being the complete front matter the engine then wrote,
|
|
134
|
+
* every key, value and its order and nothing else in it: `kind: note`, then `updated:` with the day of the note's own
|
|
135
|
+
* name (the engine wrote nothing else, so nothing else matches).
|
|
136
|
+
*
|
|
137
|
+
* A note that is neither keeps what it says: a natural `updated: 2026-03-30`, a further or reordered key, a quoted date,
|
|
138
|
+
* another day, a marker-less note anywhere else, a marker in the body. What this cannot do is tell an old note of the
|
|
139
|
+
* engine's from a person's note that copies its two lines exactly, or one that copies the marker: they are the same
|
|
140
|
+
* bytes. That is why the answer is about the note and the fact dropped is narrower (`isEngineHeaderDate`).
|
|
141
|
+
*
|
|
142
|
+
* `head` is the start of the file (the front matter is a few dozen bytes; a front matter that does not close in `head`
|
|
143
|
+
* is not the engine's).
|
|
144
|
+
*/
|
|
145
|
+
function isEngineDailyNote(filePath, head) {
|
|
146
|
+
const day = dailyNoteDayName(filePath);
|
|
147
|
+
const block = day === null ? null : frontMatterBlock(head);
|
|
148
|
+
if (day === null || block === null)
|
|
149
|
+
return false;
|
|
150
|
+
return block.split('\n').includes(exports.DAILY_NOTE_MARKER) || block === `kind: note\nupdated: ${day}`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Does this daily note hold nothing but what the engine wrote at its top: its own front matter (`isEngineDailyNote`), the
|
|
154
|
+
* heading of its day, and blank lines? That is what is left of a day whose last entry was retired, and none of it is
|
|
155
|
+
* anyone's memory. A line a person wrote anywhere in it, or a front matter that is theirs, makes it a note that holds
|
|
156
|
+
* something.
|
|
157
|
+
*/
|
|
158
|
+
function isEmptyEngineDailyNote(filePath, content) {
|
|
159
|
+
const day = dailyNoteDayName(filePath);
|
|
160
|
+
if (day === null || !isEngineDailyNote(filePath, content))
|
|
161
|
+
return false;
|
|
162
|
+
const body = content.slice(content.indexOf('\n---\n', 3) + '\n---\n'.length);
|
|
163
|
+
return body.split('\n').every(line => line === `# ${day}` || /^[ \t\r]*$/.test(line));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Is this front-matter value the engine's own `updated`: the bare date of the note's own day, which YAML reads as that
|
|
167
|
+
* day's midnight (UTC)? In a note the engine wrote that is the only value of `updated` that is bookkeeping. A person's
|
|
168
|
+
* own value in it (a plugin that stamps the time it was saved, another day, a quoted date) is theirs and is kept.
|
|
169
|
+
*/
|
|
170
|
+
function isEngineHeaderDate(filePath, value) {
|
|
171
|
+
const day = dailyNoteDayName(filePath);
|
|
172
|
+
return day !== null && value instanceof Date && value.getTime() === Date.parse(`${day}T00:00:00Z`);
|
|
173
|
+
}
|
|
174
|
+
/** What `Date.prototype.toString` prints: the Date a bare YAML date becomes, in the timezone of the host that printed it. */
|
|
175
|
+
const DATE_TO_STRING_RE = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4}(?: \(.+\))?$/;
|
|
176
|
+
/**
|
|
177
|
+
* Is this the text an earlier indexer stored as the "fact" `updated` of a daily note's header: the Date of the note's
|
|
178
|
+
* own day, printed in the timezone of the host that indexed it (any zone: it is read back to the day)? It says what the
|
|
179
|
+
* row looks like; whether the note is the engine's is `isEngineDailyNote`'s to say.
|
|
180
|
+
*/
|
|
181
|
+
function isEarlierHeaderText(filePath, text) {
|
|
182
|
+
const day = dailyNoteDayName(filePath);
|
|
183
|
+
return day !== null && typeof text === 'string' && DATE_TO_STRING_RE.test(text) && Date.parse(text) === Date.parse(`${day}T00:00:00Z`);
|
|
184
|
+
}
|
|
185
|
+
/** The parts of an entry's first line, or null when the line does not start an entry. */
|
|
186
|
+
function parseEntryHeader(line) {
|
|
187
|
+
if (!line.startsWith('- ['))
|
|
188
|
+
return null; // most lines of a note: skip the regex
|
|
189
|
+
const m = exports.ENTRY_LINE_RE.exec(line);
|
|
190
|
+
if (!m)
|
|
191
|
+
return null;
|
|
192
|
+
const escaped = m[2].endsWith(exports.ESCAPE_MARK);
|
|
193
|
+
const named = escaped ? m[2].slice(0, -exports.ESCAPE_MARK.length) : m[2];
|
|
194
|
+
const clock = OFFSET_MARK_RE.exec(named);
|
|
195
|
+
return {
|
|
196
|
+
time: m[1],
|
|
197
|
+
source: clock ? named.slice(0, clock.index) : named,
|
|
198
|
+
...(clock ? { offset: clock[1] } : {}),
|
|
199
|
+
escaped,
|
|
200
|
+
text: m[3],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* When an entry was saved, as an ISO instant: the day of its file and the time of its header, on the clock the header
|
|
205
|
+
* names (UTC when it names none, exactly as before offsets existed). An offset that makes no real time, which only a
|
|
206
|
+
* hand edit can write, is read as UTC too.
|
|
207
|
+
*/
|
|
208
|
+
function entryInstant(day, header) {
|
|
209
|
+
const utc = `${day}T${header.time}.000Z`;
|
|
210
|
+
if (!header.offset)
|
|
211
|
+
return utc;
|
|
212
|
+
const at = Date.parse(`${day}T${header.time}${header.offset}`);
|
|
213
|
+
return Number.isNaN(at) ? utc : new Date(at).toISOString();
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A statement as it is written into an entry. The first line follows the entry's prefix, so it is never at
|
|
217
|
+
* column 0; only the later lines can start like an entry and need the escape. `escaped` says whether any
|
|
218
|
+
* did, which is what the entry's header must then record.
|
|
219
|
+
*/
|
|
220
|
+
function encodeEntryText(text) {
|
|
221
|
+
const lines = text.split('\n');
|
|
222
|
+
let escaped = false;
|
|
223
|
+
for (let i = 1; i < lines.length; i++) {
|
|
224
|
+
if (ENTRY_SHAPED.test(lines[i])) {
|
|
225
|
+
lines[i] = `\\${lines[i]}`;
|
|
226
|
+
escaped = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { text: lines.join('\n'), escaped };
|
|
230
|
+
}
|
|
231
|
+
/** The inverse of the escape for one line of an escaped entry. */
|
|
232
|
+
function decodeEntryLine(line) {
|
|
233
|
+
return ESCAPED_ENTRY_SHAPED.test(line) ? line.slice(1) : line;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* One entry exactly as the writer stores it (without the line break that separates entries). `escape` is
|
|
237
|
+
* false for a file that is not in the daily-note layout, which is written as said. `offset` is given when
|
|
238
|
+
* `time` is on the user's clock rather than UTC.
|
|
239
|
+
*/
|
|
240
|
+
function formatEntry(time, source, text, escape = true, offset) {
|
|
241
|
+
const body = escape ? encodeEntryText(text) : { text, escaped: false };
|
|
242
|
+
return `- [${time}] (${source}${offsetMark(offset)}${body.escaped ? exports.ESCAPE_MARK : ''}) ${body.text}`;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The inverse for the text of a whole daily note: inside each marked entry every escaped line gets its
|
|
246
|
+
* words back, and the header loses the mark; a header loses its offset too. An entry with neither is left
|
|
247
|
+
* exactly as stored, and so is everything before the first entry. Applying it twice changes nothing more,
|
|
248
|
+
* because the first pass removes the marks.
|
|
249
|
+
*/
|
|
250
|
+
function decodeDailyNoteText(content) {
|
|
251
|
+
const lines = content.split('\n');
|
|
252
|
+
let inEscapedEntry = false;
|
|
253
|
+
for (let i = 0; i < lines.length; i++) {
|
|
254
|
+
const header = parseEntryHeader(lines[i]);
|
|
255
|
+
if (header) {
|
|
256
|
+
inEscapedEntry = header.escaped;
|
|
257
|
+
if (header.escaped || header.offset)
|
|
258
|
+
lines[i] = `- [${header.time}] (${header.source}) ${header.text}`;
|
|
259
|
+
}
|
|
260
|
+
else if (inEscapedEntry) {
|
|
261
|
+
lines[i] = decodeEntryLine(lines[i]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return lines.join('\n');
|
|
265
|
+
}
|