Repository navigation
Expand file tree
/
Copy pathindex.ts
More file actions
166 lines (146 loc) · 4.54 KB
/
Copy pathindex.ts
File metadata and controls
166 lines (146 loc) · 4.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
import { EMBEDDING_PATH, VECTOR_INDEX } from "./config.js";
import { extractFacts } from "./extract.js";
import { isReady, memories, messages } from "./store.js";
import type { MemoryScope, MessageDoc, RecalledMemory, Role } from "./types.js";
export { attach, detach, isReady } from "./store.js";
export type { MemoryScope, RecalledMemory } from "./types.js";
/**
* Write a message down, and keep any durable facts it contains.
*
* Returns the facts that were stored so the caller can show them. Seeing the
* agent say "I will remember that you are vegetarian" is much more convincing
* than trusting that something happened in the background.
*/
export async function remember(
scope: MemoryScope,
role: Role,
content: string,
options: { extract?: boolean } = {}
): Promise<string[]> {
if (!isReady()) return [];
const now = new Date();
await messages().insertOne({
threadId: scope.threadId,
userId: scope.userId,
role,
content,
createdAt: now,
});
// Only user messages are worth mining. The assistant repeating your own
// preferences back at you is not new information.
const shouldExtract = options.extract ?? role === "user";
if (!shouldExtract) return [];
const facts = await extractFacts(content);
if (facts.length === 0) return [];
const stored: string[] = [];
for (const { subject, fact } of facts) {
// No embedding call here. We write the fact as plain text and Atlas
// generates the vector for it, because the index declares this field
// with the "autoEmbed" type.
//
// Upsert on subject. If the person told us last week that they love spicy
// food and today that spicy food destroys them, we keep today's version
// instead of storing both and confusing the agent.
await memories().updateOne(
{ threadId: scope.threadId, subject },
{
$set: {
userId: scope.userId,
fact,
sourceText: content,
updatedAt: now,
},
$setOnInsert: { threadId: scope.threadId, subject, createdAt: now },
},
{ upsert: true }
);
stored.push(fact);
}
return stored;
}
/**
* Find the memories that matter for what the person just said.
*
* The filter is the important line. It keeps this search inside one thread.
* Delete it and the agent will happily answer one person using another
* person's memories.
*/
export async function recall(
scope: MemoryScope,
query: string,
limit = 5
): Promise<RecalledMemory[]> {
if (!isReady()) return [];
return memories()
.aggregate<RecalledMemory>([
{
$vectorSearch: {
index: VECTOR_INDEX,
path: EMBEDDING_PATH,
// Plain text, not a vector. Atlas embeds this with the same model the
// index declares. The shape is { text }, not a bare string.
query: { text: query },
numCandidates: limit * 15,
limit,
filter: {
threadId: { $eq: scope.threadId },
userId: { $eq: scope.userId },
},
},
},
{
$project: {
_id: 0,
subject: 1,
fact: 1,
updatedAt: 1,
score: { $meta: "vectorSearchScore" },
},
},
])
.toArray();
}
/** The last few lines of the transcript, oldest first. No vectors involved. */
export async function history(
scope: MemoryScope,
limit = 10
): Promise<Array<Pick<MessageDoc, "role" | "content">>> {
if (!isReady()) return [];
const recent = await messages()
.find(
{ threadId: scope.threadId, userId: scope.userId },
{ projection: { _id: 0, role: 1, content: 1 } }
)
.sort({ createdAt: -1 })
.limit(limit)
.toArray();
return recent.reverse();
}
/** Delete a single subject, or wipe the whole thread. */
export async function forget(
scope: MemoryScope,
subject?: string
): Promise<number> {
if (!isReady()) return 0;
if (subject) {
const result = await memories().deleteOne({
threadId: scope.threadId,
subject: subject.toLowerCase(),
});
return result.deletedCount;
}
const result = await memories().deleteMany({ threadId: scope.threadId });
await messages().deleteMany({ threadId: scope.threadId });
return result.deletedCount;
}
/** List everything stored for a thread. Used by the inspector. */
export async function listMemories(scope: MemoryScope) {
if (!isReady()) return [];
return memories()
.find(
{ threadId: scope.threadId },
{ projection: { _id: 0, subject: 1, fact: 1, updatedAt: 1 } }
)
.sort({ updatedAt: -1 })
.toArray();
}