create-evda 0.1.6 → 0.1.8
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 +23 -0
- package/README.md +5 -2
- package/bin/evda.mjs +98 -7
- package/create-evda.mjs +26 -11
- package/dist/pairing.mjs +4246 -0
- package/lib/circle-plan.mjs +24 -1
- package/lib/folders.mjs +1 -1
- package/lib/gather-apply.mjs +6 -1
- package/lib/gather.mjs +60 -10
- package/lib/mcp.mjs +81 -6
- package/lib/messages.mjs +205 -0
- package/lib/organize.mjs +124 -20
- package/lib/pairing.mjs +165 -0
- package/lib/parity.mjs +80 -7
- package/lib/roles.mjs +4 -0
- package/lib/routines.mjs +142 -18
- package/lib/setup.mjs +47 -4
- package/lib/sync-rules.mjs +10 -4
- package/lib/tools.mjs +15 -0
- package/package.json +6 -3
- package/skills/evda/SKILL.md +10 -10
- package/templates/AGENTS.md +4 -0
- package/templates/evda-digest.md +1 -0
package/lib/circle-plan.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { circleManifest, readCircles, writeCircle, opaqueIdOf, circlesDir, isRel
|
|
|
39
39
|
import { grant } from "./grants.mjs";
|
|
40
40
|
import { openEvda, readManifest, writeManifest } from "./manifest.mjs";
|
|
41
41
|
import { EvdaError } from "./errors.mjs";
|
|
42
|
+
import { defaultPartsPlan } from "./setup.mjs";
|
|
42
43
|
|
|
43
44
|
export const PLAN_FILE = "plan.json";
|
|
44
45
|
|
|
@@ -221,7 +222,8 @@ export function declare(dir, plan, { circlesRel = "06_Metadata/Circles", grantsR
|
|
|
221
222
|
mkdirSync(join(dir, manifest.mapping.grants), { recursive: true });
|
|
222
223
|
const evda = openEvda(dir);
|
|
223
224
|
const existing = new Map((readCircles(evda) ?? []).filter((c) => c.manifest !== null).map((c) => [c.name, c.manifest]));
|
|
224
|
-
|
|
225
|
+
// An owner with no name yet (create-evda run with no --owner) is named by the record id.
|
|
226
|
+
const owner = holder || manifest.owner?.name || manifest.id;
|
|
225
227
|
|
|
226
228
|
const written = [];
|
|
227
229
|
for (const c of planned.circles) {
|
|
@@ -232,6 +234,8 @@ export function declare(dir, plan, { circlesRel = "06_Metadata/Circles", grantsR
|
|
|
232
234
|
isPublic: c.public,
|
|
233
235
|
holders: [owner],
|
|
234
236
|
members: numbered(c.members.map((m) => ({ path: m.path }))),
|
|
237
|
+
// A plan circle may start the circle's rules (Spec 07, section 6); the default parts start empty.
|
|
238
|
+
policy: plan.circles.find((p) => p.name === c.name)?.policy ?? null,
|
|
235
239
|
description: c.public ? "Public: anyone may read it." : `Private: read by the devices granted it, and no one else.`,
|
|
236
240
|
});
|
|
237
241
|
written.push(writeCircle(evda, c.name, m, { overwrite: true }));
|
|
@@ -275,3 +279,22 @@ export function deviceCircles(plan, device, view = null) {
|
|
|
275
279
|
: [];
|
|
276
280
|
return { atlasId: d.atlasId, holds, never: plan.circles.map((c) => c.name).filter((n) => !holds.includes(n)), drift };
|
|
277
281
|
}
|
|
282
|
+
|
|
283
|
+
// The default parts (HQ-1925): journal and private, from setup.mjs `defaultPartsPlan`, declared by
|
|
284
|
+
// create-evda on a new record and, on the owner's yes, over an adopted record that has none. A
|
|
285
|
+
// record that already has a private part is left exactly as it is, so a yes can never replace
|
|
286
|
+
// parts the owner declared. `dryRun` answers whether the default is needed and writes nothing.
|
|
287
|
+
// Returns { needed, declared, parts }, parts being the private part names it has or would have.
|
|
288
|
+
export function defaultParts(dir, { dryRun = false, circlesRel = "circles", grantsRel = "grants", at = new Date() } = {}) {
|
|
289
|
+
const evda = openEvda(dir);
|
|
290
|
+
const have = (readCircles(evda) ?? []).filter((c) => c.manifest && c.manifest.public !== true && !c.mount).map((c) => c.name).sort();
|
|
291
|
+
if (have.length) return { needed: false, declared: false, parts: have };
|
|
292
|
+
const m = evda.manifest;
|
|
293
|
+
const slug = String(m.name ?? "record").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "record";
|
|
294
|
+
const recordId = typeof m.id === "string" && m.id ? m.id : `${slug}.atlas`;
|
|
295
|
+
const plan = defaultPartsPlan(m.mapping ?? {}, recordId);
|
|
296
|
+
const parts = plan.circles.map((c) => c.name).sort();
|
|
297
|
+
if (dryRun) return { needed: true, declared: false, parts };
|
|
298
|
+
declare(dir, plan, { circlesRel, grantsRel, recordId, at });
|
|
299
|
+
return { needed: false, declared: true, parts };
|
|
300
|
+
}
|
package/lib/folders.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { SCHEMES } from "./references.mjs";
|
|
|
27
27
|
import { EvdaError } from "./errors.mjs";
|
|
28
28
|
|
|
29
29
|
export const FOLDER_MANIFEST = "folder.evda.json";
|
|
30
|
-
export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis"];
|
|
30
|
+
export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis", "message"];
|
|
31
31
|
export const FOLDER_KEYS = ["type", "connectors", "views"];
|
|
32
32
|
export const VIEW_KEYS_REQUIRED = ["view", "of", "in", "path"];
|
|
33
33
|
export const VIEW_KEYS = [...VIEW_KEYS_REQUIRED, "status", "group"];
|
package/lib/gather-apply.mjs
CHANGED
|
@@ -287,9 +287,14 @@ function plan(evda, p, { mode }) {
|
|
|
287
287
|
if (md && pl.refs?.length) add.references = referencesYaml(pl.refs.map((ref) => ({ ref, label: refLabel(ref, i.heading ?? basename(to, ".md")) })));
|
|
288
288
|
if (md && statusFor.has(pl.item)) add.status = `status: ${statusFor.get(pl.item)}`;
|
|
289
289
|
if (md && pl.circle && falsified("apply-writes-share")) add.share = `share: [${pl.circle}]`;
|
|
290
|
+
// A credential kept in the Credentials folder is sealed (HQ-1368): evda_search, evda_recent and
|
|
291
|
+
// evda_read refuse it to every caller without naming it, and no tool releases it.
|
|
292
|
+
if (md && pl.sealed && !i.sealed) add.sealed = `${SEALED}: true`;
|
|
290
293
|
// The item's own bytes, frontmatter included, so `noted: true` and `sealed: true` ride into the
|
|
291
294
|
// record with it (HQ-1414). EVDA_CARRY_FALSIFY=gather-apply:<flag> drops that line on the way in.
|
|
292
295
|
let bytes = withFrontmatter(buf, add);
|
|
296
|
+
// A note that already said `sealed: false` (or anything but true) is sealed all the same.
|
|
297
|
+
if (add.sealed && !/^sealed:[ \t]*true[ \t]*$/m.test(bytes.toString("utf8").split(/\n---/)[0])) bytes = Buffer.from(bytes.toString("utf8").replace(/^sealed:.*$/m, `${SEALED}: true`), "utf8");
|
|
293
298
|
for (const flag of [NOTED, SEALED]) if (md && !carries("gather-apply", flag)) bytes = Buffer.from(bytes.toString("utf8").replace(new RegExp(`^${flag}:[ \\t]*true[ \\t]*\\n`, "m"), ""), "utf8");
|
|
294
299
|
const st = statSync(src);
|
|
295
300
|
out.writes.push({
|
|
@@ -424,7 +429,7 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
424
429
|
const covers = f ? f.path + c.covers.slice(f.proposal.path.length) : c.covers;
|
|
425
430
|
if (!pl.writes.some((w) => w.to.startsWith(`${covers}/`))) continue;
|
|
426
431
|
const before = tree(evda.dir);
|
|
427
|
-
const r = proposeCircle(evda, { name: c.name, who: "Only the people the owner grants it to, by name.", what: `${covers}/`, why: `The owner said yes to its own circle for these notes while gathering a pile (proposal ${p.id}).` }, { now });
|
|
432
|
+
const r = proposeCircle(evda, { name: c.name, who: c.readBy === "none" ? "Only the owner, on their own devices. No AI reads it (read_by: none), and no agent host holds it." : "Only the people the owner grants it to, by name.", what: `${covers}/`, why: `The owner said yes to its own circle for these notes while gathering a pile (proposal ${p.id}).` }, { now });
|
|
428
433
|
const after = tree(evda.dir);
|
|
429
434
|
for (const d of after.dirs.filter((x) => !before.dirs.includes(x))) t.dirs.push(d);
|
|
430
435
|
t.created.push({ path: r.path, sha256: sha256(readFileSync(join(evda.dir, r.path))), size: statSync(join(evda.dir, r.path)).size });
|
package/lib/gather.mjs
CHANGED
|
@@ -59,13 +59,41 @@ export const GATHER_VERSION = 1;
|
|
|
59
59
|
// The person-layer kinds an item is typed with, and the folder type each lives under.
|
|
60
60
|
export const GATHER_KINDS = ["note", "person", "project", "event", "log", "source", "piece", "blob", "capture"];
|
|
61
61
|
export const FOLDER_TYPE_FOR = { note: "record", person: "person", project: "project", event: "event", log: "log", source: "source", piece: "piece", blob: "blob", capture: "capture" };
|
|
62
|
-
export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "
|
|
62
|
+
export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "credentials-ordinary", "examine-nothing", "no-role"];
|
|
63
63
|
// The role each kind fills when the preset has no folder for it (Spec 07 section 2, HQ-1371):
|
|
64
64
|
// what happened, dated, is journal; one folder per thing is collections; what is known is knowledge.
|
|
65
65
|
export const ROLE_FOR_KIND = { note: "journal", capture: "journal", log: "journal", person: "collections", project: "collections", event: "collections", piece: "collections", blob: "collections", course: "collections", source: "knowledge", areas: "collections" };
|
|
66
66
|
// A person is asked about once this many files mention them and no page for them exists.
|
|
67
67
|
export const PEOPLE_MIN_FILES = 3;
|
|
68
68
|
|
|
69
|
+
// A credential written as a labeled line in a note (HQ-1368): "password: ...", "pw: ...", "PIN: ...",
|
|
70
|
+
// "wifi password: ...", "api key = ...", "token: ...", "secret: ...", recovery or backup codes, a seed
|
|
71
|
+
// phrase. A label at the start of the line, a separator, and a value after it; prose that only
|
|
72
|
+
// mentions a password ("I forgot my password again") has no value after a separator and is not one.
|
|
73
|
+
// A short value for a password-like label (a sentence after "Password:" is prose, not a password);
|
|
74
|
+
// a code or phrase label may carry many words, or its value on the next line.
|
|
75
|
+
const CRED_QUALIFIER = String.raw`(?:(?:wi-?fi|home|network|router|guest|email|e-mail|bank|account|admin|root|master|login|app|door|alarm|phone|sim|card|atm|debit|garage)\s+)*`;
|
|
76
|
+
const CRED_SHORT = String.raw`pass(?:word|wd|code|phrase)?|pwd|pw|pin(?:\s*code)?|api[\s_-]*key|access[\s_-]*key|secret(?:[\s_-]*key)?|(?:auth[\s_-]*|access[\s_-]*|api[\s_-]*)?token|private[\s_-]*key|login`;
|
|
77
|
+
const CRED_LONG = String.raw`(?:recovery|backup|2fa|mfa|one[\s-]*time)[\s_-]*(?:codes?|keys?|phrase)|seed[\s_-]*(?:phrase|words)|mnemonic(?:[\s_-]*phrase)?|secret[\s_-]*phrase`;
|
|
78
|
+
const CRED_LINE = new RegExp(String.raw`^\s*(?:[-*+•>]\s+|\d+[.)]\s+)?[*_\x60]*${CRED_QUALIFIER}(?:(${CRED_LONG})|(${CRED_SHORT}))[*_\x60]*\s*(?:[:=]|\s[-\u2013]\s)[*_\x60]*\s*(.*)$`, "i");
|
|
79
|
+
export function credentialLine(lines) {
|
|
80
|
+
const all = Array.isArray(lines) ? lines : String(lines ?? "").split("\n");
|
|
81
|
+
for (let n = 0; n < all.length; n++) {
|
|
82
|
+
const m = CRED_LINE.exec(all[n]);
|
|
83
|
+
if (!m) continue;
|
|
84
|
+
const label = (m[1] ?? m[2]).toLowerCase().replace(/[\s_-]+/g, " ");
|
|
85
|
+
const value = m[3].replace(/[*_\x60]+$/g, "").trim();
|
|
86
|
+
if (m[1]) {
|
|
87
|
+
if (value || (all[n + 1] ?? "").trim()) return label;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!value) continue;
|
|
91
|
+
if (value.split(/\s+/).length > 3) continue;
|
|
92
|
+
return label;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
70
98
|
const posix = (p) => p.split(sep).join("/");
|
|
71
99
|
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
@@ -75,6 +103,7 @@ const APP_DIRS = { ".obsidian": "obsidian" };
|
|
|
75
103
|
const ARCHIVE_EXT = new Set([".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"]);
|
|
76
104
|
const BINARY_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".heic", ".webp", ".pdf", ".mov", ".mp4", ".m4a", ".mp3", ".wav", ".xlsx", ".xls", ".docx", ".doc", ".pptx", ".key", ".pages", ".numbers", ".sqlite", ".db"]);
|
|
77
105
|
const TEXT_MAX = 2 * 1024 * 1024;
|
|
106
|
+
const NOTE_SIZED = 20000; // characters: longer text is read for therapy words only in its name and heading
|
|
78
107
|
// Folder names that hold things without saying what they are about, so they are not a subject.
|
|
79
108
|
const CONTAINERS = new Set(["documents", "desktop", "downloads", "notes", "daily", "inbox", "captures", "vault", "obsidian vault", "misc", "stuff", "untitled folder", "new folder", "export", "icloud drive"]);
|
|
80
109
|
const COURSE = /\b[A-Z]{2,4}\s?\d{3}[A-Z]?\b/;
|
|
@@ -256,8 +285,11 @@ function typeItem({ rel, buf, ext, projectRoot }) {
|
|
|
256
285
|
if (ARCHIVE_EXT.has(ext)) return { ...item, kind: "blob", archive: true, why: ["an archive file"] };
|
|
257
286
|
if (BINARY_EXT.has(ext) || isBinary(buf) || buf.length > TEXT_MAX) return { ...item, kind: "blob", why: [BINARY_EXT.has(ext) ? `a ${ext.slice(1)} file` : "binary bytes"] };
|
|
258
287
|
const raw = buf.toString("utf8");
|
|
259
|
-
|
|
260
|
-
|
|
288
|
+
// A file holding a secret or a credential line is never refused (his call, 2026-09-27: "just put
|
|
289
|
+
// it in a safer place"): it is typed from its path alone, and placed sealed in a Credentials
|
|
290
|
+
// folder that no AI reads or is told of, unless the owner says no. Nothing of it is quoted.
|
|
291
|
+
const secret = raw.split("\n").map(secretShape).find(Boolean) ?? (credentialLine(raw) ? `a ${credentialLine(raw)} line` : null);
|
|
292
|
+
if (secret) return { ...item, kind: "note", credential: `holds ${secret}`, why: [`holds ${secret}`], ...(DAY.test(name) ? { daily: true, dated: name } : {}) };
|
|
261
293
|
if (ext === ".vcf") {
|
|
262
294
|
const cards = vcards(raw);
|
|
263
295
|
return { ...item, kind: "person", mode: "reference", why: [`a Contacts export of ${cards.length} card(s)`], cards: cards.map((c) => ({ name: c.name, ref: `contacts:${c.uid ?? slugId(c.name)}`, ...(falsified("copy-contacts") ? { payload: c.payload } : {}) })) };
|
|
@@ -277,7 +309,10 @@ function typeItem({ rel, buf, ext, projectRoot }) {
|
|
|
277
309
|
const heading = headingOf(body);
|
|
278
310
|
const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
279
311
|
const facts = { heading, dated: LEADING_DAY.exec(name)?.[1] ?? (DAY.test(fm.date ?? "") ? fm.date : null), daily: DAY.test(name) };
|
|
280
|
-
|
|
312
|
+
// A therapy note says so in its name or heading, or in the words of something note-sized. One mention deep in a book or a
|
|
313
|
+
// long export (a Gutenberg Meditations says "counsellor" once, 5,800 lines in) does not make it a session note.
|
|
314
|
+
const said = THERAPY.exec(basename(rel)) ?? THERAPY.exec(heading ?? "") ?? (body.length <= NOTE_SIZED ? THERAPY.exec(body) : null);
|
|
315
|
+
if (said) facts.sensitive = { kind: "therapy", word: said[1] };
|
|
281
316
|
const att = ATTENDEES.exec(body);
|
|
282
317
|
if (att) facts.attendees = att[1].split(/,|\band\b/).map((s) => s.trim()).filter((s) => /^[A-Z][\p{L}'-]+(?: [A-Z][\p{L}'-]+)+$/u.test(s));
|
|
283
318
|
const declared = (fm.kind ?? fm.type ?? "").toLowerCase();
|
|
@@ -333,7 +368,7 @@ export function scan(sources, { now = new Date() } = {}) {
|
|
|
333
368
|
const item = { id: `s${si}:${f.rel}`, source: `s${si}`, path: f.rel, size: f.size, sha256: sha256(buf), ...facts };
|
|
334
369
|
if (projectRoot) item.project = projectRoot;
|
|
335
370
|
item.subject = subjectOf(f.rel, projectRoot);
|
|
336
|
-
if (!item.refused && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
|
|
371
|
+
if (!item.refused && !item.credential && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
|
|
337
372
|
out.items.push(item);
|
|
338
373
|
}
|
|
339
374
|
});
|
|
@@ -415,6 +450,9 @@ export function choosePreset(items, projects) {
|
|
|
415
450
|
|
|
416
451
|
// Where a kind lives under a preset: the preset's own folder when it has one, else a folder the
|
|
417
452
|
// pile needs and the preset does not have, named here and marked as such.
|
|
453
|
+
// The folder credentials are kept in: every file sealed, so no AI reads it, no tool releases it, and
|
|
454
|
+
// no AI is told it is there (HQ-1368, his call 2026-09-27).
|
|
455
|
+
export const CREDENTIALS = "Credentials";
|
|
418
456
|
const NEEDED = { note: "Notes", capture: "Captures", person: "People", project: "Projects", event: "Events", log: "Logs", source: "Sources", piece: "Writing", blob: "Attachments", areas: "Notes" };
|
|
419
457
|
|
|
420
458
|
// The role a path under the preset fills: the longest mapping path at or above it.
|
|
@@ -462,21 +500,30 @@ export function place(p) {
|
|
|
462
500
|
return NEEDED[key];
|
|
463
501
|
};
|
|
464
502
|
const therapyOwn = ans["circle:therapy"] === "yes";
|
|
503
|
+
// Credentials go to their own folder unless the owner says no; yes is the default.
|
|
504
|
+
const credentialsOwn = ans.credentials !== "no" && !falsified("credentials-ordinary");
|
|
465
505
|
const placements = [];
|
|
466
506
|
const taken = new Map();
|
|
467
507
|
// The role a destination fills: the preset's mapping, else the needed folder's own role.
|
|
468
508
|
const roleOf = (to) => presetRole(preset, to) ?? (falsified("no-role") ? null : undefined) ?? [...needed.values()].find((n) => to === n.path || to.startsWith(`${n.path}/`))?.role ?? null;
|
|
509
|
+
// Names are claimed without case: on a Mac "wifi.md" and "Wifi.md" are one file.
|
|
469
510
|
const claim = (to, id) => {
|
|
470
511
|
let t = to;
|
|
471
|
-
for (let n = 2; taken.has(t) && taken.get(t) !== id; n++) t = to.replace(/(\.[^./]+)?$/, (e) => ` (${n})${e ?? ""}`);
|
|
472
|
-
taken.set(t, id);
|
|
512
|
+
for (let n = 2; taken.has(t.toLowerCase()) && taken.get(t.toLowerCase()) !== id; n++) t = to.replace(/(\.[^./]+)?$/, (e) => ` (${n})${e ?? ""}`);
|
|
513
|
+
taken.set(t.toLowerCase(), id);
|
|
473
514
|
return t;
|
|
474
515
|
};
|
|
475
516
|
for (const i of p.items) {
|
|
476
517
|
const file = asMarkdown(i, basename(i.path));
|
|
477
518
|
let pl;
|
|
478
519
|
if (i.refused) pl = { status: "refused", to: null, why: `${i.refused}; left where it is and quoted nowhere. Keep the secret in a password manager.` };
|
|
479
|
-
else if (i.
|
|
520
|
+
else if (i.credential && credentialsOwn && /\.md$/i.test(file)) {
|
|
521
|
+
pl = { status: "placed", to: `${CREDENTIALS}/${file}`, sealed: true, why: `${i.credential}; kept in ${CREDENTIALS}/, sealed, so only the owner opens it on their own devices, no AI reads it and no tool releases it` };
|
|
522
|
+
needed.set(CREDENTIALS, { path: CREDENTIALS, type: "record", role: "collections" });
|
|
523
|
+
} else if (i.credential && credentialsOwn) {
|
|
524
|
+
// Only markdown carries `sealed: true`, so a credential in any other format is never copied.
|
|
525
|
+
pl = { status: "refused", to: null, why: `${i.credential} and is not a note that can be sealed; left where it is and quoted nowhere. Keep the secret in a password manager.` };
|
|
526
|
+
} else if (i.present) pl = { status: "present", to: null, at: i.present, why: `already in the record at ${i.present}; left where it is` };
|
|
480
527
|
else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file; unpack it and gather the folder" };
|
|
481
528
|
else if (i.cards) pl = { status: "reference", to: null, why: "held by Contacts; a person page keeps a contacts: reference, never the card" };
|
|
482
529
|
else if (i.kind === "project") {
|
|
@@ -511,6 +558,7 @@ export function place(p) {
|
|
|
511
558
|
placements.push({ item: `new:person:${who.name}`, status: "create", to, role: roleOf(to), refs: who.ref ? [who.ref] : [], mentions: who.files.length });
|
|
512
559
|
}
|
|
513
560
|
const circles = therapyOwn ? [{ name: "therapy", covers: h.areas ? `${h.areas}/Therapy` : "Therapy", status: "proposed only: a proposal file at apply, never a circle" }] : [];
|
|
561
|
+
if (credentialsOwn && placements.some((x) => x.sealed)) circles.push({ name: "credentials", covers: CREDENTIALS, readBy: "none", status: "proposed only: a proposal file at apply, never a circle; every file in it is sealed already" });
|
|
514
562
|
const used = new Map();
|
|
515
563
|
for (const pl of placements) {
|
|
516
564
|
if (!pl.to) continue;
|
|
@@ -542,6 +590,8 @@ function candidates(p) {
|
|
|
542
590
|
if (!proj.finished && !falsified("ask-everything")) continue;
|
|
543
591
|
out.push({ id: `finished:${proj.id}`, kind: "finished", options: ["finished", "live"], text: `${proj.name} looks finished (${basename(proj.files.find((f) => f === proj.finished?.file) ?? proj.root)} says "${proj.finished?.word ?? "nothing"}"). Is it finished, or live? It stays where it is; finished is its status.`, about: proj.name });
|
|
544
592
|
}
|
|
593
|
+
const creds = p.items.filter((i) => i.credential && !i.present);
|
|
594
|
+
if (creds.length) out.push({ id: "credentials", kind: "credentials", options: ["yes", "no"], default: "yes", text: `${creds.length} note(s) look like they hold passwords or keys: keep them sealed in a Credentials folder, so only you open them on your own devices and no AI reads them?`, about: "credentials" });
|
|
545
595
|
const therapy = p.items.filter((i) => i.sensitive?.kind === "therapy");
|
|
546
596
|
if (therapy.length) out.push({ id: "circle:therapy", kind: "circle", options: ["yes", "no"], text: `${therapy.length} file(s) read like therapy notes. Give them their own circle, so only who you choose reads them?`, about: "therapy" });
|
|
547
597
|
return out;
|
|
@@ -651,7 +701,7 @@ export function answer(p, questionId, value, { now = new Date() } = {}) {
|
|
|
651
701
|
|
|
652
702
|
// Items it could not place, or typed only by elimination, handed to the owner's claude -p.
|
|
653
703
|
export function withOwnersAi(p, { model = null, env = process.env } = {}) {
|
|
654
|
-
const unsure = p.items.filter((i) => !i.refused && !i.sealed && !i.noted && !i.present && (i.confidence === "low" || p.placements.find((x) => x.item === i.id)?.status === "unplaced") && !i.archive);
|
|
704
|
+
const unsure = p.items.filter((i) => !i.refused && !i.credential && !i.sealed && !i.noted && !i.present && (i.confidence === "low" || p.placements.find((x) => x.item === i.id)?.status === "unplaced") && !i.archive);
|
|
655
705
|
if (!unsure.length) { p.ai = { status: "SKIPPED", why: "nothing was unsure" }; return { ok: true, p }; }
|
|
656
706
|
const kinds = GATHER_KINDS.join(", ");
|
|
657
707
|
const lines = unsure.map((i) => `- ${i.id} (typed ${i.kind}${i.heading ? `, titled "${i.heading}"` : ""})`);
|
|
@@ -750,7 +800,7 @@ export function summaryText(p, { detail = false } = {}) {
|
|
|
750
800
|
if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}`); }
|
|
751
801
|
const open = p.questions.filter((q) => q.answer === undefined);
|
|
752
802
|
lines.push("", open.length ? `${open.length} question(s) for the owner:` : "No open questions.");
|
|
753
|
-
for (const q of open) lines.push(`- [${q.id}] ${q.text} (${q.options.join(" / ")})`);
|
|
803
|
+
for (const q of open) lines.push(`- [${q.id}] ${q.text} (${q.options.join(" / ")}${q.default ? `, default ${q.default}` : ""})`);
|
|
754
804
|
return lines.join("\n");
|
|
755
805
|
}
|
|
756
806
|
|
package/lib/mcp.mjs
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
// stdout carries protocol messages only. Nothing in this file or the tools it calls prints to
|
|
10
10
|
// stdout; doctor's report is captured into the tool result.
|
|
11
11
|
//
|
|
12
|
-
// Every tool here reads and writes files on this machine, with
|
|
13
|
-
// asks the npm registry for the tooling's latest version number
|
|
14
|
-
//
|
|
12
|
+
// Every tool here reads and writes files on this machine, with two exceptions. evda_check_update
|
|
13
|
+
// asks the npm registry for the tooling's latest version number; it carries nothing from the record
|
|
14
|
+
// and installs nothing. The device tools (evda_pair_device, evda_sync_devices, HQ-1217) talk to the
|
|
15
|
+
// relay that carries the record between the owner's own devices, and what they send is sealed on
|
|
16
|
+
// this machine: the relay cannot read it.
|
|
15
17
|
|
|
16
18
|
import { createInterface } from "node:readline";
|
|
17
19
|
import { SCHEMA_VERSION } from "./manifest.mjs";
|
|
@@ -28,6 +30,7 @@ import { createSubrecord } from "./subrecord.mjs";
|
|
|
28
30
|
import { FALSIFY } from "./mcp-auth.mjs";
|
|
29
31
|
import { filingOf, FILING_MODES } from "./day-file.mjs";
|
|
30
32
|
import { readManifest, writeManifest } from "./manifest.mjs";
|
|
33
|
+
import * as pairing from "./pairing.mjs";
|
|
31
34
|
|
|
32
35
|
// Newest first. The server answers with the client's version when it supports it, and with the
|
|
33
36
|
// newest otherwise; the client decides whether to continue.
|
|
@@ -54,6 +57,7 @@ export function instructions(filing) {
|
|
|
54
57
|
"Never quiz them. Never create a circle or grant; propose one with evda_propose_circle only when they ask to share, and wait for their yes.",
|
|
55
58
|
"Organization comes from the preset library (evda_presets): propose with evda_propose_structure, and apply with evda_apply_preset only after their yes.",
|
|
56
59
|
"When they point you at a pile they already have (a folder, an export), call evda_gather, then ask the questions from evda_gather_questions one at a time in your own words, and record each answer with evda_gather_answer. When every question is answered and the owner says yes to the summary, apply it with evda_gather_apply and confirmed: true; tell them the reversal manifest it names.",
|
|
60
|
+
DISPATCH_LINE,
|
|
57
61
|
PRIVACY_INSTRUCTIONS,
|
|
58
62
|
];
|
|
59
63
|
return lines.join(" ");
|
|
@@ -88,6 +92,12 @@ export const PRIVACY_SECTION = [
|
|
|
88
92
|
"- To keep a conversation out of training, the option is the provider's incognito or temporary chat. Claude's incognito chats are never used for training; ChatGPT keeps a Temporary Chat up to 30 days; others may offer the same.",
|
|
89
93
|
].join("\n");
|
|
90
94
|
|
|
95
|
+
// Dispatch (HQ-1246), one line on every surface an agent reads first: the server instructions and
|
|
96
|
+
// the operating layer. The vault's AGENTS-template.md is the core's source; until it carries this
|
|
97
|
+
// line, this is where it lives, and evda_operating_layer serves it above AGENTS.md.
|
|
98
|
+
export const DISPATCH_LINE = "Call evda_replies at the start of a run: it returns the owner's replies to your messages that you have not seen yet. When the owner should hear something from you (what you did, a question), send it with evda_message, which shows it on their phone as a card; a thing only filed waits until they go looking.";
|
|
99
|
+
export const DISPATCH_SECTION = `## Messages to the owner\n\n${DISPATCH_LINE}`;
|
|
100
|
+
|
|
91
101
|
const str = (description) => ({ type: "string", description });
|
|
92
102
|
|
|
93
103
|
// summarize() already names the command for the two statuses that ask the owner to run one. Over
|
|
@@ -165,7 +175,7 @@ export const TOOLS = [
|
|
|
165
175
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
166
176
|
run: (evda, args) => {
|
|
167
177
|
const r = tools.operatingLayer(evda, args);
|
|
168
|
-
return { text: `${chatSection(evda, r.text)}\n\n${PRIVACY_SECTION}\n\n${r.text}`, data: { path: r.path } };
|
|
178
|
+
return { text: `${chatSection(evda, r.text)}\n\n${DISPATCH_SECTION}\n\n${PRIVACY_SECTION}\n\n${r.text}`, data: { path: r.path } };
|
|
169
179
|
},
|
|
170
180
|
},
|
|
171
181
|
{
|
|
@@ -245,6 +255,30 @@ export const TOOLS = [
|
|
|
245
255
|
return { text: `Noted at ${r.path}. No AI reads it until the owner says it can.`, data: r };
|
|
246
256
|
},
|
|
247
257
|
},
|
|
258
|
+
{
|
|
259
|
+
name: "evda_message",
|
|
260
|
+
title: "Tell the owner something",
|
|
261
|
+
description: "Sends the owner a message: writes it as one new file in the record's messages folder, which their phone app shows as a card, and returns its id and path. Use it for what they should hear from you (what you did, a question for them); a reply comes back through evda_replies. Never edits a message once written. Refuses anything shaped like a password, key or token.",
|
|
262
|
+
inputSchema: { type: "object", properties: { title: str("One line: what the card says at a glance."), body: str("The message, in markdown."), from: str("The agent's name. Default: the name in the operating layer.") }, required: ["title", "body"], additionalProperties: false },
|
|
263
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
264
|
+
run: (evda, args) => {
|
|
265
|
+
const r = tools.message(evda, args);
|
|
266
|
+
return { text: `Sent the message ${r.id}, filed at ${r.path}. The owner's reply, when there is one, comes back through evda_replies.`, data: r };
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: "evda_replies",
|
|
271
|
+
title: "Read the owner's replies",
|
|
272
|
+
description: "Call at the start of a run: returns the owner's replies to your messages that you have not seen yet (message id, title, reply, date) and marks them seen, so each shows once; a reply the owner changes shows again. peek: true returns them without marking.",
|
|
273
|
+
inputSchema: { type: "object", properties: { peek: { type: "boolean", description: "true to read without marking them seen." } }, additionalProperties: false },
|
|
274
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
275
|
+
run: (evda, args, ctx) => {
|
|
276
|
+
const r = tools.replies(evda, args, { caller: ctx.caller });
|
|
277
|
+
const body = r.replies.map((x) => `<reply to="${x.id}" title=${JSON.stringify(x.title)} date="${x.date}">\n${x.reply}\n</reply>`).join("\n\n");
|
|
278
|
+
const main = r.replies.length ? `${r.replies.length} new repl${r.replies.length === 1 ? "y" : "ies"} from the owner${args.peek ? " (peek: not marked seen)" : ""}.\n\n${body}` : "No new replies from the owner.";
|
|
279
|
+
return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: { replies: r.replies, marked: r.marked } };
|
|
280
|
+
},
|
|
281
|
+
},
|
|
248
282
|
{
|
|
249
283
|
name: "evda_filing",
|
|
250
284
|
title: "Record how filing works",
|
|
@@ -496,10 +530,51 @@ export const TOOLS = [
|
|
|
496
530
|
return { text: `${r.text}\n\nexit ${r.exit}`, data: { exit: r.exit }, isError: r.exit !== 0 };
|
|
497
531
|
},
|
|
498
532
|
},
|
|
533
|
+
{
|
|
534
|
+
name: "evda_pair_device",
|
|
535
|
+
title: "Pair a phone",
|
|
536
|
+
description: "Call only when the owner asks to pair, add or connect a phone or another device to their EVÐA. Returns a one-time pairing code and a QR link for the phone, and the steps to do on it. The code works once, for 10 minutes, and the device gets only the parts named (journal by default). Show the code to the owner and no one else.",
|
|
537
|
+
inputSchema: {
|
|
538
|
+
type: "object",
|
|
539
|
+
properties: {
|
|
540
|
+
name: { type: "string", description: "What to call the device, such as phone. Default phone." },
|
|
541
|
+
parts: { type: "array", items: { type: "string" }, description: "The private parts of the record the device may read and add to. Default [\"journal\"]." },
|
|
542
|
+
},
|
|
543
|
+
additionalProperties: false,
|
|
544
|
+
},
|
|
545
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
546
|
+
run: async (evda, args) => {
|
|
547
|
+
tools.onlyKeys("evda_pair_device", args, ["name", "parts"]);
|
|
548
|
+
const r = await pairing.pair(evda.dir, { name: args.name, parts: args.parts });
|
|
549
|
+
return { text: pairing.pairText(r) };
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
name: "evda_sync_devices",
|
|
554
|
+
title: "Sync with paired devices",
|
|
555
|
+
description: "Call after the owner used a pairing code on their phone, or when they ask to sync their devices: adds a device that used its code, takes in what paired devices wrote, and sends what changed here. Answers in plain words.",
|
|
556
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
557
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
558
|
+
run: async (evda, args) => {
|
|
559
|
+
tools.onlyKeys("evda_sync_devices", args, []);
|
|
560
|
+
return { text: (await pairing.sync(evda.dir)).join("\n") };
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
name: "evda_devices",
|
|
565
|
+
title: "Paired devices",
|
|
566
|
+
description: "Lists the devices paired with the owner's EVÐA, whether each is waiting, paired or removed, and which parts each reads. Removing one is a command the owner runs: evda remove <device>.",
|
|
567
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
568
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
569
|
+
run: async (evda, args) => {
|
|
570
|
+
tools.onlyKeys("evda_devices", args, []);
|
|
571
|
+
return { text: (await pairing.devices(evda.dir)).join("\n") };
|
|
572
|
+
},
|
|
573
|
+
},
|
|
499
574
|
{
|
|
500
575
|
name: "evda_check_update",
|
|
501
576
|
title: "Check for newer EVDA tooling",
|
|
502
|
-
description: "Asks the npm registry whether a newer version of the EVDA tooling exists, and returns the one line that says so. At most one real check an hour; inside that hour it reports the cached result. A check that fails reports the status as UNKNOWN, never as up to date.
|
|
577
|
+
description: "Asks the npm registry whether a newer version of the EVDA tooling exists, and returns the one line that says so. At most one real check an hour; inside that hour it reports the cached result. A check that fails reports the status as UNKNOWN, never as up to date. It sends nothing from the record, and it changes nothing: taking the update is a command the owner runs in their own terminal.",
|
|
503
578
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
504
579
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
505
580
|
run: async (evda) => {
|
|
@@ -511,7 +586,7 @@ export const TOOLS = [
|
|
|
511
586
|
];
|
|
512
587
|
|
|
513
588
|
export const RESOURCES = [
|
|
514
|
-
{ uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => { const t = tools.operatingLayer(evda).text; return `${chatSection(evda, t)}\n\n${t}`; } },
|
|
589
|
+
{ uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => { const t = tools.operatingLayer(evda).text; return `${chatSection(evda, t)}\n\n${DISPATCH_SECTION}\n\n${t}`; } },
|
|
515
590
|
{
|
|
516
591
|
uri: "evda://today",
|
|
517
592
|
name: "today",
|
package/lib/messages.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Dispatch through the record (HQ-1246): the owner's agent writes a message, the phone app shows it
|
|
2
|
+
// as a card, and the owner's reply comes back as a file the agent reads on its next run.
|
|
3
|
+
//
|
|
4
|
+
// The format, which the phone app (iris-mobile, the EVÐA target) reads exactly:
|
|
5
|
+
//
|
|
6
|
+
// <messages role>/YYYY-MM-DD-HHMM-<slug>.md one message, written once by the agent
|
|
7
|
+
// ---
|
|
8
|
+
// id: <the filename stem>
|
|
9
|
+
// from: "<agent name>"
|
|
10
|
+
// title: "<title>"
|
|
11
|
+
// date: "<ISO 8601 with offset>"
|
|
12
|
+
// ---
|
|
13
|
+
// <markdown body>
|
|
14
|
+
//
|
|
15
|
+
// <messages role>/<same stem>.reply.md the owner's reply, written by the app
|
|
16
|
+
// ---
|
|
17
|
+
// in_reply_to: <the message id>
|
|
18
|
+
// date: "<ISO 8601 with offset>"
|
|
19
|
+
// ---
|
|
20
|
+
// <reply text>
|
|
21
|
+
//
|
|
22
|
+
// <messages role>/.seen.json { "<id>": "<reply date seen>" }, the agent's
|
|
23
|
+
//
|
|
24
|
+
// The messages role resolves through evda.json like every other role, `messages/` when unmapped. A
|
|
25
|
+
// message is never edited after it is written: a second message in the same minute with the same
|
|
26
|
+
// slug gets `-2`, `-3`, and a file already at a name (a reply, or anything sealed) is never touched.
|
|
27
|
+
// "Answered" is the reply file existing, never a stored flag. What the agent has seen is keyed by the
|
|
28
|
+
// reply's date, so a reply the app rewrites after the agent read it shows again.
|
|
29
|
+
//
|
|
30
|
+
// Replies are read the way every other tool reads (lib/readers.mjs): a sealed reply is in no answer
|
|
31
|
+
// and a sealed section is cut out, a noted reply or one in a circle set to a different reader is held
|
|
32
|
+
// back and named, and nothing held back is marked seen.
|
|
33
|
+
//
|
|
34
|
+
// EVDA_MESSAGES_FALSIFY=<mode,...>, for the tests. Each breaks exactly one rule:
|
|
35
|
+
// overwrite a colliding name is written over instead of getting a suffix
|
|
36
|
+
// orphans a reply with no message is returned
|
|
37
|
+
// seen-by-id a reply whose id was ever seen stays hidden, whatever its date
|
|
38
|
+
// strict-seen a .seen.json that does not parse throws instead of reading as empty
|
|
39
|
+
// no-secrets the secret check is skipped
|
|
40
|
+
// default-path the evda.json mapping is ignored and messages/ is always used
|
|
41
|
+
// raw-slug the title goes into the filename unslugged
|
|
42
|
+
// no-readers replies are shown without asking lib/readers.mjs
|
|
43
|
+
|
|
44
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { join, relative, sep } from "node:path";
|
|
46
|
+
import { resolveRole, agentsFile } from "./roles.mjs";
|
|
47
|
+
import { frontmatterOf } from "./circles.mjs";
|
|
48
|
+
import { readContext, mayRead, heldBack, callerFrom, aiView } from "./readers.mjs";
|
|
49
|
+
import { localDate } from "./day-file.mjs";
|
|
50
|
+
import { EvdaError } from "./errors.mjs";
|
|
51
|
+
|
|
52
|
+
export const MESSAGES_ROLE = "messages";
|
|
53
|
+
export const MESSAGES_DEFAULT = "messages";
|
|
54
|
+
export const SEEN_FILE = ".seen.json";
|
|
55
|
+
export const REPLY_SUFFIX = ".reply.md";
|
|
56
|
+
export const DEFAULT_FROM = "Iris";
|
|
57
|
+
const TITLE_MAX = 200;
|
|
58
|
+
const FROM_MAX = 60;
|
|
59
|
+
const BODY_MAX = 200_000;
|
|
60
|
+
const SLUG_MAX = 60;
|
|
61
|
+
|
|
62
|
+
export const MESSAGES_FALSIFY_MODES = ["overwrite", "orphans", "seen-by-id", "strict-seen", "no-secrets", "default-path", "raw-slug", "no-readers"];
|
|
63
|
+
const falsified = (mode) => (process.env.EVDA_MESSAGES_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
64
|
+
|
|
65
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
66
|
+
const refuse = (message, code) => new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
|
|
67
|
+
|
|
68
|
+
// A local time as ISO 8601 with its offset: 2026-09-25T12:04:00-05:00.
|
|
69
|
+
export function isoLocal(d) {
|
|
70
|
+
const off = -d.getTimezoneOffset();
|
|
71
|
+
const sign = off >= 0 ? "+" : "-";
|
|
72
|
+
const a = Math.abs(off);
|
|
73
|
+
return `${localDate(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(a / 60))}:${pad(a % 60)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A title as a filename part: accents folded, lowercase ASCII letters and digits joined by single
|
|
77
|
+
// hyphens, at most 60 characters. A title with nothing left (all emoji, all Greek) is "message".
|
|
78
|
+
export function slugify(title) {
|
|
79
|
+
if (falsified("raw-slug")) return String(title);
|
|
80
|
+
const s = String(title).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
81
|
+
return s.slice(0, SLUG_MAX).replace(/-+$/, "") || "message";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Where messages live: the role's mapping, else messages/ at the record root. resolveRole refuses a
|
|
85
|
+
// mapping that leaves the record.
|
|
86
|
+
export function messagesDir(evda) {
|
|
87
|
+
const r = falsified("default-path") ? { mapped: false } : resolveRole(evda, MESSAGES_ROLE);
|
|
88
|
+
if (r.mapped) {
|
|
89
|
+
if (r.exists && !r.isDir) throw refuse(`the messages role is mapped to ${r.rel}, which is a file; it holds a folder.`, "BAD_MAPPING");
|
|
90
|
+
return { abs: r.abs, rel: r.rel.replace(/\/+$/, "") };
|
|
91
|
+
}
|
|
92
|
+
return { abs: join(evda.dir, MESSAGES_DEFAULT), rel: MESSAGES_DEFAULT };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The agent's name as the operating layer's owner section gives it, else Iris.
|
|
96
|
+
export function agentName(evda) {
|
|
97
|
+
try {
|
|
98
|
+
const a = agentsFile(evda);
|
|
99
|
+
if (!a) return DEFAULT_FROM;
|
|
100
|
+
const m = /^- \*\*Agent name:\*\*[ \t]*(\S.*)$/m.exec(readFileSync(a.abs, "utf8"));
|
|
101
|
+
return m?.[1]?.trim() || DEFAULT_FROM;
|
|
102
|
+
} catch {
|
|
103
|
+
return DEFAULT_FROM;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function oneLine(value, name, max) {
|
|
108
|
+
if (typeof value !== "string" || value.trim() === "") throw refuse(`evda_message needs "${name}".`, "EMPTY");
|
|
109
|
+
const v = value.trim();
|
|
110
|
+
if (/[\r\n]/.test(v)) throw refuse(`evda_message "${name}" is one line.`, "LINES");
|
|
111
|
+
if (v.length > max) throw refuse(`evda_message "${name}" is ${v.length} characters; at most ${max}.`, "TOO_LONG");
|
|
112
|
+
return v;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Write one new message. `refuseSecrets` is passed in by lib/tools.mjs so this module and evda_write
|
|
116
|
+
// refuse with the same check and the same words.
|
|
117
|
+
export function writeMessage(evda, args, { now = new Date(), refuseSecrets } = {}) {
|
|
118
|
+
const title = oneLine(args.title, "title", TITLE_MAX);
|
|
119
|
+
if (typeof args.body !== "string" || args.body.trim() === "") throw refuse('evda_message needs "body", what you are telling the owner.', "EMPTY");
|
|
120
|
+
if (args.body.length > BODY_MAX) throw refuse(`evda_message "body" is ${args.body.length} characters; one message holds at most ${BODY_MAX}.`, "TOO_LONG");
|
|
121
|
+
const from = args.from === undefined ? agentName(evda) : oneLine(args.from, "from", FROM_MAX);
|
|
122
|
+
if (!falsified("no-secrets")) for (const s of [title, args.body, from]) refuseSecrets(s, "evda_message");
|
|
123
|
+
const dir = messagesDir(evda);
|
|
124
|
+
mkdirSync(dir.abs, { recursive: true });
|
|
125
|
+
const base = `${localDate(now)}-${pad(now.getHours())}${pad(now.getMinutes())}-${slugify(title)}`;
|
|
126
|
+
const taken = (stem) => existsSync(join(dir.abs, `${stem}.md`)) || existsSync(join(dir.abs, `${stem}${REPLY_SUFFIX}`));
|
|
127
|
+
let stem = base;
|
|
128
|
+
if (!falsified("overwrite")) for (let n = 2; taken(stem); n++) stem = `${base}-${n}`;
|
|
129
|
+
const abs = join(dir.abs, `${stem}.md`);
|
|
130
|
+
const text = ["---", `id: ${stem}`, `from: ${JSON.stringify(from)}`, `title: ${JSON.stringify(title)}`, `date: ${JSON.stringify(isoLocal(now))}`, "---", "", args.body.replace(/\n+$/, ""), ""].join("\n");
|
|
131
|
+
// "wx": never replace a file, even one that appeared between the check and the write.
|
|
132
|
+
writeFileSync(abs, text, { flag: falsified("overwrite") ? "w" : "wx" });
|
|
133
|
+
return { id: stem, path: relative(evda.dir, abs).split(sep).join("/") };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// What the agent has seen, as { id: reply date }. Missing, unreadable, or not a JSON object of
|
|
137
|
+
// strings: empty, so every reply shows and the next mark rewrites the file whole.
|
|
138
|
+
export function readSeen(dirAbs) {
|
|
139
|
+
const p = join(dirAbs, SEEN_FILE);
|
|
140
|
+
if (!existsSync(p)) return {};
|
|
141
|
+
let v;
|
|
142
|
+
try { v = JSON.parse(readFileSync(p, "utf8")); } catch (e) { if (falsified("strict-seen")) throw e; return {}; }
|
|
143
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return {};
|
|
144
|
+
return Object.fromEntries(Object.entries(v).filter(([, d]) => typeof d === "string"));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writeSeen(dirAbs, seen) {
|
|
148
|
+
const p = join(dirAbs, SEEN_FILE);
|
|
149
|
+
const t = `${p}.tmp-${process.pid}`;
|
|
150
|
+
writeFileSync(t, JSON.stringify(seen, null, 2) + "\n");
|
|
151
|
+
renameSync(t, p);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The text after a file's frontmatter.
|
|
155
|
+
function bodyOf(text) {
|
|
156
|
+
const lines = text.split("\n");
|
|
157
|
+
if (lines[0] !== "---") return text;
|
|
158
|
+
const end = lines.indexOf("---", 1);
|
|
159
|
+
return end === -1 ? text : lines.slice(end + 1).join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const dateString = (v) => (v instanceof Date ? v.toISOString() : typeof v === "string" && v.trim() !== "" ? v.trim() : null);
|
|
163
|
+
|
|
164
|
+
// The owner's replies the agent has not seen: { replies: [{ id, title, reply, date, path }], heldBack,
|
|
165
|
+
// marked }. Marks each one returned as seen unless `peek`. A reply with no message is not one.
|
|
166
|
+
export function unseenReplies(evda, { peek = false } = {}, { caller = callerFrom() } = {}) {
|
|
167
|
+
const dir = messagesDir(evda);
|
|
168
|
+
if (!existsSync(dir.abs) || !statSync(dir.abs).isDirectory()) return { replies: [], heldBack: "", marked: 0, dir: dir.rel };
|
|
169
|
+
const seen = readSeen(dir.abs);
|
|
170
|
+
const ctx = readContext(evda);
|
|
171
|
+
const refused = [];
|
|
172
|
+
let sealed = false;
|
|
173
|
+
const replies = [];
|
|
174
|
+
for (const name of readdirSync(dir.abs).sort()) {
|
|
175
|
+
if (!name.endsWith(REPLY_SUFFIX) || name.startsWith(".")) continue;
|
|
176
|
+
const id = name.slice(0, -REPLY_SUFFIX.length);
|
|
177
|
+
const abs = join(dir.abs, name);
|
|
178
|
+
if (!statSync(abs).isFile()) continue;
|
|
179
|
+
const msgAbs = join(dir.abs, `${id}.md`);
|
|
180
|
+
if (!existsSync(msgAbs) && !falsified("orphans")) continue;
|
|
181
|
+
if (!falsified("no-readers")) {
|
|
182
|
+
const may = mayRead(ctx, abs, caller);
|
|
183
|
+
if (!may.ok) { refused.push(may); continue; }
|
|
184
|
+
}
|
|
185
|
+
const raw = readFileSync(abs, "utf8");
|
|
186
|
+
const front = frontmatterOf(raw) ?? {};
|
|
187
|
+
const date = dateString(front.date) ?? statSync(abs).mtime.toISOString();
|
|
188
|
+
if (falsified("seen-by-id") ? id in seen : seen[id] === date) continue;
|
|
189
|
+
const shown = falsified("no-readers") ? { text: raw, sections: 0 } : aiView(raw);
|
|
190
|
+
if (shown.sections) sealed = true;
|
|
191
|
+
let title = id;
|
|
192
|
+
if (existsSync(msgAbs)) {
|
|
193
|
+
const mraw = readFileSync(msgAbs, "utf8");
|
|
194
|
+
const mfront = frontmatterOf(mraw);
|
|
195
|
+
if (typeof mfront?.title === "string" && mfront.title.trim()) title = mfront.title.trim();
|
|
196
|
+
}
|
|
197
|
+
replies.push({ id, title, reply: bodyOf(shown.text).replace(/^\n+|\n+$/g, ""), date, path: relative(evda.dir, abs).split(sep).join("/") });
|
|
198
|
+
}
|
|
199
|
+
replies.sort((a, b) => (a.date === b.date ? a.id.localeCompare(b.id) : a.date.localeCompare(b.date)));
|
|
200
|
+
if (!peek && replies.length) {
|
|
201
|
+
for (const r of replies) seen[r.id] = r.date;
|
|
202
|
+
writeSeen(dir.abs, seen);
|
|
203
|
+
}
|
|
204
|
+
return { replies, heldBack: heldBack(refused, { sealed }), marked: peek ? 0 : replies.length, dir: dir.rel };
|
|
205
|
+
}
|