@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.
Files changed (91) hide show
  1. package/Dockerfile +14 -8
  2. package/README.md +2 -2
  3. package/docs/architecture/memory.md +28 -29
  4. package/docs/runbook-trusted-contacts.md +1 -4
  5. package/package.json +1 -1
  6. package/src/__tests__/commit-message-enrichment-service.test.ts +0 -4
  7. package/src/__tests__/config-schema.test.ts +0 -9
  8. package/src/__tests__/conflict-policy.test.ts +76 -0
  9. package/src/__tests__/conflict-store.test.ts +14 -20
  10. package/src/__tests__/contacts-tools.test.ts +8 -61
  11. package/src/__tests__/contradiction-checker.test.ts +5 -1
  12. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +5 -3
  13. package/src/__tests__/guardian-routing-invariants.test.ts +6 -4
  14. package/src/__tests__/memory-lifecycle-e2e.test.ts +11 -10
  15. package/src/__tests__/registry.test.ts +0 -10
  16. package/src/__tests__/script-proxy-session-runtime.test.ts +6 -1
  17. package/src/__tests__/session-agent-loop.test.ts +0 -2
  18. package/src/__tests__/session-conflict-gate.test.ts +243 -388
  19. package/src/__tests__/session-profile-injection.test.ts +0 -2
  20. package/src/__tests__/session-runtime-assembly.test.ts +2 -3
  21. package/src/__tests__/session-skill-tools.test.ts +0 -49
  22. package/src/__tests__/session-workspace-cache-state.test.ts +0 -1
  23. package/src/__tests__/session-workspace-injection.test.ts +0 -1
  24. package/src/__tests__/session-workspace-tool-tracking.test.ts +0 -1
  25. package/src/__tests__/tool-grant-request-escalation.test.ts +2 -1
  26. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -1
  27. package/src/approvals/guardian-decision-primitive.ts +11 -7
  28. package/src/approvals/guardian-request-resolvers.ts +5 -3
  29. package/src/calls/relay-server.ts +5 -0
  30. package/src/config/bundled-skills/contacts/SKILL.md +7 -18
  31. package/src/config/bundled-skills/contacts/TOOLS.json +4 -20
  32. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +2 -4
  33. package/src/config/bundled-skills/contacts/tools/contact-search.ts +6 -12
  34. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +3 -24
  35. package/src/config/bundled-tool-registry.ts +0 -5
  36. package/src/config/memory-schema.ts +0 -10
  37. package/src/config/system-prompt.ts +6 -0
  38. package/src/contacts/contact-store.ts +36 -62
  39. package/src/contacts/contacts-write.ts +14 -3
  40. package/src/contacts/types.ts +9 -4
  41. package/src/daemon/handlers/config-heartbeat.ts +1 -2
  42. package/src/daemon/handlers/contacts.ts +2 -2
  43. package/src/daemon/handlers/guardian-actions.ts +1 -1
  44. package/src/daemon/handlers/sessions.ts +2 -1
  45. package/src/daemon/ipc-contract/contacts.ts +2 -2
  46. package/src/daemon/session-agent-loop.ts +1 -45
  47. package/src/daemon/session-conflict-gate.ts +21 -82
  48. package/src/daemon/session-memory.ts +7 -52
  49. package/src/daemon/session-process.ts +3 -1
  50. package/src/daemon/session-runtime-assembly.ts +18 -35
  51. package/src/heartbeat/heartbeat-service.ts +5 -1
  52. package/src/memory/conflict-intent.ts +3 -6
  53. package/src/memory/conflict-policy.ts +34 -0
  54. package/src/memory/conflict-store.ts +10 -18
  55. package/src/memory/contradiction-checker.ts +2 -2
  56. package/src/memory/db-init.ts +4 -0
  57. package/src/memory/job-handlers/conflict.ts +0 -7
  58. package/src/memory/migrations/134-contacts-notes-column.ts +51 -0
  59. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +31 -0
  60. package/src/memory/migrations/index.ts +2 -0
  61. package/src/memory/schema.ts +1 -18
  62. package/src/messaging/index.ts +0 -1
  63. package/src/messaging/types.ts +0 -38
  64. package/src/runtime/guardian-action-service.ts +3 -2
  65. package/src/runtime/guardian-outbound-actions.ts +3 -3
  66. package/src/runtime/guardian-reply-router.ts +4 -4
  67. package/src/runtime/http-server.ts +12 -0
  68. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +6 -3
  69. package/src/runtime/routes/contact-routes.ts +308 -29
  70. package/src/runtime/routes/conversation-routes.ts +2 -1
  71. package/src/runtime/routes/global-search-routes.ts +2 -2
  72. package/src/runtime/routes/guardian-action-routes.ts +1 -1
  73. package/src/runtime/routes/guardian-approval-interception.ts +2 -1
  74. package/src/runtime/routes/guardian-bootstrap-routes.ts +6 -1
  75. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +5 -1
  76. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +2 -1
  77. package/src/runtime/routes/migration-routes.ts +17 -17
  78. package/src/workspace/git-service.ts +6 -4
  79. package/src/__tests__/get-weather.test.ts +0 -393
  80. package/src/__tests__/weather-skill-regression.test.ts +0 -276
  81. package/src/autonomy/autonomy-resolver.ts +0 -62
  82. package/src/autonomy/autonomy-store.ts +0 -138
  83. package/src/autonomy/disposition-mapper.ts +0 -31
  84. package/src/autonomy/index.ts +0 -11
  85. package/src/autonomy/types.ts +0 -43
  86. package/src/config/bundled-skills/weather/SKILL.md +0 -38
  87. package/src/config/bundled-skills/weather/TOOLS.json +0 -36
  88. package/src/config/bundled-skills/weather/icon.svg +0 -24
  89. package/src/config/bundled-skills/weather/tools/get-weather.ts +0 -12
  90. package/src/messaging/triage-engine.ts +0 -344
  91. 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
