@openparachute/vault 0.7.3-rc.1 → 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 (69) 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 +521 -4
  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 +381 -10
  26. package/core/src/notes.semantic-search.test.ts +131 -0
  27. package/core/src/notes.ts +313 -6
  28. package/core/src/query-warnings.ts +20 -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 +55 -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/cli.ts +5 -1
  48. package/src/contract-errors.test.ts +2 -1
  49. package/src/embedding/capability.test.ts +33 -0
  50. package/src/embedding/capability.ts +34 -0
  51. package/src/embedding/external-api.test.ts +154 -0
  52. package/src/embedding/external-api.ts +144 -0
  53. package/src/embedding/onnx-transformers.test.ts +113 -0
  54. package/src/embedding/onnx-transformers.ts +141 -0
  55. package/src/embedding/select.test.ts +99 -0
  56. package/src/embedding/select.ts +92 -0
  57. package/src/embedding-worker.test.ts +300 -0
  58. package/src/embedding-worker.ts +226 -0
  59. package/src/mcp-http.ts +13 -1
  60. package/src/mcp-tools.ts +49 -14
  61. package/src/onboarding-seed.test.ts +64 -0
  62. package/src/routes.ts +146 -92
  63. package/src/routing.ts +17 -0
  64. package/src/semantic-search-routes.test.ts +161 -0
  65. package/src/server.ts +21 -2
  66. package/src/vault-embeddings-capability.test.ts +82 -0
  67. package/src/vault-store-embedding-wiring.test.ts +69 -0
  68. package/src/vault-store.ts +53 -2
  69. package/src/vault.test.ts +35 -11
package/core/src/notes.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Database, type SQLQueryBindings } from "bun:sqlite";
2
- import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats, VaultMap, AggregateSpec, AggregateRow } from "./types.js";
2
+ import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats, VaultMap, AggregateSpec, AggregateRow, SemanticSearchResult } from "./types.js";
3
+ import { decodeVector, dot } from "./embedding/vector-codec.js";
4
+ import { nonBlankContentClause } from "./embedding/vectors.js";
3
5
  import { normalizePath } from "./paths.js";
4
6
  import { transaction } from "./txn.js";
5
7
  import {
@@ -23,6 +25,7 @@ import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-
23
25
  import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
24
26
  import {
25
27
  buildLiteralSearchQuery,
28
+ extractLiteralBoostTerms,
26
29
  SEARCH_WEIGHT_PATH,
27
30
  SEARCH_WEIGHT_CONTENT,
28
31
  type SearchMode,
@@ -962,7 +965,7 @@ function resolveUpdatedAtBoundMs(field: string, value: string): number {
962
965
  * semantics a normal query applies, rather than risking the two drifting
963
966
  * apart.
964
967
  */
965
- function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: string[]; params: SQLQueryBindings[] } {
968
+ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: string[]; params: SQLQueryBindings[] } {
966
969
  const conditions: string[] = [];
967
970
  const params: SQLQueryBindings[] = [];
968
971
 
@@ -1808,6 +1811,61 @@ function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): Qu
1808
1811
  });
1809
1812
  }
1810
1813
 
