ultimate-brain 0.2.3 → 0.3.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
@@ -8,11 +8,11 @@ import { assertStagedValid, checkNoteInput, stageNotes } from "./note-new.js";
8
8
  import { compareCodePoints } from "./notes.js";
9
9
  import { loadSchemas } from "./schemas.js";
10
10
  import { formatDate, formatTimestamp, normalizeTimestamp } from "./time.js";
11
- const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "review"];
11
+ const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "review"];
12
12
  const CREATABLE = new Set(["areas", "goals", "projects", "people", "orgs"]);
13
- const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", review: "proposal" };
14
- const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", meeting: "meetings", proposal: "review" };
15
- const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "meeting", "proposal"]);
13
+ const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", review: "proposal" };
14
+ 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" };
15
+ const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "source", "meeting", "proposal"]);
16
16
  function property(value, key) {
17
17
  return Object.hasOwn(value, key) ? value[key] : undefined;
18
18
  }
@@ -299,7 +299,7 @@ function planDecision(row, proposal, out) {
299
299
  return;
300
300
  }
301
301
  const action = String(decision).toLowerCase();
302
- const value = property(row.properties, "Edited Value");
302
+ const note = property(row.properties, "Note");
303
303
  if (!["approve", "reject", "edit"].includes(action)) {
304
304
  out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: proposal.id, reason: "NOT_CORRECTABLE" });
305
305
  return;
@@ -308,11 +308,15 @@ function planDecision(row, proposal, out) {
308
308
  out.conflicts.push(conflict(row, "EDIT_NOT_ALLOWED", `Edit is not allowed on a ${String(proposal.attributes.kind)} proposal`, "Decision", decision));
309
309
  return;
310
310
  }
311
- if (action === "edit" && (value === null || value === undefined || value === "")) {
312
- out.conflicts.push(conflict(row, "EDIT_WITHOUT_VALUE", "Edit needs an Edited Value"));
311
+ if (action === "edit" && (note === null || note === undefined || note === "")) {
312
+ out.conflicts.push(conflict(row, "EDIT_WITHOUT_VALUE", "Edit needs a Note", "Note"));
313
313
  return;
314
314
  }
315
- out.decisions.push({ proposal: proposal.id, action: action, ...(action === "edit" ? { value } : {}), decided_at: row.last_edited, decided_by: "notion" });
315
+ if (action !== "edit" && typeof note === "string" && note !== "") {
316
+ out.conflicts.push(conflict(row, "NOTE_TO_READ", "Review Note needs interpretation by the skill", "Note", note, decision));
317
+ return;
318
+ }
319
+ out.decisions.push({ proposal: proposal.id, action: action, ...(action === "edit" ? { value: note, note: note } : {}), decided_at: row.last_edited, decided_by: "notion" });
316
320
  }
