@jacklika/dsh-tool-memory-filesystem 0.1.3-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 +124 -0
- package/README.zh.md +124 -0
- package/lib/index.js +484 -0
- package/lib/types/index.d.ts +162 -0
- package/lib/types/types.d.ts +42 -0
- package/lib/types.js +6 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Model-facing wiki/memory tools over a local Markdown vault with Obsidian-style links and YAML frontmatter."
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @jacklika/dsh-tool-memory-filesystem
|
|
7
|
+
|
|
8
|
+
English | [中文](README.zh.md)
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
`dsh-tool-memory-filesystem` gives agents read, search, and append access to a local Markdown vault. Notes are ordinary `.md` files with optional YAML frontmatter and Obsidian-style `[[link]]` references. A deployment mounts this package as a Cordis plugin; by default each session reads and writes notes under its own workspace at `<session cwd>/.dsh/memory/`, so every project keeps a private memory store. An explicit `vaultRoot` can pin one shared vault instead. The model sees `wiki_read`, `wiki_search`, and `wiki_write` tools. No vector database is required — the MVP uses filename and keyword search, with link following for contextual completeness.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Model Experience](#model-experience)
|
|
19
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
20
|
+
- [Dev Note](#dev-note)
|
|
21
|
+
|
|
22
|
+
-----
|
|
23
|
+
|
|
24
|
+
<a id="use-this-package"></a>
|
|
25
|
+
## Use this package
|
|
26
|
+
|
|
27
|
+
Mount the plugin in a profile or patch file:
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
- name: '@jacklika/dsh-tool-memory-filesystem'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Each session then uses `<session cwd>/.dsh/memory/` as its vault. To share one vault across sessions, set an explicit root:
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
- name: '@jacklika/dsh-tool-memory-filesystem'
|
|
37
|
+
config:
|
|
38
|
+
vaultRoot: /path/to/obsidian-vault
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A relative `vaultRoot` resolves against the calling session's workspace.
|
|
42
|
+
|
|
43
|
+
### Configure the vault
|
|
44
|
+
|
|
45
|
+
| Field | Type | Default | Description |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| `vaultRoot` | `string` | `''` → `<session cwd>/.dsh/memory/` | Vault root. Empty selects the per-workspace memory directory; a relative path resolves against the session workspace. |
|
|
48
|
+
| `extensions` | `string[]` | `['.md']` | File extensions treated as notes. |
|
|
49
|
+
| `maxLinkDepth` | `number` | `1` | Maximum `[[link]]` hops `wiki_read` resolves. |
|
|
50
|
+
| `maxSearchResults` | `number` | `20` | Maximum `wiki_search` hits. |
|
|
51
|
+
| `indexHiddenDirs` | `boolean` | `false` | Index directories whose names start with `.` (`.git` and `node_modules` stay excluded). Enable when `vaultRoot` points at the workspace root so `.dsh/memory/` notes are included. |
|
|
52
|
+
|
|
53
|
+
### Tools
|
|
54
|
+
|
|
55
|
+
- `wiki_read(id)` — read one note by vault-relative path and return its frontmatter, body, links, linked notes, and a `version` content fingerprint.
|
|
56
|
+
- `wiki_search(query)` — keyword search across note titles, ids, and bodies; results include backlink counts.
|
|
57
|
+
- `wiki_write(id, content, mode?, baseVersion?)` — create or append to a note. Append mode preserves frontmatter and adds a timestamp header. Passing a `version` from `wiki_read` as `baseVersion` makes the write fail loudly when another writer changed the note in between.
|
|
58
|
+
|
|
59
|
+
### Security
|
|
60
|
+
|
|
61
|
+
All paths are resolved under the vault root for that call; a path that escapes the vault is rejected. The plugin does not use `ctx.fs`, so the configured filesystem sandbox policy does not apply; vault access is governed by OS permissions and this containment check.
|
|
62
|
+
|
|
63
|
+
-----
|
|
64
|
+
|
|
65
|
+
<a id="understand-the-implementation"></a>
|
|
66
|
+
## Understand the implementation
|
|
67
|
+
|
|
68
|
+
This package is a single Cordis function plugin with no runtime service. It registers three typed tools on `ctx.tools`. The vault root is resolved per tool call: the explicit `vaultRoot` config when set (relative paths anchor at the session workspace), otherwise `<session cwd>/.dsh/memory/` so each workspace owns its notes. Each tool reads Markdown files directly through `node:fs/promises` and stays inside the resolved root via `path.resolve` + prefix checking. YAML frontmatter is parsed with `js-yaml`; `[[link|alias]]` references extract the target before the pipe. Search builds a transient index from the vault contents and sorts hits by backlink count. `wiki_write` publishes atomically: the body is staged in a sibling temp file and `rename`d over the target, so concurrent readers never observe a partially written note.
|
|
69
|
+
|
|
70
|
+
-----
|
|
71
|
+
|
|
72
|
+
<a id="model-experience"></a>
|
|
73
|
+
## Model Experience
|
|
74
|
+
|
|
75
|
+
### Tool schema
|
|
76
|
+
|
|
77
|
+
#### What the model sees
|
|
78
|
+
|
|
79
|
+
The model sees the generated [`wiki_read`, `wiki_search`, and `wiki_write` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-memory-filesystem). Their descriptions tell the model that notes are Markdown files with YAML frontmatter and Obsidian-style `[[link]]` references, and that `wiki_read` follows links up to the configured depth.
|
|
80
|
+
|
|
81
|
+
##### Verbatim description for `wiki_read`
|
|
82
|
+
|
|
83
|
+
```markdown
|
|
84
|
+
Read one Markdown note from the wiki vault, optionally following Obsidian-style [[link]] references up to the configured depth. Returns the note id, frontmatter, body, and linked notes.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
##### Verbatim description for `wiki_search`
|
|
88
|
+
|
|
89
|
+
```markdown
|
|
90
|
+
Search the wiki vault by note title or body keyword. Returns matching note ids, titles, and backlink counts. Use this before asking the user which note to read.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
##### Verbatim description for `wiki_write`
|
|
94
|
+
|
|
95
|
+
```markdown
|
|
96
|
+
Create a new note or append to an existing note in the wiki vault. The path is relative to the vault root. When appending, the new content is inserted at the end of the body after a timestamp header.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
#### Token effect
|
|
100
|
+
|
|
101
|
+
`wiki_read` returns the full body of the requested note plus every linked note reachable within `maxLinkDepth`. Long notes or dense link graphs can add many tokens to the next request. `wiki_search` returns a bounded list of result metadata only.
|
|
102
|
+
|
|
103
|
+
#### KV Cache effect
|
|
104
|
+
|
|
105
|
+
Independent. The plugin only supplies tool results; it does not change the request header, system prompt, or tool list.
|
|
106
|
+
|
|
107
|
+
## Known Limitations and Deferred Work
|
|
108
|
+
|
|
109
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
110
|
+
|
|
111
|
+
- **No sandbox policy integration** — the plugin reads files directly, so it bypasses the `ctx.fs` sandbox and approval gates. A future provider could delegate reads to `ctx.fs` to inherit policy.
|
|
112
|
+
- **No vector search** — search is keyword-only. A separate `tool-memory-vector` package could add embedding-based retrieval without changing this package.
|
|
113
|
+
- **No embedded image or binary support** — notes are treated as UTF-8 text. Attachments should remain in the attachment seam.
|
|
114
|
+
- **No built-in write coordination** — writes are atomic (temp file + `rename`), so readers never see partial files, but simultaneous `wiki_write` calls to the same note can still lose updates. Cooperative callers can pass `baseVersion` to turn a lost update into a loud conflict; mount `@jacklika/dsh-memory-queue` for in-process serialization, optionally with a heartbeat-refreshed cross-process lock.
|
|
115
|
+
|
|
116
|
+
<a id="dev-note"></a>
|
|
117
|
+
### Dev Note
|
|
118
|
+
|
|
119
|
+
<details>
|
|
120
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
121
|
+
|
|
122
|
+
None.
|
|
123
|
+
|
|
124
|
+
</details>
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "基于本地 Markdown 仓库的面向模型的 wiki/记忆工具,支持 Obsidian 风格双向链接和 YAML frontmatter。"
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @jacklika/dsh-tool-memory-filesystem
|
|
7
|
+
|
|
8
|
+
[English](README.md) | 中文
|
|
9
|
+
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
`dsh-tool-memory-filesystem` 让 agent 可以读取、搜索和追加本地 Markdown 仓库中的笔记。笔记是普通的 `.md` 文件,可包含 YAML frontmatter 和 Obsidian 风格 `[[link]]` 链接。部署时以 Cordis 插件形式挂载即可;默认情况下每个会话使用自己工作区下的 `<session cwd>/.dsh/memory/` 作为仓库,因此每个项目拥有独立的记忆库。需要共享仓库时可显式配置 `vaultRoot`。模型会看到 `wiki_read`、`wiki_search` 和 `wiki_write` 三个工具。MVP 不需要向量数据库,只使用文件名和关键词搜索,并通过链接跟随保证上下文完整。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用](#use-this-package)
|
|
17
|
+
- [实现说明](#understand-the-implementation)
|
|
18
|
+
- [模型体验](#model-experience)
|
|
19
|
+
- [已知限制与待办](#known-limitations-and-deferred-work)
|
|
20
|
+
- [开发备注](#dev-note)
|
|
21
|
+
|
|
22
|
+
-----
|
|
23
|
+
|
|
24
|
+
<a id="use-this-package"></a>
|
|
25
|
+
## 使用
|
|
26
|
+
|
|
27
|
+
在 profile 或 patch 文件中挂载插件:
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
- name: '@jacklika/dsh-tool-memory-filesystem'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
此后每个会话使用 `<session cwd>/.dsh/memory/` 作为自己的仓库。需要跨会话共享一个仓库时,显式指定根目录:
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
- name: '@jacklika/dsh-tool-memory-filesystem'
|
|
37
|
+
config:
|
|
38
|
+
vaultRoot: /path/to/obsidian-vault
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
相对路径的 `vaultRoot` 会相对于调用会话的工作区解析。
|
|
42
|
+
|
|
43
|
+
### 配置仓库
|
|
44
|
+
|
|
45
|
+
| 字段 | 类型 | 默认值 | 说明 |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| `vaultRoot` | `string` | `''` → `<session cwd>/.dsh/memory/` | 仓库根目录。留空表示使用按工作区隔离的记忆目录;相对路径相对于会话工作区解析。 |
|
|
48
|
+
| `extensions` | `string[]` | `['.md']` | 视为笔记的文件扩展名。 |
|
|
49
|
+
| `maxLinkDepth` | `number` | `1` | `wiki_read` 解析 `[[link]]` 的最大深度。 |
|
|
50
|
+
| `maxSearchResults` | `number` | `20` | `wiki_search` 返回的最大结果数。 |
|
|
51
|
+
| `indexHiddenDirs` | `boolean` | `false` | 是否索引以 `.` 开头的目录(`.git` 与 `node_modules` 始终排除)。当 `vaultRoot` 指向工作区根时开启,以纳入 `.dsh/memory/` 中的笔记。 |
|
|
52
|
+
|
|
53
|
+
### 工具
|
|
54
|
+
|
|
55
|
+
- `wiki_read(id)` — 按仓库相对路径读取笔记,返回 frontmatter、正文、链接、已解析的链接笔记,以及内容指纹 `version`。
|
|
56
|
+
- `wiki_search(query)` — 按标题、id、正文关键词搜索;结果包含反向链接数量。
|
|
57
|
+
- `wiki_write(id, content, mode?, baseVersion?)` — 创建或追加笔记。追加模式保留 frontmatter 并添加时间戳标题。把 `wiki_read` 返回的 `version` 作为 `baseVersion` 传入时,若笔记在读取后被其他写入方改动,写入会显式失败。
|
|
58
|
+
|
|
59
|
+
### 安全
|
|
60
|
+
|
|
61
|
+
所有路径都必须在该次调用解析出的仓库根目录下;越界路径会被拒绝。该插件不通过 `ctx.fs` 访问文件,因此不继承文件沙箱策略;访问权限由 OS 文件权限和本插件的越界检查共同控制。
|
|
62
|
+
|
|
63
|
+
-----
|
|
64
|
+
|
|
65
|
+
<a id="understand-the-implementation"></a>
|
|
66
|
+
## 实现说明
|
|
67
|
+
|
|
68
|
+
本包是一个没有运行时服务的 Cordis 函数插件,只在 `ctx.tools` 上注册三个类型化工具。仓库根目录在每次工具调用时解析:优先使用显式 `vaultRoot`(相对路径锚定会话工作区),否则使用 `<session cwd>/.dsh/memory/`,使每个工作区拥有自己的笔记。每个工具都通过 `node:fs/promises` 直接读取 Markdown 文件,并借助 `path.resolve` + 前缀检查保持在解析出的根目录内。YAML frontmatter 使用 `js-yaml` 解析;`[[link|alias]]` 形式的引用会提取管道符前的目标。搜索时从仓库内容构建临时索引,并按反向链接数量排序。`wiki_write` 以原子方式发布:正文先写入同级临时文件,再 `rename` 覆盖目标,因此并发读者永远不会读到写了一半的笔记。
|
|
69
|
+
|
|
70
|
+
-----
|
|
71
|
+
|
|
72
|
+
<a id="model-experience"></a>
|
|
73
|
+
## 模型体验
|
|
74
|
+
|
|
75
|
+
### Tool schema
|
|
76
|
+
|
|
77
|
+
#### 模型看到的内容
|
|
78
|
+
|
|
79
|
+
模型看到的是生成的 [`wiki_read`、`wiki_search` 和 `wiki_write` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-memory-filesystem)。工具描述会告知模型:笔记是带 YAML frontmatter 和 Obsidian 风格 `[[link]]` 链接的 Markdown 文件,`wiki_read` 会按配置深度跟随链接。
|
|
80
|
+
|
|
81
|
+
##### `wiki_read` 的完整描述
|
|
82
|
+
|
|
83
|
+
```markdown
|
|
84
|
+
Read one Markdown note from the wiki vault, optionally following Obsidian-style [[link]] references up to the configured depth. Returns the note id, frontmatter, body, and linked notes.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
##### `wiki_search` 的完整描述
|
|
88
|
+
|
|
89
|
+
```markdown
|
|
90
|
+
Search the wiki vault by note title or body keyword. Returns matching note ids, titles, and backlink counts. Use this before asking the user which note to read.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
##### `wiki_write` 的完整描述
|
|
94
|
+
|
|
95
|
+
```markdown
|
|
96
|
+
Create a new note or append to an existing note in the wiki vault. The path is relative to the vault root. When appending, the new content is inserted at the end of the body after a timestamp header.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
#### Token 影响
|
|
100
|
+
|
|
101
|
+
`wiki_read` 返回请求笔记的完整正文,以及 `maxLinkDepth` 范围内所有可达链接笔记的正文。长笔记或密集链接图可能显著增加下次请求的 token 数。`wiki_search` 只返回有限的结果元数据。
|
|
102
|
+
|
|
103
|
+
#### KV Cache 影响
|
|
104
|
+
|
|
105
|
+
独立。该插件仅提供工具结果,不改变请求头、系统提示或工具列表。
|
|
106
|
+
|
|
107
|
+
## 已知限制与待办
|
|
108
|
+
|
|
109
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
110
|
+
|
|
111
|
+
- **未接入沙箱策略** — 插件直接读取文件,绕过 `ctx.fs` 沙箱与审批机制。未来可以提供委托给 `ctx.fs` 的 provider 以继承策略。
|
|
112
|
+
- **无向量搜索** — 仅支持关键词搜索。未来可新增 `tool-memory-vector` 包提供基于 embedding 的检索,而无需修改本包。
|
|
113
|
+
- **不支持图片或二进制附件** — 笔记按 UTF-8 文本处理。附件应继续使用 attachment 体系。
|
|
114
|
+
- **无内置写协调** —— 写入是原子的(临时文件 + `rename`),读者不会看到半写文件,但对同一笔记的并发 `wiki_write` 仍可能丢更新。协作调用方可传 `baseVersion` 把丢更新变成显式冲突;挂载 `@jacklika/dsh-memory-queue` 可获得进程内串行化,并可选用心跳刷新的跨进程锁。
|
|
115
|
+
|
|
116
|
+
<a id="dev-note"></a>
|
|
117
|
+
### 开发备注
|
|
118
|
+
|
|
119
|
+
<details>
|
|
120
|
+
<summary>维护者工作上下文 — 点击展开</summary>
|
|
121
|
+
|
|
122
|
+
无。
|
|
123
|
+
|
|
124
|
+
</details>
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing wiki/memory tools over a local Markdown vault. The package
|
|
3
|
+
* provides `wiki_read`, `wiki_search`, and `wiki_write` so an agent can treat a
|
|
4
|
+
* directory of Markdown notes as long-term memory. Notes are plain files with
|
|
5
|
+
* YAML frontmatter and Obsidian-style `[[link]]` references; the agent reads,
|
|
6
|
+
* searches, and appends notes through the tool registry without touching core
|
|
7
|
+
* packages.
|
|
8
|
+
* @module @jacklika/dsh-tool-memory-filesystem
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
|
+
import { readdir, readFile, realpath, rename, rm, stat, writeFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
13
|
+
import z from '@deepseek-ai/schemastery';
|
|
14
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
15
|
+
import yaml from 'js-yaml';
|
|
16
|
+
/**
|
|
17
|
+
* Compute a vault-relative note id from an absolute path. The result always
|
|
18
|
+
* uses POSIX separators so note ids are stable across Windows, macOS, and Linux.
|
|
19
|
+
*/
|
|
20
|
+
export function vaultRelativeId(root, absolutePath) {
|
|
21
|
+
return relative(root, absolutePath).replace(/\\/g, '/');
|
|
22
|
+
}
|
|
23
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
24
|
+
export const name = 'tool-memory-filesystem';
|
|
25
|
+
/** Services required by the wiki/memory tool suite. */
|
|
26
|
+
export const inject = ['tools'];
|
|
27
|
+
/** Schemastery configuration for the filesystem memory tool consumer. */
|
|
28
|
+
export const Config = z.object({
|
|
29
|
+
vaultRoot: z.string().default(''),
|
|
30
|
+
extensions: z.array(z.string()).default(['.md']),
|
|
31
|
+
maxLinkDepth: z.number().default(1),
|
|
32
|
+
maxSearchResults: z.number().default(20),
|
|
33
|
+
indexHiddenDirs: z.boolean().default(false),
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Compute the vault root for one tool call: an explicit `vaultRoot` wins,
|
|
37
|
+
* resolved relative to the session workspace; otherwise the call gets
|
|
38
|
+
* `<session cwd>/.dsh/memory/` so each workspace owns its notes.
|
|
39
|
+
* @param vaultRoot - the configured root (`''` selects the per-workspace default).
|
|
40
|
+
* @param exec - the current tool execution carrying the agent session.
|
|
41
|
+
* @returns absolute vault root for this call.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveMemoryVaultRoot(vaultRoot, exec) {
|
|
44
|
+
const sessionCwd = exec.agent?.session.header.cwd ?? process.cwd();
|
|
45
|
+
if (vaultRoot === undefined || vaultRoot === '') {
|
|
46
|
+
return join(sessionCwd, '.dsh', 'memory');
|
|
47
|
+
}
|
|
48
|
+
return isAbsolute(vaultRoot) ? vaultRoot : resolve(sessionCwd, vaultRoot);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Reject paths that escape the vault root. The check resolves the candidate,
|
|
52
|
+
* normalizes `..`, and requires the result to start with the root path followed
|
|
53
|
+
* by a path separator (or equal the root itself).
|
|
54
|
+
* @param root - absolute vault root.
|
|
55
|
+
* @param candidate - a relative or absolute path.
|
|
56
|
+
* @returns the absolute, contained path.
|
|
57
|
+
*/
|
|
58
|
+
export function containedPath(root, candidate) {
|
|
59
|
+
const absoluteRoot = resolve(root);
|
|
60
|
+
const absolute = resolve(absoluteRoot, candidate);
|
|
61
|
+
const remainder = relative(absoluteRoot, absolute);
|
|
62
|
+
if (remainder === '..' || remainder.startsWith(`..${sep}`) || isAbsolute(remainder)) {
|
|
63
|
+
throw new Error(`tool-memory-filesystem: path ${candidate} is outside vault root ${root}`);
|
|
64
|
+
}
|
|
65
|
+
return absolute;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a vault path and reject an existing symlink or junction component
|
|
69
|
+
* whose real target leaves the vault. The nearest existing ancestor protects
|
|
70
|
+
* paths whose final file or directories do not exist yet.
|
|
71
|
+
* @param root - vault root; it must exist.
|
|
72
|
+
* @param candidate - vault-relative path.
|
|
73
|
+
* @returns the absolute lexical path after realpath containment succeeds.
|
|
74
|
+
*/
|
|
75
|
+
export async function containedPathReal(root, candidate) {
|
|
76
|
+
const absolute = containedPath(root, candidate);
|
|
77
|
+
const canonicalRoot = await realpath(resolve(root));
|
|
78
|
+
let ancestor = absolute;
|
|
79
|
+
for (;;) {
|
|
80
|
+
try {
|
|
81
|
+
const canonicalAncestor = await realpath(ancestor);
|
|
82
|
+
const remainder = relative(canonicalRoot, canonicalAncestor);
|
|
83
|
+
if (remainder === '..' || remainder.startsWith(`..${sep}`) || isAbsolute(remainder)) {
|
|
84
|
+
throw new Error(`tool-memory-filesystem: path ${candidate} resolves outside vault root ${root}`);
|
|
85
|
+
}
|
|
86
|
+
return absolute;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code !== 'ENOENT')
|
|
90
|
+
throw error;
|
|
91
|
+
const parent = dirname(ancestor);
|
|
92
|
+
if (parent === ancestor)
|
|
93
|
+
throw error;
|
|
94
|
+
ancestor = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Extract YAML frontmatter and body from Markdown text. Only the leading
|
|
100
|
+
* `---\n...\n---\n` form is recognized.
|
|
101
|
+
* @param text - raw file contents.
|
|
102
|
+
* @returns frontmatter map and body.
|
|
103
|
+
*/
|
|
104
|
+
export function splitFrontmatter(text) {
|
|
105
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(text);
|
|
106
|
+
if (match === null) {
|
|
107
|
+
return { frontmatter: {}, body: text };
|
|
108
|
+
}
|
|
109
|
+
const frontmatterText = match[1] ?? '';
|
|
110
|
+
const bodyText = match[2] ?? '';
|
|
111
|
+
try {
|
|
112
|
+
const parsed = yaml.load(frontmatterText);
|
|
113
|
+
return {
|
|
114
|
+
frontmatter: parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
115
|
+
? parsed
|
|
116
|
+
: {},
|
|
117
|
+
body: bodyText,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { frontmatter: {}, body: text };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Fingerprint one note's raw file contents. The value lets `wiki_write`
|
|
126
|
+
* detect that another writer changed the note since it was read.
|
|
127
|
+
* @param text - raw file contents.
|
|
128
|
+
* @returns content hash stable across processes.
|
|
129
|
+
*/
|
|
130
|
+
export function noteVersion(text) {
|
|
131
|
+
return createHash('sha1').update(text, 'utf8').digest('hex');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Find all Obsidian-style `[[link]]` references in note text. Aliases of the
|
|
135
|
+
* form `[[link|alias]]` return the link target only.
|
|
136
|
+
* @param text - note body.
|
|
137
|
+
* @returns array of link targets.
|
|
138
|
+
*/
|
|
139
|
+
export function extractLinks(text) {
|
|
140
|
+
const links = [];
|
|
141
|
+
const pattern = /\[\[([^|\]\r\n]+)(?:\|[^\]]*)?\]\]/g;
|
|
142
|
+
let match;
|
|
143
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
144
|
+
if (match[1] !== undefined)
|
|
145
|
+
links.push(match[1].trim());
|
|
146
|
+
}
|
|
147
|
+
return [...new Set(links)];
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Normalize a configured file extension to a dot-prefixed form.
|
|
151
|
+
* @param ext - extension string.
|
|
152
|
+
* @returns `.md` form.
|
|
153
|
+
*/
|
|
154
|
+
export function dottedExtension(ext) {
|
|
155
|
+
return ext.startsWith('.') ? ext : `.${ext}`;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Resolve a link target to an existing note file inside the vault. The target
|
|
159
|
+
* may omit the extension; extensions are tried in config order.
|
|
160
|
+
* @param root - vault root.
|
|
161
|
+
* @param extensions - note extensions.
|
|
162
|
+
* @param target - link target from `[[...]]`.
|
|
163
|
+
* @returns the resolved absolute path, or `undefined` if no file exists.
|
|
164
|
+
*/
|
|
165
|
+
export async function resolveLinkTarget(root, extensions, target) {
|
|
166
|
+
const candidates = extensions.map(ext => `${target}${dottedExtension(ext)}`);
|
|
167
|
+
candidates.push(target);
|
|
168
|
+
for (const candidate of candidates) {
|
|
169
|
+
try {
|
|
170
|
+
const path = await containedPathReal(root, candidate);
|
|
171
|
+
const info = await stat(path);
|
|
172
|
+
if (info.isFile())
|
|
173
|
+
return path;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error.code !== 'ENOENT')
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Recursively discover note files under the vault root. `.git`, `.obsidian`,
|
|
184
|
+
* and `node_modules` are always excluded; other dot-directories are skipped
|
|
185
|
+
* unless `indexHiddenDirs` is enabled.
|
|
186
|
+
* @param root - vault root.
|
|
187
|
+
* @param extensions - note extensions.
|
|
188
|
+
* @param indexHiddenDirs - descend into remaining dot-directories.
|
|
189
|
+
* @returns absolute paths of every note file.
|
|
190
|
+
*/
|
|
191
|
+
export async function listNotePaths(root, extensions, indexHiddenDirs = false) {
|
|
192
|
+
const results = [];
|
|
193
|
+
const exclude = new Set(['.git', 'node_modules', '.obsidian']);
|
|
194
|
+
async function walk(dir) {
|
|
195
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
if (!indexHiddenDirs && entry.name.startsWith('.') && entry.name !== '.')
|
|
198
|
+
continue;
|
|
199
|
+
if (exclude.has(entry.name))
|
|
200
|
+
continue;
|
|
201
|
+
const full = join(dir, entry.name);
|
|
202
|
+
if (entry.isDirectory()) {
|
|
203
|
+
await walk(full);
|
|
204
|
+
}
|
|
205
|
+
else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(dottedExtension(ext)))) {
|
|
206
|
+
results.push(full);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
await walk(root);
|
|
211
|
+
return results;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Read and parse one Markdown note, following configured link depth.
|
|
215
|
+
* @param root - vault root.
|
|
216
|
+
* @param extensions - note extensions.
|
|
217
|
+
* @param maxLinkDepth - how many link hops to resolve.
|
|
218
|
+
* @param absolutePath - absolute path of the note.
|
|
219
|
+
* @param visited - set of already-visited absolute paths to prevent cycles.
|
|
220
|
+
* @returns the parsed note with linked notes attached.
|
|
221
|
+
*/
|
|
222
|
+
export async function readNote(root, extensions, maxLinkDepth, absolutePath, visited = new Set()) {
|
|
223
|
+
if (visited.has(absolutePath)) {
|
|
224
|
+
return {
|
|
225
|
+
path: absolutePath,
|
|
226
|
+
id: vaultRelativeId(root, absolutePath),
|
|
227
|
+
frontmatter: {},
|
|
228
|
+
body: '',
|
|
229
|
+
links: [],
|
|
230
|
+
version: '',
|
|
231
|
+
linkedNotes: [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const nextVisited = new Set(visited);
|
|
235
|
+
nextVisited.add(absolutePath);
|
|
236
|
+
const text = await readFile(absolutePath, 'utf8');
|
|
237
|
+
const { frontmatter, body } = splitFrontmatter(text);
|
|
238
|
+
const links = extractLinks(text);
|
|
239
|
+
const linkedNotes = [];
|
|
240
|
+
if (maxLinkDepth > 0) {
|
|
241
|
+
for (const link of links) {
|
|
242
|
+
const target = await resolveLinkTarget(root, extensions, link);
|
|
243
|
+
if (target !== undefined) {
|
|
244
|
+
const child = await readNote(root, extensions, maxLinkDepth - 1, target, nextVisited);
|
|
245
|
+
linkedNotes.push(child);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
path: absolutePath,
|
|
251
|
+
id: vaultRelativeId(root, absolutePath),
|
|
252
|
+
frontmatter,
|
|
253
|
+
body,
|
|
254
|
+
links,
|
|
255
|
+
version: noteVersion(text),
|
|
256
|
+
linkedNotes,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Build a search index of note titles, bodies, and backlinks. The title is the
|
|
261
|
+
* first Markdown `# heading` or the basename without extension; the body keeps
|
|
262
|
+
* keyword search honest about the documented "titles and bodies" contract.
|
|
263
|
+
* @param root - vault root.
|
|
264
|
+
* @param extensions - note extensions.
|
|
265
|
+
* @param indexHiddenDirs - descend into dot-directories besides the fixed exclusions.
|
|
266
|
+
* @returns a map from note id to search result.
|
|
267
|
+
*/
|
|
268
|
+
export async function buildIndex(root, extensions, indexHiddenDirs = false) {
|
|
269
|
+
const paths = await listNotePaths(root, extensions, indexHiddenDirs);
|
|
270
|
+
const notes = [];
|
|
271
|
+
for (const path of paths) {
|
|
272
|
+
const text = await readFile(path, 'utf8');
|
|
273
|
+
const { frontmatter, body } = splitFrontmatter(text);
|
|
274
|
+
notes.push({
|
|
275
|
+
path,
|
|
276
|
+
id: vaultRelativeId(root, path),
|
|
277
|
+
frontmatter,
|
|
278
|
+
body,
|
|
279
|
+
links: extractLinks(text),
|
|
280
|
+
version: noteVersion(text),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const index = new Map();
|
|
284
|
+
for (const note of notes) {
|
|
285
|
+
const headingMatch = /^#\s+(.+)$/m.exec(note.body);
|
|
286
|
+
const title = headingMatch !== null && headingMatch[1] !== undefined
|
|
287
|
+
? headingMatch[1].trim()
|
|
288
|
+
: note.id.replace(/\.[^.]+$/, '');
|
|
289
|
+
index.set(note.id, { id: note.id, title, backlinks: [], body: note.body });
|
|
290
|
+
}
|
|
291
|
+
for (const note of notes) {
|
|
292
|
+
for (const link of note.links) {
|
|
293
|
+
const targetId = [...index.keys()].find((id) => {
|
|
294
|
+
const base = id.replace(/\.[^.]+$/, '');
|
|
295
|
+
return base === link || id === link;
|
|
296
|
+
});
|
|
297
|
+
if (targetId !== undefined) {
|
|
298
|
+
const entry = index.get(targetId);
|
|
299
|
+
if (entry !== undefined && !entry.backlinks.includes(note.id)) {
|
|
300
|
+
entry.backlinks.push(note.id);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return index;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Write `contents` to `absolutePath` atomically: stage into a sibling temp
|
|
309
|
+
* file, then `rename` over the target so concurrent readers never observe a
|
|
310
|
+
* partially written note.
|
|
311
|
+
* @param absolutePath - final note path inside the vault.
|
|
312
|
+
* @param contents - complete file body to publish.
|
|
313
|
+
*/
|
|
314
|
+
async function writeAtomic(absolutePath, contents) {
|
|
315
|
+
const tmpPath = `${absolutePath}.tmp-${process.pid}-${randomUUID()}`;
|
|
316
|
+
try {
|
|
317
|
+
await writeFile(tmpPath, contents, 'utf8');
|
|
318
|
+
await rename(tmpPath, absolutePath);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
await rm(tmpPath, { force: true }).catch(() => undefined);
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Validate a positive-integer config bound. */
|
|
326
|
+
function assertPositiveInteger(name, value) {
|
|
327
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
328
|
+
throw new Error(`tool-memory-filesystem: ${name} must be a positive integer`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Register the `wiki_read`, `wiki_search`, and `wiki_write` tools on
|
|
333
|
+
* `ctx.tools`.
|
|
334
|
+
* @param ctx - registrant context carrying the tool registry.
|
|
335
|
+
* @param config - deployment's explicit vault configuration.
|
|
336
|
+
*/
|
|
337
|
+
export function apply(ctx, config) {
|
|
338
|
+
const tools = ctx.get('tools');
|
|
339
|
+
if (typeof tools?.register !== 'function') {
|
|
340
|
+
ctx.logger?.warn('tool-memory-filesystem: ctx.tools.register unavailable; wiki tools will not register.');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const resolved = config;
|
|
344
|
+
assertPositiveInteger('maxLinkDepth', resolved.maxLinkDepth);
|
|
345
|
+
assertPositiveInteger('maxSearchResults', resolved.maxSearchResults);
|
|
346
|
+
const vaultRootFor = (exec) => resolveMemoryVaultRoot(resolved.vaultRoot, exec);
|
|
347
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
348
|
+
name: 'wiki_read',
|
|
349
|
+
description: 'Read one Markdown note from the wiki vault, optionally following Obsidian-style [[link]] references up to the configured depth. Returns the note id, frontmatter, body, and linked notes.',
|
|
350
|
+
parameters: {
|
|
351
|
+
id: {
|
|
352
|
+
type: 'string',
|
|
353
|
+
required: true,
|
|
354
|
+
description: 'Vault-relative path of the note to read (e.g. "concepts/RAG.md" or "daily/2026-09-23").',
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
output: {
|
|
358
|
+
schema: { type: 'json' },
|
|
359
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
360
|
+
},
|
|
361
|
+
async execute(args, exec) {
|
|
362
|
+
const vaultRoot = vaultRootFor(exec);
|
|
363
|
+
const absolutePath = await containedPathReal(vaultRoot, args.id);
|
|
364
|
+
const note = await readNote(vaultRoot, resolved.extensions, resolved.maxLinkDepth, absolutePath);
|
|
365
|
+
return note;
|
|
366
|
+
},
|
|
367
|
+
})));
|
|
368
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
369
|
+
name: 'wiki_search',
|
|
370
|
+
description: 'Search the wiki vault by note title or body keyword. Returns matching note ids, titles, and backlink counts. Use this before asking the user which note to read.',
|
|
371
|
+
parameters: {
|
|
372
|
+
query: {
|
|
373
|
+
type: 'string',
|
|
374
|
+
required: true,
|
|
375
|
+
description: 'Keyword or phrase to match against note titles and bodies.',
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
output: {
|
|
379
|
+
schema: {
|
|
380
|
+
type: 'array',
|
|
381
|
+
items: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
additionalProperties: false,
|
|
384
|
+
properties: {
|
|
385
|
+
id: { type: 'string' },
|
|
386
|
+
title: { type: 'string' },
|
|
387
|
+
backlinks: { type: 'array', items: { type: 'string' } },
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
392
|
+
},
|
|
393
|
+
async execute(args, exec) {
|
|
394
|
+
const vaultRoot = vaultRootFor(exec);
|
|
395
|
+
const index = await buildIndex(vaultRoot, resolved.extensions, resolved.indexHiddenDirs);
|
|
396
|
+
const terms = args.query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
397
|
+
const hits = [];
|
|
398
|
+
for (const result of index.values()) {
|
|
399
|
+
const haystack = `${result.id} ${result.title} ${result.backlinks.join(' ')} ${result.body}`.toLowerCase();
|
|
400
|
+
if (terms.every(term => haystack.includes(term))) {
|
|
401
|
+
hits.push({ id: result.id, title: result.title, backlinks: result.backlinks });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
hits.sort((a, b) => b.backlinks.length - a.backlinks.length);
|
|
405
|
+
return hits.slice(0, resolved.maxSearchResults);
|
|
406
|
+
},
|
|
407
|
+
})));
|
|
408
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
409
|
+
name: 'wiki_write',
|
|
410
|
+
description: 'Create a new note or append to an existing note in the wiki vault. The path is relative to the vault root. When appending, the new content is inserted at the end of the body after a timestamp header.',
|
|
411
|
+
parameters: {
|
|
412
|
+
id: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
required: true,
|
|
415
|
+
description: 'Vault-relative path of the note (e.g. "meetings/2026-09-23.md").',
|
|
416
|
+
},
|
|
417
|
+
content: {
|
|
418
|
+
type: 'string',
|
|
419
|
+
required: true,
|
|
420
|
+
description: 'Markdown content to write or append.',
|
|
421
|
+
},
|
|
422
|
+
mode: {
|
|
423
|
+
type: 'string',
|
|
424
|
+
description: 'Either "append" (default) or "overwrite".',
|
|
425
|
+
enum: ['append', 'overwrite'],
|
|
426
|
+
},
|
|
427
|
+
baseVersion: {
|
|
428
|
+
type: 'string',
|
|
429
|
+
description: 'Optional version returned by wiki_read. When provided, the write fails if the note changed since that read.',
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
output: {
|
|
433
|
+
schema: {
|
|
434
|
+
type: 'object',
|
|
435
|
+
additionalProperties: false,
|
|
436
|
+
properties: {
|
|
437
|
+
id: { type: 'string' },
|
|
438
|
+
mode: { type: 'string' },
|
|
439
|
+
bytes: { type: 'integer' },
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
443
|
+
},
|
|
444
|
+
async execute(args, exec) {
|
|
445
|
+
const vaultRoot = vaultRootFor(exec);
|
|
446
|
+
const mode = args.mode ?? 'append';
|
|
447
|
+
await mkdir(vaultRoot, { recursive: true });
|
|
448
|
+
const absolutePath = await containedPathReal(vaultRoot, args.id);
|
|
449
|
+
const hasExtension = resolved.extensions.some(ext => args.id.endsWith(dottedExtension(ext)));
|
|
450
|
+
if (!hasExtension) {
|
|
451
|
+
throw new Error(`tool-memory-filesystem: note id must end with one of ${resolved.extensions.join(', ')}`);
|
|
452
|
+
}
|
|
453
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
454
|
+
await containedPathReal(vaultRoot, args.id);
|
|
455
|
+
exec.signal.throwIfAborted();
|
|
456
|
+
let existing;
|
|
457
|
+
try {
|
|
458
|
+
existing = await readFile(absolutePath, 'utf8');
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// file does not exist yet
|
|
462
|
+
}
|
|
463
|
+
if (args.baseVersion !== undefined && noteVersion(existing ?? '') !== args.baseVersion) {
|
|
464
|
+
throw new Error(`tool-memory-filesystem: note ${args.id} changed since it was read; re-read it before writing`);
|
|
465
|
+
}
|
|
466
|
+
let finalBody;
|
|
467
|
+
if (mode === 'overwrite') {
|
|
468
|
+
finalBody = args.content;
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
const { frontmatter, body } = splitFrontmatter(existing ?? '');
|
|
472
|
+
const frontmatterText = Object.keys(frontmatter).length > 0
|
|
473
|
+
? `---\n${yaml.dump(frontmatter).trim()}\n---\n\n`
|
|
474
|
+
: '';
|
|
475
|
+
const timestamp = new Date().toISOString();
|
|
476
|
+
finalBody = `${frontmatterText}${body}\n\n## ${timestamp}\n\n${args.content}\n`;
|
|
477
|
+
}
|
|
478
|
+
exec.signal.throwIfAborted();
|
|
479
|
+
await writeAtomic(absolutePath, finalBody);
|
|
480
|
+
return { id: vaultRelativeId(vaultRoot, absolutePath), mode, bytes: Buffer.byteLength(finalBody, 'utf8') };
|
|
481
|
+
},
|
|
482
|
+
})));
|
|
483
|
+
}
|
|
484
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing wiki/memory tools over a local Markdown vault. The package
|
|
3
|
+
* provides `wiki_read`, `wiki_search`, and `wiki_write` so an agent can treat a
|
|
4
|
+
* directory of Markdown notes as long-term memory. Notes are plain files with
|
|
5
|
+
* YAML frontmatter and Obsidian-style `[[link]]` references; the agent reads,
|
|
6
|
+
* searches, and appends notes through the tool registry without touching core
|
|
7
|
+
* packages.
|
|
8
|
+
* @module @jacklika/dsh-tool-memory-filesystem
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
import z from '@deepseek-ai/schemastery';
|
|
12
|
+
import { type ToolRunContext } from '@deepseek-ai/dsh-tools';
|
|
13
|
+
import type { IndexedNote, Note } from './types.ts';
|
|
14
|
+
export type * from './types.ts';
|
|
15
|
+
/**
|
|
16
|
+
* Compute a vault-relative note id from an absolute path. The result always
|
|
17
|
+
* uses POSIX separators so note ids are stable across Windows, macOS, and Linux.
|
|
18
|
+
*/
|
|
19
|
+
export declare function vaultRelativeId(root: string, absolutePath: string): string;
|
|
20
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
21
|
+
export declare const name = "tool-memory-filesystem";
|
|
22
|
+
/** Services required by the wiki/memory tool suite. */
|
|
23
|
+
export declare const inject: string[];
|
|
24
|
+
/** Plugin configuration. */
|
|
25
|
+
export interface Config {
|
|
26
|
+
/**
|
|
27
|
+
* Explicit vault root. When omitted, each tool call resolves the memory
|
|
28
|
+
* directory under the calling session's workspace (`<cwd>/.dsh/memory/`).
|
|
29
|
+
* A relative path is resolved against the session workspace.
|
|
30
|
+
*/
|
|
31
|
+
vaultRoot?: string;
|
|
32
|
+
/** File extensions to treat as notes. */
|
|
33
|
+
extensions?: string[];
|
|
34
|
+
/** Maximum depth to follow `[[link]]` references when reading a note. */
|
|
35
|
+
maxLinkDepth?: number;
|
|
36
|
+
/** Maximum number of search hits to return. */
|
|
37
|
+
maxSearchResults?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Descend into dot-directories while indexing. `.git`, `.obsidian`, and
|
|
40
|
+
* `node_modules` are always excluded. Enable this when `vaultRoot` points at
|
|
41
|
+
* a directory whose notes live under a hidden path such as `.dsh/memory/`.
|
|
42
|
+
*/
|
|
43
|
+
indexHiddenDirs?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** Schemastery configuration for the filesystem memory tool consumer. */
|
|
46
|
+
export declare const Config: z<Config>;
|
|
47
|
+
/**
|
|
48
|
+
* Compute the vault root for one tool call: an explicit `vaultRoot` wins,
|
|
49
|
+
* resolved relative to the session workspace; otherwise the call gets
|
|
50
|
+
* `<session cwd>/.dsh/memory/` so each workspace owns its notes.
|
|
51
|
+
* @param vaultRoot - the configured root (`''` selects the per-workspace default).
|
|
52
|
+
* @param exec - the current tool execution carrying the agent session.
|
|
53
|
+
* @returns absolute vault root for this call.
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolveMemoryVaultRoot(vaultRoot: string | undefined, exec: Pick<ToolRunContext, 'agent'>): string;
|
|
56
|
+
/**
|
|
57
|
+
* Reject paths that escape the vault root. The check resolves the candidate,
|
|
58
|
+
* normalizes `..`, and requires the result to start with the root path followed
|
|
59
|
+
* by a path separator (or equal the root itself).
|
|
60
|
+
* @param root - absolute vault root.
|
|
61
|
+
* @param candidate - a relative or absolute path.
|
|
62
|
+
* @returns the absolute, contained path.
|
|
63
|
+
*/
|
|
64
|
+
export declare function containedPath(root: string, candidate: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a vault path and reject an existing symlink or junction component
|
|
67
|
+
* whose real target leaves the vault. The nearest existing ancestor protects
|
|
68
|
+
* paths whose final file or directories do not exist yet.
|
|
69
|
+
* @param root - vault root; it must exist.
|
|
70
|
+
* @param candidate - vault-relative path.
|
|
71
|
+
* @returns the absolute lexical path after realpath containment succeeds.
|
|
72
|
+
*/
|
|
73
|
+
export declare function containedPathReal(root: string, candidate: string): Promise<string>;
|
|
74
|
+
/**
|
|
75
|
+
* Extract YAML frontmatter and body from Markdown text. Only the leading
|
|
76
|
+
* `---\n...\n---\n` form is recognized.
|
|
77
|
+
* @param text - raw file contents.
|
|
78
|
+
* @returns frontmatter map and body.
|
|
79
|
+
*/
|
|
80
|
+
export declare function splitFrontmatter(text: string): {
|
|
81
|
+
frontmatter: Record<string, unknown>;
|
|
82
|
+
body: string;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Fingerprint one note's raw file contents. The value lets `wiki_write`
|
|
86
|
+
* detect that another writer changed the note since it was read.
|
|
87
|
+
* @param text - raw file contents.
|
|
88
|
+
* @returns content hash stable across processes.
|
|
89
|
+
*/
|
|
90
|
+
export declare function noteVersion(text: string): string;
|
|
91
|
+
/**
|
|
92
|
+
* Find all Obsidian-style `[[link]]` references in note text. Aliases of the
|
|
93
|
+
* form `[[link|alias]]` return the link target only.
|
|
94
|
+
* @param text - note body.
|
|
95
|
+
* @returns array of link targets.
|
|
96
|
+
*/
|
|
97
|
+
export declare function extractLinks(text: string): string[];
|
|
98
|
+
/**
|
|
99
|
+
* Normalize a configured file extension to a dot-prefixed form.
|
|
100
|
+
* @param ext - extension string.
|
|
101
|
+
* @returns `.md` form.
|
|
102
|
+
*/
|
|
103
|
+
export declare function dottedExtension(ext: string): string;
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a link target to an existing note file inside the vault. The target
|
|
106
|
+
* may omit the extension; extensions are tried in config order.
|
|
107
|
+
* @param root - vault root.
|
|
108
|
+
* @param extensions - note extensions.
|
|
109
|
+
* @param target - link target from `[[...]]`.
|
|
110
|
+
* @returns the resolved absolute path, or `undefined` if no file exists.
|
|
111
|
+
*/
|
|
112
|
+
export declare function resolveLinkTarget(root: string, extensions: string[], target: string): Promise<string | undefined>;
|
|
113
|
+
/**
|
|
114
|
+
* Recursively discover note files under the vault root. `.git`, `.obsidian`,
|
|
115
|
+
* and `node_modules` are always excluded; other dot-directories are skipped
|
|
116
|
+
* unless `indexHiddenDirs` is enabled.
|
|
117
|
+
* @param root - vault root.
|
|
118
|
+
* @param extensions - note extensions.
|
|
119
|
+
* @param indexHiddenDirs - descend into remaining dot-directories.
|
|
120
|
+
* @returns absolute paths of every note file.
|
|
121
|
+
*/
|
|
122
|
+
export declare function listNotePaths(root: string, extensions: string[], indexHiddenDirs?: boolean): Promise<string[]>;
|
|
123
|
+
/** Wire view of one linked note, truncated to avoid deep recursion types. */
|
|
124
|
+
export interface LinkedNote {
|
|
125
|
+
id: string;
|
|
126
|
+
path: string;
|
|
127
|
+
frontmatter: Record<string, unknown>;
|
|
128
|
+
body: string;
|
|
129
|
+
links: string[];
|
|
130
|
+
version: string;
|
|
131
|
+
linkedNotes: LinkedNote[];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Read and parse one Markdown note, following configured link depth.
|
|
135
|
+
* @param root - vault root.
|
|
136
|
+
* @param extensions - note extensions.
|
|
137
|
+
* @param maxLinkDepth - how many link hops to resolve.
|
|
138
|
+
* @param absolutePath - absolute path of the note.
|
|
139
|
+
* @param visited - set of already-visited absolute paths to prevent cycles.
|
|
140
|
+
* @returns the parsed note with linked notes attached.
|
|
141
|
+
*/
|
|
142
|
+
export declare function readNote(root: string, extensions: string[], maxLinkDepth: number, absolutePath: string, visited?: ReadonlySet<string>): Promise<Note & {
|
|
143
|
+
linkedNotes: LinkedNote[];
|
|
144
|
+
}>;
|
|
145
|
+
/**
|
|
146
|
+
* Build a search index of note titles, bodies, and backlinks. The title is the
|
|
147
|
+
* first Markdown `# heading` or the basename without extension; the body keeps
|
|
148
|
+
* keyword search honest about the documented "titles and bodies" contract.
|
|
149
|
+
* @param root - vault root.
|
|
150
|
+
* @param extensions - note extensions.
|
|
151
|
+
* @param indexHiddenDirs - descend into dot-directories besides the fixed exclusions.
|
|
152
|
+
* @returns a map from note id to search result.
|
|
153
|
+
*/
|
|
154
|
+
export declare function buildIndex(root: string, extensions: string[], indexHiddenDirs?: boolean): Promise<Map<string, IndexedNote>>;
|
|
155
|
+
/**
|
|
156
|
+
* Register the `wiki_read`, `wiki_search`, and `wiki_write` tools on
|
|
157
|
+
* `ctx.tools`.
|
|
158
|
+
* @param ctx - registrant context carrying the tool registry.
|
|
159
|
+
* @param config - deployment's explicit vault configuration.
|
|
160
|
+
*/
|
|
161
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
162
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for filesystem-backed wiki/memory notes.
|
|
3
|
+
* @module @jacklika/dsh-tool-memory-filesystem/types
|
|
4
|
+
*/
|
|
5
|
+
/** Parsed frontmatter and body of one Markdown note. */
|
|
6
|
+
export interface Note {
|
|
7
|
+
/** Absolute path to the source file. */
|
|
8
|
+
path: string;
|
|
9
|
+
/** Relative path inside the vault, used as a stable note id. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** YAML frontmatter key/value map. */
|
|
12
|
+
frontmatter: Record<string, unknown>;
|
|
13
|
+
/** Note body after frontmatter. */
|
|
14
|
+
body: string;
|
|
15
|
+
/** Obsidian-style `[[target]]` links found in the body. */
|
|
16
|
+
links: string[];
|
|
17
|
+
/**
|
|
18
|
+
* Content fingerprint of the raw file at read time. Pass it back as
|
|
19
|
+
* `baseVersion` in `wiki_write` to fail when another writer changed the
|
|
20
|
+
* note in between.
|
|
21
|
+
*/
|
|
22
|
+
version: string;
|
|
23
|
+
}
|
|
24
|
+
/** Result of a search across the vault. */
|
|
25
|
+
export interface SearchResult {
|
|
26
|
+
/** Note id (relative path). */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Note title: first heading, or the basename without extension. */
|
|
29
|
+
title: string;
|
|
30
|
+
/** Paths of notes that link to this note. */
|
|
31
|
+
backlinks: string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* In-memory index entry: the public hit plus the note body kept as keyword
|
|
35
|
+
* haystack. `wiki_search` matches against the body but never emits it, so the
|
|
36
|
+
* tool output stays a bounded metadata list.
|
|
37
|
+
*/
|
|
38
|
+
export interface IndexedNote extends SearchResult {
|
|
39
|
+
/** Note body text used for keyword matching only. */
|
|
40
|
+
body: string;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=types.d.ts.map
|
package/lib/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jacklika/dsh-tool-memory-filesystem",
|
|
3
|
+
"description": "Model-facing wiki/memory tools over a local Markdown vault: read, search, and write notes with Obsidian-style links and YAML frontmatter.",
|
|
4
|
+
"version": "0.1.3-alpha.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/JackLiKa/jacklika-agent-plugins-repo.git",
|
|
11
|
+
"directory": "packages/tool-memory-filesystem"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
16
|
+
},
|
|
17
|
+
"main": "lib/index.js",
|
|
18
|
+
"types": "lib/types/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./lib/types/index.d.ts",
|
|
22
|
+
"default": "./lib/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"lib/**/*.js",
|
|
28
|
+
"lib/types/**/*.d.ts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"README.zh.md"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@deepseek-ai/schemastery": "3.18.4",
|
|
35
|
+
"js-yaml": "^4.2.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
39
|
+
"@deepseek-ai/dsh-tools": ">=0.1.7-rc.1 <0.3.0",
|
|
40
|
+
"@deepseek-ai/dsh-util-values": ">=0.1.7-rc.1 <0.3.0"
|
|
41
|
+
}
|
|
42
|
+
}
|