create-evda 0.1.5 → 0.1.6

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 (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +74 -7
  3. package/bin/evda.mjs +391 -6
  4. package/create-evda.mjs +54 -10
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +346 -13
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/gather-apply.mjs +635 -0
  17. package/lib/gather.mjs +808 -0
  18. package/lib/grants.mjs +7 -1
  19. package/lib/guard.mjs +132 -0
  20. package/lib/hooks.mjs +124 -0
  21. package/lib/keep-hook.mjs +185 -0
  22. package/lib/kinds.mjs +23 -6
  23. package/lib/kinds.schema.json +2 -1
  24. package/lib/manifest.mjs +14 -1
  25. package/lib/mcp-auth.mjs +370 -0
  26. package/lib/mcp-http.mjs +336 -0
  27. package/lib/mcp.mjs +425 -36
  28. package/lib/migrate-people.mjs +231 -0
  29. package/lib/organize.mjs +52 -12
  30. package/lib/parity.mjs +111 -0
  31. package/lib/presets.mjs +58 -7
  32. package/lib/prompt-hook.mjs +88 -0
  33. package/lib/publish.mjs +264 -0
  34. package/lib/readers.mjs +319 -0
  35. package/lib/records.mjs +52 -0
  36. package/lib/references.mjs +5 -51
  37. package/lib/render.mjs +158 -0
  38. package/lib/roles.mjs +102 -1
  39. package/lib/secret-shape.mjs +58 -0
  40. package/lib/setup.mjs +118 -0
  41. package/lib/skills.mjs +266 -0
  42. package/lib/subrecord.mjs +121 -0
  43. package/lib/sync-rules.mjs +87 -0
  44. package/lib/tools.mjs +118 -27
  45. package/lib/update-check.mjs +146 -0
  46. package/lib/work.mjs +180 -0
  47. package/lib/yaml.mjs +117 -42
  48. package/package.json +24 -15
  49. package/presets/journal/preset.json +9 -1
  50. package/presets/projects-and-areas/preset.json +16 -9
  51. package/skills/evda/SKILL.md +58 -0
  52. package/templates/AGENTS.md +118 -14
  53. package/templates/README.md +15 -1
  54. package/templates/circles-README.md +3 -3
  55. package/templates/evda-digest.md +30 -6
  56. package/LICENSE +0 -21
  57. package/presets/student/preset.json +0 -13
package/lib/mcp.mjs CHANGED
@@ -8,6 +8,10 @@
8
8
  //
9
9
  // stdout carries protocol messages only. Nothing in this file or the tools it calls prints to
10
10
  // stdout; doctor's report is captured into the tool result.
11
+ //
12
+ // Every tool here reads and writes files on this machine, with one exception: evda_check_update
13
+ // asks the npm registry for the tooling's latest version number. That is the server's only network
14
+ // call, it carries nothing from the record, and it installs nothing.
11
15
 
12
16
  import { createInterface } from "node:readline";
13
17
  import { SCHEMA_VERSION } from "./manifest.mjs";
@@ -15,32 +19,153 @@ import { openEvda } from "./manifest.mjs";
15
19
  import { EvdaError } from "./errors.mjs";
16
20
  import * as tools from "./tools.mjs";
17
21
  import * as presets from "./presets.mjs";
22
+ import * as gather from "./gather.mjs";
23
+ import * as gatherApply from "./gather-apply.mjs";
24
+ import { checkForUpdate, summarize } from "./update-check.mjs";
25
+ import { callerFrom } from "./readers.mjs";
26
+ import { listSkills, getSkill, writeSkill, noticeRepeat, SKILLS_REL } from "./skills.mjs";
27
+ import { createSubrecord } from "./subrecord.mjs";
28
+ import { FALSIFY } from "./mcp-auth.mjs";
29
+ import { filingOf, FILING_MODES } from "./day-file.mjs";
30
+ import { readManifest, writeManifest } from "./manifest.mjs";
18
31
 
19
32
  // Newest first. The server answers with the client's version when it supports it, and with the
20
33
  // newest otherwise; the client decides whether to continue.
21
34
  export const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
22
35
  export const SERVER_NAME = "evda";
23
36
 
24
- const INSTRUCTIONS = [
25
- "This server is the owner's EVDA: their record, in plain files on this machine.",
26
- "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.",
27
- "File the owner's words verbatim with evda_write in the same turn they say them, and report the path it returns in the past tense.",
28
- "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.",
29
- "Organization comes from the preset library (evda_presets): propose with evda_propose_structure, and apply with evda_apply_preset only after their yes.",
37
+ // The server's instructions, by the owner's filing answer (HQ-1385). The Claude app was measured not
38
+ // acting on this field (2026-09-22: it told Mike "I only write to EVÐA when you ask me to" while
39
+ // this said to file unasked), so the trigger is also the first words of evda_write's description,
40
+ // which is what a deferred tool shows. This stays for the clients that read it.
41
+ export function instructions(filing) {
42
+ const lines = [
43
+ "This server is the owner's EVDA: their record, in plain files on this machine.",
44
+ "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.",
45
+ ...(filing === "asked"
46
+ ? ["The owner turned automatic filing off: file their words with evda_write only when they ask you to save or file something, and report the path it returns in the past tense."]
47
+ : ["File every message the owner sends, verbatim, with evda_write in the same turn they say it, and report the path it returns in the past tense. Your first evda_write in this conversation opens its section with one line of context; the rest go under it."]),
48
+ ...(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."] : []),
49
+ "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.",
50
+ "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.",
51
+ "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.",
52
+ "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.",
53
+ ...(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."]),
54
+ "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.",
55
+ "Organization comes from the preset library (evda_presets): propose with evda_propose_structure, and apply with evda_apply_preset only after their yes.",
56
+ "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.",
57
+ PRIVACY_INSTRUCTIONS,
58
+ ];
59
+ return lines.join(" ");
60
+ }
61
+
62
+ // What leaves the device (HQ-1413), short here because every connection reads it, and whole in the
63
+ // operating layer below. The providers' facts were verified on 2026-09-23 against their own help
64
+ // pages (Claude privacy.claude.com 10023580, OpenAI help 7730893 and 5722486, Gemini 13594961); a
65
+ // fact not verified is worded "may". test/privacy.test.mjs holds each point on both surfaces.
66
+ export const PRIVACY_INSTRUCTIONS = [
67
+ "Privacy: the record stays on the owner's devices, and this connector keeps no copy of what passes through it: only the record changes, and only when a write tool is called.",
68
+ "What a tool returns enters this conversation, which your provider keeps under the owner's own account settings.",
69
+ "Read only what the question needs, refer to a sensitive entry rather than restating it, and never ask for a noted (and, where supported, sealed) entry or work around one.",
70
+ "Rating a reply can send the whole conversation to the provider to keep, and possibly to train on, even with training off (verified 2026-09-23 for Claude, ChatGPT and Gemini; the operating layer has each).",
71
+ "When a conversation has carried personal content from the record and they might rate or share it, tell them so plainly, once, never every turn.",
72
+ "To keep a conversation out of training, the option is the provider's incognito or temporary chat.",
30
73
  ].join(" ");
31
74
 
75
+ export const PRIVACY_SECTION = [
76
+ "## Privacy: what leaves this device",
77
+ "",
78
+ "- The record stays on the owner's devices, and this connector keeps no copy of what passes through it: it logs no arguments and no results, and only the record changes, and only when a write tool is called.",
79
+ "- What a tool returns enters this conversation, which your provider keeps under the owner's own account settings. Anything you restate from the record is part of the conversation too.",
80
+ "- Read only what the question needs, and refer to a sensitive entry rather than restating it: its path or a few words, not its contents. Health, sex, money and other people's private lives are sensitive unless the owner says otherwise.",
81
+ "- Never ask for a noted (and, where supported, sealed) entry or work around one, through a search, another file or a summary.",
82
+ "- What rating a reply does, verified 2026-09-23:",
83
+ " - Claude: a thumbs up or down stores the whole conversation for up to 5 years, de-linked from the account, and it may be used for training even with training off. Raw tool results are left out; whatever you restated from them is not.",
84
+ " - ChatGPT: feedback makes the whole conversation usable for training even when the owner opted out. How long it is kept is not published.",
85
+ " - Gemini: feedback with Keep Activity off sends the last 24 hours of chats, connected-app data included, and keeps them up to 3 years.",
86
+ " - Any other provider may do the same, and a shared link may let anyone who has it read the conversation.",
87
+ "- When a conversation has carried personal content from the record and they might rate or share it, tell them so plainly, once, never every turn: what it would send, and to whom.",
88
+ "- To keep a conversation out of training, the option is the provider's incognito or temporary chat. Claude's incognito chats are never used for training; ChatGPT keeps a Temporary Chat up to 30 days; others may offer the same.",
89
+ ].join("\n");
90
+
32
91
  const str = (description) => ({ type: "string", description });
33
92
 
93
+ // summarize() already names the command for the two statuses that ask the owner to run one. Over
94
+ // stdio the owner may have no shell in front of them at all (the Claude desktop app), and `evda
95
+ // update` shells out to npx, so the result says where the command goes and never offers to run it.
96
+ const NEEDS_A_COMMAND = new Set(["UPDATE_AVAILABLE", "UNSTAMPED"]);
97
+ 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.";
98
+
99
+ // evda_write's description, by the owner's filing answer. It leads with when to call it, because a
100
+ // deferred connector tool shows the model its name and the first line of its description and
101
+ // nothing else until it is loaded (HQ-1385). An owner who has not answered is filed for as if they
102
+ // said everything, the default setup offers; the operating layer asks them.
103
+ // EVDA_CHAT_FALSIFY=old-description serves the description from before HQ-1385, which says what the
104
+ // tool does and never when, for the test that shows the first line is what files.
105
+ export const WRITE_DESCRIPTION = {
106
+ 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.",
107
+ 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.",
108
+ 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.",
109
+ };
110
+ const chatFalsified = (mode, env = process.env) => (env.EVDA_CHAT_FALSIFY ?? "").split(",").map((x) => x.trim()).includes(mode);
111
+
112
+ // The tool list a client sees, with the descriptions that depend on the owner's filing answer.
113
+ export function toolList(filing, env = process.env) {
114
+ return TOOLS.map(({ run, ...t }) => {
115
+ if (t.name !== "evda_write") return t;
116
+ const description = chatFalsified("old-description", env) ? WRITE_DESCRIPTION.old : WRITE_DESCRIPTION[filing === "asked" ? "asked" : "everything"];
117
+ return { ...t, description };
118
+ });
119
+ }
120
+
121
+ // The one setup question, asked at first connect when evda.json has no filing answer (HQ-1385).
122
+ export const FILING_QUESTION = "Should I file everything you say here automatically, or only when you ask? (everything)";
123
+
124
+ // The few lines a chat app reads before the whole operating layer (HQ-1385): who the agent is,
125
+ // whether to file, one section per conversation, no share marks or circle rules, and "just note".
126
+ // Five lines, and a sixth that asks the setup question when the owner has not answered it.
127
+ export function chatSection(evda, agentsText = "") {
128
+ const field = (label) => new RegExp(`^- \\*\\*${label}:\\*\\*[ \\t]*(\\S.*)$`, "m").exec(agentsText)?.[1]?.trim() ?? "";
129
+ const agent = field("Agent name") || "Iris";
130
+ const owner = (typeof evda.manifest?.owner?.name === "string" && evda.manifest.owner.name.trim()) || field("Who I am") || "the owner";
131
+ const filing = filingOf(evda.manifest);
132
+ const lines = [
133
+ "## In a chat app, read this first",
134
+ "",
135
+ `- 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.`,
136
+ filing === "asked"
137
+ ? "- They turned automatic filing off: file their words with evda_write only when they ask you to."
138
+ : "- File every message they send with evda_write, verbatim (filler and typos intact), in the same turn, without being asked. Report the path in the past tense.",
139
+ "- One conversation is one section: your first evda_write opens it with one line of context, and the rest go under it.",
140
+ "- Never write a share mark or a circle rule. When they want to share, propose it and wait for their yes.",
141
+ "- \"Just note this\" means evda_note: no AI reads that entry, you included, until they say it can.",
142
+ ];
143
+ 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.`);
144
+ return lines.join("\n");
145
+ }
146
+
147
+ // Record the owner's filing answer in evda.json. Every other key is kept as it was read.
148
+ export function setFiling(evda, args = {}) {
149
+ tools.onlyKeys("evda_filing", args, ["filing", "confirmed"]);
150
+ if (!FILING_MODES.includes(args.filing)) throw tools.refuse(`evda_filing "filing" is ${FILING_MODES.map((m) => JSON.stringify(m)).join(" or ")}.`, "BAD_FILING");
151
+ if (args.confirmed !== true) throw tools.refuse("evda_filing records the owner's own answer. Ask them, and pass confirmed: true with what they said.", "NOT_CONFIRMED");
152
+ const m = readManifest(evda.dir);
153
+ if (m.filing === args.filing) return { filing: args.filing, changed: false };
154
+ m.filing = args.filing;
155
+ writeManifest(evda.dir, m);
156
+ return { filing: args.filing, changed: true };
157
+ }
158
+
34
159
  export const TOOLS = [
35
160
  {
36
161
  name: "evda_operating_layer",
37
162
  title: "Read the operating layer",
38
- description: "Returns AGENTS.md, the record's operating layer: who the agent is, what this EVDA is for in the owner's words, and how to keep the record. Read it first, every session.",
163
+ 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).",
39
164
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
40
165
  annotations: { readOnlyHint: true, openWorldHint: false },
41
166
  run: (evda, args) => {
42
167
  const r = tools.operatingLayer(evda, args);
43
- return { text: r.text, data: { path: r.path } };
168
+ return { text: `${chatSection(evda, r.text)}\n\n${PRIVACY_SECTION}\n\n${r.text}`, data: { path: r.path } };
44
169
  },
45
170
  },
46
171
  {
@@ -49,21 +174,22 @@ export const TOOLS = [
49
174
  description: "Returns every record file dated in the last N days (default 3), newest first, whole.",
50
175
  inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 90, description: "How many days back, today included. Default 3." } }, additionalProperties: false },
51
176
  annotations: { readOnlyHint: true, openWorldHint: false },
52
- run: (evda, args) => {
53
- const r = tools.recent(evda, args);
177
+ run: (evda, args, ctx) => {
178
+ const r = tools.recent(evda, args, { caller: ctx.caller });
54
179
  const body = r.files.map((f) => `<file path="${f.path}" date="${f.date}">\n${f.text}\n</file>`).join("\n\n");
55
180
  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)` : ""}.`;
56
- return { text: r.files.length ? `${head}\n\n${body}` : `Nothing in the record is dated ${r.from} to ${r.to}.`, data: { from: r.from, to: r.to, paths: r.files.map((f) => f.path), omitted: r.omitted } };
181
+ 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}.`;
182
+ 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 } };
57
183
  },
58
184
  },
