create-evda 0.1.9 → 0.1.11
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/CHANGELOG.md +95 -0
- package/README.md +12 -5
- package/bin/evda.mjs +129 -6
- package/create-evda.mjs +14 -11
- package/lib/adopt.mjs +516 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +287 -0
- package/lib/attested-wrap.mjs +147 -0
- package/lib/checkout-freshness.mjs +88 -0
- package/lib/circles.mjs +4 -2
- package/lib/clients.mjs +11 -0
- package/lib/connect.mjs +1 -1
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +121 -12
- package/lib/day-writer.mjs +39 -5
- package/lib/doctor.mjs +60 -5
- package/lib/every-chat.mjs +7 -11
- package/lib/folders.mjs +2 -1
- package/lib/gather-apply.mjs +133 -24
- package/lib/gather.mjs +120 -19
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +84 -12
- package/lib/keychain.mjs +38 -1
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-http.mjs +19 -1
- package/lib/mcp.mjs +358 -56
- package/lib/measurement-log.mjs +3 -0
- package/lib/migrate-people.mjs +54 -20
- package/lib/person-folders.mjs +90 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +28 -6
- package/lib/prompt-hook.mjs +115 -4
- package/lib/publish.mjs +55 -10
- package/lib/readme.mjs +145 -0
- package/lib/record-walk.mjs +9 -0
- package/lib/record.mjs +54 -0
- package/lib/routines.mjs +13 -2
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +432 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +669 -59
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/templates/AGENTS.md +56 -6
- package/templates/evda-digest.md +2 -0
package/lib/presets.mjs
CHANGED
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
// that are missing, and reports the rest: a manifest that already says the same thing is skipped,
|
|
29
29
|
// one that says something else is a conflict and is left exactly as it is, and a preset path that
|
|
30
30
|
// already exists as a file is a conflict. Nothing is moved, renamed or deleted, so applying twice
|
|
31
|
-
// writes nothing the second time. A
|
|
31
|
+
// writes nothing the second time. A folder typed person also brings its layout (HQ-2024): a sibling
|
|
32
|
+
// Processing/ for processing docs, and a README inside the folder stating the one-file-per-person
|
|
33
|
+
// rule doctor checks, each only when missing and only when the folder's manifest is the preset's.
|
|
34
|
+
// A shipped preset that does not validate fails
|
|
32
35
|
// test/presets.test.mjs, so the person running it never meets one.
|
|
33
36
|
|
|
34
37
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -40,6 +43,8 @@ import { validateFolderManifest, FOLDER_MANIFEST, FOLDER_TYPES } from "./folders
|
|
|
40
43
|
import { ROUTINE_KEYS } from "./routines.mjs";
|
|
41
44
|
import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
|
|
42
45
|
import { EvdaError } from "./errors.mjs";
|
|
46
|
+
import { PERSON_FOLDER_README, PROCESSING_DIR, README_NAME } from "./person-folders.mjs";
|
|
47
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
43
48
|
|
|
44
49
|
export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
|
|
45
50
|
export const PRESET_FILE = "preset.json";
|
|
@@ -62,7 +67,7 @@ function pathProblem(p) {
|
|
|
62
67
|
if (p.startsWith("/")) return "is absolute; paths are relative to the record root";
|
|
63
68
|
for (const seg of p.split("/")) {
|
|
64
69
|
if (seg === "" || seg === "." || seg === "..") return `has an empty, . or .. segment`;
|
|
65
|
-
if (seg.startsWith(".") || seg
|
|
70
|
+
if (seg.startsWith(".") || outsideRecord(seg)) return `has the segment "${seg}", which doctor does not read`;
|
|
66
71
|
}
|
|
67
72
|
return null;
|
|
68
73
|
}
|
|
@@ -223,6 +228,7 @@ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}
|
|
|
223
228
|
// create directories that do not exist yet
|
|
224
229
|
// write manifests that do not exist yet ({ path, folder, manifest })
|
|
225
230
|
// views view files a manifest declares that do not exist yet
|
|
231
|
+
// files files a person folder brings that do not exist yet ({ path, text }): its README
|
|
226
232
|
// matched manifests that exist and already say what the preset says (skipped)
|
|
227
233
|
// conflicts manifests that exist and say something else, and preset paths that are files
|
|
228
234
|
// ({ path, why }); reported, never overwritten
|
|
@@ -231,7 +237,7 @@ const viewHeader = (v) => ["---", `view: ${v.view}`, `of: ${v.of}`, `in: ${v.in}
|
|
|
231
237
|
export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRESETS_DIR } = {}) {
|
|
232
238
|
const evda = openEvda(resolve(root));
|
|
233
239
|
const preset = loadPreset(id, { dir });
|
|
234
|
-
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, roles: [] };
|
|
240
|
+
const plan = { id: preset.id, title: preset.title, root: evda.dir, dry, create: [], write: [], files: [], views: [], matched: [], conflicts: [], circles: [], proposed: [], routines: preset.routines ?? [], folders: preset.folders.length, roles: [] };
|
|
235
241
|
// The four roles the record does not declare yet. A key it has with another path is a conflict,
|
|
236
242
|
// left as it is; a path it already maps under a key of the same role is already there.
|
|
237
243
|
const has = evda.manifest.mapping;
|
|
@@ -263,15 +269,29 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
|
|
|
263
269
|
const want = manifestFor(f);
|
|
264
270
|
const mpath = `${f.path}/${FOLDER_MANIFEST}`;
|
|
265
271
|
const mabs = join(abs, FOLDER_MANIFEST);
|
|
272
|
+
let ours = true;
|
|
266
273
|
if (!existsSync(mabs)) plan.write.push({ path: mpath, folder: f, manifest: want });
|
|
267
|
-
else if (!lstatSync(mabs).isFile()) plan.conflicts.push({ path: mpath, why: "exists and is not a file" });
|
|
274
|
+
else if (!lstatSync(mabs).isFile()) { plan.conflicts.push({ path: mpath, why: "exists and is not a file" }); ours = false; }
|
|
268
275
|
else {
|
|
269
276
|
let existing;
|
|
270
277
|
try { existing = JSON.parse(readFileSync(mabs, "utf8")); } catch { plan.conflicts.push({ path: mpath, why: "exists and is not valid JSON" }); continue; }
|
|
271
278
|
const diff = differences(existing, want);
|
|
272
|
-
if (diff.length) plan.conflicts.push({ path: mpath, why: `exists and differs: ${diff.join("; ")}` });
|
|
279
|
+
if (diff.length) { plan.conflicts.push({ path: mpath, why: `exists and differs: ${diff.join("; ")}` }); ours = false; }
|
|
273
280
|
else plan.matched.push({ path: mpath });
|
|
274
281
|
}
|
|
282
|
+
if (f.type === "person" && ours) {
|
|
283
|
+
const readme = `${f.path}/${README_NAME}`;
|
|
284
|
+
const rabs = join(abs, README_NAME);
|
|
285
|
+
if (!existsSync(rabs)) plan.files.push({ path: readme, text: PERSON_FOLDER_README });
|
|
286
|
+
else if (!lstatSync(rabs).isFile()) plan.conflicts.push({ path: readme, why: "exists and is not a file" });
|
|
287
|
+
const parent = f.path.includes("/") ? f.path.slice(0, f.path.lastIndexOf("/")) : "";
|
|
288
|
+
const processing = parent ? `${parent}/${PROCESSING_DIR}` : PROCESSING_DIR;
|
|
289
|
+
const pfile = blockedBy(evda.dir, processing);
|
|
290
|
+
if (pfile) {
|
|
291
|
+
if (!blocked.has(pfile)) plan.conflicts.push({ path: pfile, why: `exists as a file, so ${processing}/ for processing docs beside ${f.path} cannot be made there` });
|
|
292
|
+
blocked.add(pfile);
|
|
293
|
+
} else if (!existsSync(join(evda.dir, processing)) && !plan.create.includes(processing)) plan.create.push(processing);
|
|
294
|
+
}
|
|
275
295
|
for (const v of want.views) {
|
|
276
296
|
if (!existsSync(resolve(evda.dir, v.path))) plan.views.push({ path: v.path, view: v });
|
|
277
297
|
}
|
|
@@ -295,6 +315,7 @@ export function applyPreset(root, id, { dry = false, now = new Date(), dir = PRE
|
|
|
295
315
|
}
|
|
296
316
|
for (const rel of plan.create) mkdirSync(join(evda.dir, rel), { recursive: true });
|
|
297
317
|
for (const w of plan.write) writeFileSync(join(evda.dir, w.path), JSON.stringify(w.manifest, null, 2) + "\n", { flag: "wx" });
|
|
318
|
+
for (const w of plan.files) writeFileSync(join(evda.dir, w.path), w.text, { flag: "wx" });
|
|
298
319
|
for (const v of plan.views) {
|
|
299
320
|
mkdirSync(dirname(resolve(evda.dir, v.path)), { recursive: true });
|
|
300
321
|
writeFileSync(resolve(evda.dir, v.path), viewHeader(v.view), { flag: "wx" });
|
|
@@ -317,7 +338,7 @@ export function diffPreset(root, id, opts = {}) {
|
|
|
317
338
|
return applyPreset(root, id, { ...opts, dry: true });
|
|
318
339
|
}
|
|
319
340
|
|
|
320
|
-
export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !plan.views.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length;
|
|
341
|
+
export const planIsEmpty = (plan) => !plan.create.length && !plan.write.length && !(plan.files ?? []).length && !plan.views.length && !plan.circles.length && !plan.conflicts.length && !(plan.roles ?? []).length;
|
|
321
342
|
|
|
322
343
|
// The plan in plain words, for the owner to say yes or no to. Names each folder by its note.
|
|
323
344
|
export function summary(plan, preset = loadPreset(plan.id)) {
|
|
@@ -344,6 +365,7 @@ export function planLines(plan, { diff = false } = {}) {
|
|
|
344
365
|
const verb = plan.dry || diff ? "would" : "did";
|
|
345
366
|
for (const p of plan.create) out.push(` ${verb === "did" ? "created " : "create "} ${p}/`);
|
|
346
367
|
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(", ")}` : ""}`);
|
|
368
|
+
for (const w of plan.files ?? []) out.push(` ${verb === "did" ? "wrote " : "write "} ${w.path} the person folder's rule`);
|
|
347
369
|
for (const r of plan.roles ?? []) out.push(` ${verb === "did" ? "declared" : "declare "} ${r.key} -> ${r.rel} in evda.json`);
|
|
348
370
|
for (const v of plan.views) out.push(` ${verb === "did" ? "wrote " : "write "} ${v.path} view ${v.view.view} (header only)`);
|
|
349
371
|
if (!diff) for (const m of plan.matched) out.push(` skipped ${m.path} already says the same`);
|
package/lib/prompt-hook.mjs
CHANGED
|
@@ -23,10 +23,30 @@
|
|
|
23
23
|
//
|
|
24
24
|
// EVDA_CHAT_FALSIFY=ignore-filing files even when evda.json does not say "everything", for the test
|
|
25
25
|
// that shows the hook reads the owner's answer.
|
|
26
|
+
//
|
|
27
|
+
// ON A MACHINE THAT DOES NOT WRITE THE DAY (HQ-2817). EVDA_DAY_WRITER set means another machine
|
|
28
|
+
// writes the daily file, and a local append is the iCloud race HQ-2165 exists to stop (lib/
|
|
29
|
+
// day-writer.mjs). There the hook never touches the day file. It hands the words to the command in
|
|
30
|
+
// EVDA_DAY_WRITER_SEND, which receives evda_write's arguments as JSON on stdin and exits 0 once the
|
|
31
|
+
// writer filed them (Mike's: ssh to Hestia and its evda-tool.mjs evda_write). Each message is first
|
|
32
|
+
// saved as one JSON file in this machine's own outbox, outside the record and outside any synced
|
|
33
|
+
// folder (EVDA_PROMPT_OUTBOX, default ~/.evda/outbox), then the outbox is sent oldest first and a
|
|
34
|
+
// file is deleted only after its send exits 0. So a message said while the writer is unreachable
|
|
35
|
+
// waits there and goes on the next message that reaches it, filed once:
|
|
36
|
+
// - one drainer: only this machine's hook reads its outbox, so no other machine can file it too;
|
|
37
|
+
// - one sender per file: a send first renames the file to `.sending`, an atomic claim, so two
|
|
38
|
+
// sessions submitting at once never send the same file; a claim older than SENDING_STALE_MS
|
|
39
|
+
// (a hook that was killed mid-send) is put back for the next run.
|
|
40
|
+
// The whole send is bounded by EVDA_DAY_WRITER_SEND_MS (default 6000) because Claude Code holds the
|
|
41
|
+
// owner's message until this hook returns. One case can still file twice: a send that reached the
|
|
42
|
+
// writer and timed out before it answered. A connect timeout on the sender's side keeps that rare.
|
|
43
|
+
// With EVDA_DAY_WRITER set and no EVDA_DAY_WRITER_SEND, nothing on this machine can reach the
|
|
44
|
+
// writer, so nothing is queued and the hook tells the model to file the message with evda_write.
|
|
26
45
|
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
46
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
28
47
|
import { dirname, join, relative } from "node:path";
|
|
29
|
-
import { tmpdir } from "node:os";
|
|
48
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
49
|
+
import { spawnSync } from "node:child_process";
|
|
30
50
|
import { fileURLToPath } from "node:url";
|
|
31
51
|
import { dayRole, findRecord, filingOf, appendToDay, localDate } from "./day-file.mjs";
|
|
32
52
|
import { secretShape } from "./secret-shape.mjs";
|
|
@@ -35,14 +55,100 @@ export const PROMPT_HOOK_NAME = "evda-file-prompt.mjs";
|
|
|
35
55
|
|
|
36
56
|
const falsified = (mode) => (process.env.EVDA_CHAT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
37
57
|
|
|
58
|
+
export const DAY_WRITER_SEND_ENV = "EVDA_DAY_WRITER_SEND";
|
|
59
|
+
export const PROMPT_OUTBOX_ENV = "EVDA_PROMPT_OUTBOX";
|
|
60
|
+
export const SEND_BUDGET_MS = 6000;
|
|
61
|
+
export const SENDING_STALE_MS = 2 * 60 * 1000;
|
|
62
|
+
|
|
63
|
+
export function promptOutboxDir(env = process.env) {
|
|
64
|
+
return env[PROMPT_OUTBOX_ENV] || join(homedir(), ".evda", "outbox");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
68
|
+
|
|
69
|
+
// Save one message for the writer: a new file per message, never rewritten, in this machine's outbox.
|
|
70
|
+
export function queuePrompt(root, { words, context, now = new Date(), env = process.env }) {
|
|
71
|
+
const dir = promptOutboxDir(env);
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
const at = now.toISOString();
|
|
74
|
+
const stamp = at.replace(/[-:.]/g, "");
|
|
75
|
+
let file = join(dir, `${stamp}-${process.pid}.json`);
|
|
76
|
+
for (let n = 2; existsSync(file); n++) file = join(dir, `${stamp}-${process.pid}-${n}.json`);
|
|
77
|
+
writeFileSync(file, JSON.stringify({ tool: "evda_write", record: realOr(root), words, context, at, host: hostname().split(".")[0] || "host" }, null, 1) + "\n", { flag: "wx" });
|
|
78
|
+
return file;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Send this record's queued messages to the writer, oldest first, within `budgetMs`. Returns
|
|
82
|
+
// { sent: [file...], left, error }. Stops at the first failure, so the order of the day is kept.
|
|
83
|
+
// A claim's age is measured against the file system's own clock (the mtime of a file touched now),
|
|
84
|
+
// never Date.now(): a claim's mtime is stamped by the file system, and the two clocks can disagree.
|
|
85
|
+
export function drainPromptOutbox(root, { env = process.env, budgetMs = Number(env.EVDA_DAY_WRITER_SEND_MS) || SEND_BUDGET_MS, nowMs = null } = {}) {
|
|
86
|
+
const cmd = (env[DAY_WRITER_SEND_ENV] ?? "").trim();
|
|
87
|
+
const dir = promptOutboxDir(env);
|
|
88
|
+
const out = { sent: [], left: 0, error: null };
|
|
89
|
+
if (!cmd || !existsSync(dir)) return out;
|
|
90
|
+
if (nowMs === null) { const probe = join(dir, ".now"); writeFileSync(probe, ""); nowMs = statSync(probe).mtimeMs; }
|
|
91
|
+
const rec = realOr(root);
|
|
92
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith(".json.sending"))) {
|
|
93
|
+
const abs = join(dir, f);
|
|
94
|
+
try { if (nowMs - statSync(abs).mtimeMs > SENDING_STALE_MS) renameSync(abs, abs.replace(/\.sending$/, "")); } catch { /* another run took it back */ }
|
|
95
|
+
}
|
|
96
|
+
const mine = readdirSync(dir).filter((n) => n.endsWith(".json")).sort().filter((n) => {
|
|
97
|
+
try { return JSON.parse(readFileSync(join(dir, n), "utf8")).record === rec; } catch { return false; }
|
|
98
|
+
});
|
|
99
|
+
const deadline = Date.now() + budgetMs;
|
|
100
|
+
for (let i = 0; i < mine.length; i++) {
|
|
101
|
+
const abs = join(dir, mine[i]);
|
|
102
|
+
const claim = `${abs}.sending`;
|
|
103
|
+
try { renameSync(abs, claim); } catch { continue; } // another session claimed it
|
|
104
|
+
let entry;
|
|
105
|
+
try { entry = JSON.parse(readFileSync(claim, "utf8")); } catch { renameSync(claim, abs); continue; }
|
|
106
|
+
const left = deadline - Date.now();
|
|
107
|
+
const args = { words: entry.words, ...(entry.context ? { context: entry.context } : {}) };
|
|
108
|
+
const r = left > 200 ? spawnSync(cmd, { shell: true, input: JSON.stringify(args), encoding: "utf8", timeout: left, env }) : { status: null, error: { code: "ETIMEDOUT" } };
|
|
109
|
+
if (r.status === 0) { unlinkSync(claim); out.sent.push(abs); continue; }
|
|
110
|
+
try { renameSync(claim, abs); } catch { /* kept as a claim; the stale rule puts it back */ }
|
|
111
|
+
out.error = r.error ? (r.error.code === "ETIMEDOUT" ? `no answer in ${budgetMs}ms` : r.error.code ?? r.error.message) : `exited ${r.status ?? r.signal}: ${String(r.stderr ?? "").trim().split("\n").pop() ?? ""}`.trim();
|
|
112
|
+
out.left = mine.length - i;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const timeOf = (d) => `${localDate(d)} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
119
|
+
|
|
120
|
+
// The non-writer path: queue, send, and say what happened. Never touches the day file.
|
|
121
|
+
function viaWriter(root, prompt, now, env) {
|
|
122
|
+
const writer = env.EVDA_DAY_WRITER.trim();
|
|
123
|
+
if (!(env[DAY_WRITER_SEND_ENV] ?? "").trim()) {
|
|
124
|
+
return `evda: this machine does not write the daily file (EVDA_DAY_WRITER is ${writer}) and has no ${DAY_WRITER_SEND_ENV} to reach it, so the owner's message above is NOT filed yet. File it verbatim with evda_write this turn.`;
|
|
125
|
+
}
|
|
126
|
+
const file = queuePrompt(root, { words: prompt, context: `Claude Code on ${hostname().split(".")[0]}, said ${timeOf(now)}`, now, env });
|
|
127
|
+
const r = drainPromptOutbox(root, { env });
|
|
128
|
+
if (r.sent.includes(file)) {
|
|
129
|
+
const more = r.sent.length > 1 ? ` (with ${r.sent.length - 1} message(s) queued earlier)` : "";
|
|
130
|
+
return `evda: the owner's message above is already filed verbatim through the day writer (${writer})${more}. Do not file it again with evda_write.`;
|
|
131
|
+
}
|
|
132
|
+
return `evda: the day writer (${writer}) could not be reached (${r.error ?? "not sent"}), so the owner's message above is queued in ${dirname(file)} on this machine and goes to the writer with their next message. Do not file it again with evda_write; if you tell them, say it is queued, not filed.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
38
135
|
function stateFile(sid) {
|
|
39
136
|
const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
|
|
40
137
|
mkdirSync(dir, { recursive: true });
|
|
41
138
|
return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
42
139
|
}
|
|
43
140
|
|
|
141
|
+
// The keep hook reads this (HQ-2817): on a machine that does not write the day, the day file does
|
|
142
|
+
// not change here when a message is filed, so the time it was handed over is the evidence.
|
|
143
|
+
function markFiled(sid, now) {
|
|
144
|
+
const sf = stateFile(sid ?? "unknown");
|
|
145
|
+
let session = {};
|
|
146
|
+
try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* none yet */ }
|
|
147
|
+
writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
44
150
|
// One UserPromptSubmit event in, the hook's stdout out ("" for nothing to say). Never throws.
|
|
45
|
-
export function run(payload, { now = new Date() } = {}) {
|
|
151
|
+
export function run(payload, { now = new Date(), env = process.env } = {}) {
|
|
46
152
|
try {
|
|
47
153
|
const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
|
|
48
154
|
if (!prompt.trim() || /^\s*\//.test(prompt)) return "";
|
|
@@ -55,6 +161,11 @@ export function run(payload, { now = new Date() } = {}) {
|
|
|
55
161
|
if (!role) return "";
|
|
56
162
|
const shape = prompt.split("\n").map(secretShape).find(Boolean);
|
|
57
163
|
if (shape) return `evda: this message was not filed, because it holds ${shape}. Keep that in a password manager; file the rest of what they said with evda_write if it is worth keeping.`;
|
|
164
|
+
if ((env.EVDA_DAY_WRITER ?? "").trim()) {
|
|
165
|
+
const said = viaWriter(root, prompt, now, env);
|
|
166
|
+
if ((env[DAY_WRITER_SEND_ENV] ?? "").trim()) markFiled(payload.session_id, now); // sent or queued to send
|
|
167
|
+
return said;
|
|
168
|
+
}
|
|
58
169
|
const dayAbs = join(root, manifest.mapping[role].replace(/\/+$/, ""));
|
|
59
170
|
mkdirSync(dayAbs, { recursive: true });
|
|
60
171
|
const abs = join(dayAbs, `${localDate(now)}.md`);
|
|
@@ -62,7 +173,7 @@ export function run(payload, { now = new Date() } = {}) {
|
|
|
62
173
|
let session = {};
|
|
63
174
|
try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* the first prompt of the session */ }
|
|
64
175
|
appendToDay(abs, { now, words: prompt, session });
|
|
65
|
-
writeFileSync(sf, JSON.stringify(session) + "\n");
|
|
176
|
+
writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
|
|
66
177
|
const rel = relative(root, abs).split("\\").join("/");
|
|
67
178
|
return `evda: the owner's message above is already filed verbatim in ${rel}. Do not file it again with evda_write.`;
|
|
68
179
|
} catch {
|
package/lib/publish.mjs
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
// written, and a hit refuses. A label is its opaque id, its namespace, its own directory, a
|
|
30
30
|
// member path, or its name between slashes. The exported files' own paths are the released
|
|
31
31
|
// set (doctor prints them every run) and are left out of that search, since a marked file
|
|
32
|
-
// necessarily lives under a keyed circle's member path.
|
|
32
|
+
// necessarily lives under a keyed circle's member path. A markdown file's `sources:`
|
|
33
|
+
// frontmatter field is cut from its exported bytes before this search (HQ-2698): it is
|
|
34
|
+
// provenance, the record's own paths, and is never published.
|
|
33
35
|
//
|
|
34
36
|
// The fourth check in the spec (the site holds no credential to anything else) is a property of
|
|
35
37
|
// where the export is served, not of this code.
|
|
@@ -70,6 +72,41 @@ function filesUnder(abs) {
|
|
|
70
72
|
const posix = (rel) => rel.split(sep).join("/");
|
|
71
73
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
72
74
|
|
|
75
|
+
// Provenance (HQ-2698). A top-level `sources:` field in a markdown file's frontmatter lists the
|
|
76
|
+
// record paths the file was written from: a blog Companion names the private draft and the journal
|
|
77
|
+
// days behind it. That is the record's own bookkeeping, never part of what a stranger reads, so it
|
|
78
|
+
// is cut from every exported markdown file, whatever folder or rule released it, the way a sealed
|
|
79
|
+
// section is. Only the frontmatter field is cut: a private path anywhere in the body still reaches
|
|
80
|
+
// the stranger check and refuses. The value runs to the next top-level key, so a block list,
|
|
81
|
+
// indented or not, a flow list on one or more lines and a bare string all go with the key.
|
|
82
|
+
const PROVENANCE_KEY = /^(["']?)sources\1[ \t]*:/;
|
|
83
|
+
const isFence = (line) => line.replace(/\r$/, "") === "---";
|
|
84
|
+
const continues = (line) => /^[ \t]/.test(line) || /^-([ \t]|\r?$)/.test(line);
|
|
85
|
+
const blank = (line) => line.replace(/\r$/, "").trim() === "";
|
|
86
|
+
export function withoutProvenance(text) {
|
|
87
|
+
const lines = String(text).split("\n");
|
|
88
|
+
if (!isFence(lines[0])) return { text, cut: 0 };
|
|
89
|
+
let end = 1;
|
|
90
|
+
while (end < lines.length && !isFence(lines[end])) end++;
|
|
91
|
+
if (end >= lines.length) return { text, cut: 0 };
|
|
92
|
+
const kept = [lines[0]];
|
|
93
|
+
let cut = 0;
|
|
94
|
+
for (let i = 1; i < end; i++) {
|
|
95
|
+
if (!PROVENANCE_KEY.test(lines[i])) { kept.push(lines[i]); continue; }
|
|
96
|
+
cut++;
|
|
97
|
+
let j = i + 1;
|
|
98
|
+
for (;;) {
|
|
99
|
+
let k = j;
|
|
100
|
+
while (k < end && blank(lines[k])) k++;
|
|
101
|
+
if (k < end && continues(lines[k])) j = k + 1;
|
|
102
|
+
else break;
|
|
103
|
+
}
|
|
104
|
+
i = j - 1;
|
|
105
|
+
}
|
|
106
|
+
if (!cut) return { text, cut: 0 };
|
|
107
|
+
return { text: [...kept, ...lines.slice(end)].join("\n"), cut };
|
|
108
|
+
}
|
|
109
|
+
|
|
73
110
|
// Build the export in memory and run the checks. Writes nothing.
|
|
74
111
|
export function buildPublicExport(evda, { circleName = null, publishedAt = new Date() } = {}) {
|
|
75
112
|
const circle = publicCircle(evda, circleName);
|
|
@@ -114,14 +151,21 @@ export function buildPublicExport(evda, { circleName = null, publishedAt = new D
|
|
|
114
151
|
// A sealed section (HQ-1220) is cut out of every markdown file on the way out; the rest of the
|
|
115
152
|
// file is exported, and nothing marks where the section was. A sealed FILE never gets here:
|
|
116
153
|
// released() refuses it, and a refusal refuses the publish above.
|
|
154
|
+
// A file's `sources:` provenance is cut the same way (withoutProvenance above, HQ-2698).
|
|
117
155
|
let sealedCut = 0;
|
|
156
|
+
let provenanceCut = 0;
|
|
118
157
|
const exportBytes = (abs) => {
|
|
119
158
|
const bytes = readFileSync(abs);
|
|
120
|
-
if (!/\.md$/i.test(abs)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
if (!/\.md$/i.test(abs)) return bytes;
|
|
160
|
+
let text = bytes.toString("utf8");
|
|
161
|
+
let changed = false;
|
|
162
|
+
if (carries("publish", "sealed")) {
|
|
163
|
+
const cut = withoutSealed(text);
|
|
164
|
+
if (cut.sections) { sealedCut += cut.sections; text = cut.text; changed = true; }
|
|
165
|
+
}
|
|
166
|
+
const prov = withoutProvenance(text);
|
|
167
|
+
if (prov.cut) { provenanceCut += 1; text = prov.text; changed = true; }
|
|
168
|
+
return changed ? Buffer.from(text, "utf8") : bytes;
|
|
125
169
|
};
|
|
126
170
|
const loaded = files.filter((f) => !consumed.has(f.path)).map((f) => ({ path: f.path, bytes: exportBytes(f.abs), by: f.by }));
|
|
127
171
|
for (const v of rendered.views) loaded.push({ path: v.path, bytes: v.bytes, by: "view", view: v.view, sources: v.entries.map((e) => ({ path: e.path, sha256: e.sha256 })) });
|
|
@@ -157,9 +201,9 @@ export function buildPublicExport(evda, { circleName = null, publishedAt = new D
|
|
|
157
201
|
// What identifies a private circle to a stranger is a LABEL: a path segment, the circle's own
|
|
158
202
|
// directory, or the opaque id that travels on a Change and in an export manifest. A name
|
|
159
203
|
// matched as a word anywhere in the bytes is not that test, and on a real record it refuses
|
|
160
|
-
// every publish there is:
|
|
161
|
-
//
|
|
162
|
-
// "help other people understand me" (2026-09-21, HQ-1199). So the name is matched only
|
|
204
|
+
// every publish there is: private circles are named with ordinary words (a circle called
|
|
205
|
+
// "core" or "people"), and a public fact sheet uses those words in plain prose ("10 core
|
|
206
|
+
// mechanics", "help other people understand me") (2026-09-21, HQ-1199). So the name is matched only
|
|
163
207
|
// between slashes, and the id and namespace, which the word match never looked at, are
|
|
164
208
|
// matched literally. The gap this leaves, stated so the next reader does not rediscover it:
|
|
165
209
|
// a distinctive circle name written as bare prose passes.
|
|
@@ -188,7 +232,7 @@ export function buildPublicExport(evda, { circleName = null, publishedAt = new D
|
|
|
188
232
|
}
|
|
189
233
|
}
|
|
190
234
|
|
|
191
|
-
return { manifest, files: loaded, sealedCut };
|
|
235
|
+
return { manifest, files: loaded, sealedCut, provenanceCut };
|
|
192
236
|
}
|
|
193
237
|
|
|
194
238
|
export function canonicalManifest(manifest) {
|
|
@@ -259,6 +303,7 @@ export async function publishCommand(dir, circleName, { out, key }) {
|
|
|
259
303
|
console.log(`published ${built.files.length} file(s) from the public circle to ${at} (${built.files.length - marked - views.length} by path, ${marked} by mark${views.length ? `, ${views.length} rendered from ${drawn} entries` : ""})`);
|
|
260
304
|
for (const f of views) console.log(` view ${f.path} rendered from ${f.sources.length} entries (${f.view})`);
|
|
261
305
|
if (built.sealedCut) console.log(` ${built.sealedCut} sealed section(s) left out of the files they sit in`);
|
|
306
|
+
if (built.provenanceCut) console.log(` ${built.provenanceCut} file(s) exported without their sources: provenance`);
|
|
262
307
|
console.log(`CHECKED ${built.files.length} public members, signed by ${signed.publicKey}`);
|
|
263
308
|
return 0;
|
|
264
309
|
}
|
package/lib/readme.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// README.md, the owner-facing half of the operating layer (HQ-2812).
|
|
2
|
+
//
|
|
3
|
+
// AGENTS.md sends the agent to sections of README.md for the facts it must never give from memory:
|
|
4
|
+
// the steps for turning training off, what each AI company says, and running a model locally. A
|
|
5
|
+
// record create-evda makes has them, because it writes README.md from templates/README.md. A record
|
|
6
|
+
// `evda adopt` makes keeps the owner's own README.md, which has none of them, so the privacy step
|
|
7
|
+
// pointed at nothing. This module renders the README once, for both, and keeps those sections in an
|
|
8
|
+
// adopted README as one marked block the owner's text never shares:
|
|
9
|
+
//
|
|
10
|
+
// <the owner's README, byte for byte>
|
|
11
|
+
// <!-- evda:readme -->
|
|
12
|
+
// _lead line_
|
|
13
|
+
// ## Turning training off ... ## Running a model on your own computer ...
|
|
14
|
+
// <!-- /evda:readme -->
|
|
15
|
+
//
|
|
16
|
+
// `ensureReadme` writes the file when it is absent, appends the block when a cited section is
|
|
17
|
+
// missing, and refreshes the block's content when it is there (`evda upgrade`). A README that already
|
|
18
|
+
// holds every cited heading outside a block (create-evda's own) is left alone.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { trainingSection, termsSection, INTRO } from "./setup.mjs";
|
|
24
|
+
|
|
25
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
|
+
export const README_REL = "README.md";
|
|
27
|
+
export const README_OPEN = "<!-- evda:readme -->";
|
|
28
|
+
export const README_CLOSE = "<!-- /evda:readme -->";
|
|
29
|
+
export const README_LEAD = "_EVÐA wrote this block and `evda upgrade` keeps it current. Everything above it is yours, and EVÐA never changes it._";
|
|
30
|
+
// The first and the last heading of templates/README.md the block carries, in that file's order.
|
|
31
|
+
const FIRST = "## Turning training off";
|
|
32
|
+
const AFTER = "## Getting started";
|
|
33
|
+
|
|
34
|
+
// The local-model doc as a README section: its title dropped and its headings one level down.
|
|
35
|
+
export function localModelSection() {
|
|
36
|
+
const text = readFileSync(join(PKG, "docs", "local-model.md"), "utf8");
|
|
37
|
+
return text.split("\n").slice(1).join("\n").replace(/^## /gm, "### ").trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The README create-evda writes into a new record.
|
|
41
|
+
export function renderReadme() {
|
|
42
|
+
let text = readFileSync(join(PKG, "templates", "README.md"), "utf8");
|
|
43
|
+
const vars = { training: trainingSection(), terms: termsSection(), intro: INTRO, localModel: localModelSection() };
|
|
44
|
+
for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`{{${k}}}`, v);
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The sections AGENTS.md cites, cut from the rendered README rather than written a second time.
|
|
49
|
+
export function readmeSections() {
|
|
50
|
+
const text = renderReadme();
|
|
51
|
+
const from = text.indexOf(FIRST);
|
|
52
|
+
const to = text.indexOf(AFTER);
|
|
53
|
+
if (from === -1 || to === -1 || to < from) throw new Error(`templates/README.md no longer has "${FIRST}" before "${AFTER}", so the adopted README block cannot be cut from it.`);
|
|
54
|
+
return text.slice(from, to).trimEnd();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readmeBlock() {
|
|
58
|
+
return `${README_OPEN}\n${README_LEAD}\n\n${readmeSections()}\n${README_CLOSE}\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every README.md section an operating layer sends the agent to: a quoted heading followed by
|
|
62
|
+
// `section of` or `in`, then `README.md`, line breaks inside the quote allowed.
|
|
63
|
+
export function readmeCitations(agentsText) {
|
|
64
|
+
const flat = String(agentsText).replace(/\s+/g, " ");
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const m of flat.matchAll(/"([^"]{2,120})"(?: section)? (?:of|in) `?README\.md`?/g)) if (!out.includes(m[1])) out.push(m[1]);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
71
|
+
export const hasHeading = (text, heading) => new RegExp(`^#{1,6}[ \\t]+${escape(heading)}[ \\t]*#*[ \\t]*$`, "m").test(text);
|
|
72
|
+
|
|
73
|
+
// The cited headings README text lacks.
|
|
74
|
+
export function missingHeadings(readmeText, citations) {
|
|
75
|
+
return citations.filter((h) => !hasHeading(readmeText ?? "", h));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The block's place in README text: { start, end } around the markers (end past the closing line
|
|
79
|
+
// break), or null when there is none. Two blocks, or an open with no close, is a problem, not a block.
|
|
80
|
+
export function findBlock(text) {
|
|
81
|
+
const open = text.indexOf(README_OPEN);
|
|
82
|
+
const close = text.indexOf(README_CLOSE);
|
|
83
|
+
if (open === -1 && close === -1) return null;
|
|
84
|
+
if (open === -1 || close === -1 || close < open || text.indexOf(README_OPEN, open + 1) !== -1 || text.indexOf(README_CLOSE, close + 1) !== -1) {
|
|
85
|
+
throw new Error(`${README_REL} has a broken ${README_OPEN} block (one marker without the other, or two of them). Fix the markers by hand; nothing was changed.`);
|
|
86
|
+
}
|
|
87
|
+
let end = close + README_CLOSE.length;
|
|
88
|
+
if (text[end] === "\n") end += 1;
|
|
89
|
+
return { start: open, end };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// What the README should become. Returns { action, text, added }:
|
|
93
|
+
// "written" absent: the whole README create-evda writes
|
|
94
|
+
// "appended" a cited section was missing and there was no block: the block is added at the end
|
|
95
|
+
// "refreshed" the block was there with other content: its content is replaced
|
|
96
|
+
// "current" the block is there and matches
|
|
97
|
+
// "present" no block, and every cited heading is already in the owner's text
|
|
98
|
+
// `added` is exactly what "appended" put after the owner's bytes, so a revert can take it back out.
|
|
99
|
+
export function planReadme(text, citations) {
|
|
100
|
+
if (text === null) return { action: "written", text: renderReadme(), added: null };
|
|
101
|
+
const block = readmeBlock();
|
|
102
|
+
const at = findBlock(text);
|
|
103
|
+
if (at) {
|
|
104
|
+
const next = text.slice(0, at.start) + block + text.slice(at.end);
|
|
105
|
+
return next === text ? { action: "current", text, added: null } : { action: "refreshed", text: next, added: null };
|
|
106
|
+
}
|
|
107
|
+
if (!missingHeadings(text, citations).length) return { action: "present", text, added: null };
|
|
108
|
+
const added = `${text === "" || text.endsWith("\n") ? "" : "\n"}\n${block}`;
|
|
109
|
+
return { action: "appended", text: text + added, added };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Apply planReadme to <dir>/README.md. `citations` defaults to the record's AGENTS.md, or the shipped
|
|
113
|
+
// template's when there is none.
|
|
114
|
+
export function ensureReadme(dir, { citations = null, dry = false, agents = null } = {}) {
|
|
115
|
+
const abs = join(dir, README_REL);
|
|
116
|
+
const cites = citations ?? readmeCitations(agents ?? readFileSync(join(PKG, "templates", "AGENTS.md"), "utf8"));
|
|
117
|
+
const before = existsSync(abs) ? readFileSync(abs, "utf8") : null;
|
|
118
|
+
const plan = planReadme(before, cites);
|
|
119
|
+
if (!dry && plan.text !== before) writeFileSync(abs, plan.text);
|
|
120
|
+
return { ...plan, abs, rel: README_REL, citations: cites };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// README text with the block taken back out: exactly what was there before an "appended", when the
|
|
124
|
+
// owner did not edit around the block. Null when there is no block to take.
|
|
125
|
+
export function withoutBlock(text, added) {
|
|
126
|
+
if (added && text.endsWith(added)) return text.slice(0, -added.length);
|
|
127
|
+
let at;
|
|
128
|
+
try { at = findBlock(text); } catch { return null; }
|
|
129
|
+
if (!at) return null;
|
|
130
|
+
// The line breaks the adopt put in front of the block go with it.
|
|
131
|
+
const lead = added ? added.slice(0, added.indexOf(README_OPEN)) : "";
|
|
132
|
+
const start = lead && text.slice(at.start - lead.length, at.start) === lead ? at.start - lead.length : at.start;
|
|
133
|
+
return text.slice(0, start) + text.slice(at.end);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function readmeLine(r) {
|
|
137
|
+
const names = r.citations.map((c) => `"${c}"`).join(", ");
|
|
138
|
+
switch (r.action) {
|
|
139
|
+
case "written": return `README.md: written, with ${names}`;
|
|
140
|
+
case "appended": return `README.md: EVÐA's block added at the end, with ${names}; your text above it is unchanged`;
|
|
141
|
+
case "refreshed": return "README.md: EVÐA's block refreshed to what this tool ships; your text around it is unchanged";
|
|
142
|
+
case "current": return "README.md: EVÐA's block already matches what this tool ships";
|
|
143
|
+
default: return `README.md: already has ${names}; left alone`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The one rule every walker over the record uses for what is outside it (HQ-2811): a folder named
|
|
2
|
+
// node_modules (packages, not notes), and any name ending in .nosync, the iCloud convention for
|
|
3
|
+
// "do not sync this". A .nosync folder is one the owner chose to keep out of sync, which is the same
|
|
4
|
+
// as keeping it out of the record; Mike's vault keeps its packages in node_modules.nosync, and the
|
|
5
|
+
// bears-on notice once named a vendored eslint doc in it as an existing entry. Dot-names and app
|
|
6
|
+
// folders stay each walker's own choice, since they differ by what the walk is for.
|
|
7
|
+
export function outsideRecord(name) {
|
|
8
|
+
return name === "node_modules" || name.endsWith(".nosync");
|
|
9
|
+
}
|
package/lib/record.mjs
CHANGED
|
@@ -98,3 +98,57 @@ export function moment(evda, { from, to = from } = {}) {
|
|
|
98
98
|
out.entries.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : a.date.localeCompare(b.date)));
|
|
99
99
|
return out;
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
// What a reader needs from each section of a file to know what it was about without opening it
|
|
103
|
+
// (HQ-2324): its heading, its context line (the one line the filing agent wrote above the owner's
|
|
104
|
+
// words, `Context, not their words:` or `*Context (Iris, ...): ...*`), and the opening of the
|
|
105
|
+
// owner's words, cut at a word. A day file's headings are mostly times (`## 20:13`), so a heading
|
|
106
|
+
// alone told a chat app nothing about his last days. Text before the first heading is a section
|
|
107
|
+
// with no heading. Frontmatter, fences and the one-line marker a sealed part leaves are skipped.
|
|
108
|
+
export const OPENING_MAX = 280;
|
|
109
|
+
const CONTEXT_MAX = 240;
|
|
110
|
+
const CONTEXT_LINE = /^\*{0,2}_?\s*Context\b[^:]*:/i;
|
|
111
|
+
|
|
112
|
+
function cutAt(text, max) {
|
|
113
|
+
if (text.length <= max) return text;
|
|
114
|
+
const cut = text.slice(0, max);
|
|
115
|
+
const at = cut.lastIndexOf(" ");
|
|
116
|
+
return `${at > max / 2 ? cut.slice(0, at) : cut}...`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function sectionDigest(text, { openingMax = OPENING_MAX } = {}) {
|
|
120
|
+
let body = text;
|
|
121
|
+
if (body.startsWith("---")) {
|
|
122
|
+
const end = body.indexOf("\n---", 3);
|
|
123
|
+
if (end !== -1) body = body.slice(end + 4);
|
|
124
|
+
}
|
|
125
|
+
const out = [];
|
|
126
|
+
let cur = { heading: null, lines: [] };
|
|
127
|
+
let fence = false;
|
|
128
|
+
const flush = () => {
|
|
129
|
+
const lines = cur.lines.map((l) => l.trim()).filter((l) => l && !/^\[sealed[:\]]/i.test(l));
|
|
130
|
+
const ctxAt = lines.findIndex((l) => CONTEXT_LINE.test(l));
|
|
131
|
+
const context = ctxAt === -1 ? null : cutAt(lines[ctxAt].replace(/^[*_\s]+|[*_\s]+$/g, ""), CONTEXT_MAX);
|
|
132
|
+
const words = lines.filter((_, i) => i !== ctxAt)
|
|
133
|
+
.map((l) => l.replace(/^>\s?/, "").replace(/^>\s?/, "").trim())
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(" ")
|
|
136
|
+
.replace(/\s+/g, " ")
|
|
137
|
+
.trim();
|
|
138
|
+
if (cur.heading !== null || context || words) out.push({ heading: cur.heading, context, opening: words ? cutAt(words, openingMax) : null });
|
|
139
|
+
};
|
|
140
|
+
for (const line of body.split("\n")) {
|
|
141
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) { fence = !fence; continue; }
|
|
142
|
+
if (fence) continue;
|
|
143
|
+
const h = /^#{1,6} (.*)$/.exec(line);
|
|
144
|
+
if (h) { flush(); cur = { heading: h[1].trim(), lines: [] }; continue; }
|
|
145
|
+
cur.lines.push(line);
|
|
146
|
+
}
|
|
147
|
+
flush();
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The date a heading names (`## 2026-10-02, morning: ...`, `## October 4 (added 2026-10-04)`), or null.
|
|
152
|
+
export function headingDate(heading) {
|
|
153
|
+
return /\b(\d{4}-\d{2}-\d{2})\b/.exec(heading ?? "")?.[1] ?? null;
|
|
154
|
+
}
|
package/lib/routines.mjs
CHANGED
|
@@ -243,7 +243,17 @@ export function uninstallLaunchd(key, dir, { env = process.env, home = env.HOME
|
|
|
243
243
|
// What setup does with the routines it was given (HQ-1246). On a Mac each is installed as a launchd
|
|
244
244
|
// job; elsewhere nothing can be scheduled from here, so it prints the Claude desktop steps and says
|
|
245
245
|
// so. Returns the lines to print.
|
|
246
|
-
|
|
246
|
+
//
|
|
247
|
+
// `say` prints a line at once, before anything is installed (HQ-1368): a run with --yes asks nothing,
|
|
248
|
+
// so the line that a launchd job is about to be loaded into this Mac is said before launchctl runs,
|
|
249
|
+
// not only in the summary after it. `launchctl` is the tests' stand-in, handed through to
|
|
250
|
+
// installLaunchd with loading on, so a test can watch the order without the real launchctl.
|
|
251
|
+
export function setupNotice(key, dir) {
|
|
252
|
+
const r = ROUTINES[key];
|
|
253
|
+
return `Installing a launchd job on this Mac: ${r.title}, ${r.when.toLowerCase()}, runs ${r.command.join(" ")} on ${resolve(dir)} with your own Claude. Not wanted: create-evda --no-routines, or afterwards evda routine ${key} ${resolve(dir)} --uninstall`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function installAtSetup(keys, dir, { env = process.env, platform = process.platform, say = null, launchctl = null } = {}) {
|
|
247
257
|
const lines = [];
|
|
248
258
|
for (const key of keys) {
|
|
249
259
|
const r = ROUTINES[key];
|
|
@@ -251,7 +261,8 @@ export function installAtSetup(keys, dir, { env = process.env, platform = proces
|
|
|
251
261
|
if (platform === "darwin") {
|
|
252
262
|
let done;
|
|
253
263
|
try {
|
|
254
|
-
|
|
264
|
+
if (say && !falsified(env, "silent-install")) say(setupNotice(key, dir));
|
|
265
|
+
done = installLaunchd(key, dir, launchctl ? { env, launchctl, load: true } : { env });
|
|
255
266
|
} catch (e) {
|
|
256
267
|
if (e.code !== "TEMP_RECORD") throw e;
|
|
257
268
|
lines.push(e.message);
|