@jacklika/dsh-memory-mcp 0.1.3 → 0.1.4-alpha.1

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
 
@@ -16,7 +16,7 @@ npx dsh-memory-mcp --vault /path/to/vault
16
16
 
17
17
  ## Surface
18
18
 
19
- Tools: `wiki_read`, `wiki_search`, `wiki_write`, `wiki_graph` — same contract as `@jacklika/dsh-tool-memory-filesystem` / `-graph`, including `baseVersion` optimistic concurrency on writes. Resources: `note:///<id>` over `resources/list` / `resources/read`.
19
+ Tools: `wiki_read`, `wiki_search`, `wiki_write`, `wiki_graph` — same contract as `@jacklika/dsh-tool-memory-filesystem` / `-graph`, including `baseVersion` optimistic concurrency on writes, `mtime` + `modifiedExternally` on reads, and `+08:00` `created`/`updated` normalization on writes. Resources: `note:///<id>` over `resources/list` / `resources/read`.
20
20
 
21
21
  ## Client configuration
22
22
 
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
 
@@ -16,7 +16,7 @@ npx dsh-memory-mcp --vault /path/to/vault
16
16
 
17
17
  ## 暴露面
18
18
 
19
- 工具:`wiki_read`、`wiki_search`、`wiki_write`、`wiki_graph` —— 与 `@jacklika/dsh-tool-memory-filesystem` / `-graph` 的契约相同,写操作带 `baseVersion` 乐观并发控制。资源:通过 `resources/list` / `resources/read` 访问 `note:///<id>`。
19
+ 工具:`wiki_read`、`wiki_search`、`wiki_write`、`wiki_graph` —— 与 `@jacklika/dsh-tool-memory-filesystem` / `-graph` 的契约相同,写操作带 `baseVersion` 乐观并发控制,读操作返回 `mtime` + `modifiedExternally`,写入归一化 `created`/`updated` 为 `+08:00`。资源:通过 `resources/list` / `resources/read` 访问 `note:///<id>`。
20
20
 
21
21
  ## 客户端配置
