@vellumai/assistant 0.5.7 → 0.5.8

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 (197) hide show
  1. package/Dockerfile +2 -1
  2. package/docker-entrypoint.sh +9 -0
  3. package/docs/architecture/memory.md +13 -11
  4. package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
  5. package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
  6. package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
  7. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
  8. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/approval-cascade.test.ts +0 -1
  11. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  12. package/src/__tests__/call-controller.test.ts +0 -1
  13. package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
  14. package/src/__tests__/ces-startup-timeout.test.ts +40 -0
  15. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  16. package/src/__tests__/config-schema.test.ts +2 -0
  17. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +2 -4
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
  21. package/src/__tests__/conversation-error.test.ts +15 -1
  22. package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
  23. package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
  24. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
  25. package/src/__tests__/conversation-queue.test.ts +0 -1
  26. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  27. package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
  30. package/src/__tests__/credential-execution-client.test.ts +5 -2
  31. package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
  32. package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
  33. package/src/__tests__/credential-security-e2e.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +2 -5
  35. package/src/__tests__/credentials-cli.test.ts +4 -3
  36. package/src/__tests__/daemon-credential-client.test.ts +123 -0
  37. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
  38. package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
  39. package/src/__tests__/journal-context.test.ts +335 -0
  40. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
  41. package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
  42. package/src/__tests__/memory-recall-quality.test.ts +48 -17
  43. package/src/__tests__/memory-regressions.test.ts +408 -363
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
  45. package/src/__tests__/non-member-access-request.test.ts +2 -2
  46. package/src/__tests__/notification-decision-strategy.test.ts +71 -0
  47. package/src/__tests__/oauth-cli.test.ts +5 -1
  48. package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
  49. package/src/__tests__/provider-error-scenarios.test.ts +0 -267
  50. package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
  51. package/src/__tests__/relay-server.test.ts +1 -2
  52. package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
  53. package/src/__tests__/secret-onetime-send.test.ts +1 -1
  54. package/src/__tests__/secure-keys.test.ts +18 -15
  55. package/src/__tests__/skill-memory.test.ts +17 -3
  56. package/src/__tests__/stale-approval-dedup.test.ts +171 -0
  57. package/src/__tests__/stt-hints.test.ts +437 -0
  58. package/src/__tests__/task-memory-cleanup.test.ts +14 -0
  59. package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
  60. package/src/__tests__/voice-quality.test.ts +58 -0
  61. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  62. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
  63. package/src/acp/agent-process.ts +9 -1
  64. package/src/agent/loop.ts +1 -1
  65. package/src/approvals/guardian-request-resolvers.ts +164 -38
  66. package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
  67. package/src/calls/call-controller.ts +9 -5
  68. package/src/calls/fish-audio-client.ts +26 -14
  69. package/src/calls/stt-hints.ts +189 -0
  70. package/src/calls/tts-text-sanitizer.ts +61 -0
  71. package/src/calls/twilio-routes.ts +32 -4
  72. package/src/calls/voice-quality.ts +15 -3
  73. package/src/calls/voice-session-bridge.ts +1 -0
  74. package/src/cli/commands/avatar.ts +2 -2
  75. package/src/cli/commands/credentials.ts +110 -94
  76. package/src/cli/commands/doctor.ts +2 -2
  77. package/src/cli/commands/keys.ts +7 -7
  78. package/src/cli/commands/memory.ts +1 -1
  79. package/src/cli/commands/oauth/connections.ts +11 -29
  80. package/src/cli/commands/oauth/platform.ts +389 -43
  81. package/src/cli/lib/daemon-credential-client.ts +284 -0
  82. package/src/cli.ts +1 -1
  83. package/src/config/bundled-skills/AGENTS.md +34 -0
  84. package/src/config/bundled-skills/acp/SKILL.md +10 -0
  85. package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
  86. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  87. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
  88. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
  89. package/src/config/bundled-skills/settings/SKILL.md +15 -2
  90. package/src/config/bundled-skills/settings/TOOLS.json +46 -1
  91. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
  92. package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
  93. package/src/config/bundled-skills/slack/SKILL.md +1 -1
  94. package/src/config/bundled-tool-registry.ts +4 -0
  95. package/src/config/defaults.ts +0 -2
  96. package/src/config/env-registry.ts +4 -4
  97. package/src/config/env.ts +14 -1
  98. package/src/config/feature-flag-registry.json +1 -1
  99. package/src/config/loader.ts +8 -11
  100. package/src/config/schema.ts +5 -16
  101. package/src/config/schemas/calls.ts +17 -0
  102. package/src/config/schemas/inference.ts +2 -2
  103. package/src/config/schemas/journal.ts +16 -0
  104. package/src/config/schemas/memory-processing.ts +2 -2
  105. package/src/config/types.ts +1 -0
  106. package/src/contacts/contact-store.ts +2 -2
  107. package/src/credential-execution/executable-discovery.ts +1 -1
  108. package/src/credential-execution/startup-timeout.ts +36 -0
  109. package/src/daemon/approval-generators.ts +3 -9
  110. package/src/daemon/conversation-error.ts +13 -1
  111. package/src/daemon/conversation-memory.ts +1 -2
  112. package/src/daemon/conversation-process.ts +18 -1
  113. package/src/daemon/conversation-surfaces.ts +30 -1
  114. package/src/daemon/conversation.ts +20 -9
  115. package/src/daemon/guardian-action-generators.ts +3 -9
  116. package/src/daemon/lifecycle.ts +18 -11
  117. package/src/daemon/message-types/conversations.ts +1 -0
  118. package/src/daemon/server.ts +2 -3
  119. package/src/memory/app-store.ts +31 -0
  120. package/src/memory/db-init.ts +4 -0
  121. package/src/memory/indexer.ts +19 -10
  122. package/src/memory/items-extractor.ts +315 -322
  123. package/src/memory/job-handlers/summarization.ts +26 -16
  124. package/src/memory/jobs-store.ts +33 -1
  125. package/src/memory/journal-memory.ts +214 -0
  126. package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
  127. package/src/memory/migrations/index.ts +1 -0
  128. package/src/memory/migrations/registry.ts +8 -0
  129. package/src/memory/retriever.test.ts +37 -25
  130. package/src/memory/retriever.ts +24 -49
  131. package/src/memory/schema/memory-core.ts +2 -0
  132. package/src/memory/search/formatting.ts +7 -44
  133. package/src/memory/search/staleness.ts +4 -0
  134. package/src/memory/search/tier-classifier.ts +10 -2
  135. package/src/memory/search/types.ts +2 -5
  136. package/src/memory/task-memory-cleanup.ts +4 -3
  137. package/src/notifications/adapters/slack.ts +168 -6
  138. package/src/notifications/broadcaster.ts +1 -0
  139. package/src/notifications/copy-composer.ts +59 -2
  140. package/src/notifications/signal.ts +2 -0
  141. package/src/notifications/types.ts +2 -0
  142. package/src/prompts/journal-context.ts +133 -0
  143. package/src/prompts/persona-resolver.ts +80 -24
  144. package/src/prompts/system-prompt.ts +8 -0
  145. package/src/prompts/templates/SOUL.md +10 -0
  146. package/src/providers/provider-send-message.ts +3 -32
  147. package/src/providers/registry.ts +2 -139
  148. package/src/providers/types.ts +1 -1
  149. package/src/runtime/access-request-helper.ts +4 -0
  150. package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
  151. package/src/runtime/auth/route-policy.ts +2 -0
  152. package/src/runtime/gateway-client.ts +47 -4
  153. package/src/runtime/guardian-decision-types.ts +45 -4
  154. package/src/runtime/http-server.ts +5 -2
  155. package/src/runtime/routes/access-request-decision.ts +2 -2
  156. package/src/runtime/routes/app-management-routes.ts +2 -1
  157. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
  158. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
  159. package/src/runtime/routes/channel-readiness-routes.ts +9 -4
  160. package/src/runtime/routes/debug-routes.ts +12 -9
  161. package/src/runtime/routes/guardian-approval-interception.ts +168 -11
  162. package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
  163. package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
  164. package/src/runtime/routes/identity-routes.ts +1 -1
  165. package/src/runtime/routes/inbound-message-handler.ts +31 -1
  166. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
  167. package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
  168. package/src/runtime/routes/integrations/twilio.ts +52 -10
  169. package/src/runtime/routes/memory-item-routes.test.ts +3 -3
  170. package/src/runtime/routes/memory-item-routes.ts +25 -11
  171. package/src/runtime/routes/secret-routes.ts +141 -10
  172. package/src/runtime/routes/tts-routes.ts +11 -1
  173. package/src/security/ces-credential-client.ts +18 -9
  174. package/src/security/ces-rpc-credential-backend.ts +4 -3
  175. package/src/security/credential-backend.ts +10 -4
  176. package/src/security/secure-keys.ts +21 -4
  177. package/src/skills/catalog-install.ts +4 -36
  178. package/src/skills/skill-memory.ts +1 -0
  179. package/src/subagent/manager.ts +2 -5
  180. package/src/tools/acp/spawn.ts +78 -1
  181. package/src/tools/credentials/vault.ts +5 -3
  182. package/src/tools/memory/definitions.ts +3 -2
  183. package/src/tools/memory/handlers.ts +10 -7
  184. package/src/tools/terminal/safe-env.ts +1 -0
  185. package/src/util/browser.ts +15 -0
  186. package/src/util/platform.ts +1 -1
  187. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
  188. package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
  189. package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
  190. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
  191. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
  192. package/src/workspace/migrations/registry.ts +4 -0
  193. package/src/workspace/provider-commit-message-generator.ts +12 -21
  194. package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
  195. package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
  196. package/src/memory/search/lexical.ts +0 -48
  197. 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
 
