@jacklika/dsh-memory-mcp 0.1.2 → 0.1.4-alpha.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 +1 -1
- package/README.zh.md +1 -1
- package/package.json +6 -2
- package/src/server.mjs +146 -27
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @jacklika/dsh-memory-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP stdio server that exposes the memory vault to any MCP client (Claude Code, Cursor, Obsidian bridges, `dsh-mcp-client`). One process owns every write, so clients serialize through the server instead of negotiating filesystem locks — this is the "single writer" deployment of the memory suite.
|
|
4
4
|
|
|
5
5
|
## Run
|
|
6
6
|
|
package/README.zh.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @jacklika/dsh-memory-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP stdio 服务器,把 memory vault 暴露给任意 MCP 客户端(Claude Code、Cursor、Obsidian 桥、`dsh-mcp-client`)。单一进程持有所有写操作,因此客户端通过服务器串行化,而不是自己协商文件系统锁——这是 memory 套件的“单写者”部署形态。
|
|
4
4
|
|
|
5
5
|
## 运行
|
|
6
6
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacklika/dsh-memory-mcp",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "0.1.
|
|
3
|
+
"description": "MCP stdio server exposing the memory vault to any MCP client — one process owns all writes.",
|
|
4
|
+
"version": "0.1.4-alpha.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -29,5 +29,9 @@
|
|
|
29
29
|
"README.md",
|
|
30
30
|
"README.zh.md"
|
|
31
31
|
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"js-yaml": "^4.2.0",
|
|
34
|
+
"@jacklika/dsh-memory-time": "^0.1.0-alpha.0"
|
|
35
|
+
},
|
|
32
36
|
"license": "MIT"
|
|
33
37
|
}
|
package/src/server.mjs
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* @jacklika/dsh-memory-mcp —
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* @jacklika/dsh-memory-mcp — an MCP stdio server exposing the memory vault to
|
|
4
|
+
* any MCP client. One process owns all writes, so clients serialize through
|
|
5
|
+
* the server instead of negotiating filesystem locks.
|
|
6
6
|
*
|
|
7
7
|
* Usage: dsh-memory-mcp [--vault <path>] [--max-link-depth N]
|
|
8
8
|
* --vault defaults to <cwd>/.plugins/memory/ to match the Bundle default.
|
|
9
9
|
*/
|
|
10
10
|
import { createHash, randomUUID } from 'node:crypto'
|
|
11
11
|
import { mkdir, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
12
|
-
import {
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
13
13
|
import process from 'node:process'
|
|
14
14
|
import readline from 'node:readline'
|
|
15
|
+
import { formatBeijingTime } from '@jacklika/dsh-memory-time'
|
|
16
|
+
import yaml from 'js-yaml'
|
|
15
17
|
|
|
16
18
|
const PROTOCOL_VERSION = '2024-11-05'
|
|
17
19
|
const SERVER_INFO = { name: 'dsh-memory-mcp', version: '0.1.0' }
|
|
@@ -65,16 +67,26 @@ async function containedPathReal(candidate) {
|
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
// Mirrors splitFrontmatter in @jacklika/dsh-tool-memory-filesystem so notes
|
|
71
|
+
// written by external editors (CRLF, unquoted timestamps, YAML lists) read
|
|
72
|
+
// the same through MCP as through the dsh tools.
|
|
68
73
|
function splitFrontmatter(text) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
74
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(text)
|
|
75
|
+
if (match === null) return { frontmatter: {}, body: text }
|
|
76
|
+
const frontmatterText = match[1] ?? ''
|
|
77
|
+
const bodyText = match[2] ?? ''
|
|
78
|
+
try {
|
|
79
|
+
const parsed = yaml.load(frontmatterText)
|
|
80
|
+
const frontmatter = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
81
|
+
? parsed
|
|
82
|
+
: {}
|
|
83
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
84
|
+
if (value instanceof Date) frontmatter[key] = formatBeijingTime(value)
|
|
85
|
+
}
|
|
86
|
+
return { frontmatter, body: bodyText }
|
|
87
|
+
} catch {
|
|
88
|
+
return { frontmatter: {}, body: text }
|
|
76
89
|
}
|
|
77
|
-
return { frontmatter, body: text.slice(end + 5) }
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
function noteVersion(text) {
|
|
@@ -83,10 +95,61 @@ function noteVersion(text) {
|
|
|
83
95
|
|
|
84
96
|
function extractLinks(text) {
|
|
85
97
|
const links = []
|
|
86
|
-
for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) links.push(m[1].trim())
|
|
98
|
+
for (const m of stripCode(text).matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) links.push(m[1].trim())
|
|
87
99
|
return links
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Remove fenced code blocks and inline code spans so literal `[[...]]` inside
|
|
104
|
+
* them is not treated as a link. Fences follow CommonMark: a closing fence
|
|
105
|
+
* must use the same marker character and be at least as long as the opener —
|
|
106
|
+
* a rule a regex backreference cannot express, so fences are scanned line by
|
|
107
|
+
* line. Inline spans treat backtick runs as maximal units: a run closes only
|
|
108
|
+
* at a later run of exactly equal length; an unmatched run is literal text.
|
|
109
|
+
*/
|
|
110
|
+
function stripCode(text) {
|
|
111
|
+
const kept = []
|
|
112
|
+
let fenceChar = ''
|
|
113
|
+
let fenceLen = 0
|
|
114
|
+
for (const line of text.split('\n')) {
|
|
115
|
+
if (fenceChar === '') {
|
|
116
|
+
const open = /^( {0,3})(`{3,}|~{3,})/.exec(line)
|
|
117
|
+
if (open?.[2] !== undefined) {
|
|
118
|
+
fenceChar = open[2][0] ?? ''
|
|
119
|
+
fenceLen = open[2].length
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
kept.push(line)
|
|
123
|
+
} else if (new RegExp(`^ {0,3}${fenceChar === '`' ? '`' : '~'}{${fenceLen},}[ \t]*$`).test(line)) {
|
|
124
|
+
fenceChar = ''
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const body = kept.join('\n')
|
|
128
|
+
let out = ''
|
|
129
|
+
let i = 0
|
|
130
|
+
while (i < body.length) {
|
|
131
|
+
if (body[i] !== '`') {
|
|
132
|
+
out += body[i]
|
|
133
|
+
i++
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
let j = i
|
|
137
|
+
while (body[j] === '`') j++
|
|
138
|
+
const run = body.slice(i, j)
|
|
139
|
+
let close = body.indexOf(run, j)
|
|
140
|
+
while (close !== -1 && (body[close - 1] === '`' || body[close + run.length] === '`')) {
|
|
141
|
+
close = body.indexOf(run, close + 1)
|
|
142
|
+
}
|
|
143
|
+
if (close === -1) {
|
|
144
|
+
out += run
|
|
145
|
+
i = j
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
i = close + run.length
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
90
153
|
async function listNotePaths(dir = VAULT) {
|
|
91
154
|
const results = []
|
|
92
155
|
let entries
|
|
@@ -104,7 +167,66 @@ async function listNotePaths(dir = VAULT) {
|
|
|
104
167
|
return results
|
|
105
168
|
}
|
|
106
169
|
|
|
107
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Build a resolver over the known note ids, matching Obsidian semantics: an
|
|
172
|
+
* exact vault-relative path first, then a path-suffix match, then a bare
|
|
173
|
+
* basename match — each tier tolerates the target carrying or omitting the
|
|
174
|
+
* extension. Ambiguity within a tier resolves to the id with the fewest path
|
|
175
|
+
* segments, ties by code-unit order, so results are identical across hosts.
|
|
176
|
+
*/
|
|
177
|
+
function createLinkResolver(ids) {
|
|
178
|
+
const entries = ids.map(id => {
|
|
179
|
+
const stem = id.replace(/\.[^./]+$/, '')
|
|
180
|
+
return {
|
|
181
|
+
id,
|
|
182
|
+
stem,
|
|
183
|
+
basenameId: id.slice(id.lastIndexOf('/') + 1),
|
|
184
|
+
basenameStem: stem.slice(stem.lastIndexOf('/') + 1),
|
|
185
|
+
segments: id.split('/').length,
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
// O(1) lookup tables keyed by every segment-boundary suffix of each id and
|
|
189
|
+
// stem: an exact hit is the full-string key, a path-suffix hit is a proper
|
|
190
|
+
// suffix, and a basename hit is the last segment.
|
|
191
|
+
const exact = new Map()
|
|
192
|
+
const suffix = new Map()
|
|
193
|
+
const basename = new Map()
|
|
194
|
+
const add = (table, key, entry) => {
|
|
195
|
+
const list = table.get(key)
|
|
196
|
+
if (list === undefined) table.set(key, [entry])
|
|
197
|
+
else list.push(entry)
|
|
198
|
+
}
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
add(basename, entry.basenameId, entry)
|
|
201
|
+
add(basename, entry.basenameStem, entry)
|
|
202
|
+
for (const s of [entry.id, entry.stem]) {
|
|
203
|
+
let boundary = -1
|
|
204
|
+
while ((boundary = s.indexOf('/', boundary + 1)) !== -1) {
|
|
205
|
+
add(suffix, s.slice(boundary + 1), entry)
|
|
206
|
+
}
|
|
207
|
+
add(exact, s, entry)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const pick = matches => {
|
|
211
|
+
if (matches === undefined || matches.length === 0) return undefined
|
|
212
|
+
matches.sort((a, b) => a.segments - b.segments || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
213
|
+
return matches[0].id
|
|
214
|
+
}
|
|
215
|
+
return target => {
|
|
216
|
+
const t = target.replace(/\\/g, '/')
|
|
217
|
+
return pick(exact.get(t)) ?? pick(suffix.get(t)) ?? pick(basename.get(t))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function vaultLinkResolver() {
|
|
222
|
+
return createLinkResolver((await listNotePaths()).map(p => vaultRelativeId(p)))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function resolveLinkTarget(link, resolve) {
|
|
226
|
+
if (resolve !== undefined) {
|
|
227
|
+
const id = resolve(link)
|
|
228
|
+
if (id !== undefined) return containedPathReal(id)
|
|
229
|
+
}
|
|
108
230
|
for (const candidate of [link, ...EXTENSIONS.map(ext => `${link}${ext}`)]) {
|
|
109
231
|
try {
|
|
110
232
|
const p = await containedPathReal(candidate)
|
|
@@ -114,15 +236,10 @@ async function resolveLinkTarget(link) {
|
|
|
114
236
|
if (error?.code !== 'ENOENT') throw error
|
|
115
237
|
}
|
|
116
238
|
}
|
|
117
|
-
// search by basename among indexed notes
|
|
118
|
-
const want = basename(link).replace(/\.[^.]+$/, '')
|
|
119
|
-
for (const p of await listNotePaths()) {
|
|
120
|
-
if (basename(p).replace(/\.[^.]+$/, '') === want) return p
|
|
121
|
-
}
|
|
122
239
|
return undefined
|
|
123
240
|
}
|
|
124
241
|
|
|
125
|
-
async function readNote(absolutePath, depth, visited = new Set()) {
|
|
242
|
+
async function readNote(absolutePath, depth, visited = new Set(), resolve) {
|
|
126
243
|
const id = vaultRelativeId(absolutePath)
|
|
127
244
|
if (visited.has(absolutePath)) {
|
|
128
245
|
return { id, frontmatter: {}, body: '', links: [], version: '', linkedNotes: [] }
|
|
@@ -134,9 +251,10 @@ async function readNote(absolutePath, depth, visited = new Set()) {
|
|
|
134
251
|
const links = extractLinks(text)
|
|
135
252
|
const linkedNotes = []
|
|
136
253
|
if (depth > 0) {
|
|
254
|
+
resolve ??= await vaultLinkResolver()
|
|
137
255
|
for (const link of links) {
|
|
138
|
-
const target = await resolveLinkTarget(link)
|
|
139
|
-
if (target !== undefined) linkedNotes.push(await readNote(target, depth - 1, next))
|
|
256
|
+
const target = await resolveLinkTarget(link, resolve)
|
|
257
|
+
if (target !== undefined) linkedNotes.push(await readNote(target, depth - 1, next, resolve))
|
|
140
258
|
}
|
|
141
259
|
}
|
|
142
260
|
return { id, frontmatter, body, links, version: noteVersion(text), linkedNotes }
|
|
@@ -156,7 +274,7 @@ async function searchNotes(query, maxResults = 20) {
|
|
|
156
274
|
const haystack = `${n.id} ${n.title} ${n.body}`.toLowerCase()
|
|
157
275
|
return terms.every(t => haystack.includes(t))
|
|
158
276
|
})
|
|
159
|
-
hits.sort((a, b) => a.id.
|
|
277
|
+
hits.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
160
278
|
return hits.slice(0, maxResults).map(({ id, title }) => ({ id, title }))
|
|
161
279
|
}
|
|
162
280
|
|
|
@@ -181,9 +299,9 @@ async function writeNote(id, content, mode = 'append', baseVersion) {
|
|
|
181
299
|
} else {
|
|
182
300
|
const { frontmatter, body } = splitFrontmatter(existing ?? '')
|
|
183
301
|
const fm = Object.keys(frontmatter).length > 0
|
|
184
|
-
? `---\n${
|
|
302
|
+
? `---\n${yaml.dump(frontmatter).trimEnd()}\n---\n\n`
|
|
185
303
|
: ''
|
|
186
|
-
finalBody = `${fm}${body}\n\n## ${new Date()
|
|
304
|
+
finalBody = `${fm}${body}\n\n## ${formatBeijingTime(new Date())}\n\n${content}\n`
|
|
187
305
|
}
|
|
188
306
|
const tmp = `${absolutePath}.tmp-${process.pid}-${randomUUID()}`
|
|
189
307
|
try {
|
|
@@ -203,6 +321,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
|
|
|
203
321
|
return /^#\s+(.+)$/m.exec(body)?.[1]?.trim() ?? idOf(p).replace(/\.[^.]+$/, '')
|
|
204
322
|
}
|
|
205
323
|
let paths
|
|
324
|
+
const resolve = await vaultLinkResolver()
|
|
206
325
|
if (id === undefined) {
|
|
207
326
|
paths = await listNotePaths()
|
|
208
327
|
} else {
|
|
@@ -213,7 +332,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
|
|
|
213
332
|
const next = []
|
|
214
333
|
for (const p of frontier) {
|
|
215
334
|
for (const link of extractLinks(await readFile(p, 'utf8'))) {
|
|
216
|
-
const t = await resolveLinkTarget(link)
|
|
335
|
+
const t = await resolveLinkTarget(link, resolve)
|
|
217
336
|
if (t !== undefined && !seen.has(idOf(t))) { seen.set(idOf(t), t); next.push(t) }
|
|
218
337
|
}
|
|
219
338
|
}
|
|
@@ -229,7 +348,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
|
|
|
229
348
|
for (const p of capped) {
|
|
230
349
|
nodes.push({ id: idOf(p), title: await title(p) })
|
|
231
350
|
for (const link of extractLinks(await readFile(p, 'utf8'))) {
|
|
232
|
-
const t = await resolveLinkTarget(link)
|
|
351
|
+
const t = await resolveLinkTarget(link, resolve)
|
|
233
352
|
if (t !== undefined && idSet.has(idOf(t))) edges.push({ from: idOf(p), to: idOf(t) })
|
|
234
353
|
}
|
|
235
354
|
}
|