create-evda 0.1.10 → 0.1.12

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/mcp.mjs CHANGED
@@ -26,6 +26,7 @@ import * as gatherApply from "./gather-apply.mjs";
26
26
  import * as adoptLib from "./adopt.mjs";
27
27
  import { checkForUpdate, summarize } from "./update-check.mjs";
28
28
  import { callerFrom } from "./readers.mjs";
29
+ import { correctionsForLayer, TAUGHT_HEADING } from "./corrections.mjs";
29
30
  import { listSkills, getSkill, writeSkill, noticeRepeat, SKILLS_REL } from "./skills.mjs";
30
31
  import { createSubrecord } from "./subrecord.mjs";
31
32
  import { FALSIFY } from "./mcp-auth.mjs";
@@ -296,16 +297,27 @@ export function coreBlock(r) {
296
297
  return [];
297
298
  }
298
299
 
299
- export function layerParts(evda, client = null) {
300
+ // HQ-2818: the layer ends with what the owner taught the agent (lib/corrections.mjs), the newest
301
+ // entries of the corrections file evda.json names, the same on every surface, counted in the chat
302
+ // budget like the core. A record that keeps them in AGENTS.md's own section already serves them in
303
+ // the owner text, so nothing is added unless a chat's budget listed that section by heading.
304
+ export function layerParts(evda, client = null, caller = null) {
300
305
  const profile = clientProfile(client?.name);
306
+ const who = caller ?? callerFrom({ clientName: client?.name ?? null });
301
307
  const r = tools.operatingLayer(evda);
302
308
  const owner = forSurface(r.text, profile.surface);
303
309
  const head = [chatSection(evda, r.text, { app: profile.app }), dispatchSection(profile.surface), PRIVACY_SECTION].join("\n\n");
304
310
  const core = coreBlock(r);
305
311
  // Everything in the result but the owner sections: head, size line, core, and the joins between.
306
312
  const fixed = head.length + 2 + SIZE_LINE_RESERVE + 2 + core.reduce((n, c) => n + c.length + 2, 0);
307
- const fit = profile.surface === "terminal" ? { text: owner.text, listed: [] } : fitChat(owner.text, fixed, profile.budget);
308
- const body = [...core, fit.text].join("\n\n");
313
+ const fitWith = (extra) => (profile.surface === "terminal" ? { text: owner.text, listed: [] } : fitChat(owner.text, fixed + extra, profile.budget));
314
+ let taught = correctionsForLayer(evda, who, { agentsPath: r.path });
315
+ let fit = fitWith(taught ? taught.text.length + 2 : 0);
316
+ if (!taught && fit.listed.includes(TAUGHT_HEADING)) {
317
+ taught = correctionsForLayer(evda, who, { agentsPath: r.path, taughtListed: findSection(owner.text, TAUGHT_HEADING, profile.surface).text });
318
+ if (taught) fit = fitWith(taught.text.length + 2);
319
+ }
320
+ const body = [...core, fit.text, ...(taught ? [taught.text] : [])].join("\n\n");
309
321
  const max = profile.max;
310
322
  const pieces = packSections(body, max - PART_RESERVE - head.length, max - PART_RESERVE);
311
323
  const n = pieces.length;
@@ -316,7 +328,7 @@ export function layerParts(evda, client = null) {
316
328
  const end = i < n - 1 ? `\n\n(Part ${i + 1} of ${n} ends here. The layer continues: call evda_operating_layer with {"part": ${i + 2}} now, before answering.)` : "";
317
329
  return `${top}${p}${end}`;
318
330
  });
319
- return { path: r.path, core: r.core?.path ?? null, parts, chars, surface: profile.surface, left: owner.left, listed: fit.listed };
331
+ return { path: r.path, core: r.core?.path ?? null, parts, chars, surface: profile.surface, left: owner.left, listed: fit.listed, corrections: taught ? { path: taught.path, served: taught.served, total: taught.total } : null };
320
332
  }
321
333
 
322
334
  // Split text into pieces of at most `max` characters (the first at most `first`), cutting where a
@@ -342,6 +354,8 @@ export function packSections(text, first, max) {
342
354
  }
343
355
 
344
356
  const str = (description) => ({ type: "string", description });
357
+ // evda_write's and evda_write_agent_log's `conversation` (HQ-2823), in one set of words for both.
358
+ const CONVERSATION_ARG = str("Leave this out in a chat: the chat is already the conversation. A hook or relay that sends each message as a separate call (a new process each time) passes the same short id on every call (letters, digits, _ and -, at most 128 characters, e.g. Claude Code's session_id), so those calls continue one section under one heading instead of opening a new one each.");
345
359
 
346
360
  // summarize() already names the command for the two statuses that ask the owner to run one. Over
347
361
  // stdio the owner may have no shell in front of them at all (the Claude desktop app), and `evda
@@ -628,9 +642,9 @@ export const TOOLS = [
628
642
  if (!Number.isInteger(part) || part < 1 || part > s.parts.length) throw tools.refuse(`evda_operating_layer "part" is a whole number from 1 to ${s.parts.length}; this section has ${s.parts.length === 1 ? "one part" : `${s.parts.length} parts`}.`, "BAD_ARGUMENT");
629
643
  return { text: s.parts[part - 1], data: { path: s.path, section: s.heading, part, parts: s.parts.length } };
630
644
  }
631
- const l = layerParts(evda, ctx.client);
645
+ const l = layerParts(evda, ctx.client, ctx.caller);
632
646
  if (!Number.isInteger(part) || part < 1 || part > l.parts.length) throw tools.refuse(`evda_operating_layer "part" is a whole number from 1 to ${l.parts.length}; this layer has ${l.parts.length === 1 ? "one part" : `${l.parts.length} parts`}.`, "BAD_ARGUMENT");
