Skip to content

[recipes] Living Profile — self-updating per-fact user profile - #60

Open
alanshurafa wants to merge 36 commits into
mainfrom
contrib/alanshurafa/living-profile
Open

[recipes] Living Profile — self-updating per-fact user profile#60
alanshurafa wants to merge 36 commits into
mainfrom
contrib/alanshurafa/living-profile

Conversation

@alanshurafa

Copy link
Copy Markdown
Owner

An Omi-Memories-style living profile for any Open Brain: a daily loop distills durable facts about you from captured thoughts (per-fact rows with category, confidence, citations, and supersession), renders a profile wiki page when the wiki schema is present, and degrades to a single canonical profile thought on baseline installs.

Generalized from a production ExoCortex implementation (146-test suite there): any Chat-Completions-compatible LLM, env-driven subject identity, watermark-invariant incremental processing (a mid-run failure never silently skips thoughts), fail-closed sensitivity-tier handling, category-scoped slot supersession, and prompt-injection delimiters. Ships dashboard-snippets for the extension-slot pattern.

Siblings: entity-wiki covers pages about others; wiki-synthesis covers narrative autobiography; this covers the structured self-profile.

🤖 Generated with Claude Code

msykes and others added 30 commits May 13, 2026 08:28
Adds a small drop-in extension surface so dashboard add-ons can register
a new route + sidebar entry without touching core files:

  - extensions.config.ts: typed registry, empty by default
  - components/Sidebar.tsx: splits nav into core + extensions + trailing,
    resolves icon keys via a registry; adds clock/folder/plug/sparkles
  - lib/api.ts: exports apiFetch so extension pages can reuse the
    authenticated JSON fetch + error plumbing
  - EXTENSIONS.md: convention doc (folder layout, auth, icon registry,
    sidecar vs in-tree REST routes)

After this change, future extensions touch only their own folder under
app/<route>/ and a single entry in extensions.config.ts.

https://claude.ai/code/session_01AvZANjBLBpEh3eFzPzzGGH
… variants

JavaScript Number loses precision beyond 2^53. All ID parsing now uses
string validation instead of Number(). Added extractThoughtId() to
handle all upsert_thought RPC response shapes (scalar, {id}, {thought_id}).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Why: === short-circuits at the first differing byte. For a public Edge
Function deployed with --no-verify-jwt where MCP_ACCESS_KEY is the only
auth layer, a remote attacker can use response-latency differences to
byte-wise discover the key. Replace with XOR-accumulate over equal-length
byte arrays so comparison time is independent of where inputs differ.
Why: The gateway is deployed with --no-verify-jwt so MCP_ACCESS_KEY is
the only auth layer. Allow-Origin: * combined with write methods let any
origin attempt a cross-origin call with a leaked key, and the complete
absence of throttling meant a single leaked key could burn Supabase row
quota and OpenRouter credits at fetch speed.

- CORS_ALLOWED_ORIGINS env var (comma-separated) restricts to an
  explicit allowlist; unset keeps legacy * for backward compatibility.
- RATE_LIMIT_PER_MIN env var (default 100) enforces a rolling 60s
  per-key cap with SHA-256 hashed bucket keys and 429 + Retry-After.
- README Security section documents both settings and their defaults.
Why: /ingest and /ingestion-jobs/:id/execute forwarded request bodies
to smart-ingest with no AbortSignal, no size limit, and a bare
response.json() call. Three failure modes: hung upstream holds a worker
slot until the 150s edge timeout kills it; an attacker can POST a 50 MB
blob that the Edge Function will parse, stringify, and forward; an
HTML/text error page from Supabase causes response.json() to throw a
SyntaxError that the top-level catch mislabels as 'Invalid JSON in
request body' — pointing the caller at their own payload instead of
the real upstream failure.

- readJsonWithCap() rejects bodies over 1 MB with 413 before parsing.
- proxyFetchJson() uses AbortController with 60s timeout, distinguishes
  upstream_timeout (504), upstream_unreachable (502),
  upstream_invalid_json (502 + raw text snippet), and upstream_error
  (passes upstream status) from legitimate JSON responses.
Why: The sort query string was forwarded verbatim to PostgREST's order
param. The service role can read every column on thoughts including
embedding (1536-dim vector — heavy scan) and sensitivity_reasons (JSONB
with PII-adjacent strings). An attacker could enumerate the schema,
force unindexed scans, or partially exfiltrate metadata through sort
order of returned rows.

Allowlist: id, created_at, updated_at, importance, quality_score.
Unknown sort values return 400 with the valid options.
Why: String(error) on a PostgrestError or an Error wrapped with a
PostgREST message leaked internal SQL text — table names, column names,
and constraint names — to the caller. Under service-role access,
constraint errors include data values. That is an info-leak vector for
any authenticated attacker.

