Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Opus 4.8 76d0bab1f4 docs(architecture): KEY_FILES entry for the Life Chronicle module (#2390)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:39:36 -07:00
Garry TanandClaude Opus 4.8 73beb871ae docs: file Life Chronicle follow-up TODOs (v0.42.56.0, #2390)
Eight deferred items from the CEO/eng review decisions (auto-emit default-flip
fast-follow, live eval arm + LongMemEval slice, passive diary consent,
interval-splitting, federation, place-as-entity, meta-ontology dashboard,
materialized daily pages), each with decision provenance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:37:59 -07:00
Garry TanandClaude Opus 4.8 5a265d6257 fix(chronicle): register #2390 surfaces with the five CI guard suites (#2390)
CI caught five guard tests that pin registration invariants my diff tripped:
- schema-bootstrap-coverage: the four v122 facts ontology columns join
  COLUMN_EXEMPTIONS (facts is migration-created; the partial indexes live
  inside v122; every reader filters dimension IS NOT NULL — same precedent
  as facts.claim_metric et al).
- no-valid-until-write (R8): both engines' mergeOntologyFact forward-
  supersession is a deliberate, documented valid_until write authority
  (engine-layer, dimension IS NOT NULL only; the contradiction probe still
  never mutates).
- doctor-categories: chronicle_projection_health registered under
  BRAIN_CHECK_NAMES (same class as child_table_orphans).
- checkTypeProliferation: the test is now threshold-relative (computes
  declared from the active pack, seeds declared+6) so base-pack growth
  can't silently move the fixed threshold again.
- schema-cli: gbrain-base page-type count 25 → 27 (event + diary), with
  assertions on both new types.

All 41 guard tests green; typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:36:56 -07:00
Garry TanandClaude Opus 4.8 102583a94f v0.42.56.0 feat(chronicle): Life Chronicle — temporal timeline + diary + bi-temporal ontology (#2390)
Bump VERSION + package.json to 0.42.56.0 and add the CHANGELOG entry for the
Life Chronicle feature (#2390, closes duplicate #2388).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:05:52 -07:00
Garry Tan 7ddf611e9e Merge remote-tracking branch 'origin/master' into garrytan/plan-eng-review
# Conflicts:
#	src/core/migrate.ts
2026-07-02 08:05:14 -07:00
Garry TanandClaude Opus 4.8 d1bd5f9d05 fix(chronicle): pre-landing review — conflict validity, parse-barrier date, remote conflict redaction (#2390)
Three bugs caught by the codex pre-landing review on the diff:
1. findOntologyConflicts ignored valid_until, so a normal forward supersession
   (founder→advisor from two sources) falsely reported as a live conflict. Now
   restricted to currently-open rows (valid_until IS NULL) in both engines.
2. The extractor parse barrier accepted any when-string >= 4 chars; a non-date
   value slipped past, wrote the event page, then threw on the projection's
   ::date cast (partial write). isValidProposal now requires a real parseable date.
3. The ontology_conflicts op had no remote diary redaction (ontology_get did);
   remote callers now get diary-sourced values filtered, and conflicts that lose
   their disagreement after redaction are dropped.

Three regression tests added; 29 chronicle tests + eval 6/6 green; typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:40:33 -07:00
Garry TanandClaude Opus 4.8 d63f605e26 feat(chronicle): feature eval — gbrain eval chronicle (PRIMARY proof) (#2390)
Life Chronicle Phase A.9, the North-Star proof. A deterministic, CI-safe eval
(brings its own in-memory PGLite; no LLM, no gateway) builds a synthetic month
corpus with a known gold chronology + a planted ontology supersession + a
planted conflict, then scores the chronicle layer on six gold tasks: day
reconstruction (intra-day order), last-seen exact date, ontology supersession,
--asof valid-time travel, contradiction surfacing, and source isolation.
`gbrain eval chronicle [--json]` exits 0 iff all pass — currently 6/6. The OFF
baseline (raw meeting pages) structurally can't order intra-day events or
time-travel ontology; the ON path does. (The live-LLM OFF-vs-ON agent arm +
LongMemEval temporal slice are a follow-up; this deterministic bar gates CI.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:28:37 -07:00
Garry TanandClaude Opus 4.8 41b94ae21e feat(chronicle): E1 temporal recall — chronicle-type boost on temporal queries (#2390)
Life Chronicle Phase A.4 (E1, ambient temporality). Rather than a separate RRF
arm (which needs chunk hydration + risks the fusion path), E1 is a bounded
post-fusion boost: applyChronicleTypeBoost lifts `event`/`diary` results on
temporal queries, wired INSIDE runPostFusionStages' `recency !== 'off'` branch
so it fires ONLY on temporal intent — non-temporal search is bit-for-bit
unchanged (proven by 110 passing search-path tests). Bounded ([1.0,1.25]) +
floor-gated like the other metadata stages; attribution via `chronicle_boost`.
3 unit tests. (Empirical precision/negative measurement lands with the eval.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:24:05 -07:00
Garry TanandClaude Opus 4.8 dca35a6bd0 feat(chronicle): doctor chronicle_projection_health probe (#2390)
Life Chronicle Phase B.13. An always-run doctor check (keyed off the
event_page_id schema, NOT a migration verify-hook) counts timeline
projections whose event page is soft-deleted — hidden at read time, surfaced
here as a cleanup backlog (`gbrain integrity auto`). Tolerant of pre-migration
brains. 1 detection test. (auto_chronicle / chronicle.tz flags already work
via getConfig defaults; their docs land with document-release at ship.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:21:30 -07:00
Garry TanandClaude Opus 4.8 8d0b779306 feat(chronicle): proactive advisor collector (#2390)
Life Chronicle Phase A.7. A brain-state advisor collector surfaces two
proactive signals in `gbrain advisor`: unresolved ontology conflicts (warn,
→ `gbrain ontology-contradictions`) and recent meetings with no timeline
coverage (info, → `gbrain chronicle-backfill`). Advisory-only (no dispatch_id);
runs over MCP too (not workspace-dependent); tolerant of pre-migration brains.
3 tests + advisor-op-gate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:20:45 -07:00
Garry TanandClaude Opus 4.8 d5127e0470 feat(chronicle): delight — on-this-day + narrative rendering (#2390)
Life Chronicle Phase A.6 (delight). Dual-engine getOnThisDay (events from the
same month-day in prior years; `gbrain on-this-day [--date]`) reusing the
chronicle JOIN shape (deleted-event hiding + source scope). A pure
renderTimelineNarrative turns timeline rows into prose; `gbrain day --narrative`
returns it alongside the events. 5 tests. (Coverage gap-detection ships with
the advisor collectors next.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:19:31 -07:00
Garry TanandClaude Opus 4.8 876240b8dc feat(chronicle): backfill op — sweep existing meetings into events (#2390)
Life Chronicle Phase A.8. `chronicle_backfill` (local-only admin op;
`gbrain chronicle-backfill [--since] [--limit] [--dry-run]`) lists existing
meeting/conversation/calendar pages (source-scoped via listPages), filters
through the chronicle eligibility predicate, and enqueues one chronicle_extract
job per eligible page so existing brains populate the timeline. --dry-run
counts only; per-page enqueue failures are surfaced in `errors`, never
swallowed. 2 op tests (dry-run count + enqueue).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:59:25 -07:00
Garry TanandClaude Opus 4.8 d8e29b672d feat(chronicle): agent-context loader — volunteer_chronicle (#2390)
Life Chronicle Phase B.12. `loadChronicleContext` hands an agent the recent
timeline (last N days) + the validity-resolved current ontology for the
entities in play, in one zero-LLM payload, so it orients before acting — the
exact gap behind fumbled chronology. Pure composition over getSince +
getOntology (no new SQL). Exposed as the `volunteer_chronicle` read op
(`gbrain orient [--days] [--entities a,b]`); diary-sourced ontology is redacted
for remote callers. 2 loader tests + op-registry green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:57:47 -07:00
Garry TanandClaude Opus 4.8 01611bb854 feat(chronicle): ontology ops — get/add/dimensions/contradictions (#2390)
Life Chronicle Phase B.11. Exposes the bi-temporal ontology over CLI + MCP
(contract-first, auto-generated): `gbrain ontology <entity> [--asof]`,
`gbrain ontology-add <entity> <dim> <value>`, `gbrain ontology-dimensions`
(meta-ontology rollup), `gbrain ontology-contradictions`. All reads route
through sourceScopeOpts. Privacy: ontology_get redacts diary-sourced
observations (source under life/diary/) for untrusted (remote) callers. 3 op
tests (incl. the remote-redaction path); 47 op-registry/tool-def/description
tests stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:56:24 -07:00
Garry TanandClaude Opus 4.8 5e563f5cc9 feat(chronicle): bi-temporal per-entity ontology on the facts table (#2390)
Life Chronicle Phase B.10 — the feature's differentiator. Rather than a
parallel store, the open-world per-entity ontology EXTENDS the existing
`facts` table (eng-review G1): migration v121 adds dimension/value/value_hash
/dim_status columns + a deterministic partial-UNIQUE dedup key + an asof read
index. facts already supplies bi-temporal validity, supersession
(superseded_by), visibility (remote redaction), confidence, provenance, and
embedding — all inherited.

Dual-engine methods: mergeOntologyFact (deterministic value_hash dedup →
idempotent retry; same value corroborates; a different value forward-closes
the prior row's valid_until + superseded_by; a BACKDATED conflicting value is
recorded WITHOUT rewriting, surfaced by findOntologyConflicts), getOntology
with `--asof` valid-time travel (expired_at + status + validity-window in the
predicate so quarantined/superseded never leak), discoverOntologyDimensions,
findOntologyConflicts. Novel/LLM-proposed dimensions quarantine; a seed lexicon
canonicalizes name drift (job_role → role). 9 unit tests cover the full
lifecycle; typecheck pins both engines to the interface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:54:25 -07:00
Garry TanandClaude Opus 4.8 9434069e9b feat(chronicle): quick-capture for diary + manual events (#2390)
Life Chronicle Phase A.5. `gbrain capture` now routes the default slug by
type (diary → life/diary/, event → life/events/, else inbox/) and accepts
--who/--what/--where/--kind/--depth to assemble the `event:` frontmatter
block for `--type event`. User-declared event keys win per-key over the
flags. Goes through the existing put_page → write-through → embed path. 6
new unit tests; existing capture tests stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:00:46 -07:00
Garry TanandClaude Opus 4.8 1d04a23c5b feat(chronicle): auto-emit extractor — backstop + chronicle_extract job (#2390)
Life Chronicle Phase A.3. A put_page backstop (gated on status==='imported',
the auto-link/timeline trust gate, and the default-OFF auto_chronicle flag;
diary + event pages never eligible) enqueues a chronicle_extract minion job.
The job runs the extractor off the write path: deterministic when/who, an
injectable LLM judge (default = chat gateway; no-op when no gateway), an
all-or-nothing parse barrier (a malformed proposal writes NOTHING), then
content-addressed event pages + a timeline projection via the new dual-engine
upsertEventProjection (idempotent — re-run yields one event + one projection).

New: src/core/chronicle/{eligibility,config,extract-events,backstop}.ts,
engine.upsertEventProjection (both engines), the jobs.ts handler + a 10-min
timeout. 14 unit tests (eligibility, idempotency, parse barrier, backstop
gating + enqueue) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:58:24 -07:00
Garry TanandClaude Opus 4.8 09536fec51 feat(chronicle): event_page_id timeline projection + day/since/last-seen reads (#2390)
Life Chronicle Phase A.2. Adds a nullable event_page_id FK to
timeline_entries (migration v120; mirrored in schema.sql, pglite-schema,
schema-embedded) so a type:event page projects ONE date-index row keyed
to its depth page; a partial UNIQUE(event_page_id, date) makes
re-extraction with a changed summary an update, not a duplicate.

Dual-engine getTimelineForDate / getSince / getLastSeen filter the depth
page on deleted_at, hide soft-deleted event projections at READ time
(not just doctor), order by event effective_date for intra-day sequence,
and honor source scope (sourceIds[] > sourceId). Ops surface as
`gbrain day <date> [--week]`, `gbrain since <date> [--kind]`,
`gbrain last-seen <entity>`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:54:06 -07:00
Garry TanandClaude Opus 4.8 14778994ee feat(chronicle): register event + diary page types (#2390)
Life Chronicle Phase A.1. Adds `event` (timeline atom) and `diary`
(first-person interiority) as temporal-primitive page types under
life/events/ and life/diary/, extractable:false — registered in
ALL_PAGE_TYPES, both base schema packs, and the inferType prefix table,
with parity fixtures. Also lands the chronicle read result types
(ChronicleTimelineRow/ChronicleTimelineOpts/LastSeenResult) consumed by
Phase A.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:54:02 -07:00
53 changed files with 2758 additions and 9 deletions
+17
View File
@@ -2,6 +2,23 @@
All notable changes to GBrain will be documented in this file.
## [0.42.56.0] - 2026-07-02
**Life Chronicle: gbrain gains a temporal spine. Meetings and transcripts project into a queryable timeline, entities carry a bi-temporal ontology (sourced, confidence-weighted properties that supersede over time), and a low-friction diary captures interiority — so an agent can reconstruct "what happened the week of X", answer "when did I last interact with Y", and see how an entity's role or stance changed, instead of re-deriving chronology from scratch every session.** Built entirely on existing primitives (pages, the `facts` table, `timeline_entries`) — no new datastore. Auto-emission is off by default; opt in per below.
### Added
- **Timeline events + reads.** Meetings/transcripts auto-emit `type:event` atoms (when·where·who·what) that project into a date index and backlink to the depth page. Query them with `gbrain day <date> [--week] [--narrative]`, `gbrain since <date> [--kind]`, `gbrain last-seen <entity>`, and `gbrain on-this-day`. Intra-day ordering, read-time hiding of deleted events, and source isolation throughout.
- **Bi-temporal per-entity ontology.** An open-world, sourced, confidence-weighted property bag rides the existing `facts` table (new `dimension`/`value` columns). A new value supersedes the prior across a validity window, so `gbrain ontology <entity> [--asof <date>]` can time-travel; genuine two-source disagreement surfaces via `gbrain ontology-contradictions`; `gbrain ontology-dimensions` shows what the brain tracks about entities. Novel LLM-proposed dimensions quarantine until confirmed.
- **Diary capture + agent orientation.** `gbrain capture --type diary` (and `--type event` with `--who/--what/--where/--kind`) for low-friction entries; `gbrain orient` hands an agent the recent timeline plus resolved-entity ontology in one zero-LLM payload. `gbrain chronicle-backfill` sweeps existing meetings into the timeline.
- **Ambient temporal recall + proactive surfacing.** Temporal queries lift chronicle pages in search; `gbrain advisor` flags unresolved ontology conflicts and recent meetings missing from the timeline. A deterministic `gbrain eval chronicle` gates the feature (day-order, last-seen, supersession, contradiction, source isolation).
- **Migrations v121 (event-projection column) + v122 (facts ontology columns).** Additive; legacy rows unchanged.
### Security
- **Interiority stays local.** Diary content and diary-sourced ontology are redacted from untrusted (remote/MCP) readers across reads, search, advisor, and orientation. Auto-emission runs only for trusted local writes.
### To take advantage of v0.42.56.0
`gbrain upgrade`, then `gbrain apply-migrations --yes` (or any command that opens the brain) to pick up v121/v122. Auto-emission is OFF by default: turn it on with `gbrain config set auto_chronicle true`, then `gbrain chronicle-backfill` to populate the timeline from existing meetings. Manual capture (`gbrain capture --type event/diary`) and every read surface work immediately. Closes #2390 (duplicate #2388).
## [0.42.55.0] - 2026-06-24
**A security-hardening pass: routing dotfiles, the skills directory, page slugs, and large-file transcription are confined against multi-user-host and untrusted-input edge cases; dynamic OAuth client registration defaults to a consent-bearing grant; and a schema-lint migration brings existing brains to the fresh-install posture.** Several of these close community-reported gaps. Fresh installs were already covered; existing brains are brought to the same bar automatically on upgrade. If `gbrain doctor` flags anything afterward, its message names the object and the exact fix.
+45
View File
@@ -1,5 +1,50 @@
# TODOS
## Life Chronicle follow-ups (filed v0.42.56.0, #2390)
Deferred from the Life Chronicle wave (CEO Scope-Expansion + eng review CLEARED,
3 codex rounds absorbed, PR #2533). Every item was an explicit review decision,
not an oversight; each names its decision provenance.
- [ ] **P1 — Eval-gated auto-emit default-flip (D5.5 fast-follow).** Auto-emission
ships OFF (`auto_chronicle=false`) per spend/consent posture. The headline
fast-follow: run `gbrain eval chronicle` + a live-LLM OFF-vs-ON agent arm on a
real brain, and if the lift holds, flip the default ON in the next minor with
an upgrade notice. Where: `src/core/chronicle/config.ts`, upgrade banner in
`src/commands/upgrade.ts`.
- [ ] **P2 — Live-LLM OFF-vs-ON eval arm + LongMemEval temporal slice.** The
shipped `gbrain eval chronicle` is the deterministic CI bar (6 gold tasks).
The full North-Star proof adds (a) a live agent reconstructing a day with the
chronicle ops ON vs OFF, and (b) the LongMemEval `question_type:
temporal-reasoning` slice as secondary corroboration — verify the adapter can
filter by question type first. Where: `src/eval/chronicle/harness.ts`,
`src/commands/eval-longmemeval.ts`.
- [ ] **P2 — Passive diary capture + consent model (D3.5/E5).** Active-only in v1
by explicit decision (highest consent-risk surface). Passive detection of
first-person interiority in transcripts requires a dedicated consent design:
an explicit `chronicle.diary.passive` opt-in, a consent prompt, and
provenance-aware redaction (the facts `visibility` lane is already in place).
- [ ] **P2 — Ontology interval-splitting for backdated conflicts (G4).** A
backdated observation whose validity window overlaps an existing row is
flagged (not rewritten) in v1. Real interval algebra (split the prior window
around the backdated fact) is deliberate follow-up scope; the conflict lane
(`findOntologyConflicts`) is the holding surface. Where: both engines'
`mergeOntologyFact`.
- [ ] **P3 — Cross-brain federated timeline (D3.6/E6).** v1 holds source
isolation (scoped-default, `--all-sources` opt-in within the host brain).
Unifying across mounted team brains is its own epic with an access-policy
surface.
- [ ] **P3 — Place-as-entity (`gbrain where <venue>`).** `event.where` is
captured as free text; resolving venues to entity pages + geo-adjacency
queries is a follow-up.
- [ ] **P3 — Richer meta-ontology dashboard.** `gbrain ontology-dimensions` is
the v1 surface; a full dashboard (per-dimension drill-down, quarantine review
queue for novel dimensions) is deferred until usage shows demand.
- [ ] **P3 — Materialized daily timeline pages / emotional-arc view.** The
query-time aggregator won D5.6; embeddable `life/timeline/YYYY/MM/DD.md`
narrative pages (a single `materialize_timeline` cycle phase) revisit after
the eval shows `reflect`-style recall needs them.
## reliability fix-wave follow-ups (filed v0.42.52.0)
Deferred from the autopilot/supervisor + sync/status/minion reliability wave
+1 -1
View File
@@ -1 +1 @@
0.42.55.0
0.42.56.0
+1
View File
@@ -151,6 +151,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `test/helpers/cli-pty-runner.ts` — generic real-PTY harness (~470 lines) using pure `Bun.spawn({terminal:})` (Bun 1.3.10+; engines.bun pin in package.json). Generic primitives only, no plan-mode orchestrators. Exports `launchPty`, `resolveBinary`, `stripAnsi`, `parseNumberedOptions`, `optionsSignature`, `isNumberedOptionListVisible`, `isTrustDialogVisible`. Self-tests in `test/cli-pty-runner.test.ts` (24 cases).
- `src/core/skillpack/{init-brain-pack,brain-pack-advisory,brain-pack-lint,brain-resident-locate,nag-state}.ts` (#2180) — brain-resident skillpacks. `manifest-v1.ts` gains optional `brain_resident` + `schema_pack` (additive). `runInitBrainPack` scaffolds a pack beside brain content (`brain_resident:true`, exact `gbrain_min_version`, 5-section machine-parseable README); `applyWritePlan` is factored out of `init-scaffold.ts` for the shared refuse-overwrite loop. `brain-pack-lint.lintBrainPackTools` validates each skill's `tools:` against the serving op set (E6 version-skew). Topology A: `src/commands/sources.ts` `runAdd` prints `brain-pack-advisory` to stderr after `opsAddSource`, fail-open; `nag-state.ts` (`~/.gbrain/skillpack-nag-state.json`) keys declines by `(source-repo brain_id, source_id, pack_name)` with pure `decideNagAction` (first/reminder/version-bump/ceiling) — declines count ONLY on CLI-interactive displays. Topology B: `brain-resident-locate.loadResidentPacksForServer` (source-scoped via `sourceScopeOpts`) backs the `list_brain_skillpack` op; `getResidentSkillDetail` backs `get_skill` `source_id`; `scaffold_spec` is the git source, never a server FS path. Tests: `test/skillpack-{init-brain-pack,nag-state,brain-resident-locate}.test.ts` + the brain-resident cases in `test/skillpack-manifest-v1.test.ts`.
- `src/core/advisor/{types,run,render,recommended-set,history,apply,collect-*}.ts` + `src/commands/advisor.ts` (#2180) — `gbrain advisor`: read-only ranked actions from brain state. `run.runAdvisor` executes 8 hardcoded collectors (version [cache-only], migration, schema-pack, stalled-jobs [absent-table tolerant], usage-shape, setup-smells, uninstalled-brain-pack, uninstalled-bundled), each in its own try/catch; `rankFindings` orders critical>warn>info then collector order, caps the info tail, and drops `workspace_dependent` findings when `remote` (A1). `render.ts` is the shared `=`-bar renderer used by the advisor AND `post-install-advisory.ts` (generalized to a single current-state `recommended-set.RECOMMENDED`, `install``scaffold`). `history.ts` appends bounded `~/.gbrain/advisor-history.jsonl` (no DB migration) for since-last-run deltas; local-only. `apply.resolveApplyTarget` is the allowlist+injection guard for `commands/advisor.ts --apply <id>` (structured argv, never a shell; local-only). The `advisor` op (`operations.ts`) is read-scoped, NOT localOnly, gated by `mcp.publish_advisor` (config.ts; default off) and strictly read-only on remote. CLI wired in `cli.ts` (`CLI_ONLY` + dispatch). Bundled skill `skills/gbrain-advisor/` + weekly cron recipe. Tests: `test/advisor-{core,apply,op-gate,ranking-eval}.test.ts`.
- `src/core/chronicle/{eligibility,config,backstop,extract-events,ontology,narrative}.ts` + `src/eval/chronicle/harness.ts` + `src/commands/eval-chronicle.ts` (#2390) — Life Chronicle: the temporal spine. `eligibility.isChronicleEligible` decides which pages auto-emit events (meeting/conversation/calendar-event + directory rescue; diary and event pages NEVER eligible — privacy + anti-loop). `backstop.runChronicleBackstop` is the put_page hook body (fires ONLY on `status==='imported'` + the auto-link trust gate + the default-OFF `auto_chronicle` flag; enqueues a `chronicle_extract` minion job — LLM never runs on the write path). `extract-events.runChronicleExtract` is the job body: deterministic when/who, injectable judge (default = chat gateway), an ALL-or-nothing parse barrier (`isValidProposal` requires a real parseable date — a malformed batch writes NOTHING), then content-addressed `life/events/` pages + a `timeline_entries` projection via `engine.upsertEventProjection` (dedup `(event_page_id, date)`; idempotent re-runs). `ontology.ts` carries the deterministic pieces of the bi-temporal per-entity ontology that RIDES THE `facts` TABLE (migration v122 adds `dimension`/`value`/`value_hash`/`dim_status`): `valueHash` (normalized, timestamp-free → crash-retry idempotent), `normalizeDimension` (seed alias lexicon), `isNovelDimension` (novel → quarantined, excluded from resolution/context until confirmed). The engine methods (`mergeOntologyFact` — corroborate on same value, forward-supersede via `valid_until`+`superseded_by` on a new value, backdated conflicts kept + flagged; `getOntology` with `--asof` valid-time travel; `discoverOntologyDimensions`; `findOntologyConflicts` — currently-open rows only) live in BOTH engines; both engines are on the R8 `valid_until` write allow-list (engine-layer, `dimension IS NOT NULL` rows only). Chronicle reads (`getTimelineForDate`/`getSince`/`getLastSeen`/`getOnThisDay`) JOIN the depth page (`deleted_at IS NULL`), hide soft-deleted event projections at READ time, and order by event `effective_date` for intra-day sequence. Ops: `chronicle_day`/`chronicle_since`/`chronicle_last_seen`/`chronicle_on_this_day`/`ontology_*`/`volunteer_chronicle` (agent orientation via `src/core/context/chronicle-context.ts`)/`chronicle_backfill` (admin, localOnly). Diary privacy: diary-sourced ontology + conflict values redacted for `ctx.remote !== false` callers. Search: `applyChronicleTypeBoost` in `search/hybrid.ts` (bounded [1.0,1.25], fires only inside the `recency !== 'off'` post-fusion branch → non-temporal search bit-for-bit unchanged). Advisor collector `collect-chronicle.ts` (conflicts + coverage gap); doctor `chronicle_projection_health` (BRAIN category). Eval: `gbrain eval chronicle` — deterministic, own in-memory PGLite, 6 gold tasks (day order, last-seen, supersession, asof, conflict, isolation), exit 0 iff 6/6. Tests: `test/chronicle-*.test.ts`, `test/eval-chronicle.test.ts`.
- `src/core/skill-manifest.ts` — parser for `skill-manifest.json` records. Used by skillpack installer to detect drift between the shipped bundle and the user's local edits, so updates merge instead of overwriting.
- `src/commands/routing-eval.ts` + `src/core/routing-eval.ts``gbrain routing-eval` catches user phrasings that route to the wrong skill. Reads `skills/<name>/routing-eval.jsonl` fixtures (`{intent, expected_skill, ambiguous_with?}`). Structural layer runs in `check-resolvable` by default (zero API cost). `--llm` is a placeholder for a future LLM tie-break layer; today it emits a stderr notice and runs structural only. Uses `autoDetectSkillsDirReadOnly` and the same multi-file resolver merge as `check-resolvable`, so on OpenClaw layouts (`skills/RESOLVER.md` + `../AGENTS.md`) all three commands see the same trigger index. RESOLVER.md rows carry the full frontmatter `triggers:` arrays so the structural matcher sees realistic phrasings; ambiguous-fixture annotations cover deliberate skill chains like `enrich → article-enrichment`.
- `src/core/filing-audit.ts` + `skills/_brain-filing-rules.json` — Check 6 of `check-resolvable`. Parses `writes_pages:` / `writes_to:` frontmatter on skills and audits their filing claims against the filing-rules JSON (error severity). Internal `parseFrontmatter` is a thin wrapper over the shared `src/core/skill-frontmatter.ts` parser so both filing-audit and skill-brain-first read the same shape (`tools?`, `triggers?`, `brain_first?: 'exempt'`, typed `brain_first_typo`) from one source of truth.
+1 -1
View File
@@ -144,5 +144,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.42.55.0"
"version": "0.42.56.0"
}
+8
View File
@@ -1396,6 +1396,14 @@ async function handleCliOnly(command: string, args: string[]) {
return;
}
// v0.42.x (#2390): `gbrain eval chronicle` is deterministic — brings its own
// in-memory PGLite, no DB/gateway. CI fixture gate runs anywhere.
if (command === 'eval' && args[0] === 'chronicle') {
const { runEvalChronicle } = await import('./commands/eval-chronicle.ts');
setCliExitVerdict(await runEvalChronicle(args.slice(1)));
return;
}
// v0.41.13.0: `gbrain eval conversation-parser` is pure-function
// (parses fixture JSONL, runs parseConversation, scores results).
// No DB access; bypass connectEngine entirely so the CI fixture
+47 -3
View File
@@ -49,6 +49,12 @@ interface RunOpts {
source?: string;
quiet?: boolean;
json?: boolean;
// v0.42.x — Life Chronicle (#2390): manual `--type event` frontmatter sugar.
who?: string; // comma-separated entity slugs
what?: string;
where?: string;
kind?: string;
depth?: string; // the depth page this event backlinks
}
function parseArgs(args: string[]): RunOpts | { help: true; positional: string | undefined } {
@@ -80,6 +86,12 @@ function parseArgs(args: string[]): RunOpts | { help: true; positional: string |
if (v) opts.source = v;
continue;
}
// v0.42.x — Life Chronicle event sugar.
if (a === '--who') { const v = args[++i]; if (v) opts.who = v; continue; }
if (a === '--what') { const v = args[++i]; if (v) opts.what = v; continue; }
if (a === '--where') { const v = args[++i]; if (v) opts.where = v; continue; }
if (a === '--kind') { const v = args[++i]; if (v) opts.kind = v; continue; }
if (a === '--depth') { const v = args[++i]; if (v) opts.depth = v; continue; }
if (a.startsWith('--')) continue; // unknown flag, ignore
positional.push(a);
}
@@ -132,12 +144,21 @@ Examples:
JOB=$(gbrain capture "..." --quiet)
`;
function defaultSlug(content: string, now: Date = new Date()): string {
// v0.42.x — Life Chronicle (#2390): route the default slug prefix by type so
// `gbrain capture --type diary` lands under life/diary/ and `--type event`
// under life/events/ (matching the chronicle path-prefix inference). Everything
// else keeps the inbox/ default.
function slugPrefixForType(type?: string): string {
if (type === 'diary') return 'life/diary';
if (type === 'event') return 'life/events';
return 'inbox';
}
function defaultSlug(content: string, now: Date = new Date(), type?: string): string {
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0');
const hashPrefix = computeContentHash(content).slice(0, 8);
return `inbox/${y}-${m}-${d}-${hashPrefix}`;
return `${slugPrefixForType(type)}/${y}-${m}-${d}-${hashPrefix}`;
}
/**
@@ -245,6 +266,22 @@ function deriveTitle(rawBody: string): string {
* stamps a fresh frontmatter block, and if the body doesn't already look
* like markdown (no `#` heading), wraps it under a `# {title}` heading.
*/
// v0.42.x — Life Chronicle (#2390): assemble the `event:` frontmatter block
// from the --who/--what/--where/--kind/--depth flags (only for --type event).
// Returns undefined when no event flags are set so non-event captures are
// untouched.
function buildEventBlock(opts: RunOpts): Record<string, unknown> | undefined {
if (opts.type !== 'event') return undefined;
const who = opts.who ? opts.who.split(',').map((s) => s.trim()).filter(Boolean) : [];
const block: Record<string, unknown> = {};
if (opts.what) block.what = opts.what;
if (who.length) block.who = who;
if (opts.where) block.where = opts.where;
if (opts.kind) block.kind = opts.kind;
if (opts.depth) block.depth = opts.depth;
return Object.keys(block).length ? block : undefined;
}
export function mergeCaptureFrontmatter(rawBody: string, opts: RunOpts): string {
const nowIso = new Date().toISOString();
// Detect frontmatter: leading `---\n` or `---\r\n`, tolerating leading BOM/whitespace.
@@ -263,6 +300,8 @@ export function mergeCaptureFrontmatter(rawBody: string, opts: RunOpts): string
captured_via: opts.source ?? 'capture-cli',
captured_at: nowIso,
};
const ev = buildEventBlock(opts);
if (ev) fm.event = ev;
const looksMarkdown = /^#{1,6}\s/.test(rawBody.trimStart());
const body = looksMarkdown ? rawBody : `# ${title}\n\n${rawBody}`;
return matter.stringify(body, fm);
@@ -290,6 +329,11 @@ export function mergeCaptureFrontmatter(rawBody: string, opts: RunOpts): string
captured_via: userFm.captured_via ?? opts.source ?? 'capture-cli',
captured_at: userFm.captured_at ?? nowIso,
};
// v0.42.x — merge the event block (user-declared keys win per-key).
const ev = buildEventBlock(opts);
if (ev || userFm.event) {
merged.event = { ...(ev ?? {}), ...((userFm.event as Record<string, unknown>) ?? {}) };
}
return matter.stringify(parsed.content, merged);
}
@@ -439,7 +483,7 @@ export async function runCapture(engine: BrainEngine | null, args: string[]): Pr
// The daemon's 24h LRU dedup keys on this hash; identical captures must
// produce identical hashes. The DB content_hash (importFromContent at
// src/core/import-file.ts) gets the same treatment in Phase 3d.
const slug = parsed.slug ?? defaultSlug(normalizedBody);
const slug = parsed.slug ?? defaultSlug(normalizedBody, new Date(), parsed.type);
const fullContent = buildContent(rawBody, parsed);
const capturedAt = new Date().toISOString();
const contentHash = computeContentHash(normalizedBody);
+27
View File
@@ -538,6 +538,33 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
checks.push({ name: 'timeline_dedup_index', status: 'warn', message: 'Could not check idx_timeline_dedup shape' });
}
// v0.42.x — Life Chronicle (#2390): orphaned event projections. Reads already
// hide projections whose event page is soft-deleted (read-time correctness);
// this always-run probe surfaces the cleanup backlog. Keyed off the real
// schema (event_page_id), NOT a migration verify-hook, per
// migration-verify-hook-never-runs-on-stamped-brains.
try {
const orphans = await engine.executeRaw<{ n: number }>(
`SELECT count(*)::int AS n FROM timeline_entries te
JOIN pages ep ON ep.id = te.event_page_id
WHERE te.event_page_id IS NOT NULL AND ep.deleted_at IS NOT NULL`,
);
const n = Number(orphans[0]?.n ?? 0);
checks.push(
n === 0
? { name: 'chronicle_projection_health', status: 'ok', message: 'No orphaned event projections' }
: {
name: 'chronicle_projection_health',
status: 'warn',
message:
`${n} timeline projection(s) point to soft-deleted event pages ` +
'(hidden at read time; clean up with `gbrain integrity auto`).',
},
);
} catch {
checks.push({ name: 'chronicle_projection_health', status: 'ok', message: 'no event projections yet' });
}
// 3. Brain score
try {
const health = await engine.getHealth();
+44
View File
@@ -0,0 +1,44 @@
// v0.42.x — Life Chronicle (#2390) `gbrain eval chronicle` (Phase A.9).
// Deterministic, brings its own in-memory PGLite (no DB, no gateway), so the
// CI fixture gate runs anywhere. Exit 0 only on a perfect score.
import { PGLiteEngine } from '../core/pglite-engine.ts';
import { runChronicleEval } from '../eval/chronicle/harness.ts';
const HELP = `Usage: gbrain eval chronicle [--json]
Deterministic Life Chronicle (#2390) feature eval. Builds a synthetic month
corpus with a known gold chronology + a planted ontology supersession + a
planted conflict, then scores the chronicle layer on: day reconstruction
(intra-day order), last-seen exact date, ontology supersession + --asof
time-travel, contradiction surfacing, and source isolation.
Exit code 0 iff every task passes.
`;
export async function runEvalChronicle(args: string[]): Promise<number> {
if (args.includes('--help') || args.includes('-h')) {
process.stdout.write(HELP);
return 0;
}
const json = args.includes('--json');
const engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
try {
const result = await runChronicleEval(engine);
if (json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
process.stderr.write(
`[eval chronicle] ${result.passed}/${result.total} tasks passed ` +
`(score ${(result.score * 100).toFixed(0)}%)\n`,
);
for (const t of result.tasks) {
process.stderr.write(` ${t.passed ? 'PASS' : 'FAIL'} ${t.id}${t.detail}\n`);
}
}
return result.score === 1 ? 0 : 1;
} finally {
await engine.disconnect();
}
}
+19
View File
@@ -1504,6 +1504,25 @@ export async function registerBuiltinHandlers(
return result;
});
// v0.42.x (#2390) — Life Chronicle event extraction. NOT protected (bounded
// LLM spend per page; no shell). Enqueued by the put_page chronicle backstop
// and by `gbrain chronicle backfill`. Idempotent (content-addressed event
// slugs + projection upsert), so a retry re-runs to the same state.
worker.register('chronicle_extract', async (job) => {
const slug = typeof job.data.slug === 'string' ? job.data.slug : undefined;
if (!slug) throw new Error('chronicle_extract job requires data.slug');
const sourceId = typeof job.data.sourceId === 'string' ? job.data.sourceId : undefined;
const { runChronicleExtract } = await import('../core/chronicle/extract-events.ts');
const { chronicleTz } = await import('../core/chronicle/config.ts');
const tz = await chronicleTz(engine);
return await runChronicleExtract(engine, {
slug,
sourceId,
tz,
signal: (job as { signal?: AbortSignal }).signal,
});
});
// v0.41.39 (#1700) — enrich. NOT in PROTECTED_JOB_NAMES: per-call cost is
// bounded by data.maxCostUsd (default DEFAULT_MAX_COST_USD) and the handler
// re-creates the BudgetTracker in its own process. BudgetExhausted is caught
+60
View File
@@ -0,0 +1,60 @@
// v0.42.x — Life Chronicle (#2390) advisor collector (Phase A.7).
// Brain-state (not workspace-dependent), so it runs over MCP too. Two signals:
// - unresolved ontology conflicts (genuine disagreement, not supersession)
// - recent meetings not yet swept into the timeline (coverage gap)
// Advisory display only (no dispatch_id) — the user runs the shown command.
import type { AdvisorCollector, AdvisorContext, AdvisorFinding } from './types.ts';
export const collectChronicle: AdvisorCollector = {
id: 'chronicle',
collect: async (ctx: AdvisorContext): Promise<AdvisorFinding[]> => {
const findings: AdvisorFinding[] = [];
// 1. Unresolved ontology conflicts.
try {
const conflicts = await ctx.engine.findOntologyConflicts({ minConfidence: 0.5 });
if (conflicts.length > 0) {
findings.push({
id: 'ontology_conflicts',
severity: 'warn',
title: `${conflicts.length} entity dimension(s) have conflicting current values`,
detail: conflicts.slice(0, 5).map((c) => `${c.entity_slug}.${c.dimension}`).join(', '),
fix: { command_argv: ['gbrain', 'ontology-contradictions'] },
collector: 'chronicle',
ask_user: false,
});
}
} catch {
// Ontology columns may be absent on a brain that hasn't migrated; ignore.
}
// 2. Recent meetings not yet in the timeline (coverage gap).
try {
const rows = await ctx.engine.executeRaw<{ n: number }>(
`SELECT count(*)::int AS n FROM pages p
WHERE p.type IN ('meeting','conversation','calendar-event') AND p.deleted_at IS NULL
AND p.updated_at > now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM timeline_entries te
WHERE te.page_id = p.id AND te.event_page_id IS NOT NULL
)`,
);
const gap = Number(rows[0]?.n ?? 0);
if (gap > 0) {
findings.push({
id: 'chronicle_coverage_gap',
severity: 'info',
title: `${gap} recent meeting(s) aren't in the timeline yet`,
detail: 'Sweep them into events with `gbrain chronicle-backfill`, or enable auto_chronicle.',
fix: { command_argv: ['gbrain', 'chronicle-backfill'] },
collector: 'chronicle',
ask_user: true,
});
}
} catch {
// timeline_entries.event_page_id may be absent pre-migration; ignore.
}
return findings;
},
};
+2
View File
@@ -16,6 +16,7 @@ import { collectUsageShape } from './collect-usage-shape.ts';
import { collectSetupSmells } from './collect-setup-smells.ts';
import { collectUninstalledBrainPack } from './collect-uninstalled-brain-pack.ts';
import { collectUninstalledBundled } from './collect-uninstalled-bundled.ts';
import { collectChronicle } from './collect-chronicle.ts';
/** Deterministic v1 collector order (also the secondary sort key for ranking). */
export const COLLECTORS: AdvisorCollector[] = [
@@ -27,6 +28,7 @@ export const COLLECTORS: AdvisorCollector[] = [
collectSetupSmells,
collectUninstalledBrainPack,
collectUninstalledBundled,
collectChronicle,
];
const SEV_RANK: Record<AdvisorSeverity, number> = { critical: 0, warn: 1, info: 2 };
+38
View File
@@ -0,0 +1,38 @@
// v0.42.x — Life Chronicle (#2390) put_page chronicle backstop (Phase A.3).
// Deterministic + fire-and-forget: it ONLY checks eligibility + the auto_chronicle
// flag and enqueues a `chronicle_extract` minion job. The LLM judgment runs off
// the write path in the job handler. Mirrors facts/backstop.ts (queue mode).
import type { BrainEngine } from '../engine.ts';
import { isChronicleEligible } from './eligibility.ts';
import { isAutoChronicleEnabled } from './config.ts';
export interface ChronicleBackstopResult {
enqueued: boolean;
skipped?: string;
}
/**
* Eligibility + enqueue. The CALLER is responsible for the trust gate and for
* only invoking on a real import (status==='imported') see the put_page hook,
* which skips on remote/untrusted and on skipped/unchanged rewrites so an edit
* that changes nothing never re-enqueues.
*/
export async function runChronicleBackstop(
page: { slug: string; type: string; compiled_truth?: string; frontmatter?: Record<string, unknown> },
ctx: { engine: BrainEngine; sourceId: string },
): Promise<ChronicleBackstopResult> {
const dreamGenerated = page.frontmatter?.dream_generated === true;
const elig = isChronicleEligible({
type: page.type, slug: page.slug, body: page.compiled_truth, dreamGenerated,
});
if (!elig.ok) return { enqueued: false, skipped: elig.reason };
if (!(await isAutoChronicleEnabled(ctx.engine))) return { enqueued: false, skipped: 'auto_chronicle_off' };
try {
const { MinionQueue } = await import('../minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
await queue.add('chronicle_extract', { slug: page.slug, sourceId: ctx.sourceId });
return { enqueued: true };
} catch {
return { enqueued: false, skipped: 'enqueue_error' };
}
}
+19
View File
@@ -0,0 +1,19 @@
// v0.42.x — Life Chronicle (#2390) config flags.
import type { BrainEngine } from '../engine.ts';
/**
* Auto-emit is OFF by default (plan D5.5: spend posture extraction spends LLM
* tokens per eligible write). Enable with `gbrain config set auto_chronicle true`.
* The eval-gated default-flip is the headline fast-follow (TODO T8).
*/
export async function isAutoChronicleEnabled(engine: BrainEngine): Promise<boolean> {
const val = await engine.getConfig('auto_chronicle');
if (val == null) return false; // default OFF
return ['true', '1', 'yes', 'on'].includes(val.trim().toLowerCase());
}
/** Pinned timezone for the when→date projection cast (plan: default UTC). */
export async function chronicleTz(engine: BrainEngine): Promise<string> {
const val = await engine.getConfig('chronicle.tz');
return (val && val.trim()) || 'UTC';
}
+34
View File
@@ -0,0 +1,34 @@
// v0.42.x — Life Chronicle (#2390) chronicle-extract eligibility.
// Mirrors src/core/facts/eligibility.ts. A page is chronicle-eligible (auto-
// emits timeline events) when it is conversation-shape, NOT dream-generated,
// and NOT a diary/event page. Diary interiority is NEVER mined into events
// (privacy/consent — plan D5.4); event pages are already the output (anti-loop).
import type { PageType } from '../types.ts';
export type ChronicleEligibility = { ok: true } | { ok: false; reason: string };
const ELIGIBLE_TYPES: PageType[] = ['meeting', 'conversation', 'calendar-event'];
// Directory rescue: a meetings/… page that frontmatter-typed itself 'note' is
// still conversation-shape. life/diary excluded explicitly below.
const RESCUE_SLUG_PREFIXES = ['meetings/', 'conversations/', 'cal/', 'calendar/'] as const;
const MIN_BODY_CHARS = 80;
export function isChronicleEligible(input: {
type: PageType;
slug: string;
body?: string;
dreamGenerated?: boolean;
}): ChronicleEligibility {
const { type, slug } = input;
if (input.dreamGenerated === true) return { ok: false, reason: 'dream_generated' };
// Diary: never extract events from private interiority. Event: anti-loop.
if (type === 'diary' || slug.startsWith('life/diary/')) return { ok: false, reason: 'diary_excluded' };
if (type === 'event' || slug.startsWith('life/events/')) return { ok: false, reason: 'event_self' };
if (slug.startsWith('wiki/agents/')) return { ok: false, reason: 'subagent_scratch' };
const bodyOk = (input.body?.length ?? MIN_BODY_CHARS) >= MIN_BODY_CHARS;
if (!bodyOk) return { ok: false, reason: 'too_short' };
const typeOk = ELIGIBLE_TYPES.includes(type);
const slugOk = RESCUE_SLUG_PREFIXES.some((p) => slug.startsWith(p));
if (!typeOk && !slugOk) return { ok: false, reason: `kind:${type}` };
return { ok: true };
}
+214
View File
@@ -0,0 +1,214 @@
// v0.42.x — Life Chronicle (#2390) event extractor (Phase A.3).
//
// Pipeline (mirrors facts/extract + facts/backstop, but emits EVENT pages +
// timeline projections instead of facts):
// deterministic when/who → judge (LLM, injectable) → PARSE BARRIER
// → write event pages (content-addressed, idempotent) → project to timeline
//
// The judge is injectable so the deterministic write path is testable without a
// real gateway. The default judge calls the chat gateway; when no gateway is
// configured it returns zero events (auto-emit is a no-op, never an error).
import type { BrainEngine } from '../engine.ts';
import { computeContentHash } from '../ingestion/types.ts';
export interface ChronicleEventProposal {
when: string; // ISO datetime or YYYY-MM-DD
who: string[]; // entity slugs / names
what: string; // one-clause summary
where?: string | null;
kind: string; // meeting|call|commitment|decision|… (open vocab)
}
export interface ChronicleJudgeInput {
slug: string;
type: string;
title: string;
body: string;
effectiveDate: string | null; // depth page effective_date (deterministic when)
attendees: string[]; // deterministic who from frontmatter
}
export interface ChronicleJudgeResult { events: ChronicleEventProposal[] }
export type ChronicleJudge = (input: ChronicleJudgeInput) => Promise<ChronicleJudgeResult>;
export interface ChronicleExtractResult {
slug: string;
status: 'extracted' | 'no_events' | 'skipped';
events_written: number;
reason?: string;
}
const KIND_VOCAB = new Set([
'meeting', 'call', 'meal', 'solo', 'travel', 'work',
'commitment', 'decision', 'intro', 'conflict', 'milestone', 'event',
]);
function normalizeKind(k: string): string {
const n = (k || '').trim().toLowerCase();
return KIND_VOCAB.has(n) ? n : 'event';
}
/** Parse a when string to a Date, or null when unparseable (for effective_date). */
function safeDate(s: string): Date | null {
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
/** Resolve a when value to a stable YYYY-MM-DD at the pinned timezone. */
export function isoDay(when: string, tz: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(when)) return when;
const d = new Date(when);
if (Number.isNaN(d.getTime())) return when.slice(0, 10);
if (tz === 'UTC') return d.toISOString().slice(0, 10);
try {
return new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
} catch {
return d.toISOString().slice(0, 10);
}
}
/** PARSE BARRIER: a proposal must fully validate before ANY DB write. */
export function isValidProposal(e: unknown): e is ChronicleEventProposal {
if (!e || typeof e !== 'object') return false;
const o = e as Record<string, unknown>;
return (
typeof o.when === 'string' && o.when.length >= 4 &&
// Must be a REAL parseable date — otherwise isoDay()/::date would write a
// garbage event page and then throw on the projection cast (partial write).
!Number.isNaN(new Date(o.when).getTime()) &&
typeof o.what === 'string' && o.what.trim().length > 0 &&
Array.isArray(o.who) && o.who.every((w) => typeof w === 'string') &&
typeof o.kind === 'string'
);
}
function collectAttendees(fm: Record<string, unknown>): string[] {
const out = new Set<string>();
for (const key of ['attendees', 'people', 'who']) {
const v = fm[key];
if (Array.isArray(v)) for (const x of v) if (typeof x === 'string' && x.trim()) out.add(x.trim());
}
return [...out];
}
/**
* Run the chronicle extractor for one depth page. Idempotent: event slugs are
* content-addressed (re-run upserts the same pages) and the projection upserts
* on (event_page_id, date). A crash between writes re-runs to the same state.
*/
export async function runChronicleExtract(
engine: BrainEngine,
opts: { slug: string; sourceId?: string; judge?: ChronicleJudge; tz?: string; signal?: AbortSignal },
): Promise<ChronicleExtractResult> {
const sourceId = opts.sourceId ?? 'default';
const tz = opts.tz ?? 'UTC';
const page = await engine.getPage(opts.slug, { sourceId });
if (!page) return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'page_not_found' };
const fm = (page.frontmatter ?? {}) as Record<string, unknown>;
const edRaw = page.effective_date as unknown;
const effectiveDate: string | null =
edRaw instanceof Date ? edRaw.toISOString()
: typeof edRaw === 'string' && edRaw ? edRaw
: typeof fm.date === 'string' ? fm.date
: null;
const attendees = collectAttendees(fm);
const judge = opts.judge ?? defaultJudge(engine);
let result: ChronicleJudgeResult;
try {
result = await judge({
slug: opts.slug, type: page.type, title: page.title,
body: page.compiled_truth ?? '', effectiveDate, attendees,
});
} catch (e) {
if ((e as Error)?.name === 'AbortError') throw e;
return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'judge_error' };
}
const proposals = Array.isArray(result?.events) ? result.events : [];
if (proposals.length === 0) return { slug: opts.slug, status: 'no_events', events_written: 0 };
// PARSE BARRIER — reject the WHOLE batch on any malformed proposal; no partial writes.
if (!proposals.every(isValidProposal)) {
return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'malformed_proposal' };
}
let written = 0;
for (const ev of proposals) {
if (opts.signal?.aborted) { const e = new Error('aborted'); e.name = 'AbortError'; throw e; }
const who = ev.who.length ? ev.who : attendees;
const when = ev.when || effectiveDate || isoDay(new Date(0).toISOString(), tz);
const day = isoDay(when, tz);
const hash = computeContentHash(`${who.join(',')}|${ev.what}|${opts.slug}`).slice(0, 8);
const eventSlug = `life/events/${day}-${hash}`;
await engine.putPage(eventSlug, {
type: 'event',
title: ev.what.slice(0, 120),
compiled_truth: `${ev.what} — see [[${opts.slug}]].`,
frontmatter: {
type: 'event',
event: {
when, who, what: ev.what, where: ev.where ?? null,
kind: normalizeKind(ev.kind), depth: opts.slug,
},
captured_via: 'life-chronicle:auto',
},
effective_date: safeDate(when),
}, { sourceId });
await engine.upsertEventProjection({
depthSlug: opts.slug, eventSlug, date: day, summary: ev.what, sourceId,
});
written++;
}
return { slug: opts.slug, status: 'extracted', events_written: written };
}
const JUDGE_SYSTEM = `You segment a meeting/transcript page into discrete timeline EVENTS.
Return ONLY a JSON array. Each element: {"when": ISO datetime or YYYY-MM-DD, "who": [entity slugs/names], "what": one-clause summary, "where": optional string, "kind": one of meeting|call|meal|solo|travel|work|commitment|decision|intro|conflict|milestone|event}.
Prefer the page's known date for "when" when the text gives no explicit time. Use the provided attendee slugs for "who" when the text does not name participants. No prose, no markdown just the JSON array.`;
function defaultJudge(engine: BrainEngine): ChronicleJudge {
return async (input) => {
const { isAvailable, chat } = await import('../ai/gateway.ts');
if (!isAvailable('chat')) return { events: [] };
const body = (input.body || '').slice(0, 12_000);
let text: string;
try {
const res = await chat({
system: JUDGE_SYSTEM,
messages: [{
role: 'user',
content:
`<page slug="${input.slug}" type="${input.type}" date="${input.effectiveDate ?? ''}">\n` +
`${input.title}\n\n${body}\n</page>\n\n` +
`Known attendees: ${input.attendees.slice(0, 10).join(', ') || '(none)'}.\nExtract the events.`,
}],
maxTokens: 1500,
});
if (res.stopReason === 'refusal' || res.stopReason === 'content_filter') return { events: [] };
text = res.text;
} catch (err) {
if ((err as Error)?.name === 'AbortError') throw err;
return { events: [] };
}
const parsed = parseJudgeJson(text);
return { events: parsed };
};
}
/** Tolerant JSON-array extraction from a model response (mirrors facts parser). */
export function parseJudgeJson(text: string): ChronicleEventProposal[] {
if (!text) return [];
let s = text.trim();
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) s = fence[1].trim();
const start = s.indexOf('[');
const end = s.lastIndexOf(']');
if (start === -1 || end === -1 || end < start) return [];
try {
const arr = JSON.parse(s.slice(start, end + 1));
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
+29
View File
@@ -0,0 +1,29 @@
// v0.42.x — Life Chronicle (#2390). Shared last-seen finalizer so both engines
// compute `days_ago` identically (engine parity is on the result, not the SQL).
import type { LastSeenResult } from '../types.ts';
/**
* Build a LastSeenResult from a resolved last-seen date. `lastDate` is a bare
* 'YYYY-MM-DD' (or null when the entity was never seen). `asof` (also
* 'YYYY-MM-DD') pins the reference day for deterministic tests; defaults to now.
* Both are interpreted at UTC midnight so the day-difference is timezone-stable.
*/
export function finalizeLastSeen(
entitySlug: string,
lastDate: string | null,
lastEventSlug: string | null,
asof?: string,
): LastSeenResult {
let days_ago: number | null = null;
if (lastDate) {
const base = asof ? new Date(`${asof}T00:00:00Z`) : new Date();
const then = new Date(`${lastDate}T00:00:00Z`);
days_ago = Math.max(0, Math.floor((base.getTime() - then.getTime()) / 86_400_000));
}
return {
entity_slug: entitySlug,
last_date: lastDate,
last_event_slug: lastEventSlug,
days_ago,
};
}
+22
View File
@@ -0,0 +1,22 @@
// v0.42.x — Life Chronicle (#2390) narrative rendering (Phase A.6 delight).
// Pure function: turn timeline projection rows into a readable prose day-by-day
// summary (the `--narrative` flag on `gbrain day`). No SQL, no I/O.
import type { ChronicleTimelineRow } from '../types.ts';
export function renderTimelineNarrative(rows: ChronicleTimelineRow[]): string {
if (!rows.length) return 'No events in this window.';
const byDate = new Map<string, ChronicleTimelineRow[]>();
for (const r of rows) {
const list = byDate.get(r.date) ?? [];
list.push(r);
byDate.set(r.date, list);
}
const lines: string[] = [];
for (const [date, rs] of byDate) {
const items = rs
.map((r) => (r.kind ? `${r.summary} (${r.kind})` : r.summary))
.join('; ');
lines.push(`${date}${rs.length} event${rs.length === 1 ? '' : 's'}: ${items}`);
}
return lines.join('\n');
}
+44
View File
@@ -0,0 +1,44 @@
// v0.42.x — Life Chronicle (#2390) ontology helpers (Phase B.10).
// The ontology rides the `facts` table; these are the deterministic bits the
// engine methods (mergeOntologyFact / getOntology / …) lean on.
import { computeContentHash } from '../ingestion/types.ts';
/**
* Deterministic dedup key for an ontology value. Normalized (trim + lowercase +
* NFKC via computeContentHash) so "Advisor" and "advisor " dedup, and a crash
* retry produces the same key NO timestamp, so retries are idempotent.
*/
export function valueHash(value: string): string {
return computeContentHash(value.trim().toLowerCase()).slice(0, 16);
}
// Seed lexicon: canonicalize common dimension-name drift at WRITE time so the
// open world doesn't fragment into role/job_role/position/title for one concept.
const DIMENSION_ALIASES: Record<string, string> = {
job_role: 'role', position: 'role', title: 'role', job_title: 'role',
relationship: 'relation', rel: 'relation',
risk_appetite: 'risk_tolerance',
comms_style: 'communication_style', communication: 'communication_style',
employer_name: 'employer', company: 'employer', org: 'employer',
};
const KNOWN_DIMENSIONS = new Set<string>([
'role', 'relation', 'risk_tolerance', 'decision_style', 'communication_style',
'reliability', 'expertise', 'affect', 'location', 'employer',
]);
/** Normalize a dimension name (case/space/hyphen + alias lexicon). */
export function normalizeDimension(name: string): string {
const n = name.trim().toLowerCase().replace(/[\s-]+/g, '_');
return DIMENSION_ALIASES[n] ?? n;
}
/**
* A dimension is "novel" ( quarantine) when it's neither a seed dimension nor
* an alias of one. Quarantined observations are stored but excluded from
* current-value resolution + context loading until confirmed, so an LLM
* hallucination can't silently shape agent context.
*/
export function isNovelDimension(normalized: string): boolean {
return !KNOWN_DIMENSIONS.has(normalized);
}
+50
View File
@@ -0,0 +1,50 @@
// v0.42.x — Life Chronicle (#2390) agent-context loader (Phase B.12).
//
// Zero-LLM composition over the chronicle reads: hand an agent the recent
// timeline + the validity-resolved ontology for the entities in play, so it
// orients before acting (the exact thing missing when chronology gets fumbled).
// Pure composition of engine.getSince + engine.getOntology — no new SQL.
import type { BrainEngine } from '../engine.ts';
import type { ChronicleTimelineRow, OntologyValue } from '../types.ts';
export interface ChronicleContextOpts {
/** Lookback window in days for the recent timeline (default 7). */
days?: number;
/** Entity slugs to resolve current ontology for (e.g. the people in the session). */
entities?: string[];
/** Cap on timeline rows (default 50). */
limit?: number;
/** Untrusted caller → diary-sourced ontology is redacted. */
remote?: boolean;
sourceId?: string;
sourceIds?: string[];
}
export interface ChronicleContext {
since: string; // YYYY-MM-DD lower bound
recent_timeline: ChronicleTimelineRow[];
ontologies: Record<string, OntologyValue[]>; // entity slug → current resolved values
}
function daysAgoIso(days: number): string {
return new Date(Date.now() - Math.max(0, days) * 86_400_000).toISOString().slice(0, 10);
}
export async function loadChronicleContext(
engine: BrainEngine,
opts: ChronicleContextOpts = {},
): Promise<ChronicleContext> {
const days = typeof opts.days === 'number' && opts.days > 0 ? opts.days : 7;
const since = daysAgoIso(days);
const scope = { sourceId: opts.sourceId, sourceIds: opts.sourceIds };
const recent_timeline = await engine.getSince(since, { ...scope, limit: opts.limit ?? 50 });
const ontologies: Record<string, OntologyValue[]> = {};
for (const slug of opts.entities ?? []) {
let vals = await engine.getOntology(slug, scope);
if (opts.remote) vals = vals.filter((v) => !(v.source ?? '').startsWith('life/diary/'));
if (vals.length) ontologies[slug] = vals;
}
return { since, recent_timeline, ontologies };
}
+1
View File
@@ -58,6 +58,7 @@ export const BRAIN_CHECK_NAMES: ReadonlySet<string> = new Set([
'brain_score',
'calibration_freshness',
'child_table_orphans',
'chronicle_projection_health',
'content_sanity_audit_recent',
'contextual_retrieval_coverage',
'contradictions',
+39
View File
@@ -4,6 +4,9 @@ import type {
SearchResult, SearchOpts,
Link, GraphNode, GraphPath, RelationalFanoutRow, RelationalFanoutOpts,
TimelineEntry, TimelineInput, TimelineOpts,
ChronicleTimelineRow, ChronicleTimelineOpts, LastSeenResult,
OntologyObservationInput, OntologyMergeResult, OntologyValue, OntologyDimensionStat,
OntologyConflict, OntologyReadOpts,
RawData,
PageVersion,
BrainStats, BrainHealth,
@@ -1382,6 +1385,42 @@ export interface BrainEngine {
addTimelineEntriesBatch(entries: TimelineBatchInput[], opts?: BatchOpts): Promise<number>;
getTimeline(slug: string, opts?: TimelineOpts): Promise<TimelineEntry[]>;
// v0.42.x — Life Chronicle (#2390) timeline reads. All filter the depth page
// (and any event page) on deleted_at IS NULL, order by COALESCE(event
// effective_date, date), and honor source scope (sourceIds[] > sourceId).
/** Events/timeline rows on a given day (or its ISO week when opts.week). */
getTimelineForDate(date: string, opts?: ChronicleTimelineOpts): Promise<ChronicleTimelineRow[]>;
/** Events/timeline rows on or after `date`, optionally filtered by event.kind. */
getSince(date: string, opts?: ChronicleTimelineOpts): Promise<ChronicleTimelineRow[]>;
/** "On this day" — events from the same month-day in PRIOR years (default: today). */
getOnThisDay(opts?: { date?: string; limit?: number; sourceId?: string; sourceIds?: string[] }): Promise<ChronicleTimelineRow[]>;
/** Most recent date an entity appears (its own page or an event's `who`). */
getLastSeen(entitySlug: string, opts?: { asof?: string; sourceId?: string; sourceIds?: string[] }): Promise<LastSeenResult>;
/**
* Upsert the date-index projection row for an event page: page_id = depth
* page, event_page_id = event page, keyed (event_page_id, date). Re-extraction
* with a changed summary UPDATEs (no duplicate). Returns projected=false when
* either slug is missing in the source. Idempotent.
*/
upsertEventProjection(opts: { depthSlug: string; eventSlug: string; date: string; summary: string; detail?: string; sourceId?: string }): Promise<{ projected: boolean }>;
// v0.42.x — Life Chronicle (#2390) per-entity ontology (rides `facts`).
/**
* Record one ontology observation (entity has dimension=value). Idempotent on
* the deterministic dedup key (source_id, entity_slug, dimension, value_hash,
* source_markdown_slug). A new value forward-supersedes the prior open row
* (expired_at + superseded_by); the same value corroborates; a backdated
* conflicting value is inserted WITHOUT rewriting the prior (surfaced by
* findOntologyConflicts). Never throws on dup returns action 'noop'.
*/
mergeOntologyFact(obs: OntologyObservationInput): Promise<OntologyMergeResult>;
/** Current resolved ontology for an entity at `asof` (default now). */
getOntology(entitySlug: string, opts?: OntologyReadOpts): Promise<OntologyValue[]>;
/** Meta-ontology: which dimensions exist across the brain, and how widely. */
discoverOntologyDimensions(opts?: { sourceId?: string; sourceIds?: string[] }): Promise<OntologyDimensionStat[]>;
/** Dimensions with ≥2 distinct current-open values from ≥2 provenances. */
findOntologyConflicts(opts?: { sourceId?: string; sourceIds?: string[]; minConfidence?: number }): Promise<OntologyConflict[]>;
// Raw data
/**
* v0.31.8 (D21): `opts.sourceId` source-scopes the page-id lookup. When
+3
View File
@@ -466,6 +466,9 @@ const GBRAIN_BASE_PATH_PREFIXES: ReadonlyArray<{ prefixes: string[]; type: PageT
{ prefixes: ['/cal/', '/calendar/'], type: 'calendar-event' },
{ prefixes: ['/notes/', '/note/'], type: 'note' },
{ prefixes: ['/meetings/', '/meeting/'], type: 'meeting' },
// v0.42.x — Life Chronicle (#2390): timeline events + thought diary.
{ prefixes: ['/life/events/'], type: 'event' },
{ prefixes: ['/life/diary/'], type: 'diary' },
];
function inferType(filePath?: string): PageType {
+66
View File
@@ -5439,6 +5439,72 @@ export const MIGRATIONS: Migration[] = [
`,
},
},
{
version: 121,
name: 'timeline_entries_event_page_id',
// v0.42.x — Life Chronicle (#2390): the event→timeline projection pointer.
// A `type:event` page projects ONE date-index row into timeline_entries
// keyed to the depth/meeting page (page_id), with event_page_id pointing at
// the event page itself. Additive + idempotent: nullable FK + partial
// indexes; legacy rows keep event_page_id NULL so existing behavior is
// unchanged. The partial UNIQUE(event_page_id, date) makes re-extraction
// with a changed summary an UPDATE (not a duplicate). FK added via a guarded
// DO block (mirrors the facts_source_id_fkey pattern) so the ALTER is a
// no-op on re-runs. Mirrored in src/schema.sql, src/core/pglite-schema.ts,
// and the generated src/core/schema-embedded.ts for fresh installs.
idempotent: true,
sql: `
ALTER TABLE timeline_entries ADD COLUMN IF NOT EXISTS event_page_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'timeline_entries_event_page_id_fkey'
AND conrelid = 'timeline_entries'::regclass
) THEN
ALTER TABLE timeline_entries
ADD CONSTRAINT timeline_entries_event_page_id_fkey
FOREIGN KEY (event_page_id) REFERENCES pages(id) ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_timeline_event_page
ON timeline_entries(event_page_id) WHERE event_page_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_event_dedup
ON timeline_entries(event_page_id, date) WHERE event_page_id IS NOT NULL;
`,
},
{
version: 122,
name: 'facts_ontology_dimension',
// v0.42.x — Life Chronicle (#2390): the per-entity ontology rides the
// existing `facts` table. facts already gives bi-temporal validity
// (valid_from/valid_until/expired_at), supersession (superseded_by),
// remote redaction (visibility), confidence, provenance (source_markdown_slug),
// embedding, and corroboration (consolidated_into). The ONLY genuinely-new
// concept is a typed `dimension` (e.g. role, risk_tolerance) carrying a
// resolved `value` + a deterministic `value_hash` dedup key, plus a
// `dim_status` for quarantining novel/LLM-proposed dimensions. Plain facts
// keep dimension NULL → unchanged behavior. The partial UNIQUE is
// deterministic (no timestamp) so a crash-retry is idempotent. Additive;
// facts is migration-created (absent from static schema), so this migration
// is the single source for fresh + migrated brains.
idempotent: true,
sql: `
ALTER TABLE facts ADD COLUMN IF NOT EXISTS dimension TEXT;
ALTER TABLE facts ADD COLUMN IF NOT EXISTS value TEXT;
ALTER TABLE facts ADD COLUMN IF NOT EXISTS value_hash TEXT;
ALTER TABLE facts ADD COLUMN IF NOT EXISTS dim_status TEXT;
CREATE INDEX IF NOT EXISTS idx_facts_dimension
ON facts(source_id, entity_slug, dimension, valid_from DESC)
WHERE expired_at IS NULL AND dimension IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_facts_ontology_dedup
ON facts(source_id, entity_slug, dimension, value_hash, source_markdown_slug)
WHERE dimension IS NOT NULL;
`,
},
];
export const LATEST_VERSION = MIGRATIONS.length > 0
+5
View File
@@ -24,6 +24,7 @@
*/
const THIRTY_MIN_MS = 30 * 60 * 1000;
const TEN_MIN_MS = 10 * 60 * 1000;
/**
* Default wall-clock budget (ms) for long-running handler types. A handler
@@ -37,6 +38,10 @@ export const HANDLER_DEFAULT_TIMEOUT_MS: Readonly<Record<string, number>> = {
// #2194 fix #3: brain-wide maintenance (embed-all/orphans/purge/…) can run
// longer than a single source cycle; give it the same 30-min budget.
'autopilot-global-maintenance': THIRTY_MIN_MS,
// v0.42.x (#2390) — Life Chronicle: one page = one LLM extraction call + a
// few writes. Generous 10-min budget (vs the tight null-default) covers a
// slow gateway without the 30-min loop budget.
chronicle_extract: TEN_MIN_MS,
};
/**
+298
View File
@@ -1034,6 +1034,34 @@ const put_page: Operation = {
factsQueued = { skipped: 'backstop_error' };
}
// v0.42.x (#2390): Life Chronicle backstop. ONLY on a real import
// (status==='imported' — a skipped/unchanged rewrite still carries
// parsedPage, so gating on parsedPage alone would re-enqueue forever),
// behind the SAME trust gate as auto-link/timeline + the auto_chronicle
// flag. Enqueues a chronicle_extract job; never blocks the write.
let chronicleQueued: { queued: boolean } | { skipped: string } | undefined;
if (result.status !== 'imported') {
chronicleQueued = { skipped: 'not_imported' };
} else if (ctx.remote !== false && !trustedWorkspace) {
chronicleQueued = { skipped: 'remote' };
} else if (result.parsedPage) {
try {
const { runChronicleBackstop } = await import('./chronicle/backstop.ts');
const r = await runChronicleBackstop(
{
slug,
type: result.parsedPage.type,
compiled_truth: result.parsedPage.compiled_truth,
frontmatter: result.parsedPage.frontmatter,
},
{ engine: ctx.engine, sourceId: ctx.sourceId ?? 'default' },
);
chronicleQueued = r.enqueued ? { queued: true } : { skipped: r.skipped ?? 'skipped' };
} catch {
chronicleQueued = { skipped: 'backstop_error' };
}
}
// Post-write validator lint (PR 2.5): feature-flag-gated, non-blocking.
// When `writer.lint_on_put_page` is enabled, runs the BrainWriter's
// validators on the freshly-written page and logs findings to
@@ -1063,6 +1091,7 @@ const put_page: Operation = {
...(autoTimeline ? { auto_timeline: autoTimeline } : {}),
...(writerLint ? { writer_lint: writerLint } : {}),
...(factsQueued ? { facts_backstop: factsQueued } : {}),
...(chronicleQueued ? { chronicle_backstop: chronicleQueued } : {}),
...(writeThrough ? { write_through: writeThrough } : {}),
};
},
@@ -5019,6 +5048,271 @@ const run_skillopt: Operation = {
},
};
// ── v0.42.x — Life Chronicle (#2390) timeline read ops ───────────────────
// CLI names avoid the existing `timeline` (get_timeline, a page's own timeline):
// `gbrain day <date>` / `gbrain since <date>` / `gbrain last-seen <entity>`.
// All route through sourceScopeOpts(ctx) so reads honor source isolation.
const chronicle_day: Operation = {
name: 'chronicle_day',
description:
'Life Chronicle: events + timeline entries on a given day (or its ISO week when week=true), ' +
"ordered chronologically; each row backlinks to its depth page. Distinct from `get_timeline`/" +
"`gbrain timeline <slug>`, which shows ONE page's timeline. CLI: `gbrain day <date>`.",
scope: 'read',
params: {
date: { type: 'string', required: true, description: 'Day as YYYY-MM-DD.' },
week: { type: 'boolean', description: 'Expand to the ISO week (MonSun) containing the date.' },
limit: { type: 'number', description: 'Max rows (default 200).' },
narrative: { type: 'boolean', description: 'Also return a prose day-by-day narrative.' },
},
handler: async (ctx, p) => {
const rows = await ctx.engine.getTimelineForDate(String(p.date), {
week: p.week === true,
limit: typeof p.limit === 'number' ? p.limit : undefined,
...sourceScopeOpts(ctx),
});
if (p.narrative === true) {
const { renderTimelineNarrative } = await import('./chronicle/narrative.ts');
return { date: String(p.date), narrative: renderTimelineNarrative(rows), events: rows };
}
return rows;
},
cliHints: { name: 'day', positional: ['date'] },
};
const chronicle_on_this_day: Operation = {
name: 'chronicle_on_this_day',
description:
'Life Chronicle: events from the same calendar day in PRIOR years ("on this day"). ' +
'CLI: `gbrain on-this-day [--date YYYY-MM-DD]`.',
scope: 'read',
params: {
date: { type: 'string', description: 'Anchor day YYYY-MM-DD (default today); matches its month-day in prior years.' },
limit: { type: 'number', description: 'Max rows (default 50).' },
},
handler: async (ctx, p) => ctx.engine.getOnThisDay({
date: typeof p.date === 'string' ? p.date : undefined,
limit: typeof p.limit === 'number' ? p.limit : undefined,
...sourceScopeOpts(ctx),
}),
cliHints: { name: 'on-this-day' },
};
const chronicle_since: Operation = {
name: 'chronicle_since',
description:
'Life Chronicle: events + timeline entries on or after a date, optionally filtered by event kind. ' +
'CLI: `gbrain since <date> [--kind commitment]`.',
scope: 'read',
params: {
date: { type: 'string', required: true, description: 'Lower-bound day as YYYY-MM-DD (inclusive).' },
kind: { type: 'string', description: "Filter event projections by event.kind (e.g. 'commitment')." },
limit: { type: 'number', description: 'Max rows (default 200).' },
},
handler: async (ctx, p) => {
return ctx.engine.getSince(String(p.date), {
kind: typeof p.kind === 'string' ? p.kind : undefined,
limit: typeof p.limit === 'number' ? p.limit : undefined,
...sourceScopeOpts(ctx),
});
},
cliHints: { name: 'since', positional: ['date'] },
};
const chronicle_last_seen: Operation = {
name: 'chronicle_last_seen',
description:
"Life Chronicle: when an entity was last seen — its own timeline rows OR an event's `who`. " +
'Returns last_date, the event slug, and days_ago. CLI: `gbrain last-seen <entity-slug>`.',
scope: 'read',
params: {
entity: { type: 'string', required: true, description: 'Entity page slug (e.g. people/sarah-chen).' },
asof: { type: 'string', description: 'Reference day YYYY-MM-DD for days_ago (default today).' },
},
handler: async (ctx, p) => {
return ctx.engine.getLastSeen(String(p.entity), {
asof: typeof p.asof === 'string' ? p.asof : undefined,
...sourceScopeOpts(ctx),
});
},
cliHints: { name: 'last-seen', positional: ['entity'] },
};
const ontology_get: Operation = {
name: 'ontology_get',
description:
"Life Chronicle: the current resolved per-entity ontology (dimension → value) at `asof` " +
"(default now), with provenance + confidence + validity. CLI: `gbrain ontology <entity> [--asof YYYY-MM-DD]`.",
scope: 'read',
params: {
entity: { type: 'string', required: true, description: 'Entity page slug (e.g. people/sarah-chen).' },
asof: { type: 'string', description: 'Valid-time as-of day YYYY-MM-DD (time-travel; default now).' },
min_confidence: { type: 'number', description: 'Only return observations at/above this confidence (0..1).' },
include_quarantined: { type: 'boolean', description: 'Include quarantined novel dimensions (default false).' },
},
handler: async (ctx, p) => {
const rows = await ctx.engine.getOntology(String(p.entity), {
asof: typeof p.asof === 'string' ? p.asof : undefined,
minConfidence: typeof p.min_confidence === 'number' ? p.min_confidence : undefined,
includeQuarantined: p.include_quarantined === true,
...sourceScopeOpts(ctx),
});
// Remote redaction: never surface diary-sourced ontology to untrusted callers.
return ctx.remote !== false ? rows.filter((r) => !(r.source ?? '').startsWith('life/diary/')) : rows;
},
cliHints: { name: 'ontology', positional: ['entity'] },
};
const ontology_propose: Operation = {
name: 'ontology_propose',
description:
'Life Chronicle: record one ontology observation (entity has dimension=value), sourced + ' +
'confidence-weighted + bi-temporal. Idempotent on (entity,dimension,value,source). A new value ' +
'supersedes the prior; a backdated conflict is flagged not rewritten. CLI: `gbrain ontology-add <entity> <dimension> <value>`.',
scope: 'write',
mutating: true,
params: {
entity: { type: 'string', required: true, description: 'Entity page slug.' },
dimension: { type: 'string', required: true, description: 'Dimension (e.g. role, risk_tolerance). Normalized at write.' },
value: { type: 'string', required: true, description: 'The resolved value (e.g. advisor).' },
confidence: { type: 'number', description: '0..1; default 0.7.' },
source: { type: 'string', description: 'Provenance (page slug / uri); default "manual".' },
valid_from: { type: 'string', description: 'ISO date the value became true (default: now).' },
valid_to: { type: 'string', description: 'ISO date the value stopped being true (default: open).' },
visibility: { type: 'string', enum: ['private', 'world'], description: 'Default private.' },
},
handler: async (ctx, p) => {
return ctx.engine.mergeOntologyFact({
entitySlug: String(p.entity),
dimension: String(p.dimension),
value: String(p.value),
confidence: typeof p.confidence === 'number' ? p.confidence : undefined,
source: typeof p.source === 'string' && p.source ? p.source : 'manual',
validFrom: typeof p.valid_from === 'string' ? p.valid_from : undefined,
validTo: typeof p.valid_to === 'string' ? p.valid_to : undefined,
visibility: p.visibility === 'world' ? 'world' : 'private',
sourceId: ctx.sourceId,
});
},
cliHints: { name: 'ontology-add', positional: ['entity', 'dimension', 'value'] },
};
const ontology_dimensions: Operation = {
name: 'ontology_dimensions',
description:
'Life Chronicle meta-ontology: which dimensions the brain tracks across entities, with ' +
'entity + observation counts. CLI: `gbrain ontology-dimensions`.',
scope: 'read',
params: {},
handler: async (ctx) => ctx.engine.discoverOntologyDimensions(sourceScopeOpts(ctx)),
cliHints: { name: 'ontology-dimensions' },
};
const ontology_conflicts: Operation = {
name: 'ontology_conflicts',
description:
'Life Chronicle: dimensions with ≥2 distinct current values from ≥2 provenances (genuine ' +
'disagreement, not temporal supersession). CLI: `gbrain ontology-contradictions`.',
scope: 'read',
params: {
min_confidence: { type: 'number', description: 'Only consider observations at/above this confidence (0..1).' },
},
handler: async (ctx, p) => {
const conflicts = await ctx.engine.findOntologyConflicts({
minConfidence: typeof p.min_confidence === 'number' ? p.min_confidence : undefined,
...sourceScopeOpts(ctx),
});
if (ctx.remote === false) return conflicts;
// Remote: redact diary-sourced values; drop conflicts that no longer have
// ≥2 distinct values once diary provenance is removed (no leak via conflicts).
return conflicts
.map((c) => ({ ...c, values: c.values.filter((v) => !(v.source ?? '').startsWith('life/diary/')) }))
.filter((c) => new Set(c.values.map((v) => v.value)).size >= 2);
},
cliHints: { name: 'ontology-contradictions' },
};
const volunteer_chronicle: Operation = {
name: 'volunteer_chronicle',
description:
'Life Chronicle agent-orientation: the recent timeline (last N days) + the current ' +
'validity-resolved ontology for the named entities, in one zero-LLM payload, so an agent ' +
'orients before acting. Diary-sourced ontology is redacted for remote callers. ' +
'CLI: `gbrain orient [--days 7] [--entities people/a,people/b]`.',
scope: 'read',
params: {
days: { type: 'number', description: 'Recent-timeline lookback in days (default 7).' },
entities: { type: 'string', description: 'Comma-separated entity slugs to resolve ontology for.' },
limit: { type: 'number', description: 'Max timeline rows (default 50).' },
},
handler: async (ctx, p) => {
const { loadChronicleContext } = await import('./context/chronicle-context.ts');
const entities = typeof p.entities === 'string'
? p.entities.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
return loadChronicleContext(ctx.engine, {
days: typeof p.days === 'number' ? p.days : undefined,
entities,
limit: typeof p.limit === 'number' ? p.limit : undefined,
remote: ctx.remote !== false,
...sourceScopeOpts(ctx),
});
},
cliHints: { name: 'orient' },
};
const chronicle_backfill: Operation = {
name: 'chronicle_backfill',
description:
'Life Chronicle: sweep existing meeting/conversation/calendar pages into timeline events by ' +
'enqueuing chronicle_extract jobs (one per eligible page). --dry-run counts without enqueuing. ' +
'Local-only bulk op. CLI: `gbrain chronicle-backfill [--since YYYY-MM-DD] [--limit N] [--dry-run]`.',
scope: 'admin',
mutating: true,
localOnly: true,
params: {
since: { type: 'string', description: 'Only pages updated on/after this date (YYYY-MM-DD).' },
limit: { type: 'number', description: 'Max pages per type to sweep (default 1000).' },
dry_run: { type: 'boolean', description: 'Count eligible pages without enqueuing.' },
},
handler: async (ctx, p) => {
const { isChronicleEligible } = await import('./chronicle/eligibility.ts');
const TYPES = ['meeting', 'conversation', 'calendar-event'] as const;
const limit = typeof p.limit === 'number' ? p.limit : 1000;
const updated_after = typeof p.since === 'string' ? p.since : undefined;
const dryRun = p.dry_run === true;
const scope = sourceScopeOpts(ctx);
type QueueLike = { add: (n: string, d: Record<string, unknown>) => Promise<unknown> };
let queue: QueueLike | null = null;
if (!dryRun) {
const { MinionQueue } = await import('./minions/queue.ts');
queue = new MinionQueue(ctx.engine) as unknown as QueueLike;
}
let scanned = 0, eligible = 0, enqueued = 0;
const errors: { slug: string; error: string }[] = [];
for (const type of TYPES) {
const pages = await ctx.engine.listPages({ type, updated_after, limit, ...scope });
for (const page of pages) {
scanned++;
const dreamGenerated = (page.frontmatter as Record<string, unknown> | undefined)?.dream_generated === true;
const elig = isChronicleEligible({ type: page.type, slug: page.slug, body: page.compiled_truth, dreamGenerated });
if (!elig.ok) continue;
eligible++;
if (dryRun || !queue) continue;
try {
await queue.add('chronicle_extract', { slug: page.slug, sourceId: ctx.sourceId ?? 'default' });
enqueued++;
} catch (e) {
// Never swallow — surface per-page failures (the #2057 no-swallow pattern).
errors.push({ slug: page.slug, error: e instanceof Error ? e.message : String(e) });
}
}
}
return { scanned, eligible, enqueued, dry_run: dryRun, errors };
},
cliHints: { name: 'chronicle-backfill' },
};
export const operations: Operation[] = [
// Page CRUD
get_page, put_page, delete_page, list_pages,
@@ -5069,6 +5363,10 @@ export const operations: Operation[] = [
whoami, sources_add, sources_list, sources_remove, sources_status,
// v0.29: Salience + anomalies + recent transcripts
get_recent_salience, find_anomalies, get_recent_transcripts,
// v0.42.x (#2390): Life Chronicle timeline reads
chronicle_day, chronicle_on_this_day, chronicle_since, chronicle_last_seen,
ontology_get, ontology_propose, ontology_dimensions, ontology_conflicts,
volunteer_chronicle, chronicle_backfill,
// v0.43 (#2095): push-based context
volunteer_context,
// v0.31: hot memory (facts table)
+267
View File
@@ -30,6 +30,9 @@ import type {
SearchResult, SearchOpts,
Link, GraphNode, GraphPath,
TimelineEntry, TimelineInput, TimelineOpts,
ChronicleTimelineRow, ChronicleTimelineOpts, LastSeenResult,
OntologyObservationInput, OntologyMergeResult, OntologyValue, OntologyDimensionStat,
OntologyConflict, OntologyReadOpts,
RawData,
PageVersion,
BrainStats, BrainHealth,
@@ -48,6 +51,7 @@ import { normalizeWeightForStorage } from './takes-fence.ts';
import { executeRawJsonb } from './sql-query.ts';
import { sanitizeForJsonb, buildLinkRows, buildTimelineRows, buildTakeRows } from './batch-rows.ts';
import { GBrainError, PAGE_SORT_SQL, ENRICH_ORDER_SQL } from './types.ts';
import { finalizeLastSeen } from './chronicle/last-seen.ts';
import { computeAnomaliesFromBuckets } from './cycle/anomaly.ts';
import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte } from './search/sql-ranking.ts';
@@ -3376,6 +3380,269 @@ export class PGLiteEngine implements BrainEngine {
return result.rows as unknown as TimelineEntry[];
}
// ── v0.42.x Life Chronicle (#2390) timeline reads ───────────────────────
// Same result contract as the Postgres engine (parity is on results, not the
// builder idiom): JOIN depth page (deleted_at IS NULL), LEFT JOIN event page,
// hide soft-deleted event projections, order by COALESCE(event effective_date,
// date). Source scope: federated sourceIds[] > scalar sourceId > unscoped.
private pushChronicleSource(where: string[], params: unknown[], opts?: { sourceId?: string; sourceIds?: string[] }): void {
if (opts?.sourceIds && opts.sourceIds.length > 0) {
params.push(opts.sourceIds);
where.push(`p.source_id = ANY($${params.length}::text[])`);
} else if (opts?.sourceId) {
params.push(opts.sourceId);
where.push(`p.source_id = $${params.length}`);
}
}
private static CHRONICLE_SELECT = `
SELECT te.date::text AS date, te.summary, te.detail, te.source,
te.page_id, p.slug AS page_slug,
te.event_page_id, ep.slug AS event_slug,
ep.effective_date::text AS effective_date,
ep.frontmatter->'event'->>'kind' AS kind
FROM timeline_entries te
JOIN pages p ON p.id = te.page_id AND p.deleted_at IS NULL
LEFT JOIN pages ep ON ep.id = te.event_page_id`;
async getTimelineForDate(date: string, opts?: ChronicleTimelineOpts): Promise<ChronicleTimelineRow[]> {
const limit = opts?.limit ?? 200;
const params: unknown[] = [date];
const lower = opts?.week ? `date_trunc('week', $1::date)::date` : `$1::date`;
const upper = opts?.week ? `(date_trunc('week', $1::date) + interval '6 days')::date` : `$1::date`;
const where: string[] = [
`te.date >= ${lower}`,
`te.date <= ${upper}`,
`(te.event_page_id IS NULL OR ep.deleted_at IS NULL)`,
];
this.pushChronicleSource(where, params, opts);
params.push(limit);
const result = await this.db.query(
`${PGLiteEngine.CHRONICLE_SELECT}
WHERE ${where.join(' AND ')}
ORDER BY COALESCE(ep.effective_date, te.date::timestamptz) ASC, te.id ASC
LIMIT $${params.length}`,
params,
);
return result.rows as unknown as ChronicleTimelineRow[];
}
async getSince(date: string, opts?: ChronicleTimelineOpts): Promise<ChronicleTimelineRow[]> {
const limit = opts?.limit ?? 200;
const params: unknown[] = [date];
const where: string[] = [
`te.date >= $1::date`,
`(te.event_page_id IS NULL OR ep.deleted_at IS NULL)`,
];
if (opts?.kind) {
params.push(opts.kind);
where.push(`ep.frontmatter->'event'->>'kind' = $${params.length}`);
}
this.pushChronicleSource(where, params, opts);
params.push(limit);
const result = await this.db.query(
`${PGLiteEngine.CHRONICLE_SELECT}
WHERE ${where.join(' AND ')}
ORDER BY COALESCE(ep.effective_date, te.date::timestamptz) ASC, te.id ASC
LIMIT $${params.length}`,
params,
);
return result.rows as unknown as ChronicleTimelineRow[];
}
async getOnThisDay(opts?: { date?: string; limit?: number; sourceId?: string; sourceIds?: string[] }): Promise<ChronicleTimelineRow[]> {
const limit = opts?.limit ?? 50;
const params: unknown[] = [];
let target: string;
if (opts?.date) { params.push(opts.date); target = `$${params.length}::date`; }
else { target = `current_date`; }
const where: string[] = [
`EXTRACT(MONTH FROM te.date) = EXTRACT(MONTH FROM ${target})`,
`EXTRACT(DAY FROM te.date) = EXTRACT(DAY FROM ${target})`,
`te.date < ${target}`,
`(te.event_page_id IS NULL OR ep.deleted_at IS NULL)`,
];
this.pushChronicleSource(where, params, opts);
params.push(limit);
const result = await this.db.query(
`${PGLiteEngine.CHRONICLE_SELECT}
WHERE ${where.join(' AND ')}
ORDER BY te.date DESC, te.id ASC
LIMIT $${params.length}`,
params,
);
return result.rows as unknown as ChronicleTimelineRow[];
}
async getLastSeen(entitySlug: string, opts?: { asof?: string; sourceId?: string; sourceIds?: string[] }): Promise<LastSeenResult> {
const params: unknown[] = [entitySlug, `%${entitySlug}%`];
const where: string[] = [
`(te.event_page_id IS NULL OR ep.deleted_at IS NULL)`,
`(p.slug = $1 OR (ep.id IS NOT NULL AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(
CASE WHEN jsonb_typeof(ep.frontmatter->'event'->'who') = 'array'
THEN ep.frontmatter->'event'->'who' ELSE '[]'::jsonb END
) AS w(name) WHERE w.name = $1 OR w.name LIKE $2)))`,
];
this.pushChronicleSource(where, params, opts);
const result = await this.db.query(
`SELECT te.date::text AS last_date, ep.slug AS last_event_slug
FROM timeline_entries te
JOIN pages p ON p.id = te.page_id AND p.deleted_at IS NULL
LEFT JOIN pages ep ON ep.id = te.event_page_id
WHERE ${where.join(' AND ')}
ORDER BY COALESCE(ep.effective_date, te.date::timestamptz) DESC, te.id DESC
LIMIT 1`,
params,
);
const row = result.rows[0] as { last_date?: string; last_event_slug?: string } | undefined;
return finalizeLastSeen(entitySlug, row?.last_date ?? null, row?.last_event_slug ?? null, opts?.asof);
}
async upsertEventProjection(opts: { depthSlug: string; eventSlug: string; date: string; summary: string; detail?: string; sourceId?: string }): Promise<{ projected: boolean }> {
const sourceId = opts.sourceId ?? 'default';
const r = await this.db.query(
`INSERT INTO timeline_entries (page_id, date, source, summary, detail, event_page_id)
SELECT dp.id, $1::date, $2, $3, $4, ep.id
FROM pages dp, pages ep
WHERE dp.slug = $5 AND dp.source_id = $6 AND ep.slug = $7 AND ep.source_id = $6
ON CONFLICT (event_page_id, date) WHERE event_page_id IS NOT NULL
DO UPDATE SET summary = EXCLUDED.summary, detail = EXCLUDED.detail,
page_id = EXCLUDED.page_id, source = EXCLUDED.source
RETURNING id`,
[opts.date, 'life-chronicle:event:' + opts.eventSlug, opts.summary, opts.detail ?? '', opts.depthSlug, sourceId, opts.eventSlug],
);
return { projected: r.rows.length > 0 };
}
async mergeOntologyFact(obs: OntologyObservationInput): Promise<OntologyMergeResult> {
const sourceId = obs.sourceId ?? 'default';
const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts');
const dimension = normalizeDimension(obs.dimension);
const vh = valueHash(obs.value);
const conf = obs.confidence ?? 0.7;
const status = obs.status ?? (isNovelDimension(dimension) ? 'quarantined' : 'active');
const visibility = obs.visibility ?? 'private';
const validFrom = obs.validFrom ?? null;
const validUntil = obs.validTo ?? null;
const factText = `${dimension}: ${obs.value}`;
// "current open" = open-ended (valid_until IS NULL) + not retracted.
const cur = await this.db.query(
`SELECT id, value_hash, valid_from FROM facts
WHERE source_id = $1 AND entity_slug = $2 AND dimension = $3 AND expired_at IS NULL AND valid_until IS NULL
AND (dim_status IS NULL OR dim_status = 'active')
ORDER BY valid_from DESC NULLS LAST, confidence DESC, id DESC LIMIT 1`,
[sourceId, obs.entitySlug, dimension],
);
const current = cur.rows[0] as { id: number; value_hash: string; valid_from: string | null } | undefined;
if (current && current.value_hash === vh) {
const ins = await this.db.query(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, dimension, value, value_hash, dim_status,
confidence, source, source_markdown_slug, valid_from, valid_until, expired_at, consolidated_into)
VALUES ($1,$2,$3,'fact',$4,$5,$6,$7,$8,$9,$10,$10,COALESCE($11::timestamptz, now()),$12, now(), $13)
ON CONFLICT (source_id, entity_slug, dimension, value_hash, source_markdown_slug) WHERE dimension IS NOT NULL
DO NOTHING RETURNING id`,
[sourceId, obs.entitySlug, factText, visibility, dimension, obs.value, vh, status, conf, obs.source, validFrom, validUntil, current.id],
);
return ins.rows.length
? { action: 'corroborated', factId: Number((ins.rows[0] as { id: number }).id), supersededId: null }
: { action: 'noop', factId: null, supersededId: null };
}
const ins = await this.db.query(
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, dimension, value, value_hash, dim_status,
confidence, source, source_markdown_slug, valid_from, valid_until)
VALUES ($1,$2,$3,'fact',$4,$5,$6,$7,$8,$9,$10,$10,COALESCE($11::timestamptz, now()),$12)
ON CONFLICT (source_id, entity_slug, dimension, value_hash, source_markdown_slug) WHERE dimension IS NOT NULL
DO NOTHING RETURNING id`,
[sourceId, obs.entitySlug, factText, visibility, dimension, obs.value, vh, status, conf, obs.source, validFrom, validUntil],
);
if (!ins.rows.length) return { action: 'noop', factId: null, supersededId: null };
const newId = Number((ins.rows[0] as { id: number }).id);
let supersededId: number | null = null;
if (current && status === 'active') {
const forward = validFrom == null || current.valid_from == null
|| new Date(validFrom).getTime() >= new Date(current.valid_from).getTime();
if (forward) {
await this.db.query(
`UPDATE facts SET valid_until = COALESCE($1::timestamptz, now()), superseded_by = $2 WHERE id = $3 AND valid_until IS NULL`,
[validFrom, newId, current.id],
);
supersededId = current.id;
}
}
return { action: supersededId ? 'superseded_prior' : 'inserted', factId: newId, supersededId };
}
async getOntology(entitySlug: string, opts?: OntologyReadOpts): Promise<OntologyValue[]> {
const minConf = opts?.minConfidence ?? 0;
const includeQ = opts?.includeQuarantined ?? false;
const asof = opts?.asof ?? null;
const params: unknown[] = [entitySlug, asof, minConf, includeQ];
let scope: string;
if (opts?.sourceIds && opts.sourceIds.length) { params.push(opts.sourceIds); scope = `AND source_id = ANY($${params.length})`; }
else { params.push(opts?.sourceId ?? null); scope = `AND ($${params.length}::text IS NULL OR source_id = $${params.length})`; }
const r = await this.db.query(
`SELECT DISTINCT ON (dimension) dimension, value, confidence,
source_markdown_slug AS source, valid_from, valid_until AS valid_to,
COALESCE(dim_status,'active') AS status, id AS fact_id
FROM facts
WHERE entity_slug = $1 AND dimension IS NOT NULL AND expired_at IS NULL ${scope}
AND COALESCE(valid_from,'-infinity'::timestamptz) <= COALESCE($2::timestamptz, now())
AND COALESCE(valid_until,'infinity'::timestamptz) > COALESCE($2::timestamptz, now())
AND confidence >= $3
AND ($4::boolean OR dim_status IS NULL OR dim_status = 'active')
ORDER BY dimension, valid_from DESC NULLS LAST, confidence DESC, id DESC`,
params,
);
return r.rows.map((row) => ({ ...(row as OntologyValue), confidence: Number((row as { confidence: number }).confidence), fact_id: Number((row as { fact_id: number }).fact_id) }));
}
async discoverOntologyDimensions(opts?: { sourceId?: string; sourceIds?: string[] }): Promise<OntologyDimensionStat[]> {
const params: unknown[] = [];
let scope: string;
if (opts?.sourceIds && opts.sourceIds.length) { params.push(opts.sourceIds); scope = `AND source_id = ANY($${params.length})`; }
else { params.push(opts?.sourceId ?? null); scope = `AND ($${params.length}::text IS NULL OR source_id = $${params.length})`; }
const r = await this.db.query(
`SELECT dimension, count(DISTINCT entity_slug)::int AS entities, count(*)::int AS observations
FROM facts WHERE dimension IS NOT NULL AND expired_at IS NULL ${scope}
GROUP BY dimension ORDER BY entities DESC, dimension`,
params,
);
return r.rows.map((row) => {
const x = row as { dimension: string; entities: number; observations: number };
return { dimension: x.dimension, entities: Number(x.entities), observations: Number(x.observations) };
});
}
async findOntologyConflicts(opts?: { sourceId?: string; sourceIds?: string[]; minConfidence?: number }): Promise<OntologyConflict[]> {
const minConf = opts?.minConfidence ?? 0;
const params: unknown[] = [minConf];
let scope: string;
if (opts?.sourceIds && opts.sourceIds.length) { params.push(opts.sourceIds); scope = `AND source_id = ANY($${params.length})`; }
else { params.push(opts?.sourceId ?? null); scope = `AND ($${params.length}::text IS NULL OR source_id = $${params.length})`; }
const r = await this.db.query(
`WITH cur AS (
SELECT entity_slug, dimension, value, source_markdown_slug AS source, confidence, id AS fact_id
FROM facts WHERE dimension IS NOT NULL AND expired_at IS NULL AND valid_until IS NULL
AND (dim_status IS NULL OR dim_status = 'active') AND confidence >= $1 ${scope}
)
SELECT entity_slug, dimension,
json_agg(json_build_object('value', value, 'source', source, 'confidence', confidence, 'fact_id', fact_id)) AS values
FROM cur GROUP BY entity_slug, dimension
HAVING count(DISTINCT value) >= 2 AND count(DISTINCT source) >= 2
ORDER BY entity_slug, dimension`,
params,
);
return r.rows.map((row) => {
const x = row as { entity_slug: string; dimension: string; values: OntologyConflict['values'] };
return { entity_slug: x.entity_slug, dimension: x.dimension, values: typeof x.values === 'string' ? JSON.parse(x.values) : x.values };
});
}
// Raw data
async putRawData(
slug: string,
+6
View File
@@ -371,6 +371,9 @@ CREATE TABLE IF NOT EXISTS timeline_entries (
source TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
-- v0.42.x (Life Chronicle #2390): event-projection pointer. NULL for
-- ordinary rows. See src/schema.sql for the full rationale.
event_page_id INTEGER REFERENCES pages(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -380,6 +383,9 @@ CREATE INDEX IF NOT EXISTS idx_timeline_date ON timeline_entries(date);
-- v0.41.18.0 (codex finding #11): widened to include source so distinct
-- meeting provenance survives. Legacy rows have source='' (schema default).
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup ON timeline_entries(page_id, date, summary, source);
-- v0.42.x (Life Chronicle): event-projection lookup + dedup (partial).
CREATE INDEX IF NOT EXISTS idx_timeline_event_page ON timeline_entries(event_page_id) WHERE event_page_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_event_dedup ON timeline_entries(event_page_id, date) WHERE event_page_id IS NOT NULL;
-- ============================================================
-- page_versions: snapshot history
+254
View File
@@ -40,6 +40,9 @@ import type {
SearchResult, SearchOpts,
Link, GraphNode, GraphPath,
TimelineEntry, TimelineInput, TimelineOpts,
ChronicleTimelineRow, ChronicleTimelineOpts, LastSeenResult,
OntologyObservationInput, OntologyMergeResult, OntologyValue, OntologyDimensionStat,
OntologyConflict, OntologyReadOpts,
RawData,
PageVersion,
BrainStats, BrainHealth,
@@ -52,6 +55,7 @@ import type {
EnrichCandidatesOpts, EnrichCandidate,
} from './types.ts';
import { GBrainError, PAGE_SORT_SQL, ENRICH_ORDER_SQL } from './types.ts';
import { finalizeLastSeen } from './chronicle/last-seen.ts';
import { computeAnomaliesFromBuckets } from './cycle/anomaly.ts';
import * as db from './db.ts';
import { ConnectionManager } from './connection-manager.ts';
@@ -3440,6 +3444,256 @@ export class PostgresEngine implements BrainEngine {
return rows as unknown as TimelineEntry[];
}
// ── v0.42.x Life Chronicle (#2390) timeline reads ───────────────────────
// Shared shape: timeline_entries JOIN depth page (deleted_at IS NULL) LEFT
// JOIN event page; hide soft-deleted event projections (read-time, not just
// doctor); order by COALESCE(event effective_date, date) for intra-day
// sequence. Source scope: federated sourceIds[] > scalar sourceId > unscoped.
private chronicleSourceCond(opts?: { sourceId?: string; sourceIds?: string[] }) {
const sql = this.sql;
return opts?.sourceIds && opts.sourceIds.length > 0
? sql`AND p.source_id = ANY(${opts.sourceIds}::text[])`
: opts?.sourceId
? sql`AND p.source_id = ${opts.sourceId}`
: sql``;
}
async getTimelineForDate(date: string, opts?: ChronicleTimelineOpts): Promise<ChronicleTimelineRow[]> {
const sql = this.sql;
const limit = opts?.limit ?? 200;
// ISO week (date_trunc('week') → Monday) or the single day.
const lower = opts?.week ? sql`date_trunc('week', ${date}::date)::date` : sql`${date}::date`;
const upper = opts?.week ? sql`(date_trunc('week', ${date}::date) + interval '6 days')::date` : sql`${date}::date`;
const rows = await sql`
SELECT te.date::text AS date, te.summary, te.detail, te.source,
te.page_id, p.slug AS page_slug,
te.event_page_id, ep.slug AS event_slug,
ep.effective_date::text AS effective_date,
ep.frontmatter->'event'->>'kind' AS kind
FROM timeline_entries te
JOIN pages p ON p.id = te.page_id AND p.deleted_at IS NULL
LEFT JOIN pages ep ON ep.id = te.event_page_id
WHERE te.date >= ${lower} AND te.date <= ${upper}
AND (te.event_page_id IS NULL OR ep.deleted_at IS NULL)
${this.chronicleSourceCond(opts)}
ORDER BY COALESCE(ep.effective_date, te.date::timestamptz) ASC, te.id ASC
LIMIT ${limit}`;
return rows as unknown as ChronicleTimelineRow[];
}
async getSince(date: string, opts?: ChronicleTimelineOpts): Promise<ChronicleTimelineRow[]> {
const sql = this.sql;
const limit = opts?.limit ?? 200;
const kindCond = opts?.kind ? sql`AND ep.frontmatter->'event'->>'kind' = ${opts.kind}` : sql``;
const rows = await sql`
SELECT te.date::text AS date, te.summary, te.detail, te.source,
te.page_id, p.slug AS page_slug,
te.event_page_id, ep.slug AS event_slug,
ep.effective_date::text AS effective_date,
ep.frontmatter->'event'->>'kind' AS kind
FROM timeline_entries te
JOIN pages p ON p.id = te.page_id AND p.deleted_at IS NULL
LEFT JOIN pages ep ON ep.id = te.event_page_id
WHERE te.date >= ${date}::date
AND (te.event_page_id IS NULL OR ep.deleted_at IS NULL)
${kindCond}
${this.chronicleSourceCond(opts)}
ORDER BY COALESCE(ep.effective_date, te.date::timestamptz) ASC, te.id ASC
LIMIT ${limit}`;
return rows as unknown as ChronicleTimelineRow[];
}
async getOnThisDay(opts?: { date?: string; limit?: number; sourceId?: string; sourceIds?: string[] }): Promise<ChronicleTimelineRow[]> {
const sql = this.sql;
const limit = opts?.limit ?? 50;
const target = opts?.date ? sql`${opts.date}::date` : sql`current_date`;
const rows = await sql`
SELECT te.date::text AS date, te.summary, te.detail, te.source,
te.page_id, p.slug AS page_slug,
te.event_page_id, ep.slug AS event_slug,
ep.effective_date::text AS effective_date,
ep.frontmatter->'event'->>'kind' AS kind
FROM timeline_entries te
JOIN pages p ON p.id = te.page_id AND p.deleted_at IS NULL
LEFT JOIN pages ep ON ep.id = te.event_page_id
WHERE EXTRACT(MONTH FROM te.date) = EXTRACT(MONTH FROM ${target})
AND EXTRACT(DAY FROM te.date) = EXTRACT(DAY FROM ${target})
AND te.date < ${target}
AND (te.event_page_id IS NULL OR ep.deleted_at IS NULL)
${this.chronicleSourceCond(opts)}
ORDER BY te.date DESC, te.id ASC
LIMIT ${limit}`;
return rows as unknown as ChronicleTimelineRow[];
}
async getLastSeen(entitySlug: string, opts?: { asof?: string; sourceId?: string; sourceIds?: string[] }): Promise<LastSeenResult> {
const sql = this.sql;
// "Seen" = the entity's own page has a timeline row, OR an event's `who`
// array references the entity (exact slug or wikilink-substring match).
const rows = await sql`
SELECT te.date::text AS last_date, ep.slug AS last_event_slug
FROM timeline_entries te
JOIN pages p ON p.id = te.page_id AND p.deleted_at IS NULL
LEFT JOIN pages ep ON ep.id = te.event_page_id
WHERE (te.event_page_id IS NULL OR ep.deleted_at IS NULL)
AND (
p.slug = ${entitySlug}
OR (ep.id IS NOT NULL AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(
CASE WHEN jsonb_typeof(ep.frontmatter->'event'->'who') = 'array'
THEN ep.frontmatter->'event'->'who' ELSE '[]'::jsonb END
) AS w(name)
WHERE w.name = ${entitySlug} OR w.name LIKE ${'%' + entitySlug + '%'}
))
)
${this.chronicleSourceCond(opts)}
ORDER BY COALESCE(ep.effective_date, te.date::timestamptz) DESC, te.id DESC
LIMIT 1`;
const row = rows[0] as { last_date?: string; last_event_slug?: string } | undefined;
return finalizeLastSeen(entitySlug, row?.last_date ?? null, row?.last_event_slug ?? null, opts?.asof);
}
async upsertEventProjection(opts: { depthSlug: string; eventSlug: string; date: string; summary: string; detail?: string; sourceId?: string }): Promise<{ projected: boolean }> {
const sql = this.sql;
const sourceId = opts.sourceId ?? 'default';
const rows = await sql`
INSERT INTO timeline_entries (page_id, date, source, summary, detail, event_page_id)
SELECT dp.id, ${opts.date}::date, ${'life-chronicle:event:' + opts.eventSlug}, ${opts.summary}, ${opts.detail ?? ''}, ep.id
FROM pages dp, pages ep
WHERE dp.slug = ${opts.depthSlug} AND dp.source_id = ${sourceId}
AND ep.slug = ${opts.eventSlug} AND ep.source_id = ${sourceId}
ON CONFLICT (event_page_id, date) WHERE event_page_id IS NOT NULL
DO UPDATE SET summary = EXCLUDED.summary, detail = EXCLUDED.detail,
page_id = EXCLUDED.page_id, source = EXCLUDED.source
RETURNING id`;
return { projected: rows.length > 0 };
}
async mergeOntologyFact(obs: OntologyObservationInput): Promise<OntologyMergeResult> {
const sql = this.sql;
const sourceId = obs.sourceId ?? 'default';
const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts');
const dimension = normalizeDimension(obs.dimension);
const vh = valueHash(obs.value);
const conf = obs.confidence ?? 0.7;
const status = obs.status ?? (isNovelDimension(dimension) ? 'quarantined' : 'active');
const visibility = obs.visibility ?? 'private';
const validFrom = obs.validFrom ?? null;
const validUntil = obs.validTo ?? null;
const factText = `${dimension}: ${obs.value}`;
// The "current open" row is the open-ended one (valid_until IS NULL) that
// hasn't been retracted (expired_at IS NULL). Supersession closes its
// valid_until rather than expiring it, so --asof time-travel still sees it.
const cur = await sql<{ id: number; value_hash: string; valid_from: string | null }[]>`
SELECT id, value_hash, valid_from FROM facts
WHERE source_id = ${sourceId} AND entity_slug = ${obs.entitySlug}
AND dimension = ${dimension} AND expired_at IS NULL AND valid_until IS NULL
AND (dim_status IS NULL OR dim_status = 'active')
ORDER BY valid_from DESC NULLS LAST, confidence DESC, id DESC
LIMIT 1`;
const current = cur[0];
if (current && current.value_hash === vh) {
// Same value → corroboration (or exact dup → noop via the dedup unique).
const ins = await sql<{ id: number }[]>`
INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, dimension, value, value_hash, dim_status,
confidence, source, source_markdown_slug, valid_from, valid_until, expired_at, consolidated_into)
VALUES (${sourceId}, ${obs.entitySlug}, ${factText}, 'fact', ${visibility}, ${dimension}, ${obs.value}, ${vh}, ${status},
${conf}, ${obs.source}, ${obs.source}, COALESCE(${validFrom}::timestamptz, now()), ${validUntil}, now(), ${current.id})
ON CONFLICT (source_id, entity_slug, dimension, value_hash, source_markdown_slug) WHERE dimension IS NOT NULL
DO NOTHING
RETURNING id`;
return ins.length
? { action: 'corroborated', factId: Number(ins[0].id), supersededId: null }
: { action: 'noop', factId: null, supersededId: null };
}
const ins = await sql<{ id: number }[]>`
INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, dimension, value, value_hash, dim_status,
confidence, source, source_markdown_slug, valid_from, valid_until)
VALUES (${sourceId}, ${obs.entitySlug}, ${factText}, 'fact', ${visibility}, ${dimension}, ${obs.value}, ${vh}, ${status},
${conf}, ${obs.source}, ${obs.source}, COALESCE(${validFrom}::timestamptz, now()), ${validUntil})
ON CONFLICT (source_id, entity_slug, dimension, value_hash, source_markdown_slug) WHERE dimension IS NOT NULL
DO NOTHING
RETURNING id`;
if (!ins.length) return { action: 'noop', factId: null, supersededId: null };
const newId = Number(ins[0].id);
let supersededId: number | null = null;
if (current && status === 'active') {
const forward = validFrom == null || current.valid_from == null
|| new Date(validFrom).getTime() >= new Date(current.valid_from).getTime();
if (forward) {
// Close the prior row's valid window at the new fact's valid_from (or now()).
await sql`UPDATE facts SET valid_until = COALESCE(${validFrom}::timestamptz, now()), superseded_by = ${newId}
WHERE id = ${current.id} AND valid_until IS NULL`;
supersededId = current.id;
}
}
return { action: supersededId ? 'superseded_prior' : 'inserted', factId: newId, supersededId };
}
async getOntology(entitySlug: string, opts?: OntologyReadOpts): Promise<OntologyValue[]> {
const sql = this.sql;
const minConf = opts?.minConfidence ?? 0;
const includeQ = opts?.includeQuarantined ?? false;
const asof = opts?.asof ?? null;
const scope = opts?.sourceIds && opts.sourceIds.length
? sql`AND source_id = ANY(${opts.sourceIds})`
: sql`AND (${opts?.sourceId ?? null}::text IS NULL OR source_id = ${opts?.sourceId ?? null})`;
const rows = await sql<OntologyValue[]>`
SELECT DISTINCT ON (dimension)
dimension, value, confidence,
source_markdown_slug AS source, valid_from, valid_until AS valid_to,
COALESCE(dim_status, 'active') AS status, id AS fact_id
FROM facts
WHERE entity_slug = ${entitySlug} AND dimension IS NOT NULL AND expired_at IS NULL
${scope}
AND COALESCE(valid_from, '-infinity'::timestamptz) <= COALESCE(${asof}::timestamptz, now())
AND COALESCE(valid_until, 'infinity'::timestamptz) > COALESCE(${asof}::timestamptz, now())
AND confidence >= ${minConf}
AND (${includeQ}::boolean OR dim_status IS NULL OR dim_status = 'active')
ORDER BY dimension, valid_from DESC NULLS LAST, confidence DESC, id DESC`;
return rows.map((r) => ({ ...r, confidence: Number(r.confidence), fact_id: Number(r.fact_id) }));
}
async discoverOntologyDimensions(opts?: { sourceId?: string; sourceIds?: string[] }): Promise<OntologyDimensionStat[]> {
const sql = this.sql;
const scope = opts?.sourceIds && opts.sourceIds.length
? sql`AND source_id = ANY(${opts.sourceIds})`
: sql`AND (${opts?.sourceId ?? null}::text IS NULL OR source_id = ${opts?.sourceId ?? null})`;
const rows = await sql<{ dimension: string; entities: number; observations: number }[]>`
SELECT dimension, count(DISTINCT entity_slug)::int AS entities, count(*)::int AS observations
FROM facts
WHERE dimension IS NOT NULL AND expired_at IS NULL ${scope}
GROUP BY dimension ORDER BY entities DESC, dimension`;
return rows.map((r) => ({ dimension: r.dimension, entities: Number(r.entities), observations: Number(r.observations) }));
}
async findOntologyConflicts(opts?: { sourceId?: string; sourceIds?: string[]; minConfidence?: number }): Promise<OntologyConflict[]> {
const sql = this.sql;
const minConf = opts?.minConfidence ?? 0;
const scope = opts?.sourceIds && opts.sourceIds.length
? sql`AND source_id = ANY(${opts.sourceIds})`
: sql`AND (${opts?.sourceId ?? null}::text IS NULL OR source_id = ${opts?.sourceId ?? null})`;
const rows = await sql<{ entity_slug: string; dimension: string; values: OntologyConflict['values'] }[]>`
WITH cur AS (
SELECT entity_slug, dimension, value, source_markdown_slug AS source, confidence, id AS fact_id
FROM facts
WHERE dimension IS NOT NULL AND expired_at IS NULL AND valid_until IS NULL
AND (dim_status IS NULL OR dim_status = 'active')
AND confidence >= ${minConf} ${scope}
)
SELECT entity_slug, dimension,
json_agg(json_build_object('value', value, 'source', source, 'confidence', confidence, 'fact_id', fact_id)) AS values
FROM cur
GROUP BY entity_slug, dimension
HAVING count(DISTINCT value) >= 2 AND count(DISTINCT source) >= 2
ORDER BY entity_slug, dimension`;
return rows.map((r) => ({ entity_slug: r.entity_slug, dimension: r.dimension, values: r.values }));
}
// Raw data
async putRawData(
slug: string,
+11
View File
@@ -540,6 +540,13 @@ CREATE TABLE IF NOT EXISTS timeline_entries (
source TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
-- v0.42.x (Life Chronicle #2390): when this row is the date-index projection
-- of a \`type:event\` page, event_page_id points at that event page; page_id
-- stays the depth/meeting page. NULL for ordinary timeline entries. Reads
-- hide rows whose event page is soft-deleted; the partial unique index below
-- keys dedup on (event_page_id, date) so re-extraction with a changed summary
-- updates the row instead of double-inserting.
event_page_id INTEGER REFERENCES pages(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -549,6 +556,10 @@ CREATE INDEX IF NOT EXISTS idx_timeline_date ON timeline_entries(date);
-- include \`source\` so distinct meeting provenance survives. Legacy rows
-- have source='' (schema default) so legacy dedup behavior is preserved.
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup ON timeline_entries(page_id, date, summary, source);
-- v0.42.x (Life Chronicle): event-projection lookup + dedup. Partial
-- (event_page_id IS NOT NULL) so ordinary timeline rows are unaffected.
CREATE INDEX IF NOT EXISTS idx_timeline_event_page ON timeline_entries(event_page_id) WHERE event_page_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_event_dedup ON timeline_entries(event_page_id, date) WHERE event_page_id IS NOT NULL;
-- ============================================================
-- page_versions: snapshot history for compiled_truth
@@ -302,6 +302,23 @@ page_types:
extractable: true
expert_routing: false
# v0.42.x — Life Chronicle (#2390): timeline events + thought diary.
- name: event
primitive: temporal
path_prefixes:
- life/events/
aliases: []
extractable: false
expert_routing: false
- name: diary
primitive: temporal
path_prefixes:
- life/diary/
aliases: []
extractable: false
expert_routing: false
link_types:
- name: partner_of
inverse: partner_of
@@ -262,6 +262,27 @@ page_types:
extractable: false
expert_routing: false
# v0.42.x — Life Chronicle (#2390). `event` = timeline atom
# (when·where·who·what) backlinking to a depth page; `diary` = timestamped
# first-person interiority. Both `temporal` (events through time, not entity
# surfaces). `extractable: false`: events are already the atomic form, and
# diary interiority is NEVER mined into the facts table (privacy/consent).
- name: event
primitive: temporal
path_prefixes:
- life/events/
aliases: []
extractable: false
expert_routing: false
- name: diary
primitive: temporal
path_prefixes:
- life/diary/
aliases: []
extractable: false
expert_routing: false
# Types with no path prefix (set explicitly via frontmatter).
- name: code
primitive: media
+32
View File
@@ -334,6 +334,32 @@ export function applyTitleBoost(
/** Default title-phrase boost multiplier (mode-overridable via `title_boost`). */
export const DEFAULT_TITLE_BOOST = 1.25;
/**
* v0.42.x Life Chronicle (#2390) E1 temporal recall arm. On temporal queries
* (the caller gates this on recency !== 'off'), give chronicle `event`/`diary`
* pages a bounded boost so the timeline surfaces for "what happened…" / "when
* did" queries ambient temporality without a separate recall arm. Bounded
* ([1.0, 1.25]) + floor-gated like the other metadata stages, so it can't
* leapfrog a strong primary hit. Mutate-in-place; caller re-sorts. Pure no-op
* for non-chronicle results. NOT called on non-temporal queries (recency='off'),
* so ordinary search is bit-for-bit unchanged.
*/
export function applyChronicleTypeBoost(
results: SearchResult[],
strength: 'on' | 'strong',
floorThreshold?: number,
): void {
const factor = strength === 'strong' ? 1.25 : 1.15;
for (const r of results) {
if (!Number.isFinite(r.score)) continue;
if (floorThreshold !== undefined && r.score < floorThreshold) continue;
if (r.type === 'event' || r.type === 'diary') {
r.score *= factor;
r.chronicle_boost = factor;
}
}
}
/**
* v0.29.1 runPostFusionStages: wrap backlink + salience + recency in a
* single stage that fires from EVERY hybridSearch return path (codex
@@ -472,6 +498,12 @@ export async function runPostFusionStages(
} catch {
// Non-fatal.
}
// v0.42.x — Life Chronicle (#2390) E1: chronicle event/diary type boost.
// Gated INSIDE the recency!=off branch so it fires ONLY on temporal queries;
// non-temporal search never reaches here → bit-for-bit unchanged. Shares the
// floor threshold so it can't leapfrog a strong primary hit.
applyChronicleTypeBoost(results, opts.recency, floorThreshold);
}
// T2 — title-phrase boost. Runs after the metadata stages, before graph
+94
View File
@@ -58,6 +58,12 @@ export const ALL_PAGE_TYPES: readonly string[] = [
// loops via the dream_generated:true + type:extract_receipt belt-and-
// suspenders pattern per plan D-EXTRACT-19.
'extract_receipt',
// v0.42.x — Life Chronicle (#2390). `event` = timeline atom
// (when·where·who·what), lives under life/events/; `diary` = first-person
// interiority, lives under life/diary/. Both temporal-primitive,
// extractable:false (events are one-line atoms; diary is private interiority
// never mined into the facts table). Pack entries in gbrain-base.yaml.
'event', 'diary',
] as const;
/**
@@ -755,6 +761,8 @@ export interface SearchResult {
salience_boost?: number;
/** Multiplier applied by applyRecencyBoost. */
recency_boost?: number;
/** v0.42.x (#2390) — multiplier applied by applyChronicleTypeBoost (event/diary on temporal queries). */
chronicle_boost?: number;
/** Multiplier applied by applyExactMatchBoost. */
exact_match_boost?: number;
/** Multiplier applied by applyGraphSignals (adjacency hit). */
@@ -1279,6 +1287,92 @@ export interface TimelineOpts {
sourceIds?: string[];
}
// v0.42.x — Life Chronicle (#2390): timeline read surfaces.
// A ChronicleTimelineRow is a timeline_entries projection JOINed to its depth
// page (always) and, when it is an event projection, to the event page (for
// intra-day order via effective_date, the backlink slug, and the event kind).
export interface ChronicleTimelineRow {
date: string; // YYYY-MM-DD (the projection date, pinned tz)
summary: string;
detail: string;
source: string;
page_id: number; // depth page id (the rich page the row belongs to)
page_slug: string; // depth page slug (backlink target)
event_page_id: number | null;
event_slug: string | null; // the type:event page, when this is an event projection
effective_date: string | null; // event page effective_date — drives intra-day order
kind: string | null; // event.kind from the event page frontmatter
}
export interface ChronicleTimelineOpts {
/** getTimelineForDate: expand to the ISO week (MonSun) containing `date`. */
week?: boolean;
/** getSince: filter event projections by `event.kind`. */
kind?: string;
limit?: number;
/** Source scope (scalar). Federated `sourceIds` takes precedence when set. */
sourceId?: string;
sourceIds?: string[];
}
export interface LastSeenResult {
entity_slug: string;
/** Most recent timeline date the entity appears in (own page or an event's who). NULL if never. */
last_date: string | null;
/** The most recent event page slug involving the entity, if the last hit was an event. */
last_event_slug: string | null;
/** Whole days between last_date and `asof` (default today). NULL when last_date is NULL. */
days_ago: number | null;
}
// v0.42.x — Life Chronicle (#2390) per-entity ontology (rides the `facts` table).
// An observation is a sourced, confidence-weighted, bi-temporal claim that an
// entity has dimension=value (e.g. role=advisor). Supersession/validity/visibility
// are inherited from facts columns.
export interface OntologyObservationInput {
entitySlug: string;
dimension: string;
value: string;
/** 0..1; default 0.7. */
confidence?: number;
/** Provenance — written to facts.source_markdown_slug (the dedup key + retraction key). */
source: string;
validFrom?: string | null; // ISO; null = -infinity
validTo?: string | null; // ISO; null = open/current
visibility?: 'private' | 'world';
/** Novel/LLM-proposed dimensions land 'quarantined' (excluded from current resolution). */
status?: 'active' | 'quarantined';
sourceId?: string;
}
export interface OntologyMergeResult {
action: 'inserted' | 'corroborated' | 'superseded_prior' | 'noop';
factId: number | null;
supersededId: number | null;
}
export interface OntologyValue {
dimension: string;
value: string;
confidence: number;
source: string | null;
valid_from: string | null;
valid_to: string | null;
status: string; // 'active' | 'quarantined'
fact_id: number;
}
export interface OntologyDimensionStat { dimension: string; entities: number; observations: number }
export interface OntologyConflict {
entity_slug: string;
dimension: string;
values: { value: string; source: string | null; confidence: number; fact_id: number }[];
}
export interface OntologyReadOpts {
asof?: string;
minConfidence?: number;
includeQuarantined?: boolean;
sourceId?: string;
sourceIds?: string[];
}
// Raw data
export interface RawData {
source: string;
+89
View File
@@ -0,0 +1,89 @@
// v0.42.x — Life Chronicle (#2390) feature eval (Phase A.9), the PRIMARY proof.
//
// Deterministic + CI-safe (no LLM): builds a small synthetic corpus with a KNOWN
// gold chronology + a planted ontology supersession + a planted conflict, then
// asserts the chronicle layer answers the temporal/longitudinal questions
// correctly. This is the "does the feature deliver value" bar from the North
// Star — chronology reconstruction, last-seen, supersession time-travel,
// contradiction surfacing, source isolation — measured against gold, not vibes.
//
// The OFF baseline (raw meeting pages) structurally CANNOT order intra-day
// events or time-travel ontology; the ON path (chronicle ops) does. We score
// the ON path against gold; a perfect score is the proof the ops are correct.
import type { BrainEngine } from '../../core/engine.ts';
import { runChronicleExtract, type ChronicleJudge } from '../../core/chronicle/extract-events.ts';
export interface ChronicleEvalTask { id: string; passed: boolean; detail: string }
export interface ChronicleEvalResult {
tasks: ChronicleEvalTask[];
passed: number;
total: number;
score: number; // passed / total, [0,1]
}
const ALICE = 'people/alice-example';
const BOB = 'people/bob-example';
/** Seed the synthetic corpus into a fresh engine (schema already initialized). */
export async function seedChronicleEvalCorpus(engine: BrainEngine): Promise<void> {
// Two same-day meetings → two events, gold intra-day order AM then PM.
await engine.putPage('meetings/2026-03-02-am', {
type: 'meeting', title: 'Morning standup', compiled_truth: 'x'.repeat(120),
frontmatter: { attendees: [ALICE] }, effective_date: new Date('2026-03-02T09:00:00Z'),
});
await engine.putPage('meetings/2026-03-02-pm', {
type: 'meeting', title: 'Afternoon review', compiled_truth: 'y'.repeat(120),
frontmatter: { attendees: [ALICE] }, effective_date: new Date('2026-03-02T15:00:00Z'),
});
const judge = (when: string, what: string): ChronicleJudge => async () => ({
events: [{ when, who: [ALICE], what, kind: 'meeting' }],
});
await runChronicleExtract(engine, { slug: 'meetings/2026-03-02-am', judge: judge('2026-03-02T09:00:00Z', 'Morning standup') });
await runChronicleExtract(engine, { slug: 'meetings/2026-03-02-pm', judge: judge('2026-03-02T15:00:00Z', 'Afternoon review') });
// Ontology: alice was a founder, became an advisor (forward supersession).
await engine.mergeOntologyFact({ entitySlug: ALICE, dimension: 'role', value: 'founder', source: 'meetings/2024-01-10', validFrom: '2024-01-01' });
await engine.mergeOntologyFact({ entitySlug: ALICE, dimension: 'role', value: 'advisor', source: 'meetings/2026-03-02-pm', validFrom: '2026-03-01' });
// Planted conflict: bob has two backdated-vs-forward open values from 2 sources.
await engine.mergeOntologyFact({ entitySlug: BOB, dimension: 'role', value: 'advisor', source: 'meetings/a', validFrom: '2026-05-01' });
await engine.mergeOntologyFact({ entitySlug: BOB, dimension: 'role', value: 'founder', source: 'meetings/b', validFrom: '2026-01-01' });
}
export async function runChronicleEval(engine: BrainEngine): Promise<ChronicleEvalResult> {
await seedChronicleEvalCorpus(engine);
const tasks: ChronicleEvalTask[] = [];
const check = (id: string, ok: boolean, detail: string) => tasks.push({ id, passed: ok, detail });
// 1. Day reconstruction in correct intra-day order.
const day = await engine.getTimelineForDate('2026-03-02', { sourceId: 'default' });
const order = day.map((r) => r.summary);
check('day_order', JSON.stringify(order) === JSON.stringify(['Morning standup', 'Afternoon review']),
`order=${JSON.stringify(order)}`);
// 2. Last-seen exact date.
const ls = await engine.getLastSeen(ALICE, { sourceId: 'default' });
check('last_seen', ls.last_date === '2026-03-02', `last_date=${ls.last_date}`);
// 3. Supersession: current value is the new one.
const now = await engine.getOntology(ALICE, { sourceId: 'default' });
const roleNow = now.find((v) => v.dimension === 'role')?.value;
check('supersession_now', roleNow === 'advisor', `role_now=${roleNow}`);
// 4. Valid-time travel: as-of before the change returns the prior value.
const past = await engine.getOntology(ALICE, { sourceId: 'default', asof: '2025-01-01' });
const rolePast = past.find((v) => v.dimension === 'role')?.value;
check('supersession_asof', rolePast === 'founder', `role_asof=${rolePast}`);
// 5. Contradiction surfaced (genuine disagreement, not supersession).
const conflicts = await engine.findOntologyConflicts({ sourceId: 'default' });
check('conflict_surfaced', conflicts.some((c) => c.entity_slug === BOB && c.dimension === 'role'),
`conflicts=${conflicts.length}`);
// 6. Source isolation: querying another source leaks nothing.
const otherSource = await engine.getOntology(ALICE, { sourceId: 'nonexistent-source' });
check('source_isolation', otherSource.length === 0, `leaked=${otherSource.length}`);
const passed = tasks.filter((t) => t.passed).length;
return { tasks, passed, total: tasks.length, score: tasks.length ? passed / tasks.length : 0 };
}
+11
View File
@@ -536,6 +536,13 @@ CREATE TABLE IF NOT EXISTS timeline_entries (
source TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
-- v0.42.x (Life Chronicle #2390): when this row is the date-index projection
-- of a `type:event` page, event_page_id points at that event page; page_id
-- stays the depth/meeting page. NULL for ordinary timeline entries. Reads
-- hide rows whose event page is soft-deleted; the partial unique index below
-- keys dedup on (event_page_id, date) so re-extraction with a changed summary
-- updates the row instead of double-inserting.
event_page_id INTEGER REFERENCES pages(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -545,6 +552,10 @@ CREATE INDEX IF NOT EXISTS idx_timeline_date ON timeline_entries(date);
-- include `source` so distinct meeting provenance survives. Legacy rows
-- have source='' (schema default) so legacy dedup behavior is preserved.
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup ON timeline_entries(page_id, date, summary, source);
-- v0.42.x (Life Chronicle): event-projection lookup + dedup. Partial
-- (event_page_id IS NOT NULL) so ordinary timeline rows are unaffected.
CREATE INDEX IF NOT EXISTS idx_timeline_event_page ON timeline_entries(event_page_id) WHERE event_page_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_event_dedup ON timeline_entries(event_page_id, date) WHERE event_page_id IS NOT NULL;
-- ============================================================
-- page_versions: snapshot history for compiled_truth
+47
View File
@@ -0,0 +1,47 @@
/**
* v0.42.x Life Chronicle (#2390) advisor collector (Phase A.7).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { collectChronicle } from '../src/core/advisor/collect-chronicle.ts';
import type { AdvisorContext } from '../src/core/advisor/types.ts';
let engine: PGLiteEngine;
const ctx = (): AdvisorContext => ({ engine, remote: false } as unknown as AdvisorContext);
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
});
afterAll(async () => { await engine.disconnect(); });
beforeEach(async () => {
await engine.executeRaw('DELETE FROM timeline_entries');
await engine.executeRaw('DELETE FROM facts');
await engine.executeRaw(`DELETE FROM pages WHERE type = 'meeting'`);
});
describe('collectChronicle', () => {
test('flags recent meetings with no timeline coverage', async () => {
await engine.putPage('meetings/recent', { type: 'meeting', title: 'recent', compiled_truth: 'x'.repeat(120) });
const findings = await collectChronicle.collect(ctx());
const gap = findings.find((f) => f.id === 'chronicle_coverage_gap');
expect(gap).toBeTruthy();
expect(gap!.severity).toBe('info');
expect(gap!.fix.command_argv).toEqual(['gbrain', 'chronicle-backfill']);
});
test('flags unresolved ontology conflicts', async () => {
await engine.mergeOntologyFact({ entitySlug: 'people/x', dimension: 'role', value: 'advisor', source: 'm/a', validFrom: '2026-05-01' });
await engine.mergeOntologyFact({ entitySlug: 'people/x', dimension: 'role', value: 'founder', source: 'm/b', validFrom: '2026-01-01' });
const findings = await collectChronicle.collect(ctx());
const conflict = findings.find((f) => f.id === 'ontology_conflicts');
expect(conflict).toBeTruthy();
expect(conflict!.severity).toBe('warn');
});
test('no findings on a clean brain', async () => {
const findings = await collectChronicle.collect(ctx());
expect(findings).toHaveLength(0);
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* v0.42.x Life Chronicle (#2390) backfill op (Phase A.8).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { operationsByName } from '../src/core/operations.ts';
import type { OperationContext } from '../src/core/operations.ts';
let engine: PGLiteEngine;
const mkCtx = (): OperationContext => ({ engine, remote: false, sourceId: 'default' } as unknown as OperationContext);
const LONG = 'B'.repeat(120);
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
});
afterAll(async () => { await engine.disconnect(); });
beforeEach(async () => {
await engine.executeRaw(`DELETE FROM minion_jobs WHERE name = 'chronicle_extract'`);
await engine.executeRaw(`DELETE FROM pages WHERE type IN ('meeting','diary')`);
});
describe('chronicle_backfill op', () => {
test('dry-run counts eligible meetings without enqueuing', async () => {
await engine.putPage('meetings/m1', { type: 'meeting', title: 'm1', compiled_truth: LONG });
await engine.putPage('meetings/m2', { type: 'meeting', title: 'm2', compiled_truth: LONG });
await engine.putPage('life/diary/d1', { type: 'diary', title: 'd1', compiled_truth: LONG }); // excluded
const r = await operationsByName.chronicle_backfill.handler(mkCtx(), { dry_run: true }) as { eligible: number; enqueued: number };
expect(r.eligible).toBe(2);
expect(r.enqueued).toBe(0);
const jobs = await engine.executeRaw<{ n: number }>(`SELECT count(*)::int AS n FROM minion_jobs WHERE name='chronicle_extract'`);
expect(Number(jobs[0].n)).toBe(0);
});
test('enqueues one chronicle_extract per eligible meeting', async () => {
await engine.putPage('meetings/m1', { type: 'meeting', title: 'm1', compiled_truth: LONG });
await engine.putPage('meetings/m2', { type: 'meeting', title: 'm2', compiled_truth: LONG });
const r = await operationsByName.chronicle_backfill.handler(mkCtx(), {}) as { eligible: number; enqueued: number; errors: unknown[] };
expect(r.eligible).toBe(2);
expect(r.enqueued).toBe(2);
expect(r.errors).toHaveLength(0);
const jobs = await engine.executeRaw<{ n: number }>(`SELECT count(*)::int AS n FROM minion_jobs WHERE name='chronicle_extract'`);
expect(Number(jobs[0].n)).toBe(2);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* v0.42.x Life Chronicle (#2390) quick-capture (Phase A.5).
* Pure-function tests for the type-routed default slug and the --type event
* frontmatter sugar. No engine needed.
*/
import { describe, test, expect } from 'bun:test';
import matter from 'gray-matter';
import { __testing } from '../src/commands/capture.ts';
const { defaultSlug, mergeCaptureFrontmatter } = __testing;
const FIXED = new Date('2026-06-18T00:00:00Z');
describe('defaultSlug type routing', () => {
test('diary → life/diary/', () => {
expect(defaultSlug('a private thought', FIXED, 'diary')).toMatch(/^life\/diary\/2026-06-18-[0-9a-f]{8}$/);
});
test('event → life/events/', () => {
expect(defaultSlug('an event', FIXED, 'event')).toMatch(/^life\/events\/2026-06-18-[0-9a-f]{8}$/);
});
test('note / default → inbox/', () => {
expect(defaultSlug('a note', FIXED, 'note')).toMatch(/^inbox\/2026-06-18-[0-9a-f]{8}$/);
expect(defaultSlug('a note', FIXED)).toMatch(/^inbox\//);
});
});
describe('--type event frontmatter sugar', () => {
test('builds the event block from flags', () => {
const out = mergeCaptureFrontmatter('Sarah committed to Q3', {
type: 'event', who: 'people/sarah-chen,people/bob', what: 'committed to Q3',
where: 'Zoom', kind: 'commitment', depth: 'meetings/2026-06-18-sync',
});
const fm = matter(out).data as Record<string, any>;
expect(fm.type).toBe('event');
expect(fm.event.who).toEqual(['people/sarah-chen', 'people/bob']);
expect(fm.event.what).toBe('committed to Q3');
expect(fm.event.kind).toBe('commitment');
expect(fm.event.depth).toBe('meetings/2026-06-18-sync');
});
test('non-event capture gets no event block', () => {
const fm = matter(mergeCaptureFrontmatter('just a note', { type: 'note' })).data as Record<string, any>;
expect(fm.event).toBeUndefined();
});
test('diary capture is typed diary, no event block', () => {
const fm = matter(mergeCaptureFrontmatter('felt uncertain today', { type: 'diary' })).data as Record<string, any>;
expect(fm.type).toBe('diary');
expect(fm.event).toBeUndefined();
});
test('user-declared event keys win over flags (per-key merge)', () => {
const body = `---\ntype: event\nevent:\n kind: decision\n---\nbody`;
const fm = matter(mergeCaptureFrontmatter(body, { type: 'event', kind: 'commitment', what: 'x' })).data as Record<string, any>;
expect(fm.event.kind).toBe('decision'); // user wins
expect(fm.event.what).toBe('x'); // flag fills the gap
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* v0.42.x Life Chronicle (#2390) agent-context loader (Phase B.12).
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { loadChronicleContext } from '../src/core/context/chronicle-context.ts';
let engine: PGLiteEngine;
const SARAH = 'people/sarah-chen';
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
// An entity with a public role + a diary-sourced affect.
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'advisor', source: 'meetings/a' });
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'affect', value: 'anxious', source: 'life/diary/2026-06-18', status: 'active' });
});
afterAll(async () => { await engine.disconnect(); });
describe('loadChronicleContext', () => {
test('bundles recent timeline + resolved ontology for named entities', async () => {
const ctx = await loadChronicleContext(engine, { days: 30, entities: [SARAH], remote: false });
expect(ctx.since).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(Array.isArray(ctx.recent_timeline)).toBe(true);
expect(ctx.ontologies[SARAH].map((v) => v.dimension).sort()).toEqual(['affect', 'role']);
});
test('redacts diary-sourced ontology for remote callers', async () => {
const ctx = await loadChronicleContext(engine, { entities: [SARAH], remote: true });
const dims = (ctx.ontologies[SARAH] ?? []).map((v) => v.dimension);
expect(dims).toContain('role');
expect(dims).not.toContain('affect'); // diary-sourced → redacted remotely
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* v0.42.x Life Chronicle (#2390) delight bundle (Phase A.6): on-this-day + narrative.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { renderTimelineNarrative } from '../src/core/chronicle/narrative.ts';
import { operationsByName } from '../src/core/operations.ts';
import type { OperationContext } from '../src/core/operations.ts';
import type { ChronicleTimelineRow } from '../src/core/types.ts';
let engine: PGLiteEngine;
const mkCtx = (): OperationContext => ({ engine, remote: false, sourceId: 'default' } as unknown as OperationContext);
async function seedEntry(slug: string, date: string, summary: string) {
await engine.executeRaw(
`INSERT INTO timeline_entries (page_id, date, source, summary)
SELECT id, $1::date, 'test', $2 FROM pages WHERE slug = $3 AND source_id = 'default'`,
[date, summary, slug],
);
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
await engine.putPage('meetings/m', { type: 'meeting', title: 'm', compiled_truth: 'x'.repeat(120) });
await seedEntry('meetings/m', '2024-06-15', 'June 15 two years ago');
await seedEntry('meetings/m', '2025-06-15', 'June 15 last year');
await seedEntry('meetings/m', '2023-01-01', 'unrelated day');
});
afterAll(async () => { await engine.disconnect(); });
describe('renderTimelineNarrative', () => {
test('renders prose grouped by date', () => {
const rows = [
{ date: '2026-06-18', summary: 'Project sync', kind: 'meeting' },
{ date: '2026-06-18', summary: 'Vendor call', kind: 'call' },
] as ChronicleTimelineRow[];
const out = renderTimelineNarrative(rows);
expect(out).toContain('2026-06-18 — 2 events');
expect(out).toContain('Project sync (meeting)');
});
test('empty window', () => {
expect(renderTimelineNarrative([])).toBe('No events in this window.');
});
});
describe('getOnThisDay', () => {
test('returns same month-day in prior years, excludes other days', async () => {
const rows = await engine.getOnThisDay({ date: '2026-06-15' });
const summaries = rows.map((r) => r.summary).sort();
expect(summaries).toEqual(['June 15 last year', 'June 15 two years ago']);
});
test('op surfaces on-this-day', async () => {
const rows = await operationsByName.chronicle_on_this_day.handler(mkCtx(), { date: '2026-06-15' }) as ChronicleTimelineRow[];
expect(rows).toHaveLength(2);
});
test('chronicle_day --narrative returns prose + events', async () => {
const r = await operationsByName.chronicle_day.handler(mkCtx(), { date: '2024-06-15', narrative: true }) as { narrative: string; events: ChronicleTimelineRow[] };
expect(r.events).toHaveLength(1);
expect(r.narrative).toContain('2024-06-15');
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* v0.42.x Life Chronicle (#2390) doctor chronicle_projection_health (Phase B.13).
* Verifies the orphan-detection query the doctor check runs: a timeline
* projection whose event page is soft-deleted is counted (hidden at read time,
* flagged for cleanup).
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runChronicleExtract, type ChronicleJudge } from '../src/core/chronicle/extract-events.ts';
let engine: PGLiteEngine;
const orphanQuery = `SELECT count(*)::int AS n FROM timeline_entries te
JOIN pages ep ON ep.id = te.event_page_id
WHERE te.event_page_id IS NOT NULL AND ep.deleted_at IS NOT NULL`;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
});
afterAll(async () => { await engine.disconnect(); });
describe('chronicle_projection_health detection', () => {
test('counts projections whose event page is soft-deleted; zero otherwise', async () => {
await engine.putPage('meetings/d', { type: 'meeting', title: 'd', compiled_truth: 'x'.repeat(120), effective_date: new Date('2026-06-18T12:00:00Z') });
const judge: ChronicleJudge = async () => ({ events: [{ when: '2026-06-18T12:00:00Z', who: [], what: 'an event', kind: 'meeting' }] });
await runChronicleExtract(engine, { slug: 'meetings/d', judge });
let r = await engine.executeRaw<{ n: number }>(orphanQuery);
expect(Number(r[0].n)).toBe(0); // healthy: event page live
// Soft-delete the event page → its projection becomes an orphan.
await engine.executeRaw(`UPDATE pages SET deleted_at = now() WHERE type = 'event'`);
r = await engine.executeRaw<{ n: number }>(orphanQuery);
expect(Number(r[0].n)).toBe(1);
});
});
+139
View File
@@ -0,0 +1,139 @@
/**
* v0.42.x Life Chronicle (#2390) auto-emit extractor (Phase A.3).
* PGLite in-memory. Covers eligibility, the extractor's parse barrier +
* idempotent writes (event pages + timeline projection), and the backstop's
* auto_chronicle gating + enqueue. The LLM judge is stubbed so the deterministic
* write path is tested without a gateway.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { isChronicleEligible } from '../src/core/chronicle/eligibility.ts';
import { runChronicleExtract, type ChronicleJudge } from '../src/core/chronicle/extract-events.ts';
import { runChronicleBackstop } from '../src/core/chronicle/backstop.ts';
let engine: PGLiteEngine;
const LONG_BODY = 'A'.repeat(120);
async function countEvents(): Promise<number> {
const r = await engine.executeRaw<{ n: number }>(`SELECT count(*)::int AS n FROM pages WHERE type = 'event'`);
return Number(r[0].n);
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
});
afterAll(async () => { await engine.disconnect(); });
describe('isChronicleEligible', () => {
const body = LONG_BODY;
test('meeting is eligible', () => {
expect(isChronicleEligible({ type: 'meeting', slug: 'meetings/x', body }).ok).toBe(true);
});
test('meetings/ slug rescues a note-typed page', () => {
expect(isChronicleEligible({ type: 'note', slug: 'meetings/x', body }).ok).toBe(true);
});
test('diary is excluded (privacy)', () => {
expect(isChronicleEligible({ type: 'diary', slug: 'life/diary/x', body })).toEqual({ ok: false, reason: 'diary_excluded' });
});
test('event is excluded (anti-loop)', () => {
expect(isChronicleEligible({ type: 'event', slug: 'life/events/x', body })).toEqual({ ok: false, reason: 'event_self' });
});
test('dream-generated is excluded', () => {
expect(isChronicleEligible({ type: 'meeting', slug: 'meetings/x', body, dreamGenerated: true })).toEqual({ ok: false, reason: 'dream_generated' });
});
test('too-short body is excluded', () => {
expect(isChronicleEligible({ type: 'meeting', slug: 'meetings/x', body: 'hi' })).toEqual({ ok: false, reason: 'too_short' });
});
test('unrelated type is excluded', () => {
expect(isChronicleEligible({ type: 'concept', slug: 'wiki/concepts/x', body })).toEqual({ ok: false, reason: 'kind:concept' });
});
});
describe('runChronicleExtract', () => {
const oneEvent: ChronicleJudge = async () => ({
events: [{ when: '2026-06-18T15:30:00Z', who: ['people/sarah-chen'], what: 'Sarah committed to Q3', kind: 'commitment' }],
});
beforeEach(async () => {
await engine.executeRaw('DELETE FROM timeline_entries');
await engine.executeRaw(`DELETE FROM pages WHERE type = 'event' OR slug = 'meetings/2026-06-18-sync'`);
await engine.putPage('meetings/2026-06-18-sync', {
type: 'meeting', title: 'Weekly sync',
compiled_truth: LONG_BODY,
frontmatter: { attendees: ['people/sarah-chen'] },
effective_date: new Date('2026-06-18T15:00:00Z'),
});
});
test('writes an event page + timeline projection', async () => {
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: oneEvent });
expect(r.status).toBe('extracted');
expect(r.events_written).toBe(1);
expect(await countEvents()).toBe(1);
const day = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' });
expect(day.length).toBe(1);
expect(day[0].summary).toBe('Sarah committed to Q3');
expect(day[0].page_slug).toBe('meetings/2026-06-18-sync'); // projection keyed to depth
expect(day[0].event_slug?.startsWith('life/events/2026-06-18-')).toBe(true);
expect(day[0].kind).toBe('commitment');
});
test('is idempotent: running twice yields one event + one projection', async () => {
await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: oneEvent });
await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: oneEvent });
expect(await countEvents()).toBe(1);
const day = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' });
expect(day.length).toBe(1);
});
test('parse barrier: a malformed proposal writes NOTHING', async () => {
const before = await countEvents();
const bad: ChronicleJudge = async () => ({ events: [{ when: '2026-06-18', who: [], kind: 'x' } as never] });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: bad });
expect(r.status).toBe('skipped');
expect(r.reason).toBe('malformed_proposal');
expect(await countEvents()).toBe(before); // no partial write
});
test('parse barrier: a non-date `when` writes NOTHING (codex fix #2)', async () => {
const before = await countEvents();
const badDate: ChronicleJudge = async () => ({ events: [{ when: 'not-a-date', who: [], what: 'x', kind: 'meeting' }] });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: badDate });
expect(r.status).toBe('skipped');
expect(r.reason).toBe('malformed_proposal');
expect(await countEvents()).toBe(before);
});
test('no events → no_events status', async () => {
const none: ChronicleJudge = async () => ({ events: [] });
const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: none });
expect(r.status).toBe('no_events');
});
});
describe('runChronicleBackstop gating', () => {
beforeEach(async () => {
await engine.unsetConfig('auto_chronicle');
await engine.putPage('meetings/bs', { type: 'meeting', title: 'bs', compiled_truth: LONG_BODY });
});
test('skips when auto_chronicle is off (default)', async () => {
const r = await runChronicleBackstop({ slug: 'meetings/bs', type: 'meeting', compiled_truth: LONG_BODY }, { engine, sourceId: 'default' });
expect(r).toEqual({ enqueued: false, skipped: 'auto_chronicle_off' });
});
test('skips a diary page before consulting the flag', async () => {
const r = await runChronicleBackstop({ slug: 'life/diary/x', type: 'diary', compiled_truth: LONG_BODY }, { engine, sourceId: 'default' });
expect(r).toEqual({ enqueued: false, skipped: 'diary_excluded' });
});
test('enqueues a chronicle_extract job when enabled + eligible', async () => {
await engine.setConfig('auto_chronicle', 'true');
const r = await runChronicleBackstop({ slug: 'meetings/bs', type: 'meeting', compiled_truth: LONG_BODY }, { engine, sourceId: 'default' });
expect(r.enqueued).toBe(true);
const jobs = await engine.executeRaw<{ n: number }>(`SELECT count(*)::int AS n FROM minion_jobs WHERE name = 'chronicle_extract'`);
expect(Number(jobs[0].n)).toBeGreaterThanOrEqual(1);
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* v0.42.x Life Chronicle (#2390) ontology ops (Phase B.11).
* Exercises the op handlers (not just the engine), with focus on the
* privacy-critical remote diary-source redaction in ontology_get.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { operationsByName } from '../src/core/operations.ts';
import type { OperationContext } from '../src/core/operations.ts';
let engine: PGLiteEngine;
const SARAH = 'people/sarah-chen';
const ctx = (remote: boolean): OperationContext => ({ engine, remote, sourceId: 'default' } as unknown as OperationContext);
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
});
afterAll(async () => { await engine.disconnect(); });
beforeEach(async () => { await engine.executeRaw('DELETE FROM facts'); });
describe('ontology ops', () => {
test('ontology_propose writes; ontology_get reads back', async () => {
const r = await operationsByName.ontology_propose.handler(ctx(false), {
entity: SARAH, dimension: 'role', value: 'advisor', source: 'meetings/a',
});
expect((r as { action: string }).action).toBe('inserted');
const got = await operationsByName.ontology_get.handler(ctx(false), { entity: SARAH }) as { dimension: string; value: string }[];
expect(got).toHaveLength(1);
expect(got[0]).toMatchObject({ dimension: 'role', value: 'advisor' });
});
test('remote callers never see diary-sourced ontology', async () => {
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'advisor', source: 'meetings/a' });
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'affect', value: 'anxious', source: 'life/diary/2026-06-18', status: 'active' });
const local = await operationsByName.ontology_get.handler(ctx(false), { entity: SARAH, include_quarantined: true }) as { source: string }[];
const remote = await operationsByName.ontology_get.handler(ctx(true), { entity: SARAH, include_quarantined: true }) as { source: string }[];
expect(local.some((r) => (r.source ?? '').startsWith('life/diary/'))).toBe(true);
expect(remote.some((r) => (r.source ?? '').startsWith('life/diary/'))).toBe(false);
});
test('remote ontology_conflicts redacts diary-sourced disagreement (codex fix #3)', async () => {
// A conflict where one side is diary-sourced: advisor (meeting) vs founder (diary).
await engine.mergeOntologyFact({ entitySlug: 'people/y', dimension: 'role', value: 'advisor', source: 'meetings/a', validFrom: '2026-05-01' });
await engine.mergeOntologyFact({ entitySlug: 'people/y', dimension: 'role', value: 'founder', source: 'life/diary/2026-06-18', validFrom: '2026-01-01' });
const local = await operationsByName.ontology_conflicts.handler(ctx(false), {}) as { entity_slug: string }[];
expect(local.some((c) => c.entity_slug === 'people/y')).toBe(true);
const remote = await operationsByName.ontology_conflicts.handler(ctx(true), {}) as { entity_slug: string }[];
expect(remote.some((c) => c.entity_slug === 'people/y')).toBe(false); // diary side redacted → no longer a disagreement
});
test('ontology_dimensions + ontology_conflicts surface via ops', async () => {
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'advisor', source: 'meetings/a', validFrom: '2026-05-01' });
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/b', validFrom: '2026-01-01' });
const dims = await operationsByName.ontology_dimensions.handler(ctx(false), {}) as { dimension: string }[];
expect(dims.some((d) => d.dimension === 'role')).toBe(true);
const conflicts = await operationsByName.ontology_conflicts.handler(ctx(false), {}) as { dimension: string }[];
expect(conflicts.some((c) => c.dimension === 'role')).toBe(true);
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* v0.42.x Life Chronicle (#2390) bi-temporal ontology on `facts` (Phase B.10).
* PGLite in-memory. Covers insert / idempotent retry / corroboration / forward
* supersession / --asof valid-time travel / quarantine / backdated-conflict
* detection. The Postgres engine runs the identical SQL (see engine-parity).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
const SARAH = 'people/sarah-chen';
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
});
afterAll(async () => { await engine.disconnect(); });
beforeEach(async () => { await engine.executeRaw('DELETE FROM facts'); });
describe('mergeOntologyFact + getOntology', () => {
test('inserts a known dimension as active', async () => {
const r = await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/a', validFrom: '2024-01-01' });
expect(r.action).toBe('inserted');
const o = await engine.getOntology(SARAH);
expect(o).toHaveLength(1);
expect(o[0]).toMatchObject({ dimension: 'role', value: 'founder', status: 'active' });
});
test('idempotent retry (same value + source) is a noop', async () => {
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/a' });
const r = await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/a' });
expect(r.action).toBe('noop');
expect(await engine.getOntology(SARAH)).toHaveLength(1);
});
test('same value from a new source corroborates (still one current value)', async () => {
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/a' });
const r = await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/b' });
expect(r.action).toBe('corroborated');
const o = await engine.getOntology(SARAH);
expect(o).toHaveLength(1);
expect(o[0].value).toBe('founder');
});
test('normalizes dimension aliases (job_role → role)', async () => {
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'job_role', value: 'advisor', source: 'meetings/a' });
const o = await engine.getOntology(SARAH);
expect(o[0].dimension).toBe('role');
});
test('forward supersession + --asof valid-time travel', async () => {
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/a', validFrom: '2024-01-01' });
const r = await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'advisor', source: 'meetings/b', validFrom: '2026-05-01' });
expect(r.action).toBe('superseded_prior');
// Current value is the new one…
const now = await engine.getOntology(SARAH);
expect(now[0].value).toBe('advisor');
// …but as-of before the change, time-travel still sees the old one.
const past = await engine.getOntology(SARAH, { asof: '2025-01-01' });
expect(past[0].value).toBe('founder');
});
test('novel dimensions quarantine (excluded from current unless asked)', async () => {
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'vibe', value: 'chaotic', source: 'meetings/a' });
expect(await engine.getOntology(SARAH)).toHaveLength(0); // quarantined → hidden
const incl = await engine.getOntology(SARAH, { includeQuarantined: true });
expect(incl).toHaveLength(1);
expect(incl[0].status).toBe('quarantined');
});
});
describe('findOntologyConflicts + discoverOntologyDimensions', () => {
test('backdated conflicting value is NOT rewritten and surfaces as a conflict', async () => {
const BOB = 'people/bob';
await engine.mergeOntologyFact({ entitySlug: BOB, dimension: 'role', value: 'advisor', source: 'meetings/a', validFrom: '2026-05-01' });
const r = await engine.mergeOntologyFact({ entitySlug: BOB, dimension: 'role', value: 'founder', source: 'meetings/b', validFrom: '2026-01-01' });
expect(r.action).toBe('inserted'); // backdated → not a supersession
const conflicts = await engine.findOntologyConflicts();
const bobRole = conflicts.find((c) => c.entity_slug === BOB && c.dimension === 'role');
expect(bobRole).toBeTruthy();
expect(bobRole!.values.map((v) => v.value).sort()).toEqual(['advisor', 'founder']);
});
test('forward supersession is NOT reported as a conflict (only live disagreement is)', async () => {
// founder (2024) → advisor (2026): the founder row is closed via valid_until,
// so it must NOT count as a current conflict (codex pre-landing fix #1).
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'm/a', validFrom: '2024-01-01' });
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'advisor', source: 'm/b', validFrom: '2026-05-01' });
const conflicts = await engine.findOntologyConflicts();
expect(conflicts.some((c) => c.entity_slug === SARAH && c.dimension === 'role')).toBe(false);
});
test('discoverOntologyDimensions rolls up by dimension', async () => {
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/a' });
await engine.mergeOntologyFact({ entitySlug: 'people/bob', dimension: 'role', value: 'advisor', source: 'meetings/b' });
const dims = await engine.discoverOntologyDimensions();
const role = dims.find((d) => d.dimension === 'role');
expect(role).toBeTruthy();
expect(role!.entities).toBe(2);
});
test('source isolation: ontology is scoped by source_id', async () => {
await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/a', sourceId: 'default' });
expect(await engine.getOntology(SARAH, { sourceId: 'default' })).toHaveLength(1);
expect(await engine.getOntology(SARAH, { sourceId: 'other' })).toHaveLength(0);
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* v0.42.x Life Chronicle (#2390) E1 temporal recall boost (Phase A.4).
* Pure-function test of applyChronicleTypeBoost: boosts event/diary on temporal
* queries, leaves everything else untouched (the no-regression guarantee the
* function is only CALLED when recency !== 'off', and even then only moves
* chronicle types).
*/
import { describe, test, expect } from 'bun:test';
import { applyChronicleTypeBoost } from '../src/core/search/hybrid.ts';
import type { SearchResult } from '../src/core/types.ts';
function r(slug: string, type: string, score = 1.0): SearchResult {
return {
slug, page_id: 1, title: slug, type, chunk_text: '', chunk_source: 'compiled_truth',
chunk_id: 1, chunk_index: 0, score, stale: false,
} as SearchResult;
}
describe('applyChronicleTypeBoost', () => {
test('boosts event + diary, leaves other types untouched', () => {
const rows = [r('life/events/a', 'event'), r('life/diary/b', 'diary'), r('wiki/note', 'note'), r('people/x', 'person')];
applyChronicleTypeBoost(rows, 'on');
expect(rows[0].score).toBeCloseTo(1.15);
expect(rows[0].chronicle_boost).toBeCloseTo(1.15);
expect(rows[1].score).toBeCloseTo(1.15);
expect(rows[2].score).toBe(1.0); // note unchanged
expect(rows[2].chronicle_boost).toBeUndefined();
expect(rows[3].score).toBe(1.0); // person unchanged
});
test('strong uses a larger factor', () => {
const rows = [r('life/events/a', 'event')];
applyChronicleTypeBoost(rows, 'strong');
expect(rows[0].score).toBeCloseTo(1.25);
});
test('floor gate skips low-score results', () => {
const rows = [r('life/events/a', 'event', 0.1)];
applyChronicleTypeBoost(rows, 'on', 0.5); // 0.1 < floor 0.5 → skipped
expect(rows[0].score).toBe(0.1);
expect(rows[0].chronicle_boost).toBeUndefined();
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* v0.42.x Life Chronicle (#2390) timeline read methods (Phase A.2).
* Runs against PGLite in-memory. Covers getTimelineForDate (day + ISO week),
* getSince (+ kind filter), getLastSeen (own page + via event `who`),
* intra-day ordering by event effective_date, source isolation, read-time
* hiding of soft-deleted event projections, and the (event_page_id, date)
* dedup index.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
const ids: Record<string, number> = {};
async function insertPage(opts: {
slug: string; type: string; sourceId?: string;
effectiveDate?: string | null; frontmatter?: string;
}): Promise<number> {
const rows = await engine.executeRaw<{ id: number }>(
`INSERT INTO pages (source_id, slug, type, title, effective_date, frontmatter)
VALUES ($1, $2, $3, $4, $5::timestamptz, $6::text::jsonb)
RETURNING id`,
[opts.sourceId ?? 'default', opts.slug, opts.type, opts.slug,
opts.effectiveDate ?? null, opts.frontmatter ?? '{}'],
);
return rows[0].id;
}
async function insertProjection(depthId: number, eventId: number, date: string, summary: string): Promise<void> {
await engine.executeRaw(
`INSERT INTO timeline_entries (page_id, date, source, summary, detail, event_page_id)
VALUES ($1, $2::date, $3, $4, '', $5)`,
[depthId, date, `life-chronicle:event:${eventId}`, summary, eventId],
);
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
ids.meeting = await insertPage({ slug: 'meetings/2026-06-18-sync', type: 'meeting' });
await insertPage({ slug: 'people/sarah-chen', type: 'person' });
// 06-18: event2 at 09:00 (Bob), event1 at 15:30 (Sarah, commitment).
ids.e1 = await insertPage({ slug: 'life/events/2026-06-18-001', type: 'event', effectiveDate: '2026-06-18T15:30:00Z', frontmatter: '{"event":{"who":["people/sarah-chen"],"kind":"commitment"}}' });
ids.e2 = await insertPage({ slug: 'life/events/2026-06-18-002', type: 'event', effectiveDate: '2026-06-18T09:00:00Z', frontmatter: '{"event":{"who":["people/bob"],"kind":"meeting"}}' });
// 06-20 (same ISO week as 06-18): event3 (Sarah, decision).
ids.e3 = await insertPage({ slug: 'life/events/2026-06-20-001', type: 'event', effectiveDate: '2026-06-20T10:00:00Z', frontmatter: '{"event":{"who":["people/sarah-chen"],"kind":"decision"}}' });
await insertProjection(ids.meeting, ids.e1, '2026-06-18', 'Sarah committed to Q3');
await insertProjection(ids.meeting, ids.e2, '2026-06-18', 'Bob standup');
await insertProjection(ids.meeting, ids.e3, '2026-06-20', 'Decision on launch');
// Other-source event (isolation fixture).
await engine.executeRaw(`INSERT INTO sources (id, name) VALUES ('other', 'Other') ON CONFLICT (id) DO NOTHING`);
const otherMeeting = await insertPage({ slug: 'meetings/o', type: 'meeting', sourceId: 'other' });
ids.eOther = await insertPage({ slug: 'life/events/2026-06-18-099', type: 'event', sourceId: 'other', effectiveDate: '2026-06-18T12:00:00Z', frontmatter: '{"event":{"who":["people/zed"],"kind":"meeting"}}' });
await insertProjection(otherMeeting, ids.eOther, '2026-06-18', 'Other-source event');
});
afterAll(async () => { await engine.disconnect(); });
describe('Life Chronicle timeline reads', () => {
test('getTimelineForDate orders intra-day by event effective_date', async () => {
const rows = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' });
expect(rows.map(r => r.event_slug)).toEqual([
'life/events/2026-06-18-002', // 09:00 first
'life/events/2026-06-18-001', // 15:30 second
]);
expect(rows[0].page_slug).toBe('meetings/2026-06-18-sync'); // backlink = depth page
expect(rows[1].kind).toBe('commitment');
});
test('week expansion pulls the whole ISO week', async () => {
const day = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' });
const week = await engine.getTimelineForDate('2026-06-18', { week: true, sourceId: 'default' });
expect(day.length).toBe(2);
expect(week.length).toBe(3); // adds 06-20 (same MonSun week)
expect(week[week.length - 1].event_slug).toBe('life/events/2026-06-20-001');
});
test('getSince filters by lower-bound date and event kind', async () => {
const since19 = await engine.getSince('2026-06-19', { sourceId: 'default' });
expect(since19.map(r => r.event_slug)).toEqual(['life/events/2026-06-20-001']);
const commitments = await engine.getSince('2026-06-18', { kind: 'commitment', sourceId: 'default' });
expect(commitments.map(r => r.event_slug)).toEqual(['life/events/2026-06-18-001']);
});
test('getLastSeen finds the entity via event who, with days_ago', async () => {
const seen = await engine.getLastSeen('people/sarah-chen', { asof: '2026-06-25', sourceId: 'default' });
expect(seen.last_date).toBe('2026-06-20');
expect(seen.last_event_slug).toBe('life/events/2026-06-20-001');
expect(seen.days_ago).toBe(5);
const never = await engine.getLastSeen('people/nobody', { asof: '2026-06-25', sourceId: 'default' });
expect(never.last_date).toBeNull();
expect(never.days_ago).toBeNull();
});
test('source isolation: default scope excludes other-source events', async () => {
const def = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' });
expect(def.some(r => r.event_slug === 'life/events/2026-06-18-099')).toBe(false);
const other = await engine.getTimelineForDate('2026-06-18', { sourceId: 'other' });
expect(other.map(r => r.event_slug)).toEqual(['life/events/2026-06-18-099']);
});
test('(event_page_id, date) dedup index rejects a duplicate projection', async () => {
await expect(
insertProjection(ids.meeting, ids.e1, '2026-06-18', 'dup'),
).rejects.toThrow();
});
test('soft-deleting an event page hides it from reads (read-time, not doctor)', async () => {
await engine.executeRaw('UPDATE pages SET deleted_at = now() WHERE id = $1', [ids.e3]);
const since19 = await engine.getSince('2026-06-19', { sourceId: 'default' });
expect(since19.length).toBe(0); // event3 hidden
const seen = await engine.getLastSeen('people/sarah-chen', { asof: '2026-06-25', sourceId: 'default' });
expect(seen.last_date).toBe('2026-06-18'); // falls back to event1
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* v0.42.x Life Chronicle (#2390) feature eval harness (Phase A.9, PRIMARY proof).
* The chronicle layer must answer EVERY gold task on the synthetic corpus.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runChronicleEval } from '../src/eval/chronicle/harness.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
});
afterAll(async () => { await engine.disconnect(); });
describe('runChronicleEval', () => {
test('scores a perfect 6/6 on the synthetic corpus (the value proof)', async () => {
const result = await runChronicleEval(engine);
const failed = result.tasks.filter((t) => !t.passed).map((t) => `${t.id}: ${t.detail}`);
expect(failed).toEqual([]); // surfaces which gold task failed, if any
expect(result.score).toBe(1);
expect(result.total).toBe(6);
});
});
@@ -31,9 +31,19 @@ import { join } from 'node:path';
// - consolidate.ts (v0.35.4 — chronological writeback)
// - facts/forget.ts (v0.32.2 — user-initiated `gbrain forget`; user is
// the supersession authority, not the probe)
// - postgres-engine.ts + pglite-engine.ts (v0.42.56.0, #2390 — Life
// Chronicle ontology: `mergeOntologyFact` forward-supersession closes
// the prior OPEN row's valid_until when a NEW value arrives for the
// same (entity, dimension). Engine-layer, caller-requested, scoped to
// `dimension IS NOT NULL` ontology rows only — plain facts untouched,
// and the contradiction probe still never mutates, so the
// auto-supersession.ts:4 invariant is preserved. Deliberate design
// change per the #2390 eng review (G1: ontology extends facts).
const VALID_UNTIL_WRITE_ALLOWLIST: ReadonlySet<string> = new Set([
'src/core/cycle/phases/consolidate.ts',
'src/core/facts/forget.ts',
'src/core/postgres-engine.ts',
'src/core/pglite-engine.ts',
]);
function walkTs(dir: string, acc: string[] = []): string[] {
+3
View File
@@ -39,6 +39,9 @@ const PARITY_FIXTURES: ReadonlyArray<{ path: string; expected: string; reason: s
{ path: 'emails/em-0001.md', expected: 'email', reason: 'emails/ prefix' },
{ path: 'slack/sl-0037.md', expected: 'slack', reason: 'slack/ prefix' },
{ path: 'cal/2026-05-20.md', expected: 'calendar-event', reason: 'cal/ prefix' },
// v0.42.x — Life Chronicle (#2390): timeline events + thought diary.
{ path: 'life/events/2026-06-18-002-sync.md', expected: 'event', reason: 'life/events/ prefix' },
{ path: 'life/diary/2026-06-18-am.md', expected: 'diary', reason: 'life/diary/ prefix' },
// Stronger-signal wins: writing/ inside projects/
{ path: 'projects/blog/writing/essay.md', expected: 'writing', reason: 'writing/ wins over projects/' },
// Fallback: paths not matching any prefix
+11 -3
View File
@@ -82,13 +82,21 @@ describe('checkTypeProliferation (D16 pack-aware ratio)', () => {
});
it('warns when distinct types exceed declared+5', async () => {
// gbrain-base declares 24 types. warn threshold = 29.
// Threshold-relative (v0.42.56.0): compute `declared` from the active pack
// the same way checkTypeProliferation does, then seed declared+6 so the
// test keeps passing when the base pack grows (e.g. #2390 added
// event + diary and silently moved the fixed threshold).
const { loadActivePack } = await import('../src/core/schema-pack/load-active.ts');
const dbConfig = (await engine.getConfig('schema_pack')) ?? undefined;
const active = await loadActivePack({ cfg: null, remote: false, dbConfig }).catch(() => null);
const declared = active ? active.manifest.page_types.length : 15;
const seedCount = declared + 6; // one past the warn threshold (declared+5)
const types: string[] = [];
for (let i = 0; i < 32; i++) types.push(`custom-type-${i}`);
for (let i = 0; i < seedCount; i++) types.push(`custom-type-${i}`);
await seedPages(types);
const result = await checkTypeProliferation(engine);
expect(result.check.status).toBe('warn');
expect(result.check.message).toMatch(/32 distinct/);
expect(result.check.message).toMatch(new RegExp(`${seedCount} distinct`));
});
});
+11
View File
@@ -730,6 +730,17 @@ const COLUMN_EXEMPTIONS = new Set<string>([
// brains is invisible to them). Migration is column-only, no FK,
// no index — bootstrap probe would be pure overhead.
'facts.event_type',
// v0.42.56.0 (migration v122, #2390) — Life Chronicle ontology columns.
// Same precedent as facts.claim_metric et al: the `facts` table itself is
// migration-created (absent from PGLITE_SCHEMA_SQL), so no schema-blob
// forward reference can exist; the partial indexes (idx_facts_dimension,
// idx_facts_ontology_dedup) live INSIDE the same v122 migration. Every
// reader filters `dimension IS NOT NULL`, so NULL on old brains is
// invisible. Column-only, no bootstrap probe needed.
'facts.dimension',
'facts.value',
'facts.value_hash',
'facts.dim_status',
// v0.39.1.0 (migration v88) — schema-pack provenance per-source captured as
// inline canonical closure snapshot on every eval_candidates row. NULL by
// default; no index in PGLITE_SCHEMA_SQL references it. Migration handles
+5 -1
View File
@@ -73,7 +73,11 @@ describe('gbrain schema CLI (Phase C)', () => {
// `conversation` and `atom` into gbrain-base.
// v0.41.23.0: extended to 25 by adding `extract_receipt` for the
// unified extract receipt-writer surface (D-EXTRACT-19 belt+suspenders).
expect(r.stdout).toContain('Page types (25)');
// v0.42.56.0 (#2390): extended to 27 by adding the Life Chronicle
// `event` + `diary` temporal types (life/events/, life/diary/).
expect(r.stdout).toContain('Page types (27)');
expect(r.stdout).toContain('event :: temporal');
expect(r.stdout).toContain('diary :: temporal');
expect(r.stdout).toContain('Link verbs (12)');
expect(r.stdout).toContain('Takes kinds: fact, take, bet, hunch');
expect(r.stdout).toContain('person :: entity');