create-evda 0.1.8 → 0.1.10

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +20 -7
  3. package/bin/evda.mjs +277 -7
  4. package/create-evda.mjs +17 -4
  5. package/dist/pairing.mjs +2932 -1684
  6. package/hooks/evda-day-writer-guard.mjs +7 -0
  7. package/lib/acknowledge.mjs +187 -0
  8. package/lib/adopt.mjs +488 -0
  9. package/lib/agents.mjs +1 -1
  10. package/lib/attested-host.mjs +269 -0
  11. package/lib/browse.mjs +354 -0
  12. package/lib/circles.mjs +5 -1
  13. package/lib/clients.mjs +29 -2
  14. package/lib/connect.mjs +9 -2
  15. package/lib/connecting-apps.mjs +56 -0
  16. package/lib/connector-tools.mjs +85 -0
  17. package/lib/corrections.mjs +34 -0
  18. package/lib/credentials-migrate.mjs +120 -0
  19. package/lib/day-file.mjs +173 -15
  20. package/lib/day-writer.mjs +110 -0
  21. package/lib/doctor.mjs +45 -3
  22. package/lib/every-chat.mjs +128 -0
  23. package/lib/filechanges.mjs +203 -68
  24. package/lib/gather-apply.mjs +176 -26
  25. package/lib/gather.mjs +119 -17
  26. package/lib/html-notes.mjs +124 -0
  27. package/lib/keep-hook.mjs +61 -8
  28. package/lib/keychain.mjs +181 -0
  29. package/lib/manifest.mjs +5 -1
  30. package/lib/mcp-auth.mjs +41 -1
  31. package/lib/mcp-http.mjs +42 -4
  32. package/lib/mcp.mjs +1198 -76
  33. package/lib/measurement-log.mjs +249 -0
  34. package/lib/messages.mjs +5 -2
  35. package/lib/migrate-people.mjs +54 -20
  36. package/lib/pairing.mjs +243 -5
  37. package/lib/parity.mjs +47 -13
  38. package/lib/person-folders.mjs +89 -0
  39. package/lib/plugin-hook.mjs +109 -0
  40. package/lib/plugin.mjs +208 -0
  41. package/lib/presets.mjs +26 -5
  42. package/lib/publish.mjs +55 -10
  43. package/lib/qr.mjs +317 -0
  44. package/lib/record.mjs +74 -0
  45. package/lib/routines.mjs +54 -4
  46. package/lib/sigstore-trust.json +20 -0
  47. package/lib/skills.mjs +1 -1
  48. package/lib/sync-lock.mjs +143 -0
  49. package/lib/sync-rules.mjs +154 -1
  50. package/lib/takeout.mjs +169 -0
  51. package/lib/tdx-attestation.mjs +412 -0
  52. package/lib/tdx-trust.json +10 -0
  53. package/lib/tools.mjs +1040 -31
  54. package/lib/touches.mjs +427 -0
  55. package/lib/update-check.mjs +30 -0
  56. package/lib/watch.mjs +160 -0
  57. package/lib/yaml.mjs +8 -4
  58. package/package.json +2 -1
  59. package/skills/evda/SKILL.md +14 -0
  60. package/templates/AGENTS.md +81 -10
  61. package/templates/evda-digest.md +5 -1
@@ -36,6 +36,10 @@ import { admitDevice, devicesOf, readDevices, recordOf, retireDevice } from "./d
36
36
  import { resolveRole } from "./roles.mjs";
37
37
  import { boundaries, insideAny } from "./records.mjs";
38
38
  import { EvdaError } from "./errors.mjs";
39
+ import { carryList, datalessAmong, excluder, fileClass, walk as walkRules } from "./sync-rules.mjs";
40
+ // The day role, re-exported so pairing reads it through the four person libs the phone bundles.
41
+ export { dayRole } from "./day-file.mjs";
42
+ import { dayRole, localDate } from "./day-file.mjs";
39
43
 
40
44
  export const FILE_SET = "file.set";
41
45
  export const FILE_REMOVE = "file.remove";
@@ -365,6 +369,49 @@ export function grantView(evda) {
365
369
  };
366
370
  }
367
371
 
