ultimate-brain 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.agents/skills/brain-apply/SKILL.md +35 -22
  2. package/.agents/skills/brain-catch-up/SKILL.md +51 -36
  3. package/.agents/skills/brain-connect-notion/SKILL.md +29 -2
  4. package/.agents/skills/brain-howto/SKILL.md +1 -1
  5. package/.agents/skills/brain-howto/references/guide.md +117 -48
  6. package/.agents/skills/brain-ingest/SKILL.md +14 -6
  7. package/.agents/skills/brain-ingest/references/cli-inputs.md +31 -5
  8. package/.agents/skills/brain-ingest/references/extraction.md +36 -1
  9. package/.agents/skills/brain-ingest/references/notion.md +8 -6
  10. package/.agents/skills/brain-ingest/references/sources.md +36 -14
  11. package/.agents/skills/brain-notion-change/SKILL.md +2 -0
  12. package/.agents/skills/brain-query/SKILL.md +18 -8
  13. package/.agents/skills/brain-sync/SKILL.md +31 -12
  14. package/.agents/skills/brain-sync/references/formats.md +28 -1
  15. package/dist/cli.js +5 -4
  16. package/dist/commands/modules.js +54 -0
  17. package/dist/commands/report.js +81 -4
  18. package/dist/commands/source.js +27 -35
  19. package/dist/commands/status.js +5 -5
  20. package/dist/commands/sync.js +3 -1
  21. package/dist/commands/upgrade.js +33 -18
  22. package/dist/lib/apply.js +237 -33
  23. package/dist/lib/diff.js +31 -0
  24. package/dist/lib/errors.js +2 -0
  25. package/dist/lib/ids.js +2 -0
  26. package/dist/lib/index-builder.js +6 -1
  27. package/dist/lib/lint.js +46 -8
  28. package/dist/lib/modules.js +91 -0
  29. package/dist/lib/note-new.js +111 -18
  30. package/dist/lib/paths.js +1 -0
  31. package/dist/lib/proposals.js +74 -18
  32. package/dist/lib/query.js +1 -1
  33. package/dist/lib/schemas.js +1 -1
  34. package/dist/lib/sync.js +246 -86
  35. package/package.json +1 -1
  36. package/spec/schemas/brain.schema.json +36 -0
  37. package/spec/schemas/note.schema.json +105 -18
  38. package/templates/brain/brain.json +3 -1
  39. package/templates/brain/knowledge/references/.gitkeep +0 -0
package/dist/lib/sync.js CHANGED
@@ -1,19 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
- import { applyAll, EDITABLE } from "./apply.js";
3
+ import { applyAll } from "./apply.js";
4
4
  import { BrainError } from "./errors.js";
5
5
  import { assertNoProblems, isRecord, unknownKeys } from "./input.js";
6
6
  import { generateId, TYPE_PREFIX } from "./ids.js";
7
7
  import { assertStagedValid, checkNoteInput, stageNotes } from "./note-new.js";
8
8
  import { compareCodePoints } from "./notes.js";
9
- import { fullText, splitParts } from "./parts.js";
9
+ import { splitParts } from "./parts.js";
10
10
  import { loadSchemas } from "./schemas.js";
11
11
  import { formatDate, formatTimestamp, normalizeTimestamp } from "./time.js";
12
- const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "review", "inbox"];
12
+ import { lineDiff } from "./diff.js";
13
+ import { databaseId, moduleForDatabase, modulesOf, typeIsOn } from "./modules.js";
14
+ import { bodyHash, bodyOf } from "./proposals.js";
15
+ const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "documents", "review", "inbox"];
13
16
  const CREATABLE = new Set(["areas", "goals", "projects", "people", "orgs"]);
14
- const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", review: "proposal" };
15
- const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", source: "inbox", meeting: "meetings", proposal: "review" };
16
- const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "source", "meeting", "proposal"]);
17
+ const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", documents: "reference", review: "proposal" };
18
+ const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", reference: "documents", source: "inbox", meeting: "meetings", proposal: "review" };
19
+ const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "reference", "source", "meeting", "proposal"]);
17
20
  function property(value, key) {
18
21
  return Object.hasOwn(value, key) ? value[key] : undefined;
19
22
  }
