create-evda 0.1.9 → 0.1.11

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 (51) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +12 -5
  3. package/bin/evda.mjs +129 -6
  4. package/create-evda.mjs +14 -11
  5. package/lib/adopt.mjs +516 -0
  6. package/lib/agents.mjs +1 -1
  7. package/lib/attested-host.mjs +287 -0
  8. package/lib/attested-wrap.mjs +147 -0
  9. package/lib/checkout-freshness.mjs +88 -0
  10. package/lib/circles.mjs +4 -2
  11. package/lib/clients.mjs +11 -0
  12. package/lib/connect.mjs +1 -1
  13. package/lib/connecting-apps.mjs +56 -0
  14. package/lib/connector-tools.mjs +85 -0
  15. package/lib/corrections.mjs +34 -0
  16. package/lib/credentials-migrate.mjs +120 -0
  17. package/lib/day-file.mjs +121 -12
  18. package/lib/day-writer.mjs +39 -5
  19. package/lib/doctor.mjs +60 -5
  20. package/lib/every-chat.mjs +7 -11
  21. package/lib/folders.mjs +2 -1
  22. package/lib/gather-apply.mjs +133 -24
  23. package/lib/gather.mjs +120 -19
  24. package/lib/html-notes.mjs +124 -0
  25. package/lib/keep-hook.mjs +84 -12
  26. package/lib/keychain.mjs +38 -1
  27. package/lib/manifest.mjs +3 -1
  28. package/lib/mcp-http.mjs +19 -1
  29. package/lib/mcp.mjs +358 -56
  30. package/lib/measurement-log.mjs +3 -0
  31. package/lib/migrate-people.mjs +54 -20
  32. package/lib/person-folders.mjs +90 -0
  33. package/lib/plugin-hook.mjs +109 -0
  34. package/lib/plugin.mjs +208 -0
  35. package/lib/presets.mjs +28 -6
  36. package/lib/prompt-hook.mjs +115 -4
  37. package/lib/publish.mjs +55 -10
  38. package/lib/readme.mjs +145 -0
  39. package/lib/record-walk.mjs +9 -0
  40. package/lib/record.mjs +54 -0
  41. package/lib/routines.mjs +13 -2
  42. package/lib/takeout.mjs +169 -0
  43. package/lib/tdx-attestation.mjs +432 -0
  44. package/lib/tdx-trust.json +10 -0
  45. package/lib/tools.mjs +669 -59
  46. package/lib/touches.mjs +427 -0
  47. package/lib/update-check.mjs +30 -0
  48. package/lib/yaml.mjs +8 -4
  49. package/package.json +2 -1
  50. package/templates/AGENTS.md +56 -6
  51. package/templates/evda-digest.md +2 -0
package/lib/mcp.mjs CHANGED
@@ -23,17 +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";
34
35
  import { encodeQr, qrPng, qrText } from "./qr.mjs";
35
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";
36
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";
37
41
 
38
42
  // Newest first. The server answers with the client's version when it supports it, and with the
39
43
  // newest otherwise; the client decides whether to continue.
