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/presets.mjs
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Presets: ways to organize a record, shipped as a library so an agent picks one instead of making
|
|
2
|
+
// one up. Each is `presets/<id>/preset.json` in this package:
|
|
3
|
+
//
|
|
4
|
+
// {
|
|
5
|
+
// "id": "journal",
|
|
6
|
+
// "title": "Journal",
|
|
7
|
+
// "for": "One sentence: who it suits.",
|
|
8
|
+
// "folders": [{ "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." }],
|
|
9
|
+
// "circles": [{ "name": "family", "public": false, "description": "...", "members": { "people": { "path": "People" } } }],
|
|
10
|
+
// "routines": ["weekly"]
|
|
11
|
+
// }
|
|
12
|
+
//
|
|
13
|
+
// `folders` are paths relative to the record root. A folder with a `type` gets folder.evda.json
|
|
14
|
+
// (type, connectors, views), checked by the same validator doctor uses; a folder with no type is a
|
|
15
|
+
// plain directory and nothing more. `circles` are starter circles, in the substrate's eco shape with
|
|
16
|
+
// members by path, and they are only ever PROPOSED: a proposal file under proposals/, never a circle.
|
|
17
|
+
// `routines` name routines lib/routines.mjs already knows, and are offered, never scheduled.
|
|
18
|
+
//
|
|
19
|
+
// Applying a preset only adds. It creates the directories that are missing, writes the manifests
|
|
20
|
+
// that are missing, and reports the rest: a manifest that already says the same thing is skipped,
|
|
21
|
+
// one that says something else is a conflict and is left exactly as it is, and a preset path that
|
|
22
|
+
// already exists as a file is a conflict. Nothing is moved, renamed or deleted, so applying twice
|
|
23
|
+
// writes nothing the second time. A shipped preset that does not validate fails
|
|
24
|
+
// test/presets.test.mjs, so the person running it never meets one.
|
|
25
|
+
|
|
26
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { openEvda } from "./manifest.mjs";
|
|
30
|
+
import { resolveRole } from "./roles.mjs";
|
|
31
|
+
import { validateFolderManifest, FOLDER_MANIFEST, FOLDER_TYPES } from "./folders.mjs";
|
|
32
|
+
import { ROUTINE_KEYS } from "./routines.mjs";
|
|
33
|
+
import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
|
|
34
|
+
import { EvdaError } from "./errors.mjs";
|
|
35
|
+
|
|
36
|
+
export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
|
|
37
|
+
export const PRESET_FILE = "preset.json";
|
|
38
|
+
export const PRESET_KEYS = ["id", "title", "for", "folders", "circles", "routines"];
|
|
39
|
+
export const PRESET_FOLDER_KEYS = ["path", "type", "connectors", "views", "note"];
|
|
40
|
+
export const PRESET_CIRCLE_KEYS = ["name", "public", "description", "members"];
|
|
41
|
+
export const NONE = "none";
|
|
42
|
+
|
|
43
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
44
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
45
|
+
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
46
|
+
const CIRCLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
47
|
+
|
|
48
|
+
// A preset path: relative, forward slashes, no empty, dot or dot-dot segment, and no segment doctor's
|
|
49
|
+
// walk would skip (a dot-directory or node_modules), because a folder doctor cannot see is a promise
|
|
50
|
+
// nothing checks.
|
|
51
|
+
function pathProblem(p) {
|
|
52
|
+
if (!nonEmpty(p)) return "must be a non-empty path";
|
|
53
|
+
if (p.includes("\\")) return "uses a backslash; paths use /";
|
|
54
|
+
if (p.startsWith("/")) return "is absolute; paths are relative to the record root";
|
|
55
|
+
for (const seg of p.split("/")) {
|
|
56
|
+
if (seg === "" || seg === "." || seg === "..") return `has an empty, . or .. segment`;
|
|
57
|
+
if (seg.startsWith(".") || seg === "node_modules") return `has the segment "${seg}", which doctor does not read`;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate one preset. Returns a list of problem strings; empty is valid. Stricter than a folder
|
|
63
|
+
// manifest on disk: an unknown key or type in something this package ships is a mistake, not a
|
|
64
|
+
// newer tool's key to preserve.
|
|
65
|
+
export function validatePreset(p, dirName = null) {
|
|
66
|
+
const problems = [];
|
|
67
|
+
const fail = (m) => problems.push(`${dirName ?? p?.id ?? "preset"}: ${m}`);
|
|
68
|
+
if (!isObject(p)) { fail("must hold a JSON object."); return problems; }
|
|
69
|
+
const unknown = Object.keys(p).filter((k) => !PRESET_KEYS.includes(k));
|
|
70
|
+
if (unknown.length) fail(`unknown key(s): ${unknown.join(", ")}.`);
|
|
71
|
+
if (!nonEmpty(p.id) || !ID.test(p.id)) fail(`"id" must be lowercase letters, digits and -, got ${JSON.stringify(p.id)}.`);
|
|
72
|
+
else if (dirName !== null && p.id !== dirName) fail(`"id" is ${JSON.stringify(p.id)} but the preset lives in ${dirName}/; the directory names it.`);
|
|
73
|
+
if (!nonEmpty(p.title)) fail(`"title" is missing.`);
|
|
74
|
+
if (!nonEmpty(p.for)) fail(`"for" is missing: one sentence saying who it suits.`);
|
|
75
|
+
else if (/\n/.test(p.for)) fail(`"for" is one sentence on one line.`);
|
|
76
|
+
const paths = new Set();
|
|
77
|
+
if (!Array.isArray(p.folders) || p.folders.length === 0) fail(`"folders" must be a non-empty list.`);
|
|
78
|
+
else {
|
|
79
|
+
p.folders.forEach((f, i) => {
|
|
80
|
+
const at = `folders[${i}]`;
|
|
81
|
+
if (!isObject(f)) { fail(`${at} must be { path, type?, connectors?, views?, note? }.`); return; }
|
|
82
|
+
const extra = Object.keys(f).filter((k) => !PRESET_FOLDER_KEYS.includes(k));
|
|
83
|
+
if (extra.length) fail(`${at} unknown key(s): ${extra.join(", ")}.`);
|
|
84
|
+
const bad = pathProblem(f.path);
|
|
85
|
+
if (bad) { fail(`${at} path ${bad}.`); return; }
|
|
86
|
+
if (paths.has(f.path)) fail(`${at} path ${f.path} is listed twice.`);
|
|
87
|
+
paths.add(f.path);
|
|
88
|
+
if (f.note !== undefined && !nonEmpty(f.note)) fail(`${at} "note" must be a sentence.`);
|
|
89
|
+
if (f.type === undefined) {
|
|
90
|
+
if (f.connectors !== undefined || f.views !== undefined) fail(`${at} (${f.path}) has connectors or views and no type. An untyped folder is a plain directory with no manifest to hold them.`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!FOLDER_TYPES.includes(f.type)) fail(`${at} (${f.path}) type ${JSON.stringify(f.type)} is not one of ${FOLDER_TYPES.join(", ")}.`);
|
|
94
|
+
const v = validateFolderManifest(manifestFor(f), `${f.path}/${FOLDER_MANIFEST}`);
|
|
95
|
+
for (const pr of v.problems) fail(pr.message);
|
|
96
|
+
for (const view of f.views ?? []) {
|
|
97
|
+
if (isObject(view) && nonEmpty(view.path) && pathProblem(view.path)) fail(`${at} view "${view.view}" path ${pathProblem(view.path)}.`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (p.circles !== undefined) {
|
|
102
|
+
if (!Array.isArray(p.circles)) fail(`"circles" must be a list.`);
|
|
103
|
+
else {
|
|
104
|
+
const names = new Set();
|
|
105
|
+
p.circles.forEach((c, i) => {
|
|
106
|
+
const at = `circles[${i}]`;
|
|
107
|
+
if (!isObject(c)) { fail(`${at} must be { name, public, description, members }.`); return; }
|
|
108
|
+
const extra = Object.keys(c).filter((k) => !PRESET_CIRCLE_KEYS.includes(k));
|
|
109
|
+
if (extra.length) fail(`${at} unknown key(s): ${extra.join(", ")}.`);
|
|
110
|
+
if (!nonEmpty(c.name) || !CIRCLE_NAME.test(c.name)) fail(`${at} "name" must be letters, digits, - and _.`);
|
|
111
|
+
else if (names.has(c.name)) fail(`${at} circle ${c.name} is listed twice.`);
|
|
112
|
+
else names.add(c.name);
|
|
113
|
+
if (typeof c.public !== "boolean") fail(`${at} "public" must be true or false.`);
|
|
114
|
+
if (!nonEmpty(c.description)) fail(`${at} "description" says in plain words who may read it and why.`);
|
|
115
|
+
if (!isObject(c.members) || Object.keys(c.members).length === 0) { fail(`${at} "members" must name at least one path.`); return; }
|
|
116
|
+
for (const [name, m] of Object.entries(c.members)) {
|
|
117
|
+
if (!isObject(m) || Object.keys(m).length !== 1 || !nonEmpty(m.path)) { fail(`${at} member "${name}" must be { path }; a preset circle holds folders this preset makes.`); continue; }
|
|
118
|
+
if (![...paths].some((fp) => m.path === fp || m.path.startsWith(`${fp}/`))) fail(`${at} member "${name}" path ${m.path} is not a folder this preset makes.`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (p.routines !== undefined) {
|
|
124
|
+
if (!Array.isArray(p.routines)) fail(`"routines" must be a list.`);
|
|
125
|
+
else {
|
|
126
|
+
for (const r of p.routines) if (!ROUTINE_KEYS.includes(r)) fail(`routine ${JSON.stringify(r)} is not one this package has (${ROUTINE_KEYS.join(", ")}).`);
|
|
127
|
+
if (new Set(p.routines).size !== p.routines.length) fail(`"routines" lists one twice.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return problems;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The folder.evda.json a typed preset folder writes. The same three keys the scaffold writes.
|
|
134
|
+
export function manifestFor(folder) {
|
|
135
|
+
return { type: folder.type, connectors: folder.connectors ?? [], views: folder.views ?? [] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Read one preset from `dir` (the presets directory). Throws when it is missing or does not validate.
|
|
139
|
+
export function loadPreset(id, { dir = PRESETS_DIR } = {}) {
|
|
140
|
+
if (!nonEmpty(id) || !ID.test(id)) throw new EvdaError(`REFUSED: ${JSON.stringify(id)} is not a preset id. Presets: ${presetIds({ dir }).join(", ")}.`, { code: "NO_PRESET", exit: 2 });
|
|
141
|
+
const p = join(dir, id, PRESET_FILE);
|
|
142
|
+
if (!existsSync(p)) throw new EvdaError(`REFUSED: no preset named "${id}". Presets: ${presetIds({ dir }).join(", ")}.`, { code: "NO_PRESET", exit: 2 });
|
|
143
|
+
let preset;
|
|
144
|
+
try {
|
|
145
|
+
preset = JSON.parse(readFileSync(p, "utf8"));
|
|
146
|
+
} catch (e) {
|
|
147
|
+
throw new EvdaError(`REFUSED: preset ${id} is not valid JSON (${e.message}).`, { code: "BAD_PRESET", exit: 2 });
|
|
148
|
+
}
|
|
149
|
+
const problems = validatePreset(preset, id);
|
|
150
|
+
if (problems.length) throw new EvdaError(`REFUSED: preset ${id} is malformed: ${problems[0]}`, { code: "BAD_PRESET", exit: 2 });
|
|
151
|
+
return preset;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function presetIds({ dir = PRESETS_DIR } = {}) {
|
|
155
|
+
if (!existsSync(dir)) return [];
|
|
156
|
+
return readdirSync(dir).filter((n) => !n.startsWith(".") && existsSync(join(dir, n, PRESET_FILE))).sort();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Every shipped preset, loaded and validated.
|
|
160
|
+
export function listPresets(opts = {}) {
|
|
161
|
+
return presetIds(opts).map((id) => loadPreset(id, opts));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// What `folder`'s existing manifest lacks against the preset's: a different type, a connector or a
|
|
165
|
+
// view the preset declares and it does not. [] means it already says what the preset says; a
|
|
166
|
+
// connector or view the owner added beyond the preset is theirs and is not a difference.
|
|
167
|
+
function differences(existing, want) {
|
|
168
|
+
const out = [];
|
|
169
|
+
if (!isObject(existing)) return ["it does not hold a JSON object"];
|
|
170
|
+
if (existing.type !== want.type) out.push(`its type is ${JSON.stringify(existing.type)}, the preset's is "${want.type}"`);
|
|
171
|
+
const has = Array.isArray(existing.connectors) ? existing.connectors : [];
|
|
172
|
+
const missing = want.connectors.filter((c) => !has.includes(c));
|
|
173
|
+
if (missing.length) out.push(`it does not declare connector(s) ${missing.join(", ")}`);
|
|
174
|
+
const views = Array.isArray(existing.views) ? existing.views.map((v) => v?.view) : [];
|
|
175
|
+
const noView = want.views.filter((v) => !views.includes(v.view)).map((v) => v.view);
|
|
176
|
+
if (noView.length) out.push(`it does not declare view(s) ${noView.join(", ")}`);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The first segment of `rel` that exists and is not a directory, or null.
|
|
181
|
+
function blockedBy(root, rel) {
|
|
182
|
+
const segs = rel.split("/");
|
|
183
|
+
for (let i = 1; i <= segs.length; i++) {
|
|
184
|
+
const sub = segs.slice(0, i).join("/");
|
|
185
|
+
const abs = join(root, sub);
|
|
186
|
+
let st;
|
|
187
|
+
try { st = lstatSync(abs); } catch { return null; }
|
|
188
|
+
if (st.isSymbolicLink()) { try { st = statSync(abs); } catch { return sub; } }
|
|
189
|
+
if (!st.isDirectory()) return sub;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}`, "---", ""].join("\n");
|
|
195
|
+
|
|
196
|
+
// The plan for applying preset `id` to the record at `root`, and with `dry: false` the writes.
|
|
197
|
+
// create directories that do not exist yet
|
|
198
|
+
// write manifests that do not exist yet ({ path, folder, manifest })
|
|
199
|
+
// views view files a manifest declares that do not exist yet
|
|
200
|
+
// matched manifests that exist and already say what the preset says (skipped)
|
|
201
|
+
// conflicts manifests that exist and say something else, and preset paths that are files
|
|
202
|
+
// ({ path, why }); reported, never overwritten
|
|
203
|
+
// circles preset circles with no circle and no proposal yet, proposed on apply
|
|
204
|
+
// routines routines the preset suggests, offered and never scheduled
|
|
205
|
+
export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRESETS_DIR } = {}) {
|
|
206
|
+
const evda = openEvda(resolve(root));
|
|
207
|
+
const preset = loadPreset(id, { dir });
|
|
208
|
+
const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length };
|
|
209
|
+
const blocked = new Set();
|
|
210
|
+
for (const f of preset.folders) {
|
|
211
|
+
const abs = resolve(evda.dir, f.path);
|
|
212
|
+
if (!abs.startsWith(evda.dir + sep)) { plan.conflicts.push({ path: f.path, why: "resolves outside the record" }); continue; }
|
|
213
|
+
const file = blockedBy(evda.dir, f.path);
|
|
214
|
+
if (file) {
|
|
215
|
+
if (!blocked.has(file)) plan.conflicts.push({ path: file, why: `exists as a file, so the folder ${f.path} cannot be made there` });
|
|
216
|
+
blocked.add(file);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!existsSync(abs) && !plan.create.includes(f.path)) plan.create.push(f.path);
|
|
220
|
+
if (f.type === undefined) continue;
|
|
221
|
+
const want = manifestFor(f);
|
|
222
|
+
const mpath = `${f.path}/${FOLDER_MANIFEST}`;
|
|
223
|
+
const mabs = join(abs, FOLDER_MANIFEST);
|
|
224
|
+
if (!existsSync(mabs)) plan.write.push({ path: mpath, folder: f, manifest: want });
|
|
225
|
+
else if (!lstatSync(mabs).isFile()) plan.conflicts.push({ path: mpath, why: "exists and is not a file" });
|
|
226
|
+
else {
|
|
227
|
+
let existing;
|
|
228
|
+
try { existing = JSON.parse(readFileSync(mabs, "utf8")); } catch { plan.conflicts.push({ path: mpath, why: "exists and is not valid JSON" }); continue; }
|
|
229
|
+
const diff = differences(existing, want);
|
|
230
|
+
if (diff.length) plan.conflicts.push({ path: mpath, why: `exists and differs: ${diff.join("; ")}` });
|
|
231
|
+
else plan.matched.push({ path: mpath });
|
|
232
|
+
}
|
|
233
|
+
for (const v of want.views) {
|
|
234
|
+
if (!existsSync(resolve(evda.dir, v.path))) plan.views.push({ path: v.path, view: v });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const circlesRole = resolveRole(evda, "circles");
|
|
238
|
+
const proposals = join(evda.dir, PROPOSALS);
|
|
239
|
+
const proposalNames = existsSync(proposals) ? readdirSync(proposals) : [];
|
|
240
|
+
for (const c of preset.circles ?? []) {
|
|
241
|
+
const exists = circlesRole.mapped && existsSync(join(circlesRole.abs, c.name));
|
|
242
|
+
const proposed = proposalNames.some((n) => new RegExp(`^\\d{4}-\\d{2}-\\d{2}-circle-${c.name}(-\\d+)?\\.md$`).test(n));
|
|
243
|
+
if (!exists && !proposed) plan.circles.push(c);
|
|
244
|
+
}
|
|
245
|
+
if (dry) return plan;
|
|
246
|
+
|
|
247
|
+
for (const rel of plan.create) mkdirSync(join(evda.dir, rel), { recursive: true });
|
|
248
|
+
for (const w of plan.write) writeFileSync(join(evda.dir, w.path), JSON.stringify(w.manifest, null, 2) + "\n", { flag: "wx" });
|
|
249
|
+
for (const v of plan.views) {
|
|
250
|
+
mkdirSync(dirname(resolve(evda.dir, v.path)), { recursive: true });
|
|
251
|
+
writeFileSync(resolve(evda.dir, v.path), viewHeader(v.view), { flag: "wx" });
|
|
252
|
+
}
|
|
253
|
+
for (const c of plan.circles) {
|
|
254
|
+
const r = proposeCircle(evda, {
|
|
255
|
+
name: c.name,
|
|
256
|
+
who: c.public ? "Anyone." : "Only the people the owner grants it to, by name.",
|
|
257
|
+
what: Object.values(c.members).map((m) => m.path).join(", "),
|
|
258
|
+
why: `${c.description} Suggested by the ${preset.title} preset.`,
|
|
259
|
+
}, { now });
|
|
260
|
+
plan.proposed.push(r.path);
|
|
261
|
+
}
|
|
262
|
+
return plan;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// What preset `id` has that the record at `root` does not: the dry-run plan, which by construction
|
|
266
|
+
// holds only additions and conflicts. This is how an agent proposes one folder from the library later.
|
|
267
|
+
export function diffPreset(root, id, opts = {}) {
|
|
268
|
+
return applyPreset(root, id, { ...opts, dry: true });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length;
|
|
272
|
+
|
|
273
|
+
// The plan in plain words, for the owner to say yes or no to. Names each folder by its note.
|
|
274
|
+
export function summary(plan, preset = loadPreset(plan.id)) {
|
|
275
|
+
const lines = [];
|
|
276
|
+
const adds = preset.folders.filter((f) => plan.create.includes(f.path) || plan.write.some((w) => w.folder.path === f.path));
|
|
277
|
+
if (!adds.length && !plan.circles.length && !plan.conflicts.length) {
|
|
278
|
+
lines.push(`This record already has everything the ${plan.title} preset makes. Nothing would be written.`);
|
|
279
|
+
} else {
|
|
280
|
+
lines.push(`The ${plan.title} preset would add ${adds.length} folder(s) to this record:`);
|
|
281
|
+
for (const f of adds) lines.push(`- ${f.path}/${f.type ? ` (${f.type})` : ""}${f.note ? `: ${f.note}` : ""}`);
|
|
282
|
+
if (plan.circles.length) lines.push(`It would propose ${plan.circles.length} circle(s), as proposal files only: ${plan.circles.map((c) => c.name).join(", ")}.`);
|
|
283
|
+
if (plan.matched.length) lines.push(`${plan.matched.length} folder(s) are already set up the same way and would be left alone.`);
|
|
284
|
+
for (const c of plan.conflicts) lines.push(`Left as it is: ${c.path} ${c.why}.`);
|
|
285
|
+
lines.push("Nothing is moved, renamed or deleted. It is applied only when the owner says yes.");
|
|
286
|
+
}
|
|
287
|
+
if (plan.routines.length) lines.push(`It suits the ${plan.routines.join(" and ")} routine(s), offered separately and never scheduled without a yes.`);
|
|
288
|
+
return lines.join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Plan lines for the CLI, in the package's CHECKED / SKIPPED convention.
|
|
292
|
+
export function planLines(plan, { diff = false } = {}) {
|
|
293
|
+
const out = [];
|
|
294
|
+
const verb = plan.dry || diff ? "would" : "did";
|
|
295
|
+
for (const p of plan.create) out.push(` ${verb === "did" ? "created " : "create "} ${p}/`);
|
|
296
|
+
for (const w of plan.write) out.push(` ${verb === "did" ? "wrote " : "write "} ${w.path} type ${w.manifest.type}${w.manifest.connectors.length ? `, connectors ${w.manifest.connectors.join(", ")}` : ""}`);
|
|
297
|
+
for (const v of plan.views) out.push(` ${verb === "did" ? "wrote " : "write "} ${v.path} view ${v.view.view} (header only)`);
|
|
298
|
+
if (!diff) for (const m of plan.matched) out.push(` skipped ${m.path} already says the same`);
|
|
299
|
+
for (const c of plan.conflicts) out.push(` CONFLICT ${c.path} ${c.why}. Left as it is.`);
|
|
300
|
+
if (plan.proposed.length) for (const p of plan.proposed) out.push(` proposed ${p} (a proposal file; no circle was created)`);
|
|
301
|
+
else for (const c of plan.circles) out.push(` propose circle ${c.name} (a proposal file only)`);
|
|
302
|
+
for (const r of plan.routines) out.push(` offer routine ${r}: evda routine ${r} (nothing is scheduled without a yes)`);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The MCP tools. Each takes the open record and its arguments, and refuses an argument it does not take.
|
|
307
|
+
|
|
308
|
+
export function presetsTool(evda, args = {}) {
|
|
309
|
+
onlyKeys("evda_presets", args, ["id"]);
|
|
310
|
+
if (args.id === undefined) return { presets: listPresets().map((p) => ({ id: p.id, title: p.title, for: p.for, folders: p.folders.length })) };
|
|
311
|
+
return { preset: loadPreset(args.id) };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function proposeStructureTool(evda, args = {}) {
|
|
315
|
+
onlyKeys("evda_propose_structure", args, ["id"], "It only proposes. Applying a preset is evda_apply_preset, after the owner says yes.");
|
|
316
|
+
if (!nonEmpty(args.id)) throw refuse(`evda_propose_structure needs "id", a preset from evda_presets.`, "NO_PRESET");
|
|
317
|
+
const plan = applyPreset(evda.dir, args.id, { dry: true });
|
|
318
|
+
return { plan, summary: summary(plan) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function applyPresetTool(evda, args = {}, { now = new Date() } = {}) {
|
|
322
|
+
onlyKeys("evda_apply_preset", args, ["id", "confirmed"], "It adds the preset's folders and manifests and nothing else; it never moves, renames or deletes.");
|
|
323
|
+
if (!nonEmpty(args.id)) throw refuse(`evda_apply_preset needs "id", a preset from evda_presets.`, "NO_PRESET");
|
|
324
|
+
if (args.confirmed !== true) throw refuse(`evda_apply_preset needs "confirmed": true, and that is true only when the owner said yes to this preset in this conversation. Show them evda_propose_structure first and wait.`, "NOT_CONFIRMED");
|
|
325
|
+
loadPreset(args.id);
|
|
326
|
+
const plan = applyPreset(evda.dir, args.id, { now });
|
|
327
|
+
return { plan, lines: planLines(plan) };
|
|
328
|
+
}
|
package/lib/routines.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Starter routines: a morning organize and a weekly look back, each optional, each scheduled only on
|
|
2
|
+
// the owner's yes.
|
|
3
|
+
//
|
|
4
|
+
// The first choice is Claude's own scheduler. A local scheduled task in the Claude desktop app runs
|
|
5
|
+
// on this machine with access to this folder (https://code.claude.com/docs/en/desktop-scheduled-tasks).
|
|
6
|
+
// It is created in the app, so `evda routine` prints exactly what to put in it. A cloud routine
|
|
7
|
+
// (`/schedule`) runs on a fresh clone with no access to local files, so it cannot reach a record.
|
|
8
|
+
//
|
|
9
|
+
// The fallback is launchd on a Mac: a job that runs `evda organize --with-ai` (or `look-back`) on a
|
|
10
|
+
// calendar, through a login shell so the owner's `claude` is on PATH. `--install` writes it and
|
|
11
|
+
// loads it; nothing else here schedules anything.
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { openEvda, holderOf } from "./manifest.mjs";
|
|
18
|
+
import { serverCommand } from "./connect.mjs";
|
|
19
|
+
import { EvdaError } from "./errors.mjs";
|
|
20
|
+
|
|
21
|
+
export const ROUTINES = {
|
|
22
|
+
morning: {
|
|
23
|
+
id: "evda-morning-organize",
|
|
24
|
+
title: "EVÐA morning organize",
|
|
25
|
+
when: "Daily at 8:00",
|
|
26
|
+
calendar: { Hour: 8, Minute: 0 },
|
|
27
|
+
command: ["organize", "--with-ai"],
|
|
28
|
+
does: "files new captures into their day and writes a report of what belongs where. Moves nothing.",
|
|
29
|
+
},
|
|
30
|
+
weekly: {
|
|
31
|
+
id: "evda-weekly-look-back",
|
|
32
|
+
title: "EVÐA weekly look back",
|
|
33
|
+
when: "Weekly on Sunday at 18:00",
|
|
34
|
+
calendar: { Weekday: 0, Hour: 18, Minute: 0 },
|
|
35
|
+
command: ["look-back"],
|
|
36
|
+
does: "reads the week back to you in a report: what recurred in your words and what changed. No scores.",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
export const ROUTINE_KEYS = Object.keys(ROUTINES);
|
|
40
|
+
|
|
41
|
+
const q = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
|
|
42
|
+
|
|
43
|
+
// The shell command a routine runs, with this machine's node and this package's bin.
|
|
44
|
+
export function routineCommand(key, dir) {
|
|
45
|
+
const r = ROUTINES[key];
|
|
46
|
+
const { command, args } = serverCommand(dir);
|
|
47
|
+
// serverCommand ends in `mcp <dir>`; a routine swaps that for its own command.
|
|
48
|
+
const base = [command, ...args.slice(0, -2)];
|
|
49
|
+
return [...base, r.command[0], resolve(dir), ...r.command.slice(1)].map(q).join(" ");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function desktopInstructions(key, dir) {
|
|
53
|
+
const r = ROUTINES[key];
|
|
54
|
+
return [
|
|
55
|
+
`${r.title}: ${r.does}`,
|
|
56
|
+
"",
|
|
57
|
+
"In the Claude desktop app: open the Code tab, then Routines, then New routine, and choose Local.",
|
|
58
|
+
` Name: ${r.id}`,
|
|
59
|
+
` Schedule: ${r.when}`,
|
|
60
|
+
` Folder: ${resolve(dir)}`,
|
|
61
|
+
" Instructions: Run this command in the folder, then tell me the report path it prints and anything it SKIPPED:",
|
|
62
|
+
` ${routineCommand(key, dir)}`,
|
|
63
|
+
"",
|
|
64
|
+
"A local routine runs while the app is open and the computer is awake.",
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function label(key, evda) {
|
|
69
|
+
const id = (holderOf(evda).id ?? "record").replace(/[^A-Za-z0-9.-]+/g, "-");
|
|
70
|
+
return `io.evda.${ROUTINES[key].id}.${id}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
74
|
+
|
|
75
|
+
export function launchdPlist(key, dir, { home = homedir() } = {}) {
|
|
76
|
+
const evda = openEvda(dir);
|
|
77
|
+
const r = ROUTINES[key];
|
|
78
|
+
const log = join(home, "Library", "Logs", "evda", `${r.id}.log`);
|
|
79
|
+
const cal = Object.entries(r.calendar).map(([k, v]) => ` <key>${k}</key><integer>${v}</integer>`).join("\n");
|
|
80
|
+
return [
|
|
81
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
82
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
83
|
+
'<plist version="1.0">',
|
|
84
|
+
"<dict>",
|
|
85
|
+
` <key>Label</key><string>${xml(label(key, evda))}</string>`,
|
|
86
|
+
" <key>ProgramArguments</key>",
|
|
87
|
+
" <array>",
|
|
88
|
+
" <string>/bin/zsh</string>",
|
|
89
|
+
" <string>-lc</string>",
|
|
90
|
+
` <string>${xml(routineCommand(key, dir))}</string>`,
|
|
91
|
+
" </array>",
|
|
92
|
+
" <key>StartCalendarInterval</key>",
|
|
93
|
+
" <dict>",
|
|
94
|
+
cal,
|
|
95
|
+
" </dict>",
|
|
96
|
+
` <key>StandardOutPath</key><string>${xml(log)}</string>`,
|
|
97
|
+
` <key>StandardErrorPath</key><string>${xml(log)}</string>`,
|
|
98
|
+
"</dict>",
|
|
99
|
+
"</plist>",
|
|
100
|
+
"",
|
|
101
|
+
].join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Write the launchd job and load it. `agentsDir` and `load` exist so tests never touch the real
|
|
105
|
+
// ~/Library/LaunchAgents or launchctl.
|
|
106
|
+
export function installLaunchd(key, dir, { agentsDir = process.env.EVDA_LAUNCH_AGENTS ?? join(homedir(), "Library", "LaunchAgents"), load = process.env.EVDA_NO_LAUNCHCTL !== "1", home = homedir() } = {}) {
|
|
107
|
+
if (process.platform !== "darwin" && load) throw new EvdaError("REFUSED: launchd is macOS only. Use the Claude desktop routine instead. Nothing was written.", { exit: 2 });
|
|
108
|
+
const evda = openEvda(dir);
|
|
109
|
+
const plist = launchdPlist(key, dir, { home });
|
|
110
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
111
|
+
mkdirSync(join(home, "Library", "Logs", "evda"), { recursive: true });
|
|
112
|
+
const path = join(agentsDir, `${label(key, evda)}.plist`);
|
|
113
|
+
const existed = existsSync(path);
|
|
114
|
+
writeFileSync(path, plist);
|
|
115
|
+
let loaded = "not loaded (EVDA_NO_LAUNCHCTL=1)";
|
|
116
|
+
if (load) {
|
|
117
|
+
const uid = process.getuid?.() ?? "";
|
|
118
|
+
spawnSync("launchctl", ["bootout", `gui/${uid}`, path], { encoding: "utf8" });
|
|
119
|
+
const r = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, path], { encoding: "utf8" });
|
|
120
|
+
loaded = r.status === 0 ? "loaded" : `launchctl bootstrap exited ${r.status}: ${(r.stderr ?? "").trim()}`;
|
|
121
|
+
}
|
|
122
|
+
return { path, existed, loaded };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function routineMain(args, { pickDir }) {
|
|
126
|
+
const key = args[0];
|
|
127
|
+
if (!ROUTINE_KEYS.includes(key)) throw new EvdaError(`usage: evda routine <${ROUTINE_KEYS.join("|")}> [dir] [--launchd] [--install]`, { exit: 2 });
|
|
128
|
+
const { dir, flags } = pickDir(args.slice(1));
|
|
129
|
+
if (flags.includes("--install")) {
|
|
130
|
+
const r = installLaunchd(key, dir);
|
|
131
|
+
console.log(`${r.existed ? "replaced" : "wrote"} ${r.path}: ${r.loaded}. ${ROUTINES[key].when}, it runs: ${routineCommand(key, dir)}`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
if (flags.includes("--launchd")) {
|
|
135
|
+
process.stdout.write(launchdPlist(key, dir));
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
console.log(desktopInstructions(key, dir));
|
|
139
|
+
console.log("");
|
|
140
|
+
console.log(`Or on a Mac without the app open: evda routine ${key} ${resolve(dir)} --install (launchd, runs ${ROUTINES[key].when.toLowerCase()}).`);
|
|
141
|
+
console.log("A scheduled job cannot read a record inside Documents, Desktop, Downloads or iCloud Drive unless the shell has Full Disk Access.");
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
package/lib/setup.mjs
CHANGED
|
@@ -166,22 +166,75 @@ export const WELCOME = [
|
|
|
166
166
|
"The only requirement is that you want to, and you are open to it. Iris is the default name; call it whatever you like.",
|
|
167
167
|
].join("\n");
|
|
168
168
|
|
|
169
|
+
// The setup questions, in the order they are asked. The terminal interview and the EVÐA app both
|
|
170
|
+
// read them from here, so the two surfaces cannot ask different things. `agent` is the name the
|
|
171
|
+
// owner chose on the first question.
|
|
172
|
+
export function questions(agent = DEFAULT_AGENT_NAME) {
|
|
173
|
+
return [
|
|
174
|
+
{ key: "agentName", text: `What would you like to call your agent? (${DEFAULT_AGENT_NAME})` },
|
|
175
|
+
{ key: "owner", text: "What is your name?" },
|
|
176
|
+
{ key: "purpose", text: `What do you want ${agent} for? Say it however it comes out.` },
|
|
177
|
+
{ key: "preset", text: "How should your EVÐA be organized? Pick a starting set of folders, or start plain. Nothing you already have is moved." },
|
|
178
|
+
{ key: "voice", text: `How do you want ${agent} to talk to you?` },
|
|
179
|
+
{ key: "platform", text: "Which AI will you use it with?" },
|
|
180
|
+
{ key: "trainingOff", text: "Before anything personal goes in, turn model training off:" },
|
|
181
|
+
{ key: "import", text: "Bring in what your AI already knows about you?" },
|
|
182
|
+
{ key: "skill", text: `Install the evda skill for Claude Code, so ${agent} works through your EVÐA from any folder?` },
|
|
183
|
+
{ key: "morning", text: "Add a morning organize routine, which files your captures into their day and reports what belongs where?" },
|
|
184
|
+
{ key: "weekly", text: "Add a weekly look back routine, which reads your week back to you?" },
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// What a yes to the import offer shows: the line before IMPORT_PROMPT and the line after it.
|
|
189
|
+
export function importLines(agent = DEFAULT_AGENT_NAME) {
|
|
190
|
+
return {
|
|
191
|
+
intro: "Paste this into a new chat with ChatGPT, Claude, or whatever AI you already talk to:",
|
|
192
|
+
after: `Bring its answer to your first session with ${agent}. ${agent} files it as an import: what that AI said, not your own words, and a claim to check over time rather than a fact.`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The preset choice: a number from the menu (the last number is "none"), a preset id, a word from
|
|
197
|
+
// its title, or none. `presets` is [{ id, title, for }], passed in rather than read here, because
|
|
198
|
+
// this file is also bundled into the EVÐA app and reads no files. null when the answer names nothing.
|
|
199
|
+
export const START_PLAIN = "None, start plain";
|
|
200
|
+
export function presetKey(answer, presets) {
|
|
201
|
+
const a = String(answer ?? "").trim().toLowerCase();
|
|
202
|
+
if (!a || a === "none" || a === "plain" || a === START_PLAIN.toLowerCase()) return "none";
|
|
203
|
+
if (/^\d+$/.test(a)) {
|
|
204
|
+
const n = Number(a);
|
|
205
|
+
if (n === presets.length + 1) return "none";
|
|
206
|
+
return presets[n - 1]?.id ?? null;
|
|
207
|
+
}
|
|
208
|
+
const hit = presets.find((p) => p.id === a || p.title.toLowerCase() === a);
|
|
209
|
+
return hit ? hit.id : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
169
212
|
// Ask the setup questions one at a time. `io.ask(prompt)` returns the answer (a promise or a
|
|
170
213
|
// string); `io.out(line)` prints. Only questions whose flag is missing are asked. Returns the
|
|
171
|
-
// answers, including `trainingOff` which is true only after the owner typed yes.
|
|
172
|
-
|
|
214
|
+
// answers, including `trainingOff` which is true only after the owner typed yes. `presets` is the
|
|
215
|
+
// preset library to offer; with none, the preset question is not asked.
|
|
216
|
+
export async function interview(opts, io, { presets = [] } = {}) {
|
|
173
217
|
const out = io.out;
|
|
174
218
|
const ask = async (q) => oneLine(await io.ask(q));
|
|
175
219
|
out(WELCOME);
|
|
176
220
|
out("");
|
|
177
221
|
const a = { ...opts };
|
|
178
|
-
|
|
222
|
+
const q = (key, agent) => questions(agent).find((x) => x.key === key).text;
|
|
223
|
+
if (a.agentName == null) a.agentName = (await ask(`${q("agentName")} `)) || DEFAULT_AGENT_NAME;
|
|
179
224
|
const agent = a.agentName || DEFAULT_AGENT_NAME;
|
|
180
|
-
if (!a.owner) a.owner = await ask("
|
|
181
|
-
if (!a.purpose) a.purpose = await ask(
|
|
182
|
-
if (
|
|
225
|
+
if (!a.owner) a.owner = await ask(`${q("owner", agent)} `);
|
|
226
|
+
if (!a.purpose) a.purpose = await ask(`${q("purpose", agent)} `);
|
|
227
|
+
if (a.preset == null && presets.length) {
|
|
228
|
+
out(q("preset", agent));
|
|
229
|
+
presets.forEach((p, i) => out(` ${i + 1}. ${p.title}: ${p.for}`));
|
|
230
|
+
out(` ${presets.length + 1}. ${START_PLAIN}`);
|
|
231
|
+
let key = null;
|
|
232
|
+
while (!key) key = presetKey(await ask("Number or name: "), presets);
|
|
233
|
+
a.preset = key;
|
|
234
|
+
}
|
|
235
|
+
if (!a.voice) a.voice = await ask(`${q("voice", agent)} `);
|
|
183
236
|
if (!a.platform) {
|
|
184
|
-
out("
|
|
237
|
+
out(q("platform", agent));
|
|
185
238
|
PLATFORM_KEYS.forEach((k, i) => out(` ${i + 1}. ${PLATFORMS[k].label}`));
|
|
186
239
|
let key = null;
|
|
187
240
|
let raw = "";
|
|
@@ -195,7 +248,7 @@ export async function interview(opts, io) {
|
|
|
195
248
|
const p = PLATFORMS[a.platform];
|
|
196
249
|
if (!a.trainingOff) {
|
|
197
250
|
out("");
|
|
198
|
-
out("
|
|
251
|
+
out(q("trainingOff", agent));
|
|
199
252
|
for (const line of p.training) out(` ${line}`);
|
|
200
253
|
if (p.url) out(` ${p.url}`);
|
|
201
254
|
let yes = false;
|
|
@@ -209,14 +262,26 @@ export async function interview(opts, io) {
|
|
|
209
262
|
out("");
|
|
210
263
|
out(DICTATION);
|
|
211
264
|
out("");
|
|
212
|
-
const bring = (await ask("
|
|
265
|
+
const bring = (await ask(`${q("import", agent)} (yes/no) `)).toLowerCase();
|
|
213
266
|
if (bring === "yes" || bring === "y") {
|
|
267
|
+
const lines = importLines(agent);
|
|
214
268
|
out("");
|
|
215
|
-
out(
|
|
269
|
+
out(lines.intro);
|
|
216
270
|
out("");
|
|
217
271
|
out(` ${IMPORT_PROMPT}`);
|
|
218
272
|
out("");
|
|
219
|
-
out(
|
|
273
|
+
out(lines.after);
|
|
274
|
+
}
|
|
275
|
+
const yes = async (key) => ["yes", "y"].includes((await ask(`${q(key, agent)} (yes/no) `)).toLowerCase());
|
|
276
|
+
if (a.skill == null) {
|
|
277
|
+
out("");
|
|
278
|
+
a.skill = await yes("skill");
|
|
279
|
+
}
|
|
280
|
+
if (a.routines == null) {
|
|
281
|
+
out("");
|
|
282
|
+
out("Routines are optional and nothing is scheduled without a yes. Each runs through your own AI, never an API key in EVÐA.");
|
|
283
|
+
a.routines = [];
|
|
284
|
+
for (const key of ["morning", "weekly"]) if (await yes(key)) a.routines.push(key);
|
|
220
285
|
}
|
|
221
286
|
return a;
|
|
222
287
|
}
|