bantamkit-mcp 0.29.2 → 0.30.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/README.md +1 -1
- package/assets/tools/memory_dream.json +20 -0
- package/assets/tools/repo_map.json +44 -0
- package/dist/eventlog.js +5 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/mcp/pyargs.d.ts +1 -1
- package/dist/mcp/pyargs.js +68 -0
- package/dist/mcp/server.d.ts +36 -6
- package/dist/mcp/server.js +152 -5
- package/dist/memory/cli.js +1 -1
- package/dist/memory/component.d.ts +73 -2
- package/dist/memory/component.js +157 -5
- package/dist/memory/dream.d.ts +247 -0
- package/dist/memory/dream.js +903 -0
- package/dist/memory/store.d.ts +45 -1
- package/dist/memory/store.js +107 -2
- package/dist/repomap.d.ts +247 -0
- package/dist/repomap.js +1040 -0
- package/package.json +1 -1
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded consolidation ACROSS the memory layers. The Node half of `dream.py`.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS AND WHY THE KEY IS THE NAME. J45-1 measured the live population before a
|
|
5
|
+
* line of either half was written: ZERO duplicate pairs inside either store at
|
|
6
|
+
* `DUPLICATE_JACCARD` 0.5 or at a 0.35 floor (`MemoryStore.save` already refuses at 0.5, so
|
|
7
|
+
* a store built through `save` is duplicate-free by construction), 14 name collisions
|
|
8
|
+
* ACROSS the project and profile layers, 13 of them byte-identical, and ZERO cross-layer
|
|
9
|
+
* pairs above 0.35 that do not already share a name. The fourteenth has DIVERGED and its
|
|
10
|
+
* two bodies score 0.333 — BELOW the threshold this runtime calls a duplicate. So the key
|
|
11
|
+
* is NAME EQUALITY, similarity is computed and REPORTED (`DreamResult.similarUnmerged`) and
|
|
12
|
+
* never acted on, and the merge runs between two stores rather than inside one.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IT DOES NOT BUY, said here because the reply, the docs and the CHANGELOG must not
|
|
15
|
+
* claim otherwise: IT DOES NOT SAVE MEANINGFUL TOKENS. The profile store has no `index.md`
|
|
16
|
+
* on disk and never has — its index is derived by `indexText()` at read time and is not
|
|
17
|
+
* loaded from a file — so deduplicating it frees approximately zero prompt bytes. What it
|
|
18
|
+
* buys is CORRECTNESS: one copy of a user ruling instead of two that have already diverged.
|
|
19
|
+
*
|
|
20
|
+
* ---------------------------------------------------------------------------------------
|
|
21
|
+
* THE PORT CONTRACT, AND THE SIX PLACES THE TWO LANGUAGES WOULD HAVE DISAGREED BY DEFAULT
|
|
22
|
+
* ---------------------------------------------------------------------------------------
|
|
23
|
+
* `tools/conformance/` compares this module's answer against `dream.py`'s byte for byte, so
|
|
24
|
+
* every regex class, every sort and every number format below is PINNED to CPython's
|
|
25
|
+
* semantics rather than left to JavaScript's. Each was measured over all 1,114,112
|
|
26
|
+
* codepoints (or, for the last, over the exact binary value) before it was written:
|
|
27
|
+
*
|
|
28
|
+
* 1. `\s` — CPython's `\s` for a `str` pattern is `Py_UNICODE_ISSPACE`, which is `pysem`'s
|
|
29
|
+
* `PY_WS`: 29 codepoints. JS `\s` is 25 and the two DISAGREE ON SIX (Python also has
|
|
30
|
+
* U+001C..U+001F and U+0085, JS also has U+FEFF). Measured symmetric difference of
|
|
31
|
+
* `PY_WS_CLASS` against CPython's `\s`: **0**. Every `\s` below is `PY_WS_CLASS`.
|
|
32
|
+
* 2. `\d` — CPython's is `Nd`, JS's is `[0-9]`. `\p{Nd}` with the `u` flag is CPython's
|
|
33
|
+
* class, exactly (the only 80 disagreements are codepoints assigned in Unicode 16.0,
|
|
34
|
+
* which this Node's ICU has and CPython 3.12's UCD 15.0 does not). A count captured
|
|
35
|
+
* that way then has to be READ like Python's `int()`, which accepts any `Nd` digit —
|
|
36
|
+
* see `pyIntDigits`.
|
|
37
|
+
* 3. `\b` — CPython's `\w` for a `str` pattern is `isalnum() or '_'`, which is exactly
|
|
38
|
+
* `[\p{L}\p{N}_]` (measured: CPython's `\w` is a strict subset of it, and every one of
|
|
39
|
+
* the 5,004 extras is a Unicode 16.0 assignment). JS's `\b` without `u` is ASCII-only,
|
|
40
|
+
* so `étoday` would match there and not in Python. Spelled as lookarounds below.
|
|
41
|
+
* 4. `.` — CPython's `.` without `DOTALL` is `[^\n]`. JS's also excludes `\r`, U+2028 and
|
|
42
|
+
* U+2029, and U+2028/9 SURVIVE `splitBlocks` (they are whitespace, not line ends, to
|
|
43
|
+
* `split`). `[^\n]` is written out rather than `.`.
|
|
44
|
+
* 5. `sorted()` on `str` — CPython orders by CODEPOINT, `Array.prototype.sort` by UTF-16
|
|
45
|
+
* code unit. `cmpCodepoint`, as everywhere else in this package.
|
|
46
|
+
* 6. `f"{x:.3f}"` — CPython rounds HALF TO EVEN on the exact binary value;
|
|
47
|
+
* `Number.prototype.toFixed` rounds ties AWAY from zero. They disagree on four of the
|
|
48
|
+
* eight reachable ties (`1/16` prints `0.062` there and `0.063` here, and `5/16`,
|
|
49
|
+
* `9/16`, `13/16` likewise). `formatFixed3` does the exact BigInt arithmetic.
|
|
50
|
+
*
|
|
51
|
+
* `str.lower()` is NOT on that list, and that is a measurement rather than an assumption:
|
|
52
|
+
* over every codepoint `toLowerCase()` and CPython's `lower()` disagree 27 times and all 27
|
|
53
|
+
* are Unicode 16.0 assignments CPython 3.12 has no mapping for. The final-sigma context
|
|
54
|
+
* rule fires identically on both (`ΟΔΟΣ` -> `οδος`). `toLowerCase()` is used as-is.
|
|
55
|
+
*/
|
|
56
|
+
import { cmpCodepoint, PY_WS, PY_WS_CLASS, pyStrip } from '../pysem.js';
|
|
57
|
+
import { formatFact } from './factfile.js';
|
|
58
|
+
import { pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyParent, pyReplace, } from './pyfs.js';
|
|
59
|
+
import { DUPLICATE_JACCARD, jaccard, MemoryStore, pyCompareLt, pyEqualValue, pyHashKey, pyText, tokens, } from './store.js';
|
|
60
|
+
import { statSync } from 'node:fs';
|
|
61
|
+
/**
|
|
62
|
+
* The two layer names this pass knows. A read-only GRANT is deliberately not one of them: a
|
|
63
|
+
* grant is another operator's store, and consuming a fact out of it is not this person's to
|
|
64
|
+
* do. Grants are neither merged nor scanned.
|
|
65
|
+
*/
|
|
66
|
+
export const PROJECT_LAYER = 'project';
|
|
67
|
+
export const PROFILE_LAYER = 'profile';
|
|
68
|
+
/**
|
|
69
|
+
* The heading the union writes above claims a contradiction retired. Distinctive on
|
|
70
|
+
* purpose: a body that already contains an ordinary `## superseded` section must not be
|
|
71
|
+
* mistaken for one this pass wrote.
|
|
72
|
+
*/
|
|
73
|
+
export const SUPERSEDED_HEADING = '## superseded by a dream merge';
|
|
74
|
+
// -------------------------------------------------------------- CPython's character classes
|
|
75
|
+
/** `\w` for a `str` pattern: `isalnum() or '_'`. See note 3 in the header. */
|
|
76
|
+
const WORD = '[\\p{L}\\p{N}_]';
|
|
77
|
+
/** `\b` where the neighbouring token character is a word character — which is every one below. */
|
|
78
|
+
const NB = `(?<!${WORD})`;
|
|
79
|
+
const NA = `(?!${WORD})`;
|
|
80
|
+
/** `\s`, CPython's. See note 1. */
|
|
81
|
+
const WS = PY_WS_CLASS;
|
|
82
|
+
/** `\S`, CPython's — the same 29 codepoints, negated. */
|
|
83
|
+
const NOT_WS = `[^${PY_WS.replace(/[\]\\^-]/g, '\\$&')}]`;
|
|
84
|
+
/** `\d`, CPython's. See note 2. */
|
|
85
|
+
const ND = '\\p{Nd}';
|
|
86
|
+
/** `.` without `DOTALL`, CPython's. See note 4. */
|
|
87
|
+
const DOT = '[^\\n]';
|
|
88
|
+
/**
|
|
89
|
+
* Relative terms whose resolution is EXACT DAY ARITHMETIC against a date. Nothing vaguer is
|
|
90
|
+
* in here: `recently` and `last month` are in `UNRESOLVED` and are reported rather than
|
|
91
|
+
* rewritten, because substituting a day for them would invent a precision the writer did
|
|
92
|
+
* not have.
|
|
93
|
+
*/
|
|
94
|
+
const RELATIVE = new RegExp(`${NB}(?<simple>today|tonight|yesterday|tomorrow)${NA}` +
|
|
95
|
+
`|${NB}(?<now>right${WS}+now|just${WS}+now)${NA}` +
|
|
96
|
+
`|${NB}(?<count>${ND}+)${WS}+(?<unit>days?|weeks?)${WS}+ago${NA}`, 'giu');
|
|
97
|
+
/**
|
|
98
|
+
* Relative terms this pass REFUSES to resolve. They are reported in `DreamResult.unresolved`
|
|
99
|
+
* so a reader knows the body still carries an undated claim, and the text is left exactly as
|
|
100
|
+
* the writer left it.
|
|
101
|
+
*/
|
|
102
|
+
const UNRESOLVED = new RegExp(`${NB}recently${NA}` +
|
|
103
|
+
`|${NB}(?:last|this|next)${WS}+(?:week|month|year|quarter|night|session|time)${NA}` +
|
|
104
|
+
`|${NB}${ND}+${WS}+(?:months?|years?)${WS}+ago${NA}`, 'giu');
|
|
105
|
+
/**
|
|
106
|
+
* A date this pass has already stamped. Skipping a match that carries one is what makes
|
|
107
|
+
* absolutisation IDEMPOTENT: a second dream over the same body writes nothing.
|
|
108
|
+
*
|
|
109
|
+
* Anchored with `^` and applied to the text AFTER the match, which is `re.match`'s semantics.
|
|
110
|
+
*/
|
|
111
|
+
const STAMPED = new RegExp(`^${WS}*\\(${ND}{4}-${ND}{2}-${ND}{2}\\)`, 'u');
|
|
112
|
+
/**
|
|
113
|
+
* Blocks are paragraphs: runs of text separated by a blank line. Line endings are normalised
|
|
114
|
+
* first so a CRLF body blocks identically to an LF one on every platform. No `\s` here on
|
|
115
|
+
* purpose — the reference spells `[ \t]` and a widening would eat a U+2028 that is INSIDE a
|
|
116
|
+
* paragraph.
|
|
117
|
+
*/
|
|
118
|
+
const BLOCK_SPLIT = /\n[ \t]*\n+/;
|
|
119
|
+
/** A leading markdown bullet or heading marker, stripped before a block is read as a claim. */
|
|
120
|
+
const BULLET = new RegExp(`^(?:[-*+]${WS}+|#+${WS}+)`, 'u');
|
|
121
|
+
/**
|
|
122
|
+
* `Subject: value` on ONE line — the only shape this pass will read as a claim slot, and
|
|
123
|
+
* deliberately narrow. `subject` may not contain a colon (the split would be ambiguous),
|
|
124
|
+
* must start with a letter, and is capped at 60 characters; a value beginning `//` is
|
|
125
|
+
* refused, which is what stops `https://a/x` colliding with `https://b/y` on the subject
|
|
126
|
+
* `https`. The narrowness is the point: a contradiction rule that fired on ordinary prose
|
|
127
|
+
* would supersede claims nobody contradicted, and this pass may never drop what a person
|
|
128
|
+
* wrote.
|
|
129
|
+
*/
|
|
130
|
+
const SLOT = new RegExp(`^(?<subject>[^:\\n]{1,60}?)${WS}*:${WS}*(?<value>${NOT_WS}${DOT}*)$`, 'u');
|
|
131
|
+
const SUBJECT_OK = /^[A-Za-z][A-Za-z0-9 ._'()\[\]-]*$/u;
|
|
132
|
+
// ------------------------------------------------- CPython semantics this module needs alone
|
|
133
|
+
/** A Python `TypeError`/`AttributeError`, raised where CPython raises one and with its sentence. */
|
|
134
|
+
function pyError(kind, message) {
|
|
135
|
+
const e = kind === 'TypeError' ? new TypeError(message) : new Error(message);
|
|
136
|
+
e.name = kind;
|
|
137
|
+
return e;
|
|
138
|
+
}
|
|
139
|
+
const typeName = (v) => v === null ? 'NoneType' : typeof v === 'string' ? 'str' : v.pyType;
|
|
140
|
+
/**
|
|
141
|
+
* `value.strip()` where `value` came out of frontmatter with no type check.
|
|
142
|
+
*
|
|
143
|
+
* `_facts` puts `yaml.safe_load`'s answer straight into the dataclass, so `description: 2026`
|
|
144
|
+
* really does give the store an `int` in a `str` field. `here.description.strip()` on one is
|
|
145
|
+
* an `AttributeError` in the reference and escapes `dream()` uncaught; it is reproduced
|
|
146
|
+
* rather than smoothed over, because a caller that catches `MemoryValidationError` would
|
|
147
|
+
* swallow the smoothed version and not this one.
|
|
148
|
+
*/
|
|
149
|
+
function pyAttrStrip(value) {
|
|
150
|
+
if (typeof value !== 'string') {
|
|
151
|
+
throw pyError('AttributeError', `'${typeName(value)}' object has no attribute 'strip'`);
|
|
152
|
+
}
|
|
153
|
+
return pyStrip(value);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The text `re.sub` would accept. `collapse` is `re.sub(r"\s+", " ", text)`, and CPython's
|
|
157
|
+
* `re` refuses a non-string with this sentence before it looks at the pattern.
|
|
158
|
+
*/
|
|
159
|
+
function pyReText(value) {
|
|
160
|
+
if (typeof value !== 'string') {
|
|
161
|
+
throw pyError('TypeError', `expected string or bytes-like object, got '${typeName(value)}'`);
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `int(text)` for a run of `\p{Nd}` digits, which is what CPython's `int()` accepts.
|
|
167
|
+
*
|
|
168
|
+
* A decimal-digit block is exactly ten consecutive codepoints, so the value of a digit is
|
|
169
|
+
* its distance from the block's zero and the block's zero is found by walking back while the
|
|
170
|
+
* previous codepoint is still `Nd` — no table, and correct for every script including the
|
|
171
|
+
* ones assigned after this file was written.
|
|
172
|
+
*/
|
|
173
|
+
export function pyIntDigits(text) {
|
|
174
|
+
const nd = /\p{Nd}/u;
|
|
175
|
+
let out = 0;
|
|
176
|
+
for (const ch of text) {
|
|
177
|
+
const cp = ch.codePointAt(0);
|
|
178
|
+
let zero = cp;
|
|
179
|
+
while (zero > 0 && cp - zero < 9 && nd.test(String.fromCodePoint(zero - 1)))
|
|
180
|
+
zero -= 1;
|
|
181
|
+
out = out * 10 + (cp - zero);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* `f"{value:.3f}"` — round HALF TO EVEN on the exact binary value of the double.
|
|
187
|
+
*
|
|
188
|
+
* NOT `toFixed(3)`, which rounds ties AWAY from zero. The reachable ties are exactly the
|
|
189
|
+
* odd sixteenths (a tie at three places needs the exact value to be `k/10000` with `k`
|
|
190
|
+
* ending in 5, and only `j/16` is both that and a double), and four of the eight disagree:
|
|
191
|
+
* CPython prints `0.062`, `0.312`, `0.562`, `0.812` where `toFixed` prints `0.063`, `0.313`,
|
|
192
|
+
* `0.563`, `0.813`. A Jaccard of `1/16` is one shared token in a sixteen-token union, which
|
|
193
|
+
* is an ordinary pair and not a contrived one.
|
|
194
|
+
*
|
|
195
|
+
* The arithmetic is exact: the double is decomposed to `mantissa * 2**exponent` over
|
|
196
|
+
* `BigInt`, so nothing here rounds twice.
|
|
197
|
+
*/
|
|
198
|
+
export function formatFixed3(value) {
|
|
199
|
+
if (!Number.isFinite(value))
|
|
200
|
+
return value > 0 ? 'inf' : Number.isNaN(value) ? 'nan' : '-inf';
|
|
201
|
+
const negative = value < 0 || Object.is(value, -0);
|
|
202
|
+
const x = Math.abs(value);
|
|
203
|
+
const view = new DataView(new ArrayBuffer(8));
|
|
204
|
+
view.setFloat64(0, x);
|
|
205
|
+
const bits = view.getBigUint64(0);
|
|
206
|
+
const rawExponent = Number((bits >> 52n) & 0x7ffn);
|
|
207
|
+
const rawMantissa = bits & 0xfffffffffffffn;
|
|
208
|
+
// Subnormals carry no implicit leading bit and sit one exponent higher than the field says.
|
|
209
|
+
const mantissa = rawExponent === 0 ? rawMantissa : rawMantissa | (1n << 52n);
|
|
210
|
+
const exponent = (rawExponent === 0 ? 1 : rawExponent) - 1075;
|
|
211
|
+
// value = mantissa * 2**exponent, exactly. Want round-half-even of value * 1000.
|
|
212
|
+
let numerator = mantissa * 1000n;
|
|
213
|
+
let denominator = 1n;
|
|
214
|
+
if (exponent >= 0)
|
|
215
|
+
numerator <<= BigInt(exponent);
|
|
216
|
+
else
|
|
217
|
+
denominator = 1n << BigInt(-exponent);
|
|
218
|
+
let quotient = numerator / denominator;
|
|
219
|
+
const remainder = numerator % denominator;
|
|
220
|
+
const twice = remainder * 2n;
|
|
221
|
+
if (twice > denominator || (twice === denominator && quotient % 2n === 1n))
|
|
222
|
+
quotient += 1n;
|
|
223
|
+
const digits = quotient.toString().padStart(4, '0');
|
|
224
|
+
const whole = digits.slice(0, -3);
|
|
225
|
+
return `${negative && quotient !== 0n ? '-' : ''}${whole}.${digits.slice(-3)}`;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `sorted(values)` over what a `Fact.name` can hold — CPython's `listsort` for a single run.
|
|
229
|
+
*
|
|
230
|
+
* Every name a `str` is the total order `cmpCodepoint` gives. Anything else can RAISE, and
|
|
231
|
+
* then WHICH pair CPython happened to probe decides the sentence, so the slow path is
|
|
232
|
+
* `count_run` + `binarysort` exactly as `sortScored` spells it: pivot on the LEFT of every
|
|
233
|
+
* probe, midpoint `l + ((r - l) >> 1)`, `==` short-circuiting `<` so two facts both named
|
|
234
|
+
* `None` order rather than raise.
|
|
235
|
+
*
|
|
236
|
+
* THE EDGE, STATED: the reference sorts a `set`, whose iteration order is hash-derived, so
|
|
237
|
+
* for a list that RAISES the two runtimes can name a different pair of types. Where the
|
|
238
|
+
* order exists — every name a `str`, which is every store `save` has ever written — the
|
|
239
|
+
* answer is identical.
|
|
240
|
+
*/
|
|
241
|
+
export function sortedNames(values) {
|
|
242
|
+
if (values.every((v) => typeof v === 'string')) {
|
|
243
|
+
return [...values].sort((a, b) => cmpCodepoint(a, b));
|
|
244
|
+
}
|
|
245
|
+
const lt = (x, y) => pyEqualValue(x, y) ? false : pyCompareLt(x, y);
|
|
246
|
+
const a = [...values];
|
|
247
|
+
const n = a.length;
|
|
248
|
+
if (n < 2)
|
|
249
|
+
return a;
|
|
250
|
+
let k;
|
|
251
|
+
if (lt(a[1], a[0])) {
|
|
252
|
+
let i = 2;
|
|
253
|
+
while (i < n && lt(a[i], a[i - 1]))
|
|
254
|
+
i += 1;
|
|
255
|
+
k = i;
|
|
256
|
+
a.splice(0, k, ...a.slice(0, k).reverse());
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
let i = 2;
|
|
260
|
+
while (i < n && !lt(a[i], a[i - 1]))
|
|
261
|
+
i += 1;
|
|
262
|
+
k = i;
|
|
263
|
+
}
|
|
264
|
+
for (let start = Math.max(k, 1); start < n; start += 1) {
|
|
265
|
+
const pivot = a[start];
|
|
266
|
+
let l = 0;
|
|
267
|
+
let r = start;
|
|
268
|
+
while (l < r) {
|
|
269
|
+
const p = l + ((r - l) >> 1);
|
|
270
|
+
if (lt(pivot, a[p]))
|
|
271
|
+
r = p;
|
|
272
|
+
else
|
|
273
|
+
l = p + 1;
|
|
274
|
+
}
|
|
275
|
+
a.copyWithin(l + 1, l, start);
|
|
276
|
+
a[l] = pivot;
|
|
277
|
+
}
|
|
278
|
+
return a;
|
|
279
|
+
}
|
|
280
|
+
// --------------------------------------------------------- proleptic Gregorian day arithmetic
|
|
281
|
+
const DAYS_IN_MONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
282
|
+
/** `datetime.MAXYEAR`'s last day as an ordinal: `date.max.toordinal()`. */
|
|
283
|
+
const MAX_ORDINAL = 3652059;
|
|
284
|
+
/** `timedelta`'s own cap, which it raises about before any date is touched. */
|
|
285
|
+
const TIMEDELTA_MAX_DAYS = 999999999;
|
|
286
|
+
function isLeap(year) {
|
|
287
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
288
|
+
}
|
|
289
|
+
function daysInMonth(year, month) {
|
|
290
|
+
return month === 2 && isLeap(year) ? 29 : DAYS_IN_MONTH[month];
|
|
291
|
+
}
|
|
292
|
+
/** `date.toordinal()`: 0001-01-01 is 1. */
|
|
293
|
+
function toOrdinal(year, month, day) {
|
|
294
|
+
const y = year - 1;
|
|
295
|
+
let days = y * 365 + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400);
|
|
296
|
+
for (let m = 1; m < month; m += 1)
|
|
297
|
+
days += daysInMonth(year, m);
|
|
298
|
+
return days + day;
|
|
299
|
+
}
|
|
300
|
+
/** `date.fromordinal(n).isoformat()`. */
|
|
301
|
+
function isoFromOrdinal(ordinal) {
|
|
302
|
+
let remaining = ordinal;
|
|
303
|
+
let year = 1;
|
|
304
|
+
// Walk in 400-year cycles first so a far-future ordinal does not loop a million times.
|
|
305
|
+
const CYCLE = 146097;
|
|
306
|
+
const cycles = Math.floor((remaining - 1) / CYCLE);
|
|
307
|
+
year += cycles * 400;
|
|
308
|
+
remaining -= cycles * CYCLE;
|
|
309
|
+
for (;;) {
|
|
310
|
+
const size = isLeap(year) ? 366 : 365;
|
|
311
|
+
if (remaining <= size)
|
|
312
|
+
break;
|
|
313
|
+
remaining -= size;
|
|
314
|
+
year += 1;
|
|
315
|
+
}
|
|
316
|
+
let month = 1;
|
|
317
|
+
for (;;) {
|
|
318
|
+
const size = daysInMonth(year, month);
|
|
319
|
+
if (remaining <= size)
|
|
320
|
+
break;
|
|
321
|
+
remaining -= size;
|
|
322
|
+
month += 1;
|
|
323
|
+
}
|
|
324
|
+
const pad = (n, w) => String(n).padStart(w, '0');
|
|
325
|
+
return `${pad(year, 4)}-${pad(month, 2)}-${pad(remaining, 2)}`;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* `date.fromisoformat(text)` for the shape an mtime produces, or `null` where the reference
|
|
329
|
+
* raises `ValueError` and `_resolve` returns `None`.
|
|
330
|
+
*
|
|
331
|
+
* Deliberately narrow: `basis` is always `pyMtimeDate`'s output, which is `YYYY-MM-DD` with
|
|
332
|
+
* a four-digit year. CPython 3.11+ accepts more spellings, and none of them can arrive here.
|
|
333
|
+
*/
|
|
334
|
+
function ordinalFromIso(text) {
|
|
335
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
|
336
|
+
if (m === null)
|
|
337
|
+
return null;
|
|
338
|
+
const year = Number(m[1]);
|
|
339
|
+
const month = Number(m[2]);
|
|
340
|
+
const day = Number(m[3]);
|
|
341
|
+
if (year < 1 || month < 1 || month > 12)
|
|
342
|
+
return null;
|
|
343
|
+
if (day < 1 || day > daysInMonth(year, month))
|
|
344
|
+
return null;
|
|
345
|
+
return toOrdinal(year, month, day);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* `basis + timedelta(days=delta)`, or `null` where CPython's arithmetic leaves the calendar.
|
|
349
|
+
*
|
|
350
|
+
* GUARDED IN J45-4, AND THE GUARD IS WHAT MAKES THE TWO SIDES AGREE. `\d+` has no upper
|
|
351
|
+
* bound, so a body a person can legitimately write raised `OverflowError` out of `dream()`
|
|
352
|
+
* on BOTH runtimes — preview included. Worse, the two did not even raise the same thing:
|
|
353
|
+
* CPython has THREE refusals here and this file reproduced two, so at `2147483648 days ago`
|
|
354
|
+
* the reference said `Python int too large to convert to C int` and this port said
|
|
355
|
+
* `days=-2147483648; must have magnitude <= 999999999`. MEASURED 2026-09-06, both sides.
|
|
356
|
+
*
|
|
357
|
+
* The repair is not a third sentence. `_resolve` and this function now answer "no day" at
|
|
358
|
+
* exactly the same two boundaries — magnitude past `timedelta`'s cap, and a result outside
|
|
359
|
+
* `date.min .. date.max` — and `absolutise` routes that to the home an unresolvable
|
|
360
|
+
* relative term already had: reported in `unresolved`, body untouched. No sentence to
|
|
361
|
+
* spell means no sentence to keep in step, and the boundaries themselves are pinned by
|
|
362
|
+
* `tools/conformance/suites/dream.mjs`.
|
|
363
|
+
*/
|
|
364
|
+
function addDays(ordinal, delta) {
|
|
365
|
+
if (!Number.isFinite(delta) || Math.abs(delta) > TIMEDELTA_MAX_DAYS)
|
|
366
|
+
return null;
|
|
367
|
+
const moved = ordinal + delta;
|
|
368
|
+
if (moved < 1 || moved > MAX_ORDINAL)
|
|
369
|
+
return null;
|
|
370
|
+
return isoFromOrdinal(moved);
|
|
371
|
+
}
|
|
372
|
+
// ------------------------------------------- the pure algorithm, function for function
|
|
373
|
+
/** One line, single-spaced. The basis of every comparison key in this module. */
|
|
374
|
+
export function collapse(text) {
|
|
375
|
+
return pyStrip(text.replace(new RegExp(`${WS}+`, 'gu'), ' '));
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The identity of a block for set-union purposes: collapsed whitespace, lowercased.
|
|
379
|
+
*
|
|
380
|
+
* Punctuation is deliberately NOT stripped. A rule that ignored it would call two sentences
|
|
381
|
+
* the same because one ends in a question mark, and this pass is allowed to repeat a claim
|
|
382
|
+
* but never to drop one.
|
|
383
|
+
*/
|
|
384
|
+
export function blockKey(text) {
|
|
385
|
+
return collapse(text).toLowerCase();
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Paragraphs: non-empty runs separated by a blank line, each stripped.
|
|
389
|
+
*
|
|
390
|
+
* Line endings are normalised first, so the same body blocks identically whether it was
|
|
391
|
+
* written on Windows or on POSIX — the two runtimes must agree on the block list before they
|
|
392
|
+
* can agree on the union of two of them.
|
|
393
|
+
*/
|
|
394
|
+
export function splitBlocks(text) {
|
|
395
|
+
const normalised = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
396
|
+
const out = [];
|
|
397
|
+
for (const block of normalised.split(BLOCK_SPLIT)) {
|
|
398
|
+
const stripped = pyStrip(block);
|
|
399
|
+
if (stripped !== '')
|
|
400
|
+
out.push(stripped);
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* `[subject key, value key]` when a block is a single-line `Subject: value`, else `null`.
|
|
406
|
+
*
|
|
407
|
+
* See `SLOT` for why this is narrow. It exists only so that a genuine CONTRADICTION — the
|
|
408
|
+
* same slot given two different values — can be told apart from two claims that are merely
|
|
409
|
+
* different, which is the whole of what union already handles.
|
|
410
|
+
*/
|
|
411
|
+
export function claimSlot(block) {
|
|
412
|
+
if (block.includes('\n'))
|
|
413
|
+
return null;
|
|
414
|
+
const match = SLOT.exec(pyStrip(block).replace(BULLET, ''));
|
|
415
|
+
if (match === null)
|
|
416
|
+
return null;
|
|
417
|
+
const subject = pyStrip(pyStrip(match.groups['subject']).replace(/^\*+|\*+$/g, ''));
|
|
418
|
+
const value = pyStrip(match.groups['value']);
|
|
419
|
+
if (subject === '' || !SUBJECT_OK.test(subject) || value.startsWith('//'))
|
|
420
|
+
return null;
|
|
421
|
+
return [collapse(subject).toLowerCase(), collapse(value).toLowerCase()];
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* `[body, resolved hits, unresolved hits]` — a relative date annotated with its day.
|
|
425
|
+
*
|
|
426
|
+
* THE TERM IS KEPT AND THE DATE IS ADDED: `today` becomes `today (2026-08-27)`. Deleting the
|
|
427
|
+
* word would rewrite the sentence a person wrote; appending the day says which date was
|
|
428
|
+
* substituted and leaves the reader able to disagree with it. A term already carrying a
|
|
429
|
+
* stamp is skipped, which is what makes a second dream over the same body a no-op.
|
|
430
|
+
*
|
|
431
|
+
* A term whose arithmetic leaves the calendar (see `addDays`) is REPORTED as unresolved and
|
|
432
|
+
* the body is left alone — the same treatment `recently` and `last month` get, for the same
|
|
433
|
+
* reason: this pass may say which day it substituted, or say it could not, and never invent
|
|
434
|
+
* one. Those hits come FIRST in the unresolved list, in the order the relative scan met
|
|
435
|
+
* them, followed by the `UNRESOLVED` matches in the order the second scan met them, which
|
|
436
|
+
* is the order `dream.py` builds the same list in.
|
|
437
|
+
*/
|
|
438
|
+
export function absolutise(body, basis, name, layer) {
|
|
439
|
+
const pieces = [];
|
|
440
|
+
const hits = [];
|
|
441
|
+
const outOfRange = [];
|
|
442
|
+
let position = 0;
|
|
443
|
+
const ordinal = ordinalFromIso(basis);
|
|
444
|
+
for (const match of body.matchAll(RELATIVE)) {
|
|
445
|
+
const end = match.index + match[0].length;
|
|
446
|
+
if (STAMPED.test(body.slice(end)))
|
|
447
|
+
continue;
|
|
448
|
+
if (ordinal === null)
|
|
449
|
+
continue; // `_resolve` answers None and the reference skips it
|
|
450
|
+
const groups = match.groups;
|
|
451
|
+
let delta;
|
|
452
|
+
if (groups['simple'] !== undefined) {
|
|
453
|
+
delta = { today: 0, tonight: 0, yesterday: -1, tomorrow: 1 }[groups['simple'].toLowerCase()];
|
|
454
|
+
}
|
|
455
|
+
else if (groups['now'] !== undefined) {
|
|
456
|
+
delta = 0;
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
const count = pyIntDigits(groups['count']);
|
|
460
|
+
delta = -count * (groups['unit'].toLowerCase().startsWith('week') ? 7 : 1);
|
|
461
|
+
}
|
|
462
|
+
const resolved = addDays(ordinal, delta);
|
|
463
|
+
if (resolved === null) {
|
|
464
|
+
outOfRange.push({ name, layer, term: match[0], resolved: '', basis });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
pieces.push(body.slice(position, end));
|
|
468
|
+
pieces.push(` (${resolved})`);
|
|
469
|
+
position = end;
|
|
470
|
+
hits.push({ name, layer, term: match[0], resolved, basis });
|
|
471
|
+
}
|
|
472
|
+
pieces.push(body.slice(position));
|
|
473
|
+
const unresolved = [...outOfRange];
|
|
474
|
+
for (const match of body.matchAll(UNRESOLVED)) {
|
|
475
|
+
unresolved.push({ name, layer, term: match[0], resolved: '', basis });
|
|
476
|
+
}
|
|
477
|
+
return [pieces.join(''), hits, unresolved];
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Both descriptions, `; `-joined, unless they are the same line of text.
|
|
481
|
+
*
|
|
482
|
+
* The description is what `recall` SCORES against, so dropping one side's would cost the
|
|
483
|
+
* survivor the query vocabulary that half of the pair used to answer. Equality is on the
|
|
484
|
+
* collapsed, lowercased key — the same key every other comparison here uses — and nothing
|
|
485
|
+
* cleverer: a containment or similarity rule would be a second thing to reproduce byte for
|
|
486
|
+
* byte, and the index budget already bounds the cost of being literal.
|
|
487
|
+
*/
|
|
488
|
+
export function mergeDescriptions(base, other) {
|
|
489
|
+
if (blockKey(pyReText(base)) === blockKey(pyReText(other)))
|
|
490
|
+
return pyStrip(base);
|
|
491
|
+
return `${pyStrip(base)}; ${pyStrip(other)}`;
|
|
492
|
+
}
|
|
493
|
+
/** Ordered set union, base first, exact string identity. Order is the port's contract. */
|
|
494
|
+
export function mergeLinks(base, other) {
|
|
495
|
+
// `dict.fromkeys(...)` dedupes on HASH; `link not in out` below scans a LIST with `==`.
|
|
496
|
+
// The two are the reference's own two spellings and are kept apart deliberately.
|
|
497
|
+
const out = [];
|
|
498
|
+
const seen = new Set();
|
|
499
|
+
for (const link of base) {
|
|
500
|
+
if (!truthy(link))
|
|
501
|
+
continue;
|
|
502
|
+
const key = pyHashKey(link);
|
|
503
|
+
if (seen.has(key))
|
|
504
|
+
continue;
|
|
505
|
+
seen.add(key);
|
|
506
|
+
out.push(link);
|
|
507
|
+
}
|
|
508
|
+
for (const link of other) {
|
|
509
|
+
if (!truthy(link))
|
|
510
|
+
continue;
|
|
511
|
+
if (out.some((held) => pyEqualValue(held, link)))
|
|
512
|
+
continue;
|
|
513
|
+
out.push(link);
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
/** `bool(value)` for a frontmatter value — `store.pyTruthy`, which is not exported. */
|
|
518
|
+
function truthy(value) {
|
|
519
|
+
if (value === null)
|
|
520
|
+
return false;
|
|
521
|
+
if (typeof value === 'string')
|
|
522
|
+
return value !== '';
|
|
523
|
+
return value.truthy;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* UNION, never a winner. `[body, blocks taken from other, superseded records]`.
|
|
527
|
+
*
|
|
528
|
+
* THE ALGORITHM, stated as the contract both runtimes reproduce exactly:
|
|
529
|
+
*
|
|
530
|
+
* 1. Split both bodies into blocks (`splitBlocks`).
|
|
531
|
+
* 2. Find CONTRADICTIONS: a `claimSlot` subject present on both sides with two different
|
|
532
|
+
* value keys. The side whose FACT FILE has the later mtime wins; a tie goes to `base`,
|
|
533
|
+
* which is the writable project layer. The loser is removed from the body sequence and
|
|
534
|
+
* recorded in a `Superseded` entry — never dropped.
|
|
535
|
+
* 3. Emit every surviving BASE block in source order, skipping any whose `blockKey` has
|
|
536
|
+
* already been emitted.
|
|
537
|
+
* 4. Emit every surviving OTHER block in source order, same skip. Count these.
|
|
538
|
+
* 5. If anything was superseded, append one block: `SUPERSEDED_HEADING`, a blank line, and
|
|
539
|
+
* one `- from <layer> (<date>): <collapsed text>` line per record, sorted by subject.
|
|
540
|
+
* 6. Join with a blank line.
|
|
541
|
+
*
|
|
542
|
+
* WHY MTIME AND NOT `created` DECIDES A CONTRADICTION. J45-1 measured all three obvious
|
|
543
|
+
* clocks on the real diverged pair and every one picks wrong: newest `created` and longest
|
|
544
|
+
* body each pick the profile copy and lose the project copy's amended paragraph,
|
|
545
|
+
* project-layer-wins drops ~1.7 kB of the profile copy, and `last_recalled` is the same date
|
|
546
|
+
* on both. `created` is FIRST-LANDING and the older-created fact there holds the NEWER
|
|
547
|
+
* content, so it is actively backwards. The file's mtime is the only clock on disk that
|
|
548
|
+
* records when the text was last written, and it is the same clock `absolutise` uses.
|
|
549
|
+
*
|
|
550
|
+
* WHY UNION MAY REPEAT A CLAIM. Two paragraphs that say the same thing in different words
|
|
551
|
+
* have different `blockKey`s and both survive. That is the deliberate direction of the
|
|
552
|
+
* error: a repeated claim is something the user can delete in one edit, and a dropped ruling
|
|
553
|
+
* is not recoverable from the merged fact at all.
|
|
554
|
+
*/
|
|
555
|
+
export function mergeBodies(baseBody, otherBody, baseLayer, otherLayer, baseDate, otherDate) {
|
|
556
|
+
const baseBlocks = splitBlocks(baseBody);
|
|
557
|
+
const otherBlocks = splitBlocks(otherBody);
|
|
558
|
+
const slotsOf = (blocks) => {
|
|
559
|
+
const slots = new Map();
|
|
560
|
+
blocks.forEach((block, index) => {
|
|
561
|
+
const slot = claimSlot(block);
|
|
562
|
+
if (slot !== null && !slots.has(slot[0]))
|
|
563
|
+
slots.set(slot[0], [index, slot[1]]);
|
|
564
|
+
});
|
|
565
|
+
return slots;
|
|
566
|
+
};
|
|
567
|
+
const baseSlots = slotsOf(baseBlocks);
|
|
568
|
+
const otherSlots = slotsOf(otherBlocks);
|
|
569
|
+
const superseded = [];
|
|
570
|
+
const dropBase = new Set();
|
|
571
|
+
const dropOther = new Set();
|
|
572
|
+
const shared = [...baseSlots.keys()].filter((subject) => otherSlots.has(subject));
|
|
573
|
+
shared.sort(cmpCodepoint);
|
|
574
|
+
for (const subject of shared) {
|
|
575
|
+
const [baseIndex, baseValue] = baseSlots.get(subject);
|
|
576
|
+
const [otherIndex, otherValue] = otherSlots.get(subject);
|
|
577
|
+
if (baseValue === otherValue)
|
|
578
|
+
continue;
|
|
579
|
+
// A STRING compare on two ISO dates, which is the reference's `other_date > base_date`.
|
|
580
|
+
if (cmpCodepoint(otherDate, baseDate) > 0) {
|
|
581
|
+
dropBase.add(baseIndex);
|
|
582
|
+
superseded.push({
|
|
583
|
+
subject,
|
|
584
|
+
kept: collapse(otherBlocks[otherIndex]),
|
|
585
|
+
keptLayer: otherLayer,
|
|
586
|
+
keptDate: otherDate,
|
|
587
|
+
lost: collapse(baseBlocks[baseIndex]),
|
|
588
|
+
lostLayer: baseLayer,
|
|
589
|
+
lostDate: baseDate,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
dropOther.add(otherIndex);
|
|
594
|
+
superseded.push({
|
|
595
|
+
subject,
|
|
596
|
+
kept: collapse(baseBlocks[baseIndex]),
|
|
597
|
+
keptLayer: baseLayer,
|
|
598
|
+
keptDate: baseDate,
|
|
599
|
+
lost: collapse(otherBlocks[otherIndex]),
|
|
600
|
+
lostLayer: otherLayer,
|
|
601
|
+
lostDate: otherDate,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const seen = new Set();
|
|
606
|
+
const out = [];
|
|
607
|
+
baseBlocks.forEach((block, index) => {
|
|
608
|
+
if (dropBase.has(index))
|
|
609
|
+
return;
|
|
610
|
+
const key = blockKey(block);
|
|
611
|
+
if (seen.has(key))
|
|
612
|
+
return;
|
|
613
|
+
seen.add(key);
|
|
614
|
+
out.push(block);
|
|
615
|
+
});
|
|
616
|
+
let added = 0;
|
|
617
|
+
otherBlocks.forEach((block, index) => {
|
|
618
|
+
if (dropOther.has(index))
|
|
619
|
+
return;
|
|
620
|
+
const key = blockKey(block);
|
|
621
|
+
if (seen.has(key))
|
|
622
|
+
return;
|
|
623
|
+
seen.add(key);
|
|
624
|
+
out.push(block);
|
|
625
|
+
added += 1;
|
|
626
|
+
});
|
|
627
|
+
if (superseded.length > 0) {
|
|
628
|
+
const lines = [SUPERSEDED_HEADING, ''];
|
|
629
|
+
for (const record of superseded) {
|
|
630
|
+
lines.push(`- from ${record.lostLayer} (${record.lostDate}): ${record.lost}`);
|
|
631
|
+
}
|
|
632
|
+
out.push(lines.join('\n'));
|
|
633
|
+
}
|
|
634
|
+
return [out.join('\n\n'), added, superseded];
|
|
635
|
+
}
|
|
636
|
+
// ------------------------------------------------------------------- the pass over two stores
|
|
637
|
+
/**
|
|
638
|
+
* Rebuild `index.md` ONLY where one is already on disk. The first of J45-1's two traps.
|
|
639
|
+
*
|
|
640
|
+
* The profile store has never had an `index.md`: its index is derived by `indexText()` and
|
|
641
|
+
* `rebuildIndex` — the only writer — is never reached, because `Memory.layered` mounts that
|
|
642
|
+
* store read-only. A merge that quietly created one would put a new file in the user's home
|
|
643
|
+
* directory as a side effect, and that has to be a decision somebody makes rather than a
|
|
644
|
+
* consequence of tidying two copies of a fact into one.
|
|
645
|
+
*
|
|
646
|
+
* `pyLexists` and not `pyExists`: an `index.md` that is a dangling symlink is an entry that
|
|
647
|
+
* is THERE, and rebuilding through it is the behaviour `save` already has.
|
|
648
|
+
*/
|
|
649
|
+
function rebuildIfPresent(store) {
|
|
650
|
+
if (pyLexists(pyJoin(store.root, 'index.md')))
|
|
651
|
+
store.internals().rebuildIndex();
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Move one fact into this store's `archive/` WITHOUT touching its index.
|
|
655
|
+
*
|
|
656
|
+
* `MemoryStore.archive` cannot be used here and the difference is exactly trap 1: it ends in
|
|
657
|
+
* `rebuildIndex()` unconditionally, so archiving out of the profile store through it would
|
|
658
|
+
* create the `index.md` that store has never had. The move itself is the same call `archive`
|
|
659
|
+
* and `compact` make — `pyReplace`, which replaces an occupied destination on Windows as
|
|
660
|
+
* well as POSIX.
|
|
661
|
+
*
|
|
662
|
+
* The destination is checked by the caller, which refuses the whole pair rather than
|
|
663
|
+
* overwriting an earlier archived copy.
|
|
664
|
+
*/
|
|
665
|
+
function consume(store, name) {
|
|
666
|
+
const destination = pyJoin(store.root, 'archive', `${pyText(name)}.md`);
|
|
667
|
+
pyMkdirParents(pyParent(destination));
|
|
668
|
+
pyReplace(store.internals().factPath(name), destination);
|
|
669
|
+
}
|
|
670
|
+
function fileSize(path) {
|
|
671
|
+
try {
|
|
672
|
+
return statSync(path).size;
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return 0;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Consolidate the facts the two layers hold under the same NAME. Reversible, bounded.
|
|
680
|
+
*
|
|
681
|
+
* `dryRun` defaults to TRUE because this pass writes into the user's home directory and a
|
|
682
|
+
* destructive consolidation nobody can preview is not shippable. A dry run performs every
|
|
683
|
+
* read, every merge and every projection and writes nothing.
|
|
684
|
+
*
|
|
685
|
+
* THE BUDGET IS `compact()`'s BUDGET AND NOT A SECOND CAP. The only index this pass can grow
|
|
686
|
+
* is the project one, and it can grow it only by the bytes a unioned DESCRIPTION adds —
|
|
687
|
+
* every merged name is already a line in that index, so no line is ever added. The projected
|
|
688
|
+
* index is measured exactly the way `checkIndexBudget` measures it, and a plan that would
|
|
689
|
+
* not fit is returned unapplied with `overBudget` set, for the caller to report. It is not
|
|
690
|
+
* raised: a preview whose answer is an exception has told the operator nothing about the
|
|
691
|
+
* plan they asked to see.
|
|
692
|
+
*
|
|
693
|
+
* WRITE ORDER IS SURVIVORS FIRST, CONSUMPTION SECOND, and it is chosen for the direction of
|
|
694
|
+
* its failure. If the process dies between the two, the survivor holds everything and the
|
|
695
|
+
* profile copy is still live — a duplicate that the next dream consolidates. The other order
|
|
696
|
+
* would leave a window in which the profile copy is archived and the union has not landed.
|
|
697
|
+
*/
|
|
698
|
+
export function dream(project, profile, dryRun = true) {
|
|
699
|
+
const projectInner = project.internals();
|
|
700
|
+
const profileInner = profile.internals();
|
|
701
|
+
const projectFacts = projectInner.facts();
|
|
702
|
+
const profileFacts = profileInner.facts();
|
|
703
|
+
// `{fact.name: fact}` — the key is Python's HASH key, so a fact named `7` and one named
|
|
704
|
+
// `'7'` are two names and `7`/`7.0`/`True` are one, exactly as the reference's dict is.
|
|
705
|
+
const byProject = new Map();
|
|
706
|
+
for (const fact of projectFacts)
|
|
707
|
+
byProject.set(pyHashKey(fact.name), fact);
|
|
708
|
+
const byProfile = new Map();
|
|
709
|
+
for (const fact of profileFacts)
|
|
710
|
+
byProfile.set(pyHashKey(fact.name), fact);
|
|
711
|
+
const planned = new Map();
|
|
712
|
+
const merged = [];
|
|
713
|
+
const refused = [];
|
|
714
|
+
const hits = [];
|
|
715
|
+
const unresolved = [];
|
|
716
|
+
const consumed = [];
|
|
717
|
+
const bothKeys = [...byProject.keys()].filter((key) => byProfile.has(key));
|
|
718
|
+
for (const name of sortedNames(bothKeys.map((key) => byProject.get(key).name))) {
|
|
719
|
+
const key = pyHashKey(name);
|
|
720
|
+
const here = byProject.get(key);
|
|
721
|
+
const there = byProfile.get(key);
|
|
722
|
+
const destination = pyJoin(profile.root, 'archive', `${pyText(name)}.md`);
|
|
723
|
+
if (pyLexists(destination)) {
|
|
724
|
+
refused.push([
|
|
725
|
+
name,
|
|
726
|
+
`${destination} already exists; refusing to overwrite an earlier ` +
|
|
727
|
+
`archived copy — restore or remove it and run again`,
|
|
728
|
+
]);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const hereDate = pyMtimeDate(projectInner.factPath(name));
|
|
732
|
+
const thereDate = pyMtimeDate(profileInner.factPath(name));
|
|
733
|
+
// `and` short-circuits in the reference, so a non-`str` description is only reached
|
|
734
|
+
// when the bodies already matched. The order is load-bearing for which error escapes.
|
|
735
|
+
const identical = pyStrip(here.body) === pyStrip(there.body) &&
|
|
736
|
+
pyAttrStrip(here.description) === pyAttrStrip(there.description) &&
|
|
737
|
+
pyEqualValue(here.type, there.type) &&
|
|
738
|
+
here.links.length === there.links.length &&
|
|
739
|
+
here.links.every((link, i) => pyEqualValue(link, there.links[i]));
|
|
740
|
+
let [body, resolvedHits, unresolvedHits] = absolutise(here.body, hereDate, name, PROJECT_LAYER);
|
|
741
|
+
hits.push(...resolvedHits);
|
|
742
|
+
unresolved.push(...unresolvedHits);
|
|
743
|
+
let description;
|
|
744
|
+
let links;
|
|
745
|
+
let added;
|
|
746
|
+
let records;
|
|
747
|
+
let kind;
|
|
748
|
+
if (identical) {
|
|
749
|
+
// The profile copy is byte-identical, so it carries no claim the survivor does not
|
|
750
|
+
// already hold and no date the survivor's own absolutisation did not reach. Scanning
|
|
751
|
+
// it would report the same relative date twice for one edit.
|
|
752
|
+
description = here.description;
|
|
753
|
+
links = [...here.links];
|
|
754
|
+
added = 0;
|
|
755
|
+
records = [];
|
|
756
|
+
kind = 'identical';
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
const [otherBody, otherResolved, otherUnresolved] = absolutise(there.body, thereDate, name, PROFILE_LAYER);
|
|
760
|
+
hits.push(...otherResolved);
|
|
761
|
+
unresolved.push(...otherUnresolved);
|
|
762
|
+
[body, added, records] = mergeBodies(body, otherBody, PROJECT_LAYER, PROFILE_LAYER, hereDate, thereDate);
|
|
763
|
+
description = mergeDescriptions(here.description, there.description);
|
|
764
|
+
links = mergeLinks(here.links, there.links);
|
|
765
|
+
kind = 'diverged';
|
|
766
|
+
}
|
|
767
|
+
// `created` is FIRST-LANDING (see `MemoryStore.save`), so the union of two copies landed
|
|
768
|
+
// first on the earlier of the two dates. `last_recalled` is the opposite question — the
|
|
769
|
+
// most recent evidence anyone wanted this — so it takes the later. Neither is a tie-break
|
|
770
|
+
// for content; both are facts about the pair.
|
|
771
|
+
const createdCandidates = [here.created ?? null, there.created ?? null].filter(truthy);
|
|
772
|
+
const recalledCandidates = [here.last_recalled, there.last_recalled].filter(truthy);
|
|
773
|
+
const survivor = {
|
|
774
|
+
...here,
|
|
775
|
+
description,
|
|
776
|
+
body,
|
|
777
|
+
links,
|
|
778
|
+
created: createdCandidates.length > 0 ? pyMin(createdCandidates) : null,
|
|
779
|
+
last_recalled: recalledCandidates.length > 0 ? pyMax(recalledCandidates) : null,
|
|
780
|
+
};
|
|
781
|
+
planned.set(key, survivor);
|
|
782
|
+
consumed.push(name);
|
|
783
|
+
merged.push({
|
|
784
|
+
name,
|
|
785
|
+
kind,
|
|
786
|
+
jaccard: jaccard(tokens(`${pyText(here.name)} ${pyText(here.description)}`), tokens(`${pyText(there.name)} ${pyText(there.description)}`)),
|
|
787
|
+
survivorLayer: PROJECT_LAYER,
|
|
788
|
+
consumedLayer: PROFILE_LAYER,
|
|
789
|
+
bodyBefore: Buffer.byteLength(here.body, 'utf8'),
|
|
790
|
+
bodyAfter: Buffer.byteLength(body, 'utf8'),
|
|
791
|
+
blocksAdded: added,
|
|
792
|
+
superseded: records,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
// Facts only the project layer holds: nothing to merge, but their relative dates are this
|
|
796
|
+
// pass's to resolve. A PROFILE-ONLY fact is deliberately left alone — it is not consumed by
|
|
797
|
+
// any merge, so editing it would be a write into the user's home directory that buys
|
|
798
|
+
// nothing this pass promised.
|
|
799
|
+
const projectOnly = [...byProject.keys()].filter((key) => !byProfile.has(key));
|
|
800
|
+
for (const name of sortedNames(projectOnly.map((key) => byProject.get(key).name))) {
|
|
801
|
+
const key = pyHashKey(name);
|
|
802
|
+
const fact = byProject.get(key);
|
|
803
|
+
const basis = pyMtimeDate(projectInner.factPath(name));
|
|
804
|
+
const [body, resolvedHits, unresolvedHits] = absolutise(fact.body, basis, name, PROJECT_LAYER);
|
|
805
|
+
hits.push(...resolvedHits);
|
|
806
|
+
unresolved.push(...unresolvedHits);
|
|
807
|
+
if (body !== fact.body)
|
|
808
|
+
planned.set(key, { ...fact, body });
|
|
809
|
+
}
|
|
810
|
+
const similar = [];
|
|
811
|
+
for (const here of projectFacts) {
|
|
812
|
+
for (const there of profileFacts) {
|
|
813
|
+
if (pyEqualValue(here.name, there.name))
|
|
814
|
+
continue;
|
|
815
|
+
const score = jaccard(tokens(`${pyText(here.name)} ${pyText(here.description)}`), tokens(`${pyText(there.name)} ${pyText(there.description)}`));
|
|
816
|
+
if (score >= DUPLICATE_JACCARD) {
|
|
817
|
+
similar.push({ projectName: here.name, profileName: there.name, jaccard: score });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// `sorted(key=lambda pair: (-score, project_name, profile_name))` — a stable sort over a
|
|
822
|
+
// total key, so `Array.prototype.sort` is the same answer where the names are strings.
|
|
823
|
+
similar.sort((a, b) => b.jaccard - a.jaccard ||
|
|
824
|
+
cmpCodepoint(pyText(a.projectName), pyText(b.projectName)) ||
|
|
825
|
+
cmpCodepoint(pyText(a.profileName), pyText(b.profileName)));
|
|
826
|
+
const bytes = (text) => Buffer.byteLength(text, 'utf8');
|
|
827
|
+
const indexBefore = bytes(projectFacts.map((f) => projectInner.indexLine(f)).join(''));
|
|
828
|
+
const indexAfter = bytes(projectFacts.map((f) => projectInner.indexLine(planned.get(pyHashKey(f.name)) ?? f)).join(''));
|
|
829
|
+
const profileIndexBefore = bytes(profileFacts.map((f) => profileInner.indexLine(f)).join(''));
|
|
830
|
+
const consumedKeys = new Set(consumed.map(pyHashKey));
|
|
831
|
+
const profileIndexAfter = bytes(profileFacts
|
|
832
|
+
.filter((f) => !consumedKeys.has(pyHashKey(f.name)))
|
|
833
|
+
.map((f) => profileInner.indexLine(f))
|
|
834
|
+
.join(''));
|
|
835
|
+
const projectSizes = new Map();
|
|
836
|
+
for (const fact of projectFacts) {
|
|
837
|
+
projectSizes.set(pyHashKey(fact.name), fileSize(projectInner.factPath(fact.name)));
|
|
838
|
+
}
|
|
839
|
+
const profileSizes = new Map();
|
|
840
|
+
for (const fact of profileFacts) {
|
|
841
|
+
profileSizes.set(pyHashKey(fact.name), fileSize(profileInner.factPath(fact.name)));
|
|
842
|
+
}
|
|
843
|
+
const sum = (values) => {
|
|
844
|
+
let total = 0;
|
|
845
|
+
for (const v of values)
|
|
846
|
+
total += v;
|
|
847
|
+
return total;
|
|
848
|
+
};
|
|
849
|
+
const factBytesBefore = sum(projectSizes.values()) + sum(profileSizes.values());
|
|
850
|
+
let factBytesAfter = factBytesBefore - sum(consumed.map((n) => profileSizes.get(pyHashKey(n))));
|
|
851
|
+
for (const [key, fact] of planned) {
|
|
852
|
+
factBytesAfter += bytes(formatFact(fact)) - projectSizes.get(key);
|
|
853
|
+
}
|
|
854
|
+
const rewritten = sortedNames([...planned.values()].map((fact) => fact.name));
|
|
855
|
+
const result = {
|
|
856
|
+
applied: false,
|
|
857
|
+
dryRun,
|
|
858
|
+
merged,
|
|
859
|
+
refused,
|
|
860
|
+
absolutised: hits,
|
|
861
|
+
unresolved,
|
|
862
|
+
similarUnmerged: similar,
|
|
863
|
+
consumed,
|
|
864
|
+
rewritten,
|
|
865
|
+
archiveDir: pyJoin(profile.root, 'archive'),
|
|
866
|
+
indexBefore,
|
|
867
|
+
indexAfter,
|
|
868
|
+
budget: project.indexBudget,
|
|
869
|
+
profileIndexBefore,
|
|
870
|
+
profileIndexAfter,
|
|
871
|
+
factBytesBefore,
|
|
872
|
+
factBytesAfter,
|
|
873
|
+
projectRoot: project.root,
|
|
874
|
+
profileRoot: profile.root,
|
|
875
|
+
overBudget: indexAfter > project.indexBudget,
|
|
876
|
+
changes: merged.length + rewritten.length,
|
|
877
|
+
superseded: merged.flatMap((merge) => [...merge.superseded]),
|
|
878
|
+
};
|
|
879
|
+
if (dryRun || result.overBudget || result.changes === 0)
|
|
880
|
+
return result;
|
|
881
|
+
for (const fact of planned.values())
|
|
882
|
+
projectInner.writeFact(fact);
|
|
883
|
+
for (const name of consumed)
|
|
884
|
+
consume(profile, name);
|
|
885
|
+
rebuildIfPresent(project);
|
|
886
|
+
rebuildIfPresent(profile);
|
|
887
|
+
return { ...result, applied: true };
|
|
888
|
+
}
|
|
889
|
+
/** `min(...)` / `max(...)` over frontmatter values, with CPython's `<` and its TypeErrors. */
|
|
890
|
+
function pyMin(values) {
|
|
891
|
+
let best = values[0];
|
|
892
|
+
for (const value of values.slice(1))
|
|
893
|
+
if (pyCompareLt(value, best))
|
|
894
|
+
best = value;
|
|
895
|
+
return best;
|
|
896
|
+
}
|
|
897
|
+
function pyMax(values) {
|
|
898
|
+
let best = values[0];
|
|
899
|
+
for (const value of values.slice(1))
|
|
900
|
+
if (pyCompareLt(best, value))
|
|
901
|
+
best = value;
|
|
902
|
+
return best;
|
|
903
|
+
}
|