@@ -55,7 +59,7 @@ export function instructions(filing, surface = "terminal") {
55
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."] : []),
56
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.",
57
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.",
58
- "When the owner corrects you, record it in the same turn with evda_record_correction: it appends one dated line to AGENTS.md's \"What I have been taught\", in their own words where they gave some, and nothing else in that file changes.",
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.",
59
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.",
60
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.",
61
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."]),
@@ -152,28 +156,14 @@ export function terminalOnly(sec) {
152
156
  return false;
153
157
  }
154
158
 
155
- // The clients this server knows, by initialize's clientInfo.name, and the size of one part for each.
156
- // Names: claude-code and codex-mcp-client measured 2026-10-05 (a probe server logging clientInfo,
157
- // driven by `claude -p` and `codex exec`). claude.ai sends Anthropic/ClaudeAI, measured 2026-10-05 in
158
- // the remote server's log from a fresh web chat; claude-ai stays matched as the published form.
159
- // openai-mcp is the name ChatGPT publishes and is unmeasured until that log holds one.
160
- // Limits: claude.ai and the desktop app take a tool result of about 150,000 characters
161
- // (claude.com/docs/connectors/building, "Design within the size and timeout limits"); Claude Code
162
- // 25,000 tokens (same page, MAX_MCP_OUTPUT_TOKENS); OpenAI publishes no figure for ChatGPT and
163
- // truncates an over-budget result at a line, so ChatGPT and every unknown app get the smallest
164
- // documented cap, LAYER_PART_MAX. Codex documents none either and gets the same.
165
- export const CLIENTS = [
166
- { app: "Claude Code", match: /^claude-code$/i, surface: "terminal", max: LAYER_PART_MAX },
167
- { app: "Codex", match: /^codex-mcp-client$/i, surface: "terminal", max: LAYER_PART_MAX },
168
- { app: "claude.ai", match: /^(claude-ai|anthropic\/claudeai)$/i, surface: "chat", max: 140_000 },
169
- { app: "ChatGPT", match: /^openai-mcp$/i, surface: "chat", max: LAYER_PART_MAX },
170
- ];
171
-
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 };
172
163
  export function clientProfile(name) {
173
- const n = typeof name === "string" ? name.trim() : "";
174
- const known = CLIENTS.find((c) => c.match.test(n));
175
- if (known) return { app: known.app, surface: known.surface, max: known.max };
176
- return { app: null, surface: "chat", max: LAYER_PART_MAX };
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 };
177
167
  }
178
168
 
179
169
  // The owner layer for one surface: the text without the sections tagged for another, and the