Return a stable opaque payload: { error: 'internal_error', code:
'GENERIC', error_id: <uuid> }. Log the full error server-side tagged
with the same UUID so operators can correlate without exposing internals.
Why: PUT /thought/:id only refreshed content, embedding, type, and
importance. A caller could rewrite a standard thought to include a
credit-card number, SSN, or health identifier and the sensitivity_tier
would stay at standard — so the thought would still be returned in
exclude_restricted=true queries and in semantic search without
filtering.

Now runs detectSensitivity on the new content and applies
resolveSensitivityTier against the existing tier (escalation-only).
Writes sensitivity_tier + sensitivity_reasons into the update only when
the tier actually changes. Adds an opt-in force_sensitivity flag that
lets the caller bypass escalation-only semantics — but even with the
flag the result is clamped to at least what detection returned, so
force_sensitivity cannot hide detected PII.
…tchWithTimeout

Adds an AbortController-backed fetchWithTimeout helper to
_shared/helpers.ts and rewires all 5 outbound fetches (OpenRouter +
OpenAI embeddings; OpenRouter + OpenAI + Anthropic chat completions)
through it. Default 60s, override via FETCH_TIMEOUT_MS env.

Also widens isTransientError to match the new "fetch timeout" error
string plus "aborted" and 504, and adds a sibling isFatalProviderError
for 400/401/402/403 so BLOCKER-2 can fail-fast on hard auth/quota
errors instead of cascading to fallback providers.

Why: on upstream provider stall every caller was hanging until the
Supabase Edge Function runtime killed the connection (~150s). With
5 LLM calls per capture in the worst case, a single capture_thought
could pin an Edge Function for ~10+ minutes. This is the Wave-wide
timeout pattern applied consistently here.
Three layered safeguards so capture_thought cannot rack up unbounded
per-request LLM charges:

1. Fingerprint-first dedup in capture_thought — before we pay for any
   classification or embedding, hash the raw content and check if it
   already exists. Identical re-captures short-circuit with an
   action="deduplicated" result. upsert_thought dedups too, but by
   then we've already burned the enrichment cycle.

2. Global call budget ENHANCED_MCP_MAX_CALLS (default 10000). Edge
   Function instance tracks cumulative classifier invocations and
   returns fallback metadata once the cap is hit. Set to 0 to disable
   classification entirely for bulk imports.

3. Fail-fast on 400/401/402/403 via a new isFatalProviderError. The
   old path treated ALL errors as cascade-worthy, so a single 402
   (payment required) on OpenRouter would fire OpenAI *and* Anthropic
   in sequence — double-billing the user on the two providers that
   had nothing to do with the original failure. New path: fatal
   errors skip the fallback chain entirely. Also caps attempt 3 at
   exactly ONE fallback provider instead of iterating through all
   remaining providers.

Why: the original extractMetadata could fire up to 4 LLM calls per
capture (primary + retry + 2 fallback providers). A batch of 100
captures on a rate-limited primary would easily hit 300+ calls with
1.5s retry delays adding 150+ seconds of wall-clock, and any 402 on
OpenRouter would double-bill into OpenAI and Anthropic regardless of
whether either could plausibly fix the problem.
…ding sensitivity

update_thought was writing detectSensitivity(content).tier directly to
the row, which meant editing a `personal` thought to remove the
sensitive phrasing silently relabeled it as `standard` — and any
restricted pattern in the new content was happily persisted to the
cloud even though capture_thought refuses that same content.

Fix:
1. Pre-flight reject if the NEW content trips any RESTRICTED_PATTERN.
   Matches capture_thought's behavior and returns the detection
   reason in the error so the caller knows why.
2. Use resolveSensitivityTier() with the EXISTING row's tier as the
   floor. Escalation-only semantics: personal -> standard is blocked,
   standard -> personal / personal -> restricted still work. This is
   the same helper prepareThoughtPayload already uses everywhere else.

Why: this was a real data-leak vector. A user captures "my salary is
$120k" as `personal`, later rephrases it to "my income situation is
comfortable", and the row goes back to `standard`. The next broad
list_thoughts exposes it to any connected client. Update paths must
maintain the escalation invariant that capture paths enforce.
…release

Removes the delete_thought tool registration, the README table row,
and the 14 -> 13 tool count everywhere (README intro, Expected
Outcome, Tool Surface Area, metadata.json description). Renumbers
the remaining section comments in index.ts.

Adds an "Intentionally Excluded From This Release" section to the
README explaining why delete_thought will ship in a follow-up:
hard DELETE has no tombstone path on the enhanced-thoughts schema
today, and the maintainer's PR NateBJones-Projects#127 guidance was "depreciate and
version rather than delete." Shipping a safe soft-delete requires a
`deleted_at` column and a restore_thought sibling that don't exist
yet.