22
22
 
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.1",
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
- import { mkdir, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
12
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
11
+ import { mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
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,28 +67,128 @@ 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) {
82
93
  return createHash('sha1').update(text).digest('hex')
83
94
  }
84
95
 
96
+ // Mirrors the mtime tracking and timestamp normalization in
97
+ // @jacklika/dsh-tool-memory-filesystem so MCP clients see the same
98
+ // modifiedExternally flag and +08:00 write normalization as the dsh tools.
99
+ const observedMtimes = new Map()
100
+ const OBSERVED_MTIME_LIMIT = 256
101
+
102
+ function recordMtime(path, mtimeMs) {
103
+ observedMtimes.delete(path)
104
+ observedMtimes.set(path, mtimeMs)
105
+ if (observedMtimes.size > OBSERVED_MTIME_LIMIT) {
106
+ const oldest = observedMtimes.keys().next().value
107
+ if (oldest !== undefined) observedMtimes.delete(oldest)
108
+ }
109
+ }
110
+
111
+ function detectExternalChange(path, mtimeMs) {
112
+ const seen = observedMtimes.get(path)
113
+ recordMtime(path, mtimeMs)
114
+ return seen !== undefined && seen !== mtimeMs
115
+ }
116
+
117
+ function normalizeNoteTimestamps(frontmatter) {
118
+ for (const key of ['created', 'updated']) {
119
+ const value = frontmatter[key]
120
+ if (typeof value !== 'string') continue
121
+ const parsed = new Date(value)
122
+ if (Number.isNaN(parsed.getTime())) continue
123
+ const normalized = formatBeijingTime(parsed)
124
+ if (normalized !== value) frontmatter[key] = normalized
125
+ }
126
+ }
127
+
128
+ function normalizeTextTimestamps(text) {
129
+ const { frontmatter, body } = splitFrontmatter(text)
130
+ if (Object.keys(frontmatter).length === 0) return text
131
+ normalizeNoteTimestamps(frontmatter)
132
+ return `---\n${yaml.dump(frontmatter).trim()}\n---\n${body}`
133
+ }
134
+
85
135
  function extractLinks(text) {
86
136
  const links = []
87
- for (const m of text.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) links.push(m[1].trim())
137
+ for (const m of stripCode(text).matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) links.push(m[1].trim())
88
138
  return links
89
139
  }
90
140
 
141
+ /**
142
+ * Remove fenced code blocks and inline code spans so literal `[[...]]` inside
143
+ * them is not treated as a link. Fences follow CommonMark: a closing fence
144
+ * must use the same marker character and be at least as long as the opener —
145
+ * a rule a regex backreference cannot express, so fences are scanned line by
146
+ * line. Inline spans treat backtick runs as maximal units: a run closes only
147
+ * at a later run of exactly equal length; an unmatched run is literal text.
148
+ */
149
+ function stripCode(text) {
150
+ const kept = []
151
+ let fenceChar = ''
152
+ let fenceLen = 0
153
+ for (const line of text.split('\n')) {
154
+ if (fenceChar === '') {
155
+ const open = /^( {0,3})(`{3,}|~{3,})/.exec(line)
156
+ if (open?.[2] !== undefined) {
157
+ fenceChar = open[2][0] ?? ''
158
+ fenceLen = open[2].length
159
+ continue
160
+ }
161
+ kept.push(line)
162
+ } else if (new RegExp(`^ {0,3}${fenceChar === '`' ? '`' : '~'}{${fenceLen},}[ \t]*$`).test(line)) {
163
+ fenceChar = ''
164
+ }
165
+ }
166
+ const body = kept.join('\n')
167
+ let out = ''
168
+ let i = 0
169
+ while (i < body.length) {
170
+ if (body[i] !== '`') {
171
+ out += body[i]
172
+ i++
173
+ continue
174
+ }
175
+ let j = i
176
+ while (body[j] === '`') j++
177
+ const run = body.slice(i, j)
178
+ let close = body.indexOf(run, j)
179
+ while (close !== -1 && (body[close - 1] === '`' || body[close + run.length] === '`')) {
180
+ close = body.indexOf(run, close + 1)
181
+ }
182
+ if (close === -1) {
183
+ out += run
184
+ i = j
185
+ continue
186
+ }
187
+ i = close + run.length
188
+ }
189
+ return out
190
+ }
191
+
91
192
  async function listNotePaths(dir = VAULT) {
92
193
  const results = []
93
194
  let entries
@@ -105,7 +206,66 @@ async function listNotePaths(dir = VAULT) {
105
206
  return results
106
207
  }
107
208
 
108
- async function resolveLinkTarget(link) {
209
+ /**
210
+ * Build a resolver over the known note ids, matching Obsidian semantics: an
211
+ * exact vault-relative path first, then a path-suffix match, then a bare
212
+ * basename match — each tier tolerates the target carrying or omitting the
213
+ * extension. Ambiguity within a tier resolves to the id with the fewest path
214
+ * segments, ties by code-unit order, so results are identical across hosts.
215
+ */
216
+ function createLinkResolver(ids) {
217
+ const entries = ids.map(id => {
218
+ const stem = id.replace(/\.[^./]+$/, '')
219
+ return {
220
+ id,
221
+ stem,
222
+ basenameId: id.slice(id.lastIndexOf('/') + 1),
223
+ basenameStem: stem.slice(stem.lastIndexOf('/') + 1),
224
+ segments: id.split('/').length,
225
+ }
226
+ })
227
+ // O(1) lookup tables keyed by every segment-boundary suffix of each id and
228
+ // stem: an exact hit is the full-string key, a path-suffix hit is a proper
229
+ // suffix, and a basename hit is the last segment.
230
+ const exact = new Map()
231
+ const suffix = new Map()
232
+ const basename = new Map()
233
+ const add = (table, key, entry) => {
234
+ const list = table.get(key)
235
+ if (list === undefined) table.set(key, [entry])
236
+ else list.push(entry)
237
+ }
238
+ for (const entry of entries) {
239
+ add(basename, entry.basenameId, entry)
240
+ add(basename, entry.basenameStem, entry)
241
+ for (const s of [entry.id, entry.stem]) {
242
+ let boundary = -1
243
+ while ((boundary = s.indexOf('/', boundary + 1)) !== -1) {
244
+ add(suffix, s.slice(boundary + 1), entry)
245
+ }
246
+ add(exact, s, entry)
247
+ }
248
+ }
249
+ const pick = matches => {
250
+ if (matches === undefined || matches.length === 0) return undefined
251
+ matches.sort((a, b) => a.segments - b.segments || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
252
+ return matches[0].id
253
+ }
254
+ return target => {
255
+ const t = target.replace(/\\/g, '/')
256
+ return pick(exact.get(t)) ?? pick(suffix.get(t)) ?? pick(basename.get(t))
257
+ }
258
+ }
259
+
260
+ async function vaultLinkResolver() {
261
+ return createLinkResolver((await listNotePaths()).map(p => vaultRelativeId(p)))
262
+ }
263
+
264
+ async function resolveLinkTarget(link, resolve) {
265
+ if (resolve !== undefined) {
266
+ const id = resolve(link)
267
+ if (id !== undefined) return containedPathReal(id)
268
+ }
109
269
  for (const candidate of [link, ...EXTENSIONS.map(ext => `${link}${ext}`)]) {
110
270
  try {
111
271
  const p = await containedPathReal(candidate)
@@ -115,15 +275,10 @@ async function resolveLinkTarget(link) {
115
275
  if (error?.code !== 'ENOENT') throw error
116
276
  }
117
277
  }
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
278
  return undefined
124
279
  }
125
280
 
126
- async function readNote(absolutePath, depth, visited = new Set()) {
281
+ async function readNote(absolutePath, depth, visited = new Set(), resolve) {
127
282
  const id = vaultRelativeId(absolutePath)
128
283
  if (visited.has(absolutePath)) {
129
284
  return { id, frontmatter: {}, body: '', links: [], version: '', linkedNotes: [] }
@@ -135,9 +290,10 @@ async function readNote(absolutePath, depth, visited = new Set()) {
135
290
  const links = extractLinks(text)
136
291
  const linkedNotes = []
137
292
  if (depth > 0) {
293
+ resolve ??= await vaultLinkResolver()
138
294
  for (const link of links) {
139
- const target = await resolveLinkTarget(link)
140
- if (target !== undefined) linkedNotes.push(await readNote(target, depth - 1, next))
295
+ const target = await resolveLinkTarget(link, resolve)
296
+ if (target !== undefined) linkedNotes.push(await readNote(target, depth - 1, next, resolve))
141
297
  }
142
298
  }
143
299
  return { id, frontmatter, body, links, version: noteVersion(text), linkedNotes }
@@ -157,7 +313,7 @@ async function searchNotes(query, maxResults = 20) {
157
313
  const haystack = `${n.id} ${n.title} ${n.body}`.toLowerCase()
158
314
  return terms.every(t => haystack.includes(t))
159
315
  })
160
- hits.sort((a, b) => a.id.localeCompare(b.id))
316
+ hits.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
161
317
  return hits.slice(0, maxResults).map(({ id, title }) => ({ id, title }))
162
318
  }
163
319
 
@@ -178,11 +334,12 @@ async function writeNote(id, content, mode = 'append', baseVersion) {
178
334
  }
179
335
  let finalBody
180
336
  if (mode === 'overwrite') {
181
- finalBody = content
337
+ finalBody = normalizeTextTimestamps(content)
182
338
  } else {
183
339
  const { frontmatter, body } = splitFrontmatter(existing ?? '')
340
+ normalizeNoteTimestamps(frontmatter)
184
341
  const fm = Object.keys(frontmatter).length > 0
185
- ? `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n\n`
342
+ ? `---\n${yaml.dump(frontmatter).trimEnd()}\n---\n\n`
186
343
  : ''
187
344
  finalBody = `${fm}${body}\n\n## ${formatBeijingTime(new Date())}\n\n${content}\n`
188
345
  }
@@ -194,6 +351,7 @@ async function writeNote(id, content, mode = 'append', baseVersion) {
194
351
  await rm(tmp, { force: true }).catch(() => undefined)
195
352
  throw error
196
353
  }
354
+ recordMtime(absolutePath, (await stat(absolutePath)).mtimeMs)
197
355
  return { id: vaultRelativeId(absolutePath), mode, bytes: Buffer.byteLength(finalBody, 'utf8') }
198
356
  }
199
357
 
@@ -204,6 +362,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
204
362
  return /^#\s+(.+)$/m.exec(body)?.[1]?.trim() ?? idOf(p).replace(/\.[^.]+$/, '')
205
363
  }
206
364
  let paths
365
+ const resolve = await vaultLinkResolver()
207
366
  if (id === undefined) {
208
367
  paths = await listNotePaths()
209
368
  } else {
@@ -214,7 +373,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
214
373
  const next = []
215
374
  for (const p of frontier) {
216
375
  for (const link of extractLinks(await readFile(p, 'utf8'))) {
217
- const t = await resolveLinkTarget(link)
376
+ const t = await resolveLinkTarget(link, resolve)
218
377
  if (t !== undefined && !seen.has(idOf(t))) { seen.set(idOf(t), t); next.push(t) }
219
378
  }
220
379
  }
@@ -230,7 +389,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
230
389
  for (const p of capped) {
231
390
  nodes.push({ id: idOf(p), title: await title(p) })
232
391
  for (const link of extractLinks(await readFile(p, 'utf8'))) {
233
- const t = await resolveLinkTarget(link)
392
+ const t = await resolveLinkTarget(link, resolve)
234
393
  if (t !== undefined && idSet.has(idOf(t))) edges.push({ from: idOf(p), to: idOf(t) })
235
394
  }
236
395
  }
@@ -242,7 +401,7 @@ async function buildGraph(id, depth = 1, maxNodes = 200) {
242
401
  const TOOLS = [
243
402
  {
244
403
  name: 'wiki_read',
245
- description: 'Read one Markdown note from the wiki vault, following Obsidian-style [[link]] references up to the configured depth.',
404
+ description: 'Read one Markdown note from the wiki vault, following Obsidian-style [[link]] references up to the configured depth. Returns mtime and modifiedExternally alongside frontmatter, body, links, and version.',
246
405
  inputSchema: {
247
406
  type: 'object',
248
407
  properties: { id: { type: 'string', description: 'Vault-relative path of the note (e.g. "concepts/RAG.md").' } },
@@ -287,8 +446,16 @@ const TOOLS = [
287
446
 
288
447
  async function callTool(name, a = {}) {
289
448
  switch (name) {
290
- case 'wiki_read':
291
- return readNote(await containedPathReal(a.id), MAX_LINK_DEPTH)
449
+ case 'wiki_read': {
450
+ const absolutePath = await containedPathReal(a.id)
451
+ const note = await readNote(absolutePath, MAX_LINK_DEPTH)
452
+ const info = await stat(absolutePath)
453
+ return {
454
+ ...note,
455
+ mtime: formatBeijingTime(info.mtime),
456
+ modifiedExternally: detectExternalChange(absolutePath, info.mtimeMs),
457
+ }
458
+ }
292
459
  case 'wiki_search':
293
460
  return searchNotes(a.query ?? '')
294
461
  case 'wiki_write':