Skip to content

Make the AI user profile backend-owned for every client; delete the Dream state pass - #25434

Open
Git-on-my-level wants to merge 2 commits into
mainfrom
cloud-owned-ai-profile
Open

Git-on-my-level wants to merge 2 commits into
mainfrom
cloud-owned-ai-profile

Conversation

@Git-on-my-level

@Git-on-my-level Git-on-my-level commented Nov 15, 2026 •

Copy link
Copy Markdown
Member

What changed and why

David's decision (2026-11-16): one backend-owned AI user profile for every client, replacing both the client-owned v1 profile and the never-activated Dream state-pass profile v2.

Backend owns the profile. utils/ai_profile_refresh.py selects the sources server-side (canonical prompt memories, 50 tasks, active goals, 20 conversations from the last 7 days, 30 chat messages), runs the existing two-stage synthesis (utils/llm/ai_user_profile.py, memories feature) and writes users/{uid}.ai_user_profile with source and refreshed_at.

  • Cadence: at most once per owner per ~20h, triggered after a processed conversation (same fan-out as goal progress) and when GET /v1/users/ai-profile sees a stale profile. GET never blocks on generation.
  • Spend guards: per-owner Redis lock held for an hour (no Redis, no refresh), generated-compute admission, kill switch AI_PROFILE_BACKEND_REFRESH_ENABLED (default on; off stops automatic refreshes only).
  • New routes: POST /v1/users/ai-profile/refresh (8/day; 402/409/422/502 typed errors) and DELETE /v1/users/ai-profile. PATCH records source (user_edit/exploration).
  • No history: the stored text is the past profile for the next synthesis. A user edit is merged as a correction; an edit made while a refresh runs wins (the write is a transactional compare-and-set).
  • POST /v1/users/ai-profile/synthesize stays only for released desktop builds (documented as deprecated).

macOS and Windows read it. Local generation, /synthesize calls, local history tables, the daily trigger and the fire-and-forget mirror PATCH are removed. Both apps read the cloud profile through an in-memory, account-bound cache and send refresh/edit/delete (macOS also exploration) through the backend. Forward migrations drop the local ai_user_profiles tables; the chat SQL tool no longer lists them. Settings keeps its layout; copy now says the profile is generated automatically, and refresh/save/delete failures show an inline message.

Mobile needs no client change: the notes agent, MCP profile and owner-name fallback already read ai_user_profile, which is now populated for mobile-only owners too.

Dream state pass deleted. The weekly state pass and its ref-grounded ai_user_profile_v2 only ever ran in shadow with no reader (7-day shadow readout on 2026-11-15: 115 of 365 passes completed, 250 failed on response truncation or input budget). Removed with its flags, env, tests, query drivers and runbook text. Existing ai_user_profile_v2 fields on user docs are left in place.

Gate F ratchet. legacy_memory_surface_inventory.py tracked standalone profile synthesis for retirement under the proposed INV-MEM-6. Per the ruling above, the backend owner files (routers/users.py, database/users.py, utils/llm/ai_user_profile.py) leave that rule; client paths stay tracked and their baseline only shrinks (macOS service 47→41, Windows service 3→1, Windows db 10→1). INV-MEM-6 (status: proposed) lists "standalone profile synthesis" and "profile snapshot authority" among its MUST NOTs; this profile writes no memories, but the invariant text should be reconciled with this ruling when INV-MEM-6 is next revised.

Cost estimate (not measured): one refresh is ~5–15k input and ~1k output Luna tokens (≈$0.001–0.002 at the 2026-09-22 card) plus a few hundred bounded Firestore reads, once per active owner per day. Desktop owners already paid this daily client-side; mobile-only owners are new. Usage lands on the memories feature in llm usage. Kill switch above.

Product invariants affected

INV-AUTH-1: the desktop profile cache is in-memory and bound to the signed-in owner; an owner transition clears it and a read started under the previous owner is discarded on completion. No persisted owner-scoped state is added (the local table is dropped).

INV-CHAT-1: chat reads the profile as prompt context only; no transcript, session or agent state store is added. The macOS chat prompt now reads the cached profile per turn instead of once per session.

INV-MEM-4: the profile is a derived summary written to the user doc; it never creates, promotes or routes memory items.

INV-TASK-2: task assistants only read the profile text as context; no task or Candidate writer is added or changed.

