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,189 @@
|
|
|
1
|
+
import { mkdir, stat, readFile, writeFile, chmod } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { X509Certificate, createPrivateKey } from 'node:crypto';
|
|
4
|
+
import { getRootDir } from '../util/platform.js';
|
|
5
|
+
import { getLogger } from '../util/logger.js';
|
|
6
|
+
|
|
7
|
+
const log = getLogger('tls-certs');
|
|
8
|
+
|
|
9
|
+
const TLS_DIR = 'tls';
|
|
10
|
+
const CERT_FILENAME = 'cert.pem';
|
|
11
|
+
const KEY_FILENAME = 'key.pem';
|
|
12
|
+
const FINGERPRINT_FILENAME = 'fingerprint';
|
|
13
|
+
|
|
14
|
+
/** Returns the TLS directory path (~/.vellum/tls/). */
|
|
15
|
+
export function getTlsDir(): string {
|
|
16
|
+
return join(getRootDir(), TLS_DIR);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Returns the path to the TLS certificate. */
|
|
20
|
+
export function getTlsCertPath(): string {
|
|
21
|
+
return join(getTlsDir(), CERT_FILENAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Returns the path to the TLS private key. */
|
|
25
|
+
export function getTlsKeyPath(): string {
|
|
26
|
+
return join(getTlsDir(), KEY_FILENAME);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Returns the path to the certificate fingerprint file. */
|
|
30
|
+
export function getTlsFingerprintPath(): string {
|
|
31
|
+
return join(getTlsDir(), FINGERPRINT_FILENAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compute the SHA-256 fingerprint of a DER-encoded certificate.
|
|
36
|
+
* Returns hex lowercase, no colons.
|
|
37
|
+
*/
|
|
38
|
+
function computeFingerprint(cert: X509Certificate): string {
|
|
39
|
+
// X509Certificate.fingerprint256 returns colon-separated uppercase hex.
|
|
40
|
+
// Normalize to lowercase without colons for compact storage and comparison.
|
|
41
|
+
return cert.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check whether an existing cert+key pair is valid:
|
|
46
|
+
* - All three files exist (cert, key, fingerprint)
|
|
47
|
+
* - Cert is parseable as X509
|
|
48
|
+
* - Cert is not expired
|
|
49
|
+
* - Fingerprint file exists and matches the cert
|
|
50
|
+
* - Private key is valid and matches the certificate
|
|
51
|
+
*/
|
|
52
|
+
async function isExistingCertValid(): Promise<boolean> {
|
|
53
|
+
const certPath = getTlsCertPath();
|
|
54
|
+
const keyPath = getTlsKeyPath();
|
|
55
|
+
const fpPath = getTlsFingerprintPath();
|
|
56
|
+
|
|
57
|
+
// Check all three files exist
|
|
58
|
+
const [certExists, keyExists, fpExists] = await Promise.all([
|
|
59
|
+
stat(certPath).then(() => true, () => false),
|
|
60
|
+
stat(keyPath).then(() => true, () => false),
|
|
61
|
+
stat(fpPath).then(() => true, () => false),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
if (!certExists || !keyExists || !fpExists) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const [certPem, keyPem, storedFp] = await Promise.all([
|
|
70
|
+
readFile(certPath, 'utf-8'),
|
|
71
|
+
readFile(keyPath, 'utf-8'),
|
|
72
|
+
readFile(fpPath, 'utf-8'),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const x509 = new X509Certificate(certPem);
|
|
76
|
+
|
|
77
|
+
// Check expiration
|
|
78
|
+
const notAfter = new Date(x509.validTo);
|
|
79
|
+
if (notAfter <= new Date()) {
|
|
80
|
+
log.info('Existing TLS certificate has expired, will regenerate');
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check fingerprint matches
|
|
85
|
+
const actualFp = computeFingerprint(x509);
|
|
86
|
+
if (actualFp !== storedFp.trim()) {
|
|
87
|
+
log.info('TLS fingerprint mismatch, will regenerate');
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Verify the private key is valid and matches the certificate's public key.
|
|
92
|
+
// This catches corrupted key files or cert/key mismatches that would cause
|
|
93
|
+
// tls.createServer() to fail at runtime.
|
|
94
|
+
const privateKey = createPrivateKey(keyPem);
|
|
95
|
+
if (!x509.checkPrivateKey(privateKey)) {
|
|
96
|
+
log.info('TLS private key does not match certificate, will regenerate');
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return true;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
log.warn({ err }, 'Failed to validate existing TLS certificate, will regenerate');
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ensure a self-signed TLS certificate exists for the daemon.
|
|
109
|
+
*
|
|
110
|
+
* Stores files in `~/.vellum/tls/`:
|
|
111
|
+
* - `cert.pem` (0o644) — self-signed certificate
|
|
112
|
+
* - `key.pem` (0o600) — private key
|
|
113
|
+
* - `fingerprint` (0o644) — SHA-256 hex fingerprint (lowercase, no colons)
|
|
114
|
+
*
|
|
115
|
+
* Idempotent: skips generation if a valid cert already exists.
|
|
116
|
+
* Auto-regenerates if the cert is expired, fingerprint is missing/mismatched,
|
|
117
|
+
* or key/cert files are corrupt.
|
|
118
|
+
*
|
|
119
|
+
* Returns the cert, key (PEM strings), and fingerprint.
|
|
120
|
+
*/
|
|
121
|
+
export async function ensureTlsCert(): Promise<{ cert: string; key: string; fingerprint: string }> {
|
|
122
|
+
const tlsDir = getTlsDir();
|
|
123
|
+
const certPath = getTlsCertPath();
|
|
124
|
+
const keyPath = getTlsKeyPath();
|
|
125
|
+
const fpPath = getTlsFingerprintPath();
|
|
126
|
+
|
|
127
|
+
// Check if existing cert is still valid
|
|
128
|
+
if (await isExistingCertValid()) {
|
|
129
|
+
const [cert, key, fingerprint] = await Promise.all([
|
|
130
|
+
readFile(certPath, 'utf-8'),
|
|
131
|
+
readFile(keyPath, 'utf-8'),
|
|
132
|
+
readFile(fpPath, 'utf-8'),
|
|
133
|
+
]);
|
|
134
|
+
log.info('Using existing TLS certificate');
|
|
135
|
+
return { cert, key, fingerprint: fingerprint.trim() };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Generate new cert
|
|
139
|
+
log.info('Generating new self-signed TLS certificate');
|
|
140
|
+
await mkdir(tlsDir, { recursive: true });
|
|
141
|
+
|
|
142
|
+
// Generate RSA 2048 key
|
|
143
|
+
const keyProc = Bun.spawn(
|
|
144
|
+
['openssl', 'genrsa', '-out', keyPath, '2048'],
|
|
145
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
146
|
+
);
|
|
147
|
+
const keyExit = await keyProc.exited;
|
|
148
|
+
if (keyExit !== 0) {
|
|
149
|
+
const stderr = await new Response(keyProc.stderr).text();
|
|
150
|
+
throw new Error(`Failed to generate TLS key: ${stderr}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Generate self-signed cert (10-year validity)
|
|
154
|
+
const certProc = Bun.spawn(
|
|
155
|
+
[
|
|
156
|
+
'openssl', 'req', '-new', '-x509',
|
|
157
|
+
'-key', keyPath,
|
|
158
|
+
'-out', certPath,
|
|
159
|
+
'-days', '3650',
|
|
160
|
+
'-subj', '/CN=Vellum Daemon',
|
|
161
|
+
],
|
|
162
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
163
|
+
);
|
|
164
|
+
const certExit = await certProc.exited;
|
|
165
|
+
if (certExit !== 0) {
|
|
166
|
+
const stderr = await new Response(certProc.stderr).text();
|
|
167
|
+
throw new Error(`Failed to generate TLS certificate: ${stderr}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Compute and write fingerprint
|
|
171
|
+
const certPem = await readFile(certPath, 'utf-8');
|
|
172
|
+
const x509 = new X509Certificate(certPem);
|
|
173
|
+
const fingerprint = computeFingerprint(x509);
|
|
174
|
+
await writeFile(fpPath, fingerprint);
|
|
175
|
+
|
|
176
|
+
// Set permissions: key is private, cert and fingerprint are readable
|
|
177
|
+
await Promise.all([
|
|
178
|
+
chmod(keyPath, 0o600),
|
|
179
|
+
chmod(certPath, 0o644),
|
|
180
|
+
chmod(fpPath, 0o644),
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
log.info({ fingerprint, certPath }, 'TLS certificate generated');
|
|
184
|
+
return {
|
|
185
|
+
cert: certPem,
|
|
186
|
+
key: await readFile(keyPath, 'utf-8'),
|
|
187
|
+
fingerprint,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -35,11 +35,13 @@ export async function generateVideoThumbnail(dataBase64: string): Promise<string
|
|
|
35
35
|
outputPath,
|
|
36
36
|
], { stderr: 'pipe' });
|
|
37
37
|
|
|
38
|
-
// Race against a 10s timeout to avoid hanging on slow/stuck ffmpeg
|
|
38
|
+
// Race against a 10s timeout to avoid hanging on slow/stuck ffmpeg.
|
|
39
|
+
// The timer handle is cleared via finally() so it doesn't leak when ffmpeg exits normally.
|
|
40
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
39
41
|
const exitCode = await Promise.race([
|
|
40
|
-
proc.exited,
|
|
42
|
+
proc.exited.finally(() => clearTimeout(timer)),
|
|
41
43
|
new Promise<never>((_, reject) =>
|
|
42
|
-
setTimeout(() => { proc.kill(); reject(new Error('ffmpeg timed out')); }, 10_000)
|
|
44
|
+
timer = setTimeout(() => { proc.kill(); reject(new Error('ffmpeg timed out')); }, 10_000)
|
|
43
45
|
),
|
|
44
46
|
]);
|
|
45
47
|
|
package/src/hooks/manager.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { discoverHooks } from './discovery.js';
|
|
|
3
3
|
import { runHookScript } from './runner.js';
|
|
4
4
|
import { getLogger, isDebug } from '../util/logger.js';
|
|
5
5
|
import { getHooksDir } from '../util/platform.js';
|
|
6
|
+
import { Debouncer } from '../util/debounce.js';
|
|
6
7
|
import type { DiscoveredHook, HookEventName, HookEventData, HookTriggerResult } from './types.js';
|
|
7
8
|
|
|
8
9
|
const log = getLogger('hooks-manager');
|
|
@@ -11,7 +12,7 @@ export class HookManager {
|
|
|
11
12
|
private hooks: DiscoveredHook[] = [];
|
|
12
13
|
private eventIndex = new Map<HookEventName, DiscoveredHook[]>();
|
|
13
14
|
private watcher: FSWatcher | null = null;
|
|
14
|
-
private
|
|
15
|
+
private readonly debouncer = new Debouncer(500);
|
|
15
16
|
|
|
16
17
|
initialize(): void {
|
|
17
18
|
this.hooks = discoverHooks();
|
|
@@ -82,12 +83,10 @@ export class HookManager {
|
|
|
82
83
|
|
|
83
84
|
try {
|
|
84
85
|
this.watcher = watch(hooksDir, { recursive: true }, (_eventType, filename) => {
|
|
85
|
-
|
|
86
|
-
this.debounceTimer = setTimeout(() => {
|
|
87
|
-
this.debounceTimer = null;
|
|
86
|
+
this.debouncer.schedule(() => {
|
|
88
87
|
log.info({ filename: String(filename ?? '') }, 'Hooks directory changed, reloading');
|
|
89
88
|
this.reload();
|
|
90
|
-
}
|
|
89
|
+
});
|
|
91
90
|
});
|
|
92
91
|
log.info({ dir: hooksDir }, 'Watching hooks directory for changes');
|
|
93
92
|
} catch (err) {
|
|
@@ -96,10 +95,7 @@ export class HookManager {
|
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
stopWatching(): void {
|
|
99
|
-
|
|
100
|
-
clearTimeout(this.debounceTimer);
|
|
101
|
-
this.debounceTimer = null;
|
|
102
|
-
}
|
|
98
|
+
this.debouncer.cancel();
|
|
103
99
|
if (this.watcher) {
|
|
104
100
|
this.watcher.close();
|
|
105
101
|
this.watcher = null;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-backed version control for user-defined apps.
|
|
3
|
+
*
|
|
4
|
+
* Initializes a git repository in the apps directory (~/.vellum/apps/) and
|
|
5
|
+
* commits after every app mutation (create, update, delete, file write/edit).
|
|
6
|
+
* Commits are fire-and-forget — they never block the caller.
|
|
7
|
+
*
|
|
8
|
+
* Also exposes query methods (history, diff, file-at-version, restore) for
|
|
9
|
+
* browsing and reverting app version history.
|
|
10
|
+
*
|
|
11
|
+
* Reuses WorkspaceGitService for all git operations (mutex, circuit breaker,
|
|
12
|
+
* lazy init, etc.).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getWorkspaceGitService } from '../workspace/git-service.js';
|
|
16
|
+
import { getAppsDir } from './app-store.js';
|
|
17
|
+
import { getLogger } from '../util/logger.js';
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const log = getLogger('app-git');
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface AppVersion {
|
|
28
|
+
commitHash: string;
|
|
29
|
+
message: string;
|
|
30
|
+
/** Unix milliseconds */
|
|
31
|
+
timestamp: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Gitignore management
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Patterns excluded from app version tracking.
|
|
40
|
+
* - *.preview — large base64 preview images
|
|
41
|
+
* - records directories — user data (form submissions), not app code
|
|
42
|
+
*/
|
|
43
|
+
const APP_GITIGNORE_RULES = [
|
|
44
|
+
'*.preview',
|
|
45
|
+
'*/records/',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ensure the apps directory .gitignore contains app-specific exclusion rules.
|
|
50
|
+
* Idempotent: only appends rules that are missing.
|
|
51
|
+
*/
|
|
52
|
+
function ensureAppGitignoreRules(appsDir: string): void {
|
|
53
|
+
const gitignorePath = join(appsDir, '.gitignore');
|
|
54
|
+
let content = '';
|
|
55
|
+
if (existsSync(gitignorePath)) {
|
|
56
|
+
content = readFileSync(gitignorePath, 'utf-8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const missingRules = APP_GITIGNORE_RULES.filter(rule => !content.includes(rule));
|
|
60
|
+
if (missingRules.length > 0) {
|
|
61
|
+
if (content && !content.endsWith('\n')) {
|
|
62
|
+
content += '\n';
|
|
63
|
+
}
|
|
64
|
+
content += missingRules.join('\n') + '\n';
|
|
65
|
+
writeFileSync(gitignorePath, content, 'utf-8');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Input validation
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** Validate that a string looks like a hex commit hash (short or full). */
|
|
74
|
+
function validateCommitHash(hash: string): void {
|
|
75
|
+
if (!/^[0-9a-f]{4,40}$/i.test(hash)) {
|
|
76
|
+
throw new Error(`Invalid commit hash: ${hash}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Validate an app ID (UUID-like, no path traversal or git pathspec chars). */
|
|
81
|
+
function validateAppId(id: string): void {
|
|
82
|
+
if (!id || id.includes('/') || id.includes('\\') || id.includes('..') || id !== id.trim()) {
|
|
83
|
+
throw new Error(`Invalid app ID: ${id}`);
|
|
84
|
+
}
|
|
85
|
+
// Reject git pathspec metacharacters to prevent cross-app operations
|
|
86
|
+
if (/[*?[\]:(]/.test(id)) {
|
|
87
|
+
throw new Error(`Invalid app ID: contains git pathspec characters: ${id}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Validate a relative file path within an app (no traversal). */
|
|
92
|
+
function validateRelativePath(path: string): void {
|
|
93
|
+
if (!path || path.includes('..') || path.startsWith('/') || path.startsWith('\\')) {
|
|
94
|
+
throw new Error(`Invalid file path: ${path}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Initialization & commit
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Eagerly initialize the app git repo so that the "Initial commit" is
|
|
104
|
+
* created before any app files are written. Without this, the first
|
|
105
|
+
* mutation's files get absorbed into WorkspaceGitService's bootstrap
|
|
106
|
+
* commit and the "Create app: ..." commit ends up empty.
|
|
107
|
+
*
|
|
108
|
+
* Safe to call multiple times — ensureInitialized() is idempotent.
|
|
109
|
+
* Fire-and-forget: errors are logged but never thrown.
|
|
110
|
+
*/
|
|
111
|
+
export async function initAppGit(): Promise<void> {
|
|
112
|
+
try {
|
|
113
|
+
const appsDir = getAppsDir();
|
|
114
|
+
ensureAppGitignoreRules(appsDir);
|
|
115
|
+
const gitService = getWorkspaceGitService(appsDir);
|
|
116
|
+
await gitService.ensureInitialized();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log.error({ err }, 'Failed to initialize app git repo');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Commit app changes to the apps git repository.
|
|
124
|
+
*
|
|
125
|
+
* This is fire-and-forget: errors are logged but never thrown.
|
|
126
|
+
* The caller should not await the returned promise unless it needs
|
|
127
|
+
* to guarantee the commit completed (e.g. in tests).
|
|
128
|
+
*/
|
|
129
|
+
export async function commitAppChange(message: string): Promise<void> {
|
|
130
|
+
try {
|
|
131
|
+
const appsDir = getAppsDir();
|
|
132
|
+
|
|
133
|
+
// Re-check .gitignore rules every call in case the apps dir was
|
|
134
|
+
// recreated while the process was running.
|
|
135
|
+
ensureAppGitignoreRules(appsDir);
|
|
136
|
+
|
|
137
|
+
const gitService = getWorkspaceGitService(appsDir);
|
|
138
|
+
await gitService.commitChanges(message);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
log.error({ err, message }, 'Failed to commit app change');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Query methods
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get the commit history for a specific app.
|
|
150
|
+
*
|
|
151
|
+
* Scopes `git log` to files belonging to this app:
|
|
152
|
+
* {appId}.json, {appId}/index.html, {appId}/pages/*, etc.
|
|
153
|
+
*/
|
|
154
|
+
export async function getAppHistory(appId: string, limit = 50): Promise<AppVersion[]> {
|
|
155
|
+
validateAppId(appId);
|
|
156
|
+
const safeLimit = Math.max(1, Math.min(Math.floor(limit) || 50, 500));
|
|
157
|
+
const appsDir = getAppsDir();
|
|
158
|
+
const gitService = getWorkspaceGitService(appsDir);
|
|
159
|
+
|
|
160
|
+
// Format: hash<TAB>unix-seconds<TAB>subject line
|
|
161
|
+
const { stdout } = await gitService.runReadOnlyGit([
|
|
162
|
+
'log',
|
|
163
|
+
`--max-count=${safeLimit}`,
|
|
164
|
+
'--format=%H\t%at\t%s',
|
|
165
|
+
'--',
|
|
166
|
+
`${appId}.json`,
|
|
167
|
+
`${appId}/`,
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
if (!stdout.trim()) return [];
|
|
171
|
+
|
|
172
|
+
return stdout.trim().split('\n').map(line => {
|
|
173
|
+
const [commitHash, epochSec, ...messageParts] = line.split('\t');
|
|
174
|
+
return {
|
|
175
|
+
commitHash,
|
|
176
|
+
message: messageParts.join('\t'),
|
|
177
|
+
timestamp: parseInt(epochSec, 10) * 1000,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get a unified diff for a specific app between two commits.
|
|
184
|
+
* If `toCommit` is omitted, diffs against HEAD.
|
|
185
|
+
*/
|
|
186
|
+
export async function getAppDiff(
|
|
187
|
+
appId: string,
|
|
188
|
+
fromCommit: string,
|
|
189
|
+
toCommit?: string,
|
|
190
|
+
): Promise<string> {
|
|
191
|
+
validateAppId(appId);
|
|
192
|
+
validateCommitHash(fromCommit);
|
|
193
|
+
if (toCommit) validateCommitHash(toCommit);
|
|
194
|
+
|
|
195
|
+
const appsDir = getAppsDir();
|
|
196
|
+
const gitService = getWorkspaceGitService(appsDir);
|
|
197
|
+
|
|
198
|
+
const range = toCommit ? `${fromCommit}..${toCommit}` : `${fromCommit}..HEAD`;
|
|
199
|
+
const { stdout } = await gitService.runReadOnlyGit([
|
|
200
|
+
'diff',
|
|
201
|
+
range,
|
|
202
|
+
'--',
|
|
203
|
+
`${appId}.json`,
|
|
204
|
+
`${appId}/`,
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
return stdout;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get the contents of a file at a specific commit.
|
|
212
|
+
*/
|
|
213
|
+
export async function getAppFileAtVersion(
|
|
214
|
+
appId: string,
|
|
215
|
+
path: string,
|
|
216
|
+
commitHash: string,
|
|
217
|
+
): Promise<string> {
|
|
218
|
+
validateAppId(appId);
|
|
219
|
+
validateRelativePath(path);
|
|
220
|
+
validateCommitHash(commitHash);
|
|
221
|
+
|
|
222
|
+
const appsDir = getAppsDir();
|
|
223
|
+
const gitService = getWorkspaceGitService(appsDir);
|
|
224
|
+
|
|
225
|
+
const { stdout } = await gitService.runReadOnlyGit([
|
|
226
|
+
'show',
|
|
227
|
+
`${commitHash}:${appId}/${path}`,
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
return stdout;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Restore an app's files to a previous version.
|
|
235
|
+
*
|
|
236
|
+
* Checks out the app's files at `commitHash`, then creates a new commit
|
|
237
|
+
* recording the restore action. Both operations run under the git mutex
|
|
238
|
+
* to prevent concurrent commits from interfering.
|
|
239
|
+
*
|
|
240
|
+
* Uses --no-overlay so files added after the target commit are removed,
|
|
241
|
+
* giving a true restore rather than a merge.
|
|
242
|
+
*/
|
|
243
|
+
export async function restoreAppVersion(appId: string, commitHash: string): Promise<void> {
|
|
244
|
+
validateAppId(appId);
|
|
245
|
+
validateCommitHash(commitHash);
|
|
246
|
+
|
|
247
|
+
const appsDir = getAppsDir();
|
|
248
|
+
const gitService = getWorkspaceGitService(appsDir);
|
|
249
|
+
|
|
250
|
+
await gitService.runWithMutex(async (exec) => {
|
|
251
|
+
// Checkout the app's files at the target commit.
|
|
252
|
+
// --no-overlay removes files that don't exist at the target commit.
|
|
253
|
+
await exec([
|
|
254
|
+
'checkout',
|
|
255
|
+
commitHash,
|
|
256
|
+
'--no-overlay',
|
|
257
|
+
'--',
|
|
258
|
+
`${appId}.json`,
|
|
259
|
+
`${appId}/`,
|
|
260
|
+
]);
|
|
261
|
+
|
|
262
|
+
// Read the app name and refresh updatedAt so the restored app
|
|
263
|
+
// doesn't appear stale in recency ordering.
|
|
264
|
+
let appName = appId;
|
|
265
|
+
const jsonPath = join(appsDir, `${appId}.json`);
|
|
266
|
+
if (existsSync(jsonPath)) {
|
|
267
|
+
try {
|
|
268
|
+
const raw = readFileSync(jsonPath, 'utf-8');
|
|
269
|
+
const app = JSON.parse(raw);
|
|
270
|
+
if (app.name) appName = app.name;
|
|
271
|
+
app.updatedAt = Date.now();
|
|
272
|
+
writeFileSync(jsonPath, JSON.stringify(app, null, 2) + '\n', 'utf-8');
|
|
273
|
+
} catch {
|
|
274
|
+
// fall back to id
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const shortHash = commitHash.substring(0, 7);
|
|
279
|
+
|
|
280
|
+
// Stage only this app's files and commit atomically within the same mutex lock
|
|
281
|
+
await exec(['add', '--', `${appId}.json`, `${appId}/`]);
|
|
282
|
+
await exec(['commit', '-m', `Restore app: ${appName} to ${shortHash}`, '--allow-empty']);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Test helpers
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @internal Test-only: reset module state.
|
|
292
|
+
*/
|
|
293
|
+
export function _resetAppGitState(): void {
|
|
294
|
+
// no-op — kept for test API compatibility
|
|
295
|
+
}
|
package/src/memory/app-store.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
isPrebuiltHomeBaseApp,
|
|
30
30
|
validatePrebuiltHomeBaseHtml,
|
|
31
31
|
} from '../home-base/prebuilt-home-base-updater.js';
|
|
32
|
+
import { commitAppChange } from './app-git-service.js';
|
|
32
33
|
|
|
33
34
|
export interface AppDefinition {
|
|
34
35
|
id: string;
|
|
@@ -210,6 +211,8 @@ export function createApp(params: {
|
|
|
210
211
|
app.pages = params.pages;
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
void commitAppChange(`Create app: ${params.name}`);
|
|
215
|
+
|
|
213
216
|
return app;
|
|
214
217
|
}
|
|
215
218
|
|
|
@@ -372,14 +375,27 @@ export function updateApp(
|
|
|
372
375
|
updated.pages = loadedPages;
|
|
373
376
|
}
|
|
374
377
|
|
|
378
|
+
const changedFields = Object.keys(updates).filter(k => updates[k as keyof typeof updates] !== undefined);
|
|
379
|
+
void commitAppChange(`Update app: ${updated.name}\n\nChanged: ${changedFields.join(', ')}`);
|
|
380
|
+
|
|
375
381
|
return updated;
|
|
376
382
|
}
|
|
377
383
|
|
|
378
384
|
export function deleteApp(id: string): void {
|
|
379
385
|
validateId(id);
|
|
380
386
|
const dir = getAppsDir();
|
|
387
|
+
|
|
388
|
+
// Read app name before deleting for the commit message
|
|
389
|
+
let appName = id;
|
|
381
390
|
const filePath = join(dir, `${id}.json`);
|
|
382
391
|
if (existsSync(filePath)) {
|
|
392
|
+
try {
|
|
393
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
394
|
+
const app = JSON.parse(raw);
|
|
395
|
+
if (app.name) appName = app.name;
|
|
396
|
+
} catch {
|
|
397
|
+
// fall back to id
|
|
398
|
+
}
|
|
383
399
|
unlinkSync(filePath);
|
|
384
400
|
}
|
|
385
401
|
const previewPath = join(dir, `${id}.preview`);
|
|
@@ -388,6 +404,8 @@ export function deleteApp(id: string): void {
|
|
|
388
404
|
}
|
|
389
405
|
const appDir = join(dir, id);
|
|
390
406
|
rmSync(appDir, { recursive: true, force: true });
|
|
407
|
+
|
|
408
|
+
void commitAppChange(`Delete app: ${appName}`);
|
|
391
409
|
}
|
|
392
410
|
|
|
393
411
|
export function createAppRecord(appId: string, data: Record<string, unknown>): AppRecord {
|
|
@@ -527,6 +545,8 @@ export function writeAppFile(appId: string, path: string, content: string): void
|
|
|
527
545
|
const dir = join(resolved, '..');
|
|
528
546
|
mkdirSync(dir, { recursive: true });
|
|
529
547
|
writeFileSync(resolved, content, 'utf-8');
|
|
548
|
+
|
|
549
|
+
void commitAppChange(`Write ${path} in app ${appId}`);
|
|
530
550
|
}
|
|
531
551
|
|
|
532
552
|
/**
|
|
@@ -549,6 +569,7 @@ export function editAppFile(
|
|
|
549
569
|
const result = applyEdit(content, oldString, newString, replaceAll ?? false);
|
|
550
570
|
if (result.ok) {
|
|
551
571
|
writeFileSync(resolved, result.updatedContent, 'utf-8');
|
|
572
|
+
void commitAppChange(`Edit ${path} in app ${appId}`);
|
|
552
573
|
}
|
|
553
574
|
return result;
|
|
554
575
|
}
|
|
@@ -22,16 +22,30 @@ export function computeConflictRelevance(
|
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const NOISE_TOKENS = new Set([
|
|
26
|
+
'http', 'https', 'github', 'gitlab', 'www', 'com', 'org',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Matches full URLs (http(s)://...) and scheme-less bare domain URLs for known
|
|
30
|
+
// code hosting sites (e.g. github.com/org/repo/pull/123) so embedded path
|
|
31
|
+
// segments like "pull", "issue", "ticket" don't leak into relevance scoring.
|
|
32
|
+
// The scheme-less branch is restricted to known hosting domains to avoid
|
|
33
|
+
// stripping dotted identifiers like "index.ts/runtime".
|
|
34
|
+
const URL_PATTERN = /https?:\/\/[^\s)>\]]+|(?:github|gitlab|bitbucket)\.(?:com|org|io)\/[^\s)>\]]*/gi;
|
|
35
|
+
|
|
36
|
+
export function tokenizeForConflictRelevance(input: string): Set<string> {
|
|
37
|
+
const stripped = input.replace(URL_PATTERN, ' ');
|
|
38
|
+
const tokens = stripped
|
|
27
39
|
.toLowerCase()
|
|
28
40
|
.split(/[^a-z0-9]+/g)
|
|
29
41
|
.map((token) => token.trim())
|
|
30
|
-
.filter((token) => token.length >= 4)
|
|
42
|
+
.filter((token) => token.length >= 4)
|
|
43
|
+
.filter((token) => !/^\d+$/.test(token))
|
|
44
|
+
.filter((token) => !NOISE_TOKENS.has(token));
|
|
31
45
|
return new Set(tokens);
|
|
32
46
|
}
|
|
33
47
|
|
|
34
|
-
function overlapRatio(left: Set<string>, right: Set<string>): number {
|
|
48
|
+
export function overlapRatio(left: Set<string>, right: Set<string>): number {
|
|
35
49
|
if (left.size === 0 || right.size === 0) return 0;
|
|
36
50
|
let overlap = 0;
|
|
37
51
|
for (const token of left) {
|
|
@@ -40,6 +54,35 @@ function overlapRatio(left: Set<string>, right: Set<string>): number {
|
|
|
40
54
|
return overlap / Math.max(left.size, right.size);
|
|
41
55
|
}
|
|
42
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Tokenize for statement-to-statement coherence checking.
|
|
59
|
+
* Uses a lower minimum token length (>= 3) than `tokenizeForConflictRelevance`
|
|
60
|
+
* to preserve short technical terms like "vim", "css", "git", "npm", etc.
|
|
61
|
+
*/
|
|
62
|
+
function tokenizeForCoherence(input: string): Set<string> {
|
|
63
|
+
const stripped = input.replace(URL_PATTERN, ' ');
|
|
64
|
+
const tokens = stripped
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.split(/[^a-z0-9]+/g)
|
|
67
|
+
.map((token) => token.trim())
|
|
68
|
+
.filter((token) => token.length >= 3)
|
|
69
|
+
.filter((token) => !/^\d+$/.test(token))
|
|
70
|
+
.filter((token) => !NOISE_TOKENS.has(token));
|
|
71
|
+
return new Set(tokens);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check whether two conflict statements have topical coherence.
|
|
76
|
+
* Returns true if the statements share at least one meaningful token.
|
|
77
|
+
* Uses a permissive tokenizer (>= 3 chars) to avoid dropping short
|
|
78
|
+
* technical terms like "vim", "css", "git", etc.
|
|
79
|
+
*/
|
|
80
|
+
export function areStatementsCoherent(statementA: string, statementB: string): boolean {
|
|
81
|
+
const tokensA = tokenizeForCoherence(statementA);
|
|
82
|
+
const tokensB = tokenizeForCoherence(statementB);
|
|
83
|
+
return overlapRatio(tokensA, tokensB) > 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
43
86
|
// Action verbs that signal the user is making a deliberate choice.
|
|
44
87
|
const ACTION_CUES = new Set([
|
|
45
88
|
'keep', 'use', 'prefer', 'go', 'pick', 'choose', 'take', 'want', 'select',
|