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