@vellumai/assistant 0.8.10-dev.202606092334.09948c8 → 0.8.10-dev.202606100317.c8b43c8

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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/conversation-fork-crud.test.ts +149 -0
  3. package/src/__tests__/conversation-lifecycle.test.ts +257 -1
  4. package/src/__tests__/injector-chain.test.ts +5 -4
  5. package/src/__tests__/injector-v3-suppression.test.ts +373 -47
  6. package/src/__tests__/memory-retrieval-hook.test.ts +88 -0
  7. package/src/__tests__/strip-memory-injections.test.ts +33 -39
  8. package/src/api/responses/memory-v3-selection-log.ts +4 -4
  9. package/src/config/schemas/__tests__/memory-v3.test.ts +61 -1
  10. package/src/config/schemas/memory-v3.ts +104 -33
  11. package/src/context/strip-injections.ts +31 -0
  12. package/src/daemon/conversation-runtime-assembly.ts +139 -14
  13. package/src/daemon/conversation.ts +80 -30
  14. package/src/daemon/trust-context.ts +23 -0
  15. package/src/memory/conversation-crud.ts +47 -9
  16. package/src/memory/db-init.ts +2 -0
  17. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +12 -0
  18. package/src/memory/graph/conversation-graph-memory.ts +35 -24
  19. package/src/memory/memory-marker.ts +29 -0
  20. package/src/memory/migrations/268-add-memory-v3-selections.ts +6 -0
  21. package/src/memory/migrations/277-add-memory-v3-ever-injected.ts +29 -0
  22. package/src/memory/migrations/index.ts +1 -0
  23. package/src/memory/v2/__tests__/consolidation-job.test.ts +14 -0
  24. package/src/memory/v2/__tests__/consolidation-prompt-flag-gating-guard.test.ts +140 -0
  25. package/src/memory/v2/__tests__/injected-block-slugs.test.ts +40 -1
  26. package/src/memory/v2/__tests__/prompts-consolidation.test.ts +85 -15
  27. package/src/memory/v2/consolidation-job.ts +9 -5
  28. package/src/memory/v2/injected-block-slugs.ts +61 -12
  29. package/src/memory/v2/injection.ts +6 -1
  30. package/src/memory/v2/prompts/consolidation.ts +71 -12
  31. package/src/memory/v2/router.ts +2 -28
  32. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +47 -7
  33. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +14 -8
  34. package/src/plugins/defaults/memory-retrieval/injectors.ts +2 -18
  35. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +1157 -0
  36. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +669 -0
  37. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +161 -140
  38. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +65 -0
  39. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +335 -316
  40. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +145 -53
  41. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +38 -1
  42. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +18 -8
  43. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +112 -71
  44. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +177 -51
  45. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +4 -16
  46. package/src/plugins/defaults/memory-v3-shadow/card.test.ts +173 -0
  47. package/src/plugins/defaults/memory-v3-shadow/card.ts +116 -0
  48. package/src/plugins/defaults/memory-v3-shadow/core-set.test.ts +104 -0
  49. package/src/plugins/defaults/memory-v3-shadow/core-set.ts +59 -0
  50. package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.test.ts +305 -0
  51. package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.ts +278 -0
  52. package/src/plugins/defaults/memory-v3-shadow/hot-set.test.ts +138 -0
  53. package/src/plugins/defaults/memory-v3-shadow/hot-set.ts +85 -0
  54. package/src/plugins/defaults/memory-v3-shadow/injector.ts +329 -24
  55. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +54 -5
  56. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +169 -114
  57. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +47 -16
  58. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +144 -66
  59. package/src/plugins/defaults/memory-v3-shadow/prune.test.ts +758 -0
  60. package/src/plugins/defaults/memory-v3-shadow/prune.ts +471 -0
  61. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +68 -16
  62. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +19 -12
  63. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +92 -42
  64. package/src/plugins/defaults/memory-v3-shadow/types.ts +34 -17
  65. package/src/providers/cache-control.ts +26 -0
  66. package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/eval-turns.json +0 -36
  67. package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/live-turns.json +0 -37
  68. package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-eviction.test.ts +0 -106
  69. package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-skeleton.test.ts +0 -44
  70. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +0 -91
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.10-dev.202606092334.09948c8",
3
+ "version": "0.8.10-dev.202606100317.c8b43c8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -63,6 +63,12 @@ import {
63
63
  toolInvocations,
64
64
  } from "../memory/schema.js";
