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/CHANGELOG.md +72 -0
- package/README.md +12 -5
- package/bin/evda.mjs +122 -5
- package/create-evda.mjs +11 -3
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/clients.mjs +11 -0
- package/lib/connect.mjs +1 -1
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +121 -12
- package/lib/day-writer.mjs +8 -1
- package/lib/doctor.mjs +32 -3
- package/lib/every-chat.mjs +7 -11
- package/lib/gather-apply.mjs +133 -24
- package/lib/gather.mjs +116 -16
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +38 -1
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-http.mjs +19 -1
- package/lib/mcp.mjs +358 -56
- package/lib/measurement-log.mjs +3 -0
- package/lib/migrate-people.mjs +54 -20
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/record.mjs +54 -0
- package/lib/routines.mjs +13 -2
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +669 -59
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/templates/AGENTS.md +56 -6
- package/templates/evda-digest.md +2 -0
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`.
|
|
32
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 "<b>"
|
|
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:") ? "" : ``))
|
|
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
|
|
158
|
-
try {
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|