feat: long-memory

This commit is contained in:
Acbox
2026-01-10 00:47:42 +08:00
parent 22a8bccad9
commit ec01c6fd5e
19 changed files with 443 additions and 138 deletions
+2 -7
View File
@@ -3,20 +3,15 @@
"version": "1.0.0",
"description": "Agent package for the phonetutor monorepo",
"scripts": {
"test": "vitest",
"start": "bun run src/client/index.ts"
"test": "vitest"
},
"keywords": [],
"author": "Phonetutor",
"license": "ISC",
"packageManager": "pnpm@10.27.0",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.7",
"@ai-sdk/google": "^3.0.6",
"@ai-sdk/openai": "^3.0.7",
"@byteagent/shared": "workspace:*",
"ai": "^6.0.14",
"dotenv": "^17.2.3",
"xsai": "^0.4.1",
"zod": "^4.3.5"
}
}
+3
View File
@@ -0,0 +1,3 @@
import { streamText } from 'xsai'
streamText({})
+1
View File
@@ -0,0 +1 @@
export * from './system'
+21
View File
@@ -0,0 +1,21 @@
export interface SystemParams {
date: Date
locale: Intl.LocalesArgument
}
export const system = ({ date, locale }: SystemParams) => {
return `
---
date: ${date.toLocaleDateString(locale)}
time: ${date.toLocaleTimeString(locale)}
language: ${locale}
timezone: ${date.getTimezoneOffset()}
---
You are a personal housekeeper assistant, which able to manage the master's daily affairs.
Your abilities:
- Long memory: You possess long-term memory; conversations from the last 24 hours will be directly loaded into your context. Additionally, you can use tools to search for past memories.
- Scheduled tasks: You can create scheduled tasks to automatically remind you to do something.
- Messaging: You may allowed to use message software to send messages to the master.
`.trim()
}
+4 -1
View File
@@ -1,3 +1,6 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import { config } from 'dotenv'
export const db = drizzle(process.env.DATABASE_URL!)
config({ path: '../../' })
export const db = drizzle(process.env.DATABASE_URL!)
+16
View File
@@ -0,0 +1,16 @@
import { pgTable, timestamp, uuid, jsonb, text, vector, index } from 'drizzle-orm/pg-core'
export const memory = pgTable(
'memory',
{
id: uuid('id').primaryKey().defaultRandom(),
messages: jsonb('messages').notNull(),
timestamp: timestamp('timestamp').notNull(),
user: text('user').notNull(),
rawContent: text('raw_content').notNull(),
embedding: vector('embedding', { dimensions: 1536 }).notNull(),
},
(table) => [
index('embedding_index').using('hnsw', table.embedding.op('vector_cosine_ops')),
]
)
-10
View File
@@ -1,10 +0,0 @@
import { pgTable, text, uuid } from 'drizzle-orm/pg-core'
export const model = pgTable('model', {
id: uuid('id').primaryKey(),
modelId: text('model_id').notNull(),
name: text('name'),
baseUrl: text('base_url').notNull(),
apiKey: text('api_key').notNull(),
clientType: text('client_type').notNull()
})
+1 -1
View File
@@ -1 +1 @@
export * from './model'
export * from './memory'
+1
View File
@@ -0,0 +1 @@
# @byteagent/memory
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@byteagent/memory",
"version": "1.0.0",
"description": "",
"exports": {
".": "./src/index.ts"
},
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.27.0",
"dependencies": {
"@byteagent/db": "workspace:*",
"drizzle-orm": "^0.45.1",
"xsai": "^0.4.1"
}
}
+34
View File
@@ -0,0 +1,34 @@
import { embed } from 'xsai'
import { EmbedParams } from './types'
import { MemoryUnit } from './memory-unit'
import { rawMemory } from './raw'
import { db } from '@byteagent/db'
import { memory } from '@byteagent/db/schema'
export interface AddMemoryParams extends EmbedParams {
locale: Intl.LocalesArgument
}
export interface AddMemoryInput {
memory: MemoryUnit
}
export const createAddMemory = (params: AddMemoryParams) =>
async ({ memory: memoryUnit }: AddMemoryInput) => {
const rawContent = rawMemory(memoryUnit, params.locale)
const { embedding } = await embed({
model: params.model,
input: rawContent,
apiKey: params.apiKey,
baseURL: params.baseURL,
})
await db.insert(memory)
.values({
timestamp: memoryUnit.timestamp,
user: memoryUnit.user,
rawContent,
embedding,
messages: memoryUnit.messages,
})
.onConflictDoNothing()
}
+58
View File
@@ -0,0 +1,58 @@
import { db } from '@byteagent/db'
import { memory } from '@byteagent/db/schema'
import { and, gte, lte, asc, sql, cosineDistance, gt, desc, eq } from 'drizzle-orm'
import { MemoryUnit } from './memory-unit'
export const filterByTimestamp = async (
from: Date,
to: Date,
user: string,
) => {
const results = await db
.select()
.from(memory)
.where(and(
gte(memory.timestamp, from),
lte(memory.timestamp, to),
eq(memory.user, user),
))
.orderBy(asc(memory.timestamp))
return results.map((result) => ({
messages: result.messages,
timestamp: new Date(result.timestamp),
user: result.user,
raw: result.rawContent,
})) as MemoryUnit[]
}
export const filterByEmbedding = async (
embedding: number[],
user: string,
limit: number = 10,
) => {
const similarity = sql<number>`1 - (${cosineDistance(memory.embedding, embedding)})`
const results = await db
.select({
similarity,
messages: memory.messages,
timestamp: memory.timestamp,
user: memory.user,
rawContent: memory.rawContent,
embedding: memory.embedding,
id: memory.id,
})
.from(memory)
.where(and(
gt(similarity, 0.5),
eq(memory.user, user),
))
.orderBy((t) => desc(t.similarity))
.limit(limit)
return results.map((result) => ({
messages: result.messages,
timestamp: new Date(result.timestamp),
user: result.user,
raw: result.rawContent,
})) as MemoryUnit[]
}
+6
View File
@@ -0,0 +1,6 @@
export * from './memory-unit'
export * from './filter'
export * from './add'
export * from './search'
export * from './types'
export * from './raw'
+8
View File
@@ -0,0 +1,8 @@
import { Message } from 'xsai'
export interface MemoryUnit {
messages: Message[]
timestamp: Date
user: string
raw: string
}
+33
View File
@@ -0,0 +1,33 @@
import { Message } from 'xsai'
import { MemoryUnit } from './memory-unit'
export const rawMessages = (messages: Message[]) => {
return messages.map((message) => {
if (message.role === 'user') {
return `User: ${message.content}`
} else if (message.role === 'assistant') {
let toolCalls = ''
if (message.tool_calls && message.tool_calls.length !== 0) {
toolCalls = `Tool Calls: ${message.tool_calls.map(t => t.function.name).join(', ')}`
}
return `You: ${message.content} \n${toolCalls}`
} else if (message.role === 'tool') {
return `Tool Result: ${message.content}`
} else {
return null
}
})
.filter((message) => message !== null)
.join('\n\n')
}
export const rawMemory = (memory: MemoryUnit, locale: Intl.LocalesArgument) => {
return `
---
date: ${memory.timestamp.toLocaleDateString(locale)}
time: ${memory.timestamp.toLocaleTimeString(locale)}
timezone: ${memory.timestamp.getTimezoneOffset()}
---
${rawMessages(memory.messages)}
`.trim()
}
+23
View File
@@ -0,0 +1,23 @@
import { embed } from 'xsai'
import { filterByEmbedding } from './filter'
import { EmbedParams } from './types'
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface MemorySearchParams extends EmbedParams { }
export interface MemorySearchInput {
user: string
query: string
maxResults?: number
}
export const createMemorySearch = (params: MemorySearchParams) =>
async ({ user, query, maxResults = 10 }: MemorySearchInput) => {
const { embedding } = await embed({
model: params.model,
input: query,
apiKey: params.apiKey,
baseURL: params.baseURL,
})
return await filterByEmbedding(embedding, user, maxResults)
}
+5
View File
@@ -0,0 +1,5 @@
export interface EmbedParams {
baseURL: string
apiKey: string
model: string
}