Skip to content

Starlight - #7

Draft
divaltor wants to merge 36 commits into
mainfrom
starlight
Draft

Starlight#7
divaltor wants to merge 36 commits into
mainfrom
starlight

Conversation

@divaltor

Copy link
Copy Markdown
Owner

No description provided.

Bot memory grew steadily because the grammy runner keeps every
in-flight update context alive until its handler promise settles
(sink timeout defaults to infinity), and several outbound calls
could hang forever: generateText with no deadline, embeddings
requests with no Effect.timeout, yt-dlp with no spawn timeout, and
ky response body reads that ky's headers-only timeout never bounds.
Each pinned context also held a full base64 copy of the message
attachments, so every hang added permanent megabytes.

- Add a 120s total timeout to Llm.invoke; log invocation errors
- Add Effect.timeout to embeddings requests (30s image, 10s query)
- Kill yt-dlp after 180s; abort direct video downloads so the
  temp-dir cleanup always runs
- Pass AbortSignal.timeout to ky calls that stream response bodies
- Load attachment bytes from S3 only on the AI reply path instead
  of pinning base64 on every group message context
- Clamp the client-controlled inline-query offset to 1000
- Add a 10-minute runner sink timeout that evicts stuck updates
  and logs their id and type, covering hang sites without per-call
  deadlines (Telegram API calls, S3 reads)
Replace the legacy bot and model pipeline with an Effect-based model boundary. This centralizes provider selection, telemetry, usage accounting, tool execution, and prompt caching while removing unused application layers.
Rebuild group-chat handling on idempotent Postgres admission, fenced lane
runs, a BullMQ wake queue reconciled by a stranded-wake outbox, and an
append-only hash-chained conversation context (prompt-cache phases 2-3).
Adds the schema migration, CONVERSATION_* env settings, and BullMQ /
@prisma/adapter-pg dependencies.

Follow-up review pass over the same change set:
- fix Cyrillic wake-word regex: ASCII \b can never match Russian text
- bound admission retry to 5 exponential attempts instead of forever
- collapse repeated error mapping, lane keys, hashes, and prompt renderers
  into shared helpers; drop unused observedPendingRevision job field
- remove speculative verifyTurn chain re-hash per prepare
…d contexts

Runs pin to an immutable context generation and reprepare against it.
Soft token-cap crossings summarize older complete runs into frozen memory
after delivery, hard-cap violations checkpoint before model invocation or
block the run, and profile transitions seed a child generation from the
frozen envelope. Each attempt persists through prepared -> summarizing ->
summarized -> committed with lane locking and fencing at every step;
failed hardSafety attempts remain resumable across crashes.
…rrors non-retryable

- blockRun now clears the lease, advances processedRevision, and schedules
  successors in the same transaction; previously a blocked run stayed
  activeRunId forever and every later message re-claimed it and failed
- a frozen request hash mismatch is now a permanent ContextError so the
  run blocks instead of redriving forever (attemptCount only advances
  during model invocation); failed() unwraps TransactionError so
  ContextErrors thrown inside Prisma callbacks keep their classification
- checkpoint summarization preserves Model.Error retryability and drain
  blocks runs whose resumable checkpoint fails permanently
- simplify: shared lane lock/fence module, discriminated union for
  prepareCheckpoint results, shared reply-target renderer, test teardown
  helpers
Worst-case drains (checkpoint summarization + generation ≈ 240s) outlived
the 180s lane lease, letting a second worker reclaim the run and re-invoke
the model on it — duplicate provider spend; fencing bounded the damage to
database writes only. Lease renewal now rides the existing transactions
that open each long stage (model invocation, dispatch, checkpoint
summarization), so staleness stays under one lease period without
heartbeat timers and crash-recovery latency is unchanged.

Also add ARCHITECTURE.md covering engine invariants, and record the
tool-budget round semantics as intentional so it stops surfacing as a bug.
Replace the custom Exa API adapter and web_lookup wrapper with Exa's hosted MCP tools. This lets the current model use native search and fetch tools while preserving the existing one-round budget and optional service profile.
ConversationKey owns its wire format and BigInt boundary (format/toDb/fromDb);
usage.ts reads Usage.normalizeStep / Usage.aggregate / Usage.upstreamProvider.
Run input payload and frozen-request schemas live once in conversation/run-artifacts;
the context side decodes a derived projection view tolerant of older rows.
verifyPrefix and stableSeed now sit beside extendPrefix in prompt.ts.
Declare LiveMessagePayload.repliedText that describeReplyTarget already read.
Each package exposes typecheck alongside lint; root turbo typecheck fans out in dependency order. Starlight lint switches from oxlint to tsc --noEmit, and AGENTS.md prescribes lint followed by typecheck.
Type stored Prisma Json columns as unknown at read boundaries and cast once on write; bind provider.model outside the tryPromise closure so narrowing holds; pass options to dispatchRun to match its signature.
Telegram accepts a fixed reaction emoji set; define it once beside the delivery call that enforces it, type ReactionAction.emoji with it, and reuse it in the reply output schema.
Pass the discovered MCP tools through unwrapped instead of overriding descriptions, drop the system-prompt web-tool section, and map Exa ConfigError to a typed ExaError instead of an unhandled defect.
Config.option still failed startup with ConfigError on a malformed env; catch it to None so generate surfaces the Unavailable error through the normal reply path instead.
The Minimus registry shuts down soon, so nothing can keep pulling
from reg.mini.dev.

- valkey swaps to the official valkey/valkey:9.1.1-alpine image
- classification moves to python:3.14-slim-trixie instead of
  alpine: PyTorch ships glibc-only Linux wheels (manylinux_2_28,
  linux_x86_64), so musl cannot load them; slim keeps the same
  glibc wheel resolution as uv.lock
