create-evda 0.1.11 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ Plain-English notes on how Iris behaves differently after each version. Not a co
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ What the owner taught Iris now reaches every app: evda_operating_layer ends with the newest entries of the corrections file evda.json names (or of AGENTS.md's "What I have been taught" when a chat's size budget lists that section), the same on every surface, within the layer's budget, and only to a reader allowed to read that file.
8
+
9
+ On a machine that sends the owner's messages to another machine to file, one Claude Code session is one section of the day again, under one heading with one context line, instead of a new heading for every message: the prompt hook names its session with each send, and `evda_write` and `evda_write_agent_log` take that as `conversation` and keep its section between separate calls, in `~/.evda/conversations` (`EVDA_CONVERSATION_DIR`) outside the record (HQ-2823).
10
+
11
+ The Claude Code read guard now runs from a copy inside the record (`.claude/hooks/evda-guard/`), like the other two hooks, so it keeps working after the npx cache or checkout that installed it is gone; a reinstall replaces any older guard entry instead of adding a second one, and `evda doctor` fails when a registered guard's file is missing (HQ-2819).
12
+
13
+ The Claude Code prompt hook no longer files Claude Code's own text as the owner's words. A background
14
+ task's completion (a `<task-notification>` block) is not filed at all, and a `<system-reminder>`
15
+ block added to a real message is left out of what is filed; a message with neither stays byte for
16
+ byte (HQ-2823).
17
+
7
18
  On a machine that does not write the day (`EVDA_DAY_WRITER` set), the Claude Code prompt hook no
8
19
  longer appends to the local day file. It sends each message to the writer through the command in
