novacode 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Registry for different model providers (OpenAI, Anthropic, etc.).
3
+ * Provides a unified streaming interface for the agent loop.
4
+ */
5
+ import type { AgentEvent, ApiFormat, AssistantResult, StreamFn, StreamOpts } from "../types.ts"
6
+ import { EventStream } from "./stream.ts"
7
+
8
+ export type { AssistantResult, StreamEvent, StreamFn, StreamOpts } from "../types.ts"
9
+
10
+ // Internal map of registered provider implementations
11
+ const registry = new Map<ApiFormat, StreamFn>()
12
+
13
+ export function register(api: ApiFormat, fn: StreamFn): void {
14
+ registry.set(api, fn)
15
+ }
16
+
17
+ // Bridges provider-specific StreamEvents into AgentEvents so the loop and TUI deal with one type.
18
+ export function stream(opts: StreamOpts): EventStream<AgentEvent, AssistantResult> {
19
+ const fn = registry.get(opts.api)
20
+ if (!fn) throw new Error(`No provider registered for API format: ${opts.api}`)
21
+
22
+ // Bridge layer: converts provider-specific StreamEvents into the agent's
23
+ // AgentEvent shape, so the loop and TUI only deal with one event type.
24
+ const providerStream = fn(opts)
25
+ const agentStream = new EventStream<AgentEvent, AssistantResult>()
26
+
27
+ ;(async () => {
28
+ for await (const event of providerStream) {
29
+ if (event.type === "text_delta") {
30
+ agentStream.push({ type: "text_delta", text: event.text ?? "" })
31
+ } else if (event.type === "thinking_delta") {
32
+ agentStream.push({ type: "thinking_delta", text: event.text ?? "" })
33
+ } else if (event.type === "tool_call" && event.call) {
34
+ agentStream.push({
35
+ type: "tool_call",
36
+ call: {
37
+ type: "tool_call",
38
+ id: event.call.id,
39
+ name: event.call.name,
40
+ args: event.call.args,
41
+ },
42
+ })
43
+ } else if (event.type === "usage" && event.usage) {
44
+ agentStream.push({ type: "usage", usage: event.usage })
45
+ }
46
+ }
47
+
48
+ const res = providerStream.result
49
+ if (res) {
50
+ agentStream.finish(res)
51
+ } else {
52
+ // Fallback for unexpected closure
53
+ agentStream.finish({ content: [], usage: { in: 0, out: 0 }, stop: "stop" })
54
+ }
55
+ })()
56
+
57
+ return agentStream
58
+ }
59
+
60
+ export function getRegisteredApis(): ApiFormat[] {
61
+ return [...registry.keys()]
62
+ }
@@ -0,0 +1,77 @@
1
+ /*
2
+ * Push-based async event stream.
3
+ *
4
+ * Producers call push()/finish(). Consumers iterate with for-await-of.
5
+ * Backpressure is implicit: push() resolves immediately; the iterator
6
+ * awaits the next value only when the consumer asks for it.
7
+ */
8
+ export class EventStream<T, R> {
9
+ #events: T[] = []
10
+ #done = false
11
+ #result?: R
12
+ #resolve?: (value: T) => void
13
+ #doneResolve?: (value: R) => void
14
+ #abort = false
15
+
16
+ push(event: T): void {
17
+ if (this.#abort) return
18
+ // If a consumer is already waiting, deliver directly — skip the queue
19
+ if (this.#resolve) {
20
+ const resolve = this.#resolve
21
+ this.#resolve = undefined
22
+ resolve(event)
23
+ } else {
24
+ this.#events.push(event)
25
+ }
26
+ }
27
+
28
+ finish(result: R): void {
29
+ this.#done = true
30
+ this.#result = result
31
+ // Wake up a suspended iterator so it can see done=true and exit
32
+ if (this.#resolve) {
33
+ // undefined is a sentinel — the iterator loop checks done after waking
34
+ this.#resolve(undefined as T)
35
+ }
36
+ if (this.#doneResolve) {
37
+ this.#doneResolve(result)
38
+ }
39
+ }
40
+
41
+ abort(): void {
42
+ this.#abort = true
43
+ this.#done = true
44
+ if (this.#resolve) {
45
+ this.#resolve(undefined as T)
46
+ }
47
+ if (this.#doneResolve) {
48
+ this.#doneResolve(undefined as R)
49
+ }
50
+ }
51
+
52
+ async *[Symbol.asyncIterator](): AsyncGenerator<T> {
53
+ while (!this.#done || this.#events.length > 0) {
54
+ if (this.#events.length > 0) {
55
+ yield this.#events.shift() as T
56
+ continue
57
+ }
58
+ if (this.#done) break
59
+ const item = await new Promise<T | undefined>((resolve) => {
60
+ this.#resolve = resolve as (value: T) => void
61
+ })
62
+ if (item !== undefined && this.#events.length === 0) {
63
+ yield item
64
+ } else if (this.#events.length > 0) {
65
+ yield this.#events.shift() as T
66
+ }
67
+ }
68
+ }
69
+
70
+ get result(): R | undefined {
71
+ return this.#result
72
+ }
73
+
74
+ get isDone(): boolean {
75
+ return this.#done
76
+ }
77
+ }
@@ -0,0 +1,126 @@
1
+ import { getProvider } from "../config/providers.ts"
2
+ import { stream } from "../provider/registry.ts"
3
+ import type { Model, Msg } from "../types.ts"
4
+ import { estimateTokens } from "../util.ts"
5
+ import type { SessionStore } from "./store.ts"
6
+
7
+ const COMPACT_THRESHOLD = 0.8
8
+ const KEEP_RECENT = 10
9
+
10
+ function extractText(msg: Msg): string {
11
+ if (typeof msg.content === "string") return msg.content
12
+ return msg.content
13
+ .filter((c) => c.type === "text")
14
+ .map((c) => (c.type === "text" ? c.text : ""))
15
+ .join("")
16
+ }
17
+
18
+ function extractToolFiles(msg: Msg, toolName: string): string[] {
19
+ if (msg.role !== "tool_result") return []
20
+ if (!("tool" in msg) || msg.tool !== toolName) return []
21
+ const text = extractText(msg)
22
+ // Extract file paths from tool result content
23
+ const lines = text.split("\n")
24
+ return lines.filter((l) => l.trim().length > 0)
25
+ }
26
+
27
+ export interface CompactResult {
28
+ compacted: boolean
29
+ summary?: string
30
+ msgsRemoved: number
31
+ }
32
+
33
+ export function needsCompact(messages: Msg[], contextWindow: number): boolean {
34
+ return estimateTokens(messages) > contextWindow * COMPACT_THRESHOLD
35
+ }
36
+
37
+ export async function compact(
38
+ store: SessionStore,
39
+ sessionId: string,
40
+ messages: Msg[],
41
+ model: Model,
42
+ apiKey: string,
43
+ baseUrl: string,
44
+ ): Promise<CompactResult> {
45
+ if (!needsCompact(messages, model.contextWindow)) {
46
+ return { compacted: false, msgsRemoved: 0 }
47
+ }
48
+
49
+ const old = messages.slice(0, -KEEP_RECENT)
50
+ if (old.length === 0) {
51
+ return { compacted: false, msgsRemoved: 0 }
52
+ }
53
+ const convo = old
54
+ .map((m) => {
55
+ if (m.role === "user") return `User: ${extractText(m)}`
56
+ if (m.role === "assistant") return `Assistant: ${extractText(m)}`
57
+ if (m.role === "tool_result" && "tool" in m)
58
+ return `Tool(${m.tool}): ${extractText(m).slice(0, 200)}`
59
+ return ""
60
+ })
61
+ .join("\n\n")
62
+
63
+ const summary = await generateSummary(convo, model, apiKey, baseUrl)
64
+ if (!summary) {
65
+ return { compacted: false, msgsRemoved: 0 }
66
+ }
67
+
68
+ const filesRead: string[] = []
69
+ const filesWrote: string[] = []
70
+ for (const m of old) {
71
+ filesRead.push(...extractToolFiles(m, "read"))
72
+ filesRead.push(...extractToolFiles(m, "glob"))
73
+ filesWrote.push(...extractToolFiles(m, "write"))
74
+ filesWrote.push(...extractToolFiles(m, "edit"))
75
+ }
76
+
77
+ const seqBefore = old.length
78
+ store.saveCompaction(
79
+ sessionId,
80
+ summary,
81
+ [...new Set(filesRead)],
82
+ [...new Set(filesWrote)],
83
+ seqBefore,
84
+ )
85
+ store.truncateBeforeSeq(sessionId, seqBefore + 1)
86
+
87
+ // Insert the summary as a user message so the model retains context
88
+ const summaryMsg: Msg = {
89
+ role: "user",
90
+ content: `[Prior context summary]\n${summary}`,
91
+ ts: Date.now(),
92
+ }
93
+ store.append(sessionId, summaryMsg)
94
+
95
+ return { compacted: true, summary, msgsRemoved: old.length }
96
+ }
97
+
98
+ async function generateSummary(
99
+ convo: string,
100
+ model: Model,
101
+ apiKey: string,
102
+ baseUrl: string,
103
+ ): Promise<string | null> {
104
+ const provider = getProvider(model.provider)
105
+ if (!provider) return null
106
+
107
+ const es = stream({
108
+ api: provider.api,
109
+ model,
110
+ apiKey,
111
+ baseUrl,
112
+ system:
113
+ "Summarize this coding session concisely. Cover: what was asked, files touched, what was done, key decisions. Keep it under 300 words.",
114
+ messages: [{ role: "user", content: convo, ts: Date.now() }],
115
+ tools: [],
116
+ })
117
+
118
+ let summary = ""
119
+ for await (const ev of es) {
120
+ if (ev.type === "text_delta" && ev.text) {
121
+ summary += ev.text
122
+ }
123
+ }
124
+
125
+ return summary.trim() || null
126
+ }
@@ -0,0 +1,206 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { join } from "node:path"
3
+ import type { Msg, Session } from "../types.ts"
4
+
5
+ const SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS sessions (
7
+ id TEXT PRIMARY KEY,
8
+ cwd TEXT NOT NULL,
9
+ model TEXT NOT NULL,
10
+ provider TEXT NOT NULL,
11
+ title TEXT,
12
+ created INTEGER NOT NULL,
13
+ updated INTEGER NOT NULL
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS messages (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
19
+ seq INTEGER NOT NULL,
20
+ role TEXT NOT NULL,
21
+ content TEXT NOT NULL,
22
+ ts INTEGER NOT NULL
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
26
+
27
+ CREATE TABLE IF NOT EXISTS compactions (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
30
+ summary TEXT NOT NULL,
31
+ files_read TEXT NOT NULL DEFAULT '[]',
32
+ files_wrote TEXT NOT NULL DEFAULT '[]',
33
+ seq_before INTEGER NOT NULL,
34
+ ts INTEGER NOT NULL
35
+ );
36
+ `
37
+
38
+ function generateId(): string {
39
+ return `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`
40
+ }
41
+
42
+ export class SessionStore {
43
+ #db: Database
44
+
45
+ constructor(dbPath: string) {
46
+ this.#db = new Database(dbPath, { create: true })
47
+ this.#db.run("PRAGMA journal_mode = WAL")
48
+ this.#db.run("PRAGMA foreign_keys = ON")
49
+ this.#db.exec(SCHEMA)
50
+ }
51
+
52
+ create(cwd: string, model: string, provider: string): Session {
53
+ const id = generateId()
54
+ const now = Date.now()
55
+ this.#db
56
+ .prepare(
57
+ "INSERT INTO sessions (id, cwd, model, provider, title, created, updated) VALUES ($id, $cwd, $model, $provider, $title, $created, $updated)",
58
+ )
59
+ .run({
60
+ $id: id,
61
+ $cwd: cwd,
62
+ $model: model,
63
+ $provider: provider,
64
+ $title: null,
65
+ $created: now,
66
+ $updated: now,
67
+ })
68
+ return { id, cwd, model, provider, title: null, created: now, updated: now }
69
+ }
70
+
71
+ get(id: string): Session | null {
72
+ return (
73
+ (this.#db
74
+ .prepare(
75
+ "SELECT id, cwd, model, provider, title, created, updated FROM sessions WHERE id = $id",
76
+ )
77
+ .get({ $id: id }) as Session | null) ?? null
78
+ )
79
+ }
80
+
81
+ list(limit = 50): Session[] {
82
+ return this.#db
83
+ .prepare(
84
+ "SELECT id, cwd, model, provider, title, created, updated FROM sessions ORDER BY updated DESC LIMIT $limit",
85
+ )
86
+ .all({ $limit: limit }) as Session[]
87
+ }
88
+
89
+ delete(id: string): boolean {
90
+ const result = this.#db.prepare("DELETE FROM sessions WHERE id = $id").run({ $id: id })
91
+ return result.changes > 0
92
+ }
93
+
94
+ append(sessionId: string, msg: Msg): void {
95
+ const seq = this.#nextSeq(sessionId)
96
+ this.#db
97
+ .prepare(
98
+ "INSERT INTO messages (session_id, seq, role, content, ts) VALUES ($sid, $seq, $role, $content, $ts)",
99
+ )
100
+ .run({
101
+ $sid: sessionId,
102
+ $seq: seq,
103
+ $role: msg.role,
104
+ $content: JSON.stringify(msg),
105
+ $ts: msg.ts,
106
+ })
107
+ this.#db
108
+ .prepare("UPDATE sessions SET updated = $now WHERE id = $id")
109
+ .run({ $now: Date.now(), $id: sessionId })
110
+ }
111
+
112
+ appendMany(sessionId: string, msgs: Msg[]): void {
113
+ const tx = this.#db.transaction(() => {
114
+ for (const msg of msgs) {
115
+ this.append(sessionId, msg)
116
+ }
117
+ })
118
+ tx()
119
+ }
120
+
121
+ messages(sessionId: string): Msg[] {
122
+ const rows = this.#db
123
+ .prepare("SELECT content FROM messages WHERE session_id = $sid ORDER BY seq ASC")
124
+ .all({ $sid: sessionId }) as { content: string }[]
125
+ return rows.map((r) => JSON.parse(r.content) as Msg)
126
+ }
127
+
128
+ messagesAfter(sessionId: string, afterSeq: number): Msg[] {
129
+ const rows = this.#db
130
+ .prepare(
131
+ "SELECT content FROM messages WHERE session_id = $sid AND seq > $seq ORDER BY seq ASC",
132
+ )
133
+ .all({ $sid: sessionId, $seq: afterSeq }) as { content: string }[]
134
+ return rows.map((r) => JSON.parse(r.content) as Msg)
135
+ }
136
+
137
+ setTitle(sessionId: string, title: string): void {
138
+ this.#db
139
+ .prepare("UPDATE sessions SET title = $title WHERE id = $id")
140
+ .run({ $title: title, $id: sessionId })
141
+ }
142
+
143
+ messageCount(sessionId: string): number {
144
+ const row = this.#db
145
+ .prepare("SELECT COUNT(*) as count FROM messages WHERE session_id = $sid")
146
+ .get({ $sid: sessionId }) as { count: number }
147
+ return row.count
148
+ }
149
+
150
+ saveCompaction(
151
+ sessionId: string,
152
+ summary: string,
153
+ filesRead: string[],
154
+ filesWrote: string[],
155
+ seqBefore: number,
156
+ ): void {
157
+ this.#db
158
+ .prepare(
159
+ "INSERT INTO compactions (session_id, summary, files_read, files_wrote, seq_before, ts) VALUES ($sid, $summary, $read, $wrote, $seq, $ts)",
160
+ )
161
+ .run({
162
+ $sid: sessionId,
163
+ $summary: summary,
164
+ $read: JSON.stringify(filesRead),
165
+ $wrote: JSON.stringify(filesWrote),
166
+ $seq: seqBefore,
167
+ $ts: Date.now(),
168
+ })
169
+ }
170
+
171
+ getLatestCompaction(sessionId: string): { summary: string; seqBefore: number } | null {
172
+ return (
173
+ (this.#db
174
+ .prepare(
175
+ "SELECT summary, seq_before FROM compactions WHERE session_id = $sid ORDER BY ts DESC LIMIT 1",
176
+ )
177
+ .get({ $sid: sessionId }) as { summary: string; seqBefore: number } | null) ?? null
178
+ )
179
+ }
180
+
181
+ truncateBeforeSeq(sessionId: string, seq: number): void {
182
+ this.#db
183
+ .prepare("DELETE FROM messages WHERE session_id = $sid AND seq < $seq")
184
+ .run({ $sid: sessionId, $seq: seq })
185
+ }
186
+
187
+ close(): void {
188
+ this.#db.close(false)
189
+ }
190
+
191
+ #nextSeq(sessionId: string): number {
192
+ const row = this.#db
193
+ .prepare("SELECT MAX(seq) as maxSeq FROM messages WHERE session_id = $sid")
194
+ .get({ $sid: sessionId }) as { maxSeq: number | null }
195
+ return (row.maxSeq ?? 0) + 1
196
+ }
197
+ }
198
+
199
+ let _store: SessionStore | null = null
200
+
201
+ export function getSessionStore(dir?: string): SessionStore {
202
+ if (_store) return _store
203
+ const dbPath = join(dir ?? join(process.env.HOME ?? "~", ".novacode"), "sessions.db")
204
+ _store = new SessionStore(dbPath)
205
+ return _store
206
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Filesystem tools for reading, writing, and editing files.
3
+ * Includes safety checks to prevent path traversal.
4
+ */
5
+ import { mkdir } from "node:fs/promises"
6
+ import { dirname, extname, resolve } from "node:path"
7
+ import type { Tool, ToolResult } from "../types.ts"
8
+ import { getRelativeIfInside, textPart } from "../util.ts"
9
+
10
+ // Extensions we return as base64 images instead of text
11
+ const IMAGES = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"])
12
+
13
+ function safePath(cwd: string, p: string): string {
14
+ const abs = resolve(cwd, p)
15
+ if (abs !== cwd && !abs.startsWith(`${cwd}/`)) {
16
+ throw new Error(`Path outside project: ${p}`)
17
+ }
18
+ return abs
19
+ }
20
+
21
+ export function readTool(cwd: string): Tool {
22
+ return {
23
+ def: {
24
+ name: "read",
25
+ description:
26
+ "Read file contents. Supports text and images (jpg, png, gif, webp). Text output is truncated to 2000 lines.",
27
+ parameters: {
28
+ type: "object",
29
+ properties: {
30
+ path: { type: "string", description: "Path to file (relative or absolute)" },
31
+ offset: { type: "number", description: "Start line (1-based, default 1)" },
32
+ limit: { type: "number", description: "Max lines to read (default 2000)" },
33
+ },
34
+ required: ["path"],
35
+ },
36
+ },
37
+ async execute(args): Promise<ToolResult> {
38
+ try {
39
+ const filePath = safePath(cwd, args.path as string)
40
+ const relPath = getRelativeIfInside(cwd, filePath)
41
+ const file = Bun.file(filePath)
42
+ if (!(await file.exists())) {
43
+ return { content: [textPart(`File not found: ${relPath}`)], isError: true }
44
+ }
45
+
46
+ // Return images as base64 so the LLM can process them visually
47
+ const ext = extname(filePath).toLowerCase()
48
+ if (IMAGES.has(ext)) {
49
+ const buf = await file.arrayBuffer()
50
+ const b64 = Buffer.from(buf).toString("base64")
51
+ const mime = ext === ".jpg" ? "image/jpeg" : `image/${ext.slice(1)}`
52
+ return { content: [{ type: "image", data: b64, mime }], isError: false }
53
+ }
54
+
55
+ const content = await file.text()
56
+ const lines = content.split("\n")
57
+ const offset = Math.max(0, (Number(args.offset ?? 1) || 1) - 1)
58
+ const limit = Number(args.limit ?? 2000) || 2000
59
+ const slice = lines.slice(offset, offset + limit)
60
+ const truncated = offset + limit < lines.length
61
+
62
+ const out = slice.join("\n")
63
+ const suffix = truncated ? `\n…${lines.length - offset - limit} more lines` : ""
64
+
65
+ return { content: [textPart(out + suffix)], isError: false }
66
+ } catch (e) {
67
+ return {
68
+ content: [textPart(`Error reading file: ${(e as Error).message}`)],
69
+ isError: true,
70
+ }
71
+ }
72
+ },
73
+ }
74
+ }
75
+
76
+ export function writeTool(cwd: string): Tool {
77
+ return {
78
+ def: {
79
+ name: "write",
80
+ description: "Write content to a file. Creates the file and parent directories if needed.",
81
+ parameters: {
82
+ type: "object",
83
+ properties: {
84
+ path: { type: "string", description: "Path to file" },
85
+ content: { type: "string", description: "Content to write" },
86
+ },
87
+ required: ["path", "content"],
88
+ },
89
+ },
90
+ async execute(args): Promise<ToolResult> {
91
+ try {
92
+ const filePath = safePath(cwd, args.path as string)
93
+ const content = args.content as string
94
+ await mkdir(dirname(filePath), { recursive: true })
95
+ await Bun.write(filePath, content)
96
+ const relPath = getRelativeIfInside(cwd, filePath)
97
+ return {
98
+ content: [textPart(`Wrote ${content.length} bytes → ${relPath}`)],
99
+ isError: false,
100
+ }
101
+ } catch (e) {
102
+ return {
103
+ content: [textPart(`Error writing file: ${(e as Error).message}`)],
104
+ isError: true,
105
+ }
106
+ }
107
+ },
108
+ }
109
+ }
110
+
111
+ // Requires oldText to be unique to avoid ambiguous replacements.
112
+ export function editTool(cwd: string): Tool {
113
+ return {
114
+ def: {
115
+ name: "edit",
116
+ description:
117
+ "Edit a file using exact text replacement. Each edit's oldText must be unique in the file.",
118
+ parameters: {
119
+ type: "object",
120
+ properties: {
121
+ path: { type: "string", description: "Path to file" },
122
+ edits: {
123
+ type: "array",
124
+ description:
125
+ "Array of {oldText, newText} replacements. oldText must be unique. Non-overlapping.",
126
+ items: {
127
+ type: "object",
128
+ properties: {
129
+ oldText: { type: "string", description: "Exact text to find (must be unique)" },
130
+ newText: { type: "string", description: "Replacement text" },
131
+ },
132
+ required: ["oldText", "newText"],
133
+ },
134
+ },
135
+ },
136
+ required: ["path", "edits"],
137
+ },
138
+ },
139
+ async execute(args): Promise<ToolResult> {
140
+ try {
141
+ const filePath = safePath(cwd, args.path as string)
142
+ const file = Bun.file(filePath)
143
+ if (!(await file.exists())) {
144
+ return { content: [textPart(`File not found: ${args.path}`)], isError: true }
145
+ }
146
+
147
+ let content = await file.text()
148
+ const edits = args.edits as Array<{ oldText: string; newText: string }>
149
+
150
+ // Validate all edits before applying any — avoids partial writes on bad input
151
+ for (const edit of edits) {
152
+ const count = content.split(edit.oldText).length - 1
153
+ if (count === 0) {
154
+ return {
155
+ content: [textPart(`oldText not found: "${edit.oldText.slice(0, 80)}…"`)],
156
+ isError: true,
157
+ }
158
+ }
159
+ // Ambiguous match would replace the wrong occurrence
160
+ if (count > 1) {
161
+ return {
162
+ content: [
163
+ textPart(
164
+ `oldText found ${count} times — add surrounding context to make it unique: "${edit.oldText.slice(0, 60)}…"`,
165
+ ),
166
+ ],
167
+ isError: true,
168
+ }
169
+ }
170
+ }
171
+
172
+ // Apply edits sequentially
173
+ for (const edit of edits) {
174
+ content = content.replace(edit.oldText, edit.newText)
175
+ }
176
+
177
+ await Bun.write(filePath, content)
178
+ const relPath = getRelativeIfInside(cwd, filePath)
179
+ return {
180
+ content: [
181
+ textPart(
182
+ `Edited ${relPath} (${edits.length} replacement${edits.length > 1 ? "s" : ""})`,
183
+ ),
184
+ ],
185
+ isError: false,
186
+ }
187
+ } catch (e) {
188
+ return {
189
+ content: [textPart(`Error editing file: ${(e as Error).message}`)],
190
+ isError: true,
191
+ }
192
+ }
193
+ },
194
+ }
195
+ }