633
- const data = { path: l.path, core: l.core, part, parts: l.parts.length, chars: l.chars, listed: l.listed };
647
+ const data = { path: l.path, core: l.core, part, parts: l.parts.length, chars: l.chars, listed: l.listed, corrections: l.corrections };
634
648
  if (!opening) return { text: l.parts[part - 1], data };
635
649
  const start = openChat(evda, args, ctx, { room: clientProfile(ctx.client?.name).max - l.parts[part - 1].length - 7 - AFTER_RUN_RESERVE });
636
650
  return { text: `${l.parts[part - 1]}\n\n---\n\n${start.text}`, data: { ...data, filed: start.filed } };
@@ -687,7 +701,7 @@ export const TOOLS = [
687
701
  name: "evda_write",
688
702
  title: "File the owner's words",
689
703
  description: WRITE_DESCRIPTION.everything,
690
- inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim: filler, hedges and typos intact. Never a summary."), context: str("One line, yours, on what the conversation was about. Written above their words and labeled as not theirs. Never their words.") }, required: ["words"], additionalProperties: false },
704
+ inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim: filler, hedges and typos intact. Never a summary."), context: str("One line, yours, on what the conversation was about. Written above their words and labeled as not theirs. Never their words."), conversation: CONVERSATION_ARG }, required: ["words"], additionalProperties: false },
691
705
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
692
706
  run: async (evda, args, ctx) => {
693
707
  const dup = sameAsFiled(ctx.session, args?.words);
@@ -725,7 +739,7 @@ export const TOOLS = [
725
739
  name: "evda_write_agent_log",
726
740
  title: "File Iris's own day log",
727
741
  description: "Files the agent's own day log, separate from the owner's words. Call to record what you (the agent, not the owner) did, built, filed or found: appends to Iris's own day log, the one named sub-record at \"01_Projects/Evda/Iris - Universal Thinking Partner/memory/record/daily\", and returns the path. It takes no path. It also keeps the one pointer line to that log under the agent's heading in the owner's day for today (added, or refreshed in place, never a second line), and touches nothing else there. One section per conversation, exactly like evda_write: the first call in a conversation opens the time heading with its context line, and later calls in the same conversation continue it.",
728
- inputSchema: { type: "object", properties: { words: str("What you did, in your own words."), context: str("One line on what the conversation was about. Written above the words and labeled as not the owner's.") }, required: ["words"], additionalProperties: false },
742
+ inputSchema: { type: "object", properties: { words: str("What you did, in your own words."), context: str("One line on what the conversation was about. Written above the words and labeled as not the owner's."), conversation: CONVERSATION_ARG }, required: ["words"], additionalProperties: false },
729
743
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
730
744
  run: (evda, args, ctx) => {
731
745
  const r = tools.writeAgentLog(evda, args, { session: ctx.session });
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { readdirSync, readFileSync, statSync } from "node:fs";
21
21
  import { join } from "node:path";
22
+ import { outsideRecord } from "./record-walk.mjs";
22
23
 
23
24
  export const PROCESSING_DIR = "Processing";
24
25
  export const README_NAME = "README.md";
@@ -64,7 +65,7 @@ export function personFolderProblems(dirs, { skip = new Set() } = {}) {
64
65
  let names;
65
66
  try { names = readdirSync(d).sort(); } catch { continue; }
66
67
  for (const name of names) {
67
- if (name.startsWith(".") || name === "node_modules") continue;
68
+ if (name.startsWith(".") || outsideRecord(name)) continue;
68
69
  const p = join(d, name);
69
70
  let st;
70
71
  try { st = statSync(p); } catch { continue; }
@@ -82,7 +82,9 @@ export function handle(mode, baked, input, env = process.env) {
82
82
  const settings = JSON.parse(readFileSync(join(root, ".claude", "settings.json"), "utf8"));
83
83
  const cmd = (settings?.hooks?.PreToolUse ?? []).flatMap((m) => (m?.hooks ?? []).map((h) => h?.command)).find((c) => typeof c === "string" && GUARD_MARK.test(c));
84
84
  if (!cmd) return none;
85
- const r = spawnSync(cmd, { shell: true, input, encoding: "utf8", timeout: 25000 });
85
+ // The record's settings name the guard under $CLAUDE_PROJECT_DIR (HQ-2819), and a Cowork task's
86
+ // project is not the record, so it is set to the record for this one command.
87
+ const r = spawnSync(cmd, { shell: true, input, encoding: "utf8", timeout: 25000, env: { ...process.env, CLAUDE_PROJECT_DIR: root } });
86
88
  return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", code: r.status === 2 ? 2 : 0 };
87
89
  }
88
90
  return none;
package/lib/presets.mjs CHANGED
@@ -44,6 +44,7 @@ import { ROUTINE_KEYS } from "./routines.mjs";
44
44
  import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
45
45
  import { EvdaError } from "./errors.mjs";
46
46
  import { PERSON_FOLDER_README, PROCESSING_DIR, README_NAME } from "./person-folders.mjs";
47
+ import { outsideRecord } from "./record-walk.mjs";
47
48
 
48
49
  export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
49
50
  export const PRESET_FILE = "preset.json";
@@ -66,7 +67,7 @@ function pathProblem(p) {
66
67
  if (p.startsWith("/")) return "is absolute; paths are relative to the record root";
67
68
  for (const seg of p.split("/")) {
68
69
  if (seg === "" || seg === "." || seg === "..") return `has an empty, . or .. segment`;
69
- if (seg.startsWith(".") || seg === "node_modules") return `has the segment "${seg}", which doctor does not read`;
70
+ if (seg.startsWith(".") || outsideRecord(seg)) return `has the segment "${seg}", which doctor does not read`;
70
71
  }
71
72
  return null;
72
73
  }
@@ -23,28 +23,157 @@
23
23
  //
24
24
  // EVDA_CHAT_FALSIFY=ignore-filing files even when evda.json does not say "everything", for the test
25
25
  // that shows the hook reads the owner's answer.
26
+ //
27
+ // ON A MACHINE THAT DOES NOT WRITE THE DAY (HQ-2817). EVDA_DAY_WRITER set means another machine
28
+ // writes the daily file, and a local append is the iCloud race HQ-2165 exists to stop (lib/
29
+ // day-writer.mjs). There the hook never touches the day file. It hands the words to the command in
30
+ // EVDA_DAY_WRITER_SEND, which receives evda_write's arguments as JSON on stdin and exits 0 once the
31
+ // writer filed them (Mike's: ssh to Hestia and its evda-tool.mjs evda_write). Each message is first
32
+ // saved as one JSON file in this machine's own outbox, outside the record and outside any synced
33
+ // folder (EVDA_PROMPT_OUTBOX, default ~/.evda/outbox), then the outbox is sent oldest first and a
34
+ // file is deleted only after its send exits 0. So a message said while the writer is unreachable
35
+ // waits there and goes on the next message that reaches it, filed once:
36
+ // - one drainer: only this machine's hook reads its outbox, so no other machine can file it too;
37
+ // - one sender per file: a send first renames the file to `.sending`, an atomic claim, so two
38
+ // sessions submitting at once never send the same file; a claim older than SENDING_STALE_MS
39
+ // (a hook that was killed mid-send) is put back for the next run.
40
+ // The whole send is bounded by EVDA_DAY_WRITER_SEND_MS (default 6000) because Claude Code holds the
41
+ // owner's message until this hook returns. One case can still file twice: a send that reached the
42
+ // writer and timed out before it answered. A connect timeout on the sender's side keeps that rare.
43
+ // With EVDA_DAY_WRITER set and no EVDA_DAY_WRITER_SEND, nothing on this machine can reach the
44
+ // writer, so nothing is queued and the hook tells the model to file the message with evda_write.
26
45
 
27
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
46
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
28
47
  import { dirname, join, relative } from "node:path";
29
- import { tmpdir } from "node:os";
48
+ import { homedir, hostname, tmpdir } from "node:os";
49
+ import { spawnSync } from "node:child_process";
30
50
  import { fileURLToPath } from "node:url";
31
- import { dayRole, findRecord, filingOf, appendToDay, localDate } from "./day-file.mjs";
51
+ import { dayRole, findRecord, filingOf, appendToDay, localDate, isConversationId } from "./day-file.mjs";
32
52
  import { secretShape } from "./secret-shape.mjs";
33
53
 
34
54
  export const PROMPT_HOOK_NAME = "evda-file-prompt.mjs";
35
55
 
36
56
  const falsified = (mode) => (process.env.EVDA_CHAT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
37
57
 
58
+ export const DAY_WRITER_SEND_ENV = "EVDA_DAY_WRITER_SEND";
59
+ export const PROMPT_OUTBOX_ENV = "EVDA_PROMPT_OUTBOX";
60
+ export const SEND_BUDGET_MS = 6000;
61
+ export const SENDING_STALE_MS = 2 * 60 * 1000;
62
+
63
+ export function promptOutboxDir(env = process.env) {
64
+ return env[PROMPT_OUTBOX_ENV] || join(homedir(), ".evda", "outbox");
65
+ }
66
+
67
+ const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
68
+
69
+ // Save one message for the writer: a new file per message, never rewritten, in this machine's outbox.
70
+ // `conversation` is the Claude Code session it was said in, kept with it so a message that waited
71
+ // still continues its conversation's section when it is sent.
72
+ export function queuePrompt(root, { words, context, conversation = null, now = new Date(), env = process.env }) {
73
+ const dir = promptOutboxDir(env);
74
+ mkdirSync(dir, { recursive: true });
75
+ const at = now.toISOString();
76
+ const stamp = at.replace(/[-:.]/g, "");
77
+ let file = join(dir, `${stamp}-${process.pid}.json`);
78
+ for (let n = 2; existsSync(file); n++) file = join(dir, `${stamp}-${process.pid}-${n}.json`);
79
+ writeFileSync(file, JSON.stringify({ tool: "evda_write", record: realOr(root), words, context, ...(conversation ? { conversation } : {}), at, host: hostname().split(".")[0] || "host" }, null, 1) + "\n", { flag: "wx" });
80
+ return file;
81
+ }
82
+
83
+ // Send this record's queued messages to the writer, oldest first, within `budgetMs`. Returns
84
+ // { sent: [file...], left, error }. Stops at the first failure, so the order of the day is kept.
85
+ // A claim's age is measured against the file system's own clock (the mtime of a file touched now),
86
+ // never Date.now(): a claim's mtime is stamped by the file system, and the two clocks can disagree.
87
+ export function drainPromptOutbox(root, { env = process.env, budgetMs = Number(env.EVDA_DAY_WRITER_SEND_MS) || SEND_BUDGET_MS, nowMs = null } = {}) {
88
+ const cmd = (env[DAY_WRITER_SEND_ENV] ?? "").trim();
89
+ const dir = promptOutboxDir(env);
90
+ const out = { sent: [], left: 0, error: null };
91
+ if (!cmd || !existsSync(dir)) return out;
92
+ if (nowMs === null) { const probe = join(dir, ".now"); writeFileSync(probe, ""); nowMs = statSync(probe).mtimeMs; }
93
+ const rec = realOr(root);
94
+ for (const f of readdirSync(dir).filter((n) => n.endsWith(".json.sending"))) {
95
+ const abs = join(dir, f);
96
+ try { if (nowMs - statSync(abs).mtimeMs > SENDING_STALE_MS) renameSync(abs, abs.replace(/\.sending$/, "")); } catch { /* another run took it back */ }
97
+ }
98
+ const mine = readdirSync(dir).filter((n) => n.endsWith(".json")).sort().filter((n) => {
99
+ try { return JSON.parse(readFileSync(join(dir, n), "utf8")).record === rec; } catch { return false; }
100
+ });
101
+ const deadline = Date.now() + budgetMs;
102
+ for (let i = 0; i < mine.length; i++) {
103
+ const abs = join(dir, mine[i]);
104
+ const claim = `${abs}.sending`;
105
+ try { renameSync(abs, claim); } catch { continue; } // another session claimed it
106
+ let entry;
107
+ try { entry = JSON.parse(readFileSync(claim, "utf8")); } catch { renameSync(claim, abs); continue; }
108
+ const left = deadline - Date.now();
109
+ const args = { words: entry.words, ...(entry.context ? { context: entry.context } : {}), ...(isConversationId(entry.conversation) ? { conversation: entry.conversation } : {}) };
110
+ const r = left > 200 ? spawnSync(cmd, { shell: true, input: JSON.stringify(args), encoding: "utf8", timeout: left, env }) : { status: null, error: { code: "ETIMEDOUT" } };
111
+ if (r.status === 0) { unlinkSync(claim); out.sent.push(abs); continue; }
112
+ try { renameSync(claim, abs); } catch { /* kept as a claim; the stale rule puts it back */ }
113
+ out.error = r.error ? (r.error.code === "ETIMEDOUT" ? `no answer in ${budgetMs}ms` : r.error.code ?? r.error.message) : `exited ${r.status ?? r.signal}: ${String(r.stderr ?? "").trim().split("\n").pop() ?? ""}`.trim();
114
+ out.left = mine.length - i;
115
+ break;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ const timeOf = (d) => `${localDate(d)} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
121
+
122
+ // The non-writer path: queue, send, and say what happened. Never touches the day file.
123
+ //
124
+ // Every send is a separate evda_write on the writer, so each names its conversation, Claude Code's
125
+ // session_id, and the writer continues that conversation's section (HQ-2823, lib/conversation.mjs).
126
+ // The context line goes with every send: the writer files it once, on the conversation's first
127
+ // write, and again only if that section is gone (edited away, or its state older than two days),
128
+ // so a continuing conversation never opens a bare heading. A session_id that is not an id's shape
129
+ // is not sent, and that message opens its own section, as every message did before.
130
+ function viaWriter(root, prompt, now, env, sid) {
131
+ const writer = env.EVDA_DAY_WRITER.trim();
132
+ if (!(env[DAY_WRITER_SEND_ENV] ?? "").trim()) {
133
+ return `evda: this machine does not write the daily file (EVDA_DAY_WRITER is ${writer}) and has no ${DAY_WRITER_SEND_ENV} to reach it, so the owner's message above is NOT filed yet. File it verbatim with evda_write this turn.`;
134
+ }
135
+ const conversation = isConversationId(sid) ? sid : null;
136
+ const file = queuePrompt(root, { words: prompt, context: `Claude Code on ${hostname().split(".")[0]}, said ${timeOf(now)}`, conversation, now, env });
137
+ const r = drainPromptOutbox(root, { env });
138
+ if (r.sent.includes(file)) {
139
+ const more = r.sent.length > 1 ? ` (with ${r.sent.length - 1} message(s) queued earlier)` : "";
140
+ return `evda: the owner's message above is already filed verbatim through the day writer (${writer})${more}. Do not file it again with evda_write.`;
141
+ }
142
+ return `evda: the day writer (${writer}) could not be reached (${r.error ?? "not sent"}), so the owner's message above is queued in ${dirname(file)} on this machine and goes to the writer with their next message. Do not file it again with evda_write; if you tell them, say it is queued, not filed.`;
143
+ }
144
+
38
145
  function stateFile(sid) {
39
146
  const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
40
147
  mkdirSync(dir, { recursive: true });
41
148
  return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
42
149
  }
43
150
 
151
+ // The keep hook reads this (HQ-2817): on a machine that does not write the day, the day file does
152
+ // not change here when a message is filed, so the time it was handed over is the evidence.
153
+ function markFiled(sid, now) {
154
+ const sf = stateFile(sid ?? "unknown");
155
+ let session = {};
156
+ try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* none yet */ }
157
+ writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
158
+ }
159
+
160
+ // Claude Code delivers some of its own text through UserPromptSubmit: a background task's
161
+ // completion arrives as a <task-notification> block, and context it adds rides in <system-reminder>
162
+ // blocks. Neither is the owner's, and filing them put whole subagent reports into an owner's day as
163
+ // his words (HQ-2823). Strip those blocks; what is left is what the owner typed, or nothing.
164
+ export const HARNESS_BLOCKS = ["task-notification", "system-reminder"];
165
+ export function ownerWords(prompt) {
166
+ const text = String(prompt);
167
+ let out = text;
168
+ for (const tag of HARNESS_BLOCKS) out = out.replace(new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, "g"), "");
169
+ if (out === text) return text; // nothing of the harness's: the words stay byte for byte
170
+ return out.trim() === "" ? "" : out.replace(/^\s*\n/, "").replace(/\n\s*$/, "");
171
+ }
172
+
44
173
  // One UserPromptSubmit event in, the hook's stdout out ("" for nothing to say). Never throws.
45
- export function run(payload, { now = new Date() } = {}) {
174
+ export function run(payload, { now = new Date(), env = process.env } = {}) {
46
175
  try {
47
- const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
176
+ const prompt = ownerWords(typeof payload?.prompt === "string" ? payload.prompt : "");
48
177
  if (!prompt.trim() || /^\s*\//.test(prompt)) return "";
49
178
  const here = dirname(fileURLToPath(import.meta.url));
50
179
  const root = findRecord(payload.cwd || process.cwd()) ?? findRecord(here);
@@ -55,6 +184,11 @@ export function run(payload, { now = new Date() } = {}) {
55
184
  if (!role) return "";
56
185
  const shape = prompt.split("\n").map(secretShape).find(Boolean);
57
186
  if (shape) return `evda: this message was not filed, because it holds ${shape}. Keep that in a password manager; file the rest of what they said with evda_write if it is worth keeping.`;
187
+ if ((env.EVDA_DAY_WRITER ?? "").trim()) {
188
+ const said = viaWriter(root, prompt, now, env, payload.session_id);
189
+ if ((env[DAY_WRITER_SEND_ENV] ?? "").trim()) markFiled(payload.session_id, now); // sent or queued to send
190
+ return said;
191
+ }
58
192
  const dayAbs = join(root, manifest.mapping[role].replace(/\/+$/, ""));
59
193
  mkdirSync(dayAbs, { recursive: true });
60
194
  const abs = join(dayAbs, `${localDate(now)}.md`);
@@ -62,7 +196,7 @@ export function run(payload, { now = new Date() } = {}) {
62
196
  let session = {};
63
197
  try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* the first prompt of the session */ }
64
198
  appendToDay(abs, { now, words: prompt, session });
65
- writeFileSync(sf, JSON.stringify(session) + "\n");
199
+ writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
66
200
  const rel = relative(root, abs).split("\\").join("/");
67
201
  return `evda: the owner's message above is already filed verbatim in ${rel}. Do not file it again with evda_write.`;
68
202
  } catch {
package/lib/readme.mjs ADDED
@@ -0,0 +1,145 @@
1
+ // README.md, the owner-facing half of the operating layer (HQ-2812).
2
+ //
3
+ // AGENTS.md sends the agent to sections of README.md for the facts it must never give from memory:
4
+ // the steps for turning training off, what each AI company says, and running a model locally. A
5
+ // record create-evda makes has them, because it writes README.md from templates/README.md. A record
6
+ // `evda adopt` makes keeps the owner's own README.md, which has none of them, so the privacy step
7
+ // pointed at nothing. This module renders the README once, for both, and keeps those sections in an
8
+ // adopted README as one marked block the owner's text never shares:
9
+ //
10
+ // <the owner's README, byte for byte>
11
+ // <!-- evda:readme -->
12
+ // _lead line_
13
+ // ## Turning training off ... ## Running a model on your own computer ...
14
+ // <!-- /evda:readme -->
15
+ //
16
+ // `ensureReadme` writes the file when it is absent, appends the block when a cited section is
17
+ // missing, and refreshes the block's content when it is there (`evda upgrade`). A README that already
18
+ // holds every cited heading outside a block (create-evda's own) is left alone.
19
+
20
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { trainingSection, termsSection, INTRO } from "./setup.mjs";
24
+
25
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
26
+ export const README_REL = "README.md";
27
+ export const README_OPEN = "<!-- evda:readme -->";
28
+ export const README_CLOSE = "<!-- /evda:readme -->";
29
+ export const README_LEAD = "_EVÐA wrote this block and `evda upgrade` keeps it current. Everything above it is yours, and EVÐA never changes it._";
30
+ // The first and the last heading of templates/README.md the block carries, in that file's order.
31
+ const FIRST = "## Turning training off";
32
+ const AFTER = "## Getting started";
33
+
34
+ // The local-model doc as a README section: its title dropped and its headings one level down.
35
+ export function localModelSection() {
36
+ const text = readFileSync(join(PKG, "docs", "local-model.md"), "utf8");
37
+ return text.split("\n").slice(1).join("\n").replace(/^## /gm, "### ").trim();
38
+ }
39
+
40
+ // The README create-evda writes into a new record.
41
+ export function renderReadme() {
42
+ let text = readFileSync(join(PKG, "templates", "README.md"), "utf8");
43
+ const vars = { training: trainingSection(), terms: termsSection(), intro: INTRO, localModel: localModelSection() };
44
+ for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`{{${k}}}`, v);
45
+ return text;
46
+ }
47
+
48
+ // The sections AGENTS.md cites, cut from the rendered README rather than written a second time.
49
+ export function readmeSections() {
50
+ const text = renderReadme();
51
+ const from = text.indexOf(FIRST);
52
+ const to = text.indexOf(AFTER);
53
+ if (from === -1 || to === -1 || to < from) throw new Error(`templates/README.md no longer has "${FIRST}" before "${AFTER}", so the adopted README block cannot be cut from it.`);
54
+ return text.slice(from, to).trimEnd();
55
+ }
56
+
57
+ export function readmeBlock() {
58
+ return `${README_OPEN}\n${README_LEAD}\n\n${readmeSections()}\n${README_CLOSE}\n`;
59
+ }
60
+
61
+ // Every README.md section an operating layer sends the agent to: a quoted heading followed by
62
+ // `section of` or `in`, then `README.md`, line breaks inside the quote allowed.
63
+ export function readmeCitations(agentsText) {
64
+ const flat = String(agentsText).replace(/\s+/g, " ");
65
+ const out = [];
66
+ for (const m of flat.matchAll(/"([^"]{2,120})"(?: section)? (?:of|in) `?README\.md`?/g)) if (!out.includes(m[1])) out.push(m[1]);
67
+ return out;
68
+ }
69
+
70
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
71
+ export const hasHeading = (text, heading) => new RegExp(`^#{1,6}[ \\t]+${escape(heading)}[ \\t]*#*[ \\t]*$`, "m").test(text);
72
+
73
+ // The cited headings README text lacks.
74
+ export function missingHeadings(readmeText, citations) {
75
+ return citations.filter((h) => !hasHeading(readmeText ?? "", h));
76
+ }
77
+
78
+ // The block's place in README text: { start, end } around the markers (end past the closing line
79
+ // break), or null when there is none. Two blocks, or an open with no close, is a problem, not a block.
80
+ export function findBlock(text) {
81
+ const open = text.indexOf(README_OPEN);
82
+ const close = text.indexOf(README_CLOSE);
83
+ if (open === -1 && close === -1) return null;
84
+ if (open === -1 || close === -1 || close < open || text.indexOf(README_OPEN, open + 1) !== -1 || text.indexOf(README_CLOSE, close + 1) !== -1) {
85
+ throw new Error(`${README_REL} has a broken ${README_OPEN} block (one marker without the other, or two of them). Fix the markers by hand; nothing was changed.`);
86
+ }
87
+ let end = close + README_CLOSE.length;
88
+ if (text[end] === "\n") end += 1;
89
+ return { start: open, end };
90
+ }
91
+
92
+ // What the README should become. Returns { action, text, added }:
93
+ // "written" absent: the whole README create-evda writes
94
+ // "appended" a cited section was missing and there was no block: the block is added at the end
95
+ // "refreshed" the block was there with other content: its content is replaced
96
+ // "current" the block is there and matches
97
+ // "present" no block, and every cited heading is already in the owner's text
98
+ // `added` is exactly what "appended" put after the owner's bytes, so a revert can take it back out.
99
+ export function planReadme(text, citations) {
100
+ if (text === null) return { action: "written", text: renderReadme(), added: null };
101
+ const block = readmeBlock();
102
+ const at = findBlock(text);
103
+ if (at) {
104
+ const next = text.slice(0, at.start) + block + text.slice(at.end);
105
+ return next === text ? { action: "current", text, added: null } : { action: "refreshed", text: next, added: null };
106
+ }
107
+ if (!missingHeadings(text, citations).length) return { action: "present", text, added: null };
108
+ const added = `${text === "" || text.endsWith("\n") ? "" : "\n"}\n${block}`;
109
+ return { action: "appended", text: text + added, added };
110
+ }
111
+
112
+ // Apply planReadme to <dir>/README.md. `citations` defaults to the record's AGENTS.md, or the shipped
113
+ // template's when there is none.
114
+ export function ensureReadme(dir, { citations = null, dry = false, agents = null } = {}) {
115
+ const abs = join(dir, README_REL);
116
+ const cites = citations ?? readmeCitations(agents ?? readFileSync(join(PKG, "templates", "AGENTS.md"), "utf8"));
117
+ const before = existsSync(abs) ? readFileSync(abs, "utf8") : null;
118
+ const plan = planReadme(before, cites);
119
+ if (!dry && plan.text !== before) writeFileSync(abs, plan.text);
120
+ return { ...plan, abs, rel: README_REL, citations: cites };
121
+ }
122
+
123
+ // README text with the block taken back out: exactly what was there before an "appended", when the
124
+ // owner did not edit around the block. Null when there is no block to take.
125
+ export function withoutBlock(text, added) {
126
+ if (added && text.endsWith(added)) return text.slice(0, -added.length);
127
+ let at;
128
+ try { at = findBlock(text); } catch { return null; }
129
+ if (!at) return null;
130
+ // The line breaks the adopt put in front of the block go with it.
131
+ const lead = added ? added.slice(0, added.indexOf(README_OPEN)) : "";
132
+ const start = lead && text.slice(at.start - lead.length, at.start) === lead ? at.start - lead.length : at.start;
133
+ return text.slice(0, start) + text.slice(at.end);
134
+ }
135
+
136
+ export function readmeLine(r) {
137
+ const names = r.citations.map((c) => `"${c}"`).join(", ");
138
+ switch (r.action) {
139
+ case "written": return `README.md: written, with ${names}`;
140
+ case "appended": return `README.md: EVÐA's block added at the end, with ${names}; your text above it is unchanged`;
141
+ case "refreshed": return "README.md: EVÐA's block refreshed to what this tool ships; your text around it is unchanged";
142
+ case "current": return "README.md: EVÐA's block already matches what this tool ships";
143
+ default: return `README.md: already has ${names}; left alone`;
144
+ }
145
+ }
@@ -0,0 +1,9 @@
1
+ // The one rule every walker over the record uses for what is outside it (HQ-2811): a folder named
2
+ // node_modules (packages, not notes), and any name ending in .nosync, the iCloud convention for
3
+ // "do not sync this". A .nosync folder is one the owner chose to keep out of sync, which is the same
4
+ // as keeping it out of the record; Mike's vault keeps its packages in node_modules.nosync, and the
5
+ // bears-on notice once named a vendored eslint doc in it as an existing entry. Dot-names and app
6
+ // folders stay each walker's own choice, since they differ by what the walk is for.
7
+ export function outsideRecord(name) {
8
+ return name === "node_modules" || name.endsWith(".nosync");
9
+ }
@@ -23,12 +23,19 @@
23
23
  // 8. the nonce is one this phone issued, unused, and young enough;
24
24
  // 9. REPORTDATA equals reportDataFor(nonce, tlsPublicKey), which binds the quote to the TLS key the
25
25
  // phone then pins.
26
+ // 10. when the VM names a key-wrapping key (lib/attested-wrap.mjs), REPORTDATA also binds it, so the
27
+ // key a client wraps circle keys to is one the attested VM made, not one the host operator chose.
26
28
  //
27
29
  // The binding, exactly: REPORTDATA (64 bytes) = SHA-512( "EVDA-TDX-REPORTDATA-v1" || 0x00 ||
28
30
  // nonce (32 bytes) || SPKI DER of the TLS public key ). The nonce has a fixed length and SPKI DER
29
31
  // carries its own length, so the concatenation is unambiguous. The VM computes the same value with
30
32
  // the same function when it asks the TDX module for a quote.
31
33
  //
34
+ // With a key-wrapping key, REPORTDATA = SHA-512( "EVDA-TDX-REPORTDATA-v2" || 0x00 || nonce (32 bytes) ||
35
+ // SPKI DER of the TLS public key || the X25519 wrap public key (32 raw bytes) ). The wrap key has a fixed
36
+ // length and comes last, after a self-delimiting SPKI, and the tag differs, so neither form can be read
37
+ // as the other.
38
+ //
32
39
  // Not checked here yet: certificate revocation (Intel's PCK and root CRLs), quote v5 (refused as
33
40
  // unknown rather than half-checked), the TD relaunch advice v5 quotes carry, and whether the expected
34
41
  // measurements are themselves in Sigstore's log (lib/measurement-log.mjs checks a digest; tying the
@@ -71,11 +78,20 @@ const spkiDer = (key) => {
71
78
  return createPublicKey(key).export({ type: "spki", format: "der" });
72
79
  };
73
80
 
74
- /** The 64 bytes a TD must put in REPORTDATA to bind `nonce` (32 bytes) and `tlsPublicKey` to its quote. */
75
- export function reportDataFor(nonce, tlsPublicKey) {
81
+ /** The domain tag when REPORTDATA also binds the VM's key-wrapping key. */
82
+ export const REPORTDATA_WRAP_TAG = "EVDA-TDX-REPORTDATA-v2";
83
+
84
+ /**
85
+ * The 64 bytes a TD must put in REPORTDATA to bind `nonce` (32 bytes) and `tlsPublicKey` to its quote,
86
+ * and, when given, `wrapPublicKey` (the raw 32-byte X25519 key the VM made inside itself).
87
+ */
88
+ export function reportDataFor(nonce, tlsPublicKey, wrapPublicKey) {
76
89
  const n = Buffer.from(nonce);
77
90
  if (n.length !== 32) throw new Error("the nonce must be 32 bytes");
78
- return createHash("sha512").update(Buffer.concat([Buffer.from(REPORTDATA_TAG, "ascii"), Buffer.from([0]), n, spkiDer(tlsPublicKey)])).digest();
91
+ if (wrapPublicKey == null) return createHash("sha512").update(Buffer.concat([Buffer.from(REPORTDATA_TAG, "ascii"), Buffer.from([0]), n, spkiDer(tlsPublicKey)])).digest();
92
+ const w = Buffer.from(wrapPublicKey);
93
+ if (w.length !== 32) throw new Error("the wrap key must be 32 bytes");
94
+ return createHash("sha512").update(Buffer.concat([Buffer.from(REPORTDATA_WRAP_TAG, "ascii"), Buffer.from([0]), n, spkiDer(tlsPublicKey), w])).digest();
79
95
  }
80
96
 
81
97
  /**
@@ -381,8 +397,10 @@ function checkQuote({ quote, collateral, minTcb, now, trust }) {
381
397
  * nonce: the 32 bytes the phone sent; nonces: the createNonceBook that issued it (spent here, pass or fail).
382
398
  * tlsPublicKey: the TLS server key of this connection (KeyObject, PEM, or SPKI DER).
383
399
  * expected: { mrtd, rtmr0, rtmr1, rtmr2, rtmr3 } as hex. Every one must be named.
400
+ * wrapPublicKey: the VM's key-wrapping key as the host named it (32 raw bytes), or absent. When given,
401
+ * the quote must bind it too, and the result names it.
384
402
  */
385
- export function verifyTdxAttestation({ quote, nonce, nonces, tlsPublicKey, expected, minTcb, collateral, now, trust = TDX_TRUST } = {}) {
403
+ export function verifyTdxAttestation({ quote, nonce, nonces, tlsPublicKey, wrapPublicKey, expected, minTcb, collateral, now, trust = TDX_TRUST } = {}) {
386
404
  try {
387
405
  if (!nonces?.take) return fail("no nonce book was given, so freshness cannot be checked");
388
406
  const at = now === undefined ? undefined : new Date(now).getTime();
@@ -400,12 +418,14 @@ export function verifyTdxAttestation({ quote, nonce, nonces, tlsPublicKey, expec
400
418
  // 7. The code is the code expected.
401
419
  for (const n of MEASUREMENT_NAMES) if (r.body[n] !== expected[n]) return fail(`${n.toUpperCase()} is ${r.body[n].slice(0, 16)}..., not the expected ${expected[n].slice(0, 16)}...`);
402
420
 
403
- // 9. The quote is for this nonce and this TLS key.
404
- const want = reportDataFor(nonce, tlsPublicKey);
405
- if (!timingSafeEqual(want, r.body.reportData)) return fail("REPORTDATA does not bind this nonce and this TLS key");
421
+ // 9 and 10. The quote is for this nonce, this TLS key and, when named, this wrap key.
422
+ const want = reportDataFor(nonce, tlsPublicKey, wrapPublicKey);
423
+ if (!timingSafeEqual(want, r.body.reportData)) return fail(wrapPublicKey == null ? "REPORTDATA does not bind this nonce and this TLS key" : "REPORTDATA does not bind this nonce, this TLS key and this wrap key");
406
424
 
407
425
  const measurements = Object.fromEntries(MEASUREMENT_NAMES.map((n) => [n, r.body[n]]));
408
- return { ok: true, measurements, tcb: r.tcb, tlsPublicKeySha256: sha256(spkiDer(tlsPublicKey)).toString("hex") };
426
+ const out = { ok: true, measurements, tcb: r.tcb, tlsPublicKeySha256: sha256(spkiDer(tlsPublicKey)).toString("hex") };
427
+ if (wrapPublicKey != null) out.wrapPublicKey = Buffer.from(wrapPublicKey);
428
+ return out;
409
429
  } catch (err) {
410
430
  return fail(`the attestation could not be checked: ${err.message}`);
411
431
  }
package/lib/tools.mjs CHANGED
@@ -41,7 +41,8 @@ import { secretShape } from "./references.mjs";
41
41
  import { doctor } from "./doctor.mjs";
42
42
  import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY, isSealedFile, isNotedFile } from "./readers.mjs";
43
43
  import { EvdaError } from "./errors.mjs";
44
- import { dayRole, appendToDay, setPointerLine, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
44
+ import { dayRole, appendToDay, setPointerLine, CONTEXT_LABEL, localDate, localTime, isConversationId, CONVERSATION_MAX } from "./day-file.mjs";
45
+ import { loadConversation, saveConversation } from "./conversation.mjs";
45
46
  import { writeMessage, unseenReplies, agentName } from "./messages.mjs";
46
47
  import { acknowledge as writeAcknowledgement, readAcknowledged, acknowledgementsFor } from "./acknowledge.mjs";
47
48
  import { openEvda, holderOf } from "./manifest.mjs";
@@ -309,8 +310,13 @@ export const CONTEXT_MAX = 240;
309
310
  // another tool's own name when it reuses this same append-only logic against a different day
310
311
  // file (evda_write_agent_log, against Iris's own sub-record). The behavior is identical either
311
312
  // way; only what a refusal calls itself changes.
313
+ //
314
+ // `conversation` (HQ-2823) is an id a hook or relay names so that separate calls, each in a fresh
315
+ // process with no `session` of its own, continue one section: its section state is loaded from and
316
+ // saved to lib/conversation.mjs, outside the record. Given, it is the conversation, in place of
317
+ // `session`. A chat app holds its session already and never sends it.
312
318
  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.");
319
+ onlyKeys(toolName, args, ["words", "context", "conversation"], "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
320
  const words = text(args.words, "words", toolName);
315
321
  refuseSecrets(words, toolName);
316
322
  let context = null;
@@ -321,6 +327,10 @@ export function write(evda, args = {}, { now = new Date(), session = null, toolN
321
327
  refuseSecrets(args.context, toolName);
322
328
  context = args.context.trim();
323
329
  }
330
+ const conversation = args.conversation;
331
+ if (conversation !== undefined && !isConversationId(conversation)) {
332
+ throw refuse(`${toolName} "conversation" is a short id a hook or relay sends so separate calls continue one section: letters, digits, "_" and "-" only, at most ${CONVERSATION_MAX} characters. A chat app leaves it out.`, "CONVERSATION");
333
+ }
324
334
  const abs = join(evda.dir, todayPath(evda, { now }));
325
335
  mkdirSync(dirname(abs), { recursive: true });
326
336
  // The owner's day only: evda_write, and the tools that file the owner's message along with them.
@@ -328,11 +338,15 @@ export function write(evda, args = {}, { now = new Date(), session = null, toolN
328
338
  if (toolName !== "evda_write_agent_log") {
329
339
  const held = dayWriteHeld(abs);
330
340
  if (held !== null) {
331
- const q = queueToOutbox(abs, { now, words, context });
341
+ const q = queueToOutbox(abs, { now, words, context, conversation });
332
342
  return { path: rel(evda, q), bytes: Buffer.byteLength(words), queued: true, held };
333
343
  }
334
344
  }
335
- const w = appendToDay(abs, { now, words, context, session });
345
+ const conv = conversation === undefined ? null : loadConversation(conversation);
346
+ const w = appendToDay(abs, { now, words, context, session: conv ?? session });
347
+ // The words are in the day by now: a save that fails costs the next call a new heading, never a
348
+ // refusal that would have the caller send the same words again.
349
+ if (conv) { try { saveConversation(conversation, conv); } catch { /* see above */ } }
336
350
  return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
337
351
  }
338
352
 
@@ -354,10 +368,10 @@ function dayWriteHeld(abs) {
354
368
  }
355
369
 
356
370
  // 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`
371
+ // daily folder's .outbox/, {tool, words, context, at, host} and the caller's conversation when it named one, named `<UTC timestamp>-<host>-evda_write.json`
358
372
  // (the shape the day-writer guard's refusal names, lib/day-writer.mjs OUTBOX_DIR). One file per write,
359
373
  // 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 }) {
374
+ function queueToOutbox(abs, { now, words, context, conversation }) {
361
375
  const box = join(dirname(abs), ".outbox");
362
376
  mkdirSync(box, { recursive: true });
363
377
  const at = now.toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -365,7 +379,7 @@ function queueToOutbox(abs, { now, words, context }) {
365
379
  const stamp = at.replace(/[-:]/g, "").replace(/^(\d{8})T/, (_, d) => `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6)}T`);
366
380
  let file = join(box, `${stamp}-${host}-evda_write.json`);
367
381
  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" });
382
+ writeFileSync(file, JSON.stringify({ tool: "evda_write", words: String(words), context: context ?? "", at, host, ...(conversation === undefined ? {} : { conversation }) }, null, 1), { flag: "wx" });
369
383
  return file;
370
384
  }
371
385