Skip to content

feat(#193 Child 1): Emergent Lifebooks — domain extractor + dashboard surface#242

Merged
jayzalowitz merged 1 commit into
mainfrom
feat/emergent-lifebooks
May 9, 2026
Merged

feat(#193 Child 1): Emergent Lifebooks — domain extractor + dashboard surface#242
jayzalowitz merged 1 commit into
mainfrom
feat/emergent-lifebooks

Conversation

@jayzalowitz

Copy link
Copy Markdown
Owner

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:

  • Hard rails preserved: lifebook visibility is the only user-driven write; domains cannot be added by hand. They emerge from memory.
  • Boring deterministic: wings/rooms/drawers — same MemPalace schema. The lifebooks table is a thin index.
  • Adaptive: domain-extraction prompt is the only place domains get named. New "kind" of Lifebook is a prompt edit, not a deploy.
  • Memory port: read via existing repo, write via 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.sql migration + lifebookRepository (upsert never resurrects hidden rows)
  • runDomainExtractionJob walks active users, builds memory_summary, runs runPrompt('domain-extraction'), per-domain ensures wing + upserts lifebook. Wired on 7-day cadence in worker poll loop.
  • 12 unit tests with vi.mock on all DB + prompt-runner

Slice 2 — dashboard + per-Lifebook UX

  • /api/lifebooks/:userId (list visible/all, get-by-domain with wing summary, hide, unhide)
  • "Your Lifebooks" card on dashboard, top 5 with importance badges
  • /#lifebook/<domain> page with importance badge, signals, capabilities, wing summary, hide button
  • Dynamic SPA route in app.js

Closes for #193 Child 1

  • ✅ AC#1 worker runs runPrompt('domain-extraction') per active user
  • ✅ AC#2 schema persists [{domainName, importance, sample_signals, suggested_capabilities}]
  • ✅ AC#3 each domain creates/updates a MemPalace wing via Palace.ensureWing
  • ✅ AC#5 UX surface at #/lifebook/<domainName>
  • ✅ AC#6 dashboard "Your Lifebooks" section with top 5
  • ✅ AC#7 hide preserves underlying memory, removes from suggestions/dashboards

Out of scope (deferred follow-ups)

  • AC#4 per-Lifebook briefing prose (briefing-generator changes)
  • Capability filter by domain on Capabilities page
  • Provenance graph wing-filter (link wired, page reader is follow-up)

Test plan

  • All 34 packages build clean (pnpm build)
  • All test suites pass (pnpm test — 68 successful tasks)
  • 12 new worker tests cover happy path + edge cases
  • CI green

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings May 9, 2026 04:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lifebooks persistence (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 thread apps/worker/src/index.ts
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 thread apps/web/public/js/app.js
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 };
}
@jayzalowitz jayzalowitz merged commit 51d63c5 into main May 9, 2026
12 checks passed
@jayzalowitz jayzalowitz deleted the feat/emergent-lifebooks branch May 9, 2026 05:22
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants