create-evda 0.1.7 → 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 +45 -0
- package/README.md +11 -2
- package/bin/evda.mjs +217 -6
- package/create-evda.mjs +22 -6
- package/dist/pairing.mjs +5494 -0
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/browse.mjs +354 -0
- package/lib/circle-plan.mjs +24 -1
- 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 +931 -67
- package/lib/measurement-log.mjs +246 -0
- package/lib/messages.mjs +5 -2
- package/lib/pairing.mjs +403 -0
- 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/setup.mjs +41 -0
- 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 +4 -2
- package/skills/evda/SKILL.md +15 -2
- package/templates/AGENTS.md +25 -4
- package/templates/evda-digest.md +3 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code PreToolUse hook: on a machine with EVDA_DAY_WRITER set, refuse a direct write to the
|
|
3
|
+
// record's daily file and send the agent to evda_write on the machine that writes it (HQ-2165). The
|
|
4
|
+
// logic is lib/day-writer.mjs; this file is only the entry point a user-level settings.json names.
|
|
5
|
+
import { runHook } from "../lib/day-writer.mjs";
|
|
6
|
+
|
|
7
|
+
runHook().then((code) => process.exit(code), () => process.exit(0));
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Acknowledging what the owner put in the record (HQ-2060): the first reader to reach a weighty
|
|
2
|
+
// entry says it back to them once, and every other reader can see that it did.
|
|
3
|
+
//
|
|
4
|
+
// THE PROBLEM. An owner learns how much something weighed from how it was received. Today every
|
|
5
|
+
// agent over one record writes its own record and reads only its own: a live conversation, an
|
|
6
|
+
// always-on agent, a scheduled run. So a correction the owner filed on Monday gets reacted to three
|
|
7
|
+
// times, once by each, or zero times, because each assumes another already did. Mike, 2026-10-01:
|
|
8
|
+
// "whoever sees it first acts on it whetehr its phroneiss or plan pass or a next session".
|
|
9
|
+
//
|
|
10
|
+
// WHERE THE MARK LIVES, AND WHY NOT ON THE ENTRY.
|
|
11
|
+
// - A record file is append only, and `evda_write` takes no path on purpose. The one tool that
|
|
12
|
+
// edits a file is `evda_release_note`, which edits one frontmatter line and only on the owner's
|
|
13
|
+
// explicit yes. A mark written into the entry would make every reader a writer of the owner's
|
|
14
|
+
// words.
|
|
15
|
+
// - The unit of weight is a section, not a file: one conversation is one `## HH:MM` inside a day
|
|
16
|
+
// file that holds several. Frontmatter cannot address a section.
|
|
17
|
+
// - Their words stay their words. A line the agent appends inside the day file is the agent's text
|
|
18
|
+
// in the owner's file, and a later reader can quote it back as something they said.
|
|
19
|
+
// So the mark is a sidecar the readers share, in the shape `messages/.seen.json` already uses for
|
|
20
|
+
// "what this agent has seen" (lib/messages.mjs): a dotfile of bookkeeping, keyed by what it tracks,
|
|
21
|
+
// beside the thing it tracks. It is at the record root rather than under `.evda/`, because `.evda/`
|
|
22
|
+
// is derived and rebuildable and an acknowledgement is derivable from nothing.
|
|
23
|
+
//
|
|
24
|
+
// The format, one file per record:
|
|
25
|
+
//
|
|
26
|
+
// .acknowledged.json
|
|
27
|
+
// {
|
|
28
|
+
// "record/daily/2026-10-01.md#14:03": {
|
|
29
|
+
// "by": "Hestia",
|
|
30
|
+
// "at": "2026-10-01T06:12:00-04:00",
|
|
31
|
+
// "note": "said back to him that the Greek trade is settled and I will not reopen it"
|
|
32
|
+
// }
|
|
33
|
+
// }
|
|
34
|
+
//
|
|
35
|
+
// The key is the entry: its path relative to the record, and the exact heading of the section when
|
|
36
|
+
// the weight is in one section. A heading never changes once written, so the key is stable.
|
|
37
|
+
//
|
|
38
|
+
// Append only, in the same sense as the record: a key that is present is never written over. The
|
|
39
|
+
// second reader of an entry gets { first: false, already } and nothing is written, which is the
|
|
40
|
+
// whole mechanism. "Weighty" is never decided here; weight is the reader's judgment and this file
|
|
41
|
+
// only remembers that one of them acted.
|
|
42
|
+
//
|
|
43
|
+
// EVDA_ACK_FALSIFY=<mode,...>, for the tests. Each breaks exactly one rule:
|
|
44
|
+
// overwrite a second acknowledgement of the same entry overwrites the first and reports first
|
|
45
|
+
// any-section a section that is not a heading in that file is accepted
|
|
46
|
+
// strict-read an .acknowledged.json that does not parse throws instead of reading as empty
|
|
47
|
+
// no-secrets the secret check is skipped
|
|
48
|
+
// hide acknowledgementsFor returns nothing, so a later reader cannot see the mark
|
|
49
|
+
|
|
50
|
+
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
51
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
52
|
+
import { agentName } from "./messages.mjs";
|
|
53
|
+
import { secretShape } from "./references.mjs";
|
|
54
|
+
import { isoLocal } from "./messages.mjs";
|
|
55
|
+
import { EvdaError } from "./errors.mjs";
|
|
56
|
+
|
|
57
|
+
export const ACK_FILE = ".acknowledged.json";
|
|
58
|
+
export const NOTE_MAX = 400;
|
|
59
|
+
export const BY_MAX = 60;
|
|
60
|
+
export const SECTION_MAX = 200;
|
|
61
|
+
|
|
62
|
+
export const ACK_FALSIFY_MODES = ["overwrite", "any-section", "strict-read", "no-secrets", "hide"];
|
|
63
|
+
const falsified = (mode) => (process.env.EVDA_ACK_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
64
|
+
|
|
65
|
+
const refuse = (message, code) => new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
|
|
66
|
+
|
|
67
|
+
// The entry a mark is about: a path, and the section heading inside it when there is one.
|
|
68
|
+
export function entryKey(path, section = null) {
|
|
69
|
+
const p = String(path).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
70
|
+
return section ? `${p}#${section}` : p;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A section as it is keyed: the heading's text, with its leading #s and surrounding space gone, so
|
|
74
|
+
// "## 14:03", "##14:03" and "14:03" are one section.
|
|
75
|
+
export function normalizeSection(section) {
|
|
76
|
+
return String(section).replace(/^\s*#+\s*/, "").trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ackPath(evda) {
|
|
80
|
+
return join(evda.dir, ACK_FILE);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Every mark this record holds, as { key: { by, at, note } }. Missing, unreadable, or not a JSON
|
|
84
|
+
// object: empty, so a reader with no file acts like the first reader rather than throwing.
|
|
85
|
+
export function readAcknowledged(evda) {
|
|
86
|
+
const p = ackPath(evda);
|
|
87
|
+
if (!existsSync(p)) return {};
|
|
88
|
+
let v;
|
|
89
|
+
try { v = JSON.parse(readFileSync(p, "utf8")); } catch (e) { if (falsified("strict-read")) throw e; return {}; }
|
|
90
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return {};
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const [k, m] of Object.entries(v)) {
|
|
93
|
+
if (m === null || typeof m !== "object" || Array.isArray(m)) continue;
|
|
94
|
+
if (typeof m.at !== "string" || typeof m.note !== "string") continue;
|
|
95
|
+
out[k] = { by: typeof m.by === "string" ? m.by : null, at: m.at, note: m.note };
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeAcknowledged(evda, all) {
|
|
101
|
+
const p = ackPath(evda);
|
|
102
|
+
const keys = Object.keys(all).sort();
|
|
103
|
+
const ordered = Object.fromEntries(keys.map((k) => [k, all[k]]));
|
|
104
|
+
const t = `${p}.tmp-${process.pid}`;
|
|
105
|
+
writeFileSync(t, JSON.stringify(ordered, null, 2) + "\n");
|
|
106
|
+
renameSync(t, p);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// What is already acknowledged in one file, newest-written last: [{ section, by, at, note }]. A mark
|
|
110
|
+
// on the file as a whole comes back with section null.
|
|
111
|
+
export function acknowledgementsFor(path, all) {
|
|
112
|
+
if (falsified("hide")) return [];
|
|
113
|
+
const p = String(path).replace(/\\/g, "/");
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const [key, m] of Object.entries(all)) {
|
|
116
|
+
const hash = key.indexOf("#");
|
|
117
|
+
const keyPath = hash === -1 ? key : key.slice(0, hash);
|
|
118
|
+
if (keyPath !== p) continue;
|
|
119
|
+
out.push({ section: hash === -1 ? null : key.slice(hash + 1), by: m.by, at: m.at, note: m.note });
|
|
120
|
+
}
|
|
121
|
+
out.sort((a, b) => String(a.section ?? "").localeCompare(String(b.section ?? "")));
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Is this entry already acknowledged? The read every other reader does before it says anything.
|
|
126
|
+
export function isAcknowledged(evda, { path, section = null } = {}, all = null) {
|
|
127
|
+
const marks = all ?? readAcknowledged(evda);
|
|
128
|
+
const key = entryKey(path, section === null || section === undefined ? null : normalizeSection(section));
|
|
129
|
+
return marks[key] ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function oneLine(value, name, max) {
|
|
133
|
+
if (typeof value !== "string" || value.trim() === "") throw refuse(`evda_acknowledge needs "${name}".`, "EMPTY");
|
|
134
|
+
const v = value.trim();
|
|
135
|
+
if (/[\r\n]/.test(v)) throw refuse(`evda_acknowledge "${name}" is one line.`, "LINES");
|
|
136
|
+
if (v.length > max) throw refuse(`evda_acknowledge "${name}" is ${v.length} characters; at most ${max}.`, "TOO_LONG");
|
|
137
|
+
return v;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function refuseSecrets(value, name) {
|
|
141
|
+
if (falsified("no-secrets")) return;
|
|
142
|
+
const shape = secretShape(value);
|
|
143
|
+
if (shape) throw refuse(`evda_acknowledge "${name}" holds ${shape}. A password, key or token is never written into the record.`, "SECRET");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Does `section` exist as a markdown heading in `text`?
|
|
147
|
+
export function hasHeading(text, section) {
|
|
148
|
+
const want = normalizeSection(section);
|
|
149
|
+
return text.split("\n").some((line) => /^#{1,6}\s+/.test(line) && normalizeSection(line) === want);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Record that this reader acted on one weighty entry. Returns
|
|
153
|
+
// { key, path, section, first, by, at, note, already }.
|
|
154
|
+
//
|
|
155
|
+
// first true : nothing was marked before, this reader is the one that acts, and the mark is written.
|
|
156
|
+
// first false : another reader already did. Nothing is written, `already` says who, when and what
|
|
157
|
+
// they did, and this reader says nothing about the entry.
|
|
158
|
+
export function acknowledge(evda, args = {}, { now = new Date() } = {}) {
|
|
159
|
+
if (typeof args.path !== "string" || args.path.trim() === "") throw refuse('evda_acknowledge needs "path", the entry in the record you acted on.', "NO_PATH");
|
|
160
|
+
const abs = resolve(evda.dir, args.path.trim());
|
|
161
|
+
if (abs !== evda.dir && !abs.startsWith(evda.dir + sep)) throw refuse(`${args.path} is outside the record.`, "OUTSIDE");
|
|
162
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) throw refuse(`nothing is at ${args.path} in the record, so there is no entry to acknowledge.`, "MISSING");
|
|
163
|
+
const path = relative(evda.dir, abs).split(sep).join("/");
|
|
164
|
+
|
|
165
|
+
let section = null;
|
|
166
|
+
if (args.section !== undefined && args.section !== null && String(args.section).trim() !== "") {
|
|
167
|
+
section = normalizeSection(oneLine(String(args.section), "section", SECTION_MAX));
|
|
168
|
+
if (!falsified("any-section") && !hasHeading(readFileSync(abs, "utf8"), section)) {
|
|
169
|
+
throw refuse(`${path} has no heading "${section}". Acknowledge the heading as it is written in the file, or leave section out to acknowledge the whole entry.`, "NO_SECTION");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const key = entryKey(path, section);
|
|
174
|
+
const all = readAcknowledged(evda);
|
|
175
|
+
if (all[key] && !falsified("overwrite")) {
|
|
176
|
+
return { key, path, section, first: false, by: all[key].by, at: all[key].at, note: all[key].note, already: all[key] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const note = oneLine(args.note, "note", NOTE_MAX);
|
|
180
|
+
refuseSecrets(note, "note");
|
|
181
|
+
const by = args.by === undefined || args.by === null ? agentName(evda) : oneLine(String(args.by), "by", BY_MAX);
|
|
182
|
+
refuseSecrets(by, "by");
|
|
183
|
+
const at = isoLocal(now);
|
|
184
|
+
all[key] = { by, at, note };
|
|
185
|
+
writeAcknowledged(evda, all);
|
|
186
|
+
return { key, path, section, first: true, by, at, note, already: null };
|
|
187
|
+
}
|
package/lib/browse.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// The whole record as a folder tree, for the owner's own app (HQ-2038).
|
|
2
|
+
//
|
|
3
|
+
// The EVÐA Mac app shows every folder of the record, not only the record roles, one folder at a
|
|
4
|
+
// time so a record of tens of thousands of files opens fast. Three rules decide what it lists, and
|
|
5
|
+
// each is the rule another reader already uses, never a copy of it:
|
|
6
|
+
//
|
|
7
|
+
// what the scan reads sync-rules `scanSkip` with the plan's excludes and the sub-record
|
|
8
|
+
// boundaries, the same predicate the shadow scan and doctor walk with, so
|
|
9
|
+
// the app and the sync agree on what is in the record. Dot-named entries
|
|
10
|
+
// (.obsidian, .claude) are left out of the view on top of that.
|
|
11
|
+
// which circle circles `homesOf` through readers `readersFor`, the resolver doctor and
|
|
12
|
+
// the MCP server use: a file shows the circle that homes it.
|
|
13
|
+
// what stays closed `noted: true` and `sealed: true` entries (readers `isNotedFile`,
|
|
14
|
+
// `isSealedFile`), and a part whose read_by says no AI reads it (`none`)
|
|
15
|
+
// or does not parse: the app hosts the owner's AI connection, so it keeps
|
|
16
|
+
// those closed as the phone keeps a noted entry closed.
|
|
17
|
+
//
|
|
18
|
+
// `openEntry` opens exactly what `browse` lists as text: it walks the same rules down the path, so
|
|
19
|
+
// a path the listing would not show is refused however it is spelled.
|
|
20
|
+
//
|
|
21
|
+
// Listing never reads a file iCloud holds only in the cloud (a dataless file): reading it would
|
|
22
|
+
// download it. Such a file is listed with `cloud: true` and its frontmatter unread; opening it reads
|
|
23
|
+
// it, and a noted or sealed entry is refused then.
|
|
24
|
+
|
|
25
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { scanSkip, datalessAmong, isTextPath } from "./sync-rules.mjs";
|
|
28
|
+
import { planBeside } from "./circle-plan.mjs";
|
|
29
|
+
import { boundaries } from "./records.mjs";
|
|
30
|
+
import { resolveRole } from "./roles.mjs";
|
|
31
|
+
import { readContext, readersFor, isNotedFile, isSealedFile, aiView } from "./readers.mjs";
|
|
32
|
+
import { EvdaError } from "./errors.mjs";
|
|
33
|
+
import { FENCE_OPEN } from "./agents.mjs";
|
|
34
|
+
import { write as writeToday, todayPath } from "./tools.mjs";
|
|
35
|
+
|
|
36
|
+
// The default preset: the roles pinned above the tree, in this order, when the mapping gives each a
|
|
37
|
+
// folder. The journal first. `work` is a live source (harlequin:), not a folder, so it is never pinned.
|
|
38
|
+
export const DEFAULT_PINS = ["record/daily", "record/captures", "record/people", "collections", "collections/areas", "knowledge"];
|
|
39
|
+
|
|
40
|
+
// The record roles keep the names the phone gives them; any other pin is named by its folder.
|
|
41
|
+
const ROLE_TITLES = { "record/daily": "Journal", "record/captures": "Captures", "record/people": "People" };
|
|
42
|
+
|
|
43
|
+
// The largest file the app opens as text. Bigger text is listed and stays closed.
|
|
44
|
+
export const MAX_OPEN_BYTES = 4 * 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
const DATED = /^\d{4}-\d{2}-\d{2}/;
|
|
47
|
+
|
|
48
|
+
// Tooling the view leaves out, by kind (HQ-2038, round 2): a package manifest or lockfile, a shell
|
|
49
|
+
// script, a license, a backup copy, an empty file. It stays in the record and in the sync; the app
|
|
50
|
+
// does not list or open it, and each folder says how many it left out. Dot-named entries are left
|
|
51
|
+
// out the same way.
|
|
52
|
+
const LOCKFILE = /(^package(-lock)?\.json$)|(-lock\.json$)|(^pnpm-lock\.yaml$)|(^yarn\.lock$)/i;
|
|
53
|
+
export function isTooling(name, size) {
|
|
54
|
+
if (name.startsWith(".")) return true;
|
|
55
|
+
if (size === 0) return true;
|
|
56
|
+
if (LOCKFILE.test(name)) return true;
|
|
57
|
+
if (/\.(sh|zsh|bash)$/i.test(name)) return true;
|
|
58
|
+
if (/^(LICENSE|LICENCE|COPYING)(\.(md|txt))?$/i.test(name)) return true;
|
|
59
|
+
if (/\.bak($|[.-])|~$/i.test(name)) return true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The files that say how the record and its agent work: the manifest, the operating layer the
|
|
64
|
+
// mapping names, the agent pointer files create-evda writes, and any root file carrying the
|
|
65
|
+
// generated core fence (his IRIS.md). Only at the record's root. The app groups them under one
|
|
66
|
+
// entry instead of mixing them with notes.
|
|
67
|
+
const AGENT_FILES = ["evda.json", "AGENTS.md", "CLAUDE.md", "GEMINI.md"];
|
|
68
|
+
function isOperating(bctx, name) {
|
|
69
|
+
if (AGENT_FILES.includes(name)) return true;
|
|
70
|
+
const agents = bctx.evda.manifest?.mapping?.agents;
|
|
71
|
+
if (typeof agents === "string" && agents.replace(/^\.\//, "") === name) return true;
|
|
72
|
+
if (!/\.md$/i.test(name)) return false;
|
|
73
|
+
if (datalessAmong(bctx.evda.dir, [name]).size) return false;
|
|
74
|
+
try {
|
|
75
|
+
return readFileSync(join(bctx.evda.dir, name), "utf8").includes(FENCE_OPEN);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// "01_Projects" -> "Projects", "03_Resources/Wiki" -> "Wiki".
|
|
82
|
+
export function folderTitle(rel) {
|
|
83
|
+
const last = rel.split("/").pop() ?? rel;
|
|
84
|
+
const t = last.replace(/^\d+[\s_.-]+/, "").replace(/[_-]+/g, " ").trim();
|
|
85
|
+
return t ? t[0].toUpperCase() + t.slice(1) : last;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Everything one listing needs, read once: the scan's skip rule, the circles and the readers.
|
|
89
|
+
export function browseContext(evda) {
|
|
90
|
+
let extra = [];
|
|
91
|
+
try { extra = planBeside(evda)?.plan?.exclude ?? []; } catch { extra = []; }
|
|
92
|
+
const skip = scanSkip([...extra, ...boundaries(evda)]);
|
|
93
|
+
return { evda, skip, readers: readContext(evda) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Why the app keeps a file closed, or null when it opens. `cloud` skips the frontmatter read.
|
|
97
|
+
export function closedBecause(bctx, abs, { cloud = false } = {}) {
|
|
98
|
+
if (!cloud && isSealedFile(abs)) return "sealed";
|
|
99
|
+
if (!cloud && isNotedFile(abs)) return "noted";
|
|
100
|
+
const r = readersFor(bctx.readers, abs);
|
|
101
|
+
if (r.broken) return "reader";
|
|
102
|
+
if (Array.isArray(r.readers) && r.readers.length > 0 && r.readers.every((x) => x.kind === "none")) return "reader";
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const circleOf = (bctx, abs) => readersFor(bctx.readers, abs).circle;
|
|
107
|
+
|
|
108
|
+
const shown = (name) => !name.startsWith(".");
|
|
109
|
+
|
|
110
|
+
// A relative folder or file path, refused unless every segment is a plain, visible name.
|
|
111
|
+
function cleanRel(rel, what) {
|
|
112
|
+
const r = String(rel ?? "").replace(/^\/+|\/+$/g, "");
|
|
113
|
+
if (r === "") return "";
|
|
114
|
+
const parts = r.split("/");
|
|
115
|
+
if (parts.some((p) => p === "" || p === "." || p === ".." || !shown(p))) throw new EvdaError(`${rel} is not ${what} in your record.`, { code: "OUTSIDE", exit: 2 });
|
|
116
|
+
return parts.join("/");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Walk the rules down `rel` from the root: every segment visible, not skipped by the scan, and no
|
|
120
|
+
// symlink on the way. Returns the lstat of the last segment, or null when the path is not one the
|
|
121
|
+
// listing shows (missing included).
|
|
122
|
+
function reachable(bctx, rel) {
|
|
123
|
+
const parts = rel.split("/");
|
|
124
|
+
let st = null;
|
|
125
|
+
for (let i = 0; i < parts.length; i++) {
|
|
126
|
+
const sub = parts.slice(0, i + 1).join("/");
|
|
127
|
+
try { st = lstatSync(join(bctx.evda.dir, sub)); } catch { return null; }
|
|
128
|
+
if (st.isSymbolicLink() || bctx.skip(sub, st)) return null;
|
|
129
|
+
if (i < parts.length - 1 && !st.isDirectory()) return null;
|
|
130
|
+
}
|
|
131
|
+
return st;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The pinned folders: each DEFAULT_PINS role the mapping gives a folder, with whether it is there.
|
|
135
|
+
// A role the mapping leaves out is not pinned, except the record roles, which read from
|
|
136
|
+
// `record/<x>` as everywhere else.
|
|
137
|
+
export function pins(evda, { bctx = browseContext(evda) } = {}) {
|
|
138
|
+
const out = [];
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const role of DEFAULT_PINS) {
|
|
141
|
+
let r;
|
|
142
|
+
try { r = resolveRole(evda, role); } catch { continue; }
|
|
143
|
+
if (r.kind === "source") continue;
|
|
144
|
+
if (!r.mapped && !role.startsWith("record/")) continue;
|
|
145
|
+
let folder;
|
|
146
|
+
try { folder = cleanRel(r.mapped ? String(r.rel).replace(/^(\.\/)+/, "") : role, "a folder"); } catch { continue; }
|
|
147
|
+
if (folder === "" || seen.has(folder)) continue;
|
|
148
|
+
seen.add(folder);
|
|
149
|
+
const st = reachable(bctx, folder);
|
|
150
|
+
out.push({ role, title: ROLE_TITLES[role] ?? folderTitle(folder), folder, exists: Boolean(st?.isDirectory()) });
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
156
|
+
|
|
157
|
+
// Dated names newest first, ahead of the rest; the rest by name.
|
|
158
|
+
function fileOrder(a, b) {
|
|
159
|
+
const da = DATED.test(a.name), db = DATED.test(b.name);
|
|
160
|
+
if (da && db) return -byName(a, b);
|
|
161
|
+
if (da !== db) return da ? -1 : 1;
|
|
162
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// One folder's children: its folders and its files, each file with the circle that homes it.
|
|
166
|
+
// `hidden` counts the files kept closed (noted, sealed, a part no AI reads), never their names.
|
|
167
|
+
// `noted` gives each noted entry by its date alone (HQ-2049), newest first: the date its name
|
|
168
|
+
// starts with, else the day it was last written. Never a name, a title or a word of it.
|
|
169
|
+
// `tooling` counts what the view leaves out by kind (isTooling). At the root, the files that say
|
|
170
|
+
// how the record works are left to `operating` instead.
|
|
171
|
+
export function browse(evda, folder = "", { bctx = browseContext(evda) } = {}) {
|
|
172
|
+
const rel = cleanRel(folder, "a folder");
|
|
173
|
+
if (rel !== "") {
|
|
174
|
+
const st = reachable(bctx, rel);
|
|
175
|
+
if (!st || !st.isDirectory()) throw new EvdaError(`${rel} is not a folder in your record.`, { code: "NOT_FOUND", exit: 2 });
|
|
176
|
+
}
|
|
177
|
+
const abs = rel === "" ? evda.dir : join(evda.dir, rel);
|
|
178
|
+
const dirs = [];
|
|
179
|
+
const files = [];
|
|
180
|
+
let hidden = 0;
|
|
181
|
+
let tooling = 0;
|
|
182
|
+
const noted = [];
|
|
183
|
+
const candidates = [];
|
|
184
|
+
for (const name of readdirSync(abs)) {
|
|
185
|
+
const child = rel === "" ? name : `${rel}/${name}`;
|
|
186
|
+
let st;
|
|
187
|
+
try { st = lstatSync(join(evda.dir, child)); } catch { continue; }
|
|
188
|
+
if (st.isSymbolicLink() || bctx.skip(child, st)) continue;
|
|
189
|
+
if (!shown(name)) { tooling++; continue; }
|
|
190
|
+
if (st.isDirectory()) dirs.push({ path: child, name });
|
|
191
|
+
else if (!st.isFile()) continue;
|
|
192
|
+
else if (rel === "" && isOperating(bctx, name)) continue;
|
|
193
|
+
else if (isTooling(name, st.size)) tooling++;
|
|
194
|
+
else candidates.push({ child, name, size: st.size, mtime: st.mtimeMs });
|
|
195
|
+
}
|
|
196
|
+
// Every file, not only notes: a PDF or an image iCloud holds only in the cloud is listed as such,
|
|
197
|
+
// so the app says so and fetches it on a click instead of downloading it by opening (HQ-2071).
|
|
198
|
+
const cloud = datalessAmong(evda.dir, candidates.map((c) => c.child));
|
|
199
|
+
for (const c of candidates) {
|
|
200
|
+
const fabs = join(evda.dir, c.child);
|
|
201
|
+
const inCloud = cloud.has(c.child);
|
|
202
|
+
const why = closedBecause(bctx, fabs, { cloud: inCloud });
|
|
203
|
+
if (why) {
|
|
204
|
+
hidden++;
|
|
205
|
+
if (why === "noted") noted.push(notedDate(c.name, c.mtime));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
files.push({ path: c.child, name: c.name, size: c.size, circle: circleOf(bctx, fabs), text: isTextPath(c.child) && c.size <= MAX_OPEN_BYTES, cloud: inCloud });
|
|
209
|
+
}
|
|
210
|
+
dirs.sort(byName);
|
|
211
|
+
files.sort(fileOrder);
|
|
212
|
+
noted.sort().reverse();
|
|
213
|
+
return { folder: rel, dirs, files, hidden, noted, tooling };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A noted entry's date and nothing else: the YYYY-MM-DD its name starts with, else the local day
|
|
217
|
+
// it was last written.
|
|
218
|
+
export function notedDate(name, mtimeMs) {
|
|
219
|
+
const m = DATED.exec(name);
|
|
220
|
+
if (m) return m[0];
|
|
221
|
+
const d = new Date(mtimeMs);
|
|
222
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
223
|
+
return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The files that say how the record and its agent work, from the root (isOperating), each with its
|
|
227
|
+
// circle, by name. Closed ones are left out as anywhere else.
|
|
228
|
+
export function operating(evda, { bctx = browseContext(evda) } = {}) {
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const name of readdirSync(evda.dir).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))) {
|
|
231
|
+
let st;
|
|
232
|
+
try { st = lstatSync(join(evda.dir, name)); } catch { continue; }
|
|
233
|
+
if (!st.isFile() || st.isSymbolicLink() || bctx.skip(name, st) || !shown(name) || !isOperating(bctx, name)) continue;
|
|
234
|
+
const abs = join(evda.dir, name);
|
|
235
|
+
if (closedBecause(bctx, abs)) continue;
|
|
236
|
+
out.push({ path: name, name, size: st.size, circle: circleOf(bctx, abs), text: isTextPath(name) && st.size <= MAX_OPEN_BYTES, cloud: false });
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Append the owner's words to today's daily note, verbatim, under a new timestamped section: the
|
|
242
|
+
// same write evda_write makes (lib/tools.mjs write, lib/day-file.mjs appendToDay), so nothing is
|
|
243
|
+
// ever replaced. Returns { path, bytes, section }.
|
|
244
|
+
export const CAPTURE_CONTEXT = "Typed into the capture box in the EVÐA app.";
|
|
245
|
+
export function capture(evda, words, { now = new Date() } = {}) {
|
|
246
|
+
return writeToday(evda, { words, context: CAPTURE_CONTEXT }, { now });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The circle a file at the record's root would land in: the record's default home. The app tags a
|
|
250
|
+
// file with its circle only when it is not this one, so the usual circle is not on every row.
|
|
251
|
+
export function defaultCircle(evda, { bctx = browseContext(evda) } = {}) {
|
|
252
|
+
return circleOf(bctx, join(evda.dir, "evda-default-home.md"));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Today's Journal (HQ-2038): today's daily note, always there for the app even before it is
|
|
256
|
+
// written. `exists` false means an empty page; the first capture creates it through `write`.
|
|
257
|
+
// `path` is null only when the record maps no daily role.
|
|
258
|
+
export function today(evda, { now = new Date(), bctx = browseContext(evda) } = {}) {
|
|
259
|
+
const home = defaultCircle(evda, { bctx });
|
|
260
|
+
let path;
|
|
261
|
+
try { path = todayPath(evda, { now }); } catch { return { path: null, exists: false, text: "", circle: home, home }; }
|
|
262
|
+
const st = reachable(bctx, path);
|
|
263
|
+
if (!st?.isFile()) return { path, exists: false, text: "", circle: home, home };
|
|
264
|
+
const opened = openEntry(evda, path, { bctx });
|
|
265
|
+
return { path, exists: true, text: opened.text, circle: opened.circle, home };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Every text file the listing shows, the whole record walked one `browse` at a time, plus the
|
|
269
|
+
// operating files at the root (HQ-2049): the app's quick open and its link resolution read this,
|
|
270
|
+
// so a name the tree would not show (noted, sealed, a part no AI reads, tooling, excluded) is never
|
|
271
|
+
// offered or resolved. Paths only, in walk order; nothing of any file's words.
|
|
272
|
+
export function index(evda, { bctx = browseContext(evda) } = {}) {
|
|
273
|
+
const out = operating(evda, { bctx }).filter((f) => f.text).map((f) => f.path);
|
|
274
|
+
const walk = (folder) => {
|
|
275
|
+
const f = browse(evda, folder, { bctx });
|
|
276
|
+
for (const file of f.files) if (file.text) out.push(file.path);
|
|
277
|
+
for (const d of f.dirs) walk(d.path);
|
|
278
|
+
};
|
|
279
|
+
walk("");
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// One listed text file, opened: its words with any sealed section replaced by one line, and its
|
|
284
|
+
// circle. Anything `browse` would not list as text is refused, with nothing of it returned.
|
|
285
|
+
export function openEntry(evda, path, { bctx = browseContext(evda) } = {}) {
|
|
286
|
+
const rel = cleanRel(path, "a file");
|
|
287
|
+
const refused = () => new EvdaError(`${path} is not in your record.`, { code: "OUTSIDE", exit: 2 });
|
|
288
|
+
if (rel === "") throw refused();
|
|
289
|
+
const st = reachable(bctx, rel);
|
|
290
|
+
if (!st) throw refused();
|
|
291
|
+
if (!st.isFile()) throw refused();
|
|
292
|
+
const name = rel.split("/").pop();
|
|
293
|
+
if (isTooling(name, st.size) && !(rel === name && isOperating(bctx, name))) throw refused();
|
|
294
|
+
const abs = join(evda.dir, rel);
|
|
295
|
+
// A hard path check after the walk: the resolved file is inside the resolved record.
|
|
296
|
+
const root = realpathSync(evda.dir);
|
|
297
|
+
if (!realpathSync(abs).startsWith(root + "/")) throw refused();
|
|
298
|
+
if (!isTextPath(rel)) throw new EvdaError(`${rel} is not text, so it opens in Finder, not here.`, { code: "NOT_TEXT", exit: 2 });
|
|
299
|
+
if (st.size > MAX_OPEN_BYTES) throw new EvdaError(`${rel} is too large to open here.`, { code: "TOO_LARGE", exit: 2 });
|
|
300
|
+
if (closedBecause(bctx, abs)) throw refused();
|
|
301
|
+
const text = readFileSync(abs, "utf8");
|
|
302
|
+
return { path: rel, text: aiView(text).text, circle: circleOf(bctx, abs) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Attachments: PDFs, images and every other file the tree lists (HQ-2071) ───────────────────
|
|
306
|
+
//
|
|
307
|
+
// The app shows a PDF or an image itself and hands any other attachment to the Mac's default app.
|
|
308
|
+
// What it may show is what `browse` lists and `openEntry` would not open as text: the same walk,
|
|
309
|
+
// the same closed rules, decided from names and stat alone, so asking never downloads a file iCloud
|
|
310
|
+
// holds only in the cloud. The app serves the bytes itself, only for a path this answered.
|
|
311
|
+
|
|
312
|
+
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "heic", "heif", "gif", "webp"]);
|
|
313
|
+
|
|
314
|
+
// "pdf", "image", or "other", by the name alone.
|
|
315
|
+
export function attachmentKind(rel) {
|
|
316
|
+
const name = String(rel ?? "").split("/").pop() ?? "";
|
|
317
|
+
const dot = name.lastIndexOf(".");
|
|
318
|
+
const ext = dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
|
319
|
+
if (ext === "pdf") return "pdf";
|
|
320
|
+
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
|
321
|
+
return "other";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// One listed file that is not text: its kind, size, circle, and whether its bytes are only in
|
|
325
|
+
// iCloud. Refused, with nothing of it returned, whenever `browse` would not list it.
|
|
326
|
+
export function attachEntry(evda, path, { bctx = browseContext(evda) } = {}) {
|
|
327
|
+
const rel = cleanRel(path, "a file");
|
|
328
|
+
const refused = () => new EvdaError(`${path} is not in your record.`, { code: "OUTSIDE", exit: 2 });
|
|
329
|
+
if (rel === "") throw refused();
|
|
330
|
+
const st = reachable(bctx, rel);
|
|
331
|
+
if (!st || !st.isFile()) throw refused();
|
|
332
|
+
const name = rel.split("/").pop();
|
|
333
|
+
if (isTooling(name, st.size)) throw refused();
|
|
334
|
+
const abs = join(evda.dir, rel);
|
|
335
|
+
const root = realpathSync(evda.dir);
|
|
336
|
+
if (!realpathSync(abs).startsWith(root + "/")) throw refused();
|
|
337
|
+
if (isTextPath(rel)) throw new EvdaError(`${rel} is text, so it opens as a note.`, { code: "TEXT", exit: 2 });
|
|
338
|
+
const cloud = datalessAmong(evda.dir, [rel]).has(rel);
|
|
339
|
+
if (closedBecause(bctx, abs, { cloud })) throw refused();
|
|
340
|
+
return { path: rel, kind: attachmentKind(rel), size: st.size, circle: circleOf(bctx, abs), cloud };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Every listed file that is not text, by path, the whole tree walked as `index` walks it: what a
|
|
344
|
+
// link or an embed to a PDF or an image resolves against. Paths only.
|
|
345
|
+
export function attachments(evda, { bctx = browseContext(evda) } = {}) {
|
|
346
|
+
const out = [];
|
|
347
|
+
const walk = (folder) => {
|
|
348
|
+
const f = browse(evda, folder, { bctx });
|
|
349
|
+
for (const file of f.files) if (!file.text && !isTextPath(file.path)) out.push(file.path);
|
|
350
|
+
for (const d of f.dirs) walk(d.path);
|
|
351
|
+
};
|
|
352
|
+
walk("");
|
|
353
|
+
return out;
|
|
354
|
+
}
|
package/lib/circle-plan.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { circleManifest, readCircles, writeCircle, opaqueIdOf, circlesDir, isRel
|
|
|
39
39
|
import { grant } from "./grants.mjs";
|
|
40
40
|
import { openEvda, readManifest, writeManifest } from "./manifest.mjs";
|
|
41
41
|
import { EvdaError } from "./errors.mjs";
|
|
42
|
+
import { defaultPartsPlan } from "./setup.mjs";
|
|
42
43
|
|
|
43
44
|
export const PLAN_FILE = "plan.json";
|
|
44
45
|
|
|
@@ -221,7 +222,8 @@ export function declare(dir, plan, { circlesRel = "06_Metadata/Circles", grantsR
|
|
|
221
222
|
mkdirSync(join(dir, manifest.mapping.grants), { recursive: true });
|
|
222
223
|
const evda = openEvda(dir);
|
|
223
224
|
const existing = new Map((readCircles(evda) ?? []).filter((c) => c.manifest !== null).map((c) => [c.name, c.manifest]));
|
|
224
|
-
|
|
225
|
+
// An owner with no name yet (create-evda run with no --owner) is named by the record id.
|
|
226
|
+
const owner = holder || manifest.owner?.name || manifest.id;
|
|
225
227
|
|
|
226
228
|
const written = [];
|
|
227
229
|
for (const c of planned.circles) {
|
|
@@ -232,6 +234,8 @@ export function declare(dir, plan, { circlesRel = "06_Metadata/Circles", grantsR
|
|
|
232
234
|
isPublic: c.public,
|
|
233
235
|
holders: [owner],
|
|
234
236
|
members: numbered(c.members.map((m) => ({ path: m.path }))),
|
|
237
|
+
// A plan circle may start the circle's rules (Spec 07, section 6); the default parts start empty.
|
|
238
|
+
policy: plan.circles.find((p) => p.name === c.name)?.policy ?? null,
|
|
235
239
|
description: c.public ? "Public: anyone may read it." : `Private: read by the devices granted it, and no one else.`,
|
|
236
240
|
});
|
|
237
241
|
written.push(writeCircle(evda, c.name, m, { overwrite: true }));
|
|
@@ -275,3 +279,22 @@ export function deviceCircles(plan, device, view = null) {
|
|
|
275
279
|
: [];
|
|
276
280
|
return { atlasId: d.atlasId, holds, never: plan.circles.map((c) => c.name).filter((n) => !holds.includes(n)), drift };
|
|
277
281
|
}
|
|
282
|
+
|
|
283
|
+
// The default parts (HQ-1925): journal and private, from setup.mjs `defaultPartsPlan`, declared by
|
|
284
|
+
// create-evda on a new record and, on the owner's yes, over an adopted record that has none. A
|
|
285
|
+
// record that already has a private part is left exactly as it is, so a yes can never replace
|
|
286
|
+
// parts the owner declared. `dryRun` answers whether the default is needed and writes nothing.
|
|
287
|
+
// Returns { needed, declared, parts }, parts being the private part names it has or would have.
|
|
288
|
+
export function defaultParts(dir, { dryRun = false, circlesRel = "circles", grantsRel = "grants", at = new Date() } = {}) {
|
|
289
|
+
const evda = openEvda(dir);
|
|
290
|
+
const have = (readCircles(evda) ?? []).filter((c) => c.manifest && c.manifest.public !== true && !c.mount).map((c) => c.name).sort();
|
|
291
|
+
if (have.length) return { needed: false, declared: false, parts: have };
|
|
292
|
+
const m = evda.manifest;
|
|
293
|
+
const slug = String(m.name ?? "record").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "record";
|
|
294
|
+
const recordId = typeof m.id === "string" && m.id ? m.id : `${slug}.atlas`;
|
|
295
|
+
const plan = defaultPartsPlan(m.mapping ?? {}, recordId);
|
|
296
|
+
const parts = plan.circles.map((c) => c.name).sort();
|
|
297
|
+
if (dryRun) return { needed: true, declared: false, parts };
|
|
298
|
+
declare(dir, plan, { circlesRel, grantsRel, recordId, at });
|
|
299
|
+
return { needed: false, declared: true, parts };
|
|
300
|
+
}
|
package/lib/circles.mjs
CHANGED
|
@@ -552,7 +552,11 @@ export function markdownFiles(root) {
|
|
|
552
552
|
for (const name of readdirSync(abs).sort()) {
|
|
553
553
|
if (name.startsWith(".") || name === "node_modules") continue;
|
|
554
554
|
const p = join(abs, name);
|
|
555
|
-
|
|
555
|
+
// A dangling symlink (target lives only on another machine, e.g. raw media kept
|
|
556
|
+
// outside the synced vault) has nothing to stat. It is not markdown content either
|
|
557
|
+
// way, so skip it rather than let the whole walk crash on one broken pointer.
|
|
558
|
+
let st;
|
|
559
|
+
try { st = statSync(p); } catch { continue; }
|
|
556
560
|
if (st.isDirectory()) walk(p);
|
|
557
561
|
else if (st.isFile() && /\.md$/i.test(name)) out.push(p);
|
|
558
562
|
}
|
package/lib/clients.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// How each AI client adds the EVDA MCP server, given the command that starts it. No node: imports,
|
|
2
2
|
// so the EVDA app's webview renders the same text `evda connect` prints (lib/connect.mjs).
|
|
3
3
|
|
|
4
|
+
import { everyChatLine, CHAT_APPS, GENERIC_WHERE } from "./every-chat.mjs";
|
|
5
|
+
|
|
4
6
|
export const SERVER_NAME = "evda";
|
|
5
7
|
|
|
6
8
|
const shellQuote = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
|
|
7
9
|
const tomlString = (s) => JSON.stringify(s);
|
|
8
10
|
|
|
9
|
-
// `desktopConfig` is where claude_desktop_config.json lives on this machine, when known.
|
|
10
|
-
|
|
11
|
+
// `desktopConfig` is where claude_desktop_config.json lives on this machine, when known. `filing` is
|
|
12
|
+
// the owner's answer in evda.json, which picks the form of the every-chat line (HQ-2205).
|
|
13
|
+
export function clientsFor(command, args, { desktopConfig = null, filing = null } = {}) {
|
|
11
14
|
const argv = [command, ...args].map(shellQuote).join(" ");
|
|
12
15
|
return [
|
|
13
16
|
{
|
|
@@ -44,5 +47,18 @@ export function clientsFor(command, args, { desktopConfig = null } = {}) {
|
|
|
44
47
|
text: JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "stdio", command, args } } }, null, 2),
|
|
45
48
|
file: ".mcp.json",
|
|
46
49
|
},
|
|
50
|
+
{
|
|
51
|
+
// Not a way to add the server: the one line a chat app needs in its every-chat setting, because
|
|
52
|
+
// a chat that never calls an evda tool is reached by nothing else (lib/every-chat.mjs).
|
|
53
|
+
key: "every-chat",
|
|
54
|
+
label: "Every chat app (so each chat starts as your agent)",
|
|
55
|
+
steps: [
|
|
56
|
+
"Add this line to your chat app's every-chat instructions:",
|
|
57
|
+
...Object.values(CHAT_APPS).map((a) => `${a.label}: ${a.where}`),
|
|
58
|
+
`Any other app: ${GENERIC_WHERE}`,
|
|
59
|
+
],
|
|
60
|
+
text: everyChatLine(filing),
|
|
61
|
+
file: null,
|
|
62
|
+
},
|
|
47
63
|
];
|
|
48
64
|
}
|