- # Copy package files
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
- # Install dependencies
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 assistant
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 runs as non-root user `assistant` (uid 1001) and exposes port `3001`.
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/>resolve pending conflicts from user turn<br/>relevance + cooldown + configurable ask behavior"]
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.reaskCooldownTurns` | `3` | Minimum turn distance before re-asking the same conflict clarification. |
176
- | `memory.conflicts.resolverLlmTimeoutMs` | `12000` | Timeout bound for clarification resolver LLM fallback. |
177
- | `memory.conflicts.relevanceThreshold` | `0.3` | Similarity threshold for deciding whether a pending conflict is relevant to the current request. |
178
- | `memory.conflicts.gateMode` | `'soft'` | Conflict gate strategy. Currently only `'soft'` is supported (asks the user inline). |
179
- | `memory.conflicts.askOnIrrelevantTurns` | `false` | When `true`, soft-inject irrelevant conflict clarifications into every turn. When `false` (default), only ask when the conflict is topically relevant. |
180
- | `memory.conflicts.conflictableKinds` | `['preference', 'profile', 'constraint', 'instruction', 'style']` | Memory item kinds eligible for conflict detection. Items with kinds outside this list are auto-dismissed. |
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 : soft gate ask\n(reask cooldown + relevance + askOnIrrelevantTurns)
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 clarification prompts. This ensures untrusted actors cannot surface or exploit previously extracted memory.
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 (soft conflict instruction, active surface) via `applyRuntimeInjections`, but because it is **prepended** to content blocks, it appears first in the final message.
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
- "relationship": "friend",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -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: "assistant_inferred",
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: "assistant_inferred",
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("markConflictAsked updates lastAskedAt", () => {
222
- const pair = insertItemPair("asked");
223
- const conflict = createOrUpdatePendingConflict({
224
- scopeId: "default",
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
- relationship: "colleague",
151
- importance: 0.8,
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("colleague");
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
- importance: 0.9,
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("0.90");
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
- importance: 0.7,
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
- importance: 0.9,
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("Importance: 0.90"); // takes higher importance
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
- externalUserId: "guardian-1",
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
- externalUserId: undefined,
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 externalUserId
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
- externalUserId: "guardian-1",
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
- externalUserId: undefined,
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.externalUserId which does not match any request's
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({ externalUserId: "guardian-tg-user" }),
1223
+ actor: guardianActor({ actorExternalUserId: "guardian-tg-user" }),
1222
1224
  conversationId: "conv-guardian-chat",
1223
1225
  // pendingRequestIds: undefined — no delivery hints
1224
1226
  approvalConversationGenerator: undefined,