create-evda 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +20 -7
  3. package/bin/evda.mjs +277 -7
  4. package/create-evda.mjs +17 -4
  5. package/dist/pairing.mjs +2932 -1684
  6. package/hooks/evda-day-writer-guard.mjs +7 -0
  7. package/lib/acknowledge.mjs +187 -0
  8. package/lib/adopt.mjs +488 -0
  9. package/lib/agents.mjs +1 -1
  10. package/lib/attested-host.mjs +269 -0
  11. package/lib/browse.mjs +354 -0
  12. package/lib/circles.mjs +5 -1
  13. package/lib/clients.mjs +29 -2
  14. package/lib/connect.mjs +9 -2
  15. package/lib/connecting-apps.mjs +56 -0
  16. package/lib/connector-tools.mjs +85 -0
  17. package/lib/corrections.mjs +34 -0
  18. package/lib/credentials-migrate.mjs +120 -0
  19. package/lib/day-file.mjs +173 -15
  20. package/lib/day-writer.mjs +110 -0
  21. package/lib/doctor.mjs +45 -3
  22. package/lib/every-chat.mjs +128 -0
  23. package/lib/filechanges.mjs +203 -68
  24. package/lib/gather-apply.mjs +176 -26
  25. package/lib/gather.mjs +119 -17
  26. package/lib/html-notes.mjs +124 -0
  27. package/lib/keep-hook.mjs +61 -8
  28. package/lib/keychain.mjs +181 -0
  29. package/lib/manifest.mjs +5 -1
  30. package/lib/mcp-auth.mjs +41 -1
  31. package/lib/mcp-http.mjs +42 -4
  32. package/lib/mcp.mjs +1198 -76
  33. package/lib/measurement-log.mjs +249 -0
  34. package/lib/messages.mjs +5 -2
  35. package/lib/migrate-people.mjs +54 -20
  36. package/lib/pairing.mjs +243 -5
  37. package/lib/parity.mjs +47 -13
  38. package/lib/person-folders.mjs +89 -0
  39. package/lib/plugin-hook.mjs +109 -0
  40. package/lib/plugin.mjs +208 -0
  41. package/lib/presets.mjs +26 -5
  42. package/lib/publish.mjs +55 -10
  43. package/lib/qr.mjs +317 -0
  44. package/lib/record.mjs +74 -0
  45. package/lib/routines.mjs +54 -4
  46. package/lib/sigstore-trust.json +20 -0
  47. package/lib/skills.mjs +1 -1
  48. package/lib/sync-lock.mjs +143 -0
  49. package/lib/sync-rules.mjs +154 -1
  50. package/lib/takeout.mjs +169 -0
  51. package/lib/tdx-attestation.mjs +412 -0
  52. package/lib/tdx-trust.json +10 -0
  53. package/lib/tools.mjs +1040 -31
  54. package/lib/touches.mjs +427 -0
  55. package/lib/update-check.mjs +30 -0
  56. package/lib/watch.mjs +160 -0
  57. package/lib/yaml.mjs +8 -4
  58. package/package.json +2 -1
  59. package/skills/evda/SKILL.md +14 -0
  60. package/templates/AGENTS.md +81 -10
  61. package/templates/evda-digest.md +5 -1
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,17 +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, 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 } from "./record.mjs";
32
- import { readCircles, isSealed } from "./circles.mjs";
36
+ import { mdFiles, fileDate, headings, sectionDigest, headingDate } from "./record.mjs";
37
+ import { splitFence, FENCE_OPEN } from "./agents.mjs";
38
+ import { datalessAmong } from "./sync-rules.mjs";
39
+ import { readCircles, isSealed, frontmatterOf, markdownFiles } from "./circles.mjs";
33
40
  import { secretShape } from "./references.mjs";
34
41
  import { doctor } from "./doctor.mjs";
35
- import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY } from "./readers.mjs";
42
+ import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY, isSealedFile, isNotedFile } from "./readers.mjs";
36
43
  import { EvdaError } from "./errors.mjs";
37
- import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
38
- 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";
46
+ import { acknowledge as writeAcknowledgement, readAcknowledged, acknowledgementsFor } from "./acknowledge.mjs";
47
+ import { openEvda, holderOf } from "./manifest.mjs";
48
+ import { toYaml } from "./yaml.mjs";
49
+ import { readEntry } from "./kinds.mjs";
50
+ import { correctionsTarget } from "./corrections.mjs";
51
+ import { slug } from "./migrate-people.mjs";
39
52
 
40
53
  export { localDate, localTime, CONTEXT_LABEL };
41
54
 
