create-evda 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mcp.mjs CHANGED
@@ -11,8 +11,8 @@
11
11
  //
12
12
  // Every tool here reads and writes files on this machine, with two exceptions. evda_check_update
13
13
  // asks the npm registry for the tooling's latest version number; it carries nothing from the record
14
- // and installs nothing. The device tools (evda_pair_device, evda_sync_devices, HQ-1217) talk to the
15
- // relay that carries the record between the owner's own devices, and what they send is sealed on
14
+ // and installs nothing. The device tools (evda_pair, evda_finish_pairing, evda_sync_devices,
15
+ // evda_remove_device; HQ-1217, HQ-2040) talk to the relay that carries the record between the owner's own devices, and what they send is sealed on
16
16
  // this machine: the relay cannot read it.
17
17
 
18
18
  import { createInterface } from "node:readline";
@@ -31,6 +31,9 @@ import { FALSIFY } from "./mcp-auth.mjs";
31
31
  import { filingOf, FILING_MODES } from "./day-file.mjs";
32
32
  import { readManifest, writeManifest } from "./manifest.mjs";
33
33
  import * as pairing from "./pairing.mjs";
34
+ import { encodeQr, qrPng, qrText } from "./qr.mjs";
35
+ import { splitFence } from "./agents.mjs";
36
+ import { EVERY_CHAT_KEY, EVERY_CHAT_VERSION, CHAT_APPS, chatAppOf, needsEveryChat, everyChatWalkthrough } from "./every-chat.mjs";
34
37
 
35
38
  // Newest first. The server answers with the client's version when it supports it, and with the
36
39
  // newest otherwise; the client decides whether to continue.
@@ -41,7 +44,8 @@ export const SERVER_NAME = "evda";
41
44
  // acting on this field (2026-09-22: it told Mike "I only write to EVÐA when you ask me to" while
42
45
  // this said to file unasked), so the trigger is also the first words of evda_write's description,
43
46
  // which is what a deferred tool shows. This stays for the clients that read it.
