create-evda 0.1.9 → 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.
package/lib/gather.mjs CHANGED
@@ -27,9 +27,19 @@
27
27
  // and placed nowhere, and a person who already has a page there is not asked about. So a second
28
28
  // gather asks only about what is new. Applying the proposal is lib/gather-apply.mjs.
29
29
  //
30
+ // Byte-identical files are placed once and every other copy is named as a duplicate of that one; an
31
+ // item nobody placed is left in the pile, where it is, and the summary says so (HQ-1368).
32
+ //
30
33
  // A placed `.txt` note is named `.md` on the way in, its bytes unchanged: plain text is already
31
- // markdown, and every reader in EVDA (the occurrence index, doctor, search) reads `.md`. Nothing
32
- // else is renamed; an `.html` export keeps its name, because making it markdown would change it.
34
+ // markdown, and every reader in EVDA (the occurrence index, doctor, search) reads `.md`. A note-shaped
35
+ // `.html` file (Google Keep's Takeout, an Apple Notes export) is converted to markdown at apply and
36
+ // named `.md`, its title a heading, its checklist `- [x]` items and Keep's labels `tags:`; the
37
+ // proposal marks it `convert: "html-to-markdown"` so the owner sees the change before saying yes
38
+ // (lib/html-notes.mjs). Any other `.html` (a saved web page) keeps its name and its bytes.
39
+ //
40
+ // Google Takeout's `.json` sidecars are paired with the photo or note each describes and never placed
41
+ // themselves: a photo's date taken and caption, a Keep note's created and edited times, are folded into
42
+ // that file's entry (lib/takeout.mjs), and the sidecar is named in the summary with the file it describes.
33
43
  //
34
44
  // A chat export (HQ-1385: Claude.ai's or ChatGPT's conversations.json) is not a file to place. It is
35
45
  // the backstop for what an agent did not file while the conversation happened: the owner's own
@@ -53,13 +63,15 @@ import { askOwnersAi } from "./organize.mjs";
53
63
  import { onlyKeys, refuse, dayFile } from "./tools.mjs";
54
64
  import { readChatExport, conversations, filedSections, backfillPlan, CHAT_FORMATS } from "./chat-export.mjs";
55
65
  import { EvdaError } from "./errors.mjs";
66
+ import { noteShape, htmlNoteToMarkdown } from "./html-notes.mjs";
67
+ import { pairSidecars } from "./takeout.mjs";
56
68
  import { frontmatterOf, heldBy, withoutSealed, carries, NOTED } from "./circles.mjs";
57
69
 
58
70
  export const GATHER_VERSION = 1;
59
71
  // The person-layer kinds an item is typed with, and the folder type each lives under.
60
72
  export const GATHER_KINDS = ["note", "person", "project", "event", "log", "source", "piece", "blob", "capture"];
61
73
  export const FOLDER_TYPE_FOR = { note: "record", person: "person", project: "project", event: "event", log: "log", source: "source", piece: "piece", blob: "blob", capture: "capture" };
62
- export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "credentials-ordinary", "examine-nothing", "no-role"];
74
+ export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "credentials-ordinary", "examine-nothing", "no-role", "keep-junk", "container-subjects", "place-duplicates", "html-as-is"];
63
75
  // The role each kind fills when the preset has no folder for it (Spec 07 section 2, HQ-1371):
64
76
  // what happened, dated, is journal; one folder per thing is collections; what is known is knowledge.
65
77
  export const ROLE_FOR_KIND = { note: "journal", capture: "journal", log: "journal", person: "collections", project: "collections", event: "collections", piece: "collections", blob: "collections", course: "collections", source: "knowledge", areas: "collections" };
@@ -102,12 +114,37 @@ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
102
114
 
103
115
  const SKIP_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "node_modules", ".git", ".obsidian", ".trash", ".Trash", "__MACOSX"]);
104
116
  const APP_DIRS = { ".obsidian": "obsidian" };
117
+ // App-state junk a real pile carries (HQ-1368, the 2026-09-27 trial placed every one of these): what
118
+ // an app wrote for itself, not something the owner kept. Each is skipped, with why, and named in the
119
+ // summary. `junkWhy(name, size, isDir)` is the reason, or null for a file the owner may want.
120
+ export const JUNK_DIRS = new Set(["recently deleted"]);
121
+ export const JUNK_FILES = new Set(["archive_browser.html"]);
122
+ export function junkWhy(name, size, isDir = false) {
123
+ if (falsified("keep-junk")) return null;
124
+ const lower = name.toLowerCase();
125
+ if (isDir) return JUNK_DIRS.has(lower) ? "what the app had already deleted" : null;
126
+ if (name.startsWith("~$")) return "an Office lock file, written while a document was open";
127
+ if (JUNK_FILES.has(lower)) return "Google Takeout's index of the export, not the owner's";
128
+ if (size === 0) return "an empty file (zero bytes)";
129
+ return null;
130
+ }
105
131
  const ARCHIVE_EXT = new Set([".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"]);
106
132
  const BINARY_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".heic", ".webp", ".pdf", ".mov", ".mp4", ".m4a", ".mp3", ".wav", ".xlsx", ".xls", ".docx", ".doc", ".pptx", ".key", ".pages", ".numbers", ".sqlite", ".db"]);
107
133
  const TEXT_MAX = 2 * 1024 * 1024;
108
134
  const NOTE_SIZED = 20000; // characters: longer text is read for therapy words only in its name and heading
109
135
  // Folder names that hold things without saying what they are about, so they are not a subject.
110
136
  const CONTAINERS = new Set(["documents", "desktop", "downloads", "notes", "daily", "inbox", "captures", "vault", "obsidian vault", "misc", "stuff", "untitled folder", "new folder", "export", "icloud drive"]);
137
+ // The folders an export or an app names for itself (HQ-1368, the 2026-09-27 trial read Keep's `Keep/`
138
+ // as a subject and cited "My Contacts" and "Photos from 2025" in the preset's reason): they say which
139
+ // app the files came out of, never what they are about, so none is ever a subject, at any depth.
140
+ export const EXPORT_CONTAINERS = new Set(["takeout", "google takeout", "keep", "google keep", "drive", "my drive", "google drive", "shared with me", "google photos", "my contacts", "contacts", "apple notes", "icloud", "onedrive", "dropbox", "evernote", "notion", "exports", "backup", "backups"]);
141
+ const EXPORT_PATTERNS = [/^photos from \d{4}$/i, /^takeout[-_]\d/i];
142
+ export function isContainer(name) {
143
+ const lower = name.toLowerCase();
144
+ if (CONTAINERS.has(lower)) return true;
145
+ if (falsified("container-subjects")) return false;
146
+ return EXPORT_CONTAINERS.has(lower) || EXPORT_PATTERNS.some((re) => re.test(name));
147
+ }
111
148
  const COURSE = /\b[A-Z]{2,4}\s?\d{3}[A-Z]?\b/;
