create-evda 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -1
- package/README.md +20 -7
- package/bin/evda.mjs +277 -7
- package/create-evda.mjs +17 -4
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +29 -2
- package/lib/connect.mjs +9 -2
- 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 +173 -15
- package/lib/day-writer.mjs +110 -0
- package/lib/doctor.mjs +45 -3
- package/lib/every-chat.mjs +128 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +176 -26
- package/lib/gather.mjs +119 -17
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +181 -0
- package/lib/manifest.mjs +5 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +42 -4
- package/lib/mcp.mjs +1198 -76
- package/lib/measurement-log.mjs +249 -0
- package/lib/messages.mjs +5 -2
- package/lib/migrate-people.mjs +54 -20
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- 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/qr.mjs +317 -0
- package/lib/record.mjs +74 -0
- package/lib/routines.mjs +54 -4
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +1040 -31
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/watch.mjs +160 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +81 -10
- package/templates/evda-digest.md +5 -1
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" };
|
|
@@ -75,7 +87,9 @@ export const PEOPLE_MIN_FILES = 3;
|
|
|
75
87
|
const CRED_QUALIFIER = String.raw`(?:(?:wi-?fi|home|network|router|guest|email|e-mail|bank|account|admin|root|master|login|app|door|alarm|phone|sim|card|atm|debit|garage)\s+)*`;
|
|
76
88
|
const CRED_SHORT = String.raw`pass(?:word|wd|code|phrase)?|pwd|pw|pin(?:\s*code)?|api[\s_-]*key|access[\s_-]*key|secret(?:[\s_-]*key)?|(?:auth[\s_-]*|access[\s_-]*|api[\s_-]*)?token|private[\s_-]*key|login`;
|
|
77
89
|
const CRED_LONG = String.raw`(?:recovery|backup|2fa|mfa|one[\s-]*time)[\s_-]*(?:codes?|keys?|phrase)|seed[\s_-]*(?:phrase|words)|mnemonic(?:[\s_-]*phrase)?|secret[\s_-]*phrase`;
|
|
78
|
-
|
|
90
|
+
// Exported for lib/keychain.mjs (HQ-1916): the same line shape that types a note as holding a
|
|
91
|
+
// credential is used again at apply, to find each value on its own line and move it off disk.
|
|
92
|
+
export const CRED_LINE = new RegExp(String.raw`^\s*(?:[-*+•>]\s+|\d+[.)]\s+)?[*_\x60]*${CRED_QUALIFIER}(?:(${CRED_LONG})|(${CRED_SHORT}))[*_\x60]*\s*(?:[:=]|\s[-\u2013]\s)[*_\x60]*\s*(.*)$`, "i");
|
|
79
93
|
export function credentialLine(lines) {
|
|
80
94
|
const all = Array.isArray(lines) ? lines : String(lines ?? "").split("\n");
|
|
81
95
|
for (let n = 0; n < all.length; n++) {
|
|
@@ -100,12 +114,37 @@ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
|
100
114
|
|
|
101
115
|
const SKIP_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "node_modules", ".git", ".obsidian", ".trash", ".Trash", "__MACOSX"]);
|
|
102
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
|
+
}
|
|
103
131
|
const ARCHIVE_EXT = new Set([".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"]);
|
|
104
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"]);
|
|
105
133
|
const TEXT_MAX = 2 * 1024 * 1024;
|
|
106
134
|
const NOTE_SIZED = 20000; // characters: longer text is read for therapy words only in its name and heading
|
|
107
135
|
// Folder names that hold things without saying what they are about, so they are not a subject.
|
|
108
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
|
+
}
|
|
109
148
|
const COURSE = /\b[A-Z]{2,4}\s?\d{3}[A-Z]?\b/;
|
|
110
149
|
const COURSE_WORD = /\b(syllabus|lecture|homework|problem set|midterm)\b/i;
|
|
111
150
|
const THERAPY = /\b(therapy|therapist|counsell?or|counselling|counseling|psychiatrist|psychologist|CBT|EMDR)\b/i;
|
|
@@ -241,6 +280,8 @@ function walk(root) {
|
|
|
241
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; }
|
|
242
281
|
const s = lstatSync(abs);
|
|
243
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; }
|
|
244
285
|
if (s.isDirectory()) { go(abs); continue; }
|
|
245
286
|
if (!s.isFile()) { skipped.push({ rel, why: "not a regular file" }); continue; }
|
|
246
287
|
if (name.startsWith(".")) { skipped.push({ rel, why: "a hidden file" }); continue; }
|
|
@@ -257,7 +298,7 @@ function subjectOf(rel, projectRoot) {
|
|
|
257
298
|
const parts = rel.split("/").slice(0, -1);
|
|
258
299
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
259
300
|
const p = parts[i];
|
|
260
|
-
if (
|
|
301
|
+
if (isContainer(p)) continue;
|
|
261
302
|
if (/^(people|contacts|meetings|projects|drafts|writing|essays|clippings|reading|articles|bookmarks|logs|calendar|photos|attachments)$/i.test(p)) continue;
|
|
262
303
|
return p;
|
|
263
304
|
}
|
|
@@ -347,7 +388,13 @@ export function scan(sources, { now = new Date() } = {}) {
|
|
|
347
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));
|
|
348
389
|
out.sources.push({ id: `s${si}`, path: root, files: w.files.length, skipped: w.skipped.length, apps: w.apps });
|
|
349
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 });
|
|
350
396
|
for (const f of w.files) {
|
|
397
|
+
if (sidecarAt.has(f.rel)) continue;
|
|
351
398
|
const buf = readFileSync(f.abs);
|
|
352
399
|
const ext = extname(f.rel).toLowerCase();
|
|
353
400
|
const chat = ext === ".json" ? readChatExport(buf) : null;
|
|
@@ -362,12 +409,22 @@ export function scan(sources, { now = new Date() } = {}) {
|
|
|
362
409
|
// section is cut before any file is read for its heading, people or words. Its bytes and hash
|
|
363
410
|
// are unchanged, so apply copies it with its flag.
|
|
364
411
|
const seal = sealedOf(ext, buf);
|
|
365
|
-
|
|
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;
|
|
366
420
|
if (seal.sealed) facts.sealed = true;
|
|
367
421
|
if (seal.noted) facts.noted = true;
|
|
368
422
|
const item = { id: `s${si}:${f.rel}`, source: `s${si}`, path: f.rel, size: f.size, sha256: sha256(buf), ...facts };
|
|
369
423
|
if (projectRoot) item.project = projectRoot;
|
|
424
|
+
if (side.folded.has(f.rel)) item.takeout = side.folded.get(f.rel);
|
|
370
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];
|
|
371
428
|
if (!item.refused && !item.credential && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
|
|
372
429
|
out.items.push(item);
|
|
373
430
|
}
|
|
@@ -415,9 +472,11 @@ function findPeople(items) {
|
|
|
415
472
|
for (const p of byName.values()) {
|
|
416
473
|
const first = p.name.split(" ")[0];
|
|
417
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();
|
|
418
477
|
for (const i of items) {
|
|
419
|
-
if (i.text === undefined || p.pages.includes(i.id)) continue;
|
|
420
|
-
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); }
|
|
421
480
|
}
|
|
422
481
|
}
|
|
423
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));
|
|
@@ -482,8 +541,12 @@ function homes(preset) {
|
|
|
482
541
|
};
|
|
483
542
|
}
|
|
484
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
|
+
|
|
485
547
|
// A placed text item's name: `.txt` becomes `.md`, bytes unchanged. Every other name is kept.
|
|
486
|
-
|
|
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);
|
|
487
550
|
|
|
488
551
|
// The placement of every item, given the facts and the answers. Pure.
|
|
489
552
|
export function place(p) {
|
|
@@ -524,7 +587,7 @@ export function place(p) {
|
|
|
524
587
|
// Only markdown carries `sealed: true`, so a credential in any other format is never copied.
|
|
525
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.` };
|
|
526
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` };
|
|
527
|
-
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" };
|
|
528
591
|
else if (i.cards) pl = { status: "reference", to: null, why: "held by Contacts; a person page keeps a contacts: reference, never the card" };
|
|
529
592
|
else if (i.kind === "project") {
|
|
530
593
|
// Status is a field, never a move: a finished project stays in its collection and says so.
|
|
@@ -539,18 +602,44 @@ export function place(p) {
|
|
|
539
602
|
const course = h.courses && i.subject && COURSE.test(i.subject);
|
|
540
603
|
if (course) pl = { status: "placed", to: `${h.courses}/${i.subject}/${file}` };
|
|
541
604
|
else if (i.subject) pl = { status: "placed", to: `${folderFor("areas", "record")}/${i.subject}/${file}` };
|
|
542
|
-
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" };
|
|
543
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" };
|
|
544
607
|
else pl = { status: "placed", to: `${folderFor(i.kind, FOLDER_TYPE_FOR[i.kind])}/${file}` };
|
|
545
|
-
if (pl.to) pl.to = claim(pl.to, i.id);
|
|
546
|
-
if (pl.to) pl.role = roleOf(pl.to);
|
|
547
608
|
if (i.refs && pl.status === "placed") pl.refs = i.refs;
|
|
548
609
|
if (i.kind === "person" && !i.cards) {
|
|
549
610
|
const who = p.people.find((x) => x.page === i.id);
|
|
550
611
|
if (who?.ref) pl.refs = [who.ref];
|
|
551
612
|
}
|
|
613
|
+
if (i.html && pl.to && pl.status === "placed") pl.convert = "html-to-markdown";
|
|
552
614
|
placements.push({ item: i.id, ...pl });
|
|
553
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
|
+
}
|
|
554
643
|
// Person pages the owner said yes to, made new, holding a Contacts reference when a card names them.
|
|
555
644
|
for (const who of p.people) {
|
|
556
645
|
if (ans[`person:${slugId(who.name)}`] !== "yes" || who.page) continue;
|
|
@@ -620,7 +709,7 @@ export function questions(p) {
|
|
|
620
709
|
// ---- the proposal ----
|
|
621
710
|
|
|
622
711
|
function counts(p) {
|
|
623
|
-
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 };
|
|
624
713
|
for (const pl of p.placements) c[pl.status] += 1;
|
|
625
714
|
c.chats = (p.chats ?? []).length;
|
|
626
715
|
c.chatMessages = (p.chats ?? []).reduce((n, x) => n + x.messages, 0);
|
|
@@ -780,11 +869,12 @@ export function checkedLines(p) {
|
|
|
780
869
|
const chats = p.chats ?? [];
|
|
781
870
|
return [
|
|
782
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` : ""}`] : []),
|
|
783
|
-
`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)` : ""}`,
|
|
784
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",
|
|
785
874
|
];
|
|
786
875
|
}
|
|
787
876
|
|
|
877
|
+
const SKIPPED_SHOWN = 20;
|
|
788
878
|
export function summaryText(p, { detail = false } = {}) {
|
|
789
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.`, ""];
|
|
790
880
|
lines.push(`Preset: ${p.preset.title} (${p.preset.id}), because ${p.preset.why.join("; ")}.`, "", "Folders:");
|
|
@@ -792,12 +882,24 @@ export function summaryText(p, { detail = false } = {}) {
|
|
|
792
882
|
const byStatus = (s) => p.placements.filter((x) => x.status === s);
|
|
793
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}`); }
|
|
794
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(", ")})` : ""}`); }
|
|
795
|
-
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}`); }
|
|
796
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
|
+
}
|
|
797
895
|
if (byStatus("present").length) lines.push("", `Already in the record, left where they are: ${byStatus("present").length} item(s).`);
|
|
798
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.`);
|
|
799
897
|
if (p.circles.length) { lines.push("", "Circles:"); for (const c of p.circles) lines.push(`- ${c.name} over ${c.covers}/, ${c.status}`); }
|
|
800
|
-
|
|
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)" : ""}`); }
|
|
801
903
|
const open = p.questions.filter((q) => q.answer === undefined);
|
|
802
904
|
lines.push("", open.length ? `${open.length} question(s) for the owner:` : "No open questions.");
|
|
803
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
|
}
|