@@ -119,6 +120,7 @@ export interface BuildSystemPromptOptions {
119
120
  excludeBootstrap?: boolean;
120
121
  userPersona?: string | null;
121
122
  channelPersona?: string | null;
123
+ userSlug?: string | null;
122
124
  }
123
125
 
124
126
  /**
@@ -219,6 +221,12 @@ export function buildSystemPrompt(options?: BuildSystemPromptOptions): string {
219
221
  const integrationSection = buildIntegrationSection();
220
222
  if (integrationSection) dynamicParts.push(integrationSection);
221
223
 
224
+ const journalContext = buildJournalContext(
225
+ getConfig().journal?.contextWindowSize ?? 10,
226
+ options?.userSlug,
227
+ );
228
+ if (journalContext) dynamicParts.push(journalContext);
229
+
222
230
  const dynamicWithSkills = appendSkillsCatalog(dynamicParts.join("\n\n"));
223
231
 
224
232
  return (
@@ -36,6 +36,16 @@ 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
+
39
49
  ## Vibe
40
50
 
41
51
  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.
@@ -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
  *
@@ -1,7 +1,6 @@
1
1
  import { getProviderKeyAsync } from "../security/secure-keys.js";
2
- import { ConfigError, ProviderNotConfiguredError } from "../util/errors.js";
2
+ import { ProviderNotConfiguredError } from "../util/errors.js";
3
3
  import { AnthropicProvider } from "./anthropic/client.js";
4
- import { FailoverProvider, type ProviderHealthStatus } from "./failover.js";
5
4
  import { FireworksProvider } from "./fireworks/client.js";
6
5
  import { GeminiProvider } from "./gemini/client.js";
7
6
  import {
@@ -17,8 +16,6 @@ import type { Provider } from "./types.js";
17
16
 
18
17
  const providers = new Map<string, Provider>();
19
18
  const routingSources = new Map<string, "user-key" | "managed-proxy">();
20
- let cachedFailoverProvider: FailoverProvider | null = null;
21
- let cachedFailoverKey: string | null = null;
22
19
 
23
20
  export function registerProvider(name: string, provider: Provider): void {
24
21
  providers.set(name, provider);
@@ -27,95 +24,11 @@ export function registerProvider(name: string, provider: Provider): void {
27
24
  export function getProvider(name: string): Provider {
28
25
  const provider = providers.get(name);
29
26
  if (!provider) {
30
- throw new ConfigError(
31
- `Provider "${name}" not found. Available: ${listProviders().join(", ")}`,
32
- );
27
+ throw new ProviderNotConfiguredError(name, listProviders());
33
28
  }
34
29
  return provider;
35
30
  }
36
31
 
37
- export interface ProviderSelection {
38
- /** Ordered list of available provider names */
39
- availableProviders: string[];
40
- /** The selected (effective) primary provider name, or null if none available */
41
- selectedPrimary: string | null;
42
- /** Whether the effective primary differs from the requested primary */
43
- usedFallbackPrimary: boolean;
44
- }
45
-
46
- /**
47
- * Resolve provider selection from requested primary and provider order.
48
- * Dedupes [requestedPrimary, ...providerOrder], filtered to initialized providers.
49
- * Returns null selectedPrimary when no providers are available.
50
- */
51
- export function resolveProviderSelection(
52
- requestedPrimary: string,
53
- providerOrder: string[],
54
- ): ProviderSelection {
55
- const ordered: string[] = [];
56
- const seen = new Set<string>();
57
-
58
- for (const name of [requestedPrimary, ...providerOrder]) {
59
- if (seen.has(name)) continue;
60
- seen.add(name);
61
- if (providers.has(name)) {
62
- ordered.push(name);
63
- }
64
- }
65
-
66
- if (ordered.length === 0) {
67
- return {
68
- availableProviders: [],
69
- selectedPrimary: null,
70
- usedFallbackPrimary: false,
71
- };
72
- }
73
-
74
- return {
75
- availableProviders: ordered,
76
- selectedPrimary: ordered[0],
77
- usedFallbackPrimary: ordered[0] !== requestedPrimary,
78
- };
79
- }
80
-
81
- /**
82
- * Build a provider that tries the effective primary provider first, then falls
83
- * back to others in the configured order. If the requested primary is not
84
- * available, automatically selects the first available provider from the
85
- * deduped [primaryName, ...providerOrder] list (fail-open).
86
- *
87
- * Throws ConfigError only when NO providers are available at all.
88
- * Caches the FailoverProvider instance so health state persists across calls.
89
- */
90
- export function getFailoverProvider(
91
- primaryName: string,
92
- providerOrder: string[],
93
- ): Provider {
94
- const selection = resolveProviderSelection(primaryName, providerOrder);
95
-
96
- if (!selection.selectedPrimary) {
97
- throw new ProviderNotConfiguredError(primaryName, listProviders());
98
- }
99
-
100
- const orderedProviders: Provider[] = selection.availableProviders.map(
101
- (name) => providers.get(name)!,
102
- );
103
-
104
- if (orderedProviders.length === 1) {
105
- return orderedProviders[0];
106
- }
107
-
108
- // Cache key from effective ordered providers (not raw input strings)
109
- const cacheKey = selection.availableProviders.join(",");
110
- if (cachedFailoverProvider && cachedFailoverKey === cacheKey) {
111
- return cachedFailoverProvider;
112
- }
113
-
114
- cachedFailoverProvider = new FailoverProvider(orderedProviders);
115
- cachedFailoverKey = cacheKey;
116
- return cachedFailoverProvider;
117
- }
118
-
119
32
  export function listProviders(): string[] {
120
33
  return Array.from(providers.keys());
121
34
  }
