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
+93
View File
@@ -0,0 +1,93 @@
# Sage — AI 编程助手
功能上高度对应 opencode,但 UI 风格完全不同(Tokyo Night 配色 + Ink/React TUI),并额外内置**长期记忆**系统。
## 启动
```bash
# 在终端里直接运行(需要真实 TTY)
node sage.mjs
# 或者
cd packages/tui && node --import=tsx/esm src/index.tsx
```
## 配置 `~/.sage/config.json`
```json
{
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-4-6",
"theme": "tokyo-night",
"providers": {
"anthropic": { "apiKey": "sk-ant-..." },
"openai": { "apiKey": "sk-..." },
"google": { "apiKey": "..." },
"openrouter":{ "apiKey": "sk-or-..." }
},
"memory": {
"autoSummarize": true,
"maxMemories": 500,
"recallCount": 5
}
}
```
也可以通过环境变量:`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GOOGLE_API_KEY` / `OPENROUTER_API_KEY`
## 键位
| 按键 | 功能 |
|------|------|
| `^B` | 切换 session 面板焦点 |
| `↑↓` | 在 session 面板中导航 / 历史命令 |
| `Enter` | 发送消息 / 选中 session |
| `^C` | 中断当前生成 |
| `Esc` | 关闭弹窗 / 清除错误 |
## 斜线命令
| 命令 | 说明 |
|------|------|
| `/remember <文字>` | 手动保存一条长期记忆 |
| `/memories` | 浏览 & 删除所有记忆 |
| `/model <id> [provider]` | 切换模型,如 `/model gpt-4o openai` |
| `/new` | 新建 session |
| `/clear` | 清空显示 |
| `/abort` | 停止生成 |
| `/help` | 命令列表 |
## 长期记忆
- 每次发消息前,自动用 BM25 召回最相关的历史记忆注入上下文
- 底部黄色条 `◊ 3 memories recalled` 显示命中的记忆
- 每 20 条消息自动用 LLM 摘要本次会话并写入记忆库
- 数据存于 `~/.sage/sage.db`(SQLite + FTS5)
## 内置工具
| 工具 | 说明 |
|------|------|
| `bash` | 执行 shell 命令 |
| `read` | 读取文件 |
| `write` | 写入文件 |
| `edit` | 精确字符串替换 |
| `glob` | 文件模式匹配 |
| `grep` | 正则内容搜索(ripgrep) |
## 支持的 Provider
- **Anthropic** — Claude Opus 4.8 / Sonnet 4.6 / Haiku 4.5
- **OpenAI** — GPT-4o / GPT-4o-mini / o3
- **Google** — Gemini 2.5 Pro / Flash
- **OpenRouter** — 所有主流模型
## 项目结构
```
packages/
core/ — DB、session、工具系统
llm/ — 多 Provider + 流式调用循环
memory/ — 长期记忆 (FTS5 BM25 检索)
tui/ — Ink (React) TUI,Tokyo Night 风格
```
+2140
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "sage",
"version": "0.1.0",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"dev": "npm run start --workspace=packages/tui",
"build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present"
},
"devDependencies": {
"typescript": "^5.8.0"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@sage/core",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.44.0",
"nanoid": "^5.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^22.0.0",
"typescript": "^5.8.0"
}
}
+72
View File
@@ -0,0 +1,72 @@
import { existsSync, readFileSync, writeFileSync } from "fs"
import { join } from "path"
import { homedir } from "os"
export interface SageConfig {
defaultProvider: string
defaultModel: string
theme: "tokyo-night" | "catppuccin" | "gruvbox" | "minimal"
providers: {
anthropic?: { apiKey?: string }
openai?: { apiKey?: string; baseUrl?: string }
google?: { apiKey?: string }
openrouter?: { apiKey?: string }
}
memory: {
autoSummarize: boolean
maxMemories: number
recallCount: number
}
editor: string
}
const CONFIG_PATH = join(homedir(), ".sage", "config.json")
const DEFAULTS: SageConfig = {
defaultProvider: "anthropic",
defaultModel: "claude-sonnet-4-6",
theme: "tokyo-night",
providers: {},
memory: {
autoSummarize: true,
maxMemories: 500,
recallCount: 5,
},
editor: process.env.EDITOR ?? "vim",
}
let _config: SageConfig | null = null
export function loadConfig(): SageConfig {
if (_config) return _config
if (!existsSync(CONFIG_PATH)) {
_config = { ...DEFAULTS }
return _config
}
try {
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"))
_config = deepMerge(DEFAULTS, raw) as SageConfig
} catch {
_config = { ...DEFAULTS }
}
return _config
}
export function saveConfig(patch: Partial<SageConfig>) {
const current = loadConfig()
_config = deepMerge(current, patch) as SageConfig
writeFileSync(CONFIG_PATH, JSON.stringify(_config, null, 2))
}
function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
const result = { ...base }
for (const key of Object.keys(override)) {
if (override[key] !== null && typeof override[key] === "object" && !Array.isArray(override[key]) &&
typeof base[key] === "object" && base[key] !== null) {
result[key] = deepMerge(base[key] as Record<string, unknown>, override[key] as Record<string, unknown>)
} else {
result[key] = override[key]
}
}
return result
}
+117
View File
@@ -0,0 +1,117 @@
import Database from "better-sqlite3"
import { drizzle } from "drizzle-orm/better-sqlite3"
import * as schema from "./schema.js"
import { existsSync, mkdirSync } from "fs"
import { join } from "path"
import { homedir } from "os"
const DATA_DIR = join(homedir(), ".sage")
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true })
const DB_PATH = join(DATA_DIR, "sage.db")
let _db: ReturnType<typeof drizzle> | null = null
let _sqlite: Database.Database | null = null
export function getDb() {
if (!_db) {
_sqlite = new Database(DB_PATH)
_sqlite.pragma("journal_mode = WAL")
_sqlite.pragma("foreign_keys = ON")
_db = drizzle(_sqlite, { schema })
migrate(_sqlite)
}
return _db
}
export function getRawDb(): Database.Database {
getDb()
return _sqlite!
}
function migrate(sqlite: Database.Database) {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT 'New Session',
model TEXT NOT NULL,
provider TEXT NOT NULL,
cwd TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}'
)
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
tool_calls TEXT,
tool_call_id TEXT,
created_at INTEGER NOT NULL,
tokens_input INTEGER,
tokens_output INTEGER
)
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS tool_results (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
tool_call_id TEXT NOT NULL,
input TEXT NOT NULL,
output TEXT NOT NULL,
exit_code INTEGER,
created_at INTEGER NOT NULL
)
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
session_id TEXT,
created_at INTEGER NOT NULL,
last_accessed INTEGER NOT NULL,
access_count INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '[]'
)
`)
sqlite.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
USING fts5(id UNINDEXED, content, tags, content='memories', content_rowid='rowid')
`)
sqlite.exec(`
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, id, content, tags) VALUES (new.rowid, new.id, new.content, new.tags);
END
`)
sqlite.exec(`
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, id, content, tags) VALUES ('delete', old.rowid, old.id, old.content, old.tags);
INSERT INTO memories_fts(rowid, id, content, tags) VALUES (new.rowid, new.id, new.content, new.tags);
END
`)
sqlite.exec(`
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, id, content, tags) VALUES ('delete', old.rowid, old.id, old.content, old.tags);
END
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`)
}
+52
View File
@@ -0,0 +1,52 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
title: text("title").notNull().default("New Session"),
model: text("model").notNull(),
provider: text("provider").notNull(),
cwd: text("cwd").notNull(),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
metadata: text("metadata").notNull().default("{}"),
})
export const messages = sqliteTable("messages", {
id: text("id").primaryKey(),
sessionId: text("session_id").notNull(),
role: text("role").notNull(), // user | assistant | tool
content: text("content").notNull(),
toolCalls: text("tool_calls"), // JSON
toolCallId: text("tool_call_id"),
createdAt: integer("created_at").notNull(),
tokensInput: integer("tokens_input"),
tokensOutput: integer("tokens_output"),
})
export const toolResults = sqliteTable("tool_results", {
id: text("id").primaryKey(),
messageId: text("message_id").notNull(),
sessionId: text("session_id").notNull(),
toolName: text("tool_name").notNull(),
toolCallId: text("tool_call_id").notNull(),
input: text("input").notNull(),
output: text("output").notNull(),
exitCode: integer("exit_code"),
createdAt: integer("created_at").notNull(),
})
export const memories = sqliteTable("memories", {
id: text("id").primaryKey(),
content: text("content").notNull(),
source: text("source").notNull().default("manual"),
sessionId: text("session_id"),
createdAt: integer("created_at").notNull(),
lastAccessed: integer("last_accessed").notNull(),
accessCount: integer("access_count").notNull().default(0),
tags: text("tags").notNull().default("[]"),
})
export const config = sqliteTable("config", {
key: text("key").primaryKey(),
value: text("value").notNull(),
})
+7
View File
@@ -0,0 +1,7 @@
export * from "./db/client.js"
export * from "./db/schema.js"
export * from "./session/types.js"
export * from "./session/store.js"
export * from "./tool/types.js"
export * from "./tool/registry.js"
export * from "./config/index.js"
+86
View File
@@ -0,0 +1,86 @@
import { eq, desc } from "drizzle-orm"
import { getDb } from "../db/client.js"
import { sessions, messages } from "../db/schema.js"
import type { Session, Message, CreateSessionInput, ToolCall } from "./types.js"
import { nanoid } from "nanoid"
import { cwd as getCwd } from "process"
export async function createSession(input: CreateSessionInput): Promise<Session> {
const db = getDb()
const now = Date.now()
const id = nanoid()
const session: Session = {
id,
title: input.title ?? "New Session",
model: input.model,
provider: input.provider,
cwd: input.cwd ?? getCwd(),
createdAt: now,
updatedAt: now,
metadata: {},
}
await db.insert(sessions).values({
...session,
metadata: JSON.stringify(session.metadata),
})
return session
}
export async function getSession(id: string): Promise<Session | null> {
const db = getDb()
const row = await db.select().from(sessions).where(eq(sessions.id, id)).get()
if (!row) return null
return { ...row, metadata: JSON.parse(row.metadata) }
}
export async function listSessions(limit = 50): Promise<Session[]> {
const db = getDb()
const rows = await db.select().from(sessions).orderBy(desc(sessions.updatedAt)).limit(limit).all()
return rows.map(r => ({ ...r, metadata: JSON.parse(r.metadata) }))
}
export async function updateSessionTitle(id: string, title: string) {
const db = getDb()
await db.update(sessions).set({ title, updatedAt: Date.now() }).where(eq(sessions.id, id))
}
export async function deleteSession(id: string) {
const db = getDb()
await db.delete(sessions).where(eq(sessions.id, id))
}
export async function addMessage(msg: Omit<Message, "id" | "createdAt">): Promise<Message> {
const db = getDb()
const id = nanoid()
const now = Date.now()
const full: Message = { ...msg, id, createdAt: now }
await db.insert(messages).values({
...full,
toolCalls: msg.toolCalls ? JSON.stringify(msg.toolCalls) : null,
})
await db.update(sessions).set({ updatedAt: now }).where(eq(sessions.id, msg.sessionId))
return full
}
export async function getMessages(sessionId: string): Promise<Message[]> {
const db = getDb()
const rows = await db
.select()
.from(messages)
.where(eq(messages.sessionId, sessionId))
.orderBy(messages.createdAt)
.all()
return rows.map(r => ({
...r,
role: r.role as Message["role"],
toolCalls: r.toolCalls ? (JSON.parse(r.toolCalls) as ToolCall[]) : undefined,
tokensInput: r.tokensInput ?? undefined,
tokensOutput: r.tokensOutput ?? undefined,
toolCallId: r.toolCallId ?? undefined,
}))
}
export async function updateMessage(id: string, patch: Partial<Pick<Message, "content" | "tokensInput" | "tokensOutput">>) {
const db = getDb()
await db.update(messages).set(patch).where(eq(messages.id, id))
}
+45
View File
@@ -0,0 +1,45 @@
export type MessageRole = "user" | "assistant" | "tool"
export interface ToolCall {
id: string
name: string
input: Record<string, unknown>
}
export interface ToolResult {
toolCallId: string
toolName: string
output: string
exitCode?: number
error?: boolean
}
export interface Message {
id: string
sessionId: string
role: MessageRole
content: string
toolCalls?: ToolCall[]
toolCallId?: string
createdAt: number
tokensInput?: number
tokensOutput?: number
}
export interface Session {
id: string
title: string
model: string
provider: string
cwd: string
createdAt: number
updatedAt: number
metadata: Record<string, unknown>
}
export interface CreateSessionInput {
model: string
provider: string
cwd?: string
title?: string
}
+70
View File
@@ -0,0 +1,70 @@
import { z } from "zod"
import { spawn } from "child_process"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
const MAX_OUTPUT = 100_000
export const bash: ToolDef<{ command: string; description?: string; timeout?: number }> = {
name: "bash",
description: "Execute a shell command in the current working directory. Use for running tests, builds, git operations, and any shell task.",
inputSchema: z.object({
command: z.string().describe("The shell command to execute"),
description: z.string().optional().describe("Short description of what this command does"),
timeout: z.number().optional().default(120000).describe("Timeout in milliseconds"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const timeout = input.timeout ?? 120_000
return new Promise((resolve) => {
const proc = spawn(process.platform === "win32" ? "cmd" : "sh",
process.platform === "win32" ? ["/c", input.command] : ["-c", input.command],
{
cwd: ctx.cwd,
env: { ...process.env },
shell: false,
}
)
let stdout = ""
let stderr = ""
let killed = false
const timer = setTimeout(() => {
killed = true
proc.kill("SIGTERM")
}, timeout)
proc.stdout.on("data", (chunk: Buffer) => {
const text = chunk.toString()
stdout += text
ctx.onProgress?.(text)
})
proc.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString()
})
proc.on("close", (code) => {
clearTimeout(timer)
let output = ""
if (stdout) output += stdout
if (stderr) output += (output ? "\n--- stderr ---\n" : "") + stderr
if (killed) output += "\n[Process killed: timeout exceeded]"
const truncated = output.length > MAX_OUTPUT
if (truncated) output = output.slice(0, MAX_OUTPUT) + "\n... [output truncated]"
resolve({
content: output || "(no output)",
exitCode: code ?? 0,
error: (code ?? 0) !== 0,
truncated,
})
})
proc.on("error", (err) => {
clearTimeout(timer)
resolve({ content: `Error: ${err.message}`, exitCode: 1, error: true })
})
})
},
}
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod"
import { readFileSync, writeFileSync, existsSync } from "fs"
import { resolve } from "path"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
export const edit: ToolDef<{ file_path: string; old_string: string; new_string: string; replace_all?: boolean }> = {
name: "edit",
description: "Replace a specific string in a file with a new string. The old_string must match exactly (including whitespace). Use replace_all to replace every occurrence.",
inputSchema: z.object({
file_path: z.string().describe("Path to the file to edit"),
old_string: z.string().describe("The exact string to find and replace"),
new_string: z.string().describe("The string to replace it with"),
replace_all: z.boolean().optional().default(false).describe("Replace all occurrences instead of just the first"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const filePath = resolve(ctx.cwd, input.file_path)
if (!existsSync(filePath)) {
return { content: `File not found: ${filePath}`, error: true }
}
const original = readFileSync(filePath, "utf8")
if (!original.includes(input.old_string)) {
// Attempt fuzzy match to give helpful error
const lines = original.split("\n")
const needle = input.old_string.trim()
const close = lines.findIndex(l => l.includes(needle.split("\n")[0]?.trim() ?? ""))
const hint = close >= 0 ? ` (closest match at line ${close + 1})` : ""
return { content: `old_string not found in file${hint}. Ensure the text matches exactly including whitespace.`, error: true }
}
const occurrences = original.split(input.old_string).length - 1
if (occurrences > 1 && !input.replace_all) {
return {
content: `old_string appears ${occurrences} times. Provide more context to make it unique, or set replace_all=true.`,
error: true,
}
}
const updated = input.replace_all
? original.split(input.old_string).join(input.new_string)
: original.replace(input.old_string, input.new_string)
writeFileSync(filePath, updated, "utf8")
return { content: `Edited ${filePath}: replaced ${input.replace_all ? occurrences : 1} occurrence(s)` }
},
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod"
import { resolve } from "path"
import { spawnSync } from "child_process"
import { readdirSync, statSync, existsSync } from "fs"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
const MAX_RESULTS = 500
export const glob: ToolDef<{ pattern: string; path?: string }> = {
name: "glob",
description: "Find files matching a glob pattern. Returns file paths sorted by modification time.",
inputSchema: z.object({
pattern: z.string().describe("Glob pattern, e.g. '**/*.ts' or 'src/**/*.tsx'"),
path: z.string().optional().describe("Directory to search in (default: cwd)"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const searchDir = resolve(ctx.cwd, input.path ?? ".")
const results = matchGlob(input.pattern, searchDir)
const truncated = results.length > MAX_RESULTS
const listed = results.slice(0, MAX_RESULTS)
return {
content: listed.length > 0 ? listed.join("\n") : "(no matches)",
truncated,
}
},
}
function matchGlob(pattern: string, dir: string): string[] {
// Try ripgrep first for performance
const rg = spawnSync("rg", ["--files", "--glob", pattern, dir], {
encoding: "utf8",
maxBuffer: 10_000_000,
})
if (rg.status === 0) {
return rg.stdout.trim().split("\n").filter(Boolean)
}
// Fallback: simple recursive walk
return walkAndMatch(dir, dir, patternToRegex(pattern))
}
function walkAndMatch(base: string, dir: string, regex: RegExp): string[] {
if (!existsSync(dir)) return []
const results: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".")) continue
const full = `${dir}/${entry.name}`
const rel = full.slice(base.length + 1)
if (entry.isDirectory()) {
results.push(...walkAndMatch(base, full, regex))
} else if (regex.test(rel)) {
results.push(full)
}
}
return results
}
function patternToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "§DOUBLE§")
.replace(/\*/g, "[^/]*")
.replace(/§DOUBLE§/g, ".*")
.replace(/\?/g, "[^/]")
return new RegExp(`^${escaped}$`)
}
+50
View File
@@ -0,0 +1,50 @@
import { z } from "zod"
import { resolve } from "path"
import { spawnSync } from "child_process"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
const MAX_OUTPUT = 50_000
export const grep: ToolDef<{ pattern: string; path?: string; glob?: string; output_mode?: "content" | "files" | "count"; case_insensitive?: boolean; context?: number }> = {
name: "grep",
description: "Search for a regex pattern in files using ripgrep. Returns matching content or file paths.",
inputSchema: z.object({
pattern: z.string().describe("Regular expression pattern to search for"),
path: z.string().optional().describe("File or directory to search in"),
glob: z.string().optional().describe("Glob pattern to filter files, e.g. '*.ts'"),
output_mode: z.enum(["content", "files", "count"]).optional().default("content").describe("content: show matching lines, files: only file paths, count: match counts"),
case_insensitive: z.boolean().optional().default(false),
context: z.number().optional().describe("Lines of context around each match"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const searchPath = resolve(ctx.cwd, input.path ?? ".")
const args = ["--no-heading", "--color=never"]
if (input.case_insensitive) args.push("-i")
if (input.glob) args.push("--glob", input.glob)
if (input.output_mode === "files") args.push("-l")
if (input.output_mode === "count") args.push("-c")
if (input.context) args.push(`-C${input.context}`)
if (input.output_mode === "content") args.push("-n")
args.push(input.pattern, searchPath)
const result = spawnSync("rg", args, {
encoding: "utf8",
maxBuffer: MAX_OUTPUT * 2,
})
let output = (result.stdout ?? "") + (result.stderr ?? "")
if (result.error) {
// Fallback when rg not available
output = `ripgrep not found. Install it for grep support.\nError: ${result.error.message}`
return { content: output, error: true }
}
const truncated = output.length > MAX_OUTPUT
return {
content: output.slice(0, MAX_OUTPUT) || "(no matches)",
truncated,
}
},
}
+54
View File
@@ -0,0 +1,54 @@
import { z } from "zod"
import { readFileSync, existsSync, statSync, readdirSync } from "fs"
import { join, resolve } from "path"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
const MAX_FILE_SIZE = 1_000_000
const MAX_LINES = 2000
export const read: ToolDef<{ file_path: string; offset?: number; limit?: number }> = {
name: "read",
description: "Read the contents of a file. Use offset/limit for large files.",
inputSchema: z.object({
file_path: z.string().describe("Absolute or relative path to the file"),
offset: z.number().optional().describe("Line number to start reading from (1-based)"),
limit: z.number().optional().describe("Maximum number of lines to read"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const filePath = resolve(ctx.cwd, input.file_path)
if (!existsSync(filePath)) {
return { content: `File not found: ${filePath}`, error: true }
}
const stat = statSync(filePath)
if (stat.isDirectory()) {
const entries = readdirSync(filePath, { withFileTypes: true })
const listing = entries
.map(e => `${e.isDirectory() ? "d" : "f"} ${e.name}`)
.join("\n")
return { content: `Directory listing of ${filePath}:\n${listing}` }
}
if (stat.size > MAX_FILE_SIZE) {
return {
content: `File too large (${Math.round(stat.size / 1024)}KB). Use offset/limit to read specific sections.`,
error: true,
}
}
const raw = readFileSync(filePath, "utf8")
const lines = raw.split("\n")
const offset = (input.offset ?? 1) - 1
const limit = input.limit ?? MAX_LINES
const slice = lines.slice(offset, offset + limit)
const numbered = slice.map((l, i) => `${String(offset + i + 1).padStart(4, " ")}\t${l}`).join("\n")
const truncated = lines.length > offset + limit
return {
content: numbered,
truncated,
}
},
}
+20
View File
@@ -0,0 +1,20 @@
import type { AnyToolDef } from "./types.js"
import { bash } from "./bash.js"
import { read } from "./read.js"
import { write } from "./write.js"
import { edit } from "./edit.js"
import { glob } from "./glob.js"
import { grep } from "./grep.js"
export const ALL_TOOLS: AnyToolDef[] = [
bash as AnyToolDef,
read as AnyToolDef,
write as AnyToolDef,
edit as AnyToolDef,
glob as AnyToolDef,
grep as AnyToolDef,
]
export function getTool(name: string): AnyToolDef | undefined {
return ALL_TOOLS.find(t => t.name === name)
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod"
export interface ToolDef<TInput = Record<string, unknown>> {
name: string
description: string
inputSchema: z.ZodType<TInput>
execute: (input: TInput, context: ToolContext) => Promise<ToolOutput>
}
export interface ToolContext {
sessionId: string
cwd: string
onProgress?: (text: string) => void
}
export interface ToolOutput {
content: string
exitCode?: number
error?: boolean
truncated?: boolean
}
export type AnyToolDef = ToolDef<Record<string, unknown>>
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod"
import { writeFileSync, mkdirSync, existsSync } from "fs"
import { resolve, dirname } from "path"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
export const write: ToolDef<{ file_path: string; content: string }> = {
name: "write",
description: "Write content to a file, creating it and any parent directories if needed. Overwrites existing files.",
inputSchema: z.object({
file_path: z.string().describe("Path to the file to write"),
content: z.string().describe("Content to write to the file"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const filePath = resolve(ctx.cwd, input.file_path)
const dir = dirname(filePath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(filePath, input.content, "utf8")
const lines = input.content.split("\n").length
return { content: `Wrote ${lines} lines to ${filePath}` }
},
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@sage/llm",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"dependencies": {
"@sage/core": "*",
"ai": "^4.3.0",
"@ai-sdk/anthropic": "^1.0.0",
"@ai-sdk/openai": "^1.0.0",
"@ai-sdk/google": "^1.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0"
}
}
+148
View File
@@ -0,0 +1,148 @@
import { streamText, tool, type CoreMessage, type CoreTool, type TextStreamPart } from "ai"
import { z } from "zod"
import type { SageConfig, AnyToolDef, ToolContext } from "@sage/core"
import { ALL_TOOLS, getTool, addMessage, updateMessage } from "@sage/core"
import { getLanguageModel, type ProviderID } from "./providers/index.js"
import type { LLMEventEmitter } from "./types.js"
const SYSTEM_PROMPT = `You are Sage, an expert AI coding assistant. You help users write, debug, and understand code.
You have access to tools to read/write files, run shell commands, search code, and more.
Always prefer using tools to gather information before answering.
When editing files, use the edit tool for small changes and write for large rewrites.
Be concise but complete. Show your reasoning when working through complex problems.`
export interface RunOptions {
sessionId: string
messages: CoreMessage[]
model: string
provider: string
cwd: string
config: SageConfig
memoryContext?: string
onEvent: LLMEventEmitter
signal?: AbortSignal
}
export async function runLLM(opts: RunOptions): Promise<void> {
const { sessionId, messages, model, provider, cwd, config, onEvent, signal } = opts
const toolContext: ToolContext = {
sessionId,
cwd,
onProgress: (text) => {
/* incremental tool output — currently unused at this level */
},
}
const aiTools: Record<string, CoreTool> = {}
for (const t of ALL_TOOLS) {
aiTools[t.name] = tool({
description: t.description,
parameters: t.inputSchema as z.ZodType<Record<string, unknown>>,
execute: async (input) => {
const result = await t.execute(input as Record<string, unknown>, toolContext)
return result
},
})
}
const systemParts: string[] = [SYSTEM_PROMPT]
if (opts.memoryContext) {
systemParts.push(`\n## Recalled Memories\n${opts.memoryContext}`)
}
systemParts.push(`\nCurrent working directory: ${cwd}`)
const lm = getLanguageModel(provider as ProviderID, model, config)
const currentMessages = [...messages]
let assistantMsgId: string | null = null
let accText = ""
const stream = streamText({
model: lm,
system: systemParts.join("\n"),
messages: currentMessages,
tools: aiTools,
maxSteps: 20,
abortSignal: signal,
})
for await (const part of stream.fullStream) {
if (signal?.aborted) break
switch (part.type) {
case "text-delta": {
if (!assistantMsgId) {
const msg = await addMessage({
sessionId,
role: "assistant",
content: "",
})
assistantMsgId = msg.id
}
accText += part.textDelta
await updateMessage(assistantMsgId, { content: accText })
onEvent({ type: "text-delta", delta: part.textDelta })
break
}
case "tool-call": {
onEvent({
type: "tool-call",
toolCall: {
id: part.toolCallId,
name: part.toolName,
input: part.args as Record<string, unknown>,
},
})
break
}
case "tool-result": {
const output = typeof part.result === "string"
? part.result
: JSON.stringify(part.result, null, 2)
await addMessage({
sessionId,
role: "tool",
content: output,
toolCallId: part.toolCallId,
})
onEvent({
type: "tool-result",
toolResult: {
toolCallId: part.toolCallId,
toolName: part.toolName,
output,
},
})
break
}
case "finish": {
const usage = part.usage
if (assistantMsgId && usage) {
await updateMessage(assistantMsgId, {
tokensInput: usage.promptTokens,
tokensOutput: usage.completionTokens,
})
}
onEvent({
type: "finish",
usage: usage
? { inputTokens: usage.promptTokens, outputTokens: usage.completionTokens }
: undefined,
})
break
}
case "error": {
onEvent({ type: "error", error: part.error as Error })
break
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./client.js"
export * from "./providers/index.js"
export * from "./types.js"
+75
View File
@@ -0,0 +1,75 @@
import { createAnthropic } from "@ai-sdk/anthropic"
import { createOpenAI } from "@ai-sdk/openai"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import type { LanguageModelV1 } from "ai"
import type { SageConfig } from "@sage/core"
export type ProviderID = "anthropic" | "openai" | "google" | "openrouter"
export interface ProviderModel {
id: string
name: string
contextWindow: number
supportsTools: boolean
}
export const PROVIDER_MODELS: Record<ProviderID, ProviderModel[]> = {
anthropic: [
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", contextWindow: 200_000, supportsTools: true },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", contextWindow: 200_000, supportsTools: true },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", contextWindow: 200_000, supportsTools: true },
],
openai: [
{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000, supportsTools: true },
{ id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000, supportsTools: true },
{ id: "o3", name: "o3", contextWindow: 200_000, supportsTools: true },
],
google: [
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextWindow: 1_000_000, supportsTools: true },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", contextWindow: 1_000_000, supportsTools: true },
],
openrouter: [
{ id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6 (OR)", contextWindow: 200_000, supportsTools: true },
{ id: "openai/gpt-4o", name: "GPT-4o (OR)", contextWindow: 128_000, supportsTools: true },
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro (OR)", contextWindow: 1_000_000, supportsTools: true },
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1 (OR)", contextWindow: 64_000, supportsTools: true },
],
}
export function getLanguageModel(provider: ProviderID, modelId: string, config: SageConfig): LanguageModelV1 {
const providerCfg = config.providers[provider] ?? {}
switch (provider) {
case "anthropic": {
const apiKey = (providerCfg as { apiKey?: string }).apiKey ?? process.env.ANTHROPIC_API_KEY
const client = createAnthropic({ apiKey })
return client(modelId) as LanguageModelV1
}
case "openai": {
const cfg = providerCfg as { apiKey?: string; baseUrl?: string }
const apiKey = cfg.apiKey ?? process.env.OPENAI_API_KEY
const client = createOpenAI({ apiKey, baseURL: cfg.baseUrl })
return client(modelId) as LanguageModelV1
}
case "google": {
const apiKey = (providerCfg as { apiKey?: string }).apiKey ?? process.env.GOOGLE_API_KEY
const client = createGoogleGenerativeAI({ apiKey })
return client(modelId) as LanguageModelV1
}
case "openrouter": {
const apiKey = (providerCfg as { apiKey?: string }).apiKey ?? process.env.OPENROUTER_API_KEY
const client = createOpenAI({
apiKey,
baseURL: "https://openrouter.ai/api/v1",
headers: { "HTTP-Referer": "https://github.com/sage-ai", "X-Title": "Sage" },
})
return client(modelId) as LanguageModelV1
}
default:
throw new Error(`Unknown provider: ${provider}`)
}
}
export function listProviders(): ProviderID[] {
return ["anthropic", "openai", "google", "openrouter"]
}
+22
View File
@@ -0,0 +1,22 @@
import type { CoreMessage, CoreTool, StreamTextResult } from "ai"
import type { ToolCall, ToolResult } from "@sage/core"
export interface LLMRequest {
messages: CoreMessage[]
tools?: Record<string, CoreTool>
model: string
provider: string
maxSteps?: number
systemPrompt?: string
}
export interface LLMStreamEvent {
type: "text-delta" | "tool-call" | "tool-result" | "finish" | "error"
delta?: string
toolCall?: { id: string; name: string; input: Record<string, unknown> }
toolResult?: ToolResult
usage?: { inputTokens: number; outputTokens: number }
error?: Error
}
export type LLMEventEmitter = (event: LLMStreamEvent) => void
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+16
View File
@@ -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"
}
}
+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 []
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@sage/tui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node --import=tsx/esm src/index.tsx",
"dev": "node --import=tsx/esm --watch src/index.tsx",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sage/core": "*",
"@sage/llm": "*",
"@sage/memory": "*",
"ink": "^5.1.0",
"ink-text-input": "^6.0.0",
"react": "^18.3.0",
"chalk": "^5.4.0",
"figures": "^6.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}
}
+101
View File
@@ -0,0 +1,101 @@
import React from "react"
import { Box, Text, useInput } from "ink"
import { colors } from "./theme/index.js"
import { MessageList } from "./components/MessageList.js"
import { InputBar } from "./components/InputBar.js"
import { MemoryBar } from "./components/MemoryBar.js"
import { MemoryPanel } from "./components/MemoryPanel.js"
import { SessionOverlay } from "./components/SessionOverlay.js"
import { Footer } from "./components/Footer.js"
import { useAppState } from "./hooks/useAppState.js"
import { useState } from "react"
export function App() {
const [sessionCursor, setSessionCursor] = useState(0)
const { state, patch, submitMessage, selectSession, newSession, removeSession, abort, config } = useAppState()
useInput((_input, key) => {
if (state.openOverlay) return
if (key.ctrl && _input === "k") { patch({ openOverlay: "sessions" }); return }
if (key.escape && state.error) { patch({ error: null }); return }
})
const model = state.currentSession?.model ?? config.defaultModel
const provider = state.currentSession?.provider ?? config.defaultProvider
const cwd = state.currentSession?.cwd ?? process.cwd()
if (state.openOverlay === "sessions") {
return (
<SessionOverlay
sessions={state.sessions}
cursor={sessionCursor}
setCursor={setSessionCursor}
onSelect={s => { selectSession(s); patch({ openOverlay: null }) }}
onNew={() => { newSession(); patch({ openOverlay: null }) }}
onDelete={id => { removeSession(id); setSessionCursor(c => Math.max(0, c - 1)) }}
onClose={() => patch({ openOverlay: null })}
/>
)
}
if (state.openOverlay === "memories") {
return <MemoryPanel onClose={() => patch({ openOverlay: null })} />
}
return (
<Box flexDirection="column">
{/* Session title */}
<Box paddingLeft={2} paddingRight={2} paddingTop={1} marginBottom={1}>
<Text color={colors.muted}>sage </Text>
{state.currentSession
? <Text color={colors.text}>{state.currentSession.title}</Text>
: <Text color={colors.subtle}>no session</Text>
}
</Box>
{/* Welcome */}
{!state.currentSession && (
<Box flexDirection="column" paddingLeft={2} marginBottom={1}>
<Text color={colors.subtle}>start typing to begin, or ^K to switch sessions</Text>
</Box>
)}
{/* Messages */}
{state.currentSession && (
<MessageList
messages={state.messages}
toolCalls={state.toolCalls}
streamBuffer={state.streamBuffer}
isStreaming={state.isStreaming}
/>
)}
{/* Error/info */}
{state.error && (
<Box paddingLeft={2} marginTop={1}>
<Text color={state.error.startsWith("✓") ? colors.green : colors.red}>
{state.error}
</Text>
{!state.error.startsWith("✓") && <Text color={colors.subtle}> esc</Text>}
</Box>
)}
{/* Memory recall indicator */}
<MemoryBar memories={state.recalledMemories} />
{/* Input */}
<InputBar onSubmit={submitMessage} isStreaming={state.isStreaming} onAbort={abort} />
{/* Footer */}
<Footer
model={model}
provider={provider}
cwd={cwd}
isStreaming={state.isStreaming}
usage={state.usage}
memoryCount={state.recalledMemories.length}
sessionTitle={state.currentSession?.title ?? null}
/>
</Box>
)
}
+49
View File
@@ -0,0 +1,49 @@
import React from "react"
import { Box, Text } from "ink"
import { colors, sym } from "../theme/index.js"
import { basename } from "path"
interface Props {
model: string
provider: string
cwd: string
isStreaming: boolean
usage: { input: number; output: number }
memoryCount: number
sessionTitle: string | null
}
export function Footer({ model, provider, cwd, isStreaming, usage, memoryCount, sessionTitle }: Props) {
const dir = basename(cwd) || cwd
const modelShort = model.replace(/claude-/, "").replace(/-\d{8}$/, "")
return (
<Box flexDirection="column">
{/* divider */}
<Box paddingLeft={2} paddingRight={2}>
<Text color={colors.subtle}>
{"─".repeat(60)}
</Text>
</Box>
{/* status line */}
<Box paddingLeft={2} paddingRight={2} justifyContent="space-between">
<Box gap={2}>
<Text color={colors.muted}>{provider}</Text>
<Text color={colors.subtle}>{sym.dot}</Text>
<Text color={colors.muted}>{modelShort}</Text>
{isStreaming && <Text color={colors.orange}>{sym.dot} streaming</Text>}
</Box>
<Box gap={2}>
{memoryCount > 0 && (
<Text color={colors.yellow}>{sym.memory} {memoryCount}</Text>
)}
{usage.output > 0 && (
<Text color={colors.subtle}>{usage.output}tok</Text>
)}
<Text color={colors.muted}>{dir}</Text>
</Box>
</Box>
</Box>
)
}
+56
View File
@@ -0,0 +1,56 @@
import React, { useState, useCallback } from "react"
import { Box, Text, useInput } from "ink"
import TextInput from "ink-text-input"
import { colors, sym } from "../theme/index.js"
interface Props {
onSubmit: (text: string) => void
isStreaming: boolean
onAbort: () => void
}
export function InputBar({ onSubmit, isStreaming, onAbort }: Props) {
const [value, setValue] = useState("")
const [history, setHistory] = useState<string[]>([])
const [historyIdx, setHistoryIdx] = useState(-1)
useInput((input, key) => {
if (isStreaming && key.ctrl && input === "c") { onAbort(); return }
if (!isStreaming && key.upArrow) {
const idx = Math.min(historyIdx + 1, history.length - 1)
if (idx >= 0) { setHistoryIdx(idx); setValue(history[history.length - 1 - idx] ?? "") }
}
if (!isStreaming && key.downArrow) {
const idx = historyIdx - 1
if (idx < 0) { setHistoryIdx(-1); setValue("") }
else { setHistoryIdx(idx); setValue(history[history.length - 1 - idx] ?? "") }
}
})
const handleSubmit = useCallback((text: string) => {
if (!text.trim() || isStreaming) return
setHistory(h => [...h.slice(-49), text])
setHistoryIdx(-1)
setValue("")
onSubmit(text)
}, [onSubmit, isStreaming])
return (
<Box paddingLeft={2} paddingRight={2} marginTop={1}>
{isStreaming
? <Text color={colors.muted}>~ generating… ^C to abort</Text>
: <>
<Text color={colors.orange}>{sym.prompt} </Text>
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder="message…"
placeholderColor={colors.subtle}
/>
</>
}
</Box>
)
}
+20
View File
@@ -0,0 +1,20 @@
import React from "react"
import { Box, Text } from "ink"
import { colors, sym } from "../theme/index.js"
import type { RecalledMemory } from "@sage/memory"
interface Props {
memories: RecalledMemory[]
}
export function MemoryBar({ memories }: Props) {
if (memories.length === 0) return null
const preview = memories[0]?.content.slice(0, 55) ?? ""
const more = memories.length > 1 ? ` +${memories.length - 1}` : ""
return (
<Box paddingLeft={2} marginTop={1}>
<Text color={colors.yellow}>{sym.memory} </Text>
<Text color={colors.muted}>{preview}{more}</Text>
</Box>
)
}
@@ -0,0 +1,53 @@
import React, { useState, useEffect } from "react"
import { Box, Text, useInput } from "ink"
import { colors, sym } from "../theme/index.js"
import { listMemories, deleteMemory, type Memory } from "@sage/memory"
interface Props { onClose: () => void }
export function MemoryPanel({ onClose }: Props) {
const [mems, setMems] = useState<Memory[]>([])
const [cursor, setCursor] = useState(0)
useEffect(() => { listMemories().then(setMems) }, [])
useInput((input, key) => {
if (key.escape || input === "q") { onClose(); return }
if (key.upArrow) { setCursor(c => Math.max(0, c - 1)); return }
if (key.downArrow) { setCursor(c => Math.min(mems.length - 1, c + 1)); return }
if (input === "d" && mems[cursor]) {
deleteMemory(mems[cursor].id).then(() =>
listMemories().then(m => { setMems(m); setCursor(c => Math.min(c, m.length - 1)) })
)
}
})
return (
<Box flexDirection="column" paddingLeft={2} paddingTop={1}>
<Box marginBottom={1}>
<Text color={colors.muted}>{sym.memory} memories </Text>
<Text color={colors.subtle}>↑↓ navigate · d delete · esc close</Text>
</Box>
{mems.length === 0 && (
<Text color={colors.subtle}> no memories — use /remember &lt;text&gt;</Text>
)}
{mems.slice(0, 30).map((m, i) => {
const active = i === cursor
const text = m.content.length > 70 ? m.content.slice(0, 69) + "…" : m.content
return (
<Box key={m.id}>
<Text color={active ? colors.yellow : colors.subtle}>
{active ? `${sym.user} ` : " "}
</Text>
<Text color={active ? colors.text : colors.muted}>{text}</Text>
</Box>
)
})}
{mems.length > 30 && (
<Text color={colors.subtle}> … {mems.length - 30} more</Text>
)}
</Box>
)
}
+130
View File
@@ -0,0 +1,130 @@
import React from "react"
import { Box, Text } from "ink"
import { colors, toolIcon, sym } from "../theme/index.js"
import type { Message } from "@sage/core"
import type { ActiveToolCall } from "../hooks/useAppState.js"
interface Props {
messages: Message[]
toolCalls: ActiveToolCall[]
streamBuffer: string
isStreaming: boolean
}
export function MessageList({ messages, toolCalls, streamBuffer, isStreaming }: Props) {
const display = messages.filter(m => m.role !== "tool")
return (
<Box flexDirection="column" paddingLeft={2} paddingRight={2} flexGrow={1}>
{display.map((msg, i) => (
<MsgItem key={msg.id} msg={msg} first={i === 0} />
))}
{/* In-flight tool calls */}
{toolCalls.map(tc => (
<ToolLine key={tc.id} tc={tc} />
))}
{/* Streaming assistant text */}
{isStreaming && streamBuffer && (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text color={colors.green}>{sym.assistant} </Text>
<Text color={colors.text}>{streamBuffer}</Text>
<Text color={colors.muted}>▊</Text>
</Box>
</Box>
)}
{isStreaming && !streamBuffer && toolCalls.length === 0 && (
<Box marginTop={1}>
<Text color={colors.muted}>~ thinking…</Text>
</Box>
)}
</Box>
)
}
function MsgItem({ msg, first }: { msg: Message; first: boolean }) {
if (msg.role === "user") {
return (
<Box flexDirection="column" marginTop={first ? 0 : 1}>
{msg.content.split("\n").map((line, i) => (
<Box key={i}>
<Text color={colors.blue}>{sym.user} </Text>
<Text color={colors.text} bold={i === 0}>{line}</Text>
</Box>
))}
</Box>
)
}
if (msg.role === "assistant") {
const lines = msg.content.trim().split("\n")
return (
<Box flexDirection="column" marginTop={1}>
{lines.map((line, i) => (
<Box key={i}>
{i === 0
? <Text color={colors.green}>{sym.assistant} </Text>
: <Text>{" "}</Text>
}
<Text color={colors.text}>{line}</Text>
</Box>
))}
{msg.tokensOutput != null && (
<Box marginTop={0}>
<Text color={colors.subtle}>{" "}{sym.dot} {msg.tokensOutput} tokens</Text>
</Box>
)}
</Box>
)
}
return null
}
function ToolLine({ tc }: { tc: ActiveToolCall }) {
const icon = toolIcon[tc.name] ?? toolIcon.default
const isDone = tc.status === "done"
const isErr = tc.status === "error"
const color = isErr ? colors.red : isDone ? colors.muted : colors.orange
// Show most relevant input arg as a short label
const label = getLabel(tc.name, tc.input)
// Output preview: first non-empty line, max 80 chars
const preview = tc.output
? tc.output.split("\n").find(l => l.trim()) ?? ""
: ""
const previewTrunc = preview.length > 80 ? preview.slice(0, 79) + "…" : preview
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text color={color}>
{isDone ? sym.check : isErr ? sym.cross : sym.running}{" "}
</Text>
<Text color={colors.muted}>{icon} </Text>
<Text color={isDone ? colors.muted : colors.text}>{label}</Text>
</Box>
{previewTrunc && (
<Box paddingLeft={4}>
<Text color={colors.subtle}>{previewTrunc}</Text>
</Box>
)}
</Box>
)
}
function getLabel(name: string, input: Record<string, unknown>): string {
switch (name) {
case "bash": return String(input.command ?? "").slice(0, 72)
case "read": return String(input.file_path ?? "")
case "write": return String(input.file_path ?? "")
case "edit": return String(input.file_path ?? "")
case "glob": return String(input.pattern ?? "")
case "grep": return `${input.pattern ?? ""} ${input.path ?? ""}`.trim()
default: return name
}
}
@@ -0,0 +1,60 @@
import React from "react"
import { Box, Text, useInput } from "ink"
import { colors, sym } from "../theme/index.js"
import type { Session } from "@sage/core"
interface Props {
sessions: Session[]
cursor: number
onSelect: (s: Session) => void
onNew: () => void
onDelete: (id: string) => void
onClose: () => void
setCursor: (n: number) => void
}
export function SessionOverlay({ sessions, cursor, onSelect, onNew, onDelete, onClose, setCursor }: Props) {
useInput((input, key) => {
if (key.escape || input === "q") { onClose(); return }
if (key.upArrow) { setCursor(Math.max(0, cursor - 1)); return }
if (key.downArrow) { setCursor(Math.min(sessions.length - 1, cursor + 1)); return }
if (key.return) { const s = sessions[cursor]; if (s) { onSelect(s); onClose() } return }
if (input === "n") { onNew(); onClose(); return }
if (input === "d") {
const s = sessions[cursor]
if (s) { onDelete(s.id); setCursor(Math.max(0, cursor - 1)) }
return
}
})
return (
<Box flexDirection="column" paddingLeft={2} paddingTop={1}>
<Box marginBottom={1}>
<Text color={colors.muted}>sessions </Text>
<Text color={colors.subtle}>↑↓ navigate · enter select · n new · d delete · esc close</Text>
</Box>
{sessions.length === 0 && (
<Text color={colors.subtle}> no sessions yet — press n to start</Text>
)}
{sessions.map((s, i) => {
const active = i === cursor
const title = s.title.length > 60 ? s.title.slice(0, 59) + "…" : s.title
return (
<Box key={s.id}>
<Text color={active ? colors.blue : colors.subtle}>
{active ? `${sym.user} ` : " "}
</Text>
<Text color={active ? colors.text : colors.muted}>{title}</Text>
{active && (
<Text color={colors.subtle}>
{" "}{new Date(s.updatedAt).toLocaleDateString()}
</Text>
)}
</Box>
)
})}
</Box>
)
}
@@ -0,0 +1,50 @@
import React from "react"
import { Box, Text, useStdout } from "ink"
import { colors, symbols } from "../theme/index.js"
import type { Session } from "@sage/core"
interface Props {
sessions: Session[]
current: Session | null
onSelect: (s: Session) => void
onNew: () => void
}
export function SessionPanel({ sessions, current, onSelect, onNew }: Props) {
const { stdout } = useStdout()
return (
<Box flexDirection="column" borderStyle="single" borderColor={colors.border} width={28} paddingX={1}>
<Box marginBottom={1}>
<Text color={colors.purple} bold>◈ SAGE</Text>
<Text color={colors.fgDim}> sessions</Text>
</Box>
{sessions.length === 0 && (
<Text color={colors.fgDim} italic> No sessions yet</Text>
)}
{sessions.slice(0, 20).map((s, i) => {
const isCurrent = s.id === current?.id
const title = s.title.length > 20 ? s.title.slice(0, 19) + symbols.ellipsis : s.title
return (
<Box key={s.id} marginBottom={0}>
<Text
color={isCurrent ? colors.blue : colors.fgDim}
bold={isCurrent}
>
{isCurrent ? "▶ " : " "}{title}
</Text>
</Box>
)
})}
<Box marginTop={1} borderStyle="single" borderColor={colors.fgMuted}>
<Text color={colors.fgDim}> n new session</Text>
</Box>
<Box>
<Text color={colors.fgDim}> ↑↓ navigate</Text>
</Box>
</Box>
)
}
+36
View File
@@ -0,0 +1,36 @@
import React from "react"
import { Box, Text } from "ink"
import { colors, symbols } from "../theme/index.js"
interface Props {
model: string
provider: string
cwd: string
isStreaming: boolean
usage: { input: number; output: number }
memoryCount: number
}
export function StatusBar({ model, provider, cwd, isStreaming, usage, memoryCount }: Props) {
const cwdShort = cwd.replace(/^.*[/\\]/, "")
return (
<Box paddingX={1} justifyContent="space-between" borderStyle="single" borderColor={colors.fgMuted}>
<Box gap={2}>
<Text color={colors.purple}>◈ sage</Text>
<Text color={colors.blue}>{provider}</Text>
<Text color={colors.cyan}>{model}</Text>
{isStreaming && <Text color={colors.yellow}>● streaming</Text>}
</Box>
<Box gap={2}>
{memoryCount > 0 && (
<Text color={colors.orange}>{symbols.memory}{memoryCount}</Text>
)}
{(usage.input > 0 || usage.output > 0) && (
<Text color={colors.fgDim}>↑{usage.input} ↓{usage.output}</Text>
)}
<Text color={colors.fgDim}>{cwdShort}</Text>
</Box>
</Box>
)
}
+252
View File
@@ -0,0 +1,252 @@
import React, { useState, useEffect, useCallback } from "react"
import type { Session, Message } from "@sage/core"
import {
listSessions, createSession, getMessages, addMessage,
deleteSession, updateSessionTitle, loadConfig, getDb,
} from "@sage/core"
import type { CoreMessage } from "ai"
import {
recallMemories, formatMemoriesForContext, addMemory,
summarizeSession, type RecalledMemory,
} from "@sage/memory"
import { runLLM, getLanguageModel, type ProviderID } from "@sage/llm"
import { generateText } from "ai"
export interface ActiveToolCall {
id: string
name: string
input: Record<string, unknown>
output?: string
status: "running" | "done" | "error"
}
export interface AppState {
sessions: Session[]
currentSession: Session | null
messages: Message[]
toolCalls: ActiveToolCall[]
isStreaming: boolean
streamBuffer: string
recalledMemories: RecalledMemory[]
error: string | null
usage: { input: number; output: number }
abortController: AbortController | null
openOverlay: "sessions" | "memories" | null
}
export function useAppState() {
const config = loadConfig()
const [state, setState] = useState<AppState>({
sessions: [],
currentSession: null,
messages: [],
toolCalls: [],
isStreaming: false,
streamBuffer: "",
recalledMemories: [],
error: null,
usage: { input: 0, output: 0 },
abortController: null,
openOverlay: null,
})
const patch = useCallback(
(p: Partial<AppState> | ((prev: AppState) => Partial<AppState>)) =>
setState(s => ({ ...s, ...(typeof p === "function" ? p(s) : p) })),
[]
)
useEffect(() => { loadSessions() }, [])
async function loadSessions() {
const sessions = await listSessions()
if (sessions.length > 0) {
const msgs = await getMessages(sessions[0].id)
setState(s => ({ ...s, sessions, currentSession: sessions[0], messages: msgs }))
} else {
setState(s => ({ ...s, sessions }))
}
}
async function selectSession(session: Session) {
const messages = await getMessages(session.id)
patch({ currentSession: session, messages, toolCalls: [], streamBuffer: "", recalledMemories: [], error: null })
}
async function newSession() {
const session = await createSession({ model: config.defaultModel, provider: config.defaultProvider })
const sessions = await listSessions()
patch({ sessions, currentSession: session, messages: [], toolCalls: [], recalledMemories: [], error: null })
}
async function removeSession(id: string) {
await deleteSession(id)
const sessions = await listSessions()
if (sessions.length > 0) {
const messages = await getMessages(sessions[0].id)
patch({ sessions, currentSession: sessions[0], messages })
} else {
patch({ sessions, currentSession: null, messages: [] })
}
}
async function submitMessage(text: string) {
if (!text.trim() || state.isStreaming) return
if (text.startsWith("/")) { await handleCommand(text); return }
let session = state.currentSession
if (!session) {
session = await createSession({ model: config.defaultModel, provider: config.defaultProvider, title: text.slice(0, 50) })
const sessions = await listSessions()
patch({ sessions, currentSession: session })
}
const recalled = await recallMemories(text, config.memory.recallCount)
const memoryCtx = formatMemoriesForContext(recalled)
const userMsg = await addMessage({ sessionId: session.id, role: "user", content: text })
// Auto-title on first message
if (state.messages.length === 0 && session.title === "New Session") {
const t = text.slice(0, 60).replace(/\n/g, " ")
await updateSessionTitle(session.id, t)
const sessions = await listSessions()
patch({ sessions, currentSession: { ...session, title: t } })
session = { ...session, title: t }
}
const allMessages = [...state.messages, userMsg]
patch({ messages: allMessages, recalledMemories: recalled, isStreaming: true, streamBuffer: "", toolCalls: [], error: null })
const coreMessages = buildCoreMessages(allMessages)
const ac = new AbortController()
patch({ abortController: ac })
let localToolCalls: ActiveToolCall[] = []
try {
await runLLM({
sessionId: session.id,
messages: coreMessages,
model: session.model,
provider: session.provider,
cwd: session.cwd,
config,
memoryContext: memoryCtx || undefined,
signal: ac.signal,
onEvent: (event) => {
switch (event.type) {
case "text-delta":
patch(s => ({ streamBuffer: s.streamBuffer + (event.delta ?? "") }))
break
case "tool-call": {
const tc: ActiveToolCall = { id: event.toolCall!.id, name: event.toolCall!.name, input: event.toolCall!.input, status: "running" }
localToolCalls = [...localToolCalls, tc]
patch({ toolCalls: localToolCalls })
break
}
case "tool-result": {
localToolCalls = localToolCalls.map(tc =>
tc.id === event.toolResult?.toolCallId
? { ...tc, output: event.toolResult!.output, status: "done" as const }
: tc
)
patch({ toolCalls: localToolCalls })
break
}
case "finish":
if (event.usage) patch(s => ({ usage: { input: s.usage.input + event.usage!.inputTokens, output: s.usage.output + event.usage!.outputTokens } }))
break
case "error":
patch({ error: event.error?.message ?? "Unknown error" })
break
}
},
})
} catch (err: unknown) {
if ((err as Error)?.name !== "AbortError") patch({ error: (err as Error)?.message ?? "Request failed" })
}
const updated = await getMessages(session.id)
patch({ messages: updated, isStreaming: false, streamBuffer: "", abortController: null })
if (config.memory.autoSummarize && updated.length > 0 && updated.length % 20 === 0) {
autoSummarize(session, updated)
}
}
async function autoSummarize(session: Session, msgs: Message[]) {
try {
const lm = getLanguageModel(session.provider as ProviderID, session.model, config)
await summarizeSession(session.id, buildCoreMessages(msgs), async (prompt) => {
const r = await generateText({ model: lm, prompt, maxTokens: 800 })
return r.text
})
} catch { /* non-critical */ }
}
async function handleCommand(cmd: string) {
const parts = cmd.trim().split(/\s+/)
const command = parts[0]?.toLowerCase()
switch (command) {
case "/remember": {
const content = parts.slice(1).join(" ")
if (!content) { patch({ error: "usage: /remember <text>" }); break }
await addMemory(content, { source: "manual" })
patch({ error: "✓ memory saved" })
setTimeout(() => patch({ error: null }), 2000)
break
}
case "/memories": patch({ openOverlay: "memories" }); break
case "/sessions": patch({ openOverlay: "sessions" }); break
case "/new": await newSession(); break
case "/clear": patch({ messages: [], toolCalls: [], streamBuffer: "" }); break
case "/abort": abort(); break
case "/model": {
const model = parts[1]
const provider = parts[2] ?? state.currentSession?.provider ?? config.defaultProvider
if (!model) { patch({ error: "usage: /model <id> [provider]" }); break }
if (state.currentSession) {
patch({ currentSession: { ...state.currentSession, model, provider }, error: `✓ switched to ${provider}/${model}` })
setTimeout(() => patch({ error: null }), 2000)
}
break
}
case "/help":
patch({ error: "/remember <t> /memories /sessions /model <id> [prov] /new /clear /abort ^K sessions" })
break
default:
patch({ error: `unknown command: ${command} — /help for list` })
}
}
function abort() {
state.abortController?.abort()
patch({ isStreaming: false, abortController: null })
}
return { state, patch, submitMessage, selectSession, newSession, removeSession, abort, config }
}
function buildCoreMessages(msgs: Message[]): CoreMessage[] {
const result: CoreMessage[] = []
for (const m of msgs) {
if (m.role === "user") {
result.push({ role: "user", content: m.content })
} else if (m.role === "assistant") {
if (m.toolCalls?.length) {
result.push({
role: "assistant",
content: [
...(m.content ? [{ type: "text" as const, text: m.content }] : []),
...m.toolCalls.map(tc => ({ type: "tool-call" as const, toolCallId: tc.id, toolName: tc.name, args: tc.input })),
],
})
} else {
result.push({ role: "assistant", content: m.content })
}
} else if (m.role === "tool") {
result.push({ role: "tool", content: [{ type: "tool-result" as const, toolCallId: m.toolCallId ?? "", result: m.content }] })
}
}
return result
}
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env node
import React from "react"
import { render } from "ink"
import { App } from "./app.js"
import { getDb, loadConfig } from "@sage/core"
getDb()
loadConfig()
const { unmount } = render(<App />, { exitOnCtrlC: false })
process.on("SIGINT", () => { unmount(); process.exit(0) })
process.on("SIGTERM", () => { unmount(); process.exit(0) })
+42
View File
@@ -0,0 +1,42 @@
// Opencode-inspired minimal palette
export const colors = {
bg: "#0a0a0a",
panel: "#141414",
element: "#1e1e1e",
text: "#eeeeee",
muted: "#808080",
subtle: "#484848",
focus: "#606060",
blue: "#7aa2f7",
green: "#7fd88f",
orange: "#fab283",
purple: "#9d7cd8",
cyan: "#56b6c2",
red: "#f7768e",
yellow: "#e0af68",
} as const
// Single-char tool icons (2-char wide with trailing space)
export const toolIcon: Record<string, string> = {
bash: "$",
read: "→",
write: "←",
edit: "←",
glob: "✱",
grep: "✱",
default: "·",
}
export const sym = {
user: "▌", // left-border marker for user messages
assistant: "◆",
memory: "◊",
check: "✓",
cross: "✗",
running: "~",
prompt: "❯",
dot: "·",
bar: "─",
} as const
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { fileURLToPath, pathToFileURL } from "url"
import { join, dirname } from "path"
import { spawnSync } from "child_process"
const __dir = dirname(fileURLToPath(import.meta.url))
const tuiEntry = join(__dir, "packages", "tui", "src", "index.tsx")
const tsxLoader = join(__dir, "node_modules", "tsx", "dist", "esm", "index.cjs")
const loaderUrl = pathToFileURL(tsxLoader).href
const result = spawnSync(
process.execPath,
[`--import=${loaderUrl}`, tuiEntry, ...process.argv.slice(2)],
{ stdio: "inherit", env: { ...process.env }, cwd: process.cwd() }
)
process.exit(result.status ?? 0)
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "react",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}