my harness for niri
1import Database from "better-sqlite3"
2import fs from "fs"
3import path from "path"
4import * as sqliteVec from "sqlite-vec"
5import { fileURLToPath } from "url"
6
7const HOME_DIR = path.resolve(fileURLToPath(import.meta.url), "../../home")
8const DB_PATH = path.join(HOME_DIR, "niri.db")
9export const MEMORY_EMBEDDING_DIMENSIONS = 3072
10
11let db: Database.Database
12let vecAvailable = false
13
14function ensureWritableDirOrThrow(dirPath: string, purpose: string): void {
15 try {
16 fs.mkdirSync(dirPath, { recursive: true })
17 fs.accessSync(dirPath, fs.constants.W_OK)
18 } catch (err: any) {
19 let owner = "unknown"
20 try {
21 const st = fs.statSync(dirPath)
22 owner = `${st.uid}:${st.gid}`
23 } catch {
24 // ignore
25 }
26
27 const uid = typeof process.getuid === "function" ? process.getuid() : undefined
28 const gid = typeof process.getgid === "function" ? process.getgid() : undefined
29 const who = uid !== undefined && gid !== undefined ? `${uid}:${gid}` : "current user"
30
31 throw new Error(
32 [
33 `[db] cannot write ${purpose} under ${dirPath}`,
34 `- dir owner: ${owner}`,
35 `- process uid:gid: ${who}`,
36 "",
37 "Fix:",
38 "- If running locally: `sudo chown -R $(id -u):$(id -g) home`",
39 "- If running via docker-compose: set `AGENT_UID`/`AGENT_GID` in .env to match `id -u`/`id -g`, then recreate the container",
40 "",
41 `Original error: ${err?.message ?? String(err)}`,
42 ].join("\n"),
43 )
44 }
45}
46
47export function initDb(): void {
48 ensureWritableDirOrThrow(HOME_DIR, "niri.db")
49 db = new Database(DB_PATH)
50
51 db.pragma("journal_mode = WAL")
52 db.pragma("foreign_keys = ON")
53 try {
54 sqliteVec.load(db)
55 vecAvailable = true
56 } catch (err: any) {
57 vecAvailable = false
58 console.warn(`[db] sqlite-vec unavailable: ${err?.message ?? String(err)}`)
59 }
60
61 db.exec(`
62 create table if not exists conversations (
63 id integer primary key autoincrement,
64 startedAt text not null,
65 source text not null,
66 tokens integer not null default 0
67 );
68
69 create table if not exists messages (
70 id integer primary key autoincrement,
71 convId integer not null references conversations(id),
72 role text not null,
73 content text not null,
74 toolCalls text, -- json blob, null if none
75 toolCallId text, -- for role=tool responses
76 createdAt text not null default (datetime('now'))
77 );
78
79 create table if not exists discord_messages (
80 message_id text primary key,
81 channel_id text not null,
82 guild_id text,
83 channel_type integer,
84 author_id text,
85 author_username text,
86 content text not null default '',
87 created_at text not null,
88 is_dm integer not null default 0,
89 mentions_bot integer not null default 0,
90 is_from_bot integer not null default 0,
91 first_seen_at text not null,
92 last_seen_at text not null,
93 raw_json text not null
94 );
95
96 create index if not exists idx_discord_messages_channel
97 on discord_messages(channel_id, message_id desc);
98 create index if not exists idx_discord_messages_created
99 on discord_messages(created_at desc);
100
101 create table if not exists discord_items (
102 item_id text primary key,
103 message_id text not null references discord_messages(message_id) on delete cascade,
104 bucket text not null,
105 status text not null default 'pending',
106 action_taken text not null default 'none',
107 decision_note text,
108 first_seen_at text not null,
109 last_seen_at text not null,
110 last_decision_at text
111 );
112
113 create index if not exists idx_discord_items_status
114 on discord_items(status, last_seen_at desc);
115 create index if not exists idx_discord_items_message
116 on discord_items(message_id);
117
118 create table if not exists discord_channels (
119 channel_id text primary key,
120 guild_id text,
121 channel_type integer,
122 channel_name text,
123 guild_name text,
124 topic text,
125 is_dm integer not null default 0,
126 configured integer not null default 0,
127 note text,
128 last_note_at text,
129 first_seen_at text not null,
130 last_seen_at text not null,
131 raw_json text not null
132 );
133
134 create index if not exists idx_discord_channels_configured
135 on discord_channels(configured, guild_name, channel_name);
136 create index if not exists idx_discord_channels_last_seen
137 on discord_channels(last_seen_at desc);
138
139 create table if not exists discord_meta (
140 key text primary key,
141 value text not null,
142 updated_at text not null
143 );
144
145 create table if not exists memory_documents (
146 id integer primary key autoincrement,
147 path text not null unique,
148 kind text not null,
149 title text not null,
150 mtime_ms integer not null,
151 content_hash text not null,
152 updated_at text not null default (datetime('now'))
153 );
154
155 create index if not exists idx_memory_documents_kind
156 on memory_documents(kind, path);
157
158 create table if not exists memory_chunks (
159 id integer primary key autoincrement,
160 document_id integer not null references memory_documents(id) on delete cascade,
161 chunk_index integer not null,
162 title text not null,
163 heading_path text,
164 chunk_text text not null,
165 tags text,
166 created_at text not null default (datetime('now')),
167 unique(document_id, chunk_index)
168 );
169
170 create index if not exists idx_memory_chunks_document
171 on memory_chunks(document_id, chunk_index);
172
173 create virtual table if not exists memory_chunks_fts using fts5(
174 title,
175 heading_path,
176 chunk_text,
177 tags,
178 content='memory_chunks',
179 content_rowid='id',
180 tokenize='porter unicode61'
181 );
182
183 create trigger if not exists memory_chunks_ai after insert on memory_chunks begin
184 insert into memory_chunks_fts(rowid, title, heading_path, chunk_text, tags)
185 values (new.id, new.title, new.heading_path, new.chunk_text, new.tags);
186 end;
187
188 create trigger if not exists memory_chunks_ad after delete on memory_chunks begin
189 insert into memory_chunks_fts(memory_chunks_fts, rowid, title, heading_path, chunk_text, tags)
190 values ('delete', old.id, old.title, old.heading_path, old.chunk_text, old.tags);
191 end;
192
193 create trigger if not exists memory_chunks_au after update on memory_chunks begin
194 insert into memory_chunks_fts(memory_chunks_fts, rowid, title, heading_path, chunk_text, tags)
195 values ('delete', old.id, old.title, old.heading_path, old.chunk_text, old.tags);
196 insert into memory_chunks_fts(rowid, title, heading_path, chunk_text, tags)
197 values (new.id, new.title, new.heading_path, new.chunk_text, new.tags);
198 end;
199
200 create table if not exists memory_embedding_meta (
201 chunk_id integer primary key references memory_chunks(id) on delete cascade,
202 model text not null,
203 dimensions integer not null,
204 content_hash text not null,
205 updated_at text not null default (datetime('now'))
206 );
207
208 create index if not exists idx_memory_embedding_meta_model
209 on memory_embedding_meta(model, dimensions);
210
211 create table if not exists memory_embedding_prototypes (
212 id integer primary key,
213 name text not null unique,
214 category text not null,
215 model text not null,
216 dimensions integer not null,
217 content_hash text not null,
218 updated_at text not null default (datetime('now'))
219 );
220 `)
221
222 if (vecAvailable) {
223 db.exec(`
224 create virtual table if not exists memory_chunk_vec using vec0(
225 embedding float[${MEMORY_EMBEDDING_DIMENSIONS}] distance_metric=cosine
226 );
227
228 create virtual table if not exists memory_prototype_vec using vec0(
229 embedding float[${MEMORY_EMBEDDING_DIMENSIONS}] distance_metric=cosine
230 );
231 `)
232 }
233
234 console.log("[db] ready")
235}
236
237export function startConversation(source: string, startedAt: string): number {
238 const stmt = db.prepare("insert into conversations (startedAt, source) values (?, ?)")
239 const result = stmt.run(startedAt, source)
240 return result.lastInsertRowid as number
241}
242
243export function logMessage(
244 convId: number,
245 role: string,
246 content: string,
247 toolCalls?: unknown,
248 toolCallId?: string,
249): void {
250 const stmt = db.prepare(
251 "insert into messages (convId, role, content, toolCalls, toolCallId) values (?, ?, ?, ?, ?)",
252 )
253 stmt.run(
254 convId,
255 role,
256 content,
257 toolCalls ? JSON.stringify(toolCalls) : null,
258 toolCallId ?? null,
259 )
260}
261
262export function endConversation(id: number, tokens: number): void {
263 db.prepare("update conversations set tokens = ? where id = ?").run(tokens, id)
264}
265
266export function getDb(): Database.Database {
267 if (!db) throw new Error("Database not initialized")
268 return db
269}
270
271export function isVecAvailable(): boolean {
272 return vecAvailable
273}