create-evda 0.1.9 → 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 +72 -0
- package/README.md +12 -5
- package/bin/evda.mjs +122 -5
- package/create-evda.mjs +11 -3
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/clients.mjs +11 -0
- package/lib/connect.mjs +1 -1
- 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 +121 -12
- package/lib/day-writer.mjs +8 -1
- package/lib/doctor.mjs +32 -3
- package/lib/every-chat.mjs +7 -11
- package/lib/gather-apply.mjs +133 -24
- package/lib/gather.mjs +116 -16
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +38 -1
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-http.mjs +19 -1
- package/lib/mcp.mjs +358 -56
- package/lib/measurement-log.mjs +3 -0
- package/lib/migrate-people.mjs +54 -20
- 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/record.mjs +54 -0
- package/lib/routines.mjs +13 -2
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +669 -59
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/templates/AGENTS.md +56 -6
- package/templates/evda-digest.md +2 -0
package/lib/tools.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Three rules hold for every write, and each has a test in test/tools.test.mjs:
|
|
8
8
|
// 1. Append only. A write adds bytes to the end of a file or creates a new one. No tool takes a
|
|
9
|
-
// path to write to, and none replaces what is there.
|
|
9
|
+
// path to write to, and none replaces what is there. The one exception is evda_replace
|
|
10
|
+
// (HQ-2066), the owner's own edit from their phone: it replaces a note's whole text only when
|
|
11
|
+
// the note is byte for byte what they read, and is offered to no chat app.
|
|
10
12
|
// 2. Nothing secret-shaped is written. The words are run through the same check that guards a
|
|
11
13
|
// reference (lib/references.mjs secretShape) and a hit refuses the whole write.
|
|
12
14
|
// 3. Every write says where it landed, as a path relative to the record.
|
|
@@ -25,23 +27,28 @@
|
|
|
25
27
|
// making the circle, and granting anyone anything, stays the owner's explicit yes through
|
|
26
28
|
// `evda grant` or their own hands.
|
|
27
29
|
|
|
28
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
29
31
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
32
|
+
import { spawnSync } from "node:child_process";
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { hostname } from "node:os";
|
|
30
35
|
import { resolveRole, rolesForLayer, agentsFile } from "./roles.mjs";
|
|
31
|
-
import { mdFiles, fileDate, headings } from "./record.mjs";
|
|
36
|
+
import { mdFiles, fileDate, headings, sectionDigest, headingDate } from "./record.mjs";
|
|
32
37
|
import { splitFence, FENCE_OPEN } from "./agents.mjs";
|
|
33
38
|
import { datalessAmong } from "./sync-rules.mjs";
|
|
34
|
-
import { readCircles, isSealed, frontmatterOf } from "./circles.mjs";
|
|
39
|
+
import { readCircles, isSealed, frontmatterOf, markdownFiles } from "./circles.mjs";
|
|
35
40
|
import { secretShape } from "./references.mjs";
|
|
36
41
|
import { doctor } from "./doctor.mjs";
|
|
37
42
|
import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY, isSealedFile, isNotedFile } from "./readers.mjs";
|
|
38
43
|
import { EvdaError } from "./errors.mjs";
|
|
39
|
-
import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
|
|
40
|
-
import { writeMessage, unseenReplies } from "./messages.mjs";
|
|
44
|
+
import { dayRole, appendToDay, setPointerLine, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
|
|
45
|
+
import { writeMessage, unseenReplies, agentName } from "./messages.mjs";
|
|
41
46
|
import { acknowledge as writeAcknowledgement, readAcknowledged, acknowledgementsFor } from "./acknowledge.mjs";
|
|
42
47
|
import { openEvda, holderOf } from "./manifest.mjs";
|
|
43
48
|
import { toYaml } from "./yaml.mjs";
|
|
44
49
|
import { readEntry } from "./kinds.mjs";
|
|
50
|
+
import { correctionsTarget } from "./corrections.mjs";
|
|
51
|
+
import { slug } from "./migrate-people.mjs";
|
|
45
52
|
|
|
46
53
|
export { localDate, localTime, CONTEXT_LABEL };
|
|
47
54
|
|
|
@@ -158,13 +165,13 @@ export function coreBeside(evda, agents, agentsText) {
|
|
|
158
165
|
// 2. The last `days` days of the record, newest first, capped in size. A file is in the window by
|
|
159
166
|
// its date (filename, then a date or created key), the same rule `evda moment` uses.
|
|
160
167
|
//
|
|
161
|
-
// Returns each file's headings,
|
|
162
|
-
// (HQ-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
+
// Returns each file's headings and, per section, its context line and the opening of the owner's
|
|
169
|
+
// words (HQ-2324), not the whole file (HQ-2163). HQ-2227 had cut it to headings only, after a
|
|
170
|
+
// claude.ai question about deadlines got a relationship capture through a first line; and then a
|
|
171
|
+
// chat started knowing only timestamps (most day-file headings are times), so on 2026-10-07 it
|
|
172
|
+
// missed who a question was about. His call that day: approving the connector with training off
|
|
173
|
+
// settles privacy, so a chat reads the record as the terminal does. Noted and sealed entries still
|
|
174
|
+
// never appear (mayRead and aiView below). The whole text of one file is `evda_read`'s job, by path. `full: true` is
|
|
168
175
|
// not part of the tool's args (onlyKeys refuses it there); it is an internal option for callers in
|
|
169
176
|
// this process, such as the evda://today resource below, that still need one file's whole text.
|
|
170
177
|
//
|
|
@@ -196,7 +203,7 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
|
|
|
196
203
|
const acknowledged = acknowledgementsFor(path, marks);
|
|
197
204
|
files.push(full
|
|
198
205
|
? { date, path, text: shown.text, acknowledged }
|
|
199
|
-
: { date, path, headings: headings(shown.text), acknowledged });
|
|
206
|
+
: { date, path, headings: headings(shown.text), sections: sectionDigest(shown.text), acknowledged });
|
|
200
207
|
}
|
|
201
208
|
files.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : b.date.localeCompare(a.date)));
|
|
202
209
|
let used = 0;
|
|
@@ -219,6 +226,40 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
|
|
|
219
226
|
};
|
|
220
227
|
}
|
|
221
228
|
|
|
229
|
+
// 2'. The people whose pages gained a dated section in the last `days` days (HQ-2324), newest
|
|
230
|
+
// first: the page's name and path, and those sections with their context and opening. A section is
|
|
231
|
+
// dated by the date its heading names, the way the owner's people pages are kept ("## 2026-10-02,
|
|
232
|
+
// morning: ..."). Pages a reader may not read, and sealed sections, never appear.
|
|
233
|
+
export function recentPeople(evda, { days = 7, now = new Date(), caller = callerFrom(), perPerson = 6 } = {}) {
|
|
234
|
+
const r = resolveRole(evda, "record/people");
|
|
235
|
+
if (!r.mapped || !r.exists || !r.isDir) return { from: null, to: null, people: [] };
|
|
236
|
+
const start = new Date(now);
|
|
237
|
+
start.setDate(start.getDate() - (days - 1));
|
|
238
|
+
const from = localDate(start);
|
|
239
|
+
const to = localDate(now);
|
|
240
|
+
const ctx = readContext(evda);
|
|
241
|
+
const people = [];
|
|
242
|
+
for (const abs of mdFiles(r.abs)) {
|
|
243
|
+
if (!mayRead(ctx, abs, caller).ok) continue;
|
|
244
|
+
const shown = aiView(readFileSync(abs, "utf8"));
|
|
245
|
+
const grew = sectionDigest(shown.text)
|
|
246
|
+
.map((s) => ({ ...s, date: headingDate(s.heading) }))
|
|
247
|
+
.filter((s) => s.date && s.date >= from && s.date <= to)
|
|
248
|
+
.sort((a, b) => b.date.localeCompare(a.date));
|
|
249
|
+
if (!grew.length) continue;
|
|
250
|
+
const name = /^name:\s*["']?(.+?)["']?\s*$/m.exec(frontmatterText(shown.text))?.[1] ?? abs.split(sep).pop().replace(/\.md$/, "");
|
|
251
|
+
people.push({ name, path: rel(evda, abs), latest: grew[0].date, sections: grew.slice(0, perPerson), more: Math.max(0, grew.length - perPerson) });
|
|
252
|
+
}
|
|
253
|
+
people.sort((a, b) => (a.latest === b.latest ? a.name.localeCompare(b.name) : b.latest.localeCompare(a.latest)));
|
|
254
|
+
return { from, to, people };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function frontmatterText(text) {
|
|
258
|
+
if (!text.startsWith("---")) return "";
|
|
259
|
+
const end = text.indexOf("\n---", 3);
|
|
260
|
+
return end === -1 ? "" : text.slice(3, end);
|
|
261
|
+
}
|
|
262
|
+
|
|
222
263
|
// 2a. Record that this reader acted on one weighty entry, so no other reader acts on it again
|
|
223
264
|
// (HQ-2060). The first reader to reach it writes the mark; a later one gets first: false and nothing
|
|
224
265
|
// is written. Weight is the reader's judgment; this only remembers that one of them already did it.
|
|
@@ -282,10 +323,52 @@ export function write(evda, args = {}, { now = new Date(), session = null, toolN
|
|
|
282
323
|
}
|
|
283
324
|
const abs = join(evda.dir, todayPath(evda, { now }));
|
|
284
325
|
mkdirSync(dirname(abs), { recursive: true });
|
|
326
|
+
// The owner's day only: evda_write, and the tools that file the owner's message along with them.
|
|
327
|
+
// The agent log is the agent's own record, which nothing drains from an outbox.
|
|
328
|
+
if (toolName !== "evda_write_agent_log") {
|
|
329
|
+
const held = dayWriteHeld(abs);
|
|
330
|
+
if (held !== null) {
|
|
331
|
+
const q = queueToOutbox(abs, { now, words, context });
|
|
332
|
+
return { path: rel(evda, q), bytes: Buffer.byteLength(words), queued: true, held };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
285
335
|
const w = appendToDay(abs, { now, words, context, session });
|
|
286
336
|
return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
|
|
287
337
|
}
|
|
288
338
|
|
|
339
|
+
// EVDA_DAY_WRITE_GATE (HQ-2596): a command the writing machine asks before an evda_write touches
|
|
340
|
+
// the owner's day, run as `<gate> --file <abs>`; exit 0 writes, anything else holds. Hestia sets it
|
|
341
|
+
// to its own check (not within minutes of a boot, iCloud says its copy is current), because on
|
|
342
|
+
// 2026-10-14 it came back from an outage and appended to its pre-outage copy within two minutes,
|
|
343
|
+
// and the sync kept that copy over sections written meanwhile. Unset, every write goes ahead.
|
|
344
|
+
// Returns null to write, or the gate's one-line reason to hold. A gate that cannot run holds:
|
|
345
|
+
// a queued write waits, a write onto a stale copy is lost.
|
|
346
|
+
export const DAY_WRITE_GATE_ENV = "EVDA_DAY_WRITE_GATE";
|
|
347
|
+
function dayWriteHeld(abs) {
|
|
348
|
+
const gate = (process.env[DAY_WRITE_GATE_ENV] ?? "").trim();
|
|
349
|
+
if (!gate) return null;
|
|
350
|
+
const r = spawnSync(gate, ["--file", abs], { encoding: "utf8", timeout: 60_000 });
|
|
351
|
+
if (r.status === 0) return null;
|
|
352
|
+
const said = `${r.stdout ?? ""}\n${r.stderr ?? ""}`.trim().split("\n").filter(Boolean).pop();
|
|
353
|
+
return said || (r.error ? `the gate did not run (${r.error.code ?? r.error.message})` : `the gate exited ${r.status ?? r.signal}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Queue held words where the writer files them later (HQ-2596): one new JSON file per write in the
|
|
357
|
+
// daily folder's .outbox/, {tool, words, context, at, host}, named `<UTC timestamp>-<host>-evda_write.json`
|
|
358
|
+
// (the shape the day-writer guard's refusal names, lib/day-writer.mjs OUTBOX_DIR). One file per write,
|
|
359
|
+
// so no two writers ever share one and no sync can drop either. Returns the file's absolute path.
|
|
360
|
+
function queueToOutbox(abs, { now, words, context }) {
|
|
361
|
+
const box = join(dirname(abs), ".outbox");
|
|
362
|
+
mkdirSync(box, { recursive: true });
|
|
363
|
+
const at = now.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
364
|
+
const host = hostname().split(".")[0] || "host";
|
|
365
|
+
const stamp = at.replace(/[-:]/g, "").replace(/^(\d{8})T/, (_, d) => `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6)}T`);
|
|
366
|
+
let file = join(box, `${stamp}-${host}-evda_write.json`);
|
|
367
|
+
for (let n = 2; existsSync(file); n++) file = join(box, `${stamp}-${host}-evda_write-${n}.json`);
|
|
368
|
+
writeFileSync(file, JSON.stringify({ tool: "evda_write", words: String(words), context: context ?? "", at, host }, null, 1), { flag: "wx" });
|
|
369
|
+
return file;
|
|
370
|
+
}
|
|
371
|
+
|
|
289
372
|
// 3b. What `evda mcp`'s evda_write tool actually calls (HQ-2165). `write` above already appended
|
|
290
373
|
// the words; this reads them back after a short real delay and refuses to report success if they
|
|
291
374
|
// are gone, so a caller that overwrote the file in that gap is caught instead of the agent telling
|
|
@@ -303,6 +386,7 @@ const VERIFY_DELAY_MS = 400;
|
|
|
303
386
|
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
304
387
|
export async function writeVerified(evda, args = {}, { verifyDelayMs = VERIFY_DELAY_MS, ...rest } = {}) {
|
|
305
388
|
const r = write(evda, args, rest);
|
|
389
|
+
if (r.queued) return r; // queued, not written: nothing in the day to read back
|
|
306
390
|
const abs = join(evda.dir, r.path);
|
|
307
391
|
if (verifyDelayMs > 0) await sleep(verifyDelayMs);
|
|
308
392
|
const expect = String(args.words).replace(/\n+$/, "");
|
|
@@ -326,8 +410,14 @@ export async function writeVerified(evda, args = {}, { verifyDelayMs = VERIFY_DE
|
|
|
326
410
|
// replaces, exactly as the section's own instructions ask.
|
|
327
411
|
const CHANGE_MAX = 400;
|
|
328
412
|
const WORDS_MAX = 400;
|
|
329
|
-
|
|
330
|
-
|
|
413
|
+
const TITLE_MAX = 160;
|
|
414
|
+
// HQ-2325: when evda.json names a "corrections" file (lib/corrections.mjs), the correction is a
|
|
415
|
+
// dated section at the end of that file instead: `## YYYY-MM-DD: <title>` and one paragraph, the
|
|
416
|
+
// owner's words quoted and then what changes, the shape the owner's own log already uses. The
|
|
417
|
+
// setting wins over the heading, and a setting that does not resolve refuses rather than falling
|
|
418
|
+
// back, so a correction never lands somewhere the owner did not choose. Append only, either way.
|
|
419
|
+
export function recordCorrection(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
|
|
420
|
+
onlyKeys("evda_record_correction", args, ["words", "change", "supersedes", "title"], "It appends one dated correction where this record keeps them and nothing else: it takes no path, and it never edits or replaces an earlier entry.");
|
|
331
421
|
if (typeof args.change !== "string" || args.change.trim() === "") throw refuse('evda_record_correction needs "change": what you do differently now, one line.', "NO_CHANGE");
|
|
332
422
|
const change = args.change.trim();
|
|
333
423
|
if (/\n/.test(change)) throw refuse('evda_record_correction "change" is one line.', "MULTILINE");
|
|
@@ -341,26 +431,48 @@ export function recordCorrection(evda, args = {}, { now = new Date() } = {}) {
|
|
|
341
431
|
if (words.length > WORDS_MAX) throw refuse(`evda_record_correction "words" is ${words.length} characters; at most ${WORDS_MAX}.`, "TOO_LONG");
|
|
342
432
|
refuseSecrets(words, "evda_record_correction");
|
|
343
433
|
}
|
|
434
|
+
let title = null;
|
|
435
|
+
if (args.title !== undefined) {
|
|
436
|
+
if (typeof args.title !== "string" || args.title.trim() === "") throw refuse('evda_record_correction "title" names what went wrong in a few words; leave it out rather than send it empty.', "EMPTY");
|
|
437
|
+
title = args.title.trim();
|
|
438
|
+
if (/\n/.test(title)) throw refuse('evda_record_correction "title" is one line.', "MULTILINE");
|
|
439
|
+
if (title.length > TITLE_MAX) throw refuse(`evda_record_correction "title" is ${title.length} characters; at most ${TITLE_MAX}.`, "TOO_LONG");
|
|
440
|
+
refuseSecrets(title, "evda_record_correction");
|
|
441
|
+
}
|
|
344
442
|
let supersedes = null;
|
|
345
443
|
if (args.supersedes !== undefined) {
|
|
346
444
|
if (typeof args.supersedes !== "string" || !DATE.test(args.supersedes.trim())) throw refuse('evda_record_correction "supersedes" must be YYYY-MM-DD, the date of the earlier entry this one reverses.', "BAD_DATE");
|
|
347
445
|
supersedes = args.supersedes.trim();
|
|
348
446
|
}
|
|
447
|
+
const date = localDate(now);
|
|
448
|
+
const said = words ? `"${words}" -> ` : "";
|
|
449
|
+
const supersededNote = supersedes ? ` Supersedes ${supersedes}.` : "";
|
|
450
|
+
|
|
451
|
+
const target = correctionsTarget(evda);
|
|
452
|
+
if (target) {
|
|
453
|
+
if (target.problem) throw refuse(`evda.json ${target.problem.replace(/^"corrections"/, 'sets "corrections", but it')} Ask the owner where corrections go.`, "NO_TARGET");
|
|
454
|
+
const may = mayRead(readContext(evda), target.abs, caller);
|
|
455
|
+
if (!may.ok) throw refuse(`${may.why} It is not yours to add to.`, may.kind === "sealed" ? "SEALED" : may.kind === "noted" ? "NOTED" : "NOT_THIS_READER");
|
|
456
|
+
const raw = readFileSync(target.abs, "utf8");
|
|
457
|
+
const heading = `## ${date}: ${title ?? change}`;
|
|
458
|
+
const body = (title ? [words ? `"${words}" → ${change}` : change, supersedes ? `Supersedes ${supersedes}.` : ""] : [words ? `"${words}"` : "", supersedes ? `Supersedes ${supersedes}.` : ""]).filter(Boolean).join(" ");
|
|
459
|
+
const lead = raw === "" ? "" : raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
|
|
460
|
+
appendFileSync(target.abs, `${lead}${heading}\n${body ? `\n${body}\n` : ""}`);
|
|
461
|
+
return { path: target.rel, date, section: heading.slice(3) };
|
|
462
|
+
}
|
|
463
|
+
|
|
349
464
|
const a = agentsFile(evda);
|
|
350
|
-
if (!a) throw refuse('this record maps no "agents" role
|
|
465
|
+
if (!a) throw refuse('this record maps no "agents" role and evda.json sets no "corrections" file, so a correction has nowhere to land.', "NO_ROLE");
|
|
351
466
|
if (isSealed(a.abs)) throw refuse(`${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
|
|
352
467
|
const raw = readFileSync(a.abs, "utf8");
|
|
353
468
|
const parts = splitFence(raw);
|
|
354
469
|
const owner = parts ? parts.after : raw;
|
|
355
470
|
if (!/(^|\n)## What I have been taught(\n|$)/.test(owner)) {
|
|
356
|
-
throw refuse(`${a.rel} has no "## What I have been taught" section outside the generated block, so a correction has nowhere to land. Add that heading
|
|
471
|
+
throw refuse(`${a.rel} has no "## What I have been taught" section outside the generated block, and evda.json sets no "corrections" file, so a correction has nowhere to land. Add that heading (templates/AGENTS.md has the wording), or name the file the owner keeps corrections in as "corrections" in evda.json; evda doctor names this too.`, "NO_SECTION");
|
|
357
472
|
}
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
const line = `- ${date}: ${said}${change}${supersededNote}\n`;
|
|
362
|
-
const sep = owner.endsWith("\n") ? "" : "\n";
|
|
363
|
-
const newOwner = owner + sep + line;
|
|
473
|
+
const line = `- ${date}: ${title ? `${title}. ` : ""}${said}${change}${supersededNote}\n`;
|
|
474
|
+
const lineSep = owner.endsWith("\n") ? "" : "\n";
|
|
475
|
+
const newOwner = owner + lineSep + line;
|
|
364
476
|
const newRaw = parts ? parts.before + parts.generated + newOwner : newOwner;
|
|
365
477
|
writeFileSync(a.abs, newRaw);
|
|
366
478
|
return { path: a.rel, date, section: "What I have been taught" };
|
|
@@ -393,10 +505,59 @@ export function hasAgentLog(evda) {
|
|
|
393
505
|
return existsSync(join(evda.dir, AGENT_LOG_REL, "evda.json"));
|
|
394
506
|
}
|
|
395
507
|
|
|
508
|
+
// HQ-2306: the same call keeps the one pointer line in the owner's day, under `## <Agent>`:
|
|
509
|
+
// Iris: <one fragment per conversation>. [[<agent log day, no .md>|Iris's log for the day]]
|
|
510
|
+
// the shape Hestia's journal-append.mjs writes for its own agents, so the vault's journal:check
|
|
511
|
+
// reads both the same. Only the writing machine may touch the owner's day (HQ-2165), and this is the
|
|
512
|
+
// tool a laptop session reaches it through. The agent log is already on disk when the pointer is
|
|
513
|
+
// written, so a failed pointer is reported in the result, never thrown: a retry would log twice.
|
|
396
514
|
export function writeAgentLog(evda, args = {}, { now = new Date(), session = null } = {}) {
|
|
397
515
|
const sub = agentLogRecord(evda);
|
|
398
516
|
const r = write(sub, args, { now, session, toolName: "evda_write_agent_log" });
|
|
399
|
-
|
|
517
|
+
const path = `${AGENT_LOG_REL}/${r.path}`;
|
|
518
|
+
return { path, bytes: r.bytes, section: r.section, pointer: writeAgentPointer(evda, path, { now }) };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const POINTER_FRAGMENT_MAX = 100;
|
|
522
|
+
const POINTER_FRAGMENTS_MAX = 6;
|
|
523
|
+
|
|
524
|
+
// The fragments of the pointer line, built from the whole of the agent's day log every time so the
|
|
525
|
+
// line is a function of the log: refreshing it never drifts or duplicates. One per section (one per
|
|
526
|
+
// conversation): the first sentence of its first line that is not the context line.
|
|
527
|
+
export function agentLogFragments(logText) {
|
|
528
|
+
const frags = [];
|
|
529
|
+
for (const section of String(logText).split(/^## .*$/m).slice(1)) {
|
|
530
|
+
const first = section.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith(`*${CONTEXT_LABEL}`));
|
|
531
|
+
if (!first) continue;
|
|
532
|
+
const sentence = first.split(/(?<=[.!?])\s+/)[0].replace(/\.\s*$/, "").trim();
|
|
533
|
+
frags.push(sentence.length > POINTER_FRAGMENT_MAX ? `${sentence.slice(0, POINTER_FRAGMENT_MAX - 3).trimEnd()}…` : sentence);
|
|
534
|
+
}
|
|
535
|
+
if (frags.length > POINTER_FRAGMENTS_MAX) {
|
|
536
|
+
const shown = frags.slice(0, POINTER_FRAGMENTS_MAX - 1);
|
|
537
|
+
return [...shown, `and ${frags.length - shown.length} more`];
|
|
538
|
+
}
|
|
539
|
+
return frags.length ? frags : ["see the log"];
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function writeAgentPointer(evda, logPath, { now }) {
|
|
543
|
+
const date = localDate(now);
|
|
544
|
+
const day = join(evda.dir, todayPath(evda, { now }));
|
|
545
|
+
const at = rel(evda, day);
|
|
546
|
+
try {
|
|
547
|
+
if (existsSync(day) && isSealed(day)) return { path: at, state: "sealed" };
|
|
548
|
+
const agent = agentName(evda);
|
|
549
|
+
const link = `[[${logPath.replace(/\.md$/, "")}|${agent}'s log for the day]]`;
|
|
550
|
+
// Only what any AI reader of the log may see is quoted: a sealed section is cut, and a sealed or
|
|
551
|
+
// just-noted log gives no fragment at all, so the pointer never carries held words into the day.
|
|
552
|
+
const logAbs = join(evda.dir, logPath);
|
|
553
|
+
const held = isSealed(logAbs) || isNotedFile(logAbs);
|
|
554
|
+
const frags = held ? ["see the log"] : agentLogFragments(aiView(readFileSync(logAbs, "utf8")).text);
|
|
555
|
+
const line = `${agent}: ${frags.join("; ")}. ${link}`;
|
|
556
|
+
mkdirSync(dirname(day), { recursive: true });
|
|
557
|
+
return { path: at, state: setPointerLine(day, { date, heading: `## ${agent}`, prefix: `${agent}: `, line }) };
|
|
558
|
+
} catch (e) {
|
|
559
|
+
return { path: at, state: "failed", error: e.message };
|
|
560
|
+
}
|
|
400
561
|
}
|
|
401
562
|
|
|
402
563
|
// 3c. Append one dated section to an EXISTING person page (HQ-2164), in the evda_record_correction
|
|
@@ -416,19 +577,84 @@ function peopleDir(evda) {
|
|
|
416
577
|
return r;
|
|
417
578
|
}
|
|
418
579
|
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
580
|
+
// HQ-2696: the pages that are a person's, wherever they live in the record. A person's page is any
|
|
581
|
+
// page whose frontmatter kind is a person kind, anywhere in the record, plus any page directly in the
|
|
582
|
+
// folder the record/people role maps to (a plain page there predates the frontmatter). Reading only
|
|
583
|
+
// the people folder made a page in a Family/ folder beside it invisible (2026-10-16): the agent was
|
|
584
|
+
// told there was no page, and evda_create would have made a second one. A family member is a person,
|
|
585
|
+
// so no folder is special-cased. Only a file's head is read, so a walk of a large record stays cheap.
|
|
586
|
+
export const PERSON_KINDS = ["person-profile", "person"];
|
|
587
|
+
const PERSON_HEAD = 8192;
|
|
588
|
+
const PERSON_KIND_LINE = new RegExp(`^kind:[ \\t]*["']?(?:${PERSON_KINDS.join("|")})["']?[ \\t]*$`, "m");
|
|
589
|
+
function headOf(abs) {
|
|
590
|
+
let fd;
|
|
591
|
+
try {
|
|
592
|
+
fd = openSync(abs, "r");
|
|
593
|
+
const buf = Buffer.alloc(PERSON_HEAD);
|
|
594
|
+
return buf.toString("utf8", 0, readSync(fd, buf, 0, PERSON_HEAD, 0));
|
|
595
|
+
} catch { return ""; } finally { if (fd !== undefined) closeSync(fd); }
|
|
596
|
+
}
|
|
597
|
+
function frontOfPersonHead(abs) {
|
|
598
|
+
let text = headOf(abs);
|
|
599
|
+
if (!text.startsWith("---\n")) return null;
|
|
600
|
+
const close = text.indexOf("\n---", 3);
|
|
601
|
+
if (close === -1) { try { text = readFileSync(abs, "utf8"); } catch { return null; } }
|
|
602
|
+
else if (!PERSON_KIND_LINE.test(text.slice(0, close))) return null;
|
|
603
|
+
return frontmatterOf(text);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const normName = (s) => String(s).trim().toLowerCase().replace(/\s+/g, " ");
|
|
607
|
+
const withoutParen = (s) => s.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
608
|
+
// The names one person page answers to: its filename, its name:, each alias, and each of those
|
|
609
|
+
// without a trailing parenthetical ("John Veson (Dad / JV)" answers to "john veson" too).
|
|
610
|
+
function personNames(abs, front) {
|
|
611
|
+
const out = new Set();
|
|
612
|
+
const add = (v) => {
|
|
613
|
+
if (typeof v !== "string" || !v.trim()) return;
|
|
614
|
+
const n = normName(v);
|
|
615
|
+
out.add(n);
|
|
616
|
+
const bare = withoutParen(n);
|
|
617
|
+
if (bare) out.add(bare);
|
|
618
|
+
};
|
|
619
|
+
add(abs.split(sep).pop().replace(/\.md$/i, ""));
|
|
620
|
+
if (front) {
|
|
621
|
+
add(front.name);
|
|
622
|
+
const al = front.aliases ?? front.alias;
|
|
623
|
+
for (const a of Array.isArray(al) ? al : [al]) add(a);
|
|
624
|
+
}
|
|
625
|
+
return out;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Every person page in the record: { abs, names }.
|
|
629
|
+
export function personPages(evda) {
|
|
630
|
+
const people = resolveRole(evda, "record/people");
|
|
631
|
+
const peopleAbs = people.mapped && people.exists && people.isDir ? people.abs : null;
|
|
632
|
+
const out = [];
|
|
633
|
+
for (const abs of markdownFiles(evda.dir)) {
|
|
634
|
+
const name = abs.split(sep).pop();
|
|
635
|
+
if (/^readme\.md$/i.test(name)) continue;
|
|
636
|
+
const inPeople = peopleAbs !== null && dirname(abs) === peopleAbs;
|
|
637
|
+
const front = frontOfPersonHead(abs);
|
|
638
|
+
const isPerson = front !== null && PERSON_KINDS.includes(front.kind);
|
|
639
|
+
if (!inPeople && !isPerson) continue;
|
|
640
|
+
out.push({ abs, names: personNames(abs, isPerson ? front : null) });
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// The person pages a name points at: every page one of whose names equals it, case-insensitively;
|
|
646
|
+
// only when none does, every page whose filename starts with it (the older shorthand, "Sam" for
|
|
647
|
+
// "Sam Lee"). Returns absolute paths; more than one is ambiguous and the caller says so.
|
|
648
|
+
export function findPersonPages(evda, name) {
|
|
649
|
+
const want = normName(name);
|
|
650
|
+
const bare = withoutParen(want);
|
|
651
|
+
const pages = personPages(evda);
|
|
652
|
+
const exact = pages.filter((p) => p.names.has(want) || (bare && p.names.has(bare)));
|
|
653
|
+
if (exact.length) return exact.map((p) => p.abs);
|
|
654
|
+
return pages.filter((p) => normName(p.abs.split(sep).pop().replace(/\.md$/i, "")).startsWith(want)).map((p) => p.abs);
|
|
429
655
|
}
|
|
430
656
|
|
|
431
|
-
export function recordPersonNote(evda, args = {}, { now = new Date() } = {}) {
|
|
657
|
+
export function recordPersonNote(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
|
|
432
658
|
onlyKeys("evda_record_person_note", args, ["person", "context", "words"], "It appends one dated section to an existing person page and nothing else: it takes no path, it never creates a page, and it never edits or removes an earlier section.");
|
|
433
659
|
if (typeof args.person !== "string" || args.person.trim() === "") throw refuse('evda_record_person_note needs "person": the name on their page.', "NO_PERSON");
|
|
434
660
|
if (typeof args.context !== "string" || args.context.trim() === "") throw refuse('evda_record_person_note needs "context": one line on what this section is.', "NO_CONTEXT");
|
|
@@ -439,13 +665,14 @@ export function recordPersonNote(evda, args = {}, { now = new Date() } = {}) {
|
|
|
439
665
|
const words = text(args.words, "words", "evda_record_person_note");
|
|
440
666
|
refuseSecrets(words, "evda_record_person_note");
|
|
441
667
|
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
if (matches.length
|
|
445
|
-
if (matches.length > 1) throw refuse(`"${args.person.trim()}" matches more than one page in ${dir.rel} (${matches.join(", ")}). Say which one.`, "AMBIGUOUS");
|
|
668
|
+
const matches = findPersonPages(evda, args.person);
|
|
669
|
+
if (matches.length === 0) throw refuse(`no page for "${args.person.trim()}" anywhere in the record (a person's page is one with kind: person-profile frontmatter, or any page in the people folder). This tool never creates one: offer in one line to start their page, and on their yes make it with evda_create (kind "person").`, "MISSING");
|
|
670
|
+
if (matches.length > 1) throw refuse(`"${args.person.trim()}" matches more than one page (${matches.map((m) => rel(evda, m)).join(", ")}). Say which one.`, "AMBIGUOUS");
|
|
446
671
|
|
|
447
|
-
const abs =
|
|
672
|
+
const abs = matches[0];
|
|
448
673
|
if (isSealed(abs)) throw refuse(`${rel(evda, abs)} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
|
|
674
|
+
const may = mayRead(readContext(evda), abs, caller);
|
|
675
|
+
if (!may.ok) throw refuse(may.why, may.kind === "noted" ? "NOTED" : "READER");
|
|
449
676
|
|
|
450
677
|
const raw = readFileSync(abs, "utf8");
|
|
451
678
|
const date = localDate(now);
|
|
@@ -499,41 +726,368 @@ const APPEND_ELSEWHERE = [
|
|
|
499
726
|
["messages", "is a message file, which evda_message and the phone write."],
|
|
500
727
|
["ledger", "is the hypothesis ledger, which has its own format and tools."],
|
|
501
728
|
];
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
729
|
+
// Where an entry-shaped write may land, by path: inside the record, a Markdown file, not the
|
|
730
|
+
// operating layer, not one of the record's own files (the root, a dot-folder, proposals/), not a
|
|
731
|
+
// file another tool owns (APPEND_ELSEWHERE), and readable by this caller (sealed, noted and
|
|
732
|
+
// read_by all refuse, since what the agent may not read is not the agent's to add to). `exists`
|
|
733
|
+
// says whether the path must already hold a file (append) or must hold nothing (create).
|
|
734
|
+
function entryPath(evda, tool, path, caller, { exists = true, folder = false } = {}) {
|
|
735
|
+
if (typeof path !== "string" || path.trim() === "") throw refuse(`${tool} needs "path": the entry, relative to the record, as evda_search or evda_recent gave it.`, "BAD_ARGUMENT");
|
|
736
|
+
const want = path.trim();
|
|
737
|
+
refuseSecrets(want, tool);
|
|
508
738
|
const abs = resolve(evda.dir, want);
|
|
509
739
|
if (!abs.startsWith(evda.dir + sep)) throw refuse(`${want} is outside the record.`, "OUTSIDE");
|
|
510
740
|
const where = rel(evda, abs);
|
|
511
|
-
if (!/\.md$/i.test(abs)) throw refuse(`${where} is not a Markdown entry; only a .md entry grows
|
|
512
|
-
if (!existsSync(abs) || !statSync(abs).isFile()) throw refuse(`no entry is at ${where}. This tool never creates one: say in one line that you can start
|
|
741
|
+
if (!folder && !/\.md$/i.test(abs)) throw refuse(`${where} is not a Markdown entry; only a .md entry ${exists ? "grows" : "is started"} here.`, "NOT_AN_ENTRY");
|
|
742
|
+
if (exists && (!existsSync(abs) || !statSync(abs).isFile())) throw refuse(`no entry is at ${where}. This tool never creates one: say in one line that you can start the page, and on their yes make it with evda_create.`, "MISSING");
|
|
743
|
+
if (!exists && existsSync(abs)) throw refuse(`${where} already exists. evda_create never overwrites; add to an entry with evda_append.`, "EXISTS");
|
|
513
744
|
const a = agentsFile(evda);
|
|
514
745
|
if (a && resolve(a.abs) === abs) throw refuse(`${where} is the operating layer: a correction goes in with evda_record_correction, and a setup answer with evda_setup_answer.`, "NOT_AN_ENTRY");
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (
|
|
746
|
+
const parts = where.split("/");
|
|
747
|
+
if ((!folder && parts.length < 2) || parts.some((p) => p.startsWith("."))) throw refuse(`${where} is one of the record's own files, not an entry.`, "NOT_AN_ENTRY");
|
|
748
|
+
if (parts[0] === PROPOSALS) throw refuse(`${where} is one of the record's own files, not an entry.`, "NOT_AN_ENTRY");
|
|
518
749
|
for (const [role, why] of APPEND_ELSEWHERE) {
|
|
519
750
|
const r = resolveRole(evda, role);
|
|
520
|
-
if (
|
|
751
|
+
if (role === "record/people" && !exists && !folder) continue;
|
|
752
|
+
if (r.mapped && r.abs && (abs === r.abs || abs.startsWith(r.abs + sep) || (folder && r.abs.startsWith(abs + sep)))) throw refuse(`${where} ${why}`, "NOT_AN_ENTRY");
|
|
521
753
|
}
|
|
522
754
|
const may = mayRead(readContext(evda), abs, caller);
|
|
523
755
|
if (!may.ok) throw refuse(`${may.why} It is not yours to add to.`, may.kind === "sealed" ? "SEALED" : may.kind === "noted" ? "NOTED" : "NOT_THIS_READER");
|
|
756
|
+
return { abs, where };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Markdown headings outside fenced code, as { level, text, start, end } (end: the offset after its
|
|
760
|
+
// line), so a `#` inside a code block is never read as structure.
|
|
761
|
+
function mdHeadings(raw) {
|
|
762
|
+
const out = [];
|
|
763
|
+
let fence = null;
|
|
764
|
+
let at = 0;
|
|
765
|
+
for (const line of raw.split("\n")) {
|
|
766
|
+
const start = at;
|
|
767
|
+
at += line.length + 1;
|
|
768
|
+
const f = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
769
|
+
if (f) {
|
|
770
|
+
if (!fence) fence = f[1];
|
|
771
|
+
else if (f[1][0] === fence[0] && f[1].length >= fence.length) fence = null;
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
if (fence) continue;
|
|
775
|
+
const h = /^ {0,3}(#{1,6})[ \t]+(.*?)[ \t#]*$/.exec(line);
|
|
776
|
+
if (h) out.push({ level: h[1].length, text: h[2].trim(), start, end: Math.min(at, raw.length) });
|
|
777
|
+
}
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// The section a heading names: from the end of its heading line to the start of the next heading
|
|
782
|
+
// at its level or above, or the end of the file. Refuses a heading that is missing or matches twice.
|
|
783
|
+
function sectionUnder(raw, under, tool, where) {
|
|
784
|
+
if (typeof under !== "string" || under.trim() === "") throw refuse(`${tool} "under" is the text of a heading in ${where}, without the #s.`, "BAD_ARGUMENT");
|
|
785
|
+
const want = under.trim().replace(/^#+\s*/, "").toLowerCase();
|
|
786
|
+
const hs = mdHeadings(raw);
|
|
787
|
+
const hits = hs.filter((h) => h.text.toLowerCase() === want);
|
|
788
|
+
if (hits.length === 0) {
|
|
789
|
+
const names = hs.filter((h) => h.level <= 3).slice(0, 15).map((h) => JSON.stringify(h.text)).join(", ");
|
|
790
|
+
throw refuse(`${where} has no heading "${under.trim()}".${names ? ` Its headings include ${names}.` : ""}`, "NO_HEADING");
|
|
791
|
+
}
|
|
792
|
+
if (hits.length > 1) throw refuse(`${where} has ${hits.length} headings "${under.trim()}", so which section is meant is not clear. Name a heading that appears once.`, "AMBIGUOUS");
|
|
793
|
+
const h = hits[0];
|
|
794
|
+
const next = hs.find((x) => x.start > h.start && x.level <= h.level);
|
|
795
|
+
return { heading: h, start: h.end, end: next ? next.start : raw.length, inside: hs.filter((x) => x.start > h.start && (!next || x.start < next.start)) };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Insert `block` (whole lines, ending in "\n") at `at`, so one blank line separates it from what is
|
|
799
|
+
// before and from a heading after. Every byte of raw is kept; the result is before + insert + after.
|
|
800
|
+
function insertAt(raw, at, block) {
|
|
801
|
+
const before = raw.slice(0, at);
|
|
802
|
+
const after = raw.slice(at);
|
|
803
|
+
const lead = before === "" || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
|
|
804
|
+
const trail = after === "" ? "" : "\n";
|
|
805
|
+
const insert = `${lead}${block}${trail}`;
|
|
806
|
+
return { text: before + insert + after, insert };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const TITLE_LINE_MAX = 160;
|
|
810
|
+
function oneLine(value, name, tool, max = TITLE_LINE_MAX) {
|
|
811
|
+
if (typeof value !== "string" || value.trim() === "") throw refuse(`${tool} "${name}" is one line of text; leave it out rather than send it empty.`, "EMPTY");
|
|
812
|
+
const v = value.trim();
|
|
813
|
+
if (/\n/.test(v)) throw refuse(`${tool} "${name}" is one line.`, "MULTILINE");
|
|
814
|
+
if (v.length > max) throw refuse(`${tool} "${name}" is ${v.length} characters; at most ${max}.`, "TOO_LONG");
|
|
815
|
+
refuseSecrets(v, tool);
|
|
816
|
+
return v;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// HQ-2328: `under` places the section at the end of that heading's section instead of the end of
|
|
820
|
+
// the file, with its heading one level below, so an entry goes into the right part of a file whose
|
|
821
|
+
// last section is "Completed" or an archive. `title` names the section: `<title> (added
|
|
822
|
+
// YYYY-MM-DD)`, the dated-heading shape the record's other entries use; without it the heading is
|
|
823
|
+
// the date alone, and a second append the same day continues it.
|
|
824
|
+
export function appendEntry(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
|
|
825
|
+
onlyKeys("evda_append", args, ["path", "words", "under", "title"], "It adds one dated section to an entry that already exists and nothing else: it never creates a file and never edits what is there.");
|
|
826
|
+
const words = text(args.words, "words", "evda_append");
|
|
827
|
+
refuseSecrets(words, "evda_append");
|
|
828
|
+
const title = args.title === undefined ? null : oneLine(args.title, "title", "evda_append");
|
|
829
|
+
const { abs, where } = entryPath(evda, "evda_append", args.path, caller);
|
|
524
830
|
const raw = readFileSync(abs, "utf8");
|
|
525
831
|
const fm = frontmatterOf(raw);
|
|
526
832
|
if (fm && fm.kind === "person-profile") throw refuse(`${where} is a person profile, and each new section there needs an observation: add to it with evda_record_person_note.`, "NOT_AN_ENTRY");
|
|
527
833
|
const date = localDate(now);
|
|
528
|
-
const
|
|
834
|
+
const label = title ? `${title} (added ${date})` : date;
|
|
835
|
+
|
|
836
|
+
if (args.under !== undefined) {
|
|
837
|
+
const s = sectionUnder(raw, args.under, "evda_append", where);
|
|
838
|
+
const level = Math.min(s.heading.level + 1, 6);
|
|
839
|
+
const heading = `${"#".repeat(level)} ${label}`;
|
|
840
|
+
const last = s.inside.filter((h) => h.level === level).pop();
|
|
841
|
+
const continued = !title && last && last.text === label && !s.inside.some((h) => h.start > last.start);
|
|
842
|
+
const block = continued ? `${words.trim()}\n` : `${heading}\n\n${words.trim()}\n`;
|
|
843
|
+
const { text: next } = insertAt(raw, s.end, block);
|
|
844
|
+
writeFileSync(abs, next);
|
|
845
|
+
return { path: where, date, section: continued ? "continued" : "opened", under: s.heading.text };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const heading = `## ${label}`;
|
|
529
849
|
const lastHeading = [...raw.matchAll(/^## .*$/gm)].pop()?.[0]?.trim();
|
|
530
|
-
const continued = lastHeading === heading;
|
|
850
|
+
const continued = !title && lastHeading === heading;
|
|
531
851
|
const lead = raw === "" || raw.endsWith("\n") ? "" : "\n";
|
|
532
852
|
const add = continued ? `${lead}\n${words.trim()}\n` : `${lead}${raw === "" ? "" : "\n"}${heading}\n\n${words.trim()}\n`;
|
|
533
853
|
appendFileSync(abs, add);
|
|
534
854
|
return { path: where, date, section: continued ? "continued" : "opened" };
|
|
535
855
|
}
|
|
536
856
|
|
|
857
|
+
// The markdown tables in a text, outside fenced code: a header row, a delimiter row, then body rows,
|
|
858
|
+
// every line starting with `|`. { start, end, columns, header } with end the offset after the last row.
|
|
859
|
+
function splitRow(line) {
|
|
860
|
+
let s = line.trim();
|
|
861
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
862
|
+
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
|
|
863
|
+
return s.split(/(?<!\\)\|/).map((c) => c.trim());
|
|
864
|
+
}
|
|
865
|
+
function mdTables(raw) {
|
|
866
|
+
const lines = raw.split("\n");
|
|
867
|
+
const offs = [];
|
|
868
|
+
let at = 0;
|
|
869
|
+
for (const l of lines) { offs.push(at); at += l.length + 1; }
|
|
870
|
+
const out = [];
|
|
871
|
+
let fence = null;
|
|
872
|
+
const isRow = (l) => /^ {0,3}\|/.test(l);
|
|
873
|
+
for (let i = 0; i < lines.length; i++) {
|
|
874
|
+
const f = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
|
|
875
|
+
if (f) {
|
|
876
|
+
if (!fence) fence = f[1];
|
|
877
|
+
else if (f[1][0] === fence[0] && f[1].length >= fence.length) fence = null;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
if (fence || !isRow(lines[i]) || i + 1 >= lines.length) continue;
|
|
881
|
+
if (!/^ {0,3}\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(lines[i + 1])) continue;
|
|
882
|
+
const header = splitRow(lines[i]);
|
|
883
|
+
let j = i + 2;
|
|
884
|
+
while (j < lines.length && isRow(lines[j])) j++;
|
|
885
|
+
const lastLine = j - 1;
|
|
886
|
+
out.push({ start: offs[i], end: Math.min(offs[lastLine] + lines[lastLine].length + 1, raw.length), lastEndsFile: lastLine === lines.length - 1, columns: header.length, header });
|
|
887
|
+
i = j - 1;
|
|
888
|
+
}
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const CELL_MAX = 4000;
|
|
893
|
+
// HQ-2328: one row at the end of a markdown table in an existing entry. A log kept as a table (the
|
|
894
|
+
// food log, a spike log, a weekends board) grows by a row, not by a dated section. The row has to
|
|
895
|
+
// fill the table's columns exactly, so a row is never written into the wrong shape; a file with more
|
|
896
|
+
// than one table needs `under`, the heading whose section holds the one meant. Only the new row's
|
|
897
|
+
// bytes are added: everything before and after it is what it was.
|
|
898
|
+
export function appendRow(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
899
|
+
onlyKeys("evda_append_row", args, ["path", "cells", "under"], "It adds one row to the end of a table in an entry that already exists and nothing else: it never edits a row that is there.");
|
|
900
|
+
const { abs, where } = entryPath(evda, "evda_append_row", args.path, caller);
|
|
901
|
+
if (!Array.isArray(args.cells) || args.cells.length === 0) throw refuse('evda_append_row needs "cells": the new row\'s cells, in the table\'s column order, one string each ("" for an empty cell).', "BAD_ARGUMENT");
|
|
902
|
+
const cells = args.cells.map((c, i) => {
|
|
903
|
+
if (typeof c !== "string") throw refuse(`evda_append_row cell ${i + 1} is not text; send each cell as a string, "" when empty.`, "BAD_ARGUMENT");
|
|
904
|
+
if (/[\r\n]/.test(c)) throw refuse(`evda_append_row cell ${i + 1} has a line break; a table cell is one line.`, "MULTILINE");
|
|
905
|
+
if (c.length > CELL_MAX) throw refuse(`evda_append_row cell ${i + 1} is ${c.length} characters; at most ${CELL_MAX}.`, "TOO_LONG");
|
|
906
|
+
refuseSecrets(c, "evda_append_row");
|
|
907
|
+
return c.trim().replace(/(?<!\\)\|/g, "\\|");
|
|
908
|
+
});
|
|
909
|
+
const raw = readFileSync(abs, "utf8");
|
|
910
|
+
let tables = mdTables(raw);
|
|
911
|
+
let scope = "";
|
|
912
|
+
if (args.under !== undefined) {
|
|
913
|
+
const s = sectionUnder(raw, args.under, "evda_append_row", where);
|
|
914
|
+
tables = tables.filter((t) => t.start >= s.start && t.start < s.end);
|
|
915
|
+
scope = ` under "${s.heading.text}"`;
|
|
916
|
+
}
|
|
917
|
+
if (tables.length === 0) throw refuse(`${where} has no table${scope}. A row goes into a table that is already there.`, "NO_TABLE");
|
|
918
|
+
if (tables.length > 1) {
|
|
919
|
+
const where2 = mdHeadings(raw);
|
|
920
|
+
const named = tables.map((t) => where2.filter((h) => h.start < t.start).pop()?.text).map((h) => (h ? JSON.stringify(h) : "(before any heading)"));
|
|
921
|
+
throw refuse(`${where} has ${tables.length} tables${scope}, so which one is meant is not clear. Pass "under" with the heading of the one meant: ${named.join(", ")}.`, "AMBIGUOUS");
|
|
922
|
+
}
|
|
923
|
+
const t = tables[0];
|
|
924
|
+
if (cells.length !== t.columns) throw refuse(`the table in ${where}${scope} has ${t.columns} columns (${t.header.map((h) => JSON.stringify(h)).join(", ")}) and this row has ${cells.length} cells. Send one cell per column, "" for an empty one.`, "COLUMNS");
|
|
925
|
+
const row = `| ${cells.join(" | ")} |`.replace(/\| \|/g, "| |").replace(/\| \|/g, "| |");
|
|
926
|
+
const insert = t.lastEndsFile && !raw.endsWith("\n") ? `\n${row}` : `${row}\n`;
|
|
927
|
+
writeFileSync(abs, raw.slice(0, t.end) + insert + raw.slice(t.end));
|
|
928
|
+
return { path: where, row, columns: t.columns };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// HQ-2333: one line at the end of a list in an existing entry. A board kept as a list (his Weekends
|
|
932
|
+
// board is "- **Oct 9 - 11**: Austin (ACL) · `confirmed`", one line a weekend) grows by an item, and on
|
|
933
|
+
// 10/7 a chat with only evda_append and evda_append_row gave a new weekend its own heading and
|
|
934
|
+
// paragraph instead. The item takes the list's own marker (the next number in a numbered list), so it
|
|
935
|
+
// is written in the list's shape; the line is the caller's, in the format the items around it use.
|
|
936
|
+
const ITEM = /^( {0,3})([-*+]|\d{1,9}[.)])[ \t]+\S/;
|
|
937
|
+
function mdLists(raw) {
|
|
938
|
+
const lines = raw.split("\n");
|
|
939
|
+
const offs = [];
|
|
940
|
+
let at = 0;
|
|
941
|
+
for (const l of lines) { offs.push(at); at += l.length + 1; }
|
|
942
|
+
const kind = (m) => (/\d/.test(m) ? `n${m.slice(-1)}` : m);
|
|
943
|
+
const out = [];
|
|
944
|
+
let fence = null;
|
|
945
|
+
for (let i = 0; i < lines.length; i++) {
|
|
946
|
+
const f = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
|
|
947
|
+
if (f) {
|
|
948
|
+
if (!fence) fence = f[1];
|
|
949
|
+
else if (f[1][0] === fence[0] && f[1].length >= fence.length) fence = null;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
const m = fence ? null : ITEM.exec(lines[i]);
|
|
953
|
+
if (!m) continue;
|
|
954
|
+
const indent = m[1].length;
|
|
955
|
+
const k = kind(m[2]);
|
|
956
|
+
let last = i;
|
|
957
|
+
let lastMarker = m[2];
|
|
958
|
+
const items = [{ line: i, text: lines[i].trim(), marker: m[2] }];
|
|
959
|
+
let j = i + 1;
|
|
960
|
+
while (j < lines.length) {
|
|
961
|
+
const l = lines[j];
|
|
962
|
+
if (/^ {0,3}(`{3,}|~{3,})/.test(l)) break;
|
|
963
|
+
const n = ITEM.exec(l);
|
|
964
|
+
if (n && n[1].length === indent && kind(n[2]) === k) { items[items.length - 1].lastLine = last; items.push({ line: j, text: l.trim(), marker: n[2] }); last = j; lastMarker = n[2]; j++; continue; }
|
|
965
|
+
if (l.trim() !== "" && /^\s/.test(l) && l.search(/\S/) > indent) { last = j; j++; continue; }
|
|
966
|
+
// A line straight after an item, with no blank between, that starts nothing of its own (a
|
|
967
|
+
// heading, a fence, a table row, a quote, a rule) belongs to that item: markdown reads it as a
|
|
968
|
+
// continuation, and so does his editor. His Weekends board has two ("**Oct 31:** Houston ...").
|
|
969
|
+
if (l.trim() !== "" && j === last + 1 && !/^ {0,3}(#{1,6}(\s|$)|\||>|(\*\s*){3,}$|(-\s*){3,}$|(_\s*){3,}$)/.test(l)) { last = j; j++; continue; }
|
|
970
|
+
if (l.trim() === "") {
|
|
971
|
+
let k2 = j;
|
|
972
|
+
while (k2 < lines.length && lines[k2].trim() === "") k2++;
|
|
973
|
+
const n2 = k2 < lines.length ? ITEM.exec(lines[k2]) : null;
|
|
974
|
+
if (n2 && n2[1].length === indent && kind(n2[2]) === k) { j = k2; continue; }
|
|
975
|
+
}
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
items[items.length - 1].lastLine = last;
|
|
979
|
+
const endOf = (n) => Math.min(offs[n] + lines[n].length + 1, raw.length);
|
|
980
|
+
out.push({ start: offs[i], end: endOf(last), lastEndsFile: last === lines.length - 1, indent: m[1], marker: lastMarker, first: lines[i].trim(), items: items.map((x) => ({ text: x.text, marker: x.marker, end: endOf(x.lastLine), endsFile: x.lastLine === lines.length - 1 })) });
|
|
981
|
+
i = j - 1;
|
|
982
|
+
}
|
|
983
|
+
return out;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const ITEM_MAX = 2000;
|
|
987
|
+
export function appendItem(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
988
|
+
onlyKeys("evda_append_item", args, ["path", "line", "under", "after"], "It adds one item to a list in an entry that already exists and nothing else: it never edits an item that is there.");
|
|
989
|
+
const { abs, where } = entryPath(evda, "evda_append_item", args.path, caller);
|
|
990
|
+
if (typeof args.line !== "string" || args.line.trim() === "") throw refuse('evda_append_item needs "line": the new item, one line, in the format the list\'s other items use.', "BAD_ARGUMENT");
|
|
991
|
+
if (/[\r\n]/.test(args.line.trim())) throw refuse("evda_append_item takes one line; a list item here is one line.", "MULTILINE");
|
|
992
|
+
const body = args.line.trim().replace(/^([-*+]|\d{1,9}[.)])[ \t]+/, "");
|
|
993
|
+
if (body.length > ITEM_MAX) throw refuse(`evda_append_item line is ${body.length} characters; at most ${ITEM_MAX}.`, "TOO_LONG");
|
|
994
|
+
refuseSecrets(body, "evda_append_item");
|
|
995
|
+
const raw = readFileSync(abs, "utf8");
|
|
996
|
+
let lists = mdLists(raw);
|
|
997
|
+
let scope = "";
|
|
998
|
+
if (args.under !== undefined) {
|
|
999
|
+
const s = sectionUnder(raw, args.under, "evda_append_item", where);
|
|
1000
|
+
lists = lists.filter((l) => l.start >= s.start && l.start < s.end);
|
|
1001
|
+
scope = ` under "${s.heading.text}"`;
|
|
1002
|
+
}
|
|
1003
|
+
if (lists.length === 0) throw refuse(`${where} has no list${scope}. An item goes into a list that is already there; a new section is evda_append.`, "NO_LIST");
|
|
1004
|
+
if (lists.length > 1) {
|
|
1005
|
+
const hs = mdHeadings(raw);
|
|
1006
|
+
const named = [...new Set(lists.map((l) => hs.filter((h) => h.start < l.start).pop()?.text).map((h) => (h ? JSON.stringify(h) : "(before any heading)")))];
|
|
1007
|
+
throw refuse(`${where} has ${lists.length} lists${scope}, so which one is meant is not clear. Pass "under" with the heading of the one meant${named.length ? `: ${named.join(", ")}` : ""}.`, "AMBIGUOUS");
|
|
1008
|
+
}
|
|
1009
|
+
const l = lists[0];
|
|
1010
|
+
// HQ-2333: a list kept in order (his Weekends board is by date) takes a new item beside the one it
|
|
1011
|
+
// belongs next to, so "after" names an item already there by a piece of its text.
|
|
1012
|
+
let at = l.end;
|
|
1013
|
+
let endsFile = l.lastEndsFile;
|
|
1014
|
+
let marker = /\d/.test(l.marker) ? `${Number(l.marker.slice(0, -1)) + 1}${l.marker.slice(-1)}` : l.marker;
|
|
1015
|
+
if (args.after !== undefined) {
|
|
1016
|
+
if (typeof args.after !== "string" || args.after.trim() === "") throw refuse('evda_append_item "after" is a piece of the text of the item the new one goes right after.', "BAD_ARGUMENT");
|
|
1017
|
+
if (/\d/.test(l.marker)) throw refuse(`the list in ${where}${scope} is numbered, so a new item goes at its end; leave out "after".`, "NUMBERED");
|
|
1018
|
+
const want = args.after.trim().toLowerCase();
|
|
1019
|
+
const hits = l.items.filter((x) => x.text.toLowerCase().includes(want));
|
|
1020
|
+
if (hits.length === 0) throw refuse(`no item in the list in ${where}${scope} contains "${args.after.trim()}". Name a piece of one item's text as it is written there.`, "NO_ITEM");
|
|
1021
|
+
if (hits.length > 1) throw refuse(`${hits.length} items in the list in ${where}${scope} contain "${args.after.trim()}": ${hits.slice(0, 5).map((x) => JSON.stringify(x.text.slice(0, 60))).join(", ")}. Name a piece of text only one of them has.`, "AMBIGUOUS");
|
|
1022
|
+
at = hits[0].end;
|
|
1023
|
+
endsFile = hits[0].endsFile;
|
|
1024
|
+
marker = hits[0].marker;
|
|
1025
|
+
}
|
|
1026
|
+
const item = `${l.indent}${marker} ${body}`;
|
|
1027
|
+
const insert = endsFile && !raw.endsWith("\n") ? `\n${item}` : `${item}\n`;
|
|
1028
|
+
writeFileSync(abs, raw.slice(0, at) + insert + raw.slice(at));
|
|
1029
|
+
return { path: where, item };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// HQ-2331: start a new entry or folder, on the owner's yes. In a chat app the connector is the only
|
|
1033
|
+
// thing that writes, so without this an offer to "start a page for X" led nowhere. It creates only
|
|
1034
|
+
// where nothing is (an existing path is refused, never overwritten), only inside the record, never
|
|
1035
|
+
// in the record's own files or a part that is not this caller's to read, and never more than one
|
|
1036
|
+
// level: the folder a page goes in must already be there. A person's page is made in the
|
|
1037
|
+
// record/people folder with person-profile frontmatter that doctor reads, and a first section gets
|
|
1038
|
+
// its observation, the same shape evda_record_person_note keeps.
|
|
1039
|
+
export const CREATE_KINDS = ["page", "person", "folder"];
|
|
1040
|
+
const PERSON_NAME_MAX = 120;
|
|
1041
|
+
export function createEntry(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
|
|
1042
|
+
onlyKeys("evda_create", args, ["kind", "path", "person", "title", "context", "words", "confirmed"], "It starts one new page, person's page or folder where nothing is, and nothing else: it never overwrites, moves or deletes.");
|
|
1043
|
+
if (!CREATE_KINDS.includes(args.kind)) throw refuse(`evda_create "kind" is one of ${CREATE_KINDS.map((k) => JSON.stringify(k)).join(", ")}.`, "BAD_ARGUMENT");
|
|
1044
|
+
if (args.confirmed !== true) throw refuse("evda_create starts something new in their record only on their yes. Offer it in one line, and pass confirmed: true only once they said yes in this conversation.", "NOT_CONFIRMED");
|
|
1045
|
+
const kind = args.kind;
|
|
1046
|
+
const date = localDate(now);
|
|
1047
|
+
const words = args.words === undefined ? null : text(args.words, "words", "evda_create").trim();
|
|
1048
|
+
if (words) refuseSecrets(words, "evda_create");
|
|
1049
|
+
|
|
1050
|
+
if (kind === "folder") {
|
|
1051
|
+
for (const k of ["person", "title", "context", "words"]) if (args[k] !== undefined) throw refuse(`evda_create kind "folder" makes an empty folder and takes no "${k}"; start a page in it next.`, "BAD_ARGUMENT");
|
|
1052
|
+
const { abs, where } = entryPath(evda, "evda_create", args.path, caller, { exists: false, folder: true });
|
|
1053
|
+
if (!existsSync(dirname(abs)) || !statSync(dirname(abs)).isDirectory()) throw refuse(`${rel(evda, dirname(abs)) || "the record"} is not a folder yet. evda_create makes one level at a time: start the folder above first.`, "NO_PARENT");
|
|
1054
|
+
mkdirSync(abs);
|
|
1055
|
+
return { path: where, kind };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (kind === "person") {
|
|
1059
|
+
if (args.path !== undefined || args.title !== undefined) throw refuse('evda_create kind "person" takes the name as "person", not a path or title: the page goes in the people folder under their name.', "BAD_ARGUMENT");
|
|
1060
|
+
const name = oneLine(args.person ?? "", "person", "evda_create", PERSON_NAME_MAX);
|
|
1061
|
+
if (/[\\/:]/.test(name) || name.startsWith(".") || /^readme$/i.test(name)) throw refuse(`evda_create "person" is a name, as it goes on their page; "${name}" cannot be a file name.`, "BAD_ARGUMENT");
|
|
1062
|
+
if ((args.context === undefined) !== (words === null)) throw refuse('evda_create kind "person" takes "context" and "words" together (the first section and its observation), or neither.', "BAD_ARGUMENT");
|
|
1063
|
+
const context = args.context === undefined ? null : oneLine(args.context, "context", "evda_create", PERSON_CONTEXT_MAX);
|
|
1064
|
+
const dir = peopleDir(evda);
|
|
1065
|
+
const want = normName(name);
|
|
1066
|
+
const same = personPages(evda).filter((p) => p.names.has(want) || p.names.has(withoutParen(want)));
|
|
1067
|
+
if (same.length) throw refuse(`${rel(evda, same[0].abs)} already exists: add to it with evda_record_person_note.`, "EXISTS");
|
|
1068
|
+
const { abs, where } = entryPath(evda, "evda_create", `${dir.rel}/${name}.md`, caller, { exists: false });
|
|
1069
|
+
const owner = holderOf(evda).id ?? holderOf(evda).name;
|
|
1070
|
+
const id = slug(name) || "person";
|
|
1071
|
+
const observations = context ? [{ id: `${date}-${localTime(now).replace(":", "")}`, since: date, text: context }] : [];
|
|
1072
|
+
const fm = { kind: "person-profile", id, schemaVersion: "0.1", name, observations };
|
|
1073
|
+
const problems = readEntry("person-profile", fm, { owner }).problems;
|
|
1074
|
+
if (problems.length) throw refuse(`a person page for "${name}" would not read as a person-profile: ${problems[0]}`, "BAD_PROFILE");
|
|
1075
|
+
const body = `# ${name}\n${context ? `\n## ${context} (added ${date})\n\n${words}\n` : ""}`;
|
|
1076
|
+
writeFileSync(abs, `---\n${toYaml(fm)}---\n\n${body}`, { flag: "wx" });
|
|
1077
|
+
return { path: where, kind, observation: observations[0]?.id ?? null };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (args.person !== undefined || args.context !== undefined) throw refuse('evda_create kind "page" takes "path", "title" and "words"; a person\'s page is kind "person".', "BAD_ARGUMENT");
|
|
1081
|
+
const { abs, where } = entryPath(evda, "evda_create", args.path, caller, { exists: false });
|
|
1082
|
+
const people = resolveRole(evda, "record/people");
|
|
1083
|
+
if (people.mapped && people.abs && abs.startsWith(people.abs + sep)) throw refuse(`${where} is in the people folder: a person's page is kind "person", which writes the frontmatter doctor needs.`, "NOT_AN_ENTRY");
|
|
1084
|
+
if (!existsSync(dirname(abs)) || !statSync(dirname(abs)).isDirectory()) throw refuse(`${rel(evda, dirname(abs))} is not a folder yet. Make it first with evda_create kind "folder", on the same yes.`, "NO_PARENT");
|
|
1085
|
+
const title = args.title === undefined ? where.split("/").pop().replace(/\.md$/i, "") : oneLine(args.title, "title", "evda_create");
|
|
1086
|
+
const content = `# ${title}\n${words ? `\n## ${date}\n\n${words}\n` : ""}`;
|
|
1087
|
+
writeFileSync(abs, content, { flag: "wx" });
|
|
1088
|
+
return { path: where, kind };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
537
1091
|
// 3e. The owner's setup answers (HQ-2228), into AGENTS.md's "What this EVÐA is", in the
|
|
538
1092
|
// evda_record_correction shape: one named section, one named line, and nothing else in the file
|
|
539
1093
|
// changes. The core's setup step asks each answer to be written there "in their exact words"; a chat
|
|
@@ -629,8 +1183,7 @@ export function search(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
|
629
1183
|
const files = recordFiles(evda);
|
|
630
1184
|
const ledger = resolveRole(evda, "ledger");
|
|
631
1185
|
if (ledger.mapped && ledger.fileExists) files.push(ledger.file);
|
|
632
|
-
const
|
|
633
|
-
let more = 0;
|
|
1186
|
+
const found = [];
|
|
634
1187
|
const ctx = readContext(evda);
|
|
635
1188
|
const refused = [];
|
|
636
1189
|
// Whether any searched file is sealed or holds a sealed section, decided without the query, so
|
|
@@ -649,12 +1202,22 @@ export function search(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
|
649
1202
|
const shown = aiView(raw);
|
|
650
1203
|
if (shown.sections) sealed = true;
|
|
651
1204
|
const body = shown.text;
|
|
1205
|
+
// A line's date (HQ-2324): the date the heading it sits under names, else the file's own date,
|
|
1206
|
+
// so a people page's 10/2 section ranks with 10/2 and not with the page's first line.
|
|
1207
|
+
const own = fileDate(abs, raw) ?? "";
|
|
1208
|
+
let under = null;
|
|
652
1209
|
body.split("\n").forEach((line, i) => {
|
|
1210
|
+
const h = /^#{1,6} (.*)$/.exec(line);
|
|
1211
|
+
if (h) under = headingDate(h[1]) ?? (/^#{1,2} /.test(line) ? null : under);
|
|
653
1212
|
if (!line.toLowerCase().includes(needle)) return;
|
|
654
|
-
|
|
655
|
-
matches.push({ path: rel(evda, abs), line: i + 1, text: line.length > 400 ? `${line.slice(0, 400)}...` : line });
|
|
1213
|
+
found.push({ path: rel(evda, abs), line: i + 1, date: under ?? own, text: line.length > 400 ? `${line.slice(0, 400)}...` : line });
|
|
656
1214
|
});
|
|
657
1215
|
}
|
|
1216
|
+
// Recent first (HQ-2324): on 2026-10-07 a search for chemistry filled its limit with February
|
|
1217
|
+
// before it reached 10/2. Newest date first; undated lines last; ties keep the record's order.
|
|
1218
|
+
found.sort((a, b) => (a.date === b.date ? 0 : !a.date ? 1 : !b.date ? -1 : b.date.localeCompare(a.date)));
|
|
1219
|
+
const matches = found.slice(0, limit);
|
|
1220
|
+
const more = found.length - matches.length;
|
|
658
1221
|
return { query: args.query, searched: files.length, matches, more, refused, heldBack: heldBack(refused, { sealed }) };
|
|
659
1222
|
}
|
|
660
1223
|
|
|
@@ -672,6 +1235,53 @@ export function readFile(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
|
672
1235
|
return { path: rel(evda, abs), text, truncated: false };
|
|
673
1236
|
}
|
|
674
1237
|
|
|
1238
|
+
// 5a'. HQ-2066: the owner's own edit of one note, made on their phone, saved only when the note is
|
|
1239
|
+
// still what they read. `base` is the SHA-256 (hex) of the text evda_read returned; when the file
|
|
1240
|
+
// now hashes to anything else it was changed elsewhere since, and nothing is written, so an edit on
|
|
1241
|
+
// the phone never overwrites one made on the computer (the Mac's save safety, HQ-2049). A note
|
|
1242
|
+
// evda_read did not return whole (a sealed section cut out, or truncated for size) is refused, since
|
|
1243
|
+
// writing back what was shown would drop what was not. The machinery other tools own (the operating
|
|
1244
|
+
// layer, circles, grants, mounts, messages, the ledger, proposals, the root and dot-folders) is
|
|
1245
|
+
// refused as for evda_append; a note this caller may not read is not its to replace. A line of the
|
|
1246
|
+
// new text that was not in the old one is checked for secrets, so a note that already holds a
|
|
1247
|
+
// secret-shaped line can still be edited around it.
|
|
1248
|
+
export const versionOf = (text) => createHash("sha256").update(String(text), "utf8").digest("hex");
|
|
1249
|
+
const REPLACE_MACHINERY = ["circles", "grants", "mounts", "messages", "ledger"];
|
|
1250
|
+
export function replaceNote(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
1251
|
+
const tool = "evda_replace";
|
|
1252
|
+
onlyKeys(tool, args, ["path", "text", "base"], "It replaces one note's whole text when the note is unchanged since base, and nothing else.");
|
|
1253
|
+
if (typeof args.text !== "string") throw refuse(`${tool} needs "text", the note's whole new text.`, "BAD_ARGUMENT");
|
|
1254
|
+
if (args.text.length > MAX_READ) throw refuse(`${tool} "text" is ${args.text.length} characters; a note replaced here holds at most ${MAX_READ}.`, "TOO_LONG");
|
|
1255
|
+
if (typeof args.base !== "string" || !/^[0-9a-f]{64}$/i.test(args.base.trim())) throw refuse(`${tool} needs "base", the SHA-256 in hex of the text evda_read returned, so a note changed since is never overwritten.`, "BAD_ARGUMENT");
|
|
1256
|
+
if (typeof args.path !== "string" || args.path.trim() === "") throw refuse(`${tool} needs "path": the note, relative to the record, as evda_read gave it.`, "BAD_ARGUMENT");
|
|
1257
|
+
const want = args.path.trim();
|
|
1258
|
+
const abs = resolve(evda.dir, want);
|
|
1259
|
+
if (!abs.startsWith(evda.dir + sep)) throw refuse(`${want} is outside the record.`, "OUTSIDE");
|
|
1260
|
+
const where = rel(evda, abs);
|
|
1261
|
+
if (!/\.md$/i.test(abs)) throw refuse(`${where} is not a Markdown note; only a .md note is edited here.`, "NOT_AN_ENTRY");
|
|
1262
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) throw refuse(`no note is at ${where}; it may have been moved or deleted since it was read.`, "MISSING");
|
|
1263
|
+
const a = agentsFile(evda);
|
|
1264
|
+
if (a && resolve(a.abs) === abs) throw refuse(`${where} is the operating layer, which is edited on the computer.`, "NOT_AN_ENTRY");
|
|
1265
|
+
const parts = where.split("/");
|
|
1266
|
+
if (parts.length < 2 || parts.some((p) => p.startsWith(".")) || parts[0] === PROPOSALS) throw refuse(`${where} is one of the record's own files, not a note.`, "NOT_AN_ENTRY");
|
|
1267
|
+
for (const role of REPLACE_MACHINERY) {
|
|
1268
|
+
const r = resolveRole(evda, role);
|
|
1269
|
+
if (r.mapped && r.abs && (abs === r.abs || abs.startsWith(r.abs + sep))) throw refuse(`${where} is part of the record's own machinery, not a note.`, "NOT_AN_ENTRY");
|
|
1270
|
+
}
|
|
1271
|
+
const may = mayRead(readContext(evda), abs, caller);
|
|
1272
|
+
if (!may.ok) throw refuse(`${may.why} It is not yours to replace.`, may.kind === "sealed" ? "SEALED" : may.kind === "noted" ? "NOTED" : "NOT_THIS_READER");
|
|
1273
|
+
const raw = readFileSync(abs, "utf8");
|
|
1274
|
+
if (aiView(raw).text !== raw) throw refuse(`${where} has a sealed section, so what was read is not the whole note; edit it on the computer.`, "SEALED");
|
|
1275
|
+
if (raw.length > MAX_READ) throw refuse(`${where} is ${raw.length} characters, more than evda_read returns whole; edit it on the computer.`, "TOO_LONG");
|
|
1276
|
+
if (versionOf(raw) !== args.base.trim().toLowerCase()) throw refuse(`${where} changed since it was read, so this edit would overwrite that change. Read it again and make the edit on what is there now.`, "CHANGED");
|
|
1277
|
+
const old = new Set(raw.split("\n"));
|
|
1278
|
+
refuseSecrets(args.text.split("\n").filter((l) => !old.has(l)).join("\n"), tool);
|
|
1279
|
+
if (aiView(args.text).text !== args.text) throw refuse(`${tool}: the new text opens a sealed section, which is made on the computer.`, "SEALED");
|
|
1280
|
+
if (args.text === raw) return { path: where, changed: false, version: versionOf(raw) };
|
|
1281
|
+
writeFileSync(abs, args.text);
|
|
1282
|
+
return { path: where, changed: true, version: versionOf(args.text) };
|
|
1283
|
+
}
|
|
1284
|
+
|
|
675
1285
|
// 5b. "Just note this": the owner's words, verbatim, as a noted capture no AI reads. Refuses
|
|
676
1286
|
// anything secret-shaped, like every other write.
|
|
677
1287
|
export function noteTool(evda, args = {}, { now = new Date() } = {}) {
|