@vellumai/assistant 0.5.3 → 0.5.4
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/docs/architecture/memory.md +105 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-types.test.ts +12 -4
- package/src/__tests__/memory-reducer.test.ts +7 -1
- package/src/__tests__/memory-regressions.test.ts +24 -4
- package/src/__tests__/memory-simplified-config.test.ts +4 -4
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/loader.ts +0 -1
- package/src/config/schemas/memory-simplified.ts +1 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -0
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/lifecycle.ts +44 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/brief-time.ts +5 -4
- package/src/memory/conversation-crud.ts +210 -0
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +4 -0
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +9 -3
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/jobs-store.ts +2 -0
- package/src/memory/jobs-worker.ts +8 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-types.ts +9 -2
- package/src/memory/reducer.ts +25 -11
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/runtime/auth/route-policy.ts +10 -1
- package/src/runtime/routes/conversation-management-routes.ts +88 -2
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- package/src/runtime/routes/secret-routes.ts +1 -0
- package/src/schedule/schedule-store.ts +7 -0
- package/src/schedule/scheduler.ts +6 -2
- package/src/telemetry/usage-telemetry-reporter.ts +1 -1
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
|
@@ -2,6 +2,111 @@
|
|
|
2
2
|
|
|
3
3
|
Assistant memory and context-injection architecture details.
|
|
4
4
|
|
|
5
|
+
## Simplified Memory System (Default)
|
|
6
|
+
|
|
7
|
+
The simplified memory system replaces the legacy item/tier/staleness model with a two-layer architecture: a **brief** (time-relevant context + open loops) plus **archive recall** (observations, chunks, episodes). It is enabled by default via `memory.simplified.enabled: true`.
|
|
8
|
+
|
|
9
|
+
### Architecture Overview
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
graph TB
|
|
13
|
+
subgraph "Write Path (Simplified)"
|
|
14
|
+
MSG["Incoming Message"] --> REDUCER["Memory Reducer<br/>(LLM-backed, delayed)"]
|
|
15
|
+
REDUCER --> TC["time_contexts<br/>(brief state)"]
|
|
16
|
+
REDUCER --> OL["open_loops<br/>(brief state)"]
|
|
17
|
+
REDUCER --> OBS_R["Archive Observations<br/>(reducer output)"]
|
|
18
|
+
REDUCER --> EP_R["Archive Episodes<br/>(reducer output)"]
|
|
19
|
+
|
|
20
|
+
MSG --> INDEXER["Dual-Write Indexer"]
|
|
21
|
+
INDEXER --> OBS["memory_observations"]
|
|
22
|
+
INDEXER --> CHK["memory_chunks<br/>(content-hash deduped)"]
|
|
23
|
+
|
|
24
|
+
COMPACT["Context Compaction"] --> EP["memory_episodes"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
subgraph "Read Path (Simplified)"
|
|
28
|
+
TURN["User Turn"] --> BRIEF["Memory Brief Compiler"]
|
|
29
|
+
BRIEF --> TC
|
|
30
|
+
BRIEF --> OL
|
|
31
|
+
BRIEF --> BRIEF_OUT["<memory_brief><br/>Time contexts + Open loops"]
|
|
32
|
+
|
|
33
|
+
TURN --> RECALL_GATE["Archive Recall Gate<br/>(keyword + pattern match)"]
|
|
34
|
+
RECALL_GATE --> PREFETCH["Prefetch<br/>(episodes + observations)"]
|
|
35
|
+
RECALL_GATE --> DEEP["Deeper Recall<br/>(episodes + observations + chunks)"]
|
|
36
|
+
DEEP --> RECALL_OUT["<supporting_recall><br/>Source-linked bullets"]
|
|
37
|
+
|
|
38
|
+
BRIEF_OUT --> INJECT["Runtime Injection<br/>(prepend to user message)"]
|
|
39
|
+
RECALL_OUT --> INJECT
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
subgraph "Memory Tools (Simplified)"
|
|
43
|
+
SAVE["memory_save"] --> OBS
|
|
44
|
+
RECALL_TOOL["memory_recall"] --> RECALL_GATE
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Tables
|
|
49
|
+
|
|
50
|
+
| Table | Purpose | Write source |
|
|
51
|
+
| --------------------- | ----------------------------------------------- | ------------------------------------------------------- |
|
|
52
|
+
| `time_contexts` | Bounded temporal windows for the brief | Reducer |
|
|
53
|
+
| `open_loops` | Unresolved follow-up items for the brief | Reducer |
|
|
54
|
+
| `memory_observations` | Raw factual statements from conversation turns | Indexer dual-write, reducer, memory_save tool, backfill |
|
|
55
|
+
| `memory_chunks` | Deduplicated content units for embedding/recall | Derived from observations, content-hash deduped |
|
|
56
|
+
| `memory_episodes` | Narrative summaries of interaction spans | Compaction, reducer, backfill |
|
|
57
|
+
|
|
58
|
+
### Reducer
|
|
59
|
+
|
|
60
|
+
The memory reducer is a provider-backed (LLM) background process that analyzes unreduced conversation turns and produces structured CRUD operations for brief-state tables and archive candidates. It runs on a delay after conversation idle or switch, scheduled via the `reduce_conversation_memory` job. The reducer is side-effect-free; results are applied transactionally via `applyReducerResult`.
|
|
61
|
+
|
|
62
|
+
### Brief
|
|
63
|
+
|
|
64
|
+
The memory brief is compiled fresh on every turn from active `time_contexts` and `open_loops`. It is rendered as `<memory_brief>` XML and injected as a text block prepended to the user message. Empty sections are omitted.
|
|
65
|
+
|
|
66
|
+
### Archive Recall
|
|
67
|
+
|
|
68
|
+
Archive recall runs when the user's turn triggers a recall gate (past-reference language, analogy/debugging patterns, or strong prefetch hits). It queries episodes, observations, and chunks via keyword matching and returns up to 3 source-linked bullets in `<supporting_recall>`. No recall tag is emitted when results are empty.
|
|
69
|
+
|
|
70
|
+
### Backfill
|
|
71
|
+
|
|
72
|
+
Existing users have legacy data in `memory_segments`, `memory_summaries`, and `memory_items`. The `backfill_simplified_memory` job migrates this data into the simplified tables:
|
|
73
|
+
|
|
74
|
+
- `memory_segments` -> `memory_observations` + `memory_chunks`
|
|
75
|
+
- `memory_summaries` -> `memory_episodes`
|
|
76
|
+
- Active, high-confidence `memory_items` -> `memory_observations` + `memory_chunks`, with unambiguous items also mapped to `time_contexts` or `open_loops`
|
|
77
|
+
|
|
78
|
+
The backfill is idempotent (content-hash dedup + checkpoint tracking), processes in batches of 200, and self-enqueues continuation jobs for large datasets.
|
|
79
|
+
|
|
80
|
+
### Rollback Posture
|
|
81
|
+
|
|
82
|
+
The legacy memory system remains fully available as a short-lived rollback path:
|
|
83
|
+
|
|
84
|
+
- **Legacy tables are preserved**: `memory_segments`, `memory_items`, `memory_summaries`, and `memory_item_sources` remain in the schema and continue to receive writes from the legacy indexer/extraction pipeline.
|
|
85
|
+
- **Flag-gated**: Setting `memory.simplified.enabled: false` reverts to the legacy item/tier/staleness model for both read and write paths.
|
|
86
|
+
- **Memory tools**: `memory_save` and `memory_recall` check the flag at call time and route to the appropriate path (simplified observations or legacy items).
|
|
87
|
+
- **No data loss**: The backfill copies data without deleting legacy rows. Both systems can coexist.
|
|
88
|
+
|
|
89
|
+
### Key Files
|
|
90
|
+
|
|
91
|
+
| File | Role |
|
|
92
|
+
| ----------------------------------------------------------------- | ------------------------------------------------ |
|
|
93
|
+
| `assistant/src/config/schemas/memory-simplified.ts` | Config schema with `enabled: true` default |
|
|
94
|
+
| `assistant/src/memory/reducer.ts` | Provider-backed reducer (LLM call + parse) |
|
|
95
|
+
| `assistant/src/memory/reducer-store.ts` | Transactional result application |
|
|
96
|
+
| `assistant/src/memory/reducer-scheduler.ts` | Idle-delay and conversation-switch scheduling |
|
|
97
|
+
| `assistant/src/memory/archive-store.ts` | Observation/chunk/episode write helpers |
|
|
98
|
+
| `assistant/src/memory/archive-recall.ts` | Prefetch + deeper recall over archive tables |
|
|
99
|
+
| `assistant/src/memory/brief.ts` | Brief composer (time contexts + open loops) |
|
|
100
|
+
| `assistant/src/memory/job-handlers/backfill-simplified-memory.ts` | Legacy data migration handler |
|
|
101
|
+
| `assistant/src/tools/memory/handlers.ts` | Memory tool handlers (simplified/legacy routing) |
|
|
102
|
+
| `assistant/src/__tests__/simplified-memory-e2e.test.ts` | End-to-end test suite |
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Legacy Memory System — Daemon Data Flow
|
|
107
|
+
|
|
108
|
+
> **Note**: The legacy system below is retained as rollback support. New installations use the simplified system by default.
|
|
109
|
+
|
|
5
110
|
## Memory System — Daemon Data Flow
|
|
6
111
|
|
|
7
112
|
```mermaid
|
package/package.json
CHANGED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the archive recall module.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Explicit artifact recall (past-reference triggers)
|
|
6
|
+
* - Analogy/debugging-shaped recall
|
|
7
|
+
* - Strong prefetch triggers
|
|
8
|
+
* - Empty result omission (no `<supporting_recall>` when nothing found)
|
|
9
|
+
* - Keyword extraction
|
|
10
|
+
* - Rendering format
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
afterAll,
|
|
17
|
+
beforeAll,
|
|
18
|
+
beforeEach,
|
|
19
|
+
describe,
|
|
20
|
+
expect,
|
|
21
|
+
mock,
|
|
22
|
+
test,
|
|
23
|
+
} from "bun:test";
|
|
24
|
+
|
|
25
|
+
const testDir = mkdtempSync(join(tmpdir(), "archive-recall-test-"));
|
|
26
|
+
const dbPath = join(testDir, "test.db");
|
|
27
|
+
|
|
28
|
+
mock.module("../util/platform.js", () => ({
|
|
29
|
+
getDataDir: () => testDir,
|
|
30
|
+
isMacOS: () => process.platform === "darwin",
|
|
31
|
+
isLinux: () => process.platform === "linux",
|
|
32
|
+
isWindows: () => process.platform === "win32",
|
|
33
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
34
|
+
getDbPath: () => dbPath,
|
|
35
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
36
|
+
ensureDataDir: () => {},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
mock.module("../util/logger.js", () => ({
|
|
40
|
+
getLogger: () =>
|
|
41
|
+
new Proxy({} as Record<string, unknown>, {
|
|
42
|
+
get: () => () => {},
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import { v4 as uuid } from "uuid";
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
buildArchiveRecall,
|
|
50
|
+
classifyRecallTrigger,
|
|
51
|
+
extractKeywords,
|
|
52
|
+
prefetchArchive,
|
|
53
|
+
type RecallBullet,
|
|
54
|
+
renderSupportingRecall,
|
|
55
|
+
} from "../memory/archive-recall.js";
|
|
56
|
+
import {
|
|
57
|
+
insertCompactionEpisode,
|
|
58
|
+
insertObservation,
|
|
59
|
+
} from "../memory/archive-store.js";
|
|
60
|
+
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
61
|
+
import { conversations, messages } from "../memory/schema.js";
|
|
62
|
+
|
|
63
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function removeTestDbFiles(): void {
|
|
66
|
+
rmSync(dbPath, { force: true });
|
|
67
|
+
rmSync(`${dbPath}-shm`, { force: true });
|
|
68
|
+
rmSync(`${dbPath}-wal`, { force: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createConversation(id: string, title: string | null = null): void {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
db.insert(conversations)
|
|
75
|
+
.values({
|
|
76
|
+
id,
|
|
77
|
+
title,
|
|
78
|
+
createdAt: now,
|
|
79
|
+
updatedAt: now,
|
|
80
|
+
})
|
|
81
|
+
.run();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createMessage(
|
|
85
|
+
id: string,
|
|
86
|
+
conversationId: string,
|
|
87
|
+
role: string = "user",
|
|
88
|
+
content: string = "test message",
|
|
89
|
+
): void {
|
|
90
|
+
const db = getDb();
|
|
91
|
+
db.insert(messages)
|
|
92
|
+
.values({
|
|
93
|
+
id,
|
|
94
|
+
conversationId,
|
|
95
|
+
role,
|
|
96
|
+
content,
|
|
97
|
+
createdAt: Date.now(),
|
|
98
|
+
})
|
|
99
|
+
.run();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Test suite ──────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe("Archive Recall", () => {
|
|
105
|
+
beforeAll(() => {
|
|
106
|
+
initializeDb();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
resetDb();
|
|
111
|
+
removeTestDbFiles();
|
|
112
|
+
initializeDb();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterAll(() => {
|
|
116
|
+
resetDb();
|
|
117
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────
|
|
121
|
+
// classifyRecallTrigger
|
|
122
|
+
// ─────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe("classifyRecallTrigger", () => {
|
|
125
|
+
test("detects explicit past-reference phrases", () => {
|
|
126
|
+
expect(
|
|
127
|
+
classifyRecallTrigger("Do you remember the API we discussed?", 0),
|
|
128
|
+
).toBe("explicit_past_reference");
|
|
129
|
+
expect(classifyRecallTrigger("We talked about this last time", 0)).toBe(
|
|
130
|
+
"explicit_past_reference",
|
|
131
|
+
);
|
|
132
|
+
expect(
|
|
133
|
+
classifyRecallTrigger("As I mentioned earlier, the config is wrong", 0),
|
|
134
|
+
).toBe("explicit_past_reference");
|
|
135
|
+
expect(
|
|
136
|
+
classifyRecallTrigger("I previously told you about the bug", 0),
|
|
137
|
+
).toBe("explicit_past_reference");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("detects analogy/debugging-shaped phrases", () => {
|
|
141
|
+
expect(
|
|
142
|
+
classifyRecallTrigger("This is similar to the issue we had", 0),
|
|
143
|
+
).toBe("analogy_debug");
|
|
144
|
+
expect(classifyRecallTrigger("I keep getting this error", 0)).toBe(
|
|
145
|
+
"analogy_debug",
|
|
146
|
+
);
|
|
147
|
+
expect(classifyRecallTrigger("Same problem as yesterday", 0)).toBe(
|
|
148
|
+
"analogy_debug",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("detects strong prefetch hits", () => {
|
|
153
|
+
expect(
|
|
154
|
+
classifyRecallTrigger("How should I configure the database?", 2),
|
|
155
|
+
).toBe("strong_prefetch");
|
|
156
|
+
expect(
|
|
157
|
+
classifyRecallTrigger("How should I configure the database?", 5),
|
|
158
|
+
).toBe("strong_prefetch");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("returns none for ordinary turns", () => {
|
|
162
|
+
expect(classifyRecallTrigger("What is the capital of France?", 0)).toBe(
|
|
163
|
+
"none",
|
|
164
|
+
);
|
|
165
|
+
expect(
|
|
166
|
+
classifyRecallTrigger("Write a function to sort an array", 1),
|
|
167
|
+
).toBe("none");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("explicit past-reference takes priority over analogy", () => {
|
|
171
|
+
// "remember" matches past-reference, "same issue" matches analogy
|
|
172
|
+
expect(
|
|
173
|
+
classifyRecallTrigger("Do you remember the same issue we had?", 0),
|
|
174
|
+
).toBe("explicit_past_reference");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────
|
|
179
|
+
// extractKeywords
|
|
180
|
+
// ─────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
describe("extractKeywords", () => {
|
|
183
|
+
test("extracts meaningful words >= 4 chars", () => {
|
|
184
|
+
const kw = extractKeywords("How do I fix the authentication error?");
|
|
185
|
+
expect(kw).toContain("authentication");
|
|
186
|
+
expect(kw).toContain("error");
|
|
187
|
+
// "how", "do", "I", "fix", "the" are too short or stop words
|
|
188
|
+
expect(kw).not.toContain("how");
|
|
189
|
+
expect(kw).not.toContain("the");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("removes stop words", () => {
|
|
193
|
+
const kw = extractKeywords("I want to make this very much better");
|
|
194
|
+
expect(kw).not.toContain("want");
|
|
195
|
+
expect(kw).not.toContain("very");
|
|
196
|
+
expect(kw).not.toContain("much");
|
|
197
|
+
expect(kw).toContain("better");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("deduplicates keywords", () => {
|
|
201
|
+
const kw = extractKeywords("error error error authentication");
|
|
202
|
+
expect(kw.filter((w) => w === "error")).toHaveLength(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("returns empty for short/stop-word-only input", () => {
|
|
206
|
+
expect(extractKeywords("hi")).toEqual([]);
|
|
207
|
+
expect(extractKeywords("the a an")).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ─────────────────────────────────────────────────────────────────
|
|
212
|
+
// renderSupportingRecall
|
|
213
|
+
// ─────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
describe("renderSupportingRecall", () => {
|
|
216
|
+
test("renders bullets in <supporting_recall> tag", () => {
|
|
217
|
+
const bullets: RecallBullet[] = [
|
|
218
|
+
{
|
|
219
|
+
text: "User prefers REST APIs",
|
|
220
|
+
source: "observation",
|
|
221
|
+
sourceId: "obs-1",
|
|
222
|
+
conversationTitle: "API Discussion",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
text: "Deployed to production last week",
|
|
226
|
+
source: "episode",
|
|
227
|
+
sourceId: "ep-1",
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
const result = renderSupportingRecall(bullets);
|
|
232
|
+
expect(result).toContain("<supporting_recall>");
|
|
233
|
+
expect(result).toContain("</supporting_recall>");
|
|
234
|
+
expect(result).toContain(
|
|
235
|
+
"- User prefers REST APIs (from: API Discussion)",
|
|
236
|
+
);
|
|
237
|
+
expect(result).toContain("- Deployed to production last week");
|
|
238
|
+
// No provenance for second bullet (no conversationTitle)
|
|
239
|
+
expect(result).not.toContain("(from: undefined)");
|
|
240
|
+
expect(result).not.toContain("(from: null)");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("returns empty string for empty bullets", () => {
|
|
244
|
+
expect(renderSupportingRecall([])).toBe("");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ─────────────────────────────────────────────────────────────────
|
|
249
|
+
// Explicit artifact recall
|
|
250
|
+
// ─────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
describe("explicit artifact recall", () => {
|
|
253
|
+
test("recalls observations when user references past discussion", () => {
|
|
254
|
+
const convId = uuid();
|
|
255
|
+
const msgId = uuid();
|
|
256
|
+
createConversation(convId, "Authentication Redesign");
|
|
257
|
+
createMessage(msgId, convId);
|
|
258
|
+
|
|
259
|
+
insertObservation({
|
|
260
|
+
conversationId: convId,
|
|
261
|
+
messageId: msgId,
|
|
262
|
+
role: "user",
|
|
263
|
+
content:
|
|
264
|
+
"User wants to migrate authentication from JWT to session tokens",
|
|
265
|
+
scopeId: "default",
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const result = buildArchiveRecall(
|
|
269
|
+
"default",
|
|
270
|
+
"Do you remember what we discussed about authentication?",
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(result.trigger).toBe("explicit_past_reference");
|
|
274
|
+
expect(result.bullets.length).toBeGreaterThan(0);
|
|
275
|
+
expect(result.text).toContain("<supporting_recall>");
|
|
276
|
+
expect(result.text).toContain("authentication");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("recalls episodes when user references past work", () => {
|
|
280
|
+
const convId = uuid();
|
|
281
|
+
createConversation(convId, "Database Migration Sprint");
|
|
282
|
+
|
|
283
|
+
insertCompactionEpisode({
|
|
284
|
+
scopeId: "default",
|
|
285
|
+
conversationId: convId,
|
|
286
|
+
title: "PostgreSQL Migration Planning",
|
|
287
|
+
summary:
|
|
288
|
+
"Discussed migrating from MySQL to PostgreSQL, decided on a phased approach starting with read replicas",
|
|
289
|
+
tokenEstimate: 25,
|
|
290
|
+
startAt: Date.now() - 86_400_000,
|
|
291
|
+
endAt: Date.now() - 43_200_000,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const result = buildArchiveRecall(
|
|
295
|
+
"default",
|
|
296
|
+
"What did we talk about regarding the PostgreSQL migration?",
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
expect(result.trigger).toBe("explicit_past_reference");
|
|
300
|
+
expect(result.bullets.length).toBeGreaterThan(0);
|
|
301
|
+
expect(result.text).toContain("<supporting_recall>");
|
|
302
|
+
expect(result.text).toContain("PostgreSQL");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ─────────────────────────────────────────────────────────────────
|
|
307
|
+
// Analogy/debugging-shaped recall
|
|
308
|
+
// ─────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
describe("analogy-shaped recall", () => {
|
|
311
|
+
test("recalls when user reports a recurring issue", () => {
|
|
312
|
+
const convId = uuid();
|
|
313
|
+
const msgId = uuid();
|
|
314
|
+
createConversation(convId, "Debugging Session");
|
|
315
|
+
createMessage(msgId, convId);
|
|
316
|
+
|
|
317
|
+
insertObservation({
|
|
318
|
+
conversationId: convId,
|
|
319
|
+
messageId: msgId,
|
|
320
|
+
role: "user",
|
|
321
|
+
content:
|
|
322
|
+
"Connection timeout error when calling the payment gateway service",
|
|
323
|
+
scopeId: "default",
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const result = buildArchiveRecall(
|
|
327
|
+
"default",
|
|
328
|
+
"I keep getting a timeout error with the payment service",
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(result.trigger).toBe("analogy_debug");
|
|
332
|
+
expect(result.bullets.length).toBeGreaterThan(0);
|
|
333
|
+
expect(result.text).toContain("<supporting_recall>");
|
|
334
|
+
expect(result.text).toContain("timeout");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("recalls similar past episodes for analogy queries", () => {
|
|
338
|
+
const convId = uuid();
|
|
339
|
+
createConversation(convId, "Infrastructure Issues");
|
|
340
|
+
|
|
341
|
+
insertCompactionEpisode({
|
|
342
|
+
scopeId: "default",
|
|
343
|
+
conversationId: convId,
|
|
344
|
+
title: "Redis Connection Pool Exhaustion",
|
|
345
|
+
summary:
|
|
346
|
+
"Debugged Redis connection pool exhaustion caused by missing connection.release() calls in the retry handler",
|
|
347
|
+
tokenEstimate: 30,
|
|
348
|
+
startAt: Date.now() - 172_800_000,
|
|
349
|
+
endAt: Date.now() - 86_400_000,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const result = buildArchiveRecall(
|
|
353
|
+
"default",
|
|
354
|
+
"This is similar to the Redis connection issue we had",
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
expect(result.trigger).toBe("analogy_debug");
|
|
358
|
+
expect(result.bullets.length).toBeGreaterThan(0);
|
|
359
|
+
expect(result.text).toContain("Redis");
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ─────────────────────────────────────────────────────────────────
|
|
364
|
+
// Empty result omission
|
|
365
|
+
// ─────────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
describe("empty result omission", () => {
|
|
368
|
+
test("returns empty text when no archive content exists", () => {
|
|
369
|
+
const result = buildArchiveRecall(
|
|
370
|
+
"default",
|
|
371
|
+
"Do you remember what we discussed about quantum computing?",
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(result.trigger).toBe("explicit_past_reference");
|
|
375
|
+
expect(result.bullets).toHaveLength(0);
|
|
376
|
+
expect(result.text).toBe("");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("returns empty text for ordinary turns with no matches", () => {
|
|
380
|
+
const result = buildArchiveRecall(
|
|
381
|
+
"default",
|
|
382
|
+
"Write a hello world program in Python",
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
expect(result.trigger).toBe("none");
|
|
386
|
+
expect(result.bullets).toHaveLength(0);
|
|
387
|
+
expect(result.text).toBe("");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("does not emit <supporting_recall> when trigger fires but no data matches", () => {
|
|
391
|
+
// Seed with unrelated data
|
|
392
|
+
const convId = uuid();
|
|
393
|
+
const msgId = uuid();
|
|
394
|
+
createConversation(convId, "Cooking Tips");
|
|
395
|
+
createMessage(msgId, convId);
|
|
396
|
+
|
|
397
|
+
insertObservation({
|
|
398
|
+
conversationId: convId,
|
|
399
|
+
messageId: msgId,
|
|
400
|
+
role: "user",
|
|
401
|
+
content: "User enjoys Italian cooking with fresh basil",
|
|
402
|
+
scopeId: "default",
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Ask about something completely unrelated
|
|
406
|
+
const result = buildArchiveRecall(
|
|
407
|
+
"default",
|
|
408
|
+
"Do you remember what we discussed about Kubernetes deployments?",
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
expect(result.trigger).toBe("explicit_past_reference");
|
|
412
|
+
expect(result.bullets).toHaveLength(0);
|
|
413
|
+
expect(result.text).toBe("");
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ─────────────────────────────────────────────────────────────────
|
|
418
|
+
// Prefetch behavior
|
|
419
|
+
// ─────────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
describe("prefetch", () => {
|
|
422
|
+
test("returns hits from episodes and observations", () => {
|
|
423
|
+
const convId = uuid();
|
|
424
|
+
const msgId = uuid();
|
|
425
|
+
createConversation(convId);
|
|
426
|
+
createMessage(msgId, convId);
|
|
427
|
+
|
|
428
|
+
insertObservation({
|
|
429
|
+
conversationId: convId,
|
|
430
|
+
messageId: msgId,
|
|
431
|
+
role: "user",
|
|
432
|
+
content: "User prefers TypeScript over JavaScript",
|
|
433
|
+
scopeId: "default",
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
insertCompactionEpisode({
|
|
437
|
+
scopeId: "default",
|
|
438
|
+
conversationId: convId,
|
|
439
|
+
title: "TypeScript Configuration",
|
|
440
|
+
summary: "Set up strict TypeScript config with path aliases",
|
|
441
|
+
tokenEstimate: 15,
|
|
442
|
+
startAt: Date.now() - 3600_000,
|
|
443
|
+
endAt: Date.now() - 1800_000,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const hits = prefetchArchive("default", "TypeScript configuration setup");
|
|
447
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
448
|
+
expect(hits.some((h) => h.source === "episode")).toBe(true);
|
|
449
|
+
expect(hits.some((h) => h.source === "observation")).toBe(true);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("returns empty for no matches", () => {
|
|
453
|
+
const hits = prefetchArchive("default", "xyzzy nonexistent topic");
|
|
454
|
+
expect(hits).toHaveLength(0);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// ─────────────────────────────────────────────────────────────────
|
|
459
|
+
// Bullet cap and deduplication
|
|
460
|
+
// ─────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
describe("bullet cap and dedup", () => {
|
|
463
|
+
test("returns at most 3 bullets", () => {
|
|
464
|
+
const convId = uuid();
|
|
465
|
+
createConversation(convId);
|
|
466
|
+
|
|
467
|
+
// Insert 5 distinct observations
|
|
468
|
+
for (let i = 0; i < 5; i++) {
|
|
469
|
+
const msgId = uuid();
|
|
470
|
+
createMessage(msgId, convId);
|
|
471
|
+
insertObservation({
|
|
472
|
+
conversationId: convId,
|
|
473
|
+
messageId: msgId,
|
|
474
|
+
role: "user",
|
|
475
|
+
content: `Authentication fact number ${i}: uses OAuth2 flow variant ${i}`,
|
|
476
|
+
scopeId: "default",
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const result = buildArchiveRecall(
|
|
481
|
+
"default",
|
|
482
|
+
"Do you remember what authentication method we use?",
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(result.trigger).toBe("explicit_past_reference");
|
|
486
|
+
expect(result.bullets.length).toBeLessThanOrEqual(3);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("deduplicates identical content from different sources", () => {
|
|
490
|
+
const convId = uuid();
|
|
491
|
+
const msgId = uuid();
|
|
492
|
+
createConversation(convId);
|
|
493
|
+
createMessage(msgId, convId);
|
|
494
|
+
|
|
495
|
+
// Insert the same content as both an observation and in an episode
|
|
496
|
+
const content = "User prefers dark mode for all development tools";
|
|
497
|
+
insertObservation({
|
|
498
|
+
conversationId: convId,
|
|
499
|
+
messageId: msgId,
|
|
500
|
+
role: "user",
|
|
501
|
+
content,
|
|
502
|
+
scopeId: "default",
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
insertCompactionEpisode({
|
|
506
|
+
scopeId: "default",
|
|
507
|
+
conversationId: convId,
|
|
508
|
+
title: "Development Preferences",
|
|
509
|
+
summary: content,
|
|
510
|
+
tokenEstimate: 10,
|
|
511
|
+
startAt: Date.now() - 3600_000,
|
|
512
|
+
endAt: Date.now() - 1800_000,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const result = buildArchiveRecall(
|
|
516
|
+
"default",
|
|
517
|
+
"Do you recall my preference for dark mode development tools?",
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
// Should have bullets but content should not be duplicated
|
|
521
|
+
if (result.bullets.length > 1) {
|
|
522
|
+
const texts = result.bullets.map((b) => b.text.toLowerCase());
|
|
523
|
+
// Each bullet text should be distinct
|
|
524
|
+
const uniqueTexts = new Set(texts);
|
|
525
|
+
expect(uniqueTexts.size).toBe(texts.length);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ─────────────────────────────────────────────────────────────────
|
|
531
|
+
// Scope isolation
|
|
532
|
+
// ─────────────────────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
describe("scope isolation", () => {
|
|
535
|
+
test("only returns results from the requested scope", () => {
|
|
536
|
+
const convId = uuid();
|
|
537
|
+
const msgId = uuid();
|
|
538
|
+
createConversation(convId);
|
|
539
|
+
createMessage(msgId, convId);
|
|
540
|
+
|
|
541
|
+
insertObservation({
|
|
542
|
+
conversationId: convId,
|
|
543
|
+
messageId: msgId,
|
|
544
|
+
role: "user",
|
|
545
|
+
content: "Deployment uses Kubernetes with Helm charts",
|
|
546
|
+
scopeId: "other-scope",
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const result = buildArchiveRecall(
|
|
550
|
+
"default",
|
|
551
|
+
"Do you remember our Kubernetes deployment setup?",
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
// Should trigger but find no results in "default" scope
|
|
555
|
+
expect(result.trigger).toBe("explicit_past_reference");
|
|
556
|
+
expect(result.bullets).toHaveLength(0);
|
|
557
|
+
expect(result.text).toBe("");
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
});
|