@vellumai/assistant 0.8.11 → 0.8.12-staging.1

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 (219) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +126 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-usage-store.test.ts +11 -0
  37. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  38. package/src/__tests__/model-intents.test.ts +1 -1
  39. package/src/__tests__/oauth-cli.test.ts +19 -8
  40. package/src/__tests__/openai-provider.test.ts +34 -0
  41. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  42. package/src/__tests__/recurrence-engine.test.ts +45 -0
  43. package/src/__tests__/schedule-routes.test.ts +34 -0
  44. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  45. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  46. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  47. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  48. package/src/__tests__/subagent-tools.test.ts +1 -0
  49. package/src/__tests__/system-prompt.test.ts +109 -11
  50. package/src/__tests__/tool-error-hook.test.ts +1 -0
  51. package/src/__tests__/tool-result-spool.test.ts +337 -0
  52. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  53. package/src/__tests__/validate-input.test.ts +95 -1
  54. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  55. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  56. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  57. package/src/agent/loop.ts +81 -24
  58. package/src/api/events/usage-progress.ts +28 -0
  59. package/src/api/index.ts +6 -0
  60. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  61. package/src/bundler/app-bundler.ts +25 -42
  62. package/src/calls/call-controller.ts +1 -1
  63. package/src/cli/commands/plugins.ts +248 -15
  64. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  65. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  66. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  67. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  68. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  69. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +301 -0
  70. package/src/cli/lib/inspect-plugin.ts +252 -0
  71. package/src/cli/lib/install-from-github.ts +214 -21
  72. package/src/cli/lib/list-installed-plugins.ts +17 -6
  73. package/src/cli/lib/plugin-artifact.ts +103 -0
  74. package/src/cli/lib/plugin-details.ts +18 -1
  75. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  76. package/src/cli/lib/upgrade-plugin.ts +219 -0
  77. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  78. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  79. package/src/config/call-site-defaults.ts +13 -2
  80. package/src/config/feature-flag-registry.json +8 -16
  81. package/src/config/loader.ts +52 -59
  82. package/src/config/schema.ts +0 -2
  83. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  84. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  85. package/src/config/schemas/llm.ts +10 -0
  86. package/src/config/schemas/memory-v2.ts +13 -0
  87. package/src/config/schemas/memory-v3.ts +92 -0
  88. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  89. package/src/context/tool-result-spool.ts +104 -0
  90. package/src/credential-execution/feature-gates.ts +0 -1
  91. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  92. package/src/daemon/conversation-error.ts +6 -15
  93. package/src/daemon/conversation.ts +9 -0
  94. package/src/daemon/disk-pressure-policy.ts +0 -1
  95. package/src/daemon/lifecycle.ts +1 -20
  96. package/src/daemon/message-types/conversations.ts +2 -15
  97. package/src/daemon/trust-context.ts +1 -1
  98. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  99. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  100. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  101. package/src/home/home-content-refresh.ts +43 -31
  102. package/src/home/home-greeting-cache.ts +8 -1
  103. package/src/home/home-greeting.ts +13 -9
  104. package/src/home/suggested-prompts.ts +77 -24
  105. package/src/ipc/routes/trust-rules.test.ts +66 -72
  106. package/src/media/image-credentials.ts +2 -2
  107. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  108. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  109. package/src/memory/conversation-attention-store.ts +1 -0
  110. package/src/memory/conversation-bootstrap.ts +18 -9
  111. package/src/memory/conversation-crud.ts +12 -2
  112. package/src/memory/conversation-title-service.ts +53 -9
  113. package/src/memory/delivery-channels.ts +0 -69
  114. package/src/memory/graph/extraction-job.ts +0 -15
  115. package/src/memory/guardian-action-store.ts +1 -376
  116. package/src/memory/llm-usage-store.ts +5 -1
  117. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  119. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  120. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  121. package/src/memory/v2/consolidation-job.ts +93 -9
  122. package/src/memory/v2/injection.ts +53 -0
  123. package/src/memory/v2/prompts/consolidation.ts +1 -0
  124. package/src/memory/v2/static-context.ts +13 -1
  125. package/src/memory/v2/sweep-job.ts +1 -1
  126. package/src/memory/v2/types.ts +5 -0
  127. package/src/plugin-api/types.ts +7 -0
  128. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  129. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  130. package/src/plugins/defaults/index.ts +25 -0
  131. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  132. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  133. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  134. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  135. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  136. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  137. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  138. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  139. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  140. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  141. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  142. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  143. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  144. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  145. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  146. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  147. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  148. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  149. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  150. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  151. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  152. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  153. package/src/prompts/cache-boundary.ts +17 -0
  154. package/src/prompts/sections.ts +50 -17
  155. package/src/prompts/system-prompt.ts +12 -4
  156. package/src/prompts/templates/system-sections.ts +22 -0
  157. package/src/providers/anthropic/client.ts +74 -28
  158. package/src/providers/gemini/client.ts +5 -1
  159. package/src/providers/minimax/client.ts +9 -0
  160. package/src/providers/model-intents.ts +2 -2
  161. package/src/providers/openai/chat-completions-provider.ts +2 -1
  162. package/src/providers/openai/responses-provider.ts +5 -1
  163. package/src/providers/retry.ts +8 -0
  164. package/src/providers/types.ts +11 -0
  165. package/src/runtime/AGENTS.md +6 -0
  166. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  167. package/src/runtime/agent-wake.ts +5 -5
  168. package/src/runtime/background-job-runner.ts +2 -2
  169. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  170. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  171. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  172. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  173. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  174. package/src/runtime/pre-first-message-gate.ts +1 -1
  175. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  176. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  177. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  178. package/src/runtime/routes/__tests__/plugins-routes.test.ts +1 -0
  179. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  180. package/src/runtime/routes/btw-routes.ts +0 -14
  181. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  182. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  183. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  184. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  185. package/src/runtime/routes/home-feed-routes.ts +10 -0
  186. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  187. package/src/runtime/routes/identity-routes.ts +76 -20
  188. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  189. package/src/runtime/routes/plugins-routes.ts +21 -0
  190. package/src/runtime/routes/schedule-routes.ts +19 -2
  191. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  192. package/src/schedule/recurrence-engine.ts +34 -0
  193. package/src/schedule/scheduler.ts +1 -0
  194. package/src/skills/validate-input.ts +41 -1
  195. package/src/subagent/types.ts +26 -1
  196. package/src/telemetry/types.ts +15 -1
  197. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  198. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  199. package/src/tools/apps/executors.ts +1 -1
  200. package/src/tools/skills/skill-tool-factory.ts +19 -8
  201. package/src/usage/types.ts +8 -1
  202. package/src/util/platform.ts +16 -0
  203. package/src/watcher/engine.ts +1 -0
  204. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  205. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  206. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  207. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  210. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  211. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  212. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  213. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  214. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  215. package/src/config/schemas/updates.ts +0 -14
  216. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  217. package/src/memory/conversation-starters-cadence.ts +0 -78
  218. package/src/prompts/update-bulletin-job.ts +0 -180
  219. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -0,0 +1,114 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ // ─── Mocks ─────────────────────────────────────────────────────────────
