Initial commit: sage:TypeScript AI Agent 框架 monorepo(llm / memory / tui 三包)
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@sage/memory",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"dependencies": {
|
||||
"@sage/core": "*",
|
||||
"nanoid": "^5.0.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./store.js"
|
||||
export * from "./summarizer.js"
|
||||
@@ -0,0 +1,56 @@
|
||||
import Database from "better-sqlite3"
|
||||
import type { Memory } from "./store.js"
|
||||
|
||||
// Recall memories using SQLite FTS5 BM25 ranking
|
||||
export function recallMemoriesRaw(
|
||||
sqlite: Database.Database,
|
||||
query: string,
|
||||
limit: number
|
||||
): Array<Memory & { score: number }> {
|
||||
const sanitized = sanitizeFtsQuery(query)
|
||||
if (!sanitized) return []
|
||||
|
||||
const rows = sqlite.prepare<{
|
||||
id: string; content: string; source: string; session_id: string | null;
|
||||
created_at: number; last_accessed: number; access_count: number; tags: string; rank: number
|
||||
}, [string, number]>(`
|
||||
SELECT m.id, m.content, m.source, m.session_id, m.created_at,
|
||||
m.last_accessed, m.access_count, m.tags, mf.rank
|
||||
FROM memories_fts mf
|
||||
JOIN memories m ON m.id = mf.id
|
||||
WHERE memories_fts MATCH ?
|
||||
ORDER BY mf.rank
|
||||
LIMIT ?
|
||||
`).all(sanitized, limit)
|
||||
|
||||
if (rows.length === 0) return []
|
||||
|
||||
// Update access stats
|
||||
const now = Date.now()
|
||||
const ids = rows.map(r => r.id)
|
||||
const placeholders = ids.map(() => "?").join(",")
|
||||
sqlite.prepare(`UPDATE memories SET last_accessed = ?, access_count = access_count + 1 WHERE id IN (${placeholders})`)
|
||||
.run(now, ...ids)
|
||||
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
content: r.content,
|
||||
source: r.source as Memory["source"],
|
||||
sessionId: r.session_id ?? undefined,
|
||||
createdAt: r.created_at,
|
||||
lastAccessed: r.last_accessed,
|
||||
accessCount: r.access_count,
|
||||
tags: JSON.parse(r.tags) as string[],
|
||||
score: -r.rank,
|
||||
}))
|
||||
}
|
||||
|
||||
function sanitizeFtsQuery(query: string): string {
|
||||
return query
|
||||
.replace(/["*^()]/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 2)
|
||||
.map(w => `"${w}"`)
|
||||
.join(" OR ")
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { nanoid } from "nanoid"
|
||||
import { getRawDb, getDb } from "@sage/core"
|
||||
import { memories } from "@sage/core"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { recallMemoriesRaw } from "./retrieval.js"
|
||||
|
||||
export interface Memory {
|
||||
id: string
|
||||
content: string
|
||||
source: "manual" | "auto" | "session"
|
||||
sessionId?: string
|
||||
createdAt: number
|
||||
lastAccessed: number
|
||||
accessCount: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface RecalledMemory extends Memory {
|
||||
score: number
|
||||
}
|
||||
|
||||
export async function addMemory(
|
||||
content: string,
|
||||
opts: { source?: Memory["source"]; sessionId?: string; tags?: string[] } = {}
|
||||
): Promise<Memory> {
|
||||
const db = getDb()
|
||||
const now = Date.now()
|
||||
const id = nanoid()
|
||||
const tags = opts.tags ?? extractTags(content)
|
||||
const mem: Memory = {
|
||||
id,
|
||||
content,
|
||||
source: opts.source ?? "manual",
|
||||
sessionId: opts.sessionId,
|
||||
createdAt: now,
|
||||
lastAccessed: now,
|
||||
accessCount: 0,
|
||||
tags,
|
||||
}
|
||||
await db.insert(memories).values({
|
||||
...mem,
|
||||
tags: JSON.stringify(tags),
|
||||
sessionId: opts.sessionId ?? null,
|
||||
})
|
||||
return mem
|
||||
}
|
||||
|
||||
export async function recallMemories(query: string, limit = 5): Promise<RecalledMemory[]> {
|
||||
const sqlite = getRawDb()
|
||||
return recallMemoriesRaw(sqlite, query, limit)
|
||||
}
|
||||
|
||||
export async function listMemories(limit = 100): Promise<Memory[]> {
|
||||
const db = getDb()
|
||||
const rows = await db.select().from(memories).limit(limit).all()
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
source: r.source as Memory["source"],
|
||||
sessionId: r.sessionId ?? undefined,
|
||||
tags: JSON.parse(r.tags) as string[],
|
||||
}))
|
||||
}
|
||||
|
||||
export async function deleteMemory(id: string): Promise<boolean> {
|
||||
const db = getDb()
|
||||
const result = await db.delete(memories).where(eq(memories.id, id))
|
||||
return ((result as unknown as { changes: number }).changes ?? 0) > 0
|
||||
}
|
||||
|
||||
export function formatMemoriesForContext(mems: RecalledMemory[]): string {
|
||||
if (mems.length === 0) return ""
|
||||
return mems.map((m, i) => `${i + 1}. ${m.content}`).join("\n")
|
||||
}
|
||||
|
||||
function extractTags(content: string): string[] {
|
||||
const words = content.toLowerCase().match(/\b[a-z][a-z0-9]{2,}\b/g) ?? []
|
||||
const stopWords = new Set(["the", "and", "for", "that", "this", "with", "from", "have", "been", "will", "not", "but"])
|
||||
const freq: Record<string, number> = {}
|
||||
for (const w of words) {
|
||||
if (!stopWords.has(w)) freq[w] = (freq[w] ?? 0) + 1
|
||||
}
|
||||
return Object.entries(freq)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8)
|
||||
.map(([w]) => w)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CoreMessage } from "ai"
|
||||
import { addMemory } from "./store.js"
|
||||
|
||||
// Auto-summarize a completed session and extract key memories
|
||||
export async function summarizeSession(
|
||||
sessionId: string,
|
||||
messages: CoreMessage[],
|
||||
summarizeFn: (prompt: string) => Promise<string>
|
||||
): Promise<string[]> {
|
||||
if (messages.length < 4) return []
|
||||
|
||||
const transcript = messages
|
||||
.filter(m => m.role === "user" || m.role === "assistant")
|
||||
.slice(-30) // last 30 messages max
|
||||
.map(m => `${m.role.toUpperCase()}: ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`)
|
||||
.join("\n\n")
|
||||
|
||||
const prompt = `You are extracting key facts and insights from a coding session transcript for long-term memory storage.
|
||||
|
||||
Extract 3-7 important, reusable pieces of information that would be valuable in future sessions:
|
||||
- Technical decisions made (architecture, libraries chosen, patterns used)
|
||||
- Bugs found and their root causes
|
||||
- User preferences or conventions discovered
|
||||
- Project-specific knowledge (file structure, APIs, domain concepts)
|
||||
- Important constraints or requirements
|
||||
|
||||
Format as a JSON array of strings, each being a self-contained, specific memory.
|
||||
Do NOT include generic observations. Each memory should be specific and actionable.
|
||||
|
||||
TRANSCRIPT:
|
||||
${transcript}
|
||||
|
||||
Return ONLY a JSON array of strings.`
|
||||
|
||||
try {
|
||||
const response = await summarizeFn(prompt)
|
||||
const extracted = JSON.parse(response.trim()) as string[]
|
||||
if (!Array.isArray(extracted)) return []
|
||||
|
||||
const stored: string[] = []
|
||||
for (const fact of extracted.slice(0, 7)) {
|
||||
if (typeof fact === "string" && fact.length > 10) {
|
||||
await addMemory(fact, { source: "auto", sessionId })
|
||||
stored.push(fact)
|
||||
}
|
||||
}
|
||||
return stored
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user