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

This commit is contained in:
WpyQwq
2026-09-19 12:10:51 +08:00
commit eb29eb3fa1
47 changed files with 4429 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@sage/core",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.44.0",
"nanoid": "^5.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^22.0.0",
"typescript": "^5.8.0"
}
}
+72
View File
@@ -0,0 +1,72 @@
import { existsSync, readFileSync, writeFileSync } from "fs"
import { join } from "path"
import { homedir } from "os"
export interface SageConfig {
defaultProvider: string
defaultModel: string
theme: "tokyo-night" | "catppuccin" | "gruvbox" | "minimal"
providers: {
anthropic?: { apiKey?: string }
openai?: { apiKey?: string; baseUrl?: string }
google?: { apiKey?: string }
openrouter?: { apiKey?: string }
}
memory: {
autoSummarize: boolean
maxMemories: number
recallCount: number
}
editor: string
}
const CONFIG_PATH = join(homedir(), ".sage", "config.json")
const DEFAULTS: SageConfig = {
defaultProvider: "anthropic",
defaultModel: "claude-sonnet-4-6",
theme: "tokyo-night",
providers: {},
memory: {
autoSummarize: true,
maxMemories: 500,
recallCount: 5,
},
editor: process.env.EDITOR ?? "vim",
}
let _config: SageConfig | null = null
export function loadConfig(): SageConfig {
if (_config) return _config
if (!existsSync(CONFIG_PATH)) {
_config = { ...DEFAULTS }
return _config
}
try {
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"))
_config = deepMerge(DEFAULTS, raw) as SageConfig
} catch {
_config = { ...DEFAULTS }
}
return _config
}
export function saveConfig(patch: Partial<SageConfig>) {
const current = loadConfig()
_config = deepMerge(current, patch) as SageConfig
writeFileSync(CONFIG_PATH, JSON.stringify(_config, null, 2))
}
function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
const result = { ...base }
for (const key of Object.keys(override)) {
if (override[key] !== null && typeof override[key] === "object" && !Array.isArray(override[key]) &&
typeof base[key] === "object" && base[key] !== null) {
result[key] = deepMerge(base[key] as Record<string, unknown>, override[key] as Record<string, unknown>)
} else {
result[key] = override[key]
}
}
return result
}
+117
View File
@@ -0,0 +1,117 @@
import Database from "better-sqlite3"
import { drizzle } from "drizzle-orm/better-sqlite3"
import * as schema from "./schema.js"
import { existsSync, mkdirSync } from "fs"
import { join } from "path"
import { homedir } from "os"
const DATA_DIR = join(homedir(), ".sage")
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true })
const DB_PATH = join(DATA_DIR, "sage.db")
let _db: ReturnType<typeof drizzle> | null = null
let _sqlite: Database.Database | null = null
export function getDb() {
if (!_db) {
_sqlite = new Database(DB_PATH)
_sqlite.pragma("journal_mode = WAL")
_sqlite.pragma("foreign_keys = ON")
_db = drizzle(_sqlite, { schema })
migrate(_sqlite)
}
return _db
}
export function getRawDb(): Database.Database {
getDb()
return _sqlite!
}
function migrate(sqlite: Database.Database) {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT 'New Session',
model TEXT NOT NULL,
provider TEXT NOT NULL,
cwd TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}'
)
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
tool_calls TEXT,
tool_call_id TEXT,
created_at INTEGER NOT NULL,
tokens_input INTEGER,
tokens_output INTEGER
)
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS tool_results (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
tool_call_id TEXT NOT NULL,
input TEXT NOT NULL,
output TEXT NOT NULL,
exit_code INTEGER,
created_at INTEGER NOT NULL
)
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
session_id TEXT,
created_at INTEGER NOT NULL,
last_accessed INTEGER NOT NULL,
access_count INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '[]'
)
`)
sqlite.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
USING fts5(id UNINDEXED, content, tags, content='memories', content_rowid='rowid')
`)
sqlite.exec(`
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, id, content, tags) VALUES (new.rowid, new.id, new.content, new.tags);
END
`)
sqlite.exec(`
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, id, content, tags) VALUES ('delete', old.rowid, old.id, old.content, old.tags);
INSERT INTO memories_fts(rowid, id, content, tags) VALUES (new.rowid, new.id, new.content, new.tags);
END
`)
sqlite.exec(`
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, id, content, tags) VALUES ('delete', old.rowid, old.id, old.content, old.tags);
END
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`)
}
+52
View File
@@ -0,0 +1,52 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
title: text("title").notNull().default("New Session"),
model: text("model").notNull(),
provider: text("provider").notNull(),
cwd: text("cwd").notNull(),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
metadata: text("metadata").notNull().default("{}"),
})
export const messages = sqliteTable("messages", {
id: text("id").primaryKey(),
sessionId: text("session_id").notNull(),
role: text("role").notNull(), // user | assistant | tool
content: text("content").notNull(),
toolCalls: text("tool_calls"), // JSON
toolCallId: text("tool_call_id"),
createdAt: integer("created_at").notNull(),
tokensInput: integer("tokens_input"),
tokensOutput: integer("tokens_output"),
})
export const toolResults = sqliteTable("tool_results", {
id: text("id").primaryKey(),
messageId: text("message_id").notNull(),
sessionId: text("session_id").notNull(),
toolName: text("tool_name").notNull(),
toolCallId: text("tool_call_id").notNull(),
input: text("input").notNull(),
output: text("output").notNull(),
exitCode: integer("exit_code"),
createdAt: integer("created_at").notNull(),
})
export const memories = sqliteTable("memories", {
id: text("id").primaryKey(),
content: text("content").notNull(),
source: text("source").notNull().default("manual"),
sessionId: text("session_id"),
createdAt: integer("created_at").notNull(),
lastAccessed: integer("last_accessed").notNull(),
accessCount: integer("access_count").notNull().default(0),
tags: text("tags").notNull().default("[]"),
})
export const config = sqliteTable("config", {
key: text("key").primaryKey(),
value: text("value").notNull(),
})
+7
View File
@@ -0,0 +1,7 @@
export * from "./db/client.js"
export * from "./db/schema.js"
export * from "./session/types.js"
export * from "./session/store.js"
export * from "./tool/types.js"
export * from "./tool/registry.js"
export * from "./config/index.js"
+86
View File
@@ -0,0 +1,86 @@
import { eq, desc } from "drizzle-orm"
import { getDb } from "../db/client.js"
import { sessions, messages } from "../db/schema.js"
import type { Session, Message, CreateSessionInput, ToolCall } from "./types.js"
import { nanoid } from "nanoid"
import { cwd as getCwd } from "process"
export async function createSession(input: CreateSessionInput): Promise<Session> {
const db = getDb()
const now = Date.now()
const id = nanoid()
const session: Session = {
id,
title: input.title ?? "New Session",
model: input.model,
provider: input.provider,
cwd: input.cwd ?? getCwd(),
createdAt: now,
updatedAt: now,
metadata: {},
}
await db.insert(sessions).values({
...session,
metadata: JSON.stringify(session.metadata),
})
return session
}
export async function getSession(id: string): Promise<Session | null> {
const db = getDb()
const row = await db.select().from(sessions).where(eq(sessions.id, id)).get()
if (!row) return null
return { ...row, metadata: JSON.parse(row.metadata) }
}
export async function listSessions(limit = 50): Promise<Session[]> {
const db = getDb()
const rows = await db.select().from(sessions).orderBy(desc(sessions.updatedAt)).limit(limit).all()
return rows.map(r => ({ ...r, metadata: JSON.parse(r.metadata) }))
}
export async function updateSessionTitle(id: string, title: string) {
const db = getDb()
await db.update(sessions).set({ title, updatedAt: Date.now() }).where(eq(sessions.id, id))
}
export async function deleteSession(id: string) {
const db = getDb()
await db.delete(sessions).where(eq(sessions.id, id))
}
export async function addMessage(msg: Omit<Message, "id" | "createdAt">): Promise<Message> {
const db = getDb()
const id = nanoid()
const now = Date.now()
const full: Message = { ...msg, id, createdAt: now }
await db.insert(messages).values({
...full,
toolCalls: msg.toolCalls ? JSON.stringify(msg.toolCalls) : null,
})
await db.update(sessions).set({ updatedAt: now }).where(eq(sessions.id, msg.sessionId))
return full
}
export async function getMessages(sessionId: string): Promise<Message[]> {
const db = getDb()
const rows = await db
.select()
.from(messages)
.where(eq(messages.sessionId, sessionId))
.orderBy(messages.createdAt)
.all()
return rows.map(r => ({
...r,
role: r.role as Message["role"],
toolCalls: r.toolCalls ? (JSON.parse(r.toolCalls) as ToolCall[]) : undefined,
tokensInput: r.tokensInput ?? undefined,
tokensOutput: r.tokensOutput ?? undefined,
toolCallId: r.toolCallId ?? undefined,
}))
}
export async function updateMessage(id: string, patch: Partial<Pick<Message, "content" | "tokensInput" | "tokensOutput">>) {
const db = getDb()
await db.update(messages).set(patch).where(eq(messages.id, id))
}
+45
View File
@@ -0,0 +1,45 @@
export type MessageRole = "user" | "assistant" | "tool"
export interface ToolCall {
id: string
name: string
input: Record<string, unknown>
}
export interface ToolResult {
toolCallId: string
toolName: string
output: string
exitCode?: number
error?: boolean
}
export interface Message {
id: string
sessionId: string
role: MessageRole
content: string
toolCalls?: ToolCall[]
toolCallId?: string
createdAt: number
tokensInput?: number
tokensOutput?: number
}
export interface Session {
id: string
title: string
model: string
provider: string
cwd: string
createdAt: number
updatedAt: number
metadata: Record<string, unknown>
}
export interface CreateSessionInput {
model: string
provider: string
cwd?: string
title?: string
}
+70
View File
@@ -0,0 +1,70 @@
import { z } from "zod"
import { spawn } from "child_process"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
const MAX_OUTPUT = 100_000
export const bash: ToolDef<{ command: string; description?: string; timeout?: number }> = {
name: "bash",
description: "Execute a shell command in the current working directory. Use for running tests, builds, git operations, and any shell task.",
inputSchema: z.object({
command: z.string().describe("The shell command to execute"),
description: z.string().optional().describe("Short description of what this command does"),
timeout: z.number().optional().default(120000).describe("Timeout in milliseconds"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const timeout = input.timeout ?? 120_000
return new Promise((resolve) => {
const proc = spawn(process.platform === "win32" ? "cmd" : "sh",
process.platform === "win32" ? ["/c", input.command] : ["-c", input.command],
{
cwd: ctx.cwd,
env: { ...process.env },
shell: false,
}
)
let stdout = ""
let stderr = ""
let killed = false
const timer = setTimeout(() => {
killed = true
proc.kill("SIGTERM")
}, timeout)
proc.stdout.on("data", (chunk: Buffer) => {
const text = chunk.toString()
stdout += text
ctx.onProgress?.(text)
})
proc.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString()
})
proc.on("close", (code) => {
clearTimeout(timer)
let output = ""
if (stdout) output += stdout
if (stderr) output += (output ? "\n--- stderr ---\n" : "") + stderr
if (killed) output += "\n[Process killed: timeout exceeded]"
const truncated = output.length > MAX_OUTPUT
if (truncated) output = output.slice(0, MAX_OUTPUT) + "\n... [output truncated]"
resolve({
content: output || "(no output)",
exitCode: code ?? 0,
error: (code ?? 0) !== 0,
truncated,
})
})
proc.on("error", (err) => {
clearTimeout(timer)
resolve({ content: `Error: ${err.message}`, exitCode: 1, error: true })
})
})
},
}
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod"
import { readFileSync, writeFileSync, existsSync } from "fs"
import { resolve } from "path"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
export const edit: ToolDef<{ file_path: string; old_string: string; new_string: string; replace_all?: boolean }> = {
name: "edit",
description: "Replace a specific string in a file with a new string. The old_string must match exactly (including whitespace). Use replace_all to replace every occurrence.",
inputSchema: z.object({
file_path: z.string().describe("Path to the file to edit"),
old_string: z.string().describe("The exact string to find and replace"),
new_string: z.string().describe("The string to replace it with"),
replace_all: z.boolean().optional().default(false).describe("Replace all occurrences instead of just the first"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const filePath = resolve(ctx.cwd, input.file_path)
if (!existsSync(filePath)) {
return { content: `File not found: ${filePath}`, error: true }
}
const original = readFileSync(filePath, "utf8")
if (!original.includes(input.old_string)) {
// Attempt fuzzy match to give helpful error
const lines = original.split("\n")
const needle = input.old_string.trim()
const close = lines.findIndex(l => l.includes(needle.split("\n")[0]?.trim() ?? ""))
const hint = close >= 0 ? ` (closest match at line ${close + 1})` : ""
return { content: `old_string not found in file${hint}. Ensure the text matches exactly including whitespace.`, error: true }
}
const occurrences = original.split(input.old_string).length - 1
if (occurrences > 1 && !input.replace_all) {
return {
content: `old_string appears ${occurrences} times. Provide more context to make it unique, or set replace_all=true.`,
error: true,
}
}
const updated = input.replace_all
? original.split(input.old_string).join(input.new_string)
: original.replace(input.old_string, input.new_string)
writeFileSync(filePath, updated, "utf8")
return { content: `Edited ${filePath}: replaced ${input.replace_all ? occurrences : 1} occurrence(s)` }
},
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod"
import { resolve } from "path"
import { spawnSync } from "child_process"
import { readdirSync, statSync, existsSync } from "fs"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
const MAX_RESULTS = 500
export const glob: ToolDef<{ pattern: string; path?: string }> = {
name: "glob",
description: "Find files matching a glob pattern. Returns file paths sorted by modification time.",
inputSchema: z.object({
pattern: z.string().describe("Glob pattern, e.g. '**/*.ts' or 'src/**/*.tsx'"),
path: z.string().optional().describe("Directory to search in (default: cwd)"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const searchDir = resolve(ctx.cwd, input.path ?? ".")
const results = matchGlob(input.pattern, searchDir)
const truncated = results.length > MAX_RESULTS
const listed = results.slice(0, MAX_RESULTS)
return {
content: listed.length > 0 ? listed.join("\n") : "(no matches)",
truncated,
}
},
}
function matchGlob(pattern: string, dir: string): string[] {
// Try ripgrep first for performance
const rg = spawnSync("rg", ["--files", "--glob", pattern, dir], {
encoding: "utf8",
maxBuffer: 10_000_000,
})
if (rg.status === 0) {
return rg.stdout.trim().split("\n").filter(Boolean)
}
// Fallback: simple recursive walk
return walkAndMatch(dir, dir, patternToRegex(pattern))
}
function walkAndMatch(base: string, dir: string, regex: RegExp): string[] {
if (!existsSync(dir)) return []
const results: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".")) continue
const full = `${dir}/${entry.name}`
const rel = full.slice(base.length + 1)
if (entry.isDirectory()) {
results.push(...walkAndMatch(base, full, regex))
} else if (regex.test(rel)) {
results.push(full)
}
}
return results
}
function patternToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "§DOUBLE§")
.replace(/\*/g, "[^/]*")
.replace(/§DOUBLE§/g, ".*")
.replace(/\?/g, "[^/]")
return new RegExp(`^${escaped}$`)
}
+50
View File
@@ -0,0 +1,50 @@
import { z } from "zod"
import { resolve } from "path"
import { spawnSync } from "child_process"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
const MAX_OUTPUT = 50_000
export const grep: ToolDef<{ pattern: string; path?: string; glob?: string; output_mode?: "content" | "files" | "count"; case_insensitive?: boolean; context?: number }> = {
name: "grep",
description: "Search for a regex pattern in files using ripgrep. Returns matching content or file paths.",
inputSchema: z.object({
pattern: z.string().describe("Regular expression pattern to search for"),
path: z.string().optional().describe("File or directory to search in"),
glob: z.string().optional().describe("Glob pattern to filter files, e.g. '*.ts'"),
output_mode: z.enum(["content", "files", "count"]).optional().default("content").describe("content: show matching lines, files: only file paths, count: match counts"),
case_insensitive: z.boolean().optional().default(false),
context: z.number().optional().describe("Lines of context around each match"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const searchPath = resolve(ctx.cwd, input.path ?? ".")
const args = ["--no-heading", "--color=never"]
if (input.case_insensitive) args.push("-i")
if (input.glob) args.push("--glob", input.glob)
if (input.output_mode === "files") args.push("-l")
if (input.output_mode === "count") args.push("-c")
if (input.context) args.push(`-C${input.context}`)
if (input.output_mode === "content") args.push("-n")
args.push(input.pattern, searchPath)
const result = spawnSync("rg", args, {
encoding: "utf8",
maxBuffer: MAX_OUTPUT * 2,
})
let output = (result.stdout ?? "") + (result.stderr ?? "")
if (result.error) {
// Fallback when rg not available
output = `ripgrep not found. Install it for grep support.\nError: ${result.error.message}`
return { content: output, error: true }
}
const truncated = output.length > MAX_OUTPUT
return {
content: output.slice(0, MAX_OUTPUT) || "(no matches)",
truncated,
}
},
}
+54
View File
@@ -0,0 +1,54 @@
import { z } from "zod"
import { readFileSync, existsSync, statSync, readdirSync } from "fs"
import { join, resolve } from "path"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
const MAX_FILE_SIZE = 1_000_000
const MAX_LINES = 2000
export const read: ToolDef<{ file_path: string; offset?: number; limit?: number }> = {
name: "read",
description: "Read the contents of a file. Use offset/limit for large files.",
inputSchema: z.object({
file_path: z.string().describe("Absolute or relative path to the file"),
offset: z.number().optional().describe("Line number to start reading from (1-based)"),
limit: z.number().optional().describe("Maximum number of lines to read"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const filePath = resolve(ctx.cwd, input.file_path)
if (!existsSync(filePath)) {
return { content: `File not found: ${filePath}`, error: true }
}
const stat = statSync(filePath)
if (stat.isDirectory()) {
const entries = readdirSync(filePath, { withFileTypes: true })
const listing = entries
.map(e => `${e.isDirectory() ? "d" : "f"} ${e.name}`)
.join("\n")
return { content: `Directory listing of ${filePath}:\n${listing}` }
}
if (stat.size > MAX_FILE_SIZE) {
return {
content: `File too large (${Math.round(stat.size / 1024)}KB). Use offset/limit to read specific sections.`,
error: true,
}
}
const raw = readFileSync(filePath, "utf8")
const lines = raw.split("\n")
const offset = (input.offset ?? 1) - 1
const limit = input.limit ?? MAX_LINES
const slice = lines.slice(offset, offset + limit)
const numbered = slice.map((l, i) => `${String(offset + i + 1).padStart(4, " ")}\t${l}`).join("\n")
const truncated = lines.length > offset + limit
return {
content: numbered,
truncated,
}
},
}
+20
View File
@@ -0,0 +1,20 @@
import type { AnyToolDef } from "./types.js"
import { bash } from "./bash.js"
import { read } from "./read.js"
import { write } from "./write.js"
import { edit } from "./edit.js"
import { glob } from "./glob.js"
import { grep } from "./grep.js"
export const ALL_TOOLS: AnyToolDef[] = [
bash as AnyToolDef,
read as AnyToolDef,
write as AnyToolDef,
edit as AnyToolDef,
glob as AnyToolDef,
grep as AnyToolDef,
]
export function getTool(name: string): AnyToolDef | undefined {
return ALL_TOOLS.find(t => t.name === name)
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod"
export interface ToolDef<TInput = Record<string, unknown>> {
name: string
description: string
inputSchema: z.ZodType<TInput>
execute: (input: TInput, context: ToolContext) => Promise<ToolOutput>
}
export interface ToolContext {
sessionId: string
cwd: string
onProgress?: (text: string) => void
}
export interface ToolOutput {
content: string
exitCode?: number
error?: boolean
truncated?: boolean
}
export type AnyToolDef = ToolDef<Record<string, unknown>>
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod"
import { writeFileSync, mkdirSync, existsSync } from "fs"
import { resolve, dirname } from "path"
import type { ToolDef, ToolContext, ToolOutput } from "./types.js"
export const write: ToolDef<{ file_path: string; content: string }> = {
name: "write",
description: "Write content to a file, creating it and any parent directories if needed. Overwrites existing files.",
inputSchema: z.object({
file_path: z.string().describe("Path to the file to write"),
content: z.string().describe("Content to write to the file"),
}),
async execute(input, ctx: ToolContext): Promise<ToolOutput> {
const filePath = resolve(ctx.cwd, input.file_path)
const dir = dirname(filePath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(filePath, input.content, "utf8")
const lines = input.content.split("\n").length
return { content: `Wrote ${lines} lines to ${filePath}` }
},
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}