4
+
5
+ let greetingRefreshed = false;
6
+ let promptsRefreshed = false;
7
+ let greetingCalls = 0;
8
+ let promptsCalls = 0;
9
+ let greetingGate: Promise<void> = Promise.resolve();
10
+
11
+ mock.module("../home-greeting.js", () => ({
12
+ refreshPersonalizedGreeting: async () => {
13
+ greetingCalls++;
14
+ await greetingGate;
15
+ return greetingRefreshed;
16
+ },
17
+ }));
18
+
19
+ mock.module("../suggested-prompts.js", () => ({
20
+ refreshAssistantSuggestedPrompts: async () => {
21
+ promptsCalls++;
22
+ return promptsRefreshed;
23
+ },
24
+ }));
25
+
26
+ const publishSpy = mock<(event: unknown) => Promise<void>>(async () => {});
27
+ mock.module("../../runtime/assistant-event-hub.js", () => ({
28
+ assistantEventHub: { publish: publishSpy },
29
+ }));
30
+
31
+ mock.module("../../runtime/assistant-event.js", () => ({
32
+ buildAssistantEvent: (e: unknown) => e,
33
+ }));
34
+
35
+ mock.module("../../util/logger.js", () => ({
36
+ getLogger: () =>
37
+ new Proxy({} as Record<string, unknown>, {
38
+ get: () => () => {},
39
+ }),
40
+ }));
41
+
42
+ const { revalidateHomeContentInBackground } =
43
+ await import("../home-content-refresh.js");
44
+
45
+ async function settle(): Promise<void> {
46
+ // Let the fire-and-forget revalidation chain run to completion.
47
+ await new Promise((resolve) => setTimeout(resolve, 0));
48
+ await new Promise((resolve) => setTimeout(resolve, 0));
49
+ }
50
+
51
+ // ─── Tests ─────────────────────────────────────────────────────────────
52
+
53
+ describe("revalidateHomeContentInBackground", () => {
54
+ test("publishes home_feed_updated when content was refreshed", async () => {
55
+ publishSpy.mockClear();
56
+ greetingRefreshed = true;
57
+ promptsRefreshed = false;
58
+
59
+ revalidateHomeContentInBackground();
60
+ await settle();
61
+
62
+ expect(publishSpy).toHaveBeenCalledTimes(1);
63
+ expect(publishSpy.mock.calls[0]?.[0]).toMatchObject({
64
+ type: "home_feed_updated",
65
+ });
66
+ });
67
+
68
+ test("does not publish when both caches were fresh", async () => {
69
+ publishSpy.mockClear();
70
+ greetingRefreshed = false;
71
+ promptsRefreshed = false;
72
+
73
+ revalidateHomeContentInBackground();
74
+ await settle();
75
+
76
+ expect(publishSpy).not.toHaveBeenCalled();
77
+ });
78
+
79
+ test("concurrent calls share a single in-flight revalidation", async () => {
80
+ greetingCalls = 0;
81
+ promptsCalls = 0;
82
+ greetingRefreshed = false;
83
+ promptsRefreshed = false;
84
+
85
+ let release!: () => void;
86
+ greetingGate = new Promise((resolve) => {
87
+ release = resolve;
88
+ });
89
+
90
+ revalidateHomeContentInBackground();
91
+ revalidateHomeContentInBackground();
92
+ revalidateHomeContentInBackground();
93
+
94
+ release();
95
+ greetingGate = Promise.resolve();
96
+ await settle();
97
+
98
+ expect(greetingCalls).toBe(1);
99
+ expect(promptsCalls).toBe(1);
100
+ });
101
+
102
+ test("a completed run allows a later revalidation", async () => {
103
+ greetingCalls = 0;
104
+ greetingRefreshed = false;
105
+ promptsRefreshed = false;
106
+
107
+ revalidateHomeContentInBackground();
108
+ await settle();
109
+ revalidateHomeContentInBackground();
110
+ await settle();
111
+
112
+ expect(greetingCalls).toBe(2);
113
+ });
114
+ });
@@ -4,6 +4,25 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
4
4
 
