novacode 0.6.0 → 0.8.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.
Files changed (41) hide show
  1. package/README.md +10 -27
  2. package/dist/app-C_2My7n6.mjs +28 -0
  3. package/dist/app-C_2My7n6.mjs.map +1 -0
  4. package/dist/main.mjs +86 -36
  5. package/dist/main.mjs.map +1 -1
  6. package/package.json +1 -2
  7. package/dist/app-bQ9a_p_K.mjs +0 -22
  8. package/dist/app-bQ9a_p_K.mjs.map +0 -1
  9. package/src/agent/agent.ts +0 -87
  10. package/src/agent/loop.ts +0 -237
  11. package/src/agent/prompt.ts +0 -50
  12. package/src/commands/compact.ts +0 -28
  13. package/src/commands/index.ts +0 -128
  14. package/src/commands/models.ts +0 -85
  15. package/src/commands/providers.ts +0 -213
  16. package/src/commands/session.ts +0 -52
  17. package/src/config/providers.ts +0 -207
  18. package/src/config/store.ts +0 -66
  19. package/src/main.ts +0 -205
  20. package/src/onboarding/wizard.ts +0 -54
  21. package/src/provider/gemini.ts +0 -269
  22. package/src/provider/openai.ts +0 -239
  23. package/src/provider/stream.ts +0 -138
  24. package/src/session/compact.ts +0 -159
  25. package/src/session/store.ts +0 -209
  26. package/src/tools/fs.ts +0 -189
  27. package/src/tools/git.ts +0 -99
  28. package/src/tools/index.ts +0 -33
  29. package/src/tools/search.ts +0 -274
  30. package/src/tools/shell.ts +0 -90
  31. package/src/tools/web.ts +0 -239
  32. package/src/tui/app.tsx +0 -454
  33. package/src/tui/components/liveArea.tsx +0 -70
  34. package/src/tui/components/message.tsx +0 -117
  35. package/src/tui/components/statusBar.tsx +0 -64
  36. package/src/tui/constants.ts +0 -25
  37. package/src/tui/markdown.ts +0 -62
  38. package/src/tui/prompts.tsx +0 -205
  39. package/src/types.ts +0 -262
  40. package/src/update.ts +0 -89
  41. package/src/util.ts +0 -80
