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.
Files changed (57) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +74 -7
  3. package/bin/evda.mjs +391 -6
  4. package/create-evda.mjs +54 -10
  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 +346 -13
  15. package/lib/filechanges.mjs +622 -0
  16. package/lib/gather-apply.mjs +635 -0
  17. package/lib/gather.mjs +808 -0
  18. package/lib/grants.mjs +7 -1
  19. package/lib/guard.mjs +132 -0
  20. package/lib/hooks.mjs +124 -0
  21. package/lib/keep-hook.mjs +185 -0
  22. package/lib/kinds.mjs +23 -6
  23. package/lib/kinds.schema.json +2 -1
  24. package/lib/manifest.mjs +14 -1
  25. package/lib/mcp-auth.mjs +370 -0
  26. package/lib/mcp-http.mjs +336 -0
  27. package/lib/mcp.mjs +425 -36
  28. package/lib/migrate-people.mjs +231 -0
  29. package/lib/organize.mjs +52 -12
  30. package/lib/parity.mjs +111 -0
  31. package/lib/presets.mjs +58 -7
  32. package/lib/prompt-hook.mjs +88 -0
  33. package/lib/publish.mjs +264 -0
  34. package/lib/readers.mjs +319 -0
  35. package/lib/records.mjs +52 -0
  36. package/lib/references.mjs +5 -51
  37. package/lib/render.mjs +158 -0
  38. package/lib/roles.mjs +102 -1
  39. package/lib/secret-shape.mjs +58 -0
  40. package/lib/setup.mjs +118 -0
  41. package/lib/skills.mjs +266 -0
  42. package/lib/subrecord.mjs +121 -0
  43. package/lib/sync-rules.mjs +87 -0
  44. package/lib/tools.mjs +118 -27
  45. package/lib/update-check.mjs +146 -0
  46. package/lib/work.mjs +180 -0
  47. package/lib/yaml.mjs +117 -42
  48. package/package.json +24 -15
  49. package/presets/journal/preset.json +9 -1
  50. package/presets/projects-and-areas/preset.json +16 -9
  51. package/skills/evda/SKILL.md +58 -0
  52. package/templates/AGENTS.md +118 -14
  53. package/templates/README.md +15 -1
  54. package/templates/circles-README.md +3 -3
  55. package/templates/evda-digest.md +30 -6
  56. package/LICENSE +0 -21
  57. package/presets/student/preset.json +0 -13
package/create-evda.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  //
4
4
  // create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT]
5
5
  // [--platform claude|chatgpt|gemini|cursor|other] [--training-off] [--yes]
6
+ // [--filing everything|asked] [--no-prompt-hook]
6
7
  // [--name NAME] [--layers record,ledger[,circles]] [--preset ID|none] [--force]
7
8
  //
8
9
  // On a terminal, any setup answer not given as a flag is asked, one question at a time, and setup
@@ -28,10 +29,14 @@ import { doctor } from "./lib/doctor.mjs";
28
29
  import { readPointer, setPointer, pointerPath } from "./lib/locate.mjs";
29
30
  import { FOLDER_MANIFEST } from "./lib/folders.mjs";
30
31
  import { EvdaError } from "./lib/errors.mjs";
31
- import { clients, installSkill } from "./lib/connect.mjs";
32
+ import { clients, installSkill, installGuard } from "./lib/connect.mjs";
32
33
  import { desktopInstructions } from "./lib/routines.mjs";
34
+ import { installHook } from "./lib/hooks.mjs";
33
35
  import { applyPreset, listPresets, planLines, presetIds, NONE } from "./lib/presets.mjs";