@@ -58,6 +61,8 @@ export function parseSnapshot(input) {
58
61
  if (!isRecord(value))
59
62
  return problems.push({ pointer: p, message: "must be an object" });
60
63
  problems.push(...unknownKeys(value, ["database", "page_id", "last_edited", "properties"], p));
64
+ if (value.database === "references")
65
+ value.database = "documents"; // read as documents (0049)
61
66
  if (!DATABASES.includes(value.database))
62
67
  problems.push({ pointer: `${p}/database`, message: "is not a sync database" });
63
68
  if (typeof value.page_id !== "string" || value.page_id === "")
@@ -72,7 +77,7 @@ export function parseSnapshot(input) {
72
77
  }
73
78
  /** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
74
79
  export function buildPlan(workspace, snapshot) {
75
- const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], conflicts: [], ignored: [] };
80
+ const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], document_requests: [], promote_requests: [], refresh_requests: [], report_fixes: [], conflicts: [], ignored: [] };
76
81
  const createsByPage = new Map();
77
82
  for (const row of snapshot.rows)
78
83
  if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
@@ -80,7 +85,14 @@ export function buildPlan(workspace, snapshot) {
80
85
  if (existing === undefined)
81
86
  createsByPage.set(row.page_id, { id: generateId(TYPE_PREFIX[TYPE_FOR_DATABASE[row.database]], new Set([...workspace.ids(), ...[...createsByPage.values()].map((x) => x.id)])), type: TYPE_FOR_DATABASE[row.database] });
82
87
  }
88
+ const modules = modulesOf(workspace.config);
83
89
  for (const row of snapshot.rows) {
90
+ // A database whose module is off is not read at all (cli §5.14.2, 0048).
91
+ const module = moduleForDatabase(row.database);
92
+ if (module !== null && !modules[module]) {
93
+ result.ignored.push(ignoredRow(row, "MODULE_OFF"));
94
+ continue;
95
+ }
84
96
  if (row.database === "key_results") {
85
97
  planKeyResult(workspace, snapshot.as_of, row, result);
86
98
  continue;
@@ -112,6 +124,8 @@ export function buildPlan(workspace, snapshot) {
112
124
  // A create whose publish failed has no Brain ID in Notion yet; treat the row as if it had it (cli §5.14.2).
113
125
  const known = typeof brainId === "string" && brainId !== "" ? row : { ...row, properties: { ...row.properties, "Brain ID": note.id } };
114
126
  planExisting(workspace, snapshot.as_of, known, note, result);
127
+ if ((row.database === "decisions" || row.database === "concepts") && property(row.properties, "Promote asked") === true)
128
+ planPromote(workspace, row, note, modules.documents, result);
115
129
  }
116
130
  sortPlan(result);
117
131
  return result;
@@ -197,8 +211,9 @@ function planExisting(workspace, asOf, row, note, out) {
197
211
  const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : undefined;
198
212
  const rendered = renderedBrainValues(note, workspace);
199
213
  const publish = (name, value) => { updates.published = { ...(isRecord(updates.published) ? updates.published : {}), [name]: value }; };
214
+ const off = offRelations(workspace.config, row.database);
200
215
  for (const [name, brainField] of brainProperties(row.database)) {
201
- if (!Object.hasOwn(row.properties, name))
216
+ if (!Object.hasOwn(row.properties, name) || off.has(name))
202
217
  continue;
203
218
  if (name === "Key Facts") {
204
219
  planKeyFacts(row, note, published, updates, out);
@@ -296,52 +311,116 @@ function planKeyFacts(row, note, published, updates, out) {
296
311
  updates.published = { ...(isRecord(updates.published) ? updates.published : {}), "Key Facts": text };
297
312
  }
298
313
  function planDecision(row, proposal, out) {
299
- const decision = property(row.properties, "Decision");
300
- if (decision === undefined || property(row.properties, "State") !== "Pending")
314
+ // A snapshot taken before migration 0009 has Decision and Note; Edit there is read as Fix (cli §5.14.2, 0045).
315
+ const call = property(row.properties, "Your call") ?? property(row.properties, "Decision");
316
+ const label = Object.hasOwn(row.properties, "Your call") ? "Your call" : "Decision";
317
+ const textLabel = Object.hasOwn(row.properties, "What to fix") || !Object.hasOwn(row.properties, "Note") ? "What to fix" : "Note";
318
+ if (call === undefined || call === null || property(row.properties, "State") !== "Pending")
301
319
  return;
302
320
  if (["applied", "closed"].includes(String(proposal.attributes.status))) {
303
321
  out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "ALREADY_TERMINAL" });
304
322
  return;
305
323
  }
306
- const action = String(decision).toLowerCase();
307
- const note = property(row.properties, "Note");
308
- if (!["approve", "reject", "edit"].includes(action)) {
309
- out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "NOT_CORRECTABLE" });
324
+ const action = String(call).toLowerCase() === "edit" ? "fix" : String(call).toLowerCase();
325
+ const text = property(row.properties, textLabel);
326
+ const written = typeof text === "string" && text.trim() !== "";
327
+ if (!["approve", "reject", "fix"].includes(action)) {
328
+ out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, property: label, reason: "UNKNOWN_OPTION" });
310
329
  return;
311
330
  }
312
- if (action === "edit" && !EDITABLE.has(String(proposal.attributes.kind))) {
313
- out.conflicts.push(conflict(row, "EDIT_NOT_ALLOWED", `Edit is not allowed on a ${String(proposal.attributes.kind)} proposal`, "Decision", decision));
331
+ if (action === "fix" && !written) {
332
+ out.conflicts.push(conflict(row, "FIX_WITHOUT_TEXT", "Fix needs What to fix: what to correct", "What to fix"));
314
333
  return;
315
334
  }
316
- if (action === "edit" && (note === null || note === undefined || note === "")) {
317
- out.conflicts.push(conflict(row, "EDIT_WITHOUT_VALUE", "Edit needs a Note", "Note"));
335
+ if (action === "fix") {
336
+ out.conflicts.push(conflict(row, "FIX_TO_READ", "What to fix needs reading by the skill", "What to fix", text, "Fix"));
318
337
  return;
319
338
  }
320
- if (action !== "edit" && typeof note === "string" && note !== "") {
321
- out.conflicts.push(conflict(row, "NOTE_TO_READ", "Review Note needs interpretation by the skill", "Note", note, decision));
339
+ if (written) {
340
+ out.conflicts.push(conflict(row, "NOTE_TO_READ", "What to fix needs reading by the skill", "What to fix", text, action === "approve" ? "Approve" : "Reject"));
322
341
  return;
323
342
  }
324
- out.decisions.push({ proposal: proposal.id, action: action, ...(action === "edit" ? { value: note, note: note } : {}), decided_at: row.last_edited, decided_by: "notion" });
343
+ out.decisions.push({ proposal: proposal.id, action: action, decided_at: row.last_edited, decided_by: "notion" });
344
+ }
345
+ function ignoredRow(row, reason, propertyName) {
346
+ const brainId = property(row.properties, "Brain ID");
347
+ return { database: row.database, page_id: row.page_id, brain_id: typeof brainId === "string" && brainId !== "" ? brainId : null, ...(propertyName === undefined ? {} : { property: propertyName }), reason };
325
348
  }
326
- /** An Inbox row is read only for its Full text box (cli §5.14.2, 0043). */
349
+ /**
350
+ * An Inbox row is read for Document asked (0049) and, on a Report row, Refresh and What to fix
351
+ * (0045); a Full text box is retired (cli §5.14.2).
352
+ */
327
353
  function planInbox(workspace, row, out) {
328
- if (property(row.properties, "Full text") !== true)
329
- return;
354
+ const modules = modulesOf(workspace.config);
330
355
  const brainId = property(row.properties, "Brain ID");
331
- const ignore = (id) => out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: id, property: "Full text", reason: "NO_FULL_TEXT" });
356
+ const note = typeof brainId === "string" && brainId !== "" ? workspace.get(brainId) : undefined;
357
+ if (Object.hasOwn(row.properties, "Full text"))
358
+ out.ignored.push(ignoredRow(row, "RETIRED", "Full text"));
359
+ if (note !== undefined && isReport(note)) {
360
+ if (!modules.reports) {
361
+ out.ignored.push(ignoredRow(row, "MODULE_OFF"));
362
+ return;
363
+ }
364
+ if (property(row.properties, "Refresh asked") === true || property(row.properties, "Refresh") === true)
365
+ out.refresh_requests.push({ page_id: row.page_id, report: note.id });
366
+ const text = property(row.properties, "What to fix");
367
+ const last = (Array.isArray(note.attributes.fixes) ? note.attributes.fixes : []).filter(isRecord).at(-1);
368
+ if (typeof text === "string" && text.trim() !== "" && text !== last?.text)
369
+ out.report_fixes.push({ page_id: row.page_id, report: note.id, text });
370
+ }
371
+ if (property(row.properties, "Document asked") !== true)
372
+ return;
373
+ if (!modules.documents) {
374
+ out.ignored.push(ignoredRow(row, "MODULE_OFF", "Document asked"));
375
+ return;
376
+ }
332
377
  if (typeof brainId !== "string" || brainId === "") {
333
- ignore(null);
378
+ out.ignored.push(ignoredRow(row, "NOT_INGESTED", "Document asked"));
334
379
  return;
335
380
  }
