seekstone 0.7.1 → 0.8.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 +316 -111
- package/dist/index.js.map +1 -1
- package/package.json +8 -2
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
|
|
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") opts.client = v;
|
|
1469
1612
|
}
|
|
1470
1613
|
}
|
|
1471
1614
|
return opts;
|
|
@@ -1493,6 +1636,10 @@ 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
|
+
}
|
|
1496
1643
|
function obsidianRegistryPath(platform, env) {
|
|
1497
1644
|
if (platform === "win32") {
|
|
1498
1645
|
const j2 = path.win32.join;
|
|
@@ -1662,7 +1809,8 @@ async function runInit(opts, deps) {
|
|
|
1662
1809
|
out.push("Add to Claude Code by running:", "", ` ${mcpAddCommand(vaultPath)}`, "");
|
|
1663
1810
|
return { ok: true, exitCode: 0, output: out };
|
|
1664
1811
|
}
|
|
1665
|
-
const
|
|
1812
|
+
const clientLabel = opts.client === "cursor" ? "Cursor" : "Claude Desktop";
|
|
1813
|
+
const configPath = opts.client === "cursor" ? cursorConfigPath(deps.platform, { home: deps.env.HOME }) : claudeDesktopConfigPath(deps.platform, {
|
|
1666
1814
|
home: deps.env.HOME,
|
|
1667
1815
|
appData: deps.env.APPDATA
|
|
1668
1816
|
});
|
|
@@ -1673,12 +1821,12 @@ async function runInit(opts, deps) {
|
|
|
1673
1821
|
);
|
|
1674
1822
|
if (!opts.write) {
|
|
1675
1823
|
out.push(
|
|
1676
|
-
|
|
1824
|
+
`Add this to your ${clientLabel} config:`,
|
|
1677
1825
|
` ${configPath}`,
|
|
1678
1826
|
"",
|
|
1679
1827
|
block,
|
|
1680
1828
|
"",
|
|
1681
|
-
|
|
1829
|
+
`Then restart ${clientLabel}. Or re-run with --write to patch it automatically.`
|
|
1682
1830
|
);
|
|
1683
1831
|
return { ok: true, exitCode: 0, output: out };
|
|
1684
1832
|
}
|
|
@@ -1713,7 +1861,7 @@ async function runInit(opts, deps) {
|
|
|
1713
1861
|
`\u2713 Patched ${configPath}`,
|
|
1714
1862
|
backupPath ? ` Backup saved to ${backupPath}` : " (created a new config file)",
|
|
1715
1863
|
"",
|
|
1716
|
-
|
|
1864
|
+
`Restart ${clientLabel} to load seekstone.`
|
|
1717
1865
|
);
|
|
1718
1866
|
return { ok: true, exitCode: 0, output: out, wrotePath: configPath, backupPath };
|
|
1719
1867
|
}
|
|
@@ -1928,7 +2076,7 @@ function startWatcher(ctx2, log2, opts) {
|
|
|
1928
2076
|
}
|
|
1929
2077
|
|
|
1930
2078
|
// src/index.ts
|
|
1931
|
-
var VERSION = true ? "0.
|
|
2079
|
+
var VERSION = true ? "0.8.0" : "0.0.0-dev";
|
|
1932
2080
|
var intent = parseCliIntent(process.argv.slice(2));
|
|
1933
2081
|
if (intent === "version") {
|
|
1934
2082
|
process.stdout.write(`${VERSION}
|
|
@@ -1980,6 +2128,63 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1980
2128
|
required: ["query"]
|
|
1981
2129
|
}
|
|
1982
2130
|
},
|
|
2131
|
+
{
|
|
2132
|
+
name: "query_notes",
|
|
2133
|
+
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.",
|
|
2134
|
+
inputSchema: {
|
|
2135
|
+
type: "object",
|
|
2136
|
+
properties: {
|
|
2137
|
+
where: {
|
|
2138
|
+
type: "array",
|
|
2139
|
+
description: "Frontmatter predicates \u2014 all must match (AND).",
|
|
2140
|
+
items: {
|
|
2141
|
+
type: "object",
|
|
2142
|
+
properties: {
|
|
2143
|
+
key: { type: "string", description: "Frontmatter key to test." },
|
|
2144
|
+
op: {
|
|
2145
|
+
type: "string",
|
|
2146
|
+
enum: ["eq", "ne", "contains", "exists", "missing", "gt", "gte", "lt", "lte"],
|
|
2147
|
+
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)."
|
|
2148
|
+
},
|
|
2149
|
+
value: {
|
|
2150
|
+
description: "Comparison value. Required for every op except exists/missing."
|
|
2151
|
+
}
|
|
2152
|
+
},
|
|
2153
|
+
required: ["key", "op"]
|
|
2154
|
+
}
|
|
2155
|
+
},
|
|
2156
|
+
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
2157
|
+
tag: { type: "string", description: "Restrict to notes with this tag (# optional)." },
|
|
2158
|
+
modifiedAfter: {
|
|
2159
|
+
type: "string",
|
|
2160
|
+
description: "Only notes modified at or after this ISO 8601 date/time."
|
|
2161
|
+
},
|
|
2162
|
+
modifiedBefore: {
|
|
2163
|
+
type: "string",
|
|
2164
|
+
description: "Only notes modified before this ISO 8601 date/time."
|
|
2165
|
+
},
|
|
2166
|
+
minSizeBytes: { type: "number", description: "Only notes at least this many bytes." },
|
|
2167
|
+
maxSizeBytes: { type: "number", description: "Only notes at most this many bytes." },
|
|
2168
|
+
select: {
|
|
2169
|
+
type: "array",
|
|
2170
|
+
items: { type: "string" },
|
|
2171
|
+
description: 'Extra fields per hit: frontmatter keys, or "mtime", "size", "tags". Default returns only path + title.'
|
|
2172
|
+
},
|
|
2173
|
+
sort: {
|
|
2174
|
+
type: "string",
|
|
2175
|
+
enum: ["path", "title", "mtime", "size"],
|
|
2176
|
+
description: "Sort field (default path)."
|
|
2177
|
+
},
|
|
2178
|
+
order: {
|
|
2179
|
+
type: "string",
|
|
2180
|
+
enum: ["asc", "desc"],
|
|
2181
|
+
description: "Sort order (default asc)."
|
|
2182
|
+
},
|
|
2183
|
+
limit: { type: "number", description: "Max results (1\u2013500, default 100)." }
|
|
2184
|
+
},
|
|
2185
|
+
required: []
|
|
2186
|
+
}
|
|
2187
|
+
},
|
|
1983
2188
|
{
|
|
1984
2189
|
name: "read_note",
|
|
1985
2190
|
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 +2506,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
2301
2506
|
});
|
|
2302
2507
|
var transport = new StdioServerTransport();
|
|
2303
2508
|
await server.connect(transport);
|
|
2304
|
-
log.info("ready", { tools:
|
|
2509
|
+
log.info("ready", { tools: 17, transport: "stdio" });
|
|
2305
2510
|
process.stderr.write(
|
|
2306
2511
|
`seekstone: add to Claude Desktop:
|
|
2307
2512
|
${JSON.stringify(
|