317
321
  function planKeyResult(workspace, asOf, row, out) {
318
322
  const goals = relationIds(workspace, property(row.properties, "Goal"), out.conflicts, row, "Goal");
@@ -348,7 +352,7 @@ function notionFields(database, p, asOf) {
348
352
  }
349
353
  function brainProperties(database) {
350
354
  const common = [["Name", "title"], ["Summary", "summary"], ["Lifecycle", "lifecycle"]];
351
- 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"]], questions: [["Kind", "kind"]] };
355
+ 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"]] };
352
356
  return [...common, ...(extras[database] ?? [])];
353
357
  }
354
358
  function convertedValue(workspace, row, name, value, conflicts) {
@@ -511,13 +515,13 @@ function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0,
511
515
  if (typeof item.result === "string" && Object.hasOwn(result, item.result))
512
516
  result[item.result] += 1; return result; }
513
517
  export function render(workspace, now, ids, held) {
514
- const selected = ids === undefined ? workspace.notes.filter((note) => PUBLISHED_TYPES.has(note.type)) : ids.map((id) => workspace.get(id)).filter(Boolean);
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);
515
519
  const items = [];
516
520
  const errors = [];
517
521
  const warnings = [];
518
522
  for (const note of selected) {
519
523
  const database = DATABASE_FOR_TYPE[note.type];
520
- if (database === undefined)
524
+ if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
521
525
  continue;
522
526
  const databaseId = databaseIdFor(workspace, database);
523
527
  if (databaseId === "") {
@@ -532,33 +536,39 @@ export function render(workspace, now, ids, held) {
532
536
  errors.push({ id: note.id, reason: "PROJECT_NOT_IN_NOTION" });
533
537
  continue;
534
538
  }
539
+ const capturePage = note.type === "source" && note.attributes.kind === "capture" && typeof note.attributes.source_key === "string" && note.attributes.source_key.startsWith("notion:") ? note.attributes.source_key.slice("notion:".length) : undefined;
540
+ if (note.type === "source" && note.attributes.kind === "capture" && (capturePage === undefined || (typeof note.attributes.notion === "string" && note.attributes.notion !== capturePage))) {
541
+ errors.push({ id: note.id, reason: "NOTION_ID_MISMATCH" });
542
+ continue;
543
+ }
535
544
  const rendered = renderedBrainValues(note, workspace);
536
545
  const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : {};
537
546
  const properties = {};
538
547
  const pending = [];
539
548
  const heldProperties = held?.get(note.id) ?? new Set();
549
+ const isCreate = note.attributes.notion === undefined && capturePage === undefined;
540
550
  for (const [name, value] of Object.entries(rendered)) {
541
- if (name === "@content" || heldProperties.has(name))
551
+ if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
542
552
  continue;
543
553
  const encoded = notionProperty(name, value, note, workspace, pending);
544
- if (encoded !== undefined && (note.attributes.notion === undefined || !Object.hasOwn(published, name) || !sameValue(value, published[name])))
554
+ if (encoded !== undefined && (isCreate || !Object.hasOwn(published, name) || !sameValue(value, published[name])))
545
555
  properties[name] = encoded;
546
556
  }
547
557
  const content = renderedContent(note, workspace);
548
558
  if (content.hash !== undefined)
549
559
  rendered["@content"] = content.hash;
550
560
  const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
551
- const isCreate = note.attributes.notion === undefined;
552
561
  if (!isCreate && Object.keys(properties).length === 0 && !contentChanged)
553
562
  continue;
554
563
  if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
555
564
  properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
556
- items.push({ id: note.id, type: note.type, database, database_id: databaseId, page_id: typeof note.attributes.notion === "string" ? note.attributes.notion : null, action: isCreate ? "create" : "update", properties, body: content.body, sections: content.sections, pending_relations: pending, published: rendered });
565
+ items.push({ id: note.id, type: note.type, database, database_id: databaseId, page_id: typeof note.attributes.notion === "string" ? note.attributes.notion : capturePage ?? null, action: isCreate ? "create" : "update", properties, body: content.body, sections: content.sections, pending_relations: pending, published: rendered });
557
566
  }
558
567
  items.sort((a, b) => DATABASES.concat(["meetings"]).indexOf(a.database) - DATABASES.concat(["meetings"]).indexOf(b.database) || compareCodePoints(a.id, b.id));
559
568
  return { items, errors, warnings };
560
569
  }
561
- function databaseIdFor(workspace, database) { const notion = workspace.config.notion; return isRecord(notion) && isRecord(notion.databases) && typeof notion.databases[database] === "string" ? notion.databases[database] : ""; }
570
+ function databaseIdFor(workspace, database) { const notion = workspace.config.notion; if (!isRecord(notion) || !isRecord(notion.databases))
571
+ return ""; return typeof notion.databases[database] === "string" ? notion.databases[database] : database === "inbox" && typeof notion.databases.capture_inbox === "string" ? notion.databases.capture_inbox : ""; }
562
572
  function renderedBrainValues(note, workspace) {
563
573
  const a = note.attributes;
564
574
  const values = {};
@@ -566,8 +576,8 @@ function renderedBrainValues(note, workspace) {
566
576
  if (note.type === "proposal") {
567
577
  const target = typeof a.target === "string" ? workspace.get(a.target) : undefined;
568
578
  const addLink = a.kind === "add_link";
569
- const after = addLink && typeof a.field === "string"
570
- ? `${fieldLabel(a.field)}: ${noteTitle(a.after, workspace)}`
579
+ const after = addLink
580
+ ? proposalLinks(a).map((link) => `${fieldLabel(String(link.field))}: ${noteTitle(link.value, workspace)}`).join("\n")
571
581
  : undefined;
572
582
  const reviewText = (value) => truncate(typeof value === "string" ? reviewMarkdown(value, target) : value);
573
583
  Object.assign(values, {
@@ -581,6 +591,8 @@ function renderedBrainValues(note, workspace) {
581
591
  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 });
582
592
  return values;
583
593
  }
594
+ if (note.type === "source")
595
+ return sourceValues(note, workspace);
584
596
  common();
585
597
  if (note.type === "goal")
586
598
  Object.assign(values, { Why: a.why ?? null, Deadline: dateOutput(isRecord(a.facts) ? a.facts.deadline : undefined), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
@@ -589,20 +601,38 @@ function renderedBrainValues(note, workspace) {
589
601
  if (note.type === "person")
590
602
  Object.assign(values, { Role: isRecord(a.facts) ? a.facts.role ?? null : null, Org: relationPages(a.org === undefined ? [] : [a.org], workspace) });
591
603
  if (note.type === "decision")
592
- Object.assign(values, { Date: dateOutput(a.date), Assessment: select(a.assessment), 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) });
604
+ 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) });
593
605
  if (note.type === "question")
594
- Object.assign(values, { Kind: select(a.kind), Project: relationPages(a.projects, workspace), "Raised In": relationPages(a.raised_in === undefined ? [] : [a.raised_in], workspace) });
606
+ 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) });
607
+ if (note.type === "concept")
608
+ Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
595
609
  return values;