9
20
  `EVDA_DAY_WRITER_SEND` (evda_write's arguments as JSON on stdin), and a message the writer cannot take
package/README.md CHANGED
@@ -12,7 +12,7 @@ The AI you choose reads what it needs to think with you. With training turned of
12
12
 
13
13
  If you want more than that, you have two options. You can run a model locally on your own computer, and you can just ask how to set it up. Or you can say something is just to be noted, and no AI reads it until you say it can.
14
14
 
15
- **Who reads what.** Setup writes the AI you chose into `evda.json` as `read_by`, so it reads the whole record. A circle can name its own reader: another AI, `local` for a model on your own hardware (`evda local-model` says how), or `none`. `evda doctor` prints "Read by" for every part. Say "just note this" and your agent files it with `noted: true` (`evda note` from a terminal); no AI reads it until you say it can (`evda release <path>`), and `evda search` still finds it on your device. The MCP server refuses both in code, `hooks/evda-read-guard.mjs` (installed into the record for Claude owners) stops Claude Code's Read, Grep and shell reads of them, and `AGENTS.md` tells every other agent to decline out loud. None of this is a lock: the words are plain files on your disk, and it stops an agent that checks.
15
+ **Who reads what.** Setup writes the AI you chose into `evda.json` as `read_by`, so it reads the whole record. A circle can name its own reader: another AI, `local` for a model on your own hardware (`evda local-model` says how), or `none`. `evda doctor` prints "Read by" for every part. Say "just note this" and your agent files it with `noted: true` (`evda note` from a terminal); no AI reads it until you say it can (`evda release <path>`), and `evda search` still finds it on your device. The MCP server refuses both in code, `hooks/evda-read-guard.mjs` (copied into the record's `.claude/hooks/evda-guard/` for Claude owners) stops Claude Code's Read, Grep and shell reads of them, and `AGENTS.md` tells every other agent to decline out loud. None of this is a lock: the words are plain files on your disk, and it stops an agent that checks.
16
16
 
17
17
  EVÐA prescribes no values and no rules. The only requirement is that you want to, and you are open to it. It is built on a neo-Aristotelian philosophy; ask your agent about it, or have any agent verify it by reading this code.
18
18
 
package/bin/evda.mjs CHANGED
@@ -554,7 +554,7 @@ async function main(argv) {
554
554
  const ph = hooks.promptHookStatus(dir);
555
555
  console.log(ph.registered ? (ph.file ? (ph.current ? `prompt hook installed and current: ${hooks.SETTINGS_REL} runs ${hooks.PROMPT_HOOK_REL}` : "prompt hook installed, older than the shipped hook: run `evda hook install`") : `prompt hook registered, but ${hooks.PROMPT_HOOK_REL} is missing: run \`evda hook install\``) : "prompt hook not installed (it is only for an owner who chose to file everything)");
556
556
  const g = guardStatus(dir);
557
- console.log(g.registered ? (g.file ? `read guard installed: ${hooks.SETTINGS_REL} runs ${g.target ?? "evda guard through npx"}` : `read guard registered, but ${g.target} is missing: run \`evda hook install\``) : "read guard not installed: run `evda hook install`");
557
+ console.log(g.registered ? (g.file ? `read guard installed: ${hooks.SETTINGS_REL} runs ${g.target ?? "evda guard through npx"}` : `read guard registered, but ${g.missing.join(", ")} is missing: run \`evda hook install\``) : "read guard not installed: run `evda hook install`");
558
558
  return st.registered && st.file && st.current && g.registered && g.file && (!ph.registered || (ph.file && ph.current)) ? 0 : 1;
559
559
  }
560
560
 
package/lib/connect.mjs CHANGED
@@ -34,50 +34,114 @@ export function serverCommand(recordDir, { execPath = process.execPath, bin = jo
34
34
  return { command: stableNode(execPath, env), args: [real, "mcp", dir] };
35
35
  }
36
36
 
37
- // The command Claude Code runs for the read guard (lib/guard.mjs), by the same rule as the server:
38
- // absolute paths when the package is installed somewhere stable, the npx form from an npx cache.
39
- export function guardCommand({ execPath = process.execPath, hook = join(PACKAGE_ROOT, "hooks", "evda-read-guard.mjs"), env = process.env } = {}) {
40
- let real = hook;
41
- try { real = realpathSync(hook); } catch { /* not on disk yet, as in a test */ }
42
- const q = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
43
- if (/[\\/]_npx[\\/]/.test(real)) return "npx -y -p create-evda@latest evda guard";
44
- return `${q(stableNode(execPath, env))} ${q(real)}`;
37
+ // The read guard (lib/guard.mjs) runs from a copy inside the record, like the keep-record and prompt
38
+ // hooks (lib/hooks.mjs), never from the package that installed it (HQ-2819). An absolute path into
39
+ // the installer dies with it: the npx cache npm clears, a checkout or worktree that is removed. The
40
+ // npx form (`npx -y -p create-evda@latest evda guard`) outlives the installer but asks the registry
41
+ // before every Read, and an offline laptop then runs no guard, so it is not written either. The copy
42
+ // is the entry point plus every lib/ file it imports, found by walking the imports at install time,
43
+ // so a new import in lib/guard.mjs is copied without anyone listing it.
44
+ export const GUARD_DIR_REL = ".claude/hooks/evda-guard";
45
+ export const GUARD_REL = `${GUARD_DIR_REL}/evda-read-guard.mjs`;
46
+ export const GUARD_COMMAND = `node "$CLAUDE_PROJECT_DIR/${GUARD_REL}"`;
47
+ export const GUARD_MATCHER = "Read|Grep|Bash|NotebookRead";
48
+ const SHIPPED_GUARD = join(PACKAGE_ROOT, "hooks", "evda-read-guard.mjs");
49
+ const LIB_DIR = join(PACKAGE_ROOT, "lib");
50
+
51
+ // Every file the record's copy of the guard holds, as [path in the record, bytes]. The entry point
52
+ // is the shipped one with its one import pointed at the lib/ copied beside it; the lib files are
53
+ // byte for byte. Throws if the shipped entry no longer imports ../lib/guard.mjs, so a change there
54
+ // cannot install a copy that fails at load.
55
+ export function guardFiles() {
56
+ const entry = readFileSync(SHIPPED_GUARD, "utf8");
57
+ const IMPORT = 'from "../lib/guard.mjs"';
58
+ if (!entry.includes(IMPORT)) throw new Error(`${SHIPPED_GUARD} no longer imports ../lib/guard.mjs, so the guard copy cannot be built`);
59
+ const files = [[GUARD_REL, Buffer.from(entry.replace(IMPORT, 'from "./lib/guard.mjs"'))]];
60
+ const seen = new Set();
61
+ const walk = (name) => {
62
+ if (seen.has(name)) return;
63
+ seen.add(name);
64
+ const bytes = readFileSync(join(LIB_DIR, name));
65
+ files.push([`${GUARD_DIR_REL}/lib/${name}`, bytes]);
66
+ for (const m of bytes.toString("utf8").matchAll(/\bfrom\s*["']\.\/([^"']+)["']|\bimport\s*\(?\s*["']\.\/([^"']+)["']/g)) walk(m[1] ?? m[2]);
67
+ };
68
+ walk("guard.mjs");
69
+ return files;
45
70
  }
46
71
 
47
- // Install the read guard into one record's .claude/settings.json, for Claude Code sessions opened
48
- // in that folder. Merges into a settings file that is already there: an existing PreToolUse entry
49
- // with the same command is left alone, and nothing else in the file is touched.
50
- export function installGuard(recordDir, { command = guardCommand() } = {}) {
51
- const path = join(resolve(recordDir), ".claude", "settings.json");
72
+ // The settings commands that run the guard, in any form an installer ever wrote: this copy, an
73
+ // absolute path to some package's hooks/evda-read-guard.mjs, or the npx form.
74
+ export const GUARD_MARK = /evda-read-guard\.mjs|\bevda guard\b/;
75
+ const isGuard = (h) => typeof h?.command === "string" && GUARD_MARK.test(h.command);
76
+
77
+ // Install the read guard into one record: copy its files into .claude/hooks/evda-guard/ and make
78
+ // .claude/settings.json run that copy. Every guard entry already there (a stale absolute path, the
79
+ // npx form, a duplicate from a second install) is removed, so exactly one remains. Nothing else in
80
+ // the settings file is touched, and a file that already says exactly this is not rewritten.
81
+ export function installGuard(recordDir) {
82
+ const dir = resolve(recordDir);
83
+ const path = join(dir, ".claude", "settings.json");
52
84
  let settings = {};
53
85
  if (existsSync(path)) {
54
86
  try { settings = JSON.parse(readFileSync(path, "utf8")); } catch { return { path, action: "left alone (not valid JSON):" }; }
87
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) return { path, action: "left alone (not a JSON object):" };
88
+ }
89
+ let copied = false;
90
+ for (const [rel, bytes] of guardFiles()) {
91
+ const abs = join(dir, rel);
92
+ if (existsSync(abs) && readFileSync(abs).equals(bytes)) continue;
93
+ mkdirSync(dirname(abs), { recursive: true });
94
+ writeFileSync(abs, bytes);
95
+ copied = true;
55
96
  }
56
- const pre = (settings.hooks ??= {}).PreToolUse ??= [];
57
- if (pre.some((m) => (m.hooks ?? []).some((h) => h.command === command))) return { path, action: "already installed:" };
58
- pre.push({ matcher: "Read|Grep|Bash|NotebookRead", hooks: [{ type: "command", command }] });
97
+ const pre = Array.isArray(settings.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
98
+ const found = pre.flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []).filter(isGuard).map((h) => h.command));
99
+ if (found.length === 1 && found[0] === GUARD_COMMAND) return { path, action: copied ? "updated the guard copy:" : "already installed:" };
100
+ const kept = pre
101
+ .map((m) => (Array.isArray(m?.hooks) && m.hooks.some(isGuard) ? { ...m, hooks: m.hooks.filter((h) => !isGuard(h)) } : m))
102
+ .filter((m) => !Array.isArray(m?.hooks) || m.hooks.length);
103
+ kept.push({ matcher: GUARD_MATCHER, hooks: [{ type: "command", command: GUARD_COMMAND }] });
104
+ if (settings.hooks === null || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) settings.hooks = {};
105
+ settings.hooks.PreToolUse = kept;
59
106
  mkdirSync(dirname(path), { recursive: true });
60
107
  writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
61
- return { path, action: "installed" };
108
+ return { path, action: found.length ? `replaced ${found.length} older guard ${found.length === 1 ? "entry" : "entries"}:` : "installed" };
62
109
  }
63
110
 
64
- // Whether one record's .claude/settings.json runs the read guard, for doctor (HQ-1377):
65
- // { registered, target, file, problem }. `target` is the hook file the command names, `file` whether
66
- // it is on disk; the npx form names no file and is present whenever it is registered. Never throws.
67
- export const GUARD_MARK = /evda-read-guard\.mjs|\bevda guard\b/;
111
+ // The hook file one guard command runs, resolved against the record ($CLAUDE_PROJECT_DIR is the
112
+ // record, since the guard is registered in the record's own settings). Null for the npx form.
113
+ export function guardTarget(command, recordDir) {
114
+ const dir = resolve(recordDir);
115
+ const m = /"([^"]*evda-read-guard\.mjs)"|'([^']*evda-read-guard\.mjs)'|(\S*evda-read-guard\.mjs)/.exec(command);
116
+ if (!m) return null;
117
+ return resolve(dir, (m[1] ?? m[2] ?? m[3]).replace(/\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR/g, () => dir));
118
+ }
119
+
120
+ // Whether one record's .claude/settings.json runs the read guard, for doctor (HQ-1377, HQ-2819):
121
+ // { registered, target, file, missing, count, current, problem }. `target` is the hook file the
122
+ // first entry names, `missing` every registered file not on disk (`file` is true when there is
123
+ // none), `count` how many guard entries there are, `current` whether the record's copy matches what
124
+ // ships. The npx form names no file and is present whenever it is registered. Never throws.
68
125
  // EVDA_HOOKS_FALSIFY=blind reports no guard at all, for the test that shows doctor's line is read
69
126
  // from the settings file.
70
127
  export function guardStatus(recordDir) {
71
- if ((process.env.EVDA_HOOKS_FALSIFY ?? "").split(",").map((x) => x.trim()).includes("blind")) return { registered: false, target: null, file: false, problem: null };
72
- const path = join(resolve(recordDir), ".claude", "settings.json");
73
- if (!existsSync(path)) return { registered: false, target: null, file: false, problem: null };
128
+ const none = { registered: false, target: null, file: false, missing: [], count: 0, current: false, problem: null };
129
+ if ((process.env.EVDA_HOOKS_FALSIFY ?? "").split(",").map((x) => x.trim()).includes("blind")) return none;
130
+ const dir = resolve(recordDir);
131
+ const path = join(dir, ".claude", "settings.json");
132
+ if (!existsSync(path)) return none;
74
133
  let settings;
75
- try { settings = JSON.parse(readFileSync(path, "utf8")); } catch (e) { return { registered: false, target: null, file: false, problem: `.claude/settings.json is not valid JSON (${e.message}), so no hook in it runs.` }; }
76
- const commands = (settings?.hooks?.PreToolUse ?? []).flatMap((m) => (m?.hooks ?? []).map((h) => h?.command)).filter((c) => typeof c === "string" && GUARD_MARK.test(c));
77
- if (!commands.length) return { registered: false, target: null, file: false, problem: null };
78
- const m = /'([^']*evda-read-guard\.mjs)'|(\S*evda-read-guard\.mjs)/.exec(commands[0]);
79
- const target = m ? (m[1] ?? m[2]) : null;
80
- return { registered: true, target, file: target === null || existsSync(target), problem: null };
134
+ try { settings = JSON.parse(readFileSync(path, "utf8")); } catch (e) { return { ...none, problem: `.claude/settings.json is not valid JSON (${e.message}), so no hook in it runs.` }; }
135
+ const pre = settings?.hooks?.PreToolUse;
136
+ const commands = (Array.isArray(pre) ? pre : []).flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []).filter(isGuard).map((h) => h.command));
137
+ if (!commands.length) return none;
138
+ const targets = commands.map((c) => guardTarget(c, dir));
139
+ const missing = targets.filter((t) => t !== null && !existsSync(t));
140
+ let current = true;
141
+ if (targets.includes(join(dir, GUARD_REL))) {
142
+ try { current = guardFiles().every(([rel, bytes]) => existsSync(join(dir, rel)) && readFileSync(join(dir, rel)).equals(bytes)); } catch { current = false; }
143
+ }
144
+ return { registered: true, target: targets[0], file: missing.length === 0, missing, count: commands.length, current, problem: null };
81
145
  }
