create-evda 0.1.8 → 0.1.10

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +20 -7
  3. package/bin/evda.mjs +277 -7
  4. package/create-evda.mjs +17 -4
  5. package/dist/pairing.mjs +2932 -1684
  6. package/hooks/evda-day-writer-guard.mjs +7 -0
  7. package/lib/acknowledge.mjs +187 -0
  8. package/lib/adopt.mjs +488 -0
  9. package/lib/agents.mjs +1 -1
  10. package/lib/attested-host.mjs +269 -0
  11. package/lib/browse.mjs +354 -0
  12. package/lib/circles.mjs +5 -1
  13. package/lib/clients.mjs +29 -2
  14. package/lib/connect.mjs +9 -2
  15. package/lib/connecting-apps.mjs +56 -0
  16. package/lib/connector-tools.mjs +85 -0
  17. package/lib/corrections.mjs +34 -0
  18. package/lib/credentials-migrate.mjs +120 -0
  19. package/lib/day-file.mjs +173 -15
  20. package/lib/day-writer.mjs +110 -0
  21. package/lib/doctor.mjs +45 -3
  22. package/lib/every-chat.mjs +128 -0
  23. package/lib/filechanges.mjs +203 -68
  24. package/lib/gather-apply.mjs +176 -26
  25. package/lib/gather.mjs +119 -17
  26. package/lib/html-notes.mjs +124 -0
  27. package/lib/keep-hook.mjs +61 -8
  28. package/lib/keychain.mjs +181 -0
  29. package/lib/manifest.mjs +5 -1
  30. package/lib/mcp-auth.mjs +41 -1
  31. package/lib/mcp-http.mjs +42 -4
  32. package/lib/mcp.mjs +1198 -76
  33. package/lib/measurement-log.mjs +249 -0
  34. package/lib/messages.mjs +5 -2
  35. package/lib/migrate-people.mjs +54 -20
  36. package/lib/pairing.mjs +243 -5
  37. package/lib/parity.mjs +47 -13
  38. package/lib/person-folders.mjs +89 -0
  39. package/lib/plugin-hook.mjs +109 -0
  40. package/lib/plugin.mjs +208 -0
  41. package/lib/presets.mjs +26 -5
  42. package/lib/publish.mjs +55 -10
  43. package/lib/qr.mjs +317 -0
  44. package/lib/record.mjs +74 -0
  45. package/lib/routines.mjs +54 -4
  46. package/lib/sigstore-trust.json +20 -0
  47. package/lib/skills.mjs +1 -1
  48. package/lib/sync-lock.mjs +143 -0
  49. package/lib/sync-rules.mjs +154 -1
  50. package/lib/takeout.mjs +169 -0
  51. package/lib/tdx-attestation.mjs +412 -0
  52. package/lib/tdx-trust.json +10 -0
  53. package/lib/tools.mjs +1040 -31
  54. package/lib/touches.mjs +427 -0
  55. package/lib/update-check.mjs +30 -0
  56. package/lib/watch.mjs +160 -0
  57. package/lib/yaml.mjs +8 -4
  58. package/package.json +2 -1
  59. package/skills/evda/SKILL.md +14 -0
  60. package/templates/AGENTS.md +81 -10
  61. package/templates/evda-digest.md +5 -1
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";
@@ -23,14 +23,21 @@ import * as tools from "./tools.mjs";
23
23
  import * as presets from "./presets.mjs";
24
24
  import * as gather from "./gather.mjs";
25
25
  import * as gatherApply from "./gather-apply.mjs";
26
+ import * as adoptLib from "./adopt.mjs";
26
27
  import { checkForUpdate, summarize } from "./update-check.mjs";
27
28
  import { callerFrom } from "./readers.mjs";
28
29
  import { listSkills, getSkill, writeSkill, noticeRepeat, SKILLS_REL } from "./skills.mjs";
29
30
  import { createSubrecord } from "./subrecord.mjs";
30
31
  import { FALSIFY } from "./mcp-auth.mjs";
31
- import { filingOf, FILING_MODES } from "./day-file.mjs";
32
+ import { filingOf, FILING_MODES, entriesAsk } from "./day-file.mjs";
32
33
  import { readManifest, writeManifest } from "./manifest.mjs";
33
34
  import * as pairing from "./pairing.mjs";
35
+ import { encodeQr, qrPng, qrText } from "./qr.mjs";
36
+ import { splitFence } from "./agents.mjs";
37
+ import { touches, touchesText, warmTouches } from "./touches.mjs";
38
+ import { CLAUDE_AI_LAYER_BUDGET, appNamed } from "./connecting-apps.mjs";
39
+ import { EVERY_CHAT_KEY, EVERY_CHAT_VERSION, CHAT_APPS, chatAppOf, needsEveryChat, everyChatWalkthrough } from "./every-chat.mjs";
40
+ import { newToolsFor, newToolsNotice } from "./connector-tools.mjs";
34
41
 
35
42
  // Newest first. The server answers with the client's version when it supports it, and with the
36
43
  // newest otherwise; the client decides whether to continue.
@@ -41,7 +48,8 @@ export const SERVER_NAME = "evda";
41
48
  // acting on this field (2026-09-22: it told Mike "I only write to EVÐA when you ask me to" while
42
49
  // this said to file unasked), so the trigger is also the first words of evda_write's description,
43
50
  // which is what a deferred tool shows. This stays for the clients that read it.
