create-evda 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -3
- package/bin/evda.mjs +70 -1
- package/create-evda.mjs +27 -3
- package/lib/clients.mjs +48 -0
- package/lib/connect.mjs +84 -0
- package/lib/mcp.mjs +258 -0
- package/lib/organize.mjs +224 -0
- package/lib/routines.mjs +143 -0
- package/lib/setup.mjs +66 -9
- package/lib/tools.mjs +274 -0
- package/package.json +5 -3
- package/skills/evda/SKILL.md +70 -0
- package/templates/AGENTS.md +4 -0
- package/templates/evda-digest.md +1 -0
package/lib/organize.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// `evda organize`: go through what the owner left inward and say where it belongs. Moves nothing.
|
|
2
|
+
//
|
|
3
|
+
// The mechanical part is deterministic and done here:
|
|
4
|
+
// - each dated capture is copied, verbatim, into the daily file of its date, once. A marker comment
|
|
5
|
+
// in the daily file records the copy, so a second run files nothing twice and a rebuilt .evda/
|
|
6
|
+
// cannot make it forget.
|
|
7
|
+
// - the report lists what needs a person's judgment: people a recent note mentions whose file has
|
|
8
|
+
// nothing from that note, imports to check, undated notes, and notes that sit under no role.
|
|
9
|
+
// The report is appended to reports/organize/<date>.md. Nothing is moved, renamed or deleted, and
|
|
10
|
+
// nothing is written into circles/ or grants/.
|
|
11
|
+
//
|
|
12
|
+
// The judgment part runs through the owner's own AI (`--with-ai`): `claude -p` with the evda skill
|
|
13
|
+
// and read-only EVDA tools, given the report. Its answer is appended to the same report. EVDA holds
|
|
14
|
+
// no key and calls no model; `claude` is the owner's install, signed in as the owner.
|
|
15
|
+
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { resolveRole, resolveAll } from "./roles.mjs";
|
|
20
|
+
import { mdFiles, fileDate } from "./record.mjs";
|
|
21
|
+
import { secretShape } from "./references.mjs";
|
|
22
|
+
import { importsDir, localDate, localTime } from "./tools.mjs";
|
|
23
|
+
import { serverCommand, SKILL_SOURCE } from "./connect.mjs";
|
|
24
|
+
import { SERVER_NAME } from "./clients.mjs";
|
|
25
|
+
import { EvdaError } from "./errors.mjs";
|
|
26
|
+
|
|
27
|
+
export const REPORTS = "reports";
|
|
28
|
+
const marker = (rel) => `<!-- evda:capture ${rel} -->`;
|
|
29
|
+
const posix = (p) => p.split("\\").join("/");
|
|
30
|
+
|
|
31
|
+
function stripFrontmatter(text) {
|
|
32
|
+
if (!text.startsWith("---\n")) return text;
|
|
33
|
+
const end = text.indexOf("\n---", 3);
|
|
34
|
+
if (end === -1) return text;
|
|
35
|
+
return text.slice(text.indexOf("\n", end + 1) + 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function secretLine(text) {
|
|
39
|
+
for (const line of text.split("\n")) {
|
|
40
|
+
const s = secretShape(line);
|
|
41
|
+
if (s) return s;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A person's names: the first `# ` heading and the file's stem with - and _ as spaces.
|
|
47
|
+
function personNames(abs, text) {
|
|
48
|
+
const names = new Set();
|
|
49
|
+
const h = /^# (.+)$/m.exec(text);
|
|
50
|
+
if (h) names.add(h[1].trim());
|
|
51
|
+
names.add(basename(abs, ".md").replace(/[-_]+/g, " ").trim());
|
|
52
|
+
return [...names].filter((n) => n.length >= 2);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const mentions = (text, name) => new RegExp(`(^|[^\\p{L}\\p{N}])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\p{L}\\p{N}])`, "iu").test(text);
|
|
56
|
+
|
|
57
|
+
// What organize would do and has to say, without writing. `window` is how many days back a note
|
|
58
|
+
// counts as recent for the people check.
|
|
59
|
+
export function plan(evda, { now = new Date(), window = 7 } = {}) {
|
|
60
|
+
const rel = (abs) => posix(relative(evda.dir, abs));
|
|
61
|
+
const daily = resolveRole(evda, "record/daily");
|
|
62
|
+
const captures = resolveRole(evda, "record/captures");
|
|
63
|
+
const people = resolveRole(evda, "record/people");
|
|
64
|
+
if (!daily.mapped || !daily.exists) throw new EvdaError('organize needs a "record/daily" role that exists; it files captures into their day.', { code: "NO_ROLE", exit: 2 });
|
|
65
|
+
const start = new Date(now);
|
|
66
|
+
start.setDate(start.getDate() - (window - 1));
|
|
67
|
+
const from = localDate(start);
|
|
68
|
+
const out = { filings: [], people: [], imports: [], undated: [], unplaced: [], refused: [], examined: 0 };
|
|
69
|
+
|
|
70
|
+
// Captures into their day.
|
|
71
|
+
if (captures.mapped && captures.exists) {
|
|
72
|
+
for (const abs of mdFiles(captures.abs)) {
|
|
73
|
+
out.examined += 1;
|
|
74
|
+
const text = readFileSync(abs, "utf8");
|
|
75
|
+
const date = fileDate(abs, text);
|
|
76
|
+
if (!date) { out.undated.push(rel(abs)); continue; }
|
|
77
|
+
const secret = secretLine(text);
|
|
78
|
+
if (secret) { out.refused.push({ path: rel(abs), why: `holds ${secret}; not copied` }); continue; }
|
|
79
|
+
const target = join(daily.abs, `${date}.md`);
|
|
80
|
+
if (existsSync(target) && readFileSync(target, "utf8").includes(marker(rel(abs)))) continue;
|
|
81
|
+
out.filings.push({ from: rel(abs), to: rel(target), date, body: stripFrontmatter(text).replace(/\n+$/, "") });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const abs of mdFiles(daily.abs)) {
|
|
85
|
+
out.examined += 1;
|
|
86
|
+
if (!fileDate(abs, readFileSync(abs, "utf8"))) out.undated.push(rel(abs));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// People a recent note mentions, whose file carries nothing from that note.
|
|
90
|
+
if (people.mapped && people.exists) {
|
|
91
|
+
const recent = [...mdFiles(daily.abs), ...(captures.mapped && captures.exists ? mdFiles(captures.abs) : [])]
|
|
92
|
+
.map((abs) => ({ abs, text: readFileSync(abs, "utf8") }))
|
|
93
|
+
.filter((f) => { const d = fileDate(f.abs, f.text); return d && d >= from; });
|
|
94
|
+
for (const p of mdFiles(people.abs)) {
|
|
95
|
+
out.examined += 1;
|
|
96
|
+
const ptext = readFileSync(p, "utf8");
|
|
97
|
+
for (const name of personNames(p, ptext)) {
|
|
98
|
+
for (const f of recent) {
|
|
99
|
+
if (!mentions(f.text, name)) continue;
|
|
100
|
+
if (ptext.includes(rel(f.abs)) || ptext.includes(basename(f.abs, ".md"))) continue;
|
|
101
|
+
if (out.people.some((x) => x.person === rel(p) && x.note === rel(f.abs))) continue;
|
|
102
|
+
out.people.push({ person: rel(p), name, note: rel(f.abs) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Imports: each is a claim to check with the owner over time. Recent ones are listed.
|
|
109
|
+
const imp = importsDir(evda);
|
|
110
|
+
if (existsSync(imp.abs)) {
|
|
111
|
+
for (const abs of mdFiles(imp.abs)) {
|
|
112
|
+
out.examined += 1;
|
|
113
|
+
const d = fileDate(abs, readFileSync(abs, "utf8"));
|
|
114
|
+
if (d && d >= from) out.imports.push(rel(abs));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Notes under the record's folder that no role or the imports folder claims.
|
|
119
|
+
const claimed = resolveAll(evda).filter((r) => r.mapped && r.exists && r.isDir).map((r) => r.abs);
|
|
120
|
+
claimed.push(imp.abs);
|
|
121
|
+
const root = dirname(daily.abs);
|
|
122
|
+
if (root !== evda.dir && existsSync(root)) {
|
|
123
|
+
const walk = (d) => {
|
|
124
|
+
for (const n of readdirSync(d).sort()) {
|
|
125
|
+
if (n.startsWith(".")) continue;
|
|
126
|
+
const abs = join(d, n);
|
|
127
|
+
if (claimed.includes(abs)) continue;
|
|
128
|
+
if (statSync(abs).isDirectory()) walk(abs);
|
|
129
|
+
else if (n.endsWith(".md")) { out.examined += 1; out.unplaced.push(rel(abs)); }
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk(root);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function reportText(p, { now = new Date(), window = 7 } = {}) {
|
|
138
|
+
const lines = [`## Run ${localTime(now)}`, ""];
|
|
139
|
+
lines.push(`Examined ${p.examined} note(s). Moved nothing.`, "");
|
|
140
|
+
lines.push("### Filed");
|
|
141
|
+
if (p.filings.length) for (const f of p.filings) lines.push(`- ${f.from} copied verbatim into ${f.to}`);
|
|
142
|
+
else lines.push("- Nothing new to file.");
|
|
143
|
+
lines.push("", `### People mentioned in the last ${window} days`);
|
|
144
|
+
if (p.people.length) for (const x of p.people) lines.push(`- ${x.note} mentions ${x.name}; ${x.person} has nothing from it yet.`);
|
|
145
|
+
else lines.push("- None without a note on their file.");
|
|
146
|
+
lines.push("", "### Imports to check with the owner over time");
|
|
147
|
+
if (p.imports.length) for (const i of p.imports) lines.push(`- ${i}: what another AI said, not their words.`);
|
|
148
|
+
else lines.push("- None recent.");
|
|
149
|
+
if (p.undated.length) { lines.push("", "### Undated, so not filed"); for (const u of p.undated) lines.push(`- ${u}: needs a YYYY-MM-DD at the start of its name, or a date: line.`); }
|
|
150
|
+
if (p.unplaced.length) { lines.push("", "### Under no role"); for (const u of p.unplaced) lines.push(`- ${u}: not in daily, captures, people or imports.`); }
|
|
151
|
+
if (p.refused.length) { lines.push("", "### Not copied"); for (const r of p.refused) lines.push(`- ${r.path}: ${r.why}. Keep the secret in a password manager and take it out of the note.`); }
|
|
152
|
+
lines.push("");
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function appendReport(evda, kind, date, block) {
|
|
157
|
+
const dir = join(evda.dir, REPORTS, kind);
|
|
158
|
+
mkdirSync(dir, { recursive: true });
|
|
159
|
+
const abs = join(dir, `${date}.md`);
|
|
160
|
+
if (!existsSync(abs)) writeFileSync(abs, `# ${kind === "organize" ? "Organize" : "Look back"}, ${date}\n\nWritten by evda ${kind}. Append only; each run adds a section.\n`);
|
|
161
|
+
appendFileSync(abs, `\n${block.replace(/\n+$/, "")}\n`);
|
|
162
|
+
return posix(relative(evda.dir, abs));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Run the owner's claude -p with the skill and read-only EVDA tools. Returns { ok, text, why }.
|
|
166
|
+
export function askOwnersAi(evda, prompt, { model = null, env = process.env, timeoutMs = 600_000 } = {}) {
|
|
167
|
+
const bin = env.EVDA_CLAUDE || "claude";
|
|
168
|
+
const { command, args } = serverCommand(evda.dir);
|
|
169
|
+
const mcp = JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "stdio", command, args } } });
|
|
170
|
+
const allowed = ["evda_operating_layer", "evda_recent", "evda_search", "evda_circles"].map((t) => `mcp__${SERVER_NAME}__${t}`);
|
|
171
|
+
const skill = existsSync(SKILL_SOURCE) ? readFileSync(SKILL_SOURCE, "utf8") : "";
|
|
172
|
+
const argv = ["-p", prompt, "--mcp-config", mcp, "--strict-mcp-config", "--allowedTools", allowed.join(","), "--output-format", "text"];
|
|
173
|
+
if (skill) argv.push("--append-system-prompt", skill);
|
|
174
|
+
if (model) argv.push("--model", model);
|
|
175
|
+
const r = spawnSync(bin, argv, { cwd: evda.dir, env, encoding: "utf8", timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
|
|
176
|
+
if (r.error) return { ok: false, why: r.error.code === "ENOENT" ? `${bin} is not installed or not on PATH` : String(r.error.message) };
|
|
177
|
+
if (r.status !== 0) return { ok: false, why: `${bin} -p exited ${r.status}: ${(r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" ")}` };
|
|
178
|
+
const text = (r.stdout ?? "").trim();
|
|
179
|
+
if (!text) return { ok: false, why: `${bin} -p answered with nothing` };
|
|
180
|
+
return { ok: true, text };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function organize(evda, { now = new Date(), window = 7, withAi = false, dryRun = false, model = null, env = process.env } = {}) {
|
|
184
|
+
const p = plan(evda, { now, window });
|
|
185
|
+
const date = localDate(now);
|
|
186
|
+
let report = reportText(p, { now, window });
|
|
187
|
+
if (dryRun) return { plan: p, report, path: null, exit: 0, summary: `${report}\n(dry run: nothing was written)` };
|
|
188
|
+
|
|
189
|
+
for (const f of p.filings) {
|
|
190
|
+
const abs = join(evda.dir, f.to);
|
|
191
|
+
const head = existsSync(abs) ? "" : `# ${f.date}\n`;
|
|
192
|
+
const lead = existsSync(abs) && !readFileSync(abs, "utf8").endsWith("\n") ? "\n" : "";
|
|
193
|
+
appendFileSync(abs, `${lead}${head}\n## Capture: ${basename(f.from, ".md")}\n\n${marker(f.from)}\n\n${f.body}\n`);
|
|
194
|
+
}
|
|
195
|
+
let exit = 0;
|
|
196
|
+
if (withAi) {
|
|
197
|
+
const ai = askOwnersAi(evda, [
|
|
198
|
+
"Use the evda skill's Organizing section. Here is today's organize report for this record.",
|
|
199
|
+
"For each note that needs a place, answer in one line: the note's path, where it belongs, and the words from the note that say so, quoted.",
|
|
200
|
+
"Check against the record with the evda tools first. Write nothing. If nothing needs a place, say so in one line.",
|
|
201
|
+
"",
|
|
202
|
+
report,
|
|
203
|
+
].join("\n"), { model, env });
|
|
204
|
+
if (ai.ok) {
|
|
205
|
+
const secret = secretLine(ai.text);
|
|
206
|
+
report += secret ? `\n### Your AI's read\n\nNot kept: the answer held ${secret}.\n` : `\n### Your AI's read (claude -p)\n\n${ai.text}\n`;
|
|
207
|
+
} else {
|
|
208
|
+
report += `\n### Your AI's read\n\nSKIPPED: ${ai.why}. Nothing else in this run depended on it.\n`;
|
|
209
|
+
exit = 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const path = appendReport(evda, "organize", date, report);
|
|
213
|
+
return { plan: p, report, path, exit, summary: `${report}\nReport appended to ${path}. Filed ${p.filings.length} capture(s).` };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function lookBack(evda, { now = new Date(), model = null, env = process.env } = {}) {
|
|
217
|
+
const ai = askOwnersAi(evda, "Use the evda skill's Look back section for the last 7 days of this record. Write nothing; your answer is appended to a report.", { model, env });
|
|
218
|
+
const date = localDate(now);
|
|
219
|
+
const block = ai.ok
|
|
220
|
+
? (secretLine(ai.text) ? `## Run ${localTime(now)}\n\nNot kept: the answer held a secret-shaped line.\n` : `## Run ${localTime(now)}\n\n${ai.text}\n`)
|
|
221
|
+
: `## Run ${localTime(now)}\n\nSKIPPED: ${ai.why}.\n`;
|
|
222
|
+
const path = appendReport(evda, "look-back", date, block);
|
|
223
|
+
return { path, exit: ai.ok ? 0 : 1, summary: `${block}\nAppended to ${path}.` };
|
|
224
|
+
}
|
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
|
@@ -69,11 +69,19 @@ export function trainingSection() {
|
|
|
69
69
|
}).join("\n");
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
// A chat app
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// A chat app's project instructions field cannot read or write this folder: it holds text for
|
|
73
|
+
// that conversation only, and nothing persists to record/. Pasting AGENTS.md into a ChatGPT or
|
|
74
|
+
// Claude Project does not run an EVDA. The Claude desktop app is different once its Filesystem
|
|
75
|
+
// extension is pointed at this folder; it then reads AGENTS.md the way Claude Code does.
|
|
76
|
+
export const DESKTOP_FILESYSTEM = [
|
|
77
|
+
"Claude desktop app: Settings, then Extensions, install \"Filesystem\", add this folder, and it reads AGENTS.md too.",
|
|
75
78
|
];
|
|
76
79
|
|
|
80
|
+
// The prompt an owner pastes into a chat app (ChatGPT, Claude, or any AI they already talk to) to
|
|
81
|
+
// pull out what it knows or has guessed about them, so it can be brought into their EVDA once as
|
|
82
|
+
// an import. Plain text, nothing structured, because the answer is filed verbatim.
|
|
83
|
+
export const IMPORT_PROMPT = "Write out everything you remember or have inferred about me: facts, preferences, patterns, things I've told you directly, and things you've only guessed. Plain text, as much as you have, and do not summarize it down.";
|
|
84
|
+
|
|
77
85
|
// The loaders: one line that imports AGENTS.md, for the tools that do not read it on their own.
|
|
78
86
|
// Codex, Cursor, Copilot, Windsurf, Zed and the others on agents.md read AGENTS.md directly and
|
|
79
87
|
// need nothing.
|
|
@@ -158,6 +166,32 @@ export const WELCOME = [
|
|
|
158
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.",
|
|
159
167
|
].join("\n");
|
|
160
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: "voice", text: `How do you want ${agent} to talk to you?` },
|
|
178
|
+
{ key: "platform", text: "Which AI will you use it with?" },
|
|
179
|
+
{ key: "trainingOff", text: "Before anything personal goes in, turn model training off:" },
|
|
180
|
+
{ key: "import", text: "Bring in what your AI already knows about you?" },
|
|
181
|
+
{ key: "skill", text: `Install the evda skill for Claude Code, so ${agent} works through your EVÐA from any folder?` },
|
|
182
|
+
{ key: "morning", text: "Add a morning organize routine, which files your captures into their day and reports what belongs where?" },
|
|
183
|
+
{ key: "weekly", text: "Add a weekly look back routine, which reads your week back to you?" },
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// What a yes to the import offer shows: the line before IMPORT_PROMPT and the line after it.
|
|
188
|
+
export function importLines(agent = DEFAULT_AGENT_NAME) {
|
|
189
|
+
return {
|
|
190
|
+
intro: "Paste this into a new chat with ChatGPT, Claude, or whatever AI you already talk to:",
|
|
191
|
+
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.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
161
195
|
// Ask the setup questions one at a time. `io.ask(prompt)` returns the answer (a promise or a
|
|
162
196
|
// string); `io.out(line)` prints. Only questions whose flag is missing are asked. Returns the
|
|
163
197
|
// answers, including `trainingOff` which is true only after the owner typed yes.
|
|
@@ -167,13 +201,14 @@ export async function interview(opts, io) {
|
|
|
167
201
|
out(WELCOME);
|
|
168
202
|
out("");
|
|
169
203
|
const a = { ...opts };
|
|
170
|
-
|
|
204
|
+
const q = (key, agent) => questions(agent).find((x) => x.key === key).text;
|
|
205
|
+
if (a.agentName == null) a.agentName = (await ask(`${q("agentName")} `)) || DEFAULT_AGENT_NAME;
|
|
171
206
|
const agent = a.agentName || DEFAULT_AGENT_NAME;
|
|
172
|
-
if (!a.owner) a.owner = await ask("
|
|
173
|
-
if (!a.purpose) a.purpose = await ask(
|
|
174
|
-
if (!a.voice) a.voice = await ask(
|
|
207
|
+
if (!a.owner) a.owner = await ask(`${q("owner", agent)} `);
|
|
208
|
+
if (!a.purpose) a.purpose = await ask(`${q("purpose", agent)} `);
|
|
209
|
+
if (!a.voice) a.voice = await ask(`${q("voice", agent)} `);
|
|
175
210
|
if (!a.platform) {
|
|
176
|
-
out("
|
|
211
|
+
out(q("platform", agent));
|
|
177
212
|
PLATFORM_KEYS.forEach((k, i) => out(` ${i + 1}. ${PLATFORMS[k].label}`));
|
|
178
213
|
let key = null;
|
|
179
214
|
let raw = "";
|
|
@@ -187,7 +222,7 @@ export async function interview(opts, io) {
|
|
|
187
222
|
const p = PLATFORMS[a.platform];
|
|
188
223
|
if (!a.trainingOff) {
|
|
189
224
|
out("");
|
|
190
|
-
out("
|
|
225
|
+
out(q("trainingOff", agent));
|
|
191
226
|
for (const line of p.training) out(` ${line}`);
|
|
192
227
|
if (p.url) out(` ${p.url}`);
|
|
193
228
|
let yes = false;
|
|
@@ -200,5 +235,27 @@ export async function interview(opts, io) {
|
|
|
200
235
|
}
|
|
201
236
|
out("");
|
|
202
237
|
out(DICTATION);
|
|
238
|
+
out("");
|
|
239
|
+
const bring = (await ask(`${q("import", agent)} (yes/no) `)).toLowerCase();
|
|
240
|
+
if (bring === "yes" || bring === "y") {
|
|
241
|
+
const lines = importLines(agent);
|
|
242
|
+
out("");
|
|
243
|
+
out(lines.intro);
|
|
244
|
+
out("");
|
|
245
|
+
out(` ${IMPORT_PROMPT}`);
|
|
246
|
+
out("");
|
|
247
|
+
out(lines.after);
|
|
248
|
+
}
|
|
249
|
+
const yes = async (key) => ["yes", "y"].includes((await ask(`${q(key, agent)} (yes/no) `)).toLowerCase());
|
|
250
|
+
if (a.skill == null) {
|
|
251
|
+
out("");
|
|
252
|
+
a.skill = await yes("skill");
|
|
253
|
+
}
|
|
254
|
+
if (a.routines == null) {
|
|
255
|
+
out("");
|
|
256
|
+
out("Routines are optional and nothing is scheduled without a yes. Each runs through your own AI, never an API key in EVÐA.");
|
|
257
|
+
a.routines = [];
|
|
258
|
+
for (const key of ["morning", "weekly"]) if (await yes(key)) a.routines.push(key);
|
|
259
|
+
}
|
|
203
260
|
return a;
|
|
204
261
|
}
|