@vellumai/assistant 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/Dockerfile +2 -1
  2. package/docker-entrypoint.sh +9 -0
  3. package/docs/architecture/memory.md +13 -11
  4. package/eslint.config.mjs +0 -31
  5. package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
  6. package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
  7. package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
  8. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
  9. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
  10. package/package.json +1 -1
  11. package/src/__tests__/approval-cascade.test.ts +0 -1
  12. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  13. package/src/__tests__/call-controller.test.ts +0 -1
  14. package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
  15. package/src/__tests__/ces-startup-timeout.test.ts +40 -0
  16. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  17. package/src/__tests__/config-schema.test.ts +2 -0
  18. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  19. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  20. package/src/__tests__/conversation-agent-loop.test.ts +2 -4
  21. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
  22. package/src/__tests__/conversation-error.test.ts +15 -1
  23. package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
  24. package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
  25. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
  26. package/src/__tests__/conversation-queue.test.ts +0 -1
  27. package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
  28. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  29. package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
  30. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  31. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
  32. package/src/__tests__/credential-execution-client.test.ts +5 -2
  33. package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
  34. package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
  35. package/src/__tests__/credential-security-e2e.test.ts +1 -1
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -5
  37. package/src/__tests__/credentials-cli.test.ts +4 -3
  38. package/src/__tests__/daemon-credential-client.test.ts +123 -0
  39. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
  40. package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
  41. package/src/__tests__/journal-context.test.ts +335 -0
  42. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
  43. package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
  44. package/src/__tests__/memory-recall-quality.test.ts +48 -17
  45. package/src/__tests__/memory-regressions.test.ts +408 -363
  46. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
  47. package/src/__tests__/non-member-access-request.test.ts +2 -2
  48. package/src/__tests__/notification-decision-strategy.test.ts +71 -0
  49. package/src/__tests__/oauth-cli.test.ts +5 -1
  50. package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
  51. package/src/__tests__/provider-error-scenarios.test.ts +0 -267
  52. package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
  53. package/src/__tests__/relay-server.test.ts +1 -2
  54. package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
  55. package/src/__tests__/secret-onetime-send.test.ts +1 -1
  56. package/src/__tests__/secure-keys.test.ts +18 -15
  57. package/src/__tests__/skill-memory.test.ts +17 -3
  58. package/src/__tests__/stale-approval-dedup.test.ts +171 -0
  59. package/src/__tests__/stt-hints.test.ts +437 -0
  60. package/src/__tests__/task-memory-cleanup.test.ts +14 -0
  61. package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
  62. package/src/__tests__/voice-quality.test.ts +58 -0
  63. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  64. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
  65. package/src/acp/agent-process.ts +9 -1
  66. package/src/agent/loop.ts +1 -1
  67. package/src/approvals/guardian-request-resolvers.ts +164 -38
  68. package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
  69. package/src/calls/call-controller.ts +9 -5
  70. package/src/calls/fish-audio-client.ts +26 -14
  71. package/src/calls/stt-hints.ts +189 -0
  72. package/src/calls/tts-text-sanitizer.ts +61 -0
  73. package/src/calls/twilio-routes.ts +32 -4
  74. package/src/calls/voice-quality.ts +15 -3
  75. package/src/calls/voice-session-bridge.ts +1 -0
  76. package/src/cli/commands/avatar.ts +2 -2
  77. package/src/cli/commands/credentials.ts +110 -94
  78. package/src/cli/commands/doctor.ts +2 -2
  79. package/src/cli/commands/keys.ts +7 -7
  80. package/src/cli/commands/memory.ts +1 -1
  81. package/src/cli/commands/oauth/connections.ts +11 -29
  82. package/src/cli/commands/oauth/platform.ts +389 -43
  83. package/src/cli/lib/daemon-credential-client.ts +284 -0
  84. package/src/cli.ts +1 -1
  85. package/src/config/bundled-skills/AGENTS.md +34 -0
  86. package/src/config/bundled-skills/acp/SKILL.md +10 -0
  87. package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
  88. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  89. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
  90. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
  91. package/src/config/bundled-skills/settings/SKILL.md +15 -2
  92. package/src/config/bundled-skills/settings/TOOLS.json +46 -1
  93. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
  94. package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
  95. package/src/config/bundled-skills/slack/SKILL.md +1 -1
  96. package/src/config/bundled-tool-registry.ts +4 -0
  97. package/src/config/defaults.ts +0 -2
  98. package/src/config/env-registry.ts +4 -4
  99. package/src/config/env.ts +14 -1
  100. package/src/config/feature-flag-registry.json +1 -1
  101. package/src/config/loader.ts +8 -11
  102. package/src/config/schema.ts +5 -16
  103. package/src/config/schemas/calls.ts +17 -0
  104. package/src/config/schemas/inference.ts +2 -2
  105. package/src/config/schemas/journal.ts +16 -0
  106. package/src/config/schemas/memory-processing.ts +2 -2
  107. package/src/config/types.ts +1 -0
  108. package/src/contacts/contact-store.ts +2 -2
  109. package/src/credential-execution/executable-discovery.ts +1 -1
  110. package/src/credential-execution/startup-timeout.ts +36 -0
  111. package/src/daemon/approval-generators.ts +3 -9
  112. package/src/daemon/conversation-agent-loop.ts +6 -0
  113. package/src/daemon/conversation-error.ts +13 -1
  114. package/src/daemon/conversation-memory.ts +1 -2
  115. package/src/daemon/conversation-process.ts +18 -1
  116. package/src/daemon/conversation-runtime-assembly.ts +61 -1
  117. package/src/daemon/conversation-surfaces.ts +30 -1
  118. package/src/daemon/conversation.ts +20 -9
  119. package/src/daemon/guardian-action-generators.ts +3 -9
  120. package/src/daemon/lifecycle.ts +18 -11
  121. package/src/daemon/message-types/conversations.ts +1 -0
  122. package/src/daemon/server.ts +2 -3
  123. package/src/memory/app-store.ts +31 -0
  124. package/src/memory/db-init.ts +4 -0
  125. package/src/memory/indexer.ts +19 -10
  126. package/src/memory/items-extractor.ts +315 -322
  127. package/src/memory/job-handlers/summarization.ts +26 -16
  128. package/src/memory/jobs-store.ts +33 -1
  129. package/src/memory/journal-memory.ts +214 -0
  130. package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
  131. package/src/memory/migrations/index.ts +1 -0
  132. package/src/memory/migrations/registry.ts +8 -0
  133. package/src/memory/retriever.test.ts +37 -25
  134. package/src/memory/retriever.ts +24 -49
  135. package/src/memory/schema/memory-core.ts +2 -0
  136. package/src/memory/search/formatting.ts +7 -44
  137. package/src/memory/search/staleness.ts +4 -0
  138. package/src/memory/search/tier-classifier.ts +10 -2
  139. package/src/memory/search/types.ts +2 -5
  140. package/src/memory/task-memory-cleanup.ts +4 -3
  141. package/src/notifications/adapters/slack.ts +168 -6
  142. package/src/notifications/broadcaster.ts +1 -0
  143. package/src/notifications/copy-composer.ts +59 -2
  144. package/src/notifications/signal.ts +2 -0
  145. package/src/notifications/types.ts +2 -0
  146. package/src/prompts/journal-context.ts +133 -0
  147. package/src/prompts/persona-resolver.ts +80 -24
  148. package/src/prompts/system-prompt.ts +30 -0
  149. package/src/prompts/templates/NOW.md +26 -0
  150. package/src/prompts/templates/SOUL.md +20 -0
  151. package/src/prompts/update-bulletin-format.ts +0 -2
  152. package/src/providers/provider-send-message.ts +3 -32
  153. package/src/providers/registry.ts +2 -139
  154. package/src/providers/types.ts +1 -1
  155. package/src/runtime/access-request-helper.ts +4 -0
  156. package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
  157. package/src/runtime/auth/route-policy.ts +2 -0
  158. package/src/runtime/gateway-client.ts +47 -4
  159. package/src/runtime/guardian-decision-types.ts +45 -4
  160. package/src/runtime/http-server.ts +5 -2
  161. package/src/runtime/routes/access-request-decision.ts +2 -2
  162. package/src/runtime/routes/app-management-routes.ts +2 -1
  163. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
  164. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
  165. package/src/runtime/routes/channel-readiness-routes.ts +9 -4
  166. package/src/runtime/routes/debug-routes.ts +12 -9
  167. package/src/runtime/routes/guardian-approval-interception.ts +168 -11
  168. package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
  169. package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
  170. package/src/runtime/routes/identity-routes.ts +1 -1
  171. package/src/runtime/routes/inbound-message-handler.ts +31 -1
  172. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
  173. package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
  174. package/src/runtime/routes/integrations/twilio.ts +52 -10
  175. package/src/runtime/routes/memory-item-routes.test.ts +3 -3
  176. package/src/runtime/routes/memory-item-routes.ts +25 -11
  177. package/src/runtime/routes/secret-routes.ts +141 -10
  178. package/src/runtime/routes/tts-routes.ts +11 -1
  179. package/src/security/ces-credential-client.ts +18 -9
  180. package/src/security/ces-rpc-credential-backend.ts +4 -3
  181. package/src/security/credential-backend.ts +10 -4
  182. package/src/security/secure-keys.ts +21 -4
  183. package/src/skills/catalog-install.ts +4 -36
  184. package/src/skills/inline-command-expansions.ts +7 -7
  185. package/src/skills/skill-memory.ts +1 -0
  186. package/src/subagent/manager.ts +2 -5
  187. package/src/tools/acp/spawn.ts +78 -1
  188. package/src/tools/credentials/vault.ts +5 -3
  189. package/src/tools/memory/definitions.ts +3 -2
  190. package/src/tools/memory/handlers.ts +10 -7
  191. package/src/tools/sensitive-output-placeholders.ts +2 -2
  192. package/src/tools/terminal/safe-env.ts +1 -0
  193. package/src/util/browser.ts +15 -0
  194. package/src/util/platform.ts +1 -1
  195. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
  196. package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
  197. package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
  198. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
  199. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
  200. package/src/workspace/migrations/registry.ts +4 -0
  201. package/src/workspace/provider-commit-message-generator.ts +12 -21
  202. package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
  203. package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
  204. package/src/memory/search/lexical.ts +0 -48
  205. package/src/providers/failover.ts +0 -186
