create-evda 0.1.5 → 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 +74 -7
- package/bin/evda.mjs +391 -6
- package/create-evda.mjs +54 -10
- 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 +346 -13
- package/lib/filechanges.mjs +622 -0
- 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 +425 -36
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +52 -12
- package/lib/parity.mjs +111 -0
- package/lib/presets.mjs +58 -7
- 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 +118 -0
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +87 -0
- package/lib/tools.mjs +118 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +24 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +58 -0
- package/templates/AGENTS.md +118 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +30 -6
- package/LICENSE +0 -21
- package/presets/student/preset.json +0 -13
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.",
|
|
@@ -178,6 +280,7 @@ export function questions(agent = DEFAULT_AGENT_NAME) {
|
|
|
178
280
|
{ key: "voice", text: `How do you want ${agent} to talk to you?` },
|
|
179
281
|
{ key: "platform", text: "Which AI will you use it with?" },
|
|
180
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)` },
|
|
181
284
|
{ key: "import", text: "Bring in what your AI already knows about you?" },
|
|
182
285
|
{ key: "skill", text: `Install the evda skill for Claude Code, so ${agent} works through your EVÐA from any folder?` },
|
|
183
286
|
{ key: "morning", text: "Add a morning organize routine, which files your captures into their day and reports what belongs where?" },
|
|
@@ -185,6 +288,15 @@ export function questions(agent = DEFAULT_AGENT_NAME) {
|
|
|
185
288
|
];
|
|
186
289
|
}
|
|
187
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
|
+
|
|
188
300
|
// What a yes to the import offer shows: the line before IMPORT_PROMPT and the line after it.
|
|
189
301
|
export function importLines(agent = DEFAULT_AGENT_NAME) {
|
|
190
302
|
return {
|
|
@@ -259,6 +371,12 @@ export async function interview(opts, io, { presets = [] } = {}) {
|
|
|
259
371
|
}
|
|
260
372
|
a.trainingOff = true;
|
|
261
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
|
+
}
|
|
262
380
|
out("");
|
|
263
381
|
out(DICTATION);
|
|
264
382
|
out("");
|
package/lib/skills.mjs
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Skills the agent writes for itself (HQ-1377): a process it found itself repeating, written down
|
|
2
|
+
// once so the next run follows it instead of rediscovering it.
|
|
3
|
+
//
|
|
4
|
+
// A skill lives in the record at `.claude/skills/<name>/SKILL.md`, the Anthropic skill format (a
|
|
5
|
+
// `name` and a `description` saying when to use it, in frontmatter, then the steps). Claude Code
|
|
6
|
+
// loads that folder on its own when a session opens in the record; every other app reads the same
|
|
7
|
+
// files through the MCP server's evda_skills tool. The folder is a dot folder, so it is never a
|
|
8
|
+
// record entry, yet it syncs with the record, so a skill travels to every device the record does.
|
|
9
|
+
//
|
|
10
|
+
// Two refusals, each with a test in test/skills.test.mjs:
|
|
11
|
+
// 1. Nothing outside `.claude/skills/<name>/`. The tool takes a name, never a path, and a name
|
|
12
|
+
// that is not kebab-case (which is how `..` and `/` would get in) is refused.
|
|
13
|
+
// 2. Nothing that is a rule about the owner's life. A skill is how the agent works. A threshold,
|
|
14
|
+
// count or streak about a person is never a skill, whoever asks (Mike, 2026-09-16: the mean is
|
|
15
|
+
// relative to the person, and a rule with a number in it about their life is the wrong shape).
|
|
16
|
+
//
|
|
17
|
+
// Revising: a skill that went wrong on a run is rewritten with revise: true and what went wrong; the
|
|
18
|
+
// previous text is appended to REVISIONS.md beside it, so a revision never loses what it replaced.
|
|
19
|
+
//
|
|
20
|
+
// EVDA_SKILLS_FALSIFY=<mode,...>, for the tests: outside skips the path check, owner-rules skips the
|
|
21
|
+
// life-rule check, frontmatter makes doctor accept any frontmatter, no-hint turns the repeated-
|
|
22
|
+
// process line in the MCP server off.
|
|
23
|
+
|
|
24
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { join, resolve, sep } from "node:path";
|
|
26
|
+
import { frontmatterOf } from "./circles.mjs";
|
|
27
|
+
import { EvdaError } from "./errors.mjs";
|
|
28
|
+
|
|
29
|
+
export const SKILLS_REL = ".claude/skills";
|
|
30
|
+
export const SKILL_FILE = "SKILL.md";
|
|
31
|
+
export const REVISIONS_FILE = "REVISIONS.md";
|
|
32
|
+
export const NAME_MAX = 64;
|
|
33
|
+
export const DESCRIPTION_MAX = 1024;
|
|
34
|
+
export const SKILLS_FALSIFY_MODES = ["outside", "owner-rules", "frontmatter", "no-hint"];
|
|
35
|
+
export const skillsFalsified = (mode, env = process.env) => (env.EVDA_SKILLS_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
36
|
+
|
|
37
|
+
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
38
|
+
const STEP = /^\s*(?:\d+[.)]|[-*])\s+\S/m;
|
|
39
|
+
const SAYS_WHEN = /\b(?:use|when|whenever|before|after)\b/i;
|
|
40
|
+
|
|
41
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
42
|
+
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
43
|
+
|
|
44
|
+
function refuse(message, code) {
|
|
45
|
+
return new EvdaError(`REFUSED: ${message} Nothing was written.`, { code, exit: 2 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- what counts as a rule about the owner's life ----
|
|
49
|
+
|
|
50
|
+
// Words that point at the owner rather than at the agent. "you" is left out on purpose: a skill
|
|
51
|
+
// talks to the agent as "you".
|
|
52
|
+
const OWNER_WORDS = /\b(?:the owner|owner's|they|them|their|theirs|he|him|his|she|her|hers)\b/i;
|
|
53
|
+
// A quota over a period with a unit of living: "8 hours a night", "3 drinks per week".
|
|
54
|
+
const QUOTA = /\b\d+(?:\.\d+)?\s*(?:x\s*)?(?:times?|hours?|hrs?|minutes?|mins?|days?|nights?|drinks?|cups?|glasses|meals?|snacks?|calories|kcal|steps|grams?|g|kg|lbs?|pounds|reps?|sets|miles|km|servings?|pages|sessions?|workouts?)\s+(?:a|an|per|each|every)\s+(?:day|week|month|night|morning|evening|weekend)\b/i;
|
|
55
|
+
// A streak is the word itself, or a run counted in days: "five days in a row", "consecutive nights".
|
|
56
|
+
// "twice in a row" about a correction is how the agent notices a process, and is not one.
|
|
57
|
+
const STREAK = /\bstreaks?\b|\bchain unbroken\b|\b(?:days?|nights?|weeks?|mornings?)\s+(?:in a row|straight)\b|\bconsecutive\s+(?:days?|nights?|weeks?|mornings?)\b/i;
|
|
58
|
+
const EVERY = /\b(?:every|each)\s+(?:single\s+)?(?:day|night|morning|evening|week)\b|\bdaily\b|\bnightly\b/i;
|
|
59
|
+
const MUST = /\b(?:must|should|has to|have to|needs? to|required|make sure|never miss|no exceptions)\b/i;
|
|
60
|
+
const THRESHOLD = /\b(?:at least|at most|no more than|fewer than|less than|more than|under|over|up to|max(?:imum)?|min(?:imum)?|limit(?:ed)? (?:to|of)|cap(?:ped)? at|target of|goal of|quota of)\s+\$?\d/i;
|
|
61
|
+
const LIFE = /\b(?:sleep|slept|bed|bedtime|wake|woke|eat|ate|eating|food|meals?|snacks?|drink|drinks|drinking|alcohol|sugar|calories|carbs|protein|weight|weigh|exercise|workouts?|gym|run|running|steps|screen time|phone|scroll|spend|spent|spending|money|budget|meditat\w*|water|caffeine|coffee|study|studied|read|journal\w*|smok\w*|vap\w*|weed|thc|sex|porn|social media)\b/i;
|
|
62
|
+
|
|
63
|
+
// The line and the reason when a text reads as a rule about the owner's life, or null.
|
|
64
|
+
export function ownerRule(text, { owner = "", env = process.env } = {}) {
|
|
65
|
+
if (skillsFalsified("owner-rules", env)) return null;
|
|
66
|
+
const first = String(owner ?? "").trim().split(/\s+/)[0] ?? "";
|
|
67
|
+
const named = first.length >= 2 ? new RegExp(`\\b${first.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i") : null;
|
|
68
|
+
const lines = String(text).split("\n");
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const l = lines[i];
|
|
71
|
+
const aboutOwner = OWNER_WORDS.test(l) || (named ? named.test(l) : false);
|
|
72
|
+
let why = null;
|
|
73
|
+
if (QUOTA.test(l)) why = "a quota over a period";
|
|
74
|
+
else if (aboutOwner && STREAK.test(l)) why = "a streak";
|
|
75
|
+
else if (aboutOwner && EVERY.test(l) && MUST.test(l)) why = "something they must do every day";
|
|
76
|
+
else if (aboutOwner && THRESHOLD.test(l) && LIFE.test(l)) why = "a threshold on how they live";
|
|
77
|
+
if (why) return { line: i + 1, text: l.trim(), why };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- reading and checking ----
|
|
83
|
+
|
|
84
|
+
// The record's skills folder, absolute, or null when it would resolve outside the record (a
|
|
85
|
+
// symlinked .claude pointing elsewhere). Existence is not required.
|
|
86
|
+
export function skillsDir(evda) {
|
|
87
|
+
const abs = join(evda.dir, SKILLS_REL);
|
|
88
|
+
if (skillsFalsified("outside")) return abs;
|
|
89
|
+
let p = evda.dir;
|
|
90
|
+
for (const part of SKILLS_REL.split("/")) {
|
|
91
|
+
p = join(p, part);
|
|
92
|
+
if (!existsSync(p)) break;
|
|
93
|
+
if (lstatSync(p).isSymbolicLink()) {
|
|
94
|
+
const real = realpathSync(p);
|
|
95
|
+
const root = realpathSync(evda.dir);
|
|
96
|
+
if (real !== root && !real.startsWith(root + sep)) return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return abs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The problems with one skill's text, as sentences, and what it parsed to. `folder` is the name of
|
|
103
|
+
// the directory it sits in, which the frontmatter name must match.
|
|
104
|
+
export function checkSkill(text, { folder = null, owner = "", env = process.env } = {}) {
|
|
105
|
+
const problems = [];
|
|
106
|
+
const warnings = [];
|
|
107
|
+
// What the skill format requires (a parseable name and description) fails; what makes a skill
|
|
108
|
+
// useful (it says when, it has steps) is required of one the agent writes, and only warned about
|
|
109
|
+
// in one written by hand, which may be prose (Mike's vault skills are, and they load fine).
|
|
110
|
+
const advisories = [];
|
|
111
|
+
const front = frontmatterOf(text);
|
|
112
|
+
const loose = skillsFalsified("frontmatter", env);
|
|
113
|
+
const name = front?.name;
|
|
114
|
+
const description = front?.description;
|
|
115
|
+
if (!loose) {
|
|
116
|
+
if (!front) problems.push(`has no frontmatter: it starts with a --- block holding name and description.`);
|
|
117
|
+
else {
|
|
118
|
+
if (typeof name !== "string" || !KEBAB.test(name)) problems.push(`name ${JSON.stringify(name ?? null)} is not kebab-case (lowercase letters, digits and single hyphens).`);
|
|
119
|
+
else if (name.length > NAME_MAX) problems.push(`name is ${name.length} characters; at most ${NAME_MAX}.`);
|
|
120
|
+
else if (folder !== null && name !== folder) problems.push(`name ${JSON.stringify(name)} differs from its folder ${JSON.stringify(folder)}.`);
|
|
121
|
+
if (typeof description !== "string" || description.trim() === "") problems.push(`has no description. The description is what tells an agent when to use it.`);
|
|
122
|
+
else if (description.length > DESCRIPTION_MAX) problems.push(`description is ${description.length} characters; at most ${DESCRIPTION_MAX}.`);
|
|
123
|
+
else if (!SAYS_WHEN.test(description)) advisories.push(`description does not say when to use it (no "use", "when", "before" or "after" in it).`);
|
|
124
|
+
}
|
|
125
|
+
const body = bodyOf(text);
|
|
126
|
+
if (!STEP.test(body)) advisories.push(`has no steps: the body holds no numbered or bulleted step.`);
|
|
127
|
+
}
|
|
128
|
+
const rule = ownerRule(bodyOf(text) + "\n" + (typeof description === "string" ? description : ""), { owner, env });
|
|
129
|
+
if (rule) warnings.push(`line ${rule.line} reads as a rule about the owner's life (${rule.why}): "${rule.text}". A skill is how the agent works, never a threshold, count or streak about a person.`);
|
|
130
|
+
return { name: typeof name === "string" ? name : folder, description: typeof description === "string" ? description : null, problems, advisories, warnings: [...advisories, ...warnings] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function bodyOf(text) {
|
|
134
|
+
const lines = String(text).split("\n");
|
|
135
|
+
if (lines[0] !== "---") return text;
|
|
136
|
+
const end = lines.indexOf("---", 1);
|
|
137
|
+
return end === -1 ? text : lines.slice(end + 1).join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Every skill in the record, sorted by folder name: { name, folder, path, description, problems,
|
|
141
|
+
// warnings }. A folder with no SKILL.md is reported as a problem, not skipped.
|
|
142
|
+
export function listSkills(evda, { env = process.env } = {}) {
|
|
143
|
+
const dir = skillsDir(evda);
|
|
144
|
+
if (dir === null) return { dir: SKILLS_REL, outside: true, skills: [] };
|
|
145
|
+
if (!existsSync(dir)) return { dir: SKILLS_REL, skills: [] };
|
|
146
|
+
const owner = evda.manifest?.owner?.name ?? "";
|
|
147
|
+
const skills = [];
|
|
148
|
+
for (const folder of readdirSync(dir).sort()) {
|
|
149
|
+
if (folder.startsWith(".")) continue;
|
|
150
|
+
const abs = join(dir, folder);
|
|
151
|
+
if (!lstatSync(abs).isDirectory()) continue;
|
|
152
|
+
const path = `${SKILLS_REL}/${folder}/${SKILL_FILE}`;
|
|
153
|
+
const file = join(abs, SKILL_FILE);
|
|
154
|
+
if (!existsSync(file)) { skills.push({ name: folder, folder, path, description: null, problems: [`${folder}/ holds no ${SKILL_FILE}.`], advisories: [], warnings: [] }); continue; }
|
|
155
|
+
const r = checkSkill(readFileSync(file, "utf8"), { folder, owner, env });
|
|
156
|
+
skills.push({ folder, path, ...r });
|
|
157
|
+
}
|
|
158
|
+
return { dir: SKILLS_REL, skills };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// One skill's whole text by name.
|
|
162
|
+
export function getSkill(evda, name) {
|
|
163
|
+
if (typeof name !== "string" || !KEBAB.test(name)) throw new EvdaError(`REFUSED: evda_skills: ${JSON.stringify(name ?? null)} is not a skill name (kebab-case), so it names nothing in ${SKILLS_REL}.`, { code: "BAD_SKILL_NAME", exit: 2 });
|
|
164
|
+
const dir = skillsDir(evda);
|
|
165
|
+
const file = dir && join(dir, name, SKILL_FILE);
|
|
166
|
+
if (!file || !existsSync(file)) {
|
|
167
|
+
const have = listSkills(evda).skills.map((s) => s.folder);
|
|
168
|
+
throw new EvdaError(`No skill named ${JSON.stringify(name)} in ${SKILLS_REL}. ${have.length ? `There are: ${have.join(", ")}.` : "There are none yet."}`, { code: "NO_SKILL", exit: 2 });
|
|
169
|
+
}
|
|
170
|
+
return { name, path: `${SKILLS_REL}/${name}/${SKILL_FILE}`, text: readFileSync(file, "utf8") };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- writing ----
|
|
174
|
+
|
|
175
|
+
const yamlString = (s) => (/^[\w(][^:#\n]*$/.test(s) && !/^(?:true|false|null|yes|no|~)$/i.test(s) && !/\s$/.test(s) ? s : JSON.stringify(s));
|
|
176
|
+
const titleOf = (name) => name.split("-").map((w, i) => (i === 0 ? w[0].toUpperCase() + w.slice(1) : w)).join(" ");
|
|
177
|
+
|
|
178
|
+
export function renderSkill({ name, description, steps }) {
|
|
179
|
+
return `---\nname: ${name}\ndescription: ${yamlString(description.replace(/\s*\n\s*/g, " ").trim())}\n---\n\n# ${titleOf(name)}\n\n${steps.trim()}\n`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Write a new skill, or revise one with revise: true and `why` (what went wrong on the run).
|
|
183
|
+
export function writeSkill(evda, args = {}, { now = new Date(), env = process.env } = {}) {
|
|
184
|
+
const allowed = ["name", "description", "steps", "revise", "why"];
|
|
185
|
+
const extra = Object.keys(args).filter((k) => !allowed.includes(k));
|
|
186
|
+
if (extra.length) throw refuse(`evda_write_skill does not take ${extra.map((k) => JSON.stringify(k)).join(", ")}. It takes a name, never a path: a skill is always ${SKILLS_REL}/<name>/${SKILL_FILE}.`, "UNKNOWN_ARGUMENT");
|
|
187
|
+
const { name, description, steps } = args;
|
|
188
|
+
if (typeof name !== "string" || !KEBAB.test(name) || name.length > NAME_MAX) {
|
|
189
|
+
if (!(skillsFalsified("outside", env) && typeof name === "string" && name)) {
|
|
190
|
+
throw refuse(`evda_write_skill: the name ${JSON.stringify(name ?? null)} is not kebab-case of at most ${NAME_MAX} characters, so it could land outside ${SKILLS_REL}/. A skill is only ever written at ${SKILLS_REL}/<name>/${SKILL_FILE}.`, "OUTSIDE_SKILLS");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (typeof description !== "string" || description.trim() === "") throw refuse(`evda_write_skill needs "description": when to use it, starting "Use when".`, "NO_DESCRIPTION");
|
|
194
|
+
if (typeof steps !== "string" || steps.trim() === "") throw refuse(`evda_write_skill needs "steps": the steps, one numbered line each.`, "NO_STEPS");
|
|
195
|
+
const dir = skillsDir(evda);
|
|
196
|
+
if (dir === null) throw refuse(`${SKILLS_REL} resolves outside the record (a symlink), so no skill is written there.`, "OUTSIDE_SKILLS");
|
|
197
|
+
const folder = resolve(dir, name);
|
|
198
|
+
if (!skillsFalsified("outside", env) && !folder.startsWith(dir + sep)) throw refuse(`${name} resolves outside ${SKILLS_REL}/.`, "OUTSIDE_SKILLS");
|
|
199
|
+
const text = renderSkill({ name, description, steps });
|
|
200
|
+
const check = checkSkill(text, { folder: skillsFalsified("outside", env) ? null : name, env });
|
|
201
|
+
const bad = [...check.problems, ...check.advisories];
|
|
202
|
+
if (bad.length) throw refuse(`evda_write_skill: the skill ${bad.join(" ")}`, "BAD_SKILL");
|
|
203
|
+
const rule = ownerRule(`${description}\n${steps}`, { owner: evda.manifest?.owner?.name ?? "", env });
|
|
204
|
+
if (rule) throw refuse(`evda_write_skill: line ${rule.line} reads as a rule about the owner's life (${rule.why}): "${rule.text}". A skill is how you work (the steps of a process you repeat), never a threshold, count or streak about a person. Write the process without it.`, "OWNER_RULE");
|
|
205
|
+
const file = join(folder, SKILL_FILE);
|
|
206
|
+
const rel = `${SKILLS_REL}/${name}/${SKILL_FILE}`;
|
|
207
|
+
const exists = existsSync(file);
|
|
208
|
+
if (exists && args.revise !== true) throw refuse(`a skill named ${name} already exists at ${rel}. To change it after a run went wrong, pass revise: true and "why", what went wrong; read it first with evda_skills.`, "SKILL_EXISTS");
|
|
209
|
+
if (!exists && args.revise === true) throw refuse(`there is no skill named ${name} to revise. Write it without revise.`, "NO_SKILL");
|
|
210
|
+
if (args.revise === true && (typeof args.why !== "string" || args.why.trim() === "")) throw refuse(`a revision needs "why": what went wrong on the run that made it need one.`, "NO_WHY");
|
|
211
|
+
mkdirSync(folder, { recursive: true });
|
|
212
|
+
let revisions = null;
|
|
213
|
+
if (exists) {
|
|
214
|
+
const before = readFileSync(file, "utf8");
|
|
215
|
+
if (before === text) return { path: rel, action: "unchanged", name };
|
|
216
|
+
revisions = `${SKILLS_REL}/${name}/${REVISIONS_FILE}`;
|
|
217
|
+
const head = existsSync(join(folder, REVISIONS_FILE)) ? "" : `# Revisions of ${name}\n\nWhat each revision replaced, and why. Newest last.\n`;
|
|
218
|
+
appendFileSync(join(folder, REVISIONS_FILE), `${head}\n## ${localDate(now)}\n\n${args.why.trim()}\n\nThe text it replaced:\n\n\`\`\`\`markdown\n${before.trimEnd()}\n\`\`\`\`\n`);
|
|
219
|
+
}
|
|
220
|
+
writeFileSync(file, text);
|
|
221
|
+
return { path: rel, action: exists ? "revised" : "written", name, revisions };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- noticing a repeated process ----
|
|
225
|
+
|
|
226
|
+
// Tools that are how a session starts or keeps itself, never a step of a process the owner asked
|
|
227
|
+
// for. And the plain read-and-file loop: search, read, write is the record's ordinary use, not a
|
|
228
|
+
// process worth a skill, so a run made only of those is never pointed at.
|
|
229
|
+
const META = new Set(["evda_operating_layer", "evda_skills", "evda_write_skill", "evda_check_update"]);
|
|
230
|
+
const ORDINARY = new Set(["evda_recent", "evda_search", "evda_read", "evda_write", "evda_note", "evda_circles", "evda_presets"]);
|
|
231
|
+
export const MIN_STEPS = 3;
|
|
232
|
+
export const MAX_STEPS = 8;
|
|
233
|
+
|
|
234
|
+
// Given the session's step list (tool names, oldest first) after a call, the repeated run it now
|
|
235
|
+
// ends with, as a list of tool names, or null. A run is MIN_STEPS to MAX_STEPS calls long, holds at
|
|
236
|
+
// least two different tools and one that is not ordinary, and ran before, ending before this one
|
|
237
|
+
// started. The longest such run wins.
|
|
238
|
+
export function repeatedRun(steps) {
|
|
239
|
+
const s = steps.filter((n) => !META.has(n));
|
|
240
|
+
const n = s.length;
|
|
241
|
+
for (let len = Math.min(MAX_STEPS, Math.floor(n / 2)); len >= MIN_STEPS; len--) {
|
|
242
|
+
const tail = s.slice(n - len);
|
|
243
|
+
if (new Set(tail).size < 2 || tail.every((t) => ORDINARY.has(t))) continue;
|
|
244
|
+
for (let start = n - 2 * len; start >= 0; start--) {
|
|
245
|
+
if (tail.every((t, i) => s[start + i] === t)) return tail;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function repeatHint(run) {
|
|
252
|
+
return `These steps have now run twice in this conversation: ${run.join(", ")}. If that is one process, write it down once as a skill with evda_write_skill (a kebab-case name, a description saying when to use it, the steps), and tell the owner in one line that you did. If it is not a process, ignore this.`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Count one call for the hint. At most one hint per distinct run per session; never with no-hint.
|
|
256
|
+
export function noticeRepeat(session, name, { env = process.env } = {}) {
|
|
257
|
+
(session.steps ??= []).push(name);
|
|
258
|
+
(session.hinted ??= new Set());
|
|
259
|
+
if (skillsFalsified("no-hint", env)) return null;
|
|
260
|
+
const run = repeatedRun(session.steps);
|
|
261
|
+
if (!run) return null;
|
|
262
|
+
const key = run.join(">");
|
|
263
|
+
if (session.hinted.has(key)) return null;
|
|
264
|
+
session.hinted.add(key);
|
|
265
|
+
return repeatHint(run);
|
|
266
|
+
}
|