ultimate-brain 0.3.2 → 0.4.1

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 (35) hide show
  1. package/.agents/skills/brain-apply/SKILL.md +10 -6
  2. package/.agents/skills/brain-catch-up/SKILL.md +28 -15
  3. package/.agents/skills/brain-howto/references/guide.md +49 -12
  4. package/.agents/skills/brain-ingest/SKILL.md +14 -8
  5. package/.agents/skills/brain-ingest/references/cli-inputs.md +19 -2
  6. package/.agents/skills/brain-ingest/references/extraction.md +11 -7
  7. package/.agents/skills/brain-ingest/references/notion.md +26 -11
  8. package/.agents/skills/brain-ingest/references/sources.md +24 -1
  9. package/.agents/skills/brain-notion-change/SKILL.md +16 -2
  10. package/.agents/skills/brain-query/SKILL.md +22 -2
  11. package/.agents/skills/brain-sync/SKILL.md +9 -6
  12. package/.agents/skills/brain-sync/references/formats.md +1 -1
  13. package/dist/cli.js +5 -2
  14. package/dist/commands/proposal.js +21 -1
  15. package/dist/commands/repo.js +110 -0
  16. package/dist/commands/report.js +115 -0
  17. package/dist/commands/source.js +56 -0
  18. package/dist/commands/status.js +4 -1
  19. package/dist/commands/views.js +30 -15
  20. package/dist/lib/apply.js +83 -10
  21. package/dist/lib/errors.js +3 -0
  22. package/dist/lib/frontmatter.js +9 -4
  23. package/dist/lib/lint.js +34 -5
  24. package/dist/lib/note-new.js +44 -0
  25. package/dist/lib/notes.js +1 -0
  26. package/dist/lib/parts.js +82 -0
  27. package/dist/lib/proposals.js +183 -8
  28. package/dist/lib/repos.js +129 -0
  29. package/dist/lib/sync.js +190 -26
  30. package/dist/lib/views.js +53 -15
  31. package/package.json +1 -1
  32. package/spec/schemas/brain.schema.json +3 -0
  33. package/spec/schemas/note.schema.json +130 -2
  34. package/spec/schemas/proposal.schema.json +461 -14
  35. package/templates/brain/AGENTS.md +4 -4
package/dist/lib/sync.js CHANGED
@@ -6,9 +6,10 @@ 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
10
  import { loadSchemas } from "./schemas.js";
10
11
  import { formatDate, formatTimestamp, normalizeTimestamp } from "./time.js";
11
- const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "review"];
12
+ const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "review", "inbox"];
12
13
  const CREATABLE = new Set(["areas", "goals", "projects", "people", "orgs"]);
13
14
  const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", review: "proposal" };
14
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" };
@@ -71,7 +72,7 @@ export function parseSnapshot(input) {
71
72
  }