336
- const note = workspace.get(brainId);
337
381
  if (note === undefined) {
338
382
  out.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
339
383
  return;
340
384
  }
341
- if (hasInboxBody(note))
342
- out.full_text_requests.push({ page_id: row.page_id, source: note.id });
343
- else
344
- ignore(note.id);
385
+ if (note.type !== "source" || note.attributes.kind === undefined || note.attributes.kind === "minutes") {
386
+ out.ignored.push(ignoredRow(row, "NOT_A_SOURCE", "Document asked"));
387
+ return;
388
+ }
389
+ const source = latestVersion(workspace, note);
390
+ const files = workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === source.id).map((file) => file.id).sort(compareCodePoints);
391
+ const onlyKeep = property(row.properties, "Only keep");
392
+ const only = typeof onlyKeep === "string" && onlyKeep.trim() !== "" ? onlyKeep : null;
393
+ const request = { page_id: row.page_id, source: source.id, files, only_keep: only };
394
+ if (only === null) {
395
+ const wholes = (files.length > 0 ? files : [source.id]).map((id) => wholeDocumentOf(workspace, id));
396
+ if (wholes.every((id) => id !== undefined))
397
+ request.existing = wholes;
398
+ }
399
+ out.document_requests.push(request);
400
+ }
401
+ /** A Promote asked box on a decision or concept row (cli §5.14.2, 0049). */
402
+ function planPromote(workspace, row, note, documentsOn, out) {
403
+ if (!documentsOn) {
404
+ out.ignored.push(ignoredRow(row, "MODULE_OFF", "Promote asked"));
405
+ return;
406
+ }
407
+ const existing = workspace.notes.find((candidate) => candidate.type === "reference" && candidate.attributes.origin === "promoted" && candidate.attributes.promoted_from === note.id);
408
+ out.promote_requests.push({ page_id: row.page_id, note: note.id, ...(existing === undefined ? {} : { existing: existing.id }) });
409
+ }
410
+ /** The latest version of a source, following `replaces` forward (schema §4, 0045). */
411
+ function latestVersion(workspace, note) {
412
+ let current = note;
413
+ const seen = new Set([note.id]);
414
+ for (;;) {
415
+ const next = workspace.notes.find((candidate) => candidate.type === "source" && candidate.attributes.replaces === current.id);
416
+ if (next === undefined || seen.has(next.id))
417
+ return current;
418
+ seen.add(next.id);
419
+ current = next;
420
+ }
421
+ }
422
+ function wholeDocumentOf(workspace, source) {
423
+ return workspace.notes.find((note) => note.type === "reference" && note.attributes.origin === "whole" && Array.isArray(note.attributes.sources) && note.attributes.sources[0] === source)?.id;
345
424
  }
346
425
  /** A source whose Inbox row the brain writes: not minutes, a capture or an attached file (0042, 0043). */
347
426
  export function hasInboxBody(note) {
@@ -368,8 +447,8 @@ function planKeyResult(workspace, asOf, row, out) {
368
447
  out.updates.push({ id: goal, notion_fields: values });
369
448
  }
370
449
  function sortPlan(plan) {
371
- const order = (item) => [DATABASES.indexOf(String(item.database)), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
372
- for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.full_text_requests, plan.conflicts, plan.ignored])
450
+ const order = (item) => [String(DATABASES.indexOf(String(item.database))).padStart(2, "0"), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
451
+ for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.document_requests, plan.promote_requests, plan.refresh_requests, plan.report_fixes, plan.conflicts, plan.ignored])
373
452
  values.sort((a, b) => order(a).localeCompare(order(b)));
