create-evda 0.1.9 → 0.1.10
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 +72 -0
- package/README.md +12 -5
- package/bin/evda.mjs +122 -5
- package/create-evda.mjs +11 -3
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/clients.mjs +11 -0
- package/lib/connect.mjs +1 -1
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +121 -12
- package/lib/day-writer.mjs +8 -1
- package/lib/doctor.mjs +32 -3
- package/lib/every-chat.mjs +7 -11
- package/lib/gather-apply.mjs +133 -24
- package/lib/gather.mjs +116 -16
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +38 -1
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-http.mjs +19 -1
- package/lib/mcp.mjs +358 -56
- package/lib/measurement-log.mjs +3 -0
- package/lib/migrate-people.mjs +54 -20
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/record.mjs +54 -0
- package/lib/routines.mjs +13 -2
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +669 -59
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/templates/AGENTS.md +56 -6
- package/templates/evda-digest.md +2 -0
package/lib/day-file.mjs
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
// local time) with its one line of context; every later write in the same conversation appends
|
|
7
7
|
// under it, as a new paragraph, with no heading. A conversation is whatever the caller passes as
|
|
8
8
|
// `session`: one MCP session (a stdio process, or one Mcp-Session-Id over HTTP), or one Claude Code
|
|
9
|
-
// session id in the prompt hook. When
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// session id in the prompt hook. When another conversation wrote a section below this one since,
|
|
10
|
+
// the next write still goes into this conversation's own section, above theirs (HQ-2330), so words
|
|
11
|
+
// never land under a heading that is not theirs and one conversation stays one section. Only a hand
|
|
12
|
+
// edit of the section itself makes the next write open a new heading.
|
|
12
13
|
//
|
|
13
14
|
// FILING. evda.json `filing` is the owner's answer to the one setup question: "everything" (file
|
|
14
15
|
// every message they send, without being asked) or "asked" (only when they ask). Absent means they
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
// EVDA_CHAT_FALSIFY=heading-per-write opens a new heading on every write, for the test that shows
|
|
23
24
|
// one conversation is one section.
|
|
24
25
|
|
|
25
|
-
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
26
|
+
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
26
27
|
import { dirname, join, resolve } from "node:path";
|
|
27
28
|
|
|
28
29
|
export const FILING_MODES = ["everything", "asked"];
|
|
@@ -62,6 +63,48 @@ export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
|
62
63
|
|
|
63
64
|
export const contextLine = (context) => `*${CONTEXT_LABEL} ${String(context).trim().replace(/\*/g, "")}*`;
|
|
64
65
|
|
|
66
|
+
// NAMED ENTRIES (HQ-2799). A write tool's answer names the existing entries the filed words bear on
|
|
67
|
+
// (lib/touches.mjs, HQ-2631), and a conversation named some and added to none of them is asked once.
|
|
68
|
+
// The MCP server keeps what it named per chat; the Stop hook reads the same lines back out of the
|
|
69
|
+
// Claude Code transcript. Both build and parse them here, so the line one writes is the line the
|
|
70
|
+
// other reads, and nothing is stored in the owner's files: the transcript and the chat are the state.
|
|
71
|
+
export const RELATED_LEAD = "These existing entries bear on what you just filed: ";
|
|
72
|
+
export const TICKETS_LEAD = "These open tickets on the board bear on it: ";
|
|
73
|
+
export const ENTRIES_ASK_LEAD = "This conversation was pointed at existing entries and added to none of them:";
|
|
74
|
+
|
|
75
|
+
export const relatedLine = (related) => `${RELATED_LEAD}${related.map((r) => `${r.path} (named as "${r.name}")`).join("; ")}.`;
|
|
76
|
+
|
|
77
|
+
export const ticketsLine = (tickets) => `${TICKETS_LEAD}${tickets.map((k) => `${k.id} "${k.title}" (named as "${k.named}")`).join("; ")}.`;
|
|
78
|
+
|
|
79
|
+
// The open tickets a tool result's text names (HQ-2798's line), as ["HQ-<n>", ...].
|
|
80
|
+
export function ticketsNamedIn(text) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
83
|
+
if (!line.startsWith(TICKETS_LEAD)) continue;
|
|
84
|
+
for (const m of line.slice(TICKETS_LEAD.length).matchAll(/(?:^|; )(HQ-\d+) "/g)) if (!out.includes(m[1])) out.push(m[1]);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The entries a tool result's text names, as [{ path, name }], from its RELATED_LEAD line.
|
|
90
|
+
export function namedIn(text) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
93
|
+
if (!line.startsWith(RELATED_LEAD)) continue;
|
|
94
|
+
const body = line.slice(RELATED_LEAD.length).replace(/\.$/, "");
|
|
95
|
+
for (const m of body.matchAll(/(?:^|; )(.+?) \(named as "([^"]*)"\)/g)) out.push({ path: m[1], name: m[2] });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Whether a text already carries the ask, so neither surface asks a second time.
|
|
101
|
+
export const askedIn = (text) => String(text ?? "").includes(ENTRIES_ASK_LEAD);
|
|
102
|
+
|
|
103
|
+
// The ask, naming the entries and tickets. `how` says how this surface adds to an entry.
|
|
104
|
+
export function entriesAsk(paths, how = "evda_append; a person's page with evda_record_person_note", tickets = []) {
|
|
105
|
+
return `${ENTRIES_ASK_LEAD} ${[...paths, ...tickets].join(", ")}. If what the owner said adds something to one of them, add a dated section to it now (${how})${tickets.length ? ", or update the ticket on the board" : ""}. If nothing new applies, adding nothing is fine. This is asked once.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
// The bytes one write appends to a day file whose current text is `current` (null when it does not
|
|
66
109
|
// exist yet). `continuing` is true when this conversation's section is the last thing in the file.
|
|
67
110
|
// Returns { text, opened }.
|
|
@@ -125,18 +168,84 @@ function withDayLock(fn) {
|
|
|
125
168
|
}
|
|
126
169
|
|
|
127
170
|
// Append `words` to the day file at `abs`. `session` is the conversation's mutable state ({} to
|
|
128
|
-
// start one, null for a write that belongs to no conversation, which always opens a heading)
|
|
129
|
-
//
|
|
130
|
-
//
|
|
171
|
+
// start one, null for a write that belongs to no conversation, which always opens a heading).
|
|
172
|
+
//
|
|
173
|
+
// HQ-2330: the conversation keeps its section, one per file, in `session.sections[abs]`: the
|
|
174
|
+
// section's exact text as this conversation last wrote it (`tail`, from its heading to its last
|
|
175
|
+
// words) and whether it carries a context line. A later write finds that text in the file and goes
|
|
176
|
+
// straight after it: at the end of the file when nothing came after (a plain append, as before),
|
|
177
|
+
// or above whatever another writer added below it since (a read-modify-write under the same lock).
|
|
178
|
+
// Keyed by file, so the agent log and the owner's day each keep their own section in one chat
|
|
179
|
+
// (measured 2026-10-07: a Claude Code session's evda_write_agent_log between two evda_writes left
|
|
180
|
+
// the next write a bare `## 20:41`, and the agent log a bare heading per entry). When the section's
|
|
181
|
+
// text is no longer in the file (edited by hand), the write opens a new heading, and that heading
|
|
182
|
+
// repeats the conversation's context line, so a continuing conversation never has a bare heading.
|
|
183
|
+
// Returns { opened, bytes, inserted }.
|
|
131
184
|
export function appendToDay(abs, { now = new Date(), words, context = null, session = null }) {
|
|
132
185
|
return withDayLock(() => {
|
|
133
186
|
const exists = existsSync(abs);
|
|
134
187
|
const current = exists ? readFileSync(abs, "utf8") : null;
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
188
|
+
const mine = session ? sectionOf(session, abs) : null;
|
|
189
|
+
const at = mine && current !== null && !falsified("heading-per-write") ? current.lastIndexOf(mine.tail) : -1;
|
|
190
|
+
if (at >= 0) {
|
|
191
|
+
const body = String(words).replace(/\n+$/, "");
|
|
192
|
+
const ctx = context && !mine.context ? `${contextLine(context)}\n\n` : "";
|
|
193
|
+
const add = `\n${ctx}${body}\n`;
|
|
194
|
+
const end = at + mine.tail.length;
|
|
195
|
+
if (end === current.length) appendFileSync(abs, add);
|
|
196
|
+
else writeFileSync(abs, current.slice(0, end) + add + current.slice(end));
|
|
197
|
+
session.sections[abs] = { tail: mine.tail + add, context: mine.context || Boolean(ctx), line: mine.line ?? (context ? String(context).trim() : null) };
|
|
198
|
+
return { opened: false, bytes: Buffer.byteLength(add), inserted: end !== current.length };
|
|
199
|
+
}
|
|
200
|
+
// A new section. A conversation that already had one here and lost it keeps its context line.
|
|
201
|
+
const line = context ?? mine?.line ?? null;
|
|
202
|
+
const w = sectionBytes({ current, date: localDate(now), time: localTime(now), words, context: line, continuing: false });
|
|
138
203
|
appendFileSync(abs, w.text);
|
|
139
|
-
if (session)
|
|
140
|
-
|
|
204
|
+
if (session) {
|
|
205
|
+
const start = w.text.indexOf("\n## ");
|
|
206
|
+
session.sections ??= {};
|
|
207
|
+
session.sections[abs] = { tail: w.text.slice(start + 1), context: Boolean(line), line: line === null ? null : String(line).trim() };
|
|
208
|
+
}
|
|
209
|
+
return { opened: true, bytes: Buffer.byteLength(w.text), inserted: false };
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// This conversation's section in the file at `abs`, or null. A session saved before HQ-2330 kept
|
|
214
|
+
// one `section` by size, not text; it is read as no section, so its next write opens a heading.
|
|
215
|
+
function sectionOf(session, abs) {
|
|
216
|
+
const s = session.sections?.[abs];
|
|
217
|
+
return s && typeof s.tail === "string" && s.tail ? s : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The one pointer line under `heading` in the day file at `abs` (HQ-2306): the line in the owner's
|
|
221
|
+
// day that points at an agent's own log for that day. Exactly one line starting with `prefix` lives
|
|
222
|
+
// in that section; this replaces it in place, inserts it under the heading, appends the heading
|
|
223
|
+
// after everything else, or creates the day, and touches no other byte. Under the same lock as
|
|
224
|
+
// appendToDay, because this is a read-modify-write and a stale read would drop an append.
|
|
225
|
+
// Returns "created", "added", "inserted" or "refreshed".
|
|
226
|
+
export function setPointerLine(abs, { date, heading, prefix, line }) {
|
|
227
|
+
return withDayLock(() => {
|
|
228
|
+
const existing = existsSync(abs) ? readFileSync(abs, "utf8") : "";
|
|
229
|
+
if (existing === "") {
|
|
230
|
+
writeFileSync(abs, `# ${date}\n\n${heading}\n\n${line}\n`);
|
|
231
|
+
return "created";
|
|
232
|
+
}
|
|
233
|
+
const lines = existing.split("\n");
|
|
234
|
+
const at = lines.findIndex((l) => l.trim() === heading);
|
|
235
|
+
if (at === -1) {
|
|
236
|
+
appendFileSync(abs, `${existing.endsWith("\n") ? "" : "\n"}\n${heading}\n\n${line}\n`);
|
|
237
|
+
return "added";
|
|
238
|
+
}
|
|
239
|
+
let end = lines.length;
|
|
240
|
+
for (let i = at + 1; i < lines.length; i++) if (/^#{1,6} /.test(lines[i])) { end = i; break; }
|
|
241
|
+
const idx = lines.slice(at + 1, end).findIndex((l) => l.startsWith(prefix));
|
|
242
|
+
if (idx === -1) {
|
|
243
|
+
lines.splice(at + 1, 0, "", line);
|
|
244
|
+
writeFileSync(abs, lines.join("\n"));
|
|
245
|
+
return "inserted";
|
|
246
|
+
}
|
|
247
|
+
lines[at + 1 + idx] = line;
|
|
248
|
+
writeFileSync(abs, lines.join("\n"));
|
|
249
|
+
return "refreshed";
|
|
141
250
|
});
|
|
142
251
|
}
|
package/lib/day-writer.mjs
CHANGED
|
@@ -66,8 +66,15 @@ export function isDayFile(abs) {
|
|
|
66
66
|
return real(dirname(abs)) === day;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// HQ-2596: where words wait when the writer cannot be reached. One JSON file per write in the daily
|
|
70
|
+
// folder's `.outbox/`, so each file has one writer and no sync can drop it; the writer files them,
|
|
71
|
+
// oldest first, once its own copy of the day has synced. A file in that folder is not a day file, so
|
|
72
|
+
// this guard allows writing it.
|
|
73
|
+
export const OUTBOX_DIR = ".outbox";
|
|
74
|
+
|
|
69
75
|
function sentence(writer, file) {
|
|
70
|
-
|
|
76
|
+
const outbox = `${join(dirname(file), OUTBOX_DIR)}/`;
|
|
77
|
+
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 or that machine cannot be reached, queue the words in the outbox instead: write one new file per message to ${outbox}<UTC timestamp>-<host>-evda_write.json holding {tool, words, context, at, host} (tool "evda_write", words verbatim, at in UTC ISO), and the writer files them into the day when it is back. Tell the owner the words are queued, not filed, and do not write the daily file here.`;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
// The decision for one PreToolUse input. { decision: "allow" } or { decision: "deny", reason }.
|
package/lib/doctor.mjs
CHANGED
|
@@ -69,9 +69,11 @@ import { folderManifests, folderFor, viewNamedIn, FOLDER_MANIFEST } from "./fold
|
|
|
69
69
|
import { referencesIn } from "./references.mjs";
|
|
70
70
|
import { KIND_FOR_TYPE, PERSON_KINDS, entryFromFile, readEntry } from "./kinds.mjs";
|
|
71
71
|
import { datedHeadings } from "./migrate-people.mjs";
|
|
72
|
+
import { personFolderProblems, README_NAME } from "./person-folders.mjs";
|
|
72
73
|
import { EvdaError } from "./errors.mjs";
|
|
73
74
|
import { hookStatus, promptHookStatus, HOOK_REL, PROMPT_HOOK_REL, SETTINGS_REL } from "./hooks.mjs";
|
|
74
75
|
import { FILING_MODES, filingOf } from "./day-file.mjs";
|
|
76
|
+
import { correctionsTarget } from "./corrections.mjs";
|
|
75
77
|
import { EVERY_CHAT_KEY } from "./every-chat.mjs";
|
|
76
78
|
import { listSkills, SKILLS_REL } from "./skills.mjs";
|
|
77
79
|
import { guardStatus } from "./connect.mjs";
|
|
@@ -237,7 +239,12 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
|
|
|
237
239
|
fail(rel, e.message);
|
|
238
240
|
}
|
|
239
241
|
if (fence === null) warn(rel, "has no evda:generated fence. The owner has taken the whole file; `evda upgrade` has nothing it owns to replace.");
|
|
240
|
-
|
|
242
|
+
// HQ-2325: a record that keeps its corrections in their own file names it in evda.json, and
|
|
243
|
+
// then the heading is not where they land, so its absence is not a warning.
|
|
244
|
+
const corrections = correctionsTarget({ dir, manifest });
|
|
245
|
+
if (corrections?.problem) warn("evda.json", `${corrections.problem} evda_record_correction refuses until it does.`);
|
|
246
|
+
else if (corrections) ok(1, "corrections", `evda.json names ${corrections.rel}: a correction lands there as a dated section`);
|
|
247
|
+
else if (!/## What I have been taught/.test(agents)) warn(rel, 'has no "## What I have been taught" section, and evda.json names no "corrections" file, so a correction has nowhere to land.');
|
|
241
248
|
}
|
|
242
249
|
// Claude Code reads CLAUDE.md and nothing else. If the operating layer is elsewhere, CLAUDE.md
|
|
243
250
|
// has to import it or a Claude Code session here starts with no instructions.
|
|
@@ -673,10 +680,21 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
|
|
|
673
680
|
if (name === FOLDER_MANIFEST) continue;
|
|
674
681
|
const rel = relative(dir, p);
|
|
675
682
|
const read = entryFromFile(name, readFileSync(p, "utf8"));
|
|
676
|
-
|
|
683
|
+
// In a person folder every file directly inside is a person's profile (HQ-2024), so one
|
|
684
|
+
// with no kind is a profile doctor would otherwise never read, and fails. Its README, its
|
|
685
|
+
// manifest and its declared views are not people; Processing/ sits beside the folder.
|
|
686
|
+
const unread = want === "person-profile" && p.slice(0, p.lastIndexOf(sep)) === f.dir && name !== README_NAME;
|
|
687
|
+
if (read === null) {
|
|
688
|
+
if (unread && /\.md$/i.test(name)) fail(rel, `has no person-profile frontmatter, so doctor never reads it. ${where} declares type person: every file in it opens with kind: person-profile (scripts/migrate-people.mjs adds it).`);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
677
691
|
if (read.error) { fail(rel, `${read.error} ${where} holds ${want} entries.`); continue; }
|
|
678
692
|
const kind = read.raw?.kind;
|
|
679
|
-
if (kind === undefined) {
|
|
693
|
+
if (kind === undefined) {
|
|
694
|
+
if (unread) fail(rel, `has no "kind", so doctor never reads it as a person-profile. ${where} declares type person.`);
|
|
695
|
+
else warn(rel, `has no "kind", so it is not read as a ${want}. ${where} declares type ${f.type}.`);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
680
698
|
entries += 1;
|
|
681
699
|
if (kind !== want) { fail(rel, `is a ${kind}, and ${where} declares type ${f.type}, whose entries are ${want}. Move it, or say what the folder holds.`); continue; }
|
|
682
700
|
counts[want] = (counts[want] ?? 0) + 1;
|
|
@@ -695,6 +713,17 @@ export function doctor(dir, { out = console.log, via = "argument", depth = 0 } =
|
|
|
695
713
|
}
|
|
696
714
|
}
|
|
697
715
|
}
|
|
716
|
+
// Person folders (HQ-2024, lib/person-folders.mjs): one file per person, named for the
|
|
717
|
+
// person, no subfolder, no lone parenthetical, no redirect stub. Read across every person
|
|
718
|
+
// folder at once, because two people who share a name can live in two folders.
|
|
719
|
+
const people = typed.filter((f) => f.type === "person");
|
|
720
|
+
if (people.length) {
|
|
721
|
+
const skipPaths = new Set(people.flatMap((f) => [join(f.dir, FOLDER_MANIFEST), ...f.views.map((v) => resolve(dir, v.path))]));
|
|
722
|
+
const layout = personFolderProblems(people.map((f) => f.dir), { skip: skipPaths });
|
|
723
|
+
for (const p of layout.problems) fail(relative(dir, p.path), p.message);
|
|
724
|
+
if (layout.files === 0) skip("person folder layout", `${people.length} person folder(s) hold no person files yet`);
|
|
725
|
+
else ok(layout.files, "person files", `across ${people.length} person folder(s): one file each, no subfolder, no lone parenthetical, no redirect stub`);
|
|
726
|
+
}
|
|
698
727
|
if (entries === 0) skip("typed entries", `${typed.length} typed folder(s) hold no entry files yet`);
|
|
699
728
|
else ok(entries, "typed entries", `across ${typed.length} folder(s): ${PERSON_KINDS.filter((k) => counts[k]).map((k) => `${counts[k]} ${k}`).join(", ") || "none of the declared kind"}`);
|
|
700
729
|
}
|
package/lib/every-chat.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// here, so no surface can carry a different copy. No node: imports, because lib/clients.mjs is
|
|
13
13
|
// rendered in the EVDA app's webview and reads this file.
|
|
14
14
|
|
|
15
|
+
import { appLike } from "./connecting-apps.mjs";
|
|
16
|
+
|
|
15
17
|
export const EVERY_CHAT_KEY = "every_chat";
|
|
16
18
|
|
|
17
19
|
// Bumped when the line's meaning changes. An owner who set an older version is asked once more,
|
|
@@ -66,18 +68,12 @@ export const CHAT_APPS = {
|
|
|
66
68
|
};
|
|
67
69
|
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
70
|
|
|
69
|
-
// Which app is calling, from initialize's clientInfo.name
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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;
|
|
71
|
+
// Which chat app is calling, from initialize's clientInfo.name: the every-chat key of the first row
|
|
72
|
+
// in lib/connecting-apps.mjs (HQ-2218) whose pattern matches it. A coding agent's row has none
|
|
73
|
+
// (null), and a name no row matches is "other", asked with GENERIC_WHERE rather than skipped.
|
|
75
74
|
export function chatAppOf(clientName) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
if (/claude|anthropic/i.test(n)) return "claude";
|
|
79
|
-
if (/openai|chatgpt/i.test(n)) return "chatgpt";
|
|
80
|
-
return "other";
|
|
75
|
+
const row = appLike(clientName);
|
|
76
|
+
return row ? row.chat : "other";
|
|
81
77
|
}
|
|
82
78
|
|
|
83
79
|
// What evda.json says for one app: { state: "set" | "declined", form, version, date } or null.
|