create-evda 0.1.3 → 0.1.5
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/README.md +58 -3
- package/bin/evda.mjs +117 -1
- package/create-evda.mjs +39 -3
- package/lib/clients.mjs +48 -0
- package/lib/connect.mjs +84 -0
- package/lib/doctor.mjs +5 -6
- package/lib/folders.mjs +22 -8
- package/lib/mcp.mjs +301 -0
- package/lib/organize.mjs +224 -0
- package/lib/presets.mjs +328 -0
- package/lib/routines.mjs +143 -0
- package/lib/setup.mjs +76 -11
- package/lib/tools.mjs +274 -0
- package/package.json +6 -3
- package/presets/journal/preset.json +11 -0
- package/presets/projects-and-areas/preset.json +27 -0
- package/presets/student/preset.json +13 -0
- package/skills/evda/SKILL.md +88 -0
- package/templates/AGENTS.md +12 -2
- package/templates/evda-digest.md +3 -0
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// `evda mcp`: the record as an MCP server over stdio, so the AI someone already uses (Claude Code,
|
|
2
|
+
// Codex, the Claude desktop app) can read and write their EVDA.
|
|
3
|
+
//
|
|
4
|
+
// No SDK. The stdio transport is newline-delimited JSON-RPC 2.0 on stdin and stdout, with logs on
|
|
5
|
+
// stderr, and a server that only answers requests needs initialize, ping, tools and resources. That
|
|
6
|
+
// is small enough to write here and keep the package at zero runtime dependencies
|
|
7
|
+
// (test/deps.test.mjs), which is what lets `npx create-evda` install nothing but itself.
|
|
8
|
+
//
|
|
9
|
+
// stdout carries protocol messages only. Nothing in this file or the tools it calls prints to
|
|
10
|
+
// stdout; doctor's report is captured into the tool result.
|
|
11
|
+
|
|
12
|
+
import { createInterface } from "node:readline";
|
|
13
|
+
import { SCHEMA_VERSION } from "./manifest.mjs";
|
|
14
|
+
import { openEvda } from "./manifest.mjs";
|
|
15
|
+
import { EvdaError } from "./errors.mjs";
|
|
16
|
+
import * as tools from "./tools.mjs";
|
|
17
|
+
import * as presets from "./presets.mjs";
|
|
18
|
+
|
|
19
|
+
// Newest first. The server answers with the client's version when it supports it, and with the
|
|
20
|
+
// newest otherwise; the client decides whether to continue.
|
|
21
|
+
export const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
22
|
+
export const SERVER_NAME = "evda";
|
|
23
|
+
|
|
24
|
+
const INSTRUCTIONS = [
|
|
25
|
+
"This server is the owner's EVDA: their record, in plain files on this machine.",
|
|
26
|
+
"Call evda_operating_layer first and follow it for the whole session: it is who you are (Iris unless they renamed you) and how the record is kept.",
|
|
27
|
+
"File the owner's words verbatim with evda_write in the same turn they say them, and report the path it returns in the past tense.",
|
|
28
|
+
"Never quiz them. Never create a circle or grant; propose one with evda_propose_circle only when they ask to share, and wait for their yes.",
|
|
29
|
+
"Organization comes from the preset library (evda_presets): propose with evda_propose_structure, and apply with evda_apply_preset only after their yes.",
|
|
30
|
+
].join(" ");
|
|
31
|
+
|
|
32
|
+
const str = (description) => ({ type: "string", description });
|
|
33
|
+
|
|
34
|
+
export const TOOLS = [
|
|
35
|
+
{
|
|
36
|
+
name: "evda_operating_layer",
|
|
37
|
+
title: "Read the operating layer",
|
|
38
|
+
description: "Returns AGENTS.md, the record's operating layer: who the agent is, what this EVDA is for in the owner's words, and how to keep the record. Read it first, every session.",
|
|
39
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
40
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
41
|
+
run: (evda, args) => {
|
|
42
|
+
const r = tools.operatingLayer(evda, args);
|
|
43
|
+
return { text: r.text, data: { path: r.path } };
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "evda_recent",
|
|
48
|
+
title: "Read the last few days",
|
|
49
|
+
description: "Returns every record file dated in the last N days (default 3), newest first, whole.",
|
|
50
|
+
inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 90, description: "How many days back, today included. Default 3." } }, additionalProperties: false },
|
|
51
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
52
|
+
run: (evda, args) => {
|
|
53
|
+
const r = tools.recent(evda, args);
|
|
54
|
+
const body = r.files.map((f) => `<file path="${f.path}" date="${f.date}">\n${f.text}\n</file>`).join("\n\n");
|
|
55
|
+
const head = `${r.files.length} file(s) dated ${r.from} to ${r.to}${r.omitted ? `, ${r.omitted} more omitted for size (read them with evda_search)` : ""}.`;
|
|
56
|
+
return { text: r.files.length ? `${head}\n\n${body}` : `Nothing in the record is dated ${r.from} to ${r.to}.`, data: { from: r.from, to: r.to, paths: r.files.map((f) => f.path), omitted: r.omitted } };
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "evda_write",
|
|
61
|
+
title: "File the owner's words",
|
|
62
|
+
description: "Appends the owner's words, exactly as they said them, to today's daily file under the local time, and returns the path. Append only: it takes no path and never edits or replaces anything. Refuses anything shaped like a password, key or token.",
|
|
63
|
+
inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim: filler, hedges and typos intact. Never a summary.") }, required: ["words"], additionalProperties: false },
|
|
64
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
65
|
+
run: (evda, args) => {
|
|
66
|
+
const r = tools.write(evda, args);
|
|
67
|
+
return { text: `Filed to ${r.path}.`, data: r };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "evda_import",
|
|
72
|
+
title: "File an import from another AI",
|
|
73
|
+
description: "Files what another AI (ChatGPT, Claude, Gemini...) said it knows about the owner, verbatim, under imports/ with its source and date, and returns the path. An import is not the owner's words; each line is a claim to check with them over time.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
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.") },
|
|
77
|
+
required: ["text", "source"],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
81
|
+
run: (evda, args) => {
|
|
82
|
+
const r = tools.importText(evda, args);
|
|
83
|
+
return { text: `Filed the import to ${r.path}.`, data: r };
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "evda_search",
|
|
88
|
+
title: "Search the record",
|
|
89
|
+
description: "Plain text search, case-insensitive, over the record, its imports and the ledger, on this machine. Nothing leaves it. Returns each matching line with its path and line number.",
|
|
90
|
+
inputSchema: { type: "object", properties: { query: str("Words to find."), limit: { type: "integer", minimum: 1, maximum: 500, description: "Most lines to return. Default 50." } }, required: ["query"], additionalProperties: false },
|
|
91
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
92
|
+
run: (evda, args) => {
|
|
93
|
+
const r = tools.search(evda, args);
|
|
94
|
+
const lines = r.matches.map((m) => `${m.path}:${m.line}: ${m.text}`);
|
|
95
|
+
const tail = r.more ? `\n${r.more} more match(es) past the limit.` : "";
|
|
96
|
+
return { text: r.matches.length ? `${r.matches.length} match(es) in ${r.searched} file(s):\n${lines.join("\n")}${tail}` : `No match for ${JSON.stringify(r.query)} in ${r.searched} file(s).`, data: r };
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "evda_circles",
|
|
101
|
+
title: "List circles",
|
|
102
|
+
description: "Lists the record's circles (the parts the owner chose to share), who may read each, what it covers and its policy rules. Read only.",
|
|
103
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
104
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
105
|
+
run: (evda, args) => {
|
|
106
|
+
const r = tools.circles(evda, args);
|
|
107
|
+
if (!r.role) return { text: "This record has no circles layer. Everything in it is private.", data: r };
|
|
108
|
+
if (!r.circles.length) return { text: "No circles yet. Everything in the record is private.", data: r };
|
|
109
|
+
const lines = r.circles.map((c) => [
|
|
110
|
+
`${c.name} (${c.public ? "public" : "private"}): ${c.description ?? "no description"}`,
|
|
111
|
+
` members: ${c.members.join(", ") || "none"}; maintainers: ${c.maintainers.join(", ") || "none"}`,
|
|
112
|
+
...c.policy.map((p) => ` rule [${p.status}] ${p.rule}`),
|
|
113
|
+
...c.problems.map((p) => ` problem: ${p}`),
|
|
114
|
+
].join("\n"));
|
|
115
|
+
return { text: lines.join("\n"), data: r };
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "evda_propose_circle",
|
|
120
|
+
title: "Propose a circle",
|
|
121
|
+
description: "Writes a proposal for a circle as a file under proposals/ for the owner to read. It never creates the circle, grants anyone access, or moves an entry: those wait for the owner's explicit yes. Use it only when the owner asks to share something.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
name: str("Short name for the circle: letters, digits, - and _."),
|
|
126
|
+
who: str("Who may read it, in plain words."),
|
|
127
|
+
what: str("What it covers."),
|
|
128
|
+
policy: { type: "array", items: { type: "string" }, description: "Its rules, one sentence each, in the owner's words where possible." },
|
|
129
|
+
why: str("Why the owner wants it, in their words."),
|
|
130
|
+
},
|
|
131
|
+
required: ["name", "who", "what"],
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
},
|
|
134
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
135
|
+
run: (evda, args) => {
|
|
136
|
+
const r = tools.proposeCircle(evda, args);
|
|
137
|
+
return { text: `Wrote the proposal to ${r.path}. No circle was created and nobody was granted anything; that waits for the owner's yes.`, data: r };
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "evda_presets",
|
|
142
|
+
title: "List presets",
|
|
143
|
+
description: "Lists the ways to organize a record that EVDA ships (the preset library), each with who it suits, or with an id shows one preset's folders. Read only. A new folder is proposed from a preset here, never invented.",
|
|
144
|
+
inputSchema: { type: "object", properties: { id: str("A preset id, to show that one. Omit to list them all.") }, additionalProperties: false },
|
|
145
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
146
|
+
run: (evda, args) => {
|
|
147
|
+
const r = presets.presetsTool(evda, args);
|
|
148
|
+
if (r.presets) return { text: r.presets.map((p) => `${p.id}: ${p.title}. ${p.for} (${p.folders} folders)`).join("\n") };
|
|
149
|
+
const p = r.preset;
|
|
150
|
+
return { text: [`${p.id}: ${p.title}. ${p.for}`, ...p.folders.map((f) => `- ${f.path}/ ${f.type ?? "untyped"}${f.connectors?.length ? `, connectors ${f.connectors.join(", ")}` : ""}${f.note ? `: ${f.note}` : ""}`), ...(p.routines?.length ? [`routines it suits: ${p.routines.join(", ")}`] : [])].join("\n") };
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "evda_propose_structure",
|
|
155
|
+
title: "Propose a preset's structure",
|
|
156
|
+
description: "Shows what applying a preset would add to this record, as a plan and a plain-language summary to show the owner. It never writes. Folders that already match are left alone, one that differs is reported and never changed, and nothing is ever moved, renamed or deleted.",
|
|
157
|
+
inputSchema: { type: "object", properties: { id: str("The preset id, from evda_presets.") }, required: ["id"], additionalProperties: false },
|
|
158
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
159
|
+
run: (evda, args) => {
|
|
160
|
+
const r = presets.proposeStructureTool(evda, args);
|
|
161
|
+
return { text: `${r.summary}\n\nPlan:\n${presets.planLines(r.plan).join("\n") || " nothing to add"}` };
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "evda_apply_preset",
|
|
166
|
+
title: "Apply a preset",
|
|
167
|
+
description: "Adds a preset's folders and folder manifests to the record. Call it only after showing the owner evda_propose_structure and the owner said yes to this preset in this conversation; pass confirmed: true only then. It only adds: it never moves, renames, deletes or overwrites, and a circle in the preset becomes a proposal file, never a circle.",
|
|
168
|
+
inputSchema: {
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: { id: str("The preset id the owner said yes to."), confirmed: { type: "boolean", description: "true only when the owner said yes to this preset in this conversation." } },
|
|
171
|
+
required: ["id", "confirmed"],
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
},
|
|
174
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
175
|
+
run: (evda, args) => {
|
|
176
|
+
const r = presets.applyPresetTool(evda, args);
|
|
177
|
+
const n = r.plan.create.length + r.plan.write.length;
|
|
178
|
+
return { text: `${n ? `Applied the ${r.plan.title} preset.` : `Nothing was written: the record already has the ${r.plan.title} preset.`}\n${r.lines.join("\n")}` };
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: "evda_doctor",
|
|
183
|
+
title: "Check the record",
|
|
184
|
+
description: "Runs evda doctor on the record and returns its report. Exit 0 means well formed.",
|
|
185
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
186
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
187
|
+
run: (evda, args) => {
|
|
188
|
+
const r = tools.doctorReport(evda, args);
|
|
189
|
+
return { text: `${r.text}\n\nexit ${r.exit}`, data: { exit: r.exit }, isError: r.exit !== 0 };
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
export const RESOURCES = [
|
|
195
|
+
{ uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => tools.operatingLayer(evda).text },
|
|
196
|
+
{
|
|
197
|
+
uri: "evda://today",
|
|
198
|
+
name: "today",
|
|
199
|
+
title: "Today's daily file",
|
|
200
|
+
mimeType: "text/markdown",
|
|
201
|
+
read: (evda) => {
|
|
202
|
+
const r = tools.recent(evda, { days: 1 });
|
|
203
|
+
const daily = r.files.find((f) => /(^|\/)\d{4}-\d{2}-\d{2}\.md$/.test(f.path) && f.date === r.to);
|
|
204
|
+
return daily ? daily.text : `# ${r.to}\n\nNothing written today yet.\n`;
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
const RPC = { PARSE: -32700, INVALID: -32600, METHOD: -32601, PARAMS: -32602, INTERNAL: -32603 };
|
|
210
|
+
|
|
211
|
+
// One request in, one response out (or null for a notification). Pure apart from the tools' own
|
|
212
|
+
// file writes, so tests drive it without a process.
|
|
213
|
+
export function handle(msg, ctx) {
|
|
214
|
+
const isRequest = msg && typeof msg === "object" && msg.jsonrpc === "2.0" && typeof msg.method === "string";
|
|
215
|
+
const hasId = msg && Object.prototype.hasOwnProperty.call(msg, "id") && msg.id !== null;
|
|
216
|
+
if (!isRequest) return hasId || !msg ? error(msg?.id ?? null, RPC.INVALID, "not a JSON-RPC 2.0 request") : null;
|
|
217
|
+
if (!hasId) return null; // notifications: initialized, cancelled. Nothing to answer.
|
|
218
|
+
const ok = (result) => ({ jsonrpc: "2.0", id: msg.id, result });
|
|
219
|
+
const p = msg.params ?? {};
|
|
220
|
+
try {
|
|
221
|
+
switch (msg.method) {
|
|
222
|
+
case "initialize": {
|
|
223
|
+
const asked = p.protocolVersion;
|
|
224
|
+
return ok({
|
|
225
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
226
|
+
capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false } },
|
|
227
|
+
serverInfo: { name: SERVER_NAME, title: "EVDA", version: ctx.version },
|
|
228
|
+
instructions: INSTRUCTIONS,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
case "ping":
|
|
232
|
+
return ok({});
|
|
233
|
+
case "tools/list":
|
|
234
|
+
return ok({ tools: TOOLS.map(({ run, ...t }) => t) });
|
|
235
|
+
case "tools/call": {
|
|
236
|
+
const tool = TOOLS.find((t) => t.name === p.name);
|
|
237
|
+
if (!tool) return error(msg.id, RPC.PARAMS, `unknown tool ${JSON.stringify(p.name)}`);
|
|
238
|
+
const args = p.arguments ?? {};
|
|
239
|
+
if (typeof args !== "object" || Array.isArray(args)) return error(msg.id, RPC.PARAMS, "arguments must be an object");
|
|
240
|
+
try {
|
|
241
|
+
const r = tool.run(ctx.evda(), args);
|
|
242
|
+
// Text only. A client that finds structuredContent may show the model that instead of the text
|
|
243
|
+
// (Claude Code did, 2026-09-17: evda_operating_layer reached the model as {"path":"AGENTS.md"}),
|
|
244
|
+
// so everything the model needs, paths included, is in the text.
|
|
245
|
+
return ok({ content: [{ type: "text", text: r.text }], isError: Boolean(r.isError) });
|
|
246
|
+
} catch (e) {
|
|
247
|
+
// A refusal is a tool result the model reads and acts on, not a protocol failure.
|
|
248
|
+
if (e instanceof EvdaError) return ok({ content: [{ type: "text", text: e.message }], isError: true });
|
|
249
|
+
throw e;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
case "resources/list":
|
|
253
|
+
return ok({ resources: RESOURCES.map(({ read, ...r }) => r) });
|
|
254
|
+
case "resources/read": {
|
|
255
|
+
const res = RESOURCES.find((r) => r.uri === p.uri);
|
|
256
|
+
if (!res) return error(msg.id, -32002, `resource not found: ${p.uri}`);
|
|
257
|
+
return ok({ contents: [{ uri: res.uri, mimeType: res.mimeType, text: res.read(ctx.evda()) }] });
|
|
258
|
+
}
|
|
259
|
+
case "resources/templates/list":
|
|
260
|
+
return ok({ resourceTemplates: [] });
|
|
261
|
+
case "prompts/list":
|
|
262
|
+
return ok({ prompts: [] });
|
|
263
|
+
default:
|
|
264
|
+
return error(msg.id, RPC.METHOD, `method not found: ${msg.method}`);
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
ctx.log?.(e.stack ?? String(e));
|
|
268
|
+
return error(msg.id, RPC.INTERNAL, e instanceof EvdaError ? e.message : "internal error; the server's stderr has the detail");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function error(id, code, message) {
|
|
273
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Serve on stdio until stdin closes. `dir` is the located record; it is reopened for every call so
|
|
277
|
+
// a record edited by hand between calls is read as it is now.
|
|
278
|
+
export function serve({ dir, version, input = process.stdin, output = process.stdout, log = (s) => process.stderr.write(`[evda mcp] ${s}\n`) }) {
|
|
279
|
+
const ctx = { version, log, evda: () => openEvda(dir) };
|
|
280
|
+
openEvda(dir); // refuse to start on something that is not a record
|
|
281
|
+
log(`serving ${dir} (tool schema ${SCHEMA_VERSION})`);
|
|
282
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
283
|
+
const send = (m) => output.write(`${JSON.stringify(m)}\n`);
|
|
284
|
+
rl.on("line", (line) => {
|
|
285
|
+
if (line.trim() === "") return;
|
|
286
|
+
let msg;
|
|
287
|
+
try {
|
|
288
|
+
msg = JSON.parse(line);
|
|
289
|
+
} catch {
|
|
290
|
+
return send(error(null, RPC.PARSE, "parse error"));
|
|
291
|
+
}
|
|
292
|
+
if (Array.isArray(msg)) {
|
|
293
|
+
const out = msg.map((m) => handle(m, ctx)).filter(Boolean);
|
|
294
|
+
if (out.length) send(out);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const r = handle(msg, ctx);
|
|
298
|
+
if (r) send(r);
|
|
299
|
+
});
|
|
300
|
+
return new Promise((resolveDone) => rl.on("close", resolveDone));
|
|
301
|
+
}
|
package/lib/organize.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// `evda organize`: go through what the owner left inward and say where it belongs. Moves nothing.
|
|
2
|
+
//
|
|
3
|
+
// The mechanical part is deterministic and done here:
|
|
4
|
+
// - each dated capture is copied, verbatim, into the daily file of its date, once. A marker comment
|
|
5
|
+
// in the daily file records the copy, so a second run files nothing twice and a rebuilt .evda/
|
|
6
|
+
// cannot make it forget.
|
|
7
|
+
// - the report lists what needs a person's judgment: people a recent note mentions whose file has
|
|
8
|
+
// nothing from that note, imports to check, undated notes, and notes that sit under no role.
|
|
9
|
+
// The report is appended to reports/organize/<date>.md. Nothing is moved, renamed or deleted, and
|
|
10
|
+
// nothing is written into circles/ or grants/.
|
|
11
|
+
//
|
|
12
|
+
// The judgment part runs through the owner's own AI (`--with-ai`): `claude -p` with the evda skill
|
|
13
|
+
// and read-only EVDA tools, given the report. Its answer is appended to the same report. EVDA holds
|
|
14
|
+
// no key and calls no model; `claude` is the owner's install, signed in as the owner.
|
|
15
|
+
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { resolveRole, resolveAll } from "./roles.mjs";
|
|
20
|
+
import { mdFiles, fileDate } from "./record.mjs";
|
|
21
|
+
import { secretShape } from "./references.mjs";
|
|
22
|
+
import { importsDir, localDate, localTime } from "./tools.mjs";
|
|
23
|
+
import { serverCommand, SKILL_SOURCE } from "./connect.mjs";
|
|
24
|
+
import { SERVER_NAME } from "./clients.mjs";
|
|
25
|
+
import { EvdaError } from "./errors.mjs";
|
|
26
|
+
|
|
27
|
+
export const REPORTS = "reports";
|
|
28
|
+
const marker = (rel) => `<!-- evda:capture ${rel} -->`;
|
|
29
|
+
const posix = (p) => p.split("\\").join("/");
|
|
30
|
+
|
|
31
|
+
function stripFrontmatter(text) {
|
|
32
|
+
if (!text.startsWith("---\n")) return text;
|
|
33
|
+
const end = text.indexOf("\n---", 3);
|
|
34
|
+
if (end === -1) return text;
|
|
35
|
+
return text.slice(text.indexOf("\n", end + 1) + 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function secretLine(text) {
|
|
39
|
+
for (const line of text.split("\n")) {
|
|
40
|
+
const s = secretShape(line);
|
|
41
|
+
if (s) return s;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A person's names: the first `# ` heading and the file's stem with - and _ as spaces.
|
|
47
|
+
function personNames(abs, text) {
|
|
48
|
+
const names = new Set();
|
|
49
|
+
const h = /^# (.+)$/m.exec(text);
|
|
50
|
+
if (h) names.add(h[1].trim());
|
|
51
|
+
names.add(basename(abs, ".md").replace(/[-_]+/g, " ").trim());
|
|
52
|
+
return [...names].filter((n) => n.length >= 2);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const mentions = (text, name) => new RegExp(`(^|[^\\p{L}\\p{N}])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\p{L}\\p{N}])`, "iu").test(text);
|
|
56
|
+
|
|
57
|
+
// What organize would do and has to say, without writing. `window` is how many days back a note
|
|
58
|
+
// counts as recent for the people check.
|
|
59
|
+
export function plan(evda, { now = new Date(), window = 7 } = {}) {
|
|
60
|
+
const rel = (abs) => posix(relative(evda.dir, abs));
|
|
61
|
+
const daily = resolveRole(evda, "record/daily");
|
|
62
|
+
const captures = resolveRole(evda, "record/captures");
|
|
63
|
+
const people = resolveRole(evda, "record/people");
|
|
64
|
+
if (!daily.mapped || !daily.exists) throw new EvdaError('organize needs a "record/daily" role that exists; it files captures into their day.', { code: "NO_ROLE", exit: 2 });
|
|
65
|
+
const start = new Date(now);
|
|
66
|
+
start.setDate(start.getDate() - (window - 1));
|
|
67
|
+
const from = localDate(start);
|
|
68
|
+
const out = { filings: [], people: [], imports: [], undated: [], unplaced: [], refused: [], examined: 0 };
|
|
69
|
+
|
|
70
|
+
// Captures into their day.
|
|
71
|
+
if (captures.mapped && captures.exists) {
|
|
72
|
+
for (const abs of mdFiles(captures.abs)) {
|
|
73
|
+
out.examined += 1;
|
|
74
|
+
const text = readFileSync(abs, "utf8");
|
|
75
|
+
const date = fileDate(abs, text);
|
|
76
|
+
if (!date) { out.undated.push(rel(abs)); continue; }
|
|
77
|
+
const secret = secretLine(text);
|
|
78
|
+
if (secret) { out.refused.push({ path: rel(abs), why: `holds ${secret}; not copied` }); continue; }
|
|
79
|
+
const target = join(daily.abs, `${date}.md`);
|
|
80
|
+
if (existsSync(target) && readFileSync(target, "utf8").includes(marker(rel(abs)))) continue;
|
|
81
|
+
out.filings.push({ from: rel(abs), to: rel(target), date, body: stripFrontmatter(text).replace(/\n+$/, "") });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const abs of mdFiles(daily.abs)) {
|
|
85
|
+
out.examined += 1;
|
|
86
|
+
if (!fileDate(abs, readFileSync(abs, "utf8"))) out.undated.push(rel(abs));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// People a recent note mentions, whose file carries nothing from that note.
|
|
90
|
+
if (people.mapped && people.exists) {
|
|
91
|
+
const recent = [...mdFiles(daily.abs), ...(captures.mapped && captures.exists ? mdFiles(captures.abs) : [])]
|
|
92
|
+
.map((abs) => ({ abs, text: readFileSync(abs, "utf8") }))
|
|
93
|
+
.filter((f) => { const d = fileDate(f.abs, f.text); return d && d >= from; });
|
|
94
|
+
for (const p of mdFiles(people.abs)) {
|
|
95
|
+
out.examined += 1;
|
|
96
|
+
const ptext = readFileSync(p, "utf8");
|
|
97
|
+
for (const name of personNames(p, ptext)) {
|
|
98
|
+
for (const f of recent) {
|
|
99
|
+
if (!mentions(f.text, name)) continue;
|
|
100
|
+
if (ptext.includes(rel(f.abs)) || ptext.includes(basename(f.abs, ".md"))) continue;
|
|
101
|
+
if (out.people.some((x) => x.person === rel(p) && x.note === rel(f.abs))) continue;
|
|
102
|
+
out.people.push({ person: rel(p), name, note: rel(f.abs) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Imports: each is a claim to check with the owner over time. Recent ones are listed.
|
|
109
|
+
const imp = importsDir(evda);
|
|
110
|
+
if (existsSync(imp.abs)) {
|
|
111
|
+
for (const abs of mdFiles(imp.abs)) {
|
|
112
|
+
out.examined += 1;
|
|
113
|
+
const d = fileDate(abs, readFileSync(abs, "utf8"));
|
|
114
|
+
if (d && d >= from) out.imports.push(rel(abs));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Notes under the record's folder that no role or the imports folder claims.
|
|
119
|
+
const claimed = resolveAll(evda).filter((r) => r.mapped && r.exists && r.isDir).map((r) => r.abs);
|
|
120
|
+
claimed.push(imp.abs);
|
|
121
|
+
const root = dirname(daily.abs);
|
|
122
|
+
if (root !== evda.dir && existsSync(root)) {
|
|
123
|
+
const walk = (d) => {
|
|
124
|
+
for (const n of readdirSync(d).sort()) {
|
|
125
|
+
if (n.startsWith(".")) continue;
|
|
126
|
+
const abs = join(d, n);
|
|
127
|
+
if (claimed.includes(abs)) continue;
|
|
128
|
+
if (statSync(abs).isDirectory()) walk(abs);
|
|
129
|
+
else if (n.endsWith(".md")) { out.examined += 1; out.unplaced.push(rel(abs)); }
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk(root);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function reportText(p, { now = new Date(), window = 7 } = {}) {
|
|
138
|
+
const lines = [`## Run ${localTime(now)}`, ""];
|
|
139
|
+
lines.push(`Examined ${p.examined} note(s). Moved nothing.`, "");
|
|
140
|
+
lines.push("### Filed");
|
|
141
|
+
if (p.filings.length) for (const f of p.filings) lines.push(`- ${f.from} copied verbatim into ${f.to}`);
|
|
142
|
+
else lines.push("- Nothing new to file.");
|
|
143
|
+
lines.push("", `### People mentioned in the last ${window} days`);
|
|
144
|
+
if (p.people.length) for (const x of p.people) lines.push(`- ${x.note} mentions ${x.name}; ${x.person} has nothing from it yet.`);
|
|
145
|
+
else lines.push("- None without a note on their file.");
|
|
146
|
+
lines.push("", "### Imports to check with the owner over time");
|
|
147
|
+
if (p.imports.length) for (const i of p.imports) lines.push(`- ${i}: what another AI said, not their words.`);
|
|
148
|
+
else lines.push("- None recent.");
|
|
149
|
+
if (p.undated.length) { lines.push("", "### Undated, so not filed"); for (const u of p.undated) lines.push(`- ${u}: needs a YYYY-MM-DD at the start of its name, or a date: line.`); }
|
|
150
|
+
if (p.unplaced.length) { lines.push("", "### Under no role"); for (const u of p.unplaced) lines.push(`- ${u}: not in daily, captures, people or imports.`); }
|
|
151
|
+
if (p.refused.length) { lines.push("", "### Not copied"); for (const r of p.refused) lines.push(`- ${r.path}: ${r.why}. Keep the secret in a password manager and take it out of the note.`); }
|
|
152
|
+
lines.push("");
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function appendReport(evda, kind, date, block) {
|
|
157
|
+
const dir = join(evda.dir, REPORTS, kind);
|
|
158
|
+
mkdirSync(dir, { recursive: true });
|
|
159
|
+
const abs = join(dir, `${date}.md`);
|
|
160
|
+
if (!existsSync(abs)) writeFileSync(abs, `# ${kind === "organize" ? "Organize" : "Look back"}, ${date}\n\nWritten by evda ${kind}. Append only; each run adds a section.\n`);
|
|
161
|
+
appendFileSync(abs, `\n${block.replace(/\n+$/, "")}\n`);
|
|
162
|
+
return posix(relative(evda.dir, abs));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Run the owner's claude -p with the skill and read-only EVDA tools. Returns { ok, text, why }.
|
|
166
|
+
export function askOwnersAi(evda, prompt, { model = null, env = process.env, timeoutMs = 600_000 } = {}) {
|
|
167
|
+
const bin = env.EVDA_CLAUDE || "claude";
|
|
168
|
+
const { command, args } = serverCommand(evda.dir);
|
|
169
|
+
const mcp = JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "stdio", command, args } } });
|
|
170
|
+
const allowed = ["evda_operating_layer", "evda_recent", "evda_search", "evda_circles"].map((t) => `mcp__${SERVER_NAME}__${t}`);
|
|
171
|
+
const skill = existsSync(SKILL_SOURCE) ? readFileSync(SKILL_SOURCE, "utf8") : "";
|
|
172
|
+
const argv = ["-p", prompt, "--mcp-config", mcp, "--strict-mcp-config", "--allowedTools", allowed.join(","), "--output-format", "text"];
|
|
173
|
+
if (skill) argv.push("--append-system-prompt", skill);
|
|
174
|
+
if (model) argv.push("--model", model);
|
|
175
|
+
const r = spawnSync(bin, argv, { cwd: evda.dir, env, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
|
|
176
|
+
if (r.error) return { ok: false, why: r.error.code === "ENOENT" ? `${bin} is not installed or not on PATH` : String(r.error.message) };
|
|
177
|
+
if (r.status !== 0) return { ok: false, why: `${bin} -p exited ${r.status}: ${(r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" ")}` };
|
|
178
|
+
const text = (r.stdout ?? "").trim();
|
|
179
|
+
if (!text) return { ok: false, why: `${bin} -p answered with nothing` };
|
|
180
|
+
return { ok: true, text };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function organize(evda, { now = new Date(), window = 7, withAi = false, dryRun = false, model = null, env = process.env } = {}) {
|
|
184
|
+
const p = plan(evda, { now, window });
|
|
185
|
+
const date = localDate(now);
|
|
186
|
+
let report = reportText(p, { now, window });
|
|
187
|
+
if (dryRun) return { plan: p, report, path: null, exit: 0, summary: `${report}\n(dry run: nothing was written)` };
|
|
188
|
+
|
|
189
|
+
for (const f of p.filings) {
|
|
190
|
+
const abs = join(evda.dir, f.to);
|
|
191
|
+
const head = existsSync(abs) ? "" : `# ${f.date}\n`;
|
|
192
|
+
const lead = existsSync(abs) && !readFileSync(abs, "utf8").endsWith("\n") ? "\n" : "";
|
|
193
|
+
appendFileSync(abs, `${lead}${head}\n## Capture: ${basename(f.from, ".md")}\n\n${marker(f.from)}\n\n${f.body}\n`);
|
|
194
|
+
}
|
|
195
|
+
let exit = 0;
|
|
196
|
+
if (withAi) {
|
|
197
|
+
const ai = askOwnersAi(evda, [
|
|
198
|
+
"Use the evda skill's Organizing section. Here is today's organize report for this record.",
|
|
199
|
+
"For each note that needs a place, answer in one line: the note's path, where it belongs, and the words from the note that say so, quoted.",
|
|
200
|
+
"Check against the record with the evda tools first. Write nothing. If nothing needs a place, say so in one line.",
|
|
201
|
+
"",
|
|
202
|
+
report,
|
|
203
|
+
].join("\n"), { model, env });
|
|
204
|
+
if (ai.ok) {
|
|
205
|
+
const secret = secretLine(ai.text);
|
|
206
|
+
report += secret ? `\n### Your AI's read\n\nNot kept: the answer held ${secret}.\n` : `\n### Your AI's read (claude -p)\n\n${ai.text}\n`;
|
|
207
|
+
} else {
|
|
208
|
+
report += `\n### Your AI's read\n\nSKIPPED: ${ai.why}. Nothing else in this run depended on it.\n`;
|
|
209
|
+
exit = 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const path = appendReport(evda, "organize", date, report);
|
|
213
|
+
return { plan: p, report, path, exit, summary: `${report}\nReport appended to ${path}. Filed ${p.filings.length} capture(s).` };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function lookBack(evda, { now = new Date(), model = null, env = process.env } = {}) {
|
|
217
|
+
const ai = askOwnersAi(evda, "Use the evda skill's Look back section for the last 7 days of this record. Write nothing; your answer is appended to a report.", { model, env });
|
|
218
|
+
const date = localDate(now);
|
|
219
|
+
const block = ai.ok
|
|
220
|
+
? (secretLine(ai.text) ? `## Run ${localTime(now)}\n\nNot kept: the answer held a secret-shaped line.\n` : `## Run ${localTime(now)}\n\n${ai.text}\n`)
|
|
221
|
+
: `## Run ${localTime(now)}\n\nSKIPPED: ${ai.why}.\n`;
|
|
222
|
+
const path = appendReport(evda, "look-back", date, block);
|
|
223
|
+
return { path, exit: ai.ok ? 0 : 1, summary: `${block}\nAppended to ${path}.` };
|
|
224
|
+
}
|