@vellumai/assistant 0.11.3 → 0.11.4-staging.1
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/ARCHITECTURE.md +11 -6
- package/docs/architecture/memory.md +11 -0
- package/docs/architecture/turn-actor.md +70 -0
- package/docs/flux-turn-detection-spike.md +243 -0
- package/docs/stt-provider-onboarding.md +3 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/openapi.yaml +140 -38
- package/package.json +1 -1
- package/scripts/voice-ttft-spike.ts +3 -3
- package/src/__tests__/app-compiler.test.ts +38 -3
- package/src/__tests__/attachments-store.test.ts +21 -12
- package/src/__tests__/byok-default-profile-ensure.test.ts +2 -0
- package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
- package/src/__tests__/call-site-routing-provider.test.ts +1 -1
- package/src/__tests__/channel-availability-routes.test.ts +14 -1
- package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
- package/src/__tests__/channel-delivery-store.test.ts +14 -14
- package/src/__tests__/config-loader-backfill.test.ts +3 -3
- package/src/__tests__/config-schema.test.ts +25 -10
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop.test.ts +28 -20
- package/src/__tests__/conversation-attention-store.test.ts +63 -0
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
- package/src/__tests__/conversation-fork-crud.test.ts +69 -0
- package/src/__tests__/conversation-fork-referential.test.ts +67 -0
- package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
- package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
- package/src/__tests__/conversation-queue.test.ts +55 -4
- package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
- package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
- package/src/__tests__/credential-prompt-route.test.ts +7 -10
- package/src/__tests__/custom-profile-ensure.test.ts +5 -1
- package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
- package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
- package/src/__tests__/document-append-idempotency.test.ts +233 -0
- package/src/__tests__/edit-propagation.test.ts +0 -7
- package/src/__tests__/helpers/mock-actor-context.ts +49 -0
- package/src/__tests__/helpers/mock-conversation.ts +13 -1
- package/src/__tests__/injector-chain.test.ts +63 -41
- package/src/__tests__/injector-disk-pressure.test.ts +11 -23
- package/src/__tests__/llm-context-resolution.test.ts +73 -1
- package/src/__tests__/llm-schema.test.ts +5 -2
- package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
- package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
- package/src/__tests__/mtime-cache.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +0 -20
- package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
- package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
- package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
- package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
- package/src/__tests__/scoped-approval-grants.test.ts +11 -6
- package/src/__tests__/secret-ingress-channel.test.ts +0 -1
- package/src/__tests__/skills.test.ts +32 -0
- package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
- package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
- package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
- package/src/__tests__/turn-events-store.test.ts +43 -0
- package/src/__tests__/user-plugin-loader.test.ts +1 -1
- package/src/__tests__/visible-app-context.test.ts +16 -9
- package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
- package/src/__tests__/worker-plugin-surface.test.ts +77 -0
- package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
- package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
- package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
- package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
- package/src/acp/__tests__/auth-required.test.ts +161 -0
- package/src/acp/acp-claude-oauth.ts +19 -2
- package/src/acp/agent-process.test.ts +100 -0
- package/src/acp/agent-process.ts +29 -26
- package/src/acp/auth-required.ts +102 -0
- package/src/acp/session-manager.test.ts +119 -0
- package/src/acp/session-manager.ts +68 -2
- package/src/api/events/acp-auth-required.ts +55 -0
- package/src/api/index.ts +7 -0
- package/src/apps/app-store.ts +3 -0
- package/src/bundler/package-resolver.ts +2 -30
- package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
- package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
- package/src/calls/call-controller.ts +9 -2
- package/src/calls/call-setup-flow.ts +0 -1
- package/src/calls/media-stream-stt-session.ts +15 -0
- package/src/calls/voice-session-bridge.ts +71 -16
- package/src/calls/voice-triage-escalate.ts +104 -2
- package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
- package/src/channels/config.ts +13 -0
- package/src/channels/plugin-channel-declarations.ts +108 -0
- package/src/channels/types.ts +30 -0
- package/src/cli/AGENTS.md +5 -2
- package/src/cli/commands/credentials.help.ts +2 -2
- package/src/cli/commands/inference-providers.ts +1 -1
- package/src/cli/commands/mcp.help.ts +13 -4
- package/src/cli/commands/mcp.ts +9 -0
- package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
- package/src/cli/commands/memory/index.help.ts +43 -1
- package/src/cli/commands/memory/memory-v3.ts +64 -0
- package/src/cli/commands/stt.help.ts +27 -2
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/upgrade-plugin.ts +42 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
- package/src/config/__tests__/default-provider.test.ts +6 -1
- package/src/config/__tests__/profile-materialization.test.ts +75 -19
- package/src/config/bundled-skills/acp/SKILL.md +6 -7
- package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
- package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
- package/src/config/call-site-defaults.ts +4 -5
- package/src/config/default-profile-catalog.ts +82 -11
- package/src/config/default-profile-names.ts +4 -1
- package/src/config/default-provider-resolution.ts +4 -0
- package/src/config/llm-context-resolution.ts +11 -3
- package/src/config/llm-resolver.ts +28 -1
- package/src/config/profile-materialization.ts +70 -22
- package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
- package/src/config/schemas/call-site-catalog.ts +4 -4
- package/src/config/schemas/live-voice.ts +57 -23
- package/src/config/schemas/llm.ts +59 -32
- package/src/config/schemas/mcp.ts +23 -0
- package/src/config/schemas/plugin-updates.ts +6 -2
- package/src/config/schemas/stt.ts +1 -0
- package/src/context/outbound-sanitize.ts +96 -1
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
- package/src/daemon/conversation-agent-loop.ts +17 -6
- package/src/daemon/conversation-messaging.ts +5 -1
- package/src/daemon/conversation-notifiers.ts +9 -1
- package/src/daemon/conversation-process.ts +9 -6
- package/src/daemon/conversation-runtime-assembly.ts +3 -4
- package/src/daemon/conversation-tool-setup.ts +1 -2
- package/src/daemon/conversation.ts +48 -0
- package/src/daemon/mcp-reload-service.ts +36 -6
- package/src/daemon/process-message.ts +13 -3
- package/src/daemon/providers-setup.ts +6 -3
- package/src/daemon/trust-context-types.ts +29 -0
- package/src/daemon/wake-conversation-ops.ts +3 -2
- package/src/documents/document-store.ts +138 -5
- package/src/hooks/hook-loader.ts +3 -3
- package/src/hooks/registry.ts +50 -6
- package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
- package/src/inbound/oauth-callback-url.ts +61 -0
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
- package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
- package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
- package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
- package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
- package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
- package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
- package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
- package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
- package/src/live-voice/live-voice-archive.ts +2 -0
- package/src/live-voice/live-voice-metrics.ts +57 -32
- package/src/live-voice/live-voice-photo.ts +1 -2
- package/src/live-voice/live-voice-session.ts +535 -314
- package/src/live-voice/progress-narration.ts +277 -0
- package/src/live-voice/protocol.ts +21 -1
- package/src/mcp/__tests__/effective-config.test.ts +238 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
- package/src/mcp/client.ts +16 -11
- package/src/mcp/effective-config.ts +113 -0
- package/src/mcp/manager.ts +11 -6
- package/src/mcp/mcp-auth-orchestrator.ts +13 -22
- package/src/mcp/mcp-oauth-provider.ts +205 -240
- package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
- package/src/monitoring/plugin-auto-update.ts +128 -24
- package/src/notifications/signal.ts +1 -0
- package/src/permissions/confirmation-guardian-request.test.ts +15 -11
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/question-guardian-request.test.ts +14 -6
- package/src/permissions/question-guardian-request.ts +1 -2
- package/src/persistence/attachments-store.ts +8 -1
- package/src/persistence/bookmark-crud.ts +3 -7
- package/src/persistence/conversation-attention-store.ts +16 -45
- package/src/persistence/conversation-crud.ts +33 -4
- package/src/persistence/conversation-lineage.ts +9 -0
- package/src/persistence/conversation-queries.ts +108 -41
- package/src/persistence/delivery-crud.ts +38 -29
- package/src/persistence/external-conversation-store.ts +32 -4
- package/src/persistence/llm-request-log-store.ts +4 -10
- package/src/persistence/llm-usage-store.ts +8 -3
- package/src/persistence/message-reads.test.ts +197 -0
- package/src/persistence/message-reads.ts +211 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
- package/src/persistence/real-user-turn-filter.ts +27 -3
- package/src/persistence/steps.ts +9 -0
- package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
- package/src/plugin-api/conversation-turn.ts +168 -5
- package/src/plugin-api/index.ts +21 -5
- package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
- package/src/plugins/defaults/memory/AGENTS.md +4 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
- package/src/plugins/defaults/memory/buffer-format.ts +165 -0
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
- package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
- package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
- package/src/plugins/defaults/memory/indexer.ts +3 -1
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
- package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
- package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
- package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
- package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
- package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
- package/src/plugins/defaults/memory/v3/prune.ts +2 -0
- package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
- package/src/plugins/defaults/memory/worker.ts +6 -3
- package/src/plugins/external-plugin-loader.ts +47 -0
- package/src/plugins/mcp-servers.ts +361 -0
- package/src/plugins/mtime-cache.ts +23 -49
- package/src/plugins/worker-plugin-surface.ts +33 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
- package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
- package/src/providers/__tests__/retry-callsite.test.ts +5 -2
- package/src/providers/call-site-routing.ts +30 -3
- package/src/providers/connection-resolution.ts +194 -11
- package/src/providers/inference/auth.ts +6 -0
- package/src/providers/inference/connection-availability.ts +24 -2
- package/src/providers/inference/connections.ts +2 -0
- package/src/providers/model-intents.ts +26 -6
- package/src/providers/openai/codex-models.ts +2 -1
- package/src/providers/provider-send-message.ts +32 -3
- package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
- package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
- package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
- package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
- package/src/providers/speech-to-text/provider-catalog.ts +99 -8
- package/src/providers/speech-to-text/resolve.ts +25 -2
- package/src/routes/worker.ts +17 -5
- package/src/runtime/access-request-helper.ts +9 -12
- package/src/runtime/agent-wake.ts +3 -3
- package/src/runtime/pre-first-message-gate.ts +4 -0
- package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
- package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
- package/src/runtime/routes/channel-availability-routes.ts +32 -14
- package/src/runtime/routes/channel-route-shared.ts +0 -6
- package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
- package/src/runtime/routes/conversation-list-routes.ts +112 -1
- package/src/runtime/routes/conversation-query-routes.ts +40 -27
- package/src/runtime/routes/credential-prompt-routes.ts +4 -7
- package/src/runtime/routes/default-provider-routes.ts +15 -0
- package/src/runtime/routes/inbound-message-handler.ts +17 -41
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
- package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
- package/src/runtime/routes/inference-profiles-routes.ts +20 -11
- package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
- package/src/runtime/routes/log-export-routes.ts +3 -0
- package/src/runtime/routes/mcp-auth-routes.ts +148 -57
- package/src/runtime/routes/plugins-routes.ts +21 -3
- package/src/runtime/routes/stt-routes.ts +31 -25
- package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
- package/src/runtime/routes/user-route-dispatcher.ts +39 -14
- package/src/runtime/routes/user-route-import.ts +108 -0
- package/src/schedule/worker.ts +6 -0
- package/src/security/oauth2.ts +6 -22
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
- package/src/stt/__tests__/types.test.ts +94 -0
- package/src/stt/daemon-batch-transcriber.ts +10 -0
- package/src/stt/stt-stream-session.ts +8 -4
- package/src/stt/types.ts +103 -0
- package/src/subagent/manager.ts +1 -3
- package/src/subagent/types.ts +7 -6
- package/src/tools/acp/spawn.test.ts +97 -0
- package/src/tools/acp/spawn.ts +32 -0
- package/src/tools/document/document-tool.ts +12 -3
- package/src/tools/registry.ts +2 -1
- package/src/tools/workflows/run-workflow.ts +1 -2
- package/src/tts/__tests__/reasoning-tag-filter.test.ts +63 -0
- package/src/tts/reasoning-tag-filter.ts +110 -0
- package/src/workspace/byok-default-profile-ensure.ts +61 -18
- package/src/workspace/custom-profile-ensure.ts +4 -24
- package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
- package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
- package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
- package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
- package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/workspace/provider-commit-message-generator.ts +7 -5
- package/src/live-voice/__tests__/front-decision.test.ts +0 -645
- package/src/live-voice/front-decision.ts +0 -476
|
@@ -59,6 +59,10 @@ Everything else under the plugin root is **spine**:
|
|
|
59
59
|
- Shared infra at the root: `logging`, `paths`, `embeddings`, `anisotropy`,
|
|
60
60
|
`frontmatter`, `validation`, `llm-helpers`, `config`, `memory-db`,
|
|
61
61
|
`host-utils`, `path-containment`, `prompt-override`, `memory-marker`,
|
|
62
|
+
`buffer-format` (the sole owner of the `memory/buffer.md` entry format:
|
|
63
|
+
the writer plus the one matcher every reader uses. It lives at the root
|
|
64
|
+
precisely so `substrate/`, `graph/`, and `graph-topology/` can all reach it
|
|
65
|
+
without a tier importing spine. Do not add a second matcher anywhere),
|
|
62
66
|
`segmenter`, `message-media`, `worker`, `worker-control`,
|
|
63
67
|
`memory-recall-log-store`, `activation-session-store` (the onboarding
|
|
64
68
|
activation rail — **not** a memory tier despite the name),
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the single owner of the `memory/buffer.md` entry format.
|
|
3
|
+
*
|
|
4
|
+
* These cases pin exactly which lines count as an entry opening, covering the
|
|
5
|
+
* shapes a second matcher is most likely to get wrong: indentation, spacing
|
|
6
|
+
* after the bullet dash, and spacing inside the date. Every one of them is the
|
|
7
|
+
* difference between one fact and two.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
formatBufferTimestamp,
|
|
14
|
+
formatRememberEntry,
|
|
15
|
+
isBufferEntryStart,
|
|
16
|
+
matchBufferEntryStart,
|
|
17
|
+
splitBufferEntries,
|
|
18
|
+
} from "../buffer-format.js";
|
|
19
|
+
|
|
20
|
+
describe("matchBufferEntryStart", () => {
|
|
21
|
+
test("matches what formatRememberEntry writes, and captures both halves", () => {
|
|
22
|
+
const line = formatRememberEntry(
|
|
23
|
+
"Alice prefers dark mode",
|
|
24
|
+
new Date(2026, 0, 1, 9, 0),
|
|
25
|
+
).trimEnd();
|
|
26
|
+
expect(line).toBe("- [Jan 1, 9:00 AM] Alice prefers dark mode");
|
|
27
|
+
|
|
28
|
+
const match = matchBufferEntryStart(line);
|
|
29
|
+
expect(match).toEqual({
|
|
30
|
+
timestamp: "Jan 1, 9:00 AM",
|
|
31
|
+
text: "Alice prefers dark mode",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("round-trips every hour so the AM/PM and 12-hour edges hold", () => {
|
|
36
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
37
|
+
const line = formatRememberEntry(
|
|
38
|
+
"fact",
|
|
39
|
+
new Date(2026, 0, 1, hour, 5),
|
|
40
|
+
).trimEnd();
|
|
41
|
+
const match = matchBufferEntryStart(line);
|
|
42
|
+
expect(match).not.toBeNull();
|
|
43
|
+
expect(match!.timestamp).toBe(
|
|
44
|
+
formatBufferTimestamp(new Date(2026, 0, 1, hour, 5)),
|
|
45
|
+
);
|
|
46
|
+
expect(match!.text).toBe("fact");
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("an indented entry-shaped line is body text, not a new entry", () => {
|
|
51
|
+
// The writer puts only an entry's opening line at column 0 and nests the
|
|
52
|
+
// body under it, so anything indented is part of the fact above it.
|
|
53
|
+
// Reading it as an entry splits one fact into two nodes in the Memory tab.
|
|
54
|
+
expect(isBufferEntryStart(" - [Jan 1, 9:00 AM] indented")).toBe(false);
|
|
55
|
+
expect(isBufferEntryStart("\t- [Jan 1, 9:00 AM] tab-indented")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("only the writer's exact spacing counts as an entry", () => {
|
|
59
|
+
// A line the writer could not have produced is body text. Recognizing it
|
|
60
|
+
// would either split the fact holding it or hand a caller a timestamp in a
|
|
61
|
+
// shape that no real entry uses, which matters because the consolidation
|
|
62
|
+
// cutoff is compared against entry timestamps textually.
|
|
63
|
+
expect(isBufferEntryStart("-[Jan 1, 9:00 AM] no space after dash")).toBe(
|
|
64
|
+
false,
|
|
65
|
+
);
|
|
66
|
+
expect(isBufferEntryStart("- [Jan 1, 9:00 AM] two spaces")).toBe(false);
|
|
67
|
+
expect(isBufferEntryStart("- [Jan 1, 9:00 AM] double-spaced date")).toBe(
|
|
68
|
+
false,
|
|
69
|
+
);
|
|
70
|
+
expect(
|
|
71
|
+
isBufferEntryStart("- [Jan 1, 9:00AM] no space before meridiem"),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("day and month widths the writer produces all match", () => {
|
|
76
|
+
// `formatBufferTimestamp` pads neither the day nor the hour, so single and
|
|
77
|
+
// double digit forms both occur on disk.
|
|
78
|
+
for (const line of [
|
|
79
|
+
"- [Jan 1, 9:00 AM] single-digit day and hour",
|
|
80
|
+
"- [Dec 31, 12:05 PM] double-digit day and hour",
|
|
81
|
+
"- [Sep 9, 11:59 PM] boundary",
|
|
82
|
+
]) {
|
|
83
|
+
expect(isBufferEntryStart(line)).toBe(true);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("continuation bullets carrying other bracketed text are not entries", () => {
|
|
88
|
+
expect(isBufferEntryStart(" - [ ] a checklist item")).toBe(false);
|
|
89
|
+
expect(isBufferEntryStart("- [ ] a checklist item")).toBe(false);
|
|
90
|
+
expect(isBufferEntryStart("- [[wikilink]] bullet")).toBe(false);
|
|
91
|
+
expect(isBufferEntryStart(" plain prose")).toBe(false);
|
|
92
|
+
expect(isBufferEntryStart("")).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("tolerates trailing whitespace, which editors add invisibly", () => {
|
|
96
|
+
expect(isBufferEntryStart("- [Jan 1, 9:00 AM] fact ")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("formatRememberEntry round trip", () => {
|
|
101
|
+
const WRITTEN_AT = new Date(2026, 0, 1, 9, 0);
|
|
102
|
+
|
|
103
|
+
test("a fact whose body is itself entry-shaped survives as one entry", () => {
|
|
104
|
+
const content = "plan:\n- [Jan 2, 9:00 AM] step";
|
|
105
|
+
const written = formatRememberEntry(content, WRITTEN_AT);
|
|
106
|
+
|
|
107
|
+
// The body is nested, so it cannot be mistaken for the next fact.
|
|
108
|
+
expect(written).toBe(
|
|
109
|
+
"- [Jan 1, 9:00 AM] plan:\n - [Jan 2, 9:00 AM] step\n",
|
|
110
|
+
);
|
|
111
|
+
expect(splitBufferEntries(written.trimEnd().split("\n"))).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("any content round-trips to exactly one entry", () => {
|
|
115
|
+
// The delimiter is "canonical entry shape at column 0", so the property
|
|
116
|
+
// that matters is that no fact body can imitate one, whatever the user
|
|
117
|
+
// dictated.
|
|
118
|
+
const adversarial = [
|
|
119
|
+
"plan:\n- [Jan 2, 9:00 AM] step",
|
|
120
|
+
"plan:\n- [Jan 2, 9:00 AM] step\n- [Jan 3, 10:15 PM] another",
|
|
121
|
+
"- [Jan 2, 9:00 AM] body opens with an entry shape",
|
|
122
|
+
"top\n\n- [Dec 31, 12:05 PM] after a blank line",
|
|
123
|
+
"top\n - [Jan 2, 9:00 AM] already indented",
|
|
124
|
+
"top\n\t- [Jan 2, 9:00 AM] tab indented",
|
|
125
|
+
"top\n- [ ] checklist\n- [[wikilink]] doc\n- plain bullet",
|
|
126
|
+
"single line, no body",
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
for (const content of adversarial) {
|
|
130
|
+
const written = formatRememberEntry(content, WRITTEN_AT);
|
|
131
|
+
expect(splitBufferEntries(written.trimEnd().split("\n"))).toHaveLength(1);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("the fact text a reader recovers is unchanged by nesting", () => {
|
|
136
|
+
const content = "plan:\n- [Jan 2, 9:00 AM] step";
|
|
137
|
+
const groups = splitBufferEntries(
|
|
138
|
+
formatRememberEntry(content, WRITTEN_AT).trimEnd().split("\n"),
|
|
139
|
+
);
|
|
140
|
+
// Opening clause on the entry, body carried verbatim under it.
|
|
141
|
+
expect(groups[0]!.start?.text).toBe("plan:");
|
|
142
|
+
expect(groups[0]!.lines).toEqual([
|
|
143
|
+
"- [Jan 1, 9:00 AM] plan:",
|
|
144
|
+
" - [Jan 2, 9:00 AM] step",
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("blank body lines stay blank rather than becoming whitespace", () => {
|
|
149
|
+
const written = formatRememberEntry("a\n\nb", WRITTEN_AT);
|
|
150
|
+
expect(written).toBe("- [Jan 1, 9:00 AM] a\n\n b\n");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("splitBufferEntries", () => {
|
|
155
|
+
test("keeps a multiline fact's body with its opening line", () => {
|
|
156
|
+
const groups = splitBufferEntries([
|
|
157
|
+
"- [Jan 1, 9:00 AM] first",
|
|
158
|
+
"- [Jan 2, 9:00 AM] second",
|
|
159
|
+
" body of second",
|
|
160
|
+
" - [ ] checklist inside second",
|
|
161
|
+
" - [Jan 3, 9:00 AM] indented, still second",
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
expect(groups).toHaveLength(2);
|
|
165
|
+
expect(groups[0]!.lines).toEqual(["- [Jan 1, 9:00 AM] first"]);
|
|
166
|
+
expect(groups[1]!.lines).toHaveLength(4);
|
|
167
|
+
expect(groups[1]!.start?.text).toBe("second");
|
|
168
|
+
expect(groups[1]!.firstLine).toBe(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("surfaces leading prose as a headless group rather than dropping it", () => {
|
|
172
|
+
const groups = splitBufferEntries([
|
|
173
|
+
"hand-written header",
|
|
174
|
+
"- [Jan 1, 9:00 AM] first",
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
expect(groups).toHaveLength(2);
|
|
178
|
+
expect(groups[0]!.start).toBeNull();
|
|
179
|
+
expect(groups[0]!.lines).toEqual(["hand-written header"]);
|
|
180
|
+
expect(groups[1]!.start).not.toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a buffer with no entries at all is one headless group", () => {
|
|
184
|
+
const groups = splitBufferEntries(["just prose", "more prose"]);
|
|
185
|
+
expect(groups).toHaveLength(1);
|
|
186
|
+
expect(groups[0]!.start).toBeNull();
|
|
187
|
+
expect(groups[0]!.lines).toHaveLength(2);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("preserves every input line exactly once, in order", () => {
|
|
191
|
+
const lines = [
|
|
192
|
+
"prose",
|
|
193
|
+
"- [Jan 1, 9:00 AM] a",
|
|
194
|
+
" body",
|
|
195
|
+
"",
|
|
196
|
+
"- [Jan 2, 9:00 AM] b",
|
|
197
|
+
];
|
|
198
|
+
expect(splitBufferEntries(lines).flatMap((g) => g.lines)).toEqual(lines);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("empty input yields no groups", () => {
|
|
202
|
+
expect(splitBufferEntries([])).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
|
|
3
5
|
import {
|
|
4
6
|
createConversation,
|
|
5
7
|
type MessageRow,
|
|
@@ -8,6 +10,7 @@ import { getDb } from "../../../../persistence/db-connection.js";
|
|
|
8
10
|
import { initializeDb } from "../../../../persistence/db-init.js";
|
|
9
11
|
import { messages } from "../../../../persistence/schema/index.js";
|
|
10
12
|
import {
|
|
13
|
+
getRetrospectiveMessagesAfter,
|
|
11
14
|
hasQualifyingUserMessageAfter,
|
|
12
15
|
messagesHaveUserActivity,
|
|
13
16
|
} from "../memory-retrospective-accounting.js";
|
|
@@ -197,3 +200,72 @@ describe("messagesHaveUserActivity", () => {
|
|
|
197
200
|
expect(messagesHaveUserActivity([])).toBe(false);
|
|
198
201
|
});
|
|
199
202
|
});
|
|
203
|
+
|
|
204
|
+
describe("getRetrospectiveMessagesAfter", () => {
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
const db = getDb();
|
|
207
|
+
db.run(`DELETE FROM messages`);
|
|
208
|
+
db.run(`DELETE FROM conversations`);
|
|
209
|
+
createConversation({ id: CONV });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("truncates at a stale mid-slice unfinalized row so the cursor never passes it", () => {
|
|
213
|
+
const first = insertRaw({ role: "user", content: TEXT, createdAt: 1_000 });
|
|
214
|
+
const stale = insertRaw({
|
|
215
|
+
role: "assistant",
|
|
216
|
+
content: TEXT,
|
|
217
|
+
createdAt: 2_000,
|
|
218
|
+
});
|
|
219
|
+
insertRaw({ role: "user", content: TEXT, createdAt: 3_000 });
|
|
220
|
+
insertRaw({ role: "assistant", content: TEXT, createdAt: 4_000 });
|
|
221
|
+
getDb()
|
|
222
|
+
.update(messages)
|
|
223
|
+
.set({ finalized: 0 })
|
|
224
|
+
.where(eq(messages.id, stale))
|
|
225
|
+
.run();
|
|
226
|
+
|
|
227
|
+
const slice = getRetrospectiveMessagesAfter(CONV, null);
|
|
228
|
+
|
|
229
|
+
// The slice stops BEFORE the stale row: taking the later finalized rows
|
|
230
|
+
// as the cutoff would advance the cursor past the stale row, and once it
|
|
231
|
+
// finalizes it would sit behind the cursor, unreviewed forever.
|
|
232
|
+
expect(slice.map((row) => row.id)).toEqual([first]);
|
|
233
|
+
|
|
234
|
+
getDb()
|
|
235
|
+
.update(messages)
|
|
236
|
+
.set({ finalized: 1 })
|
|
237
|
+
.where(eq(messages.id, stale))
|
|
238
|
+
.run();
|
|
239
|
+
|
|
240
|
+
// Once the row resolves, the slice continues past it.
|
|
241
|
+
expect(getRetrospectiveMessagesAfter(CONV, null)).toHaveLength(4);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("excludes unfinalized rows so the cutoff stays on rows a fork holds", () => {
|
|
245
|
+
insertRaw({ role: "user", content: TEXT, createdAt: 1_000 });
|
|
246
|
+
const lastFinalized = insertRaw({
|
|
247
|
+
role: "assistant",
|
|
248
|
+
content: TEXT,
|
|
249
|
+
createdAt: 2_000,
|
|
250
|
+
});
|
|
251
|
+
const streaming = insertRaw({
|
|
252
|
+
role: "assistant",
|
|
253
|
+
content: TEXT,
|
|
254
|
+
createdAt: 3_000,
|
|
255
|
+
});
|
|
256
|
+
getDb()
|
|
257
|
+
.update(messages)
|
|
258
|
+
.set({ finalized: 0 })
|
|
259
|
+
.where(eq(messages.id, streaming))
|
|
260
|
+
.run();
|
|
261
|
+
|
|
262
|
+
const slice = getRetrospectiveMessagesAfter(CONV, null);
|
|
263
|
+
|
|
264
|
+
// The invariant: no slice member is unfinalized, and the cutoff the job
|
|
265
|
+
// would take (the last slice row) is a row the retrospective fork
|
|
266
|
+
// actually contains.
|
|
267
|
+
expect(slice.some((row) => row.finalized === 0)).toBe(false);
|
|
268
|
+
expect(slice.at(-1)?.id).toBe(lastFinalized);
|
|
269
|
+
expect(slice.map((row) => row.id)).not.toContain(streaming);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -168,7 +168,10 @@ mock.module("../find-most-recent-retrospective-for.js", () => ({
|
|
|
168
168
|
mock.module("../../../../persistence/conversation-crud.js", () => ({
|
|
169
169
|
getMessagesAfter: (id: string, afterId: string | null) => {
|
|
170
170
|
messagesAfterCalls.push({ conversationId: id, afterId });
|
|
171
|
-
|
|
171
|
+
// Real rows always carry `finalized` (NOT NULL, default 1); the slice
|
|
172
|
+
// filter drops unfinalized rows, so mock rows default to finalized
|
|
173
|
+
// unless a test explicitly stages a streaming row.
|
|
174
|
+
return newMessages.map((row) => ({ finalized: 1, ...row }));
|
|
172
175
|
},
|
|
173
176
|
getMessages: (id: string) => {
|
|
174
177
|
if (messagesByConversationId[id]) {
|
|
@@ -96,7 +96,10 @@ mock.module("../find-most-recent-retrospective-for.js", () => ({
|
|
|
96
96
|
}));
|
|
97
97
|
|
|
98
98
|
mock.module("../../../../persistence/conversation-crud.js", () => ({
|
|
99
|
-
|
|
99
|
+
// Real rows always carry `finalized` (NOT NULL, default 1); the slice
|
|
100
|
+
// filter drops unfinalized rows, so mock rows default to finalized.
|
|
101
|
+
getMessagesAfter: (_id: string, _afterId: string | null) =>
|
|
102
|
+
newMessages.map((row) => ({ finalized: 1, ...row })),
|
|
100
103
|
getMessages: (id: string) => messageStores[id] ?? [],
|
|
101
104
|
// `defaultResolveTarget` in the REAL wake reads the FORK id's row for the
|
|
102
105
|
// archived check, so both rows carry archivedAt/createdAt. The fork's
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Memory buffer entry format: the single owner
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// `memory/buffer.md` is the append-only staging log that `remember()` writes
|
|
6
|
+
// and consolidation drains. Its line format is written here and recognized
|
|
7
|
+
// here, by one matcher, so the writer and every reader agree by construction.
|
|
8
|
+
//
|
|
9
|
+
// This module deliberately sits at the plugin root (shared infra, not a tier)
|
|
10
|
+
// and imports nothing. `substrate/` is the bottom tier and may not import a
|
|
11
|
+
// tier, but plugin-root infra is fair game, so all readers can reach this one
|
|
12
|
+
// without inverting the layering.
|
|
13
|
+
//
|
|
14
|
+
// A second matcher for this format anywhere in the tree is a bug. Two copies
|
|
15
|
+
// disagree on the shapes nobody thinks to test (indentation, spacing after the
|
|
16
|
+
// bullet dash, spacing inside the date), and each disagreement splits or merges
|
|
17
|
+
// somebody's fact.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A parsed buffer entry opening line.
|
|
21
|
+
*/
|
|
22
|
+
export interface BufferEntryStart {
|
|
23
|
+
/**
|
|
24
|
+
* The bracketed timestamp, verbatim. Returned unparsed so it can serve
|
|
25
|
+
* directly as a consolidation cutoff: both sides of the agent's
|
|
26
|
+
* "timestamp >= cutoff" comparison then share this exact shape.
|
|
27
|
+
*/
|
|
28
|
+
timestamp: string;
|
|
29
|
+
/** Everything after the timestamp bracket, trimmed. */
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Matches an entry opening line: `- [Mon D, h:mm AM/PM] fact`, the shape
|
|
35
|
+
* {@link formatRememberEntry} writes.
|
|
36
|
+
*
|
|
37
|
+
* Anchored at column 0, requiring a space after the dash, and spelling the
|
|
38
|
+
* timestamp with the single spaces {@link formatBufferTimestamp} emits.
|
|
39
|
+
* Everything else in the file is a continuation line belonging to the entry
|
|
40
|
+
* above it, which is how a multiline fact keeps its body: `remember()` writes
|
|
41
|
+
* only the opening line at column 0 and nests the body beneath it. That rule
|
|
42
|
+
* is what makes a bullet carrying other bracketed text (a `- [ ]` checklist
|
|
43
|
+
* item, a `- [[wikilink]]` bullet) read as continuation rather than as a new
|
|
44
|
+
* fact, and it is why a body line can never imitate an opening.
|
|
45
|
+
*
|
|
46
|
+
* Matching the writer's exact shape is the conservative choice in both
|
|
47
|
+
* directions: a line the writer could not have produced stays body text, so an
|
|
48
|
+
* odd hand-edit merges into the fact around it instead of splitting that fact
|
|
49
|
+
* or supplying a malformed timestamp to a caller that treats one as a cutoff.
|
|
50
|
+
*
|
|
51
|
+
* Group 1 is the timestamp, group 2 the fact text.
|
|
52
|
+
*/
|
|
53
|
+
const BUFFER_ENTRY_REGEX =
|
|
54
|
+
/^- \[([A-Z][a-z]{2} \d{1,2}, \d{1,2}:\d{2} [AP]M)\]\s*(.*)$/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse `line` as an entry opening, or `null` if it is a continuation line.
|
|
58
|
+
*
|
|
59
|
+
* Pass the raw line. Callers must not trim it first: leading whitespace is
|
|
60
|
+
* exactly what distinguishes an indented body line from a real entry, so a
|
|
61
|
+
* trimmed line reads an indented body bullet as a fresh fact and splits one
|
|
62
|
+
* entry into two.
|
|
63
|
+
*/
|
|
64
|
+
export function matchBufferEntryStart(line: string): BufferEntryStart | null {
|
|
65
|
+
const match = BUFFER_ENTRY_REGEX.exec(line.trimEnd());
|
|
66
|
+
if (match === null) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return { timestamp: match[1]!, text: match[2]!.trim() };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether `line` opens a buffer entry. See {@link matchBufferEntryStart}. */
|
|
73
|
+
export function isBufferEntryStart(line: string): boolean {
|
|
74
|
+
return BUFFER_ENTRY_REGEX.test(line.trimEnd());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format `now` as a buffer-entry timestamp (`Mon D, h:mm AM/PM`). Exported so
|
|
79
|
+
* the consolidation job can present its cutoff in the same shape the buffer
|
|
80
|
+
* entries use, making the agent's "timestamp >= cutoff" comparison
|
|
81
|
+
* unambiguous at minute precision.
|
|
82
|
+
*/
|
|
83
|
+
export function formatBufferTimestamp(now: Date): string {
|
|
84
|
+
const month = now.toLocaleString("en-US", { month: "short" });
|
|
85
|
+
const day = now.getDate();
|
|
86
|
+
const hours = now.getHours();
|
|
87
|
+
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
88
|
+
const ampm = hours >= 12 ? "PM" : "AM";
|
|
89
|
+
const displayHour = hours % 12 || 12;
|
|
90
|
+
return `${month} ${day}, ${displayHour}:${minutes} ${ampm}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Indent applied to a fact's continuation lines. Two spaces is the markdown
|
|
95
|
+
* convention for content nested under a `- ` bullet, so an entry stays
|
|
96
|
+
* readable as a list item.
|
|
97
|
+
*/
|
|
98
|
+
const CONTINUATION_INDENT = " ";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build a timestamped bullet entry for `buffer.md` / `archive/<date>.md`.
|
|
102
|
+
*
|
|
103
|
+
* Format mirrors the long-standing v1 PKB layout so buffers stay
|
|
104
|
+
* human-readable and downstream consumers (sweep, consolidation, the graph
|
|
105
|
+
* view) can parse the same shape regardless of which path produced the entry.
|
|
106
|
+
*
|
|
107
|
+
* A fact's body is indented, which is what makes the format round-trip. The
|
|
108
|
+
* buffer is line-delimited and the delimiter is "canonical entry shape at
|
|
109
|
+
* column 0", so a body line carrying that exact shape would otherwise be
|
|
110
|
+
* indistinguishable from the start of the next fact and split one entry into
|
|
111
|
+
* two. Indenting moves every body line off column 0, so no fact content can
|
|
112
|
+
* imitate a delimiter no matter what the user dictated. Blank lines stay
|
|
113
|
+
* genuinely blank rather than becoming whitespace.
|
|
114
|
+
*
|
|
115
|
+
* Readers strip the indent: {@link matchBufferEntryStart} classifies on the
|
|
116
|
+
* raw line while `parseBufferEntries` trims continuation lines, so the fact
|
|
117
|
+
* text a caller gets back is unchanged by this.
|
|
118
|
+
*
|
|
119
|
+
* Exported so memory sweep / extractor jobs format their auto-remembered
|
|
120
|
+
* entries identically to user-facing `remember()` calls.
|
|
121
|
+
*/
|
|
122
|
+
export function formatRememberEntry(content: string, now: Date): string {
|
|
123
|
+
const [opening = "", ...body] = content.split("\n");
|
|
124
|
+
const nested = body.map((line) =>
|
|
125
|
+
line.trim().length === 0 ? "" : `${CONTINUATION_INDENT}${line}`,
|
|
126
|
+
);
|
|
127
|
+
return `- [${formatBufferTimestamp(now)}] ${[opening, ...nested].join("\n")}\n`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Split buffer content into entries, each keeping its lines verbatim.
|
|
132
|
+
*
|
|
133
|
+
* The unit callers almost always want. Counting or trimming a buffer by raw
|
|
134
|
+
* lines conflates a five-line fact with five facts, and cuts a fact in half
|
|
135
|
+
* wherever the count runs out.
|
|
136
|
+
*
|
|
137
|
+
* Leading lines that precede the first entry opening (a hand-written buffer,
|
|
138
|
+
* or stray prose) are returned as a headless first group with
|
|
139
|
+
* `start === null`, so callers can decide what to do with them rather than
|
|
140
|
+
* silently dropping content.
|
|
141
|
+
*/
|
|
142
|
+
export function splitBufferEntries(
|
|
143
|
+
lines: readonly string[],
|
|
144
|
+
): BufferEntryLines[] {
|
|
145
|
+
const groups: BufferEntryLines[] = [];
|
|
146
|
+
for (const [index, line] of lines.entries()) {
|
|
147
|
+
const start = matchBufferEntryStart(line);
|
|
148
|
+
if (start !== null || groups.length === 0) {
|
|
149
|
+
groups.push({ start, firstLine: index, lines: [line] });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
groups[groups.length - 1]!.lines.push(line);
|
|
153
|
+
}
|
|
154
|
+
return groups;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One entry's verbatim lines, as returned by {@link splitBufferEntries}. */
|
|
158
|
+
export interface BufferEntryLines {
|
|
159
|
+
/** `null` for the headless group before the first entry opening. */
|
|
160
|
+
start: BufferEntryStart | null;
|
|
161
|
+
/** Index of this group's first line in the input array. */
|
|
162
|
+
firstLine: number;
|
|
163
|
+
/** The group's lines, verbatim and in order. */
|
|
164
|
+
lines: string[];
|
|
165
|
+
}
|
|
@@ -224,6 +224,12 @@ async function gatherCandidateRowsFromQdrant(
|
|
|
224
224
|
* the source/type/excluded-conversation predicates in SQL (Qdrant has no
|
|
225
225
|
* visibility filtering). The app-side scorer determines final ordering, so
|
|
226
226
|
* the SQL tie-break falls back to recency.
|
|
227
|
+
*
|
|
228
|
+
* No `finalized = 1` predicate, deliberately: every id in `messageIds` comes
|
|
229
|
+
* from the lexical or vector index, and indexing filters `finalized = 1` at
|
|
230
|
+
* index time (`message-lexical.ts`), so an unfinalized row can never be a
|
|
231
|
+
* candidate here. Do not copy this query shape to a reader whose id set has a
|
|
232
|
+
* different provenance; those need the filter themselves.
|
|
227
233
|
*/
|
|
228
234
|
function searchByIds(
|
|
229
235
|
messageIds: readonly string[],
|
|
@@ -17,6 +17,9 @@ export async function loadImageRefData(
|
|
|
17
17
|
const msg = db
|
|
18
18
|
.select({ content: messages.content })
|
|
19
19
|
.from(messages)
|
|
20
|
+
// No completeness predicate: refs come from the extracted memory graph,
|
|
21
|
+
// and extraction reads finalized rows only, so an unfinalized id cannot
|
|
22
|
+
// reach here. Do not copy this shape for ids with other provenance.
|
|
20
23
|
.where(eq(messages.id, ref.messageId))
|
|
21
24
|
.get();
|
|
22
25
|
if (!msg) {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../../../../config/memory-v3-gate.js";
|
|
16
16
|
import type { AssistantConfig } from "../../../../config/types.js";
|
|
17
17
|
import { enqueueMemoryJob } from "../../../../persistence/jobs-store.js";
|
|
18
|
+
import { formatRememberEntry } from "../buffer-format.js";
|
|
18
19
|
import { getWorkspaceDir } from "../paths.js";
|
|
19
20
|
import type { GraphStats } from "./store.js";
|
|
20
21
|
import {
|
|
@@ -98,36 +99,6 @@ export function handleRemember(
|
|
|
98
99
|
return { success: true, message };
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
/**
|
|
102
|
-
* Format `now` as a buffer-entry timestamp (`Mon D, h:mm AM/PM`). Exported so
|
|
103
|
-
* the memory v2 consolidation job can present its cutoff in the same shape
|
|
104
|
-
* the buffer entries use, making the agent's "timestamp ≥ cutoff" comparison
|
|
105
|
-
* unambiguous at minute precision.
|
|
106
|
-
*/
|
|
107
|
-
export function formatBufferTimestamp(now: Date): string {
|
|
108
|
-
const month = now.toLocaleString("en-US", { month: "short" });
|
|
109
|
-
const day = now.getDate();
|
|
110
|
-
const hours = now.getHours();
|
|
111
|
-
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
112
|
-
const ampm = hours >= 12 ? "PM" : "AM";
|
|
113
|
-
const displayHour = hours % 12 || 12;
|
|
114
|
-
return `${month} ${day}, ${displayHour}:${minutes} ${ampm}`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Build a timestamped bullet entry for `buffer.md` / `archive/<date>.md`.
|
|
119
|
-
*
|
|
120
|
-
* Format mirrors the long-standing v1 PKB layout so v2 buffers stay
|
|
121
|
-
* human-readable and downstream consumers (sweep, consolidation) can parse
|
|
122
|
-
* the same shape regardless of which path produced the entry.
|
|
123
|
-
*
|
|
124
|
-
* Exported so memory v2 sweep / extractor jobs format their auto-remembered
|
|
125
|
-
* entries identically to user-facing `remember()` calls.
|
|
126
|
-
*/
|
|
127
|
-
export function formatRememberEntry(content: string, now: Date): string {
|
|
128
|
-
return `- [${formatBufferTimestamp(now)}] ${content}\n`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
102
|
/**
|
|
132
103
|
* Append `entry` to `<rootDir>/buffer.md` and `<rootDir>/archive/<today>.md`,
|
|
133
104
|
* creating the archive directory and seeding the archive header if missing.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { formatRememberEntry } from "../buffer-format.js";
|
|
3
4
|
import {
|
|
4
5
|
buildPendingGraph,
|
|
5
6
|
findPendingEntryForContent,
|
|
@@ -42,6 +43,39 @@ describe("parseBufferEntries", () => {
|
|
|
42
43
|
expect(entries[1]!.text).toBe("separate fact");
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
test("a written fact stays one node even when its body is entry-shaped at column 0", () => {
|
|
47
|
+
// Round trip through the real writer. A user can dictate a fact whose body
|
|
48
|
+
// is itself a dated bullet, and without the writer nesting the body those
|
|
49
|
+
// lines are byte-identical to the start of the next fact, so one memory
|
|
50
|
+
// renders as several pending nodes in the Memory tab.
|
|
51
|
+
const entries = parseBufferEntries(
|
|
52
|
+
formatRememberEntry(
|
|
53
|
+
"Rollout plan:\n- [Jul 21, 9:00 AM] cutover window opens\n- [Jul 22, 9:00 AM] rollback deadline",
|
|
54
|
+
new Date(2026, 6, 20, 15, 15),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(entries).toHaveLength(1);
|
|
59
|
+
expect(entries[0]!.text).toBe(
|
|
60
|
+
"Rollout plan:\n- [Jul 21, 9:00 AM] cutover window opens\n- [Jul 22, 9:00 AM] rollback deadline",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an indented entry-shaped line stays inside the fact above it", () => {
|
|
65
|
+
// Only an entry's opening line sits at column 0, so an indented bullet
|
|
66
|
+
// that happens to carry a timestamp is body text the user wrote. Reading
|
|
67
|
+
// it as an entry splits one fact into two nodes in the Memory tab.
|
|
68
|
+
const entries = parseBufferEntries(
|
|
69
|
+
"- [Jul 20, 3:15 PM] Timeline for the migration:\n" +
|
|
70
|
+
" - [Jul 21, 9:00 AM] cutover window opens\n" +
|
|
71
|
+
" - [Jul 22, 9:00 AM] rollback deadline\n",
|
|
72
|
+
);
|
|
73
|
+
expect(entries).toHaveLength(1);
|
|
74
|
+
expect(entries[0]!.text).toBe(
|
|
75
|
+
"Timeline for the migration:\n- [Jul 21, 9:00 AM] cutover window opens\n- [Jul 22, 9:00 AM] rollback deadline",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
45
79
|
test("keeps interior blank lines inside a multiline fact", () => {
|
|
46
80
|
const entries = parseBufferEntries(
|
|
47
81
|
"- [Jul 20, 3:15 PM] first paragraph\n\nsecond paragraph\n- [Jul 20, 3:16 PM] next\n",
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
import { readFile } from "node:fs/promises";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
formatRememberEntry,
|
|
23
|
+
matchBufferEntryStart,
|
|
24
|
+
} from "../buffer-format.js";
|
|
22
25
|
import type { MemoryGraphEdge, MemoryGraphNode } from "./types.js";
|
|
23
26
|
|
|
24
27
|
/** Node id prefix marking a pending buffer entry (id = prefix + text hash). */
|
|
@@ -36,15 +39,6 @@ const MAX_PENDING_NODES = 200;
|
|
|
36
39
|
|
|
37
40
|
const PENDING_LABEL_MAX_CHARS = 60;
|
|
38
41
|
|
|
39
|
-
/**
|
|
40
|
-
* Matches a real entry start: `- [Mon D, h:mm AM/PM] fact`, the exact shape
|
|
41
|
-
* `formatRememberEntry` writes. The timestamp must be present and
|
|
42
|
-
* timestamp-shaped — a bullet with other bracketed text (e.g. a `- [ ]`
|
|
43
|
-
* checklist item inside a multiline fact) is a continuation, not an entry.
|
|
44
|
-
*/
|
|
45
|
-
const BUFFER_ENTRY_REGEX =
|
|
46
|
-
/^-\s+\[[A-Z][a-z]{2}\s+\d{1,2},\s+\d{1,2}:\d{2}\s+[AP]M\]\s*(.*)$/;
|
|
47
|
-
|
|
48
42
|
/** Lenient fallback for hand-written buffers: a plain bullet with no
|
|
49
43
|
* timestamped entry seen yet still counts as an entry of its own. */
|
|
50
44
|
const PLAIN_BULLET_REGEX = /^-\s+(.+)$/;
|
|
@@ -102,10 +96,12 @@ export function parseBufferEntries(content: string): PendingBufferEntry[] {
|
|
|
102
96
|
|
|
103
97
|
for (const rawLine of content.split("\n")) {
|
|
104
98
|
const line = rawLine.trimEnd();
|
|
105
|
-
|
|
99
|
+
// Matched against the untrimmed line: an indented entry-shaped bullet is
|
|
100
|
+
// body text inside the fact above it, not a fact of its own.
|
|
101
|
+
const entryStart = matchBufferEntryStart(line);
|
|
106
102
|
if (entryStart) {
|
|
107
103
|
flush();
|
|
108
|
-
current = [entryStart
|
|
104
|
+
current = [entryStart.text];
|
|
109
105
|
continue;
|
|
110
106
|
}
|
|
111
107
|
if (current !== null) {
|
|
@@ -70,10 +70,7 @@ const postCompact: HookFunction<PostCompactContext> = async (ctx) => {
|
|
|
70
70
|
// concurrent request's actor and the contact/member registry the actor
|
|
71
71
|
// context derives from can be mutated by contact tools. The snapshots are the
|
|
72
72
|
// values the turn's initial assembly resolved.
|
|
73
|
-
const trust =
|
|
74
|
-
conversation?.currentTurnTrustContext ??
|
|
75
|
-
conversation?.trustContext ??
|
|
76
|
-
FALLBACK_TURN_TRUST;
|
|
73
|
+
const trust = conversation?.getTurnOrRestingTrust() ?? FALLBACK_TURN_TRUST;
|
|
77
74
|
const actorContext = conversation?.currentTurnInboundActorContext ?? null;
|
|
78
75
|
// Render the `model_profile:` label from the turn-start notice key, using
|
|
79
76
|
// the call site self-resolved from the live conversation — the same
|