Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { z } from "zod";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

export const LoreConfig = z.object({
model: z
Expand Down Expand Up @@ -119,9 +121,9 @@ export function config(): LoreConfig {
}

export async function load(directory: string): Promise<LoreConfig> {
const file = Bun.file(`${directory}/.lore.json`);
if (await file.exists()) {
const raw = await file.json();
const path = join(directory, ".lore.json");
if (existsSync(path)) {
const raw = JSON.parse(readFileSync(path, "utf8"));
current = LoreConfig.parse(raw);
return current;
}
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Database } from "bun:sqlite";
import { Database } from "#db/driver";
import { join, dirname } from "path";
import { mkdirSync } from "fs";

Expand Down Expand Up @@ -303,7 +303,11 @@ export function db(): Database {
mkdirSync(dir, { recursive: true });
path = join(dir, "lore.db");
}
instance = new Database(path, { create: true });
// Both `bun:sqlite` and `node:sqlite` create the file by default if it doesn't
// exist, so no special option is needed. (bun:sqlite's `{ create: true }`
// exists only to opt INTO creation when you want readonly=false — which is
// already the default for our case.)
instance = new Database(path);
instance.exec("PRAGMA journal_mode = WAL");
instance.exec("PRAGMA foreign_keys = ON");
// Return freed pages to the OS incrementally on each transaction commit
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/db/driver.bun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Bun runtime driver for Lore's SQLite access.
//
// Selected automatically via the `#db/driver` subpath import map when running
// under Bun (OpenCode plugin, `bun test`).
//
// The `Database` class is re-exported as-is; `bun:sqlite`'s API already matches
// everything Lore uses: `.query(sql)` with cached prepared statements, `.run()`,
// `.all()`, `.get()`, transactions, PRAGMAs, BLOB columns, and FTS5.

import { Database } from "bun:sqlite";
import { createHash } from "node:crypto";

export { Database };

/** Stable SHA-256 hex digest — replaces the Bun-only `Bun.CryptoHasher`. */
export function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
54 changes: 54 additions & 0 deletions packages/core/src/db/driver.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Node runtime driver for Lore's SQLite access.
//
// Selected via the `#db/driver` subpath import map when running under Node
// (Pi extension, future ACP server, and CI nodes that aren't Bun). `node:sqlite`
// has shipped in Node since 22.5 and stabilized (no flag) in Node 24.
//
// Bun deliberately does NOT implement `node:sqlite`, so src code that imports
// from this file must go through `#db/driver`. Never import `node:sqlite`
// directly outside this file — it will break `bun test` which runs against src.

import { DatabaseSync, type StatementSync } from "node:sqlite";
import { createHash } from "node:crypto";

/**
* Per-database cache of prepared statements keyed by SQL string.
*
* `bun:sqlite` automatically caches prepared statements per-DB when using
* `.query(sql)`; `node:sqlite` has only `.prepare(sql)` which recompiles on
* every call. We add a thin `.query()` alias on top of `.prepare()` with
* caching so every existing call site (`db().query(...).all(...)`) keeps
* working identically.
*
* WeakMap: cache is tied to the Database instance lifetime, no manual cleanup.
*/
const statementCache = new WeakMap<DatabaseSync, Map<string, StatementSync>>();

/**
* Drop-in replacement for `bun:sqlite`'s `Database`.
*
* Adds a `.query()` method that caches the underlying `StatementSync`
* per SQL string. All other methods (`.prepare()`, `.exec()`, `.run()`,
* `.close()`, PRAGMAs, transactions) come from `DatabaseSync` unchanged.
*/
export class Database extends DatabaseSync {
/** Cached prepared statement for this SQL. Compiled on first call. */
query(sql: string): StatementSync {
let map = statementCache.get(this);
if (!map) {
map = new Map<string, StatementSync>();
statementCache.set(this, map);
}
let stmt = map.get(sql);
if (!stmt) {
stmt = this.prepare(sql);
map.set(sql, stmt);
}
return stmt;
}
}

/** Stable SHA-256 hex digest — replaces the Bun-only `Bun.CryptoHasher`. */
export function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
4 changes: 3 additions & 1 deletion packages/core/src/distillation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,9 @@ function resetOrphans(projectPath: string, sessionID: string): number {
"UPDATE temporal_messages SET distilled = 0 WHERE project_id = ? AND session_id = ? AND distilled = 1",
)
.run(pid, sessionID);
return result.changes;
// node:sqlite returns `changes` as `number | bigint`; bun:sqlite returns `number`.
// Coerce to number — SQLite will never return a row count > 2^53.
return Number(result.changes);
}
// Find orphans: marked distilled but not in any source_ids
const distilled = db()
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/lat-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { join, relative, basename } from "path";
import { remark } from "remark";
import type { Root, Heading, Paragraph, Text } from "mdast";
import { db, ensureProject } from "./db";
import { sha256 } from "#db/driver";
import { ftsQuery, ftsQueryOr, extractTopTerms, EMPTY_QUERY } from "./search";
import * as log from "./log";

Expand Down Expand Up @@ -171,9 +172,7 @@ function listMarkdownFiles(dir: string): string[] {

/** Compute SHA-256 hash of file content for change detection. */
function contentHash(content: string): string {
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(content);
return hasher.digest("hex");
return sha256(content);
}

// ---- Public API ----
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/ltm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,8 @@ export function pruneOversized(maxLength: number): number {
"UPDATE knowledge SET confidence = 0, updated_at = ? WHERE LENGTH(content) > ? AND confidence > 0",
)
.run(Date.now(), maxLength);
return result.changes;
// node:sqlite returns `changes` as `number | bigint`; coerce for cross-runtime parity.
return Number(result.changes);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -710,7 +711,8 @@ export function cascadeRefReplace(oldId: string, newId: string): number {
// Clean up any rows that became self-referential
db().query("DELETE FROM knowledge_refs WHERE from_id = to_id").run();

return result.changes;
// node:sqlite returns `changes` as `number | bigint`; coerce for cross-runtime parity.
return Number(result.changes);
}

/**
Expand Down
63 changes: 63 additions & 0 deletions packages/core/test/db-driver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { test, expect } from "bun:test";
import { Database, sha256 } from "../src/db/driver.bun";

// Smoke tests for the db driver shim — confirms the API surface Lore relies on
// is identical between the bun and node drivers. The full Lore test suite
// exercises the rest via normal DB usage; this file exists mostly so failures
// surface at `bun test` time if we ever drift, and so we have something to
// audit when adding a future ffi-based driver.

test("Database.query() returns a cached prepared statement", () => {
const db = new Database(":memory:");
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");

// Two query() calls with the same SQL should be able to run independently
// (bun:sqlite and the node shim both achieve this via caching).
const insert = db.query("INSERT INTO t (id, name) VALUES (?, ?)");
insert.run(1, "foo");
insert.run(2, "bar");

const all = db.query("SELECT id, name FROM t ORDER BY id").all();
expect(all).toEqual([
{ id: 1, name: "foo" },
{ id: 2, name: "bar" },
]);

const get = db.query("SELECT id, name FROM t WHERE id = ?").get(1);
expect(get).toEqual({ id: 1, name: "foo" });

db.close();
});

test("FTS5 MATCH and bm25() work via the driver", () => {
const db = new Database(":memory:");
db.exec("CREATE VIRTUAL TABLE f USING fts5(content, tokenize='porter unicode61')");
db.exec("INSERT INTO f (content) VALUES ('hello world'), ('goodbye moon')");
const rows = db
.query("SELECT content, bm25(f) AS score FROM f WHERE f MATCH ? ORDER BY score")
.all("hello") as Array<{ content: string; score: number }>;
expect(rows.length).toBe(1);
expect(rows[0].content).toBe("hello world");
expect(typeof rows[0].score).toBe("number");
db.close();
});

test("DELETE...RETURNING works via the driver", () => {
const db = new Database(":memory:");
db.exec("CREATE TABLE q (id INTEGER PRIMARY KEY, data TEXT)");
db.query("INSERT INTO q (id, data) VALUES (?, ?)").run(1, "alpha");
const returned = db
.query("DELETE FROM q WHERE id = ? RETURNING data")
.all(1) as Array<{ data: string }>;
expect(returned).toEqual([{ data: "alpha" }]);
db.close();
});

test("sha256() returns a stable hex digest", () => {
expect(sha256("hello")).toBe(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
);
expect(sha256("")).toBe(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
});
4 changes: 2 additions & 2 deletions packages/opencode/scripts/list-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ type SessionRow = {
const projectFilter = values.project;

const rows = db()
.query<SessionRow, []>(
.query(
`SELECT
p.path AS project_path,
t.session_id,
Expand All @@ -48,7 +48,7 @@ const rows = db()
GROUP BY t.project_id, t.session_id
ORDER BY last_msg DESC`,
)
.all();
.all() as SessionRow[];

const filtered = projectFilter
? rows.filter((r) => r.project_path.includes(projectFilter))
Expand Down
Loading