- Invert the transport layers so the pre-response retry is OUTERMOST and the
provider-HTTP instrumentation is INNER. Before, the retry lived inside
createStreamingFetch (under the instrumentation), so a reset the retry
recovered from logged only a clean "OK status=200" — the
"PRE-RESPONSE FAILED ... ECONNRESET ... idleSincePrevCall" signal went blind
exactly when the fix works, and AI_STREAM_KEEPALIVE_MS couldn't be tuned from
prod data. Now createStreamingFetch is the dispatcher-bound BASE (no retry) and
a new withPreResponseRetry() wraps it; ai.service composes
withPreResponseRetry(createInstrumentedFetch('AiService:provider-http',
createStreamingFetch())), so every attempt — including recovered resets — flows
through the instrumentation. (Also expresses the keepAlive-config vs retry-
behavior boundary structurally, per review #3.)
- Add the retry-exhaustion test: a server that resets EVERY connection, asserting
the call rejects with a retryable connection error AND exactly
PRE_RESPONSE_CONNECT_RETRIES + 1 (= 3) requests reached the server — pinning the
bound and that the final error propagates (guards an off-by-one / infinite loop
/ swallowed error). Existing happy-retry + abort tests moved onto
withPreResponseRetry.
Verified on the stand: a normal turn still streams (reasoning + finish) and the
provider-HTTP telemetry still logs. server tsc + ai/mcp specs green (30).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuilt on develop (after #176) and reworked per review: instead of inferring the
provider from baseUrl (`if (baseUrl)`), the admin picks the chat provider
EXPLICITLY via a new `chatApiStyle` ('openai-compatible' | 'openai'), mirroring
the existing sttApiStyle. A custom baseURL can front real OpenAI too, so the
heuristic was fragile.
Why reasoning was missing: glm-5.2 (and DeepSeek etc.) stream their thinking as
`reasoning_content`, but the official @ai-sdk/openai provider does not map that
field. 'openai-compatible' uses @ai-sdk/openai-compatible, which does — so
reasoning parts now stream (verified live: reasoning-start/delta/end appear, and
disappear when set to 'openai').
- Default (unset) = 'openai-compatible', so existing openai+baseUrl workspaces
surface reasoning with no admin action. No DB migration (field lives in the
settings.ai.provider JSON blob).
- includeUsage: true on the openai-compatible model — without it the provider
omits streamed usage, zeroing the live token counter / reasoning-token
metadata. The official provider always sent it; this keeps parity. (Confirmed
live: usage.totalTokens present.)
- openai-compatible has no default endpoint, so with no baseURL (real OpenAI, or
a role's cross-driver override that cleared it) it falls back to the official
provider.
Plumbing: ai.types (ChatApiStyle / CHAT_API_STYLES + AiProviderSettings /
MaskedAiSettings), update DTO (@IsIn), ai-settings.service (resolve / getMasked /
update allowlist), workspace.repo updateAiProviderSettings ALLOWED (the second,
SQL-level allowlist the review missed — without it the field never persisted),
ai.service selector. Client: ai-settings-service types + a Protocol <Select> in
the chat section + i18n (en/ru). Scope is chat-only (embeddings don't stream
reasoning; STT already has sttApiStyle).
Tests: ai.service.spec — 4 cases (openai-compatible+baseURL, openai+baseURL,
default-unset, openai-compatible-without-baseURL fallback). Verified on the stand:
default streams reasoning + usage; 'openai' drops reasoning; the setting
round-trips. server + client tsc clean; 36 ai/settings specs green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Wording: every comment now says the stream timeouts are RAISED to a
generous-but-finite ~15-min silence timeout, not "disabled (0)" (the stale
comments contradicted the code, which uses AI_STREAM_TIMEOUT_MS, default
900000ms).
- Architecture (the load-bearing-temporary trap): the streaming fetch reached
the chat provider only by riding the "temporary DIAGNOSTIC" telemetry, so
deleting the telemetry by its own label would silently revert the timeout fix.
Legitimize it: rename ai-http-diagnostics.ts -> ai-provider-http.ts,
createDiagnosticFetch -> createInstrumentedFetch, field aiDiagnosticFetch ->
aiProviderFetch, drop the "temporary" labels, and document the chat transport
(streaming fetch + instrumentation) as one intentional construct.
- Docs: AI_STREAM_TIMEOUT_MS added to .env.example next to AI_EMBEDDING_TIMEOUT_MS.
- Tests:
- ai-provider-http.spec: createInstrumentedFetch delegates to the injected
baseFetch with the same input/init, returns the Response untouched, rethrows
the error, and defaults to global fetch — covering the baseFetch seam.
- ai-streaming-fetch.spec: the delayed-server test is now LOAD-BEARING — with
AI_STREAM_TIMEOUT_MS set below the 1.5s server delay the call actually rejects
(a lost dispatcher -> global 300s default would NOT), proving the configured
dispatcher is wired; plus the default-timeout happy path.
server tsc clean; ai-streaming-fetch / ai-provider-http / ai.service / mcp-servers
/ ai-error specs green (41).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Long research turns failed mid-task with "Lost connection to the AI provider".
Node's global fetch (undici) defaults BOTH headersTimeout and bodyTimeout to
300_000ms, and the chat provider + the external-MCP dispatcher both ran on it
with no override, so:
- the z.ai chat stream dropped when a late step's huge accumulated context
pushed the model's time-to-first-token past 5 min (the model reasons
server-side with NO streamed reasoning, so the connection is silent until the
first answer token — reproduced: even a trivial glm-5.2 query has a ~4-8s
first-chunk gap; a long run reaches 400k+-token steps), or a reasoning model
paused >5 min between chunks (bodyTimeout);
- the crawl4ai SSE transport, held open across the whole turn, dropped when it
idled >5 min between tool calls.
Fix: a dedicated undici dispatcher whose stream timeouts are raised to a
generous-but-FINITE silence timeout (default 15 min, AI_STREAM_TIMEOUT_MS) on
each path. NOT disabled (0): that would let a genuinely hung provider — with the
client still connected — hang forever, since the turn's abortSignal only fires on
client disconnect. The timeout bounds SILENCE (time-to-first-byte and the gap
BETWEEN chunks), NOT total turn duration, so an arbitrarily long turn that keeps
streaming is never cut; only a stream quiet for >15 min is treated as a hang.
- ai-streaming-fetch.ts: createStreamingFetch() + streamTimeoutMs() /
streamingDispatcherOptions() (the shared, configurable timeout).
- ai.service: the chat provider fetch is createStreamingFetch(), wrapped by the
existing passive ECONNRESET telemetry (createDiagnosticFetch gained an
optional baseFetch) so the telemetry observes the SAME transport.
- mcp-clients: the SSRF-pinned Agent uses streamingDispatcherOptions().
Investigation: reproduced the transport mechanism against the real z.ai endpoint
(a 1ms headersTimeout throws UND_ERR_HEADERS_TIMEOUT — the exact drop) and ran
the actual research agent to a ~428k-token context. Verified the fixed path
streams cleanly live (glm-5.2 turns finish; telemetry confirms the streaming
fetch is in use).
Tests: ai-streaming-fetch.spec (default 15m + env override + invalid fallback +
both-timeouts + streams a delayed response); ai-http-diagnostics + ai/mcp specs
green. server tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigate the intermittent (~20-30%) long-turn failure
"Lost connection to the AI provider" = AI_RetryError / read ECONNRESET
on the gitmost->z.ai link (browser-agnostic, mid-turn). Pure
instrumentation, no behavior change:
- ai-http-diagnostics.ts: a passive fetch wrapper injected into the
OpenAI-compatible (z.ai) client. Per provider HTTP call it logs
time-to-headers/status on success, and on a pre-response rejection the
latency, error code/cause, request-body size and idle-gap since the
previous call. The Response is returned untouched (streaming intact),
errors rethrown unchanged; no retry/timeout/dispatcher.
- ai.service.ts: wire the instrumented fetch into the openai case only.
Lets us classify the reset as connection-phase vs mid-stream before
choosing a fix, without repeating the reverted RetryAgent (#140).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The custom undici RetryAgent + aiFetch transport added for issue #140
did not actually heal mid-stream provider drops: undici's retry path is
a Range-based download-resume that SSE/chat-completions endpoints cannot
satisfy, so a reset after the first byte only swapped ECONNRESET for a
"server does not support the range header" error. Its only real effect
was reconnecting a poisoned keep-alive socket before the first byte, and
PR #141 on top of it turned the 60s headers timeout into deterministic
~61s failures (plus CONTENT_LENGTH_MISMATCH from retrying a POST body
after a timeout abort). The root cause is the z.ai coding endpoint, not
our transport.
Remove the whole layer and return all AI provider calls to Node's
default global fetch.
- delete integrations/ai/ai-http.ts and its spec
- ai.service.ts: drop the aiFetch import, the AI_BYPASS_RESILIENT_FETCH
diagnostic toggle, and fetch:aiFetch from every chat/embedding/STT
factory; raw STT call back to global fetch
- ai-chat.controller.ts: drop the stream-timing START log + startedAt
- ai-chat.service.ts: drop the first-chunk/FINISHED/ERROR timing logs
- .env.example: drop AI_BYPASS_RESILIENT_FETCH
Reverts: 1af5d34a, 7c308728, b7abb7ea, 35fc58ea, d6cd2754, 6efb8656.
Preserved (not part of the rollback): client-disconnect abort, title
generation in onFinish, partial-answer persistence, Safari SSE heartbeat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The streaming chat turn hangs in all browsers while the non-streaming test
endpoint works — both use the same model/transport (createOpenAI + aiFetch),
so the suspect is the streaming path / custom undici RetryAgent transport.
- ai-http.ts: wrap aiFetch with per-request timing logs (start, ms-to-headers
on success, elapsed ms + cause on failure). Chat at info, embeddings at
debug. Only host+path logged.
- ai-chat.controller.ts / ai-chat.service.ts: log turn START, first-chunk
latency, FINISHED duration, and elapsed ms on disconnect/error/abort.
- ai.service.ts: AI_BYPASS_RESILIENT_FETCH=true makes the CHAT model omit
fetch:aiFetch and use the default global fetch — isolates transport vs
request-shape. Chat-only; embeddings/STT untouched; reversible via env.
- .env.example: document the flag.
No timeout/retry change. tsc clean; ai-chat + ai suites pass (292).
Outbound LLM calls used Node's default global undici agent (default
keep-alive pooling, no transport-level reconnect), so a TCP RST on a
reused/poisoned keep-alive socket surfaced as
"Cannot connect to API: read ECONNRESET" and failed the chat stream and
title generation after the AI SDK's own retries were exhausted.
Add a dedicated resilient outbound HTTP layer (ai-http.ts): a shared
undici RetryAgent over a tuned Agent, exposed as `aiFetch` and injected
into every AI provider factory (createOpenAI chat/embeddings/STT,
createGoogleGenerativeAI, createOllama) plus the raw JSON STT fetch. The
RetryAgent reconnects on connection-level errors (ECONNRESET, ...) on a
FRESH socket, opts POST into the retry methods (undici's default list
excludes POST), and leaves HTTP-status retries (429/5xx + Retry-After) to
the AI SDK to avoid double-retry.
- ai-http.ts: shared RetryAgent(Agent) + aiFetch (maxRetries 2,
conservative keep-alive, connect timeout, streaming-safe timeouts)
- ai.service.ts: inject fetch: aiFetch into every provider factory
- ai-http.spec.ts: regression test that aiFetch injects the RetryAgent
dispatcher into the underlying fetch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a per-workspace `sttLanguage` setting (ISO-639-1 hint; empty =
auto-detect) and a searchable language picker in the Voice / STT settings
card. The hint is forwarded to the transcription endpoint:
- multipart path via the AI SDK `providerOptions.openai.language`
- JSON (OpenRouter) path via a top-level `language` body field
only when non-empty, so auto-detect behaves exactly as before.
Threaded through the whole stack: ai.types, update DTO, AiSettingsService
(resolve/getMasked/update), the workspace.repo SQL allowlist, the client
ai-settings service types, and the provider-settings form. Adds en-US
source keys and ru-RU translations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts with the independently-merged ai-agent-roles feature:
- ai-chat.module.ts: keep BOTH AiAgentRolesModule and the public-share
wiring (Share/Search modules, PublicShareChatController, services).
- ai.service.ts: take develop's getChatModel ChatModelOverride superset,
which already covers the public-share model-id-only override.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up fixes on the agent-roles feature:
- ai.service: a cross-driver override to the ollama driver (when the
workspace driver is not ollama) now fails with an explicit 503 instead
of silently reusing the workspace base URL, which belongs to a different
provider. Same-driver ollama and openai/gemini overrides are unchanged.
- migration: add a partial unique index on (workspace_id, name) WHERE
deleted_at IS NULL so role names are unique per workspace without
soft-deleted rows blocking re-creation; map Postgres 23505 to a 409
ConflictException on create/update.
- dto: validate the role id as @IsUUID instead of @IsString.
- roles list: do not expose instructions/modelConfig to non-admin members.
The list endpoint now returns a picker view (id/name/emoji/description/
enabled) to members and the full view only to admins (same gate as the
CRUD endpoints). Client IAiRole fields made optional accordingly.
Adds tests for the cross-driver-ollama throw, the 23505->409 mapping, and
the non-admin picker-view security invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lets an unauthenticated viewer of a published share ask an AI scoped strictly
to that share's page tree. The authenticated agent is untouched; the security
boundary is the tool scope (no identity), and nothing is persisted.
Server:
- workspace toggle settings.ai.publicShareAssistant (default off) +
optional settings.ai.provider.publicShareChatModel (cheap model id; reuses
the chat driver/baseUrl/key). getChatModel(workspaceId, override) substitutes
only the model id, falling back to chatModel.
- POST /api/shares/ai/stream (@Public, SSE). Guardrail funnel, each failing
before streaming: toggle off -> 404; share missing/wrong-workspace/sharing
off -> 404; pageId not in share tree -> 404; provider unconfigured -> 503;
per-IP (5/min) and per-workspace (300/h, IP-independent) rate limits -> 429.
Uniform 404s never confirm a private page's existence.
- forShare read-only in-process toolset: searchSharePages (existing shareId
FTS branch, no spaceId/userId), getSharePage (getShareForPage gate +
share.id check, content via the public sanitizer), listSharePages. No write/
comment/history/cross-space/external-MCP tools.
- Locked share system prompt + immutable safety block; stepCountIs(5).
- /shares/page-info exposes an aiAssistant flag (gated behind isSharingAllowed).
Client: an ephemeral, text-only Ask-AI widget on the public shared page,
shown only when the flag is set; useChat -> /api/shares/ai/stream,
credentials omit. Admin toggle + model field in Settings -> AI.
Also adds a jest moduleNameMapper for src/-rooted imports (fixes pre-existing
unresolvable specs; additive).
Implements docs/public-share-assistant-plan.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reusable, workspace-shared agent roles for the built-in AI chat. A role is
a named persona (system-prompt instructions) + optional model override; a
chat is bound to a role at creation and applies it every turn.
Backend:
- migration 20260620T120000: ai_agent_roles table + ai_chats.role_id
(FK ON DELETE SET NULL); hand-merged types into db.d.ts/entity.types.ts
(db.d.ts is hand-curated here, full codegen would clobber it).
- core/ai-chat/roles: CRUD module. list = any workspace member; create/
update/delete = admin (Manage Settings ability, like ai-settings/mcp).
All repo queries scoped by workspace_id; soft-delete (deleted_at).
- buildSystemPrompt gains roleInstructions: role REPLACES the persona base
(admin prompt / DEFAULT_PROMPT) but SAFETY_FRAMEWORK + context are always
still appended.
- stream(): role resolved from ai_chats.role_id for existing chats (never
the request body -> no per-turn role swap); body.roleId only on creation.
Disabled (enabled=false) and soft-deleted roles fall back to universal.
- getChatModel(workspaceId, override): role model_config can swap model id /
driver; a driver without configured creds throws 503 with a clear message
naming the driver+role, resolved BEFORE response hijack.
Client:
- new-chat role picker (enabled roles only, default Universal assistant),
roleId sent only on the first message; role badge (emoji+name) in the chat
header and conversation list; admin Agent-roles management section in
Settings -> AI (add/edit/delete, MCP-form pattern).
Tests: ai-chat.prompt.spec (role layering + safety always present, incl.
jailbreak); ai.service.spec (override on unconfigured driver -> 503).
Implements docs/ai-agent-roles-plan.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the implicit `hostname endsWith openrouter.ai` detection with an
explicit, admin-chosen provider field `sttApiStyle` ('multipart' = OpenAI-
compatible multipart /audio/transcriptions; 'json' = OpenRouter-style JSON +
base64 input_audio). The transcription path now branches on the stored field,
not on the URL — nothing hidden from the admin.
- ai.types: add SttApiStyle + STT_API_STYLES; field on AiProviderSettings and
MaskedAiSettings (resolved via ResolvedAiConfig).
- update-ai-settings.dto: validate sttApiStyle with @IsIn(STT_API_STYLES).
- ai-settings.service: plumb sttApiStyle through resolve()/getMasked() and the
non-secret update whitelist; workspace.repo: add it to the ALLOWED array so it
persists.
- ai.service: drop isOpenRouter(); transcribe() branches on cfg.sttApiStyle;
rename helper to transcribeJsonBase64 with provider-neutral error text and a
BadRequestException (400) when the base URL is missing for the JSON style.
- client: SttApiStyle type on IAiSettings/IAiSettingsUpdate; "Request format"
Select on the Voice/STT settings card; i18n.
- ai.service: route *.openrouter.ai STT to its JSON+base64
/audio/transcriptions API; keep the OpenAI multipart path (AI SDK) for
OpenAI/self-hosted whisper. Unify transcription behind transcribe().
- /transcribe controller: surface the real provider/transport reason
(describeProviderError) instead of an opaque 500; preserve HttpException.
- testConnection: add an 'stt' capability (silent-WAV probe) + DTO; client
gets a Test endpoint button and status dot on the Voice/STT card.
- useDictation: log full errors to the console and show the real reason
(mic start + transcription paths); handle NotReadable/Abort and missing
mediaDevices.
- docs(CLAUDE.md): require full error logging + specific user-facing messages.
Add push-to-talk voice dictation that transcribes recorded audio on the
server via the workspace's OpenAI-compatible AI provider (Whisper /
gpt-4o-transcribe / self-hosted whisper), then inserts the text.
Backend:
- New `stt_api_key_enc` column + migration; STT creds parity with chat/
embeddings (sttModel/sttBaseUrl/sttApiKey, write-only key, fallbacks to
chat baseUrl/key). Both provider whitelists updated (service + repo).
- AiService.getTranscriptionModel + AiTranscriptionService.
- Gated POST /ai-chat/transcribe (dictation flag → 403, JWT + workspace
scope + throttle, 25MB cap, MIME whitelist, never logs audio/key).
- New `settings.ai.dictation` workspace flag (DTO + service + audit).
Frontend:
- Wire up the Voice/STT settings card (model/base URL/key) and the
Voice-dictation toggle.
- New `features/dictation`: useDictation (MediaRecorder state machine),
MicButton, transcribe service; integrated into the chat composer and a
new editor-toolbar dictation group, both gated by ai.dictation.
Rebuild the workspace AI settings page into card-based "Endpoints"
(Chat / Embeddings / Voice) matching the new design, and split the
single connection test into independent per-endpoint Test buttons.
- server: testConnection(workspaceId, capability) probes only the
requested capability ('chat' | 'embeddings'); add TestAiConnectionDto
and wire it through the /workspace/ai-settings/test controller
- client: testAiConnection(capability) + capability-typed mutation; two
independent test mutation instances so Chat/Embeddings results are isolated
- client: full rewrite of ai-provider-settings into Endpoints section —
drop the provider dropdown (driver is always openai, base URL + key
always shown), move the "AI chat" and surface the "Semantic search"
feature toggles into card headers, system message behind an Edit modal,
pgvector/reindex footer, and a disabled Voice/STT stub
- client: restyle external MCP tools and the MCP server section; collapse
the AI sections in workspace-settings; remove the standalone
ai-chat-settings component
- toggles now surface the server error message (e.g. missing pgvector)
- i18n: add new English strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bulk embedding reindex could hang on a single page forever
("Indexed 27 of 34 pages") with zero log output:
- all progress logs were debug-level, suppressed in production (pino info);
- embedMany() had no timeout, so a slow/hung embeddings endpoint blocked
the sequential per-page loop indefinitely.
Changes:
- ai.service.embedTexts: bound embedMany with AbortSignal.timeout
(configurable via AI_EMBEDDING_TIMEOUT_MS, default 120000ms); on timeout
throw a clear, greppable message, classified by both signal.aborted and
the error name (TimeoutError/AbortError/ResponseAborted) so a real
provider error racing the timer keeps its diagnostics.
- embedding-indexer.reindexWorkspace: promote lifecycle/progress logs to
info; log "[i/N] indexing page <id>" BEFORE the await so a hang names the
stuck page; warn on slow pages (>30s); add timing + final summary.
- .env.example: document AI_EMBEDDING_TIMEOUT_MS.
A misconfigured embeddings endpoint failed the RAG indexer with an opaque
"Invalid JSON response" and was not caught by "Test connection" (which only
probed the chat model), so it only surfaced silently during background
indexing.
- add describeProviderError(): formats AI SDK errors as
"<statusCode>: <message> | response body: <truncated one-line snippet>"
(statusCode/message/responseBody never carry the API key)
- use it in the bulk-reindex catch and the embedding processor's formatter so
the real cause (e.g. an HTML 404 from a wrong base URL) is visible in logs
- testConnection now probes chat AND embeddings independently: skips a probe
when that capability is unconfigured, returns ok:false with a Chat:/Embeddings:
prefix on real failure, "not configured" when neither is set
Per-workspace AI provider config previously shared a single base URL and
a single API key between the chat model and the embedding model. Add
dedicated, optional embedding endpoint/token that fall back to the chat
values when empty, preserving backward compatibility.
- db: new migration adds nullable `embedding_api_key_enc` to
`ai_provider_credentials`; chat key stays in `api_key_enc`
- repo: add `upsertEmbeddingKey` / `clearEmbeddingKey` (on-conflict
touches only its own column, so chat/embedding keys never overwrite)
- ai-settings.service: store non-secret `embeddingBaseUrl`; resolve()
applies fallback (embeddingBaseUrl || baseUrl; embedding key || chat
key); getMasked() exposes raw `embeddingBaseUrl` + `hasEmbeddingApiKey`,
never the key; update() handles the embedding key write-only
- ai.service: getEmbeddingModel() builds openai/gemini/ollama with the
embedding-specific URL/key; chat path unchanged
- client: new "Embedding base URL" and "Embedding API key" fields with
fallback hints and a clear-key action
Requires running the DB migration on deploy.
- openai provider: use .chat() (Chat Completions) instead of the default callable
(Responses API), which gateways reject on multi-turn -> 400.
- updateAiProviderSettings: assemble settings.ai.provider via jsonb_build_object
with ::text-cast bound params + jsonb_typeof self-heal (postgres.js was
double-encoding it into an array; the ::text cast avoids 'could not determine
data type of parameter').
- chat agent: drop the hard maxOutputTokens cap (truncated complex tool calls);
keep a tiny cap only on the test-connection ping.
- testConnection + chat stream: surface the real provider error (statusCode+message)
to logs and the UI instead of generic masks; never log the API key.
- chat UI: typing indicator, incremental streaming render, tool 'running' status, Stop.
Also bundled (prior uncommitted ai-chat work):
- history 'AI agent' provenance badge; vector RAG (pgvector image + page_embeddings
+ AI_QUEUE indexer + space-scoped semanticSearch); external MCP servers backend
(@ai-sdk/mcp client, SSRF IP-pinning, encrypted headers, admin CRUD/Test);
yjs duplicate-instance fix via pnpm patch (single CJS instance server-side).
WIP checkpoint of the gitmost AI-chat backend (plan stages A + B1 + B3a).
The agent acts under the requesting user's JWT (Docmost CASL enforces page
access); the external service-account /mcp endpoint is untouched.
LLM provider config (A2-A4):
- integrations/crypto: AES-256-GCM SecretBoxService (key derived from APP_SECRET,
per-record salt/iv; clear error on rotation instead of crashing).
- ai_provider_credentials table/repo/types: encrypted API key stored outside
workspace settings/baseFields, write-only (never returned by any endpoint).
- integrations/ai: per-workspace AI SDK v6 provider driver (openai/gemini/ollama),
admin-gated GET(masked)/PATCH(write-only key)/Test endpoints; settings.ai.provider
holds non-secret config incl. systemPrompt. Removed unused AI_* env getters (DB is
the single source of truth).
Chat module (A1, A5-A8):
- ai_chats/ai_chat_messages repos (workspace-scoped, soft-delete, tsv never selected).
- core/ai-chat: CRUD + POST /ai-chat/stream (Fastify hijack + AI SDK v6
pipeUIMessageStreamToResponse, abort on disconnect, persist user/assistant msgs).
- Agent loop: streamText + stepCountIs(8); read tools searchPages/getPage via a
per-request DocmostClient over loopback REST under the user's minted access token.
- Gate settings.ai.chat (+ 503 when provider unconfigured); buildSystemPrompt with a
non-removable safety/anti-prompt-injection framework. Per-user rate limit.
Per-user auth (B1):
- @docmost/mcp DocmostClient gains an additive getToken variant (carry a user JWT,
re-fetch on 401) and exports DocmostClient; the email/password service-account path
(external /mcp, stdio) is unchanged.
Agent-edit provenance backbone (B3a):
- Migration: pages/page_history (last_updated_source, last_updated_ai_chat_id) and
comments (created_source, ai_chat_id, resolved_source).
- Signed actor/aiChatId claim in the collab token; onAuthenticate propagates it,
onStoreDocument writes it with a sticky agent marker, saveHistory copies it.
Migrations auto-run on boot (additive). Write tools, frontend, RAG and external MCP
servers are not in this checkpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>