create-evda 0.1.6 → 0.1.7
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 +14 -0
- package/bin/evda.mjs +37 -4
- package/create-evda.mjs +10 -6
- package/lib/folders.mjs +1 -1
- package/lib/gather-apply.mjs +6 -1
- package/lib/gather.mjs +60 -10
- package/lib/mcp.mjs +33 -2
- package/lib/messages.mjs +205 -0
- package/lib/organize.mjs +124 -20
- package/lib/parity.mjs +80 -7
- package/lib/roles.mjs +4 -0
- package/lib/routines.mjs +142 -18
- package/lib/setup.mjs +6 -4
- package/lib/sync-rules.mjs +10 -4
- package/lib/tools.mjs +15 -0
- package/package.json +3 -2
- package/skills/evda/SKILL.md +10 -9
- package/templates/AGENTS.md +4 -0
- package/templates/evda-digest.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ Plain-English notes on how Iris behaves differently after each version. Not a co
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
Organizing is on from the start. Setup installs the morning organize on a Mac (a launchd job that
|
|
8
|
+
runs your own Claude each morning at 8:00, or when the computer wakes), says so in one line, and
|
|
9
|
+
offers off: answer no during setup, pass `--no-routines`, or later run `evda routine morning
|
|
10
|
+
--uninstall`. The weekly look back is still offered, not on. When your AI's part of a routine cannot
|
|
11
|
+
run, the report now says why in words you can act on (Claude is not signed in on this computer: open
|
|
12
|
+
Claude and sign in), and the filing part still runs.
|
|
13
|
+
|
|
14
|
+
Your agent can message you. `evda_message` (and `evda message`) writes one new file in your record's
|
|
15
|
+
`messages/` folder, which the EVÐA phone app shows as a card, and your reply comes back as a file beside
|
|
16
|
+
it. `evda_replies` (and `evda replies`) hands your agent each reply once at the start of its next run,
|
|
17
|
+
and again if you change it. A new record has the folder; an older one gets it the first time a message
|
|
18
|
+
is sent, and `evda.json` can map it elsewhere. A message refuses anything shaped like a password, and a
|
|
19
|
+
reply you seal or just note is not shown to your agent.
|
|
20
|
+
|
|
7
21
|
A note you asked to have just noted, or a sealed one, stays that way wherever EVDA copies it.
|
|
8
22
|
`evda organize` no longer copies a just-noted capture into its day: the day gets one line saying where
|
|
9
23
|
it is, and once you release it the next organize files its words. Gathering a pile quotes nothing of a
|
package/bin/evda.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { parity, formatParity } from "../lib/parity.mjs";
|
|
|
34
34
|
import { note, releaseNote, ownerSearch, readContext, readersFor, describeReaders, parseReaders, READ_BY } from "../lib/readers.mjs";
|
|
35
35
|
import { runHook } from "../lib/guard.mjs";
|
|
36
36
|
import { markdownFiles } from "../lib/circles.mjs";
|
|
37
|
-
import { refuseSecrets } from "../lib/tools.mjs";
|
|
37
|
+
import { refuseSecrets, message as messageTool, replies as repliesTool } from "../lib/tools.mjs";
|
|
38
38
|
|
|
39
39
|
const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
|
|
40
40
|
|
|
@@ -102,9 +102,10 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
|
|
|
102
102
|
undo an apply byte for byte; refused when a placed file was edited
|
|
103
103
|
evda look-back [dir] [--model M]
|
|
104
104
|
the week, read back by the owner's claude -p, appended to a report
|
|
105
|
-
evda routine <morning|weekly> [dir] [--launchd] [--install]
|
|
105
|
+
evda routine <morning|weekly> [dir] [--launchd] [--install] [--uninstall]
|
|
106
106
|
print a scheduled routine for Claude, or a launchd fallback; --install
|
|
107
|
-
writes the launchd job,
|
|
107
|
+
writes the launchd job, --uninstall removes it (setup installs the
|
|
108
|
+
morning organize unless told no)
|
|
108
109
|
evda preset list the organizations this package ships, and who each suits
|
|
109
110
|
evda preset show <id> one preset's folders
|
|
110
111
|
evda preset apply <id> [--dry] [dir]
|
|
@@ -134,6 +135,12 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
|
|
|
134
135
|
or project, or a folder of effort files. CHECKED n, or SKIPPED and why
|
|
135
136
|
evda note "<words>" [--in dir] just note it: a capture no AI reads until you release it (words from stdin with -)
|
|
136
137
|
evda release <path> [dir] you can read that now: clear the just-noted flag on one entry
|
|
138
|
+
evda message --title "<title>" [--from NAME] [--in dir] "<body>"
|
|
139
|
+
tell the owner something: one new file in the messages role, a card
|
|
140
|
+
in their phone app (body from stdin with -)
|
|
141
|
+
evda replies [--peek] [--in dir] [--json]
|
|
142
|
+
the owner's replies not seen yet, marked seen unless --peek; read
|
|
143
|
+
under read_by as the AI $EVDA_READER names (claude, local, ...)
|
|
137
144
|
evda search <words> [dir] your own search, on this device, no model; just-noted and sealed entries included and marked
|
|
138
145
|
evda readers [dir] which AI reads each part of the record, from read_by
|
|
139
146
|
evda local-model how to run a model on your own computer and point a circle at it
|
|
@@ -410,7 +417,7 @@ async function main(argv) {
|
|
|
410
417
|
}
|
|
411
418
|
|
|
412
419
|
case "routine": {
|
|
413
|
-
// evda routine <morning|weekly> [dir] [--launchd] [--install]
|
|
420
|
+
// evda routine <morning|weekly> [dir] [--launchd] [--install] [--uninstall]
|
|
414
421
|
const { routineMain } = await import("../lib/routines.mjs");
|
|
415
422
|
return routineMain(args, { pickDir, flagValue });
|
|
416
423
|
}
|
|
@@ -507,6 +514,32 @@ async function main(argv) {
|
|
|
507
514
|
return 0;
|
|
508
515
|
}
|
|
509
516
|
|
|
517
|
+
case "message": {
|
|
518
|
+
// Like note, the body may hold a slash, so the record is --in <dir> or the locator.
|
|
519
|
+
const take = (flag) => { const i = args.indexOf(flag); if (i < 0) return undefined; const v = args[i + 1]; if (v === undefined) throw new EvdaError(`${flag} needs a value`, { exit: 2 }); args.splice(i, 2); return v; };
|
|
520
|
+
const dirArg = take("--in");
|
|
521
|
+
const title = take("--title");
|
|
522
|
+
const from = take("--from");
|
|
523
|
+
const dir = locate({ explicit: dirArg }).dir;
|
|
524
|
+
let body = args.join(" ");
|
|
525
|
+
if (body === "-" || body === "") body = readFileSync(0, "utf8");
|
|
526
|
+
const r = messageTool(openEvda(dir), { title, body, ...(from === undefined ? {} : { from }) });
|
|
527
|
+
console.log(`Sent the message ${r.id}, filed at ${r.path}.`);
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
case "replies": {
|
|
532
|
+
const i = args.indexOf("--in");
|
|
533
|
+
const dir = locate({ explicit: i >= 0 ? args[i + 1] : undefined }).dir;
|
|
534
|
+
const peek = args.includes("--peek");
|
|
535
|
+
const r = repliesTool(openEvda(dir), peek ? { peek: true } : {});
|
|
536
|
+
if (args.includes("--json")) { console.log(JSON.stringify({ replies: r.replies, marked: r.marked }, null, 2)); return 0; }
|
|
537
|
+
for (const x of r.replies) console.log(`## ${x.title} (${x.id}, ${x.date})\n\n${x.reply}\n`);
|
|
538
|
+
if (r.heldBack) console.log(r.heldBack);
|
|
539
|
+
console.log(`CHECKED ${r.dir}/ ${r.replies.length} new repl${r.replies.length === 1 ? "y" : "ies"}, ${r.marked} marked seen${peek ? " (peek)" : ""}`);
|
|
540
|
+
return 0;
|
|
541
|
+
}
|
|
542
|
+
|
|
510
543
|
case "release": {
|
|
511
544
|
const { dir, entry } = pickReleaseArgs(args);
|
|
512
545
|
const r = releaseNote(openEvda(dir), entry);
|
package/create-evda.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { readPointer, setPointer, pointerPath } from "./lib/locate.mjs";
|
|
|
30
30
|
import { FOLDER_MANIFEST } from "./lib/folders.mjs";
|
|
31
31
|
import { EvdaError } from "./lib/errors.mjs";
|
|
32
32
|
import { clients, installSkill, installGuard } from "./lib/connect.mjs";
|
|
33
|
-
import {
|
|
33
|
+
import { defaultRoutines, installAtSetup } from "./lib/routines.mjs";
|
|
34
34
|
import { installHook } from "./lib/hooks.mjs";
|
|
35
35
|
import { applyPreset, listPresets, planLines, presetIds, NONE } from "./lib/presets.mjs";
|
|
36
36
|
import { FILING_MODES } from "./lib/day-file.mjs";
|
|
@@ -53,6 +53,7 @@ const CANONICAL = {
|
|
|
53
53
|
circles: "circles",
|
|
54
54
|
grants: "grants",
|
|
55
55
|
mounts: "mounts",
|
|
56
|
+
messages: "messages",
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
// What each record folder holds (Spec 07, section 2), written as folder.evda.json beside the
|
|
@@ -61,6 +62,7 @@ const FOLDER_TYPES = {
|
|
|
61
62
|
"record/daily": "record",
|
|
62
63
|
"record/captures": "capture",
|
|
63
64
|
"record/people": "person",
|
|
65
|
+
messages: "message",
|
|
64
66
|
};
|
|
65
67
|
|
|
66
68
|
const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--filing everything|asked] [--no-prompt-hook] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--skill] [--routines morning,weekly] [--preset ${presetIds().join("|")}|none] [--use] [--no-pointer] [--no-guard]`;
|
|
@@ -149,6 +151,9 @@ export function scaffold(opts) {
|
|
|
149
151
|
const today = localDate();
|
|
150
152
|
const mapping = { agents: CANONICAL.agents };
|
|
151
153
|
for (const layer of opts.layers) for (const role of rolesForLayer(layer)) mapping[role] = CANONICAL[role];
|
|
154
|
+
// Messages from the owner's agent, and their replies (HQ-1246): every record has the folder, in
|
|
155
|
+
// whatever layers, because the phone app's Dispatch reads it.
|
|
156
|
+
mapping.messages = CANONICAL.messages;
|
|
152
157
|
|
|
153
158
|
mkdirSync(dir, { recursive: true });
|
|
154
159
|
for (const [role, rel] of Object.entries(mapping)) if (role !== "agents") mkdirSync(join(dir, rel), { recursive: true });
|
|
@@ -322,11 +327,10 @@ export async function main(argv, io = null) {
|
|
|
322
327
|
log("");
|
|
323
328
|
log(`Skill: ${r.action} ${r.path}`);
|
|
324
329
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
+
// Organizing is on unless the owner said otherwise (HQ-1246): no flag and no answer means the
|
|
331
|
+
// morning organize is installed.
|
|
332
|
+
const env = io?.env ?? process.env;
|
|
333
|
+
for (const l of installAtSetup(opts.routines ?? defaultRoutines(env), dir, { env, platform: io?.platform ?? process.platform })) log(l);
|
|
330
334
|
log("");
|
|
331
335
|
for (const l of INTRO.split("\n")) log(l);
|
|
332
336
|
log("");
|
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
|
@@ -54,6 +54,7 @@ export function instructions(filing) {
|
|
|
54
54
|
"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
55
|
"Organization comes from the preset library (evda_presets): propose with evda_propose_structure, and apply with evda_apply_preset only after their yes.",
|
|
56
56
|
"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.",
|
|
57
|
+
DISPATCH_LINE,
|
|
57
58
|
PRIVACY_INSTRUCTIONS,
|
|
58
59
|
];
|
|
59
60
|
return lines.join(" ");
|
|
@@ -88,6 +89,12 @@ export const PRIVACY_SECTION = [
|
|
|
88
89
|
"- 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
90
|
].join("\n");
|
|
90
91
|
|
|
92
|
+
// Dispatch (HQ-1246), one line on every surface an agent reads first: the server instructions and
|
|
93
|
+
// the operating layer. The vault's AGENTS-template.md is the core's source; until it carries this
|
|
94
|
+
// line, this is where it lives, and evda_operating_layer serves it above AGENTS.md.
|
|
95
|
+
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.";
|
|
96
|
+
export const DISPATCH_SECTION = `## Messages to the owner\n\n${DISPATCH_LINE}`;
|
|
97
|
+
|
|
91
98
|
const str = (description) => ({ type: "string", description });
|
|
92
99
|
|
|
93
100
|
// summarize() already names the command for the two statuses that ask the owner to run one. Over
|
|
@@ -165,7 +172,7 @@ export const TOOLS = [
|
|
|
165
172
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
166
173
|
run: (evda, args) => {
|
|
167
174
|
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 } };
|
|
175
|
+
return { text: `${chatSection(evda, r.text)}\n\n${DISPATCH_SECTION}\n\n${PRIVACY_SECTION}\n\n${r.text}`, data: { path: r.path } };
|
|
169
176
|
},
|
|
170
177
|
},
|
|
171
178
|
{
|
|
@@ -245,6 +252,30 @@ export const TOOLS = [
|
|
|
245
252
|
return { text: `Noted at ${r.path}. No AI reads it until the owner says it can.`, data: r };
|
|
246
253
|
},
|
|
247
254
|
},
|
|
255
|
+
{
|
|
256
|
+
name: "evda_message",
|
|
257
|
+
title: "Tell the owner something",
|
|
258
|
+
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.",
|
|
259
|
+
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 },
|
|
260
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
261
|
+
run: (evda, args) => {
|
|
262
|
+
const r = tools.message(evda, args);
|
|
263
|
+
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 };
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "evda_replies",
|
|
268
|
+
title: "Read the owner's replies",
|
|
269
|
+
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.",
|
|
270
|
+
inputSchema: { type: "object", properties: { peek: { type: "boolean", description: "true to read without marking them seen." } }, additionalProperties: false },
|
|
271
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
272
|
+
run: (evda, args, ctx) => {
|
|
273
|
+
const r = tools.replies(evda, args, { caller: ctx.caller });
|
|
274
|
+
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");
|
|
275
|
+
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.";
|
|
276
|
+
return { text: r.heldBack ? `${main}\n\n${r.heldBack}` : main, data: { replies: r.replies, marked: r.marked } };
|
|
277
|
+
},
|
|
278
|
+
},
|
|
248
279
|
{
|
|
249
280
|
name: "evda_filing",
|
|
250
281
|
title: "Record how filing works",
|
|
@@ -511,7 +542,7 @@ export const TOOLS = [
|
|
|
511
542
|
];
|
|
512
543
|
|
|
513
544
|
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}`; } },
|
|
545
|
+
{ 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
546
|
{
|
|
516
547
|
uri: "evda://today",
|
|
517
548
|
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
|
+
}
|