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/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,17 +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, 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 } from "./record.mjs";
|
|
32
|
-
import {
|
|
36
|
+
import { mdFiles, fileDate, headings, sectionDigest, headingDate } from "./record.mjs";
|
|
37
|
+
import { splitFence, FENCE_OPEN } from "./agents.mjs";
|
|
38
|
+
import { datalessAmong } from "./sync-rules.mjs";
|
|
39
|
+
import { readCircles, isSealed, frontmatterOf, markdownFiles } from "./circles.mjs";
|
|
33
40
|
import { secretShape } from "./references.mjs";
|
|
34
41
|
import { doctor } from "./doctor.mjs";
|
|
35
|
-
import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY } from "./readers.mjs";
|
|
42
|
+
import { readContext, mayRead, heldBack, callerFrom, aiView, note as writeNote, releaseNote, SEALED_WHY, isSealedFile, isNotedFile } from "./readers.mjs";
|
|
36
43
|
import { EvdaError } from "./errors.mjs";
|
|
37
|
-
import { dayRole, appendToDay, CONTEXT_LABEL, localDate, localTime } from "./day-file.mjs";
|
|
38
|
-
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";
|
|
46
|
+
import { acknowledge as writeAcknowledgement, readAcknowledged, acknowledgementsFor } from "./acknowledge.mjs";
|
|
47
|
+
import { openEvda, holderOf } from "./manifest.mjs";
|
|
48
|
+
import { toYaml } from "./yaml.mjs";
|
|
49
|
+
import { readEntry } from "./kinds.mjs";
|
|
50
|
+
import { correctionsTarget } from "./corrections.mjs";
|
|
51
|
+
import { slug } from "./migrate-people.mjs";
|
|
39
52
|
|
|
40
53
|
export { localDate, localTime, CONTEXT_LABEL };
|
|
41
54
|
|
|
@@ -99,12 +112,73 @@ export function operatingLayer(evda, args = {}) {
|
|
|
99
112
|
const a = agentsFile(evda);
|
|
100
113
|
if (!a) throw new EvdaError('this record maps no "agents" role, so it has no operating layer.', { code: "NO_ROLE" });
|
|
101
114
|
if (isSealed(a.abs)) throw new EvdaError(`REFUSED: ${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, { code: "SEALED", exit: 2 });
|
|
102
|
-
|
|
115
|
+
const raw = readFileSync(a.abs, "utf8");
|
|
116
|
+
const found = coreBeside(evda, a, raw);
|
|
117
|
+
return { path: a.rel, text: aiView(raw).text, core: found.core, coreNote: found.note };
|
|
103
118
|
}
|
|
104
119
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
120
|
+
// The generated core, when the agents file is only the owner layer (HQ-2203). A scaffolded record's
|
|
121
|
+
// AGENTS.md carries the `evda:generated` fence itself, and then there is nothing to add. A record
|
|
122
|
+
// that maps agents to an owner-only file (his: CLAUDE.md) keeps the core in another markdown file
|
|
123
|
+
// in the same folder (his: IRIS.md, built from the vault's template), and Claude Code loads it
|
|
124
|
+
// through its own prompt while a chat app gets only this tool. The rule is the one browse.mjs
|
|
125
|
+
// uses to show operating files: the markdown file beside the agents file that carries the fence.
|
|
126
|
+
// No name is assumed, so an owner who renamed their agent still gets theirs, and the core is read
|
|
127
|
+
// from the record, never from the server's templates. Two candidates are not guessed between:
|
|
128
|
+
// neither is served and the note names them. A sealed or noted file, or one still in the cloud
|
|
129
|
+
// (reading it would start a download), is not a candidate.
|
|
130
|
+
export function coreBeside(evda, agents, agentsText) {
|
|
131
|
+
try {
|
|
132
|
+
if (splitFence(agentsText)) return { core: null, note: null };
|
|
133
|
+
} catch {
|
|
134
|
+
return { core: null, note: null };
|
|
135
|
+
}
|
|
136
|
+
const folder = dirname(agents.abs);
|
|
137
|
+
let names;
|
|
138
|
+
try {
|
|
139
|
+
names = readdirSync(folder).filter((n) => /\.md$/i.test(n) && join(folder, n) !== agents.abs).sort();
|
|
140
|
+
} catch {
|
|
141
|
+
return { core: null, note: null };
|
|
142
|
+
}
|
|
143
|
+
const cloud = datalessAmong(folder, names);
|
|
144
|
+
const found = [];
|
|
145
|
+
for (const n of names) {
|
|
146
|
+
const abs = join(folder, n);
|
|
147
|
+
if (cloud.has(n)) continue;
|
|
148
|
+
let text;
|
|
149
|
+
try {
|
|
150
|
+
if (!statSync(abs).isFile()) continue;
|
|
151
|
+
text = readFileSync(abs, "utf8");
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!text.includes(FENCE_OPEN) || isSealedFile(abs) || isNotedFile(abs)) continue;
|
|
156
|
+
let parts;
|
|
157
|
+
try { parts = splitFence(text); } catch { continue; }
|
|
158
|
+
if (parts) found.push({ path: rel(evda, abs), text: aiView(parts.generated).text });
|
|
159
|
+
}
|
|
160
|
+
if (found.length === 1) return { core: found[0], note: null };
|
|
161
|
+
if (found.length > 1) return { core: null, note: `${found.map((f) => f.path).join(" and ")} both carry the generated core, so neither was served here. Read the one the owner means with evda_read.` };
|
|
162
|
+
return { core: null, note: null };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2. The last `days` days of the record, newest first, capped in size. A file is in the window by
|
|
166
|
+
// its date (filename, then a date or created key), the same rule `evda moment` uses.
|
|
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
|
|
175
|
+
// not part of the tool's args (onlyKeys refuses it there); it is an internal option for callers in
|
|
176
|
+
// this process, such as the evda://today resource below, that still need one file's whole text.
|
|
177
|
+
//
|
|
178
|
+
// Each file carries what an earlier reader already acknowledged in it (HQ-2060, lib/acknowledge.mjs):
|
|
179
|
+
// this is the one read path every reader uses at session start, so it is where a reader learns that
|
|
180
|
+
// a weighty entry is already handled and it should stay quiet about it.
|
|
181
|
+
export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(), full = false } = {}) {
|
|
108
182
|
onlyKeys("evda_recent", args, ["days"]);
|
|
109
183
|
const days = args.days === undefined ? 3 : Number(args.days);
|
|
110
184
|
if (!Number.isInteger(days) || days < 1 || days > 90) throw new EvdaError("evda_recent days must be a whole number from 1 to 90.", { code: "BAD_ARGUMENT", exit: 2 });
|
|
@@ -116,6 +190,7 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
|
|
|
116
190
|
const ctx = readContext(evda);
|
|
117
191
|
const refused = [];
|
|
118
192
|
let sealed = false;
|
|
193
|
+
const marks = readAcknowledged(evda);
|
|
119
194
|
for (const abs of recordFiles(evda)) {
|
|
120
195
|
const raw = readFileSync(abs, "utf8");
|
|
121
196
|
const date = fileDate(abs, raw);
|
|
@@ -124,18 +199,80 @@ export function recent(evda, args = {}, { now = new Date(), caller = callerFrom(
|
|
|
124
199
|
if (!may.ok) { refused.push(may); continue; }
|
|
125
200
|
const shown = aiView(raw);
|
|
126
201
|
if (shown.sections) sealed = true;
|
|
127
|
-
|
|
202
|
+
const path = rel(evda, abs);
|
|
203
|
+
const acknowledged = acknowledgementsFor(path, marks);
|
|
204
|
+
files.push(full
|
|
205
|
+
? { date, path, text: shown.text, acknowledged }
|
|
206
|
+
: { date, path, headings: headings(shown.text), sections: sectionDigest(shown.text), acknowledged });
|
|
128
207
|
}
|
|
129
208
|
files.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : b.date.localeCompare(a.date)));
|
|
130
209
|
let used = 0;
|
|
131
210
|
const out = [];
|
|
132
211
|
let truncated = 0;
|
|
133
212
|
for (const f of files) {
|
|
134
|
-
|
|
135
|
-
used +=
|
|
213
|
+
const weight = full ? f.text.length : f.headings.join("\n").length;
|
|
214
|
+
if (used + weight > MAX_READ) { truncated += 1; continue; }
|
|
215
|
+
used += weight;
|
|
136
216
|
out.push(f);
|
|
137
217
|
}
|
|
138
|
-
return {
|
|
218
|
+
return {
|
|
219
|
+
from,
|
|
220
|
+
to,
|
|
221
|
+
files: out,
|
|
222
|
+
omitted: truncated,
|
|
223
|
+
acknowledged: out.reduce((n, f) => n + f.acknowledged.length, 0),
|
|
224
|
+
refused: refused.filter((r) => r.kind !== "sealed"),
|
|
225
|
+
heldBack: heldBack(refused, { sealed }),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
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
|
+
|
|
263
|
+
// 2a. Record that this reader acted on one weighty entry, so no other reader acts on it again
|
|
264
|
+
// (HQ-2060). The first reader to reach it writes the mark; a later one gets first: false and nothing
|
|
265
|
+
// is written. Weight is the reader's judgment; this only remembers that one of them already did it.
|
|
266
|
+
export function acknowledgeEntry(evda, args = {}, { now = new Date() } = {}) {
|
|
267
|
+
onlyKeys("evda_acknowledge", args, ["path", "section", "note", "by"], "It writes one mark beside the record and nothing else: it never edits the entry, and it never writes over a mark another reader left.");
|
|
268
|
+
return writeAcknowledgement(evda, args, { now });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 2b. Every mark the record holds, for a reader that wants them without the files.
|
|
272
|
+
export function acknowledgements(evda, args = {}) {
|
|
273
|
+
onlyKeys("evda_acknowledgements", args, []);
|
|
274
|
+
const all = readAcknowledged(evda);
|
|
275
|
+
return { count: Object.keys(all).length, marks: Object.entries(all).map(([key, m]) => ({ key, ...m })) };
|
|
139
276
|
}
|
|
140
277
|
|
|
141
278
|
// Every markdown file the record holds: each record-layer role, and the imports folder. Not the
|
|
@@ -155,6 +292,12 @@ function recordFiles(evda) {
|
|
|
155
292
|
return out;
|
|
156
293
|
}
|
|
157
294
|
|
|
295
|
+
// Today's daily file, relative to the record, whether or not it exists yet: the one `write` appends
|
|
296
|
+
// to. The app's Today's Journal (HQ-2038) shows it, and an empty page when it is not written yet.
|
|
297
|
+
export function todayPath(evda, { now = new Date() } = {}) {
|
|
298
|
+
return rel(evda, join(dailyDir(evda).abs, `${localDate(now)}.md`));
|
|
299
|
+
}
|
|
300
|
+
|
|
158
301
|
// 3. Append the owner's words, verbatim, to today's daily file. Creates the file with a date heading
|
|
159
302
|
// when it is the first write of the day. Returns the path.
|
|
160
303
|
// `context` (HQ-1377) is one line on what the conversation was about, in the agent's words, written
|
|
@@ -162,25 +305,179 @@ function recordFiles(evda) {
|
|
|
162
305
|
// `session` (HQ-1385) is the conversation: its first write opens `## HH:MM` with the context line,
|
|
163
306
|
// and later writes append under it (lib/day-file.mjs, the one append path).
|
|
164
307
|
export const CONTEXT_MAX = 240;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
308
|
+
// `toolName` (HQ-2164) is what an error names itself as: evda_write when called directly, or
|
|
309
|
+
// another tool's own name when it reuses this same append-only logic against a different day
|
|
310
|
+
// file (evda_write_agent_log, against Iris's own sub-record). The behavior is identical either
|
|
311
|
+
// way; only what a refusal calls itself changes.
|
|
312
|
+
export function write(evda, args = {}, { now = new Date(), session = null, toolName = "evda_write" } = {}) {
|
|
313
|
+
onlyKeys(toolName, args, ["words", "context"], "It appends to today's daily file and nothing else: it takes no path, and it never replaces or edits what is already written.");
|
|
314
|
+
const words = text(args.words, "words", toolName);
|
|
315
|
+
refuseSecrets(words, toolName);
|
|
169
316
|
let context = null;
|
|
170
317
|
if (args.context !== undefined) {
|
|
171
|
-
if (typeof args.context !== "string" || args.context.trim() === "") throw refuse(
|
|
172
|
-
if (/\n/.test(args.context.trim())) throw refuse(
|
|
173
|
-
if (args.context.trim().length > CONTEXT_MAX) throw refuse(
|
|
174
|
-
refuseSecrets(args.context,
|
|
318
|
+
if (typeof args.context !== "string" || args.context.trim() === "") throw refuse(`${toolName} "context" is one line on what the conversation was about; leave it out rather than send it empty.`, "EMPTY");
|
|
319
|
+
if (/\n/.test(args.context.trim())) throw refuse(`${toolName} "context" is one line. Their words go in "words"; the context is a single line on what the conversation was about.`, "CONTEXT_LINES");
|
|
320
|
+
if (args.context.trim().length > CONTEXT_MAX) throw refuse(`${toolName} "context" is ${args.context.trim().length} characters; one line of context is at most ${CONTEXT_MAX}.`, "TOO_LONG");
|
|
321
|
+
refuseSecrets(args.context, toolName);
|
|
175
322
|
context = args.context.trim();
|
|
176
323
|
}
|
|
177
|
-
const
|
|
178
|
-
mkdirSync(
|
|
179
|
-
|
|
324
|
+
const abs = join(evda.dir, todayPath(evda, { now }));
|
|
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
|
+
}
|
|
180
335
|
const w = appendToDay(abs, { now, words, context, session });
|
|
181
336
|
return { path: rel(evda, abs), bytes: Buffer.byteLength(words), section: w.opened ? "opened" : "continued" };
|
|
182
337
|
}
|
|
183
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
|
+
|
|
372
|
+
// 3b. What `evda mcp`'s evda_write tool actually calls (HQ-2165). `write` above already appended
|
|
373
|
+
// the words; this reads them back after a short real delay and refuses to report success if they
|
|
374
|
+
// are gone, so a caller that overwrote the file in that gap is caught instead of the agent telling
|
|
375
|
+
// the owner "Filed" about words that are no longer there (the night of 2026-10-03: the connector's
|
|
376
|
+
// evda_write reported Filed at 03:40:46Z and the owner's words were gone by the time anyone looked).
|
|
377
|
+
//
|
|
378
|
+
// What this catches: a same-host writer (Hestia's journal-append.mjs doing a read-modify-write of
|
|
379
|
+
// the whole file) that lands in the window between this function's append and its own read-back.
|
|
380
|
+
// What this CANNOT catch: two different machines' local replicas of this file diverging and iCloud
|
|
381
|
+
// picking one wholesale, which can resolve on any timescale and is not bounded by a short delay.
|
|
382
|
+
// That class is why every writer but one host must stop touching this file's local copy at all --
|
|
383
|
+
// see test/single-writer.test.mjs. This is a backstop for the class it can catch, not the fix for
|
|
384
|
+
// the class that caused the incident; HQ-2165's "Done when" is the single-writer change, not this.
|
|
385
|
+
const VERIFY_DELAY_MS = 400;
|
|
386
|
+
const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
387
|
+
export async function writeVerified(evda, args = {}, { verifyDelayMs = VERIFY_DELAY_MS, ...rest } = {}) {
|
|
388
|
+
const r = write(evda, args, rest);
|
|
389
|
+
if (r.queued) return r; // queued, not written: nothing in the day to read back
|
|
390
|
+
const abs = join(evda.dir, r.path);
|
|
391
|
+
if (verifyDelayMs > 0) await sleep(verifyDelayMs);
|
|
392
|
+
const expect = String(args.words).replace(/\n+$/, "");
|
|
393
|
+
const after = existsSync(abs) ? readFileSync(abs, "utf8") : "";
|
|
394
|
+
if (!after.includes(expect)) {
|
|
395
|
+
throw new EvdaError(
|
|
396
|
+
`evda_write: ${r.path} was written, then read back after ${verifyDelayMs}ms and the words were gone -- `
|
|
397
|
+
+ "another writer overwrote the file in between. Tell the owner the write FAILED; never say \"Filed\" "
|
|
398
|
+
+ "about this call.",
|
|
399
|
+
{ code: "LOST_WRITE", exit: 1 },
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
return r;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 3a. Append one dated correction to AGENTS.md's "What I have been taught" (HQ-1997). That section
|
|
406
|
+
// is always outside the installer's fence (Spec 05 ruling 2, lib/agents.mjs splitFence), so a write
|
|
407
|
+
// here never touches the generated block and survives `evda upgrade` untouched, the same way any
|
|
408
|
+
// other owner edit below the fence does. Append only: an earlier entry is never edited or removed;
|
|
409
|
+
// a correction that reverses one is a new entry with "supersedes" naming the date of the one it
|
|
410
|
+
// replaces, exactly as the section's own instructions ask.
|
|
411
|
+
const CHANGE_MAX = 400;
|
|
412
|
+
const WORDS_MAX = 400;
|
|
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.");
|
|
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");
|
|
422
|
+
const change = args.change.trim();
|
|
423
|
+
if (/\n/.test(change)) throw refuse('evda_record_correction "change" is one line.', "MULTILINE");
|
|
424
|
+
if (change.length > CHANGE_MAX) throw refuse(`evda_record_correction "change" is ${change.length} characters; at most ${CHANGE_MAX}.`, "TOO_LONG");
|
|
425
|
+
refuseSecrets(change, "evda_record_correction");
|
|
426
|
+
let words = null;
|
|
427
|
+
if (args.words !== undefined) {
|
|
428
|
+
if (typeof args.words !== "string" || args.words.trim() === "") throw refuse('evda_record_correction "words" is the owner\'s exact words; leave it out rather than send it empty.', "EMPTY");
|
|
429
|
+
words = args.words.trim();
|
|
430
|
+
if (/\n/.test(words)) throw refuse('evda_record_correction "words" is one line.', "MULTILINE");
|
|
431
|
+
if (words.length > WORDS_MAX) throw refuse(`evda_record_correction "words" is ${words.length} characters; at most ${WORDS_MAX}.`, "TOO_LONG");
|
|
432
|
+
refuseSecrets(words, "evda_record_correction");
|
|
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
|
+
}
|
|
442
|
+
let supersedes = null;
|
|
443
|
+
if (args.supersedes !== undefined) {
|
|
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");
|
|
445
|
+
supersedes = args.supersedes.trim();
|
|
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
|
+
|
|
464
|
+
const a = agentsFile(evda);
|
|
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");
|
|
466
|
+
if (isSealed(a.abs)) throw refuse(`${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
|
|
467
|
+
const raw = readFileSync(a.abs, "utf8");
|
|
468
|
+
const parts = splitFence(raw);
|
|
469
|
+
const owner = parts ? parts.after : raw;
|
|
470
|
+
if (!/(^|\n)## What I have been taught(\n|$)/.test(owner)) {
|
|
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");
|
|
472
|
+
}
|
|
473
|
+
const line = `- ${date}: ${title ? `${title}. ` : ""}${said}${change}${supersededNote}\n`;
|
|
474
|
+
const lineSep = owner.endsWith("\n") ? "" : "\n";
|
|
475
|
+
const newOwner = owner + lineSep + line;
|
|
476
|
+
const newRaw = parts ? parts.before + parts.generated + newOwner : newOwner;
|
|
477
|
+
writeFileSync(a.abs, newRaw);
|
|
478
|
+
return { path: a.rel, date, section: "What I have been taught" };
|
|
479
|
+
}
|
|
480
|
+
|
|
184
481
|
// The day file for a date, as { abs, rel }, for writers that are not evda_write (the chat-export
|
|
185
482
|
// backstop in `evda gather`). Throws when the record maps no day role.
|
|
186
483
|
export function dayFile(evda, date) {
|
|
@@ -188,6 +485,661 @@ export function dayFile(evda, date) {
|
|
|
188
485
|
return { abs: join(r.abs, `${date}.md`), rel: rel(evda, join(r.abs, `${date}.md`)), dir: r.abs };
|
|
189
486
|
}
|
|
190
487
|
|
|
488
|
+
// 3b. Iris's own day log (HQ-2164): a fixed, named sub-record, exactly like AGENTS.md's "What I
|
|
489
|
+
// have been taught" is a fixed, named section. Not a general path: this is the one place
|
|
490
|
+
// evda_write_agent_log is ever allowed to write, and it is reused unchanged from `write` above
|
|
491
|
+
// against that sub-record's own evda.json, so a section there opens and continues by the same
|
|
492
|
+
// one-conversation rule as the owner's own daily file.
|
|
493
|
+
const AGENT_LOG_REL = "01_Projects/Evda/Iris - Universal Thinking Partner/memory";
|
|
494
|
+
function agentLogRecord(evda) {
|
|
495
|
+
const abs = join(evda.dir, AGENT_LOG_REL);
|
|
496
|
+
if (!existsSync(join(abs, "evda.json"))) {
|
|
497
|
+
throw refuse(`${AGENT_LOG_REL} has no evda.json, so there is no agent log sub-record to write into.`, "NO_ROLE");
|
|
498
|
+
}
|
|
499
|
+
return openEvda(abs);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Whether this record has the agent log sub-record. A chat app is offered evda_write_agent_log only
|
|
503
|
+
// when it does (HQ-2164), so a chat is never handed a tool that can only refuse.
|
|
504
|
+
export function hasAgentLog(evda) {
|
|
505
|
+
return existsSync(join(evda.dir, AGENT_LOG_REL, "evda.json"));
|
|
506
|
+
}
|
|
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.
|
|
514
|
+
export function writeAgentLog(evda, args = {}, { now = new Date(), session = null } = {}) {
|
|
515
|
+
const sub = agentLogRecord(evda);
|
|
516
|
+
const r = write(sub, args, { now, session, toolName: "evda_write_agent_log" });
|
|
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
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 3c. Append one dated section to an EXISTING person page (HQ-2164), in the evda_record_correction
|
|
564
|
+
// shape: one named target class (record/people), resolved by name rather than by path, and append
|
|
565
|
+
// only. Never creates a page (CLAUDE.md: "Only docs that already exist. Do not auto-create a new
|
|
566
|
+
// profile mid-conversation; offer it."); refuses a sealed page, and refuses when the name matches
|
|
567
|
+
// more than one page rather than guessing. When the page's frontmatter is `kind: person-profile`,
|
|
568
|
+
// the new section also gets a matching `observations` entry (CLAUDE.md / doctor's HQ-1397 rule:
|
|
569
|
+
// "A People doc with kind: person-profile frontmatter gets an observation for each new dated
|
|
570
|
+
// section"), so this write can never silently leave a profile failing that check. Everything else
|
|
571
|
+
// in the file, including the rest of the frontmatter, is byte for byte what it was.
|
|
572
|
+
const PERSON_CONTEXT_MAX = CONTEXT_MAX;
|
|
573
|
+
function peopleDir(evda) {
|
|
574
|
+
const r = resolveRole(evda, "record/people");
|
|
575
|
+
if (!r.mapped) throw refuse('this record maps no "record/people" role, so it has no person pages to write into.', "NO_ROLE");
|
|
576
|
+
if (!r.exists || !r.isDir) throw refuse(`${r.rel} is mapped as "record/people" but is not a folder.`, "NO_ROLE");
|
|
577
|
+
return r;
|
|
578
|
+
}
|
|
579
|
+
|
|
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);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function recordPersonNote(evda, args = {}, { now = new Date(), caller = callerFrom() } = {}) {
|
|
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.");
|
|
659
|
+
if (typeof args.person !== "string" || args.person.trim() === "") throw refuse('evda_record_person_note needs "person": the name on their page.', "NO_PERSON");
|
|
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");
|
|
661
|
+
const context = args.context.trim();
|
|
662
|
+
if (/\n/.test(context)) throw refuse('evda_record_person_note "context" is one line.', "MULTILINE");
|
|
663
|
+
if (context.length > PERSON_CONTEXT_MAX) throw refuse(`evda_record_person_note "context" is ${context.length} characters; at most ${PERSON_CONTEXT_MAX}.`, "TOO_LONG");
|
|
664
|
+
refuseSecrets(context, "evda_record_person_note");
|
|
665
|
+
const words = text(args.words, "words", "evda_record_person_note");
|
|
666
|
+
refuseSecrets(words, "evda_record_person_note");
|
|
667
|
+
|
|
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");
|
|
671
|
+
|
|
672
|
+
const abs = matches[0];
|
|
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");
|
|
676
|
+
|
|
677
|
+
const raw = readFileSync(abs, "utf8");
|
|
678
|
+
const date = localDate(now);
|
|
679
|
+
const heading = `## ${context} (added ${date})`;
|
|
680
|
+
const lead = raw.endsWith("\n") ? "" : "\n";
|
|
681
|
+
const section = `${lead}\n${heading}\n\n${words.trim()}\n`;
|
|
682
|
+
|
|
683
|
+
const fm = frontmatterOf(raw);
|
|
684
|
+
let newRaw = raw + section;
|
|
685
|
+
let observationId = null;
|
|
686
|
+
if (fm && fm.kind === "person-profile") {
|
|
687
|
+
const lines = raw.split("\n");
|
|
688
|
+
const end = lines.indexOf("---", 1);
|
|
689
|
+
const body = lines.slice(end + 1).join("\n");
|
|
690
|
+
const obs = Array.isArray(fm.observations) ? fm.observations : [];
|
|
691
|
+
const stamp = `${date}-${localTime(now).replace(":", "")}`;
|
|
692
|
+
const ids = new Set(obs.map((o) => o.id));
|
|
693
|
+
let id = stamp;
|
|
694
|
+
for (let n = 2; ids.has(id); n++) id = `${stamp}-${n}`;
|
|
695
|
+
const observation = { id, since: date, text: context };
|
|
696
|
+
const nextFm = { ...fm, observations: [...obs, observation] };
|
|
697
|
+
const owner = holderOf(evda).id ?? holderOf(evda).name;
|
|
698
|
+
const before = readEntry("person-profile", fm, { owner });
|
|
699
|
+
const after = readEntry("person-profile", nextFm, { owner });
|
|
700
|
+
const introduced = after.dto === null ? [after.problems[0]] : after.problems.filter((p) => !before.problems.includes(p));
|
|
701
|
+
if (introduced.length) throw refuse(`writing this observation into ${rel(evda, abs)} would leave its frontmatter invalid: ${introduced[0]}`, "BAD_PROFILE");
|
|
702
|
+
const newFrontmatter = `---\n${toYaml(nextFm)}---\n`;
|
|
703
|
+
newRaw = newFrontmatter + body + section;
|
|
704
|
+
observationId = id;
|
|
705
|
+
}
|
|
706
|
+
writeFileSync(abs, newRaw);
|
|
707
|
+
return { path: rel(evda, abs), date, observation: observationId };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// 3d. "Entries grow on their own" (HQ-2228): one dated section on an entry that already exists, by
|
|
711
|
+
// its path. In a chat app this is the only way the core's rule can be carried out, since the
|
|
712
|
+
// connector is the only thing that writes. It never creates a file and never touches a byte already
|
|
713
|
+
// there: the section goes at the end, `## YYYY-MM-DD`, and a second append the same day continues
|
|
714
|
+
// that section instead of opening a second heading for the day. Who may write follows who may read
|
|
715
|
+
// (lib/readers.mjs mayRead): a sealed entry, a noted one and a part read by a different AI are each
|
|
716
|
+
// refused, since the core says those are not the agent's to add to. The files other tools own are
|
|
717
|
+
// refused with the tool that does it: the operating layer, the day's file, a person's page, and the
|
|
718
|
+
// record's own machinery (circles, grants, mounts, messages, the ledger, proposals, anything at the
|
|
719
|
+
// root or in a dot-folder, which is where skills live).
|
|
720
|
+
const APPEND_ELSEWHERE = [
|
|
721
|
+
["record/daily", "is the day's file: the owner's words go there with evda_write."],
|
|
722
|
+
["record/people", "is a person's page: add to it with evda_record_person_note, which also keeps its observations in step."],
|
|
723
|
+
["circles", "is part of how the record is shared, which only the owner changes."],
|
|
724
|
+
["grants", "is part of how the record is shared, which only the owner changes."],
|
|
725
|
+
["mounts", "is part of how the record is shared, which only the owner changes."],
|
|
726
|
+
["messages", "is a message file, which evda_message and the phone write."],
|
|
727
|
+
["ledger", "is the hypothesis ledger, which has its own format and tools."],
|
|
728
|
+
];
|
|
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);
|
|
738
|
+
const abs = resolve(evda.dir, want);
|
|
739
|
+
if (!abs.startsWith(evda.dir + sep)) throw refuse(`${want} is outside the record.`, "OUTSIDE");
|
|
740
|
+
const where = rel(evda, abs);
|
|
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");
|
|
744
|
+
const a = agentsFile(evda);
|
|
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");
|
|
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");
|
|
749
|
+
for (const [role, why] of APPEND_ELSEWHERE) {
|
|
750
|
+
const r = resolveRole(evda, role);
|
|
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");
|
|
753
|
+
}
|
|
754
|
+
const may = mayRead(readContext(evda), abs, caller);
|
|
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);
|
|
830
|
+
const raw = readFileSync(abs, "utf8");
|
|
831
|
+
const fm = frontmatterOf(raw);
|
|
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");
|
|
833
|
+
const date = localDate(now);
|
|
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}`;
|
|
849
|
+
const lastHeading = [...raw.matchAll(/^## .*$/gm)].pop()?.[0]?.trim();
|
|
850
|
+
const continued = !title && lastHeading === heading;
|
|
851
|
+
const lead = raw === "" || raw.endsWith("\n") ? "" : "\n";
|
|
852
|
+
const add = continued ? `${lead}\n${words.trim()}\n` : `${lead}${raw === "" ? "" : "\n"}${heading}\n\n${words.trim()}\n`;
|
|
853
|
+
appendFileSync(abs, add);
|
|
854
|
+
return { path: where, date, section: continued ? "continued" : "opened" };
|
|
855
|
+
}
|
|
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
|
+
|
|
1091
|
+
// 3e. The owner's setup answers (HQ-2228), into AGENTS.md's "What this EVÐA is", in the
|
|
1092
|
+
// evda_record_correction shape: one named section, one named line, and nothing else in the file
|
|
1093
|
+
// changes. The core's setup step asks each answer to be written there "in their exact words"; a chat
|
|
1094
|
+
// app had no tool to do it. A field is written once, when it is empty: a filled one is the owner's
|
|
1095
|
+
// to change, in their own file, so the agent never rewrites what they said.
|
|
1096
|
+
export const SETUP_FIELDS = ["Agent name", "Who I am", "What I want this for", "How I want it to talk to me", "The AI I use with it, and its training setting"];
|
|
1097
|
+
const SETUP_HEADING = /^## What this EV(?:Ð|D)A is[ \t]*$/m;
|
|
1098
|
+
export function setupAnswer(evda, args = {}) {
|
|
1099
|
+
onlyKeys("evda_setup_answer", args, ["field", "words"], "It fills one empty field of \"What this EVÐA is\" and nothing else.");
|
|
1100
|
+
if (!SETUP_FIELDS.includes(args.field)) throw refuse(`evda_setup_answer "field" is one of ${SETUP_FIELDS.map((f) => JSON.stringify(f)).join(", ")}.`, "BAD_FIELD");
|
|
1101
|
+
if (typeof args.words !== "string" || args.words.trim() === "") throw refuse('evda_setup_answer needs "words": the owner\'s answer, exactly as they said it.', "EMPTY");
|
|
1102
|
+
const words = args.words.trim();
|
|
1103
|
+
if (/\n/.test(words)) throw refuse('evda_setup_answer "words" is one line: a field holds one line of their answer.', "MULTILINE");
|
|
1104
|
+
if (words.length > WORDS_MAX) throw refuse(`evda_setup_answer "words" is ${words.length} characters; at most ${WORDS_MAX}.`, "TOO_LONG");
|
|
1105
|
+
refuseSecrets(words, "evda_setup_answer");
|
|
1106
|
+
const a = agentsFile(evda);
|
|
1107
|
+
if (!a) throw refuse('this record maps no "agents" role, so it has no AGENTS.md to write the answer into.', "NO_ROLE");
|
|
1108
|
+
if (isSealed(a.abs)) throw refuse(`${a.rel} ${SEALED_WHY.replace(/^It /, "is ")}`, "SEALED");
|
|
1109
|
+
const raw = readFileSync(a.abs, "utf8");
|
|
1110
|
+
// Only the owner's text: the installer's generated block is never written.
|
|
1111
|
+
const parts = splitFence(raw);
|
|
1112
|
+
const pieces = parts ? [["before", parts.before], ["after", parts.after]] : [["whole", raw]];
|
|
1113
|
+
for (const [name, piece] of pieces) {
|
|
1114
|
+
const h = SETUP_HEADING.exec(piece);
|
|
1115
|
+
if (!h) continue;
|
|
1116
|
+
const start = h.index + h[0].length;
|
|
1117
|
+
const next = piece.slice(start).search(/^## /m);
|
|
1118
|
+
const end = next < 0 ? piece.length : start + next;
|
|
1119
|
+
const section = piece.slice(start, end);
|
|
1120
|
+
const label = args.field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1121
|
+
const line = new RegExp(`^- \\*\\*${label}:\\*\\*([ \\t]*)(.*)$`, "m").exec(section);
|
|
1122
|
+
if (!line) throw refuse(`${a.rel}'s "What this EVÐA is" has no "${args.field}" line. Add "- **${args.field}:**" there first (templates/AGENTS.md has the wording).`, "NO_FIELD");
|
|
1123
|
+
if (line[2].trim() !== "") throw refuse(`"${args.field}" already reads: ${line[2].trim()}. A filled field is the owner's to change, in their own file.`, "ALREADY_SET");
|
|
1124
|
+
const at = start + line.index;
|
|
1125
|
+
const newPiece = `${piece.slice(0, at)}- **${args.field}:** ${words}${piece.slice(at + line[0].length)}`;
|
|
1126
|
+
const newRaw = !parts ? newPiece : name === "before" ? newPiece + parts.generated + parts.after : parts.before + parts.generated + newPiece;
|
|
1127
|
+
writeFileSync(a.abs, newRaw);
|
|
1128
|
+
return { path: a.rel, field: args.field };
|
|
1129
|
+
}
|
|
1130
|
+
throw refuse(`${a.rel} has no "## What this EVÐA is" section outside the generated block. Add it first (templates/AGENTS.md has the wording).`, "NO_SECTION");
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Whether setup is unfinished: some field of "What this EVÐA is" is there and empty.
|
|
1134
|
+
export function setupPending(agentsText) {
|
|
1135
|
+
const h = SETUP_HEADING.exec(agentsText ?? "");
|
|
1136
|
+
if (!h) return false;
|
|
1137
|
+
const rest = agentsText.slice(h.index + h[0].length);
|
|
1138
|
+
const next = rest.search(/^## /m);
|
|
1139
|
+
const section = next < 0 ? rest : rest.slice(0, next);
|
|
1140
|
+
return SETUP_FIELDS.some((f) => new RegExp(`^- \\*\\*${f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\*\\*[ \\t]*$`, "m").test(section));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
191
1143
|
// 4. File what another AI said about the owner, verbatim, under imports/, labeled with its source
|
|
192
1144
|
// and date. A second import from the same source on the same day is appended to that file.
|
|
193
1145
|
export function importText(evda, args = {}, { now = new Date() } = {}) {
|
|
@@ -231,8 +1183,7 @@ export function search(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
|
231
1183
|
const files = recordFiles(evda);
|
|
232
1184
|
const ledger = resolveRole(evda, "ledger");
|
|
233
1185
|
if (ledger.mapped && ledger.fileExists) files.push(ledger.file);
|
|
234
|
-
const
|
|
235
|
-
let more = 0;
|
|
1186
|
+
const found = [];
|
|
236
1187
|
const ctx = readContext(evda);
|
|
237
1188
|
const refused = [];
|
|
238
1189
|
// Whether any searched file is sealed or holds a sealed section, decided without the query, so
|
|
@@ -251,12 +1202,22 @@ export function search(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
|
251
1202
|
const shown = aiView(raw);
|
|
252
1203
|
if (shown.sections) sealed = true;
|
|
253
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;
|
|
254
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);
|
|
255
1212
|
if (!line.toLowerCase().includes(needle)) return;
|
|
256
|
-
|
|
257
|
-
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 });
|
|
258
1214
|
});
|
|
259
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;
|
|
260
1221
|
return { query: args.query, searched: files.length, matches, more, refused, heldBack: heldBack(refused, { sealed }) };
|
|
261
1222
|
}
|
|
262
1223
|
|
|
@@ -274,6 +1235,53 @@ export function readFile(evda, args = {}, { caller = callerFrom() } = {}) {
|
|
|
274
1235
|
return { path: rel(evda, abs), text, truncated: false };
|
|
275
1236
|
}
|
|
276
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
|
+
|
|
277
1285
|
// 5b. "Just note this": the owner's words, verbatim, as a noted capture no AI reads. Refuses
|
|
278
1286
|
// anything secret-shaped, like every other write.
|
|
279
1287
|
export function noteTool(evda, args = {}, { now = new Date() } = {}) {
|
|
@@ -298,7 +1306,8 @@ export function releaseTool(evda, args = {}, { now = new Date() } = {}) {
|
|
|
298
1306
|
}
|
|
299
1307
|
|
|
300
1308
|
// 5d. Tell the owner something (HQ-1246): one new message file under the messages role, which the
|
|
301
|
-
// phone app shows as a card
|
|
1309
|
+
// phone app shows as a card once paired and synced -- this call never confirms that part (HQ-2161).
|
|
1310
|
+
// Never edits one; refuses anything secret-shaped, like every write.
|
|
302
1311
|
export function message(evda, args = {}, { now = new Date() } = {}) {
|
|
303
1312
|
onlyKeys("evda_message", args, ["title", "body", "from"], "It writes one new message for the owner and nothing else: it takes no path, and it never edits a message once written.");
|
|
304
1313
|
return writeMessage(evda, args, { now, refuseSecrets });
|