82
146
 
83
147
  // A `node` on PATH that is this same binary, when there is one: Homebrew's /opt/homebrew/bin/node
@@ -0,0 +1,68 @@
1
+ // A conversation's section state kept between separate evda_write calls (HQ-2823).
2
+ //
3
+ // appendToDay (lib/day-file.mjs) keeps one conversation in one section through a `session` object,
4
+ // and an MCP process holds that object in memory. A relay that spawns a fresh `evda mcp` for every
5
+ // message (Hestia's evda-tool.mjs, which the prompt hook on a non-writer laptop sends through) has
6
+ // no memory between calls, so on 2026-10-22 every message the owner sent from his laptop opened
7
+ // its own `## HH:MM`. A caller that names its conversation (evda_write's `conversation`) gets the
8
+ // section state loaded from and saved to one small file here instead.
9
+ //
10
+ // Where: EVDA_CONVERSATION_DIR, default ~/.evda/conversations: outside the record and outside any
11
+ // synced folder, the way the prompt outbox is (lib/prompt-hook.mjs promptOutboxDir). What: the
12
+ // `sections` appendToDay keeps, which hold the section's own text so the next write can find it.
13
+ // That is the owner's words outside the record, so the folder is the owner's alone (0700) and a
14
+ // file not written for CONVERSATION_KEEP_MS is deleted on the next save. A section lives in one
15
+ // day's file, so a conversation older than that has nothing left to continue. Age is the
16
+ // `savedAt` written inside each file by the same clock that judges it, never the file's mtime:
17
+ // a clock the OS does not share (a shifted test clock, a restored backup) would otherwise read a
18
+ // file saved a moment ago as old and delete a live conversation.
19
+
20
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+ import { isConversationId } from "./day-file.mjs";
24
+
25
+ export const CONVERSATION_DIR_ENV = "EVDA_CONVERSATION_DIR";
26
+ export const CONVERSATION_KEEP_MS = 2 * 24 * 60 * 60 * 1000;
27
+
28
+ export function conversationDir(env = process.env) {
29
+ return env[CONVERSATION_DIR_ENV] || join(homedir(), ".evda", "conversations");
30
+ }
31
+
32
+ const fileOf = (id, env) => join(conversationDir(env), `${id}.json`);
33
+
34
+ // The session object appendToDay takes for conversation `id`: { sections } as last saved, or {}.
35
+ export function loadConversation(id, { env = process.env } = {}) {
36
+ if (!isConversationId(id)) throw new Error(`not a conversation id: ${JSON.stringify(id)}`);
37
+ try {
38
+ const s = JSON.parse(readFileSync(fileOf(id, env), "utf8"));
39
+ return s && typeof s.sections === "object" && s.sections ? { sections: s.sections } : {};
40
+ } catch { return {}; }
41
+ }
42
+
43
+ // Save it (written whole, then renamed into place, so a reader never sees half a file), and delete
44
+ // every conversation not saved for CONVERSATION_KEEP_MS.
45
+ export function saveConversation(id, session, { env = process.env, nowMs = Date.now() } = {}) {
46
+ if (!isConversationId(id)) throw new Error(`not a conversation id: ${JSON.stringify(id)}`);
47
+ const dir = conversationDir(env);
48
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
49
+ const abs = fileOf(id, env);
50
+ const part = `${abs}.${process.pid}.part`;
51
+ writeFileSync(part, JSON.stringify({ sections: session.sections ?? {}, savedAt: nowMs }) + "\n", { mode: 0o600 });
52
+ renameSync(part, abs);
53
+ for (const n of readdirSync(dir)) {
54
+ const f = join(dir, n);
55
+ if (f === abs) continue;
56
+ try { if (nowMs - savedAtOf(f) > CONVERSATION_KEEP_MS) unlinkSync(f); } catch { /* gone already */ }
57
+ }
58
+ }
59
+
60
+ // When `f` was saved: its own savedAt, or its mtime for a file written without one (a leftover
61
+ // .part, a file from before savedAt existed).
62
+ function savedAtOf(f) {
63
+ try {
64
+ const t = JSON.parse(readFileSync(f, "utf8")).savedAt;
65
+ if (Number.isFinite(t)) return t;
66
+ } catch { /* not a whole conversation file */ }
67
+ return statSync(f).mtimeMs;
68
+ }
@@ -8,9 +8,11 @@
8
8
  // every correction from a chat app was refused and lost. Doctor reads the same setting through this
