@vellumai/assistant 0.12.5 → 0.12.6-dev.202609292116.41f7caa
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +2 -0
- package/ARCHITECTURE.md +26 -3
- package/docs/automatic-done.md +32 -0
- package/docs/desktop-browser-cli.md +3 -2
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
- package/node_modules/@vellumai/environments/src/shell.test.ts +13 -1
- package/node_modules/@vellumai/environments/src/shell.ts +22 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/status-contracts.test.ts +39 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +25 -0
- package/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
- package/openapi.yaml +270 -10
- package/package.json +1 -1
- package/src/__tests__/agent-loop.test.ts +76 -0
- package/src/__tests__/anthropic-provider.test.ts +2 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
- package/src/__tests__/config-get-vision-flag.test.ts +1 -1
- package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +160 -154
- package/src/__tests__/conversation-evictor.test.ts +54 -0
- package/src/__tests__/conversation-pairing.test.ts +112 -0
- package/src/__tests__/document-append-idempotency.test.ts +13 -0
- package/src/__tests__/document-create-dedupe.test.ts +13 -0
- package/src/__tests__/document-find-replace.test.ts +13 -0
- package/src/__tests__/document-revision-history.test.ts +390 -0
- package/src/__tests__/document-revision.test.ts +425 -0
- package/src/__tests__/document-tool-security.test.ts +13 -0
- package/src/__tests__/document-update-default-surface.test.ts +13 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +270 -0
- package/src/__tests__/gateway-flag-listener.test.ts +59 -6
- package/src/__tests__/headless-browser-navigate.test.ts +20 -30
- package/src/__tests__/host-cu-proxy.test.ts +79 -0
- package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
- package/src/__tests__/host-shell-tool.test.ts +6 -2
- package/src/__tests__/identity-routes.test.ts +4 -3
- package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -4
- package/src/__tests__/llm-schema.test.ts +1 -1
- package/src/__tests__/notification-platform-adapter.test.ts +83 -1
- package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
- package/src/__tests__/openai-provider.test.ts +38 -11
- package/src/__tests__/pricing.test.ts +11 -0
- package/src/__tests__/public-ingress-urls.test.ts +26 -0
- package/src/__tests__/retry-thinking-tool-choice.test.ts +47 -0
- package/src/__tests__/skill-execute-input.test.ts +11 -0
- package/src/__tests__/tool-approval-handler.test.ts +4 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
- package/src/__tests__/usage-attribution.test.ts +31 -0
- package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
- package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
- package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
- package/src/acp/agent-process.ts +6 -1
- package/src/acp/client-handler.ts +6 -1
- package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
- package/src/agent/loop.ts +161 -17
- package/src/api/events/document-editor-show.ts +5 -0
- package/src/api/events/document-editor-update.ts +10 -0
- package/src/api/events/tool-result.ts +1 -0
- package/src/calls/__tests__/call-controller.test.ts +273 -1
- package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
- package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +54 -0
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-controller.ts +359 -25
- package/src/calls/channel-admission-reader.ts +15 -9
- package/src/calls/media-stream-server.ts +18 -0
- package/src/calls/media-stream-stt-session.ts +30 -9
- package/src/calls/twilio-provider.ts +14 -31
- package/src/calls/voice-session-bridge.ts +18 -1
- package/src/cli/commands/browser.ts +1 -2
- package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +2 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
- package/src/cli/lib/bundled-marketplace.json +54 -2
- package/src/cli/lib/inspect-plugin.ts +15 -15
- package/src/cli/lib/list-installed-plugins.ts +29 -28
- package/src/cli/lib/plugin-artifact.ts +74 -63
- package/src/cli/lib/plugin-catalog-visibility.ts +1 -0
- package/src/cli/lib/plugin-details.ts +27 -42
- package/src/cli/lib/plugin-marketplace.ts +6 -8
- package/src/cli/lib/plugin-surfaces.ts +8 -7
- package/src/cli.ts +1 -3
- package/src/config/__tests__/chat-settings.test.ts +121 -0
- package/src/config/assistant-feature-flags.ts +28 -23
- package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
- package/src/config/chat-settings.ts +100 -0
- package/src/config/default-profile-catalog.ts +1 -1
- package/src/config/feature-flag-cache.ts +7 -25
- package/src/config/feature-flag-registry.json +9 -9
- package/src/config/loader.ts +5 -1
- package/src/config/profile-text-generation.ts +25 -21
- package/src/config/schemas/conversations.ts +14 -0
- package/src/config/schemas/llm.ts +1 -0
- package/src/config/schemas/notifications.ts +8 -1
- package/src/contacts/gateway-channel-read.ts +1 -1
- package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
- package/src/conversations/__tests__/auto-archive.test.ts +304 -0
- package/src/conversations/auto-archive-activity.ts +61 -0
- package/src/conversations/auto-archive.ts +201 -0
- package/src/daemon/computer-use-observation.test.ts +81 -0
- package/src/daemon/computer-use-observation.ts +23 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
- package/src/daemon/conversation-agent-loop.ts +14 -6
- package/src/daemon/conversation-evictor.ts +50 -16
- package/src/daemon/conversation-messaging.ts +4 -0
- package/src/daemon/conversation-process.ts +15 -0
- package/src/daemon/conversation-queue-manager.ts +2 -0
- package/src/daemon/conversation.ts +2 -0
- package/src/daemon/doordash-steps.ts +1 -3
- package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
- package/src/daemon/handlers/__tests__/config-channels.test.ts +1 -1
- package/src/daemon/handlers/config-a2a.ts +75 -2
- package/src/daemon/handlers/shared.ts +13 -2
- package/src/daemon/host-cu-proxy.ts +34 -3
- package/src/daemon/lifecycle.ts +15 -2
- package/src/daemon/process-message.ts +5 -0
- package/src/daemon/shutdown-handlers.ts +3 -0
- package/src/daemon/turn-finalization.ts +4 -0
- package/src/desktop/desktop-browser-endpoint.ts +11 -2
- package/src/desktop/desktop-chrome-policy.test.ts +1 -0
- package/src/desktop/desktop-chrome-policy.ts +7 -1
- package/src/desktop/desktop-computer-use.test.ts +14 -4
- package/src/desktop/desktop-session-manager.ts +5 -3
- package/src/documents/document-revisions-store.ts +153 -0
- package/src/documents/document-store.ts +471 -133
- package/src/followups/followup-store.ts +0 -4
- package/src/i18n/__tests__/i18n.test.ts +25 -0
- package/src/i18n/assistant-text.ts +18 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/messages.ts +11 -0
- package/src/inbound/__tests__/oauth-callback-url.test.ts +25 -2
- package/src/inbound/oauth-callback-url.ts +30 -1
- package/src/inbound/public-ingress-urls.ts +12 -2
- package/src/ipc/gateway-client.test.ts +40 -0
- package/src/ipc/gateway-client.ts +12 -29
- package/src/ipc/gateway-flag-listener.ts +7 -10
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +71 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
- package/src/live-voice/__tests__/protocol.test.ts +77 -0
- package/src/live-voice/__tests__/share-targets.test.ts +163 -0
- package/src/live-voice/live-voice-session.ts +23 -6
- package/src/live-voice/protocol.ts +22 -0
- package/src/live-voice/share-targets.ts +225 -0
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +144 -3
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -0
- package/src/mcp/client.ts +1 -19
- package/src/mcp/mcp-oauth-provider.ts +66 -49
- package/src/media/openrouter-image-service.ts +39 -22
- package/src/monitoring/__tests__/oom-inheritance-sweep.test.ts +86 -0
- package/src/monitoring/__tests__/oom-kill-reporter.test.ts +374 -0
- package/src/monitoring/control.ts +2 -1
- package/src/monitoring/oom-inheritance-sweep.ts +96 -0
- package/src/monitoring/oom-kill-reporter.ts +469 -0
- package/src/monitoring/resource-sampler.ts +30 -0
- package/src/notifications/README.md +53 -4
- package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
- package/src/notifications/__tests__/broadcaster.test.ts +558 -6
- package/src/notifications/__tests__/completion-policy.test.ts +37 -0
- package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
- package/src/notifications/adapters/platform.ts +14 -0
- package/src/notifications/assistant-initiated-thread.ts +101 -0
- package/src/notifications/broadcaster.ts +94 -16
- package/src/notifications/chat-reply-policy.ts +11 -0
- package/src/notifications/completion-policy.ts +12 -2
- package/src/notifications/conversation-pairing.ts +15 -70
- package/src/notifications/emit-signal.ts +34 -3
- package/src/notifications/types.ts +2 -0
- package/src/oauth/connect-orchestrator.ts +1 -2
- package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
- package/src/persistence/conversation-auto-archive.ts +170 -0
- package/src/persistence/conversation-crud.ts +2 -1
- package/src/persistence/conversation-queries.ts +15 -8
- package/src/persistence/embeddings/embed.test.ts +96 -0
- package/src/persistence/embeddings/embed.ts +20 -2
- package/src/persistence/embeddings/embedding-gemini.test.ts +39 -3
- package/src/persistence/embeddings/embedding-gemini.ts +39 -1
- package/src/persistence/embeddings/embedding-local.ts +6 -0
- package/src/persistence/embeddings/qdrant-manager.ts +5 -0
- package/src/persistence/job-utils.ts +10 -0
- package/src/persistence/migrations/384-add-document-revision.test.ts +83 -0
- package/src/persistence/migrations/384-add-document-revision.ts +21 -0
- package/src/persistence/migrations/385-create-document-revisions.test.ts +67 -0
- package/src/persistence/migrations/385-create-document-revisions.ts +28 -0
- package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
- package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
- package/src/persistence/schema/conversations.ts +1 -0
- package/src/persistence/schema/documents.ts +20 -0
- package/src/persistence/steps.ts +6 -0
- package/src/platform/managed-speech.ts +42 -46
- package/src/plugin-api/vision-support.test.ts +21 -21
- package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +11 -6
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
- package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
- package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
- package/src/providers/anthropic/client.ts +1 -1
- package/src/providers/fireworks/client.test.ts +168 -0
- package/src/providers/fireworks/client.ts +24 -0
- package/src/providers/inference/adapter-factory.ts +6 -0
- package/src/providers/ionet/client.test.ts +143 -0
- package/src/providers/ionet/client.ts +37 -0
- package/src/providers/model-catalog.ts +141 -84
- package/src/providers/model-intents.ts +4 -4
- package/src/providers/openai/chat-completions-provider.ts +33 -2
- package/src/providers/retry.ts +56 -4
- package/src/providers/search-provider-catalog.ts +10 -0
- package/src/providers/types.ts +10 -2
- package/src/providers/unparseable-tool-args.ts +29 -5
- package/src/providers/usage-tracking.ts +5 -3
- package/src/runtime/assistant-event-hub.ts +23 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
- package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
- package/src/runtime/channel-reply-delivery.ts +2 -0
- package/src/runtime/channel-retry-sweep.ts +1 -2
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
- package/src/runtime/migrations/vbundle-builder.ts +19 -3
- package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
- package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
- package/src/runtime/migrations/vbundle-validator.ts +54 -1
- package/src/runtime/pending-interactions.ts +20 -2
- package/src/runtime/routes/__tests__/contact-routes.test.ts +5 -3
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
- package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
- package/src/runtime/routes/contact-routes.ts +2 -2
- package/src/runtime/routes/conversation-list-routes.ts +10 -2
- package/src/runtime/routes/conversation-query-routes.ts +48 -10
- package/src/runtime/routes/conversation-routes.ts +7 -0
- package/src/runtime/routes/documents-routes.ts +185 -47
- package/src/runtime/routes/events-routes.ts +1 -3
- package/src/runtime/routes/host-app-control-routes.ts +1 -0
- package/src/runtime/routes/host-bash-routes.ts +1 -0
- package/src/runtime/routes/host-browser-routes.ts +15 -0
- package/src/runtime/routes/host-cu-routes.ts +4 -1
- package/src/runtime/routes/host-file-routes.ts +1 -0
- package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
- package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
- package/src/runtime/routes/host-transfer-routes.ts +1 -0
- package/src/runtime/routes/identity-routes.ts +3 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
- package/src/runtime/routes/integrations/a2a.ts +4 -1
- package/src/runtime/routes/migration-routes.ts +3 -1
- package/src/runtime/routes/notification-routes.ts +1 -2
- package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
- package/src/runtime/routes/user-route-dispatcher.ts +5 -1
- package/src/runtime/verification-templates.ts +1 -2
- package/src/sequence/analytics.ts +0 -4
- package/src/sequence/engine.ts +1 -11
- package/src/sequence/guardrails.ts +1 -6
- package/src/sequence/importer.ts +0 -8
- package/src/sequence/store.ts +0 -10
- package/src/signals/user-message.ts +0 -6
- package/src/skills/catalog-install.ts +52 -23
- package/src/tools/browser/browser-execution.ts +20 -23
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +1 -3
- package/src/tools/document/document-tool.ts +13 -11
- package/src/tools/executor.ts +1 -2
- package/src/tools/host-terminal/host-shell.ts +4 -2
- package/src/tools/network/__tests__/web-search.test.ts +105 -0
- package/src/tools/network/web-fetch.ts +2 -4
- package/src/tools/network/web-search.ts +239 -12
- package/src/tools/shared/filesystem/format-diff.ts +1 -2
- package/src/tools/shared/shell-output.test.ts +42 -0
- package/src/tools/shared/shell-output.ts +27 -4
- package/src/tools/skills/execute.ts +10 -7
- package/src/tools/terminal/shell.ts +3 -3
- package/src/tools/tool-approval-handler.ts +9 -1
- package/src/tools/ui-surface/visual-validation.ts +1 -2
- package/src/usage/attribution.ts +18 -1
- package/src/util/__tests__/oom-priority.test.ts +55 -0
- package/src/util/__tests__/worker-memory.test.ts +16 -0
- package/src/util/debounce.ts +0 -4
- package/src/util/oom-priority.ts +79 -0
- package/src/util/platform.ts +0 -2
- package/src/util/plugin-manifest.ts +5 -7
- package/src/util/pricing.ts +25 -0
- package/src/util/worker-memory.ts +9 -3
- package/src/util/worker-process.ts +8 -0
- package/src/workspace/byok-default-profile-ensure.ts +11 -0
- package/src/workspace/hatched-date.ts +11 -4
- package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
- package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
- package/src/workspace/migrations/registry.ts +4 -0
|
@@ -7,8 +7,16 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
|
|
10
|
+
import { getSqlite } from "../persistence/db-connection.js";
|
|
10
11
|
import { rawAll, rawGet, rawRun } from "../persistence/raw-query.js";
|
|
11
12
|
import { getLogger } from "../util/logger.js";
|
|
13
|
+
import {
|
|
14
|
+
type DocumentRevisionAuthor,
|
|
15
|
+
type DocumentSnapshotSource,
|
|
16
|
+
getDocumentRevision,
|
|
17
|
+
recordDocumentSnapshot,
|
|
18
|
+
shouldSnapshotBeforeUserSave,
|
|
19
|
+
} from "./document-revisions-store.js";
|
|
12
20
|
|
|
13
21
|
const log = getLogger("document-store");
|
|
14
22
|
|
|
@@ -25,6 +33,8 @@ export interface DocumentRecord {
|
|
|
25
33
|
wordCount: number;
|
|
26
34
|
createdAt: number;
|
|
27
35
|
updatedAt: number;
|
|
36
|
+
/** Bumped by one on every write. */
|
|
37
|
+
revision: number;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -68,32 +78,40 @@ interface DocumentRow {
|
|
|
68
78
|
word_count: number;
|
|
69
79
|
created_at: number;
|
|
70
80
|
updated_at: number;
|
|
81
|
+
revision: number;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
type DocumentListRow = Omit<DocumentRow, "content">;
|
|
74
85
|
|
|
86
|
+
/** How many times a read-modify-write retries after losing a revision race. */
|
|
87
|
+
const MAX_WRITE_ATTEMPTS = 5;
|
|
88
|
+
|
|
75
89
|
function escapeSqlLikePattern(value: string): string {
|
|
76
90
|
return value.replace(/[\\%_]/g, "\\$&");
|
|
77
91
|
}
|
|
78
92
|
|
|
79
|
-
function
|
|
93
|
+
function mapRowToSummary(row: DocumentListRow): DocumentSummary {
|
|
80
94
|
return {
|
|
81
95
|
surfaceId: row.surface_id,
|
|
82
96
|
conversationId: row.conversation_id,
|
|
83
97
|
title: row.title,
|
|
84
|
-
content: row.content,
|
|
85
98
|
wordCount: row.word_count,
|
|
86
99
|
createdAt: row.created_at,
|
|
87
100
|
updatedAt: row.updated_at,
|
|
101
|
+
revision: row.revision,
|
|
88
102
|
};
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
function mapRowToRecord(row: DocumentRow): DocumentRecord {
|
|
106
|
+
return { ...mapRowToSummary(row), content: row.content };
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
/** Look up a single document by surface ID. Returns `null` when not found. */
|
|
92
110
|
export function getDocumentById(surfaceId: string): DocumentRecord | null {
|
|
93
111
|
try {
|
|
94
112
|
const row = rawGet<DocumentRow>(
|
|
95
113
|
"documents:getDocumentById",
|
|
96
|
-
/*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at
|
|
114
|
+
/*sql*/ `SELECT surface_id, conversation_id, title, content, word_count, created_at, updated_at, revision
|
|
97
115
|
FROM documents
|
|
98
116
|
WHERE surface_id = ?`,
|
|
99
117
|
surfaceId,
|
|
@@ -151,7 +169,7 @@ export function getDocumentsForConversation(
|
|
|
151
169
|
"documents:getDocumentsForConversation",
|
|
152
170
|
/*sql*/ `
|
|
153
171
|
SELECT d.surface_id, dc.conversation_id AS conversation_id,
|
|
154
|
-
d.title, d.word_count, d.created_at, d.updated_at
|
|
172
|
+
d.title, d.word_count, d.created_at, d.updated_at, d.revision
|
|
155
173
|
FROM documents d
|
|
156
174
|
INNER JOIN document_conversations dc ON d.surface_id = dc.surface_id
|
|
157
175
|
WHERE dc.conversation_id = ?
|
|
@@ -164,20 +182,36 @@ export function getDocumentsForConversation(
|
|
|
164
182
|
{ conversationId, count: rows.length },
|
|
165
183
|
"Listed documents for conversation",
|
|
166
184
|
);
|
|
167
|
-
return rows.map(
|
|
168
|
-
surfaceId: row.surface_id,
|
|
169
|
-
conversationId: row.conversation_id,
|
|
170
|
-
title: row.title,
|
|
171
|
-
wordCount: row.word_count,
|
|
172
|
-
createdAt: row.created_at,
|
|
173
|
-
updatedAt: row.updated_at,
|
|
174
|
-
}));
|
|
185
|
+
return rows.map(mapRowToSummary);
|
|
175
186
|
} catch (error) {
|
|
176
187
|
log.error({ err: error, conversationId }, "List error");
|
|
177
188
|
return [];
|
|
178
189
|
}
|
|
179
190
|
}
|
|
180
191
|
|
|
192
|
+
/**
|
|
193
|
+
* List every document, most recently updated first.
|
|
194
|
+
* Returns an empty array on error.
|
|
195
|
+
*/
|
|
196
|
+
export function listAllDocuments(): DocumentSummary[] {
|
|
197
|
+
try {
|
|
198
|
+
const rows = rawAll<DocumentListRow>(
|
|
199
|
+
"documents:listAllDocuments",
|
|
200
|
+
/*sql*/ `
|
|
201
|
+
SELECT surface_id, conversation_id, title, word_count, created_at, updated_at, revision
|
|
202
|
+
FROM documents
|
|
203
|
+
ORDER BY updated_at DESC
|
|
204
|
+
`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
log.info({ count: rows.length }, "Listed documents");
|
|
208
|
+
return rows.map(mapRowToSummary);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
log.error({ err: error }, "List error");
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
181
215
|
/**
|
|
182
216
|
* Search documents by title substring (case-insensitive).
|
|
183
217
|
* When `conversationId` is supplied, only documents associated with that
|
|
@@ -195,7 +229,7 @@ export function searchDocumentsByTitle(
|
|
|
195
229
|
"documents:searchByTitle:scoped",
|
|
196
230
|
/*sql*/ `
|
|
197
231
|
SELECT d.surface_id, dc.conversation_id AS conversation_id,
|
|
198
|
-
d.title, d.word_count, d.created_at, d.updated_at
|
|
232
|
+
d.title, d.word_count, d.created_at, d.updated_at, d.revision
|
|
199
233
|
FROM documents d
|
|
200
234
|
INNER JOIN document_conversations dc ON d.surface_id = dc.surface_id
|
|
201
235
|
WHERE dc.conversation_id = ?
|
|
@@ -209,7 +243,7 @@ export function searchDocumentsByTitle(
|
|
|
209
243
|
: rawAll<DocumentListRow>(
|
|
210
244
|
"documents:searchByTitle:all",
|
|
211
245
|
/*sql*/ `
|
|
212
|
-
SELECT surface_id, conversation_id, title, word_count, created_at, updated_at
|
|
246
|
+
SELECT surface_id, conversation_id, title, word_count, created_at, updated_at, revision
|
|
213
247
|
FROM documents
|
|
214
248
|
WHERE title COLLATE NOCASE LIKE ? ESCAPE '\\'
|
|
215
249
|
ORDER BY updated_at DESC
|
|
@@ -222,14 +256,7 @@ export function searchDocumentsByTitle(
|
|
|
222
256
|
{ query, conversationId: options.conversationId, count: rows.length },
|
|
223
257
|
"Searched documents by title",
|
|
224
258
|
);
|
|
225
|
-
return rows.map(
|
|
226
|
-
surfaceId: row.surface_id,
|
|
227
|
-
conversationId: row.conversation_id,
|
|
228
|
-
title: row.title,
|
|
229
|
-
wordCount: row.word_count,
|
|
230
|
-
createdAt: row.created_at,
|
|
231
|
-
updatedAt: row.updated_at,
|
|
232
|
-
}));
|
|
259
|
+
return rows.map(mapRowToSummary);
|
|
233
260
|
} catch (error) {
|
|
234
261
|
log.error({ err: error, query }, "Search error");
|
|
235
262
|
return [];
|
|
@@ -404,24 +431,93 @@ export function findInDocument(
|
|
|
404
431
|
// Document persistence
|
|
405
432
|
// ---------------------------------------------------------------------------
|
|
406
433
|
|
|
407
|
-
|
|
434
|
+
/** The document state a conditional save lost to. */
|
|
435
|
+
export interface DocumentRevisionConflict {
|
|
436
|
+
revision: number;
|
|
437
|
+
title: string;
|
|
438
|
+
content: string;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export type SaveDocumentResult =
|
|
442
|
+
| { success: true; surfaceId: string; revision: number }
|
|
443
|
+
| { success: false; error: string; conflict?: DocumentRevisionConflict };
|
|
444
|
+
|
|
445
|
+
function readDocumentState(surfaceId: string): DocumentSnapshotSource | null {
|
|
446
|
+
const row = rawGet<{
|
|
447
|
+
revision: number;
|
|
448
|
+
title: string;
|
|
449
|
+
content: string;
|
|
450
|
+
word_count: number;
|
|
451
|
+
}>(
|
|
452
|
+
"documents:readDocumentState",
|
|
453
|
+
/*sql*/ `SELECT revision, title, content, word_count FROM documents WHERE surface_id = ?`,
|
|
454
|
+
surfaceId,
|
|
455
|
+
);
|
|
456
|
+
return row
|
|
457
|
+
? {
|
|
458
|
+
surfaceId,
|
|
459
|
+
revision: row.revision,
|
|
460
|
+
title: row.title,
|
|
461
|
+
content: row.content,
|
|
462
|
+
wordCount: row.word_count,
|
|
463
|
+
}
|
|
464
|
+
: null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Snapshot the state a user save is about to replace, when the save will land
|
|
469
|
+
* and changes something and {@link shouldSnapshotBeforeUserSave} allows it.
|
|
470
|
+
* Runs inside the save's transaction.
|
|
471
|
+
*/
|
|
472
|
+
function snapshotBeforeUserSave(params: {
|
|
473
|
+
surfaceId: string;
|
|
474
|
+
title: string;
|
|
475
|
+
content: string;
|
|
476
|
+
baseRevision?: number;
|
|
477
|
+
}): void {
|
|
478
|
+
const current = readDocumentState(params.surfaceId);
|
|
479
|
+
if (
|
|
480
|
+
!current ||
|
|
481
|
+
(params.baseRevision !== undefined &&
|
|
482
|
+
current.revision !== params.baseRevision) ||
|
|
483
|
+
(current.title === params.title && current.content === params.content)
|
|
484
|
+
) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (shouldSnapshotBeforeUserSave(params.surfaceId)) {
|
|
488
|
+
recordDocumentSnapshot(current, "user");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Write a document's title and body, creating the row when it does not exist.
|
|
494
|
+
*
|
|
495
|
+
* Without `baseRevision` the write is unconditional. With it, an existing row
|
|
496
|
+
* is only overwritten when its revision still equals `baseRevision`; otherwise
|
|
497
|
+
* nothing is written and the result carries the row's current state. A row
|
|
498
|
+
* that does not exist yet is created either way.
|
|
499
|
+
*/
|
|
500
|
+
function writeDocument(params: {
|
|
408
501
|
surfaceId: string;
|
|
409
502
|
conversationId: string;
|
|
410
503
|
title: string;
|
|
411
504
|
content: string;
|
|
412
505
|
wordCount: number;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
506
|
+
baseRevision?: number;
|
|
507
|
+
}): { revision: number } | { conflict: DocumentRevisionConflict } {
|
|
508
|
+
const now = Date.now();
|
|
509
|
+
if (params.baseRevision === undefined) {
|
|
510
|
+
const row = rawGet<{ revision: number }>(
|
|
417
511
|
"documents:saveDocument",
|
|
418
|
-
`INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at)
|
|
419
|
-
VALUES (?, ?, ?, ?, ?, ?,
|
|
512
|
+
/*sql*/ `INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at, revision)
|
|
513
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
420
514
|
ON CONFLICT(surface_id) DO UPDATE SET
|
|
421
515
|
title = excluded.title,
|
|
422
516
|
content = excluded.content,
|
|
423
517
|
word_count = excluded.word_count,
|
|
424
|
-
updated_at = excluded.updated_at
|
|
518
|
+
updated_at = excluded.updated_at,
|
|
519
|
+
revision = documents.revision + 1
|
|
520
|
+
RETURNING revision`,
|
|
425
521
|
params.surfaceId,
|
|
426
522
|
params.conversationId,
|
|
427
523
|
params.title,
|
|
@@ -430,14 +526,102 @@ export function saveDocument(params: {
|
|
|
430
526
|
now,
|
|
431
527
|
now,
|
|
432
528
|
);
|
|
529
|
+
return { revision: row!.revision };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const updated = rawGet<{ revision: number }>(
|
|
533
|
+
"documents:saveDocument:conditional",
|
|
534
|
+
/*sql*/ `UPDATE documents
|
|
535
|
+
SET title = ?, content = ?, word_count = ?, updated_at = ?, revision = revision + 1
|
|
536
|
+
WHERE surface_id = ? AND revision = ?
|
|
537
|
+
RETURNING revision`,
|
|
538
|
+
params.title,
|
|
539
|
+
params.content,
|
|
540
|
+
params.wordCount,
|
|
541
|
+
now,
|
|
542
|
+
params.surfaceId,
|
|
543
|
+
params.baseRevision,
|
|
544
|
+
);
|
|
545
|
+
if (updated) {
|
|
546
|
+
return updated;
|
|
547
|
+
}
|
|
548
|
+
const inserted = rawGet<{ revision: number }>(
|
|
549
|
+
"documents:saveDocument:insertIfMissing",
|
|
550
|
+
/*sql*/ `INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at, revision)
|
|
551
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
552
|
+
ON CONFLICT(surface_id) DO NOTHING
|
|
553
|
+
RETURNING revision`,
|
|
554
|
+
params.surfaceId,
|
|
555
|
+
params.conversationId,
|
|
556
|
+
params.title,
|
|
557
|
+
params.content,
|
|
558
|
+
params.wordCount,
|
|
559
|
+
now,
|
|
560
|
+
now,
|
|
561
|
+
);
|
|
562
|
+
if (inserted) {
|
|
563
|
+
return inserted;
|
|
564
|
+
}
|
|
565
|
+
const current = readDocumentState(params.surfaceId);
|
|
566
|
+
if (!current) {
|
|
567
|
+
throw new Error("Document disappeared during a conditional save");
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
conflict: {
|
|
571
|
+
revision: current.revision,
|
|
572
|
+
title: current.title,
|
|
573
|
+
content: current.content,
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Create a document, or overwrite one as a user save from the editor. An
|
|
580
|
+
* overwrite may first snapshot the replaced state into the document's history
|
|
581
|
+
* (see {@link snapshotBeforeUserSave}).
|
|
582
|
+
*/
|
|
583
|
+
export function saveDocument(params: {
|
|
584
|
+
surfaceId: string;
|
|
585
|
+
conversationId: string;
|
|
586
|
+
title: string;
|
|
587
|
+
content: string;
|
|
588
|
+
wordCount: number;
|
|
589
|
+
baseRevision?: number;
|
|
590
|
+
}): SaveDocumentResult {
|
|
591
|
+
try {
|
|
592
|
+
const written = getSqlite()
|
|
593
|
+
.transaction(() => {
|
|
594
|
+
snapshotBeforeUserSave(params);
|
|
595
|
+
return writeDocument(params);
|
|
596
|
+
})
|
|
597
|
+
.immediate();
|
|
598
|
+
if ("conflict" in written) {
|
|
599
|
+
log.info(
|
|
600
|
+
{
|
|
601
|
+
surfaceId: params.surfaceId,
|
|
602
|
+
baseRevision: params.baseRevision,
|
|
603
|
+
currentRevision: written.conflict.revision,
|
|
604
|
+
},
|
|
605
|
+
"Rejected document save against a stale revision",
|
|
606
|
+
);
|
|
607
|
+
return {
|
|
608
|
+
success: false,
|
|
609
|
+
error: "Document changed since the base revision",
|
|
610
|
+
conflict: written.conflict,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
433
613
|
log.info(
|
|
434
|
-
{
|
|
614
|
+
{
|
|
615
|
+
surfaceId: params.surfaceId,
|
|
616
|
+
title: params.title,
|
|
617
|
+
revision: written.revision,
|
|
618
|
+
},
|
|
435
619
|
"Saved document",
|
|
436
620
|
);
|
|
437
621
|
|
|
438
622
|
// Best-effort: associate the document with the conversation.
|
|
439
623
|
// Failures (e.g. migration not yet applied, table missing) must not
|
|
440
|
-
// cause the save response to report failure
|
|
624
|
+
// cause the save response to report failure: the document itself is
|
|
441
625
|
// already persisted at this point.
|
|
442
626
|
try {
|
|
443
627
|
addDocumentConversation(params.surfaceId, params.conversationId);
|
|
@@ -448,7 +632,11 @@ export function saveDocument(params: {
|
|
|
448
632
|
);
|
|
449
633
|
}
|
|
450
634
|
|
|
451
|
-
return {
|
|
635
|
+
return {
|
|
636
|
+
success: true,
|
|
637
|
+
surfaceId: params.surfaceId,
|
|
638
|
+
revision: written.revision,
|
|
639
|
+
};
|
|
452
640
|
} catch (error) {
|
|
453
641
|
log.error({ err: error, surfaceId: params.surfaceId }, "Save error");
|
|
454
642
|
return {
|
|
@@ -458,6 +646,143 @@ export function saveDocument(params: {
|
|
|
458
646
|
}
|
|
459
647
|
}
|
|
460
648
|
|
|
649
|
+
/** What a {@link mutateDocumentContent} callback decides for one attempt. */
|
|
650
|
+
export type ContentMutation<T> =
|
|
651
|
+
| { content: string; value: T }
|
|
652
|
+
| { value: T; content?: undefined };
|
|
653
|
+
|
|
654
|
+
/** Thrown inside a write transaction to roll it back after losing a race. */
|
|
655
|
+
class LostRevisionRace extends Error {}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Read-modify-write a document's body without losing a concurrent write.
|
|
659
|
+
*
|
|
660
|
+
* `mutate` receives the current body and returns the new one, or no `content`
|
|
661
|
+
* to leave the row untouched; a body equal to the current one is not written
|
|
662
|
+
* either. The state being replaced is snapshotted into the document's history
|
|
663
|
+
* as `author`'s edit, in the same transaction as the write. The write is a
|
|
664
|
+
* compare-and-swap on `revision`: when another write lands between the read
|
|
665
|
+
* and the write, the transaction rolls back and `mutate` runs again against
|
|
666
|
+
* the fresh body, up to {@link MAX_WRITE_ATTEMPTS} times.
|
|
667
|
+
*
|
|
668
|
+
* Returns `null` when the document does not exist, otherwise the callback's
|
|
669
|
+
* value, the body now stored, and the revision after the call.
|
|
670
|
+
*/
|
|
671
|
+
export function mutateDocumentContent<T>(
|
|
672
|
+
surfaceId: string,
|
|
673
|
+
author: DocumentRevisionAuthor,
|
|
674
|
+
mutate: (current: {
|
|
675
|
+
content: string;
|
|
676
|
+
revision: number;
|
|
677
|
+
}) => ContentMutation<T>,
|
|
678
|
+
): { value: T; content: string; revision: number } | null {
|
|
679
|
+
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
|
|
680
|
+
const current = readDocumentState(surfaceId);
|
|
681
|
+
if (!current) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
const mutation = mutate(current);
|
|
685
|
+
if (
|
|
686
|
+
mutation.content === undefined ||
|
|
687
|
+
mutation.content === current.content
|
|
688
|
+
) {
|
|
689
|
+
return {
|
|
690
|
+
value: mutation.value,
|
|
691
|
+
content: current.content,
|
|
692
|
+
revision: current.revision,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const newContent = mutation.content;
|
|
696
|
+
try {
|
|
697
|
+
const written = getSqlite()
|
|
698
|
+
.transaction(() => {
|
|
699
|
+
recordDocumentSnapshot(current, author);
|
|
700
|
+
const row = rawGet<{ revision: number }>(
|
|
701
|
+
"documents:mutateDocumentContent:update",
|
|
702
|
+
/*sql*/ `UPDATE documents
|
|
703
|
+
SET content = ?, word_count = ?, updated_at = ?, revision = revision + 1
|
|
704
|
+
WHERE surface_id = ? AND revision = ?
|
|
705
|
+
RETURNING revision`,
|
|
706
|
+
newContent,
|
|
707
|
+
countWords(newContent),
|
|
708
|
+
Date.now(),
|
|
709
|
+
surfaceId,
|
|
710
|
+
current.revision,
|
|
711
|
+
);
|
|
712
|
+
if (!row) {
|
|
713
|
+
throw new LostRevisionRace();
|
|
714
|
+
}
|
|
715
|
+
return row;
|
|
716
|
+
})
|
|
717
|
+
.immediate();
|
|
718
|
+
return {
|
|
719
|
+
value: mutation.value,
|
|
720
|
+
content: newContent,
|
|
721
|
+
revision: written.revision,
|
|
722
|
+
};
|
|
723
|
+
} catch (error) {
|
|
724
|
+
if (!(error instanceof LostRevisionRace)) {
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
log.info(
|
|
729
|
+
{ surfaceId, attempt, baseRevision: current.revision },
|
|
730
|
+
"Document changed during a read-modify-write; retrying",
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
throw new Error("Document kept changing during the write; try again");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Outcome of {@link restoreDocumentRevision}. */
|
|
737
|
+
export type RestoreDocumentRevisionResult =
|
|
738
|
+
| { success: true; revision: number; title: string; content: string }
|
|
739
|
+
| { success: false; notFound: "document" | "revision" };
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Put a snapshot's title and body back as a new revision of the document.
|
|
743
|
+
* The current state is snapshotted first, so the restore is itself undoable.
|
|
744
|
+
*/
|
|
745
|
+
export function restoreDocumentRevision(
|
|
746
|
+
surfaceId: string,
|
|
747
|
+
revision: number,
|
|
748
|
+
): RestoreDocumentRevisionResult {
|
|
749
|
+
return getSqlite()
|
|
750
|
+
.transaction((): RestoreDocumentRevisionResult => {
|
|
751
|
+
const current = readDocumentState(surfaceId);
|
|
752
|
+
if (!current) {
|
|
753
|
+
return { success: false, notFound: "document" };
|
|
754
|
+
}
|
|
755
|
+
const target = getDocumentRevision(surfaceId, revision);
|
|
756
|
+
if (!target) {
|
|
757
|
+
return { success: false, notFound: "revision" };
|
|
758
|
+
}
|
|
759
|
+
recordDocumentSnapshot(current, "user");
|
|
760
|
+
const row = rawGet<{ revision: number }>(
|
|
761
|
+
"documents:restoreDocumentRevision",
|
|
762
|
+
/*sql*/ `UPDATE documents
|
|
763
|
+
SET title = ?, content = ?, word_count = ?, updated_at = ?, revision = revision + 1
|
|
764
|
+
WHERE surface_id = ?
|
|
765
|
+
RETURNING revision`,
|
|
766
|
+
target.title,
|
|
767
|
+
target.content,
|
|
768
|
+
target.wordCount,
|
|
769
|
+
Date.now(),
|
|
770
|
+
surfaceId,
|
|
771
|
+
);
|
|
772
|
+
log.info(
|
|
773
|
+
{ surfaceId, restoredRevision: revision, revision: row!.revision },
|
|
774
|
+
"Restored document revision",
|
|
775
|
+
);
|
|
776
|
+
return {
|
|
777
|
+
success: true,
|
|
778
|
+
revision: row!.revision,
|
|
779
|
+
title: target.title,
|
|
780
|
+
content: target.content,
|
|
781
|
+
};
|
|
782
|
+
})
|
|
783
|
+
.immediate();
|
|
784
|
+
}
|
|
785
|
+
|
|
461
786
|
export const DEFAULT_DOCUMENT_TITLE = "Untitled Document";
|
|
462
787
|
|
|
463
788
|
/**
|
|
@@ -468,7 +793,7 @@ export function createDocument(params: {
|
|
|
468
793
|
conversationId: string;
|
|
469
794
|
title?: string;
|
|
470
795
|
content?: string;
|
|
471
|
-
}):
|
|
796
|
+
}): SaveDocumentResult {
|
|
472
797
|
const content = params.content ?? "";
|
|
473
798
|
return saveDocument({
|
|
474
799
|
surfaceId: `doc-${randomUUID()}`,
|
|
@@ -490,11 +815,79 @@ export interface ReplaceInDocumentOptions {
|
|
|
490
815
|
}
|
|
491
816
|
|
|
492
817
|
export type ReplaceInDocumentResult =
|
|
493
|
-
| {
|
|
818
|
+
| {
|
|
819
|
+
success: true;
|
|
820
|
+
replacements_made: number;
|
|
821
|
+
content_changed: boolean;
|
|
822
|
+
/** The body now stored. */
|
|
823
|
+
content: string;
|
|
824
|
+
/** The revision after the call. */
|
|
825
|
+
revision: number;
|
|
826
|
+
}
|
|
494
827
|
| { success: false; error: string };
|
|
495
828
|
|
|
496
829
|
/**
|
|
497
|
-
*
|
|
830
|
+
* Apply a find-and-replace to `content`. Returns the unchanged input and a
|
|
831
|
+
* zero count when nothing matches or `maxReplacements` is not positive.
|
|
832
|
+
*/
|
|
833
|
+
function replaceText(
|
|
834
|
+
content: string,
|
|
835
|
+
find: string,
|
|
836
|
+
replace: string,
|
|
837
|
+
options: ReplaceInDocumentOptions,
|
|
838
|
+
): { content: string; replacementsMade: number } {
|
|
839
|
+
const flags = "g" + (options.caseSensitive === true ? "" : "i");
|
|
840
|
+
const pattern = options.regex
|
|
841
|
+
? new RegExp(find, flags)
|
|
842
|
+
: new RegExp(RegExp.escape(find), flags);
|
|
843
|
+
|
|
844
|
+
const totalMatches = [...content.matchAll(pattern)].length;
|
|
845
|
+
if (
|
|
846
|
+
totalMatches === 0 ||
|
|
847
|
+
(options.maxReplacements != null && options.maxReplacements <= 0)
|
|
848
|
+
) {
|
|
849
|
+
return { content, replacementsMade: 0 };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (
|
|
853
|
+
options.maxReplacements == null ||
|
|
854
|
+
options.maxReplacements >= totalMatches
|
|
855
|
+
) {
|
|
856
|
+
return {
|
|
857
|
+
content: content.replace(pattern, replace),
|
|
858
|
+
replacementsMade: totalMatches,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Iterative replacement up to maxReplacements using manual exec loop
|
|
863
|
+
// so backreferences in the replacement string work correctly.
|
|
864
|
+
const limit = options.maxReplacements;
|
|
865
|
+
let count = 0;
|
|
866
|
+
let result = "";
|
|
867
|
+
let lastIndex = 0;
|
|
868
|
+
let m: RegExpExecArray | null;
|
|
869
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
870
|
+
if (count >= limit) {
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
result += content.slice(lastIndex, m.index);
|
|
874
|
+
const singleMatchPattern = new RegExp(
|
|
875
|
+
pattern.source,
|
|
876
|
+
pattern.flags.replace("g", ""),
|
|
877
|
+
);
|
|
878
|
+
result += m[0].replace(singleMatchPattern, replace);
|
|
879
|
+
lastIndex = m.index + m[0].length;
|
|
880
|
+
count++;
|
|
881
|
+
if (m[0].length === 0) {
|
|
882
|
+
pattern.lastIndex++;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
result += content.slice(lastIndex);
|
|
886
|
+
return { content: result, replacementsMade: count };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Find and replace text within a document, like sed, as an assistant edit.
|
|
498
891
|
* Supports literal text and regex patterns with optional backreferences.
|
|
499
892
|
*/
|
|
500
893
|
export function replaceInDocument(
|
|
@@ -504,80 +897,27 @@ export function replaceInDocument(
|
|
|
504
897
|
options: ReplaceInDocumentOptions = {},
|
|
505
898
|
): ReplaceInDocumentResult {
|
|
506
899
|
try {
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
900
|
+
const outcome = mutateDocumentContent(surfaceId, "assistant", (current) => {
|
|
901
|
+
const replaced = replaceText(current.content, find, replace, options);
|
|
902
|
+
return replaced.replacementsMade === 0
|
|
903
|
+
? { value: 0 }
|
|
904
|
+
: { content: replaced.content, value: replaced.replacementsMade };
|
|
905
|
+
});
|
|
906
|
+
if (!outcome) {
|
|
513
907
|
return { success: false, error: "Document not found" };
|
|
514
908
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const totalMatches = [...row.content.matchAll(pattern)].length;
|
|
522
|
-
if (
|
|
523
|
-
totalMatches === 0 ||
|
|
524
|
-
(options.maxReplacements != null && options.maxReplacements <= 0)
|
|
525
|
-
) {
|
|
526
|
-
return { success: true, replacements_made: 0, content_changed: false };
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
let newContent: string;
|
|
530
|
-
let replacementsMade: number;
|
|
531
|
-
|
|
532
|
-
if (
|
|
533
|
-
options.maxReplacements != null &&
|
|
534
|
-
options.maxReplacements < totalMatches
|
|
535
|
-
) {
|
|
536
|
-
// Iterative replacement up to maxReplacements using manual exec loop
|
|
537
|
-
// so backreferences in the replacement string work correctly.
|
|
538
|
-
const limit = options.maxReplacements;
|
|
539
|
-
let count = 0;
|
|
540
|
-
let result = "";
|
|
541
|
-
let lastIndex = 0;
|
|
542
|
-
let m: RegExpExecArray | null;
|
|
543
|
-
while ((m = pattern.exec(row.content)) !== null) {
|
|
544
|
-
if (count >= limit) {
|
|
545
|
-
break;
|
|
546
|
-
}
|
|
547
|
-
result += row.content.slice(lastIndex, m.index);
|
|
548
|
-
const singleMatchPattern = new RegExp(
|
|
549
|
-
pattern.source,
|
|
550
|
-
pattern.flags.replace("g", ""),
|
|
551
|
-
);
|
|
552
|
-
result += m[0].replace(singleMatchPattern, replace);
|
|
553
|
-
lastIndex = m.index + m[0].length;
|
|
554
|
-
count++;
|
|
555
|
-
if (m[0].length === 0) {
|
|
556
|
-
pattern.lastIndex++;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
result += row.content.slice(lastIndex);
|
|
560
|
-
newContent = result;
|
|
561
|
-
replacementsMade = count;
|
|
562
|
-
} else {
|
|
563
|
-
newContent = row.content.replace(pattern, replace);
|
|
564
|
-
replacementsMade = totalMatches;
|
|
909
|
+
if (outcome.value > 0) {
|
|
910
|
+
log.info(
|
|
911
|
+
{ surfaceId, replacementsMade: outcome.value },
|
|
912
|
+
"Replaced text in document",
|
|
913
|
+
);
|
|
565
914
|
}
|
|
566
|
-
|
|
567
|
-
const wordCount = countWords(newContent);
|
|
568
|
-
rawRun(
|
|
569
|
-
"documents:replaceInDocument:update",
|
|
570
|
-
/*sql*/ `UPDATE documents SET content = ?, word_count = ?, updated_at = ? WHERE surface_id = ?`,
|
|
571
|
-
newContent,
|
|
572
|
-
wordCount,
|
|
573
|
-
Date.now(),
|
|
574
|
-
surfaceId,
|
|
575
|
-
);
|
|
576
|
-
log.info({ surfaceId, replacementsMade }, "Replaced text in document");
|
|
577
915
|
return {
|
|
578
916
|
success: true,
|
|
579
|
-
replacements_made:
|
|
580
|
-
content_changed:
|
|
917
|
+
replacements_made: outcome.value,
|
|
918
|
+
content_changed: outcome.value > 0,
|
|
919
|
+
content: outcome.content,
|
|
920
|
+
revision: outcome.revision,
|
|
581
921
|
};
|
|
582
922
|
} catch (error) {
|
|
583
923
|
log.error({ err: error, surfaceId }, "Replace-in-document error");
|
|
@@ -699,57 +1039,55 @@ export interface DocumentContentUpdated {
|
|
|
699
1039
|
appliedMarkdown: string;
|
|
700
1040
|
/** True when a duplicated leading run was dropped from an append. */
|
|
701
1041
|
duplicateLeadingContentSkipped: boolean;
|
|
1042
|
+
/** The revision after the write. */
|
|
1043
|
+
revision: number;
|
|
702
1044
|
}
|
|
703
1045
|
|
|
704
|
-
/** Update persisted document content (append or replace). */
|
|
1046
|
+
/** Update persisted document content (append or replace) as an assistant edit. */
|
|
705
1047
|
export function updateDocumentContent(
|
|
706
1048
|
surfaceId: string,
|
|
707
1049
|
markdown: string,
|
|
708
1050
|
mode: string,
|
|
709
1051
|
): DocumentContentUpdated | { success: false; error: string } {
|
|
710
1052
|
try {
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1053
|
+
const appending = mode === "append";
|
|
1054
|
+
const outcome = mutateDocumentContent(surfaceId, "assistant", (current) => {
|
|
1055
|
+
const appliedMarkdown = appending
|
|
1056
|
+
? stripDuplicateLeadingBlocks(current.content, markdown)
|
|
1057
|
+
: markdown;
|
|
1058
|
+
const sep =
|
|
1059
|
+
appending && current.content.length > 0 && appliedMarkdown.length > 0
|
|
1060
|
+
? "\n\n"
|
|
1061
|
+
: "";
|
|
1062
|
+
return {
|
|
1063
|
+
content: appending
|
|
1064
|
+
? current.content + sep + appliedMarkdown
|
|
1065
|
+
: appliedMarkdown,
|
|
1066
|
+
value: appliedMarkdown,
|
|
1067
|
+
};
|
|
1068
|
+
});
|
|
1069
|
+
if (!outcome) {
|
|
717
1070
|
log.info({ surfaceId }, "No persisted document to update");
|
|
718
1071
|
return { success: false, error: "Document not found" };
|
|
719
1072
|
}
|
|
720
|
-
const
|
|
721
|
-
const appliedMarkdown = appending
|
|
722
|
-
? stripDuplicateLeadingBlocks(existing.content, markdown)
|
|
723
|
-
: markdown;
|
|
1073
|
+
const appliedMarkdown = outcome.value;
|
|
724
1074
|
const duplicateLeadingContentSkipped =
|
|
725
1075
|
appending && appliedMarkdown !== markdown;
|
|
726
|
-
const sep =
|
|
727
|
-
appending && existing.content.length > 0 && appliedMarkdown.length > 0
|
|
728
|
-
? "\n\n"
|
|
729
|
-
: "";
|
|
730
|
-
const newContent = appending
|
|
731
|
-
? existing.content + sep + appliedMarkdown
|
|
732
|
-
: appliedMarkdown;
|
|
733
|
-
const wordCount = countWords(newContent);
|
|
734
|
-
rawRun(
|
|
735
|
-
"documents:updateDocumentContent:update",
|
|
736
|
-
/*sql*/ `UPDATE documents SET content = ?, word_count = ?, updated_at = ? WHERE surface_id = ?`,
|
|
737
|
-
newContent,
|
|
738
|
-
wordCount,
|
|
739
|
-
Date.now(),
|
|
740
|
-
surfaceId,
|
|
741
|
-
);
|
|
742
1076
|
if (duplicateLeadingContentSkipped) {
|
|
743
1077
|
log.info(
|
|
744
1078
|
{ surfaceId, skippedChars: markdown.length - appliedMarkdown.length },
|
|
745
1079
|
"Skipped append content duplicating the document tail",
|
|
746
1080
|
);
|
|
747
1081
|
}
|
|
748
|
-
log.info(
|
|
1082
|
+
log.info(
|
|
1083
|
+
{ surfaceId, mode, revision: outcome.revision },
|
|
1084
|
+
"Updated document content",
|
|
1085
|
+
);
|
|
749
1086
|
return {
|
|
750
1087
|
success: true,
|
|
751
1088
|
appliedMarkdown,
|
|
752
1089
|
duplicateLeadingContentSkipped,
|
|
1090
|
+
revision: outcome.revision,
|
|
753
1091
|
};
|
|
754
1092
|
} catch (error) {
|
|
755
1093
|
log.error({ err: error, surfaceId }, "Document content update error");
|