@vellumai/assistant 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/Dockerfile +2 -1
  2. package/docker-entrypoint.sh +9 -0
  3. package/docs/architecture/memory.md +13 -11
  4. package/eslint.config.mjs +0 -31
  5. package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
  6. package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
  7. package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
  8. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
  9. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
  10. package/package.json +1 -1
  11. package/src/__tests__/approval-cascade.test.ts +0 -1
  12. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  13. package/src/__tests__/call-controller.test.ts +0 -1
  14. package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
  15. package/src/__tests__/ces-startup-timeout.test.ts +40 -0
  16. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  17. package/src/__tests__/config-schema.test.ts +2 -0
  18. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  19. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  20. package/src/__tests__/conversation-agent-loop.test.ts +2 -4
  21. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
  22. package/src/__tests__/conversation-error.test.ts +15 -1
  23. package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
  24. package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
  25. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
  26. package/src/__tests__/conversation-queue.test.ts +0 -1
  27. package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
  28. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  29. package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
  30. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  31. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
  32. package/src/__tests__/credential-execution-client.test.ts +5 -2
  33. package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
  34. package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
  35. package/src/__tests__/credential-security-e2e.test.ts +1 -1
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -5
  37. package/src/__tests__/credentials-cli.test.ts +4 -3
  38. package/src/__tests__/daemon-credential-client.test.ts +123 -0
  39. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
  40. package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
  41. package/src/__tests__/journal-context.test.ts +335 -0
  42. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
  43. package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
  44. package/src/__tests__/memory-recall-quality.test.ts +48 -17
  45. package/src/__tests__/memory-regressions.test.ts +408 -363
  46. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
  47. package/src/__tests__/non-member-access-request.test.ts +2 -2
  48. package/src/__tests__/notification-decision-strategy.test.ts +71 -0
  49. package/src/__tests__/oauth-cli.test.ts +5 -1
  50. package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
  51. package/src/__tests__/provider-error-scenarios.test.ts +0 -267
  52. package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
  53. package/src/__tests__/relay-server.test.ts +1 -2
  54. package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
  55. package/src/__tests__/secret-onetime-send.test.ts +1 -1
  56. package/src/__tests__/secure-keys.test.ts +18 -15
  57. package/src/__tests__/skill-memory.test.ts +17 -3
  58. package/src/__tests__/stale-approval-dedup.test.ts +171 -0
  59. package/src/__tests__/stt-hints.test.ts +437 -0
  60. package/src/__tests__/task-memory-cleanup.test.ts +14 -0
  61. package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
  62. package/src/__tests__/voice-quality.test.ts +58 -0
  63. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  64. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
  65. package/src/acp/agent-process.ts +9 -1
  66. package/src/agent/loop.ts +1 -1
  67. package/src/approvals/guardian-request-resolvers.ts +164 -38
  68. package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
  69. package/src/calls/call-controller.ts +9 -5
  70. package/src/calls/fish-audio-client.ts +26 -14
  71. package/src/calls/stt-hints.ts +189 -0
  72. package/src/calls/tts-text-sanitizer.ts +61 -0
  73. package/src/calls/twilio-routes.ts +32 -4
  74. package/src/calls/voice-quality.ts +15 -3
  75. package/src/calls/voice-session-bridge.ts +1 -0
  76. package/src/cli/commands/avatar.ts +2 -2
  77. package/src/cli/commands/credentials.ts +110 -94
  78. package/src/cli/commands/doctor.ts +2 -2
  79. package/src/cli/commands/keys.ts +7 -7
  80. package/src/cli/commands/memory.ts +1 -1
  81. package/src/cli/commands/oauth/connections.ts +11 -29
  82. package/src/cli/commands/oauth/platform.ts +389 -43
  83. package/src/cli/lib/daemon-credential-client.ts +284 -0
  84. package/src/cli.ts +1 -1
  85. package/src/config/bundled-skills/AGENTS.md +34 -0
  86. package/src/config/bundled-skills/acp/SKILL.md +10 -0
  87. package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
  88. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  89. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
  90. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
  91. package/src/config/bundled-skills/settings/SKILL.md +15 -2
  92. package/src/config/bundled-skills/settings/TOOLS.json +46 -1
  93. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
  94. package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
  95. package/src/config/bundled-skills/slack/SKILL.md +1 -1
  96. package/src/config/bundled-tool-registry.ts +4 -0
  97. package/src/config/defaults.ts +0 -2
  98. package/src/config/env-registry.ts +4 -4
  99. package/src/config/env.ts +14 -1
  100. package/src/config/feature-flag-registry.json +1 -1
  101. package/src/config/loader.ts +8 -11
  102. package/src/config/schema.ts +5 -16
  103. package/src/config/schemas/calls.ts +17 -0
  104. package/src/config/schemas/inference.ts +2 -2
  105. package/src/config/schemas/journal.ts +16 -0
  106. package/src/config/schemas/memory-processing.ts +2 -2
  107. package/src/config/types.ts +1 -0
  108. package/src/contacts/contact-store.ts +2 -2
  109. package/src/credential-execution/executable-discovery.ts +1 -1
  110. package/src/credential-execution/startup-timeout.ts +36 -0
  111. package/src/daemon/approval-generators.ts +3 -9
  112. package/src/daemon/conversation-agent-loop.ts +6 -0
  113. package/src/daemon/conversation-error.ts +13 -1
  114. package/src/daemon/conversation-memory.ts +1 -2
  115. package/src/daemon/conversation-process.ts +18 -1
  116. package/src/daemon/conversation-runtime-assembly.ts +61 -1
  117. package/src/daemon/conversation-surfaces.ts +30 -1
  118. package/src/daemon/conversation.ts +20 -9
  119. package/src/daemon/guardian-action-generators.ts +3 -9
  120. package/src/daemon/lifecycle.ts +18 -11
  121. package/src/daemon/message-types/conversations.ts +1 -0
  122. package/src/daemon/server.ts +2 -3
  123. package/src/memory/app-store.ts +31 -0
  124. package/src/memory/db-init.ts +4 -0
  125. package/src/memory/indexer.ts +19 -10
  126. package/src/memory/items-extractor.ts +315 -322
  127. package/src/memory/job-handlers/summarization.ts +26 -16
  128. package/src/memory/jobs-store.ts +33 -1
  129. package/src/memory/journal-memory.ts +214 -0
  130. package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
  131. package/src/memory/migrations/index.ts +1 -0
  132. package/src/memory/migrations/registry.ts +8 -0
  133. package/src/memory/retriever.test.ts +37 -25
  134. package/src/memory/retriever.ts +24 -49
  135. package/src/memory/schema/memory-core.ts +2 -0
  136. package/src/memory/search/formatting.ts +7 -44
  137. package/src/memory/search/staleness.ts +4 -0
  138. package/src/memory/search/tier-classifier.ts +10 -2
  139. package/src/memory/search/types.ts +2 -5
  140. package/src/memory/task-memory-cleanup.ts +4 -3
  141. package/src/notifications/adapters/slack.ts +168 -6
  142. package/src/notifications/broadcaster.ts +1 -0
  143. package/src/notifications/copy-composer.ts +59 -2
  144. package/src/notifications/signal.ts +2 -0
  145. package/src/notifications/types.ts +2 -0
  146. package/src/prompts/journal-context.ts +133 -0
  147. package/src/prompts/persona-resolver.ts +80 -24
  148. package/src/prompts/system-prompt.ts +30 -0
  149. package/src/prompts/templates/NOW.md +26 -0
  150. package/src/prompts/templates/SOUL.md +20 -0
  151. package/src/prompts/update-bulletin-format.ts +0 -2
  152. package/src/providers/provider-send-message.ts +3 -32
  153. package/src/providers/registry.ts +2 -139
  154. package/src/providers/types.ts +1 -1
  155. package/src/runtime/access-request-helper.ts +4 -0
  156. package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
  157. package/src/runtime/auth/route-policy.ts +2 -0
  158. package/src/runtime/gateway-client.ts +47 -4
  159. package/src/runtime/guardian-decision-types.ts +45 -4
  160. package/src/runtime/http-server.ts +5 -2
  161. package/src/runtime/routes/access-request-decision.ts +2 -2
  162. package/src/runtime/routes/app-management-routes.ts +2 -1
  163. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
  164. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
  165. package/src/runtime/routes/channel-readiness-routes.ts +9 -4
  166. package/src/runtime/routes/debug-routes.ts +12 -9
  167. package/src/runtime/routes/guardian-approval-interception.ts +168 -11
  168. package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
  169. package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
  170. package/src/runtime/routes/identity-routes.ts +1 -1
  171. package/src/runtime/routes/inbound-message-handler.ts +31 -1
  172. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
  173. package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
  174. package/src/runtime/routes/integrations/twilio.ts +52 -10
  175. package/src/runtime/routes/memory-item-routes.test.ts +3 -3
  176. package/src/runtime/routes/memory-item-routes.ts +25 -11
  177. package/src/runtime/routes/secret-routes.ts +141 -10
  178. package/src/runtime/routes/tts-routes.ts +11 -1
  179. package/src/security/ces-credential-client.ts +18 -9
  180. package/src/security/ces-rpc-credential-backend.ts +4 -3
  181. package/src/security/credential-backend.ts +10 -4
  182. package/src/security/secure-keys.ts +21 -4
  183. package/src/skills/catalog-install.ts +4 -36
  184. package/src/skills/inline-command-expansions.ts +7 -7
  185. package/src/skills/skill-memory.ts +1 -0
  186. package/src/subagent/manager.ts +2 -5
  187. package/src/tools/acp/spawn.ts +78 -1
  188. package/src/tools/credentials/vault.ts +5 -3
  189. package/src/tools/memory/definitions.ts +3 -2
  190. package/src/tools/memory/handlers.ts +10 -7
  191. package/src/tools/sensitive-output-placeholders.ts +2 -2
  192. package/src/tools/terminal/safe-env.ts +1 -0
  193. package/src/util/browser.ts +15 -0
  194. package/src/util/platform.ts +1 -1
  195. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
  196. package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
  197. package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
  198. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
  199. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
  200. package/src/workspace/migrations/registry.ts +4 -0
  201. package/src/workspace/provider-commit-message-generator.ts +12 -21
  202. package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
  203. package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
  204. package/src/memory/search/lexical.ts +0 -48
  205. package/src/providers/failover.ts +0 -186
@@ -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);
@@ -2,7 +2,7 @@
2
2
  * Memory lifecycle E2E regression test.
3
3
  *
4
4
  * Verifies the new memory pipeline end-to-end:
5
- * - 6-kind enum items (identity, preference, project, decision, constraint, event)
5
+ * - Standard-kind enum items (identity, preference, project, decision, constraint, event, journal, capability, ...)
6
6
  * - Supersession chains (supersedes/supersededBy fields)
7
7
  * - Hybrid search retrieval
8
8
  * - Two-layer XML injection format (<memory_context> with sections)
@@ -44,10 +44,31 @@ mock.module("../util/logger.js", () => ({
44
44
  }),
45
45
  }));
46
46
 
47
+ // Stub the local embedding backend so the real ONNX model never loads
48
+ mock.module("../memory/embedding-local.js", () => ({
49
+ LocalEmbeddingBackend: class {
50
+ readonly provider = "local" as const;
51
+ readonly model: string;
52
+ constructor(model: string) {
53
+ this.model = model;
54
+ }
55
+ async embed(texts: string[]): Promise<number[][]> {
56
+ return texts.map(() => new Array(384).fill(0));
57
+ }
58
+ },
59
+ }));
60
+
61
+ // Dynamic Qdrant mock: tests can push results to be returned by searchWithFilter/hybridSearch
62
+ let mockQdrantResults: Array<{
63
+ id: string;
64
+ score: number;
65
+ payload: Record<string, unknown>;
66
+ }> = [];
67
+
47
68
  mock.module("../memory/qdrant-client.js", () => ({
48
69
  getQdrantClient: () => ({
49
- searchWithFilter: async () => [],
50
- hybridSearch: async () => [],
70
+ searchWithFilter: async () => mockQdrantResults,
71
+ hybridSearch: async () => mockQdrantResults,
51
72
  upsertPoints: async () => {},
52
73
  deletePoints: async () => {},
53
74
  }),
@@ -60,7 +81,6 @@ const TEST_CONFIG = {
60
81
  ...DEFAULT_CONFIG.memory,
61
82
  embeddings: {
62
83
  ...DEFAULT_CONFIG.memory.embeddings,
63
- provider: "openai" as const,
64
84
  required: false,
65
85
  },
66
86
  extraction: {
@@ -115,6 +135,7 @@ describe("Memory lifecycle E2E regression", () => {
115
135
  db.run("DELETE FROM conversations");
116
136
  db.run("DELETE FROM memory_jobs");
117
137
  db.run("DELETE FROM memory_checkpoints");
138
+ mockQdrantResults = [];
118
139
  resetCleanupScheduleThrottle();
119
140
  resetStaleSweepThrottle();
120
141
  });
@@ -128,7 +149,7 @@ describe("Memory lifecycle E2E regression", () => {
128
149
  }
129
150
  });
130
151
 
131
- test("extraction produces items with 6-kind enum and supersession chains form correctly", async () => {
152
+ test("extraction produces items with standard-kind enum and supersession chains form correctly", async () => {
132
153
  const db = getDb();
133
154
  const now = 1_701_100_000_000;
134
155
  const conversationId = "conv-memory-lifecycle";
@@ -165,7 +186,7 @@ describe("Memory lifecycle E2E regression", () => {
165
186
  ])
166
187
  .run();
167
188
 
168
- // Seed items using the 6-kind enum
189
+ // Seed items using the standard-kind enum
169
190
  const kinds = [
170
191
  "identity",
171
192
  "preference",
@@ -299,12 +320,8 @@ describe("Memory lifecycle E2E regression", () => {
299
320
  TEST_CONFIG,
300
321
  );
301
322
 
302
- // Recency search finds segments but their finalScore (semantic*0.7 +
303
- // recency*0.2 + confidence*0.1) is too low to pass tier classification
304
- // (threshold > 0.6) because semantic=0 with Qdrant mocked empty.
305
- // Verify recency search ran successfully.
306
- expect(recall.recencyHits).toBeGreaterThan(0);
307
- // Candidates exist but don't pass tier classification, so injectedText is empty
323
+ // Without semantic search (Qdrant mocked empty), no candidates pass
324
+ // tier classification (threshold > 0.6).
308
325
  expect(recall.enabled).toBe(true);
309
326
  });
310
327
 
@@ -343,15 +360,47 @@ describe("Memory lifecycle E2E regression", () => {
343
360
  })
344
361
  .run();
345
362
 
346
- db.run(`
347
- INSERT INTO memory_segments (
348
- id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at
349
- ) VALUES (
350
- 'seg-injection-seed', 'msg-injection-seed', '${conversationId}', 'user', 0,
351
- 'My preferred timezone is America/Los_Angeles.', 10, 'default',
352
- ${now + 10}, ${now + 10}
353
- )
354
- `);
363
+ // Seed a memory item so the semantic search path can find it
364
+ db.insert(memoryItems)
365
+ .values({
366
+ id: "item-timezone-pref",
367
+ kind: "preference",
368
+ subject: "timezone preference",
369
+ statement: "My preferred timezone is America/Los_Angeles.",
370
+ status: "active",
371
+ confidence: 0.9,
372
+ importance: 0.8,
373
+ fingerprint: "fp-item-timezone-pref",
374
+ firstSeenAt: now + 10,
375
+ lastSeenAt: now + 10,
376
+ })
377
+ .run();
378
+
379
+ db.insert(memoryItemSources)
380
+ .values({
381
+ memoryItemId: "item-timezone-pref",
382
+ messageId: "msg-injection-seed",
383
+ evidence: "timezone preference evidence",
384
+ createdAt: now + 10,
385
+ })
386
+ .run();
387
+
388
+ // Mock Qdrant to return the timezone preference item
389
+ mockQdrantResults = [
390
+ {
391
+ id: "emb-timezone-pref",
392
+ score: 0.92,
393
+ payload: {
394
+ target_type: "item",
395
+ target_id: "item-timezone-pref",
396
+ text: "My preferred timezone is America/Los_Angeles.",
397
+ kind: "preference",
398
+ status: "active",
399
+ created_at: now + 10,
400
+ last_seen_at: now + 10,
401
+ },
402
+ },
403
+ ];
355
404
 
356
405
  const recall = await buildMemoryRecall(
357
406
  "timezone",
@@ -359,10 +408,6 @@ describe("Memory lifecycle E2E regression", () => {
359
408
  TEST_CONFIG,
360
409
  );
361
410
 
362
- // The recency-only promotion path (Step 6 in retriever) ensures the
363
- // seeded segment reaches tier 2 and is injected even without semantic
364
- // search. Verify structure of the two-layer XML format.
365
- expect(recall.recencyHits).toBeGreaterThan(0);
366
411
  expect(recall.enabled).toBe(true);
367
412
  expect(recall.injectedText.length).toBeGreaterThan(0);
368
413
  expect(recall.injectedTokens).toBeGreaterThan(0);