@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
@@ -0,0 +1,123 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { credentialKey } from "../security/credential-key.js";
4
+
5
+ let fallbackValues = new Map<string, string>();
6
+
7
+ mock.module("../config/env.js", () => ({
8
+ getRuntimeHttpHost: () => "127.0.0.1",
9
+ getRuntimeHttpPort: () => 4123,
10
+ }));
11
+
12
+ mock.module("../daemon/daemon-control.js", () => ({
13
+ healthCheckHost: (host: string) => host,
14
+ isHttpHealthy: async () => true,
15
+ }));
16
+
17
+ mock.module("../runtime/auth/token-service.js", () => ({
18
+ initAuthSigningKey: () => {},
19
+ loadOrCreateSigningKey: () => "signing-key",
20
+ mintDaemonDeliveryToken: () => "daemon-token",
21
+ }));
22
+
23
+ mock.module("../security/secure-keys.js", () => ({
24
+ deleteSecureKeyAsync: async () => "deleted" as const,
25
+ getSecureKeyAsync: async (account: string) => fallbackValues.get(account),
26
+ getSecureKeyResultAsync: async (account: string) => ({
27
+ value: fallbackValues.get(account),
28
+ unreachable: false,
29
+ }),
30
+ setSecureKeyAsync: async () => true,
31
+ }));
32
+
33
+ mock.module("../util/logger.js", () => ({
34
+ getLogger: () =>
35
+ new Proxy({} as Record<string, unknown>, {
36
+ get: () => () => {},
37
+ }),
38
+ }));
39
+
40
+ import {
41
+ getSecureKeyResultViaDaemon,
42
+ getSecureKeyViaDaemon,
43
+ } from "../cli/lib/daemon-credential-client.js";
44
+
45
+ const originalFetch = globalThis.fetch;
46
+ const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
47
+
48
+ function getRequestBody(index = 0): Record<string, unknown> {
49
+ const body = fetchCalls[index]?.init?.body;
50
+ if (typeof body !== "string") {
51
+ throw new Error("Expected fetch body to be a JSON string");
52
+ }
53
+ return JSON.parse(body) as Record<string, unknown>;
54
+ }
55
+
56
+ beforeEach(() => {
57
+ fallbackValues = new Map();
58
+ fetchCalls.length = 0;
59
+ const mockFetch = mock(
60
+ async (input: string | URL | Request, init?: RequestInit) => {
61
+ const url =
62
+ typeof input === "string"
63
+ ? input
64
+ : input instanceof URL
65
+ ? input.toString()
66
+ : input.url;
67
+ fetchCalls.push({ url, init });
68
+ return new Response(
69
+ JSON.stringify({ found: true, value: "secret-value" }),
70
+ {
71
+ status: 200,
72
+ headers: { "Content-Type": "application/json" },
73
+ },
74
+ );
75
+ },
76
+ );
77
+ globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
78
+ });
79
+
80
+ afterEach(() => {
81
+ globalThis.fetch = originalFetch;
82
+ });
83
+
84
+ describe("daemon credential read requests", () => {
85
+ test("keeps provider secrets on the api_key path", async () => {
86
+ const value = await getSecureKeyViaDaemon("openai");
87
+
88
+ expect(value).toBe("secret-value");
89
+ expect(fetchCalls).toHaveLength(1);
90
+ expect(fetchCalls[0]?.url).toBe("http://127.0.0.1:4123/v1/secrets/read");
91
+ expect(getRequestBody()).toEqual({
92
+ type: "api_key",
93
+ name: "openai",
94
+ reveal: true,
95
+ });
96
+ });
97
+
98
+ test("converts canonical credential keys into credential reads", async () => {
99
+ const value = await getSecureKeyViaDaemon(
100
+ credentialKey("vellum", "platform_base_url"),
101
+ );
102
+
103
+ expect(value).toBe("secret-value");
104
+ expect(getRequestBody()).toEqual({
105
+ type: "credential",
106
+ name: "vellum:platform_base_url",
107
+ reveal: true,
108
+ });
109
+ });
110
+
111
+ test("preserves compound credential service names on metadata reads", async () => {
112
+ const result = await getSecureKeyResultViaDaemon(
113
+ credentialKey("integration:google", "client_secret"),
114
+ );
115
+
116
+ expect(result).toEqual({ value: "secret-value", unreachable: false });
117
+ expect(getRequestBody()).toEqual({
118
+ type: "credential",
119
+ name: "integration:google:client_secret",
120
+ reveal: true,
121
+ });
122
+ });
123
+ });
@@ -128,6 +128,7 @@ describe("TwiML parameter propagation", () => {
128
128
  transcriptionProvider: "deepgram",
129
129
  ttsProvider: "google",
130
130
  voice: "en-US-Standard-A",
131
+ interruptSensitivity: "low",
131
132
  };
132
133
 
133
134
  test("includes verificationSessionId as Parameter when provided", () => {
@@ -1,6 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import { deliverChannelReply } from "../runtime/gateway-client.js";
3
+ import {
4
+ ChannelDeliveryError,
5
+ deliverChannelReply,
6
+ } from "../runtime/gateway-client.js";
4
7
 
5
8
  type FetchCall = {
6
9
  url: string;
@@ -163,4 +166,79 @@ describe("gateway-client managed outbound lane", () => {
163
166
  text: "standard gateway callback",
164
167
  });
165
168
  });
169
+
170
+ test("throws ChannelDeliveryError with userMessage when gateway returns JSON error with userMessage", async () => {
171
+ globalThis.fetch = mock(async () => {
172
+ return new Response(
173
+ JSON.stringify({
174
+ error: "Permission denied",
175
+ userMessage:
176
+ "The bot is not a member of this channel. Please invite it first.",
177
+ }),
178
+ { status: 403 },
179
+ );
180
+ }) as unknown as typeof globalThis.fetch;
181
+
182
+ let caught: unknown;
183
+ try {
184
+ await deliverChannelReply("https://gateway.test/deliver/slack", {
185
+ chatId: "C123",
186
+ text: "hello",
187
+ });
188
+ } catch (err) {
189
+ caught = err;
190
+ }
191
+
192
+ expect(caught).toBeInstanceOf(ChannelDeliveryError);
193
+ const deliveryError = caught as ChannelDeliveryError;
194
+ expect(deliveryError.statusCode).toBe(403);
195
+ expect(deliveryError.userMessage).toBe(
196
+ "The bot is not a member of this channel. Please invite it first.",
197
+ );
198
+ expect(deliveryError.message).toContain("403");
199
+ });
200
+
201
+ test("throws ChannelDeliveryError without userMessage when gateway returns JSON error without userMessage", async () => {
202
+ globalThis.fetch = mock(async () => {
203
+ return new Response(JSON.stringify({ error: "Delivery failed" }), {
204
+ status: 502,
205
+ });
206
+ }) as unknown as typeof globalThis.fetch;
207
+
208
+ let caught: unknown;
209
+ try {
210
+ await deliverChannelReply("https://gateway.test/deliver/slack", {
211
+ chatId: "C123",
212
+ text: "hello",
213
+ });
214
+ } catch (err) {
215
+ caught = err;
216
+ }
217
+
218
+ expect(caught).toBeInstanceOf(ChannelDeliveryError);
219
+ const deliveryError = caught as ChannelDeliveryError;
220
+ expect(deliveryError.statusCode).toBe(502);
221
+ expect(deliveryError.userMessage).toBeUndefined();
222
+ });
223
+
224
+ test("throws ChannelDeliveryError without userMessage when gateway returns non-JSON error", async () => {
225
+ globalThis.fetch = mock(async () => {
226
+ return new Response("Internal Server Error", { status: 500 });
227
+ }) as unknown as typeof globalThis.fetch;
228
+
229
+ let caught: unknown;
230
+ try {
231
+ await deliverChannelReply("https://gateway.test/deliver/slack", {
232
+ chatId: "C123",
233
+ text: "hello",
234
+ });
235
+ } catch (err) {
236
+ caught = err;
237
+ }
238
+
239
+ expect(caught).toBeInstanceOf(ChannelDeliveryError);
240
+ const deliveryError = caught as ChannelDeliveryError;
241
+ expect(deliveryError.statusCode).toBe(500);
242
+ expect(deliveryError.userMessage).toBeUndefined();
243
+ });
166
244
  });
