@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.
- package/Dockerfile +2 -1
- package/docker-entrypoint.sh +9 -0
- package/docs/architecture/memory.md +13 -11
- package/eslint.config.mjs +0 -31
- package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/approval-cascade.test.ts +0 -1
- package/src/__tests__/browser-fill-credential.test.ts +1 -1
- package/src/__tests__/call-controller.test.ts +0 -1
- package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
- package/src/__tests__/ces-startup-timeout.test.ts +40 -0
- package/src/__tests__/config-schema-cmd.test.ts +0 -1
- package/src/__tests__/config-schema.test.ts +2 -0
- package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
- package/src/__tests__/conversation-agent-loop.test.ts +2 -4
- package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
- package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
- package/src/__tests__/conversation-queue.test.ts +0 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
- package/src/__tests__/conversation-slash-queue.test.ts +0 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
- package/src/__tests__/credential-execution-client.test.ts +5 -2
- package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
- package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
- package/src/__tests__/credential-security-e2e.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +2 -5
- package/src/__tests__/credentials-cli.test.ts +4 -3
- package/src/__tests__/daemon-credential-client.test.ts +123 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
- package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
- package/src/__tests__/journal-context.test.ts +335 -0
- package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
- package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
- package/src/__tests__/memory-recall-quality.test.ts +48 -17
- package/src/__tests__/memory-regressions.test.ts +408 -363
- package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
- package/src/__tests__/non-member-access-request.test.ts +2 -2
- package/src/__tests__/notification-decision-strategy.test.ts +71 -0
- package/src/__tests__/oauth-cli.test.ts +5 -1
- package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
- package/src/__tests__/provider-error-scenarios.test.ts +0 -267
- package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
- package/src/__tests__/relay-server.test.ts +1 -2
- package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
- package/src/__tests__/secret-onetime-send.test.ts +1 -1
- package/src/__tests__/secure-keys.test.ts +18 -15
- package/src/__tests__/skill-memory.test.ts +17 -3
- package/src/__tests__/stale-approval-dedup.test.ts +171 -0
- package/src/__tests__/stt-hints.test.ts +437 -0
- package/src/__tests__/task-memory-cleanup.test.ts +14 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
- package/src/__tests__/voice-quality.test.ts +58 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
- package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
- package/src/acp/agent-process.ts +9 -1
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-request-resolvers.ts +164 -38
- package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
- package/src/calls/call-controller.ts +9 -5
- package/src/calls/fish-audio-client.ts +26 -14
- package/src/calls/stt-hints.ts +189 -0
- package/src/calls/tts-text-sanitizer.ts +61 -0
- package/src/calls/twilio-routes.ts +32 -4
- package/src/calls/voice-quality.ts +15 -3
- package/src/calls/voice-session-bridge.ts +1 -0
- package/src/cli/commands/avatar.ts +2 -2
- package/src/cli/commands/credentials.ts +110 -94
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/keys.ts +7 -7
- package/src/cli/commands/memory.ts +1 -1
- package/src/cli/commands/oauth/connections.ts +11 -29
- package/src/cli/commands/oauth/platform.ts +389 -43
- package/src/cli/lib/daemon-credential-client.ts +284 -0
- package/src/cli.ts +1 -1
- package/src/config/bundled-skills/AGENTS.md +34 -0
- package/src/config/bundled-skills/acp/SKILL.md +10 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
- package/src/config/bundled-skills/settings/SKILL.md +15 -2
- package/src/config/bundled-skills/settings/TOOLS.json +46 -1
- package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
- package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
- package/src/config/bundled-skills/slack/SKILL.md +1 -1
- package/src/config/bundled-tool-registry.ts +4 -0
- package/src/config/defaults.ts +0 -2
- package/src/config/env-registry.ts +4 -4
- package/src/config/env.ts +14 -1
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/loader.ts +8 -11
- package/src/config/schema.ts +5 -16
- package/src/config/schemas/calls.ts +17 -0
- package/src/config/schemas/inference.ts +2 -2
- package/src/config/schemas/journal.ts +16 -0
- package/src/config/schemas/memory-processing.ts +2 -2
- package/src/config/types.ts +1 -0
- package/src/contacts/contact-store.ts +2 -2
- package/src/credential-execution/executable-discovery.ts +1 -1
- package/src/credential-execution/startup-timeout.ts +36 -0
- package/src/daemon/approval-generators.ts +3 -9
- package/src/daemon/conversation-agent-loop.ts +6 -0
- package/src/daemon/conversation-error.ts +13 -1
- package/src/daemon/conversation-memory.ts +1 -2
- package/src/daemon/conversation-process.ts +18 -1
- package/src/daemon/conversation-runtime-assembly.ts +61 -1
- package/src/daemon/conversation-surfaces.ts +30 -1
- package/src/daemon/conversation.ts +20 -9
- package/src/daemon/guardian-action-generators.ts +3 -9
- package/src/daemon/lifecycle.ts +18 -11
- package/src/daemon/message-types/conversations.ts +1 -0
- package/src/daemon/server.ts +2 -3
- package/src/memory/app-store.ts +31 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/indexer.ts +19 -10
- package/src/memory/items-extractor.ts +315 -322
- package/src/memory/job-handlers/summarization.ts +26 -16
- package/src/memory/jobs-store.ts +33 -1
- package/src/memory/journal-memory.ts +214 -0
- package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/retriever.test.ts +37 -25
- package/src/memory/retriever.ts +24 -49
- package/src/memory/schema/memory-core.ts +2 -0
- package/src/memory/search/formatting.ts +7 -44
- package/src/memory/search/staleness.ts +4 -0
- package/src/memory/search/tier-classifier.ts +10 -2
- package/src/memory/search/types.ts +2 -5
- package/src/memory/task-memory-cleanup.ts +4 -3
- package/src/notifications/adapters/slack.ts +168 -6
- package/src/notifications/broadcaster.ts +1 -0
- package/src/notifications/copy-composer.ts +59 -2
- package/src/notifications/signal.ts +2 -0
- package/src/notifications/types.ts +2 -0
- package/src/prompts/journal-context.ts +133 -0
- package/src/prompts/persona-resolver.ts +80 -24
- package/src/prompts/system-prompt.ts +30 -0
- package/src/prompts/templates/NOW.md +26 -0
- package/src/prompts/templates/SOUL.md +20 -0
- package/src/prompts/update-bulletin-format.ts +0 -2
- package/src/providers/provider-send-message.ts +3 -32
- package/src/providers/registry.ts +2 -139
- package/src/providers/types.ts +1 -1
- package/src/runtime/access-request-helper.ts +4 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
- package/src/runtime/auth/route-policy.ts +2 -0
- package/src/runtime/gateway-client.ts +47 -4
- package/src/runtime/guardian-decision-types.ts +45 -4
- package/src/runtime/http-server.ts +5 -2
- package/src/runtime/routes/access-request-decision.ts +2 -2
- package/src/runtime/routes/app-management-routes.ts +2 -1
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
- package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
- package/src/runtime/routes/channel-readiness-routes.ts +9 -4
- package/src/runtime/routes/debug-routes.ts +12 -9
- package/src/runtime/routes/guardian-approval-interception.ts +168 -11
- package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
- package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
- package/src/runtime/routes/identity-routes.ts +1 -1
- package/src/runtime/routes/inbound-message-handler.ts +31 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
- package/src/runtime/routes/integrations/twilio.ts +52 -10
- package/src/runtime/routes/memory-item-routes.test.ts +3 -3
- package/src/runtime/routes/memory-item-routes.ts +25 -11
- package/src/runtime/routes/secret-routes.ts +141 -10
- package/src/runtime/routes/tts-routes.ts +11 -1
- package/src/security/ces-credential-client.ts +18 -9
- package/src/security/ces-rpc-credential-backend.ts +4 -3
- package/src/security/credential-backend.ts +10 -4
- package/src/security/secure-keys.ts +21 -4
- package/src/skills/catalog-install.ts +4 -36
- package/src/skills/inline-command-expansions.ts +7 -7
- package/src/skills/skill-memory.ts +1 -0
- package/src/subagent/manager.ts +2 -5
- package/src/tools/acp/spawn.ts +78 -1
- package/src/tools/credentials/vault.ts +5 -3
- package/src/tools/memory/definitions.ts +3 -2
- package/src/tools/memory/handlers.ts +10 -7
- package/src/tools/sensitive-output-placeholders.ts +2 -2
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/util/browser.ts +15 -0
- package/src/util/platform.ts +1 -1
- package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
- package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
- package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
- package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/workspace/provider-commit-message-generator.ts +12 -21
- package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
- package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
- package/src/memory/search/lexical.ts +0 -48
- 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 {
|
|
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
|
-
* -
|
|
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
|
|
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
|
|
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
|
-
//
|
|
303
|
-
//
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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);
|