create-evda 0.1.11 → 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 +11 -0
- package/README.md +1 -1
- package/bin/evda.mjs +1 -1
- 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/doctor.mjs +10 -6
- package/lib/mcp.mjs +22 -8
- package/lib/plugin-hook.mjs +3 -1
- package/lib/prompt-hook.mjs +31 -8
- package/lib/tools.mjs +21 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ Plain-English notes on how Iris behaves differently after each version. Not a co
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
What the owner taught Iris now reaches every app: evda_operating_layer ends with the newest entries of the corrections file evda.json names (or of AGENTS.md's "What I have been taught" when a chat's size budget lists that section), the same on every surface, within the layer's budget, and only to a reader allowed to read that file.
|
|
8
|
+
|
|
9
|
+
On a machine that sends the owner's messages to another machine to file, one Claude Code session is one section of the day again, under one heading with one context line, instead of a new heading for every message: the prompt hook names its session with each send, and `evda_write` and `evda_write_agent_log` take that as `conversation` and keep its section between separate calls, in `~/.evda/conversations` (`EVDA_CONVERSATION_DIR`) outside the record (HQ-2823).
|
|
10
|
+
|
|
11
|
+
The Claude Code read guard now runs from a copy inside the record (`.claude/hooks/evda-guard/`), like the other two hooks, so it keeps working after the npx cache or checkout that installed it is gone; a reinstall replaces any older guard entry instead of adding a second one, and `evda doctor` fails when a registered guard's file is missing (HQ-2819).
|
|
12
|
+
|
|
13
|
+
The Claude Code prompt hook no longer files Claude Code's own text as the owner's words. A background
|
|
14
|
+
task's completion (a `<task-notification>` block) is not filed at all, and a `<system-reminder>`
|
|
15
|
+
block added to a real message is left out of what is filed; a message with neither stays byte for
|
|
16
|
+
byte (HQ-2823).
|
|
17
|
+
|
|
7
18
|
On a machine that does not write the day (`EVDA_DAY_WRITER` set), the Claude Code prompt hook no
|
|
8
19
|
longer appends to the local day file. It sends each message to the writer through the command in
|
|
9
20
|
`EVDA_DAY_WRITER_SEND` (evda_write's arguments as JSON on stdin), and a message the writer cannot take
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ The AI you choose reads what it needs to think with you. With training turned of
|
|
|
12
12
|
|
|
13
13
|
If you want more than that, you have two options. You can run a model locally on your own computer, and you can just ask how to set it up. Or you can say something is just to be noted, and no AI reads it until you say it can.
|
|
14
14
|
|
|
15
|
-
**Who reads what.** Setup writes the AI you chose into `evda.json` as `read_by`, so it reads the whole record. A circle can name its own reader: another AI, `local` for a model on your own hardware (`evda local-model` says how), or `none`. `evda doctor` prints "Read by" for every part. Say "just note this" and your agent files it with `noted: true` (`evda note` from a terminal); no AI reads it until you say it can (`evda release <path>`), and `evda search` still finds it on your device. The MCP server refuses both in code, `hooks/evda-read-guard.mjs` (
|
|
15
|
+
**Who reads what.** Setup writes the AI you chose into `evda.json` as `read_by`, so it reads the whole record. A circle can name its own reader: another AI, `local` for a model on your own hardware (`evda local-model` says how), or `none`. `evda doctor` prints "Read by" for every part. Say "just note this" and your agent files it with `noted: true` (`evda note` from a terminal); no AI reads it until you say it can (`evda release <path>`), and `evda search` still finds it on your device. The MCP server refuses both in code, `hooks/evda-read-guard.mjs` (copied into the record's `.claude/hooks/evda-guard/` for Claude owners) stops Claude Code's Read, Grep and shell reads of them, and `AGENTS.md` tells every other agent to decline out loud. None of this is a lock: the words are plain files on your disk, and it stops an agent that checks.
|
|
16
16
|
|
|
17
17
|
EVÐA prescribes no values and no rules. The only requirement is that you want to, and you are open to it. It is built on a neo-Aristotelian philosophy; ask your agent about it, or have any agent verify it by reading this code.
|
|
18
18
|
|
package/bin/evda.mjs
CHANGED
|
@@ -554,7 +554,7 @@ async function main(argv) {
|
|
|
554
554
|
const ph = hooks.promptHookStatus(dir);
|
|
555
555
|
console.log(ph.registered ? (ph.file ? (ph.current ? `prompt hook installed and current: ${hooks.SETTINGS_REL} runs ${hooks.PROMPT_HOOK_REL}` : "prompt hook installed, older than the shipped hook: run `evda hook install`") : `prompt hook registered, but ${hooks.PROMPT_HOOK_REL} is missing: run \`evda hook install\``) : "prompt hook not installed (it is only for an owner who chose to file everything)");
|
|
556
556
|
const g = guardStatus(dir);
|
|
557
|
-
console.log(g.registered ? (g.file ? `read guard installed: ${hooks.SETTINGS_REL} runs ${g.target ?? "evda guard through npx"}` : `read guard registered, but ${g.
|
|
557
|
+
console.log(g.registered ? (g.file ? `read guard installed: ${hooks.SETTINGS_REL} runs ${g.target ?? "evda guard through npx"}` : `read guard registered, but ${g.missing.join(", ")} is missing: run \`evda hook install\``) : "read guard not installed: run `evda hook install`");
|
|
558
558
|
return st.registered && st.file && st.current && g.registered && g.file && (!ph.registered || (ph.file && ph.current)) ? 0 : 1;
|
|
559
559
|
}
|
|
560
560
|
|
package/lib/connect.mjs
CHANGED
|
@@ -34,50 +34,114 @@ export function serverCommand(recordDir, { execPath = process.execPath, bin = jo
|
|
|
34
34
|
return { command: stableNode(execPath, env), args: [real, "mcp", dir] };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// The
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
// The read guard (lib/guard.mjs) runs from a copy inside the record, like the keep-record and prompt
|
|
38
|
+
// hooks (lib/hooks.mjs), never from the package that installed it (HQ-2819). An absolute path into
|
|
39
|
+
// the installer dies with it: the npx cache npm clears, a checkout or worktree that is removed. The
|
|
40
|
+
// npx form (`npx -y -p create-evda@latest evda guard`) outlives the installer but asks the registry
|
|
41
|
+
// before every Read, and an offline laptop then runs no guard, so it is not written either. The copy
|
|
42
|
+
// is the entry point plus every lib/ file it imports, found by walking the imports at install time,
|
|
43
|
+
// so a new import in lib/guard.mjs is copied without anyone listing it.
|
|
44
|
+
export const GUARD_DIR_REL = ".claude/hooks/evda-guard";
|
|
45
|
+
export const GUARD_REL = `${GUARD_DIR_REL}/evda-read-guard.mjs`;
|
|
46
|
+
export const GUARD_COMMAND = `node "$CLAUDE_PROJECT_DIR/${GUARD_REL}"`;
|
|
47
|
+
export const GUARD_MATCHER = "Read|Grep|Bash|NotebookRead";
|
|
48
|
+
const SHIPPED_GUARD = join(PACKAGE_ROOT, "hooks", "evda-read-guard.mjs");
|
|
49
|
+
const LIB_DIR = join(PACKAGE_ROOT, "lib");
|
|
50
|
+
|
|
51
|
+
// Every file the record's copy of the guard holds, as [path in the record, bytes]. The entry point
|
|
52
|
+
// is the shipped one with its one import pointed at the lib/ copied beside it; the lib files are
|
|
53
|
+
// byte for byte. Throws if the shipped entry no longer imports ../lib/guard.mjs, so a change there
|
|
54
|
+
// cannot install a copy that fails at load.
|
|
55
|
+
export function guardFiles() {
|
|
56
|
+
const entry = readFileSync(SHIPPED_GUARD, "utf8");
|
|
57
|
+
const IMPORT = 'from "../lib/guard.mjs"';
|
|
58
|
+
if (!entry.includes(IMPORT)) throw new Error(`${SHIPPED_GUARD} no longer imports ../lib/guard.mjs, so the guard copy cannot be built`);
|
|
59
|
+
const files = [[GUARD_REL, Buffer.from(entry.replace(IMPORT, 'from "./lib/guard.mjs"'))]];
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const walk = (name) => {
|
|
62
|
+
if (seen.has(name)) return;
|
|
63
|
+
seen.add(name);
|
|
64
|
+
const bytes = readFileSync(join(LIB_DIR, name));
|
|
65
|
+
files.push([`${GUARD_DIR_REL}/lib/${name}`, bytes]);
|
|
66
|
+
for (const m of bytes.toString("utf8").matchAll(/\bfrom\s*["']\.\/([^"']+)["']|\bimport\s*\(?\s*["']\.\/([^"']+)["']/g)) walk(m[1] ?? m[2]);
|
|
67
|
+
};
|
|
68
|
+
walk("guard.mjs");
|
|
69
|
+
return files;
|
|
45
70
|
}
|
|
46
71
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
// The settings commands that run the guard, in any form an installer ever wrote: this copy, an
|
|
73
|
+
// absolute path to some package's hooks/evda-read-guard.mjs, or the npx form.
|
|
74
|
+
export const GUARD_MARK = /evda-read-guard\.mjs|\bevda guard\b/;
|
|
75
|
+
const isGuard = (h) => typeof h?.command === "string" && GUARD_MARK.test(h.command);
|
|
76
|
+
|
|
77
|
+
// Install the read guard into one record: copy its files into .claude/hooks/evda-guard/ and make
|
|
78
|
+
// .claude/settings.json run that copy. Every guard entry already there (a stale absolute path, the
|
|
79
|
+
// npx form, a duplicate from a second install) is removed, so exactly one remains. Nothing else in
|
|
80
|
+
// the settings file is touched, and a file that already says exactly this is not rewritten.
|
|
81
|
+
export function installGuard(recordDir) {
|
|
82
|
+
const dir = resolve(recordDir);
|
|
83
|
+
const path = join(dir, ".claude", "settings.json");
|
|
52
84
|
let settings = {};
|
|
53
85
|
if (existsSync(path)) {
|
|
54
86
|
try { settings = JSON.parse(readFileSync(path, "utf8")); } catch { return { path, action: "left alone (not valid JSON):" }; }
|
|
87
|
+
if (settings === null || typeof settings !== "object" || Array.isArray(settings)) return { path, action: "left alone (not a JSON object):" };
|
|
88
|
+
}
|
|
89
|
+
let copied = false;
|
|
90
|
+
for (const [rel, bytes] of guardFiles()) {
|
|
91
|
+
const abs = join(dir, rel);
|
|
92
|
+
if (existsSync(abs) && readFileSync(abs).equals(bytes)) continue;
|
|
93
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
94
|
+
writeFileSync(abs, bytes);
|
|
95
|
+
copied = true;
|
|
55
96
|
}
|
|
56
|
-
const pre = (settings.hooks
|
|
57
|
-
|
|
58
|
-
|
|
97
|
+
const pre = Array.isArray(settings.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
98
|
+
const found = pre.flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []).filter(isGuard).map((h) => h.command));
|
|
99
|
+
if (found.length === 1 && found[0] === GUARD_COMMAND) return { path, action: copied ? "updated the guard copy:" : "already installed:" };
|
|
100
|
+
const kept = pre
|
|
101
|
+
.map((m) => (Array.isArray(m?.hooks) && m.hooks.some(isGuard) ? { ...m, hooks: m.hooks.filter((h) => !isGuard(h)) } : m))
|
|
102
|
+
.filter((m) => !Array.isArray(m?.hooks) || m.hooks.length);
|
|
103
|
+
kept.push({ matcher: GUARD_MATCHER, hooks: [{ type: "command", command: GUARD_COMMAND }] });
|
|
104
|
+
if (settings.hooks === null || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) settings.hooks = {};
|
|
105
|
+
settings.hooks.PreToolUse = kept;
|
|
59
106
|
mkdirSync(dirname(path), { recursive: true });
|
|
60
107
|
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
|
|
61
|
-
return { path, action: "installed" };
|
|
108
|
+
return { path, action: found.length ? `replaced ${found.length} older guard ${found.length === 1 ? "entry" : "entries"}:` : "installed" };
|
|
62
109
|
}
|
|
63
110
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
111
|
+
// The hook file one guard command runs, resolved against the record ($CLAUDE_PROJECT_DIR is the
|
|
112
|
+
// record, since the guard is registered in the record's own settings). Null for the npx form.
|
|
113
|
+
export function guardTarget(command, recordDir) {
|
|
114
|
+
const dir = resolve(recordDir);
|
|
115
|
+
const m = /"([^"]*evda-read-guard\.mjs)"|'([^']*evda-read-guard\.mjs)'|(\S*evda-read-guard\.mjs)/.exec(command);
|
|
116
|
+
if (!m) return null;
|
|
117
|
+
return resolve(dir, (m[1] ?? m[2] ?? m[3]).replace(/\$\{CLAUDE_PROJECT_DIR\}|\$CLAUDE_PROJECT_DIR/g, () => dir));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Whether one record's .claude/settings.json runs the read guard, for doctor (HQ-1377, HQ-2819):
|
|
121
|
+
// { registered, target, file, missing, count, current, problem }. `target` is the hook file the
|
|
122
|
+
// first entry names, `missing` every registered file not on disk (`file` is true when there is
|
|
123
|
+
// none), `count` how many guard entries there are, `current` whether the record's copy matches what
|
|
124
|
+
// ships. The npx form names no file and is present whenever it is registered. Never throws.
|
|
68
125
|
// EVDA_HOOKS_FALSIFY=blind reports no guard at all, for the test that shows doctor's line is read
|
|
69
126
|
// from the settings file.
|
|
70
127
|
export function guardStatus(recordDir) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
128
|
+
const none = { registered: false, target: null, file: false, missing: [], count: 0, current: false, problem: null };
|
|
129
|
+
if ((process.env.EVDA_HOOKS_FALSIFY ?? "").split(",").map((x) => x.trim()).includes("blind")) return none;
|
|
130
|
+
const dir = resolve(recordDir);
|
|
131
|
+
const path = join(dir, ".claude", "settings.json");
|
|
132
|
+
if (!existsSync(path)) return none;
|
|
74
133
|
let settings;
|
|
75
|
-
try { settings = JSON.parse(readFileSync(path, "utf8")); } catch (e) { return {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
134
|
+
try { settings = JSON.parse(readFileSync(path, "utf8")); } catch (e) { return { ...none, problem: `.claude/settings.json is not valid JSON (${e.message}), so no hook in it runs.` }; }
|
|
135
|
+
const pre = settings?.hooks?.PreToolUse;
|
|
136
|
+
const commands = (Array.isArray(pre) ? pre : []).flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []).filter(isGuard).map((h) => h.command));
|
|
137
|
+
if (!commands.length) return none;
|
|
138
|
+
const targets = commands.map((c) => guardTarget(c, dir));
|
|
139
|
+
const missing = targets.filter((t) => t !== null && !existsSync(t));
|
|
140
|
+
let current = true;
|
|
141
|
+
if (targets.includes(join(dir, GUARD_REL))) {
|
|
142
|
+
try { current = guardFiles().every(([rel, bytes]) => existsSync(join(dir, rel)) && readFileSync(join(dir, rel)).equals(bytes)); } catch { current = false; }
|
|
143
|
+
}
|
|
144
|
+
return { registered: true, target: targets[0], file: missing.length === 0, missing, count: commands.length, current, problem: null };
|
|
81
145
|
}
|
|
82
146
|
|
|
83
147
|
// A `node` on PATH that is this same binary, when there is one: Homebrew's /opt/homebrew/bin/node
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// A conversation's section state kept between separate evda_write calls (HQ-2823).
|
|
2
|
+
//
|
|
3
|
+
// appendToDay (lib/day-file.mjs) keeps one conversation in one section through a `session` object,
|
|
4
|
+
// and an MCP process holds that object in memory. A relay that spawns a fresh `evda mcp` for every
|
|
5
|
+
// message (Hestia's evda-tool.mjs, which the prompt hook on a non-writer laptop sends through) has
|
|
6
|
+
// no memory between calls, so on 2026-10-22 every message the owner sent from his laptop opened
|
|
7
|
+
// its own `## HH:MM`. A caller that names its conversation (evda_write's `conversation`) gets the
|
|
8
|
+
// section state loaded from and saved to one small file here instead.
|
|
9
|
+
//
|
|
10
|
+
// Where: EVDA_CONVERSATION_DIR, default ~/.evda/conversations: outside the record and outside any
|
|
11
|
+
// synced folder, the way the prompt outbox is (lib/prompt-hook.mjs promptOutboxDir). What: the
|
|
12
|
+
// `sections` appendToDay keeps, which hold the section's own text so the next write can find it.
|
|
13
|
+
// That is the owner's words outside the record, so the folder is the owner's alone (0700) and a
|
|
14
|
+
// file not written for CONVERSATION_KEEP_MS is deleted on the next save. A section lives in one
|
|
15
|
+
// day's file, so a conversation older than that has nothing left to continue. Age is the
|
|
16
|
+
// `savedAt` written inside each file by the same clock that judges it, never the file's mtime:
|
|
17
|
+
// a clock the OS does not share (a shifted test clock, a restored backup) would otherwise read a
|
|
18
|
+
// file saved a moment ago as old and delete a live conversation.
|
|
19
|
+
|
|
20
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { isConversationId } from "./day-file.mjs";
|
|
24
|
+
|
|
25
|
+
export const CONVERSATION_DIR_ENV = "EVDA_CONVERSATION_DIR";
|
|
26
|
+
export const CONVERSATION_KEEP_MS = 2 * 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
export function conversationDir(env = process.env) {
|
|
29
|
+
return env[CONVERSATION_DIR_ENV] || join(homedir(), ".evda", "conversations");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fileOf = (id, env) => join(conversationDir(env), `${id}.json`);
|
|
33
|
+
|
|
34
|
+
// The session object appendToDay takes for conversation `id`: { sections } as last saved, or {}.
|
|
35
|
+
export function loadConversation(id, { env = process.env } = {}) {
|
|
36
|
+
if (!isConversationId(id)) throw new Error(`not a conversation id: ${JSON.stringify(id)}`);
|
|
37
|
+
try {
|
|
38
|
+
const s = JSON.parse(readFileSync(fileOf(id, env), "utf8"));
|
|
39
|
+
return s && typeof s.sections === "object" && s.sections ? { sections: s.sections } : {};
|
|
40
|
+
} catch { return {}; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Save it (written whole, then renamed into place, so a reader never sees half a file), and delete
|
|
44
|
+
// every conversation not saved for CONVERSATION_KEEP_MS.
|
|
45
|
+
export function saveConversation(id, session, { env = process.env, nowMs = Date.now() } = {}) {
|
|
46
|
+
if (!isConversationId(id)) throw new Error(`not a conversation id: ${JSON.stringify(id)}`);
|
|
47
|
+
const dir = conversationDir(env);
|
|
48
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
49
|
+
const abs = fileOf(id, env);
|
|
50
|
+
const part = `${abs}.${process.pid}.part`;
|
|
51
|
+
writeFileSync(part, JSON.stringify({ sections: session.sections ?? {}, savedAt: nowMs }) + "\n", { mode: 0o600 });
|
|
52
|
+
renameSync(part, abs);
|
|
53
|
+
for (const n of readdirSync(dir)) {
|
|
54
|
+
const f = join(dir, n);
|
|
55
|
+
if (f === abs) continue;
|
|
56
|
+
try { if (nowMs - savedAtOf(f) > CONVERSATION_KEEP_MS) unlinkSync(f); } catch { /* gone already */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// When `f` was saved: its own savedAt, or its mtime for a file written without one (a leftover
|
|
61
|
+
// .part, a file from before savedAt existed).
|
|
62
|
+
function savedAtOf(f) {
|
|
63
|
+
try {
|
|
64
|
+
const t = JSON.parse(readFileSync(f, "utf8")).savedAt;
|
|
65
|
+
if (Number.isFinite(t)) return t;
|
|
66
|
+
} catch { /* not a whole conversation file */ }
|
|
67
|
+
return statSync(f).mtimeMs;
|
|
68
|
+
}
|
package/lib/corrections.mjs
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
// every correction from a chat app was refused and lost. Doctor reads the same setting through this
|
|
9
9
|
// one function, so the tool and the check cannot disagree on what a valid setting is.
|
|
10
10
|
|
|
11
|
-
import { existsSync, statSync } from "node:fs";
|
|
12
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
11
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
13
13
|
import { isSealed } from "./circles.mjs";
|
|
14
|
+
import { readContext, mayRead, aiView } from "./readers.mjs";
|
|
15
|
+
import { datalessAmong } from "./sync-rules.mjs";
|
|
14
16
|
|
|
15
17
|
export const CORRECTIONS_KEY = "corrections";
|
|
16
18
|
|
|
@@ -32,3 +34,76 @@ export function correctionsTarget(evda) {
|
|
|
32
34
|
if (isSealed(abs)) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, which is sealed, so no agent writes a correction into it.` };
|
|
33
35
|
return { rel, abs, problem: null };
|
|
34
36
|
}
|
|
37
|
+
|
|
38
|
+
// What the owner taught the agent, served in the operating layer (HQ-2818). A chat app learns who
|
|
39
|
+
// it is only from evda_operating_layer, so a correction written to a separate corrections file was
|
|
40
|
+
// recorded and never read back on any chat surface. The layer now carries the newest entries, the
|
|
41
|
+
// same selection on every surface, so a correction made in one app reaches the next chat in any.
|
|
42
|
+
//
|
|
43
|
+
// The size is fixed, not the whole file: his file measured 671,346 characters and 445 sections on
|
|
44
|
+
// 2026-10-22, ten times a chat's whole budget. Entries are the file's `## ` sections in file order
|
|
45
|
+
// (evda_record_correction appends, so the last is the newest); the newest are kept whole while they
|
|
46
|
+
// fit, and the block says how many older ones the file holds and how to find one.
|
|
47
|
+
export const CORRECTIONS_LAYER_MAX = 8_000;
|
|
48
|
+
export const TAUGHT_HEADING = "What I have been taught";
|
|
49
|
+
|
|
50
|
+
// A corrections file's entries: its `## ` sections, in file order. The text before the first one is
|
|
51
|
+
// the file's own preamble, not an entry.
|
|
52
|
+
export function fileEntries(text) {
|
|
53
|
+
return text.split(/^(?=## )/m).filter((sec) => sec.startsWith("## ")).map((sec) => sec.replace(/\s+$/, ""));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The "What I have been taught" section's entries: its `- ` lines, each with any lines under it.
|
|
57
|
+
export function sectionEntries(sec) {
|
|
58
|
+
const body = sec.split("\n").slice(1).join("\n");
|
|
59
|
+
return body.split(/^(?=- )/m).filter((e) => e.startsWith("- ")).map((e) => e.replace(/\s+$/, ""));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The newest entries that fit in `max` characters joined by blank lines, oldest of them first.
|
|
63
|
+
// When even the newest is longer, it is cut at a line and says where the rest is.
|
|
64
|
+
export function newestEntries(entries, max, where) {
|
|
65
|
+
const kept = [];
|
|
66
|
+
let size = 0;
|
|
67
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
68
|
+
const grow = entries[i].length + (kept.length ? 2 : 0);
|
|
69
|
+
if (size + grow > max) break;
|
|
70
|
+
kept.unshift(entries[i]);
|
|
71
|
+
size += grow;
|
|
72
|
+
}
|
|
73
|
+
if (!kept.length && entries.length) {
|
|
74
|
+
const tail = `\n(This entry is cut here; it continues in ${where}.)`;
|
|
75
|
+
const e = entries[entries.length - 1];
|
|
76
|
+
let cut = e.slice(0, Math.max(0, max - tail.length));
|
|
77
|
+
const at = cut.lastIndexOf("\n");
|
|
78
|
+
if (at > 0) cut = cut.slice(0, at);
|
|
79
|
+
return { kept: [cut + tail], older: entries.length - 1 };
|
|
80
|
+
}
|
|
81
|
+
return { kept, older: entries.length - kept.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function block(path, entries, max) {
|
|
85
|
+
if (!entries.length) return null;
|
|
86
|
+
const { kept, older } = newestEntries(entries, max, path);
|
|
87
|
+
const more = older ? ` ${older} older ${older === 1 ? "entry is" : "entries are"} in ${path}; evda_search finds one by its words.` : "";
|
|
88
|
+
const head = `## ${TAUGHT_HEADING} (from ${path})\n\nWhat the owner corrected you on, newest last, word for word as ${path} keeps it: how they want you to work, learned from real misses.${more}`;
|
|
89
|
+
return { text: `${head}\n\n${kept.join("\n\n")}\n`, path, served: kept.length, total: entries.length };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The corrections block for the layer, or null when there is nothing to add. `caller` is who reads
|
|
93
|
+
// the layer: a file this caller may not read (sealed, noted, or a read_by that is not theirs) is not
|
|
94
|
+
// served, by the same mayRead every read tool uses, and a sealed section inside it is never shown.
|
|
95
|
+
// A file still in the cloud is not read, since reading it would start a download.
|
|
96
|
+
//
|
|
97
|
+
// With no "corrections" setting the entries live in AGENTS.md's own section, which the layer already
|
|
98
|
+
// serves, so nothing is added; `taughtListed` is that section's text when a chat's budget listed it
|
|
99
|
+
// by heading instead, and then its newest lines are served here so they still reach the chat.
|
|
100
|
+
export function correctionsForLayer(evda, caller, { max = CORRECTIONS_LAYER_MAX, taughtListed = null, agentsPath = "AGENTS.md" } = {}) {
|
|
101
|
+
const target = correctionsTarget(evda);
|
|
102
|
+
if (!target) return taughtListed ? block(agentsPath, sectionEntries(taughtListed), max) : null;
|
|
103
|
+
if (target.problem) return null;
|
|
104
|
+
if (!mayRead(readContext(evda), target.abs, caller).ok) return null;
|
|
105
|
+
if (datalessAmong(dirname(target.abs), [basename(target.abs)]).size) return null;
|
|
106
|
+
let raw;
|
|
107
|
+
try { raw = readFileSync(target.abs, "utf8"); } catch { return null; }
|
|
108
|
+
return block(target.rel, fileEntries(aiView(raw).text), max);
|
|
109
|
+
}
|
package/lib/day-file.mjs
CHANGED
|
@@ -61,6 +61,13 @@ const pad = (n) => String(n).padStart(2, "0");
|
|
|
61
61
|
export const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
62
62
|
export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
63
63
|
|
|
64
|
+
// A conversation id a caller names so that separate evda_write calls continue one section (HQ-2823):
|
|
65
|
+
// the prompt hook sends Claude Code's session_id. Short and opaque, letters, digits, `_` and `-`
|
|
66
|
+
// only, because the writer names a state file after it. Checked here so the hook and evda_write
|
|
67
|
+
// read one rule.
|
|
68
|
+
export const CONVERSATION_MAX = 128;
|
|
69
|
+
export const isConversationId = (v) => typeof v === "string" && v.length <= CONVERSATION_MAX && /^[A-Za-z0-9_-]+$/.test(v);
|
|
70
|
+
|
|
64
71
|
export const contextLine = (context) => `*${CONTEXT_LABEL} ${String(context).trim().replace(/\*/g, "")}*`;
|
|
65
72
|
|
|
66
73
|
// NAMED ENTRIES (HQ-2799). A write tool's answer names the existing entries the filed words bear on
|
package/lib/doctor.mjs
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
import { checkoutFreshness } from "./checkout-freshness.mjs";
|
|
50
50
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
51
|
-
import { join, relative, resolve, sep } from "node:path";
|
|
51
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
52
52
|
import { readManifest, checkVersion, unknownKeys, unknownLayers, holderOf, holderProblems, KNOWN_LAYERS, SCHEMA_VERSION } from "./manifest.mjs";
|
|
53
53
|
import { readGrants } from "./grants.mjs";
|
|
54
54
|
import { readDevices } from "./devices.mjs";
|
|
@@ -77,7 +77,7 @@ import { FILING_MODES, filingOf } from "./day-file.mjs";
|
|
|
77
77
|
import { correctionsTarget } from "./corrections.mjs";
|
|
78
78
|
import { EVERY_CHAT_KEY } from "./every-chat.mjs";
|
|
79
79
|
import { listSkills, SKILLS_REL } from "./skills.mjs";
|
|
80
|
-
import { guardStatus } from "./connect.mjs";
|
|
80
|
+
import { guardStatus, GUARD_DIR_REL, GUARD_REL } from "./connect.mjs";
|
|
81
81
|
import { readmeCitations, missingHeadings, README_REL } from "./readme.mjs";
|
|
82
82
|
|
|
83
83
|
// The dated sections of a person-profile that no observation covers (HQ-1397). An observation
|
|
@@ -322,12 +322,16 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0, ch
|
|
|
322
322
|
|
|
323
323
|
// 5c. the read guard (HQ-1374), the other hook create-evda installs for a Claude owner (HQ-1377):
|
|
324
324
|
// a PreToolUse hook in the record's own settings that stops a Claude Code session reading a noted
|
|
325
|
-
// entry.
|
|
326
|
-
//
|
|
325
|
+
// entry. A registered guard whose file is gone fails (HQ-2819): every read then runs a command
|
|
326
|
+
// that is not there, Claude Code lets the read through, and the owner was told it is guarded. The
|
|
327
|
+
// usual cause is an install that pointed into an npx cache or a checkout since removed.
|
|
327
328
|
const guard = guardStatus(dir);
|
|
329
|
+
const posixRel = (root, abs) => { const r = relative(resolve(root), abs); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(sep).join("/") : abs; };
|
|
328
330
|
if (guard.problem) warn(SETTINGS_REL, guard.problem);
|
|
329
|
-
else if (guard.registered && !guard.file)
|
|
330
|
-
else if (guard.registered)
|
|
331
|
+
else if (guard.registered && !guard.file) fail(SETTINGS_REL, `registers the read guard, but ${guard.missing.join(" and ")} ${guard.missing.length === 1 ? "is" : "are"} missing, so Claude Code cannot run it and a noted entry is not held back there. \`evda hook install\` copies the guard into ${GUARD_DIR_REL}/ and replaces the entry.`);
|
|
332
|
+
else if (guard.registered && guard.count > 1) warn(SETTINGS_REL, `registers the read guard ${guard.count} times. \`evda hook install\` leaves one.`);
|
|
333
|
+
else if (guard.registered && !guard.current) warn(GUARD_REL, "or a file copied beside it differs from what this tooling ships. `evda hook install` updates them.");
|
|
334
|
+
else if (guard.registered) ok(1, "read guard", `${SETTINGS_REL} runs ${guard.target ? posixRel(dir, guard.target) : "the evda guard through npx"} before Read, Grep and shell reads`);
|
|
331
335
|
else skip("read guard", `not installed. A Claude Code session here is not stopped before reading a just-noted entry (the MCP server still holds it back); \`evda hook install\` adds it to ${SETTINGS_REL}.`);
|
|
332
336
|
|
|
333
337
|
// 6. record files
|
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/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/prompt-hook.mjs
CHANGED
|
@@ -48,7 +48,7 @@ import { dirname, join, relative } from "node:path";
|
|
|
48
48
|
import { homedir, hostname, tmpdir } from "node:os";
|
|
49
49
|
import { spawnSync } from "node:child_process";
|
|
50
50
|
import { fileURLToPath } from "node:url";
|
|
51
|
-
import { dayRole, findRecord, filingOf, appendToDay, localDate } from "./day-file.mjs";
|
|
51
|
+
import { dayRole, findRecord, filingOf, appendToDay, localDate, isConversationId } from "./day-file.mjs";
|
|
52
52
|
import { secretShape } from "./secret-shape.mjs";
|
|
53
53
|
|
|
54
54
|
export const PROMPT_HOOK_NAME = "evda-file-prompt.mjs";
|
|
@@ -67,14 +67,16 @@ export function promptOutboxDir(env = process.env) {
|
|
|
67
67
|
const realOr = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
68
68
|
|
|
69
69
|
// Save one message for the writer: a new file per message, never rewritten, in this machine's outbox.
|
|
70
|
-
|
|
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 }) {
|
|
71
73
|
const dir = promptOutboxDir(env);
|
|
72
74
|
mkdirSync(dir, { recursive: true });
|
|
73
75
|
const at = now.toISOString();
|
|
74
76
|
const stamp = at.replace(/[-:.]/g, "");
|
|
75
77
|
let file = join(dir, `${stamp}-${process.pid}.json`);
|
|
76
78
|
for (let n = 2; existsSync(file); n++) file = join(dir, `${stamp}-${process.pid}-${n}.json`);
|
|
77
|
-
writeFileSync(file, JSON.stringify({ tool: "evda_write", record: realOr(root), words, context, at, host: hostname().split(".")[0] || "host" }, null, 1) + "\n", { flag: "wx" });
|
|
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" });
|
|
78
80
|
return file;
|
|
79
81
|
}
|
|
80
82
|
|
|
@@ -104,7 +106,7 @@ export function drainPromptOutbox(root, { env = process.env, budgetMs = Number(e
|
|
|
104
106
|
let entry;
|
|
105
107
|
try { entry = JSON.parse(readFileSync(claim, "utf8")); } catch { renameSync(claim, abs); continue; }
|
|
106
108
|
const left = deadline - Date.now();
|
|
107
|
-
const args = { words: entry.words, ...(entry.context ? { context: entry.context } : {}) };
|
|
109
|
+
const args = { words: entry.words, ...(entry.context ? { context: entry.context } : {}), ...(isConversationId(entry.conversation) ? { conversation: entry.conversation } : {}) };
|
|
108
110
|
const r = left > 200 ? spawnSync(cmd, { shell: true, input: JSON.stringify(args), encoding: "utf8", timeout: left, env }) : { status: null, error: { code: "ETIMEDOUT" } };
|
|
109
111
|
if (r.status === 0) { unlinkSync(claim); out.sent.push(abs); continue; }
|
|
110
112
|
try { renameSync(claim, abs); } catch { /* kept as a claim; the stale rule puts it back */ }
|
|
@@ -118,12 +120,20 @@ export function drainPromptOutbox(root, { env = process.env, budgetMs = Number(e
|
|
|
118
120
|
const timeOf = (d) => `${localDate(d)} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
119
121
|
|
|
120
122
|
// The non-writer path: queue, send, and say what happened. Never touches the day file.
|
|
121
|
-
|
|
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) {
|
|
122
131
|
const writer = env.EVDA_DAY_WRITER.trim();
|
|
123
132
|
if (!(env[DAY_WRITER_SEND_ENV] ?? "").trim()) {
|
|
124
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.`;
|
|
125
134
|
}
|
|
126
|
-
const
|
|
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 });
|
|
127
137
|
const r = drainPromptOutbox(root, { env });
|
|
128
138
|
if (r.sent.includes(file)) {
|
|
129
139
|
const more = r.sent.length > 1 ? ` (with ${r.sent.length - 1} message(s) queued earlier)` : "";
|
|
@@ -147,10 +157,23 @@ function markFiled(sid, now) {
|
|
|
147
157
|
writeFileSync(sf, JSON.stringify({ ...session, filedAt: now.toISOString() }) + "\n");
|
|
148
158
|
}
|
|
149
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
|
+
|
|
150
173
|
// One UserPromptSubmit event in, the hook's stdout out ("" for nothing to say). Never throws.
|
|
151
174
|
export function run(payload, { now = new Date(), env = process.env } = {}) {
|
|
152
175
|
try {
|
|
153
|
-
const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
|
|
176
|
+
const prompt = ownerWords(typeof payload?.prompt === "string" ? payload.prompt : "");
|
|
154
177
|
if (!prompt.trim() || /^\s*\//.test(prompt)) return "";
|
|
155
178
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
156
179
|
const root = findRecord(payload.cwd || process.cwd()) ?? findRecord(here);
|
|
@@ -162,7 +185,7 @@ export function run(payload, { now = new Date(), env = process.env } = {}) {
|
|
|
162
185
|
const shape = prompt.split("\n").map(secretShape).find(Boolean);
|
|
163
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.`;
|
|
164
187
|
if ((env.EVDA_DAY_WRITER ?? "").trim()) {
|
|
165
|
-
const said = viaWriter(root, prompt, now, env);
|
|
188
|
+
const said = viaWriter(root, prompt, now, env, payload.session_id);
|
|
166
189
|
if ((env[DAY_WRITER_SEND_ENV] ?? "").trim()) markFiled(payload.session_id, now); // sent or queued to send
|
|
167
190
|
return said;
|
|
168
191
|
}
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-evda",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The person layer over the substrate: scaffold and operate an EVDA, a directory that knows what it is. Plain files are the truth, the tooling is optional, and there are no runtime dependencies.",
|
|
6
6
|
"bin": {
|