@noir-ai/store 1.0.0-beta.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agaaaptr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @noir-ai/store
2
+
3
+ Embedded storage for Noir: `better-sqlite3` + FTS5 (BM25 ranking with window snippets) + `sqlite-vec` (384-dimensional vector kNN). The daemon owns the single write handle; a read-only filesystem fallback covers the daemon-down case. The `ProjectId`-keyed database lives at `.noir/store/<projectId>.db`.
4
+
5
+ Part of the **[Noir](https://github.com/agaaaptr/noir#readme)** toolkit — the discipline, context, and memory layer for any agentic CLI.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @noir-ai/store
11
+ ```
12
+
13
+ > Most users install the CLI instead, which pulls in the packages it needs:
14
+ >
15
+ > ```bash
16
+ > npm install -g @noir-ai/cli
17
+ > ```
18
+
19
+ ## License
20
+
21
+ MIT © agaaaptr
@@ -0,0 +1,116 @@
1
+ import Database from 'better-sqlite3';
2
+ import { ProjectId } from '@noir-ai/core';
3
+
4
+ /**
5
+ * Hand-rolled versioned-SQL migration runner.
6
+ *
7
+ * Idempotent: tracks applied versions in a `schema_version` table and only runs
8
+ * each migration once. Each migration is applied in its own transaction; on
9
+ * failure the transaction is rolled back and the error re-thrown.
10
+ */
11
+ declare function migrate(db: Database.Database): void;
12
+
13
+ interface OpenOptions {
14
+ projectId: ProjectId;
15
+ root: string;
16
+ readonly?: boolean;
17
+ }
18
+ interface FtsHit {
19
+ id: string;
20
+ source: string;
21
+ score: number;
22
+ snippet: string;
23
+ meta?: unknown;
24
+ }
25
+ interface VecHit {
26
+ id: string;
27
+ source: string;
28
+ score: number;
29
+ meta?: unknown;
30
+ }
31
+ type EmbedFn = (text: string) => Promise<Float32Array>;
32
+ /** Input to {@link Store.indexDoc}: an upsertable document. */
33
+ interface IndexDoc {
34
+ id: string;
35
+ source: string;
36
+ content: string;
37
+ meta?: unknown;
38
+ }
39
+ /** Optional filters/cap for {@link Store.searchFt}. */
40
+ interface SearchFtOpts {
41
+ /** Maximum number of hits to return (default 10). */
42
+ limit?: number;
43
+ /** Restrict results to a single source. */
44
+ source?: string;
45
+ }
46
+ /** Metadata accepted by {@link Store.upsertVec}. */
47
+ interface VecUpsertMeta {
48
+ /** Logical source bucket for the vector (default `'default'`). */
49
+ source?: string;
50
+ }
51
+ /** Optional filters/cap for {@link Store.knn}. */
52
+ interface VecOpts {
53
+ /** Maximum number of neighbors to return (default 5). */
54
+ limit?: number;
55
+ /** Restrict results to a single source bucket (applied at kNN scan time). */
56
+ source?: string;
57
+ }
58
+ interface Store {
59
+ readonly projectId: ProjectId;
60
+ getState<T>(key: string): T | null;
61
+ setState<T>(key: string, value: T): void;
62
+ /** Upsert a document into the `docs` table (FTS sync is automatic via triggers). */
63
+ indexDoc(doc: IndexDoc): void;
64
+ /** Delete a document row by id; FTS cleanup is automatic via the `docs_ad` trigger. */
65
+ deleteDoc(id: string): void;
66
+ /** BM25 full-text search with window-extracted snippets. */
67
+ searchFt(query: string, opts?: SearchFtOpts): FtsHit[];
68
+ /** Upsert a 384-dim vector keyed by `id` (idempotent; delete-by-id-then-insert). */
69
+ upsertVec(id: string, vec: Float32Array, meta?: VecUpsertMeta): void;
70
+ /** Delete a vector row by id (idempotent; used by the context indexer for removals). */
71
+ deleteVec(id: string): void;
72
+ /** k-nearest-neighbor search over `vec`; results ordered by ascending distance. */
73
+ knn(vec: Float32Array, opts?: VecOpts): VecHit[];
74
+ /** Count rows in the `docs` table (live read from the single writer handle). */
75
+ countDocs(): number;
76
+ /** Count rows in the `vec` table (live read from the single writer handle). */
77
+ countVecs(): number;
78
+ /** Export all `docs` rows to `<dir>/<id>.md` with YAML frontmatter. */
79
+ exportMarkdown(dir: string): Promise<string[]>;
80
+ close(): Promise<void>;
81
+ }
82
+
83
+ /**
84
+ * Open (or create) the project's embedded SQLite store at
85
+ * `.noir/store/<projectId>.db`.
86
+ *
87
+ * `sqlite-vec` is loaded into every connection (read-safe) so kNN queries work
88
+ * in both read-write and read-only modes. In read-write mode the schema is
89
+ * migrated to the latest version, WAL journaling is enabled, and the `vec0`
90
+ * virtual table (deferred from v1 because `vec0` requires the extension
91
+ * loaded) is created. In read-only mode none of those writes happen; callers
92
+ * get a best-effort read handle against whatever schema already exists on
93
+ * disk — kNN works if the `vec` table was created by a prior read-write open,
94
+ * otherwise queries fail clearly against the missing table.
95
+ *
96
+ * `__db` is exposed for tests to assert schema state directly.
97
+ */
98
+ declare function openStore(opts: OpenOptions): Promise<Store & {
99
+ __db: Database.Database;
100
+ }>;
101
+
102
+ type VecAvailability = {
103
+ ok: true;
104
+ } | {
105
+ ok: false;
106
+ reason: string;
107
+ };
108
+ /**
109
+ * Probe sqlite-vec native availability ONCE (memoized). Returns `{ ok: true }`
110
+ * when the binary loads, else `{ ok: false, reason }`. Use to gate vec-backed
111
+ * test suites (`describe.skip` on absence) so the default suite stays green
112
+ * offline on an unsupported platform.
113
+ */
114
+ declare function vecAvailability(): VecAvailability;
115
+
116
+ export { type EmbedFn, type FtsHit, type IndexDoc, type OpenOptions, type SearchFtOpts, type Store, type VecAvailability, type VecHit, type VecOpts, type VecUpsertMeta, migrate, openStore, vecAvailability };
package/dist/index.js ADDED
@@ -0,0 +1,219 @@
1
+ // src/migrations.ts
2
+ var V1_SQL = (
3
+ /* sql */
4
+ `
5
+ CREATE TABLE IF NOT EXISTS kv (
6
+ key TEXT PRIMARY KEY,
7
+ value TEXT NOT NULL
8
+ );
9
+
10
+ CREATE TABLE IF NOT EXISTS docs (
11
+ id TEXT PRIMARY KEY,
12
+ source TEXT NOT NULL,
13
+ content TEXT NOT NULL,
14
+ meta TEXT
15
+ );
16
+
17
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
18
+ content,
19
+ content='docs',
20
+ content_rowid='rowid',
21
+ tokenize='porter unicode61'
22
+ );
23
+
24
+ -- keep docs_fts in sync with docs (external-content sync triggers:
25
+ -- the 'delete' command requires content='docs' above to function)
26
+ CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
27
+ INSERT INTO docs_fts(rowid, content) VALUES (new.rowid, new.content);
28
+ END;
29
+
30
+ CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN
31
+ INSERT INTO docs_fts(docs_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
32
+ END;
33
+
34
+ CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN
35
+ INSERT INTO docs_fts(docs_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
36
+ INSERT INTO docs_fts(rowid, content) VALUES (new.rowid, new.content);
37
+ END;
38
+ `
39
+ );
40
+ var migrations = [{ version: 1, sql: V1_SQL }];
41
+ function migrate(db) {
42
+ db.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);");
43
+ const applied = new Set(
44
+ db.prepare("SELECT version FROM schema_version").all().map((r) => r.version)
45
+ );
46
+ for (const m of migrations) {
47
+ if (applied.has(m.version)) continue;
48
+ db.exec("BEGIN");
49
+ try {
50
+ db.exec(m.sql);
51
+ db.prepare("INSERT INTO schema_version(version) VALUES (?)").run(m.version);
52
+ db.exec("COMMIT");
53
+ } catch (e) {
54
+ db.exec("ROLLBACK");
55
+ throw e;
56
+ }
57
+ }
58
+ }
59
+
60
+ // src/sqlite-store.ts
61
+ import { mkdirSync } from "fs";
62
+ import { paths } from "@noir-ai/core";
63
+ import Database from "better-sqlite3";
64
+ import * as sqliteVec from "sqlite-vec";
65
+
66
+ // src/markdown.ts
67
+ import { writeFileSync } from "fs";
68
+ import { join } from "path";
69
+ async function exportMarkdown(db, dir) {
70
+ const rows = db.prepare("SELECT id, source, content FROM docs").all();
71
+ const written = [];
72
+ for (const r of rows) {
73
+ const p = join(dir, `${r.id}.md`);
74
+ writeFileSync(p, `---
75
+ id: ${r.id}
76
+ source: ${r.source}
77
+ ---
78
+
79
+ ${r.content}
80
+ `, "utf8");
81
+ written.push(p);
82
+ }
83
+ return written;
84
+ }
85
+
86
+ // src/sqlite-store.ts
87
+ async function openStore(opts) {
88
+ const projectId = opts.projectId;
89
+ const dbPath = paths.storeDb(opts.root, projectId);
90
+ if (opts.readonly !== true) {
91
+ mkdirSync(paths.storeDir(opts.root), { recursive: true });
92
+ }
93
+ const db = new Database(dbPath, { readonly: opts.readonly === true });
94
+ sqliteVec.load(db);
95
+ if (opts.readonly !== true) {
96
+ db.pragma("journal_mode = WAL");
97
+ migrate(db);
98
+ db.exec(
99
+ "CREATE VIRTUAL TABLE IF NOT EXISTS vec USING vec0(embedding float[384], source TEXT, id TEXT)"
100
+ );
101
+ }
102
+ const readonly = opts.readonly === true;
103
+ const getState = (key) => {
104
+ const row = db.prepare("SELECT value FROM kv WHERE key = ?").get(key);
105
+ return row ? JSON.parse(row.value) : null;
106
+ };
107
+ const setState = (key, value) => {
108
+ if (readonly) {
109
+ throw new Error("store is read-only (daemon down)");
110
+ }
111
+ db.prepare(
112
+ "INSERT INTO kv(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
113
+ ).run(key, JSON.stringify(value));
114
+ };
115
+ const indexDoc = (doc) => {
116
+ if (readonly) {
117
+ throw new Error("store is read-only (daemon down)");
118
+ }
119
+ db.prepare(
120
+ "INSERT INTO docs(id, source, content, meta) VALUES(?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET source=excluded.source, content=excluded.content, meta=excluded.meta"
121
+ ).run(doc.id, doc.source, doc.content, doc.meta ? JSON.stringify(doc.meta) : null);
122
+ };
123
+ const deleteDoc = (id) => {
124
+ if (readonly) {
125
+ throw new Error("store is read-only (daemon down)");
126
+ }
127
+ db.prepare("DELETE FROM docs WHERE id = ?").run(id);
128
+ };
129
+ const searchFt = (query, opts2) => {
130
+ const limit = opts2?.limit ?? 10;
131
+ const source = opts2?.source;
132
+ const sql = source ? `SELECT d.id AS id, d.source AS source, d.meta AS meta, bm25(docs_fts) AS score,
133
+ snippet(docs_fts, 0, '<<', '>>', '\u2026', 16) AS snippet
134
+ FROM docs_fts JOIN docs d ON d.rowid = docs_fts.rowid
135
+ WHERE docs_fts MATCH ? AND d.source = ?
136
+ ORDER BY score LIMIT ?` : `SELECT d.id AS id, d.source AS source, d.meta AS meta, bm25(docs_fts) AS score,
137
+ snippet(docs_fts, 0, '<<', '>>', '\u2026', 16) AS snippet
138
+ FROM docs_fts JOIN docs d ON d.rowid = docs_fts.rowid
139
+ WHERE docs_fts MATCH ?
140
+ ORDER BY score LIMIT ?`;
141
+ const rows = source ? db.prepare(sql).all(query, source, limit) : db.prepare(sql).all(query, limit);
142
+ return rows.map((r) => ({
143
+ id: r.id,
144
+ source: r.source,
145
+ score: r.score,
146
+ snippet: r.snippet,
147
+ ...r.meta ? { meta: JSON.parse(r.meta) } : {}
148
+ }));
149
+ };
150
+ const upsertVec = (id, vec, meta) => {
151
+ if (readonly) {
152
+ throw new Error("store is read-only (daemon down)");
153
+ }
154
+ const buf = Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
155
+ const source = meta?.source ?? "default";
156
+ const upsert = db.transaction(() => {
157
+ db.prepare("DELETE FROM vec WHERE id = ?").run(id);
158
+ db.prepare("INSERT INTO vec(embedding, source, id) VALUES (?, ?, ?)").run(buf, source, id);
159
+ });
160
+ upsert();
161
+ };
162
+ const deleteVec = (id) => {
163
+ if (readonly) {
164
+ throw new Error("store is read-only (daemon down)");
165
+ }
166
+ db.prepare("DELETE FROM vec WHERE id = ?").run(id);
167
+ };
168
+ const knn = (vec, opts2) => {
169
+ const limit = opts2?.limit ?? 5;
170
+ const buf = Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
171
+ const source = opts2?.source;
172
+ const sql = source ? "SELECT id, source, distance FROM vec WHERE embedding MATCH ? AND k = ? AND source = ? ORDER BY distance" : "SELECT id, source, distance FROM vec WHERE embedding MATCH ? AND k = ? ORDER BY distance";
173
+ const rows = source ? db.prepare(sql).all(buf, limit, source) : db.prepare(sql).all(buf, limit);
174
+ return rows.map((r) => ({ id: r.id, source: r.source, score: r.distance }));
175
+ };
176
+ const countDocs = () => db.prepare("SELECT count(*) AS c FROM docs").get().c;
177
+ const countVecs = () => db.prepare("SELECT count(*) AS c FROM vec").get().c;
178
+ return {
179
+ projectId,
180
+ __db: db,
181
+ getState,
182
+ setState,
183
+ indexDoc,
184
+ deleteDoc,
185
+ searchFt,
186
+ upsertVec,
187
+ deleteVec,
188
+ knn,
189
+ countDocs,
190
+ countVecs,
191
+ exportMarkdown: (dir) => exportMarkdown(db, dir),
192
+ close: async () => {
193
+ db.close();
194
+ }
195
+ };
196
+ }
197
+
198
+ // src/vec-probe.ts
199
+ import Database2 from "better-sqlite3";
200
+ import * as sqliteVec2 from "sqlite-vec";
201
+ var cached;
202
+ function vecAvailability() {
203
+ if (cached) return cached;
204
+ try {
205
+ const probe = new Database2(":memory:");
206
+ sqliteVec2.load(probe);
207
+ probe.close();
208
+ cached = { ok: true };
209
+ } catch (e) {
210
+ cached = { ok: false, reason: e instanceof Error ? e.message : String(e) };
211
+ }
212
+ return cached;
213
+ }
214
+ export {
215
+ migrate,
216
+ openStore,
217
+ vecAvailability
218
+ };
219
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/migrations.ts","../src/sqlite-store.ts","../src/markdown.ts","../src/vec-probe.ts"],"sourcesContent":["import type Database from 'better-sqlite3';\n\n/**\n * Schema v1.\n *\n * NOTE on packaging: the v1 SQL is inlined here as a TS string (rather than\n * shipped as a separate `sql/v1.sql` resolved via `import.meta.url`) because\n * the installed tsup (8.5.1) exposes no `copy: { items: [...] }` option, only\n * `publicDir`. Inlining avoids all file-resolution concerns across src/dist and\n * keeps the schema the single source of truth right next to the runner.\n *\n * NOTE on `vec`: the `vec0` virtual table requires the `sqlite-vec` extension\n * to be loaded into the connection, which T1 does NOT do. Its DDL is therefore\n * intentionally omitted from v1 and will be added in T4 (via a v2 migration or\n * a load-time create once `sqliteVec.load(db)` runs).\n */\nconst V1_SQL = /* sql */ `\nCREATE TABLE IF NOT EXISTS kv (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS docs (\n id TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n content TEXT NOT NULL,\n meta TEXT\n);\n\nCREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(\n content,\n content='docs',\n content_rowid='rowid',\n tokenize='porter unicode61'\n);\n\n-- keep docs_fts in sync with docs (external-content sync triggers:\n-- the 'delete' command requires content='docs' above to function)\nCREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN\n INSERT INTO docs_fts(rowid, content) VALUES (new.rowid, new.content);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN\n INSERT INTO docs_fts(docs_fts, rowid, content) VALUES ('delete', old.rowid, old.content);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN\n INSERT INTO docs_fts(docs_fts, rowid, content) VALUES ('delete', old.rowid, old.content);\n INSERT INTO docs_fts(rowid, content) VALUES (new.rowid, new.content);\nEND;\n`;\n\ninterface Migration {\n version: number;\n sql: string;\n}\n\n/** Ordered migrations. v1 is the initial schema. */\nconst migrations: Migration[] = [{ version: 1, sql: V1_SQL }];\n\n/**\n * Hand-rolled versioned-SQL migration runner.\n *\n * Idempotent: tracks applied versions in a `schema_version` table and only runs\n * each migration once. Each migration is applied in its own transaction; on\n * failure the transaction is rolled back and the error re-thrown.\n */\nexport function migrate(db: Database.Database): void {\n db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);');\n\n const applied = new Set(\n db\n .prepare('SELECT version FROM schema_version')\n .all()\n .map((r) => (r as { version: number }).version),\n );\n\n for (const m of migrations) {\n if (applied.has(m.version)) continue;\n db.exec('BEGIN');\n try {\n db.exec(m.sql);\n db.prepare('INSERT INTO schema_version(version) VALUES (?)').run(m.version);\n db.exec('COMMIT');\n } catch (e) {\n db.exec('ROLLBACK');\n throw e;\n }\n }\n}\n","import { mkdirSync } from 'node:fs';\nimport { type ProjectId, paths } from '@noir-ai/core';\nimport Database from 'better-sqlite3';\nimport * as sqliteVec from 'sqlite-vec';\nimport { exportMarkdown } from './markdown.js';\nimport { migrate } from './migrations.js';\nimport type {\n FtsHit,\n IndexDoc,\n OpenOptions,\n SearchFtOpts,\n Store,\n VecHit,\n VecOpts,\n VecUpsertMeta,\n} from './types.js';\n\n/** Internal row shape from the docs_fts JOIN docs query. */\ninterface FtsRow {\n id: string;\n source: string;\n meta: string | null;\n score: number;\n snippet: string;\n}\n\n/** Internal row shape from the vec0 kNN query. */\ninterface VecRow {\n id: string;\n source: string;\n distance: number;\n}\n\n/**\n * Open (or create) the project's embedded SQLite store at\n * `.noir/store/<projectId>.db`.\n *\n * `sqlite-vec` is loaded into every connection (read-safe) so kNN queries work\n * in both read-write and read-only modes. In read-write mode the schema is\n * migrated to the latest version, WAL journaling is enabled, and the `vec0`\n * virtual table (deferred from v1 because `vec0` requires the extension\n * loaded) is created. In read-only mode none of those writes happen; callers\n * get a best-effort read handle against whatever schema already exists on\n * disk — kNN works if the `vec` table was created by a prior read-write open,\n * otherwise queries fail clearly against the missing table.\n *\n * `__db` is exposed for tests to assert schema state directly.\n */\nexport async function openStore(opts: OpenOptions): Promise<Store & { __db: Database.Database }> {\n const projectId: ProjectId = opts.projectId;\n const dbPath = paths.storeDb(opts.root, projectId);\n if (opts.readonly !== true) {\n mkdirSync(paths.storeDir(opts.root), { recursive: true });\n }\n\n const db = new Database(dbPath, { readonly: opts.readonly === true });\n\n // Load sqlite-vec first (read-safe; needed for kNN in either mode).\n sqliteVec.load(db);\n\n if (opts.readonly !== true) {\n db.pragma('journal_mode = WAL');\n migrate(db);\n // vec0 DDL deferred from v1: vec0 requires the sqlite-vec extension to be\n // loaded (done above). `source`/`id` are metadata columns — filterable in\n // kNN (`source = ?`) and deletable for idempotent upsert (`id = ?`). vec0\n // keys on rowid (auto-assigned); there is no text primary key.\n db.exec(\n 'CREATE VIRTUAL TABLE IF NOT EXISTS vec USING vec0(embedding float[384], source TEXT, id TEXT)',\n );\n }\n // read-only: do not write. If the schema is missing, queries simply fail —\n // acceptable for degraded reads (e.g. inspecting a foreign DB).\n\n const readonly = opts.readonly === true;\n\n const getState = <T>(key: string): T | null => {\n const row = db.prepare('SELECT value FROM kv WHERE key = ?').get(key) as\n | { value: string }\n | undefined;\n return row ? (JSON.parse(row.value) as T) : null;\n };\n\n const setState = <T>(key: string, value: T): void => {\n if (readonly) {\n throw new Error('store is read-only (daemon down)');\n }\n db.prepare(\n 'INSERT INTO kv(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',\n ).run(key, JSON.stringify(value));\n };\n\n const indexDoc = (doc: IndexDoc): void => {\n if (readonly) {\n throw new Error('store is read-only (daemon down)');\n }\n // Upsert into docs; the docs_ai/docs_au triggers keep docs_fts in sync.\n db.prepare(\n 'INSERT INTO docs(id, source, content, meta) VALUES(?, ?, ?, ?) ' +\n 'ON CONFLICT(id) DO UPDATE SET source=excluded.source, content=excluded.content, meta=excluded.meta',\n ).run(doc.id, doc.source, doc.content, doc.meta ? JSON.stringify(doc.meta) : null);\n };\n\n const deleteDoc = (id: string): void => {\n if (readonly) {\n throw new Error('store is read-only (daemon down)');\n }\n // The docs_ad AFTER DELETE trigger replays the row into docs_fts as a\n // 'delete' command (external-content sync, see migrations.ts), keeping the\n // FTS index consistent — no manual docs_fts work needed here.\n db.prepare('DELETE FROM docs WHERE id = ?').run(id);\n };\n\n const searchFt = (query: string, opts?: SearchFtOpts): FtsHit[] => {\n const limit = opts?.limit ?? 10;\n // bm25(): more negative = more relevant, so ORDER BY score ascending.\n // snippet(docs_fts, 0, ...): column 0 is `content`; 16-token window with\n // <<match>> markers — NEVER the full content (blueprint §9.2).\n const source = opts?.source;\n const sql = source\n ? `SELECT d.id AS id, d.source AS source, d.meta AS meta, bm25(docs_fts) AS score,\n snippet(docs_fts, 0, '<<', '>>', '…', 16) AS snippet\n FROM docs_fts JOIN docs d ON d.rowid = docs_fts.rowid\n WHERE docs_fts MATCH ? AND d.source = ?\n ORDER BY score LIMIT ?`\n : `SELECT d.id AS id, d.source AS source, d.meta AS meta, bm25(docs_fts) AS score,\n snippet(docs_fts, 0, '<<', '>>', '…', 16) AS snippet\n FROM docs_fts JOIN docs d ON d.rowid = docs_fts.rowid\n WHERE docs_fts MATCH ?\n ORDER BY score LIMIT ?`;\n const rows = source\n ? (db.prepare(sql).all(query, source, limit) as FtsRow[])\n : (db.prepare(sql).all(query, limit) as FtsRow[]);\n return rows.map((r) => ({\n id: r.id,\n source: r.source,\n score: r.score,\n snippet: r.snippet,\n ...(r.meta ? { meta: JSON.parse(r.meta) } : {}),\n }));\n };\n\n const upsertVec = (id: string, vec: Float32Array, meta?: VecUpsertMeta): void => {\n if (readonly) {\n throw new Error('store is read-only (daemon down)');\n }\n // Account for Float32Array views (byteOffset/byteLength) so a subarray\n // binds exactly its own bytes, not the whole backing ArrayBuffer.\n const buf = Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);\n const source = meta?.source ?? 'default';\n // vec0 keys on rowid; `id` is a metadata column. Idempotent via\n // delete-by-id-then-insert (both are filterable metadata ops) in one txn.\n const upsert = db.transaction(() => {\n db.prepare('DELETE FROM vec WHERE id = ?').run(id);\n db.prepare('INSERT INTO vec(embedding, source, id) VALUES (?, ?, ?)').run(buf, source, id);\n });\n upsert();\n };\n\n const deleteVec = (id: string): void => {\n if (readonly) {\n throw new Error('store is read-only (daemon down)');\n }\n // vec0 keys on rowid; `id` is a filterable metadata column, so delete-by-id\n // is a plain metadata predicate (same one upsertVec uses for idempotency).\n db.prepare('DELETE FROM vec WHERE id = ?').run(id);\n };\n\n const knn = (vec: Float32Array, opts?: VecOpts): VecHit[] => {\n const limit = opts?.limit ?? 5;\n const buf = Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);\n // `MATCH ? AND k = ?` is the canonical vec0 kNN form (version-independent;\n // `ORDER BY distance` is ascending by default — nearest first). distance is\n // L2, so lower == more similar; we mirror it onto `score` to match the FTS\n // convention where lower bm25 == more relevant.\n const source = opts?.source;\n const sql = source\n ? 'SELECT id, source, distance FROM vec WHERE embedding MATCH ? AND k = ? AND source = ? ORDER BY distance'\n : 'SELECT id, source, distance FROM vec WHERE embedding MATCH ? AND k = ? ORDER BY distance';\n const rows = source\n ? (db.prepare(sql).all(buf, limit, source) as VecRow[])\n : (db.prepare(sql).all(buf, limit) as VecRow[]);\n return rows.map((r) => ({ id: r.id, source: r.source, score: r.distance }));\n };\n\n const countDocs = (): number =>\n (db.prepare('SELECT count(*) AS c FROM docs').get() as { c: number }).c;\n\n const countVecs = (): number =>\n (db.prepare('SELECT count(*) AS c FROM vec').get() as { c: number }).c;\n\n return {\n projectId,\n __db: db,\n getState,\n setState,\n indexDoc,\n deleteDoc,\n searchFt,\n upsertVec,\n deleteVec,\n knn,\n countDocs,\n countVecs,\n exportMarkdown: (dir: string) => exportMarkdown(db, dir),\n close: async () => {\n db.close();\n },\n };\n}\n","import { writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type Database from 'better-sqlite3';\n\n/**\n * Export all documents from the `docs` table to markdown files.\n *\n * Each document is written to `<dir>/<id>.md` with YAML frontmatter\n * containing `id` and `source`, followed by the document content.\n *\n * @param db - The SQLite database connection.\n * @param dir - The directory to write markdown files to.\n * @returns Array of written file paths.\n */\nexport async function exportMarkdown(db: Database.Database, dir: string): Promise<string[]> {\n const rows = db.prepare('SELECT id, source, content FROM docs').all() as {\n id: string;\n source: string;\n content: string;\n }[];\n const written: string[] = [];\n for (const r of rows) {\n const p = join(dir, `${r.id}.md`);\n writeFileSync(p, `---\\nid: ${r.id}\\nsource: ${r.source}\\n---\\n\\n${r.content}\\n`, 'utf8');\n written.push(p);\n }\n return written;\n}\n","// Centralized sqlite-vec native-binary availability probe.\n//\n// sqlite-vec ships a per-platform native binary that the store loads into every\n// connection at open time. When the binary is missing/broken on a host, store\n// open and any vec operation throw. Workspace packages that consume the store\n// (context, daemon, …) need to gate their vec-backed test suites on this — but\n// they MUST NOT import `better-sqlite3` / `sqlite-vec` directly: those are the\n// store's own dependencies and are not resolvable from other packages under\n// pnpm's strict node_modules. This module is the single place that touches the\n// native modules; everyone else asks the store via `vecAvailability()`.\n\nimport Database from 'better-sqlite3';\nimport * as sqliteVec from 'sqlite-vec';\n\nexport type VecAvailability = { ok: true } | { ok: false; reason: string };\n\nlet cached: VecAvailability | undefined;\n\n/**\n * Probe sqlite-vec native availability ONCE (memoized). Returns `{ ok: true }`\n * when the binary loads, else `{ ok: false, reason }`. Use to gate vec-backed\n * test suites (`describe.skip` on absence) so the default suite stays green\n * offline on an unsupported platform.\n */\nexport function vecAvailability(): VecAvailability {\n if (cached) return cached;\n try {\n const probe = new Database(':memory:');\n sqliteVec.load(probe);\n probe.close();\n cached = { ok: true };\n } catch (e) {\n cached = { ok: false, reason: e instanceof Error ? e.message : String(e) };\n }\n return cached;\n}\n"],"mappings":"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","names":["opts","Database","sqliteVec"]}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@noir-ai/store",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Noir store — embedded storage: better-sqlite3 + FTS5 (BM25, window snippets) + sqlite-vec (384-dim kNN).",
5
+ "license": "MIT",
6
+ "author": "agaaaptr",
7
+ "homepage": "https://github.com/agaaaptr/noir#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/agaaaptr/noir.git",
11
+ "directory": "packages/store"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/agaaaptr/noir/issues"
15
+ },
16
+ "keywords": [
17
+ "noir",
18
+ "sqlite",
19
+ "fts5",
20
+ "vector",
21
+ "sqlite-vec",
22
+ "embeddings",
23
+ "persistence"
24
+ ],
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "type": "module",
33
+ "main": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "import": "./dist/index.js"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "README.md"
44
+ ],
45
+ "dependencies": {
46
+ "better-sqlite3": "^12.0.0",
47
+ "sqlite-vec": "^0.1.0",
48
+ "@noir-ai/core": "1.0.0-beta.1"
49
+ },
50
+ "devDependencies": {
51
+ "@types/better-sqlite3": "^7.6.0",
52
+ "@types/node": "^26.1.1"
53
+ },
54
+ "scripts": {
55
+ "build": "tsup",
56
+ "typecheck": "tsc --noEmit"
57
+ }
58
+ }