ultimate-brain 0.4.1 → 0.5.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.
package/dist/lib/sync.js CHANGED
@@ -1,6 +1,6 @@
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";
@@ -9,11 +9,13 @@ import { compareCodePoints } from "./notes.js";
9
9
  import { fullText, 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 { bodyHash, bodyOf } from "./proposals.js";
14
+ const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "references", "review", "inbox"];
13
15
  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"]);
16
+ const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", references: "reference", review: "proposal" };
17
+ const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", reference: "references", source: "inbox", meeting: "meetings", proposal: "review" };
18
+ const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "reference", "source", "meeting", "proposal"]);
17
19
  function property(value, key) {
18
20
  return Object.hasOwn(value, key) ? value[key] : undefined;
19
21
  }
@@ -72,7 +74,7 @@ export function parseSnapshot(input) {
72
74
  }
73
75
  /** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
74
76
  export function buildPlan(workspace, snapshot) {
75
- const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], conflicts: [], ignored: [] };
77
+ const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], refresh_requests: [], report_fixes: [], conflicts: [], ignored: [] };
76
78
  const createsByPage = new Map();
77
79
  for (const row of snapshot.rows)
78
80
  if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
@@ -296,44 +298,56 @@ function planKeyFacts(row, note, published, updates, out) {
296
298
  updates.published = { ...(isRecord(updates.published) ? updates.published : {}), "Key Facts": text };
297
299
  }
298
300
  function planDecision(row, proposal, out) {
299
- const decision = property(row.properties, "Decision");
300
- if (decision === undefined || property(row.properties, "State") !== "Pending")
301
+ // A snapshot taken before migration 0009 has Decision and Note; Edit there is read as Fix (cli §5.14.2, 0045).
302
+ const call = property(row.properties, "Your call") ?? property(row.properties, "Decision");
303
+ const label = Object.hasOwn(row.properties, "Your call") ? "Your call" : "Decision";
304
+ const textLabel = Object.hasOwn(row.properties, "What to fix") || !Object.hasOwn(row.properties, "Note") ? "What to fix" : "Note";
305
+ if (call === undefined || call === null || property(row.properties, "State") !== "Pending")
301
306
  return;
302
307
  if (["applied", "closed"].includes(String(proposal.attributes.status))) {
303
308
  out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "ALREADY_TERMINAL" });
304
309
  return;
305
310
  }
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" });
311
+ const action = String(call).toLowerCase() === "edit" ? "fix" : String(call).toLowerCase();
312
+ const text = property(row.properties, textLabel);
313
+ const written = typeof text === "string" && text.trim() !== "";
314
+ if (!["approve", "reject", "fix"].includes(action)) {
315
+ out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, property: label, reason: "UNKNOWN_OPTION" });
310
316
  return;
311
317
  }
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));
318
+ if (action === "fix" && !written) {
319
+ out.conflicts.push(conflict(row, "FIX_WITHOUT_TEXT", "Fix needs What to fix: what to correct", "What to fix"));
314
320
  return;
315
321
  }
316
- if (action === "edit" && (note === null || note === undefined || note === "")) {
317
- out.conflicts.push(conflict(row, "EDIT_WITHOUT_VALUE", "Edit needs a Note", "Note"));
322
+ if (action === "fix") {
323
+ out.conflicts.push(conflict(row, "FIX_TO_READ", "What to fix needs reading by the skill", "What to fix", text, "Fix"));
318
324
  return;
319
325
  }
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));
326
+ if (written) {
327
+ 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
328
  return;
323
329
  }
324
- out.decisions.push({ proposal: proposal.id, action: action, ...(action === "edit" ? { value: note, note: note } : {}), decided_at: row.last_edited, decided_by: "notion" });
330
+ out.decisions.push({ proposal: proposal.id, action: action, decided_at: row.last_edited, decided_by: "notion" });
325
331
  }
326
- /** An Inbox row is read only for its Full text box (cli §5.14.2, 0043). */
332
+ /** An Inbox row is read for its Full text box (0043) and, on a Report row, Refresh and What to fix (cli §5.14.2, 0045). */
327
333
  function planInbox(workspace, row, out) {
334
+ const brainId = property(row.properties, "Brain ID");
335
+ const note = typeof brainId === "string" && brainId !== "" ? workspace.get(brainId) : undefined;
336
+ if (note !== undefined && isReport(note)) {
337
+ if (property(row.properties, "Refresh") === true)
338
+ out.refresh_requests.push({ page_id: row.page_id, report: note.id });
339
+ const text = property(row.properties, "What to fix");
340
+ const last = (Array.isArray(note.attributes.fixes) ? note.attributes.fixes : []).filter(isRecord).at(-1);
341
+ if (typeof text === "string" && text.trim() !== "" && text !== last?.text)
342
+ out.report_fixes.push({ page_id: row.page_id, report: note.id, text });
343
+ }
328
344
  if (property(row.properties, "Full text") !== true)
329
345
  return;
330
- const brainId = property(row.properties, "Brain ID");
331
346
  const ignore = (id) => out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: id, property: "Full text", reason: "NO_FULL_TEXT" });