34
- import { PLATFORMS, PLATFORM_KEYS, LOADERS, DESKTOP_FILESYSTEM, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview, trainingSection } from "./lib/setup.mjs";
36
+ import { FILING_MODES } from "./lib/day-file.mjs";
37
+ import { installPromptHook } from "./lib/hooks.mjs";
38
+ import { PLATFORMS, PLATFORM_KEYS, LOADERS, DESKTOP_FILESYSTEM, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview, trainingSection, termsSection, readerFor, INTRO } from "./lib/setup.mjs";
39
+ import { describeReaders, parseReaders } from "./lib/readers.mjs";
35
40
 
36
41
  const HERE = dirname(fileURLToPath(import.meta.url));
37
42
  const TEMPLATES = join(HERE, "templates");
@@ -47,6 +52,7 @@ const CANONICAL = {
47
52
  ledger: "ledger",
48
53
  circles: "circles",
49
54
  grants: "grants",
55
+ mounts: "mounts",
50
56
  };
51
57
 
52
58
  // What each record folder holds (Spec 07, section 2), written as folder.evda.json beside the
@@ -57,13 +63,13 @@ const FOLDER_TYPES = {
57
63
  "record/people": "person",
58
64
  };
59
65
 
60
- const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--skill] [--routines morning,weekly] [--preset ${presetIds().join("|")}|none] [--use] [--no-pointer]`;
66
+ const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--filing everything|asked] [--no-prompt-hook] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--skill] [--routines morning,weekly] [--preset ${presetIds().join("|")}|none] [--use] [--no-pointer] [--no-guard]`;
61
67
 
