novacode 0.5.3 → 0.6.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.
- package/README.md +16 -23
- package/dist/app-bQ9a_p_K.mjs +22 -0
- package/dist/app-bQ9a_p_K.mjs.map +1 -0
- package/dist/main.mjs +33 -56
- package/dist/main.mjs.map +1 -1
- package/package.json +3 -4
- package/src/commands/compact.ts +1 -1
- package/src/commands/index.ts +46 -4
- package/src/commands/session.ts +23 -11
- package/src/main.ts +57 -27
- package/src/provider/gemini.ts +11 -3
- package/src/provider/openai.ts +28 -4
- package/src/provider/stream.ts +1 -3
- package/src/session/compact.ts +43 -10
- package/src/session/store.ts +170 -167
- package/src/tools/web.ts +1 -1
- package/src/tui/app.tsx +118 -221
- package/src/tui/components/liveArea.tsx +70 -0
- package/src/tui/components/message.tsx +117 -0
- package/src/tui/components/statusBar.tsx +64 -0
- package/src/tui/constants.ts +25 -0
- package/src/types.ts +14 -0
- package/src/util.ts +19 -0
- package/dist/app-BZ42XPxw.mjs +0 -21
- package/dist/app-BZ42XPxw.mjs.map +0 -1
package/src/session/store.ts
CHANGED
|
@@ -1,206 +1,209 @@
|
|
|
1
|
+
import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
1
2
|
import { join } from "node:path"
|
|
2
|
-
import
|
|
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
|
-
`
|
|
3
|
+
import type { Compaction, Msg, Session } from "../types.ts"
|
|
37
4
|
|
|
38
5
|
function generateId(): string {
|
|
39
6
|
return `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`
|
|
40
7
|
}
|
|
41
8
|
|
|
42
9
|
export class SessionStore {
|
|
43
|
-
#
|
|
10
|
+
#sessionsDir: string
|
|
44
11
|
|
|
45
|
-
constructor(
|
|
46
|
-
this.#
|
|
47
|
-
this.#db.pragma("journal_mode = WAL")
|
|
48
|
-
this.#db.pragma("foreign_keys = ON")
|
|
49
|
-
this.#db.exec(SCHEMA)
|
|
12
|
+
constructor(sessionsDir: string) {
|
|
13
|
+
this.#sessionsDir = sessionsDir
|
|
50
14
|
}
|
|
51
15
|
|
|
52
|
-
|
|
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> {
|
|
53
33
|
const id = generateId()
|
|
54
34
|
const now = Date.now()
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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)
|
|
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)
|
|
116
72
|
}
|
|
117
|
-
|
|
118
|
-
|
|
73
|
+
sessions.sort((a, b) => b.updated - a.updated)
|
|
74
|
+
return sessions.slice(0, limit)
|
|
75
|
+
} catch {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
119
78
|
}
|
|
120
79
|
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
.all({ sid: sessionId }) as { content: string }[]
|
|
125
|
-
return rows.map((r) => JSON.parse(r.content) as Msg)
|
|
80
|
+
async latest(): Promise<Session | null> {
|
|
81
|
+
const sessions = await this.list(1)
|
|
82
|
+
return sessions[0] ?? null
|
|
126
83
|
}
|
|
127
84
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|
|
135
92
|
}
|
|
136
93
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
101
|
}
|
|
142
102
|
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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)
|
|
148
112
|
}
|
|
149
113
|
|
|
150
|
-
|
|
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(
|
|
151
143
|
sessionId: string,
|
|
152
144
|
summary: string,
|
|
153
145
|
filesRead: string[],
|
|
154
146
|
filesWrote: string[],
|
|
155
147
|
seqBefore: number,
|
|
156
|
-
): void {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|
|
185
194
|
}
|
|
186
195
|
|
|
187
196
|
close(): void {
|
|
188
|
-
|
|
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
|
|
197
|
+
// no-op
|
|
196
198
|
}
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
let _store: SessionStore | null = null
|
|
200
202
|
|
|
201
|
-
export function getSessionStore(dir?: string): SessionStore {
|
|
203
|
+
export async function getSessionStore(dir?: string): Promise<SessionStore> {
|
|
202
204
|
if (_store) return _store
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
+
const sessionsPath = join(dir ?? join(process.env.HOME ?? "~", ".novacode"), "sessions")
|
|
206
|
+
await mkdir(sessionsPath, { recursive: true })
|
|
207
|
+
_store = new SessionStore(sessionsPath)
|
|
205
208
|
return _store
|
|
206
209
|
}
|
package/src/tools/web.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Web tools for searching and fetching internet content.
|
|
3
|
-
* Uses DuckDuckGo HTML for search (no API key needed) and
|
|
3
|
+
* Uses DuckDuckGo HTML for search (no API key needed) and Node's built-in
|
|
4
4
|
* fetch for reading URLs.
|
|
5
5
|
*/
|
|
6
6
|
import type { Tool, ToolResult } from "../types.ts"
|