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/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 {
|
|
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
|
-
|
|
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}
|
|
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
|
+
}
|