ultimate-brain 0.3.1 → 0.4.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 (38) hide show
  1. package/.agents/skills/brain-apply/SKILL.md +10 -6
  2. package/.agents/skills/brain-catch-up/SKILL.md +27 -14
  3. package/.agents/skills/brain-connect-notion/SKILL.md +2 -2
  4. package/.agents/skills/brain-howto/references/guide.md +44 -11
  5. package/.agents/skills/brain-ingest/SKILL.md +14 -8
  6. package/.agents/skills/brain-ingest/references/cli-inputs.md +19 -2
  7. package/.agents/skills/brain-ingest/references/extraction.md +11 -7
  8. package/.agents/skills/brain-ingest/references/notion.md +25 -11
  9. package/.agents/skills/brain-ingest/references/sources.md +24 -1
  10. package/.agents/skills/brain-notion-change/SKILL.md +16 -2
  11. package/.agents/skills/brain-query/SKILL.md +22 -2
  12. package/.agents/skills/brain-sync/SKILL.md +6 -4
  13. package/.agents/skills/brain-sync/references/formats.md +1 -1
  14. package/README.md +1 -1
  15. package/dist/cli.js +5 -2
  16. package/dist/commands/proposal.js +21 -1
  17. package/dist/commands/repo.js +110 -0
  18. package/dist/commands/report.js +115 -0
  19. package/dist/commands/source.js +56 -0
  20. package/dist/commands/status.js +4 -1
  21. package/dist/commands/views.js +30 -15
  22. package/dist/lib/apply.js +83 -10
  23. package/dist/lib/errors.js +3 -0
  24. package/dist/lib/frontmatter.js +9 -4
  25. package/dist/lib/lint.js +34 -5
  26. package/dist/lib/note-new.js +44 -0
  27. package/dist/lib/notes.js +1 -0
  28. package/dist/lib/parts.js +82 -0
  29. package/dist/lib/proposals.js +183 -8
  30. package/dist/lib/repos.js +129 -0
  31. package/dist/lib/sync.js +167 -28
  32. package/dist/lib/views.js +53 -15
  33. package/package.json +1 -1
  34. package/spec/schemas/brain.schema.json +46 -0
  35. package/spec/schemas/note.schema.json +343 -3
  36. package/spec/schemas/proposal.schema.json +393 -10
  37. package/templates/brain/AGENTS.md +4 -4
  38. package/templates/brain/brain.json +2 -1
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,14 +547,22 @@ 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;
526
566
  const databaseId = databaseIdFor(workspace, database);
527
567
  if (databaseId === "") {
528
568
  warnings.push({ code: "DATABASE_NOT_CONFIGURED", message: `${database} database is not configured`, id: note.id });
@@ -546,7 +586,7 @@ export function render(workspace, now, ids, held) {
546
586
  const properties = {};
547
587
  const pending = [];
548
588
  const heldProperties = held?.get(note.id) ?? new Set();
549
- const isCreate = note.attributes.notion === undefined && capturePage === undefined;
589
+ const isCreate = isReport(note) || (note.attributes.notion === undefined && capturePage === undefined);
550
590
  for (const [name, value] of Object.entries(rendered)) {
551
591
  if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
552
592
  continue;
@@ -558,7 +598,16 @@ export function render(workspace, now, ids, held) {
558
598
  if (content.hash !== undefined)
559
599
  rendered["@content"] = content.hash;
560
600
  const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
561
- if (!isCreate && Object.keys(properties).length === 0 && !contentChanged)
601
+ // Full text (0043): the text goes out once per choice, and a ticked box is cleared once per request.
602
+ const full = note.type === "source" && hasInboxBody(note) ? fullTextDue(note, published["@full_text"]) : undefined;
603
+ if (full?.sections !== undefined)
604
+ content.sections = { ...(content.sections ?? {}), ...full.sections };
605
+ if (full?.clear === true)
606
+ properties["Full text"] = { type: "checkbox", value: false };
607
+ if (full !== undefined && (full.sections !== undefined || full.clear))
608
+ rendered["@full_text"] = full.record;
609
+ const fullTextDueNow = full !== undefined && (full.sections !== undefined || full.clear);
610
+ if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow)
562
611
  continue;
563
612
  if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
564
613
  properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
@@ -578,15 +627,26 @@ function renderedBrainValues(note, workspace) {
578
627
  const addLink = a.kind === "add_link";
579
628
  const after = addLink
580
629
  ? proposalLinks(a).map((link) => `${fieldLabel(String(link.field))}: ${noteTitle(link.value, workspace)}`).join("\n")
581
- : undefined;
630
+ : a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : undefined;
582
631
  const reviewText = (value) => truncate(typeof value === "string" ? reviewMarkdown(value, target) : value);
632
+ // A Review notes row names its source: the meeting for minutes, else the source (0041).
633
+ const source = a.kind === "review_notes" && typeof a.source === "string" ? workspace.get(a.source) : undefined;
634
+ const targetText = source !== undefined ? `${source.attributes.title} (${source.id})` : target === undefined ? null : `${target.attributes.title} (${target.id})`;
635
+ const page = (source ?? target)?.attributes.notion;
636
+ const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
583
637
  Object.assign(values, {
584
- Name: a.title, Kind: select(a.kind), Target: target === undefined ? null : `${target.attributes.title} (${target.id})`,
638
+ Name: a.title, Kind: select(a.kind), Target: targetText,
639
+ "Target Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
640
+ "Source Meeting": meeting?.type === "meeting" && typeof meeting.attributes.notion === "string" ? [meeting.attributes.notion] : [],
585
641
  Before: reviewText(proposalBefore(a, workspace)), After: reviewText(after ?? proposalAfter(a, workspace)),
586
642
  Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
587
643
  });
588
644
  return values;
589
645
  }
646
+ if (isReport(note)) {
647
+ 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 });
648
+ return values;
649
+ }
590
650
  if (note.type === "meeting") {
591
651
  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
652
  return values;
@@ -604,6 +664,9 @@ function renderedBrainValues(note, workspace) {
604
664
  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
665
  if (note.type === "question")
606
666
  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) });