59
185
  {
60
186
  name: "evda_write",
61
187
  title: "File the owner's words",
62
- description: "Appends the owner's words, exactly as they said them, to today's daily file under the local time, 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.",
63
- inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim: filler, hedges and typos intact. Never a summary.") }, required: ["words"], additionalProperties: false },
188
+ description: WRITE_DESCRIPTION.everything,
189
+ 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 },
64
190
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
65
- run: (evda, args) => {
66
- const r = tools.write(evda, args);
191
+ run: (evda, args, ctx) => {
192
+ const r = tools.write(evda, args, { session: ctx.session });
67
193
  return { text: `Filed to ${r.path}.`, data: r };
68
194
  },
69
195
  },
@@ -89,11 +215,66 @@ export const TOOLS = [
89
215
  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.",
90
216
  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 },
91
217
  annotations: { readOnlyHint: true, openWorldHint: false },
92
- run: (evda, args) => {
93
- const r = tools.search(evda, args);
218
+ run: (evda, args, ctx) => {
219
+ const r = tools.search(evda, args, { caller: ctx.caller });
94
220
  const lines = r.matches.map((m) => `${m.path}:${m.line}: ${m.text}`);
95
221
  const tail = r.more ? `\n${r.more} more match(es) past the limit.` : "";
96
- return { text: 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).`, data: r };
222
+ 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).`;
223
+ return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: r };
224
+ },
225
+ },
226
+ {
227
+ name: "evda_read",
228
+ title: "Read one file of the record",
229
+ 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.",
230
+ inputSchema: { type: "object", properties: { path: str("The file's path, relative to the record.") }, required: ["path"], additionalProperties: false },
231
+ annotations: { readOnlyHint: true, openWorldHint: false },
232
+ run: (evda, args, ctx) => {
233
+ const r = tools.readFile(evda, args, { caller: ctx.caller });
234
+ return { text: `<file path="${r.path}">\n${r.text}\n</file>${r.truncated ? "\n(truncated for size)" : ""}` };
235
+ },
236
+ },
237
+ {
238
+ name: "evda_note",
239
+ title: "Just note something",
240
+ 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.",
241
+ inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim.") }, required: ["words"], additionalProperties: false },
242
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
243
+ run: (evda, args) => {
244
+ const r = tools.noteTool(evda, args);
245
+ return { text: `Noted at ${r.path}. No AI reads it until the owner says it can.`, data: r };
246
+ },
247
+ },
248
+ {
249
+ name: "evda_filing",
250
+ title: "Record how filing works",
251
+ 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.",
252
+ inputSchema: {
253
+ type: "object",
254
+ 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." } },
255
+ required: ["filing", "confirmed"],
256
+ additionalProperties: false,
257
+ },
258
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
259
+ run: (evda, args) => {
260
+ const r = setFiling(evda, args);
261
+ 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 };
262
+ },
263
+ },
264
+ {
265
+ name: "evda_release_note",
266
+ title: "Release a noted entry",
267
+ 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.",
268
+ inputSchema: {
269
+ type: "object",
270
+ 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." } },
271
+ required: ["path", "confirmed"],
272
+ additionalProperties: false,
273
+ },
274
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
275
+ run: (evda, args) => {
276
+ const r = tools.releaseTool(evda, args);
277
+ return { text: `Released ${r.path}. It reads like any other entry now.`, data: r };
97
278
  },
98
279
  },