@@ -314,7 +304,7 @@ export function layerParts(evda, client = null) {
314
304
  const core = coreBlock(r);
315
305
  // Everything in the result but the owner sections: head, size line, core, and the joins between.
316
306
  const fixed = head.length + 2 + SIZE_LINE_RESERVE + 2 + core.reduce((n, c) => n + c.length + 2, 0);
317
- const fit = profile.surface === "terminal" ? { text: owner.text, listed: [] } : fitChat(owner.text, fixed);
307
+ const fit = profile.surface === "terminal" ? { text: owner.text, listed: [] } : fitChat(owner.text, fixed, profile.budget);
318
308
  const body = [...core, fit.text].join("\n\n");
319
309
  const max = profile.max;
320
310
  const pieces = packSections(body, max - PART_RESERVE - head.length, max - PART_RESERVE);
@@ -432,7 +422,10 @@ export const CLI_TOOLS = {
432
422
  // answer ("ask them once ... and record the answer there").
433
423
  // HQ-2228: an entry growing is evda_append (any entry) beside evda_record_person_note (a person's
434
424
  // page), and setup ("Write each answer into that section in their exact words") is evda_setup_answer.
435
- export const CHAT_PROSE_TOOLS = ["evda_operating_layer", "evda_write", "evda_read", "evda_search", "evda_recent", "evda_record_person_note", "evda_append", "evda_setup_answer", "evda_import", "evda_filing"];
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"];
436
429
 
437
430
  // The tool names a text tells an agent to use: evda_* names and `evda <command>` runs.
438
431
  export function toolsNamedIn(text) {
@@ -467,6 +460,10 @@ export function firstSentence(text) {
467
460
  // The one setup question, asked at first connect when evda.json has no filing answer (HQ-1385).
468
461
  export const FILING_QUESTION = "Should I file everything you say here automatically, or only when you ask? (everything)";
469
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
+
470
467
  // The few lines a chat app reads before the whole operating layer (HQ-1385): who the agent is,
471
468
  // whether to file, one section per conversation, no share marks or circle rules, and "just note".
472
469
  // Five lines, and a sixth that asks the setup question when the owner has not answered it.
@@ -508,7 +505,8 @@ export function chatSection(evda, agentsText = "", { app = null } = {}) {
508
505
  "- One conversation is one section: your first evda_write opens it with one line of context, and the rest go under it.",
509
506
  "- Never write a share mark or a circle rule. When they want to share, propose it and wait for their yes.",
510
507
  "- \"Just note this\" means evda_note: no AI reads that entry, you included, until they say it can.",
511
- "- When a person, project or place that already has an entry comes up, add a dated section to it with evda_append (a person's page with evda_record_person_note). Neither creates or overwrites anything.",
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,
512
510
  ];
513
511
  // HQ-2164: what the agent itself did goes to its own day log. Named here, so a chat app is offered
514
512
  // the tool (chatToolNames offers what this layer names), and only when the record has that log.
@@ -604,7 +602,7 @@ export const TOOLS = [
604
602
  // HQ-2223, slice 2: the first sentence holds the words a model searches by when it starts a chat
605
603
  // as the owner's agent (who you are, the owner's record, a new chat), and this is the one tool
606
604
  // whose definition says "operating layer", so a search for it finds this tool and no other.
607
- description: "Loads the operating layer: who you are for the owner and how their record is kept, to read at the start of every new chat before your first reply. Pass the owner's first message as first_message (and context, one line on what the chat is about) and it is filed verbatim in this same call, exactly as evda_write files it, and the result also carries their unseen replies and the recent days' headings, so you can answer after this one call. Returns how to act here, a few lines for a chat app, what leaves this device, the core of how the agent thinks, and then AGENTS.md (who the agent is, what this EVDA is for in the owner's words, and how the record is kept). A long layer comes in parts; read every part before answering. In a chat app, owner sections past a size budget are listed by heading; pass section to read one whole.",
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.",
608
606
  inputSchema: {
609
607
  type: "object",
610
608
  properties: {
@@ -634,22 +632,20 @@ export const TOOLS = [
634
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");
635
633
  const data = { path: l.path, core: l.core, part, parts: l.parts.length, chars: l.chars, listed: l.listed };
636
634
  if (!opening) return { text: l.parts[part - 1], data };
637
- const start = openChat(evda, args, ctx);
635
+ const start = openChat(evda, args, ctx, { room: clientProfile(ctx.client?.name).max - l.parts[part - 1].length - 7 - AFTER_RUN_RESERVE });
638
636
  return { text: `${l.parts[part - 1]}\n\n---\n\n${start.text}`, data: { ...data, filed: start.filed } };
639
637
  },
640
638
  },
641
639
  {
642
640
  name: "evda_recent",
643
641
  title: "Read the last few days",
644
- description: "Lists the record's files from the last few days by path and headings. N days (default 3), newest first, never a word of the body. Read a specific one whole with evda_read, by the path this returns. Each file says what an earlier reader already acknowledged in it, which you do not raise again.",
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.",
645
643
  inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 90, description: "How many days back, today included. Default 3." } }, additionalProperties: false },
646
644
  annotations: { readOnlyHint: true, openWorldHint: false },
647
645
  run: (evda, args, ctx) => {
648
646
  const r = tools.recent(evda, args, { caller: ctx.caller, now: ctx.now });
649
- const body = r.files.map((f) => `<file path="${f.path}" date="${f.date}"${ackAttr(f.acknowledged)}>${f.headings.length ? `\n${f.headings.map((h) => `# ${h}`).join("\n")}` : ""}\n</file>`).join("\n\n");
650
- const head = `${r.files.length} file(s) dated ${r.from} to ${r.to}, headings only, no body text (read one whole with evda_read when the question needs it)${r.omitted ? `, ${r.omitted} more omitted for size (read them with evda_search)` : ""}.${r.acknowledged ? ` ${acknowledgedNote(r.acknowledged)}` : ""}`;
651
- const main = r.files.length ? `${head}\n\n${body}` : `Nothing${r.refused.length ? " you may read" : ""} in the record is dated ${r.from} to ${r.to}.`;
652
- return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: { from: r.from, to: r.to, paths: r.files.map((f) => f.path), omitted: r.omitted, acknowledged: r.acknowledged } };
647
+ const room = ctx.room ?? clientProfile(ctx.client?.name).max - PART_RESERVE;
648
+ return renderRecent(r, room);
653
649
  },
654
650
  },
655
651
  {
@@ -700,18 +696,20 @@ export const TOOLS = [
700
696
  // returning, so a clobbered write is never reported to the owner as "Filed".
701
697
  const r = await tools.writeVerified(evda, args, { session: ctx.session });
702
698
  markWrite(ctx.session, args.words, r.path);
703
- return { text: `Filed to ${r.path}.`, data: r };
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") });
704
701
  },
705
702
  },
706
703
  {
707
704
  name: "evda_record_correction",
708
705
  title: "Record a correction",
709
- description: "Records a correction as one dated line in what the agent has been taught. Call when the owner corrects you (\"no, don't do that\", \"that's not how I work anymore\"): appends one dated line to AGENTS.md's \"What I have been taught\" section, in the owner's own words where they gave some, and returns the path. That section is always outside the installer's block, so `evda upgrade` never touches what this writes. Append only: it never edits or removes an earlier entry. When a correction reverses one, pass supersedes with that entry's date instead of trying to change it.",
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.",
710
707
  inputSchema: {
711
708
  type: "object",
712
709
  properties: {
713
710
  words: str("The owner's exact words, quoted verbatim, when they gave some. Omit rather than paraphrase."),
714
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."),
715
713
  supersedes: str("YYYY-MM-DD of an earlier entry this correction reverses, if any."),
716
714
  },
717
715
  required: ["change"],
@@ -719,25 +717,29 @@ export const TOOLS = [
719
717
  },
720
718
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
721
719
  run: (evda, args, ctx) => {
722
- const r = tools.recordCorrection(evda, args);
720
+ const r = tools.recordCorrection(evda, args, { caller: ctx.caller });
723
721
  return { text: `Recorded to ${r.path}, under "${r.section}".`, data: r };
724
722
  },
725
723
  },
726
724
  {
727
725
  name: "evda_write_agent_log",
728
726
  title: "File Iris's own day log",
729
- description: "Files the agent's own day log, separate from the owner's words. Call to record what you (the agent, not the owner) did, built, filed or found: appends to Iris's own day log, the one named sub-record at \"01_Projects/Evda/Iris - Universal Thinking Partner/memory/record/daily\", and returns the path. It takes no path and reaches nowhere else. One section per conversation, exactly like evda_write: the first call in a conversation opens the time heading with its context line, and later calls in the same conversation continue it.",
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.",
730
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 },
731
729
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
732
730
  run: (evda, args, ctx) => {
733
731
  const r = tools.writeAgentLog(evda, args, { session: ctx.session });
734
- return { text: `Filed to ${r.path}.`, data: r };
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 };
735
737
  },
736
738
  },
737
739
  {
738
740
  name: "evda_record_person_note",
739
741
  title: "Add to a person's page",
740
- description: "Adds one dated section to an existing person's page, found by their name. The page is under the record/people role; returns the path. Resolves the page by the name you give, not a path; refuses when no page matches (it never creates one; offer to make the page first) and when more than one page matches. Refuses a sealed page. When the page's frontmatter is kind: person-profile, also adds a matching observations entry so the page still passes evda doctor. Append only: an earlier section is never edited or removed.",
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.",
741
743
  inputSchema: {
742
744
  type: "object",
743
745
  properties: {
@@ -749,20 +751,22 @@ export const TOOLS = [
749
751
  additionalProperties: false,
750
752
  },
751
753
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
752
- run: (evda, args) => {
753
- const r = tools.recordPersonNote(evda, args);
754
- return { text: `Added to ${r.path}${r.observation ? `, observation ${r.observation}` : ""}.`, data: r };
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") });
755
757
  },
756
758
  },
757
759
  {
758
760
  name: "evda_append",
759
761
  title: "Add to an existing entry",
760
- description: "Adds one dated section to an entry that already exists, by its path. Call when a project, place or other entry the record already has comes up again: appends `## YYYY-MM-DD` and the words to the end of that file, or continues today's section when it is already the last one, and returns the path. Every byte already there stays as it was. Refuses a path with no entry (it never creates one; offer to start the page), a sealed entry, a just-noted entry, a part set to be read by a different AI, and anything shaped like a password, key or token. A person's page goes through evda_record_person_note, the day's words through evda_write.",
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.",
761
763
  inputSchema: {
762
764
  type: "object",
763
765
  properties: {
764
766
  path: str("The existing entry, relative to the record, as evda_search or evda_recent gave it."),
765
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)`."),
766
770
  },
767
771
  required: ["path", "words"],
768
772
  additionalProperties: false,
@@ -770,7 +774,73 @@ export const TOOLS = [
770
774
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
771
775
  run: (evda, args, ctx) => {
772
776
  const r = tools.appendEntry(evda, args, { caller: ctx.caller });
773
- return { text: r.section === "continued" ? `Added to the ${r.date} section of ${r.path} (continued).` : `Added a ${r.date} section to ${r.path}.`, data: r };
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 },
841
+ run: (evda, args, ctx) => {
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 };
774
844
  },
775
845
  },
776
846
  {
@@ -833,6 +903,28 @@ export const TOOLS = [
833
903
  return { text: `<file path="${r.path}">\n${r.text}\n</file>${r.truncated ? "\n(truncated for size)" : ""}` };
834
904
  }),
835
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 };
926
+ },
927
+ },
836
928
  {
837
929
  name: "evda_note",
838
930
  title: "Just note something",
@@ -1064,6 +1156,30 @@ export const TOOLS = [
1064
1156
  return { text: r.lines.join("\n") };
1065
1157
  },
1066
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
+ },
1067
1183
  {
1068
1184
  name: "evda_skills",
1069
1185
  title: "List or read the agent's skills",
@@ -1283,22 +1399,158 @@ const RECEIVED = new Set(["evda_import", "evda_gather_answer"]);
1283
1399
  const WRITES = new Set(["evda_write", "evda_note"]);
1284
1400
 
1285
1401
  export function newSession() {
1286
- return { calls: 0, reads: 0, received: 0, writes: 0, lastNudge: -Infinity, layer: false, preambled: false, lastCall: null, everyChatAsked: false, lastFiled: null };
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 };
1287
1403
  }
1288
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
+
1289
1533
  // The one-call start (HQ-2223, slice 2). Measured 2026-10-05: a fresh claude.ai chat took 5 tool
1290
1534
  // calls before it replied, each its own Allow click. So the layer call takes the owner's first
1291
1535
  // message and files it the way evda_write does (same day file, same section for the chat, same
1292
1536
  // refusals), and returns what a chat reads next (unseen replies, the recent days' headings), so the
1293
1537
  // chat can answer after one call. An owner who turned automatic filing off, or has not answered
1294
1538
  // yet, is filed for only when they ask: nothing is written and the result says so.
1295
- export function openChat(evda, args, ctx) {
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 } = {}) {
1296
1542
  const f = fileAlong(evda, { words: args.first_message, context: args.context }, ctx, "evda_operating_layer");
1297
1543
  const head = f.filed
1298
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.`
1299
1545
  : f.head;
1300
1546
  const run = (name) => TOOLS.find((t) => t.name === name).run(evda, {}, ctx).text;
1301
- const parts = [head, `## Their replies you have not seen\n\n${run("evda_replies")}`, `## The last few days\n\n${run("evda_recent")}`];
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] : [])];
1302
1554
  return { text: parts.join("\n\n"), filed: f.filed ? f.path : null };
1303
1555
  }
1304
1556
 
@@ -1336,6 +1588,7 @@ export function fileAlong(evda, { words, context }, ctx, toolName) {
1336
1588
  ctx.session.writes += 1;
1337
1589
  ctx.session.lastFiled = { words: words.trim(), path: r.path, by: "along" };
1338
1590
  }
1591
+ if (r.queued) return { filed: true, queued: true, path: r.path, head: queuedText(r) };
1339
1592
  return { filed: true, path: r.path, head: `Filed their message to ${r.path}.` };
1340
1593
  } catch (e) {
1341
1594
  if (!(e instanceof EvdaError) || e.code !== "SECRET") throw e;
@@ -1343,6 +1596,21 @@ export function fileAlong(evda, { words, context }, ctx, toolName) {
1343
1596
  }
1344
1597
  }