332
347
  if (typeof brainId !== "string" || brainId === "") {
333
348
  ignore(null);
334
349
  return;
335
350
  }
336
- const note = workspace.get(brainId);
337
351
  if (note === undefined) {
338
352
  out.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
339
353
  return;
@@ -369,7 +383,7 @@ function planKeyResult(workspace, asOf, row, out) {
369
383
  }
370
384
  function sortPlan(plan) {
371
385
  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])
386
+ for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.full_text_requests, plan.refresh_requests, plan.report_fixes, plan.conflicts, plan.ignored])
373
387
  values.sort((a, b) => order(a).localeCompare(order(b)));
374
388
  }
375
389
  function notionFields(database, p, asOf) {
@@ -382,7 +396,7 @@ function notionFields(database, p, asOf) {
382
396
  }
383
397
  function brainProperties(database) {
384
398
  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"]] };
399
+ 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"]], references: [["Project", "reference_projects"], ["Area", "reference_area"]] };
386
400
  return [...common, ...(extras[database] ?? [])];
387
401
  }
388
402
  function convertedValue(workspace, row, name, value, conflicts) {
@@ -446,9 +460,10 @@ export function parsePlan(input) {
446
460
  if (!isRecord(input))
447
461
  assertNoProblems([{ pointer: "", message: "must be an object" }]);
448
462
  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" });
463
+ const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "full_text_requests", "refresh_requests", "report_fixes", "conflicts", "ignored"], "");
464
+ for (const key of ["full_text_requests", "refresh_requests", "report_fixes"])
465
+ if (p[key] !== undefined && !Array.isArray(p[key]))
466
+ problems.push({ pointer: `/${key}`, message: "must be a list" });
452
467
  if (!isTimestamp(p.as_of))
453
468
  problems.push({ pointer: "/as_of", message: "must be an ISO 8601 UTC timestamp" });
454
469
  for (const key of ["create", "updates", "corrections", "decisions", "conflicts", "ignored"])
@@ -466,7 +481,7 @@ export function parsePlan(input) {
466
481
  problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
467
482
  });
468
483
  assertNoProblems(problems);
469
- return { ...p, full_text_requests: p.full_text_requests ?? [], as_of: normalizeTimestamp(p.as_of) };
484
+ return { ...p, full_text_requests: p.full_text_requests ?? [], refresh_requests: p.refresh_requests ?? [], report_fixes: p.report_fixes ?? [], as_of: normalizeTimestamp(p.as_of) };
470
485
  }