667
+ // An answered question's Status goes out once as Answered; it stays yours after that (0041).
668
+ if (note.type === "question" && a.answer !== undefined && (!isRecord(a.notion_published) || a.notion_published.Status !== "Answered"))
669
+ values.Status = "Answered";
607
670
  if (note.type === "concept")
608
671
  Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
609
672
  return values;
@@ -616,8 +679,13 @@ function projectAreas(projects, workspace) {
616
679
  });
617
680
  return [...new Set(areas)].sort(compareCodePoints);
618
681
  }
682
+ /** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
683
+ function producedBy(note, workspace) {
684
+ const sources = new Set([note.id, ...workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === note.id).map((candidate) => candidate.id)]);
685
+ 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))));
686
+ }
619
687
  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));
688
+ const produced = producedBy(note, workspace);
621
689
  const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
622
690
  const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
623
691
  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 +702,24 @@ function notionProperty(name, value, note, workspace, pending) {
634
702
  const mode = (note.type === "meeting" || note.type === "source") ? (["Project", "Area"].includes(name) ? "if_empty" : note.type === "meeting" ? "add" : undefined) : undefined;
635
703
  return { type: "relation", value: Array.isArray(value) ? value : [], ...(mode === undefined ? {} : { mode }) };
636
704
  }
637
- if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status"].includes(name))
705
+ if (name === "Source Meeting") {
706
+ const meeting = typeof note.attributes.source === "string" ? workspace.get(note.attributes.source) : undefined;
707
+ if (meeting?.type === "meeting" && typeof meeting.attributes.notion !== "string")
708
+ pending.push({ property: name, ids: [meeting.id] });
709
+ return { type: "relation", value: Array.isArray(value) ? value : [] };
710
+ }
711
+ if (name === "Ask")
712
+ return { type: "relation", value: Array.isArray(value) ? value : [] };
713
+ if (name === "Target Page")
714
+ return { type: "url", value };
715
+ if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
638
716
  return { type: "select", value };
639
717
  if (name === "Last Synced" || name === "Date" || name === "Deadline" || name === "Ingested At")
640
718
  return { type: "date", value };
641
719
  return { type: name === "Name" ? "title" : "rich_text", value };
642
720
  }
643
721
  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));
722
+ const produced = producedBy(note, workspace);
645
723
  if (name === "Decisions")
646
724
  return produced.filter((candidate) => candidate.type === "decision").map((candidate) => candidate.id);
647
725
  if (name === "Questions")
@@ -662,15 +740,37 @@ function proposalLinks(attributes) {
662
740
  return attributes.links.filter(isRecord).map((link) => ({ field: link.field, value: link.value }));
663
741
  return attributes.field === undefined ? [] : [{ field: attributes.field, value: attributes.after }];
664
742
  }
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 }; }
743
+ function renderedContent(note, workspace) {
744
+ if (note.type === "area") {
745
+ const brief = workspace.notes.find((x) => x.type === "view" && x.attributes.view_kind === "area_brief" && x.attributes.area === note.id);
746
+ if (brief === undefined)
747
+ return { body: null, sections: null };
748
+ const sections = { Brief: notionMarkdown(generatedBlock(brief.body), brief, workspace) };
749
+ return { body: null, sections, hash: hash(JSON.stringify(sections)) };
750
+ }
751
+ // A source's digest is written once; its Full text is tracked by @full_text, not @content (0043).
752
+ if (note.type === "source") {
753
+ if (!hasInboxBody(note))
754
+ return { body: null, sections: null };
755
+ const body = digestBody(note);
756
+ return { body, sections: null, hash: hash(body) };
757
+ }
758
+ if (isReport(note)) {
759
+ const body = notionMarkdown(note.body.replace(/^\n+/, ""), note, workspace);
760
+ return { body, sections: null, hash: hash(body) };
761
+ }
762
+ if (note.type === "decision" || note.type === "question" || note.type === "concept") {
763
+ const body = notionMarkdown(note.body, note, workspace);
764
+ return { body, sections: null, hash: hash(body) };
765
+ }
766
+ if (note.type === "project") {
767
+ const brief = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/brief.md`);
768
+ const log = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/decision-log.md`);
769
+ const sections = { Brief: notionMarkdown(generatedBlock(brief?.body ?? ""), note, workspace), "Decision log": notionMarkdown(generatedBlock(log?.body ?? ""), note, workspace) };
770
+ return { body: null, sections, hash: hash(JSON.stringify(sections)) };
771
+ }
772
+ return { body: null, sections: null };
773
+ }
674
774
  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
775
  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
776
  function reviewMarkdown(body, target) {
@@ -699,8 +799,45 @@ function personTitle(value, workspace) { return typeof value === "string" && wor
699
799
  function noteTitle(value, workspace) { return typeof value === "string" ? workspace.get(value)?.attributes.title ?? value : value ?? null; }
700
800
  function fieldLabel(field) { return field[0]?.toUpperCase() + field.slice(1).replaceAll("_", " "); }
701
801
  function truncate(value) { return typeof value === "string" ? value.slice(0, 2000) : value; }
802
+ /** The lettered lines of a Review notes row: its notes, then its links (cli §5.14.4). */
803
+ function reviewNotesAfter(attributes, workspace) {
804
+ const notes = (Array.isArray(attributes.notes) ? attributes.notes : []).map((id) => {
805
+ const note = workspace.get(String(id));
806
+ return `${note === undefined ? "Note" : fieldLabel(note.type)}: ${String(noteTitle(id, workspace))}`;
807
+ });
808
+ 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))}`);
809
+ return [...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
810
+ }
811
+ /** a, b, … z, then aa, ab, … */
812
+ export function letters(index) {
813
+ return index < 26 ? String.fromCharCode(97 + index) : `${letters(Math.floor(index / 26) - 1)}${letters(index % 26)}`;
814
+ }
815
+ function isReport(note) { return note.type === "view" && note.attributes.view_kind === "report"; }
816
+ /**
817
+ * Whether a source's Full text is due (cli §5.14.4): B of `@full_text` against S. The text goes out
818
+ * when it is chosen and S doesn't hold the same choice; the box is cleared when a request is new.
819
+ */
820
+ function fullTextDue(note, stored) {
821
+ const a = note.attributes;
822
+ const record = { text: a.notion_text ?? "digest", parts: Array.isArray(a.notion_parts) ? a.notion_parts : null, requested: a.notion_text_requested ?? null };
823
+ const s = isRecord(stored) ? stored : undefined;
824
+ const send = record.text === "full" && (s === undefined || s.text !== record.text || !isDeepStrictEqual(s.parts ?? null, record.parts));
825
+ const clear = record.requested !== null && record.requested !== (s?.requested ?? null);
826
+ return { record, ...(send ? { sections: { "Full text": fullText(note.body, record.parts) } } : {}), clear };
827
+ }
828
+ /** A brain-created Inbox row's body: a line about the source, its summary and its digest (cli §5.14.4). */
829
+ function digestBody(note) {
830
+ const a = note.attributes;
831
+ 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";
832
+ const date = typeof a.captured === "string" ? a.captured.slice(0, 10) : "";
833
+ const fileDate = /^sources\/[^/]+\/(\d{4}-\d{2}-\d{2})-/.exec(note.path)?.[1] ?? date;
834
+ const parts = splitParts(note.body);
835
+ const words = note.body.split(/\s+/).filter((word) => word !== "").length;
836
+ const line = `*${String(select(a.kind))} · ${origin} · ${fileDate} · ${words} words${parts.kind === "slides" ? ` · ${parts.parts.length} slides` : ""}*`;
837
+ 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");
838
+ }
702
839
  function proposalBefore(attributes, workspace) {
703
- if (attributes.kind === "add_link")
840
+ if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer")
704
841
  return null;
705
842
  if (attributes.kind === "supersede")
706
843
  return truncate(noteTitle(attributes.supersedes, workspace));
@@ -717,6 +854,8 @@ function proposalAfter(attributes, workspace) {
717
854
  return noteTitle(attributes.target, workspace);
718
855
  if (attributes.kind === "merge")
719
856
  return noteTitle(attributes.merge_into, workspace);
857
+ if (attributes.kind === "answer")
858
+ return `${String(attributes.answer)}${attributes.answer_note === undefined ? "" : ` → ${String(noteTitle(attributes.answer_note, workspace))}`}`;
720
859
  return attributes.after ?? null;
721
860
  }
722
861
  export function renderTasks(workspace, targetId, input) {
@@ -740,7 +879,7 @@ export function renderTasks(workspace, targetId, input) {
740
879
  const put = (key, type, value) => { const name = config.properties[key]; if (typeof name === "string" && name !== "")
741
880
  properties[name] = { type, value }; };
742
881
  put("title", "title", item.title);
743
- put("status", "select", config.new_task_status);
882
+ put("status", config.status_type, config.new_task_status);
744
883
  if (item.due !== undefined)
745
884
  put("due", "date", dateOutput(item.due));
746
885
  if (!sourceMode && item.owner !== undefined)
@@ -799,7 +938,7 @@ function taskProjects(workspace, meeting, value, itemIndex) {
799
938
  function publishedPages(value, workspace) { return (Array.isArray(value) ? value : []).map((id) => workspace.get(String(id))?.attributes.notion).filter((id) => typeof id === "string"); }
800
939
  function tasksConfig(workspace) { if (!loadSchemas().brain(workspace.config))
801
940
  throw new BrainError("BRAIN_CONFIG_INVALID", "brain.json fails its schema"); const notion = workspace.config.notion; const tasks = notion.tasks; if (typeof tasks.database !== "string" || tasks.database === "")
802
- throw new BrainError("BRAIN_CONFIG_INVALID", "Tasks database is not configured"); return { database: tasks.database, properties: tasks.properties, new_task_status: tasks.new_task_status }; }
941
+ throw new BrainError("BRAIN_CONFIG_INVALID", "Tasks database is not configured"); return { database: tasks.database, properties: tasks.properties, new_task_status: tasks.new_task_status, status_type: tasks.status_type === "status" ? "status" : "select" }; }
803
942
  export function record(workspace, input) {
804
943
  if (!isRecord(input) || !Array.isArray(input.records))
805
944
  throw new BrainError("INPUT_INVALID", "record input needs a records list");
@@ -823,7 +962,7 @@ export function record(workspace, input) {
823
962
  }
824
963
  if (isRecord(entry.published))
825
964
  doc.attributes.notion_published = { ...(isRecord(doc.attributes.notion_published) ? doc.attributes.notion_published : {}), ...entry.published };
826
- if (note.type !== "view" && note.type !== "proposal")
965
+ if ((note.type !== "view" || isReport(note)) && note.type !== "proposal")
827
966
  doc.attributes.last_synced = syncedAt;
828
967
  if ((note.type === "meeting" || note.type === "source") && Array.isArray(entry.tasks))
829
968
  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.1",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for an AI-assisted Markdown and Git second brain",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -64,6 +64,46 @@
64
64
  }
65
65
  }
66
66
  },
67
+ "repos": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": "object",
71
+ "required": [
72
+ "name",
73
+ "path",
74
+ "area",
75
+ "patterns",
76
+ "commit"
77
+ ],
78
+ "additionalProperties": false,
79
+ "properties": {
80
+ "name": {
81
+ "type": "string",
82
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
83
+ },
84
+ "path": {
85
+ "type": "string",
86
+ "pattern": "^(/|~/)"
87
+ },
88
+ "area": {
89
+ "type": "string",
90
+ "pattern": "^area-[0-9abcdefghjkmnpqrstvwxyz]{8}$"
91
+ },
92
+ "patterns": {
93
+ "type": "array",
94
+ "minItems": 1,
95
+ "items": {
96
+ "type": "string",
97
+ "minLength": 1
98
+ }
99
+ },
100
+ "commit": {
101
+ "type": "string",
102
+ "pattern": "^([0-9a-f]{40})?$"
103
+ }
104
+ }
105
+ }
106
+ },
67
107
  "notion": {
68
108
  "type": "object",
69
109
  "required": [
@@ -172,6 +212,12 @@
172
212
  },
173
213
  "new_task_status": {
174
214
  "type": "string"
215
+ },
216
+ "status_type": {
217
+ "enum": [
218
+ "status",
219
+ "select"
220
+ ]
175
221
  }
176
222
  },
177
223
  "allOf": [