1345
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
+
1346
1614
  // Whether these words are the ones last filed in this chat (fileAlong's rule). evda_write passes
1347
1615
  // spend: true and matches only a ride-along's mark, and spends it either way.
1348
1616
  export function sameAsFiled(session, words, { spend = true } = {}) {
@@ -1368,10 +1636,20 @@ function markWrite(session, words, path) {
1368
1636
  // a new chat taken for an old one, would leave it with no layer and no nudge.
1369
1637
  export const CHAT_IDLE_MS = 20 * 60 * 1000;
1370
1638
 
1371
- function enterChat(session, now) {
1372
- if (session.lastCall !== null && now - session.lastCall > CHAT_IDLE_MS) {
1373
- Object.assign(session, newSession());
1374
- }
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());
1375
1653
  session.lastCall = now;
1376
1654
  }
1377
1655
 
@@ -1381,8 +1659,8 @@ function enterChat(session, now) {
1381
1659
  // chat get one line until the layer is loaded. Returns "preamble", "reminder" or null.
1382
1660
  // EVDA_CHAT_FALSIFY=no-layer-preamble turns it off, for the control test.
1383
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.";
1384
- export function layerNotice(session, name, { env = process.env, now = Date.now() } = {}) {
1385
- enterChat(session, now);
1662
+ export function layerNotice(session, name, { env = process.env, now = Date.now(), args = {} } = {}) {
1663
+ enterChat(session, now, name, args, env);
1386
1664
  if (name === "evda_operating_layer") { session.layer = true; return null; }
1387
1665
  if (session.layer || chatFalsified("no-layer-preamble", env)) return null;
1388
1666
  if (session.preambled) return "reminder";
@@ -1428,6 +1706,24 @@ export function countCall(session, name, { env = process.env, filing = null } =
1428
1706
  return true;
1429
1707
  }
1430
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
+
1431
1727
  // The tools this client is offered: every tool for a coding agent, the chat set for any other.
1432
1728
  // A record that cannot be opened offers the chat set's fixed part.
1433
1729
  function offered(ctx) {
@@ -1463,6 +1759,8 @@ export async function handle(msg, ctx) {
1463
1759
  // Which chat app, for the every-chat walkthrough only (HQ-2205). It decides which words are
1464
1760
  // shown, never what may be read, so a client that misnames itself costs nothing but wording.
1465
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 */ }
1466
1764
  return ok({
1467
1765
  protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
1468
1766
  capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false } },
@@ -1486,10 +1784,14 @@ export async function handle(msg, ctx) {
1486
1784
  if (!ctx.caller) ctx.caller = callerFrom({ env: ctx.env ?? process.env });
1487
1785
  ctx.session ??= newSession();
1488
1786
  const evda = ctx.evda();
1489
- const notice = layerNotice(ctx.session, tool.name, { env: process.env, now: ctx.clock?.() ?? Date.now() });
1787
+ const notice = layerNotice(ctx.session, tool.name, { env: process.env, now: ctx.clock?.() ?? Date.now(), args });
1490
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}`;
1491
1791
  const r = await tool.run(evda, args, ctx);
1492
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) });
1493
1795
  // evda_filing just changed the answer the walkthrough's line depends on, so it reads it again.
1494
1796
  const setup = r.isError ? null : everyChatNotice(ctx.session, ctx.app, tool.name === "evda_filing" ? ctx.evda().manifest : evda.manifest, tool.name);
1495
1797
  // A process run twice is pointed at once (HQ-1377), so an agent in an app with no memory of
@@ -1500,7 +1802,7 @@ export async function handle(msg, ctx) {
1500
1802
  // so everything the model needs, paths included, is in the text.
1501
1803
  // An image (evda_pair's QR code, HQ-2040) follows the text; everything the model needs is still in the text.
1502
1804
  const images = (r.images ?? []).map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType }));
1503
- return ok({ content: [{ type: "text", text: `${r.text}${tail}${setup ? `\n\n---\n\n${setup}` : ""}${nudge}${repeat ? `\n\n${repeat}` : ""}` }, ...images], isError: Boolean(r.isError) });
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) });
1504
1806
  } catch (e) {
1505
1807
  // A refusal is a tool result the model reads and acts on, not a protocol failure.
1506
1808
  if (e instanceof EvdaError) return ok({ content: [{ type: "text", text: `${e.message}${tail}` }], isError: true });
@@ -1542,7 +1844,7 @@ function error(id, code, message) {
1542
1844
  // Serve on stdio until stdin closes. `dir` is the located record; it is reopened for every call so
1543
1845
  // a record edited by hand between calls is read as it is now.
1544
1846
  export function serve({ dir, version, input = process.stdin, output = process.stdout, log = (s) => process.stderr.write(`[evda mcp] ${s}\n`) }) {
1545
- const ctx = { version, log, evda: () => openEvda(dir), session: newSession() };
1847
+ const ctx = { version, log, evda: () => openEvda(dir), session: newSession(), keepsToolList: true };
1546
1848
  openEvda(dir); // refuse to start on something that is not a record
1547
1849
  log(`serving ${dir} (tool schema ${SCHEMA_VERSION})`);
1548
1850
  const rl = createInterface({ input, crlfDelay: Infinity });