112
149
  const COURSE_WORD = /\b(syllabus|lecture|homework|problem set|midterm)\b/i;
113
150
  const THERAPY = /\b(therapy|therapist|counsell?or|counselling|counseling|psychiatrist|psychologist|CBT|EMDR)\b/i;
@@ -243,6 +280,8 @@ function walk(root) {
243
280
  if (SKIP_NAMES.has(name)) { skipped.push({ rel, why: APP_DIRS[name] ? `${APP_DIRS[name]}'s own settings, not the owner's notes` : "app or system state" }); continue; }
244
281
  const s = lstatSync(abs);
245
282
  if (s.isSymbolicLink()) { skipped.push({ rel, why: "a symlink; not followed" }); continue; }
283
+ const junk = s.isDirectory() || s.isFile() ? junkWhy(name, s.size, s.isDirectory()) : null;
284
+ if (junk) { skipped.push({ rel: s.isDirectory() ? `${rel}/` : rel, why: junk }); continue; }
246
285
  if (s.isDirectory()) { go(abs); continue; }
247
286
  if (!s.isFile()) { skipped.push({ rel, why: "not a regular file" }); continue; }
248
287
  if (name.startsWith(".")) { skipped.push({ rel, why: "a hidden file" }); continue; }
@@ -259,7 +298,7 @@ function subjectOf(rel, projectRoot) {
259
298
  const parts = rel.split("/").slice(0, -1);
260
299
  for (let i = parts.length - 1; i >= 0; i--) {
261
300
  const p = parts[i];
262
- if (CONTAINERS.has(p.toLowerCase())) continue;
301
+ if (isContainer(p)) continue;
263
302
  if (/^(people|contacts|meetings|projects|drafts|writing|essays|clippings|reading|articles|bookmarks|logs|calendar|photos|attachments)$/i.test(p)) continue;
264
303
  return p;
265
304
  }
@@ -349,7 +388,13 @@ export function scan(sources, { now = new Date() } = {}) {
349
388
  const readmeDirs = new Set(w.files.filter((f) => /^readme\.(md|txt)$/i.test(basename(f.rel)) && f.rel.includes("/")).map((f) => dirname(f.rel)).filter((d) => w.files.filter((f) => dirname(f.rel) === d).length >= 2));
350
389
  out.sources.push({ id: `s${si}`, path: root, files: w.files.length, skipped: w.skipped.length, apps: w.apps });
351
390
  for (const s of w.skipped) out.skipped.push({ source: `s${si}`, ...s });
391
+ // Google Takeout's sidecars (HQ-1368, lib/takeout.mjs): each is paired with the photo or note it
392
+ // describes, its date and caption folded into that file's entry, and is never an entry of its own.
393
+ const side = pairSidecars(w.files, (abs) => readFileSync(abs));
394
+ const sidecarAt = new Map(side.sidecars.map((x) => [x.rel, x]));
395
+ for (const x of side.sidecars) out.skipped.push({ source: `s${si}`, ...x });
352
396
  for (const f of w.files) {
397
+ if (sidecarAt.has(f.rel)) continue;
353
398
  const buf = readFileSync(f.abs);
354
399
  const ext = extname(f.rel).toLowerCase();
355
400
  const chat = ext === ".json" ? readChatExport(buf) : null;
@@ -364,12 +409,22 @@ export function scan(sources, { now = new Date() } = {}) {
364
409
  // section is cut before any file is read for its heading, people or words. Its bytes and hash
365
410
  // are unchanged, so apply copies it with its flag.
366
411
  const seal = sealedOf(ext, buf);
367
- const facts = typeItem({ rel: f.rel, buf: seal.buf, ext, projectRoot });
412
+ // A note-shaped HTML file is read as the markdown it will become (HQ-1368), so it is typed,
413
+ // searched for people and checked for a credential by its words, not its tags.
414
+ const shape = (ext === ".html" || ext === ".htm") && !falsified("html-as-is") ? noteShape(buf.toString("utf8")) : null;
415
+ const conv = shape ? htmlNoteToMarkdown(buf.toString("utf8")) : null;
416
+ if (conv?.empty) { out.skipped.push({ source: `s${si}`, rel: f.rel, why: "an empty note: no title and no body" }); continue; }
417
+ if (conv) seal.buf = Buffer.from(conv.markdown, "utf8");
418
+ const facts = typeItem({ rel: f.rel, buf: seal.buf, ext: conv ? ".md" : ext, projectRoot });
419
+ if (conv) facts.html = shape;
368
420
  if (seal.sealed) facts.sealed = true;
369
421
  if (seal.noted) facts.noted = true;
370
422
  const item = { id: `s${si}:${f.rel}`, source: `s${si}`, path: f.rel, size: f.size, sha256: sha256(buf), ...facts };
371
423
  if (projectRoot) item.project = projectRoot;
424
+ if (side.folded.has(f.rel)) item.takeout = side.folded.get(f.rel);
372
425
  item.subject = subjectOf(f.rel, projectRoot);
426
+ // A Keep note sits in Takeout/Keep, which says nothing of what it is about; its first label does.
427
+ if (!item.subject && !projectRoot && conv?.labels.length) item.subject = conv.labels[0];
373
428
  if (!item.refused && !item.credential && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
374
429
  out.items.push(item);
375
430
  }
@@ -417,9 +472,11 @@ function findPeople(items) {
417
472
  for (const p of byName.values()) {
418
473
  const first = p.name.split(" ")[0];
419
474
  const re = new RegExp(`(^|[^\\p{L}])(${escape(p.name)}${firsts.get(first) === 1 && first.length >= 3 ? `|${escape(first)}` : ""})($|[^\\p{L}])`, "u");
475
+ // Identical copies of one file are one mention, so a duplicate does not count a person twice.
476
+ const seen = new Set();
420
477
  for (const i of items) {
421
- if (i.text === undefined || p.pages.includes(i.id)) continue;
422
- if (re.test(i.text) || re.test(i.path)) p.files.add(i.id);
478
+ if (i.text === undefined || p.pages.includes(i.id) || seen.has(i.sha256)) continue;
479
+ if (re.test(i.text) || re.test(i.path)) { p.files.add(i.id); seen.add(i.sha256); }
423
480
  }
424
481
  }
425
482
  return [...byName.values()].map((p) => ({ name: p.name, ref: p.cards[0] ?? null, page: p.pages[0] ?? null, files: [...p.files].sort() })).sort((a, b) => a.name.localeCompare(b.name));
@@ -484,8 +541,12 @@ function homes(preset) {
484
541
  };
485
542
  }
486
543
 
544
+ // A name a copy is given by the system that made it: "x (1)", "x copy", "x copy 2", "Copy of x".
545
+ const COPY_MARKER = /(?: \(\d+\)| copy(?: \d+)?|^copy of .+)$/i;
546
+
487
547
  // A placed text item's name: `.txt` becomes `.md`, bytes unchanged. Every other name is kept.
488
- const asMarkdown = (i, name) => (i.kind !== "blob" && /\.txt$/i.test(name) ? name.replace(/\.txt$/i, ".md") : name);
548
+ // A note-shaped `.html` becomes `.md` too, its bytes converted at apply (lib/html-notes.mjs).
549
+ const asMarkdown = (i, name) => (i.kind === "blob" ? name : /\.txt$/i.test(name) ? name.replace(/\.txt$/i, ".md") : i.html && /\.html?$/i.test(name) ? name.replace(/\.html?$/i, ".md") : name);
489
550
 
490
551
  // The placement of every item, given the facts and the answers. Pure.
491
552
  export function place(p) {
@@ -526,7 +587,7 @@ export function place(p) {
526
587
  // Only markdown carries `sealed: true`, so a credential in any other format is never copied.
527
588
  pl = { status: "refused", to: null, why: `${i.credential} and is not a note that can be sealed; left where it is and quoted nowhere. Keep the secret in a password manager.` };
528
589
  } else if (i.present) pl = { status: "present", to: null, at: i.present, why: `already in the record at ${i.present}; left where it is` };
529
- else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file; unpack it and gather the folder" };
590
+ else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file, left in the pile; unpack it and gather the folder" };
530
591
  else if (i.cards) pl = { status: "reference", to: null, why: "held by Contacts; a person page keeps a contacts: reference, never the card" };
531
592
  else if (i.kind === "project") {
532
593
  // Status is a field, never a move: a finished project stays in its collection and says so.
@@ -541,18 +602,44 @@ export function place(p) {
541
602
  const course = h.courses && i.subject && COURSE.test(i.subject);
542
603
  if (course) pl = { status: "placed", to: `${h.courses}/${i.subject}/${file}` };
543
604
  else if (i.subject) pl = { status: "placed", to: `${folderFor("areas", "record")}/${i.subject}/${file}` };
544
- else pl = { status: "unplaced", to: null, why: `no subject: it sits in no folder that says what it is about${h.captures ? `; ${h.captures}/ holds it until someone says` : ""}` };
605
+ else pl = { status: "unplaced", to: null, why: "no subject: it sits in no folder that says what it is about; left in the pile, where it is, until someone says where it goes" };
545
606
  } else if (i.mode === "reference") pl = { status: "reference", to: `${folderFor(i.kind, FOLDER_TYPE_FOR[i.kind])}/${basename(i.path, extname(i.path))}.md`, refs: i.refs, why: "a new entry holding the reference; the file stays where it is" };
546
607
  else pl = { status: "placed", to: `${folderFor(i.kind, FOLDER_TYPE_FOR[i.kind])}/${file}` };
547
- if (pl.to) pl.to = claim(pl.to, i.id);
548
- if (pl.to) pl.role = roleOf(pl.to);
549
608
  if (i.refs && pl.status === "placed") pl.refs = i.refs;
550
609
  if (i.kind === "person" && !i.cards) {
551
610
  const who = p.people.find((x) => x.page === i.id);
552
611
  if (who?.ref) pl.refs = [who.ref];
553
612
  }
613
+ if (i.html && pl.to && pl.status === "placed") pl.convert = "html-to-markdown";
554
614
  placements.push({ item: i.id, ...pl });
555
615
  }
616
+ // Byte-identical files are placed once (HQ-1368): the copy placed is one that has somewhere to go,
617
+ // then one whose name carries no copy marker ("ENERGY STAR (1).pdf", "Plan copy.txt"), then the
618
+ // shortest path, then the first walked; every other copy stays in the pile, named as a duplicate of
619
+ // that one. A project's own files are kept whole, so an identical file in two projects is in both.
620
+ if (!falsified("place-duplicates")) {
621
+ const items = new Map(p.items.map((i) => [i.id, i]));
622
+ const groups = new Map();
623
+ placements.forEach((pl, n) => {
624
+ const i = items.get(pl.item);
625
+ if (!["placed", "reference", "unplaced"].includes(pl.status) || i.kind === "project" || i.cards || i.credential) return;
626
+ groups.set(i.sha256, [...(groups.get(i.sha256) ?? []), n]);
627
+ });
628
+ const marked = (path) => COPY_MARKER.test(basename(path, extname(path)));
629
+ for (const g of groups.values()) {
630
+ if (g.length < 2) continue;
631
+ const rank = (n) => [placements[n].status === "unplaced" ? 1 : 0, marked(items.get(placements[n].item).path) ? 1 : 0, placements[n].item.length, n];
632
+ const order = (a, b) => { const x = rank(a), y = rank(b); const k = x.findIndex((v, j) => v !== y[j]); return k === -1 ? 0 : x[k] - y[k]; };
633
+ const keep = [...g].sort(order)[0];
634
+ const kept = placements[keep];
635
+ for (const n of g) if (n !== keep) placements[n] = { item: placements[n].item, status: "duplicate", to: null, of: kept.item, why: `the same bytes as ${kept.item}, which is ${kept.status === "unplaced" ? "also left in the pile" : "placed"}; this copy is left in the pile` };
636
+ }
637
+ }
638
+ // Names are claimed after the duplicates are out, so the copy kept never yields its name to one left behind.
639
+ for (const pl of placements) {
640
+ if (pl.to) pl.to = claim(pl.to, pl.item);
641
+ if (pl.to) pl.role = roleOf(pl.to);
642
+ }
556
643
  // Person pages the owner said yes to, made new, holding a Contacts reference when a card names them.
557
644
  for (const who of p.people) {
558
645
  if (ans[`person:${slugId(who.name)}`] !== "yes" || who.page) continue;
@@ -622,7 +709,7 @@ export function questions(p) {
622
709
  // ---- the proposal ----
623
710
 
624
711
  function counts(p) {
625
- const c = { items: p.items.length, placed: 0, reference: 0, create: 0, unplaced: 0, refused: 0, present: 0, skipped: p.skipped.length };
712
+ const c = { items: p.items.length, placed: 0, reference: 0, create: 0, unplaced: 0, refused: 0, present: 0, duplicate: 0, skipped: p.skipped.length, sidecars: p.skipped.filter((x) => x.sidecar).length };
626
713
  for (const pl of p.placements) c[pl.status] += 1;
627
714
  c.chats = (p.chats ?? []).length;
628
715
  c.chatMessages = (p.chats ?? []).reduce((n, x) => n + x.messages, 0);
@@ -782,11 +869,12 @@ export function checkedLines(p) {
782
869
  const chats = p.chats ?? [];
783
870
  return [
784
871
  ...(chats.length ? [`CHECKED ${c.chatMessages} message(s) of the owner's in ${chats.reduce((n, x) => n + x.conversations, 0)} conversation(s) from ${chats.length} chat export(s)${p.into ? ` ${chats.reduce((n, x) => n + (x.add ?? 0), 0)} not yet in the record` : ""}`] : []),
785
- `CHECKED ${c.items} items in ${p.sources.length} source(s) ${c.placed} placed, ${c.reference} by reference, ${c.create} to create, ${c.unplaced} left for a person, ${c.refused} refused, ${c.skipped} skipped${p.into ? `, ${c.present} already in the record` : ""}`,
872
+ `CHECKED ${c.items} items in ${p.sources.length} source(s) ${c.placed} placed, ${c.reference} by reference, ${c.create} to create, ${c.unplaced} left for a person, ${c.refused} refused, ${c.skipped} skipped${p.into ? `, ${c.present} already in the record` : ""}${c.duplicate ? `, ${c.duplicate} duplicate(s) left in the pile` : ""}${c.sidecars ? ` (${c.sidecars} of them Takeout sidecars, ${p.items.filter((i) => i.takeout).length} folded into their files)` : ""}`,
786
873
  c.questions ? `CHECKED ${c.questions} question(s) each changes where at least one item goes; ${c.open} open` : "SKIPPED questions: nothing in this pile needs the owner to decide a placement",
787
874
  ];
788
875
  }
789
876
 
877
+ const SKIPPED_SHOWN = 20;
790
878
  export function summaryText(p, { detail = false } = {}) {
791
879
  const lines = [`A proposal for ${p.into ? `growing the EVDA at ${p.into} from` : "turning"} ${p.sources.map((s) => s.path).join(", ")}${p.into ? "" : " into an EVDA"}. Nothing was moved, copied or changed.`, ""];
792
880
  lines.push(`Preset: ${p.preset.title} (${p.preset.id}), because ${p.preset.why.join("; ")}.`, "", "Folders:");
@@ -794,12 +882,24 @@ export function summaryText(p, { detail = false } = {}) {
794
882
  const byStatus = (s) => p.placements.filter((x) => x.status === s);
795
883
  if (byStatus("reference").length) { lines.push("", "Kept as references, never copied:"); for (const x of byStatus("reference")) lines.push(`- ${x.item}${x.to ? ` -> ${x.to}` : ""}: ${x.why}`); }
796
884
  if (byStatus("create").length) { lines.push("", "New pages:"); for (const x of byStatus("create")) lines.push(`- ${x.to}${x.refs.length ? ` (${x.refs.join(", ")})` : ""}`); }
797
- if (byStatus("unplaced").length) { lines.push("", "Left for a person:"); for (const x of byStatus("unplaced")) lines.push(`- ${x.item}: ${x.why}`); }
885
+ if (byStatus("unplaced").length) { lines.push("", "Left in the pile, where they are, for a person to place:"); for (const x of byStatus("unplaced")) lines.push(`- ${x.item}: ${x.why}`); }
886
+ if (byStatus("duplicate").length) { lines.push("", "Duplicates, left in the pile; one copy of each is placed:"); for (const x of byStatus("duplicate")) lines.push(`- ${x.item}: the same bytes as ${x.of}`); }
798
887
  if (byStatus("refused").length) { lines.push("", "Refused:"); for (const x of byStatus("refused")) lines.push(`- ${x.item}: ${x.why}`); }
888
+ if (p.skipped.length) {
889
+ // Named, so the owner sees what was left out and why (HQ-1368). A long list is cut unless --all.
890
+ const shown = detail ? p.skipped : p.skipped.slice(0, SKIPPED_SHOWN);
891
+ lines.push("", "Skipped, never placed:");
892
+ for (const x of shown) lines.push(`- ${x.rel}: ${x.why}`);
893
+ if (shown.length < p.skipped.length) lines.push(`- and ${p.skipped.length - shown.length} more (show --all lists them)`);
894
+ }
799
895
  if (byStatus("present").length) lines.push("", `Already in the record, left where they are: ${byStatus("present").length} item(s).`);
800
896
  for (const c of p.chats ?? []) lines.push("", `A ${CHAT_FORMATS[c.format]} export (${c.path}): ${c.messages} message(s) of the owner's in ${c.conversations} conversation(s)${c.add !== undefined ? `, ${c.add} of them not in the record yet, to be added under each conversation's date and time` : ", each added to its day unless the record already holds it"}. Only their messages; the model's replies are never written.`);
801
897
  if (p.circles.length) { lines.push("", "Circles:"); for (const c of p.circles) lines.push(`- ${c.name} over ${c.covers}/, ${c.status}`); }
802
- if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}`); }
898
+ const converted = byStatus("placed").filter((x) => x.convert === "html-to-markdown");
899
+ if (converted.length) { lines.push("", `Converted from HTML to markdown on the way in, so they read as notes: ${converted.length} note(s); the pile keeps its .html files.`); for (const x of converted.slice(0, 20)) lines.push(`- ${x.item} -> ${x.to}`); if (converted.length > 20) lines.push(`- and ${converted.length - 20} more`); }
900
+ const folded = p.items.filter((i) => i.takeout);
901
+ if (folded.length) { lines.push("", `Google Takeout's details folded in, so each sidecar is no entry of its own: ${folded.length} file(s); a photo's date taken becomes its file's date, a note's times its frontmatter. No place and no tagged person is read.`); for (const i of folded.slice(0, 20)) lines.push(`- ${i.id}${i.takeout.taken ? `, taken ${i.takeout.taken}` : ""}${i.takeout.created ? `, created ${i.takeout.created}` : ""}${i.takeout.caption ? ", with a caption" : ""} (from ${i.takeout.sidecar})`); if (folded.length > 20) lines.push(`- and ${folded.length - 20} more`); }
902
+ if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}${x.convert ? " (converted from HTML)" : ""}`); }
803
903
  const open = p.questions.filter((q) => q.answer === undefined);
804
904
  lines.push("", open.length ? `${open.length} question(s) for the owner:` : "No open questions.");
805
905
  for (const q of open) lines.push(`- [${q.id}] ${q.text} (${q.options.join(" / ")}${q.default ? `, default ${q.default}` : ""})`);
@@ -0,0 +1,124 @@
1
+ // Note-shaped HTML to markdown (HQ-1368). Google Keep's Takeout export and Apple Notes' HTML export
2
+ // write each note as a small HTML file, and every reader in EVDA (search, the occurrence index,
3
+ // doctor) reads markdown, so a note left as HTML is invisible to all of them. A gather converts a
4
+ // note-shaped HTML file into a `.md` note at apply, and says so in the proposal before anyone says yes.
5
+ //
6
+ // Only note-shaped HTML is converted. A saved web page (scripts, stylesheet links, navigation, forms,
7
+ // a "saved from url" comment) is not a note the owner wrote: it keeps its `.html` name and its bytes.
8
+ //
9
+ // noteShape(html) "keep", "apple-notes", "html-note", or null when the file is not a note
10
+ // htmlNoteToMarkdown(html) { markdown, title, labels, empty }: the note as markdown, frontmatter
11
+ // `tags:` holding Keep's labels; `empty` when it has neither a title nor a body
12
+ //
13
+ // EVDA_GATHER_FALSIFY=html-as-is turns the conversion off (lib/gather.mjs), so the tests can show
14
+ // their checks fail when it is gone.
15
+
16
+ const NAMED = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " ", ndash: "-", mdash: "-", hellip: "...", lsquo: "'", rsquo: "'", ldquo: "\"", rdquo: "\"", bull: "*", copy: "(c)", reg: "(R)", trade: "(TM)" };
17
+ export function decodeEntities(s) {
18
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
19
+ if (e[0] === "#") {
20
+ const n = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
21
+ return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m;
22
+ }
23
+ return NAMED[e.toLowerCase()] ?? m;
24
+ }).replace(/ /g, " ");
25
+ }
26
+
27
+ const KEEP = /<div\s+class="note\b[^"]*"/i;
28
+ const KEEP_PART = /class="(?:title|content|heading|chips|list(?:item)?)"/i;
29
+ // What a saved web page carries and a note never does.
30
+ const NOT_A_NOTE = /<!--\s*saved from url|<(?:nav|form|iframe|input|button|select|textarea|header|footer|aside|noscript|frameset|object|embed)\b|<link\b[^>]*rel=["']?stylesheet|<script\b|<meta\b[^>]*property=["']?og:/i;
31
+ // The tags a note's body is made of. Anything else and it is a page, not a note.
32
+ const NOTE_TAGS = new Set(["html", "head", "meta", "title", "style", "body", "div", "p", "br", "span", "font", "b", "strong", "i", "em", "u", "s", "strike", "del", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "a", "img", "tt", "code", "pre", "blockquote", "table", "thead", "tbody", "tr", "td", "th", "hr", "sup", "sub", "center", "small", "big"]);
33
+ const NOTE_SIZED = 20000;
34
+
35
+ export function noteShape(html) {
36
+ const raw = String(html ?? "");
37
+ if (!/<(?:html|body|div|p|br|h1)\b/i.test(raw)) return null;
38
+ if (KEEP.test(raw) && KEEP_PART.test(raw)) return "keep";
39
+ if (NOT_A_NOTE.test(raw)) return null;
40
+ const withoutStyle = raw.replace(/<style\b[\s\S]*?<\/style\s*>/gi, "").replace(/<!--[\s\S]*?-->/g, "").replace(/<!doctype[^>]*>/gi, "");
41
+ for (const m of withoutStyle.matchAll(/<\/?([a-z][a-z0-9]*)\b/gi)) if (!NOTE_TAGS.has(m[1].toLowerCase())) return null;
42
+ const text = decodeEntities(withoutStyle.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
43
+ if (text.length > NOTE_SIZED) return null;
44
+ if (/<meta\b[^>]*(?:name=["']?generator["']?[^>]*content=["'][^"']*notes|apple-notes)/i.test(raw) || /<div>[\s\S]*<\/div>/i.test(withoutStyle) && !/<p\b/i.test(withoutStyle)) return "apple-notes";
45
+ return "html-note";
46
+ }
47
+
48
+ // The element whose opening tag matches `open`, with everything up to its balanced closing tag.
49
+ function element(html, open) {
50
+ const m = open.exec(html);
51
+ if (!m) return null;
52
+ const tag = /^<([a-z0-9]+)/i.exec(m[0])[1].toLowerCase();
53
+ const re = new RegExp(`<(/?)${tag}\\b[^>]*>`, "gi");
54
+ re.lastIndex = m.index + m[0].length;
55
+ let depth = 1;
56
+ for (let t; (t = re.exec(html));) {
57
+ depth += t[1] ? -1 : 1;
58
+ if (depth === 0) return { inner: html.slice(m.index + m[0].length, t.index), start: m.index, end: re.lastIndex };
59
+ }
60
+ return { inner: html.slice(m.index + m[0].length), start: m.index, end: html.length };
61
+ }
62
+
63
+ // Inline tags to markdown. Entities are decoded once, at the end of a block pass, so a decoded "&lt;b&gt;"
64
+ // is never read again as a tag.
65
+ const tags = (s) => (s
66
+ .replace(/<br\s*\/?>/gi, "\n")
67
+ .replace(/<(b|strong)\b[^>]*>([\s\S]*?)<\/\1\s*>/gi, (_, __, t) => (t.replace(/<[^>]+>/g, "").trim() ? `**${t.trim()}**` : t))
68
+ .replace(/<(i|em)\b[^>]*>([\s\S]*?)<\/\1\s*>/gi, (_, __, t) => (t.replace(/<[^>]+>/g, "").trim() ? `*${t.trim()}*` : t))
69
+ .replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>/gi, (_, href, t) => { const label = t.replace(/<[^>]+>/g, "").trim(); return label && label !== href ? `[${label}](${href})` : href; })
70
+ .replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*>/gi, (_, src) => (src.startsWith("data:") ? "" : `![](${src})`))
71
+ .replace(/<[^>]+>/g, ""));
72
+ const inline = (s) => decodeEntities(tags(s));
73
+
74
+ // A body of HTML to markdown lines: blocks become lines, list items bullets, Keep's checked items `- [x]`.
75
+ function blocks(html) {
76
+ let s = html
77
+ .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "")
78
+ .replace(/<!--[\s\S]*?-->/g, "")
79
+ .replace(/<head\b[\s\S]*?<\/head\s*>/gi, "");
80
+ s = s.replace(/<li\b([^>]*)>([\s\S]*?)<\/li\s*>/gi, (_, attrs, inner) => {
81
+ const keep = /class="[^"]*listitem/i.test(attrs);
82
+ const checked = keep && /class="[^"]*\bchecked\b/i.test(attrs);
83
+ const text = keep ? (/<span\s+class="text"[^>]*>([\s\S]*?)<\/span>/i.exec(inner)?.[1] ?? inner.replace(/<span\s+class="bullet"[^>]*>[\s\S]*?<\/span>/gi, "")) : inner;
84
+ const t = tags(text).replace(/\s*\n\s*/g, " ").trim();
85
+ return `\n${keep ? (checked ? "- [x] " : "- [ ] ") : "- "}${t}\n`;
86
+ });
87
+ s = s.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi, (_, n, t) => `\n${"#".repeat(Number(n))} ${tags(t).replace(/\s+/g, " ").trim()}\n\n`);
88
+ s = s.replace(/<hr\b[^>]*>/gi, "\n---\n");
89
+ s = s.replace(/<\/(div|p|tr|blockquote|pre|ul|ol|table)\s*>/gi, "\n").replace(/<(div|p|tr|blockquote|pre|ul|ol|table)\b[^>]*>/gi, "\n").replace(/<\/t[dh]\s*>/gi, " ");
90
+ const lines = inline(s).split("\n").map((l) => l.replace(/[ \t]+/g, " ").trim());
91
+ // A list is tight: no blank line between two items of it.
92
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^(- .*)\n\n(?=- )/gm, "$1\n").replace(/^(- .*)\n\n(?=- )/gm, "$1\n").trim();
93
+ }
94
+
95
+ const yamlString = (s) => JSON.stringify(s);
96
+
97
+ export function htmlNoteToMarkdown(html) {
98
+ const raw = String(html ?? "");
99
+ const shape = noteShape(raw);
100
+ let title = "";
101
+ let body = "";
102
+ let labels = [];
103
+ if (shape === "keep") {
104
+ const t = element(raw, /<div\s+class="title"[^>]*>/i);
105
+ title = t ? inline(t.inner).replace(/\s+/g, " ").trim() : "";
106
+ const c = element(raw, /<div\s+class="content"[^>]*>/i);
107
+ body = c ? blocks(c.inner) : "";
108
+ labels = [...raw.matchAll(/<span\s+class="label-name"[^>]*>([\s\S]*?)<\/span>/gi)].map((m) => inline(m[1]).trim()).filter(Boolean);
109
+ } else {
110
+ const b = element(raw, /<body\b[^>]*>/i);
111
+ body = blocks(b ? b.inner : raw);
112
+ const docTitle = /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(raw);
113
+ const first = body.split("\n")[0] ?? "";
114
+ const h = /^#{1,6} (.+)$/.exec(first);
115
+ const bold = /^\*\*(.+)\*\*$/.exec(first);
116
+ if (h || bold) { title = (h ?? bold)[1].trim(); body = body.split("\n").slice(1).join("\n").trim(); }
117
+ else if (docTitle) title = inline(docTitle[1]).replace(/\s+/g, " ").trim();
118
+ if (title && body.split("\n")[0].trim() === title) body = body.split("\n").slice(1).join("\n").trim();
119
+ }
120
+ const empty = !title && !body;
121
+ const front = labels.length ? `---\ntags: [${labels.map(yamlString).join(", ")}]\n---\n` : "";
122
+ const markdown = empty ? "" : `${front}${title ? `# ${title}\n` : ""}${title && body ? "\n" : ""}${body ? `${body}\n` : ""}`;
123
+ return { markdown, title: title || null, labels, empty, shape };
124
+ }
package/lib/keep-hook.mjs CHANGED
@@ -29,14 +29,23 @@
29
29
  // anything. A block stores the event index it blocked at, so the next block needs a new stretch.
30
30
  // Any error of its own exits 0 without output: a broken hook must never trap a session.
31
31
  //
32
+ // NAMED ENTRIES (HQ-2799). When the day check lets the session stop, a second check runs: a tool
33
+ // result in the transcript named existing entries (lib/touches.mjs's line, parsed by day-file.mjs's
34
+ // namedIn), and nothing in the session wrote to any of them (a write tool or an evda_* call naming the
35
+ // entry's path, or the entry's file modified since it was first named). Then it blocks once per
36
+ // session, naming them and saying that adding nothing is fine. Not when the MCP server already asked
37
+ // in a tool result of this session. Open tickets a result named (HQ-2798's line) count the same way,
38
+ // answered by a board write naming the ticket's number. The transcript is the state; the state file only remembers the
39
+ // ask.
40
+ //
32
41
  // EVDA_RECORD_FALSIFY=no-block makes decide() never block, for the tests that show the block is
33
- // what the pass depends on.
42
+ // what the pass depends on; no-entries-ask turns off the named-entries check alone.
34
43
 
35
44
  import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
36
45
  import { dirname, join } from "node:path";
37
46
  import { tmpdir } from "node:os";
38
47
  import { fileURLToPath } from "node:url";
39
- import { dayRole, findRecord, filingOf, localDate } from "./day-file.mjs";
48
+ import { dayRole, findRecord, filingOf, localDate, namedIn, ticketsNamedIn, askedIn, entriesAsk } from "./day-file.mjs";
40
49
 
41
50
  export { dayRole, findRecord };
42
51
 
@@ -85,6 +94,15 @@ export function events(transcriptText) {
85
94
  const said = userText(d);
86
95
  if (said.trim()) out.push({ kind: "owner", words: said.trim().split(/\s+/).length, at });
87
96
  const c = d.message?.content;
97
+ if (d.type === "user" && Array.isArray(c)) {
98
+ for (const x of c) {
99
+ if (x?.type !== "tool_result") continue;
100
+ const text = typeof x.content === "string" ? x.content : Array.isArray(x.content) ? x.content.filter((y) => y?.type === "text").map((y) => y.text ?? "").join("\n") : "";
101
+ const named = namedIn(text);
102
+ const tickets = ticketsNamedIn(text);
103
+ if (named.length || tickets.length || askedIn(text)) out.push({ kind: "result", named, tickets, asked: askedIn(text), at });
104
+ }
105
+ }
88
106
  if (d.type === "assistant" && Array.isArray(c)) {
89
107
  for (const x of c) if (x?.type === "tool_use") out.push({ kind: "tool", name: String(x.name ?? ""), input: JSON.stringify(x.input ?? {}), at });
90
108
  }
@@ -134,6 +152,37 @@ export function decide(evs, facts, { lastBlock = 0, fileMtime = null } = {}) {
134
152
  return { block: true, reason, ...facts_ };
135
153
  }
136
154
 
155
+ const ANY_WRITE = /(^|__)evda_(write|note|append|append_row|append_item|record_person_note|replace|create)$/;
156
+
157
+ // The named-entries decision, pure. `mtimeOf(rel)` is an entry's modification time in ms, or null.
158
+ export function decideEntries(evs, facts, { asked = false, mtimeOf = () => null } = {}) {
159
+ if (asked || falsified("no-entries-ask")) return { block: false };
160
+ const named = [];
161
+ const tickets = [];
162
+ let since = NaN;
163
+ for (const e of evs) {
164
+ if (e.kind !== "result") continue;
165
+ if (e.asked) return { block: false };
166
+ for (const n of e.named) if (!named.some((x) => x.path === n.path)) named.push(n);
167
+ for (const k of e.tickets ?? []) if (!tickets.includes(k)) tickets.push(k);
168
+ if (e.named.length && !Number.isFinite(since)) since = e.at;
169
+ }
170
+ if (!named.length && !tickets.length) return { block: false };
171
+ const wrote = (rel) => evs.some((e) => {
172
+ if (e.kind !== "tool" || !names(e.input, rel, join(facts.root, rel))) return false;
173
+ return WRITE_TOOLS.has(e.name) || ANY_WRITE.test(e.name) || (e.name === "Bash" && BASH_WRITE.test(e.input));
174
+ }) || (Number.isFinite(since) && (mtimeOf(rel) ?? -Infinity) >= since);
175
+ // A ticket is answered by any board write naming its number: a harlequin tool other than a read,
176
+ // or the board CLI's update, done, handoff or schedule.
177
+ const updated = (id) => {
178
+ const n = id.slice(3);
179
+ const hit = new RegExp(`(HQ-${n}\\b|"number":"?${n}\\b|--number[ =]${n}\\b)`);
180
+ return evs.some((e) => e.kind === "tool" && hit.test(e.input) && ((/harlequin/i.test(e.name) && !/_(show|list|status)$/.test(e.name)) || (e.name === "Bash" && /\bboard\b.*\b(update|done|handoff|schedule)\b/.test(e.input))));
181
+ };
182
+ if (named.some((n) => wrote(n.path)) || tickets.some(updated)) return { block: false };
183
+ return { block: true, reason: `evda: ${entriesAsk(named.map((n) => n.path), "a dated section at the end, by Edit or evda_append; a person's page with evda_record_person_note", tickets)}` };
184
+ }
185
+
137
186
  function stateFile(sid) {
138
187
  const dir = process.env.EVDA_KEEP_STATE_DIR || join(tmpdir(), "evda-keep-record");
139
188
  mkdirSync(dir, { recursive: true });
@@ -154,14 +203,18 @@ export function run(payload, { now = new Date() } = {}) {
154
203
  if (!facts || facts.filing === "asked") return "";
155
204
  const evs = events(readFileSync(tpath, "utf8"));
156
205
  const sf = stateFile(payload.session_id ?? "unknown");
157
- let lastBlock = 0;
158
- try { lastBlock = Number(JSON.parse(readFileSync(sf, "utf8")).at) || 0; } catch { /* first stop of the session */ }
159
- if (lastBlock > evs.length) lastBlock = 0; // a different transcript under the same id
206
+ let state = {};
207
+ try { state = JSON.parse(readFileSync(sf, "utf8")) ?? {}; } catch { /* first stop of the session */ }
208
+ let lastBlock = Number(state.at) || 0;
209
+ if (lastBlock > evs.length) { lastBlock = 0; state = {}; } // a different transcript under the same id
160
210
  const fileMtime = existsSync(facts.fileAbs) ? statSync(facts.fileAbs).mtimeMs : null;
161
211
  const d = decide(evs, facts, { lastBlock, fileMtime });
162
- if (!d.block) return "";
163
- writeFileSync(sf, JSON.stringify({ at: d.at, when: now.toISOString() }) + "\n");
164
- return JSON.stringify({ decision: "block", reason: d.reason });
212
+ const mtimeOf = (rel) => { try { return statSync(join(root, rel)).mtimeMs; } catch { return null; } };
213
+ const e = d.block ? null : decideEntries(evs, facts, { asked: Boolean(state.entries), mtimeOf });
214
+ if (!d.block && !e.block) return "";
215
+ const next = d.block ? { ...state, at: d.at, when: now.toISOString() } : { ...state, at: lastBlock, entries: now.toISOString() };
216
+ writeFileSync(sf, JSON.stringify(next) + "\n");
217
+ return JSON.stringify({ decision: "block", reason: d.block ? d.reason : e.reason });
165
218
  } catch {
166
219
  return "";
167
220
  }
package/lib/keychain.mjs CHANGED
@@ -15,8 +15,16 @@
15
15
  // EVDA_KEYCHAIN_FALSIFY=<mode,...>, for the tests:
16
16
  // no-keychain keychainAvailable() always says no, so a credential file is never redacted
17
17
  // write-fails every "security add-generic-password" fails, so the value stays in the file
18
+ //
19
+ // HQ-2051: under node's test runner, nothing here may reach the default (login) keychain. A test run
20
+ // that names no keychain, or names one that does not exist (`security` then falls back to the
21
+ // default instead of failing), throws before `security` runs. On Hestia's CI runner the default
22
+ // keychain does not exist under the throwaway HOME, so such a call raised a "Keychain Not Found"
23
+ // dialog that sat on the screen for hours and failed every Mac journey. A test that truly means the
24
+ // login keychain sets EVDA_KEYCHAIN_ALLOW_DEFAULT=1; none does.
18
25
 
19
26
  import { execFileSync } from "node:child_process";
27
+ import { existsSync } from "node:fs";
20
28
  import { CRED_LINE } from "./gather.mjs";
21
29
  import { secretShape } from "./references.mjs";
22
30
 
@@ -40,17 +48,33 @@ export function keychainAvailable() {
40
48
  }
41
49
  }
42
50
 
51
+ // True inside a `node --test` run: its children carry NODE_TEST_CONTEXT, and so does every CLI a
52
+ // test spawns with the inherited env; --test-isolation=none runs in-process with --test in execArgv.
53
+ export function underTestRunner(env = process.env, execArgv = process.execArgv) {
54
+ return Boolean(env.NODE_TEST_CONTEXT) || execArgv.some((a) => a === "--test" || a.startsWith("--test-"));
55
+ }
56
+
57
+ // Throws when a test run would reach the default keychain. Every Keychain call below goes through it.
58
+ export function assertKeychainAllowed(keychainPath, env = process.env) {
59
+ if (!underTestRunner(env) || env.EVDA_KEYCHAIN_ALLOW_DEFAULT === "1") return;
60
+ if (!keychainPath) throw new Error("evda refuses the default keychain under a test run: pass a throwaway keychainPath or set EVDA_KEYCHAIN_FALSIFY (HQ-2051)");
61
+ if (!existsSync(keychainPath)) throw new Error(`evda refuses a keychain that does not exist under a test run (${keychainPath}): \`security\` would fall back to the default keychain (HQ-2051)`);
62
+ }
63
+
43
64
  const keychainArgs = (keychainPath) => (keychainPath ? [keychainPath] : []);
44
65
 
45
66
  // `security add-generic-password`, overwriting an item already at this account (a second gather of
46
67
  // the same file lands on the same value). Throws on failure; the caller decides what that means.
47
68
  export function addSecret({ account, value, keychainPath = null } = {}) {
48
69
  if (falsified("write-fails")) throw new Error("EVDA_KEYCHAIN_FALSIFY=write-fails");
70
+ assertKeychainAllowed(keychainPath);
49
71
  execFileSync("security", ["add-generic-password", "-a", account, "-s", SERVICE, "-w", value, "-U", ...keychainArgs(keychainPath)], { stdio: ["ignore", "ignore", "pipe"] });
50
72
  }
51
73
 
52
- // Never throws: revert removes what it can and says what it removed.
74
+ // Never throws on a missing item: revert removes what it can and says what it removed. Under a test
75
+ // run it does throw when it would reach the default keychain (HQ-2051).
53
76
  export function removeSecret({ account, keychainPath = null } = {}) {
77
+ assertKeychainAllowed(keychainPath);
54
78
  try {
55
79
  execFileSync("security", ["delete-generic-password", "-a", account, "-s", SERVICE, ...keychainArgs(keychainPath)], { stdio: ["ignore", "ignore", "ignore"] });
56
80
  return true;
@@ -61,6 +85,7 @@ export function removeSecret({ account, keychainPath = null } = {}) {
61
85
 
62
86
  // The value at an account, or throws (no item, or the Keychain refused to read it).
63
87
  export function readSecret({ account, keychainPath = null } = {}) {
88
+ assertKeychainAllowed(keychainPath);
64
89
  return execFileSync("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w", ...keychainArgs(keychainPath)], { stdio: ["ignore", "pipe", "ignore"] }).toString("utf8").replace(/\n$/, "");
65
90
  }
66
91
 
@@ -70,12 +95,17 @@ export function readSecret({ account, keychainPath = null } = {}) {
70
95
  // label of its own, so it is numbered "secret", "secret 2", ... in the order it appears. A labeled
71
96
  // line whose value is empty (the code sits on the next line instead) is left alone: multi-line values
72
97
  // are out of this first version's scope.
98
+ // A line already holding a pointer is not a value, so a file sealed once has nothing left to move
99
+ // (HQ-2622: the migration's second run is a no-op because of this). Two lines with the same label
100
+ // ("password" twice, or a "secret:" line beside a bare token) get "<label> 2", "<label> 3", ... so
101
+ // each lands on its own Keychain account and neither overwrites the other.
73
102
  export function extractSecrets(text) {
74
103
  const lines = text.split("\n");
75
104
  const out = [];
76
105
  let bare = 0;
77
106
  for (let i = 0; i < lines.length; i++) {
78
107
  const line = lines[i];
108
+ if (POINTER.test(line)) continue;
79
109
  const m = CRED_LINE.exec(line);
80
110
  if (m) {
81
111
  const label = (m[1] ?? m[2]).toLowerCase().replace(/[\s_-]+/g, " ");
@@ -89,6 +119,13 @@ export function extractSecrets(text) {
89
119
  out.push({ index: i, label: bare === 1 ? "secret" : `secret ${bare}`, prefix: "", value: line, whole: true });
90
120
  }
91
121
  }
122
+ const used = new Set();
123
+ for (const s of out) {
124
+ let label = s.label;
125
+ for (let n = 2; used.has(label); n++) label = `${s.label} ${n}`;
126
+ used.add(label);
127
+ s.label = label;
128
+ }
92
129
  return out;
93
130
  }
94
131
 
package/lib/manifest.mjs CHANGED
@@ -18,7 +18,9 @@ export const KNOWN_LAYERS = ["record", "ledger", "circles"];
18
18
  // lib/day-file.mjs filingOf reads it for every agent and hook.
19
19
  // `every_chat` (HQ-2205) is, per chat app, whether the owner put the every-chat line in that app's
20
20
  // settings ("set") or does not want it ("declined"); lib/every-chat.mjs reads it, evda_every_chat writes it.
21
- export const KNOWN_KEYS = ["evda", "id", "name", "owner", "created", "layers", "mapping", "subrecords", "read_by", "filing", "every_chat", "sync"];
21
+ // `corrections` (HQ-2325) is the file a correction is appended to, when the owner keeps them in
22
+ // their own file rather than the operating layer's "What I have been taught"; lib/corrections.mjs reads it.
23
+ export const KNOWN_KEYS = ["evda", "id", "name", "owner", "created", "layers", "mapping", "subrecords", "read_by", "filing", "every_chat", "sync", "corrections"];
22
24
 
23
25
  // Whether a record's circles stop at its sub-records (HQ-1371). A sub-record is a directory below
24
26
  // the root holding its own evda.json. "bounded": the parent's circles, coverage, scan and release