create-evda 0.1.10 → 0.1.12
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 +34 -0
- package/README.md +2 -2
- package/bin/evda.mjs +8 -2
- package/create-evda.mjs +3 -8
- package/lib/adopt.mjs +43 -15
- package/lib/attested-host.mjs +25 -7
- package/lib/attested-wrap.mjs +147 -0
- package/lib/checkout-freshness.mjs +88 -0
- package/lib/circles.mjs +4 -2
- package/lib/connect.mjs +94 -30
- package/lib/conversation.mjs +68 -0
- package/lib/corrections.mjs +77 -2
- package/lib/day-file.mjs +7 -0
- package/lib/day-writer.mjs +31 -4
- package/lib/doctor.mjs +38 -8
- package/lib/folders.mjs +2 -1
- package/lib/gather.mjs +4 -3
- package/lib/keep-hook.mjs +23 -4
- package/lib/mcp.mjs +22 -8
- package/lib/person-folders.mjs +2 -1
- package/lib/plugin-hook.mjs +3 -1
- package/lib/presets.mjs +2 -1
- package/lib/prompt-hook.mjs +140 -6
- package/lib/readme.mjs +145 -0
- package/lib/record-walk.mjs +9 -0
- package/lib/tdx-attestation.mjs +28 -8
- package/lib/tools.mjs +21 -7
- package/package.json +1 -1
package/lib/mcp.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import * as gatherApply from "./gather-apply.mjs";
|
|
|
26
26
|
import * as adoptLib from "./adopt.mjs";
|
|
27
27
|
import { checkForUpdate, summarize } from "./update-check.mjs";
|
|
28
28
|
import { callerFrom } from "./readers.mjs";
|
|
29
|
+
import { correctionsForLayer, TAUGHT_HEADING } from "./corrections.mjs";
|
|
29
30
|
import { listSkills, getSkill, writeSkill, noticeRepeat, SKILLS_REL } from "./skills.mjs";
|
|
30
31
|
import { createSubrecord } from "./subrecord.mjs";
|
|
31
32
|
import { FALSIFY } from "./mcp-auth.mjs";
|
|
@@ -296,16 +297,27 @@ export function coreBlock(r) {
|
|
|
296
297
|
return [];
|
|
297
298
|
}
|
|
298
299
|
|
|
299
|
-
|
|
300
|
+
// HQ-2818: the layer ends with what the owner taught the agent (lib/corrections.mjs), the newest
|
|
301
|
+
// entries of the corrections file evda.json names, the same on every surface, counted in the chat
|
|
302
|
+
// budget like the core. A record that keeps them in AGENTS.md's own section already serves them in
|
|
303
|
+
// the owner text, so nothing is added unless a chat's budget listed that section by heading.
|
|
304
|
+
export function layerParts(evda, client = null, caller = null) {
|
|
300
305
|
const profile = clientProfile(client?.name);
|
|
306
|
+
const who = caller ?? callerFrom({ clientName: client?.name ?? null });
|
|
301
307
|
const r = tools.operatingLayer(evda);
|
|
302
308
|
const owner = forSurface(r.text, profile.surface);
|
|
303
309
|
const head = [chatSection(evda, r.text, { app: profile.app }), dispatchSection(profile.surface), PRIVACY_SECTION].join("\n\n");
|
|
304
310
|
const core = coreBlock(r);
|
|
305
311
|
// Everything in the result but the owner sections: head, size line, core, and the joins between.
|
|
306
312
|
const fixed = head.length + 2 + SIZE_LINE_RESERVE + 2 + core.reduce((n, c) => n + c.length + 2, 0);
|
|
307
|
-
const
|
|
308
|
-
|
|
313
|
+
const fitWith = (extra) => (profile.surface === "terminal" ? { text: owner.text, listed: [] } : fitChat(owner.text, fixed + extra, profile.budget));
|
|
314
|
+
let taught = correctionsForLayer(evda, who, { agentsPath: r.path });
|
|
315
|
+
let fit = fitWith(taught ? taught.text.length + 2 : 0);
|
|
316
|
+
if (!taught && fit.listed.includes(TAUGHT_HEADING)) {
|
|
317
|
+
taught = correctionsForLayer(evda, who, { agentsPath: r.path, taughtListed: findSection(owner.text, TAUGHT_HEADING, profile.surface).text });
|
|
318
|
+
if (taught) fit = fitWith(taught.text.length + 2);
|
|
319
|
+
}
|
|
320
|
+
const body = [...core, fit.text, ...(taught ? [taught.text] : [])].join("\n\n");
|
|
309
321
|
const max = profile.max;
|
|
310
322
|
const pieces = packSections(body, max - PART_RESERVE - head.length, max - PART_RESERVE);
|
|
311
323
|
const n = pieces.length;
|
|
@@ -316,7 +328,7 @@ export function layerParts(evda, client = null) {
|
|
|
316
328
|
const end = i < n - 1 ? `\n\n(Part ${i + 1} of ${n} ends here. The layer continues: call evda_operating_layer with {"part": ${i + 2}} now, before answering.)` : "";
|
|
317
329
|
return `${top}${p}${end}`;
|
|
318
330
|
});
|
|
319
|
-
return { path: r.path, core: r.core?.path ?? null, parts, chars, surface: profile.surface, left: owner.left, listed: fit.listed };
|
|
331
|
+
return { path: r.path, core: r.core?.path ?? null, parts, chars, surface: profile.surface, left: owner.left, listed: fit.listed, corrections: taught ? { path: taught.path, served: taught.served, total: taught.total } : null };
|
|
320
332
|
}
|
|
321
333
|
|
|
322
334
|
// Split text into pieces of at most `max` characters (the first at most `first`), cutting where a
|
|
@@ -342,6 +354,8 @@ export function packSections(text, first, max) {
|
|
|
342
354
|
}
|
|
343
355
|
|
|
344
356
|
const str = (description) => ({ type: "string", description });
|
|
357
|
+
// evda_write's and evda_write_agent_log's `conversation` (HQ-2823), in one set of words for both.
|
|
358
|
+
const CONVERSATION_ARG = str("Leave this out in a chat: the chat is already the conversation. A hook or relay that sends each message as a separate call (a new process each time) passes the same short id on every call (letters, digits, _ and -, at most 128 characters, e.g. Claude Code's session_id), so those calls continue one section under one heading instead of opening a new one each.");
|
|
345
359
|
|
|
346
360
|
// summarize() already names the command for the two statuses that ask the owner to run one. Over
|
|
347
361
|
// stdio the owner may have no shell in front of them at all (the Claude desktop app), and `evda
|
|
@@ -628,9 +642,9 @@ export const TOOLS = [
|
|
|
628
642
|
if (!Number.isInteger(part) || part < 1 || part > s.parts.length) throw tools.refuse(`evda_operating_layer "part" is a whole number from 1 to ${s.parts.length}; this section has ${s.parts.length === 1 ? "one part" : `${s.parts.length} parts`}.`, "BAD_ARGUMENT");
|
|
629
643
|
return { text: s.parts[part - 1], data: { path: s.path, section: s.heading, part, parts: s.parts.length } };
|
|
630
644
|
}
|
|
631
|
-
const l = layerParts(evda, ctx.client);
|
|
645
|
+
const l = layerParts(evda, ctx.client, ctx.caller);
|
|
632
646
|
if (!Number.isInteger(part) || part < 1 || part > l.parts.length) throw tools.refuse(`evda_operating_layer "part" is a whole number from 1 to ${l.parts.length}; this layer has ${l.parts.length === 1 ? "one part" : `${l.parts.length} parts`}.`, "BAD_ARGUMENT");
|
|
633
|
-
const data = { path: l.path, core: l.core, part, parts: l.parts.length, chars: l.chars, listed: l.listed };
|
|
647
|
+
const data = { path: l.path, core: l.core, part, parts: l.parts.length, chars: l.chars, listed: l.listed, corrections: l.corrections };
|
|
634
648
|
if (!opening) return { text: l.parts[part - 1], data };
|
|
635
649
|
const start = openChat(evda, args, ctx, { room: clientProfile(ctx.client?.name).max - l.parts[part - 1].length - 7 - AFTER_RUN_RESERVE });
|
|
636
650
|
return { text: `${l.parts[part - 1]}\n\n---\n\n${start.text}`, data: { ...data, filed: start.filed } };
|
|
@@ -687,7 +701,7 @@ export const TOOLS = [
|
|
|
687
701
|
name: "evda_write",
|
|
688
702
|
title: "File the owner's words",
|
|
689
703
|
description: WRITE_DESCRIPTION.everything,
|
|
690
|
-
inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim: filler, hedges and typos intact. Never a summary."), context: str("One line, yours, on what the conversation was about. Written above their words and labeled as not theirs. Never their words.") }, required: ["words"], additionalProperties: false },
|
|
704
|
+
inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim: filler, hedges and typos intact. Never a summary."), context: str("One line, yours, on what the conversation was about. Written above their words and labeled as not theirs. Never their words."), conversation: CONVERSATION_ARG }, required: ["words"], additionalProperties: false },
|
|
691
705
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
692
706
|
run: async (evda, args, ctx) => {
|
|
693
707
|
const dup = sameAsFiled(ctx.session, args?.words);
|
|
@@ -725,7 +739,7 @@ export const TOOLS = [
|
|
|
725
739
|
name: "evda_write_agent_log",
|
|
726
740
|
title: "File Iris's own day log",
|
|
727
741
|
description: "Files the agent's own day log, separate from the owner's words. Call to record what you (the agent, not the owner) did, built, filed or found: appends to Iris's own day log, the one named sub-record at \"01_Projects/Evda/Iris - Universal Thinking Partner/memory/record/daily\", and returns the path. It takes no path. It also keeps the one pointer line to that log under the agent's heading in the owner's day for today (added, or refreshed in place, never a second line), and touches nothing else there. One section per conversation, exactly like evda_write: the first call in a conversation opens the time heading with its context line, and later calls in the same conversation continue it.",
|
|
728
|
-
inputSchema: { type: "object", properties: { words: str("What you did, in your own words."), context: str("One line on what the conversation was about. Written above the words and labeled as not the owner's.") }, required: ["words"], additionalProperties: false },
|
|
742
|
+
inputSchema: { type: "object", properties: { words: str("What you did, in your own words."), context: str("One line on what the conversation was about. Written above the words and labeled as not the owner's."), conversation: CONVERSATION_ARG }, required: ["words"], additionalProperties: false },
|
|
729
743
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
730
744
|
run: (evda, args, ctx) => {
|
|
731
745
|
const r = tools.writeAgentLog(evda, args, { session: ctx.session });
|
package/lib/person-folders.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
22
23
|
|
|
23
24
|
export const PROCESSING_DIR = "Processing";
|
|
24
25
|
export const README_NAME = "README.md";
|
|
@@ -64,7 +65,7 @@ export function personFolderProblems(dirs, { skip = new Set() } = {}) {
|
|
|
64
65
|
let names;
|
|
65
66
|
try { names = readdirSync(d).sort(); } catch { continue; }
|
|
66
67
|
for (const name of names) {
|
|
67
|
-
if (name.startsWith(".") || name
|
|
68
|
+
if (name.startsWith(".") || outsideRecord(name)) continue;
|
|
68
69
|
const p = join(d, name);
|
|
69
70
|
let st;
|
|
70
71
|
try { st = statSync(p); } catch { continue; }
|
package/lib/plugin-hook.mjs
CHANGED
|
@@ -82,7 +82,9 @@ export function handle(mode, baked, input, env = process.env) {
|
|
|
82
82
|
const settings = JSON.parse(readFileSync(join(root, ".claude", "settings.json"), "utf8"));
|
|
83
83
|
const cmd = (settings?.hooks?.PreToolUse ?? []).flatMap((m) => (m?.hooks ?? []).map((h) => h?.command)).find((c) => typeof c === "string" && GUARD_MARK.test(c));
|
|
84
84
|
if (!cmd) return none;
|
|
85
|
-
|
|
85
|
+
// The record's settings name the guard under $CLAUDE_PROJECT_DIR (HQ-2819), and a Cowork task's
|
|
86
|
+
// project is not the record, so it is set to the record for this one command.
|
|
87
|
+
const r = spawnSync(cmd, { shell: true, input, encoding: "utf8", timeout: 25000, env: { ...process.env, CLAUDE_PROJECT_DIR: root } });
|
|
86
88
|
return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", code: r.status === 2 ? 2 : 0 };
|
|
87
89
|
}
|
|
88
90
|
return none;
|
package/lib/presets.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { ROUTINE_KEYS } from "./routines.mjs";
|
|
|
44
44
|
import { onlyKeys, proposeCircle, refuse, PROPOSALS } from "./tools.mjs";
|
|
45
45
|
import { EvdaError } from "./errors.mjs";
|
|
46
46
|
import { PERSON_FOLDER_README, PROCESSING_DIR, README_NAME } from "./person-folders.mjs";
|
|
47
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
47
48
|
|
|
48
49
|
export const PRESETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "presets");
|
|
49
50
|
export const PRESET_FILE = "preset.json";
|
|
@@ -66,7 +67,7 @@ function pathProblem(p) {
|
|
|
66
67
|
if (p.startsWith("/")) return "is absolute; paths are relative to the record root";
|
|
67
68
|
for (const seg of p.split("/")) {
|
|
68
69
|
if (seg === "" || seg === "." || seg === "..") return `has an empty, . or .. segment`;
|
|
69
|
-
if (seg.startsWith(".") || seg
|
|
70
|
+
if (seg.startsWith(".") || outsideRecord(seg)) return `has the segment "${seg}", which doctor does not read`;
|
|
70
71
|
}
|
|
71
72
|
return null;
|
|
72
73
|
}
|
package/lib/prompt-hook.mjs
CHANGED
|
@@ -23,28 +23,157 @@
|
|
|
23
23
|
//
|
|
24
24
|
// EVDA_CHAT_FALSIFY=ignore-filing files even when evda.json does not say "everything", for the test
|
|
25
25
|
// that shows the hook reads the owner's answer.
|
|
26
|
+
//
|
|
27
|
+
// ON A MACHINE THAT DOES NOT WRITE THE DAY (HQ-2817). EVDA_DAY_WRITER set means another machine
|
|
28
|
+
// writes the daily file, and a local append is the iCloud race HQ-2165 exists to stop (lib/
|
|
29
|
+
// day-writer.mjs). There the hook never touches the day file. It hands the words to the command in
|
|
30
|
+
// EVDA_DAY_WRITER_SEND, which receives evda_write's arguments as JSON on stdin and exits 0 once the
|
|
31
|
+
// writer filed them (Mike's: ssh to Hestia and its evda-tool.mjs evda_write). Each message is first
|
|
32
|
+
// saved as one JSON file in this machine's own outbox, outside the record and outside any synced
|
|
33
|
+
// folder (EVDA_PROMPT_OUTBOX, default ~/.evda/outbox), then the outbox is sent oldest first and a
|
|
34
|
+
// file is deleted only after its send exits 0. So a message said while the writer is unreachable
|
|
35
|
+
// waits there and goes on the next message that reaches it, filed once:
|
|
36
|
+
// - one drainer: only this machine's hook reads its outbox, so no other machine can file it too;
|
|
37
|
+
// - one sender per file: a send first renames the file to `.sending`, an atomic claim, so two
|
|
38
|
+
// sessions submitting at once never send the same file; a claim older than SENDING_STALE_MS
|
|
39
|
+
// (a hook that was killed mid-send) is put back for the next run.
|
|
40
|
+
// The whole send is bounded by EVDA_DAY_WRITER_SEND_MS (default 6000) because Claude Code holds the
|
|
41
|
+
// owner's message until this hook returns. One case can still file twice: a send that reached the
|
|
42
|
+
// writer and timed out before it answered. A connect timeout on the sender's side keeps that rare.
|
|
43
|
+
// With EVDA_DAY_WRITER set and no EVDA_DAY_WRITER_SEND, nothing on this machine can reach the
|
|
44
|
+
// writer, so nothing is queued and the hook tells the model to file the message with evda_write.
|
|
26
45
|
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
46
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
28
47
|
import { dirname, join, relative } from "node:path";
|
|
29
|
-
import { tmpdir } from "node:os";
|
|
48
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
49
|
+
import { spawnSync } from "node:child_process";
|
|
30
50
|
import { fileURLToPath } from "node:url";
|
|
31
|
-
import { dayRole, findRecord, filingOf, appendToDay, localDate } from "./day-file.mjs";
|
|
51
|
+
import { dayRole, findRecord, filingOf, appendToDay, localDate, isConversationId } from "./day-file.mjs";
|
|
32
52
|
import { secretShape } from "./secret-shape.mjs";
|
|
33
53
|
|
|
34
54
|
export const PROMPT_HOOK_NAME = "evda-file-prompt.mjs";
|
|
35
55
|
|
|
36
56
|
const falsified = (mode) => (process.env.EVDA_CHAT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
37
57
|
|
|
58
|
+
export const DAY_WRITER_SEND_ENV = "EVDA_DAY_WRITER_SEND";
|
|
59
|
+
export const PROMPT_OUTBOX_ENV = "EVDA_PROMPT_OUTBOX";
|
|
60
|
+
export const SEND_BUDGET_MS = 6000;
|
|
61
|
+
export const SENDING_STALE_MS = 2 * 60 * 1000;
|
|
62
|
+
|
|
63
|
+
export function promptOutboxDir(env = process.env) {
|
|
64
|
+
return env[PROMPT_OUTBOX_ENV] || join(homedir(), ".evda", "outbox");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
68
|
+
|
|
69
|
+
// Save one message for the writer: a new file per message, never rewritten, in this machine's outbox.
|
|
70
|
+
// `conversation` is the Claude Code session it was said in, kept with it so a message that waited
|
|
71
|
+
// still continues its conversation's section when it is sent.
|
|
72
|
+
export function queuePrompt(root, { words, context, conversation = null, now = new Date(), env = process.env }) {
|
|
73
|
+
const dir = promptOutboxDir(env);
|
|
74
|
+
mkdirSync(dir, { recursive: true });
|
|
75
|
+
const at = now.toISOString();
|
|
76
|
+
const stamp = at.replace(/[-:.]/g, "");
|
|
77
|
+
let file = join(dir, `${stamp}-${process.pid}.json`);
|
|
78
|
+
for (let n = 2; existsSync(file); n++) file = join(dir, `${stamp}-${process.pid}-${n}.json`);
|
|
79
|
+
writeFileSync(file, JSON.stringify({ tool: "evda_write", record: realOr(root), words, context, ...(conversation ? { conversation } : {}), at, host: hostname().split(".")[0] || "host" }, null, 1) + "\n", { flag: "wx" });
|
|
80
|
+
return file;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Send this record's queued messages to the writer, oldest first, within `budgetMs`. Returns
|
|
84
|
+
// { sent: [file...], left, error }. Stops at the first failure, so the order of the day is kept.
|
|
85
|
+
// A claim's age is measured against the file system's own clock (the mtime of a file touched now),
|
|
86
|
+
// never Date.now(): a claim's mtime is stamped by the file system, and the two clocks can disagree.
|
|
87
|
+
export function drainPromptOutbox(root, { env = process.env, budgetMs = Number(env.EVDA_DAY_WRITER_SEND_MS) || SEND_BUDGET_MS, nowMs = null } = {}) {
|
|
88
|
+
const cmd = (env[DAY_WRITER_SEND_ENV] ?? "").trim();
|
|
89
|
+
const dir = promptOutboxDir(env);
|
|
90
|
+
const out = { sent: [], left: 0, error: null };
|
|
91
|
+
if (!cmd || !existsSync(dir)) return out;
|
|
92
|
+
if (nowMs === null) { const probe = join(dir, ".now"); writeFileSync(probe, ""); nowMs = statSync(probe).mtimeMs; }
|
|
93
|
+
const rec = realOr(root);
|
|
94
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith(".json.sending"))) {
|
|
95
|
+
const abs = join(dir, f);
|
|
96
|
+
try { if (nowMs - statSync(abs).mtimeMs > SENDING_STALE_MS) renameSync(abs, abs.replace(/\.sending$/, "")); } catch { /* another run took it back */ }
|
|
97
|
+
}
|
|
98
|
+
const mine = readdirSync(dir).filter((n) => n.endsWith(".json")).sort().filter((n) => {
|
|
99
|
+
try { return JSON.parse(readFileSync(join(dir, n), "utf8")).record === rec; } catch { return false; }
|
|
100
|
+
});
|
|
101
|
+
const deadline = Date.now() + budgetMs;
|
|
102
|
+
for (let i = 0; i < mine.length; i++) {
|
|
103
|
+
const abs = join(dir, mine[i]);
|
|
104
|
+
const claim = `${abs}.sending`;
|
|
105
|
+
try { renameSync(abs, claim); } catch { continue; } // another session claimed it
|
|
106
|
+
let entry;
|
|
107
|
+
try { entry = JSON.parse(readFileSync(claim, "utf8")); } catch { renameSync(claim, abs); continue; }
|
|
108
|
+
const left = deadline - Date.now();
|
|
109
|
+
const args = { words: entry.words, ...(entry.context ? { context: entry.context } : {}), ...(isConversationId(entry.conversation) ? { conversation: entry.conversation } : {}) };
|
|
110
|
+
const r = left > 200 ? spawnSync(cmd, { shell: true, input: JSON.stringify(args), encoding: "utf8", timeout: left, env }) : { status: null, error: { code: "ETIMEDOUT" } };
|
|
111
|
+
if (r.status === 0) { unlinkSync(claim); out.sent.push(abs); continue; }
|
|
112
|
+
try { renameSync(claim, abs); } catch { /* kept as a claim; the stale rule puts it back */ }
|
|
113
|
+
out.error = r.error ? (r.error.code === "ETIMEDOUT" ? `no answer in ${budgetMs}ms` : r.error.code ?? r.error.message) : `exited ${r.status ?? r.signal}: ${String(r.stderr ?? "").trim().split("\n").pop() ?? ""}`.trim();
|
|
114
|
+
out.left = mine.length - i;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const timeOf = (d) => `${localDate(d)} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
121
|
+
|
|
122
|
+
// The non-writer path: queue, send, and say what happened. Never touches the day file.
|
|
123
|
+
//
|
|
124
|
+
// Every send is a separate evda_write on the writer, so each names its conversation, Claude Code's
|
|
125
|
+
// session_id, and the writer continues that conversation's section (HQ-2823, lib/conversation.mjs).
|
|
126
|
+
// The context line goes with every send: the writer files it once, on the conversation's first
|
|
127
|
+
// write, and again only if that section is gone (edited away, or its state older than two days),
|
|
128
|
+
// so a continuing conversation never opens a bare heading. A session_id that is not an id's shape
|
|
129
|
+
// is not sent, and that message opens its own section, as every message did before.
|
|
130
|
+
function viaWriter(root, prompt, now, env, sid) {
|
|
131
|
+
const writer = env.EVDA_DAY_WRITER.trim();
|
|
132
|
+
if (!(env[DAY_WRITER_SEND_ENV] ?? "").trim()) {
|
|
133
|
+
return `evda: this machine does not write the daily file (EVDA_DAY_WRITER is ${writer}) and has no ${DAY_WRITER_SEND_ENV} to reach it, so the owner's message above is NOT filed yet. File it verbatim with evda_write this turn.`;
|
|
134
|
+
}
|
|
135
|
+
const conversation = isConversationId(sid) ? sid : null;
|
|
136
|
+
const file = queuePrompt(root, { words: prompt, context: `Claude Code on ${hostname().split(".")[0]}, said ${timeOf(now)}`, conversation, now, env });
|
|
137
|
+
const r = drainPromptOutbox(root, { env });
|
|
138
|
+
if (r.sent.includes(file)) {
|
|
139
|
+
const more = r.sent.length > 1 ? ` (with ${r.sent.length - 1} message(s) queued earlier)` : "";
|
|
140
|
+
return `evda: the owner's message above is already filed verbatim through the day writer (${writer})${more}. Do not file it again with evda_write.`;
|
|
141
|
+
}
|
|
142
|
+
return `evda: the day writer (${writer}) could not be reached (${r.error ?? "not sent"}), so the owner's message above is queued in ${dirname(file)} on this machine and goes to the writer with their next message. Do not file it again with evda_write; if you tell them, say it is queued, not filed.`;
|
|
143
|
+
}
|
|
144
|
+
|
|
38
145
|
function stateFile(sid) {
|
|
39
146
|
const dir = process.env.EVDA_PROMPT_STATE_DIR || join(tmpdir(), "evda-file-prompt");
|
|
40
147
|
mkdirSync(dir, { recursive: true });
|
|
41
148
|
return join(dir, `${String(sid).replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
42
149
|
}
|
|
43
150
|
|
|
151
|
+
// The keep hook reads this (HQ-2817): on a machine that does not write the day, the day file does
|
|
152
|
+
// not change here when a message is filed, so the time it was handed over is the evidence.
|
|
153
|
+
function markFiled(sid, now) {
|
|
154
|
+
const sf = stateFile(sid ?? "unknown");
|
|
155
|
+
let session = {};
|
|
156
|
+
try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* none yet */ }
|
|
157
|
+
writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Claude Code delivers some of its own text through UserPromptSubmit: a background task's
|
|
161
|
+
// completion arrives as a <task-notification> block, and context it adds rides in <system-reminder>
|
|
162
|
+
// blocks. Neither is the owner's, and filing them put whole subagent reports into an owner's day as
|
|
163
|
+
// his words (HQ-2823). Strip those blocks; what is left is what the owner typed, or nothing.
|
|
164
|
+
export const HARNESS_BLOCKS = ["task-notification", "system-reminder"];
|
|
165
|
+
export function ownerWords(prompt) {
|
|
166
|
+
const text = String(prompt);
|
|
167
|
+
let out = text;
|
|
168
|
+
for (const tag of HARNESS_BLOCKS) out = out.replace(new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, "g"), "");
|
|
169
|
+
if (out === text) return text; // nothing of the harness's: the words stay byte for byte
|
|
170
|
+
return out.trim() === "" ? "" : out.replace(/^\s*\n/, "").replace(/\n\s*$/, "");
|
|
171
|
+
}
|
|
172
|
+
|
|
44
173
|
// One UserPromptSubmit event in, the hook's stdout out ("" for nothing to say). Never throws.
|
|
45
|
-
export function run(payload, { now = new Date() } = {}) {
|
|
174
|
+
export function run(payload, { now = new Date(), env = process.env } = {}) {
|
|
46
175
|
try {
|
|
47
|
-
const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
|
|
176
|
+
const prompt = ownerWords(typeof payload?.prompt === "string" ? payload.prompt : "");
|
|
48
177
|
if (!prompt.trim() || /^\s*\//.test(prompt)) return "";
|
|
49
178
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
50
179
|
const root = findRecord(payload.cwd || process.cwd()) ?? findRecord(here);
|
|
@@ -55,6 +184,11 @@ export function run(payload, { now = new Date() } = {}) {
|
|
|
55
184
|
if (!role) return "";
|
|
56
185
|
const shape = prompt.split("\n").map(secretShape).find(Boolean);
|
|
57
186
|
if (shape) return `evda: this message was not filed, because it holds ${shape}. Keep that in a password manager; file the rest of what they said with evda_write if it is worth keeping.`;
|
|
187
|
+
if ((env.EVDA_DAY_WRITER ?? "").trim()) {
|
|
188
|
+
const said = viaWriter(root, prompt, now, env, payload.session_id);
|
|
189
|
+
if ((env[DAY_WRITER_SEND_ENV] ?? "").trim()) markFiled(payload.session_id, now); // sent or queued to send
|
|
190
|
+
return said;
|
|
191
|
+
}
|
|
58
192
|
const dayAbs = join(root, manifest.mapping[role].replace(/\/+$/, ""));
|
|
59
193
|
mkdirSync(dayAbs, { recursive: true });
|
|
60
194
|
const abs = join(dayAbs, `${localDate(now)}.md`);
|
|
@@ -62,7 +196,7 @@ export function run(payload, { now = new Date() } = {}) {
|
|
|
62
196
|
let session = {};
|
|
63
197
|
try { session = JSON.parse(readFileSync(sf, "utf8")); } catch { /* the first prompt of the session */ }
|
|
64
198
|
appendToDay(abs, { now, words: prompt, session });
|
|
65
|
-
writeFileSync(sf, JSON.stringify(session) + "\n");
|
|
199
|
+
writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
|
|
66
200
|
const rel = relative(root, abs).split("\\").join("/");
|
|
67
201
|
return `evda: the owner's message above is already filed verbatim in ${rel}. Do not file it again with evda_write.`;
|
|
68
202
|
} catch {
|
package/lib/readme.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// README.md, the owner-facing half of the operating layer (HQ-2812).
|
|
2
|
+
//
|
|
3
|
+
// AGENTS.md sends the agent to sections of README.md for the facts it must never give from memory:
|
|
4
|
+
// the steps for turning training off, what each AI company says, and running a model locally. A
|
|
5
|
+
// record create-evda makes has them, because it writes README.md from templates/README.md. A record
|
|
6
|
+
// `evda adopt` makes keeps the owner's own README.md, which has none of them, so the privacy step
|
|
7
|
+
// pointed at nothing. This module renders the README once, for both, and keeps those sections in an
|
|
8
|
+
// adopted README as one marked block the owner's text never shares:
|
|
9
|
+
//
|
|
10
|
+
// <the owner's README, byte for byte>
|
|
11
|
+
// <!-- evda:readme -->
|
|
12
|
+
// _lead line_
|
|
13
|
+
// ## Turning training off ... ## Running a model on your own computer ...
|
|
14
|
+
// <!-- /evda:readme -->
|
|
15
|
+
//
|
|
16
|
+
// `ensureReadme` writes the file when it is absent, appends the block when a cited section is
|
|
17
|
+
// missing, and refreshes the block's content when it is there (`evda upgrade`). A README that already
|
|
18
|
+
// holds every cited heading outside a block (create-evda's own) is left alone.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { trainingSection, termsSection, INTRO } from "./setup.mjs";
|
|
24
|
+
|
|
25
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
|
+
export const README_REL = "README.md";
|
|
27
|
+
export const README_OPEN = "<!-- evda:readme -->";
|
|
28
|
+
export const README_CLOSE = "<!-- /evda:readme -->";
|
|
29
|
+
export const README_LEAD = "_EVÐA wrote this block and `evda upgrade` keeps it current. Everything above it is yours, and EVÐA never changes it._";
|
|
30
|
+
// The first and the last heading of templates/README.md the block carries, in that file's order.
|
|
31
|
+
const FIRST = "## Turning training off";
|
|
32
|
+
const AFTER = "## Getting started";
|
|
33
|
+
|
|
34
|
+
// The local-model doc as a README section: its title dropped and its headings one level down.
|
|
35
|
+
export function localModelSection() {
|
|
36
|
+
const text = readFileSync(join(PKG, "docs", "local-model.md"), "utf8");
|
|
37
|
+
return text.split("\n").slice(1).join("\n").replace(/^## /gm, "### ").trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The README create-evda writes into a new record.
|
|
41
|
+
export function renderReadme() {
|
|
42
|
+
let text = readFileSync(join(PKG, "templates", "README.md"), "utf8");
|
|
43
|
+
const vars = { training: trainingSection(), terms: termsSection(), intro: INTRO, localModel: localModelSection() };
|
|
44
|
+
for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`{{${k}}}`, v);
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The sections AGENTS.md cites, cut from the rendered README rather than written a second time.
|
|
49
|
+
export function readmeSections() {
|
|
50
|
+
const text = renderReadme();
|
|
51
|
+
const from = text.indexOf(FIRST);
|
|
52
|
+
const to = text.indexOf(AFTER);
|
|
53
|
+
if (from === -1 || to === -1 || to < from) throw new Error(`templates/README.md no longer has "${FIRST}" before "${AFTER}", so the adopted README block cannot be cut from it.`);
|
|
54
|
+
return text.slice(from, to).trimEnd();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readmeBlock() {
|
|
58
|
+
return `${README_OPEN}\n${README_LEAD}\n\n${readmeSections()}\n${README_CLOSE}\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every README.md section an operating layer sends the agent to: a quoted heading followed by
|
|
62
|
+
// `section of` or `in`, then `README.md`, line breaks inside the quote allowed.
|
|
63
|
+
export function readmeCitations(agentsText) {
|
|
64
|
+
const flat = String(agentsText).replace(/\s+/g, " ");
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const m of flat.matchAll(/"([^"]{2,120})"(?: section)? (?:of|in) `?README\.md`?/g)) if (!out.includes(m[1])) out.push(m[1]);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
71
|
+
export const hasHeading = (text, heading) => new RegExp(`^#{1,6}[ \\t]+${escape(heading)}[ \\t]*#*[ \\t]*$`, "m").test(text);
|
|
72
|
+
|
|
73
|
+
// The cited headings README text lacks.
|
|
74
|
+
export function missingHeadings(readmeText, citations) {
|
|
75
|
+
return citations.filter((h) => !hasHeading(readmeText ?? "", h));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The block's place in README text: { start, end } around the markers (end past the closing line
|
|
79
|
+
// break), or null when there is none. Two blocks, or an open with no close, is a problem, not a block.
|
|
80
|
+
export function findBlock(text) {
|
|
81
|
+
const open = text.indexOf(README_OPEN);
|
|
82
|
+
const close = text.indexOf(README_CLOSE);
|
|
83
|
+
if (open === -1 && close === -1) return null;
|
|
84
|
+
if (open === -1 || close === -1 || close < open || text.indexOf(README_OPEN, open + 1) !== -1 || text.indexOf(README_CLOSE, close + 1) !== -1) {
|
|
85
|
+
throw new Error(`${README_REL} has a broken ${README_OPEN} block (one marker without the other, or two of them). Fix the markers by hand; nothing was changed.`);
|
|
86
|
+
}
|
|
87
|
+
let end = close + README_CLOSE.length;
|
|
88
|
+
if (text[end] === "\n") end += 1;
|
|
89
|
+
return { start: open, end };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// What the README should become. Returns { action, text, added }:
|
|
93
|
+
// "written" absent: the whole README create-evda writes
|
|
94
|
+
// "appended" a cited section was missing and there was no block: the block is added at the end
|
|
95
|
+
// "refreshed" the block was there with other content: its content is replaced
|
|
96
|
+
// "current" the block is there and matches
|
|
97
|
+
// "present" no block, and every cited heading is already in the owner's text
|
|
98
|
+
// `added` is exactly what "appended" put after the owner's bytes, so a revert can take it back out.
|
|
99
|
+
export function planReadme(text, citations) {
|
|
100
|
+
if (text === null) return { action: "written", text: renderReadme(), added: null };
|
|
101
|
+
const block = readmeBlock();
|
|
102
|
+
const at = findBlock(text);
|
|
103
|
+
if (at) {
|
|
104
|
+
const next = text.slice(0, at.start) + block + text.slice(at.end);
|
|
105
|
+
return next === text ? { action: "current", text, added: null } : { action: "refreshed", text: next, added: null };
|
|
106
|
+
}
|
|
107
|
+
if (!missingHeadings(text, citations).length) return { action: "present", text, added: null };
|
|
108
|
+
const added = `${text === "" || text.endsWith("\n") ? "" : "\n"}\n${block}`;
|
|
109
|
+
return { action: "appended", text: text + added, added };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Apply planReadme to <dir>/README.md. `citations` defaults to the record's AGENTS.md, or the shipped
|
|
113
|
+
// template's when there is none.
|
|
114
|
+
export function ensureReadme(dir, { citations = null, dry = false, agents = null } = {}) {
|
|
115
|
+
const abs = join(dir, README_REL);
|
|
116
|
+
const cites = citations ?? readmeCitations(agents ?? readFileSync(join(PKG, "templates", "AGENTS.md"), "utf8"));
|
|
117
|
+
const before = existsSync(abs) ? readFileSync(abs, "utf8") : null;
|
|
118
|
+
const plan = planReadme(before, cites);
|
|
119
|
+
if (!dry && plan.text !== before) writeFileSync(abs, plan.text);
|
|
120
|
+
return { ...plan, abs, rel: README_REL, citations: cites };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// README text with the block taken back out: exactly what was there before an "appended", when the
|
|
124
|
+
// owner did not edit around the block. Null when there is no block to take.
|
|
125
|
+
export function withoutBlock(text, added) {
|
|
126
|
+
if (added && text.endsWith(added)) return text.slice(0, -added.length);
|
|
127
|
+
let at;
|
|
128
|
+
try { at = findBlock(text); } catch { return null; }
|
|
129
|
+
if (!at) return null;
|
|
130
|
+
// The line breaks the adopt put in front of the block go with it.
|
|
131
|
+
const lead = added ? added.slice(0, added.indexOf(README_OPEN)) : "";
|
|
132
|
+
const start = lead && text.slice(at.start - lead.length, at.start) === lead ? at.start - lead.length : at.start;
|
|
133
|
+
return text.slice(0, start) + text.slice(at.end);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function readmeLine(r) {
|
|
137
|
+
const names = r.citations.map((c) => `"${c}"`).join(", ");
|
|
138
|
+
switch (r.action) {
|
|
139
|
+
case "written": return `README.md: written, with ${names}`;
|
|
140
|
+
case "appended": return `README.md: EVÐA's block added at the end, with ${names}; your text above it is unchanged`;
|
|
141
|
+
case "refreshed": return "README.md: EVÐA's block refreshed to what this tool ships; your text around it is unchanged";
|
|
142
|
+
case "current": return "README.md: EVÐA's block already matches what this tool ships";
|
|
143
|
+
default: return `README.md: already has ${names}; left alone`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The one rule every walker over the record uses for what is outside it (HQ-2811): a folder named
|
|
2
|
+
// node_modules (packages, not notes), and any name ending in .nosync, the iCloud convention for
|
|
3
|
+
// "do not sync this". A .nosync folder is one the owner chose to keep out of sync, which is the same
|
|
4
|
+
// as keeping it out of the record; Mike's vault keeps its packages in node_modules.nosync, and the
|
|
5
|
+
// bears-on notice once named a vendored eslint doc in it as an existing entry. Dot-names and app
|
|
6
|
+
// folders stay each walker's own choice, since they differ by what the walk is for.
|
|
7
|
+
export function outsideRecord(name) {
|
|
8
|
+
return name === "node_modules" || name.endsWith(".nosync");
|
|
9
|
+
}
|
package/lib/tdx-attestation.mjs
CHANGED
|
@@ -23,12 +23,19 @@
|
|
|
23
23
|
// 8. the nonce is one this phone issued, unused, and young enough;
|
|
24
24
|
// 9. REPORTDATA equals reportDataFor(nonce, tlsPublicKey), which binds the quote to the TLS key the
|
|
25
25
|
// phone then pins.
|
|
26
|
+
// 10. when the VM names a key-wrapping key (lib/attested-wrap.mjs), REPORTDATA also binds it, so the
|
|
27
|
+
// key a client wraps circle keys to is one the attested VM made, not one the host operator chose.
|
|
26
28
|
//
|
|
27
29
|
// The binding, exactly: REPORTDATA (64 bytes) = SHA-512( "EVDA-TDX-REPORTDATA-v1" || 0x00 ||
|
|
28
30
|
// nonce (32 bytes) || SPKI DER of the TLS public key ). The nonce has a fixed length and SPKI DER
|
|
29
31
|
// carries its own length, so the concatenation is unambiguous. The VM computes the same value with
|
|
30
32
|
// the same function when it asks the TDX module for a quote.
|
|
31
33
|
//
|
|
34
|
+
// With a key-wrapping key, REPORTDATA = SHA-512( "EVDA-TDX-REPORTDATA-v2" || 0x00 || nonce (32 bytes) ||
|
|
35
|
+
// SPKI DER of the TLS public key || the X25519 wrap public key (32 raw bytes) ). The wrap key has a fixed
|
|
36
|
+
// length and comes last, after a self-delimiting SPKI, and the tag differs, so neither form can be read
|
|
37
|
+
// as the other.
|
|
38
|
+
//
|
|
32
39
|
// Not checked here yet: certificate revocation (Intel's PCK and root CRLs), quote v5 (refused as
|
|
33
40
|
// unknown rather than half-checked), the TD relaunch advice v5 quotes carry, and whether the expected
|
|
34
41
|
// measurements are themselves in Sigstore's log (lib/measurement-log.mjs checks a digest; tying the
|
|
@@ -71,11 +78,20 @@ const spkiDer = (key) => {
|
|
|
71
78
|
return createPublicKey(key).export({ type: "spki", format: "der" });
|
|
72
79
|
};
|
|
73
80
|
|
|
74
|
-
/** The
|
|
75
|
-
export
|
|
81
|
+
/** The domain tag when REPORTDATA also binds the VM's key-wrapping key. */
|
|
82
|
+
export const REPORTDATA_WRAP_TAG = "EVDA-TDX-REPORTDATA-v2";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The 64 bytes a TD must put in REPORTDATA to bind `nonce` (32 bytes) and `tlsPublicKey` to its quote,
|
|
86
|
+
* and, when given, `wrapPublicKey` (the raw 32-byte X25519 key the VM made inside itself).
|
|
87
|
+
*/
|
|
88
|
+
export function reportDataFor(nonce, tlsPublicKey, wrapPublicKey) {
|
|
76
89
|
const n = Buffer.from(nonce);
|
|
77
90
|
if (n.length !== 32) throw new Error("the nonce must be 32 bytes");
|
|
78
|
-
return createHash("sha512").update(Buffer.concat([Buffer.from(REPORTDATA_TAG, "ascii"), Buffer.from([0]), n, spkiDer(tlsPublicKey)])).digest();
|
|
91
|
+
if (wrapPublicKey == null) return createHash("sha512").update(Buffer.concat([Buffer.from(REPORTDATA_TAG, "ascii"), Buffer.from([0]), n, spkiDer(tlsPublicKey)])).digest();
|
|
92
|
+
const w = Buffer.from(wrapPublicKey);
|
|
93
|
+
if (w.length !== 32) throw new Error("the wrap key must be 32 bytes");
|
|
94
|
+
return createHash("sha512").update(Buffer.concat([Buffer.from(REPORTDATA_WRAP_TAG, "ascii"), Buffer.from([0]), n, spkiDer(tlsPublicKey), w])).digest();
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
/**
|
|
@@ -381,8 +397,10 @@ function checkQuote({ quote, collateral, minTcb, now, trust }) {
|
|
|
381
397
|
* nonce: the 32 bytes the phone sent; nonces: the createNonceBook that issued it (spent here, pass or fail).
|
|
382
398
|
* tlsPublicKey: the TLS server key of this connection (KeyObject, PEM, or SPKI DER).
|
|
383
399
|
* expected: { mrtd, rtmr0, rtmr1, rtmr2, rtmr3 } as hex. Every one must be named.
|
|
400
|
+
* wrapPublicKey: the VM's key-wrapping key as the host named it (32 raw bytes), or absent. When given,
|
|
401
|
+
* the quote must bind it too, and the result names it.
|
|
384
402
|
*/
|
|
385
|
-
export function verifyTdxAttestation({ quote, nonce, nonces, tlsPublicKey, expected, minTcb, collateral, now, trust = TDX_TRUST } = {}) {
|
|
403
|
+
export function verifyTdxAttestation({ quote, nonce, nonces, tlsPublicKey, wrapPublicKey, expected, minTcb, collateral, now, trust = TDX_TRUST } = {}) {
|
|
386
404
|
try {
|
|
387
405
|
if (!nonces?.take) return fail("no nonce book was given, so freshness cannot be checked");
|
|
388
406
|
const at = now === undefined ? undefined : new Date(now).getTime();
|
|
@@ -400,12 +418,14 @@ export function verifyTdxAttestation({ quote, nonce, nonces, tlsPublicKey, expec
|
|
|
400
418
|
// 7. The code is the code expected.
|
|
401
419
|
for (const n of MEASUREMENT_NAMES) if (r.body[n] !== expected[n]) return fail(`${n.toUpperCase()} is ${r.body[n].slice(0, 16)}..., not the expected ${expected[n].slice(0, 16)}...`);
|
|
402
420
|
|
|
403
|
-
// 9. The quote is for this nonce and this
|
|
404
|
-
const want = reportDataFor(nonce, tlsPublicKey);
|
|
405
|
-
if (!timingSafeEqual(want, r.body.reportData)) return fail("REPORTDATA does not bind this nonce and this TLS key");
|
|
421
|
+
// 9 and 10. The quote is for this nonce, this TLS key and, when named, this wrap key.
|
|
422
|
+
const want = reportDataFor(nonce, tlsPublicKey, wrapPublicKey);
|
|
423
|
+
if (!timingSafeEqual(want, r.body.reportData)) return fail(wrapPublicKey == null ? "REPORTDATA does not bind this nonce and this TLS key" : "REPORTDATA does not bind this nonce, this TLS key and this wrap key");
|
|
406
424
|
|
|
407
425
|
const measurements = Object.fromEntries(MEASUREMENT_NAMES.map((n) => [n, r.body[n]]));
|
|
408
|
-
|
|
426
|
+
const out = { ok: true, measurements, tcb: r.tcb, tlsPublicKeySha256: sha256(spkiDer(tlsPublicKey)).toString("hex") };
|
|
427
|
+
if (wrapPublicKey != null) out.wrapPublicKey = Buffer.from(wrapPublicKey);
|
|
428
|
+
return out;
|
|
409
429
|
} catch (err) {
|
|
410
430
|
return fail(`the attestation could not be checked: ${err.message}`);
|
|
411
431
|
}
|
package/lib/tools.mjs
CHANGED
|
@@ -41,7 +41,8 @@ import { secretShape } from "./references.mjs";
|
|
|
41
41
|
import { doctor } from "./doctor.mjs";
|
|
42
42
|
import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY, isSealedFile, isNotedFile } from "./readers.mjs";
|
|
43
43
|
import { EvdaError } from "./errors.mjs";
|
|
44
|
-
import { dayRole, appendToDay, setPointerLine, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
|
|
44
|
+
import { dayRole, appendToDay, setPointerLine, CONTEXT_LABEL, localDate, localTime, isConversationId, CONVERSATION_MAX } from "./day-file.mjs";
|
|
45
|
+
import { loadConversation, saveConversation } from "./conversation.mjs";
|
|
45
46
|
import { writeMessage, unseenReplies, agentName } from "./messages.mjs";
|
|
46
47
|
import { acknowledge as writeAcknowledgement, readAcknowledged, acknowledgementsFor } from "./acknowledge.mjs";
|
|
47
48
|
import { openEvda, holderOf } from "./manifest.mjs";
|
|
@@ -309,8 +310,13 @@ export const CONTEXT_MAX = 240;
|
|
|
309
310
|
// another tool's own name when it reuses this same append-only logic against a different day
|
|
310
311
|
// file (evda_write_agent_log, against Iris's own sub-record). The behavior is identical either
|
|
311
312
|
// way; only what a refusal calls itself changes.
|
|
313
|
+
//
|
|
314
|
+
// `conversation` (HQ-2823) is an id a hook or relay names so that separate calls, each in a fresh
|
|
315
|
+
// process with no `session` of its own, continue one section: its section state is loaded from and
|
|
316
|
+
// saved to lib/conversation.mjs, outside the record. Given, it is the conversation, in place of
|
|
317
|
+
// `session`. A chat app holds its session already and never sends it.
|
|
312
318
|
export function write(evda, args = {}, { now = new Date(), session = null, toolName = "evda_write" } = {}) {
|
|
313
|
-
onlyKeys(toolName, args, ["words", "context"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
|
|
319
|
+
onlyKeys(toolName, args, ["words", "context", "conversation"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
|
|
314
320
|
const words = text(args.words, "words", toolName);
|
|
315
321
|
refuseSecrets(words, toolName);
|
|
316
322
|
let context = null;
|
|
@@ -321,6 +327,10 @@ export function write(evda, args = {}, { now = new Date(), session = null, toolN
|
|
|
321
327
|
refuseSecrets(args.context, toolName);
|
|
322
328
|
context = args.context.trim();
|
|
323
329
|
}
|
|
330
|
+
const conversation = args.conversation;
|
|
331
|
+
if (conversation !== undefined && !isConversationId(conversation)) {
|
|
332
|
+
throw refuse(`${toolName} "conversation" is a short id a hook or relay sends so separate calls continue one section: letters, digits, "_" and "-" only, at most ${CONVERSATION_MAX} characters. A chat app leaves it out.`, "CONVERSATION");
|
|
333
|
+
}
|
|
324
334
|
const abs = join(evda.dir, todayPath(evda, { now }));
|
|
325
335
|
mkdirSync(dirname(abs), { recursive: true });
|
|
326
336
|
// The owner's day only: evda_write, and the tools that file the owner's message along with them.
|
|
@@ -328,11 +338,15 @@ export function write(evda, args = {}, { now = new Date(), session = null, toolN
|
|
|
328
338
|
if (toolName !== "evda_write_agent_log") {
|
|
329
339
|
const held = dayWriteHeld(abs);
|
|
330
340
|
if (held !== null) {
|
|
331
|
-
const q = queueToOutbox(abs, { now, words, context });
|
|
341
|
+
const q = queueToOutbox(abs, { now, words, context, conversation });
|
|
332
342
|
return { path: rel(evda, q), bytes: Buffer.byteLength(words), queued: true, held };
|
|
333
343
|
}
|
|
334
344
|
}
|
|
335
|
-
const
|
|
345
|
+
const conv = conversation === undefined ? null : loadConversation(conversation);
|
|
346
|
+
const w = appendToDay(abs, { now, words, context, session: conv ?? session });
|
|
347
|
+
// The words are in the day by now: a save that fails costs the next call a new heading, never a
|
|
348
|
+
// refusal that would have the caller send the same words again.
|
|
349
|
+
if (conv) { try { saveConversation(conversation, conv); } catch { /* see above */ } }
|
|
336
350
|
return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
|
|
337
351
|
}
|
|
338
352
|
|
|
@@ -354,10 +368,10 @@ function dayWriteHeld(abs) {
|
|
|
354
368
|
}
|
|
355
369
|
|
|
356
370
|
// Queue held words where the writer files them later (HQ-2596): one new JSON file per write in the
|
|
357
|
-
// daily folder's .outbox/, {tool, words, context, at, host}, named `<UTC timestamp>-<host>-evda_write.json`
|
|
371
|
+
// daily folder's .outbox/, {tool, words, context, at, host} and the caller's conversation when it named one, named `<UTC timestamp>-<host>-evda_write.json`
|
|
358
372
|
// (the shape the day-writer guard's refusal names, lib/day-writer.mjs OUTBOX_DIR). One file per write,
|
|
359
373
|
// so no two writers ever share one and no sync can drop either. Returns the file's absolute path.
|
|
360
|
-
function queueToOutbox(abs, { now, words, context }) {
|
|
374
|
+
function queueToOutbox(abs, { now, words, context, conversation }) {
|
|
361
375
|
const box = join(dirname(abs), ".outbox");
|
|
362
376
|
mkdirSync(box, { recursive: true });
|
|
363
377
|
const at = now.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
@@ -365,7 +379,7 @@ function queueToOutbox(abs, { now, words, context }) {
|
|
|
365
379
|
const stamp = at.replace(/[-:]/g, "").replace(/^(\d{8})T/, (_, d) => `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6)}T`);
|
|
366
380
|
let file = join(box, `${stamp}-${host}-evda_write.json`);
|
|
367
381
|
for (let n = 2; existsSync(file); n++) file = join(box, `${stamp}-${host}-evda_write-${n}.json`);
|
|
368
|
-
writeFileSync(file, JSON.stringify({ tool: "evda_write", words: String(words), context: context ?? "", at, host }, null, 1), { flag: "wx" });
|
|
382
|
+
writeFileSync(file, JSON.stringify({ tool: "evda_write", words: String(words), context: context ?? "", at, host, ...(conversation === undefined ? {} : { conversation }) }, null, 1), { flag: "wx" });
|
|
369
383
|
return file;
|
|
370
384
|
}
|
|
371
385
|
|