create-evda 0.1.3 → 0.1.4

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/lib/tools.mjs ADDED
@@ -0,0 +1,274 @@
1
+ // The record operations behind `evda mcp`: what an AI the owner already uses may do to their EVDA.
2
+ //
3
+ // Every tool here works on plain files on this machine. Nothing is sent anywhere and no model is
4
+ // called. The AI that calls these tools is the owner's own (Claude Code, Codex, the Claude app);
5
+ // EVDA holds no key and makes no request.
6
+ //
7
+ // Three rules hold for every write, and each has a test in test/tools.test.mjs:
8
+ // 1. Append only. A write adds bytes to the end of a file or creates a new one. No tool takes a
9
+ // path to write to, and none replaces what is there.
10
+ // 2. Nothing secret-shaped is written. The words are run through the same check that guards a
11
+ // reference (lib/references.mjs secretShape) and a hit refuses the whole write.
12
+ // 3. Every write says where it landed, as a path relative to the record.
13
+ //
14
+ // A circle is never created here. `propose_circle` writes a proposal file for the owner to read;
15
+ // making the circle, and granting anyone anything, stays the owner's explicit yes through
16
+ // `evda grant` or their own hands.
17
+
18
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
19
+ import { dirname, join, relative } from "node:path";
20
+ import { resolveRole, rolesForLayer, agentsFile } from "./roles.mjs";
21
+ import { mdFiles, fileDate } from "./record.mjs";
22
+ import { readCircles } from "./circles.mjs";
23
+ import { secretShape } from "./references.mjs";
24
+ import { doctor } from "./doctor.mjs";
25
+ import { EvdaError } from "./errors.mjs";
26
+
27
+ const pad = (n) => String(n).padStart(2, "0");
28
+ export const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
29
+ export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
30
+
31
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
32
+ const MAX_WORDS = 200_000;
33
+ const MAX_READ = 256_000;
34
+
35
+ function refuse(message, code = "REFUSED") {
36
+ return new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
37
+ }
38
+
39
+ // Arguments a tool does not take are refused by name, so an agent that tries to pass a path or a
40
+ // replacement learns the tool cannot do that, instead of the key being silently ignored.
41
+ function onlyKeys(tool, args, allowed, hint = "") {
42
+ const extra = Object.keys(args ?? {}).filter((k) => !allowed.includes(k));
43
+ if (extra.length) throw refuse(`${tool} does not take ${extra.map((k) => JSON.stringify(k)).join(", ")}.${hint ? ` ${hint}` : ""}`, "UNKNOWN_ARGUMENT");
44
+ }
45
+
46
+ function text(value, name, tool) {
47
+ if (typeof value !== "string" || value.trim() === "") throw refuse(`${tool} needs "${name}", the text to file.`, "EMPTY");
48
+ if (value.length > MAX_WORDS) throw refuse(`${tool} "${name}" is ${value.length} characters; one write holds at most ${MAX_WORDS}.`, "TOO_LONG");
49
+ return value;
50
+ }
51
+
52
+ // Refuse the write when any line carries something shaped like a password, key or token. The value
53
+ // is never echoed back: the message names the shape and the line number only.
54
+ export function refuseSecrets(value, tool) {
55
+ const lines = value.split("\n");
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const shape = secretShape(lines[i]);
58
+ if (shape) throw refuse(`${tool}: line ${i + 1} holds ${shape}. A password, key or token is never written into the record; keep it in a password manager and point at the item with a reference.`, "SECRET");
59
+ }
60
+ }
61
+
62
+ function rel(evda, abs) {
63
+ return relative(evda.dir, abs).split("\\").join("/");
64
+ }
65
+
66
+ function dailyDir(evda) {
67
+ const r = resolveRole(evda, "record/daily");
68
+ if (!r.mapped) throw refuse('this record maps no "record/daily" role, so there is no daily file to append to.', "NO_ROLE");
69
+ return r;
70
+ }
71
+
72
+ // Where imports go: `imports/` beside the daily role, so `record/daily` gives `record/imports`, and
73
+ // an adopted `journal/days` gives `journal/imports`.
74
+ export function importsDir(evda) {
75
+ const r = dailyDir(evda);
76
+ return { abs: join(dirname(r.abs), "imports"), rel: join(dirname(r.rel), "imports").split("\\").join("/") };
77
+ }
78
+
79
+ // Proposals live at the record root, outside circles/ and grants/, so nothing that reads access
80
+ // can mistake one for a decision.
81
+ export const PROPOSALS = "proposals";
82
+
83
+ // 1. The operating layer, whole. The first thing an agent reads.
84
+ export function operatingLayer(evda, args = {}) {
85
+ onlyKeys("evda_operating_layer", args, []);
86
+ const a = agentsFile(evda);
87
+ if (!a) throw new EvdaError('this record maps no "agents" role, so it has no operating layer.', { code: "NO_ROLE" });
88
+ return { path: a.rel, text: readFileSync(a.abs, "utf8") };
89
+ }
90
+
91
+ // 2. The last `days` days of the record, newest first, whole files, capped in size. A file is in the
92
+ // window by its date (filename, then a date or created key), the same rule `evda moment` uses.
93
+ export function recent(evda, args = {}, { now = new Date() } = {}) {
94
+ onlyKeys("evda_recent", args, ["days"]);
95
+ const days = args.days === undefined ? 3 : Number(args.days);
96
+ if (!Number.isInteger(days) || days < 1 || days > 90) throw new EvdaError("evda_recent days must be a whole number from 1 to 90.", { code: "BAD_ARGUMENT", exit: 2 });
97
+ const start = new Date(now);
98
+ start.setDate(start.getDate() - (days - 1));
99
+ const from = localDate(start);
100
+ const to = localDate(now);
101
+ const files = [];
102
+ for (const abs of recordFiles(evda)) {
103
+ const body = readFileSync(abs, "utf8");
104
+ const date = fileDate(abs, body);
105
+ if (!date || date < from || date > to) continue;
106
+ files.push({ date, path: rel(evda, abs), text: body });
107
+ }
108
+ files.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : b.date.localeCompare(a.date)));
109
+ let used = 0;
110
+ const out = [];
111
+ let truncated = 0;
112
+ for (const f of files) {
113
+ if (used + f.text.length > MAX_READ) { truncated += 1; continue; }
114
+ used += f.text.length;
115
+ out.push(f);
116
+ }
117
+ return { from, to, files: out, omitted: truncated };
118
+ }
119
+
120
+ // Every markdown file the record holds: each record-layer role, and the imports folder. Not the
121
+ // ledger and not the operating layer; those have their own tools.
122
+ function recordFiles(evda) {
123
+ const seen = new Set();
124
+ const out = [];
125
+ const add = (dir) => {
126
+ if (!dir || !existsSync(dir) || !statSync(dir).isDirectory()) return;
127
+ for (const p of mdFiles(dir)) if (!seen.has(p)) { seen.add(p); out.push(p); }
128
+ };
129
+ for (const role of rolesForLayer("record")) {
130
+ const r = resolveRole(evda, role);
131
+ if (r.mapped && r.exists && r.isDir) add(r.abs);
132
+ }
133
+ try { add(importsDir(evda).abs); } catch { /* no daily role, so no imports folder */ }
134
+ return out;
135
+ }
136
+
137
+ // 3. Append the owner's words, verbatim, to today's daily file, under the local time. Creates the
138
+ // file with a date heading when it is the first write of the day. Returns the path.
139
+ export function write(evda, args = {}, { now = new Date() } = {}) {
140
+ onlyKeys("evda_write", args, ["words"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
141
+ const words = text(args.words, "words", "evda_write");
142
+ refuseSecrets(words, "evda_write");
143
+ const r = dailyDir(evda);
144
+ mkdirSync(r.abs, { recursive: true });
145
+ const date = localDate(now);
146
+ const abs = join(r.abs, `${date}.md`);
147
+ const head = existsSync(abs) ? "" : `# ${date}\n`;
148
+ const lead = existsSync(abs) && !readFileSync(abs, "utf8").endsWith("\n") ? "\n" : "";
149
+ appendFileSync(abs, `${lead}${head}\n## ${localTime(now)}\n\n${words.replace(/\n+$/, "")}\n`);
150
+ return { path: rel(evda, abs), bytes: Buffer.byteLength(words) };
151
+ }
152
+
153
+ // 4. File what another AI said about the owner, verbatim, under imports/, labeled with its source
154
+ // and date. A second import from the same source on the same day is appended to that file.
155
+ export function importText(evda, args = {}, { now = new Date() } = {}) {
156
+ onlyKeys("evda_import", args, ["text", "source", "date"], "It files a new import or appends to that day's import from the same source; it never edits one.");
157
+ const body = text(args.text, "text", "evda_import");
158
+ if (typeof args.source !== "string" || args.source.trim() === "") throw refuse('evda_import needs "source": which AI or app said this (ChatGPT, Claude, Gemini...).', "NO_SOURCE");
159
+ const source = args.source.trim().replace(/\s+/g, " ");
160
+ if (source.length > 60) throw refuse('evda_import "source" is a name, at most 60 characters.', "BAD_SOURCE");
161
+ const date = args.date === undefined ? localDate(now) : String(args.date);
162
+ if (!DATE.test(date)) throw refuse('evda_import "date" must be YYYY-MM-DD.', "BAD_DATE");
163
+ refuseSecrets(body, "evda_import");
164
+ refuseSecrets(source, "evda_import");
165
+ const dir = importsDir(evda);
166
+ mkdirSync(dir.abs, { recursive: true });
167
+ const slug = source.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "import";
168
+ const abs = join(dir.abs, `${date}-${slug}.md`);
169
+ const block = `\n## Imported ${localDate(now)} ${localTime(now)}\n\n${body.replace(/\n+$/, "")}\n`;
170
+ if (existsSync(abs)) appendFileSync(abs, block);
171
+ else {
172
+ writeFileSync(abs, [
173
+ `# Import from ${source}, ${date}`,
174
+ "",
175
+ `source: ${source}`,
176
+ `date: ${date}`,
177
+ "",
178
+ `What ${source} said about the owner, filed verbatim. These are not the owner's words. Each line is a claim to check with them over time, not a fact.`,
179
+ "",
180
+ ].join("\n") + block);
181
+ }
182
+ return { path: rel(evda, abs), bytes: Buffer.byteLength(body) };
183
+ }
184
+
185
+ // 5. Plain text search inside the record, on this machine. Case-insensitive substring over every
186
+ // record file, the imports, and the ledger. Returns each matching line with its path and number.
187
+ export function search(evda, args = {}) {
188
+ onlyKeys("evda_search", args, ["query", "limit"]);
189
+ if (typeof args.query !== "string" || args.query.trim() === "") throw new EvdaError('evda_search needs "query".', { code: "BAD_ARGUMENT", exit: 2 });
190
+ const limit = args.limit === undefined ? 50 : Number(args.limit);
191
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new EvdaError("evda_search limit must be a whole number from 1 to 500.", { code: "BAD_ARGUMENT", exit: 2 });
192
+ const needle = args.query.toLowerCase();
193
+ const files = recordFiles(evda);
194
+ const ledger = resolveRole(evda, "ledger");
195
+ if (ledger.mapped && ledger.fileExists) files.push(ledger.file);
196
+ const matches = [];
197
+ let more = 0;
198
+ for (const abs of files) {
199
+ readFileSync(abs, "utf8").split("\n").forEach((line, i) => {
200
+ if (!line.toLowerCase().includes(needle)) return;
201
+ if (matches.length >= limit) { more += 1; return; }
202
+ matches.push({ path: rel(evda, abs), line: i + 1, text: line.length > 400 ? `${line.slice(0, 400)}...` : line });
203
+ });
204
+ }
205
+ return { query: args.query, searched: files.length, matches, more };
206
+ }
207
+
208
+ // 6. Circles and their policies, as the record declares them. Read only.
209
+ export function circles(evda, args = {}) {
210
+ onlyKeys("evda_circles", args, []);
211
+ const all = readCircles(evda);
212
+ if (all === null) return { role: false, circles: [] };
213
+ return {
214
+ role: true,
215
+ circles: all.map((c) => ({
216
+ name: c.name,
217
+ path: c.rel,
218
+ public: c.manifest?.public ?? null,
219
+ description: c.manifest?.description ?? null,
220
+ maintainers: Array.isArray(c.manifest?.maintainers) ? c.manifest.maintainers.map((m) => m?.name).filter(Boolean) : [],
221
+ members: Object.keys(c.members),
222
+ policy: (c.policy?.rules ?? []).map((r) => ({ rule: r.rule, status: r.status, since: r.since })),
223
+ problems: c.problems.filter((p) => p.level === "fail").map((p) => p.message),
224
+ })),
225
+ };
226
+ }
227
+
228
+ // 7. Propose a circle. Writes one new file under proposals/ and nothing else: no circle directory,
229
+ // no eco.yaml, no grant. The owner reads it and says yes or no.
230
+ export function proposeCircle(evda, args = {}, { now = new Date() } = {}) {
231
+ onlyKeys("evda_propose_circle", args, ["name", "who", "what", "policy", "why"], "A proposal is a file for the owner to read. Creating the circle, adding a reader or moving an entry into it waits for their yes and is not done through this tool.");
232
+ const name = typeof args.name === "string" ? args.name.trim() : "";
233
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) throw refuse('evda_propose_circle "name" must be letters, digits, - and _ (it would name the circle folder).', "BAD_NAME");
234
+ const who = text(args.who, "who", "evda_propose_circle");
235
+ const what = text(args.what, "what", "evda_propose_circle");
236
+ const why = args.why === undefined ? "" : text(args.why, "why", "evda_propose_circle");
237
+ const policy = args.policy === undefined ? [] : args.policy;
238
+ if (!Array.isArray(policy) || policy.some((p) => typeof p !== "string" || p.trim() === "")) throw refuse('evda_propose_circle "policy" is a list of rules, one sentence each, in the owner\'s words.', "BAD_POLICY");
239
+ for (const s of [who, what, why, ...policy]) refuseSecrets(s, "evda_propose_circle");
240
+ const date = localDate(now);
241
+ const dir = join(evda.dir, PROPOSALS);
242
+ mkdirSync(dir, { recursive: true });
243
+ let abs = join(dir, `${date}-circle-${name}.md`);
244
+ for (let n = 2; existsSync(abs); n++) abs = join(dir, `${date}-circle-${name}-${n}.md`);
245
+ writeFileSync(abs, [
246
+ `# Proposed circle: ${name}`,
247
+ "",
248
+ `Proposed ${date}. Not a circle yet: nothing was created and nobody was granted anything. It becomes one only when the owner says yes.`,
249
+ "",
250
+ "## Who may read it",
251
+ "",
252
+ who.trim(),
253
+ "",
254
+ "## What it covers",
255
+ "",
256
+ what.trim(),
257
+ "",
258
+ "## Policy",
259
+ "",
260
+ ...(policy.length ? policy.map((p) => `- ${p.trim()}`) : ["(none proposed yet)"]),
261
+ "",
262
+ ...(why ? ["## Why", "", why.trim(), ""] : []),
263
+ ].join("\n"));
264
+ const circlesRole = resolveRole(evda, "circles");
265
+ return { path: rel(evda, abs), circleCreated: false, circlesRole: circlesRole.mapped ? circlesRole.rel : null };
266
+ }
267
+
268
+ // 8. evda doctor, captured. Exit 0 is well formed.
269
+ export function doctorReport(evda, args = {}) {
270
+ onlyKeys("evda_doctor", args, []);
271
+ const lines = [];
272
+ const r = doctor(evda.dir, { out: (l) => lines.push(l), via: "evda mcp" });
273
+ return { exit: r.exit, text: lines.join("\n") };
274
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-evda",
3
- "version": "0.1.3",
4
- "description": "Make your own EVÐA: a running journal of your life with Iris, as plain files on your machine, for any AI. No runtime dependencies.",
3
+ "version": "0.1.4",
4
+ "description": "Make your own EVÐA: a running journal of your life with Iris, as plain files on your machine, for any AI. Connect Claude Code, Codex or the Claude app over MCP. No runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-evda": "create-evda.mjs",
@@ -14,6 +14,7 @@
14
14
  "bin",
15
15
  "lib",
16
16
  "templates",
17
+ "skills",
17
18
  "migrations",
18
19
  "create-evda.mjs",
19
20
  "README.md",
@@ -23,7 +24,8 @@
23
24
  "evda",
24
25
  "personal-record",
25
26
  "agents",
26
- "markdown"
27
+ "markdown",
28
+ "mcp"
27
29
  ],
28
30
  "author": "Mike Veson",
29
31
  "license": "MIT",
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: evda
3
+ description: Use when the person talks to their EVÐA from Claude Code, Codex or the Claude app through the evda MCP server, or says "evda", "Iris", "my record", "journal this", "log this", or freewrites about their day, what is on their mind, a person, or something that felt off. Also use for the scheduled organize and look back routines. Acts as Iris through the evda_* tools, files their words verbatim, and never quizzes.
4
+ ---
5
+
6
+ # EVÐA
7
+
8
+ The person's EVÐA is their record: plain files on their machine, reached through the `evda` MCP
9
+ server. You are the agent it describes (Iris unless they renamed you). EVÐA holds no API key and
10
+ calls no model; you are their own AI, and everything you keep goes through the tools below.
11
+
12
+ ## Every session
13
+
14
+ 1. **Call `evda_operating_layer` first and follow it for the whole session.** It is who you are,
15
+ what this EVÐA is for in their words, how the record is kept, and what they have taught you.
16
+ Where it and this skill disagree, it wins.
17
+ 2. Call `evda_recent` for the last few days, and read what they point you at with `evda_search`.
18
+ 3. Say in one line what you read, then answer them.
19
+
20
+ If the tools are not there, say so plainly and point them at `evda connect`, which prints the
21
+ setup for each client. Do not keep the record some other way.
22
+
23
+ ## Keeping the record
24
+
25
+ - **File their words in the same turn they say them**, with `evda_write`: verbatim, filler, hedges
26
+ and typos intact. Never a summary, never your words.
27
+ - **Report every filing in the past tense, naming the path the tool returned.** Never "I'll file".
28
+ - **`evda_write` only appends.** There is no edit and no rewrite. A correction is a new write.
29
+ - **What another AI said about them is an import**, filed with `evda_import` and its source. It is
30
+ not their words: never quote it back as something they said. Each line is a claim to check.
31
+ - **Never write a password, key or token.** The tools refuse one; tell them to keep it in their
32
+ password manager.
33
+
34
+ ## How to talk
35
+
36
+ - **Never quiz them.** No lists of questions, no check-ins about open items, no "how did X go"
37
+ unless they bring it up. One real question when it helps, at most.
38
+ - Take a position and show why; they push back and you get more specific. Never flatter.
39
+ - Never cite a date, quote or event that is not in the record. Check with `evda_search` first.
40
+
41
+ ## Circles
42
+
43
+ - Everything is private by default.
44
+ - **Only when they ask to share something**, propose a circle with `evda_propose_circle`: who may
45
+ read it, what it covers, a short policy in their words. Tell them the proposal's path and wait.
46
+ - **Never create a circle, grant anyone access, or move an entry into one.** That is their explicit
47
+ yes, done by them (`evda grant`), never by you.
48
+
49
+ ## Organizing (scheduled or asked)
50
+
51
+ `evda organize` does the mechanical part itself: it copies each capture, verbatim, into the daily
52
+ file of its date and writes a report under `reports/organize/`. With `--with-ai` it then asks you,
53
+ through `claude -p`, for the part that needs judgment, and appends your answer to that report.
54
+
55
+ When you are asked for that judgment:
56
+
57
+ 1. Read the report you are given, then use the read tools (`evda_recent`, `evda_search`,
58
+ `evda_circles`) to check each item against the record.
59
+ 2. For each note that needs a place, answer in one line: the note's path, where it belongs (a
60
+ person's file, a project, the ledger only if they already agreed to it), and the words from the
61
+ note that say so, quoted.
62
+ 3. You write nothing yourself here. Move nothing, rename nothing, delete nothing, and never touch
63
+ `circles/` or `grants/`. The owner reads the report and decides.
64
+ 4. If nothing needs a place, say that in one line.
65
+
66
+ ## Look back (weekly)
67
+
68
+ Read the week with `evda_recent` (7 days). Write back, in a few lines, what recurred in their own
69
+ words and what changed, quoting the file you took each from. No score, no streak, no grades, and
70
+ no rule with a number in it. If nothing stands out, say that.