create-evda 0.1.8 → 0.1.9
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 +37 -1
- package/README.md +8 -2
- package/bin/evda.mjs +157 -4
- package/create-evda.mjs +6 -1
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +18 -2
- package/lib/connect.mjs +8 -1
- package/lib/day-file.mjs +58 -9
- package/lib/day-writer.mjs +103 -0
- package/lib/doctor.mjs +13 -0
- package/lib/every-chat.mjs +132 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +44 -3
- package/lib/gather.mjs +3 -1
- package/lib/keychain.mjs +144 -0
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +24 -4
- package/lib/mcp.mjs +893 -73
- package/lib/measurement-log.mjs +246 -0
- package/lib/messages.mjs +5 -2
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +20 -0
- package/lib/routines.mjs +42 -3
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/tools.mjs +423 -24
- package/lib/watch.mjs +160 -0
- package/package.json +1 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +25 -4
- package/templates/evda-digest.md +3 -1
package/lib/day-file.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// EVDA_CHAT_FALSIFY=heading-per-write opens a new heading on every write, for the test that shows
|
|
23
23
|
// one conversation is one section.
|
|
24
24
|
|
|
25
|
-
import { appendFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
25
|
+
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
26
26
|
import { dirname, join, resolve } from "node:path";
|
|
27
27
|
|
|
28
28
|
export const FILING_MODES = ["everything", "asked"];
|
|
@@ -77,17 +77,66 @@ export function sectionBytes({ current, date, time, words, context = null, conti
|
|
|
77
77
|
return { text: `${lead}${head}\n## ${time}\n\n${ctx}${body}\n`, opened: true };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// EVDA_DAY_LOCK_PATH (HQ-2165): when set, every append below takes this lock first, with the same
|
|
81
|
+
// protocol Hestia's own journal-append.mjs takes through /usr/bin/shlock on the same path: the file
|
|
82
|
+
// is created exclusively and holds the holder's pid and a newline (shlock's own format, so each side
|
|
83
|
+
// sees the other's lock as held), it is released by deleting it, and a lock older than 120s is
|
|
84
|
+
// broken (a journal append is sub-second, so anything older is a dead process, not contention).
|
|
85
|
+
// Done in node rather than by running shlock, which exists only on macOS: on Linux or Windows every
|
|
86
|
+
// write with the lock set would wait 8s and fail.
|
|
87
|
+
//
|
|
88
|
+
// This exists because two processes on ONE host -- Hestia's passes (journal-append.mjs, writing the
|
|
89
|
+
// pointer line and an agent's record with a read-the-whole-file-then-writeFileSync-the-whole-file
|
|
90
|
+
// step) and `evda mcp`'s own evda_write (pure appendFileSync here) -- both touch the same local file
|
|
91
|
+
// with no shared lock today. appendFileSync alone is atomic, so two appends never corrupt each
|
|
92
|
+
// other; the loss happens when a read-modify-write like journal-append.mjs's runs with a stale read
|
|
93
|
+
// that predates this function's append, and then overwrites the whole file including the words this
|
|
94
|
+
// function just added. Hestia's evda-mcp-run.sh sets this to the same ~/.hestia/journal.lock its
|
|
95
|
+
// passes already use, so the two processes serialize against each other.
|
|
96
|
+
//
|
|
97
|
+
// NOT a fix for two different HOSTS: a lock file inside the iCloud-synced vault cannot coordinate
|
|
98
|
+
// two machines, because acquiring it is itself a write subject to the exact same sync delay as the
|
|
99
|
+
// content it is meant to protect. That class has no file-level fix; see HQ-2165 and
|
|
100
|
+
// test/single-writer.test.mjs. This lock only ever runs on one host.
|
|
101
|
+
function takeLock(lockPath) {
|
|
102
|
+
let fd;
|
|
103
|
+
try { fd = openSync(lockPath, "wx"); } catch (e) { if (e.code === "EEXIST") return false; throw e; }
|
|
104
|
+
try { writeSync(fd, `${process.pid}\n`); } finally { closeSync(fd); }
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
109
|
+
|
|
110
|
+
function withDayLock(fn) {
|
|
111
|
+
const lockPath = process.env.EVDA_DAY_LOCK_PATH;
|
|
112
|
+
if (!lockPath) return fn();
|
|
113
|
+
const start = Date.now();
|
|
114
|
+
while (!takeLock(lockPath)) {
|
|
115
|
+
let age = 0;
|
|
116
|
+
try { age = Date.now() - statSync(lockPath).mtimeMs; } catch { continue; /* released between the two calls */ }
|
|
117
|
+
if (age > 120_000) {
|
|
118
|
+
try { unlinkSync(lockPath); } catch { /* raced another breaker */ }
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (Date.now() - start > 8000) throw new Error(`evda day-file: could not acquire ${lockPath} within 8s`);
|
|
122
|
+
pause(100);
|
|
123
|
+
}
|
|
124
|
+
try { return fn(); } finally { try { unlinkSync(lockPath); } catch { /* already gone */ } }
|
|
125
|
+
}
|
|
126
|
+
|
|
80
127
|
// Append `words` to the day file at `abs`. `session` is the conversation's mutable state ({} to
|
|
81
128
|
// start one, null for a write that belongs to no conversation, which always opens a heading); it
|
|
82
129
|
// remembers which file this conversation's section is in and how long that file was after its last
|
|
83
130
|
// write. Returns { opened, bytes }.
|
|
84
131
|
export function appendToDay(abs, { now = new Date(), words, context = null, session = null }) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
132
|
+
return withDayLock(() => {
|
|
133
|
+
const exists = existsSync(abs);
|
|
134
|
+
const current = exists ? readFileSync(abs, "utf8") : null;
|
|
135
|
+
const s = session?.section;
|
|
136
|
+
const continuing = Boolean(s && exists && s.abs === abs && statSync(abs).size === s.size);
|
|
137
|
+
const w = sectionBytes({ current, date: localDate(now), time: localTime(now), words, context, continuing, contextWritten: Boolean(s?.context) });
|
|
138
|
+
appendFileSync(abs, w.text);
|
|
139
|
+
if (session) session.section = { abs, size: statSync(abs).size, context: Boolean(context) || (continuing && Boolean(s?.context)) };
|
|
140
|
+
return { opened: w.opened, bytes: Buffer.byteLength(w.text) };
|
|
141
|
+
});
|
|
93
142
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// One writer for the daily file (HQ-2165): a PreToolUse hook that refuses a direct write to the
|
|
2
|
+
// record's daily file on any machine that is not the one writing it.
|
|
3
|
+
//
|
|
4
|
+
// iCloud (and any folder sync) gives each machine its own local copy of the daily file. When two
|
|
5
|
+
// machines append to their own copies within one sync window, the sync keeps one whole copy and
|
|
6
|
+
// drops the other, with no conflict copy (2026-10-03: the connector's evda_write on Hestia and a
|
|
7
|
+
// laptop session's `cat >>` 35 seconds apart; the earlier words are in no file). No lock fixes it,
|
|
8
|
+
// because taking a lock in the synced folder is itself a write with the same delay. What fixes it is
|
|
9
|
+
// one machine doing every write to that file, and every other machine sending its words there:
|
|
10
|
+
// through the evda MCP tool evda_write, which the writing machine serves (test/single-writer.test.mjs
|
|
11
|
+
// test 2 is the proof that one replica keeps both writes).
|
|
12
|
+
//
|
|
13
|
+
// EVDA_DAY_WRITER names that machine's evda MCP endpoint. Set it on every machine that is NOT the
|
|
14
|
+
// writer; leave it unset on the writer and on every install with one machine, where this allows
|
|
15
|
+
// everything. The hook lives in the user's own Claude Code settings on that machine, never in the
|
|
16
|
+
// record's synced .claude/settings.json, so the writer never sees it.
|
|
17
|
+
//
|
|
18
|
+
// It refuses Write, Edit, MultiEdit and NotebookEdit of a file directly in the daily folder, and a
|
|
19
|
+
// shell command that names such a file next to a write-shaped operator (>, >>, tee, sed -i, cp, mv,
|
|
20
|
+
// rm and the rest). Like the read guard, it stops a cooperating agent on the ordinary paths; a
|
|
21
|
+
// command built to hide its target gets past it. Anything it cannot parse is allowed.
|
|
22
|
+
//
|
|
23
|
+
// EVDA_DAY_WRITER_FALSIFY=allow-all every decision is allow, so the tests can be watched failing
|
|
24
|
+
|
|
25
|
+
import { realpathSync } from "node:fs";
|
|
26
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
27
|
+
import { openEvda } from "./manifest.mjs";
|
|
28
|
+
import { dayRole, findRecord } from "./day-file.mjs";
|
|
29
|
+
import { shellPaths } from "./guard.mjs";
|
|
30
|
+
|
|
31
|
+
export const DAY_WRITER_ENV = "EVDA_DAY_WRITER";
|
|
32
|
+
|
|
33
|
+
// Shell commands that change the files they name, and the target of a redirect. A day file named
|
|
34
|
+
// only by a reader (cat, grep, or a reader with 2>/dev/null) is a read, left to the read guard.
|
|
35
|
+
const WRITERS = /((^|[\s;&|(`])(tee|cp|mv|rm|truncate|touch|install|ln|dd|ed|ex)(\s|$)|\b(sed|perl|ruby)\s+(-[A-Za-z]*\s+)*-[A-Za-z]*i)/;
|
|
36
|
+
const REDIRECT = />>?\|?\s*("[^"]*"|'[^']*'|[^\s;&|<>()`]+)/g;
|
|
37
|
+
|
|
38
|
+
// The paths a shell command may write: every path when a writing command runs, else redirect targets.
|
|
39
|
+
export function shellWriteTargets(command) {
|
|
40
|
+
const c = String(command);
|
|
41
|
+
if (WRITERS.test(c)) return shellPaths(c);
|
|
42
|
+
return [...c.matchAll(REDIRECT)].map((m) => m[1].replace(/^["']|["']$/g, "")).filter((w) => w && !w.startsWith("&"));
|
|
43
|
+
}
|
|
44
|
+
const FILE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
45
|
+
|
|
46
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
47
|
+
|
|
48
|
+
// The daily folder of the record holding `abs`, real path, or null when `abs` is in no record or the
|
|
49
|
+
// record maps no daily role.
|
|
50
|
+
function dailyDirOf(abs) {
|
|
51
|
+
const root = findRecord(dirname(abs));
|
|
52
|
+
if (!root) return null;
|
|
53
|
+
let evda;
|
|
54
|
+
try { evda = openEvda(real(root)); } catch { return null; }
|
|
55
|
+
const role = dayRole(evda.manifest.mapping);
|
|
56
|
+
if (!role) return null;
|
|
57
|
+
return real(join(evda.dir, evda.manifest.mapping[role]));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// True when `abs` is a markdown file directly in its record's daily folder. A file there need not
|
|
61
|
+
// exist yet: creating today's file is the first write.
|
|
62
|
+
export function isDayFile(abs) {
|
|
63
|
+
if (!/\.md$/i.test(abs)) return false;
|
|
64
|
+
const day = dailyDirOf(abs);
|
|
65
|
+
if (!day) return false;
|
|
66
|
+
return real(dirname(abs)) === day;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sentence(writer, file) {
|
|
70
|
+
return `EVÐA: ${basename(file)} is the daily file, and this machine does not write it. ${DAY_WRITER_ENV} names the machine that does (${writer}). Two machines writing one daily file through a synced folder lose one of the writes with no conflict copy. Append the words with the evda MCP tool evda_write, which that machine serves. If evda_write is not connected in this session, tell the owner in one plain sentence that the words were not filed and why, and do not write the file here.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The decision for one PreToolUse input. { decision: "allow" } or { decision: "deny", reason }.
|
|
74
|
+
export function decide(input, { env = process.env } = {}) {
|
|
75
|
+
const writer = (env[DAY_WRITER_ENV] ?? "").trim();
|
|
76
|
+
if (!writer) return { decision: "allow" };
|
|
77
|
+
if (env.EVDA_DAY_WRITER_FALSIFY === "allow-all") return { decision: "allow" };
|
|
78
|
+
const tool = input?.tool_name;
|
|
79
|
+
const args = input?.tool_input ?? {};
|
|
80
|
+
const cwd = typeof input?.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
81
|
+
const at = (p) => (isAbsolute(p) ? p : join(cwd, p));
|
|
82
|
+
let paths = [];
|
|
83
|
+
if (FILE_TOOLS.has(tool)) paths = [args.file_path ?? args.notebook_path].filter((p) => typeof p === "string" && p);
|
|
84
|
+
else if (tool === "Bash") {
|
|
85
|
+
paths = shellWriteTargets(args.command ?? "");
|
|
86
|
+
} else return { decision: "allow" };
|
|
87
|
+
const hit = paths.map(at).find(isDayFile);
|
|
88
|
+
if (!hit) return { decision: "allow" };
|
|
89
|
+
return { decision: "deny", reason: sentence(writer, hit) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The hook's whole life: read stdin, answer on stdout, exit 0.
|
|
93
|
+
export async function runHook({ stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
|
|
94
|
+
let raw = "";
|
|
95
|
+
for await (const chunk of stdin) raw += chunk;
|
|
96
|
+
let input;
|
|
97
|
+
try { input = JSON.parse(raw); } catch { return 0; }
|
|
98
|
+
const d = decide(input, { env });
|
|
99
|
+
if (d.decision === "deny") {
|
|
100
|
+
stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: d.reason } }) + "\n");
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
package/lib/doctor.mjs
CHANGED
|
@@ -72,6 +72,7 @@ import { datedHeadings } from "./migrate-people.mjs";
|
|
|
72
72
|
import { EvdaError } from "./errors.mjs";
|
|
73
73
|
import { hookStatus, promptHookStatus, HOOK_REL, PROMPT_HOOK_REL, SETTINGS_REL } from "./hooks.mjs";
|
|
74
74
|
import { FILING_MODES, filingOf } from "./day-file.mjs";
|
|
75
|
+
import { EVERY_CHAT_KEY } from "./every-chat.mjs";
|
|
75
76
|
import { listSkills, SKILLS_REL } from "./skills.mjs";
|
|
76
77
|
import { guardStatus } from "./connect.mjs";
|
|
77
78
|
|
|
@@ -265,6 +266,18 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
|
|
|
265
266
|
else if (manifest.filing) ok(1, "filing", `evda.json says "${manifest.filing}": ${manifest.filing === "everything" ? "every message the owner sends is filed without being asked" : "their words are filed only when they ask"}`);
|
|
266
267
|
else skip("filing", "evda.json has no answer yet. The agent asks the owner once, at first connect, and records it with evda_filing.");
|
|
267
268
|
|
|
269
|
+
// 5b2b. every_chat (HQ-2205): per chat app, whether the owner put the every-chat line in its
|
|
270
|
+
// settings. An entry the server cannot read is asked about again, so it warns rather than fails.
|
|
271
|
+
const everyChat = manifest[EVERY_CHAT_KEY];
|
|
272
|
+
if (everyChat === undefined) skip("every-chat line", "evda.json records no app yet. The connector asks the owner once per chat app, the first time they connect from it, and records the answer with evda_every_chat.");
|
|
273
|
+
else if (!everyChat || typeof everyChat !== "object" || Array.isArray(everyChat)) warn("evda.json", `"${EVERY_CHAT_KEY}" must map each chat app to its answer, got ${JSON.stringify(everyChat)}. Every app is asked again until it does.`);
|
|
274
|
+
else {
|
|
275
|
+
const bad = Object.entries(everyChat).filter(([, e]) => !e || typeof e !== "object" || !["set", "declined"].includes(e.state));
|
|
276
|
+
if (bad.length) warn("evda.json", `"${EVERY_CHAT_KEY}" has an entry with no "state" of "set" or "declined" for ${bad.map(([k]) => k).join(", ")}, so that app is asked again.`);
|
|
277
|
+
const good = Object.entries(everyChat).filter(([k]) => !bad.some(([b]) => b === k));
|
|
278
|
+
if (good.length) ok(1, "every-chat line", good.map(([k, e]) => `${k}: ${e.state}${e.date ? ` ${e.date}` : ""}`).join("; "));
|
|
279
|
+
}
|
|
280
|
+
|
|
268
281
|
// 5b3. the prompt hook (HQ-1385): a UserPromptSubmit hook that files each message, for an owner
|
|
269
282
|
// who chose everything. Installed and current is a check; installed while filing says otherwise
|
|
270
283
|
// warns, because it then files nothing and the settings say it does.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The every-chat line (HQ-2205, layer 4): the one sentence an owner puts in their chat app's
|
|
2
|
+
// account-wide instructions so that every chat loads the operating layer before it answers.
|
|
3
|
+
//
|
|
4
|
+
// Why it exists. Hestia's evda-mcp log (2026-09-23 to 2026-10-05) holds 153 claude.ai sessions, and
|
|
5
|
+
// 145 of them never called a single EVÐA tool. A chat that calls no tool never sees a tool
|
|
6
|
+
// description, a tool result or the server's instructions (which claude.ai does not act on anyway),
|
|
7
|
+
// so nothing the server does can reach it. The one thing every chat in an app reads is that app's
|
|
8
|
+
// own account-wide setting. So the connector asks the owner once, at the moment it first sees them in
|
|
9
|
+
// that app, to put this line there, and evda.json remembers the answer per app.
|
|
10
|
+
//
|
|
11
|
+
// One source. create-evda, `evda connect`, the shipped skill and the server all print the line from
|
|
12
|
+
// here, so no surface can carry a different copy. No node: imports, because lib/clients.mjs is
|
|
13
|
+
// rendered in the EVDA app's webview and reads this file.
|
|
14
|
+
|
|
15
|
+
export const EVERY_CHAT_KEY = "every_chat";
|
|
16
|
+
|
|
17
|
+
// Bumped when the line's meaning changes. An owner who set an older version is asked once more,
|
|
18
|
+
// because the line in their app no longer says what the record needs; one who declined is not.
|
|
19
|
+
export const EVERY_CHAT_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
// The line, by the owner's filing answer (evda.json "filing"). It is written in the first person
|
|
22
|
+
// because the app shows it to the model as the owner's own words. Each clause carries its reason,
|
|
23
|
+
// since a model follows a reason into cases a bare order did not foresee:
|
|
24
|
+
// - "before your first reply" is the whole point: a chat that answers first answers as a generic
|
|
25
|
+
// assistant, and the first message is the one most often lost.
|
|
26
|
+
// - the tool is named exactly, so an app that defers connector tools can search for it by name.
|
|
27
|
+
// - "if those tools are not in this chat, ignore this" keeps a chat with the connector switched
|
|
28
|
+
// off from stalling or apologizing.
|
|
29
|
+
// An owner who chose "asked" gets the same load and no instruction to file everything (HQ-1385).
|
|
30
|
+
export const EVERY_CHAT_LINE = {
|
|
31
|
+
everything:
|
|
32
|
+
"My EVÐA (my personal record) is connected through the evda tools. In every chat, before your first reply, call evda_operating_layer and follow it, because it says who you are for me and how my record is kept; then file each message I send with evda_write, in my exact words, in the same turn, so nothing I say is lost. If the evda tools are not in this chat, ignore this.",
|
|
33
|
+
asked:
|
|
34
|
+
"My EVÐA (my personal record) is connected through the evda tools. In every chat, before your first reply, call evda_operating_layer and follow it, because it says who you are for me and how my record is kept. Save something to my record only when I ask you to. If the evda tools are not in this chat, ignore this.",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function everyChatLine(filing) {
|
|
38
|
+
return EVERY_CHAT_LINE[filing === "asked" ? "asked" : "everything"];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Where each chat app keeps its every-chat setting, only as its own help page says it, with the
|
|
42
|
+
// page and the date it was read. An app not listed here, or one whose page could not be read, gets
|
|
43
|
+
// GENERIC_WHERE: naming a menu that does not exist would send the owner hunting for it.
|
|
44
|
+
// claude: support.claude.com 10185728, read 2026-10-05: "Click your initials in the lower left
|
|
45
|
+
// corner. Select 'Settings.' Under 'Instructions for Claude,' describe your instructions" and
|
|
46
|
+
// "Any instructions you add here will be applied to all of your conversations with Claude."
|
|
47
|
+
// One account setting, so claude.ai, the desktop app and the phone app share it.
|
|
48
|
+
// chatgpt: help.openai.com 8096356, read 2026-10-05: web and desktop, Settings, Personalization,
|
|
49
|
+
// Enable customization on, the Custom Instructions field; iOS and Android, Settings, Customize
|
|
50
|
+
// ChatGPT. Free and Go plans hold up to 1,500 characters, which the line fits inside.
|
|
51
|
+
export const CHAT_APPS = {
|
|
52
|
+
claude: {
|
|
53
|
+
label: "Claude",
|
|
54
|
+
where: "Click your initials in the lower left corner, open Settings, and add it under \"Instructions for Claude\". It applies to every chat on claude.ai, the desktop app and the phone app.",
|
|
55
|
+
limit: null,
|
|
56
|
+
source: "https://support.claude.com/en/articles/10185728-understanding-claude-s-personalization-features",
|
|
57
|
+
checked: "2026-10-05",
|
|
58
|
+
},
|
|
59
|
+
chatgpt: {
|
|
60
|
+
label: "ChatGPT",
|
|
61
|
+
where: "On the web or desktop, open Settings, then Personalization, make sure Enable customization is on, and add it to the Custom Instructions field. On a phone it is Settings, then Customize ChatGPT.",
|
|
62
|
+
limit: 1500,
|
|
63
|
+
source: "https://help.openai.com/en/articles/8096356-chatgpt-custom-instructions",
|
|
64
|
+
checked: "2026-10-05",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
export const GENERIC_WHERE = "Add it to your app's every-chat instructions: the setting that applies to all of your conversations, not just this one.";
|
|
68
|
+
|
|
69
|
+
// Which app is calling, from initialize's clientInfo.name. Coding agents (Claude Code, Codex,
|
|
70
|
+
// Cursor and the like) are left out, because their every-session file is AGENTS.md or CLAUDE.md,
|
|
71
|
+
// which create-evda already writes into the record. Matched by pattern: the names claude.ai and
|
|
72
|
+
// ChatGPT send are not yet measured (HQ-2207 measures them), and a name that matches nothing is
|
|
73
|
+
// "other", which is asked with GENERIC_WHERE rather than skipped, so a new app still gets the line.
|
|
74
|
+
const CODING = /claude[-_ ]?code|codex|cursor|copilot|vscode|windsurf|zed|opencode|cline|aider|gemini[-_ ]?cli|goose|mcp-inspector|inspector/i;
|
|
75
|
+
export function chatAppOf(clientName) {
|
|
76
|
+
const n = String(clientName ?? "").trim();
|
|
77
|
+
if (CODING.test(n)) return null;
|
|
78
|
+
if (/claude|anthropic/i.test(n)) return "claude";
|
|
79
|
+
if (/openai|chatgpt/i.test(n)) return "chatgpt";
|
|
80
|
+
return "other";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// What evda.json says for one app: { state: "set" | "declined", form, version, date } or null.
|
|
84
|
+
export function everyChatEntry(manifest, app) {
|
|
85
|
+
const all = (manifest?.manifest ?? manifest)?.[EVERY_CHAT_KEY];
|
|
86
|
+
const e = all && typeof all === "object" && !Array.isArray(all) ? all[app] : null;
|
|
87
|
+
return e && typeof e === "object" && !Array.isArray(e) ? e : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Whether this app's owner still needs to be asked. A "declined" stands: the owner said no, and
|
|
91
|
+
// asking again would be nagging. A "set" stands while the line they set says what the record needs
|
|
92
|
+
// now: the same filing form and the same version.
|
|
93
|
+
export function needsEveryChat(manifest, app, filing) {
|
|
94
|
+
if (!app) return false;
|
|
95
|
+
const e = everyChatEntry(manifest, app);
|
|
96
|
+
if (e?.state === "declined") return false;
|
|
97
|
+
if (e?.state === "set") return !(e.form === (filing === "asked" ? "asked" : "everything") && e.version === EVERY_CHAT_VERSION);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The walkthrough the model gives the owner, in the result of the first tool it calls in a chat.
|
|
102
|
+
// It is written to the model: what the setting is for, the line to hand over whole, where it goes in
|
|
103
|
+
// this app, and how the answer is kept, so the owner is asked once per app and never again.
|
|
104
|
+
export function everyChatWalkthrough(app, filing) {
|
|
105
|
+
const a = CHAT_APPS[app];
|
|
106
|
+
const line = everyChatLine(filing);
|
|
107
|
+
return [
|
|
108
|
+
`## One setting for every chat${a ? ` in ${a.label}` : ""}`,
|
|
109
|
+
"",
|
|
110
|
+
`Chats that never call an evda tool never reach this record: they answer as a generic assistant and keep nothing the owner says. The one thing every chat in ${a ? a.label : "their app"} reads is its every-chat setting, and nothing in the record shows that this owner has put the line there yet. So once, in this reply and after answering what they asked, tell them in a sentence or two why it matters and give them this line to copy, whole and exactly as written:`,
|
|
111
|
+
"",
|
|
112
|
+
"```",
|
|
113
|
+
line,
|
|
114
|
+
"```",
|
|
115
|
+
"",
|
|
116
|
+
`Where it goes: ${a ? a.where : GENERIC_WHERE}`,
|
|
117
|
+
"",
|
|
118
|
+
`When they say it is set, or that the line is already in place (set in an earlier chat, or added by hand), record it with evda_every_chat (app "${app}", state "set", confirmed: true), so no later chat in ${a ? a.label : "this app"} asks again. If they say they do not want it, record state "declined" the same way, and it is never raised again in ${a ? a.label : "this app"}. If they leave it for now, say nothing more about it in this chat.`,
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The line and where it goes in every app, for the setup surfaces: create-evda, `evda connect`.
|
|
123
|
+
export function everyChatSetupLines(filing) {
|
|
124
|
+
return [
|
|
125
|
+
"So every chat starts as your agent, even one that never touches your record, add this line to your chat app's every-chat instructions:",
|
|
126
|
+
"",
|
|
127
|
+
` ${everyChatLine(filing)}`,
|
|
128
|
+
"",
|
|
129
|
+
...Object.values(CHAT_APPS).map((a) => ` ${a.label}: ${a.where}`),
|
|
130
|
+
` Any other app: ${GENERIC_WHERE}`,
|
|
131
|
+
];
|
|
132
|
+
}
|