How it was verified

  • Backend, each file in its own process (as CI runs): test_ai_profile_refresh.py 16 passed (cadence, kill switch, lock/compute denial, user-edit correction, edit-during-synthesis, failures never write, source formatting, transactional compare-and-set on the strict Firestore fake); test_ai_user_profile_route.py 9 passed; test_firestore_query_shapes.py 596 passed and test_firestore_runtime_witnesses.py 25 passed (new caller uses the existing start/end-date shape with a witness); plus the profile helper, cache, MCP, user-name and composer-context files.
  • State-pass removal: 81 affected unit files (every importer of dream_agent/dream_store/dream_judge, every test_dream_*, runtime-env and flag-registry tests) run per file; all pass after the query-witness fix. Pyright clean on the three Dream modules.
  • Checks: async-blocker scan 0 findings; route-policy baseline current; import side effects 0; stub pollution 0; app-client OpenAPI compatibility passed vs merge base; legacy memory ratchet PASS; flag registry regenerated; app-client OpenAPI and generated Swift/TS clients regenerated.
  • macOS: swift build --build-tests; 44 tests passed (AIUserProfileServiceTests, LocalProfileHistoryRetirementMigrationTests, ChatDiscoverabilityTests, ProactivityRetirementMigrationTests); SwiftLint 0 violations; swift-format, test-quality, flow-lint and strict e2e flow coverage clean.
  • Windows: pnpm typecheck, pnpm lint and pnpm test (5548 passed) clean.
  • Not run: a live backend or a named desktop bundle against the new routes.

Line-count exceptions

Line-Count-Exception: backend/routers/users.py | 2802 -> 2850 | DELETE and refresh routes live beside the existing GET/PATCH/synthesize AI-profile routes; a separate router would need its own role-table entry for one feature
Line-Count-Exception: backend/database/users.py | 2521 -> 2566 | write/delete/fresh-read helpers share the user_ai_profile cache policy and read path already in this module
Line-Count-Exception: backend/database/redis_db.py | 1568 -> 1578 | per-owner AI profile refresh lock, same shape as the neighbouring daily locks
Line-Count-Exception: backend/utils/conversations/process_conversation.py | 3414 -> 3417 | one fire-and-forget refresh call beside goal progress
Line-Count-Exception: desktop/macos/Desktop/Sources/Rewind/Core/RewindDatabase.swift | 3470 -> 3471 | registers the migration that drops the local ai_user_profiles table

UI evidence

UI-Evidence: none -- Settings layout unchanged; copy and an inline error line only

Failure class (fixes)

Failure-Class: none

🤖 Generated with Claude Code

View guided diff Turn on auto-fix

Git-on-my-level and others added 2 commits November 16, 2026 04:19
The backend now selects the sources, synthesizes and writes
users/{uid}.ai_user_profile for every client, at most once per owner per
~20h after a processed conversation or a stale GET, plus POST
/v1/users/ai-profile/refresh and DELETE /v1/users/ai-profile. A user edit
is carried into the next synthesis as a correction. Kill switch:
AI_PROFILE_BACKEND_REFRESH_ENABLED (default on).

macOS and Windows stop generating, storing history or mirroring the
profile; they read the cloud profile through an in-memory cache and send
refresh, edit, delete and exploration through the backend. The local
ai_user_profiles tables are dropped by forward migrations.

The Dream weekly state pass and its ref-grounded profile v2 only ever
ran in shadow with no reader; they are removed with their flags, env,
tests and runbook text.

The return-only /synthesize route stays for released desktop builds.

Failure-Class: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A user edit or delete that lands between the refresh check and its write
was overwritten by the generated text. The write now runs in a Firestore
transaction that only replaces the profile when the stored text is still
the synthesis input, and skips missing user docs. Also records the two
new fire-and-forget refresh sites in the background-task baseline.

Failure-Class: none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mintlify

mintlify Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
omi 🟢 Ready View Preview Nov 15, 2026, 9:25 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions

Copy link
Copy Markdown
Contributor

@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment /approve-ui aff3aa414fd3bb36935775cda88abb7db1ead98b. Each new head needs a fresh approval. To withdraw it, comment /reject-ui <full head SHA>. Non-UI work and unrelated access are unaffected.

@cursor cursor 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.

Agentic security review completed for this PR. One medium finding is reported inline.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment on lines +189 to +192
try:
acquired = redis_db.try_acquire_ai_profile_refresh_lock(uid, ttl=10 * 60)
except Exception:
acquired = True # An explicit, rate-limited request may proceed without Redis.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: MEDIUM

POST /v1/users/ai-profile/refresh is an authenticated LLM spend path whose Redis-backed guards both fail open. The route-policy manifest marks users:ai_profile_refresh as fail_closed, but the handler uses with_rate_limit(...) with the default fail-open behavior, and refresh_now treats any Redis lock exception as acquisition success so the request still runs. Automatic refresh_if_stale fail-closes on the same lock error.

Impact: During a Redis outage (or any deployment where Redis commands throw), an authenticated owner can issue unbounded parallel Generate Now requests. Each call occupies llm_executor and runs two-stage synthesis plus Firestore reads, so this is shared-pool cost and availability abuse rather than a data-plane IDOR.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit aff3aa4. Configure here.

