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.
@@ -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 BetterSqlite3 from "better-sqlite3"
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
- #db: BetterSqlite3.Database
10
+ #sessionsDir: string
44
11
 
45
- constructor(dbPath: string) {
46
- this.#db = new BetterSqlite3(dbPath)
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
- create(cwd: string, model: string, provider: string): Session {
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
- 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)
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
- tx()
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
- 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)
80
+ async latest(): Promise<Session | null> {
81
+ const sessions = await this.list(1)
82
+ return sessions[0] ?? null
126
83
  }
127
84
 
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)
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
- 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 })
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
- 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
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
- saveCompaction(
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
- 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 })
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
- this.#db.close()
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 dbPath = join(dir ?? join(process.env.HOME ?? "~", ".novacode"), "sessions.db")
204
- _store = new SessionStore(dbPath)
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 Bun's built-in
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"