62
68
  export function parseArgs(argv) {
63
69
  const opts = {
64
70
  layers: ["record", "ledger"], force: false, name: null, owner: "", kind: "person", id: null, pointer: true, use: false,
65
71
  yes: false, trainingOff: false, agentName: null, purpose: "", voice: "", platform: null, platformName: null,
66
- skill: null, routines: null, preset: null,
72
+ skill: null, routines: null, preset: null, guard: true, filing: null, promptHook: true,
67
73
  };
68
74
  const args = argv.flatMap((a) => {
69
75
  const eq = /^(--[a-z-]+)=([\s\S]*)$/.exec(a);
@@ -82,6 +88,12 @@ export function parseArgs(argv) {
82
88
  else if (a === "--yes" || a === "-y") opts.yes = true;
83
89
  else if (a === "--training-off") opts.trainingOff = true;
84
90
  else if (a === "--skill") opts.skill = true;
91
+ else if (a === "--no-guard") opts.guard = false;
92
+ else if (a === "--no-prompt-hook") opts.promptHook = false;
93
+ else if (a === "--filing") {
94
+ opts.filing = value(++i, a).trim().toLowerCase();
95
+ if (!FILING_MODES.includes(opts.filing)) throw new EvdaError(`--filing takes ${FILING_MODES.join(" or ")}, got ${args[i]}`, { exit: 2 });
96
+ }
85
97
  else if (a === "--no-skill") opts.skill = false;
86
98
  else if (a === "--no-routines") opts.routines = [];
87
99
  else if (a === "--routines") {
@@ -147,9 +159,18 @@ export function scaffold(opts) {
147
159
  const id = opts.id ?? `${slug(opts.name)}.atlas`;
148
160
  const problems = holderProblems({ owner: { kind }, id });
149
161
  if (problems.length) throw new EvdaError(`REFUSED: ${problems.join(" ")}`, { exit: 2 });
150
- const manifest = { evda: SCHEMA_VERSION, id, name: opts.name, owner: { name: opts.owner ?? "", kind }, created: today, layers: opts.layers, mapping };
162
+ // A new record's circles stop at any record made inside it (HQ-1371); a record adopted before
163
+ // that declares it when its owner opts in, so what it already syncs does not change under it.
164
+ const manifest = { evda: SCHEMA_VERSION, id, name: opts.name, owner: { name: opts.owner ?? "", kind }, created: today, layers: opts.layers, mapping, subrecords: "bounded" };
165
+ // Who reads the record: the AI the owner chose, for all of it (HQ-1374). Not local by default,
166
+ // because most people have no local model; a circle can say otherwise later, on the owner's yes.
167
+ const readBy = readerFor({ platform: opts.platform, platformName: opts.platformName, trainingOff: opts.trainingOff, date: today });
168
+ if (readBy) manifest.read_by = readBy;
169
+ // The owner's answer to the one filing question (HQ-1385). Unanswered, the key is left out and the
170
+ // agent asks at first connect.
171
+ if (opts.filing) manifest.filing = opts.filing;
151
172
  writeIfAbsent(join(dir, "evda.json"), JSON.stringify(manifest, null, 2) + "\n");
152
- writeIfAbsent(join(dir, "README.md"), render("README.md", { training: trainingSection() }));
173
+ writeIfAbsent(join(dir, "README.md"), render("README.md", { training: trainingSection(), terms: termsSection(), intro: INTRO, localModel: localModelSection() }));
153
174
  // The owner section of AGENTS.md holds the setup answers in the owner's words. The AI line is
154
175
  // written only when training off was confirmed; empty, it is what makes the agent ask.
155
176
  const answers = {
@@ -167,6 +188,15 @@ export function scaffold(opts) {
167
188
  for (const [role, type] of Object.entries(FOLDER_TYPES)) {
168
189
  if (mapping[role]) writeIfAbsent(join(dir, mapping[role], FOLDER_MANIFEST), JSON.stringify({ type, connectors: [], views: [] }, null, 2) + "\n");
169
190
  }
191
+ // The Claude Code read guard, for an owner who chose Claude: sessions opened in this folder are
192
+ // stopped before reading a just-noted entry, or a part set to be read by a different AI.
193
+ const guard = opts.platform === "claude" && opts.guard !== false ? installGuard(dir) : null;
194
+ // Claude Code gets the keep-record Stop hook in the record's own .claude/ (HQ-1376): a session
195
+ // that read the record or heard the owner out and filed nothing is asked once before it stops.
196
+ const hook = opts.platform === "claude" && opts.hook !== false ? installHook(dir) : null;
197
+ // And the prompt hook (HQ-1385), only when the owner chose to have everything filed: each message
198
+ // they send in Claude Code is filed verbatim before the model sees it.
199
+ const promptHook = opts.platform === "claude" && opts.filing === "everything" && opts.promptHook !== false ? installPromptHook(dir) : null;
170
200
  writeVersion(dir, SCHEMA_VERSION);
171
201
  reindex({ dir, manifest, version: { declared: SCHEMA_VERSION, onDisk: SCHEMA_VERSION } });
172
202
 
@@ -180,7 +210,13 @@ export function scaffold(opts) {
180
210
  } else {
181
211
  pointer.action = "skipped (--no-pointer)";
182
212
  }
183
- return { dir, manifest, pointer };
213
+ return { dir, manifest, pointer, guard, hook, promptHook };
214
+ }
215
+
216
+ // The local-model doc as a README section: its title dropped and its headings one level down.
217
+ function localModelSection() {
218
+ const text = readFileSync(join(HERE, "docs", "local-model.md"), "utf8");
219
+ return text.split("\n").slice(1).join("\n").replace(/^## /gm, "### ").trim();
184
220
  }
185
221
 
186
222
  // Day one. The record's first daily file records the one true thing that has happened to it: it
@@ -223,7 +259,7 @@ async function terminalIO() {
223
259
  }
224
260
 
225
261
  export function needsAsking(opts) {
226
- return opts.agentName == null || !opts.owner || !opts.purpose || !opts.voice || !opts.platform || !opts.trainingOff;
262
+ return opts.agentName == null || !opts.owner || !opts.purpose || !opts.voice || !opts.platform || !opts.trainingOff || !opts.filing;
227
263
  }
228
264
 
229
265
  // `io` is for tests: { interactive, ask(question), out(line) }. Without it, setup is interactive
@@ -245,9 +281,15 @@ export async function main(argv, io = null) {
245
281
  }
246
282
  log("");
247
283
  }
248
- const { dir, pointer } = scaffold(opts);
284
+ const { dir, pointer, guard, hook, promptHook, manifest } = scaffold(opts);
249
285
  log(`created an EVDA at ${dir}`);
250
- log(`locator ${pointer.path}: ${pointer.action}\n`);
286
+ log(`locator ${pointer.path}: ${pointer.action}`);
287
+ log(`read by: ${describeReaders(parseReaders(manifest.read_by).readers)}`);
288
+ if (guard) log(`read guard for Claude Code: ${guard.action} ${guard.path}`);
289
+ if (hook) log(`Claude Code Stop hook: ${hook.action} (${hook.settings} runs ${hook.hook}, in this record only)`);
290
+ if (promptHook) log(`Claude Code prompt hook: ${promptHook.action} (${promptHook.settings} runs ${promptHook.hook}, which files each message you send)`);
291
+ log(`filing: ${manifest.filing === "everything" ? "everything you say is filed automatically" : manifest.filing === "asked" ? "only when you ask" : "not answered yet; your agent asks the first time you connect"}`);
292
+ log("");
251
293
  // The chosen preset only adds folders to the fresh record, and doctor below reads what it wrote.
252
294
  if (opts.preset && opts.preset !== NONE) {
253
295
  const plan = applyPreset(dir, opts.preset);
@@ -286,6 +328,8 @@ export async function main(argv, io = null) {
286
328
  log(`Or schedule it with launchd on a Mac: evda routine ${key} ${dir} --install`);
287
329
  }
288
330
  log("");
331
+ for (const l of INTRO.split("\n")) log(l);
332
+ log("");
289
333
  log(`Then freewrite: what happened today, what is on your mind, what felt slightly off. Your words go into record/ as you talk. README.md says the rest.`);
290
334
  if (!asked) log(DICTATION);
291
335
  return 0;
@@ -0,0 +1,34 @@
1
+ # Running a model on your own computer
2
+
3
+ A model on your own computer reads your record without it going to any company. It is slower and less capable than the models the AI companies run, and likelier to say something that is not in your record, so check what it cites against the file it names.
4
+
5
+ ## 1. Install a runner
6
+
7
+ Either one works. Both are free and run on a Mac, Windows or Linux.
8
+
9
+ - **Ollama** (<https://ollama.com>). Install it, then in a terminal: `ollama pull llama3.1:8b`. It serves the model on this computer at `http://localhost:11434`.
10
+ - **LM Studio** (<https://lmstudio.ai>). Install it, open the Discover tab, and download a model. It can also start MCP servers itself, which is how it reaches your EVÐA (step 3).
11
+
12
+ A Mac with Apple silicon and 16 GB of memory, or a PC with a graphics card that has 8 GB, runs a model of about 7 or 8 billion parameters. Less memory means a smaller model.
13
+
14
+ ## 2. Say which parts it reads
15
+
16
+ In the circle's `eco.yaml`, or in `evda.json` for the whole record:
17
+
18
+ read_by: local
19
+
20
+ or, naming the model and where it runs:
21
+
22
+ read_by:
23
+ - local: llama3.1:8b
24
+ on: this Mac
25
+
26
+ A part set to `local` is refused to any hosted AI that checks: the evda MCP server refuses it, the Claude Code guard refuses it, and `AGENTS.md` tells every other agent to decline. You can keep the rest on the AI you already use. Run `evda doctor` after, and it prints who reads each part.
27
+
28
+ ## 3. Connect the model to your EVÐA
29
+
30
+ Run `evda connect` to print the command that starts the evda MCP server. Give that command to an app that runs local models and speaks MCP (LM Studio does, in its Program tab under mcp.json), and add one setting so the server knows the caller is local:
31
+
32
+ "env": { "EVDA_READER": "local" }
33
+
34
+ Then ask it what you wrote this week. If it answers from your files, it is reading them on this computer.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code PreToolUse hook: refuse Read, Grep and shell reads of a just-noted entry, or of a
3
+ // file whose circle is read by an AI other than Claude. The logic is lib/guard.mjs; this file is
4
+ // only the entry point a settings.json names. create-evda installs it for Claude owners.
5
+ import { runHook } from "../lib/guard.mjs";
6
+
7
+ runHook().then((code) => process.exit(code), () => process.exit(0));
@@ -0,0 +1,198 @@
1
+ // The backstop (HQ-1385): what the owner said to Claude or ChatGPT that no agent filed, taken from the
2
+ // app's own data export and added to the day it was said.
3
+ //
4
+ // An agent files while the conversation happens (evda_write, the nudge, the Claude Code hooks), and
5
+ // the Claude app was measured not doing it (2026-09-22). The export holds every conversation, so
6
+ // `evda gather <conversations.json> --into <record>` reads it, keeps only the owner's own messages,
7
+ // and adds each one the record does not already hold, under its conversation's date and time. It
8
+ // runs through gather's proposal and apply, so the owner sees the count first, says yes, and
9
+ // `evda gather revert` takes it back byte for byte.
10
+ //
11
+ // Two formats, told apart by shape, never by file name:
12
+ // claude Claude.ai Settings > Privacy > Export data: conversations.json, an array of
13
+ // { uuid, name, created_at, chat_messages: [{ sender: "human"|"assistant", text,
14
+ // content: [{ type: "text", text }], created_at }] }
15
+ // chatgpt ChatGPT Settings > Data controls > Export: conversations.json, an array of
16
+ // { title, create_time, current_node, mapping: { id: { parent, message: { author:
17
+ // { role }, create_time, content: { parts } } } } }, read along the branch the owner
18
+ // last saw (current_node back to the root)
19
+ //
20
+ // Only the owner's messages are read: sender "human", role "user". The model's replies are not
21
+ // their words and are never written.
22
+ //
23
+ // ALREADY FILED. A message is in the record when the normalized text of some section of a day file
24
+ // (lowercase, letters and digits only, one space between words) contains the message's normalized
25
+ // text, and that section's `## HH:MM` falls between WINDOW_BEFORE_MIN before the conversation began
26
+ // and WINDOW_AFTER_MIN after the message was sent. Text in a day file above its first heading counts
27
+ // for the whole day. So a message filed live, under the conversation's section, is found, and a
28
+ // second backfill of the same export adds nothing.
29
+ //
30
+ // A message holding something shaped like a password, key or token is not written, and is counted.
31
+ //
32
+ // EVDA_CHAT_FALSIFY=<mode,...>, for the tests:
33
+ // no-dedupe nothing counts as already filed
34
+ // assistant-too the model's replies are read as the owner's
35
+
36
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
37
+ import { join } from "node:path";
38
+ import { secretShape } from "./secret-shape.mjs";
39
+ import { localDate, localTime, sectionBytes } from "./day-file.mjs";
40
+
41
+ export const CHAT_FORMATS = { claude: "Claude.ai", chatgpt: "ChatGPT" };
42
+ export const WINDOW_BEFORE_MIN = 60;
43
+ export const WINDOW_AFTER_MIN = 12 * 60;
44
+ const TITLE_MAX = 120;
45
+ const DAY_FILE = /^(\d{4}-\d{2}-\d{2})\.md$/;
46
+
47
+ const falsified = (mode) => (process.env.EVDA_CHAT_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
48
+
49
+ export const normalize = (t) => String(t).normalize("NFKC").toLowerCase().replace(/[‘’]/g, "'").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
50
+
51
+ const ms = (v) => {
52
+ if (typeof v === "number" && Number.isFinite(v)) return v < 1e12 ? Math.round(v * 1000) : v;
53
+ if (typeof v === "string" && v) { const t = Date.parse(v); return Number.isFinite(t) ? t : null; }
54
+ return null;
55
+ };
56
+
57
+ // "claude", "chatgpt", or null. `data` is the parsed JSON.
58
+ export function chatFormat(data) {
59
+ if (!Array.isArray(data) || data.length === 0) return null;
60
+ const first = data.find((c) => c && typeof c === "object");
61
+ if (!first) return null;
62
+ if (Array.isArray(first.chat_messages)) return "claude";
63
+ if (first.mapping && typeof first.mapping === "object" && !Array.isArray(first.mapping)) return "chatgpt";
64
+ return null;
65
+ }
66
+
67
+ // Parse bytes as a chat export: { format, data } or null when they are not one.
68
+ export function readChatExport(buf) {
69
+ const head = buf.subarray(0, 64).toString("utf8").trimStart();
70
+ if (!head.startsWith("[")) return null;
71
+ let data;
72
+ try { data = JSON.parse(buf.toString("utf8")); } catch { return null; }
73
+ const format = chatFormat(data);
74
+ return format ? { format, data } : null;
75
+ }
76
+
77
+ const claudeText = (m) => {
78
+ if (typeof m.text === "string" && m.text.trim()) return m.text;
79
+ return Array.isArray(m.content) ? m.content.filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n\n") : "";
80
+ };
81
+
82
+ function chatgptBranch(conv) {
83
+ const map = conv.mapping ?? {};
84
+ const out = [];
85
+ if (conv.current_node && map[conv.current_node]) {
86
+ const seen = new Set();
87
+ for (let id = conv.current_node; id && map[id] && !seen.has(id); id = map[id].parent) { seen.add(id); out.push(map[id]); }
88
+ return out.reverse();
89
+ }
90
+ return Object.values(map).sort((a, b) => (ms(a?.message?.create_time) ?? 0) - (ms(b?.message?.create_time) ?? 0));
91
+ }
92
+
93
+ const chatgptText = (msg) => {
94
+ const parts = msg?.content?.parts;
95
+ if (!Array.isArray(parts)) return typeof msg?.content?.text === "string" ? msg.content.text : "";
96
+ return parts.filter((p) => typeof p === "string").join("\n\n");
97
+ };
98
+
99
+ // The export as conversations of the owner's messages:
100
+ // [{ id, title, started, messages: [{ at, text }] }], each message's `at` in ms. A conversation with
101
+ // no message from the owner is left out.
102
+ export function conversations({ format, data }) {
103
+ const out = [];
104
+ const owner = (role) => role === (format === "claude" ? "human" : "user") || (falsified("assistant-too") && role === "assistant");
105
+ for (const conv of data) {
106
+ if (!conv || typeof conv !== "object") continue;
107
+ const started = ms(format === "claude" ? conv.created_at : conv.create_time);
108
+ const messages = [];
109
+ if (format === "claude") {
110
+ for (const m of conv.chat_messages ?? []) {
111
+ if (!owner(m?.sender)) continue;
112
+ const text = claudeText(m);
113
+ if (text.trim()) messages.push({ at: ms(m.created_at) ?? started, text });
114
+ }
115
+ } else {
116
+ for (const node of chatgptBranch(conv)) {
117
+ const m = node?.message;
118
+ if (!m || !owner(m.author?.role) || m.metadata?.is_visually_hidden_from_conversation) continue;
119
+ const text = chatgptText(m);
120
+ if (text.trim()) messages.push({ at: ms(m.create_time) ?? started, text });
121
+ }
122
+ }
123
+ if (!messages.length) continue;
124
+ const first = started ?? Math.min(...messages.map((m) => m.at ?? Infinity));
125
+ let title = typeof (conv.name ?? conv.title) === "string" ? String(conv.name ?? conv.title).replace(/\s+/g, " ").trim().slice(0, TITLE_MAX) : "";
126
+ if (title && secretShape(title)) title = "";
127
+ out.push({ id: String(conv.uuid ?? conv.conversation_id ?? conv.id ?? out.length), title, started: Number.isFinite(first) ? first : null, messages: messages.map((m) => ({ ...m, at: m.at ?? first })) });
128
+ }
129
+ return out;
130
+ }
131
+
132
+ // The day files' sections, for the already-filed test: [{ from, to, text }] with times in ms and the
133
+ // normalized text. A `## HH:MM` heading starts a section at that minute; text above the first
134
+ // heading spans its whole day.
135
+ export function filedSections(dayDir) {
136
+ const out = [];
137
+ if (!dayDir || !existsSync(dayDir)) return out;
138
+ for (const name of readdirSync(dayDir)) {
139
+ const m = DAY_FILE.exec(name);
140
+ if (!m) continue;
141
+ const [y, mo, d] = m[1].split("-").map(Number);
142
+ const text = readFileSync(join(dayDir, name), "utf8");
143
+ const parts = text.split(/^## (\d{1,2}):(\d{2})\b.*$/m);
144
+ const dayStart = new Date(y, mo - 1, d).getTime();
145
+ out.push({ from: dayStart, to: new Date(y, mo - 1, d + 1).getTime() - 1, text: normalize(parts[0]) });
146
+ for (let i = 1; i < parts.length; i += 3) {
147
+ const at = new Date(y, mo - 1, d, Number(parts[i]), Number(parts[i + 1])).getTime();
148
+ out.push({ from: at, to: at, text: normalize(parts[i + 2] ?? "") });
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ export function isFiled(sections, conv, msg) {
155
+ if (falsified("no-dedupe")) return false;
156
+ const want = normalize(msg.text);
157
+ if (!want) return true;
158
+ const lo = (conv.started ?? msg.at) - WINDOW_BEFORE_MIN * 60_000;
159
+ const hi = msg.at + WINDOW_AFTER_MIN * 60_000;
160
+ return sections.some((s) => s.to >= lo && s.from <= hi && s.text.includes(want));
161
+ }
162
+
163
+ // What a backfill adds: one entry per conversation with messages the record lacks.
164
+ // [{ date, time, title, words: [text], conversation }] and the counts.
165
+ export function backfillPlan(convs, sections, { format }) {
166
+ const adds = [];
167
+ const c = { conversations: convs.length, messages: 0, filed: 0, secret: 0, add: 0 };
168
+ for (const conv of convs) {
169
+ const words = [];
170
+ for (const msg of conv.messages) {
171
+ c.messages += 1;
172
+ if (msg.text.split("\n").some(secretShape)) { c.secret += 1; continue; }
173
+ if (isFiled(sections, conv, msg)) { c.filed += 1; continue; }
174
+ words.push(msg.text);
175
+ }
176
+ if (!words.length) continue;
177
+ c.add += words.length;
178
+ const when = new Date(conv.started ?? conv.messages[0].at);
179
+ adds.push({ date: localDate(when), time: localTime(when), title: conv.title, conversation: conv.id, words, context: contextFor(format, conv.title) });
180
+ }
181
+ return { adds, counts: c };
182
+ }
183
+
184
+ export const contextFor = (format, title) => `from their ${CHAT_FORMATS[format]} export${title ? `, the conversation "${title.replace(/"/g, "'")}"` : ""}; added by evda gather, since it was not filed when it was said`;
185
+
186
+ // The bytes to append to a day file whose text is `current` (null when absent) for these adds.
187
+ export function dayAppend(current, adds) {
188
+ let text = current;
189
+ let out = "";
190
+ for (const a of adds) {
191
+ for (let i = 0; i < a.words.length; i++) {
192
+ const w = sectionBytes({ current: text, date: a.date, time: a.time, words: a.words[i], context: i === 0 ? a.context : null, continuing: i > 0, contextWritten: true });
193
+ out += w.text;
194
+ text = (text ?? "") + w.text;
195
+ }
196
+ }
197
+ return out;
198
+ }