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.
Files changed (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +93 -8
  3. package/bin/evda.mjs +437 -5
  4. package/create-evda.mjs +68 -12
  5. package/docs/local-model.md +34 -0
  6. package/hooks/evda-read-guard.mjs +7 -0
  7. package/lib/chat-export.mjs +198 -0
  8. package/lib/circle-plan.mjs +277 -0
  9. package/lib/circles.mjs +479 -28
  10. package/lib/connect.mjs +46 -0
  11. package/lib/day-file.mjs +93 -0
  12. package/lib/declarations.mjs +235 -0
  13. package/lib/devices.mjs +202 -0
  14. package/lib/doctor.mjs +351 -19
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/folders.mjs +22 -8
  17. package/lib/gather-apply.mjs +635 -0
  18. package/lib/gather.mjs +808 -0
  19. package/lib/grants.mjs +7 -1
  20. package/lib/guard.mjs +132 -0
  21. package/lib/hooks.mjs +124 -0
  22. package/lib/keep-hook.mjs +185 -0
  23. package/lib/kinds.mjs +23 -6
  24. package/lib/kinds.schema.json +2 -1
  25. package/lib/manifest.mjs +14 -1
  26. package/lib/mcp-auth.mjs +370 -0
  27. package/lib/mcp-http.mjs +336 -0
  28. package/lib/mcp.mjs +467 -35
  29. package/lib/migrate-people.mjs +231 -0
  30. package/lib/organize.mjs +52 -12
  31. package/lib/parity.mjs +111 -0
  32. package/lib/presets.mjs +379 -0
  33. package/lib/prompt-hook.mjs +88 -0
  34. package/lib/publish.mjs +264 -0
  35. package/lib/readers.mjs +319 -0
  36. package/lib/records.mjs +52 -0
  37. package/lib/references.mjs +5 -51
  38. package/lib/render.mjs +158 -0
  39. package/lib/roles.mjs +102 -1
  40. package/lib/secret-shape.mjs +58 -0
  41. package/lib/setup.mjs +146 -2
  42. package/lib/skills.mjs +266 -0
  43. package/lib/subrecord.mjs +121 -0
  44. package/lib/sync-rules.mjs +87 -0
  45. package/lib/tools.mjs +120 -29
  46. package/lib/update-check.mjs +146 -0
  47. package/lib/work.mjs +180 -0
  48. package/lib/yaml.mjs +117 -42
  49. package/package.json +25 -15
  50. package/presets/journal/preset.json +19 -0
  51. package/presets/projects-and-areas/preset.json +34 -0
  52. package/skills/evda/SKILL.md +76 -0
  53. package/templates/AGENTS.md +129 -15
  54. package/templates/README.md +15 -1
  55. package/templates/circles-README.md +3 -3
  56. package/templates/evda-digest.md +32 -5
  57. package/LICENSE +0 -21
package/lib/gather.mjs ADDED
@@ -0,0 +1,808 @@
1
+ // `evda gather`: point EVDA at a pile the owner already has (a Documents folder, a Notes or
2
+ // Obsidian export, a contacts export) and get back a proposal for the EVDA it would become.
3
+ // Spec 07, "Gathering a pile".
4
+ //
5
+ // Three steps, each its own function, and none of them writes into a source or into a record:
6
+ //
7
+ // scan walk each source, type every file with a person-layer kind, and record the facts a
8
+ // placement needs: its subject, the project it sits in, the people it mentions, whether
9
+ // it reads like therapy notes. App state (.obsidian/, .DS_Store) is skipped and said so.
10
+ // place derive the preset, the folders, and one placement per item from those facts and the
11
+ // owner's answers. Pure: the same facts and answers give the same placements.
12
+ // questions derive what to ask: only a question whose answers give different placements is kept,
13
+ // so the owner is never asked something that changes nothing.
14
+ //
15
+ // The proposal is one JSON file outside every source and every record: `--out`, or
16
+ // $EVDA_GATHER_DIR, or ~/.evda/gather/. Anything an app holds (a Contacts card, a bookmark, a list
17
+ // of links) becomes a reference (Spec 07 section 4), and the proposal never carries its payload:
18
+ // a card's phone number and email are read to be discarded. A file holding something shaped like a
19
+ // secret is refused and quoted nowhere. The proposal quotes at most a heading or the one word that
20
+ // typed an item, never a sentence from a note.
21
+ //
22
+ // EVDA holds no key and calls no model. `--with-ai` hands the items it could not place to the
23
+ // owner's own `claude -p`, the same way `evda organize --with-ai` does, and records the answer.
24
+ //
25
+ // With `--into <record>` the gather also reads the record it would grow: an item whose bytes the
26
+ // record already holds (by hash, or by the record's own list of what it gathered) is marked present
27
+ // and placed nowhere, and a person who already has a page there is not asked about. So a second
28
+ // gather asks only about what is new. Applying the proposal is lib/gather-apply.mjs.
29
+ //
30
+ // A placed `.txt` note is named `.md` on the way in, its bytes unchanged: plain text is already
31
+ // markdown, and every reader in EVDA (the occurrence index, doctor, search) reads `.md`. Nothing
32
+ // else is renamed; an `.html` export keeps its name, because making it markdown would change it.
33
+ //
34
+ // A chat export (HQ-1385: Claude.ai's or ChatGPT's conversations.json) is not a file to place. It is
35
+ // the backstop for what an agent did not file while the conversation happened: the owner's own
36
+ // messages the record does not already hold are added to their day at apply, under the
37
+ // conversation's date and time (lib/chat-export.mjs). The proposal carries only counts.
38
+ //
39
+ // EVDA_GATHER_FALSIFY=<mode,...> breaks one guarantee on purpose, so the tests can show each check
40
+ // fails when its guarantee is gone. The gather's modes are FALSIFY_MODES below and the apply's are
41
+ // APPLY_FALSIFY_MODES in lib/gather-apply.mjs; nothing reads it otherwise.
42
+
43
+ import { createHash } from "node:crypto";
44
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, appendFileSync } from "node:fs";
45
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
46
+ import { homedir } from "node:os";
47
+ import { loadPreset, listPresets } from "./presets.mjs";
48
+ import { fourRoleOf } from "./roles.mjs";
49
+ import { secretShape } from "./references.mjs";
50
+ import { findRoot, openEvda } from "./manifest.mjs";
51
+ import { folderManifests } from "./folders.mjs";
52
+ import { askOwnersAi } from "./organize.mjs";
53
+ import { onlyKeys, refuse, dayFile } from "./tools.mjs";
54
+ import { readChatExport, conversations, filedSections, backfillPlan, CHAT_FORMATS } from "./chat-export.mjs";
55
+ import { EvdaError } from "./errors.mjs";
56
+ import { frontmatterOf, heldBy, withoutSealed, carries, NOTED } from "./circles.mjs";
57
+
58
+ export const GATHER_VERSION = 1;
59
+ // The person-layer kinds an item is typed with, and the folder type each lives under.
60
+ export const GATHER_KINDS = ["note", "person", "project", "event", "log", "source", "piece", "blob", "capture"];
61
+ export const FOLDER_TYPE_FOR = { note: "record", person: "person", project: "project", event: "event", log: "log", source: "source", piece: "piece", blob: "blob", capture: "capture" };
62
+ export const FALSIFY_MODES = ["touch-source", "copy-contacts", "ask-everything", "answer-no-replace", "keep-secrets", "examine-nothing", "no-role"];
63
+ // The role each kind fills when the preset has no folder for it (Spec 07 section 2, HQ-1371):
64
+ // what happened, dated, is journal; one folder per thing is collections; what is known is knowledge.
65
+ export const ROLE_FOR_KIND = { note: "journal", capture: "journal", log: "journal", person: "collections", project: "collections", event: "collections", piece: "collections", blob: "collections", course: "collections", source: "knowledge", areas: "collections" };
66
+ // A person is asked about once this many files mention them and no page for them exists.
67
+ export const PEOPLE_MIN_FILES = 3;
68
+
69
+ const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
70
+ const posix = (p) => p.split(sep).join("/");
71
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
72
+
73
+ const SKIP_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "node_modules", ".git", ".obsidian", ".trash", ".Trash", "__MACOSX"]);
74
+ const APP_DIRS = { ".obsidian": "obsidian" };
75
+ const ARCHIVE_EXT = new Set([".zip", ".tar", ".gz", ".tgz", ".7z", ".rar"]);
76
+ const BINARY_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".heic", ".webp", ".pdf", ".mov", ".mp4", ".m4a", ".mp3", ".wav", ".xlsx", ".xls", ".docx", ".doc", ".pptx", ".key", ".pages", ".numbers", ".sqlite", ".db"]);
77
+ const TEXT_MAX = 2 * 1024 * 1024;
78
+ // Folder names that hold things without saying what they are about, so they are not a subject.
79
+ const CONTAINERS = new Set(["documents", "desktop", "downloads", "notes", "daily", "inbox", "captures", "vault", "obsidian vault", "misc", "stuff", "untitled folder", "new folder", "export", "icloud drive"]);
80
+ const COURSE = /\b[A-Z]{2,4}\s?\d{3}[A-Z]?\b/;
81
+ const COURSE_WORD = /\b(syllabus|lecture|homework|problem set|midterm)\b/i;
82
+ const THERAPY = /\b(therapy|therapist|counsell?or|counselling|counseling|psychiatrist|psychologist|CBT|EMDR)\b/i;
83
+ const FINISHED = /\b(shipped|finished|wrapped up|completed|retro|retrospective|post-?mortem|archived|done;|done\.)/i;
84
+ const MEETING = /\b(meeting|sync|standup|stand-up|1-1|1:1|one on one|design review|call with|interview)\b/i;
85
+ const ATTENDEES = /^(?:attendees|participants|with|present)\s*:\s*(.+)$/im;
86
+ const URL_LINE = /^https?:\/\/\S+$/;
87
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
88
+ const LEADING_DAY = /^(\d{4}-\d{2}-\d{2})\b/;
89
+
90
+ function isBinary(buf) {
91
+ const n = Math.min(buf.length, 8192);
92
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
93
+ return false;
94
+ }
95
+
96
+ function frontmatter(text) {
97
+ if (!text.startsWith("---\n")) return { fm: {}, body: text };
98
+ const end = text.indexOf("\n---", 3);
99
+ if (end === -1) return { fm: {}, body: text };
100
+ const fm = {};
101
+ for (const line of text.slice(4, end).split("\n")) {
102
+ const m = /^([A-Za-z_][\w-]*):\s*(.*?)\s*$/.exec(line);
103
+ if (m) fm[m[1].toLowerCase()] = m[2].replace(/^["']|["']$/g, "");
104
+ }
105
+ return { fm, body: text.slice(text.indexOf("\n", end + 1) + 1) };
106
+ }
107
+
108
+ const stripHtml = (t) => t.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/h\d>/gi, "\n\n").replace(/<\/(p|div|li)>/gi, "\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&");
109
+
110
+ function headingOf(body) {
111
+ const h = /^#{1,6} (.+)$/m.exec(body);
112
+ if (h) return h[1].trim();
113
+ // A plain text export's title is its first line, when a blank line and more words follow it.
114
+ const lines = body.split("\n");
115
+ const at = lines.findIndex((l) => l.trim() !== "");
116
+ if (at === -1 || lines[at].length > 80 || (lines[at + 1] ?? "x").trim() !== "") return null;
117
+ return lines.slice(at + 2).some((l) => l.trim() !== "") ? lines[at].trim() : null;
118
+ }
119
+
120
+ // Parse a vCard file into { uid, name } per card. Every other property is read past and dropped.
121
+ function vcards(text) {
122
+ const out = [];
123
+ let card = null;
124
+ for (const raw of text.split(/\r?\n/)) {
125
+ const line = raw.trim();
126
+ if (/^BEGIN:VCARD$/i.test(line)) card = { uid: null, name: null, payload: [] };
127
+ else if (/^END:VCARD$/i.test(line)) { if (card?.name) out.push(card); card = null; }
128
+ else if (card) {
129
+ const m = /^([A-Z-]+)(?:;[^:]*)?:(.*)$/i.exec(line);
130
+ if (!m) continue;
131
+ const key = m[1].toUpperCase();
132
+ if (key === "UID") card.uid = m[2].trim();
133
+ else if (key === "FN") card.name = m[2].trim();
134
+ else if (key === "TEL" || key === "EMAIL" || key === "ADR") card.payload.push(`${key}:${m[2].trim()}`);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+
140
+ function weblocUrl(text) {
141
+ const m = /<string>(https?:\/\/[^<]+)<\/string>/.exec(text) ?? /^URL=(https?:\/\/\S+)$/m.exec(text);
142
+ return m ? m[1] : null;
143
+ }
144
+
145
+ const slugId = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
146
+
147
+ // ---- what a record already holds ----
148
+
149
+ // Where a record keeps its list of what it gathered: one JSON line per file an apply wrote, with
150
+ // the hash of the source bytes it came from. A file an apply changed on the way in (a reference
151
+ // added to its frontmatter, a finished status) no longer hashes like its source; this is how a
152
+ // second gather still knows it.
153
+ export const GATHERED = ".evda/gathered.jsonl";
154
+ const WALK_SKIP = new Set([".git", "node_modules", ".evda", ".obsidian", ".trash", ".DS_Store"]);
155
+
156
+ // Every file in the record by the hash of its bytes, and the source hashes it has gathered.
157
+ // Returns { byHash: Map<sha256, rel>, gathered: Map<sha256, rel> }.
158
+ export function presentIndex(dir) {
159
+ const byHash = new Map();
160
+ const gathered = new Map();
161
+ if (falsified("reapply-resorts")) return { byHash, gathered };
162
+ const go = (d) => {
163
+ for (const name of readdirSync(d).sort()) {
164
+ if (WALK_SKIP.has(name)) continue;
165
+ const abs = join(d, name);
166
+ const st = lstatSync(abs);
167
+ if (st.isDirectory()) go(abs);
168
+ else if (st.isFile()) {
169
+ const h = sha256(readFileSync(abs));
170
+ if (!byHash.has(h)) byHash.set(h, posix(relative(dir, abs)));
171
+ }
172
+ }
173
+ };
174
+ go(dir);
175
+ const g = join(dir, GATHERED);
176
+ if (existsSync(g)) {
177
+ for (const line of readFileSync(g, "utf8").split("\n")) {
178
+ if (!line.trim()) continue;
179
+ try { const r = JSON.parse(line); if (r.source && r.to) gathered.set(r.source, r.to); } catch { /* a line an older tool wrote; skipped */ }
180
+ }
181
+ }
182
+ return { byHash, gathered };
183
+ }
184
+
185
+ // Where a record already holds an item, or null.
186
+ export const presentAt = (index, sha) => index.byHash.get(sha) ?? index.gathered.get(sha) ?? null;
187
+
188
+ // The record's person pages, by name: every markdown file in a folder whose manifest says person.
189
+ export function personPages(evda) {
190
+ const out = new Map();
191
+ for (const m of folderManifests(evda)) {
192
+ if (m.type !== "person") continue;
193
+ for (const n of readdirSync(m.dir)) if (/\.md$/i.test(n)) out.set(basename(n, extname(n)), posix(relative(evda.dir, join(m.dir, n))));
194
+ }
195
+ return out;
196
+ }
197
+
198
+ // ---- scan ----
199
+
200
+ // Walk one source. Returns { files: [{ rel, abs, size }], skipped: [{ rel, why }], apps: [] }.
201
+ function walk(root) {
202
+ const files = [];
203
+ const skipped = [];
204
+ const apps = new Set();
205
+ const st = lstatSync(root);
206
+ if (st.isFile()) return { files: [{ rel: basename(root), abs: root, size: st.size }], skipped, apps: [] };
207
+ const go = (dir) => {
208
+ for (const name of readdirSync(dir).sort()) {
209
+ const abs = join(dir, name);
210
+ const rel = posix(relative(root, abs));
211
+ if (APP_DIRS[name]) apps.add(APP_DIRS[name]);
212
+ if (SKIP_NAMES.has(name)) { skipped.push({ rel, why: APP_DIRS[name] ? `${APP_DIRS[name]}'s own settings, not the owner's notes` : "app or system state" }); continue; }
213
+ const s = lstatSync(abs);
214
+ if (s.isSymbolicLink()) { skipped.push({ rel, why: "a symlink; not followed" }); continue; }
215
+ if (s.isDirectory()) { go(abs); continue; }
216
+ if (!s.isFile()) { skipped.push({ rel, why: "not a regular file" }); continue; }
217
+ if (name.startsWith(".")) { skipped.push({ rel, why: "a hidden file" }); continue; }
218
+ files.push({ rel, abs, size: s.size });
219
+ }
220
+ };
221
+ go(root);
222
+ return { files, skipped, apps: [...apps] };
223
+ }
224
+
225
+ // The folders an item sits in, below the source root, without the containers.
226
+ function subjectOf(rel, projectRoot) {
227
+ if (projectRoot) return null;
228
+ const parts = rel.split("/").slice(0, -1);
229
+ for (let i = parts.length - 1; i >= 0; i--) {
230
+ const p = parts[i];
231
+ if (CONTAINERS.has(p.toLowerCase())) continue;
232
+ if (/^(people|contacts|meetings|projects|drafts|writing|essays|clippings|reading|articles|bookmarks|logs|calendar|photos|attachments)$/i.test(p)) continue;
233
+ return p;
234
+ }
235
+ return null;
236
+ }
237
+
238
+ // A project is a folder directly under a folder named Projects, or any folder holding a README
239
+ // beside at least one other file. Returns the project's folder (relative) for a file, or null.
240
+ function projectRootFor(rel, readmeDirs) {
241
+ const parts = rel.split("/");
242
+ for (let i = 1; i < parts.length; i++) {
243
+ const dir = parts.slice(0, i).join("/");
244
+ if (i >= 2 && /^projects?$/i.test(parts[i - 2]) ) return dir;
245
+ if (readmeDirs.has(dir)) return dir;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ // Type one file. Returns the item's facts. `text` is null for a binary.
251
+ function typeItem({ rel, buf, ext, projectRoot }) {
252
+ const name = basename(rel, extname(rel));
253
+ const folders = rel.split("/").slice(0, -1);
254
+ const inFolder = (re) => folders.some((f) => re.test(f));
255
+ const item = { kind: null, why: [], mode: "move", confidence: "high" };
256
+ if (ARCHIVE_EXT.has(ext)) return { ...item, kind: "blob", archive: true, why: ["an archive file"] };
257
+ if (BINARY_EXT.has(ext) || isBinary(buf) || buf.length > TEXT_MAX) return { ...item, kind: "blob", why: [BINARY_EXT.has(ext) ? `a ${ext.slice(1)} file` : "binary bytes"] };
258
+ const raw = buf.toString("utf8");
259
+ const secret = raw.split("\n").map(secretShape).find(Boolean);
260
+ if (secret && !falsified("keep-secrets")) return { ...item, kind: "note", refused: `holds ${secret}`, why: [`holds ${secret}`] };
261
+ if (ext === ".vcf") {
262
+ const cards = vcards(raw);
263
+ return { ...item, kind: "person", mode: "reference", why: [`a Contacts export of ${cards.length} card(s)`], cards: cards.map((c) => ({ name: c.name, ref: `contacts:${c.uid ?? slugId(c.name)}`, ...(falsified("copy-contacts") ? { payload: c.payload } : {}) })) };
264
+ }
265
+ if (ext === ".webloc" || ext === ".url") {
266
+ const url = weblocUrl(raw);
267
+ return url ? { ...item, kind: "source", mode: "reference", refs: [`url:${url}`], why: ["a bookmark"] } : { ...item, kind: "blob", why: ["a bookmark file with no URL in it"] };
268
+ }
269
+ if (ext === ".ics") return { ...item, kind: "event", why: ["a calendar export"] };
270
+ if (ext === ".csv" || ext === ".tsv") {
271
+ const [head, first] = raw.split(/\r?\n/);
272
+ if (/\bdate\b/i.test(head ?? "") || LEADING_DAY.test(first ?? "")) return { ...item, kind: "log", why: ["rows of fields with a date column"] };
273
+ return { ...item, kind: "source", why: ["a table"] };
274
+ }
275
+ const text = ext === ".html" || ext === ".htm" ? stripHtml(raw) : raw;
276
+ const { fm, body } = frontmatter(text);
277
+ const heading = headingOf(body);
278
+ const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
279
+ const facts = { heading, dated: LEADING_DAY.exec(name)?.[1] ?? (DAY.test(fm.date ?? "") ? fm.date : null), daily: DAY.test(name) };
280
+ if (THERAPY.test(body)) facts.sensitive = { kind: "therapy", word: THERAPY.exec(body)[1] };
281
+ const att = ATTENDEES.exec(body);
282
+ if (att) facts.attendees = att[1].split(/,|\band\b/).map((s) => s.trim()).filter((s) => /^[A-Z][\p{L}'-]+(?: [A-Z][\p{L}'-]+)+$/u.test(s));
283
+ const declared = (fm.kind ?? fm.type ?? "").toLowerCase();
284
+ const typed = (kind, why, extra = {}) => ({ ...item, ...facts, kind, why: [why], ...extra });
285
+ if (GATHER_KINDS.includes(declared)) return typed(declared, `frontmatter says ${fm.kind ? "kind" : "type"}: ${declared}`);
286
+ if (projectRoot) {
287
+ const fin = FINISHED.exec(body);
288
+ return typed("project", `in the project folder ${projectRoot}`, fin ? { finishedWord: fin[1].replace(/[;.]$/, "") } : {});
289
+ }
290
+ if (lines.length && lines.every((l) => URL_LINE.test(l))) return typed("source", "a list of links and nothing else", { mode: "reference", refs: lines.map((l) => `url:${l}`) });
291
+ if (inFolder(/^(people|contacts)$/i)) return typed("person", `in the folder ${folders.find((f) => /^(people|contacts)$/i.test(f))}`);
292
+ if (att || MEETING.test(name)) return typed("event", att ? `names its ${att[0].split(":")[0].toLowerCase()}` : `named "${MEETING.exec(name)[1]}"`);
293
+ if (/^(draft|published|final)$/i.test(fm.status ?? "")) return typed("piece", `frontmatter says status: ${fm.status}`);
294
+ if (inFolder(/^(drafts|writing|essays)$/i)) return typed("piece", `in the folder ${folders.find((f) => /^(drafts|writing|essays)$/i.test(f))}`);
295
+ const src = /^source:\s*(https?:\/\/\S+)/im.exec(body);
296
+ if (src) return typed("source", "names its source", { refs: [`url:${src[1]}`] });
297
+ if (inFolder(/^(clippings|reading|articles)$/i)) return typed("source", `in the folder ${folders.find((f) => /^(clippings|reading|articles)$/i.test(f))}`);
298
+ if (facts.daily) return typed("note", "named for its day");
299
+ if (inFolder(/^(inbox|captures)$/i)) return typed("capture", `in the folder ${folders.find((f) => /^(inbox|captures)$/i.test(f))}`);
300
+ if (!heading && lines.length <= 2 && body.trim().length < 280) return typed("capture", "a line or two with no title");
301
+ return typed("note", facts.dated ? "dated words" : "words with no other shape", { confidence: facts.dated || heading ? "high" : "low" });
302
+ }
303
+
304
+ // Scan every source. Reads bytes; writes nothing anywhere.
305
+ export function scan(sources, { now = new Date() } = {}) {
306
+ if (!Array.isArray(sources) || sources.length === 0) throw new EvdaError("REFUSED: gather needs at least one source: a folder or an export file.", { code: "NO_SOURCE", exit: 2 });
307
+ const out = { sources: [], items: [], skipped: [], chats: [] };
308
+ sources.forEach((src, si) => {
309
+ const root = resolve(src);
310
+ if (!existsSync(root)) throw new EvdaError(`REFUSED: the source ${root} does not exist. Nothing was written.`, { code: "NO_SOURCE", exit: 2 });
311
+ const w = falsified("examine-nothing") ? { files: [], skipped: [], apps: [] } : walk(root);
312
+ const readmeDirs = new Set(w.files.filter((f) => /^readme\.(md|txt)$/i.test(basename(f.rel)) && f.rel.includes("/")).map((f) => dirname(f.rel)).filter((d) => w.files.filter((f) => dirname(f.rel) === d).length >= 2));
313
+ out.sources.push({ id: `s${si}`, path: root, files: w.files.length, skipped: w.skipped.length, apps: w.apps });
314
+ for (const s of w.skipped) out.skipped.push({ source: `s${si}`, ...s });
315
+ for (const f of w.files) {
316
+ const buf = readFileSync(f.abs);
317
+ const ext = extname(f.rel).toLowerCase();
318
+ const chat = ext === ".json" ? readChatExport(buf) : null;
319
+ if (chat) {
320
+ const convs = conversations(chat);
321
+ out.chats.push({ source: `s${si}`, path: f.rel, sha256: sha256(buf), format: chat.format, conversations: convs.length, messages: convs.reduce((n, c) => n + c.messages.length, 0) });
322
+ continue;
323
+ }
324
+ const projectRoot = projectRootFor(f.rel, readmeDirs);
325
+ // Sealed (HQ-1220) and just noted (HQ-1414): such a file is typed from its path and frontmatter
326
+ // alone, so no heading, name or word of it reaches the proposal or the owner's AI; a sealed
327
+ // section is cut before any file is read for its heading, people or words. Its bytes and hash
328
+ // are unchanged, so apply copies it with its flag.
329
+ const seal = sealedOf(ext, buf);
330
+ const facts = typeItem({ rel: f.rel, buf: seal.buf, ext, projectRoot });
331
+ if (seal.sealed) facts.sealed = true;
332
+ if (seal.noted) facts.noted = true;
333
+ const item = { id: `s${si}:${f.rel}`, source: `s${si}`, path: f.rel, size: f.size, sha256: sha256(buf), ...facts };
334
+ if (projectRoot) item.project = projectRoot;
335
+ item.subject = subjectOf(f.rel, projectRoot);
336
+ if (!item.refused && !item.sealed && !item.noted && item.kind !== "blob" && item.mode !== "reference") item.text = seal.buf.toString("utf8");
337
+ out.items.push(item);
338
+ }
339
+ });
340
+ if (falsified("touch-source")) {
341
+ const t = out.items.find((i) => i.text !== undefined);
342
+ if (t) appendFileSync(join(out.sources.find((s) => s.id === t.source).path, t.path), " ");
343
+ }
344
+ return out;
345
+ }
346
+
347
+ // What a pile file may be read as: a sealed or just-noted markdown or text file is its frontmatter
348
+ // only, and any other text file is itself with every sealed section cut. { sealed, noted, buf }.
349
+ function sealedOf(ext, buf) {
350
+ if (![".md", ".markdown", ".txt"].includes(ext)) return { sealed: false, noted: false, buf };
351
+ const raw = buf.toString("utf8");
352
+ const held = heldBy(frontmatterOf(raw), "gather");
353
+ if (held) {
354
+ const lines = raw.split("\n");
355
+ const end = lines.indexOf("---", 1);
356
+ return { sealed: held !== NOTED, noted: held === NOTED, buf: Buffer.from(`${lines.slice(0, end + 1).join("\n")}\n`, "utf8") };
357
+ }
358
+ if (!carries("gather", "sealed")) return { sealed: false, noted: false, buf };
359
+ const cut = withoutSealed(raw);
360
+ return { sealed: false, noted: false, buf: cut.sections ? Buffer.from(cut.text, "utf8") : buf };
361
+ }
362
+
363
+ // People: names from Contacts cards, attendee lines and existing People pages, and which files
364
+ // mention each. Only a name a source itself names is a person; nothing is guessed from capitals.
365
+ function findPeople(items) {
366
+ const byName = new Map();
367
+ const add = (name, fact) => {
368
+ const p = byName.get(name) ?? { name, cards: [], pages: [], files: new Set() };
369
+ Object.entries(fact).forEach(([k, v]) => p[k].push(v));
370
+ byName.set(name, p);
371
+ };
372
+ for (const i of items) {
373
+ for (const c of i.cards ?? []) add(c.name, { cards: c.ref });
374
+ for (const a of i.attendees ?? []) add(a, {});
375
+ if (i.kind === "person" && i.mode !== "reference") add(i.heading ?? basename(i.path, extname(i.path)), { pages: i.id });
376
+ }
377
+ const firsts = new Map();
378
+ for (const n of byName.keys()) firsts.set(n.split(" ")[0], (firsts.get(n.split(" ")[0]) ?? 0) + 1);
379
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
380
+ for (const p of byName.values()) {
381
+ const first = p.name.split(" ")[0];
382
+ const re = new RegExp(`(^|[^\\p{L}])(${escape(p.name)}${firsts.get(first) === 1 && first.length >= 3 ? `|${escape(first)}` : ""})($|[^\\p{L}])`, "u");
383
+ for (const i of items) {
384
+ if (i.text === undefined || p.pages.includes(i.id)) continue;
385
+ if (re.test(i.text) || re.test(i.path)) p.files.add(i.id);
386
+ }
387
+ }
388
+ return [...byName.values()].map((p) => ({ name: p.name, ref: p.cards[0] ?? null, page: p.pages[0] ?? null, files: [...p.files].sort() })).sort((a, b) => a.name.localeCompare(b.name));
389
+ }
390
+
391
+ function findProjects(items) {
392
+ const roots = new Map();
393
+ for (const i of items) {
394
+ if (!i.project) continue;
395
+ const r = roots.get(`${i.source}:${i.project}`) ?? { id: `${i.source}:${i.project}`, source: i.source, root: i.project, name: basename(i.project), files: [], finished: null };
396
+ r.files.push(i.id);
397
+ if (i.finishedWord && !r.finished) r.finished = { file: i.id, word: i.finishedWord };
398
+ roots.set(r.id, r);
399
+ }
400
+ return [...roots.values()];
401
+ }
402
+
403
+ // Which preset the pile fits, and why, from counts alone.
404
+ export function choosePreset(items, projects) {
405
+ const courses = new Set(items.flatMap((i) => i.path.split("/").slice(0, -1).filter((f) => COURSE.test(f) || COURSE_WORD.test(f))));
406
+ const subjects = new Set(items.map((i) => i.subject).filter(Boolean));
407
+ // A course is a collection (Spec 07 section 2), so courses make Projects and areas, whose
408
+ // Academics folder holds one folder per course.
409
+ if (courses.size >= 2) return { id: "projects-and-areas", why: [`${courses.size} folders look like courses (${[...courses].slice(0, 3).join(", ")}), and a course is a collection`] };
410
+ if (projects.length >= 1 || subjects.size >= 3) return { id: "projects-and-areas", why: [`${projects.length} project folder(s) and ${subjects.size} subjects (${[...subjects].slice(0, 4).join(", ")})`] };
411
+ return { id: "journal", why: [`mostly dated notes and people, ${subjects.size} subject(s) and no project folder`] };
412
+ }
413
+
414
+ // ---- place ----
415
+
416
+ // Where a kind lives under a preset: the preset's own folder when it has one, else a folder the
417
+ // pile needs and the preset does not have, named here and marked as such.
418
+ const NEEDED = { note: "Notes", capture: "Captures", person: "People", project: "Projects", event: "Events", log: "Logs", source: "Sources", piece: "Writing", blob: "Attachments", areas: "Notes" };
419
+
420
+ // The role a path under the preset fills: the longest mapping path at or above it.
421
+ export function presetRole(preset, rel) {
422
+ if (falsified("no-role")) return null;
423
+ let best = null;
424
+ for (const [key, path] of Object.entries(preset.mapping ?? {})) {
425
+ if ((rel === path || rel.startsWith(`${path}/`)) && (!best || path.length > best.path.length)) best = { key, path, role: fourRoleOf(key) };
426
+ }
427
+ return best?.role ?? null;
428
+ }
429
+
430
+ // Where each kind lives under a preset, by type and by the role the kind fills: of the preset's
431
+ // folders of that type, one in the kind's role first.
432
+ function homes(preset) {
433
+ const typed = (t, role = null) => {
434
+ const all = preset.folders.filter((f) => f.type === t);
435
+ return (all.find((f) => role && presetRole(preset, f.path) === role) ?? all[0])?.path ?? null;
436
+ };
437
+ const named = (re) => preset.folders.find((f) => re.test(basename(f.path)))?.path ?? null;
438
+ return {
439
+ type: typed,
440
+ areas: named(/^\d*_?Areas$/) ?? preset.folders.find((f) => f.type === "concept" && /^Notes$/.test(basename(f.path)))?.path ?? null,
441
+ attachments: named(/^\d*_?Attachments$/),
442
+ captures: typed("capture", "journal"),
443
+ courses: typed("course", "collections"),
444
+ };
445
+ }
446
+
447
+ // A placed text item's name: `.txt` becomes `.md`, bytes unchanged. Every other name is kept.
448
+ const asMarkdown = (i, name) => (i.kind !== "blob" && /\.txt$/i.test(name) ? name.replace(/\.txt$/i, ".md") : name);
449
+
450
+ // The placement of every item, given the facts and the answers. Pure.
451
+ export function place(p) {
452
+ const preset = loadPreset(p.preset.id);
453
+ const h = homes(preset);
454
+ const ans = Object.fromEntries((p.answers ?? []).map((a) => [a.question, a.value]));
455
+ const needed = new Map();
456
+ const folderFor = (key, type) => {
457
+ const own = key === "areas" ? h.areas : key === "blob" ? (h.attachments ?? h.type("blob", "collections")) : h.type(FOLDER_TYPE_FOR[key] ?? key, ROLE_FOR_KIND[key]);
458
+ if (own) return own;
459
+ // A folder the preset lacks is named for the kind and fills the kind's role, declared as a
460
+ // facet of that role in the proposal's mapping, so no gathered folder fills no role.
461
+ needed.set(NEEDED[key], { path: NEEDED[key], type: type ?? null, role: ROLE_FOR_KIND[key] ?? "collections" });
462
+ return NEEDED[key];
463
+ };
464
+ const therapyOwn = ans["circle:therapy"] === "yes";
465
+ const placements = [];
466
+ const taken = new Map();
467
+ // The role a destination fills: the preset's mapping, else the needed folder's own role.
468
+ const roleOf = (to) => presetRole(preset, to) ?? (falsified("no-role") ? null : undefined) ?? [...needed.values()].find((n) => to === n.path || to.startsWith(`${n.path}/`))?.role ?? null;
469
+ const claim = (to, id) => {
470
+ let t = to;
471
+ for (let n = 2; taken.has(t) && taken.get(t) !== id; n++) t = to.replace(/(\.[^./]+)?$/, (e) => ` (${n})${e ?? ""}`);
472
+ taken.set(t, id);
473
+ return t;
474
+ };
475
+ for (const i of p.items) {
476
+ const file = asMarkdown(i, basename(i.path));
477
+ let pl;
478
+ if (i.refused) pl = { status: "refused", to: null, why: `${i.refused}; left where it is and quoted nowhere. Keep the secret in a password manager.` };
479
+ else if (i.present) pl = { status: "present", to: null, at: i.present, why: `already in the record at ${i.present}; left where it is` };
480
+ else if (i.archive) pl = { status: "unplaced", to: null, why: "an archive file; unpack it and gather the folder" };
481
+ else if (i.cards) pl = { status: "reference", to: null, why: "held by Contacts; a person page keeps a contacts: reference, never the card" };
482
+ else if (i.kind === "project") {
483
+ // Status is a field, never a move: a finished project stays in its collection and says so.
484
+ const proj = p.projects.find((x) => x.id === `${i.source}:${i.project}`);
485
+ const finished = ans[`finished:${proj.id}`] === "finished";
486
+ pl = { status: "placed", to: `${folderFor("project", "project")}/${proj.name}/${asMarkdown(i, posix(relative(i.project, i.path)))}`, ...(finished ? { fields: { status: "finished" } } : {}) };
487
+ } else if (i.sensitive?.kind === "therapy" && therapyOwn) {
488
+ pl = { status: "placed", to: `${h.areas ? `${h.areas}/Therapy` : "Therapy"}/${file}`, circle: "therapy" };
489
+ if (!h.areas) needed.set("Therapy", { path: "Therapy", type: "record" });
490
+ } else if (i.kind === "note" && i.daily) pl = { status: "placed", to: `${folderFor("note", "record")}/${file}` };
491
+ else if (i.kind === "note") {
492
+ const course = h.courses && i.subject && COURSE.test(i.subject);
493
+ if (course) pl = { status: "placed", to: `${h.courses}/${i.subject}/${file}` };
494
+ else if (i.subject) pl = { status: "placed", to: `${folderFor("areas", "record")}/${i.subject}/${file}` };
495
+ else pl = { status: "unplaced", to: null, why: `no subject: it sits in no folder that says what it is about${h.captures ? `; ${h.captures}/ holds it until someone says` : ""}` };
496
+ } else if (i.mode === "reference") pl = { status: "reference", to: `${folderFor(i.kind, FOLDER_TYPE_FOR[i.kind])}/${basename(i.path, extname(i.path))}.md`, refs: i.refs, why: "a new entry holding the reference; the file stays where it is" };
497
+ else pl = { status: "placed", to: `${folderFor(i.kind, FOLDER_TYPE_FOR[i.kind])}/${file}` };
498
+ if (pl.to) pl.to = claim(pl.to, i.id);
499
+ if (pl.to) pl.role = roleOf(pl.to);
500
+ if (i.refs && pl.status === "placed") pl.refs = i.refs;
501
+ if (i.kind === "person" && !i.cards) {
502
+ const who = p.people.find((x) => x.page === i.id);
503
+ if (who?.ref) pl.refs = [who.ref];
504
+ }
505
+ placements.push({ item: i.id, ...pl });
506
+ }
507
+ // Person pages the owner said yes to, made new, holding a Contacts reference when a card names them.
508
+ for (const who of p.people) {
509
+ if (ans[`person:${slugId(who.name)}`] !== "yes" || who.page) continue;
510
+ const to = claim(`${folderFor("person", "person")}/${who.name}.md`, `new:person:${who.name}`);
511
+ placements.push({ item: `new:person:${who.name}`, status: "create", to, role: roleOf(to), refs: who.ref ? [who.ref] : [], mentions: who.files.length });
512
+ }
513
+ const circles = therapyOwn ? [{ name: "therapy", covers: h.areas ? `${h.areas}/Therapy` : "Therapy", status: "proposed only: a proposal file at apply, never a circle" }] : [];
514
+ const used = new Map();
515
+ for (const pl of placements) {
516
+ if (!pl.to) continue;
517
+ const top = preset.folders.map((f) => f.path).filter((fp) => pl.to.startsWith(`${fp}/`)).sort((a, b) => b.length - a.length)[0] ?? pl.to.split("/")[0];
518
+ used.set(top, (used.get(top) ?? 0) + 1);
519
+ }
520
+ const folders = [...used.entries()].map(([path, n]) => {
521
+ const f = preset.folders.find((x) => x.path === path);
522
+ return f ? { path, type: f.type ?? null, role: presetRole(preset, path), from: "preset", items: n } : { path, type: needed.get(path)?.type ?? null, role: needed.get(path)?.role ?? null, from: "needed: the preset has no folder for these", items: n };
523
+ }).sort((a, b) => a.path.localeCompare(b.path));
524
+ // The roles applying this proposal would declare: the preset's, and a facet for every needed
525
+ // folder, so each placed item lands in a folder that fills one of the four roles.
526
+ const mapping = { ...(preset.mapping ?? {}) };
527
+ for (const f of folders.filter((x) => x.from !== "preset" && x.role)) mapping[`${f.role}/${f.path.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`] = f.path;
528
+ return { placements, folders, circles, mapping };
529
+ }
530
+
531
+ // ---- questions ----
532
+
533
+ // Every question the facts could raise, before the filter.
534
+ function candidates(p) {
535
+ const out = [];
536
+ for (const who of p.people) {
537
+ if (who.page) continue;
538
+ if (who.files.length < PEOPLE_MIN_FILES && !falsified("ask-everything")) continue;
539
+ out.push({ id: `person:${slugId(who.name)}`, kind: "person", options: ["yes", "no"], text: `${who.files.length} file(s) mention ${who.name}. Keep a page for ${who.name}?`, about: who.name });
540
+ }
541
+ for (const proj of p.projects) {
542
+ if (!proj.finished && !falsified("ask-everything")) continue;
543
+ out.push({ id: `finished:${proj.id}`, kind: "finished", options: ["finished", "live"], text: `${proj.name} looks finished (${basename(proj.files.find((f) => f === proj.finished?.file) ?? proj.root)} says "${proj.finished?.word ?? "nothing"}"). Is it finished, or live? It stays where it is; finished is its status.`, about: proj.name });
544
+ }
545
+ const therapy = p.items.filter((i) => i.sensitive?.kind === "therapy");
546
+ if (therapy.length) out.push({ id: "circle:therapy", kind: "circle", options: ["yes", "no"], text: `${therapy.length} file(s) read like therapy notes. Give them their own circle, so only who you choose reads them?`, about: "therapy" });
547
+ return out;
548
+ }
549
+
550
+ const placementMap = (p) => Object.fromEntries(place(p).placements.map((x) => [x.item, placedAs(x)]));
551
+ // A placement in one string: where it goes, and the fields it is given there.
552
+ const placedAs = (x) => `${x.status}:${x.to}${x.fields ? ` ${JSON.stringify(x.fields)}` : ""}`;
553
+
554
+ // The questions worth asking: those whose answers place at least one item differently. Each
555
+ // carries the items it moves.
556
+ export function questions(p) {
557
+ const base = p.answers ?? [];
558
+ const out = [];
559
+ for (const q of candidates(p)) {
560
+ const outcomes = q.options.map((o) => placementMap({ ...p, answers: [...base.filter((a) => a.question !== q.id), { question: q.id, value: o }] }));
561
+ const ids = new Set(outcomes.flatMap((m) => Object.keys(m)));
562
+ const affects = [...ids].filter((id) => new Set(outcomes.map((m) => m[id])).size > 1).sort();
563
+ if (!affects.length && !falsified("ask-everything")) continue;
564
+ const answered = base.find((a) => a.question === q.id);
565
+ out.push({ ...q, affects, ...(answered ? { answer: answered.value } : {}) });
566
+ }
567
+ return out;
568
+ }
569
+
570
+ // ---- the proposal ----
571
+
572
+ function counts(p) {
573
+ const c = { items: p.items.length, placed: 0, reference: 0, create: 0, unplaced: 0, refused: 0, present: 0, skipped: p.skipped.length };
574
+ for (const pl of p.placements) c[pl.status] += 1;
575
+ c.chats = (p.chats ?? []).length;
576
+ c.chatMessages = (p.chats ?? []).reduce((n, x) => n + x.messages, 0);
577
+ c.questions = p.questions.length;
578
+ c.open = p.questions.filter((q) => q.answer === undefined).length;
579
+ return c;
580
+ }
581
+
582
+ function refresh(p) {
583
+ const r = place(p);
584
+ p.placements = r.placements;
585
+ p.folders = r.folders;
586
+ p.circles = r.circles;
587
+ p.mapping = r.mapping;
588
+ p.questions = questions(p);
589
+ p.counts = counts(p);
590
+ return p;
591
+ }
592
+
593
+ export function gather(sources, { now = new Date(), into = null } = {}) {
594
+ const s = scan(sources, { now });
595
+ // The record this pile would grow, when named: what it already holds is present, and a person
596
+ // with a page there has one.
597
+ let record = null;
598
+ if (into !== null) {
599
+ const abs = resolve(into);
600
+ if (!existsSync(join(abs, "evda.json"))) throw new EvdaError(`REFUSED: --into ${abs} is not an EVDA (no evda.json). Name the record this pile would grow, or leave --into off for a new one. Nothing was written.`, { code: "NOT_A_RECORD", exit: 2 });
601
+ record = openEvda(abs);
602
+ const index = presentIndex(record.dir);
603
+ for (const i of s.items) { const at = presentAt(index, i.sha256); if (at) i.present = at; }
604
+ // What each chat export would add to this record: the owner's messages it does not hold yet.
605
+ for (const c of s.chats) c.add = chatPlan(record, s.sources.find((x) => x.id === c.source), c).counts.add;
606
+ }
607
+ const projects = findProjects(s.items);
608
+ const people = findPeople(s.items);
609
+ if (record && !falsified("reapply-resorts")) {
610
+ const pages = personPages(record);
611
+ for (const who of people) if (!who.page && pages.has(who.name)) who.page = `record:${pages.get(who.name)}`;
612
+ }
613
+ const preset = choosePreset(s.items, projects);
614
+ const p = {
615
+ evdaGather: GATHER_VERSION,
616
+ id: `${now.toISOString().slice(0, 10)}-${sha256(Buffer.from(s.sources.map((x) => x.path).join("\n"))).slice(0, 8)}`,
617
+ created: now.toISOString(),
618
+ note: "A proposal only. Nothing in any source was changed, and nothing was written into a record.",
619
+ sources: s.sources,
620
+ ...(record ? { into: record.dir } : {}),
621
+ preset: { ...preset, title: loadPreset(preset.id).title },
622
+ projects,
623
+ people,
624
+ items: s.items.map(({ text, finishedWord, ...rest }) => rest),
625
+ skipped: s.skipped,
626
+ chats: s.chats,
627
+ answers: [],
628
+ ai: null,
629
+ };
630
+ // The people and project facts were read from the text; the text itself is not kept.
631
+ return refresh(p);
632
+ }
633
+
634
+ // Record one answer and re-place. Returns { proposal, moved: [{ item, from, to }] }.
635
+ export function answer(p, questionId, value, { now = new Date() } = {}) {
636
+ const q = questions(p).find((x) => x.id === questionId);
637
+ if (!q) throw refuse(`no open question "${questionId}" on this proposal. Its questions: ${questions(p).map((x) => x.id).join(", ") || "none"}.`, "NO_QUESTION");
638
+ if (!q.options.includes(value)) throw refuse(`"${questionId}" takes ${q.options.map((o) => JSON.stringify(o)).join(" or ")}, not ${JSON.stringify(value)}.`, "BAD_ANSWER");
639
+ const said = (x) => (x.to ? `${x.to}${x.fields?.status ? ` (status ${x.fields.status})` : ""}` : null);
640
+ const before = Object.fromEntries(p.placements.map((x) => [x.item, said(x)]));
641
+ p.answers = [...p.answers.filter((a) => a.question !== questionId), { question: questionId, value, at: now.toISOString() }];
642
+ if (falsified("answer-no-replace")) { p.questions = questions(p); return { proposal: p, moved: [] }; }
643
+ refresh(p);
644
+ const after = Object.fromEntries(p.placements.map((x) => [x.item, said(x)]));
645
+ const moved = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((id) => before[id] !== after[id]).sort().map((id) => ({ item: id, from: before[id] ?? null, to: after[id] ?? null }));
646
+ p.answers[p.answers.length - 1].moved = moved.length;
647
+ return { proposal: p, moved };
648
+ }
649
+
650
+ // ---- the owner's AI ----
651
+
652
+ // Items it could not place, or typed only by elimination, handed to the owner's claude -p.
653
+ export function withOwnersAi(p, { model = null, env = process.env } = {}) {
654
+ const unsure = p.items.filter((i) => !i.refused && !i.sealed && !i.noted && !i.present && (i.confidence === "low" || p.placements.find((x) => x.item === i.id)?.status === "unplaced") && !i.archive);
655
+ if (!unsure.length) { p.ai = { status: "SKIPPED", why: "nothing was unsure" }; return { ok: true, p }; }
656
+ const kinds = GATHER_KINDS.join(", ");
657
+ const lines = unsure.map((i) => `- ${i.id} (typed ${i.kind}${i.heading ? `, titled "${i.heading}"` : ""})`);
658
+ const prompt = [
659
+ "These files are from a pile the owner is turning into an EVDA. The gatherer could not place them.",
660
+ `For each, answer on one line: <id> | <kind> | <subject>, where kind is one of ${kinds} and subject is the area it is about in one or two words, or - for none.`,
661
+ "Only the lines in that shape are read. Write nothing else to disk.",
662
+ "",
663
+ ...lines,
664
+ ].join("\n");
665
+ const ai = askOwnersAi(null, prompt, { model, env });
666
+ if (!ai.ok) { p.ai = { status: "SKIPPED", why: ai.why }; return { ok: false, p }; }
667
+ if (ai.text.split("\n").some(secretShape)) { p.ai = { status: "NOT_KEPT", why: "the answer held a secret-shaped line" }; return { ok: false, p }; }
668
+ let applied = 0;
669
+ let ignored = 0;
670
+ for (const line of ai.text.split("\n")) {
671
+ const m = /^\s*-?\s*(\S.*?)\s*\|\s*([a-z]+)\s*\|\s*(.+?)\s*$/.exec(line);
672
+ const item = m && unsure.find((i) => i.id === m[1]);
673
+ if (!item || !GATHER_KINDS.includes(m[2]) || /[/\\]|\.\./.test(m[3])) { if (line.trim()) ignored += 1; continue; }
674
+ item.kind = m[2];
675
+ item.subject = m[3] === "-" ? null : m[3];
676
+ item.confidence = "owner's AI";
677
+ item.why = [...item.why, `the owner's AI typed it ${m[2]}${m[3] === "-" ? "" : `, about ${m[3]}`}`];
678
+ applied += 1;
679
+ }
680
+ p.ai = { status: "ASKED", asked: unsure.length, applied, ignored };
681
+ refresh(p);
682
+ return { ok: true, p };
683
+ }
684
+
685
+ // ---- files ----
686
+
687
+ export function gatherDir(env = process.env) {
688
+ return resolve(env.EVDA_GATHER_DIR || join(homedir(), ".evda", "gather"));
689
+ }
690
+
691
+ // Refuse an output path inside a source or inside a record: the gather writes into neither.
692
+ export function checkOut(out, p) {
693
+ const abs = resolve(out);
694
+ for (const s of p.sources) {
695
+ if (abs === s.path || abs.startsWith(s.path + sep)) throw new EvdaError(`REFUSED: the proposal would be written inside the source ${s.path}. A gather writes nothing into a source; pass --out somewhere else, or set EVDA_GATHER_DIR. Nothing was written.`, { code: "OUT_IN_SOURCE", exit: 2 });
696
+ }
697
+ const rec = findRoot(dirname(abs));
698
+ if (rec) throw new EvdaError(`REFUSED: the proposal would be written inside the EVDA at ${rec}. A gather writes nothing into a record until the owner says yes to applying it. Nothing was written.`, { code: "OUT_IN_RECORD", exit: 2 });
699
+ return abs;
700
+ }
701
+
702
+ export function saveProposal(p, out) {
703
+ const abs = checkOut(out, p);
704
+ mkdirSync(dirname(abs), { recursive: true });
705
+ const tmp = `${abs}.tmp-${process.pid}`;
706
+ writeFileSync(tmp, JSON.stringify(p, null, 2) + "\n");
707
+ renameSync(tmp, abs);
708
+ return abs;
709
+ }
710
+
711
+ export function proposalPath(ref, env = process.env) {
712
+ if (typeof ref !== "string" || ref.trim() === "") throw refuse("a proposal is named by its id or its path.", "NO_PROPOSAL");
713
+ if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]{8}$/.test(ref)) return join(gatherDir(env), `${ref}.json`);
714
+ return resolve(ref);
715
+ }
716
+
717
+ export function loadProposal(ref, env = process.env) {
718
+ const abs = proposalPath(ref, env);
719
+ if (!existsSync(abs)) throw refuse(`no gather proposal at ${abs}.`, "NO_PROPOSAL");
720
+ let p;
721
+ try { p = JSON.parse(readFileSync(abs, "utf8")); } catch (e) { throw refuse(`${abs} is not valid JSON (${e.message}).`, "BAD_PROPOSAL"); }
722
+ if (p?.evdaGather !== GATHER_VERSION || !Array.isArray(p.items)) throw refuse(`${abs} is not a gather proposal.`, "BAD_PROPOSAL");
723
+ return { p, abs };
724
+ }
725
+
726
+ // ---- words ----
727
+
728
+ export function checkedLines(p) {
729
+ const c = p.counts;
730
+ const chats = p.chats ?? [];
731
+ return [
732
+ ...(chats.length ? [`CHECKED ${c.chatMessages} message(s) of the owner's in ${chats.reduce((n, x) => n + x.conversations, 0)} conversation(s) from ${chats.length} chat export(s)${p.into ? ` ${chats.reduce((n, x) => n + (x.add ?? 0), 0)} not yet in the record` : ""}`] : []),
733
+ `CHECKED ${c.items} items in ${p.sources.length} source(s) ${c.placed} placed, ${c.reference} by reference, ${c.create} to create, ${c.unplaced} left for a person, ${c.refused} refused, ${c.skipped} skipped${p.into ? `, ${c.present} already in the record` : ""}`,
734
+ c.questions ? `CHECKED ${c.questions} question(s) each changes where at least one item goes; ${c.open} open` : "SKIPPED questions: nothing in this pile needs the owner to decide a placement",
735
+ ];
736
+ }
737
+
738
+ export function summaryText(p, { detail = false } = {}) {
739
+ const lines = [`A proposal for ${p.into ? `growing the EVDA at ${p.into} from` : "turning"} ${p.sources.map((s) => s.path).join(", ")}${p.into ? "" : " into an EVDA"}. Nothing was moved, copied or changed.`, ""];
740
+ lines.push(`Preset: ${p.preset.title} (${p.preset.id}), because ${p.preset.why.join("; ")}.`, "", "Folders:");
741
+ for (const f of p.folders) lines.push(`- ${f.path}/${f.type ? ` (${f.type})` : ""}${f.role ? `, ${f.role}` : ""}: ${f.items} item(s)${f.from === "preset" ? "" : ", a folder the preset does not have"}`);
742
+ const byStatus = (s) => p.placements.filter((x) => x.status === s);
743
+ if (byStatus("reference").length) { lines.push("", "Kept as references, never copied:"); for (const x of byStatus("reference")) lines.push(`- ${x.item}${x.to ? ` -> ${x.to}` : ""}: ${x.why}`); }
744
+ if (byStatus("create").length) { lines.push("", "New pages:"); for (const x of byStatus("create")) lines.push(`- ${x.to}${x.refs.length ? ` (${x.refs.join(", ")})` : ""}`); }
745
+ if (byStatus("unplaced").length) { lines.push("", "Left for a person:"); for (const x of byStatus("unplaced")) lines.push(`- ${x.item}: ${x.why}`); }
746
+ if (byStatus("refused").length) { lines.push("", "Refused:"); for (const x of byStatus("refused")) lines.push(`- ${x.item}: ${x.why}`); }
747
+ if (byStatus("present").length) lines.push("", `Already in the record, left where they are: ${byStatus("present").length} item(s).`);
748
+ for (const c of p.chats ?? []) lines.push("", `A ${CHAT_FORMATS[c.format]} export (${c.path}): ${c.messages} message(s) of the owner's in ${c.conversations} conversation(s)${c.add !== undefined ? `, ${c.add} of them not in the record yet, to be added under each conversation's date and time` : ", each added to its day unless the record already holds it"}. Only their messages; the model's replies are never written.`);
749
+ if (p.circles.length) { lines.push("", "Circles:"); for (const c of p.circles) lines.push(`- ${c.name} over ${c.covers}/, ${c.status}`); }
750
+ if (detail) { lines.push("", "Every placement:"); for (const x of byStatus("placed")) lines.push(`- ${x.item} -> ${x.to}`); }
751
+ const open = p.questions.filter((q) => q.answer === undefined);
752
+ lines.push("", open.length ? `${open.length} question(s) for the owner:` : "No open questions.");
753
+ for (const q of open) lines.push(`- [${q.id}] ${q.text} (${q.options.join(" / ")})`);
754
+ return lines.join("\n");
755
+ }
756
+
757
+ // ---- chat exports ----
758
+
759
+ // The day folder of a record, or null when it maps no day role.
760
+ export function dayDirOf(evda) {
761
+ try { return dayFile(evda, "0000-00-00").dir; } catch { return null; }
762
+ }
763
+
764
+ // What one chat export adds to a record now: lib/chat-export.mjs backfillPlan over the record's day
765
+ // files. `source` is the proposal's source entry, `c` its chat entry. Throws when the export changed
766
+ // since the gather.
767
+ export function chatPlan(evda, source, c) {
768
+ const abs = existsSync(source.path) && lstatSync(source.path).isFile() ? source.path : join(source.path, c.path);
769
+ const buf = readFileSync(abs);
770
+ if (sha256(buf) !== c.sha256) throw refuse(`the chat export ${abs} changed since the gather; gather it again.`, "CHANGED");
771
+ const chat = readChatExport(buf);
772
+ const plan = backfillPlan(conversations(chat), filedSections(dayDirOf(evda)), { format: chat.format });
773
+ return { ...plan, abs };
774
+ }
775
+
776
+ // ---- MCP tools ----
777
+
778
+ export function gatherTool(evda, args = {}, { now = new Date(), env = process.env } = {}) {
779
+ onlyKeys("evda_gather", args, ["sources", "into"], "It only proposes. It moves, copies and writes nothing in the sources or the record.");
780
+ if (!Array.isArray(args.sources) || !args.sources.length || !args.sources.every((s) => typeof s === "string" && s.trim() !== "")) throw refuse('evda_gather needs "sources": the folders or export files to gather, as paths on this machine.', "NO_SOURCE");
781
+ if (args.into !== undefined && (typeof args.into !== "string" || args.into.trim() === "")) throw refuse('evda_gather "into" is the path of the record this pile would grow.', "BAD_INTO");
782
+ const p = gather(args.sources, { now, into: args.into ?? null });
783
+ if (p.counts.items === 0 && !p.counts.chats) throw refuse(`evda_gather found nothing to gather in ${args.sources.join(", ")}: CHECKED 0 items.`, "EMPTY_SOURCE");
784
+ const path = saveProposal(p, join(gatherDir(env), `${p.id}.json`));
785
+ return { p, path };
786
+ }
787
+
788
+ export function gatherQuestionsTool(evda, args = {}, { env = process.env } = {}) {
789
+ onlyKeys("evda_gather_questions", args, ["proposal"]);
790
+ const { p } = loadProposal(args.proposal, env);
791
+ return { p, open: p.questions.filter((q) => q.answer === undefined) };
792
+ }
793
+
794
+ export function gatherAnswerTool(evda, args = {}, { now = new Date(), env = process.env } = {}) {
795
+ onlyKeys("evda_gather_answer", args, ["proposal", "question", "answer"], "It records the owner's answer on the proposal and re-places what it changes; it moves no file.");
796
+ const { p, abs } = loadProposal(args.proposal, env);
797
+ const r = answer(p, args.question, args.answer, { now });
798
+ saveProposal(r.proposal, abs);
799
+ return { ...r, path: abs };
800
+ }
801
+
802
+ export function answerLines(r) {
803
+ return r.moved.length ? r.moved.map((m) => ` ${m.item}: ${m.from ?? "(nothing)"} -> ${m.to ?? "(nothing)"}`) : [" nothing moved"];
804
+ }
805
+
806
+ // A preset the gather knows. Exported for tests that check every shipped preset has a home for
807
+ // every kind, or names the folder it needs.
808
+ export const presetsKnown = () => listPresets().map((x) => x.id);