@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
@@ -33,10 +33,7 @@ function parseContact(row: typeof contacts.$inferSelect): Contact {
33
33
  return {
34
34
  id: row.id,
35
35
  displayName: row.displayName,
36
- relationship: row.relationship,
37
- importance: row.importance,
38
- responseExpectation: row.responseExpectation,
39
- preferredTone: row.preferredTone,
36
+ notes: row.notes,
40
37
  lastInteraction: row.lastInteraction,
41
38
  interactionCount: row.interactionCount,
42
39
  createdAt: row.createdAt,
@@ -153,10 +150,7 @@ export function getChannelById(channelId: string): ContactChannel | null {
153
150
  export function upsertContact(params: {
154
151
  id?: string;
155
152
  displayName: string;
156
- relationship?: string | null;
157
- importance?: number;
158
- responseExpectation?: string | null;
159
- preferredTone?: string | null;
153
+ notes?: string | null;
160
154
  role?: ContactRole;
161
155
  contactType?: ContactType;
162
156
  principalId?: string | null;
@@ -178,24 +172,9 @@ export function upsertContact(params: {
178
172
  if (existing) {
179
173
  const updateSet: Record<string, unknown> = {
180
174
  displayName: params.displayName,
181
- relationship:
182
- params.relationship !== undefined
183
- ? params.relationship
184
- : existing.relationship,
185
- importance:
186
- params.importance !== undefined
187
- ? params.importance
188
- : existing.importance,
189
- responseExpectation:
190
- params.responseExpectation !== undefined
191
- ? params.responseExpectation
192
- : existing.responseExpectation,
193
- preferredTone:
194
- params.preferredTone !== undefined
195
- ? params.preferredTone
196
- : existing.preferredTone,
197
175
  updatedAt: now,
198
176
  };
177
+ if (params.notes !== undefined) updateSet.notes = params.notes;
199
178
  if (params.role !== undefined) updateSet.role = params.role;
200
179
  if (params.contactType !== undefined)
201
180
  updateSet.contactType = params.contactType;
@@ -213,6 +192,7 @@ export function upsertContact(params: {
213
192
  syncChannels(contactId, params.channels, now);
214
193
  }
215
194
 
195
+ emitContactChange();
216
196
  return { ...getContactInternal(contactId)!, created: false };
217
197
  }
218
198
  }
@@ -259,14 +239,7 @@ export function upsertContact(params: {
259
239
  displayName: params.displayName,
260
240
  updatedAt: now,
261
241
  };
262
- if (params.relationship !== undefined)
263
- updateSet.relationship = params.relationship;
264
- if (params.importance !== undefined)
265
- updateSet.importance = params.importance;
266
- if (params.responseExpectation !== undefined)
267
- updateSet.responseExpectation = params.responseExpectation;
268
- if (params.preferredTone !== undefined)
269
- updateSet.preferredTone = params.preferredTone;
242
+ if (params.notes !== undefined) updateSet.notes = params.notes;
270
243
  if (params.role !== undefined) updateSet.role = params.role;
271
244
  if (params.contactType !== undefined)
272
245
  updateSet.contactType = params.contactType;
@@ -281,6 +254,7 @@ export function upsertContact(params: {
281
254
  .run();
282
255
 
283
256
  syncChannels(contactId, params.channels, now);
257
+ emitContactChange();
284
258
  return { ...getContactInternal(contactId)!, created: false };
285
259
  }
286
260
  }
@@ -292,10 +266,7 @@ export function upsertContact(params: {
292
266
  .values({
293
267
  id: contactId,
294
268
  displayName: params.displayName,
295
- relationship: params.relationship ?? null,
296
- importance: params.importance ?? 0.5,
297
- responseExpectation: params.responseExpectation ?? null,
298
- preferredTone: params.preferredTone ?? null,
269
+ notes: params.notes ?? null,
299
270
  lastInteraction: null,
300
271
  interactionCount: 0,
301
272
  role: params.role ?? "contact",
@@ -311,6 +282,7 @@ export function upsertContact(params: {
311
282
  syncChannels(contactId, params.channels, now);
312
283
  }
313
284
 
285
+ emitContactChange();
314
286
  return { ...getContactInternal(contactId)!, created: true };
315
287
  }
316
288
 
@@ -436,7 +408,6 @@ export function searchContacts(params: {
436
408
  query?: string;
437
409
  channelAddress?: string;
438
410
  channelType?: string;
439
- relationship?: string;
440
411
  role?: ContactRole;
441
412
  contactType?: ContactType;
442
413
  limit?: number;
@@ -491,7 +462,7 @@ export function searchContacts(params: {
491
462
  }
492
463
 
493
464
  // Search by channel type alone (no address)
494
- if (params.channelType && !params.query && !params.relationship) {
465
+ if (params.channelType && !params.query) {
495
466
  const channelRows = db
496
467
  .select({ contactId: contactChannels.contactId })
497
468
  .from(contactChannels)
@@ -525,7 +496,7 @@ export function searchContacts(params: {
525
496
  return results;
526
497
  }
527
498
 
528
- // Search by display name and/or relationship, optionally filtered by channelType
499
+ // Search by display name, optionally filtered by channelType
529
500
  const conditions = [
530
501
  or(
531
502
  eq(contacts.assistantId, params.assistantId),
@@ -534,20 +505,11 @@ export function searchContacts(params: {
534
505
  ];
535
506
  if (params.query) {
536
507
  const sanitized = escapeLike(params.query);
537
- if (
538
- !sanitized &&
539
- !params.relationship &&
540
- !params.role &&
541
- !params.contactType
542
- )
543
- return [];
508
+ if (!sanitized && !params.role && !params.contactType) return [];
544
509
  if (sanitized) {
545
510
  conditions.push(like(contacts.displayName, `%${sanitized}%`));
546
511
  }
547
512
  }
548
- if (params.relationship) {
549
- conditions.push(eq(contacts.relationship, params.relationship));
550
- }
551
513
  if (params.role) {
552
514
  conditions.push(eq(contacts.role, params.role));
553
515
  }
@@ -569,7 +531,7 @@ export function searchContacts(params: {
569
531
  .from(contacts)
570
532
  .innerJoin(contactChannels, eq(contacts.id, contactChannels.contactId))
571
533
  .where(whereClause)
572
- .orderBy(desc(contacts.importance), desc(contacts.lastInteraction))
534
+ .orderBy(desc(contacts.updatedAt), desc(contacts.lastInteraction))
573
535
  .all();
574
536
 
575
537
  const contactIds = [...new Set(rows.map((r) => r.contactId))];
@@ -590,7 +552,7 @@ export function searchContacts(params: {
590
552
  .select()
591
553
  .from(contacts)
592
554
  .where(whereClause)
593
- .orderBy(desc(contacts.importance), desc(contacts.lastInteraction))
555
+ .orderBy(desc(contacts.updatedAt), desc(contacts.lastInteraction))
594
556
  .limit(limit)
595
557
  .all();
596
558
 
@@ -617,7 +579,7 @@ export function listContacts(
617
579
  .where(conditions.length === 1 ? conditions[0] : and(...conditions))
618
580
  .orderBy(
619
581
  sql`${contacts.role} = 'guardian' DESC`,
620
- desc(contacts.importance),
582
+ desc(contacts.updatedAt),
621
583
  desc(contacts.lastInteraction),
622
584
  )
623
585
  .limit(effectiveLimit)
@@ -626,9 +588,9 @@ export function listContacts(
626
588
  }
627
589
 
628
590
  /**
629
- * Merge two contacts into one. The surviving contact keeps the higher importance,
630
- * more recent interaction timestamp, and all channels from both contacts.
631
- * The donor contact is deleted after merging.
591
+ * Merge two contacts into one. The surviving contact keeps the
592
+ * more recent interaction timestamp, concatenated notes, and all channels
593
+ * from both contacts. The donor contact is deleted after merging.
632
594
  */
633
595
  export function mergeContacts(
634
596
  keepId: string,
@@ -673,7 +635,6 @@ export function mergeContacts(
673
635
  if (!merge) throw new Error(`Contact "${mergeId}" not found`);
674
636
 
675
637
  // Resolve merged field values — pick the better/more recent value
676
- const mergedImportance = Math.max(keep.importance, merge.importance);
677
638
  const mergedInteractionCount =
678
639
  keep.interactionCount + merge.interactionCount;
679
640
  const mergedLastInteraction =
@@ -681,14 +642,9 @@ export function mergeContacts(
681
642
 
682
643
  tx.update(contacts)
683
644
  .set({
684
- importance: mergedImportance,
685
645
  interactionCount: mergedInteractionCount,
686
646
  lastInteraction: mergedLastInteraction,
687
- // Prefer keep's values, fall back to merge's
688
- relationship: keep.relationship ?? merge.relationship,
689
- responseExpectation:
690
- keep.responseExpectation ?? merge.responseExpectation,
691
- preferredTone: keep.preferredTone ?? merge.preferredTone,
647
+ notes: [keep.notes, merge.notes].filter(Boolean).join("\n") || null,
692
648
  updatedAt: now,
693
649
  // Rebind legacy null-scoped contacts to prevent cross-assistant leakage
694
650
  ...(keep.assistantId == null ? { assistantId } : {}),
@@ -728,6 +684,7 @@ export function mergeContacts(
728
684
  tx.delete(contacts).where(eq(contacts.id, mergeId)).run();
729
685
  });
730
686
 
687
+ emitContactChange();
731
688
  return getContactInternal(keepId)!;
732
689
  }
733
690
 
@@ -1026,6 +983,23 @@ export function updateChannelLastSeenById(channelId: string): void {
1026
983
  .run();
1027
984
  }
1028
985
 
986
+ /**
987
+ * Atomically increment interactionCount and set lastInteraction on a contact.
988
+ * Optimized for the hot path — single UPDATE with no prior SELECT.
989
+ */
990
+ export function updateContactInteraction(contactId: string): void {
991
+ const db = getDb();
992
+ const now = Date.now();
993
+ db.update(contacts)
994
+ .set({
995
+ lastInteraction: now,
996
+ interactionCount: sql`${contacts.interactionCount} + 1`,
997
+ updatedAt: now,
998
+ })
999
+ .where(eq(contacts.id, contactId))
1000
+ .run();
1001
+ }
1002
+
1029
1003
  // ── Assistant Contact Metadata ──────────────────────────────────────
1030
1004
 
1031
1005
  function parseAssistantMetadata(
@@ -19,6 +19,7 @@ import {
19
19
  getContactInternal,
20
20
  updateChannelLastSeenById,
21
21
  updateChannelStatus,
22
+ updateContactInteraction,
22
23
  upsertContact,
23
24
  } from "./contact-store.js";
24
25
  import type {
@@ -78,6 +79,7 @@ export function createGuardianBinding(params: {
78
79
  upsertContact({
79
80
  displayName,
80
81
  role: "guardian",
82
+ notes: "guardian",
81
83
  principalId: params.guardianPrincipalId,
82
84
  assistantId: params.assistantId,
83
85
  channels: [
@@ -109,7 +111,6 @@ export function createGuardianBinding(params: {
109
111
  updatedAt: now,
110
112
  };
111
113
 
112
- emitContactChange();
113
114
  return result;
114
115
  }
115
116
 
@@ -204,8 +205,6 @@ export function upsertMember(params: {
204
205
  externalChatId: params.externalChatId,
205
206
  });
206
207
 
207
- emitContactChange();
208
-
209
208
  if (contactResult) {
210
209
  return { contact: contactResult.contact, channel: contactResult.channel };
211
210
  }
@@ -285,3 +284,15 @@ export function touchChannelLastSeen(channelId: string): void {
285
284
  log.warn({ err }, "Failed to update channel lastSeenAt");
286
285
  }
287
286
  }
287
+
288
+ /**
289
+ * Increment the interaction count and update lastInteraction on a contact.
290
+ * Expects a plain contact UUID (Contact.id).
291
+ */
292
+ export function touchContactInteraction(contactId: string): void {
293
+ try {
294
+ updateContactInteraction(contactId);
295
+ } catch (err) {
296
+ log.warn({ err }, "Failed to update contact interaction stats");
297
+ }
298
+ }
@@ -28,16 +28,21 @@ export type AssistantContactMetadata =
28
28
  export interface Contact {
29
29
  id: string;
30
30
  displayName: string;
31
- relationship: string | null;
32
- importance: number;
33
- responseExpectation: string | null;
34
- preferredTone: string | null;
31
+ /** Free-text notes about this contact (e.g. relationship, communication preferences). */
32
+ notes: string | null;
35
33
  lastInteraction: number | null;
36
34
  interactionCount: number;
37
35
  createdAt: number;
38
36
  updatedAt: number;
39
37
  role: ContactRole;
40
38
  contactType: ContactType;
39
+ /**
40
+ * Internal auth identity (e.g. "vellum-principal-<uuid>"). Only meaningful
41
+ * for guardian contacts — it ties the contact record to the auth layer so
42
+ * the system can verify "this API caller IS this guardian" via JWT
43
+ * actorPrincipalId. Always null for non-guardian contacts, which are
44
+ * identified by channel address instead.
45
+ */
41
46
  principalId: string | null;
42
47
  assistantId: string | null;
43
48
  }
@@ -218,8 +218,7 @@ export function handleHeartbeatRunNow(
218
218
  ctx.send(socket, {
219
219
  type: "heartbeat_run_now_response",
220
220
  success: false,
221
- error:
222
- "Heartbeat skipped (a previous run is still active)",
221
+ error: "Heartbeat skipped (a previous run is still active)",
223
222
  });
224
223
  }
225
224
  })
@@ -38,8 +38,8 @@ function toContactPayload(contact: ContactWithChannels): ContactPayload {
38
38
  id: contact.id,
39
39
  displayName: contact.displayName,
40
40
  role: contact.role,
41
- relationship: contact.relationship ?? undefined,
42
- importance: contact.importance,
41
+ notes: contact.notes ?? undefined,
42
+ contactType: contact.contactType ?? undefined,
43
43
  lastInteraction: contact.lastInteraction ?? undefined,
44
44
  interactionCount: contact.interactionCount,
45
45
  channels: contact.channels.map(toChannelPayload),
@@ -38,7 +38,7 @@ export const guardianActionsHandlers = defineHandlers({
38
38
  conversationId: msg.conversationId,
39
39
  channel: "vellum",
40
40
  actorContext: {
41
- externalUserId: localTrustCtx.guardianExternalUserId,
41
+ actorPrincipalId: localTrustCtx.guardianExternalUserId,
42
42
  guardianPrincipalId: localTrustCtx.guardianPrincipalId ?? undefined,
43
43
  },
44
44
  });
@@ -895,7 +895,8 @@ export async function handleUserMessage(
895
895
  messageText: messageText.trim(),
896
896
  channel: ipcChannel,
897
897
  actor: {
898
- externalUserId: localCtx.guardianExternalUserId,
898
+ actorPrincipalId: localCtx.guardianPrincipalId ?? undefined,
899
+ actorExternalUserId: localCtx.guardianExternalUserId,
899
900
  channel: ipcChannel,
900
901
  guardianPrincipalId: localCtx.guardianPrincipalId ?? undefined,
901
902
  },
@@ -40,8 +40,8 @@ export interface ContactPayload {
40
40
  id: string;
41
41
  displayName: string;
42
42
  role: "guardian" | "contact";
43
- relationship?: string;
44
- importance: number;
43
+ notes?: string;
44
+ contactType?: string;
45
45
  lastInteraction?: number;
46
46
  interactionCount: number;
47
47
  channels: ContactChannelPayload[];
@@ -453,50 +453,7 @@ export async function runAgentLoopImpl(
453
453
  onEvent,
454
454
  );
455
455
 
456
- if (memoryResult.conflictClarification) {
457
- const loopChannelMeta = {
458
- ...provenanceFromTrustContext(ctx.trustContext),
459
- userMessageChannel: capturedTurnChannelContext.userMessageChannel,
460
- assistantMessageChannel:
461
- capturedTurnChannelContext.assistantMessageChannel,
462
- userMessageInterface: capturedTurnInterfaceContext.userMessageInterface,
463
- assistantMessageInterface:
464
- capturedTurnInterfaceContext.assistantMessageInterface,
465
- };
466
- const assistantMessage = createAssistantMessage(
467
- memoryResult.conflictClarification,
468
- );
469
- await conversationStore.addMessage(
470
- ctx.conversationId,
471
- "assistant",
472
- JSON.stringify(assistantMessage.content),
473
- loopChannelMeta,
474
- );
475
- ctx.messages.push(assistantMessage);
476
- onEvent({
477
- type: "assistant_text_delta",
478
- text: memoryResult.conflictClarification,
479
- sessionId: ctx.conversationId,
480
- });
481
- ctx.traceEmitter.emit(
482
- "message_complete",
483
- "Conflict clarification requested (relevant)",
484
- {
485
- requestId: reqId,
486
- status: "info",
487
- attributes: { conflictGate: "relevant" },
488
- },
489
- );
490
- onEvent({ type: "message_complete", sessionId: ctx.conversationId });
491
- return;
492
- }
493
-
494
- const {
495
- recall,
496
- dynamicProfile,
497
- softConflictInstruction,
498
- recallInjectionStrategy,
499
- } = memoryResult;
456
+ const { recall, dynamicProfile, recallInjectionStrategy } = memoryResult;
500
457
  runMessages = memoryResult.runMessages;
501
458
 
502
459
  // Build active surface context
@@ -585,7 +542,6 @@ export async function runAgentLoopImpl(
585
542
 
586
543
  // Shared injection options — reused whenever we need to re-inject after reduction.
587
544
  const injectionOpts = {
588
- softConflictInstruction,
589
545
  activeSurface,
590
546
  workspaceTopLevelContext: ctx.workspaceTopLevelContext,
591
547
  channelCapabilities: ctx.channelCapabilities ?? null,
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Conflict-gate logic extracted from Session.
3
3
  *
4
- * Decides whether to ask the user about a pending memory conflict (relevant gate)
5
- * or skip entirely.
4
+ * Handles pending memory conflicts internally: dismisses non-user-evidenced
5
+ * and non-actionable conflicts, and attempts resolution when the user's reply
6
+ * looks like an explicit clarification with topical relevance. Never produces
7
+ * user-facing clarification text.
6
8
  */
7
9
 
8
10
  import { resolveConflictClarification } from "../memory/clarification-resolver.js";
@@ -14,47 +16,34 @@ import {
14
16
  } from "../memory/conflict-intent.js";
15
17
  import {
16
18
  isConflictKindPairEligible,
19
+ isConflictUserEvidenced,
17
20
  isStatementConflictEligible,
18
21
  } from "../memory/conflict-policy.js";
19
22
  import type { PendingConflictDetail } from "../memory/conflict-store.js";
20
23
  import {
21
24
  applyConflictResolution,
22
25
  listPendingConflictDetails,
23
- markConflictAsked,
24
26
  resolveConflict,
25
27
  } from "../memory/conflict-store.js";
26
28
 
27
- export interface ConflictGateDecision {
28
- question: string;
29
- relevant: boolean;
30
- }
31
-
32
29
  export class ConflictGate {
33
- private turnCounter = 0;
34
- private lastAskedTurn = new Map<string, number>();
35
-
36
30
  async evaluate(
37
31
  userMessage: string,
38
32
  conflictConfig: {
39
33
  enabled: boolean;
40
34
  gateMode: string;
41
35
  relevanceThreshold: number;
42
- reaskCooldownTurns: number;
43
36
  resolverLlmTimeoutMs: number;
44
- askOnIrrelevantTurns: boolean;
45
37
  conflictableKinds: readonly string[];
46
38
  },
47
39
  scopeId = "default",
48
- ): Promise<ConflictGateDecision | null> {
49
- if (!conflictConfig.enabled || conflictConfig.gateMode !== "soft")
50
- return null;
40
+ ): Promise<void> {
41
+ if (!conflictConfig.enabled || conflictConfig.gateMode !== "soft") return;
51
42
 
52
- this.turnCounter += 1;
53
- const threshold = conflictConfig.relevanceThreshold;
54
- const cooldownTurns = Math.max(1, conflictConfig.reaskCooldownTurns);
55
43
  const pendingBeforeResolve = listPendingConflictDetails(scopeId, 50);
56
44
 
57
- // Dismiss non-actionable conflicts (kind/statement policy or incoherent pair)
45
+ // Dismiss non-actionable conflicts (kind/statement policy, incoherent pair,
46
+ // or assistant-inferred-only provenance with no user evidence)
58
47
  const dismissedIds = new Set<string>();
59
48
  for (const conflict of pendingBeforeResolve) {
60
49
  const dismissReason = this.getDismissReason(
@@ -73,13 +62,15 @@ export class ConflictGate {
73
62
  const actionablePending = pendingBeforeResolve.filter(
74
63
  (c) => !dismissedIds.has(c.id),
75
64
  );
65
+
66
+ // Attempt resolution only for explicit clarification-like replies with
67
+ // topical relevance to the conflict statements
76
68
  const clarificationReply = looksLikeClarificationReply(userMessage);
77
69
  const candidatesBeforeResolve = actionablePending.filter((conflict) => {
78
70
  const relevance = computeConflictRelevance(userMessage, conflict);
79
71
  return shouldAttemptConflictResolution({
80
72
  clarificationReply,
81
73
  relevance,
82
- wasRecentlyAsked: this.wasRecentlyAsked(conflict.id, cooldownTurns),
83
74
  });
84
75
  });
85
76
  await this.resolvePendingConflicts(
@@ -87,45 +78,6 @@ export class ConflictGate {
87
78
  conflictConfig.resolverLlmTimeoutMs,
88
79
  candidatesBeforeResolve,
89
80
  );
90
-
91
- const pending = listPendingConflictDetails(scopeId, 50);
92
- if (pending.length === 0) return null;
93
-
94
- const scored = pending.map((conflict) => ({
95
- conflict,
96
- relevance: computeConflictRelevance(userMessage, conflict),
97
- }));
98
- // Try relevant conflicts first
99
- const askable = scored
100
- .filter((entry) => entry.relevance >= threshold)
101
- .find((entry) => this.shouldAsk(entry.conflict.id, cooldownTurns));
102
-
103
- // If no relevant conflict to ask and askOnIrrelevantTurns is enabled, try ones
104
- // below the threshold (including zero-relevance). Zero-relevance conflicts are
105
- // surfaced but not tracked as asked, preventing wasRecentlyAsked from triggering
106
- // heuristic resolution on subsequent unrelated turns.
107
- const candidateToAsk =
108
- askable ??
109
- (conflictConfig.askOnIrrelevantTurns
110
- ? scored.find(
111
- (entry) =>
112
- entry.relevance < threshold &&
113
- this.shouldAsk(entry.conflict.id, cooldownTurns),
114
- )
115
- : undefined);
116
-
117
- if (!candidateToAsk) return null;
118
-
119
- if (askable || candidateToAsk.relevance > 0) {
120
- this.lastAskedTurn.set(candidateToAsk.conflict.id, this.turnCounter);
121
- markConflictAsked(candidateToAsk.conflict.id);
122
- }
123
- return {
124
- question:
125
- candidateToAsk.conflict.clarificationQuestion ??
126
- buildFallbackConflictQuestion(candidateToAsk.conflict),
127
- relevant: candidateToAsk.relevance >= threshold,
128
- };
129
81
  }
130
82
 
131
83
  private async resolvePendingConflicts(
@@ -156,18 +108,6 @@ export class ConflictGate {
156
108
  }
157
109
  }
158
110
 
159
- private shouldAsk(conflictId: string, cooldownTurns: number): boolean {
160
- const lastAsked = this.lastAskedTurn.get(conflictId);
161
- if (lastAsked === undefined) return true;
162
- return this.turnCounter - lastAsked >= cooldownTurns;
163
- }
164
-
165
- private wasRecentlyAsked(conflictId: string, cooldownTurns: number): boolean {
166
- const lastAsked = this.lastAskedTurn.get(conflictId);
167
- if (lastAsked === undefined) return false;
168
- return this.turnCounter - lastAsked <= cooldownTurns;
169
- }
170
-
171
111
  /**
172
112
  * Returns a dismissal reason if the conflict should be dismissed, or null if actionable.
173
113
  */
@@ -211,18 +151,17 @@ export class ConflictGate {
211
151
  ) {
212
152
  return "Dismissed by conflict policy (incoherent — zero statement overlap).";
213
153
  }
154
+ // Dismiss conflicts where neither side has user-evidenced provenance
155
+ if (
156
+ !isConflictUserEvidenced(
157
+ conflict.existingVerificationState,
158
+ conflict.candidateVerificationState,
159
+ )
160
+ ) {
161
+ return "Dismissed by conflict policy (no user-evidenced provenance).";
162
+ }
214
163
  return null;
215
164
  }
216
165
  }
217
166
 
218
- export function buildFallbackConflictQuestion(
219
- conflict: PendingConflictDetail,
220
- ): string {
221
- return [
222
- "I have two conflicting notes and need your confirmation.",
223
- `A) ${conflict.existingStatement}`,
224
- `B) ${conflict.candidateStatement}`,
225
- "Which one should I keep?",
226
- ].join("\n");
227
- }
228
167
  export { computeConflictRelevance, looksLikeClarificationReply };