65
65
  import { hydrate as hydrateActivationState } from "../memory/v2/activation-store.js";
66
+ import {
67
+ getInjected as getV3Injected,
68
+ markPruned as markV3Pruned,
69
+ MEMORY_V3_INJECTED_BLOCK_METADATA_KEY,
70
+ recordInjected as recordV3Injected,
71
+ } from "../plugins/defaults/memory-v3-shadow/ever-injected-store.js";
66
72
 
67
73
  initializeDb();
68
74
 
@@ -77,6 +83,7 @@ function resetTables(): void {
77
83
  db.delete(llmRequestLogs).run();
78
84
  db.delete(toolInvocations).run();
79
85
  db.delete(memoryJobs).run();
86
+ db.run("DELETE FROM memory_v3_ever_injected");
80
87
  db.run("DELETE FROM message_attachments");
81
88
  db.run("DELETE FROM attachments");
82
89
  db.run("DELETE FROM messages");
@@ -1032,6 +1039,148 @@ describe("forkConversation", () => {
1032
1039
  ]);
1033
1040
  });
1034
1041
 
1042
+ test("copies the parent's memory-v3 everInjected record into a full fork", async () => {
1043
+ const source = createConversation("V3 carry thread");
1044
+ await addMessage(source.id, "user", "first turn", { skipIndexing: true });
1045
+
1046
+ recordV3Injected(
1047
+ source.id,
1048
+ [
1049
+ { slug: "topics/page-a", bytes: 120 },
1050
+ { slug: "topics/page-b", bytes: 340 },
1051
+ ],
1052
+ 1_700_000_000_000,
1053
+ );
1054
+ markV3Pruned(source.id, ["topics/page-b"], 1_700_000_001_000);
1055
+
1056
+ const fork = forkConversation({ conversationId: source.id });
1057
+
1058
+ // Full-row copy, pruned state included.
1059
+ expect(getV3Injected(fork.id)).toEqual(
1060
+ new Map([
1061
+ ["topics/page-a", { bytes: 120, prunedAt: null }],
1062
+ ["topics/page-b", { bytes: 340, prunedAt: 1_700_000_001_000 }],
1063
+ ]),
1064
+ );
1065
+ // Parent record is untouched.
1066
+ expect(getV3Injected(source.id).size).toBe(2);
1067
+ });
1068
+
1069
+ test("leaves the fork's memory-v3 record empty when the parent has none", async () => {
1070
+ const source = createConversation("V3 pristine thread");
1071
+ await addMessage(source.id, "user", "first message", {
1072
+ skipIndexing: true,
1073
+ });
1074
+
1075
+ const fork = forkConversation({ conversationId: source.id });
1076
+
1077
+ expect(getV3Injected(fork.id).size).toBe(0);
1078
+ });
1079
+
1080
+ test("truncated fork seeds the memory-v3 record from inherited v3 card blocks", async () => {
1081
+ const source = createConversation("V3 truncated seed thread");
1082
+ await addMessage(source.id, "user", "first turn", {
1083
+ metadata: {
1084
+ [MEMORY_V3_INJECTED_BLOCK_METADATA_KEY]:
1085
+ "# memory/concepts/topics/page-a.md\nCard A\n\n# memory/concepts/topics/page-b.md\nCard B",
1086
+ // A v2 block on the same message must seed only the v2 record.
1087
+ memoryInjectedBlock: "# memory/concepts/topics/page-v2.md\nSummary",
1088
+ },
1089
+ skipIndexing: true,
1090
+ });
1091
+ await addMessage(source.id, "assistant", "first reply", {
1092
+ skipIndexing: true,
1093
+ });
1094
+ const boundaryMessage = await addMessage(source.id, "user", "second turn", {
1095
+ skipIndexing: true,
1096
+ });
1097
+ await addMessage(source.id, "assistant", "second reply", {
1098
+ skipIndexing: true,
1099
+ });
1100
+ // Past the fork boundary — its card must NOT be claimed.
1101
+ await addMessage(source.id, "user", "third turn", {
1102
+ metadata: {
1103
+ [MEMORY_V3_INJECTED_BLOCK_METADATA_KEY]:
1104
+ "# memory/concepts/topics/page-c.md\nCard C",
1105
+ },
1106
+ skipIndexing: true,
1107
+ });
1108
+
1109
+ recordV3Injected(
1110
+ source.id,
1111
+ [
1112
+ { slug: "topics/page-a", bytes: 120 },
1113
+ { slug: "topics/page-b", bytes: 340 },
1114
+ { slug: "topics/page-c", bytes: 90 },
1115
+ ],
1116
+ 1_700_000_000_000,
1117
+ );
1118
+
1119
+ const fork = forkConversation({
1120
+ conversationId: source.id,
1121
+ throughMessageId: boundaryMessage.id,
1122
+ });
1123
+
1124
+ // Exactly the slugs whose card blocks live in the copied history,
1125
+ // dedup-only (`bytes = 0` — resident accounting restarts on the child).
1126
+ expect(getV3Injected(fork.id)).toEqual(
1127
+ new Map([
1128
+ ["topics/page-a", { bytes: 0, prunedAt: null }],
1129
+ ["topics/page-b", { bytes: 0, prunedAt: null }],
1130
+ ]),
1131
+ );
1132
+ // The v2 seed picked up only the v2 block, not the v3 cards.
1133
+ const childState = await hydrateActivationState(getDb(), fork.id);
1134
+ expect(childState?.everInjected.map((e) => e.slug)).toEqual([
1135
+ "topics/page-v2",
1136
+ ]);
1137
+ });
1138
+
1139
+ test("truncated fork carries the parent's pruned tombstones for inherited v3 slugs", async () => {
1140
+ // Pruning never rewrites the persisted metadata block, so the fork scan
1141
+ // sees pruned cards' sections too — the seed must tombstone them, or the
1142
+ // child's rehydration would resurrect cards the parent's live view lost.
1143
+ const source = createConversation("V3 truncated pruned thread");
1144
+ await addMessage(source.id, "user", "first turn", {
1145
+ metadata: {
1146
+ [MEMORY_V3_INJECTED_BLOCK_METADATA_KEY]:
1147
+ "# memory/concepts/topics/page-a.md\nCard A\n\n# memory/concepts/topics/page-b.md\nCard B",
1148
+ },
1149
+ skipIndexing: true,
1150
+ });
1151
+ const boundaryMessage = await addMessage(
1152
+ source.id,
1153
+ "assistant",
1154
+ "first reply",
1155
+ { skipIndexing: true },
1156
+ );
1157
+ // Past the fork boundary — forces the truncated-seed path (a fork through
1158
+ // the final message takes the wholesale-copy path instead).
1159
+ await addMessage(source.id, "user", "second turn", { skipIndexing: true });
1160
+
1161
+ recordV3Injected(
1162
+ source.id,
1163
+ [
1164
+ { slug: "topics/page-a", bytes: 120 },
1165
+ { slug: "topics/page-b", bytes: 340 },
1166
+ ],
1167
+ 1_700_000_000_000,
1168
+ );
1169
+ markV3Pruned(source.id, ["topics/page-b"], 1_700_000_005_000);
1170
+
1171
+ const fork = forkConversation({
1172
+ conversationId: source.id,
1173
+ throughMessageId: boundaryMessage.id,
1174
+ });
1175
+
1176
+ expect(getV3Injected(fork.id)).toEqual(
1177
+ new Map([
1178
+ ["topics/page-a", { bytes: 0, prunedAt: null }],
1179
+ ["topics/page-b", { bytes: 0, prunedAt: 1_700_000_005_000 }],
1180
+ ]),
1181
+ );
1182
+ });
1183
+
1035
1184
  test("defaults conversationType to standard and inherits the parent's group", async () => {
1036
1185
  const source = createConversation("Default inheritance thread");
1037
1186
  await addMessage(source.id, "user", "first message", {
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  // Stub out heavy dependencies before importing Conversation
4
4
  mock.module("../util/logger.js", () => ({
@@ -110,8 +110,38 @@ mock.module("../memory/conversation-queries.js", () => ({
110
110
  listConversations: () => [],
111
111
  }));
112
112
 
113
+ // `loadFromDb` reads the conversation's pruned v3 card slugs (prune valve)
114
+ // when a row carries a `memoryV3InjectedBlock`. Stub the store read so these
115
+ // tests never touch a real DB; the stub DELEGATES to the real implementation
116
+ // unless this file is actively running (`mock.module` is process-global and
117
+ // would otherwise leak into sibling files that use the real store).
118
+ const realEverInjectedStore = {
119
+ ...(await import("../plugins/defaults/memory-v3-shadow/ever-injected-store.js")),
120
+ };
121
+ let lifecycleStoreMockActive = false;
122
+ let mockPrunedSlugs = new Set<string>();
123
+ mock.module(
124
+ "../plugins/defaults/memory-v3-shadow/ever-injected-store.js",
125
+ () => ({
126
+ ...realEverInjectedStore,
127
+ getPrunedSlugs: (conversationId: string) =>
128
+ lifecycleStoreMockActive
129
+ ? mockPrunedSlugs
130
+ : realEverInjectedStore.getPrunedSlugs(conversationId),
131
+ }),
132
+ );
133
+
113
134
  import { Conversation } from "../daemon/conversation.js";
114
135
 
136
+ beforeEach(() => {
137
+ lifecycleStoreMockActive = true;
138
+ mockPrunedSlugs = new Set();
139
+ });
140
+
141
+ afterAll(() => {
142
+ lifecycleStoreMockActive = false;
143
+ });
144
+
115
145
  function makeConversation(): Conversation {
116
146
  const provider = {
117
147
  name: "mock",
@@ -346,6 +376,156 @@ describe("loadFromDb metadata injection rehydration", () => {
346
376
  expect(firstBlock.text.match(/<memory>/g)?.length).toBe(1);
347
377
  });
348
378
 
379
+ test("memoryV3InjectedBlock rehydrates wrapped on ALL rows (tail included)", async () => {
380
+ // The memory-v3 frozen card block persists UNWRAPPED under its own key and
381
+ // must come back wrapped on every row — including the tail, since the next
382
+ // turn injects only NET-NEW cards (deduped via the v3 store) and never
383
+ // re-renders this row's cards.
384
+ mockConversation = defaultConv();
385
+ mockDbMessages = [
386
+ {
387
+ id: "m1",
388
+ role: "user",
389
+ content: JSON.stringify([{ type: "text", text: "First turn" }]),
390
+ metadata: JSON.stringify({
391
+ memoryV3InjectedBlock:
392
+ "header line\n\n# memory/concepts/page-a.md\nhead a",
393
+ }),
394
+ },
395
+ {
396
+ id: "m2",
397
+ role: "assistant",
398
+ content: JSON.stringify([{ type: "text", text: "Reply" }]),
399
+ },
400
+ {
401
+ id: "m3",
402
+ role: "user",
403
+ content: JSON.stringify([{ type: "text", text: "Tail turn" }]),
404
+ metadata: JSON.stringify({
405
+ memoryV3InjectedBlock: "# memory/concepts/page-b.md\nhead b",
406
+ }),
407
+ },
408
+ ];
409
+
410
+ const conversation = makeConversation();
411
+ await conversation.loadFromDb();
412
+ const messages = conversation.getMessages();
413
+
414
+ expect(messages).toHaveLength(3);
415
+ expect(messages[0].content).toEqual([
416
+ {
417
+ type: "text",
418
+ text: "<memory>\nheader line\n\n# memory/concepts/page-a.md\nhead a\n</memory>",
419
+ },
420
+ { type: "text", text: "First turn" },
421
+ ]);
422
+ // Tail row rehydrates too (unlike turn_context / system_reminder).
423
+ expect(messages[2].content).toEqual([
424
+ {
425
+ type: "text",
426
+ text: "<memory>\n# memory/concepts/page-b.md\nhead b\n</memory>",
427
+ },
428
+ { type: "text", text: "Tail turn" },
429
+ ]);
430
+ });
431
+
432
+ test("pruned slugs' card sections are skipped at v3 rehydration (prune valve persistence)", async () => {
433
+ // The prune valve marks cards pruned in the everInjected store instead of
434
+ // rewriting the persisted metadata; the rehydration splice re-filters on
435
+ // every load, which is what makes a prune survive restarts.
436
+ mockConversation = defaultConv();
437
+ mockPrunedSlugs = new Set(["page-a"]);
438
+ mockDbMessages = [
439
+ {
440
+ id: "m1",
441
+ role: "user",
442
+ content: JSON.stringify([{ type: "text", text: "First turn" }]),
443
+ metadata: JSON.stringify({
444
+ memoryV3InjectedBlock:
445
+ "header line\n\n# memory/concepts/page-a.md\nhead a\n\n# memory/concepts/page-b.md\nhead b",
446
+ }),
447
+ },
448
+ {
449
+ id: "m2",
450
+ role: "assistant",
451
+ content: JSON.stringify([{ type: "text", text: "Reply" }]),
452
+ },
453
+ ];
454
+
455
+ const conversation = makeConversation();
456
+ await conversation.loadFromDb();
457
+ const messages = conversation.getMessages();
458
+
459
+ expect(messages[0].content).toEqual([
460
+ {
461
+ type: "text",
462
+ text: "<memory>\nheader line\n\n# memory/concepts/page-b.md\nhead b\n</memory>",
463
+ },
464
+ { type: "text", text: "First turn" },
465
+ ]);
466
+ });
467
+
468
+ test("a fully-pruned memoryV3InjectedBlock is skipped entirely at rehydration", async () => {
469
+ mockConversation = defaultConv();
470
+ mockPrunedSlugs = new Set(["page-a"]);
471
+ mockDbMessages = [
472
+ {
473
+ id: "m1",
474
+ role: "user",
475
+ content: JSON.stringify([{ type: "text", text: "First turn" }]),
476
+ metadata: JSON.stringify({
477
+ memoryV3InjectedBlock:
478
+ "header line\n\n# memory/concepts/page-a.md\nhead a",
479
+ }),
480
+ },
481
+ {
482
+ id: "m2",
483
+ role: "assistant",
484
+ content: JSON.stringify([{ type: "text", text: "Reply" }]),
485
+ },
486
+ ];
487
+
488
+ const conversation = makeConversation();
489
+ await conversation.loadFromDb();
490
+ const messages = conversation.getMessages();
491
+
492
+ // No memory block at all — an instruction header with zero cards carries
493
+ // no content (matches the live strip, which removes the emptied block).
494
+ expect(messages[0].content).toEqual([{ type: "text", text: "First turn" }]);
495
+ });
496
+
497
+ test("defensively-wrapped memoryV3InjectedBlock rehydrates singly-wrapped", async () => {
498
+ mockConversation = defaultConv();
499
+ mockDbMessages = [
500
+ {
501
+ id: "m1",
502
+ role: "user",
503
+ content: JSON.stringify([{ type: "text", text: "Hi" }]),
504
+ metadata: JSON.stringify({
505
+ memoryV3InjectedBlock:
506
+ "<memory>\n# memory/concepts/page-a.md\nhead a\n</memory>",
507
+ }),
508
+ },
509
+ {
510
+ id: "m2",
511
+ role: "assistant",
512
+ content: JSON.stringify([{ type: "text", text: "Hello" }]),
513
+ },
514
+ ];
515
+
516
+ const conversation = makeConversation();
517
+ await conversation.loadFromDb();
518
+ const messages = conversation.getMessages();
519
+
520
+ const firstBlock = messages[0].content[0];
521
+ expect(firstBlock).toEqual({
522
+ type: "text",
523
+ text: "<memory>\n# memory/concepts/page-a.md\nhead a\n</memory>",
524
+ });
525
+ if (firstBlock.type !== "text") throw new Error("unexpected block type");
526
+ expect(firstBlock.text.match(/<memory>/g)?.length).toBe(1);
527
+ });
528
+
349
529
  test("malformed metadata is tolerated: load does not throw, content unchanged", async () => {
350
530
  mockConversation = defaultConv();
351
531
  mockDbMessages = [
@@ -606,6 +786,82 @@ describe("loadFromDb metadata injection rehydration", () => {
606
786
  ]);
607
787
  });
608
788
 
789
+ test("rehydration order matches live assembly when memoryV2StaticBlock and memoryV3InjectedBlock co-occur", async () => {
790
+ // First-turn and first-post-compaction rows of a memory-v3-live
791
+ // conversation persist BOTH `memoryV2StaticBlock` and the v3 card block.
792
+ // Live assembly applies after-memory-prefix splices in ascending
793
+ // injector order (pkb-context 30, pkb-reminder 35, memory-v2-static 38,
794
+ // now-md 40, memory-v3-shadow 1000); each splice lands at the
795
+ // memory-prefix boundary (`countMemoryPrefixBlocks` counts `<memory>`
796
+ // AND `<info>` blocks), so the v3 block — spliced last and itself
797
+ // `<memory>`-wrapped — lands AFTER the `<info>` static block but before
798
+ // now-md's earlier splice:
799
+ // [<workspace>, <turn_context>, <memory>dynamic</memory>,
800
+ // <info>v2static</info>, <memory>v3cards</memory>, <NOW.md>,
801
+ // <system_reminder>, <knowledge_base>, ...original]
802
+ // Rehydration must reproduce this byte-for-byte or every daemon restart
803
+ // busts the provider prefix cache for the whole conversation history.
804
+ // (`memoryInjectedBlock` and the v3 key are mutually exclusive on real
805
+ // rows; both are included here to pin the relative order of all splices.)
806
+ mockConversation = defaultConv();
807
+ mockDbMessages = [
808
+ {
809
+ id: "m1",
810
+ role: "user",
811
+ content: JSON.stringify([{ type: "text", text: "First turn" }]),
812
+ metadata: JSON.stringify({
813
+ workspaceBlock: "<workspace>\nworkspace body\n</workspace>",
814
+ turnContextBlock: "<turn_context>\nctx payload\n</turn_context>",
815
+ memoryInjectedBlock: "mem payload",
816
+ memoryV2StaticBlock:
817
+ "<info>\n## Essentials\n\nAlice prefers VS Code.\n</info>",
818
+ memoryV3InjectedBlock:
819
+ "header line\n\n# memory/concepts/page-a.md\nhead a",
820
+ nowScratchpadBlock: "<NOW.md>\nnow body\n</NOW.md>",
821
+ pkbSystemReminderBlock:
822
+ "<system_reminder>\npkb reminder body\n</system_reminder>",
823
+ pkbContextBlock: "<knowledge_base>\nkb body\n</knowledge_base>",
824
+ }),
825
+ },
826
+ {
827
+ id: "m2",
828
+ role: "assistant",
829
+ content: JSON.stringify([{ type: "text", text: "Reply" }]),
830
+ },
831
+ {
832
+ id: "m3",
833
+ role: "user",
834
+ content: JSON.stringify([{ type: "text", text: "Tail" }]),
835
+ },
836
+ ];
837
+
838
+ const conversation = makeConversation();
839
+ await conversation.loadFromDb();
840
+ const messages = conversation.getMessages();
841
+
842
+ expect(messages).toHaveLength(3);
843
+ expect(messages[0].content).toEqual([
844
+ { type: "text", text: "<workspace>\nworkspace body\n</workspace>" },
845
+ { type: "text", text: "<turn_context>\nctx payload\n</turn_context>" },
846
+ { type: "text", text: "<memory>\nmem payload\n</memory>" },
847
+ {
848
+ type: "text",
849
+ text: "<info>\n## Essentials\n\nAlice prefers VS Code.\n</info>",
850
+ },
851
+ {
852
+ type: "text",
853
+ text: "<memory>\nheader line\n\n# memory/concepts/page-a.md\nhead a\n</memory>",
854
+ },
855
+ { type: "text", text: "<NOW.md>\nnow body\n</NOW.md>" },
856
+ {
857
+ type: "text",
858
+ text: "<system_reminder>\npkb reminder body\n</system_reminder>",
859
+ },
860
+ { type: "text", text: "<knowledge_base>\nkb body\n</knowledge_base>" },
861
+ { type: "text", text: "First turn" },
862
+ ]);
863
+ });
864
+
609
865
  test("untrusted-actor view does not rehydrate memoryV2StaticBlock", async () => {
610
866
  mockConversation = defaultConv();
611
867
  // Rows with `trusted_contact` / `unknown` provenance survive the
@@ -342,16 +342,17 @@ describe("injector chain", () => {
342
342
  expect(byName.get("thread-focus")).toBe(DEFAULT_INJECTOR_ORDER.threadFocus);
343
343
  });
344
344
 
345
- test("the injector chain sorts the defaults plus memory-v3 by ascending order", () => {
346
- // The assembled chain merges the defaults with the memory-v3 injector and
347
- // sorts by `order`, so memory-v3 (order 1000) sits last.
345
+ test("the injector chain sorts the defaults plus the memory-v3 injectors by ascending order", () => {
346
+ // The assembled chain merges the defaults with the two memory-v3
347
+ // injectors and sorts by `order`, so the cards injector (order 1000) and
348
+ // the spotlight injector (order 1001) sit last, in that order.
348
349
  const chain = getInjectorChain();
349
350
  const orders = chain.map((i) => i.order);
350
351
  expect(orders).toEqual([...orders].sort((a, b) => a - b));
351
- expect(chain[chain.length - 1]?.name).toBe("memory-v3-shadow");
352
352
  expect(chain.map((i) => i.name)).toEqual([
353
353
  ...defaultInjectors.map((i) => i.name),
354
354
  "memory-v3-shadow",
355
+ "memory-v3-spotlight",
355
356
  ]);
356
357
  });
357
358