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.
- package/.agents/skills/brain-apply/SKILL.md +10 -6
- package/.agents/skills/brain-catch-up/SKILL.md +27 -14
- package/.agents/skills/brain-connect-notion/SKILL.md +2 -2
- package/.agents/skills/brain-howto/references/guide.md +44 -11
- package/.agents/skills/brain-ingest/SKILL.md +14 -8
- package/.agents/skills/brain-ingest/references/cli-inputs.md +19 -2
- package/.agents/skills/brain-ingest/references/extraction.md +11 -7
- package/.agents/skills/brain-ingest/references/notion.md +25 -11
- package/.agents/skills/brain-ingest/references/sources.md +24 -1
- package/.agents/skills/brain-notion-change/SKILL.md +16 -2
- package/.agents/skills/brain-query/SKILL.md +22 -2
- package/.agents/skills/brain-sync/SKILL.md +6 -4
- package/.agents/skills/brain-sync/references/formats.md +1 -1
- package/README.md +1 -1
- package/dist/cli.js +5 -2
- package/dist/commands/proposal.js +21 -1
- package/dist/commands/repo.js +110 -0
- package/dist/commands/report.js +115 -0
- package/dist/commands/source.js +56 -0
- package/dist/commands/status.js +4 -1
- package/dist/commands/views.js +30 -15
- package/dist/lib/apply.js +83 -10
- package/dist/lib/errors.js +3 -0
- package/dist/lib/frontmatter.js +9 -4
- package/dist/lib/lint.js +34 -5
- package/dist/lib/note-new.js +44 -0
- package/dist/lib/notes.js +1 -0
- package/dist/lib/parts.js +82 -0
- package/dist/lib/proposals.js +183 -8
- package/dist/lib/repos.js +129 -0
- package/dist/lib/sync.js +167 -28
- package/dist/lib/views.js +53 -15
- package/package.json +1 -1
- package/spec/schemas/brain.schema.json +46 -0
- package/spec/schemas/note.schema.json +343 -3
- package/spec/schemas/proposal.schema.json +393 -10
- package/templates/brain/AGENTS.md +4 -4
- 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
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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",
|
|
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
|
-
:
|
|
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
|
|
63
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
@@ -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": [
|