374
453
  }
375
454
  function notionFields(database, p, asOf) {
@@ -382,7 +461,7 @@ function notionFields(database, p, asOf) {
382
461
  }
383
462
  function brainProperties(database) {
384
463
  const common = [["Name", "title"], ["Summary", "summary"], ["Lifecycle", "lifecycle"]];
385
- const extras = { goals: [["Why", "why"], ["Deadline", "facts.deadline"], ["Area", "area"]], projects: [["Key Facts", "facts"], ["Area", "area"], ["Goal", "goal"], ["Client", "client"], ["People", "people"]], people: [["Role", "facts.role"], ["Org", "org"]], decisions: [["Assessment", "assessment"], ["Project", "projects"], ["Area", "derived_area"]], questions: [["Kind", "kind"], ["Project", "projects"], ["Area", "derived_area"]], concepts: [["Project", "concept_projects"], ["Area", "concept_area"]] };
464
+ const extras = { goals: [["Why", "why"], ["Deadline", "facts.deadline"], ["Area", "area"]], projects: [["Key Facts", "facts"], ["Area", "area"], ["Goal", "goal"], ["Client", "client"], ["People", "people"]], people: [["Role", "facts.role"], ["Org", "org"]], decisions: [["Assessment", "assessment"], ["Project", "projects"], ["Area", "derived_area"]], questions: [["Kind", "kind"], ["Project", "projects"], ["Area", "derived_area"]], concepts: [["Project", "concept_projects"], ["Area", "concept_area"]], documents: [["Project", "reference_projects"], ["Area", "reference_area"]] };
386
465
  return [...common, ...(extras[database] ?? [])];
387
466
  }
388
467
  function convertedValue(workspace, row, name, value, conflicts) {
@@ -410,6 +489,28 @@ function linksUnpublished(workspace, note, brainField) {
410
489
  return ids.some((id) => typeof workspace.get(String(id))?.attributes.notion !== "string");
411
490
  }
412
491
  /** Equal in Notion's normalized form; relations compare as sets, and an empty text equals null. */
492
+ /** Relation properties left out while their module is off (cli §5.14.4, 0048), per database. */
493
+ function offRelations(config, database) {
494
+ const on = modulesOf(config);
495
+ const off = new Set();
496
+ if (!on.orgs && database === "projects")
497
+ off.add("Client");
498
+ if (!on.orgs && database === "people")
499
+ off.add("Org");
500
+ if (!on.planning && database === "projects")
501
+ off.add("Goal");
502
+ if (!on.knowledge && (database === "inbox" || database === "meetings")) {
503
+ off.add("Decisions");
504
+ off.add("Questions");
505
+ }
506
+ if (!on.knowledge && database === "inbox")
507
+ off.add("Concepts");
508
+ if (!on.documents && database === "inbox")
509
+ off.add("Documents");
510
+ return off;
511
+ }
512
+ /** Brain values that were renamed (0048, 0049): an S of the old one compares equal to the new one. */
513
+ const RENAMED_VALUES = { "Brain Status": { Processed: "Done" }, Kind: { Document: "File" } };
413
514
  function sameValue(a, b) {
414
515
  const norm = (value) => Array.isArray(value) ? [...value].map(String).sort(compareCodePoints) : value === "" || value === undefined ? null : value;
415
516
  return isDeepStrictEqual(norm(a), norm(b));
@@ -446,9 +547,10 @@ export function parsePlan(input) {
446
547
  if (!isRecord(input))
447
548
  assertNoProblems([{ pointer: "", message: "must be an object" }]);
448
549
  const p = input;
449
- const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "full_text_requests", "conflicts", "ignored"], "");
450
- if (p.full_text_requests !== undefined && !Array.isArray(p.full_text_requests))
451
- problems.push({ pointer: "/full_text_requests", message: "must be a list" });
550
+ const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "document_requests", "promote_requests", "refresh_requests", "report_fixes", "conflicts", "ignored"], "");
551
+ for (const key of ["document_requests", "promote_requests", "refresh_requests", "report_fixes"])
552
+ if (p[key] !== undefined && !Array.isArray(p[key]))
553
+ problems.push({ pointer: `/${key}`, message: "must be a list" });
452
554
  if (!isTimestamp(p.as_of))
453
555
  problems.push({ pointer: "/as_of", message: "must be an ISO 8601 UTC timestamp" });
454
556
  for (const key of ["create", "updates", "corrections", "decisions", "conflicts", "ignored"])
@@ -466,7 +568,7 @@ export function parsePlan(input) {
466
568
  problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
467
569
  });
468
570
  assertNoProblems(problems);
469
- return { ...p, full_text_requests: p.full_text_requests ?? [], as_of: normalizeTimestamp(p.as_of) };
571
+ return { ...p, document_requests: p.document_requests ?? [], promote_requests: p.promote_requests ?? [], refresh_requests: p.refresh_requests ?? [], report_fixes: p.report_fixes ?? [], as_of: normalizeTimestamp(p.as_of) };
470
572
  }