372
+ // The id this record's computer speaks as (HQ-2005). A record create-evda made grants its own id
373
+ // owner, and the computer is the record id. A record whose parts were declared by hand may grant
374
+ // owner to a named device instead (his: `laptop.atlas` owner of every part, `hestia.atlas` an
375
+ // editor, the record id nothing); then the computer is the one device that owns every private part
376
+ // this record homes. None, or more than one, is refused in one line naming what was found, so a
377
+ // caller can refuse before it writes anything. A record with no private parts is its own id.
378
+ //
379
+ // A computer that pins a device as a PEER in its own state is not that device (HQ-2040): Hestia
380
+ // holds the laptop as a peer, so it never speaks as laptop.atlas. `notMe` lists those pins. With
381
+ // them set aside, the one owner left is this computer even when it owns only some parts (his
382
+ // Hestia owns core, where a phone pairs); none left is the same refusal, so a computer with no
383
+ // grant of its own writes nothing rather than minting a second identity for another device's id.
384
+ export const NOT_AN_OWNER_LEAD = "This record has private parts";
385
+ export async function ownerDevice(evda, { notMe = [] } = {}) {
386
+ const own = typeof evda.manifest?.id === "string" ? evda.manifest.id : "";
387
+ const view = grantView(evda);
388
+ const parts = view.index.filter((c) => !c.public && !c.mount);
389
+ if (!own || parts.length === 0) return own;
390
+ const owns = async (id) => {
391
+ const out = [];
392
+ for (const c of parts) if (await view.mayShareKey(id, c.id)) out.push(c.name);
393
+ return out;
394
+ };
395
+ if ((await owns(own)).length > 0) return own;
396
+ const grants = (readGrants(evda) ?? []).filter((g) => g.role === "owner" && g.principalId && g.problems.every((p) => p.level !== "fail"));
397
+ const devices = readDevices(evda) ?? { lists: new Map(), problems: [] };
398
+ const names = new Map();
399
+ for (const g of grants) for (const d of devicesOf(devices, g.principalId)) if (!names.has(d)) names.set(d, g.holder?.name ?? d);
400
+ const found = [];
401
+ const peers = new Set(notMe);
402
+ for (const id of [...names.keys()].sort()) if (id !== own && !peers.has(id)) found.push({ id, name: names.get(id), parts: await owns(id) });
403
+ const full = found.filter((f) => f.parts.length === parts.length);
404
+ if (full.length === 1) return full[0].id;
405
+ const some = found.filter((f) => f.parts.length > 0);
406
+ if (peers.size > 0 && full.length === 0 && some.length === 1) return some[0].id;
407
+ const list = parts.map((c) => c.name).join(", ");
408
+ if (full.length > 1) {
409
+ throw new EvdaError(`${NOT_AN_OWNER_LEAD} (${list}), and ${full.length} devices own every one of them (${full.map((f) => f.name).join(", ")}), so this computer cannot tell which of them it is. Nothing was written.`, { code: "NOT_AN_OWNER", exit: 2 });
410
+ }
411
+ const who = found.filter((f) => f.parts.length > 0).map((f) => `${f.name} owns ${f.parts.join(", ")}`);
412
+ throw new EvdaError(`${NOT_AN_OWNER_LEAD} (${list}), but this computer is not an owner of any of them, and no one device owns them all${who.length ? `: ${who.join("; ")}` : ": no grant names an owner"}. Nothing was written.`, { code: "NOT_AN_OWNER", exit: 2 });
413
+ }
414
+
368
415
  // Paths under an append-only role, where two devices' concurrent appends union instead of
369
416
  // conflicting: the daily notes and the ledger (Spec 08, section 3).
