ultimate-brain 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/brain-apply/SKILL.md +10 -6
- package/.agents/skills/brain-catch-up/SKILL.md +28 -15
- package/.agents/skills/brain-howto/references/guide.md +49 -12
- 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 +26 -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 +9 -6
- package/.agents/skills/brain-sync/references/formats.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 +190 -26
- package/dist/lib/views.js +53 -15
- package/package.json +1 -1
- package/spec/schemas/brain.schema.json +3 -0
- package/spec/schemas/note.schema.json +130 -2
- package/spec/schemas/proposal.schema.json +461 -14
- package/templates/brain/AGENTS.md +4 -4
package/dist/lib/sync.js
CHANGED
|
@@ -6,9 +6,10 @@ import { assertNoProblems, isRecord, unknownKeys } from "./input.js";
|
|
|
6
6
|
import { generateId, TYPE_PREFIX } from "./ids.js";
|
|
7
7
|
import { assertStagedValid, checkNoteInput, stageNotes } from "./note-new.js";
|
|
8
8
|
import { compareCodePoints } from "./notes.js";
|
|
9
|
+
import { fullText, splitParts } from "./parts.js";
|
|
9
10
|
import { loadSchemas } from "./schemas.js";
|
|
10
11
|
import { formatDate, formatTimestamp, normalizeTimestamp } from "./time.js";
|
|
11
|
-
const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "review"];
|
|
12
|
+
const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "review", "inbox"];
|
|
12
13
|
const CREATABLE = new Set(["areas", "goals", "projects", "people", "orgs"]);
|
|
13
14
|
const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", review: "proposal" };
|
|
14
15
|
const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", source: "inbox", meeting: "meetings", proposal: "review" };
|
|
@@ -71,7 +72,7 @@ export function parseSnapshot(input) {
|
|
|
71
72
|
}
|
|
72
73
|
/** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
|
|
73
74
|
export function buildPlan(workspace, snapshot) {
|
|
74
|
-
const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], conflicts: [], ignored: [] };
|
|
75
|
+
const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], full_text_requests: [], conflicts: [], ignored: [] };
|
|
75
76
|
const createsByPage = new Map();
|
|
76
77
|
for (const row of snapshot.rows)
|
|
77
78
|
if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
|
|
@@ -84,6 +85,10 @@ export function buildPlan(workspace, snapshot) {
|
|
|
84
85
|
planKeyResult(workspace, snapshot.as_of, row, result);
|
|
85
86
|
continue;
|
|
86
87
|
}
|
|
88
|
+
if (row.database === "inbox") {
|
|
89
|
+
planInbox(workspace, row, result);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
87
92
|
const brainId = property(row.properties, "Brain ID");
|
|
88
93
|
let note = typeof brainId === "string" && brainId !== "" ? workspace.get(brainId) : noteByNotion(workspace, row.page_id);
|
|
89
94
|
if (typeof brainId === "string" && brainId !== "" && note === undefined) {
|
|
@@ -318,6 +323,31 @@ function planDecision(row, proposal, out) {
|
|
|
318
323
|
}
|
|
319
324
|
out.decisions.push({ proposal: proposal.id, action: action, ...(action === "edit" ? { value: note, note: note } : {}), decided_at: row.last_edited, decided_by: "notion" });
|
|
320
325
|
}
|
|
326
|
+
/** An Inbox row is read only for its Full text box (cli §5.14.2, 0043). */
|
|
327
|
+
function planInbox(workspace, row, out) {
|
|
328
|
+
if (property(row.properties, "Full text") !== true)
|
|
329
|
+
return;
|
|
330
|
+
const brainId = property(row.properties, "Brain ID");
|
|
331
|
+
const ignore = (id) => out.ignored.push({ database: row.database, page_id: row.page_id, brain_id: id, property: "Full text", reason: "NO_FULL_TEXT" });
|
|
332
|
+
if (typeof brainId !== "string" || brainId === "") {
|
|
333
|
+
ignore(null);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const note = workspace.get(brainId);
|
|
337
|
+
if (note === undefined) {
|
|
338
|
+
out.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (hasInboxBody(note))
|
|
342
|
+
out.full_text_requests.push({ page_id: row.page_id, source: note.id });
|
|
343
|
+
else
|
|
344
|
+
ignore(note.id);
|
|
345
|
+
}
|
|
346
|
+
/** A source whose Inbox row the brain writes: not minutes, a capture or an attached file (0042, 0043). */
|
|
347
|
+
export function hasInboxBody(note) {
|
|
348
|
+
const kind = note.attributes.kind;
|
|
349
|
+
return note.type === "source" && kind !== undefined && kind !== "minutes" && kind !== "capture" && note.attributes.attached_to === undefined;
|
|
350
|
+
}
|
|
321
351
|
function planKeyResult(workspace, asOf, row, out) {
|
|
322
352
|
const goals = relationIds(workspace, property(row.properties, "Goal"), out.conflicts, row, "Goal");
|
|
323
353
|
const goal = goals?.[0];
|
|
@@ -339,7 +369,7 @@ function planKeyResult(workspace, asOf, row, out) {
|
|
|
339
369
|
}
|
|
340
370
|
function sortPlan(plan) {
|
|
341
371
|
const order = (item) => [DATABASES.indexOf(String(item.database)), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
|
|
342
|
-
for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.conflicts, plan.ignored])
|
|
372
|
+
for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.full_text_requests, plan.conflicts, plan.ignored])
|
|
343
373
|
values.sort((a, b) => order(a).localeCompare(order(b)));
|
|
344
374
|
}
|
|
345
375
|
function notionFields(database, p, asOf) {
|
|
@@ -416,7 +446,9 @@ export function parsePlan(input) {
|
|
|
416
446
|
if (!isRecord(input))
|
|
417
447
|
assertNoProblems([{ pointer: "", message: "must be an object" }]);
|
|
418
448
|
const p = input;
|
|
419
|
-
const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "conflicts", "ignored"], "");
|
|
449
|
+
const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "full_text_requests", "conflicts", "ignored"], "");
|
|
450
|
+
if (p.full_text_requests !== undefined && !Array.isArray(p.full_text_requests))
|
|
451
|
+
problems.push({ pointer: "/full_text_requests", message: "must be a list" });
|
|
420
452
|
if (!isTimestamp(p.as_of))
|
|
421
453
|
problems.push({ pointer: "/as_of", message: "must be an ISO 8601 UTC timestamp" });
|
|
422
454
|
for (const key of ["create", "updates", "corrections", "decisions", "conflicts", "ignored"])
|
|
@@ -434,7 +466,7 @@ export function parsePlan(input) {
|
|
|
434
466
|
problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
|
|
435
467
|
});
|
|
436
468
|
assertNoProblems(problems);
|
|
437
|
-
return { ...p, as_of: normalizeTimestamp(p.as_of) };
|
|
469
|
+
return { ...p, full_text_requests: p.full_text_requests ?? [], as_of: normalizeTimestamp(p.as_of) };
|
|
438
470
|
}
|
|
439
471
|
export function applyPlan(workspace, plan, now, recordOnly) {
|
|
440
472
|
const create = [];
|
|
@@ -506,7 +538,7 @@ export function applyPlan(workspace, plan, now, recordOnly) {
|
|
|
506
538
|
...updates.filter((item) => item.result === "applied").map((item) => ({ op: "sync-update", id: String(item.id), title: String(workspace.get(String(item.id))?.attributes.title ?? item.id) })),
|
|
507
539
|
...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
|
|
508
540
|
];
|
|
509
|
-
return { create, updates, corrections, decisions: decisionResult, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
|
|
541
|
+
return { create, updates, corrections, decisions: decisionResult, full_text_requests: plan.full_text_requests, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
|
|
510
542
|
}
|
|
511
543
|
function references(value) { if (Array.isArray(value))
|
|
512
544
|
return value.flatMap(references); if (isRecord(value))
|
|
@@ -515,19 +547,34 @@ function summarize(items) { const result = { applied: 0, closed: 0, recorded: 0,
|
|
|
515
547
|
if (typeof item.result === "string" && Object.hasOwn(result, item.result))
|
|
516
548
|
result[item.result] += 1; return result; }
|
|
517
549
|
export function render(workspace, now, ids, held) {
|
|
518
|
-
const selected = ids === undefined
|
|
550
|
+
const selected = ids === undefined
|
|
551
|
+
? workspace.notes.filter((note) => (PUBLISHED_TYPES.has(note.type) && !(note.type === "source" && note.attributes.kind === undefined)) || (isReport(note) && note.attributes.notion === undefined))
|
|
552
|
+
: ids.map((id) => workspace.get(id)).filter(Boolean);
|
|
519
553
|
const items = [];
|
|
520
554
|
const errors = [];
|
|
521
555
|
const warnings = [];
|
|
522
556
|
for (const note of selected) {
|
|
523
|
-
const database = DATABASE_FOR_TYPE[note.type];
|
|
557
|
+
const database = isReport(note) ? "inbox" : DATABASE_FOR_TYPE[note.type];
|
|
524
558
|
if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
|
|
525
559
|
continue;
|
|
560
|
+
// A published report is never rendered again (0043).
|
|
561
|
+
if (isReport(note) && note.attributes.notion !== undefined)
|
|
562
|
+
continue;
|
|
563
|
+
// An attached source has no Inbox row: its notes go on the parent's (0042).
|
|
564
|
+
if (note.type === "source" && note.attributes.attached_to !== undefined)
|
|
565
|
+
continue;
|
|
566
|
+
// A decided Review row leaves Notion 30 days after its decision, once (0045).
|
|
567
|
+
if (note.type === "proposal" && isRecord(note.attributes.notion_published) && note.attributes.notion_published["@trashed"] !== undefined)
|
|
568
|
+
continue;
|
|
526
569
|
const databaseId = databaseIdFor(workspace, database);
|
|
527
570
|
if (databaseId === "") {
|
|
528
571
|
warnings.push({ code: "DATABASE_NOT_CONFIGURED", message: `${database} database is not configured`, id: note.id });
|
|
529
572
|
continue;
|
|
530
573
|
}
|
|
574
|
+
if (note.type === "proposal" && trashDue(note, now)) {
|
|
575
|
+
items.push({ id: note.id, type: note.type, database, database_id: databaseId, page_id: String(note.attributes.notion), action: "trash", properties: {}, body: null, sections: null, pending_relations: [], published: { "@trashed": formatTimestamp(now) } });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
531
578
|
if (note.type === "meeting" && typeof note.attributes.notion !== "string") {
|
|
532
579
|
errors.push({ id: note.id, reason: "MEETING_NOT_PUBLISHED" });
|
|
533
580
|
continue;
|
|
@@ -546,7 +593,7 @@ export function render(workspace, now, ids, held) {
|
|
|
546
593
|
const properties = {};
|
|
547
594
|
const pending = [];
|
|
548
595
|
const heldProperties = held?.get(note.id) ?? new Set();
|
|
549
|
-
const isCreate = note.attributes.notion === undefined && capturePage === undefined;
|
|
596
|
+
const isCreate = isReport(note) || (note.attributes.notion === undefined && capturePage === undefined);
|
|
550
597
|
for (const [name, value] of Object.entries(rendered)) {
|
|
551
598
|
if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
|
|
552
599
|
continue;
|
|
@@ -558,7 +605,16 @@ export function render(workspace, now, ids, held) {
|
|
|
558
605
|
if (content.hash !== undefined)
|
|
559
606
|
rendered["@content"] = content.hash;
|
|
560
607
|
const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
|
|
561
|
-
|
|
608
|
+
// Full text (0043): the text goes out once per choice, and a ticked box is cleared once per request.
|
|
609
|
+
const full = note.type === "source" && hasInboxBody(note) ? fullTextDue(note, published["@full_text"]) : undefined;
|
|
610
|
+
if (full?.sections !== undefined)
|
|
611
|
+
content.sections = { ...(content.sections ?? {}), ...full.sections };
|
|
612
|
+
if (full?.clear === true)
|
|
613
|
+
properties["Full text"] = { type: "checkbox", value: false };
|
|
614
|
+
if (full !== undefined && (full.sections !== undefined || full.clear))
|
|
615
|
+
rendered["@full_text"] = full.record;
|
|
616
|
+
const fullTextDueNow = full !== undefined && (full.sections !== undefined || full.clear);
|
|
617
|
+
if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow)
|
|
562
618
|
continue;
|
|
563
619
|
if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
|
|
564
620
|
properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
|
|
@@ -567,6 +623,16 @@ export function render(workspace, now, ids, held) {
|
|
|
567
623
|
items.sort((a, b) => DATABASES.concat(["meetings"]).indexOf(a.database) - DATABASES.concat(["meetings"]).indexOf(b.database) || compareCodePoints(a.id, b.id));
|
|
568
624
|
return { items, errors, warnings };
|
|
569
625
|
}
|
|
626
|
+
const TRASH_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
627
|
+
/** A published Review row whose proposal was applied or closed more than 30 days before `now` (0045). */
|
|
628
|
+
function trashDue(note, now) {
|
|
629
|
+
const a = note.attributes;
|
|
630
|
+
if (typeof a.notion !== "string" || (a.status !== "applied" && a.status !== "closed"))
|
|
631
|
+
return false;
|
|
632
|
+
const decided = isRecord(a.decision) && typeof a.decision.decided_at === "string" ? a.decision.decided_at : a.updated;
|
|
633
|
+
const time = typeof decided === "string" ? Date.parse(decided) : Number.NaN;
|
|
634
|
+
return Number.isFinite(time) && now.getTime() - time > TRASH_AFTER_MS;
|
|
635
|
+
}
|
|
570
636
|
function databaseIdFor(workspace, database) { const notion = workspace.config.notion; if (!isRecord(notion) || !isRecord(notion.databases))
|
|
571
637
|
return ""; return typeof notion.databases[database] === "string" ? notion.databases[database] : database === "inbox" && typeof notion.databases.capture_inbox === "string" ? notion.databases.capture_inbox : ""; }
|
|
572
638
|
function renderedBrainValues(note, workspace) {
|
|
@@ -578,15 +644,26 @@ function renderedBrainValues(note, workspace) {
|
|
|
578
644
|
const addLink = a.kind === "add_link";
|
|
579
645
|
const after = addLink
|
|
580
646
|
? proposalLinks(a).map((link) => `${fieldLabel(String(link.field))}: ${noteTitle(link.value, workspace)}`).join("\n")
|
|
581
|
-
: undefined;
|
|
647
|
+
: a.kind === "review_notes" ? reviewNotesAfter(a, workspace) : undefined;
|
|
582
648
|
const reviewText = (value) => truncate(typeof value === "string" ? reviewMarkdown(value, target) : value);
|
|
649
|
+
// A Review notes row names its source: the meeting for minutes, else the source (0041).
|
|
650
|
+
const source = a.kind === "review_notes" && typeof a.source === "string" ? workspace.get(a.source) : undefined;
|
|
651
|
+
const targetText = source !== undefined ? `${source.attributes.title} (${source.id})` : target === undefined ? null : `${target.attributes.title} (${target.id})`;
|
|
652
|
+
const page = (source ?? target)?.attributes.notion;
|
|
653
|
+
const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
|
|
583
654
|
Object.assign(values, {
|
|
584
|
-
Name: a.title, Kind: select(a.kind), Target:
|
|
655
|
+
Name: a.title, Kind: select(a.kind), Target: targetText,
|
|
656
|
+
"Target Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
|
|
657
|
+
"Source Meeting": meeting?.type === "meeting" && typeof meeting.attributes.notion === "string" ? [meeting.attributes.notion] : [],
|
|
585
658
|
Before: reviewText(proposalBefore(a, workspace)), After: reviewText(after ?? proposalAfter(a, workspace)),
|
|
586
659
|
Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
|
|
587
660
|
});
|
|
588
661
|
return values;
|
|
589
662
|
}
|
|
663
|
+
if (isReport(note)) {
|
|
664
|
+
Object.assign(values, { Name: a.title, Kind: "Report", Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), Ask: typeof a.asked_in === "string" ? [a.asked_in] : [], "Brain Status": "Processed", "Brain ID": note.id });
|
|
665
|
+
return values;
|
|
666
|
+
}
|
|
590
667
|
if (note.type === "meeting") {
|
|
591
668
|
Object.assign(values, { Decisions: relationPages(a.decisions, workspace), Questions: relationPages(a.questions, workspace), Attendees: relationPages(a.attendees, workspace), Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace), "Brain Status": "Ingested", "Ingested At": dateOutput(a.created), "Brain ID": note.id });
|
|
592
669
|
return values;
|
|
@@ -604,6 +681,17 @@ function renderedBrainValues(note, workspace) {
|
|
|
604
681
|
Object.assign(values, { Date: dateOutput(a.date), Assessment: select(a.assessment), Area: relationPages(projectAreas(a.projects, workspace), workspace), Project: relationPages(a.projects, workspace), "Decided In": relationPages(a.decided_in === undefined ? [] : [a.decided_in], workspace), Supersedes: relationPages((Array.isArray(a.relations) ? a.relations.filter(isRecord).filter((x) => x.type === "supersedes").map((x) => x.target) : []), workspace) });
|
|
605
682
|
if (note.type === "question")
|
|
606
683
|
Object.assign(values, { Kind: select(a.kind), Area: relationPages(projectAreas(a.projects, workspace), workspace), Project: relationPages(a.projects, workspace), "Raised In": relationPages(a.raised_in === undefined ? [] : [a.raised_in], workspace) });
|
|
684
|
+
// Status goes out once as Open while you never set one, and once as Answered after an approved answer; it stays yours otherwise (0041, 0045).
|
|
685
|
+
if (note.type === "question") {
|
|
686
|
+
const published = isRecord(a.notion_published) ? a.notion_published : {};
|
|
687
|
+
const yours = isRecord(a.notion_fields) && a.notion_fields.status !== undefined;
|
|
688
|
+
if (a.answer !== undefined) {
|
|
689
|
+
if (published.Status !== "Answered")
|
|
690
|
+
values.Status = "Answered";
|
|
691
|
+
}
|
|
692
|
+
else if (published.Status === undefined && !yours)
|
|
693
|
+
values.Status = "Open";
|
|
694
|
+
}
|
|
607
695
|
if (note.type === "concept")
|
|
608
696
|
Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
|
|
609
697
|
return values;
|
|
@@ -616,8 +704,13 @@ function projectAreas(projects, workspace) {
|
|
|
616
704
|
});
|
|
617
705
|
return [...new Set(areas)].sort(compareCodePoints);
|
|
618
706
|
}
|
|
707
|
+
/** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
|
|
708
|
+
function producedBy(note, workspace) {
|
|
709
|
+
const sources = new Set([note.id, ...workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === note.id).map((candidate) => candidate.id)]);
|
|
710
|
+
return workspace.notes.filter((candidate) => ["decision", "question", "concept", "person"].includes(candidate.type) && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(id))));
|
|
711
|
+
}
|
|
619
712
|
function sourceValues(note, workspace) {
|
|
620
|
-
const produced =
|
|
713
|
+
const produced = producedBy(note, workspace);
|
|
621
714
|
const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
|
|
622
715
|
const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
|
|
623
716
|
const areas = [...new Set([...produced.map((candidate) => candidate.attributes.area).filter((id) => typeof id === "string"), ...projects.flatMap((id) => { const area = workspace.get(id)?.attributes.area; return typeof area === "string" ? [area] : []; })])].sort(compareCodePoints);
|
|
@@ -634,14 +727,24 @@ function notionProperty(name, value, note, workspace, pending) {
|
|
|
634
727
|
const mode = (note.type === "meeting" || note.type === "source") ? (["Project", "Area"].includes(name) ? "if_empty" : note.type === "meeting" ? "add" : undefined) : undefined;
|
|
635
728
|
return { type: "relation", value: Array.isArray(value) ? value : [], ...(mode === undefined ? {} : { mode }) };
|
|
636
729
|
}
|
|
637
|
-
if (
|
|
730
|
+
if (name === "Source Meeting") {
|
|
731
|
+
const meeting = typeof note.attributes.source === "string" ? workspace.get(note.attributes.source) : undefined;
|
|
732
|
+
if (meeting?.type === "meeting" && typeof meeting.attributes.notion !== "string")
|
|
733
|
+
pending.push({ property: name, ids: [meeting.id] });
|
|
734
|
+
return { type: "relation", value: Array.isArray(value) ? value : [] };
|
|
735
|
+
}
|
|
736
|
+
if (name === "Ask")
|
|
737
|
+
return { type: "relation", value: Array.isArray(value) ? value : [] };
|
|
738
|
+
if (name === "Target Page")
|
|
739
|
+
return { type: "url", value };
|
|
740
|
+
if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
|
|
638
741
|
return { type: "select", value };
|
|
639
742
|
if (name === "Last Synced" || name === "Date" || name === "Deadline" || name === "Ingested At")
|
|
640
743
|
return { type: "date", value };
|
|
641
744
|
return { type: name === "Name" ? "title" : "rich_text", value };
|
|
642
745
|
}
|
|
643
746
|
function sourceRelationIds(note, name, workspace) {
|
|
644
|
-
const produced =
|
|
747
|
+
const produced = producedBy(note, workspace);
|
|
645
748
|
if (name === "Decisions")
|
|
646
749
|
return produced.filter((candidate) => candidate.type === "decision").map((candidate) => candidate.id);
|
|
647
750
|
if (name === "Questions")
|
|
@@ -662,15 +765,37 @@ function proposalLinks(attributes) {
|
|
|
662
765
|
return attributes.links.filter(isRecord).map((link) => ({ field: link.field, value: link.value }));
|
|
663
766
|
return attributes.field === undefined ? [] : [{ field: attributes.field, value: attributes.after }];
|
|
664
767
|
}
|
|
665
|
-
function renderedContent(note, workspace) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
768
|
+
function renderedContent(note, workspace) {
|
|
769
|
+
if (note.type === "area") {
|
|
770
|
+
const brief = workspace.notes.find((x) => x.type === "view" && x.attributes.view_kind === "area_brief" && x.attributes.area === note.id);
|
|
771
|
+
if (brief === undefined)
|
|
772
|
+
return { body: null, sections: null };
|
|
773
|
+
const sections = { Brief: notionMarkdown(generatedBlock(brief.body), brief, workspace) };
|
|
774
|
+
return { body: null, sections, hash: hash(JSON.stringify(sections)) };
|
|
775
|
+
}
|
|
776
|
+
// A source's digest is written once; its Full text is tracked by @full_text, not @content (0043).
|
|
777
|
+
if (note.type === "source") {
|
|
778
|
+
if (!hasInboxBody(note))
|
|
779
|
+
return { body: null, sections: null };
|
|
780
|
+
const body = digestBody(note);
|
|
781
|
+
return { body, sections: null, hash: hash(body) };
|
|
782
|
+
}
|
|
783
|
+
if (isReport(note)) {
|
|
784
|
+
const body = notionMarkdown(note.body.replace(/^\n+/, ""), note, workspace);
|
|
785
|
+
return { body, sections: null, hash: hash(body) };
|
|
786
|
+
}
|
|
787
|
+
if (note.type === "decision" || note.type === "question" || note.type === "concept") {
|
|
788
|
+
const body = notionMarkdown(note.body, note, workspace);
|
|
789
|
+
return { body, sections: null, hash: hash(body) };
|
|
790
|
+
}
|
|
791
|
+
if (note.type === "project") {
|
|
792
|
+
const brief = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/brief.md`);
|
|
793
|
+
const log = workspace.notes.find((x) => x.path === `${note.path.slice(0, note.path.lastIndexOf("/"))}/decision-log.md`);
|
|
794
|
+
const sections = { Brief: notionMarkdown(generatedBlock(brief?.body ?? ""), note, workspace), "Decision log": notionMarkdown(generatedBlock(log?.body ?? ""), note, workspace) };
|
|
795
|
+
return { body: null, sections, hash: hash(JSON.stringify(sections)) };
|
|
796
|
+
}
|
|
797
|
+
return { body: null, sections: null };
|
|
798
|
+
}
|
|
674
799
|
function generatedBlock(body) { const start = body.indexOf("<!-- @generated:start -->"); const end = body.indexOf("<!-- @generated:end -->"); return start >= 0 && end > start ? body.slice(start + "<!-- @generated:start -->".length, end).trim() : body.trim(); }
|
|
675
800
|
function notionMarkdown(body, source, workspace) { const fromFolder = source.path.includes("/") ? source.path.slice(0, source.path.lastIndexOf("/")) : ""; return body.replace(/\^\[(inferred|ambiguous)\]/g, (_, kind) => kind === "inferred" ? " (AI inference)" : " (ambiguous)").replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_all, title, link) => { const targetPath = normalizeRelative(fromFolder, link); const target = workspace.notes.find((note) => note.path === targetPath); return typeof target?.attributes.notion === "string" ? `[${title}](https://www.notion.so/${target.attributes.notion.replaceAll("-", "")})` : title; }); }
|
|
676
801
|
function reviewMarkdown(body, target) {
|
|
@@ -699,8 +824,45 @@ function personTitle(value, workspace) { return typeof value === "string" && wor
|
|
|
699
824
|
function noteTitle(value, workspace) { return typeof value === "string" ? workspace.get(value)?.attributes.title ?? value : value ?? null; }
|
|
700
825
|
function fieldLabel(field) { return field[0]?.toUpperCase() + field.slice(1).replaceAll("_", " "); }
|
|
701
826
|
function truncate(value) { return typeof value === "string" ? value.slice(0, 2000) : value; }
|
|
827
|
+
/** The lettered lines of a Review notes row: its notes, then its links (cli §5.14.4). */
|
|
828
|
+
function reviewNotesAfter(attributes, workspace) {
|
|
829
|
+
const notes = (Array.isArray(attributes.notes) ? attributes.notes : []).map((id) => {
|
|
830
|
+
const note = workspace.get(String(id));
|
|
831
|
+
return `${note === undefined ? "Note" : fieldLabel(note.type)}: ${String(noteTitle(id, workspace))}`;
|
|
832
|
+
});
|
|
833
|
+
const links = (Array.isArray(attributes.links) ? attributes.links.filter(isRecord) : []).map((link) => `${fieldLabel(String(link.field))} on ${String(noteTitle(link.target, workspace))}: ${String(noteTitle(link.value, workspace))}`);
|
|
834
|
+
return [...notes, ...links].map((line, index) => `${letters(index)}) ${line}`).join("\n");
|
|
835
|
+
}
|
|
836
|
+
/** a, b, … z, then aa, ab, … */
|
|
837
|
+
export function letters(index) {
|
|
838
|
+
return index < 26 ? String.fromCharCode(97 + index) : `${letters(Math.floor(index / 26) - 1)}${letters(index % 26)}`;
|
|
839
|
+
}
|
|
840
|
+
function isReport(note) { return note.type === "view" && note.attributes.view_kind === "report"; }
|
|
841
|
+
/**
|
|
842
|
+
* Whether a source's Full text is due (cli §5.14.4): B of `@full_text` against S. The text goes out
|
|
843
|
+
* when it is chosen and S doesn't hold the same choice; the box is cleared when a request is new.
|
|
844
|
+
*/
|
|
845
|
+
function fullTextDue(note, stored) {
|
|
846
|
+
const a = note.attributes;
|
|
847
|
+
const record = { text: a.notion_text ?? "digest", parts: Array.isArray(a.notion_parts) ? a.notion_parts : null, requested: a.notion_text_requested ?? null };
|
|
848
|
+
const s = isRecord(stored) ? stored : undefined;
|
|
849
|
+
const send = record.text === "full" && (s === undefined || s.text !== record.text || !isDeepStrictEqual(s.parts ?? null, record.parts));
|
|
850
|
+
const clear = record.requested !== null && record.requested !== (s?.requested ?? null);
|
|
851
|
+
return { record, ...(send ? { sections: { "Full text": fullText(note.body, record.parts) } } : {}), clear };
|
|
852
|
+
}
|
|
853
|
+
/** A brain-created Inbox row's body: a line about the source, its summary and its digest (cli §5.14.4). */
|
|
854
|
+
function digestBody(note) {
|
|
855
|
+
const a = note.attributes;
|
|
856
|
+
const origin = typeof a.file_name === "string" ? a.file_name : typeof a.source_key === "string" && a.source_key.startsWith("url:") ? a.source_key.slice(4) : "pasted text";
|
|
857
|
+
const date = typeof a.captured === "string" ? a.captured.slice(0, 10) : "";
|
|
858
|
+
const fileDate = /^sources\/[^/]+\/(\d{4}-\d{2}-\d{2})-/.exec(note.path)?.[1] ?? date;
|
|
859
|
+
const parts = splitParts(note.body);
|
|
860
|
+
const words = note.body.split(/\s+/).filter((word) => word !== "").length;
|
|
861
|
+
const line = `*${String(select(a.kind))} · ${origin} · ${fileDate} · ${words} words${parts.kind === "slides" ? ` · ${parts.parts.length} slides` : ""}*`;
|
|
862
|
+
return [line, typeof a.summary === "string" ? a.summary : undefined, typeof a.digest === "string" ? a.digest.replace(/\n+$/, "") : undefined].filter((part) => part !== undefined).join("\n\n");
|
|
863
|
+
}
|
|
702
864
|
function proposalBefore(attributes, workspace) {
|
|
703
|
-
if (attributes.kind === "add_link")
|
|
865
|
+
if (attributes.kind === "add_link" || attributes.kind === "review_notes" || attributes.kind === "answer")
|
|
704
866
|
return null;
|
|
705
867
|
if (attributes.kind === "supersede")
|
|
706
868
|
return truncate(noteTitle(attributes.supersedes, workspace));
|
|
@@ -717,6 +879,8 @@ function proposalAfter(attributes, workspace) {
|
|
|
717
879
|
return noteTitle(attributes.target, workspace);
|
|
718
880
|
if (attributes.kind === "merge")
|
|
719
881
|
return noteTitle(attributes.merge_into, workspace);
|
|
882
|
+
if (attributes.kind === "answer")
|
|
883
|
+
return `${String(attributes.answer)}${attributes.answer_note === undefined ? "" : ` → ${String(noteTitle(attributes.answer_note, workspace))}`}`;
|
|
720
884
|
return attributes.after ?? null;
|
|
721
885
|
}
|
|
722
886
|
export function renderTasks(workspace, targetId, input) {
|
|
@@ -823,7 +987,7 @@ export function record(workspace, input) {
|
|
|
823
987
|
}
|
|
824
988
|
if (isRecord(entry.published))
|
|
825
989
|
doc.attributes.notion_published = { ...(isRecord(doc.attributes.notion_published) ? doc.attributes.notion_published : {}), ...entry.published };
|
|
826
|
-
if (note.type !== "view" && note.type !== "proposal")
|
|
990
|
+
if ((note.type !== "view" || isReport(note)) && note.type !== "proposal")
|
|
827
991
|
doc.attributes.last_synced = syncedAt;
|
|
828
992
|
if ((note.type === "meeting" || note.type === "source") && Array.isArray(entry.tasks))
|
|
829
993
|
doc.attributes.tasks = [...new Set([...(Array.isArray(doc.attributes.tasks) ? doc.attributes.tasks : []), ...entry.tasks.filter((id) => typeof id === "string")])];
|
package/dist/lib/views.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { compareCodePoints } from "./notes.js";
|
|
3
3
|
const CURRENT_ASSESSMENTS = new Set(["accepted", "provisional", "contested"]);
|
|
4
|
-
/** Render one project view from canonical notes only (spec/schema.md §16). */
|
|
4
|
+
/** Render one project view, or an area's brief, from canonical notes only (spec/schema.md §16). */
|
|
5
5
|
export function deriveView(project, kind, notes) {
|
|
6
6
|
const canonical = notes.filter((note) => note.type !== "view");
|
|
7
7
|
const byId = new Map(canonical.map((note) => [note.id, note]));
|
|
8
8
|
const generated = new Set([project.id]);
|
|
9
|
-
const link = (note) => noteLink(projectViewPath(project, kind), note);
|
|
9
|
+
const link = (note) => noteLink(kind === "area_brief" ? areaBriefPath(project) : projectViewPath(project, kind), note);
|
|
10
10
|
const title = (note) => text(note.attributes.title);
|
|
11
11
|
const add = (note) => {
|
|
12
12
|
generated.add(note.id);
|
|
@@ -14,10 +14,16 @@ export function deriveView(project, kind, notes) {
|
|
|
14
14
|
};
|
|
15
15
|
const sections = kind === "brief"
|
|
16
16
|
? briefSections(project, canonical, byId, title, add)
|
|
17
|
-
:
|
|
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