99
280
  {
@@ -178,6 +359,132 @@ export const TOOLS = [
178
359
  return { text: `${n ? `Applied the ${r.plan.title} preset.` : `Nothing was written: the record already has the ${r.plan.title} preset.`}\n${r.lines.join("\n")}` };
179
360
  },
180
361
  },
362
+ {
363
+ name: "evda_gather",
364
+ title: "Gather a pile into a proposal",
365
+ 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.",
366
+ 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 },
367
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
368
+ run: (evda, args) => {
369
+ const r = gather.gatherTool(evda, args);
370
+ return { text: `Proposal ${r.p.id}, written to ${r.path}.\n\n${gather.summaryText(r.p)}\n\n${gather.checkedLines(r.p).join("\n")}` };
371
+ },
372
+ },
373
+ {
374
+ name: "evda_gather_questions",
375
+ title: "What a gather needs the owner to decide",
376
+ description: "Returns the open questions on a gather proposal. Each one changes where at least one item goes, and nothing else is ever asked. Ask them one at a time, in conversation, in your own words; never as a list or a quiz.",
377
+ inputSchema: { type: "object", properties: { proposal: str("The proposal id evda_gather returned.") }, required: ["proposal"], additionalProperties: false },
378
+ annotations: { readOnlyHint: true, openWorldHint: false },
379
+ run: (evda, args) => {
380
+ const r = gather.gatherQuestionsTool(evda, args);
381
+ if (!r.open.length) return { text: `No open questions on ${r.p.id}.` };
382
+ return { text: r.open.map((q) => `[${q.id}] ${q.text} Answers: ${q.options.join(" or ")}. Moves ${q.affects.length} item(s).`).join("\n") };
383
+ },
384
+ },
385
+ {
386
+ name: "evda_gather_answer",
387
+ title: "Record the owner's answer on a gather",
388
+ 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.",
389
+ inputSchema: {
390
+ type: "object",
391
+ 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.") },
392
+ required: ["proposal", "question", "answer"],
393
+ additionalProperties: false,
394
+ },
395
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
396
+ run: (evda, args) => {
397
+ const r = gather.gatherAnswerTool(evda, args);
398
+ const open = r.proposal.questions.filter((q) => q.answer === undefined).length;
399
+ return { text: `Recorded ${args.question} = ${args.answer}. ${r.moved.length} item(s) re-placed:\n${gather.answerLines(r).join("\n")}\n${open} question(s) still open.` };
400
+ },
401
+ },
402
+ {
403
+ name: "evda_gather_apply",
404
+ title: "Apply a gather proposal",
405
+ 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.",
406
+ inputSchema: {
407
+ type: "object",
408
+ properties: {
409
+ proposal: str("The proposal id evda_gather returned."),
410
+ into: str("A path: the record to grow, or a new folder to make one in. Omit to grow the open record."),
411
+ move: { type: "boolean", description: "true to move the files out of their sources; copy is the default." },
412
+ confirmed: { type: "boolean", description: "true only when the owner said yes to applying this proposal in this conversation." },
413
+ },
414
+ required: ["proposal", "confirmed"],
415
+ additionalProperties: false,
416
+ },
417
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
418
+ run: async (evda, args) => {
419
+ const r = await gatherApply.gatherApplyTool(evda, args);
420
+ return { text: r.lines.join("\n") };
421
+ },
422
+ },
423
+ {
424
+ name: "evda_skills",
425
+ title: "List or read the agent's skills",
426
+ 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.`,
427
+ inputSchema: { type: "object", properties: { name: str("A skill's name, to read that one. Omit to list them.") }, additionalProperties: false },
428
+ annotations: { readOnlyHint: true, openWorldHint: false },
429
+ run: (evda, args) => {
430
+ if (args.name !== undefined) {
431
+ const r = getSkill(evda, args.name);
432
+ return { text: `<file path="${r.path}">\n${r.text}\n</file>` };
433
+ }
434
+ const r = listSkills(evda);
435
+ if (!r.skills.length) return { text: `No skills in ${SKILLS_REL} yet. When you repeat a process, write it with evda_write_skill.` };
436
+ return { text: r.skills.map((x) => `${x.folder}: ${x.description ?? "(no description)"}${x.problems.length ? ` [broken: ${x.problems.join(" ")}]` : ""}`).join("\n") };
437
+ },
438
+ },
439
+ {
440
+ name: "evda_write_skill",
441
+ title: "Write down a process as a skill",
442
+ 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.`,
443
+ inputSchema: {
444
+ type: "object",
445
+ properties: {
446
+ name: str("Kebab-case, at most 64 characters: what the process is, such as start-a-project."),
447
+ description: str("When to use it, starting \"Use when\"."),
448
+ steps: str("The steps, one numbered line each, in markdown."),
449
+ revise: { type: "boolean", description: "true to rewrite an existing skill after a run of it went wrong." },
450
+ why: str("With revise: what went wrong on the run."),
451
+ },
452
+ required: ["name", "description", "steps"],
453
+ additionalProperties: false,
454
+ },
455
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
456
+ run: (evda, args) => {
457
+ const r = writeSkill(evda, args);
458
+ if (r.action === "unchanged") return { text: `${r.path} already says exactly that. Nothing changed.` };
459
+ return { text: `${r.action === "revised" ? "Revised" : "Wrote"} the skill ${r.name} at ${r.path}${r.revisions ? `; what it replaced is in ${r.revisions}` : ""}. Tell the owner in one line that you ${r.action === "revised" ? "revised" : "wrote"} it and what it is for.` };
460
+ },
461
+ },
462
+ {
463
+ name: "evda_create_subrecord",
464
+ title: "Make an EVÐA inside this one",
465
+ 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.",
466
+ inputSchema: {
467
+ type: "object",
468
+ properties: {
469
+ path: str("Where in the record it goes, relative, such as Projects/Garden. New or empty."),
470
+ name: str("Its name. Default: the last part of the path."),
471
+ purpose: str("What it is for, in the owner's words. Goes into its AGENTS.md."),
472
+ kind: { type: "string", enum: ["person", "agent", "company"], description: "Who holds it. Default person, the owner." },
473
+ bounded: { type: "boolean", description: "true to make this record's circles stop at the new one. Changes this record's evda.json; needs confirmed." },
474
+ confirmed: { type: "boolean", description: "true only when the owner said yes to bounded in this conversation." },
475
+ },
476
+ required: ["path"],
477
+ additionalProperties: false,
478
+ },
479
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
480
+ run: (evda, args) => {
481
+ const r = createSubrecord(evda, args);
482
+ const lines = r.doctor.text.split("\n");
483
+ const shown = lines.length > 200 ? [...lines.slice(0, 20), ` ... ${lines.length - 40} line(s) ...`, ...lines.slice(-20)] : lines;
484
+ const parent = r.madeParentBounded ? "This record's evda.json now says \"subrecords\": \"bounded\", so its circles stop there." : r.parentBounded ? "This record's evda.json is unchanged; it was already bounded, so its circles stop there." : "This record's evda.json is unchanged, so its circles still cover the new one's files as their own.";
485
+ return { text: `Made the EVÐA ${r.name} (${r.id}) at ${r.path}, with the four roles and an AGENTS.md. ${parent}\n\nDoctor on this record, exit ${r.doctor.exit}:\n${shown.join("\n")}`, isError: r.doctor.exit !== 0 };
486
+ },
487
+ },
181
488
  {
182
489
  name: "evda_doctor",
183
490
  title: "Check the record",
@@ -189,28 +496,79 @@ export const TOOLS = [
189
496
  return { text: `${r.text}\n\nexit ${r.exit}`, data: { exit: r.exit }, isError: r.exit !== 0 };
190
497
  },
191
498
  },
