Initial commit: sage:TypeScript AI Agent 框架 monorepo(llm / memory / tui 三包)

This commit is contained in:
WpyQwq
2026-09-19 12:10:51 +08:00
commit eb29eb3fa1
47 changed files with 4429 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export * from "./store.js"
export * from "./summarizer.js"
+56
View File
@@ -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 ")
}
+86
View File
@@ -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)
}
+51
View File
@@ -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 []
}
}