471
573
  export function applyPlan(workspace, plan, now, recordOnly) {
472
574
  const create = [];
@@ -538,28 +640,32 @@ export function applyPlan(workspace, plan, now, recordOnly) {
538
640
  ...updates.filter((item) => item.result === "applied").map((item) => ({ op: "sync-update", id: String(item.id), title: String(workspace.get(String(item.id))?.attributes.title ?? item.id) })),
539
641
  ...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
540
642
  ];
541
- return { create, updates, corrections, decisions: decisionResult, full_text_requests: plan.full_text_requests, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
643
+ return { create, updates, corrections, decisions: decisionResult, document_requests: plan.document_requests, promote_requests: plan.promote_requests, refresh_requests: plan.refresh_requests, report_fixes: plan.report_fixes, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
542
644
  }
543
645
  function references(value) { if (Array.isArray(value))
544
646
  return value.flatMap(references); if (isRecord(value))
545
- return Object.values(value).flatMap(references); return typeof value === "string" && /^(area|gol|prj|per|org|mtg|dec|que|con|src|mem)-/.test(value) ? [value] : []; }
647
+ return Object.values(value).flatMap(references); return typeof value === "string" && /^(area|gol|prj|per|org|mtg|dec|que|con|ref|src|mem)-/.test(value) ? [value] : []; }
546
648
  function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0, corrected: 0, skipped: 0, failed: 0 }; for (const item of items)
547
649
  if (typeof item.result === "string" && Object.hasOwn(result, item.result))
548
650
  result[item.result] += 1; return result; }
549
651
  export function render(workspace, now, ids, held) {
550
652
  const selected = ids === undefined
551
- ? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) || (isReport(note) && note.attributes.notion === undefined))
653
+ ? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) || isReport(note))
552
654
  : ids.map((id) => workspace.get(id)).filter(Boolean);
553
655
  const items = [];
554
656
  const errors = [];
555
657
  const warnings = [];
658
+ const modules = modulesOf(workspace.config);
556
659
  for (const note of selected) {
557
660
  const database = isReport(note) ? "inbox" : DATABASE_FOR_TYPE[note.type];
558
661
  if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
559
662
  continue;
560
- // A published report is never rendered again (0043).
561
- if (isReport(note) && note.attributes.notion !== undefined)
663
+ // A note of a module that is off isn't published; naming one is an error (0048).
664
+ if ((isReport(note) && !modules.reports) || !typeIsOn(workspace.config, note.type)) {
665
+ if (ids !== undefined)
666
+ errors.push({ id: note.id, reason: "MODULE_OFF" });
562
667
  continue;
668
+ }
563
669
  // An attached source has no Inbox row: its notes go on the parent's (0042).
564
670
  if (note.type === "source" && note.attributes.attached_to !== undefined)
565
671
  continue;
@@ -590,31 +696,48 @@ export function render(workspace, now, ids, held) {
590
696
  }
591
697
  const rendered = renderedBrainValues(note, workspace);
592
698
  const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : {};
699
+ for (const name of offRelations(workspace.config, database))
700
+ delete rendered[name];
593
701
  const properties = {};
594
702
  const pending = [];
595
703
  const heldProperties = held?.get(note.id) ?? new Set();
596
- const isCreate = isReport(note) || (note.attributes.notion === undefined && capturePage === undefined);
704
+ const isCreate = note.attributes.notion === undefined && capturePage === undefined;
597
705
  for (const [name, value] of Object.entries(rendered)) {
598
706
  if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
599
707
  continue;
600
708
  const encoded = notionProperty(name, value, note, workspace, pending);
601
- if (encoded !== undefined && (isCreate || !Object.hasOwn(published, name) || !sameValue(value, published[name])))
709
+ if (encoded !== undefined && (isCreate || !Object.hasOwn(published, name) || !sameValue(value, RENAMED_VALUES[name]?.[String(published[name])] ?? published[name])))
602
710
  properties[name] = encoded;
603
711
  }
604
712
  const content = renderedContent(note, workspace);
605
713
  if (content.hash !== undefined)
606
714
  rendered["@content"] = content.hash;
715
+ // A promoted Document's body is yours once its page exists (0049): sent on create only.
716
+ if (note.type === "reference" && note.attributes.origin === "promoted" && !isCreate) {
717
+ content.body = null;
718
+ delete rendered["@content"];
719
+ if (Object.hasOwn(published, "@content"))
720
+ rendered["@content"] = published["@content"];
721
+ content.hash = undefined;
722
+ }
607
723
  const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
608
- // Full text (0043): the text goes out once per choice, and a ticked box is cleared once per request.
609
- const full = note.type === "source" && hasInboxBody(note) ? fullTextDue(note, published["@full_text"]) : undefined;
610
- if (full?.sections !== undefined)
611
- content.sections = { ...(content.sections ?? {}), ...full.sections };
612
- if (full?.clear === true)
613
- properties["Full text"] = { type: "checkbox", value: false };
614
- if (full !== undefined && (full.sections !== undefined || full.clear))
615
- rendered["@full_text"] = full.record;
616
- const fullTextDueNow = full !== undefined && (full.sections !== undefined || full.clear);
617
- if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow)
724
+ // A report (0045): a refresh sends the body once and clears Refresh; a new fix clears What to fix.
725
+ let reportDue = false;
726
+ if (isReport(note)) {
727
+ const fixes = Array.isArray(note.attributes.fixes) ? note.attributes.fixes.length : 0;
728
+ const stored = typeof published["@fixes"] === "number" ? published["@fixes"] : 0;
729
+ if (!isCreate && contentChanged)
730
+ properties["Refresh asked"] = { type: "checkbox", value: false };
731
+ if (!isCreate && !contentChanged)
732
+ content.body = null;
733
+ if (fixes > stored) {
734
+ properties["What to fix"] = { type: "rich_text", value: null };
735
+ reportDue = true;
736
+ }
737
+ rendered["@fixes"] = Math.max(fixes, stored);
738
+ }
739
+ // Full text (0043) is retired (0049): nothing is sent, and `@full_text` in S stays as it is.
740
+ if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !reportDue)
618
741
  continue;
619
742
  if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
620
743
  properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
@@ -633,8 +756,7 @@ function trashDue(note, now) {
633
756
  const time = typeof decided === "string" ? Date.parse(decided) : Number.NaN;
634
757
  return Number.isFinite(time) && now.getTime() - time > TRASH_AFTER_MS;
635
758
  }
636
- function databaseIdFor(workspace, database) { const notion = workspace.config.notion; if (!isRecord(notion) || !isRecord(notion.databases))
637
- return ""; return typeof notion.databases[database] === "string" ? notion.databases[database] : database === "inbox" && typeof notion.databases.capture_inbox === "string" ? notion.databases.capture_inbox : ""; }
759
+ function databaseIdFor(workspace, database) { return databaseId(workspace.config, database); }
638
760
  function renderedBrainValues(note, workspace) {
639
761
  const a = note.attributes;
640
762
  const values = {};
@@ -644,7 +766,9 @@ function renderedBrainValues(note, workspace) {
644
766
  const addLink = a.kind === "add_link";
645
767
  const after = addLink
646
768
  ? proposalLinks(a).map((link) => `${fieldLabel(String(link.field))}: ${noteTitle(link.value, workspace)}`).join("\n")
647
- : a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : undefined;
769
+ : a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : a.kind === "update_body" ? bodyChange(a, workspace)?.join("\n") ?? null : undefined;
770
+ // A body diff can only be shown while the target's body is the one proposed against; then S keeps it (0045).
771
+ const proposed = staleBody(a, workspace) && isRecord(a.notion_published) && Object.hasOwn(a.notion_published, "Proposed") ? a.notion_published.Proposed : undefined;
648
772
  const reviewText = (value) => truncate(typeof value === "string" ? reviewMarkdown(value, target) : value);
649
773
  // A Review notes row names its source: the meeting for minutes, else the source (0041).
650
774
  const source = a.kind === "review_notes" && typeof a.source === "string" ? workspace.get(a.source) : undefined;
@@ -652,16 +776,16 @@ function renderedBrainValues(note, workspace) {
652
776
  const page = (source ?? target)?.attributes.notion;
653
777
  const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
654
778
  Object.assign(values, {
655
- Name: a.title, Kind: select(a.kind), Target: targetText,
656
- "Target Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
779
+ Name: reviewName(a), Kind: select(a.kind), About: targetText,
780
+ "About Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
657
781
  "Source Meeting": meeting?.type === "meeting" && typeof meeting.attributes.notion === "string" ? [meeting.attributes.notion] : [],
658
- Before: reviewText(proposalBefore(a, workspace)), After: reviewText(after ?? proposalAfter(a, workspace)),
659
- Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
782
+ Now: reviewText(proposalBefore(a, workspace)), Proposed: proposed !== undefined ? proposed : reviewText(after ?? proposalAfter(a, workspace)),
783
+ Why: reviewText(a.why), Area: relationPages(reviewArea(a, workspace), workspace), State: select(proposalState(a.status)), "Brain ID": note.id,
660
784
  });
661
785
  return values;
662
786
  }
663
787
  if (isReport(note)) {
664
- Object.assign(values, { Name: a.title, Kind: "Report", Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), Ask: typeof a.asked_in === "string" ? [a.asked_in] : [], "Brain Status": "Processed", "Brain ID": note.id });
788
+ Object.assign(values, { Name: a.title, Kind: "Report", Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), Ask: typeof a.asked_in === "string" ? [a.asked_in] : Array.isArray(a.asked_in) ? a.asked_in : [], "Brain Status": "Done", "Brain ID": note.id });
665
789
  return values;
666
790
  }
667
791
  if (note.type === "meeting") {
@@ -692,10 +816,30 @@ function renderedBrainValues(note, workspace) {
692
816
  else if (published.Status === undefined && !yours)
693
817
  values.Status = "Open";
694
818
  }
695
- if (note.type === "concept")
819
+ if (note.type === "concept" || note.type === "reference")
696
820
  Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
821
+ if (note.type === "reference") {
822
+ const from = a.origin === "promoted" && typeof a.promoted_from === "string" ? a.promoted_from : undefined;
823
+ Object.assign(values, { "From decision": relationPages(from?.startsWith("dec-") ? [from] : [], workspace), "From concept": relationPages(from?.startsWith("con-") ? [from] : [], workspace) });
824
+ }
697
825
  return values;
698
826
  }
827
+ /** A Review row's Name says which kind of row it is (cli §5.14.4, 0048). */
828
+ function reviewName(a) {
829
+ const prefix = ["contradiction", "supersede", "merge", "new_item"].includes(String(a.kind)) ? "Conflict: " : a.kind === "answer" ? "Answer: " : "";
830
+ return typeof a.title === "string" && prefix !== "" && !a.title.startsWith(prefix) ? `${prefix}${a.title}` : a.title;
831
+ }
832
+ /** A Review row's one area: its source note's own area, else the area of its first project (cli §5.14.4, 0050). */
833
+ function reviewArea(a, workspace) {
834
+ const source = typeof a.source === "string" ? workspace.get(a.source) : undefined;
835
+ if (source === undefined)
836
+ return [];
837
+ if (typeof source.attributes.area === "string")
838
+ return [source.attributes.area];
839
+ const first = Array.isArray(source.attributes.projects) ? source.attributes.projects[0] : undefined;
840
+ const area = typeof first === "string" ? workspace.get(first)?.attributes.area : undefined;
841
+ return typeof area === "string" ? [area] : [];
842
+ }
699
843
  /** Area on decisions and questions is a derived Notion relation (decision 0034). */
700
844
  function projectAreas(projects, workspace) {
701
845
  const areas = (Array.isArray(projects) ? projects : []).flatMap((project) => {
@@ -707,20 +851,23 @@ function projectAreas(projects, workspace) {
707
851
  /** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
708
852
  function producedBy(note, workspace) {
709
853
  const sources = new Set([note.id, ...workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === note.id).map((candidate) => candidate.id)]);
710
- return workspace.notes.filter((candidate) => ["decision", "question", "concept", "person"].includes(candidate.type) && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(id))));
854
+ // A promoted Document comes from its note, not the source (0049).
855
+ return workspace.notes.filter((candidate) => ["decision", "question", "concept", "reference", "person"].includes(candidate.type) && !(candidate.type === "reference" && candidate.attributes.origin === "promoted") && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(id))));
711
856
  }
712
857
  function sourceValues(note, workspace) {
713
858
  const produced = producedBy(note, workspace);
714
859
  const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
715
860
  const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
716
861
  const areas = [...new Set([...produced.map((candidate) => candidate.attributes.area).filter((id) => typeof id === "string"), ...projects.flatMap((id) => { const area = workspace.get(id)?.attributes.area; return typeof area === "string" ? [area] : []; })])].sort(compareCodePoints);
717
- return { Name: note.attributes.title, Kind: select(note.attributes.kind), Summary: note.attributes.summary ?? null, Decisions: relationPages(ids("decision"), workspace), Questions: relationPages(ids("question"), workspace), Concepts: relationPages(ids("concept"), workspace), People: relationPages(ids("person"), workspace), Project: relationPages(projects, workspace), Area: relationPages(areas, workspace), "Brain Status": "Processed", "Brain ID": note.id };
862
+ return { Name: note.attributes.title, Kind: note.attributes.kind === "document" ? "File" : select(note.attributes.kind), Summary: note.attributes.summary ?? null, Decisions: relationPages(ids("decision"), workspace), Questions: relationPages(ids("question"), workspace), Concepts: relationPages(ids("concept"), workspace), ...(databaseIdFor(workspace, "documents") === "" ? {} : { Documents: relationPages(ids("reference"), workspace) }), People: relationPages(ids("person"), workspace), Project: relationPages(projects, workspace), Area: relationPages(areas, workspace), "Brain Status": "Done", "Brain ID": note.id };
718
863
  }
719
864
  function notionProperty(name, value, note, workspace, pending) {
720
- const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "Attendees"].includes(name);
865
+ const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "Documents", "Attendees", "From decision", "From concept"].includes(name);
721
866
  if (relation) {
722
867
  const sourceField = { Area: "area", Goal: "goal", Client: "client", People: "people", Org: "org", Project: "projects", "Decided In": "decided_in", "Raised In": "raised_in", Decisions: "decisions", Questions: "questions", Attendees: "attendees" };
723
- const raw = name === "Area" && (note.type === "decision" || note.type === "question") ? projectAreas(note.attributes.projects, workspace) : name === "Supersedes" ? (Array.isArray(note.attributes.relations) ? note.attributes.relations.filter(isRecord).filter((item) => item.type === "supersedes").map((item) => item.target) : []) : note.type === "source" ? sourceRelationIds(note, name, workspace) : note.attributes[sourceField[name] ?? ""];
868
+ const raw = name === "Area" && note.type === "proposal" ? reviewArea(note.attributes, workspace)
869
+ : name === "From decision" || name === "From concept" ? (Array.isArray(value) && note.attributes.promoted_from !== undefined && String(note.attributes.promoted_from).startsWith(name === "From decision" ? "dec-" : "con-") ? [note.attributes.promoted_from] : [])
870
+ : name === "Area" && (note.type === "decision" || note.type === "question") ? projectAreas(note.attributes.projects, workspace) : name === "Supersedes" ? (Array.isArray(note.attributes.relations) ? note.attributes.relations.filter(isRecord).filter((item) => item.type === "supersedes").map((item) => item.target) : []) : note.type === "source" ? sourceRelationIds(note, name, workspace) : note.attributes[sourceField[name] ?? ""];
724
871
  const missing = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map(String).filter((id) => typeof workspace.get(id)?.attributes.notion !== "string");
725
872
  if (missing.length > 0)
726
873
  pending.push({ property: name, ids: missing });
@@ -735,8 +882,11 @@ function notionProperty(name, value, note, workspace, pending) {
735
882
  }
736
883
  if (name === "Ask")
737
884
  return { type: "relation", value: Array.isArray(value) ? value : [] };
738
- if (name === "Target Page")
885
+ if (name === "About Page")
739
886
  return { type: "url", value };
887
+ // An Inbox row's Kind is written only when empty in Notion, so a Kind you chose stands (0048).
888
+ if (name === "Kind" && note.type === "source")
889
+ return { type: "select", value, mode: "if_empty" };
740
890
  if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
741
891
  return { type: "select", value };
742
892
  if (name === "Last Synced" || name === "Date" || name === "Deadline" || name === "Ingested At")
@@ -751,6 +901,8 @@ function sourceRelationIds(note, name, workspace) {
751
901
  return produced.filter((candidate) => candidate.type === "question").map((candidate) => candidate.id);
752
902
  if (name === "Concepts")
753
903
  return produced.filter((candidate) => candidate.type === "concept").map((candidate) => candidate.id);
904
+ if (name === "Documents")
905
+ return produced.filter((candidate) => candidate.type === "reference").map((candidate) => candidate.id);
754
906
  if (name === "People")
755
907
  return produced.filter((candidate) => candidate.type === "person").map((candidate) => candidate.id);
756
908
  const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))];
@@ -773,7 +925,7 @@ function renderedContent(note, workspace) {
773
925
  const sections = { Brief: notionMarkdown(generatedBlock(brief.body), brief, workspace) };
774
926
  return { body: null, sections, hash: hash(JSON.stringify(sections)) };
775
927
  }
776
- // A source's digest is written once; its Full text is tracked by @full_text, not @content (0043).
928
+ // A source's digest is written once (0043).
777
929
  if (note.type === "source") {
778
930
  if (!hasInboxBody(note))
779
931
  return { body: null, sections: null };
@@ -784,7 +936,7 @@ function renderedContent(note, workspace) {
784
936
  const body = notionMarkdown(note.body.replace(/^\n+/, ""), note, workspace);
785
937
  return { body, sections: null, hash: hash(body) };
786
938
  }
787
- if (note.type === "decision" || note.type === "question" || note.type === "concept") {
939
+ if (note.type === "decision" || note.type === "question" || note.type === "concept" || note.type === "reference") {
788
940
  const body = notionMarkdown(note.body, note, workspace);
789
941
  return { body, sections: null, hash: hash(body) };
790
942
  }
@@ -824,32 +976,40 @@ function personTitle(value, workspace) { return typeof value === "string" && wor
824
976
  function noteTitle(value, workspace) { return typeof value === "string" ? workspace.get(value)?.attributes.title ?? value : value ?? null; }
825
977
  function fieldLabel(field) { return field[0]?.toUpperCase() + field.slice(1).replaceAll("_", " "); }
826
978
  function truncate(value) { return typeof value === "string" ? value.slice(0, 2000) : value; }
827
- /** The lettered lines of a Review notes row: its notes, then its links (cli §5.14.4). */
979
+ /** The lettered lines of a Review notes row: its changes, then its notes, then its links (cli §5.14.4, 0045). */
828
980
  function reviewNotesAfter(attributes, workspace) {
981
+ const changes = (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).map((change) => {
982
+ const target = String(noteTitle(change.target, workspace));
983
+ if (change.kind === "update_fact")
984
+ return `⚠ ${fieldLabel(String(change.field))} on ${target}: ${change.before === undefined ? "(new)" : String(noteTitle(change.before, workspace))} → ${String(noteTitle(change.after, workspace))}`;
985
+ if (change.kind === "update_body")
986
+ return `⚠ ${target}: ${bodyChange(change, workspace)?.length ?? 0} lines changed`;
987
+ return `Add to ${target}: ${String(change.content ?? "").split("\n")[0].slice(0, 80)}`;
988
+ });
829
989
  const notes = (Array.isArray(attributes.notes) ? attributes.notes : []).map((id) => {
830
990
  const note = workspace.get(String(id));
831
991
  return `${note === undefined ? "Note" : fieldLabel(note.type)}: ${String(noteTitle(id, workspace))}`;
832
992
  });
833
993
  const links = (Array.isArray(attributes.links) ? attributes.links.filter(isRecord) : []).map((link) => `${fieldLabel(String(link.field))} on ${String(noteTitle(link.target, workspace))}: ${String(noteTitle(link.value, workspace))}`);
834
- return [...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
994
+ return [...changes, ...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
995
+ }
996
+ /** The changed lines an update_body proposes against its target's body (cli §5.19), or undefined once that body changed. */
997
+ function bodyChange(change, workspace) {
998
+ const target = typeof change.target === "string" ? workspace.get(change.target) : undefined;
999
+ if (target === undefined || bodyHash(target.body) !== change.before_hash)
1000
+ return undefined;
1001
+ return lineDiff(target.body, bodyOf(String(change.content ?? "")));
1002
+ }
1003
+ /** Whether a proposal holds an update_body whose target's body is no longer the one it was proposed against. */
1004
+ function staleBody(attributes, workspace) {
1005
+ const bodies = attributes.kind === "update_body" ? [attributes] : (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).filter((change) => change.kind === "update_body");
1006
+ return bodies.some((change) => bodyChange(change, workspace) === undefined);
835
1007
  }
836
1008
  /** a, b, … z, then aa, ab, … */
837
1009
  export function letters(index) {
838
1010
  return index < 26 ? String.fromCharCode(97 + index) : `${letters(Math.floor(index / 26) - 1)}${letters(index % 26)}`;
839
1011
  }
840
1012
  function isReport(note) { return note.type === "view" && note.attributes.view_kind === "report"; }
841
- /**
842
- * Whether a source's Full text is due (cli §5.14.4): B of `@full_text` against S. The text goes out
843
- * when it is chosen and S doesn't hold the same choice; the box is cleared when a request is new.
844
- */
845
- function fullTextDue(note, stored) {
846
- const a = note.attributes;
847
- const record = { text: a.notion_text ?? "digest", parts: Array.isArray(a.notion_parts) ? a.notion_parts : null, requested: a.notion_text_requested ?? null };
848
- const s = isRecord(stored) ? stored : undefined;
849
- const send = record.text === "full" && (s === undefined || s.text !== record.text || !isDeepStrictEqual(s.parts ?? null, record.parts));
850
- const clear = record.requested !== null && record.requested !== (s?.requested ?? null);
851
- return { record, ...(send ? { sections: { "Full text": fullText(note.body, record.parts) } } : {}), clear };
852
- }
853
1013
  /** A brain-created Inbox row's body: a line about the source, its summary and its digest (cli §5.14.4). */
854
1014
  function digestBody(note) {
855
1015
  const a = note.attributes;
@@ -862,7 +1022,7 @@ function digestBody(note) {
862
1022
  return [line, typeof a.summary === "string" ? a.summary : undefined, typeof a.digest === "string" ? a.digest.replace(/\n+$/, "") : undefined].filter((part) => part !== undefined).join("\n\n");
863
1023
  }
864
1024
  function proposalBefore(attributes, workspace) {
865
- if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer")
1025
+ if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer" || attributes.kind === "update_body")
866
1026
  return null;
867
1027
  if (attributes.kind === "supersede")
868
1028
  return truncate(noteTitle(attributes.supersedes, workspace));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-brain",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "CLI for an AI-assisted Markdown and Git second brain",
5
5
  "license": "MIT",
6
6
  "keywords": [