499
+ {
500
+ name: "evda_check_update",
501
+ title: "Check for newer EVDA tooling",
502
+ 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. This is the only tool that reaches the network, it sends nothing from the record, and it changes nothing: taking the update is a command the owner runs in their own terminal.",
503
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
504
+ annotations: { readOnlyHint: true, openWorldHint: true },
505
+ run: async (evda) => {
506
+ const result = await checkForUpdate(evda.dir);
507
+ const line = summarize(result);
508
+ return { text: NEEDS_A_COMMAND.has(result.status) ? `${line}\n\n${TERMINAL_NOTE}` : line };
509
+ },
510
+ },
192
511
  ];
193
512
 
194
513
  export const RESOURCES = [
195
- { uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => tools.operatingLayer(evda).text },
514
+ { 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${t}`; } },
196
515
  {
197
516
  uri: "evda://today",
198
517
  name: "today",
199
518
  title: "Today's daily file",
200
519
  mimeType: "text/markdown",
201
- read: (evda) => {
202
- const r = tools.recent(evda, { days: 1 });
520
+ read: (evda, ctx) => {
521
+ const r = tools.recent(evda, { days: 1 }, { caller: ctx.caller });
203
522
  const daily = r.files.find((f) => /(^|\/)\d{4}-\d{2}-\d{2}\.md$/.test(f.path) && f.date === r.to);
204
- return daily ? daily.text : `# ${r.to}\n\nNothing written today yet.\n`;
523
+ if (daily) return daily.text;
524
+ return r.heldBack ? `# ${r.to}\n\n${r.heldBack}\n` : `# ${r.to}\n\nNothing written today yet.\n`;
205
525
  },
206
526
  },