72
73
  /** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
73
74
  export function buildPlan(workspace, snapshot) {
74
- const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], conflicts: [], ignored: [] };
75
+ const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], conflicts: [], ignored: [] };
75
76
  const createsByPage = new Map();
76
77
  for (const row of snapshot.rows)
77
78
  if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
@@ -84,6 +85,10 @@ export function buildPlan(workspace, snapshot) {
84
85
  planKeyResult(workspace, snapshot.as_of, row, result);
85
86
  continue;
86
87
  }
88
+ if (row.database === "inbox") {
89
+ planInbox(workspace, row, result);
90
+ continue;
91
+ }
87
92
  const brainId = property(row.properties, "Brain ID");
88
93
  let note = typeof brainId === "string" && brainId !== "" ? workspace.get(brainId) : noteByNotion(workspace, row.page_id);
89
94
  if (typeof brainId === "string" && brainId !== "" && note === undefined) {
@@ -318,6 +323,31 @@ function planDecision(row, proposal, out) {
318
323
  }
319
324
  out.decisions.push({ proposal: proposal.id, action: action, ...(action === "edit" ? { value: note, note: note } : {}), decided_at: row.last_edited, decided_by: "notion" });
320
325
  }
326
+ /** An Inbox row is read only for its Full text box (cli §5.14.2, 0043). */
327
+ function planInbox(workspace, row, out) {
328
+ if (property(row.properties, "Full text") !== true)
329
+ return;
330
+ 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" });
332
+ if (typeof brainId !== "string" || brainId === "") {
333
+ ignore(null);
334
+ return;
335
+ }
336
+ const note = workspace.get(brainId);
337
+ if (note === undefined) {
338
+ out.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
339
+ return;
340
+ }
341
+ if (hasInboxBody(note))
342
+ out.full_text_requests.push({ page_id: row.page_id, source: note.id });
343
+ else
344
+ ignore(note.id);
345
+ }
346
+ /** A source whose Inbox row the brain writes: not minutes, a capture or an attached file (0042, 0043). */
347
+ export function hasInboxBody(note) {
348
+ const kind = note.attributes.kind;
349
+ return note.type === "source" && kind !== undefined && kind !== "minutes" && kind !== "capture" && note.attributes.attached_to === undefined;
350
+ }
321
351
  function planKeyResult(workspace, asOf, row, out) {
322
352
  const goals = relationIds(workspace, property(row.properties, "Goal"), out.conflicts, row, "Goal");
323
353
  const goal = goals?.[0];
@@ -339,7 +369,7 @@ function planKeyResult(workspace, asOf, row, out) {
339
369
  }
340
370
  function sortPlan(plan) {
341
371
  const order = (item) => [DATABASES.indexOf(String(item.database)), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
342
- for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.conflicts, plan.ignored])
372
+ for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.full_text_requests, plan.conflicts, plan.ignored])
343
373
  values.sort((a, b) => order(a).localeCompare(order(b)));
344
374
  }
345
375
  function notionFields(database, p, asOf) {
@@ -416,7 +446,9 @@ export function parsePlan(input) {
416
446
  if (!isRecord(input))
417
447
  assertNoProblems([{ pointer: "", message: "must be an object" }]);
418
448
  const p = input;
419
- const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "conflicts", "ignored"], "");
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" });
420
452
  if (!isTimestamp(p.as_of))
421
453
  problems.push({ pointer: "/as_of", message: "must be an ISO 8601 UTC timestamp" });
422
454
  for (const key of ["create", "updates", "corrections", "decisions", "conflicts", "ignored"])
@@ -434,7 +466,7 @@ export function parsePlan(input) {
434
466
  problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
435
467
  });
436
468
  assertNoProblems(problems);
437
- return { ...p, as_of: normalizeTimestamp(p.as_of) };
469
+ return { ...p, full_text_requests: p.full_text_requests ?? [], as_of: normalizeTimestamp(p.as_of) };
438
470
  }
439
471
  export function applyPlan(workspace, plan, now, recordOnly) {
440
472
  const create = [];
@@ -506,7 +538,7 @@ export function applyPlan(workspace, plan, now, recordOnly) {
506
538
  ...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) })),
507
539
  ...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
508
540
  ];
509
- return { create, updates, corrections, decisions: decisionResult, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
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 };
510
542
  }
511
543
  function references(value) { if (Array.isArray(value))
512
544
  return value.flatMap(references); if (isRecord(value))
@@ -515,19 +547,34 @@ function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0,
515
547
  if (typeof item.result === "string" && Object.hasOwn(result, item.result))
516
548
  result[item.result] += 1; return result; }
517
549
  export function render(workspace, now, ids, held) {
518
- const selected = ids === undefined ? workspace.notes.filter((note) => PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) : ids.map((id) => workspace.get(id)).filter(Boolean);
550
+ 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))
552
+ : ids.map((id) => workspace.get(id)).filter(Boolean);
519
553
  const items = [];
520
554
  const errors = [];
521
555
  const warnings = [];
