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 +22 -0
- package/dist/index.js +337 -118
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
+
[](https://github.com/shaqmughal/seekstone/releases/latest/download/seekstone.mcpb) [](https://cursor.com/install-mcp?name=seekstone&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsInNlZWtzdG9uZSJdLCJlbnYiOnsiU0VFS1NUT05FX1ZBVUxUIjoiL2Fic29sdXRlL3BhdGgvdG8veW91ci92YXVsdCJ9fQ%3D%3D) [](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
|
|
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
|
|
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
|
|
904
|
-
var ReadNoteInput =
|
|
905
|
-
path:
|
|
906
|
-
section:
|
|
907
|
-
block:
|
|
908
|
-
lines:
|
|
909
|
-
from:
|
|
910
|
-
to:
|
|
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:
|
|
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
|
|
1006
|
-
var ReplaceInNoteInput =
|
|
1007
|
-
path:
|
|
1008
|
-
find:
|
|
1009
|
-
replace:
|
|
1010
|
-
regex:
|
|
1011
|
-
caseSensitive:
|
|
1012
|
-
wholeWord:
|
|
1013
|
-
limit:
|
|
1014
|
-
dryRun:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
2523
|
+
log.info("ready", { tools: 17, transport: "stdio" });
|
|
2305
2524
|
process.stderr.write(
|
|
2306
2525
|
`seekstone: add to Claude Desktop:
|
|
2307
2526
|
${JSON.stringify(
|