Why: the drafted implementation was hard DELETE on a row with no
deleted_at column, no audit trail, no restore path. Aligning with
PR NateBJones-Projects#127 posture is cheaper than trying to bolt on soft-delete here
without schema support — we'll land both tool and schema changes
together in a later PR.
…prefix

Renames the four tools that share names with server/index.ts so both
MCP servers can stay connected without the model seeing duplicate
tool entries:

- search_thoughts  -> brain_search_thoughts
- list_thoughts    -> brain_list_thoughts
- capture_thought  -> brain_capture_thought
- thought_stats    -> brain_thought_stats

Also updates the README What-It-Does, Step 4, Expected Outcome, and
Tool Reference sections to reflect the new names and explain the
collision-prevention intent, plus matching section comments and
internal error-log labels in index.ts for grep-ability.

Why: Claude Desktop and most MCP clients list connector tools in a
flat namespace. When the stock server and this server both expose
`capture_thought`, the model has to guess which one the user meant;
if it picks the stock one, there's no sensitivity pre-flight and
"restricted content stays local" silently breaks. `brain_` prefix is
a cheap one-pass rename that eliminates the footgun by design.
…e tableExists

Two related fixes:

1. The schema guard in ops_source_monitor was looking for
   `ops_source_volume_24h`, a view name that exists in neither this
   repo nor the brain-health-monitoring recipe. Result: once the user
   installed the recipe (which defines `ops_source_ingestion_24h`,
   `ops_source_errors_24h`, `ops_source_recent_failures`), the tool
   STILL returned "install required views" because the guard looked
   for a view nothing creates. Fix: check `ops_source_ingestion_24h`
   (one of the real views) and add a partial-install detection that
   returns a graceful "only partially installed" response if any one
   of the three views is missing.

2. `tableExists` previously required the target to have an `id`
   column (`select("id")`). That works for tables but not for views
   like `ops_source_errors_24h` which has only
   `(source, error_events_24h)`. Switched to
   `select("*", { head: true, count: "exact" }).limit(0)` which
   performs a HEAD request with no data transfer and no column-name
   dependency, so it works on any table or view.

