@batonfx/memory 0.4.0 → 0.4.1
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/README.md +5 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -33120
- package/dist/semantic-recall.d.ts +15 -0
- package/dist/semantic-recall.js +90 -0
- package/dist/vector-store.d.ts +51 -0
- package/dist/vector-store.js +77 -0
- package/dist/working-memory.d.ts +19 -0
- package/dist/working-memory.js +143 -0
- package/package.json +29 -4
- package/.turbo/turbo-build.log +0 -5
- package/src/index.ts +0 -29
- package/src/semantic-recall.ts +0 -123
- package/src/vector-store.ts +0 -132
- package/src/working-memory.ts +0 -208
- package/test/index.test.ts +0 -55
- package/test/semantic-recall.test.ts +0 -154
- package/test/vector-store.test.ts +0 -121
- package/test/working-memory.test.ts +0 -155
- package/tsconfig.json +0 -7
package/src/vector-store.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { Context, Effect, HashMap, Layer, Ref, Schema } from "effect"
|
|
2
|
-
import { Memory } from "@batonfx/core"
|
|
3
|
-
|
|
4
|
-
/** @experimental */
|
|
5
|
-
export interface Document {
|
|
6
|
-
readonly id: string
|
|
7
|
-
readonly key: Memory.Key
|
|
8
|
-
readonly text: string
|
|
9
|
-
readonly metadata?: Memory.Metadata
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** @experimental */
|
|
13
|
-
export interface Embedded extends Document {
|
|
14
|
-
readonly embedding: ReadonlyArray<number>
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** @experimental */
|
|
18
|
-
export interface Match {
|
|
19
|
-
readonly document: Embedded
|
|
20
|
-
readonly score: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** @experimental */
|
|
24
|
-
export interface Query {
|
|
25
|
-
readonly key: Memory.Key
|
|
26
|
-
readonly embedding: ReadonlyArray<number>
|
|
27
|
-
readonly limit: number
|
|
28
|
-
readonly minScore?: number
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** @experimental */
|
|
32
|
-
export interface DeleteInput {
|
|
33
|
-
readonly key: Memory.Key
|
|
34
|
-
readonly id?: string | undefined
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** @experimental */
|
|
38
|
-
export class VectorStoreError extends Schema.TaggedErrorClass<VectorStoreError>()("@batonfx/memory/VectorStoreError", {
|
|
39
|
-
message: Schema.String,
|
|
40
|
-
}) {}
|
|
41
|
-
|
|
42
|
-
/** @experimental */
|
|
43
|
-
export interface Interface {
|
|
44
|
-
readonly upsert: (documents: ReadonlyArray<Embedded>) => Effect.Effect<void, VectorStoreError>
|
|
45
|
-
readonly query: (query: Query) => Effect.Effect<ReadonlyArray<Match>, VectorStoreError>
|
|
46
|
-
readonly delete: (input: DeleteInput) => Effect.Effect<void, VectorStoreError>
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** @experimental */
|
|
50
|
-
export class VectorStore extends Context.Service<VectorStore, Interface>()("@batonfx/memory/VectorStore") {}
|
|
51
|
-
|
|
52
|
-
const storageKey = (key: Memory.Key, id: string): string => JSON.stringify([key.agent, key.subject, id])
|
|
53
|
-
|
|
54
|
-
const sameKey = (left: Memory.Key, right: Memory.Key): boolean =>
|
|
55
|
-
left.agent === right.agent && left.subject === right.subject
|
|
56
|
-
|
|
57
|
-
const validateVector = (label: string, vector: ReadonlyArray<number>): Effect.Effect<void, VectorStoreError> => {
|
|
58
|
-
const invalid = vector.find((value) => !Number.isFinite(value))
|
|
59
|
-
return invalid === undefined
|
|
60
|
-
? Effect.void
|
|
61
|
-
: Effect.fail(new VectorStoreError({ message: `${label} contains a non-finite value` }))
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const cosine = (left: ReadonlyArray<number>, right: ReadonlyArray<number>): number => {
|
|
65
|
-
let dot = 0
|
|
66
|
-
let leftMagnitude = 0
|
|
67
|
-
let rightMagnitude = 0
|
|
68
|
-
for (let index = 0; index < left.length; index += 1) {
|
|
69
|
-
const leftValue = left[index] ?? 0
|
|
70
|
-
const rightValue = right[index] ?? 0
|
|
71
|
-
dot += leftValue * rightValue
|
|
72
|
-
leftMagnitude += leftValue * leftValue
|
|
73
|
-
rightMagnitude += rightValue * rightValue
|
|
74
|
-
}
|
|
75
|
-
if (leftMagnitude === 0 || rightMagnitude === 0) return 0
|
|
76
|
-
return dot / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude))
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const make = Ref.make(HashMap.empty<string, Embedded>()).pipe(
|
|
80
|
-
Effect.map(
|
|
81
|
-
(documents): Interface => ({
|
|
82
|
-
upsert: (nextDocuments) =>
|
|
83
|
-
Effect.gen(function* () {
|
|
84
|
-
for (const document of nextDocuments) {
|
|
85
|
-
yield* validateVector(`document ${document.id} embedding`, document.embedding)
|
|
86
|
-
}
|
|
87
|
-
yield* Ref.update(documents, (current) => {
|
|
88
|
-
let next = current
|
|
89
|
-
for (const document of nextDocuments) {
|
|
90
|
-
next = HashMap.set(next, storageKey(document.key, document.id), document)
|
|
91
|
-
}
|
|
92
|
-
return next
|
|
93
|
-
})
|
|
94
|
-
}),
|
|
95
|
-
query: (input) =>
|
|
96
|
-
Effect.gen(function* () {
|
|
97
|
-
if (input.limit <= 0) return []
|
|
98
|
-
yield* validateVector("query embedding", input.embedding)
|
|
99
|
-
const current = yield* Ref.get(documents)
|
|
100
|
-
const matches: Array<Match> = []
|
|
101
|
-
for (const [, document] of current) {
|
|
102
|
-
if (!sameKey(document.key, input.key)) continue
|
|
103
|
-
if (document.embedding.length !== input.embedding.length) {
|
|
104
|
-
return yield* Effect.fail(
|
|
105
|
-
new VectorStoreError({
|
|
106
|
-
message: `document ${document.id} embedding dimension ${document.embedding.length} does not match query dimension ${input.embedding.length}`,
|
|
107
|
-
}),
|
|
108
|
-
)
|
|
109
|
-
}
|
|
110
|
-
const score = cosine(document.embedding, input.embedding)
|
|
111
|
-
if (input.minScore !== undefined && score < input.minScore) continue
|
|
112
|
-
matches.push({ document, score })
|
|
113
|
-
}
|
|
114
|
-
return matches.toSorted((left, right) => right.score - left.score).slice(0, input.limit)
|
|
115
|
-
}),
|
|
116
|
-
delete: (input) =>
|
|
117
|
-
Ref.update(documents, (current) =>
|
|
118
|
-
HashMap.filter(current, (document) => {
|
|
119
|
-
if (!sameKey(document.key, input.key)) return true
|
|
120
|
-
return input.id === undefined ? false : document.id !== input.id
|
|
121
|
-
}),
|
|
122
|
-
),
|
|
123
|
-
}),
|
|
124
|
-
),
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
/** @experimental */
|
|
128
|
-
export const memoryLayer: Layer.Layer<VectorStore> = Layer.effect(VectorStore, make.pipe(Effect.map(VectorStore.of)))
|
|
129
|
-
|
|
130
|
-
/** @experimental */
|
|
131
|
-
export const testLayer = (implementation: Interface): Layer.Layer<VectorStore> =>
|
|
132
|
-
Layer.succeed(VectorStore, VectorStore.of(implementation))
|
package/src/working-memory.ts
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import { Effect, HashMap, Layer, Ref } from "effect"
|
|
2
|
-
import { LanguageModel, Prompt, Toolkit } from "effect/unstable/ai"
|
|
3
|
-
import { Memory } from "@batonfx/core"
|
|
4
|
-
|
|
5
|
-
/** @experimental */
|
|
6
|
-
export interface SummarizeOptions {
|
|
7
|
-
readonly model: Layer.Layer<LanguageModel.LanguageModel>
|
|
8
|
-
readonly prompt?: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** @experimental */
|
|
12
|
-
export interface Options {
|
|
13
|
-
readonly maxMessages?: number
|
|
14
|
-
readonly summarize?: SummarizeOptions
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
type StoredRole = "user" | "assistant"
|
|
18
|
-
|
|
19
|
-
interface IncomingItem {
|
|
20
|
-
readonly role: StoredRole
|
|
21
|
-
readonly text: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface StoredItem extends IncomingItem {
|
|
25
|
-
readonly id: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface KeyState {
|
|
29
|
-
readonly summary?: string
|
|
30
|
-
readonly recent: ReadonlyArray<StoredItem>
|
|
31
|
-
readonly counter: number
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const emptyState: KeyState = {
|
|
35
|
-
recent: [],
|
|
36
|
-
counter: 0,
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const defaultSummaryPrompt = "Summarize the conversation memory while preserving stable user preferences and facts."
|
|
40
|
-
|
|
41
|
-
const errorMessage = (error: unknown) => (error instanceof Error ? `${error.name}: ${error.message}` : String(error))
|
|
42
|
-
|
|
43
|
-
const memoryError = (error: unknown): Memory.MemoryError => new Memory.MemoryError({ message: errorMessage(error) })
|
|
44
|
-
|
|
45
|
-
const keyId = (key: Memory.Key): string => JSON.stringify([key.agent, key.subject])
|
|
46
|
-
|
|
47
|
-
const textPart = (text: string) => Prompt.makePart("text", { text })
|
|
48
|
-
|
|
49
|
-
const textFromParts = (parts: ReadonlyArray<Prompt.Part>): string =>
|
|
50
|
-
parts
|
|
51
|
-
.filter((part): part is Prompt.TextPart => part.type === "text")
|
|
52
|
-
.map((part) => part.text)
|
|
53
|
-
.join("")
|
|
54
|
-
|
|
55
|
-
const roleLabel = (role: StoredRole): string => (role === "user" ? "User" : "Assistant")
|
|
56
|
-
|
|
57
|
-
const formatItem = (item: IncomingItem): string => `${roleLabel(item.role)}: ${item.text}`
|
|
58
|
-
|
|
59
|
-
const normalize = (prompt: Prompt.Prompt): ReadonlyArray<IncomingItem> =>
|
|
60
|
-
prompt.content.flatMap((message) => {
|
|
61
|
-
if (message.role !== "user" && message.role !== "assistant") return []
|
|
62
|
-
const text = textFromParts(message.content).trim()
|
|
63
|
-
return text.length === 0 ? [] : [{ role: message.role, text }]
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
const sameItem = (left: IncomingItem, right: IncomingItem): boolean =>
|
|
67
|
-
left.role === right.role && left.text === right.text
|
|
68
|
-
|
|
69
|
-
const appendStart = (recent: ReadonlyArray<StoredItem>, incoming: ReadonlyArray<IncomingItem>): number => {
|
|
70
|
-
if (recent.length === 0) return 0
|
|
71
|
-
for (let start = Math.max(0, incoming.length - recent.length); start >= 0; start -= 1) {
|
|
72
|
-
if (start + recent.length > incoming.length) continue
|
|
73
|
-
let matches = true
|
|
74
|
-
for (let offset = 0; offset < recent.length; offset += 1) {
|
|
75
|
-
const stored = recent[offset]
|
|
76
|
-
const next = incoming[start + offset]
|
|
77
|
-
if (stored === undefined || next === undefined || !sameItem(stored, next)) {
|
|
78
|
-
matches = false
|
|
79
|
-
break
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if (matches) return start + recent.length
|
|
83
|
-
}
|
|
84
|
-
return 0
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const trimToWindow = (
|
|
88
|
-
items: ReadonlyArray<StoredItem>,
|
|
89
|
-
maxMessages: number,
|
|
90
|
-
): { readonly overflow: ReadonlyArray<StoredItem>; readonly recent: ReadonlyArray<StoredItem> } => {
|
|
91
|
-
if (items.length <= maxMessages) return { overflow: [], recent: items }
|
|
92
|
-
if (maxMessages === 0) return { overflow: items, recent: [] }
|
|
93
|
-
return {
|
|
94
|
-
overflow: items.slice(0, items.length - maxMessages),
|
|
95
|
-
recent: items.slice(-maxMessages),
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const renderSummaryPrompt = (
|
|
100
|
-
prompt: string,
|
|
101
|
-
summary: string | undefined,
|
|
102
|
-
overflow: ReadonlyArray<StoredItem>,
|
|
103
|
-
): string =>
|
|
104
|
-
[
|
|
105
|
-
prompt,
|
|
106
|
-
...(summary === undefined ? [] : [`Existing summary:\n${summary}`]),
|
|
107
|
-
`New messages:\n${overflow.map(formatItem).join("\n")}`,
|
|
108
|
-
"Return only the updated summary.",
|
|
109
|
-
].join("\n\n")
|
|
110
|
-
|
|
111
|
-
const summarizeOverflow = (
|
|
112
|
-
options: SummarizeOptions,
|
|
113
|
-
summary: string | undefined,
|
|
114
|
-
overflow: ReadonlyArray<StoredItem>,
|
|
115
|
-
): Effect.Effect<string | undefined, Memory.MemoryError> =>
|
|
116
|
-
Effect.gen(function* () {
|
|
117
|
-
const model = yield* LanguageModel.LanguageModel
|
|
118
|
-
const response = yield* model.generateText({
|
|
119
|
-
prompt: renderSummaryPrompt(options.prompt ?? defaultSummaryPrompt, summary, overflow),
|
|
120
|
-
toolkit: Toolkit.empty,
|
|
121
|
-
toolChoice: "none",
|
|
122
|
-
})
|
|
123
|
-
const text = response.text.trim()
|
|
124
|
-
return text.length === 0 ? summary : text
|
|
125
|
-
}).pipe(Effect.provide(options.model), Effect.mapError(memoryError))
|
|
126
|
-
|
|
127
|
-
const recallItems = (state: KeyState): ReadonlyArray<Memory.Item> => [
|
|
128
|
-
...(state.summary === undefined
|
|
129
|
-
? []
|
|
130
|
-
: [
|
|
131
|
-
{
|
|
132
|
-
id: "working-summary",
|
|
133
|
-
parts: [textPart(`<working-memory-summary>\n${state.summary}\n</working-memory-summary>`)],
|
|
134
|
-
},
|
|
135
|
-
]),
|
|
136
|
-
...state.recent.map((item) => ({ id: item.id, parts: [textPart(formatItem(item))] })),
|
|
137
|
-
]
|
|
138
|
-
|
|
139
|
-
/** @experimental */
|
|
140
|
-
export const make = (options: Options = {}): Effect.Effect<Memory.Interface> =>
|
|
141
|
-
Ref.make(HashMap.empty<string, KeyState>()).pipe(
|
|
142
|
-
Effect.map((states): Memory.Interface => {
|
|
143
|
-
const maxMessages = Math.max(0, Math.floor(options.maxMessages ?? 20))
|
|
144
|
-
return {
|
|
145
|
-
recall: (input) =>
|
|
146
|
-
Ref.get(states).pipe(
|
|
147
|
-
Effect.map((current) => HashMap.get(current, keyId(input.key))),
|
|
148
|
-
Effect.map((state) => (state._tag === "Some" ? recallItems(state.value) : [])),
|
|
149
|
-
),
|
|
150
|
-
remember: (input) => {
|
|
151
|
-
const incoming = normalize(input.transcript)
|
|
152
|
-
if (incoming.length === 0) return Effect.void
|
|
153
|
-
return Effect.gen(function* () {
|
|
154
|
-
const current = yield* Ref.get(states)
|
|
155
|
-
const id = keyId(input.key)
|
|
156
|
-
const existing = HashMap.get(current, id).pipe((option) =>
|
|
157
|
-
option._tag === "Some" ? option.value : emptyState,
|
|
158
|
-
)
|
|
159
|
-
const start = appendStart(existing.recent, incoming)
|
|
160
|
-
const appended = incoming.slice(start)
|
|
161
|
-
if (appended.length === 0) return
|
|
162
|
-
let counter = existing.counter
|
|
163
|
-
const stored = appended.map((item) => {
|
|
164
|
-
counter += 1
|
|
165
|
-
return { id: `working-${counter}`, role: item.role, text: item.text }
|
|
166
|
-
})
|
|
167
|
-
const combined = [...existing.recent, ...stored]
|
|
168
|
-
const trimmed = trimToWindow(combined, maxMessages)
|
|
169
|
-
const summary =
|
|
170
|
-
trimmed.overflow.length === 0
|
|
171
|
-
? existing.summary
|
|
172
|
-
: options.summarize === undefined
|
|
173
|
-
? existing.summary
|
|
174
|
-
: yield* summarizeOverflow(options.summarize, existing.summary, trimmed.overflow)
|
|
175
|
-
const nextState: KeyState = {
|
|
176
|
-
recent: trimmed.recent,
|
|
177
|
-
counter,
|
|
178
|
-
...(summary === undefined ? {} : { summary }),
|
|
179
|
-
}
|
|
180
|
-
yield* Ref.update(states, HashMap.set(id, nextState))
|
|
181
|
-
})
|
|
182
|
-
},
|
|
183
|
-
forget: (input) =>
|
|
184
|
-
Ref.update(states, (current) => {
|
|
185
|
-
const id = keyId(input.key)
|
|
186
|
-
if (input.id === undefined) return HashMap.remove(current, id)
|
|
187
|
-
const existing = HashMap.get(current, id)
|
|
188
|
-
if (existing._tag === "None") return current
|
|
189
|
-
const summary = input.id === "working-summary" ? undefined : existing.value.summary
|
|
190
|
-
const recent = existing.value.recent.filter((item) => item.id !== input.id)
|
|
191
|
-
if (summary === undefined && recent.length === 0) return HashMap.remove(current, id)
|
|
192
|
-
const nextState: KeyState = {
|
|
193
|
-
recent,
|
|
194
|
-
counter: existing.value.counter,
|
|
195
|
-
...(summary === undefined ? {} : { summary }),
|
|
196
|
-
}
|
|
197
|
-
return HashMap.set(current, id, nextState)
|
|
198
|
-
}),
|
|
199
|
-
}
|
|
200
|
-
}),
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
/** @experimental */
|
|
204
|
-
export const makeWorkingMemory = make
|
|
205
|
-
|
|
206
|
-
/** @experimental */
|
|
207
|
-
export const layer = (options: Options = {}): Layer.Layer<Memory.Memory> =>
|
|
208
|
-
Layer.effect(Memory.Memory, make(options).pipe(Effect.map(Memory.Memory.of)))
|
package/test/index.test.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
-
import { Effect, Layer } from "effect"
|
|
3
|
-
import { EmbeddingModel, Prompt } from "effect/unstable/ai"
|
|
4
|
-
import { Memory } from "@batonfx/core"
|
|
5
|
-
import { combinedLayer, VectorStore } from "../src/index"
|
|
6
|
-
|
|
7
|
-
const key: Memory.Key = { agent: "memory-agent", subject: "subject-a" }
|
|
8
|
-
|
|
9
|
-
const textPart = (text: string) => Prompt.makePart("text", { text })
|
|
10
|
-
const user = (text: string) => Prompt.makeMessage("user", { content: [textPart(text)] })
|
|
11
|
-
const assistant = (text: string) => Prompt.makeMessage("assistant", { content: [textPart(text)] })
|
|
12
|
-
const prompt = (...messages: ReadonlyArray<Prompt.Message>) => Prompt.fromMessages(messages)
|
|
13
|
-
|
|
14
|
-
const itemText = (item: Memory.Item): string =>
|
|
15
|
-
item.parts
|
|
16
|
-
.filter((part): part is Prompt.TextPart => part.type === "text")
|
|
17
|
-
.map((part) => part.text)
|
|
18
|
-
.join("")
|
|
19
|
-
|
|
20
|
-
const embeddingLayer = Layer.effect(
|
|
21
|
-
EmbeddingModel.EmbeddingModel,
|
|
22
|
-
EmbeddingModel.make({
|
|
23
|
-
embedMany: ({ inputs }) =>
|
|
24
|
-
Effect.succeed({
|
|
25
|
-
results: inputs.map(() => [1, 0]),
|
|
26
|
-
usage: { inputTokens: undefined },
|
|
27
|
-
}),
|
|
28
|
-
}),
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
describe("@batonfx/memory", () => {
|
|
32
|
-
it.effect("combinedLayer recalls working memory before semantic matches", () =>
|
|
33
|
-
Effect.gen(function* () {
|
|
34
|
-
const memory = yield* Memory.Memory
|
|
35
|
-
|
|
36
|
-
yield* memory.remember({
|
|
37
|
-
key,
|
|
38
|
-
turn: 0,
|
|
39
|
-
terminal: true,
|
|
40
|
-
transcript: prompt(user("What color is the sky?"), assistant("blue")),
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
const recalled = yield* memory.recall({ key, turn: 0, prompt: prompt(user("color")) })
|
|
44
|
-
|
|
45
|
-
expect(recalled.map(itemText)).toEqual(["Assistant: blue", "User: What color is the sky?\nAssistant: blue"])
|
|
46
|
-
}).pipe(
|
|
47
|
-
Effect.provide(
|
|
48
|
-
combinedLayer({ working: { maxMessages: 1 }, semantic: { limit: 5 } }).pipe(
|
|
49
|
-
Layer.provideMerge(VectorStore.memoryLayer),
|
|
50
|
-
Layer.provideMerge(embeddingLayer),
|
|
51
|
-
),
|
|
52
|
-
),
|
|
53
|
-
),
|
|
54
|
-
)
|
|
55
|
-
})
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
-
import { Effect, Layer } from "effect"
|
|
3
|
-
import { AiError, EmbeddingModel, Prompt } from "effect/unstable/ai"
|
|
4
|
-
import { Memory } from "@batonfx/core"
|
|
5
|
-
import { SemanticRecall, VectorStore } from "../src/index"
|
|
6
|
-
|
|
7
|
-
const key: Memory.Key = { agent: "memory-agent", subject: "subject-a" }
|
|
8
|
-
const otherKey: Memory.Key = { agent: "memory-agent", subject: "subject-b" }
|
|
9
|
-
|
|
10
|
-
const textPart = (text: string) => Prompt.makePart("text", { text })
|
|
11
|
-
const user = (text: string) => Prompt.makeMessage("user", { content: [textPart(text)] })
|
|
12
|
-
const assistant = (text: string) => Prompt.makeMessage("assistant", { content: [textPart(text)] })
|
|
13
|
-
const prompt = (...messages: ReadonlyArray<Prompt.Message>) => Prompt.fromMessages(messages)
|
|
14
|
-
|
|
15
|
-
const itemText = (item: Memory.Item): string =>
|
|
16
|
-
item.parts
|
|
17
|
-
.filter((part): part is Prompt.TextPart => part.type === "text")
|
|
18
|
-
.map((part) => part.text)
|
|
19
|
-
.join("")
|
|
20
|
-
|
|
21
|
-
const vectorForText = (text: string): Array<number> =>
|
|
22
|
-
text.toLowerCase().includes("color") || text.toLowerCase().includes("blue") ? [1, 0] : [0, 1]
|
|
23
|
-
|
|
24
|
-
const embeddingLayer = Layer.effect(
|
|
25
|
-
EmbeddingModel.EmbeddingModel,
|
|
26
|
-
EmbeddingModel.make({
|
|
27
|
-
embedMany: ({ inputs }) =>
|
|
28
|
-
Effect.succeed({
|
|
29
|
-
results: inputs.map(vectorForText),
|
|
30
|
-
usage: { inputTokens: undefined },
|
|
31
|
-
}),
|
|
32
|
-
}),
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
const memoryLayer = SemanticRecall.layer({ limit: 5 }).pipe(
|
|
36
|
-
Layer.provideMerge(VectorStore.memoryLayer),
|
|
37
|
-
Layer.provideMerge(embeddingLayer),
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
describe("SemanticRecall", () => {
|
|
41
|
-
it.effect("remembers a terminal user and assistant exchange for later recall", () =>
|
|
42
|
-
Effect.gen(function* () {
|
|
43
|
-
const memory = yield* Memory.Memory
|
|
44
|
-
|
|
45
|
-
yield* memory.remember({
|
|
46
|
-
key,
|
|
47
|
-
turn: 0,
|
|
48
|
-
terminal: true,
|
|
49
|
-
transcript: prompt(user("What color is the sky?"), assistant("blue")),
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
const recalled = yield* memory.recall({ key, turn: 0, prompt: prompt(user("color")) })
|
|
53
|
-
|
|
54
|
-
expect(recalled).toHaveLength(1)
|
|
55
|
-
expect(itemText(recalled[0]!)).toContain("User: What color is the sky?")
|
|
56
|
-
expect(itemText(recalled[0]!)).toContain("Assistant: blue")
|
|
57
|
-
}).pipe(Effect.provide(memoryLayer)),
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
it.effect("does not upsert nonterminal turns", () =>
|
|
61
|
-
Effect.gen(function* () {
|
|
62
|
-
const memory = yield* Memory.Memory
|
|
63
|
-
|
|
64
|
-
yield* memory.remember({
|
|
65
|
-
key,
|
|
66
|
-
turn: 0,
|
|
67
|
-
terminal: false,
|
|
68
|
-
transcript: prompt(user("What color is the sky?"), assistant("blue")),
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
const recalled = yield* memory.recall({ key, turn: 0, prompt: prompt(user("color")) })
|
|
72
|
-
|
|
73
|
-
expect(recalled).toEqual([])
|
|
74
|
-
}).pipe(Effect.provide(memoryLayer)),
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
it.effect("isolates recall by memory key", () =>
|
|
78
|
-
Effect.gen(function* () {
|
|
79
|
-
const memory = yield* Memory.Memory
|
|
80
|
-
|
|
81
|
-
yield* memory.remember({
|
|
82
|
-
key,
|
|
83
|
-
turn: 0,
|
|
84
|
-
terminal: true,
|
|
85
|
-
transcript: prompt(user("What color is the sky?"), assistant("blue")),
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
const recalled = yield* memory.recall({ key: otherKey, turn: 0, prompt: prompt(user("color")) })
|
|
89
|
-
|
|
90
|
-
expect(recalled).toEqual([])
|
|
91
|
-
}).pipe(Effect.provide(memoryLayer)),
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
it.effect("forgets one semantic memory id within the exact memory key", () =>
|
|
95
|
-
Effect.gen(function* () {
|
|
96
|
-
const memory = yield* Memory.Memory
|
|
97
|
-
|
|
98
|
-
yield* memory.remember({
|
|
99
|
-
key,
|
|
100
|
-
turn: 0,
|
|
101
|
-
terminal: true,
|
|
102
|
-
transcript: prompt(user("What color is the sky?"), assistant("blue")),
|
|
103
|
-
})
|
|
104
|
-
yield* memory.remember({
|
|
105
|
-
key,
|
|
106
|
-
turn: 1,
|
|
107
|
-
terminal: true,
|
|
108
|
-
transcript: prompt(user("What color is the ocean?"), assistant("blue")),
|
|
109
|
-
})
|
|
110
|
-
yield* memory.remember({
|
|
111
|
-
key: otherKey,
|
|
112
|
-
turn: 0,
|
|
113
|
-
terminal: true,
|
|
114
|
-
transcript: prompt(user("What color is the door?"), assistant("blue")),
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
yield* memory.forget({ key, id: "semantic-1" })
|
|
118
|
-
|
|
119
|
-
const retained = yield* memory.recall({ key, turn: 0, prompt: prompt(user("color")) })
|
|
120
|
-
const otherRetained = yield* memory.recall({ key: otherKey, turn: 0, prompt: prompt(user("color")) })
|
|
121
|
-
|
|
122
|
-
expect(retained.map(itemText)).toEqual(["User: What color is the ocean?\nAssistant: blue"])
|
|
123
|
-
expect(otherRetained.map(itemText)).toEqual(["User: What color is the door?\nAssistant: blue"])
|
|
124
|
-
}).pipe(Effect.provide(memoryLayer)),
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
it.effect("maps embedding failures to MemoryError", () => {
|
|
128
|
-
const embeddingError = AiError.make({
|
|
129
|
-
module: "SemanticRecallTest",
|
|
130
|
-
method: "embedMany",
|
|
131
|
-
reason: new AiError.UnknownError({ description: "embedding failed" }),
|
|
132
|
-
})
|
|
133
|
-
const failingEmbedding = Layer.effect(
|
|
134
|
-
EmbeddingModel.EmbeddingModel,
|
|
135
|
-
EmbeddingModel.make({ embedMany: () => Effect.fail(embeddingError) }),
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
return Effect.gen(function* () {
|
|
139
|
-
const memory = yield* Memory.Memory
|
|
140
|
-
|
|
141
|
-
const failure = yield* Effect.flip(memory.recall({ key, turn: 0, prompt: prompt(user("color")) }))
|
|
142
|
-
|
|
143
|
-
expect(failure._tag).toBe("@batonfx/core/MemoryError")
|
|
144
|
-
expect(failure.message).toContain("embedding failed")
|
|
145
|
-
}).pipe(
|
|
146
|
-
Effect.provide(
|
|
147
|
-
SemanticRecall.layer({ limit: 5 }).pipe(
|
|
148
|
-
Layer.provideMerge(VectorStore.memoryLayer),
|
|
149
|
-
Layer.provideMerge(failingEmbedding),
|
|
150
|
-
),
|
|
151
|
-
),
|
|
152
|
-
)
|
|
153
|
-
})
|
|
154
|
-
})
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
-
import { Effect } from "effect"
|
|
3
|
-
import { Memory } from "@batonfx/core"
|
|
4
|
-
import { VectorStore } from "../src/index"
|
|
5
|
-
|
|
6
|
-
const key: Memory.Key = { agent: "agent-a", subject: "subject-a" }
|
|
7
|
-
const otherSubject: Memory.Key = { agent: "agent-a", subject: "subject-b" }
|
|
8
|
-
const otherAgent: Memory.Key = { agent: "agent-b", subject: "subject-a" }
|
|
9
|
-
|
|
10
|
-
const document = (
|
|
11
|
-
id: string,
|
|
12
|
-
memoryKey: Memory.Key,
|
|
13
|
-
text: string,
|
|
14
|
-
embedding: ReadonlyArray<number>,
|
|
15
|
-
): VectorStore.Embedded => ({
|
|
16
|
-
id,
|
|
17
|
-
key: memoryKey,
|
|
18
|
-
text,
|
|
19
|
-
embedding,
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
describe("VectorStore", () => {
|
|
23
|
-
it.effect("ranks matching-key documents by cosine score", () =>
|
|
24
|
-
Effect.gen(function* () {
|
|
25
|
-
const store = yield* VectorStore.VectorStore
|
|
26
|
-
|
|
27
|
-
yield* store.upsert([
|
|
28
|
-
document("near", key, "near", [1, 0]),
|
|
29
|
-
document("far", key, "far", [0, 1]),
|
|
30
|
-
document("also-near", key, "also-near", [0.8, 0.2]),
|
|
31
|
-
])
|
|
32
|
-
|
|
33
|
-
const matches = yield* store.query({ key, embedding: [1, 0], limit: 2 })
|
|
34
|
-
|
|
35
|
-
expect(matches.map((match) => match.document.text)).toEqual(["near", "also-near"])
|
|
36
|
-
expect(matches[0]?.score).toBeGreaterThan(matches[1]?.score ?? 0)
|
|
37
|
-
}).pipe(Effect.provide(VectorStore.memoryLayer)),
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
it.effect("enforces exact agent and subject key isolation", () =>
|
|
41
|
-
Effect.gen(function* () {
|
|
42
|
-
const store = yield* VectorStore.VectorStore
|
|
43
|
-
|
|
44
|
-
yield* store.upsert([
|
|
45
|
-
document("same-id", key, "visible", [1, 0]),
|
|
46
|
-
document("same-id", otherSubject, "other subject", [1, 0]),
|
|
47
|
-
document("same-id", otherAgent, "other agent", [1, 0]),
|
|
48
|
-
])
|
|
49
|
-
|
|
50
|
-
const matches = yield* store.query({ key, embedding: [1, 0], limit: 10 })
|
|
51
|
-
|
|
52
|
-
expect(matches.map((match) => match.document.text)).toEqual(["visible"])
|
|
53
|
-
}).pipe(Effect.provide(VectorStore.memoryLayer)),
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
it.effect("applies limit and minScore", () =>
|
|
57
|
-
Effect.gen(function* () {
|
|
58
|
-
const store = yield* VectorStore.VectorStore
|
|
59
|
-
|
|
60
|
-
yield* store.upsert([document("near", key, "near", [1, 0]), document("also-near", key, "also-near", [0.8, 0.2])])
|
|
61
|
-
|
|
62
|
-
const limited = yield* store.query({ key, embedding: [1, 0], limit: 1 })
|
|
63
|
-
const thresholded = yield* store.query({ key, embedding: [1, 0], limit: 10, minScore: 0.99 })
|
|
64
|
-
|
|
65
|
-
expect(limited.map((match) => match.document.text)).toEqual(["near"])
|
|
66
|
-
expect(thresholded.map((match) => match.document.text)).toEqual(["near"])
|
|
67
|
-
}).pipe(Effect.provide(VectorStore.memoryLayer)),
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
it.effect("fails loudly on matching-key dimension mismatch", () =>
|
|
71
|
-
Effect.gen(function* () {
|
|
72
|
-
const store = yield* VectorStore.VectorStore
|
|
73
|
-
|
|
74
|
-
yield* store.upsert([document("bad", key, "bad", [1, 0, 0])])
|
|
75
|
-
|
|
76
|
-
const failure = yield* Effect.flip(store.query({ key, embedding: [1, 0], limit: 1 }))
|
|
77
|
-
|
|
78
|
-
expect(failure._tag).toBe("@batonfx/memory/VectorStoreError")
|
|
79
|
-
expect(failure.message).toContain("dimension")
|
|
80
|
-
}).pipe(Effect.provide(VectorStore.memoryLayer)),
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
it.effect("deletes documents for the exact memory key", () =>
|
|
84
|
-
Effect.gen(function* () {
|
|
85
|
-
const store = yield* VectorStore.VectorStore
|
|
86
|
-
|
|
87
|
-
yield* store.upsert([
|
|
88
|
-
document("same-id", key, "visible", [1, 0]),
|
|
89
|
-
document("same-id", otherSubject, "other subject", [1, 0]),
|
|
90
|
-
])
|
|
91
|
-
|
|
92
|
-
yield* store.delete({ key })
|
|
93
|
-
|
|
94
|
-
const deletedMatches = yield* store.query({ key, embedding: [1, 0], limit: 10 })
|
|
95
|
-
const remainingMatches = yield* store.query({ key: otherSubject, embedding: [1, 0], limit: 10 })
|
|
96
|
-
|
|
97
|
-
expect(deletedMatches).toEqual([])
|
|
98
|
-
expect(remainingMatches.map((match) => match.document.text)).toEqual(["other subject"])
|
|
99
|
-
}).pipe(Effect.provide(VectorStore.memoryLayer)),
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
it.effect("deletes one document id within the exact memory key", () =>
|
|
103
|
-
Effect.gen(function* () {
|
|
104
|
-
const store = yield* VectorStore.VectorStore
|
|
105
|
-
|
|
106
|
-
yield* store.upsert([
|
|
107
|
-
document("first", key, "first", [1, 0]),
|
|
108
|
-
document("second", key, "second", [0, 1]),
|
|
109
|
-
document("first", otherSubject, "other subject", [1, 0]),
|
|
110
|
-
])
|
|
111
|
-
|
|
112
|
-
yield* store.delete({ key, id: "first" })
|
|
113
|
-
|
|
114
|
-
const deletedKeyMatches = yield* store.query({ key, embedding: [1, 0], limit: 10 })
|
|
115
|
-
const otherSubjectMatches = yield* store.query({ key: otherSubject, embedding: [1, 0], limit: 10 })
|
|
116
|
-
|
|
117
|
-
expect(deletedKeyMatches.map((match) => match.document.text)).toEqual(["second"])
|
|
118
|
-
expect(otherSubjectMatches.map((match) => match.document.text)).toEqual(["other subject"])
|
|
119
|
-
}).pipe(Effect.provide(VectorStore.memoryLayer)),
|
|
120
|
-
)
|
|
121
|
-
})
|