471
486
  export function applyPlan(workspace, plan, now, recordOnly) {
472
487
  const create = [];
@@ -538,17 +553,17 @@ export function applyPlan(workspace, plan, now, recordOnly) {
538
553
  ...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
554
  ...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
540
555
  ];
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 };
556
+ return { create, updates, corrections, decisions: decisionResult, full_text_requests: plan.full_text_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
557
  }
543
558
  function references(value) { if (Array.isArray(value))
544
559
  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] : []; }
560
+ 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
561
  function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0, corrected: 0, skipped: 0, failed: 0 }; for (const item of items)
547
562
  if (typeof item.result === "string" && Object.hasOwn(result, item.result))
548
563
  result[item.result] += 1; return result; }
549
564
  export function render(workspace, now, ids, held) {
550
565
  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))
566
+ ? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) || isReport(note))
552
567
  : ids.map((id) => workspace.get(id)).filter(Boolean);
553
568
  const items = [];
554
569
  const errors = [];
@@ -557,9 +572,6 @@ export function render(workspace, now, ids, held) {
557
572
  const database = isReport(note) ? "inbox" : DATABASE_FOR_TYPE[note.type];
558
573
  if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
559
574
  continue;
560
- // A published report is never rendered again (0043).
561
- if (isReport(note) && note.attributes.notion !== undefined)
562
- continue;
563
575
  // An attached source has no Inbox row: its notes go on the parent's (0042).
564
576
  if (note.type === "source" && note.attributes.attached_to !== undefined)
565
577
  continue;
@@ -593,7 +605,7 @@ export function render(workspace, now, ids, held) {
593
605
  const properties = {};
594
606
  const pending = [];
595
607
  const heldProperties = held?.get(note.id) ?? new Set();
596
- const isCreate = isReport(note) || (note.attributes.notion === undefined && capturePage === undefined);
608
+ const isCreate = note.attributes.notion === undefined && capturePage === undefined;
597
609
  for (const [name, value] of Object.entries(rendered)) {
598
610
  if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
599
611
  continue;
@@ -605,6 +617,21 @@ export function render(workspace, now, ids, held) {
605
617
  if (content.hash !== undefined)
606
618
  rendered["@content"] = content.hash;
607
619
  const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
620
+ // A report (0045): a refresh sends the body once and clears Refresh; a new fix clears What to fix.
621
+ let reportDue = false;
622
+ if (isReport(note)) {
623
+ const fixes = Array.isArray(note.attributes.fixes) ? note.attributes.fixes.length : 0;
624
+ const stored = typeof published["@fixes"] === "number" ? published["@fixes"] : 0;
625
+ if (!isCreate && contentChanged)
626
+ properties.Refresh = { type: "checkbox", value: false };
627
+ if (!isCreate && !contentChanged)
628
+ content.body = null;
629
+ if (fixes > stored) {
630
+ properties["What to fix"] = { type: "rich_text", value: null };
631
+ reportDue = true;
632
+ }
633
+ rendered["@fixes"] = Math.max(fixes, stored);
634
+ }
608
635
  // Full text (0043): the text goes out once per choice, and a ticked box is cleared once per request.
609
636
  const full = note.type === "source" && hasInboxBody(note) ? fullTextDue(note, published["@full_text"]) : undefined;
610
637
  if (full?.sections !== undefined)
@@ -614,7 +641,7 @@ export function render(workspace, now, ids, held) {
614
641
  if (full !== undefined && (full.sections !== undefined || full.clear))
615
642
  rendered["@full_text"] = full.record;
616
643
  const fullTextDueNow = full !== undefined && (full.sections !== undefined || full.clear);
617
- if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow)
644
+ if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow && !reportDue)
618
645
  continue;
619
646
  if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
620
647
  properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
@@ -644,7 +671,9 @@ function renderedBrainValues(note, workspace) {
644
671
  const addLink = a.kind === "add_link";
645
672
  const after = addLink
646
673
  ? proposalLinks(a).map((link) => `${fieldLabel(String(link.field))}: ${noteTitle(link.value, workspace)}`).join("\n")
647
- : a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : undefined;
674
+ : a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : a.kind === "update_body" ? bodyChange(a, workspace)?.join("\n") ?? null : undefined;
675
+ // A body diff can only be shown while the target's body is the one proposed against; then S keeps it (0045).
676
+ const proposed = staleBody(a, workspace) && isRecord(a.notion_published) && Object.hasOwn(a.notion_published, "Proposed") ? a.notion_published.Proposed : undefined;
648
677
  const reviewText = (value) => truncate(typeof value === "string" ? reviewMarkdown(value, target) : value);
649
678
  // A Review notes row names its source: the meeting for minutes, else the source (0041).
650
679
  const source = a.kind === "review_notes" && typeof a.source === "string" ? workspace.get(a.source) : undefined;
@@ -652,16 +681,16 @@ function renderedBrainValues(note, workspace) {
652
681
  const page = (source ?? target)?.attributes.notion;
653
682
  const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
654
683
  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,
684
+ Name: a.title, Kind: select(a.kind), About: targetText,
685
+ "About Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
657
686
  "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)),
687
+ Now: reviewText(proposalBefore(a, workspace)), Proposed: proposed !== undefined ? proposed : reviewText(after ?? proposalAfter(a, workspace)),
659
688
  Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
660
689
  });
661
690
  return values;
662
691
  }
663
692
  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 });
