create-evda 0.1.8 → 0.1.10
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 +109 -1
- package/README.md +20 -7
- package/bin/evda.mjs +277 -7
- package/create-evda.mjs +17 -4
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +29 -2
- package/lib/connect.mjs +9 -2
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +173 -15
- package/lib/day-writer.mjs +110 -0
- package/lib/doctor.mjs +45 -3
- package/lib/every-chat.mjs +128 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +176 -26
- package/lib/gather.mjs +119 -17
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +181 -0
- package/lib/manifest.mjs +5 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +42 -4
- package/lib/mcp.mjs +1198 -76
- package/lib/measurement-log.mjs +249 -0
- package/lib/messages.mjs +5 -2
- package/lib/migrate-people.mjs +54 -20
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +74 -0
- package/lib/routines.mjs +54 -4
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +1040 -31
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/watch.mjs +160 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +81 -10
- package/templates/evda-digest.md +5 -1
package/lib/clients.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// How each AI client adds the EVDA MCP server, given the command that starts it. No node: imports,
|
|
2
2
|
// so the EVDA app's webview renders the same text `evda connect` prints (lib/connect.mjs).
|
|
3
3
|
|
|
4
|
+
import { everyChatLine, CHAT_APPS, GENERIC_WHERE } from "./every-chat.mjs";
|
|
5
|
+
|
|
4
6
|
export const SERVER_NAME = "evda";
|
|
5
7
|
|
|
6
8
|
const shellQuote = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
|
|
7
9
|
const tomlString = (s) => JSON.stringify(s);
|
|
8
10
|
|
|
9
|
-
// `desktopConfig` is where claude_desktop_config.json lives on this machine, when known.
|
|
10
|
-
|
|
11
|
+
// `desktopConfig` is where claude_desktop_config.json lives on this machine, when known. `filing` is
|
|
12
|
+
// the owner's answer in evda.json, which picks the form of the every-chat line (HQ-2205).
|
|
13
|
+
export function clientsFor(command, args, { desktopConfig = null, filing = null } = {}) {
|
|
11
14
|
const argv = [command, ...args].map(shellQuote).join(" ");
|
|
12
15
|
return [
|
|
13
16
|
{
|
|
@@ -37,6 +40,17 @@ export function clientsFor(command, args, { desktopConfig = null } = {}) {
|
|
|
37
40
|
text: JSON.stringify({ mcpServers: { [SERVER_NAME]: { command, args } } }, null, 2),
|
|
38
41
|
file: desktopConfig ?? "claude_desktop_config.json",
|
|
39
42
|
},
|
|
43
|
+
{
|
|
44
|
+
// Cowork starts no server from claude_desktop_config.json and reads no folder's hooks; a plugin
|
|
45
|
+
// is the one way in (HQ-2802, lib/plugin.mjs). The same command as the server, with plugin for mcp.
|
|
46
|
+
key: "cowork",
|
|
47
|
+
label: "Claude Cowork",
|
|
48
|
+
steps: [
|
|
49
|
+
"Cowork does not use the desktop config above. Run this once to build a plugin for this record, then in the Claude app open Customize, then Plugins, then Add, then Upload plugin, and choose the zip it names.",
|
|
50
|
+
],
|
|
51
|
+
text: [command, ...args.map((a, i) => (i === args.length - 2 && a === "mcp" ? "plugin" : a))].map(shellQuote).join(" "),
|
|
52
|
+
file: null,
|
|
53
|
+
},
|
|
40
54
|
{
|
|
41
55
|
key: "project",
|
|
42
56
|
label: "One folder only (.mcp.json)",
|
|
@@ -44,5 +58,18 @@ export function clientsFor(command, args, { desktopConfig = null } = {}) {
|
|
|
44
58
|
text: JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "stdio", command, args } } }, null, 2),
|
|
45
59
|
file: ".mcp.json",
|
|
46
60
|
},
|
|
61
|
+
{
|
|
62
|
+
// Not a way to add the server: the one line a chat app needs in its every-chat setting, because
|
|
63
|
+
// a chat that never calls an evda tool is reached by nothing else (lib/every-chat.mjs).
|
|
64
|
+
key: "every-chat",
|
|
65
|
+
label: "Every chat app (so each chat starts as your agent)",
|
|
66
|
+
steps: [
|
|
67
|
+
"Add this line to your chat app's every-chat instructions:",
|
|
68
|
+
...Object.values(CHAT_APPS).map((a) => `${a.label}: ${a.where}`),
|
|
69
|
+
`Any other app: ${GENERIC_WHERE}`,
|
|
70
|
+
],
|
|
71
|
+
text: everyChatLine(filing),
|
|
72
|
+
file: null,
|
|
73
|
+
},
|
|
47
74
|
];
|
|
48
75
|
}
|
package/lib/connect.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { delimiter, dirname, join, resolve } from "node:path";
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { clientsFor, SERVER_NAME } from "./clients.mjs";
|
|
17
|
+
import { filingOf } from "./day-file.mjs";
|
|
17
18
|
|
|
18
19
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
export const PACKAGE_ROOT = resolve(HERE, "..");
|
|
@@ -81,7 +82,7 @@ export function guardStatus(recordDir) {
|
|
|
81
82
|
|
|
82
83
|
// A `node` on PATH that is this same binary, when there is one: Homebrew's /opt/homebrew/bin/node
|
|
83
84
|
// survives an upgrade, and the versioned Cellar path process.execPath reports does not.
|
|
84
|
-
function stableNode(execPath, env) {
|
|
85
|
+
export function stableNode(execPath = process.execPath, env = process.env) {
|
|
85
86
|
let target;
|
|
86
87
|
try { target = realpathSync(execPath); } catch { return execPath; }
|
|
87
88
|
for (const d of String(env.PATH ?? "").split(delimiter)) {
|
|
@@ -101,7 +102,13 @@ export function desktopConfigPath(platform = process.platform, env = process.env
|
|
|
101
102
|
// Every client's setup for one record, as data. `text` fields are what a person copies.
|
|
102
103
|
export function clients(recordDir, opts = {}) {
|
|
103
104
|
const { command, args } = serverCommand(recordDir, opts);
|
|
104
|
-
return clientsFor(command, args, { desktopConfig: desktopConfigPath() });
|
|
105
|
+
return clientsFor(command, args, { desktopConfig: desktopConfigPath(), filing: opts.filing ?? filingIn(recordDir) });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The owner's filing answer, for the form of the every-chat line. A record that cannot be read yet
|
|
109
|
+
// (connect run before create-evda finished) gets the "everything" form, the default setup offers.
|
|
110
|
+
function filingIn(recordDir) {
|
|
111
|
+
try { return filingOf(JSON.parse(readFileSync(join(resolve(recordDir), "evda.json"), "utf8"))); } catch { return null; }
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
export function connectText(recordDir, opts = {}) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Which app is connecting, from initialize's clientInfo.name (HQ-2218). One table names each app once,
|
|
2
|
+
// and both uses read it: clientProfile in lib/mcp.mjs (which sections, how large a part) by the exact
|
|
3
|
+
// names in `names`, and chatAppOf in lib/every-chat.mjs (which every-chat setup words) by `like`.
|
|
4
|
+
// Before this the two were separate tables (HQ-2207, HQ-2205) that agreed only by care.
|
|
5
|
+
// No node: imports, because lib/every-chat.mjs reads this file and lib/clients.mjs, rendered in the
|
|
6
|
+
// EVDA app's webview, reads that one.
|
|
7
|
+
//
|
|
8
|
+
// names: the exact clientInfo.name values, compared trimmed and case-insensitively. claude-code and
|
|
9
|
+
// codex-mcp-client measured 2026-10-05 (a probe server logging clientInfo, driven by `claude -p` and
|
|
10
|
+
// `codex exec`). claude.ai sends Anthropic/ClaudeAI, measured 2026-10-05 in the remote server's log
|
|
11
|
+
// from a fresh web chat; claude-ai stays as the published form. openai-mcp is the name ChatGPT
|
|
12
|
+
// publishes and is unmeasured until Hestia's evda-mcp log holds one (it held none on 2026-10-15).
|
|
13
|
+
//
|
|
14
|
+
// like: a pattern for names nobody has measured yet, read top to bottom, first match wins. Every
|
|
15
|
+
// coding agent comes before the chat apps, because its every-session file is AGENTS.md or
|
|
16
|
+
// CLAUDE.md, which create-evda already writes into the record, so it gets no every-chat words
|
|
17
|
+
// (chat: null). A name no row matches is "other", asked with GENERIC_WHERE, so a new app still gets
|
|
18
|
+
// the line.
|
|
19
|
+
//
|
|
20
|
+
// surface, max, budget: what clientProfile serves an app it knows by name. max null is the smallest
|
|
21
|
+
// documented cap, LAYER_PART_MAX in lib/mcp.mjs; budget null is CHAT_LAYER_BUDGET there.
|
|
22
|
+
// Limits: claude.ai and the desktop app take a tool result of about 150,000 characters
|
|
23
|
+
// (claude.com/docs/connectors/building, "Design within the size and timeout limits"); Claude Code
|
|
24
|
+
// 25,000 tokens (same page, MAX_MCP_OUTPUT_TOKENS); OpenAI publishes no figure for ChatGPT and
|
|
25
|
+
// truncates an over-budget result at a line, so ChatGPT and every unknown app get the smallest
|
|
26
|
+
// cap. Codex documents none either and gets the same.
|
|
27
|
+
// claude.ai's budget is its own (HQ-2327): his chat layer measured 2026-10-07 at about 90,000
|
|
28
|
+
// characters whole (head 5,000, core 28,717, owner sections 56,041), and at the shared 70,000 his
|
|
29
|
+
// "Proactive Context Loading" (20,238, the Deep Work Queue, Weekends & Trips and friction rules)
|
|
30
|
+
// reached claude.ai as one listed heading, fetched 0 times in 21 layer loads. 100,000 serves it
|
|
31
|
+
// whole and leaves 40,000 of the 140,000 cap for what the first call adds after the layer
|
|
32
|
+
// (openChat: replies, recent days, people).
|
|
33
|
+
export const CLAUDE_AI_LAYER_BUDGET = 100_000;
|
|
34
|
+
|
|
35
|
+
export const CONNECTING_APPS = [
|
|
36
|
+
{ app: "Claude Code", names: ["claude-code"], like: /claude[-_ ]?code/i, chat: null, surface: "terminal", max: null, budget: null },
|
|
37
|
+
{ app: "Codex", names: ["codex-mcp-client"], like: /codex/i, chat: null, surface: "terminal", max: null, budget: null },
|
|
38
|
+
// Coding agents nobody has measured. No exact name, so clientProfile treats them as unknown apps.
|
|
39
|
+
{ app: "another coding agent", names: [], like: /cursor|copilot|vscode|windsurf|zed|opencode|cline|aider|gemini[-_ ]?cli|goose|mcp-inspector|inspector/i, chat: null, surface: null, max: null, budget: null },
|
|
40
|
+
{ app: "claude.ai", names: ["claude-ai", "anthropic/claudeai"], like: /claude|anthropic/i, chat: "claude", surface: "chat", max: 140_000, budget: CLAUDE_AI_LAYER_BUDGET },
|
|
41
|
+
{ app: "ChatGPT", names: ["openai-mcp"], like: /openai|chatgpt/i, chat: "chatgpt", surface: "chat", max: null, budget: null },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const clean = (name) => (typeof name === "string" ? name.trim() : "");
|
|
45
|
+
|
|
46
|
+
// The row whose exact names include this one, or null.
|
|
47
|
+
export function appNamed(name) {
|
|
48
|
+
const n = clean(name).toLowerCase();
|
|
49
|
+
return n ? CONNECTING_APPS.find((a) => a.names.includes(n)) ?? null : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The first row whose pattern matches this name, or null.
|
|
53
|
+
export function appLike(name) {
|
|
54
|
+
const n = String(name ?? "").trim();
|
|
55
|
+
return CONNECTING_APPS.find((a) => a.like.test(n)) ?? null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// New tools in a chat app (HQ-2205, the last step). A chat app keeps a connector's tool list and the
|
|
2
|
+
// permission set on each tool, and the permissions a person set before do not cover a tool that came
|
|
3
|
+
// later: a call to it can be refused, or held for approval, without the model being told why. Adding
|
|
4
|
+
// or renaming a tool on this server is exactly that. So the server remembers, per record and per
|
|
5
|
+
// chat app, every tool name it has offered that app, and the first time it offers a name the app has
|
|
6
|
+
// never been offered, the next tool result says so once, in plain words, with where the permissions
|
|
7
|
+
// are set. Then the name joins the remembered list and is never raised again in that app.
|
|
8
|
+
//
|
|
9
|
+
// Only names count. A permission belongs to a tool's name, so a changed description needs nothing,
|
|
10
|
+
// and a tool that left the list (evda_every_chat once the owner answered it) needs nothing either.
|
|
11
|
+
// One that comes back is not new: the app was offered it before.
|
|
12
|
+
//
|
|
13
|
+
// A first connection is told nothing: the person is setting permissions for the first time, and the
|
|
14
|
+
// setup already says where (ALWAYS_ALLOW_LINE in lib/mcp.mjs). It only remembers what was offered.
|
|
15
|
+
//
|
|
16
|
+
// Where it is kept: `<record>/.evda/connector-tools.json`, the record's own state, which sync never
|
|
17
|
+
// sends to another holder (sync-rules EXCLUDE_ROOT_NAMES) and nothing reads as the owner's words. It
|
|
18
|
+
// is bookkeeping about what was offered an app, kept with the record rather than the computer because
|
|
19
|
+
// an app's permissions belong to the owner's account in that app, whichever computer serves it. Lost,
|
|
20
|
+
// it costs one notice: the next connection is taken for a first one and remembers the list again.
|
|
21
|
+
//
|
|
22
|
+
// { "claude": { "tools": ["evda_read", ...], "date": "2026-10-18" } }
|
|
23
|
+
|
|
24
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { CHAT_APPS } from "./every-chat.mjs";
|
|
27
|
+
|
|
28
|
+
export const CONNECTOR_TOOLS_FILE = "connector-tools.json";
|
|
29
|
+
|
|
30
|
+
// Where each app sets a connector's tool permissions. Claude's is the one this package already gives
|
|
31
|
+
// in setup (ALWAYS_ALLOW_LINE); an app whose page has not been read gets the general words, because
|
|
32
|
+
// naming a menu that does not exist would send them hunting for it.
|
|
33
|
+
export const PERMISSIONS_WHERE = {
|
|
34
|
+
claude: "claude.ai Settings, Connectors, EVÐA, tool permissions",
|
|
35
|
+
};
|
|
36
|
+
export const GENERIC_PERMISSIONS_WHERE = "the EVÐA connector's settings in their app, where each tool's permission is set";
|
|
37
|
+
|
|
38
|
+
export const CONNECTOR_TOOLS = join(".evda", CONNECTOR_TOOLS_FILE);
|
|
39
|
+
export function connectorToolsPath(evda) {
|
|
40
|
+
return typeof evda?.dir === "string" && evda.dir ? join(evda.dir, CONNECTOR_TOOLS) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readSeen(path) {
|
|
44
|
+
try {
|
|
45
|
+
const all = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
return all && typeof all === "object" && !Array.isArray(all) ? all : {};
|
|
47
|
+
} catch { return {}; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeSeen(path, all) {
|
|
51
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
52
|
+
const part = `${path}.${process.pid}.tmp`;
|
|
53
|
+
writeFileSync(part, `${JSON.stringify(all, null, 2)}\n`);
|
|
54
|
+
renameSync(part, path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Compare what this app is offered now against every name it was offered before, and remember the
|
|
58
|
+
// union. Returns the names it has never been offered, or [] on a first connection, an unchanged
|
|
59
|
+
// list, or no record to keep the list in.
|
|
60
|
+
export function newToolsFor(evda, app, offered, { now = new Date() } = {}) {
|
|
61
|
+
if (!app) return [];
|
|
62
|
+
const path = connectorToolsPath(evda);
|
|
63
|
+
if (!path) return [];
|
|
64
|
+
const all = readSeen(path);
|
|
65
|
+
const before = Array.isArray(all[app]?.tools) ? all[app].tools.filter((n) => typeof n === "string") : null;
|
|
66
|
+
const names = [...new Set(offered)].sort();
|
|
67
|
+
const fresh = before ? names.filter((n) => !before.includes(n)) : [];
|
|
68
|
+
if (before && !fresh.length) return [];
|
|
69
|
+
all[app] = { tools: [...new Set([...(before ?? []), ...names])].sort(), date: now.toISOString().slice(0, 10) };
|
|
70
|
+
writeSeen(path, all);
|
|
71
|
+
return fresh;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// What the model reads, after the tool's own result, the once an app is offered new tools. `titles`
|
|
75
|
+
// maps a tool name to the title the app shows the person beside its permission.
|
|
76
|
+
export function newToolsNotice(app, names, titles = {}) {
|
|
77
|
+
const label = CHAT_APPS[app]?.label ?? "this app";
|
|
78
|
+
const where = PERMISSIONS_WHERE[app] ?? GENERIC_PERMISSIONS_WHERE;
|
|
79
|
+
const list = names.map((n) => (titles[n] ? `${titles[n]} (${n})` : n)).join(", ");
|
|
80
|
+
return [
|
|
81
|
+
`## New EVÐA tools in ${label}`,
|
|
82
|
+
"",
|
|
83
|
+
`EVÐA has tools ${label} was not offered before: ${list}. A chat app keeps the tool permissions the owner set when they connected, and those do not cover a tool added later, so a call to one of these can be refused or held without saying why. Once, after answering what they asked, tell them in a sentence or two: EVÐA added tools, and to use them they should open ${where} and set the new ones to allow. If a call to one of these tools fails in this chat, tell them this is the likely reason rather than trying it again. This is said once in ${label} and not again.`,
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Where a correction lands (HQ-2325). By default it is one dated line in the operating layer's
|
|
2
|
+
// "## What I have been taught". A record that keeps that list in its own file names it in evda.json:
|
|
3
|
+
//
|
|
4
|
+
// "corrections": "<path to a Markdown file, relative to the record>"
|
|
5
|
+
//
|
|
6
|
+
// and evda_record_correction appends a dated section there instead. The owner's vault keeps its list
|
|
7
|
+
// in a Schema Friction Log that CLAUDE.md names as its "What I have been taught", so without this
|
|
8
|
+
// every correction from a chat app was refused and lost. Doctor reads the same setting through this
|
|
9
|
+
// one function, so the tool and the check cannot disagree on what a valid setting is.
|
|
10
|
+
|
|
11
|
+
import { existsSync, statSync } from "node:fs";
|
|
12
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
13
|
+
import { isSealed } from "./circles.mjs";
|
|
14
|
+
|
|
15
|
+
export const CORRECTIONS_KEY = "corrections";
|
|
16
|
+
|
|
17
|
+
// null when evda.json does not set it. Otherwise { rel, abs, problem }: problem is null when the
|
|
18
|
+
// setting names an existing, unsealed Markdown file inside the record, and a plain sentence when not.
|
|
19
|
+
export function correctionsTarget(evda) {
|
|
20
|
+
const value = evda.manifest?.[CORRECTIONS_KEY];
|
|
21
|
+
if (value === undefined) return null;
|
|
22
|
+
const bad = (problem, rel = String(value)) => ({ rel, abs: null, problem });
|
|
23
|
+
if (typeof value !== "string" || value.trim() === "") return bad(`"${CORRECTIONS_KEY}" must be the path of a Markdown file, relative to the record.`);
|
|
24
|
+
const want = value.trim();
|
|
25
|
+
if (isAbsolute(want)) return bad(`"${CORRECTIONS_KEY}" is ${want}, an absolute path; it names a file relative to the record.`, want);
|
|
26
|
+
const root = resolve(evda.dir);
|
|
27
|
+
const abs = resolve(root, want);
|
|
28
|
+
if (!abs.startsWith(root + sep)) return bad(`"${CORRECTIONS_KEY}" is ${want}, which is outside the record.`, want);
|
|
29
|
+
const rel = relative(root, abs).split("\\").join("/");
|
|
30
|
+
if (!/\.md$/i.test(abs)) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, which is not a Markdown file.` };
|
|
31
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, and there is no file there, so a correction has nowhere to land.` };
|
|
32
|
+
if (isSealed(abs)) return { rel, abs, problem: `"${CORRECTIONS_KEY}" names ${rel}, which is sealed, so no agent writes a correction into it.` };
|
|
33
|
+
return { rel, abs, problem: null };
|
|
34
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// One-time move of Credentials/ files written before HQ-1916 into the Keychain (HQ-2622).
|
|
2
|
+
//
|
|
3
|
+
// HQ-1916 made `evda gather apply` put each credential value into the login Keychain and leave a
|
|
4
|
+
// pointer in the file. Files already sitting in a record's Credentials/ folder, sealed before that
|
|
5
|
+
// shipped (or kept plain because the Keychain write failed then), still hold their values. This
|
|
6
|
+
// walks Credentials/ once and gives each of those files the same treatment: every value goes into
|
|
7
|
+
// the Keychain, and its line keeps its label with the value replaced by the pointer HQ-1916 writes:
|
|
8
|
+
// Password: in your Keychain (EVÐA, wifi.md, password)
|
|
9
|
+
//
|
|
10
|
+
// Safe to run again: a line that already holds a pointer is not a value (extractSecrets skips it),
|
|
11
|
+
// so a migrated file has nothing left to move and is not rewritten.
|
|
12
|
+
// All-or-nothing per file: a value is never removed from a file until every value in that file is
|
|
13
|
+
// in the Keychain, and if any write fails, the items this run added for that file are removed again
|
|
14
|
+
// and the file is left byte for byte as it was. Nothing is lost.
|
|
15
|
+
// A Keychain item already at an account with a different value (another file of the same name, or
|
|
16
|
+
// one the owner made by hand) is never overwritten: the value is filed under the next free label,
|
|
17
|
+
// "password 2", and the pointer names that label. One already holding the same value is reused.
|
|
18
|
+
//
|
|
19
|
+
// The Keychain is reached only through lib/keychain.mjs, so EVDA_KEYCHAIN_FALSIFY and a throwaway
|
|
20
|
+
// keychain path work here exactly as they do for gather apply.
|
|
21
|
+
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, chmodSync, unlinkSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { CREDENTIALS } from "./gather.mjs";
|
|
25
|
+
import { SERVICE, accountFor, addSecret, extractSecrets, keychainAvailable, pointerFor, readSecret, removeSecret } from "./keychain.mjs";
|
|
26
|
+
|
|
27
|
+
// The label a value is filed under: its own, unless the Keychain already holds a different value
|
|
28
|
+
// there or this file already used it, then "<label> 2", "<label> 3", ... until one is free or
|
|
29
|
+
// already holds this exact value. Returns { label, exists }.
|
|
30
|
+
function freeLabel(fileName, label, value, used, keychainPath) {
|
|
31
|
+
for (let n = 1; ; n++) {
|
|
32
|
+
const candidate = n === 1 ? label : `${label} ${n}`;
|
|
33
|
+
if (used.has(candidate)) continue;
|
|
34
|
+
let held;
|
|
35
|
+
try { held = readSecret({ account: accountFor(fileName, candidate), keychainPath }); } catch { return { label: candidate, exists: false }; }
|
|
36
|
+
if (held === value) return { label: candidate, exists: true };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Move one file's values into the Keychain and return its new text, or say why it stays as it is.
|
|
41
|
+
// Returns { status: "moved", text, added, labels } | { status: "unchanged" } | { status: "kept", why }.
|
|
42
|
+
export function migrateText(text, fileName, { keychainPath = null, dry = false } = {}) {
|
|
43
|
+
const secrets = extractSecrets(text);
|
|
44
|
+
if (!secrets.length) return { status: "unchanged" };
|
|
45
|
+
if (dry) return { status: "moved", text: null, added: [], labels: secrets.map((s) => s.label), dry: true };
|
|
46
|
+
const used = new Set();
|
|
47
|
+
const added = [];
|
|
48
|
+
const labels = [];
|
|
49
|
+
try {
|
|
50
|
+
for (const s of secrets) {
|
|
51
|
+
const { label, exists } = freeLabel(fileName, s.label, s.value, used, keychainPath);
|
|
52
|
+
used.add(label);
|
|
53
|
+
labels.push(label);
|
|
54
|
+
if (exists) continue;
|
|
55
|
+
const account = accountFor(fileName, label);
|
|
56
|
+
addSecret({ account, value: s.value, keychainPath });
|
|
57
|
+
added.push(account);
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
for (const account of added) removeSecret({ account, keychainPath });
|
|
61
|
+
return { status: "kept", why: `the Keychain write failed (${String(e.message ?? e).split("\n")[0]})` };
|
|
62
|
+
}
|
|
63
|
+
const lines = text.split("\n");
|
|
64
|
+
secrets.forEach((s, i) => { lines[s.index] = s.whole ? pointerFor(fileName, labels[i]) : `${s.prefix}${pointerFor(fileName, labels[i])}`; });
|
|
65
|
+
return { status: "moved", text: lines.join("\n"), added, labels };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Every file directly in <root>/Credentials/, migrated. Never throws for one file's failure.
|
|
69
|
+
// Returns { folder, files: [{ name, status, why?, count? }], moved, unchanged, kept, skipped }.
|
|
70
|
+
export function migrateCredentials(root, { keychainPath = null, dry = false } = {}) {
|
|
71
|
+
const folder = join(root, CREDENTIALS);
|
|
72
|
+
const r = { folder, exists: existsSync(folder), available: true, dry, files: [], moved: 0, unchanged: 0, kept: 0, skipped: 0 };
|
|
73
|
+
if (!r.exists) return r;
|
|
74
|
+
const names = readdirSync(folder).filter((n) => !n.startsWith(".")).sort();
|
|
75
|
+
r.available = keychainAvailable();
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
const abs = join(folder, name);
|
|
78
|
+
const st = statSync(abs);
|
|
79
|
+
if (!st.isFile() || !/\.(md|txt)$/i.test(name)) { r.skipped += 1; r.files.push({ name, status: "skipped", why: st.isFile() ? "not a text note" : "a folder; only files directly in Credentials/ are read" }); continue; }
|
|
80
|
+
const text = readFileSync(abs, "utf8");
|
|
81
|
+
if (!extractSecrets(text).length) { r.unchanged += 1; r.files.push({ name, status: "unchanged" }); continue; }
|
|
82
|
+
if (!r.available) { r.kept += 1; r.files.push({ name, status: "kept", why: "not on macOS, or the Keychain is unavailable" }); continue; }
|
|
83
|
+
const m = migrateText(text, name, { keychainPath, dry });
|
|
84
|
+
if (m.status === "kept") { r.kept += 1; r.files.push({ name, status: "kept", why: m.why }); continue; }
|
|
85
|
+
if (!dry) {
|
|
86
|
+
// Written beside the original and renamed over it, so the file is never half written.
|
|
87
|
+
const tmp = join(folder, `.${name}.hq2622-${process.pid}.tmp`);
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(tmp, m.text, { mode: st.mode & 0o7777 });
|
|
90
|
+
chmodSync(tmp, st.mode & 0o7777);
|
|
91
|
+
renameSync(tmp, abs);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
try { unlinkSync(tmp); } catch { /* never made */ }
|
|
94
|
+
for (const account of m.added) removeSecret({ account, keychainPath });
|
|
95
|
+
r.kept += 1;
|
|
96
|
+
r.files.push({ name, status: "kept", why: `the file could not be rewritten (${String(e.message ?? e).split("\n")[0]})` });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
r.moved += 1;
|
|
101
|
+
r.files.push({ name, status: "moved", count: m.labels.length });
|
|
102
|
+
}
|
|
103
|
+
return r;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// What `evda secret migrate` prints, ending in one CHECKED or SKIPPED line.
|
|
107
|
+
export function migrateLines(r) {
|
|
108
|
+
if (!r.exists) return [`SKIPPED Credentials: ${r.folder} does not exist, so there is nothing to move`];
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const f of r.files) {
|
|
111
|
+
if (f.status === "moved") out.push(` ${r.dry ? "would move" : "moved"} ${f.name}: ${f.count} value(s) ${r.dry ? "would go" : "went"} to the Keychain (${SERVICE}); the file holds a pointer, not the value`);
|
|
112
|
+
else if (f.status === "kept") out.push(` kept ${f.name}: the value stays in the sealed file (${f.why})`);
|
|
113
|
+
else if (f.status === "skipped") out.push(` skipped ${f.name}: ${f.why}`);
|
|
114
|
+
else out.push(` done ${f.name}: no value left in the file`);
|
|
115
|
+
}
|
|
116
|
+
const n = r.files.length - r.skipped;
|
|
117
|
+
if (!n) out.push(`SKIPPED Credentials: ${r.folder} holds no credential file`);
|
|
118
|
+
else out.push(`CHECKED ${n} credential file(s) ${r.moved} ${r.dry ? "would move" : "moved"} to the Keychain, ${r.unchanged} already holding no value, ${r.kept} kept in the sealed file`);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
package/lib/day-file.mjs
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
// local time) with its one line of context; every later write in the same conversation appends
|
|
7
7
|
// under it, as a new paragraph, with no heading. A conversation is whatever the caller passes as
|
|
8
8
|
// `session`: one MCP session (a stdio process, or one Mcp-Session-Id over HTTP), or one Claude Code
|
|
9
|
-
// session id in the prompt hook. When
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// session id in the prompt hook. When another conversation wrote a section below this one since,
|
|
10
|
+
// the next write still goes into this conversation's own section, above theirs (HQ-2330), so words
|
|
11
|
+
// never land under a heading that is not theirs and one conversation stays one section. Only a hand
|
|
12
|
+
// edit of the section itself makes the next write open a new heading.
|
|
12
13
|
//
|
|
13
14
|
// FILING. evda.json `filing` is the owner's answer to the one setup question: "everything" (file
|
|
14
15
|
// every message they send, without being asked) or "asked" (only when they ask). Absent means they
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
// EVDA_CHAT_FALSIFY=heading-per-write opens a new heading on every write, for the test that shows
|
|
23
24
|
// one conversation is one section.
|
|
24
25
|
|
|
25
|
-
import { appendFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
26
|
+
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
26
27
|
import { dirname, join, resolve } from "node:path";
|
|
27
28
|
|
|
28
29
|
export const FILING_MODES = ["everything", "asked"];
|
|
@@ -62,6 +63,48 @@ export const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
|
62
63
|
|
|
63
64
|
export const contextLine = (context) => `*${CONTEXT_LABEL} ${String(context).trim().replace(/\*/g, "")}*`;
|
|
64
65
|
|
|
66
|
+
// NAMED ENTRIES (HQ-2799). A write tool's answer names the existing entries the filed words bear on
|
|
67
|
+
// (lib/touches.mjs, HQ-2631), and a conversation named some and added to none of them is asked once.
|
|
68
|
+
// The MCP server keeps what it named per chat; the Stop hook reads the same lines back out of the
|
|
69
|
+
// Claude Code transcript. Both build and parse them here, so the line one writes is the line the
|
|
70
|
+
// other reads, and nothing is stored in the owner's files: the transcript and the chat are the state.
|
|
71
|
+
export const RELATED_LEAD = "These existing entries bear on what you just filed: ";
|
|
72
|
+
export const TICKETS_LEAD = "These open tickets on the board bear on it: ";
|
|
73
|
+
export const ENTRIES_ASK_LEAD = "This conversation was pointed at existing entries and added to none of them:";
|
|
74
|
+
|
|
75
|
+
export const relatedLine = (related) => `${RELATED_LEAD}${related.map((r) => `${r.path} (named as "${r.name}")`).join("; ")}.`;
|
|
76
|
+
|
|
77
|
+
export const ticketsLine = (tickets) => `${TICKETS_LEAD}${tickets.map((k) => `${k.id} "${k.title}" (named as "${k.named}")`).join("; ")}.`;
|
|
78
|
+
|
|
79
|
+
// The open tickets a tool result's text names (HQ-2798's line), as ["HQ-<n>", ...].
|
|
80
|
+
export function ticketsNamedIn(text) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
83
|
+
if (!line.startsWith(TICKETS_LEAD)) continue;
|
|
84
|
+
for (const m of line.slice(TICKETS_LEAD.length).matchAll(/(?:^|; )(HQ-\d+) "/g)) if (!out.includes(m[1])) out.push(m[1]);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The entries a tool result's text names, as [{ path, name }], from its RELATED_LEAD line.
|
|
90
|
+
export function namedIn(text) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
93
|
+
if (!line.startsWith(RELATED_LEAD)) continue;
|
|
94
|
+
const body = line.slice(RELATED_LEAD.length).replace(/\.$/, "");
|
|
95
|
+
for (const m of body.matchAll(/(?:^|; )(.+?) \(named as "([^"]*)"\)/g)) out.push({ path: m[1], name: m[2] });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Whether a text already carries the ask, so neither surface asks a second time.
|
|
101
|
+
export const askedIn = (text) => String(text ?? "").includes(ENTRIES_ASK_LEAD);
|
|
102
|
+
|
|
103
|
+
// The ask, naming the entries and tickets. `how` says how this surface adds to an entry.
|
|
104
|
+
export function entriesAsk(paths, how = "evda_append; a person's page with evda_record_person_note", tickets = []) {
|
|
105
|
+
return `${ENTRIES_ASK_LEAD} ${[...paths, ...tickets].join(", ")}. If what the owner said adds something to one of them, add a dated section to it now (${how})${tickets.length ? ", or update the ticket on the board" : ""}. If nothing new applies, adding nothing is fine. This is asked once.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
// The bytes one write appends to a day file whose current text is `current` (null when it does not
|
|
66
109
|
// exist yet). `continuing` is true when this conversation's section is the last thing in the file.
|
|
67
110
|
// Returns { text, opened }.
|
|
@@ -77,17 +120,132 @@ export function sectionBytes({ current, date, time, words, context = null, conti
|
|
|
77
120
|
return { text: `${lead}${head}\n## ${time}\n\n${ctx}${body}\n`, opened: true };
|
|
78
121
|
}
|
|
79
122
|
|
|
123
|
+
// EVDA_DAY_LOCK_PATH (HQ-2165): when set, every append below takes this lock first, with the same
|
|
124
|
+
// protocol Hestia's own journal-append.mjs takes through /usr/bin/shlock on the same path: the file
|
|
125
|
+
// is created exclusively and holds the holder's pid and a newline (shlock's own format, so each side
|
|
126
|
+
// sees the other's lock as held), it is released by deleting it, and a lock older than 120s is
|
|
127
|
+
// broken (a journal append is sub-second, so anything older is a dead process, not contention).
|
|
128
|
+
// Done in node rather than by running shlock, which exists only on macOS: on Linux or Windows every
|
|
129
|
+
// write with the lock set would wait 8s and fail.
|
|
130
|
+
//
|
|
131
|
+
// This exists because two processes on ONE host -- Hestia's passes (journal-append.mjs, writing the
|
|
132
|
+
// pointer line and an agent's record with a read-the-whole-file-then-writeFileSync-the-whole-file
|
|
133
|
+
// step) and `evda mcp`'s own evda_write (pure appendFileSync here) -- both touch the same local file
|
|
134
|
+
// with no shared lock today. appendFileSync alone is atomic, so two appends never corrupt each
|
|
135
|
+
// other; the loss happens when a read-modify-write like journal-append.mjs's runs with a stale read
|
|
136
|
+
// that predates this function's append, and then overwrites the whole file including the words this
|
|
137
|
+
// function just added. Hestia's evda-mcp-run.sh sets this to the same ~/.hestia/journal.lock its
|
|
138
|
+
// passes already use, so the two processes serialize against each other.
|
|
139
|
+
//
|
|
140
|
+
// NOT a fix for two different HOSTS: a lock file inside the iCloud-synced vault cannot coordinate
|
|
141
|
+
// two machines, because acquiring it is itself a write subject to the exact same sync delay as the
|
|
142
|
+
// content it is meant to protect. That class has no file-level fix; see HQ-2165 and
|
|
143
|
+
// test/single-writer.test.mjs. This lock only ever runs on one host.
|
|
144
|
+
function takeLock(lockPath) {
|
|
145
|
+
let fd;
|
|
146
|
+
try { fd = openSync(lockPath, "wx"); } catch (e) { if (e.code === "EEXIST") return false; throw e; }
|
|
147
|
+
try { writeSync(fd, `${process.pid}\n`); } finally { closeSync(fd); }
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
152
|
+
|
|
153
|
+
function withDayLock(fn) {
|
|
154
|
+
const lockPath = process.env.EVDA_DAY_LOCK_PATH;
|
|
155
|
+
if (!lockPath) return fn();
|
|
156
|
+
const start = Date.now();
|
|
157
|
+
while (!takeLock(lockPath)) {
|
|
158
|
+
let age = 0;
|
|
159
|
+
try { age = Date.now() - statSync(lockPath).mtimeMs; } catch { continue; /* released between the two calls */ }
|
|
160
|
+
if (age > 120_000) {
|
|
161
|
+
try { unlinkSync(lockPath); } catch { /* raced another breaker */ }
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (Date.now() - start > 8000) throw new Error(`evda day-file: could not acquire ${lockPath} within 8s`);
|
|
165
|
+
pause(100);
|
|
166
|
+
}
|
|
167
|
+
try { return fn(); } finally { try { unlinkSync(lockPath); } catch { /* already gone */ } }
|
|
168
|
+
}
|
|
169
|
+
|
|
80
170
|
// Append `words` to the day file at `abs`. `session` is the conversation's mutable state ({} to
|
|
81
|
-
// start one, null for a write that belongs to no conversation, which always opens a heading)
|
|
82
|
-
//
|
|
83
|
-
//
|
|
171
|
+
// start one, null for a write that belongs to no conversation, which always opens a heading).
|
|
172
|
+
//
|
|
173
|
+
// HQ-2330: the conversation keeps its section, one per file, in `session.sections[abs]`: the
|
|
174
|
+
// section's exact text as this conversation last wrote it (`tail`, from its heading to its last
|
|
175
|
+
// words) and whether it carries a context line. A later write finds that text in the file and goes
|
|
176
|
+
// straight after it: at the end of the file when nothing came after (a plain append, as before),
|
|
177
|
+
// or above whatever another writer added below it since (a read-modify-write under the same lock).
|
|
178
|
+
// Keyed by file, so the agent log and the owner's day each keep their own section in one chat
|
|
179
|
+
// (measured 2026-10-07: a Claude Code session's evda_write_agent_log between two evda_writes left
|
|
180
|
+
// the next write a bare `## 20:41`, and the agent log a bare heading per entry). When the section's
|
|
181
|
+
// text is no longer in the file (edited by hand), the write opens a new heading, and that heading
|
|
182
|
+
// repeats the conversation's context line, so a continuing conversation never has a bare heading.
|
|
183
|
+
// Returns { opened, bytes, inserted }.
|
|
84
184
|
export function appendToDay(abs, { now = new Date(), words, context = null, session = null }) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
185
|
+
return withDayLock(() => {
|
|
186
|
+
const exists = existsSync(abs);
|
|
187
|
+
const current = exists ? readFileSync(abs, "utf8") : null;
|
|
188
|
+
const mine = session ? sectionOf(session, abs) : null;
|
|
189
|
+
const at = mine && current !== null && !falsified("heading-per-write") ? current.lastIndexOf(mine.tail) : -1;
|
|
190
|
+
if (at >= 0) {
|
|
191
|
+
const body = String(words).replace(/\n+$/, "");
|
|
192
|
+
const ctx = context && !mine.context ? `${contextLine(context)}\n\n` : "";
|
|
193
|
+
const add = `\n${ctx}${body}\n`;
|
|
194
|
+
const end = at + mine.tail.length;
|
|
195
|
+
if (end === current.length) appendFileSync(abs, add);
|
|
196
|
+
else writeFileSync(abs, current.slice(0, end) + add + current.slice(end));
|
|
197
|
+
session.sections[abs] = { tail: mine.tail + add, context: mine.context || Boolean(ctx), line: mine.line ?? (context ? String(context).trim() : null) };
|
|
198
|
+
return { opened: false, bytes: Buffer.byteLength(add), inserted: end !== current.length };
|
|
199
|
+
}
|
|
200
|
+
// A new section. A conversation that already had one here and lost it keeps its context line.
|
|
201
|
+
const line = context ?? mine?.line ?? null;
|
|
202
|
+
const w = sectionBytes({ current, date: localDate(now), time: localTime(now), words, context: line, continuing: false });
|
|
203
|
+
appendFileSync(abs, w.text);
|
|
204
|
+
if (session) {
|
|
205
|
+
const start = w.text.indexOf("\n## ");
|
|
206
|
+
session.sections ??= {};
|
|
207
|
+
session.sections[abs] = { tail: w.text.slice(start + 1), context: Boolean(line), line: line === null ? null : String(line).trim() };
|
|
208
|
+
}
|
|
209
|
+
return { opened: true, bytes: Buffer.byteLength(w.text), inserted: false };
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// This conversation's section in the file at `abs`, or null. A session saved before HQ-2330 kept
|
|
214
|
+
// one `section` by size, not text; it is read as no section, so its next write opens a heading.
|
|
215
|
+
function sectionOf(session, abs) {
|
|
216
|
+
const s = session.sections?.[abs];
|
|
217
|
+
return s && typeof s.tail === "string" && s.tail ? s : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The one pointer line under `heading` in the day file at `abs` (HQ-2306): the line in the owner's
|
|
221
|
+
// day that points at an agent's own log for that day. Exactly one line starting with `prefix` lives
|
|
222
|
+
// in that section; this replaces it in place, inserts it under the heading, appends the heading
|
|
223
|
+
// after everything else, or creates the day, and touches no other byte. Under the same lock as
|
|
224
|
+
// appendToDay, because this is a read-modify-write and a stale read would drop an append.
|
|
225
|
+
// Returns "created", "added", "inserted" or "refreshed".
|
|
226
|
+
export function setPointerLine(abs, { date, heading, prefix, line }) {
|
|
227
|
+
return withDayLock(() => {
|
|
228
|
+
const existing = existsSync(abs) ? readFileSync(abs, "utf8") : "";
|
|
229
|
+
if (existing === "") {
|
|
230
|
+
writeFileSync(abs, `# ${date}\n\n${heading}\n\n${line}\n`);
|
|
231
|
+
return "created";
|
|
232
|
+
}
|
|
233
|
+
const lines = existing.split("\n");
|
|
234
|
+
const at = lines.findIndex((l) => l.trim() === heading);
|
|
235
|
+
if (at === -1) {
|
|
236
|
+
appendFileSync(abs, `${existing.endsWith("\n") ? "" : "\n"}\n${heading}\n\n${line}\n`);
|
|
237
|
+
return "added";
|
|
238
|
+
}
|
|
239
|
+
let end = lines.length;
|
|
240
|
+
for (let i = at + 1; i < lines.length; i++) if (/^#{1,6} /.test(lines[i])) { end = i; break; }
|
|
241
|
+
const idx = lines.slice(at + 1, end).findIndex((l) => l.startsWith(prefix));
|
|
242
|
+
if (idx === -1) {
|
|
243
|
+
lines.splice(at + 1, 0, "", line);
|
|
244
|
+
writeFileSync(abs, lines.join("\n"));
|
|
245
|
+
return "inserted";
|
|
246
|
+
}
|
|
247
|
+
lines[at + 1 + idx] = line;
|
|
248
|
+
writeFileSync(abs, lines.join("\n"));
|
|
249
|
+
return "refreshed";
|
|
250
|
+
});
|
|
93
251
|
}
|