create-evda 0.1.8 → 0.1.9

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.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code PreToolUse hook: on a machine with EVDA_DAY_WRITER set, refuse a direct write to the
3
+ // record's daily file and send the agent to evda_write on the machine that writes it (HQ-2165). The
4
+ // logic is lib/day-writer.mjs; this file is only the entry point a user-level settings.json names.
5
+ import { runHook } from "../lib/day-writer.mjs";
6
+
7
+ runHook().then((code) => process.exit(code), () => process.exit(0));
@@ -0,0 +1,187 @@
1
+ // Acknowledging what the owner put in the record (HQ-2060): the first reader to reach a weighty
2
+ // entry says it back to them once, and every other reader can see that it did.
3
+ //
4
+ // THE PROBLEM. An owner learns how much something weighed from how it was received. Today every
5
+ // agent over one record writes its own record and reads only its own: a live conversation, an
6
+ // always-on agent, a scheduled run. So a correction the owner filed on Monday gets reacted to three
7
+ // times, once by each, or zero times, because each assumes another already did. Mike, 2026-10-01:
8
+ // "whoever sees it first acts on it whetehr its phroneiss or plan pass or a next session".
9
+ //
10
+ // WHERE THE MARK LIVES, AND WHY NOT ON THE ENTRY.
11
+ // - A record file is append only, and `evda_write` takes no path on purpose. The one tool that
12
+ // edits a file is `evda_release_note`, which edits one frontmatter line and only on the owner's
13
+ // explicit yes. A mark written into the entry would make every reader a writer of the owner's
14
+ // words.
15
+ // - The unit of weight is a section, not a file: one conversation is one `## HH:MM` inside a day
16
+ // file that holds several. Frontmatter cannot address a section.
17
+ // - Their words stay their words. A line the agent appends inside the day file is the agent's text
18
+ // in the owner's file, and a later reader can quote it back as something they said.
19
+ // So the mark is a sidecar the readers share, in the shape `messages/.seen.json` already uses for
20
+ // "what this agent has seen" (lib/messages.mjs): a dotfile of bookkeeping, keyed by what it tracks,
21
+ // beside the thing it tracks. It is at the record root rather than under `.evda/`, because `.evda/`
22
+ // is derived and rebuildable and an acknowledgement is derivable from nothing.
23
+ //
24
+ // The format, one file per record:
25
+ //
26
+ // .acknowledged.json
27
+ // {
28
+ // "record/daily/2026-10-01.md#14:03": {
29
+ // "by": "Hestia",
30
+ // "at": "2026-10-01T06:12:00-04:00",
31
+ // "note": "said back to him that the Greek trade is settled and I will not reopen it"
32
+ // }
33
+ // }
34
+ //
35
+ // The key is the entry: its path relative to the record, and the exact heading of the section when
36
+ // the weight is in one section. A heading never changes once written, so the key is stable.
37
+ //
38
+ // Append only, in the same sense as the record: a key that is present is never written over. The
39
+ // second reader of an entry gets { first: false, already } and nothing is written, which is the
40
+ // whole mechanism. "Weighty" is never decided here; weight is the reader's judgment and this file
41
+ // only remembers that one of them acted.
42
+ //
43
+ // EVDA_ACK_FALSIFY=<mode,...>, for the tests. Each breaks exactly one rule:
44
+ // overwrite a second acknowledgement of the same entry overwrites the first and reports first
45
+ // any-section a section that is not a heading in that file is accepted
46
+ // strict-read an .acknowledged.json that does not parse throws instead of reading as empty
47
+ // no-secrets the secret check is skipped
48
+ // hide acknowledgementsFor returns nothing, so a later reader cannot see the mark
49
+
50
+ import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
51
+ import { join, relative, resolve, sep } from "node:path";
52
+ import { agentName } from "./messages.mjs";
53
+ import { secretShape } from "./references.mjs";
54
+ import { isoLocal } from "./messages.mjs";
55
+ import { EvdaError } from "./errors.mjs";
56
+
57
+ export const ACK_FILE = ".acknowledged.json";
58
+ export const NOTE_MAX = 400;
59
+ export const BY_MAX = 60;
60
+ export const SECTION_MAX = 200;
61
+
62
+ export const ACK_FALSIFY_MODES = ["overwrite", "any-section", "strict-read", "no-secrets", "hide"];
63
+ const falsified = (mode) => (process.env.EVDA_ACK_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
64
+
65
+ const refuse = (message, code) => new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
66
+
67
+ // The entry a mark is about: a path, and the section heading inside it when there is one.
68
+ export function entryKey(path, section = null) {
69
+ const p = String(path).replace(/\\/g, "/").replace(/^\.\//, "");
70
+ return section ? `${p}#${section}` : p;
71
+ }
72
+
73
+ // A section as it is keyed: the heading's text, with its leading #s and surrounding space gone, so
74
+ // "## 14:03", "##14:03" and "14:03" are one section.
75
+ export function normalizeSection(section) {
76
+ return String(section).replace(/^\s*#+\s*/, "").trim();
77
+ }
78
+
79
+ function ackPath(evda) {
80
+ return join(evda.dir, ACK_FILE);
81
+ }
82
+
83
+ // Every mark this record holds, as { key: { by, at, note } }. Missing, unreadable, or not a JSON
84
+ // object: empty, so a reader with no file acts like the first reader rather than throwing.
85
+ export function readAcknowledged(evda) {
86
+ const p = ackPath(evda);
87
+ if (!existsSync(p)) return {};
88
+ let v;
89
+ try { v = JSON.parse(readFileSync(p, "utf8")); } catch (e) { if (falsified("strict-read")) throw e; return {}; }
90
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return {};
91
+ const out = {};
92
+ for (const [k, m] of Object.entries(v)) {
93
+ if (m === null || typeof m !== "object" || Array.isArray(m)) continue;
94
+ if (typeof m.at !== "string" || typeof m.note !== "string") continue;
95
+ out[k] = { by: typeof m.by === "string" ? m.by : null, at: m.at, note: m.note };
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function writeAcknowledged(evda, all) {
101
+ const p = ackPath(evda);
102
+ const keys = Object.keys(all).sort();
103
+ const ordered = Object.fromEntries(keys.map((k) => [k, all[k]]));
104
+ const t = `${p}.tmp-${process.pid}`;
105
+ writeFileSync(t, JSON.stringify(ordered, null, 2) + "\n");
106
+ renameSync(t, p);
107
+ }
108
+
109
+ // What is already acknowledged in one file, newest-written last: [{ section, by, at, note }]. A mark
110
+ // on the file as a whole comes back with section null.
111
+ export function acknowledgementsFor(path, all) {
112
+ if (falsified("hide")) return [];
113
+ const p = String(path).replace(/\\/g, "/");
114
+ const out = [];
115
+ for (const [key, m] of Object.entries(all)) {
116
+ const hash = key.indexOf("#");
117
+ const keyPath = hash === -1 ? key : key.slice(0, hash);
118
+ if (keyPath !== p) continue;
119
+ out.push({ section: hash === -1 ? null : key.slice(hash + 1), by: m.by, at: m.at, note: m.note });
120
+ }
121
+ out.sort((a, b) => String(a.section ?? "").localeCompare(String(b.section ?? "")));
122
+ return out;
123
+ }
124
+
125
+ // Is this entry already acknowledged? The read every other reader does before it says anything.
126
+ export function isAcknowledged(evda, { path, section = null } = {}, all = null) {
127
+ const marks = all ?? readAcknowledged(evda);
128
+ const key = entryKey(path, section === null || section === undefined ? null : normalizeSection(section));
129
+ return marks[key] ?? null;
130
+ }
131
+
132
+ function oneLine(value, name, max) {
133
+ if (typeof value !== "string" || value.trim() === "") throw refuse(`evda_acknowledge needs "${name}".`, "EMPTY");
134
+ const v = value.trim();
135
+ if (/[\r\n]/.test(v)) throw refuse(`evda_acknowledge "${name}" is one line.`, "LINES");
136
+ if (v.length > max) throw refuse(`evda_acknowledge "${name}" is ${v.length} characters; at most ${max}.`, "TOO_LONG");
137
+ return v;
138
+ }
139
+
140
+ function refuseSecrets(value, name) {
141
+ if (falsified("no-secrets")) return;
142
+ const shape = secretShape(value);
143
+ if (shape) throw refuse(`evda_acknowledge "${name}" holds ${shape}. A password, key or token is never written into the record.`, "SECRET");
144
+ }
145
+
146
+ // Does `section` exist as a markdown heading in `text`?
147
+ export function hasHeading(text, section) {
148
+ const want = normalizeSection(section);
149
+ return text.split("\n").some((line) => /^#{1,6}\s+/.test(line) && normalizeSection(line) === want);
150
+ }
151
+
152
+ // Record that this reader acted on one weighty entry. Returns
153
+ // { key, path, section, first, by, at, note, already }.
154
+ //
155
+ // first true : nothing was marked before, this reader is the one that acts, and the mark is written.
156
+ // first false : another reader already did. Nothing is written, `already` says who, when and what
157
+ // they did, and this reader says nothing about the entry.
158
+ export function acknowledge(evda, args = {}, { now = new Date() } = {}) {
159
+ if (typeof args.path !== "string" || args.path.trim() === "") throw refuse('evda_acknowledge needs "path", the entry in the record you acted on.', "NO_PATH");
160
+ const abs = resolve(evda.dir, args.path.trim());
161
+ if (abs !== evda.dir && !abs.startsWith(evda.dir + sep)) throw refuse(`${args.path} is outside the record.`, "OUTSIDE");
162
+ if (!existsSync(abs) || !statSync(abs).isFile()) throw refuse(`nothing is at ${args.path} in the record, so there is no entry to acknowledge.`, "MISSING");
163
+ const path = relative(evda.dir, abs).split(sep).join("/");
164
+
165
+ let section = null;
166
+ if (args.section !== undefined && args.section !== null && String(args.section).trim() !== "") {
167
+ section = normalizeSection(oneLine(String(args.section), "section", SECTION_MAX));
168
+ if (!falsified("any-section") && !hasHeading(readFileSync(abs, "utf8"), section)) {
169
+ throw refuse(`${path} has no heading "${section}". Acknowledge the heading as it is written in the file, or leave section out to acknowledge the whole entry.`, "NO_SECTION");
170
+ }
171
+ }
172
+
173
+ const key = entryKey(path, section);
174
+ const all = readAcknowledged(evda);
175
+ if (all[key] && !falsified("overwrite")) {
176
+ return { key, path, section, first: false, by: all[key].by, at: all[key].at, note: all[key].note, already: all[key] };
177
+ }
178
+
179
+ const note = oneLine(args.note, "note", NOTE_MAX);
180
+ refuseSecrets(note, "note");
181
+ const by = args.by === undefined || args.by === null ? agentName(evda) : oneLine(String(args.by), "by", BY_MAX);
182
+ refuseSecrets(by, "by");
183
+ const at = isoLocal(now);
184
+ all[key] = { by, at, note };
185
+ writeAcknowledged(evda, all);
186
+ return { key, path, section, first: true, by, at, note, already: null };
187
+ }
package/lib/browse.mjs ADDED
@@ -0,0 +1,354 @@
1
+ // The whole record as a folder tree, for the owner's own app (HQ-2038).
2
+ //
3
+ // The EVÐA Mac app shows every folder of the record, not only the record roles, one folder at a
4
+ // time so a record of tens of thousands of files opens fast. Three rules decide what it lists, and
5
+ // each is the rule another reader already uses, never a copy of it:
6
+ //
7
+ // what the scan reads sync-rules `scanSkip` with the plan's excludes and the sub-record
8
+ // boundaries, the same predicate the shadow scan and doctor walk with, so
9
+ // the app and the sync agree on what is in the record. Dot-named entries
10
+ // (.obsidian, .claude) are left out of the view on top of that.
11
+ // which circle circles `homesOf` through readers `readersFor`, the resolver doctor and
12
+ // the MCP server use: a file shows the circle that homes it.
13
+ // what stays closed `noted: true` and `sealed: true` entries (readers `isNotedFile`,
14
+ // `isSealedFile`), and a part whose read_by says no AI reads it (`none`)
15
+ // or does not parse: the app hosts the owner's AI connection, so it keeps
16
+ // those closed as the phone keeps a noted entry closed.
17
+ //
18
+ // `openEntry` opens exactly what `browse` lists as text: it walks the same rules down the path, so
19
+ // a path the listing would not show is refused however it is spelled.
20
+ //
21
+ // Listing never reads a file iCloud holds only in the cloud (a dataless file): reading it would
22
+ // download it. Such a file is listed with `cloud: true` and its frontmatter unread; opening it reads
23
+ // it, and a noted or sealed entry is refused then.
24
+
25
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { scanSkip, datalessAmong, isTextPath } from "./sync-rules.mjs";
28
+ import { planBeside } from "./circle-plan.mjs";
29
+ import { boundaries } from "./records.mjs";
30
+ import { resolveRole } from "./roles.mjs";
31
+ import { readContext, readersFor, isNotedFile, isSealedFile, aiView } from "./readers.mjs";
32
+ import { EvdaError } from "./errors.mjs";
33
+ import { FENCE_OPEN } from "./agents.mjs";
34
+ import { write as writeToday, todayPath } from "./tools.mjs";
35
+
36
+ // The default preset: the roles pinned above the tree, in this order, when the mapping gives each a
37
+ // folder. The journal first. `work` is a live source (harlequin:), not a folder, so it is never pinned.
38
+ export const DEFAULT_PINS = ["record/daily", "record/captures", "record/people", "collections", "collections/areas", "knowledge"];
39
+
40
+ // The record roles keep the names the phone gives them; any other pin is named by its folder.
41
+ const ROLE_TITLES = { "record/daily": "Journal", "record/captures": "Captures", "record/people": "People" };
42
+
43
+ // The largest file the app opens as text. Bigger text is listed and stays closed.
44
+ export const MAX_OPEN_BYTES = 4 * 1024 * 1024;
45
+
46
+ const DATED = /^\d{4}-\d{2}-\d{2}/;
47
+
48
+ // Tooling the view leaves out, by kind (HQ-2038, round 2): a package manifest or lockfile, a shell
49
+ // script, a license, a backup copy, an empty file. It stays in the record and in the sync; the app
50
+ // does not list or open it, and each folder says how many it left out. Dot-named entries are left
51
+ // out the same way.
52
+ const LOCKFILE = /(^package(-lock)?\.json$)|(-lock\.json$)|(^pnpm-lock\.yaml$)|(^yarn\.lock$)/i;
53
+ export function isTooling(name, size) {
54
+ if (name.startsWith(".")) return true;
55
+ if (size === 0) return true;
56
+ if (LOCKFILE.test(name)) return true;
57
+ if (/\.(sh|zsh|bash)$/i.test(name)) return true;
58
+ if (/^(LICENSE|LICENCE|COPYING)(\.(md|txt))?$/i.test(name)) return true;
59
+ if (/\.bak($|[.-])|~$/i.test(name)) return true;
60
+ return false;
61
+ }
62
+
63
+ // The files that say how the record and its agent work: the manifest, the operating layer the
64
+ // mapping names, the agent pointer files create-evda writes, and any root file carrying the
65
+ // generated core fence (his IRIS.md). Only at the record's root. The app groups them under one
66
+ // entry instead of mixing them with notes.
67
+ const AGENT_FILES = ["evda.json", "AGENTS.md", "CLAUDE.md", "GEMINI.md"];
68
+ function isOperating(bctx, name) {
69
+ if (AGENT_FILES.includes(name)) return true;
70
+ const agents = bctx.evda.manifest?.mapping?.agents;
71
+ if (typeof agents === "string" && agents.replace(/^\.\//, "") === name) return true;
72
+ if (!/\.md$/i.test(name)) return false;
73
+ if (datalessAmong(bctx.evda.dir, [name]).size) return false;
74
+ try {
75
+ return readFileSync(join(bctx.evda.dir, name), "utf8").includes(FENCE_OPEN);
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ // "01_Projects" -> "Projects", "03_Resources/Wiki" -> "Wiki".
82
+ export function folderTitle(rel) {
83
+ const last = rel.split("/").pop() ?? rel;
84
+ const t = last.replace(/^\d+[\s_.-]+/, "").replace(/[_-]+/g, " ").trim();
85
+ return t ? t[0].toUpperCase() + t.slice(1) : last;
86
+ }
87
+
88
+ // Everything one listing needs, read once: the scan's skip rule, the circles and the readers.
89
+ export function browseContext(evda) {
90
+ let extra = [];
91
+ try { extra = planBeside(evda)?.plan?.exclude ?? []; } catch { extra = []; }
92
+ const skip = scanSkip([...extra, ...boundaries(evda)]);
93
+ return { evda, skip, readers: readContext(evda) };
94
+ }
95
+
96
+ // Why the app keeps a file closed, or null when it opens. `cloud` skips the frontmatter read.
97
+ export function closedBecause(bctx, abs, { cloud = false } = {}) {
98
+ if (!cloud && isSealedFile(abs)) return "sealed";
99
+ if (!cloud && isNotedFile(abs)) return "noted";
100
+ const r = readersFor(bctx.readers, abs);
101
+ if (r.broken) return "reader";
102
+ if (Array.isArray(r.readers) && r.readers.length > 0 && r.readers.every((x) => x.kind === "none")) return "reader";
103
+ return null;
104
+ }
105
+
106
+ const circleOf = (bctx, abs) => readersFor(bctx.readers, abs).circle;
107
+
108
+ const shown = (name) => !name.startsWith(".");
109
+
110
+ // A relative folder or file path, refused unless every segment is a plain, visible name.
111
+ function cleanRel(rel, what) {
112
+ const r = String(rel ?? "").replace(/^\/+|\/+$/g, "");
113
+ if (r === "") return "";
114
+ const parts = r.split("/");
115
+ if (parts.some((p) => p === "" || p === "." || p === ".." || !shown(p))) throw new EvdaError(`${rel} is not ${what} in your record.`, { code: "OUTSIDE", exit: 2 });
116
+ return parts.join("/");
117
+ }
118
+
119
+ // Walk the rules down `rel` from the root: every segment visible, not skipped by the scan, and no
120
+ // symlink on the way. Returns the lstat of the last segment, or null when the path is not one the
121
+ // listing shows (missing included).
122
+ function reachable(bctx, rel) {
123
+ const parts = rel.split("/");
124
+ let st = null;
125
+ for (let i = 0; i < parts.length; i++) {
126
+ const sub = parts.slice(0, i + 1).join("/");
127
+ try { st = lstatSync(join(bctx.evda.dir, sub)); } catch { return null; }
128
+ if (st.isSymbolicLink() || bctx.skip(sub, st)) return null;
129
+ if (i < parts.length - 1 && !st.isDirectory()) return null;
130
+ }
131
+ return st;
132
+ }
133
+
134
+ // The pinned folders: each DEFAULT_PINS role the mapping gives a folder, with whether it is there.
135
+ // A role the mapping leaves out is not pinned, except the record roles, which read from
136
+ // `record/<x>` as everywhere else.
137
+ export function pins(evda, { bctx = browseContext(evda) } = {}) {
138
+ const out = [];
139
+ const seen = new Set();
140
+ for (const role of DEFAULT_PINS) {
141
+ let r;
142
+ try { r = resolveRole(evda, role); } catch { continue; }
143
+ if (r.kind === "source") continue;
144
+ if (!r.mapped && !role.startsWith("record/")) continue;
145
+ let folder;
146
+ try { folder = cleanRel(r.mapped ? String(r.rel).replace(/^(\.\/)+/, "") : role, "a folder"); } catch { continue; }
147
+ if (folder === "" || seen.has(folder)) continue;
148
+ seen.add(folder);
149
+ const st = reachable(bctx, folder);
150
+ out.push({ role, title: ROLE_TITLES[role] ?? folderTitle(folder), folder, exists: Boolean(st?.isDirectory()) });
151
+ }
152
+ return out;
153
+ }
154
+
155
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
156
+
157
+ // Dated names newest first, ahead of the rest; the rest by name.
158
+ function fileOrder(a, b) {
159
+ const da = DATED.test(a.name), db = DATED.test(b.name);
160
+ if (da && db) return -byName(a, b);
161
+ if (da !== db) return da ? -1 : 1;
162
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
163
+ }
164
+
165
+ // One folder's children: its folders and its files, each file with the circle that homes it.
166
+ // `hidden` counts the files kept closed (noted, sealed, a part no AI reads), never their names.
167
+ // `noted` gives each noted entry by its date alone (HQ-2049), newest first: the date its name
168
+ // starts with, else the day it was last written. Never a name, a title or a word of it.
169
+ // `tooling` counts what the view leaves out by kind (isTooling). At the root, the files that say
170
+ // how the record works are left to `operating` instead.
171
+ export function browse(evda, folder = "", { bctx = browseContext(evda) } = {}) {
172
+ const rel = cleanRel(folder, "a folder");
173
+ if (rel !== "") {
174
+ const st = reachable(bctx, rel);
175
+ if (!st || !st.isDirectory()) throw new EvdaError(`${rel} is not a folder in your record.`, { code: "NOT_FOUND", exit: 2 });
176
+ }
177
+ const abs = rel === "" ? evda.dir : join(evda.dir, rel);
178
+ const dirs = [];
179
+ const files = [];
180
+ let hidden = 0;
181
+ let tooling = 0;
182
+ const noted = [];
183
+ const candidates = [];
184
+ for (const name of readdirSync(abs)) {
185
+ const child = rel === "" ? name : `${rel}/${name}`;
186
+ let st;
187
+ try { st = lstatSync(join(evda.dir, child)); } catch { continue; }
188
+ if (st.isSymbolicLink() || bctx.skip(child, st)) continue;
189
+ if (!shown(name)) { tooling++; continue; }
190
+ if (st.isDirectory()) dirs.push({ path: child, name });
191
+ else if (!st.isFile()) continue;
192
+ else if (rel === "" && isOperating(bctx, name)) continue;
193
+ else if (isTooling(name, st.size)) tooling++;
194
+ else candidates.push({ child, name, size: st.size, mtime: st.mtimeMs });
195
+ }
196
+ // Every file, not only notes: a PDF or an image iCloud holds only in the cloud is listed as such,
197
+ // so the app says so and fetches it on a click instead of downloading it by opening (HQ-2071).
198
+ const cloud = datalessAmong(evda.dir, candidates.map((c) => c.child));
199
+ for (const c of candidates) {
200
+ const fabs = join(evda.dir, c.child);
201
+ const inCloud = cloud.has(c.child);
202
+ const why = closedBecause(bctx, fabs, { cloud: inCloud });
203
+ if (why) {
204
+ hidden++;
205
+ if (why === "noted") noted.push(notedDate(c.name, c.mtime));
206
+ continue;
207
+ }
208
+ files.push({ path: c.child, name: c.name, size: c.size, circle: circleOf(bctx, fabs), text: isTextPath(c.child) && c.size <= MAX_OPEN_BYTES, cloud: inCloud });
209
+ }
210
+ dirs.sort(byName);
211
+ files.sort(fileOrder);
212
+ noted.sort().reverse();
213
+ return { folder: rel, dirs, files, hidden, noted, tooling };
214
+ }
215
+
216
+ // A noted entry's date and nothing else: the YYYY-MM-DD its name starts with, else the local day
217
+ // it was last written.
218
+ export function notedDate(name, mtimeMs) {
219
+ const m = DATED.exec(name);
220
+ if (m) return m[0];
221
+ const d = new Date(mtimeMs);
222
+ const two = (n) => String(n).padStart(2, "0");
223
+ return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
224
+ }
225
+
226
+ // The files that say how the record and its agent work, from the root (isOperating), each with its
227
+ // circle, by name. Closed ones are left out as anywhere else.
228
+ export function operating(evda, { bctx = browseContext(evda) } = {}) {
229
+ const out = [];
230
+ for (const name of readdirSync(evda.dir).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))) {
231
+ let st;
232
+ try { st = lstatSync(join(evda.dir, name)); } catch { continue; }
233
+ if (!st.isFile() || st.isSymbolicLink() || bctx.skip(name, st) || !shown(name) || !isOperating(bctx, name)) continue;
234
+ const abs = join(evda.dir, name);
235
+ if (closedBecause(bctx, abs)) continue;
236
+ out.push({ path: name, name, size: st.size, circle: circleOf(bctx, abs), text: isTextPath(name) && st.size <= MAX_OPEN_BYTES, cloud: false });
237
+ }
238
+ return out;
239
+ }
240
+
241
+ // Append the owner's words to today's daily note, verbatim, under a new timestamped section: the
242
+ // same write evda_write makes (lib/tools.mjs write, lib/day-file.mjs appendToDay), so nothing is
243
+ // ever replaced. Returns { path, bytes, section }.
244
+ export const CAPTURE_CONTEXT = "Typed into the capture box in the EVÐA app.";
245
+ export function capture(evda, words, { now = new Date() } = {}) {
246
+ return writeToday(evda, { words, context: CAPTURE_CONTEXT }, { now });
247
+ }
248
+
249
+ // The circle a file at the record's root would land in: the record's default home. The app tags a
250
+ // file with its circle only when it is not this one, so the usual circle is not on every row.
251
+ export function defaultCircle(evda, { bctx = browseContext(evda) } = {}) {
252
+ return circleOf(bctx, join(evda.dir, "evda-default-home.md"));
253
+ }
254
+
255
+ // Today's Journal (HQ-2038): today's daily note, always there for the app even before it is
256
+ // written. `exists` false means an empty page; the first capture creates it through `write`.
257
+ // `path` is null only when the record maps no daily role.
258
+ export function today(evda, { now = new Date(), bctx = browseContext(evda) } = {}) {
259
+ const home = defaultCircle(evda, { bctx });
260
+ let path;
261
+ try { path = todayPath(evda, { now }); } catch { return { path: null, exists: false, text: "", circle: home, home }; }
262
+ const st = reachable(bctx, path);
263
+ if (!st?.isFile()) return { path, exists: false, text: "", circle: home, home };
264
+ const opened = openEntry(evda, path, { bctx });
265
+ return { path, exists: true, text: opened.text, circle: opened.circle, home };
266
+ }
267
+
268
+ // Every text file the listing shows, the whole record walked one `browse` at a time, plus the
269
+ // operating files at the root (HQ-2049): the app's quick open and its link resolution read this,
270
+ // so a name the tree would not show (noted, sealed, a part no AI reads, tooling, excluded) is never
271
+ // offered or resolved. Paths only, in walk order; nothing of any file's words.
272
+ export function index(evda, { bctx = browseContext(evda) } = {}) {
273
+ const out = operating(evda, { bctx }).filter((f) => f.text).map((f) => f.path);
274
+ const walk = (folder) => {
275
+ const f = browse(evda, folder, { bctx });
276
+ for (const file of f.files) if (file.text) out.push(file.path);
277
+ for (const d of f.dirs) walk(d.path);
278
+ };
279
+ walk("");
280
+ return out;
281
+ }
282
+
283
+ // One listed text file, opened: its words with any sealed section replaced by one line, and its
284
+ // circle. Anything `browse` would not list as text is refused, with nothing of it returned.
285
+ export function openEntry(evda, path, { bctx = browseContext(evda) } = {}) {
286
+ const rel = cleanRel(path, "a file");
287
+ const refused = () => new EvdaError(`${path} is not in your record.`, { code: "OUTSIDE", exit: 2 });
288
+ if (rel === "") throw refused();
289
+ const st = reachable(bctx, rel);
290
+ if (!st) throw refused();
291
+ if (!st.isFile()) throw refused();
292
+ const name = rel.split("/").pop();
293
+ if (isTooling(name, st.size) && !(rel === name && isOperating(bctx, name))) throw refused();
294
+ const abs = join(evda.dir, rel);
295
+ // A hard path check after the walk: the resolved file is inside the resolved record.
296
+ const root = realpathSync(evda.dir);
297
+ if (!realpathSync(abs).startsWith(root + "/")) throw refused();
298
+ if (!isTextPath(rel)) throw new EvdaError(`${rel} is not text, so it opens in Finder, not here.`, { code: "NOT_TEXT", exit: 2 });
299
+ if (st.size > MAX_OPEN_BYTES) throw new EvdaError(`${rel} is too large to open here.`, { code: "TOO_LARGE", exit: 2 });
300
+ if (closedBecause(bctx, abs)) throw refused();
301
+ const text = readFileSync(abs, "utf8");
302
+ return { path: rel, text: aiView(text).text, circle: circleOf(bctx, abs) };
303
+ }
304
+
305
+ // ─── Attachments: PDFs, images and every other file the tree lists (HQ-2071) ───────────────────
306
+ //
307
+ // The app shows a PDF or an image itself and hands any other attachment to the Mac's default app.
308
+ // What it may show is what `browse` lists and `openEntry` would not open as text: the same walk,
309
+ // the same closed rules, decided from names and stat alone, so asking never downloads a file iCloud
310
+ // holds only in the cloud. The app serves the bytes itself, only for a path this answered.
311
+
312
+ const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "heic", "heif", "gif", "webp"]);
313
+
314
+ // "pdf", "image", or "other", by the name alone.
315
+ export function attachmentKind(rel) {
316
+ const name = String(rel ?? "").split("/").pop() ?? "";
317
+ const dot = name.lastIndexOf(".");
318
+ const ext = dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
319
+ if (ext === "pdf") return "pdf";
320
+ if (IMAGE_EXTENSIONS.has(ext)) return "image";
321
+ return "other";
322
+ }
323
+
324
+ // One listed file that is not text: its kind, size, circle, and whether its bytes are only in
325
+ // iCloud. Refused, with nothing of it returned, whenever `browse` would not list it.
326
+ export function attachEntry(evda, path, { bctx = browseContext(evda) } = {}) {
327
+ const rel = cleanRel(path, "a file");
328
+ const refused = () => new EvdaError(`${path} is not in your record.`, { code: "OUTSIDE", exit: 2 });
329
+ if (rel === "") throw refused();
330
+ const st = reachable(bctx, rel);
331
+ if (!st || !st.isFile()) throw refused();
332
+ const name = rel.split("/").pop();
333
+ if (isTooling(name, st.size)) throw refused();
334
+ const abs = join(evda.dir, rel);
335
+ const root = realpathSync(evda.dir);
336
+ if (!realpathSync(abs).startsWith(root + "/")) throw refused();
337
+ if (isTextPath(rel)) throw new EvdaError(`${rel} is text, so it opens as a note.`, { code: "TEXT", exit: 2 });
338
+ const cloud = datalessAmong(evda.dir, [rel]).has(rel);
339
+ if (closedBecause(bctx, abs, { cloud })) throw refused();
340
+ return { path: rel, kind: attachmentKind(rel), size: st.size, circle: circleOf(bctx, abs), cloud };
341
+ }
342
+
343
+ // Every listed file that is not text, by path, the whole tree walked as `index` walks it: what a
344
+ // link or an embed to a PDF or an image resolves against. Paths only.
345
+ export function attachments(evda, { bctx = browseContext(evda) } = {}) {
346
+ const out = [];
347
+ const walk = (folder) => {
348
+ const f = browse(evda, folder, { bctx });
349
+ for (const file of f.files) if (!file.text && !isTextPath(file.path)) out.push(file.path);
350
+ for (const d of f.dirs) walk(d.path);
351
+ };
352
+ walk("");
353
+ return out;
354
+ }
package/lib/circles.mjs CHANGED
@@ -552,7 +552,11 @@ export function markdownFiles(root) {
552
552
  for (const name of readdirSync(abs).sort()) {
553
553
  if (name.startsWith(".") || name === "node_modules") continue;
554
554
  const p = join(abs, name);
555
- const st = statSync(p);
555
+ // A dangling symlink (target lives only on another machine, e.g. raw media kept
556
+ // outside the synced vault) has nothing to stat. It is not markdown content either
557
+ // way, so skip it rather than let the whole walk crash on one broken pointer.
558
+ let st;
559
+ try { st = statSync(p); } catch { continue; }
556
560
  if (st.isDirectory()) walk(p);
557
561
  else if (st.isFile() && /\.md$/i.test(name)) out.push(p);
558
562
  }
package/lib/clients.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  // How each AI client adds the EVDA MCP server, given the command that starts it. No node: imports,
2
2
  // so the EVDA app's webview renders the same text `evda connect` prints (lib/connect.mjs).
3
3
 
4
+ import { everyChatLine, CHAT_APPS, GENERIC_WHERE } from "./every-chat.mjs";
5
+
4
6
  export const SERVER_NAME = "evda";
5
7
 
6
8
  const shellQuote = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
7
9
  const tomlString = (s) => JSON.stringify(s);
8
10
 
9
- // `desktopConfig` is where claude_desktop_config.json lives on this machine, when known.
10
- export function clientsFor(command, args, { desktopConfig = null } = {}) {
11
+ // `desktopConfig` is where claude_desktop_config.json lives on this machine, when known. `filing` is
12
+ // the owner's answer in evda.json, which picks the form of the every-chat line (HQ-2205).
13
+ export function clientsFor(command, args, { desktopConfig = null, filing = null } = {}) {
11
14
  const argv = [command, ...args].map(shellQuote).join(" ");
12
15
  return [
13
16
  {
@@ -44,5 +47,18 @@ export function clientsFor(command, args, { desktopConfig = null } = {}) {
44
47
  text: JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "stdio", command, args } } }, null, 2),
45
48
  file: ".mcp.json",
46
49
  },
50
+ {
51
+ // Not a way to add the server: the one line a chat app needs in its every-chat setting, because
52
+ // a chat that never calls an evda tool is reached by nothing else (lib/every-chat.mjs).
53
+ key: "every-chat",
54
+ label: "Every chat app (so each chat starts as your agent)",
55
+ steps: [
56
+ "Add this line to your chat app's every-chat instructions:",
57
+ ...Object.values(CHAT_APPS).map((a) => `${a.label}: ${a.where}`),
58
+ `Any other app: ${GENERIC_WHERE}`,
59
+ ],
60
+ text: everyChatLine(filing),
61
+ file: null,
62
+ },
47
63
  ];
48
64
  }
package/lib/connect.mjs CHANGED
@@ -14,6 +14,7 @@ import { delimiter, dirname, join, resolve } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { clientsFor, SERVER_NAME } from "./clients.mjs";
17
+ import { filingOf } from "./day-file.mjs";
17
18
 
18
19
  const HERE = dirname(fileURLToPath(import.meta.url));
19
20
  export const PACKAGE_ROOT = resolve(HERE, "..");
@@ -101,7 +102,13 @@ export function desktopConfigPath(platform = process.platform, env = process.env
101
102
  // Every client's setup for one record, as data. `text` fields are what a person copies.
102
103
  export function clients(recordDir, opts = {}) {
103
104
  const { command, args } = serverCommand(recordDir, opts);
104
- return clientsFor(command, args, { desktopConfig: desktopConfigPath() });
105
+ return clientsFor(command, args, { desktopConfig: desktopConfigPath(), filing: opts.filing ?? filingIn(recordDir) });
106
+ }
107
+
108
+ // The owner's filing answer, for the form of the every-chat line. A record that cannot be read yet
109
+ // (connect run before create-evda finished) gets the "everything" form, the default setup offers.
110
+ function filingIn(recordDir) {
111
+ try { return filingOf(JSON.parse(readFileSync(join(resolve(recordDir), "evda.json"), "utf8"))); } catch { return null; }
105
112
  }
106
113
 
107
114
  export function connectText(recordDir, opts = {}) {