596
610
  }
611
+ /** Area on decisions and questions is a derived Notion relation (decision 0034). */
612
+ function projectAreas(projects, workspace) {
613
+ const areas = (Array.isArray(projects) ? projects : []).flatMap((project) => {
614
+ const area = workspace.get(String(project))?.attributes.area;
615
+ return typeof area === "string" ? [area] : [];
616
+ });
617
+ return [...new Set(areas)].sort(compareCodePoints);
618
+ }
619
+ 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));
621
+ const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
622
+ const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
623
+ 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);
624
+ 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 };
625
+ }
597
626
  function notionProperty(name, value, note, workspace, pending) {
598
- const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Attendees"].includes(name);
627
+ const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "Attendees"].includes(name);
599
628
  if (relation) {
600
629
  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" };
601
- const raw = name === "Supersedes" ? (Array.isArray(note.attributes.relations) ? note.attributes.relations.filter(isRecord).filter((item) => item.type === "supersedes").map((item) => item.target) : []) : note.attributes[sourceField[name] ?? ""];
630
+ 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] ?? ""];
602
631
  const missing = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map(String).filter((id) => typeof workspace.get(id)?.attributes.notion !== "string");
603
632
  if (missing.length > 0)
604
633
  pending.push({ property: name, ids: missing });
605
- return { type: "relation", value: Array.isArray(value) ? value : [], ...(note.type === "meeting" ? { mode: ["Project", "Area"].includes(name) ? "if_empty" : "add" } : {}) };
634
+ const mode = (note.type === "meeting" || note.type === "source") ? (["Project", "Area"].includes(name) ? "if_empty" : note.type === "meeting" ? "add" : undefined) : undefined;
635
+ return { type: "relation", value: Array.isArray(value) ? value : [], ...(mode === undefined ? {} : { mode }) };
606
636
  }
607
637
  if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status"].includes(name))
608
638
  return { type: "select", value };
@@ -610,7 +640,29 @@ function notionProperty(name, value, note, workspace, pending) {
610
640
  return { type: "date", value };
611
641
  return { type: name === "Name" ? "title" : "rich_text", value };
612
642
  }
613
- function renderedContent(note, workspace) { if (note.type === "decision" || note.type === "question") {
643
+ 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));
645
+ if (name === "Decisions")
646
+ return produced.filter((candidate) => candidate.type === "decision").map((candidate) => candidate.id);
647
+ if (name === "Questions")
648
+ return produced.filter((candidate) => candidate.type === "question").map((candidate) => candidate.id);
649
+ if (name === "Concepts")
650
+ return produced.filter((candidate) => candidate.type === "concept").map((candidate) => candidate.id);
651
+ if (name === "People")
652
+ return produced.filter((candidate) => candidate.type === "person").map((candidate) => candidate.id);
653
+ const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))];
654
+ if (name === "Project")
655
+ return projects;
656
+ if (name === "Area")
657
+ return [...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] : []; })])];
658
+ return [];
659
+ }
660
+ function proposalLinks(attributes) {
661
+ if (Array.isArray(attributes.links))
662
+ return attributes.links.filter(isRecord).map((link) => ({ field: link.field, value: link.value }));
663
+ return attributes.field === undefined ? [] : [{ field: attributes.field, value: attributes.after }];
664
+ }
665
+ function renderedContent(note, workspace) { if (note.type === "decision" || note.type === "question" || note.type === "concept") {
614
666
  const body = notionMarkdown(note.body, note, workspace);
615
667
  return { body, sections: null, hash: hash(body) };
616
668
  } if (note.type === "project") {
@@ -667,17 +719,22 @@ function proposalAfter(attributes, workspace) {
667
719
  return noteTitle(attributes.merge_into, workspace);
668
720
  return attributes.after ?? null;
669
721
  }
670
- export function renderTasks(workspace, meetingId, input) {
722
+ export function renderTasks(workspace, targetId, input) {
671
723
  if (!isRecord(input) || !Array.isArray(input.items))
672
724
  throw new BrainError("INPUT_INVALID", "tasks input needs an items list");
673
725
  const config = tasksConfig(workspace);
674
- const meeting = workspace.get(meetingId);
675
- if (meeting?.type !== "meeting")
676
- throw new BrainError("UNKNOWN_ID", `No meeting with id ${meetingId}`);
726
+ const target = workspace.get(targetId);
727
+ const sourceMode = target?.type === "source";
728
+ if (target === undefined)
729
+ throw new BrainError("UNKNOWN_ID", `No meeting or source has id ${targetId}`);
730
+ if (target.type !== "meeting" && !sourceMode)
731
+ throw new BrainError("INPUT_INVALID", `${targetId} is not a meeting or source`);
732
+ if (sourceMode && target.attributes.kind === undefined)
733
+ throw new BrainError("INPUT_INVALID", "minutes sources use their meeting for tasks");
677
734
  const tasks = input.items.map((item, index) => {
678
735
  if (!isRecord(item) || typeof item.title !== "string" || item.title === "")
679
736
  throw new BrainError("INPUT_INVALID", "each task needs a title");
680
- const projects = taskProjects(workspace, meeting, item.projects, index);
737
+ const projects = taskProjects(workspace, sourceMode ? undefined : target, item.projects, index);
681
738
  const properties = {};
682
739
  const pending_relations = [];
683
740
  const put = (key, type, value) => { const name = config.properties[key]; if (typeof name === "string" && name !== "")
@@ -686,7 +743,7 @@ export function renderTasks(workspace, meetingId, input) {
686
743
  put("status", "select", config.new_task_status);
687
744
  if (item.due !== undefined)
688
745
  put("due", "date", dateOutput(item.due));
689
- if (item.owner !== undefined)
746
+ if (!sourceMode && item.owner !== undefined)
690
747
  put("owner", "person_name", item.owner);
691
748
  const projectProperty = config.properties.project;
692
749
  if (typeof projectProperty === "string" && projectProperty !== "") {
@@ -695,19 +752,28 @@ export function renderTasks(workspace, meetingId, input) {
695
752
  pending_relations.push({ property: projectProperty, ids: unpublished });
696
753
  put("project", "relation", publishedPages(projects, workspace));
697
754
  }
698
- put("area", "relation", publishedPages(meeting.attributes.area === undefined ? [] : [meeting.attributes.area], workspace));
699
- if (config.properties.meeting !== "")
700
- put("meeting", "relation", [meeting.attributes.notion]);
755
+ const areas = sourceMode ? [...new Set(projects.flatMap((id) => { const area = workspace.get(id)?.attributes.area; return typeof area === "string" ? [area] : []; }))] : target.attributes.area === undefined ? [] : [target.attributes.area];
756
+ put("area", "relation", publishedPages(areas, workspace));
757
+ if (sourceMode) {
758
+ if (typeof config.properties.source === "string" && config.properties.source !== "") {
759
+ if (typeof target.attributes.notion !== "string")
760
+ pending_relations.push({ property: config.properties.source, ids: [target.id] });
761
+ else
762
+ put("source", "relation", [target.attributes.notion]);
763
+ }
764
+ }
765
+ else if (config.properties.meeting !== "")
766
+ put("meeting", "relation", [target.attributes.notion]);
701
767
  else
702
- put("meeting_url", "url", meeting.attributes.notion === undefined ? undefined : `https://www.notion.so/${String(meeting.attributes.notion).replaceAll("-", "")}`);
703
- put("brain_id", "rich_text", meeting.id);
768
+ put("meeting_url", "url", target.attributes.notion === undefined ? undefined : `https://www.notion.so/${String(target.attributes.notion).replaceAll("-", "")}`);
769
+ put("brain_id", "rich_text", target.id);
704
770
  return { database_id: config.database, properties, pending_relations };
705
771
  });
706
772
  return { items: tasks };
707
773
  }
708
774
  function taskProjects(workspace, meeting, value, itemIndex) {
709
775
  if (value === undefined || (Array.isArray(value) && value.length === 0))
710
- return Array.isArray(meeting.attributes.projects) ? meeting.attributes.projects.filter((id) => typeof id === "string") : [];
776
+ return Array.isArray(meeting?.attributes.projects) ? meeting.attributes.projects.filter((id) => typeof id === "string") : [];
711
777
  if (!Array.isArray(value))
712
778
  throw new BrainError("INPUT_INVALID", "task projects must be an array", [{ pointer: `/items/${itemIndex}/projects`, message: "must be an array of project ids" }]);
713
779
  const problems = [];
@@ -757,9 +823,9 @@ export function record(workspace, input) {
757
823
  }
758
824
  if (isRecord(entry.published))
759
825
  doc.attributes.notion_published = { ...(isRecord(doc.attributes.notion_published) ? doc.attributes.notion_published : {}), ...entry.published };
760
- if (note.type !== "source" && note.type !== "view" && note.type !== "proposal")
826
+ if (note.type !== "view" && note.type !== "proposal")
761
827
  doc.attributes.last_synced = syncedAt;
762
- if (note.type === "meeting" && Array.isArray(entry.tasks))
828
+ if ((note.type === "meeting" || note.type === "source") && Array.isArray(entry.tasks))
763
829
  doc.attributes.tasks = [...new Set([...(Array.isArray(doc.attributes.tasks) ? doc.attributes.tasks : []), ...entry.tasks.filter((id) => typeof id === "string")])];
764
830
  workspace.save(note.path, doc);
765
831
  const errors = workspace.newErrors();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-brain",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for an AI-assisted Markdown and Git second brain",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -25,6 +25,10 @@
25
25
  "name": {
26
26
  "type": "string",
27
27
  "minLength": 1
28
+ },
29
+ "person": {
30
+ "type": "string",
31
+ "pattern": "^per-[0-9abcdefghjkmnpqrstvwxyz]{8}$"
28
32
  }
29
33
  }
30
34
  },
@@ -40,6 +44,26 @@
40
44
  }
41
45
  }
42
46
  },
47
+ "settings": {
48
+ "type": "object",
49
+ "additionalProperties": false,
50
+ "properties": {
51
+ "publish": {
52
+ "enum": [
53
+ "ask",
54
+ "auto"
55
+ ],
56
+ "default": "ask"
57
+ },
58
+ "push": {
59
+ "enum": [
60
+ "auto",
61
+ "remind"
62
+ ],
63
+ "default": "auto"
64
+ }
65
+ }
66
+ },
43
67
  "notion": {
44
68
  "type": "object",
45
69
  "required": [
@@ -83,6 +107,14 @@
83
107
  "$ref": "#/$defs/dbId"
84
108
  },
85
109
  "capture_inbox": {
110
+ "$ref": "#/$defs/dbId",
111
+ "deprecated": true,
112
+ "description": "Old name of inbox; read as inbox when inbox is absent. brain upgrade renames it."
113
+ },
114
+ "inbox": {
115
+ "$ref": "#/$defs/dbId"
116
+ },
117
+ "concepts": {
86
118
  "$ref": "#/$defs/dbId"
87
119
  }
88
120
  }
@@ -130,6 +162,9 @@
130
162
  "meeting_url": {
131
163
  "type": "string"
132
164
  },
165
+ "source": {
166
+ "type": "string"
167
+ },
133
168
  "brain_id": {
134
169
  "type": "string"
135
170
  }
@@ -833,6 +833,27 @@
833
833
  "converter": {
834
834
  "type": "string",
835
835
  "minLength": 1
836
+ },
837
+ "summary": {
838
+ "type": "string",
839
+ "minLength": 1,
840
+ "maxLength": 400
841
+ },
842
+ "notion": {
843
+ "$ref": "#/$defs/notionPageId"
844
+ },
845
+ "notion_published": {
846
+ "description": "Notion property name → value last published to the Inbox (spec/cli.md §5.14).",
847
+ "type": "object"
848
+ },
849
+ "last_synced": {
850
+ "$ref": "#/$defs/timestamp"
851
+ },
852
+ "tasks": {
853
+ "type": "array",
854
+ "items": {
855
+ "$ref": "#/$defs/notionPageId"
856
+ }
836
857
  }
837
858
  },
838
859
  "not": {
@@ -896,6 +917,25 @@
896
917
  }
897
918
  }