@@ -99,12 +112,73 @@ export function operatingLayer(evda, args = {}) {
99
112
  const a = agentsFile(evda);
100
113
  if (!a) throw new EvdaError('this record maps no "agents" role, so it has no operating layer.', { code: "NO_ROLE" });
101
114
  if (isSealed(a.abs)) throw new EvdaError(`REFUSED: ${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, { code: "SEALED", exit: 2 });
102
- return { path: a.rel, text: aiView(readFileSync(a.abs, "utf8")).text };
115
+ const raw = readFileSync(a.abs, "utf8");
116
+ const found = coreBeside(evda, a, raw);
117
+ return { path: a.rel, text: aiView(raw).text, core: found.core, coreNote: found.note };
103
118
  }
104
119
 
105
- // 2. The last `days` days of the record, newest first, whole files, capped in size. A file is in the
106
- // window by its date (filename, then a date or created key), the same rule `evda moment` uses.
107
- export function recent(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
120
+ // The generated core, when the agents file is only the owner layer (HQ-2203). A scaffolded record's
121
+ // AGENTS.md carries the `evda:generated` fence itself, and then there is nothing to add. A record
122
+ // that maps agents to an owner-only file (his: CLAUDE.md) keeps the core in another markdown file
123
+ // in the same folder (his: IRIS.md, built from the vault's template), and Claude Code loads it
124
+ // through its own prompt while a chat app gets only this tool. The rule is the one browse.mjs
125
+ // uses to show operating files: the markdown file beside the agents file that carries the fence.
126
+ // No name is assumed, so an owner who renamed their agent still gets theirs, and the core is read
127
+ // from the record, never from the server's templates. Two candidates are not guessed between:
128
+ // neither is served and the note names them. A sealed or noted file, or one still in the cloud
129
+ // (reading it would start a download), is not a candidate.
130
+ export function coreBeside(evda, agents, agentsText) {
131
+ try {
132
+ if (splitFence(agentsText)) return { core: null, note: null };
133
+ } catch {
134
+ return { core: null, note: null };
135
+ }
136
+ const folder = dirname(agents.abs);
137
+ let names;
138
+ try {
139
+ names = readdirSync(folder).filter((n) => /\.md$/i.test(n) && join(folder, n) !== agents.abs).sort();
140
+ } catch {
141
+ return { core: null, note: null };
142
+ }
143
+ const cloud = datalessAmong(folder, names);
144
+ const found = [];
145
+ for (const n of names) {
146
+ const abs = join(folder, n);
147
+ if (cloud.has(n)) continue;
148
+ let text;
149
+ try {
150
+ if (!statSync(abs).isFile()) continue;
151
+ text = readFileSync(abs, "utf8");
152
+ } catch {
153
+ continue;
154
+ }
155
+ if (!text.includes(FENCE_OPEN) || isSealedFile(abs) || isNotedFile(abs)) continue;
156
+ let parts;
157
+ try { parts = splitFence(text); } catch { continue; }
158
+ if (parts) found.push({ path: rel(evda, abs), text: aiView(parts.generated).text });
159
+ }
160
+ if (found.length === 1) return { core: found[0], note: null };
161
+ if (found.length > 1) return { core: null, note: `${found.map((f) => f.path).join(" and ")} both carry the generated core, so neither was served here. Read the one the owner means with evda_read.` };
162
+ return { core: null, note: null };
163
+ }
164
+
165
+ // 2. The last `days` days of the record, newest first, capped in size. A file is in the window by
166
+ // its date (filename, then a date or created key), the same rule `evda moment` uses.
167
+ //
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
175
+ // not part of the tool's args (onlyKeys refuses it there); it is an internal option for callers in
176
+ // this process, such as the evda://today resource below, that still need one file's whole text.
177
+ //
178
+ // Each file carries what an earlier reader already acknowledged in it (HQ-2060, lib/acknowledge.mjs):
179
+ // this is the one read path every reader uses at session start, so it is where a reader learns that
180
+ // a weighty entry is already handled and it should stay quiet about it.
181
+ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(), full = false } = {}) {
108
182
  onlyKeys("evda_recent", args, ["days"]);
109
183
  const days = args.days === undefined ? 3 : Number(args.days);
110
184
  if (!Number.isInteger(days) || days < 1 || days > 90) throw new EvdaError("evda_recent days must be a whole number from 1 to 90.", { code: "BAD_ARGUMENT", exit: 2 });
@@ -116,6 +190,7 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
116
190
  const ctx = readContext(evda);
117
191
  const refused = [];
118
192
  let sealed = false;
193
+ const marks = readAcknowledged(evda);
119
194
  for (const abs of recordFiles(evda)) {
120
195
  const raw = readFileSync(abs, "utf8");
121
196
  const date = fileDate(abs, raw);
@@ -124,18 +199,80 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
124
199
  if (!may.ok) { refused.push(may); continue; }
125
200
  const shown = aiView(raw);
126
201
  if (shown.sections) sealed = true;
127
- files.push({ date, path: rel(evda, abs), text: shown.text });
202
+ const path = rel(evda, abs);
203
+ const acknowledged = acknowledgementsFor(path, marks);
204
+ files.push(full
205
+ ? { date, path, text: shown.text, acknowledged }
206
+ : { date, path, headings: headings(shown.text), sections: sectionDigest(shown.text), acknowledged });
128
207
  }
129
208
  files.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : b.date.localeCompare(a.date)));
130
209
  let used = 0;
131
210
  const out = [];
132
211
  let truncated = 0;
133
212
  for (const f of files) {
134
- if (used + f.text.length > MAX_READ) { truncated += 1; continue; }
135
- used += f.text.length;
213
+ const weight = full ? f.text.length : f.headings.join("\n").length;
214
+ if (used + weight > MAX_READ) { truncated += 1; continue; }
215
+ used += weight;
136
216
  out.push(f);
137
217
  }
138
- return { from, to, files: out, omitted: truncated, refused: refused.filter((r) => r.kind !== "sealed"), heldBack: heldBack(refused, { sealed }) };
218
+ return {
219
+ from,
220
+ to,
221
+ files: out,
222
+ omitted: truncated,
223
+ acknowledged: out.reduce((n, f) => n + f.acknowledged.length, 0),
224
+ refused: refused.filter((r) => r.kind !== "sealed"),
225
+ heldBack: heldBack(refused, { sealed }),
226
+ };
227
+ }
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
+
263
+ // 2a. Record that this reader acted on one weighty entry, so no other reader acts on it again
264
+ // (HQ-2060). The first reader to reach it writes the mark; a later one gets first: false and nothing
265
+ // is written. Weight is the reader's judgment; this only remembers that one of them already did it.
266
+ export function acknowledgeEntry(evda, args = {}, { now = new Date() } = {}) {
267
+ onlyKeys("evda_acknowledge", args, ["path", "section", "note", "by"], "It writes one mark beside the record and nothing else: it never edits the entry, and it never writes over a mark another reader left.");
268
+ return writeAcknowledgement(evda, args, { now });
269
+ }
270
+
271
+ // 2b. Every mark the record holds, for a reader that wants them without the files.
272
+ export function acknowledgements(evda, args = {}) {
273
+ onlyKeys("evda_acknowledgements", args, []);
274
+ const all = readAcknowledged(evda);
275
+ return { count: Object.keys(all).length, marks: Object.entries(all).map(([key, m]) => ({ key, ...m })) };
139
276
  }
140
277
 
141
278
  // Every markdown file the record holds: each record-layer role, and the imports folder. Not the
@@ -155,6 +292,12 @@ function recordFiles(evda) {
155
292
  return out;
156
293
  }
157
294
 
295
+ // Today's daily file, relative to the record, whether or not it exists yet: the one `write` appends
296
+ // to. The app's Today's Journal (HQ-2038) shows it, and an empty page when it is not written yet.
297
+ export function todayPath(evda, { now = new Date() } = {}) {
298
+ return rel(evda, join(dailyDir(evda).abs, `${localDate(now)}.md`));
299
+ }
300
+
158
301
  // 3. Append the owner's words, verbatim, to today's daily file. Creates the file with a date heading
159
302
  // when it is the first write of the day. Returns the path.
160
303
  // `context` (HQ-1377) is one line on what the conversation was about, in the agent's words, written
@@ -162,25 +305,179 @@ function recordFiles(evda) {
162
305
  // `session` (HQ-1385) is the conversation: its first write opens `## HH:MM` with the context line,
163
306
  // and later writes append under it (lib/day-file.mjs, the one append path).
164
307
  export const CONTEXT_MAX = 240;
165
- export function write(evda, args = {}, { now = new Date(), session = null } = {}) {
166
- onlyKeys("evda_write", args, ["words", "context"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
167
- const words = text(args.words, "words", "evda_write");
168
- refuseSecrets(words, "evda_write");
308
+ // `toolName` (HQ-2164) is what an error names itself as: evda_write when called directly, or
309
+ // another tool's own name when it reuses this same append-only logic against a different day
310
+ // file (evda_write_agent_log, against Iris's own sub-record). The behavior is identical either
311
+ // way; only what a refusal calls itself changes.
312
+ export function write(evda, args = {}, { now = new Date(), session = null, toolName = "evda_write" } = {}) {
313
+ onlyKeys(toolName, args, ["words", "context"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
314
+ const words = text(args.words, "words", toolName);
315
+ refuseSecrets(words, toolName);
169
316
  let context = null;
170
317
  if (args.context !== undefined) {
171
- if (typeof args.context !== "string" || args.context.trim() === "") throw refuse(`evda_write "context" is one line on what the conversation was about; leave it out rather than send it empty.`, "EMPTY");
172
- if (/\n/.test(args.context.trim())) throw refuse(`evda_write "context" is one line. Their words go in "words"; the context is a single line on what the conversation was about.`, "CONTEXT_LINES");
173
- if (args.context.trim().length > CONTEXT_MAX) throw refuse(`evda_write "context" is ${args.context.trim().length} characters; one line of context is at most ${CONTEXT_MAX}.`, "TOO_LONG");
174
- refuseSecrets(args.context, "evda_write");
318
+ if (typeof args.context !== "string" || args.context.trim() === "") throw refuse(`${toolName} "context" is one line on what the conversation was about; leave it out rather than send it empty.`, "EMPTY");
319
+ if (/\n/.test(args.context.trim())) throw refuse(`${toolName} "context" is one line. Their words go in "words"; the context is a single line on what the conversation was about.`, "CONTEXT_LINES");
320
+ if (args.context.trim().length > CONTEXT_MAX) throw refuse(`${toolName} "context" is ${args.context.trim().length} characters; one line of context is at most ${CONTEXT_MAX}.`, "TOO_LONG");
321
+ refuseSecrets(args.context, toolName);
175
322
  context = args.context.trim();
176
323
  }
177
- const r = dailyDir(evda);
178
- mkdirSync(r.abs, { recursive: true });
179
- const abs = join(r.abs, `${localDate(now)}.md`);
324
+ const abs = join(evda.dir, todayPath(evda, { now }));
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
+ }
180
335
  const w = appendToDay(abs, { now, words, context, session });
181
336
  return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
182
337
  }
183
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
+
372
+ // 3b. What `evda mcp`'s evda_write tool actually calls (HQ-2165). `write` above already appended
373
+ // the words; this reads them back after a short real delay and refuses to report success if they
374
+ // are gone, so a caller that overwrote the file in that gap is caught instead of the agent telling
375
+ // the owner "Filed" about words that are no longer there (the night of 2026-10-03: the connector's
376
+ // evda_write reported Filed at 03:40:46Z and the owner's words were gone by the time anyone looked).
377
+ //
378
+ // What this catches: a same-host writer (Hestia's journal-append.mjs doing a read-modify-write of
379
+ // the whole file) that lands in the window between this function's append and its own read-back.
380
+ // What this CANNOT catch: two different machines' local replicas of this file diverging and iCloud
381
+ // picking one wholesale, which can resolve on any timescale and is not bounded by a short delay.
382
+ // That class is why every writer but one host must stop touching this file's local copy at all --
383
+ // see test/single-writer.test.mjs. This is a backstop for the class it can catch, not the fix for
384
+ // the class that caused the incident; HQ-2165's "Done when" is the single-writer change, not this.
385
+ const VERIFY_DELAY_MS = 400;
386
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
387
+ export async function writeVerified(evda, args = {}, { verifyDelayMs = VERIFY_DELAY_MS, ...rest } = {}) {
388
+ const r = write(evda, args, rest);
389
+ if (r.queued) return r; // queued, not written: nothing in the day to read back
390
+ const abs = join(evda.dir, r.path);
391
+ if (verifyDelayMs > 0) await sleep(verifyDelayMs);
392
+ const expect = String(args.words).replace(/\n+$/, "");
393
+ const after = existsSync(abs) ? readFileSync(abs, "utf8") : "";
394
+ if (!after.includes(expect)) {
395
+ throw new EvdaError(
396
+ `evda_write: ${r.path} was written, then read back after ${verifyDelayMs}ms and the words were gone -- `
397
+ + "another writer overwrote the file in between. Tell the owner the write FAILED; never say \"Filed\" "
398
+ + "about this call.",
399
+ { code: "LOST_WRITE", exit: 1 },
400
+ );
401
+ }
402
+ return r;
403
+ }
404
+
405
+ // 3a. Append one dated correction to AGENTS.md's "What I have been taught" (HQ-1997). That section
406
+ // is always outside the installer's fence (Spec 05 ruling 2, lib/agents.mjs splitFence), so a write
407
+ // here never touches the generated block and survives `evda upgrade` untouched, the same way any
408
+ // other owner edit below the fence does. Append only: an earlier entry is never edited or removed;
409
+ // a correction that reverses one is a new entry with "supersedes" naming the date of the one it
410
+ // replaces, exactly as the section's own instructions ask.
411
+ const CHANGE_MAX = 400;
412
+ const WORDS_MAX = 400;
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.");
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");
422
+ const change = args.change.trim();
423
+ if (/\n/.test(change)) throw refuse('evda_record_correction "change" is one line.', "MULTILINE");
424
+ if (change.length > CHANGE_MAX) throw refuse(`evda_record_correction "change" is ${change.length} characters; at most ${CHANGE_MAX}.`, "TOO_LONG");
425
+ refuseSecrets(change, "evda_record_correction");
426
+ let words = null;
427
+ if (args.words !== undefined) {
428
+ if (typeof args.words !== "string" || args.words.trim() === "") throw refuse('evda_record_correction "words" is the owner\'s exact words; leave it out rather than send it empty.', "EMPTY");
429
+ words = args.words.trim();
430
+ if (/\n/.test(words)) throw refuse('evda_record_correction "words" is one line.', "MULTILINE");
431
+ if (words.length > WORDS_MAX) throw refuse(`evda_record_correction "words" is ${words.length} characters; at most ${WORDS_MAX}.`, "TOO_LONG");
432
+ refuseSecrets(words, "evda_record_correction");
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
+ }
442
+ let supersedes = null;
443
+ if (args.supersedes !== undefined) {
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");
445
+ supersedes = args.supersedes.trim();
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
+
464
+ const a = agentsFile(evda);
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");
466
+ if (isSealed(a.abs)) throw refuse(`${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
467
+ const raw = readFileSync(a.abs, "utf8");
468
+ const parts = splitFence(raw);
469
+ const owner = parts ? parts.after : raw;
470
+ if (!/(^|\n)## What I have been taught(\n|$)/.test(owner)) {
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");
472
+ }
473
+ const line = `- ${date}: ${title ? `${title}. ` : ""}${said}${change}${supersededNote}\n`;
474
+ const lineSep = owner.endsWith("\n") ? "" : "\n";
475
+ const newOwner = owner + lineSep + line;
476
+ const newRaw = parts ? parts.before + parts.generated + newOwner : newOwner;
477
+ writeFileSync(a.abs, newRaw);
478
+ return { path: a.rel, date, section: "What I have been taught" };
479
+ }
480
+
184
481
  // The day file for a date, as { abs, rel }, for writers that are not evda_write (the chat-export
185
482
  // backstop in `evda gather`). Throws when the record maps no day role.
186
483
  export function dayFile(evda, date) {
@@ -188,6 +485,661 @@ export function dayFile(evda, date) {
188
485
  return { abs: join(r.abs, `${date}.md`), rel: rel(evda, join(r.abs, `${date}.md`)), dir: r.abs };
189
486
  }
190
487
 
488
+ // 3b. Iris's own day log (HQ-2164): a fixed, named sub-record, exactly like AGENTS.md's "What I
489
+ // have been taught" is a fixed, named section. Not a general path: this is the one place
490
+ // evda_write_agent_log is ever allowed to write, and it is reused unchanged from `write` above
491
+ // against that sub-record's own evda.json, so a section there opens and continues by the same
492
+ // one-conversation rule as the owner's own daily file.
493
+ const AGENT_LOG_REL = "01_Projects/Evda/Iris - Universal Thinking Partner/memory";
494
+ function agentLogRecord(evda) {
495
+ const abs = join(evda.dir, AGENT_LOG_REL);
496
+ if (!existsSync(join(abs, "evda.json"))) {
497
+ throw refuse(`${AGENT_LOG_REL} has no evda.json, so there is no agent log sub-record to write into.`, "NO_ROLE");
498
+ }
499
+ return openEvda(abs);
500
+ }
501
+
502
+ // Whether this record has the agent log sub-record. A chat app is offered evda_write_agent_log only
503
+ // when it does (HQ-2164), so a chat is never handed a tool that can only refuse.
504
+ export function hasAgentLog(evda) {
505
+ return existsSync(join(evda.dir, AGENT_LOG_REL, "evda.json"));
506
+ }
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.
514
+ export function writeAgentLog(evda, args = {}, { now = new Date(), session = null } = {}) {
515
+ const sub = agentLogRecord(evda);
516
+ const r = write(sub, args, { now, session, toolName: "evda_write_agent_log" });
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
+ }
561
+ }
562
+
563
+ // 3c. Append one dated section to an EXISTING person page (HQ-2164), in the evda_record_correction
564
+ // shape: one named target class (record/people), resolved by name rather than by path, and append
565
+ // only. Never creates a page (CLAUDE.md: "Only docs that already exist. Do not auto-create a new
566
+ // profile mid-conversation; offer it."); refuses a sealed page, and refuses when the name matches
567
+ // more than one page rather than guessing. When the page's frontmatter is `kind: person-profile`,
568
+ // the new section also gets a matching `observations` entry (CLAUDE.md / doctor's HQ-1397 rule:
569
+ // "A People doc with kind: person-profile frontmatter gets an observation for each new dated
570
+ // section"), so this write can never silently leave a profile failing that check. Everything else
571
+ // in the file, including the rest of the frontmatter, is byte for byte what it was.
572
+ const PERSON_CONTEXT_MAX = CONTEXT_MAX;
573
+ function peopleDir(evda) {
574
+ const r = resolveRole(evda, "record/people");
575
+ if (!r.mapped) throw refuse('this record maps no "record/people" role, so it has no person pages to write into.', "NO_ROLE");
576
+ if (!r.exists || !r.isDir) throw refuse(`${r.rel} is mapped as "record/people" but is not a folder.`, "NO_ROLE");
577
+ return r;
578
+ }
579
+
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);
655
+ }
656
+
657
+ export function recordPersonNote(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
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.");
659
+ if (typeof args.person !== "string" || args.person.trim() === "") throw refuse('evda_record_person_note needs "person": the name on their page.', "NO_PERSON");
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");
661
+ const context = args.context.trim();
662
+ if (/\n/.test(context)) throw refuse('evda_record_person_note "context" is one line.', "MULTILINE");
663
+ if (context.length > PERSON_CONTEXT_MAX) throw refuse(`evda_record_person_note "context" is ${context.length} characters; at most ${PERSON_CONTEXT_MAX}.`, "TOO_LONG");
664
+ refuseSecrets(context, "evda_record_person_note");
665
+ const words = text(args.words, "words", "evda_record_person_note");
666
+ refuseSecrets(words, "evda_record_person_note");
667
+
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");
671
+
672
+ const abs = matches[0];
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");
676
+
677
+ const raw = readFileSync(abs, "utf8");
678
+ const date = localDate(now);
679
+ const heading = `## ${context} (added ${date})`;
680
+ const lead = raw.endsWith("\n") ? "" : "\n";
681
+ const section = `${lead}\n${heading}\n\n${words.trim()}\n`;
682
+
683
+ const fm = frontmatterOf(raw);
684
+ let newRaw = raw + section;
685
+ let observationId = null;
686
+ if (fm && fm.kind === "person-profile") {
687
+ const lines = raw.split("\n");
688
+ const end = lines.indexOf("---", 1);
689
+ const body = lines.slice(end + 1).join("\n");
690
+ const obs = Array.isArray(fm.observations) ? fm.observations : [];
691
+ const stamp = `${date}-${localTime(now).replace(":", "")}`;
692
+ const ids = new Set(obs.map((o) => o.id));
693
+ let id = stamp;
694
+ for (let n = 2; ids.has(id); n++) id = `${stamp}-${n}`;
695
+ const observation = { id, since: date, text: context };
696
+ const nextFm = { ...fm, observations: [...obs, observation] };
697
+ const owner = holderOf(evda).id ?? holderOf(evda).name;
698
+ const before = readEntry("person-profile", fm, { owner });
699
+ const after = readEntry("person-profile", nextFm, { owner });
700
+ const introduced = after.dto === null ? [after.problems[0]] : after.problems.filter((p) => !before.problems.includes(p));
701
+ if (introduced.length) throw refuse(`writing this observation into ${rel(evda, abs)} would leave its frontmatter invalid: ${introduced[0]}`, "BAD_PROFILE");
702
+ const newFrontmatter = `---\n${toYaml(nextFm)}---\n`;
703
+ newRaw = newFrontmatter + body + section;
704
+ observationId = id;
705
+ }
706
+ writeFileSync(abs, newRaw);
707
+ return { path: rel(evda, abs), date, observation: observationId };
708
+ }
709
+
710
+ // 3d. "Entries grow on their own" (HQ-2228): one dated section on an entry that already exists, by
711
+ // its path. In a chat app this is the only way the core's rule can be carried out, since the
712
+ // connector is the only thing that writes. It never creates a file and never touches a byte already
713
+ // there: the section goes at the end, `## YYYY-MM-DD`, and a second append the same day continues
714
+ // that section instead of opening a second heading for the day. Who may write follows who may read
715
+ // (lib/readers.mjs mayRead): a sealed entry, a noted one and a part read by a different AI are each
716
+ // refused, since the core says those are not the agent's to add to. The files other tools own are
717
+ // refused with the tool that does it: the operating layer, the day's file, a person's page, and the
718
+ // record's own machinery (circles, grants, mounts, messages, the ledger, proposals, anything at the
719
+ // root or in a dot-folder, which is where skills live).
720
+ const APPEND_ELSEWHERE = [
721
+ ["record/daily", "is the day's file: the owner's words go there with evda_write."],
722
+ ["record/people", "is a person's page: add to it with evda_record_person_note, which also keeps its observations in step."],
723
+ ["circles", "is part of how the record is shared, which only the owner changes."],
724
+ ["grants", "is part of how the record is shared, which only the owner changes."],
725
+ ["mounts", "is part of how the record is shared, which only the owner changes."],
726
+ ["messages", "is a message file, which evda_message and the phone write."],
727
+ ["ledger", "is the hypothesis ledger, which has its own format and tools."],
728
+ ];
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);
738
+ const abs = resolve(evda.dir, want);
739
+ if (!abs.startsWith(evda.dir + sep)) throw refuse(`${want} is outside the record.`, "OUTSIDE");
740
+ const where = rel(evda, abs);
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");
744
+ const a = agentsFile(evda);
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");
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");
749
+ for (const [role, why] of APPEND_ELSEWHERE) {
750
+ const r = resolveRole(evda, role);
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");
753
+ }
754
+ const may = mayRead(readContext(evda), abs, caller);
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);
830
+ const raw = readFileSync(abs, "utf8");
831
+ const fm = frontmatterOf(raw);
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");
833
+ const date = localDate(now);
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}`;
849
+ const lastHeading = [...raw.matchAll(/^## .*$/gm)].pop()?.[0]?.trim();
850
+ const continued = !title && lastHeading === heading;
851
+ const lead = raw === "" || raw.endsWith("\n") ? "" : "\n";
852
+ const add = continued ? `${lead}\n${words.trim()}\n` : `${lead}${raw === "" ? "" : "\n"}${heading}\n\n${words.trim()}\n`;
853
+ appendFileSync(abs, add);
854
+ return { path: where, date, section: continued ? "continued" : "opened" };
855
+ }
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
+
1091
+ // 3e. The owner's setup answers (HQ-2228), into AGENTS.md's "What this EVÐA is", in the
1092
+ // evda_record_correction shape: one named section, one named line, and nothing else in the file
1093
+ // changes. The core's setup step asks each answer to be written there "in their exact words"; a chat
1094
+ // app had no tool to do it. A field is written once, when it is empty: a filled one is the owner's
1095
+ // to change, in their own file, so the agent never rewrites what they said.
1096
+ export const SETUP_FIELDS = ["Agent name", "Who I am", "What I want this for", "How I want it to talk to me", "The AI I use with it, and its training setting"];
1097
+ const SETUP_HEADING = /^## What this EV(?:Ð|D)A is[ \t]*$/m;
1098
+ export function setupAnswer(evda, args = {}) {
1099
+ onlyKeys("evda_setup_answer", args, ["field", "words"], "It fills one empty field of \"What this EVÐA is\" and nothing else.");
1100
+ if (!SETUP_FIELDS.includes(args.field)) throw refuse(`evda_setup_answer "field" is one of ${SETUP_FIELDS.map((f) => JSON.stringify(f)).join(", ")}.`, "BAD_FIELD");
1101
+ if (typeof args.words !== "string" || args.words.trim() === "") throw refuse('evda_setup_answer needs "words": the owner\'s answer, exactly as they said it.', "EMPTY");
1102
+ const words = args.words.trim();
1103
+ if (/\n/.test(words)) throw refuse('evda_setup_answer "words" is one line: a field holds one line of their answer.', "MULTILINE");
1104
+ if (words.length > WORDS_MAX) throw refuse(`evda_setup_answer "words" is ${words.length} characters; at most ${WORDS_MAX}.`, "TOO_LONG");
1105
+ refuseSecrets(words, "evda_setup_answer");
1106
+ const a = agentsFile(evda);
1107
+ if (!a) throw refuse('this record maps no "agents" role, so it has no AGENTS.md to write the answer into.', "NO_ROLE");
1108
+ if (isSealed(a.abs)) throw refuse(`${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
1109
+ const raw = readFileSync(a.abs, "utf8");
1110
+ // Only the owner's text: the installer's generated block is never written.
1111
+ const parts = splitFence(raw);
1112
+ const pieces = parts ? [["before", parts.before], ["after", parts.after]] : [["whole", raw]];
1113
+ for (const [name, piece] of pieces) {
1114
+ const h = SETUP_HEADING.exec(piece);
1115
+ if (!h) continue;
1116
+ const start = h.index + h[0].length;
1117
+ const next = piece.slice(start).search(/^## /m);
1118
+ const end = next < 0 ? piece.length : start + next;
1119
+ const section = piece.slice(start, end);
1120
+ const label = args.field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1121
+ const line = new RegExp(`^- \\*\\*${label}:\\*\\*([ \\t]*)(.*)$`, "m").exec(section);
1122
+ if (!line) throw refuse(`${a.rel}'s "What this EVÐA is" has no "${args.field}" line. Add "- **${args.field}:**" there first (templates/AGENTS.md has the wording).`, "NO_FIELD");
1123
+ if (line[2].trim() !== "") throw refuse(`"${args.field}" already reads: ${line[2].trim()}. A filled field is the owner's to change, in their own file.`, "ALREADY_SET");
1124
+ const at = start + line.index;
1125
+ const newPiece = `${piece.slice(0, at)}- **${args.field}:** ${words}${piece.slice(at + line[0].length)}`;
1126
+ const newRaw = !parts ? newPiece : name === "before" ? newPiece + parts.generated + parts.after : parts.before + parts.generated + newPiece;
1127
+ writeFileSync(a.abs, newRaw);
1128
+ return { path: a.rel, field: args.field };
1129
+ }
1130
+ throw refuse(`${a.rel} has no "## What this EVÐA is" section outside the generated block. Add it first (templates/AGENTS.md has the wording).`, "NO_SECTION");
1131
+ }
1132
+
1133
+ // Whether setup is unfinished: some field of "What this EVÐA is" is there and empty.
1134
+ export function setupPending(agentsText) {
1135
+ const h = SETUP_HEADING.exec(agentsText ?? "");
1136
+ if (!h) return false;
1137
+ const rest = agentsText.slice(h.index + h[0].length);
1138
+ const next = rest.search(/^## /m);
1139
+ const section = next < 0 ? rest : rest.slice(0, next);
1140
+ return SETUP_FIELDS.some((f) => new RegExp(`^- \\*\\*${f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\*\\*[ \\t]*$`, "m").test(section));
1141
+ }
1142
+
191
1143
  // 4. File what another AI said about the owner, verbatim, under imports/, labeled with its source
192
1144
  // and date. A second import from the same source on the same day is appended to that file.
193
1145
  export function importText(evda, args = {}, { now = new Date() } = {}) {
@@ -231,8 +1183,7 @@ export function search(evda, args = {}, { caller = callerFrom() } = {}) {
231
1183
  const files = recordFiles(evda);
232
1184
  const ledger = resolveRole(evda, "ledger");
233
1185
  if (ledger.mapped && ledger.fileExists) files.push(ledger.file);
234
- const matches = [];
235
- let more = 0;
1186
+ const found = [];
236
1187
  const ctx = readContext(evda);
237
1188
  const refused = [];
238
1189
  // Whether any searched file is sealed or holds a sealed section, decided without the query, so
@@ -251,12 +1202,22 @@ export function search(evda, args = {}, { caller = callerFrom() } = {}) {
251
1202
  const shown = aiView(raw);
252
1203
  if (shown.sections) sealed = true;
253
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;
254
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);
255
1212
  if (!line.toLowerCase().includes(needle)) return;
256
- if (matches.length >= limit) { more += 1; return; }
257
- 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 });
258
1214
  });
259
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;
260
1221
  return { query: args.query, searched: files.length, matches, more, refused, heldBack: heldBack(refused, { sealed }) };
261
1222
  }
262
1223
 
@@ -274,6 +1235,53 @@ export function readFile(evda, args = {}, { caller = callerFrom() } = {}) {
274
1235
  return { path: rel(evda, abs), text, truncated: false };
275
1236
  }
276
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
+
277
1285
  // 5b. "Just note this": the owner's words, verbatim, as a noted capture no AI reads. Refuses
278
1286
  // anything secret-shaped, like every other write.
279
1287
  export function noteTool(evda, args = {}, { now = new Date() } = {}) {
@@ -298,7 +1306,8 @@ export function releaseTool(evda, args = {}, { now = new Date() } = {}) {
298
1306
  }
299
1307
 
300
1308
  // 5d. Tell the owner something (HQ-1246): one new message file under the messages role, which the
301
- // phone app shows as a card. Never edits one; refuses anything secret-shaped, like every write.
1309
+ // phone app shows as a card once paired and synced -- this call never confirms that part (HQ-2161).
1310
+ // Never edits one; refuses anything secret-shaped, like every write.
302
1311
  export function message(evda, args = {}, { now = new Date() } = {}) {
303
1312
  onlyKeys("evda_message", args, ["title", "body", "from"], "It writes one new message for the owner and nothing else: it takes no path, and it never edits a message once written.");
304
1313
  return writeMessage(evda, args, { now, refuseSecrets });