1814
+ /**
1815
+ * Post-rank a search result page (title axis, ratified 2026-07-17) so
1816
+ * notes whose display title (first line of content) contains EVERY boost
1817
+ * term outrank the rest — a title-match tier ahead of a body-only-match
1818
+ * tier. Stable partition: within a tier, the incoming order (FTS5
1819
+ * relevance, or an explicit `sort`) is preserved untouched — two notes
1820
+ * that both match, or both don't, keep their relative order.
1821
+ *
1822
+ * No-migration by design (see the search-seam note on `searchNotes`
1823
+ * below): re-orders the already-fetched page in memory instead of adding
1824
+ * a weighted title column to `notes_fts`, which would need a schema
1825
+ * migration + backfill. `notes_fts`'s existing `SEARCH_WEIGHT_PATH` bm25
1826
+ * weighting (above) already boosts by note PATH — a different axis from
1827
+ * the ratified title model (title = first line of CONTENT, not path).
1828
+ * This function is the one that boosts by the content-derived title.
1829
+ */
1830
+ function boostTitleMatches(notes: Note[], terms: string[]): Note[] {
1831
+ if (terms.length === 0) return notes;
1832
+ return notes
1833
+ .map((note, index) => ({ note, index, titleMatch: titleMatchesAllTerms(note.content, terms) }))
1834
+ .sort((a, b) => {
1835
+ if (a.titleMatch === b.titleMatch) return a.index - b.index; // stable
1836
+ return a.titleMatch ? -1 : 1;
1837
+ })
1838
+ .map((x) => x.note);
1839
+ }
1840
+
1841
+ function titleMatchesAllTerms(content: string | null | undefined, terms: string[]): boolean {
1842
+ const title = computeDisplayTitle(content);
1843
+ if (!title) return false;
1844
+ const lower = title.toLowerCase();
1845
+ return terms.every((t) => lower.includes(t));
1846
+ }
1847
+
1848
+ /**
1849
+ * Apply the title-boost post-rank to a fetched search page. Skipped
1850
+ * whenever it would fight the caller's intent:
1851
+ * - an explicit `sort: "asc"|"desc"` means the caller wants chronological
1852
+ * order, not relevance re-ranking;
1853
+ * - `search_mode: "advanced"` text carries raw FTS5 syntax (boolean
1854
+ * operators, column filters, prefix `*`) — naively tokenizing it into
1855
+ * "terms" would misread operators as content (see
1856
+ * `extractLiteralBoostTerms`'s doc comment). Only literal-mode queries
1857
+ * (the default) get boosted today.
1858
+ */
1859
+ function applySearchTitleBoost(
1860
+ notes: Note[],
1861
+ mode: SearchMode,
1862
+ rawQuery: string,
1863
+ sort: "asc" | "desc" | undefined,
1864
+ ): Note[] {
1865
+ if (sort !== undefined || mode !== "literal") return notes;
1866
+ return boostTitleMatches(notes, extractLiteralBoostTerms(rawQuery));
1867
+ }
1868
+
1811
1869
  export function searchNotes(
1812
1870
  db: Database,
1813
1871
  query: string,
@@ -1844,6 +1902,14 @@ export function searchNotes(
1844
1902
  // The weights are our own numeric constants (not user input) interpolated
1845
1903
  // directly — bm25()'s weight arguments are positional per-column
1846
1904
  // multipliers, not general SQL expressions callers can influence.
1905
+ //
1906
+ // NOTE: "title" here means the note's `path` — this predates the title
1907
+ // axis (ratified 2026-07-17), under which a note's title is its first
1908
+ // CONTENT line, not its path. `applySearchTitleBoost` (below) is the
1909
+ // mechanism for THAT axis: a post-rank pass over the already-scored page,
1910
+ // not a third FTS column. The two are complementary, not redundant — a
1911
+ // note can have a matching path with a non-matching first line, or vice
1912
+ // versa.
1847
1913
  const scoreExpr = `(-1.0 * bm25(notes_fts, ${SEARCH_WEIGHT_PATH}, ${SEARCH_WEIGHT_CONTENT}))`;
1848
1914
 
1849
1915
  // `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
@@ -1884,7 +1950,7 @@ export function searchNotes(
1884
1950
  ORDER BY ${orderBy}
1885
1951
  LIMIT ?
1886
1952
  `).all(ftsQuery, ...searchTags, limit) as (NoteRow & { score: number })[];
1887
- return notesWithTags(db, rows, scoresById(rows));
1953
+ return applySearchTitleBoost(notesWithTags(db, rows, scoresById(rows)), mode, query, opts?.sort);
1888
1954
  } catch (err) {
1889
1955
  // Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
1890
1956
  // advanced mode expects it (the caller passed raw syntax); literal
@@ -1906,13 +1972,121 @@ export function searchNotes(
1906
1972
  ORDER BY ${orderBy}
1907
1973
  LIMIT ?
1908
1974
  `).all(ftsQuery, limit) as (NoteRow & { score: number })[];
1909
- return notesWithTags(db, rows, scoresById(rows));
1975
+ return applySearchTitleBoost(notesWithTags(db, rows, scoresById(rows)), mode, query, opts?.sort);
1910
1976
  } catch (err) {
1911
1977
  if (err instanceof QueryError) throw err;
1912
1978
  throw searchSyntaxError(query, err, mode);
1913
1979
  }
1914
1980
  }