898
919
  }
920
+ },
921
+ {
922
+ "if": {
923
+ "required": [
924
+ "kind"
925
+ ],
926
+ "properties": {
927
+ "kind": {
928
+ "const": "capture"
929
+ }
930
+ }
931
+ },
932
+ "then": {
933
+ "properties": {
934
+ "source_key": {
935
+ "pattern": "^notion:"
936
+ }
937
+ }
938
+ }
899
939
  }
900
940
  ]
901
941
  },
@@ -104,6 +104,32 @@
104
104
  "value": {
105
105
  "$ref": "note.schema.json#/$defs/factValue"
106
106
  },
107
+ "note": {
108
+ "type": "string",
109
+ "minLength": 1
110
+ },
111
+ "exclude": {
112
+ "type": "array",
113
+ "minItems": 1,
114
+ "uniqueItems": true,
115
+ "items": {
116
+ "type": "object",
117
+ "required": [
118
+ "field",
119
+ "value"
120
+ ],
121
+ "additionalProperties": false,
122
+ "properties": {
123
+ "field": {
124
+ "type": "string"
125
+ },
126
+ "value": {
127
+ "type": "string",
128
+ "minLength": 1
129
+ }
130
+ }
131
+ }
132
+ },
107
133
  "decided_at": {
108
134
  "$ref": "note.schema.json#/$defs/timestamp"
109
135
  },