370
417
  export function unionPaths(evda) {
@@ -415,10 +462,16 @@ export function circleCrypto(crypto) {
415
462
  // has no mapping for the home record, still unions where the home does.
416
463
  // onMinted optional ({ change, circle, payload }) -> after the Change is appended, once per
417
464
  // circle the file reaches
418
- // blobs optional { seal(bytes) -> { hash, contentKey }, link(changeId, hash) }: a file above
419
- // BLOB_THRESHOLD bytes, or one that is not UTF-8, rides the blob lane (Spec 08 §1). Its
420
- // bytes are sealed once under a fresh content key and kept by the device; the payload
421
- // carries { hash, contentKey } in place of the text. Absent, such a file is minted as text.
465
+ // blobs optional { seal(bytes) -> { hash, contentKey }, link(changeId, hash) }: a text file
466
+ // above BLOB_THRESHOLD bytes or not UTF-8, and a file the manifest carries, rides the
467
+ // blob lane (Spec 08 §1). Its bytes are sealed once under a fresh content key and kept
468
+ // by the device; the payload carries { hash, contentKey } in place of the text. Absent,
469
+ // such a file is minted as text.
470
+ // carry optional, the manifest's `sync.carry` list (sync-rules `carryList`). Every file that
471
+ // is neither text nor carried is a REFERENCE (HQ-2017): its payload is { path, sha256,
472
+ // baseSha256, ref: { name, size } }, with no text and no blob, and nothing is sealed.
473
+ // The scan passes `how.reference` for such a file and no bytes; a file that reaches
474
+ // mint with bytes is classified here again, so no caller can seal media by mistake.
422
475
  //
423
476
  // Throwing is how a callback says "not recorded": the scan leaves the path for the next run.
424
477
  export const BLOB_THRESHOLD = 256 * 1024;
@@ -431,8 +484,17 @@ const utf8OrNull = (bytes) => {
431
484
  }
432
485
  };
433
486
 
434
- export function fileMinter({ log, index, seal, notifyFor, previousSha, changesetId, originUser = null, onMinted, blobs, unionPath = null, readText = null }) {
487
+ // The reference payload for a file whose bytes do not travel (HQ-2017).
488
+ const refPayload = (address, sha256, baseSha256, size) => ({
489
+ path: address,
490
+ sha256,
491
+ baseSha256,
492
+ ref: { name: address.slice(address.lastIndexOf("/") + 1), size },
493
+ });
494
+
495
+ export function fileMinter({ log, index, seal, notifyFor, previousSha, changesetId, originUser = null, onMinted, blobs, unionPath = null, readText = null, carry = [] }) {
435
496
  const at = addressing(index);
497
+ const classOf = fileClass(carry);
436
498
  const circleOf = (path) => {
437
499
  const c = circleForPath(index, path);
438
500
  if (!c) throw new EvdaError(`${path} is in no circle, so it has no one to sync to.`, { code: "NO_CIRCLE" });
@@ -461,10 +523,11 @@ export function fileMinter({ log, index, seal, notifyFor, previousSha, changeset
461
523
  return first;
462
524
  };
463
525
  return {
464
- async mint(item, bytes) {
526
+ async mint(item, bytes, how = {}) {
465
527
  const home = circleOf(item.path);
466
528
  const address = at.address(home, item.path);
467
- const text = utf8OrNull(bytes);
529
+ const kind = how.reference ? "reference" : classOf(item.path);
530
+ const text = kind === "reference" ? null : utf8OrNull(bytes);
468
531
  const circles = home.mount ? [home] : circlesFor(index, item.path, text, readText);
469
532
  const union = unionPath?.(item.path) === true ? { union: true } : {};
470
533
  // A circle the file reached before and reaches no longer (its mark was taken off, or its home
@@ -474,7 +537,13 @@ export function fileMinter({ log, index, seal, notifyFor, previousSha, changeset
474
537
  const withdraw = async () => {
475
538
  if (withdrawn.length > 0) await append(FILE_REMOVE, item.path, { path: address, sha256: null, baseSha256: await previousSha(item.path) }, withdrawn);
476
539
  };
477
- if (blobs && (text === null || bytes.length > BLOB_THRESHOLD)) {
540
+ if (kind === "reference") {
541
+ const size = how.reference?.size ?? bytes.length;
542
+ await append(FILE_SET, item.path, refPayload(address, item.sha256, await previousSha(item.path), size), circles);
543
+ await withdraw();
544
+ return;
545
+ }
546
+ if (blobs && (kind === "carried" || text === null || bytes.length > BLOB_THRESHOLD)) {
478
547
  const sealed = await blobs.seal(bytes);
479
548
  const blob = { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") };
480
549
  await append(FILE_SET, item.path, { path: address, sha256: item.sha256, baseSha256: await previousSha(item.path), blob }, circles, sealed.hash);
@@ -528,28 +597,35 @@ export function fileMinter({ log, index, seal, notifyFor, previousSha, changeset
528
597
  // `ancestors`: every sha this device logged for the address before the current one, from any circle
529
598
  // it can open. A receiver holding any of them fast-forwards; a receiver holding a version nobody
530
599
  // here has seen keeps it and gets the current text beside it, by the ordinary merge table.
531
- export function catchUp({ evda, view, log, seal, open, changesetId, originUser = null, unionPath = null, blobs = null, exclude = null }) {
600
+ //
601
+ // `stream(circleId, { skip })` yields the same Changes one at a time, minting each as it is asked
602
+ // for, today's day file first and then newest first (HQ-2068); `snapshot` is the stream collected.
603
+ //
604
+ // `onFileSet(rel, sha256)`, when given, is awaited once per file.set this mints (HQ-2109): the
605
+ // catch-up reads and hashes the file itself, so it is the one place that can tell the scan index the
606
+ // file is already accounted for, at no extra read. Without it, a device's next ordinary scan finds no
607
+ // row for a file the catch-up just sent, reads it as never-before-seen, and mints a second Change for
608
+ // bytes the peer already has.
609
+ export function catchUp({ evda, view, log, seal, open, changesetId, originUser = null, unionPath = null, blobs = null, exclude = null, onFileSet = null }) {
610
+ // What travels, by the same rule as the scan (sync-rules, HQ-2017): a reference is hashed from a
611
+ // local file and carries no bytes; a dataless file is not opened at all, and is left out of the
612
+ // catch-up (it is neither sent nor withdrawn) until its bytes are on this disk.
613
+ const classOf = fileClass(carryList(evda.manifest));
532
614
  const readText = (rel) => {
533
615
  const abs = join(evda.dir, rel);
534
616
  return existsSync(abs) && statSync(abs).isFile() ? readFileSync(abs, "utf8") : null;
535
617
  };
618
+ // The files a circle member holds, by the sync rules' own walk (HQ-2017): the same exclusions the
619
+ // scan lists by (node_modules, .git, .evda, the plan's paths), no symlink, no iCloud placeholder,
620
+ // nothing over the blob lane. The catch-up once walked by its own rule (every dot name skipped,
621
+ // node_modules kept), so what it sent and withdrew disagreed with what the scan minted.
622
+ const skip = exclude ?? excluder();
536
623
  const walk = (prefix) => {
537
- const out = [];
538
624
  const start = join(evda.dir, prefix);
539
- if (!existsSync(start)) return out;
540
- if (statSync(start).isFile()) return [prefix];
541
- const stack = [prefix.replace(/\/+$/, "")];
542
- while (stack.length > 0) {
543
- const dir = stack.pop();
544
- for (const name of readdirSync(join(evda.dir, dir)).sort()) {
545
- if (name.startsWith(".")) continue;
546
- const rel = dir === "" ? name : `${dir}/${name}`;
547
- const st = statSync(join(evda.dir, rel));
548
- if (st.isDirectory()) stack.push(rel);
549
- else if (st.isFile()) out.push(rel);
550
- }
551
- }
552
- return out;
625
+ if (!existsSync(start)) return [];
626
+ const from = prefix.replace(/\/+$/, "");
627
+ if (statSync(start).isFile()) return skip(from) ? [] : [from];
628
+ return [...walkRules(evda.dir, { exclude: skip, from })].filter((e) => e.kind === "file").map((e) => e.rel);
553
629
  };
554
630
  const shaOf = async (bytes) => Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex");
555
631
 
@@ -568,55 +644,114 @@ export function catchUp({ evda, view, log, seal, open, changesetId, originUser =
568
644
  return out;
569
645
  };
570
646
 
571
- return {
572
- async snapshot(circleId) {
573
- const index = view().index;
574
- const target = circleById(index, circleId);
575
- if (!target) throw new EvdaError(`no circle ${circleId} in this record.`, { code: "NO_CIRCLE" });
576
- if (target.mount) throw new EvdaError(`circle "${target.name}" is held from ${target.mount.holder}; only its home catches a device up.`, { code: "NOT_OWNER" });
577
- const at = addressing(index);
578
- const present = new Map();
579
- for (const { prefix, match } of scanPrefixes(index, (c) => !c.mount && !c.frozen)) {
580
- for (const rel of walk(prefix)) {
581
- if (!match(rel) || present.has(rel) || exclude?.(rel)) continue;
582
- const bytes = readFileSync(join(evda.dir, rel));
583
- const text = utf8OrNull(bytes);
584
- if (!circlesFor(index, rel, text, readText).includes(target)) continue;
585
- present.set(rel, { bytes, text });
586
- }
647
+ const dayDir = dayFolderOf(evda.manifest);
648
+
649
+ // One Change per file the circle reaches now, newest first (HQ-2068), then one removal per address
650
+ // it no longer reaches. Each is minted only when the caller asks for the next, so a caller that
651
+ // posts as it goes has the first batch on the relay before the rest is read. `skip` holds
652
+ // addresses already sent by an earlier, interrupted run: neither minted again nor withdrawn.
653
+ async function* stream(circleId, { skip = new Set(), now = new Date() } = {}) {
654
+ const index = view().index;
655
+ const target = circleById(index, circleId);
656
+ if (!target) throw new EvdaError(`no circle ${circleId} in this record.`, { code: "NO_CIRCLE" });
657
+ if (target.mount) throw new EvdaError(`circle "${target.name}" is held from ${target.mount.holder}; only its home catches a device up.`, { code: "NOT_OWNER" });
658
+ const at = addressing(index);
659
+ const listed = new Set();
660
+ const skipped = new Set();
661
+ const files = [];
662
+ for (const { prefix, match } of scanPrefixes(index, (c) => !c.mount && !c.frozen)) {
663
+ const rels = walk(prefix).filter((rel) => match(rel) && !listed.has(rel));
664
+ const dataless = datalessAmong(evda.dir, rels);
665
+ for (const rel of rels) {
666
+ if (listed.has(rel)) continue;
667
+ listed.add(rel);
668
+ if (dataless.has(rel)) { skipped.add(rel); continue; }
669
+ let mtimeMs = 0;
670
+ try { mtimeMs = statSync(join(evda.dir, rel)).mtimeMs; } catch { continue; }
671
+ files.push({ rel, mtimeMs });
587
672
  }
588
- const minted = [];
589
- const append = async (kind, payload, blobHash = null) => {
590
- const id = await changesetId();
591
- const change = await log.append({ changesetId: id, eco: target.id, originUser, kind, target: payload.path, payload: await seal(target, payload), notify: [] });
592
- if (blobHash !== null) await blobs.link(change.id, blobHash);
593
- minted.push(change);
594
- };
595
- const addresses = new Set();
596
- for (const [rel, { bytes, text }] of [...present.entries()].sort(([a], [b]) => a.localeCompare(b))) {
597
- const address = at.address(circleForPath(index, rel), rel);
598
- addresses.add(address);
599
- const sha256 = await shaOf(bytes);
600
- const ancestors = await ancestorsOf(address, sha256);
601
- const base = { path: address, sha256, baseSha256: ancestors.at(-1) ?? null, ancestors };
602
- if (blobs && (text === null || bytes.length > BLOB_THRESHOLD)) {
603
- const sealed = await blobs.seal(bytes);
604
- await append(FILE_SET, { ...base, blob: { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") } }, sealed.hash);
605
- continue;
606
- }
607
- await append(FILE_SET, { ...base, text: text ?? new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes), ...(unionPath?.(rel) === true ? { union: true } : {}) });
673
+ }
674
+ files.sort(catchUpOrder(dayDir, localDate(now)));
675
+ const mint = async (kind, payload, blobHash = null) => {
676
+ const id = await changesetId();
677
+ const change = await log.append({ changesetId: id, eco: target.id, originUser, kind, target: payload.path, payload: await seal(target, payload), notify: [] });
678
+ if (blobHash !== null) await blobs.link(change.id, blobHash);
679
+ return change;
680
+ };
681
+ const addresses = new Set();
682
+ for (const { rel } of files) {
683
+ const kind = classOf(rel);
684
+ let bytes = null;
685
+ let text = null;
686
+ let size = 0;
687
+ let known = null;
688
+ if (kind === "reference") {
689
+ // Only its home circle: a mark to release is frontmatter, and a reference has no text.
690
+ if (circleForPath(index, rel) !== target) continue;
691
+ const raw = readFileSync(join(evda.dir, rel));
692
+ size = raw.length;
693
+ known = await shaOf(raw);
694
+ } else {
695
+ bytes = readFileSync(join(evda.dir, rel));
696
+ text = utf8OrNull(bytes);
697
+ if (!circlesFor(index, rel, text, readText).includes(target)) continue;
608
698
  }
609
- // Addresses the circle held before and does not now: removed, or no longer released into it.
610
- const gone = new Set();
611
- for (const change of await log.forEco(target.id)) {
612
- if (change.kind === FILE_SET && !addresses.has(change.target)) gone.add(change.target);
699
+ const address = at.address(circleForPath(index, rel), rel);
700
+ addresses.add(address);
701
+ if (skip.has(address)) continue;
702
+ const sha256 = known ?? (await shaOf(bytes));
703
+ // Told as soon as the hash is known, whichever branch below mints: a file.set is coming for
704
+ // this path either way, and the scan index should already agree with it by the time it lands.
705
+ if (onFileSet) await onFileSet(rel, sha256);
706
+ const ancestors = await ancestorsOf(address, sha256);
707
+ const base = { path: address, sha256, baseSha256: ancestors.at(-1) ?? null, ancestors };
708
+ if (kind === "reference") {
709
+ yield await mint(FILE_SET, { ...refPayload(address, sha256, base.baseSha256, size), ancestors });
710
+ continue;
613
711
  }
614
- for (const address of [...gone].sort()) {
615
- const ancestors = await ancestorsOf(address, null);
616
- if (ancestors.length === 0) continue;
617
- await append(FILE_REMOVE, { path: address, sha256: null, baseSha256: ancestors.at(-1), ancestors });
712
+ if (blobs && (kind === "carried" || text === null || bytes.length > BLOB_THRESHOLD)) {
713
+ const sealed = await blobs.seal(bytes);
714
+ yield await mint(FILE_SET, { ...base, blob: { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") } }, sealed.hash);
715
+ continue;
618
716
  }
717
+ yield await mint(FILE_SET, { ...base, text: text ?? new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes), ...(unionPath?.(rel) === true ? { union: true } : {}) });
718
+ }
719
+ // Addresses the circle held before and does not now: removed, or no longer released into it.
720
+ const gone = new Set();
721
+ const skippedAddresses = new Set([...skipped].map((rel) => { try { return at.address(circleForPath(index, rel), rel); } catch { return rel; } }));
722
+ for (const change of await log.forEco(target.id)) {
723
+ if (change.kind === FILE_SET && !addresses.has(change.target) && !skippedAddresses.has(change.target) && !skip.has(change.target)) gone.add(change.target);
724
+ }
725
+ for (const address of [...gone].sort()) {
726
+ const ancestors = await ancestorsOf(address, null);
727
+ if (ancestors.length === 0) continue;
728
+ yield await mint(FILE_REMOVE, { path: address, sha256: null, baseSha256: ancestors.at(-1), ancestors });
729
+ }
730
+ }
731
+
732
+ return {
733
+ stream,
734
+ async snapshot(circleId, o = {}) {
735
+ const minted = [];
736
+ for await (const change of stream(circleId, o)) minted.push(change);
619
737
  return minted;
620
738
  },
621
739
  };
622
740
  }
741
+
742
+ // The order a catch-up sends a circle's files in (HQ-2068): today's day file first, then the most
743
+ // recently modified, then by path, so a phone joining a large record shows what was written last
744
+ // within seconds instead of after the alphabet.
745
+ export function catchUpOrder(dayDir, today) {
746
+ const dayPrefix = `${String(dayDir ?? "").replace(/\/+$/, "")}/`;
747
+ const rank = (rel) => (rel.startsWith(dayPrefix) && rel.slice(dayPrefix.length).startsWith(today) ? 0 : 1);
748
+ return (a, b) => rank(a.rel) - rank(b.rel) || b.mtimeMs - a.mtimeMs || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0);
749
+ }
750
+
751
+ // The day folder a record's mapping names, else create-evda's default (as pairing.ts reads it).
752
+ function dayFolderOf(manifest) {
753
+ const mapping = manifest?.mapping;
754
+ const role = dayRole(mapping);
755
+ const rel = role ? mapping?.[role] : undefined;
756
+ return typeof rel === "string" && rel.trim() !== "" ? rel.replace(/\/+$/, "") : "record/daily";
757
+ }