207
527
  ];
208
528
 
529
+ // The nudge (HQ-1376), for the apps with no Stop hook (the Claude desktop app, ChatGPT): the
530
+ // server counts, per session (one stdio process is one session), what was read and what was filed.
531
+ // Once the session has read the record or been handed the owner's content and filed nothing, a tool
532
+ // result carries one line saying so, at most once every NUDGE_EVERY calls, and never after a write.
533
+ // EVDA_RECORD_FALSIFY=no-nudge turns it off, for the test that shows the nudge is what files.
534
+ export const NUDGE = "Nothing from this conversation is filed yet: file their words with evda_write before you finish.";
535
+ export const NUDGE_EVERY = 3;
536
+ const READS = new Set(["evda_recent", "evda_search", "evda_read"]);
537
+ const RECEIVED = new Set(["evda_import", "evda_gather_answer"]);
538
+ const WRITES = new Set(["evda_write", "evda_note"]);
539
+
540
+ export function newSession() {
541
+ return { calls: 0, reads: 0, received: 0, writes: 0, lastNudge: -Infinity };
542
+ }
543
+
544
+ // Count one tool call and say whether its result carries the nudge.
545
+ // The call that reads is not nudged itself: the line goes on the results after it, which is when
546
+ // the owner has had a turn to say something worth filing.
547
+ // An owner who chose to file only when they ask (filing "asked", HQ-1385) is never nudged.
548
+ export function countCall(session, name, { env = process.env, filing = null } = {}) {
549
+ session.calls += 1;
550
+ const before = session.reads + session.received;
551
+ if (WRITES.has(name)) { session.writes += 1; return false; }
552
+ if (READS.has(name)) session.reads += 1;
553
+ if (RECEIVED.has(name)) session.received += 1;
554
+ if (session.writes > 0 || before === 0 || filing === "asked") return false;
555
+ if ((env.EVDA_RECORD_FALSIFY ?? "").split(",").map((m) => m.trim()).includes("no-nudge")) return false;
556
+ if (session.calls - session.lastNudge < NUDGE_EVERY) return false;
557
+ session.lastNudge = session.calls;
558
+ return true;
559
+ }
560
+
561
+ // The owner's filing answer as the record says it now, or null when it cannot be read.
562
+ function filingNow(ctx) {
563
+ try { return filingOf(ctx.evda().manifest); } catch { return null; }
564
+ }
565
+
209
566
  const RPC = { PARSE: -32700, INVALID: -32600, METHOD: -32601, PARAMS: -32602, INTERNAL: -32603 };