@@ -170,6 +196,35 @@
170
196
  "notion_published": {
171
197
  "description": "Review property name → value last published (spec/cli.md §5.14).",
172
198
  "type": "object"
199
+ },
200
+ "links": {
201
+ "type": "array",
202
+ "minItems": 1,
203
+ "uniqueItems": true,
204
+ "items": {
205
+ "type": "object",
206
+ "required": [
207
+ "field",
208
+ "value"
209
+ ],
210
+ "additionalProperties": false,
211
+ "properties": {
212
+ "field": {
213
+ "enum": [
214
+ "aliases",
215
+ "tags",
216
+ "projects",
217
+ "people",
218
+ "attendees",
219
+ "deciders"
220
+ ]
221
+ },
222
+ "value": {
223
+ "type": "string",
224
+ "minLength": 1
225
+ }
226
+ }
227
+ }
173
228
  }
174
229
  },
175
230
  "additionalProperties": false,
@@ -237,9 +292,7 @@
237
292
  },
238
293
  "then": {
239
294
  "required": [
240
- "target",
241
- "field",
242
- "after"
295
+ "target"
243
296
  ],
244
297
  "properties": {
245
298
  "field": {
@@ -275,7 +328,39 @@
275
328
  ]
276
329
  }
277
330
  ]
278
- }
331
+ },
332
+ "oneOf": [
333
+ {
334
+ "required": [
335
+ "links"
336
+ ],
337
+ "not": {
338
+ "anyOf": [
339
+ {
340
+ "required": [
341
+ "field"
342
+ ]
343
+ },
344
+ {
345
+ "required": [
346
+ "after"
347
+ ]
348
+ }
349
+ ]
350
+ }
351
+ },
352
+ {
353
+ "required": [
354
+ "field",
355
+ "after"
356
+ ],
357
+ "not": {
358
+ "required": [
359
+ "links"
360
+ ]
361
+ }
362
+ }
363
+ ]
279
364
  }