1915
1981
 
1982
+ /**
1983
+ * Semantic-search scan (EXPERIMENTAL — `SEMANTIC-MVP-PLAN.md`). Structural
1984
+ * sibling of `searchNotes`: text-shaped filters compose the same way, but
1985
+ * ranking is cosine similarity over `note_vectors` instead of FTS5 bm25.
1986
+ *
1987
+ * Filter-then-rank (per the plan): `buildFilterConditions` — the SAME
1988
+ * filter builder `queryNotes` uses — narrows to the candidate note set
1989
+ * FIRST (tags/metadata/dates/path/etc. all apply), so "notes about X that
1990
+ * are also #project" is one call, exactly like `search` composes with
1991
+ * structured filters. Only THEN does the brute-force cosine scan run, and
1992
+ * only over that (usually much smaller) candidate set — this is the perf
1993
+ * lever the architecture doc calls out (filter shrinks N before the O(N)
1994
+ * vector work).
1995
+ *
1996
+ * `model` restricts the scan to vectors recorded under the CURRENTLY
1997
+ * configured embedding model — a row left over from a prior model (see
1998
+ * `core/src/embedding/staleness.ts`) never contributes to ranking, so a
1999
+ * model change can't silently blend two incomparable vector spaces.
2000
+ *
2001
+ * Ranking is best-CHUNK-per-note (max cosine over a note's chunks, per the
2002
+ * ratified per-section chunking decision), but the returned unit is
2003
+ * always the NOTE (via `fetchNotesByIdsOrdered`), never a bare chunk —
2004
+ * matching `search`'s "text in → ranked notes out" contract.
2005
+ *
2006
+ * `queryVector` MUST already be L2-normalized (so the dot product below
2007
+ * IS cosine similarity) — `Store.semanticSearch` normalizes right after
2008
+ * embedding the query text; stored vectors are normalized at write time.
2009
+ */
2010
+ export function semanticSearchNotes(
2011
+ db: Database,
2012
+ queryVector: Float32Array,
2013
+ opts: QueryOpts,
2014
+ model: string,
2015
+ ): SemanticSearchResult {
2016
+ const limit = typeof opts.limit === "number" ? opts.limit : 10;
2017
+ const { conditions, params } = buildFilterConditions(db, opts);
2018
+ // Blank/whitespace-only notes are never candidates: they have nothing
2019
+ // embeddable and never get a note_vectors row (see `embedding-worker.ts`'s
2020
+ // chunk filter), so counting one as a "pending" candidate would produce a
2021
+ // phantom embeddings_pending warning that never drains. See
2022
+ // `nonBlankContentClause`'s doc comment.
2023
+ const allConditions = [...conditions, nonBlankContentClause("n.content")];
2024
+ const whereClause = `WHERE ${allConditions.join(" AND ")}`;
2025
+
2026
+ const idRows = db.prepare(`SELECT n.id FROM notes n ${whereClause}`).all(...params) as {
2027
+ id: string;
2028
+ }[];
2029
+ const candidateIds = idRows.map((r) => r.id);
2030
+ if (candidateIds.length === 0) {
2031
+ return { notes: [], pendingCount: 0, totalCandidates: 0 };
2032
+ }
2033
+
2034
+ // Pull every candidate's vector rows for the ACTIVE model, batched to
2035
+ // stay under the DO 100-bound-param cap (same chunking `fetchNotesByIdsOrdered`
2036
+ // uses). A note absent from this map has no fresh vector yet — pending.
2037
+ const vecRowsByNote = new Map<string, { chunk_ix: number; vector: Uint8Array }[]>();
2038
+ for (const idChunk of chunkForInClause(candidateIds)) {
2039
+ const placeholders = idChunk.map(() => "?").join(", ");
2040
+ const rows = db.prepare(
2041
+ `SELECT note_id, chunk_ix, vector FROM note_vectors WHERE model = ? AND note_id IN (${placeholders})`,
2042
+ ).all(model, ...idChunk) as { note_id: string; chunk_ix: number; vector: Uint8Array }[];
2043
+ for (const row of rows) {
2044
+ let list = vecRowsByNote.get(row.note_id);
2045
+ if (!list) {
2046
+ list = [];
2047
+ vecRowsByNote.set(row.note_id, list);
2048
+ }
2049
+ list.push(row);
2050
+ }
2051
+ }
2052
+
2053
+ // Best-chunk-per-note cosine (max over the note's chunks) — a long note
2054
+ // whose 3rd section matches outranks a note with weak whole-note
2055
+ // similarity, which is the whole point of per-section chunking.
2056
+ const scored: { id: string; score: number }[] = [];
2057
+ for (const id of candidateIds) {
2058
+ const rows = vecRowsByNote.get(id);
2059
+ if (!rows || rows.length === 0) continue;
2060
+ let best = -Infinity;
2061
+ for (const row of rows) {
2062
+ const score = dot(decodeVector(row.vector), queryVector);
2063
+ if (score > best) best = score;
2064
+ }
2065
+ scored.push({ id, score: best });
2066
+ }
2067
+ scored.sort((a, b) => b.score - a.score);
2068
+ const top = scored.slice(0, limit);
2069
+
2070
+ // Reuse the existing hydration machinery — same as every other id-list
2071
+ // fetch path (tags batched, no N+1).
2072
+ const hydrated = fetchNotesByIdsOrdered(db, top.map((t) => t.id));
2073
+ const byId = new Map(hydrated.map((n) => [n.id, n]));
2074
+ const scoreById = new Map(top.map((t) => [t.id, t.score]));
2075
+ const notes: Note[] = [];
2076
+ for (const t of top) {
2077
+ const note = byId.get(t.id);
2078
+ if (!note) continue; // defensive: row vanished between the two queries
2079
+ note.score = scoreById.get(t.id);
2080
+ notes.push(note);
2081
+ }
2082
+
2083
+ return {
2084
+ notes,
2085
+ pendingCount: candidateIds.length - vecRowsByNote.size,
2086
+ totalCandidates: candidateIds.length,
2087
+ };
2088
+ }
2089
+
1916
2090
  /**
1917
2091
  * Map rows → Notes with tags hydrated in one batched query. `scores`
1918
2092
  * (vault#551 WS2C) is an optional id → weighted-bm25-score map, attached
@@ -2592,10 +2766,142 @@ export function mergeTags(
2592
2766
  /** Max code points in a NoteIndex preview. */
2593
2767
  export const NOTE_INDEX_PREVIEW_LEN = 120;
2594
2768
 
2769
+ /** Max code points in a computed `displayTitle` (title axis, ratified 2026-07-17). */
2770
+ export const DISPLAY_TITLE_MAX_LEN = 120;
2771
+
2772
+ /**
2773
+ * Bounded line-count a leading frontmatter block's closing fence is
2774
+ * searched within (see `computeDisplayTitle`). Frontmatter blocks are a
2775
+ * handful of lines in practice; capping the scan keeps a pathological
2776
+ * "content starts with `---` but never closes it" note O(1)-ish rather
2777
+ * than a full-content scan on every derivation.
2778
+ */
2779
+ const FRONTMATTER_SCAN_LINES = 100;
2780
+
2781
+ /**
2782
+ * Shared by `computeDisplayTitle` and `computeLede`: given `content` already
2783
+ * split into `lines`, return the index of the first line that counts as the
2784
+ * start of the DOCUMENT body — after a leading closed frontmatter block, if
2785
+ * `content` opens with one (see `computeDisplayTitle`'s frontmatter-skip
2786
+ * doc for the full rationale). `0` when there's no leading frontmatter.
2787
+ */
2788
+ function skipLeadingFrontmatter(lines: string[]): number {
2789
+ if (lines[0]?.trim() !== "---") return 0;
2790
+ const scanLimit = Math.min(lines.length, FRONTMATTER_SCAN_LINES);
2791
+ for (let i = 1; i < scanLimit; i++) {
2792
+ if (lines[i]?.trim() === "---") return i + 1;
2793
+ }
2794
+ return 0;
2795
+ }
2796
+
2797
+ /**
2798
+ * Derive a note's display title: the first non-empty line of `content`,
2799
+ * with a leading markdown heading marker (`#` through `######`) and its
2800
+ * following whitespace stripped, truncated to `DISPLAY_TITLE_MAX_LEN` code
2801
+ * points. `null` when content has no non-empty line (empty or
2802
+ * whitespace/heading-marker-only note).
2803
+ *
2804
+ * The ratified title model (2026-07-17): a note's title IS its first line
2805
+ * — derived from content, NEVER stored as its own column. This function is
2806
+ * the one place that derivation happens; callers (`toNoteIndex` below) call
2807
+ * it fresh at read time. Timestamp-path formatting — what a surface shows
2808
+ * in place of a `null` title — is deliberately NOT this function's job; it
2809
+ * reports the honest content-derived value (or its absence) and leaves
2810
+ * rendering to the caller.
2811
+ *
2812
+ * Frontmatter skip: normal ingestion strips a YAML frontmatter block into
2813
+ * `metadata` before create, so `content` never carries one — but a direct
2814
+ * MCP/REST create can paste raw frontmatter-bearing text
2815
+ * (`---\ntitle: X\n---\n# Real Title`), and the first line of that
2816
+ * DOCUMENT is not the first line of its delimiter. When `content` opens
2817
+ * with a `---` line, derivation starts after the matching CLOSING `---`
2818
+ * (searched within the first `FRONTMATTER_SCAN_LINES` lines). An
2819
+ * unterminated opening `---` falls back to the pre-existing behavior —
2820
+ * scanning from line 0 — so a note whose real first line is literally
2821
+ * `---` isn't mangled.
2822
+ */
2823
+ export function computeDisplayTitle(content: string | null | undefined): string | null {
2824
+ if (!content) return null;
2825
+ const lines = content.split("\n");
2826
+ const startIndex = skipLeadingFrontmatter(lines);
2827
+ for (let i = startIndex; i < lines.length; i++) {
2828
+ const stripped = lines[i]!.replace(/^#{1,6}\s*/, "").trim();
2829
+ if (stripped === "") continue;
2830
+ // Iterate by Unicode code points so we don't split surrogate pairs mid-character.
2831
+ const codePoints = Array.from(stripped);
2832
+ return codePoints.length > DISPLAY_TITLE_MAX_LEN
2833
+ ? codePoints.slice(0, DISPLAY_TITLE_MAX_LEN).join("")
2834
+ : stripped;
2835
+ }
2836
+ return null;
2837
+ }
2838
+
2839
+ /** Max code points in a computed `lede` (summaries-as-content, 2026-07-17 dialogue). */
2840
+ export const LEDE_MAX_LEN = 400;
2841
+
2842
+ /**
2843
+ * Derive a note's "lede": the first non-empty PARAGRAPH after the title
2844
+ * line (a run of consecutive non-blank lines, whitespace-collapsed to one
2845
+ * line, truncated to `LEDE_MAX_LEN` code points). `null` when there's no
2846
+ * paragraph after the title — a title-only note has no lede to report, and
2847
+ * callers must not fall back to repeating the title itself.
2848
+ *
2849
+ * This is the machinery half of a soft convention (2026-07-17 dialogue):
2850
+ * summaries work better as visible CONTENT — a note's opening paragraph —
2851
+ * than as hidden metadata, because visible text gets corrected by the notes
2852
+ * that reference it while hidden metadata rots unnoticed. Nothing validates
2853
+ * that a note actually opens with a title + lede; this function just reports
2854
+ * what it finds, honestly, using the SAME title-line rule as
2855
+ * `computeDisplayTitle` (including its frontmatter skip) so a caller that
2856
+ * shows both title and lede sees them agree on where the title ends.
2857
+ */
2858
+ export function computeLede(content: string | null | undefined): string | null {
2859
+ if (!content) return null;
2860
+ const lines = content.split("\n");
2861
+ const bodyStart = skipLeadingFrontmatter(lines);
2862
+
2863
+ let titleLine = -1;
2864
+ for (let i = bodyStart; i < lines.length; i++) {
2865
+ if (lines[i]!.replace(/^#{1,6}\s*/, "").trim() !== "") {
2866
+ titleLine = i;
2867
+ break;
2868
+ }
2869
+ }
2870
+ if (titleLine === -1) return null; // no title at all — nothing to find a lede after
2871
+
2872
+ let i = titleLine + 1;
2873
+ while (i < lines.length && lines[i]!.trim() === "") i++; // skip blank lines after the title
2874
+
2875
+ const paragraphLines: string[] = [];
2876
+ while (i < lines.length && lines[i]!.trim() !== "") {
2877
+ paragraphLines.push(lines[i]!);
2878
+ i++;
2879
+ }
2880
+ if (paragraphLines.length === 0) return null; // title-only note
2881
+
2882
+ const paragraph = paragraphLines.join(" ").replace(/\s+/g, " ").trim();
2883
+ if (paragraph === "") return null;
2884
+
2885
+ const codePoints = Array.from(paragraph);
2886
+ return codePoints.length > LEDE_MAX_LEN
2887
+ ? codePoints.slice(0, LEDE_MAX_LEN).join("")
2888
+ : paragraph;
2889
+ }
2890
+
2595
2891
  /**
2596
2892
  * Convert a full Note into its lean index shape:
2597
- * drops `content`, adds `byteSize` and a whitespace-collapsed `preview`.
2598
- * Shared between the `query-notes` MCP tool, HTTP /notes endpoints, and /graph.
2893
+ * drops `content`, adds `byteSize`, a whitespace-collapsed `preview`, and a
2894
+ * computed `displayTitle`. Shared between the `query-notes` MCP tool, HTTP
2895
+ * /notes endpoints, and /graph.
2896
+ *
2897
+ * Perf note: `displayTitle` costs nothing extra here — every caller of this
2898
+ * function already has the full `Note` (content included) in hand by the
2899
+ * time it's called. `queryNotes`'s two-phase page fetch selects a narrow
2900
+ * `id`-only column list for the ORDER BY/pagination phase, but the second
2901
+ * phase (`fetchNotesByIdsOrdered`) always `SELECT *`s the page's full rows
2902
+ * before `toNoteIndex` ever runs — same as `preview`/`byteSize` today. If a
2903
+ * future lean-list path is added that genuinely avoids a content read, it
2904
+ * must NOT call this function (or `toNoteIndex`) on a content-less row.
2599
2905
  */
2600
2906
  export function toNoteIndex(note: Note): NoteIndex {
2601
2907
  const content = note.content ?? "";
@@ -2622,6 +2928,7 @@ export function toNoteIndex(note: Note): NoteIndex {
2622
2928
  metadata: note.metadata,
2623
2929
  byteSize,
2624
2930
  preview,
2931
+ displayTitle: computeDisplayTitle(note.content),
2625
2932
  ...(note.score !== undefined ? { score: note.score } : {}),
2626
2933
  };
2627
2934
  }
@@ -395,3 +395,23 @@ export function searchDidYouMeanWarning(rawQuery: string, suggestion: string): Q
395
395
  did_you_mean: suggestion,
396
396
  };
397
397
  }
