@jacklika/dsh-memory-mcp 0.1.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @jacklika/dsh-memory-mcp
2
2
 
3
- Zero-dependency 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.
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
- 零依赖的 MCP stdio 服务器,把 memory vault 暴露给任意 MCP 客户端(Claude Code、Cursor、Obsidian 桥、`dsh-mcp-client`)。单一进程持有所有写操作,因此客户端通过服务器串行化,而不是自己协商文件系统锁——这是 memory 套件的“单写者”部署形态。
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": "Zero-dependency MCP stdio server exposing the memory vault to any MCP client — one process owns all writes.",
4
- "version": "0.1.3",
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
  },
@@ -30,6 +30,7 @@
30
30
  "README.zh.md"
31
31
  ],
32
32
  "dependencies": {
33
+ "js-yaml": "^4.2.0",
33
34
  "@jacklika/dsh-memory-time": "^0.1.0-alpha.0"
34
35
  },
35
36
  "license": "MIT"
package/src/server.mjs CHANGED
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @jacklika/dsh-memory-mcp — a zero-dependency MCP stdio server exposing the
4
- * memory vault to any MCP client. One process owns all writes, so clients
5
- * serialize through the server instead of negotiating filesystem locks.
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 { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
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
15
  import { formatBeijingTime } from '@jacklika/dsh-memory-time'
16
+ import yaml from 'js-yaml'
16
17
 
17
18
  const PROTOCOL_VERSION = '2024-11-05'
18
19
  const SERVER_INFO = { name: 'dsh-memory-mcp', version: '0.1.0' }
@@ -66,16 +67,26 @@ async function containedPathReal(candidate) {
66
67
  }
67
68
  }
68
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.
69
73
  function splitFrontmatter(text) {
70
- if (!text.startsWith('---\n')) return { frontmatter: {}, body: text }
71
- const end = text.indexOf('\n---\n', 4)
72
- if (end < 0) return { frontmatter: {}, body: text }
73
- const frontmatter = {}
74
- for (const line of text.slice(4, end).split('\n')) {
75
- const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
76
- if (m) frontmatter[m[1]] = m[2]
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 }
77
89
  }
78
- return { frontmatter, body: text.slice(end + 5) }
79
90
  }
80
91
 
81
92
  function noteVersion(text) {
@@ -84,10 +95,61 @@ function noteVersion(text) {
84
95
 
85
96
  function extractLinks(text) {
86
97
  const links = []
87
- 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())
88
99
  return links
89
100
  }
90
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
+
91
153
  async function listNotePaths(dir = VAULT) {
92
154
  const results = []
93
155
  let entries
@@ -105,7 +167,66 @@ async function listNotePaths(dir = VAULT) {
105
167
  return results
106
168
  }
107
169
 
108
- async function resolveLinkTarget(link) {
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
+ }
109
230
  for (const candidate of [link, ...EXTENSIONS.map(ext => `${link}${ext}`)]) {
110
231
  try {
111
232
  const p = await containedPathReal(candidate)
@@ -115,15 +236,10 @@ async function resolveLinkTarget(link) {
115
236
  if (error?.code !== 'ENOENT') throw error
116
237
  }
117
238
  }
118
- // search by basename among indexed notes
119
- const want = basename(link).replace(/\.[^.]+$/, '')
120
- for (const p of await listNotePaths()) {
121
- if (basename(p).replace(/\.[^.]+$/, '') === want) return p
122
- }
123
239
  return undefined
124
240
  }
125
241
 
126
- async function readNote(absolutePath, depth, visited = new Set()) {
242
+ async function readNote(absolutePath, depth, visited = new Set(), resolve) {
127
243
  const id = vaultRelativeId(absolutePath)
128
244
  if (visited.has(absolutePath)) {
129
245
  return { id, frontmatter: {}, body: '', links: [], version: '', linkedNotes: [] }
@@ -135,9 +251,10 @@ async function readNote(absolutePath, depth, visited = new Set()) {
135
251
  const links = extractLinks(text)
136
252
  const linkedNotes = []
137
253
  if (depth > 0) {
254
+ resolve ??= await vaultLinkResolver()
138
255
  for (const link of links) {
139
- const target = await resolveLinkTarget(link)
140
- 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))
141
258
  }
142
259
  }
143
260
  return { id, frontmatter, body, links, version: noteVersion(text), linkedNotes }
@@ -157,7 +274,7 @@ async function searchNotes(query, maxResults = 20) {
157
274
  const haystack = `${n.id} ${n.title} ${n.body}`.toLowerCase()
158
275
  return terms.every(t => haystack.includes(t))
159
276
  })
160
- hits.sort((a, b) => a.id.localeCompare(b.id))
277
+ hits.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
161
278
  return hits.slice(0, maxResults).map(({ id, title }) => ({ id, title }))
162
279
  }
163
280
 
@@ -182,7 +299,7 @@ async function writeNote(id, content, mode = 'append', baseVersion) {
182
299
  } else {
183
300
  const { frontmatter, body } = splitFrontmatter(existing ?? '')
184
301
  const fm = Object.keys(frontmatter).length > 0
185
- ? `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n\n`
302
+ ? `---\n${yaml.dump(frontmatter).trimEnd()}\n---\n\n`
186
303
  : ''
187
304
  finalBody = `${fm}${body}\n\n## ${formatBeijingTime(new Date())}\n\n${content}\n`
188
305
  }
@@ -204,6 +321,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
204
321
  return /^#\s+(.+)$/m.exec(body)?.[1]?.trim() ?? idOf(p).replace(/\.[^.]+$/, '')
205
322
  }
206
323
  let paths
324
+ const resolve = await vaultLinkResolver()
207
325
  if (id === undefined) {
208
326
  paths = await listNotePaths()
209
327
  } else {
@@ -214,7 +332,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
214
332
  const next = []
215
333
  for (const p of frontier) {
216
334
  for (const link of extractLinks(await readFile(p, 'utf8'))) {
217
- const t = await resolveLinkTarget(link)
335
+ const t = await resolveLinkTarget(link, resolve)
218
336
  if (t !== undefined && !seen.has(idOf(t))) { seen.set(idOf(t), t); next.push(t) }
219
337
  }
220
338
  }
@@ -230,7 +348,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
230
348
  for (const p of capped) {
231
349
  nodes.push({ id: idOf(p), title: await title(p) })
232
350
  for (const link of extractLinks(await readFile(p, 'utf8'))) {
233
- const t = await resolveLinkTarget(link)
351
+ const t = await resolveLinkTarget(link, resolve)
234
352
  if (t !== undefined && idSet.has(idOf(t))) edges.push({ from: idOf(p), to: idOf(t) })
235
353
  }
236
354
  }