create-evda 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.mjs CHANGED
@@ -25,17 +25,23 @@
25
25
  // making the circle, and granting anyone anything, stays the owner's explicit yes through
26
26
  // `evda grant` or their own hands.
27
27
 
28
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
28
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
29
29
  import { dirname, join, relative, resolve, sep } from "node:path";
30
30
  import { resolveRole, rolesForLayer, agentsFile } from "./roles.mjs";
31
- import { mdFiles, fileDate } from "./record.mjs";
32
- import { readCircles, isSealed } from "./circles.mjs";
31
+ import { mdFiles, fileDate, headings } from "./record.mjs";
32
+ import { splitFence, FENCE_OPEN } from "./agents.mjs";
33
+ import { datalessAmong } from "./sync-rules.mjs";
34
+ import { readCircles, isSealed, frontmatterOf } from "./circles.mjs";
33
35
  import { secretShape } from "./references.mjs";
34
36
  import { doctor } from "./doctor.mjs";
35
- import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY } from "./readers.mjs";
37
+ import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY, isSealedFile, isNotedFile } from "./readers.mjs";
36
38
  import { EvdaError } from "./errors.mjs";
37
39
  import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
38
40
  import { writeMessage, unseenReplies } from "./messages.mjs";
41
+ import { acknowledge as writeAcknowledgement, readAcknowledged, acknowledgementsFor } from "./acknowledge.mjs";
42
+ import { openEvda, holderOf } from "./manifest.mjs";
43
+ import { toYaml } from "./yaml.mjs";
44
+ import { readEntry } from "./kinds.mjs";
39
45
 
40
46
  export { localDate, localTime, CONTEXT_LABEL };
41
47
 
@@ -99,12 +105,73 @@ export function operatingLayer(evda, args = {}) {
99
105
  const a = agentsFile(evda);
100
106
  if (!a) throw new EvdaError('this record maps no "agents" role, so it has no operating layer.', { code: "NO_ROLE" });
101
107
  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 };
108
+ const raw = readFileSync(a.abs, "utf8");
109
+ const found = coreBeside(evda, a, raw);
110
+ return { path: a.rel, text: aiView(raw).text, core: found.core, coreNote: found.note };
103
111
  }