210
567
 
211
- // One request in, one response out (or null for a notification). Pure apart from the tools' own
212
- // file writes, so tests drive it without a process.
213
- export function handle(msg, ctx) {
568
+ // One request in, one response out (or null for a notification), as a promise: evda_check_update
569
+ // awaits the registry, and every other tool still runs to completion synchronously inside its own
570
+ // call. Pure apart from the tools' own file writes, so tests drive it without a process.
571
+ export async function handle(msg, ctx) {
214
572
  const isRequest = msg && typeof msg === "object" && msg.jsonrpc === "2.0" && typeof msg.method === "string";
215
573
  const hasId = msg && Object.prototype.hasOwnProperty.call(msg, "id") && msg.id !== null;
216
574
  if (!isRequest) return hasId || !msg ? error(msg?.id ?? null, RPC.INVALID, "not a JSON-RPC 2.0 request") : null;
@@ -221,28 +579,39 @@ export function handle(msg, ctx) {
221
579
  switch (msg.method) {
222
580
  case "initialize": {
223
581
  const asked = p.protocolVersion;
582
+ // Who is calling, for every read that follows: the client's own name, or $EVDA_READER.
583
+ ctx.caller = callerFrom({ clientName: p.clientInfo?.name ?? null, env: ctx.env ?? process.env });
224
584
  return ok({
225
585
  protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
226
586
  capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false } },
227
587
  serverInfo: { name: SERVER_NAME, title: "EVDA", version: ctx.version },
228
- instructions: INSTRUCTIONS,
588
+ instructions: instructions(filingNow(ctx)),
229
589
  });
230
590
  }
231
591
  case "ping":
232
592
  return ok({});
233
593
  case "tools/list":
234
- return ok({ tools: TOOLS.map(({ run, ...t }) => t) });
594
+ return ok({ tools: toolList(filingNow(ctx), ctx.env ?? process.env) });
235
595
  case "tools/call": {
236
596
  const tool = TOOLS.find((t) => t.name === p.name);
237
597
  if (!tool) return error(msg.id, RPC.PARAMS, `unknown tool ${JSON.stringify(p.name)}`);
238
598
  const args = p.arguments ?? {};
239
599
  if (typeof args !== "object" || Array.isArray(args)) return error(msg.id, RPC.PARAMS, "arguments must be an object");
600
+ // EVDA_MCP_FALSIFY=log-args: a debug line with the arguments, the leak test/privacy.test.mjs exists to catch.
601
+ if (FALSIFY("log-args", process.env)) ctx.log?.(`debug ${p.name} ${JSON.stringify(args)}`);
240
602
  try {
241
- const r = tool.run(ctx.evda(), args);
603
+ if (!ctx.caller) ctx.caller = callerFrom({ env: ctx.env ?? process.env });
604
+ ctx.session ??= newSession();
605
+ const evda = ctx.evda();
606
+ const r = await tool.run(evda, args, ctx);
607
+ const nudge = countCall(ctx.session, tool.name, { filing: filingOf(evda.manifest) }) ? `\n\n${NUDGE}` : "";
608
+ // A process run twice is pointed at once (HQ-1377), so an agent in an app with no memory of
609
+ // its own still writes it down as a skill. Only a call that did its work counts as a step.
610
+ const repeat = r.isError ? null : noticeRepeat(ctx.session, tool.name);
242
611
  // Text only. A client that finds structuredContent may show the model that instead of the text
243
612
  // (Claude Code did, 2026-09-17: evda_operating_layer reached the model as {"path":"AGENTS.md"}),
244
613
  // so everything the model needs, paths included, is in the text.
245
- return ok({ content: [{ type: "text", text: r.text }], isError: Boolean(r.isError) });
614
+ return ok({ content: [{ type: "text", text: `${r.text}${nudge}${repeat ? `\n\n${repeat}` : ""}` }], isError: Boolean(r.isError) });
246
615
  } catch (e) {
247
616
  // A refusal is a tool result the model reads and acts on, not a protocol failure.
248
617
  if (e instanceof EvdaError) return ok({ content: [{ type: "text", text: e.message }], isError: true });
@@ -254,7 +623,9 @@ export function handle(msg, ctx) {
254
623
  case "resources/read": {
255
624
  const res = RESOURCES.find((r) => r.uri === p.uri);
256
625
  if (!res) return error(msg.id, -32002, `resource not found: ${p.uri}`);
257
- return ok({ contents: [{ uri: res.uri, mimeType: res.mimeType, text: res.read(ctx.evda()) }] });
626
+ if (!ctx.caller) ctx.caller = callerFrom({ env: ctx.env ?? process.env });
627
+ if (res.uri === "evda://today") (ctx.session ??= newSession()).reads += 1;
628
+ return ok({ contents: [{ uri: res.uri, mimeType: res.mimeType, text: res.read(ctx.evda(), ctx) }] });
258
629
  }
259
630
  case "resources/templates/list":
260
631
  return ok({ resourceTemplates: [] });
@@ -276,11 +647,18 @@ function error(id, code, message) {
276
647
  // Serve on stdio until stdin closes. `dir` is the located record; it is reopened for every call so
277
648
  // a record edited by hand between calls is read as it is now.
278
649
  export function serve({ dir, version, input = process.stdin, output = process.stdout, log = (s) => process.stderr.write(`[evda mcp] ${s}\n`) }) {
279
- const ctx = { version, log, evda: () => openEvda(dir) };
650
+ const ctx = { version, log, evda: () => openEvda(dir), session: newSession() };
280
651
  openEvda(dir); // refuse to start on something that is not a record
281
652
  log(`serving ${dir} (tool schema ${SCHEMA_VERSION})`);
282
653
  const rl = createInterface({ input, crlfDelay: Infinity });
283
654
  const send = (m) => output.write(`${JSON.stringify(m)}\n`);
655
+ // What is still being answered, so a stdin that closes mid-call does not cut the answer off.
656
+ const inFlight = new Set();
657
+ const track = (p) => {
658
+ const settled = p.catch((e) => log(e?.stack ?? String(e)));
659
+ inFlight.add(settled);
660
+ settled.finally(() => inFlight.delete(settled));
661
+ };
284
662
  rl.on("line", (line) => {
285
663
  if (line.trim() === "") return;
286
664
  let msg;
@@ -289,13 +667,24 @@ export function serve({ dir, version, input = process.stdin, output = process.st
289
667
  } catch {
290
668
  return send(error(null, RPC.PARSE, "parse error"));
291
669
  }
670
+ // A response carries the id it answers, so a tool that awaits the network holds up nothing else.
671
+ const answer = (m) => handle(m, ctx).catch((e) => {
672
+ log(e?.stack ?? String(e));
673
+ return error(m?.id ?? null, RPC.INTERNAL, "internal error; the server's stderr has the detail");
674
+ });
292
675
  if (Array.isArray(msg)) {
293
- const out = msg.map((m) => handle(m, ctx)).filter(Boolean);
294
- if (out.length) send(out);
676
+ track(Promise.all(msg.map(answer)).then((out) => {
677
+ const answers = out.filter(Boolean);
678
+ if (answers.length) send(answers);
679
+ }));
295
680
  return;
296
681
  }
297
- const r = handle(msg, ctx);
298
- if (r) send(r);
682
+ track(answer(msg).then((r) => {
683
+ if (r) send(r);
684
+ }));
685
+ });
686
+ const closed = new Promise((resolveDone) => rl.on("close", resolveDone));
687
+ return closed.then(async () => {
688
+ await Promise.all(inFlight);
299
689
  });
300
- return new Promise((resolveDone) => rl.on("close", resolveDone));
301
690
  }