ultimate-brain 0.5.0 → 0.6.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 +3 -3
- package/.agents/skills/brain-catch-up/SKILL.md +38 -31
- package/.agents/skills/brain-connect-notion/SKILL.md +29 -2
- package/.agents/skills/brain-howto/references/guide.md +63 -17
- package/.agents/skills/brain-ingest/SKILL.md +11 -7
- package/.agents/skills/brain-ingest/references/cli-inputs.md +18 -8
- package/.agents/skills/brain-ingest/references/extraction.md +24 -11
- package/.agents/skills/brain-ingest/references/notion.md +8 -6
- package/.agents/skills/brain-ingest/references/sources.md +21 -19
- package/.agents/skills/brain-notion-change/SKILL.md +2 -2
- package/.agents/skills/brain-query/SKILL.md +8 -7
- package/.agents/skills/brain-sync/SKILL.md +24 -10
- package/.agents/skills/brain-sync/references/formats.md +28 -1
- package/dist/cli.js +2 -1
- package/dist/commands/modules.js +54 -0
- package/dist/commands/source.js +4 -46
- package/dist/commands/status.js +4 -4
- package/dist/commands/sync.js +3 -1
- package/dist/commands/upgrade.js +33 -18
- package/dist/lib/errors.js +2 -0
- package/dist/lib/index-builder.js +2 -2
- package/dist/lib/lint.js +8 -1
- package/dist/lib/modules.js +91 -0
- package/dist/lib/note-new.js +90 -16
- package/dist/lib/proposals.js +2 -0
- package/dist/lib/sync.js +166 -57
- package/package.json +1 -1
- package/spec/schemas/brain.schema.json +36 -0
- package/spec/schemas/note.schema.json +105 -18
- package/templates/brain/AGENTS.md +2 -1
- package/templates/brain/brain.json +2 -1
package/dist/lib/sync.js
CHANGED
|
@@ -6,15 +6,16 @@ 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 {
|
|
9
|
+
import { splitParts } from "./parts.js";
|
|
10
10
|
import { loadSchemas } from "./schemas.js";
|
|
11
11
|
import { formatDate, formatTimestamp, normalizeTimestamp } from "./time.js";
|
|
12
12
|
import { lineDiff } from "./diff.js";
|
|
13
|
+
import { databaseId, moduleForDatabase, modulesOf, typeIsOn } from "./modules.js";
|
|
13
14
|
import { bodyHash, bodyOf } from "./proposals.js";
|
|
14
|
-
const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "
|
|
15
|
+
const DATABASES = ["areas", "goals", "key_results", "projects", "people", "orgs", "decisions", "questions", "concepts", "documents", "review", "inbox"];
|
|
15
16
|
const CREATABLE = new Set(["areas", "goals", "projects", "people", "orgs"]);
|
|
16
|
-
const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept",
|
|
17
|
-
const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", reference: "
|
|
17
|
+
const TYPE_FOR_DATABASE = { areas: "area", goals: "goal", projects: "project", people: "person", orgs: "org", decisions: "decision", questions: "question", concepts: "concept", documents: "reference", review: "proposal" };
|
|
18
|
+
const DATABASE_FOR_TYPE = { area: "areas", goal: "goals", project: "projects", person: "people", org: "orgs", decision: "decisions", question: "questions", concept: "concepts", reference: "documents", source: "inbox", meeting: "meetings", proposal: "review" };
|
|
18
19
|
const PUBLISHED_TYPES = new Set(["area", "goal", "project", "person", "org", "decision", "question", "concept", "reference", "source", "meeting", "proposal"]);
|
|
19
20
|
function property(value, key) {
|
|
20
21
|
return Object.hasOwn(value, key) ? value[key] : undefined;
|
|
@@ -60,6 +61,8 @@ export function parseSnapshot(input) {
|
|
|
60
61
|
if (!isRecord(value))
|
|
61
62
|
return problems.push({ pointer: p, message: "must be an object" });
|
|
62
63
|
problems.push(...unknownKeys(value, ["database", "page_id", "last_edited", "properties"], p));
|
|
64
|
+
if (value.database === "references")
|
|
65
|
+
value.database = "documents"; // read as documents (0049)
|
|
63
66
|
if (!DATABASES.includes(value.database))
|
|
64
67
|
problems.push({ pointer: `${p}/database`, message: "is not a sync database" });
|
|
65
68
|
if (typeof value.page_id !== "string" || value.page_id === "")
|
|
@@ -74,7 +77,7 @@ export function parseSnapshot(input) {
|
|
|
74
77
|
}
|
|
75
78
|
/** Compute the deterministic, no-network Notion pull plan (cli §5.14.2). */
|
|
76
79
|
export function buildPlan(workspace, snapshot) {
|
|
77
|
-
const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [],
|
|
80
|
+
const result = { as_of: snapshot.as_of, create: [], updates: [], corrections: [], decisions: [], document_requests: [], promote_requests: [], refresh_requests: [], report_fixes: [], conflicts: [], ignored: [] };
|
|
78
81
|
const createsByPage = new Map();
|
|
79
82
|
for (const row of snapshot.rows)
|
|
80
83
|
if (CREATABLE.has(row.database) && blank(property(row.properties, "Brain ID"))) {
|
|
@@ -82,7 +85,14 @@ export function buildPlan(workspace, snapshot) {
|
|
|
82
85
|
if (existing === undefined)
|
|
83
86
|
createsByPage.set(row.page_id, { id: generateId(TYPE_PREFIX[TYPE_FOR_DATABASE[row.database]], new Set([...workspace.ids(), ...[...createsByPage.values()].map((x) => x.id)])), type: TYPE_FOR_DATABASE[row.database] });
|
|
84
87
|
}
|
|
88
|
+
const modules = modulesOf(workspace.config);
|
|
85
89
|
for (const row of snapshot.rows) {
|
|
90
|
+
// A database whose module is off is not read at all (cli §5.14.2, 0048).
|
|
91
|
+
const module = moduleForDatabase(row.database);
|
|
92
|
+
if (module !== null && !modules[module]) {
|
|
93
|
+
result.ignored.push(ignoredRow(row, "MODULE_OFF"));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
86
96
|
if (row.database === "key_results") {
|
|
87
97
|
planKeyResult(workspace, snapshot.as_of, row, result);
|
|
88
98
|
continue;
|
|
@@ -114,6 +124,8 @@ export function buildPlan(workspace, snapshot) {
|
|
|
114
124
|
// A create whose publish failed has no Brain ID in Notion yet; treat the row as if it had it (cli §5.14.2).
|
|
115
125
|
const known = typeof brainId === "string" && brainId !== "" ? row : { ...row, properties: { ...row.properties, "Brain ID": note.id } };
|
|
116
126
|
planExisting(workspace, snapshot.as_of, known, note, result);
|
|
127
|
+
if ((row.database === "decisions" || row.database === "concepts") && property(row.properties, "Promote asked") === true)
|
|
128
|
+
planPromote(workspace, row, note, modules.documents, result);
|
|
117
129
|
}
|
|
118
130
|
sortPlan(result);
|
|
119
131
|
return result;
|
|
@@ -199,8 +211,9 @@ function planExisting(workspace, asOf, row, note, out) {
|
|
|
199
211
|
const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : undefined;
|
|
200
212
|
const rendered = renderedBrainValues(note, workspace);
|
|
201
213
|
const publish = (name, value) => { updates.published = { ...(isRecord(updates.published) ? updates.published : {}), [name]: value }; };
|
|
214
|
+
const off = offRelations(workspace.config, row.database);
|
|
202
215
|
for (const [name, brainField] of brainProperties(row.database)) {
|
|
203
|
-
if (!Object.hasOwn(row.properties, name))
|
|
216
|
+
if (!Object.hasOwn(row.properties, name) || off.has(name))
|
|
204
217
|
continue;
|
|
205
218
|
if (name === "Key Facts") {
|
|
206
219
|
planKeyFacts(row, note, published, updates, out);
|
|
@@ -329,33 +342,85 @@ function planDecision(row, proposal, out) {
|
|
|
329
342
|
}
|
|
330
343
|
out.decisions.push({ proposal: proposal.id, action: action, decided_at: row.last_edited, decided_by: "notion" });
|
|
331
344
|
}
|
|
332
|
-
|
|
345
|
+
function ignoredRow(row, reason, propertyName) {
|
|
346
|
+
const brainId = property(row.properties, "Brain ID");
|
|
347
|
+
return { database: row.database, page_id: row.page_id, brain_id: typeof brainId === "string" && brainId !== "" ? brainId : null, ...(propertyName === undefined ? {} : { property: propertyName }), reason };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* An Inbox row is read for Document asked (0049) and, on a Report row, Refresh and What to fix
|
|
351
|
+
* (0045); a Full text box is retired (cli §5.14.2).
|
|
352
|
+
*/
|
|
333
353
|
function planInbox(workspace, row, out) {
|
|
354
|
+
const modules = modulesOf(workspace.config);
|
|
334
355
|
const brainId = property(row.properties, "Brain ID");
|
|
335
356
|
const note = typeof brainId === "string" && brainId !== "" ? workspace.get(brainId) : undefined;
|
|
357
|
+
if (Object.hasOwn(row.properties, "Full text"))
|
|
358
|
+
out.ignored.push(ignoredRow(row, "RETIRED", "Full text"));
|
|
336
359
|
if (note !== undefined && isReport(note)) {
|
|
337
|
-
if (
|
|
360
|
+
if (!modules.reports) {
|
|
361
|
+
out.ignored.push(ignoredRow(row, "MODULE_OFF"));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (property(row.properties, "Refresh asked") === true || property(row.properties, "Refresh") === true)
|
|
338
365
|
out.refresh_requests.push({ page_id: row.page_id, report: note.id });
|
|
339
366
|
const text = property(row.properties, "What to fix");
|
|
340
367
|
const last = (Array.isArray(note.attributes.fixes) ? note.attributes.fixes : []).filter(isRecord).at(-1);
|
|
341
368
|
if (typeof text === "string" && text.trim() !== "" && text !== last?.text)
|
|
342
369
|
out.report_fixes.push({ page_id: row.page_id, report: note.id, text });
|
|
343
370
|
}
|
|
344
|
-
if (property(row.properties, "
|
|
371
|
+
if (property(row.properties, "Document asked") !== true)
|
|
345
372
|
return;
|
|
346
|
-
|
|
373
|
+
if (!modules.documents) {
|
|
374
|
+
out.ignored.push(ignoredRow(row, "MODULE_OFF", "Document asked"));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
347
377
|
if (typeof brainId !== "string" || brainId === "") {
|
|
348
|
-
|
|
378
|
+
out.ignored.push(ignoredRow(row, "NOT_INGESTED", "Document asked"));
|
|
349
379
|
return;
|
|
350
380
|
}
|
|
351
381
|
if (note === undefined) {
|
|
352
382
|
out.conflicts.push(conflict(row, "UNKNOWN_BRAIN_ID", `No note has Brain ID ${brainId}`));
|
|
353
383
|
return;
|
|
354
384
|
}
|
|
355
|
-
if (
|
|
356
|
-
out.
|
|
357
|
-
|
|
358
|
-
|
|
385
|
+
if (note.type !== "source" || note.attributes.kind === undefined || note.attributes.kind === "minutes") {
|
|
386
|
+
out.ignored.push(ignoredRow(row, "NOT_A_SOURCE", "Document asked"));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const source = latestVersion(workspace, note);
|
|
390
|
+
const files = workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === source.id).map((file) => file.id).sort(compareCodePoints);
|
|
391
|
+
const onlyKeep = property(row.properties, "Only keep");
|
|
392
|
+
const only = typeof onlyKeep === "string" && onlyKeep.trim() !== "" ? onlyKeep : null;
|
|
393
|
+
const request = { page_id: row.page_id, source: source.id, files, only_keep: only };
|
|
394
|
+
if (only === null) {
|
|
395
|
+
const wholes = (files.length > 0 ? files : [source.id]).map((id) => wholeDocumentOf(workspace, id));
|
|
396
|
+
if (wholes.every((id) => id !== undefined))
|
|
397
|
+
request.existing = wholes;
|
|
398
|
+
}
|
|
399
|
+
out.document_requests.push(request);
|
|
400
|
+
}
|
|
401
|
+
/** A Promote asked box on a decision or concept row (cli §5.14.2, 0049). */
|
|
402
|
+
function planPromote(workspace, row, note, documentsOn, out) {
|
|
403
|
+
if (!documentsOn) {
|
|
404
|
+
out.ignored.push(ignoredRow(row, "MODULE_OFF", "Promote asked"));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const existing = workspace.notes.find((candidate) => candidate.type === "reference" && candidate.attributes.origin === "promoted" && candidate.attributes.promoted_from === note.id);
|
|
408
|
+
out.promote_requests.push({ page_id: row.page_id, note: note.id, ...(existing === undefined ? {} : { existing: existing.id }) });
|
|
409
|
+
}
|
|
410
|
+
/** The latest version of a source, following `replaces` forward (schema §4, 0045). */
|
|
411
|
+
function latestVersion(workspace, note) {
|
|
412
|
+
let current = note;
|
|
413
|
+
const seen = new Set([note.id]);
|
|
414
|
+
for (;;) {
|
|
415
|
+
const next = workspace.notes.find((candidate) => candidate.type === "source" && candidate.attributes.replaces === current.id);
|
|
416
|
+
if (next === undefined || seen.has(next.id))
|
|
417
|
+
return current;
|
|
418
|
+
seen.add(next.id);
|
|
419
|
+
current = next;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function wholeDocumentOf(workspace, source) {
|
|
423
|
+
return workspace.notes.find((note) => note.type === "reference" && note.attributes.origin === "whole" && Array.isArray(note.attributes.sources) && note.attributes.sources[0] === source)?.id;
|
|
359
424
|
}
|
|
360
425
|
/** A source whose Inbox row the brain writes: not minutes, a capture or an attached file (0042, 0043). */
|
|
361
426
|
export function hasInboxBody(note) {
|
|
@@ -382,8 +447,8 @@ function planKeyResult(workspace, asOf, row, out) {
|
|
|
382
447
|
out.updates.push({ id: goal, notion_fields: values });
|
|
383
448
|
}
|
|
384
449
|
function sortPlan(plan) {
|
|
385
|
-
const order = (item) => [DATABASES.indexOf(String(item.database)), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
|
|
386
|
-
for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.
|
|
450
|
+
const order = (item) => [String(DATABASES.indexOf(String(item.database))).padStart(2, "0"), String(item.page_id ?? item.id), String(item.field ?? item.property ?? "")].join("\u0000");
|
|
451
|
+
for (const values of [plan.create, plan.updates, plan.corrections, plan.decisions, plan.document_requests, plan.promote_requests, plan.refresh_requests, plan.report_fixes, plan.conflicts, plan.ignored])
|
|
387
452
|
values.sort((a, b) => order(a).localeCompare(order(b)));
|
|
388
453
|
}
|
|
389
454
|
function notionFields(database, p, asOf) {
|
|
@@ -396,7 +461,7 @@ function notionFields(database, p, asOf) {
|
|
|
396
461
|
}
|
|
397
462
|
function brainProperties(database) {
|
|
398
463
|
const common = [["Name", "title"], ["Summary", "summary"], ["Lifecycle", "lifecycle"]];
|
|
399
|
-
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"]],
|
|
464
|
+
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"]], documents: [["Project", "reference_projects"], ["Area", "reference_area"]] };
|
|
400
465
|
return [...common, ...(extras[database] ?? [])];
|
|
401
466
|
}
|
|
402
467
|
function convertedValue(workspace, row, name, value, conflicts) {
|
|
@@ -424,6 +489,28 @@ function linksUnpublished(workspace, note, brainField) {
|
|
|
424
489
|
return ids.some((id) => typeof workspace.get(String(id))?.attributes.notion !== "string");
|
|
425
490
|
}
|
|
426
491
|
/** Equal in Notion's normalized form; relations compare as sets, and an empty text equals null. */
|
|
492
|
+
/** Relation properties left out while their module is off (cli §5.14.4, 0048), per database. */
|
|
493
|
+
function offRelations(config, database) {
|
|
494
|
+
const on = modulesOf(config);
|
|
495
|
+
const off = new Set();
|
|
496
|
+
if (!on.orgs && database === "projects")
|
|
497
|
+
off.add("Client");
|
|
498
|
+
if (!on.orgs && database === "people")
|
|
499
|
+
off.add("Org");
|
|
500
|
+
if (!on.planning && database === "projects")
|
|
501
|
+
off.add("Goal");
|
|
502
|
+
if (!on.knowledge && (database === "inbox" || database === "meetings")) {
|
|
503
|
+
off.add("Decisions");
|
|
504
|
+
off.add("Questions");
|
|
505
|
+
}
|
|
506
|
+
if (!on.knowledge && database === "inbox")
|
|
507
|
+
off.add("Concepts");
|
|
508
|
+
if (!on.documents && database === "inbox")
|
|
509
|
+
off.add("Documents");
|
|
510
|
+
return off;
|
|
511
|
+
}
|
|
512
|
+
/** Brain values that were renamed (0048, 0049): an S of the old one compares equal to the new one. */
|
|
513
|
+
const RENAMED_VALUES = { "Brain Status": { Processed: "Done" }, Kind: { Document: "File" } };
|
|
427
514
|
function sameValue(a, b) {
|
|
428
515
|
const norm = (value) => Array.isArray(value) ? [...value].map(String).sort(compareCodePoints) : value === "" || value === undefined ? null : value;
|
|
429
516
|
return isDeepStrictEqual(norm(a), norm(b));
|
|
@@ -460,8 +547,8 @@ export function parsePlan(input) {
|
|
|
460
547
|
if (!isRecord(input))
|
|
461
548
|
assertNoProblems([{ pointer: "", message: "must be an object" }]);
|
|
462
549
|
const p = input;
|
|
463
|
-
const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "
|
|
464
|
-
for (const key of ["
|
|
550
|
+
const problems = unknownKeys(p, ["as_of", "create", "updates", "corrections", "decisions", "document_requests", "promote_requests", "refresh_requests", "report_fixes", "conflicts", "ignored"], "");
|
|
551
|
+
for (const key of ["document_requests", "promote_requests", "refresh_requests", "report_fixes"])
|
|
465
552
|
if (p[key] !== undefined && !Array.isArray(p[key]))
|
|
466
553
|
problems.push({ pointer: `/${key}`, message: "must be a list" });
|
|
467
554
|
if (!isTimestamp(p.as_of))
|
|
@@ -481,7 +568,7 @@ export function parsePlan(input) {
|
|
|
481
568
|
problems.push({ pointer: `/create/${index}/lifecycle`, message: "must be a lifecycle string" });
|
|
482
569
|
});
|
|
483
570
|
assertNoProblems(problems);
|
|
484
|
-
return { ...p,
|
|
571
|
+
return { ...p, document_requests: p.document_requests ?? [], promote_requests: p.promote_requests ?? [], refresh_requests: p.refresh_requests ?? [], report_fixes: p.report_fixes ?? [], as_of: normalizeTimestamp(p.as_of) };
|
|
485
572
|
}
|
|
486
573
|
export function applyPlan(workspace, plan, now, recordOnly) {
|
|
487
574
|
const create = [];
|
|
@@ -553,7 +640,7 @@ export function applyPlan(workspace, plan, now, recordOnly) {
|
|
|
553
640
|
...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) })),
|
|
554
641
|
...(correctionApply?.log ?? []), ...(decisionApply.log ?? []),
|
|
555
642
|
];
|
|
556
|
-
return { create, updates, corrections, decisions: decisionResult,
|
|
643
|
+
return { create, updates, corrections, decisions: decisionResult, document_requests: plan.document_requests, promote_requests: plan.promote_requests, refresh_requests: plan.refresh_requests, report_fixes: plan.report_fixes, conflicts: plan.conflicts, ignored: plan.ignored, summary: summarize([...create, ...updates, ...corrections, ...decisionResult].map((item) => ({ ...item }))), log };
|
|
557
644
|
}
|
|
558
645
|
function references(value) { if (Array.isArray(value))
|
|
559
646
|
return value.flatMap(references); if (isRecord(value))
|
|
@@ -568,10 +655,17 @@ export function render(workspace, now, ids, held) {
|
|
|
568
655
|
const items = [];
|
|
569
656
|
const errors = [];
|
|
570
657
|
const warnings = [];
|
|
658
|
+
const modules = modulesOf(workspace.config);
|
|
571
659
|
for (const note of selected) {
|
|
572
660
|
const database = isReport(note) ? "inbox" : DATABASE_FOR_TYPE[note.type];
|
|
573
661
|
if (database === undefined || (note.type === "source" && note.attributes.kind === undefined))
|
|
574
662
|
continue;
|
|
663
|
+
// A note of a module that is off isn't published; naming one is an error (0048).
|
|
664
|
+
if ((isReport(note) && !modules.reports) || !typeIsOn(workspace.config, note.type)) {
|
|
665
|
+
if (ids !== undefined)
|
|
666
|
+
errors.push({ id: note.id, reason: "MODULE_OFF" });
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
575
669
|
// An attached source has no Inbox row: its notes go on the parent's (0042).
|
|
576
670
|
if (note.type === "source" && note.attributes.attached_to !== undefined)
|
|
577
671
|
continue;
|
|
@@ -602,6 +696,8 @@ export function render(workspace, now, ids, held) {
|
|
|
602
696
|
}
|
|
603
697
|
const rendered = renderedBrainValues(note, workspace);
|
|
604
698
|
const published = isRecord(note.attributes.notion_published) ? note.attributes.notion_published : {};
|
|
699
|
+
for (const name of offRelations(workspace.config, database))
|
|
700
|
+
delete rendered[name];
|
|
605
701
|
const properties = {};
|
|
606
702
|
const pending = [];
|
|
607
703
|
const heldProperties = held?.get(note.id) ?? new Set();
|
|
@@ -610,12 +706,20 @@ export function render(workspace, now, ids, held) {
|
|
|
610
706
|
if (name === "@content" || heldProperties.has(name) || (capturePage !== undefined && name === "Name"))
|
|
611
707
|
continue;
|
|
612
708
|
const encoded = notionProperty(name, value, note, workspace, pending);
|
|
613
|
-
if (encoded !== undefined && (isCreate || !Object.hasOwn(published, name) || !sameValue(value, published[name])))
|
|
709
|
+
if (encoded !== undefined && (isCreate || !Object.hasOwn(published, name) || !sameValue(value, RENAMED_VALUES[name]?.[String(published[name])] ?? published[name])))
|
|
614
710
|
properties[name] = encoded;
|
|
615
711
|
}
|
|
616
712
|
const content = renderedContent(note, workspace);
|
|
617
713
|
if (content.hash !== undefined)
|
|
618
714
|
rendered["@content"] = content.hash;
|
|
715
|
+
// A promoted Document's body is yours once its page exists (0049): sent on create only.
|
|
716
|
+
if (note.type === "reference" && note.attributes.origin === "promoted" && !isCreate) {
|
|
717
|
+
content.body = null;
|
|
718
|
+
delete rendered["@content"];
|
|
719
|
+
if (Object.hasOwn(published, "@content"))
|
|
720
|
+
rendered["@content"] = published["@content"];
|
|
721
|
+
content.hash = undefined;
|
|
722
|
+
}
|
|
619
723
|
const contentChanged = content.hash !== undefined && !isDeepStrictEqual(content.hash, published["@content"]);
|
|
620
724
|
// A report (0045): a refresh sends the body once and clears Refresh; a new fix clears What to fix.
|
|
621
725
|
let reportDue = false;
|
|
@@ -623,7 +727,7 @@ export function render(workspace, now, ids, held) {
|
|
|
623
727
|
const fixes = Array.isArray(note.attributes.fixes) ? note.attributes.fixes.length : 0;
|
|
624
728
|
const stored = typeof published["@fixes"] === "number" ? published["@fixes"] : 0;
|
|
625
729
|
if (!isCreate && contentChanged)
|
|
626
|
-
properties
|
|
730
|
+
properties["Refresh asked"] = { type: "checkbox", value: false };
|
|
627
731
|
if (!isCreate && !contentChanged)
|
|
628
732
|
content.body = null;
|
|
629
733
|
if (fixes > stored) {
|
|
@@ -632,16 +736,8 @@ export function render(workspace, now, ids, held) {
|
|
|
632
736
|
}
|
|
633
737
|
rendered["@fixes"] = Math.max(fixes, stored);
|
|
634
738
|
}
|
|
635
|
-
// Full text (0043)
|
|
636
|
-
|
|
637
|
-
if (full?.sections !== undefined)
|
|
638
|
-
content.sections = { ...(content.sections ?? {}), ...full.sections };
|
|
639
|
-
if (full?.clear === true)
|
|
640
|
-
properties["Full text"] = { type: "checkbox", value: false };
|
|
641
|
-
if (full !== undefined && (full.sections !== undefined || full.clear))
|
|
642
|
-
rendered["@full_text"] = full.record;
|
|
643
|
-
const fullTextDueNow = full !== undefined && (full.sections !== undefined || full.clear);
|
|
644
|
-
if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !fullTextDueNow && !reportDue)
|
|
739
|
+
// Full text (0043) is retired (0049): nothing is sent, and `@full_text` in S stays as it is.
|
|
740
|
+
if (!isCreate && Object.keys(properties).length === 0 && !contentChanged && !reportDue)
|
|
645
741
|
continue;
|
|
646
742
|
if (!isCreate && note.type !== "meeting" && note.type !== "proposal")
|
|
647
743
|
properties["Last Synced"] = { type: "date", value: { start: formatTimestamp(now), end: null } };
|
|
@@ -660,8 +756,7 @@ function trashDue(note, now) {
|
|
|
660
756
|
const time = typeof decided === "string" ? Date.parse(decided) : Number.NaN;
|
|
661
757
|
return Number.isFinite(time) && now.getTime() - time > TRASH_AFTER_MS;
|
|
662
758
|
}
|
|
663
|
-
function databaseIdFor(workspace, database) {
|
|
664
|
-
return ""; return typeof notion.databases[database] === "string" ? notion.databases[database] : database === "inbox" && typeof notion.databases.capture_inbox === "string" ? notion.databases.capture_inbox : ""; }
|
|
759
|
+
function databaseIdFor(workspace, database) { return databaseId(workspace.config, database); }
|
|
665
760
|
function renderedBrainValues(note, workspace) {
|
|
666
761
|
const a = note.attributes;
|
|
667
762
|
const values = {};
|
|
@@ -681,16 +776,16 @@ function renderedBrainValues(note, workspace) {
|
|
|
681
776
|
const page = (source ?? target)?.attributes.notion;
|
|
682
777
|
const meeting = typeof a.source === "string" ? workspace.get(a.source) : undefined;
|
|
683
778
|
Object.assign(values, {
|
|
684
|
-
Name: a
|
|
779
|
+
Name: reviewName(a), Kind: select(a.kind), About: targetText,
|
|
685
780
|
"About Page": typeof page === "string" ? `https://www.notion.so/${page.replaceAll("-", "")}` : null,
|
|
686
781
|
"Source Meeting": meeting?.type === "meeting" && typeof meeting.attributes.notion === "string" ? [meeting.attributes.notion] : [],
|
|
687
782
|
Now: reviewText(proposalBefore(a, workspace)), Proposed: proposed !== undefined ? proposed : reviewText(after ?? proposalAfter(a, workspace)),
|
|
688
|
-
Why: reviewText(a.why), State: select(proposalState(a.status)), "Brain ID": note.id,
|
|
783
|
+
Why: reviewText(a.why), Area: relationPages(reviewArea(a, workspace), workspace), State: select(proposalState(a.status)), "Brain ID": note.id,
|
|
689
784
|
});
|
|
690
785
|
return values;
|
|
691
786
|
}
|
|
692
787
|
if (isReport(note)) {
|
|
693
|
-
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] : Array.isArray(a.asked_in) ? a.asked_in : [], "Brain Status": "
|
|
788
|
+
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] : Array.isArray(a.asked_in) ? a.asked_in : [], "Brain Status": "Done", "Brain ID": note.id });
|
|
694
789
|
return values;
|
|
695
790
|
}
|
|
696
791
|
if (note.type === "meeting") {
|
|
@@ -723,8 +818,28 @@ function renderedBrainValues(note, workspace) {
|
|
|
723
818
|
}
|
|
724
819
|
if (note.type === "concept" || note.type === "reference")
|
|
725
820
|
Object.assign(values, { Project: relationPages(a.projects, workspace), Area: relationPages(a.area === undefined ? [] : [a.area], workspace) });
|
|
821
|
+
if (note.type === "reference") {
|
|
822
|
+
const from = a.origin === "promoted" && typeof a.promoted_from === "string" ? a.promoted_from : undefined;
|
|
823
|
+
Object.assign(values, { "From decision": relationPages(from?.startsWith("dec-") ? [from] : [], workspace), "From concept": relationPages(from?.startsWith("con-") ? [from] : [], workspace) });
|
|
824
|
+
}
|
|
726
825
|
return values;
|
|
727
826
|
}
|
|
827
|
+
/** A Review row's Name says which kind of row it is (cli §5.14.4, 0048). */
|
|
828
|
+
function reviewName(a) {
|
|
829
|
+
const prefix = ["contradiction", "supersede", "merge", "new_item"].includes(String(a.kind)) ? "Conflict: " : a.kind === "answer" ? "Answer: " : "";
|
|
830
|
+
return typeof a.title === "string" && prefix !== "" && !a.title.startsWith(prefix) ? `${prefix}${a.title}` : a.title;
|
|
831
|
+
}
|
|
832
|
+
/** A Review row's one area: its source note's own area, else the area of its first project (cli §5.14.4, 0050). */
|
|
833
|
+
function reviewArea(a, workspace) {
|
|
834
|
+
const source = typeof a.source === "string" ? workspace.get(a.source) : undefined;
|
|
835
|
+
if (source === undefined)
|
|
836
|
+
return [];
|
|
837
|
+
if (typeof source.attributes.area === "string")
|
|
838
|
+
return [source.attributes.area];
|
|
839
|
+
const first = Array.isArray(source.attributes.projects) ? source.attributes.projects[0] : undefined;
|
|
840
|
+
const area = typeof first === "string" ? workspace.get(first)?.attributes.area : undefined;
|
|
841
|
+
return typeof area === "string" ? [area] : [];
|
|
842
|
+
}
|
|
728
843
|
/** Area on decisions and questions is a derived Notion relation (decision 0034). */
|
|
729
844
|
function projectAreas(projects, workspace) {
|
|
730
845
|
const areas = (Array.isArray(projects) ? projects : []).flatMap((project) => {
|
|
@@ -736,20 +851,23 @@ function projectAreas(projects, workspace) {
|
|
|
736
851
|
/** The notes a source produced, with those of the sources attached to it (cli §5.14.4, 0042). */
|
|
737
852
|
function producedBy(note, workspace) {
|
|
738
853
|
const sources = new Set([note.id, ...workspace.notes.filter((candidate) => candidate.type === "source" && candidate.attributes.attached_to === note.id).map((candidate) => candidate.id)]);
|
|
739
|
-
|
|
854
|
+
// A promoted Document comes from its note, not the source (0049).
|
|
855
|
+
return workspace.notes.filter((candidate) => ["decision", "question", "concept", "reference", "person"].includes(candidate.type) && !(candidate.type === "reference" && candidate.attributes.origin === "promoted") && Array.isArray(candidate.attributes.sources) && candidate.attributes.sources.some((id) => sources.has(String(id))));
|
|
740
856
|
}
|
|
741
857
|
function sourceValues(note, workspace) {
|
|
742
858
|
const produced = producedBy(note, workspace);
|
|
743
859
|
const ids = (type) => produced.filter((candidate) => candidate.type === type).map((candidate) => candidate.id).sort(compareCodePoints);
|
|
744
860
|
const projects = [...new Set(produced.flatMap((candidate) => Array.isArray(candidate.attributes.projects) ? candidate.attributes.projects.filter((id) => typeof id === "string") : []))].sort(compareCodePoints);
|
|
745
861
|
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);
|
|
746
|
-
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), ...(databaseIdFor(workspace, "
|
|
862
|
+
return { Name: note.attributes.title, Kind: note.attributes.kind === "document" ? "File" : select(note.attributes.kind), Summary: note.attributes.summary ?? null, Decisions: relationPages(ids("decision"), workspace), Questions: relationPages(ids("question"), workspace), Concepts: relationPages(ids("concept"), workspace), ...(databaseIdFor(workspace, "documents") === "" ? {} : { Documents: relationPages(ids("reference"), workspace) }), People: relationPages(ids("person"), workspace), Project: relationPages(projects, workspace), Area: relationPages(areas, workspace), "Brain Status": "Done", "Brain ID": note.id };
|
|
747
863
|
}
|
|
748
864
|
function notionProperty(name, value, note, workspace, pending) {
|
|
749
|
-
const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "
|
|
865
|
+
const relation = ["Area", "Goal", "Client", "People", "Org", "Project", "Decided In", "Raised In", "Supersedes", "Decisions", "Questions", "Concepts", "Documents", "Attendees", "From decision", "From concept"].includes(name);
|
|
750
866
|
if (relation) {
|
|
751
867
|
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" };
|
|
752
|
-
const raw = name === "Area" &&
|
|
868
|
+
const raw = name === "Area" && note.type === "proposal" ? reviewArea(note.attributes, workspace)
|
|
869
|
+
: name === "From decision" || name === "From concept" ? (Array.isArray(value) && note.attributes.promoted_from !== undefined && String(note.attributes.promoted_from).startsWith(name === "From decision" ? "dec-" : "con-") ? [note.attributes.promoted_from] : [])
|
|
870
|
+
: 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] ?? ""];
|
|
753
871
|
const missing = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map(String).filter((id) => typeof workspace.get(id)?.attributes.notion !== "string");
|
|
754
872
|
if (missing.length > 0)
|
|
755
873
|
pending.push({ property: name, ids: missing });
|
|
@@ -766,6 +884,9 @@ function notionProperty(name, value, note, workspace, pending) {
|
|
|
766
884
|
return { type: "relation", value: Array.isArray(value) ? value : [] };
|
|
767
885
|
if (name === "About Page")
|
|
768
886
|
return { type: "url", value };
|
|
887
|
+
// An Inbox row's Kind is written only when empty in Notion, so a Kind you chose stands (0048).
|
|
888
|
+
if (name === "Kind" && note.type === "source")
|
|
889
|
+
return { type: "select", value, mode: "if_empty" };
|
|
769
890
|
if (["Lifecycle", "Assessment", "Kind", "State", "Brain Status", "Status"].includes(name))
|
|
770
891
|
return { type: "select", value };
|
|
771
892
|
if (name === "Last Synced" || name === "Date" || name === "Deadline" || name === "Ingested At")
|
|
@@ -780,7 +901,7 @@ function sourceRelationIds(note, name, workspace) {
|
|
|
780
901
|
return produced.filter((candidate) => candidate.type === "question").map((candidate) => candidate.id);
|
|
781
902
|
if (name === "Concepts")
|
|
782
903
|
return produced.filter((candidate) => candidate.type === "concept").map((candidate) => candidate.id);
|
|
783
|
-
if (name === "
|
|
904
|
+
if (name === "Documents")
|
|
784
905
|
return produced.filter((candidate) => candidate.type === "reference").map((candidate) => candidate.id);
|
|
785
906
|
if (name === "People")
|
|
786
907
|
return produced.filter((candidate) => candidate.type === "person").map((candidate) => candidate.id);
|
|
@@ -804,7 +925,7 @@ function renderedContent(note, workspace) {
|
|
|
804
925
|
const sections = { Brief: notionMarkdown(generatedBlock(brief.body), brief, workspace) };
|
|
805
926
|
return { body: null, sections, hash: hash(JSON.stringify(sections)) };
|
|
806
927
|
}
|
|
807
|
-
// A source's digest is written once
|
|
928
|
+
// A source's digest is written once (0043).
|
|
808
929
|
if (note.type === "source") {
|
|
809
930
|
if (!hasInboxBody(note))
|
|
810
931
|
return { body: null, sections: null };
|
|
@@ -889,18 +1010,6 @@ export function letters(index) {
|
|
|
889
1010
|
return index < 26 ? String.fromCharCode(97 + index) : `${letters(Math.floor(index / 26) - 1)}${letters(index % 26)}`;
|
|
890
1011
|
}
|
|
891
1012
|
function isReport(note) { return note.type === "view" && note.attributes.view_kind === "report"; }
|
|
892
|
-
/**
|
|
893
|
-
* Whether a source's Full text is due (cli §5.14.4): B of `@full_text` against S. The text goes out
|
|
894
|
-
* when it is chosen and S doesn't hold the same choice; the box is cleared when a request is new.
|
|
895
|
-
*/
|
|
896
|
-
function fullTextDue(note, stored) {
|
|
897
|
-
const a = note.attributes;
|
|
898
|
-
const record = { text: a.notion_text ?? "digest", parts: Array.isArray(a.notion_parts) ? a.notion_parts : null, requested: a.notion_text_requested ?? null };
|
|
899
|
-
const s = isRecord(stored) ? stored : undefined;
|
|
900
|
-
const send = record.text === "full" && (s === undefined || s.text !== record.text || !isDeepStrictEqual(s.parts ?? null, record.parts));
|
|
901
|
-
const clear = record.requested !== null && record.requested !== (s?.requested ?? null);
|
|
902
|
-
return { record, ...(send ? { sections: { "Full text": fullText(note.body, record.parts) } } : {}), clear };
|
|
903
|
-
}
|
|
904
1013
|
/** A brain-created Inbox row's body: a line about the source, its summary and its digest (cli §5.14.4). */
|
|
905
1014
|
function digestBody(note) {
|
|
906
1015
|
const a = note.attributes;
|
package/package.json
CHANGED
|
@@ -64,6 +64,37 @@
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
|
+
"modules": {
|
|
68
|
+
"description": "Which optional modules are on (decision 0048, spec/schema.md §12). Absent: every module on (a brain made before 0.6.0). A key absent from a present block takes the module's default.",
|
|
69
|
+
"type": "object",
|
|
70
|
+
"additionalProperties": false,
|
|
71
|
+
"properties": {
|
|
72
|
+
"knowledge": {
|
|
73
|
+
"type": "boolean",
|
|
74
|
+
"default": true
|
|
75
|
+
},
|
|
76
|
+
"orgs": {
|
|
77
|
+
"type": "boolean",
|
|
78
|
+
"default": true
|
|
79
|
+
},
|
|
80
|
+
"documents": {
|
|
81
|
+
"type": "boolean",
|
|
82
|
+
"default": false
|
|
83
|
+
},
|
|
84
|
+
"reports": {
|
|
85
|
+
"type": "boolean",
|
|
86
|
+
"default": false
|
|
87
|
+
},
|
|
88
|
+
"tasks": {
|
|
89
|
+
"type": "boolean",
|
|
90
|
+
"default": false
|
|
91
|
+
},
|
|
92
|
+
"planning": {
|
|
93
|
+
"type": "boolean",
|
|
94
|
+
"default": false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
67
98
|
"repos": {
|
|
68
99
|
"type": "array",
|
|
69
100
|
"items": {
|
|
@@ -158,6 +189,11 @@
|
|
|
158
189
|
"$ref": "#/$defs/dbId"
|
|
159
190
|
},
|
|
160
191
|
"references": {
|
|
192
|
+
"$ref": "#/$defs/dbId",
|
|
193
|
+
"deprecated": true,
|
|
194
|
+
"description": "Old name of documents (decision 0049); read as documents when documents is absent. brain upgrade renames it."
|
|
195
|
+
},
|
|
196
|
+
"documents": {
|
|
161
197
|
"$ref": "#/$defs/dbId"
|
|
162
198
|
}
|
|
163
199
|
}
|