398
+
399
+ /**
400
+ * `embeddings_pending` warning (semantic search MVP, EXPERIMENTAL —
401
+ * `SEMANTIC-MVP-PLAN.md`) — a `semantic: true` query ran and returned
402
+ * REAL results, but `pending` of the `total_candidates` structured-filter
403
+ * matches have no vector yet for the active embedding model (a fresh
404
+ * note, or the backfill sweep hasn't reached it yet). Attached alongside
405
+ * honest (possibly incomplete) results — NEVER instead of them, and never
406
+ * a silent keyword fallback: the caller always knows it got a partial
407
+ * semantic ranking, not a complete one. Callers only push this when
408
+ * `pending > 0` (the common steady-state case attaches nothing).
409
+ */
410
+ export function embeddingsPendingWarning(pending: number, totalCandidates: number): QueryWarning {
411
+ return {
412
+ code: "embeddings_pending",
413
+ message: `${pending} of ${totalCandidates} candidate note(s) have no embedding yet for the active model — results may be incomplete until the backfill catches up.`,
414
+ pending,
415
+ total_candidates: totalCandidates,
416
+ };
417
+ }
@@ -41,6 +41,7 @@
41
41
  */
42
42
 
43
43
  import { Database } from "bun:sqlite";
44
+ import { timestampToMs } from "./cursor.js";
44
45
 