Why: without this fix the tool never activates even when the user
installs exactly the recipe the README told them to install — a
pure dead-end UX. And `tableExists` was one unusual view schema away
from false-negatives on other operational tooling.
Adds an escapeLikePattern helper that escapes `\`, `%`, and `_` in a
user query before interpolating into an ILIKE pattern. graph_search
now runs `%${escapeLikePattern(query)}%` instead of `%${query}%`.

Why: a user searching for "100%" (e.g. "100% uptime") was producing
the ILIKE pattern `%100%%` which matches every entity whose
canonical_name contains "100" — effectively the whole graph for a
dense brain, capped only by LIMIT. And "a_b" was matching "aab",
"axb", etc. Not SQL injection (PostgREST parameterizes the value)
but a DoS-adjacent correctness bug that passes unit tests on
alphanumeric queries and falls over on real queries.
… fallback

Two auth hardening changes:

1. Replace `provided !== MCP_ACCESS_KEY` with a timingSafeEqualStrings
   helper that prefers crypto.subtle.timingSafeEqual and falls back to
   a manual XOR loop. Length mismatch short-circuits — acceptable for
   the fixed 32-char access key; a variable-length key would need a
   different pattern.
2. Drop the `?key=<access-key>` query-parameter fallback. Auth now
   requires `x-brain-key: <key>` OR `Authorization: Bearer <key>`
   only. Query strings end up in Supabase request logs, CDN logs, and
   any intermediate proxy logs — leaking the credential into places
   that don't get rotated with the secret itself. Also updated README
   Step 3 and Troubleshooting to document the header-only posture.

Why: timing-safe comparison is the Wave-wide review bar for any
bearer-equivalent token, and URL query credentials are a classic
"works today, leaks tomorrow" anti-pattern.
… search

Two-layer defense for the "top-N then post-filter" correctness bug:

1. Forward start_date / end_date into the match_thoughts RPC filter
   payload along with exclude_restricted. RPC versions that honour
   these filter keys will pre-filter at the SQL level before applying
   the similarity cutoff — making the behavior server-side correct.
   Older RPCs ignore unknown filter keys and we fall through to (2).

2. When a date filter is active, over-fetch 3x the requested limit
   (capped at 500) instead of limit + 50. The previous +50 slack was
   catastrophic on dense recent brains: a top-200 result set where
   all 200 matches were recent would silently return 0 rows for an
   old-date query even when relevant matches existed below rank 200.

Also documents the limitation in a new README "Known Limitations"
section so users running on the older RPC signature understand the
workaround (switch to mode: "text" or narrow the query).

Why: silent empty results are the worst class of search UX failure
because users assume "no matches" rather than "cutoff too tight." The
over-fetch cost is bounded at 500 rows, a few milliseconds on any
reasonable brain size.
…ture

Adds a new "Security" section to the README covering:

1. This server's own auth model (constant-time compare, header-only
   MCP_ACCESS_KEY, service_role under the hood as the sensitivity-
   filter boundary).
2. Companion schema risk: the enhanced-thoughts schema installs its
   three SECURITY DEFINER RPCs with service_role-only grants by
   default; granting anon/authenticated on those RPCs would be an
   RLS bypass because SECURITY DEFINER runs with the function
   owner's privileges. Combined with a publicly-reachable
   enhanced-mcp deployment that pattern would let anyone with the
   Supabase project URL + anon key read thought content directly,
   routing around this server's sensitivity filtering.

Why: the README previously advertised the RPC names (which makes
them discoverable) without warning that exposing them to anon
collapses the whole sensitivity story. A one-paragraph callout
costs nothing and is exactly the kind of "safe defaults" note the
upstream gate reviewers expect from integration docs.
PR NateBJones-Projects#218 shipped the Open Brain capture extension without the runtime hardening proven in the downstream ExoCortex build. Graft it in, transliterated to the Open Brain naming/config regime: 120s ingest timeout + HTTP status on API errors; permanent (4xx) vs transient error classification with rejected-capture logging; bucketed seen-fingerprint cache for O(1) dedup; session-metrics persistence across MV3 worker restarts; 200-item retry-queue cap with dead-lettering; sync exponential backoff, crash-safe cursor flushing, in-flight locks and auth-expiry tracking for Claude/ChatGPT; sensitivity fail-closed retry; content-bridge channel-closed guard; deepest-match Claude DOM extraction; popup last-error surfacing. Preserves the Open Brain rebrand, empty-by-default endpoint + HTTPS/loopback policy, GET/SAVE_CONFIG flow, and history-only Gemini design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Accepting the access key via ?key= leaks it into CDN/proxy/Supabase
access logs. Accept it only via the x-brain-key or Authorization: Bearer
headers, matching enhanced-mcp. Updates README examples + auth docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
State that the service-role backend bypasses RLS (key = full-brain
access, single-tenant by design) and that the key must be high-entropy
since rate limiting is best-effort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document that MCP_ACCESS_KEY should be high-entropy (single shared
secret, single-tenant) and that the wildcard CORS is deliberate and
safe given header-based auth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/dashboard-extension-hook

Add config-driven extension hook for sidebar
justfinethanku and others added 6 commits June 25, 2026 09:04
…lanshurafa/chrome-capture-gemini-history

[integrations] Chrome extension — Gemini bulk history sync (Phase B/C)
…lanshurafa/enhanced-mcp

[integrations] Enhanced MCP server (alpha tool suite)
…lanshurafa/rest-api-gateway

[integrations] REST API gateway
…-ob1-gate-v2-empty-runs

[docs] Fix OB1 gate v2 workflow runs
Distills durable facts from the whole brain (not one wearable), cites
evidence per fact, and supersedes stale facts instead of duplicating
them. Degrades to a single canonical thought on baseline OB1 without
wiki_pages; upgrades to real wiki sections when that schema is present.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0460d90dec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


function json(data: unknown, status = 200, req?: Request): Response {
const headers = req ? corsHeadersFor(req) : {
"Access-Control-Allow-Origin": CORS_ALLOWED_ORIGINS.length === 0 ? "*" : "null",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve allowed origins on JSON responses

When CORS_ALLOWED_ORIGINS is set, this fallback emits Access-Control-Allow-Origin: null for every handler that calls json(...) without passing req (for example /health, /search, and /recent). The preflight uses corsHeadersFor(req), but the actual response is then blocked by browsers for the documented dashboard origins, so production CORS allowlisting makes the gateway unusable. Thread req through the helper or otherwise reflect the allowed Origin on all responses.

Useful? React with 👍 / 👎.

Comment on lines +488 to +490
let query = supabase.from("thoughts")
.select("id, content, type, source_type, importance, metadata, created_at, updated_at")
.order("created_at", { ascending: false })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Filter restricted rows from recent results

In brains that contain sensitivity_tier = 'restricted', this /recent query returns raw content from those rows because it has no exclude_restricted default or filter, unlike the other read endpoints. Since this gateway is documented as the sensitivity-filter boundary, any authenticated client using Recent can display restricted memories without explicitly opting in; add the same default neq('sensitivity_tier', 'restricted') behavior here.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants