feat(linkedin-studio): second-brain SB-S0 foundation — brain/ scaffold + profile fold + id/provenance spine [skip-docs]
SB-S0 (Foundation) of the second-brain arc: a new TS package scripts/brain/
(structural copy of specifics-bank) establishing the spine later slices hang on.
TDD throughout (failing test first); fold scope = P1+P2 only per operator decision.
- types.ts: one provenance vocab (human|published|ai-draft) + the six-field
ProfileFact record + two-layer ProfileDoc.
- id.ts: slugify + mintEntityId (sha256[:12], kind-namespaced, slug-keyed so the
id is stable across value edits) + normalizeProvenance (throws on unknown). [SC4]
- profile.ts: no-YAML line-grammar parse/serialize (parse∘serialize = identity over
the whole doc; values may contain ]/|/quotes) [SC2] + foldUserProfile: lossless,
idempotent, source-absent-aware fold of config/user-profile.template.md. Pinned
extraction — P1 labeled scalars (group-headers skipped, [placeholder]→empty) + P2
expertise group; stops at "### Research Tooling" so deferred explainer prose can't
leak in as fields. Checkbox-prefs (Goals/Tone/MCPs/Assets) deferred (§8). [SC3]
- dataRoot.ts: inline per-package resolver (repo idiom; no new seam → SC6).
- scaffold.ts/cli.ts: idempotent `brain init` — brain/{index,profile,operations}.md
+ journal/ + ingest/{inbox,published} under the data-dir; compare-then-skip, never
clobbers a user edit. No session-start wiring (SB-S2 owns it). [SC1]
Gate: new BRAIN floor in test-runner.sh (34 tests; trends/specifics/contract floors
unchanged) + anti-erosion floor 74→75. Full gate 90/0/0, tsc --noEmit clean. [SC5/SC6]
No new command/agent/reference → no version bump, no structure-count change.
Refs docs/second-brain/{brief,plan-sb-s0,architecture}.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
This commit is contained in:
parent
d6acb5d36c
commit
8c927198f5
17 changed files with 1554 additions and 8 deletions
45
scripts/brain/src/cli.ts
Normal file
45
scripts/brain/src/cli.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI for the second-brain foundation (SB-S0).
|
||||
*
|
||||
* node --import tsx src/cli.ts init
|
||||
*
|
||||
* `init` scaffolds the `brain/` + `ingest/` tree under the per-user data dir
|
||||
* (`LINKEDIN_STUDIO_DATA` overrides the root). Idempotent — running it twice is a
|
||||
* no-op. No session-start wiring yet (SB-S2 owns that); this is the invokable
|
||||
* subcommand only.
|
||||
*
|
||||
* Exit code: 0 on success, 2 on usage error.
|
||||
*/
|
||||
|
||||
import { dataRoot } from "./dataRoot.js";
|
||||
import { initBrain } from "./scaffold.js";
|
||||
|
||||
function usage(msg: string): never {
|
||||
console.error(`error: ${msg}`);
|
||||
console.error("usage:\n init");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const [command] = process.argv.slice(2);
|
||||
|
||||
if (command === "init") {
|
||||
const { created, skipped } = initBrain();
|
||||
console.log(`Brain root: ${dataRoot("brain")}`);
|
||||
if (created.length > 0) {
|
||||
console.log(`Created (${created.length}):`);
|
||||
for (const c of created) console.log(` + ${c}`);
|
||||
}
|
||||
if (skipped.length > 0) {
|
||||
console.log(`Skipped — already present (${skipped.length}):`);
|
||||
for (const s of skipped) console.log(` · ${s}`);
|
||||
}
|
||||
if (created.length === 0) console.log("Already initialised — nothing to do.");
|
||||
return;
|
||||
}
|
||||
|
||||
usage(command ? `unknown command: ${command}` : "no command given");
|
||||
}
|
||||
|
||||
main();
|
||||
19
scripts/brain/src/dataRoot.ts
Normal file
19
scripts/brain/src/dataRoot.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Per-package data-root resolver (M3: the repo idiom, NOT a new shared seam).
|
||||
*
|
||||
* The trends/specifics/analytics packages each inline this same resolver
|
||||
* (`trends/src/store.ts:180`, `specifics-bank/src/bank.ts:150`,
|
||||
* `analytics/src/utils/storage.ts:54`) rather than importing a shared util —
|
||||
* there is no cross-package path map. SB-S0 matches that idiom, so it adds NO new
|
||||
* seam function and the `data-root.mjs ⇄ storage.ts` twin-sync stays untouched
|
||||
* (SC6). De-duplicating the now-four copies is an explicit out-of-scope refactor.
|
||||
*/
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Resolve `sub` under the per-user data dir; `LINKEDIN_STUDIO_DATA` overrides the root. */
|
||||
export function dataRoot(sub: string): string {
|
||||
const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio");
|
||||
return join(root, sub);
|
||||
}
|
||||
54
scripts/brain/src/id.ts
Normal file
54
scripts/brain/src/id.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Canonical entity-id + provenance normalization (SB-S0 spine).
|
||||
*
|
||||
* Pure + deterministic — no filesystem, no clock, no network. The id is keyed on
|
||||
* a STABLE SLUG of the label, not the raw label, so editing a fact's *value* (or
|
||||
* the label's case/whitespace) never re-mints the id. SB-S3 will thread this id
|
||||
* through the tributaries; SB-S0 only establishes mint + shape.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { PROVENANCE_VALUES } from "./types.js";
|
||||
import type { Provenance } from "./types.js";
|
||||
|
||||
/**
|
||||
* Stable slug: lowercase, trim, every non-alphanumeric run → a single `-`, with
|
||||
* leading/trailing dashes stripped. Stable across case + whitespace variation of
|
||||
* the same label, so it is a safe id key.
|
||||
*/
|
||||
export function slugify(label: string): string {
|
||||
return label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* One canonical entity id = first 12 hex of `sha256(`${kind}:${slugify(key)}`)`.
|
||||
* Matches the trends/specifics `sha256[:12]` idiom (`specifics-bank/src/bank.ts:58`).
|
||||
* `kind` namespaces the id so the same key under different kinds never collides.
|
||||
*/
|
||||
export function mintEntityId(seed: { kind: string; key: string }): string {
|
||||
return createHash("sha256")
|
||||
.update(`${seed.kind}:${slugify(seed.key)}`)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw provenance string to the single brain vocabulary. Trims +
|
||||
* lowercases, then returns the match or THROWS — provenance is load-bearing for
|
||||
* the model-collapse guard, so an unknown value is an error, never a silent pass.
|
||||
*/
|
||||
export function normalizeProvenance(raw: string): Provenance {
|
||||
const candidate = raw.trim().toLowerCase();
|
||||
const match = PROVENANCE_VALUES.find((v) => v === candidate);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`invalid provenance: ${JSON.stringify(raw)} — expected one of ${PROVENANCE_VALUES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
224
scripts/brain/src/profile.ts
Normal file
224
scripts/brain/src/profile.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* The two-layer `brain/profile.md` line-grammar + the user-profile fold (SB-S0).
|
||||
*
|
||||
* NO YAML dependency (M2): the repo has no YAML parser, so the profile uses a
|
||||
* defined line-grammar that round-trips with the regex idiom already in-tree
|
||||
* (`state-updater.mjs` scalar regex). One fact = one line:
|
||||
*
|
||||
* - [<provenance>|<status>|<first_seen>|<last_seen>|<evidence_count>|<id>] <value>
|
||||
*
|
||||
* The six bracket tokens are all CONSTRAINED (two enums, two ISO dates, an int, a
|
||||
* 12-hex id) — none can contain `]` or `|`, so the first `]` unambiguously closes
|
||||
* the bracket and `<value>` (free single-line text, may contain `]`/`|`/quotes) is
|
||||
* everything after `] `. `parseProfile(serializeProfile(doc)) === doc` (SC2).
|
||||
*/
|
||||
|
||||
import { mintEntityId } from "./id.js";
|
||||
import { SCHEMA_VERSION } from "./types.js";
|
||||
import type { FactStatus, ProfileDoc, ProfileFact, Provenance } from "./types.js";
|
||||
|
||||
const STATIC_HEADER = "## Static";
|
||||
const DYNAMIC_HEADER = "## Dynamic";
|
||||
|
||||
/**
|
||||
* One fact line. Tokens are pipe-joined in a fixed order; an empty value emits no
|
||||
* trailing space (so the common source-absent fact is a clean `- [tokens]`).
|
||||
*/
|
||||
function serializeFact(f: ProfileFact): string {
|
||||
const bracket = [
|
||||
f.provenance,
|
||||
f.status,
|
||||
f.first_seen,
|
||||
f.last_seen,
|
||||
String(f.evidence_count),
|
||||
f.id,
|
||||
].join("|");
|
||||
return f.value === "" ? `- [${bracket}]` : `- [${bracket}] ${f.value}`;
|
||||
}
|
||||
|
||||
/** Deterministic full-document serialization; both sections always present. */
|
||||
export function serializeProfile(doc: ProfileDoc): string {
|
||||
const lines: string[] = [
|
||||
"# Profile",
|
||||
"",
|
||||
`schemaVersion: ${doc.schemaVersion}`,
|
||||
"",
|
||||
STATIC_HEADER,
|
||||
"",
|
||||
...doc.static.map(serializeFact),
|
||||
"",
|
||||
DYNAMIC_HEADER,
|
||||
"",
|
||||
...doc.dynamic.map(serializeFact),
|
||||
];
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
const FACT_RE =
|
||||
/^- \[(human|published|ai-draft)\|(active|superseded)\|(\d{4}-\d{2}-\d{2})\|(\d{4}-\d{2}-\d{2})\|(\d+)\|([0-9a-f]{12})\](?: (.*))?$/;
|
||||
|
||||
/**
|
||||
* Parse a profile document back into the typed two-layer doc. Lines that are not
|
||||
* fact lines or section headers (blank lines, the title) carry no data and are
|
||||
* skipped — the fact lines ARE the authoritative representation (C1), so nothing
|
||||
* data-bearing is lost, and `parse ∘ serialize` is a true identity.
|
||||
*/
|
||||
export function parseProfile(text: string): ProfileDoc {
|
||||
const schemaMatch = text.match(/^schemaVersion:\s*(\d+)\s*$/m);
|
||||
const version = schemaMatch ? Number(schemaMatch[1]) : SCHEMA_VERSION;
|
||||
if (version !== SCHEMA_VERSION) {
|
||||
throw new Error(`unsupported profile schemaVersion: ${version} (expected ${SCHEMA_VERSION})`);
|
||||
}
|
||||
|
||||
const staticFacts: ProfileFact[] = [];
|
||||
const dynamicFacts: ProfileFact[] = [];
|
||||
let section: "static" | "dynamic" | null = null;
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === STATIC_HEADER) {
|
||||
section = "static";
|
||||
continue;
|
||||
}
|
||||
if (trimmed === DYNAMIC_HEADER) {
|
||||
section = "dynamic";
|
||||
continue;
|
||||
}
|
||||
const m = line.match(FACT_RE);
|
||||
if (!m) continue;
|
||||
const fact: ProfileFact = {
|
||||
provenance: m[1] as Provenance,
|
||||
status: m[2] as FactStatus,
|
||||
first_seen: m[3],
|
||||
last_seen: m[4],
|
||||
evidence_count: Number(m[5]),
|
||||
id: m[6],
|
||||
value: m[7] ?? "",
|
||||
};
|
||||
if (section === "static") staticFacts.push(fact);
|
||||
else if (section === "dynamic") dynamicFacts.push(fact);
|
||||
}
|
||||
|
||||
return { schemaVersion: SCHEMA_VERSION, static: staticFacts, dynamic: dynamicFacts };
|
||||
}
|
||||
|
||||
// ── user-profile fold ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Folds the field-set of `config/user-profile.template.md` into the static layer
|
||||
// (M4: extraction pinned against the real template). Exactly two productions, and
|
||||
// a hard section boundary:
|
||||
//
|
||||
// P1 — labeled scalar: `**Label:** value` (optionally `- ` prefixed). A line
|
||||
// whose value is EMPTY is a GROUP HEADER (`**Signature Elements:**`) and is
|
||||
// skipped — only the scalar fields under it fold. A `[placeholder]` value
|
||||
// is stored EMPTY (unfilled); a literal value is kept.
|
||||
// P2 — expertise group: numbered lines under `**Core Expertise Areas …:**`
|
||||
// become `expertise-area-N` fields.
|
||||
// Boundary — extraction STOPS at the `### Research Tooling` section, so the
|
||||
// deferred Research-MCP / Asset-Utilization prose (which uses the same
|
||||
// `**Label:** value` syntax for explainer text) cannot leak in as fields.
|
||||
//
|
||||
// Deferred to a later slice when the profile schema grows (§8): the checkbox
|
||||
// preference groups (Goals, Tone, MCPs, Assets), Content Style Mix, and the
|
||||
// Voice-Profile-Summary qualities — none of which match P1/P2.
|
||||
|
||||
const PROFILE_FIELD_KIND = "profile-field";
|
||||
|
||||
interface ExtractedField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** A leading `[...]` bracketed placeholder means the field is unfilled → empty. */
|
||||
function placeholderToEmpty(raw: string): string {
|
||||
return /^\[[^\]]*\]/.test(raw) ? "" : raw;
|
||||
}
|
||||
|
||||
/** Apply P1 + P2 over the template/instance text up to the Research-Tooling boundary. */
|
||||
function extractFields(rawText: string): ExtractedField[] {
|
||||
const cutoff = rawText.search(/^###\s+Research Tooling\b/m);
|
||||
const text = cutoff === -1 ? rawText : rawText.slice(0, cutoff);
|
||||
|
||||
const fields: ExtractedField[] = [];
|
||||
let inExpertise = false;
|
||||
let expertiseIndex = 0;
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
// P2 anchor — the expertise group header (value-less by construction).
|
||||
if (/^\s*\*\*Core Expertise Areas\b.*:\*\*\s*$/.test(line)) {
|
||||
inExpertise = true;
|
||||
expertiseIndex = 0;
|
||||
continue;
|
||||
}
|
||||
if (inExpertise) {
|
||||
const item = line.match(/^\s*(\d+)\.\s+(.*)$/);
|
||||
if (item) {
|
||||
expertiseIndex += 1;
|
||||
fields.push({ label: `expertise-area-${expertiseIndex}`, value: placeholderToEmpty(item[2].trim()) });
|
||||
continue;
|
||||
}
|
||||
// A non-blank, non-numbered line closes the group; blanks keep it open.
|
||||
if (line.trim() !== "") inExpertise = false;
|
||||
}
|
||||
// P1 — labeled scalar.
|
||||
const m = line.match(/^\s*(?:- )?\*\*(.+?):\*\*\s*(.*)$/);
|
||||
if (m) {
|
||||
const value = m[2].trim();
|
||||
if (value === "") continue; // group header — owned by its sub-fields, not a fact
|
||||
fields.push({ label: m[1].trim(), value: placeholderToEmpty(value) });
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the user-profile field-set into a two-layer `ProfileDoc.static`. The
|
||||
* TEMPLATE defines the canonical field-set; an optional runtime INSTANCE supplies
|
||||
* filled values; an optional EXISTING doc makes the fold idempotent.
|
||||
*
|
||||
* Lossless + idempotent (SC3):
|
||||
* - source-absent → every template field present, placeholder values empty;
|
||||
* - populated → each filled instance value carried onto the matching field;
|
||||
* - re-run → no duplication, a non-empty value is never overwritten, only
|
||||
* `last_seen` is bumped to `today`.
|
||||
*
|
||||
* Pure — the caller supplies the file texts and the run date (`today`).
|
||||
*/
|
||||
export function foldUserProfile(opts: {
|
||||
templateText: string;
|
||||
instanceText?: string;
|
||||
existing?: ProfileDoc;
|
||||
today: string;
|
||||
}): ProfileDoc {
|
||||
const { templateText, instanceText, existing, today } = opts;
|
||||
|
||||
const templateFields = extractFields(templateText);
|
||||
const instanceByLabel = new Map(
|
||||
(instanceText ? extractFields(instanceText) : []).map((f) => [f.label, f.value]),
|
||||
);
|
||||
const existingById = new Map((existing?.static ?? []).map((f) => [f.id, f]));
|
||||
|
||||
const staticFacts: ProfileFact[] = templateFields.map((tf) => {
|
||||
const id = mintEntityId({ kind: PROFILE_FIELD_KIND, key: tf.label });
|
||||
const instanceValue = instanceByLabel.get(tf.label);
|
||||
// Prefer a filled instance value over the template's (possibly empty) value.
|
||||
const incomingValue = instanceValue !== undefined && instanceValue !== "" ? instanceValue : tf.value;
|
||||
|
||||
const prev = existingById.get(id);
|
||||
if (prev) {
|
||||
// Idempotent merge: never overwrite a non-empty value; fill empties; bump last_seen.
|
||||
return { ...prev, value: prev.value !== "" ? prev.value : incomingValue, last_seen: today };
|
||||
}
|
||||
return {
|
||||
id,
|
||||
value: incomingValue,
|
||||
first_seen: today,
|
||||
last_seen: today,
|
||||
evidence_count: 0,
|
||||
provenance: "human",
|
||||
status: "active",
|
||||
};
|
||||
});
|
||||
|
||||
return { schemaVersion: SCHEMA_VERSION, static: staticFacts, dynamic: existing?.dynamic ?? [] };
|
||||
}
|
||||
117
scripts/brain/src/scaffold.ts
Normal file
117
scripts/brain/src/scaffold.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* `initBrain` — the idempotent runtime scaffold for the second brain (SB-S0 D1/D5).
|
||||
*
|
||||
* Creates the `brain/` + `ingest/` tree under the per-user data dir (via the
|
||||
* `dataRoot` resolver, so `LINKEDIN_STUDIO_DATA` relocates it for tests + real
|
||||
* installs). Idempotent + migration-safe: a directory that exists is left alone,
|
||||
* a file that exists is NEVER rewritten (compare-then-skip, never blind-write) —
|
||||
* so a second run is a no-op and a user-edited file is preserved.
|
||||
*
|
||||
* NO session-start wiring (SB-S2 owns that) — this only ships the invokable init.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { dataRoot } from "./dataRoot.js";
|
||||
import { foldUserProfile, serializeProfile } from "./profile.js";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
/** The shipped template that defines the canonical profile field-set. */
|
||||
const TEMPLATE_PATH = join(HERE, "..", "..", "..", "config", "user-profile.template.md");
|
||||
/** The runtime instance (if the user copied + filled the template). */
|
||||
const INSTANCE_SUB = join("profile", "user-profile.md");
|
||||
|
||||
const SCAFFOLD_DIRS = ["brain/journal", "ingest/inbox", "ingest/published"];
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function indexSeed(): string {
|
||||
return `# Brain — Index (MOC)
|
||||
|
||||
> Map of Content — one screen pointing at every tributary, with a freshness flag.
|
||||
> Generated by \`brain init\`; safe to hand-edit (a re-run never clobbers it).
|
||||
|
||||
| Tributary | What it holds | Freshness |
|
||||
|-----------|---------------|-----------|
|
||||
| voice-samples | writing style | — |
|
||||
| specifics-bank | lived raw material | — |
|
||||
| trends | external signal | — |
|
||||
| analytics | performance | — |
|
||||
| ingest | inbox + published (the gold signal) | — |
|
||||
|
||||
- [profile.md](profile.md) — semantic, two-layer (static + dynamic) profile
|
||||
- [operations.md](operations.md) — plans · ideas · the "who I am now" anchor
|
||||
- journal/ — episodic, append-only session log (the consolidation loop's source)
|
||||
`;
|
||||
}
|
||||
|
||||
function operationsSeed(): string {
|
||||
return `# Operations
|
||||
|
||||
## Who I am now (anchor)
|
||||
|
||||
<!-- Frozen-past-self guard: a periodic, user-authored "where I'm headed now"
|
||||
statement that deprecates older inferences. Update it when your direction shifts. -->
|
||||
|
||||
## Plans
|
||||
|
||||
## Ideas
|
||||
`;
|
||||
}
|
||||
|
||||
function profileSeed(): string {
|
||||
const templateText = readFileSync(TEMPLATE_PATH, "utf8");
|
||||
const instancePath = dataRoot(INSTANCE_SUB);
|
||||
const instanceText = existsSync(instancePath) ? readFileSync(instancePath, "utf8") : undefined;
|
||||
return serializeProfile(foldUserProfile({ templateText, instanceText, today: today() }));
|
||||
}
|
||||
|
||||
export interface InitResult {
|
||||
/** Data-relative paths created this run. */
|
||||
created: string[];
|
||||
/** Data-relative paths that already existed and were left untouched. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the brain scaffold idempotently. Returns the created/skipped split.
|
||||
* File content seeds are computed lazily, so an already-present file costs no
|
||||
* template read or fold.
|
||||
*/
|
||||
export function initBrain(): InitResult {
|
||||
const created: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const sub of SCAFFOLD_DIRS) {
|
||||
const abs = dataRoot(sub);
|
||||
if (existsSync(abs)) {
|
||||
skipped.push(sub);
|
||||
} else {
|
||||
mkdirSync(abs, { recursive: true });
|
||||
created.push(sub);
|
||||
}
|
||||
}
|
||||
|
||||
const files: Array<{ sub: string; seed: () => string }> = [
|
||||
{ sub: "brain/profile.md", seed: profileSeed },
|
||||
{ sub: "brain/index.md", seed: indexSeed },
|
||||
{ sub: "brain/operations.md", seed: operationsSeed },
|
||||
];
|
||||
|
||||
for (const { sub, seed } of files) {
|
||||
const abs = dataRoot(sub);
|
||||
if (existsSync(abs)) {
|
||||
skipped.push(sub);
|
||||
continue;
|
||||
}
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, seed(), "utf8");
|
||||
created.push(sub);
|
||||
}
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
55
scripts/brain/src/types.ts
Normal file
55
scripts/brain/src/types.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Shared types + constants for the second-brain foundation (SB-S0).
|
||||
*
|
||||
* The two invariants this module pins (architecture.md §"Invariants"):
|
||||
* - ONE provenance shape across the whole brain: `human | published | ai-draft`.
|
||||
* The model-collapse guard (profile/voice learn from `published` only) hangs on
|
||||
* this being the single source of truth, not reinvented per silo.
|
||||
* - ONE canonical entity-id + one fact record shape (the six fields below), so a
|
||||
* later slice can thread the id through tributaries (SB-S3) without re-deciding
|
||||
* the record schema.
|
||||
*/
|
||||
|
||||
/** The single provenance vocabulary for the whole brain. */
|
||||
export type Provenance = "human" | "published" | "ai-draft";
|
||||
|
||||
/** A fact is `active` until a later, superseding fact demotes it (SB-S2). */
|
||||
export type FactStatus = "active" | "superseded";
|
||||
|
||||
/** The accepted provenance values, as a runtime list (used by normalizeProvenance). */
|
||||
export const PROVENANCE_VALUES: readonly Provenance[] = ["human", "published", "ai-draft"] as const;
|
||||
|
||||
/** The accepted fact-status values, as a runtime list. */
|
||||
export const FACT_STATUS_VALUES: readonly FactStatus[] = ["active", "superseded"] as const;
|
||||
|
||||
/** The profile schema version — bumped only on a breaking grammar change. */
|
||||
export const SCHEMA_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
* One distilled fact in `brain/profile.md`. Every fact carries all six fields so
|
||||
* the consolidation loop (SB-S2) has evidence/provenance/temporal context to
|
||||
* promote, decay, and reconcile against.
|
||||
*/
|
||||
export interface ProfileFact {
|
||||
/** Canonical entity id (id.ts), keyed on a stable slug — stable across value edits. */
|
||||
id: string;
|
||||
/** Single-line value (no embedded newline — enforced by the line-grammar). */
|
||||
value: string;
|
||||
/** YYYY-MM-DD — first observation. */
|
||||
first_seen: string;
|
||||
/** YYYY-MM-DD — most recent observation. */
|
||||
last_seen: string;
|
||||
/** How many independent observations back this fact (threshold-promotion, SB-S2). */
|
||||
evidence_count: number;
|
||||
/** Where the fact came from — drives provenance-weighted learning. */
|
||||
provenance: Provenance;
|
||||
/** `active` or `superseded`. */
|
||||
status: FactStatus;
|
||||
}
|
||||
|
||||
/** The two-layer profile document: stable `static` facts + evolving `dynamic` facts. */
|
||||
export interface ProfileDoc {
|
||||
schemaVersion: typeof SCHEMA_VERSION;
|
||||
static: ProfileFact[];
|
||||
dynamic: ProfileFact[];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue