vellum 0.2.13 → 0.2.14
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/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, deterministic policy helpers for memory conflict eligibility.
|
|
3
|
+
* Used by contradiction checker, session conflict gate, and background resolver.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ConflictPolicyConfig {
|
|
7
|
+
conflictableKinds: readonly string[];
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns true when the given memory item kind is eligible to participate
|
|
13
|
+
* in conflict detection according to the current policy.
|
|
14
|
+
*/
|
|
15
|
+
export function isConflictKindEligible(kind: string, config: ConflictPolicyConfig): boolean {
|
|
16
|
+
return config.conflictableKinds.includes(kind);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns true when both sides of a potential conflict pair are kind-eligible.
|
|
21
|
+
*/
|
|
22
|
+
export function isConflictKindPairEligible(
|
|
23
|
+
existingKind: string,
|
|
24
|
+
candidateKind: string,
|
|
25
|
+
config: ConflictPolicyConfig,
|
|
26
|
+
): boolean {
|
|
27
|
+
return isConflictKindEligible(existingKind, config) && isConflictKindEligible(candidateKind, config);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Transient statement classification ─────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const PR_URL_PATTERN = /github\.com\/[^/]+\/[^/]+\/pull\/\d+/i;
|
|
33
|
+
const ISSUE_TICKET_PATTERN = /\b(?:issue|pr|ticket|pull request)\s*#?\d+/i;
|
|
34
|
+
const TRACKING_LANGUAGE_PATTERN = /\b(?:this pr|that issue|while we wait|currently tracking)\b/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns true when a statement looks like a transient tracking note
|
|
38
|
+
* (PR URLs, issue references, short-lived progress notes) rather than
|
|
39
|
+
* a durable user preference or instruction.
|
|
40
|
+
*/
|
|
41
|
+
export function isTransientTrackingStatement(statement: string): boolean {
|
|
42
|
+
if (PR_URL_PATTERN.test(statement)) return true;
|
|
43
|
+
if (ISSUE_TICKET_PATTERN.test(statement)) return true;
|
|
44
|
+
if (TRACKING_LANGUAGE_PATTERN.test(statement)) return true;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DURABLE_INSTRUCTION_CUES = /\b(?:always|never|default|every time|by default|style|format|tone|convention|standard)\b/i;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns true when a statement contains strong durable instruction cues,
|
|
52
|
+
* suggesting it represents a persistent user preference or style rule.
|
|
53
|
+
*/
|
|
54
|
+
export function isDurableInstructionStatement(statement: string): boolean {
|
|
55
|
+
return DURABLE_INSTRUCTION_CUES.test(statement);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns true when a statement of the given kind is eligible to participate
|
|
60
|
+
* in conflict detection at the statement level. This combines kind eligibility
|
|
61
|
+
* with statement-level durability heuristics.
|
|
62
|
+
*
|
|
63
|
+
* For instruction/style kinds: requires positive durable cues and no transient cues.
|
|
64
|
+
* For other eligible kinds: rejects if transient tracking cues dominate.
|
|
65
|
+
*/
|
|
66
|
+
export function isStatementConflictEligible(kind: string, statement: string, config?: ConflictPolicyConfig): boolean {
|
|
67
|
+
if (config && !isConflictKindEligible(kind, config)) return false;
|
|
68
|
+
if (isTransientTrackingStatement(statement)) return false;
|
|
69
|
+
if (kind === 'instruction' || kind === 'style') {
|
|
70
|
+
return isDurableInstructionStatement(statement);
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
@@ -50,6 +50,8 @@ export interface ResolveConflictInput {
|
|
|
50
50
|
export interface PendingConflictDetail extends MemoryItemConflict {
|
|
51
51
|
existingStatement: string;
|
|
52
52
|
candidateStatement: string;
|
|
53
|
+
existingKind: string;
|
|
54
|
+
candidateKind: string;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export type ConflictResolutionAction = 'keep_existing' | 'keep_candidate' | 'merge';
|
|
@@ -170,7 +172,9 @@ export function listPendingConflictDetails(scopeId: string, limit = 100): Pendin
|
|
|
170
172
|
c.created_at,
|
|
171
173
|
c.updated_at,
|
|
172
174
|
existing_item.statement AS existing_statement,
|
|
173
|
-
candidate_item.statement AS candidate_statement
|
|
175
|
+
candidate_item.statement AS candidate_statement,
|
|
176
|
+
existing_item.kind AS existing_kind,
|
|
177
|
+
candidate_item.kind AS candidate_kind
|
|
174
178
|
FROM memory_item_conflicts c
|
|
175
179
|
INNER JOIN memory_items existing_item ON existing_item.id = c.existing_item_id
|
|
176
180
|
INNER JOIN memory_items candidate_item ON candidate_item.id = c.candidate_item_id
|
|
@@ -193,6 +197,8 @@ export function listPendingConflictDetails(scopeId: string, limit = 100): Pendin
|
|
|
193
197
|
updated_at: number;
|
|
194
198
|
existing_statement: string;
|
|
195
199
|
candidate_statement: string;
|
|
200
|
+
existing_kind: string;
|
|
201
|
+
candidate_kind: string;
|
|
196
202
|
}>;
|
|
197
203
|
|
|
198
204
|
return rows.map((row) => ({
|
|
@@ -210,6 +216,8 @@ export function listPendingConflictDetails(scopeId: string, limit = 100): Pendin
|
|
|
210
216
|
updatedAt: row.updated_at,
|
|
211
217
|
existingStatement: row.existing_statement,
|
|
212
218
|
candidateStatement: row.candidate_statement,
|
|
219
|
+
existingKind: row.existing_kind,
|
|
220
|
+
candidateKind: row.candidate_kind,
|
|
213
221
|
}));
|
|
214
222
|
}
|
|
215
223
|
|
|
@@ -3,6 +3,8 @@ import { eq } from 'drizzle-orm';
|
|
|
3
3
|
import { getConfig } from '../config/loader.js';
|
|
4
4
|
import { getLogger } from '../util/logger.js';
|
|
5
5
|
import { truncate } from '../util/truncate.js';
|
|
6
|
+
import { areStatementsCoherent } from './conflict-intent.js';
|
|
7
|
+
import { isConflictKindEligible, isStatementConflictEligible } from './conflict-policy.js';
|
|
6
8
|
import { createOrUpdatePendingConflict } from './conflict-store.js';
|
|
7
9
|
import { getDb } from './db.js';
|
|
8
10
|
import { enqueueMemoryJob } from './jobs-store.js';
|
|
@@ -61,7 +63,33 @@ export async function checkContradictions(newItemId: string): Promise<void> {
|
|
|
61
63
|
return;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
if (!isConflictKindEligible(newItem.kind, config.memory.conflicts)) {
|
|
67
|
+
log.debug({ newItemId, kind: newItem.kind }, 'Skipping contradiction check — kind not eligible for conflicts');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Skip if the new item's statement is transient/non-durable
|
|
72
|
+
if (!isStatementConflictEligible(newItem.kind, newItem.statement, config.memory.conflicts)) {
|
|
73
|
+
log.debug({ newItemId, kind: newItem.kind }, 'Skipping contradiction check — statement is transient or non-durable');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
for (const existing of candidates) {
|
|
78
|
+
// Skip candidate if its statement is transient/non-durable
|
|
79
|
+
if (!isStatementConflictEligible(existing.kind, existing.statement, config.memory.conflicts)) {
|
|
80
|
+
log.debug({ existingId: existing.id }, 'Skipping candidate — statement is transient or non-durable');
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Skip pairs with zero topical overlap — they are not real contradictions
|
|
85
|
+
if (!areStatementsCoherent(existing.statement, newItem.statement)) {
|
|
86
|
+
log.debug(
|
|
87
|
+
{ existingId: existing.id, newId: newItem.id },
|
|
88
|
+
'Skipping candidate — zero statement overlap (incoherent pair)',
|
|
89
|
+
);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
65
93
|
try {
|
|
66
94
|
const result = await classifyRelationship(apiKey, existing, newItem);
|
|
67
95
|
await handleRelationship(result, existing, newItem);
|
|
@@ -52,6 +52,21 @@ export function deleteConversationKey(
|
|
|
52
52
|
.run();
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Map a conversation key to an existing conversation ID (no creation).
|
|
57
|
+
*/
|
|
58
|
+
export function setConversationKey(conversationKey: string, conversationId: string): void {
|
|
59
|
+
const db = getDb();
|
|
60
|
+
db.insert(conversationKeys)
|
|
61
|
+
.values({
|
|
62
|
+
id: uuid(),
|
|
63
|
+
conversationKey,
|
|
64
|
+
conversationId,
|
|
65
|
+
createdAt: Date.now(),
|
|
66
|
+
})
|
|
67
|
+
.run();
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
/**
|
|
56
71
|
* Get or create a conversation for the given conversationKey.
|
|
57
72
|
*
|
package/src/memory/db.ts
CHANGED
|
@@ -790,6 +790,10 @@ export function initializeDb(): void {
|
|
|
790
790
|
// Add claim ownership token to prevent cross-handler claim interference
|
|
791
791
|
try { database.run(/*sql*/ `ALTER TABLE processed_callbacks ADD COLUMN claim_id TEXT`); } catch { /* already exists */ }
|
|
792
792
|
|
|
793
|
+
// Caller identity persistence for auditability
|
|
794
|
+
try { database.run(/*sql*/ `ALTER TABLE call_sessions ADD COLUMN caller_identity_mode TEXT`); } catch { /* already exists */ }
|
|
795
|
+
try { database.run(/*sql*/ `ALTER TABLE call_sessions ADD COLUMN caller_identity_source TEXT`); } catch { /* already exists */ }
|
|
796
|
+
|
|
793
797
|
// Unique constraint: at most one non-null provider_call_sid per (provider, provider_call_sid).
|
|
794
798
|
// On upgraded databases that pre-date this constraint, duplicate rows may exist; deduplicate
|
|
795
799
|
// them first to avoid a UNIQUE constraint failure that would prevent startup.
|
|
@@ -900,6 +904,28 @@ export function initializeDb(): void {
|
|
|
900
904
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status)`);
|
|
901
905
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_task_candidates_promoted ON task_candidates(promoted_task_id)`);
|
|
902
906
|
|
|
907
|
+
// ── External Conversation Bindings ──────────────────────────────────
|
|
908
|
+
|
|
909
|
+
database.run(/*sql*/ `
|
|
910
|
+
CREATE TABLE IF NOT EXISTS external_conversation_bindings (
|
|
911
|
+
conversation_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
|
|
912
|
+
source_channel TEXT NOT NULL,
|
|
913
|
+
external_chat_id TEXT NOT NULL,
|
|
914
|
+
external_user_id TEXT,
|
|
915
|
+
display_name TEXT,
|
|
916
|
+
username TEXT,
|
|
917
|
+
created_at INTEGER NOT NULL,
|
|
918
|
+
updated_at INTEGER NOT NULL,
|
|
919
|
+
last_inbound_at INTEGER,
|
|
920
|
+
last_outbound_at INTEGER
|
|
921
|
+
)
|
|
922
|
+
`);
|
|
923
|
+
|
|
924
|
+
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_ext_conv_bindings_channel_chat ON external_conversation_bindings(source_channel, external_chat_id)`);
|
|
925
|
+
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_ext_conv_bindings_channel ON external_conversation_bindings(source_channel)`);
|
|
926
|
+
|
|
927
|
+
migrateExtConvBindingsChannelChatUnique(database);
|
|
928
|
+
|
|
903
929
|
migrateMemoryFtsBackfill(database);
|
|
904
930
|
}
|
|
905
931
|
|
|
@@ -1764,6 +1790,61 @@ function migrateLlmUsageEventsDropAssistantId(database: ReturnType<typeof drizzl
|
|
|
1764
1790
|
}
|
|
1765
1791
|
}
|
|
1766
1792
|
|
|
1793
|
+
/**
|
|
1794
|
+
* One-shot migration: deduplicate external_conversation_bindings rows that
|
|
1795
|
+
* share the same (source_channel, external_chat_id), then create a unique
|
|
1796
|
+
* index to enforce the invariant at DB level.
|
|
1797
|
+
*
|
|
1798
|
+
* For each duplicate group, the binding with the newest updatedAt (then
|
|
1799
|
+
* createdAt) is kept; older duplicates are deleted.
|
|
1800
|
+
*/
|
|
1801
|
+
function migrateExtConvBindingsChannelChatUnique(database: ReturnType<typeof drizzle<typeof schema>>): void {
|
|
1802
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
1803
|
+
|
|
1804
|
+
// If the unique index already exists, nothing to do.
|
|
1805
|
+
const idxExists = raw.query(
|
|
1806
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_ext_conv_bindings_channel_chat_unique'`,
|
|
1807
|
+
).get();
|
|
1808
|
+
if (idxExists) return;
|
|
1809
|
+
|
|
1810
|
+
// Check if the table exists (first boot edge case).
|
|
1811
|
+
const tableExists = raw.query(
|
|
1812
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'external_conversation_bindings'`,
|
|
1813
|
+
).get();
|
|
1814
|
+
if (!tableExists) return;
|
|
1815
|
+
|
|
1816
|
+
// Remove duplicates: keep the row with the newest updatedAt, then createdAt.
|
|
1817
|
+
// Since conversation_id is the PK (rowid alias), we use it for ordering ties.
|
|
1818
|
+
try {
|
|
1819
|
+
raw.exec('BEGIN');
|
|
1820
|
+
|
|
1821
|
+
raw.exec(/*sql*/ `
|
|
1822
|
+
DELETE FROM external_conversation_bindings
|
|
1823
|
+
WHERE rowid NOT IN (
|
|
1824
|
+
SELECT rowid FROM (
|
|
1825
|
+
SELECT rowid,
|
|
1826
|
+
ROW_NUMBER() OVER (
|
|
1827
|
+
PARTITION BY source_channel, external_chat_id
|
|
1828
|
+
ORDER BY updated_at DESC, created_at DESC, rowid DESC
|
|
1829
|
+
) AS rn
|
|
1830
|
+
FROM external_conversation_bindings
|
|
1831
|
+
)
|
|
1832
|
+
WHERE rn = 1
|
|
1833
|
+
)
|
|
1834
|
+
`);
|
|
1835
|
+
|
|
1836
|
+
raw.exec(/*sql*/ `
|
|
1837
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ext_conv_bindings_channel_chat_unique
|
|
1838
|
+
ON external_conversation_bindings(source_channel, external_chat_id)
|
|
1839
|
+
`);
|
|
1840
|
+
|
|
1841
|
+
raw.exec('COMMIT');
|
|
1842
|
+
} catch (e) {
|
|
1843
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
1844
|
+
throw e;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1767
1848
|
/**
|
|
1768
1849
|
* One-shot migration: remove duplicate (provider, provider_call_sid) rows from
|
|
1769
1850
|
* call_sessions so that the unique index can be created safely on upgraded databases
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EmbeddingBackend, EmbeddingRequestOptions } from './embedding-backend.js';
|
|
2
2
|
import { getLogger } from '../util/logger.js';
|
|
3
|
+
import { PromiseGuard } from '../util/promise-guard.js';
|
|
3
4
|
|
|
4
5
|
const log = getLogger('memory-embedding-local');
|
|
5
6
|
|
|
@@ -19,7 +20,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
19
20
|
readonly provider = 'local' as const;
|
|
20
21
|
readonly model: string;
|
|
21
22
|
private extractor: FeatureExtractionPipeline | null = null;
|
|
22
|
-
private
|
|
23
|
+
private readonly initGuard = new PromiseGuard<void>();
|
|
23
24
|
|
|
24
25
|
constructor(model: string) {
|
|
25
26
|
this.model = model;
|
|
@@ -50,18 +51,7 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
50
51
|
|
|
51
52
|
private async ensureInitialized(): Promise<void> {
|
|
52
53
|
if (this.extractor) return;
|
|
53
|
-
|
|
54
|
-
await this.initPromise;
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
this.initPromise = this.initialize();
|
|
59
|
-
try {
|
|
60
|
-
await this.initPromise;
|
|
61
|
-
} catch (err) {
|
|
62
|
-
this.initPromise = null;
|
|
63
|
-
throw err;
|
|
64
|
-
}
|
|
54
|
+
await this.initGuard.run(() => this.initialize());
|
|
65
55
|
}
|
|
66
56
|
|
|
67
57
|
private async initialize(): Promise<void> {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store for external conversation bindings — maps internal conversation IDs
|
|
3
|
+
* to external channel identifiers (e.g. Telegram chat ID, SMS thread ID).
|
|
4
|
+
*
|
|
5
|
+
* This enables the system to track which conversations originated from
|
|
6
|
+
* external channels and expose channel metadata in session/conversation
|
|
7
|
+
* list APIs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { eq, and, inArray } from 'drizzle-orm';
|
|
11
|
+
import { getDb } from './db.js';
|
|
12
|
+
import { externalConversationBindings } from './schema.js';
|
|
13
|
+
|
|
14
|
+
export interface ExternalConversationBinding {
|
|
15
|
+
conversationId: string;
|
|
16
|
+
sourceChannel: string;
|
|
17
|
+
externalChatId: string;
|
|
18
|
+
externalUserId?: string | null;
|
|
19
|
+
displayName?: string | null;
|
|
20
|
+
username?: string | null;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
updatedAt: number;
|
|
23
|
+
lastInboundAt?: number | null;
|
|
24
|
+
lastOutboundAt?: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UpsertBindingInput {
|
|
28
|
+
conversationId: string;
|
|
29
|
+
sourceChannel: string;
|
|
30
|
+
externalChatId: string;
|
|
31
|
+
externalUserId?: string | null;
|
|
32
|
+
displayName?: string | null;
|
|
33
|
+
username?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Insert or update an external conversation binding on conflict (conversationId).
|
|
38
|
+
* On conflict, updates channel metadata and timestamps.
|
|
39
|
+
*/
|
|
40
|
+
export function upsertBinding(input: UpsertBindingInput): void {
|
|
41
|
+
const db = getDb();
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
|
|
44
|
+
// If a stale binding exists for this (sourceChannel, externalChatId) under a
|
|
45
|
+
// different conversationId, remove it first so the unique index is not violated.
|
|
46
|
+
const existing = getBindingByChannelChat(input.sourceChannel, input.externalChatId);
|
|
47
|
+
if (existing && existing.conversationId !== input.conversationId) {
|
|
48
|
+
db.delete(externalConversationBindings)
|
|
49
|
+
.where(eq(externalConversationBindings.conversationId, existing.conversationId))
|
|
50
|
+
.run();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
db.insert(externalConversationBindings)
|
|
54
|
+
.values({
|
|
55
|
+
conversationId: input.conversationId,
|
|
56
|
+
sourceChannel: input.sourceChannel,
|
|
57
|
+
externalChatId: input.externalChatId,
|
|
58
|
+
externalUserId: input.externalUserId ?? null,
|
|
59
|
+
displayName: input.displayName ?? null,
|
|
60
|
+
username: input.username ?? null,
|
|
61
|
+
createdAt: now,
|
|
62
|
+
updatedAt: now,
|
|
63
|
+
lastInboundAt: now,
|
|
64
|
+
})
|
|
65
|
+
.onConflictDoUpdate({
|
|
66
|
+
target: externalConversationBindings.conversationId,
|
|
67
|
+
set: {
|
|
68
|
+
sourceChannel: input.sourceChannel,
|
|
69
|
+
externalChatId: input.externalChatId,
|
|
70
|
+
externalUserId: input.externalUserId ?? null,
|
|
71
|
+
displayName: input.displayName ?? null,
|
|
72
|
+
username: input.username ?? null,
|
|
73
|
+
updatedAt: now,
|
|
74
|
+
lastInboundAt: now,
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
.run();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Upsert an external conversation binding for outbound sends.
|
|
82
|
+
* Similar to upsertBinding but touches lastOutboundAt instead of lastInboundAt,
|
|
83
|
+
* and only requires channel identifiers (no sender metadata needed).
|
|
84
|
+
*/
|
|
85
|
+
export function upsertOutboundBinding(input: {
|
|
86
|
+
conversationId: string;
|
|
87
|
+
sourceChannel: string;
|
|
88
|
+
externalChatId: string;
|
|
89
|
+
}): void {
|
|
90
|
+
const db = getDb();
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
|
|
93
|
+
// If a stale binding exists for this (sourceChannel, externalChatId) under a
|
|
94
|
+
// different conversationId, remove it first so the unique index is not violated.
|
|
95
|
+
const existing = getBindingByChannelChat(input.sourceChannel, input.externalChatId);
|
|
96
|
+
if (existing && existing.conversationId !== input.conversationId) {
|
|
97
|
+
db.delete(externalConversationBindings)
|
|
98
|
+
.where(eq(externalConversationBindings.conversationId, existing.conversationId))
|
|
99
|
+
.run();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
db.insert(externalConversationBindings)
|
|
103
|
+
.values({
|
|
104
|
+
conversationId: input.conversationId,
|
|
105
|
+
sourceChannel: input.sourceChannel,
|
|
106
|
+
externalChatId: input.externalChatId,
|
|
107
|
+
externalUserId: null,
|
|
108
|
+
displayName: null,
|
|
109
|
+
username: null,
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
lastOutboundAt: now,
|
|
113
|
+
})
|
|
114
|
+
.onConflictDoUpdate({
|
|
115
|
+
target: externalConversationBindings.conversationId,
|
|
116
|
+
set: {
|
|
117
|
+
sourceChannel: input.sourceChannel,
|
|
118
|
+
externalChatId: input.externalChatId,
|
|
119
|
+
updatedAt: now,
|
|
120
|
+
lastOutboundAt: now,
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
.run();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Look up an external binding by conversation ID.
|
|
128
|
+
*/
|
|
129
|
+
export function getBindingByConversation(
|
|
130
|
+
conversationId: string,
|
|
131
|
+
): ExternalConversationBinding | null {
|
|
132
|
+
const db = getDb();
|
|
133
|
+
const row = db
|
|
134
|
+
.select()
|
|
135
|
+
.from(externalConversationBindings)
|
|
136
|
+
.where(eq(externalConversationBindings.conversationId, conversationId))
|
|
137
|
+
.get();
|
|
138
|
+
return row ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Look up an external binding by channel + external chat ID.
|
|
143
|
+
*/
|
|
144
|
+
export function getBindingByChannelChat(
|
|
145
|
+
sourceChannel: string,
|
|
146
|
+
externalChatId: string,
|
|
147
|
+
): ExternalConversationBinding | null {
|
|
148
|
+
const db = getDb();
|
|
149
|
+
const row = db
|
|
150
|
+
.select()
|
|
151
|
+
.from(externalConversationBindings)
|
|
152
|
+
.where(
|
|
153
|
+
and(
|
|
154
|
+
eq(externalConversationBindings.sourceChannel, sourceChannel),
|
|
155
|
+
eq(externalConversationBindings.externalChatId, externalChatId),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
.get();
|
|
159
|
+
return row ?? null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Remove an external binding for a conversation.
|
|
164
|
+
*/
|
|
165
|
+
export function deleteBinding(conversationId: string): void {
|
|
166
|
+
const db = getDb();
|
|
167
|
+
db.delete(externalConversationBindings)
|
|
168
|
+
.where(eq(externalConversationBindings.conversationId, conversationId))
|
|
169
|
+
.run();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Remove an external binding by channel + external chat ID.
|
|
174
|
+
* Used when disconnecting a synced thread by its channel identifiers.
|
|
175
|
+
*/
|
|
176
|
+
export function deleteBindingByChannelChat(
|
|
177
|
+
sourceChannel: string,
|
|
178
|
+
externalChatId: string,
|
|
179
|
+
): void {
|
|
180
|
+
const db = getDb();
|
|
181
|
+
db.delete(externalConversationBindings)
|
|
182
|
+
.where(
|
|
183
|
+
and(
|
|
184
|
+
eq(externalConversationBindings.sourceChannel, sourceChannel),
|
|
185
|
+
eq(externalConversationBindings.externalChatId, externalChatId),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
.run();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* List all external bindings, optionally filtered by sourceChannel.
|
|
193
|
+
*/
|
|
194
|
+
export function listBindings(options?: {
|
|
195
|
+
sourceChannel?: string;
|
|
196
|
+
}): ExternalConversationBinding[] {
|
|
197
|
+
const db = getDb();
|
|
198
|
+
const query = db
|
|
199
|
+
.select()
|
|
200
|
+
.from(externalConversationBindings);
|
|
201
|
+
|
|
202
|
+
if (options?.sourceChannel) {
|
|
203
|
+
return query
|
|
204
|
+
.where(eq(externalConversationBindings.sourceChannel, options.sourceChannel))
|
|
205
|
+
.all();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return query.all();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get bindings for multiple conversation IDs at once.
|
|
213
|
+
* Returns a map of conversationId -> binding for efficient lookup.
|
|
214
|
+
*/
|
|
215
|
+
export function getBindingsForConversations(
|
|
216
|
+
conversationIds: string[],
|
|
217
|
+
): Map<string, ExternalConversationBinding> {
|
|
218
|
+
if (conversationIds.length === 0) return new Map();
|
|
219
|
+
|
|
220
|
+
const db = getDb();
|
|
221
|
+
const result = new Map<string, ExternalConversationBinding>();
|
|
222
|
+
|
|
223
|
+
const all = db
|
|
224
|
+
.select()
|
|
225
|
+
.from(externalConversationBindings)
|
|
226
|
+
.where(inArray(externalConversationBindings.conversationId, conversationIds))
|
|
227
|
+
.all();
|
|
228
|
+
|
|
229
|
+
for (const row of all) {
|
|
230
|
+
result.set(row.conversationId, row);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
looksLikeClarificationReply,
|
|
7
7
|
shouldAttemptConflictResolution,
|
|
8
8
|
} from '../conflict-intent.js';
|
|
9
|
+
import { isConflictKindPairEligible, isStatementConflictEligible } from '../conflict-policy.js';
|
|
9
10
|
import { getDb } from '../db.js';
|
|
10
11
|
import { resolveConflictClarification } from '../clarification-resolver.js';
|
|
11
|
-
import { applyConflictResolution, listPendingConflictDetails } from '../conflict-store.js';
|
|
12
|
+
import { applyConflictResolution, listPendingConflictDetails, resolveConflict } from '../conflict-store.js';
|
|
12
13
|
import { enqueueMemoryJob, type MemoryJob } from '../jobs-store.js';
|
|
13
14
|
import { asPositiveMs, asString } from '../job-utils.js';
|
|
14
15
|
import { extractTextFromStoredMessageContent } from '../message-content.js';
|
|
@@ -43,7 +44,26 @@ export async function resolvePendingConflictsForMessageJob(job: MemoryJob, confi
|
|
|
43
44
|
if (!clarificationReply) return;
|
|
44
45
|
|
|
45
46
|
const pending = listPendingConflictDetails(scopeId, 25);
|
|
46
|
-
|
|
47
|
+
|
|
48
|
+
// Dismiss non-actionable conflicts (kind or statement policy)
|
|
49
|
+
const conflictableKinds = config.memory.conflicts.conflictableKinds;
|
|
50
|
+
for (const conflict of pending) {
|
|
51
|
+
const kindEligible = isConflictKindPairEligible(
|
|
52
|
+
conflict.existingKind, conflict.candidateKind, { conflictableKinds },
|
|
53
|
+
);
|
|
54
|
+
if (!kindEligible
|
|
55
|
+
|| !isStatementConflictEligible(conflict.existingKind, conflict.existingStatement, { conflictableKinds })
|
|
56
|
+
|| !isStatementConflictEligible(conflict.candidateKind, conflict.candidateStatement, { conflictableKinds })) {
|
|
57
|
+
resolveConflict(conflict.id, {
|
|
58
|
+
status: 'dismissed',
|
|
59
|
+
resolutionNote: 'Dismissed by conflict policy (transient/non-durable).',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Re-fetch after dismissal
|
|
65
|
+
const actionablePending = listPendingConflictDetails(scopeId, 25);
|
|
66
|
+
const eligible = actionablePending.filter((conflict) => conflict.createdAt <= message.createdAt);
|
|
47
67
|
if (eligible.length === 0) return;
|
|
48
68
|
const candidates = eligible.filter((conflict) => {
|
|
49
69
|
const askedAt = conflict.lastAskedAt;
|