280
365
  },
281
366
  {
@@ -554,6 +639,48 @@
554
639
  }
555
640
  }
556
641
  }
642
+ },
643
+ {
644
+ "if": {
645
+ "required": [
646
+ "links"
647
+ ]
648
+ },
649
+ "then": {
650
+ "properties": {
651
+ "kind": {
652
+ "const": "add_link"
653
+ }
654
+ }
655
+ }
656
+ },
657
+ {
658
+ "if": {
659
+ "required": [
660
+ "decision"
661
+ ],
662
+ "properties": {
663
+ "decision": {
664
+ "required": [
665
+ "exclude"
666
+ ]
667
+ }
668
+ }
669
+ },
670
+ "then": {
671
+ "properties": {
672
+ "kind": {
673
+ "const": "add_link"
674
+ },
675
+ "decision": {
676
+ "properties": {
677
+ "action": {
678
+ "const": "approve"
679
+ }
680
+ }
681
+ }
682
+ }
683
+ }
557
684
  }
558
685
  ]
559
686
  }
@@ -13,7 +13,8 @@ Each operation has a skill; follow it end to end:
13
13
  | The user wants to… | Skill |
14
14
  |---|---|
15
15
  | connect the brain to Notion (after building the space) | `brain-connect-notion` |
16
- | ingest reviewed meeting minutes | `brain-ingest` |
16
+ | catch up: do everything that's waiting (decided reviews, reviewed meetings, the Inbox, the drop folder) | `brain-catch-up` |
17
+ | ingest one input: reviewed minutes, a document, a web page, a pasted email or note, the Inbox, the drop folder | `brain-ingest` |
17
18
  | apply decisions from the Notion Review queue | `brain-apply` |
18
19
  | pull Notion edits and new rows, republish notes | `brain-sync` |
19
20
  | answer a question from the brain | `brain-query` |
@@ -28,7 +29,8 @@ Each operation has a skill; follow it end to end:
28
29
  - **A new decision or question for a meeting that already exists**: a `new_item` proposal, because the meeting note must change too.
29
30
  - **Generated files** (`index.md`, `log/`, the `@generated` blocks of `brief.md` and `decision-log.md`) and **source snapshots** in `sources/` are written by the CLI only. To change what a view shows, change the canonical note and regenerate.
30
31
  - **After every write:** `brain index`, `brain views`, `brain lint`. Commit when lint shows no errors you introduced, e.g. `ingest: Acme sync 2026-12-03 (4 new, 3 proposals)`. A run that publishes to Notion commits again after `brain sync record`, so the stored page ids are in Git too.
31
- - **After the last commit:** `git push`. If it fails (offline, no remote), say so in the report's last line and carry on; the work is committed.
32
+ - **Publishing** follows `settings.publish` in `brain.json` (see `brain status`): `ask` (the default) asks once per run before writing to Notion; `auto` publishes without asking. After a run the user was happy with and that asked, offer once to switch: `brain config set settings.publish '"auto"'`.
33
+ - **After the last commit,** per `settings.push`: `auto` (the default) runs `git push`; if it fails (offline, no remote), say so in the report's last line and carry on, the work is committed. `remind` leaves it to the user, and the report's last line says how many commits are not pushed.
32
34
 
33
35
  ## Product files and findings
34
36
 
@@ -2,6 +2,7 @@
2
2
  "schema_version": 1,
3
3
  "owner": { "name": "Owner" },
4
4
  "budgets": { "ingest_max_notes": 8 },
5
+ "settings": { "publish": "ask", "push": "auto" },
5
6
  "notion": {
6
7
  "databases": {
7
8
  "areas": "",
@@ -14,7 +15,8 @@
14
15
  "decisions": "",
15
16
  "questions": "",
16
17
  "review": "",
17
- "capture_inbox": ""
18
+ "inbox": "",
19
+ "concepts": ""
18
20
  },
19
21
  "tasks": {
20
22
  "database": "",
@@ -27,6 +29,7 @@
27
29
  "owner": "",
28
30
  "meeting": "Meeting",
29
31
  "meeting_url": "",
32
+ "source": "",
30
33
  "brain_id": "Brain ID"
31
34
  },
32
35
  "new_task_status": "To Do"