diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index b00a2707..cf4bd989 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -306,10 +306,13 @@ browser tab being open.** The fleet runs server-side in the venice edge function on two kinds of trigger: - **Chat-turn tail** - `getStreamingResponse` registers a - sequential curation -> samskara -> reflection chain under + sequential curation -> samskara chain under `EdgeRuntime.waitUntil` after each completed turn. The tail is the low-latency driver for work the user notices - in-session: thread titles, samskara mints, fresh memories. + in-session: thread titles, samskara mints. Reflection is + deliberately NOT in the chain - memory formation is + sweep-only so it keeps a fixed, predictable cadence (see + `memory.md`). - **pg_cron sweeps** - scheduled jobs that pg_net-POST a function route, as the catch-up and maintenance drivers. The minute ladder: embed backfill `*/5`, bias `:03`, wiki diff --git a/docs/dev/chat.md b/docs/dev/chat.md index 6e457e2b..c760b7b0 100644 --- a/docs/dev/chat.md +++ b/docs/dev/chat.md @@ -412,15 +412,13 @@ A chat turn goes: `last_summarised_msg_id`. The chat loop creates that assistant message; the background workers pick it up on their next poll. See `./summaries.md`. -- **Reflection** — driven directly from the completed-turn - tail. `getStreamingResponse` (the streaming orchestrator) - fires `reflectOneThread` via `EdgeRuntime.waitUntil` after - the chat response ships, draining one day-gate-eligible - thread from the reflection queue as background work. The - chat loop creates the terminal assistant message that makes - a thread eligible; reflection acts on it on the same turn's - tail (after at least a calendar day has elapsed). See - `./memory.md`. +- **Reflection** — no direct call. The chat loop creates the + terminal assistant message that makes a thread eligible; + the hourly reflection sweep (reflection's only driver) + picks the thread up once its newest message is at least a + calendar day old. Deliberately not tail-driven, so edits + and retries any time before that sweep are what reflection + sees. See `./memory.md`. - **Topics** — `Chat.svelte` owns the `selectedTopics` / `topicsVocabulary` state for the drawer's topic-filter dropdown and threads `selectedTopics` through the three diff --git a/docs/dev/memory.md b/docs/dev/memory.md index 85c1c2d0..ee37f88c 100644 --- a/docs/dev/memory.md +++ b/docs/dev/memory.md @@ -41,11 +41,11 @@ ride the system prompt independently. ## Role in the app Every turn the main chat model can call `memory_recall` to pull in -relevant memories; at the tail of each completed streaming chat turn, -the venice edge function fires a reflection pass that reads a settled -thread and decides whether to write, update, or invalidate memories -based on what it saw. The store lives in each user's own Supabase; -the writes happen through the same tool harness the main chat uses. +relevant memories; on an hourly cron cadence, the venice edge +function's reflection sweep reads settled threads and decides whether +to write, update, or invalidate memories based on what it saw. The +store lives in each user's own Supabase; the writes happen through +the same tool harness the main chat uses. From the user's perspective this is the Memory feature documented in `docs/user/memory.md`. The dev side has five moving parts: @@ -53,8 +53,8 @@ in `docs/user/memory.md`. The dev side has five moving parts: 1. **The store** — `memories` table, RLS-scoped, with a pgvector `embedding` column populated asynchronously. 2. **The writer** — the reflection agent runs in the venice edge - function, reads settled threads end-to-end, and uses a - write-scoped subset of the memory tools. + function on the hourly sweep, reads settled threads end-to-end, + and uses a write-scoped subset of the memory tools. 3. **The reader** — the recall agent runs in the venice edge function, inline in the tool dispatch when the main model invokes the `memory_recall` tool. Read-only. @@ -124,9 +124,9 @@ in `docs/user/memory.md`. The dev side has five moving parts: `memory_conversation` row per surfaced memory - the co-occurrence hint queue rem drains. - `supabase/functions/venice/agents/reflection.ts` — the reflection - agent. Exports `reflectOneThread(adminClient, userId)`; runs - write-scoped with no return value (side effects = memory tool - calls). Its prompt instructs TIMELESS memories - no "this session", + agent. Exports `runReflectionSweepTick(adminClient)`; runs + write-scoped (side effects = memory tool calls) and returns a + per-tick drain summary. Its prompt instructs TIMELESS memories - no "this session", no write-date narration, no first-person AI self-logging - because a body that stamps when it was written reads back later as a current-chat event (the row's `created_at` already records when it @@ -327,26 +327,25 @@ in `docs/user/memory.md`. The dev side has five moving parts: headless read-only tool loop on the fast tier. Returns a structured JSON output the tool encodes as the `role='tool'` message payload for the next round. -- **Reflection (edge function tail)** — at the end of each - successfully completed streaming chat turn, `getStreamingResponse` - fires `reflectOneThread(adminClient, userId)` via - `EdgeRuntime.waitUntil` as background work after the chat response - ships. Each invocation opportunistically drains one day-gate- - eligible thread from the existing reflection queue - NOT - necessarily the thread that just finished. Claim mutual exclusion - is the per-thread claim RPC (each call uses a fresh random holder - id); no `worker_leases` row is involved. -- **Reflection catch-up sweep** — pg_cron job +- **Reflection sweep (the only driver)** — pg_cron job `nak-reflection-sweep` (hourly, minute 27) pg_net-POSTs - `/reflection-sweep` -> `runReflectionSweepTick`, which claims the - most-overdue eligible thread across ALL users + `/reflection-sweep` -> `runReflectionSweepTick`, which drains the + day-gated queue one thread at a time: claim the most-overdue + eligible thread across ALL users (`claim_next_thread_for_reflection_sweep`, SECURITY DEFINER, - per-owner timezone off the profile) and runs the same shared - reflect body. Exists because the tail only fires when its owner - converses - without it a dormant account's queue never moves. The - per-thread claim makes tail + sweep double-driving safe. The dev - shim ticks this route too. -- **Reflection attempt cap** — both reflection claims count + per-owner timezone off the profile), reflect it, claim the next - + until the queue is empty, the per-tick cap (5 threads) is hit, or + the tick's time budget (180s of new claims) is spent; the next + hourly tick resumes. Reflection deliberately does NOT ride the + chat turn's waitUntil tail (unlike curation and samskara): the + fixed cadence gives the user a predictable window to edit or + retry a settled conversation before reflection reads it, and the + strictly sequential drain keeps at most one reflection agent + writing to the store per tick. Claim mutual exclusion is the + per-thread claim RPC (each cycle uses a fresh random holder id); + no `worker_leases` row is involved, and overlapping ticks simply + claim different threads. The dev shim ticks this route too. +- **Reflection attempt cap** — the sweep claim counts ATTEMPTS at claim time (`threads.reflection_attempt_msg_id` + `reflection_attempt_count`): three claims against the same terminal message and the thread stops being offered, until a new @@ -503,7 +502,7 @@ in `docs/user/memory.md`. The dev side has five moving parts: - **Reflection claim columns** — `threads.reflection_holder_id` and `threads.reflection_claim_expires_at` are the mutual- exclusion primitive for the server-side reflection path. - The claim-RPC pair (`claim_next_thread_for_reflection` / + The claim-RPC pair (`claim_next_thread_for_reflection_sweep` / `mark_thread_reflected_if_claimed`) uses a fresh random holder id per call; there is no `worker_leases` row for reflection. - **`memories.last_librarian_visit_at timestamptz`** — per-row @@ -651,14 +650,16 @@ from the same ports); the browser carries only the wire schemas. `done`); attaching it also injects the `activity` narration parameter into the tools' wire schemas, so the sweep paths (no listener) stay narration-free. -- `reflectOneThread(adminClient, userId)` — the edge function - entry point. Claims one day-gate-eligible thread (newest message - on a prior calendar day in the user's timezone, with >= 2 user - messages), runs the reflection agent's headless tool loop, and - stamps `last_reflected_msg_id` via a claim-guarded RPC. The - agent's "answer" is whatever `memory_*` tool calls it made; - the final text is discarded. Returns without error when the - queue is empty or the claim is lost. +- `runReflectionSweepTick(adminClient)` — the edge function + entry point. Drains day-gate-eligible threads (newest message + on a prior calendar day in the owner's timezone, with >= 2 user + messages) one at a time up to the per-tick cap/budget. Each + cycle runs the reflection agent's headless tool loop and stamps + `last_reflected_msg_id` via a claim-guarded RPC. The agent's + "answer" is whatever `memory_*` tool calls it made; the final + text is discarded. Non-throwing; returns a per-tick summary + (`reflected` / `claimLost` / `emptySlice` counters plus what + stopped the drain). ## The body-length budget (non-growth rule) @@ -826,11 +827,11 @@ renders the delta as a chip (`memorySizeDelta` in `./topics.md` under "Memory topics" for the unit shape, the schema deltas, and the trigger / claim discipline. - **Summaries / conversation recall** — separate store (thread - rows), separate agents. Summary and reflection both run in the - venice edge function, fired from the completed-chat-turn tail - with an hourly sweep as catch-up. Both use the same per-row - claim-RPC pattern on `threads` but have independent claim - columns and no shared lease. + rows), separate agents. Both run in the venice edge function: + summary from the completed-chat-turn tail with an hourly sweep + as catch-up, reflection from its hourly sweep only. Both use + the same per-row claim-RPC pattern on `threads` but have + independent claim columns and no shared lease. - **Logging** - the reflection agent and both librarian passes emit breadcrumbs through `createEdgeLogger` (sources `reflection`, `rem`, `deep-sleep`), which both writes to the diff --git a/docs/dev/samskara.md b/docs/dev/samskara.md index c1e76f88..5f8e0fc0 100644 --- a/docs/dev/samskara.md +++ b/docs/dev/samskara.md @@ -254,7 +254,7 @@ toast is just a glance cue that the bias model is forming. LLM work. - **`samskaraOnTurnTail(admin, userId)`** - fired from `getStreamingResponse`'s `EdgeRuntime.waitUntil` tail on - completed turns, sequenced curation -> samskara -> reflection. + completed turns, sequenced curation -> samskara. Runs the session-responsive phases: a capped assimilate drain, then one pair-relate probe, then one mint-tier1 probe. (Reaction scoring is no longer a tail phase - it moved to the next-day @@ -566,8 +566,8 @@ embeddings backfill's claim -> process -> save shape. Two drivers run the phases, split by timing sensitivity: - **Turn tail** (`samskaraOnTurnTail`) - an assimilate drain - capped at `TAIL_ASSIMILATE_CAP` (3) so the - chain never delays reflection behind it, then one pair-relate + capped at `TAIL_ASSIMILATE_CAP` (3) so one tail invocation + never monopolises the background budget, then one pair-relate probe, then one mint-tier1 probe (the in-session toast surface). - **Hourly sweep** (`runSamskaraSweepTick`) - a cross-user @@ -1175,7 +1175,7 @@ summarizer reads samskaras to feed the agent. options struct, and samskara is currently the only caller. Function side, `getStreamingResponse`'s waitUntil tail drives `samskaraOnTurnTail` on every completed turn, sequenced - curation -> samskara -> reflection. `Chat.svelte` mounts the + curation -> samskara. `Chat.svelte` mounts the single `` component and owns the `subscribeToSamskaraInserts` realtime subscription that turns `samskara-mint` Broadcast events into `SAMSKARA_MINT_EVENT`. See @@ -1192,9 +1192,9 @@ summarizer reads samskaras to feed the agent. predictive bias the model formed on its own. No data flows between them. The reflection agent reads thread transcripts and writes memories; the samskara assimilator reads - individual exchanges and writes substrate. Both ride the same - waitUntil tail, samskara first (reflection can span minutes - and samskara carries the fleet's only hard timing window). + individual exchanges and writes substrate. Samskara rides the + waitUntil tail (it carries the fleet's only hard timing + window); reflection is sweep-only on an hourly cadence. See `./memory.md`. - **Bias profile** - sibling server-side pipeline, no data flow. Bias profile aggregates cognitive-bias observations across diff --git a/docs/dev/second-thoughts.md b/docs/dev/second-thoughts.md index 8e9298db..281f3b03 100644 --- a/docs/dev/second-thoughts.md +++ b/docs/dev/second-thoughts.md @@ -63,7 +63,7 @@ user live. Runs as a turn-tail unit (`secondThoughtsOnTurnTail`), FIRST in the `EdgeRuntime.waitUntil` tail of `getStreamingResponse.ts` where -`curateOnTurnTail` / `samskaraOnTurnTail` / `reflectOneThread` also run, +`curateOnTurnTail` / `samskaraOnTurnTail` also run, and only on `terminalKind === 'completed'`. Detached, so it adds zero latency to the user-visible turn. Best-effort and non-throwing: a failure leaves the row without a verdict, never breaks the turn. @@ -331,7 +331,7 @@ composition + wiring. ## Interactions - **Chat ([`chat.md`](./chat.md))** - the reviewer is a turn-tail unit - next to curation/samskara/reflection. The refinement reuses the + next to curation/samskara. The refinement reuses the browser send/regenerate flow for one extra APPEND turn anchored to the original user message; it touches the send path, `commit_assistant_message`'s anchor handling (append does not conflict diff --git a/docs/qa/use-cases/context-recall-priming.md b/docs/qa/use-cases/context-recall-priming.md index 89a7c7da..f914cc2e 100644 --- a/docs/qa/use-cases/context-recall-priming.md +++ b/docs/qa/use-cases/context-recall-priming.md @@ -170,7 +170,7 @@ the recall-quality properties those subsystems now owe. 8. **Timeless writer (reflection).** In a SEPARATE existing thread (>= 2 user messages, last activity on a prior calendar day so the drain is eligible), hold a short exchange that teaches one clear new fact about - the user. Let reflection run (chat-turn tail, or the hourly sweep; see + the user. Let reflection run (the hourly sweep; see [reflection-drain](./reflection-drain.md) to force it). Inspect the newest memory: diff --git a/docs/qa/use-cases/reflection-drain.md b/docs/qa/use-cases/reflection-drain.md index 7aab68b9..be85e6ed 100644 --- a/docs/qa/use-cases/reflection-drain.md +++ b/docs/qa/use-cases/reflection-drain.md @@ -1,11 +1,14 @@ -# Reflection: tail drain, catch-up sweep, attempt cap +# Reflection: sweep drain loop, attempt cap ## Covers -The reflection agent's two drivers - the chat-turn waitUntil tail -and the hourly `/reflection-sweep` cron route - plus the per-thread -claim mutual exclusion and the attempt cap +The reflection agent's single driver - the hourly `/reflection-sweep` +cron route's per-tick drain loop (claim one thread, reflect it, claim +the next, up to the cap/time budget) - plus the per-thread claim +mutual exclusion and the attempt cap ([dev: memory](../../dev/memory.md), "Reflection" entries). +Reflection deliberately does NOT run on the chat-turn tail: a +completed turn must produce no reflection activity. ## Preconditions @@ -22,13 +25,17 @@ claim mutual exclusion and the attempt cap where id = ''; ``` +- For the multi-thread drain check (step 2), make two or more + threads eligible with the same statement. - `SR` = the service-role key from `supabase status -o json`. ## Steps -1. Tail drain: send a chat message in ANY thread and let the turn - complete. Watch the Logs drawer's `reflection` source. -2. Sweep: tick the route directly and watch the same source: +1. No tail drive: with an eligible thread queued, send a chat + message in ANY thread and let the turn complete. Watch the Logs + drawer's `reflection` source. +2. Sweep drain: tick the route directly with 2+ eligible threads + queued and watch the same source: ```sh curl -s -X POST \ @@ -56,15 +63,17 @@ claim mutual exclusion and the attempt cap ## Expected -- (1) On a turn whose user has an eligible OLDER thread queued, the - drawer shows `[reflection] picked up thread ...` then - `finished thread ... (N tool calls over M messages)`; new - memories appear for content-bearing threads. With an empty queue - the tail is silent at default levels (trace line only). +- (1) The completed turn produces NO `reflection` lines in the + drawer and the eligible thread's `last_reflected_msg_id` does not + advance - the tail no longer drives reflection. - (2) Immediate `{"accepted":true}` (the tick runs detached); the - drawer shows the same picked-up/finished pair when a thread was - eligible, `last_reflected_msg_id` advances, and - `reflection_attempt_count` resets to 0 on the mark. + drawer shows a `picked up thread ...` / `finished thread ... (N + tool calls over M messages)` pair PER eligible thread, one after + another (sequential, not interleaved), until the queue empties or + the cap (5 threads) / time budget (180s of new claims) stops the + loop. `last_reflected_msg_id` advances and + `reflection_attempt_count` resets to 0 on each mark; new memories + appear for content-bearing threads. - (3) Gateway 401 without a JWT; route-level `{"error":"forbidden"}` 403 with a non-service JWT. - (4) The count reaches 3 and the fourth claim returns no row for @@ -73,7 +82,8 @@ claim mutual exclusion and the attempt cap hosted invocation wall clock, OR caps out at 3 attempts and stops burning Venice calls. Local measurement: ~9 minutes end-to-end on a 14-message/69KB thread - likely over the hosted window; the cap - is the backstop. + is the backstop, and the 180s claim cutoff keeps one slow thread + from dragging later claims past the wall clock with it. ## Cleanup @@ -90,7 +100,7 @@ update threads set reflection_attempt_count = 0, | Date | Env | Commit | Result | Notes | | ---- | --- | ------ | ------ | ----- | -| 2026-06-09 | local | 4e33cc3 | pass (1) | four queued reflections drained in order across turns, drawer lines live | -| 2026-06-10 | local | d37dbcd | pass (2,3) | detached tick accepted in 215ms; sweep claimed cross-user; 401/403 posture held | +| 2026-06-09 | local | 4e33cc3 | pass (1) | pre-rework baseline: four queued reflections drained in order across turns via the then-extant tail driver, drawer lines live | +| 2026-06-10 | local | d37dbcd | pass (2,3) | pre-rework baseline: detached tick accepted in 215ms; sweep claimed cross-user; 401/403 posture held | | 2026-06-10 | local | 2e37c8b | pass (4) | counter hit 3, fourth claim skipped the thread | | 2026-06-10 | local | d37dbcd | note | full reflect+mark on the 69KB thread took ~9 min detached; completed only after the TTL fix (600s) | diff --git a/docs/user/memory.md b/docs/user/memory.md index e4c6a2f1..27e50ec0 100644 --- a/docs/user/memory.md +++ b/docs/user/memory.md @@ -34,9 +34,12 @@ or overly behavioral. That's intentional. ## How it grows -When a conversation settles (no new messages for a while), a -background agent reads the whole thread and decides whether anything -it saw is worth remembering. For each candidate memory, the agent: +When a conversation settles (its newest message is at least a +calendar day old), an hourly background pass reads the whole thread +and decides whether anything it saw is worth remembering. The lag is +deliberate: until that pass runs, you can still edit or retry any +part of the conversation and memory formation only ever sees the +corrected version. For each candidate memory, the agent: 1. **Searches existing memories** for anything close. Duplicates are worse than nothing — they dilute search. diff --git a/scripts/dev-backfill-cron.mjs b/scripts/dev-backfill-cron.mjs index ea87a2d5..e6a55ee4 100644 --- a/scripts/dev-backfill-cron.mjs +++ b/scripts/dev-backfill-cron.mjs @@ -18,8 +18,8 @@ // (the two memory librarians; same most-overdue-user claim shape // with their own 12h cadences); // - `nak_trigger_reflection_sweep()` hourly -> POST /reflection-sweep -// (reflection's catch-up drain - the chat-turn tail is the primary -// driver, this reaches queues whose owners stopped conversing); +// (reflection's only driver - each tick drains eligible threads +// one at a time until its cap or time budget stops it); // - `nak_trigger_curation_sweep()` hourly -> POST /curation-sweep, // `nak_trigger_bias_sweep()` hourly -> POST /bias-sweep, and // `nak_trigger_samskara_sweep()` hourly -> POST /samskara-sweep diff --git a/src/lib/supabase/realtime.ts b/src/lib/supabase/realtime.ts index b22bc13e..9513b42c 100644 --- a/src/lib/supabase/realtime.ts +++ b/src/lib/supabase/realtime.ts @@ -488,7 +488,7 @@ export function subscribeToGroceryChanges( /** * Subscribe to any change on the signed-in user's memories. The * wiki-articles twin above, for the memory writers that all live - * server-side now (reflection on the chat-turn tail, the rem and + * server-side now (the hourly reflection sweep, the rem and * deep-sleep librarian sweeps): the caller (Chat.svelte) routes the * notification into emitMemoryChange so an open Memories panel * refetches through the path it already had. Same coarse contract - diff --git a/src/screens/Chat.svelte b/src/screens/Chat.svelte index 43820299..cf281c2a 100644 --- a/src/screens/Chat.svelte +++ b/src/screens/Chat.svelte @@ -2002,7 +2002,7 @@ }); // Realtime: the memories twin of the wiki relay above. Every memory - // writer is server-side now (reflection on the chat-turn tail, the + // writer is server-side now (the hourly reflection sweep, the // rem / deep-sleep librarian sweeps), so this subscription is how an // open Memories panel learns a background write landed. $effect(() => { diff --git a/supabase/functions/venice/agents/reflection.ts b/supabase/functions/venice/agents/reflection.ts index c0e84ced..82a3ba44 100644 --- a/supabase/functions/venice/agents/reflection.ts +++ b/supabase/functions/venice/agents/reflection.ts @@ -6,28 +6,29 @@ // user. The model's final text is discarded - the memory_* side effects // ARE the output. // -// Drive shape - it drains OLDER threads, not "this" one. This module is -// fired from getStreamingResponse's terminal tail (via edgeWaitUntil) -// once per completed chat turn, but it does NOT reflect the thread that -// just finished. claim_next_thread_for_reflection only claims a thread -// whose newest message lands on a PRIOR calendar day in the user's -// timezone and that carries >= 2 user messages. So each turn-completion -// opportunistically drains ONE reflection-eligible thread from the -// existing day-gated queue. The day-gate exists because memory_recall -// has no per-conversation source attribution: a memory derived from a +// Drive shape - the hourly cron sweep is the ONLY driver. Each tick +// drains the day-gated queue one thread at a time (claim -> reflect -> +// claim the next) until the queue is empty, the per-tick cap is hit, +// or the tick's time budget runs out; the next hourly tick resumes. +// The sweep claim only offers a thread whose newest message lands on a +// PRIOR calendar day in the owner's timezone and that carries >= 2 +// user messages. The day-gate exists because memory_recall has no +// per-conversation source attribution: a memory derived from a // half-finished thought must not ride straight back into the same -// conversation that produced it. Faithful to the browser supervisor's -// behaviour (same queue, same gate); only the driver changed from a -// supervisor poll to a chat-activity piggyback. +// conversation that produced it. Reflection deliberately does NOT ride +// the chat turn's waitUntil tail the way curation and samskara do: +// memory formation gets a fixed, predictable cadence (edit or retry a +// conversation any time before the next hourly tick after midnight and +// reflection only ever sees the corrected thread), and the queue +// drains for dormant users at the same rate as active ones. // -// No lease coordinator. The browser ran reflection under a -// LeaseCoordinator so that only one of several open tabs/devices drove -// the supervisor at a time. Server-side that coordination is moot: the -// claim RPC's atomic per-thread claim+TTL IS the mutual exclusion. Two -// concurrent edge invocations that both call claim simply get two -// different threads (or one gets none) - more reflection throughput, not -// a correctness problem. So this module claims with a fresh per-call -// holder id and skips the lease machinery entirely. +// No lease coordinator, and no global singleton guard. The claim RPC's +// atomic per-thread claim+TTL IS the mutual exclusion: two overlapping +// ticks that both call claim simply get two different threads (or one +// gets none). Within a tick the drain loop is strictly sequential, so +// at most one reflection agent per tick is writing to the memory store +// at a time - which is what keeps near-simultaneous duplicate writes +// unlikely without any cross-invocation coordination. import type { SupabaseClient } from '@supabase/supabase-js'; import { createEdgeLogger } from '../../_shared/edge-log.ts'; @@ -450,106 +451,17 @@ function buildReflectionToolbox(): Toolbox { }; } -/** - * Resolve the user's display timezone for the day-gate. Stored in - * profiles.settings.displayTimezone (Settings -> AI -> About you). - * Falls back to UTC, matching the claim RPC's own p_timezone default. - */ -async function loadDisplayTimezone( - adminClient: SupabaseClient, - userId: string, -): Promise { - const { data, error } = await adminClient - .from('profiles') - .select('settings') - .eq('user_id', userId) - .maybeSingle<{ settings: Record | null }>(); - if (error || !data?.settings) return 'UTC'; - const tz = data.settings.displayTimezone; - return typeof tz === 'string' && tz.length > 0 ? tz : 'UTC'; -} - -/** Outcome of one reflectOneThread cycle, for the caller's diagnostic log. */ -export interface ReflectionCycleResult { +/** Outcome of one claim+reflect cycle inside a sweep tick's drain loop. */ +interface ReflectionCycleResult { outcome: 'no-thread' | 'empty-slice' | 'reflected' | 'claim-lost' | 'error'; threadId?: string; toolCalls?: number; } /** - * Run one reflection cycle for `userId`: claim the oldest day-gate- - * eligible thread, reflect on it, mark it done. A no-op when the queue - * is empty. Best-effort and NON-throwing by contract - the caller fires - * this from a chat turn's background tail and must not let a reflection - * failure touch the turn's recorded outcome, so every failure path is - * caught here, logged, and folded into an `error` result. Progress is - * logged through an edge logger so the browser Logs drawer sees the - * cycle even though it runs server-side; flush() at the end guarantees - * the final line lands before the waitUntil tail tears down. - */ -export async function reflectOneThread( - adminClient: SupabaseClient, - userId: string, -): Promise { - const log = createEdgeLogger(userId, 'reflection'); - try { - const timezone = await loadDisplayTimezone(adminClient, userId); - // Fresh holder per call - see the no-lease rationale in the file - // preamble. The claim+mark pair share this one holder; nothing else - // needs to recognise it. - const holderId = crypto.randomUUID(); - - // Claim atomically. p_user_id is the b-strict escape hatch: the - // service-role admin client has no auth.uid(), so the RPC scopes to - // the thread owner via coalesce(p_user_id, auth.uid()). - const { data: claimRows, error: claimErr } = await adminClient.rpc( - 'claim_next_thread_for_reflection', - { - p_holder_id: holderId, - p_ttl_seconds: REFLECTION_CLAIM_TTL_SECONDS, - p_timezone: timezone, - p_user_id: userId, - }, - ); - if (claimErr) { - throw new Error(`claim_next_thread_for_reflection failed: ${claimErr.message}`); - } - const claim = Array.isArray(claimRows) ? claimRows[0] : claimRows; - if (!claim || typeof claim.thread_id !== 'string') { - // Routine: the day-gated queue is empty on most turns. trace tier - // so it's available when actively watching but stays out of the - // default drawer view. - log.trace('no reflection-eligible thread to drain this turn'); - return { outcome: 'no-thread' }; - } - const threadId = claim.thread_id as string; - const terminalMsgId = claim.terminal_msg_id as string; - return await reflectClaimedThread( - adminClient, - userId, - log, - threadId, - terminalMsgId, - holderId, - ); - } catch (err) { - log.error( - 'reflection cycle failed', - err instanceof Error ? err : new Error(String(err)), - ); - return { outcome: 'error' }; - } finally { - // Flush before the waitUntil tail settles so the outcome line (the - // one worth seeing) isn't dropped as an un-awaited broadcast. - await log.flush(); - } -} - -/** - * The run half shared by both reflection drivers (the chat-turn tail - * and the cron catch-up sweep): the caller already holds the + * The run half of one drain cycle: the caller already holds the * per-thread claim; this reflects the thread and marks it done. - * Throws on infrastructure failure - each driver owns its own + * Throws on infrastructure failure - the cycle wrapper owns the * catch/log/flush posture. */ async function reflectClaimedThread( @@ -590,10 +502,9 @@ async function reflectClaimedThread( toolbox: buildReflectionToolbox(), baseCtx, apiKey, - // No outer turn to cancel - reflection runs in a background - // tail or a cron tick, after any user-visible work already - // shipped. A never-aborting signal lets runHeadlessAgent run - // to its own maxRounds backstop. + // No outer turn to cancel - reflection runs in a cron tick, + // never on a user-visible path. A never-aborting signal lets + // runHeadlessAgent run to its own maxRounds backstop. signal: new AbortController().signal, }, // parentDepth 0: reflection is a top-level agent (depth 1), same @@ -629,18 +540,94 @@ async function reflectClaimedThread( } /** - * One cron catch-up tick: claim the most-overdue reflection-eligible - * thread across ALL users and reflect on it. The chat-turn tail is - * reflection's primary driver but only fires when its owner - * converses; this sweep drains queues the tail can't reach. One - * thread per tick - the hourly schedule resumes the drain, matching - * the other fleets' pacing. Double-driving with the tail is safe: - * the per-thread claim columns are the mutual exclusion, so - * whichever driver claims first wins. Non-throwing, same contract - * as reflectOneThread. + * Per-tick thread cap for the sweep's drain loop. Bounds one tick's + * worst-case Venice spend; a backlog deeper than the cap drains + * across successive hourly ticks. Sequential on purpose - one + * reflection agent writing to the memory store at a time keeps + * near-simultaneous duplicate writes unlikely. + */ +const REFLECTION_SWEEP_MAX_THREADS = 5; + +/** + * Wall-clock cutoff for claiming ANOTHER thread within one tick. The + * hosted edge runtime kills an isolate at roughly 400s (see + * DEEP_SLEEP_BUDGET_MS in deep_sleep.ts for the fuller story), so a + * loop that kept claiming until empty would compound several long + * reflections into a guaranteed mid-flight death. Stopping new claims + * at 180s leaves a reflection claimed at the cutoff over 200s to + * finish - most do. One that doesn't dies exactly as it would have + * under a single-claim tick, and the attempt cap (3 claims per + * terminal message) keeps such a thread from wedging the queue. + */ +const REFLECTION_SWEEP_BUDGET_MS = 180_000; + +/** What one sweep tick did, and why its drain loop stopped. */ +export interface ReflectionSweepSummary { + reflected: number; + claimLost: number; + emptySlice: number; + stoppedBy: 'empty-queue' | 'cap' | 'budget' | 'error'; +} + +/** + * One cron tick: drain the reflection queue across ALL users, one + * thread at a time - claim the most-overdue eligible thread, reflect + * it, claim the next - until the queue is empty, the per-tick cap is + * hit, or the time budget is spent. The hourly schedule resumes the + * drain. This is reflection's ONLY driver (see the file preamble for + * why it does not ride the chat turn's tail). Non-throwing: cycle + * failures are folded into the summary, and an `error` cycle stops + * the loop rather than hot-looping a broken dependency. */ export async function runReflectionSweepTick( adminClient: SupabaseClient, +): Promise { + const startedAt = Date.now(); + const summary: ReflectionSweepSummary = { + reflected: 0, + claimLost: 0, + emptySlice: 0, + stoppedBy: 'cap', + }; + for (let i = 0; i < REFLECTION_SWEEP_MAX_THREADS; i++) { + // The first cycle always runs regardless of the budget - a tick + // must make at least one unit of progress, same guarantee the + // old single-claim tick gave. + if (i > 0 && Date.now() - startedAt > REFLECTION_SWEEP_BUDGET_MS) { + summary.stoppedBy = 'budget'; + return summary; + } + const cycle = await sweepClaimAndReflectOnce(adminClient); + switch (cycle.outcome) { + case 'no-thread': + summary.stoppedBy = 'empty-queue'; + return summary; + case 'error': + summary.stoppedBy = 'error'; + return summary; + case 'reflected': + summary.reflected += 1; + break; + case 'claim-lost': + // Another tick's claim outlived ours mid-run; the queue may + // still hold more work, so keep draining. + summary.claimLost += 1; + break; + case 'empty-slice': + summary.emptySlice += 1; + break; + } + } + return summary; +} + +/** + * One drain cycle: claim the most-overdue reflection-eligible thread + * across all users and reflect it. Non-throwing; every failure path + * folds into an `error` result for the loop to act on. + */ +async function sweepClaimAndReflectOnce( + adminClient: SupabaseClient, ): Promise { const holderId = crypto.randomUUID(); let claim: { thread_id?: unknown; terminal_msg_id?: unknown; user_id?: unknown } | null; diff --git a/supabase/functions/venice/agents/second_thoughts.ts b/supabase/functions/venice/agents/second_thoughts.ts index fb700bd8..6fa65a7b 100644 --- a/supabase/functions/venice/agents/second_thoughts.ts +++ b/supabase/functions/venice/agents/second_thoughts.ts @@ -2,7 +2,7 @@ // // Runs from the streaming function's completed-turn waitUntil tail // (getStreamingResponse.ts), a sibling to curateOnTurnTail / -// samskaraOnTurnTail / reflectOneThread. After a turn commits, it +// samskaraOnTurnTail. After a turn commits, it // re-reads what the model just said and reports a FELT CONFIDENCE - // stands behind it, or something feels off - onto the terminal // assistant row's `second_thoughts` jsonb column. diff --git a/supabase/functions/venice/getStreamingResponse.ts b/supabase/functions/venice/getStreamingResponse.ts index baab63e6..67a52650 100644 --- a/supabase/functions/venice/getStreamingResponse.ts +++ b/supabase/functions/venice/getStreamingResponse.ts @@ -75,7 +75,6 @@ import { stripGeneratedImage, type GeneratedImagePayload, } from './tools/_generated_image.ts'; -import { reflectOneThread } from './agents/reflection.ts'; import { curateOnTurnTail } from './agents/curation.ts'; import { samskaraOnTurnTail } from './agents/samskara.ts'; import { secondThoughtsOnTurnTail } from './agents/second_thoughts.ts'; @@ -1169,26 +1168,20 @@ export async function getStreamingResponse( `${runId} end terminalKind=${terminalKind} persistedId=${persistedId || 'none'}`, ); - // Reflection piggyback. A completed chat turn is the trigger that - // drains ONE day-gate-eligible OLDER thread from the reflection - // queue (not this thread - see agents/reflection.ts for why). The - // hourly /reflection-sweep cron route is the catch-up sibling for - // users who stopped conversing; the per-thread claim makes the two - // drivers safe together. Runs here in the already-detached waitUntil tail, - // after the response shipped and the channels tore down, so it never - // delays the user-visible turn. reflectOneThread is non-throwing and - // logs its own outcome (to the function log + the user's Logs - // drawer); the catch is a defensive backstop so a reflection bug - // still can't disturb this turn's already-committed row. + // Reflection deliberately does NOT run here. Memory formation is + // sweep-only (the hourly /reflection-sweep cron drain) so it keeps + // a fixed, predictable cadence: the user can edit or retry a + // conversation any time before the first hourly tick after the + // day-gate opens and reflection only ever sees the corrected + // thread. See the drive-shape preamble in agents/reflection.ts. if (terminalKind === 'completed') { // Second-thoughts reflex, FIRST in the tail: re-read the turn we // just committed and write a self-doubt verdict onto the terminal // assistant row. The browser hydrates it via the messages UPDATE // echo (subscribeToMessages listens for UPDATE), so the // per-message slide-down lands a beat after the reply settles. - // Ordered ahead of curation/samskara/reflection so the - // user-visible verdict isn't starved behind reflection, which can - // span minutes of tool rounds. Guarded on persistedId - a turn + // Ordered ahead of curation/samskara so the user-visible verdict + // ships before the housekeeping work. Guarded on persistedId - a turn // that committed no assistant row (should not happen on the // 'completed' path, but cheap to check) has nothing to review. if (persistedId) { @@ -1204,34 +1197,26 @@ export async function getStreamingResponse( log.error(`${runId} second-thoughts tail failed:`, err); } } - // Curation piggyback, BEFORE reflection on purpose: the chain is - // sequential and reflection can span minutes of tool rounds, - // while curation is a handful of quick completions whose first + // Curation piggyback: a handful of quick completions whose first // unit (auto-title) is the user-visible one - a brand-new // conversation sits on the 'New conversation' placeholder until - // it runs. Same non-throwing contract and hourly catch-up - // sibling (/curation-sweep) as reflection below. + // it runs. Non-throwing, with an hourly catch-up sibling + // (/curation-sweep) for users who stop conversing. try { await curateOnTurnTail(opts.adminClient, opts.userId); } catch (err) { log.error(`${runId} curation tail failed:`, err); } - // Samskara before reflection: reflection can span minutes of - // tool rounds, and the samskara rotation carries the fleet's - // only hard timing window (reaction-classify must catch a fired - // cohort 1-10 minutes after the fire - this turn's user message - // is what resolves the PREVIOUS turn's cohort). The hourly + // Samskara last: the rotation carries the fleet's only hard + // timing window (reaction-classify must catch a fired cohort + // 1-10 minutes after the fire - this turn's user message is what + // resolves the PREVIOUS turn's cohort). The hourly // /samskara-sweep cron is the catch-up sibling. try { await samskaraOnTurnTail(opts.adminClient, opts.userId); } catch (err) { log.error(`${runId} samskara tail failed:`, err); } - try { - await reflectOneThread(opts.adminClient, opts.userId); - } catch (err) { - log.error(`${runId} reflection tail failed:`, err); - } } // Drain pending drawer broadcasts before the waitUntil promise diff --git a/supabase/functions/venice/index.ts b/supabase/functions/venice/index.ts index 9de59cbb..ecccf7c6 100644 --- a/supabase/functions/venice/index.ts +++ b/supabase/functions/venice/index.ts @@ -861,16 +861,15 @@ const handleWikiRecordsSweep = sweepHandler(runWikiRecordsSweepTick); const handleWikiLibrarianSweep = sweepHandler(runWikiLibrarianSweepTick); const handleRemSweep = sweepHandler(runRemSweepTick); const handleDeepSleepSweep = sweepHandler(runDeepSleepSweepTick); -// Reflection's catch-up drain. The primary driver stays the chat -// turn's waitUntil tail in getStreamingResponse; this route exists so -// a user who stops conversing still gets their queue drained, and so -// reflection's trigger surface is visible in this routing table like -// every other fleet's. +// Reflection's ONLY driver: an hourly drain loop that claims and +// reflects eligible threads one at a time until the queue is empty or +// the tick's cap/budget stops it. Deliberately not tail-driven - see +// the drive-shape preamble in agents/reflection.ts. const handleReflectionSweep = sweepHandler(runReflectionSweepTick); // Curation catch-up drain (auto-title, thread topics, summaries, // memory topics, recipe topics). The primary driver is the chat -// turn's waitUntil tail in getStreamingResponse, same dual-driver -// shape as reflection; this route is what drains work created +// turn's waitUntil tail in getStreamingResponse; this route is +// what drains work created // server-side (rem / deep-sleep consolidations re-queue memory tags) // or left behind by a failed tail attempt. const handleCurationSweep = sweepHandler(runCurationSweepTick); diff --git a/supabase/schema.sql b/supabase/schema.sql index b1228308..ba62d9b6 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -992,8 +992,8 @@ alter table public.threads add column if not exists last_reflected_msg_id uuid references public.messages(id) on delete set null, add column if not exists reflection_holder_id text, add column if not exists reflection_claim_expires_at timestamptz, - -- Attempt accounting, stamped AT CLAIM TIME by both reflection - -- claims. Counting attempts (not failures) is deliberate: a run + -- Attempt accounting, stamped AT CLAIM TIME by the sweep claim. + -- Counting attempts (not failures) is deliberate: a run -- that dies to the invocation wall clock never reaches an error -- handler, so a failure counter would miss exactly the deaths that -- need bounding. Three attempts at the same terminal message and @@ -4541,8 +4541,8 @@ grant execute on function public.nak_sweep_stale_streams() to service_role; -- Reflection pipeline RPCs ----------------------------------------------- -- --- The reflection agent's worker runs on the same claim/lease pattern as --- the embeddings worker, but against `threads` instead of `memories` +-- The reflection agent runs on the same claim/lease pattern as the +-- embeddings worker, but against `threads` instead of `memories` -- and with a different "what does 'needs work' mean?" predicate. -- -- "Needs reflection" = there exists a terminal assistant message in the @@ -4554,121 +4554,25 @@ grant execute on function public.nak_sweep_stale_streams() to service_role; -- user prompt + assistant reply, no follow-up) doesn't burn Venice -- calls reflecting on a conversation that hadn't actually started yet. -- --- The function returns `(thread_id, terminal_msg_id)` atomically. The --- worker fetches messages up to `terminal_msg_id` (so a race where the +-- The claim returns `(thread_id, terminal_msg_id)` atomically. The +-- agent fetches messages up to `terminal_msg_id` (so a race where the -- user adds more turns mid-reflection just queues the thread for the -- next cycle), runs its tool-call loop, and calls -- `mark_thread_reflected_if_claimed` with the same msg_id it got here. --- If the claim was lost (device B took over mid-reflection) the mark --- returns false and the whole run is discarded — device B will redo it. - --- Claim the oldest thread in need of reflection and return its id plus --- the terminal assistant message we should reflect up to. `for update --- skip locked` is belt-and-suspenders under the lease invariant (only --- one device should be claiming at a time); it costs nothing and --- removes an entire class of wrong answer from the corner where two --- devices briefly both think they hold the lease. +-- If the claim was lost (another run took over mid-reflection) the mark +-- returns false and the whole run is discarded — the winner redoes it. + +-- The per-user claim variant (claim_next_thread_for_reflection) is +-- retired: reflection is sweep-only, and the cross-user sweep claim +-- below is the single claiming path. The drops cover every signature +-- the variant ever shipped with. drop function if exists public.claim_next_thread_for_reflection(text, int); drop function if exists public.claim_next_thread_for_reflection(text, int, text); -create or replace function public.claim_next_thread_for_reflection( - p_holder_id text, - p_ttl_seconds int, - -- User's display timezone from Settings -> AI -> About you; - -- determines the calendar day the eligibility gate buckets on. - -- Same shape as claim_next_thread_for_wiki - we want the - -- reflection pass to leave in-flight conversations alone so a - -- memory derived from a half-finished thought doesn't land - -- before the user has a chance to correct or extend it. The - -- memory_recall tool has no per-conversation source attribution - -- on memories, so a same-day write could ride straight back into - -- the conversation that produced it. - p_timezone text default 'UTC', - -- b-strict escape hatch (see search_memories_by_embedding): the - -- browser supervisor calls with auth.uid() in scope and leaves this - -- null; the venice edge function fires reflection from a chat turn's - -- waitUntil tail with a service-role client that has no uid, so it - -- passes the thread owner's id explicitly. security invoker stays - -- correct because service_role bypasses RLS and the coalesce scopes - -- the claim to one user either way. - p_user_id uuid default null -) returns table (thread_id uuid, terminal_msg_id uuid) -language sql security invoker as $$ - with candidate as ( - -- Oldest thread (by updated_at ascending) that has a terminal - -- assistant message newer than what we've reflected on, passes the - -- token-volume guard, lands on a calendar day strictly before - -- today in the user's timezone, and isn't currently claimed. The - -- terminal-message lookup is a lateral join so we get both the - -- thread row AND the specific msg id to mark up to, in one - -- round trip. The newest-message lookup is a second lateral so - -- the day-gate buckets on messages.created_at - same source - -- the wiki claim uses, stable against unrelated bumps to - -- threads.updated_at. - select t.id as thread_id, term.msg_id as terminal_msg_id - from public.threads t - cross join lateral ( - select m.id as msg_id - from public.messages m - where m.thread_id = t.id - and m.role = 'assistant' - and (m.tool_calls is null - or jsonb_typeof(m.tool_calls) <> 'array' - or jsonb_array_length(m.tool_calls) = 0) - and m.content is not null - and length(m.content) > 0 - order by m.created_at desc - limit 1 - ) term - cross join lateral ( - select m2.created_at - from public.messages m2 - where m2.thread_id = t.id - order by m2.created_at desc - limit 1 - ) newest - where t.user_id = coalesce(p_user_id, auth.uid()) - and term.msg_id is distinct from t.last_reflected_msg_id - -- Attempt cap: stop offering a terminal message that has - -- already burned three claims (see the column comment on - -- reflection_attempt_count). A different terminal message - -- means new conversation turns landed - fresh budget. - and (term.msg_id is distinct from t.reflection_attempt_msg_id - or t.reflection_attempt_count < 3) - and (t.reflection_claim_expires_at is null - or t.reflection_claim_expires_at < now()) - and (newest.created_at at time zone p_timezone)::date - < (now() at time zone p_timezone)::date - and ( - -- At least two user messages on the thread. A single user - -- prompt + assistant reply is a one-shot Q&A; we only want - -- to reflect once the user came back with a follow-up, which - -- is the cheapest signal that the conversation has substance - -- worth turning into memories. - select count(*) - from public.messages m2 - where m2.thread_id = t.id - and m2.role = 'user' - ) >= 2 - order by t.updated_at asc - limit 1 - for update of t skip locked - ) - update public.threads t - set reflection_holder_id = p_holder_id, - reflection_claim_expires_at = now() + make_interval(secs => p_ttl_seconds), - reflection_attempt_count = case - when t.reflection_attempt_msg_id is distinct from c.terminal_msg_id then 1 - else t.reflection_attempt_count + 1 - end, - reflection_attempt_msg_id = c.terminal_msg_id - from candidate c - where t.id = c.thread_id - returning t.id as thread_id, c.terminal_msg_id; -$$; +drop function if exists public.claim_next_thread_for_reflection(text, int, text, uuid); -- Record a completed reflection IF the claim is still ours. Returns -- true on success, false when the claim expired or was stolen (another --- device took over). The worker treats false the same way +-- run took over). The agent treats false the same way -- save_memory_embedding_if_claimed does: drop the work, loop to the -- next row. Any memory writes the agent already made during the run -- stay — they're owned by the user, not the claim, and re-reflection @@ -4679,9 +4583,9 @@ create or replace function public.mark_thread_reflected_if_claimed( p_thread_id uuid, p_holder_id text, p_msg_id uuid, - -- b-strict escape hatch, same as the claim RPC above: null from the - -- browser (auth.uid() in scope), the thread owner's id from the - -- service-role edge-function caller. + -- b-strict escape hatch: the service-role edge-function caller has + -- no auth.uid(), so it passes the thread owner's id explicitly + -- (coalesced below). p_user_id uuid default null ) returns boolean language plpgsql security invoker as $$ @@ -4701,12 +4605,9 @@ begin return updated > 0; end $$; --- service_role grants for the edge-function reflection driver (the --- venice function fires reflection from a chat turn's waitUntil tail). --- The browser keeps calling these as the authenticated user; these --- grants just let the service-role client reach them too. -grant execute on function - public.claim_next_thread_for_reflection(text, int, text, uuid) to service_role; +-- service_role grant for the edge-function reflection driver (the +-- venice function's sweep tick marks threads with a service-role +-- client, passing the owner's id through the b-strict escape hatch). grant execute on function public.mark_thread_reflected_if_claimed(uuid, text, uuid, uuid) to service_role; @@ -4736,15 +4637,14 @@ exception when others then return 'UTC'; end $$; --- Global reflection sweep claim: the cron catch-up drain's variant of --- claim_next_thread_for_reflection. Same candidate predicate, but --- across ALL users - the timezone comes off each owner's profile --- (nak_safe_timezone, UTC fallback) instead of a parameter. The --- per-turn waitUntil tail only drains when its owner converses, so --- without this sweep a dormant account's reflection queue never --- moves. Tail + sweep double-driving is safe by construction: the --- per-thread claim columns are the mutual exclusion, so whichever --- driver claims first wins and the other sees no candidate. +-- Global reflection sweep claim: reflection's only claiming path. +-- Picks the most-overdue eligible thread across ALL users - the +-- timezone comes off each owner's profile (nak_safe_timezone, UTC +-- fallback). The sweep tick calls this in a drain loop (claim -> +-- reflect -> claim the next), and overlapping ticks are safe by +-- construction: the per-thread claim columns are the mutual +-- exclusion, so whichever caller claims first wins and the other +-- sees a different candidate or none. drop function if exists public.claim_next_thread_for_reflection_sweep(text, int); create or replace function public.claim_next_thread_for_reflection_sweep( p_holder_id text, @@ -13063,17 +12963,17 @@ end $cron$; -- --------------------------------------------------------------------------- --- Scheduled reflection catch-up sweep (pg_cron -> pg_net -> venice function) --- --- Reflection's primary driver is the chat turn's waitUntil tail in --- getStreamingResponse - one eligible older thread drains per --- completed turn. This hourly sweep is the catch-up path for queues --- the tail can't reach: a user who stops conversing leaves eligible --- threads stranded (no turns -> no draining). One thread per tick, --- claimed across all users by claim_next_thread_for_reflection_sweep; --- the per-thread claim columns make tail + sweep double-driving safe. --- Same Vault-secret custody and no-op-until-seeded behavior as the --- other sweep triggers above. +-- Scheduled reflection sweep (pg_cron -> pg_net -> venice function) +-- +-- Reflection's ONLY driver. Each hourly tick drains the day-gated +-- queue one thread at a time (claimed across all users by +-- claim_next_thread_for_reflection_sweep) until it runs empty or the +-- tick's cap/time budget stops it; the next tick resumes. Memory +-- formation deliberately does not ride the chat turn's tail - the +-- fixed cadence gives the user a predictable window to edit or retry +-- a settled conversation before reflection reads it. Same +-- Vault-secret custody and no-op-until-seeded behavior as the other +-- sweep triggers above. -- --------------------------------------------------------------------------- create or replace function public.nak_trigger_reflection_sweep()