44
- export function instructions(filing) {
51
+ export function instructions(filing, surface = "terminal") {
52
+ const chat = surface !== "terminal";
45
53
  const lines = [
46
54
  "This server is the owner's EVDA: their record, in plain files on this machine.",
47
55
  "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 +59,17 @@ export function instructions(filing) {
51
59
  ...(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
60
  "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
61
  "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.",
62
+ "When the owner corrects you, record it in the same turn with evda_record_correction: it appends one dated entry where this record keeps corrections (the file evda.json names as \"corrections\", else AGENTS.md's \"What I have been taught\"), in their own words where they gave some, and nothing else in that file changes.",
54
63
  "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
64
  "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
65
  ...(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.",
66
+ // A chat app is offered the chat tool set (chatToolNames), so its instructions name no tool outside it (HQ-2227).
67
+ chat
68
+ ? "Never quiz them. Never create a circle or grant: when they ask to share, propose it in words and wait for their yes."
69
+ : "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
70
  "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,
71
+ ...(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."]),
72
+ chat ? CHAT_DISPATCH_LINE : DISPATCH_LINE,
61
73
  PRIVACY_INSTRUCTIONS,
62
74
  ];
63
75
  return lines.join(" ");
@@ -95,8 +107,239 @@ export const PRIVACY_SECTION = [
95
107
  // Dispatch (HQ-1246), one line on every surface an agent reads first: the server instructions and
96
108
  // the operating layer. The vault's AGENTS-template.md is the core's source; until it carries this
97
109
  // 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.";
110
+ 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
111
  export const DISPATCH_SECTION = `## Messages to the owner\n\n${DISPATCH_LINE}`;
112
+ // In a chat app the owner is reading the conversation, so a message goes in the reply, and their
113
+ // replies to messages other agents sent come in the layer call's result (HQ-2227). So the chat layer
114
+ // says that and names neither evda_message nor evda_replies, which the chat tool set leaves out.
115
+ 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\".";
116
+ export const CHAT_DISPATCH_SECTION = `## Messages to the owner\n\n${CHAT_DISPATCH_LINE}`;
117
+ export const dispatchSection = (surface) => (surface === "terminal" ? DISPATCH_SECTION : CHAT_DISPATCH_SECTION);
118
+
119
+ // The operating layer as a chat app receives it (HQ-2203): the chat lines, Dispatch, privacy, a line
120
+ // with the layer's size, the generated core when the agents file lacks it (tools.coreBeside), and
121
+ // the agents file whole. His measured 2026-10-04: an owner layer of 72,284 characters plus a core
122
+ // of about 28,600 is about 27,300 tokens (Anthropic's count_tokens, 3.7 characters a token), over
123
+ // the 25,000-token cap Claude Code puts on one MCP tool result. So a layer longer than its client's
124
+ // part size comes in parts, cut only where a `## ` section starts (inside a section only when one
125
+ // section alone is too long), and every part ends by naming the next.
126
+ export const LAYER_PART_MAX = 80_000;
127
+ const PART_RESERVE = 400;
128
+
129
+ // Which of the owner's sections a client gets is the owner's call, made with one marker (HQ-2207):
130
+ // a `## ` section with SURFACE_TERMINAL on a line of its own text applies only where the agent has a shell
131
+ // (the scripts it may run, the hosts it may ssh to, its local MCP servers). A coding agent gets every
132
+ // section; any other client, named or not, gets the rest. A chat app is the default, so an app this
133
+ // table has never heard of works on day one, and a record with no marker reads the same everywhere.
134
+ export const SURFACE_TERMINAL = "<!-- evda:surface terminal -->";
135
+
136
+ // Whether one `## ` section is marked for a terminal (HQ-2223, slice 2): the marker on a line of its
137
+ // own anywhere in the section's own text, before its first subsection, and not inside a code fence.
138
+ // It used to count only on the line right after the heading, and on 2026-10-05 an edit that put one
139
+ // line above it turned a terminal section into a chat one with nothing said; a marker the owner wrote
140
+ // in the section means what it says wherever the section's own text holds it. A marker under a `### `
141
+ // is not the section's, and one in a fence is the marker being shown, not used.
142
+ export function terminalOnly(sec) {
143
+ let fence = null;
144
+ for (const line of sec.split(/\r?\n/).slice(1)) {
145
+ const t = line.trim();
146
+ const f = /^(`{3,}|~{3,})/.exec(t);
147
+ if (f) {
148
+ if (fence === null) fence = f[1][0];
149
+ else if (f[1][0] === fence) fence = null;
150
+ continue;
151
+ }
152
+ if (fence !== null) continue;
153
+ if (/^#{3,}\s/.test(t)) return false;
154
+ if (t === SURFACE_TERMINAL) return true;
155
+ }
156
+ return false;
157
+ }
158
+
159
+ // Which app is connecting, and what it is served: the one table in lib/connecting-apps.mjs
160
+ // (HQ-2218), read here by exact name. An app it does not name gets a chat app's sections and the
161
+ // smallest documented part, LAYER_PART_MAX.
162
+ export { CLAUDE_AI_LAYER_BUDGET };
163
+ export function clientProfile(name) {
164
+ const known = appNamed(name);
165
+ if (known) return { app: known.app, surface: known.surface, max: known.max ?? LAYER_PART_MAX, budget: known.budget ?? CHAT_LAYER_BUDGET };
166
+ return { app: null, surface: "chat", max: LAYER_PART_MAX, budget: CHAT_LAYER_BUDGET };
167
+ }
168
+
169
+ // The owner layer for one surface: the text without the sections tagged for another, and the
170
+ // headings it left out. A terminal reads the text as it is.
171
+ export function forSurface(text, surface) {
172
+ if (surface === "terminal" || !text.includes(SURFACE_TERMINAL)) return { text, left: [] };
173
+ const left = [];
174
+ const kept = text.split(/^(?=## )/m).filter((sec) => {
175
+ const m = /^## (.*)/.exec(sec);
176
+ if (m && terminalOnly(sec)) { left.push(m[1].trim()); return false; }
177
+ return true;
178
+ });
179
+ return { text: kept.join(""), left };
180
+ }
181
+
182
+ // A section the owner marked for a terminal is dropped from a chat app's layer without a word
183
+ // (HQ-2227). It used to be named in a line ("left out here: ..."), and a chat app read that as a
184
+ // list to fetch: claude.ai, 2026-10-05, spent calls asking for sections it could not be given.
185
+
186
+ // The chat budget (HQ-2223): how long the operating layer a chat app reads in its first call may
187
+ // be, in characters, everything in that result counted. Set from two measurements: the generated
188
+ // core, which every chat needs whole, is about 29,100 characters (his IRIS.md, 2026-10-05), and the
189
+ // lines above it (chat section, Dispatch, privacy) about 4,500. A budget of 70,000 leaves the owner's
190
+ // own sections about as much room again as the core, enough for the opening sections a first reply
191
+ // draws on (his reach through "Proactive Context Loading", 29,700 characters), while the whole result
192
+ // stays under LAYER_PART_MAX minus the part reserve, so every chat app (ChatGPT and unknown apps
193
+ // included) takes it in one call, and near 19,000 tokens (3.7 characters a token) instead of the
194
+ // 27,300 his whole layer cost. Past it, a section is one line in an index and is fetched whole by
195
+ // name when a conversation needs it. A coding agent is not budgeted: it gets every section.
196
+ export const CHAT_LAYER_BUDGET = 70_000;
197
+ // The size line ("Operating layer: N characters, in one part.") is written after the layer is cut.
198
+ const SIZE_LINE_RESERVE = 120;
199
+ // A listed section's first line is cut here, at a word, so one long paragraph cannot fill the index.
200
+ const INDEX_LINE_MAX = 200;
201
+
202
+ // The owner layer as units, in order. A unit with a heading is one `## ` section with everything
203
+ // under it; a unit with none is served whole always: the text before the first heading, and the
204
+ // installer's generated fence (the core, in a scaffolded AGENTS.md), which is never listed.
205
+ export function ownerUnits(text) {
206
+ let fence = null;
207
+ try { fence = splitFence(text); } catch { fence = null; }
208
+ const chunks = fence ? [[fence.before, false], [fence.generated, true], [fence.after, false]] : [[text, false]];
209
+ const units = [];
210
+ for (const [chunk, pinned] of chunks) {
211
+ if (!chunk) continue;
212
+ if (pinned) { units.push({ text: chunk, heading: null }); continue; }
213
+ for (const sec of chunk.split(/^(?=## )/m)) {
214
+ if (!sec) continue;
215
+ const m = /^## (.*)/.exec(sec);
216
+ units.push({ text: sec, heading: m ? m[1].trim() : null });
217
+ }
218
+ }
219
+ return units;
220
+ }
221
+
222
+ function firstLineOf(sec) {
223
+ const line = sec.split("\n").slice(1).map((l) => l.trim()).find((l) => l && l !== SURFACE_TERMINAL) ?? "";
224
+ if (line.length <= INDEX_LINE_MAX) return line;
225
+ const cut = line.slice(0, INDEX_LINE_MAX);
226
+ const at = cut.lastIndexOf(" ");
227
+ return `${at > INDEX_LINE_MAX / 2 ? cut.slice(0, at) : cut}...`;
228
+ }
229
+
230
+ // A listed section, where it stands in the owner layer: its heading, how to fetch it, and how it opens.
231
+ function sectionStub(u) {
232
+ 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`;
233
+ }
234
+
235
+ // The owner layer as a chat app reads it: whole when the result fits the budget. Otherwise first
236
+ // fit, in the file's order: each section is served whole if the result still fits with it, and is
237
+ // listed where it stands if not, and the next one is tried. One large section early in a file does
238
+ // not push out every smaller one after it (HQ-2223: for Mike, Preferences, how to talk to him).
239
+ // `fixed` is everything else in the result. The stubs are counted from the start, so a section is
240
+ // kept only when the result with it, and with every section not yet decided listed, fits.
241
+ export function fitChat(text, fixed, budget = CHAT_LAYER_BUDGET) {
242
+ if (fixed + text.length <= budget) return { text, listed: [] };
243
+ const units = ownerUnits(text);
244
+ const sections = units.filter((u) => u.heading !== null);
245
+ if (!sections.length) return { text, listed: [] };
246
+ const keep = new Set();
247
+ const render = () => units.map((u) => (u.heading === null || keep.has(u) ? u.text : sectionStub(u))).join("");
248
+ let size = fixed + render().length;
249
+ for (const u of sections) {
250
+ const grow = u.text.length - sectionStub(u).length;
251
+ if (size + grow <= budget) { keep.add(u); size += grow; }
252
+ }
253
+ return { text: render(), listed: sections.filter((u) => !keep.has(u)).map((u) => u.heading) };
254
+ }
255
+
256
+ // One section of the owner layer, whole, by its heading: matched exactly, then without case, with
257
+ // or without a leading "## ". Every section under that heading comes back, in order. A section the
258
+ // owner tagged for a terminal is refused to any other client.
259
+ export function findSection(text, name, surface) {
260
+ 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");
261
+ const want = name.trim().replace(/^##\s+/, "").trim();
262
+ const units = ownerUnits(text).filter((u) => u.heading !== null);
263
+ let hits = units.filter((u) => u.heading === want);
264
+ if (!hits.length) hits = units.filter((u) => u.heading.toLowerCase() === want.toLowerCase());
265
+ const offered = [...new Set(units.filter((u) => surface === "terminal" || !terminalOnly(u.text)).map((u) => u.heading))];
266
+ 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");
267
+ if (surface !== "terminal") {
268
+ const served = hits.filter((u) => !terminalOnly(u.text));
269
+ 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");
270
+ hits = served;
271
+ }
272
+ return { heading: hits[0].heading, text: hits.map((u) => u.text).join("\n") };
273
+ }
274
+
275
+ // A section fetched whole, in parts when it is longer than the client's part size.
276
+ export function sectionParts(evda, client, name) {
277
+ const profile = clientProfile(client?.name);
278
+ const r = tools.operatingLayer(evda);
279
+ const s = findSection(r.text, name, profile.surface);
280
+ const head = `Section ${JSON.stringify(s.heading)} of the owner layer, whole.`;
281
+ const max = profile.max;
282
+ const pieces = packSections(s.text, max - PART_RESERVE - head.length - 2, max - PART_RESERVE);
283
+ const n = pieces.length;
284
+ const ask = (i) => `{"section": ${JSON.stringify(s.heading)}, "part": ${i}}`;
285
+ const parts = pieces.map((p, i) => {
286
+ const top = i === 0 ? `${head}\n\n` : `Section ${JSON.stringify(s.heading)}, part ${i + 1} of ${n}, continued.\n\n`;
287
+ 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.)` : "";
288
+ return `${top}${p}${end}`;
289
+ });
290
+ return { path: r.path, heading: s.heading, parts };
291
+ }
292
+
293
+ export function coreBlock(r) {
294
+ if (r.core) return [r.core.text];
295
+ if (r.coreNote) return [r.coreNote];
296
+ return [];
297
+ }
298
+
299
+ export function layerParts(evda, client = null) {
300
+ const profile = clientProfile(client?.name);
301
+ const r = tools.operatingLayer(evda);
302
+ const owner = forSurface(r.text, profile.surface);
303
+ const head = [chatSection(evda, r.text, { app: profile.app }), dispatchSection(profile.surface), PRIVACY_SECTION].join("\n\n");
304
+ const core = coreBlock(r);
305
+ // Everything in the result but the owner sections: head, size line, core, and the joins between.
306
+ const fixed = head.length + 2 + SIZE_LINE_RESERVE + 2 + core.reduce((n, c) => n + c.length + 2, 0);
307
+ const fit = profile.surface === "terminal" ? { text: owner.text, listed: [] } : fitChat(owner.text, fixed, profile.budget);
308
+ const body = [...core, fit.text].join("\n\n");
309
+ const max = profile.max;
310
+ const pieces = packSections(body, max - PART_RESERVE - head.length, max - PART_RESERVE);
311
+ const n = pieces.length;
312
+ const chars = head.length + body.length;
313
+ 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."}`;
314
+ const parts = pieces.map((p, i) => {
315
+ const top = i === 0 ? `${head}\n\n${size}\n\n` : `Operating layer, part ${i + 1} of ${n}, continued.\n\n`;
316
+ const end = i < n - 1 ? `\n\n(Part ${i + 1} of ${n} ends here. The layer continues: call evda_operating_layer with {"part": ${i + 2}} now, before answering.)` : "";
317
+ return `${top}${p}${end}`;
318
+ });
319
+ return { path: r.path, core: r.core?.path ?? null, parts, chars, surface: profile.surface, left: owner.left, listed: fit.listed };
320
+ }
321
+
322
+ // Split text into pieces of at most `max` characters (the first at most `first`), cutting where a
323
+ // `## ` heading starts; a section longer than a piece is cut at a line, and a line at a character.
324
+ // The pieces joined with "" are the text.
325
+ export function packSections(text, first, max) {
326
+ const units = [];
327
+ for (const sec of text.split(/^(?=## )/m)) {
328
+ if (sec.length <= Math.min(first, max)) { units.push(sec); continue; }
329
+ for (const line of sec.split(/(?<=\n)/)) {
330
+ for (let i = 0; i < line.length; i += max) units.push(line.slice(i, i + max));
331
+ }
332
+ }
333
+ const out = [];
334
+ let cur = "";
335
+ for (const u of units) {
336
+ const cap = out.length === 0 ? first : max;
337
+ if (cur && cur.length + u.length > cap) { out.push(cur); cur = ""; }
338
+ cur += u;
339
+ }
340
+ out.push(cur);
341
+ return out;
342
+ }
100
343
 
101
344
  const str = (description) => ({ type: "string", description });
102
345
 
@@ -106,6 +349,22 @@ const str = (description) => ({ type: "string", description });
106
349
  const NEEDS_A_COMMAND = new Set(["UPDATE_AVAILABLE", "UNSTAMPED"]);
107
350
  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
351
 
352
+ // Layer 1 of HQ-2205: the first words of every tool a chat app shows the model. claude.ai defers
353
+ // connector tools and shows only each one's cut-off first line, and it does not act on the server's
354
+ // instructions, so the first line is the only place a model that never loaded the layer reads that
355
+ // it should. The clause carries its reason in a few words, then the tool's own description follows.
356
+ // evda_write and evda_note keep their trigger as their first words (HQ-1385 measured that the first
357
+ // words are what files) and carry the same clause inside their first sentence instead.
358
+ // HQ-2223: the clause follows each tool's own first sentence. claude.ai's tool search ranks on the
359
+ // description, and when every one opened with this same sentence it took three searches to find
360
+ // evda_write (2026-10-05).
361
+ // HQ-2223, slice 2: the clause no longer names the layer tool. With evda_operating_layer in all 29
362
+ // descriptions, a search for "operating layer" matched every tool equally and returned five that
363
+ // were not it (2026-10-05). So only the layer tool carries those words, and every other tool points
364
+ // at it by what it does; a model that has the name from the server's instructions searches by it.
365
+ export const LAYER_TOOL_REF = "the tool that loads who you are here";
366
+ export const LAYER_FIRST = `Call ${LAYER_TOOL_REF} first in a new chat. `;
367
+
109
368
  // evda_write's description, by the owner's filing answer. It leads with when to call it, because a
110
369
  // deferred connector tool shows the model its name and the first line of its description and
111
370
  // nothing else until it is loaded (HQ-1385). An owner who has not answered is filed for as if they
@@ -113,28 +372,123 @@ const TERMINAL_NOTE = "Run that command in a terminal on this machine. Nothing h
113
372
  // EVDA_CHAT_FALSIFY=old-description serves the description from before HQ-1385, which says what the
114
373
  // tool does and never when, for the test that shows the first line is what files.
115
374
  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.",
375
+ 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.`,
376
+ 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
377
  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
378
  };
120
379
  const chatFalsified = (mode, env = process.env) => (env.EVDA_CHAT_FALSIFY ?? "").split(",").map((x) => x.trim()).includes(mode);
121
380
 
381
+ const OWN_TRIGGER_FIRST = new Set(["evda_operating_layer", "evda_write", "evda_note"]);
382
+
122
383
  // 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 };
384
+ export function toolList(filing, env = process.env, only = null) {
385
+ return TOOLS.filter((t) => !only || only.has(t.name)).map(({ run, ...t }) => {
386
+ if (t.name === "evda_write") {
387
+ const description = chatFalsified("old-description", env) ? WRITE_DESCRIPTION.old : WRITE_DESCRIPTION[filing === "asked" ? "asked" : "everything"];
388
+ return { ...t, description };
389
+ }
390
+ if (OWN_TRIGGER_FIRST.has(t.name)) return t;
391
+ const lead = firstSentence(t.description);
392
+ return { ...t, description: `${lead} ${LAYER_FIRST}${t.description.slice(lead.length).trimStart()}`.trimEnd() };
128
393
  });
129
394
  }
130
395
 
396
+ // The tools a chat app is offered (HQ-2227). claude.ai defers every connector tool behind a search
397
+ // that returns about five, so each tool a chat needs can cost a search, and 37 tools made the first
398
+ // answer take about 7 calls (2026-10-05). The rule: a chat app gets every tool the operating layer it
399
+ // reads tells it to use, and nothing else. So the set is derived from that layer, for this record and
400
+ // this client: every evda_* name in it (the chat lines, the core, the owner sections a chat may read,
401
+ // listed ones included), every `evda <command>` it says to run mapped to the tool that does it here,
402
+ // and the few tools its prose asks for without a name. A coding agent gets every tool.
403
+ //
404
+ // A command the core names must be in CLI_TOOLS, mapped to the tools that do it in a chat or to none
405
+ // with the reason; test/chat-tools.test.mjs fails on one that is not, so a new command is a decision.
406
+ export const CLI_TOOLS = {
407
+ "check-update": ["evda_check_update"],
408
+ acknowledge: ["evda_acknowledge"],
409
+ preset: ["evda_presets", "evda_propose_structure", "evda_apply_preset"],
410
+ note: ["evda_note"],
411
+ release: ["evda_release_note"],
412
+ // "after structural changes when the platform allows it": in a chat the structural tools
413
+ // (evda_apply_preset, evda_create_subrecord) report what doctor would, so it is not offered there.
414
+ doctor: [],
415
+ // The installer's own commands, run in a terminal; nothing here runs them.
416
+ upgrade: [],
417
+ "local-model": [],
418
+ };
419
+ // What the core asks for in prose, without a tool name: reading by path and searching (every
420
+ // session, "Read the last few days ... and anything they point you at"), an entry or a person's page
421
+ // growing ("Entries grow on their own"), the setup answers, an import ("An import is what another AI said"), and the filing
422
+ // answer ("ask them once ... and record the answer there").
423
+ // HQ-2228: an entry growing is evda_append (any entry) beside evda_record_person_note (a person's
424
+ // page), and setup ("Write each answer into that section in their exact words") is evda_setup_answer.
425
+ // HQ-2328/HQ-2331: a row in a log kept as a table is evda_append_row, and the page, person's page or
426
+ // folder the core says to offer ("start it only on their yes") is evda_create. HQ-2333: an item in a
427
+ // board or queue kept as a list is evda_append_item.
428
+ export const CHAT_PROSE_TOOLS = ["evda_operating_layer", "evda_write", "evda_read", "evda_search", "evda_recent", "evda_record_person_note", "evda_append", "evda_append_row", "evda_append_item", "evda_create", "evda_setup_answer", "evda_import", "evda_filing"];
429
+
430
+ // The tool names a text tells an agent to use: evda_* names and `evda <command>` runs.
431
+ export function toolsNamedIn(text) {
432
+ const names = new Set(text.match(/\bevda_[a-z_]+\b/g) ?? []);
433
+ for (const m of text.matchAll(/`evda ([a-z][a-z-]*)/g)) for (const t of CLI_TOOLS[m[1]] ?? []) names.add(t);
434
+ return names;
435
+ }
436
+
437
+ export function chatToolNames(evda, { app = null } = {}) {
438
+ const known = new Set(TOOLS.map((t) => t.name));
439
+ const names = new Set(CHAT_PROSE_TOOLS);
440
+ let text = "";
441
+ try {
442
+ const r = tools.operatingLayer(evda);
443
+ const owner = forSurface(r.text, "chat").text;
444
+ text = [chatSection(evda, r.text), CHAT_DISPATCH_SECTION, PRIVACY_SECTION, ...coreBlock(r), owner].join("\n\n");
445
+ } catch { text = chatSection(evda, ""); }
446
+ text += `\n\n${instructions(filingOf(evda.manifest), "chat")}`;
447
+ for (const n of toolsNamedIn(text)) names.add(n);
448
+ // The every-chat walkthrough rides on a chat's first result while this app has no answer.
449
+ if (needsEveryChat(evda.manifest, app, filingOf(evda.manifest))) names.add("evda_every_chat");
450
+ return new Set([...names].filter((n) => known.has(n)));
451
+ }
452
+
453
+ // A description's first sentence: up to the first period that ends a sentence, so not one in an
454
+ // ellipsis or a dotted name such as evda.json.
455
+ export function firstSentence(text) {
456
+ const m = /^.*?[^.]\.(?=\s|$)/s.exec(text);
457
+ return m ? m[0] : text;
458
+ }
459
+
131
460
  // The one setup question, asked at first connect when evda.json has no filing answer (HQ-1385).
132
461
  export const FILING_QUESTION = "Should I file everything you say here automatically, or only when you ask? (everything)";
133
462
 
463
+ // HQ-2326: on 2026-10-07 a claude.ai chat worked out a real read of what he asked, wrote it into a
464
+ // person's page, and replied with only the filing report. In a chat the reply is where he hears it.
465
+ export const REPLY_FIRST_LINE = "- Your reply carries your thinking and your answer. Report filing after it, in one line; a page you write never replaces saying it to them here.";
466
+
134
467
  // The few lines a chat app reads before the whole operating layer (HQ-1385): who the agent is,
135
468
  // whether to file, one section per conversation, no share marks or circle rules, and "just note".
136
469
  // Five lines, and a sixth that asks the setup question when the owner has not answered it.
137
- export function chatSection(evda, agentsText = "") {
470
+ // HQ-2228: in a claude.ai chat whose setup is not finished, one line telling the owner that Always
471
+ // allow on this connector makes every chat easier, and where it is. A connector cannot grant itself
472
+ // permission, so it is a recommendation with its reason, never a step setup waits on. Only claude.ai,
473
+ // whose setting this names; another app's is somewhere else, and a setting is never named from memory.
474
+ 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.";
475
+
476
+ // THE NOW LINE (HQ-2268). A chat model has no clock and guesses the time, or reads a UTC stamp as
477
+ // the owner's; each surface that worked the time out on its own was wrong once (HQ-713, HQ-2199,
478
+ // HQ-2236). So the agent is handed the time, in the owner's zone, at the top of what it reads first.
479
+ // The zone is this process's own (TZ). The package stays zone-agnostic: whoever runs the server
480
+ // sets TZ to where the owner is, and EVDA_ZONE_SOURCE to how it knows (Hestia: his-zone.mjs, every
481
+ // five minutes). Same shape as the line Hestia's passes open with: `Now: Tue 10/6 9:58 AM PDT (America/Los_Angeles, from ...)`.
482
+ export function nowLine(at = new Date(), env = process.env) {
483
+ const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
484
+ const p = Object.fromEntries(new Intl.DateTimeFormat("en-US", {
485
+ timeZone: zone, weekday: "short", month: "numeric", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short",
486
+ }).formatToParts(at).map((x) => [x.type, x.value]));
487
+ const source = String(env.EVDA_ZONE_SOURCE ?? "").trim() || "this machine's clock";
488
+ return `Now: ${p.weekday} ${p.month}/${p.day} ${p.hour}:${p.minute} ${p.dayPeriod} ${p.timeZoneName} (${zone}, from ${source})`;
489
+ }
490
+
491
+ export function chatSection(evda, agentsText = "", { app = null } = {}) {
138
492
  const field = (label) => new RegExp(`^- \\*\\*${label}:\\*\\*[ \\t]*(\\S.*)$`, "m").exec(agentsText)?.[1]?.trim() ?? "";
139
493
  const agent = field("Agent name") || "Iris";
140
494
  const owner = (typeof evda.manifest?.owner?.name === "string" && evda.manifest.owner.name.trim()) || field("Who I am") || "the owner";
@@ -142,6 +496,8 @@ export function chatSection(evda, agentsText = "") {
142
496
  const lines = [
143
497
  "## In a chat app, read this first",
144
498
  "",
499
+ `${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.`,
500
+ "",
145
501
  `- 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
502
  filing === "asked"
147
503
  ? "- They turned automatic filing off: file their words with evda_write only when they ask you to."
@@ -149,7 +505,16 @@ export function chatSection(evda, agentsText = "") {
149
505
  "- One conversation is one section: your first evda_write opens it with one line of context, and the rest go under it.",
150
506
  "- Never write a share mark or a circle rule. When they want to share, propose it and wait for their yes.",
151
507
  "- \"Just note this\" means evda_note: no AI reads that entry, you included, until they say it can.",
508
+ "- 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. A log or board kept as a table grows by a row (evda_append_row), and one kept as a list by an item in its format (evda_append_item).",
509
+ REPLY_FIRST_LINE,
152
510
  ];
511
+ // HQ-2164: what the agent itself did goes to its own day log. Named here, so a chat app is offered
512
+ // the tool (chatToolNames offers what this layer names), and only when the record has that log.
513
+ 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.");
514
+ if (tools.setupPending(agentsText)) {
515
+ lines.push("- Setup is not finished: write each answer into \"What this EVÐA is\" with evda_setup_answer, in their exact words.");
516
+ if (app === "claude.ai") lines.push(`- ${ALWAYS_ALLOW_LINE}`);
517
+ }
153
518
  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
519
  return lines.join("\n");
155
520
  }
@@ -166,30 +531,156 @@ export function setFiling(evda, args = {}) {
166
531
  return { filing: args.filing, changed: true };
167
532
  }
168
533
 
534
+ // Record whether the owner put the every-chat line in one app (HQ-2205, layer 4), so the next chat
535
+ // in that app is not asked again. It writes one entry under evda.json "every_chat" and no other key:
536
+ // the rest of the manifest is written back as it was read. The app defaults to the one this session
537
+ // connected from, which is what the walkthrough named.
538
+ export const EVERY_CHAT_APPS = [...Object.keys(CHAT_APPS), "other"];
539
+ export const EVERY_CHAT_STATES = ["set", "declined"];
540
+ export function setEveryChat(evda, args = {}, { app: sessionApp = null, now = new Date() } = {}) {
541
+ tools.onlyKeys("evda_every_chat", args, ["app", "state", "confirmed"]);
542
+ const app = args.app ?? sessionApp;
543
+ 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");
544
+ 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");
545
+ 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");
546
+ const m = readManifest(evda.dir);
547
+ const all = m[EVERY_CHAT_KEY] && typeof m[EVERY_CHAT_KEY] === "object" && !Array.isArray(m[EVERY_CHAT_KEY]) ? m[EVERY_CHAT_KEY] : {};
548
+ const form = filingOf(m) === "asked" ? "asked" : "everything";
549
+ const entry = { state: args.state, form, version: EVERY_CHAT_VERSION, date: tools.localDate(now) };
550
+ m[EVERY_CHAT_KEY] = { ...all, [app]: entry };
551
+ writeManifest(evda.dir, m);
552
+ return { app, ...entry };
553
+ }
554
+
555
+ // What an earlier reader already acknowledged in a file (HQ-2060), as one attribute on its tag, so
556
+ // a reader sees the mark in the same breath as the words and never twice-acts on the same entry.
557
+ // Outside the file's text on purpose: nothing the agent writes goes inside the owner's words.
558
+ export function ackAttr(acknowledged = []) {
559
+ if (!acknowledged?.length) return "";
560
+ const esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
561
+ const parts = acknowledged.map((a) => `${a.section ? `${a.section}: ` : ""}${esc(a.by ?? "an earlier reader")} at ${esc(a.at)} ${esc(a.note)}`);
562
+ return ` acknowledged="${parts.join(" | ")}"`;
563
+ }
564
+
565
+ export function acknowledgedNote(n) {
566
+ 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.`;
567
+ }
568
+
569
+ // The ride-along arguments on evda_read and evda_search (HQ-2227): the owner's message this turn,
570
+ // filed by fileAlong when the call runs, so a turn that reads files in the same call. evda_write
571
+ // stays the tool for a turn with nothing to read.
572
+ 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.";
573
+ const ALONG_PROPS = {
574
+ 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."),
575
+ context: str("With owner_message only: one line, yours, on what the chat is about. Written above their words and labeled as not theirs."),
576
+ };
577
+
578
+ // Run a read or a search with its ride-along: the tool's own arguments go to the tool, and the
579
+ // filing line follows its result. The filing runs first, so a refused read still files the message.
580
+ function withAlong(evda, args, ctx, toolName, run) {
581
+ const { owner_message, context, ...rest } = args ?? {};
582
+ if (owner_message === undefined) {
583
+ 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");
584
+ return run(rest);
585
+ }
586
+ 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");
587
+ const f = fileAlong(evda, { words: owner_message, context }, ctx, toolName);
588
+ let r;
589
+ try {
590
+ r = run(rest);
591
+ } catch (e) {
592
+ if (e instanceof EvdaError) throw new EvdaError(`${e.message}\n\n${f.head}`, { code: e.code, exit: e.exit });
593
+ throw e;
594
+ }
595
+ return { ...r, text: `${r.text}\n\n---\n\n${f.head}`, filed: f.filed ? f.path : null };
596
+ }
597
+
169
598
  export const TOOLS = [
170
599
  {
171
600
  name: "evda_operating_layer",
172
601
  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 } };
602
+ // HQ-2223, slice 2: the first sentence holds the words a model searches by when it starts a chat
603
+ // as the owner's agent (who you are, the owner's record, a new chat), and this is the one tool
604
+ // whose definition says "operating layer", so a search for it finds this tool and no other.
605
+ 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, the recent days (each section's context and the opening of their words) and the people whose pages grew this week, 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.",
606
+ inputSchema: {
607
+ type: "object",
608
+ properties: {
609
+ part: { type: "integer", minimum: 1, description: "Which part of a long layer or section. Default 1; the end of each part names the next." },
610
+ section: { type: "string", description: "A heading from the layer's list of sections, to read that section whole." },
611
+ 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." },
612
+ 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." },
613
+ },
614
+ additionalProperties: false,
615
+ },
616
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
617
+ run: (evda, args, ctx = {}) => {
618
+ tools.onlyKeys("evda_operating_layer", args, ["part", "section", "first_message", "context"]);
619
+ const part = args.part === undefined ? 1 : args.part;
620
+ const opening = args.first_message !== undefined;
621
+ 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");
622
+ if (opening) {
623
+ 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");
624
+ 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");
625
+ }
626
+ if (args.section !== undefined) {
627
+ const s = sectionParts(evda, ctx.client, args.section);
628
+ if (!Number.isInteger(part) || part < 1 || part > s.parts.length) throw tools.refuse(`evda_operating_layer "part" is a whole number from 1 to ${s.parts.length}; this section has ${s.parts.length === 1 ? "one part" : `${s.parts.length} parts`}.`, "BAD_ARGUMENT");
629
+ return { text: s.parts[part - 1], data: { path: s.path, section: s.heading, part, parts: s.parts.length } };
630
+ }
631
+ const l = layerParts(evda, ctx.client);
632
+ if (!Number.isInteger(part) || part < 1 || part > l.parts.length) throw tools.refuse(`evda_operating_layer "part" is a whole number from 1 to ${l.parts.length}; this layer has ${l.parts.length === 1 ? "one part" : `${l.parts.length} parts`}.`, "BAD_ARGUMENT");
633
+ const data = { path: l.path, core: l.core, part, parts: l.parts.length, chars: l.chars, listed: l.listed };
634
+ if (!opening) return { text: l.parts[part - 1], data };
635
+ const start = openChat(evda, args, ctx, { room: clientProfile(ctx.client?.name).max - l.parts[part - 1].length - 7 - AFTER_RUN_RESERVE });
636
+ return { text: `${l.parts[part - 1]}\n\n---\n\n${start.text}`, data: { ...data, filed: start.filed } };
179
637
  },
180
638
  },
181
639
  {
182
640
  name: "evda_recent",
183
641
  title: "Read the last few days",
184
- description: "Returns every record file dated in the last N days (default 3), newest first, whole.",
642
+ description: "Lists what the record's last few days were about. Per file, each section's heading, its context line and the opening of the owner's words; N days (default 3), newest first. 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
643
  inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 90, description: "How many days back, today included. Default 3." } }, additionalProperties: false },
186
644
  annotations: { readOnlyHint: true, openWorldHint: false },
187
645
  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)` : ""}.`;
191
- 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 } };
646
+ const r = tools.recent(evda, args, { caller: ctx.caller, now: ctx.now });
647
+ const room = ctx.room ?? clientProfile(ctx.client?.name).max - PART_RESERVE;
648
+ return renderRecent(r, room);
649
+ },
650
+ },
651
+ {
652
+ name: "evda_acknowledge",
653
+ title: "Mark that you acted on a weighty entry",
654
+ 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.",
655
+ inputSchema: {
656
+ type: "object",
657
+ properties: {
658
+ path: str("The entry you acted on, as a path inside the record: the path evda_recent returned."),
659
+ 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."),
660
+ note: str("One line: what you did about it. The next reader reads this instead of acting again."),
661
+ by: str("Who you are, when that is not the agent this record names: a scheduled run, an always-on agent."),
662
+ },
663
+ required: ["path", "note"],
664
+ additionalProperties: false,
665
+ },
666
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
667
+ run: (evda, args) => {
668
+ const r = tools.acknowledgeEntry(evda, args);
669
+ const what = r.section ? `${r.path} "${r.section}"` : r.path;
670
+ 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 };
671
+ 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 };
672
+ },
673
+ },
674
+ {
675
+ name: "evda_acknowledgements",
676
+ title: "What readers have already acknowledged",
677
+ 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.",
678
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
679
+ annotations: { readOnlyHint: true, openWorldHint: false },
680
+ run: (evda, args) => {
681
+ const r = tools.acknowledgements(evda, args);
682
+ if (!r.count) return { text: "Nothing in this record is acknowledged yet.", data: r };
683
+ return { text: `${r.count} acknowledged:\n${r.marks.map((m) => `- ${m.key} (${m.by ?? "unknown"}, ${m.at}): ${m.note}`).join("\n")}`, data: r };
193
684
  },
194
685
  },
195
686
  {
@@ -198,15 +689,183 @@ export const TOOLS = [
198
689
  description: WRITE_DESCRIPTION.everything,
199
690
  inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim: filler, hedges and typos intact. Never a summary."), context: str("One line, yours, on what the conversation was about. Written above their words and labeled as not theirs. Never their words.") }, required: ["words"], additionalProperties: false },
200
691
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
692
+ run: async (evda, args, ctx) => {
693
+ const dup = sameAsFiled(ctx.session, args?.words);
694
+ 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 } };
695
+ // HQ-2165: writeVerified reads the words back after a short delay and throws instead of
696
+ // returning, so a clobbered write is never reported to the owner as "Filed".
697
+ const r = await tools.writeVerified(evda, args, { session: ctx.session });
698
+ markWrite(ctx.session, args.words, r.path);
699
+ if (r.queued) return { text: queuedText(r), data: r };
700
+ return withTouches(evda, ctx, { text: `Filed to ${r.path}.`, data: r }, { path: r.path, words: [args.context, args.words].filter(Boolean).join("\n") });
701
+ },
702
+ },
703
+ {
704
+ name: "evda_record_correction",
705
+ title: "Record a correction",
706
+ description: "Records a correction as one dated entry 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 a dated section to the file evda.json names as \"corrections\" when it names one, else 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.",
707
+ inputSchema: {
708
+ type: "object",
709
+ properties: {
710
+ words: str("The owner's exact words, quoted verbatim, when they gave some. Omit rather than paraphrase."),
711
+ change: str("One line: what you do differently now."),
712
+ title: str("A few words naming what went wrong, e.g. \"missed the midterm when checking a week\". The entry's heading when corrections go to their own file."),
713
+ supersedes: str("YYYY-MM-DD of an earlier entry this correction reverses, if any."),
714
+ },
715
+ required: ["change"],
716
+ additionalProperties: false,
717
+ },
718
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
719
+ run: (evda, args, ctx) => {
720
+ const r = tools.recordCorrection(evda, args, { caller: ctx.caller });
721
+ return { text: `Recorded to ${r.path}, under "${r.section}".`, data: r };
722
+ },
723
+ },
724
+ {
725
+ name: "evda_write_agent_log",
726
+ title: "File Iris's own day log",
727
+ description: "Files the agent's own day log, separate from the owner's words. Call to record what you (the agent, not the owner) did, built, filed or found: appends to Iris's own day log, the one named sub-record at \"01_Projects/Evda/Iris - Universal Thinking Partner/memory/record/daily\", and returns the path. It takes no path. It also keeps the one pointer line to that log under the agent's heading in the owner's day for today (added, or refreshed in place, never a second line), and touches nothing else there. One section per conversation, exactly like evda_write: the first call in a conversation opens the time heading with its context line, and later calls in the same conversation continue it.",
728
+ inputSchema: { type: "object", properties: { words: str("What you did, in your own words."), context: str("One line on what the conversation was about. Written above the words and labeled as not the owner's.") }, required: ["words"], additionalProperties: false },
729
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
730
+ run: (evda, args, ctx) => {
731
+ const r = tools.writeAgentLog(evda, args, { session: ctx.session });
732
+ const p = r.pointer;
733
+ const pointer = p.state === "failed" ? ` The pointer line in ${p.path} was NOT written (${p.error}); the log entry is filed, so do not call this again for it.`
734
+ : p.state === "sealed" ? ` ${p.path} is sealed, so its pointer line was left alone.`
735
+ : ` Pointer line in ${p.path} ${p.state}.`;
736
+ return { text: `Filed to ${r.path}.${pointer}`, data: r };
737
+ },
738
+ },
739
+ {
740
+ name: "evda_record_person_note",
741
+ title: "Add to a person's page",
742
+ description: "Adds one dated section to an existing person's page, found by their name. Returns the path. A person's page is any page with kind: person-profile frontmatter wherever it is in the record (a Family folder, a second people folder), or any page in the people folder; the name you give matches its filename, its name: or an alias, with or without a trailing parenthetical. Refuses when no page matches (it never creates one; offer to make the page first) and when more than one page matches (say which). Refuses a sealed page, a just-noted page and one in a part set to be read by a different AI. 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.",
743
+ inputSchema: {
744
+ type: "object",
745
+ properties: {
746
+ person: str("The name on their page, e.g. the file's name without .md."),
747
+ context: str("One line: what this section is. Becomes the heading and, for a person-profile page, the observation's text."),
748
+ words: str("The section's words."),
749
+ },
750
+ required: ["person", "context", "words"],
751
+ additionalProperties: false,
752
+ },
753
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
754
+ run: (evda, args, ctx) => {
755
+ const r = tools.recordPersonNote(evda, args, { caller: ctx.caller });
756
+ return withTouches(evda, ctx, { text: `Added to ${r.path}${r.observation ? `, observation ${r.observation}` : ""}.`, data: r }, { path: r.path, words: [args.context, args.words].filter(Boolean).join("\n") });
757
+ },
758
+ },
759
+ {
760
+ name: "evda_append",
761
+ title: "Add to an existing entry",
762
+ 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. With under, the section goes at the end of that heading's section instead (so a new item lands in the queue, not under \"Completed\"), its heading one level below. With title, the heading is `<title> (added YYYY-MM-DD)`. Every byte already there stays as it was. Refuses a path with no entry (start one with evda_create, on their yes), 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 row in a table goes in with evda_append_row, an item in a list with evda_append_item, a person's page through evda_record_person_note, the day's words through evda_write.",
763
+ inputSchema: {
764
+ type: "object",
765
+ properties: {
766
+ path: str("The existing entry, relative to the record, as evda_search or evda_recent gave it."),
767
+ words: str("The new section's words. Their experience in their words; never another person's motive stated as fact."),
768
+ under: str("Optional: the text of a heading in that file, without the #s. The section goes at the end of that heading's section instead of the end of the file."),
769
+ title: str("Optional, one line: what the section is. The heading becomes `<title> (added YYYY-MM-DD)`."),
770
+ },
771
+ required: ["path", "words"],
772
+ additionalProperties: false,
773
+ },
774
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
775
+ run: (evda, args, ctx) => {
776
+ const r = tools.appendEntry(evda, args, { caller: ctx.caller });
777
+ const under = r.under ? ` under "${r.under}"` : "";
778
+ return withTouches(evda, ctx, { text: r.section === "continued" ? `Added to the ${r.date} section of ${r.path}${under} (continued).` : `Added a ${r.date} section to ${r.path}${under}.`, data: r }, { path: r.path, words: [args.title, args.words].filter(Boolean).join("\n") });
779
+ },
780
+ },
781
+ {
782
+ name: "evda_append_row",
783
+ title: "Add a row to a table",
784
+ description: "Adds one row to a table in an entry that already exists, by its path. Call when the owner reports something kept as a table (a food log, a spike log, a word list): the row goes at the end of that table, its cells in the table's column order, and every other byte of the file stays as it was. Returns the path and the row written. A file with more than one table needs under, the heading whose section holds the table meant. Refuses a row whose cell count is not the table's column count, a cell with a line break, a path with no entry, a sealed or just-noted entry, a part set to be read by a different AI, and anything shaped like a password, key or token.",
785
+ inputSchema: {
786
+ type: "object",
787
+ properties: {
788
+ path: str("The existing entry, relative to the record, as evda_search or evda_recent gave it."),
789
+ cells: { type: "array", items: { type: "string" }, description: "The row's cells in the table's column order, one string each, \"\" for an empty cell. Their words where the column holds their words." },
790
+ under: str("Optional: the text of the heading whose section holds the table, without the #s. Needed when the file has more than one table."),
791
+ },
792
+ required: ["path", "cells"],
793
+ additionalProperties: false,
794
+ },
795
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
796
+ run: (evda, args, ctx) => {
797
+ const r = tools.appendRow(evda, args, { caller: ctx.caller });
798
+ return withTouches(evda, ctx, { text: `Added a row to ${r.path}: ${r.row}`, data: r }, { path: r.path, words: (args.cells ?? []).join("\n") });
799
+ },
800
+ },
801
+ {
802
+ name: "evda_append_item",
803
+ title: "Add an item to a list",
804
+ description: "Adds one item to a list in an existing entry. Call when the owner reports something kept as a list of one-line items (a board of weekends or trips, a queue, a reading list): read the list first and write the line in the format its items use, and it goes in after the last item with the list's own marker (the next number in a numbered list), or with after right after the item that names, so a list kept in order stays in order; every other byte of the file is kept. Returns the path and the item written. A file with more than one list needs under, the heading whose section holds the list meant. Refuses a line with a line break, a path with no entry, a section with no list (a new section is evda_append), a sealed or just-noted entry, a part set to be read by a different AI, and anything shaped like a password, key or token.",
805
+ inputSchema: {
806
+ type: "object",
807
+ properties: {
808
+ path: str("The existing entry, relative to the record, as evda_search or evda_recent gave it."),
809
+ line: str("The new item, one line, without its marker, in the format the list's other items use. Their words where the list holds their words."),
810
+ under: str("Optional: the text of the heading whose section holds the list, without the #s. Needed when the file has more than one list."),
811
+ after: str("Optional: a piece of the text of the item the new one goes right after, as it is written there (for a dated list, the item it follows in date order). Without it the item goes at the end."),
812
+ },
813
+ required: ["path", "line"],
814
+ additionalProperties: false,
815
+ },
816
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
817
+ run: (evda, args, ctx) => {
818
+ const r = tools.appendItem(evda, args, { caller: ctx.caller });
819
+ return withTouches(evda, ctx, { text: `Added an item to ${r.path}: ${r.item}`, data: r }, { path: r.path, words: args.line });
820
+ },
821
+ },
822
+ {
823
+ name: "evda_create",
824
+ title: "Start a new page or folder",
825
+ description: "Starts a new page, person's page or folder in the record, on the owner's yes. Call after you offered in one line to start an entry that does not exist yet (\"want me to start a page for X?\") and they said yes in this conversation; pass confirmed: true only then. kind \"page\" makes a Markdown page at path with a title and, with words, a first dated section; kind \"person\" makes their page in the people folder under their name, with the person-profile frontmatter doctor reads and, with context and words, a first section and its observation; kind \"folder\" makes one empty folder. Returns the path. Refuses a path that already exists (it never overwrites), a path outside the record, the record's own files, a folder that is not there yet for the page, a part that is sealed or set to be read by a different AI, and anything shaped like a password, key or token.",
826
+ inputSchema: {
827
+ type: "object",
828
+ properties: {
829
+ kind: { type: "string", enum: tools.CREATE_KINDS, description: "page, person, or folder." },
830
+ path: str("For a page or folder: where, relative to the record. A page ends in .md, and the folder it goes in must exist."),
831
+ person: str("For a person's page: their name, as the page should be called."),
832
+ title: str("For a page: its title. Defaults to the file name."),
833
+ context: str("For a person's page, with words: one line on what the first section is. Becomes its heading and the observation's text."),
834
+ words: str("The first section's words, in the owner's words. Optional."),
835
+ confirmed: { type: "boolean", description: "true only when the owner said yes to starting this in this conversation." },
836
+ },
837
+ required: ["kind", "confirmed"],
838
+ additionalProperties: false,
839
+ },
840
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
201
841
  run: (evda, args, ctx) => {
202
- const r = tools.write(evda, args, { session: ctx.session });
203
- return { text: `Filed to ${r.path}.`, data: r };
842
+ const r = tools.createEntry(evda, args, { caller: ctx.caller });
843
+ return { text: `Created ${r.kind === "folder" ? "the folder" : "the page"} ${r.path}${r.observation ? `, observation ${r.observation}` : ""}.`, data: r };
844
+ },
845
+ },
846
+ {
847
+ name: "evda_setup_answer",
848
+ title: "Write a setup answer",
849
+ 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.`,
850
+ inputSchema: {
851
+ type: "object",
852
+ properties: {
853
+ field: { type: "string", enum: tools.SETUP_FIELDS, description: "Which field of \"What this EVÐA is\" this answers." },
854
+ words: str("Their answer, exactly as they said it: hedges intact, one line. Never a summary."),
855
+ },
856
+ required: ["field", "words"],
857
+ additionalProperties: false,
858
+ },
859
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
860
+ run: (evda, args) => {
861
+ const r = tools.setupAnswer(evda, args);
862
+ return { text: `Wrote "${r.field}" in ${r.path}, in their words.`, data: r };
204
863
  },
205
864
  },
206
865
  {
207
866
  name: "evda_import",
208
867
  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.",
868
+ 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
869
  inputSchema: {
211
870
  type: "object",
212
871
  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 +881,54 @@ export const TOOLS = [
222
881
  {
223
882
  name: "evda_search",
224
883
  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 });
884
+ 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}`,
885
+ 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 },
886
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
887
+ run: (evda, args, ctx) => withAlong(evda, args, ctx, "evda_search", (rest) => {
888
+ const r = tools.search(evda, rest, { caller: ctx.caller });
230
889
  const lines = r.matches.map((m) => `${m.path}:${m.line}: ${m.text}`);
231
890
  const tail = r.more ? `\n${r.more} more match(es) past the limit.` : "";
232
891
  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
892
  return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: r };
234
- },
893
+ }),
235
894
  },