104
112
 
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() } = {}) {
113
+ // The generated core, when the agents file is only the owner layer (HQ-2203). A scaffolded record's
114
+ // AGENTS.md carries the `evda:generated` fence itself, and then there is nothing to add. A record
115
+ // that maps agents to an owner-only file (his: CLAUDE.md) keeps the core in another markdown file
116
+ // in the same folder (his: IRIS.md, built from the vault's template), and Claude Code loads it
117
+ // through its own prompt while a chat app gets only this tool. The rule is the one browse.mjs
118
+ // uses to show operating files: the markdown file beside the agents file that carries the fence.
119
+ // No name is assumed, so an owner who renamed their agent still gets theirs, and the core is read
120
+ // from the record, never from the server's templates. Two candidates are not guessed between:
121
+ // neither is served and the note names them. A sealed or noted file, or one still in the cloud
122
+ // (reading it would start a download), is not a candidate.
123
+ export function coreBeside(evda, agents, agentsText) {
124
+ try {
125
+ if (splitFence(agentsText)) return { core: null, note: null };
126
+ } catch {
127
+ return { core: null, note: null };
128
+ }
129
+ const folder = dirname(agents.abs);
130
+ let names;
131
+ try {
132
+ names = readdirSync(folder).filter((n) => /\.md$/i.test(n) && join(folder, n) !== agents.abs).sort();
133
+ } catch {
134
+ return { core: null, note: null };
135
+ }
136
+ const cloud = datalessAmong(folder, names);
137
+ const found = [];
138
+ for (const n of names) {
139
+ const abs = join(folder, n);
140
+ if (cloud.has(n)) continue;
141
+ let text;
142
+ try {
143
+ if (!statSync(abs).isFile()) continue;
144
+ text = readFileSync(abs, "utf8");
145
+ } catch {
146
+ continue;
147
+ }
148
+ if (!text.includes(FENCE_OPEN) || isSealedFile(abs) || isNotedFile(abs)) continue;
149
+ let parts;
150
+ try { parts = splitFence(text); } catch { continue; }
151
+ if (parts) found.push({ path: rel(evda, abs), text: aiView(parts.generated).text });
152
+ }
153
+ if (found.length === 1) return { core: found[0], note: null };
154
+ 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.` };
155
+ return { core: null, note: null };
156
+ }
157
+
158
+ // 2. The last `days` days of the record, newest first, capped in size. A file is in the window by
159
+ // its date (filename, then a date or created key), the same rule `evda moment` uses.
160
+ //
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
+ // not part of the tool's args (onlyKeys refuses it there); it is an internal option for callers in
169
+ // this process, such as the evda://today resource below, that still need one file's whole text.
170
+ //
171
+ // Each file carries what an earlier reader already acknowledged in it (HQ-2060, lib/acknowledge.mjs):
172
+ // this is the one read path every reader uses at session start, so it is where a reader learns that
173
+ // a weighty entry is already handled and it should stay quiet about it.
174
+ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(), full = false } = {}) {
108
175
  onlyKeys("evda_recent", args, ["days"]);
109
176
  const days = args.days === undefined ? 3 : Number(args.days);
110
177
  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 +183,7 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
116
183
  const ctx = readContext(evda);
117
184
  const refused = [];
118
185
  let sealed = false;
186
+ const marks = readAcknowledged(evda);
119
187
  for (const abs of recordFiles(evda)) {
120
188
  const raw = readFileSync(abs, "utf8");
121
189
  const date = fileDate(abs, raw);
@@ -124,18 +192,46 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
124
192
  if (!may.ok) { refused.push(may); continue; }
125
193
  const shown = aiView(raw);
126
194
  if (shown.sections) sealed = true;
127
- files.push({ date, path: rel(evda, abs), text: shown.text });
195
+ const path = rel(evda, abs);
196
+ const acknowledged = acknowledgementsFor(path, marks);
197
+ files.push(full
198
+ ? { date, path, text: shown.text, acknowledged }
199
+ : { date, path, headings: headings(shown.text), acknowledged });
128
200
  }
129
201
  files.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : b.date.localeCompare(a.date)));
130
202
  let used = 0;
131
203
  const out = [];
132
204
  let truncated = 0;
133
205
  for (const f of files) {
134
- if (used + f.text.length > MAX_READ) { truncated += 1; continue; }
135
- used += f.text.length;
206
+ const weight = full ? f.text.length : f.headings.join("\n").length;
207
+ if (used + weight > MAX_READ) { truncated += 1; continue; }
208
+ used += weight;
136
209
  out.push(f);
137
210
  }
138
- return { from, to, files: out, omitted: truncated, refused: refused.filter((r) => r.kind !== "sealed"), heldBack: heldBack(refused, { sealed }) };
211
+ return {
212
+ from,
213
+ to,
214
+ files: out,
215
+ omitted: truncated,
216
+ acknowledged: out.reduce((n, f) => n + f.acknowledged.length, 0),
217
+ refused: refused.filter((r) => r.kind !== "sealed"),
218
+ heldBack: heldBack(refused, { sealed }),
219
+ };
220
+ }
221
+
222
+ // 2a. Record that this reader acted on one weighty entry, so no other reader acts on it again
223
+ // (HQ-2060). The first reader to reach it writes the mark; a later one gets first: false and nothing
224
+ // is written. Weight is the reader's judgment; this only remembers that one of them already did it.
225
+ export function acknowledgeEntry(evda, args = {}, { now = new Date() } = {}) {
226
+ 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.");
227
+ return writeAcknowledgement(evda, args, { now });
228
+ }
229
+
230
+ // 2b. Every mark the record holds, for a reader that wants them without the files.
231
+ export function acknowledgements(evda, args = {}) {
232
+ onlyKeys("evda_acknowledgements", args, []);
233
+ const all = readAcknowledged(evda);
234
+ return { count: Object.keys(all).length, marks: Object.entries(all).map(([key, m]) => ({ key, ...m })) };
139
235
  }
140
236
 
141
237
  // Every markdown file the record holds: each record-layer role, and the imports folder. Not the
@@ -155,6 +251,12 @@ function recordFiles(evda) {
155
251
  return out;
156
252
  }
157
253
 
254
+ // Today's daily file, relative to the record, whether or not it exists yet: the one `write` appends
255
+ // to. The app's Today's Journal (HQ-2038) shows it, and an empty page when it is not written yet.
256
+ export function todayPath(evda, { now = new Date() } = {}) {
257
+ return rel(evda, join(dailyDir(evda).abs, `${localDate(now)}.md`));
258
+ }
259
+
158
260
  // 3. Append the owner's words, verbatim, to today's daily file. Creates the file with a date heading
159
261
  // when it is the first write of the day. Returns the path.
160
262
  // `context` (HQ-1377) is one line on what the conversation was about, in the agent's words, written
@@ -162,25 +264,108 @@ function recordFiles(evda) {
162
264
  // `session` (HQ-1385) is the conversation: its first write opens `## HH:MM` with the context line,
163
265
  // and later writes append under it (lib/day-file.mjs, the one append path).
164
266
  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");
267
+ // `toolName` (HQ-2164) is what an error names itself as: evda_write when called directly, or
268
+ // another tool's own name when it reuses this same append-only logic against a different day
269
+ // file (evda_write_agent_log, against Iris's own sub-record). The behavior is identical either
270
+ // way; only what a refusal calls itself changes.
271
+ export function write(evda, args = {}, { now = new Date(), session = null, toolName = "evda_write" } = {}) {
272
+ 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.");
273
+ const words = text(args.words, "words", toolName);
274
+ refuseSecrets(words, toolName);
169
275
  let context = null;
170
276
  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");
277
+ 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");
278
+ 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");
279
+ 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");
280
+ refuseSecrets(args.context, toolName);
175
281
  context = args.context.trim();
176
282
  }
177
- const r = dailyDir(evda);
178
- mkdirSync(r.abs, { recursive: true });
179
- const abs = join(r.abs, `${localDate(now)}.md`);
283
+ const abs = join(evda.dir, todayPath(evda, { now }));
284
+ mkdirSync(dirname(abs), { recursive: true });
180
285
  const w = appendToDay(abs, { now, words, context, session });
181
286
  return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
182
287
  }
183
288
 
289
+ // 3b. What `evda mcp`'s evda_write tool actually calls (HQ-2165). `write` above already appended
290
+ // the words; this reads them back after a short real delay and refuses to report success if they
291
+ // are gone, so a caller that overwrote the file in that gap is caught instead of the agent telling
292
+ // the owner "Filed" about words that are no longer there (the night of 2026-10-03: the connector's
293
+ // evda_write reported Filed at 03:40:46Z and the owner's words were gone by the time anyone looked).
294
+ //
295
+ // What this catches: a same-host writer (Hestia's journal-append.mjs doing a read-modify-write of
296
+ // the whole file) that lands in the window between this function's append and its own read-back.
297
+ // What this CANNOT catch: two different machines' local replicas of this file diverging and iCloud
298
+ // picking one wholesale, which can resolve on any timescale and is not bounded by a short delay.
299
+ // That class is why every writer but one host must stop touching this file's local copy at all --
300
+ // see test/single-writer.test.mjs. This is a backstop for the class it can catch, not the fix for
301
+ // the class that caused the incident; HQ-2165's "Done when" is the single-writer change, not this.
302
+ const VERIFY_DELAY_MS = 400;
303
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
304
+ export async function writeVerified(evda, args = {}, { verifyDelayMs = VERIFY_DELAY_MS, ...rest } = {}) {
305
+ const r = write(evda, args, rest);
306
+ const abs = join(evda.dir, r.path);
307
+ if (verifyDelayMs > 0) await sleep(verifyDelayMs);
308
+ const expect = String(args.words).replace(/\n+$/, "");
309
+ const after = existsSync(abs) ? readFileSync(abs, "utf8") : "";
310
+ if (!after.includes(expect)) {
311
+ throw new EvdaError(
312
+ `evda_write: ${r.path} was written, then read back after ${verifyDelayMs}ms and the words were gone -- `
313
+ + "another writer overwrote the file in between. Tell the owner the write FAILED; never say \"Filed\" "
314
+ + "about this call.",
315
+ { code: "LOST_WRITE", exit: 1 },
316
+ );
317
+ }
318
+ return r;
319
+ }
320
+
321
+ // 3a. Append one dated correction to AGENTS.md's "What I have been taught" (HQ-1997). That section
322
+ // is always outside the installer's fence (Spec 05 ruling 2, lib/agents.mjs splitFence), so a write
323
+ // here never touches the generated block and survives `evda upgrade` untouched, the same way any
324
+ // other owner edit below the fence does. Append only: an earlier entry is never edited or removed;
325
+ // a correction that reverses one is a new entry with "supersedes" naming the date of the one it
326
+ // replaces, exactly as the section's own instructions ask.
327
+ const CHANGE_MAX = 400;
328
+ 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.");
331
+ 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
+ const change = args.change.trim();
333
+ if (/\n/.test(change)) throw refuse('evda_record_correction "change" is one line.', "MULTILINE");
334
+ if (change.length > CHANGE_MAX) throw refuse(`evda_record_correction "change" is ${change.length} characters; at most ${CHANGE_MAX}.`, "TOO_LONG");
335
+ refuseSecrets(change, "evda_record_correction");
336
+ let words = null;
337
+ if (args.words !== undefined) {
338
+ 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");
339
+ words = args.words.trim();
340
+ if (/\n/.test(words)) throw refuse('evda_record_correction "words" is one line.', "MULTILINE");
341
+ if (words.length > WORDS_MAX) throw refuse(`evda_record_correction "words" is ${words.length} characters; at most ${WORDS_MAX}.`, "TOO_LONG");
342
+ refuseSecrets(words, "evda_record_correction");
343
+ }
344
+ let supersedes = null;
345
+ if (args.supersedes !== undefined) {
346
+ 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
+ supersedes = args.supersedes.trim();
348
+ }
349
+ 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");
351
+ if (isSealed(a.abs)) throw refuse(`${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
352
+ const raw = readFileSync(a.abs, "utf8");
353
+ const parts = splitFence(raw);
354
+ const owner = parts ? parts.after : raw;
355
+ 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");
357
+ }
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;
364
+ const newRaw = parts ? parts.before + parts.generated + newOwner : newOwner;
365
+ writeFileSync(a.abs, newRaw);
366
+ return { path: a.rel, date, section: "What I have been taught" };
367
+ }
368
+
184
369
  // The day file for a date, as { abs, rel }, for writers that are not evda_write (the chat-export
185
370
  // backstop in `evda gather`). Throws when the record maps no day role.
186
371
  export function dayFile(evda, date) {
@@ -188,6 +373,219 @@ export function dayFile(evda, date) {
188
373
  return { abs: join(r.abs, `${date}.md`), rel: rel(evda, join(r.abs, `${date}.md`)), dir: r.abs };
189
374
  }
190
375
 
376
+ // 3b. Iris's own day log (HQ-2164): a fixed, named sub-record, exactly like AGENTS.md's "What I
377
+ // have been taught" is a fixed, named section. Not a general path: this is the one place
378
+ // evda_write_agent_log is ever allowed to write, and it is reused unchanged from `write` above
379
+ // against that sub-record's own evda.json, so a section there opens and continues by the same
380
+ // one-conversation rule as the owner's own daily file.
381
+ const AGENT_LOG_REL = "01_Projects/Evda/Iris - Universal Thinking Partner/memory";
382
+ function agentLogRecord(evda) {
383
+ const abs = join(evda.dir, AGENT_LOG_REL);
384
+ if (!existsSync(join(abs, "evda.json"))) {
385
+ throw refuse(`${AGENT_LOG_REL} has no evda.json, so there is no agent log sub-record to write into.`, "NO_ROLE");
386
+ }
387
+ return openEvda(abs);
388
+ }
389
+
390
+ // Whether this record has the agent log sub-record. A chat app is offered evda_write_agent_log only
391
+ // when it does (HQ-2164), so a chat is never handed a tool that can only refuse.
392
+ export function hasAgentLog(evda) {
393
+ return existsSync(join(evda.dir, AGENT_LOG_REL, "evda.json"));
394
+ }
395
+
396
+ export function writeAgentLog(evda, args = {}, { now = new Date(), session = null } = {}) {
397
+ const sub = agentLogRecord(evda);
398
+ 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 };
400
+ }
401
+
402
+ // 3c. Append one dated section to an EXISTING person page (HQ-2164), in the evda_record_correction
403
+ // shape: one named target class (record/people), resolved by name rather than by path, and append
404
+ // only. Never creates a page (CLAUDE.md: "Only docs that already exist. Do not auto-create a new
405
+ // profile mid-conversation; offer it."); refuses a sealed page, and refuses when the name matches
406
+ // more than one page rather than guessing. When the page's frontmatter is `kind: person-profile`,
407
+ // the new section also gets a matching `observations` entry (CLAUDE.md / doctor's HQ-1397 rule:
408
+ // "A People doc with kind: person-profile frontmatter gets an observation for each new dated
409
+ // section"), so this write can never silently leave a profile failing that check. Everything else
410
+ // in the file, including the rest of the frontmatter, is byte for byte what it was.
411
+ const PERSON_CONTEXT_MAX = CONTEXT_MAX;
412
+ function peopleDir(evda) {
413
+ const r = resolveRole(evda, "record/people");
414
+ if (!r.mapped) throw refuse('this record maps no "record/people" role, so it has no person pages to write into.', "NO_ROLE");
415
+ if (!r.exists || !r.isDir) throw refuse(`${r.rel} is mapped as "record/people" but is not a folder.`, "NO_ROLE");
416
+ return r;
417
+ }
418
+
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));
429
+ }
430
+
431
+ export function recordPersonNote(evda, args = {}, { now = new Date() } = {}) {
432
+ 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
+ if (typeof args.person !== "string" || args.person.trim() === "") throw refuse('evda_record_person_note needs "person": the name on their page.', "NO_PERSON");
434
+ 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");
435
+ const context = args.context.trim();
436
+ if (/\n/.test(context)) throw refuse('evda_record_person_note "context" is one line.', "MULTILINE");
437
+ if (context.length > PERSON_CONTEXT_MAX) throw refuse(`evda_record_person_note "context" is ${context.length} characters; at most ${PERSON_CONTEXT_MAX}.`, "TOO_LONG");
438
+ refuseSecrets(context, "evda_record_person_note");
439
+ const words = text(args.words, "words", "evda_record_person_note");
440
+ refuseSecrets(words, "evda_record_person_note");
441
+
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");
446
+
447
+ const abs = join(dir.abs, matches[0]);
448
+ if (isSealed(abs)) throw refuse(`${rel(evda, abs)} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
449
+
450
+ const raw = readFileSync(abs, "utf8");
451
+ const date = localDate(now);
452
+ const heading = `## ${context} (added ${date})`;
453
+ const lead = raw.endsWith("\n") ? "" : "\n";
454
+ const section = `${lead}\n${heading}\n\n${words.trim()}\n`;
455
+
456
+ const fm = frontmatterOf(raw);
457
+ let newRaw = raw + section;
458
+ let observationId = null;
459
+ if (fm && fm.kind === "person-profile") {
460
+ const lines = raw.split("\n");
461
+ const end = lines.indexOf("---", 1);
462
+ const body = lines.slice(end + 1).join("\n");
463
+ const obs = Array.isArray(fm.observations) ? fm.observations : [];
464
+ const stamp = `${date}-${localTime(now).replace(":", "")}`;
465
+ const ids = new Set(obs.map((o) => o.id));
466
+ let id = stamp;
467
+ for (let n = 2; ids.has(id); n++) id = `${stamp}-${n}`;
468
+ const observation = { id, since: date, text: context };
469
+ const nextFm = { ...fm, observations: [...obs, observation] };
470
+ const owner = holderOf(evda).id ?? holderOf(evda).name;
471
+ const before = readEntry("person-profile", fm, { owner });
472
+ const after = readEntry("person-profile", nextFm, { owner });
473
+ const introduced = after.dto === null ? [after.problems[0]] : after.problems.filter((p) => !before.problems.includes(p));
474
+ if (introduced.length) throw refuse(`writing this observation into ${rel(evda, abs)} would leave its frontmatter invalid: ${introduced[0]}`, "BAD_PROFILE");
475
+ const newFrontmatter = `---\n${toYaml(nextFm)}---\n`;
476
+ newRaw = newFrontmatter + body + section;
477
+ observationId = id;
478
+ }
479
+ writeFileSync(abs, newRaw);
480
+ return { path: rel(evda, abs), date, observation: observationId };
481
+ }
482
+
483
+ // 3d. "Entries grow on their own" (HQ-2228): one dated section on an entry that already exists, by
484
+ // its path. In a chat app this is the only way the core's rule can be carried out, since the
485
+ // connector is the only thing that writes. It never creates a file and never touches a byte already
486
+ // there: the section goes at the end, `## YYYY-MM-DD`, and a second append the same day continues
487
+ // that section instead of opening a second heading for the day. Who may write follows who may read
488
+ // (lib/readers.mjs mayRead): a sealed entry, a noted one and a part read by a different AI are each
489
+ // refused, since the core says those are not the agent's to add to. The files other tools own are
490
+ // refused with the tool that does it: the operating layer, the day's file, a person's page, and the
491
+ // record's own machinery (circles, grants, mounts, messages, the ledger, proposals, anything at the
492
+ // root or in a dot-folder, which is where skills live).
493
+ const APPEND_ELSEWHERE = [
494
+ ["record/daily", "is the day's file: the owner's words go there with evda_write."],
495
+ ["record/people", "is a person's page: add to it with evda_record_person_note, which also keeps its observations in step."],
496
+ ["circles", "is part of how the record is shared, which only the owner changes."],
497
+ ["grants", "is part of how the record is shared, which only the owner changes."],
498
+ ["mounts", "is part of how the record is shared, which only the owner changes."],
499
+ ["messages", "is a message file, which evda_message and the phone write."],
500
+ ["ledger", "is the hypothesis ledger, which has its own format and tools."],
501
+ ];
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();
508
+ const abs = resolve(evda.dir, want);
509
+ if (!abs.startsWith(evda.dir + sep)) throw refuse(`${want} is outside the record.`, "OUTSIDE");
510
+ 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");
513
+ const a = agentsFile(evda);
514
+ 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");
518
+ for (const [role, why] of APPEND_ELSEWHERE) {
519
+ 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");
521
+ }
522
+ const may = mayRead(readContext(evda), abs, caller);
523
+ 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");
524
+ const raw = readFileSync(abs, "utf8");
525
+ const fm = frontmatterOf(raw);
526
+ 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
+ const date = localDate(now);
528
+ const heading = `## ${date}`;
529
+ const lastHeading = [...raw.matchAll(/^## .*$/gm)].pop()?.[0]?.trim();
530
+ const continued = lastHeading === heading;
531
+ const lead = raw === "" || raw.endsWith("\n") ? "" : "\n";
532
+ const add = continued ? `${lead}\n${words.trim()}\n` : `${lead}${raw === "" ? "" : "\n"}${heading}\n\n${words.trim()}\n`;
533
+ appendFileSync(abs, add);
534
+ return { path: where, date, section: continued ? "continued" : "opened" };
535
+ }
536
+
537
+ // 3e. The owner's setup answers (HQ-2228), into AGENTS.md's "What this EVÐA is", in the
538
+ // evda_record_correction shape: one named section, one named line, and nothing else in the file
539
+ // changes. The core's setup step asks each answer to be written there "in their exact words"; a chat
540
+ // app had no tool to do it. A field is written once, when it is empty: a filled one is the owner's
541
+ // to change, in their own file, so the agent never rewrites what they said.
542
+ 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"];
543
+ const SETUP_HEADING = /^## What this EV(?:Ð|D)A is[ \t]*$/m;
544
+ export function setupAnswer(evda, args = {}) {
545
+ onlyKeys("evda_setup_answer", args, ["field", "words"], "It fills one empty field of \"What this EVÐA is\" and nothing else.");
546
+ 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");
547
+ 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");
548
+ const words = args.words.trim();
549
+ if (/\n/.test(words)) throw refuse('evda_setup_answer "words" is one line: a field holds one line of their answer.', "MULTILINE");
550
+ if (words.length > WORDS_MAX) throw refuse(`evda_setup_answer "words" is ${words.length} characters; at most ${WORDS_MAX}.`, "TOO_LONG");
551
+ refuseSecrets(words, "evda_setup_answer");
552
+ const a = agentsFile(evda);
553
+ if (!a) throw refuse('this record maps no "agents" role, so it has no AGENTS.md to write the answer into.', "NO_ROLE");
554
+ if (isSealed(a.abs)) throw refuse(`${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
555
+ const raw = readFileSync(a.abs, "utf8");
556
+ // Only the owner's text: the installer's generated block is never written.
557
+ const parts = splitFence(raw);
558
+ const pieces = parts ? [["before", parts.before], ["after", parts.after]] : [["whole", raw]];
559
+ for (const [name, piece] of pieces) {
560
+ const h = SETUP_HEADING.exec(piece);
561
+ if (!h) continue;
562
+ const start = h.index + h[0].length;
563
+ const next = piece.slice(start).search(/^## /m);
564
+ const end = next < 0 ? piece.length : start + next;
565
+ const section = piece.slice(start, end);
566
+ const label = args.field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
567
+ const line = new RegExp(`^- \\*\\*${label}:\\*\\*([ \\t]*)(.*)$`, "m").exec(section);
568
+ 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");
569
+ 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");
570
+ const at = start + line.index;
571
+ const newPiece = `${piece.slice(0, at)}- **${args.field}:** ${words}${piece.slice(at + line[0].length)}`;
572
+ const newRaw = !parts ? newPiece : name === "before" ? newPiece + parts.generated + parts.after : parts.before + parts.generated + newPiece;
573
+ writeFileSync(a.abs, newRaw);
574
+ return { path: a.rel, field: args.field };
575
+ }
576
+ 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");
577
+ }
578
+
579
+ // Whether setup is unfinished: some field of "What this EVÐA is" is there and empty.
580
+ export function setupPending(agentsText) {
581
+ const h = SETUP_HEADING.exec(agentsText ?? "");
582
+ if (!h) return false;
583
+ const rest = agentsText.slice(h.index + h[0].length);
584
+ const next = rest.search(/^## /m);
585
+ const section = next < 0 ? rest : rest.slice(0, next);
586
+ return SETUP_FIELDS.some((f) => new RegExp(`^- \\*\\*${f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\*\\*[ \\t]*$`, "m").test(section));
587
+ }
588
+
191
589
  // 4. File what another AI said about the owner, verbatim, under imports/, labeled with its source
192
590
  // and date. A second import from the same source on the same day is appended to that file.
193
591
  export function importText(evda, args = {}, { now = new Date() } = {}) {
@@ -298,7 +696,8 @@ export function releaseTool(evda, args = {}, { now = new Date() } = {}) {
298
696
  }
299
697
 
300
698
  // 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.
699
+ // phone app shows as a card once paired and synced -- this call never confirms that part (HQ-2161).
700
+ // Never edits one; refuses anything secret-shaped, like every write.
302
701
  export function message(evda, args = {}, { now = new Date() } = {}) {
303
702
  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
703
  return writeMessage(evda, args, { now, refuseSecrets });