create-evda 0.1.5 → 0.1.6
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 +100 -0
- package/README.md +74 -7
- package/bin/evda.mjs +391 -6
- package/create-evda.mjs +54 -10
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +346 -13
- package/lib/filechanges.mjs +622 -0
- package/lib/gather-apply.mjs +635 -0
- package/lib/gather.mjs +808 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +425 -36
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +52 -12
- package/lib/parity.mjs +111 -0
- package/lib/presets.mjs +58 -7
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +102 -1
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +118 -0
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +87 -0
- package/lib/tools.mjs +118 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +24 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +58 -0
- package/templates/AGENTS.md +118 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +30 -6
- package/LICENSE +0 -21
- package/presets/student/preset.json +0 -13
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Making a record inside a record from a conversation (HQ-1377, the HQ-1371 shape): the owner asks
|
|
2
|
+
// for a sub-EVÐA for a project, a company or an agent, and their AI makes it through the MCP server.
|
|
3
|
+
//
|
|
4
|
+
// What it makes, at a path inside the record: an evda.json with the four roles (journal from
|
|
5
|
+
// record/daily and record/captures, work from work/, collections from record/people, knowledge from
|
|
6
|
+
// the ledger), an AGENTS.md whose owner section carries what it is for, and everything else a new
|
|
7
|
+
// record gets from create-evda. It is made by the same scaffold, so a sub-record is never a second
|
|
8
|
+
// shape of record.
|
|
9
|
+
//
|
|
10
|
+
// The parent stays as it is. Its evda.json changes only when the owner opts into "subrecords":
|
|
11
|
+
// "bounded" (the parent's circles stop at the sub-record), which changes what the parent syncs and
|
|
12
|
+
// so waits for confirmed: true. Doctor runs on the parent before and after, walking the new
|
|
13
|
+
// sub-record as its own record; when the parent was clean before and is not after, everything this
|
|
14
|
+
// call made is removed again and the report says why.
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { isAbsolute, join, normalize, resolve, sep } from "node:path";
|
|
18
|
+
import { parseArgs, scaffold, slug } from "../create-evda.mjs";
|
|
19
|
+
import { readManifest, isBounded } from "./manifest.mjs";
|
|
20
|
+
import { subRecords } from "./records.mjs";
|
|
21
|
+
import { doctor } from "./doctor.mjs";
|
|
22
|
+
import { EvdaError } from "./errors.mjs";
|
|
23
|
+
|
|
24
|
+
export const WORK_DIR = "work";
|
|
25
|
+
|
|
26
|
+
// EVDA_SUBRECORD_FALSIFY=<mode,...>, for the tests: no-doctor returns without running doctor after,
|
|
27
|
+
// keep-broken leaves what it made in place when doctor fails on it.
|
|
28
|
+
export const SUBRECORD_FALSIFY_MODES = ["no-doctor", "keep-broken"];
|
|
29
|
+
const falsified = (mode) => (process.env.EVDA_SUBRECORD_FALSIFY ?? "").split(",").map((x) => x.trim()).includes(mode);
|
|
30
|
+
|
|
31
|
+
function refuse(message, code) {
|
|
32
|
+
return new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runDoctor(dir) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
const r = doctor(dir, { out: (l) => lines.push(l) });
|
|
38
|
+
return { exit: r.exit, text: lines.join("\n") };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Where the new record may go, as a relative path, or a refusal naming why not.
|
|
42
|
+
export function placeFor(evda, path) {
|
|
43
|
+
if (typeof path !== "string" || path.trim() === "") throw refuse(`evda_create_subrecord needs "path": where in the record the new EVÐA goes, such as Projects/Garden.`, "NO_PATH");
|
|
44
|
+
if (isAbsolute(path)) throw refuse(`"path" ${JSON.stringify(path)} is absolute; it is relative to the record.`, "BAD_PATH");
|
|
45
|
+
const rel = normalize(path).split(sep).join("/").replace(/\/+$/, "");
|
|
46
|
+
const abs = resolve(evda.dir, rel);
|
|
47
|
+
if (abs === evda.dir || !abs.startsWith(evda.dir + sep)) throw refuse(`"path" ${JSON.stringify(path)} is not inside the record.`, "BAD_PATH");
|
|
48
|
+
if (rel.split("/").some((part) => part.startsWith("."))) throw refuse(`"path" ${JSON.stringify(path)} goes through a dot folder, which is the tooling's, not the record's.`, "BAD_PATH");
|
|
49
|
+
const inside = subRecords(evda.dir).find((s) => rel === s || rel.startsWith(`${s}/`));
|
|
50
|
+
if (inside !== undefined) throw refuse(`${rel} is inside the sub-record at ${inside}, which is a record of its own. Make it from that record's server.`, "INSIDE_SUBRECORD");
|
|
51
|
+
for (const [key, value] of Object.entries(evda.manifest.mapping)) {
|
|
52
|
+
if (typeof value !== "string" || /^[a-z][a-z0-9-]*:/.test(value)) continue;
|
|
53
|
+
const mapped = value.replace(/\/+$/, "");
|
|
54
|
+
if (mapped === rel || mapped.startsWith(`${rel}/`)) throw refuse(`${rel} would hold ${mapped}, which this record maps as "${key}". A parent's own folders never go inside a sub-record.`, "HOLDS_ROLE");
|
|
55
|
+
if (key === "agents" && rel.startsWith(`${mapped}/`)) throw refuse(`${rel} is under ${mapped}, a file.`, "BAD_PATH");
|
|
56
|
+
}
|
|
57
|
+
if (existsSync(abs) && readdirSync(abs).length) throw refuse(`${rel} already exists and is not empty. A sub-EVÐA starts in a new or empty folder; to make an existing folder a record of its own, ask the owner and run create-evda there with --force.`, "NOT_EMPTY");
|
|
58
|
+
return { rel, abs };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createSubrecord(evda, args = {}) {
|
|
62
|
+
const allowed = ["path", "name", "purpose", "kind", "bounded", "confirmed"];
|
|
63
|
+
const extra = Object.keys(args).filter((k) => !allowed.includes(k));
|
|
64
|
+
if (extra.length) throw refuse(`evda_create_subrecord does not take ${extra.map((k) => JSON.stringify(k)).join(", ")}.`, "UNKNOWN_ARGUMENT");
|
|
65
|
+
if (args.bounded === true && args.confirmed !== true) {
|
|
66
|
+
throw refuse(`bounded: true changes this record's evda.json so its circles stop at the new sub-record, which changes what it syncs. Pass confirmed: true only when the owner said yes to that in this conversation. Without bounded, the sub-EVÐA is made and the parent is left as it is.`, "NOT_CONFIRMED");
|
|
67
|
+
}
|
|
68
|
+
const { rel, abs } = placeFor(evda, args.path);
|
|
69
|
+
const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : rel.split("/").pop();
|
|
70
|
+
const kind = args.kind ?? "person";
|
|
71
|
+
const parentId = typeof evda.manifest.id === "string" && evda.manifest.id ? evda.manifest.id : "atlas";
|
|
72
|
+
const id = `${slug(name)}.${parentId}`;
|
|
73
|
+
const owner = evda.manifest.owner?.name ?? "";
|
|
74
|
+
|
|
75
|
+
const before = runDoctor(evda.dir);
|
|
76
|
+
const manifestFile = join(evda.dir, "evda.json");
|
|
77
|
+
const parentBefore = readFileSync(manifestFile, "utf8");
|
|
78
|
+
const existed = existsSync(abs);
|
|
79
|
+
// The outermost folder this call creates, so an undo leaves no empty parent behind.
|
|
80
|
+
const parts = rel.split("/");
|
|
81
|
+
let top = abs;
|
|
82
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
83
|
+
const p = join(evda.dir, ...parts.slice(0, i));
|
|
84
|
+
if (!existsSync(p)) { top = p; break; }
|
|
85
|
+
}
|
|
86
|
+
let madeParentBounded = false;
|
|
87
|
+
try {
|
|
88
|
+
const argv = [abs, "--yes", "--no-pointer", "--no-guard", "--name", name, "--kind", kind, "--id", id, "--layers", "record,ledger"];
|
|
89
|
+
if (owner) argv.push("--owner", owner);
|
|
90
|
+
if (typeof args.purpose === "string" && args.purpose.trim()) argv.push("--purpose", args.purpose.trim());
|
|
91
|
+
scaffold({ ...parseArgs(argv), force: existed });
|
|
92
|
+
// The fourth role. record/daily and record/captures fill journal, record/people collections and
|
|
93
|
+
// the ledger knowledge; work is a folder of effort files until the owner points it at a board.
|
|
94
|
+
const m = readManifest(abs);
|
|
95
|
+
m.mapping.work = WORK_DIR;
|
|
96
|
+
if (evda.manifest.read_by !== undefined && m.read_by === undefined) m.read_by = evda.manifest.read_by;
|
|
97
|
+
mkdirSync(join(abs, WORK_DIR), { recursive: true });
|
|
98
|
+
writeFileSync(join(abs, "evda.json"), `${JSON.stringify(m, null, 2)}\n`);
|
|
99
|
+
if (args.bounded === true && !isBounded(evda.manifest)) {
|
|
100
|
+
const pm = JSON.parse(parentBefore);
|
|
101
|
+
pm.subrecords = "bounded";
|
|
102
|
+
writeFileSync(manifestFile, `${JSON.stringify(pm, null, 2)}\n`);
|
|
103
|
+
madeParentBounded = true;
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
undo();
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
const after = falsified("no-doctor") ? { exit: 0, text: "" } : runDoctor(evda.dir);
|
|
110
|
+
if (before.exit === 0 && after.exit !== 0 && !falsified("keep-broken")) {
|
|
111
|
+
undo();
|
|
112
|
+
throw new EvdaError(`REFUSED: doctor was clean on this record before and failed with the sub-EVÐA at ${rel}, so what this call made was removed and the record is as it was.\n\n${after.text}`, { code: "DOCTOR_FAILED", exit: 2 });
|
|
113
|
+
}
|
|
114
|
+
return { path: rel, id, name, parentBounded: isBounded(readManifest(evda.dir)), madeParentBounded, doctor: after, doctorBefore: before.exit };
|
|
115
|
+
|
|
116
|
+
function undo() {
|
|
117
|
+
if (existed) for (const n of readdirSync(abs)) rmSync(join(abs, n), { recursive: true, force: true });
|
|
118
|
+
else rmSync(top, { recursive: true, force: true });
|
|
119
|
+
writeFileSync(manifestFile, parentBefore);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Which files in a record sync, and which are left where they are (Spec 08, stages S0 and S1).
|
|
2
|
+
//
|
|
3
|
+
// One definition, read by three things that must agree: the circle plan (what a circle's members
|
|
4
|
+
// are), the coverage check (every file in exactly one circle), and the shadow scan and parity check
|
|
5
|
+
// (what is sent, and what the shadow must equal). Two copies of this list would be two answers to
|
|
6
|
+
// "does this file sync", and parity would pass on a file both sides agreed to skip.
|
|
7
|
+
//
|
|
8
|
+
// Three kinds of thing never sync, and each is counted rather than dropped:
|
|
9
|
+
//
|
|
10
|
+
// excluded by name anywhere (.DS_Store, node_modules, .git), by name at the root (.evda, the
|
|
11
|
+
// derived index every device rebuilds), or by a path the plan names. Reported as
|
|
12
|
+
// EXCLUDED n.
|
|
13
|
+
// symlink never followed and never carried: sync moves bytes, and a link's target may be
|
|
14
|
+
// outside the record, missing, or itself. A symlink inside a circle fails the
|
|
15
|
+
// coverage check until the plan excludes it.
|
|
16
|
+
// placeholder an iCloud stub (`.name.icloud`) for a file whose bytes are not on this disk.
|
|
17
|
+
// Syncing the stub would make parity pass while the file never moved, so a stub
|
|
18
|
+
// inside a circle fails until it is downloaded.
|
|
19
|
+
//
|
|
20
|
+
// And one kind cannot sync yet: a file larger than the relay's blob lane takes (64 MB, less the
|
|
21
|
+
// seal), which fails the coverage check until the plan excludes it.
|
|
22
|
+
|
|
23
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
export const EXCLUDE_NAMES = [".DS_Store", "node_modules", ".git", ".Trash", "Icon\r"];
|
|
27
|
+
export const EXCLUDE_ROOT_NAMES = [".evda"];
|
|
28
|
+
// The relay refuses a blob over 64 MiB (`xania-relay/src/relay.mjs`, MAX_BLOB_BYTES); the seal adds
|
|
29
|
+
// a nonce, a tag and an ephemeral key, so leave a margin.
|
|
30
|
+
export const MAX_SYNC_BYTES = 64 * 1024 * 1024 - 64 * 1024;
|
|
31
|
+
|
|
32
|
+
const PLACEHOLDER = /^\..+\.icloud$/;
|
|
33
|
+
export const isPlaceholder = (name) => PLACEHOLDER.test(name);
|
|
34
|
+
|
|
35
|
+
const norm = (rel) => rel.replace(/^\/+|\/+$/g, "");
|
|
36
|
+
|
|
37
|
+
// A predicate over a relative path: is it (or something above it) excluded? `extra` is the plan's
|
|
38
|
+
// own list of excluded paths, each covering itself and everything under it.
|
|
39
|
+
export function excluder(extra = []) {
|
|
40
|
+
const paths = extra.map(norm).filter((p) => p !== "");
|
|
41
|
+
return (rel) => {
|
|
42
|
+
const parts = norm(rel).split("/");
|
|
43
|
+
if (parts.some((p) => EXCLUDE_NAMES.includes(p))) return true;
|
|
44
|
+
if (EXCLUDE_ROOT_NAMES.includes(parts[0])) return true;
|
|
45
|
+
const r = parts.join("/");
|
|
46
|
+
return paths.some((p) => r === p || r.startsWith(`${p}/`));
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Walk a directory without following links. Yields { rel, kind, size } for every entry that is not
|
|
51
|
+
// a directory, where kind is file | symlink | placeholder | excluded | oversize. An excluded
|
|
52
|
+
// directory is yielded once, as itself, and not entered.
|
|
53
|
+
export function* walk(root, { exclude = excluder(), from = "" } = {}) {
|
|
54
|
+
const dir = from === "" ? root : join(root, from);
|
|
55
|
+
let names;
|
|
56
|
+
try {
|
|
57
|
+
names = readdirSync(dir).sort();
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") return;
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
for (const name of names) {
|
|
63
|
+
const rel = from === "" ? name : `${from}/${name}`;
|
|
64
|
+
const st = lstatSync(join(root, rel));
|
|
65
|
+
if (exclude(rel)) {
|
|
66
|
+
yield { rel, kind: "excluded", size: st.isDirectory() ? 0 : st.size, dir: st.isDirectory() };
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (st.isSymbolicLink()) { yield { rel, kind: "symlink", size: 0 }; continue; }
|
|
70
|
+
if (st.isDirectory()) { yield* walk(root, { exclude, from: rel }); continue; }
|
|
71
|
+
if (isPlaceholder(name)) { yield { rel, kind: "placeholder", size: st.size }; continue; }
|
|
72
|
+
yield { rel, kind: st.size > MAX_SYNC_BYTES ? "oversize" : "file", size: st.size };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The same rules as a filter a scanner applies while it walks: true means do not list it and do not
|
|
77
|
+
// enter it. Everything that is not a plain, syncable file is skipped, and `walk` is what counts them.
|
|
78
|
+
export function scanSkip(extra = []) {
|
|
79
|
+
const exclude = excluder(extra);
|
|
80
|
+
return (rel, st) => {
|
|
81
|
+
if (exclude(rel)) return true;
|
|
82
|
+
if (st.isSymbolicLink()) return true;
|
|
83
|
+
if (st.isDirectory()) return false;
|
|
84
|
+
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
85
|
+
return isPlaceholder(name) || st.size > MAX_SYNC_BYTES;
|
|
86
|
+
};
|
|
87
|
+
}
|
package/lib/tools.mjs
CHANGED
|
@@ -11,22 +11,32 @@
|
|
|
11
11
|
// reference (lib/references.mjs secretShape) and a hit refuses the whole write.
|
|
12
12
|
// 3. Every write says where it landed, as a path relative to the record.
|
|
13
13
|
//
|
|
14
|
+
// Who may read (HQ-1374). Every tool that returns the record's words asks lib/readers.mjs first, for
|
|
15
|
+
// each file: a just-noted entry is returned to no AI, and a file whose circle (or evda.json) names a
|
|
16
|
+
// reader that is not the caller is held back. What was held back is said in one plain sentence at
|
|
17
|
+
// the end of the answer, so the agent declines out loud instead of going quiet. The caller is who
|
|
18
|
+
// the MCP client said it was on initialize; $EVDA_READER overrides it.
|
|
19
|
+
//
|
|
20
|
+
// Sealed (HQ-1220) is stronger and holds for every caller: a sealed file is in no answer, and no
|
|
21
|
+
// answer names it or says whether it matched; a sealed section is cut out of every file shown
|
|
22
|
+
// (lib/readers.mjs aiView). Every tool below that returns words goes through both.
|
|
23
|
+
//
|
|
14
24
|
// A circle is never created here. `propose_circle` writes a proposal file for the owner to read;
|
|
15
25
|
// making the circle, and granting anyone anything, stays the owner's explicit yes through
|
|
16
26
|
// `evda grant` or their own hands.
|
|
17
27
|
|
|
18
28
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
-
import { dirname, join, relative } from "node:path";
|
|
29
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
20
30
|
import { resolveRole, rolesForLayer, agentsFile } from "./roles.mjs";
|
|
21
31
|
import { mdFiles, fileDate } from "./record.mjs";
|
|
22
|
-
import { readCircles } from "./circles.mjs";
|
|
32
|
+
import { readCircles, isSealed } from "./circles.mjs";
|
|
23
33
|
import { secretShape } from "./references.mjs";
|
|
24
34
|
import { doctor } from "./doctor.mjs";
|
|
35
|
+
import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY } from "./readers.mjs";
|
|
25
36
|
import { EvdaError } from "./errors.mjs";
|
|
37
|
+
import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
|
|
26
38
|
|
|
27
|
-
|
|
28
|
-
export const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
29
|
-
export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
39
|
+
export { localDate, localTime, CONTEXT_LABEL };
|
|
30
40
|
|
|
31
41
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
32
42
|
const MAX_WORDS = 200_000;
|
|
@@ -63,10 +73,12 @@ function rel(evda, abs) {
|
|
|
63
73
|
return relative(evda.dir, abs).split("\\").join("/");
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
// The day's record: `record/daily`, else the `journal` role (HQ-1371). keep-hook.mjs dayRole is the
|
|
77
|
+
// one rule, so the Stop hook watches the same file this appends to.
|
|
66
78
|
function dailyDir(evda) {
|
|
67
|
-
const
|
|
68
|
-
if (!
|
|
69
|
-
return
|
|
79
|
+
const role = dayRole(evda.manifest.mapping);
|
|
80
|
+
if (!role) throw refuse('this record maps no "record/daily" or "journal" role, so there is no daily file to append to.', "NO_ROLE");
|
|
81
|
+
return resolveRole(evda, role);
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
// Where imports go: `imports/` beside the daily role, so `record/daily` gives `record/imports`, and
|
|
@@ -85,12 +97,13 @@ export function operatingLayer(evda, args = {}) {
|
|
|
85
97
|
onlyKeys("evda_operating_layer", args, []);
|
|
86
98
|
const a = agentsFile(evda);
|
|
87
99
|
if (!a) throw new EvdaError('this record maps no "agents" role, so it has no operating layer.', { code: "NO_ROLE" });
|
|
88
|
-
|
|
100
|
+
if (isSealed(a.abs)) throw new EvdaError(`REFUSED: ${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, { code: "SEALED", exit: 2 });
|
|
101
|
+
return { path: a.rel, text: aiView(readFileSync(a.abs, "utf8")).text };
|
|
89
102
|
}
|
|
90
103
|
|
|
91
104
|
// 2. The last `days` days of the record, newest first, whole files, capped in size. A file is in the
|
|
92
105
|
// window by its date (filename, then a date or created key), the same rule `evda moment` uses.
|
|
93
|
-
export function recent(evda, args = {}, { now = new Date() } = {}) {
|
|
106
|
+
export function recent(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
|
|
94
107
|
onlyKeys("evda_recent", args, ["days"]);
|
|
95
108
|
const days = args.days === undefined ? 3 : Number(args.days);
|
|
96
109
|
if (!Number.isInteger(days) || days < 1 || days > 90) throw new EvdaError("evda_recent days must be a whole number from 1 to 90.", { code: "BAD_ARGUMENT", exit: 2 });
|
|
@@ -99,11 +112,18 @@ export function recent(evda, args = {}, { now = new Date() } = {}) {
|
|
|
99
112
|
const from = localDate(start);
|
|
100
113
|
const to = localDate(now);
|
|
101
114
|
const files = [];
|
|
115
|
+
const ctx = readContext(evda);
|
|
116
|
+
const refused = [];
|
|
117
|
+
let sealed = false;
|
|
102
118
|
for (const abs of recordFiles(evda)) {
|
|
103
|
-
const
|
|
104
|
-
const date = fileDate(abs,
|
|
119
|
+
const raw = readFileSync(abs, "utf8");
|
|
120
|
+
const date = fileDate(abs, raw);
|
|
105
121
|
if (!date || date < from || date > to) continue;
|
|
106
|
-
|
|
122
|
+
const may = mayRead(ctx, abs, caller);
|
|
123
|
+
if (!may.ok) { refused.push(may); continue; }
|
|
124
|
+
const shown = aiView(raw);
|
|
125
|
+
if (shown.sections) sealed = true;
|
|
126
|
+
files.push({ date, path: rel(evda, abs), text: shown.text });
|
|
107
127
|
}
|
|
108
128
|
files.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : b.date.localeCompare(a.date)));
|
|
109
129
|
let used = 0;
|
|
@@ -114,7 +134,7 @@ export function recent(evda, args = {}, { now = new Date() } = {}) {
|
|
|
114
134
|
used += f.text.length;
|
|
115
135
|
out.push(f);
|
|
116
136
|
}
|
|
117
|
-
return { from, to, files: out, omitted: truncated };
|
|
137
|
+
return { from, to, files: out, omitted: truncated, refused: refused.filter((r) => r.kind !== "sealed"), heldBack: heldBack(refused, { sealed }) };
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
// Every markdown file the record holds: each record-layer role, and the imports folder. Not the
|
|
@@ -134,20 +154,37 @@ function recordFiles(evda) {
|
|
|
134
154
|
return out;
|
|
135
155
|
}
|
|
136
156
|
|
|
137
|
-
// 3. Append the owner's words, verbatim, to today's daily file
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
157
|
+
// 3. Append the owner's words, verbatim, to today's daily file. Creates the file with a date heading
|
|
158
|
+
// when it is the first write of the day. Returns the path.
|
|
159
|
+
// `context` (HQ-1377) is one line on what the conversation was about, in the agent's words, written
|
|
160
|
+
// above theirs and labeled as not theirs, so a later reader never mistakes it for something they said.
|
|
161
|
+
// `session` (HQ-1385) is the conversation: its first write opens `## HH:MM` with the context line,
|
|
162
|
+
// and later writes append under it (lib/day-file.mjs, the one append path).
|
|
163
|
+
export const CONTEXT_MAX = 240;
|
|
164
|
+
export function write(evda, args = {}, { now = new Date(), session = null } = {}) {
|
|
165
|
+
onlyKeys("evda_write", args, ["words", "context"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
|
|
141
166
|
const words = text(args.words, "words", "evda_write");
|
|
142
167
|
refuseSecrets(words, "evda_write");
|
|
168
|
+
let context = null;
|
|
169
|
+
if (args.context !== undefined) {
|
|
170
|
+
if (typeof args.context !== "string" || args.context.trim() === "") throw refuse(`evda_write "context" is one line on what the conversation was about; leave it out rather than send it empty.`, "EMPTY");
|
|
171
|
+
if (/\n/.test(args.context.trim())) throw refuse(`evda_write "context" is one line. Their words go in "words"; the context is a single line on what the conversation was about.`, "CONTEXT_LINES");
|
|
172
|
+
if (args.context.trim().length > CONTEXT_MAX) throw refuse(`evda_write "context" is ${args.context.trim().length} characters; one line of context is at most ${CONTEXT_MAX}.`, "TOO_LONG");
|
|
173
|
+
refuseSecrets(args.context, "evda_write");
|
|
174
|
+
context = args.context.trim();
|
|
175
|
+
}
|
|
143
176
|
const r = dailyDir(evda);
|
|
144
177
|
mkdirSync(r.abs, { recursive: true });
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
178
|
+
const abs = join(r.abs, `${localDate(now)}.md`);
|
|
179
|
+
const w = appendToDay(abs, { now, words, context, session });
|
|
180
|
+
return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The day file for a date, as { abs, rel }, for writers that are not evda_write (the chat-export
|
|
184
|
+
// backstop in `evda gather`). Throws when the record maps no day role.
|
|
185
|
+
export function dayFile(evda, date) {
|
|
186
|
+
const r = dailyDir(evda);
|
|
187
|
+
return { abs: join(r.abs, `${date}.md`), rel: rel(evda, join(r.abs, `${date}.md`)), dir: r.abs };
|
|
151
188
|
}
|
|
152
189
|
|
|
153
190
|
// 4. File what another AI said about the owner, verbatim, under imports/, labeled with its source
|
|
@@ -184,7 +221,7 @@ export function importText(evda, args = {}, { now = new Date() } = {}) {
|
|
|
184
221
|
|
|
185
222
|
// 5. Plain text search inside the record, on this machine. Case-insensitive substring over every
|
|
186
223
|
// record file, the imports, and the ledger. Returns each matching line with its path and number.
|
|
187
|
-
export function search(evda, args = {}) {
|
|
224
|
+
export function search(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
188
225
|
onlyKeys("evda_search", args, ["query", "limit"]);
|
|
189
226
|
if (typeof args.query !== "string" || args.query.trim() === "") throw new EvdaError('evda_search needs "query".', { code: "BAD_ARGUMENT", exit: 2 });
|
|
190
227
|
const limit = args.limit === undefined ? 50 : Number(args.limit);
|
|
@@ -195,14 +232,68 @@ export function search(evda, args = {}) {
|
|
|
195
232
|
if (ledger.mapped && ledger.fileExists) files.push(ledger.file);
|
|
196
233
|
const matches = [];
|
|
197
234
|
let more = 0;
|
|
235
|
+
const ctx = readContext(evda);
|
|
236
|
+
const refused = [];
|
|
237
|
+
// Whether any searched file is sealed or holds a sealed section, decided without the query, so
|
|
238
|
+
// the answer to a search whose only hit is sealed is the answer to one that hit nothing.
|
|
239
|
+
let sealed = false;
|
|
198
240
|
for (const abs of files) {
|
|
199
|
-
readFileSync(abs, "utf8")
|
|
241
|
+
const raw = readFileSync(abs, "utf8");
|
|
242
|
+
const may = mayRead(ctx, abs, caller);
|
|
243
|
+
if (!may.ok && may.kind === "sealed") { sealed = true; continue; }
|
|
244
|
+
if (!may.ok) {
|
|
245
|
+
// Only a held-back file that would have matched is named, and never the line it matched on.
|
|
246
|
+
// Matched on what the AI could be shown, so a hit inside a sealed section names nothing.
|
|
247
|
+
if (aiView(raw).text.toLowerCase().includes(needle)) refused.push(may);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const shown = aiView(raw);
|
|
251
|
+
if (shown.sections) sealed = true;
|
|
252
|
+
const body = shown.text;
|
|
253
|
+
body.split("\n").forEach((line, i) => {
|
|
200
254
|
if (!line.toLowerCase().includes(needle)) return;
|
|
201
255
|
if (matches.length >= limit) { more += 1; return; }
|
|
202
256
|
matches.push({ path: rel(evda, abs), line: i + 1, text: line.length > 400 ? `${line.slice(0, 400)}...` : line });
|
|
203
257
|
});
|
|
204
258
|
}
|
|
205
|
-
return { query: args.query, searched: files.length, matches, more };
|
|
259
|
+
return { query: args.query, searched: files.length, matches, more, refused, heldBack: heldBack(refused, { sealed }) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 5a. One file of the record, whole, when the caller may read it; a refusal that says why when not.
|
|
263
|
+
export function readFile(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
264
|
+
onlyKeys("evda_read", args, ["path"]);
|
|
265
|
+
if (typeof args.path !== "string" || args.path.trim() === "") throw new EvdaError('evda_read needs "path", a file in the record.', { code: "BAD_ARGUMENT", exit: 2 });
|
|
266
|
+
const abs = resolve(evda.dir, args.path.trim());
|
|
267
|
+
if (!abs.startsWith(evda.dir + sep)) throw new EvdaError(`REFUSED: ${args.path} is outside the record.`, { code: "OUTSIDE", exit: 2 });
|
|
268
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) throw new EvdaError(`Nothing is at ${args.path} in the record.`, { code: "MISSING", exit: 2 });
|
|
269
|
+
const may = mayRead(readContext(evda), abs, caller);
|
|
270
|
+
if (!may.ok) throw new EvdaError(`REFUSED: ${may.why}`, { code: may.kind === "sealed" ? "SEALED" : may.kind === "noted" ? "NOTED" : "NOT_THIS_READER", exit: 2 });
|
|
271
|
+
const text = aiView(readFileSync(abs, "utf8")).text;
|
|
272
|
+
if (text.length > MAX_READ) return { path: rel(evda, abs), text: text.slice(0, MAX_READ), truncated: true };
|
|
273
|
+
return { path: rel(evda, abs), text, truncated: false };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 5b. "Just note this": the owner's words, verbatim, as a noted capture no AI reads. Refuses
|
|
277
|
+
// anything secret-shaped, like every other write.
|
|
278
|
+
export function noteTool(evda, args = {}, { now = new Date() } = {}) {
|
|
279
|
+
onlyKeys("evda_note", args, ["words"], "It writes one new noted capture and nothing else.");
|
|
280
|
+
const words = text(args.words, "words", "evda_note");
|
|
281
|
+
refuseSecrets(words, "evda_note");
|
|
282
|
+
return writeNote(evda, words, { now });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 5c. "You can read that now": clear the flag on one noted entry. Only on the owner's yes in this
|
|
286
|
+
// conversation, and only the flag: the words are not changed. The one tool that edits a file, and
|
|
287
|
+
// it edits one frontmatter line.
|
|
288
|
+
export function releaseTool(evda, args = {}, { now = new Date() } = {}) {
|
|
289
|
+
onlyKeys("evda_release_note", args, ["path", "confirmed"]);
|
|
290
|
+
// Sealed is not noted: no tool releases it, whatever was said. Unsealing is the owner's own edit.
|
|
291
|
+
if (typeof args.path === "string" && args.path.trim() !== "") {
|
|
292
|
+
const abs = resolve(evda.dir, args.path.trim());
|
|
293
|
+
if (existsSync(abs) && isSealed(abs)) throw new EvdaError(`REFUSED: ${args.path} is sealed: only its owner opens it, on their own devices, and no tool releases it. Unsealing is the owner editing the file themselves. Nothing was changed.`, { code: "SEALED", exit: 2 });
|
|
294
|
+
}
|
|
295
|
+
if (args.confirmed !== true) throw new EvdaError("REFUSED: evda_release_note needs confirmed: true, which means the owner said in this conversation that this entry can be read now. Ask them which one, and wait. Nothing was changed.", { code: "UNCONFIRMED", exit: 2 });
|
|
296
|
+
return releaseNote(evda, args.path, { now });
|
|
206
297
|
}
|
|
207
298
|
|
|
208
299
|
// 6. Circles and their policies, as the record declares them. Read only.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Update-check hygiene (HQ-1242), from the three gstack rules the plan doc names for this
|
|
2
|
+
// package (01_Projects/Evda/Evda/2026-09-15 - What EVDA Takes From gstack.md, section
|
|
3
|
+
// "5. Update-check hygiene"):
|
|
4
|
+
//
|
|
5
|
+
// 1. A crash must not read as up to date. Any network or parse failure returns
|
|
6
|
+
// status: "CHECK_FAILED" with the reason attached, never a silent "you're current."
|
|
7
|
+
// 2. The check is throttled and snoozable: at most once per THROTTLE_MS (one hour, per the
|
|
8
|
+
// plan doc's "throttled to once an hour," not gstack's escalating 24h/48h/7d scheme).
|
|
9
|
+
// 3. Nothing destructive runs from here. This module only reads the registry and this
|
|
10
|
+
// record's own state file; it never writes AGENTS.md, never runs a migration.
|
|
11
|
+
//
|
|
12
|
+
// `.evda/package-version` (not `.evda/version`, which is the unrelated SCHEMA_VERSION marker
|
|
13
|
+
// lib/manifest.mjs owns) records which npm version last replaced the AGENTS.md fence via
|
|
14
|
+
// `evda upgrade`. `.evda/update-check.json` records when this module last asked the registry,
|
|
15
|
+
// so re-running `evda check-update` inside the same hour is a local read, not a network call.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { join, dirname } from "node:path";
|
|
19
|
+
|
|
20
|
+
export const THROTTLE_MS = 60 * 60 * 1000; // one hour, per the plan doc
|
|
21
|
+
const REGISTRY_URL = "https://registry.npmjs.org/create-evda/latest";
|
|
22
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
23
|
+
|
|
24
|
+
function stateFile(dir) {
|
|
25
|
+
return join(dir, ".evda", "update-check.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function packageVersionFile(dir) {
|
|
29
|
+
return join(dir, ".evda", "package-version");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readJsonSafe(path) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// What this record's own AGENTS.md fence was last generated from. Absent means `evda upgrade`
|
|
41
|
+
// has never stamped it (an install predating this feature, or a record adopted by hand); that
|
|
42
|
+
// is reported as its own status rather than guessed at.
|
|
43
|
+
export function installedVersion(dir) {
|
|
44
|
+
try {
|
|
45
|
+
return readFileSync(packageVersionFile(dir), "utf8").trim();
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stampInstalledVersion(dir, version) {
|
|
52
|
+
const f = packageVersionFile(dir);
|
|
53
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
54
|
+
writeFileSync(f, version + "\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fetches the registry's `latest` dist-tag. Never throws: every failure mode (network, timeout,
|
|
58
|
+
// a non-200, a body with no `version` field) becomes a returned CHECK_FAILED result, because a
|
|
59
|
+
// thrown error from inside a session preamble is worse than a stale-looking check.
|
|
60
|
+
async function fetchLatest() {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(REGISTRY_URL, { signal: controller.signal });
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
return { ok: false, reason: `registry returned HTTP ${res.status}` };
|
|
67
|
+
}
|
|
68
|
+
const body = await res.json();
|
|
69
|
+
if (typeof body?.version !== "string" || !body.version) {
|
|
70
|
+
return { ok: false, reason: "registry response had no version field" };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, version: body.version };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { ok: false, reason: err?.name === "AbortError" ? `timed out after ${FETCH_TIMEOUT_MS}ms` : String(err?.message ?? err) };
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function higherVersion(a, b) {
|
|
81
|
+
const pa = a.split(".").map(Number);
|
|
82
|
+
const pb = b.split(".").map(Number);
|
|
83
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
84
|
+
const da = pa[i] ?? 0, db = pb[i] ?? 0;
|
|
85
|
+
if (da !== db) return da > db ? a : b;
|
|
86
|
+
}
|
|
87
|
+
return a;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The one entry point. `now` is injected so a caller (or a test) can control throttling without
|
|
91
|
+
// waiting an hour; callers outside a test pass Date.now().
|
|
92
|
+
//
|
|
93
|
+
// Returns one of:
|
|
94
|
+
// { status: "SKIPPED_THROTTLED", checkedAt: <ms of the last real check> }
|
|
95
|
+
// { status: "CHECK_FAILED", reason: "..." }
|
|
96
|
+
// { status: "UP_TO_DATE", installed, latest }
|
|
97
|
+
// { status: "UPDATE_AVAILABLE", installed, latest }
|
|
98
|
+
// { status: "UNSTAMPED", latest } -- this record predates package-version stamping
|
|
99
|
+
export async function checkForUpdate(dir, { now = Date.now(), force = false } = {}) {
|
|
100
|
+
const sf = stateFile(dir);
|
|
101
|
+
const state = readJsonSafe(sf);
|
|
102
|
+
|
|
103
|
+
if (!force && state?.lastCheckedAt && now - state.lastCheckedAt < THROTTLE_MS) {
|
|
104
|
+
return { status: "SKIPPED_THROTTLED", checkedAt: state.lastCheckedAt };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const fetched = await fetchLatest();
|
|
108
|
+
|
|
109
|
+
// Record the attempt either way: a failure that never updates lastCheckedAt would retry every
|
|
110
|
+
// single session until the registry recovers, defeating the throttle's whole purpose. The
|
|
111
|
+
// failure itself is still returned to the caller, never swallowed into a false pass.
|
|
112
|
+
mkdirSync(dirname(sf), { recursive: true });
|
|
113
|
+
writeFileSync(sf, JSON.stringify({ lastCheckedAt: now, lastResult: fetched.ok ? fetched.version : `CHECK_FAILED: ${fetched.reason}` }, null, 2) + "\n");
|
|
114
|
+
|
|
115
|
+
if (!fetched.ok) {
|
|
116
|
+
return { status: "CHECK_FAILED", reason: fetched.reason };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const installed = installedVersion(dir);
|
|
120
|
+
if (!installed) {
|
|
121
|
+
return { status: "UNSTAMPED", latest: fetched.version };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const newer = higherVersion(installed, fetched.version) === fetched.version && installed !== fetched.version;
|
|
125
|
+
return newer
|
|
126
|
+
? { status: "UPDATE_AVAILABLE", installed, latest: fetched.version }
|
|
127
|
+
: { status: "UP_TO_DATE", installed, latest: fetched.version };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One line, for the preamble and for `evda check-update`'s own stdout. Never throws.
|
|
131
|
+
export function summarize(result) {
|
|
132
|
+
switch (result.status) {
|
|
133
|
+
case "SKIPPED_THROTTLED":
|
|
134
|
+
return `update check: skipped, checked within the last hour (${new Date(result.checkedAt).toISOString()})`;
|
|
135
|
+
case "CHECK_FAILED":
|
|
136
|
+
return `update check: CHECK_FAILED, update status UNKNOWN (${result.reason})`;
|
|
137
|
+
case "UNSTAMPED":
|
|
138
|
+
return `update check: this record predates version stamping; latest is ${result.latest}. Run "evda upgrade" once to start tracking it.`;
|
|
139
|
+
case "UP_TO_DATE":
|
|
140
|
+
return `update check: up to date (${result.installed})`;
|
|
141
|
+
case "UPDATE_AVAILABLE":
|
|
142
|
+
return `update check: ${result.installed} -> ${result.latest} available. Run "npx -y -p create-evda@latest evda update" to take it.`;
|
|
143
|
+
default:
|
|
144
|
+
return `update check: CHECK_FAILED, update status UNKNOWN (unrecognized result shape)`;
|
|
145
|
+
}
|
|
146
|
+
}
|