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.
- package/CHANGELOG.md +100 -0
- package/README.md +74 -7
- package/bin/evda.mjs +391 -6
- package/create-evda.mjs +54 -10
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +346 -13
- package/lib/filechanges.mjs +622 -0
- package/lib/gather-apply.mjs +635 -0
- package/lib/gather.mjs +808 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +425 -36
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +52 -12
- package/lib/parity.mjs +111 -0
- package/lib/presets.mjs +58 -7
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +102 -1
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +118 -0
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +87 -0
- package/lib/tools.mjs +118 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +24 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +58 -0
- package/templates/AGENTS.md +118 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +30 -6
- package/LICENSE +0 -21
- 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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: "
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
212
|
-
//
|
|
213
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
}
|