Initial commit: sage:TypeScript AI Agent 框架 monorepo(llm / memory / tui 三包)
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user