create-evda 0.1.6 → 0.1.8

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.
@@ -39,6 +39,7 @@ import { circleManifest, readCircles, writeCircle, opaqueIdOf, circlesDir, isRel
39
39
  import { grant } from "./grants.mjs";
40
40
  import { openEvda, readManifest, writeManifest } from "./manifest.mjs";
41
41
  import { EvdaError } from "./errors.mjs";
42
+ import { defaultPartsPlan } from "./setup.mjs";
42
43
 
43
44
  export const PLAN_FILE = "plan.json";
44
45
 
@@ -221,7 +222,8 @@ export function declare(dir, plan, { circlesRel = "06_Metadata/Circles", grantsR
221
222
  mkdirSync(join(dir, manifest.mapping.grants), { recursive: true });
222
223
  const evda = openEvda(dir);
223
224
  const existing = new Map((readCircles(evda) ?? []).filter((c) => c.manifest !== null).map((c) => [c.name, c.manifest]));
224
- const owner = holder ?? manifest.owner?.name ?? manifest.id;
225
+ // An owner with no name yet (create-evda run with no --owner) is named by the record id.
226
+ const owner = holder || manifest.owner?.name || manifest.id;
225
227
 
226
228
  const written = [];
227
229
  for (const c of planned.circles) {
@@ -232,6 +234,8 @@ export function declare(dir, plan, { circlesRel = "06_Metadata/Circles", grantsR
232
234
  isPublic: c.public,
233
235
  holders: [owner],
234
236
  members: numbered(c.members.map((m) => ({ path: m.path }))),
237
+ // A plan circle may start the circle's rules (Spec 07, section 6); the default parts start empty.
238
+ policy: plan.circles.find((p) => p.name === c.name)?.policy ?? null,
235
239
  description: c.public ? "Public: anyone may read it." : `Private: read by the devices granted it, and no one else.`,
236
240
  });
237
241
  written.push(writeCircle(evda, c.name, m, { overwrite: true }));
@@ -275,3 +279,22 @@ export function deviceCircles(plan, device, view = null) {
275
279
  : [];
276
280
  return { atlasId: d.atlasId, holds, never: plan.circles.map((c) => c.name).filter((n) => !holds.includes(n)), drift };
277
281
  }
282
+
283
+ // The default parts (HQ-1925): journal and private, from setup.mjs `defaultPartsPlan`, declared by
284
+ // create-evda on a new record and, on the owner's yes, over an adopted record that has none. A
285
+ // record that already has a private part is left exactly as it is, so a yes can never replace
286
+ // parts the owner declared. `dryRun` answers whether the default is needed and writes nothing.
287
+ // Returns { needed, declared, parts }, parts being the private part names it has or would have.
288
+ export function defaultParts(dir, { dryRun = false, circlesRel = "circles", grantsRel = "grants", at = new Date() } = {}) {
289
+ const evda = openEvda(dir);
290
+ const have = (readCircles(evda) ?? []).filter((c) => c.manifest && c.manifest.public !== true && !c.mount).map((c) => c.name).sort();
291
+ if (have.length) return { needed: false, declared: false, parts: have };
292
+ const m = evda.manifest;
293
+ const slug = String(m.name ?? "record").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "record";
294
+ const recordId = typeof m.id === "string" && m.id ? m.id : `${slug}.atlas`;
295
+ const plan = defaultPartsPlan(m.mapping ?? {}, recordId);
296
+ const parts = plan.circles.map((c) => c.name).sort();
297
+ if (dryRun) return { needed: true, declared: false, parts };
298
+ declare(dir, plan, { circlesRel, grantsRel, recordId, at });
299
+ return { needed: false, declared: true, parts };
300
+ }
package/lib/folders.mjs CHANGED
@@ -27,7 +27,7 @@ import { SCHEMES } from "./references.mjs";
27
27
  import { EvdaError } from "./errors.mjs";
28
28
 
29
29
  export const FOLDER_MANIFEST = "folder.evda.json";
30
- export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis"];
30
+ export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis", "message"];
31
31
  export const FOLDER_KEYS = ["type", "connectors", "views"];
32
32
  export const VIEW_KEYS_REQUIRED = ["view", "of", "in", "path"];
33
33
  export const VIEW_KEYS = [...VIEW_KEYS_REQUIRED, "status", "group"];
@@ -287,9 +287,14 @@ function plan(evda, p, { mode }) {
287
287
  if (md && pl.refs?.length) add.references = referencesYaml(pl.refs.map((ref) => ({ ref, label: refLabel(ref, i.heading ?? basename(to, ".md")) })));
288
288
  if (md && statusFor.has(pl.item)) add.status = `status: ${statusFor.get(pl.item)}`;
289
289
  if (md && pl.circle && falsified("apply-writes-share")) add.share = `share: [${pl.circle}]`;
290
+ // A credential kept in the Credentials folder is sealed (HQ-1368): evda_search, evda_recent and
291
+ // evda_read refuse it to every caller without naming it, and no tool releases it.
292
+ if (md && pl.sealed && !i.sealed) add.sealed = `${SEALED}: true`;
290
293
  // The item's own bytes, frontmatter included, so `noted: true` and `sealed: true` ride into the
291
294
  // record with it (HQ-1414). EVDA_CARRY_FALSIFY=gather-apply:<flag> drops that line on the way in.
292
295
  let bytes = withFrontmatter(buf, add);
296
+ // A note that already said `sealed: false` (or anything but true) is sealed all the same.
297
+ if (add.sealed && !/^sealed:[ \t]*true[ \t]*$/m.test(bytes.toString("utf8").split(/\n---/)[0])) bytes = Buffer.from(bytes.toString("utf8").replace(/^sealed:.*$/m, `${SEALED}: true`), "utf8");
293
298
  for (const flag of [NOTED, SEALED]) if (md && !carries("gather-apply", flag)) bytes = Buffer.from(bytes.toString("utf8").replace(new RegExp(`^${flag}:[ \\t]*true[ \\t]*\\n`, "m"), ""), "utf8");
294
299
  const st = statSync(src);
295
300
  out.writes.push({
@@ -424,7 +429,7 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
424
429
  const covers = f ? f.path + c.covers.slice(f.proposal.path.length) : c.covers;
425
430
  if (!pl.writes.some((w) => w.to.startsWith(`${covers}/`))) continue;
426
431
  const before = tree(evda.dir);
427
- const r = proposeCircle(evda, { name: c.name, who: "Only the people the owner grants it to, by name.", what: `${covers}/`, why: `The owner said yes to its own circle for these notes while gathering a pile (proposal ${p.id}).` }, { now });
432
+ const r = proposeCircle(evda, { name: c.name, who: c.readBy === "none" ? "Only the owner, on their own devices. No AI reads it (read_by: none), and no agent host holds it." : "Only the people the owner grants it to, by name.", what: `${covers}/`, why: `The owner said yes to its own circle for these notes while gathering a pile (proposal ${p.id}).` }, { now });
428
433
  const after = tree(evda.dir);
429
434
  for (const d of after.dirs.filter((x) => !before.dirs.includes(x))) t.dirs.push(d);
430
435
  t.created.push({ path: r.path, sha256: sha256(readFileSync(join(evda.dir, r.path))), size: statSync(join(evda.dir, r.path)).size });
package/lib/gather.mjs CHANGED
@@ -59,13 +59,41 @@ export const GATHER_VERSION = 1;
59
59
  // The person-layer kinds an item is typed with, and the folder type each lives under.
60
60
  export const GATHER_KINDS = ["note", "person", "project", "event", "log", "source", "piece", "blob", "capture"];
61
61
  export const FOLDER_TYPE_FOR = { note: "record", person: "person", project: "project", event: "event", log: "log", source: "source", piece: "piece", blob: "blob", capture: "capture" };
62
- export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "keep-secrets", "examine-nothing", "no-role"];
62
+ export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "credentials-ordinary", "examine-nothing", "no-role"];
63
63
  // The role each kind fills when the preset has no folder for it (Spec 07 section 2, HQ-1371):
64
64
  // what happened, dated, is journal; one folder per thing is collections; what is known is knowledge.
65
65
  export const ROLE_FOR_KIND = { note: "journal", capture: "journal", log: "journal", person: "collections", project: "collections", event: "collections", piece: "collections", blob: "collections", course: "collections", source: "knowledge", areas: "collections" };
66
66
  // A person is asked about once this many files mention them and no page for them exists.
67
67
  export const PEOPLE_MIN_FILES = 3;
68
68
 
69
+ // A credential written as a labeled line in a note (HQ-1368): "password: ...", "pw: ...", "PIN: ...",
70
+ // "wifi password: ...", "api key = ...", "token: ...", "secret: ...", recovery or backup codes, a seed
71
+ // phrase. A label at the start of the line, a separator, and a value after it; prose that only
72
+ // mentions a password ("I forgot my password again") has no value after a separator and is not one.
73
+ // A short value for a password-like label (a sentence after "Password:" is prose, not a password);
74
+ // a code or phrase label may carry many words, or its value on the next line.
75
+ const CRED_QUALIFIER = String.raw`(?:(?:wi-?fi|home|network|router|guest|email|e-mail|bank|account|admin|root|master|login|app|door|alarm|phone|sim|card|atm|debit|garage)\s+)*`;
76
+ const CRED_SHORT = String.raw`pass(?:word|wd|code|phrase)?|pwd|pw|pin(?:\s*code)?|api[\s_-]*key|access[\s_-]*key|secret(?:[\s_-]*key)?|(?:auth[\s_-]*|access[\s_-]*|api[\s_-]*)?token|private[\s_-]*key|login`;
77
+ const CRED_LONG = String.raw`(?:recovery|backup|2fa|mfa|one[\s-]*time)[\s_-]*(?:codes?|keys?|phrase)|seed[\s_-]*(?:phrase|words)|mnemonic(?:[\s_-]*phrase)?|secret[\s_-]*phrase`;
78
+ const CRED_LINE = new RegExp(String.raw`^\s*(?:[-*+•>]\s+|\d+[.)]\s+)?[*_\x60]*${CRED_QUALIFIER}(?:(${CRED_LONG})|(${CRED_SHORT}))[*_\x60]*\s*(?:[:=]|\s[-\u2013]\s)[*_\x60]*\s*(.*)$`, "i");
79
+ export function credentialLine(lines) {
80
+ const all = Array.isArray(lines) ? lines : String(lines ?? "").split("\n");
81
+ for (let n = 0; n < all.length; n++) {
82
+ const m = CRED_LINE.exec(all[n]);
83
+ if (!m) continue;
84
+ const label = (m[1] ?? m[2]).toLowerCase().replace(/[\s_-]+/g, " ");
85
+ const value = m[3].replace(/[*_\x60]+$/g, "").trim();
86
+ if (m[1]) {
87
+ if (value || (all[n + 1] ?? "").trim()) return label;
88
+ continue;
89
+ }
90
+ if (!value) continue;
91
+ if (value.split(/\s+/).length > 3) continue;
92
+ return label;
93
+ }
94
+ return null;
95
+ }
96
+
69
97
  const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
70
98
  const posix = (p) => p.split(sep).join("/");
71
99
  const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
@@ -75,6 +103,7 @@ const APP_DIRS = { ".obsidian": "obsidian" };
75
103
  const ARCHIVE_EXT = new Set([".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"]);
76
104
  const BINARY_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".heic", ".webp", ".pdf", ".mov", ".mp4", ".m4a", ".mp3", ".wav", ".xlsx", ".xls", ".docx", ".doc", ".pptx", ".key", ".pages", ".numbers", ".sqlite", ".db"]);
77
105
  const TEXT_MAX = 2 * 1024 * 1024;
106
+ const NOTE_SIZED = 20000; // characters: longer text is read for therapy words only in its name and heading
78
107
  // Folder names that hold things without saying what they are about, so they are not a subject.
79
108
  const CONTAINERS = new Set(["documents", "desktop", "downloads", "notes", "daily", "inbox", "captures", "vault", "obsidian vault", "misc", "stuff", "untitled folder", "new folder", "export", "icloud drive"]);
80
109
  const COURSE = /\b[A-Z]{2,4}\s?\d{3}[A-Z]?\b/;
@@ -256,8 +285,11 @@ function typeItem({ rel, buf, ext, projectRoot }) {
256
285
  if (ARCHIVE_EXT.has(ext)) return { ...item, kind: "blob", archive: true, why: ["an archive file"] };
257
286
  if (BINARY_EXT.has(ext) || isBinary(buf) || buf.length > TEXT_MAX) return { ...item, kind: "blob", why: [BINARY_EXT.has(ext) ? `a ${ext.slice(1)} file` : "binary bytes"] };
258
287
  const raw = buf.toString("utf8");
259
- const secret = raw.split("\n").map(secretShape).find(Boolean);
260
- if (secret && !falsified("keep-secrets")) return { ...item, kind: "note", refused: `holds ${secret}`, why: [`holds ${secret}`] };
288
+ // A file holding a secret or a credential line is never refused (his call, 2026-09-27: "just put
289
+ // it in a safer place"): it is typed from its path alone, and placed sealed in a Credentials
290
+ // folder that no AI reads or is told of, unless the owner says no. Nothing of it is quoted.
291
+ const secret = raw.split("\n").map(secretShape).find(Boolean) ?? (credentialLine(raw) ? `a ${credentialLine(raw)} line` : null);
292
+ if (secret) return { ...item, kind: "note", credential: `holds ${secret}`, why: [`holds ${secret}`], ...(DAY.test(name) ? { daily: true, dated: name } : {}) };
261
293
  if (ext === ".vcf") {
262
294
  const cards = vcards(raw);
263
295
  return { ...item, kind: "person", mode: "reference", why: [`a Contacts export of ${cards.length} card(s)`], cards: cards.map((c) => ({ name: c.name, ref: `contacts:${c.uid ?? slugId(c.name)}`, ...(falsified("copy-contacts") ? { payload: c.payload } : {}) })) };
@@ -277,7 +309,10 @@ function typeItem({ rel, buf, ext, projectRoot }) {
277
309
  const heading = headingOf(body);
278
310
  const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
279
311
  const facts = { heading, dated: LEADING_DAY.exec(name)?.[1] ?? (DAY.test(fm.date ?? "") ? fm.date : null), daily: DAY.test(name) };
280
- if (THERAPY.test(body)) facts.sensitive = { kind: "therapy", word: THERAPY.exec(body)[1] };
312
+ // A therapy note says so in its name or heading, or in the words of something note-sized. One mention deep in a book or a
313
+ // long export (a Gutenberg Meditations says "counsellor" once, 5,800 lines in) does not make it a session note.
314
+ const said = THERAPY.exec(basename(rel)) ?? THERAPY.exec(heading ?? "") ?? (body.length <= NOTE_SIZED ? THERAPY.exec(body) : null);
315
+ if (said) facts.sensitive = { kind: "therapy", word: said[1] };
281
316
  const att = ATTENDEES.exec(body);
282
317
  if (att) facts.attendees = att[1].split(/,|\band\b/).map((s) => s.trim()).filter((s) => /^[A-Z][\p{L}'-]+(?: [A-Z][\p{L}'-]+)+$/u.test(s));
283
318
  const declared = (fm.kind ?? fm.type ?? "").toLowerCase();
@@ -333,7 +368,7 @@ export function scan(sources, { now = new Date() } = {}) {
333
368
  const item = { id: `s${si}:${f.rel}`, source: `s${si}`, path: f.rel, size: f.size, sha256: sha256(buf), ...facts };
334
369
  if (projectRoot) item.project = projectRoot;
335
370
  item.subject = subjectOf(f.rel, projectRoot);
336
- if (!item.refused && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
371
+ if (!item.refused && !item.credential && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
337
372
  out.items.push(item);
338
373
  }
339
374
  });
@@ -415,6 +450,9 @@ export function choosePreset(items, projects) {
415
450
 
416
451
  // Where a kind lives under a preset: the preset's own folder when it has one, else a folder the
417
452
  // pile needs and the preset does not have, named here and marked as such.
453
+ // The folder credentials are kept in: every file sealed, so no AI reads it, no tool releases it, and
454
+ // no AI is told it is there (HQ-1368, his call 2026-09-27).
455
+ export const CREDENTIALS = "Credentials";
418
456
  const NEEDED = { note: "Notes", capture: "Captures", person: "People", project: "Projects", event: "Events", log: "Logs", source: "Sources", piece: "Writing", blob: "Attachments", areas: "Notes" };
419
457
 
420
458
  // The role a path under the preset fills: the longest mapping path at or above it.
@@ -462,21 +500,30 @@ export function place(p) {
462
500
  return NEEDED[key];
463
501
  };
464
502
  const therapyOwn = ans["circle:therapy"] === "yes";
503
+ // Credentials go to their own folder unless the owner says no; yes is the default.
504
+ const credentialsOwn = ans.credentials !== "no" && !falsified("credentials-ordinary");
465
505
  const placements = [];
466
506
  const taken = new Map();
467
507
  // The role a destination fills: the preset's mapping, else the needed folder's own role.
468
508
  const roleOf = (to) => presetRole(preset, to) ?? (falsified("no-role") ? null : undefined) ?? [...needed.values()].find((n) => to === n.path || to.startsWith(`${n.path}/`))?.role ?? null;
509
+ // Names are claimed without case: on a Mac "wifi.md" and "Wifi.md" are one file.
469
510
  const claim = (to, id) => {
470
511
  let t = to;
471
- for (let n = 2; taken.has(t) && taken.get(t) !== id; n++) t = to.replace(/(\.[^./]+)?$/, (e) => ` (${n})${e ?? ""}`);
472
- taken.set(t, id);
512
+ for (let n = 2; taken.has(t.toLowerCase()) && taken.get(t.toLowerCase()) !== id; n++) t = to.replace(/(\.[^./]+)?$/, (e) => ` (${n})${e ?? ""}`);
513
+ taken.set(t.toLowerCase(), id);
473
514
  return t;
474
515
  };
475
516
  for (const i of p.items) {
476
517
  const file = asMarkdown(i, basename(i.path));
477
518
  let pl;
478
519
  if (i.refused) pl = { status: "refused", to: null, why: `${i.refused}; left where it is and quoted nowhere. Keep the secret in a password manager.` };
479
- else if (i.present) pl = { status: "present", to: null, at: i.present, why: `already in the record at ${i.present}; left where it is` };
520
+ else if (i.credential && credentialsOwn && /\.md$/i.test(file)) {
521
+ pl = { status: "placed", to: `${CREDENTIALS}/${file}`, sealed: true, why: `${i.credential}; kept in ${CREDENTIALS}/, sealed, so only the owner opens it on their own devices, no AI reads it and no tool releases it` };
522
+ needed.set(CREDENTIALS, { path: CREDENTIALS, type: "record", role: "collections" });
523
+ } else if (i.credential && credentialsOwn) {
524
+ // Only markdown carries `sealed: true`, so a credential in any other format is never copied.
525
+ pl = { status: "refused", to: null, why: `${i.credential} and is not a note that can be sealed; left where it is and quoted nowhere. Keep the secret in a password manager.` };
526
+ } else if (i.present) pl = { status: "present", to: null, at: i.present, why: `already in the record at ${i.present}; left where it is` };
480
527
  else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file; unpack it and gather the folder" };
481
528
  else if (i.cards) pl = { status: "reference", to: null, why: "held by Contacts; a person page keeps a contacts: reference, never the card" };
482
529
  else if (i.kind === "project") {
@@ -511,6 +558,7 @@ export function place(p) {
511
558
  placements.push({ item: `new:person:${who.name}`, status: "create", to, role: roleOf(to), refs: who.ref ? [who.ref] : [], mentions: who.files.length });
512
559
  }
513
560
  const circles = therapyOwn ? [{ name: "therapy", covers: h.areas ? `${h.areas}/Therapy` : "Therapy", status: "proposed only: a proposal file at apply, never a circle" }] : [];
561
+ if (credentialsOwn && placements.some((x) => x.sealed)) circles.push({ name: "credentials", covers: CREDENTIALS, readBy: "none", status: "proposed only: a proposal file at apply, never a circle; every file in it is sealed already" });
514
562
  const used = new Map();
515
563
  for (const pl of placements) {
516
564
  if (!pl.to) continue;
@@ -542,6 +590,8 @@ function candidates(p) {
542
590
  if (!proj.finished && !falsified("ask-everything")) continue;
543
591
  out.push({ id: `finished:${proj.id}`, kind: "finished", options: ["finished", "live"], text: `${proj.name} looks finished (${basename(proj.files.find((f) => f === proj.finished?.file) ?? proj.root)} says "${proj.finished?.word ?? "nothing"}"). Is it finished, or live? It stays where it is; finished is its status.`, about: proj.name });
544
592
  }
593
+ const creds = p.items.filter((i) => i.credential && !i.present);
594
+ if (creds.length) out.push({ id: "credentials", kind: "credentials", options: ["yes", "no"], default: "yes", text: `${creds.length} note(s) look like they hold passwords or keys: keep them sealed in a Credentials folder, so only you open them on your own devices and no AI reads them?`, about: "credentials" });
545
595
  const therapy = p.items.filter((i) => i.sensitive?.kind === "therapy");
546
596
  if (therapy.length) out.push({ id: "circle:therapy", kind: "circle", options: ["yes", "no"], text: `${therapy.length} file(s) read like therapy notes. Give them their own circle, so only who you choose reads them?`, about: "therapy" });
547
597
  return out;
@@ -651,7 +701,7 @@ export function answer(p, questionId, value, { now = new Date() } = {}) {
651
701
 
652
702
  // Items it could not place, or typed only by elimination, handed to the owner's claude -p.
653
703
  export function withOwnersAi(p, { model = null, env = process.env } = {}) {
654
- const unsure = p.items.filter((i) => !i.refused && !i.sealed && !i.noted && !i.present && (i.confidence === "low" || p.placements.find((x) => x.item === i.id)?.status === "unplaced") && !i.archive);
704
+ const unsure = p.items.filter((i) => !i.refused && !i.credential && !i.sealed && !i.noted && !i.present && (i.confidence === "low" || p.placements.find((x) => x.item === i.id)?.status === "unplaced") && !i.archive);
655
705
  if (!unsure.length) { p.ai = { status: "SKIPPED", why: "nothing was unsure" }; return { ok: true, p }; }
656
706
  const kinds = GATHER_KINDS.join(", ");
657
707
  const lines = unsure.map((i) => `- ${i.id} (typed ${i.kind}${i.heading ? `, titled "${i.heading}"` : ""})`);
@@ -750,7 +800,7 @@ export function summaryText(p, { detail = false } = {}) {
750
800
  if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}`); }
751
801
  const open = p.questions.filter((q) => q.answer === undefined);
752
802
  lines.push("", open.length ? `${open.length} question(s) for the owner:` : "No open questions.");
753
- for (const q of open) lines.push(`- [${q.id}] ${q.text} (${q.options.join(" / ")})`);
803
+ for (const q of open) lines.push(`- [${q.id}] ${q.text} (${q.options.join(" / ")}${q.default ? `, default ${q.default}` : ""})`);
754
804
  return lines.join("\n");
755
805
  }
756
806
 
package/lib/mcp.mjs CHANGED
@@ -9,9 +9,11 @@
9
9
  // stdout carries protocol messages only. Nothing in this file or the tools it calls prints to
10
10
  // stdout; doctor's report is captured into the tool result.
11
11
  //
12
- // Every tool here reads and writes files on this machine, with one exception: evda_check_update
13
- // asks the npm registry for the tooling's latest version number. That is the server's only network
14
- // call, it carries nothing from the record, and it installs nothing.
12
+ // Every tool here reads and writes files on this machine, with two exceptions. evda_check_update
13
+ // asks the npm registry for the tooling's latest version number; it carries nothing from the record
14
+ // and installs nothing. The device tools (evda_pair_device, evda_sync_devices, HQ-1217) talk to the
15
+ // relay that carries the record between the owner's own devices, and what they send is sealed on
16
+ // this machine: the relay cannot read it.
15
17
 
16
18
  import { createInterface } from "node:readline";
17
19
  import { SCHEMA_VERSION } from "./manifest.mjs";
@@ -28,6 +30,7 @@ import { createSubrecord } from "./subrecord.mjs";
28
30
  import { FALSIFY } from "./mcp-auth.mjs";
29
31
  import { filingOf, FILING_MODES } from "./day-file.mjs";
30
32
  import { readManifest, writeManifest } from "./manifest.mjs";
33
+ import * as pairing from "./pairing.mjs";
31
34
 
32
35
  // Newest first. The server answers with the client's version when it supports it, and with the
33
36
  // newest otherwise; the client decides whether to continue.
@@ -54,6 +57,7 @@ export function instructions(filing) {
54
57
  "Never quiz them. Never create a circle or grant; propose one with evda_propose_circle only when they ask to share, and wait for their yes.",
55
58
  "Organization comes from the preset library (evda_presets): propose with evda_propose_structure, and apply with evda_apply_preset only after their yes.",
56
59
  "When they point you at a pile they already have (a folder, an export), call evda_gather, then ask the questions from evda_gather_questions one at a time in your own words, and record each answer with evda_gather_answer. When every question is answered and the owner says yes to the summary, apply it with evda_gather_apply and confirmed: true; tell them the reversal manifest it names.",
60
+ DISPATCH_LINE,
57
61
  PRIVACY_INSTRUCTIONS,
58
62
  ];
59
63
  return lines.join(" ");
@@ -88,6 +92,12 @@ export const PRIVACY_SECTION = [
88
92
  "- To keep a conversation out of training, the option is the provider's incognito or temporary chat. Claude's incognito chats are never used for training; ChatGPT keeps a Temporary Chat up to 30 days; others may offer the same.",
89
93
  ].join("\n");
90
94
 
95
+ // Dispatch (HQ-1246), one line on every surface an agent reads first: the server instructions and
96
+ // the operating layer. The vault's AGENTS-template.md is the core's source; until it carries this
97
+ // line, this is where it lives, and evda_operating_layer serves it above AGENTS.md.
98
+ export const DISPATCH_LINE = "Call evda_replies at the start of a run: it returns the owner's replies to your messages that you have not seen yet. When the owner should hear something from you (what you did, a question), send it with evda_message, which shows it on their phone as a card; a thing only filed waits until they go looking.";
99
+ export const DISPATCH_SECTION = `## Messages to the owner\n\n${DISPATCH_LINE}`;
100
+
91
101
  const str = (description) => ({ type: "string", description });
92
102
 
93
103
  // summarize() already names the command for the two statuses that ask the owner to run one. Over
@@ -165,7 +175,7 @@ export const TOOLS = [
165
175
  annotations: { readOnlyHint: true, openWorldHint: false },
166
176
  run: (evda, args) => {
167
177
  const r = tools.operatingLayer(evda, args);
168
- return { text: `${chatSection(evda, r.text)}\n\n${PRIVACY_SECTION}\n\n${r.text}`, data: { path: r.path } };
178
+ return { text: `${chatSection(evda, r.text)}\n\n${DISPATCH_SECTION}\n\n${PRIVACY_SECTION}\n\n${r.text}`, data: { path: r.path } };
169
179
  },
170
180
  },
171
181
  {
@@ -245,6 +255,30 @@ export const TOOLS = [
245
255
  return { text: `Noted at ${r.path}. No AI reads it until the owner says it can.`, data: r };
246
256
  },
247
257
  },
258
+ {
259
+ name: "evda_message",
260
+ title: "Tell the owner something",
261
+ description: "Sends the owner a message: writes it as one new file in the record's messages folder, which their phone app shows as a card, and returns its id and path. Use it for what they should hear from you (what you did, a question for them); a reply comes back through evda_replies. Never edits a message once written. Refuses anything shaped like a password, key or token.",
262
+ inputSchema: { type: "object", properties: { title: str("One line: what the card says at a glance."), body: str("The message, in markdown."), from: str("The agent's name. Default: the name in the operating layer.") }, required: ["title", "body"], additionalProperties: false },
263
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
264
+ run: (evda, args) => {
265
+ const r = tools.message(evda, args);
266
+ return { text: `Sent the message ${r.id}, filed at ${r.path}. The owner's reply, when there is one, comes back through evda_replies.`, data: r };
267
+ },
268
+ },
269
+ {
270
+ name: "evda_replies",
271
+ title: "Read the owner's replies",
272
+ description: "Call at the start of a run: returns the owner's replies to your messages that you have not seen yet (message id, title, reply, date) and marks them seen, so each shows once; a reply the owner changes shows again. peek: true returns them without marking.",
273
+ inputSchema: { type: "object", properties: { peek: { type: "boolean", description: "true to read without marking them seen." } }, additionalProperties: false },
274
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
275
+ run: (evda, args, ctx) => {
276
+ const r = tools.replies(evda, args, { caller: ctx.caller });
277
+ const body = r.replies.map((x) => `<reply to="${x.id}" title=${JSON.stringify(x.title)} date="${x.date}">\n${x.reply}\n</reply>`).join("\n\n");
278
+ const main = r.replies.length ? `${r.replies.length} new repl${r.replies.length === 1 ? "y" : "ies"} from the owner${args.peek ? " (peek: not marked seen)" : ""}.\n\n${body}` : "No new replies from the owner.";
279
+ return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: { replies: r.replies, marked: r.marked } };
280
+ },
281
+ },
248
282
  {
249
283
  name: "evda_filing",
250
284
  title: "Record how filing works",
@@ -496,10 +530,51 @@ export const TOOLS = [
496
530
  return { text: `${r.text}\n\nexit ${r.exit}`, data: { exit: r.exit }, isError: r.exit !== 0 };
497
531
  },
498
532
  },
533
+ {
534
+ name: "evda_pair_device",
535
+ title: "Pair a phone",
536
+ description: "Call only when the owner asks to pair, add or connect a phone or another device to their EVÐA. Returns a one-time pairing code and a QR link for the phone, and the steps to do on it. The code works once, for 10 minutes, and the device gets only the parts named (journal by default). Show the code to the owner and no one else.",
537
+ inputSchema: {
538
+ type: "object",
539
+ properties: {
540
+ name: { type: "string", description: "What to call the device, such as phone. Default phone." },
541
+ parts: { type: "array", items: { type: "string" }, description: "The private parts of the record the device may read and add to. Default [\"journal\"]." },
542
+ },
543
+ additionalProperties: false,
544
+ },
545
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
546
+ run: async (evda, args) => {
547
+ tools.onlyKeys("evda_pair_device", args, ["name", "parts"]);
548
+ const r = await pairing.pair(evda.dir, { name: args.name, parts: args.parts });
549
+ return { text: pairing.pairText(r) };
550
+ },
551
+ },
552
+ {
553
+ name: "evda_sync_devices",
554
+ title: "Sync with paired devices",
555
+ description: "Call after the owner used a pairing code on their phone, or when they ask to sync their devices: adds a device that used its code, takes in what paired devices wrote, and sends what changed here. Answers in plain words.",
556
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
557
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
558
+ run: async (evda, args) => {
559
+ tools.onlyKeys("evda_sync_devices", args, []);
560
+ return { text: (await pairing.sync(evda.dir)).join("\n") };
561
+ },
562
+ },
563
+ {
564
+ name: "evda_devices",
565
+ title: "Paired devices",
566
+ description: "Lists the devices paired with the owner's EVÐA, whether each is waiting, paired or removed, and which parts each reads. Removing one is a command the owner runs: evda remove <device>.",
567
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
568
+ annotations: { readOnlyHint: true, openWorldHint: false },
569
+ run: async (evda, args) => {
570
+ tools.onlyKeys("evda_devices", args, []);
571
+ return { text: (await pairing.devices(evda.dir)).join("\n") };
572
+ },
573
+ },
499
574
  {
500
575
  name: "evda_check_update",
501
576
  title: "Check for newer EVDA tooling",
502
- description: "Asks the npm registry whether a newer version of the EVDA tooling exists, and returns the one line that says so. At most one real check an hour; inside that hour it reports the cached result. A check that fails reports the status as UNKNOWN, never as up to date. This is the only tool that reaches the network, it sends nothing from the record, and it changes nothing: taking the update is a command the owner runs in their own terminal.",
577
+ description: "Asks the npm registry whether a newer version of the EVDA tooling exists, and returns the one line that says so. At most one real check an hour; inside that hour it reports the cached result. A check that fails reports the status as UNKNOWN, never as up to date. It sends nothing from the record, and it changes nothing: taking the update is a command the owner runs in their own terminal.",
503
578
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
504
579
  annotations: { readOnlyHint: true, openWorldHint: true },
505
580
  run: async (evda) => {
@@ -511,7 +586,7 @@ export const TOOLS = [
511
586
  ];
512
587
 
513
588
  export const RESOURCES = [
514
- { uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => { const t = tools.operatingLayer(evda).text; return `${chatSection(evda, t)}\n\n${t}`; } },
589
+ { uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => { const t = tools.operatingLayer(evda).text; return `${chatSection(evda, t)}\n\n${DISPATCH_SECTION}\n\n${t}`; } },
515
590
  {
516
591
  uri: "evda://today",
517
592
  name: "today",
@@ -0,0 +1,205 @@
1
+ // Dispatch through the record (HQ-1246): the owner's agent writes a message, the phone app shows it
2
+ // as a card, and the owner's reply comes back as a file the agent reads on its next run.
3
+ //
4
+ // The format, which the phone app (iris-mobile, the EVÐA target) reads exactly:
5
+ //
6
+ // <messages role>/YYYY-MM-DD-HHMM-<slug>.md one message, written once by the agent
7
+ // ---
8
+ // id: <the filename stem>
9
+ // from: "<agent name>"
10
+ // title: "<title>"
11
+ // date: "<ISO 8601 with offset>"
12
+ // ---
13
+ // <markdown body>
14
+ //
15
+ // <messages role>/<same stem>.reply.md the owner's reply, written by the app
16
+ // ---
17
+ // in_reply_to: <the message id>
18
+ // date: "<ISO 8601 with offset>"
19
+ // ---
20
+ // <reply text>
21
+ //
22
+ // <messages role>/.seen.json { "<id>": "<reply date seen>" }, the agent's
23
+ //
24
+ // The messages role resolves through evda.json like every other role, `messages/` when unmapped. A
25
+ // message is never edited after it is written: a second message in the same minute with the same
26
+ // slug gets `-2`, `-3`, and a file already at a name (a reply, or anything sealed) is never touched.
27
+ // "Answered" is the reply file existing, never a stored flag. What the agent has seen is keyed by the
28
+ // reply's date, so a reply the app rewrites after the agent read it shows again.
29
+ //
30
+ // Replies are read the way every other tool reads (lib/readers.mjs): a sealed reply is in no answer
31
+ // and a sealed section is cut out, a noted reply or one in a circle set to a different reader is held
32
+ // back and named, and nothing held back is marked seen.
33
+ //
34
+ // EVDA_MESSAGES_FALSIFY=<mode,...>, for the tests. Each breaks exactly one rule:
35
+ // overwrite a colliding name is written over instead of getting a suffix
36
+ // orphans a reply with no message is returned
37
+ // seen-by-id a reply whose id was ever seen stays hidden, whatever its date
38
+ // strict-seen a .seen.json that does not parse throws instead of reading as empty
39
+ // no-secrets the secret check is skipped
40
+ // default-path the evda.json mapping is ignored and messages/ is always used
41
+ // raw-slug the title goes into the filename unslugged
42
+ // no-readers replies are shown without asking lib/readers.mjs
43
+
44
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
45
+ import { join, relative, sep } from "node:path";
46
+ import { resolveRole, agentsFile } from "./roles.mjs";
47
+ import { frontmatterOf } from "./circles.mjs";
48
+ import { readContext, mayRead, heldBack, callerFrom, aiView } from "./readers.mjs";
49
+ import { localDate } from "./day-file.mjs";
50
+ import { EvdaError } from "./errors.mjs";
51
+
52
+ export const MESSAGES_ROLE = "messages";
53
+ export const MESSAGES_DEFAULT = "messages";
54
+ export const SEEN_FILE = ".seen.json";
55
+ export const REPLY_SUFFIX = ".reply.md";
56
+ export const DEFAULT_FROM = "Iris";
57
+ const TITLE_MAX = 200;
58
+ const FROM_MAX = 60;
59
+ const BODY_MAX = 200_000;
60
+ const SLUG_MAX = 60;
61
+
62
+ export const MESSAGES_FALSIFY_MODES = ["overwrite", "orphans", "seen-by-id", "strict-seen", "no-secrets", "default-path", "raw-slug", "no-readers"];
63
+ const falsified = (mode) => (process.env.EVDA_MESSAGES_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
64
+
65
+ const pad = (n) => String(n).padStart(2, "0");
66
+ const refuse = (message, code) => new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
67
+
68
+ // A local time as ISO 8601 with its offset: 2026-09-25T12:04:00-05:00.
69
+ export function isoLocal(d) {
70
+ const off = -d.getTimezoneOffset();
71
+ const sign = off >= 0 ? "+" : "-";
72
+ const a = Math.abs(off);
73
+ return `${localDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(a / 60))}:${pad(a % 60)}`;
74
+ }
75
+
76
+ // A title as a filename part: accents folded, lowercase ASCII letters and digits joined by single
77
+ // hyphens, at most 60 characters. A title with nothing left (all emoji, all Greek) is "message".
78
+ export function slugify(title) {
79
+ if (falsified("raw-slug")) return String(title);
80
+ const s = String(title).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
81
+ return s.slice(0, SLUG_MAX).replace(/-+$/, "") || "message";
82
+ }
83
+
84
+ // Where messages live: the role's mapping, else messages/ at the record root. resolveRole refuses a
85
+ // mapping that leaves the record.
86
+ export function messagesDir(evda) {
87
+ const r = falsified("default-path") ? { mapped: false } : resolveRole(evda, MESSAGES_ROLE);
88
+ if (r.mapped) {
89
+ if (r.exists && !r.isDir) throw refuse(`the messages role is mapped to ${r.rel}, which is a file; it holds a folder.`, "BAD_MAPPING");
90
+ return { abs: r.abs, rel: r.rel.replace(/\/+$/, "") };
91
+ }
92
+ return { abs: join(evda.dir, MESSAGES_DEFAULT), rel: MESSAGES_DEFAULT };
93
+ }
94
+
95
+ // The agent's name as the operating layer's owner section gives it, else Iris.
96
+ export function agentName(evda) {
97
+ try {
98
+ const a = agentsFile(evda);
99
+ if (!a) return DEFAULT_FROM;
100
+ const m = /^- \*\*Agent name:\*\*[ \t]*(\S.*)$/m.exec(readFileSync(a.abs, "utf8"));
101
+ return m?.[1]?.trim() || DEFAULT_FROM;
102
+ } catch {
103
+ return DEFAULT_FROM;
104
+ }
105
+ }
106
+
107
+ function oneLine(value, name, max) {
108
+ if (typeof value !== "string" || value.trim() === "") throw refuse(`evda_message needs "${name}".`, "EMPTY");
109
+ const v = value.trim();
110
+ if (/[\r\n]/.test(v)) throw refuse(`evda_message "${name}" is one line.`, "LINES");
111
+ if (v.length > max) throw refuse(`evda_message "${name}" is ${v.length} characters; at most ${max}.`, "TOO_LONG");
112
+ return v;
113
+ }
114
+
115
+ // Write one new message. `refuseSecrets` is passed in by lib/tools.mjs so this module and evda_write
116
+ // refuse with the same check and the same words.
117
+ export function writeMessage(evda, args, { now = new Date(), refuseSecrets } = {}) {
118
+ const title = oneLine(args.title, "title", TITLE_MAX);
119
+ if (typeof args.body !== "string" || args.body.trim() === "") throw refuse('evda_message needs "body", what you are telling the owner.', "EMPTY");
120
+ if (args.body.length > BODY_MAX) throw refuse(`evda_message "body" is ${args.body.length} characters; one message holds at most ${BODY_MAX}.`, "TOO_LONG");
121
+ const from = args.from === undefined ? agentName(evda) : oneLine(args.from, "from", FROM_MAX);
122
+ if (!falsified("no-secrets")) for (const s of [title, args.body, from]) refuseSecrets(s, "evda_message");
123
+ const dir = messagesDir(evda);
124
+ mkdirSync(dir.abs, { recursive: true });
125
+ const base = `${localDate(now)}-${pad(now.getHours())}${pad(now.getMinutes())}-${slugify(title)}`;
126
+ const taken = (stem) => existsSync(join(dir.abs, `${stem}.md`)) || existsSync(join(dir.abs, `${stem}${REPLY_SUFFIX}`));
127
+ let stem = base;
128
+ if (!falsified("overwrite")) for (let n = 2; taken(stem); n++) stem = `${base}-${n}`;
129
+ const abs = join(dir.abs, `${stem}.md`);
130
+ const text = ["---", `id: ${stem}`, `from: ${JSON.stringify(from)}`, `title: ${JSON.stringify(title)}`, `date: ${JSON.stringify(isoLocal(now))}`, "---", "", args.body.replace(/\n+$/, ""), ""].join("\n");
131
+ // "wx": never replace a file, even one that appeared between the check and the write.
132
+ writeFileSync(abs, text, { flag: falsified("overwrite") ? "w" : "wx" });
133
+ return { id: stem, path: relative(evda.dir, abs).split(sep).join("/") };
134
+ }
135
+
136
+ // What the agent has seen, as { id: reply date }. Missing, unreadable, or not a JSON object of
137
+ // strings: empty, so every reply shows and the next mark rewrites the file whole.
138
+ export function readSeen(dirAbs) {
139
+ const p = join(dirAbs, SEEN_FILE);
140
+ if (!existsSync(p)) return {};
141
+ let v;
142
+ try { v = JSON.parse(readFileSync(p, "utf8")); } catch (e) { if (falsified("strict-seen")) throw e; return {}; }
143
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return {};
144
+ return Object.fromEntries(Object.entries(v).filter(([, d]) => typeof d === "string"));
145
+ }
146
+
147
+ function writeSeen(dirAbs, seen) {
148
+ const p = join(dirAbs, SEEN_FILE);
149
+ const t = `${p}.tmp-${process.pid}`;
150
+ writeFileSync(t, JSON.stringify(seen, null, 2) + "\n");
151
+ renameSync(t, p);
152
+ }
153
+
154
+ // The text after a file's frontmatter.
155
+ function bodyOf(text) {
156
+ const lines = text.split("\n");
157
+ if (lines[0] !== "---") return text;
158
+ const end = lines.indexOf("---", 1);
159
+ return end === -1 ? text : lines.slice(end + 1).join("\n");
160
+ }
161
+
162
+ const dateString = (v) => (v instanceof Date ? v.toISOString() : typeof v === "string" && v.trim() !== "" ? v.trim() : null);
163
+
164
+ // The owner's replies the agent has not seen: { replies: [{ id, title, reply, date, path }], heldBack,
165
+ // marked }. Marks each one returned as seen unless `peek`. A reply with no message is not one.
166
+ export function unseenReplies(evda, { peek = false } = {}, { caller = callerFrom() } = {}) {
167
+ const dir = messagesDir(evda);
168
+ if (!existsSync(dir.abs) || !statSync(dir.abs).isDirectory()) return { replies: [], heldBack: "", marked: 0, dir: dir.rel };
169
+ const seen = readSeen(dir.abs);
170
+ const ctx = readContext(evda);
171
+ const refused = [];
172
+ let sealed = false;
173
+ const replies = [];
174
+ for (const name of readdirSync(dir.abs).sort()) {
175
+ if (!name.endsWith(REPLY_SUFFIX) || name.startsWith(".")) continue;
176
+ const id = name.slice(0, -REPLY_SUFFIX.length);
177
+ const abs = join(dir.abs, name);
178
+ if (!statSync(abs).isFile()) continue;
179
+ const msgAbs = join(dir.abs, `${id}.md`);
180
+ if (!existsSync(msgAbs) && !falsified("orphans")) continue;
181
+ if (!falsified("no-readers")) {
182
+ const may = mayRead(ctx, abs, caller);
183
+ if (!may.ok) { refused.push(may); continue; }
184
+ }
185
+ const raw = readFileSync(abs, "utf8");
186
+ const front = frontmatterOf(raw) ?? {};
187
+ const date = dateString(front.date) ?? statSync(abs).mtime.toISOString();
188
+ if (falsified("seen-by-id") ? id in seen : seen[id] === date) continue;
189
+ const shown = falsified("no-readers") ? { text: raw, sections: 0 } : aiView(raw);
190
+ if (shown.sections) sealed = true;
191
+ let title = id;
192
+ if (existsSync(msgAbs)) {
193
+ const mraw = readFileSync(msgAbs, "utf8");
194
+ const mfront = frontmatterOf(mraw);
195
+ if (typeof mfront?.title === "string" && mfront.title.trim()) title = mfront.title.trim();
196
+ }
197
+ replies.push({ id, title, reply: bodyOf(shown.text).replace(/^\n+|\n+$/g, ""), date, path: relative(evda.dir, abs).split(sep).join("/") });
198
+ }
199
+ replies.sort((a, b) => (a.date === b.date ? a.id.localeCompare(b.id) : a.date.localeCompare(b.date)));
200
+ if (!peek && replies.length) {
201
+ for (const r of replies) seen[r.id] = r.date;
202
+ writeSeen(dir.abs, seen);
203
+ }
204
+ return { replies, heldBack: heldBack(refused, { sealed }), marked: peek ? 0 : replies.length, dir: dir.rel };
205
+ }