@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: aff3aa414f

ℹ️ 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".

Comment thread backend/routers/users.py
"""The backend-owned profile. Never blocks on generation; a stale one is refreshed in the background."""
profile = users_db.get_ai_user_profile(uid)
if ai_profile_refresh.automatic_refresh_enabled() and ai_profile_refresh.is_stale(profile):
submit_with_context(postprocess_executor, ai_profile_refresh.refresh_if_stale, uid)

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 Move automatic profile synthesis off the postprocessing pool

When multiple stale users read their profiles or finish conversations, each refresh occupies a shared postprocessing worker throughout source collection and two synchronous LLM calls. These calls take roughly 10–40 seconds according to the new clients, and there is no cross-user concurrency limit, so a burst can occupy all 24 workers and delay conversation processing, task persistence, and other postprocessing. Both this route and the new conversation hook should start an async coordinator that dispatches database work and model calls to their respective pools. This also violates the backend guide's explicit restriction that submit_with_context is for short work, never pipelines holding a slot for more than 10 seconds.

AGENTS.md reference: backend/AGENTS.md:L271-L275

Useful? React with 👍 / 👎.

Comment on lines +212 to +214
try:
if not redis_db.try_acquire_ai_profile_refresh_lock(uid):
return 'locked'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Separate retry cooldown from the active refresh lock

After any automatic refresh acquires this lock, it remains held for an hour even after generation finishes or fails. refresh_now() acquires the same key, so Generate Now/Regenerate returns HTTP 409 for the remainder of that hour despite no refresh being in progress. Opening Settings itself triggers this condition for a stale profile; an insufficient-data attempt also prevents a manual retry after the user adds data. Release the active-operation lock when automatic work ends and implement automatic retry throttling separately so it does not block explicit requests.

Useful? React with 👍 / 👎.

Comment on lines +244 to +246
const p: Promise<AiProfileResult<AiUserProfile | null>> = run('read', async () => {
const profile = parseProfile(await send('GET', PROFILE_PATH, undefined, READ_TIMEOUT_MS))
storeIfCurrent(uid, profile)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject reads that started before a profile mutation

On Windows/Linux, a background GET can read the old profile, then finish after an edit, deletion, or manual refresh has successfully updated the cache. storeIfCurrent() checks only account identity, so that older response overwrites the mutation's cached result and marks it fresh for another hour. Prompt consumers can consequently continue using text the user just corrected or deleted. Capture a mutation revision when starting the read and discard its result if a mutation has committed since then, as the new macOS service already does.

Useful? React with 👍 / 👎.


static func registerMigration(on migrator: inout DatabaseMigrator) {
migrator.registerMigration("retireAIUserProfileHistory") { db in
try db.execute(sql: "DROP TABLE IF EXISTS \(retiredTable)")

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 locally saved profile changes before dropping the table

For users upgrading after a failed profile sync, this migration permanently deletes the only copy of their latest profile or manual corrections. The previous macOS generateProfile() and updateProfileText() implementations committed locally first and sent the backend PATCH in a fire-and-forget task whose failure was only logged, so the backend is not guaranteed to contain the current text. The Windows retirement migration makes the same assumption, and its previous edit path likewise retained local changes when syncing failed. Preserve pending local content through an owner-scoped migration or recovery step before dropping either table; regenerating from backend sources cannot recover user-written corrections that never reached the backend.

Useful? React with 👍 / 👎.

Comment on lines +233 to +236
let existing = await fetchProfile()?.profileText
let combined = Self.combine(existing: existing, exploration: text)
do {
let sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date())
let conversations = try await APIClient.shared.getConversations(
limit: 20,
startDate: sevenDaysAgo
)
return conversations.compactMap { convo in
let title = convo.structured.title
let summary = convo.structured.overview
guard !title.isEmpty else { return nil }
return "\(title): \(summary)"
}
_ = try await updateProfileText(combined, source: .exploration)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Distinguish a failed profile read before appending exploration

If file exploration finishes before this service has cached a profile and its GET fails transiently, fetchProfile() returns nil just as it does when the backend genuinely has no profile. This method then builds a replacement containing only the exploration text and PATCHes it; if that request succeeds, the existing cloud profile, including any user corrections, is silently overwritten. Even with a cache, a failed read can cause replacement using an outdated copy. Require a successful authoritative read before this read-modify-write operation, or implement an atomic append on the backend, and report failure without replacing the profile when the read fails.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-15T21:32:18.044019Z aff3aa4 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

This branch was successfully deployed

1 active deployment
staging - docs — aff3aa41 Deployed Nov 15, 2026 by mintlify[bot]
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.

1 participant