@jacklika/dsh-tool-memory-filesystem 0.1.3-alpha.11 → 0.1.3-alpha.13
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 +5 -5
- package/README.zh.md +5 -5
- package/lib/index.js +42 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,20 +52,20 @@ A relative `vaultRoot` resolves against the calling session's workspace.
|
|
|
52
52
|
|
|
53
53
|
### Tools
|
|
54
54
|
|
|
55
|
-
- `wiki_read(id)` — read one note by vault-relative path and return its frontmatter, body, links, linked notes, a `version` content fingerprint, the file's `mtime` as a `+08:00` timestamp, and a `modifiedExternally` flag that is `true` when the file changed on disk since this plugin last observed it (e.g. an Obsidian edit). The plugin keeps a bounded 256-entry LRU of observed mtimes keyed by absolute path; its own `wiki_write` re-records the mtime so plugin writes never flag. Failures come back as structured errors that name the vault-relative id (`cannot read note "x.md": no such note`) and never embed host paths.
|
|
55
|
+
- `wiki_read(id)` — read one note by vault-relative path and return its frontmatter, body, links, linked notes, a `version` content fingerprint, the file's `mtime` as a `+08:00` timestamp, and a `modifiedExternally` flag that is `true` when the file changed on disk since this plugin last observed it (e.g. an Obsidian edit). The plugin keeps a bounded 256-entry LRU of observed mtimes keyed by absolute path; its own `wiki_write` re-records the mtime so plugin writes never flag. Failures come back as structured errors that name the vault-relative id (`cannot read note "x.md": no such note`) and never embed host paths, so a read issued before the vault's first write — when the vault root itself does not exist yet — reports the same `no such note` as a missing file in a vault that already exists.
|
|
56
56
|
- `wiki_search(query)` — keyword search across note titles, ids, and bodies; query terms are OR-matched and ranked by field-weighted BM25-style scoring (title/id hits weigh most, rare terms weigh more) plus a verbatim-phrase bonus and a link-graph boost; when `wiki_semantic_search` is mounted the two rankings fuse via reciprocal rank fusion. Returns a `{ hits, total, truncated }` page: each hit carries the note id, title, score, and backlinks; `total` counts every match *before* the `maxSearchResults` cap; `truncated` is `true` when that cap cut the list, so a caller can tell a complete result set from a partial one instead of assuming the vault holds nothing else. Entries that cannot be read (deleted mid-scan, permission denied) are skipped and logged rather than failing the call.
|
|
57
|
-
- `wiki_write(id, content, mode?, baseVersion?)` — create or append to a note. Append mode preserves frontmatter and adds a timestamp header; both write modes normalize `created`/`updated` frontmatter values to `+08:00` second precision (unparseable values pass through). Passing a `version` from `wiki_read` as `baseVersion` makes the write fail loudly when another writer changed the note in between. The result is `{ id, mode, bytes, version }`, where `version` fingerprints the bytes just published — feed it straight back as `baseVersion` on the next write to chain optimistic-concurrency checks without a `wiki_read` round-trip. Writing through an in-vault symlink updates the linked note and keeps the link (a broken link is refused), and atomic replacement carries the note's existing permission bits to the new file.
|
|
57
|
+
- `wiki_write(id, content, mode?, baseVersion?, requireExisting?)` — create or append to a note. Append mode preserves frontmatter and adds a timestamp header; both write modes normalize `created`/`updated` frontmatter values to `+08:00` second precision (unparseable values pass through). Passing a `version` from `wiki_read` as `baseVersion` makes the write fail loudly when another writer changed the note in between. Setting `requireExisting: true` makes the write update-only: the note must already exist, and a missing note fails instead of being created (no parent directory is left behind), so a caller that addressed the wrong id gets an error rather than a stray note. The result is `{ id, mode, bytes, version }`, where `version` fingerprints the bytes just published — feed it straight back as `baseVersion` on the next write to chain optimistic-concurrency checks without a `wiki_read` round-trip. Writing through an in-vault symlink updates the linked note and keeps the link (a broken link is refused), and atomic replacement carries the note's existing permission bits to the new file.
|
|
58
58
|
|
|
59
59
|
### Security
|
|
60
60
|
|
|
61
|
-
All paths are resolved under the vault root for that call; a path that escapes the vault is rejected. In-vault symlinks are followed to their real target, which is re-checked for containment, so a symlinked note is appended through rather than replaced; a broken symlink is refused. Error messages stay vault-relative and never embed host paths. 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.
|
|
61
|
+
All paths are resolved under the vault root for that call; a path that escapes the vault is rejected. Note ids are addressed with `/` only: an id containing a backslash is rejected with a structured error instead of being read as a platform-dependent separator, so the same id cannot name different files on different systems, and percent-encoding is never decoded, so `%2e%2e` is a literal name. `[[link]]` targets stay lenient where ids are strict — a target written with Windows-style separators resolves the same as its `/` form. In-vault symlinks are followed to their real target, which is re-checked for containment, so a symlinked note is appended through rather than replaced; a broken symlink is refused. Error messages stay vault-relative and never embed host paths. 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
62
|
|
|
63
63
|
-----
|
|
64
64
|
|
|
65
65
|
<a id="understand-the-implementation"></a>
|
|
66
66
|
## Understand the implementation
|
|
67
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 plus a realpath containment re-check. YAML frontmatter is parsed with `js-yaml` (a leading UTF-8 BOM before the opening `---` is tolerated, and duplicate keys resolve last-one-wins instead of dropping the block); `[[link|alias]]` references extract the target before the pipe; `[[...]]` inside inline code spans or fenced code blocks is documentation, not a link, and is ignored. Link targets resolve Obsidian-style against the indexed note ids: an exact vault-relative path first, then a path-suffix match (`[[notes/foo]]` finds `shared/notes/foo.md`), then a basename match anywhere in the vault — each tier tolerates the target carrying or omitting the extension. Ambiguous matches pick the fewest path segments, then the lowest id in code-point order, so resolution is deterministic across platforms. Search builds a transient index from the vault contents and ranks it in layers: OR-matched field-weighted scoring (id/title ×3, backlinks ×1, body ×1, with IDF so rare terms weigh more and BM25 saturation plus length normalization so long notes do not dominate), a verbatim-phrase bonus, and a graph boost for notes linked from strong hits. When `wiki_semantic_search` is registered, its ranking is fused in via reciprocal rank fusion; semantic failures fall back to the lexical ranking. The transient index skips entries that cannot be read (each logged as a warning) so one bad file cannot fail a scan. `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; the staged file inherits the target's permission bits and is removed again if staging or rename fails.
|
|
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 plus a realpath containment re-check. YAML frontmatter is parsed with `js-yaml` (a leading UTF-8 BOM before the opening `---` is tolerated, and duplicate keys resolve last-one-wins instead of dropping the block); `[[link|alias]]` references extract the target before the pipe; `[[...]]` inside inline code spans or fenced code blocks is documentation, not a link, and is ignored. Link targets resolve Obsidian-style against the indexed note ids: an exact vault-relative path first, then a path-suffix match (`[[notes/foo]]` finds `shared/notes/foo.md`), then a basename match anywhere in the vault — each tier tolerates the target carrying or omitting the extension. Ambiguous matches pick the fewest path segments, then the lowest id in code-point order, so resolution is deterministic across platforms, and Windows-style separators in a link target normalize to `/` the same way the resolver's own normalization does. Note ids, by contrast, reject a backslash outright, so the strict and lenient sides of the rule never disagree about which file a name denotes. Search builds a transient index from the vault contents and ranks it in layers: OR-matched field-weighted scoring (id/title ×3, backlinks ×1, body ×1, with IDF so rare terms weigh more and BM25 saturation plus length normalization so long notes do not dominate), a verbatim-phrase bonus, and a graph boost for notes linked from strong hits. When `wiki_semantic_search` is registered, its ranking is fused in via reciprocal rank fusion; semantic failures fall back to the lexical ranking. The transient index skips entries that cannot be read (each logged as a warning) so one bad file cannot fail a scan. `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; the staged file inherits the target's permission bits and is removed again if staging or rename fails.
|
|
69
69
|
|
|
70
70
|
-----
|
|
71
71
|
|
|
@@ -93,7 +93,7 @@ Search the wiki vault by note title or body keyword. Terms are OR-matched; resul
|
|
|
93
93
|
##### Verbatim description for `wiki_write`
|
|
94
94
|
|
|
95
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. Returns { id, mode, bytes, version }, where version fingerprints the written content and can be passed as baseVersion on the next write without reading the note again.
|
|
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. Set requireExisting to update a note that must already exist: the write then fails instead of creating a new note. Returns { id, mode, bytes, version }, where version fingerprints the written content and can be passed as baseVersion on the next write without reading the note again.
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
#### Token effect
|
package/README.zh.md
CHANGED
|
@@ -52,20 +52,20 @@ kind: "package-reference"
|
|
|
52
52
|
|
|
53
53
|
### 工具
|
|
54
54
|
|
|
55
|
-
- `wiki_read(id)` — 按仓库相对路径读取笔记,返回 frontmatter、正文、链接、已解析的链接笔记、内容指纹 `version`、`+08:00` 格式的文件 `mtime`,以及 `modifiedExternally` 标志(文件自本插件上次观察以来在磁盘上被改动时为 `true`,例如 Obsidian 编辑)。插件按绝对路径维护一个 256 条上限的 LRU mtime 表;自身的 `wiki_write` 会刷新记录,因此插件写入永不误报。失败会以结构化错误返回,错误信息只含仓库相对 id(如 `cannot read note "x.md": no such note
|
|
55
|
+
- `wiki_read(id)` — 按仓库相对路径读取笔记,返回 frontmatter、正文、链接、已解析的链接笔记、内容指纹 `version`、`+08:00` 格式的文件 `mtime`,以及 `modifiedExternally` 标志(文件自本插件上次观察以来在磁盘上被改动时为 `true`,例如 Obsidian 编辑)。插件按绝对路径维护一个 256 条上限的 LRU mtime 表;自身的 `wiki_write` 会刷新记录,因此插件写入永不误报。失败会以结构化错误返回,错误信息只含仓库相对 id(如 `cannot read note "x.md": no such note`),绝不嵌入宿主绝对路径;因此在仓库首次写入之前(仓库根目录尚不存在时)发起的读取,与已存在仓库中读取缺失文件一样返回 `no such note`。
|
|
56
56
|
- `wiki_search(query)` — 按标题、id、正文关键词搜索;查询词采用 OR 匹配,并按字段加权的 BM25 式评分排序(标题/id 命中权重最高,稀有词权重更高),另有整段短语加分与被强命中笔记链接的图增强;挂载 `wiki_semantic_search` 时两路排序经 RRF 融合。返回 `{ hits, total, truncated }` 分页结构:每条命中含笔记 id、标题、分数与反向链接;`total` 是 `maxSearchResults` 截断**之前**的全部命中数;`truncated` 为 `true` 表示结果被该上限截断,调用方因此能区分“结果完整”与“还有更多”,而不会误以为仓库里只有这些。无法读取的条目(扫描途中被删除、权限不足)会被跳过并记入日志,而不是让整次调用失败。
|
|
57
|
-
- `wiki_write(id, content, mode?, baseVersion?)` — 创建或追加笔记。追加模式保留 frontmatter 并添加时间戳标题;两种写入模式都会把 `created`/`updated` frontmatter 值归一化为 `+08:00` 秒级精度(不可解析的值原样保留)。把 `wiki_read` 返回的 `version` 作为 `baseVersion`
|
|
57
|
+
- `wiki_write(id, content, mode?, baseVersion?, requireExisting?)` — 创建或追加笔记。追加模式保留 frontmatter 并添加时间戳标题;两种写入模式都会把 `created`/`updated` frontmatter 值归一化为 `+08:00` 秒级精度(不可解析的值原样保留)。把 `wiki_read` 返回的 `version` 作为 `baseVersion` 传入时,若笔记在读取后被其他写入方改动,写入会显式失败。设置 `requireExisting: true` 表示只更新、不新建:笔记必须已存在,缺失时写入直接失败而不会创建(也不会留下空父目录),调用方若写错了 id 会收到错误而不是多出一篇笔记。返回值为 `{ id, mode, bytes, version }`,其中 `version` 就是刚发布内容的指纹——可直接作为下一次写入的 `baseVersion`,无需再 `wiki_read` 一次即可串联乐观并发校验。通过仓库内 symlink 写入会更新链接指向的笔记并保留链接本身(损坏的链接会被拒绝);原子替换会把笔记原有的权限位带到新文件。
|
|
58
58
|
|
|
59
59
|
### 安全
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
所有路径都必须在该次调用解析出的仓库根目录下;越界路径会被拒绝。笔记 id 只使用 `/` 作为分隔符:id 中出现反斜杠会直接以结构化错误拒绝,而不会被当作随平台变化的分隔符,因此同一个 id 在不同系统上不会指向不同文件;百分号编码也从不解码,`%2e%2e` 只是一个字面名字。`[[link]]` 链接目标则保持宽松:用 Windows 风格分隔符写出的目标与其 `/` 形式解析结果一致。仓库内的 symlink 会先解析到真实目标并重新做包含校验,因此追加写入落在链接目标上而非替换链接;损坏的 symlink 会被拒绝。错误信息保持仓库相对路径,不嵌入宿主绝对路径。该插件不通过 `ctx.fs` 访问文件,因此不继承文件沙箱策略;访问权限由 OS 文件权限和本插件的越界检查共同控制。
|
|
62
62
|
|
|
63
63
|
-----
|
|
64
64
|
|
|
65
65
|
<a id="understand-the-implementation"></a>
|
|
66
66
|
## 实现说明
|
|
67
67
|
|
|
68
|
-
本包是一个没有运行时服务的 Cordis 函数插件,只在 `ctx.tools` 上注册三个类型化工具。仓库根目录在每次工具调用时解析:优先使用显式 `vaultRoot`(相对路径锚定会话工作区),否则使用 `<session cwd>/.dsh/memory/`,使每个工作区拥有自己的笔记。每个工具都通过 `node:fs/promises` 直接读取 Markdown 文件,并借助 `path.resolve` 词法前缀检查加 realpath 包含复检保持在解析出的根目录内。YAML frontmatter 使用 `js-yaml` 解析(开头 `---` 前带 UTF-8 BOM 亦可识别;frontmatter 出现重复键时按“后者胜出”处理,而不是丢弃整个块);`[[link|alias]]` 形式的引用会提取管道符前的目标;出现在行内代码或围栏代码块中的 `[[...]]` 是语法示例而非链接,会被忽略。链接目标按 Obsidian 语义对已索引的笔记 id 解析:先精确匹配仓库相对路径,再做路径后缀匹配(`[[notes/foo]]` 可命中 `shared/notes/foo.md`),最后匹配仓库内任意位置的 basename——每一级都容忍目标带或不带扩展名。多重命中时优先路径段最少者,仍并列则取码点序最小的 id
|
|
68
|
+
本包是一个没有运行时服务的 Cordis 函数插件,只在 `ctx.tools` 上注册三个类型化工具。仓库根目录在每次工具调用时解析:优先使用显式 `vaultRoot`(相对路径锚定会话工作区),否则使用 `<session cwd>/.dsh/memory/`,使每个工作区拥有自己的笔记。每个工具都通过 `node:fs/promises` 直接读取 Markdown 文件,并借助 `path.resolve` 词法前缀检查加 realpath 包含复检保持在解析出的根目录内。YAML frontmatter 使用 `js-yaml` 解析(开头 `---` 前带 UTF-8 BOM 亦可识别;frontmatter 出现重复键时按“后者胜出”处理,而不是丢弃整个块);`[[link|alias]]` 形式的引用会提取管道符前的目标;出现在行内代码或围栏代码块中的 `[[...]]` 是语法示例而非链接,会被忽略。链接目标按 Obsidian 语义对已索引的笔记 id 解析:先精确匹配仓库相对路径,再做路径后缀匹配(`[[notes/foo]]` 可命中 `shared/notes/foo.md`),最后匹配仓库内任意位置的 basename——每一级都容忍目标带或不带扩展名。多重命中时优先路径段最少者,仍并列则取码点序最小的 id,因此解析结果跨平台确定;链接目标中的 Windows 风格分隔符会与解析器自身的归一化一致地转成 `/`。与之相对,笔记 id 直接拒绝反斜杠,严格与宽松两侧因此不会对“名字指向哪个文件”产生分歧。搜索时从仓库内容构建临时索引(无法读取的条目会被跳过并记入日志,单个坏文件不会使整次扫描失败)并分层排序:OR 匹配的字段加权评分(id/标题 ×3、反向链接 ×1、正文 ×1,含 IDF 使稀有词权重更高、BM25 饱和与长度归一化避免长笔记占优)、整段短语加分,以及被强命中笔记链接的图增强。挂载 `wiki_semantic_search` 时其排序经倒数排名融合并入;语义检索失败时回退到纯词法排序。`wiki_write` 以原子方式发布:正文先写入同级临时文件,再 `rename` 覆盖目标,因此并发读者永远不会读到写了一半的笔记;临时文件继承目标笔记的权限位,且写入或 rename 失败时会被清理。
|
|
69
69
|
|
|
70
70
|
-----
|
|
71
71
|
|
|
@@ -93,7 +93,7 @@ Search the wiki vault by note title or body keyword. Terms are OR-matched; resul
|
|
|
93
93
|
##### `wiki_write` 的完整描述
|
|
94
94
|
|
|
95
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. Returns { id, mode, bytes, version }, where version fingerprints the written content and can be passed as baseVersion on the next write without reading the note again.
|
|
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. Set requireExisting to update a note that must already exist: the write then fails instead of creating a new note. Returns { id, mode, bytes, version }, where version fingerprints the written content and can be passed as baseVersion on the next write without reading the note again.
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
#### Token 影响
|
package/lib/index.js
CHANGED
|
@@ -67,6 +67,12 @@ export function containedPath(root, candidate) {
|
|
|
67
67
|
if (candidate.includes('\0')) {
|
|
68
68
|
throw new Error('tool-memory-filesystem: note id must not contain a NUL byte');
|
|
69
69
|
}
|
|
70
|
+
// A backslash is a path separator only on Windows; the same id would name
|
|
71
|
+
// different files per platform. Note ids always use '/', matching the id
|
|
72
|
+
// rules the scope and curator plugins already enforce.
|
|
73
|
+
if (candidate.includes('\\')) {
|
|
74
|
+
throw new Error(`tool-memory-filesystem: note id ${JSON.stringify(candidate)} must not contain a backslash; use "/" as the path separator`);
|
|
75
|
+
}
|
|
70
76
|
const absoluteRoot = resolve(root);
|
|
71
77
|
const absolute = resolve(absoluteRoot, candidate);
|
|
72
78
|
const remainder = relative(absoluteRoot, absolute);
|
|
@@ -360,13 +366,18 @@ export function createLinkResolver(ids) {
|
|
|
360
366
|
* @returns the resolved absolute path, or `undefined` if no file exists.
|
|
361
367
|
*/
|
|
362
368
|
export async function resolveLinkTarget(root, extensions, target, resolve) {
|
|
369
|
+
// Link targets stay lenient where ids are strict: the resolver looks targets
|
|
370
|
+
// up rather than addressing files, so Windows-style separators normalize to
|
|
371
|
+
// '/' the same way {@link createLinkResolver} does. Percent-encoding is not
|
|
372
|
+
// decoded on either path, so `%2e%2e` is a literal name, never an escape.
|
|
373
|
+
const normalized = target.replace(/\\/g, '/');
|
|
363
374
|
if (resolve !== undefined) {
|
|
364
|
-
const id = resolve(
|
|
375
|
+
const id = resolve(normalized);
|
|
365
376
|
if (id !== undefined)
|
|
366
377
|
return containedPathReal(root, id);
|
|
367
378
|
}
|
|
368
|
-
const candidates = extensions.map(ext => `${
|
|
369
|
-
candidates.push(
|
|
379
|
+
const candidates = extensions.map(ext => `${normalized}${dottedExtension(ext)}`);
|
|
380
|
+
candidates.push(normalized);
|
|
370
381
|
for (const candidate of candidates) {
|
|
371
382
|
// A NUL byte can never name a real note; skip it instead of aborting the
|
|
372
383
|
// whole read over one malformed link.
|
|
@@ -669,7 +680,16 @@ export function apply(ctx, config) {
|
|
|
669
680
|
},
|
|
670
681
|
async execute(args, exec) {
|
|
671
682
|
const vaultRoot = vaultRootFor(exec);
|
|
672
|
-
const absolutePath = await containedPathReal(vaultRoot, args.id)
|
|
683
|
+
const absolutePath = await containedPathReal(vaultRoot, args.id)
|
|
684
|
+
.catch(error => {
|
|
685
|
+
// A vault whose root does not exist yet simply holds no such note.
|
|
686
|
+
// An errno failure always carries an absolute host path in its raw
|
|
687
|
+
// message, so it is rewritten; a containment or id rejection has no
|
|
688
|
+
// errno and keeps its own message.
|
|
689
|
+
if (error.code !== undefined)
|
|
690
|
+
throw noteIoError('read', args.id, error);
|
|
691
|
+
throw error;
|
|
692
|
+
});
|
|
673
693
|
const linkResolver = createLinkResolver((await listNotePaths(vaultRoot, resolved.extensions, resolved.indexHiddenDirs, skipLogger(ctx, vaultRoot)))
|
|
674
694
|
.map(path => vaultRelativeId(vaultRoot, path)));
|
|
675
695
|
const note = await readNote(vaultRoot, resolved.extensions, resolved.maxLinkDepth, absolutePath, new Set(), linkResolver)
|
|
@@ -760,7 +780,7 @@ export function apply(ctx, config) {
|
|
|
760
780
|
})));
|
|
761
781
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
762
782
|
name: 'wiki_write',
|
|
763
|
-
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. Returns { id, mode, bytes, version }, where version fingerprints the written content and can be passed as baseVersion on the next write without reading the note again.',
|
|
783
|
+
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. Set requireExisting to update a note that must already exist: the write then fails instead of creating a new note. Returns { id, mode, bytes, version }, where version fingerprints the written content and can be passed as baseVersion on the next write without reading the note again.',
|
|
764
784
|
parameters: {
|
|
765
785
|
id: {
|
|
766
786
|
type: 'string',
|
|
@@ -781,6 +801,10 @@ export function apply(ctx, config) {
|
|
|
781
801
|
type: 'string',
|
|
782
802
|
description: 'Optional version returned by wiki_read or by an earlier wiki_write. When provided, the write fails if the note changed since that version.',
|
|
783
803
|
},
|
|
804
|
+
requireExisting: {
|
|
805
|
+
type: 'boolean',
|
|
806
|
+
description: 'Refuse to create the note: the write must target an existing note. Use this for update-only writes, where a missing note means the caller addressed the wrong id rather than intending a new note.',
|
|
807
|
+
},
|
|
784
808
|
},
|
|
785
809
|
output: {
|
|
786
810
|
schema: {
|
|
@@ -808,14 +832,6 @@ export function apply(ctx, config) {
|
|
|
808
832
|
throw new Error(`tool-memory-filesystem: note id must end with one of ${resolved.extensions.join(', ')}`);
|
|
809
833
|
}
|
|
810
834
|
const writeTarget = await resolveSymlinkWriteTarget(vaultRoot, absolutePath, args.id);
|
|
811
|
-
try {
|
|
812
|
-
await mkdir(dirname(writeTarget), { recursive: true });
|
|
813
|
-
}
|
|
814
|
-
catch (error) {
|
|
815
|
-
throw noteIoError('write', args.id, error);
|
|
816
|
-
}
|
|
817
|
-
await containedPathReal(vaultRoot, args.id);
|
|
818
|
-
exec.signal.throwIfAborted();
|
|
819
835
|
let existing;
|
|
820
836
|
try {
|
|
821
837
|
existing = await readFile(writeTarget, 'utf8');
|
|
@@ -827,6 +843,19 @@ export function apply(ctx, config) {
|
|
|
827
843
|
if (error.code !== 'ENOENT')
|
|
828
844
|
throw noteIoError('write', args.id, error);
|
|
829
845
|
}
|
|
846
|
+
if (existing === undefined && args.requireExisting === true) {
|
|
847
|
+
throw new Error(`tool-memory-filesystem: note ${JSON.stringify(args.id)} does not exist; refusing to create it`);
|
|
848
|
+
}
|
|
849
|
+
// Parents are created only after the existence rules passed, so a
|
|
850
|
+
// refused update leaves no empty directory behind.
|
|
851
|
+
try {
|
|
852
|
+
await mkdir(dirname(writeTarget), { recursive: true });
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
855
|
+
throw noteIoError('write', args.id, error);
|
|
856
|
+
}
|
|
857
|
+
await containedPathReal(vaultRoot, args.id);
|
|
858
|
+
exec.signal.throwIfAborted();
|
|
830
859
|
if (args.baseVersion !== undefined && noteVersion(existing ?? '') !== args.baseVersion) {
|
|
831
860
|
throw new Error(`tool-memory-filesystem: note ${args.id} changed since it was read; re-read it before writing`);
|
|
832
861
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacklika/dsh-tool-memory-filesystem",
|
|
3
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.
|
|
4
|
+
"version": "0.1.3-alpha.13",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|