The spec: plan_write and plan_update (new, shared, offered in every mode) and plan_submit (now shared) keep one structured plan — findings, objectives, phases of tasks — in place of the todo list, which lives on as a former name of plan_write. The shared system text is one text for both projects, with variables for what differs; the mode texts, a prototypes block and a library block are shared. acp.md: the client hands the device what only it knows (context, library, prototypes), the plan travels as a structure (plan_state), library calls carry the chat's scope, and a client may rewind a session. loop.json says its numbers as numbers. Four conformance areas: plan, prototype, library_scope, toolcall_index. The CLI: the plan is kept per session in the store and drawn above the prompt and on the approval card; the prompt is assembled from the spec's texts with their variables filled; the link reads the instance's instructions, personalization, knowledge scope and prototype offer, sends the plan as a structure and answers rewind; library calls to the instance carry the session as scope; a prototype written in the terminal is saved as a file and named in the transcript; a tool-call fragment without an index continues the last call; the default theme is Moria; dead exports and Hermes leftovers removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
316 lines
15 KiB
TypeScript
316 lines
15 KiB
TypeScript
// The library, after LLeMbas's (services/library/, © Jaroslav Beneš, MIT):
|
|
// notes the agent keeps, and knowledge bases of documents — files, directories, web pages —
|
|
// split into pieces and searched by their words (FTS5, bm25) and, when an embedding model is
|
|
// set, by meaning too, the two fused by reciprocal rank. One SQLite file,
|
|
// ~/.local/share/lembas/library.db, shared by every project; a project may narrow which
|
|
// bases it searches (`knowledge:` in its config).
|
|
import { Database } from "bun:sqlite"
|
|
import { createHash } from "node:crypto"
|
|
import { mkdirSync } from "node:fs"
|
|
import { join } from "node:path"
|
|
import { paths } from "../config/paths.ts"
|
|
import { anyOf, ftsQuery } from "../tool/session_search.ts"
|
|
import { split } from "./chunks.ts"
|
|
|
|
const SCHEMA = `
|
|
CREATE TABLE IF NOT EXISTS notes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL,
|
|
created INTEGER NOT NULL, updated INTEGER NOT NULL
|
|
);
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(title, body, content='notes', content_rowid='id');
|
|
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
|
|
INSERT INTO notes_fts (rowid, title, body) VALUES (new.id, new.title, new.body);
|
|
END;
|
|
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
|
|
INSERT INTO notes_fts (notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
|
|
END;
|
|
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
|
|
INSERT INTO notes_fts (notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
|
|
INSERT INTO notes_fts (rowid, title, body) VALUES (new.id, new.title, new.body);
|
|
END;
|
|
|
|
CREATE TABLE IF NOT EXISTS bases (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS documents (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, base_id INTEGER NOT NULL REFERENCES bases(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL, source TEXT NOT NULL, text TEXT NOT NULL, hash TEXT NOT NULL,
|
|
truncated INTEGER NOT NULL DEFAULT 0, error TEXT, added INTEGER NOT NULL,
|
|
UNIQUE (base_id, source)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS chunks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
ordinal INTEGER NOT NULL, text TEXT NOT NULL, vector BLOB, dims INTEGER, model TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS chunks_doc ON chunks(doc_id, ordinal);
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, content='chunks', content_rowid='id');
|
|
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
|
|
INSERT INTO chunks_fts (rowid, text) VALUES (new.id, new.text);
|
|
END;
|
|
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
|
|
INSERT INTO chunks_fts (chunks_fts, rowid, text) VALUES ('delete', old.id, old.text);
|
|
END;
|
|
`
|
|
|
|
/** Reciprocal-rank fusion: a result's score is the sum over the lists it is in of 1/(K + rank). */
|
|
export const RRF_K = 60
|
|
/** Pieces looked at per document wanted: a document scores as its best piece. */
|
|
const CHUNK_MULTIPLIER = 6
|
|
export const SNIPPET_CHARS = 1200
|
|
|
|
export interface Base {
|
|
id: number
|
|
name: string
|
|
description: string
|
|
documents: number
|
|
}
|
|
|
|
export interface Doc {
|
|
id: number
|
|
base: string
|
|
title: string
|
|
source: string
|
|
chars: number
|
|
truncated: boolean
|
|
error?: string
|
|
added: number
|
|
}
|
|
|
|
export interface Hit {
|
|
id: number
|
|
base: string
|
|
title: string
|
|
source: string
|
|
snippet: string
|
|
}
|
|
|
|
export interface Note {
|
|
id: number
|
|
scope: string
|
|
title: string
|
|
body: string
|
|
updated: number
|
|
}
|
|
|
|
/** knowledge_search's `base` against the bases a conversation may search (harness
|
|
* conformance/library_scope.json, shared with LLeMbas): no `base` is all of them; an id matches
|
|
* first, then a name whatever its case; anything else is the error the tool answers with,
|
|
* naming what there is. */
|
|
export function resolveBase(bases: { id: string; name: string }[], base: string | null | undefined): { ids: string[] } | { error: string } {
|
|
const wanted = (base ?? "").trim()
|
|
if (!wanted) return { ids: bases.map((b) => b.id) }
|
|
const byId = bases.find((b) => b.id === wanted)
|
|
if (byId) return { ids: [byId.id] }
|
|
const lower = wanted.toLowerCase()
|
|
const byName = bases.find((b) => b.name.toLowerCase() === lower)
|
|
if (byName) return { ids: [byName.id] }
|
|
return { error: `No knowledge base called "${wanted}" here. Available: ${bases.map((b) => b.name).join(", ") || "none"}.` }
|
|
}
|
|
|
|
/** An embedding model: turns texts into vectors (unit length), all of one width. */
|
|
export interface Embedder {
|
|
model: string
|
|
embed(texts: string[], signal?: AbortSignal): Promise<Float32Array[]>
|
|
}
|
|
|
|
const hash = (s: string) => createHash("sha256").update(s).digest("hex")
|
|
const toBlob = (v: Float32Array) => new Uint8Array(v.buffer, v.byteOffset, v.byteLength)
|
|
const fromBlob = (b: Uint8Array) => new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength))
|
|
|
|
export class Library {
|
|
readonly db: Database
|
|
|
|
constructor(file = join(paths.data, "library.db")) {
|
|
mkdirSync(paths.data, { recursive: true })
|
|
this.db = new Database(file, { create: true })
|
|
this.db.run("PRAGMA journal_mode = WAL")
|
|
this.db.run("PRAGMA foreign_keys = ON")
|
|
this.db.exec(SCHEMA)
|
|
}
|
|
|
|
close() {
|
|
this.db.close()
|
|
}
|
|
|
|
// ── notes ────────────────────────────────────────────────────────────────────────────────
|
|
addNote(scope: string, title: string, body: string): Note {
|
|
const now = Date.now()
|
|
const r = this.db.query("INSERT INTO notes (scope, title, body, created, updated) VALUES (?, ?, ?, ?, ?)").run(scope, title, body, now, now)
|
|
return { id: Number(r.lastInsertRowid), scope, title, body, updated: now }
|
|
}
|
|
|
|
note(id: number): Note | undefined {
|
|
return (this.db.query("SELECT id, scope, title, body, updated FROM notes WHERE id = ?").get(id) as Note | null) ?? undefined
|
|
}
|
|
|
|
editNote(id: number, change: { title?: string; body?: string }) {
|
|
const n = this.note(id)
|
|
if (!n) throw new Error(`there is no note ${id}`)
|
|
this.db.query("UPDATE notes SET title = ?, body = ?, updated = ? WHERE id = ?").run(change.title ?? n.title, change.body ?? n.body, Date.now(), id)
|
|
}
|
|
|
|
deleteNote(id: number) {
|
|
this.db.query("DELETE FROM notes WHERE id = ?").run(id)
|
|
}
|
|
|
|
/** Notes in these scopes ("global", and a project's root), by words — or the newest, with no query. */
|
|
notes(scopes: string[], query?: string, limit = 10): Note[] {
|
|
const inScope = `n.scope IN (${scopes.map(() => "?").join(",")})`
|
|
if (!query?.trim()) return this.db.query(`SELECT id, scope, title, body, updated FROM notes n WHERE ${inScope} ORDER BY updated DESC LIMIT ?`).all(...scopes, limit) as Note[]
|
|
const run = (match: string) =>
|
|
this.db.query(`SELECT n.id, n.scope, n.title, n.body, n.updated FROM notes_fts f JOIN notes n ON n.id = f.rowid WHERE notes_fts MATCH ? AND ${inScope} ORDER BY bm25(notes_fts) LIMIT ?`).all(match, ...scopes, limit) as Note[]
|
|
const q = ftsQuery(query)
|
|
if (!q) return []
|
|
const all = run(q)
|
|
const any = all.length ? undefined : anyOf(q)
|
|
return any ? run(any) : all
|
|
}
|
|
|
|
// ── knowledge bases ──────────────────────────────────────────────────────────────────────
|
|
createBase(name: string, description = ""): Base {
|
|
if (!/^[\w.-]{1,64}$/.test(name)) throw new Error(`"${name}" is not a base name: letters, digits, - _ . — up to 64`)
|
|
if (this.base(name)) throw new Error(`there is a knowledge base "${name}" already`)
|
|
const r = this.db.query("INSERT INTO bases (name, description, created) VALUES (?, ?, ?)").run(name, description, Date.now())
|
|
return { id: Number(r.lastInsertRowid), name, description, documents: 0 }
|
|
}
|
|
|
|
base(name: string): Base | undefined {
|
|
return (this.db.query("SELECT b.id, b.name, b.description, (SELECT count(*) FROM documents d WHERE d.base_id = b.id) AS documents FROM bases b WHERE b.name = ?").get(name) as Base | null) ?? undefined
|
|
}
|
|
|
|
bases(): Base[] {
|
|
return this.db.query("SELECT b.id, b.name, b.description, (SELECT count(*) FROM documents d WHERE d.base_id = b.id) AS documents FROM bases b ORDER BY b.name").all() as Base[]
|
|
}
|
|
|
|
deleteBase(name: string) {
|
|
this.db.query("DELETE FROM bases WHERE name = ?").run(name)
|
|
}
|
|
|
|
documents(base: string): Doc[] {
|
|
return (
|
|
this.db
|
|
.query("SELECT d.id, b.name AS base, d.title, d.source, length(d.text) AS chars, d.truncated, d.error, d.added FROM documents d JOIN bases b ON b.id = d.base_id WHERE b.name = ? ORDER BY d.title")
|
|
.all(base) as (Omit<Doc, "truncated" | "error"> & { truncated: number; error: string | null })[]
|
|
).map((d) => ({ ...d, truncated: !!d.truncated, error: d.error ?? undefined }))
|
|
}
|
|
|
|
document(id: number): (Doc & { text: string }) | undefined {
|
|
const d = this.db
|
|
.query("SELECT d.id, b.name AS base, d.title, d.source, d.text, length(d.text) AS chars, d.truncated, d.error, d.added FROM documents d JOIN bases b ON b.id = d.base_id WHERE d.id = ?")
|
|
.get(id) as (Omit<Doc, "truncated" | "error"> & { text: string; truncated: number; error: string | null }) | null
|
|
return d ? { ...d, truncated: !!d.truncated, error: d.error ?? undefined } : undefined
|
|
}
|
|
|
|
deleteDocument(id: number) {
|
|
this.db.query("DELETE FROM documents WHERE id = ?").run(id)
|
|
}
|
|
|
|
/** Put a document into a base, or replace the one from the same source. Unchanged text is left as
|
|
* it is (its pieces and vectors kept). Returns the document's id and whether anything changed. */
|
|
putDocument(base: string, d: { title: string; source: string; text: string; truncated?: boolean; error?: string }): { id: number; changed: boolean } {
|
|
const b = this.base(base)
|
|
if (!b) throw new Error(`there is no knowledge base "${base}"`)
|
|
const h = hash(d.text)
|
|
const old = this.db.query("SELECT id, hash FROM documents WHERE base_id = ? AND source = ?").get(b.id, d.source) as { id: number; hash: string } | null
|
|
if (old && old.hash === h) return { id: old.id, changed: false }
|
|
return this.db.transaction(() => {
|
|
if (old) this.db.query("DELETE FROM documents WHERE id = ?").run(old.id)
|
|
const r = this.db
|
|
.query("INSERT INTO documents (base_id, title, source, text, hash, truncated, error, added) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
.run(b.id, d.title, d.source, d.text, h, d.truncated ? 1 : 0, d.error ?? null, Date.now())
|
|
const id = Number(r.lastInsertRowid)
|
|
const insert = this.db.query("INSERT INTO chunks (doc_id, ordinal, text) VALUES (?, ?, ?)")
|
|
split(d.text).forEach((t, i) => insert.run(id, i, t))
|
|
return { id, changed: true }
|
|
})()
|
|
}
|
|
|
|
/** Pieces with no vector from this model (new ones, or all of them after the model changed). */
|
|
unembedded(model: string, limit = 256): { id: number; text: string }[] {
|
|
return this.db.query("SELECT id, text FROM chunks WHERE vector IS NULL OR model IS NOT ? LIMIT ?").all(model, limit) as { id: number; text: string }[]
|
|
}
|
|
|
|
setVectors(model: string, rows: { id: number; vector: Float32Array }[]) {
|
|
const q = this.db.query("UPDATE chunks SET vector = ?, dims = ?, model = ? WHERE id = ?")
|
|
this.db.transaction(() => {
|
|
for (const r of rows) q.run(toBlob(r.vector), r.vector.length, model, r.id)
|
|
})()
|
|
}
|
|
|
|
/** Give every piece a vector from `e`, in batches; returns how many were embedded. */
|
|
async embedAll(e: Embedder, signal?: AbortSignal, batch = 16): Promise<number> {
|
|
let n = 0
|
|
for (;;) {
|
|
const rows = this.unembedded(e.model, batch)
|
|
if (!rows.length) return n
|
|
const vectors = await e.embed(rows.map((r) => r.text), signal)
|
|
if (vectors.length !== rows.length) throw new Error(`the embedding model returned ${vectors.length} vectors for ${rows.length} texts`)
|
|
this.setVectors(
|
|
e.model,
|
|
rows.map((r, i) => ({ id: r.id, vector: vectors[i]! })),
|
|
)
|
|
n += rows.length
|
|
}
|
|
}
|
|
|
|
/** Documents for a query: words (bm25) and, with an embedder, meaning (cosine) — fused by RRF.
|
|
* `bases`: only these (undefined: every base). */
|
|
async search(query: string, o: { bases?: string[]; limit?: number; embedder?: Embedder; signal?: AbortSignal } = {}): Promise<Hit[]> {
|
|
const limit = o.limit ?? 6
|
|
const want = limit * CHUNK_MULTIPLIER
|
|
const inBases = o.bases ? `AND b.name IN (${o.bases.map(() => "?").join(",") || "''"})` : ""
|
|
const bargs = o.bases ?? []
|
|
const lists: number[][] = []
|
|
|
|
const q = ftsQuery(query)
|
|
if (q) {
|
|
const run = (match: string) =>
|
|
(
|
|
this.db
|
|
.query(`SELECT c.id FROM chunks_fts f JOIN chunks c ON c.id = f.rowid JOIN documents d ON d.id = c.doc_id JOIN bases b ON b.id = d.base_id WHERE chunks_fts MATCH ? ${inBases} ORDER BY bm25(chunks_fts) LIMIT ?`)
|
|
.all(match, ...bargs, want) as { id: number }[]
|
|
).map((r) => r.id)
|
|
let ids = run(q)
|
|
const any = ids.length ? undefined : anyOf(q)
|
|
if (any) ids = run(any)
|
|
lists.push(ids)
|
|
}
|
|
|
|
if (o.embedder) {
|
|
const [v] = await o.embedder.embed([query], o.signal)
|
|
if (v) {
|
|
const rows = this.db
|
|
.query(`SELECT c.id, c.vector FROM chunks c JOIN documents d ON d.id = c.doc_id JOIN bases b ON b.id = d.base_id WHERE c.model = ? AND c.dims = ? ${inBases}`)
|
|
.all(o.embedder.model, v.length, ...bargs) as { id: number; vector: Uint8Array }[]
|
|
const scored = rows.map((r) => {
|
|
const w = fromBlob(r.vector)
|
|
let dot = 0
|
|
for (let i = 0; i < w.length; i++) dot += w[i]! * v[i]!
|
|
return { id: r.id, dot }
|
|
})
|
|
scored.sort((a, b) => b.dot - a.dot)
|
|
lists.push(scored.slice(0, want).map((s) => s.id))
|
|
}
|
|
}
|
|
|
|
// Fused per piece, then a document scores as its best piece.
|
|
const score = new Map<number, number>()
|
|
for (const list of lists) list.forEach((id, rank) => score.set(id, (score.get(id) ?? 0) + 1 / (RRF_K + rank + 1)))
|
|
const best = new Map<number, { chunk: number; score: number }>()
|
|
const docOf = this.db.query("SELECT doc_id FROM chunks WHERE id = ?")
|
|
for (const [chunk, s] of score) {
|
|
const doc = (docOf.get(chunk) as { doc_id: number }).doc_id
|
|
const cur = best.get(doc)
|
|
if (!cur || s > cur.score) best.set(doc, { chunk, score: s })
|
|
}
|
|
const top = [...best.entries()].sort((a, b) => b[1].score - a[1].score).slice(0, limit)
|
|
const meta = this.db.query("SELECT d.id, b.name AS base, d.title, d.source FROM documents d JOIN bases b ON b.id = d.base_id WHERE d.id = ?")
|
|
const text = this.db.query("SELECT text FROM chunks WHERE id = ?")
|
|
return top.map(([doc, { chunk }]) => {
|
|
const m = meta.get(doc) as { id: number; base: string; title: string; source: string }
|
|
const t = (text.get(chunk) as { text: string }).text
|
|
return { ...m, snippet: t.length > SNIPPET_CHARS ? `${t.slice(0, SNIPPET_CHARS)}…` : t }
|
|
})
|
|
}
|
|
}
|