@sabaiway/agent-workflow-memory 3.2.0 → 4.1.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.
@@ -0,0 +1,466 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { tokenizeMarkdown } from './markdown-blocks.mjs';
4
+ import {
5
+ parseChangelogText,
6
+ computeCutoffs,
7
+ categorize,
8
+ compressEntry,
9
+ buildChangelog,
10
+ buildRecent,
11
+ buildCold,
12
+ groupByMonth,
13
+ } from './archive-changelog.mjs';
14
+
15
+ // ── The conservation / round-trip harness (Phase 2.0) ─────────────────────────────────
16
+ //
17
+ // Built BEFORE the parsers move onto the tokenizer, against the current code, so it is a
18
+ // checker and not a rationalisation. Its contract, for EVERY input:
19
+ //
20
+ // either the parse REFUSES loudly (a typed Error carrying exitCode 1 and a message),
21
+ // or the three properties hold:
22
+ // conservation — every body line lands in exactly one bucket, none dropped, none doubled
23
+ // round-trip — a full rotation re-parses to the same entry set
24
+ // self-consumption — the rotator accepts the HOT/WARM/COLD its own writer just wrote
25
+ //
26
+ // A crash (TypeError, missing exitCode) is NOT a refusal: a refusal has a predicate AND a
27
+ // disposition, and the disposition asserted here is "a typed error that names its cause,
28
+ // with no partial result". Deliberately dropped decoration (blank lines, `---` separators,
29
+ // `**Last Updated:**` footers) is excluded from the accounting on both sides — everything
30
+ // else must be conserved.
31
+
32
+ const FM = '---\ntype: history\nlastUpdated: 2026-07-28\nmaxLines: 700\n---\n';
33
+ const B = '```';
34
+ const TODAY = '2026-07-28';
35
+
36
+ const matchableView = (line) => line.replace(/\s+$/, '');
37
+ const isDroppable = (line) =>
38
+ line === '' || line === '---' || /^\*\*Last Updated:/i.test(line);
39
+
40
+ const accountable = (text) =>
41
+ text.split('\n').map(matchableView).filter((line) => !isDroppable(line));
42
+
43
+ const countLines = (lines) => {
44
+ const counts = new Map();
45
+ for (const line of lines) counts.set(line, (counts.get(line) ?? 0) + 1);
46
+ return counts;
47
+ };
48
+
49
+ const conservationDelta = (text, parsed) => {
50
+ const input = countLines(accountable(text));
51
+ const output = countLines(
52
+ [parsed.frontmatter, parsed.preamble, ...parsed.entries.map((e) => e.block), parsed.footer]
53
+ .filter((s) => s !== '')
54
+ .flatMap((s) => accountable(s)),
55
+ );
56
+ const missing = [];
57
+ const duplicated = [];
58
+ for (const [line, n] of input) if ((output.get(line) ?? 0) < n) missing.push(line);
59
+ for (const [line, n] of output) if (n > (input.get(line) ?? 0)) duplicated.push(line);
60
+ return { missing, duplicated };
61
+ };
62
+
63
+ const assertTypedRefusal = (err, context) => {
64
+ assert.ok(err instanceof Error, `${context}: a refusal must be an Error, got ${typeof err}`);
65
+ assert.equal(
66
+ err.exitCode,
67
+ 1,
68
+ `${context}: a refusal carries exitCode 1 — a crash is not a refusal (${err.message})`,
69
+ );
70
+ assert.ok(err.message.length > 0, `${context}: a refusal names its cause`);
71
+ };
72
+
73
+ const identity = (entry) => `${entry.dateStr}|${entry.title}`;
74
+
75
+ const rotateInMemory = (parsed) => {
76
+ const cutoffs = computeCutoffs(TODAY, 3, 30);
77
+ const { hot, warm, cold } = categorize(parsed.entries, cutoffs);
78
+ const coldByMonth = groupByMonth(cold);
79
+ const tiers = [
80
+ [
81
+ 'HOT',
82
+ buildChangelog({
83
+ frontmatter: parsed.frontmatter || FM,
84
+ preamble: parsed.preamble || '# Changelog',
85
+ hot,
86
+ footer: parsed.footer,
87
+ hasArchive: warm.length > 0 || cold.length > 0,
88
+ }),
89
+ ],
90
+ ];
91
+ if (warm.length > 0) tiers.push(['WARM', buildRecent(warm, TODAY)]);
92
+ for (const [key, entries] of coldByMonth) {
93
+ const [year, month] = key.split('-');
94
+ tiers.push([`COLD ${key}`, buildCold(year, month, entries, TODAY)]);
95
+ }
96
+ return { tiers, hot, warm, cold };
97
+ };
98
+
99
+ // ── seeded generator with a one-pass oracle ───────────────────────────────────────────
100
+ //
101
+ // The oracle (which headings are REAL entries, and whether the document must refuse) is
102
+ // recorded WHILE emitting, never re-derived with a regex afterwards — so a disagreement
103
+ // with the parser is a real failure, not two copies of one mistake agreeing. Drawn from
104
+ // the LCG's high bits (the low bit strictly alternates and starves input classes).
105
+
106
+ const DATE_GRID = [
107
+ ['07', '28'], ['07', '27'], ['07', '26'], // HOT at TODAY with hot-days=3
108
+ ['07', '20'], ['07', '10'], ['07', '02'], // WARM
109
+ ['05', '27'], ['05', '15'], ['03', '02'], // COLD
110
+ ];
111
+
112
+ const MALFORMED = [
113
+ '## 2026-06-15 (no dash sep)',
114
+ '## 2026/07/20 — slash date',
115
+ '## 2026-7-20 — single digit',
116
+ '## 2026-07 — truncated',
117
+ '## 20260720 — unseparated',
118
+ '## 2026-07.20 — mixed separators',
119
+ '## 2026-02-30 — impossible date',
120
+ ' ## 2026-07-19 — indented',
121
+ '##\t2026-07-18 — tab separated',
122
+ ];
123
+
124
+ const buildDoc = (seed) => {
125
+ let state = seed;
126
+ const rand = (n) => {
127
+ state = (state * 1103515245 + 12345) % 2147483648;
128
+ return Math.floor(state / 65536) % n;
129
+ };
130
+
131
+ const eol = rand(4) === 0 ? '\r\n' : '\n';
132
+ const rows = [];
133
+ const oracle = { entries: [], mustRefuse: false, crlf: eol === '\r\n' };
134
+
135
+ const frontmatter = rand(5) === 0 ? '' : FM.replace(/\n/g, eol);
136
+ rows.push('# Changelog');
137
+ rows.push('');
138
+ if (rand(3) === 0) {
139
+ rows.push('## History');
140
+ rows.push('');
141
+ rows.push('> older sessions are layered.');
142
+ rows.push('');
143
+ rows.push('---');
144
+ rows.push('');
145
+ }
146
+
147
+ const entryCount = 1 + rand(4);
148
+ // start ranges over the WHOLE grid tail (a start of 3 was the blind spot: with at most four
149
+ // entries the COLD rows were unreachable in every seed, and the non-idempotent COLD compressor
150
+ // sailed through — the tier sentinel below pins the reach).
151
+ const start = rand(6);
152
+ for (let i = 0; i < entryCount; i += 1) {
153
+ const [month, day] = DATE_GRID[start + i];
154
+ const sep = rand(2) === 0 ? '.' : '-';
155
+ const title = rand(5) === 0 ? '' : `entry ${seed}-${i}`;
156
+ rows.push(title === '' ? `## 2026${sep}${month}${sep}${day}` : `## 2026${sep}${month}${sep}${day} — ${title}`);
157
+ oracle.entries.push(`2026.${month}.${day}|${title}`);
158
+ rows.push('');
159
+
160
+ const bodyKind = rand(4);
161
+ if (bodyKind === 0) {
162
+ // Sometimes metric-bearing, so COLD compression's **Result:** extraction is exercised.
163
+ rows.push(rand(2) === 0 ? `**Goal:** body of ${i}.` : `**Goal:** body of ${i}; ${1 + rand(20)} tests green.`);
164
+ } else if (bodyKind === 1) {
165
+ const fence = rand(2) === 0 ? B : '~~~';
166
+ const lead = rand(2) === 0;
167
+ if (lead) {
168
+ rows.push('Write entries like this:');
169
+ rows.push('');
170
+ }
171
+ rows.push(`${fence}markdown`);
172
+ rows.push(`## 2026.01.0${1 + rand(8)} — fenced sample`);
173
+ if (rand(2) === 0) {
174
+ rows.push('');
175
+ rows.push('more fenced text');
176
+ }
177
+ rows.push(fence);
178
+ } else {
179
+ rows.push(`plain body ${i}.`);
180
+ rows.push(`second line ${i}.`);
181
+ }
182
+ rows.push('');
183
+ if (rand(3) === 0) {
184
+ rows.push('---');
185
+ rows.push('');
186
+ }
187
+ if (rand(4) === 0) {
188
+ rows.push(MALFORMED[rand(MALFORMED.length)]);
189
+ rows.push('');
190
+ rows.push('ORPHAN BODY.');
191
+ rows.push('');
192
+ oracle.mustRefuse = true;
193
+ }
194
+ }
195
+
196
+ if (rand(3) === 0) {
197
+ rows.push('## Footer');
198
+ rows.push('');
199
+ rows.push('stray note.');
200
+ }
201
+ if (rand(6) === 0) {
202
+ rows.push(`${B}markdown`);
203
+ rows.push('## 2026.02.02 — hidden behind the open fence');
204
+ oracle.mustRefuse = true;
205
+ }
206
+
207
+ return { text: frontmatter + rows.join(eol) + eol, ...oracle };
208
+ };
209
+
210
+ // ── calibration — green before and after, recorded with the baseline ──────────────────
211
+
212
+ describe('calibration — a clean corpus, both separators', () => {
213
+ it('parses, conserves every line, and rotation re-parses to the same set', () => {
214
+ const text = `${FM}\n# Changelog\n\n## 2026-07-28 — iso hot\n\nbody a.\n\n## 2026.07.10 — dotted warm\n\nbody b.\n\n## 2026.03.02 — dotted cold\n\nbody c.\n\n## Footer\n\nstray.\n`;
215
+ const parsed = parseChangelogText(text);
216
+ assert.deepEqual(parsed.entries.map(identity), [
217
+ '2026.07.28|iso hot',
218
+ '2026.07.10|dotted warm',
219
+ '2026.03.02|dotted cold',
220
+ ]);
221
+ assert.deepEqual(conservationDelta(text, parsed), { missing: [], duplicated: [] });
222
+ const { tiers } = rotateInMemory(parsed);
223
+ const reparsed = tiers.flatMap(([, tierText]) => parseChangelogText(tierText).entries.map(identity));
224
+ assert.deepEqual(reparsed.sort(), parsed.entries.map(identity).sort());
225
+ });
226
+ });
227
+
228
+ // ── doorway fixtures — one per face observed live in Phase 1 ──────────────────────────
229
+
230
+ describe('doorway fixtures — each must refuse or conserve', () => {
231
+ const between = (middle) =>
232
+ `${FM}\n# Changelog\n\n## 2026.07.21 — good one\n\nbody one.\n\n${middle}\n\nORPHAN BODY.\n\n## 2026.01.05 — good two\n\nbody two.\n`;
233
+
234
+ it('doorway one: a malformed date heading between entries is never glued or duplicated', () => {
235
+ const text = between('## 2026-06-15 (no dash sep)');
236
+ let parsed;
237
+ try {
238
+ parsed = parseChangelogText(text);
239
+ } catch (err) {
240
+ assertTypedRefusal(err, 'doorway one');
241
+ return;
242
+ }
243
+ assert.deepEqual(conservationDelta(text, parsed), { missing: [], duplicated: [] });
244
+ });
245
+
246
+ it('doorway two: a heading inside a closed fence is never counted as an entry', () => {
247
+ const text = `${FM}\n# Changelog\n\n## 2026.07.21 — teaches the format\n\nWrite entries like this:\n\n${B}markdown\n## 2026.01.03 — fenced sample\n${B}\n\nend of body.\n`;
248
+ const parsed = parseChangelogText(text);
249
+ assert.deepEqual(parsed.entries.map(identity), ['2026.07.21|teaches the format']);
250
+ });
251
+
252
+ it('doorway three: an unclosed fence refuses loudly instead of hiding the rest of the file', () => {
253
+ const text = `${FM}\n# Changelog\n\n## 2026.07.21 — good one\n\n${B}markdown\n## 2026.07.20 — hidden\n`;
254
+ let threw = null;
255
+ try {
256
+ parseChangelogText(text);
257
+ } catch (err) {
258
+ threw = err;
259
+ }
260
+ assert.ok(threw, 'an unclosed fence was silently absorbed');
261
+ assertTypedRefusal(threw, 'doorway three');
262
+ });
263
+
264
+ it('doorway four: the writer never emits an unclosed fence — compressed output tokenizes', () => {
265
+ const text = `${FM}\n# Changelog\n\n## 2026.03.02 — carries a fenced block\n\n${B}\nfenced text\n\nmore fenced text\n${B}\n`;
266
+ const parsed = parseChangelogText(text);
267
+ assert.equal(parsed.entries.length, 1);
268
+ const cold = buildCold('2026', '03', parsed.entries, TODAY);
269
+ tokenizeMarkdown(cold, 'history/2026-03.md');
270
+ });
271
+
272
+ it('doorway five: a CRLF file parses identically to its LF twin', () => {
273
+ const lf = `${FM}\n# Changelog\n\n## 2026.07.21 — titled\n\nbody one.\n\n## 2026-07-20\n\nbody two.\n`;
274
+ const a = parseChangelogText(lf);
275
+ const b = parseChangelogText(lf.replace(/\n/g, '\r\n'));
276
+ assert.deepEqual(b.entries.map(identity), a.entries.map(identity));
277
+ assert.equal(b.frontmatter.replace(/\r\n/g, '\n'), a.frontmatter);
278
+ });
279
+
280
+ it('doorway six: slash, single-digit, truncated, unseparated and impossible dates refuse', () => {
281
+ for (const heading of MALFORMED) {
282
+ const text = between(heading);
283
+ let threw = null;
284
+ try {
285
+ parseChangelogText(text);
286
+ } catch (err) {
287
+ threw = err;
288
+ }
289
+ assert.ok(threw, `"${heading}" fell through silently instead of refusing`);
290
+ assertTypedRefusal(threw, `"${heading}"`);
291
+ }
292
+ });
293
+ });
294
+
295
+ // ── properties over generated documents ───────────────────────────────────────────────
296
+
297
+ describe('rotation properties over generated documents', () => {
298
+ const SEEDS = 300;
299
+
300
+ it('every body line lands in exactly one bucket, or the parse refuses loudly', () => {
301
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
302
+ const doc = buildDoc(seed);
303
+ let parsed;
304
+ try {
305
+ parsed = parseChangelogText(doc.text);
306
+ } catch (err) {
307
+ assertTypedRefusal(err, `seed ${seed}`);
308
+ continue;
309
+ }
310
+ assert.deepEqual(conservationDelta(doc.text, parsed), { missing: [], duplicated: [] }, `seed ${seed}`);
311
+ }
312
+ });
313
+
314
+ it('a document whose unit-shaped headings all parse yields exactly the generated entry set', () => {
315
+ let covered = 0;
316
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
317
+ const doc = buildDoc(seed);
318
+ if (doc.mustRefuse) continue;
319
+ covered += 1;
320
+ const parsed = parseChangelogText(doc.text);
321
+ assert.deepEqual(parsed.entries.map(identity).sort(), [...doc.entries].sort(), `seed ${seed}`);
322
+ }
323
+ assert.ok(covered >= 50, `the generator must produce clean documents (got ${covered})`);
324
+ });
325
+
326
+ it('an unparseable unit-shaped heading or an unclosed fence refuses loudly, never absorbs', () => {
327
+ let covered = 0;
328
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
329
+ const doc = buildDoc(seed);
330
+ if (!doc.mustRefuse) continue;
331
+ covered += 1;
332
+ let threw = null;
333
+ try {
334
+ parseChangelogText(doc.text);
335
+ } catch (err) {
336
+ threw = err;
337
+ }
338
+ assert.ok(threw, `seed ${seed}: an unparseable unit-shaped heading was silently absorbed`);
339
+ assertTypedRefusal(threw, `seed ${seed}`);
340
+ }
341
+ assert.ok(covered >= 20, `the generator must produce refusable documents (got ${covered})`);
342
+ });
343
+
344
+ it('a full rotation re-parses to the same entry set — nothing lost, nothing duplicated', () => {
345
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
346
+ const doc = buildDoc(seed);
347
+ if (doc.mustRefuse) continue;
348
+ let parsed;
349
+ try {
350
+ parsed = parseChangelogText(doc.text);
351
+ } catch (err) {
352
+ assertTypedRefusal(err, `seed ${seed}`);
353
+ continue;
354
+ }
355
+ const { tiers } = rotateInMemory(parsed);
356
+ const reparsed = [];
357
+ for (const [tier, tierText] of tiers) {
358
+ let again;
359
+ try {
360
+ again = parseChangelogText(tierText);
361
+ } catch (err) {
362
+ assert.fail(`seed ${seed}: the rotator refused its own ${tier} output — ${err.message}`);
363
+ }
364
+ reparsed.push(...again.entries.map(identity));
365
+ }
366
+ assert.deepEqual(reparsed.sort(), parsed.entries.map(identity).sort(), `seed ${seed}`);
367
+ }
368
+ });
369
+
370
+ it('the rotator accepts its own writer output — every tier it writes tokenizes cleanly', () => {
371
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
372
+ const doc = buildDoc(seed);
373
+ if (doc.mustRefuse) continue;
374
+ let parsed;
375
+ try {
376
+ parsed = parseChangelogText(doc.text);
377
+ } catch (err) {
378
+ assertTypedRefusal(err, `seed ${seed}`);
379
+ continue;
380
+ }
381
+ const { tiers } = rotateInMemory(parsed);
382
+ for (const [tier, tierText] of tiers) {
383
+ try {
384
+ tokenizeMarkdown(tierText, tier);
385
+ } catch (err) {
386
+ assert.fail(`seed ${seed}: the writer emitted a ${tier} tier the tokenizer refuses — ${err.message}`);
387
+ }
388
+ }
389
+ }
390
+ });
391
+
392
+ it('the generator reaches all three tiers across the seed range', () => {
393
+ const cutoffs = computeCutoffs(TODAY, 3, 30);
394
+ let hotSeen = 0;
395
+ let warmSeen = 0;
396
+ let coldSeen = 0;
397
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
398
+ const doc = buildDoc(seed);
399
+ if (doc.mustRefuse) continue;
400
+ const entries = doc.entries.map((identityStr) => {
401
+ const [dateStr] = identityStr.split('|');
402
+ const [year, month, day] = dateStr.split('.');
403
+ return { dateObj: new Date(`${year}-${month}-${day}T00:00:00Z`) };
404
+ });
405
+ const { hot, warm, cold } = categorize(entries, cutoffs);
406
+ hotSeen += hot.length;
407
+ warmSeen += warm.length;
408
+ coldSeen += cold.length;
409
+ }
410
+ assert.ok(hotSeen > 0 && warmSeen > 0 && coldSeen > 0, `tier reach: hot ${hotSeen} / warm ${warmSeen} / cold ${coldSeen}`);
411
+ });
412
+
413
+ it('rewriting an already-written WARM or COLD tier is a byte fixed point', () => {
414
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
415
+ const doc = buildDoc(seed);
416
+ if (doc.mustRefuse) continue;
417
+ let parsed;
418
+ try {
419
+ parsed = parseChangelogText(doc.text);
420
+ } catch (err) {
421
+ assertTypedRefusal(err, `seed ${seed}`);
422
+ continue;
423
+ }
424
+ const { tiers } = rotateInMemory(parsed);
425
+ for (const [tier, tierText] of tiers) {
426
+ if (tier === 'HOT') continue;
427
+ const again = parseChangelogText(tierText, tier).entries;
428
+ const rebuilt = tier === 'WARM'
429
+ ? buildRecent(again, TODAY)
430
+ : buildCold(...tier.slice('COLD '.length).split('-'), again, TODAY);
431
+ assert.equal(rebuilt, tierText, `seed ${seed}: ${tier} is not a fixed point`);
432
+ }
433
+ }
434
+ });
435
+
436
+ it('rotating an already-rotated HOT tier is a byte fixed point', () => {
437
+ for (let seed = 1; seed <= SEEDS; seed += 1) {
438
+ const doc = buildDoc(seed);
439
+ if (doc.mustRefuse) continue;
440
+ let parsed;
441
+ try {
442
+ parsed = parseChangelogText(doc.text);
443
+ } catch (err) {
444
+ assertTypedRefusal(err, `seed ${seed}`);
445
+ continue;
446
+ }
447
+ const { tiers, hot, warm, cold } = rotateInMemory(parsed);
448
+ // Stated scope cut, not a silent cap: with ZERO hot entries and a footer, the rebuilt file
449
+ // holds a "## Footer" with no entry before it, so the re-parse folds it into the preamble
450
+ // and the second build re-orders — the known zero-unit+footer corner (council round 2,
451
+ // both backends: document, don't fold — a footer boundary with zero entries would
452
+ // reintroduce the pinned "## History slurped every entry" mis-detection).
453
+ if (hot.length === 0) continue;
454
+ const hotText = tiers[0][1];
455
+ const second = parseChangelogText(hotText);
456
+ const rebuilt = buildChangelog({
457
+ frontmatter: second.frontmatter || FM,
458
+ preamble: second.preamble || '# Changelog',
459
+ hot: second.entries,
460
+ footer: second.footer,
461
+ hasArchive: warm.length > 0 || cold.length > 0,
462
+ });
463
+ assert.equal(rebuilt, hotText, `seed ${seed}`);
464
+ }
465
+ });
466
+ });