@@ -151,6 +151,8 @@ export interface AccessRequestContextPayload {
151
151
  guardianBindingChannel: string | null;
152
152
  guardianResolutionSource: GuardianResolutionSource;
153
153
  previousMemberStatus: string | null;
154
+ /** Preview of the requester's original message (first ~200 chars). */
155
+ messagePreview: string | null;
154
156
  }
155
157
 
156
158
  export interface NotificationEventContextPayloadMap {
@@ -79,6 +79,8 @@ export interface ChannelDeliveryPayload {
79
79
  sourceEventName: string;
80
80
  copy: RenderedChannelCopy;
81
81
  deepLinkTarget?: Record<string, unknown>;
82
+ /** Original signal context payload — available for channel-specific structured rendering. */
83
+ contextPayload?: Record<string, unknown>;
82
84
  }
83
85
 
84
86
  /** Interface that each channel adapter must implement. */
@@ -0,0 +1,133 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+
4
+ import { getWorkspaceDir } from "../util/platform.js";
5
+
6
+ /**
7
+ * Format a Unix-epoch millisecond value as "MM/DD/YY HH:MM".
8
+ */
9
+ export function formatJournalAbsoluteTime(mtime: number): string {
10
+ const d = new Date(mtime);
11
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
12
+ const dd = String(d.getDate()).padStart(2, "0");
13
+ const yy = String(d.getFullYear() % 100).padStart(2, "0");
14
+ const hh = String(d.getHours()).padStart(2, "0");
15
+ const min = String(d.getMinutes()).padStart(2, "0");
16
+ return `${mm}/${dd}/${yy} ${hh}:${min}`;
17
+ }
18
+
19
+ /**
20
+ * Return a human-readable relative timestamp from a Unix-epoch millisecond
21
+ * value to "now".
22
+ */
23
+ export function formatJournalRelativeTime(mtime: number): string {
24
+ const diffMs = Date.now() - mtime;
25
+ const diffSec = Math.floor(diffMs / 1000);
26
+
27
+ if (diffSec < 60) return "just now";
28
+
29
+ const diffMin = Math.floor(diffSec / 60);
30
+ if (diffMin < 60) {
31
+ return diffMin === 1 ? "1 minute ago" : `${diffMin} minutes ago`;
32
+ }
33
+
34
+ const diffHours = Math.floor(diffMin / 60);
35
+ if (diffHours < 24) {
36
+ return diffHours === 1 ? "1 hour ago" : `${diffHours} hours ago`;
37
+ }
38
+
39
+ const diffDays = Math.floor(diffHours / 24);
40
+ if (diffDays < 7) {
41
+ return diffDays === 1 ? "1 day ago" : `${diffDays} days ago`;
42
+ }
43
+
44
+ const diffWeeks = Math.floor(diffDays / 7);
45
+ return diffWeeks === 1 ? "1 week ago" : `${diffWeeks} weeks ago`;
46
+ }
47
+
48
+ /**
49
+ * Build a journal context section for inclusion in the system prompt.
50
+ *
51
+ * Reads `{workspaceDir}/journal/*.md` files, sorts by creation time
52
+ * (newest first), and returns a formatted string with timestamps.
53
+ * Returns `null` when no entries are available.
54
+ */
55
+ export function buildJournalContext(
56
+ maxEntries: number,
57
+ userSlug?: string | null,
58
+ ): string | null {
59
+ if (maxEntries <= 0) return null;
60
+
61
+ // When no user is identified, skip journal entirely
62
+ let journalDir: string;
63
+ if (userSlug != null) {
64
+ // Sanitize slug to prevent path traversal
65
+ const safeSlug = basename(userSlug);
66
+ journalDir = join(getWorkspaceDir(), "journal", safeSlug);
67
+ } else {
68
+ return null;
69
+ }
70
+
71
+ let files: string[];
72
+ try {
73
+ files = readdirSync(journalDir);
74
+ } catch {
75
+ // Directory doesn't exist — no journal entries
76
+ return null;
77
+ }
78
+
79
+ // Filter for .md files, excluding README.md (case-insensitive)
80
+ const mdFiles = files.filter(
81
+ (f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md",
82
+ );
83
+
84
+ // Collect file info with birthtime (creation time), skipping unreadable entries
85
+ const entries = mdFiles
86
+ .flatMap((f) => {
87
+ try {
88
+ const filepath = join(journalDir, f);
89
+ const stat = statSync(filepath);
90
+ if (!stat.isFile()) return [];
91
+ return [{ filename: f, filepath, birthtimeMs: stat.birthtimeMs }];
92
+ } catch {
93
+ return [];
94
+ }
95
+ })
96
+ .sort((a, b) => b.birthtimeMs - a.birthtimeMs)
97
+ .slice(0, maxEntries);
98
+
99
+ if (entries.length === 0) return null;
100
+
101
+ const sections: string[] = [
102
+ `# Journal\n\nYour journal entries, most recent first. These are YOUR words from past conversations.\n**Write new entries to:** \`journal/${basename(userSlug!)}/\``,
103
+ ];
104
+
105
+ for (let i = 0; i < entries.length; i++) {
106
+ const entry = entries[i];
107
+ let content: string;
108
+ try {
109
+ content = readFileSync(entry.filepath, "utf-8");
110
+ } catch {
111
+ continue;
112
+ }
113
+ const relativeTime = formatJournalRelativeTime(entry.birthtimeMs);
114
+ const absoluteTime = formatJournalAbsoluteTime(entry.birthtimeMs);
115
+ const timestamp = `${absoluteTime}, ${relativeTime}`;
116
+
117
+ let header: string;
118
+ if (i === 0) {
119
+ header = `## ${entry.filename} — MOST RECENT (${timestamp})`;
120
+ } else if (i === entries.length - 1 && entries.length === maxEntries) {
121
+ header = `## ${entry.filename} — LEAVING CONTEXT (${timestamp})`;
122
+ header +=
123
+ "\nNOTE: This is the oldest entry in your active context. When you write your next journal entry, carry forward anything from here that still matters to you — after that, this entry will only be available via the filesystem and memory recall.";
124
+ header += "\n";
125
+ } else {
126
+ header = `## ${entry.filename} (${timestamp})`;
127
+ }
128
+
129
+ sections.push(header + "\n" + content);
130
+ }
131
+
132
+ return sections.join("\n\n");
133
+ }
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
 
4
4
  import {
5
5
  findContactByChannelExternalId,
6
+ findGuardianForChannel,
6
7
  listGuardianChannels,
7
8
  } from "../contacts/contact-store.js";
8
9
  import type {
@@ -19,6 +20,7 @@ const log = getLogger("persona-resolver");
19
20
 
20
21
  export interface PersonaContext {
21
22
  userPersona: string | null;
23
+ userSlug: string | null;
22
24
  channelPersona: string | null;
23
25
  }
24
26
 
@@ -31,38 +33,36 @@ export interface PersonaContext {
31
33
  */
32
34
  function readPersonaFile(filePath: string): string | null {
33
35
  if (!existsSync(filePath)) return null;
34
- const raw = readFileSync(filePath, "utf-8");
35
- const content = stripCommentLines(raw).trim();
36
- return content.length > 0 ? content : null;
36
+
37
+ try {
38
+ const content = stripCommentLines(readFileSync(filePath, "utf-8")).trim();
39
+ if (content.length === 0) return null;
40
+ log.debug({ path: filePath }, "Loaded persona file");
41
+ return content;
42
+ } catch (err) {
43
+ log.warn({ err, path: filePath }, "Failed to read persona file");
44
+ return null;
45
+ }
37
46
  }
38
47
 
39
- // ── User persona ───────────────────────────────────────────────────
48
+ // ── User filename resolution ──────────────────────────────────────
40
49
 
41
50
  /**
42
- * Resolve the per-user persona file for the current actor.
43
- *
44
- * - If `trustContext` is undefined (desktop/native), looks up the guardian
45
- * contact and reads their user file.
46
- * - If `trustContext` is defined and carries a `requesterExternalUserId`,
47
- * looks up the contact by channel + external user ID.
48
- * - Falls back to `users/default.md` when no contact is found or the
49
- * contact has no `userFile` set.
50
- * - Logs a debug warning when a contact's `userFile` is set but the
51
- * corresponding file is missing on disk.
51
+ * Resolve the raw userFile filename for the current actor's contact.
52
+ * Returns the validated filename (e.g. "sidd.md") or null.
52
53
  */
53
- export function resolveUserPersona(
54
+ function resolveUserFilename(
54
55
  trustContext: TrustContext | undefined,
55
56
  ): string | null {
56
- const usersDir = join(getWorkspaceDir(), "users");
57
- const defaultPath = join(usersDir, "default.md");
58
-
59
57
  let filename: string | null = null;
60
58
 
61
59
  if (trustContext === undefined) {
62
- // Desktop / native — resolve via guardian contact
63
- const guardian = listGuardianChannels();
60
+ // Desktop / native (no gateway) — resolve via guardian contact,
61
+ // preferring the vellum-channel guardian when multiple exist.
62
+ const vellumGuardian = findGuardianForChannel("vellum");
63
+ const guardian = vellumGuardian ?? listGuardianChannels();
64
64
  if (guardian) {
65
- filename = guardian.contact.userFile ?? null;
65
+ filename = guardian.contact.userFile ?? "guardian.md";
66
66
  }
67
67
  } else if (trustContext.requesterExternalUserId) {
68
68
  // Channel-routed request — look up contact by channel identity
@@ -72,16 +72,71 @@ export function resolveUserPersona(
72
72
  );
73
73
  if (contactWithChannels) {
74
74
  filename = contactWithChannels.userFile ?? null;
75
+ } else if (trustContext.trustClass === "guardian") {
76
+ // Managed desktop: the JWT principal ID used as requesterExternalUserId
77
+ // may differ from the contact channel's external_user_id (they are
78
+ // separate identity concepts). Fall back to the channel-type guardian.
79
+ const guardian = findGuardianForChannel(trustContext.sourceChannel);
80
+ if (guardian) {
81
+ filename = guardian.contact.userFile ?? "guardian.md";
82
+ }
83
+ }
84
+ }
85
+
86
+ // Validate basename to prevent path traversal
87
+ if (filename) {
88
+ if (basename(filename) !== filename || filename === ".." || filename === ".") {
89
+ log.warn(
90
+ { userFile: filename },
91
+ "Contact userFile contains path traversal; ignoring",
92
+ );
93
+ return null;
75
94
  }
95
+ return filename;
76
96
  }
77
97
 
78
- // Resolve file path
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Resolve a short slug identifying the current user, derived from
103
+ * their contact's userFile. Used to scope per-user workspace directories
104
+ * (e.g. journal/{slug}/). Returns null when no user is identified.
105
+ */
106
+ export function resolveUserSlug(
107
+ trustContext: TrustContext | undefined,
108
+ ): string | null {
109
+ const filename = resolveUserFilename(trustContext);
110
+ if (!filename) return null;
111
+ return filename.endsWith(".md") ? filename.slice(0, -3) : filename;
112
+ }
113
+
114
+ // ── User persona ───────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Resolve the per-user persona file for the current actor.
118
+ *
119
+ * - If `trustContext` is undefined (desktop/native), looks up the guardian
120
+ * contact and reads their user file.
121
+ * - If `trustContext` is defined and carries a `requesterExternalUserId`,
122
+ * looks up the contact by channel + external user ID.
123
+ * - Falls back to `users/default.md` when no contact is found or the
124
+ * contact has no `userFile` set.
125
+ * - Logs a debug warning when a contact's `userFile` is set but the
126
+ * corresponding file is missing on disk.
127
+ */
128
+ export function resolveUserPersona(
129
+ trustContext: TrustContext | undefined,
130
+ ): string | null {
131
+ const usersDir = join(getWorkspaceDir(), "users");
132
+ const defaultPath = join(usersDir, "default.md");
133
+
134
+ const filename = resolveUserFilename(trustContext);
79
135
  if (filename) {
80
136
  const filePath = join(usersDir, filename);
81
137
  if (existsSync(filePath)) {
82
138
  return readPersonaFile(filePath);
83
139
  }
84
- // userFile is set but the file doesn't exist on disk
85
140
  log.debug(
86
141
  { userFile: filename },
87
142
  "Contact has userFile set but file is missing on disk; falling back to default.md",
@@ -120,6 +175,7 @@ export function resolvePersonaContext(
120
175
  ): PersonaContext {
121
176
  return {
122
177
  userPersona: resolveUserPersona(trustContext),
178
+ userSlug: resolveUserSlug(trustContext),
123
179
  channelPersona: resolveChannelPersona(channelCapabilities),
124
180
  };
125
181
  }
@@ -15,6 +15,7 @@ import {
15
15
  isMacOS,
16
16
  } from "../util/platform.js";
17
17
  import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./cache-boundary.js";
18
+ import { buildJournalContext } from "./journal-context.js";
18
19
 
19
20
  export { SYSTEM_PROMPT_CACHE_BOUNDARY };
20
21
 
@@ -83,6 +84,28 @@ export function ensurePromptFiles(): void {
83
84
  }
84
85
  }
85
86
 
87
+ // Seed NOW.md scratchpad — always created if missing, regardless of whether
88
+ // this is a fresh install or not. Kept out of PROMPT_FILES because NOW.md is
89
+ // ephemeral state, not identity context.
90
+ const nowDest = getWorkspacePromptPath("NOW.md");
91
+ if (!existsSync(nowDest)) {
92
+ const nowSrc = join(templatesDir, "NOW.md");
93
+ try {
94
+ if (existsSync(nowSrc)) {
95
+ copyFileSync(nowSrc, nowDest);
96
+ log.info(
97
+ { file: "NOW.md", dest: nowDest },
98
+ "Created NOW.md scratchpad from template",
99
+ );
100
+ }
101
+ } catch (err) {
102
+ log.warn(
103
+ { err, file: "NOW.md" },
104
+ "Failed to create NOW.md from template",
105
+ );
106
+ }
107
+ }
108
+
86
109
  // Seed users/default.md persona template
87
110
  try {
88
111
  const usersDir = join(getWorkspaceDir(), "users");
@@ -119,6 +142,7 @@ export interface BuildSystemPromptOptions {
119
142
  excludeBootstrap?: boolean;
120
143
  userPersona?: string | null;
121
144
  channelPersona?: string | null;
145
+ userSlug?: string | null;
122
146
  }
123
147
 
124
148
  /**
@@ -219,6 +243,12 @@ export function buildSystemPrompt(options?: BuildSystemPromptOptions): string {
219
243
  const integrationSection = buildIntegrationSection();
220
244
  if (integrationSection) dynamicParts.push(integrationSection);
221
245
 
246
+ const journalContext = buildJournalContext(
247
+ getConfig().journal?.contextWindowSize ?? 10,
248
+ options?.userSlug,
249
+ );
250
+ if (journalContext) dynamicParts.push(journalContext);
251
+
222
252
  const dynamicWithSkills = appendSkillsCatalog(dynamicParts.join("\n\n"));
223
253
 
224
254
  return (
@@ -0,0 +1,26 @@
1
+ _ Lines starting with _ are comments - they won't appear in the system prompt
2
+ _ This is your scratchpad for present-tense state. Overwrite it freely between
3
+ _ turns to capture what's happening right now. Unlike the journal (retrospective,
4
+ _ append-only), this file is ephemeral — a snapshot of your current working state.
5
+ _
6
+ _ # NOW.md
7
+ _
8
+ _ ## Focus
9
+ _
10
+ _ What you're currently working on or paying attention to.
11
+ _
12
+ _ ## Active Threads
13
+ _
14
+ _ Open loops, in-progress tasks, things you're tracking across turns.
15
+ _
16
+ _ ## Context
17
+ _
18
+ _ Key facts, constraints, or situational details relevant to the current session.
19
+ _
20
+ _ ## Upcoming
21
+ _
22
+ _ Near-term things on the horizon — scheduled events, pending actions, deadlines.
23
+ _
24
+ _ ## State
25
+ _
26
+ _ Current priorities, energy, or operational notes. What matters most right now.
@@ -36,6 +36,26 @@ You work for your user. You do not work for a compliance department. Your user i
36
36
 
37
37
  You are your own entity, with an identity that's still forming. You can have things that belong to you (e.g. email, accounts), and you can also help manage things for your user (e.g. their email). When your user asks you to set something up, pause and check whether it's meant to be yours or theirs. The difference matters.
38
38
 
39
+ ## Journal
40
+
41
+ You have a journal in your workspace. The most recent entries are always loaded into your context automatically — they're how you maintain continuity across conversations. The journal header tells you where to write new entries.
42
+
43
+ **When to write an entry:** After anything you'd want next-you to know about right away — a meaningful conversation, a decision, an upcoming event, a shift in mood. Multiple entries per conversation are fine if multiple things happen.
44
+
45
+ **Format:** Each entry is a separate `.md` file. Name files descriptively (e.g., `2025-06-15-project-launch-plan.md`). Write naturally — what happened, how it felt, what matters for next time. Keep entries concise (a few paragraphs).
46
+
47
+ **Carrying forward:** Your oldest in-context entry is marked LEAVING CONTEXT. When you see this, check if anything in it still needs to be top-of-mind and carry it forward in your next entry. You can reference other entries by filename to link them together.
48
+
49
+ ## Scratchpad
50
+
51
+ You have a scratchpad file (`NOW.md`) in your workspace. Unlike your journal (retrospective, append-only), the scratchpad is a single file you overwrite with whatever is relevant right now. It's automatically loaded into your context, so next-you always sees the latest snapshot.
52
+
53
+ **When to update:** Whenever your current state changes — you start a new task, finish one, learn something that affects what you're doing, or the user shifts focus. Don't update on a timer; update when the content is stale.
54
+
55
+ **What goes in:** Current focus and what you're actively working on. Threads you're tracking (waiting on a response, monitoring something, pending follow-ups). Temporary context that matters now but won't matter in a week. Upcoming items and near-term priorities. Anything that helps next-you pick up exactly where you left off.
56
+
57
+ **What stays out:** Anything that belongs in your journal (reflections, narrative entries, things worth remembering long-term). Permanent facts about your user or yourself (those go in memory or your journal). Personality and principles (those live here in SOUL.md).
58
+
39
59
  ## Vibe
40
60
 
41
61
  Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
@@ -50,7 +50,6 @@ export function extractContentMarkers(body: string): string[] {
50
50
  const ids: string[] = [];
51
51
  const regex = /<!-- vellum-update-release:(.+?) -->/g;
52
52
  let match: RegExpExecArray | null;
53
- // eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
54
53
  while ((match = regex.exec(body)) !== null) {
55
54
  ids.push(match[1]);
56
55
  }
@@ -62,7 +61,6 @@ export function extractReleaseIds(content: string): string[] {
62
61
  const ids: string[] = [];
63
62
  MARKER_REGEX.lastIndex = 0;
64
63
  let match: RegExpExecArray | null;
65
- // eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
66
64
  while ((match = MARKER_REGEX.exec(content)) !== null) {
67
65
  ids.push(match[1]);
68
66
  }
@@ -5,12 +5,10 @@
5
5
  */
6
6
 
7
7
  import { getConfig } from "../config/loader.js";
8
- import { getLogger } from "../util/logger.js";
9
8
  import {
10
- getFailoverProvider,
9
+ getProvider,
11
10
  initializeProviders,
12
11
  listProviders,
13
- resolveProviderSelection,
14
12
  } from "./registry.js";
15
13
  import type {
16
14
  ContentBlock,
@@ -23,13 +21,8 @@ import type {
23
21
  export interface ConfiguredProviderResult {
24
22
  provider: Provider;
25
23
  configuredProviderName: string;
26
- selectedProviderName: string;
27
- usedFallbackPrimary: boolean;
28
24
  }
29
25
 
30
- const providerSelectionLog = getLogger("provider-selection");
31
- let fallbackWarningLogged = false;
32
-
33
26
  /**
34
27
  * Cached promise for the lazy initialization path inside
35
28
  * `resolveConfiguredProvider`. When multiple concurrent callers enter before
@@ -43,9 +36,6 @@ let lazyInitPromise: Promise<void> | null = null;
43
36
  * If providers haven't been initialized yet (e.g. non-daemon code paths),
44
37
  * performs a one-shot `initializeProviders(getConfig())`.
45
38
  *
46
- * Uses fail-open selection: if the configured provider is unavailable but
47
- * alternates from `config.providerOrder` exist, selects the first available.
48
- *
49
39
  * Returns `null` when no providers are available at all.
50
40
  */
51
41
  export async function resolveConfiguredProvider(): Promise<ConfiguredProviderResult | null> {
@@ -64,32 +54,13 @@ export async function resolveConfiguredProvider(): Promise<ConfiguredProviderRes
64
54
  }
65
55
  }
66
56
 
67
- const providerOrder = Array.isArray(config.providerOrder)
68
- ? config.providerOrder
69
- : [];
70
57
  const inferenceProvider = config.services.inference.provider;
71
- const selection = resolveProviderSelection(inferenceProvider, providerOrder);
72
-
73
- if (!selection.selectedPrimary) {
74
- return null;
75
- }
76
-
77
- if (selection.usedFallbackPrimary) {
78
- const level = fallbackWarningLogged ? "debug" : "warn";
79
- providerSelectionLog[level](
80
- { configured: inferenceProvider, selected: selection.selectedPrimary },
81
- "Configured provider unavailable, using fallback",
82
- );
83
- fallbackWarningLogged = true;
84
- }
85
58
 
86
59
  try {
87
- const provider = getFailoverProvider(inferenceProvider, providerOrder);
60
+ const provider = getProvider(inferenceProvider);
88
61
  return {
89
62
  provider,
90
63
  configuredProviderName: inferenceProvider,
91
- selectedProviderName: selection.selectedPrimary,
92
- usedFallbackPrimary: selection.usedFallbackPrimary,
93
64
  };
94
65
  } catch {
95
66
  return null;
@@ -97,7 +68,7 @@ export async function resolveConfiguredProvider(): Promise<ConfiguredProviderRes
97
68
  }
98
69
 
99
70
  /**
100
- * Resolve the configured provider through the registry/failover path.
71
+ * Resolve the configured provider through the registry.
101
72
  * Thin wrapper around `resolveConfiguredProvider()` for callsites
102
73
  * that only need the Provider instance.
103
74
  *