@@ -151,7 +64,6 @@ export interface ProvidersConfig {
151
64
  provider: string;
152
65
  };
153
66
  };
154
- providerOrder?: string[];
155
67
  timeouts?: { providerStreamTimeoutSec?: number };
156
68
  }
157
69
 
@@ -172,53 +84,6 @@ function resolveModel(config: ProvidersConfig, providerName: string): string {
172
84
  return getProviderDefaultModel(providerName);
173
85
  }
174
86
 
175
- export interface ProviderDebugStatus {
176
- configuredPrimary: string;
177
- activePrimary: string | null;
178
- usedFallback: boolean;
179
- registeredProviders: string[];
180
- failoverHealth: ProviderHealthStatus[] | null;
181
- overallHealth: "healthy" | "degraded" | "down";
182
- routingSources: Record<string, "user-key" | "managed-proxy">;
183
- }
184
-
185
- export function getProviderDebugStatus(
186
- configuredProvider: string,
187
- providerOrder: string[],
188
- ): ProviderDebugStatus {
189
- const registered = listProviders();
190
- const selection = resolveProviderSelection(configuredProvider, providerOrder);
191
-
192
- let failoverHealth: ProviderHealthStatus[] | null = null;
193
- if (cachedFailoverProvider) {
194
- failoverHealth = cachedFailoverProvider.getHealthStatus();
195
- }
196
-
197
- let overallHealth: "healthy" | "degraded" | "down" = "down";
198
- if (registered.length > 0 && selection.selectedPrimary) {
199
- if (!failoverHealth) {
200
- overallHealth = "healthy";
201
- } else {
202
- const healthyCount = failoverHealth.filter((h) => h.healthy).length;
203
- if (healthyCount === failoverHealth.length) {
204
- overallHealth = "healthy";
205
- } else if (healthyCount > 0) {
206
- overallHealth = "degraded";
207
- }
208
- }
209
- }
210
-
211
- return {
212
- configuredPrimary: configuredProvider,
213
- activePrimary: selection.selectedPrimary,
214
- usedFallback: selection.usedFallbackPrimary,
215
- registeredProviders: registered,
216
- failoverHealth,
217
- overallHealth,
218
- routingSources: Object.fromEntries(routingSources),
219
- };
220
- }
221
-
222
87
  /**
223
88
  * Resolve provider credentials using mode-aware logic.
224
89
  * In "managed" mode, routes through the platform proxy.
@@ -273,8 +138,6 @@ export async function initializeProviders(
273
138
  ): Promise<void> {
274
139
  providers.clear();
275
140
  routingSources.clear();
276
- cachedFailoverProvider = null;
277
- cachedFailoverKey = null;
278
141
 
279
142
  const streamTimeoutMs =
280
143
  (config.timeouts?.providerStreamTimeoutSec ?? 300) * 1000;
@@ -130,7 +130,7 @@ export type ProviderEvent =
130
130
  export interface SendMessageConfig {
131
131
  model?: string;
132
132
  modelIntent?: ModelIntent;
133
- effort?: "low" | "medium" | "high";
133
+ effort?: "low" | "medium" | "high" | "max";
134
134
  [key: string]: unknown;
135
135
  }
136
136
 
@@ -55,6 +55,8 @@ export interface AccessRequestParams {
55
55
  actorDisplayName?: string;
56
56
  actorUsername?: string;
57
57
  previousMemberStatus?: Exclude<ChannelStatus, "unverified">;
58
+ /** Preview of the requester's original message, shown to the guardian. */
59
+ messagePreview?: string;
58
60
  }
59
61
 
60
62
  export type AccessRequestResult =
@@ -90,6 +92,7 @@ export function notifyGuardianOfAccessRequest(
90
92
  actorDisplayName,
91
93
  actorUsername,
92
94
  previousMemberStatus,
95
+ messagePreview,
93
96
  } = params;
94
97
 
95
98
  if (!actorExternalId) {
@@ -244,6 +247,7 @@ export function notifyGuardianOfAccessRequest(
244
247
  guardianBindingChannel,
245
248
  guardianResolutionSource,
246
249
  previousMemberStatus: previousMemberStatus ?? null,
250
+ messagePreview: messagePreview ?? null,
247
251
  },
248
252
  dedupeKey: `access-request:${canonicalRequest.id}`,
249
253
  onConversationCreated: (info) => {