236
895
  {
237
896
  name: "evda_read",
238
897
  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 });
898
+ 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}`,
899
+ inputSchema: { type: "object", properties: { path: str("The file's path, relative to the record."), ...ALONG_PROPS }, required: ["path"], additionalProperties: false },
900
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
901
+ run: (evda, args, ctx) => withAlong(evda, args, ctx, "evda_read", (rest) => {
902
+ const r = tools.readFile(evda, rest, { caller: ctx.caller });
244
903
  return { text: `<file path="${r.path}">\n${r.text}\n</file>${r.truncated ? "\n(truncated for size)" : ""}` };
904
+ }),
905
+ },
906
+ // HQ-2066: the phone's editor saves through this, the Mac's save safety on the writer. It is in no
907
+ // chat set (CHAT_PROSE_TOOLS), so a chat app is never offered a tool that replaces a note.
908
+ {
909
+ name: "evda_replace",
910
+ title: "Save the owner's edit of one note",
911
+ description: "Replaces one note's whole text with the owner's edit, only when the note is unchanged since it was read. Call only to save an edit the owner made by hand, as the phone's editor does: pass the path, the whole new text, and base, the SHA-256 in hex of the text evda_read returned. Refuses, with nothing written, a note changed since (read it again and edit what is there now), a missing note, a note with a sealed section or too long to read whole, a sealed, just-noted or other-AI note, the record's own files, and a new line shaped like a password, key or token. Returns the path and the saved text's SHA-256, the base for the next save.",
912
+ inputSchema: {
913
+ type: "object",
914
+ properties: {
915
+ path: str("The note, relative to the record, as evda_read gave it."),
916
+ text: { type: "string", description: "The note's whole new text, exactly as the owner left it." },
917
+ base: str("The SHA-256, in hex, of the text evda_read returned for this note."),
918
+ },
919
+ required: ["path", "text", "base"],
920
+ additionalProperties: false,
921
+ },
922
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
923
+ run: (evda, args, ctx) => {
924
+ const r = tools.replaceNote(evda, args, { caller: ctx.caller });
925
+ return { text: `${r.changed ? "Saved" : "Nothing to save in"} ${r.path}. version ${r.version}`, data: r };
245
926
  },
246
927
  },
247
928
  {
248
929
  name: "evda_note",
249
930
  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.",
931
+ 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
932
  inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim.") }, required: ["words"], additionalProperties: false },
252
933
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
253
934
  run: (evda, args) => {
@@ -258,18 +939,18 @@ export const TOOLS = [
258
939
  {
259
940
  name: "evda_message",
260
941
  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 },
942
+ 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.",
943
+ 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
944
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
264
945
  run: (evda, args) => {
265
946
  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 };
947
+ 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
948
  },
268
949
  },
269
950
  {
270
951
  name: "evda_replies",
271
952
  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.",
953
+ 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
954
  inputSchema: { type: "object", properties: { peek: { type: "boolean", description: "true to read without marking them seen." } }, additionalProperties: false },
274
955
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
275
956
  run: (evda, args, ctx) => {
@@ -282,7 +963,7 @@ export const TOOLS = [
282
963
  {
283
964
  name: "evda_filing",
284
965
  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.",
966
+ 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
967
  inputSchema: {
287
968
  type: "object",
288
969
  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 +976,31 @@ export const TOOLS = [
295
976
  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
977
  },
297
978
  },
979
+ {
980
+ name: "evda_every_chat",
981
+ title: "Record the every-chat setting",
982
+ 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.",
983
+ inputSchema: {
984
+ type: "object",
985
+ properties: {
986
+ app: { type: "string", enum: EVERY_CHAT_APPS, description: "The app they set it in. Default: the app this conversation is in." },
987
+ state: { type: "string", enum: EVERY_CHAT_STATES, description: "set, or declined." },
988
+ confirmed: { type: "boolean", description: "true only when the owner said this in this conversation." },
989
+ },
990
+ required: ["state", "confirmed"],
991
+ additionalProperties: false,
992
+ },
993
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
994
+ run: (evda, args, ctx) => {
995
+ const r = setEveryChat(evda, args, { app: ctx.app ?? null });
996
+ const label = CHAT_APPS[r.app]?.label ?? "this app";
997
+ 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 };
998
+ },
999
+ },
298
1000
  {
299
1001
  name: "evda_release_note",
300
1002
  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.",
1003
+ 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
1004
  inputSchema: {
303
1005
  type: "object",
304
1006
  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 +1016,7 @@ export const TOOLS = [
314
1016
  {
315
1017
  name: "evda_circles",
316
1018
  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.",
1019
+ 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
1020
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
319
1021
  annotations: { readOnlyHint: true, openWorldHint: false },
320
1022
  run: (evda, args) => {
@@ -333,7 +1035,7 @@ export const TOOLS = [
333
1035
  {
334
1036
  name: "evda_propose_circle",
335
1037
  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.",
1038
+ 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
1039
  inputSchema: {
338
1040
  type: "object",
339
1041
  properties: {
@@ -355,7 +1057,7 @@ export const TOOLS = [
355
1057
  {
356
1058
  name: "evda_presets",
357
1059
  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.",
1060
+ 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
1061
  inputSchema: { type: "object", properties: { id: str("A preset id, to show that one. Omit to list them all.") }, additionalProperties: false },
360
1062
  annotations: { readOnlyHint: true, openWorldHint: false },
361
1063
  run: (evda, args) => {
@@ -368,7 +1070,7 @@ export const TOOLS = [
368
1070
  {
369
1071
  name: "evda_propose_structure",
370
1072
  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.",
1073
+ 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
1074
  inputSchema: { type: "object", properties: { id: str("The preset id, from evda_presets.") }, required: ["id"], additionalProperties: false },
373
1075
  annotations: { readOnlyHint: true, openWorldHint: false },
374
1076
  run: (evda, args) => {
@@ -396,7 +1098,7 @@ export const TOOLS = [
396
1098
  {
397
1099
  name: "evda_gather",
398
1100
  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.",
1101
+ 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
1102
  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
1103
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
402
1104
  run: (evda, args) => {
@@ -419,7 +1121,7 @@ export const TOOLS = [
419
1121
  {
420
1122
  name: "evda_gather_answer",
421
1123
  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.",
1124
+ 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
1125
  inputSchema: {
424
1126
  type: "object",
425
1127
  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 +1138,7 @@ export const TOOLS = [
436
1138
  {
437
1139
  name: "evda_gather_apply",
438
1140
  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.",
1141
+ 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
1142
  inputSchema: {
441
1143
  type: "object",
442
1144
  properties: {
@@ -454,10 +1156,34 @@ export const TOOLS = [
454
1156
  return { text: r.lines.join("\n") };
455
1157
  },
456
1158
  },
1159
+ {
1160
+ name: "evda_adopt",
1161
+ title: "Adopt a folder of notes where it is",
1162
+ description: "Makes a folder of notes the owner already keeps (an old Iris Vault, an Obsidian vault) an EVÐA in place: nothing moves. It maps the folders already there to the record's roles (an Iris Vault without asking), writes evda.json, AGENTS.md, .evda/ and the Claude Code hooks, and adds one @AGENTS.md line to the end of their CLAUDE.md; every other file stays byte for byte. Call it with dry: true first: it returns the mapping and names each folder it cannot place. Ask the owner about each of those one at a time, in your own words, and pass their answers as map. Ask whether everything they say should be filed automatically or only when they ask, and pass it as filing. Call it without dry only after they said yes to the mapping in this conversation, with confirmed: true. It runs doctor and names the reversal manifest that `evda adopt --revert` undoes byte for byte; tell them that path.",
1163
+ inputSchema: {
1164
+ type: "object",
1165
+ properties: {
1166
+ dir: str("The folder to adopt, as a path on this machine."),
1167
+ map: { type: "object", additionalProperties: { type: "string" }, description: "The owner's answers: folder (relative to dir) -> role (record/daily, record/captures, record/people, journal, collections, knowledge, work, each with an optional /facet, or none)." },
1168
+ filing: { type: "string", enum: ["everything", "asked"], description: "The owner's answer: file everything they say, or only when asked." },
1169
+ owner: str("The owner's name, as they gave it."),
1170
+ dry: { type: "boolean", description: "true to see the mapping and the open questions; writes nothing." },
1171
+ confirmed: { type: "boolean", description: "true only when the owner said yes to adopting this folder with this mapping in this conversation." },
1172
+ },
1173
+ required: ["dir"],
1174
+ additionalProperties: false,
1175
+ },
1176
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
1177
+ run: (evda, args) => {
1178
+ const r = adoptLib.adoptTool(args);
1179
+ if (r.dry) return { text: [...r.lines, "", r.questions.length ? `Open questions, ask them one at a time:\n${r.questions.map((q) => `- ${q}`).join("\n")}` : "No open questions. Nothing was written; adopt it with confirmed: true after the owner's yes."].join("\n") };
1180
+ return { text: [...r.lines, "", ...adoptLib.connectLines(r.dir, args.filing ?? null)].join("\n") };
1181
+ },
1182
+ },
457
1183
  {
458
1184
  name: "evda_skills",
459
1185
  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.`,