45
46
  // ---------------------------------------------------------------------------
46
47
  // Types
@@ -57,7 +58,7 @@ export interface SchemaField {
57
58
  * `type_mismatch` warning that previously fired on every integer-shaped
58
59
  * field because the validator had no `"integer"` case. See vault#310.
59
60
  */
60
- type?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "reference";
61
+ type?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "reference" | "date";
61
62
  enum?: string[];
62
63
  description?: string;
63
64
  /**
@@ -320,6 +321,82 @@ export function resolveNoteSchemas(
320
321
  return { effectiveTags, mergedFields, conflicts };
321
322
  }
322
323
 
324
+ /**
325
+ * Normalize `date`-typed field VALUES in `metadata` to canonical UTC ISO
326
+ * form (`Z`-suffixed) BEFORE persisting (vault#date-field-type — mixed-
327
+ * offset corruption, caught in review).
328
+ *
329
+ * The bug: every consumer of an indexed `date` field — `buildOperatorClause`
330
+ * (query-operators.ts), `date_filter` and `order_by` (notes.ts) — compares
331
+ * the generated `meta_<field>` column as raw TEXT. ISO-8601 strings sort
332
+ * correctly under a TEXT compare ONLY when every value shares the same
333
+ * offset representation. `timestampToMs` (cursor.ts) — the validator both
334
+ * `defaultMatchesType` and `valueMatchesType` reuse — correctly ACCEPTS an
335
+ * explicit `±HH:MM` offset (validation was never the gap), but a value like
336
+ * `"2026-07-16T10:00:00+02:00"` (= `08:00Z`) persisted VERBATIM sorts AFTER
337
+ * `"2026-07-16T09:00:00Z"` (= `09:00Z`) under a raw string compare, even
338
+ * though the actual instant is earlier — a mixed-offset vault silently gets
339
+ * wrong range-query/order_by/date_filter results. This is the exact bug
340
+ * class `updated_at` got a dedicated ms-mirror column for (vault#585/#586,
341
+ * see `notes.ts`'s `dateFilter` block) that never extended to user-declared
342
+ * `date` fields.
343
+ *
344
+ * The fix is normalize-on-write, not reject-on-write (matching the
345
+ * "paths are normalized on write" precedent — instant preserved,
346
+ * representation canonicalized) — rejecting offsets would defeat the
347
+ * type's own motivation (calendar integrations and other emitters commonly
348
+ * produce offset timestamps, not always `Z`). Only a FULL timestamp (has a
349
+ * time component) is rewritten, to `new Date(ms).toISOString()` — always
350
+ * UTC, millisecond precision, `Z`-suffixed. A bare `YYYY-MM-DD` value is
351
+ * left untouched: it has no offset to normalize, is already canonical, and
352
+ * prefix-sorts correctly against full timestamps sharing the same calendar
353
+ * day. A value that fails `timestampToMs` is left untouched too — that's a
354
+ * `type_mismatch` for `valueMatchesType` to catch, not this function's job.
355
+ *
356
+ * COPY-ON-WRITE, never mutates the input `metadata` object (vault#date-
357
+ * field-type review round 2) — `core/` is a published library; a direct
358
+ * embedder holding a reference to the object THEY passed in must never see
359
+ * it change out from under them. Returns the SAME reference when nothing
360
+ * needs rewriting (the common case — no unnecessary allocation on the
361
+ * no-op path) or `metadata` is undefined; returns a freshly shallow-copied
362
+ * object, with only the rewritten field(s) replaced, the first time a
363
+ * rewrite is actually needed. Every call site must use the RETURNED value
364
+ * (not assume its input was mutated) — see `store.createNote`/`updateNote`/
365
+ * `createNotes`/`createNoteRaw`, all of which reassign their local
366
+ * `opts`/`updates`/`input` binding from the return rather than relying on a
367
+ * side effect.
368
+ *
369
+ * Called from `store.createNote`/`updateNote`/`createNotes`/`createNoteRaw`
370
+ * — the lowest chokepoint every write path (MCP, REST, import) funnels
371
+ * through — BEFORE the row is persisted, so the value that's validated by
372
+ * `validateNote`/`valueMatchesType` upstream (offset-tolerant, so
373
+ * normalization can't newly fail a write) and the value that's written +
374
+ * echoed back on the response are the SAME normalized string.
375
+ */
376
+ export function normalizeDateFields(
377
+ resolved: ResolvedSchemas,
378
+ note: { tags?: string[]; metadata?: Record<string, unknown> },
379
+ ): Record<string, unknown> | undefined {
380
+ const metadata = note.metadata;
381
+ if (!metadata) return metadata;
382
+ const { mergedFields } = resolveNoteSchemas(resolved, note);
383
+ let copy: Record<string, unknown> | undefined;
384
+ for (const [fieldName, { spec }] of mergedFields) {
385
+ if (spec.type !== "date") continue;
386
+ const value = metadata[fieldName];
387
+ if (typeof value !== "string") continue;
388
+ // Bare `YYYY-MM-DD` (no time component) has no offset to normalize.
389
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) continue;
390
+ const ms = timestampToMs(value);
391
+ if (ms === null) continue; // unparseable — valueMatchesType's job, not ours
392
+ const canonical = new Date(ms).toISOString();
393
+ if (canonical === value) continue;
394
+ if (!copy) copy = { ...metadata }; // lazy: only copy once a rewrite is due
395
+ copy[fieldName] = canonical;
396
+ }
397
+ return copy ?? metadata;
398
+ }
399
+
323
400
  function fieldSpecsEqual(a: SchemaField, b: SchemaField): boolean {
324
401
  if (a.type !== b.type) return false;
325
402
  if (!stringArraysEqual(a.enum, b.enum)) return false;
@@ -390,6 +467,13 @@ function valueMatchesType(value: unknown, type: SchemaField["type"], cardinality
390
467
  return Array.isArray(value) && value.every((item) => typeof item === "string");
391
468
  }
392
469
  return typeof value === "string";
470
+ // `date` validates like `string` — an ISO-8601 date (`YYYY-MM-DD`) or
471
+ // full RFC3339 timestamp. Reuses `cursor.ts`'s `timestampToMs` (the SAME
472
+ // UTC-correct parser `date_filter`'s `updated_at` bound uses), not a
473
+ // second date parser — see tag-schemas.ts's `VALID_FIELD_TYPES` doc
474
+ // comment.
475
+ case "date":
476
+ return typeof value === "string" && timestampToMs(value) !== null;
393
477
  }
394
478
  }
395
479