9
9
  // one function, so the tool and the check cannot disagree on what a valid setting is.
10
10
 
11
- import { existsSync, statSync } from "node:fs";
12
- import { isAbsolute, relative, resolve, sep } from "node:path";
11
+ import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
13
13
  import { isSealed } from "./circles.mjs";
14
+ import { readContext, mayRead, aiView } from "./readers.mjs";
15
+ import { datalessAmong } from "./sync-rules.mjs";
14
16
 
15
17
  export const CORRECTIONS_KEY = "corrections";
16
18
 
@@ -32,3 +34,76 @@ export function correctionsTarget(evda) {
32
34
  if (isSealed(abs)) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, which is sealed, so no agent writes a correction into it.` };
33
35
  return { rel, abs, problem: null };
34
36
  }
37
+
38
+ // What the owner taught the agent, served in the operating layer (HQ-2818). A chat app learns who
39
+ // it is only from evda_operating_layer, so a correction written to a separate corrections file was
40
+ // recorded and never read back on any chat surface. The layer now carries the newest entries, the
41
+ // same selection on every surface, so a correction made in one app reaches the next chat in any.
42
+ //
43
+ // The size is fixed, not the whole file: his file measured 671,346 characters and 445 sections on
44
+ // 2026-10-22, ten times a chat's whole budget. Entries are the file's `## ` sections in file order
45
+ // (evda_record_correction appends, so the last is the newest); the newest are kept whole while they
46
+ // fit, and the block says how many older ones the file holds and how to find one.
47
+ export const CORRECTIONS_LAYER_MAX = 8_000;
48
+ export const TAUGHT_HEADING = "What I have been taught";
49
+
50
+ // A corrections file's entries: its `## ` sections, in file order. The text before the first one is
51
+ // the file's own preamble, not an entry.
52
+ export function fileEntries(text) {
53
+ return text.split(/^(?=## )/m).filter((sec) => sec.startsWith("## ")).map((sec) => sec.replace(/\s+$/, ""));
54
+ }
55
+
56
+ // The "What I have been taught" section's entries: its `- ` lines, each with any lines under it.
57
+ export function sectionEntries(sec) {
58
+ const body = sec.split("\n").slice(1).join("\n");
59
+ return body.split(/^(?=- )/m).filter((e) => e.startsWith("- ")).map((e) => e.replace(/\s+$/, ""));
60
+ }
61
+
62
+ // The newest entries that fit in `max` characters joined by blank lines, oldest of them first.
63
+ // When even the newest is longer, it is cut at a line and says where the rest is.
64
+ export function newestEntries(entries, max, where) {
65
+ const kept = [];
66
+ let size = 0;
67
+ for (let i = entries.length - 1; i >= 0; i--) {
68
+ const grow = entries[i].length + (kept.length ? 2 : 0);
69
+ if (size + grow > max) break;
70
+ kept.unshift(entries[i]);
71
+ size += grow;
72
+ }
73
+ if (!kept.length && entries.length) {
74
+ const tail = `\n(This entry is cut here; it continues in ${where}.)`;
75
+ const e = entries[entries.length - 1];
76
+ let cut = e.slice(0, Math.max(0, max - tail.length));
77
+ const at = cut.lastIndexOf("\n");
78
+ if (at > 0) cut = cut.slice(0, at);
79
+ return { kept: [cut + tail], older: entries.length - 1 };
80
+ }
81
+ return { kept, older: entries.length - kept.length };
82
+ }
83
+
84
+ function block(path, entries, max) {
85
+ if (!entries.length) return null;
86
+ const { kept, older } = newestEntries(entries, max, path);
87
+ const more = older ? ` ${older} older ${older === 1 ? "entry is" : "entries are"} in ${path}; evda_search finds one by its words.` : "";
88
+ const head = `## ${TAUGHT_HEADING} (from ${path})\n\nWhat the owner corrected you on, newest last, word for word as ${path} keeps it: how they want you to work, learned from real misses.${more}`;
89
+ return { text: `${head}\n\n${kept.join("\n\n")}\n`, path, served: kept.length, total: entries.length };
90
+ }
91
+
92
+ // The corrections block for the layer, or null when there is nothing to add. `caller` is who reads
93
+ // the layer: a file this caller may not read (sealed, noted, or a read_by that is not theirs) is not
94
+ // served, by the same mayRead every read tool uses, and a sealed section inside it is never shown.
95
+ // A file still in the cloud is not read, since reading it would start a download.
96
+ //
97
+ // With no "corrections" setting the entries live in AGENTS.md's own section, which the layer already
98
+ // serves, so nothing is added; `taughtListed` is that section's text when a chat's budget listed it
99
+ // by heading instead, and then its newest lines are served here so they still reach the chat.
100
+ export function correctionsForLayer(evda, caller, { max = CORRECTIONS_LAYER_MAX, taughtListed = null, agentsPath = "AGENTS.md" } = {}) {
101
+ const target = correctionsTarget(evda);
102
+ if (!target) return taughtListed ? block(agentsPath, sectionEntries(taughtListed), max) : null;
103
+ if (target.problem) return null;
104
+ if (!mayRead(readContext(evda), target.abs, caller).ok) return null;
105
+ if (datalessAmong(dirname(target.abs), [basename(target.abs)]).size) return null;
106
+ let raw;
107
+ try { raw = readFileSync(target.abs, "utf8"); } catch { return null; }
108
+ return block(target.rel, fileEntries(aiView(raw).text), max);
109
+ }
package/lib/day-file.mjs CHANGED
@@ -61,6 +61,13 @@ const pad = (n) => String(n).padStart(2, "0");
61
61
  export const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
62
62
  export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
63
63
 
64
+ // A conversation id a caller names so that separate evda_write calls continue one section (HQ-2823):
65
+ // the prompt hook sends Claude Code's session_id. Short and opaque, letters, digits, `_` and `-`
66
+ // only, because the writer names a state file after it. Checked here so the hook and evda_write
67
+ // read one rule.
68
+ export const CONVERSATION_MAX = 128;
69
+ export const isConversationId = (v) => typeof v === "string" && v.length <= CONVERSATION_MAX && /^[A-Za-z0-9_-]+$/.test(v);
70
+
64
71
  export const contextLine = (context) => `*${CONTEXT_LABEL} ${String(context).trim().replace(/\*/g, "")}*`;
65
72
 
66
73
  // NAMED ENTRIES (HQ-2799). A write tool's answer names the existing entries the filed words bear on
package/lib/doctor.mjs CHANGED
@@ -48,7 +48,7 @@
48
48
 
49
49
  import { checkoutFreshness } from "./checkout-freshness.mjs";
50
50
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
51
- import { join, relative, resolve, sep } from "node:path";
51
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
52
52
  import { readManifest, checkVersion, unknownKeys, unknownLayers, holderOf, holderProblems, KNOWN_LAYERS, SCHEMA_VERSION } from "./manifest.mjs";
53
53
  import { readGrants } from "./grants.mjs";
54
54
  import { readDevices } from "./devices.mjs";
@@ -77,7 +77,7 @@ import { FILING_MODES, filingOf } from "./day-file.mjs";
77
77
  import { correctionsTarget } from "./corrections.mjs";
78
78
  import { EVERY_CHAT_KEY } from "./every-chat.mjs";
79
79
  import { listSkills, SKILLS_REL } from "./skills.mjs";
80
- import { guardStatus } from "./connect.mjs";
80
+ import { guardStatus, GUARD_DIR_REL, GUARD_REL } from "./connect.mjs";
81
81
  import { readmeCitations, missingHeadings, README_REL } from "./readme.mjs";
82
82
 
83
83
  // The dated sections of a person-profile that no observation covers (HQ-1397). An observation
@@ -322,12 +322,16 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0, ch
322
322
 
323
323
  // 5c. the read guard (HQ-1374), the other hook create-evda installs for a Claude owner (HQ-1377):
324
324
  // a PreToolUse hook in the record's own settings that stops a Claude Code session reading a noted
325
- // entry. Reported present or missing, like the Stop hook above; a registered guard whose file is
326
- // gone warns, because every read then runs a command that is not there.
325
+ // entry. A registered guard whose file is gone fails (HQ-2819): every read then runs a command
326
+ // that is not there, Claude Code lets the read through, and the owner was told it is guarded. The
327
+ // usual cause is an install that pointed into an npx cache or a checkout since removed.
327
328
  const guard = guardStatus(dir);
329
+ const posixRel = (root, abs) => { const r = relative(resolve(root), abs); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(sep).join("/") : abs; };
328
330
  if (guard.problem) warn(SETTINGS_REL, guard.problem);
329
- else if (guard.registered && !guard.file) warn(SETTINGS_REL, `registers the read guard, but ${guard.target} is missing, so Claude Code cannot run it and a noted entry is not held back there. \`evda hook install\` restores it.`);
330
- else if (guard.registered) ok(1, "read guard", `${SETTINGS_REL} runs ${guard.target ?? "the evda guard through npx"} before Read, Grep and shell reads`);
331
+ else if (guard.registered && !guard.file) fail(SETTINGS_REL, `registers the read guard, but ${guard.missing.join(" and ")} ${guard.missing.length === 1 ? "is" : "are"} missing, so Claude Code cannot run it and a noted entry is not held back there. \`evda hook install\` copies the guard into ${GUARD_DIR_REL}/ and replaces the entry.`);
332
+ else if (guard.registered && guard.count > 1) warn(SETTINGS_REL, `registers the read guard ${guard.count} times. \`evda hook install\` leaves one.`);
333
+ else if (guard.registered && !guard.current) warn(GUARD_REL, "or a file copied beside it differs from what this tooling ships. `evda hook install` updates them.");
334
+ else if (guard.registered) ok(1, "read guard", `${SETTINGS_REL} runs ${guard.target ? posixRel(dir, guard.target) : "the evda guard through npx"} before Read, Grep and shell reads`);
331
335
  else skip("read guard", `not installed. A Claude Code session here is not stopped before reading a just-noted entry (the MCP server still holds it back); \`evda hook install\` adds it to ${SETTINGS_REL}.`);
332
336
 
333
337
  // 6. record files
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 });
@@ -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;
@@ -48,7 +48,7 @@ import { dirname, join, relative } from "node:path";
48
48
  import { homedir, hostname, tmpdir } from "node:os";
49
49
  import { spawnSync } from "node:child_process";
50
50
  import { fileURLToPath } from "node:url";
51
- import { dayRole, findRecord, filingOf, appendToDay, localDate } from "./day-file.mjs";
51
+ import { dayRole, findRecord, filingOf, appendToDay, localDate, isConversationId } from "./day-file.mjs";
52
52
  import { secretShape } from "./secret-shape.mjs";
53
53
 
54
54
  export const PROMPT_HOOK_NAME = "evda-file-prompt.mjs";
@@ -67,14 +67,16 @@ export function promptOutboxDir(env = process.env) {
67
67
  const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
68
68
 
69
69
  // Save one message for the writer: a new file per message, never rewritten, in this machine's outbox.
70
- export function queuePrompt(root, { words, context, now = new Date(), env = process.env }) {
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 }) {
71
73
  const dir = promptOutboxDir(env);
72
74
  mkdirSync(dir, { recursive: true });
73
75
  const at = now.toISOString();
74
76
  const stamp = at.replace(/[-:.]/g, "");
75
77
  let file = join(dir, `${stamp}-${process.pid}.json`);
76
78
  for (let n = 2; existsSync(file); n++) file = join(dir, `${stamp}-${process.pid}-${n}.json`);
77
- writeFileSync(file, JSON.stringify({ tool: "evda_write", record: realOr(root), words, context, at, host: hostname().split(".")[0] || "host" }, null, 1) + "\n", { flag: "wx" });
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" });
78
80
  return file;
79
81
  }
80
82
 
@@ -104,7 +106,7 @@ export function drainPromptOutbox(root, { env = process.env, budgetMs = Number(e
104
106
  let entry;
105
107
  try { entry = JSON.parse(readFileSync(claim, "utf8")); } catch { renameSync(claim, abs); continue; }
106
108
  const left = deadline - Date.now();
107
- const args = { words: entry.words, ...(entry.context ? { context: entry.context } : {}) };
109
+ const args = { words: entry.words, ...(entry.context ? { context: entry.context } : {}), ...(isConversationId(entry.conversation) ? { conversation: entry.conversation } : {}) };
108
110
  const r = left > 200 ? spawnSync(cmd, { shell: true, input: JSON.stringify(args), encoding: "utf8", timeout: left, env }) : { status: null, error: { code: "ETIMEDOUT" } };
109
111
  if (r.status === 0) { unlinkSync(claim); out.sent.push(abs); continue; }
110
112
  try { renameSync(claim, abs); } catch { /* kept as a claim; the stale rule puts it back */ }
@@ -118,12 +120,20 @@ export function drainPromptOutbox(root, { env = process.env, budgetMs = Number(e
118
120
  const timeOf = (d) => `${localDate(d)} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
119
121
 
120
122
  // The non-writer path: queue, send, and say what happened. Never touches the day file.
121
- function viaWriter(root, prompt, now, env) {
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) {
122
131
  const writer = env.EVDA_DAY_WRITER.trim();
123
132
  if (!(env[DAY_WRITER_SEND_ENV] ?? "").trim()) {
124
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.`;
125
134
  }
126
- const file = queuePrompt(root, { words: prompt, context: `Claude Code on ${hostname().split(".")[0]}, said ${timeOf(now)}`, now, env });
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 });
127
137
  const r = drainPromptOutbox(root, { env });
128
138
  if (r.sent.includes(file)) {
129
139
  const more = r.sent.length > 1 ? ` (with ${r.sent.length - 1} message(s) queued earlier)` : "";
@@ -147,10 +157,23 @@ function markFiled(sid, now) {
147
157
  writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
148
158
  }
149
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
+
150
173
  // One UserPromptSubmit event in, the hook's stdout out ("" for nothing to say). Never throws.
151
174
  export function run(payload, { now = new Date(), env = process.env } = {}) {
152
175
  try {
153
- const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
176
+ const prompt = ownerWords(typeof payload?.prompt === "string" ? payload.prompt : "");
154
177
  if (!prompt.trim() || /^\s*\//.test(prompt)) return "";
155
178
  const here = dirname(fileURLToPath(import.meta.url));
156
179
  const root = findRecord(payload.cwd || process.cwd()) ?? findRecord(here);
@@ -162,7 +185,7 @@ export function run(payload, { now = new Date(), env = process.env } = {}) {
162
185
  const shape = prompt.split("\n").map(secretShape).find(Boolean);
163
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.`;
164
187
  if ((env.EVDA_DAY_WRITER ?? "").trim()) {
165
- const said = viaWriter(root, prompt, now, env);
188
+ const said = viaWriter(root, prompt, now, env, payload.session_id);
166
189
  if ((env[DAY_WRITER_SEND_ENV] ?? "").trim()) markFiled(payload.session_id, now); // sent or queued to send
167
190
  return said;
168
191
  }
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-evda",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "description": "The person layer over the substrate: scaffold and operate an EVDA, a directory that knows what it is. Plain files are the truth, the tooling is optional, and there are no runtime dependencies.",
6
6
  "bin": {