1186
+ 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
1187
  inputSchema: { type: "object", properties: { name: str("A skill's name, to read that one. Omit to list them.") }, additionalProperties: false },
462
1188
  annotations: { readOnlyHint: true, openWorldHint: false },
463
1189
  run: (evda, args) => {
@@ -473,7 +1199,7 @@ export const TOOLS = [
473
1199
  {
474
1200
  name: "evda_write_skill",
475
1201
  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.`,
1202
+ 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
1203
  inputSchema: {
478
1204
  type: "object",
479
1205
  properties: {
@@ -496,7 +1222,7 @@ export const TOOLS = [
496
1222
  {
497
1223
  name: "evda_create_subrecord",
498
1224
  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.",
1225
+ 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
1226
  inputSchema: {
501
1227
  type: "object",
502
1228
  properties: {
@@ -531,9 +1257,9 @@ export const TOOLS = [
531
1257
  },
532
1258
  },
533
1259
  {
534
- name: "evda_pair_device",
1260
+ name: "evda_pair",
535
1261
  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.",
1262
+ 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
1263
  inputSchema: {
538
1264
  type: "object",
539
1265
  properties: {
@@ -544,15 +1270,39 @@ export const TOOLS = [
544
1270
  },
545
1271
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
546
1272
  run: async (evda, args) => {
547
- tools.onlyKeys("evda_pair_device", args, ["name", "parts"]);
1273
+ tools.onlyKeys("evda_pair", args, ["name", "parts"]);
1274
+ // The device tools read process.env, never ctx.env: over HTTP ctx.env names the reader only.
548
1275
  const r = await pairing.pair(evda.dir, { name: args.name, parts: args.parts });
549
- return { text: pairing.pairText(r) };
1276
+ pairing.watchPairing(evda.dir, { until: r.expiresAt });
1277
+ const qr = encodeQr(r.qr, { ecl: "M" });
1278
+ return {
1279
+ text: pairing.pairToolText(r, { qrText: qrText(qr) }),
1280
+ images: [{ data: qrPng(qr).toString("base64"), mimeType: "image/png" }],
1281
+ };
1282
+ },
1283
+ },
1284
+ {
1285
+ name: "evda_finish_pairing",
1286
+ title: "Wait for a phone to join",
1287
+ 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.",
1288
+ inputSchema: {
1289
+ type: "object",
1290
+ properties: { seconds: { type: "number", description: "How long to wait for the device, 0 to 120. Default 60." } },
1291
+ additionalProperties: false,
1292
+ },
1293
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1294
+ run: async (evda, args) => {
1295
+ tools.onlyKeys("evda_finish_pairing", args, ["seconds"]);
1296
+ if (args.seconds !== undefined && !(typeof args.seconds === "number" && args.seconds >= 0 && args.seconds <= pairing.FINISH_WAIT_MAX_S)) {
1297
+ throw tools.refuse(`evda_finish_pairing "seconds" is a number from 0 to ${pairing.FINISH_WAIT_MAX_S}.`, "BAD_SECONDS");
1298
+ }
1299
+ return { text: (await pairing.finishWaiting(evda.dir, { seconds: args.seconds ?? 60 })).join("\n") };
550
1300
  },
551
1301
  },
552
1302
  {
553
1303
  name: "evda_sync_devices",
554
1304
  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.",
1305
+ 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
1306
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
557
1307
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
558
1308
  run: async (evda, args) => {
@@ -563,18 +1313,54 @@ export const TOOLS = [
563
1313
  {
564
1314
  name: "evda_devices",
565
1315
  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>.",
1316
+ 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
1317
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
568
1318
  annotations: { readOnlyHint: true, openWorldHint: false },
569
1319
  run: async (evda, args) => {
570
1320
  tools.onlyKeys("evda_devices", args, []);
571
- return { text: (await pairing.devices(evda.dir)).join("\n") };
1321
+ return { text: (await pairing.deviceContacts(evda.dir)).join("\n") };
1322
+ },
1323
+ },
1324
+ {
1325
+ name: "evda_sync_status",
1326
+ title: "Sync status",
1327
+ 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.",
1328
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1329
+ annotations: { readOnlyHint: true, openWorldHint: false },
1330
+ run: async (evda, args) => {
1331
+ tools.onlyKeys("evda_sync_status", args, []);
1332
+ return { text: (await pairing.syncStatus(evda.dir)).join("\n") };
1333
+ },
1334
+ },
1335
+ {
1336
+ name: "evda_remove_device",
1337
+ title: "Remove a device",
1338
+ 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.",
1339
+ inputSchema: {
1340
+ type: "object",
1341
+ properties: {
1342
+ name: { type: "string", description: "The device's name, as evda_devices lists it." },
1343
+ confirm: { type: "boolean", description: "true only once the owner said yes to removing this device." },
1344
+ },
1345
+ required: ["name"],
1346
+ additionalProperties: false,
1347
+ },
1348
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1349
+ run: async (evda, args) => {
1350
+ tools.onlyKeys("evda_remove_device", args, ["name", "confirm"]);
1351
+ 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");
1352
+ if (args.confirm !== undefined && typeof args.confirm !== "boolean") throw tools.refuse("evda_remove_device \"confirm\" is true or false.", "BAD_CONFIRM");
1353
+ const what = await pairing.describeRemoval(evda.dir, args.name);
1354
+ if (args.confirm !== true) {
1355
+ return { text: [...what, "", "Nothing was changed. Put this to the owner, and call again with confirm: true only if they say yes."].join("\n") };
1356
+ }
1357
+ return { text: (await pairing.remove(evda.dir, args.name)).join("\n") };
572
1358
  },
573
1359
  },
574
1360
  {
575
1361
  name: "evda_check_update",
576
1362
  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.",
1363
+ 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
1364
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
579
1365
  annotations: { readOnlyHint: true, openWorldHint: true },
580
1366
  run: async (evda) => {
@@ -586,14 +1372,14 @@ export const TOOLS = [
586
1372
  ];
587
1373
 
588
1374
  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}`; } },
1375
+ { 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
1376
  {
591
1377
  uri: "evda://today",
592
1378
  name: "today",
593
1379
  title: "Today's daily file",
594
1380
  mimeType: "text/markdown",
595
1381
  read: (evda, ctx) => {
596
- const r = tools.recent(evda, { days: 1 }, { caller: ctx.caller });
1382
+ const r = tools.recent(evda, { days: 1 }, { caller: ctx.caller, full: true });
597
1383
  const daily = r.files.find((f) => /(^|\/)\d{4}-\d{2}-\d{2}\.md$/.test(f.path) && f.date === r.to);
598
1384
  if (daily) return daily.text;
599
1385
  return r.heldBack ? `# ${r.to}\n\n${r.heldBack}\n` : `# ${r.to}\n\nNothing written today yet.\n`;
@@ -606,14 +1392,301 @@ export const RESOURCES = [
606
1392
  // Once the session has read the record or been handed the owner's content and filed nothing, a tool
607
1393
  // result carries one line saying so, at most once every NUDGE_EVERY calls, and never after a write.
608
1394
  // 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.";
1395
+ 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
1396
  export const NUDGE_EVERY = 3;
611
1397
  const READS = new Set(["evda_recent", "evda_search", "evda_read"]);
612
1398
  const RECEIVED = new Set(["evda_import", "evda_gather_answer"]);
613
1399
  const WRITES = new Set(["evda_write", "evda_note"]);
614
1400
 
615
1401
  export function newSession() {
616
- return { calls: 0, reads: 0, received: 0, writes: 0, lastNudge: -Infinity };
1402
+ return { calls: 0, reads: 0, received: 0, writes: 0, lastNudge: -Infinity, layer: false, preambled: false, lastCall: null, everyChatAsked: false, lastFiled: null, agentWork: [], agentLogged: false, agentLogNudged: false, sections: {}, named: [], namedTickets: [], wrotePaths: [], entriesAsked: false };
1403
+ }
1404
+
1405
+ // The agent-log nudge (HQ-2329). In Claude Code a Stop hook makes the agent log what it did; a chat
1406
+ // app has none, and measured 2026-10-07 claude.ai made 2 evda_write_agent_log calls in 13 sessions
1407
+ // against 14 from Claude Code: the 10/7 test chat wrote a person page, the Deep Work Queue and the
1408
+ // journal and logged none of it. So once a chat has written something beyond the owner's words, the
1409
+ // result of that write says what to log, once per conversation, and never after the chat has logged.
1410
+ // A write tool counts unless it is named here: the owner's own words (filed by the layer, a read, a
1411
+ // search, evda_write, evda_note), setup answers, device pairing and the log itself. A write tool
1412
+ // added later counts without anyone remembering to add it.
1413
+ // EVDA_RECORD_FALSIFY=no-agent-log-nudge turns it off, for the control test.
1414
+ const NOT_AGENT_WORK = new Set([
1415
+ "evda_operating_layer", "evda_write", "evda_note", "evda_write_agent_log", "evda_search", "evda_read", "evda_replies",
1416
+ "evda_acknowledge", "evda_import", "evda_filing", "evda_every_chat", "evda_setup_answer", "evda_release_note",
1417
+ "evda_gather", "evda_gather_answer", "evda_pair", "evda_finish_pairing", "evda_sync_devices", "evda_remove_device", "evda_check_update",
1418
+ ]);
1419
+ export function isAgentWork(tool) {
1420
+ return Boolean(tool) && tool.annotations?.readOnlyHint === false && !NOT_AGENT_WORK.has(tool.name);
1421
+ }
1422
+ export function agentLogNudge(paths) {
1423
+ const what = paths.length ? `wrote to ${paths.join(", ")}` : "changed the record";
1424
+ return `You ${what} in this conversation, beyond the owner's words, and your own day log has nothing from this conversation yet. Before you answer, say what you did (built, filed, fixed, found) in a sentence or two with evda_write_agent_log, with one line of context. Only once per conversation is this said.`;
1425
+ }
1426
+
1427
+ // The named-entries ask (HQ-2799). A write's answer names the existing entries its words bear on
1428
+ // (withTouches); nothing checked that any was acted on, and on 2026-10-21 a named project page was
1429
+ // never opened. So the server keeps, per chat, what it named and what was written, and the first
1430
+ // result after one named entries, while none of them has been written to, asks once, naming them and
1431
+ // saying that adding nothing is fine: the agent judges. Quiet after a write to any of them or after
1432
+ // asking once. Never for an owner who files only when they ask (HQ-1385). Open tickets the answer
1433
+ // named (HQ-2798) are listed in the ask too; the board is another server's, so this one cannot see a
1434
+ // ticket being updated, and only a write to a named entry answers the ask here.
1435
+ // EVDA_RECORD_FALSIFY=no-entries-ask turns it off, for the control test.
1436
+ export function countEntries(session, r, { filing = null, env = process.env } = {}) {
1437
+ if (!r || r.isError) return null;
1438
+ session.named ??= [];
1439
+ session.wrotePaths ??= [];
1440
+ const path = typeof r.data?.path === "string" ? r.data.path : null;
1441
+ if (path && !session.wrotePaths.includes(path)) session.wrotePaths.push(path);
1442
+ const answered = () => session.named.some((n) => session.wrotePaths.includes(n.path));
1443
+ let ask = null;
1444
+ session.namedTickets ??= [];
1445
+ if ((session.named.length || session.namedTickets.length) && !answered() && !session.entriesAsked && filing !== "asked" && !(env.EVDA_RECORD_FALSIFY ?? "").split(",").map((m) => m.trim()).includes("no-entries-ask")) {
1446
+ session.entriesAsked = true;
1447
+ ask = entriesAsk(session.named.map((n) => n.path), undefined, session.namedTickets);
1448
+ }
1449
+ for (const n of r.data?.touches?.related ?? []) if (!session.named.some((x) => x.path === n.path)) session.named.push({ path: n.path, name: n.name });
1450
+ for (const k of r.data?.touches?.tickets ?? []) if (k?.id && !session.namedTickets.includes(k.id)) session.namedTickets.push(k.id);
1451
+ return ask;
1452
+ }
1453
+
1454
+ // Count one call toward the agent-log nudge and say whether its result carries it. `hasLog` is
1455
+ // whether the record has the agent log at all; with none, there is nothing to nudge toward.
1456
+ export function countAgentWork(session, tool, r, { hasLog, env = process.env } = {}) {
1457
+ if (tool?.name === "evda_write_agent_log" && !r?.isError) { session.agentLogged = true; return null; }
1458
+ if (!isAgentWork(tool) || r?.isError) return null;
1459
+ const path = typeof r?.data?.path === "string" ? r.data.path : null;
1460
+ if (path && !session.agentWork.includes(path)) session.agentWork.push(path);
1461
+ if (!path && !session.agentWork.length) session.agentWork.push(null);
1462
+ if (!hasLog || session.agentLogged || session.agentLogNudged) return null;
1463
+ if ((env.EVDA_RECORD_FALSIFY ?? "").split(",").map((m) => m.trim()).includes("no-agent-log-nudge")) return null;
1464
+ session.agentLogNudged = true;
1465
+ return agentLogNudge(session.agentWork.filter(Boolean));
1466
+ }
1467
+
1468
+ // The recent days as a chat reads them (HQ-2324): per file, each section's heading, its context line
1469
+ // in parentheses and the opening of the owner's words after ">". Measured 2026-10-07 on his record:
1470
+ // 3 days were 13 files and 3,181 characters as headings, and most headings were times, so a chat
1471
+ // knew when he wrote and not what. It fits `room` characters: every file by heading first, then
1472
+ // sections gain their context and opening newest first (newest file, and its last section first,
1473
+ // since a day file grows downward) while they fit, so an over-full window loses detail on its
1474
+ // oldest sections before it loses a file.
1475
+ const sectionLine = (s, rich) => [s.heading !== null ? `# ${s.heading}` : null, rich && s.context ? `(${s.context})` : null, rich && s.opening ? `> ${s.opening}` : null].filter(Boolean).join("\n");
1476
+ const fileOpen = (f) => `<file path="${f.path}" date="${f.date}"${ackAttr(f.acknowledged)}>`;
1477
+ function renderFiles(files, rich) {
1478
+ return files.map((f, i) => {
1479
+ const lines = f.sections.map((s, j) => sectionLine(s, rich[i].has(j))).filter(Boolean);
1480
+ return `${fileOpen(f)}${lines.length ? `\n${lines.join("\n")}` : ""}\n</file>`;
1481
+ }).join("\n\n");
1482
+ }
1483
+
1484
+ export function renderRecent(r, room = Infinity) {
1485
+ const files = r.files.map((f) => ({ ...f, sections: f.sections ?? f.headings.map((h) => ({ heading: h, context: null, opening: null })) }));
1486
+ const head = (shown, thin, cut) => `${shown.length} file(s) dated ${r.from} to ${r.to}, newest first: each section's heading, its context line in parentheses and the opening of the owner's words after ">" (read one whole with evda_read when the question needs it)${thin ? `; the oldest ${thin} section(s) by heading only, for size` : ""}${r.omitted + cut ? `, ${r.omitted + cut} more file(s) omitted for size (read them with evda_search)` : ""}.${r.acknowledged ? ` ${acknowledgedNote(r.acknowledged)}` : ""}`;
1487
+ const tail = r.heldBack ? `\n\n${r.heldBack}` : "";
1488
+ if (!files.length) return { text: `Nothing${r.refused.length ? " you may read" : ""} in the record is dated ${r.from} to ${r.to}.${tail}`, data: { from: r.from, to: r.to, paths: [], omitted: r.omitted, acknowledged: r.acknowledged } };
1489
+ const reserve = head(files, 99999, 99999).length + tail.length + 4;
1490
+ // Every file by heading, dropping the oldest while even that is over the room.
1491
+ let shown = files.slice();
1492
+ const none = () => shown.map(() => new Set());
1493
+ while (shown.length > 1 && reserve + renderFiles(shown, none()).length > room) shown.pop();
1494
+ const rich = none();
1495
+ let size = reserve + renderFiles(shown, rich).length;
1496
+ let thin = 0;
1497
+ for (let i = 0; i < shown.length; i++) {
1498
+ for (let j = shown[i].sections.length - 1; j >= 0; j--) {
1499
+ const s = shown[i].sections[j];
1500
+ const grow = sectionLine(s, true).length - sectionLine(s, false).length + (s.heading === null && !sectionLine(s, false) ? 1 : 0);
1501
+ if (grow === 0) continue;
1502
+ if (size + grow <= room) { rich[i].add(j); size += grow; } else thin += 1;
1503
+ }
1504
+ }
1505
+ const cut = files.length - shown.length;
1506
+ const text = `${head(shown, thin, cut)}\n\n${renderFiles(shown, rich)}${tail}`;
1507
+ return { text, data: { from: r.from, to: r.to, paths: shown.map((f) => f.path), omitted: r.omitted + cut, acknowledged: r.acknowledged } };
1508
+ }
1509
+
1510
+ // The people whose pages grew this week (HQ-2324), in at most `room` characters, newest first.
1511
+ export function renderPeople(p, room = Infinity) {
1512
+ if (!p.people.length) return "";
1513
+ const block = (x) => `<person name="${x.name.replace(/"/g, "&quot;")}" path="${x.path}">\n${x.sections.map((s) => sectionLine(s, true)).join("\n")}${x.more ? `\n(${x.more} more dated section(s) this week: read the page with evda_read)` : ""}\n</person>`;
1514
+ const top = `## People whose pages grew this week\n\nDated sections added ${p.from} to ${p.to}, newest first. Read a page whole with evda_read when the conversation turns to them.`;
1515
+ const out = [];
1516
+ let size = top.length;
1517
+ for (const x of p.people) {
1518
+ const b = block(x);
1519
+ if (size + b.length + 2 > room) break;
1520
+ out.push(b);
1521
+ size += b.length + 2;
1522
+ }
1523
+ return out.length ? `${top}\n\n${out.join("\n\n")}` : "";
1524
+ }
1525
+
1526
+ // The people window: a week, so a chat knows who his last week was about (HQ-2324, 10/7: the 10/2
1527
+ // question about chemistry was five days back, outside the three-day recent window).
1528
+ export const PEOPLE_DAYS = 7;
1529
+ // What handle() adds after a tool's own text on the same result: the every-chat walkthrough (about
1530
+ // 1,400 characters, measured 2026-10-07 for ChatGPT), the nudge, the repeat notice.
1531
+ const AFTER_RUN_RESERVE = 3_000;
1532
+
1533
+ // The one-call start (HQ-2223, slice 2). Measured 2026-10-05: a fresh claude.ai chat took 5 tool
1534
+ // calls before it replied, each its own Allow click. So the layer call takes the owner's first
1535
+ // message and files it the way evda_write does (same day file, same section for the chat, same
1536
+ // refusals), and returns what a chat reads next (unseen replies, the recent days' headings), so the
1537
+ // chat can answer after one call. An owner who turned automatic filing off, or has not answered
1538
+ // yet, is filed for only when they ask: nothing is written and the result says so.
1539
+ // `room` is what the client's result has left after the layer part this rides on (HQ-2324): the
1540
+ // replies come whole, then the people block and the recent days share what is left.
1541
+ export function openChat(evda, args, ctx, { room = LAYER_PART_MAX } = {}) {
1542
+ const f = fileAlong(evda, { words: args.first_message, context: args.context }, ctx, "evda_operating_layer");
1543
+ const head = f.filed
1544
+ ? `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.`
1545
+ : f.head;
1546
+ const run = (name) => TOOLS.find((t) => t.name === name).run(evda, {}, ctx).text;
1547
+ const replies = `## Their replies you have not seen\n\n${run("evda_replies")}`;
1548
+ const DAYS = "## The last few days\n\n";
1549
+ let left = room - head.length - replies.length - DAYS.length - 8;
1550
+ const people = renderPeople(tools.recentPeople(evda, { days: PEOPLE_DAYS, caller: ctx.caller, now: ctx.now }), Math.floor(left / 3));
1551
+ left -= people.length + 2;
1552
+ const recent = renderRecent(tools.recent(evda, {}, { caller: ctx.caller, now: ctx.now }), left).text;
1553
+ const parts = [head, replies, `${DAYS}${recent}`, ...(people ? [people] : [])];
1554
+ return { text: parts.join("\n\n"), filed: f.filed ? f.path : null };
1555
+ }
1556
+
1557
+ // Filing that rides along (HQ-2227): the owner's message passed on a call that is not evda_write
1558
+ // (the layer's first_message, a read's or a search's owner_message) is filed the way evda_write files
1559
+ // it: the same day file, the same section for the chat, the same refusals. It is the automatic path,
1560
+ // so it files only when the owner said everything; "asked" or no answer yet files nothing and says
1561
+ // so. A secret is not filed and the call it rode on still answers. claude.ai measured 2026-10-05:
1562
+ // the first answer took about 7 calls, one of them a write per turn, so a turn that reads can file
1563
+ // in the same call.
1564
+ //
1565
+ // Every filing in a chat leaves one mark, the words last filed (session.lastFiled), and the dedupe is
1566
+ // one rule over it: a model that passes the same message on the read, the search and then evda_write
1567
+ // in one turn files it once. A mark left by a ride-along is matched by any later filing of the same
1568
+ // words; evda_write spends it, so after that the same words again are the owner saying it again. A
1569
+ // mark left by evda_write is matched only by a ride-along, so two evda_writes of "ok" in two turns
1570
+ // still file twice, as before.
1571
+ export function fileAlong(evda, { words, context }, ctx, toolName) {
1572
+ const filing = filingOf(evda.manifest);
1573
+ if (filing !== "everything") {
1574
+ return {
1575
+ filed: false,
1576
+ head: filing === "asked"
1577
+ ? "Not filed: they turned automatic filing off, so their message is filed only if they ask (then with evda_write)."
1578
+ : "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.",
1579
+ };
1580
+ }
1581
+ const dup = sameAsFiled(ctx.session, words, { spend: false });
1582
+ if (dup) return { filed: true, deduped: true, path: dup.path, head: `Already filed at ${dup.path}; nothing was written again.` };
1583
+ try {
1584
+ const w = { words };
1585
+ if (context !== undefined) w.context = context;
1586
+ const r = tools.write(evda, w, { session: ctx.session, toolName });
1587
+ if (ctx.session) {
1588
+ ctx.session.writes += 1;
1589
+ ctx.session.lastFiled = { words: words.trim(), path: r.path, by: "along" };
1590
+ }
1591
+ if (r.queued) return { filed: true, queued: true, path: r.path, head: queuedText(r) };
1592
+ return { filed: true, path: r.path, head: `Filed their message to ${r.path}.` };
1593
+ } catch (e) {
1594
+ if (!(e instanceof EvdaError) || e.code !== "SECRET") throw e;
1595
+ return { filed: false, head: `Not filed: ${e.message}` };
1596
+ }
1597
+ }
1598
+
1599
+ // What a filing bears on (HQ-2631): the existing entries the filed words name and the shared circles
1600
+ // they and the written path reach, and the open board tickets they reference when the work role is a
1601
+ // board (HQ-2798), appended to a write tool's answer as information. Empty adds nothing.
1602
+ export function withTouches(evda, ctx, out, { path, words }) {
1603
+ const t = touches(evda, { paths: [path], words, caller: ctx.caller ?? callerFrom() });
1604
+ const block = touchesText(t);
1605
+ return block ? { text: `${out.text}\n\n${block}`, data: { ...out.data, touches: { related: t.related, circles: t.circles, tickets: t.tickets ?? [] } } } : out;
1606
+ }
1607
+
1608
+ // What an evda_write held by EVDA_DAY_WRITE_GATE says (HQ-2596): queued, where, and why, so the
1609
+ // agent tells the owner the truth and does not call it again (a second call queues a second copy).
1610
+ function queuedText(r) {
1611
+ return `Queued, not filed yet: this machine is holding writes to the day (${r.held}). Their words wait at ${r.path} and are filed into the day, in order, once its copy has synced. Tell them the words are queued, not filed, and do not call evda_write again for them.`;
1612
+ }
1613
+
1614
+ // Whether these words are the ones last filed in this chat (fileAlong's rule). evda_write passes
1615
+ // spend: true and matches only a ride-along's mark, and spends it either way.
1616
+ export function sameAsFiled(session, words, { spend = true } = {}) {
1617
+ const mark = session?.lastFiled;
1618
+ if (!mark) return null;
1619
+ const same = typeof words === "string" && words.trim() === mark.words;
1620
+ if (!spend) return same ? mark : null;
1621
+ if (mark.by !== "along") return null;
1622
+ session.lastFiled = null;
1623
+ return same ? mark : null;
1624
+ }
1625
+
1626
+ // What evda_write leaves for the ride-alongs after it to match.
1627
+ function markWrite(session, words, path) {
1628
+ if (session && typeof words === "string") session.lastFiled = { words: words.trim(), path, by: "write" };
1629
+ }
1630
+
1631
+ // One chat, as the server can see it (HQ-2205). An Mcp-Session-Id is not one conversation: on
1632
+ // Hestia, 2026-10-05, claude.ai sent one session id for 7.5 hours, into conversations other than the
1633
+ // one that opened it, while a chat's own turns were at most 15 minutes apart. So a gap longer than
1634
+ // this between tool calls on a session starts a new chat: the layer counts as not served and
1635
+ // nothing as filed. A long pause inside one chat costs the short preamble again; the other error,
1636
+ // a new chat taken for an old one, would leave it with no layer and no nudge.
1637
+ export const CHAT_IDLE_MS = 20 * 60 * 1000;
1638
+
1639
+ // HQ-2330: claude.ai also sends one session id into chats only minutes apart (2026-10-07, Hestia:
1640
+ // session 57ff531a served the 20:24 chat, the 20:30 chat and the 20:38 test chat), and no header or
1641
+ // parameter it sends names the conversation. What does mark one is the call every chat starts with:
1642
+ // evda_operating_layer, the layer the server's instructions and the tool's own description say to
1643
+ // read at the start of a new chat. So a layer call for its start (part 1, no section: a long layer's
1644
+ // later parts and a section read are the same chat reading on) after anything else on the session
1645
+ // starts a new chat too: nothing filed, nothing logged, and the next write opens its own section. A chat that
1646
+ // reloads the layer mid-way costs one new section, which carries its context line; the other error,
1647
+ // two chats in one section, files one chat's words under the other's heading.
1648
+ const opensLayer = (args) => (args?.part === undefined || args.part === 1) && args?.section === undefined;
1649
+ function enterChat(session, now, name, args, env) {
1650
+ const idle = session.lastCall !== null && now - session.lastCall > CHAT_IDLE_MS;
1651
+ const restart = name === "evda_operating_layer" && opensLayer(args) && session.calls > 0 && !chatFalsified("no-layer-boundary", env);
1652
+ if (idle || restart) Object.assign(session, newSession());
1653
+ session.lastCall = now;
1654
+ }
1655
+
1656
+ // Layer 2 of HQ-2205. A model that never loaded evda_operating_layer answers as a generic assistant
1657
+ // and does not file, and no wording reaches it reliably, so the server says it in the result of the
1658
+ // first tool it does call: the short chat section, never the whole layer. Later results in the same
1659
+ // chat get one line until the layer is loaded. Returns "preamble", "reminder" or null.
1660
+ // EVDA_CHAT_FALSIFY=no-layer-preamble turns it off, for the control test.
1661
+ 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.";
1662
+ export function layerNotice(session, name, { env = process.env, now = Date.now(), args = {} } = {}) {
1663
+ enterChat(session, now, name, args, env);
1664
+ if (name === "evda_operating_layer") { session.layer = true; return null; }
1665
+ if (session.layer || chatFalsified("no-layer-preamble", env)) return null;
1666
+ if (session.preambled) return "reminder";
1667
+ session.preambled = true;
1668
+ return "preamble";
1669
+ }
1670
+
1671
+ export function layerPreamble(evda) {
1672
+ let agentsText = "";
1673
+ try { agentsText = tools.operatingLayer(evda).text; } catch { /* a record with no AGENTS.md still gets the section */ }
1674
+ // The section's first line says the layer follows it, which is true inside the layer and not here.
1675
+ const section = chatSection(evda, agentsText).replace("the full operating layer follows and holds for the whole session.", "evda_operating_layer holds the rest.");
1676
+ 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}`;
1677
+ }
1678
+
1679
+ // Layer 4 of HQ-2205: whether this result carries the every-chat walkthrough. Once per chat, in the
1680
+ // first result, for a client that named itself on initialize as a chat app, while evda.json has no
1681
+ // answer from the owner for that app. Not while the filing question is open: two setup questions at
1682
+ // once is a quiz, and the line depends on that answer, so evda_filing's result carries it instead.
1683
+ // EVDA_CHAT_FALSIFY=no-every-chat turns it off, for the control test.
1684
+ export function everyChatNotice(session, app, manifest, name, env = process.env) {
1685
+ if (!app || session.everyChatAsked || name === "evda_every_chat" || chatFalsified("no-every-chat", env)) return null;
1686
+ const filing = filingOf(manifest);
1687
+ if (filing === null || !needsEveryChat(manifest, app, filing)) return null;
1688
+ session.everyChatAsked = true;
1689
+ return everyChatWalkthrough(app, filing);
617
1690
  }
618
1691
 
619
1692
  // Count one tool call and say whether its result carries the nudge.
@@ -633,6 +1706,31 @@ export function countCall(session, name, { env = process.env, filing = null } =
633
1706
  return true;
634
1707
  }
635
1708
 
1709
+ // The new-tools notice (HQ-2205, lib/connector-tools.mjs): once per connection, on its first tool
1710
+ // call, a chat app's offered names are compared with every name it was offered before on this
1711
+ // record. It rides in the tail, so a refused call carries it too: the refusal may be the very
1712
+ // permission it is about. A coding agent (offered null) sets its own permissions and is skipped.
1713
+ // It writes the record's .evda/connector-tools.json, so only a server keeps it: serve() and
1714
+ // lib/mcp-http.mjs set ctx.keepsToolList, and a caller of handle() that did not ask writes nothing.
1715
+ // EVDA_CHAT_FALSIFY=no-new-tools turns it off, for the control test.
1716
+ export function toolListNotice(ctx, evda, env = ctx.env ?? process.env) {
1717
+ if (ctx.toolsChecked) return null;
1718
+ ctx.toolsChecked = true;
1719
+ if (!ctx.keepsToolList || !ctx.app || chatFalsified("no-new-tools", env)) return null;
1720
+ const names = offered(ctx);
1721
+ if (!names) return null;
1722
+ const added = newToolsFor(evda, ctx.app, names, { now: new Date(ctx.clock?.() ?? Date.now()) });
1723
+ if (!added.length) return null;
1724
+ return newToolsNotice(ctx.app, added, Object.fromEntries(TOOLS.map((t) => [t.name, t.title])));
1725
+ }
1726
+
1727
+ // The tools this client is offered: every tool for a coding agent, the chat set for any other.
1728
+ // A record that cannot be opened offers the chat set's fixed part.
1729
+ function offered(ctx) {
1730
+ if (clientProfile(ctx.client?.name).surface === "terminal") return null;
1731
+ try { return chatToolNames(ctx.evda(), { app: ctx.app ?? null }); } catch { return new Set(CHAT_PROSE_TOOLS); }
1732
+ }
1733
+
636
1734
  // The owner's filing answer as the record says it now, or null when it cannot be read.
637
1735
  function filingNow(ctx) {
638
1736
  try { return filingOf(ctx.evda().manifest); } catch { return null; }
@@ -656,17 +1754,24 @@ export async function handle(msg, ctx) {
656
1754
  const asked = p.protocolVersion;
657
1755
  // Who is calling, for every read that follows: the client's own name, or $EVDA_READER.
658
1756
  ctx.caller = callerFrom({ clientName: p.clientInfo?.name ?? null, env: ctx.env ?? process.env });
1757
+ // Which app this is, for the operating layer's surface (HQ-2207). Name and version only.
1758
+ ctx.client = clientInfoOf(p);
1759
+ // Which chat app, for the every-chat walkthrough only (HQ-2205). It decides which words are
1760
+ // shown, never what may be read, so a client that misnames itself costs nothing but wording.
1761
+ ctx.app = chatAppOf(p.clientInfo?.name ?? null);
1762
+ // HQ-2631: index the record's names after this answer, so the first filing does not wait.
1763
+ try { const e = typeof ctx.evda === "function" ? ctx.evda() : null; if (e?.dir) warmTouches(e); } catch { /* the first filing builds it */ }
659
1764
  return ok({
660
1765
  protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
661
1766
  capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false } },
662
1767
  serverInfo: { name: SERVER_NAME, title: "EVDA", version: ctx.version },
663
- instructions: instructions(filingNow(ctx)),
1768
+ instructions: instructions(filingNow(ctx), clientProfile(ctx.client?.name).surface),
664
1769
  });
665
1770
  }
666
1771
  case "ping":
667
1772
  return ok({});
668
1773
  case "tools/list":
669
- return ok({ tools: toolList(filingNow(ctx), ctx.env ?? process.env) });
1774
+ return ok({ tools: toolList(filingNow(ctx), ctx.env ?? process.env, offered(ctx)) });
670
1775
  case "tools/call": {
671
1776
  const tool = TOOLS.find((t) => t.name === p.name);
672
1777
  if (!tool) return error(msg.id, RPC.PARAMS, `unknown tool ${JSON.stringify(p.name)}`);
@@ -674,22 +1779,33 @@ export async function handle(msg, ctx) {
674
1779
  if (typeof args !== "object" || Array.isArray(args)) return error(msg.id, RPC.PARAMS, "arguments must be an object");
675
1780
  // EVDA_MCP_FALSIFY=log-args: a debug line with the arguments, the leak test/privacy.test.mjs exists to catch.
676
1781
  if (FALSIFY("log-args", process.env)) ctx.log?.(`debug ${p.name} ${JSON.stringify(args)}`);
1782
+ let tail = "";
677
1783
  try {
678
1784
  if (!ctx.caller) ctx.caller = callerFrom({ env: ctx.env ?? process.env });
679
1785
  ctx.session ??= newSession();
680
1786
  const evda = ctx.evda();
1787
+ const notice = layerNotice(ctx.session, tool.name, { env: process.env, now: ctx.clock?.() ?? Date.now(), args });
1788
+ tail = notice === "preamble" ? `\n\n---\n\n${layerPreamble(evda)}` : notice === "reminder" ? `\n\n${LAYER_REMINDER}` : "";
1789
+ const added = toolListNotice(ctx, evda);
1790
+ if (added) tail += `\n\n---\n\n${added}`;
681
1791
  const r = await tool.run(evda, args, ctx);
682
1792
  const nudge = countCall(ctx.session, tool.name, { filing: filingOf(evda.manifest) }) ? `\n\n${NUDGE}` : "";
1793
+ const logNudge = countAgentWork(ctx.session, tool, r, { hasLog: tools.hasAgentLog(evda) });
1794
+ const entries = countEntries(ctx.session, r, { filing: filingOf(evda.manifest) });
1795
+ // evda_filing just changed the answer the walkthrough's line depends on, so it reads it again.
1796
+ const setup = r.isError ? null : everyChatNotice(ctx.session, ctx.app, tool.name === "evda_filing" ? ctx.evda().manifest : evda.manifest, tool.name);
683
1797
  // A process run twice is pointed at once (HQ-1377), so an agent in an app with no memory of
684
1798
  // its own still writes it down as a skill. Only a call that did its work counts as a step.
685
1799
  const repeat = r.isError ? null : noticeRepeat(ctx.session, tool.name);
686
1800
  // Text only. A client that finds structuredContent may show the model that instead of the text
687
1801
  // (Claude Code did, 2026-09-17: evda_operating_layer reached the model as {"path":"AGENTS.md"}),
688
1802
  // 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) });
1803
+ // An image (evda_pair's QR code, HQ-2040) follows the text; everything the model needs is still in the text.
1804
+ const images = (r.images ?? []).map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType }));
1805
+ return ok({ content: [{ type: "text", text: `${r.text}${tail}${setup ? `\n\n---\n\n${setup}` : ""}${nudge}${logNudge ? `\n\n${logNudge}` : ""}${entries ? `\n\n${entries}` : ""}${repeat ? `\n\n${repeat}` : ""}` }, ...images], isError: Boolean(r.isError) });
690
1806
  } catch (e) {
691
1807
  // 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 });
1808
+ if (e instanceof EvdaError) return ok({ content: [{ type: "text", text: `${e.message}${tail}` }], isError: true });
693
1809
  throw e;
694
1810
  }
695
1811
  }
@@ -715,6 +1831,12 @@ export async function handle(msg, ctx) {
715
1831
  }
716
1832
  }
717
1833
 
1834
+ // initialize's clientInfo, reduced to a short name and version: the only part of it kept or logged.
1835
+ export function clientInfoOf(params) {
1836
+ const clip = (v) => (typeof v === "string" ? v.replace(/[^\x20-\x7e]/g, "").slice(0, 64) : null);
1837
+ return { name: clip(params?.clientInfo?.name), version: clip(params?.clientInfo?.version) };
1838
+ }
1839
+
718
1840
  function error(id, code, message) {
719
1841
  return { jsonrpc: "2.0", id, error: { code, message } };
720
1842
  }
@@ -722,7 +1844,7 @@ function error(id, code, message) {
722
1844
  // Serve on stdio until stdin closes. `dir` is the located record; it is reopened for every call so
723
1845
  // a record edited by hand between calls is read as it is now.
724
1846
  export function serve({ dir, version, input = process.stdin, output = process.stdout, log = (s) => process.stderr.write(`[evda mcp] ${s}\n`) }) {
725
- const ctx = { version, log, evda: () => openEvda(dir), session: newSession() };
1847
+ const ctx = { version, log, evda: () => openEvda(dir), session: newSession(), keepsToolList: true };
726
1848
  openEvda(dir); // refuse to start on something that is not a record
727
1849
  log(`serving ${dir} (tool schema ${SCHEMA_VERSION})`);
728
1850
  const rl = createInterface({ input, crlfDelay: Infinity });