@vellumai/assistant 0.4.57 → 0.5.0
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/package.json +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +28 -21
- package/src/__tests__/encrypted-store.test.ts +24 -12
- package/src/__tests__/file-read-tool.test.ts +40 -0
- package/src/__tests__/host-file-read-tool.test.ts +87 -0
- package/src/__tests__/identity-intro-cache.test.ts +209 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +3 -3
- package/src/__tests__/skill-memory.test.ts +14 -12
- package/src/daemon/conversation-runtime-assembly.ts +4 -3
- package/src/daemon/trace-emitter.ts +3 -2
- package/src/memory/search/staleness.ts +4 -1
- package/src/notifications/decision-engine.ts +43 -2
- package/src/notifications/emit-signal.ts +1 -0
- package/src/prompts/templates/BOOTSTRAP.md +10 -4
- package/src/prompts/templates/IDENTITY.md +1 -2
- package/src/providers/anthropic/client.ts +5 -17
- package/src/runtime/access-request-helper.ts +15 -1
- package/src/runtime/guardian-vellum-migration.ts +1 -3
- package/src/runtime/routes/btw-routes.ts +84 -0
- package/src/runtime/routes/identity-intro-cache.ts +105 -0
- package/src/runtime/routes/identity-routes.ts +51 -0
- package/src/runtime/routes/settings-routes.ts +1 -1
- package/src/security/encrypted-store.ts +1 -2
- package/src/skills/skill-memory.ts +5 -3
- package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
- package/src/telemetry/usage-telemetry-reporter.ts +2 -0
- package/src/tools/filesystem/read.ts +14 -3
- package/src/tools/host-filesystem/read.ts +17 -1
- package/src/util/pricing.ts +4 -0
package/package.json
CHANGED
|
@@ -1055,39 +1055,46 @@ describe("applyRuntimeInjections with inboundActorContext", () => {
|
|
|
1055
1055
|
|
|
1056
1056
|
describe("buildTurnContextBlock (channel-only)", () => {
|
|
1057
1057
|
test("collapses to single field when all channels match", () => {
|
|
1058
|
-
const block = buildTurnContextBlock(
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1058
|
+
const block = buildTurnContextBlock(
|
|
1059
|
+
{
|
|
1060
|
+
turnContext: {
|
|
1061
|
+
userMessageChannel: "telegram",
|
|
1062
|
+
assistantMessageChannel: "telegram",
|
|
1063
|
+
},
|
|
1064
|
+
conversationOriginChannel: "telegram",
|
|
1062
1065
|
},
|
|
1063
|
-
|
|
1064
|
-
|
|
1066
|
+
undefined,
|
|
1067
|
+
);
|
|
1065
1068
|
expect(block).toBe(
|
|
1066
|
-
"<turn_context>\n" +
|
|
1067
|
-
"channel: telegram\n" +
|
|
1068
|
-
"</turn_context>",
|
|
1069
|
+
"<turn_context>\n" + "channel: telegram\n" + "</turn_context>",
|
|
1069
1070
|
);
|
|
1070
1071
|
});
|
|
1071
1072
|
|
|
1072
1073
|
test('uses "unknown" when conversationOriginChannel is null', () => {
|
|
1073
|
-
const block = buildTurnContextBlock(
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1074
|
+
const block = buildTurnContextBlock(
|
|
1075
|
+
{
|
|
1076
|
+
turnContext: {
|
|
1077
|
+
userMessageChannel: "vellum",
|
|
1078
|
+
assistantMessageChannel: "vellum",
|
|
1079
|
+
},
|
|
1080
|
+
conversationOriginChannel: null,
|
|
1077
1081
|
},
|
|
1078
|
-
|
|
1079
|
-
|
|
1082
|
+
undefined,
|
|
1083
|
+
);
|
|
1080
1084
|
expect(block).toContain("conversation_origin_channel: unknown");
|
|
1081
1085
|
});
|
|
1082
1086
|
|
|
1083
1087
|
test("handles mixed channels", () => {
|
|
1084
|
-
const block = buildTurnContextBlock(
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
+
const block = buildTurnContextBlock(
|
|
1089
|
+
{
|
|
1090
|
+
turnContext: {
|
|
1091
|
+
userMessageChannel: "telegram",
|
|
1092
|
+
assistantMessageChannel: "vellum",
|
|
1093
|
+
},
|
|
1094
|
+
conversationOriginChannel: "vellum",
|
|
1088
1095
|
},
|
|
1089
|
-
|
|
1090
|
-
|
|
1096
|
+
undefined,
|
|
1097
|
+
);
|
|
1091
1098
|
expect(block).toContain("user_message_channel: telegram");
|
|
1092
1099
|
expect(block).toContain("assistant_message_channel: vellum");
|
|
1093
1100
|
expect(block).toContain("conversation_origin_channel: vellum");
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createCipheriv,
|
|
3
|
-
pbkdf2Sync,
|
|
4
|
-
randomBytes,
|
|
5
|
-
} from "node:crypto";
|
|
1
|
+
import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
|
|
6
2
|
import {
|
|
7
3
|
chmodSync,
|
|
8
4
|
existsSync,
|
|
@@ -72,11 +68,23 @@ const SALT_LENGTH = 32;
|
|
|
72
68
|
/** Local copy of the legacy machine entropy derivation (the export was removed). */
|
|
73
69
|
function legacyMachineEntropy(): string {
|
|
74
70
|
const parts: string[] = [];
|
|
75
|
-
try {
|
|
76
|
-
|
|
71
|
+
try {
|
|
72
|
+
parts.push(hostname());
|
|
73
|
+
} catch {
|
|
74
|
+
parts.push("unknown-host");
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
parts.push(userInfo().username);
|
|
78
|
+
} catch {
|
|
79
|
+
parts.push("unknown-user");
|
|
80
|
+
}
|
|
77
81
|
parts.push(process.platform);
|
|
78
82
|
parts.push(process.arch);
|
|
79
|
-
try {
|
|
83
|
+
try {
|
|
84
|
+
parts.push(userInfo().homedir);
|
|
85
|
+
} catch {
|
|
86
|
+
parts.push("/tmp");
|
|
87
|
+
}
|
|
80
88
|
return parts.join(":");
|
|
81
89
|
}
|
|
82
90
|
|
|
@@ -346,7 +354,8 @@ describe("encrypted-store", () => {
|
|
|
346
354
|
// Create a v1 store using legacy encryption
|
|
347
355
|
const salt = randomBytes(SALT_LENGTH);
|
|
348
356
|
const legacyKey = legacyDeriveKey(salt);
|
|
349
|
-
const entries: Record<string, { iv: string; tag: string; data: string }> =
|
|
357
|
+
const entries: Record<string, { iv: string; tag: string; data: string }> =
|
|
358
|
+
{};
|
|
350
359
|
entries["api-key"] = legacyEncrypt("secret-123", legacyKey);
|
|
351
360
|
entries["other-key"] = legacyEncrypt("secret-456", legacyKey);
|
|
352
361
|
writeV1Store(STORE_PATH, salt, entries);
|
|
@@ -372,7 +381,8 @@ describe("encrypted-store", () => {
|
|
|
372
381
|
// Create a v1 store
|
|
373
382
|
const salt = randomBytes(SALT_LENGTH);
|
|
374
383
|
const legacyKey = legacyDeriveKey(salt);
|
|
375
|
-
const entries: Record<string, { iv: string; tag: string; data: string }> =
|
|
384
|
+
const entries: Record<string, { iv: string; tag: string; data: string }> =
|
|
385
|
+
{};
|
|
376
386
|
entries["my-key"] = legacyEncrypt("my-value", legacyKey);
|
|
377
387
|
writeV1Store(STORE_PATH, salt, entries);
|
|
378
388
|
|
|
@@ -393,7 +403,8 @@ describe("encrypted-store", () => {
|
|
|
393
403
|
// Create a v1 store with one good entry and one tampered entry
|
|
394
404
|
const salt = randomBytes(SALT_LENGTH);
|
|
395
405
|
const legacyKey = legacyDeriveKey(salt);
|
|
396
|
-
const entries: Record<string, { iv: string; tag: string; data: string }> =
|
|
406
|
+
const entries: Record<string, { iv: string; tag: string; data: string }> =
|
|
407
|
+
{};
|
|
397
408
|
entries["good"] = legacyEncrypt("good-value", legacyKey);
|
|
398
409
|
entries["bad"] = legacyEncrypt("bad-value", legacyKey);
|
|
399
410
|
// Tamper with the bad entry
|
|
@@ -423,7 +434,8 @@ describe("encrypted-store", () => {
|
|
|
423
434
|
// write v1 store + store.key but leave store as v1
|
|
424
435
|
const salt = randomBytes(SALT_LENGTH);
|
|
425
436
|
const legacyKey = legacyDeriveKey(salt);
|
|
426
|
-
const entries: Record<string, { iv: string; tag: string; data: string }> =
|
|
437
|
+
const entries: Record<string, { iv: string; tag: string; data: string }> =
|
|
438
|
+
{};
|
|
427
439
|
entries["test-key"] = legacyEncrypt("test-value", legacyKey);
|
|
428
440
|
writeV1Store(STORE_PATH, salt, entries);
|
|
429
441
|
|
|
@@ -177,3 +177,43 @@ describe("file_read image support", () => {
|
|
|
177
177
|
expect(result.content).toContain("outside the working directory");
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
// ── Out-of-bounds hint for host_file_read ─────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe("file_read out-of-bounds hint", () => {
|
|
184
|
+
test("suggests host_file_read for out-of-bounds text file path", async () => {
|
|
185
|
+
const dir = makeTempDir();
|
|
186
|
+
|
|
187
|
+
const result = await fileReadTool.execute(
|
|
188
|
+
{ path: "/etc/passwd" },
|
|
189
|
+
makeContext(dir),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(result.isError).toBe(true);
|
|
193
|
+
expect(result.content).toContain("host_file_read");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("suggests host_file_read for out-of-bounds image file path", async () => {
|
|
197
|
+
const dir = makeTempDir();
|
|
198
|
+
|
|
199
|
+
const result = await fileReadTool.execute(
|
|
200
|
+
{ path: "/Users/someone/Desktop/screenshot.png" },
|
|
201
|
+
makeContext(dir),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(result.isError).toBe(true);
|
|
205
|
+
expect(result.content).toContain("host_file_read");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("does not suggest host_file_read for missing file within sandbox", async () => {
|
|
209
|
+
const dir = makeTempDir();
|
|
210
|
+
|
|
211
|
+
const result = await fileReadTool.execute(
|
|
212
|
+
{ path: "nonexistent.txt" },
|
|
213
|
+
makeContext(dir),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expect(result.isError).toBe(true);
|
|
217
|
+
expect(result.content).not.toContain("host_file_read");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -8,6 +8,12 @@ import type { ToolContext } from "../tools/types.js";
|
|
|
8
8
|
|
|
9
9
|
const testDirs: string[] = [];
|
|
10
10
|
|
|
11
|
+
function makeTempDir(): string {
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), "host-file-read-test-"));
|
|
13
|
+
testDirs.push(dir);
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
function makeContext(): ToolContext {
|
|
12
18
|
return {
|
|
13
19
|
workingDir: "/tmp",
|
|
@@ -22,6 +28,18 @@ afterEach(() => {
|
|
|
22
28
|
}
|
|
23
29
|
});
|
|
24
30
|
|
|
31
|
+
// Minimal valid JPEG: FF D8 FF E0 header
|
|
32
|
+
const JPEG_HEADER = Buffer.from([
|
|
33
|
+
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01,
|
|
34
|
+
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Minimal PNG header
|
|
38
|
+
const PNG_HEADER = Buffer.from([
|
|
39
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
|
40
|
+
0x48, 0x44, 0x52,
|
|
41
|
+
]);
|
|
42
|
+
|
|
25
43
|
describe("host_file_read tool", () => {
|
|
26
44
|
test("rejects relative paths", async () => {
|
|
27
45
|
const result = await hostFileReadTool.execute(
|
|
@@ -145,3 +163,72 @@ describe("host_file_read tool", () => {
|
|
|
145
163
|
expect(result.content).toContain("symlink-content");
|
|
146
164
|
});
|
|
147
165
|
});
|
|
166
|
+
|
|
167
|
+
describe("host_file_read image support", () => {
|
|
168
|
+
test("returns image content block for .png file", async () => {
|
|
169
|
+
const dir = makeTempDir();
|
|
170
|
+
const filePath = join(dir, "screenshot.png");
|
|
171
|
+
writeFileSync(filePath, PNG_HEADER);
|
|
172
|
+
|
|
173
|
+
const result = await hostFileReadTool.execute(
|
|
174
|
+
{ path: filePath },
|
|
175
|
+
makeContext(),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(result.isError).toBe(false);
|
|
179
|
+
expect(result.content).toContain("Image loaded");
|
|
180
|
+
expect(result.content).toContain("image/png");
|
|
181
|
+
expect((result as any).contentBlocks).toBeDefined();
|
|
182
|
+
expect((result as any).contentBlocks[0].type).toBe("image");
|
|
183
|
+
expect((result as any).contentBlocks[0].source.media_type).toBe(
|
|
184
|
+
"image/png",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("returns correct media type for .jpg file", async () => {
|
|
189
|
+
const dir = makeTempDir();
|
|
190
|
+
const filePath = join(dir, "photo.jpg");
|
|
191
|
+
writeFileSync(filePath, JPEG_HEADER);
|
|
192
|
+
|
|
193
|
+
const result = await hostFileReadTool.execute(
|
|
194
|
+
{ path: filePath },
|
|
195
|
+
makeContext(),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
expect(result.isError).toBe(false);
|
|
199
|
+
expect(result.content).toContain("Image loaded");
|
|
200
|
+
expect(result.content).toContain("image/jpeg");
|
|
201
|
+
expect((result as any).contentBlocks).toBeDefined();
|
|
202
|
+
expect((result as any).contentBlocks[0].type).toBe("image");
|
|
203
|
+
expect((result as any).contentBlocks[0].source.media_type).toBe(
|
|
204
|
+
"image/jpeg",
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("returns error for non-existent image path", async () => {
|
|
209
|
+
const filePath = join(tmpdir(), `host-file-read-missing-${Date.now()}.png`);
|
|
210
|
+
const result = await hostFileReadTool.execute(
|
|
211
|
+
{ path: filePath },
|
|
212
|
+
makeContext(),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(result.isError).toBe(true);
|
|
216
|
+
expect(result.content).toContain("file not found");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("text file still works as before (regression)", async () => {
|
|
220
|
+
const dir = makeTempDir();
|
|
221
|
+
const filePath = join(dir, "notes.txt");
|
|
222
|
+
writeFileSync(filePath, "hello world\nsecond line\n");
|
|
223
|
+
|
|
224
|
+
const result = await hostFileReadTool.execute(
|
|
225
|
+
{ path: filePath },
|
|
226
|
+
makeContext(),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(result.isError).toBe(false);
|
|
230
|
+
expect(result.content).toContain("1 hello world");
|
|
231
|
+
expect(result.content).toContain("2 second line");
|
|
232
|
+
expect((result as any).contentBlocks).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the identity intro cache (identity-intro-cache.ts).
|
|
3
|
+
*
|
|
4
|
+
* Validates TTL-based expiration, content-hash-based invalidation when
|
|
5
|
+
* workspace identity files change, and round-trip get/set behavior.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Mocks — must be defined before importing the module under test
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
// Suppress logger output
|
|
15
|
+
mock.module("../util/logger.js", () => ({
|
|
16
|
+
getLogger: () =>
|
|
17
|
+
new Proxy({} as Record<string, unknown>, {
|
|
18
|
+
get: () => () => {},
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// In-memory checkpoint store
|
|
23
|
+
const checkpointStore = new Map<string, string>();
|
|
24
|
+
|
|
25
|
+
mock.module("../memory/checkpoints.js", () => ({
|
|
26
|
+
getMemoryCheckpoint: (key: string) => checkpointStore.get(key) ?? null,
|
|
27
|
+
setMemoryCheckpoint: (key: string, value: string) => {
|
|
28
|
+
checkpointStore.set(key, value);
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Simulated workspace file contents
|
|
33
|
+
const workspaceFiles: Record<string, string> = {};
|
|
34
|
+
|
|
35
|
+
mock.module("../util/platform.js", () => ({
|
|
36
|
+
getWorkspacePromptPath: (name: string) => `/mock/workspace/${name}`,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
mock.module("node:fs", () => ({
|
|
40
|
+
existsSync: (path: string) => {
|
|
41
|
+
const name = path.split("/").pop() ?? "";
|
|
42
|
+
return name in workspaceFiles;
|
|
43
|
+
},
|
|
44
|
+
readFileSync: (path: string, _encoding: string) => {
|
|
45
|
+
const name = path.split("/").pop() ?? "";
|
|
46
|
+
if (name in workspaceFiles) return workspaceFiles[name];
|
|
47
|
+
throw new Error(`ENOENT: ${path}`);
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Imports (after mocks)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
computeIdentityContentHash,
|
|
57
|
+
getCachedIntro,
|
|
58
|
+
setCachedIntro,
|
|
59
|
+
} from "../runtime/routes/identity-intro-cache.js";
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
checkpointStore.clear();
|
|
67
|
+
for (const key of Object.keys(workspaceFiles)) {
|
|
68
|
+
delete workspaceFiles[key];
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Tests
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe("identity intro cache", () => {
|
|
77
|
+
test("returns null when cache is empty", () => {
|
|
78
|
+
expect(getCachedIntro()).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("round-trip: set then get returns cached text", () => {
|
|
82
|
+
workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
|
|
83
|
+
workspaceFiles["SOUL.md"] = "Be playful.";
|
|
84
|
+
workspaceFiles["USER.md"] = "The user likes coffee.";
|
|
85
|
+
|
|
86
|
+
setCachedIntro("Hey, I'm Atlas.");
|
|
87
|
+
const cached = getCachedIntro();
|
|
88
|
+
expect(cached).not.toBeNull();
|
|
89
|
+
expect(cached!.text).toBe("Hey, I'm Atlas.");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("returns null when cache is expired (TTL exceeded)", () => {
|
|
93
|
+
workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
|
|
94
|
+
|
|
95
|
+
setCachedIntro("Hello!");
|
|
96
|
+
|
|
97
|
+
// Manually set the timestamp to 5 hours ago
|
|
98
|
+
const fiveHoursAgo = String(Date.now() - 5 * 60 * 60 * 1000);
|
|
99
|
+
checkpointStore.set("identity:intro:cached_at", fiveHoursAgo);
|
|
100
|
+
|
|
101
|
+
expect(getCachedIntro()).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns cached text when within TTL (3 hours ago)", () => {
|
|
105
|
+
workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
|
|
106
|
+
|
|
107
|
+
setCachedIntro("Hello!");
|
|
108
|
+
|
|
109
|
+
// Set timestamp to 3 hours ago (within 4-hour TTL)
|
|
110
|
+
const threeHoursAgo = String(Date.now() - 3 * 60 * 60 * 1000);
|
|
111
|
+
checkpointStore.set("identity:intro:cached_at", threeHoursAgo);
|
|
112
|
+
|
|
113
|
+
const cached = getCachedIntro();
|
|
114
|
+
expect(cached).not.toBeNull();
|
|
115
|
+
expect(cached!.text).toBe("Hello!");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("busts cache when IDENTITY.md changes", () => {
|
|
119
|
+
workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
|
|
120
|
+
setCachedIntro("I'm Atlas!");
|
|
121
|
+
|
|
122
|
+
// Change IDENTITY.md
|
|
123
|
+
workspaceFiles["IDENTITY.md"] = "- **Name:** Nova";
|
|
124
|
+
|
|
125
|
+
expect(getCachedIntro()).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("busts cache when SOUL.md changes", () => {
|
|
129
|
+
workspaceFiles["SOUL.md"] = "Be playful.";
|
|
130
|
+
setCachedIntro("Hey there!");
|
|
131
|
+
|
|
132
|
+
// Change SOUL.md
|
|
133
|
+
workspaceFiles["SOUL.md"] = "Be serious and formal.";
|
|
134
|
+
|
|
135
|
+
expect(getCachedIntro()).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("busts cache when USER.md changes", () => {
|
|
139
|
+
workspaceFiles["USER.md"] = "Likes coffee.";
|
|
140
|
+
setCachedIntro("Good morning!");
|
|
141
|
+
|
|
142
|
+
// Change USER.md
|
|
143
|
+
workspaceFiles["USER.md"] = "Likes tea.";
|
|
144
|
+
|
|
145
|
+
expect(getCachedIntro()).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("cache survives when files are unchanged", () => {
|
|
149
|
+
workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
|
|
150
|
+
workspaceFiles["SOUL.md"] = "Be chill.";
|
|
151
|
+
workspaceFiles["USER.md"] = "Likes sunsets.";
|
|
152
|
+
|
|
153
|
+
setCachedIntro("Atlas here.");
|
|
154
|
+
|
|
155
|
+
// Read twice — both should return the cached value
|
|
156
|
+
expect(getCachedIntro()?.text).toBe("Atlas here.");
|
|
157
|
+
expect(getCachedIntro()?.text).toBe("Atlas here.");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("computeIdentityContentHash is deterministic", () => {
|
|
161
|
+
workspaceFiles["IDENTITY.md"] = "test";
|
|
162
|
+
workspaceFiles["SOUL.md"] = "test2";
|
|
163
|
+
workspaceFiles["USER.md"] = "test3";
|
|
164
|
+
|
|
165
|
+
const hash1 = computeIdentityContentHash();
|
|
166
|
+
const hash2 = computeIdentityContentHash();
|
|
167
|
+
expect(hash1).toBe(hash2);
|
|
168
|
+
expect(hash1).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("computeIdentityContentHash changes when file content changes", () => {
|
|
172
|
+
workspaceFiles["IDENTITY.md"] = "v1";
|
|
173
|
+
const hash1 = computeIdentityContentHash();
|
|
174
|
+
|
|
175
|
+
workspaceFiles["IDENTITY.md"] = "v2";
|
|
176
|
+
const hash2 = computeIdentityContentHash();
|
|
177
|
+
|
|
178
|
+
expect(hash1).not.toBe(hash2);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("handles missing workspace files gracefully", () => {
|
|
182
|
+
// No files exist — should still work (empty content hashed)
|
|
183
|
+
setCachedIntro("Hello!");
|
|
184
|
+
const cached = getCachedIntro();
|
|
185
|
+
expect(cached).not.toBeNull();
|
|
186
|
+
expect(cached!.text).toBe("Hello!");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("returns null when text checkpoint is missing", () => {
|
|
190
|
+
checkpointStore.set("identity:intro:content_hash", "abc");
|
|
191
|
+
checkpointStore.set("identity:intro:cached_at", String(Date.now()));
|
|
192
|
+
// Missing text — should return null
|
|
193
|
+
expect(getCachedIntro()).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("returns null when hash checkpoint is missing", () => {
|
|
197
|
+
checkpointStore.set("identity:intro:text", "Hello");
|
|
198
|
+
checkpointStore.set("identity:intro:cached_at", String(Date.now()));
|
|
199
|
+
// Missing hash — should return null
|
|
200
|
+
expect(getCachedIntro()).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("returns null when timestamp checkpoint is missing", () => {
|
|
204
|
+
checkpointStore.set("identity:intro:text", "Hello");
|
|
205
|
+
checkpointStore.set("identity:intro:content_hash", "abc");
|
|
206
|
+
// Missing timestamp — should return null
|
|
207
|
+
expect(getCachedIntro()).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -623,7 +623,7 @@ describe("access-request-helper unit tests", () => {
|
|
|
623
623
|
expect(telegram!.status).toBe("sent");
|
|
624
624
|
});
|
|
625
625
|
|
|
626
|
-
test("notifyGuardianOfAccessRequest
|
|
626
|
+
test("notifyGuardianOfAccessRequest skips vellum fallback for same-channel-only routing (telegram)", async () => {
|
|
627
627
|
mockEmitResult = {
|
|
628
628
|
signalId: "sig-no-vellum",
|
|
629
629
|
deduplicated: false,
|
|
@@ -657,8 +657,8 @@ describe("access-request-helper unit tests", () => {
|
|
|
657
657
|
(d) => d.destinationChannel === "telegram",
|
|
658
658
|
);
|
|
659
659
|
|
|
660
|
-
|
|
661
|
-
expect(vellum
|
|
660
|
+
// Same-channel routing skips vellum delivery entirely — no fallback record
|
|
661
|
+
expect(vellum).toBeUndefined();
|
|
662
662
|
expect(telegram).toBeDefined();
|
|
663
663
|
expect(telegram!.destinationChatId).toBe("guardian-chat-456");
|
|
664
664
|
expect(telegram!.status).toBe("sent");
|
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
afterAll,
|
|
6
|
-
beforeEach,
|
|
7
|
-
describe,
|
|
8
|
-
expect,
|
|
9
|
-
mock,
|
|
10
|
-
test,
|
|
11
|
-
} from "bun:test";
|
|
4
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
12
5
|
|
|
13
6
|
import { eq } from "drizzle-orm";
|
|
14
7
|
|
|
@@ -46,8 +39,9 @@ mock.module("../memory/qdrant-client.js", () => ({
|
|
|
46
39
|
}));
|
|
47
40
|
|
|
48
41
|
// Controllable mock for resolveCatalog used by seedCatalogSkillMemories
|
|
49
|
-
let mockResolveCatalog: () => Promise<
|
|
50
|
-
|
|
42
|
+
let mockResolveCatalog: () => Promise<
|
|
43
|
+
import("../skills/catalog-install.js").CatalogSkill[]
|
|
44
|
+
> = async () => [];
|
|
51
45
|
|
|
52
46
|
mock.module("../skills/catalog-install.js", () => ({
|
|
53
47
|
resolveCatalog: (..._args: unknown[]) => mockResolveCatalog(),
|
|
@@ -453,7 +447,11 @@ describe("seedCatalogSkillMemories", () => {
|
|
|
453
447
|
|
|
454
448
|
test("skips skills whose feature flag is disabled", async () => {
|
|
455
449
|
const skills: CatalogSkill[] = [
|
|
456
|
-
makeSkill({
|
|
450
|
+
makeSkill({
|
|
451
|
+
id: "unflagged-skill",
|
|
452
|
+
name: "Unflagged",
|
|
453
|
+
description: "No flag",
|
|
454
|
+
}),
|
|
457
455
|
makeSkill({
|
|
458
456
|
id: "flagged-skill",
|
|
459
457
|
name: "Flagged",
|
|
@@ -485,7 +483,11 @@ describe("seedCatalogSkillMemories", () => {
|
|
|
485
483
|
test("prunes pre-existing capability for a skill whose flag becomes disabled", async () => {
|
|
486
484
|
// First seed with both skills, all flags enabled
|
|
487
485
|
const skills: CatalogSkill[] = [
|
|
488
|
-
makeSkill({
|
|
486
|
+
makeSkill({
|
|
487
|
+
id: "unflagged-skill",
|
|
488
|
+
name: "Unflagged",
|
|
489
|
+
description: "No flag",
|
|
490
|
+
}),
|
|
489
491
|
makeSkill({
|
|
490
492
|
id: "flagged-skill",
|
|
491
493
|
name: "Flagged",
|
|
@@ -655,7 +655,6 @@ export function injectTurnContext(
|
|
|
655
655
|
};
|
|
656
656
|
}
|
|
657
657
|
|
|
658
|
-
|
|
659
658
|
/**
|
|
660
659
|
* Build the `<inbound_actor_context>` text block used for model grounding.
|
|
661
660
|
*
|
|
@@ -737,7 +736,10 @@ export function buildInboundActorContextBlock(
|
|
|
737
736
|
}
|
|
738
737
|
// Contact metadata - only included when the sender has a contact record
|
|
739
738
|
// with non-default values.
|
|
740
|
-
if (
|
|
739
|
+
if (
|
|
740
|
+
ctx.contactNotes &&
|
|
741
|
+
sanitizeInlineContextValue(ctx.contactNotes) !== ctx.trustClass
|
|
742
|
+
) {
|
|
741
743
|
lines.push(
|
|
742
744
|
`contact_notes: ${sanitizeInlineContextValue(ctx.contactNotes)}`,
|
|
743
745
|
);
|
|
@@ -932,7 +934,6 @@ export interface InterfaceTurnContextParams {
|
|
|
932
934
|
conversationOriginInterface: InterfaceId | null;
|
|
933
935
|
}
|
|
934
936
|
|
|
935
|
-
|
|
936
937
|
/** Strip interface turn context blocks (both legacy separate and unified). */
|
|
937
938
|
export function stripInterfaceTurnContext(messages: Message[]): Message[] {
|
|
938
939
|
return stripUserTextBlocksByPrefix(messages, [
|
|
@@ -67,13 +67,14 @@ export class TraceEmitter {
|
|
|
67
67
|
attributes,
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
// Send to client first so synchronous DB writes don't block SSE delivery.
|
|
71
|
+
this.sendToClient(event);
|
|
72
|
+
|
|
70
73
|
try {
|
|
71
74
|
persistTraceEvent(event as TraceEvent);
|
|
72
75
|
} catch (err) {
|
|
73
76
|
log.warn({ err, eventId }, "Failed to persist trace event");
|
|
74
77
|
}
|
|
75
|
-
|
|
76
|
-
this.sendToClient(event);
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
@@ -22,7 +22,10 @@ export function computeStaleness(
|
|
|
22
22
|
now: number,
|
|
23
23
|
): { level: StalenessLevel; ratio: number } {
|
|
24
24
|
const baseLifetime = BASE_LIFETIME_MS[item.kind] ?? DEFAULT_LIFETIME_MS;
|
|
25
|
-
const reinforcement = Math.max(
|
|
25
|
+
const reinforcement = Math.max(
|
|
26
|
+
1,
|
|
27
|
+
1 + 0.3 * (item.sourceConversationCount - 1),
|
|
28
|
+
);
|
|
26
29
|
const effectiveLifetime = baseLifetime * reinforcement;
|
|
27
30
|
const age = now - item.firstSeenAt;
|
|
28
31
|
const ratio = age / effectiveLifetime;
|