@vellumai/assistant 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +18 -27
- package/docs/architecture/memory.md +105 -0
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-types.test.ts +12 -4
- package/src/__tests__/memory-reducer.test.ts +7 -1
- package/src/__tests__/memory-regressions.test.ts +24 -4
- package/src/__tests__/memory-simplified-config.test.ts +4 -4
- package/src/__tests__/openai-whisper.test.ts +93 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
- package/src/__tests__/volume-security-guard.test.ts +155 -0
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
- package/src/config/env-registry.ts +9 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/loader.ts +0 -1
- package/src/config/schemas/memory-simplified.ts +1 -1
- package/src/credential-execution/managed-catalog.ts +5 -15
- package/src/daemon/config-watcher.ts +4 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -0
- package/src/daemon/daemon-control.ts +7 -0
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/lifecycle.ts +51 -2
- package/src/daemon/providers-setup.ts +2 -1
- package/src/hooks/manager.ts +7 -0
- package/src/instrument.ts +33 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/brief-time.ts +5 -4
- package/src/memory/conversation-crud.ts +210 -0
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +4 -0
- package/src/memory/embedding-local.ts +11 -5
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +24 -30
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/jobs-store.ts +2 -0
- package/src/memory/jobs-worker.ts +8 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-types.ts +9 -2
- package/src/memory/reducer.ts +25 -11
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/slack/adapter.ts +29 -2
- package/src/oauth/connection-resolver.test.ts +22 -18
- package/src/oauth/connection-resolver.ts +92 -7
- package/src/oauth/platform-connection.test.ts +78 -69
- package/src/oauth/platform-connection.ts +12 -19
- package/src/permissions/trust-client.ts +343 -0
- package/src/permissions/trust-store-interface.ts +105 -0
- package/src/permissions/trust-store.ts +523 -36
- package/src/platform/client.test.ts +148 -0
- package/src/platform/client.ts +71 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
- package/src/providers/speech-to-text/openai-whisper.ts +68 -0
- package/src/providers/speech-to-text/resolve.ts +9 -0
- package/src/providers/speech-to-text/types.ts +17 -0
- package/src/runtime/auth/route-policy.ts +10 -1
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/routes/conversation-management-routes.ts +88 -2
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- package/src/runtime/routes/inbound-message-handler.ts +27 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
- package/src/runtime/routes/log-export-routes.ts +1 -0
- package/src/runtime/routes/secret-routes.ts +5 -1
- package/src/schedule/schedule-store.ts +7 -0
- package/src/schedule/scheduler.ts +6 -2
- package/src/security/ces-credential-client.ts +173 -0
- package/src/security/secure-keys.ts +65 -22
- package/src/signals/bash.ts +3 -0
- package/src/signals/cancel.ts +3 -0
- package/src/signals/confirm.ts +3 -0
- package/src/signals/conversation-undo.ts +3 -0
- package/src/signals/event-stream.ts +7 -0
- package/src/signals/shotgun.ts +3 -0
- package/src/signals/trust-rule.ts +3 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
- package/src/telemetry/usage-telemetry-reporter.ts +22 -20
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- package/src/util/device-id.ts +70 -7
- package/src/util/logger.ts +35 -9
- package/src/util/platform.ts +29 -5
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
- package/src/workspace/migrations/registry.ts +2 -0
|
@@ -38,8 +38,13 @@ class FileReadTool implements Tool {
|
|
|
38
38
|
type: "number",
|
|
39
39
|
description: "Maximum number of lines to read",
|
|
40
40
|
},
|
|
41
|
+
activity: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description:
|
|
44
|
+
"Brief non-technical explanation of what you are doing and why, shown to the user as a status update.",
|
|
45
|
+
},
|
|
41
46
|
},
|
|
42
|
-
required: ["path"],
|
|
47
|
+
required: ["path", "activity"],
|
|
43
48
|
},
|
|
44
49
|
};
|
|
45
50
|
}
|
|
@@ -28,8 +28,13 @@ class FileWriteTool implements Tool {
|
|
|
28
28
|
type: "string",
|
|
29
29
|
description: "The content to write to the file",
|
|
30
30
|
},
|
|
31
|
+
activity: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description:
|
|
34
|
+
"Brief non-technical explanation of what you are doing and why, shown to the user as a status update.",
|
|
35
|
+
},
|
|
31
36
|
},
|
|
32
|
-
required: ["path", "content"],
|
|
37
|
+
required: ["path", "content", "activity"],
|
|
33
38
|
},
|
|
34
39
|
};
|
|
35
40
|
}
|
|
@@ -2,6 +2,8 @@ import { and, eq, ne } from "drizzle-orm";
|
|
|
2
2
|
import { v4 as uuid } from "uuid";
|
|
3
3
|
|
|
4
4
|
import type { AssistantConfig } from "../../config/types.js";
|
|
5
|
+
import { buildArchiveRecall } from "../../memory/archive-recall.js";
|
|
6
|
+
import { insertObservation } from "../../memory/archive-store.js";
|
|
5
7
|
import { getDb } from "../../memory/db.js";
|
|
6
8
|
import { computeMemoryFingerprint } from "../../memory/fingerprint.js";
|
|
7
9
|
import { enqueueMemoryJob } from "../../memory/jobs-store.js";
|
|
@@ -18,7 +20,7 @@ const log = getLogger("memory-tools");
|
|
|
18
20
|
|
|
19
21
|
export async function handleMemorySave(
|
|
20
22
|
args: Record<string, unknown>,
|
|
21
|
-
|
|
23
|
+
config: AssistantConfig,
|
|
22
24
|
conversationId: string,
|
|
23
25
|
messageId: string | undefined,
|
|
24
26
|
scopeId: string = "default",
|
|
@@ -63,6 +65,19 @@ export async function handleMemorySave(
|
|
|
63
65
|
? truncate(args.subject.trim(), 80, "")
|
|
64
66
|
: inferSubjectFromStatement(statement.trim());
|
|
65
67
|
|
|
68
|
+
// When simplified memory is enabled, save directly to the simplified
|
|
69
|
+
// observation/chunk tables instead of the legacy memory_items table.
|
|
70
|
+
if (config.memory.simplified.enabled) {
|
|
71
|
+
return handleSimplifiedMemorySave(
|
|
72
|
+
kind,
|
|
73
|
+
subject,
|
|
74
|
+
statement.trim(),
|
|
75
|
+
conversationId,
|
|
76
|
+
messageId,
|
|
77
|
+
scopeId,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
try {
|
|
67
82
|
const db = getDb();
|
|
68
83
|
const id = uuid();
|
|
@@ -275,6 +290,12 @@ export async function handleMemoryRecall(
|
|
|
275
290
|
? args.scope.trim()
|
|
276
291
|
: "default";
|
|
277
292
|
|
|
293
|
+
// When simplified memory is enabled, use the archive recall path
|
|
294
|
+
// instead of the legacy hybrid retriever.
|
|
295
|
+
if (config.memory.simplified.enabled) {
|
|
296
|
+
return handleSimplifiedMemoryRecall(query.trim(), scopeId ?? "default");
|
|
297
|
+
}
|
|
298
|
+
|
|
278
299
|
// Scope policy: "conversation" means strict (only that scope),
|
|
279
300
|
// anything else allows fallback to the default scope.
|
|
280
301
|
const scopePolicyOverride: ScopePolicyOverride | undefined = scopeId
|
|
@@ -411,6 +432,113 @@ export async function handleMemoryDelete(
|
|
|
411
432
|
}
|
|
412
433
|
}
|
|
413
434
|
|
|
435
|
+
// ── Simplified memory helpers ────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Save a memory item as an observation + chunk in the simplified system.
|
|
439
|
+
* This is used when simplified memory is enabled instead of writing to
|
|
440
|
+
* the legacy memory_items table.
|
|
441
|
+
*/
|
|
442
|
+
function handleSimplifiedMemorySave(
|
|
443
|
+
kind: string,
|
|
444
|
+
subject: string,
|
|
445
|
+
statement: string,
|
|
446
|
+
conversationId: string,
|
|
447
|
+
messageId: string | undefined,
|
|
448
|
+
scopeId: string,
|
|
449
|
+
): ToolExecutionResult {
|
|
450
|
+
try {
|
|
451
|
+
const trimmedStatement = truncate(statement, 500, "");
|
|
452
|
+
const content = `[${kind}] ${subject}: ${trimmedStatement}`;
|
|
453
|
+
|
|
454
|
+
const result = insertObservation({
|
|
455
|
+
conversationId,
|
|
456
|
+
messageId: messageId ?? null,
|
|
457
|
+
role: "user",
|
|
458
|
+
content,
|
|
459
|
+
scopeId,
|
|
460
|
+
modality: "text",
|
|
461
|
+
source: "tool:memory_save",
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
log.debug(
|
|
465
|
+
{
|
|
466
|
+
observationId: result.observationId,
|
|
467
|
+
chunkId: result.chunkId,
|
|
468
|
+
kind,
|
|
469
|
+
subject,
|
|
470
|
+
conversationId,
|
|
471
|
+
messageId,
|
|
472
|
+
},
|
|
473
|
+
"Memory saved via simplified system",
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
content: `Saved to memory (ID: ${result.observationId}).\nKind: ${kind}\nSubject: ${subject}\nStatement: ${trimmedStatement}`,
|
|
478
|
+
isError: false,
|
|
479
|
+
};
|
|
480
|
+
} catch (err) {
|
|
481
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
482
|
+
log.error({ err }, "simplified memory_save failed");
|
|
483
|
+
return { content: `Error: Failed to save memory: ${msg}`, isError: true };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Recall memories using the simplified archive recall path instead of
|
|
489
|
+
* the legacy hybrid retriever.
|
|
490
|
+
*/
|
|
491
|
+
function handleSimplifiedMemoryRecall(
|
|
492
|
+
query: string,
|
|
493
|
+
scopeId: string,
|
|
494
|
+
): ToolExecutionResult {
|
|
495
|
+
try {
|
|
496
|
+
const recallResult = buildArchiveRecall(scopeId, query);
|
|
497
|
+
|
|
498
|
+
if (recallResult.bullets.length === 0) {
|
|
499
|
+
return {
|
|
500
|
+
content: JSON.stringify({
|
|
501
|
+
text: "No matching memories found.",
|
|
502
|
+
resultCount: 0,
|
|
503
|
+
degraded: false,
|
|
504
|
+
items: [],
|
|
505
|
+
sources: { semantic: 0, recency: 0 },
|
|
506
|
+
}),
|
|
507
|
+
isError: false,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const items = recallResult.bullets.map((b) => ({
|
|
512
|
+
id: b.sourceId,
|
|
513
|
+
type: b.source,
|
|
514
|
+
kind: b.source,
|
|
515
|
+
}));
|
|
516
|
+
|
|
517
|
+
const result = {
|
|
518
|
+
text: recallResult.text,
|
|
519
|
+
resultCount: recallResult.bullets.length,
|
|
520
|
+
degraded: false,
|
|
521
|
+
items,
|
|
522
|
+
sources: {
|
|
523
|
+
semantic: recallResult.prefetchHitCount,
|
|
524
|
+
recency: 0,
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
content: JSON.stringify(result),
|
|
530
|
+
isError: false,
|
|
531
|
+
};
|
|
532
|
+
} catch (err) {
|
|
533
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
534
|
+
log.error({ err, query }, "simplified memory_recall failed");
|
|
535
|
+
return {
|
|
536
|
+
content: `Error: Memory recall failed: ${msg}`,
|
|
537
|
+
isError: true,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
414
542
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
415
543
|
|
|
416
544
|
function inferSubjectFromStatement(statement: string): string {
|
|
@@ -41,6 +41,7 @@ export async function executeScheduleCreate(
|
|
|
41
41
|
const routingHints = input.routing_hints as
|
|
42
42
|
| Record<string, unknown>
|
|
43
43
|
| undefined;
|
|
44
|
+
const quiet = (input.quiet as boolean) ?? false;
|
|
44
45
|
|
|
45
46
|
if (!name || typeof name !== "string") {
|
|
46
47
|
return {
|
|
@@ -112,6 +113,7 @@ export async function executeScheduleCreate(
|
|
|
112
113
|
mode,
|
|
113
114
|
routingIntent: routingIntent as RoutingIntent | undefined,
|
|
114
115
|
routingHints,
|
|
116
|
+
quiet,
|
|
115
117
|
});
|
|
116
118
|
|
|
117
119
|
const fireDate = formatLocalDate(job.nextRunAt);
|
|
@@ -187,6 +189,7 @@ export async function executeScheduleCreate(
|
|
|
187
189
|
mode,
|
|
188
190
|
routingIntent: routingIntent as RoutingIntent | undefined,
|
|
189
191
|
routingHints,
|
|
192
|
+
quiet,
|
|
190
193
|
});
|
|
191
194
|
|
|
192
195
|
const scheduleDescription =
|
|
@@ -62,7 +62,11 @@ export async function executeScheduleList(
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
lines.push(
|
|
65
|
+
lines.push(
|
|
66
|
+
` Enabled: ${job.enabled}`,
|
|
67
|
+
` Quiet: ${job.quiet}`,
|
|
68
|
+
` Message: ${job.message}`,
|
|
69
|
+
);
|
|
66
70
|
|
|
67
71
|
if (!oneShot) {
|
|
68
72
|
lines.push(` Next run: ${formatLocalDate(job.nextRunAt)}`);
|
|
@@ -97,6 +97,11 @@ export async function executeScheduleUpdate(
|
|
|
97
97
|
updates.routingHints = input.routing_hints;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Quiet mode
|
|
101
|
+
if (input.quiet !== undefined) {
|
|
102
|
+
updates.quiet = input.quiet;
|
|
103
|
+
}
|
|
104
|
+
|
|
100
105
|
// Auto-detect syntax when expression changes without explicit syntax
|
|
101
106
|
if (input.expression !== undefined || input.syntax !== undefined) {
|
|
102
107
|
const resolved = normalizeScheduleSyntax({
|
|
@@ -159,6 +164,7 @@ export async function executeScheduleUpdate(
|
|
|
159
164
|
mode?: ScheduleMode;
|
|
160
165
|
routingIntent?: RoutingIntent;
|
|
161
166
|
routingHints?: Record<string, unknown>;
|
|
167
|
+
quiet?: boolean;
|
|
162
168
|
},
|
|
163
169
|
);
|
|
164
170
|
|
package/src/util/device-id.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* extensible for future per-device metadata.
|
|
7
7
|
*
|
|
8
8
|
* Path resolution:
|
|
9
|
-
* - Containerized (IS_CONTAINERIZED=true): uses
|
|
10
|
-
* persistent
|
|
9
|
+
* - Containerized (IS_CONTAINERIZED=true): uses /home/assistant (the assistant
|
|
10
|
+
* user's persistent home dir) so device.json lives on the assistant's own
|
|
11
|
+
* filesystem rather than the shared data volume. Falls back to BASE_DATA_DIR
|
|
12
|
+
* for migration from the old location.
|
|
11
13
|
* - Local (single or multi-instance): uses homedir() so all instances on the
|
|
12
14
|
* same machine share a single device ID, even when BASE_DATA_DIR is set to
|
|
13
15
|
* an instance-scoped directory.
|
|
@@ -31,18 +33,34 @@ let cached: string | undefined;
|
|
|
31
33
|
/**
|
|
32
34
|
* Resolve the base directory for device.json.
|
|
33
35
|
*
|
|
34
|
-
* In containerized environments,
|
|
35
|
-
*
|
|
36
|
+
* In containerized environments, device.json is stored under /home/assistant
|
|
37
|
+
* (the assistant user's persistent home dir) rather than on the shared data
|
|
38
|
+
* volume. Device ID is assistant-specific state that doesn't need to be shared.
|
|
36
39
|
* In local environments (including multi-instance), homedir() is stable and
|
|
37
40
|
* shared across instances, giving a true per-machine device ID.
|
|
38
41
|
*/
|
|
39
42
|
export function getDeviceIdBaseDir(): string {
|
|
40
43
|
if (getIsContainerized()) {
|
|
41
|
-
return
|
|
44
|
+
return "/home/assistant";
|
|
42
45
|
}
|
|
43
46
|
return homedir();
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the legacy base directory for device.json migration.
|
|
51
|
+
*
|
|
52
|
+
* Returns the old containerized path (BASE_DATA_DIR) so we can fall back to
|
|
53
|
+
* reading device.json from the shared volume if it hasn't been migrated yet.
|
|
54
|
+
* Returns undefined when not containerized or when no legacy path exists.
|
|
55
|
+
*/
|
|
56
|
+
function getLegacyDeviceIdBaseDir(): string | undefined {
|
|
57
|
+
if (!getIsContainerized()) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const baseDataDir = getBaseDataDir();
|
|
61
|
+
return baseDataDir || undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
/**
|
|
47
65
|
* Get the stable device ID for this machine.
|
|
48
66
|
*
|
|
@@ -78,10 +96,55 @@ export function getDeviceId(): string {
|
|
|
78
96
|
}
|
|
79
97
|
}
|
|
80
98
|
} catch (err) {
|
|
81
|
-
log.warn({ err }, "Failed to read device.json —
|
|
99
|
+
log.warn({ err }, "Failed to read device.json — checking legacy path");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Migration fallback: check the legacy location (shared volume) if the new
|
|
103
|
+
// location doesn't have a valid device.json yet.
|
|
104
|
+
const legacyBase = getLegacyDeviceIdBaseDir();
|
|
105
|
+
if (legacyBase) {
|
|
106
|
+
const legacyPath = join(legacyBase, ".vellum", "device.json");
|
|
107
|
+
try {
|
|
108
|
+
if (existsSync(legacyPath)) {
|
|
109
|
+
const raw = JSON.parse(readFileSync(legacyPath, "utf-8"));
|
|
110
|
+
if (
|
|
111
|
+
raw &&
|
|
112
|
+
typeof raw === "object" &&
|
|
113
|
+
typeof raw.deviceId === "string" &&
|
|
114
|
+
raw.deviceId.length > 0
|
|
115
|
+
) {
|
|
116
|
+
cached = raw.deviceId as string;
|
|
117
|
+
log.info(
|
|
118
|
+
{ deviceId: cached },
|
|
119
|
+
"Resolved device ID from legacy device.json — will persist to new location",
|
|
120
|
+
);
|
|
121
|
+
// Persist to the new location so future reads don't need the fallback
|
|
122
|
+
try {
|
|
123
|
+
mkdirSync(vellumDir, { recursive: true });
|
|
124
|
+
writeFileSync(
|
|
125
|
+
filePath,
|
|
126
|
+
JSON.stringify({ deviceId: cached }, null, 2) + "\n",
|
|
127
|
+
{ mode: 0o644 },
|
|
128
|
+
);
|
|
129
|
+
log.info("Migrated device.json to new location");
|
|
130
|
+
} catch (writeErr) {
|
|
131
|
+
log.warn(
|
|
132
|
+
{ err: writeErr },
|
|
133
|
+
"Failed to migrate device.json to new location",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return cached;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
log.warn(
|
|
141
|
+
{ err },
|
|
142
|
+
"Failed to read legacy device.json — generating new device ID",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
82
145
|
}
|
|
83
146
|
|
|
84
|
-
// Either the file doesn't exist, or deviceId was missing/empty.
|
|
147
|
+
// Either the file doesn't exist at either location, or deviceId was missing/empty.
|
|
85
148
|
// Generate a new UUID and persist it.
|
|
86
149
|
try {
|
|
87
150
|
mkdirSync(vellumDir, { recursive: true });
|
package/src/util/logger.ts
CHANGED
|
@@ -76,20 +76,44 @@ let rootLogger: pino.Logger | null = null;
|
|
|
76
76
|
let activeLogDate: string | null = null;
|
|
77
77
|
let activeLogFileConfig: LogFileConfig | null = null;
|
|
78
78
|
|
|
79
|
+
function resolveLogDir(config: LogFileConfig): string | undefined {
|
|
80
|
+
if (!config.dir) return undefined;
|
|
81
|
+
|
|
82
|
+
if (!existsSync(config.dir)) {
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(config.dir, { recursive: true });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (getIsContainerized()) {
|
|
87
|
+
// Config has a host-specific path that can't be created inside the
|
|
88
|
+
// container (e.g. /Users/…). Fall back to the default log directory.
|
|
89
|
+
const fallback = join(getLogPath(), "..");
|
|
90
|
+
console.warn(
|
|
91
|
+
`[logger] Configured logFile.dir "${config.dir}" cannot be created ` +
|
|
92
|
+
`in container (${(err as Error).message}). Falling back to "${fallback}".`,
|
|
93
|
+
);
|
|
94
|
+
if (!existsSync(fallback)) {
|
|
95
|
+
mkdirSync(fallback, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
return fallback;
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return config.dir;
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
function buildRotatingLogger(config: LogFileConfig): pino.Logger {
|
|
80
|
-
|
|
107
|
+
const dir = resolveLogDir(config);
|
|
108
|
+
if (!dir) {
|
|
81
109
|
return pino(
|
|
82
110
|
{ name: "assistant", serializers: logSerializers },
|
|
83
111
|
pinoPretty(prettyOpts({ destination: 1 })),
|
|
84
112
|
);
|
|
85
113
|
}
|
|
86
114
|
|
|
87
|
-
if (!existsSync(config.dir)) {
|
|
88
|
-
mkdirSync(config.dir, { recursive: true });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
115
|
const today = formatDate(new Date());
|
|
92
|
-
const filePath = logFilePathForDate(
|
|
116
|
+
const filePath = logFilePathForDate(dir, new Date());
|
|
93
117
|
const fileDest = pino.destination({
|
|
94
118
|
dest: filePath,
|
|
95
119
|
sync: false,
|
|
@@ -107,7 +131,7 @@ function buildRotatingLogger(config: LogFileConfig): pino.Logger {
|
|
|
107
131
|
);
|
|
108
132
|
|
|
109
133
|
activeLogDate = today;
|
|
110
|
-
activeLogFileConfig = config;
|
|
134
|
+
activeLogFileConfig = { ...config, dir };
|
|
111
135
|
|
|
112
136
|
// When stdout is not a TTY (e.g. desktop app redirects to a hatch log file),
|
|
113
137
|
// write to the rotating file only — the hatch log already captured early
|
|
@@ -144,8 +168,10 @@ function ensureCurrentDate(): void {
|
|
|
144
168
|
export function initLogger(config: LogFileConfig): void {
|
|
145
169
|
rootLogger = buildRotatingLogger(config);
|
|
146
170
|
|
|
147
|
-
|
|
148
|
-
|
|
171
|
+
// Use the resolved dir (may differ from config.dir when containerized)
|
|
172
|
+
const resolvedDir = activeLogFileConfig?.dir;
|
|
173
|
+
if (resolvedDir && config.retentionDays > 0) {
|
|
174
|
+
const removed = pruneOldLogFiles(resolvedDir, config.retentionDays);
|
|
149
175
|
if (removed > 0) {
|
|
150
176
|
rootLogger.info(
|
|
151
177
|
{ removed, retentionDays: config.retentionDays },
|
package/src/util/platform.ts
CHANGED
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
getBaseDataDir,
|
|
13
|
+
getIsContainerized,
|
|
14
|
+
getWorkspaceDirOverride,
|
|
15
|
+
} from "../config/env-registry.js";
|
|
12
16
|
|
|
13
17
|
export function isMacOS(): boolean {
|
|
14
18
|
return process.platform === "darwin";
|
|
@@ -237,6 +241,15 @@ export function getInterfacesDir(): string {
|
|
|
237
241
|
return join(getDataDir(), "interfaces");
|
|
238
242
|
}
|
|
239
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Returns the sounds directory (~/.vellum/workspace/data/sounds).
|
|
246
|
+
* Custom sound files and sound configuration live here. Sound files
|
|
247
|
+
* can be large, so this directory is excluded from diagnostic exports.
|
|
248
|
+
*/
|
|
249
|
+
export function getSoundsDir(): string {
|
|
250
|
+
return join(getWorkspaceDir(), "data", "sounds");
|
|
251
|
+
}
|
|
252
|
+
|
|
240
253
|
/**
|
|
241
254
|
* Returns the TCP port the daemon should listen on for iOS clients.
|
|
242
255
|
* Hardcoded default: 8765.
|
|
@@ -356,13 +369,19 @@ export function getSignalsDir(): string {
|
|
|
356
369
|
// Currently not used by call-sites; wired in later PRs.
|
|
357
370
|
|
|
358
371
|
/**
|
|
359
|
-
* Returns
|
|
372
|
+
* Returns the workspace root for user-facing state.
|
|
373
|
+
*
|
|
374
|
+
* When the WORKSPACE_DIR env var is set, returns that value (used in
|
|
375
|
+
* containerized deployments where the workspace is a separate volume).
|
|
376
|
+
* Otherwise falls back to ~/.vellum/workspace.
|
|
360
377
|
*
|
|
361
378
|
* WARNING: The entire workspace directory is included in diagnostic log exports
|
|
362
379
|
* ("Send logs to Vellum"). Do not store secrets, API keys, or sensitive
|
|
363
380
|
* credentials here — use the credential store or ~/.vellum/protected/ instead.
|
|
364
381
|
*/
|
|
365
382
|
export function getWorkspaceDir(): string {
|
|
383
|
+
const override = getWorkspaceDirOverride();
|
|
384
|
+
if (override) return override;
|
|
366
385
|
return join(getRootDir(), "workspace");
|
|
367
386
|
}
|
|
368
387
|
|
|
@@ -413,13 +432,17 @@ export function ensureDataDir(): void {
|
|
|
413
432
|
const root = getRootDir();
|
|
414
433
|
const workspace = getWorkspaceDir();
|
|
415
434
|
const wsData = join(workspace, "data");
|
|
435
|
+
const containerized = getIsContainerized();
|
|
416
436
|
const dirs = [
|
|
417
|
-
// Root-level dirs (runtime
|
|
437
|
+
// Root-level dirs (runtime)
|
|
418
438
|
root,
|
|
419
|
-
|
|
439
|
+
// protected, signals, hooks are local-only — skip in containerized mode
|
|
440
|
+
// (credentials via CES HTTP API, trust via gateway API, no IPC signals)
|
|
441
|
+
...(containerized
|
|
442
|
+
? []
|
|
443
|
+
: [join(root, "protected"), join(root, "signals"), join(root, "hooks")]),
|
|
420
444
|
// Workspace dirs
|
|
421
445
|
workspace,
|
|
422
|
-
join(root, "hooks"),
|
|
423
446
|
join(workspace, "skills"),
|
|
424
447
|
join(workspace, "embedding-models"),
|
|
425
448
|
join(workspace, "conversations"),
|
|
@@ -432,6 +455,7 @@ export function ensureDataDir(): void {
|
|
|
432
455
|
join(wsData, "memory", "knowledge"),
|
|
433
456
|
join(wsData, "apps"),
|
|
434
457
|
join(wsData, "interfaces"),
|
|
458
|
+
join(wsData, "sounds"),
|
|
435
459
|
];
|
|
436
460
|
for (const dir of dirs) {
|
|
437
461
|
if (!existsSync(dir)) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace migration: Migrate workspace data from /data to /workspace volume.
|
|
3
|
+
*
|
|
4
|
+
* In the old Docker volume layout, workspace data lived at
|
|
5
|
+
* `$BASE_DATA_DIR/.vellum/workspace`. In the new layout, WORKSPACE_DIR points
|
|
6
|
+
* to a dedicated volume (e.g. `/workspace`). On first boot with the new layout,
|
|
7
|
+
* this migration copies existing workspace data from the old location to the
|
|
8
|
+
* new volume so nothing is lost.
|
|
9
|
+
*
|
|
10
|
+
* Idempotent:
|
|
11
|
+
* - Skips if WORKSPACE_DIR is not set (non-Docker or old layout).
|
|
12
|
+
* - Skips if the workspace volume already has data (config.json exists).
|
|
13
|
+
* - Skips if the sentinel file exists (already migrated).
|
|
14
|
+
* - Skips if the old workspace directory doesn't exist or is empty.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { cpSync, existsSync, readdirSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
getBaseDataDir,
|
|
22
|
+
getWorkspaceDirOverride,
|
|
23
|
+
} from "../../config/env-registry.js";
|
|
24
|
+
import type { WorkspaceMigration } from "./types.js";
|
|
25
|
+
|
|
26
|
+
const SENTINEL_FILENAME = ".workspace-volume-migrated";
|
|
27
|
+
|
|
28
|
+
export const migrateToWorkspaceVolumeMigration: WorkspaceMigration = {
|
|
29
|
+
id: "014-migrate-to-workspace-volume",
|
|
30
|
+
description:
|
|
31
|
+
"Copy workspace data from old /data/.vellum/workspace to new WORKSPACE_DIR volume on first boot",
|
|
32
|
+
|
|
33
|
+
run(workspaceDir: string): void {
|
|
34
|
+
const workspaceDirOverride = getWorkspaceDirOverride();
|
|
35
|
+
|
|
36
|
+
// Only relevant when WORKSPACE_DIR is explicitly set (Docker with separate volume)
|
|
37
|
+
if (!workspaceDirOverride) return;
|
|
38
|
+
|
|
39
|
+
const sentinelPath = join(workspaceDir, SENTINEL_FILENAME);
|
|
40
|
+
|
|
41
|
+
// Already migrated — skip
|
|
42
|
+
if (existsSync(sentinelPath)) return;
|
|
43
|
+
|
|
44
|
+
// If the workspace volume already has data (config.json), assume it's
|
|
45
|
+
// already populated — either by a previous migration or manual setup.
|
|
46
|
+
if (existsSync(join(workspaceDir, "config.json"))) {
|
|
47
|
+
// Write sentinel so we don't re-check on every boot
|
|
48
|
+
writeSentinel(sentinelPath);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolve the old workspace location: $BASE_DATA_DIR/.vellum/workspace
|
|
53
|
+
const baseDataDir = getBaseDataDir();
|
|
54
|
+
if (!baseDataDir) {
|
|
55
|
+
// No BASE_DATA_DIR means there's no old location to migrate from
|
|
56
|
+
writeSentinel(sentinelPath);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const oldWorkspaceDir = join(baseDataDir, ".vellum", "workspace");
|
|
61
|
+
|
|
62
|
+
// If the old workspace doesn't exist or is empty, nothing to migrate
|
|
63
|
+
if (!existsSync(oldWorkspaceDir)) {
|
|
64
|
+
writeSentinel(sentinelPath);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let entries: string[];
|
|
69
|
+
try {
|
|
70
|
+
entries = readdirSync(oldWorkspaceDir);
|
|
71
|
+
} catch {
|
|
72
|
+
// Can't read old workspace — write sentinel and move on
|
|
73
|
+
writeSentinel(sentinelPath);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (entries.length === 0) {
|
|
78
|
+
writeSentinel(sentinelPath);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Copy everything from old workspace to new workspace volume.
|
|
83
|
+
// Use cpSync with recursive to handle nested directories.
|
|
84
|
+
// Copy each entry individually rather than the whole directory to avoid
|
|
85
|
+
// overwriting the target directory itself (which may already have
|
|
86
|
+
// sub-directories created by ensureDataDir).
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const src = join(oldWorkspaceDir, entry);
|
|
89
|
+
const dst = join(workspaceDir, entry);
|
|
90
|
+
|
|
91
|
+
// Skip if destination already exists (partial previous run)
|
|
92
|
+
if (existsSync(dst)) continue;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
cpSync(src, dst, { recursive: true });
|
|
96
|
+
} catch {
|
|
97
|
+
// Best-effort per entry — continue with remaining items
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Mark migration complete
|
|
102
|
+
writeSentinel(sentinelPath);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function writeSentinel(sentinelPath: string): void {
|
|
107
|
+
try {
|
|
108
|
+
writeFileSync(sentinelPath, new Date().toISOString() + "\n", "utf-8");
|
|
109
|
+
} catch {
|
|
110
|
+
// Best-effort — if we can't write the sentinel, the migration runner's
|
|
111
|
+
// checkpoint will still prevent re-running the migration function.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -10,6 +10,7 @@ import { appDirRenameMigration } from "./010-app-dir-rename.js";
|
|
|
10
10
|
import { backfillInstallationIdMigration } from "./011-backfill-installation-id.js";
|
|
11
11
|
import { renameConversationDiskViewDirsMigration } from "./012-rename-conversation-disk-view-dirs.js";
|
|
12
12
|
import { repairConversationDiskViewMigration } from "./013-repair-conversation-disk-view.js";
|
|
13
|
+
import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
|
|
13
14
|
import type { WorkspaceMigration } from "./types.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -29,4 +30,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
|
|
|
29
30
|
appDirRenameMigration,
|
|
30
31
|
renameConversationDiskViewDirsMigration,
|
|
31
32
|
repairConversationDiskViewMigration,
|
|
33
|
+
migrateToWorkspaceVolumeMigration,
|
|
32
34
|
];
|