@@ -0,0 +1,335 @@
1
+ import { mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
5
+
6
+ const TEST_DIR = join(tmpdir(), `vellum-journal-test-${crypto.randomUUID()}`);
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
9
+ const realPlatform = require("../util/platform.js");
10
+ mock.module("../util/platform.js", () => ({
11
+ ...realPlatform,
12
+ getWorkspaceDir: () => TEST_DIR,
13
+ }));
14
+
15
+ const {
16
+ buildJournalContext,
17
+ formatJournalRelativeTime,
18
+ formatJournalAbsoluteTime,
19
+ } = await import("../prompts/journal-context.js");
20
+
21
+ /** Small delay to ensure distinct file birthtimes on APFS. */
22
+ const tick = () => Bun.sleep(5);
23
+
24
+ describe("formatJournalRelativeTime", () => {
25
+ test("returns 'just now' for times less than 60 seconds ago", () => {
26
+ const now = Date.now();
27
+ expect(formatJournalRelativeTime(now - 30_000)).toBe("just now");
28
+ expect(formatJournalRelativeTime(now - 1_000)).toBe("just now");
29
+ expect(formatJournalRelativeTime(now)).toBe("just now");
30
+ });
31
+
32
+ test("returns minutes for times between 1-59 minutes ago", () => {
33
+ const now = Date.now();
34
+ expect(formatJournalRelativeTime(now - 60_000)).toBe("1 minute ago");
35
+ expect(formatJournalRelativeTime(now - 5 * 60_000)).toBe("5 minutes ago");
36
+ expect(formatJournalRelativeTime(now - 59 * 60_000)).toBe("59 minutes ago");
37
+ });
38
+
39
+ test("returns hours for times between 1-23 hours ago", () => {
40
+ const now = Date.now();
41
+ expect(formatJournalRelativeTime(now - 60 * 60_000)).toBe("1 hour ago");
42
+ expect(formatJournalRelativeTime(now - 3 * 60 * 60_000)).toBe(
43
+ "3 hours ago",
44
+ );
45
+ expect(formatJournalRelativeTime(now - 23 * 60 * 60_000)).toBe(
46
+ "23 hours ago",
47
+ );
48
+ });
49
+
50
+ test("returns days for times between 1-6 days ago", () => {
51
+ const now = Date.now();
52
+ expect(formatJournalRelativeTime(now - 24 * 60 * 60_000)).toBe(
53
+ "1 day ago",
54
+ );
55
+ expect(formatJournalRelativeTime(now - 3 * 24 * 60 * 60_000)).toBe(
56
+ "3 days ago",
57
+ );
58
+ expect(formatJournalRelativeTime(now - 6 * 24 * 60 * 60_000)).toBe(
59
+ "6 days ago",
60
+ );
61
+ });
62
+
63
+ test("returns weeks for times 7 or more days ago", () => {
64
+ const now = Date.now();
65
+ expect(formatJournalRelativeTime(now - 7 * 24 * 60 * 60_000)).toBe(
66
+ "1 week ago",
67
+ );
68
+ expect(formatJournalRelativeTime(now - 14 * 24 * 60 * 60_000)).toBe(
69
+ "2 weeks ago",
70
+ );
71
+ expect(formatJournalRelativeTime(now - 30 * 24 * 60 * 60_000)).toBe(
72
+ "4 weeks ago",
73
+ );
74
+ });
75
+ });
76
+
77
+ describe("formatJournalAbsoluteTime", () => {
78
+ test("formats a timestamp as MM/DD/YY HH:MM", () => {
79
+ // 2025-03-15 14:30:00
80
+ const ts = new Date(2025, 2, 15, 14, 30, 0).getTime();
81
+ expect(formatJournalAbsoluteTime(ts)).toBe("03/15/25 14:30");
82
+ });
83
+
84
+ test("zero-pads single-digit months, days, hours, and minutes", () => {
85
+ // 2025-01-05 09:05:00
86
+ const ts = new Date(2025, 0, 5, 9, 5, 0).getTime();
87
+ expect(formatJournalAbsoluteTime(ts)).toBe("01/05/25 09:05");
88
+ });
89
+
90
+ test("handles midnight", () => {
91
+ const ts = new Date(2025, 5, 20, 0, 0, 0).getTime();
92
+ expect(formatJournalAbsoluteTime(ts)).toBe("06/20/25 00:00");
93
+ });
94
+ });
95
+
96
+ describe("buildJournalContext", () => {
97
+ const journalDir = join(TEST_DIR, "journal");
98
+
99
+ beforeEach(() => {
100
+ mkdirSync(journalDir, { recursive: true });
101
+ });
102
+
103
+ afterEach(() => {
104
+ rmSync(TEST_DIR, { recursive: true, force: true });
105
+ });
106
+
107
+ test("returns null when maxEntries is 0", () => {
108
+ const userDir = join(journalDir, "testuser");
109
+ mkdirSync(userDir, { recursive: true });
110
+ writeFileSync(join(userDir, "entry.md"), "content");
111
+ expect(buildJournalContext(0, "testuser")).toBeNull();
112
+ });
113
+
114
+ test("returns null when maxEntries is negative", () => {
115
+ const userDir = join(journalDir, "testuser");
116
+ mkdirSync(userDir, { recursive: true });
117
+ writeFileSync(join(userDir, "entry.md"), "content");
118
+ expect(buildJournalContext(-1, "testuser")).toBeNull();
119
+ });
120
+
121
+ test("returns null when journal directory does not exist", () => {
122
+ rmSync(journalDir, { recursive: true, force: true });
123
+ expect(buildJournalContext(10, "testuser")).toBeNull();
124
+ });
125
+
126
+ test("returns null when journal directory has no .md files", () => {
127
+ const userDir = join(journalDir, "testuser");
128
+ mkdirSync(userDir, { recursive: true });
129
+ writeFileSync(join(userDir, "notes.txt"), "not markdown");
130
+ expect(buildJournalContext(10, "testuser")).toBeNull();
131
+ });
132
+
133
+ test("excludes README.md (case-insensitive)", () => {
134
+ const userDir = join(journalDir, "testuser");
135
+ mkdirSync(userDir, { recursive: true });
136
+ writeFileSync(join(userDir, "README.md"), "readme content");
137
+ writeFileSync(join(userDir, "readme.md"), "readme content lower");
138
+ expect(buildJournalContext(10, "testuser")).toBeNull();
139
+ });
140
+
141
+ test("returns formatted journal context with single entry", () => {
142
+ const userDir = join(journalDir, "testuser");
143
+ mkdirSync(userDir, { recursive: true });
144
+ writeFileSync(join(userDir, "goals.md"), "My goals for this week.");
145
+ const result = buildJournalContext(10, "testuser");
146
+ expect(result).not.toBeNull();
147
+ expect(result).toContain("# Journal");
148
+ expect(result).toContain(
149
+ "Your journal entries, most recent first. These are YOUR words from past conversations.",
150
+ );
151
+ expect(result).toContain("## goals.md — MOST RECENT");
152
+ expect(result).toContain("My goals for this week.");
153
+ // Single entry, window not full — should NOT have LEAVING CONTEXT
154
+ expect(result).not.toContain("LEAVING CONTEXT");
155
+ });
156
+
157
+ test("sorts entries by creation time, newest first", async () => {
158
+ const userDir = join(journalDir, "testuser");
159
+ mkdirSync(userDir, { recursive: true });
160
+ // Small delays between writes ensure distinct birthtimes.
161
+ writeFileSync(join(userDir, "old.md"), "old entry");
162
+ await tick();
163
+ writeFileSync(join(userDir, "mid.md"), "mid entry");
164
+ await tick();
165
+ writeFileSync(join(userDir, "new.md"), "new entry");
166
+
167
+ const result = buildJournalContext(10, "testuser")!;
168
+ const newIdx = result.indexOf("new.md");
169
+ const midIdx = result.indexOf("mid.md");
170
+ const oldIdx = result.indexOf("old.md");
171
+ expect(newIdx).toBeLessThan(midIdx);
172
+ expect(midIdx).toBeLessThan(oldIdx);
173
+ });
174
+
175
+ test("marks most recent entry with MOST RECENT", async () => {
176
+ const userDir = join(journalDir, "testuser");
177
+ mkdirSync(userDir, { recursive: true });
178
+ writeFileSync(join(userDir, "older.md"), "older");
179
+ await tick();
180
+ writeFileSync(join(userDir, "newest.md"), "newest");
181
+
182
+ const result = buildJournalContext(10, "testuser")!;
183
+ expect(result).toContain("## newest.md — MOST RECENT");
184
+ expect(result).not.toContain("## older.md — MOST RECENT");
185
+ });
186
+
187
+ test("marks oldest entry with LEAVING CONTEXT when window is full", async () => {
188
+ const userDir = join(journalDir, "testuser");
189
+ mkdirSync(userDir, { recursive: true });
190
+ // Create in chronological order: c (oldest), b, a (newest)
191
+ writeFileSync(join(userDir, "c.md"), "entry c");
192
+ await tick();
193
+ writeFileSync(join(userDir, "b.md"), "entry b");
194
+ await tick();
195
+ writeFileSync(join(userDir, "a.md"), "entry a");
196
+
197
+ // maxEntries = 3 matches the number of files, so window is full
198
+ const result = buildJournalContext(3, "testuser")!;
199
+ expect(result).toContain("## a.md — MOST RECENT");
200
+ expect(result).toContain("## c.md — LEAVING CONTEXT");
201
+ expect(result).toContain(
202
+ "NOTE: This is the oldest entry in your active context.",
203
+ );
204
+ expect(result).toContain(
205
+ "carry forward anything from here that still matters to you",
206
+ );
207
+ });
208
+
209
+ test("does NOT mark oldest entry with LEAVING CONTEXT when window is not full", async () => {
210
+ const userDir = join(journalDir, "testuser");
211
+ mkdirSync(userDir, { recursive: true });
212
+ writeFileSync(join(userDir, "b.md"), "entry b");
213
+ await tick();
214
+ writeFileSync(join(userDir, "a.md"), "entry a");
215
+
216
+ // maxEntries = 5, only 2 files — window is NOT full
217
+ const result = buildJournalContext(5, "testuser")!;
218
+ expect(result).toContain("## a.md — MOST RECENT");
219
+ expect(result).not.toContain("LEAVING CONTEXT");
220
+ });
221
+
222
+ test("limits entries to maxEntries", async () => {
223
+ const userDir = join(journalDir, "testuser");
224
+ mkdirSync(userDir, { recursive: true });
225
+ // Create files 0-4 sequentially; file 4 is newest
226
+ for (let i = 0; i < 5; i++) {
227
+ writeFileSync(join(userDir, `entry-${i}.md`), `content ${i}`);
228
+ if (i < 4) await tick();
229
+ }
230
+
231
+ const result = buildJournalContext(3, "testuser")!;
232
+ // Should contain only 3 newest entries (entry-4, entry-3, entry-2)
233
+ expect(result).toContain("entry-4.md");
234
+ expect(result).toContain("entry-3.md");
235
+ expect(result).toContain("entry-2.md");
236
+ expect(result).not.toContain("entry-1.md");
237
+ expect(result).not.toContain("entry-0.md");
238
+ });
239
+
240
+ test("maxEntries=1 with exactly one entry marks it MOST RECENT, not LEAVING CONTEXT", () => {
241
+ const userDir = join(journalDir, "testuser");
242
+ mkdirSync(userDir, { recursive: true });
243
+ writeFileSync(join(userDir, "solo.md"), "only entry");
244
+ const result = buildJournalContext(1, "testuser")!;
245
+ expect(result).toContain("## solo.md — MOST RECENT");
246
+ expect(result).not.toContain("LEAVING CONTEXT");
247
+ });
248
+
249
+ test("includes both absolute and relative timestamps in headers", () => {
250
+ const userDir = join(journalDir, "testuser");
251
+ mkdirSync(userDir, { recursive: true });
252
+ writeFileSync(join(userDir, "recent.md"), "recent content");
253
+
254
+ const result = buildJournalContext(10, "testuser")!;
255
+ // File was just created, so relative time should be "just now"
256
+ expect(result).toContain("just now");
257
+ // Absolute time should match the file's birthtime
258
+ const birthtime = statSync(join(userDir, "recent.md")).birthtimeMs;
259
+ const expected = formatJournalAbsoluteTime(birthtime);
260
+ expect(result).toContain(expected);
261
+ });
262
+
263
+ test("middle entries have plain headers with timestamps", async () => {
264
+ const userDir = join(journalDir, "testuser");
265
+ mkdirSync(userDir, { recursive: true });
266
+ // Create in chronological order: last (oldest), middle, first (newest)
267
+ writeFileSync(join(userDir, "last.md"), "last");
268
+ await tick();
269
+ writeFileSync(join(userDir, "middle.md"), "middle");
270
+ await tick();
271
+ writeFileSync(join(userDir, "first.md"), "first");
272
+
273
+ const result = buildJournalContext(3, "testuser")!;
274
+ // Middle entry should have plain header format (no MOST RECENT, no LEAVING CONTEXT)
275
+ // Format: ## middle.md (MM/DD/YY HH:MM, <relative time>)
276
+ expect(result).toMatch(
277
+ /## middle\.md \(\d{2}\/\d{2}\/\d{2} \d{2}:\d{2}, .+\)/,
278
+ );
279
+ });
280
+
281
+ // --- Per-user scoping tests ---
282
+
283
+ test("reads from per-user directory when userSlug is provided", () => {
284
+ const aliceDir = join(journalDir, "alice");
285
+ mkdirSync(aliceDir, { recursive: true });
286
+ writeFileSync(join(aliceDir, "thoughts.md"), "Alice's thoughts");
287
+ writeFileSync(join(aliceDir, "plans.md"), "Alice's plans");
288
+
289
+ const result = buildJournalContext(10, "alice");
290
+ expect(result).not.toBeNull();
291
+ expect(result).toContain("Alice's thoughts");
292
+ expect(result).toContain("Alice's plans");
293
+ });
294
+
295
+ test("returns null when userSlug is provided but directory does not exist", () => {
296
+ // journal/bob/ does not exist
297
+ const result = buildJournalContext(10, "bob");
298
+ expect(result).toBeNull();
299
+ });
300
+
301
+ test("returns null when no userSlug is provided", () => {
302
+ // Even if root journal/ has entries, no slug means null
303
+ writeFileSync(join(journalDir, "orphan.md"), "orphan entry");
304
+ const result = buildJournalContext(10);
305
+ expect(result).toBeNull();
306
+ });
307
+
308
+ test("returns null when userSlug is null", () => {
309
+ writeFileSync(join(journalDir, "orphan.md"), "orphan entry");
310
+ const result = buildJournalContext(10, null);
311
+ expect(result).toBeNull();
312
+ });
313
+
314
+ test("includes write-path directive in header when userSlug is provided", () => {
315
+ const aliceDir = join(journalDir, "alice");
316
+ mkdirSync(aliceDir, { recursive: true });
317
+ writeFileSync(join(aliceDir, "entry.md"), "some content");
318
+
319
+ const result = buildJournalContext(10, "alice")!;
320
+ expect(result).toContain("**Write new entries to:** `journal/alice/`");
321
+ });
322
+
323
+ test("sanitizes path-traversal in userSlug", () => {
324
+ // basename("../etc") => "etc", so it should read from journal/etc/
325
+ const etcDir = join(journalDir, "etc");
326
+ mkdirSync(etcDir, { recursive: true });
327
+ writeFileSync(join(etcDir, "safe.md"), "safe content");
328
+
329
+ const result = buildJournalContext(10, "../etc");
330
+ expect(result).not.toBeNull();
331
+ expect(result).toContain("safe content");
332
+ // Should reference the sanitized path, not the traversal attempt
333
+ expect(result).toContain("`journal/etc/`");
334
+ });
335
+ });
@@ -5,7 +5,6 @@
5
5
  * - compaction.summaryCalls: 2-6
6
6
  * - compaction.estimatedInputTokens: < previousEstimatedInputTokens
7
7
  * - recall.injectedTokens: <= computed dynamic budget
8
- * - recall.recencyHits: > 0
9
8
  * - recall.enabled: true
10
9
  */
11
10
  import { mkdtempSync, rmSync } from "node:fs";
@@ -303,8 +302,6 @@ describe("Memory context benchmark", () => {
303
302
  { maxInjectTokensOverride: recallBudget },
304
303
  );
305
304
 
306
- // Recency search finds conversation-scoped segments.
307
- expect(recall.recencyHits).toBeGreaterThan(0);
308
305
  expect(recall.enabled).toBe(true);
309
306
  // With Qdrant mock returning a high-scoring result, content should be injected.
310
307
  expect(recall.selectedCount).toBeGreaterThan(0);