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.
- package/CHANGELOG.md +37 -1
- package/README.md +8 -2
- package/bin/evda.mjs +157 -4
- package/create-evda.mjs +6 -1
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +18 -2
- package/lib/connect.mjs +8 -1
- package/lib/day-file.mjs +58 -9
- package/lib/day-writer.mjs +103 -0
- package/lib/doctor.mjs +13 -0
- package/lib/every-chat.mjs +132 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +44 -3
- package/lib/gather.mjs +3 -1
- package/lib/keychain.mjs +144 -0
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +24 -4
- package/lib/mcp.mjs +893 -73
- package/lib/measurement-log.mjs +246 -0
- package/lib/messages.mjs +5 -2
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +20 -0
- package/lib/routines.mjs +42 -3
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/tools.mjs +423 -24
- package/lib/watch.mjs +160 -0
- package/package.json +1 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +25 -4
- package/templates/evda-digest.md +3 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// One sync at a time per record, across processes (HQ-1925).
|
|
2
|
+
//
|
|
3
|
+
// The Mac app syncs on its own, a launchd job or a person at a terminal can run `evda sync`, and the
|
|
4
|
+
// MCP's evda_sync_devices syncs too. Every one of them reaches the relay through lib/pairing.mjs, so
|
|
5
|
+
// the lock is taken there and nowhere else.
|
|
6
|
+
//
|
|
7
|
+
// The lock is `<record>/.evda/sync.lock`: the record's derived state, which sync-rules never sends
|
|
8
|
+
// to a device, never under record/. It holds the holder's pid and that process's start time. A lock
|
|
9
|
+
// is stale only when its holder is gone: the pid runs no process, or the process running under that
|
|
10
|
+
// pid started at a different time (the pid was reused). Its age never counts, because a sync over a
|
|
11
|
+
// slow network is still a live sync.
|
|
12
|
+
//
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { EvdaError } from "./errors.mjs";
|
|
19
|
+
|
|
20
|
+
export const SYNC_LOCK = join(".evda", "sync.lock");
|
|
21
|
+
export const SYNC_BUSY = "A sync of this EVÐA is already running. Try again in a moment.";
|
|
22
|
+
export const SYNC_WAIT_MS = 20_000;
|
|
23
|
+
|
|
24
|
+
/** When the process with this pid started, as the system reports it, or null when no process has that pid. */
|
|
25
|
+
export function processStart(pid) {
|
|
26
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code !== "EPERM") return null; // EPERM: it runs, as someone else
|
|
31
|
+
}
|
|
32
|
+
if (process.platform === "win32") return ""; // no start time to read; the pid alone decides
|
|
33
|
+
try {
|
|
34
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
35
|
+
env: { PATH: "/bin:/usr/bin", LC_ALL: "C" },
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
38
|
+
}).trim();
|
|
39
|
+
return out || null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null; // ps exits 1 when the pid is gone between the two checks
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Is the holder named in a lock file still the process that wrote it? */
|
|
46
|
+
export function holderAlive(holder, { startOf = processStart } = {}) {
|
|
47
|
+
if (!holder || !Number.isInteger(holder.pid)) return false;
|
|
48
|
+
const now = startOf(holder.pid);
|
|
49
|
+
if (now === null) return false;
|
|
50
|
+
if (now === "" || !holder.start) return true; // no start time on this system: the pid decides
|
|
51
|
+
return now === holder.start;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readHolder(raw) {
|
|
55
|
+
try {
|
|
56
|
+
const h = JSON.parse(raw);
|
|
57
|
+
return h && typeof h === "object" ? h : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readRaw(path) {
|
|
64
|
+
try {
|
|
65
|
+
return readFileSync(path, "utf8");
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error?.code === "ENOENT") return null;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const SYNC_GUARD = join(".evda", "sync.guard");
|
|
73
|
+
|
|
74
|
+
// Judging a lock stale and taking it must be one step, or two processes that both read the same
|
|
75
|
+
// stale lock can each take it. That step runs inside an exclusive SQLite transaction on
|
|
76
|
+
// .evda/sync.guard: SQLite holds it with an OS file lock, which the OS drops when a process dies, so
|
|
77
|
+
// the guard itself can never be left stale. It is held only for the few file operations below,
|
|
78
|
+
// never for a sync. node:sqlite is loaded here and not at the top, because pairing (the only caller)
|
|
79
|
+
// already needs it and every other evda command must still run on a Node without it.
|
|
80
|
+
async function guarded(dir, fn) {
|
|
81
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
82
|
+
const db = new DatabaseSync(join(dir, SYNC_GUARD));
|
|
83
|
+
try {
|
|
84
|
+
db.exec("PRAGMA busy_timeout = 10000");
|
|
85
|
+
db.exec("BEGIN EXCLUSIVE");
|
|
86
|
+
try {
|
|
87
|
+
return fn();
|
|
88
|
+
} finally {
|
|
89
|
+
db.exec("COMMIT");
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
db.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pause = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Take the record's sync lock, waiting up to `waitMs` for a running sync to finish. Returns a
|
|
100
|
+
* release function. When the wait runs out, throws SYNC_BUSY (exit 2: nothing was written).
|
|
101
|
+
* `beforeReclaim` is for tests: it runs, synchronously, between judging a lock stale and taking it.
|
|
102
|
+
*/
|
|
103
|
+
export async function acquireSyncLock(dir, { waitMs, pollMs = 100, startOf = processStart, env = process.env, beforeReclaim } = {}) {
|
|
104
|
+
const fromEnv = env.EVDA_SYNC_WAIT_MS ? Number(env.EVDA_SYNC_WAIT_MS) : NaN;
|
|
105
|
+
const wait = waitMs ?? (Number.isFinite(fromEnv) && fromEnv >= 0 ? fromEnv : SYNC_WAIT_MS);
|
|
106
|
+
const path = join(dir, SYNC_LOCK);
|
|
107
|
+
mkdirSync(join(dir, ".evda"), { recursive: true });
|
|
108
|
+
const mine = JSON.stringify({ pid: process.pid, start: startOf(process.pid) || null, n: randomBytes(4).toString("hex") });
|
|
109
|
+
const deadline = Date.now() + wait;
|
|
110
|
+
for (;;) {
|
|
111
|
+
const took = await guarded(dir, () => {
|
|
112
|
+
const raw = readRaw(path);
|
|
113
|
+
if (raw !== null) {
|
|
114
|
+
if (holderAlive(readHolder(raw), { startOf })) return false;
|
|
115
|
+
if (beforeReclaim) beforeReclaim();
|
|
116
|
+
unlinkSync(path);
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(path, mine, { flag: "wx" });
|
|
119
|
+
return true;
|
|
120
|
+
});
|
|
121
|
+
if (took) break;
|
|
122
|
+
if (Date.now() >= deadline) throw new EvdaError(SYNC_BUSY, { code: "SYNC_BUSY", exit: 2 });
|
|
123
|
+
await pause(pollMs);
|
|
124
|
+
}
|
|
125
|
+
let released = false;
|
|
126
|
+
return async function release() {
|
|
127
|
+
if (released) return;
|
|
128
|
+
released = true;
|
|
129
|
+
await guarded(dir, () => {
|
|
130
|
+
if (readRaw(path) === mine) unlinkSync(path);
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Run `fn` holding the record's sync lock; the lock is released however `fn` ends. */
|
|
136
|
+
export async function withSyncLock(dir, fn, opts = {}) {
|
|
137
|
+
const release = await acquireSyncLock(dir, opts);
|
|
138
|
+
try {
|
|
139
|
+
return await fn();
|
|
140
|
+
} finally {
|
|
141
|
+
await release();
|
|
142
|
+
}
|
|
143
|
+
}
|
package/lib/sync-rules.mjs
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// And one kind cannot sync yet: a file larger than the relay's blob lane takes (64 MB, less the
|
|
22
22
|
// seal), which fails the coverage check until the plan excludes it.
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { lstatSync, readdirSync, statfsSync } from "node:fs";
|
|
25
26
|
import { join } from "node:path";
|
|
26
27
|
|
|
27
28
|
export const EXCLUDE_NAMES = [".DS_Store", "node_modules", ".git", ".Trash", "Icon\r"];
|
|
@@ -91,3 +92,155 @@ export function scanSkip(extra = []) {
|
|
|
91
92
|
return isPlaceholder(name) || st.size > MAX_SYNC_BYTES;
|
|
92
93
|
};
|
|
93
94
|
}
|
|
95
|
+
|
|
96
|
+
// ─── What travels (HQ-2017) ─────────────────────────────────────────────────────────────────────
|
|
97
|
+
//
|
|
98
|
+
// Only text travels by default. His decision, 2026-09-30: "I don't think it needs to carry media. We
|
|
99
|
+
// don't need media to be the thing that travels. That can be one device." A pairing sealed every
|
|
100
|
+
// voice memo, video and PDF of a 1.8 GB iCloud record into the device database (21 GB, the disk full).
|
|
101
|
+
//
|
|
102
|
+
// One rule, read by the scan (which files are read), the minter and the catch-up (what a Change
|
|
103
|
+
// carries) and parity (what a shadow must hold), on the laptop's `evda sync` and on the shadow tick:
|
|
104
|
+
//
|
|
105
|
+
// text a name ending in one of TEXT_EXTENSIONS. Minted with its bytes, as before.
|
|
106
|
+
// carried any other file under a path the manifest names in `sync.carry`. Its bytes ride the
|
|
107
|
+
// blob lane, as every binary did before.
|
|
108
|
+
// reference everything else. Its Change names the file (path, name, size, sha256) and carries no
|
|
109
|
+
// bytes; nothing is sealed into the blob store and nothing is uploaded.
|
|
110
|
+
//
|
|
111
|
+
// Decided from the NAME alone, never from the bytes, because reading a file iCloud holds only in the
|
|
112
|
+
// cloud is what downloads it.
|
|
113
|
+
|
|
114
|
+
export const TEXT_EXTENSIONS = new Set([
|
|
115
|
+
"md", "markdown", "txt", "text", "canvas", "base", "json", "jsonl", "yaml", "yml", "csv", "tsv",
|
|
116
|
+
"html", "htm", "css", "js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "sh", "zsh", "toml", "xml",
|
|
117
|
+
"ini", "tex", "bib", "org", "rst", "sql", "log",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const extOf = (rel) => {
|
|
121
|
+
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
122
|
+
const dot = name.lastIndexOf(".");
|
|
123
|
+
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Whether a path is text by its name. */
|
|
127
|
+
export const isTextPath = (rel) => TEXT_EXTENSIONS.has(extOf(rel));
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The paths whose bytes travel whatever they are, from the manifest: `"sync": { "carry": [...] }`,
|
|
131
|
+
* each a path relative to the record covering itself and everything under it. Anything else in
|
|
132
|
+
* that key is ignored.
|
|
133
|
+
*/
|
|
134
|
+
export function carryList(manifest) {
|
|
135
|
+
const list = manifest?.sync?.carry;
|
|
136
|
+
return Array.isArray(list) ? list.filter((p) => typeof p === "string").map(norm).filter((p) => p !== "") : [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `(rel) -> "text" | "carried" | "reference"`, for a carry list from `carryList`. */
|
|
140
|
+
export function fileClass(carry = []) {
|
|
141
|
+
const paths = carry.map(norm).filter((p) => p !== "");
|
|
142
|
+
return (rel) => {
|
|
143
|
+
if (isTextPath(rel)) return "text";
|
|
144
|
+
const r = norm(rel);
|
|
145
|
+
return paths.some((p) => r === p || r.startsWith(`${p}/`)) ? "carried" : "reference";
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Files iCloud holds only in the cloud (HQ-2017) ─────────────────────────────────────────────
|
|
150
|
+
//
|
|
151
|
+
// A dataless file is listed with its full size and has no bytes on this disk; opening it makes iCloud
|
|
152
|
+
// download it. macOS marks it with SF_DATALESS in st_flags. Node's stat does not expose st_flags, so
|
|
153
|
+
// a file is a candidate when it has a size and no blocks (the strong sign), and a candidate is
|
|
154
|
+
// confirmed by reading its flags with stat(1), which does not open it. A file whose bytes are local
|
|
155
|
+
// is never a candidate, so stat(1) runs only for the few that are.
|
|
156
|
+
export const SF_DATALESS = 0x40000000;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The paths among `rels` (relative to `root`) that are dataless, read without opening any of them.
|
|
160
|
+
* `statBin` is stat(1); `EVDA_STAT_BIN` replaces it, so a test can stand in for iCloud.
|
|
161
|
+
*/
|
|
162
|
+
export function datalessAmong(root, rels, { env = process.env, statOf = (rel) => lstatSync(join(root, rel)) } = {}) {
|
|
163
|
+
if (process.platform !== "darwin" && !env.EVDA_STAT_BIN) return new Set();
|
|
164
|
+
const candidates = [];
|
|
165
|
+
for (const rel of rels) {
|
|
166
|
+
let st;
|
|
167
|
+
try { st = statOf(rel); } catch { continue; }
|
|
168
|
+
if (st.isFile() && st.size > 0 && st.blocks === 0) candidates.push(rel);
|
|
169
|
+
}
|
|
170
|
+
const out = new Set();
|
|
171
|
+
const bin = env.EVDA_STAT_BIN || "/usr/bin/stat";
|
|
172
|
+
for (let i = 0; i < candidates.length; i += 200) {
|
|
173
|
+
const chunk = candidates.slice(i, i + 200);
|
|
174
|
+
let text = "";
|
|
175
|
+
try {
|
|
176
|
+
text = execFileSync(bin, ["-f", "%f", ...chunk.map((r) => join(root, r))], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
177
|
+
} catch (e) {
|
|
178
|
+
text = typeof e?.stdout === "string" ? e.stdout : "";
|
|
179
|
+
}
|
|
180
|
+
const flags = text.split("\n").filter((l) => l.trim() !== "");
|
|
181
|
+
chunk.forEach((rel, j) => {
|
|
182
|
+
const f = Number(flags[j]);
|
|
183
|
+
if (Number.isFinite(f) && (f & SF_DATALESS) !== 0) out.add(rel);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── The owner's size limit (HQ-2017) ───────────────────────────────────────────────────────────
|
|
190
|
+
//
|
|
191
|
+
// `"sync": { "maxBytes": N }` in evda.json: a file larger than N bytes is never opened by a sync or a
|
|
192
|
+
// tick, whatever its class, and is reported. Missing, zero, negative or not a number reads as
|
|
193
|
+
// MAX_SYNC_BYTES; above it reads as MAX_SYNC_BYTES, since the relay refuses anything larger anyway.
|
|
194
|
+
// On 2026-09-30 a sync read a 686 MB video whole, to seal it.
|
|
195
|
+
|
|
196
|
+
/** The size limit a manifest sets, in bytes, clamped to the relay's. */
|
|
197
|
+
export function maxSyncBytes(manifest) {
|
|
198
|
+
const n = manifest?.sync?.maxBytes;
|
|
199
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n <= 0) return MAX_SYNC_BYTES;
|
|
200
|
+
return Math.min(Math.floor(n), MAX_SYNC_BYTES);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const mbOf = (n) => (n < 1024 ** 2 ? `${n} bytes` : `${(n / 1024 ** 2).toFixed(1)} MB`);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What a scan does with each listed file, decided before a byte is read: a Map from path to
|
|
207
|
+
* "read" (text or carried: minted with its bytes), "reference" (hashed for its identity, minted
|
|
208
|
+
* without its bytes) or { skip, kind } (never opened, and reported). A dataless file is skipped, text
|
|
209
|
+
* or not: its bytes are not on this disk, and reading them is what downloads them. A file over
|
|
210
|
+
* `maxBytes` is skipped too, marked `kind: "size"`: reading it whole is the cost being avoided.
|
|
211
|
+
*/
|
|
212
|
+
export function scanClasses(root, carry = [], { env = process.env, maxBytes = MAX_SYNC_BYTES } = {}) {
|
|
213
|
+
const classOf = fileClass(carry);
|
|
214
|
+
const limit = Number.isFinite(maxBytes) && maxBytes > 0 ? Math.min(maxBytes, MAX_SYNC_BYTES) : MAX_SYNC_BYTES;
|
|
215
|
+
return async (stats) => {
|
|
216
|
+
const rels = stats.map((s) => s.path);
|
|
217
|
+
const dataless = datalessAmong(root, rels, { env });
|
|
218
|
+
const out = new Map();
|
|
219
|
+
for (const s of stats) {
|
|
220
|
+
const rel = s.path;
|
|
221
|
+
if (dataless.has(rel)) out.set(rel, { skip: "in iCloud only; not downloaded" });
|
|
222
|
+
else if (s.size > limit) out.set(rel, { skip: `${mbOf(s.size)}, over the ${mbOf(limit)} size limit; not read`, kind: "size" });
|
|
223
|
+
else out.set(rel, classOf(rel) === "reference" ? "reference" : "read");
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── The disk floor (HQ-2017) ───────────────────────────────────────────────────────────────────
|
|
230
|
+
//
|
|
231
|
+
// A sync or tick stops before the disk its state is on falls below a floor, and says why in one line.
|
|
232
|
+
// The default is 5 GiB; `EVDA_DISK_FLOOR_BYTES` sets another (a test sets one above what is free).
|
|
233
|
+
export const DISK_FLOOR_BYTES = 5 * 1024 ** 3;
|
|
234
|
+
|
|
235
|
+
const gib = (n) => `${(n / 1024 ** 3).toFixed(1)} GB`;
|
|
236
|
+
|
|
237
|
+
/** `(bytes) -> null | "<one line>"`: whether writing `bytes` more under `dir` keeps the floor. */
|
|
238
|
+
export function diskRoom(dir, { env = process.env, freeOf = (d) => { const s = statfsSync(d); return Number(s.bavail) * Number(s.bsize); } } = {}) {
|
|
239
|
+
const set = Number(env.EVDA_DISK_FLOOR_BYTES);
|
|
240
|
+
const floor = env.EVDA_DISK_FLOOR_BYTES !== undefined && Number.isFinite(set) && set >= 0 ? set : DISK_FLOOR_BYTES;
|
|
241
|
+
return (bytes = 0) => {
|
|
242
|
+
const free = freeOf(dir);
|
|
243
|
+
if (free - bytes >= floor) return null;
|
|
244
|
+
return `Stopped: this disk has ${gib(free)} free, and syncing more would take it below the ${gib(floor)} floor. Nothing more was written; free some space and sync again.`;
|
|
245
|
+
};
|
|
246
|
+
}
|