- non-root contract preserved: explicit uid/gid 1000 user keeps
  the deployment group_add render-group mapping and HF cache
  volume ownership working
- libgomp1/libatomic1/libnuma1 now installed via apt in both
  stages, replacing the minimus-specific .so copy hack

The three bun apps were already on oven/bun alpine images.
The ConversationContext service file carried pure domain logic
(checkpoint boundary selection, run-to-transcript projection,
summary contract) as loose functions below the service layer.
That mixed domain rules with Effect and Prisma plumbing and made
the logic reachable only through a database-backed service.

Move it into two namespace modules following the existing
Lane/Prompt/ConversationKey convention:

- Checkpoint (@/context/checkpoint): head/tail boundary
  resolution over sealed turns, summary instructions, Summary
  schema
- Transcript (@/context/transcript): projection of a finalized
  run into transcript turn candidates, kind-to-role mapping,
  known-message-id collection

Transaction orchestration (prepare/commit/summarize checkpoint,
ensureActiveContext) stays in the service where its
Prisma.TransactionClient and service dependencies live.
Behavior is unchanged; names dropped their noun prefix to avoid
stutter under the namespace.
The extracted namespace modules re-declared database row shapes by
hand: Transcript.ProjectionRun duplicated the run include used by
appendFinalized, CheckpointTailTurn repeated context-turn columns,
and both boundary functions carried an inline structural constraint.
Copies like these drift silently when the schema changes, which
contradicts the house rule of extracting from generated types with
Pick instead of restating them.

Derive every row-facing type from the Prisma client, mirroring how
OpenCode V2 derives from Drizzle's $inferSelect:

- Transcript.Run = ConversationRunGetPayload over the exact
  include appendFinalized queries
- Checkpoint.SealedTurn = ConversationContextTurnGetPayload with
  its transcript source
- Checkpoint.TailTurn and Checkpoint.Attempt as Picks of those
  models, replacing CheckpointTailTurn and the inline ordinals

projectRun now compiles against real columns, so comparisons such
as run.status === "failed" are checked against the generated enum.
No runtime behavior changes.
Modules previously exported loose top-level symbols and every consumer
invented its own module alias through \`import * as X\`, so the same
module could appear under different names across call sites and nothing
tied a file to the name it was consumed under.

Following the OpenCode V2 convention, each shared module now declares
one canonical \`export namespace <Name>\` block at the source and
consumers import it by name (\`import { Transcript } from
"@/context/transcript"\`). Member access is unchanged, so only import
lines and module wrappers moved.

- Enforce with oxlint import/no-namespace; apps/web keeps
  \`import * as React\` per shadcn/ui convention
- ChatReply keeps its eager prompt read as a module-scope const because
  top-level await cannot live inside a namespace body
- Disable eslint/no-inner-declarations: namespace members are
  module-level declarations, not nested blocks
- Document the rule in AGENTS.md and the effect skill
Finalization used to re-read every stored transcript content blob and
type-cast our own JSON writes back to unknown just to rebuild the set
of already-projected Telegram message ids. The scan was the only source
for those ids because linkedReplyContext targets often have no input
row (replies to photos or pre-bot messages), and its cost grew with the
full lane history.

projectRun now records the id it projects into a nullable
source_message_id column, written once from trusted payload and action
values; both readers select the column instead of scanning JSON, and
collectMessageIds is gone. Content bytes are untouched, so rolling
prefix hashes and byte-identical replay are unaffected. The migration
backfills existing rows from content JSON and the actions join.

Also repair the frozen-request regression fixture: b7cbf92 widened
PreparedRequestSchema but left {currentDate} in place, so decode failed
before the hash comparison ran; DB-gated tests skip without
DATABASE_URL, which hid the red.
The prepared request froze six fields, but only currentDate and
sessionId feed a retry: messages were never read back, and the JSON
fingerprint/eligibility duplicated columns written in the same
transaction. Every run row carried up to 20 rendered messages that no
code path consumed. Freeze only what time erodes; rebuild rendering
deterministically from the immutable batch inputs.

Reply-target membership came from the active context generation's
turns, so a hard checkpoint could summarize a target out of the
retained tail and silently change D mid-run. Derive it from the
append-only lane transcript instead, which stays stable across
checkpoints and retries.

Eligibility now has one owner: the runs column written at claim time,
matching the rule that persisted batch decisions must not be
recomputed on retry.

Lane keys converted number->BigInt at nearly every helper call;
ClaimedRun now carries the DB-shaped key converted once at claim, and
ConversationKey.format accepts both id shapes.
Persist attributed user, chat, and topic memories as immutable revisions so each conversation run uses a stable context snapshot. Enforce privacy projection between group and direct-message scopes, admit only whitelisted DMs, and let users remove their retained memory with /forget.
Forget reset only lanes where the user had posted, so sibling
threads whose context embedded shared chat memory kept serving
forgotten facts after confirmation. Its busy check also read
activeRunId before acquiring the lane locks, letting a claim slip
past it; the drain-side discard gates could not fully close that
window on their own.

- Derive the reset lane set from affected namespace scopes and
  re-read activeRunId under each held lock
- Remove the now-unreachable discard gates from the drain flow
- Rebase stale build attempts onto the current parent revision
  instead of superseding them behind their unique watermark
- Keep frozen revisions across a forget reset; rendering already
  skips namespaces with unprocessed forget observations
- Single-source FrozenMemoryRevision in run artifacts and reuse
  the generated MemoryVisibility enum in zod schemas

Regression-tested against a throwaway Postgres: both new tests
fail on the prior behavior and pass here.
JPEG encoding turned transparent pixels outside the rounded card black. Render the card over its theme background so shared light cards keep clean corners.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant