Initial commit: sage:TypeScript AI Agent 框架 monorepo(llm / memory / tui 三包)
This commit is contained in:
@@ -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 风格
|
||||||
|
```
|
||||||
Generated
+2140
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)` }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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}$`)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>>
|
||||||
@@ -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}` }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./client.js"
|
||||||
|
export * from "./providers/index.js"
|
||||||
|
export * from "./types.js"
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <text></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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) })
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user