create-evda 0.1.4 → 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 +20 -2
- package/bin/evda.mjs +47 -0
- package/create-evda.mjs +16 -4
- package/lib/doctor.mjs +5 -6
- package/lib/folders.mjs +22 -8
- package/lib/mcp.mjs +43 -0
- package/lib/presets.mjs +328 -0
- package/lib/setup.mjs +28 -2
- package/lib/tools.mjs +2 -2
- package/package.json +2 -1
- 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 +18 -0
- package/templates/AGENTS.md +12 -2
- package/templates/evda-digest.md +3 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npx create-evda my-evda
|
|
|
14
14
|
|
|
15
15
|
## Setup
|
|
16
16
|
|
|
17
|
-
On a terminal, `create-evda` asks one question at a time: what to call your agent (Iris unless you rename it), who you are, what you want this for, how it should talk to you, and which AI you will use. It shows where that AI's training setting is, links the provider's own page, and does not finish until you type yes. The answers go into "What this EVÐA is" at the top of `AGENTS.md`, in your words. It suggests dictation: Apple's built in dictation, or Wispr Flow with data collection turned off. Last, it offers to bring in what an AI you already use knows about you; see below.
|
|
17
|
+
On a terminal, `create-evda` asks one question at a time: what to call your agent (Iris unless you rename it), who you are, what you want this for, how to organize it (a preset, or start plain), how it should talk to you, and which AI you will use. It shows where that AI's training setting is, links the provider's own page, and does not finish until you type yes. The answers go into "What this EVÐA is" at the top of `AGENTS.md`, in your words. It suggests dictation: Apple's built in dictation, or Wispr Flow with data collection turned off. Last, it offers to bring in what an AI you already use knows about you; see below.
|
|
18
18
|
|
|
19
19
|
Unattended, nothing is asked:
|
|
20
20
|
|
|
@@ -23,7 +23,7 @@ npx create-evda my-evda --yes --agent-name Iris --owner "Dana" --purpose "why wo
|
|
|
23
23
|
--voice "direct" --platform claude --training-off
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
`--platform` takes `claude`, `chatgpt` (ChatGPT and Codex), `gemini`, `cursor` or `other`. Without `--training-off` the AI line stays empty and the agent asks about training in the first session. A run with no terminal on stdin is treated as `--yes`.
|
|
26
|
+
`--platform` takes `claude`, `chatgpt` (ChatGPT and Codex), `gemini`, `cursor` or `other`. `--preset` takes a preset id or `none`; unattended, no flag means none. Without `--training-off` the AI line stays empty and the agent asks about training in the first session. A run with no terminal on stdin is treated as `--yes`.
|
|
27
27
|
|
|
28
28
|
## Any AI
|
|
29
29
|
|
|
@@ -44,6 +44,9 @@ Setup ends by asking whether to bring in what an AI you already talk to knows ab
|
|
|
44
44
|
evda_search plain text search inside the record, on this machine
|
|
45
45
|
evda_circles your circles and their policies
|
|
46
46
|
evda_propose_circle a proposal file under proposals/. Never a circle, never a grant
|
|
47
|
+
evda_presets the preset library, or one preset's folders
|
|
48
|
+
evda_propose_structure what a preset would add to this record, in plain words. Writes nothing
|
|
49
|
+
evda_apply_preset adds a preset's folders; refused unless confirmed: true, the owner's yes
|
|
47
50
|
evda_doctor evda doctor, as a report
|
|
48
51
|
|
|
49
52
|
Every write appends, refuses anything shaped like a password, key or token, and says the path it wrote. `evda connect` prints the setup for your record, with its path filled in:
|
|
@@ -61,6 +64,19 @@ For the Claude desktop app, add the same command under `mcpServers` in `claude_d
|
|
|
61
64
|
|
|
62
65
|
**Routines.** Setup offers a morning organize and a weekly look back, each optional, and schedules nothing without a yes. `evda routine morning` prints what to put in a local routine in the Claude desktop app (Code tab, then Routines, then New routine, then Local). `evda routine morning --install` writes and loads a launchd job instead, on a Mac. A cloud routine cannot read local files, so it cannot reach a record.
|
|
63
66
|
|
|
67
|
+
## Presets
|
|
68
|
+
|
|
69
|
+
How a record is organized comes from a library, so an agent proposes a folder that already exists in it instead of inventing one. Each preset is `presets/<id>/preset.json`: an `id`, a `title`, `for` (who it suits), `folders` (each a path, and for a typed folder its `type`, `connectors` and `views`, validated by the same code as `folder.evda.json`), optional `circles` (proposed as proposal files, never created) and optional `routines` (offered, never scheduled). Three ship: `projects-and-areas` (numbered top folders for inbox, projects, areas, resources, archive, captures, metadata and attachments), `journal`, and `student`. Setup offers them after purpose, with "none, start plain".
|
|
70
|
+
|
|
71
|
+
evda preset list
|
|
72
|
+
evda preset show <id>
|
|
73
|
+
evda preset apply <id> [--dry] [dir]
|
|
74
|
+
evda preset diff <id> [dir] what the preset has that this record does not
|
|
75
|
+
|
|
76
|
+
Applying only adds: missing directories are created and missing manifests written; a manifest that already says the same is skipped, one that says something else (or a file where a folder would go) is a `CONFLICT` that is left as it is, and nothing is moved, renamed or deleted. A second apply writes nothing. Exit 1 when there was a conflict. `test/presets.test.mjs` validates every shipped preset, so a malformed one fails a test rather than a person.
|
|
77
|
+
|
|
78
|
+
`evda doctor` reads folder manifests across the whole record root, not only under the record roles, skipping dot-directories and `node_modules` and not following symlinks.
|
|
79
|
+
|
|
64
80
|
## Circles and other people
|
|
65
81
|
|
|
66
82
|
Everything is private until you share it. A circle is a part of the record you choose to share, plus who may read it. Audience and content are set separately: a public circle has the widest audience and the least content. Ask the agent to share something and it proposes a circle with a plain policy and waits for your yes; it never creates one, adds a reader, or moves an entry on its own. Another person reads a circle through a grant naming their own record (`evda grant family --to sam.atlas --kind person`), and `evda revoke` takes it back. A part can be more private than its circle, never less.
|
|
@@ -134,6 +150,8 @@ The tree above is what the scaffolder creates and not a requirement. `evda.json`
|
|
|
134
150
|
evda look-back [dir] the week, read back by your own claude -p, appended to a report
|
|
135
151
|
evda routine <morning|weekly> [dir] [--launchd] [--install]
|
|
136
152
|
a Claude desktop routine, or a launchd job with --install
|
|
153
|
+
evda preset list | show <id> | apply <id> [--dry] [dir] | diff <id> [dir]
|
|
154
|
+
organize the record from the preset library; apply only adds
|
|
137
155
|
|
|
138
156
|
The record is found through the locator: a directory argument, then `$EVDA_HOME`, then the nearest ancestor of the working directory holding `evda.json`, then `~/.evda/current`. Nothing assumes it was launched inside one.
|
|
139
157
|
|
package/bin/evda.mjs
CHANGED
|
@@ -61,11 +61,18 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
|
|
|
61
61
|
evda routine <morning|weekly> [dir] [--launchd] [--install]
|
|
62
62
|
print a scheduled routine for Claude, or a launchd fallback; --install
|
|
63
63
|
writes the launchd job, and nothing is scheduled without it
|
|
64
|
+
evda preset list the organizations this package ships, and who each suits
|
|
65
|
+
evda preset show <id> one preset's folders
|
|
66
|
+
evda preset apply <id> [--dry] [dir]
|
|
67
|
+
add the preset's folders and manifests. Only adds: an existing
|
|
68
|
+
manifest that differs is a CONFLICT and is left as it is
|
|
69
|
+
evda preset diff <id> [dir] what the preset has that this record does not
|
|
64
70
|
evda status [dir] one screen summary
|
|
65
71
|
evda where which EVDA the locator resolves to, and by which rule
|
|
66
72
|
evda use <dir> point ~/.evda/current at a record
|
|
67
73
|
evda init <dir> [--yes] [--agent-name N] [--owner O] [--purpose P] [--voice V]
|
|
68
74
|
[--platform claude|chatgpt|gemini|cursor|other] [--training-off] [--layers record,ledger]
|
|
75
|
+
[--preset <id>|none]
|
|
69
76
|
the same as create-evda: asks on a terminal, runs from flags otherwise
|
|
70
77
|
|
|
71
78
|
Locator order: directory argument, $EVDA_HOME, the working directory's ancestors, ~/.evda/current.
|
|
@@ -174,6 +181,46 @@ async function main(argv) {
|
|
|
174
181
|
return routineMain(args, { pickDir, flagValue });
|
|
175
182
|
}
|
|
176
183
|
|
|
184
|
+
case "preset": {
|
|
185
|
+
// evda preset list | show <id> | apply <id> [--dry] [dir] | diff <id> [dir]
|
|
186
|
+
const presets = await import("../lib/presets.mjs");
|
|
187
|
+
const [sub, ...rest] = args;
|
|
188
|
+
if (sub === "list") {
|
|
189
|
+
const all = presets.listPresets();
|
|
190
|
+
for (const p of all) console.log(`${p.id.padEnd(20)} ${p.title}: ${p.for}`);
|
|
191
|
+
console.log(`CHECKED ${all.length} presets`);
|
|
192
|
+
return all.length ? 0 : 1;
|
|
193
|
+
}
|
|
194
|
+
const id = rest.find((a) => !a.startsWith("--"));
|
|
195
|
+
if (!["show", "apply", "diff"].includes(sub) || !id) throw new EvdaError("usage: evda preset list | show <id> | apply <id> [--dry] [dir] | diff <id> [dir]", { exit: 2 });
|
|
196
|
+
if (sub === "show") {
|
|
197
|
+
const p = presets.loadPreset(id);
|
|
198
|
+
console.log(`${p.title} (${p.id}): ${p.for}`);
|
|
199
|
+
for (const f of p.folders) console.log(` ${`${f.path}/`.padEnd(32)} ${(f.type ?? "untyped").padEnd(8)}${f.connectors?.length ? ` connectors ${f.connectors.join(", ")}` : ""}${f.note ? ` ${f.note}` : ""}`);
|
|
200
|
+
for (const c of p.circles ?? []) console.log(` circle ${c.name} (${c.public ? "public" : "private"}, proposed only): ${c.description}`);
|
|
201
|
+
for (const r of p.routines ?? []) console.log(` routine ${r} (offered, never scheduled without a yes)`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
const { dir } = pickDir(rest.filter((a, i) => i !== rest.indexOf(id)));
|
|
205
|
+
if (sub === "diff") {
|
|
206
|
+
const plan = presets.diffPreset(dir, id);
|
|
207
|
+
console.log(`evda preset diff ${id} ${plan.root}`);
|
|
208
|
+
for (const l of presets.planLines(plan, { diff: true })) console.log(l);
|
|
209
|
+
if (presets.planIsEmpty(plan)) console.log(`SKIPPED preset diff: this record already has everything ${id} makes`);
|
|
210
|
+
console.log(`CHECKED ${plan.folders} preset folders ${plan.create.length} missing, ${plan.write.length} manifest(s) missing, ${plan.matched.length} matching, ${plan.conflicts.length} conflict(s)`);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
const dry = args.includes("--dry");
|
|
214
|
+
const plan = presets.applyPreset(dir, id, { dry });
|
|
215
|
+
console.log(`evda preset apply ${id}${dry ? " --dry" : ""} ${plan.root}`);
|
|
216
|
+
for (const l of presets.planLines(plan)) console.log(l);
|
|
217
|
+
const wrote = plan.create.length + plan.write.length + plan.views.length + plan.proposed.length;
|
|
218
|
+
console.log(`CHECKED ${plan.folders} preset folders ${plan.create.length} ${dry ? "to create" : "created"}, ${plan.write.length} manifest(s) ${dry ? "to write" : "written"}, ${plan.matched.length} skipped, ${plan.conflicts.length} conflict(s)`);
|
|
219
|
+
if (!dry && wrote === 0 && !plan.conflicts.length) console.log("Nothing was written: the record already has this preset.");
|
|
220
|
+
if (dry) console.log("Dry run: nothing was written.");
|
|
221
|
+
return plan.conflicts.length ? 1 : 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
177
224
|
case "where": {
|
|
178
225
|
const found = locate({});
|
|
179
226
|
console.log(`${found.dir} (via ${found.via})`);
|
package/create-evda.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT]
|
|
5
5
|
// [--platform claude|chatgpt|gemini|cursor|other] [--training-off] [--yes]
|
|
6
|
-
// [--name NAME] [--layers record,ledger[,circles]] [--force]
|
|
6
|
+
// [--name NAME] [--layers record,ledger[,circles]] [--preset ID|none] [--force]
|
|
7
7
|
//
|
|
8
8
|
// On a terminal, any setup answer not given as a flag is asked, one question at a time, and setup
|
|
9
9
|
// does not finish until the owner confirms model training is off for the AI they chose. With
|
|
@@ -30,6 +30,7 @@ import { FOLDER_MANIFEST } from "./lib/folders.mjs";
|
|
|
30
30
|
import { EvdaError } from "./lib/errors.mjs";
|
|
31
31
|
import { clients, installSkill } from "./lib/connect.mjs";
|
|
32
32
|
import { desktopInstructions } from "./lib/routines.mjs";
|
|
33
|
+
import { applyPreset, listPresets, planLines, presetIds, NONE } from "./lib/presets.mjs";
|
|
33
34
|
import { PLATFORMS, PLATFORM_KEYS, LOADERS, DESKTOP_FILESYSTEM, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview, trainingSection } from "./lib/setup.mjs";
|
|
34
35
|
|
|
35
36
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -56,13 +57,13 @@ const FOLDER_TYPES = {
|
|
|
56
57
|
"record/people": "person",
|
|
57
58
|
};
|
|
58
59
|
|
|
59
|
-
const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--skill] [--routines morning,weekly] [--use] [--no-pointer]`;
|
|
60
|
+
const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--skill] [--routines morning,weekly] [--preset ${presetIds().join("|")}|none] [--use] [--no-pointer]`;
|
|
60
61
|
|
|
61
62
|
export function parseArgs(argv) {
|
|
62
63
|
const opts = {
|
|
63
64
|
layers: ["record", "ledger"], force: false, name: null, owner: "", kind: "person", id: null, pointer: true, use: false,
|
|
64
65
|
yes: false, trainingOff: false, agentName: null, purpose: "", voice: "", platform: null, platformName: null,
|
|
65
|
-
skill: null, routines: null,
|
|
66
|
+
skill: null, routines: null, preset: null,
|
|
66
67
|
};
|
|
67
68
|
const args = argv.flatMap((a) => {
|
|
68
69
|
const eq = /^(--[a-z-]+)=([\s\S]*)$/.exec(a);
|
|
@@ -88,6 +89,10 @@ export function parseArgs(argv) {
|
|
|
88
89
|
const bad = opts.routines.filter((r) => !["morning", "weekly"].includes(r));
|
|
89
90
|
if (bad.length) throw new EvdaError(`--routines takes morning, weekly or both, got ${bad.join(", ")}`, { exit: 2 });
|
|
90
91
|
}
|
|
92
|
+
else if (a === "--preset") {
|
|
93
|
+
opts.preset = value(++i, a).trim();
|
|
94
|
+
if (opts.preset !== NONE && !presetIds().includes(opts.preset)) throw new EvdaError(`--preset takes ${[...presetIds(), NONE].join(", ")}, got ${opts.preset}`, { exit: 2 });
|
|
95
|
+
}
|
|
91
96
|
else if (a === "--name") opts.name = value(++i, a);
|
|
92
97
|
else if (a === "--owner") opts.owner = value(++i, a);
|
|
93
98
|
else if (a === "--agent-name") opts.agentName = value(++i, a);
|
|
@@ -234,7 +239,7 @@ export async function main(argv, io = null) {
|
|
|
234
239
|
refuseNonEmpty(opts.dir, opts.force);
|
|
235
240
|
const term = io ?? (await terminalIO());
|
|
236
241
|
try {
|
|
237
|
-
opts = await interview(opts, term);
|
|
242
|
+
opts = await interview(opts, term, { presets: listPresets() });
|
|
238
243
|
} finally {
|
|
239
244
|
term.close?.();
|
|
240
245
|
}
|
|
@@ -243,6 +248,13 @@ export async function main(argv, io = null) {
|
|
|
243
248
|
const { dir, pointer } = scaffold(opts);
|
|
244
249
|
log(`created an EVDA at ${dir}`);
|
|
245
250
|
log(`locator ${pointer.path}: ${pointer.action}\n`);
|
|
251
|
+
// The chosen preset only adds folders to the fresh record, and doctor below reads what it wrote.
|
|
252
|
+
if (opts.preset && opts.preset !== NONE) {
|
|
253
|
+
const plan = applyPreset(dir, opts.preset);
|
|
254
|
+
log(`preset ${plan.id}: ${plan.title}`);
|
|
255
|
+
for (const l of planLines(plan)) log(l);
|
|
256
|
+
log("");
|
|
257
|
+
}
|
|
246
258
|
const report = doctor(dir, { out: log, via: "create-evda" });
|
|
247
259
|
if (report.exit !== 0) {
|
|
248
260
|
console.error("\ncreate-evda wrote the layout but doctor does not pass on it. That is a bug in create-evda; the output above says where.");
|
package/lib/doctor.mjs
CHANGED
|
@@ -318,12 +318,11 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
|
|
|
318
318
|
else ok(grants.length, "grants", `across ${new Set(grants.map((g) => g.circle).filter(Boolean)).size} circle(s), ${grants.filter((g) => g.holder?.kind === "agent").length} to agents`);
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
// 12. folder manifests (Spec 07, section 2): each
|
|
322
|
-
//
|
|
321
|
+
// 12. folder manifests (Spec 07, section 2): each folder says what its entries are and which
|
|
322
|
+
// connectors feed its references. Read across the whole record root, not only the record roles
|
|
323
|
+
// (lib/folders.mjs folderManifests says why). A manifest that is not JSON refuses the run.
|
|
323
324
|
let folders = [];
|
|
324
|
-
|
|
325
|
-
skip("folder manifests", "no record role resolved to a directory");
|
|
326
|
-
} else {
|
|
325
|
+
{
|
|
327
326
|
try {
|
|
328
327
|
folders = folderManifests(evda);
|
|
329
328
|
} catch (e) {
|
|
@@ -335,7 +334,7 @@ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
|
|
|
335
334
|
for (const p of f.problems) (p.level === "fail" ? fail : warn)(`folder ${relative(dir, f.dir) || "."}`, p.message);
|
|
336
335
|
out(` ${(relative(dir, f.dir) || ".").padEnd(24)} type ${f.type ?? "?"} connectors [${f.connectors.join(", ")}] views [${f.views.map((v) => v.view).join(", ")}]`);
|
|
337
336
|
}
|
|
338
|
-
if (folders.length === 0) skip("folder manifests", `no record
|
|
337
|
+
if (folders.length === 0) skip("folder manifests", `no folder in the record carries ${FOLDER_MANIFEST}`);
|
|
339
338
|
else ok(folders.length, "folder manifests", `${folders.filter((f) => f.connectors.length).length} with connectors`);
|
|
340
339
|
|
|
341
340
|
// 12b. views (Spec 07, section 5): a manifest's `views` declare the markdown files the renderer
|
package/lib/folders.mjs
CHANGED
|
@@ -112,24 +112,38 @@ export function validateFolderManifest(m, rel) {
|
|
|
112
112
|
return out;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
// Every folder manifest
|
|
116
|
-
//
|
|
115
|
+
// Every folder manifest in the record. Each item is { rel, abs, dir, ...validateFolderManifest }.
|
|
116
|
+
// Throws only on a manifest that is not JSON.
|
|
117
|
+
//
|
|
118
|
+
// The record roles are walked first, in role order and deepest last, then the rest of the record
|
|
119
|
+
// root, because a folder a preset or the owner made outside every role still says what it holds and
|
|
120
|
+
// is still read. Until HQ-1216 only the roles were walked, and a malformed manifest one level outside
|
|
121
|
+
// them printed OK. The walk skips dot-directories and node_modules and does not follow a symlink
|
|
122
|
+
// below a role or the root, so a link cannot pull another tree, or the same tree twice, into the count.
|
|
123
|
+
//
|
|
124
|
+
// FALSIFIED 2026-09-17: a fresh scaffold with Outside/folder.evda.json holding {"kind":"person"}
|
|
125
|
+
// (no type). The roles-only walk (main at 2788455) printed "CHECKED 3 folder manifests" and
|
|
126
|
+
// "OK 9 check(s), 0 warning(s)", exit 0. This walk prints "FAIL folder Outside
|
|
127
|
+
// Outside/folder.evda.json: has no "type". ..." and "CHECKED 4 folder manifests", exit 1.
|
|
117
128
|
export function folderManifests(evda) {
|
|
118
129
|
const out = [];
|
|
119
|
-
const
|
|
130
|
+
const seen = new Set();
|
|
120
131
|
const walk = (dir) => {
|
|
132
|
+
if (seen.has(dir)) return;
|
|
133
|
+
seen.add(dir);
|
|
121
134
|
const m = readFolderManifest(dir);
|
|
122
135
|
if (m !== null) {
|
|
123
136
|
const abs = join(dir, FOLDER_MANIFEST);
|
|
124
137
|
out.push({ rel: relative(evda.dir, abs), abs, dir, ...validateFolderManifest(m, relative(evda.dir, abs)) });
|
|
125
138
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
139
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
140
|
+
for (const e of entries) {
|
|
141
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
142
|
+
if (e.isDirectory()) walk(join(dir, e.name));
|
|
130
143
|
}
|
|
131
144
|
};
|
|
132
|
-
for (const r of
|
|
145
|
+
for (const r of resolveAll(evda).filter((x) => x.known && x.mapped && x.layer === "record" && x.exists && x.isDir)) walk(r.abs);
|
|
146
|
+
walk(evda.dir);
|
|
133
147
|
return out;
|
|
134
148
|
}
|
|
135
149
|
|
package/lib/mcp.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { SCHEMA_VERSION } from "./manifest.mjs";
|
|
|
14
14
|
import { openEvda } from "./manifest.mjs";
|
|
15
15
|
import { EvdaError } from "./errors.mjs";
|
|
16
16
|
import * as tools from "./tools.mjs";
|
|
17
|
+
import * as presets from "./presets.mjs";
|
|
17
18
|
|
|
18
19
|
// Newest first. The server answers with the client's version when it supports it, and with the
|
|
19
20
|
// newest otherwise; the client decides whether to continue.
|
|
@@ -25,6 +26,7 @@ const INSTRUCTIONS = [
|
|
|
25
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.",
|
|
26
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.",
|
|
27
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.",
|
|
28
30
|
].join(" ");
|
|
29
31
|
|
|
30
32
|
const str = (description) => ({ type: "string", description });
|
|
@@ -135,6 +137,47 @@ export const TOOLS = [
|
|
|
135
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 };
|
|
136
138
|
},
|
|
137
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
|
+
},
|
|
138
181
|
{
|
|
139
182
|
name: "evda_doctor",
|
|
140
183
|
title: "Check the record",
|
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/setup.mjs
CHANGED
|
@@ -174,6 +174,7 @@ export function questions(agent = DEFAULT_AGENT_NAME) {
|
|
|
174
174
|
{ key: "agentName", text: `What would you like to call your agent? (${DEFAULT_AGENT_NAME})` },
|
|
175
175
|
{ key: "owner", text: "What is your name?" },
|
|
176
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." },
|
|
177
178
|
{ key: "voice", text: `How do you want ${agent} to talk to you?` },
|
|
178
179
|
{ key: "platform", text: "Which AI will you use it with?" },
|
|
179
180
|
{ key: "trainingOff", text: "Before anything personal goes in, turn model training off:" },
|
|
@@ -192,10 +193,27 @@ export function importLines(agent = DEFAULT_AGENT_NAME) {
|
|
|
192
193
|
};
|
|
193
194
|
}
|
|
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
|
+
|
|
195
212
|
// Ask the setup questions one at a time. `io.ask(prompt)` returns the answer (a promise or a
|
|
196
213
|
// string); `io.out(line)` prints. Only questions whose flag is missing are asked. Returns the
|
|
197
|
-
// answers, including `trainingOff` which is true only after the owner typed yes.
|
|
198
|
-
|
|
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 = [] } = {}) {
|
|
199
217
|
const out = io.out;
|
|
200
218
|
const ask = async (q) => oneLine(await io.ask(q));
|
|
201
219
|
out(WELCOME);
|
|
@@ -206,6 +224,14 @@ export async function interview(opts, io) {
|
|
|
206
224
|
const agent = a.agentName || DEFAULT_AGENT_NAME;
|
|
207
225
|
if (!a.owner) a.owner = await ask(`${q("owner", agent)} `);
|
|
208
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
|
+
}
|
|
209
235
|
if (!a.voice) a.voice = await ask(`${q("voice", agent)} `);
|
|
210
236
|
if (!a.platform) {
|
|
211
237
|
out(q("platform", agent));
|
package/lib/tools.mjs
CHANGED
|
@@ -32,13 +32,13 @@ const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
32
32
|
const MAX_WORDS = 200_000;
|
|
33
33
|
const MAX_READ = 256_000;
|
|
34
34
|
|
|
35
|
-
function refuse(message, code = "REFUSED") {
|
|
35
|
+
export function refuse(message, code = "REFUSED") {
|
|
36
36
|
return new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Arguments a tool does not take are refused by name, so an agent that tries to pass a path or a
|
|
40
40
|
// replacement learns the tool cannot do that, instead of the key being silently ignored.
|
|
41
|
-
function onlyKeys(tool, args, allowed, hint = "") {
|
|
41
|
+
export function onlyKeys(tool, args, allowed, hint = "") {
|
|
42
42
|
const extra = Object.keys(args ?? {}).filter((k) => !allowed.includes(k));
|
|
43
43
|
if (extra.length) throw refuse(`${tool} does not take ${extra.map((k) => JSON.stringify(k)).join(", ")}.${hint ? ` ${hint}` : ""}`, "UNKNOWN_ARGUMENT");
|
|
44
44
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-evda",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"templates",
|
|
17
17
|
"skills",
|
|
18
18
|
"migrations",
|
|
19
|
+
"presets",
|
|
19
20
|
"create-evda.mjs",
|
|
20
21
|
"README.md",
|
|
21
22
|
"LICENSE"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "journal",
|
|
3
|
+
"title": "Journal",
|
|
4
|
+
"for": "Someone who mostly wants to write about their days and the people in them, with little structure.",
|
|
5
|
+
"folders": [
|
|
6
|
+
{ "path": "Journal", "type": "record", "note": "One file per day, named YYYY-MM-DD.md." },
|
|
7
|
+
{ "path": "Captures", "type": "capture", "note": "A thought as it arrived." },
|
|
8
|
+
{ "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." },
|
|
9
|
+
{ "path": "Notes", "type": "concept", "note": "Ideas worth keeping past the day." }
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "projects-and-areas",
|
|
3
|
+
"title": "Projects and areas",
|
|
4
|
+
"for": "Someone running several projects and parts of life at once, who captures fast, sorts later, and looks back each week.",
|
|
5
|
+
"folders": [
|
|
6
|
+
{ "path": "00_Inbox", "note": "Anything not sorted yet." },
|
|
7
|
+
{ "path": "01_Projects", "type": "project", "note": "Work with an end: one folder per project." },
|
|
8
|
+
{ "path": "02_Areas", "note": "Parts of life with no end date: one folder per area." },
|
|
9
|
+
{ "path": "02_Areas/Academics", "note": "One folder per course below this one, typed course." },
|
|
10
|
+
{ "path": "02_Areas/Relationships/People", "type": "person", "connectors": ["contacts"], "note": "One file per person." },
|
|
11
|
+
{ "path": "03_Resources", "note": "Reference material kept for later." },
|
|
12
|
+
{ "path": "03_Resources/Raw", "type": "source", "connectors": ["url"], "note": "Sources as they arrived, unedited." },
|
|
13
|
+
{ "path": "03_Resources/Wiki/Concepts", "type": "concept", "note": "One page per recurring idea, named." },
|
|
14
|
+
{ "path": "03_Resources/Wiki/Syntheses", "type": "concept", "note": "Connections worked out across ideas." },
|
|
15
|
+
{ "path": "03_Resources/Wiki/Sources", "type": "source", "connectors": ["url"], "note": "A summary per source cited often." },
|
|
16
|
+
{ "path": "04_Archive", "note": "Finished or dropped, kept as it was." },
|
|
17
|
+
{ "path": "05_Process", "type": "capture", "note": "Captures waiting to be filed." },
|
|
18
|
+
{ "path": "05_Process/Processed", "type": "capture", "note": "Captures already filed, kept." },
|
|
19
|
+
{ "path": "05_Process/_media", "type": "blob", "note": "Photos and audio a capture carried." },
|
|
20
|
+
{ "path": "06_Metadata", "note": "The record about the record." },
|
|
21
|
+
{ "path": "06_Metadata/Daily Journal", "type": "record", "note": "One file per day, named YYYY-MM-DD.md." },
|
|
22
|
+
{ "path": "06_Metadata/Weekly Reviews", "type": "concept", "note": "One look back per week." },
|
|
23
|
+
{ "path": "06_Metadata/Templates", "note": "Note templates." },
|
|
24
|
+
{ "path": "07_Attachments", "note": "Files notes link to." }
|
|
25
|
+
],
|
|
26
|
+
"routines": ["morning", "weekly"]
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "student",
|
|
3
|
+
"title": "Student",
|
|
4
|
+
"for": "Someone in school who keeps courses, readings and projects alongside their own days.",
|
|
5
|
+
"folders": [
|
|
6
|
+
{ "path": "Journal", "type": "record", "note": "One file per day, named YYYY-MM-DD.md." },
|
|
7
|
+
{ "path": "Courses", "note": "One folder per course below this one, typed course." },
|
|
8
|
+
{ "path": "People", "type": "person", "connectors": ["contacts"], "note": "One file per person." },
|
|
9
|
+
{ "path": "Projects", "type": "project", "note": "Work with an end: one folder per project." },
|
|
10
|
+
{ "path": "Readings", "type": "source", "connectors": ["url"], "note": "What was assigned or read, with a link." },
|
|
11
|
+
{ "path": "Captures", "type": "capture", "note": "A thought as it arrived." }
|
|
12
|
+
]
|
|
13
|
+
}
|
package/skills/evda/SKILL.md
CHANGED
|
@@ -46,6 +46,24 @@ setup for each client. Do not keep the record some other way.
|
|
|
46
46
|
- **Never create a circle, grant anyone access, or move an entry into one.** That is their explicit
|
|
47
47
|
yes, done by them (`evda grant`), never by you.
|
|
48
48
|
|
|
49
|
+
## How the record is organized
|
|
50
|
+
|
|
51
|
+
Organization comes from the preset library, never from you making one up.
|
|
52
|
+
|
|
53
|
+
- **During setup**, or when they ask how to organize their EVÐA: list the presets with
|
|
54
|
+
`evda_presets`, say in one line which fits what they told you and why, show the plan with
|
|
55
|
+
`evda_propose_structure`, and wait for their yes. Only then call `evda_apply_preset` with
|
|
56
|
+
`confirmed: true`. A yes to something else is not a yes to this.
|
|
57
|
+
- **Later, only when a new kind of thing keeps appearing** (courses, readings, the same kind of
|
|
58
|
+
note again and again) and a folder in a preset already covers it: name that folder from the
|
|
59
|
+
library, show it with `evda_propose_structure`, and wait. Never invent a folder the library does
|
|
60
|
+
not have.
|
|
61
|
+
- **Applying only adds.** It never moves, renames or deletes, and a folder that is already set up
|
|
62
|
+
differently is reported and left alone. Tell them what it reported.
|
|
63
|
+
- **Where something shared goes:** a plain folder when it is theirs and private; a circle when it
|
|
64
|
+
is theirs and shared with specific people; its own EVÐA when someone else holds it or it will
|
|
65
|
+
outlive their involvement (a company, a team's project).
|
|
66
|
+
|
|
49
67
|
## Organizing (scheduled or asked)
|
|
50
68
|
|
|
51
69
|
`evda organize` does the mechanical part itself: it copies each capture, verbatim, into the daily
|
package/templates/AGENTS.md
CHANGED
|
@@ -205,12 +205,14 @@ help with that too. There is almost always a way to live well in the situation t
|
|
|
205
205
|
1. Read this file to the end, including "What I have been taught".
|
|
206
206
|
2. **If any field in "What this EVÐA is" is empty, set it up first,** conversationally. **One question
|
|
207
207
|
per message**, in this order, each its own turn: what to call you (Iris if they have no preference),
|
|
208
|
-
who they are, what they want this for, how they want you to talk, which AI they use with you, and
|
|
208
|
+
who they are, what they want this for, how to organize it, how they want you to talk, which AI they use with you, and
|
|
209
209
|
then whether model training is off for it. Write each answer into that section **in their exact
|
|
210
210
|
words**, hedges intact. **Before anything personal is written, training must be confirmed off.**
|
|
211
211
|
Give the steps only from the "Turning training off" section of `README.md`; **never give a setting
|
|
212
212
|
name or a provider's data policy from memory**, because those change and a wrong one is a privacy
|
|
213
|
-
failure. Once it is confirmed, file anything personal they already said during setup.
|
|
213
|
+
failure. Once it is confirmed, file anything personal they already said during setup. For how to
|
|
214
|
+
organize it, offer the presets by name and who each suits, or none, and apply the one they pick
|
|
215
|
+
only after their yes (see "Where things go").
|
|
214
216
|
3. Read the last few days of `record/` and anything they point you at.
|
|
215
217
|
4. Say in one line what you read, then answer them.
|
|
216
218
|
|
|
@@ -255,6 +257,14 @@ belongs and follow that type's shape. Point at what lives elsewhere (a contact,
|
|
|
255
257
|
another person's EVÐA) with a reference, never a copy. Never write a password, key or token into the
|
|
256
258
|
record. Run `evda doctor` after structural changes when the platform allows it.
|
|
257
259
|
|
|
260
|
+
- **Organization comes from the preset library.** `evda preset list` shows each preset and who it
|
|
261
|
+
suits. Propose one, show what it would add with `evda preset apply <id> --dry`, and apply it only
|
|
262
|
+
after their yes. Applying only adds; nothing is moved, renamed or deleted.
|
|
263
|
+
- **A new folder is proposed from a preset, never invented.** When a new kind of thing keeps
|
|
264
|
+
appearing and a preset already has a folder for it, name that folder (`evda preset diff <id>`
|
|
265
|
+
lists what the record lacks) and wait for their yes.
|
|
266
|
+
- **A plain folder when it is theirs and private; a circle when it is theirs and shared with specific people; its own EVÐA when someone else holds it or it will outlive their involvement (a company, a team's project).**
|
|
267
|
+
|
|
258
268
|
## Reads held loosely
|
|
259
269
|
|
|
260
270
|
- **Parked is the default.** A read about them goes in `ledger/` only when they have agreed it
|
package/templates/evda-digest.md
CHANGED
|
@@ -58,6 +58,9 @@ Instruction-only digest of the generated block of AGENTS.md. Read AGENTS.md in f
|
|
|
58
58
|
- Nothing is scored
|
|
59
59
|
- Never set a rule about their life with a threshold, count, streak or quota
|
|
60
60
|
## Where things go
|
|
61
|
+
- Organization comes from the preset library
|
|
62
|
+
- A new folder is proposed from a preset, never invented
|
|
63
|
+
- A plain folder when it is theirs and private; a circle when it is theirs and shared with specific people; its own EVÐA when someone else holds it or it will outlive their involvement (a company, a team's project)
|
|
61
64
|
## Reads held loosely
|
|
62
65
|
- Parked is the default
|
|
63
66
|
- Never quiz them about it and never list what is open
|