44
- export function instructions(filing) {
47
+ export function instructions(filing, surface = "terminal") {
48
+ const chat = surface !== "terminal";
45
49
  const lines = [
46
50
  "This server is the owner's EVDA: their record, in plain files on this machine.",
47
51
  "Call evda_operating_layer first and follow it for the whole session: it is who you are (Iris unless they renamed you) and how the record is kept.",
@@ -51,13 +55,17 @@ export function instructions(filing) {
51
55
  ...(filing === null ? ["They have not said how filing works yet: evda_operating_layer holds the one question to ask them first, and evda_filing records the answer."] : []),
52
56
  "The day's entry is their words verbatim plus one line of context on what the conversation was about, passed as evda_write's context: that line is yours and is labeled as not theirs, and it is never written into words. Where anything else goes (a person's page, a project, the ledger) is your call by the record's four roles, journal, work, collections and knowledge.",
53
57
  "When you notice a process repeating (the same steps done or explained a second time, the same correction twice), write it once as a skill with evda_write_skill and tell the owner in one line; read the skills there are with evda_skills and follow the one that fits; when a run of one goes wrong, revise it. A skill is how you work, never a rule about their life.",
58
+ "When the owner corrects you, record it in the same turn with evda_record_correction: it appends one dated line to AGENTS.md's \"What I have been taught\", in their own words where they gave some, and nothing else in that file changes.",
54
59
  "When they ask for an EVÐA inside this one (a project, a company, an agent), make it with evda_create_subrecord; it runs doctor and returns the report. Sharing, grants and deletes still wait for their explicit yes.",
55
60
  "When they say to just note something, file it with evda_note instead: no AI reads a noted entry, you included, until they say it can, and then evda_release_note clears it. A sealed entry or section is theirs alone: no tool shows it or releases it, and you are never told where it is; when a tool says part of the record is sealed, tell them it is there for them on their own device. When a tool says something was not shown, tell them in one plain sentence what is there and how they open it.",
56
61
  ...(filing === "asked" ? [] : ["Filing is expected without being asked: they should never have to say \"save this\", and a conversation that ends with nothing of theirs filed has lost it."]),
57
- "Never quiz them. Never create a circle or grant; propose one with evda_propose_circle only when they ask to share, and wait for their yes.",
62
+ // A chat app is offered the chat tool set (chatToolNames), so its instructions name no tool outside it (HQ-2227).
63
+ chat
64
+ ? "Never quiz them. Never create a circle or grant: when they ask to share, propose it in words and wait for their yes."
65
+ : "Never quiz them. Never create a circle or grant; propose one with evda_propose_circle only when they ask to share, and wait for their yes.",
58
66
  "Organization comes from the preset library (evda_presets): propose with evda_propose_structure, and apply with evda_apply_preset only after their yes.",
59
- "When they point you at a pile they already have (a folder, an export), call evda_gather, then ask the questions from evda_gather_questions one at a time in your own words, and record each answer with evda_gather_answer. When every question is answered and the owner says yes to the summary, apply it with evda_gather_apply and confirmed: true; tell them the reversal manifest it names.",
60
- DISPATCH_LINE,
67
+ ...(chat ? [] : ["When they point you at a pile they already have (a folder, an export), call evda_gather, then ask the questions from evda_gather_questions one at a time in your own words, and record each answer with evda_gather_answer. When every question is answered and the owner says yes to the summary, apply it with evda_gather_apply and confirmed: true; tell them the reversal manifest it names."]),
68
+ chat ? CHAT_DISPATCH_LINE : DISPATCH_LINE,
61
69
  PRIVACY_INSTRUCTIONS,
62
70
  ];
63
71
  return lines.join(" ");
@@ -95,8 +103,253 @@ export const PRIVACY_SECTION = [
95
103
  // Dispatch (HQ-1246), one line on every surface an agent reads first: the server instructions and
96
104
  // the operating layer. The vault's AGENTS-template.md is the core's source; until it carries this
97
105
  // line, this is where it lives, and evda_operating_layer serves it above AGENTS.md.
98
- export const DISPATCH_LINE = "Call evda_replies at the start of a run: it returns the owner's replies to your messages that you have not seen yet. When the owner should hear something from you (what you did, a question), send it with evda_message, which shows it on their phone as a card; a thing only filed waits until they go looking.";
106
+ export const DISPATCH_LINE = "Call evda_replies at the start of a run: it returns the owner's replies to your messages that you have not seen yet. When the owner should hear something from you (what you did, a question), send it with evda_message. It only files the message into the record; whether that reaches them as a notification depends on their EVÐA app being paired and synced, which this tool cannot see or confirm. Never tell the owner a notification or a card went out -- say only that you sent or filed the message, and that a reply, if any, comes back through evda_replies.";
99
107
  export const DISPATCH_SECTION = `## Messages to the owner\n\n${DISPATCH_LINE}`;
108
+ // In a chat app the owner is reading the conversation, so a message goes in the reply, and their
109
+ // replies to messages other agents sent come in the layer call's result (HQ-2227). So the chat layer
110
+ // says that and names neither evda_message nor evda_replies, which the chat tool set leaves out.
111
+ export const CHAT_DISPATCH_LINE = "In a chat app the owner is reading this conversation, so tell them here, in your reply. Their replies to messages an agent sent them come with this layer when you pass first_message, under \"Their replies you have not seen\".";
112
+ export const CHAT_DISPATCH_SECTION = `## Messages to the owner\n\n${CHAT_DISPATCH_LINE}`;
113
+ export const dispatchSection = (surface) => (surface === "terminal" ? DISPATCH_SECTION : CHAT_DISPATCH_SECTION);
114
+
115
+ // The operating layer as a chat app receives it (HQ-2203): the chat lines, Dispatch, privacy, a line
116
+ // with the layer's size, the generated core when the agents file lacks it (tools.coreBeside), and
117
+ // the agents file whole. His measured 2026-10-04: an owner layer of 72,284 characters plus a core
118
+ // of about 28,600 is about 27,300 tokens (Anthropic's count_tokens, 3.7 characters a token), over
119
+ // the 25,000-token cap Claude Code puts on one MCP tool result. So a layer longer than its client's
120
+ // part size comes in parts, cut only where a `## ` section starts (inside a section only when one
121
+ // section alone is too long), and every part ends by naming the next.
122
+ export const LAYER_PART_MAX = 80_000;
123
+ const PART_RESERVE = 400;
124
+
125
+ // Which of the owner's sections a client gets is the owner's call, made with one marker (HQ-2207):
126
+ // a `## ` section with SURFACE_TERMINAL on a line of its own text applies only where the agent has a shell
127
+ // (the scripts it may run, the hosts it may ssh to, its local MCP servers). A coding agent gets every
128
+ // section; any other client, named or not, gets the rest. A chat app is the default, so an app this
129
+ // table has never heard of works on day one, and a record with no marker reads the same everywhere.
130
+ export const SURFACE_TERMINAL = "<!-- evda:surface terminal -->";
131
+
132
+ // Whether one `## ` section is marked for a terminal (HQ-2223, slice 2): the marker on a line of its
133
+ // own anywhere in the section's own text, before its first subsection, and not inside a code fence.
134
+ // It used to count only on the line right after the heading, and on 2026-10-05 an edit that put one
135
+ // line above it turned a terminal section into a chat one with nothing said; a marker the owner wrote
136
+ // in the section means what it says wherever the section's own text holds it. A marker under a `### `
137
+ // is not the section's, and one in a fence is the marker being shown, not used.
138
+ export function terminalOnly(sec) {
139
+ let fence = null;
140
+ for (const line of sec.split(/\r?\n/).slice(1)) {
141
+ const t = line.trim();
142
+ const f = /^(`{3,}|~{3,})/.exec(t);
143
+ if (f) {
144
+ if (fence === null) fence = f[1][0];
145
+ else if (f[1][0] === fence) fence = null;
146
+ continue;
147
+ }
148
+ if (fence !== null) continue;
149
+ if (/^#{3,}\s/.test(t)) return false;
150
+ if (t === SURFACE_TERMINAL) return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+ // The clients this server knows, by initialize's clientInfo.name, and the size of one part for each.
156
+ // Names: claude-code and codex-mcp-client measured 2026-10-05 (a probe server logging clientInfo,
157
+ // driven by `claude -p` and `codex exec`). claude.ai sends Anthropic/ClaudeAI, measured 2026-10-05 in
158
+ // the remote server's log from a fresh web chat; claude-ai stays matched as the published form.
159
+ // openai-mcp is the name ChatGPT publishes and is unmeasured until that log holds one.
160
+ // Limits: claude.ai and the desktop app take a tool result of about 150,000 characters
161
+ // (claude.com/docs/connectors/building, "Design within the size and timeout limits"); Claude Code
162
+ // 25,000 tokens (same page, MAX_MCP_OUTPUT_TOKENS); OpenAI publishes no figure for ChatGPT and
163
+ // truncates an over-budget result at a line, so ChatGPT and every unknown app get the smallest
164
+ // documented cap, LAYER_PART_MAX. Codex documents none either and gets the same.
165
+ export const CLIENTS = [
166
+ { app: "Claude Code", match: /^claude-code$/i, surface: "terminal", max: LAYER_PART_MAX },
167
+ { app: "Codex", match: /^codex-mcp-client$/i, surface: "terminal", max: LAYER_PART_MAX },
168
+ { app: "claude.ai", match: /^(claude-ai|anthropic\/claudeai)$/i, surface: "chat", max: 140_000 },
169
+ { app: "ChatGPT", match: /^openai-mcp$/i, surface: "chat", max: LAYER_PART_MAX },
170
+ ];
171
+
172
+ export function clientProfile(name) {
173
+ const n = typeof name === "string" ? name.trim() : "";
174
+ const known = CLIENTS.find((c) => c.match.test(n));
175
+ if (known) return { app: known.app, surface: known.surface, max: known.max };
176
+ return { app: null, surface: "chat", max: LAYER_PART_MAX };
177
+ }
178
+
179
+ // The owner layer for one surface: the text without the sections tagged for another, and the
180
+ // headings it left out. A terminal reads the text as it is.
181
+ export function forSurface(text, surface) {
182
+ if (surface === "terminal" || !text.includes(SURFACE_TERMINAL)) return { text, left: [] };
183
+ const left = [];
184
+ const kept = text.split(/^(?=## )/m).filter((sec) => {
185
+ const m = /^## (.*)/.exec(sec);
186
+ if (m && terminalOnly(sec)) { left.push(m[1].trim()); return false; }
187
+ return true;
188
+ });
189
+ return { text: kept.join(""), left };
190
+ }
191
+
192
+ // A section the owner marked for a terminal is dropped from a chat app's layer without a word
193
+ // (HQ-2227). It used to be named in a line ("left out here: ..."), and a chat app read that as a
194
+ // list to fetch: claude.ai, 2026-10-05, spent calls asking for sections it could not be given.
195
+
196
+ // The chat budget (HQ-2223): how long the operating layer a chat app reads in its first call may
197
+ // be, in characters, everything in that result counted. Set from two measurements: the generated
198
+ // core, which every chat needs whole, is about 29,100 characters (his IRIS.md, 2026-10-05), and the
199
+ // lines above it (chat section, Dispatch, privacy) about 4,500. A budget of 70,000 leaves the owner's
200
+ // own sections about as much room again as the core, enough for the opening sections a first reply
201
+ // draws on (his reach through "Proactive Context Loading", 29,700 characters), while the whole result
202
+ // stays under LAYER_PART_MAX minus the part reserve, so every chat app (ChatGPT and unknown apps
203
+ // included) takes it in one call, and near 19,000 tokens (3.7 characters a token) instead of the
204
+ // 27,300 his whole layer cost. Past it, a section is one line in an index and is fetched whole by
205
+ // name when a conversation needs it. A coding agent is not budgeted: it gets every section.
206
+ export const CHAT_LAYER_BUDGET = 70_000;
207
+ // The size line ("Operating layer: N characters, in one part.") is written after the layer is cut.
208
+ const SIZE_LINE_RESERVE = 120;
209
+ // A listed section's first line is cut here, at a word, so one long paragraph cannot fill the index.
210
+ const INDEX_LINE_MAX = 200;
211
+
212
+ // The owner layer as units, in order. A unit with a heading is one `## ` section with everything
213
+ // under it; a unit with none is served whole always: the text before the first heading, and the
214
+ // installer's generated fence (the core, in a scaffolded AGENTS.md), which is never listed.
215
+ export function ownerUnits(text) {
216
+ let fence = null;
217
+ try { fence = splitFence(text); } catch { fence = null; }
218
+ const chunks = fence ? [[fence.before, false], [fence.generated, true], [fence.after, false]] : [[text, false]];
219
+ const units = [];
220
+ for (const [chunk, pinned] of chunks) {
221
+ if (!chunk) continue;
222
+ if (pinned) { units.push({ text: chunk, heading: null }); continue; }
223
+ for (const sec of chunk.split(/^(?=## )/m)) {
224
+ if (!sec) continue;
225
+ const m = /^## (.*)/.exec(sec);
226
+ units.push({ text: sec, heading: m ? m[1].trim() : null });
227
+ }
228
+ }
229
+ return units;
230
+ }
231
+
232
+ function firstLineOf(sec) {
233
+ const line = sec.split("\n").slice(1).map((l) => l.trim()).find((l) => l && l !== SURFACE_TERMINAL) ?? "";
234
+ if (line.length <= INDEX_LINE_MAX) return line;
235
+ const cut = line.slice(0, INDEX_LINE_MAX);
236
+ const at = cut.lastIndexOf(" ");
237
+ return `${at > INDEX_LINE_MAX / 2 ? cut.slice(0, at) : cut}...`;
238
+ }
239
+
240
+ // A listed section, where it stands in the owner layer: its heading, how to fetch it, and how it opens.
241
+ function sectionStub(u) {
242
+ return `## ${u.heading}\n\n(Listed here, not served whole, to keep a chat's first call short: call evda_operating_layer with {"section": ${JSON.stringify(u.heading)}} and read it whole before acting on it. It opens: ${firstLineOf(u.text)})\n\n`;
243
+ }
244
+
245
+ // The owner layer as a chat app reads it: whole when the result fits the budget. Otherwise first
246
+ // fit, in the file's order: each section is served whole if the result still fits with it, and is
247
+ // listed where it stands if not, and the next one is tried. One large section early in a file does
248
+ // not push out every smaller one after it (HQ-2223: for Mike, Preferences, how to talk to him).
249
+ // `fixed` is everything else in the result. The stubs are counted from the start, so a section is
250
+ // kept only when the result with it, and with every section not yet decided listed, fits.
251
+ export function fitChat(text, fixed, budget = CHAT_LAYER_BUDGET) {
252
+ if (fixed + text.length <= budget) return { text, listed: [] };
253
+ const units = ownerUnits(text);
254
+ const sections = units.filter((u) => u.heading !== null);
255
+ if (!sections.length) return { text, listed: [] };
256
+ const keep = new Set();
257
+ const render = () => units.map((u) => (u.heading === null || keep.has(u) ? u.text : sectionStub(u))).join("");
258
+ let size = fixed + render().length;
259
+ for (const u of sections) {
260
+ const grow = u.text.length - sectionStub(u).length;
261
+ if (size + grow <= budget) { keep.add(u); size += grow; }
262
+ }
263
+ return { text: render(), listed: sections.filter((u) => !keep.has(u)).map((u) => u.heading) };
264
+ }
265
+
266
+ // One section of the owner layer, whole, by its heading: matched exactly, then without case, with
267
+ // or without a leading "## ". Every section under that heading comes back, in order. A section the
268
+ // owner tagged for a terminal is refused to any other client.
269
+ export function findSection(text, name, surface) {
270
+ if (typeof name !== "string" || !name.trim()) throw tools.refuse('evda_operating_layer "section" is a heading of the owner layer, as the list in the first result names it.', "BAD_ARGUMENT");
271
+ const want = name.trim().replace(/^##\s+/, "").trim();
272
+ const units = ownerUnits(text).filter((u) => u.heading !== null);
273
+ let hits = units.filter((u) => u.heading === want);
274
+ if (!hits.length) hits = units.filter((u) => u.heading.toLowerCase() === want.toLowerCase());
275
+ const offered = [...new Set(units.filter((u) => surface === "terminal" || !terminalOnly(u.text)).map((u) => u.heading))];
276
+ if (!hits.length) throw tools.refuse(`The owner layer has no section ${JSON.stringify(want)}. Its sections are: ${offered.map((h) => JSON.stringify(h)).join(", ")}.`, "NO_SECTION");
277
+ if (surface !== "terminal") {
278
+ const served = hits.filter((u) => !terminalOnly(u.text));
279
+ if (!served.length) throw tools.refuse(`${JSON.stringify(hits[0].heading)} applies only where the agent has a terminal (its owner marked it so), so it is not served in a chat app.`, "TERMINAL_ONLY");
280
+ hits = served;
281
+ }
282
+ return { heading: hits[0].heading, text: hits.map((u) => u.text).join("\n") };
283
+ }
284
+
285
+ // A section fetched whole, in parts when it is longer than the client's part size.
286
+ export function sectionParts(evda, client, name) {
287
+ const profile = clientProfile(client?.name);
288
+ const r = tools.operatingLayer(evda);
289
+ const s = findSection(r.text, name, profile.surface);
290
+ const head = `Section ${JSON.stringify(s.heading)} of the owner layer, whole.`;
291
+ const max = profile.max;
292
+ const pieces = packSections(s.text, max - PART_RESERVE - head.length - 2, max - PART_RESERVE);
293
+ const n = pieces.length;
294
+ const ask = (i) => `{"section": ${JSON.stringify(s.heading)}, "part": ${i}}`;
295
+ const parts = pieces.map((p, i) => {
296
+ const top = i === 0 ? `${head}\n\n` : `Section ${JSON.stringify(s.heading)}, part ${i + 1} of ${n}, continued.\n\n`;
297
+ const end = i < n - 1 ? `\n\n(Part ${i + 1} of ${n} ends here. The section continues: call evda_operating_layer with ${ask(i + 2)} now.)` : "";
298
+ return `${top}${p}${end}`;
299
+ });
300
+ return { path: r.path, heading: s.heading, parts };
301
+ }
302
+
303
+ export function coreBlock(r) {
304
+ if (r.core) return [r.core.text];
305
+ if (r.coreNote) return [r.coreNote];
306
+ return [];
307
+ }
308
+
309
+ export function layerParts(evda, client = null) {
310
+ const profile = clientProfile(client?.name);
311
+ const r = tools.operatingLayer(evda);
312
+ const owner = forSurface(r.text, profile.surface);
313
+ const head = [chatSection(evda, r.text, { app: profile.app }), dispatchSection(profile.surface), PRIVACY_SECTION].join("\n\n");
314
+ const core = coreBlock(r);
315
+ // Everything in the result but the owner sections: head, size line, core, and the joins between.
316
+ const fixed = head.length + 2 + SIZE_LINE_RESERVE + 2 + core.reduce((n, c) => n + c.length + 2, 0);
317
+ const fit = profile.surface === "terminal" ? { text: owner.text, listed: [] } : fitChat(owner.text, fixed);
318
+ const body = [...core, fit.text].join("\n\n");
319
+ const max = profile.max;
320
+ const pieces = packSections(body, max - PART_RESERVE - head.length, max - PART_RESERVE);
321
+ const n = pieces.length;
322
+ const chars = head.length + body.length;
323
+ const size = `Operating layer: ${chars.toLocaleString("en-US")} characters, in ${n === 1 ? "one part" : `${n} parts`}.${n === 1 ? "" : " Read every part before your first answer; each part ends by naming the next."}`;
324
+ const parts = pieces.map((p, i) => {
325
+ const top = i === 0 ? `${head}\n\n${size}\n\n` : `Operating layer, part ${i + 1} of ${n}, continued.\n\n`;
326
+ 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.)` : "";
327
+ return `${top}${p}${end}`;
328
+ });
329
+ return { path: r.path, core: r.core?.path ?? null, parts, chars, surface: profile.surface, left: owner.left, listed: fit.listed };
330
+ }
331
+
332
+ // Split text into pieces of at most `max` characters (the first at most `first`), cutting where a
333
+ // `## ` heading starts; a section longer than a piece is cut at a line, and a line at a character.
334
+ // The pieces joined with "" are the text.
335
+ export function packSections(text, first, max) {
336
+ const units = [];
337
+ for (const sec of text.split(/^(?=## )/m)) {
338
+ if (sec.length <= Math.min(first, max)) { units.push(sec); continue; }
339
+ for (const line of sec.split(/(?<=\n)/)) {
340
+ for (let i = 0; i < line.length; i += max) units.push(line.slice(i, i + max));
341
+ }
342
+ }
343
+ const out = [];
344
+ let cur = "";
345
+ for (const u of units) {
346
+ const cap = out.length === 0 ? first : max;
347
+ if (cur && cur.length + u.length > cap) { out.push(cur); cur = ""; }
348
+ cur += u;
349
+ }
350
+ out.push(cur);
351
+ return out;
352
+ }
100
353
 
101
354
  const str = (description) => ({ type: "string", description });
102
355
 
@@ -106,6 +359,22 @@ const str = (description) => ({ type: "string", description });
106
359
  const NEEDS_A_COMMAND = new Set(["UPDATE_AVAILABLE", "UNSTAMPED"]);
107
360
  const TERMINAL_NOTE = "Run that command in a terminal on this machine. Nothing here can run it: this server tells the owner an update exists, it never installs one.";
108
361
 
362
+ // Layer 1 of HQ-2205: the first words of every tool a chat app shows the model. claude.ai defers
363
+ // connector tools and shows only each one's cut-off first line, and it does not act on the server's
364
+ // instructions, so the first line is the only place a model that never loaded the layer reads that
365
+ // it should. The clause carries its reason in a few words, then the tool's own description follows.
366
+ // evda_write and evda_note keep their trigger as their first words (HQ-1385 measured that the first
367
+ // words are what files) and carry the same clause inside their first sentence instead.
368
+ // HQ-2223: the clause follows each tool's own first sentence. claude.ai's tool search ranks on the
369
+ // description, and when every one opened with this same sentence it took three searches to find
370
+ // evda_write (2026-10-05).
371
+ // HQ-2223, slice 2: the clause no longer names the layer tool. With evda_operating_layer in all 29
372
+ // descriptions, a search for "operating layer" matched every tool equally and returned five that
373
+ // were not it (2026-10-05). So only the layer tool carries those words, and every other tool points
374
+ // at it by what it does; a model that has the name from the server's instructions searches by it.
375
+ export const LAYER_TOOL_REF = "the tool that loads who you are here";
376
+ export const LAYER_FIRST = `Call ${LAYER_TOOL_REF} first in a new chat. `;
377
+
109
378
  // evda_write's description, by the owner's filing answer. It leads with when to call it, because a
110
379
  // deferred connector tool shows the model its name and the first line of its description and
111
380
  // nothing else until it is loaded (HQ-1385). An owner who has not answered is filed for as if they
@@ -113,28 +382,116 @@ const TERMINAL_NOTE = "Run that command in a terminal on this machine. Nothing h
113
382
  // EVDA_CHAT_FALSIFY=old-description serves the description from before HQ-1385, which says what the
114
383
  // tool does and never when, for the test that shows the first line is what files.
115
384
  export const WRITE_DESCRIPTION = {
116
- everything: "Call on every message the owner sends, unless automatic filing is off: files their words verbatim in today's daily file, one section per conversation (pass context, one line on what it is about, with the first), and returns the path. Append only. Refuses anything shaped like a password, key or token.",
117
- asked: "Call only when the owner asks you to save or file something (they turned automatic filing off): files their words verbatim in today's daily file, one section per conversation, and returns the path. Append only. Refuses anything shaped like a password, key or token.",
385
+ everything: `Call on every message the owner sends (in a new chat, after ${LAYER_TOOL_REF}), unless automatic filing is off: files the owner's message verbatim and writes it to today's daily file, one section per chat (with the first, pass context: one line on what it is about), and returns the path. Append only. Refuses a password, key or token.`,
386
+ asked: `Call only when the owner asks you to save or file something (in a new chat, after ${LAYER_TOOL_REF}; they turned automatic filing off): files the owner's message verbatim and writes it to today's daily file, one section per chat, and returns the path. Append only. Refuses anything shaped like a password, key or token.`,
118
387
  old: "Appends the owner's words, exactly as they said them, to today's daily file under the local time, with one line of context on what the conversation was about when given, and returns the path. Append only: it takes no path and never edits or replaces anything. Refuses anything shaped like a password, key or token.",
119
388
  };
120
389
  const chatFalsified = (mode, env = process.env) => (env.EVDA_CHAT_FALSIFY ?? "").split(",").map((x) => x.trim()).includes(mode);
121
390
 
391
+ const OWN_TRIGGER_FIRST = new Set(["evda_operating_layer", "evda_write", "evda_note"]);
392
+
122
393
  // The tool list a client sees, with the descriptions that depend on the owner's filing answer.
123
- export function toolList(filing, env = process.env) {
124
- return TOOLS.map(({ run, ...t }) => {
125
- if (t.name !== "evda_write") return t;
126
- const description = chatFalsified("old-description", env) ? WRITE_DESCRIPTION.old : WRITE_DESCRIPTION[filing === "asked" ? "asked" : "everything"];
127
- return { ...t, description };
394
+ export function toolList(filing, env = process.env, only = null) {
395
+ return TOOLS.filter((t) => !only || only.has(t.name)).map(({ run, ...t }) => {
396
+ if (t.name === "evda_write") {
397
+ const description = chatFalsified("old-description", env) ? WRITE_DESCRIPTION.old : WRITE_DESCRIPTION[filing === "asked" ? "asked" : "everything"];
398
+ return { ...t, description };
399
+ }
400
+ if (OWN_TRIGGER_FIRST.has(t.name)) return t;
401
+ const lead = firstSentence(t.description);
402
+ return { ...t, description: `${lead} ${LAYER_FIRST}${t.description.slice(lead.length).trimStart()}`.trimEnd() };
128
403
  });
129
404
  }
130
405
 
406
+ // The tools a chat app is offered (HQ-2227). claude.ai defers every connector tool behind a search
407
+ // that returns about five, so each tool a chat needs can cost a search, and 37 tools made the first
408
+ // answer take about 7 calls (2026-10-05). The rule: a chat app gets every tool the operating layer it
409
+ // reads tells it to use, and nothing else. So the set is derived from that layer, for this record and
410
+ // this client: every evda_* name in it (the chat lines, the core, the owner sections a chat may read,
411
+ // listed ones included), every `evda <command>` it says to run mapped to the tool that does it here,
412
+ // and the few tools its prose asks for without a name. A coding agent gets every tool.
413
+ //
414
+ // A command the core names must be in CLI_TOOLS, mapped to the tools that do it in a chat or to none
415
+ // with the reason; test/chat-tools.test.mjs fails on one that is not, so a new command is a decision.
416
+ export const CLI_TOOLS = {
417
+ "check-update": ["evda_check_update"],
418
+ acknowledge: ["evda_acknowledge"],
419
+ preset: ["evda_presets", "evda_propose_structure", "evda_apply_preset"],
420
+ note: ["evda_note"],
421
+ release: ["evda_release_note"],
422
+ // "after structural changes when the platform allows it": in a chat the structural tools
423
+ // (evda_apply_preset, evda_create_subrecord) report what doctor would, so it is not offered there.
424
+ doctor: [],
425
+ // The installer's own commands, run in a terminal; nothing here runs them.
426
+ upgrade: [],
427
+ "local-model": [],
428
+ };
429
+ // What the core asks for in prose, without a tool name: reading by path and searching (every
430
+ // session, "Read the last few days ... and anything they point you at"), an entry or a person's page
431
+ // growing ("Entries grow on their own"), the setup answers, an import ("An import is what another AI said"), and the filing
432
+ // answer ("ask them once ... and record the answer there").
433
+ // HQ-2228: an entry growing is evda_append (any entry) beside evda_record_person_note (a person's
434
+ // page), and setup ("Write each answer into that section in their exact words") is evda_setup_answer.
435
+ export const CHAT_PROSE_TOOLS = ["evda_operating_layer", "evda_write", "evda_read", "evda_search", "evda_recent", "evda_record_person_note", "evda_append", "evda_setup_answer", "evda_import", "evda_filing"];
436
+
437
+ // The tool names a text tells an agent to use: evda_* names and `evda <command>` runs.
438
+ export function toolsNamedIn(text) {
439
+ const names = new Set(text.match(/\bevda_[a-z_]+\b/g) ?? []);
440
+ for (const m of text.matchAll(/`evda ([a-z][a-z-]*)/g)) for (const t of CLI_TOOLS[m[1]] ?? []) names.add(t);
441
+ return names;
442
+ }
443
+
444
+ export function chatToolNames(evda, { app = null } = {}) {
445
+ const known = new Set(TOOLS.map((t) => t.name));
446
+ const names = new Set(CHAT_PROSE_TOOLS);
447
+ let text = "";
448
+ try {
449
+ const r = tools.operatingLayer(evda);
450
+ const owner = forSurface(r.text, "chat").text;
451
+ text = [chatSection(evda, r.text), CHAT_DISPATCH_SECTION, PRIVACY_SECTION, ...coreBlock(r), owner].join("\n\n");
452
+ } catch { text = chatSection(evda, ""); }
453
+ text += `\n\n${instructions(filingOf(evda.manifest), "chat")}`;
454
+ for (const n of toolsNamedIn(text)) names.add(n);
455
+ // The every-chat walkthrough rides on a chat's first result while this app has no answer.
456
+ if (needsEveryChat(evda.manifest, app, filingOf(evda.manifest))) names.add("evda_every_chat");
457
+ return new Set([...names].filter((n) => known.has(n)));
458
+ }
459
+
460
+ // A description's first sentence: up to the first period that ends a sentence, so not one in an
461
+ // ellipsis or a dotted name such as evda.json.
462
+ export function firstSentence(text) {
463
+ const m = /^.*?[^.]\.(?=\s|$)/s.exec(text);
464
+ return m ? m[0] : text;
465
+ }
466
+
131
467
  // The one setup question, asked at first connect when evda.json has no filing answer (HQ-1385).
132
468
  export const FILING_QUESTION = "Should I file everything you say here automatically, or only when you ask? (everything)";
133
469
 
134
470
  // The few lines a chat app reads before the whole operating layer (HQ-1385): who the agent is,
135
471
  // whether to file, one section per conversation, no share marks or circle rules, and "just note".
136
472
  // Five lines, and a sixth that asks the setup question when the owner has not answered it.
137
- export function chatSection(evda, agentsText = "") {
473
+ // HQ-2228: in a claude.ai chat whose setup is not finished, one line telling the owner that Always
474
+ // allow on this connector makes every chat easier, and where it is. A connector cannot grant itself
475
+ // permission, so it is a recommendation with its reason, never a step setup waits on. Only claude.ai,
476
+ // whose setting this names; another app's is somewhere else, and a setting is never named from memory.
477
+ export const ALWAYS_ALLOW_LINE = "In claude.ai, setting the EVÐA connector to Always allow makes every chat easier: filing and reading happen without them approving each call. Tell them once during this setup, in one line, with where it is: claude.ai Settings, Connectors, EVÐA, tool permissions. It is their choice, and setup goes on either way.";
478
+
479
+ // THE NOW LINE (HQ-2268). A chat model has no clock and guesses the time, or reads a UTC stamp as
480
+ // the owner's; each surface that worked the time out on its own was wrong once (HQ-713, HQ-2199,
481
+ // HQ-2236). So the agent is handed the time, in the owner's zone, at the top of what it reads first.
482
+ // The zone is this process's own (TZ). The package stays zone-agnostic: whoever runs the server
483
+ // sets TZ to where the owner is, and EVDA_ZONE_SOURCE to how it knows (Hestia: his-zone.mjs, every
484
+ // five minutes). Same shape as the line Hestia's passes open with: `Now: Tue 10/6 9:58 AM PDT (America/Los_Angeles, from ...)`.
485
+ export function nowLine(at = new Date(), env = process.env) {
486
+ const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
487
+ const p = Object.fromEntries(new Intl.DateTimeFormat("en-US", {
488
+ timeZone: zone, weekday: "short", month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short",
489
+ }).formatToParts(at).map((x) => [x.type, x.value]));
490
+ const source = String(env.EVDA_ZONE_SOURCE ?? "").trim() || "this machine's clock";
491
+ return `Now: ${p.weekday} ${p.month}/${p.day} ${p.hour}:${p.minute} ${p.dayPeriod} ${p.timeZoneName} (${zone}, from ${source})`;
492
+ }
493
+
494
+ export function chatSection(evda, agentsText = "", { app = null } = {}) {
138
495
  const field = (label) => new RegExp(`^- \\*\\*${label}:\\*\\*[ \\t]*(\\S.*)$`, "m").exec(agentsText)?.[1]?.trim() ?? "";
139
496
  const agent = field("Agent name") || "Iris";
140
497
  const owner = (typeof evda.manifest?.owner?.name === "string" && evda.manifest.owner.name.trim()) || field("Who I am") || "the owner";
@@ -142,6 +499,8 @@ export function chatSection(evda, agentsText = "") {
142
499
  const lines = [
143
500
  "## In a chat app, read this first",
144
501
  "",
502
+ `${nowLine()}. That is the owner's local time: use it for "today", "tonight" and any time you say, and never work it out from a timestamp.`,
503
+ "",
145
504
  `- You are ${agent}, ${owner}'s thinking partner, and this server is their EVÐA; the full operating layer follows and holds for the whole session.`,
146
505
  filing === "asked"
147
506
  ? "- They turned automatic filing off: file their words with evda_write only when they ask you to."
@@ -149,7 +508,15 @@ export function chatSection(evda, agentsText = "") {
149
508
  "- One conversation is one section: your first evda_write opens it with one line of context, and the rest go under it.",
150
509
  "- Never write a share mark or a circle rule. When they want to share, propose it and wait for their yes.",
151
510
  "- \"Just note this\" means evda_note: no AI reads that entry, you included, until they say it can.",
511
+ "- When a person, project or place that already has an entry comes up, add a dated section to it with evda_append (a person's page with evda_record_person_note). Neither creates or overwrites anything.",
152
512
  ];
513
+ // HQ-2164: what the agent itself did goes to its own day log. Named here, so a chat app is offered
514
+ // the tool (chatToolNames offers what this layer names), and only when the record has that log.
515
+ if (tools.hasAgentLog(evda)) lines.push("- What you yourself did (built, filed, fixed, found) goes to your own day log with evda_write_agent_log, never into their words.");
516
+ if (tools.setupPending(agentsText)) {
517
+ lines.push("- Setup is not finished: write each answer into \"What this EVÐA is\" with evda_setup_answer, in their exact words.");
518
+ if (app === "claude.ai") lines.push(`- ${ALWAYS_ALLOW_LINE}`);
519
+ }
153
520
  if (filing === null && !chatFalsified("no-consent-question")) lines.push(`- They have not said how filing works yet. Before anything else, ask this one line and nothing more: "${FILING_QUESTION}" Record the answer with evda_filing, then file the messages before it too if they said everything.`);
154
521
  return lines.join("\n");
155
522
  }
@@ -166,30 +533,158 @@ export function setFiling(evda, args = {}) {
166
533
  return { filing: args.filing, changed: true };
167
534
  }
168
535
 
536
+ // Record whether the owner put the every-chat line in one app (HQ-2205, layer 4), so the next chat
537
+ // in that app is not asked again. It writes one entry under evda.json "every_chat" and no other key:
538
+ // the rest of the manifest is written back as it was read. The app defaults to the one this session
539
+ // connected from, which is what the walkthrough named.
540
+ export const EVERY_CHAT_APPS = [...Object.keys(CHAT_APPS), "other"];
541
+ export const EVERY_CHAT_STATES = ["set", "declined"];
542
+ export function setEveryChat(evda, args = {}, { app: sessionApp = null, now = new Date() } = {}) {
543
+ tools.onlyKeys("evda_every_chat", args, ["app", "state", "confirmed"]);
544
+ const app = args.app ?? sessionApp;
545
+ if (!EVERY_CHAT_APPS.includes(app)) throw tools.refuse(`evda_every_chat "app" is ${EVERY_CHAT_APPS.map((a) => JSON.stringify(a)).join(", ")}: the app the owner set it in.`, "BAD_APP");
546
+ if (!EVERY_CHAT_STATES.includes(args.state)) throw tools.refuse(`evda_every_chat "state" is "set" (they added the line) or "declined" (they do not want it).`, "BAD_STATE");
547
+ if (args.confirmed !== true) throw tools.refuse("evda_every_chat records what the owner told you. Ask them, and pass confirmed: true only with what they said in this conversation.", "NOT_CONFIRMED");
548
+ const m = readManifest(evda.dir);
549
+ const all = m[EVERY_CHAT_KEY] && typeof m[EVERY_CHAT_KEY] === "object" && !Array.isArray(m[EVERY_CHAT_KEY]) ? m[EVERY_CHAT_KEY] : {};
550
+ const form = filingOf(m) === "asked" ? "asked" : "everything";
551
+ const entry = { state: args.state, form, version: EVERY_CHAT_VERSION, date: tools.localDate(now) };
552
+ m[EVERY_CHAT_KEY] = { ...all, [app]: entry };
553
+ writeManifest(evda.dir, m);
554
+ return { app, ...entry };
555
+ }
556
+
557
+ // What an earlier reader already acknowledged in a file (HQ-2060), as one attribute on its tag, so
558
+ // a reader sees the mark in the same breath as the words and never twice-acts on the same entry.
559
+ // Outside the file's text on purpose: nothing the agent writes goes inside the owner's words.
560
+ export function ackAttr(acknowledged = []) {
561
+ if (!acknowledged?.length) return "";
562
+ const esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
563
+ const parts = acknowledged.map((a) => `${a.section ? `${a.section}: ` : ""}${esc(a.by ?? "an earlier reader")} at ${esc(a.at)} ${esc(a.note)}`);
564
+ return ` acknowledged="${parts.join(" | ")}"`;
565
+ }
566
+
567
+ export function acknowledgedNote(n) {
568
+ return `${n} entr${n === 1 ? "y" : "ies"} here ${n === 1 ? "is" : "are"} already acknowledged (the acknowledged attribute says who and what they did): do not raise ${n === 1 ? "it" : "them"} again unless the owner does. Anything weighty with no mark is yours to acknowledge once, with evda_acknowledge.`;
569
+ }
570
+
571
+ // The ride-along arguments on evda_read and evda_search (HQ-2227): the owner's message this turn,
572
+ // filed by fileAlong when the call runs, so a turn that reads files in the same call. evda_write
573
+ // stays the tool for a turn with nothing to read.
574
+ export const ALONG_LINE = "Pass the owner's message this turn as owner_message (and context the first time) and it is filed verbatim as evda_write files it, once per message, so you need no separate evda_write.";
575
+ const ALONG_PROPS = {
576
+ owner_message: str("The owner's message this turn, verbatim: filler, hedges and typos intact. Filed as evda_write files it, unless they turned automatic filing off. Never a summary."),
577
+ context: str("With owner_message only: one line, yours, on what the chat is about. Written above their words and labeled as not theirs."),
578
+ };
579
+
580
+ // Run a read or a search with its ride-along: the tool's own arguments go to the tool, and the
581
+ // filing line follows its result. The filing runs first, so a refused read still files the message.
582
+ function withAlong(evda, args, ctx, toolName, run) {
583
+ const { owner_message, context, ...rest } = args ?? {};
584
+ if (owner_message === undefined) {
585
+ if (context !== undefined) throw tools.refuse(`${toolName} "context" goes with "owner_message": it is the line above the owner's message. Pass both, or neither.`, "BAD_ARGUMENT");
586
+ return run(rest);
587
+ }
588
+ if (typeof owner_message !== "string" || !owner_message.trim()) throw tools.refuse(`${toolName} "owner_message" is the owner's message this turn, verbatim, as text; leave it out when there is none.`, "BAD_ARGUMENT");
589
+ const f = fileAlong(evda, { words: owner_message, context }, ctx, toolName);
590
+ let r;
591
+ try {
592
+ r = run(rest);
593
+ } catch (e) {
594
+ if (e instanceof EvdaError) throw new EvdaError(`${e.message}\n\n${f.head}`, { code: e.code, exit: e.exit });
595
+ throw e;
596
+ }
597
+ return { ...r, text: `${r.text}\n\n---\n\n${f.head}`, filed: f.filed ? f.path : null };
598
+ }
599
+
169
600
  export const TOOLS = [
170
601
  {
171
602
  name: "evda_operating_layer",
172
603
  title: "Read the operating layer",
173
- description: "Call first, every session: returns how to act here, a few lines for a chat app, what leaves this device, and then AGENTS.md whole, the record's operating layer (who the agent is, what this EVDA is for in the owner's words, and how the record is kept).",
174
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
175
- annotations: { readOnlyHint: true, openWorldHint: false },
176
- run: (evda, args) => {
177
- const r = tools.operatingLayer(evda, args);
178
- return { text: `${chatSection(evda, r.text)}\n\n${DISPATCH_SECTION}\n\n${PRIVACY_SECTION}\n\n${r.text}`, data: { path: r.path } };
604
+ // HQ-2223, slice 2: the first sentence holds the words a model searches by when it starts a chat
605
+ // as the owner's agent (who you are, the owner's record, a new chat), and this is the one tool
606
+ // whose definition says "operating layer", so a search for it finds this tool and no other.
607
+ description: "Loads the operating layer: who you are for the owner and how their record is kept, to read at the start of every new chat before your first reply. Pass the owner's first message as first_message (and context, one line on what the chat is about) and it is filed verbatim in this same call, exactly as evda_write files it, and the result also carries their unseen replies and the recent days' headings, so you can answer after this one call. Returns how to act here, a few lines for a chat app, what leaves this device, the core of how the agent thinks, and then AGENTS.md (who the agent is, what this EVDA is for in the owner's words, and how the record is kept). A long layer comes in parts; read every part before answering. In a chat app, owner sections past a size budget are listed by heading; pass section to read one whole.",
608
+ inputSchema: {
609
+ type: "object",
610
+ properties: {
611
+ part: { type: "integer", minimum: 1, description: "Which part of a long layer or section. Default 1; the end of each part names the next." },
612
+ section: { type: "string", description: "A heading from the layer's list of sections, to read that section whole." },
613
+ first_message: { type: "string", description: "The owner's first message in this chat, verbatim: filler, hedges and typos intact. Filed as evda_write files it, unless they turned automatic filing off. Never a summary." },
614
+ context: { type: "string", description: "With first_message only: one line, yours, on what the chat is about. Written above their words and labeled as not theirs." },
615
+ },
616
+ additionalProperties: false,
617
+ },
618
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
619
+ run: (evda, args, ctx = {}) => {
620
+ tools.onlyKeys("evda_operating_layer", args, ["part", "section", "first_message", "context"]);
621
+ const part = args.part === undefined ? 1 : args.part;
622
+ const opening = args.first_message !== undefined;
623
+ if (args.context !== undefined && !opening) throw tools.refuse('evda_operating_layer "context" goes with "first_message": it is the line above the owner\'s first message. Pass both, or neither.', "BAD_ARGUMENT");
624
+ if (opening) {
625
+ if (typeof args.first_message !== "string" || !args.first_message.trim()) throw tools.refuse('evda_operating_layer "first_message" is the owner\'s first message in this chat, verbatim, as text; leave it out when there is none.', "BAD_ARGUMENT");
626
+ if (args.section !== undefined || part !== 1) throw tools.refuse('evda_operating_layer "first_message" goes on the first call of a chat, with no "section" and no later "part": the message is filed once, with the start of the layer.', "BAD_ARGUMENT");
627
+ }
628
+ if (args.section !== undefined) {
629
+ const s = sectionParts(evda, ctx.client, args.section);
630
+ 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");
631
+ return { text: s.parts[part - 1], data: { path: s.path, section: s.heading, part, parts: s.parts.length } };
632
+ }
633
+ const l = layerParts(evda, ctx.client);
634
+ 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");
635
+ const data = { path: l.path, core: l.core, part, parts: l.parts.length, chars: l.chars, listed: l.listed };
636
+ if (!opening) return { text: l.parts[part - 1], data };
637
+ const start = openChat(evda, args, ctx);
638
+ return { text: `${l.parts[part - 1]}\n\n---\n\n${start.text}`, data: { ...data, filed: start.filed } };
179
639
  },
180
640
  },
181
641
  {
182
642
  name: "evda_recent",
183
643
  title: "Read the last few days",
184
- description: "Returns every record file dated in the last N days (default 3), newest first, whole.",
644
+ description: "Lists the record's files from the last few days by path and headings. N days (default 3), newest first, never a word of the body. Read a specific one whole with evda_read, by the path this returns. Each file says what an earlier reader already acknowledged in it, which you do not raise again.",
185
645
  inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 90, description: "How many days back, today included. Default 3." } }, additionalProperties: false },
186
646
  annotations: { readOnlyHint: true, openWorldHint: false },
187
647
  run: (evda, args, ctx) => {
188
- const r = tools.recent(evda, args, { caller: ctx.caller });
189
- const body = r.files.map((f) => `<file path="${f.path}" date="${f.date}">\n${f.text}\n</file>`).join("\n\n");
190
- const head = `${r.files.length} file(s) dated ${r.from} to ${r.to}${r.omitted ? `, ${r.omitted} more omitted for size (read them with evda_search)` : ""}.`;
648
+ const r = tools.recent(evda, args, { caller: ctx.caller, now: ctx.now });
649
+ const body = r.files.map((f) => `<file path="${f.path}" date="${f.date}"${ackAttr(f.acknowledged)}>${f.headings.length ? `\n${f.headings.map((h) => `# ${h}`).join("\n")}` : ""}\n</file>`).join("\n\n");
650
+ const head = `${r.files.length} file(s) dated ${r.from} to ${r.to}, headings only, no body text (read one whole with evda_read when the question needs it)${r.omitted ? `, ${r.omitted} more omitted for size (read them with evda_search)` : ""}.${r.acknowledged ? ` ${acknowledgedNote(r.acknowledged)}` : ""}`;
191
651
  const main = r.files.length ? `${head}\n\n${body}` : `Nothing${r.refused.length ? " you may read" : ""} in the record is dated ${r.from} to ${r.to}.`;
192
- return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: { from: r.from, to: r.to, paths: r.files.map((f) => f.path), omitted: r.omitted } };
652
+ return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: { from: r.from, to: r.to, paths: r.files.map((f) => f.path), omitted: r.omitted, acknowledged: r.acknowledged } };
653
+ },
654
+ },
655
+ {
656
+ name: "evda_acknowledge",
657
+ title: "Mark that you acted on a weighty entry",
658
+ description: "Marks a weighty entry acknowledged so no other reader raises it again. Call once you have said a weighty entry back to the owner (a correction, a decision, a hard moment), so no other reader of this record says it again. Writes one mark beside the record, never into the entry. The first reader to reach an entry is the one that acts: when it is already marked this writes nothing, returns who did it and what they did, and you say nothing about that entry unless the owner brings it up. evda_recent shows the marks on every file it returns.",
659
+ inputSchema: {
660
+ type: "object",
661
+ properties: {
662
+ path: str("The entry you acted on, as a path inside the record: the path evda_recent returned."),
663
+ section: str("That section's heading exactly as the file writes it (\"## 14:03\"), when the weight is in one section of a day file rather than the whole entry."),
664
+ note: str("One line: what you did about it. The next reader reads this instead of acting again."),
665
+ by: str("Who you are, when that is not the agent this record names: a scheduled run, an always-on agent."),
666
+ },
667
+ required: ["path", "note"],
668
+ additionalProperties: false,
669
+ },
670
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
671
+ run: (evda, args) => {
672
+ const r = tools.acknowledgeEntry(evda, args);
673
+ const what = r.section ? `${r.path} "${r.section}"` : r.path;
674
+ if (!r.first) return { text: `Already acknowledged by ${r.by ?? "an earlier reader"} at ${r.at}: ${r.note}\n\nNothing was written. Say nothing about ${what} unless the owner brings it up.`, data: r };
675
+ return { text: `Marked ${what} acknowledged, as ${r.by}. You reached it first, so it is yours to say back to them; every later reader sees this mark and stays quiet.`, data: r };
676
+ },
677
+ },
678
+ {
679
+ name: "evda_acknowledgements",
680
+ title: "What readers have already acknowledged",
681
+ description: "Lists the entries an earlier reader already said back to the owner. Each mark says who did it, when, and what they did. Read only.",
682
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
683
+ annotations: { readOnlyHint: true, openWorldHint: false },
684
+ run: (evda, args) => {
685
+ const r = tools.acknowledgements(evda, args);
686
+ if (!r.count) return { text: "Nothing in this record is acknowledged yet.", data: r };
687
+ return { text: `${r.count} acknowledged:\n${r.marks.map((m) => `- ${m.key} (${m.by ?? "unknown"}, ${m.at}): ${m.note}`).join("\n")}`, data: r };
193
688
  },
194
689
  },
195
690
  {
@@ -198,15 +693,109 @@ export const TOOLS = [
198
693
  description: WRITE_DESCRIPTION.everything,
199
694
  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 },
200
695
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
696
+ run: async (evda, args, ctx) => {
697
+ const dup = sameAsFiled(ctx.session, args?.words);
698
+ if (dup) return { text: `Already filed: these words were filed in this chat a moment ago (with the layer call, a read or a search), at ${dup.path}. Nothing was written again.`, data: { path: dup.path, deduped: true } };
699
+ // HQ-2165: writeVerified reads the words back after a short delay and throws instead of
700
+ // returning, so a clobbered write is never reported to the owner as "Filed".
701
+ const r = await tools.writeVerified(evda, args, { session: ctx.session });
702
+ markWrite(ctx.session, args.words, r.path);
703
+ return { text: `Filed to ${r.path}.`, data: r };
704
+ },
705
+ },
706
+ {
707
+ name: "evda_record_correction",
708
+ title: "Record a correction",
709
+ description: "Records a correction as one dated line in what the agent has been taught. Call when the owner corrects you (\"no, don't do that\", \"that's not how I work anymore\"): appends one dated line to AGENTS.md's \"What I have been taught\" section, in the owner's own words where they gave some, and returns the path. That section is always outside the installer's block, so `evda upgrade` never touches what this writes. Append only: it never edits or removes an earlier entry. When a correction reverses one, pass supersedes with that entry's date instead of trying to change it.",
710
+ inputSchema: {
711
+ type: "object",
712
+ properties: {
713
+ words: str("The owner's exact words, quoted verbatim, when they gave some. Omit rather than paraphrase."),
714
+ change: str("One line: what you do differently now."),
715
+ supersedes: str("YYYY-MM-DD of an earlier entry this correction reverses, if any."),
716
+ },
717
+ required: ["change"],
718
+ additionalProperties: false,
719
+ },
720
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
721
+ run: (evda, args, ctx) => {
722
+ const r = tools.recordCorrection(evda, args);
723
+ return { text: `Recorded to ${r.path}, under "${r.section}".`, data: r };
724
+ },
725
+ },
726
+ {
727
+ name: "evda_write_agent_log",
728
+ title: "File Iris's own day log",
729
+ 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 and reaches nowhere else. 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.",
730
+ 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 },
731
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
201
732
  run: (evda, args, ctx) => {
202
- const r = tools.write(evda, args, { session: ctx.session });
733
+ const r = tools.writeAgentLog(evda, args, { session: ctx.session });
203
734
  return { text: `Filed to ${r.path}.`, data: r };
204
735
  },
205
736
  },
737
+ {
738
+ name: "evda_record_person_note",
739
+ title: "Add to a person's page",
740
+ description: "Adds one dated section to an existing person's page, found by their name. The page is under the record/people role; returns the path. Resolves the page by the name you give, not a path; refuses when no page matches (it never creates one; offer to make the page first) and when more than one page matches. Refuses a sealed page. When the page's frontmatter is kind: person-profile, also adds a matching observations entry so the page still passes evda doctor. Append only: an earlier section is never edited or removed.",
741
+ inputSchema: {
742
+ type: "object",
743
+ properties: {
744
+ person: str("The name on their page, e.g. the file's name without .md."),
745
+ context: str("One line: what this section is. Becomes the heading and, for a person-profile page, the observation's text."),
746
+ words: str("The section's words."),
747
+ },
748
+ required: ["person", "context", "words"],
749
+ additionalProperties: false,
750
+ },
751
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
752
+ run: (evda, args) => {
753
+ const r = tools.recordPersonNote(evda, args);
754
+ return { text: `Added to ${r.path}${r.observation ? `, observation ${r.observation}` : ""}.`, data: r };
755
+ },
756
+ },
757
+ {
758
+ name: "evda_append",
759
+ title: "Add to an existing entry",
760
+ description: "Adds one dated section to an entry that already exists, by its path. Call when a project, place or other entry the record already has comes up again: appends `## YYYY-MM-DD` and the words to the end of that file, or continues today's section when it is already the last one, and returns the path. Every byte already there stays as it was. Refuses a path with no entry (it never creates one; offer to start the page), a sealed entry, a just-noted entry, a part set to be read by a different AI, and anything shaped like a password, key or token. A person's page goes through evda_record_person_note, the day's words through evda_write.",
761
+ inputSchema: {
762
+ type: "object",
763
+ properties: {
764
+ path: str("The existing entry, relative to the record, as evda_search or evda_recent gave it."),
765
+ words: str("The new section's words. Their experience in their words; never another person's motive stated as fact."),
766
+ },
767
+ required: ["path", "words"],
768
+ additionalProperties: false,
769
+ },
770
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
771
+ run: (evda, args, ctx) => {
772
+ const r = tools.appendEntry(evda, args, { caller: ctx.caller });
773
+ return { text: r.section === "continued" ? `Added to the ${r.date} section of ${r.path} (continued).` : `Added a ${r.date} section to ${r.path}.`, data: r };
774
+ },
775
+ },
776
+ {
777
+ name: "evda_setup_answer",
778
+ title: "Write a setup answer",
779
+ description: `Writes one setup answer, in the owner's exact words. Call during setup, once per answer: fills one empty field of AGENTS.md's "What this EVÐA is" (${tools.SETUP_FIELDS.map((f) => JSON.stringify(f)).join(", ")}) and changes nothing else in the file, and returns the path. Refuses a field that already has an answer (changing it is the owner's own edit), more than one line, and anything shaped like a password, key or token.`,
780
+ inputSchema: {
781
+ type: "object",
782
+ properties: {
783
+ field: { type: "string", enum: tools.SETUP_FIELDS, description: "Which field of \"What this EVÐA is\" this answers." },
784
+ words: str("Their answer, exactly as they said it: hedges intact, one line. Never a summary."),
785
+ },
786
+ required: ["field", "words"],
787
+ additionalProperties: false,
788
+ },
789
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
790
+ run: (evda, args) => {
791
+ const r = tools.setupAnswer(evda, args);
792
+ return { text: `Wrote "${r.field}" in ${r.path}, in their words.`, data: r };
793
+ },
794
+ },
206
795
  {
207
796
  name: "evda_import",
208
797
  title: "File an import from another AI",
209
- description: "Files what another AI (ChatGPT, Claude, Gemini...) said it knows about the owner, verbatim, under imports/ with its source and date, and returns the path. An import is not the owner's words; each line is a claim to check with them over time.",
798
+ description: "Files what another AI said it knows about the owner, verbatim, under imports/. Pass its source (ChatGPT, Claude, Gemini...) and date; returns the path. An import is not the owner's words; each line is a claim to check with them over time.",
210
799
  inputSchema: {
211
800
  type: "object",
212
801
  properties: { text: str("The other AI's answer, verbatim."), source: str("Which AI or app said it."), date: str("YYYY-MM-DD the other AI wrote it. Default today.") },
@@ -222,32 +811,32 @@ export const TOOLS = [
222
811
  {
223
812
  name: "evda_search",
224
813
  title: "Search the record",
225
- description: "Plain text search, case-insensitive, over the record, its imports and the ledger, on this machine. Nothing leaves it. Returns each matching line with its path and line number.",
226
- inputSchema: { type: "object", properties: { query: str("Words to find."), limit: { type: "integer", minimum: 1, maximum: 500, description: "Most lines to return. Default 50." } }, required: ["query"], additionalProperties: false },
227
- annotations: { readOnlyHint: true, openWorldHint: false },
228
- run: (evda, args, ctx) => {
229
- const r = tools.search(evda, args, { caller: ctx.caller });
814
+ description: `Searches the record for words, plain text and case-insensitive. It covers the record, its imports and the ledger, on this machine. Nothing leaves it. Returns each matching line with its path and line number. ${ALONG_LINE}`,
815
+ inputSchema: { type: "object", properties: { query: str("Words to find."), limit: { type: "integer", minimum: 1, maximum: 500, description: "Most lines to return. Default 50." }, ...ALONG_PROPS }, required: ["query"], additionalProperties: false },
816
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
817
+ run: (evda, args, ctx) => withAlong(evda, args, ctx, "evda_search", (rest) => {
818
+ const r = tools.search(evda, rest, { caller: ctx.caller });
230
819
  const lines = r.matches.map((m) => `${m.path}:${m.line}: ${m.text}`);
231
820
  const tail = r.more ? `\n${r.more} more match(es) past the limit.` : "";
232
821
  const main = r.matches.length ? `${r.matches.length} match(es) in ${r.searched} file(s):\n${lines.join("\n")}${tail}` : `No match for ${JSON.stringify(r.query)} in ${r.searched} file(s).`;
233
822
  return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: r };
234
- },
823
+ }),
235
824
  },
236
825
  {
237
826
  name: "evda_read",
238
827
  title: "Read one file of the record",
239
- description: "Returns one file of the record, whole, by its path. Refuses, with the reason, a sealed entry, a just-noted entry and a file whose circle is set to be read by a different AI; a sealed section is replaced by one line saying it is there.",
240
- inputSchema: { type: "object", properties: { path: str("The file's path, relative to the record.") }, required: ["path"], additionalProperties: false },
241
- annotations: { readOnlyHint: true, openWorldHint: false },
242
- run: (evda, args, ctx) => {
243
- const r = tools.readFile(evda, args, { caller: ctx.caller });
828
+ description: `Returns one file of the record, whole, by its path. Refuses, with the reason, a sealed entry, a just-noted entry and a file whose circle is set to be read by a different AI; a sealed section is replaced by one line saying it is there. ${ALONG_LINE}`,
829
+ inputSchema: { type: "object", properties: { path: str("The file's path, relative to the record."), ...ALONG_PROPS }, required: ["path"], additionalProperties: false },
830
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
831
+ run: (evda, args, ctx) => withAlong(evda, args, ctx, "evda_read", (rest) => {
832
+ const r = tools.readFile(evda, rest, { caller: ctx.caller });
244
833
  return { text: `<file path="${r.path}">\n${r.text}\n</file>${r.truncated ? "\n(truncated for size)" : ""}` };
245
- },
834
+ }),
246
835
  },
247
836
  {
248
837
  name: "evda_note",
249
838
  title: "Just note something",
250
- description: "Call only when the owner says to just note something: files their words verbatim as a noted capture and returns the path. No AI reads a noted entry, this one included, until the owner says it can; it stays on their devices and in their own search. Refuses anything shaped like a password, key or token.",
839
+ description: `Call only when the owner says to just note something (in a new chat, after ${LAYER_TOOL_REF}): files their words verbatim as a noted capture and returns the path. No AI reads a noted entry, this one included, until the owner says it can; it stays on their devices and in their own search. Refuses anything shaped like a password, key or token.`,
251
840
  inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim.") }, required: ["words"], additionalProperties: false },
252
841
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
253
842
  run: (evda, args) => {
@@ -258,18 +847,18 @@ export const TOOLS = [
258
847
  {
259
848
  name: "evda_message",
260
849
  title: "Tell the owner something",
261
- description: "Sends the owner a message: writes it as one new file in the record's messages folder, which their phone app shows as a card, and returns its id and path. Use it for what they should hear from you (what you did, a question for them); a reply comes back through evda_replies. Never edits a message once written. Refuses anything shaped like a password, key or token.",
262
- inputSchema: { type: "object", properties: { title: str("One line: what the card says at a glance."), body: str("The message, in markdown."), from: str("The agent's name. Default: the name in the operating layer.") }, required: ["title", "body"], additionalProperties: false },
850
+ description: "Sends the owner a message: writes it as one new file in the record's messages folder, and returns its id and path. This tool cannot see whether the owner's phone receives it -- that depends on their EVÐA app being paired and synced. Tell them only that you sent or filed the message; never say a notification or a card reached them unless they have told you so themselves. Use it for what they should hear from you (what you did, a question for them); a reply comes back through evda_replies. Never edits a message once written. Refuses anything shaped like a password, key or token.",
851
+ inputSchema: { type: "object", properties: { title: str("One line: what the message is about at a glance."), body: str("The message, in markdown."), from: str("The agent's name. Default: the agent name this record gives you.") }, required: ["title", "body"], additionalProperties: false },
263
852
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
264
853
  run: (evda, args) => {
265
854
  const r = tools.message(evda, args);
266
- return { text: `Sent the message ${r.id}, filed at ${r.path}. The owner's reply, when there is one, comes back through evda_replies.`, data: r };
855
+ return { text: `Filed the message ${r.id} at ${r.path}. This does not confirm the owner saw it: whether it shows on their phone depends on their EVÐA app, which this tool cannot check. Tell them only that you sent or filed it, never that a notification or a card appeared. Their reply, when there is one, comes back through evda_replies.`, data: r };
267
856
  },
268
857
  },
269
858
  {
270
859
  name: "evda_replies",
271
860
  title: "Read the owner's replies",
272
- description: "Call at the start of a run: returns the owner's replies to your messages that you have not seen yet (message id, title, reply, date) and marks them seen, so each shows once; a reply the owner changes shows again. peek: true returns them without marking.",
861
+ description: "Returns the owner's replies to the agent's messages, each once. Call at the start of a run: returns the owner's replies to your messages that you have not seen yet (message id, title, reply, date) and marks them seen, so each shows once; a reply the owner changes shows again. peek: true returns them without marking.",
273
862
  inputSchema: { type: "object", properties: { peek: { type: "boolean", description: "true to read without marking them seen." } }, additionalProperties: false },
274
863
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
275
864
  run: (evda, args, ctx) => {
@@ -282,7 +871,7 @@ export const TOOLS = [
282
871
  {
283
872
  name: "evda_filing",
284
873
  title: "Record how filing works",
285
- description: "Call once, when the owner answers how filing works: records \"everything\" (file every message they send, without being asked) or \"asked\" (only when they ask) in evda.json, and returns what it wrote. Pass confirmed: true only with the answer they gave in this conversation. It changes the one filing key and nothing else.",
874
+ description: "Records whether the owner wants every message filed or only on request. Call once, when the owner answers how filing works: records \"everything\" (file every message they send, without being asked) or \"asked\" (only when they ask) in evda.json, and returns what it wrote. Pass confirmed: true only with the answer they gave in this conversation. It changes the one filing key and nothing else.",
286
875
  inputSchema: {
287
876
  type: "object",
288
877
  properties: { filing: { type: "string", enum: FILING_MODES, description: "everything, or asked." }, confirmed: { type: "boolean", description: "true only when the owner gave this answer in this conversation." } },
@@ -295,10 +884,31 @@ export const TOOLS = [
295
884
  return { text: r.changed ? `Recorded "filing": "${r.filing}" in evda.json. ${r.filing === "everything" ? "Every message they send is filed from here on, this one included." : "From here on, file only when they ask."}` : `evda.json already says "filing": "${r.filing}". Nothing changed.`, data: r };
296
885
  },
297
886
  },
887
+ {
888
+ name: "evda_every_chat",
889
+ title: "Record the every-chat setting",
890
+ description: "Records whether the every-chat line is set in a chat app, or declined. Call when the owner says they added the every-chat line to their chat app's settings, or that they do not want it: records \"set\" or \"declined\" for that app in evda.json, so no later chat in that app asks them again. Pass confirmed: true only with what they said in this conversation. It changes that one app's entry and nothing else.",
891
+ inputSchema: {
892
+ type: "object",
893
+ properties: {
894
+ app: { type: "string", enum: EVERY_CHAT_APPS, description: "The app they set it in. Default: the app this conversation is in." },
895
+ state: { type: "string", enum: EVERY_CHAT_STATES, description: "set, or declined." },
896
+ confirmed: { type: "boolean", description: "true only when the owner said this in this conversation." },
897
+ },
898
+ required: ["state", "confirmed"],
899
+ additionalProperties: false,
900
+ },
901
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
902
+ run: (evda, args, ctx) => {
903
+ const r = setEveryChat(evda, args, { app: ctx.app ?? null });
904
+ const label = CHAT_APPS[r.app]?.label ?? "this app";
905
+ return { text: r.state === "set" ? `Recorded in evda.json that the every-chat line is set in ${label}. No chat there asks again.` : `Recorded in evda.json that they do not want the every-chat line in ${label}. It is not raised there again.`, data: r };
906
+ },
907
+ },
298
908
  {
299
909
  name: "evda_release_note",
300
910
  title: "Release a noted entry",
301
- description: "Clears the just-noted flag on one entry so it can be read, after the owner said in this conversation that this entry can be read now. Pass confirmed: true only then. It changes one frontmatter line and never the words. Refuses a sealed entry: no tool releases one.",
911
+ description: "Clears the just-noted flag on one entry so it can be read. Only after the owner said in this conversation that this entry can be read now. Pass confirmed: true only then. It changes one frontmatter line and never the words. Refuses a sealed entry: no tool releases one.",
302
912
  inputSchema: {
303
913
  type: "object",
304
914
  properties: { path: str("The noted entry's path, relative to the record."), confirmed: { type: "boolean", description: "true only when the owner said in this conversation that this entry can be read now." } },
@@ -314,7 +924,7 @@ export const TOOLS = [
314
924
  {
315
925
  name: "evda_circles",
316
926
  title: "List circles",
317
- description: "Lists the record's circles (the parts the owner chose to share), who may read each, what it covers and its policy rules. Read only.",
927
+ description: "Lists the record's circles, the parts the owner chose to share. For each: who may read it, what it covers and its policy rules. Read only.",
318
928
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
319
929
  annotations: { readOnlyHint: true, openWorldHint: false },
320
930
  run: (evda, args) => {
@@ -333,7 +943,7 @@ export const TOOLS = [
333
943
  {
334
944
  name: "evda_propose_circle",
335
945
  title: "Propose a circle",
336
- description: "Writes a proposal for a circle as a file under proposals/ for the owner to read. It never creates the circle, grants anyone access, or moves an entry: those wait for the owner's explicit yes. Use it only when the owner asks to share something.",
946
+ description: "Writes a proposal for a circle under proposals/ for the owner to read. It never creates the circle, grants anyone access, or moves an entry: those wait for the owner's explicit yes. Use it only when the owner asks to share something.",
337
947
  inputSchema: {
338
948
  type: "object",
339
949
  properties: {
@@ -355,7 +965,7 @@ export const TOOLS = [
355
965
  {
356
966
  name: "evda_presets",
357
967
  title: "List presets",
358
- description: "Lists the ways to organize a record that EVDA ships (the preset library), each with who it suits, or with an id shows one preset's folders. Read only. A new folder is proposed from a preset here, never invented.",
968
+ description: "Lists the ways to organize a record that EVDA ships, the preset library. Each says who it suits; with an id, shows one preset's folders. Read only. A new folder is proposed from a preset here, never invented.",
359
969
  inputSchema: { type: "object", properties: { id: str("A preset id, to show that one. Omit to list them all.") }, additionalProperties: false },
360
970
  annotations: { readOnlyHint: true, openWorldHint: false },
361
971
  run: (evda, args) => {
@@ -368,7 +978,7 @@ export const TOOLS = [
368
978
  {
369
979
  name: "evda_propose_structure",
370
980
  title: "Propose a preset's structure",
371
- description: "Shows what applying a preset would add to this record, as a plan and a plain-language summary to show the owner. It never writes. Folders that already match are left alone, one that differs is reported and never changed, and nothing is ever moved, renamed or deleted.",
981
+ description: "Shows what applying a preset would add to this record, without writing. It returns a plan and a plain-language summary to show the owner. It never writes. Folders that already match are left alone, one that differs is reported and never changed, and nothing is ever moved, renamed or deleted.",
372
982
  inputSchema: { type: "object", properties: { id: str("The preset id, from evda_presets.") }, required: ["id"], additionalProperties: false },
373
983
  annotations: { readOnlyHint: true, openWorldHint: false },
374
984
  run: (evda, args) => {
@@ -396,7 +1006,7 @@ export const TOOLS = [
396
1006
  {
397
1007
  name: "evda_gather",
398
1008
  title: "Gather a pile into a proposal",
399
- description: "Walks the folders or export files the owner names (a Documents folder, a Notes or Obsidian export, a contacts export), types every file, and writes a proposal: a preset, the folders the pile needs, and where each item would go. Anything an app holds becomes a reference, never a copy. It moves, copies and changes nothing in the sources and writes nothing into the record; the proposal is a file outside both. Returns the proposal's id and its summary.",
1009
+ description: "Proposes where a pile of the owner's files would go in the record. It walks the folders or export files the owner names (a Documents folder, a Notes or Obsidian export, a contacts export), types every file, and writes a proposal: a preset, the folders the pile needs, and where each item would go. Anything an app holds becomes a reference, never a copy. It moves, copies and changes nothing in the sources and writes nothing into the record; the proposal is a file outside both. Returns the proposal's id and its summary.",
400
1010
  inputSchema: { type: "object", properties: { sources: { type: "array", items: { type: "string" }, minItems: 1, description: "Paths on this machine: folders or export files." }, into: str("The record this pile would grow, when it grows one: what that record already holds is left out, and a person with a page there is not asked about.") }, required: ["sources"], additionalProperties: false },
401
1011
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
402
1012
  run: (evda, args) => {
@@ -419,7 +1029,7 @@ export const TOOLS = [
419
1029
  {
420
1030
  name: "evda_gather_answer",
421
1031
  title: "Record the owner's answer on a gather",
422
- description: "Records the owner's answer to one gather question on the proposal, re-places the items it affects, and says what moved. Pass the answer they gave in this conversation, never a guess. It changes the proposal file only; no source file and nothing in the record moves.",
1032
+ description: "Records an answer to one gather question and re-places the items it affects. It says what moved. Pass the answer they gave in this conversation, never a guess. It changes the proposal file only; no source file and nothing in the record moves.",
423
1033
  inputSchema: {
424
1034
  type: "object",
425
1035
  properties: { proposal: str("The proposal id."), question: str("The question id, in brackets in evda_gather_questions."), answer: str("One of the question's answers, as the owner gave it.") },
@@ -436,7 +1046,7 @@ export const TOOLS = [
436
1046
  {
437
1047
  name: "evda_gather_apply",
438
1048
  title: "Apply a gather proposal",
439
- description: "Writes a gather proposal into a record: a new one made with the proposal's preset when \"into\" is a new folder, or the record named (the open one by default), adopting what it already holds and re-sorting nothing. Copies by default; move: true takes the files out of their sources. Call it only after every question is answered and the owner said yes to applying this proposal in this conversation; pass confirmed: true only then. It writes no share mark and no circle, keeps references as references, runs doctor, and writes a reversal manifest that `evda gather revert` undoes byte for byte.",
1049
+ description: "Writes an answered gather proposal into a record, copying by default. The record is a new one made with the proposal's preset when \"into\" is a new folder, or the record named (the open one by default), adopting what it already holds and re-sorting nothing. Copies by default; move: true takes the files out of their sources. Call it only after every question is answered and the owner said yes to applying this proposal in this conversation; pass confirmed: true only then. It writes no share mark and no circle, keeps references as references, runs doctor, and writes a reversal manifest that `evda gather revert` undoes byte for byte.",
440
1050
  inputSchema: {
441
1051
  type: "object",
442
1052
  properties: {
@@ -457,7 +1067,7 @@ export const TOOLS = [
457
1067
  {
458
1068
  name: "evda_skills",
459
1069
  title: "List or read the agent's skills",
460
- description: `Lists the skills in this record (${SKILLS_REL}/<name>/SKILL.md), each with when to use it, or with a name returns that skill whole. A skill is a process the agent wrote down after repeating it; follow the one whose description fits what is being done. Read only.`,
1070
+ description: `Lists the agent's skills in this record, or returns one whole by name. Skills live at ${SKILLS_REL}/<name>/SKILL.md, each with when to use it. A skill is a process the agent wrote down after repeating it; follow the one whose description fits what is being done. Read only.`,
461
1071
  inputSchema: { type: "object", properties: { name: str("A skill's name, to read that one. Omit to list them.") }, additionalProperties: false },
462
1072
  annotations: { readOnlyHint: true, openWorldHint: false },
463
1073
  run: (evda, args) => {
@@ -473,7 +1083,7 @@ export const TOOLS = [
473
1083
  {
474
1084
  name: "evda_write_skill",
475
1085
  title: "Write down a process as a skill",
476
- description: `Writes a skill into the record at ${SKILLS_REL}/<name>/SKILL.md (Claude Code loads it on its own; any app reads it with evda_skills): a kebab-case name, a description saying when to use it, and the steps. Use it when you notice you are doing or explaining the same process a second time, or making the same correction twice, and tell the owner in one line. With revise: true and why (what went wrong on a run), it rewrites one and keeps what it replaced in REVISIONS.md. Refuses a name that would land outside the skills folder, and refuses content that is a rule about the owner's life (a threshold, count or streak about a person): a skill is how you work.`,
1086
+ description: `Writes down a process the agent repeats as a skill in this record. It lands at ${SKILLS_REL}/<name>/SKILL.md (Claude Code loads it on its own; any app reads it with evda_skills): a kebab-case name, a description saying when to use it, and the steps. Use it when you notice you are doing or explaining the same process a second time, or making the same correction twice, and tell the owner in one line. With revise: true and why (what went wrong on a run), it rewrites one and keeps what it replaced in REVISIONS.md. Refuses a name that would land outside the skills folder, and refuses content that is a rule about the owner's life (a threshold, count or streak about a person): a skill is how you work.`,
477
1087
  inputSchema: {
478
1088
  type: "object",
479
1089
  properties: {
@@ -496,7 +1106,7 @@ export const TOOLS = [
496
1106
  {
497
1107
  name: "evda_create_subrecord",
498
1108
  title: "Make an EVÐA inside this one",
499
- description: "Makes a record inside this record at a path (a project, a company, an agent): its own evda.json with the four roles (journal, work, collections, knowledge) and an AGENTS.md saying what it is for, then runs doctor on this record, which walks the new one, and returns the report. The parent's evda.json is unchanged unless bounded: true, which makes this record's circles stop at the new one and so needs confirmed: true, the owner's yes in this conversation. Refuses a path that is not empty, one inside another sub-record, and one that would hold a folder this record maps. When doctor was clean before and fails after, it removes what it made.",
1109
+ description: "Makes an EVÐA inside this record at a path: a project, a company, an agent. It gets its own evda.json with the four roles (journal, work, collections, knowledge) and an AGENTS.md saying what it is for, then runs doctor on this record, which walks the new one, and returns the report. The parent's evda.json is unchanged unless bounded: true, which makes this record's circles stop at the new one and so needs confirmed: true, the owner's yes in this conversation. Refuses a path that is not empty, one inside another sub-record, and one that would hold a folder this record maps. When doctor was clean before and fails after, it removes what it made.",
500
1110
  inputSchema: {
501
1111
  type: "object",
502
1112
  properties: {
@@ -531,9 +1141,9 @@ export const TOOLS = [
531
1141
  },
532
1142
  },
533
1143
  {
534
- name: "evda_pair_device",
1144
+ name: "evda_pair",
535
1145
  title: "Pair a phone",
536
- description: "Call only when the owner asks to pair, add or connect a phone or another device to their EVÐA. Returns a one-time pairing code and a QR link for the phone, and the steps to do on it. The code works once, for 10 minutes, and the device gets only the parts named (journal by default). Show the code to the owner and no one else.",
1146
+ description: `Pairs a phone or another device with this EVÐA through a one-time code. Call only when the owner asks to pair, add or connect a phone or another device to their EVÐA: adding a device is the owner's explicit yes, never yours. Pairs the device to the computer this EVÐA server runs on (the answer names it), so the device syncs with that computer's copy of the record. Returns a one-time code, a QR code to scan (as an image and as text), and the steps on the phone. The code works once, for 10 minutes, and the device gets only the parts named (journal by default). This server then adds the device on its own as soon as it uses the code, until the code runs out; evda_finish_pairing waits for that. Show the code to the owner and no one else.`,
537
1147
  inputSchema: {
538
1148
  type: "object",
539
1149
  properties: {
@@ -544,15 +1154,39 @@ export const TOOLS = [
544
1154
  },
545
1155
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
546
1156
  run: async (evda, args) => {
547
- tools.onlyKeys("evda_pair_device", args, ["name", "parts"]);
1157
+ tools.onlyKeys("evda_pair", args, ["name", "parts"]);
1158
+ // The device tools read process.env, never ctx.env: over HTTP ctx.env names the reader only.
548
1159
  const r = await pairing.pair(evda.dir, { name: args.name, parts: args.parts });
549
- return { text: pairing.pairText(r) };
1160
+ pairing.watchPairing(evda.dir, { until: r.expiresAt });
1161
+ const qr = encodeQr(r.qr, { ecl: "M" });
1162
+ return {
1163
+ text: pairing.pairToolText(r, { qrText: qrText(qr) }),
1164
+ images: [{ data: qrPng(qr).toString("base64"), mimeType: "image/png" }],
1165
+ };
1166
+ },
1167
+ },
1168
+ {
1169
+ name: "evda_finish_pairing",
1170
+ title: "Wait for a phone to join",
1171
+ description: "Waits for a device to use its pairing code, and adds it when it does. Call after evda_pair, once the owner says they scanned the code or pasted it on the device: waits up to `seconds` (default 60, at most 120) for the device to use its code, adds it to this computer's copy of the record when it does, and says so in plain words. It adds only a device the owner already asked to pair with evda_pair. Never waits on a running sync: that sync adds the device itself.",
1172
+ inputSchema: {
1173
+ type: "object",
1174
+ properties: { seconds: { type: "number", description: "How long to wait for the device, 0 to 120. Default 60." } },
1175
+ additionalProperties: false,
1176
+ },
1177
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1178
+ run: async (evda, args) => {
1179
+ tools.onlyKeys("evda_finish_pairing", args, ["seconds"]);
1180
+ if (args.seconds !== undefined && !(typeof args.seconds === "number" && args.seconds >= 0 && args.seconds <= pairing.FINISH_WAIT_MAX_S)) {
1181
+ throw tools.refuse(`evda_finish_pairing "seconds" is a number from 0 to ${pairing.FINISH_WAIT_MAX_S}.`, "BAD_SECONDS");
1182
+ }
1183
+ return { text: (await pairing.finishWaiting(evda.dir, { seconds: args.seconds ?? 60 })).join("\n") };
550
1184
  },
551
1185
  },
552
1186
  {
553
1187
  name: "evda_sync_devices",
554
1188
  title: "Sync with paired devices",
555
- description: "Call after the owner used a pairing code on their phone, or when they ask to sync their devices: adds a device that used its code, takes in what paired devices wrote, and sends what changed here. Answers in plain words.",
1189
+ description: "Syncs the record with the owner's paired devices. Call after the owner used a pairing code on their phone, or when they ask to sync their devices: adds a device that used its code, takes in what paired devices wrote, and sends what changed here. Syncs the copy of the record on the computer this EVÐA server runs on. Waits for a sync already running there, for a bounded time. Answers in plain words.",
556
1190
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
557
1191
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
558
1192
  run: async (evda, args) => {
@@ -563,18 +1197,54 @@ export const TOOLS = [
563
1197
  {
564
1198
  name: "evda_devices",
565
1199
  title: "Paired devices",
566
- description: "Lists the devices paired with the owner's EVÐA, whether each is waiting, paired or removed, and which parts each reads. Removing one is a command the owner runs: evda remove <device>.",
1200
+ description: "Lists the devices paired with the owner's EVÐA on this computer. For each, on the computer this EVÐA server runs on (the answer names it): whether it is waiting, paired or removed, which parts it reads, and when this computer last heard from it. A device paired through another computer is listed there, not here. Removing one is evda_remove_device, and only on the owner's explicit yes.",
567
1201
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
568
1202
  annotations: { readOnlyHint: true, openWorldHint: false },
569
1203
  run: async (evda, args) => {
570
1204
  tools.onlyKeys("evda_devices", args, []);
571
- return { text: (await pairing.devices(evda.dir)).join("\n") };
1205
+ return { text: (await pairing.deviceContacts(evda.dir)).join("\n") };
1206
+ },
1207
+ },
1208
+ {
1209
+ name: "evda_sync_status",
1210
+ title: "Sync status",
1211
+ description: "Says how syncing stands with each paired device, in plain words. On the computer this EVÐA server runs on: for each device, what is waiting to go out, what was sent and not yet confirmed, what the device confirmed, when this computer last heard from it, and whether a sync is running now and what runs it. Reads only; never starts or waits on a sync.",
1212
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1213
+ annotations: { readOnlyHint: true, openWorldHint: false },
1214
+ run: async (evda, args) => {
1215
+ tools.onlyKeys("evda_sync_status", args, []);
1216
+ return { text: (await pairing.syncStatus(evda.dir)).join("\n") };
1217
+ },
1218
+ },
1219
+ {
1220
+ name: "evda_remove_device",
1221
+ title: "Remove a device",
1222
+ description: "Removes a paired device's access to the record, which cannot be undone. Call only when the owner asks to remove, unpair or disconnect a device, and pass confirm: true only after they said yes to what removing it does: removing a device is the owner's explicit yes, never yours, and it cannot be undone. Without confirm: true it changes nothing and returns what removing that device would do, to put to the owner. With it: the device loses its access, the parts it read get new keys so nothing new reaches it, and this computer syncs. What the device already has stays on it. Acts on the computer this EVÐA server runs on.",
1223
+ inputSchema: {
1224
+ type: "object",
1225
+ properties: {
1226
+ name: { type: "string", description: "The device's name, as evda_devices lists it." },
1227
+ confirm: { type: "boolean", description: "true only once the owner said yes to removing this device." },
1228
+ },
1229
+ required: ["name"],
1230
+ additionalProperties: false,
1231
+ },
1232
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1233
+ run: async (evda, args) => {
1234
+ tools.onlyKeys("evda_remove_device", args, ["name", "confirm"]);
1235
+ if (typeof args.name !== "string" || !args.name.trim()) throw tools.refuse("evda_remove_device needs the device's \"name\", as evda_devices lists it.", "NO_NAME");
1236
+ if (args.confirm !== undefined && typeof args.confirm !== "boolean") throw tools.refuse("evda_remove_device \"confirm\" is true or false.", "BAD_CONFIRM");
1237
+ const what = await pairing.describeRemoval(evda.dir, args.name);
1238
+ if (args.confirm !== true) {
1239
+ return { text: [...what, "", "Nothing was changed. Put this to the owner, and call again with confirm: true only if they say yes."].join("\n") };
1240
+ }
1241
+ return { text: (await pairing.remove(evda.dir, args.name)).join("\n") };
572
1242
  },
573
1243
  },
574
1244
  {
575
1245
  name: "evda_check_update",
576
1246
  title: "Check for newer EVDA tooling",
577
- description: "Asks the npm registry whether a newer version of the EVDA tooling exists, and returns the one line that says so. At most one real check an hour; inside that hour it reports the cached result. A check that fails reports the status as UNKNOWN, never as up to date. It sends nothing from the record, and it changes nothing: taking the update is a command the owner runs in their own terminal.",
1247
+ description: "Asks the npm registry whether newer EVDA tooling exists. Returns the one line that says so. At most one real check an hour; inside that hour it reports the cached result. A check that fails reports the status as UNKNOWN, never as up to date. It sends nothing from the record, and it changes nothing: taking the update is a command the owner runs in their own terminal.",
578
1248
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
579
1249
  annotations: { readOnlyHint: true, openWorldHint: true },
580
1250
  run: async (evda) => {
@@ -586,14 +1256,14 @@ export const TOOLS = [
586
1256
  ];
587
1257
 
588
1258
  export const RESOURCES = [
589
- { uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => { const t = tools.operatingLayer(evda).text; return `${chatSection(evda, t)}\n\n${DISPATCH_SECTION}\n\n${t}`; } },
1259
+ { uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda, ctx = {}) => { const r = tools.operatingLayer(evda); const surface = clientProfile(ctx.client?.name).surface; const o = forSurface(r.text, surface); return [chatSection(evda, r.text, { app: clientProfile(ctx.client?.name).app }), dispatchSection(surface), ...coreBlock(r), o.text].join("\n\n"); } },
590
1260
  {
591
1261
  uri: "evda://today",
592
1262
  name: "today",
593
1263
  title: "Today's daily file",
594
1264
  mimeType: "text/markdown",
595
1265
  read: (evda, ctx) => {
596
- const r = tools.recent(evda, { days: 1 }, { caller: ctx.caller });
1266
+ const r = tools.recent(evda, { days: 1 }, { caller: ctx.caller, full: true });
597
1267
  const daily = r.files.find((f) => /(^|\/)\d{4}-\d{2}-\d{2}\.md$/.test(f.path) && f.date === r.to);
598
1268
  if (daily) return daily.text;
599
1269
  return r.heldBack ? `# ${r.to}\n\n${r.heldBack}\n` : `# ${r.to}\n\nNothing written today yet.\n`;
@@ -606,14 +1276,139 @@ export const RESOURCES = [
606
1276
  // Once the session has read the record or been handed the owner's content and filed nothing, a tool
607
1277
  // result carries one line saying so, at most once every NUDGE_EVERY calls, and never after a write.
608
1278
  // EVDA_RECORD_FALSIFY=no-nudge turns it off, for the test that shows the nudge is what files.
609
- export const NUDGE = "Nothing from this conversation is filed yet: file their words with evda_write before you finish.";
1279
+ export const NUDGE = "Nothing the owner has said in this conversation is filed yet, so if it ended now their words would be lost: file them verbatim with evda_write in this turn.";
610
1280
  export const NUDGE_EVERY = 3;
611
1281
  const READS = new Set(["evda_recent", "evda_search", "evda_read"]);
612
1282
  const RECEIVED = new Set(["evda_import", "evda_gather_answer"]);
613
1283
  const WRITES = new Set(["evda_write", "evda_note"]);
614
1284
 
615
1285
  export function newSession() {
616
- return { calls: 0, reads: 0, received: 0, writes: 0, lastNudge: -Infinity };
1286
+ return { calls: 0, reads: 0, received: 0, writes: 0, lastNudge: -Infinity, layer: false, preambled: false, lastCall: null, everyChatAsked: false, lastFiled: null };
1287
+ }
1288
+
1289
+ // The one-call start (HQ-2223, slice 2). Measured 2026-10-05: a fresh claude.ai chat took 5 tool
1290
+ // calls before it replied, each its own Allow click. So the layer call takes the owner's first
1291
+ // message and files it the way evda_write does (same day file, same section for the chat, same
1292
+ // refusals), and returns what a chat reads next (unseen replies, the recent days' headings), so the
1293
+ // chat can answer after one call. An owner who turned automatic filing off, or has not answered
1294
+ // yet, is filed for only when they ask: nothing is written and the result says so.
1295
+ export function openChat(evda, args, ctx) {
1296
+ const f = fileAlong(evda, { words: args.first_message, context: args.context }, ctx, "evda_operating_layer");
1297
+ const head = f.filed
1298
+ ? `Filed their first message to ${f.path}. Tell them in the past tense, and file each later message with evda_write; this one is already filed.`
1299
+ : f.head;
1300
+ const run = (name) => TOOLS.find((t) => t.name === name).run(evda, {}, ctx).text;
1301
+ const parts = [head, `## Their replies you have not seen\n\n${run("evda_replies")}`, `## The last few days\n\n${run("evda_recent")}`];
1302
+ return { text: parts.join("\n\n"), filed: f.filed ? f.path : null };
1303
+ }
1304
+
1305
+ // Filing that rides along (HQ-2227): the owner's message passed on a call that is not evda_write
1306
+ // (the layer's first_message, a read's or a search's owner_message) is filed the way evda_write files
1307
+ // it: the same day file, the same section for the chat, the same refusals. It is the automatic path,
1308
+ // so it files only when the owner said everything; "asked" or no answer yet files nothing and says
1309
+ // so. A secret is not filed and the call it rode on still answers. claude.ai measured 2026-10-05:
1310
+ // the first answer took about 7 calls, one of them a write per turn, so a turn that reads can file
1311
+ // in the same call.
1312
+ //
1313
+ // Every filing in a chat leaves one mark, the words last filed (session.lastFiled), and the dedupe is
1314
+ // one rule over it: a model that passes the same message on the read, the search and then evda_write
1315
+ // in one turn files it once. A mark left by a ride-along is matched by any later filing of the same
1316
+ // words; evda_write spends it, so after that the same words again are the owner saying it again. A
1317
+ // mark left by evda_write is matched only by a ride-along, so two evda_writes of "ok" in two turns
1318
+ // still file twice, as before.
1319
+ export function fileAlong(evda, { words, context }, ctx, toolName) {
1320
+ const filing = filingOf(evda.manifest);
1321
+ if (filing !== "everything") {
1322
+ return {
1323
+ filed: false,
1324
+ head: filing === "asked"
1325
+ ? "Not filed: they turned automatic filing off, so their message is filed only if they ask (then with evda_write)."
1326
+ : "Not filed: they have not said how filing works yet. Ask the filing question first; if they say everything, file this message with evda_write too.",
1327
+ };
1328
+ }
1329
+ const dup = sameAsFiled(ctx.session, words, { spend: false });
1330
+ if (dup) return { filed: true, deduped: true, path: dup.path, head: `Already filed at ${dup.path}; nothing was written again.` };
1331
+ try {
1332
+ const w = { words };
1333
+ if (context !== undefined) w.context = context;
1334
+ const r = tools.write(evda, w, { session: ctx.session, toolName });
1335
+ if (ctx.session) {
1336
+ ctx.session.writes += 1;
1337
+ ctx.session.lastFiled = { words: words.trim(), path: r.path, by: "along" };
1338
+ }
1339
+ return { filed: true, path: r.path, head: `Filed their message to ${r.path}.` };
1340
+ } catch (e) {
1341
+ if (!(e instanceof EvdaError) || e.code !== "SECRET") throw e;
1342
+ return { filed: false, head: `Not filed: ${e.message}` };
1343
+ }
1344
+ }
1345
+
1346
+ // Whether these words are the ones last filed in this chat (fileAlong's rule). evda_write passes
1347
+ // spend: true and matches only a ride-along's mark, and spends it either way.
1348
+ export function sameAsFiled(session, words, { spend = true } = {}) {
1349
+ const mark = session?.lastFiled;
1350
+ if (!mark) return null;
1351
+ const same = typeof words === "string" && words.trim() === mark.words;
1352
+ if (!spend) return same ? mark : null;
1353
+ if (mark.by !== "along") return null;
1354
+ session.lastFiled = null;
1355
+ return same ? mark : null;
1356
+ }
1357
+
1358
+ // What evda_write leaves for the ride-alongs after it to match.
1359
+ function markWrite(session, words, path) {
1360
+ if (session && typeof words === "string") session.lastFiled = { words: words.trim(), path, by: "write" };
1361
+ }
1362
+
1363
+ // One chat, as the server can see it (HQ-2205). An Mcp-Session-Id is not one conversation: on
1364
+ // Hestia, 2026-10-05, claude.ai sent one session id for 7.5 hours, into conversations other than the
1365
+ // one that opened it, while a chat's own turns were at most 15 minutes apart. So a gap longer than
1366
+ // this between tool calls on a session starts a new chat: the layer counts as not served and
1367
+ // nothing as filed. A long pause inside one chat costs the short preamble again; the other error,
1368
+ // a new chat taken for an old one, would leave it with no layer and no nudge.
1369
+ export const CHAT_IDLE_MS = 20 * 60 * 1000;
1370
+
1371
+ function enterChat(session, now) {
1372
+ if (session.lastCall !== null && now - session.lastCall > CHAT_IDLE_MS) {
1373
+ Object.assign(session, newSession());
1374
+ }
1375
+ session.lastCall = now;
1376
+ }
1377
+
1378
+ // Layer 2 of HQ-2205. A model that never loaded evda_operating_layer answers as a generic assistant
1379
+ // and does not file, and no wording reaches it reliably, so the server says it in the result of the
1380
+ // first tool it does call: the short chat section, never the whole layer. Later results in the same
1381
+ // chat get one line until the layer is loaded. Returns "preamble", "reminder" or null.
1382
+ // EVDA_CHAT_FALSIFY=no-layer-preamble turns it off, for the control test.
1383
+ export const LAYER_REMINDER = "evda_operating_layer is still not loaded in this conversation: call it before you answer, so you answer as who you are here.";
1384
+ export function layerNotice(session, name, { env = process.env, now = Date.now() } = {}) {
1385
+ enterChat(session, now);
1386
+ if (name === "evda_operating_layer") { session.layer = true; return null; }
1387
+ if (session.layer || chatFalsified("no-layer-preamble", env)) return null;
1388
+ if (session.preambled) return "reminder";
1389
+ session.preambled = true;
1390
+ return "preamble";
1391
+ }
1392
+
1393
+ export function layerPreamble(evda) {
1394
+ let agentsText = "";
1395
+ try { agentsText = tools.operatingLayer(evda).text; } catch { /* a record with no AGENTS.md still gets the section */ }
1396
+ // The section's first line says the layer follows it, which is true inside the layer and not here.
1397
+ const section = chatSection(evda, agentsText).replace("the full operating layer follows and holds for the whole session.", "evda_operating_layer holds the rest.");
1398
+ return `This conversation has not loaded the operating layer yet: call evda_operating_layer now, before you answer. Until you do, you are answering as a generic assistant, not as the one this record set up. These lines hold until then:\n\n${section}`;
1399
+ }
1400
+
1401
+ // Layer 4 of HQ-2205: whether this result carries the every-chat walkthrough. Once per chat, in the
1402
+ // first result, for a client that named itself on initialize as a chat app, while evda.json has no
1403
+ // answer from the owner for that app. Not while the filing question is open: two setup questions at
1404
+ // once is a quiz, and the line depends on that answer, so evda_filing's result carries it instead.
1405
+ // EVDA_CHAT_FALSIFY=no-every-chat turns it off, for the control test.
1406
+ export function everyChatNotice(session, app, manifest, name, env = process.env) {
1407
+ if (!app || session.everyChatAsked || name === "evda_every_chat" || chatFalsified("no-every-chat", env)) return null;
1408
+ const filing = filingOf(manifest);
1409
+ if (filing === null || !needsEveryChat(manifest, app, filing)) return null;
1410
+ session.everyChatAsked = true;
1411
+ return everyChatWalkthrough(app, filing);
617
1412
  }
618
1413
 
619
1414
  // Count one tool call and say whether its result carries the nudge.
@@ -633,6 +1428,13 @@ export function countCall(session, name, { env = process.env, filing = null } =
633
1428
  return true;
634
1429
  }
635
1430
 
1431
+ // The tools this client is offered: every tool for a coding agent, the chat set for any other.
1432
+ // A record that cannot be opened offers the chat set's fixed part.
1433
+ function offered(ctx) {
1434
+ if (clientProfile(ctx.client?.name).surface === "terminal") return null;
1435
+ try { return chatToolNames(ctx.evda(), { app: ctx.app ?? null }); } catch { return new Set(CHAT_PROSE_TOOLS); }
1436
+ }
1437
+
636
1438
  // The owner's filing answer as the record says it now, or null when it cannot be read.
637
1439
  function filingNow(ctx) {
638
1440
  try { return filingOf(ctx.evda().manifest); } catch { return null; }
@@ -656,17 +1458,22 @@ export async function handle(msg, ctx) {
656
1458
  const asked = p.protocolVersion;
657
1459
  // Who is calling, for every read that follows: the client's own name, or $EVDA_READER.
658
1460
  ctx.caller = callerFrom({ clientName: p.clientInfo?.name ?? null, env: ctx.env ?? process.env });
1461
+ // Which app this is, for the operating layer's surface (HQ-2207). Name and version only.
1462
+ ctx.client = clientInfoOf(p);
1463
+ // Which chat app, for the every-chat walkthrough only (HQ-2205). It decides which words are
1464
+ // shown, never what may be read, so a client that misnames itself costs nothing but wording.
1465
+ ctx.app = chatAppOf(p.clientInfo?.name ?? null);
659
1466
  return ok({
660
1467
  protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
661
1468
  capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false } },
662
1469
  serverInfo: { name: SERVER_NAME, title: "EVDA", version: ctx.version },
663
- instructions: instructions(filingNow(ctx)),
1470
+ instructions: instructions(filingNow(ctx), clientProfile(ctx.client?.name).surface),
664
1471
  });
665
1472
  }
666
1473
  case "ping":
667
1474
  return ok({});
668
1475
  case "tools/list":
669
- return ok({ tools: toolList(filingNow(ctx), ctx.env ?? process.env) });
1476
+ return ok({ tools: toolList(filingNow(ctx), ctx.env ?? process.env, offered(ctx)) });
670
1477
  case "tools/call": {
671
1478
  const tool = TOOLS.find((t) => t.name === p.name);
672
1479
  if (!tool) return error(msg.id, RPC.PARAMS, `unknown tool ${JSON.stringify(p.name)}`);
@@ -674,22 +1481,29 @@ export async function handle(msg, ctx) {
674
1481
  if (typeof args !== "object" || Array.isArray(args)) return error(msg.id, RPC.PARAMS, "arguments must be an object");
675
1482
  // EVDA_MCP_FALSIFY=log-args: a debug line with the arguments, the leak test/privacy.test.mjs exists to catch.
676
1483
  if (FALSIFY("log-args", process.env)) ctx.log?.(`debug ${p.name} ${JSON.stringify(args)}`);
1484
+ let tail = "";
677
1485
  try {
678
1486
  if (!ctx.caller) ctx.caller = callerFrom({ env: ctx.env ?? process.env });
679
1487
  ctx.session ??= newSession();
680
1488
  const evda = ctx.evda();
1489
+ const notice = layerNotice(ctx.session, tool.name, { env: process.env, now: ctx.clock?.() ?? Date.now() });
1490
+ tail = notice === "preamble" ? `\n\n---\n\n${layerPreamble(evda)}` : notice === "reminder" ? `\n\n${LAYER_REMINDER}` : "";
681
1491
  const r = await tool.run(evda, args, ctx);
682
1492
  const nudge = countCall(ctx.session, tool.name, { filing: filingOf(evda.manifest) }) ? `\n\n${NUDGE}` : "";
1493
+ // evda_filing just changed the answer the walkthrough's line depends on, so it reads it again.
1494
+ const setup = r.isError ? null : everyChatNotice(ctx.session, ctx.app, tool.name === "evda_filing" ? ctx.evda().manifest : evda.manifest, tool.name);
683
1495
  // A process run twice is pointed at once (HQ-1377), so an agent in an app with no memory of
684
1496
  // its own still writes it down as a skill. Only a call that did its work counts as a step.
685
1497
  const repeat = r.isError ? null : noticeRepeat(ctx.session, tool.name);
686
1498
  // Text only. A client that finds structuredContent may show the model that instead of the text
687
1499
  // (Claude Code did, 2026-09-17: evda_operating_layer reached the model as {"path":"AGENTS.md"}),
688
1500
  // so everything the model needs, paths included, is in the text.
689
- return ok({ content: [{ type: "text", text: `${r.text}${nudge}${repeat ? `\n\n${repeat}` : ""}` }], isError: Boolean(r.isError) });
1501
+ // An image (evda_pair's QR code, HQ-2040) follows the text; everything the model needs is still in the text.
1502
+ const images = (r.images ?? []).map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType }));
1503
+ return ok({ content: [{ type: "text", text: `${r.text}${tail}${setup ? `\n\n---\n\n${setup}` : ""}${nudge}${repeat ? `\n\n${repeat}` : ""}` }, ...images], isError: Boolean(r.isError) });
690
1504
  } catch (e) {
691
1505
  // A refusal is a tool result the model reads and acts on, not a protocol failure.
692
- if (e instanceof EvdaError) return ok({ content: [{ type: "text", text: e.message }], isError: true });
1506
+ if (e instanceof EvdaError) return ok({ content: [{ type: "text", text: `${e.message}${tail}` }], isError: true });
693
1507
  throw e;
694
1508
  }
695
1509
  }
@@ -715,6 +1529,12 @@ export async function handle(msg, ctx) {
715
1529
  }
716
1530
  }
717
1531
 
1532
+ // initialize's clientInfo, reduced to a short name and version: the only part of it kept or logged.
1533
+ export function clientInfoOf(params) {
1534
+ const clip = (v) => (typeof v === "string" ? v.replace(/[^\x20-\x7e]/g, "").slice(0, 64) : null);
1535
+ return { name: clip(params?.clientInfo?.name), version: clip(params?.clientInfo?.version) };
1536
+ }
1537
+
718
1538
  function error(id, code, message) {
719
1539
  return { jsonrpc: "2.0", id, error: { code, message } };
720
1540
  }