seekstone 0.11.1 → 0.12.0

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/dist/index.js CHANGED
@@ -238,15 +238,6 @@ async function appendNote(ctx2, input) {
238
238
  };
239
239
  }
240
240
 
241
- // src/tools/create_note.ts
242
- import { access, mkdir, readFile as readFile2 } from "fs/promises";
243
- import { dirname, join as join2 } from "path";
244
- import { stringify as stringifyYaml } from "yaml";
245
- import { z as z2 } from "zod";
246
-
247
- // src/index/doc.ts
248
- import { basename, extname } from "path";
249
-
250
241
  // ../core/src/extract.ts
251
242
  var LINK_WITH_LINES_RE = /(!?\[\[)([^\]|#\n]{1,512})(#[^\]|\n]{1,512})?(\|[^\]\n]{1,512})?(\]\])/g;
252
243
  function extractLinksWithLines(raw) {
@@ -288,7 +279,298 @@ function frontmatterTags(fm) {
288
279
  return [];
289
280
  }
290
281
 
282
+ // src/tools/context_pack.ts
283
+ import { z as z3 } from "zod";
284
+
285
+ // src/index/excerpt.ts
286
+ function extractExcerpt(text, terms, maxLen = 200) {
287
+ const lower = text.toLowerCase();
288
+ let bestIdx = -1;
289
+ for (const term of terms) {
290
+ const idx = lower.indexOf(term.toLowerCase());
291
+ if (idx !== -1 && (bestIdx === -1 || idx < bestIdx)) {
292
+ bestIdx = idx;
293
+ }
294
+ }
295
+ if (bestIdx === -1) {
296
+ return truncate(text.trimStart(), maxLen);
297
+ }
298
+ const half = Math.floor(maxLen / 2);
299
+ const start = Math.max(0, bestIdx - half);
300
+ const end = Math.min(text.length, bestIdx + half + (terms[0]?.length ?? 0));
301
+ const slice = text.slice(start, end).trim();
302
+ return `${start > 0 ? "\u2026" : ""}${slice}${end < text.length ? "\u2026" : ""}`;
303
+ }
304
+ function truncate(s, max) {
305
+ if (s.length <= max) return s;
306
+ return `${s.slice(0, max).trimEnd()}\u2026`;
307
+ }
308
+
309
+ // src/index/resolve.ts
310
+ function resolveLink(target, notes2) {
311
+ if (notes2.has(target)) return target;
312
+ const withMd = `${target}.md`;
313
+ if (notes2.has(withMd)) return withMd;
314
+ const targetLower = target.toLowerCase();
315
+ const targetBase = (target.split("/").pop() ?? target).toLowerCase();
316
+ for (const path2 of notes2.keys()) {
317
+ const pathNoExt = path2.replace(/\.md$/i, "");
318
+ if (pathNoExt.toLowerCase() === targetLower) return path2;
319
+ const pathBase = (pathNoExt.split("/").pop() ?? pathNoExt).toLowerCase();
320
+ if (pathBase === targetBase) return path2;
321
+ }
322
+ return void 0;
323
+ }
324
+
325
+ // src/tools/search.ts
326
+ import { z as z2 } from "zod";
327
+ var SearchInput = z2.object({
328
+ query: z2.string().min(1).describe("Search query. Supports fuzzy matching and prefix search."),
329
+ limit: z2.number().int().min(1).max(50).default(10).describe("Maximum number of results to return."),
330
+ folder: z2.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
331
+ tag: z2.string().optional().describe("Restrict results to notes containing this tag."),
332
+ excerptLength: z2.number().int().min(20).max(2e3).optional().describe(
333
+ "Max characters of match context per hit (default 120). Lower trims payload; higher gives more context."
334
+ )
335
+ });
336
+ function basenameNoExt(path2) {
337
+ const base = path2.slice(path2.lastIndexOf("/") + 1);
338
+ const dot = base.lastIndexOf(".");
339
+ return dot > 0 ? base.slice(0, dot) : base;
340
+ }
341
+ function search(ctx2, input) {
342
+ const results = ctx2.index.search(input.query, {
343
+ boost: { title: 3, tags: 2, body: 1 },
344
+ fuzzy: 0.2,
345
+ prefix: true
346
+ });
347
+ const terms = input.query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
348
+ const hits = [];
349
+ for (const r of results) {
350
+ if (input.folder && !r.id.startsWith(input.folder)) continue;
351
+ if (input.tag) {
352
+ const note2 = ctx2.notes.get(r.id);
353
+ if (!note2) continue;
354
+ const noteTags2 = note2.tags.split(" ");
355
+ if (!noteTags2.some((t) => t === input.tag || t === input.tag?.replace(/^#/, ""))) continue;
356
+ }
357
+ const note = ctx2.notes.get(r.id);
358
+ const body = note?.body ?? "";
359
+ const noteTags = note?.tags ? note.tags.split(" ").filter(Boolean) : [];
360
+ const title = r.title;
361
+ const hit = {
362
+ path: r.id,
363
+ // Round the raw MiniSearch score: 2 decimals preserve relevance gaps without 17-digit float tax.
364
+ score: Math.round(r.score * 100) / 100,
365
+ excerpt: extractExcerpt(body, terms, input.excerptLength ?? 120)
366
+ };
367
+ if (title && title !== basenameNoExt(r.id)) hit.title = title;
368
+ if (noteTags.length > 0) hit.tags = noteTags;
369
+ hits.push(hit);
370
+ if (hits.length >= input.limit) break;
371
+ }
372
+ return hits;
373
+ }
374
+
375
+ // src/tools/context_pack.ts
376
+ var ContextPackInput = z3.object({
377
+ query: z3.string().min(1).describe("Natural-language question or topic to assemble context for."),
378
+ budgetBytes: z3.number().int().min(256).max(16384).default(2048).describe("Hard cap on the response JSON size in bytes. The pack never exceeds it.")
379
+ });
380
+ var MAX_CANDIDATES = 10;
381
+ var NEIGHBOR_HITS = 3;
382
+ var MAX_NEIGHBORS = 5;
383
+ var NEIGHBOR_SUMMARY_LEN = 100;
384
+ var MIN_EXCERPT_LEN = 60;
385
+ var FM_MAX_BYTES = 120;
386
+ var SOURCES_MAX = 8;
387
+ function entryCost(entry) {
388
+ return Buffer.byteLength(JSON.stringify(entry), "utf8") + 1;
389
+ }
390
+ function pickFm(fm) {
391
+ if (!fm) return void 0;
392
+ const out = {};
393
+ let picked = 0;
394
+ for (const key of Object.keys(fm)) {
395
+ const v = fm[key];
396
+ const t = typeof v;
397
+ if (t !== "string" && t !== "number" && t !== "boolean") continue;
398
+ const candidate = { ...out, [key]: v };
399
+ if (Buffer.byteLength(JSON.stringify(candidate), "utf8") > FM_MAX_BYTES) break;
400
+ out[key] = v;
401
+ picked += 1;
402
+ }
403
+ return picked > 0 ? out : void 0;
404
+ }
405
+ function clamp(n, lo, hi) {
406
+ return Math.min(hi, Math.max(lo, n));
407
+ }
408
+ function contextPack(ctx2, input) {
409
+ const results = ctx2.index.search(input.query, {
410
+ boost: { title: 3, tags: 2, body: 1 },
411
+ fuzzy: 0.2,
412
+ prefix: true
413
+ });
414
+ const totalMatches = results.length;
415
+ if (totalMatches === 0) {
416
+ return { excerpts: [], neighborhood: [], sources: [], totalMatches: 0, confidence: "none" };
417
+ }
418
+ const terms = input.query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
419
+ const scoreByPath = /* @__PURE__ */ new Map();
420
+ for (const r of results) scoreByPath.set(r.id, r.score);
421
+ const budget = input.budgetBytes;
422
+ let used = Buffer.byteLength(
423
+ JSON.stringify({
424
+ excerpts: [],
425
+ neighborhood: [],
426
+ sources: [],
427
+ totalMatches,
428
+ confidence: "high",
429
+ truncated: true
430
+ }),
431
+ "utf8"
432
+ );
433
+ const excerptLen = clamp(Math.floor(budget / 10), 80, 400);
434
+ const reserve = Math.min(Math.floor(budget / 4), 600);
435
+ const candidates = results.slice(0, MAX_CANDIDATES);
436
+ const excerpts = [];
437
+ const overflowHits = [];
438
+ let droppedForBudget = false;
439
+ const buildEntry = (r, len) => {
440
+ const note = ctx2.notes.get(r.id);
441
+ const body = note?.body ?? "";
442
+ const noteTags = note?.tags ? note.tags.split(" ").filter(Boolean) : [];
443
+ const entry = {
444
+ path: r.id,
445
+ score: Math.round(r.score * 100) / 100,
446
+ excerpt: extractExcerpt(body, terms, len)
447
+ };
448
+ const title = r.title;
449
+ if (title && title !== basenameNoExt(r.id)) entry.title = title;
450
+ if (noteTags.length > 0) entry.tags = noteTags;
451
+ const fm = pickFm(note?.fm ?? null);
452
+ if (fm) entry.fm = fm;
453
+ return entry;
454
+ };
455
+ const excerptCap = budget - reserve;
456
+ let phaseOpen = true;
457
+ for (const r of candidates) {
458
+ if (!phaseOpen) {
459
+ overflowHits.push({ path: r.id });
460
+ continue;
461
+ }
462
+ let placed = false;
463
+ const shrinkLens = [
464
+ .../* @__PURE__ */ new Set([
465
+ excerptLen,
466
+ Math.max(Math.floor(excerptLen / 2), MIN_EXCERPT_LEN),
467
+ MIN_EXCERPT_LEN
468
+ ])
469
+ ];
470
+ for (const len of shrinkLens) {
471
+ const entry = buildEntry(r, len);
472
+ const cost = entryCost(entry);
473
+ if (used + cost <= excerptCap) {
474
+ excerpts.push(entry);
475
+ used += cost;
476
+ placed = true;
477
+ if (len !== excerptLen) phaseOpen = false;
478
+ break;
479
+ }
480
+ }
481
+ if (!placed) {
482
+ phaseOpen = false;
483
+ droppedForBudget = true;
484
+ overflowHits.push({ path: r.id });
485
+ }
486
+ }
487
+ const includedPaths = new Set(excerpts.map((e) => e.path));
488
+ const seeds = excerpts.slice(0, NEIGHBOR_HITS).map((e) => e.path);
489
+ const agg = /* @__PURE__ */ new Map();
490
+ const connect = (neighborPath, seed, rel, line) => {
491
+ if (neighborPath === seed || includedPaths.has(neighborPath)) return;
492
+ if (!ctx2.notes.has(neighborPath)) return;
493
+ let entry = agg.get(neighborPath);
494
+ if (!entry) {
495
+ entry = { rels: /* @__PURE__ */ new Set(), seedPaths: /* @__PURE__ */ new Set() };
496
+ agg.set(neighborPath, entry);
497
+ }
498
+ entry.rels.add(rel);
499
+ entry.seedPaths.add(seed);
500
+ if (rel === "backlink" && entry.line === void 0 && line !== void 0) entry.line = line;
501
+ };
502
+ for (const seed of seeds) {
503
+ for (const ref of ctx2.backlinks.get(seed) ?? []) {
504
+ connect(ref.path, seed, "backlink", ref.line);
505
+ }
506
+ const note = ctx2.notes.get(seed);
507
+ if (!note) continue;
508
+ for (const link of extractLinksWithLines(note.raw)) {
509
+ const target = resolveLink(link.target, ctx2.notes);
510
+ if (target !== void 0) connect(target, seed, "outlink");
511
+ }
512
+ }
513
+ const ranked = [...agg.entries()].sort((a, b) => {
514
+ const conn = b[1].seedPaths.size - a[1].seedPaths.size;
515
+ if (conn !== 0) return conn;
516
+ const score = (scoreByPath.get(b[0]) ?? 0) - (scoreByPath.get(a[0]) ?? 0);
517
+ if (score !== 0) return score;
518
+ return a[0] < b[0] ? -1 : 1;
519
+ });
520
+ const neighborhood = [];
521
+ const overflowNeighbors = [];
522
+ for (const [path2, info] of ranked) {
523
+ if (neighborhood.length >= MAX_NEIGHBORS) {
524
+ overflowNeighbors.push(info.line !== void 0 ? { path: path2, line: info.line } : { path: path2 });
525
+ continue;
526
+ }
527
+ const note = ctx2.notes.get(path2);
528
+ const entry = {
529
+ path: path2,
530
+ rel: info.rels.size === 2 ? "both" : info.rels.has("backlink") ? "backlink" : "outlink",
531
+ summary: extractExcerpt(note?.body ?? "", terms, NEIGHBOR_SUMMARY_LEN)
532
+ };
533
+ if (info.line !== void 0) entry.line = info.line;
534
+ const cost = entryCost(entry);
535
+ if (used + cost > budget) {
536
+ droppedForBudget = true;
537
+ overflowNeighbors.push(info.line !== void 0 ? { path: path2, line: info.line } : { path: path2 });
538
+ for (const [p, i] of ranked.slice(ranked.findIndex(([rp]) => rp === path2) + 1)) {
539
+ overflowNeighbors.push(i.line !== void 0 ? { path: p, line: i.line } : { path: p });
540
+ }
541
+ break;
542
+ }
543
+ neighborhood.push(entry);
544
+ used += cost;
545
+ }
546
+ const sources = [];
547
+ const sourcePaths = /* @__PURE__ */ new Set();
548
+ for (const ref of [...overflowHits, ...overflowNeighbors]) {
549
+ if (sources.length >= SOURCES_MAX) break;
550
+ if (includedPaths.has(ref.path) || sourcePaths.has(ref.path)) continue;
551
+ const cost = entryCost(ref);
552
+ if (used + cost > budget) break;
553
+ sources.push(ref);
554
+ sourcePaths.add(ref.path);
555
+ used += cost;
556
+ }
557
+ const hasTermInExcerpt = excerpts.some(
558
+ (e) => terms.some((t) => e.excerpt.toLowerCase().includes(t))
559
+ );
560
+ const confidence = excerpts.length === 0 || terms.length > 0 && !hasTermInExcerpt ? "low" : "high";
561
+ const result = { excerpts, neighborhood, sources, totalMatches, confidence };
562
+ if (droppedForBudget) result.truncated = true;
563
+ return result;
564
+ }
565
+
566
+ // src/tools/create_note.ts
567
+ import { access, mkdir, readFile as readFile2 } from "fs/promises";
568
+ import { dirname, join as join2 } from "path";
569
+ import { stringify as stringifyYaml } from "yaml";
570
+ import { z as z4 } from "zod";
571
+
291
572
  // src/index/doc.ts
573
+ import { basename, extname } from "path";
292
574
  function buildDoc(relPath, raw) {
293
575
  const fm = parseFrontmatter(raw);
294
576
  const allTags = [.../* @__PURE__ */ new Set([...extractInlineTags(fm.body), ...frontmatterTags(fm.data)])];
@@ -312,16 +594,16 @@ function upsertDoc(ctx2, doc) {
312
594
  }
313
595
 
314
596
  // src/tools/create_note.ts
315
- var CreateNoteInput = z2.object({
316
- path: z2.string().describe(
597
+ var CreateNoteInput = z4.object({
598
+ path: z4.string().describe(
317
599
  'Vault-relative path for the new note, e.g. "Daily Notes/2026-06-01.md". Parent directories are created automatically.'
318
600
  ),
319
- content: z2.string().default("").describe("Body content for the note."),
320
- frontmatter: z2.record(z2.string(), z2.unknown()).optional().describe("Frontmatter key-value pairs to write as a YAML block at the top of the note."),
321
- overwrite: z2.boolean().default(false).describe(
601
+ content: z4.string().default("").describe("Body content for the note."),
602
+ frontmatter: z4.record(z4.string(), z4.unknown()).optional().describe("Frontmatter key-value pairs to write as a YAML block at the top of the note."),
603
+ overwrite: z4.boolean().default(false).describe(
322
604
  "Overwrite an existing note. Defaults to false \u2014 throws if the note already exists."
323
605
  ),
324
- prevHash: z2.string().optional().describe(
606
+ prevHash: z4.string().optional().describe(
325
607
  "Optional compare-and-swap guard for overwrite: true \u2014 the contentHash of the note being replaced. Fails with hash_conflict if it changed since it was read."
326
608
  )
327
609
  }).refine((v) => v.prevHash === void 0 || v.overwrite === true, {
@@ -378,23 +660,7 @@ ${stringifyYaml(input.frontmatter)}---
378
660
  // src/tools/delete_note.ts
379
661
  import { access as access2, mkdir as mkdir2, rename as rename2, rm } from "fs/promises";
380
662
  import { basename as basename2, extname as extname2, join as join3 } from "path";
381
- import { z as z3 } from "zod";
382
-
383
- // src/index/resolve.ts
384
- function resolveLink(target, notes2) {
385
- if (notes2.has(target)) return target;
386
- const withMd = `${target}.md`;
387
- if (notes2.has(withMd)) return withMd;
388
- const targetLower = target.toLowerCase();
389
- const targetBase = (target.split("/").pop() ?? target).toLowerCase();
390
- for (const path2 of notes2.keys()) {
391
- const pathNoExt = path2.replace(/\.md$/i, "");
392
- if (pathNoExt.toLowerCase() === targetLower) return path2;
393
- const pathBase = (pathNoExt.split("/").pop() ?? pathNoExt).toLowerCase();
394
- if (pathBase === targetBase) return path2;
395
- }
396
- return void 0;
397
- }
663
+ import { z as z5 } from "zod";
398
664
 
399
665
  // src/index/backlinks.ts
400
666
  function removeNoteBacklinks(ctx2, relPath) {
@@ -429,9 +695,9 @@ function addNoteBacklinks(ctx2, relPath, raw) {
429
695
  }
430
696
 
431
697
  // src/tools/delete_note.ts
432
- var DeleteNoteInput = z3.object({
433
- path: z3.string().describe("Vault-relative path of the note to delete."),
434
- permanent: z3.boolean().default(false).describe(
698
+ var DeleteNoteInput = z5.object({
699
+ path: z5.string().describe("Vault-relative path of the note to delete."),
700
+ permanent: z5.boolean().default(false).describe(
435
701
  "Permanently remove the note instead of moving it to the vault .trash/ folder. Defaults to false \u2014 deletes are recoverable."
436
702
  )
437
703
  });
@@ -476,11 +742,11 @@ async function deleteNote(ctx2, rawInput) {
476
742
 
477
743
  // src/tools/get_backlinks.ts
478
744
  import { join as join4 } from "path";
479
- import { z as z4 } from "zod";
480
- var GetBacklinksInput = z4.object({
481
- path: z4.string().describe("Vault-relative path to the target note."),
482
- includeContext: z4.boolean().optional().describe("Include a short excerpt (~200 chars) from the linking line. Default true."),
483
- limit: z4.number().int().min(1).max(500).optional().describe("Max backlinks to return. Default 50.")
745
+ import { z as z6 } from "zod";
746
+ var GetBacklinksInput = z6.object({
747
+ path: z6.string().describe("Vault-relative path to the target note."),
748
+ includeContext: z6.boolean().optional().describe("Include a short excerpt (~200 chars) from the linking line. Default true."),
749
+ limit: z6.number().int().min(1).max(500).optional().describe("Max backlinks to return. Default 50.")
484
750
  });
485
751
  var EXCERPT_MAX = 200;
486
752
  function getBacklinks(ctx2, input) {
@@ -513,9 +779,9 @@ function getBacklinks(ctx2, input) {
513
779
 
514
780
  // src/tools/get_links.ts
515
781
  import { join as join5 } from "path";
516
- import { z as z5 } from "zod";
517
- var GetLinksInput = z5.object({
518
- path: z5.string().describe("Vault-relative path to the note.")
782
+ import { z as z7 } from "zod";
783
+ var GetLinksInput = z7.object({
784
+ path: z7.string().describe("Vault-relative path to the note.")
519
785
  });
520
786
  function getLinks(ctx2, input) {
521
787
  const absPath = join5(ctx2.vaultRoot, input.path);
@@ -545,13 +811,13 @@ function getLinks(ctx2, input) {
545
811
  }
546
812
 
547
813
  // src/tools/list_notes.ts
548
- import { z as z6 } from "zod";
549
- var ListNotesInput = z6.object({
550
- folder: z6.string().optional().describe(
814
+ import { z as z8 } from "zod";
815
+ var ListNotesInput = z8.object({
816
+ folder: z8.string().optional().describe(
551
817
  'Vault-relative folder prefix to list. Omit for all notes. Example: "Daily Notes/2026".'
552
818
  ),
553
- tag: z6.string().optional().describe("Filter by tag. Include or omit the leading #."),
554
- limit: z6.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
819
+ tag: z8.string().optional().describe("Filter by tag. Include or omit the leading #."),
820
+ limit: z8.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
555
821
  });
556
822
  function listNotes(ctx2, input) {
557
823
  const tag = input.tag?.replace(/^#/, "");
@@ -574,11 +840,11 @@ function listNotes(ctx2, input) {
574
840
  }
575
841
 
576
842
  // src/tools/list_tags.ts
577
- import { z as z7 } from "zod";
578
- var ListTagsInput = z7.object({
579
- pattern: z7.string().optional().describe('Substring filter on tag name. Example: "work" matches "work" and "area/work".'),
580
- minCount: z7.number().int().min(1).optional().describe("Only return tags used in at least this many notes."),
581
- sort: z7.enum(["count", "name"]).default("count").describe("Sort by usage count descending (default) or alphabetically.")
843
+ import { z as z9 } from "zod";
844
+ var ListTagsInput = z9.object({
845
+ pattern: z9.string().optional().describe('Substring filter on tag name. Example: "work" matches "work" and "area/work".'),
846
+ minCount: z9.number().int().min(1).optional().describe("Only return tags used in at least this many notes."),
847
+ sort: z9.enum(["count", "name"]).default("count").describe("Sort by usage count descending (default) or alphabetically.")
582
848
  });
583
849
  function listTags(ctx2, input) {
584
850
  const counts = /* @__PURE__ */ new Map();
@@ -607,7 +873,7 @@ function listTags(ctx2, input) {
607
873
  // src/tools/move_note.ts
608
874
  import { access as access3, mkdir as mkdir3, rename as rename3 } from "fs/promises";
609
875
  import { dirname as dirname2, join as join6 } from "path";
610
- import { z as z8 } from "zod";
876
+ import { z as z10 } from "zod";
611
877
 
612
878
  // src/tools/rewrite_links.ts
613
879
  import { posix } from "path";
@@ -700,11 +966,11 @@ function hasMarkdownLinkTo(raw, sourcePath, targetPath) {
700
966
  }
701
967
 
702
968
  // src/tools/move_note.ts
703
- var MoveNoteInput = z8.object({
704
- from: z8.string().describe("Vault-relative path of the note to move."),
705
- to: z8.string().describe("Vault-relative destination path."),
706
- overwrite: z8.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false."),
707
- rewriteLinks: z8.boolean().default(true).describe(
969
+ var MoveNoteInput = z10.object({
970
+ from: z10.string().describe("Vault-relative path of the note to move."),
971
+ to: z10.string().describe("Vault-relative destination path."),
972
+ overwrite: z10.boolean().default(false).describe("Overwrite the destination if it exists. Defaults to false."),
973
+ rewriteLinks: z10.boolean().default(true).describe(
708
974
  "Rewrite wikilinks and markdown links in other notes that point at the moved note, so nothing breaks. Defaults to true; pass false to move the file only."
709
975
  )
710
976
  });
@@ -838,11 +1104,11 @@ function buildOutline(raw, options = {}) {
838
1104
  }
839
1105
 
840
1106
  // src/tools/outline_note.ts
841
- import { z as z9 } from "zod";
842
- var OutlineNoteInput = z9.object({
843
- path: z9.string().describe("Vault-relative path to the note."),
844
- includeBlocks: z9.boolean().default(true).describe("Include block-reference anchors (^id). Default true."),
845
- includeSizes: z9.boolean().default(false).describe("Include per-section character length in each heading entry. Default false.")
1107
+ import { z as z11 } from "zod";
1108
+ var OutlineNoteInput = z11.object({
1109
+ path: z11.string().describe("Vault-relative path to the note."),
1110
+ includeBlocks: z11.boolean().default(true).describe("Include block-reference anchors (^id). Default true."),
1111
+ includeSizes: z11.boolean().default(false).describe("Include per-section character length in each heading entry. Default false.")
846
1112
  });
847
1113
  async function outlineNote(ctx2, input) {
848
1114
  const absPath = join7(ctx2.vaultRoot, input.path);
@@ -860,13 +1126,13 @@ async function outlineNote(ctx2, input) {
860
1126
  import { readFile as readFile4 } from "fs/promises";
861
1127
  import { join as join8 } from "path";
862
1128
  import { parseDocument } from "yaml";
863
- import { z as z10 } from "zod";
864
- var PatchFrontmatterInput = z10.object({
865
- path: z10.string().describe("Vault-relative path to the note."),
866
- patch: z10.record(z10.string(), z10.unknown()).describe(
1129
+ import { z as z12 } from "zod";
1130
+ var PatchFrontmatterInput = z12.object({
1131
+ path: z12.string().describe("Vault-relative path to the note."),
1132
+ patch: z12.record(z12.string(), z12.unknown()).describe(
867
1133
  "Key-value pairs to set in the frontmatter. Existing keys not in the patch are unchanged. Pass null as a value to remove a key."
868
1134
  ),
869
- prevHash: z10.string().optional().describe(
1135
+ prevHash: z12.string().optional().describe(
870
1136
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
871
1137
  )
872
1138
  });
@@ -938,21 +1204,21 @@ ${original}`;
938
1204
  // src/tools/patch_note.ts
939
1205
  import { readFile as readFile5 } from "fs/promises";
940
1206
  import { join as join9 } from "path";
941
- import { z as z11 } from "zod";
942
- var PatchNoteInput = z11.object({
943
- path: z11.string().describe("Vault-relative path to the note."),
944
- target: z11.union([
945
- z11.object({ heading: z11.string().describe("Heading text (without # markers).") }),
946
- z11.object({ block: z11.string().describe("Block reference ID (without the ^ prefix).") })
1207
+ import { z as z13 } from "zod";
1208
+ var PatchNoteInput = z13.object({
1209
+ path: z13.string().describe("Vault-relative path to the note."),
1210
+ target: z13.union([
1211
+ z13.object({ heading: z13.string().describe("Heading text (without # markers).") }),
1212
+ z13.object({ block: z13.string().describe("Block reference ID (without the ^ prefix).") })
947
1213
  ]).describe("Target section. Provide exactly one of heading or block."),
948
- operation: z11.enum(["append", "prepend", "replace"]).describe(
1214
+ operation: z13.enum(["append", "prepend", "replace"]).describe(
949
1215
  "append: insert after section content. prepend: insert after heading line. replace: swap section content."
950
1216
  ),
951
- content: z11.string().min(1).describe("Content to insert or replace with."),
952
- createIfMissing: z11.boolean().default(false).describe(
1217
+ content: z13.string().min(1).describe("Content to insert or replace with."),
1218
+ createIfMissing: z13.boolean().default(false).describe(
953
1219
  "If the heading target is not found, append a new heading (level 2) + content. Only valid for heading targets."
954
1220
  ),
955
- prevHash: z11.string().optional().describe(
1221
+ prevHash: z13.string().optional().describe(
956
1222
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
957
1223
  )
958
1224
  });
@@ -1088,8 +1354,8 @@ function updateCache(ctx2, path2, newContent, bodyStart) {
1088
1354
  // src/tools/periodic_note.ts
1089
1355
  import { access as access4, mkdir as mkdir4, readFile as readFile6 } from "fs/promises";
1090
1356
  import { dirname as dirname3, join as join10 } from "path";
1091
- import { z as z12 } from "zod";
1092
- var PERIOD = z12.enum(["daily", "weekly", "monthly", "quarterly", "yearly"]);
1357
+ import { z as z14 } from "zod";
1358
+ var PERIOD = z14.enum(["daily", "weekly", "monthly", "quarterly", "yearly"]);
1093
1359
  var DEFAULTS = {
1094
1360
  daily: { folder: "", format: "YYYY-MM-DD", template: "" },
1095
1361
  weekly: { folder: "", format: "gggg-[W]ww", template: "" },
@@ -1154,12 +1420,12 @@ function resolveNotePath(cfg, date) {
1154
1420
  const filename = `${formatMomentDate(date, cfg.format)}.md`;
1155
1421
  return cfg.folder ? `${cfg.folder}/${filename}` : filename;
1156
1422
  }
1157
- var GetPeriodicNoteInput = z12.object({
1423
+ var GetPeriodicNoteInput = z14.object({
1158
1424
  period: PERIOD.default("daily").describe(
1159
1425
  "Period type: daily (default), weekly, monthly, quarterly, or yearly."
1160
1426
  ),
1161
- date: z12.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today when omitted."),
1162
- createIfMissing: z12.boolean().default(false).describe(
1427
+ date: z14.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today when omitted."),
1428
+ createIfMissing: z14.boolean().default(false).describe(
1163
1429
  "Create the note from the configured template if it does not exist. Default false \u2014 reports missing without creating."
1164
1430
  )
1165
1431
  });
@@ -1197,12 +1463,12 @@ async function getPeriodicNote(ctx2, rawInput) {
1197
1463
  upsertDoc(ctx2, buildDoc(path2, body));
1198
1464
  return { path: path2, existed: false, created: true, contentHash: contentHash(body) };
1199
1465
  }
1200
- var AppendPeriodicNoteInput = z12.object({
1466
+ var AppendPeriodicNoteInput = z14.object({
1201
1467
  period: PERIOD.default("daily").describe("Period type. Default: daily."),
1202
- date: z12.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today."),
1203
- content: z12.string().min(1).describe("Text to append to the note body. Separated from existing content by a blank line."),
1204
- createIfMissing: z12.boolean().default(true).describe("Create the note if it does not exist before appending. Default true."),
1205
- prevHash: z12.string().optional().describe(
1468
+ date: z14.string().optional().describe("ISO date string (YYYY-MM-DD). Defaults to today."),
1469
+ content: z14.string().min(1).describe("Text to append to the note body. Separated from existing content by a blank line."),
1470
+ createIfMissing: z14.boolean().default(true).describe("Create the note if it does not exist before appending. Default true."),
1471
+ prevHash: z14.string().optional().describe(
1206
1472
  "Optional compare-and-swap guard: the contentHash from a prior read of the resolved periodic note. Fails with hash_conflict if it changed since."
1207
1473
  )
1208
1474
  });
@@ -1262,114 +1528,36 @@ async function appendPeriodicNote(ctx2, rawInput) {
1262
1528
  }
1263
1529
 
1264
1530
  // src/tools/query_notes.ts
1265
- import { z as z14 } from "zod";
1266
-
1267
- // src/tools/search.ts
1268
- import { z as z13 } from "zod";
1269
-
1270
- // src/index/excerpt.ts
1271
- function extractExcerpt(text, terms, maxLen = 200) {
1272
- const lower = text.toLowerCase();
1273
- let bestIdx = -1;
1274
- for (const term of terms) {
1275
- const idx = lower.indexOf(term.toLowerCase());
1276
- if (idx !== -1 && (bestIdx === -1 || idx < bestIdx)) {
1277
- bestIdx = idx;
1278
- }
1279
- }
1280
- if (bestIdx === -1) {
1281
- return truncate(text.trimStart(), maxLen);
1282
- }
1283
- const half = Math.floor(maxLen / 2);
1284
- const start = Math.max(0, bestIdx - half);
1285
- const end = Math.min(text.length, bestIdx + half + (terms[0]?.length ?? 0));
1286
- const slice = text.slice(start, end).trim();
1287
- return `${start > 0 ? "\u2026" : ""}${slice}${end < text.length ? "\u2026" : ""}`;
1288
- }
1289
- function truncate(s, max) {
1290
- if (s.length <= max) return s;
1291
- return `${s.slice(0, max).trimEnd()}\u2026`;
1292
- }
1293
-
1294
- // src/tools/search.ts
1295
- var SearchInput = z13.object({
1296
- query: z13.string().min(1).describe("Search query. Supports fuzzy matching and prefix search."),
1297
- limit: z13.number().int().min(1).max(50).default(10).describe("Maximum number of results to return."),
1298
- folder: z13.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
1299
- tag: z13.string().optional().describe("Restrict results to notes containing this tag."),
1300
- excerptLength: z13.number().int().min(20).max(2e3).optional().describe(
1301
- "Max characters of match context per hit (default 120). Lower trims payload; higher gives more context."
1302
- )
1303
- });
1304
- function basenameNoExt(path2) {
1305
- const base = path2.slice(path2.lastIndexOf("/") + 1);
1306
- const dot = base.lastIndexOf(".");
1307
- return dot > 0 ? base.slice(0, dot) : base;
1308
- }
1309
- function search(ctx2, input) {
1310
- const results = ctx2.index.search(input.query, {
1311
- boost: { title: 3, tags: 2, body: 1 },
1312
- fuzzy: 0.2,
1313
- prefix: true
1314
- });
1315
- const terms = input.query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
1316
- const hits = [];
1317
- for (const r of results) {
1318
- if (input.folder && !r.id.startsWith(input.folder)) continue;
1319
- if (input.tag) {
1320
- const note2 = ctx2.notes.get(r.id);
1321
- if (!note2) continue;
1322
- const noteTags2 = note2.tags.split(" ");
1323
- if (!noteTags2.some((t) => t === input.tag || t === input.tag?.replace(/^#/, ""))) continue;
1324
- }
1325
- const note = ctx2.notes.get(r.id);
1326
- const body = note?.body ?? "";
1327
- const noteTags = note?.tags ? note.tags.split(" ").filter(Boolean) : [];
1328
- const title = r.title;
1329
- const hit = {
1330
- path: r.id,
1331
- // Round the raw MiniSearch score: 2 decimals preserve relevance gaps without 17-digit float tax.
1332
- score: Math.round(r.score * 100) / 100,
1333
- excerpt: extractExcerpt(body, terms, input.excerptLength ?? 120)
1334
- };
1335
- if (title && title !== basenameNoExt(r.id)) hit.title = title;
1336
- if (noteTags.length > 0) hit.tags = noteTags;
1337
- hits.push(hit);
1338
- if (hits.length >= input.limit) break;
1339
- }
1340
- return hits;
1341
- }
1342
-
1343
- // src/tools/query_notes.ts
1531
+ import { z as z15 } from "zod";
1344
1532
  var OPS_WITHOUT_VALUE = /* @__PURE__ */ new Set(["exists", "missing"]);
1345
- var Predicate = z14.object({
1346
- key: z14.string().min(1).describe("Frontmatter key to test (top-level)."),
1347
- op: z14.enum(["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"]).describe(
1533
+ var Predicate = z15.object({
1534
+ key: z15.string().min(1).describe("Frontmatter key to test (top-level)."),
1535
+ op: z15.enum(["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"]).describe(
1348
1536
  "eq/ne compare scalars; contains matches array membership or substring; exists/missing test key presence; gt/gte/lt/lte compare numbers or strings (ISO dates sort correctly)."
1349
1537
  ),
1350
- value: z14.union([z14.string(), z14.number(), z14.boolean()]).optional().describe("Comparison value. Required for every op except exists/missing.")
1538
+ value: z15.union([z15.string(), z15.number(), z15.boolean()]).optional().describe("Comparison value. Required for every op except exists/missing.")
1351
1539
  }).refine((p) => OPS_WITHOUT_VALUE.has(p.op) || p.value !== void 0, {
1352
1540
  message: "value is required for this op"
1353
1541
  });
1354
- var ISO_DATE = z14.string().refine((s) => !Number.isNaN(Date.parse(s)), { message: "not a parseable date" });
1355
- var QueryNotesInput = z14.object({
1356
- where: z14.array(Predicate).max(20).default([]).describe("Frontmatter predicates. All must match (AND)."),
1357
- folder: z14.string().optional().describe("Restrict to notes under this vault-relative folder prefix."),
1358
- tag: z14.string().optional().describe("Restrict to notes with this tag (# prefix optional)."),
1542
+ var ISO_DATE = z15.string().refine((s) => !Number.isNaN(Date.parse(s)), { message: "not a parseable date" });
1543
+ var QueryNotesInput = z15.object({
1544
+ where: z15.array(Predicate).max(20).default([]).describe("Frontmatter predicates. All must match (AND)."),
1545
+ folder: z15.string().optional().describe("Restrict to notes under this vault-relative folder prefix."),
1546
+ tag: z15.string().optional().describe("Restrict to notes with this tag (# prefix optional)."),
1359
1547
  modifiedAfter: ISO_DATE.optional().describe(
1360
1548
  "Only notes modified at or after this ISO 8601 date/time."
1361
1549
  ),
1362
1550
  modifiedBefore: ISO_DATE.optional().describe(
1363
1551
  "Only notes modified before this ISO 8601 date/time."
1364
1552
  ),
1365
- minSizeBytes: z14.number().int().min(0).optional().describe("Only notes at least this many bytes."),
1366
- maxSizeBytes: z14.number().int().min(0).optional().describe("Only notes at most this many bytes."),
1367
- select: z14.array(z14.string()).max(20).default([]).describe(
1553
+ minSizeBytes: z15.number().int().min(0).optional().describe("Only notes at least this many bytes."),
1554
+ maxSizeBytes: z15.number().int().min(0).optional().describe("Only notes at most this many bytes."),
1555
+ select: z15.array(z15.string()).max(20).default([]).describe(
1368
1556
  'Extra fields to return per hit: frontmatter keys, or the specials "mtime", "size", "tags". Default returns only path + title, keeping the payload minimal.'
1369
1557
  ),
1370
- sort: z14.enum(["path", "title", "mtime", "size"]).default("path").describe("Sort field."),
1371
- order: z14.enum(["asc", "desc"]).default("asc").describe("Sort order."),
1372
- limit: z14.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
1558
+ sort: z15.enum(["path", "title", "mtime", "size"]).default("path").describe("Sort field."),
1559
+ order: z15.enum(["asc", "desc"]).default("asc").describe("Sort order."),
1560
+ limit: z15.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
1373
1561
  });
1374
1562
  function scalarEq(a, b) {
1375
1563
  if (a === b) return true;
@@ -1469,16 +1657,16 @@ function queryNotes(ctx2, input) {
1469
1657
  // src/tools/read_note.ts
1470
1658
  import { readFile as readFile7 } from "fs/promises";
1471
1659
  import { join as join11 } from "path";
1472
- import { z as z15 } from "zod";
1473
- var ReadNoteInput = z15.object({
1474
- path: z15.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".'),
1475
- section: z15.string().optional().describe("Return only this heading section (heading text, # prefix optional)."),
1476
- block: z15.string().optional().describe("Return only the block anchored by this id (^ prefix optional)."),
1477
- lines: z15.object({
1478
- from: z15.number().int().min(1).describe("First line (1-indexed, inclusive)."),
1479
- to: z15.number().int().min(1).optional().describe("Last line (1-indexed, inclusive). Defaults to end of file.")
1660
+ import { z as z16 } from "zod";
1661
+ var ReadNoteInput = z16.object({
1662
+ path: z16.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".'),
1663
+ section: z16.string().optional().describe("Return only this heading section (heading text, # prefix optional)."),
1664
+ block: z16.string().optional().describe("Return only the block anchored by this id (^ prefix optional)."),
1665
+ lines: z16.object({
1666
+ from: z16.number().int().min(1).describe("First line (1-indexed, inclusive)."),
1667
+ to: z16.number().int().min(1).optional().describe("Last line (1-indexed, inclusive). Defaults to end of file.")
1480
1668
  }).optional().describe("Return only this line range (1-indexed, inclusive)."),
1481
- includeFrontmatter: z15.boolean().optional().describe(
1669
+ includeFrontmatter: z16.boolean().optional().describe(
1482
1670
  "Include frontmatter in span results. Default false for section/block spans. No effect on lines or whole-note reads."
1483
1671
  )
1484
1672
  }).refine((v) => [v.section, v.block, v.lines].filter((x) => x !== void 0).length <= 1, {
@@ -1579,17 +1767,17 @@ async function readNote(ctx2, input) {
1579
1767
  // src/tools/replace_in_note.ts
1580
1768
  import { readFile as readFile8 } from "fs/promises";
1581
1769
  import { join as join12 } from "path";
1582
- import { z as z16 } from "zod";
1583
- var ReplaceInNoteInput = z16.object({
1584
- path: z16.string().describe("Vault-relative path to the note."),
1585
- find: z16.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
1586
- replace: z16.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
1587
- regex: z16.boolean().default(false).describe("Treat find as a regular expression."),
1588
- caseSensitive: z16.boolean().default(false).describe("Case-sensitive matching."),
1589
- wholeWord: z16.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
1590
- limit: z16.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
1591
- dryRun: z16.boolean().default(false).describe("If true, report matches without writing anything."),
1592
- prevHash: z16.string().optional().describe(
1770
+ import { z as z17 } from "zod";
1771
+ var ReplaceInNoteInput = z17.object({
1772
+ path: z17.string().describe("Vault-relative path to the note."),
1773
+ find: z17.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
1774
+ replace: z17.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
1775
+ regex: z17.boolean().default(false).describe("Treat find as a regular expression."),
1776
+ caseSensitive: z17.boolean().default(false).describe("Case-sensitive matching."),
1777
+ wholeWord: z17.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
1778
+ limit: z17.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
1779
+ dryRun: z17.boolean().default(false).describe("If true, report matches without writing anything."),
1780
+ prevHash: z17.string().optional().describe(
1593
1781
  "Optional compare-and-swap guard: the contentHash from a prior read. Fails with hash_conflict if the note changed since."
1594
1782
  )
1595
1783
  });
@@ -1701,7 +1889,8 @@ var META_KEYS = [
1701
1889
  "period",
1702
1890
  "date",
1703
1891
  "permanent",
1704
- "prevHash"
1892
+ "prevHash",
1893
+ "budgetBytes"
1705
1894
  ];
1706
1895
  function safeMeta(args) {
1707
1896
  if (typeof args !== "object" || args === null) return {};
@@ -1770,6 +1959,11 @@ async function run(ctx2, name, args) {
1770
1959
  const hits = queryNotes(ctx2, input);
1771
1960
  return { content: [{ type: "text", text: JSON.stringify(hits) }] };
1772
1961
  }
1962
+ case "context_pack": {
1963
+ const input = ContextPackInput.parse(args);
1964
+ const pack = contextPack(ctx2, input);
1965
+ return { content: [{ type: "text", text: JSON.stringify(pack) }] };
1966
+ }
1773
1967
  case "read_note": {
1774
1968
  const input = ReadNoteInput.parse(args);
1775
1969
  const result = await readNote(ctx2, input);
@@ -2489,6 +2683,21 @@ var ALL_TOOLS = [
2489
2683
  required: []
2490
2684
  }
2491
2685
  },
2686
+ {
2687
+ name: "context_pack",
2688
+ description: 'Assemble everything needed to ANSWER a natural-language question in one call, under a strict byte budget (default 2048): ranked excerpts, linked neighbor notes (backlinks/outlinks) with one-line summaries, and follow-up source paths. Use search to locate notes and query_notes for metadata filters; use context_pack when you want answer-ready context without multiple round-trips. Empty excerpts with confidence "none" or "low" means the vault lacks coverage \u2014 do not infer content.',
2689
+ inputSchema: {
2690
+ type: "object",
2691
+ properties: {
2692
+ query: { type: "string", description: "Natural-language question or topic." },
2693
+ budgetBytes: {
2694
+ type: "number",
2695
+ description: "Hard cap on response JSON bytes (256\u201316384, default 2048)."
2696
+ }
2697
+ },
2698
+ required: ["query"]
2699
+ }
2700
+ },
2492
2701
  {
2493
2702
  name: "read_note",
2494
2703
  description: "Read a note or a span of it \u2014 by heading section, block reference, or line range. Returns structured JSON with the content, bytes returned, total note size, and a contentHash to pass as prevHash to edit tools for compare-and-swap. Use search or outline_note first to find the right path and section names.",
@@ -2907,7 +3116,7 @@ function startWatcher(ctx2, log2, opts) {
2907
3116
  }
2908
3117
 
2909
3118
  // src/index.ts
2910
- var VERSION = true ? "0.11.1" : "0.0.0-dev";
3119
+ var VERSION = true ? "0.12.0" : "0.0.0-dev";
2911
3120
  var intent = parseCliIntent(process.argv.slice(2));
2912
3121
  if (intent === "version") {
2913
3122
  process.stdout.write(`${VERSION}