@@ -1,209 +0,0 @@
1
- import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
2
- import { join } from "node:path"
3
- import type { Compaction, Msg, Session } from "../types.ts"
4
-
5
- function generateId(): string {
6
- return `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`
7
- }
8
-
9
- export class SessionStore {
10
- #sessionsDir: string
11
-
12
- constructor(sessionsDir: string) {
13
- this.#sessionsDir = sessionsDir
14
- }
15
-
16
- #sessionDir(id: string): string {
17
- return join(this.#sessionsDir, id)
18
- }
19
-
20
- #metadataPath(id: string): string {
21
- return join(this.#sessionDir(id), "metadata.json")
22
- }
23
-
24
- #messagesPath(id: string): string {
25
- return join(this.#sessionDir(id), "messages.jsonl")
26
- }
27
-
28
- #compactionPath(id: string): string {
29
- return join(this.#sessionDir(id), "compaction.json")
30
- }
31
-
32
- async create(cwd: string, model: string, provider: string): Promise<Session> {
33
- const id = generateId()
34
- const now = Date.now()
35
- const session: Session = {
36
- id,
37
- cwd,
38
- model,
39
- provider,
40
- title: null,
41
- created: now,
42
- updated: now,
43
- }
44
-
45
- await mkdir(this.#sessionDir(id), { recursive: true })
46
- await writeFile(this.#metadataPath(id), JSON.stringify(session, null, 2))
47
- return session
48
- }
49
-
50
- async get(id: string): Promise<Session | null> {
51
- try {
52
- const data = await readFile(this.#metadataPath(id), "utf-8")
53
- return JSON.parse(data) as Session
54
- } catch {
55
- return null
56
- }
57
- }
58
-
59
- async list(limit = 10): Promise<Session[]> {
60
- try {
61
- const entries = await readdir(this.#sessionsDir, { withFileTypes: true })
62
- const dirNames = entries
63
- .filter((e) => e.isDirectory())
64
- .map((e) => e.name)
65
- .sort((a, b) => b.localeCompare(a))
66
-
67
- const candidates = dirNames.slice(0, Math.max(limit * 2, 50))
68
- const sessions: Session[] = []
69
- for (const name of candidates) {
70
- const s = await this.get(name)
71
- if (s) sessions.push(s)
72
- }
73
- sessions.sort((a, b) => b.updated - a.updated)
74
- return sessions.slice(0, limit)
75
- } catch {
76
- return []
77
- }
78
- }
79
-
80
- async latest(): Promise<Session | null> {
81
- const sessions = await this.list(1)
82
- return sessions[0] ?? null
83
- }
84
-
85
- async delete(id: string): Promise<boolean> {
86
- try {
87
- await rm(this.#sessionDir(id), { recursive: true, force: true })
88
- return true
89
- } catch {
90
- return false
91
- }
92
- }
93
-
94
- async deleteAll(): Promise<void> {
95
- try {
96
- await rm(this.#sessionsDir, { recursive: true, force: true })
97
- await mkdir(this.#sessionsDir, { recursive: true })
98
- } catch {
99
- // ignore
100
- }
101
- }
102
-
103
- async append(sessionId: string, msg: Msg): Promise<void> {
104
- const session = await this.get(sessionId)
105
- if (!session) return
106
-
107
- session.updated = Date.now()
108
- await writeFile(this.#metadataPath(sessionId), JSON.stringify(session, null, 2))
109
-
110
- const line = `${JSON.stringify(msg)}\n`
111
- await appendFile(this.#messagesPath(sessionId), line)
112
- }
113
-
114
- async messages(sessionId: string): Promise<Msg[]> {
115
- try {
116
- const data = await readFile(this.#messagesPath(sessionId), "utf-8")
117
- const lines = data.split("\n").filter((l) => l.trim().length > 0)
118
- return lines.map((l) => JSON.parse(l) as Msg)
119
- } catch {
120
- return []
121
- }
122
- }
123
-
124
- async messageCount(sessionId: string): Promise<number> {
125
- try {
126
- const data = await readFile(this.#messagesPath(sessionId), "utf-8")
127
- const lines = data.split("\n").filter((l) => l.trim().length > 0)
128
- return lines.length
129
- } catch {
130
- return 0
131
- }
132
- }
133
-
134
- async setTitle(sessionId: string, title: string): Promise<void> {
135
- const session = await this.get(sessionId)
136
- if (!session) return
137
- session.title = title
138
- session.updated = Date.now()
139
- await writeFile(this.#metadataPath(sessionId), JSON.stringify(session, null, 2))
140
- }
141
-
142
- async saveCompaction(
143
- sessionId: string,
144
- summary: string,
145
- filesRead: string[],
146
- filesWrote: string[],
147
- seqBefore: number,
148
- ): Promise<void> {
149
- const compaction: Compaction = {
150
- summary,
151
- seqBefore,
152
- filesRead,
153
- filesWrote,
154
- ts: Date.now(),
155
- }
156
- await writeFile(this.#compactionPath(sessionId), JSON.stringify(compaction, null, 2))
157
- }
158
-
159
- async getLatestCompaction(sessionId: string): Promise<Compaction | null> {
160
- try {
161
- const data = await readFile(this.#compactionPath(sessionId), "utf-8")
162
- return JSON.parse(data) as Compaction
163
- } catch {
164
- return null
165
- }
166
- }
167
-
168
- async truncateBeforeSeq(sessionId: string, seq: number): Promise<void> {
169
- const msgs = await this.messages(sessionId)
170
- const remaining = msgs.slice(seq)
171
- const data =
172
- remaining.map((m) => JSON.stringify(m)).join("\n") + (remaining.length > 0 ? "\n" : "")
173
- await writeFile(this.#messagesPath(sessionId), data)
174
- }
175
-
176
- async prune(limit = 10): Promise<void> {
177
- try {
178
- const entries = await readdir(this.#sessionsDir, { withFileTypes: true })
179
- const dirNames = entries
180
- .filter((e) => e.isDirectory())
181
- .map((e) => e.name)
182
- .sort((a, b) => b.localeCompare(a))
183
-
184
- const targets = limit > 0 ? dirNames.slice(0, limit) : dirNames
185
- for (const name of targets) {
186
- const count = await this.messageCount(name)
187
- if (count === 0) {
188
- await this.delete(name)
189
- }
190
- }
191
- } catch {
192
- // ignore
193
- }
194
- }
195
-
196
- close(): void {
197
- // no-op
198
- }
199
- }
200
-
201
- let _store: SessionStore | null = null
202
-
203
- export async function getSessionStore(dir?: string): Promise<SessionStore> {
204
- if (_store) return _store
205
- const sessionsPath = join(dir ?? join(process.env.HOME ?? "~", ".novacode"), "sessions")
206
- await mkdir(sessionsPath, { recursive: true })
207
- _store = new SessionStore(sessionsPath)
208
- return _store
209
- }
package/src/tools/fs.ts DELETED
@@ -1,189 +0,0 @@
1
- /**
2
- * Filesystem tools for reading, writing, and editing files.
3
- * Includes safety checks to prevent path traversal.
4
- */
5
- import { mkdir, readFile, writeFile } 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
- // Return images as base64 so the LLM can process them visually
41
- const ext = extname(filePath).toLowerCase()
42
- if (IMAGES.has(ext)) {
43
- const buf = await readFile(filePath)
44
- const b64 = buf.toString("base64")
45
- const mime = ext === ".jpg" ? "image/jpeg" : `image/${ext.slice(1)}`
46
- return { content: [{ type: "image", data: b64, mime }], isError: false }
47
- }
48
-
49
- const content = await readFile(filePath, "utf-8")
50
- const lines = content.split("\n")
51
- const offset = Math.max(0, (Number(args.offset ?? 1) || 1) - 1)
52
- const limit = Number(args.limit ?? 2000) || 2000
53
- const slice = lines.slice(offset, offset + limit)
54
- const truncated = offset + limit < lines.length
55
-
56
- const out = slice.join("\n")
57
- const suffix = truncated ? `\n…${lines.length - offset - limit} more lines` : ""
58
-
59
- return { content: [textPart(out + suffix)], isError: false }
60
- } catch (e) {
61
- return {
62
- content: [textPart(`Error reading file: ${(e as Error).message}`)],
63
- isError: true,
64
- }
65
- }
66
- },
67
- }
68
- }
69
-
70
- export function writeTool(cwd: string): Tool {
71
- return {
72
- def: {
73
- name: "write",
74
- description: "Write content to a file. Creates the file and parent directories if needed.",
75
- parameters: {
76
- type: "object",
77
- properties: {
78
- path: { type: "string", description: "Path to file" },
79
- content: { type: "string", description: "Content to write" },
80
- },
81
- required: ["path", "content"],
82
- },
83
- },
84
- async execute(args): Promise<ToolResult> {
85
- try {
86
- const filePath = safePath(cwd, args.path as string)
87
- const content = args.content as string
88
- await mkdir(dirname(filePath), { recursive: true })
89
- await writeFile(filePath, content)
90
- const relPath = getRelativeIfInside(cwd, filePath)
91
- return {
92
- content: [textPart(`Wrote ${content.length} bytes → ${relPath}`)],
93
- isError: false,
94
- }
95
- } catch (e) {
96
- return {
97
- content: [textPart(`Error writing file: ${(e as Error).message}`)],
98
- isError: true,
99
- }
100
- }
101
- },
102
- }
103
- }
104
-
105
- // Requires oldText to be unique to avoid ambiguous replacements.
106
- export function editTool(cwd: string): Tool {
107
- return {
108
- def: {
109
- name: "edit",
110
- description:
111
- "Edit a file using exact text replacement. Each edit's oldText must be unique in the file.",
112
- parameters: {
113
- type: "object",
114
- properties: {
115
- path: { type: "string", description: "Path to file" },
116
- edits: {
117
- type: "array",
118
- description:
119
- "Array of {oldText, newText} replacements. oldText must be unique. Non-overlapping.",
120
- items: {
121
- type: "object",
122
- properties: {
123
- oldText: { type: "string", description: "Exact text to find (must be unique)" },
124
- newText: { type: "string", description: "Replacement text" },
125
- },
126
- required: ["oldText", "newText"],
127
- },
128
- },
129
- },
130
- required: ["path", "edits"],
131
- },
132
- },
133
- async execute(args): Promise<ToolResult> {
134
- try {
135
- const filePath = safePath(cwd, args.path as string)
136
- let content: string
137
- try {
138
- content = await readFile(filePath, "utf-8")
139
- } catch {
140
- return { content: [textPart(`File not found: ${args.path}`)], isError: true }
141
- }
142
- const edits = args.edits as Array<{ oldText: string; newText: string }>
143
-
144
- // Validate all edits before applying any — avoids partial writes on bad input
145
- for (const edit of edits) {
146
- const count = content.split(edit.oldText).length - 1
147
- if (count === 0) {
148
- return {
149
- content: [textPart(`oldText not found: "${edit.oldText.slice(0, 80)}…"`)],
150
- isError: true,
151
- }
152
- }
153
- // Ambiguous match would replace the wrong occurrence
154
- if (count > 1) {
155
- return {
156
- content: [
157
- textPart(
158
- `oldText found ${count} times — add surrounding context to make it unique: "${edit.oldText.slice(0, 60)}…"`,
159
- ),
160
- ],
161
- isError: true,
162
- }
163
- }
164
- }
165
-
166
- // Apply edits sequentially
167
- for (const edit of edits) {
168
- content = content.replace(edit.oldText, edit.newText)
169
- }
170
-
171
- await writeFile(filePath, content)
172
- const relPath = getRelativeIfInside(cwd, filePath)
173
- return {
174
- content: [
175
- textPart(
176
- `Edited ${relPath} (${edits.length} replacement${edits.length > 1 ? "s" : ""})`,
177
- ),
178
- ],
179
- isError: false,
180
- }
181
- } catch (e) {
182
- return {
183
- content: [textPart(`Error editing file: ${(e as Error).message}`)],
184
- isError: true,
185
- }
186
- }
187
- },
188
- }
189
- }
package/src/tools/git.ts DELETED
@@ -1,99 +0,0 @@
1
- /**
2
- * Git tools for executing safe repository operations programmatically.
3
- */
4
- import { spawn } from "node:child_process"
5
- import type { Tool, ToolResult } from "../types.ts"
6
- import { textPart } from "../util.ts"
7
-
8
- export function gitTool(cwd: string): Tool {
9
- return {
10
- def: {
11
- name: "git",
12
- description:
13
- "Execute safe, non-interactive git commands (status, diff, log, add, commit) in the repository.",
14
- parameters: {
15
- type: "object",
16
- properties: {
17
- action: {
18
- type: "string",
19
- enum: ["status", "diff", "log", "add", "commit"],
20
- description: "The git action to execute",
21
- },
22
- args: {
23
- type: "array",
24
- description: "Optional additional arguments or file paths for the git action",
25
- items: { type: "string" },
26
- },
27
- },
28
- required: ["action"],
29
- },
30
- },
31
- async execute(args, signal): Promise<ToolResult> {
32
- const action = args.action as string
33
- const extraArgs = (args.args as string[]) || []
34
-
35
- const allowed = new Set(["status", "diff", "log", "add", "commit"])
36
- if (!allowed.has(action)) {
37
- return {
38
- content: [textPart(`Error: Git action '${action}' is not supported.`)],
39
- isError: true,
40
- }
41
- }
42
-
43
- try {
44
- const cmd = ["git", action, ...extraArgs]
45
- const proc = spawn(cmd[0]!, cmd.slice(1), {
46
- cwd,
47
- stdio: ["ignore", "pipe", "pipe"],
48
- env: { ...process.env, PAGER: "cat" },
49
- })
50
-
51
- let stdout = ""
52
- let stderr = ""
53
- proc.stdout.on("data", (chunk: Buffer) => {
54
- stdout += chunk.toString()
55
- })
56
- proc.stderr.on("data", (chunk: Buffer) => {
57
- stderr += chunk.toString()
58
- })
59
-
60
- const onAbort = () => {
61
- proc.kill("SIGKILL")
62
- proc.stdout.destroy()
63
- proc.stderr.destroy()
64
- }
65
- signal?.addEventListener("abort", onAbort, { once: true })
66
-
67
- let exitCode: number
68
- try {
69
- exitCode = await new Promise<number>((resolve, reject) => {
70
- proc.on("error", reject)
71
- proc.on("close", (code) => resolve(code ?? -1))
72
- })
73
- } finally {
74
- signal?.removeEventListener("abort", onAbort)
75
- }
76
-
77
- // Prevent context window blowout by truncating very large outputs
78
- const MAX = 50_000
79
- let out = ""
80
- if (stdout) out += stdout.slice(0, MAX)
81
- if (stderr) {
82
- if (out) out += "\n"
83
- out += stderr.slice(0, MAX - out.length)
84
- }
85
- if (out.length >= MAX) out += "\n…truncated"
86
-
87
- return {
88
- content: [textPart(out || "(no output)")],
89
- isError: exitCode !== 0,
90
- }
91
- } catch (e) {
92
- return {
93
- content: [textPart(`Error running git: ${(e as Error).message}`)],
94
- isError: true,
95
- }
96
- }
97
- },
98
- }
99
- }
@@ -1,33 +0,0 @@
1
- import type { Tool } from "../types.ts"
2
- import { editTool, readTool, writeTool } from "./fs.ts"
3
- import { gitTool } from "./git.ts"
4
- import { globTool, grepTool, lsTool, treeTool } from "./search.ts"
5
- import { bashTool } from "./shell.ts"
6
- import { webFetchTool, webSearchTool } from "./web.ts"
7
-
8
- export function getAllTools(cwd: string): Tool[] {
9
- return [
10
- readTool(cwd),
11
- writeTool(cwd),
12
- editTool(cwd),
13
- bashTool(cwd),
14
- globTool(cwd),
15
- grepTool(cwd),
16
- lsTool(cwd),
17
- treeTool(cwd),
18
- gitTool(cwd),
19
- webSearchTool(),
20
- webFetchTool(),
21
- ]
22
- }
23
-
24
- export function getDefaultTools(cwd: string): Tool[] {
25
- return [
26
- readTool(cwd),
27
- writeTool(cwd),
28
- editTool(cwd),
29
- bashTool(cwd),
30
- webSearchTool(),
31
- webFetchTool(),
32
- ]
33
- }