diff --git a/docs/dev/README.md b/docs/dev/README.md index 210416d6..bb2f79c0 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -131,7 +131,7 @@ changing a contract that other features depend on. turn: the injection order of the bias appendix + the context-recall / samskara / intuition `` chain + the per-turn metadata block, plus freshness, failure degradation, - and observability rules. The spec `chat-loop.ts` implements. + and observability rules. The spec `chat/loop.ts` implements. - [Settings](./settings.md) — the settings modal + `profiles.settings` JSONB + theme. - [Help](./help.md) — in-app rendering of `docs/user/`. diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 700fcdc6..9079a3b0 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -245,7 +245,7 @@ the function-side wire shape: `/stream` route with a thread + anchor-message context, subscribes to the `thread::stream` Broadcast channel, and yields the function-published event union. Used only by the - main user-facing chat (`chat-loop.ts`). + main user-facing chat (`chat/loop.ts`). - `SupabaseService.complete(req)` - non-streaming one-shot, routed through the venice/complete route. Used only by the one intentional browser-side completion path left: the intuition diff --git a/docs/dev/attachments.md b/docs/dev/attachments.md index dabe62ad..fe31f4aa 100644 --- a/docs/dev/attachments.md +++ b/docs/dev/attachments.md @@ -57,7 +57,7 @@ covers the attachment-specific pieces. routes the multipart upload through the venice edge function's `/text-parser` route; the function holds the shared key and relays the response. -- `src/lib/chat-loop.ts` - `toVeniceMessage` accepts `visionSpec` + +- `src/lib/chat/loop.ts` - `toVeniceMessage` accepts `visionSpec` + `imageUrls` (the pre-resolved signed URLs) and routes user rows through `buildUserVeniceContent` when they carry attachments. - `src/lib/extractedTextDrawer.svelte.ts` - rune-based singleton store diff --git a/docs/dev/bias-profile.md b/docs/dev/bias-profile.md index 92c48617..fdd635ab 100644 --- a/docs/dev/bias-profile.md +++ b/docs/dev/bias-profile.md @@ -175,7 +175,7 @@ interval lower bound (not the mean) as the surfacing gate. (passing `p_user_id`) so the sweep re-analyzes with the fresh message. All three writes swallow their errors - bias never blocks a turn. -- **`buildSystemPrompt()`** - in `src/lib/chat-prompt.ts`, builds the +- **`buildSystemPrompt()`** - in `src/lib/chat/system-prompt.ts`, builds the baseline only. The browser ships a bias-free system prompt; the server appends the bias block with the same blank-line separator `buildSystemPrompt` uses between sections, so the wire bytes are diff --git a/docs/dev/chat.md b/docs/dev/chat.md index 72c90156..f1da229a 100644 --- a/docs/dev/chat.md +++ b/docs/dev/chat.md @@ -2,7 +2,7 @@ The main user-facing surface plus the browser-side orchestration that drives a single turn. `Chat.svelte` renders the drawer, -composer, and message list; `chat-loop.ts` builds the per-turn +composer, and message list; `chat/loop.ts` builds the per-turn priming chain and the system-prompt preamble, then issues one `venice.streamChat` call that routes through the venice edge function's `/stream` route. The function owns the streaming round @@ -19,7 +19,7 @@ A chat turn goes: 2. `Chat.svelte` resolves the effective model profile (per-thread pin -> default profile), builds the history in OpenAI shape, and calls `runChatLoop`. -3. `chat-loop.ts` runs the per-turn priming layers (samskara +3. `chat/loop.ts` runs the per-turn priming layers (samskara fire-and-compound, intuition, context recall), stitches their synthetic `` blocks into the history, assembles the three-layer system-prompt preamble (baseline, user-configured, @@ -31,7 +31,7 @@ A chat turn goes: round via the `commit_assistant_message` RPC, and broadcasts typed events on `thread::stream` so the browser's UI stays live. -5. `chat-loop.ts` consumes those events and routes them to the +5. `chat/loop.ts` consumes those events and routes them to the UI handler surface (streaming bubble, reasoning panel, tool timings, rate-limit indicator, slop-notice cards). At the END event it captures the persisted assistant row id + terminal @@ -50,7 +50,7 @@ A chat turn goes: - `src/screens/Chat.svelte` - the screen itself. Drawer, composer, message list, thread lifecycle, plus the call sites for every other feature that hooks into chat. -- `src/lib/chat-loop.ts` - `runChatLoop`, `toVeniceMessage`, and +- `src/lib/chat/loop.ts` - `runChatLoop`, `toVeniceMessage`, and the per-turn priming + event-routing orchestration. Issues one `venice.streamChat` call per turn; the function-side round chain takes over from there. Split from the screen so the @@ -372,7 +372,7 @@ A chat turn goes: - **System prompts are re-assembled every round, browser-side.** The baseline tool-framing system message is NOT persisted - it's built from the tool registry at request-time by - `buildSystemPrompt` in `chat-loop.ts`. User-configured prompts + `buildSystemPrompt` in `chat/loop.ts`. User-configured prompts from Settings ride AFTER the baseline so a custom "you are a pirate" prompt still wins on voice while the tool framing stays in force. If you add a new tool, the model learns about it on @@ -539,7 +539,7 @@ A chat turn goes: the initial SUBSCRIBED, and a later `CHANNEL_ERROR` / `TIMED_OUT` / `CLOSED` that this tab did not initiate flips `disconnected` and closes the drain, which then throws `StreamDisconnectedError`. That propagates - through `chat-loop.ts` to `runExchange`'s catch, which releases the + through `chat/loop.ts` to `runExchange`'s catch, which releases the slot and calls `reconnectInflightTurn` - the exact poll-the-row path above, seeded with the partial the dropped stream had buffered. So a mid-turn drop degrades gracefully to the reconnect poll instead of @@ -569,7 +569,7 @@ A chat turn goes: turn ends - the persisted DB row is the ONLY thing that can show the partial afterward. The drain in `venice.ts` no longer `close()`s on the `error` broadcast event (END is the sole terminal), and - `consumeStreamEvents` (`chat-loop.ts`) stashes the terminal error + `consumeStreamEvents` (`chat/loop.ts`) stashes the terminal error instead of throwing immediately, throwing only AFTER the post-loop `onAssistantPersisted` hydration has handed the persisted partial to its card. Without the deferral the throw races ahead of hydration and diff --git a/docs/dev/exchange.md b/docs/dev/exchange.md index b0ea3c2b..bca4e37c 100644 --- a/docs/dev/exchange.md +++ b/docs/dev/exchange.md @@ -317,7 +317,7 @@ deterministic. ## Interactions -- **Chat loop (`src/lib/chat-loop.ts`)** — owns the actual +- **Chat loop (`src/lib/chat/loop.ts`)** — owns the actual streaming + tool execution. The exchange module is the screen's state container; the chat loop is the producer. Every `handlers.X` callback in the `runChatLoop` call writes through diff --git a/docs/dev/library.md b/docs/dev/library.md index 2abc8973..59abd0e1 100644 --- a/docs/dev/library.md +++ b/docs/dev/library.md @@ -94,7 +94,7 @@ Tools: Prompt: -- `src/lib/chat-prompt.ts` - `LIBRARY_BLOCK`, after `WIKI_BLOCK`. +- `src/lib/chat/system-prompt.ts` - `LIBRARY_BLOCK`, after `WIKI_BLOCK`. UI: diff --git a/docs/dev/logging.md b/docs/dev/logging.md index 4a12f886..badedf1b 100644 --- a/docs/dev/logging.md +++ b/docs/dev/logging.md @@ -152,7 +152,7 @@ Pick a short, stable source tag. Existing tags: feature groups under a single drawer filter, same pattern as `bias` - `chat-loop` - the browser-side turn orchestrator - (`src/lib/chat-loop.ts`). Notably carries `venice request wire` at + (`src/lib/chat/loop.ts`). Notably carries `venice request wire` at debug: the full `requestMessages` array sent for the turn's opening round, priming `` chain included. Round-1 wire only (later tool rounds run edge-side under `stream`). Drop to `Debug+` when a diff --git a/docs/dev/prompt-augmentation.md b/docs/dev/prompt-augmentation.md index f5f2ee57..406e9d5d 100644 --- a/docs/dev/prompt-augmentation.md +++ b/docs/dev/prompt-augmentation.md @@ -7,7 +7,7 @@ augmentation layer**. This doc is that layer's contract: who may inject what, in what order, when it counts as fresh, how it degrades on failure, and where it is observable. The code that enforces the contract lives in two places: the browser assembles the baseline system -prompt + conversation + metadata in `src/lib/chat-loop.ts` +prompt + conversation + metadata in `src/lib/chat/loop.ts` (`runChatLoop` -> `requestMessages`), and the server's priming stage (`supabase/functions/venice/priming.ts` `runServerPriming`, the opening stage of `getStreamingResponse`) appends the bias appendix and splices @@ -40,7 +40,7 @@ Two distinct injection surfaces: - **The baseline system prompt (row 1)** carries the slowly-changing, always-on context: the tool catalog and the bias-profile appendix. The tool catalog is assembled by `buildSystemPrompt()` in - `src/lib/chat-prompt.ts` (browser); the bias-profile appendix is + `src/lib/chat/system-prompt.ts` (browser); the bias-profile appendix is rendered and appended server-side by `applyBiasPriming` (`supabase/functions/venice/priming.ts`) before the first round, joined with the same blank-line separator so the wire bytes match. @@ -66,7 +66,7 @@ Two distinct injection surfaces: | Samskara fire | `` (row 6) | `fireSamskaras` (`venice/priming/samskara.ts`) | computed per turn | raced against `SAMSKARA_PRIMING_TIMEOUT_MS` | | Intuition | `` (row 7) | `runIntuitionPipeline` (`venice/priming/intuition.ts`) | `threads.intuition_payload` | `isPayloadFreshForInjection` (STALE_FUSE_MS) | | Tool catalog | system (row 1) | `buildSystemPrompt` / `buildToolList` (`src/lib/tools`) | n/a (derived from enabled toolboxes) | per-turn snapshot of `toolboxes_enabled` | -| Metadata block | system (row 8) | `buildMetadataSystemMessage` (`src/lib/chat-loop`) | n/a | rebuilt every turn | +| Metadata block | system (row 8) | `buildMetadataSystemMessage` (`src/lib/chat/prompt-assembly`) | n/a | rebuilt every turn | ## Ordering @@ -160,7 +160,7 @@ browser surface is unchanged: round-tripped to the drawer via the edge-log Broadcast relay. **The assembled prompt is NOT surfaced in any drawer wire dump.** The browser logs its own *pre-priming* view of the request under source - `chat` ("venice request wire", in `chat-loop.ts`) before it POSTs; + `chat` ("venice request wire", in `chat/loop.ts`) before it POSTs; the server then appends the bias + intent appendices and splices the `` chain server-side, so those additions never appear in that dump. The server-side `stream` source carries only operational lines @@ -184,7 +184,7 @@ browser surface is unchanged: - [`tools.md`](./tools.md) - the tool catalog + toolbox-state halves of the system prompt. - The baseline prompt + catalog are browser-side - (`src/lib/chat-prompt.ts`); the bias appendix + ``-chain + (`src/lib/chat/system-prompt.ts`); the bias appendix + ``-chain assembly + ordering are server-side (`supabase/functions/venice/priming.ts`), with the trigger evaluator mirrored in `_shared/priming-triggers.ts`. diff --git a/docs/dev/second-thoughts.md b/docs/dev/second-thoughts.md index 7b0b18a9..52380957 100644 --- a/docs/dev/second-thoughts.md +++ b/docs/dev/second-thoughts.md @@ -239,7 +239,7 @@ and fires the RPC best-effort for persistence across reload / device. merge that lands the verdict's realtime echo. - `src/lib/chat/prompt-assembly.ts` - `toVeniceMessage`'s acted-doubt `` projection. -- `src/lib/chat/types.ts` / `src/lib/chat-loop.ts` / `src/lib/venice.ts` +- `src/lib/chat/types.ts` / `src/lib/chat/loop.ts` / `src/lib/venice.ts` - the `skipPriming` plumbing. - `src/lib/supabase.ts` - `markSecondThoughtsActed`. diff --git a/docs/dev/tools.md b/docs/dev/tools.md index a92987b2..07b55ff2 100644 --- a/docs/dev/tools.md +++ b/docs/dev/tools.md @@ -19,7 +19,7 @@ The catalog and the dispatch live on opposite sides of the wire: - **The browser owns the catalog.** `buildToolList` composes the wire `tools` array from the thread's enabled toolboxes, and - `src/lib/chat-prompt.ts` renders the same registry into the + `src/lib/chat/system-prompt.ts` renders the same registry into the system-prompt catalog. Every browser `ToolDef` is a `serverSideTool(schema)` - catalog metadata plus an `execute()` that throws. Nothing dispatches tools in the browser. @@ -205,7 +205,7 @@ Browser catalog (`src/lib/tools/`): Adjacent browser modules: -- `src/lib/chat-prompt.ts` - `buildSystemPrompt` renders the +- `src/lib/chat/system-prompt.ts` - `buildSystemPrompt` renders the registry into the system-prompt catalog; `buildToolboxStateBlock` renders the volatile `(on)`/`(off)` state. - `src/lib/ask-user.ts` - the ask_user suspend/resume envelope @@ -252,7 +252,7 @@ Edge dispatch (`supabase/functions/venice/`): ## Entry points -- **Chat loop** - `chat-loop.ts` calls +- **Chat loop** - `chat/loop.ts` calls `buildToolList(thread.toolboxes_enabled)` to ship the wire `tools` array, then observes the streamed `tool_call_request` / `tool_call_response` events. The edge function is @@ -266,7 +266,7 @@ Edge dispatch (`supabase/functions/venice/`): Triggers and per-agent stories live with the owning features (`./memory.md`, `./wiki.md`). - **System prompt assembly** - `buildSystemPrompt({ biasProfile })` - in `src/lib/chat-prompt.ts` composes the baseline system message. + in `src/lib/chat/system-prompt.ts` composes the baseline system message. The catalog section lists always-on tools first, then each gated toolbox and its tools. The catalog is state-free: it lists what toolboxes exist, not which are enabled, so the baseline stays @@ -366,7 +366,7 @@ Edge dispatch (`supabase/functions/venice/`): (first-seen wins). Callers should never construct this array by hand. - `buildSystemPrompt(opts?)` / `buildToolboxStateBlock(enabled)` - - live in `src/lib/chat-prompt.ts`, importing the registry from + live in `src/lib/chat/system-prompt.ts`, importing the registry from here. The baseline is state-free; the state block renders the gated toolboxes as `(on)`/`(off)` lines and rides the per-turn metadata message. Unknown names in `enabled` are ignored; diff --git a/docs/dev/wiki.md b/docs/dev/wiki.md index fde8e5b7..353dc301 100644 --- a/docs/dev/wiki.md +++ b/docs/dev/wiki.md @@ -353,7 +353,7 @@ Main-thread plumbing: `profiles.settings.displayTimezone` server-side. - `src/lib/routing.svelte.ts` - extends `DrawerTab` with `'wiki'` and `Route` with `wiki_article_id`. -- `src/lib/chat-prompt.ts` - `WIKI_BLOCK` after `JOURNAL_BLOCK` in +- `src/lib/chat/system-prompt.ts` - `WIKI_BLOCK` after `JOURNAL_BLOCK` in the section list. UI: diff --git a/docs/qa/use-cases/chat-cutoff-retry.md b/docs/qa/use-cases/chat-cutoff-retry.md index b5f684c5..eb41654e 100644 --- a/docs/qa/use-cases/chat-cutoff-retry.md +++ b/docs/qa/use-cases/chat-cutoff-retry.md @@ -12,7 +12,7 @@ cut-off reply's partial is preserved as a card, not dropped"): write even when only reasoning streamed (`ensureAssistantRow` normally fires on first `response_text`). Browser side, `venice.ts` keeps the drain open past the `error` broadcast so the terminal END - carries the row id, and `consumeStreamEvents` (`chat-loop.ts`) + carries the row id, and `consumeStreamEvents` (`chat/loop.ts`) hydrates that row before throwing. 2. **Retry replaces.** `retryIncompleteTurn` + the classification predicates in `src/lib/ui/incomplete-turn.ts` (`isReasoningOnlyStall`, diff --git a/docs/qa/use-cases/intent-injection-toggle.md b/docs/qa/use-cases/intent-injection-toggle.md index 3d34ec4c..a831f564 100644 --- a/docs/qa/use-cases/intent-injection-toggle.md +++ b/docs/qa/use-cases/intent-injection-toggle.md @@ -36,7 +36,7 @@ drawer source ([dev: logging](../../dev/logging.md)). The "# Working intentions" block is spliced into the row-0 system message INSIDE the edge function (`applyIntentPriming`), AFTER the browser has already POSTed and logged its own pre-priming view of -the wire (`chat-loop.ts` "venice request wire", source `chat`). The +the wire (`chat/loop.ts` "venice request wire", source `chat`). The server-side `stream` logger carries only operational lines (round/historyLen/terminal kind), never the assembled prompt content. So there is NO drawer-surfaced dump of the row-0 system @@ -125,4 +125,4 @@ update threads set intent_active_at_turn = '{}' where user_id = '$UID'; | Date | Env | Commit | Result | Notes | | ---- | --- | ------ | ------ | ----- | | 2026-06-24 | — | (this commit) | not run | Authored alongside the feature; first execution pending a live stack (cloud authoring env has none). The sequencing (intent block after bias on row 0) is also pinned by a Deno orchestration test; this case proves the end-to-end wire + the toggle gate + the snapshot, which unit tests cannot reach. | -| 2026-06-24 | local (dev-start) | f05168c | pass | First execution, driven by calling the real `applyIntentPriming` against the local Postgres (the orchestrator/Venice/Realtime path is unnecessary - priming is what mutates row-0 + writes the snapshot). Gate held: toggle off -> no block injected, `intent_active_at_turn` stayed `{}`. Toggle on -> block injected into row-0 with the dispositional-leans framing + user-instructions-first precedence note + both statements as bullets; snapshot held exactly the 2 active intent ids (cross-checked). With a bias block pre-seeded on row-0, the intent block landed AFTER it (precedence "guidance above" resolves). Cap arithmetic is pure + unit-covered (`intent-format.test.ts`). Rewrote the verification this run: dropped the non-executable "read the block in the `stream` wire" step (the block is spliced server-side after the browser logs its pre-priming wire, and the `stream` logger carries only operational lines - confirmed against `chat-loop.ts` + `getStreamingResponse.ts`); the faithful signal is the `intent_active_at_turn` snapshot. Root inaccuracy lives in `prompt-augmentation.md`'s Observability claim (tracked separately). | +| 2026-06-24 | local (dev-start) | f05168c | pass | First execution, driven by calling the real `applyIntentPriming` against the local Postgres (the orchestrator/Venice/Realtime path is unnecessary - priming is what mutates row-0 + writes the snapshot). Gate held: toggle off -> no block injected, `intent_active_at_turn` stayed `{}`. Toggle on -> block injected into row-0 with the dispositional-leans framing + user-instructions-first precedence note + both statements as bullets; snapshot held exactly the 2 active intent ids (cross-checked). With a bias block pre-seeded on row-0, the intent block landed AFTER it (precedence "guidance above" resolves). Cap arithmetic is pure + unit-covered (`intent-format.test.ts`). Rewrote the verification this run: dropped the non-executable "read the block in the `stream` wire" step (the block is spliced server-side after the browser logs its pre-priming wire, and the `stream` logger carries only operational lines - confirmed against `chat/loop.ts` + `getStreamingResponse.ts`); the faithful signal is the `intent_active_at_turn` snapshot. Root inaccuracy lives in `prompt-augmentation.md`'s Observability claim (tracked separately). | diff --git a/src/components/SamskaraHealthPanel.svelte b/src/components/SamskaraHealthPanel.svelte index bf803d57..05700842 100644 --- a/src/components/SamskaraHealthPanel.svelte +++ b/src/components/SamskaraHealthPanel.svelte @@ -17,9 +17,10 @@ * One Refresh reloads BOTH - the summary and every health read - so * the page is a single coherent snapshot, not two stale halves. * - * Composition only: every severity classification, relative-time - * format, and count-to-label transform is delegated to - * `$lib/ui/samskara-browse`. + * Composition only: the severity classification, regen status, and + * count-to-label transforms are delegated to + * `$lib/ui/samskara-health`; the relative-time format comes from + * `$lib/ui/samskara-browse`, shared with the detail pane. */ import { onMount } from 'svelte'; import { app } from '$lib/state.svelte'; @@ -28,13 +29,13 @@ compoundRegenStatus, worstSeverity, healthHeadline, - relativeTime, verdictBreakdown, tier2CandidateLabel, samskaraCountPhrase, HEALTH_THRESHOLDS, type Severity, - } from '$lib/ui/samskara-browse'; + } from '$lib/ui/samskara-health'; + import { relativeTime } from '$lib/ui/samskara-browse'; import type { SamskaraHealthSnapshot, SamskaraRates } from '$lib/supabase'; let loading = $state(true); diff --git a/src/lib/app-state/root.svelte.ts b/src/lib/app-state/root.svelte.ts index 15d87e18..9bfc97d3 100644 --- a/src/lib/app-state/root.svelte.ts +++ b/src/lib/app-state/root.svelte.ts @@ -102,7 +102,7 @@ interface AppState { * per-turn system-prompt appendix asking the model to use light * Markdown emphasis for semantic save-points (bold terms, italic * phrases). Opt-in; seeded from Supabase on unlock. See - * chat-loop.ts for the exact blurb. + * chat/loop.ts for the exact blurb. */ emphasisMarkdown: boolean; /** diff --git a/src/lib/ask-user.ts b/src/lib/ask-user.ts index d509584b..fd36e67a 100644 --- a/src/lib/ask-user.ts +++ b/src/lib/ask-user.ts @@ -7,7 +7,7 @@ * `serverSideTool`); the edge function returns the pending sentinel. * What stays browser-side is everything that reads that sentinel off a * persisted tool-result row and drives the suspend/resume UI: - * `chat-loop.ts` detects the suspended turn, `Chat.svelte` and + * `chat/loop.ts` detects the suspended turn, `Chat.svelte` and * `AskUserCard.svelte` render the card and write the answer back. Those * consumers share these content shapes, so they live here in a non-tool * module rather than alongside the dead browser impl. @@ -80,7 +80,7 @@ export interface AskUserAnsweredContent { * options are dropped rather than failing the whole call - the * AskUserCard renders a question with however many valid options * survived, and free-form answering covers the rest. Used by - * chat-loop.ts to pre-populate the card from the in-flight tool_call + * chat/loop.ts to pre-populate the card from the in-flight tool_call * event without waiting for the persisted row. */ export function extractAskUserPrompt(args: Record): { @@ -102,7 +102,7 @@ export function extractAskUserPrompt(args: Record): { } /** - * Type guards + content parser. Used by chat-loop.ts (to detect the + * Type guards + content parser. Used by chat/loop.ts (to detect the * suspended state on a tool-result row) and by Chat.svelte (to project * the message into an AskUserCard block). Returns null for any other * content - regular tool results, error payloads, malformed JSON. The diff --git a/src/lib/chat-loop.ts b/src/lib/chat/loop.ts similarity index 95% rename from src/lib/chat-loop.ts rename to src/lib/chat/loop.ts index 825294ef..e100d1c1 100644 --- a/src/lib/chat-loop.ts +++ b/src/lib/chat/loop.ts @@ -59,24 +59,24 @@ * return. */ -import type { VeniceMessage } from './venice'; -import { buildToolList } from './tools'; -import { buildSystemPrompt } from './chat-prompt'; -import { recordSubstrateStub } from './samskara'; -import { createLogger } from './logger.svelte'; -import { consumeStreamEvents } from './chat/stream-events'; -import { countUserRounds } from './intuition'; +import type { VeniceMessage } from '../venice'; +import { buildToolList } from '../tools'; +import { buildSystemPrompt } from './system-prompt'; +import { recordSubstrateStub } from '../samskara'; +import { createLogger } from '../logger.svelte'; +import { consumeStreamEvents } from './stream-events'; +import { countUserRounds } from '../intuition'; import { buildMetadataSystemMessage, splitSystemPreamble, -} from './chat/prompt-assembly'; -import type { ChatLoopOptions, ChatLoopResult } from './chat/types'; +} from './prompt-assembly'; +import type { ChatLoopOptions, ChatLoopResult } from './types'; -// `toVeniceMessage` (the stored-row -> wire projection) now lives in -// ./chat/prompt-assembly; re-exported here so its external consumers +// `toVeniceMessage` (the stored-row -> wire projection) lives in +// ./prompt-assembly; re-exported here so its external consumers // (Chat.svelte, tools/wire.ts, the wire test) keep importing it from -// `$lib/chat-loop`. -export { toVeniceMessage } from './chat/prompt-assembly'; +// `$lib/chat/loop`. +export { toVeniceMessage } from './prompt-assembly'; const log = createLogger('chat-loop'); diff --git a/src/lib/chat/prompt-assembly.ts b/src/lib/chat/prompt-assembly.ts index bb0fc18c..cbcb57e3 100644 --- a/src/lib/chat/prompt-assembly.ts +++ b/src/lib/chat/prompt-assembly.ts @@ -2,7 +2,7 @@ * Pure prompt-assembly builders for a chat turn. Each is a plain * function - options in, string or VeniceMessage out - with no loop * state and no side effects, so they test in isolation and `runChatLoop` - * (../chat-loop.ts) reads as a conductor that calls them. The per-turn + * (./loop.ts) reads as a conductor that calls them. The per-turn * metadata block, the wall-clock paragraph, the thread-attachments * inventory, the system-preamble split, and the stored-Message -> * wire-shape projection all live here. @@ -13,7 +13,7 @@ import type { Message, ThreadAttachmentSummary } from '../supabase'; import type { VeniceMessage } from '../venice'; import { buildUserVeniceContent } from '../attachments'; -import { buildToolboxStateBlock } from '../chat-prompt'; +import { buildToolboxStateBlock } from './system-prompt'; import { sanitizeToolCallIdForWire, sanitizeToolCallsForWire, @@ -24,7 +24,7 @@ import { coerceSecondThoughts, } from '../ui/second-thoughts'; -// --- appended verbatim from chat-loop.ts --- +// --- appended verbatim from chat/loop.ts --- /** Placeholder string threads ship with from schema.sql + draft creation. */ const DEFAULT_THREAD_TITLE = 'New conversation'; diff --git a/src/lib/chat/stream-events.ts b/src/lib/chat/stream-events.ts index 3eee03b0..a375d901 100644 --- a/src/lib/chat/stream-events.ts +++ b/src/lib/chat/stream-events.ts @@ -6,7 +6,7 @@ * server's terminalKind back onto the interrupted / conflict / * awaitingUserAnswer flags the caller's UI keys off. * - * Split out from ../chat-loop.ts: `runChatLoop` feeds this its + * Split out from ../chat/loop.ts: `runChatLoop` feeds this its * `venice.streamChat` iterator and projects the returned * `ConsumedStreamResult` into its own `ChatLoopResult`. The consumer * closes over no loop state - everything it needs arrives in `opts` - diff --git a/src/lib/chat-prompt.ts b/src/lib/chat/system-prompt.ts similarity index 98% rename from src/lib/chat-prompt.ts rename to src/lib/chat/system-prompt.ts index e2888df7..02155cf1 100644 --- a/src/lib/chat-prompt.ts +++ b/src/lib/chat/system-prompt.ts @@ -11,7 +11,7 @@ * sub-agents) live next to their callers; the "chat" in the name is * literal - this is the prompt for the user-facing chat loop only. * - * Two exports, both called from `src/lib/chat-loop.ts` once per turn + * Two exports, both called from `src/lib/chat/loop.ts` once per turn * and from the test suite: `buildSystemPrompt` builds the stable * baseline, and `buildToolboxStateBlock` builds the volatile * gated-toolbox on/off block that rides in the per-turn metadata @@ -25,8 +25,8 @@ * source. The blocks join with blank lines between them at the bottom * of `buildSystemPrompt`, alongside the catalog. */ -import { TOOLBOXES, alwaysOnToolbox, toggleToolbox } from './tools'; -import type { Toolbox } from './tools'; +import { TOOLBOXES, alwaysOnToolbox, toggleToolbox } from '../tools'; +import type { Toolbox } from '../tools'; /** * Gated toolboxes - everything in `TOOLBOXES` other than the always-on @@ -52,7 +52,7 @@ const GATED_TOOLBOXES: readonly Toolbox[] = TOOLBOXES.filter( * a dedicated metadata system message that the chat-loop assembles per * round and pins at the TAIL of the request, after the conversation * (for prompt-cache stability - see `buildMetadataSystemMessage` and the - * request assembly in `chat-loop.ts`). The + * request assembly in `chat/loop.ts`). The * samskara/intuition/context-recall priming projections ride as * assistant `` messages after the user turn, not as appendix * text. The bias-profile appendix that used to ride at the end here is @@ -276,7 +276,7 @@ Examples: * The catalog carries NO per-turn (on)/(off) state - it lists what * exists, not what is currently enabled. The volatile enabled/disabled * state rides in the per-turn metadata system message instead (see - * `buildToolboxStateBlock` and the request assembly in chat-loop.ts). + * `buildToolboxStateBlock` and the request assembly in chat/loop.ts). * Keeping the catalog state-free is what makes the baseline system * prompt byte-stable across a mid-conversation toggle_toolbox flip, so * a toggle re-encodes only the small trailing metadata block rather @@ -378,7 +378,7 @@ export function buildToolboxStateBlock(enabled: readonly string[]): string { * Per-turn ambient context (datetime, toolbox state, attachments * inventory, formatting and title nudges, identity facts) is NOT * carried here. It rides as a separate metadata system message that - * chat-loop.ts builds per round and pins at the TAIL of the request. + * chat/loop.ts builds per round and pins at the TAIL of the request. * Recall and intuition projections ride as assistant `` * messages after the user turn. The baseline this function returns is * fully stable across rounds and across toolbox toggles - nothing in diff --git a/src/lib/chat/types.ts b/src/lib/chat/types.ts index 077397a0..d09e578f 100644 --- a/src/lib/chat/types.ts +++ b/src/lib/chat/types.ts @@ -1,6 +1,6 @@ /** * Chat-turn contract types. The option/result/handler shapes that - * `runChatLoop` (../chat-loop.ts) takes and returns, plus the + * `runChatLoop` (./loop.ts) takes and returns, plus the * stream consumer (./stream-events.ts) shares. Kept in their own * module so the behavior files stay focused on logic and so UI code * that only needs the `SubconsciousOp` vocabulary doesn't import a diff --git a/src/lib/stream-guards.ts b/src/lib/stream-guards.ts index 52e299d4..f6cab743 100644 --- a/src/lib/stream-guards.ts +++ b/src/lib/stream-guards.ts @@ -21,7 +21,7 @@ * actual failure mode. * * A guard inspects an in-flight attempt and returns a verdict; the - * async wrapper that drives the verdicts lives in chat-loop.ts + * async wrapper that drives the verdicts lives in chat/loop.ts * (`streamChatWithGuards`) because it needs the streaming generator and * the abort plumbing. Everything in THIS file is pure and unit-testable * without a Venice client or a Svelte runtime - that split is @@ -33,7 +33,7 @@ * lean on `combineVerdicts` to compose. The wrapper, the buffering, and * the retry cap are all guard-agnostic. * - * Interacts with: chat-loop.ts (the async wrapper + the round consumer), + * Interacts with: chat/loop.ts (the async wrapper + the round consumer), * models/index.ts (which models arm the special-token guard, via * `modelLeaksSpecialTokens`), venice.ts (`StreamEvent`). */ diff --git a/src/lib/supabase/venice-proxy.ts b/src/lib/supabase/venice-proxy.ts index 19483848..457dc3e3 100644 --- a/src/lib/supabase/venice-proxy.ts +++ b/src/lib/supabase/venice-proxy.ts @@ -83,7 +83,7 @@ export async function veniceFunctionError(error: unknown): Promise * Maximum attempts (initial + retries) before `SupabaseService.complete` * surfaces a 429 to the caller. Picked so a brief quota dip recovers * transparently while a stuck quota still surfaces within ~10s of total - * wait. The streaming path in chat-loop.ts uses its own attempt count; + * wait. The streaming path in chat/loop.ts uses its own attempt count; * the non-streaming chat seam sits behind tool sub-calls and background * agents with no UI feedback, so a propagated 429 lands as a silent * `{error: "..."}` in a tool-result row or a swallowed agent failure - @@ -104,7 +104,7 @@ const COMPLETE_RATE_LIMIT_FALLBACK_WAIT_MS = [1_000, 1_710, 2_924, 5_000]; /** * Hard cap on a single 429 wait inside `complete`. Mirrors - * RATE_LIMIT_WAIT_CAP_MS in chat-loop.ts: a Retry-After longer than a + * RATE_LIMIT_WAIT_CAP_MS in chat/loop.ts: a Retry-After longer than a * minute almost certainly means a daily/monthly cap that won't clear * during the current call, so surface it as a hard error rather than * blocking a tool sub-call (or, worse, a background agent the user @@ -272,7 +272,7 @@ export async function extractText( * supports it) and the inter-attempt sleep. * * Streaming chat completion still talks to Venice directly from - * src/lib/chat-loop.ts; the streaming attractor is the next driver-B + * src/lib/chat/loop.ts; the streaming attractor is the next driver-B * milestone. */ export async function complete( diff --git a/src/lib/tools/ask_user.schema.ts b/src/lib/tools/ask_user.schema.ts index 18f8c142..3fd05575 100644 --- a/src/lib/tools/ask_user.schema.ts +++ b/src/lib/tools/ask_user.schema.ts @@ -5,7 +5,7 @@ * other tool in the catalog, its "result" is supplied by the user, not * computed by code - the chat-loop suspends after this call lands and * waits for a UI-provided answer before resuming. See `./ask_user.ts` - * for the sentinel/answer wire shapes and `src/lib/chat-loop.ts` for + * for the sentinel/answer wire shapes and `src/lib/chat/loop.ts` for * how the loop drives the suspend/resume cycle. * * Constants exported so the impl, the UI card, and tests share one diff --git a/src/lib/tools/index.ts b/src/lib/tools/index.ts index 799145fa..2c5b3296 100644 --- a/src/lib/tools/index.ts +++ b/src/lib/tools/index.ts @@ -11,7 +11,7 @@ * - This file composes them into named toolboxes (cooking, memories, * always_on), resolves names to defs, and projects them into * the OpenAI / Venice request shape. - * - The main-chat system prompt is assembled in `../chat-prompt.ts`, + * - The main-chat system prompt is assembled in `../chat/system-prompt.ts`, * which imports the registry from here to render the dynamic tool * catalog. Prose blocks and the catalog renderer live there, not * here. @@ -128,7 +128,7 @@ import { serverSideTool } from './server_side'; // The eager always-on surfaces whose dispatch is server-side. Each is // a serverSideTool: catalog metadata for the wire payload, a throwing // execute() that fires only if a regression re-routes dispatch -// browser-side. `toggleToolbox` is read by chat-prompt.ts for its +// browser-side. `toggleToolbox` is read by chat/system-prompt.ts for its // `.name` (to filter it out of the rendered catalog) and re-exported // below; the rest are referenced only by `alwaysOnToolbox`. const toggleToolbox = serverSideTool(toggleToolboxSchema); @@ -602,7 +602,7 @@ export function getToolFormatters(name: string): ToolFormatters | undefined { } export { toOpenAIToolDef }; -// `toggleToolbox` is read by chat-prompt.ts for its `.name`; re-exported +// `toggleToolbox` is read by chat/system-prompt.ts for its `.name`; re-exported // for that one consumer. export { toggleToolbox }; export type { ToolDef, OpenAIToolDef, ToolContext, ToolResult, Toolbox } from './types'; diff --git a/src/lib/tools/wire.ts b/src/lib/tools/wire.ts index e545de22..acdf9934 100644 --- a/src/lib/tools/wire.ts +++ b/src/lib/tools/wire.ts @@ -103,7 +103,7 @@ export function sanitizeToolCallIdForWire(id: string): string { * tool-error result row), so the next round's model sees the failure * via the tool result regardless of what the echoed arguments say. * - * Used by chat-loop.ts (toVeniceMessage + the in-loop history push) + * Used by chat/loop.ts (toVeniceMessage + the in-loop history push) * and the no-tool completion agents' messageToVenice helpers that * project a stored Message onto a VeniceMessage (summary, topics). * The venice function's agent runner carries its own mirror of this @@ -157,7 +157,7 @@ export function sanitizeToolCallsForWire( * tool whose schema nests free-form fields under a wrapper still * benefits. * - * Throws on invalid JSON. The caller (chat-loop.ts) catches the + * Throws on invalid JSON. The caller (chat/loop.ts) catches the * throw and surfaces it as a tool error so the next round sees the * parse failure instead of a silent default. */ diff --git a/src/lib/ui/samskara-browse.ts b/src/lib/ui/samskara-browse.ts index 3f93c2fe..7eb0eb2c 100644 --- a/src/lib/ui/samskara-browse.ts +++ b/src/lib/ui/samskara-browse.ts @@ -1,15 +1,18 @@ /** - * UI-behavior primitives for the Samskara diagnostics tab (Corpus + - * Health panels). Pure functions only - no runes, no Svelte, no DOM, no - * Supabase. The companion components (`SamskaraBrowseList.svelte`, - * `SamskaraHealthPanel.svelte`, `Samskaras.svelte`) compose these with + * UI-behavior primitives for the Samskara tab's Corpus browse list and + * per-samskara detail pane. Pure functions only - no runes, no Svelte, + * no DOM, no Supabase. The companion components + * (`SamskaraBrowseList.svelte`, `Samskaras.svelte`) compose these with * framework-native reactivity. * * Everything a port to React/Solid/Vue would rewrite stays in the * components; everything else - sort/tier option lists, the - * hide-similar collapse, the health-severity classification and its - * thresholds, label/pluralization/relative-time helpers - lives here - * and is unit-tested directly. + * hide-similar collapse, provenance grouping, the per-samskara verdict + * list, label/valence/relative-time formatters - lives here and is + * unit-tested directly. The Health panel's severity toolkit lives in + * the sibling ./samskara-health.ts; that panel also reads this + * module's `relativeTime`, and the two verdict lists share this + * module's VerdictCount shape. */ import type { SamskaraBrowseSort, @@ -122,93 +125,6 @@ export function collapseSimilar( return out; } -// --- Health severity ---------------------------------------------------- - -export type Severity = 'ok' | 'warn' | 'alarm'; - -/** - * Thresholds for the Health panel's severity classification. Starting - * defaults - tune against observed pipeline behaviour. Each pair is - * [warn-at, alarm-at]: a value >= alarm-at is 'alarm', >= warn-at is - * 'warn', else 'ok'. - * - * Backlogs only matter when they're DEEP and persistent: the workers run - * client-side, so a backlog accumulates while no tab is open and drains - * when one is - a snapshot of a few pending rows is normal, not a stall. - * Hence the loose [50, 500] bars. Orphans and stuck claims, by contrast, - * should be ~0 regardless of worker scheduling, so their bars are tight. - * - * Deliberately NOT here: a "fires aged out unresolved" bar. That count - * grows unbounded by design - reaction-classify only ever resolves the - * cohort whose follow-up landed in the 1-10min window, so ~95% of fires - * age out unresolved forever. Flagging it as a failure is a false alarm - * (it was the original cause of a permanent "something is stuck"). - */ -export const HEALTH_THRESHOLDS = { - pendingAssimilate: [50, 500], - pendingEmbed: [50, 500], - orphanFires: [1, 5], - stuckClaims: [1, 3], -} as const satisfies Record; - -/** Classify a backlog/inconsistency count against a [warn, alarm] pair. */ -export function severityFor(value: number, thresholds: readonly [number, number]): Severity { - if (value >= thresholds[1]) return 'alarm'; - if (value >= thresholds[0]) return 'warn'; - return 'ok'; -} - -export interface CompoundRegenStatus { - /** Severity for the headline dot. */ - sev: Severity; - /** New samskaras formed since the last regen (the event-arm value). */ - delta: number; - /** Count at which the background regen fires (the log10-damped bar). */ - threshold: number; -} - -/** - * Compound-summary regen status, derived from the SAME predicate the - * background regen uses (schema.sql `samskara_should_regen_compound`), - * NOT wall-clock age. - * - * Age alone is a false positive: the summary only regenerates when the - * hourly sweep visits a user, and the sweep only fans out to users with - * substrate/fire activity in the last couple of hours - * (SWEEP_USER_WINDOW_HOURS). An idle account's summary therefore drifts - * arbitrarily far past the predicate's 6h window with nothing wrong and - * nothing to do - an age>=6h/>=24h dot lit amber/red on exactly that - * benign case. - * - * The actionable arm is the event count: samskaras formed since the last - * regen, against the log10-damped threshold. Unlike age, the delta only - * grows while the user is active - which is precisely when the sweep can - * act on it - so a delta stuck past the bar is a real "the sweep isn't - * keeping up" signal, not an idle-time artifact. - * - * threshold = max(3, ceil(5 * log10(total + 10))), mirroring the SQL. - * Both sides are base-10: JS `Math.log10` equals Postgres unary `log()` - * (NOT `ln()`); see the base-10 caution in the SQL function. Due at - * >= threshold (warn); escalates to alarm at >= 2x threshold, where - * "due but unmet" stops reading as "the next sweep will catch it". - */ -export function compoundRegenStatus( - totalSamskaras: number, - samskaraCountAtRegen: number, - hasSummary: boolean, -): CompoundRegenStatus { - const threshold = Math.max(3, Math.ceil(5 * Math.log10(totalSamskaras + 10))); - // No summary yet is mild, not an alarm: the normal resting state of a - // corpus that hasn't formed enough samskaras to prime the first regen. - // Mirrors the predicate's `last_regen_at is null and count > 0` arm. - if (!hasSummary) { - return { sev: totalSamskaras > 0 ? 'warn' : 'ok', delta: totalSamskaras, threshold }; - } - const delta = Math.max(0, totalSamskaras - samskaraCountAtRegen); - const sev: Severity = delta >= 2 * threshold ? 'alarm' : delta >= threshold ? 'warn' : 'ok'; - return { sev, delta, threshold }; -} - /** * One-line summary of the hide-similar collapse for the muted label * under the slider: how many distinct samskaras remain after folding @@ -219,26 +135,6 @@ export function matchSummary(shown: number, total: number): string { return `Showing ${shown} of ${total} - ${hidden} folded as similar`; } -/** - * Worst severity across a set - for a single panel-level headline dot. - * 'alarm' dominates 'warn' dominates 'ok'. - */ -export function worstSeverity(severities: readonly Severity[]): Severity { - if (severities.includes('alarm')) return 'alarm'; - if (severities.includes('warn')) return 'warn'; - return 'ok'; -} - -/** - * Headline copy for the Health panel's overall severity dot. One - * phrase per severity tier; the dot itself carries the color. - */ -export function healthHeadline(overall: Severity): string { - if (overall === 'ok') return 'Pipeline healthy'; - if (overall === 'warn') return 'Needs a look'; - return 'Something is stuck'; -} - /** * Provenance kinds in display order: the substrate that formed the * samskara first, then the relations that tied that substrate together, @@ -290,27 +186,6 @@ export interface VerdictCount { count: number; } -/** - * The judged-fire verdict breakdown as a labelled list, in the order - * the Health panel stacks them. Extracted so the panel iterates a - * vertical list rather than interpolating three slash-separated counts - * on one line - that line wrapped mid-slash and read as jarring on - * narrow (mobile) viewports. - */ -export function verdictBreakdown(rates: { - held: number; - contradicted: number; - notBorneOut: number; - notEngaged: number; -}): VerdictCount[] { - return [ - { label: 'held', count: rates.held }, - { label: 'contradicted', count: rates.contradicted }, - { label: 'not-borne-out', count: rates.notBorneOut }, - { label: 'not-engaged', count: rates.notEngaged }, - ]; -} - /** * Lifetime verdict breakdown for a single samskara's detail pane. Same * order as verdictBreakdown plus a trailing `pending` (fired but not yet @@ -332,18 +207,3 @@ export function verdictCountList(counts: { { label: 'pending', count: counts.pending }, ]; } - -/** - * Health-panel readout for the tier-2 detector: how many tier-1 members - * it would currently hand the minter. Size is 0 (nothing offerable) or - * >= the minter's 3-member floor; the singular branch is defensive. - */ -export function tier2CandidateLabel(size: number): string { - if (size <= 0) return 'none available'; - return `available (${size} member${size === 1 ? '' : 's'})`; -} - -/** "N samskara" / "N samskaras" - the compound-summary coverage caption. */ -export function samskaraCountPhrase(count: number): string { - return `${count} samskara${count === 1 ? '' : 's'}`; -} diff --git a/src/lib/ui/samskara-health.ts b/src/lib/ui/samskara-health.ts new file mode 100644 index 00000000..c49151bd --- /dev/null +++ b/src/lib/ui/samskara-health.ts @@ -0,0 +1,155 @@ +/** + * UI-behavior primitives for the Samskara Health panel + * (src/components/SamskaraHealthPanel.svelte): the severity + * classification and its thresholds, the compound-summary regen + * status, the panel headline, and the health-side count labels. + * Pure functions only - no runes, no Svelte imports, no DOM. + * + * The browse/detail primitives (list constants, collapse, provenance + * grouping, per-samskara verdict list) live in the sibling + * ./samskara-browse.ts; the health panel also reads that module's + * `relativeTime` directly. The shared VerdictCount row shape is + * imported from there so the two verdict lists render identically. + */ +import type { VerdictCount } from './samskara-browse'; + +export type Severity = 'ok' | 'warn' | 'alarm'; + +/** + * Thresholds for the Health panel's severity classification. Starting + * defaults - tune against observed pipeline behaviour. Each pair is + * [warn-at, alarm-at]: a value >= alarm-at is 'alarm', >= warn-at is + * 'warn', else 'ok'. + * + * Backlogs only matter when they're DEEP and persistent: the workers run + * client-side, so a backlog accumulates while no tab is open and drains + * when one is - a snapshot of a few pending rows is normal, not a stall. + * Hence the loose [50, 500] bars. Orphans and stuck claims, by contrast, + * should be ~0 regardless of worker scheduling, so their bars are tight. + * + * Deliberately NOT here: a "fires aged out unresolved" bar. That count + * grows unbounded by design - reaction-classify only ever resolves the + * cohort whose follow-up landed in the 1-10min window, so ~95% of fires + * age out unresolved forever. Flagging it as a failure is a false alarm + * (it was the original cause of a permanent "something is stuck"). + */ +export const HEALTH_THRESHOLDS = { + pendingAssimilate: [50, 500], + pendingEmbed: [50, 500], + orphanFires: [1, 5], + stuckClaims: [1, 3], +} as const satisfies Record; + +/** Classify a backlog/inconsistency count against a [warn, alarm] pair. */ +export function severityFor(value: number, thresholds: readonly [number, number]): Severity { + if (value >= thresholds[1]) return 'alarm'; + if (value >= thresholds[0]) return 'warn'; + return 'ok'; +} + +export interface CompoundRegenStatus { + /** Severity for the headline dot. */ + sev: Severity; + /** New samskaras formed since the last regen (the event-arm value). */ + delta: number; + /** Count at which the background regen fires (the log10-damped bar). */ + threshold: number; +} + +/** + * Compound-summary regen status, derived from the SAME predicate the + * background regen uses (schema.sql `samskara_should_regen_compound`), + * NOT wall-clock age. + * + * Age alone is a false positive: the summary only regenerates when the + * hourly sweep visits a user, and the sweep only fans out to users with + * substrate/fire activity in the last couple of hours + * (SWEEP_USER_WINDOW_HOURS). An idle account's summary therefore drifts + * arbitrarily far past the predicate's 6h window with nothing wrong and + * nothing to do - an age>=6h/>=24h dot lit amber/red on exactly that + * benign case. + * + * The actionable arm is the event count: samskaras formed since the last + * regen, against the log10-damped threshold. Unlike age, the delta only + * grows while the user is active - which is precisely when the sweep can + * act on it - so a delta stuck past the bar is a real "the sweep isn't + * keeping up" signal, not an idle-time artifact. + * + * threshold = max(3, ceil(5 * log10(total + 10))), mirroring the SQL. + * Both sides are base-10: JS `Math.log10` equals Postgres unary `log()` + * (NOT `ln()`); see the base-10 caution in the SQL function. Due at + * >= threshold (warn); escalates to alarm at >= 2x threshold, where + * "due but unmet" stops reading as "the next sweep will catch it". + */ +export function compoundRegenStatus( + totalSamskaras: number, + samskaraCountAtRegen: number, + hasSummary: boolean, +): CompoundRegenStatus { + const threshold = Math.max(3, Math.ceil(5 * Math.log10(totalSamskaras + 10))); + // No summary yet is mild, not an alarm: the normal resting state of a + // corpus that hasn't formed enough samskaras to prime the first regen. + // Mirrors the predicate's `last_regen_at is null and count > 0` arm. + if (!hasSummary) { + return { sev: totalSamskaras > 0 ? 'warn' : 'ok', delta: totalSamskaras, threshold }; + } + const delta = Math.max(0, totalSamskaras - samskaraCountAtRegen); + const sev: Severity = delta >= 2 * threshold ? 'alarm' : delta >= threshold ? 'warn' : 'ok'; + return { sev, delta, threshold }; +} + +/** + * Worst severity across a set - for a single panel-level headline dot. + * 'alarm' dominates 'warn' dominates 'ok'. + */ +export function worstSeverity(severities: readonly Severity[]): Severity { + if (severities.includes('alarm')) return 'alarm'; + if (severities.includes('warn')) return 'warn'; + return 'ok'; +} + +/** + * Headline copy for the Health panel's overall severity dot. One + * phrase per severity tier; the dot itself carries the color. + */ +export function healthHeadline(overall: Severity): string { + if (overall === 'ok') return 'Pipeline healthy'; + if (overall === 'warn') return 'Needs a look'; + return 'Something is stuck'; +} + +/** + * The judged-fire verdict breakdown as a labelled list, in the order + * the Health panel stacks them. Extracted so the panel iterates a + * vertical list rather than interpolating three slash-separated counts + * on one line - that line wrapped mid-slash and read as jarring on + * narrow (mobile) viewports. + */ +export function verdictBreakdown(rates: { + held: number; + contradicted: number; + notBorneOut: number; + notEngaged: number; +}): VerdictCount[] { + return [ + { label: 'held', count: rates.held }, + { label: 'contradicted', count: rates.contradicted }, + { label: 'not-borne-out', count: rates.notBorneOut }, + { label: 'not-engaged', count: rates.notEngaged }, + ]; +} + +/** + * Health-panel readout for the tier-2 detector: how many tier-1 members + * it would currently hand the minter. Size is 0 (nothing offerable) or + * >= the minter's 3-member floor; the singular branch is defensive. + */ +export function tier2CandidateLabel(size: number): string { + if (size <= 0) return 'none available'; + return `available (${size} member${size === 1 ? '' : 's'})`; +} + +/** "N samskara" / "N samskaras" - the compound-summary coverage caption. */ +export function samskaraCountPhrase(count: number): string { + return `${count} samskara${count === 1 ? '' : 's'}`; +} diff --git a/src/lib/venice.ts b/src/lib/venice.ts index 709fb6ae..b3f75094 100644 --- a/src/lib/venice.ts +++ b/src/lib/venice.ts @@ -156,7 +156,7 @@ export interface Citation { * {@link ChatRequest.webCitations} so a caller can keep grounding * while suppressing the `[1]` / `[2]` markers in the answer body. * - * Caller scoping: the main chat loop in `chat-loop.ts` deliberately + * Caller scoping: the main chat loop in `chat/loop.ts` deliberately * does NOT set `webSearch` or `webCitations` on its `streamChat` * calls. Venice treats `enable_web_search: 'on'` as unconditional * (every request runs a search), so leaving the flag unset is the @@ -1021,7 +1021,7 @@ export class VeniceClient { } catch (err) { // SSE parse failures, network interruptions mid-stream, and // reader.read() rejections all land here. The error is re- - // thrown so the for-await consumer in chat-loop.ts sees it + // thrown so the for-await consumer in chat/loop.ts sees it // (and runExchange's outer catch surfaces it to the user), // but we log at the source layer too so the log drawer shows // the error with `venice` as the source tag. Mobile users @@ -1083,7 +1083,7 @@ export class VeniceClient { * longer trust the live path and must reconcile against the row. * * Thrown by the drain (not yielded) so the for-await consumer in - * chat-loop.ts surfaces it as an exception, and Chat.svelte's + * chat/loop.ts surfaces it as an exception, and Chat.svelte's * runExchange catch can route it into the poll-the-row reconnect * (`reconnectInflightTurn`) instead of the generic "response was cut * off" banner. NOT a VeniceError subclass on purpose: the existing diff --git a/src/screens/Chat.svelte b/src/screens/Chat.svelte index 3237e85b..ba917a45 100644 --- a/src/screens/Chat.svelte +++ b/src/screens/Chat.svelte @@ -69,7 +69,7 @@ type SamskaraSubstrateDiagnosticRow, type TopicVocabulary, } from '$lib/supabase'; - import { runChatLoop, toVeniceMessage } from '$lib/chat-loop'; + import { runChatLoop, toVeniceMessage } from '$lib/chat/loop'; import { GuardExhaustedError } from '$lib/stream-guards'; import { slopNoticeCopy } from '$lib/ui/slop-notice'; import { ExchangeStore, mergeMessagesById } from '$lib/exchange/exchange-store.svelte'; @@ -4066,7 +4066,7 @@ try { // Rate-limit retries are handled inside the chat-loop now - // (see streamChatWithRateLimitRetry in chat-loop.ts), which + // (see streamChatWithRateLimitRetry in chat/loop.ts), which // sleeps for the duration parsed from Venice's Retry-After / // x-ratelimit-reset-* headers and re-issues the request up // to RATE_LIMIT_MAX_ATTEMPTS times. By the time a rate_limit diff --git a/supabase/schema.sql b/supabase/schema.sql index c70d8d09..ba688918 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -3561,7 +3561,7 @@ $$; -- Scored sibling of search_memories_by_embedding. Same ranking formula, -- but returns the boosted similarity score alongside each row so the -- caller can threshold in application code. Used by the opening-turn --- memory-recall priming in chat-loop.ts, which needs a minimum-score +-- memory-recall priming in chat/loop.ts, which needs a minimum-score -- gate to avoid injecting noise on turns that don't actually look like -- anything the user's memories cover. Kept as a separate function so -- the main memory_search path (and the Memories browser) stays on the diff --git a/tests/samskara-browse.test.ts b/tests/samskara-browse.test.ts index 360e2a4b..d1e99616 100644 --- a/tests/samskara-browse.test.ts +++ b/tests/samskara-browse.test.ts @@ -1,25 +1,18 @@ /** - * Unit coverage for the Samskara browse/health UI primitives. Pure - * functions, no mount - drives the decision logic the diagnostics tab's - * components delegate to. + * Unit coverage for the Samskara browse/detail UI primitives. Pure + * functions, no mount - drives the decision logic the Corpus list and + * detail pane delegate to. The Health panel's severity toolkit is + * covered in tests/samskara-health.test.ts. */ import { describe, it, expect } from 'vitest'; import { collapseSimilar, - severityFor, - compoundRegenStatus, matchSummary, - worstSeverity, - healthHeadline, relativeTime, formatValence, emptyMessage, - HEALTH_THRESHOLDS, groupProvenance, - verdictBreakdown, verdictCountList, - tier2CandidateLabel, - samskaraCountPhrase, type CollapsedRow, } from '../src/lib/ui/samskara-browse'; import type { SamskaraCorpusRow, SamskaraProvenanceRow } from '../src/lib/supabase'; @@ -76,46 +69,6 @@ describe('collapseSimilar', () => { }); }); -describe('severityFor', () => { - it('classifies against a [warn, alarm] pair', () => { - expect(severityFor(0, HEALTH_THRESHOLDS.orphanFires)).toBe('ok'); - expect(severityFor(1, HEALTH_THRESHOLDS.orphanFires)).toBe('warn'); - expect(severityFor(5, HEALTH_THRESHOLDS.orphanFires)).toBe('alarm'); - expect(severityFor(49, HEALTH_THRESHOLDS.pendingAssimilate)).toBe('ok'); - expect(severityFor(50, HEALTH_THRESHOLDS.pendingAssimilate)).toBe('warn'); - expect(severityFor(500, HEALTH_THRESHOLDS.pendingAssimilate)).toBe('alarm'); - }); -}); - -describe('compoundRegenStatus', () => { - // threshold = max(3, ceil(5 * log10(total + 10))). At total=152 the bar - // is ceil(5 * log10(162)) = ceil(11.04) = 12; alarm at 2x = 24. - it('severity tracks the regen backlog, not the summary age', () => { - expect(compoundRegenStatus(152, 152, true).sev).toBe('ok'); // 0 new - expect(compoundRegenStatus(152, 145, true).sev).toBe('ok'); // 7 < 12 - expect(compoundRegenStatus(152, 140, true).sev).toBe('warn'); // 12 >= 12 - expect(compoundRegenStatus(152, 128, true).sev).toBe('alarm'); // 24 >= 24 - }); - it('exposes the delta and threshold for the readout', () => { - expect(compoundRegenStatus(152, 145, true)).toMatchObject({ delta: 7, threshold: 12 }); - }); - it('floors the threshold at 3 for a small corpus', () => { - // ceil(5 * log10(13)) = ceil(5.57) = 6, so the floor doesn't bind - // here; a near-empty corpus (total=0 -> ceil(5*log10(10))=5) is still - // above 3, so the floor only matters as a guard, never a false alarm. - expect(compoundRegenStatus(3, 0, true).threshold).toBeGreaterThanOrEqual(3); - }); - it('treats a missing summary as warn when any samskaras exist, else ok', () => { - expect(compoundRegenStatus(5, 0, false).sev).toBe('warn'); - expect(compoundRegenStatus(0, 0, false).sev).toBe('ok'); - }); - it('clamps a negative delta to ok (count_at_regen above current count)', () => { - // A regen stamped a higher count than the live total (e.g. reaping - // dropped rows after the stamp) must not read as a backlog. - expect(compoundRegenStatus(140, 152, true).sev).toBe('ok'); - }); -}); - describe('matchSummary', () => { it('reports shown / total and how many were folded', () => { expect(matchSummary(47, 120)).toBe('Showing 47 of 120 - 73 folded as similar'); @@ -123,23 +76,6 @@ describe('matchSummary', () => { }); }); -describe('worstSeverity', () => { - it('alarm dominates warn dominates ok', () => { - expect(worstSeverity(['ok', 'warn', 'alarm'])).toBe('alarm'); - expect(worstSeverity(['ok', 'warn'])).toBe('warn'); - expect(worstSeverity(['ok', 'ok'])).toBe('ok'); - expect(worstSeverity([])).toBe('ok'); - }); -}); - -describe('healthHeadline', () => { - it('maps each severity tier to its headline phrase', () => { - expect(healthHeadline('ok')).toBe('Pipeline healthy'); - expect(healthHeadline('warn')).toBe('Needs a look'); - expect(healthHeadline('alarm')).toBe('Something is stuck'); - }); -}); - describe('groupProvenance', () => { const row = ( kind: SamskaraProvenanceRow['kind'], @@ -195,19 +131,6 @@ describe('relativeTime / formatValence / emptyMessage', () => { }); }); -describe('verdictBreakdown', () => { - it('emits the four verdicts in the panel stack order', () => { - const out = verdictBreakdown({ held: 5, contradicted: 2, notBorneOut: 3, notEngaged: 7 }); - expect(out.map((v) => v.label)).toEqual([ - 'held', - 'contradicted', - 'not-borne-out', - 'not-engaged', - ]); - expect(out.map((v) => v.count)).toEqual([5, 2, 3, 7]); - }); -}); - describe('verdictCountList', () => { it('emits the four verdicts plus a trailing pending, in order', () => { const out = verdictCountList({ @@ -228,23 +151,3 @@ describe('verdictCountList', () => { }); }); -describe('tier2CandidateLabel', () => { - it('reports none when nothing is offerable', () => { - expect(tier2CandidateLabel(0)).toBe('none available'); - // Defensive: the RPC never returns negatives, but the floor holds. - expect(tier2CandidateLabel(-1)).toBe('none available'); - }); - it('reports the member count, pluralizing', () => { - // The minter's floor is 3, so 1 is the defensive-singular path. - expect(tier2CandidateLabel(1)).toBe('available (1 member)'); - expect(tier2CandidateLabel(4)).toBe('available (4 members)'); - }); -}); - -describe('samskaraCountPhrase', () => { - it('pluralizes the coverage caption', () => { - expect(samskaraCountPhrase(0)).toBe('0 samskaras'); - expect(samskaraCountPhrase(1)).toBe('1 samskara'); - expect(samskaraCountPhrase(14)).toBe('14 samskaras'); - }); -}); diff --git a/tests/samskara-health.test.ts b/tests/samskara-health.test.ts new file mode 100644 index 00000000..98c59aef --- /dev/null +++ b/tests/samskara-health.test.ts @@ -0,0 +1,109 @@ +/** + * Unit coverage for the Samskara Health panel primitives. Pure + * functions, no mount - drives the severity classification, the + * regen-status derivation, and the health-side labels the panel + * delegates to. The browse/detail primitives are covered in + * tests/samskara-browse.test.ts. + */ +import { describe, it, expect } from 'vitest'; +import { + severityFor, + compoundRegenStatus, + worstSeverity, + healthHeadline, + verdictBreakdown, + tier2CandidateLabel, + samskaraCountPhrase, + HEALTH_THRESHOLDS, +} from '../src/lib/ui/samskara-health'; + +describe('severityFor', () => { + it('classifies against a [warn, alarm] pair', () => { + expect(severityFor(0, HEALTH_THRESHOLDS.orphanFires)).toBe('ok'); + expect(severityFor(1, HEALTH_THRESHOLDS.orphanFires)).toBe('warn'); + expect(severityFor(5, HEALTH_THRESHOLDS.orphanFires)).toBe('alarm'); + expect(severityFor(49, HEALTH_THRESHOLDS.pendingAssimilate)).toBe('ok'); + expect(severityFor(50, HEALTH_THRESHOLDS.pendingAssimilate)).toBe('warn'); + expect(severityFor(500, HEALTH_THRESHOLDS.pendingAssimilate)).toBe('alarm'); + }); +}); + +describe('compoundRegenStatus', () => { + // threshold = max(3, ceil(5 * log10(total + 10))). At total=152 the bar + // is ceil(5 * log10(162)) = ceil(11.04) = 12; alarm at 2x = 24. + it('severity tracks the regen backlog, not the summary age', () => { + expect(compoundRegenStatus(152, 152, true).sev).toBe('ok'); // 0 new + expect(compoundRegenStatus(152, 145, true).sev).toBe('ok'); // 7 < 12 + expect(compoundRegenStatus(152, 140, true).sev).toBe('warn'); // 12 >= 12 + expect(compoundRegenStatus(152, 128, true).sev).toBe('alarm'); // 24 >= 24 + }); + it('exposes the delta and threshold for the readout', () => { + expect(compoundRegenStatus(152, 145, true)).toMatchObject({ delta: 7, threshold: 12 }); + }); + it('floors the threshold at 3 for a small corpus', () => { + // ceil(5 * log10(13)) = ceil(5.57) = 6, so the floor doesn't bind + // here; a near-empty corpus (total=0 -> ceil(5*log10(10))=5) is still + // above 3, so the floor only matters as a guard, never a false alarm. + expect(compoundRegenStatus(3, 0, true).threshold).toBeGreaterThanOrEqual(3); + }); + it('treats a missing summary as warn when any samskaras exist, else ok', () => { + expect(compoundRegenStatus(5, 0, false).sev).toBe('warn'); + expect(compoundRegenStatus(0, 0, false).sev).toBe('ok'); + }); + it('clamps a negative delta to ok (count_at_regen above current count)', () => { + // A regen stamped a higher count than the live total (e.g. reaping + // dropped rows after the stamp) must not read as a backlog. + expect(compoundRegenStatus(140, 152, true).sev).toBe('ok'); + }); +}); + +describe('worstSeverity', () => { + it('alarm dominates warn dominates ok', () => { + expect(worstSeverity(['ok', 'warn', 'alarm'])).toBe('alarm'); + expect(worstSeverity(['ok', 'warn'])).toBe('warn'); + expect(worstSeverity(['ok', 'ok'])).toBe('ok'); + expect(worstSeverity([])).toBe('ok'); + }); +}); + +describe('healthHeadline', () => { + it('maps each severity tier to its headline phrase', () => { + expect(healthHeadline('ok')).toBe('Pipeline healthy'); + expect(healthHeadline('warn')).toBe('Needs a look'); + expect(healthHeadline('alarm')).toBe('Something is stuck'); + }); +}); + +describe('verdictBreakdown', () => { + it('emits the four verdicts in the panel stack order', () => { + const out = verdictBreakdown({ held: 5, contradicted: 2, notBorneOut: 3, notEngaged: 7 }); + expect(out.map((v) => v.label)).toEqual([ + 'held', + 'contradicted', + 'not-borne-out', + 'not-engaged', + ]); + expect(out.map((v) => v.count)).toEqual([5, 2, 3, 7]); + }); +}); + +describe('tier2CandidateLabel', () => { + it('reports none when nothing is offerable', () => { + expect(tier2CandidateLabel(0)).toBe('none available'); + // Defensive: the RPC never returns negatives, but the floor holds. + expect(tier2CandidateLabel(-1)).toBe('none available'); + }); + it('reports the member count, pluralizing', () => { + // The minter's floor is 3, so 1 is the defensive-singular path. + expect(tier2CandidateLabel(1)).toBe('available (1 member)'); + expect(tier2CandidateLabel(4)).toBe('available (4 members)'); + }); +}); + +describe('samskaraCountPhrase', () => { + it('pluralizes the coverage caption', () => { + expect(samskaraCountPhrase(0)).toBe('0 samskaras'); + expect(samskaraCountPhrase(1)).toBe('1 samskara'); + expect(samskaraCountPhrase(14)).toBe('14 samskaras'); + }); +}); diff --git a/tests/chat-prompt.test.ts b/tests/system-prompt.test.ts similarity index 99% rename from tests/chat-prompt.test.ts rename to tests/system-prompt.test.ts index 96a02124..9a4102fb 100644 --- a/tests/chat-prompt.test.ts +++ b/tests/system-prompt.test.ts @@ -30,7 +30,7 @@ import { import { buildSystemPrompt, buildToolboxStateBlock, -} from '../src/lib/chat-prompt'; +} from '../src/lib/chat/system-prompt'; describe('buildSystemPrompt', () => { it('primes the model to write an activity sentence per call', () => { @@ -256,7 +256,7 @@ describe('buildSystemPrompt', () => { // The wire-shape refactor retired all three: the user message now // rides bare (role:user is the boundary), datetime moved into a // prose paragraph in the per-turn metadata system message - // (chat-loop.ts), and the placeholder/topic-drift title nudges + // (chat/loop.ts), and the placeholder/topic-drift title nudges // moved into that same metadata system message. Keeping any of // the old framing in the baseline would teach the model to look // for tags it will never see. diff --git a/tests/user-settings.test.ts b/tests/user-settings.test.ts index b626850c..e2f07610 100644 --- a/tests/user-settings.test.ts +++ b/tests/user-settings.test.ts @@ -197,7 +197,7 @@ describe('coerceSettings', () => { it('drops empty / non-string profile fields so absent === blank', () => { // Empty string is the "not set" sentinel. The coercer drops it - // so the appendix builder in chat-loop.ts never has to + // so the appendix builder in chat/loop.ts never has to // distinguish "user typed nothing" from "field never set." expect(coerceSettings({ userName: '' })).toEqual({}); expect(coerceSettings({ userLocation: '' })).toEqual({}); diff --git a/tests/wire.test.ts b/tests/wire.test.ts index feeaae9d..92b360a2 100644 --- a/tests/wire.test.ts +++ b/tests/wire.test.ts @@ -2,7 +2,7 @@ * Tests for the shared tool-call wire-projection helpers in * `src/lib/tools/wire.ts`. * - * The sanitiser used to live inline in chat-loop.ts; it was hoisted to + * The sanitiser used to live inline in chat/loop.ts; it was hoisted to * a leaf module so every wire-projection site (chat-loop's * toVeniceMessage and in-loop history push, the headless tool loop in * tools/run.ts, and every agent's messageToVenice helper) can call in @@ -230,7 +230,7 @@ describe('parseToolArguments', () => { }); it('throws on invalid JSON so the caller can route to a tool error', () => { - // chat-loop.ts and tools/run.ts both wrap the call in try/catch + // chat/loop.ts and tools/run.ts both wrap the call in try/catch // and return a tool-error result row; preserving the throw means // those call sites keep working without change. expect(() => parseToolArguments('{not valid json')).toThrow();