5
5
  const mockIntegrationSummary = "Gmail ✓ | Slack ✓ | Twilio ✗ | Telegram ✗";
6
6
  let mockSidechainText = "";
7
+ let sidechainCalls = 0;
8
+
9
+ // In-memory stand-in for the memory_checkpoints table so the cache
10
+ // round-trips without a real database.
11
+ const checkpointStore = new Map<string, string>();
12
+ let checkpointWritesShouldThrow = false;
13
+
14
+ mock.module("../../memory/checkpoints.js", () => ({
15
+ getMemoryCheckpoint: (key: string) => checkpointStore.get(key) ?? null,
16
+ setMemoryCheckpoint: (key: string, value: string) => {
17
+ if (checkpointWritesShouldThrow) {
18
+ throw new Error("synthetic checkpoint write failure");
19
+ }
20
+ checkpointStore.set(key, value);
21
+ },
22
+ deleteMemoryCheckpoint: (key: string) => {
23
+ checkpointStore.delete(key);
24
+ },
25
+ }));
7
26
 
8
27
  mock.module("../../schedule/integration-status.js", () => ({
9
28
  formatIntegrationSummary: async () => mockIntegrationSummary,
@@ -41,7 +60,10 @@ mock.module("../../prompts/system-prompt.js", () => ({
41
60
  }));
42
61
 
43
62
  mock.module("../../runtime/btw-sidechain.js", () => ({
44
- runBtwSidechain: async () => ({ text: mockSidechainText }),
63
+ runBtwSidechain: async () => {
64
+ sidechainCalls++;
65
+ return { text: mockSidechainText };
66
+ },
45
67
  }));
46
68
 
47
69
  mock.module("../../runtime/assistant-event.js", () => ({
@@ -62,8 +84,10 @@ const {
62
84
 
63
85
  describe("getSuggestedPrompts", () => {
64
86
  afterEach(() => {
87
+ checkpointWritesShouldThrow = false;
65
88
  invalidateAssistantSuggestedPromptsCache();
66
89
  mockSidechainText = "";
90
+ sidechainCalls = 0;
67
91
  });
68
92
 
69
93
  test("returns empty array before cache is populated", async () => {
@@ -98,12 +122,17 @@ describe("getSuggestedPrompts", () => {
98
122
  expect(await getSuggestedPrompts()).toEqual([]);
99
123
  });
100
124
 
101
- test("handles empty LLM response gracefully", async () => {
125
+ test("handles empty LLM response gracefully and caches the empty result", async () => {
102
126
  mockSidechainText = "";
103
127
 
104
- await refreshAssistantSuggestedPrompts();
105
- const prompts = await getSuggestedPrompts();
106
- expect(prompts).toEqual([]);
128
+ expect(await refreshAssistantSuggestedPrompts()).toBe(false);
129
+ expect(await getSuggestedPrompts()).toEqual([]);
130
+
131
+ // The empty result is cached: the next refresh within the TTL must
132
+ // not hit the LLM again (prevents a generation loop on every Home
133
+ // feed GET while the model returns nothing usable).
134
+ expect(await refreshAssistantSuggestedPrompts()).toBe(false);
135
+ expect(sidechainCalls).toBe(1);
107
136
  });
108
137
 
109
138
  test("handles malformed LLM response gracefully", async () => {
@@ -113,4 +142,56 @@ describe("getSuggestedPrompts", () => {
113
142
  const prompts = await getSuggestedPrompts();
114
143
  expect(prompts).toEqual([]);
115
144
  });
145
+
146
+ test("refresh reports whether new content was generated", async () => {
147
+ mockSidechainText = JSON.stringify([
148
+ { label: "Do stuff", prompt: "Do stuff for me" },
149
+ ]);
150
+
151
+ expect(await refreshAssistantSuggestedPrompts()).toBe(true);
152
+ // Cache is fresh — second refresh must not hit the LLM again.
153
+ expect(await refreshAssistantSuggestedPrompts()).toBe(false);
154
+ expect(sidechainCalls).toBe(1);
155
+ });
156
+
157
+ test("refresh reports false when the cache write fails", async () => {
158
+ mockSidechainText = JSON.stringify([
159
+ { label: "Do stuff", prompt: "Do stuff for me" },
160
+ ]);
161
+ checkpointWritesShouldThrow = true;
162
+
163
+ // The generation succeeded but nothing was cached, so the caller must
164
+ // not announce fresh content (clients would refetch into a cache miss
165
+ // and re-trigger generation on every Home load).
166
+ expect(await refreshAssistantSuggestedPrompts()).toBe(false);
167
+ expect(await getSuggestedPrompts()).toEqual([]);
168
+ });
169
+
170
+ test("refresh regenerates after invalidation", async () => {
171
+ mockSidechainText = JSON.stringify([
172
+ { label: "Do stuff", prompt: "Do stuff for me" },
173
+ ]);
174
+
175
+ await refreshAssistantSuggestedPrompts();
176
+ invalidateAssistantSuggestedPromptsCache();
177
+
178
+ expect(await refreshAssistantSuggestedPrompts()).toBe(true);
179
+ expect(sidechainCalls).toBe(2);
180
+ });
181
+
182
+ test("cache survives across module state (checkpoint-backed)", async () => {
183
+ mockSidechainText = JSON.stringify([
184
+ { label: "Persisted", prompt: "I came from the checkpoint store" },
185
+ ]);
186
+
187
+ await refreshAssistantSuggestedPrompts();
188
+ // Simulate the read path a fresh daemon would take: the prompts come
189
+ // back from the checkpoint store, not module-local memory.
190
+ expect(checkpointStore.get("home:suggested_prompts:json")).toContain(
191
+ "Persisted",
192
+ );
193
+ const prompts = await getSuggestedPrompts();
194
+ expect(prompts).toHaveLength(1);
195
+ expect(prompts[0]!.label).toBe("Persisted");
196
+ });
116
197
  });
@@ -1,52 +1,64 @@
1
1
  /**
2
- * Background refresh timer for LLM-generated home page content.
2
+ * On-demand revalidation for LLM-generated home page content.
3
3
  *
4
- * Keeps the personalized greeting and assistant-generated suggestion
5
- * prompts warm in their caches so the GET handler never triggers LLM
6
- * calls or database writes (see `src/runtime/AGENTS.md` — GET handler
7
- * idempotency rule).
4
+ * The personalized greeting and assistant-generated suggestion prompts
5
+ * are served from caches (see `home-greeting-cache.ts` and
6
+ * `suggested-prompts.ts`). The GET handler stays read-only; when a
7
+ * client fetches the home feed it calls `revalidateHomeContentInBackground()`
8
+ * fire-and-forget, which regenerates whichever caches are stale and
9
+ * publishes a `home_feed_updated` event when fresh content lands so
10
+ * connected clients refetch and the personalized content swaps in.
8
11
  *
9
- * Call `startHomeContentRefresh()` once during daemon startup; the
10
- * timer handles periodic re-generation automatically.
12
+ * This keeps LLM cost proportional to actual Home usage: nothing
13
+ * generates at daemon startup or on a timer, and the per-cache TTLs
14
+ * (4 hours each) bound how often a regeneration can happen.
11
15
  */
12
16
 
17
+ import { buildAssistantEvent } from "../runtime/assistant-event.js";
18
+ import { assistantEventHub } from "../runtime/assistant-event-hub.js";
13
19
  import { getLogger } from "../util/logger.js";
14
20
  import { refreshPersonalizedGreeting } from "./home-greeting.js";
15
21
  import { refreshAssistantSuggestedPrompts } from "./suggested-prompts.js";
16
22
 
17
23
  const log = getLogger("home-content-refresh");
18
24
 
19
- const REFRESH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
25
+ let inFlight: Promise<void> | null = null;
20
26
 
21
- let refreshTimer: ReturnType<typeof setInterval> | null = null;
22
-
23
- async function refreshAll(): Promise<void> {
24
- await Promise.all([
27
+ async function revalidateAll(): Promise<void> {
28
+ const [greetingRefreshed, promptsRefreshed] = await Promise.all([
25
29
  refreshPersonalizedGreeting(),
26
30
  refreshAssistantSuggestedPrompts(),
27
31
  ]);
28
- }
29
32
 
30
- /**
31
- * Start periodic background refresh of home page LLM content.
32
- * Runs an initial generation immediately (fire-and-forget) and
33
- * schedules re-generation every 30 minutes.
34
- */
35
- export function startHomeContentRefresh(): void {
36
- void refreshAll().catch((err) =>
37
- log.warn({ err }, "Initial home content refresh failed"),
38
- );
33
+ if (!greetingRefreshed && !promptsRefreshed) {
34
+ return;
35
+ }
39
36
 
40
- refreshTimer = setInterval(() => {
41
- void refreshAll().catch((err) =>
42
- log.warn({ err }, "Periodic home content refresh failed"),
43
- );
44
- }, REFRESH_INTERVAL_MS);
37
+ await assistantEventHub.publish(
38
+ buildAssistantEvent({
39
+ type: "home_feed_updated",
40
+ updatedAt: new Date().toISOString(),
41
+ newItemCount: 0,
42
+ }),
43
+ );
45
44
  }
46
45
 
47
- export function stopHomeContentRefresh(): void {
48
- if (refreshTimer) {
49
- clearInterval(refreshTimer);
50
- refreshTimer = null;
46
+ /**
47
+ * Regenerate stale home content in the background. Returns immediately;
48
+ * callers (the home feed GET handler) must not await generation. Both
49
+ * refreshers no-op when their cache is fresh, so calling this on every
50
+ * feed fetch is cheap — at most one generation per cache TTL window.
51
+ * Concurrent calls share a single in-flight revalidation.
52
+ */
53
+ export function revalidateHomeContentInBackground(): void {
54
+ if (inFlight) {
55
+ return;
51
56
  }
57
+ inFlight = revalidateAll()
58
+ .catch((err) => {
59
+ log.warn({ err }, "Home content revalidation failed");
60
+ })
61
+ .finally(() => {
62
+ inFlight = null;
63
+ });
52
64
  }
@@ -79,14 +79,21 @@ export function getCachedHomeGreeting(): string | null {
79
79
  }
80
80
  }
81
81
 
82
- export function setCachedHomeGreeting(text: string): void {
82
+ /**
83
+ * Persist a freshly generated greeting. Returns `true` when the cache
84
+ * write landed; `false` on failure so callers don't report fresh content
85
+ * that the next read cannot serve.
86
+ */
87
+ export function setCachedHomeGreeting(text: string): boolean {
83
88
  try {
84
89
  const hash = computeIdentityContentHash();
85
90
  const now = String(Date.now());
86
91
  setMemoryCheckpoint(CHECKPOINT_KEY_TEXT, text);
87
92
  setMemoryCheckpoint(CHECKPOINT_KEY_HASH, hash);
88
93
  setMemoryCheckpoint(CHECKPOINT_KEY_TIMESTAMP, now);
94
+ return true;
89
95
  } catch {
90
96
  // Cache write failure is non-fatal — next request will regenerate.
97
+ return false;
91
98
  }
92
99
  }
@@ -6,9 +6,11 @@
6
6
  * caches the result for 4 hours (busted when identity files change).
7
7
  *
8
8
  * The GET handler reads only from cache (`getPersonalizedGreeting`).
9
- * Generation runs in the background via `refreshPersonalizedGreeting`,
10
- * called at daemon startup and periodically by the home-content
11
- * refresh timer.
9
+ * Generation runs on demand via `refreshPersonalizedGreeting`, invoked
10
+ * fire-and-forget by the home-content revalidation coordinator when a
11
+ * client fetches the home feed and the cache is stale (see
12
+ * `home-content-refresh.ts`). Nothing generates at daemon startup or on
13
+ * a timer — LLM cost is only incurred when a user actually views Home.
12
14
  */
13
15
 
14
16
  import { resolveCallSiteConfig } from "../config/llm-resolver.js";
@@ -37,13 +39,14 @@ export function getPersonalizedGreeting(): string | null {
37
39
 
38
40
  /**
39
41
  * Generate a personalized greeting via LLM and write it to cache.
40
- * No-ops when the cache is still fresh. Intended for background
41
- * invocation (daemon startup / periodic refresh), not the GET path.
42
+ * No-ops when the cache is still fresh. Intended for fire-and-forget
43
+ * background invocation, not the GET path. Returns `true` when a new
44
+ * greeting was generated and cached.
42
45
  */
43
- export async function refreshPersonalizedGreeting(): Promise<void> {
46
+ export async function refreshPersonalizedGreeting(): Promise<boolean> {
44
47
  const cached = getCachedHomeGreeting();
45
48
  if (cached) {
46
- return;
49
+ return false;
47
50
  }
48
51
 
49
52
  try {
@@ -52,7 +55,7 @@ export async function refreshPersonalizedGreeting(): Promise<void> {
52
55
 
53
56
  const provider = await getConfiguredProvider("homeGreeting");
54
57
  if (!provider) {
55
- return;
58
+ return false;
56
59
  }
57
60
 
58
61
  const systemPrompt = buildSystemPrompt({
@@ -77,9 +80,10 @@ export async function refreshPersonalizedGreeting(): Promise<void> {
77
80
 
78
81
  const text = result.text.trim();
79
82
  if (text) {
80
- setCachedHomeGreeting(text);
83
+ return setCachedHomeGreeting(text);
81
84
  }
82
85
  } catch (err) {
83
86
  log.warn({ err }, "Failed to generate personalized home greeting");
84
87
  }
88
+ return false;
85
89
  }
@@ -3,13 +3,25 @@
3
3
  *
4
4
  * Returns an array of `SuggestedPrompt` items shown at the top of the
5
5
  * Home page as conversation starters. All prompts are generated by the
6
- * assistant based on the user's connected services and context —
7
- * read from an in-memory cache in the GET path; generation runs in
8
- * the background via `refreshAssistantSuggestedPrompts`.
6
+ * assistant based on the user's connected services and context — read
7
+ * from a checkpoint-backed cache in the GET path. Generation runs on
8
+ * demand via `refreshAssistantSuggestedPrompts`, invoked fire-and-forget
9
+ * by the home-content revalidation coordinator when a client fetches the
10
+ * home feed and the cache is stale (see `home-content-refresh.ts`).
11
+ * Nothing generates at daemon startup or on a timer.
12
+ *
13
+ * The cache persists in the `memory_checkpoints` table so a daemon
14
+ * restart does not force a regeneration. OAuth connect/disconnect paths
15
+ * invalidate it explicitly so suggestions track integration state.
9
16
  */
10
17
 
11
18
  import { resolveCallSiteConfig } from "../config/llm-resolver.js";
12
19
  import { getConfig } from "../config/loader.js";
20
+ import {
21
+ deleteMemoryCheckpoint,
22
+ getMemoryCheckpoint,
23
+ setMemoryCheckpoint,
24
+ } from "../memory/checkpoints.js";
13
25
  import { buildSystemPrompt } from "../prompts/system-prompt.js";
14
26
  import { getConfiguredProvider } from "../providers/provider-send-message.js";
15
27
  import { buildAssistantEvent } from "../runtime/assistant-event.js";
@@ -22,14 +34,46 @@ import type { SuggestedPrompt } from "./feed-types.js";
22
34
  const log = getLogger("suggested-prompts");
23
35
 
24
36
  const LLM_SUGGESTIONS_TIMEOUT_MS = 5_000;
25
- const LLM_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
37
+ const LLM_CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
38
+
39
+ const CHECKPOINT_KEY_JSON = "home:suggested_prompts:json";
40
+ const CHECKPOINT_KEY_TIMESTAMP = "home:suggested_prompts:cached_at";
26
41
 
27
42
  // ---------------------------------------------------------------------------
28
- // In-memory cache for LLM-generated suggestions
43
+ // Checkpoint-backed cache for LLM-generated suggestions
29
44
  // ---------------------------------------------------------------------------
30
45
 
31
- let cachedLLMPrompts: SuggestedPrompt[] = [];
32
- let cachedLLMPromptsAt = 0;
46
+ function readCachedPrompts(): SuggestedPrompt[] | null {
47
+ try {
48
+ const json = getMemoryCheckpoint(CHECKPOINT_KEY_JSON);
49
+ const timestampStr = getMemoryCheckpoint(CHECKPOINT_KEY_TIMESTAMP);
50
+ if (!json || !timestampStr) {
51
+ return null;
52
+ }
53
+ const cachedAt = Number(timestampStr);
54
+ if (isNaN(cachedAt) || Date.now() - cachedAt > LLM_CACHE_TTL_MS) {
55
+ return null;
56
+ }
57
+ const parsed = JSON.parse(json);
58
+ if (!Array.isArray(parsed)) {
59
+ return null;
60
+ }
61
+ return parsed as SuggestedPrompt[];
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function writeCachedPrompts(prompts: SuggestedPrompt[]): boolean {
68
+ try {
69
+ setMemoryCheckpoint(CHECKPOINT_KEY_JSON, JSON.stringify(prompts));
70
+ setMemoryCheckpoint(CHECKPOINT_KEY_TIMESTAMP, String(Date.now()));
71
+ return true;
72
+ } catch {
73
+ // Cache write failure is non-fatal — the next refresh regenerates.
74
+ return false;
75
+ }
76
+ }
33
77
 
34
78
  /**
35
79
  * Return cached assistant-generated prompts. No LLM calls happen in
@@ -37,22 +81,23 @@ let cachedLLMPromptsAt = 0;
37
81
  * background refresh populates the cache.
38
82
  */
39
83
  export async function getSuggestedPrompts(): Promise<SuggestedPrompt[]> {
40
- if (Date.now() - cachedLLMPromptsAt < LLM_CACHE_TTL_MS) {
41
- return [...cachedLLMPrompts];
42
- }
43
- return [];
84
+ return readCachedPrompts() ?? [];
44
85
  }
45
86
 
46
87
  /**
47
- * Drops the in-memory suggestion cache so the next background refresh
48
- * regenerates prompts with current integration state.
88
+ * Drops the suggestion cache so the next on-demand refresh regenerates
89
+ * prompts with current integration state.
49
90
  *
50
91
  * Called from OAuth connect/disconnect paths so suggestions reflect the
51
- * new state within one reload instead of waiting for the 30-minute TTL.
92
+ * new state within one reload instead of waiting for the TTL.
52
93
  */
53
94
  export function invalidateAssistantSuggestedPromptsCache(): void {
54
- cachedLLMPrompts = [];
55
- cachedLLMPromptsAt = 0;
95
+ try {
96
+ deleteMemoryCheckpoint(CHECKPOINT_KEY_JSON);
97
+ deleteMemoryCheckpoint(CHECKPOINT_KEY_TIMESTAMP);
98
+ } catch {
99
+ // Invalidation failure is non-fatal — the TTL still bounds staleness.
100
+ }
56
101
  assistantEventHub
57
102
  .publish(
58
103
  buildAssistantEvent({
@@ -70,21 +115,29 @@ export function invalidateAssistantSuggestedPromptsCache(): void {
70
115
  }
71
116
 
72
117
  /**
73
- * Generate LLM-based suggestion prompts and write them to the in-memory
74
- * cache. No-ops when the cache is still fresh. Intended for background
75
- * invocation (daemon startup / periodic refresh), not the GET path.
118
+ * Generate LLM-based suggestion prompts and write them to the cache.
119
+ * No-ops when the cache is still fresh. Intended for fire-and-forget
120
+ * background invocation, not the GET path. Returns `true` when a new
121
+ * batch was generated and cached.
76
122
  */
77
- export async function refreshAssistantSuggestedPrompts(): Promise<void> {
78
- if (Date.now() - cachedLLMPromptsAt < LLM_CACHE_TTL_MS) {
79
- return;
123
+ export async function refreshAssistantSuggestedPrompts(): Promise<boolean> {
124
+ if (readCachedPrompts() !== null) {
125
+ return false;
80
126
  }
81
127
 
82
128
  try {
83
129
  const llmPrompts = await generateAssistantPrompts();
84
- cachedLLMPrompts = llmPrompts;
85
- cachedLLMPromptsAt = Date.now();
130
+ // Cache empty results too — an empty or unparseable LLM response
131
+ // would otherwise leave the cache unpopulated and every Home feed
132
+ // GET would re-trigger generation until the TTL window closed.
133
+ // Only report success when the cache write landed AND there is new
134
+ // content — otherwise the coordinator would publish
135
+ // home_feed_updated for content the next GET cannot serve.
136
+ const wrote = writeCachedPrompts(llmPrompts);
137
+ return wrote && llmPrompts.length > 0;
86
138
  } catch (err) {
87
139
  log.warn({ err }, "Failed to refresh assistant suggested prompts");
140
+ return false;
88
141
  }
89
142
  }
90
143