@openparachute/vault 0.7.2 → 0.7.3-rc.10

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.
Files changed (74) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/policy.test.ts +66 -0
  3. package/core/src/attachment/policy.ts +131 -0
  4. package/core/src/attachment/tickets.test.ts +45 -0
  5. package/core/src/attachment/tickets.ts +117 -0
  6. package/core/src/attachment-tickets-tool.test.ts +286 -0
  7. package/core/src/conformance.ts +2 -1
  8. package/core/src/contract-typed-index.test.ts +4 -3
  9. package/core/src/core.test.ts +607 -11
  10. package/core/src/display-title.test.ts +190 -0
  11. package/core/src/embedding/chunker.test.ts +97 -0
  12. package/core/src/embedding/chunker.ts +180 -0
  13. package/core/src/embedding/provider.ts +108 -0
  14. package/core/src/embedding/staleness.test.ts +87 -0
  15. package/core/src/embedding/staleness.ts +83 -0
  16. package/core/src/embedding/vector-codec.test.ts +99 -0
  17. package/core/src/embedding/vector-codec.ts +60 -0
  18. package/core/src/embedding/vectors.test.ts +163 -0
  19. package/core/src/embedding/vectors.ts +135 -0
  20. package/core/src/expand.ts +11 -3
  21. package/core/src/indexed-fields.test.ts +9 -3
  22. package/core/src/indexed-fields.ts +9 -1
  23. package/core/src/lede.test.ts +96 -0
  24. package/core/src/mcp-semantic-search.test.ts +160 -0
  25. package/core/src/mcp.ts +405 -10
  26. package/core/src/notes.semantic-search.test.ts +131 -0
  27. package/core/src/notes.ts +319 -7
  28. package/core/src/query-warnings.ts +40 -0
  29. package/core/src/schema-defaults.ts +85 -1
  30. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  31. package/core/src/schema.ts +81 -1
  32. package/core/src/search-fts-v25.test.ts +9 -1
  33. package/core/src/search-query.test.ts +42 -0
  34. package/core/src/search-query.ts +27 -0
  35. package/core/src/search-title-boost.test.ts +125 -0
  36. package/core/src/seed-packs.test.ts +117 -1
  37. package/core/src/seed-packs.ts +217 -1
  38. package/core/src/store.semantic-search.test.ts +236 -0
  39. package/core/src/store.ts +162 -5
  40. package/core/src/tag-schemas.ts +27 -12
  41. package/core/src/types.ts +63 -1
  42. package/core/src/vault-projection.ts +48 -0
  43. package/package.json +6 -1
  44. package/src/add-pack.test.ts +32 -0
  45. package/src/attachment-tickets.test.ts +350 -0
  46. package/src/attachment-tickets.ts +264 -0
  47. package/src/auth.ts +8 -2
  48. package/src/cli.ts +5 -1
  49. package/src/contract-errors.test.ts +2 -1
  50. package/src/contract-honest-queries.test.ts +88 -0
  51. package/src/contract-search.test.ts +41 -0
  52. package/src/embedding/capability.test.ts +33 -0
  53. package/src/embedding/capability.ts +34 -0
  54. package/src/embedding/external-api.test.ts +154 -0
  55. package/src/embedding/external-api.ts +144 -0
  56. package/src/embedding/onnx-transformers.test.ts +113 -0
  57. package/src/embedding/onnx-transformers.ts +141 -0
  58. package/src/embedding/select.test.ts +99 -0
  59. package/src/embedding/select.ts +92 -0
  60. package/src/embedding-worker.test.ts +300 -0
  61. package/src/embedding-worker.ts +226 -0
  62. package/src/mcp-http.ts +13 -1
  63. package/src/mcp-tools.ts +49 -14
  64. package/src/onboarding-seed.test.ts +64 -0
  65. package/src/routes.ts +173 -92
  66. package/src/routing.ts +17 -0
  67. package/src/scribe-discovery.test.ts +76 -1
  68. package/src/scribe-discovery.ts +28 -9
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +21 -2
  71. package/src/vault-embeddings-capability.test.ts +82 -0
  72. package/src/vault-store-embedding-wiring.test.ts +69 -0
  73. package/src/vault-store.ts +53 -2
  74. package/src/vault.test.ts +62 -13
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Freshness gate for `note_vectors` (V2 — see `SEMANTIC-MVP-PLAN.md` §3,
3
+ * "Staleness hook"). Pure diffing logic: given a note's CURRENT chunks
4
+ * (from `chunkNoteContent`) and the vector rows already on disk, decide
5
+ * which chunk indices need (re-)embedding and which existing rows are
6
+ * obsolete (the note shrank, or the configured model changed) and should
7
+ * be pruned.
8
+ *
9
+ * No I/O here — `core/src/notes.ts`/`store.ts` own reading `note_vectors`
10
+ * and applying the resulting plan (embed the stale ones, delete the
11
+ * obsolete ones). Keeping this pure makes "no-op edit ≠ re-embed" and
12
+ * "model change ⇒ full sweep" both directly testable without a database.
13
+ *
14
+ * `content_hash` uses `node:crypto`'s `createHash` — already a core
15
+ * dependency (see `core/src/cursor.ts`), so this doesn't add a new one.
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import type { Chunk } from "./chunker.js";
20
+
21
+ /** Deterministic content hash for one chunk's text — the freshness key. */
22
+ export function contentHash(text: string): string {
23
+ return createHash("sha256").update(text, "utf8").digest("hex");
24
+ }
25
+
26
+ /** The subset of a `note_vectors` row this module needs to reason about. */
27
+ export interface ExistingVectorRow {
28
+ chunk_ix: number;
29
+ model: string;
30
+ content_hash: string;
31
+ }
32
+
33
+ export interface StalenessPlan {
34
+ /**
35
+ * Chunks that need embedding: either no row exists yet at this
36
+ * `chunk_ix`, or the existing row's `model`/`content_hash` doesn't
37
+ * match — a no-op edit (same content, same model) never lands here.
38
+ */
39
+ stale: Chunk[];
40
+ /**
41
+ * `chunk_ix` values present in `existing` that no longer correspond to
42
+ * a current chunk (the note shrank) OR belong to a stale `model` — safe
43
+ * to DELETE. Pruning these is what keeps a shrunk note's leftover
44
+ * high-index chunk from lingering as a ghost match in the cosine scan.
45
+ */
46
+ obsoleteIxs: number[];
47
+ }
48
+
49
+ /**
50
+ * Diff a note's current chunk set against its existing `note_vectors`
51
+ * rows for the ACTIVE `model`. Rows recorded under a different model are
52
+ * always obsolete (a `note_vectors` row's `model` never mixes with the
53
+ * configured provider's model within one scan — see
54
+ * `core/src/notes.ts:semanticSearchNotes`, which filters `WHERE model =
55
+ * ?`) — so a model change surfaces as "every existing row is obsolete,
56
+ * every current chunk is stale," i.e. a full re-embed sweep, matching the
57
+ * plan's "model change → stale sweep (re-embed)."
58
+ */
59
+ export function planStaleness(
60
+ existing: ExistingVectorRow[],
61
+ chunks: Chunk[],
62
+ model: string,
63
+ ): StalenessPlan {
64
+ const existingByIx = new Map(existing.map((r) => [r.chunk_ix, r]));
65
+ const currentIxs = new Set(chunks.map((c) => c.ix));
66
+
67
+ const stale: Chunk[] = [];
68
+ for (const c of chunks) {
69
+ const row = existingByIx.get(c.ix);
70
+ if (!row || row.model !== model || row.content_hash !== contentHash(c.text)) {
71
+ stale.push(c);
72
+ }
73
+ }
74
+
75
+ const obsoleteIxs: number[] = [];
76
+ for (const row of existing) {
77
+ if (!currentIxs.has(row.chunk_ix) || row.model !== model) {
78
+ obsoleteIxs.push(row.chunk_ix);
79
+ }
80
+ }
81
+
82
+ return { stale, obsoleteIxs };
83
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { normalize, dot, encodeVector, decodeVector } from "./vector-codec.js";
4
+
5
+ describe("normalize", () => {
6
+ it("produces a unit-length vector", () => {
7
+ const v = new Float32Array([3, 4]); // 3-4-5 triangle
8
+ const n = normalize(v);
9
+ const len = Math.sqrt(n[0] * n[0] + n[1] * n[1]);
10
+ expect(len).toBeCloseTo(1, 5);
11
+ expect(n[0]).toBeCloseTo(0.6, 5);
12
+ expect(n[1]).toBeCloseTo(0.8, 5);
13
+ });
14
+
15
+ it("does not mutate the input", () => {
16
+ const v = new Float32Array([3, 4]);
17
+ normalize(v);
18
+ expect(v[0]).toBe(3);
19
+ expect(v[1]).toBe(4);
20
+ });
21
+
22
+ it("guards the all-zero vector instead of dividing by zero", () => {
23
+ const v = new Float32Array([0, 0, 0]);
24
+ const n = normalize(v);
25
+ expect(Array.from(n)).toEqual([0, 0, 0]);
26
+ expect(n.every((x) => Number.isFinite(x))).toBe(true);
27
+ });
28
+ });
29
+
30
+ describe("dot", () => {
31
+ it("computes the dot product of two vectors", () => {
32
+ const a = new Float32Array([1, 2, 3]);
33
+ const b = new Float32Array([4, 5, 6]);
34
+ expect(dot(a, b)).toBeCloseTo(1 * 4 + 2 * 5 + 3 * 6, 5);
35
+ });
36
+
37
+ it("cosine similarity of two normalized identical vectors is 1", () => {
38
+ const v = normalize(new Float32Array([1, 2, 3, 4]));
39
+ expect(dot(v, v)).toBeCloseTo(1, 5);
40
+ });
41
+
42
+ it("cosine similarity of two normalized orthogonal vectors is 0", () => {
43
+ const a = normalize(new Float32Array([1, 0]));
44
+ const b = normalize(new Float32Array([0, 1]));
45
+ expect(dot(a, b)).toBeCloseTo(0, 5);
46
+ });
47
+ });
48
+
49
+ describe("encodeVector / decodeVector round-trip", () => {
50
+ it("round-trips a vector through actual bytes (not a tautological identity check)", () => {
51
+ const original = new Float32Array([0.1, -0.2, 3.14159, -0.0, 42, -42, 1e-10]);
52
+ const encoded = encodeVector(original);
53
+ expect(encoded).toBeInstanceOf(Uint8Array);
54
+ expect(encoded.byteLength).toBe(original.length * 4);
55
+ const decoded = decodeVector(encoded);
56
+ expect(decoded.length).toBe(original.length);
57
+ for (let i = 0; i < original.length; i++) {
58
+ expect(decoded[i]).toBeCloseTo(original[i], 5);
59
+ }
60
+ });
61
+
62
+ it("round-trips through an ACTUAL SQLite BLOB column, not just in-memory bytes", () => {
63
+ const db = new Database(":memory:");
64
+ db.exec("CREATE TABLE t (v BLOB)");
65
+ const original = normalize(new Float32Array(384).map((_, i) => Math.sin(i)));
66
+ db.prepare("INSERT INTO t (v) VALUES (?)").run(encodeVector(original));
67
+ const row = db.prepare("SELECT v FROM t").get() as { v: Uint8Array };
68
+ const decoded = decodeVector(row.v);
69
+ expect(decoded.length).toBe(original.length);
70
+ for (let i = 0; i < original.length; i++) {
71
+ expect(decoded[i]).toBeCloseTo(original[i], 5);
72
+ }
73
+ db.close();
74
+ });
75
+
76
+ it("encodeVector copies rather than aliasing the input buffer", () => {
77
+ const original = new Float32Array([1, 2, 3]);
78
+ const encoded = encodeVector(original);
79
+ original[0] = 999;
80
+ const decoded = decodeVector(encoded);
81
+ expect(decoded[0]).toBeCloseTo(1, 5); // unaffected by the mutation above
82
+ });
83
+
84
+ it("decodeVector throws on a byte length that isn't a multiple of 4", () => {
85
+ const bad = new Uint8Array([1, 2, 3]);
86
+ expect(() => decodeVector(bad)).toThrow(RangeError);
87
+ });
88
+
89
+ it("decodeVector handles a non-4-aligned view into a larger shared buffer", () => {
90
+ // Simulate what a driver might hand back: a Uint8Array view starting at
91
+ // a byte offset that isn't a multiple of 4 within a larger buffer.
92
+ const backing = new ArrayBuffer(20);
93
+ const view = new Uint8Array(backing, 1, 16); // offset 1 — misaligned for Float32Array
94
+ const original = new Float32Array([1.5, -2.5, 3.5, 4.5]);
95
+ view.set(new Uint8Array(original.buffer, original.byteOffset, original.byteLength));
96
+ const decoded = decodeVector(view);
97
+ expect(Array.from(decoded)).toEqual(Array.from(original));
98
+ });
99
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Vector <-> BLOB codec + cosine-similarity math for semantic search.
3
+ *
4
+ * `note_vectors.vector` stores a `Float32Array` as raw bytes (a BLOB
5
+ * column) — the door-agnostic shape the architecture doc settled on (a
6
+ * BLOB in the same per-vault SQLite both `bun:sqlite` and the Cloudflare
7
+ * DO shim support natively, vs. a vector extension neither door can share).
8
+ * Vectors are stored L2-NORMALIZED (see `normalize` below) so that ranking
9
+ * is a plain dot product — no per-query renormalization of every candidate
10
+ * row, which matters once a scan is touching thousands of rows.
11
+ */
12
+
13
+ /** L2-normalize a vector in place... no — returns a NEW Float32Array (never mutates the input). */
14
+ export function normalize(v: Float32Array): Float32Array {
15
+ let sumSq = 0;
16
+ for (let i = 0; i < v.length; i++) sumSq += v[i]! * v[i]!;
17
+ const norm = Math.sqrt(sumSq) || 1; // guard the all-zero vector (never divide by 0)
18
+ const out = new Float32Array(v.length);
19
+ for (let i = 0; i < v.length; i++) out[i] = v[i]! / norm;
20
+ return out;
21
+ }
22
+
23
+ /** Dot product of two equal-length vectors. Cosine similarity IFF both are L2-normalized. */
24
+ export function dot(a: Float32Array, b: Float32Array): number {
25
+ let sum = 0;
26
+ const n = Math.min(a.length, b.length);
27
+ for (let i = 0; i < n; i++) sum += a[i]! * b[i]!;
28
+ return sum;
29
+ }
30
+
31
+ /**
32
+ * Encode a `Float32Array` to raw bytes for a SQLite BLOB parameter.
33
+ * Copies (never returns a view over the input's possibly-shared
34
+ * `ArrayBuffer`) so the caller's array can be mutated/GC'd freely
35
+ * afterward without corrupting what's bound to the statement.
36
+ */
37
+ export function encodeVector(v: Float32Array): Uint8Array {
38
+ const copy = new Float32Array(v); // copies the values into a fresh buffer
39
+ return new Uint8Array(copy.buffer, copy.byteOffset, copy.byteLength);
40
+ }
41
+
42
+ /**
43
+ * Decode a BLOB column's raw bytes back to a `Float32Array`. `bun:sqlite`
44
+ * returns BLOB columns as `Uint8Array`; a byte length not a multiple of 4
45
+ * (corruption, or a caller passing the wrong column) throws rather than
46
+ * silently truncating the last partial float.
47
+ */
48
+ export function decodeVector(blob: Uint8Array): Float32Array {
49
+ if (blob.byteLength % 4 !== 0) {
50
+ throw new RangeError(
51
+ `decodeVector: byte length ${blob.byteLength} is not a multiple of 4 (Float32Array) — corrupt or non-vector BLOB`,
52
+ );
53
+ }
54
+ // Copy into a freshly-aligned buffer: `blob` may be a view with a
55
+ // non-4-aligned `byteOffset` into a larger shared buffer (bun:sqlite's
56
+ // returned Uint8Array isn't guaranteed aligned), and Float32Array's
57
+ // constructor requires `byteOffset % 4 === 0` on the input buffer.
58
+ const copy = new Uint8Array(blob); // copies bytes into a new, 0-offset buffer
59
+ return new Float32Array(copy.buffer, copy.byteOffset, copy.byteLength / 4);
60
+ }
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "../store.js";
4
+ import {
5
+ getNoteVectorRows,
6
+ deleteObsoleteVectorRows,
7
+ upsertNoteVector,
8
+ countNotesPendingEmbedding,
9
+ getNotesPendingEmbedding,
10
+ } from "./vectors.js";
11
+ import { normalize, decodeVector } from "./vector-codec.js";
12
+
13
+ const MODEL = "test-model";
14
+
15
+ let db: Database;
16
+ let store: SqliteStore;
17
+
18
+ beforeEach(() => {
19
+ db = new Database(":memory:");
20
+ store = new SqliteStore(db);
21
+ });
22
+
23
+ describe("upsertNoteVector / getNoteVectorRows", () => {
24
+ it("a fresh note has zero vector rows", async () => {
25
+ const note = await store.createNote("hello", { path: "n" });
26
+ expect(getNoteVectorRows(db, note.id)).toEqual([]);
27
+ });
28
+
29
+ it("upsert inserts a new row, readable back with matching hash/model", async () => {
30
+ const note = await store.createNote("hello", { path: "n" });
31
+ upsertNoteVector(db, note.id, { ix: 0, text: "hello" }, normalize(new Float32Array([1, 2, 3])), MODEL, "hash-a");
32
+ const rows = getNoteVectorRows(db, note.id);
33
+ expect(rows).toEqual([{ chunk_ix: 0, model: MODEL, content_hash: "hash-a" }]);
34
+ });
35
+
36
+ it("upsert on an existing chunk_ix REPLACES it (re-embed overwrites in place)", async () => {
37
+ const note = await store.createNote("hello", { path: "n" });
38
+ upsertNoteVector(db, note.id, { ix: 0, text: "v1" }, normalize(new Float32Array([1, 0, 0])), MODEL, "hash-v1");
39
+ upsertNoteVector(db, note.id, { ix: 0, text: "v2" }, normalize(new Float32Array([0, 1, 0])), MODEL, "hash-v2");
40
+
41
+ const rows = getNoteVectorRows(db, note.id);
42
+ expect(rows.length).toBe(1);
43
+ expect(rows[0].content_hash).toBe("hash-v2");
44
+
45
+ const raw = db.prepare("SELECT vector FROM note_vectors WHERE note_id = ? AND chunk_ix = 0").get(note.id) as {
46
+ vector: Uint8Array;
47
+ };
48
+ const decoded = decodeVector(raw.vector);
49
+ expect(decoded[1]).toBeCloseTo(1, 5); // the v2 vector, not v1
50
+ });
51
+
52
+ it("a model change on the same chunk_ix also replaces (never appends a second row)", async () => {
53
+ const note = await store.createNote("hello", { path: "n" });
54
+ upsertNoteVector(db, note.id, { ix: 0, text: "x" }, normalize(new Float32Array([1, 0])), "model-a", "hash");
55
+ upsertNoteVector(db, note.id, { ix: 0, text: "x" }, normalize(new Float32Array([0, 1])), "model-b", "hash");
56
+ const rows = getNoteVectorRows(db, note.id);
57
+ expect(rows.length).toBe(1);
58
+ expect(rows[0].model).toBe("model-b");
59
+ });
60
+
61
+ it("multiple chunk_ix rows coexist independently", async () => {
62
+ const note = await store.createNote("hello", { path: "n" });
63
+ upsertNoteVector(db, note.id, { ix: 0, text: "a" }, normalize(new Float32Array([1, 0])), MODEL, "h0");
64
+ upsertNoteVector(db, note.id, { ix: 1, text: "b" }, normalize(new Float32Array([0, 1])), MODEL, "h1");
65
+ const rows = getNoteVectorRows(db, note.id).sort((a, b) => a.chunk_ix - b.chunk_ix);
66
+ expect(rows.map((r) => r.chunk_ix)).toEqual([0, 1]);
67
+ });
68
+ });
69
+
70
+ describe("deleteObsoleteVectorRows", () => {
71
+ it("removes only the specified chunk_ix rows", async () => {
72
+ const note = await store.createNote("hello", { path: "n" });
73
+ upsertNoteVector(db, note.id, { ix: 0, text: "a" }, normalize(new Float32Array([1, 0])), MODEL, "h0");
74
+ upsertNoteVector(db, note.id, { ix: 1, text: "b" }, normalize(new Float32Array([0, 1])), MODEL, "h1");
75
+ upsertNoteVector(db, note.id, { ix: 2, text: "c" }, normalize(new Float32Array([1, 1])), MODEL, "h2");
76
+
77
+ deleteObsoleteVectorRows(db, note.id, [1]);
78
+
79
+ const rows = getNoteVectorRows(db, note.id).map((r) => r.chunk_ix).sort();
80
+ expect(rows).toEqual([0, 2]);
81
+ });
82
+
83
+ it("is a no-op on an empty list (no IN () syntax error)", async () => {
84
+ const note = await store.createNote("hello", { path: "n" });
85
+ upsertNoteVector(db, note.id, { ix: 0, text: "a" }, normalize(new Float32Array([1, 0])), MODEL, "h0");
86
+ expect(() => deleteObsoleteVectorRows(db, note.id, [])).not.toThrow();
87
+ expect(getNoteVectorRows(db, note.id).length).toBe(1);
88
+ });
89
+ });
90
+
91
+ describe("countNotesPendingEmbedding", () => {
92
+ it("all notes pending when none have vectors", async () => {
93
+ await store.createNote("a", { path: "a" });
94
+ await store.createNote("b", { path: "b" });
95
+ expect(countNotesPendingEmbedding(db, MODEL)).toEqual({ total: 2, pending: 2 });
96
+ });
97
+
98
+ it("a note with at least one vector under the active model is no longer pending", async () => {
99
+ const a = await store.createNote("a", { path: "a" });
100
+ await store.createNote("b", { path: "b" });
101
+ upsertNoteVector(db, a.id, { ix: 0, text: "a" }, normalize(new Float32Array([1, 0])), MODEL, "h");
102
+ expect(countNotesPendingEmbedding(db, MODEL)).toEqual({ total: 2, pending: 1 });
103
+ });
104
+
105
+ it("a vector under a DIFFERENT model doesn't count toward the active model's embedded set", async () => {
106
+ const a = await store.createNote("a", { path: "a" });
107
+ upsertNoteVector(db, a.id, { ix: 0, text: "a" }, normalize(new Float32Array([1, 0])), "old-model", "h");
108
+ expect(countNotesPendingEmbedding(db, MODEL)).toEqual({ total: 1, pending: 1 });
109
+ });
110
+
111
+ it("zero notes: zero total, zero pending", () => {
112
+ expect(countNotesPendingEmbedding(db, MODEL)).toEqual({ total: 0, pending: 0 });
113
+ });
114
+
115
+ it("L4: a blank note is excluded from `total` too, not just `pending` — it never counts as pending, and never inflates the denominator either", async () => {
116
+ const real = await store.createNote("real content", { path: "real" });
117
+ await store.createNote("", { path: "blank" });
118
+ // No vector for `real` yet — before this fix `total` would be 2
119
+ // (counting the blank note), so `pending` would be 2 forever (the
120
+ // blank note can never get embedded) instead of settling to 0 once
121
+ // `real` is embedded.
122
+ expect(countNotesPendingEmbedding(db, MODEL)).toEqual({ total: 1, pending: 1 });
123
+
124
+ upsertNoteVector(db, real.id, { ix: 0, text: "real content" }, normalize(new Float32Array([1, 0])), MODEL, "h");
125
+ expect(countNotesPendingEmbedding(db, MODEL)).toEqual({ total: 1, pending: 0 });
126
+ });
127
+ });
128
+
129
+ describe("getNotesPendingEmbedding", () => {
130
+ it("returns notes with zero vectors under the active model", async () => {
131
+ const a = await store.createNote("a content", { path: "a" });
132
+ const b = await store.createNote("b content", { path: "b" });
133
+ upsertNoteVector(db, a.id, { ix: 0, text: "a content" }, normalize(new Float32Array([1, 0])), MODEL, "h");
134
+
135
+ const pending = getNotesPendingEmbedding(db, MODEL);
136
+ expect(pending.map((n) => n.id)).toEqual([b.id]);
137
+ expect(pending[0].content).toBe("b content");
138
+ });
139
+
140
+ it("respects an optional limit", async () => {
141
+ for (let i = 0; i < 5; i++) await store.createNote(`note ${i}`, { path: `n${i}` });
142
+ const pending = getNotesPendingEmbedding(db, MODEL, 2);
143
+ expect(pending.length).toBe(2);
144
+ });
145
+
146
+ it("empty array when every note is embedded", async () => {
147
+ const a = await store.createNote("a", { path: "a" });
148
+ upsertNoteVector(db, a.id, { ix: 0, text: "a" }, normalize(new Float32Array([1, 0])), MODEL, "h");
149
+ expect(getNotesPendingEmbedding(db, MODEL)).toEqual([]);
150
+ });
151
+
152
+ it("M3: excludes a blank note — never returned as pending, so the sweep can't hammer it forever", async () => {
153
+ await store.createNote("", { path: "blank" });
154
+ const real = await store.createNote("real content", { path: "real" });
155
+ const pending = getNotesPendingEmbedding(db, MODEL);
156
+ expect(pending.map((n) => n.id)).toEqual([real.id]);
157
+ });
158
+
159
+ it("M3: excludes a whitespace-only note", async () => {
160
+ await store.createNote(" \n\t ", { path: "whitespace" });
161
+ expect(getNotesPendingEmbedding(db, MODEL)).toEqual([]);
162
+ });
163
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `note_vectors` CRUD — the small, pure-SQL surface the embed-on-write
3
+ * hook (registered in `src/`, self-host; the DO-alarm drain, cloud) uses
4
+ * to apply a `StalenessPlan` (see `staleness.ts`). Kept separate from
5
+ * `notes.ts`'s read-side `semanticSearchNotes` scan so the write-path
6
+ * helpers here have no dependency on the ranking/cosine code, and vice
7
+ * versa.
8
+ */
9
+
10
+ import { Database } from "bun:sqlite";
11
+ import { encodeVector } from "./vector-codec.js";
12
+ import type { Chunk } from "./chunker.js";
13
+ import type { ExistingVectorRow } from "./staleness.js";
14
+
15
+ /**
16
+ * SQL fragment: `<colRef>` (a `content` column reference — bare `content`
17
+ * or an aliased `n.content`) is non-blank. A blank/whitespace-only note
18
+ * has nothing embeddable (see `embedding-worker.ts`'s chunk filter) and
19
+ * NEVER gets a `note_vectors` row, so every "which notes still need
20
+ * embedding" query — the backfill-sweep candidate list
21
+ * (`getNotesPendingEmbedding`), the progress count
22
+ * (`countNotesPendingEmbedding`), and the semantic-search scan's own
23
+ * `pendingCount` (`notes.ts:semanticSearchNotes`) — must exclude it, or a
24
+ * vault with even one blank note carries a permanent phantom "pending"
25
+ * count that never drains.
26
+ *
27
+ * SQLite's `TRIM(x)` (no second arg) only strips literal SPACE
28
+ * characters, unlike JS's `.trim()` — a note that's only tabs/newlines
29
+ * would slip through a bare `TRIM(content) = ''` check. `TRIM(x, y)`'s
30
+ * second-argument form strips every character IN `y` from both ends, so
31
+ * passing the space/tab/LF/VT/FF/CR set (`char(32,9,10,11,12,13)`) makes
32
+ * this match JS whitespace semantics closely enough for a "nothing here"
33
+ * check (exotic Unicode whitespace isn't covered — not worth the
34
+ * complexity for a defensive filter, not user-facing validation).
35
+ */
36
+ export function nonBlankContentClause(colRef: string): string {
37
+ return `TRIM(COALESCE(${colRef}, ''), char(32,9,10,11,12,13)) != ''`;
38
+ }
39
+
40
+ /** Read a note's existing `note_vectors` rows — the shape `planStaleness` diffs against. */
41
+ export function getNoteVectorRows(db: Database, noteId: string): ExistingVectorRow[] {
42
+ return db
43
+ .prepare("SELECT chunk_ix, model, content_hash FROM note_vectors WHERE note_id = ?")
44
+ .all(noteId) as ExistingVectorRow[];
45
+ }
46
+
47
+ /**
48
+ * Delete specific obsolete chunk rows for a note — the "cheap sync write"
49
+ * that keeps a shrunk note (or a model change) from leaving a ghost
50
+ * vector that `semanticSearchNotes` would otherwise still rank against.
51
+ * No-op on an empty list (avoids an `IN ()` syntax error).
52
+ */
53
+ export function deleteObsoleteVectorRows(db: Database, noteId: string, obsoleteIxs: number[]): void {
54
+ if (obsoleteIxs.length === 0) return;
55
+ const placeholders = obsoleteIxs.map(() => "?").join(", ");
56
+ db.prepare(`DELETE FROM note_vectors WHERE note_id = ? AND chunk_ix IN (${placeholders})`).run(
57
+ noteId,
58
+ ...obsoleteIxs,
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Write (insert or replace) one chunk's embedded vector. Replace-by-PK
64
+ * semantics — a re-embed (content changed, or the model changed)
65
+ * overwrites the existing `(note_id, chunk_ix)` row in place; a vault
66
+ * never carries two models' vectors for the same chunk simultaneously
67
+ * (see the `note_vectors` schema doc comment).
68
+ */
69
+ export function upsertNoteVector(
70
+ db: Database,
71
+ noteId: string,
72
+ chunk: Chunk,
73
+ vector: Float32Array,
74
+ model: string,
75
+ contentHash: string,
76
+ ): void {
77
+ db.prepare(
78
+ `INSERT INTO note_vectors (note_id, chunk_ix, vector, dims, model, content_hash, embedded_at)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?)
80
+ ON CONFLICT (note_id, chunk_ix) DO UPDATE SET
81
+ vector = excluded.vector,
82
+ dims = excluded.dims,
83
+ model = excluded.model,
84
+ content_hash = excluded.content_hash,
85
+ embedded_at = excluded.embedded_at`,
86
+ ).run(noteId, chunk.ix, encodeVector(vector), vector.length, model, contentHash, new Date().toISOString());
87
+ }
88
+
89
+ /**
90
+ * Count notes that have zero `note_vectors` rows under `model` — the same
91
+ * "pending" definition `semanticSearchNotes`/`getNotesPendingEmbedding`
92
+ * use (see `nonBlankContentClause`'s doc for why blank notes are excluded
93
+ * from `total`, not just `pending` — otherwise `pending` could go
94
+ * negative, or a vault with only blank notes would report 100% pending
95
+ * forever). Used by the `embeddings` capability field (`GET /api/vault`)
96
+ * to report backfill progress without a full semantic scan.
97
+ */
98
+ export function countNotesPendingEmbedding(db: Database, model: string): { total: number; pending: number } {
99
+ const total = (
100
+ db.prepare(`SELECT COUNT(*) AS n FROM notes WHERE ${nonBlankContentClause("content")}`).get() as { n: number }
101
+ ).n;
102
+ const embedded = (
103
+ db
104
+ .prepare("SELECT COUNT(DISTINCT note_id) AS n FROM note_vectors WHERE model = ?")
105
+ .get(model) as { n: number }
106
+ ).n;
107
+ return { total, pending: Math.max(0, total - embedded) };
108
+ }
109
+
110
+ /**
111
+ * Notes with zero `note_vectors` rows under `model` — the actual
112
+ * candidate list (not just a count) the self-host in-process drain and
113
+ * the cloud DO-alarm drain walk to backfill/catch up a vault. A note with
114
+ * SOME but not all chunks embedded (a partial prior embed, e.g. a crash
115
+ * mid-drain) is NOT returned here — see the module doc on
116
+ * `countNotesPendingEmbedding` for the same coarse-but-honest tradeoff;
117
+ * the embed-on-write hook re-derives full freshness per note via
118
+ * `planStaleness`, so a partially-embedded note self-heals on its next
119
+ * edit even if the sweep doesn't re-visit it.
120
+ *
121
+ * Excludes blank/whitespace-only notes — see `nonBlankContentClause`'s
122
+ * doc comment for why (they'd otherwise be "pending" FOREVER, hammering
123
+ * the provider with empty input every sweep pass forever).
124
+ */
125
+ export function getNotesPendingEmbedding(
126
+ db: Database,
127
+ model: string,
128
+ limit?: number,
129
+ ): { id: string; content: string }[] {
130
+ const sql = `SELECT id, content FROM notes
131
+ WHERE id NOT IN (SELECT DISTINCT note_id FROM note_vectors WHERE model = ?)
132
+ AND ${nonBlankContentClause("content")}${typeof limit === "number" ? " LIMIT ?" : ""}`;
133
+ const params: (string | number)[] = typeof limit === "number" ? [model, limit] : [model];
134
+ return db.prepare(sql).all(...params) as { id: string; content: string }[];
135
+ }
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Used by `query-notes` when `expand_links=true`. Replaces wikilink matches
5
5
  * with delimited blocks containing the linked note's content (full mode) or
6
- * metadata summary (summary mode). Deduplicates across the query and guards
7
- * against cycles via a shared `expanded` set.
6
+ * a summary (summary mode): `metadata.summary` when present, else the
7
+ * target note's lede (see `summaryText`). Deduplicates across the query and
8
+ * guards against cycles via a shared `expanded` set.
8
9
  */
9
10
 
10
11
  import { Database } from "bun:sqlite";
@@ -152,11 +153,18 @@ function renderSummary(note: Note): string {
152
153
  return `<expanded path="${pathAttr}" mode="summary">\n${summary}\n</expanded>`;
153
154
  }
154
155
 
156
+ /**
157
+ * `metadata.summary` wins when present (no behavior change for existing
158
+ * callers). Absent it, falls back to the target note's lede — its opening
159
+ * paragraph, per `computeLede` — so a note that was never given a
160
+ * `metadata.summary` still yields something useful in summary-mode
161
+ * expansion instead of an empty block.
162
+ */
155
163
  function summaryText(note: Note): string {
156
164
  const meta = note.metadata as Record<string, unknown> | undefined;
157
165
  const s = meta?.summary;
158
166
  if (typeof s === "string" && s.trim()) return s.trim();
159
- return "";
167
+ return noteOps.computeLede(note.content) ?? "";
160
168
  }
161
169
 
162
170
  function escapeAttr(s: string): string {
@@ -67,13 +67,16 @@ describe("indexed-fields: module", () => {
67
67
  expect(() => validateFieldName("'; DROP TABLE notes; --")).toThrow(IndexedFieldError);
68
68
  });
69
69
 
70
- it("TYPE_MAP covers string/integer/boolean/reference", () => {
70
+ it("TYPE_MAP covers string/integer/boolean/reference/date", () => {
71
71
  expect(TYPE_MAP.string).toBe("TEXT");
72
72
  expect(TYPE_MAP.integer).toBe("INTEGER");
73
73
  expect(TYPE_MAP.boolean).toBe("INTEGER");
74
74
  // vault#typed-reference-field: `reference` stores like `string` — the
75
75
  // metadata VALUE (an id/path/title) is what's indexed here.
76
76
  expect(TYPE_MAP.reference).toBe("TEXT");
77
+ // vault#date-field-type: `date` also stores like `string` — an ISO-8601
78
+ // string, which sorts correctly under a plain TEXT comparison.
79
+ expect(TYPE_MAP.date).toBe("TEXT");
77
80
  });
78
81
 
79
82
  it("declareField creates column + index on first declaration", () => {
@@ -212,12 +215,15 @@ describe("update-tag: indexed flag", () => {
212
215
  });
213
216
 
214
217
  it("unsupported field type for indexing throws", async () => {
218
+ // vault#date-field-type: `date` JOINED the indexable subset (stores TEXT,
219
+ // same as string/reference) — `number` (a float) is the current example
220
+ // of a recognized-but-unindexable type; see TYPE_MAP.
215
221
  expect(() =>
216
222
  findTool("update-tag").execute({
217
223
  tag: "project",
218
- fields: { weird: { type: "date", indexed: true } },
224
+ fields: { weird: { type: "number", indexed: true } },
219
225
  }),
220
- ).toThrow(/unsupported type "date"/);
226
+ ).toThrow(/unsupported type "number"/);
221
227
  });
222
228
 
223
229
  it("invalid field name for indexing throws", async () => {
@@ -27,13 +27,21 @@ export type SqliteType = "TEXT" | "INTEGER";
27
27
  // `string` — because the metadata VALUE (an id/path/title) is what's
28
28
  // indexed here; the resolved graph link is a separate concern maintained by
29
29
  // `core/src/store.ts`'s write path. See tag-schemas.ts's `VALID_FIELD_TYPES`.
30
- export type FieldType = "string" | "integer" | "boolean" | "reference";
30
+ // `date` is also stored as TEXT: an indexed `date` field holds an ISO-8601
31
+ // string (validated by `tag-schemas.ts`'s `defaultMatchesType` /
32
+ // `schema-defaults.ts`'s `valueMatchesType`), and ISO-8601 strings are
33
+ // lexicographically comparable — the same reason TEXT-stored `updated_at`
34
+ // range queries already work — so `gt`/`gte`/`lt`/`lte`, `date_filter`, and
35
+ // `order_by` all fall out of the generic TEXT-column machinery with no
36
+ // type-specific SQL.
37
+ export type FieldType = "string" | "integer" | "boolean" | "reference" | "date";
31
38
 
32
39
  export const TYPE_MAP: Record<FieldType, SqliteType> = {
33
40
  string: "TEXT",
34
41
  integer: "INTEGER",
35
42
  boolean: "INTEGER",
36
43
  reference: "TEXT",
44
+ date: "TEXT",
37
45
  };
38
46
 
39
47
  export interface IndexedField {