@batonfx/memory 0.1.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 ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@batonfx/memory",
4
+ "version": "0.1.0",
5
+ "private": false,
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "build": "bun build ./src/index.ts --outdir dist --target bun --format esm",
12
+ "lint": "bun --cwd ../.. oxlint packages/memory/src packages/memory/test",
13
+ "test": "bun --cwd ../.. vitest run packages/memory/test --passWithNoTests",
14
+ "typecheck": "bun tsc --noEmit"
15
+ },
16
+ "dependencies": {
17
+ "@batonfx/core": "0.1.0",
18
+ "effect": "4.0.0-beta.93"
19
+ },
20
+ "devDependencies": {
21
+ "@effect/vitest": "4.0.0-beta.93",
22
+ "@types/bun": "1.3.13",
23
+ "typescript": "5.8.2",
24
+ "vitest": "4.0.16"
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { Effect, Layer } from "effect"
2
+ import * as Ai from "effect/unstable/ai"
3
+ import { Memory } from "@batonfx/core"
4
+ import * as SemanticRecall from "./semantic-recall"
5
+ import * as VectorStore from "./vector-store"
6
+ import * as WorkingMemory from "./working-memory"
7
+
8
+ export * as SemanticRecall from "./semantic-recall"
9
+ export * as VectorStore from "./vector-store"
10
+ export * as WorkingMemory from "./working-memory"
11
+
12
+ /** @experimental */
13
+ export interface CombinedOptions {
14
+ readonly working?: WorkingMemory.Options
15
+ readonly semantic?: SemanticRecall.Options
16
+ }
17
+
18
+ /** @experimental */
19
+ export const combinedLayer = (
20
+ options: CombinedOptions = {},
21
+ ): Layer.Layer<Memory.Memory, never, VectorStore.VectorStore | Ai.EmbeddingModel.EmbeddingModel> =>
22
+ Layer.effect(
23
+ Memory.Memory,
24
+ Effect.gen(function* () {
25
+ const working = yield* WorkingMemory.make(options.working)
26
+ const semantic = yield* SemanticRecall.make(options.semantic)
27
+ return Memory.Memory.of(Memory.merge(working, semantic))
28
+ }),
29
+ )
@@ -0,0 +1,119 @@
1
+ import { Effect, Layer, Ref } from "effect"
2
+ import * as Ai from "effect/unstable/ai"
3
+ import { Memory } from "@batonfx/core"
4
+ import * as VectorStore from "./vector-store"
5
+
6
+ /** @experimental */
7
+ export interface Options {
8
+ readonly limit?: number
9
+ readonly minScore?: number
10
+ }
11
+
12
+ const errorMessage = (error: unknown) => (error instanceof Error ? `${error.name}: ${error.message}` : String(error))
13
+
14
+ const memoryError = (error: unknown): Memory.MemoryError => new Memory.MemoryError({ message: errorMessage(error) })
15
+
16
+ const textPart = (text: string) => Ai.Prompt.makePart("text", { text })
17
+
18
+ const textFromParts = (parts: ReadonlyArray<Ai.Prompt.Part>): string =>
19
+ parts
20
+ .filter((part): part is Ai.Prompt.TextPart => part.type === "text")
21
+ .map((part) => part.text)
22
+ .join("")
23
+
24
+ const userText = (prompt: Ai.Prompt.Prompt): string =>
25
+ prompt.content
26
+ .filter((message): message is Ai.Prompt.UserMessage => message.role === "user")
27
+ .map((message) => textFromParts(message.content))
28
+ .filter((text) => text.length > 0)
29
+ .join("\n\n")
30
+
31
+ const finalExchangeText = (prompt: Ai.Prompt.Prompt): string | undefined => {
32
+ let assistant: string | undefined
33
+ let assistantIndex = -1
34
+ for (let index = prompt.content.length - 1; index >= 0; index -= 1) {
35
+ const message = prompt.content[index]
36
+ if (message?.role !== "assistant") continue
37
+ const text = textFromParts(message.content).trim()
38
+ if (text.length === 0) continue
39
+ assistant = text
40
+ assistantIndex = index
41
+ break
42
+ }
43
+ if (assistant === undefined) return undefined
44
+ for (let index = assistantIndex - 1; index >= 0; index -= 1) {
45
+ const message = prompt.content[index]
46
+ if (message?.role !== "user") continue
47
+ const text = textFromParts(message.content).trim()
48
+ if (text.length === 0) continue
49
+ return `User: ${text}\nAssistant: ${assistant}`
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ const itemFromMatch = (match: VectorStore.Match): Memory.Item => ({
55
+ id: match.document.id,
56
+ parts: [textPart(match.document.text)],
57
+ metadata: { ...match.document.metadata, score: match.score },
58
+ })
59
+
60
+ /** @experimental */
61
+ export const make = (
62
+ options: Options = {},
63
+ ): Effect.Effect<Memory.Interface, never, VectorStore.VectorStore | Ai.EmbeddingModel.EmbeddingModel> =>
64
+ Effect.gen(function* () {
65
+ const store = yield* VectorStore.VectorStore
66
+ const embeddingModel = yield* Ai.EmbeddingModel.EmbeddingModel
67
+ const counter = yield* Ref.make(0)
68
+ const limit = options.limit ?? 5
69
+
70
+ return {
71
+ recall: (input) => {
72
+ const text = userText(input.prompt)
73
+ if (text.length === 0) return Effect.succeed([])
74
+ return embeddingModel.embed(text).pipe(
75
+ Effect.mapError(memoryError),
76
+ Effect.flatMap((embedding) =>
77
+ store
78
+ .query({
79
+ key: input.key,
80
+ embedding: embedding.vector,
81
+ limit,
82
+ ...(options.minScore === undefined ? {} : { minScore: options.minScore }),
83
+ })
84
+ .pipe(Effect.mapError(memoryError)),
85
+ ),
86
+ Effect.map((matches) => matches.map(itemFromMatch)),
87
+ )
88
+ },
89
+ remember: (input) => {
90
+ if (!input.terminal) return Effect.void
91
+ const text = finalExchangeText(input.transcript)
92
+ if (text === undefined) return Effect.void
93
+ return embeddingModel.embed(text).pipe(
94
+ Effect.mapError(memoryError),
95
+ Effect.flatMap((embedding) =>
96
+ Ref.modify(counter, (current) => [`semantic-${current + 1}`, current + 1]).pipe(
97
+ Effect.flatMap((id) =>
98
+ store.upsert([
99
+ {
100
+ id,
101
+ key: input.key,
102
+ text,
103
+ embedding: embedding.vector,
104
+ },
105
+ ]),
106
+ ),
107
+ ),
108
+ ),
109
+ Effect.mapError(memoryError),
110
+ )
111
+ },
112
+ }
113
+ })
114
+
115
+ /** @experimental */
116
+ export const layer = (
117
+ options: Options = {},
118
+ ): Layer.Layer<Memory.Memory, never, VectorStore.VectorStore | Ai.EmbeddingModel.EmbeddingModel> =>
119
+ Layer.effect(Memory.Memory, make(options).pipe(Effect.map(Memory.Memory.of)))
@@ -0,0 +1,118 @@
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 class VectorStoreError extends Schema.TaggedErrorClass<VectorStoreError>()("@batonfx/memory/VectorStoreError", {
33
+ message: Schema.String,
34
+ }) {}
35
+
36
+ /** @experimental */
37
+ export interface Interface {
38
+ readonly upsert: (documents: ReadonlyArray<Embedded>) => Effect.Effect<void, VectorStoreError>
39
+ readonly query: (query: Query) => Effect.Effect<ReadonlyArray<Match>, VectorStoreError>
40
+ }
41
+
42
+ /** @experimental */
43
+ export class VectorStore extends Context.Service<VectorStore, Interface>()("@batonfx/memory/VectorStore") {}
44
+
45
+ const storageKey = (key: Memory.Key, id: string): string => JSON.stringify([key.agent, key.subject, id])
46
+
47
+ const sameKey = (left: Memory.Key, right: Memory.Key): boolean =>
48
+ left.agent === right.agent && left.subject === right.subject
49
+
50
+ const validateVector = (label: string, vector: ReadonlyArray<number>): Effect.Effect<void, VectorStoreError> => {
51
+ const invalid = vector.find((value) => !Number.isFinite(value))
52
+ return invalid === undefined
53
+ ? Effect.void
54
+ : Effect.fail(new VectorStoreError({ message: `${label} contains a non-finite value` }))
55
+ }
56
+
57
+ const cosine = (left: ReadonlyArray<number>, right: ReadonlyArray<number>): number => {
58
+ let dot = 0
59
+ let leftMagnitude = 0
60
+ let rightMagnitude = 0
61
+ for (let index = 0; index < left.length; index += 1) {
62
+ const leftValue = left[index] ?? 0
63
+ const rightValue = right[index] ?? 0
64
+ dot += leftValue * rightValue
65
+ leftMagnitude += leftValue * leftValue
66
+ rightMagnitude += rightValue * rightValue
67
+ }
68
+ if (leftMagnitude === 0 || rightMagnitude === 0) return 0
69
+ return dot / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude))
70
+ }
71
+
72
+ const make = Ref.make(HashMap.empty<string, Embedded>()).pipe(
73
+ Effect.map(
74
+ (documents): Interface => ({
75
+ upsert: (nextDocuments) =>
76
+ Effect.gen(function* () {
77
+ for (const document of nextDocuments) {
78
+ yield* validateVector(`document ${document.id} embedding`, document.embedding)
79
+ }
80
+ yield* Ref.update(documents, (current) => {
81
+ let next = current
82
+ for (const document of nextDocuments) {
83
+ next = HashMap.set(next, storageKey(document.key, document.id), document)
84
+ }
85
+ return next
86
+ })
87
+ }),
88
+ query: (input) =>
89
+ Effect.gen(function* () {
90
+ if (input.limit <= 0) return []
91
+ yield* validateVector("query embedding", input.embedding)
92
+ const current = yield* Ref.get(documents)
93
+ const matches: Array<Match> = []
94
+ for (const [, document] of current) {
95
+ if (!sameKey(document.key, input.key)) continue
96
+ if (document.embedding.length !== input.embedding.length) {
97
+ return yield* Effect.fail(
98
+ new VectorStoreError({
99
+ message: `document ${document.id} embedding dimension ${document.embedding.length} does not match query dimension ${input.embedding.length}`,
100
+ }),
101
+ )
102
+ }
103
+ const score = cosine(document.embedding, input.embedding)
104
+ if (input.minScore !== undefined && score < input.minScore) continue
105
+ matches.push({ document, score })
106
+ }
107
+ return matches.toSorted((left, right) => right.score - left.score).slice(0, input.limit)
108
+ }),
109
+ }),
110
+ ),
111
+ )
112
+
113
+ /** @experimental */
114
+ export const memoryLayer: Layer.Layer<VectorStore> = Layer.effect(VectorStore, make.pipe(Effect.map(VectorStore.of)))
115
+
116
+ /** @experimental */
117
+ export const testLayer = (implementation: Interface): Layer.Layer<VectorStore> =>
118
+ Layer.succeed(VectorStore, VectorStore.of(implementation))
@@ -0,0 +1,189 @@
1
+ import { Effect, HashMap, Layer, Ref } from "effect"
2
+ import * as Ai from "effect/unstable/ai"
3
+ import { Memory } from "@batonfx/core"
4
+
5
+ /** @experimental */
6
+ export interface SummarizeOptions {
7
+ readonly model: Layer.Layer<Ai.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) => Ai.Prompt.makePart("text", { text })
48
+
49
+ const textFromParts = (parts: ReadonlyArray<Ai.Prompt.Part>): string =>
50
+ parts
51
+ .filter((part): part is Ai.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: Ai.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* Ai.LanguageModel.LanguageModel
118
+ const response = yield* model.generateText({
119
+ prompt: renderSummaryPrompt(options.prompt ?? defaultSummaryPrompt, summary, overflow),
120
+ toolkit: Ai.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
+ }
184
+ }),
185
+ )
186
+
187
+ /** @experimental */
188
+ export const layer = (options: Options = {}): Layer.Layer<Memory.Memory> =>
189
+ Layer.effect(Memory.Memory, make(options).pipe(Effect.map(Memory.Memory.of)))
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect, Layer } from "effect"
3
+ import * as Ai 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) => Ai.Prompt.makePart("text", { text })
10
+ const user = (text: string) => Ai.Prompt.makeMessage("user", { content: [textPart(text)] })
11
+ const assistant = (text: string) => Ai.Prompt.makeMessage("assistant", { content: [textPart(text)] })
12
+ const prompt = (...messages: ReadonlyArray<Ai.Prompt.Message>) => Ai.Prompt.fromMessages(messages)
13
+
14
+ const itemText = (item: Memory.Item): string =>
15
+ item.parts
16
+ .filter((part): part is Ai.Prompt.TextPart => part.type === "text")
17
+ .map((part) => part.text)
18
+ .join("")
19
+
20
+ const embeddingLayer = Layer.effect(
21
+ Ai.EmbeddingModel.EmbeddingModel,
22
+ Ai.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
+ })
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect, Layer } from "effect"
3
+ import * as Ai 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) => Ai.Prompt.makePart("text", { text })
11
+ const user = (text: string) => Ai.Prompt.makeMessage("user", { content: [textPart(text)] })
12
+ const assistant = (text: string) => Ai.Prompt.makeMessage("assistant", { content: [textPart(text)] })
13
+ const prompt = (...messages: ReadonlyArray<Ai.Prompt.Message>) => Ai.Prompt.fromMessages(messages)
14
+
15
+ const itemText = (item: Memory.Item): string =>
16
+ item.parts
17
+ .filter((part): part is Ai.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
+ Ai.EmbeddingModel.EmbeddingModel,
26
+ Ai.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("maps embedding failures to MemoryError", () => {
95
+ const embeddingError = Ai.AiError.make({
96
+ module: "SemanticRecallTest",
97
+ method: "embedMany",
98
+ reason: new Ai.AiError.UnknownError({ description: "embedding failed" }),
99
+ })
100
+ const failingEmbedding = Layer.effect(
101
+ Ai.EmbeddingModel.EmbeddingModel,
102
+ Ai.EmbeddingModel.make({ embedMany: () => Effect.fail(embeddingError) }),
103
+ )
104
+
105
+ return Effect.gen(function* () {
106
+ const memory = yield* Memory.Memory
107
+
108
+ const failure = yield* Effect.flip(memory.recall({ key, turn: 0, prompt: prompt(user("color")) }))
109
+
110
+ expect(failure._tag).toBe("@batonfx/core/MemoryError")
111
+ expect(failure.message).toContain("embedding failed")
112
+ }).pipe(
113
+ Effect.provide(
114
+ SemanticRecall.layer({ limit: 5 }).pipe(
115
+ Layer.provideMerge(VectorStore.memoryLayer),
116
+ Layer.provideMerge(failingEmbedding),
117
+ ),
118
+ ),
119
+ )
120
+ })
121
+ })