@vellumai/assistant 0.4.30 → 0.4.31
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/Dockerfile +14 -8
- package/README.md +2 -2
- package/docs/architecture/memory.md +28 -29
- package/docs/runbook-trusted-contacts.md +1 -4
- package/package.json +1 -1
- package/src/__tests__/commit-message-enrichment-service.test.ts +0 -4
- package/src/__tests__/config-schema.test.ts +0 -9
- package/src/__tests__/conflict-policy.test.ts +76 -0
- package/src/__tests__/conflict-store.test.ts +14 -20
- package/src/__tests__/contacts-tools.test.ts +8 -61
- package/src/__tests__/contradiction-checker.test.ts +5 -1
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +5 -3
- package/src/__tests__/guardian-routing-invariants.test.ts +6 -4
- package/src/__tests__/memory-lifecycle-e2e.test.ts +11 -10
- package/src/__tests__/registry.test.ts +0 -10
- package/src/__tests__/script-proxy-session-runtime.test.ts +6 -1
- package/src/__tests__/session-agent-loop.test.ts +0 -2
- package/src/__tests__/session-conflict-gate.test.ts +243 -388
- package/src/__tests__/session-profile-injection.test.ts +0 -2
- package/src/__tests__/session-runtime-assembly.test.ts +2 -3
- package/src/__tests__/session-skill-tools.test.ts +0 -49
- package/src/__tests__/session-workspace-cache-state.test.ts +0 -1
- package/src/__tests__/session-workspace-injection.test.ts +0 -1
- package/src/__tests__/session-workspace-tool-tracking.test.ts +0 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +2 -1
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -1
- package/src/approvals/guardian-decision-primitive.ts +11 -7
- package/src/approvals/guardian-request-resolvers.ts +5 -3
- package/src/calls/relay-server.ts +5 -0
- package/src/config/bundled-skills/contacts/SKILL.md +7 -18
- package/src/config/bundled-skills/contacts/TOOLS.json +4 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +2 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +6 -12
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +3 -24
- package/src/config/bundled-tool-registry.ts +0 -5
- package/src/config/memory-schema.ts +0 -10
- package/src/config/system-prompt.ts +6 -0
- package/src/contacts/contact-store.ts +36 -62
- package/src/contacts/contacts-write.ts +14 -3
- package/src/contacts/types.ts +9 -4
- package/src/daemon/handlers/config-heartbeat.ts +1 -2
- package/src/daemon/handlers/contacts.ts +2 -2
- package/src/daemon/handlers/guardian-actions.ts +1 -1
- package/src/daemon/handlers/sessions.ts +2 -1
- package/src/daemon/ipc-contract/contacts.ts +2 -2
- package/src/daemon/session-agent-loop.ts +1 -45
- package/src/daemon/session-conflict-gate.ts +21 -82
- package/src/daemon/session-memory.ts +7 -52
- package/src/daemon/session-process.ts +3 -1
- package/src/daemon/session-runtime-assembly.ts +18 -35
- package/src/heartbeat/heartbeat-service.ts +5 -1
- package/src/memory/conflict-intent.ts +3 -6
- package/src/memory/conflict-policy.ts +34 -0
- package/src/memory/conflict-store.ts +10 -18
- package/src/memory/contradiction-checker.ts +2 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/job-handlers/conflict.ts +0 -7
- package/src/memory/migrations/134-contacts-notes-column.ts +51 -0
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +31 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema.ts +1 -18
- package/src/messaging/index.ts +0 -1
- package/src/messaging/types.ts +0 -38
- package/src/runtime/guardian-action-service.ts +3 -2
- package/src/runtime/guardian-outbound-actions.ts +3 -3
- package/src/runtime/guardian-reply-router.ts +4 -4
- package/src/runtime/http-server.ts +12 -0
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +6 -3
- package/src/runtime/routes/contact-routes.ts +308 -29
- package/src/runtime/routes/conversation-routes.ts +2 -1
- package/src/runtime/routes/global-search-routes.ts +2 -2
- package/src/runtime/routes/guardian-action-routes.ts +1 -1
- package/src/runtime/routes/guardian-approval-interception.ts +2 -1
- package/src/runtime/routes/guardian-bootstrap-routes.ts +6 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +5 -1
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +2 -1
- package/src/runtime/routes/migration-routes.ts +17 -17
- package/src/workspace/git-service.ts +6 -4
- package/src/__tests__/get-weather.test.ts +0 -393
- package/src/__tests__/weather-skill-regression.test.ts +0 -276
- package/src/autonomy/autonomy-resolver.ts +0 -62
- package/src/autonomy/autonomy-store.ts +0 -138
- package/src/autonomy/disposition-mapper.ts +0 -31
- package/src/autonomy/index.ts +0 -11
- package/src/autonomy/types.ts +0 -43
- package/src/config/bundled-skills/weather/SKILL.md +0 -38
- package/src/config/bundled-skills/weather/TOOLS.json +0 -36
- package/src/config/bundled-skills/weather/icon.svg +0 -24
- package/src/config/bundled-skills/weather/tools/get-weather.ts +0 -12
- package/src/messaging/triage-engine.ts +0 -344
- package/src/tools/weather/service.ts +0 -712
package/Dockerfile
CHANGED
|
@@ -16,16 +16,21 @@ RUN apt-get update && apt-get install -y \
|
|
|
16
16
|
RUN curl -fsSL https://bun.sh/install | bash
|
|
17
17
|
ENV PATH="/root/.bun/bin:${PATH}"
|
|
18
18
|
|
|
19
|
-
#
|
|
20
|
-
COPY package.json bun.lock ./
|
|
19
|
+
# Install assistant and CLI dependencies first for cache reuse
|
|
20
|
+
COPY assistant/package.json assistant/bun.lock ./assistant/
|
|
21
|
+
RUN cd /app/assistant && bun install --frozen-lockfile
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
RUN bun install --frozen-lockfile
|
|
23
|
+
COPY cli/package.json cli/bun.lock ./cli/
|
|
24
|
+
RUN cd /app/cli && bun install --frozen-lockfile
|
|
25
|
+
|
|
26
|
+
# Copy source
|
|
27
|
+
COPY assistant ./assistant
|
|
28
|
+
COPY cli ./cli
|
|
24
29
|
|
|
25
30
|
# Final stage
|
|
26
31
|
FROM debian:trixie@sha256:3615a749858a1cba49b408fb49c37093db813321355a9ab7c1f9f4836341e9db AS runner
|
|
27
32
|
|
|
28
|
-
WORKDIR /app
|
|
33
|
+
WORKDIR /app/assistant
|
|
29
34
|
|
|
30
35
|
# Install runtime dependencies for Playwright and tree-sitter
|
|
31
36
|
RUN apt-get update && apt-get install -y \
|
|
@@ -47,6 +52,10 @@ RUN apt-get update && apt-get install -y \
|
|
|
47
52
|
COPY --from=builder /root/.bun/bin/bun /usr/local/bin/bun
|
|
48
53
|
RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx
|
|
49
54
|
|
|
55
|
+
# Install a vellum CLI launcher backed by the bundled local cli package
|
|
56
|
+
RUN printf '#!/usr/bin/env sh\nexec bun run /app/cli/src/index.ts "$@"\n' > /usr/local/bin/vellum && \
|
|
57
|
+
chmod +x /usr/local/bin/vellum
|
|
58
|
+
|
|
50
59
|
# Create non-root user that also has sudo access so it can like install stuff
|
|
51
60
|
RUN groupadd --system --gid 1001 assistant && \
|
|
52
61
|
useradd --system --uid 1001 --gid assistant --create-home --shell /bin/bash assistant && \
|
|
@@ -95,8 +104,5 @@ ENV IS_CONTAINERIZED=true
|
|
|
95
104
|
# Copy from builder
|
|
96
105
|
COPY --from=builder /app /app
|
|
97
106
|
|
|
98
|
-
# Copy source
|
|
99
|
-
COPY . .
|
|
100
|
-
|
|
101
107
|
# Run the daemon + http server
|
|
102
108
|
CMD ["bun", "run", "src/daemon/main.ts"]
|
package/README.md
CHANGED
|
@@ -481,7 +481,7 @@ bun run db:push # Apply migrations
|
|
|
481
481
|
|
|
482
482
|
```bash
|
|
483
483
|
# Build production image
|
|
484
|
-
docker build -t vellum-assistant:local
|
|
484
|
+
docker build -f assistant/Dockerfile -t vellum-assistant:local .
|
|
485
485
|
|
|
486
486
|
# Run
|
|
487
487
|
docker run --rm -p 3001:3001 \
|
|
@@ -489,7 +489,7 @@ docker run --rm -p 3001:3001 \
|
|
|
489
489
|
vellum-assistant:local
|
|
490
490
|
```
|
|
491
491
|
|
|
492
|
-
The image
|
|
492
|
+
The image exposes port `3001` and bundles the `vellum` CLI binary.
|
|
493
493
|
|
|
494
494
|
## Troubleshooting
|
|
495
495
|
|
|
@@ -41,7 +41,7 @@ graph TB
|
|
|
41
41
|
|
|
42
42
|
subgraph "Read Path (Memory Recall)"
|
|
43
43
|
QUERY["Recall Query Builder<br/>User request + compacted context summary"]
|
|
44
|
-
CONFLICT_GATE["Soft Conflict Gate<br/>dismiss non-actionable conflicts (kind + statement policy)<br/>
|
|
44
|
+
CONFLICT_GATE["Soft Conflict Gate<br/>dismiss non-actionable conflicts (kind + statement + provenance policy)<br/>attempt internal resolution from user turn<br/>relevance-based; never produces user-facing prompts"]
|
|
45
45
|
PROFILE_BUILD["Dynamic Profile Compiler<br/>active trusted profile memories<br/>user_confirmed > user_reported > assistant_inferred"]
|
|
46
46
|
PROFILE_INJECT["Inject profile context block<br/>into runtime user tail<br/>(strict token cap)"]
|
|
47
47
|
BUDGET["Dynamic Recall Budget<br/>computeRecallBudget()<br/>from prompt headroom"]
|
|
@@ -158,28 +158,26 @@ The key distinction: normal compaction is a cost-optimized background process th
|
|
|
158
158
|
|
|
159
159
|
### Memory Retrieval Config Knobs (Defaults)
|
|
160
160
|
|
|
161
|
-
| Config key | Default | Purpose
|
|
162
|
-
| --------------------------------------------------------- | ----------------------------------------------------------------: |
|
|
163
|
-
| `memory.retrieval.dynamicBudget.enabled` | `true` | Toggle per-turn recall budget calculation from live prompt headroom.
|
|
164
|
-
| `memory.retrieval.dynamicBudget.minInjectTokens` | `1200` | Lower clamp for computed recall injection budget.
|
|
165
|
-
| `memory.retrieval.dynamicBudget.maxInjectTokens` | `10000` | Upper clamp for computed recall injection budget.
|
|
166
|
-
| `memory.retrieval.dynamicBudget.targetHeadroomTokens` | `10000` | Reserved headroom to keep free for response generation/tool traces.
|
|
167
|
-
| `memory.entity.extractRelations.enabled` | `true` | Enable relation edge extraction and persistence in `memory_entity_relations`.
|
|
168
|
-
| `memory.entity.extractRelations.backfillBatchSize` | `200` | Batch size for checkpointed `backfill_entity_relations` jobs.
|
|
169
|
-
| `memory.entity.relationRetrieval.enabled` | `true` | Enable one-hop relation expansion from matched seed entities at recall time.
|
|
170
|
-
| `memory.entity.relationRetrieval.maxSeedEntities` | `8` | Maximum matched seed entities from the query.
|
|
171
|
-
| `memory.entity.relationRetrieval.maxNeighborEntities` | `20` | Maximum unique neighbor entities expanded from relation edges.
|
|
172
|
-
| `memory.entity.relationRetrieval.maxEdges` | `40` | Maximum relation edges traversed during expansion.
|
|
173
|
-
| `memory.entity.relationRetrieval.neighborScoreMultiplier` | `0.7` | Downweight multiplier for relation-expanded candidates vs direct entity hits.
|
|
174
|
-
| `memory.conflicts.enabled` | `true` | Enable soft conflict gate for unresolved `memory_item_conflicts`.
|
|
175
|
-
| `memory.conflicts.
|
|
176
|
-
| `memory.conflicts.
|
|
177
|
-
| `memory.conflicts.
|
|
178
|
-
| `memory.conflicts.
|
|
179
|
-
| `memory.
|
|
180
|
-
| `memory.
|
|
181
|
-
| `memory.profile.enabled` | `true` | Enable dynamic profile compilation from active trusted profile/preference/constraint/instruction memories. |
|
|
182
|
-
| `memory.profile.maxInjectTokens` | `800` | Hard token cap enforced by `ProfileCompiler` when generating the runtime profile block. |
|
|
161
|
+
| Config key | Default | Purpose |
|
|
162
|
+
| --------------------------------------------------------- | ----------------------------------------------------------------: | ------------------------------------------------------------------------------------------------------------------ |
|
|
163
|
+
| `memory.retrieval.dynamicBudget.enabled` | `true` | Toggle per-turn recall budget calculation from live prompt headroom. |
|
|
164
|
+
| `memory.retrieval.dynamicBudget.minInjectTokens` | `1200` | Lower clamp for computed recall injection budget. |
|
|
165
|
+
| `memory.retrieval.dynamicBudget.maxInjectTokens` | `10000` | Upper clamp for computed recall injection budget. |
|
|
166
|
+
| `memory.retrieval.dynamicBudget.targetHeadroomTokens` | `10000` | Reserved headroom to keep free for response generation/tool traces. |
|
|
167
|
+
| `memory.entity.extractRelations.enabled` | `true` | Enable relation edge extraction and persistence in `memory_entity_relations`. |
|
|
168
|
+
| `memory.entity.extractRelations.backfillBatchSize` | `200` | Batch size for checkpointed `backfill_entity_relations` jobs. |
|
|
169
|
+
| `memory.entity.relationRetrieval.enabled` | `true` | Enable one-hop relation expansion from matched seed entities at recall time. |
|
|
170
|
+
| `memory.entity.relationRetrieval.maxSeedEntities` | `8` | Maximum matched seed entities from the query. |
|
|
171
|
+
| `memory.entity.relationRetrieval.maxNeighborEntities` | `20` | Maximum unique neighbor entities expanded from relation edges. |
|
|
172
|
+
| `memory.entity.relationRetrieval.maxEdges` | `40` | Maximum relation edges traversed during expansion. |
|
|
173
|
+
| `memory.entity.relationRetrieval.neighborScoreMultiplier` | `0.7` | Downweight multiplier for relation-expanded candidates vs direct entity hits. |
|
|
174
|
+
| `memory.conflicts.enabled` | `true` | Enable soft conflict gate for unresolved `memory_item_conflicts`. |
|
|
175
|
+
| `memory.conflicts.resolverLlmTimeoutMs` | `12000` | Timeout bound for clarification resolver LLM fallback. |
|
|
176
|
+
| `memory.conflicts.relevanceThreshold` | `0.3` | Similarity threshold for deciding whether a pending conflict is relevant to the current request. |
|
|
177
|
+
| `memory.conflicts.gateMode` | `'soft'` | Conflict gate strategy. Currently only `'soft'` is supported (resolves conflicts internally without user prompts). |
|
|
178
|
+
| `memory.conflicts.conflictableKinds` | `['preference', 'profile', 'constraint', 'instruction', 'style']` | Memory item kinds eligible for conflict detection. Items with kinds outside this list are auto-dismissed. |
|
|
179
|
+
| `memory.profile.enabled` | `true` | Enable dynamic profile compilation from active trusted profile/preference/constraint/instruction memories. |
|
|
180
|
+
| `memory.profile.maxInjectTokens` | `800` | Hard token cap enforced by `ProfileCompiler` when generating the runtime profile block. |
|
|
183
181
|
|
|
184
182
|
### Memory Recall Debugging Playbook
|
|
185
183
|
|
|
@@ -211,7 +209,7 @@ The key distinction: normal compaction is a cost-optimized background process th
|
|
|
211
209
|
stateDiagram-v2
|
|
212
210
|
[*] --> ActiveItems : extract_items/check_contradictions
|
|
213
211
|
ActiveItems --> PendingConflict : ambiguous_contradiction\n(candidate -> pending_clarification)
|
|
214
|
-
PendingConflict --> PendingConflict :
|
|
212
|
+
PendingConflict --> PendingConflict : internal evaluation\n(relevance check, no user prompt)
|
|
215
213
|
PendingConflict --> Dismissed : non-actionable\n(kind policy + transient statement filter)
|
|
216
214
|
PendingConflict --> ResolvedKeepExisting : clarification resolver\n+ applyConflictResolution
|
|
217
215
|
PendingConflict --> ResolvedKeepCandidate : clarification resolver\n+ applyConflictResolution
|
|
@@ -224,6 +222,10 @@ stateDiagram-v2
|
|
|
224
222
|
SupersededItems --> CleanupItems : cleanup_stale_superseded_items
|
|
225
223
|
```
|
|
226
224
|
|
|
225
|
+
### Internal-Only Conflict Handling
|
|
226
|
+
|
|
227
|
+
Memory conflict resolution is entirely internal and non-interruptive. The conflict gate evaluates pending conflicts on each turn, dismisses non-actionable ones (based on kind policy, statement eligibility, coherence, and provenance), and attempts resolution when user input looks like a natural clarification. At no point does the conflict system produce user-facing clarification prompts, inject conflict instructions into the assistant's response, or block the user's request. The user is never aware that a conflict exists; the runtime response path always continues answering the user's actual request. This invariant is enforced across the conflict gate (`session-conflict-gate.ts`), session memory (`session-memory.ts`), session agent loop (`session-agent-loop.ts`), and runtime assembly (`session-runtime-assembly.ts`).
|
|
228
|
+
|
|
227
229
|
Runtime profile flow (per turn):
|
|
228
230
|
|
|
229
231
|
1. `ProfileCompiler` builds a trusted profile block from active `profile` / `preference` / `constraint` / `instruction` items under strict token cap.
|
|
@@ -238,7 +240,7 @@ Two trust gates enforce trust-class-based access control over the memory pipelin
|
|
|
238
240
|
|
|
239
241
|
- **Write gate** (`indexer.ts`): The `extract_items` and `resolve_conflicts` jobs only run for messages from trusted actors (guardian or undefined provenance). Messages from untrusted actors (`trusted_contact`, `unknown`) are still segmented and embedded — so they appear in conversation context — but no profile extraction or conflict resolution is triggered. This prevents untrusted channels from injecting or mutating long-term memory items.
|
|
240
242
|
|
|
241
|
-
- **Read gate** (`session-memory.ts`): When the current session's actor is untrusted, the memory recall pipeline returns a no-op context — no recall injection, no dynamic profile, no conflict
|
|
243
|
+
- **Read gate** (`session-memory.ts`): When the current session's actor is untrusted, the memory recall pipeline returns a no-op context — no recall injection, no dynamic profile, no conflict resolution. This ensures untrusted actors cannot surface or exploit previously extracted memory.
|
|
242
244
|
|
|
243
245
|
Trust policy is **cross-channel and trust-class-based**: decisions use `trustContext.trustClass`, not the channel string. Desktop/IPC sessions default to `trustClass: 'guardian'`. External channels (Telegram, SMS, WhatsApp, voice) provide explicit trust context via the resolver. Messages without provenance metadata are treated as trusted (guardian); all new messages carry provenance.
|
|
244
246
|
|
|
@@ -381,7 +383,7 @@ graph TB
|
|
|
381
383
|
- **Prepend, not append**: The workspace block is prepended to the user message content so that Anthropic cache breakpoints continue to land on the trailing user text block, preserving prompt cache efficiency.
|
|
382
384
|
- **Runtime-only**: The injected `<workspace_top_level>` block is stripped from `this.messages` after the agent loop completes. It never persists in conversation history or the database.
|
|
383
385
|
- **Dirty-refresh**: The scanner runs once on the first turn, then only re-runs after a successful mutation tool (`file_edit`, `file_write`, `bash`). Failed tool results do not trigger a refresh.
|
|
384
|
-
- **Injection ordering**: Workspace context is injected after other runtime injections (
|
|
386
|
+
- **Injection ordering**: Workspace context is injected after other runtime injections (active surface, etc.) via `applyRuntimeInjections`, but because it is **prepended** to content blocks, it appears first in the final message.
|
|
385
387
|
|
|
386
388
|
### Cache compatibility
|
|
387
389
|
|
|
@@ -506,7 +508,6 @@ graph TB
|
|
|
506
508
|
10. **Provider-aware commit message generation (optional)**: When `workspaceGit.commitMessageLLM.enabled` is `true`, turn-boundary commits attempt to generate a descriptive commit message using the configured LLM provider before falling back to deterministic messages. The feature ships disabled by default and is designed to never degrade turn completion guarantees.
|
|
507
509
|
|
|
508
510
|
**Commit message LLM fallback chain**: The generator runs a sequence of pre-flight checks before calling the LLM. Each check that fails produces a machine-readable `llmFallbackReason` in the structured log output and immediately returns a deterministic message. The checks, in order:
|
|
509
|
-
|
|
510
511
|
1. `disabled` — `commitMessageLLM.enabled` is `false` or `useConfiguredProvider` is `false`
|
|
511
512
|
2. `missing_provider_api_key` — the configured provider's API key is not set in `config.apiKeys` (skipped for keyless providers like Ollama that run without an API key)
|
|
512
513
|
3. `breaker_open` — the generator's internal circuit breaker is open after consecutive LLM failures (exponential backoff)
|
|
@@ -516,11 +517,9 @@ graph TB
|
|
|
516
517
|
7. `timeout` — the LLM call exceeded `timeoutMs` (AbortController fires)
|
|
517
518
|
8. `provider_error` — the provider threw an exception during the LLM call
|
|
518
519
|
9. `invalid_output` — the LLM returned empty text, the literal string "FALLBACK", or total output > 500 chars
|
|
519
|
-
|
|
520
520
|
- **Subject line capping**: If the LLM subject line exceeds 72 chars it is deterministically truncated to 72 chars. This is NOT treated as a failure (no breaker penalty, no deterministic fallback).
|
|
521
521
|
|
|
522
522
|
**Fast model resolution**: The LLM call uses a small/fast model to minimize latency and cost. The model is resolved **before** any provider call as a pre-flight check:
|
|
523
|
-
|
|
524
523
|
- If `commitMessageLLM.providerFastModelOverrides[provider]` is set, that model is used.
|
|
525
524
|
- Otherwise, a built-in default is used: `anthropic` -> `claude-haiku-4-5-20251001`, `openai` -> `gpt-4o-mini`, `gemini` -> `gemini-2.0-flash`.
|
|
526
525
|
- If the configured provider has no override and no built-in default (e.g., `ollama`, `fireworks`, `openrouter`), the generator returns a deterministic fallback with reason `missing_fast_model` and the provider is never called. To enable LLM commit messages for such providers, set `providerFastModelOverrides[provider]` to the desired model.
|
|
@@ -62,10 +62,7 @@ Response shape:
|
|
|
62
62
|
{
|
|
63
63
|
"id": "uuid",
|
|
64
64
|
"displayName": "Alice",
|
|
65
|
-
"
|
|
66
|
-
"importance": 0.5,
|
|
67
|
-
"responseExpectation": null,
|
|
68
|
-
"preferredTone": null,
|
|
65
|
+
"notes": null,
|
|
69
66
|
"lastInteraction": 1700000000000,
|
|
70
67
|
"interactionCount": 12,
|
|
71
68
|
"createdAt": 1699000000000,
|
package/package.json
CHANGED
|
@@ -42,10 +42,6 @@ describe("CommitEnrichmentService", () => {
|
|
|
42
42
|
beforeEach(() => {
|
|
43
43
|
_resetGitServiceRegistry();
|
|
44
44
|
_resetEnrichmentService();
|
|
45
|
-
// Previous tests' enrichment jobs may leave a stale index.lock if
|
|
46
|
-
// the git process exits but the lock file isn't flushed before the
|
|
47
|
-
// next test runs git operations in the shared testDir.
|
|
48
|
-
rmSync(join(testDir, ".git", "index.lock"), { force: true });
|
|
49
45
|
});
|
|
50
46
|
|
|
51
47
|
afterEach(async () => {
|
|
@@ -168,10 +168,8 @@ describe("AssistantConfigSchema", () => {
|
|
|
168
168
|
expect(result.memory.conflicts).toEqual({
|
|
169
169
|
enabled: true,
|
|
170
170
|
gateMode: "soft",
|
|
171
|
-
reaskCooldownTurns: 3,
|
|
172
171
|
resolverLlmTimeoutMs: 12000,
|
|
173
172
|
relevanceThreshold: 0.3,
|
|
174
|
-
askOnIrrelevantTurns: false,
|
|
175
173
|
conflictableKinds: [
|
|
176
174
|
"preference",
|
|
177
175
|
"profile",
|
|
@@ -189,13 +187,6 @@ describe("AssistantConfigSchema", () => {
|
|
|
189
187
|
expect(result.success).toBe(false);
|
|
190
188
|
});
|
|
191
189
|
|
|
192
|
-
test("rejects invalid memory.conflicts.askOnIrrelevantTurns", () => {
|
|
193
|
-
const result = AssistantConfigSchema.safeParse({
|
|
194
|
-
memory: { conflicts: { askOnIrrelevantTurns: 123 } },
|
|
195
|
-
});
|
|
196
|
-
expect(result.success).toBe(false);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
190
|
test("rejects invalid memory.conflicts.conflictableKinds entry", () => {
|
|
200
191
|
const result = AssistantConfigSchema.safeParse({
|
|
201
192
|
memory: { conflicts: { conflictableKinds: ["invalid_kind"] } },
|
|
@@ -3,9 +3,11 @@ import { describe, expect, test } from "bun:test";
|
|
|
3
3
|
import {
|
|
4
4
|
isConflictKindEligible,
|
|
5
5
|
isConflictKindPairEligible,
|
|
6
|
+
isConflictUserEvidenced,
|
|
6
7
|
isDurableInstructionStatement,
|
|
7
8
|
isStatementConflictEligible,
|
|
8
9
|
isTransientTrackingStatement,
|
|
10
|
+
isUserEvidencedVerificationState,
|
|
9
11
|
} from "../memory/conflict-policy.js";
|
|
10
12
|
|
|
11
13
|
describe("conflict-policy", () => {
|
|
@@ -190,4 +192,78 @@ describe("conflict-policy", () => {
|
|
|
190
192
|
).toBe(true);
|
|
191
193
|
});
|
|
192
194
|
});
|
|
195
|
+
|
|
196
|
+
describe("isUserEvidencedVerificationState", () => {
|
|
197
|
+
test("accepts user_reported", () => {
|
|
198
|
+
expect(isUserEvidencedVerificationState("user_reported")).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("accepts user_confirmed", () => {
|
|
202
|
+
expect(isUserEvidencedVerificationState("user_confirmed")).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("accepts legacy_import", () => {
|
|
206
|
+
expect(isUserEvidencedVerificationState("legacy_import")).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("rejects assistant_inferred", () => {
|
|
210
|
+
expect(isUserEvidencedVerificationState("assistant_inferred")).toBe(
|
|
211
|
+
false,
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("rejects unknown states", () => {
|
|
216
|
+
expect(isUserEvidencedVerificationState("")).toBe(false);
|
|
217
|
+
expect(isUserEvidencedVerificationState("auto_detected")).toBe(false);
|
|
218
|
+
expect(isUserEvidencedVerificationState("pending")).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("isConflictUserEvidenced", () => {
|
|
223
|
+
test("returns true when existing side is user-evidenced", () => {
|
|
224
|
+
expect(
|
|
225
|
+
isConflictUserEvidenced("user_reported", "assistant_inferred"),
|
|
226
|
+
).toBe(true);
|
|
227
|
+
expect(
|
|
228
|
+
isConflictUserEvidenced("user_confirmed", "assistant_inferred"),
|
|
229
|
+
).toBe(true);
|
|
230
|
+
expect(
|
|
231
|
+
isConflictUserEvidenced("legacy_import", "assistant_inferred"),
|
|
232
|
+
).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("returns true when candidate side is user-evidenced", () => {
|
|
236
|
+
expect(
|
|
237
|
+
isConflictUserEvidenced("assistant_inferred", "user_reported"),
|
|
238
|
+
).toBe(true);
|
|
239
|
+
expect(
|
|
240
|
+
isConflictUserEvidenced("assistant_inferred", "user_confirmed"),
|
|
241
|
+
).toBe(true);
|
|
242
|
+
expect(
|
|
243
|
+
isConflictUserEvidenced("assistant_inferred", "legacy_import"),
|
|
244
|
+
).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("returns true when both sides are user-evidenced", () => {
|
|
248
|
+
expect(isConflictUserEvidenced("user_reported", "user_confirmed")).toBe(
|
|
249
|
+
true,
|
|
250
|
+
);
|
|
251
|
+
expect(isConflictUserEvidenced("legacy_import", "user_reported")).toBe(
|
|
252
|
+
true,
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("returns false when neither side is user-evidenced", () => {
|
|
257
|
+
expect(
|
|
258
|
+
isConflictUserEvidenced("assistant_inferred", "assistant_inferred"),
|
|
259
|
+
).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("returns false for unknown states on both sides", () => {
|
|
263
|
+
expect(isConflictUserEvidenced("auto_detected", "pending")).toBe(false);
|
|
264
|
+
expect(
|
|
265
|
+
isConflictUserEvidenced("assistant_inferred", "auto_detected"),
|
|
266
|
+
).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
193
269
|
});
|
|
@@ -33,7 +33,6 @@ import {
|
|
|
33
33
|
getPendingConflictByPair,
|
|
34
34
|
listPendingConflictDetails,
|
|
35
35
|
listPendingConflicts,
|
|
36
|
-
markConflictAsked,
|
|
37
36
|
resolveConflict,
|
|
38
37
|
} from "../memory/conflict-store.js";
|
|
39
38
|
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
@@ -60,6 +59,10 @@ function resetTables() {
|
|
|
60
59
|
function insertItemPair(
|
|
61
60
|
suffix: string,
|
|
62
61
|
scopeId = "default",
|
|
62
|
+
opts?: {
|
|
63
|
+
existingVerificationState?: string;
|
|
64
|
+
candidateVerificationState?: string;
|
|
65
|
+
},
|
|
63
66
|
): { existingItemId: string; candidateItemId: string } {
|
|
64
67
|
const db = getDb();
|
|
65
68
|
const now = Date.now();
|
|
@@ -76,7 +79,8 @@ function insertItemPair(
|
|
|
76
79
|
confidence: 0.8,
|
|
77
80
|
importance: 0.5,
|
|
78
81
|
fingerprint: `fp-existing-${suffix}`,
|
|
79
|
-
verificationState:
|
|
82
|
+
verificationState:
|
|
83
|
+
opts?.existingVerificationState ?? "assistant_inferred",
|
|
80
84
|
scopeId,
|
|
81
85
|
firstSeenAt: now,
|
|
82
86
|
lastSeenAt: now,
|
|
@@ -90,7 +94,8 @@ function insertItemPair(
|
|
|
90
94
|
confidence: 0.8,
|
|
91
95
|
importance: 0.5,
|
|
92
96
|
fingerprint: `fp-candidate-${suffix}`,
|
|
93
|
-
verificationState:
|
|
97
|
+
verificationState:
|
|
98
|
+
opts?.candidateVerificationState ?? "assistant_inferred",
|
|
94
99
|
scopeId,
|
|
95
100
|
firstSeenAt: now,
|
|
96
101
|
lastSeenAt: now,
|
|
@@ -218,24 +223,11 @@ describe("conflict-store", () => {
|
|
|
218
223
|
expect(pendingDefault[0].status).toBe("pending_clarification");
|
|
219
224
|
});
|
|
220
225
|
|
|
221
|
-
test("
|
|
222
|
-
const pair = insertItemPair("
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
existingItemId: pair.existingItemId,
|
|
226
|
-
candidateItemId: pair.candidateItemId,
|
|
227
|
-
relationship: "ambiguous_contradiction",
|
|
226
|
+
test("listPendingConflictDetails joins current statements and verification states", () => {
|
|
227
|
+
const pair = insertItemPair("details", "workspace-a", {
|
|
228
|
+
existingVerificationState: "user_confirmed",
|
|
229
|
+
candidateVerificationState: "assistant_inferred",
|
|
228
230
|
});
|
|
229
|
-
|
|
230
|
-
const askedAt = 1_734_000_000_000;
|
|
231
|
-
expect(markConflictAsked(conflict.id, askedAt)).toBe(true);
|
|
232
|
-
const updated = getConflictById(conflict.id);
|
|
233
|
-
expect(updated?.lastAskedAt).toBe(askedAt);
|
|
234
|
-
expect(updated?.updatedAt).toBe(askedAt);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test("listPendingConflictDetails joins current statements", () => {
|
|
238
|
-
const pair = insertItemPair("details", "workspace-a");
|
|
239
231
|
createOrUpdatePendingConflict({
|
|
240
232
|
scopeId: "workspace-a",
|
|
241
233
|
existingItemId: pair.existingItemId,
|
|
@@ -250,6 +242,8 @@ describe("conflict-store", () => {
|
|
|
250
242
|
expect(details[0].candidateStatement).toBe("Candidate statement details");
|
|
251
243
|
expect(details[0].existingKind).toBe("fact");
|
|
252
244
|
expect(details[0].candidateKind).toBe("fact");
|
|
245
|
+
expect(details[0].existingVerificationState).toBe("user_confirmed");
|
|
246
|
+
expect(details[0].candidateVerificationState).toBe("assistant_inferred");
|
|
253
247
|
});
|
|
254
248
|
|
|
255
249
|
test("applyConflictResolution keeps candidate and resolves conflict row", () => {
|
|
@@ -140,17 +140,14 @@ describe("contact_upsert tool", () => {
|
|
|
140
140
|
expect(result.isError).toBe(false);
|
|
141
141
|
expect(result.content).toContain("Created contact");
|
|
142
142
|
expect(result.content).toContain("Alice");
|
|
143
|
-
expect(result.content).toContain("Importance: 0.50");
|
|
144
143
|
});
|
|
145
144
|
|
|
146
145
|
test("creates a contact with all fields", async () => {
|
|
147
146
|
const result = await executeContactUpsert(
|
|
148
147
|
{
|
|
149
148
|
display_name: "Bob",
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
response_expectation: "within_hours",
|
|
153
|
-
preferred_tone: "professional",
|
|
149
|
+
notes:
|
|
150
|
+
"Colleague at Acme Corp, prefers professional tone, responds within hours",
|
|
154
151
|
channels: [
|
|
155
152
|
{ type: "email", address: "bob@example.com", is_primary: true },
|
|
156
153
|
{ type: "slack", address: "@bob" },
|
|
@@ -161,10 +158,7 @@ describe("contact_upsert tool", () => {
|
|
|
161
158
|
|
|
162
159
|
expect(result.isError).toBe(false);
|
|
163
160
|
expect(result.content).toContain("Bob");
|
|
164
|
-
expect(result.content).toContain("
|
|
165
|
-
expect(result.content).toContain("0.80");
|
|
166
|
-
expect(result.content).toContain("within_hours");
|
|
167
|
-
expect(result.content).toContain("professional");
|
|
161
|
+
expect(result.content).toContain("Notes: Colleague at Acme Corp");
|
|
168
162
|
expect(result.content).toContain("email: bob@example.com");
|
|
169
163
|
expect(result.content).toContain("slack: @bob");
|
|
170
164
|
});
|
|
@@ -185,7 +179,7 @@ describe("contact_upsert tool", () => {
|
|
|
185
179
|
{
|
|
186
180
|
id: contactId,
|
|
187
181
|
display_name: "Charlie Updated",
|
|
188
|
-
|
|
182
|
+
notes: "Updated notes for Charlie",
|
|
189
183
|
},
|
|
190
184
|
ctx,
|
|
191
185
|
);
|
|
@@ -193,7 +187,7 @@ describe("contact_upsert tool", () => {
|
|
|
193
187
|
expect(updateResult.isError).toBe(false);
|
|
194
188
|
expect(updateResult.content).toContain("Updated contact");
|
|
195
189
|
expect(updateResult.content).toContain("Charlie Updated");
|
|
196
|
-
expect(updateResult.content).toContain("
|
|
190
|
+
expect(updateResult.content).toContain("Notes: Updated notes for Charlie");
|
|
197
191
|
});
|
|
198
192
|
|
|
199
193
|
test("auto-matches by channel address on create", async () => {
|
|
@@ -239,36 +233,6 @@ describe("contact_upsert tool", () => {
|
|
|
239
233
|
expect(result.isError).toBe(true);
|
|
240
234
|
expect(result.content).toContain("display_name is required");
|
|
241
235
|
});
|
|
242
|
-
|
|
243
|
-
test("rejects importance out of range", async () => {
|
|
244
|
-
const result = await executeContactUpsert(
|
|
245
|
-
{
|
|
246
|
-
display_name: "Test",
|
|
247
|
-
importance: 1.5,
|
|
248
|
-
},
|
|
249
|
-
ctx,
|
|
250
|
-
);
|
|
251
|
-
|
|
252
|
-
expect(result.isError).toBe(true);
|
|
253
|
-
expect(result.content).toContain(
|
|
254
|
-
"importance must be a number between 0 and 1",
|
|
255
|
-
);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test("rejects negative importance", async () => {
|
|
259
|
-
const result = await executeContactUpsert(
|
|
260
|
-
{
|
|
261
|
-
display_name: "Test",
|
|
262
|
-
importance: -0.1,
|
|
263
|
-
},
|
|
264
|
-
ctx,
|
|
265
|
-
);
|
|
266
|
-
|
|
267
|
-
expect(result.isError).toBe(true);
|
|
268
|
-
expect(result.content).toContain(
|
|
269
|
-
"importance must be a number between 0 and 1",
|
|
270
|
-
);
|
|
271
|
-
});
|
|
272
236
|
});
|
|
273
237
|
|
|
274
238
|
// ── contact_search ──────────────────────────────────────────────────
|
|
@@ -305,23 +269,6 @@ describe("contact_search tool", () => {
|
|
|
305
269
|
expect(result.content).toContain("Charlie");
|
|
306
270
|
});
|
|
307
271
|
|
|
308
|
-
test("searches by relationship", async () => {
|
|
309
|
-
await executeContactUpsert(
|
|
310
|
-
{ display_name: "Diana", relationship: "friend" },
|
|
311
|
-
ctx,
|
|
312
|
-
);
|
|
313
|
-
await executeContactUpsert(
|
|
314
|
-
{ display_name: "Eve", relationship: "colleague" },
|
|
315
|
-
ctx,
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
const result = await executeContactSearch({ relationship: "friend" }, ctx);
|
|
319
|
-
|
|
320
|
-
expect(result.isError).toBe(false);
|
|
321
|
-
expect(result.content).toContain("Diana");
|
|
322
|
-
expect(result.content).not.toContain("Eve");
|
|
323
|
-
});
|
|
324
|
-
|
|
325
272
|
test("returns no results message when nothing matches", async () => {
|
|
326
273
|
await executeContactUpsert({ display_name: "Existing" }, ctx);
|
|
327
274
|
|
|
@@ -380,7 +327,7 @@ describe("contact_merge tool", () => {
|
|
|
380
327
|
const r1 = await executeContactUpsert(
|
|
381
328
|
{
|
|
382
329
|
display_name: "Alice (Email)",
|
|
383
|
-
|
|
330
|
+
notes: "Prefers email",
|
|
384
331
|
channels: [{ type: "email", address: "alice@example.com" }],
|
|
385
332
|
},
|
|
386
333
|
ctx,
|
|
@@ -388,7 +335,7 @@ describe("contact_merge tool", () => {
|
|
|
388
335
|
const r2 = await executeContactUpsert(
|
|
389
336
|
{
|
|
390
337
|
display_name: "Alice (Slack)",
|
|
391
|
-
|
|
338
|
+
notes: "Active on Slack",
|
|
392
339
|
channels: [{ type: "slack", address: "@alice" }],
|
|
393
340
|
},
|
|
394
341
|
ctx,
|
|
@@ -407,7 +354,7 @@ describe("contact_merge tool", () => {
|
|
|
407
354
|
|
|
408
355
|
expect(result.isError).toBe(false);
|
|
409
356
|
expect(result.content).toContain("Merged");
|
|
410
|
-
expect(result.content).toContain("
|
|
357
|
+
expect(result.content).toContain("Notes: Prefers email\nActive on Slack"); // concatenated notes
|
|
411
358
|
expect(result.content).toContain("email: alice@example.com");
|
|
412
359
|
expect(result.content).toContain("slack: @alice");
|
|
413
360
|
|
|
@@ -201,7 +201,11 @@ describe("checkContradictions", () => {
|
|
|
201
201
|
expect(conflicts[0].existingItemId).toBe("item-existing-ambiguous");
|
|
202
202
|
expect(conflicts[0].candidateItemId).toBe("item-candidate-ambiguous");
|
|
203
203
|
expect(conflicts[0].relationship).toBe("ambiguous_contradiction");
|
|
204
|
-
expect(conflicts[0].clarificationQuestion).toContain(
|
|
204
|
+
expect(conflicts[0].clarificationQuestion).toContain("Pending conflict:");
|
|
205
|
+
expect(conflicts[0].clarificationQuestion).not.toContain(
|
|
206
|
+
"I have conflicting notes",
|
|
207
|
+
);
|
|
208
|
+
expect(conflicts[0].clarificationQuestion).not.toContain(
|
|
205
209
|
"Which one is correct?",
|
|
206
210
|
);
|
|
207
211
|
});
|
|
@@ -71,7 +71,8 @@ const TEST_PRINCIPAL_ID = "test-principal-id";
|
|
|
71
71
|
|
|
72
72
|
function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
73
73
|
return {
|
|
74
|
-
|
|
74
|
+
actorPrincipalId: TEST_PRINCIPAL_ID,
|
|
75
|
+
actorExternalUserId: "guardian-1",
|
|
75
76
|
channel: "telegram",
|
|
76
77
|
guardianPrincipalId: TEST_PRINCIPAL_ID,
|
|
77
78
|
...overrides,
|
|
@@ -80,7 +81,8 @@ function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
|
80
81
|
|
|
81
82
|
function trustedActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
82
83
|
return {
|
|
83
|
-
|
|
84
|
+
actorPrincipalId: TEST_PRINCIPAL_ID,
|
|
85
|
+
actorExternalUserId: undefined,
|
|
84
86
|
channel: "desktop",
|
|
85
87
|
guardianPrincipalId: TEST_PRINCIPAL_ID,
|
|
86
88
|
...overrides,
|
|
@@ -254,7 +256,7 @@ describe("applyCanonicalGuardianDecision", () => {
|
|
|
254
256
|
|
|
255
257
|
expect(result.applied).toBe(true);
|
|
256
258
|
if (!result.applied) return;
|
|
257
|
-
// No grant minted because trusted actor has no
|
|
259
|
+
// No grant minted because trusted actor has no actorExternalUserId
|
|
258
260
|
expect(result.grantMinted).toBe(false);
|
|
259
261
|
});
|
|
260
262
|
|
|
@@ -94,7 +94,8 @@ const TEST_PRINCIPAL_ID = "test-principal-id";
|
|
|
94
94
|
|
|
95
95
|
function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
96
96
|
return {
|
|
97
|
-
|
|
97
|
+
actorPrincipalId: TEST_PRINCIPAL_ID,
|
|
98
|
+
actorExternalUserId: "guardian-1",
|
|
98
99
|
channel: "telegram",
|
|
99
100
|
guardianPrincipalId: TEST_PRINCIPAL_ID,
|
|
100
101
|
...overrides,
|
|
@@ -103,7 +104,8 @@ function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
|
103
104
|
|
|
104
105
|
function trustedActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
105
106
|
return {
|
|
106
|
-
|
|
107
|
+
actorPrincipalId: TEST_PRINCIPAL_ID,
|
|
108
|
+
actorExternalUserId: undefined,
|
|
107
109
|
channel: "desktop",
|
|
108
110
|
guardianPrincipalId: TEST_PRINCIPAL_ID,
|
|
109
111
|
...overrides,
|
|
@@ -1212,13 +1214,13 @@ describe("routing invariant: destination hints do not bypass tool_approval princ
|
|
|
1212
1214
|
});
|
|
1213
1215
|
|
|
1214
1216
|
// No pendingRequestIds passed — identity-based fallback uses
|
|
1215
|
-
// actor.
|
|
1217
|
+
// actor.actorExternalUserId which does not match any request's
|
|
1216
1218
|
// guardianExternalUserId (since it's null).
|
|
1217
1219
|
const result = await routeGuardianReply(
|
|
1218
1220
|
replyCtx({
|
|
1219
1221
|
messageText: "approve",
|
|
1220
1222
|
channel: "telegram",
|
|
1221
|
-
actor: guardianActor({
|
|
1223
|
+
actor: guardianActor({ actorExternalUserId: "guardian-tg-user" }),
|
|
1222
1224
|
conversationId: "conv-guardian-chat",
|
|
1223
1225
|
// pendingRequestIds: undefined — no delivery hints
|
|
1224
1226
|
approvalConversationGenerator: undefined,
|