create-evda 0.1.4 → 0.1.6
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 +100 -0
- package/README.md +93 -8
- package/bin/evda.mjs +437 -5
- package/create-evda.mjs +68 -12
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +351 -19
- package/lib/filechanges.mjs +622 -0
- package/lib/folders.mjs +22 -8
- package/lib/gather-apply.mjs +635 -0
- package/lib/gather.mjs +808 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +467 -35
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +52 -12
- package/lib/parity.mjs +111 -0
- package/lib/presets.mjs +379 -0
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +102 -1
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +146 -2
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +87 -0
- package/lib/tools.mjs +120 -29
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +25 -15
- package/presets/journal/preset.json +19 -0
- package/presets/projects-and-areas/preset.json +34 -0
- package/skills/evda/SKILL.md +76 -0
- package/templates/AGENTS.md +129 -15
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +32 -5
- package/LICENSE +0 -21
package/lib/references.mjs
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
|
|
35
35
|
import { EvdaError } from "./errors.mjs";
|
|
36
36
|
import { parseYaml } from "./yaml.mjs";
|
|
37
|
+
import { secretShape } from "./secret-shape.mjs";
|
|
38
|
+
|
|
39
|
+
export { secretShape };
|
|
37
40
|
|
|
38
41
|
export const SCHEMES = ["contacts", "1password", "url", "evda"];
|
|
39
42
|
export const REFERENCE_KEYS = ["ref", "label", "resolved"];
|
|
@@ -130,58 +133,9 @@ function bad(msg) {
|
|
|
130
133
|
return new EvdaError(msg, { code: "BAD_REFERENCE", exit: 1 });
|
|
131
134
|
}
|
|
132
135
|
|
|
133
|
-
// Secret detection
|
|
134
|
-
//
|
|
135
|
-
// stamp on their tokens, a bearer header, a URL carrying a password or a credential in its query,
|
|
136
|
-
// and as a last net a long mixed-case high-entropy token that is not a UUID.
|
|
136
|
+
// Secret detection is lib/secret-shape.mjs, shared with the prompt hook; a key named like a secret
|
|
137
|
+
// is refused here too.
|
|
137
138
|
const SECRET_KEY = /(pass(word|wd|phrase)?|secret|token|api[-_ ]?key|access[-_ ]?key|private[-_ ]?key|credential|bearer|cookie|session[-_ ]?id|otp|pin[-_ ]?code|client[-_ ]?secret)/i;
|
|
138
|
-
const SECRET_PARAM = /^(key|token|access_token|api_key|apikey|auth|password|secret|sig|signature|session|x-amz-signature|x-amz-credential)$/i;
|
|
139
|
-
const SECRET_VALUE = [
|
|
140
|
-
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key block"],
|
|
141
|
-
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, "a JWT"],
|
|
142
|
-
[/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/, "an API key (sk-...)"],
|
|
143
|
-
[/\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{8,}\b/, "an API key (sk_live_...)"],
|
|
144
|
-
[/\bgh[pousr]_[A-Za-z0-9]{20,}\b/, "a GitHub token"],
|
|
145
|
-
[/\bglpat-[A-Za-z0-9_-]{20,}\b/, "a GitLab token"],
|
|
146
|
-
[/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, "a Slack token"],
|
|
147
|
-
[/\bAKIA[0-9A-Z]{16}\b/, "an AWS access key id"],
|
|
148
|
-
[/\bAIza[0-9A-Za-z_-]{35}\b/, "a Google API key"],
|
|
149
|
-
[/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/i, "a bearer token"],
|
|
150
|
-
];
|
|
151
|
-
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
152
|
-
|
|
153
|
-
function entropy(s) {
|
|
154
|
-
const counts = new Map();
|
|
155
|
-
for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
156
|
-
let h = 0;
|
|
157
|
-
for (const n of counts.values()) {
|
|
158
|
-
const p = n / s.length;
|
|
159
|
-
h -= p * Math.log2(p);
|
|
160
|
-
}
|
|
161
|
-
return h;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Why a string looks like a secret, or null.
|
|
165
|
-
export function secretShape(value) {
|
|
166
|
-
if (typeof value !== "string") return null;
|
|
167
|
-
for (const [re, what] of SECRET_VALUE) if (re.test(value)) return what;
|
|
168
|
-
const m = /^(?:url:)?([a-z][a-z0-9+.-]*:\/\/[^\s]+)$/i.exec(value);
|
|
169
|
-
if (m) {
|
|
170
|
-
let u = null;
|
|
171
|
-
try { u = new URL(m[1]); } catch { u = null; }
|
|
172
|
-
if (u) {
|
|
173
|
-
if (u.password !== "") return "a password in the URL";
|
|
174
|
-
for (const k of u.searchParams.keys()) if (SECRET_PARAM.test(k)) return `a credential in the URL query (${k})`;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
for (const tok of value.split(/[\s"'<>]+/)) {
|
|
178
|
-
if (tok.length < 40 || UUID.test(tok)) continue;
|
|
179
|
-
if (!/[A-Z]/.test(tok) || !/[a-z]/.test(tok) || !/\d/.test(tok)) continue;
|
|
180
|
-
if (!/^[A-Za-z0-9+/=_-]+$/.test(tok)) continue;
|
|
181
|
-
if (entropy(tok) >= 4.5) return "a long high-entropy token";
|
|
182
|
-
}
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
139
|
|
|
186
140
|
// Why a key name looks like it holds a secret, or null.
|
|
187
141
|
export function secretKey(name) {
|
package/lib/render.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Views rendered at publish (Spec 07 section 5, HQ-1351).
|
|
2
|
+
//
|
|
3
|
+
// One fact, stored once, as a typed entry placed by its subject. The public circle carries a
|
|
4
|
+
// `render` member for each file the site reads, and the file is rendered on the way out from every
|
|
5
|
+
// released entry whose frontmatter names the view:
|
|
6
|
+
//
|
|
7
|
+
// ---
|
|
8
|
+
// kind: piece
|
|
9
|
+
// share: public the mark, so the public circle's rule gathers it
|
|
10
|
+
// view: projects which render member it feeds
|
|
11
|
+
// order: 3 its place in the rendered list; unique within the view
|
|
12
|
+
// id: proj_iris the item's own id, repeated so a reader of the folder sees it
|
|
13
|
+
// title: Iris
|
|
14
|
+
// ---
|
|
15
|
+
// ```json
|
|
16
|
+
// { "id": "proj_iris", ... } the item, exactly as the rendered file carries it
|
|
17
|
+
// ```
|
|
18
|
+
//
|
|
19
|
+
// A list view is the entries' items as one JSON list, sorted by `order`. An object view holds
|
|
20
|
+
// exactly one entry and renders its item alone. The rendered bytes are deterministic (two-space
|
|
21
|
+
// JSON, one trailing newline), so the same entries render to the same file on any device.
|
|
22
|
+
//
|
|
23
|
+
// Provenance (HQ-1367). An entry re-derived from its subject's own record says where from and when:
|
|
24
|
+
//
|
|
25
|
+
// derived_from: the record paths (a folder, a README) and shipped tickets (HQ-<n>) it was
|
|
26
|
+
// - "01_Projects/Evda/Iris - Universal Thinking Partner/README.md"
|
|
27
|
+
// - HQ-1351 derived from; a path must exist in the record
|
|
28
|
+
// derived_at: "2026-09-25" when; an ISO date or date-time, never in the future
|
|
29
|
+
//
|
|
30
|
+
// Both or neither: an entry with only one is a problem, since a stranger's answer must trace to what
|
|
31
|
+
// the record held. The fields live in the entry's frontmatter and never reach the rendered file.
|
|
32
|
+
// `provenance` on each rendered entry reports them, with `changed`: the source files modified after
|
|
33
|
+
// derived_at, which is a fact about files the publish pass reads to find what to re-derive.
|
|
34
|
+
//
|
|
35
|
+
// Everything that can go wrong is a problem the caller fails on, never a dropped entry: a view no
|
|
36
|
+
// render member declares, a missing or repeated `order`, a body with no JSON block or one that does
|
|
37
|
+
// not parse, an `id` in the frontmatter that is not the item's, an object view with two entries, a
|
|
38
|
+
// render member that draws nothing. A publish with any problem refuses; doctor names each one.
|
|
39
|
+
|
|
40
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
41
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
42
|
+
import { createHash } from "node:crypto";
|
|
43
|
+
import { frontmatterOf, withoutSealed } from "./circles.mjs";
|
|
44
|
+
|
|
45
|
+
// The frontmatter keys an entry uses to reach a view.
|
|
46
|
+
export const VIEW = "view";
|
|
47
|
+
export const ORDER = "order";
|
|
48
|
+
export const DERIVED_FROM = "derived_from";
|
|
49
|
+
export const DERIVED_AT = "derived_at";
|
|
50
|
+
|
|
51
|
+
const TICKET = /^HQ-[1-9][0-9]*$/;
|
|
52
|
+
const ISO = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
53
|
+
|
|
54
|
+
// The provenance an entry carries: { provenance } (null when it carries none) or { problem }.
|
|
55
|
+
// `recordDir` resolves paths; `now` is injectable so a test can pin "the future".
|
|
56
|
+
export function provenanceOf(front, recordDir, { now = new Date() } = {}) {
|
|
57
|
+
const from = front?.[DERIVED_FROM];
|
|
58
|
+
const at = front?.[DERIVED_AT];
|
|
59
|
+
if (from === undefined && at === undefined) return { provenance: null };
|
|
60
|
+
if (from === undefined) return { problem: `has ${DERIVED_AT} and no ${DERIVED_FROM}. A derived entry names what it was derived from.` };
|
|
61
|
+
if (at === undefined) return { problem: `has ${DERIVED_FROM} and no ${DERIVED_AT}. A derived entry says when it was derived.` };
|
|
62
|
+
if (!Array.isArray(from) || from.length === 0 || from.some((s) => typeof s !== "string" || s.trim() === "")) return { problem: `has ${DERIVED_FROM}: ${JSON.stringify(from)}; it is a non-empty list of record paths and HQ-<n> tickets.` };
|
|
63
|
+
const when = String(at);
|
|
64
|
+
if (!ISO.test(when) || Number.isNaN(Date.parse(when))) return { problem: `has ${DERIVED_AT}: ${JSON.stringify(at)}, which is not an ISO date.` };
|
|
65
|
+
const t = Date.parse(when);
|
|
66
|
+
if (t > now.getTime() + 24 * 3600 * 1000) return { problem: `has ${DERIVED_AT}: ${when}, which is in the future.` };
|
|
67
|
+
const root = resolve(recordDir);
|
|
68
|
+
const tickets = [];
|
|
69
|
+
const files = [];
|
|
70
|
+
for (const s of from) {
|
|
71
|
+
if (TICKET.test(s)) { tickets.push(s); continue; }
|
|
72
|
+
if (isAbsolute(s)) return { problem: `derives from ${JSON.stringify(s)}, an absolute path; a source is relative to the record root.` };
|
|
73
|
+
const abs = resolve(root, s);
|
|
74
|
+
if (abs !== root && !abs.startsWith(root + sep)) return { problem: `derives from ${JSON.stringify(s)}, which is outside the record.` };
|
|
75
|
+
if (!existsSync(abs)) return { problem: `derives from ${JSON.stringify(s)}, and nothing is there. A source the record does not hold cannot be traced to.` };
|
|
76
|
+
files.push({ path: s, abs });
|
|
77
|
+
}
|
|
78
|
+
// A date with no time is the end of that day: a source saved that afternoon was read for it.
|
|
79
|
+
const cutoff = /T/.test(when) ? t : t + 24 * 3600 * 1000;
|
|
80
|
+
const changed = files.filter((f) => { try { return statSync(f.abs).mtimeMs > cutoff; } catch { return false; } }).map((f) => f.path);
|
|
81
|
+
return { provenance: { from: [...from], at: when, tickets, files: files.map((f) => f.path), changed } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
85
|
+
|
|
86
|
+
// The item an entry holds: the first ```json fence in the body. { value } or { problem }.
|
|
87
|
+
export function entryJson(text) {
|
|
88
|
+
const m = /^```json[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/m.exec(text);
|
|
89
|
+
if (!m) return { problem: "has no ```json block. A view entry holds its item in one fenced JSON block." };
|
|
90
|
+
try {
|
|
91
|
+
return { value: JSON.parse(m[1]) };
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { problem: `its \`\`\`json block does not parse (${e.message}).` };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Render the bytes of one view. Pure: the same entries render to the same bytes in any order.
|
|
98
|
+
export function renderBytes(shape, items) {
|
|
99
|
+
const value = shape === "object" ? items[0] : items;
|
|
100
|
+
return Buffer.from(JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Group the files released into a public circle (`gathered` is `released()`'s row for it, or any
|
|
104
|
+
// object with byPath and byRule lists of { path, abs, member, circle? }) by the view each names,
|
|
105
|
+
// and render every render member of the circle.
|
|
106
|
+
//
|
|
107
|
+
// Returns { views, loose, problems }:
|
|
108
|
+
// views one per render member: { member, view, path, shape, entries: [{ path, abs, sha256,
|
|
109
|
+
// order, id, home }], bytes } with entries sorted by order; bytes is null when the
|
|
110
|
+
// view has a problem
|
|
111
|
+
// loose the released files that name no view, unchanged, exported as themselves
|
|
112
|
+
// problems [{ path, message }], each one a refusal
|
|
113
|
+
export function renderViews(evda, circle, gathered) {
|
|
114
|
+
const renders = Object.entries(circle.members).filter(([, m]) => m.kind === "render").map(([member, m]) => ({ member, view: m.view, path: m.rel, shape: m.shape, entries: [], bytes: null }));
|
|
115
|
+
const byView = new Map(renders.map((r) => [r.view, r]));
|
|
116
|
+
const loose = [];
|
|
117
|
+
const problems = [];
|
|
118
|
+
const problem = (path, message) => problems.push({ path, message });
|
|
119
|
+
|
|
120
|
+
for (const f of [...gathered.byPath, ...gathered.byRule]) {
|
|
121
|
+
let text;
|
|
122
|
+
try { text = readFileSync(f.abs, "utf8"); } catch (e) { problem(f.path, `could not be read (${e.message}).`); continue; }
|
|
123
|
+
// A sealed section (HQ-1220) is never rendered, whatever fence it holds.
|
|
124
|
+
text = withoutSealed(text).text;
|
|
125
|
+
const front = frontmatterOf(text);
|
|
126
|
+
const view = front?.[VIEW];
|
|
127
|
+
if (view === undefined || view === null || view === "") { loose.push(f); continue; }
|
|
128
|
+
if (typeof view !== "string") { problem(f.path, `has ${VIEW}: ${JSON.stringify(view)}, which is not a view name.`); continue; }
|
|
129
|
+
const target = byView.get(view);
|
|
130
|
+
if (!target) { problem(f.path, `names view "${view}", and no render member of circle "${circle.name}" declares it (declared: ${renders.map((r) => r.view).join(", ") || "none"}). A released entry that reaches nowhere is a silent drop.`); continue; }
|
|
131
|
+
const order = front[ORDER];
|
|
132
|
+
if (!Number.isInteger(order)) { problem(f.path, `has ${ORDER}: ${JSON.stringify(order)}; a view entry says where it goes with an integer.`); continue; }
|
|
133
|
+
const json = entryJson(text);
|
|
134
|
+
if (json.problem) { problem(f.path, json.problem); continue; }
|
|
135
|
+
const item = json.value;
|
|
136
|
+
const itemId = item && typeof item === "object" && !Array.isArray(item) ? item.id : undefined;
|
|
137
|
+
if (front.id !== undefined && itemId !== undefined && String(front.id) !== String(itemId)) { problem(f.path, `says id: ${JSON.stringify(front.id)} in its frontmatter and its item says "id": ${JSON.stringify(itemId)}. One id per fact.`); continue; }
|
|
138
|
+
const prov = provenanceOf(front, evda.dir);
|
|
139
|
+
if (prov.problem) { problem(f.path, prov.problem); continue; }
|
|
140
|
+
target.entries.push({ path: f.path, abs: f.abs, sha256: sha256Hex(Buffer.from(text, "utf8")), order, id: front.id ?? itemId ?? null, home: f.circle ?? circle.name, provenance: prov.provenance, item });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const r of renders) {
|
|
144
|
+
r.entries.sort((a, b) => a.order - b.order || a.path.localeCompare(b.path));
|
|
145
|
+
const seen = new Map();
|
|
146
|
+
for (const e of r.entries) {
|
|
147
|
+
if (seen.has(e.order)) problem(e.path, `has ${ORDER}: ${e.order} in view "${r.view}", and so does ${seen.get(e.order)}. One place per entry.`);
|
|
148
|
+
seen.set(e.order, e.path);
|
|
149
|
+
}
|
|
150
|
+
if (r.entries.length === 0) problem(r.path, `is rendered by member "${r.member}" from view "${r.view}", and no released entry names that view. A view that draws nothing is not published.`);
|
|
151
|
+
if (r.shape === "object" && r.entries.length > 1) problem(r.path, `is an object view ("${r.view}") and ${r.entries.length} entries name it: ${r.entries.map((e) => e.path).join(", ")}. An object view holds exactly one.`);
|
|
152
|
+
const mine = problems.some((p) => p.path === r.path || r.entries.some((e) => e.path === p.path));
|
|
153
|
+
if (!mine) r.bytes = renderBytes(r.shape, r.entries.map((e) => e.item));
|
|
154
|
+
for (const e of r.entries) delete e.item;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { views: renders, loose, problems };
|
|
158
|
+
}
|
package/lib/roles.mjs
CHANGED
|
@@ -22,8 +22,52 @@ export const ROLES = {
|
|
|
22
22
|
// Who may read each circle: one grant file per (reader, circle), kept outside the circles as the
|
|
23
23
|
// substrate keeps members/ outside ecos/. The access answer cannot live inside what it protects.
|
|
24
24
|
grants: { layer: "circles", kind: "dir" },
|
|
25
|
+
// Where circles held FROM OTHER HOLDERS land (HQ-835): `<mounts>/<owner record id>/<circle>/`, one
|
|
26
|
+
// directory per owner and one subfolder per circle, so several circles from one owner are held
|
|
27
|
+
// side by side. Unmapped, a record holds one circle per owner at `mounts/<owner record id>`.
|
|
28
|
+
mounts: { layer: "circles", kind: "dir" },
|
|
29
|
+
// The four roles every record has at any scale (Spec 07 section 2, "Four roles, at every scale",
|
|
30
|
+
// HQ-1371). Each maps to one or more paths: the role's own key and any `<role>/<facet>` key, so a
|
|
31
|
+
// record with three journal folders writes `journal`, `journal/captures`, `journal/dreams`. Every
|
|
32
|
+
// value stays a string, so a reader that only knows string mappings still opens the record.
|
|
33
|
+
// `work` may instead name a live source, `harlequin:` or `harlequin:<project>` (or the object
|
|
34
|
+
// `{ "source": "harlequin", "project": "<id>" }`), read by lib/work.mjs.
|
|
35
|
+
journal: { layer: null, kind: "dir", four: true },
|
|
36
|
+
work: { layer: null, kind: "dir", four: true, source: true },
|
|
37
|
+
collections: { layer: null, kind: "dir", four: true },
|
|
38
|
+
knowledge: { layer: null, kind: "dir", four: true },
|
|
25
39
|
};
|
|
26
40
|
|
|
41
|
+
export const FOUR_ROLES = ["journal", "work", "collections", "knowledge"];
|
|
42
|
+
|
|
43
|
+
// Keys named before the four roles, each a facet of one of them. They keep working under their
|
|
44
|
+
// own names (every tool that reads `record/daily` still does); the four-role reader counts them.
|
|
45
|
+
export const LEGACY_FACETS = { "record/daily": "journal", "record/captures": "journal", "record/people": "collections", ledger: "knowledge" };
|
|
46
|
+
|
|
47
|
+
// The sources `work` can name. Anything else is refused by doctor rather than read as a path.
|
|
48
|
+
export const WORK_SOURCES = ["harlequin"];
|
|
49
|
+
|
|
50
|
+
// The four role a mapping key fills, or null: `journal`, `journal/dreams`, `record/daily` all fill
|
|
51
|
+
// journal.
|
|
52
|
+
export function fourRoleOf(key) {
|
|
53
|
+
if (LEGACY_FACETS[key]) return LEGACY_FACETS[key];
|
|
54
|
+
const base = key.split("/")[0];
|
|
55
|
+
return FOUR_ROLES.includes(base) && (key === base || key.length > base.length + 1) ? base : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The spec a key is read with: its own, or its four role's for a facet key.
|
|
59
|
+
const specOf = (role) => ROLES[role] ?? (fourRoleOf(role) && role.includes("/") ? ROLES[fourRoleOf(role)] : undefined);
|
|
60
|
+
|
|
61
|
+
// A `work` value that names a source rather than a folder, as { source, project }, or null for a
|
|
62
|
+
// path. The string form is `<source>:` or `<source>:<project>`, the same scheme-prefix shape a
|
|
63
|
+
// reference uses (Spec 07 section 4).
|
|
64
|
+
export function workSource(value) {
|
|
65
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) return { source: value.source, project: value.project ?? null, form: "object" };
|
|
66
|
+
if (typeof value !== "string") return null;
|
|
67
|
+
const m = /^([a-z][a-z0-9-]*):(.*)$/.exec(value);
|
|
68
|
+
return m ? { source: m[1], project: m[2] === "" ? null : m[2], form: "string" } : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
export const ROLE_NAMES = Object.keys(ROLES);
|
|
28
72
|
|
|
29
73
|
export function rolesForLayer(layer) {
|
|
@@ -33,11 +77,15 @@ export function rolesForLayer(layer) {
|
|
|
33
77
|
// Resolve one role. Never throws for an absent or missing role; the caller decides how loud
|
|
34
78
|
// to be. Throws only when a mapped path escapes the record, which is refused everywhere.
|
|
35
79
|
export function resolveRole(evda, role) {
|
|
36
|
-
const spec =
|
|
80
|
+
const spec = specOf(role);
|
|
37
81
|
const rel = evda.manifest.mapping[role];
|
|
38
82
|
if (rel === undefined) {
|
|
39
83
|
return { role, layer: spec?.layer ?? null, known: !!spec, mapped: false, rel: null, abs: null, exists: false };
|
|
40
84
|
}
|
|
85
|
+
if (spec?.source) {
|
|
86
|
+
const source = workSource(rel);
|
|
87
|
+
if (source) return { role, layer: null, kind: "source", known: true, mapped: true, rel: null, abs: null, exists: true, isDir: false, source };
|
|
88
|
+
}
|
|
41
89
|
if (isAbsolute(rel)) {
|
|
42
90
|
throw new EvdaError(`REFUSED: mapping for "${role}" is absolute (${rel}). Mappings are relative to the record root.`, {
|
|
43
91
|
code: "BAD_MAPPING",
|
|
@@ -87,3 +135,56 @@ export function ledgerFile(evda) {
|
|
|
87
135
|
}
|
|
88
136
|
return r;
|
|
89
137
|
}
|
|
138
|
+
|
|
139
|
+
// The four roles of a record (HQ-1371), each a list of what fills it. A path entry is
|
|
140
|
+
// { key, role, rel, abs, exists, isDir }; a source entry (work only) is { key, role, source }.
|
|
141
|
+
// `problems` are sentences for doctor, never thrown: a path that is missing or is a file, one path
|
|
142
|
+
// filling two roles, a path inside a sub-record (which has its own four roles), and a work source
|
|
143
|
+
// this tool cannot read. `subRecords` is the list of sub-record roots relative to the record.
|
|
144
|
+
// EVDA_ROLES_FALSIFY=<mode,...>, for the tests: two-roles skips the one-path-one-role check,
|
|
145
|
+
// missing-path skips the existence check, into-subrecord skips the sub-record check.
|
|
146
|
+
export const ROLES_FALSIFY_MODES = ["two-roles", "missing-path", "into-subrecord"];
|
|
147
|
+
const rolesFalsified = (mode) => (process.env.EVDA_ROLES_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
148
|
+
|
|
149
|
+
export function fourRoles(evda, { subRecords = [] } = {}) {
|
|
150
|
+
const roles = Object.fromEntries(FOUR_ROLES.map((r) => [r, []]));
|
|
151
|
+
const problems = [];
|
|
152
|
+
for (const key of Object.keys(evda.manifest.mapping)) {
|
|
153
|
+
const role = fourRoleOf(key);
|
|
154
|
+
if (!role) continue;
|
|
155
|
+
let r;
|
|
156
|
+
try { r = resolveRole(evda, key); } catch (e) { problems.push(e.message); continue; }
|
|
157
|
+
if (r.kind === "source") {
|
|
158
|
+
const { source, project } = r.source;
|
|
159
|
+
if (typeof source !== "string" || !WORK_SOURCES.includes(source)) { problems.push(`"${key}" names the source ${JSON.stringify(source)}; this tool reads ${WORK_SOURCES.join(", ")}, or a folder of effort files.`); continue; }
|
|
160
|
+
if (project !== null && (typeof project !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(project))) { problems.push(`"${key}" names the project ${JSON.stringify(project)}, which is not a board project id.`); continue; }
|
|
161
|
+
roles.work.push({ key, role, source: { source, project } });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (typeof evda.manifest.mapping[key] !== "string") { problems.push(`"${key}" must be a path relative to the record${role === "work" ? `, or a source such as "harlequin:"` : ""}.`); continue; }
|
|
165
|
+
const rel = r.rel.replace(/\/+$/, "");
|
|
166
|
+
if (!r.exists) { if (!rolesFalsified("missing-path")) problems.push(`"${key}" is mapped to ${rel}, which does not exist.`); }
|
|
167
|
+
else if (!r.isDir) problems.push(`"${key}" is mapped to ${rel}, which is a file; a role holds a folder.`);
|
|
168
|
+
const inside = subRecords.find((s) => rel === s || rel.startsWith(`${s}/`));
|
|
169
|
+
if (inside !== undefined && !rolesFalsified("into-subrecord")) problems.push(`"${key}" is mapped to ${rel}, inside the sub-record at ${inside}, which declares its own roles. A parent reaches a sub-record through a mount, never by mapping its folders.`);
|
|
170
|
+
roles[role].push({ key, role, rel, abs: r.abs, exists: r.exists, isDir: r.isDir });
|
|
171
|
+
}
|
|
172
|
+
// One path, one role. The same folder under two roles would give its files two answers.
|
|
173
|
+
const byPath = new Map();
|
|
174
|
+
for (const role of FOUR_ROLES) for (const e of roles[role]) if (e.rel !== undefined) {
|
|
175
|
+
const was = byPath.get(e.rel);
|
|
176
|
+
if (was && was.role !== role && !rolesFalsified("two-roles")) problems.push(`${e.rel} fills two roles, ${was.role} ("${was.key}") and ${role} ("${e.key}"). A folder fills one.`);
|
|
177
|
+
else if (!was) byPath.set(e.rel, e);
|
|
178
|
+
}
|
|
179
|
+
return { roles, problems, declared: FOUR_ROLES.filter((r) => roles[r].length) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The four role a path in the record is in, by the longest role path above it, or null.
|
|
183
|
+
export function roleForPath(four, rel) {
|
|
184
|
+
let best = null;
|
|
185
|
+
for (const role of FOUR_ROLES) for (const e of four.roles[role]) {
|
|
186
|
+
if (e.rel === undefined) continue;
|
|
187
|
+
if (e.rel === "" || rel === e.rel || rel.startsWith(`${e.rel}/`)) if (!best || e.rel.length > best.rel.length) best = e;
|
|
188
|
+
}
|
|
189
|
+
return best;
|
|
190
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Whether a string is shaped like a secret (a password, key or token), for every write that refuses
|
|
2
|
+
// one: a reference, evda_write, a gather, and the Claude Code prompt hook (HQ-1385).
|
|
3
|
+
//
|
|
4
|
+
// node: built-ins only, and no imports at all: `evda hook install` copies this file byte for byte
|
|
5
|
+
// beside the prompt hook in a record's .claude/hooks/, so the hook refuses exactly what the MCP
|
|
6
|
+
// server refuses. lib/references.mjs re-exports it; `evda doctor` says when the copy is older.
|
|
7
|
+
//
|
|
8
|
+
// A value shaped like a secret refuses the write.
|
|
9
|
+
// The shapes are the obvious ones: a private key block, a JWT, the prefixes the common services
|
|
10
|
+
// stamp on their tokens, a bearer header, a URL carrying a password or a credential in its query,
|
|
11
|
+
// and as a last net a long mixed-case high-entropy token that is not a UUID.
|
|
12
|
+
const SECRET_PARAM = /^(key|token|access_token|api_key|apikey|auth|password|secret|sig|signature|session|x-amz-signature|x-amz-credential)$/i;
|
|
13
|
+
const SECRET_VALUE = [
|
|
14
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key block"],
|
|
15
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, "a JWT"],
|
|
16
|
+
[/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/, "an API key (sk-...)"],
|
|
17
|
+
[/\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{8,}\b/, "an API key (sk_live_...)"],
|
|
18
|
+
[/\bgh[pousr]_[A-Za-z0-9]{20,}\b/, "a GitHub token"],
|
|
19
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}\b/, "a GitLab token"],
|
|
20
|
+
[/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, "a Slack token"],
|
|
21
|
+
[/\bAKIA[0-9A-Z]{16}\b/, "an AWS access key id"],
|
|
22
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/, "a Google API key"],
|
|
23
|
+
[/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/i, "a bearer token"],
|
|
24
|
+
];
|
|
25
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
26
|
+
|
|
27
|
+
function entropy(s) {
|
|
28
|
+
const counts = new Map();
|
|
29
|
+
for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
30
|
+
let h = 0;
|
|
31
|
+
for (const n of counts.values()) {
|
|
32
|
+
const p = n / s.length;
|
|
33
|
+
h -= p * Math.log2(p);
|
|
34
|
+
}
|
|
35
|
+
return h;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Why a string looks like a secret, or null.
|
|
39
|
+
export function secretShape(value) {
|
|
40
|
+
if (typeof value !== "string") return null;
|
|
41
|
+
for (const [re, what] of SECRET_VALUE) if (re.test(value)) return what;
|
|
42
|
+
const m = /^(?:url:)?([a-z][a-z0-9+.-]*:\/\/[^\s]+)$/i.exec(value);
|
|
43
|
+
if (m) {
|
|
44
|
+
let u = null;
|
|
45
|
+
try { u = new URL(m[1]); } catch { u = null; }
|
|
46
|
+
if (u) {
|
|
47
|
+
if (u.password !== "") return "a password in the URL";
|
|
48
|
+
for (const k of u.searchParams.keys()) if (SECRET_PARAM.test(k)) return `a credential in the URL query (${k})`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const tok of value.split(/[\s"'<>]+/)) {
|
|
52
|
+
if (tok.length < 40 || UUID.test(tok)) continue;
|
|
53
|
+
if (!/[A-Z]/.test(tok) || !/[a-z]/.test(tok) || !/\d/.test(tok)) continue;
|
|
54
|
+
if (!/^[A-Za-z0-9+/=_-]+$/.test(tok)) continue;
|
|
55
|
+
if (entropy(tok) >= 4.5) return "a long high-entropy token";
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
package/lib/setup.mjs
CHANGED
|
@@ -58,7 +58,105 @@ export const PLATFORMS = {
|
|
|
58
58
|
|
|
59
59
|
export const PLATFORM_KEYS = Object.keys(PLATFORMS);
|
|
60
60
|
|
|
61
|
+
// What each company's own pages say about training and retention, for its consumer apps and for its
|
|
62
|
+
// API or business plans. Quoted, never paraphrased inside the quotes, each with the page it came from
|
|
63
|
+
// and the date it was read. This is what an agent answers with when someone asks which AI is safest:
|
|
64
|
+
// the table and its date, never a ranking of which company is likelier to misuse data. A page that
|
|
65
|
+
// could not be read says so rather than being filled in from memory.
|
|
66
|
+
export const TERMS_CHECKED = "2026-09-22";
|
|
67
|
+
export const PROVIDER_TERMS = {
|
|
68
|
+
claude: {
|
|
69
|
+
company: "Anthropic",
|
|
70
|
+
consumer: [
|
|
71
|
+
{ says: "If you decide to turn off the model training setting, we will not use any new chats and coding sessions you have with Claude for future model training.", url: "https://privacy.claude.com/en/articles/12109829-how-do-i-change-my-model-improvement-privacy-settings" },
|
|
72
|
+
{ says: "Deleted from our back-end storage systems within 30 days", about: "a chat you delete", url: "https://privacy.claude.com/en/articles/10023548-how-long-do-you-store-my-data" },
|
|
73
|
+
{ says: "we may retain your data in a de-identified format for up to 5 years in our model training pipelines.", about: "with training on", url: "https://privacy.claude.com/en/articles/10023548-how-long-do-you-store-my-data" },
|
|
74
|
+
],
|
|
75
|
+
business: [
|
|
76
|
+
{ says: "Anthropic may not train models on Customer Content from Services.", url: "https://www.anthropic.com/legal/commercial-terms" },
|
|
77
|
+
{ says: "For Anthropic API users, we automatically delete inputs and outputs on our backend within 30 days of receipt or generation", url: "https://privacy.claude.com/en/articles/7996866-how-long-do-you-store-my-organization-s-data" },
|
|
78
|
+
{ says: "Prompts submitted to, and outputs generated by, covered models are retained for 30 days to support our safety work, on every platform where these models are offered.", about: "the models that page names as covered", url: "https://privacy.claude.com/en/articles/15425996-data-retention-practices-for-covered-models" },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
chatgpt: {
|
|
82
|
+
company: "OpenAI",
|
|
83
|
+
consumer: [
|
|
84
|
+
{ says: null, unread: "The page refused automated reads when checked. Read it yourself before relying on it.", url: "https://help.openai.com/en/articles/7730893-data-controls-faq" },
|
|
85
|
+
],
|
|
86
|
+
business: [
|
|
87
|
+
{ says: "As of March 1, 2023, data sent to the OpenAI API is not used to train or improve OpenAI models (unless you explicitly opt in to share data with us).", url: "https://platform.openai.com/docs/guides/your-data" },
|
|
88
|
+
{ says: "By default, abuse monitoring logs are generated for all API feature usage and retained for up to 30 days, unless longer retention is required by law", url: "https://platform.openai.com/docs/guides/your-data" },
|
|
89
|
+
{ says: null, unread: "ChatGPT Business and Enterprise: the page refused automated reads when checked.", url: "https://openai.com/enterprise-privacy/" },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
gemini: {
|
|
93
|
+
company: "Google",
|
|
94
|
+
consumer: [
|
|
95
|
+
{ says: "If the Keep Activity setting is off: Your future chats won't appear in your Activity, and won't be used to train our AI models, unless you choose to send Google feedback.", url: "https://support.google.com/gemini/answer/13594961" },
|
|
96
|
+
{ says: "Temporary chats and chats you have when Keep Activity is off are retained with your account for 72 hours", url: "https://support.google.com/gemini/answer/13594961" },
|
|
97
|
+
{ says: "Reviewed chats are retained for up to three years.", about: "chats a person reviewed", url: "https://support.google.com/gemini/answer/13594961" },
|
|
98
|
+
],
|
|
99
|
+
business: [
|
|
100
|
+
{ says: "Google doesn't use your prompts (including associated system instructions, cached content, and files such as images, videos, or documents) or responses to improve our products", about: "the paid Gemini API", url: "https://ai.google.dev/gemini-api/terms" },
|
|
101
|
+
{ says: "For Paid Services, Google logs prompts and responses for a limited period of time, solely for detecting and preventing violations of the Prohibited Use Policy", url: "https://ai.google.dev/gemini-api/terms" },
|
|
102
|
+
{ says: "Workspace does not use customer data for training models without customer's prior permission or instruction.", url: "https://support.google.com/a/answer/15706919" },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
cursor: {
|
|
106
|
+
company: "Cursor (Anysphere)",
|
|
107
|
+
consumer: [
|
|
108
|
+
{ says: "With Privacy Mode enabled, your code is never used for training.", url: "https://cursor.com/help/security-and-privacy/privacy" },
|
|
109
|
+
{ says: "Anysphere retains your personal data only for as long as necessary to operate the Service effectively and to support legitimate business needs", url: "https://cursor.com/privacy" },
|
|
110
|
+
],
|
|
111
|
+
business: [
|
|
112
|
+
{ says: "For teams, Privacy Mode is enabled by default for all team members.", url: "https://cursor.com/help/security-and-privacy/privacy" },
|
|
113
|
+
{ says: "ZDR doesn't apply when you use your own API keys.", url: "https://cursor.com/help/security-and-privacy/privacy" },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// The "What each company says" section of the scaffolded README, generated from PROVIDER_TERMS.
|
|
119
|
+
export function termsSection() {
|
|
120
|
+
const line = (t) => (t.says === null ? ` - ${t.unread} <${t.url}>` : ` - "${t.says}"${t.about ? ` (${t.about})` : ""} <${t.url}>`);
|
|
121
|
+
return [
|
|
122
|
+
`Quoted from each company's own pages, read on ${TERMS_CHECKED}. Terms change; each line links the page to check.`,
|
|
123
|
+
"",
|
|
124
|
+
...Object.entries(PROVIDER_TERMS).flatMap(([k, p]) => [
|
|
125
|
+
`- **${p.company}** (${SHORT_NAMES_FOR_TERMS[k]})`,
|
|
126
|
+
" - Personal plans:",
|
|
127
|
+
...p.consumer.map((t) => ` ${line(t)}`),
|
|
128
|
+
" - API and business plans:",
|
|
129
|
+
...p.business.map((t) => ` ${line(t)}`),
|
|
130
|
+
]),
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Who reads a new record, written into evda.json at setup: the AI the owner chose. The default is
|
|
135
|
+
// not local, because most people have no local model; restriction is something they opt into.
|
|
136
|
+
// Training is written as off only when they confirmed it.
|
|
137
|
+
export function readerFor({ platform, platformName, trainingOff, date }) {
|
|
138
|
+
if (!platform) return null;
|
|
139
|
+
const r = { ai: platform };
|
|
140
|
+
if (platform === "other" && oneLineName(platformName)) r.name = oneLineName(platformName);
|
|
141
|
+
r.training = trainingOff ? "off" : "unknown";
|
|
142
|
+
if (trainingOff) r.confirmed = date;
|
|
143
|
+
return [r];
|
|
144
|
+
}
|
|
145
|
+
const oneLineName = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
|
|
146
|
+
|
|
147
|
+
// What a new owner is told about where their words go. In create-evda's finish screen, the
|
|
148
|
+
// scaffolded README, and Spec 07 section 8; scripts/build-templates.mjs --check fails when the
|
|
149
|
+
// spec's copy differs from this one.
|
|
150
|
+
export const INTRO = [
|
|
151
|
+
"Your information is stored locally on your own devices as files you can open with anything. When your devices sync, what goes between them is locked with keys only your devices have, so EVÐA can't read it.",
|
|
152
|
+
"",
|
|
153
|
+
"The AI you choose reads what it needs to think with you. With training turned off, the company behind it says it won't use your information, and that promise is backed by their terms and by law. We can't prove it for them, and I don't want to pretend we can.",
|
|
154
|
+
"",
|
|
155
|
+
"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.",
|
|
156
|
+
].join("\n");
|
|
157
|
+
|
|
61
158
|
const SHORT_NAMES = { claude: "Claude", chatgpt: "ChatGPT", gemini: "Gemini", cursor: "Cursor", other: "" };
|
|
159
|
+
const SHORT_NAMES_FOR_TERMS = { claude: "Claude", chatgpt: "ChatGPT and Codex", gemini: "Gemini", cursor: "Cursor" };
|
|
62
160
|
|
|
63
161
|
// The "Turning training off" section of the scaffolded README, generated from PLATFORMS so the
|
|
64
162
|
// owner's copy and the setup flow say the same thing.
|
|
@@ -161,6 +259,10 @@ export const WELCOME = [
|
|
|
161
259
|
"",
|
|
162
260
|
"Freewrite with Iris about anything: what stresses you out, what you are unsure of, the things you do every day that feel slightly off. You will get better at seeing your thoughts, understanding why you do what you do, why certain people matter to you and why you react the way you do. Iris cannot give you the right answer for your life, but it is good at suggestions: take them, push back on what you disagree with, get specific, and get a new response back. Iris keeps it all in your EVÐA as you talk.",
|
|
163
261
|
"",
|
|
262
|
+
"What it actually does is keep your words, not a summary of them. What you say goes in the way you said it, filed into the day it happened, the person it was about, or just the thought as it arrived. Nothing gets compressed into a takeaway, because the takeaway is the part that turns out to be wrong. Alongside the record sits a ledger: a claim about you, and what actually happened to it. A claim that does not hold gets marked as not holding, so it stops coming back.",
|
|
263
|
+
"",
|
|
264
|
+
"That is why it gets better the more you use it. In the first week Iris is working from almost nothing and will ask you things you have already answered. By the second month it is not asking those, and it can see things one day cannot show: the same frustration turning up in three places you thought were unrelated, a person you keep circling back to, a rule you set for yourself in March and quietly stopped following in June. You get that by writing, not by filling anything in.",
|
|
265
|
+
"",
|
|
164
266
|
"It is not easy to put your deepest thoughts into a system, especially ones you have not admitted to yourself, never mind a friend or a therapist. EVÐA is built for exactly that, and it is open source so you can check. Your data is always yours, and never accessible to anyone at EVÐA: the company never receives your record. The AI you use does receive what Iris reads, which is why setup asks you to turn training off first.",
|
|
165
267
|
"",
|
|
166
268
|
"The only requirement is that you want to, and you are open to it. Iris is the default name; call it whatever you like.",
|
|
@@ -174,9 +276,11 @@ export function questions(agent = DEFAULT_AGENT_NAME) {
|
|
|
174
276
|
{ key: "agentName", text: `What would you like to call your agent? (${DEFAULT_AGENT_NAME})` },
|
|
175
277
|
{ key: "owner", text: "What is your name?" },
|
|
176
278
|
{ key: "purpose", text: `What do you want ${agent} for? Say it however it comes out.` },
|
|
279
|
+
{ key: "preset", text: "How should your EVÐA be organized? Pick a starting set of folders, or start plain. Nothing you already have is moved." },
|
|
177
280
|
{ key: "voice", text: `How do you want ${agent} to talk to you?` },
|
|
178
281
|
{ key: "platform", text: "Which AI will you use it with?" },
|
|
179
282
|
{ key: "trainingOff", text: "Before anything personal goes in, turn model training off:" },
|
|
283
|
+
{ key: "filing", text: `Should ${agent} file everything you say automatically, or only when you ask? (everything)` },
|
|
180
284
|
{ key: "import", text: "Bring in what your AI already knows about you?" },
|
|
181
285
|
{ key: "skill", text: `Install the evda skill for Claude Code, so ${agent} works through your EVÐA from any folder?` },
|
|
182
286
|
{ key: "morning", text: "Add a morning organize routine, which files your captures into their day and reports what belongs where?" },
|
|
@@ -184,6 +288,15 @@ export function questions(agent = DEFAULT_AGENT_NAME) {
|
|
|
184
288
|
];
|
|
185
289
|
}
|
|
186
290
|
|
|
291
|
+
// The filing answer (HQ-1385): "everything" or "asked", from what the owner typed. Empty is the
|
|
292
|
+
// default the question shows, everything. null when the answer names neither.
|
|
293
|
+
export function filingKey(answer) {
|
|
294
|
+
const a = String(answer ?? "").trim().toLowerCase().replace(/[.!]+$/, "");
|
|
295
|
+
if (a === "" || ["everything", "every", "all", "e", "1", "yes", "y", "automatically", "auto"].includes(a)) return "everything";
|
|
296
|
+
if (["asked", "ask", "a", "2", "no", "n", "only when i ask", "when i ask", "only when asked", "manual", "manually"].includes(a)) return "asked";
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
187
300
|
// What a yes to the import offer shows: the line before IMPORT_PROMPT and the line after it.
|
|
188
301
|
export function importLines(agent = DEFAULT_AGENT_NAME) {
|
|
189
302
|
return {
|
|
@@ -192,10 +305,27 @@ export function importLines(agent = DEFAULT_AGENT_NAME) {
|
|
|
192
305
|
};
|
|
193
306
|
}
|
|
194
307
|
|
|
308
|
+
// The preset choice: a number from the menu (the last number is "none"), a preset id, a word from
|
|
309
|
+
// its title, or none. `presets` is [{ id, title, for }], passed in rather than read here, because
|
|
310
|
+
// this file is also bundled into the EVÐA app and reads no files. null when the answer names nothing.
|
|
311
|
+
export const START_PLAIN = "None, start plain";
|
|
312
|
+
export function presetKey(answer, presets) {
|
|
313
|
+
const a = String(answer ?? "").trim().toLowerCase();
|
|
314
|
+
if (!a || a === "none" || a === "plain" || a === START_PLAIN.toLowerCase()) return "none";
|
|
315
|
+
if (/^\d+$/.test(a)) {
|
|
316
|
+
const n = Number(a);
|
|
317
|
+
if (n === presets.length + 1) return "none";
|
|
318
|
+
return presets[n - 1]?.id ?? null;
|
|
319
|
+
}
|
|
320
|
+
const hit = presets.find((p) => p.id === a || p.title.toLowerCase() === a);
|
|
321
|
+
return hit ? hit.id : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
195
324
|
// Ask the setup questions one at a time. `io.ask(prompt)` returns the answer (a promise or a
|
|
196
325
|
// string); `io.out(line)` prints. Only questions whose flag is missing are asked. Returns the
|
|
197
|
-
// answers, including `trainingOff` which is true only after the owner typed yes.
|
|
198
|
-
|
|
326
|
+
// answers, including `trainingOff` which is true only after the owner typed yes. `presets` is the
|
|
327
|
+
// preset library to offer; with none, the preset question is not asked.
|
|
328
|
+
export async function interview(opts, io, { presets = [] } = {}) {
|
|
199
329
|
const out = io.out;
|
|
200
330
|
const ask = async (q) => oneLine(await io.ask(q));
|
|
201
331
|
out(WELCOME);
|
|
@@ -206,6 +336,14 @@ export async function interview(opts, io) {
|
|
|
206
336
|
const agent = a.agentName || DEFAULT_AGENT_NAME;
|
|
207
337
|
if (!a.owner) a.owner = await ask(`${q("owner", agent)} `);
|
|
208
338
|
if (!a.purpose) a.purpose = await ask(`${q("purpose", agent)} `);
|
|
339
|
+
if (a.preset == null && presets.length) {
|
|
340
|
+
out(q("preset", agent));
|
|
341
|
+
presets.forEach((p, i) => out(` ${i + 1}. ${p.title}: ${p.for}`));
|
|
342
|
+
out(` ${presets.length + 1}. ${START_PLAIN}`);
|
|
343
|
+
let key = null;
|
|
344
|
+
while (!key) key = presetKey(await ask("Number or name: "), presets);
|
|
345
|
+
a.preset = key;
|
|
346
|
+
}
|
|
209
347
|
if (!a.voice) a.voice = await ask(`${q("voice", agent)} `);
|
|
210
348
|
if (!a.platform) {
|
|
211
349
|
out(q("platform", agent));
|
|
@@ -233,6 +371,12 @@ export async function interview(opts, io) {
|
|
|
233
371
|
}
|
|
234
372
|
a.trainingOff = true;
|
|
235
373
|
}
|
|
374
|
+
if (!a.filing) {
|
|
375
|
+
out("");
|
|
376
|
+
let key = null;
|
|
377
|
+
while (!key) key = filingKey(await ask(`${q("filing", agent)} `));
|
|
378
|
+
a.filing = key;
|
|
379
|
+
}
|
|
236
380
|
out("");
|
|
237
381
|
out(DICTATION);
|
|
238
382
|
out("");
|