feat(#193 Child 1): Emergent Lifebooks — domain extractor + dashboard surface#242
Merged
Conversation
…ings + dashboard surface
The OSS-launch headline made executable: instead of hardcoding 7 life
verticals, the twin reads MemPalace, names the domains the user actually
operates in, creates a wing per domain, and surfaces them on the
dashboard. Adding a new "kind" of Lifebook is a prompt edit, not a code
deploy.
Slice 1 — domain extractor worker:
- 036-lifebooks.sql: lifebooks table with importance enum + JSONB
signals/capabilities, soft-hide via hidden_at
- lifebookRepository: upsert (idempotent, never resurrects hidden rows),
listVisible/listAll/findByDomain/hide/unhide
- runDomainExtractionJob: walks active users, builds memory_summary
from knowledge_entities + knowledge_triples, runs
runPrompt('domain-extraction'), per-domain ensures wing + upserts
lifebook. Wired into worker poll loop on 7-day cadence.
- 12 unit tests: mocks all DB + prompt-runner so no real subsystems hit.
Slice 2 — dashboard + per-Lifebook UX:
- /api/lifebooks/:userId routes (list visible/all, get-by-domain with
wing summary, hide, unhide)
- "Your Lifebooks" card on dashboard, top 5 with importance badges
- /#lifebook/<domain> page: badge, signals, capabilities, wing summary,
hide button. Dynamic SPA route added to app.js.
- api-client helpers: fetchLifebooks, fetchLifebook, hide, unhide
Architecturally fits the theme: hard rails preserved (no user-driven
domain creation), boring deterministic schema (same wings/rooms/drawers
as MemPalace), adaptive layer (domain-extraction prompt is the only
place domains get *named*), memory port (read via existing repo, write
via Palace.ensureWing — future gbrain backend plugs in without
touching the worker).
Out of scope: per-Lifebook briefing prose, capability filtering by
domain, provenance graph wing filter (links wired, page reader is
follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces “Emergent Lifebooks” (Issue #193 Child 1): a weekly worker that extracts a user’s life domains from MemPalace and persists them as Lifebooks, plus API + SPA surfaces to list, view, and hide/unhide those Lifebooks.
Changes:
- Added
lifebookspersistence (migration + repository) and wired a weekly domain-extraction worker job. - Added Lifebooks API routes (
list,get,hide,unhide) and dashboard/per-lifebook SPA UI. - Updated web API client + SPA router to support
#/lifebook/<domain>navigation.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/db/src/schemas/schema.sql | Adds lifebooks table to the base schema snapshot. |
| packages/db/src/migrations/036-lifebooks.sql | Adds DB migration creating lifebooks table + indexes. |
| packages/db/src/repositories/lifebook-repository.ts | Implements lifebook upsert/list/find/hide/unhide repository methods. |
| packages/db/src/repositories/index.ts | Re-exports lifebook repository and types. |
| packages/db/src/index.ts | Re-exports lifebook repository and types at package root. |
| apps/worker/src/jobs/domain-extraction.ts | New domain-extraction job that summarizes memory, runs prompt, ensures wings, and upserts lifebooks. |
| apps/worker/src/index.ts | Wires the domain-extraction job into the worker poll loop (weekly). |
| apps/worker/src/tests/domain-extraction.test.ts | Adds unit tests for domain-extraction orchestration/validation behavior. |
| apps/api/src/routes/lifebooks.ts | New Lifebooks API router (list/get/hide/unhide + wing summary). |
| apps/api/src/index.ts | Mounts the Lifebooks router under /api/lifebooks. |
| apps/web/public/js/api-client.js | Adds Lifebooks API client helpers. |
| apps/web/public/js/pages/dashboard.js | Adds “Your Lifebooks” dashboard card using Lifebooks API. |
| apps/web/public/js/pages/lifebook.js | Adds per-Lifebook page UI + hide action. |
| apps/web/public/js/app.js | Adds dynamic SPA route for #/lifebook/<domain>. |
| CHANGELOG.md | Documents the Lifebooks feature set and rationale. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+90
to
+94
| `SELECT kind, canonical, count(*)::STRING AS freq | ||
| FROM knowledge_entities | ||
| WHERE user_id = $1 | ||
| GROUP BY kind, canonical | ||
| ORDER BY count(*) DESC |
| `SELECT subject, predicate, object | ||
| FROM knowledge_triples | ||
| WHERE user_id = $1 AND (valid_to IS NULL OR valid_to > now()) | ||
| ORDER BY observed_at DESC |
Comment on lines
+512
to
+521
| // Re-extract life domains weekly (#193 Child 1). The job no-ops when no | ||
| // LlmClient is available — extraction is LLM-dependent. Per-user errors | ||
| // are absorbed inside the job. | ||
| if (nowMs - lastDomainExtractionAt >= DOMAIN_EXTRACTION_INTERVAL_MS) { | ||
| await runDomainExtractionJob().catch((err) => { | ||
| log.warn('Domain extraction job failed', { | ||
| error: err instanceof Error ? err.message : String(err), | ||
| }); | ||
| }); | ||
| lastDomainExtractionAt = nowMs; |
Comment on lines
+155
to
+168
| function isExtractedDomain(x: unknown): x is ExtractedDomain { | ||
| if (x === null || typeof x !== 'object') return false; | ||
| const o = x as Record<string, unknown>; | ||
| return ( | ||
| typeof o['domainName'] === 'string' && | ||
| (o['importance'] === 'core' || o['importance'] === 'secondary' || o['importance'] === 'emerging') && | ||
| Array.isArray(o['sample_signals']) && | ||
| Array.isArray(o['suggested_capabilities']) | ||
| ); | ||
| } | ||
|
|
||
| function coerceDomainList(raw: unknown): ExtractedDomain[] { | ||
| if (!Array.isArray(raw)) return []; | ||
| return raw.filter(isExtractedDomain).slice(0, 10); |
Comment on lines
+52
to
+60
| const { userId, domainName } = req.params; | ||
| if (!userId || !domainName) { | ||
| res.status(400).json({ error: 'Missing userId or domainName' }); | ||
| return; | ||
| } | ||
| const row = await lifebookRepository.findByDomain(userId, decodeURIComponent(domainName)); | ||
| if (!row) { | ||
| res.status(404).json({ error: 'Lifebook not found' }); | ||
| return; |
| const rooms = await mempalaceRepository.getRooms(row.wing_id); | ||
| const drawers = await mempalaceRepository.getDrawers(userId, { | ||
| wingId: row.wing_id, | ||
| limit: 1, |
| ${wing ? ` | ||
| <div class="card"> | ||
| <div class="card-header"><span class="card-title">Memory wing</span></div> | ||
| <div class="card-subtitle">${wing.roomCount} rooms · ${wing.drawerCount}+ drawers</div> |
Comment on lines
+312
to
+315
| // Your Lifebooks (#193 Child 1): top 5 detected life domains. | ||
| const lifebooks = (lifebooksData?.status === 'fulfilled' && Array.isArray(lifebooksData.value?.lifebooks)) | ||
| ? lifebooksData.value.lifebooks.slice(0, 5) | ||
| : []; |
Comment on lines
+18
to
+34
| export function createLifebooksRouter(): Router { | ||
| const router = Router(); | ||
| bindUserIdParamOwnership(router); | ||
|
|
||
| router.get('/:userId', async (req, res, next) => { | ||
| try { | ||
| const { userId } = req.params; | ||
| if (!userId) { | ||
| res.status(400).json({ error: 'Missing userId parameter' }); | ||
| return; | ||
| } | ||
| const rows = await lifebookRepository.listVisible(userId); | ||
| res.json({ lifebooks: rows.map(rowToJson) }); | ||
| } catch (err) { | ||
| next(err); | ||
| } | ||
| }); |
Comment on lines
+354
to
+358
| if (!route && lifebookMatch) { | ||
| let title = 'Lifebook'; | ||
| try { title = `Lifebook · ${decodeURIComponent(lifebookMatch[1])}`; } catch { /* keep default */ } | ||
| route = { title, render: renderLifebook }; | ||
| } |
This was referenced May 9, 2026
Open
jayzalowitz
added a commit
that referenced
this pull request
May 12, 2026
Closes the "capability filter by domain on Capabilities page" item PR carries a Lifebook dropdown next to the existing category filter; selecting a Lifebook intersects the registry results with that Lifebook's suggestedCapabilities: registryId[] set. No DB migration required — the intersection set already lives on lifebooks.suggested_capabilities (populated by the domain-extraction worker shipped in #242). The capabilities page fetches /api/lifebooks alongside its existing data and applies applyLifebookFilter() — a pure helper trivial to lift into a vitest harness if/when one lands for apps/web. UX details: - Dropdown shows visible (non-hidden) Lifebooks only. - When the user has zero Lifebooks, the dropdown is omitted entirely. - Empty-result state surfaces the active Lifebook name in the copy. - A Lifebook with empty suggestedCapabilities means "extractor proposed nothing yet" — shows everything rather than collapsing, distinguishing "haven't decided" from "decided nothing matches." Internal cleanup: three renderRegistryResults() call sites collapsed to (userId, readRegistryFilterState()) via a new readRegistryFilterState helper. Adding the Lifebook filter without this would have meant editing five separate call sites. Test plan: smoke-tested in Chrome across three scenarios (all-Lifebooks, Health-only, Money-only, switch back to all-Lifebooks). Filter toggles correctly; hidden Lifebook stays out of the dropdown. node --check clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jayzalowitz
added a commit
that referenced
this pull request
May 12, 2026
) * feat(#193 follow-up): filter the capabilities registry by Lifebook Closes the "capability filter by domain on Capabilities page" item PR carries a Lifebook dropdown next to the existing category filter; selecting a Lifebook intersects the registry results with that Lifebook's suggestedCapabilities: registryId[] set. No DB migration required — the intersection set already lives on lifebooks.suggested_capabilities (populated by the domain-extraction worker shipped in #242). The capabilities page fetches /api/lifebooks alongside its existing data and applies applyLifebookFilter() — a pure helper trivial to lift into a vitest harness if/when one lands for apps/web. UX details: - Dropdown shows visible (non-hidden) Lifebooks only. - When the user has zero Lifebooks, the dropdown is omitted entirely. - Empty-result state surfaces the active Lifebook name in the copy. - A Lifebook with empty suggestedCapabilities means "extractor proposed nothing yet" — shows everything rather than collapsing, distinguishing "haven't decided" from "decided nothing matches." Internal cleanup: three renderRegistryResults() call sites collapsed to (userId, readRegistryFilterState()) via a new readRegistryFilterState helper. Adding the Lifebook filter without this would have meant editing five separate call sites. Test plan: smoke-tested in Chrome across three scenarios (all-Lifebooks, Health-only, Money-only, switch back to all-Lifebooks). Filter toggles correctly; hidden Lifebook stays out of the dropdown. node --check clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(#256 post-/review): correct lifebook field, remove duplicate event handlers, make filter truly pure Three Copilot findings on PR #256 addressed: 1. Lifebook visibility filter now reads `hidden: boolean` (the actual API field) instead of the nonexistent `hiddenAt`. The previous `!lb.hiddenAt` was a no-op AND would have fail-opened if the endpoint ever returned hidden rows. The server-side listVisible() still does the real filtering; the client-side guard is defense in depth. 2. Removed data-action from the category + lifebook <select> elements. Each had an explicit change listener too, so the global click delegator double-fired renderRegistryResults on every dropdown-open. Change listener is now the sole entry point. Dead switch cases deleted. 3. applyLifebookFilter() is actually pure now — lifebooks is the third argument with a default that pulls from cache only at the call site. Future vitest harness can drive it without monkeypatching module state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(#256 round 2): drop misleading "pure" claim from CHANGELOG Copilot round-2 flagged that the CHANGELOG entry described readRegistryFilterState() as "pure" — but it reads the current dropdown / input values from the DOM, so it's stateful by definition. Rewrote the bullet to describe it as "isolated" (one place to change when filters are added) instead, which is the actual property the helper provides. The other two round-2 comments on this PR (applyLifebookFilter "pure" docstring + data-action duplicate listeners) were both already addressed in the round-1 fix-up (2c7eef2) — they were re-flagged because Copilot was reading stale state. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
jayzalowitz
added a commit
that referenced
this pull request
May 12, 2026
Closes the "provenance graph wing-filter consumer" item PR #242 (#193 Child 1) explicitly deferred. The lifebook page has been linking to page couldn't honor that filter because nodes had no wing linkage. This PR closes the gap end-to-end. Migration 041: - Adds nullable wing_id UUID column to capability_provenance_nodes - Partial index (user_id, wing_id, occurred_at DESC) WHERE wing_id IS NOT NULL keeps per-wing graph queries indexed without bloating the index with the NULL long tail - No FK to lifebooks (would block lifebook hard-delete); the frontend filter naturally excludes NULL rows Repository: - provenanceRepository.writeNode() auto-derives wing_id when the caller doesn't pass one: if the payload carries a registryId, look up the matching lifebook and stamp its wing_id. Best-effort — unknown registryId stays NULL. Explicit wingId always wins. API: - GET /api/capabilities/provenance-graph accepts wing=<uuid> query param. UUID-validated (400 on bad shape). Each node response carries wingId: string | null. Frontend: - provenance-graph.js reads the wing param off the hash query string, passes it through fetchProvenanceGraph, and renders a scoped-state indicator with a "Show all wings" button. UUID validated client-side too. Test plan: 3 new vitest cases for the API (wing filter active, invalid wing → 400, node response includes wingId). api 547/547, workspace 70/70 turbo tasks green. Out of scope (follow-ups): per-domain briefing prose (the other migration nodes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jayzalowitz
added a commit
that referenced
this pull request
May 12, 2026
* feat(#193 follow-up): provenance graph filter by Lifebook wing Closes the "provenance graph wing-filter consumer" item PR #242 (#193 Child 1) explicitly deferred. The lifebook page has been linking to page couldn't honor that filter because nodes had no wing linkage. This PR closes the gap end-to-end. Migration 041: - Adds nullable wing_id UUID column to capability_provenance_nodes - Partial index (user_id, wing_id, occurred_at DESC) WHERE wing_id IS NOT NULL keeps per-wing graph queries indexed without bloating the index with the NULL long tail - No FK to lifebooks (would block lifebook hard-delete); the frontend filter naturally excludes NULL rows Repository: - provenanceRepository.writeNode() auto-derives wing_id when the caller doesn't pass one: if the payload carries a registryId, look up the matching lifebook and stamp its wing_id. Best-effort — unknown registryId stays NULL. Explicit wingId always wins. API: - GET /api/capabilities/provenance-graph accepts wing=<uuid> query param. UUID-validated (400 on bad shape). Each node response carries wingId: string | null. Frontend: - provenance-graph.js reads the wing param off the hash query string, passes it through fetchProvenanceGraph, and renders a scoped-state indicator with a "Show all wings" button. UUID validated client-side too. Test plan: 3 new vitest cases for the API (wing filter active, invalid wing → 400, node response includes wingId). api 547/547, workspace 70/70 turbo tasks green. Out of scope (follow-ups): per-domain briefing prose (the other migration nodes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(#257 round 2): tighten docstrings (wing-clear handler + resolveWingIdFromPayload) Two docstring accuracy nits from Copilot round 2: 1. pg-clear-wing-filter handler: previous comment claimed it "re-renders explicitly so it doesn't depend on the hashchange listener" but the wing-actually-cleared branch DOES rely on hashchange. Comment now describes both branches accurately (hashchange path when wing was set; explicit-render path for the unusual double-click case). 2. resolveWingIdFromPayload: docstring claimed null is returned when payload is "not an object", but the signature already excludes that case at compile time. Docstring now lists the actual runtime cases (undefined payload, missing/non-string registryId, no lifebook match, lifebook with no wing). Test plan: db builds clean; web syntax clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced May 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes Child 1 of #193. The OSS-launch headline made executable: instead of hardcoding 7 life verticals, the twin reads MemPalace, names the domains the user actually operates in, creates a wing per domain, and surfaces them on the dashboard.
Slice 1 (worker + persistence) and Slice 2 (API + dashboard + per-Lifebook page) shipped in one PR for atomic review.
Why this fits the theme
This is the architectural philosophy made executable:
Palace.ensureWing. Future gbrain backend (Capability loop #T: @skytwin/memory-gbrain + memory-hybrid composer + CRDB adapter (target v1.0.5) #197) plugs in without touching the worker.What ships
Slice 1 — domain extractor worker
036-lifebooks.sqlmigration +lifebookRepository(upsert never resurrects hidden rows)runDomainExtractionJobwalks active users, builds memory_summary, runsrunPrompt('domain-extraction'), per-domain ensures wing + upserts lifebook. Wired on 7-day cadence in worker poll loop.vi.mockon all DB + prompt-runnerSlice 2 — dashboard + per-Lifebook UX
/api/lifebooks/:userId(list visible/all, get-by-domain with wing summary, hide, unhide)/#lifebook/<domain>page with importance badge, signals, capabilities, wing summary, hide buttonCloses for #193 Child 1
runPrompt('domain-extraction')per active user[{domainName, importance, sample_signals, suggested_capabilities}]Palace.ensureWing#/lifebook/<domainName>Out of scope (deferred follow-ups)
Test plan
pnpm build)pnpm test— 68 successful tasks)🤖 Generated with Claude Code