create-evda 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +12 -5
  3. package/bin/evda.mjs +129 -6
  4. package/create-evda.mjs +14 -11
  5. package/lib/adopt.mjs +516 -0
  6. package/lib/agents.mjs +1 -1
  7. package/lib/attested-host.mjs +287 -0
  8. package/lib/attested-wrap.mjs +147 -0
  9. package/lib/checkout-freshness.mjs +88 -0
  10. package/lib/circles.mjs +4 -2
  11. package/lib/clients.mjs +11 -0
  12. package/lib/connect.mjs +1 -1
  13. package/lib/connecting-apps.mjs +56 -0
  14. package/lib/connector-tools.mjs +85 -0
  15. package/lib/corrections.mjs +34 -0
  16. package/lib/credentials-migrate.mjs +120 -0
  17. package/lib/day-file.mjs +121 -12
  18. package/lib/day-writer.mjs +39 -5
  19. package/lib/doctor.mjs +60 -5
  20. package/lib/every-chat.mjs +7 -11
  21. package/lib/folders.mjs +2 -1
  22. package/lib/gather-apply.mjs +133 -24
  23. package/lib/gather.mjs +120 -19
  24. package/lib/html-notes.mjs +124 -0
  25. package/lib/keep-hook.mjs +84 -12
  26. package/lib/keychain.mjs +38 -1
  27. package/lib/manifest.mjs +3 -1
  28. package/lib/mcp-http.mjs +19 -1
  29. package/lib/mcp.mjs +358 -56
  30. package/lib/measurement-log.mjs +3 -0
  31. package/lib/migrate-people.mjs +54 -20
  32. package/lib/person-folders.mjs +90 -0
  33. package/lib/plugin-hook.mjs +109 -0
  34. package/lib/plugin.mjs +208 -0
  35. package/lib/presets.mjs +28 -6
  36. package/lib/prompt-hook.mjs +115 -4
  37. package/lib/publish.mjs +55 -10
  38. package/lib/readme.mjs +145 -0
  39. package/lib/record-walk.mjs +9 -0
  40. package/lib/record.mjs +54 -0
  41. package/lib/routines.mjs +13 -2
  42. package/lib/takeout.mjs +169 -0
  43. package/lib/tdx-attestation.mjs +432 -0
  44. package/lib/tdx-trust.json +10 -0
  45. package/lib/tools.mjs +669 -59
  46. package/lib/touches.mjs +427 -0
  47. package/lib/update-check.mjs +30 -0
  48. package/lib/yaml.mjs +8 -4
  49. package/package.json +2 -1
  50. package/templates/AGENTS.md +56 -6
  51. package/templates/evda-digest.md +2 -0
package/lib/tools.mjs CHANGED
@@ -6,7 +6,9 @@
6
6
  //
7
7
  // Three rules hold for every write, and each has a test in test/tools.test.mjs:
8
8
  // 1. Append only. A write adds bytes to the end of a file or creates a new one. No tool takes a
9
- // path to write to, and none replaces what is there.
9
+ // path to write to, and none replaces what is there. The one exception is evda_replace
10
+ // (HQ-2066), the owner's own edit from their phone: it replaces a note's whole text only when
11
+ // the note is byte for byte what they read, and is offered to no chat app.
10
12
  // 2. Nothing secret-shaped is written. The words are run through the same check that guards a
11
13
  // reference (lib/references.mjs secretShape) and a hit refuses the whole write.
12
14
  // 3. Every write says where it landed, as a path relative to the record.
@@ -25,23 +27,28 @@
25
27
  // making the circle, and granting anyone anything, stays the owner's explicit yes through
26
28
  // `evda grant` or their own hands.
27
29
 
28
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
30
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from "node:fs";
29
31
  import { dirname, join, relative, resolve, sep } from "node:path";
32
+ import { spawnSync } from "node:child_process";
33
+ import { createHash } from "node:crypto";
34
+ import { hostname } from "node:os";
30
35
  import { resolveRole, rolesForLayer, agentsFile } from "./roles.mjs";
31
- import { mdFiles, fileDate, headings } from "./record.mjs";
36
+ import { mdFiles, fileDate, headings, sectionDigest, headingDate } from "./record.mjs";
32
37
  import { splitFence, FENCE_OPEN } from "./agents.mjs";
33
38
  import { datalessAmong } from "./sync-rules.mjs";
34
- import { readCircles, isSealed, frontmatterOf } from "./circles.mjs";
39
+ import { readCircles, isSealed, frontmatterOf, markdownFiles } from "./circles.mjs";
35
40
  import { secretShape } from "./references.mjs";
36
41
  import { doctor } from "./doctor.mjs";
37
42
  import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY, isSealedFile, isNotedFile } from "./readers.mjs";
38
43
  import { EvdaError } from "./errors.mjs";
39
- import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
40
- import { writeMessage, unseenReplies } from "./messages.mjs";
44
+ import { dayRole, appendToDay, setPointerLine, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
45
+ import { writeMessage, unseenReplies, agentName } from "./messages.mjs";
41
46
  import { acknowledge as writeAcknowledgement, readAcknowledged, acknowledgementsFor } from "./acknowledge.mjs";
42
47
  import { openEvda, holderOf } from "./manifest.mjs";
43
48
  import { toYaml } from "./yaml.mjs";
44
49
  import { readEntry } from "./kinds.mjs";
50
+ import { correctionsTarget } from "./corrections.mjs";
51
+ import { slug } from "./migrate-people.mjs";
45
52
 
46
53
  export { localDate, localTime, CONTEXT_LABEL };
47
54
 
@@ -158,13 +165,13 @@ export function coreBeside(evda, agents, agentsText) {
158
165
  // 2. The last `days` days of the record, newest first, capped in size. A file is in the window by
159
166
  // its date (filename, then a date or created key), the same rule `evda moment` uses.
160
167
  //
161
- // Returns each file's headings, not the whole file (HQ-2163) and not its first line either
162
- // (HQ-2227): this tool is for triage across many files, and pulling every matching file whole into
163
- // a hosted chat put sensitive captures into it unread. A first line did the same at a smaller size:
164
- // a capture is often one line long, so on 2026-10-05 a claude.ai question about deadlines got the
165
- // text of a relationship capture and an edibles night through it. A heading is what the owner or
166
- // the filing tool chose to title an entry; the words under it are read by path, when the question
167
- // needs them. The whole text of one file is `evda_read`'s job, by path. `full: true` is
168
+ // Returns each file's headings and, per section, its context line and the opening of the owner's
169
+ // words (HQ-2324), not the whole file (HQ-2163). HQ-2227 had cut it to headings only, after a
170
+ // claude.ai question about deadlines got a relationship capture through a first line; and then a
171
+ // chat started knowing only timestamps (most day-file headings are times), so on 2026-10-07 it
172
+ // missed who a question was about. His call that day: approving the connector with training off
173
+ // settles privacy, so a chat reads the record as the terminal does. Noted and sealed entries still
174
+ // never appear (mayRead and aiView below). The whole text of one file is `evda_read`'s job, by path. `full: true` is
168
175
  // not part of the tool's args (onlyKeys refuses it there); it is an internal option for callers in
169
176
  // this process, such as the evda://today resource below, that still need one file's whole text.
170
177
  //
@@ -196,7 +203,7 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
196
203
  const acknowledged = acknowledgementsFor(path, marks);
197
204
  files.push(full
198
205
  ? { date, path, text: shown.text, acknowledged }
199
- : { date, path, headings: headings(shown.text), acknowledged });
206
+ : { date, path, headings: headings(shown.text), sections: sectionDigest(shown.text), acknowledged });
200
207
  }
201
208
  files.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : b.date.localeCompare(a.date)));
202
209
  let used = 0;
@@ -219,6 +226,40 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
219
226
  };
220
227
  }
221
228
 
229
+ // 2'. The people whose pages gained a dated section in the last `days` days (HQ-2324), newest
230
+ // first: the page's name and path, and those sections with their context and opening. A section is
231
+ // dated by the date its heading names, the way the owner's people pages are kept ("## 2026-10-02,
232
+ // morning: ..."). Pages a reader may not read, and sealed sections, never appear.
233
+ export function recentPeople(evda, { days = 7, now = new Date(), caller = callerFrom(), perPerson = 6 } = {}) {
234
+ const r = resolveRole(evda, "record/people");
235
+ if (!r.mapped || !r.exists || !r.isDir) return { from: null, to: null, people: [] };
236
+ const start = new Date(now);
237
+ start.setDate(start.getDate() - (days - 1));
238
+ const from = localDate(start);
239
+ const to = localDate(now);
240
+ const ctx = readContext(evda);
241
+ const people = [];
242
+ for (const abs of mdFiles(r.abs)) {
243
+ if (!mayRead(ctx, abs, caller).ok) continue;
244
+ const shown = aiView(readFileSync(abs, "utf8"));
245
+ const grew = sectionDigest(shown.text)
246
+ .map((s) => ({ ...s, date: headingDate(s.heading) }))
247
+ .filter((s) => s.date && s.date >= from && s.date <= to)
248
+ .sort((a, b) => b.date.localeCompare(a.date));
249
+ if (!grew.length) continue;
250
+ const name = /^name:\s*["']?(.+?)["']?\s*$/m.exec(frontmatterText(shown.text))?.[1] ?? abs.split(sep).pop().replace(/\.md$/, "");
251
+ people.push({ name, path: rel(evda, abs), latest: grew[0].date, sections: grew.slice(0, perPerson), more: Math.max(0, grew.length - perPerson) });
252
+ }
253
+ people.sort((a, b) => (a.latest === b.latest ? a.name.localeCompare(b.name) : b.latest.localeCompare(a.latest)));
254
+ return { from, to, people };
255
+ }
256
+
257
+ function frontmatterText(text) {
258
+ if (!text.startsWith("---")) return "";
259
+ const end = text.indexOf("\n---", 3);
260
+ return end === -1 ? "" : text.slice(3, end);
261
+ }
262
+
222
263
  // 2a. Record that this reader acted on one weighty entry, so no other reader acts on it again
223
264
  // (HQ-2060). The first reader to reach it writes the mark; a later one gets first: false and nothing
224
265
  // is written. Weight is the reader's judgment; this only remembers that one of them already did it.
@@ -282,10 +323,52 @@ export function write(evda, args = {}, { now = new Date(), session = null, toolN
282
323
  }
283
324
  const abs = join(evda.dir, todayPath(evda, { now }));
284
325
  mkdirSync(dirname(abs), { recursive: true });
326
+ // The owner's day only: evda_write, and the tools that file the owner's message along with them.
327
+ // The agent log is the agent's own record, which nothing drains from an outbox.
328
+ if (toolName !== "evda_write_agent_log") {
329
+ const held = dayWriteHeld(abs);
330
+ if (held !== null) {
331
+ const q = queueToOutbox(abs, { now, words, context });
332
+ return { path: rel(evda, q), bytes: Buffer.byteLength(words), queued: true, held };
333
+ }
334
+ }
285
335
  const w = appendToDay(abs, { now, words, context, session });
286
336
  return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
287
337
  }
288
338
 
339
+ // EVDA_DAY_WRITE_GATE (HQ-2596): a command the writing machine asks before an evda_write touches
340
+ // the owner's day, run as `<gate> --file <abs>`; exit 0 writes, anything else holds. Hestia sets it
341
+ // to its own check (not within minutes of a boot, iCloud says its copy is current), because on
342
+ // 2026-10-14 it came back from an outage and appended to its pre-outage copy within two minutes,
343
+ // and the sync kept that copy over sections written meanwhile. Unset, every write goes ahead.
344
+ // Returns null to write, or the gate's one-line reason to hold. A gate that cannot run holds:
345
+ // a queued write waits, a write onto a stale copy is lost.
346
+ export const DAY_WRITE_GATE_ENV = "EVDA_DAY_WRITE_GATE";
347
+ function dayWriteHeld(abs) {
348
+ const gate = (process.env[DAY_WRITE_GATE_ENV] ?? "").trim();
349
+ if (!gate) return null;
350
+ const r = spawnSync(gate, ["--file", abs], { encoding: "utf8", timeout: 60_000 });
351
+ if (r.status === 0) return null;
352
+ const said = `${r.stdout ?? ""}\n${r.stderr ?? ""}`.trim().split("\n").filter(Boolean).pop();
353
+ return said || (r.error ? `the gate did not run (${r.error.code ?? r.error.message})` : `the gate exited ${r.status ?? r.signal}`);
354
+ }
355
+
356
+ // Queue held words where the writer files them later (HQ-2596): one new JSON file per write in the
357
+ // daily folder's .outbox/, {tool, words, context, at, host}, named `<UTC timestamp>-<host>-evda_write.json`
358
+ // (the shape the day-writer guard's refusal names, lib/day-writer.mjs OUTBOX_DIR). One file per write,
359
+ // so no two writers ever share one and no sync can drop either. Returns the file's absolute path.
360
+ function queueToOutbox(abs, { now, words, context }) {
361
+ const box = join(dirname(abs), ".outbox");
362
+ mkdirSync(box, { recursive: true });
363
+ const at = now.toISOString().replace(/\.\d{3}Z$/, "Z");
364
+ const host = hostname().split(".")[0] || "host";
365
+ const stamp = at.replace(/[-:]/g, "").replace(/^(\d{8})T/, (_, d) => `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6)}T`);
366
+ let file = join(box, `${stamp}-${host}-evda_write.json`);
367
+ for (let n = 2; existsSync(file); n++) file = join(box, `${stamp}-${host}-evda_write-${n}.json`);
368
+ writeFileSync(file, JSON.stringify({ tool: "evda_write", words: String(words), context: context ?? "", at, host }, null, 1), { flag: "wx" });
369
+ return file;
370
+ }
371
+
289
372
  // 3b. What `evda mcp`'s evda_write tool actually calls (HQ-2165). `write` above already appended
290
373
  // the words; this reads them back after a short real delay and refuses to report success if they
291
374
  // are gone, so a caller that overwrote the file in that gap is caught instead of the agent telling
@@ -303,6 +386,7 @@ const VERIFY_DELAY_MS = 400;
303
386
  const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
304
387
  export async function writeVerified(evda, args = {}, { verifyDelayMs = VERIFY_DELAY_MS, ...rest } = {}) {
305
388
  const r = write(evda, args, rest);
389
+ if (r.queued) return r; // queued, not written: nothing in the day to read back
306
390
  const abs = join(evda.dir, r.path);
307
391
  if (verifyDelayMs > 0) await sleep(verifyDelayMs);
308
392
  const expect = String(args.words).replace(/\n+$/, "");
@@ -326,8 +410,14 @@ export async function writeVerified(evda, args = {}, { verifyDelayMs = VERIFY_DE
326
410
  // replaces, exactly as the section's own instructions ask.
327
411
  const CHANGE_MAX = 400;
328
412
  const WORDS_MAX = 400;
329
- export function recordCorrection(evda, args = {}, { now = new Date() } = {}) {
330
- onlyKeys("evda_record_correction", args, ["words", "change", "supersedes"], "It appends one dated line to \"What I have been taught\" and nothing else: it takes no path, and it never edits or replaces an earlier entry.");
413
+ const TITLE_MAX = 160;
414
+ // HQ-2325: when evda.json names a "corrections" file (lib/corrections.mjs), the correction is a
415
+ // dated section at the end of that file instead: `## YYYY-MM-DD: <title>` and one paragraph, the
416
+ // owner's words quoted and then what changes, the shape the owner's own log already uses. The
417
+ // setting wins over the heading, and a setting that does not resolve refuses rather than falling
418
+ // back, so a correction never lands somewhere the owner did not choose. Append only, either way.
419
+ export function recordCorrection(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
420
+ onlyKeys("evda_record_correction", args, ["words", "change", "supersedes", "title"], "It appends one dated correction where this record keeps them and nothing else: it takes no path, and it never edits or replaces an earlier entry.");
331
421
  if (typeof args.change !== "string" || args.change.trim() === "") throw refuse('evda_record_correction needs "change": what you do differently now, one line.', "NO_CHANGE");
332
422
  const change = args.change.trim();
333
423
  if (/\n/.test(change)) throw refuse('evda_record_correction "change" is one line.', "MULTILINE");
@@ -341,26 +431,48 @@ export function recordCorrection(evda, args = {}, { now = new Date() } = {}) {
341
431
  if (words.length > WORDS_MAX) throw refuse(`evda_record_correction "words" is ${words.length} characters; at most ${WORDS_MAX}.`, "TOO_LONG");
342
432
  refuseSecrets(words, "evda_record_correction");
343
433
  }
434
+ let title = null;
435
+ if (args.title !== undefined) {
436
+ if (typeof args.title !== "string" || args.title.trim() === "") throw refuse('evda_record_correction "title" names what went wrong in a few words; leave it out rather than send it empty.', "EMPTY");
437
+ title = args.title.trim();
438
+ if (/\n/.test(title)) throw refuse('evda_record_correction "title" is one line.', "MULTILINE");
439
+ if (title.length > TITLE_MAX) throw refuse(`evda_record_correction "title" is ${title.length} characters; at most ${TITLE_MAX}.`, "TOO_LONG");
440
+ refuseSecrets(title, "evda_record_correction");
441
+ }
344
442
  let supersedes = null;
345
443
  if (args.supersedes !== undefined) {
346
444
  if (typeof args.supersedes !== "string" || !DATE.test(args.supersedes.trim())) throw refuse('evda_record_correction "supersedes" must be YYYY-MM-DD, the date of the earlier entry this one reverses.', "BAD_DATE");
347
445
  supersedes = args.supersedes.trim();
348
446
  }
447
+ const date = localDate(now);
448
+ const said = words ? `"${words}" -> ` : "";
449
+ const supersededNote = supersedes ? ` Supersedes ${supersedes}.` : "";
450
+
451
+ const target = correctionsTarget(evda);
452
+ if (target) {
453
+ if (target.problem) throw refuse(`evda.json ${target.problem.replace(/^"corrections"/, 'sets "corrections", but it')} Ask the owner where corrections go.`, "NO_TARGET");
454
+ const may = mayRead(readContext(evda), target.abs, caller);
455
+ if (!may.ok) throw refuse(`${may.why} It is not yours to add to.`, may.kind === "sealed" ? "SEALED" : may.kind === "noted" ? "NOTED" : "NOT_THIS_READER");
456
+ const raw = readFileSync(target.abs, "utf8");
457
+ const heading = `## ${date}: ${title ?? change}`;
458
+ const body = (title ? [words ? `"${words}" → ${change}` : change, supersedes ? `Supersedes ${supersedes}.` : ""] : [words ? `"${words}"` : "", supersedes ? `Supersedes ${supersedes}.` : ""]).filter(Boolean).join(" ");
459
+ const lead = raw === "" ? "" : raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
460
+ appendFileSync(target.abs, `${lead}${heading}\n${body ? `\n${body}\n` : ""}`);
461
+ return { path: target.rel, date, section: heading.slice(3) };
462
+ }
463
+
349
464
  const a = agentsFile(evda);
350
- if (!a) throw refuse('this record maps no "agents" role, so it has no AGENTS.md to write the correction into.', "NO_ROLE");
465
+ if (!a) throw refuse('this record maps no "agents" role and evda.json sets no "corrections" file, so a correction has nowhere to land.', "NO_ROLE");
351
466
  if (isSealed(a.abs)) throw refuse(`${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
352
467
  const raw = readFileSync(a.abs, "utf8");
353
468
  const parts = splitFence(raw);
354
469
  const owner = parts ? parts.after : raw;
355
470
  if (!/(^|\n)## What I have been taught(\n|$)/.test(owner)) {
356
- throw refuse(`${a.rel} has no "## What I have been taught" section outside the generated block, so a correction has nowhere to land. Add that heading first (templates/AGENTS.md has the wording); evda doctor names this too.`, "NO_SECTION");
471
+ throw refuse(`${a.rel} has no "## What I have been taught" section outside the generated block, and evda.json sets no "corrections" file, so a correction has nowhere to land. Add that heading (templates/AGENTS.md has the wording), or name the file the owner keeps corrections in as "corrections" in evda.json; evda doctor names this too.`, "NO_SECTION");
357
472
  }
358
- const date = localDate(now);
359
- const said = words ? `"${words}" -> ` : "";
360
- const supersededNote = supersedes ? ` Supersedes ${supersedes}.` : "";
361
- const line = `- ${date}: ${said}${change}${supersededNote}\n`;
362
- const sep = owner.endsWith("\n") ? "" : "\n";
363
- const newOwner = owner + sep + line;
473
+ const line = `- ${date}: ${title ? `${title}. ` : ""}${said}${change}${supersededNote}\n`;
474
+ const lineSep = owner.endsWith("\n") ? "" : "\n";
475
+ const newOwner = owner + lineSep + line;
364
476
  const newRaw = parts ? parts.before + parts.generated + newOwner : newOwner;
365
477
  writeFileSync(a.abs, newRaw);
366
478
  return { path: a.rel, date, section: "What I have been taught" };
@@ -393,10 +505,59 @@ export function hasAgentLog(evda) {
393
505
  return existsSync(join(evda.dir, AGENT_LOG_REL, "evda.json"));
394
506
  }
395
507
 
508
+ // HQ-2306: the same call keeps the one pointer line in the owner's day, under `## <Agent>`:
509
+ // Iris: <one fragment per conversation>. [[<agent log day, no .md>|Iris's log for the day]]
510
+ // the shape Hestia's journal-append.mjs writes for its own agents, so the vault's journal:check
511
+ // reads both the same. Only the writing machine may touch the owner's day (HQ-2165), and this is the
512
+ // tool a laptop session reaches it through. The agent log is already on disk when the pointer is
513
+ // written, so a failed pointer is reported in the result, never thrown: a retry would log twice.
396
514
  export function writeAgentLog(evda, args = {}, { now = new Date(), session = null } = {}) {
397
515
  const sub = agentLogRecord(evda);
398
516
  const r = write(sub, args, { now, session, toolName: "evda_write_agent_log" });
399
- return { path: `${AGENT_LOG_REL}/${r.path}`, bytes: r.bytes, section: r.section };
517
+ const path = `${AGENT_LOG_REL}/${r.path}`;
518
+ return { path, bytes: r.bytes, section: r.section, pointer: writeAgentPointer(evda, path, { now }) };
519
+ }
520
+
521
+ const POINTER_FRAGMENT_MAX = 100;
522
+ const POINTER_FRAGMENTS_MAX = 6;
523
+
524
+ // The fragments of the pointer line, built from the whole of the agent's day log every time so the
525
+ // line is a function of the log: refreshing it never drifts or duplicates. One per section (one per
526
+ // conversation): the first sentence of its first line that is not the context line.
527
+ export function agentLogFragments(logText) {
528
+ const frags = [];
529
+ for (const section of String(logText).split(/^## .*$/m).slice(1)) {
530
+ const first = section.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith(`*${CONTEXT_LABEL}`));
531
+ if (!first) continue;
532
+ const sentence = first.split(/(?<=[.!?])\s+/)[0].replace(/\.\s*$/, "").trim();
533
+ frags.push(sentence.length > POINTER_FRAGMENT_MAX ? `${sentence.slice(0, POINTER_FRAGMENT_MAX - 3).trimEnd()}…` : sentence);
534
+ }
535
+ if (frags.length > POINTER_FRAGMENTS_MAX) {
536
+ const shown = frags.slice(0, POINTER_FRAGMENTS_MAX - 1);
537
+ return [...shown, `and ${frags.length - shown.length} more`];
538
+ }
539
+ return frags.length ? frags : ["see the log"];
540
+ }
541
+
542
+ function writeAgentPointer(evda, logPath, { now }) {
543
+ const date = localDate(now);
544
+ const day = join(evda.dir, todayPath(evda, { now }));
545
+ const at = rel(evda, day);
546
+ try {
547
+ if (existsSync(day) && isSealed(day)) return { path: at, state: "sealed" };
548
+ const agent = agentName(evda);
549
+ const link = `[[${logPath.replace(/\.md$/, "")}|${agent}'s log for the day]]`;
550
+ // Only what any AI reader of the log may see is quoted: a sealed section is cut, and a sealed or
551
+ // just-noted log gives no fragment at all, so the pointer never carries held words into the day.
552
+ const logAbs = join(evda.dir, logPath);
553
+ const held = isSealed(logAbs) || isNotedFile(logAbs);
554
+ const frags = held ? ["see the log"] : agentLogFragments(aiView(readFileSync(logAbs, "utf8")).text);
555
+ const line = `${agent}: ${frags.join("; ")}. ${link}`;
556
+ mkdirSync(dirname(day), { recursive: true });
557
+ return { path: at, state: setPointerLine(day, { date, heading: `## ${agent}`, prefix: `${agent}: `, line }) };
558
+ } catch (e) {
559
+ return { path: at, state: "failed", error: e.message };
560
+ }
400
561
  }
401
562
 
402
563
  // 3c. Append one dated section to an EXISTING person page (HQ-2164), in the evda_record_correction
@@ -416,19 +577,84 @@ function peopleDir(evda) {
416
577
  return r;
417
578
  }
418
579
 
419
- // Files in `dir` whose name (without .md) matches `name`, case-insensitively, exact match first.
420
- // No folders (CLAUDE.md: one file per person), so this never recurses.
421
- function findPersonFiles(dir, name) {
422
- const want = String(name).trim().toLowerCase().replace(/\s+/g, " ");
423
- const all = readdirSync(dir.abs, { withFileTypes: true })
424
- .filter((e) => e.isFile() && /\.md$/i.test(e.name) && e.name.toLowerCase() !== "readme.md")
425
- .map((e) => e.name);
426
- const stem = (f) => f.replace(/\.md$/i, "").toLowerCase();
427
- const exact = all.filter((f) => stem(f) === want);
428
- return exact.length ? exact : all.filter((f) => stem(f).startsWith(want));
580
+ // HQ-2696: the pages that are a person's, wherever they live in the record. A person's page is any
581
+ // page whose frontmatter kind is a person kind, anywhere in the record, plus any page directly in the
582
+ // folder the record/people role maps to (a plain page there predates the frontmatter). Reading only
583
+ // the people folder made a page in a Family/ folder beside it invisible (2026-10-16): the agent was
584
+ // told there was no page, and evda_create would have made a second one. A family member is a person,
585
+ // so no folder is special-cased. Only a file's head is read, so a walk of a large record stays cheap.
586
+ export const PERSON_KINDS = ["person-profile", "person"];
587
+ const PERSON_HEAD = 8192;
588
+ const PERSON_KIND_LINE = new RegExp(`^kind:[ \\t]*["']?(?:${PERSON_KINDS.join("|")})["']?[ \\t]*$`, "m");
589
+ function headOf(abs) {
590
+ let fd;
591
+ try {
592
+ fd = openSync(abs, "r");
593
+ const buf = Buffer.alloc(PERSON_HEAD);
594
+ return buf.toString("utf8", 0, readSync(fd, buf, 0, PERSON_HEAD, 0));
595
+ } catch { return ""; } finally { if (fd !== undefined) closeSync(fd); }
596
+ }
597
+ function frontOfPersonHead(abs) {
598
+ let text = headOf(abs);
599
+ if (!text.startsWith("---\n")) return null;
600
+ const close = text.indexOf("\n---", 3);
601
+ if (close === -1) { try { text = readFileSync(abs, "utf8"); } catch { return null; } }
602
+ else if (!PERSON_KIND_LINE.test(text.slice(0, close))) return null;
603
+ return frontmatterOf(text);
604
+ }
605
+
606
+ const normName = (s) => String(s).trim().toLowerCase().replace(/\s+/g, " ");
607
+ const withoutParen = (s) => s.replace(/\s*\([^)]*\)\s*$/, "").trim();
608
+ // The names one person page answers to: its filename, its name:, each alias, and each of those
609
+ // without a trailing parenthetical ("John Veson (Dad / JV)" answers to "john veson" too).
610
+ function personNames(abs, front) {
611
+ const out = new Set();
612
+ const add = (v) => {
613
+ if (typeof v !== "string" || !v.trim()) return;
614
+ const n = normName(v);
615
+ out.add(n);
616
+ const bare = withoutParen(n);
617
+ if (bare) out.add(bare);
618
+ };
619
+ add(abs.split(sep).pop().replace(/\.md$/i, ""));
620
+ if (front) {
621
+ add(front.name);
622
+ const al = front.aliases ?? front.alias;
623
+ for (const a of Array.isArray(al) ? al : [al]) add(a);
624
+ }
625
+ return out;
626
+ }
627
+
628
+ // Every person page in the record: { abs, names }.
629
+ export function personPages(evda) {
630
+ const people = resolveRole(evda, "record/people");
631
+ const peopleAbs = people.mapped && people.exists && people.isDir ? people.abs : null;
632
+ const out = [];
633
+ for (const abs of markdownFiles(evda.dir)) {
634
+ const name = abs.split(sep).pop();
635
+ if (/^readme\.md$/i.test(name)) continue;
636
+ const inPeople = peopleAbs !== null && dirname(abs) === peopleAbs;
637
+ const front = frontOfPersonHead(abs);
638
+ const isPerson = front !== null && PERSON_KINDS.includes(front.kind);
639
+ if (!inPeople && !isPerson) continue;
640
+ out.push({ abs, names: personNames(abs, isPerson ? front : null) });
641
+ }
642
+ return out;
643
+ }
644
+
645
+ // The person pages a name points at: every page one of whose names equals it, case-insensitively;
646
+ // only when none does, every page whose filename starts with it (the older shorthand, "Sam" for
647
+ // "Sam Lee"). Returns absolute paths; more than one is ambiguous and the caller says so.
648
+ export function findPersonPages(evda, name) {
649
+ const want = normName(name);
650
+ const bare = withoutParen(want);
651
+ const pages = personPages(evda);
652
+ const exact = pages.filter((p) => p.names.has(want) || (bare && p.names.has(bare)));
653
+ if (exact.length) return exact.map((p) => p.abs);
654
+ return pages.filter((p) => normName(p.abs.split(sep).pop().replace(/\.md$/i, "")).startsWith(want)).map((p) => p.abs);
429
655
  }
430
656
 
431
- export function recordPersonNote(evda, args = {}, { now = new Date() } = {}) {
657
+ export function recordPersonNote(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
432
658
  onlyKeys("evda_record_person_note", args, ["person", "context", "words"], "It appends one dated section to an existing person page and nothing else: it takes no path, it never creates a page, and it never edits or removes an earlier section.");
433
659
  if (typeof args.person !== "string" || args.person.trim() === "") throw refuse('evda_record_person_note needs "person": the name on their page.', "NO_PERSON");
434
660
  if (typeof args.context !== "string" || args.context.trim() === "") throw refuse('evda_record_person_note needs "context": one line on what this section is.', "NO_CONTEXT");
@@ -439,13 +665,14 @@ export function recordPersonNote(evda, args = {}, { now = new Date() } = {}) {
439
665
  const words = text(args.words, "words", "evda_record_person_note");
440
666
  refuseSecrets(words, "evda_record_person_note");
441
667
 
442
- const dir = peopleDir(evda);
443
- const matches = findPersonFiles(dir, args.person);
444
- if (matches.length === 0) throw refuse(`no page for "${args.person.trim()}" in ${dir.rel}. This tool never creates one; offer to make the page first.`, "MISSING");
445
- if (matches.length > 1) throw refuse(`"${args.person.trim()}" matches more than one page in ${dir.rel} (${matches.join(", ")}). Say which one.`, "AMBIGUOUS");
668
+ const matches = findPersonPages(evda, args.person);
669
+ if (matches.length === 0) throw refuse(`no page for "${args.person.trim()}" anywhere in the record (a person's page is one with kind: person-profile frontmatter, or any page in the people folder). This tool never creates one: offer in one line to start their page, and on their yes make it with evda_create (kind "person").`, "MISSING");
670
+ if (matches.length > 1) throw refuse(`"${args.person.trim()}" matches more than one page (${matches.map((m) => rel(evda, m)).join(", ")}). Say which one.`, "AMBIGUOUS");
446
671
 
447
- const abs = join(dir.abs, matches[0]);
672
+ const abs = matches[0];
448
673
  if (isSealed(abs)) throw refuse(`${rel(evda, abs)} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
674
+ const may = mayRead(readContext(evda), abs, caller);
675
+ if (!may.ok) throw refuse(may.why, may.kind === "noted" ? "NOTED" : "READER");
449
676
 
450
677
  const raw = readFileSync(abs, "utf8");
451
678
  const date = localDate(now);
@@ -499,41 +726,368 @@ const APPEND_ELSEWHERE = [
499
726
  ["messages", "is a message file, which evda_message and the phone write."],
500
727
  ["ledger", "is the hypothesis ledger, which has its own format and tools."],
501
728
  ];
502
- export function appendEntry(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
503
- onlyKeys("evda_append", args, ["path", "words"], "It adds one dated section to the end of an entry that already exists and nothing else: it never creates a file and never edits what is there.");
504
- if (typeof args.path !== "string" || args.path.trim() === "") throw refuse('evda_append needs "path": the existing entry, relative to the record, as evda_search or evda_recent gave it.', "BAD_ARGUMENT");
505
- const words = text(args.words, "words", "evda_append");
506
- refuseSecrets(words, "evda_append");
507
- const want = args.path.trim();
729
+ // Where an entry-shaped write may land, by path: inside the record, a Markdown file, not the
730
+ // operating layer, not one of the record's own files (the root, a dot-folder, proposals/), not a
731
+ // file another tool owns (APPEND_ELSEWHERE), and readable by this caller (sealed, noted and
732
+ // read_by all refuse, since what the agent may not read is not the agent's to add to). `exists`
733
+ // says whether the path must already hold a file (append) or must hold nothing (create).
734
+ function entryPath(evda, tool, path, caller, { exists = true, folder = false } = {}) {
735
+ if (typeof path !== "string" || path.trim() === "") throw refuse(`${tool} needs "path": the entry, relative to the record, as evda_search or evda_recent gave it.`, "BAD_ARGUMENT");
736
+ const want = path.trim();
737
+ refuseSecrets(want, tool);
508
738
  const abs = resolve(evda.dir, want);
509
739
  if (!abs.startsWith(evda.dir + sep)) throw refuse(`${want} is outside the record.`, "OUTSIDE");
510
740
  const where = rel(evda, abs);
511
- if (!/\.md$/i.test(abs)) throw refuse(`${where} is not a Markdown entry; only a .md entry grows by a dated section.`, "NOT_AN_ENTRY");
512
- if (!existsSync(abs) || !statSync(abs).isFile()) throw refuse(`no entry is at ${where}. This tool never creates one: say in one line that you can start a page, and start it only on their yes.`, "MISSING");
741
+ if (!folder && !/\.md$/i.test(abs)) throw refuse(`${where} is not a Markdown entry; only a .md entry ${exists ? "grows" : "is started"} here.`, "NOT_AN_ENTRY");
742
+ if (exists && (!existsSync(abs) || !statSync(abs).isFile())) throw refuse(`no entry is at ${where}. This tool never creates one: say in one line that you can start the page, and on their yes make it with evda_create.`, "MISSING");
743
+ if (!exists && existsSync(abs)) throw refuse(`${where} already exists. evda_create never overwrites; add to an entry with evda_append.`, "EXISTS");
513
744
  const a = agentsFile(evda);
514
745
  if (a && resolve(a.abs) === abs) throw refuse(`${where} is the operating layer: a correction goes in with evda_record_correction, and a setup answer with evda_setup_answer.`, "NOT_AN_ENTRY");
515
- if (!where.includes("/") || where.split("/").some((p) => p.startsWith("."))) throw refuse(`${where} is one of the record's own files, not an entry.`, "NOT_AN_ENTRY");
516
- const top = where.split("/")[0];
517
- if (top === PROPOSALS) throw refuse(`${where} is one of the record's own files, not an entry.`, "NOT_AN_ENTRY");
746
+ const parts = where.split("/");
747
+ if ((!folder && parts.length < 2) || parts.some((p) => p.startsWith("."))) throw refuse(`${where} is one of the record's own files, not an entry.`, "NOT_AN_ENTRY");
748
+ if (parts[0] === PROPOSALS) throw refuse(`${where} is one of the record's own files, not an entry.`, "NOT_AN_ENTRY");
518
749
  for (const [role, why] of APPEND_ELSEWHERE) {
519
750
  const r = resolveRole(evda, role);
520
- if (r.mapped && r.abs && (abs === r.abs || abs.startsWith(r.abs + sep))) throw refuse(`${where} ${why}`, "NOT_AN_ENTRY");
751
+ if (role === "record/people" && !exists && !folder) continue;
752
+ if (r.mapped && r.abs && (abs === r.abs || abs.startsWith(r.abs + sep) || (folder && r.abs.startsWith(abs + sep)))) throw refuse(`${where} ${why}`, "NOT_AN_ENTRY");
521
753
  }
522
754
  const may = mayRead(readContext(evda), abs, caller);
523
755
  if (!may.ok) throw refuse(`${may.why} It is not yours to add to.`, may.kind === "sealed" ? "SEALED" : may.kind === "noted" ? "NOTED" : "NOT_THIS_READER");
756
+ return { abs, where };
757
+ }
758
+
759
+ // Markdown headings outside fenced code, as { level, text, start, end } (end: the offset after its
760
+ // line), so a `#` inside a code block is never read as structure.
761
+ function mdHeadings(raw) {
762
+ const out = [];
763
+ let fence = null;
764
+ let at = 0;
765
+ for (const line of raw.split("\n")) {
766
+ const start = at;
767
+ at += line.length + 1;
768
+ const f = /^ {0,3}(`{3,}|~{3,})/.exec(line);
769
+ if (f) {
770
+ if (!fence) fence = f[1];
771
+ else if (f[1][0] === fence[0] && f[1].length >= fence.length) fence = null;
772
+ continue;
773
+ }
774
+ if (fence) continue;
775
+ const h = /^ {0,3}(#{1,6})[ \t]+(.*?)[ \t#]*$/.exec(line);
776
+ if (h) out.push({ level: h[1].length, text: h[2].trim(), start, end: Math.min(at, raw.length) });
777
+ }
778
+ return out;
779
+ }
780
+
781
+ // The section a heading names: from the end of its heading line to the start of the next heading
782
+ // at its level or above, or the end of the file. Refuses a heading that is missing or matches twice.
783
+ function sectionUnder(raw, under, tool, where) {
784
+ if (typeof under !== "string" || under.trim() === "") throw refuse(`${tool} "under" is the text of a heading in ${where}, without the #s.`, "BAD_ARGUMENT");
785
+ const want = under.trim().replace(/^#+\s*/, "").toLowerCase();
786
+ const hs = mdHeadings(raw);
787
+ const hits = hs.filter((h) => h.text.toLowerCase() === want);
788
+ if (hits.length === 0) {
789
+ const names = hs.filter((h) => h.level <= 3).slice(0, 15).map((h) => JSON.stringify(h.text)).join(", ");
790
+ throw refuse(`${where} has no heading "${under.trim()}".${names ? ` Its headings include ${names}.` : ""}`, "NO_HEADING");
791
+ }
792
+ if (hits.length > 1) throw refuse(`${where} has ${hits.length} headings "${under.trim()}", so which section is meant is not clear. Name a heading that appears once.`, "AMBIGUOUS");
793
+ const h = hits[0];
794
+ const next = hs.find((x) => x.start > h.start && x.level <= h.level);
795
+ return { heading: h, start: h.end, end: next ? next.start : raw.length, inside: hs.filter((x) => x.start > h.start && (!next || x.start < next.start)) };
796
+ }
797
+
798
+ // Insert `block` (whole lines, ending in "\n") at `at`, so one blank line separates it from what is
799
+ // before and from a heading after. Every byte of raw is kept; the result is before + insert + after.
800
+ function insertAt(raw, at, block) {
801
+ const before = raw.slice(0, at);
802
+ const after = raw.slice(at);
803
+ const lead = before === "" || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
804
+ const trail = after === "" ? "" : "\n";
805
+ const insert = `${lead}${block}${trail}`;
806
+ return { text: before + insert + after, insert };
807
+ }
808
+
809
+ const TITLE_LINE_MAX = 160;
810
+ function oneLine(value, name, tool, max = TITLE_LINE_MAX) {
811
+ if (typeof value !== "string" || value.trim() === "") throw refuse(`${tool} "${name}" is one line of text; leave it out rather than send it empty.`, "EMPTY");
812
+ const v = value.trim();
813
+ if (/\n/.test(v)) throw refuse(`${tool} "${name}" is one line.`, "MULTILINE");
814
+ if (v.length > max) throw refuse(`${tool} "${name}" is ${v.length} characters; at most ${max}.`, "TOO_LONG");
815
+ refuseSecrets(v, tool);
816
+ return v;
817
+ }
818
+
819
+ // HQ-2328: `under` places the section at the end of that heading's section instead of the end of
820
+ // the file, with its heading one level below, so an entry goes into the right part of a file whose
821
+ // last section is "Completed" or an archive. `title` names the section: `<title> (added
822
+ // YYYY-MM-DD)`, the dated-heading shape the record's other entries use; without it the heading is
823
+ // the date alone, and a second append the same day continues it.
824
+ export function appendEntry(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
825
+ onlyKeys("evda_append", args, ["path", "words", "under", "title"], "It adds one dated section to an entry that already exists and nothing else: it never creates a file and never edits what is there.");
826
+ const words = text(args.words, "words", "evda_append");
827
+ refuseSecrets(words, "evda_append");
828
+ const title = args.title === undefined ? null : oneLine(args.title, "title", "evda_append");
829
+ const { abs, where } = entryPath(evda, "evda_append", args.path, caller);
524
830
  const raw = readFileSync(abs, "utf8");
525
831
  const fm = frontmatterOf(raw);
526
832
  if (fm && fm.kind === "person-profile") throw refuse(`${where} is a person profile, and each new section there needs an observation: add to it with evda_record_person_note.`, "NOT_AN_ENTRY");
527
833
  const date = localDate(now);
528
- const heading = `## ${date}`;
834
+ const label = title ? `${title} (added ${date})` : date;
835
+
836
+ if (args.under !== undefined) {
837
+ const s = sectionUnder(raw, args.under, "evda_append", where);
838
+ const level = Math.min(s.heading.level + 1, 6);
839
+ const heading = `${"#".repeat(level)} ${label}`;
840
+ const last = s.inside.filter((h) => h.level === level).pop();
841
+ const continued = !title && last && last.text === label && !s.inside.some((h) => h.start > last.start);
842
+ const block = continued ? `${words.trim()}\n` : `${heading}\n\n${words.trim()}\n`;
843
+ const { text: next } = insertAt(raw, s.end, block);
844
+ writeFileSync(abs, next);
845
+ return { path: where, date, section: continued ? "continued" : "opened", under: s.heading.text };
846
+ }
847
+
848
+ const heading = `## ${label}`;
529
849
  const lastHeading = [...raw.matchAll(/^## .*$/gm)].pop()?.[0]?.trim();
530
- const continued = lastHeading === heading;
850
+ const continued = !title && lastHeading === heading;
531
851
  const lead = raw === "" || raw.endsWith("\n") ? "" : "\n";
532
852
  const add = continued ? `${lead}\n${words.trim()}\n` : `${lead}${raw === "" ? "" : "\n"}${heading}\n\n${words.trim()}\n`;
533
853
  appendFileSync(abs, add);
534
854
  return { path: where, date, section: continued ? "continued" : "opened" };
535
855
  }
536
856
 
857
+ // The markdown tables in a text, outside fenced code: a header row, a delimiter row, then body rows,
858
+ // every line starting with `|`. { start, end, columns, header } with end the offset after the last row.
859
+ function splitRow(line) {
860
+ let s = line.trim();
861
+ if (s.startsWith("|")) s = s.slice(1);
862
+ if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
863
+ return s.split(/(?<!\\)\|/).map((c) => c.trim());
864
+ }
865
+ function mdTables(raw) {
866
+ const lines = raw.split("\n");
867
+ const offs = [];
868
+ let at = 0;
869
+ for (const l of lines) { offs.push(at); at += l.length + 1; }
870
+ const out = [];
871
+ let fence = null;
872
+ const isRow = (l) => /^ {0,3}\|/.test(l);
873
+ for (let i = 0; i < lines.length; i++) {
874
+ const f = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
875
+ if (f) {
876
+ if (!fence) fence = f[1];
877
+ else if (f[1][0] === fence[0] && f[1].length >= fence.length) fence = null;
878
+ continue;
879
+ }
880
+ if (fence || !isRow(lines[i]) || i + 1 >= lines.length) continue;
881
+ if (!/^ {0,3}\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(lines[i + 1])) continue;
882
+ const header = splitRow(lines[i]);
883
+ let j = i + 2;
884
+ while (j < lines.length && isRow(lines[j])) j++;
885
+ const lastLine = j - 1;
886
+ out.push({ start: offs[i], end: Math.min(offs[lastLine] + lines[lastLine].length + 1, raw.length), lastEndsFile: lastLine === lines.length - 1, columns: header.length, header });
887
+ i = j - 1;
888
+ }
889
+ return out;
890
+ }
891
+
892
+ const CELL_MAX = 4000;
893
+ // HQ-2328: one row at the end of a markdown table in an existing entry. A log kept as a table (the
894
+ // food log, a spike log, a weekends board) grows by a row, not by a dated section. The row has to
895
+ // fill the table's columns exactly, so a row is never written into the wrong shape; a file with more
896
+ // than one table needs `under`, the heading whose section holds the one meant. Only the new row's
897
+ // bytes are added: everything before and after it is what it was.
898
+ export function appendRow(evda, args = {}, { caller = callerFrom() } = {}) {
899
+ onlyKeys("evda_append_row", args, ["path", "cells", "under"], "It adds one row to the end of a table in an entry that already exists and nothing else: it never edits a row that is there.");
900
+ const { abs, where } = entryPath(evda, "evda_append_row", args.path, caller);
901
+ if (!Array.isArray(args.cells) || args.cells.length === 0) throw refuse('evda_append_row needs "cells": the new row\'s cells, in the table\'s column order, one string each ("" for an empty cell).', "BAD_ARGUMENT");
902
+ const cells = args.cells.map((c, i) => {
903
+ if (typeof c !== "string") throw refuse(`evda_append_row cell ${i + 1} is not text; send each cell as a string, "" when empty.`, "BAD_ARGUMENT");
904
+ if (/[\r\n]/.test(c)) throw refuse(`evda_append_row cell ${i + 1} has a line break; a table cell is one line.`, "MULTILINE");
905
+ if (c.length > CELL_MAX) throw refuse(`evda_append_row cell ${i + 1} is ${c.length} characters; at most ${CELL_MAX}.`, "TOO_LONG");
906
+ refuseSecrets(c, "evda_append_row");
907
+ return c.trim().replace(/(?<!\\)\|/g, "\\|");
908
+ });
909
+ const raw = readFileSync(abs, "utf8");
910
+ let tables = mdTables(raw);
911
+ let scope = "";
912
+ if (args.under !== undefined) {
913
+ const s = sectionUnder(raw, args.under, "evda_append_row", where);
914
+ tables = tables.filter((t) => t.start >= s.start && t.start < s.end);
915
+ scope = ` under "${s.heading.text}"`;
916
+ }
917
+ if (tables.length === 0) throw refuse(`${where} has no table${scope}. A row goes into a table that is already there.`, "NO_TABLE");
918
+ if (tables.length > 1) {
919
+ const where2 = mdHeadings(raw);
920
+ const named = tables.map((t) => where2.filter((h) => h.start < t.start).pop()?.text).map((h) => (h ? JSON.stringify(h) : "(before any heading)"));
921
+ throw refuse(`${where} has ${tables.length} tables${scope}, so which one is meant is not clear. Pass "under" with the heading of the one meant: ${named.join(", ")}.`, "AMBIGUOUS");
922
+ }
923
+ const t = tables[0];
924
+ if (cells.length !== t.columns) throw refuse(`the table in ${where}${scope} has ${t.columns} columns (${t.header.map((h) => JSON.stringify(h)).join(", ")}) and this row has ${cells.length} cells. Send one cell per column, "" for an empty one.`, "COLUMNS");
925
+ const row = `| ${cells.join(" | ")} |`.replace(/\| \|/g, "| |").replace(/\| \|/g, "| |");
926
+ const insert = t.lastEndsFile && !raw.endsWith("\n") ? `\n${row}` : `${row}\n`;
927
+ writeFileSync(abs, raw.slice(0, t.end) + insert + raw.slice(t.end));
928
+ return { path: where, row, columns: t.columns };
929
+ }
930
+
931
+ // HQ-2333: one line at the end of a list in an existing entry. A board kept as a list (his Weekends
932
+ // board is "- **Oct 9 - 11**: Austin (ACL) · `confirmed`", one line a weekend) grows by an item, and on
933
+ // 10/7 a chat with only evda_append and evda_append_row gave a new weekend its own heading and
934
+ // paragraph instead. The item takes the list's own marker (the next number in a numbered list), so it
935
+ // is written in the list's shape; the line is the caller's, in the format the items around it use.
936
+ const ITEM = /^( {0,3})([-*+]|\d{1,9}[.)])[ \t]+\S/;
937
+ function mdLists(raw) {
938
+ const lines = raw.split("\n");
939
+ const offs = [];
940
+ let at = 0;
941
+ for (const l of lines) { offs.push(at); at += l.length + 1; }
942
+ const kind = (m) => (/\d/.test(m) ? `n${m.slice(-1)}` : m);
943
+ const out = [];
944
+ let fence = null;
945
+ for (let i = 0; i < lines.length; i++) {
946
+ const f = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
947
+ if (f) {
948
+ if (!fence) fence = f[1];
949
+ else if (f[1][0] === fence[0] && f[1].length >= fence.length) fence = null;
950
+ continue;
951
+ }
952
+ const m = fence ? null : ITEM.exec(lines[i]);
953
+ if (!m) continue;
954
+ const indent = m[1].length;
955
+ const k = kind(m[2]);
956
+ let last = i;
957
+ let lastMarker = m[2];
958
+ const items = [{ line: i, text: lines[i].trim(), marker: m[2] }];
959
+ let j = i + 1;
960
+ while (j < lines.length) {
961
+ const l = lines[j];
962
+ if (/^ {0,3}(`{3,}|~{3,})/.test(l)) break;
963
+ const n = ITEM.exec(l);
964
+ if (n && n[1].length === indent && kind(n[2]) === k) { items[items.length - 1].lastLine = last; items.push({ line: j, text: l.trim(), marker: n[2] }); last = j; lastMarker = n[2]; j++; continue; }
965
+ if (l.trim() !== "" && /^\s/.test(l) && l.search(/\S/) > indent) { last = j; j++; continue; }
966
+ // A line straight after an item, with no blank between, that starts nothing of its own (a
967
+ // heading, a fence, a table row, a quote, a rule) belongs to that item: markdown reads it as a
968
+ // continuation, and so does his editor. His Weekends board has two ("**Oct 31:** Houston ...").
969
+ if (l.trim() !== "" && j === last + 1 && !/^ {0,3}(#{1,6}(\s|$)|\||>|(\*\s*){3,}$|(-\s*){3,}$|(_\s*){3,}$)/.test(l)) { last = j; j++; continue; }
970
+ if (l.trim() === "") {
971
+ let k2 = j;
972
+ while (k2 < lines.length && lines[k2].trim() === "") k2++;
973
+ const n2 = k2 < lines.length ? ITEM.exec(lines[k2]) : null;
974
+ if (n2 && n2[1].length === indent && kind(n2[2]) === k) { j = k2; continue; }
975
+ }
976
+ break;
977
+ }
978
+ items[items.length - 1].lastLine = last;
979
+ const endOf = (n) => Math.min(offs[n] + lines[n].length + 1, raw.length);
980
+ out.push({ start: offs[i], end: endOf(last), lastEndsFile: last === lines.length - 1, indent: m[1], marker: lastMarker, first: lines[i].trim(), items: items.map((x) => ({ text: x.text, marker: x.marker, end: endOf(x.lastLine), endsFile: x.lastLine === lines.length - 1 })) });
981
+ i = j - 1;
982
+ }
983
+ return out;
984
+ }
985
+
986
+ const ITEM_MAX = 2000;
987
+ export function appendItem(evda, args = {}, { caller = callerFrom() } = {}) {
988
+ onlyKeys("evda_append_item", args, ["path", "line", "under", "after"], "It adds one item to a list in an entry that already exists and nothing else: it never edits an item that is there.");
989
+ const { abs, where } = entryPath(evda, "evda_append_item", args.path, caller);
990
+ if (typeof args.line !== "string" || args.line.trim() === "") throw refuse('evda_append_item needs "line": the new item, one line, in the format the list\'s other items use.', "BAD_ARGUMENT");
991
+ if (/[\r\n]/.test(args.line.trim())) throw refuse("evda_append_item takes one line; a list item here is one line.", "MULTILINE");
992
+ const body = args.line.trim().replace(/^([-*+]|\d{1,9}[.)])[ \t]+/, "");
993
+ if (body.length > ITEM_MAX) throw refuse(`evda_append_item line is ${body.length} characters; at most ${ITEM_MAX}.`, "TOO_LONG");
994
+ refuseSecrets(body, "evda_append_item");
995
+ const raw = readFileSync(abs, "utf8");
996
+ let lists = mdLists(raw);
997
+ let scope = "";
998
+ if (args.under !== undefined) {
999
+ const s = sectionUnder(raw, args.under, "evda_append_item", where);
1000
+ lists = lists.filter((l) => l.start >= s.start && l.start < s.end);
1001
+ scope = ` under "${s.heading.text}"`;
1002
+ }
1003
+ if (lists.length === 0) throw refuse(`${where} has no list${scope}. An item goes into a list that is already there; a new section is evda_append.`, "NO_LIST");
1004
+ if (lists.length > 1) {
1005
+ const hs = mdHeadings(raw);
1006
+ const named = [...new Set(lists.map((l) => hs.filter((h) => h.start < l.start).pop()?.text).map((h) => (h ? JSON.stringify(h) : "(before any heading)")))];
1007
+ throw refuse(`${where} has ${lists.length} lists${scope}, so which one is meant is not clear. Pass "under" with the heading of the one meant${named.length ? `: ${named.join(", ")}` : ""}.`, "AMBIGUOUS");
1008
+ }
1009
+ const l = lists[0];
1010
+ // HQ-2333: a list kept in order (his Weekends board is by date) takes a new item beside the one it
1011
+ // belongs next to, so "after" names an item already there by a piece of its text.
1012
+ let at = l.end;
1013
+ let endsFile = l.lastEndsFile;
1014
+ let marker = /\d/.test(l.marker) ? `${Number(l.marker.slice(0, -1)) + 1}${l.marker.slice(-1)}` : l.marker;
1015
+ if (args.after !== undefined) {
1016
+ if (typeof args.after !== "string" || args.after.trim() === "") throw refuse('evda_append_item "after" is a piece of the text of the item the new one goes right after.', "BAD_ARGUMENT");
1017
+ if (/\d/.test(l.marker)) throw refuse(`the list in ${where}${scope} is numbered, so a new item goes at its end; leave out "after".`, "NUMBERED");
1018
+ const want = args.after.trim().toLowerCase();
1019
+ const hits = l.items.filter((x) => x.text.toLowerCase().includes(want));
1020
+ if (hits.length === 0) throw refuse(`no item in the list in ${where}${scope} contains "${args.after.trim()}". Name a piece of one item's text as it is written there.`, "NO_ITEM");
1021
+ if (hits.length > 1) throw refuse(`${hits.length} items in the list in ${where}${scope} contain "${args.after.trim()}": ${hits.slice(0, 5).map((x) => JSON.stringify(x.text.slice(0, 60))).join(", ")}. Name a piece of text only one of them has.`, "AMBIGUOUS");
1022
+ at = hits[0].end;
1023
+ endsFile = hits[0].endsFile;
1024
+ marker = hits[0].marker;
1025
+ }
1026
+ const item = `${l.indent}${marker} ${body}`;
1027
+ const insert = endsFile && !raw.endsWith("\n") ? `\n${item}` : `${item}\n`;
1028
+ writeFileSync(abs, raw.slice(0, at) + insert + raw.slice(at));
1029
+ return { path: where, item };
1030
+ }
1031
+
1032
+ // HQ-2331: start a new entry or folder, on the owner's yes. In a chat app the connector is the only
1033
+ // thing that writes, so without this an offer to "start a page for X" led nowhere. It creates only
1034
+ // where nothing is (an existing path is refused, never overwritten), only inside the record, never
1035
+ // in the record's own files or a part that is not this caller's to read, and never more than one
1036
+ // level: the folder a page goes in must already be there. A person's page is made in the
1037
+ // record/people folder with person-profile frontmatter that doctor reads, and a first section gets
1038
+ // its observation, the same shape evda_record_person_note keeps.
1039
+ export const CREATE_KINDS = ["page", "person", "folder"];
1040
+ const PERSON_NAME_MAX = 120;
1041
+ export function createEntry(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
1042
+ onlyKeys("evda_create", args, ["kind", "path", "person", "title", "context", "words", "confirmed"], "It starts one new page, person's page or folder where nothing is, and nothing else: it never overwrites, moves or deletes.");
1043
+ if (!CREATE_KINDS.includes(args.kind)) throw refuse(`evda_create "kind" is one of ${CREATE_KINDS.map((k) => JSON.stringify(k)).join(", ")}.`, "BAD_ARGUMENT");
1044
+ if (args.confirmed !== true) throw refuse("evda_create starts something new in their record only on their yes. Offer it in one line, and pass confirmed: true only once they said yes in this conversation.", "NOT_CONFIRMED");
1045
+ const kind = args.kind;
1046
+ const date = localDate(now);
1047
+ const words = args.words === undefined ? null : text(args.words, "words", "evda_create").trim();
1048
+ if (words) refuseSecrets(words, "evda_create");
1049
+
1050
+ if (kind === "folder") {
1051
+ for (const k of ["person", "title", "context", "words"]) if (args[k] !== undefined) throw refuse(`evda_create kind "folder" makes an empty folder and takes no "${k}"; start a page in it next.`, "BAD_ARGUMENT");
1052
+ const { abs, where } = entryPath(evda, "evda_create", args.path, caller, { exists: false, folder: true });
1053
+ if (!existsSync(dirname(abs)) || !statSync(dirname(abs)).isDirectory()) throw refuse(`${rel(evda, dirname(abs)) || "the record"} is not a folder yet. evda_create makes one level at a time: start the folder above first.`, "NO_PARENT");
1054
+ mkdirSync(abs);
1055
+ return { path: where, kind };
1056
+ }
1057
+
1058
+ if (kind === "person") {
1059
+ if (args.path !== undefined || args.title !== undefined) throw refuse('evda_create kind "person" takes the name as "person", not a path or title: the page goes in the people folder under their name.', "BAD_ARGUMENT");
1060
+ const name = oneLine(args.person ?? "", "person", "evda_create", PERSON_NAME_MAX);
1061
+ if (/[\\/:]/.test(name) || name.startsWith(".") || /^readme$/i.test(name)) throw refuse(`evda_create "person" is a name, as it goes on their page; "${name}" cannot be a file name.`, "BAD_ARGUMENT");
1062
+ if ((args.context === undefined) !== (words === null)) throw refuse('evda_create kind "person" takes "context" and "words" together (the first section and its observation), or neither.', "BAD_ARGUMENT");
1063
+ const context = args.context === undefined ? null : oneLine(args.context, "context", "evda_create", PERSON_CONTEXT_MAX);
1064
+ const dir = peopleDir(evda);
1065
+ const want = normName(name);
1066
+ const same = personPages(evda).filter((p) => p.names.has(want) || p.names.has(withoutParen(want)));
1067
+ if (same.length) throw refuse(`${rel(evda, same[0].abs)} already exists: add to it with evda_record_person_note.`, "EXISTS");
1068
+ const { abs, where } = entryPath(evda, "evda_create", `${dir.rel}/${name}.md`, caller, { exists: false });
1069
+ const owner = holderOf(evda).id ?? holderOf(evda).name;
1070
+ const id = slug(name) || "person";
1071
+ const observations = context ? [{ id: `${date}-${localTime(now).replace(":", "")}`, since: date, text: context }] : [];
1072
+ const fm = { kind: "person-profile", id, schemaVersion: "0.1", name, observations };
1073
+ const problems = readEntry("person-profile", fm, { owner }).problems;
1074
+ if (problems.length) throw refuse(`a person page for "${name}" would not read as a person-profile: ${problems[0]}`, "BAD_PROFILE");
1075
+ const body = `# ${name}\n${context ? `\n## ${context} (added ${date})\n\n${words}\n` : ""}`;
1076
+ writeFileSync(abs, `---\n${toYaml(fm)}---\n\n${body}`, { flag: "wx" });
1077
+ return { path: where, kind, observation: observations[0]?.id ?? null };
1078
+ }
1079
+
1080
+ if (args.person !== undefined || args.context !== undefined) throw refuse('evda_create kind "page" takes "path", "title" and "words"; a person\'s page is kind "person".', "BAD_ARGUMENT");
1081
+ const { abs, where } = entryPath(evda, "evda_create", args.path, caller, { exists: false });
1082
+ const people = resolveRole(evda, "record/people");
1083
+ if (people.mapped && people.abs && abs.startsWith(people.abs + sep)) throw refuse(`${where} is in the people folder: a person's page is kind "person", which writes the frontmatter doctor needs.`, "NOT_AN_ENTRY");
1084
+ if (!existsSync(dirname(abs)) || !statSync(dirname(abs)).isDirectory()) throw refuse(`${rel(evda, dirname(abs))} is not a folder yet. Make it first with evda_create kind "folder", on the same yes.`, "NO_PARENT");
1085
+ const title = args.title === undefined ? where.split("/").pop().replace(/\.md$/i, "") : oneLine(args.title, "title", "evda_create");
1086
+ const content = `# ${title}\n${words ? `\n## ${date}\n\n${words}\n` : ""}`;
1087
+ writeFileSync(abs, content, { flag: "wx" });
1088
+ return { path: where, kind };
1089
+ }
1090
+
537
1091
  // 3e. The owner's setup answers (HQ-2228), into AGENTS.md's "What this EVÐA is", in the
538
1092
  // evda_record_correction shape: one named section, one named line, and nothing else in the file
539
1093
  // changes. The core's setup step asks each answer to be written there "in their exact words"; a chat
@@ -629,8 +1183,7 @@ export function search(evda, args = {}, { caller = callerFrom() } = {}) {
629
1183
  const files = recordFiles(evda);
630
1184
  const ledger = resolveRole(evda, "ledger");
631
1185
  if (ledger.mapped && ledger.fileExists) files.push(ledger.file);
632
- const matches = [];
633
- let more = 0;
1186
+ const found = [];
634
1187
  const ctx = readContext(evda);
635
1188
  const refused = [];
636
1189
  // Whether any searched file is sealed or holds a sealed section, decided without the query, so
@@ -649,12 +1202,22 @@ export function search(evda, args = {}, { caller = callerFrom() } = {}) {
649
1202
  const shown = aiView(raw);
650
1203
  if (shown.sections) sealed = true;
651
1204
  const body = shown.text;
1205
+ // A line's date (HQ-2324): the date the heading it sits under names, else the file's own date,
1206
+ // so a people page's 10/2 section ranks with 10/2 and not with the page's first line.
1207
+ const own = fileDate(abs, raw) ?? "";
1208
+ let under = null;
652
1209
  body.split("\n").forEach((line, i) => {
1210
+ const h = /^#{1,6} (.*)$/.exec(line);
1211
+ if (h) under = headingDate(h[1]) ?? (/^#{1,2} /.test(line) ? null : under);
653
1212
  if (!line.toLowerCase().includes(needle)) return;
654
- if (matches.length >= limit) { more += 1; return; }
655
- matches.push({ path: rel(evda, abs), line: i + 1, text: line.length > 400 ? `${line.slice(0, 400)}...` : line });
1213
+ found.push({ path: rel(evda, abs), line: i + 1, date: under ?? own, text: line.length > 400 ? `${line.slice(0, 400)}...` : line });
656
1214
  });
657
1215
  }
1216
+ // Recent first (HQ-2324): on 2026-10-07 a search for chemistry filled its limit with February
1217
+ // before it reached 10/2. Newest date first; undated lines last; ties keep the record's order.
1218
+ found.sort((a, b) => (a.date === b.date ? 0 : !a.date ? 1 : !b.date ? -1 : b.date.localeCompare(a.date)));
1219
+ const matches = found.slice(0, limit);
1220
+ const more = found.length - matches.length;
658
1221
  return { query: args.query, searched: files.length, matches, more, refused, heldBack: heldBack(refused, { sealed }) };
659
1222
  }
660
1223
 
@@ -672,6 +1235,53 @@ export function readFile(evda, args = {}, { caller = callerFrom() } = {}) {
672
1235
  return { path: rel(evda, abs), text, truncated: false };
673
1236
  }
674
1237
 
1238
+ // 5a'. HQ-2066: the owner's own edit of one note, made on their phone, saved only when the note is
1239
+ // still what they read. `base` is the SHA-256 (hex) of the text evda_read returned; when the file
1240
+ // now hashes to anything else it was changed elsewhere since, and nothing is written, so an edit on
1241
+ // the phone never overwrites one made on the computer (the Mac's save safety, HQ-2049). A note
1242
+ // evda_read did not return whole (a sealed section cut out, or truncated for size) is refused, since
1243
+ // writing back what was shown would drop what was not. The machinery other tools own (the operating
1244
+ // layer, circles, grants, mounts, messages, the ledger, proposals, the root and dot-folders) is
1245
+ // refused as for evda_append; a note this caller may not read is not its to replace. A line of the
1246
+ // new text that was not in the old one is checked for secrets, so a note that already holds a
1247
+ // secret-shaped line can still be edited around it.
1248
+ export const versionOf = (text) => createHash("sha256").update(String(text), "utf8").digest("hex");
1249
+ const REPLACE_MACHINERY = ["circles", "grants", "mounts", "messages", "ledger"];
1250
+ export function replaceNote(evda, args = {}, { caller = callerFrom() } = {}) {
1251
+ const tool = "evda_replace";
1252
+ onlyKeys(tool, args, ["path", "text", "base"], "It replaces one note's whole text when the note is unchanged since base, and nothing else.");
1253
+ if (typeof args.text !== "string") throw refuse(`${tool} needs "text", the note's whole new text.`, "BAD_ARGUMENT");
1254
+ if (args.text.length > MAX_READ) throw refuse(`${tool} "text" is ${args.text.length} characters; a note replaced here holds at most ${MAX_READ}.`, "TOO_LONG");
1255
+ if (typeof args.base !== "string" || !/^[0-9a-f]{64}$/i.test(args.base.trim())) throw refuse(`${tool} needs "base", the SHA-256 in hex of the text evda_read returned, so a note changed since is never overwritten.`, "BAD_ARGUMENT");
1256
+ if (typeof args.path !== "string" || args.path.trim() === "") throw refuse(`${tool} needs "path": the note, relative to the record, as evda_read gave it.`, "BAD_ARGUMENT");
1257
+ const want = args.path.trim();
1258
+ const abs = resolve(evda.dir, want);
1259
+ if (!abs.startsWith(evda.dir + sep)) throw refuse(`${want} is outside the record.`, "OUTSIDE");
1260
+ const where = rel(evda, abs);
1261
+ if (!/\.md$/i.test(abs)) throw refuse(`${where} is not a Markdown note; only a .md note is edited here.`, "NOT_AN_ENTRY");
1262
+ if (!existsSync(abs) || !statSync(abs).isFile()) throw refuse(`no note is at ${where}; it may have been moved or deleted since it was read.`, "MISSING");
1263
+ const a = agentsFile(evda);
1264
+ if (a && resolve(a.abs) === abs) throw refuse(`${where} is the operating layer, which is edited on the computer.`, "NOT_AN_ENTRY");
1265
+ const parts = where.split("/");
1266
+ if (parts.length < 2 || parts.some((p) => p.startsWith(".")) || parts[0] === PROPOSALS) throw refuse(`${where} is one of the record's own files, not a note.`, "NOT_AN_ENTRY");
1267
+ for (const role of REPLACE_MACHINERY) {
1268
+ const r = resolveRole(evda, role);
1269
+ if (r.mapped && r.abs && (abs === r.abs || abs.startsWith(r.abs + sep))) throw refuse(`${where} is part of the record's own machinery, not a note.`, "NOT_AN_ENTRY");
1270
+ }
1271
+ const may = mayRead(readContext(evda), abs, caller);
1272
+ if (!may.ok) throw refuse(`${may.why} It is not yours to replace.`, may.kind === "sealed" ? "SEALED" : may.kind === "noted" ? "NOTED" : "NOT_THIS_READER");
1273
+ const raw = readFileSync(abs, "utf8");
1274
+ if (aiView(raw).text !== raw) throw refuse(`${where} has a sealed section, so what was read is not the whole note; edit it on the computer.`, "SEALED");
1275
+ if (raw.length > MAX_READ) throw refuse(`${where} is ${raw.length} characters, more than evda_read returns whole; edit it on the computer.`, "TOO_LONG");
1276
+ if (versionOf(raw) !== args.base.trim().toLowerCase()) throw refuse(`${where} changed since it was read, so this edit would overwrite that change. Read it again and make the edit on what is there now.`, "CHANGED");
1277
+ const old = new Set(raw.split("\n"));
1278
+ refuseSecrets(args.text.split("\n").filter((l) => !old.has(l)).join("\n"), tool);
1279
+ if (aiView(args.text).text !== args.text) throw refuse(`${tool}: the new text opens a sealed section, which is made on the computer.`, "SEALED");
1280
+ if (args.text === raw) return { path: where, changed: false, version: versionOf(raw) };
1281
+ writeFileSync(abs, args.text);
1282
+ return { path: where, changed: true, version: versionOf(args.text) };
1283
+ }
1284
+
675
1285
  // 5b. "Just note this": the owner's words, verbatim, as a noted capture no AI reads. Refuses
676
1286
  // anything secret-shaped, like every other write.
677
1287
  export function noteTool(evda, args = {}, { now = new Date() } = {}) {