seekstone 0.7.2 → 0.9.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/README.md CHANGED
@@ -8,6 +8,8 @@ It reads your vault **directly from disk** instead of routing through the Obsidi
8
8
 
9
9
  (Previously also published as `obsidian-mcp-seekstone` — that alias is deprecated; existing installs keep working, but install `seekstone` going forward.)
10
10
 
11
+ [![Install in Claude Desktop](https://img.shields.io/badge/Install_in-Claude_Desktop-D97757?style=for-the-badge&logo=anthropic&logoColor=white)](https://github.com/shaqmughal/seekstone/releases/latest/download/seekstone.mcpb) [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=seekstone&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsInNlZWtzdG9uZSJdLCJlbnYiOnsiU0VFS1NUT05FX1ZBVUxUIjoiL2Fic29sdXRlL3BhdGgvdG8veW91ci92YXVsdCJ9fQ%3D%3D) [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=for-the-badge&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22seekstone%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22seekstone%22%5D%2C%22env%22%3A%7B%22SEEKSTONE_VAULT%22%3A%22%2Fabsolute%2Fpath%2Fto%2Fyour%2Fvault%22%7D%7D)
12
+
11
13
  ---
12
14
 
13
15
  ## Install
@@ -64,6 +66,26 @@ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
64
66
  claude mcp add seekstone --env SEEKSTONE_VAULT=/absolute/path/to/your/vault -- npx -y seekstone
65
67
  ```
66
68
 
69
+ ### Option 5 — Cursor
70
+
71
+ One-click via the [Install in Cursor](https://cursor.com/install-mcp?name=seekstone&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsInNlZWtzdG9uZSJdLCJlbnYiOnsiU0VFS1NUT05FX1ZBVUxUIjoiL2Fic29sdXRlL3BhdGgvdG8veW91ci92YXVsdCJ9fQ%3D%3D) link above (then set `SEEKSTONE_VAULT` to your vault path), or let the CLI auto-detect your vault:
72
+
73
+ ```bash
74
+ npx -y seekstone init --client cursor --write
75
+ ```
76
+
77
+ Or add the Option 3 JSON block to `~/.cursor/mcp.json`.
78
+
79
+ ### Option 6 — VS Code
80
+
81
+ One-click via the [Install in VS Code](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22seekstone%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22seekstone%22%5D%2C%22env%22%3A%7B%22SEEKSTONE_VAULT%22%3A%22%2Fabsolute%2Fpath%2Fto%2Fyour%2Fvault%22%7D%7D) link above (then set `SEEKSTONE_VAULT` to your vault path), or from the terminal:
82
+
83
+ ```bash
84
+ code --add-mcp '{"name":"seekstone","command":"npx","args":["-y","seekstone"],"env":{"SEEKSTONE_VAULT":"/absolute/path/to/your/vault"}}'
85
+ ```
86
+
87
+ Other MCP clients (Windsurf, Cline, …) take the Option 3 JSON block in their own MCP config file.
88
+
67
89
  ---
68
90
 
69
91
  ## Tools
package/dist/index.js CHANGED
@@ -21,18 +21,18 @@ function helpText(version) {
21
21
  return `seekstone ${version} \u2014 an Obsidian MCP server (filesystem-direct, low context-tax)
22
22
 
23
23
  Seekstone runs as a Model Context Protocol stdio server. It is normally
24
- launched by an MCP client (Claude Desktop, Claude Code), not run by hand.
24
+ launched by an MCP client (Claude Desktop, Claude Code, Cursor, \u2026), not run by hand.
25
25
 
26
26
  Usage:
27
27
  seekstone Start the MCP server (reads from stdin/stdout)
28
- seekstone init Validate a vault and print/patch the Claude config
28
+ seekstone init Validate a vault and print/patch the client MCP config
29
29
  seekstone --version Print the version and exit
30
30
  seekstone --help Print this help and exit
31
31
 
32
32
  "seekstone init" options:
33
33
  --vault <path> Vault to use (auto-detected from Obsidian if omitted; fallback: $SEEKSTONE_VAULT)
34
- --client <name> desktop (default) | code
35
- --write Patch the Claude Desktop config in place (backs it up first)
34
+ --client <name> desktop (default) | code | cursor | vscode
35
+ --write Patch the client config in place (backs it up first)
36
36
 
37
37
  Required environment:
38
38
  SEEKSTONE_VAULT Absolute path to your Obsidian vault
@@ -206,6 +206,7 @@ function buildDoc(relPath, raw) {
206
206
  body: fm.body,
207
207
  tags: allTags.join(" "),
208
208
  fmKeys: fm.keys.join(" "),
209
+ fm: fm.data,
209
210
  raw,
210
211
  sizeBytes: Buffer.byteLength(raw, "utf8"),
211
212
  mtimeMs: Date.now()
@@ -897,19 +898,224 @@ async function appendPeriodicNote(ctx2, rawInput) {
897
898
  return { path: path2, bytesWritten: Buffer.byteLength(newRaw, "utf8") };
898
899
  }
899
900
 
901
+ // src/tools/query_notes.ts
902
+ import { z as z14 } from "zod";
903
+
904
+ // src/tools/search.ts
905
+ import { z as z13 } from "zod";
906
+
907
+ // src/index/excerpt.ts
908
+ function extractExcerpt(text, terms, maxLen = 200) {
909
+ const lower = text.toLowerCase();
910
+ let bestIdx = -1;
911
+ for (const term of terms) {
912
+ const idx = lower.indexOf(term.toLowerCase());
913
+ if (idx !== -1 && (bestIdx === -1 || idx < bestIdx)) {
914
+ bestIdx = idx;
915
+ }
916
+ }
917
+ if (bestIdx === -1) {
918
+ return truncate(text.trimStart(), maxLen);
919
+ }
920
+ const half = Math.floor(maxLen / 2);
921
+ const start = Math.max(0, bestIdx - half);
922
+ const end = Math.min(text.length, bestIdx + half + (terms[0]?.length ?? 0));
923
+ const slice = text.slice(start, end).trim();
924
+ return `${start > 0 ? "\u2026" : ""}${slice}${end < text.length ? "\u2026" : ""}`;
925
+ }
926
+ function truncate(s, max) {
927
+ if (s.length <= max) return s;
928
+ return `${s.slice(0, max).trimEnd()}\u2026`;
929
+ }
930
+
931
+ // src/tools/search.ts
932
+ var SearchInput = z13.object({
933
+ query: z13.string().min(1).describe("Search query. Supports fuzzy matching and prefix search."),
934
+ limit: z13.number().int().min(1).max(50).default(10).describe("Maximum number of results to return."),
935
+ folder: z13.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
936
+ tag: z13.string().optional().describe("Restrict results to notes containing this tag."),
937
+ excerptLength: z13.number().int().min(20).max(2e3).optional().describe(
938
+ "Max characters of match context per hit (default 120). Lower trims payload; higher gives more context."
939
+ )
940
+ });
941
+ function basenameNoExt(path2) {
942
+ const base = path2.slice(path2.lastIndexOf("/") + 1);
943
+ const dot = base.lastIndexOf(".");
944
+ return dot > 0 ? base.slice(0, dot) : base;
945
+ }
946
+ function search(ctx2, input) {
947
+ const results = ctx2.index.search(input.query, {
948
+ boost: { title: 3, tags: 2, body: 1 },
949
+ fuzzy: 0.2,
950
+ prefix: true
951
+ });
952
+ const terms = input.query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
953
+ const hits = [];
954
+ for (const r of results) {
955
+ if (input.folder && !r.id.startsWith(input.folder)) continue;
956
+ if (input.tag) {
957
+ const note2 = ctx2.notes.get(r.id);
958
+ if (!note2) continue;
959
+ const noteTags2 = note2.tags.split(" ");
960
+ if (!noteTags2.some((t) => t === input.tag || t === input.tag?.replace(/^#/, ""))) continue;
961
+ }
962
+ const note = ctx2.notes.get(r.id);
963
+ const body = note?.body ?? "";
964
+ const noteTags = note?.tags ? note.tags.split(" ").filter(Boolean) : [];
965
+ const title = r.title;
966
+ const hit = {
967
+ path: r.id,
968
+ // Round the raw MiniSearch score: 2 decimals preserve relevance gaps without 17-digit float tax.
969
+ score: Math.round(r.score * 100) / 100,
970
+ excerpt: extractExcerpt(body, terms, input.excerptLength ?? 120)
971
+ };
972
+ if (title && title !== basenameNoExt(r.id)) hit.title = title;
973
+ if (noteTags.length > 0) hit.tags = noteTags;
974
+ hits.push(hit);
975
+ if (hits.length >= input.limit) break;
976
+ }
977
+ return hits;
978
+ }
979
+
980
+ // src/tools/query_notes.ts
981
+ var OPS_WITHOUT_VALUE = /* @__PURE__ */ new Set(["exists", "missing"]);
982
+ var Predicate = z14.object({
983
+ key: z14.string().min(1).describe("Frontmatter key to test (top-level)."),
984
+ op: z14.enum(["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"]).describe(
985
+ "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)."
986
+ ),
987
+ value: z14.union([z14.string(), z14.number(), z14.boolean()]).optional().describe("Comparison value. Required for every op except exists/missing.")
988
+ }).refine((p) => OPS_WITHOUT_VALUE.has(p.op) || p.value !== void 0, {
989
+ message: "value is required for this op"
990
+ });
991
+ var ISO_DATE = z14.string().refine((s) => !Number.isNaN(Date.parse(s)), { message: "not a parseable date" });
992
+ var QueryNotesInput = z14.object({
993
+ where: z14.array(Predicate).max(20).default([]).describe("Frontmatter predicates. All must match (AND)."),
994
+ folder: z14.string().optional().describe("Restrict to notes under this vault-relative folder prefix."),
995
+ tag: z14.string().optional().describe("Restrict to notes with this tag (# prefix optional)."),
996
+ modifiedAfter: ISO_DATE.optional().describe(
997
+ "Only notes modified at or after this ISO 8601 date/time."
998
+ ),
999
+ modifiedBefore: ISO_DATE.optional().describe(
1000
+ "Only notes modified before this ISO 8601 date/time."
1001
+ ),
1002
+ minSizeBytes: z14.number().int().min(0).optional().describe("Only notes at least this many bytes."),
1003
+ maxSizeBytes: z14.number().int().min(0).optional().describe("Only notes at most this many bytes."),
1004
+ select: z14.array(z14.string()).max(20).default([]).describe(
1005
+ 'Extra fields to return per hit: frontmatter keys, or the specials "mtime", "size", "tags". Default returns only path + title, keeping the payload minimal.'
1006
+ ),
1007
+ sort: z14.enum(["path", "title", "mtime", "size"]).default("path").describe("Sort field."),
1008
+ order: z14.enum(["asc", "desc"]).default("asc").describe("Sort order."),
1009
+ limit: z14.number().int().min(1).max(500).default(100).describe("Maximum number of results.")
1010
+ });
1011
+ function scalarEq(a, b) {
1012
+ if (a === b) return true;
1013
+ if (a === null || a === void 0 || typeof a === "object") return false;
1014
+ return String(a) === String(b);
1015
+ }
1016
+ function compare(a, b) {
1017
+ if (a === null || a === void 0 || typeof a === "object" || typeof a === "boolean") {
1018
+ return void 0;
1019
+ }
1020
+ const an = Number(a);
1021
+ const bn = Number(b);
1022
+ if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
1023
+ const as = String(a);
1024
+ const bs = String(b);
1025
+ return as < bs ? -1 : as > bs ? 1 : 0;
1026
+ }
1027
+ function matches(fm, p) {
1028
+ const val = fm?.[p.key];
1029
+ switch (p.op) {
1030
+ case "exists":
1031
+ return val !== void 0;
1032
+ case "missing":
1033
+ return val === void 0;
1034
+ case "eq":
1035
+ return scalarEq(val, p.value);
1036
+ // `ne` requires the key to exist — notes without the key don't match.
1037
+ case "ne":
1038
+ return val !== void 0 && !scalarEq(val, p.value);
1039
+ case "contains": {
1040
+ if (Array.isArray(val)) return val.some((v) => scalarEq(v, p.value));
1041
+ if (typeof val === "string") {
1042
+ return val.toLowerCase().includes(String(p.value).toLowerCase());
1043
+ }
1044
+ return false;
1045
+ }
1046
+ case "gt":
1047
+ case "gte":
1048
+ case "lt":
1049
+ case "lte": {
1050
+ const c = compare(val, p.value);
1051
+ if (c === void 0) return false;
1052
+ if (p.op === "gt") return c > 0;
1053
+ if (p.op === "gte") return c >= 0;
1054
+ if (p.op === "lt") return c < 0;
1055
+ return c <= 0;
1056
+ }
1057
+ }
1058
+ }
1059
+ function queryNotes(ctx2, input) {
1060
+ const tag = input.tag?.replace(/^#/, "");
1061
+ const after = input.modifiedAfter ? Date.parse(input.modifiedAfter) : void 0;
1062
+ const before = input.modifiedBefore ? Date.parse(input.modifiedBefore) : void 0;
1063
+ const fmKeys = input.select.filter((s) => s !== "mtime" && s !== "size" && s !== "tags");
1064
+ const hits = [];
1065
+ for (const [path2, note] of ctx2.notes) {
1066
+ if (input.folder && !path2.startsWith(input.folder)) continue;
1067
+ if (tag && !note.tags.split(" ").filter(Boolean).includes(tag)) continue;
1068
+ if (after !== void 0 && note.mtimeMs < after) continue;
1069
+ if (before !== void 0 && note.mtimeMs >= before) continue;
1070
+ if (input.minSizeBytes !== void 0 && note.sizeBytes < input.minSizeBytes) continue;
1071
+ if (input.maxSizeBytes !== void 0 && note.sizeBytes > input.maxSizeBytes) continue;
1072
+ if (!input.where.every((p) => matches(note.fm, p))) continue;
1073
+ const hit = { path: path2 };
1074
+ if (note.title && note.title !== basenameNoExt(path2)) hit.title = note.title;
1075
+ if (input.select.includes("mtime")) hit.mtime = new Date(note.mtimeMs).toISOString();
1076
+ if (input.select.includes("size")) hit.sizeBytes = note.sizeBytes;
1077
+ if (input.select.includes("tags")) {
1078
+ const noteTags = note.tags.split(" ").filter(Boolean);
1079
+ if (noteTags.length > 0) hit.tags = noteTags;
1080
+ }
1081
+ if (fmKeys.length > 0 && note.fm) {
1082
+ const fm = {};
1083
+ for (const k of fmKeys) {
1084
+ if (note.fm[k] !== void 0) fm[k] = note.fm[k];
1085
+ }
1086
+ if (Object.keys(fm).length > 0) hit.fm = fm;
1087
+ }
1088
+ hits.push({ hit, note });
1089
+ }
1090
+ const dir = input.order === "desc" ? -1 : 1;
1091
+ hits.sort((a, b) => {
1092
+ switch (input.sort) {
1093
+ case "mtime":
1094
+ return (a.note.mtimeMs - b.note.mtimeMs) * dir;
1095
+ case "size":
1096
+ return (a.note.sizeBytes - b.note.sizeBytes) * dir;
1097
+ case "title":
1098
+ return a.note.title.localeCompare(b.note.title) * dir;
1099
+ default:
1100
+ return a.hit.path.localeCompare(b.hit.path) * dir;
1101
+ }
1102
+ });
1103
+ return hits.slice(0, input.limit).map((h) => h.hit);
1104
+ }
1105
+
900
1106
  // src/tools/read_note.ts
901
1107
  import { readFile as readFile6 } from "fs/promises";
902
1108
  import { join as join11 } from "path";
903
- import { z as z13 } from "zod";
904
- var ReadNoteInput = z13.object({
905
- path: z13.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".'),
906
- section: z13.string().optional().describe("Return only this heading section (heading text, # prefix optional)."),
907
- block: z13.string().optional().describe("Return only the block anchored by this id (^ prefix optional)."),
908
- lines: z13.object({
909
- from: z13.number().int().min(1).describe("First line (1-indexed, inclusive)."),
910
- to: z13.number().int().min(1).optional().describe("Last line (1-indexed, inclusive). Defaults to end of file.")
1109
+ import { z as z15 } from "zod";
1110
+ var ReadNoteInput = z15.object({
1111
+ path: z15.string().describe('Vault-relative path to the note, e.g. "Daily Notes/2026-05-29.md".'),
1112
+ section: z15.string().optional().describe("Return only this heading section (heading text, # prefix optional)."),
1113
+ block: z15.string().optional().describe("Return only the block anchored by this id (^ prefix optional)."),
1114
+ lines: z15.object({
1115
+ from: z15.number().int().min(1).describe("First line (1-indexed, inclusive)."),
1116
+ to: z15.number().int().min(1).optional().describe("Last line (1-indexed, inclusive). Defaults to end of file.")
911
1117
  }).optional().describe("Return only this line range (1-indexed, inclusive)."),
912
- includeFrontmatter: z13.boolean().optional().describe(
1118
+ includeFrontmatter: z15.boolean().optional().describe(
913
1119
  "Include frontmatter in span results. Default false for section/block spans. No effect on lines or whole-note reads."
914
1120
  )
915
1121
  }).refine((v) => [v.section, v.block, v.lines].filter((x) => x !== void 0).length <= 1, {
@@ -1002,16 +1208,16 @@ async function readNote(ctx2, input) {
1002
1208
  // src/tools/replace_in_note.ts
1003
1209
  import { readFile as readFile7, rename as rename3, writeFile as writeFile6 } from "fs/promises";
1004
1210
  import { join as join12 } from "path";
1005
- import { z as z14 } from "zod";
1006
- var ReplaceInNoteInput = z14.object({
1007
- path: z14.string().describe("Vault-relative path to the note."),
1008
- find: z14.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
1009
- replace: z14.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
1010
- regex: z14.boolean().default(false).describe("Treat find as a regular expression."),
1011
- caseSensitive: z14.boolean().default(false).describe("Case-sensitive matching."),
1012
- wholeWord: z14.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
1013
- limit: z14.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
1014
- dryRun: z14.boolean().default(false).describe("If true, report matches without writing anything.")
1211
+ import { z as z16 } from "zod";
1212
+ var ReplaceInNoteInput = z16.object({
1213
+ path: z16.string().describe("Vault-relative path to the note."),
1214
+ find: z16.string().min(1).max(1e3).describe("Text or pattern to find (max 1000 chars; bounds regex-mode pattern size)."),
1215
+ replace: z16.string().describe("Replacement text. Supports $1, $2, \u2026 backreferences in regex mode."),
1216
+ regex: z16.boolean().default(false).describe("Treat find as a regular expression."),
1217
+ caseSensitive: z16.boolean().default(false).describe("Case-sensitive matching."),
1218
+ wholeWord: z16.boolean().default(false).describe("Match whole words only (\\b word-boundary)."),
1219
+ limit: z16.number().int().min(1).optional().describe("Maximum number of replacements. Omit to replace all."),
1220
+ dryRun: z16.boolean().default(false).describe("If true, report matches without writing anything.")
1015
1221
  });
1016
1222
  function escapeRegex(s) {
1017
1223
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1048,7 +1254,7 @@ async function replaceInNote(ctx2, input) {
1048
1254
  const body = raw.slice(fm.bodyStart);
1049
1255
  const allMatches = [...body.matchAll(re)];
1050
1256
  const toReplace = input.limit !== void 0 ? allMatches.slice(0, input.limit) : allMatches;
1051
- const matches = toReplace.map((m) => {
1257
+ const matches2 = toReplace.map((m) => {
1052
1258
  const fileOffset = fm.bodyStart + (m.index ?? 0);
1053
1259
  const line = raw.slice(0, fileOffset).split("\n").length;
1054
1260
  return { line, byteOffset: fileOffset };
@@ -1057,7 +1263,7 @@ async function replaceInNote(ctx2, input) {
1057
1263
  return {
1058
1264
  path: input.path,
1059
1265
  replacements: toReplace.length,
1060
- matches,
1266
+ matches: matches2,
1061
1267
  frontmatterUnchanged: true
1062
1268
  };
1063
1269
  }
@@ -1086,88 +1292,12 @@ async function replaceInNote(ctx2, input) {
1086
1292
  return {
1087
1293
  path: input.path,
1088
1294
  replacements: toReplace.length,
1089
- matches,
1295
+ matches: matches2,
1090
1296
  bytesWritten: Buffer.byteLength(newContent, "utf8"),
1091
1297
  frontmatterUnchanged: true
1092
1298
  };
1093
1299
  }
1094
1300
 
1095
- // src/tools/search.ts
1096
- import { z as z15 } from "zod";
1097
-
1098
- // src/index/excerpt.ts
1099
- function extractExcerpt(text, terms, maxLen = 200) {
1100
- const lower = text.toLowerCase();
1101
- let bestIdx = -1;
1102
- for (const term of terms) {
1103
- const idx = lower.indexOf(term.toLowerCase());
1104
- if (idx !== -1 && (bestIdx === -1 || idx < bestIdx)) {
1105
- bestIdx = idx;
1106
- }
1107
- }
1108
- if (bestIdx === -1) {
1109
- return truncate(text.trimStart(), maxLen);
1110
- }
1111
- const half = Math.floor(maxLen / 2);
1112
- const start = Math.max(0, bestIdx - half);
1113
- const end = Math.min(text.length, bestIdx + half + (terms[0]?.length ?? 0));
1114
- const slice = text.slice(start, end).trim();
1115
- return `${start > 0 ? "\u2026" : ""}${slice}${end < text.length ? "\u2026" : ""}`;
1116
- }
1117
- function truncate(s, max) {
1118
- if (s.length <= max) return s;
1119
- return `${s.slice(0, max).trimEnd()}\u2026`;
1120
- }
1121
-
1122
- // src/tools/search.ts
1123
- var SearchInput = z15.object({
1124
- query: z15.string().min(1).describe("Search query. Supports fuzzy matching and prefix search."),
1125
- limit: z15.number().int().min(1).max(50).default(10).describe("Maximum number of results to return."),
1126
- folder: z15.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
1127
- tag: z15.string().optional().describe("Restrict results to notes containing this tag."),
1128
- excerptLength: z15.number().int().min(20).max(2e3).optional().describe(
1129
- "Max characters of match context per hit (default 120). Lower trims payload; higher gives more context."
1130
- )
1131
- });
1132
- function basenameNoExt(path2) {
1133
- const base = path2.slice(path2.lastIndexOf("/") + 1);
1134
- const dot = base.lastIndexOf(".");
1135
- return dot > 0 ? base.slice(0, dot) : base;
1136
- }
1137
- function search(ctx2, input) {
1138
- const results = ctx2.index.search(input.query, {
1139
- boost: { title: 3, tags: 2, body: 1 },
1140
- fuzzy: 0.2,
1141
- prefix: true
1142
- });
1143
- const terms = input.query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
1144
- const hits = [];
1145
- for (const r of results) {
1146
- if (input.folder && !r.id.startsWith(input.folder)) continue;
1147
- if (input.tag) {
1148
- const note2 = ctx2.notes.get(r.id);
1149
- if (!note2) continue;
1150
- const noteTags2 = note2.tags.split(" ");
1151
- if (!noteTags2.some((t) => t === input.tag || t === input.tag?.replace(/^#/, ""))) continue;
1152
- }
1153
- const note = ctx2.notes.get(r.id);
1154
- const body = note?.body ?? "";
1155
- const noteTags = note?.tags ? note.tags.split(" ").filter(Boolean) : [];
1156
- const title = r.title;
1157
- const hit = {
1158
- path: r.id,
1159
- // Round the raw MiniSearch score: 2 decimals preserve relevance gaps without 17-digit float tax.
1160
- score: Math.round(r.score * 100) / 100,
1161
- excerpt: extractExcerpt(body, terms, input.excerptLength ?? 120)
1162
- };
1163
- if (title && title !== basenameNoExt(r.id)) hit.title = title;
1164
- if (noteTags.length > 0) hit.tags = noteTags;
1165
- hits.push(hit);
1166
- if (hits.length >= input.limit) break;
1167
- }
1168
- return hits;
1169
- }
1170
-
1171
1301
  // src/dispatch.ts
1172
1302
  var META_KEYS = [
1173
1303
  "path",
@@ -1180,6 +1310,12 @@ var META_KEYS = [
1180
1310
  "pattern",
1181
1311
  "minCount",
1182
1312
  "sort",
1313
+ "order",
1314
+ "select",
1315
+ "modifiedAfter",
1316
+ "modifiedBefore",
1317
+ "minSizeBytes",
1318
+ "maxSizeBytes",
1183
1319
  "period",
1184
1320
  "date"
1185
1321
  ];
@@ -1191,6 +1327,7 @@ function safeMeta(args) {
1191
1327
  if (a[k] !== void 0) out[k] = a[k];
1192
1328
  }
1193
1329
  if (typeof a.query === "string") out.queryLen = a.query.length;
1330
+ if (Array.isArray(a.where)) out.whereCount = a.where.length;
1194
1331
  return out;
1195
1332
  }
1196
1333
  function resultBytes(r) {
@@ -1227,6 +1364,11 @@ async function run(ctx2, name, args) {
1227
1364
  const hits = search(ctx2, input);
1228
1365
  return { content: [{ type: "text", text: JSON.stringify(hits) }] };
1229
1366
  }
1367
+ case "query_notes": {
1368
+ const input = QueryNotesInput.parse(args);
1369
+ const hits = queryNotes(ctx2, input);
1370
+ return { content: [{ type: "text", text: JSON.stringify(hits) }] };
1371
+ }
1230
1372
  case "read_note": {
1231
1373
  const input = ReadNoteInput.parse(args);
1232
1374
  const result = await readNote(ctx2, input);
@@ -1351,7 +1493,7 @@ function classify(relPath) {
1351
1493
  return "other";
1352
1494
  }
1353
1495
  async function walkVault(vaultRoot2) {
1354
- const matches = await fg("**/*", {
1496
+ const matches2 = await fg("**/*", {
1355
1497
  cwd: vaultRoot2,
1356
1498
  absolute: true,
1357
1499
  onlyFiles: true,
@@ -1363,7 +1505,7 @@ async function walkVault(vaultRoot2) {
1363
1505
  suppressErrors: true
1364
1506
  });
1365
1507
  const entries = [];
1366
- for (const m of matches) {
1508
+ for (const m of matches2) {
1367
1509
  const absPath = typeof m === "string" ? m : m.path;
1368
1510
  const stats = typeof m === "string" ? await stat(absPath) : m.stats;
1369
1511
  if (!stats) continue;
@@ -1415,6 +1557,7 @@ async function buildIndex(vaultRoot2) {
1415
1557
  body: fm.body,
1416
1558
  tags: allTags.join(" "),
1417
1559
  fmKeys: fm.keys.join(" "),
1560
+ fm: fm.data,
1418
1561
  raw,
1419
1562
  sizeBytes: entry.sizeBytes,
1420
1563
  mtimeMs: entry.mtimeMs
@@ -1465,7 +1608,7 @@ function parseInitArgs(argv) {
1465
1608
  else if (a === "--vault") opts.vault = argv[++i];
1466
1609
  else if (a === "--client") {
1467
1610
  const v = argv[++i];
1468
- if (v === "desktop" || v === "code") opts.client = v;
1611
+ if (v === "desktop" || v === "code" || v === "cursor" || v === "vscode") opts.client = v;
1469
1612
  }
1470
1613
  }
1471
1614
  return opts;
@@ -1493,6 +1636,13 @@ function claudeDesktopConfigPath(platform, env) {
1493
1636
  }
1494
1637
  return j(home, ".config", "Claude", "claude_desktop_config.json");
1495
1638
  }
1639
+ function cursorConfigPath(platform, env) {
1640
+ const j = platform === "win32" ? path.win32.join : path.posix.join;
1641
+ return j(env.home ?? "", ".cursor", "mcp.json");
1642
+ }
1643
+ function vscodeMcpConfigPath(cwd) {
1644
+ return join14(cwd, ".vscode", "mcp.json");
1645
+ }
1496
1646
  function obsidianRegistryPath(platform, env) {
1497
1647
  if (platform === "win32") {
1498
1648
  const j2 = path.win32.join;
@@ -1536,6 +1686,15 @@ function mergeSeekstoneConfig(existing, vaultPath) {
1536
1686
  servers.seekstone = seekstoneServerConfig(vaultPath);
1537
1687
  return { ...base, mcpServers: servers };
1538
1688
  }
1689
+ function vscodeServerConfig(vaultPath) {
1690
+ return { type: "stdio", ...seekstoneServerConfig(vaultPath) };
1691
+ }
1692
+ function mergeSeekstoneConfigVsCode(existing, vaultPath) {
1693
+ const base = existing ? structuredClone(existing) : {};
1694
+ const servers = { ...base.servers ?? {} };
1695
+ servers.seekstone = vscodeServerConfig(vaultPath);
1696
+ return { ...base, servers };
1697
+ }
1539
1698
  async function validateVault(vaultPath) {
1540
1699
  try {
1541
1700
  const st = await stat2(vaultPath);
@@ -1613,6 +1772,7 @@ async function runInit(opts, deps) {
1613
1772
  };
1614
1773
  }
1615
1774
  }
1775
+ vaultPath = path.resolve(deps.cwd ?? process.cwd(), vaultPath);
1616
1776
  const check = await validateVault(vaultPath);
1617
1777
  if (!check.ok) {
1618
1778
  return { ok: false, exitCode: 1, output: [`\u2717 ${check.error}`] };
@@ -1662,23 +1822,24 @@ async function runInit(opts, deps) {
1662
1822
  out.push("Add to Claude Code by running:", "", ` ${mcpAddCommand(vaultPath)}`, "");
1663
1823
  return { ok: true, exitCode: 0, output: out };
1664
1824
  }
1665
- const configPath = claudeDesktopConfigPath(deps.platform, {
1825
+ const clientLabel = opts.client === "cursor" ? "Cursor" : opts.client === "vscode" ? "VS Code (GitHub Copilot)" : "Claude Desktop";
1826
+ const configPath = opts.client === "vscode" ? vscodeMcpConfigPath(deps.cwd ?? process.cwd()) : opts.client === "cursor" ? cursorConfigPath(deps.platform, { home: deps.env.HOME }) : claudeDesktopConfigPath(deps.platform, {
1666
1827
  home: deps.env.HOME,
1667
1828
  appData: deps.env.APPDATA
1668
1829
  });
1669
- const block = JSON.stringify(
1670
- { mcpServers: { seekstone: seekstoneServerConfig(vaultPath) } },
1671
- null,
1672
- 2
1673
- );
1830
+ const block = opts.client === "vscode" ? JSON.stringify({ servers: { seekstone: vscodeServerConfig(vaultPath) } }, null, 2) : JSON.stringify({ mcpServers: { seekstone: seekstoneServerConfig(vaultPath) } }, null, 2);
1831
+ const restartHint = opts.client === "vscode" ? "Reload VS Code, then open Copilot Chat in Agent mode \u2014 seekstone appears in the tools picker." : `Restart ${clientLabel} to load seekstone.`;
1674
1832
  if (!opts.write) {
1675
1833
  out.push(
1676
- "Add this to your Claude Desktop config:",
1834
+ `Add this to your ${clientLabel} config:`,
1677
1835
  ` ${configPath}`,
1836
+ ...opts.client === "vscode" ? [
1837
+ ` (workspace config \u2014 run from your project root, or use the Command Palette's "MCP: Open User Configuration" for user-global)`
1838
+ ] : [],
1678
1839
  "",
1679
1840
  block,
1680
1841
  "",
1681
- "Then restart Claude Desktop. Or re-run with --write to patch it automatically."
1842
+ `${restartHint} Or re-run with --write to patch it automatically.`
1682
1843
  );
1683
1844
  return { ok: true, exitCode: 0, output: out };
1684
1845
  }
@@ -1700,7 +1861,7 @@ async function runInit(opts, deps) {
1700
1861
  }
1701
1862
  existing = null;
1702
1863
  }
1703
- const merged = mergeSeekstoneConfig(existing, vaultPath);
1864
+ const merged = opts.client === "vscode" ? mergeSeekstoneConfigVsCode(existing, vaultPath) : mergeSeekstoneConfig(existing, vaultPath);
1704
1865
  let backupPath;
1705
1866
  await mkdir4(dirname4(configPath), { recursive: true });
1706
1867
  if (existingRaw !== null) {
@@ -1713,7 +1874,7 @@ async function runInit(opts, deps) {
1713
1874
  `\u2713 Patched ${configPath}`,
1714
1875
  backupPath ? ` Backup saved to ${backupPath}` : " (created a new config file)",
1715
1876
  "",
1716
- "Restart Claude Desktop to load seekstone."
1877
+ restartHint
1717
1878
  );
1718
1879
  return { ok: true, exitCode: 0, output: out, wrotePath: configPath, backupPath };
1719
1880
  }
@@ -1928,7 +2089,7 @@ function startWatcher(ctx2, log2, opts) {
1928
2089
  }
1929
2090
 
1930
2091
  // src/index.ts
1931
- var VERSION = true ? "0.7.2" : "0.0.0-dev";
2092
+ var VERSION = true ? "0.9.0" : "0.0.0-dev";
1932
2093
  var intent = parseCliIntent(process.argv.slice(2));
1933
2094
  if (intent === "version") {
1934
2095
  process.stdout.write(`${VERSION}
@@ -1944,7 +2105,8 @@ if (intent === "init") {
1944
2105
  const result = await runInit(parseInitArgs(process.argv.slice(3)), {
1945
2106
  env: process.env,
1946
2107
  platform: process.platform,
1947
- timestamp: (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")
2108
+ timestamp: (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"),
2109
+ cwd: process.cwd()
1948
2110
  });
1949
2111
  process.stdout.write(`${result.output.join("\n")}
1950
2112
  `);
@@ -1980,6 +2142,63 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1980
2142
  required: ["query"]
1981
2143
  }
1982
2144
  },
2145
+ {
2146
+ name: "query_notes",
2147
+ description: "Structured metadata query \u2014 filter notes by frontmatter key/value predicates, tag, folder, modified time, and size. Returns compact rows (path + title by default; opt into more via select), not note content. Use this instead of search when filtering by properties rather than text.",
2148
+ inputSchema: {
2149
+ type: "object",
2150
+ properties: {
2151
+ where: {
2152
+ type: "array",
2153
+ description: "Frontmatter predicates \u2014 all must match (AND).",
2154
+ items: {
2155
+ type: "object",
2156
+ properties: {
2157
+ key: { type: "string", description: "Frontmatter key to test." },
2158
+ op: {
2159
+ type: "string",
2160
+ enum: ["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"],
2161
+ description: "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)."
2162
+ },
2163
+ value: {
2164
+ description: "Comparison value. Required for every op except exists/missing."
2165
+ }
2166
+ },
2167
+ required: ["key", "op"]
2168
+ }
2169
+ },
2170
+ folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
2171
+ tag: { type: "string", description: "Restrict to notes with this tag (# optional)." },
2172
+ modifiedAfter: {
2173
+ type: "string",
2174
+ description: "Only notes modified at or after this ISO 8601 date/time."
2175
+ },
2176
+ modifiedBefore: {
2177
+ type: "string",
2178
+ description: "Only notes modified before this ISO 8601 date/time."
2179
+ },
2180
+ minSizeBytes: { type: "number", description: "Only notes at least this many bytes." },
2181
+ maxSizeBytes: { type: "number", description: "Only notes at most this many bytes." },
2182
+ select: {
2183
+ type: "array",
2184
+ items: { type: "string" },
2185
+ description: 'Extra fields per hit: frontmatter keys, or "mtime", "size", "tags". Default returns only path + title.'
2186
+ },
2187
+ sort: {
2188
+ type: "string",
2189
+ enum: ["path", "title", "mtime", "size"],
2190
+ description: "Sort field (default path)."
2191
+ },
2192
+ order: {
2193
+ type: "string",
2194
+ enum: ["asc", "desc"],
2195
+ description: "Sort order (default asc)."
2196
+ },
2197
+ limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
2198
+ },
2199
+ required: []
2200
+ }
2201
+ },
1983
2202
  {
1984
2203
  name: "read_note",
1985
2204
  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, and total note size so payload savings are measurable. Use search or outline_note first to find the right path and section names.",
@@ -2301,7 +2520,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
2301
2520
  });
2302
2521
  var transport = new StdioServerTransport();
2303
2522
  await server.connect(transport);
2304
- log.info("ready", { tools: 16, transport: "stdio" });
2523
+ log.info("ready", { tools: 17, transport: "stdio" });
2305
2524
  process.stderr.write(
2306
2525
  `seekstone: add to Claude Desktop:
2307
2526
  ${JSON.stringify(