feat(ai-chat): per-user AI agent backend — LLM config, read-only agent, provenance schema
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>
This commit is contained in:
@@ -103,8 +103,13 @@ export class AuthenticationExtension implements Extension {
|
||||
|
||||
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
|
||||
|
||||
// Carry the signed agent-edit provenance claim into the hocuspocus
|
||||
// connection context (§6.6 / §15 C2). The human collab path omits these
|
||||
// claims, so it resolves to actor='user' / aiChatId=null.
|
||||
return {
|
||||
user,
|
||||
actor: jwtPayload.actor ?? 'user',
|
||||
aiChatId: jwtPayload.aiChatId ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ import { TransclusionService } from '../../core/page/transclusion/transclusion.s
|
||||
export class PersistenceExtension implements Extension {
|
||||
private readonly logger = new Logger(PersistenceExtension.name);
|
||||
private contributors: Map<string, Set<string>> = new Map();
|
||||
// Sticky agent-edit marker (§15 H2): a coalesced snapshot may mix human and
|
||||
// agent edits. We accumulate "an agent touched this document during the
|
||||
// coalescing window" per document and OR it across all edits in the window,
|
||||
// so the snapshot is marked 'agent' regardless of who wrote last.
|
||||
private agentTouched: Map<string, boolean> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
@@ -113,6 +118,12 @@ export class PersistenceExtension implements Extension {
|
||||
|
||||
let page: Page = null;
|
||||
const editingUserIds = this.consumeContributors(documentName);
|
||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||
// if the current writer is the agent (covers a store with no prior onChange
|
||||
// agent event in the same window). §15 H2.
|
||||
const agentTouched =
|
||||
this.consumeAgentTouched(documentName) || context?.actor === 'agent';
|
||||
const lastUpdatedSource = agentTouched ? 'agent' : 'user';
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
@@ -152,6 +163,9 @@ export class PersistenceExtension implements Extension {
|
||||
textContent: textContent,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
// Human stays the responsible author; these annotate the source.
|
||||
lastUpdatedSource,
|
||||
lastUpdatedAiChatId: context?.aiChatId ?? null,
|
||||
contributorIds: contributorIds,
|
||||
},
|
||||
pageId,
|
||||
@@ -169,6 +183,8 @@ export class PersistenceExtension implements Extension {
|
||||
JSON.stringify({
|
||||
type: 'page.updated',
|
||||
updatedAt: new Date().toISOString(),
|
||||
// Provenance for a future live badge; 'user' for human edits.
|
||||
source: lastUpdatedSource,
|
||||
lastUpdatedById: context?.user?.id,
|
||||
lastUpdatedBy: context?.user
|
||||
? {
|
||||
@@ -228,11 +244,18 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
this.contributors.get(documentName).add(userId);
|
||||
|
||||
// Sticky agent marker: once an agent connection touches the document in the
|
||||
// coalescing window, keep it marked until the next snapshot consumes it.
|
||||
if (data.context?.actor === 'agent') {
|
||||
this.agentTouched.set(documentName, true);
|
||||
}
|
||||
}
|
||||
|
||||
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
|
||||
const documentName = data.documentName;
|
||||
this.contributors.delete(documentName);
|
||||
this.agentTouched.delete(documentName);
|
||||
}
|
||||
|
||||
private consumeContributors(documentName: string): string[] {
|
||||
@@ -243,6 +266,13 @@ export class PersistenceExtension implements Extension {
|
||||
return userIds;
|
||||
}
|
||||
|
||||
/** Read and clear the sticky agent-touched flag for this coalescing window. */
|
||||
private consumeAgentTouched(documentName: string): boolean {
|
||||
const touched = this.agentTouched.get(documentName) ?? false;
|
||||
this.agentTouched.delete(documentName);
|
||||
return touched;
|
||||
}
|
||||
|
||||
private async enqueuePageHistory(page: Page): Promise<void> {
|
||||
const pageAge = Date.now() - new Date(page.createdAt).getTime();
|
||||
const delay =
|
||||
|
||||
Reference in New Issue
Block a user