@openparachute/vault 0.7.0-rc.8 → 0.7.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/core/src/aggregate.test.ts +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +1151 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/doctor.ts +23 -1
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +587 -79
- package/core/src/notes.ts +371 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +42 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +98 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +244 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +100 -19
- package/src/routes.ts +469 -39
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +923 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/core/src/notes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
2
|
-
import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats } from "./types.js";
|
|
2
|
+
import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats, VaultMap, AggregateSpec, AggregateRow } from "./types.js";
|
|
3
3
|
import { normalizePath } from "./paths.js";
|
|
4
4
|
import { transaction } from "./txn.js";
|
|
5
5
|
import {
|
|
@@ -28,8 +28,7 @@ import {
|
|
|
28
28
|
SEARCH_WEIGHT_CONTENT,
|
|
29
29
|
type SearchMode,
|
|
30
30
|
} from "./search-query.js";
|
|
31
|
-
|
|
32
|
-
let idCounter = 0;
|
|
31
|
+
import { generateUlid } from "./ulid.js";
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
34
|
* Write-attribution context (vault#298) — the two axes of provenance threaded
|
|
@@ -61,20 +60,25 @@ function attrValue(v: string | null | undefined): string | null {
|
|
|
61
60
|
return t.length === 0 ? null : t;
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Generate a new note/attachment ID.
|
|
65
|
+
*
|
|
66
|
+
* As of vault#ulid-ids this returns a ULID (see `ulid.ts`) — monotonic,
|
|
67
|
+
* lexicographically time-sortable, Crockford base32, opaque, and
|
|
68
|
+
* collision-resistant. Previously this returned a timestamp-format ID
|
|
69
|
+
* (`YYYY-MM-DD-HH-MM-SS-ffffff`).
|
|
70
|
+
*
|
|
71
|
+
* IMPORTANT: existing notes are NOT migrated. Old timestamp-format IDs
|
|
72
|
+
* stay exactly as they are — only newly-generated IDs use the ULID
|
|
73
|
+
* format, so a vault's `id` column is (and must remain) a mix of both
|
|
74
|
+
* shapes indefinitely. Nothing may assume a uniform id format, and
|
|
75
|
+
* nothing may parse a note's ID to recover its creation time — that's
|
|
76
|
+
* what the `created_at` column is for. The cursor-pagination tiebreaker
|
|
77
|
+
* (`cursor.ts`) treats `id` as an opaque, stable string for ordering
|
|
78
|
+
* ties only, which holds for any id format.
|
|
79
|
+
*/
|
|
65
80
|
export function generateId(): string {
|
|
66
|
-
|
|
67
|
-
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
|
68
|
-
const micro = now.getMilliseconds() * 1000 + (idCounter++ % 1000);
|
|
69
|
-
return [
|
|
70
|
-
now.getFullYear(),
|
|
71
|
-
pad(now.getMonth() + 1),
|
|
72
|
-
pad(now.getDate()),
|
|
73
|
-
pad(now.getHours()),
|
|
74
|
-
pad(now.getMinutes()),
|
|
75
|
-
pad(now.getSeconds()),
|
|
76
|
-
pad(micro, 6),
|
|
77
|
-
].join("-");
|
|
81
|
+
return generateUlid();
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
export function createNote(
|
|
@@ -183,6 +187,66 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
|
|
|
183
187
|
);
|
|
184
188
|
}
|
|
185
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Extract a note's "H1 title" — the first line in `content` that starts
|
|
192
|
+
* with a literal `"# "` (a level-1 Markdown heading; `"## "`+ don't match
|
|
193
|
+
* because `^#[ \t]` fails at that line's start once a second `#` follows
|
|
194
|
+
* the first). Returns null when no such line exists, or the text after
|
|
195
|
+
* the marker is blank once trimmed.
|
|
196
|
+
*/
|
|
197
|
+
export function extractH1Title(content: string): string | null {
|
|
198
|
+
const match = content.match(/^#[ \t]+(.+)$/m);
|
|
199
|
+
if (!match) return null;
|
|
200
|
+
const title = match[1]!.trim();
|
|
201
|
+
return title.length > 0 ? title : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Find every note whose H1 title (see {@link extractH1Title}) equals
|
|
206
|
+
* `title`, case-insensitively (matches the COLLATE NOCASE policy every
|
|
207
|
+
* other path/basename lookup in this file uses). Used as the title-fallback
|
|
208
|
+
* step for wikilink/id/path resolution — a target that misses on
|
|
209
|
+
* id/path/basename gets one more chance against notes whose displayed
|
|
210
|
+
* title differs from their path.
|
|
211
|
+
*
|
|
212
|
+
* This is a full-content scan: no index exists on "first heading line," so
|
|
213
|
+
* every note's content is read once and matched in JS. Acceptable because
|
|
214
|
+
* every call site reaches this ONLY after the cheap indexed lookups (id,
|
|
215
|
+
* path, basename) have already missed — a rare fallback path, not the hot
|
|
216
|
+
* resolution route.
|
|
217
|
+
*/
|
|
218
|
+
export function findNotesByTitle(db: Database, title: string): { id: string; path: string | null }[] {
|
|
219
|
+
const needle = title.trim().toLowerCase();
|
|
220
|
+
if (!needle) return [];
|
|
221
|
+
const rows = db.prepare("SELECT id, path, content FROM notes").all() as {
|
|
222
|
+
id: string;
|
|
223
|
+
path: string | null;
|
|
224
|
+
content: string;
|
|
225
|
+
}[];
|
|
226
|
+
const matches: { id: string; path: string | null }[] = [];
|
|
227
|
+
for (const row of rows) {
|
|
228
|
+
const h1 = extractH1Title(row.content);
|
|
229
|
+
if (h1 && h1.toLowerCase() === needle) {
|
|
230
|
+
matches.push({ id: row.id, path: row.path });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return matches;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Title-fallback lookup: resolve `title` to a note ONLY when exactly one
|
|
238
|
+
* note in the vault carries that H1 title (see {@link findNotesByTitle}).
|
|
239
|
+
* Zero matches or 2+ matches (ambiguous) both return null — "don't guess"
|
|
240
|
+
* mirrors the existing basename-ambiguity policy (vault#328). Callers use
|
|
241
|
+
* this as the LAST resort after id and path/basename resolution have
|
|
242
|
+
* already missed; exact id/path matches always win first.
|
|
243
|
+
*/
|
|
244
|
+
export function getNoteByTitle(db: Database, title: string): Note | null {
|
|
245
|
+
const matches = findNotesByTitle(db, title);
|
|
246
|
+
if (matches.length !== 1) return null;
|
|
247
|
+
return getNote(db, matches[0]!.id);
|
|
248
|
+
}
|
|
249
|
+
|
|
186
250
|
export function getNotes(db: Database, ids: string[]): Note[] {
|
|
187
251
|
if (ids.length === 0) return [];
|
|
188
252
|
// Dedupe before chunking: a duplicate id straddling two chunk boundaries
|
|
@@ -769,8 +833,20 @@ function validateIsoDateValue(field: string, value: string): void {
|
|
|
769
833
|
}
|
|
770
834
|
}
|
|
771
835
|
|
|
772
|
-
|
|
773
|
-
|
|
836
|
+
/**
|
|
837
|
+
* Build the shared WHERE-clause conditions + bound params for the filter
|
|
838
|
+
* surface both `queryNotes` and `aggregateNotes` apply: tag membership
|
|
839
|
+
* (include/exclude/presence), link/broken-link presence, id-set scoping,
|
|
840
|
+
* exact path / path-prefix / extension, write-attribution, metadata
|
|
841
|
+
* (operator + plain-equality), and date range. Does NOT include the
|
|
842
|
+
* cursor-keyset predicate, ORDER BY, or LIMIT/OFFSET — those are
|
|
843
|
+
* query-shape concerns specific to `queryNotes`'s paginated-list contract
|
|
844
|
+
* and don't apply to an aggregate rollup (which spans every matching row,
|
|
845
|
+
* not a page of them). Extracted so aggregation reuses the EXACT filter
|
|
846
|
+
* semantics a normal query applies, rather than risking the two drifting
|
|
847
|
+
* apart.
|
|
848
|
+
*/
|
|
849
|
+
function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: string[]; params: SQLQueryBindings[] } {
|
|
774
850
|
const conditions: string[] = [];
|
|
775
851
|
const params: SQLQueryBindings[] = [];
|
|
776
852
|
|
|
@@ -851,6 +927,32 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
851
927
|
);
|
|
852
928
|
}
|
|
853
929
|
|
|
930
|
+
// Presence: has_broken_links (vault#555) — a dangling outbound wikilink or
|
|
931
|
+
// structured `links` target that never resolved. The `unresolved_wikilinks`
|
|
932
|
+
// table is created lazily (see wikilinks.ts:ensureUnresolvedTable) only when
|
|
933
|
+
// a link actually goes unresolved — a vault where nothing ever has won't
|
|
934
|
+
// have the table at all. Check existence first rather than reference it
|
|
935
|
+
// unconditionally: a read-only query filter shouldn't have the side effect
|
|
936
|
+
// of creating a table, and a bare `EXISTS`/`NOT EXISTS` against a missing
|
|
937
|
+
// table would throw "no such table" instead of the correct empty answer.
|
|
938
|
+
if (opts.hasBrokenLinks !== undefined) {
|
|
939
|
+
const unresolvedTableExists = db.prepare(
|
|
940
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'unresolved_wikilinks'",
|
|
941
|
+
).get() !== null;
|
|
942
|
+
if (!unresolvedTableExists) {
|
|
943
|
+
// No table yet means no note has ever had a broken link:
|
|
944
|
+
// hasBrokenLinks:true matches nothing; hasBrokenLinks:false is a
|
|
945
|
+
// no-op (every note already qualifies) — add no condition at all.
|
|
946
|
+
if (opts.hasBrokenLinks) conditions.push("0 = 1");
|
|
947
|
+
} else {
|
|
948
|
+
conditions.push(
|
|
949
|
+
opts.hasBrokenLinks
|
|
950
|
+
? `EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`
|
|
951
|
+
: `NOT EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`,
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
854
956
|
// ID set filter — used by `near` to push neighborhood scoping into SQL so
|
|
855
957
|
// that LIMIT applies to the neighborhood, not the whole notes table.
|
|
856
958
|
if (opts.ids !== undefined) {
|
|
@@ -1028,6 +1130,13 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1028
1130
|
}
|
|
1029
1131
|
}
|
|
1030
1132
|
|
|
1133
|
+
return { conditions, params };
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
1137
|
+
validateLimitOffset(opts);
|
|
1138
|
+
const { conditions, params } = buildFilterConditions(db, opts);
|
|
1139
|
+
|
|
1031
1140
|
// ---- Cursor predicate (vault#313) ----
|
|
1032
1141
|
//
|
|
1033
1142
|
// Cursor mode is keyed on PRESENCE of `opts.cursor`, not truthiness
|
|
@@ -1176,6 +1285,133 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
1176
1285
|
return fetchNotesByIdsOrdered(db, idRows.map((r) => r.id));
|
|
1177
1286
|
}
|
|
1178
1287
|
|
|
1288
|
+
/**
|
|
1289
|
+
* Aggregation / rollup query (top new-feature ask from a UX round — see
|
|
1290
|
+
* `QueryOpts.aggregate` / `AggregateSpec` in types.ts). Applies the SAME
|
|
1291
|
+
* filter surface `queryNotes` does — via the shared `buildFilterConditions`:
|
|
1292
|
+
* tags, exclude-tags, presence filters, `ids`, path/extension, write-
|
|
1293
|
+
* attribution, metadata, date range — BEFORE grouping, so aggregating over a
|
|
1294
|
+
* filtered query rolls up exactly the notes that query would have listed.
|
|
1295
|
+
*
|
|
1296
|
+
* Tag-scope is NOT enforced here — core stays scope-unaware, same division
|
|
1297
|
+
* every other core query surface keeps. A tag-scoped caller narrows via
|
|
1298
|
+
* `opts.ids` to a pre-computed visible-note-id set (see `src/mcp-tools.ts`'s
|
|
1299
|
+
* `aggregateVisibility` wiring for MCP and `src/routes.ts`'s REST aggregate
|
|
1300
|
+
* branch — both filter to visible notes FIRST, then pass the resulting ids
|
|
1301
|
+
* in here, exactly like a normal query's `near` neighborhood scoping does).
|
|
1302
|
+
*
|
|
1303
|
+
* `group_by: "tag"` groups by tag MEMBERSHIP, not partition: implemented as
|
|
1304
|
+
* a JOIN against `note_tags`, so a note carrying N of the tags present in
|
|
1305
|
+
* the filtered result set contributes to N separate group rows (the
|
|
1306
|
+
* existing filter conditions, which reference `n.*`, compose unchanged
|
|
1307
|
+
* against the join). Any other `group_by` value must be a declared
|
|
1308
|
+
* `indexed: true` metadata field — reuses `requireIndexedField`, the same
|
|
1309
|
+
* FIELD_NOT_INDEXED contract `metadata` operator queries and `order_by`
|
|
1310
|
+
* use — and groups by its generated `meta_<field>` column.
|
|
1311
|
+
*
|
|
1312
|
+
* `op: "sum"` requires `field` — another indexed field whose declared
|
|
1313
|
+
* storage type is `INTEGER` (the only indexable numeric shape; a bare
|
|
1314
|
+
* `type: "number"` schema field is never indexed — see
|
|
1315
|
+
* `indexed-fields.ts`'s `TYPE_MAP` — and a `TEXT`-backed field can't be
|
|
1316
|
+
* summed). `op: "count"` ignores `field`.
|
|
1317
|
+
*
|
|
1318
|
+
* A note whose group_by value is absent/null collects into one
|
|
1319
|
+
* `{group: null, ...}` row — standard SQL `GROUP BY` behavior, not silently
|
|
1320
|
+
* dropped.
|
|
1321
|
+
*/
|
|
1322
|
+
export function aggregateNotes(db: Database, opts: QueryOpts): AggregateRow[] {
|
|
1323
|
+
const spec = opts.aggregate;
|
|
1324
|
+
if (!spec) {
|
|
1325
|
+
throw new QueryError(
|
|
1326
|
+
`aggregateNotes requires opts.aggregate`,
|
|
1327
|
+
"INVALID_QUERY",
|
|
1328
|
+
{
|
|
1329
|
+
error_type: "invalid_query",
|
|
1330
|
+
field: "aggregate",
|
|
1331
|
+
hint: `pass { group_by, op } — group_by is an indexed metadata field or "tag"; op is "count" or "sum"`,
|
|
1332
|
+
},
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
if (typeof spec.group_by !== "string" || spec.group_by.length === 0) {
|
|
1336
|
+
throw new QueryError(
|
|
1337
|
+
`aggregate.group_by is required — an indexed metadata field name, or "tag"`,
|
|
1338
|
+
"INVALID_QUERY",
|
|
1339
|
+
{
|
|
1340
|
+
error_type: "invalid_query",
|
|
1341
|
+
field: "aggregate.group_by",
|
|
1342
|
+
got: spec.group_by,
|
|
1343
|
+
hint: `pass an indexed metadata field name, or "tag"`,
|
|
1344
|
+
},
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
if (spec.op !== "count" && spec.op !== "sum") {
|
|
1348
|
+
throw new QueryError(
|
|
1349
|
+
`invalid aggregate.op: ${JSON.stringify(spec.op)} — must be "count" or "sum"`,
|
|
1350
|
+
"INVALID_QUERY",
|
|
1351
|
+
{ error_type: "invalid_query", field: "aggregate.op", got: spec.op, hint: `pass "count" or "sum"` },
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
if (spec.op === "sum" && (typeof spec.field !== "string" || spec.field.length === 0)) {
|
|
1355
|
+
throw new QueryError(
|
|
1356
|
+
`aggregate.field is required when aggregate.op is "sum"`,
|
|
1357
|
+
"INVALID_QUERY",
|
|
1358
|
+
{
|
|
1359
|
+
error_type: "invalid_query",
|
|
1360
|
+
field: "aggregate.field",
|
|
1361
|
+
hint: "pass the indexed numeric metadata field to sum",
|
|
1362
|
+
},
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const { conditions, params } = buildFilterConditions(db, opts);
|
|
1367
|
+
|
|
1368
|
+
const groupByTag = spec.group_by === "tag";
|
|
1369
|
+
let groupExpr: string;
|
|
1370
|
+
let fromClause: string;
|
|
1371
|
+
if (groupByTag) {
|
|
1372
|
+
groupExpr = "nt.tag_name";
|
|
1373
|
+
fromClause = "FROM notes n JOIN note_tags nt ON nt.note_id = n.id";
|
|
1374
|
+
} else {
|
|
1375
|
+
// `group_by` came from indexed_fields (validated via FIELD_NAME_RE at
|
|
1376
|
+
// declaration time), so interpolating the column name is safe — same
|
|
1377
|
+
// justification `orderBy`/`buildOperatorClause` use.
|
|
1378
|
+
requireIndexedField(db, spec.group_by);
|
|
1379
|
+
groupExpr = `"meta_${spec.group_by}"`;
|
|
1380
|
+
fromClause = "FROM notes n";
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
let valueExpr: string;
|
|
1384
|
+
if (spec.op === "count") {
|
|
1385
|
+
valueExpr = "COUNT(*)";
|
|
1386
|
+
} else {
|
|
1387
|
+
const fieldInfo = requireIndexedField(db, spec.field!);
|
|
1388
|
+
if (fieldInfo.sqliteType !== "INTEGER") {
|
|
1389
|
+
throw new QueryError(
|
|
1390
|
+
`aggregate.field "${spec.field}" is not numeric (declared sqlite type ${fieldInfo.sqliteType}) — "sum" requires a field declared type: "integer" or "boolean" in its tag schema`,
|
|
1391
|
+
"INVALID_QUERY",
|
|
1392
|
+
{
|
|
1393
|
+
error_type: "invalid_query",
|
|
1394
|
+
field: "aggregate.field",
|
|
1395
|
+
got: spec.field,
|
|
1396
|
+
hint: `sum requires a numeric indexed field (type: "integer"/"boolean"); "${spec.field}" is declared a non-numeric type`,
|
|
1397
|
+
},
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
valueExpr = `SUM("meta_${spec.field}")`;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1404
|
+
const sql = `
|
|
1405
|
+
SELECT ${groupExpr} AS group_key, ${valueExpr} AS value
|
|
1406
|
+
${fromClause}
|
|
1407
|
+
${whereClause}
|
|
1408
|
+
GROUP BY ${groupExpr}
|
|
1409
|
+
ORDER BY ${groupExpr}
|
|
1410
|
+
`;
|
|
1411
|
+
const rows = db.prepare(sql).all(...params) as { group_key: string | number | null; value: number | null }[];
|
|
1412
|
+
return rows.map((r) => ({ group: r.group_key, value: r.value ?? 0 }));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1179
1415
|
/**
|
|
1180
1416
|
* Fetch full note rows for `ids`, preserving the input order, with tags
|
|
1181
1417
|
* hydrated via ONE batched query per chunk (not one per note). Ids not
|
|
@@ -1238,6 +1474,7 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
|
|
|
1238
1474
|
excludeTags: opts.excludeTags,
|
|
1239
1475
|
hasTags: opts.hasTags,
|
|
1240
1476
|
hasLinks: opts.hasLinks,
|
|
1477
|
+
hasBrokenLinks: opts.hasBrokenLinks,
|
|
1241
1478
|
path: opts.path,
|
|
1242
1479
|
pathPrefix: opts.pathPrefix,
|
|
1243
1480
|
extension: opts.extension,
|
|
@@ -2332,6 +2569,122 @@ export function getVaultStats(
|
|
|
2332
2569
|
};
|
|
2333
2570
|
}
|
|
2334
2571
|
|
|
2572
|
+
// ---- Vault map (front-door structural orientation) ----
|
|
2573
|
+
|
|
2574
|
+
/** Shared bucket-expression for the top-level path segment: the text before
|
|
2575
|
+
* the first `/`, or the whole path when it has none. Applied to whichever
|
|
2576
|
+
* `path` column reference the caller substitutes in (`path` or `n.path`). */
|
|
2577
|
+
function pathBucketExpr(pathCol: string): string {
|
|
2578
|
+
return `CASE WHEN instr(${pathCol}, '/') > 0 THEN substr(${pathCol}, 1, instr(${pathCol}, '/') - 1) ELSE ${pathCol} END`;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/**
|
|
2582
|
+
* Compute the compact structural map `vault-info` surfaces for one-call
|
|
2583
|
+
* orientation: total note count, every tag currently carried by at least one
|
|
2584
|
+
* note with its membership count, and every top-level path "bucket" (the
|
|
2585
|
+
* first `/`-delimited segment of `path`) with its note count — plus a count
|
|
2586
|
+
* of notes with no path at all (excluded from `path_buckets`, nothing to
|
|
2587
|
+
* bucket). Counts only, no content; three grouped-COUNT queries, safe on
|
|
2588
|
+
* large vaults.
|
|
2589
|
+
*
|
|
2590
|
+
* `opts.tagFilter`, when the KEY IS PRESENT, restricts every count to notes
|
|
2591
|
+
* reachable through that exact tag-name set (an `IN`-clause join through
|
|
2592
|
+
* `note_tags`) — the scope-aware path a tag-scoped caller's already-expanded
|
|
2593
|
+
* allowlist takes (server layer; core stays scope-unaware, it just filters
|
|
2594
|
+
* by the plain tag-name list it's given). An empty array is a valid,
|
|
2595
|
+
* meaningful filter — "nothing is in scope" — and short-circuits to an
|
|
2596
|
+
* all-zero map WITHOUT falling through to the unfiltered/full-vault query;
|
|
2597
|
+
* only OMITTING `tagFilter` entirely computes the vault-wide map.
|
|
2598
|
+
*/
|
|
2599
|
+
export function getVaultMap(
|
|
2600
|
+
db: Database,
|
|
2601
|
+
opts?: { tagFilter?: string[] },
|
|
2602
|
+
): VaultMap {
|
|
2603
|
+
const hasFilter = opts !== undefined && opts.tagFilter !== undefined;
|
|
2604
|
+
const tagFilter = opts?.tagFilter ?? [];
|
|
2605
|
+
|
|
2606
|
+
if (hasFilter && tagFilter.length === 0) {
|
|
2607
|
+
return { total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 };
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
if (hasFilter) {
|
|
2611
|
+
const placeholders = tagFilter.map(() => "?").join(",");
|
|
2612
|
+
|
|
2613
|
+
const totalRow = db
|
|
2614
|
+
.prepare(`SELECT COUNT(DISTINCT note_id) as c FROM note_tags WHERE tag_name IN (${placeholders})`)
|
|
2615
|
+
.get(...tagFilter) as { c: number };
|
|
2616
|
+
|
|
2617
|
+
const tagRows = db
|
|
2618
|
+
.prepare(
|
|
2619
|
+
`SELECT tag_name AS name, COUNT(*) AS count
|
|
2620
|
+
FROM note_tags
|
|
2621
|
+
WHERE tag_name IN (${placeholders})
|
|
2622
|
+
GROUP BY tag_name
|
|
2623
|
+
ORDER BY count DESC, name ASC`,
|
|
2624
|
+
)
|
|
2625
|
+
.all(...tagFilter) as { name: string; count: number }[];
|
|
2626
|
+
|
|
2627
|
+
const bucketRows = db
|
|
2628
|
+
.prepare(
|
|
2629
|
+
`SELECT ${pathBucketExpr("n.path")} AS name, COUNT(DISTINCT n.id) AS count
|
|
2630
|
+
FROM notes n
|
|
2631
|
+
JOIN note_tags nt ON nt.note_id = n.id
|
|
2632
|
+
WHERE nt.tag_name IN (${placeholders}) AND n.path IS NOT NULL
|
|
2633
|
+
GROUP BY name
|
|
2634
|
+
ORDER BY count DESC, name ASC`,
|
|
2635
|
+
)
|
|
2636
|
+
.all(...tagFilter) as { name: string; count: number }[];
|
|
2637
|
+
|
|
2638
|
+
const unfiledRow = db
|
|
2639
|
+
.prepare(
|
|
2640
|
+
`SELECT COUNT(DISTINCT n.id) as c
|
|
2641
|
+
FROM notes n
|
|
2642
|
+
JOIN note_tags nt ON nt.note_id = n.id
|
|
2643
|
+
WHERE nt.tag_name IN (${placeholders}) AND n.path IS NULL`,
|
|
2644
|
+
)
|
|
2645
|
+
.get(...tagFilter) as { c: number };
|
|
2646
|
+
|
|
2647
|
+
return {
|
|
2648
|
+
total_notes: totalRow.c,
|
|
2649
|
+
tags: tagRows,
|
|
2650
|
+
path_buckets: bucketRows,
|
|
2651
|
+
unfiled_notes: unfiledRow.c,
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
const totalRow = db.prepare("SELECT COUNT(*) as c FROM notes").get() as { c: number };
|
|
2656
|
+
|
|
2657
|
+
const tagRows = db
|
|
2658
|
+
.prepare(
|
|
2659
|
+
`SELECT tag_name AS name, COUNT(*) AS count
|
|
2660
|
+
FROM note_tags
|
|
2661
|
+
GROUP BY tag_name
|
|
2662
|
+
ORDER BY count DESC, name ASC`,
|
|
2663
|
+
)
|
|
2664
|
+
.all() as { name: string; count: number }[];
|
|
2665
|
+
|
|
2666
|
+
const bucketRows = db
|
|
2667
|
+
.prepare(
|
|
2668
|
+
`SELECT ${pathBucketExpr("path")} AS name, COUNT(*) AS count
|
|
2669
|
+
FROM notes
|
|
2670
|
+
WHERE path IS NOT NULL
|
|
2671
|
+
GROUP BY name
|
|
2672
|
+
ORDER BY count DESC, name ASC`,
|
|
2673
|
+
)
|
|
2674
|
+
.all() as { name: string; count: number }[];
|
|
2675
|
+
|
|
2676
|
+
const unfiledRow = db
|
|
2677
|
+
.prepare("SELECT COUNT(*) as c FROM notes WHERE path IS NULL")
|
|
2678
|
+
.get() as { c: number };
|
|
2679
|
+
|
|
2680
|
+
return {
|
|
2681
|
+
total_notes: totalRow.c,
|
|
2682
|
+
tags: tagRows,
|
|
2683
|
+
path_buckets: bucketRows,
|
|
2684
|
+
unfiled_notes: unfiledRow.c,
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2335
2688
|
// ---- Bulk Operations ----
|
|
2336
2689
|
|
|
2337
2690
|
export interface BulkNoteInput {
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type TagHierarchy,
|
|
35
35
|
} from "./tag-hierarchy.js";
|
|
36
36
|
import { chunkForInClause } from "./sql-in.js";
|
|
37
|
+
import { escapeFtsToken } from "./search-query.js";
|
|
37
38
|
|
|
38
39
|
export interface QueryWarning {
|
|
39
40
|
code: string;
|
|
@@ -231,13 +232,15 @@ export function ignoredParamWarning(param: string, reason: string): QueryWarning
|
|
|
231
232
|
* - the FTS5 vocabulary — a `notes_fts_vocab` `fts5vocab('row')` table
|
|
232
233
|
* created here LAZILY and BEST-EFFORT (not part of the schema/
|
|
233
234
|
* migration — see the try/catch below) — the terms actually indexed,
|
|
234
|
-
* POST-tokenization/stemming (porter).
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
235
|
+
* POST-tokenization/stemming (porter). The closest CANDIDATE is found
|
|
236
|
+
* against this stemmed vocabulary (small, deduped, cheap to
|
|
237
|
+
* range-scan), but a stemmed candidate is never returned verbatim —
|
|
238
|
+
* see `resolveSurfaceForm` below, which maps it back to a real word
|
|
239
|
+
* before it's handed to the caller (vault#570 — a raw stem like
|
|
240
|
+
* "propoli" or "cactu" isn't a word anyone would type).
|
|
239
241
|
* - tag names, when the caller passes `tagNames` (already cached by the
|
|
240
|
-
* caller's tag hierarchy — free to include
|
|
242
|
+
* caller's tag hierarchy — free to include, and already real words —
|
|
243
|
+
* no de-stemming needed).
|
|
241
244
|
*
|
|
242
245
|
* `fts5vocab` is registered by the same FTS5 extension init as `notes_fts`
|
|
243
246
|
* itself (not a separately-enabled module) so it's expected to be
|
|
@@ -263,6 +266,8 @@ export function computeSearchDidYouMean(
|
|
|
263
266
|
const tokens = rawQuery.trim().split(/\s+/).filter(Boolean);
|
|
264
267
|
if (tokens.length === 0) return undefined;
|
|
265
268
|
|
|
269
|
+
const tagNameSet = tagNames ? new Set(tagNames) : undefined;
|
|
270
|
+
|
|
266
271
|
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts_vocab USING fts5vocab(notes_fts, 'row')");
|
|
267
272
|
const exactStmt = db.prepare("SELECT 1 FROM notes_fts_vocab WHERE term = ? LIMIT 1");
|
|
268
273
|
const rangeStmt = db.prepare("SELECT term FROM notes_fts_vocab WHERE length(term) BETWEEN ? AND ?");
|
|
@@ -284,14 +289,19 @@ export function computeSearchDidYouMean(
|
|
|
284
289
|
const hi = clean.length + 2;
|
|
285
290
|
const rows = rangeStmt.all(lo, hi) as { term: string }[];
|
|
286
291
|
const candidates = new Set<string>(rows.map((r) => r.term));
|
|
287
|
-
if (
|
|
292
|
+
if (tagNameSet) for (const t of tagNameSet) candidates.add(t);
|
|
288
293
|
|
|
289
294
|
const suggestion = suggestSimilarTag(candidates, lower);
|
|
290
|
-
if (suggestion
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
+
if (!suggestion || suggestion.toLowerCase() === lower) return tok;
|
|
296
|
+
|
|
297
|
+
changed = true;
|
|
298
|
+
// A literal tag name is already a real word — return it verbatim. A
|
|
299
|
+
// vocabulary hit, though, came out of the PORTER-STEMMED index, so
|
|
300
|
+
// it's usually a truncated stem ("cactu" for "cactus") rather than a
|
|
301
|
+
// word a user would ever type (vault#570) — recover the real surface
|
|
302
|
+
// form before handing it back.
|
|
303
|
+
if (tagNameSet?.has(suggestion)) return suggestion;
|
|
304
|
+
return resolveSurfaceForm(db, suggestion) ?? suggestion;
|
|
295
305
|
});
|
|
296
306
|
|
|
297
307
|
if (!changed) return undefined;
|
|
@@ -301,6 +311,44 @@ export function computeSearchDidYouMean(
|
|
|
301
311
|
}
|
|
302
312
|
}
|
|
303
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Map a porter-stemmed vocabulary term ("cactu") back to the real
|
|
316
|
+
* dictionary word a note actually contains ("cactus") — vault#570 (issue
|
|
317
|
+
* #570's search polish item). `notes_fts` is porter-tokenized, so MATCHing
|
|
318
|
+
* the bare stem finds any note carrying a word that stems to it (the stem
|
|
319
|
+
* came FROM `notes_fts_vocab` in the first place, so at least one match is
|
|
320
|
+
* guaranteed). From there, tokenize that note's own path/content text —
|
|
321
|
+
* unstemmed, done here in JS with the same punctuation-stripping rule used
|
|
322
|
+
* above — and return the first token that literally STARTS WITH the stem.
|
|
323
|
+
* This works because Porter's rules are almost entirely suffix-stripping:
|
|
324
|
+
* the stem is near-always a prefix of the original word. Irregular cases
|
|
325
|
+
* (a stem that isn't a literal prefix) are a known, accepted limitation —
|
|
326
|
+
* same class as the porter tokenizer's irregular-plural gap documented in
|
|
327
|
+
* docs/HTTP_API.md — and simply fall back to the raw stem via the caller's
|
|
328
|
+
* `?? suggestion`, never worse than the pre-fix behavior.
|
|
329
|
+
*
|
|
330
|
+
* Bounded to a handful of rows (`LIMIT 5`) and wrapped in try/catch same as
|
|
331
|
+
* the caller — a nicety, never worth risking the search response itself.
|
|
332
|
+
*/
|
|
333
|
+
function resolveSurfaceForm(db: Database, stem: string): string | undefined {
|
|
334
|
+
try {
|
|
335
|
+
const rows = db
|
|
336
|
+
.prepare("SELECT path, content FROM notes_fts WHERE notes_fts MATCH ? LIMIT 5")
|
|
337
|
+
.all(escapeFtsToken(stem)) as { path: string | null; content: string | null }[];
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
const text = `${row.path ?? ""} ${row.content ?? ""}`;
|
|
340
|
+
for (const raw of text.split(/[^\p{L}\p{N}]+/gu)) {
|
|
341
|
+
if (!raw) continue;
|
|
342
|
+
const lower = raw.toLowerCase();
|
|
343
|
+
if (lower.length > stem.length && lower.startsWith(stem)) return lower;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return undefined;
|
|
347
|
+
} catch {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
304
352
|
/**
|
|
305
353
|
* `search_did_you_mean` warning (vault#551 WS2B) — wraps
|
|
306
354
|
* {@link computeSearchDidYouMean}'s suggestion in the standard warning
|
|
@@ -57,7 +57,7 @@ export interface SchemaField {
|
|
|
57
57
|
* `type_mismatch` warning that previously fired on every integer-shaped
|
|
58
58
|
* field because the validator had no `"integer"` case. See vault#310.
|
|
59
59
|
*/
|
|
60
|
-
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
|
60
|
+
type?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "reference";
|
|
61
61
|
enum?: string[];
|
|
62
62
|
description?: string;
|
|
63
63
|
/**
|
|
@@ -369,6 +369,12 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
|
369
369
|
return Array.isArray(value);
|
|
370
370
|
case "object":
|
|
371
371
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
372
|
+
// `reference` (vault#typed-reference-field) validates like `string` —
|
|
373
|
+
// the write path (core/src/store.ts) separately resolves this value to
|
|
374
|
+
// a note and maintains a graph link; see tag-schemas.ts's
|
|
375
|
+
// `VALID_FIELD_TYPES` doc comment for the full contract.
|
|
376
|
+
case "reference":
|
|
377
|
+
return typeof value === "string";
|
|
372
378
|
}
|
|
373
379
|
}
|
|
374
380
|
|
|
@@ -291,6 +291,20 @@ describe("getting-started pack", () => {
|
|
|
291
291
|
expect(GETTING_STARTED_CONTENT).toContain("over MCP");
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
+
test("Getting Started carries the journaling / agent-memory / search conventions (vault#555)", () => {
|
|
295
|
+
expect(GETTING_STARTED_CONTENT).toContain("## A few shapes worth reusing");
|
|
296
|
+
// Journaling: indexed entry_date + mood enum.
|
|
297
|
+
expect(GETTING_STARTED_CONTENT).toContain("entry_date");
|
|
298
|
+
expect(GETTING_STARTED_CONTENT).toContain("mood");
|
|
299
|
+
// Agent-memory pattern: thread + messages + the if_exists:"ignore" crash-replay recipe.
|
|
300
|
+
expect(GETTING_STARTED_CONTENT).toContain("#thread");
|
|
301
|
+
expect(GETTING_STARTED_CONTENT).toContain("#message");
|
|
302
|
+
expect(GETTING_STARTED_CONTENT).toContain('if_exists: "ignore"');
|
|
303
|
+
// Search one-liner: literal by default, search_mode:"advanced" for FTS syntax.
|
|
304
|
+
expect(GETTING_STARTED_CONTENT).toContain("literal by default");
|
|
305
|
+
expect(GETTING_STARTED_CONTENT).toContain('search_mode: "advanced"');
|
|
306
|
+
});
|
|
307
|
+
|
|
294
308
|
test("default vault-description constants orient + tell the AI to self-replace them", () => {
|
|
295
309
|
for (const d of [DEFAULT_VAULT_DESCRIPTION, IMPORTED_VAULT_DESCRIPTION]) {
|
|
296
310
|
expect(d.length).toBeGreaterThan(100);
|
package/core/src/seed-packs.ts
CHANGED
|
@@ -392,10 +392,14 @@ description) so the next one picks up where you left off.
|
|
|
392
392
|
### If this vault already has content
|
|
393
393
|
|
|
394
394
|
Some vaults arrive full — an import, a restore, months of use. Then your first
|
|
395
|
-
job is to **learn it, not seed it**: \`vault-info
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
only
|
|
395
|
+
job is to **learn it, not seed it**: \`vault-info\` — a single call now returns
|
|
396
|
+
a compact \`map\` (every tag's note count, every top-level path's note count,
|
|
397
|
+
total notes) alongside the schema catalog, so you get the shape of a strange
|
|
398
|
+
vault in one round trip; add \`include_stats: true\` only if you also want the
|
|
399
|
+
deeper monthly distribution. Then \`list-tags\` and read a sample
|
|
400
|
+
(\`query-notes { search: "..." }\`), and reflect the shape back to the person
|
|
401
|
+
and ask what's missing or wrong. Propose structure only once you can describe
|
|
402
|
+
what's already there.
|
|
399
403
|
|
|
400
404
|
### Close the loop: describe the vault
|
|
401
405
|
|
|
@@ -424,7 +428,9 @@ Core moves you already have as MCP tools:
|
|
|
424
428
|
- \`query-notes\` — by id/path, by tag, full-text \`search\`, or graph \`near\` a note.
|
|
425
429
|
- \`list-tags\` / \`update-tag\` / \`delete-tag\` — manage the tag vocabulary + schemas.
|
|
426
430
|
- \`find-path\` — shortest link path between two notes.
|
|
427
|
-
- \`vault-info\` — refresh the live schema
|
|
431
|
+
- \`vault-info\` — refresh the live schema projection any time; the base call
|
|
432
|
+
includes a compact \`map\` (tag counts, top-level path-bucket counts, total
|
|
433
|
+
notes) so you can orient in this one call — no flag needed.
|
|
428
434
|
|
|
429
435
|
\`[[wikilinks]]\` in note content auto-link to the note at that path — use them
|
|
430
436
|
freely; they resolve even if the target is created later.
|
|
@@ -481,6 +487,37 @@ support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
|
|
|
481
487
|
\`order_by\`; all tags declaring the same field must agree on its \`type\` and
|
|
482
488
|
\`indexed\` flag.
|
|
483
489
|
|
|
490
|
+
## A few shapes worth reusing
|
|
491
|
+
|
|
492
|
+
Testers of this vault independently reinvented these — start from them instead:
|
|
493
|
+
|
|
494
|
+
- **Journaling.** One tag (\`#journal\`) with an indexed \`entry_date\`
|
|
495
|
+
(\`{ type: "string", indexed: true }\`, ISO date) and a \`mood\` enum
|
|
496
|
+
(\`{ type: "string", enum: [...] }\`). Indexing \`entry_date\` (see "Only
|
|
497
|
+
\`indexed: true\` fields support operator queries" above) is what makes
|
|
498
|
+
date-range queries (\`metadata: { entry_date: { gte: "2026-01-01" } }\`) and
|
|
499
|
+
\`order_by: "entry_date"\` work.
|
|
500
|
+
- **Agent memory (thread + messages, crash-replay-safe).** Model a
|
|
501
|
+
conversation as one \`#thread\` note (metadata: \`status\`, \`cursor\`) plus many
|
|
502
|
+
\`#message\` notes linked to it (\`relationship: "in-thread"\`), each carrying
|
|
503
|
+
its own \`status\`. If your write loop can crash and replay the same message,
|
|
504
|
+
give it a stable \`path\` (e.g. \`Threads/<thread-id>/msg-<n>\`) and create it
|
|
505
|
+
with \`if_exists: "ignore"\` — a retry after a crash safely returns the
|
|
506
|
+
already-written message instead of creating a duplicate, no
|
|
507
|
+
query-before-write round trip needed.
|
|
508
|
+
- **Search.** \`query-notes { search: "..." }\` is literal by default — your
|
|
509
|
+
text is escaped, not parsed as FTS5 syntax, so punctuation like "didn't" or
|
|
510
|
+
"18.6" just works. Pass \`search_mode: "advanced"\` only when you actually
|
|
511
|
+
want FTS5 boolean/phrase/prefix syntax.
|
|
512
|
+
- **A front-door "Map" note.** Once a vault grows past a few dozen notes,
|
|
513
|
+
keep one note (path \`Map\`, or whatever fits) as a human-legible index —
|
|
514
|
+
the tags and top-level paths that matter and what each means, with
|
|
515
|
+
wikilinks into anchor notes. It's the "why" companion to \`vault-info\`'s
|
|
516
|
+
auto-computed \`map\` (tag/path-bucket counts, the "what's there"): the
|
|
517
|
+
counts tell a fresh AI the shape of the vault in one call, this note tells
|
|
518
|
+
it what that shape MEANS. Update it when the vault's structure shifts;
|
|
519
|
+
link to it from the vault description so it's easy to find.
|
|
520
|
+
|
|
484
521
|
## Write gotchas
|
|
485
522
|
|
|
486
523
|
A few behaviors worth knowing before you write at scale:
|