693
+ 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": "Processed", "Brain ID": note.id });
665
694
  return values;
666
695
  }
667
696
  if (note.type === "meeting") {
@@ -692,7 +721,7 @@ function renderedBrainValues(note, workspace) {
692
721
  else if (published.Status === undefined && !yours)
693
722
  values.Status = "Open";
694
723
  }
695
- if (note.type === "concept")
724
+ if (note.type === "concept" || note.type === "reference")
696
725
  Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
697
726
  return values;
698
727
  }
@@ -707,17 +736,17 @@ function projectAreas(projects, workspace) {
707
736
  /** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
708
737
  function producedBy(note, workspace) {
709
738
  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))));
739
+ return workspace.notes.filter((candidate) => ["decision", "question", "concept", "reference", "person"].includes(candidate.type) && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(id))));
711
740
  }
712
741
  function sourceValues(note, workspace) {
713
742
  const produced = producedBy(note, workspace);
714
743
  const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
715
744
  const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
716
745
  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 };
746
+ 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), ...(databaseIdFor(workspace, "references") === "" ? {} : { References: relationPages(ids("reference"), workspace) }), People: relationPages(ids("person"), workspace), Project: relationPages(projects, workspace), Area: relationPages(areas, workspace), "Brain Status": "Processed", "Brain ID": note.id };
718
747
  }
719
748
  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);
749
+ const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "References", "Attendees"].includes(name);
721
750
  if (relation) {
722
751
  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
752
  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] ?? ""];
@@ -735,7 +764,7 @@ function notionProperty(name, value, note, workspace, pending) {
735
764
  }
736
765
  if (name === "Ask")
737
766
  return { type: "relation", value: Array.isArray(value) ? value : [] };
738
- if (name === "Target Page")
767
+ if (name === "About Page")
739
768
  return { type: "url", value };
740
769
  if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
741
770
  return { type: "select", value };
@@ -751,6 +780,8 @@ function sourceRelationIds(note, name, workspace) {
751
780
  return produced.filter((candidate) => candidate.type === "question").map((candidate) => candidate.id);
752
781
  if (name === "Concepts")
753
782
  return produced.filter((candidate) => candidate.type === "concept").map((candidate) => candidate.id);
783
+ if (name === "References")
784
+ return produced.filter((candidate) => candidate.type === "reference").map((candidate) => candidate.id);
754
785
  if (name === "People")
755
786
  return produced.filter((candidate) => candidate.type === "person").map((candidate) => candidate.id);
756
787
  const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))];
@@ -784,7 +815,7 @@ function renderedContent(note, workspace) {
784
815
  const body = notionMarkdown(note.body.replace(/^\n+/, ""), note, workspace);
785
816
  return { body, sections: null, hash: hash(body) };
786
817
  }
787
- if (note.type === "decision" || note.type === "question" || note.type === "concept") {
818
+ if (note.type === "decision" || note.type === "question" || note.type === "concept" || note.type === "reference") {
788
819
  const body = notionMarkdown(note.body, note, workspace);
789
820
  return { body, sections: null, hash: hash(body) };
790
821
  }
@@ -824,14 +855,34 @@ function personTitle(value, workspace) { return typeof value === "string" && wor
824
855
  function noteTitle(value, workspace) { return typeof value === "string" ? workspace.get(value)?.attributes.title ?? value : value ?? null; }
825
856
  function fieldLabel(field) { return field[0]?.toUpperCase() + field.slice(1).replaceAll("_", " "); }
826
857
  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). */
858
+ /** The lettered lines of a Review notes row: its changes, then its notes, then its links (cli §5.14.4, 0045). */
828
859
  function reviewNotesAfter(attributes, workspace) {
860
+ const changes = (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).map((change) => {
861
+ const target = String(noteTitle(change.target, workspace));
862
+ if (change.kind === "update_fact")
863
+ return `⚠ ${fieldLabel(String(change.field))} on ${target}: ${change.before === undefined ? "(new)" : String(noteTitle(change.before, workspace))} → ${String(noteTitle(change.after, workspace))}`;
864
+ if (change.kind === "update_body")
865
+ return `⚠ ${target}: ${bodyChange(change, workspace)?.length ?? 0} lines changed`;
866
+ return `Add to ${target}: ${String(change.content ?? "").split("\n")[0].slice(0, 80)}`;
867
+ });
829
868
  const notes = (Array.isArray(attributes.notes) ? attributes.notes : []).map((id) => {
830
869
  const note = workspace.get(String(id));
831
870
  return `${note === undefined ? "Note" : fieldLabel(note.type)}: ${String(noteTitle(id, workspace))}`;
832
871
  });
833
872
  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");
873
+ return [...changes, ...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
874
+ }
875
+ /** The changed lines an update_body proposes against its target's body (cli §5.19), or undefined once that body changed. */
876
+ function bodyChange(change, workspace) {
877
+ const target = typeof change.target === "string" ? workspace.get(change.target) : undefined;
878
+ if (target === undefined || bodyHash(target.body) !== change.before_hash)
879
+ return undefined;
880
+ return lineDiff(target.body, bodyOf(String(change.content ?? "")));
881
+ }
882
+ /** Whether a proposal holds an update_body whose target's body is no longer the one it was proposed against. */
883
+ function staleBody(attributes, workspace) {
884
+ const bodies = attributes.kind === "update_body" ? [attributes] : (Array.isArray(attributes.changes) ? attributes.changes.filter(isRecord) : []).filter((change) => change.kind === "update_body");
885
+ return bodies.some((change) => bodyChange(change, workspace) === undefined);
835
886
  }
836
887
  /** a, b, … z, then aa, ab, … */
837
888
  export function letters(index) {
@@ -862,7 +913,7 @@ function digestBody(note) {
862
913
  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
914
  }
864
915
  function proposalBefore(attributes, workspace) {
865
- if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer")
916
+ if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer" || attributes.kind === "update_body")
866
917
  return null;
867
918
  if (attributes.kind === "supersede")
868
919
  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.5.0",
4
4
  "description": "CLI for an AI-assisted Markdown and Git second brain",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -16,7 +16,8 @@
16
16
  "questions": "",
17
17
  "review": "",
18
18
  "inbox": "",
19
- "concepts": ""
19
+ "concepts": "",
20
+ "references": ""
20
21
  },
21
22
  "tasks": {
22
23
  "database": "",
File without changes