522
556
  for (const note of selected) {
523
- const database = DATABASE_FOR_TYPE[note.type];
557
+ const database = isReport(note) ? "inbox" : DATABASE_FOR_TYPE[note.type];
524
558
  if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
525
559
  continue;
560
+ // A published report is never rendered again (0043).
561
+ if (isReport(note) && note.attributes.notion !== undefined)
562
+ continue;
563
+ // An attached source has no Inbox row: its notes go on the parent's (0042).
564
+ if (note.type === "source" && note.attributes.attached_to !== undefined)
565
+ continue;
566
+ // A decided Review row leaves Notion 30 days after its decision, once (0045).
567
+ if (note.type === "proposal" && isRecord(note.attributes.notion_published) && note.attributes.notion_published["@trashed"] !== undefined)
568
+ continue;
526
569
  const databaseId = databaseIdFor(workspace, database);
527
570
  if (databaseId === "") {
528
571
  warnings.push({ code: "DATABASE_NOT_CONFIGURED", message: `${database} database is not configured`, id: note.id });
529
572
  continue;
530
573
  }
574
+ if (note.type === "proposal" && trashDue(note, now)) {
575
+ items.push({ id: note.id, type: note.type, database, database_id: databaseId, page_id: String(note.attributes.notion), action: "trash", properties: {}, body: null, sections: null, pending_relations: [], published: { "@trashed": formatTimestamp(now) } });
576
+ continue;
577
+ }
531
578
  if (note.type === "meeting" && typeof note.attributes.notion !== "string") {
532
579
  errors.push({ id: note.id, reason: "MEETING_NOT_PUBLISHED" });
533
580
  continue;
@@ -546,7 +593,7 @@ export function render(workspace, now, ids, held) {
546
593
  const properties = {};
547
594
  const pending = [];
548
595
  const heldProperties = held?.get(note.id) ?? new Set();
549
- const isCreate = note.attributes.notion === undefined && capturePage === undefined;
596
+ const isCreate = isReport(note) || (note.attributes.notion === undefined && capturePage === undefined);
550
597
  for (const [name, value] of Object.entries(rendered)) {
551
598
  if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
552
599
  continue;
@@ -558,7 +605,16 @@ export function render(workspace, now, ids, held) {
558
605
  if (content.hash !== undefined)
559
606
  rendered["@content"] = content.hash;
560
607
  const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
561
- if (!isCreate && Object.keys(properties).length === 0 && !contentChanged)
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)
562
618
  continue;
563
619
  if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
564
620
  properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
@@ -567,6 +623,16 @@ export function render(workspace, now, ids, held) {
567
623
  items.sort((a, b) => DATABASES.concat(["meetings"]).indexOf(a.database) - DATABASES.concat(["meetings"]).indexOf(b.database) || compareCodePoints(a.id, b.id));
568
624
  return { items, errors, warnings };
569
625
  }
626
+ const TRASH_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
627
+ /** A published Review row whose proposal was applied or closed more than 30 days before `now` (0045). */
628
+ function trashDue(note, now) {
629
+ const a = note.attributes;
630
+ if (typeof a.notion !== "string" || (a.status !== "applied" && a.status !== "closed"))
631
+ return false;
632
+ const decided = isRecord(a.decision) && typeof a.decision.decided_at === "string" ? a.decision.decided_at : a.updated;
633
+ const time = typeof decided === "string" ? Date.parse(decided) : Number.NaN;
634
+ return Number.isFinite(time) && now.getTime() - time > TRASH_AFTER_MS;
635
+ }
570
636
  function databaseIdFor(workspace, database) { const notion = workspace.config.notion; if (!isRecord(notion) || !isRecord(notion.databases))
571
637
  return ""; return typeof notion.databases[database] === "string" ? notion.databases[database] : database === "inbox" && typeof notion.databases.capture_inbox === "string" ? notion.databases.capture_inbox : ""; }
572
638
  function renderedBrainValues(note, workspace) {
@@ -578,15 +644,26 @@ function renderedBrainValues(note, workspace) {
578
644
  const addLink = a.kind === "add_link";
579
645
  const after = addLink
580
646
  ? proposalLinks(a).map((link) => `${fieldLabel(String(link.field))}: ${noteTitle(link.value, workspace)}`).join("\n")
581
- : undefined;
647
+ : a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : undefined;
582
648
  const reviewText = (value) => truncate(typeof value === "string" ? reviewMarkdown(value, target) : value);
649
+ // A Review notes row names its source: the meeting for minutes, else the source (0041).
650
+ const source = a.kind === "review_notes" && typeof a.source === "string" ? workspace.get(a.source) : undefined;
651
+ const targetText = source !== undefined ? `${source.attributes.title} (${source.id})` : target === undefined ? null : `${target.attributes.title} (${target.id})`;
652
+ const page = (source ?? target)?.attributes.notion;
653
+ const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
583
654
  Object.assign(values, {
584
- Name: a.title, Kind: select(a.kind), Target: target === undefined ? null : `${target.attributes.title} (${target.id})`,
655
+ Name: a.title, Kind: select(a.kind), Target: targetText,
656
+ "Target Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
657
+ "Source Meeting": meeting?.type === "meeting" && typeof meeting.attributes.notion === "string" ? [meeting.attributes.notion] : [],
585
658
  Before: reviewText(proposalBefore(a, workspace)), After: reviewText(after ?? proposalAfter(a, workspace)),
586
659
  Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
587
660
  });
588
661
  return values;
589
662
  }
663
+ 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 });
665
+ return values;
666
+ }
590
667
  if (note.type === "meeting") {
591
668
  Object.assign(values, { Decisions: relationPages(a.decisions, workspace), Questions: relationPages(a.questions, workspace), Attendees: relationPages(a.attendees, workspace), Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), "Brain Status": "Ingested", "Ingested At": dateOutput(a.created), "Brain ID": note.id });
592
669
  return values;
@@ -604,6 +681,17 @@ function renderedBrainValues(note, workspace) {
604
681
  Object.assign(values, { Date: dateOutput(a.date), Assessment: select(a.assessment), Area: relationPages(projectAreas(a.projects, workspace), workspace), Project: relationPages(a.projects, workspace), "Decided In": relationPages(a.decided_in === undefined ? [] : [a.decided_in], workspace), Supersedes: relationPages((Array.isArray(a.relations) ? a.relations.filter(isRecord).filter((x) => x.type === "supersedes").map((x) => x.target) : []), workspace) });
605
682
  if (note.type === "question")
606
683
  Object.assign(values, { Kind: select(a.kind), Area: relationPages(projectAreas(a.projects, workspace), workspace), Project: relationPages(a.projects, workspace), "Raised In": relationPages(a.raised_in === undefined ? [] : [a.raised_in], workspace) });
684
+ // Status goes out once as Open while you never set one, and once as Answered after an approved answer; it stays yours otherwise (0041, 0045).
685
+ if (note.type === "question") {
686
+ const published = isRecord(a.notion_published) ? a.notion_published : {};
687
+ const yours = isRecord(a.notion_fields) && a.notion_fields.status !== undefined;
688
+ if (a.answer !== undefined) {
689
+ if (published.Status !== "Answered")
690
+ values.Status = "Answered";
691
+ }
692
+ else if (published.Status === undefined && !yours)
693
+ values.Status = "Open";
694
+ }
607
695
  if (note.type === "concept")
608
696
  Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
609
697
  return values;
@@ -616,8 +704,13 @@ function projectAreas(projects, workspace) {
616
704
  });
617
705
  return [...new Set(areas)].sort(compareCodePoints);
618
706
  }
707
+ /** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
708
+ function producedBy(note, workspace) {
709
+ 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))));
711
+ }
619
712
  function sourceValues(note, workspace) {
620
- const produced = workspace.notes.filter((candidate) => ["decision", "question", "concept", "person"].includes(candidate.type) && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.includes(note.id));
713
+ const produced = producedBy(note, workspace);
621
714
  const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
622
715
  const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
623
716
  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);
@@ -634,14 +727,24 @@ function notionProperty(name, value, note, workspace, pending) {
634
727
  const mode = (note.type === "meeting" || note.type === "source") ? (["Project", "Area"].includes(name) ? "if_empty" : note.type === "meeting" ? "add" : undefined) : undefined;
635
728
  return { type: "relation", value: Array.isArray(value) ? value : [], ...(mode === undefined ? {} : { mode }) };
636
729
  }
637
- if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status"].includes(name))
730
+ if (name === "Source Meeting") {
731
+ const meeting = typeof note.attributes.source === "string" ? workspace.get(note.attributes.source) : undefined;
732
+ if (meeting?.type === "meeting" && typeof meeting.attributes.notion !== "string")
733
+ pending.push({ property: name, ids: [meeting.id] });
734
+ return { type: "relation", value: Array.isArray(value) ? value : [] };
735
+ }
736
+ if (name === "Ask")
737
+ return { type: "relation", value: Array.isArray(value) ? value : [] };
738
+ if (name === "Target Page")
739
+ return { type: "url", value };
740
+ if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
638
741
  return { type: "select", value };
639
742
  if (name === "Last Synced" || name === "Date" || name === "Deadline" || name === "Ingested At")
640
743
  return { type: "date", value };
641
744
  return { type: name === "Name" ? "title" : "rich_text", value };
642
745
  }
643
746
  function sourceRelationIds(note, name, workspace) {
644
- const produced = workspace.notes.filter((candidate) => ["decision", "question", "concept", "person"].includes(candidate.type) && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.includes(note.id));
747
+ const produced = producedBy(note, workspace);
645
748
  if (name === "Decisions")
646
749
  return produced.filter((candidate) => candidate.type === "decision").map((candidate) => candidate.id);
647
750
  if (name === "Questions")
@@ -662,15 +765,37 @@ function proposalLinks(attributes) {
662
765
  return attributes.links.filter(isRecord).map((link) => ({ field: link.field, value: link.value }));
663
766
  return attributes.field === undefined ? [] : [{ field: attributes.field, value: attributes.after }];
664
767
  }
665
- function renderedContent(note, workspace) { if (note.type === "decision" || note.type === "question" || note.type === "concept") {
666
- const body = notionMarkdown(note.body, note, workspace);
667
- return { body, sections: null, hash: hash(body) };
668
- } if (note.type === "project") {
669
- const brief = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/brief.md`);
670
- const log = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/decision-log.md`);
671
- const sections = { Brief: notionMarkdown(generatedBlock(brief?.body ?? ""), note, workspace), "Decision log": notionMarkdown(generatedBlock(log?.body ?? ""), note, workspace) };
672
- return { body: null, sections, hash: hash(JSON.stringify(sections)) };
673
- } return { body: null, sections: null }; }
768
+ function renderedContent(note, workspace) {
769
+ if (note.type === "area") {
770
+ const brief = workspace.notes.find((x) => x.type === "view" && x.attributes.view_kind === "area_brief" && x.attributes.area === note.id);
771
+ if (brief === undefined)
772
+ return { body: null, sections: null };
773
+ const sections = { Brief: notionMarkdown(generatedBlock(brief.body), brief, workspace) };
774
+ return { body: null, sections, hash: hash(JSON.stringify(sections)) };
775
+ }
776
+ // A source's digest is written once; its Full text is tracked by @full_text, not @content (0043).
777
+ if (note.type === "source") {
778
+ if (!hasInboxBody(note))
779
+ return { body: null, sections: null };
780
+ const body = digestBody(note);
781
+ return { body, sections: null, hash: hash(body) };
782
+ }
783
+ if (isReport(note)) {
784
+ const body = notionMarkdown(note.body.replace(/^\n+/, ""), note, workspace);
785
+ return { body, sections: null, hash: hash(body) };
786
+ }
787
+ if (note.type === "decision" || note.type === "question" || note.type === "concept") {
788
+ const body = notionMarkdown(note.body, note, workspace);
789
+ return { body, sections: null, hash: hash(body) };
790
+ }
791
+ if (note.type === "project") {
792
+ const brief = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/brief.md`);
793
+ const log = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/decision-log.md`);
794
+ const sections = { Brief: notionMarkdown(generatedBlock(brief?.body ?? ""), note, workspace), "Decision log": notionMarkdown(generatedBlock(log?.body ?? ""), note, workspace) };
795
+ return { body: null, sections, hash: hash(JSON.stringify(sections)) };
796
+ }
797
+ return { body: null, sections: null };
798
+ }
674
799
  function generatedBlock(body) { const start = body.indexOf("<!-- @generated:start -->"); const end = body.indexOf("<!-- @generated:end -->"); return start >= 0 && end > start ? body.slice(start + "<!-- @generated:start -->".length, end).trim() : body.trim(); }
675
800
  function notionMarkdown(body, source, workspace) { const fromFolder = source.path.includes("/") ? source.path.slice(0, source.path.lastIndexOf("/")) : ""; return body.replace(/\^\[(inferred|ambiguous)\]/g, (_, kind) => kind === "inferred" ? " (AI inference)" : " (ambiguous)").replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_all, title, link) => { const targetPath = normalizeRelative(fromFolder, link); const target = workspace.notes.find((note) => note.path === targetPath); return typeof target?.attributes.notion === "string" ? `[${title}](https://www.notion.so/${target.attributes.notion.replaceAll("-", "")})` : title; }); }
676
801
  function reviewMarkdown(body, target) {
@@ -699,8 +824,45 @@ function personTitle(value, workspace) { return typeof value === "string" && wor
699
824
  function noteTitle(value, workspace) { return typeof value === "string" ? workspace.get(value)?.attributes.title ?? value : value ?? null; }
700
825
  function fieldLabel(field) { return field[0]?.toUpperCase() + field.slice(1).replaceAll("_", " "); }
701
826
  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). */
828
+ function reviewNotesAfter(attributes, workspace) {
829
+ const notes = (Array.isArray(attributes.notes) ? attributes.notes : []).map((id) => {
830
+ const note = workspace.get(String(id));
831
+ return `${note === undefined ? "Note" : fieldLabel(note.type)}: ${String(noteTitle(id, workspace))}`;
832
+ });
833
+ 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");
835
+ }
836
+ /** a, b, … z, then aa, ab, … */
837
+ export function letters(index) {
838
+ return index < 26 ? String.fromCharCode(97 + index) : `${letters(Math.floor(index / 26) - 1)}${letters(index % 26)}`;
839
+ }
840
+ 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
+ /** A brain-created Inbox row's body: a line about the source, its summary and its digest (cli §5.14.4). */
854
+ function digestBody(note) {
855
+ const a = note.attributes;
856
+ const origin = typeof a.file_name === "string" ? a.file_name : typeof a.source_key === "string" && a.source_key.startsWith("url:") ? a.source_key.slice(4) : "pasted text";
857
+ const date = typeof a.captured === "string" ? a.captured.slice(0, 10) : "";
858
+ const fileDate = /^sources\/[^/]+\/(\d{4}-\d{2}-\d{2})-/.exec(note.path)?.[1] ?? date;
859
+ const parts = splitParts(note.body);
860
+ const words = note.body.split(/\s+/).filter((word) => word !== "").length;
861
+ const line = `*${String(select(a.kind))} · ${origin} · ${fileDate} · ${words} words${parts.kind === "slides" ? ` · ${parts.parts.length} slides` : ""}*`;
862
+ 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
+ }
702
864
  function proposalBefore(attributes, workspace) {
703
- if (attributes.kind === "add_link")
865
+ if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer")
704
866
  return null;
705
867
  if (attributes.kind === "supersede")
706
868
  return truncate(noteTitle(attributes.supersedes, workspace));
@@ -717,6 +879,8 @@ function proposalAfter(attributes, workspace) {
717
879
  return noteTitle(attributes.target, workspace);
718
880
  if (attributes.kind === "merge")
719
881
  return noteTitle(attributes.merge_into, workspace);
882
+ if (attributes.kind === "answer")
883
+ return `${String(attributes.answer)}${attributes.answer_note === undefined ? "" : ` → ${String(noteTitle(attributes.answer_note, workspace))}`}`;
720
884
  return attributes.after ?? null;
721
885
  }
722
886
  export function renderTasks(workspace, targetId, input) {
@@ -823,7 +987,7 @@ export function record(workspace, input) {
823
987
  }
824
988
  if (isRecord(entry.published))
825
989
  doc.attributes.notion_published = { ...(isRecord(doc.attributes.notion_published) ? doc.attributes.notion_published : {}), ...entry.published };
826
- if (note.type !== "view" && note.type !== "proposal")
990
+ if ((note.type !== "view" || isReport(note)) && note.type !== "proposal")
827
991
  doc.attributes.last_synced = syncedAt;
828
992
  if ((note.type === "meeting" || note.type === "source") && Array.isArray(entry.tasks))
829
993
  doc.attributes.tasks = [...new Set([...(Array.isArray(doc.attributes.tasks) ? doc.attributes.tasks : []), ...entry.tasks.filter((id) => typeof id === "string")])];
package/dist/lib/views.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { posix } from "node:path";
2
2
  import { compareCodePoints } from "./notes.js";
3
3
  const CURRENT_ASSESSMENTS = new Set(["accepted", "provisional", "contested"]);
4
- /** Render one project view from canonical notes only (spec/schema.md §16). */
4
+ /** Render one project view, or an area's brief, from canonical notes only (spec/schema.md §16). */
5
5
  export function deriveView(project, kind, notes) {
6
6
  const canonical = notes.filter((note) => note.type !== "view");
7
7
  const byId = new Map(canonical.map((note) => [note.id, note]));
8
8
  const generated = new Set([project.id]);
9
- const link = (note) => noteLink(projectViewPath(project, kind), note);
9
+ const link = (note) => noteLink(kind === "area_brief" ? areaBriefPath(project) : projectViewPath(project, kind), note);
10
10
  const title = (note) => text(note.attributes.title);
11
11
  const add = (note) => {
12
12
  generated.add(note.id);
@@ -14,10 +14,16 @@ export function deriveView(project, kind, notes) {
14
14
  };
15
15
  const sections = kind === "brief"
16
16
  ? briefSections(project, canonical, byId, title, add)
17
- : decisionLogSections(project, canonical, byId, title, add, link);
17
+ : kind === "area_brief"
18
+ ? areaBriefSections(project, canonical, byId, title, add)
19
+ : decisionLogSections(project, canonical, byId, title, add, link);
18
20
  const generatedBlock = ["<!-- @generated:start -->", ...sections.flatMap((section, index) => index === 0 ? section : ["", ...section]), "<!-- @generated:end -->"].join("\n");
19
21
  return { kind, generatedBlock, generatedFrom: [...generated].sort(compareCodePoints) };
20
22
  }
23
+ /** An area's brief: `areas/briefs/<slug of the area's file name>.md` (spec/schema.md §16.3). */
24
+ export function areaBriefPath(area) {
25
+ return `areas/briefs/${posix.basename(area.path, ".md")}.md`;
26
+ }
21
27
  /** The standard view filename beside a project's `project.md`. */
22
28
  export function projectViewPath(project, kind) {
23
29
  return `${posix.dirname(project.path)}/${kind === "brief" ? "brief.md" : "decision-log.md"}`;
@@ -59,25 +65,57 @@ function briefSections(project, notes, byId, title, add) {
59
65
  const role = scalar(record(person.attributes.facts).role);
60
66
  return `- ${add(person)}${role === undefined ? "" : ` — ${role}`}`;
61
67
  }))];
62
- const decisions = notes
63
- .filter((note) => note.type === "decision" && belongsToProject(note, project) && CURRENT_ASSESSMENTS.has(String(note.attributes.assessment)))
68
+ const decisionsSection = recentDecisions(notes, (note) => belongsToProject(note, project), add);
69
+ const questionsSection = openQuestions(notes, (note) => belongsToProject(note, project), add, Infinity);
70
+ const meetings = notes
71
+ .filter((note) => note.type === "meeting" && belongsToProject(note, project))
64
72
  .sort(sortDescendingBy("date"))
65
73
  .slice(0, 5);
66
- const decisionsSection = ["## Recent decisions", ...(decisions.length === 0 ? ["- None"] : decisions.map((decision) => `- ${text(decision.attributes.date)} — ${add(decision)} (${text(decision.attributes.assessment)})`))];
67
- const questions = notes
68
- .filter((note) => note.type === "question" && belongsToProject(note, project))
74
+ const meetingsSection = ["## Recent meetings", ...(meetings.length === 0 ? ["- None"] : meetings.map((meeting) => `- ${text(meeting.attributes.date)} — ${add(meeting)}`))];
75
+ return [status, goalSection, peopleSection, decisionsSection, questionsSection, meetingsSection];
76
+ }
77
+ /** An area's brief (spec/schema.md §16.3). */
78
+ function areaBriefSections(area, notes, byId, title, add) {
79
+ const projects = notes
80
+ .filter((note) => note.type === "project" && note.attributes.area === area.id && record(note.attributes.notion_fields).archived !== true)
81
+ .sort((left, right) => compareCodePoints(title(left), title(right)) || compareCodePoints(left.id, right.id));
82
+ const projectsSection = ["## Projects", ...(projects.length === 0 ? ["- None"] : projects.map((project) => {
83
+ const status = scalar(record(project.attributes.notion_fields).status);
84
+ return `- ${add(project)}${status === undefined ? "" : ` — ${status}`}`;
85
+ }))];
86
+ // A note belongs to the area through its own area or the area of one of its projects.
87
+ const belongs = (note) => note.attributes.area === area.id
88
+ || list(note.attributes.projects).some((id) => typeof id === "string" && byId.get(id)?.attributes.area === area.id);
89
+ return [projectsSection, recentDecisions(notes, belongs, add), openQuestions(notes, belongs, add, 10)];
90
+ }
91
+ /** `## Recent decisions`: reviewed current decisions, newest first, at most 5 (spec/schema.md §16.1 item 4). */
92
+ function recentDecisions(notes, belongs, add) {
93
+ const candidates = notes
94
+ .filter((note) => note.type === "decision" && belongs(note) && CURRENT_ASSESSMENTS.has(String(note.attributes.assessment)))
95
+ .sort(sortDescendingBy("date"));
96
+ return reviewedSection("## Recent decisions", candidates, 5, (decision) => `- ${text(decision.attributes.date)} — ${add(decision)} (${text(decision.attributes.assessment)})`);
97
+ }
98
+ /** `## Open questions`: reviewed, unanswered questions still Open, newest first (spec/schema.md §16.1 item 5). */
99
+ function openQuestions(notes, belongs, add, limit) {
100
+ const candidates = notes
101
+ .filter((note) => note.type === "question" && belongs(note) && note.attributes.answer === undefined)
69
102
  .filter((note) => {
70
103
  const status = record(note.attributes.notion_fields).status;
71
104
  return status === undefined || status === "Open";
72
105
  })
73
106
  .sort(sortDescendingBy("created"));
74
- const questionsSection = ["## Open questions", ...(questions.length === 0 ? ["- None"] : questions.map((question) => `- ${add(question)} (${text(question.attributes.kind)})`))];
75
- const meetings = notes
76
- .filter((note) => note.type === "meeting" && belongsToProject(note, project))
77
- .sort(sortDescendingBy("date"))
78
- .slice(0, 5);
79
- const meetingsSection = ["## Recent meetings", ...(meetings.length === 0 ? ["- None"] : meetings.map((meeting) => `- ${text(meeting.attributes.date)} — ${add(meeting)}`))];
80
- return [status, goalSection, peopleSection, decisionsSection, questionsSection, meetingsSection];
107
+ return reviewedSection("## Open questions", candidates, limit, (question) => `- ${add(question)} (${text(question.attributes.kind)})`);
108
+ }
109
+ /**
110
+ * Only `reviewed` notes make item lines (0041). With none, the drafts that would qualify are
111
+ * counted in a waiting line, and produce no item line.
112
+ */
113
+ function reviewedSection(heading, candidates, limit, line) {
114
+ const reviewed = candidates.filter((note) => note.attributes.lifecycle === "reviewed").slice(0, limit);
115
+ if (reviewed.length > 0)
116
+ return [heading, ...reviewed.map(line)];
117
+ const waiting = candidates.filter((note) => note.attributes.lifecycle === "draft").length;
118
+ return [heading, waiting === 0 ? "- None" : `- Nothing reviewed yet: ${waiting} note${waiting === 1 ? "" : "s"} waiting in Review`];
81
119
  }
82
120
  function decisionLogSections(project, notes, byId, _title, add, link) {
83
121
  const decisions = notes.filter((note) => note.type === "decision" && belongsToProject(note, project));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-brain",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "CLI for an AI-assisted Markdown and Git second brain",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -156,6 +156,9 @@
156
156
  },
157
157
  "concepts": {
158
158
  "$ref": "#/$defs/dbId"
159
+ },
160
+ "references": {
161
+ "$ref": "#/$defs/dbId"
159
162
  }
160
163
  }
161
164
  },