@jacklika/dsh-tool-memory-filesystem 0.1.3-alpha.4 → 0.1.3-alpha.6
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 +7 -7
- package/README.zh.md +7 -7
- package/lib/index.js +252 -29
- package/lib/search.js +150 -0
- package/lib/types/index.d.ts +32 -4
- package/lib/types/search.d.ts +41 -0
- package/lib/types/types.d.ts +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,9 +52,9 @@ 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,
|
|
56
|
-
- `wiki_search(query)` — keyword search across note titles, ids, and bodies;
|
|
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.
|
|
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.
|
|
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. Results include the score and backlink counts.
|
|
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.
|
|
58
58
|
|
|
59
59
|
### Security
|
|
60
60
|
|
|
@@ -65,7 +65,7 @@ All paths are resolved under the vault root for that call; a path that escapes t
|
|
|
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. 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
|
|
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; `[[...]]` 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. `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
69
|
|
|
70
70
|
-----
|
|
71
71
|
|
|
@@ -76,18 +76,18 @@ This package is a single Cordis function plugin with no runtime service. It regi
|
|
|
76
76
|
|
|
77
77
|
#### What the model sees
|
|
78
78
|
|
|
79
|
-
The model sees the generated
|
|
79
|
+
The model sees the generated `wiki_read`, `wiki_search`, and `wiki_write` schemas. 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
80
|
|
|
81
81
|
##### Verbatim description for `wiki_read`
|
|
82
82
|
|
|
83
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
|
|
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, linked notes, mtime, and a modifiedExternally flag that is true when the file changed on disk since this tool last observed it.
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
##### Verbatim description for `wiki_search`
|
|
88
88
|
|
|
89
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.
|
|
90
|
+
Search the wiki vault by note title or body keyword. Terms are OR-matched; results are ranked by field-weighted relevance (title/id hits outrank body hits, rare terms weigh more, notes linked from strong hits get a boost, and results may be fused with semantic search when available). Returns matching note ids, titles, scores, and backlink counts. Use this before asking the user which note to read.
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
##### Verbatim description for `wiki_write`
|
package/README.zh.md
CHANGED
|
@@ -52,9 +52,9 @@ kind: "package-reference"
|
|
|
52
52
|
|
|
53
53
|
### 工具
|
|
54
54
|
|
|
55
|
-
- `wiki_read(id)` — 按仓库相对路径读取笔记,返回 frontmatter
|
|
56
|
-
- `wiki_search(query)` — 按标题、id
|
|
57
|
-
- `wiki_write(id, content, mode?, baseVersion?)` — 创建或追加笔记。追加模式保留 frontmatter
|
|
55
|
+
- `wiki_read(id)` — 按仓库相对路径读取笔记,返回 frontmatter、正文、链接、已解析的链接笔记、内容指纹 `version`、`+08:00` 格式的文件 `mtime`,以及 `modifiedExternally` 标志(文件自本插件上次观察以来在磁盘上被改动时为 `true`,例如 Obsidian 编辑)。插件按绝对路径维护一个 256 条上限的 LRU mtime 表;自身的 `wiki_write` 会刷新记录,因此插件写入永不误报。
|
|
56
|
+
- `wiki_search(query)` — 按标题、id、正文关键词搜索;查询词采用 OR 匹配,并按字段加权的 BM25 式评分排序(标题/id 命中权重最高,稀有词权重更高),另有整段短语加分与被强命中笔记链接的图增强;挂载 `wiki_semantic_search` 时两路排序经 RRF 融合。结果包含分数与反向链接数量。
|
|
57
|
+
- `wiki_write(id, content, mode?, baseVersion?)` — 创建或追加笔记。追加模式保留 frontmatter 并添加时间戳标题;两种写入模式都会把 `created`/`updated` frontmatter 值归一化为 `+08:00` 秒级精度(不可解析的值原样保留)。把 `wiki_read` 返回的 `version` 作为 `baseVersion` 传入时,若笔记在读取后被其他写入方改动,写入会显式失败。
|
|
58
58
|
|
|
59
59
|
### 安全
|
|
60
60
|
|
|
@@ -65,7 +65,7 @@ kind: "package-reference"
|
|
|
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` + 前缀检查保持在解析出的根目录内。YAML frontmatter 使用 `js-yaml` 解析;`[[link|alias]]`
|
|
68
|
+
本包是一个没有运行时服务的 Cordis 函数插件,只在 `ctx.tools` 上注册三个类型化工具。仓库根目录在每次工具调用时解析:优先使用显式 `vaultRoot`(相对路径锚定会话工作区),否则使用 `<session cwd>/.dsh/memory/`,使每个工作区拥有自己的笔记。每个工具都通过 `node:fs/promises` 直接读取 Markdown 文件,并借助 `path.resolve` + 前缀检查保持在解析出的根目录内。YAML frontmatter 使用 `js-yaml` 解析;`[[link|alias]]` 形式的引用会提取管道符前的目标;出现在行内代码或围栏代码块中的 `[[...]]` 是语法示例而非链接,会被忽略。链接目标按 Obsidian 语义对已索引的笔记 id 解析:先精确匹配仓库相对路径,再做路径后缀匹配(`[[notes/foo]]` 可命中 `shared/notes/foo.md`),最后匹配仓库内任意位置的 basename——每一级都容忍目标带或不带扩展名。多重命中时优先路径段最少者,仍并列则取码点序最小的 id,因此解析结果跨平台确定。搜索时从仓库内容构建临时索引并分层排序:OR 匹配的字段加权评分(id/标题 ×3、反向链接 ×1、正文 ×1,含 IDF 使稀有词权重更高、BM25 饱和与长度归一化避免长笔记占优)、整段短语加分,以及被强命中笔记链接的图增强。挂载 `wiki_semantic_search` 时其排序经倒数排名融合并入;语义检索失败时回退到纯词法排序。`wiki_write` 以原子方式发布:正文先写入同级临时文件,再 `rename` 覆盖目标,因此并发读者永远不会读到写了一半的笔记。
|
|
69
69
|
|
|
70
70
|
-----
|
|
71
71
|
|
|
@@ -76,18 +76,18 @@ kind: "package-reference"
|
|
|
76
76
|
|
|
77
77
|
#### 模型看到的内容
|
|
78
78
|
|
|
79
|
-
模型看到的是生成的
|
|
79
|
+
模型看到的是生成的 `wiki_read`、`wiki_search` 和 `wiki_write` schema。工具描述会告知模型:笔记是带 YAML frontmatter 和 Obsidian 风格 `[[link]]` 链接的 Markdown 文件,`wiki_read` 会按配置深度跟随链接。
|
|
80
80
|
|
|
81
81
|
##### `wiki_read` 的完整描述
|
|
82
82
|
|
|
83
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
|
|
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, linked notes, mtime, and a modifiedExternally flag that is true when the file changed on disk since this tool last observed it.
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
##### `wiki_search` 的完整描述
|
|
88
88
|
|
|
89
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.
|
|
90
|
+
Search the wiki vault by note title or body keyword. Terms are OR-matched; results are ranked by field-weighted relevance (title/id hits outrank body hits, rare terms weigh more, notes linked from strong hits get a boost, and results may be fused with semantic search when available). Returns matching note ids, titles, scores, and backlink counts. Use this before asking the user which note to read.
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
##### `wiki_write` 的完整描述
|
package/lib/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import z from '@deepseek-ai/schemastery';
|
|
|
14
14
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
15
15
|
import { formatBeijingTime } from '@jacklika/dsh-memory-time';
|
|
16
16
|
import yaml from 'js-yaml';
|
|
17
|
+
import { codeUnitCompare, fuseRankings, rankNotes } from "./search.js";
|
|
17
18
|
/**
|
|
18
19
|
* Compute a vault-relative note id from an absolute path. The result always
|
|
19
20
|
* uses POSIX separators so note ids are stable across Windows, macOS, and Linux.
|
|
@@ -111,12 +112,16 @@ export function splitFrontmatter(text) {
|
|
|
111
112
|
const bodyText = match[2] ?? '';
|
|
112
113
|
try {
|
|
113
114
|
const parsed = yaml.load(frontmatterText);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
const frontmatter = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
116
|
+
? parsed
|
|
117
|
+
: {};
|
|
118
|
+
// js-yaml coerces unquoted timestamps to Date; vault convention stores them
|
|
119
|
+
// as Asia/Shanghai +08:00 strings, so convert back before they reach callers.
|
|
120
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
121
|
+
if (value instanceof Date)
|
|
122
|
+
frontmatter[key] = formatBeijingTime(value);
|
|
123
|
+
}
|
|
124
|
+
return { frontmatter, body: bodyText };
|
|
120
125
|
}
|
|
121
126
|
catch {
|
|
122
127
|
return { frontmatter: {}, body: text };
|
|
@@ -131,6 +136,68 @@ export function splitFrontmatter(text) {
|
|
|
131
136
|
export function noteVersion(text) {
|
|
132
137
|
return createHash('sha1').update(text, 'utf8').digest('hex');
|
|
133
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Last `mtimeMs` observed per note path, keyed by absolute path so identical
|
|
141
|
+
* note ids in different vaults never collide. Bounded at 256 entries with
|
|
142
|
+
* LRU eviction: an unbounded map would grow with every note ever read in a
|
|
143
|
+
* long-running process, while a fixed cap keeps the newest observations —
|
|
144
|
+
* the only ones a `modifiedExternally` comparison can still act on.
|
|
145
|
+
*/
|
|
146
|
+
const observedMtimes = new Map();
|
|
147
|
+
const OBSERVED_MTIME_LIMIT = 256;
|
|
148
|
+
/**
|
|
149
|
+
* Record the freshest observed `mtimeMs` for a path, evicting the oldest
|
|
150
|
+
* entry when the cap is reached.
|
|
151
|
+
*/
|
|
152
|
+
function recordMtime(path, mtimeMs) {
|
|
153
|
+
observedMtimes.delete(path);
|
|
154
|
+
observedMtimes.set(path, mtimeMs);
|
|
155
|
+
if (observedMtimes.size > OBSERVED_MTIME_LIMIT) {
|
|
156
|
+
const oldest = observedMtimes.keys().next().value;
|
|
157
|
+
if (oldest !== undefined)
|
|
158
|
+
observedMtimes.delete(oldest);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Whether the note changed on disk since this plugin last observed it.
|
|
163
|
+
* The first observation records and reports `false`; a successful
|
|
164
|
+
* `wiki_write` re-records so the plugin's own writes never flag.
|
|
165
|
+
*/
|
|
166
|
+
function detectExternalChange(path, mtimeMs) {
|
|
167
|
+
const seen = observedMtimes.get(path);
|
|
168
|
+
recordMtime(path, mtimeMs);
|
|
169
|
+
return seen !== undefined && seen !== mtimeMs;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Normalize `created`/`updated` frontmatter timestamps to the vault's
|
|
173
|
+
* `+08:00` second-precision convention. Parseable values in any other form
|
|
174
|
+
* (`Z`, `+00:00`, millisecond precision) are rewritten; unparseable values
|
|
175
|
+
* pass through untouched so a non-date string is never corrupted.
|
|
176
|
+
*/
|
|
177
|
+
export function normalizeNoteTimestamps(frontmatter) {
|
|
178
|
+
for (const key of ['created', 'updated']) {
|
|
179
|
+
const value = frontmatter[key];
|
|
180
|
+
if (typeof value !== 'string')
|
|
181
|
+
continue;
|
|
182
|
+
const parsed = new Date(value);
|
|
183
|
+
if (Number.isNaN(parsed.getTime()))
|
|
184
|
+
continue;
|
|
185
|
+
const normalized = formatBeijingTime(parsed);
|
|
186
|
+
if (normalized !== value)
|
|
187
|
+
frontmatter[key] = normalized;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Normalize timestamps inside a complete note text. Returns the text
|
|
192
|
+
* unchanged when there is no frontmatter to rewrite.
|
|
193
|
+
*/
|
|
194
|
+
function normalizeTextTimestamps(text) {
|
|
195
|
+
const { frontmatter, body } = splitFrontmatter(text);
|
|
196
|
+
if (Object.keys(frontmatter).length === 0)
|
|
197
|
+
return text;
|
|
198
|
+
normalizeNoteTimestamps(frontmatter);
|
|
199
|
+
return `---\n${yaml.dump(frontmatter).trim()}\n---\n${body}`;
|
|
200
|
+
}
|
|
134
201
|
/**
|
|
135
202
|
* Find all Obsidian-style `[[link]]` references in note text. Aliases of the
|
|
136
203
|
* form `[[link|alias]]` return the link target only.
|
|
@@ -141,12 +208,66 @@ export function extractLinks(text) {
|
|
|
141
208
|
const links = [];
|
|
142
209
|
const pattern = /\[\[([^|\]\r\n]+)(?:\|[^\]]*)?\]\]/g;
|
|
143
210
|
let match;
|
|
144
|
-
while ((match = pattern.exec(text)) !== null) {
|
|
211
|
+
while ((match = pattern.exec(stripCode(text))) !== null) {
|
|
145
212
|
if (match[1] !== undefined)
|
|
146
213
|
links.push(match[1].trim());
|
|
147
214
|
}
|
|
148
215
|
return [...new Set(links)];
|
|
149
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Remove fenced code blocks and inline code spans so literal `[[...]]` inside
|
|
219
|
+
* them is not treated as a link. Fences follow CommonMark: a closing fence
|
|
220
|
+
* must use the same marker character and be at least as long as the opener —
|
|
221
|
+
* a rule a regex backreference cannot express, so fences are scanned line by
|
|
222
|
+
* line. Inline spans treat backtick runs as maximal units: a run closes only
|
|
223
|
+
* at a later run of exactly equal length; an unmatched run is literal text.
|
|
224
|
+
* @param text - note body.
|
|
225
|
+
* @returns the text with all code sections dropped.
|
|
226
|
+
*/
|
|
227
|
+
function stripCode(text) {
|
|
228
|
+
const kept = [];
|
|
229
|
+
let fenceChar = '';
|
|
230
|
+
let fenceLen = 0;
|
|
231
|
+
for (const line of text.split('\n')) {
|
|
232
|
+
if (fenceChar === '') {
|
|
233
|
+
const open = /^( {0,3})(`{3,}|~{3,})/.exec(line);
|
|
234
|
+
if (open?.[2] !== undefined) {
|
|
235
|
+
fenceChar = open[2][0] ?? '';
|
|
236
|
+
fenceLen = open[2].length;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
kept.push(line);
|
|
240
|
+
}
|
|
241
|
+
else if (new RegExp(`^ {0,3}${fenceChar === '`' ? '`' : '~'}{${fenceLen},}[ \t]*$`).test(line)) {
|
|
242
|
+
fenceChar = '';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const body = kept.join('\n');
|
|
246
|
+
let out = '';
|
|
247
|
+
let i = 0;
|
|
248
|
+
while (i < body.length) {
|
|
249
|
+
if (body[i] !== '`') {
|
|
250
|
+
out += body[i];
|
|
251
|
+
i++;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
let j = i;
|
|
255
|
+
while (body[j] === '`')
|
|
256
|
+
j++;
|
|
257
|
+
const run = body.slice(i, j);
|
|
258
|
+
let close = body.indexOf(run, j);
|
|
259
|
+
while (close !== -1 && (body[close - 1] === '`' || body[close + run.length] === '`')) {
|
|
260
|
+
close = body.indexOf(run, close + 1);
|
|
261
|
+
}
|
|
262
|
+
if (close === -1) {
|
|
263
|
+
out += run;
|
|
264
|
+
i = j;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
i = close + run.length;
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
150
271
|
/**
|
|
151
272
|
* Normalize a configured file extension to a dot-prefixed form.
|
|
152
273
|
* @param ext - extension string.
|
|
@@ -156,14 +277,79 @@ export function dottedExtension(ext) {
|
|
|
156
277
|
return ext.startsWith('.') ? ext : `.${ext}`;
|
|
157
278
|
}
|
|
158
279
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
280
|
+
* Build a resolver over the known note ids, matching Obsidian semantics: an
|
|
281
|
+
* exact vault-relative path first, then a path-suffix match, then a bare
|
|
282
|
+
* basename match — each tier tolerates the target carrying or omitting the
|
|
283
|
+
* extension. Ambiguity within a tier resolves to the id with the fewest path
|
|
284
|
+
* segments, ties by code-unit order, so results are identical across hosts.
|
|
285
|
+
* @param ids - vault-relative note ids (e.g. `shared/notes/foo.md`).
|
|
286
|
+
* @returns resolver function for `[[...]]` targets.
|
|
287
|
+
*/
|
|
288
|
+
export function createLinkResolver(ids) {
|
|
289
|
+
const entries = ids.map(id => {
|
|
290
|
+
const stem = id.replace(/\.[^./]+$/, '');
|
|
291
|
+
return {
|
|
292
|
+
id,
|
|
293
|
+
stem,
|
|
294
|
+
basenameId: id.slice(id.lastIndexOf('/') + 1),
|
|
295
|
+
basenameStem: stem.slice(stem.lastIndexOf('/') + 1),
|
|
296
|
+
segments: id.split('/').length,
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
// O(1) lookup tables keyed by every segment-boundary suffix of each id and
|
|
300
|
+
// stem: an exact hit is the full-string key, a path-suffix hit is a proper
|
|
301
|
+
// suffix, and a basename hit is the last segment.
|
|
302
|
+
const exact = new Map();
|
|
303
|
+
const suffix = new Map();
|
|
304
|
+
const basename = new Map();
|
|
305
|
+
const add = (table, key, entry) => {
|
|
306
|
+
const list = table.get(key);
|
|
307
|
+
if (list === undefined)
|
|
308
|
+
table.set(key, [entry]);
|
|
309
|
+
else
|
|
310
|
+
list.push(entry);
|
|
311
|
+
};
|
|
312
|
+
for (const entry of entries) {
|
|
313
|
+
add(basename, entry.basenameId, entry);
|
|
314
|
+
add(basename, entry.basenameStem, entry);
|
|
315
|
+
for (const s of [entry.id, entry.stem]) {
|
|
316
|
+
let boundary = -1;
|
|
317
|
+
while ((boundary = s.indexOf('/', boundary + 1)) !== -1) {
|
|
318
|
+
add(suffix, s.slice(boundary + 1), entry);
|
|
319
|
+
}
|
|
320
|
+
add(exact, s, entry);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const pick = (matches) => {
|
|
324
|
+
if (matches === undefined || matches.length === 0)
|
|
325
|
+
return undefined;
|
|
326
|
+
matches.sort((a, b) => a.segments - b.segments || codeUnitCompare(a.id, b.id));
|
|
327
|
+
return matches[0]?.id;
|
|
328
|
+
};
|
|
329
|
+
return (target) => {
|
|
330
|
+
const t = target.replace(/\\/g, '/');
|
|
331
|
+
const hit = pick(exact.get(t)) ?? pick(suffix.get(t)) ?? pick(basename.get(t));
|
|
332
|
+
return hit;
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Resolve a link target to an existing note file inside the vault. When
|
|
337
|
+
* `resolve` is provided the target is first looked up in the note-id set with
|
|
338
|
+
* Obsidian semantics (exact path, then path suffix, then basename); otherwise
|
|
339
|
+
* the target is probed as a vault-relative path and extensions are tried in
|
|
340
|
+
* config order. Every result still passes the realpath containment check.
|
|
161
341
|
* @param root - vault root.
|
|
162
342
|
* @param extensions - note extensions.
|
|
163
343
|
* @param target - link target from `[[...]]`.
|
|
344
|
+
* @param resolve - optional resolver built by {@link createLinkResolver}.
|
|
164
345
|
* @returns the resolved absolute path, or `undefined` if no file exists.
|
|
165
346
|
*/
|
|
166
|
-
export async function resolveLinkTarget(root, extensions, target) {
|
|
347
|
+
export async function resolveLinkTarget(root, extensions, target, resolve) {
|
|
348
|
+
if (resolve !== undefined) {
|
|
349
|
+
const id = resolve(target);
|
|
350
|
+
if (id !== undefined)
|
|
351
|
+
return containedPathReal(root, id);
|
|
352
|
+
}
|
|
167
353
|
const candidates = extensions.map(ext => `${target}${dottedExtension(ext)}`);
|
|
168
354
|
candidates.push(target);
|
|
169
355
|
for (const candidate of candidates) {
|
|
@@ -227,9 +413,11 @@ export async function listNotePaths(root, extensions, indexHiddenDirs = false) {
|
|
|
227
413
|
* @param maxLinkDepth - how many link hops to resolve.
|
|
228
414
|
* @param absolutePath - absolute path of the note.
|
|
229
415
|
* @param visited - set of already-visited absolute paths to prevent cycles.
|
|
416
|
+
* @param linkResolver - resolver over the vault's note ids; built once per
|
|
417
|
+
* call tree so bare `[[filename]]` links resolve without rescanning.
|
|
230
418
|
* @returns the parsed note with linked notes attached.
|
|
231
419
|
*/
|
|
232
|
-
export async function readNote(root, extensions, maxLinkDepth, absolutePath, visited = new Set()) {
|
|
420
|
+
export async function readNote(root, extensions, maxLinkDepth, absolutePath, visited = new Set(), linkResolver) {
|
|
233
421
|
if (visited.has(absolutePath)) {
|
|
234
422
|
return {
|
|
235
423
|
path: absolutePath,
|
|
@@ -249,9 +437,9 @@ export async function readNote(root, extensions, maxLinkDepth, absolutePath, vis
|
|
|
249
437
|
const linkedNotes = [];
|
|
250
438
|
if (maxLinkDepth > 0) {
|
|
251
439
|
for (const link of links) {
|
|
252
|
-
const target = await resolveLinkTarget(root, extensions, link);
|
|
440
|
+
const target = await resolveLinkTarget(root, extensions, link, linkResolver);
|
|
253
441
|
if (target !== undefined) {
|
|
254
|
-
const child = await readNote(root, extensions, maxLinkDepth - 1, target, nextVisited);
|
|
442
|
+
const child = await readNote(root, extensions, maxLinkDepth - 1, target, nextVisited, linkResolver);
|
|
255
443
|
linkedNotes.push(child);
|
|
256
444
|
}
|
|
257
445
|
}
|
|
@@ -298,12 +486,10 @@ export async function buildIndex(root, extensions, indexHiddenDirs = false) {
|
|
|
298
486
|
: note.id.replace(/\.[^.]+$/, '');
|
|
299
487
|
index.set(note.id, { id: note.id, title, backlinks: [], body: note.body });
|
|
300
488
|
}
|
|
489
|
+
const linkResolver = createLinkResolver([...index.keys()]);
|
|
301
490
|
for (const note of notes) {
|
|
302
491
|
for (const link of note.links) {
|
|
303
|
-
const targetId =
|
|
304
|
-
const base = id.replace(/\.[^.]+$/, '');
|
|
305
|
-
return base === link || id === link;
|
|
306
|
-
});
|
|
492
|
+
const targetId = linkResolver(link);
|
|
307
493
|
if (targetId !== undefined) {
|
|
308
494
|
const entry = index.get(targetId);
|
|
309
495
|
if (entry !== undefined && !entry.backlinks.includes(note.id)) {
|
|
@@ -356,7 +542,7 @@ export function apply(ctx, config) {
|
|
|
356
542
|
const vaultRootFor = (exec) => resolveMemoryVaultRoot(resolved.vaultRoot, exec);
|
|
357
543
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
358
544
|
name: 'wiki_read',
|
|
359
|
-
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
|
|
545
|
+
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, linked notes, mtime, and a modifiedExternally flag that is true when the file changed on disk since this tool last observed it.',
|
|
360
546
|
parameters: {
|
|
361
547
|
id: {
|
|
362
548
|
type: 'string',
|
|
@@ -371,13 +557,20 @@ export function apply(ctx, config) {
|
|
|
371
557
|
async execute(args, exec) {
|
|
372
558
|
const vaultRoot = vaultRootFor(exec);
|
|
373
559
|
const absolutePath = await containedPathReal(vaultRoot, args.id);
|
|
374
|
-
const
|
|
375
|
-
|
|
560
|
+
const linkResolver = createLinkResolver((await listNotePaths(vaultRoot, resolved.extensions, resolved.indexHiddenDirs))
|
|
561
|
+
.map(path => vaultRelativeId(vaultRoot, path)));
|
|
562
|
+
const note = await readNote(vaultRoot, resolved.extensions, resolved.maxLinkDepth, absolutePath, new Set(), linkResolver);
|
|
563
|
+
const info = await stat(absolutePath);
|
|
564
|
+
return {
|
|
565
|
+
...note,
|
|
566
|
+
mtime: formatBeijingTime(info.mtime),
|
|
567
|
+
modifiedExternally: detectExternalChange(absolutePath, info.mtimeMs),
|
|
568
|
+
};
|
|
376
569
|
},
|
|
377
570
|
})));
|
|
378
571
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
379
572
|
name: 'wiki_search',
|
|
380
|
-
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.',
|
|
573
|
+
description: 'Search the wiki vault by note title or body keyword. Terms are OR-matched; results are ranked by field-weighted relevance (title/id hits outrank body hits, rare terms weigh more, notes linked from strong hits get a boost, and results may be fused with semantic search when available). Returns matching note ids, titles, scores, and backlink counts. Use this before asking the user which note to read.',
|
|
381
574
|
parameters: {
|
|
382
575
|
query: {
|
|
383
576
|
type: 'string',
|
|
@@ -394,6 +587,7 @@ export function apply(ctx, config) {
|
|
|
394
587
|
properties: {
|
|
395
588
|
id: { type: 'string' },
|
|
396
589
|
title: { type: 'string' },
|
|
590
|
+
score: { type: 'number' },
|
|
397
591
|
backlinks: { type: 'array', items: { type: 'string' } },
|
|
398
592
|
},
|
|
399
593
|
},
|
|
@@ -403,15 +597,41 @@ export function apply(ctx, config) {
|
|
|
403
597
|
async execute(args, exec) {
|
|
404
598
|
const vaultRoot = vaultRootFor(exec);
|
|
405
599
|
const index = await buildIndex(vaultRoot, resolved.extensions, resolved.indexHiddenDirs);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
600
|
+
let hits = rankNotes(index, args.query);
|
|
601
|
+
// Optional semantic layer: when `wiki_semantic_search` is mounted, fuse
|
|
602
|
+
// both rankings via reciprocal rank fusion. Only the dispatch is
|
|
603
|
+
// fail-soft — an unavailable or erroring semantic layer degrades to the
|
|
604
|
+
// lexical ranking; parse/fusion errors propagate so real bugs surface
|
|
605
|
+
// instead of silently weakening results.
|
|
606
|
+
if (ctx.tools.get('wiki_semantic_search') !== undefined) {
|
|
607
|
+
let semantic;
|
|
608
|
+
try {
|
|
609
|
+
semantic = await ctx.tools.execute({
|
|
610
|
+
callId: `${exec.callId}:wiki_semantic_search:${randomUUID()}`,
|
|
611
|
+
rootCallId: exec.rootCallId,
|
|
612
|
+
name: 'wiki_semantic_search',
|
|
613
|
+
arguments: { query: args.query },
|
|
614
|
+
parent: exec.token,
|
|
615
|
+
signal: exec.signal,
|
|
616
|
+
...(exec.agent !== undefined ? { agent: exec.agent } : {}),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
ctx.logger?.warn('tool-memory-filesystem: semantic fusion skipped: ' + String(error));
|
|
621
|
+
}
|
|
622
|
+
if (semantic !== undefined && !semantic.isError) {
|
|
623
|
+
const semanticHits = JSON.parse(semantic.content
|
|
624
|
+
.map(block => {
|
|
625
|
+
const maybe = block;
|
|
626
|
+
return maybe.type === 'text' && typeof maybe.text === 'string' ? maybe.text : '';
|
|
627
|
+
})
|
|
628
|
+
.join('') || '[]');
|
|
629
|
+
hits = fuseRankings(hits, semanticHits.map(hit => hit.id), index);
|
|
630
|
+
}
|
|
631
|
+
else if (semantic !== undefined) {
|
|
632
|
+
ctx.logger?.warn('tool-memory-filesystem: semantic fusion skipped: wiki_semantic_search returned an error');
|
|
412
633
|
}
|
|
413
634
|
}
|
|
414
|
-
hits.sort((a, b) => b.backlinks.length - a.backlinks.length);
|
|
415
635
|
return hits.slice(0, resolved.maxSearchResults);
|
|
416
636
|
},
|
|
417
637
|
})));
|
|
@@ -475,10 +695,11 @@ export function apply(ctx, config) {
|
|
|
475
695
|
}
|
|
476
696
|
let finalBody;
|
|
477
697
|
if (mode === 'overwrite') {
|
|
478
|
-
finalBody = args.content;
|
|
698
|
+
finalBody = normalizeTextTimestamps(args.content);
|
|
479
699
|
}
|
|
480
700
|
else {
|
|
481
701
|
const { frontmatter, body } = splitFrontmatter(existing ?? '');
|
|
702
|
+
normalizeNoteTimestamps(frontmatter);
|
|
482
703
|
const frontmatterText = Object.keys(frontmatter).length > 0
|
|
483
704
|
? `---\n${yaml.dump(frontmatter).trim()}\n---\n\n`
|
|
484
705
|
: '';
|
|
@@ -487,6 +708,8 @@ export function apply(ctx, config) {
|
|
|
487
708
|
}
|
|
488
709
|
exec.signal.throwIfAborted();
|
|
489
710
|
await writeAtomic(absolutePath, finalBody);
|
|
711
|
+
const info = await stat(absolutePath);
|
|
712
|
+
recordMtime(absolutePath, info.mtimeMs);
|
|
490
713
|
return { id: vaultRelativeId(vaultRoot, absolutePath), mode, bytes: Buffer.byteLength(finalBody, 'utf8') };
|
|
491
714
|
},
|
|
492
715
|
})));
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-layer lexical ranking for `wiki_search`: field-weighted BM25-style
|
|
3
|
+
* scoring over note id/title/backlinks/body, a verbatim-phrase bonus, and a
|
|
4
|
+
* link-graph boost for notes referenced by strong lexical hits. Substring
|
|
5
|
+
* matching (not tokenization) keeps CJK text working without a segmenter.
|
|
6
|
+
* @module @jacklika/dsh-tool-memory-filesystem/search
|
|
7
|
+
*/
|
|
8
|
+
const K1 = 1.2;
|
|
9
|
+
const B = 0.75;
|
|
10
|
+
const TITLE_WEIGHT = 3;
|
|
11
|
+
const BODY_WEIGHT = 1;
|
|
12
|
+
const BACKLINK_WEIGHT = 1;
|
|
13
|
+
const GRAPH_BOOST = 0.15;
|
|
14
|
+
const RRF_K = 60;
|
|
15
|
+
function countOccurrences(haystack, needle) {
|
|
16
|
+
if (needle === '')
|
|
17
|
+
return 0;
|
|
18
|
+
let count = 0;
|
|
19
|
+
let from = 0;
|
|
20
|
+
while ((from = haystack.indexOf(needle, from)) !== -1) {
|
|
21
|
+
count += 1;
|
|
22
|
+
from += needle.length;
|
|
23
|
+
}
|
|
24
|
+
return count;
|
|
25
|
+
}
|
|
26
|
+
function toHit(note, score) {
|
|
27
|
+
return {
|
|
28
|
+
id: note.id,
|
|
29
|
+
title: note.title,
|
|
30
|
+
score: Math.round(score * 10000) / 10000,
|
|
31
|
+
backlinks: note.backlinks,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Rank every note in `index` against `query`. Any note containing at least one
|
|
36
|
+
* query term (OR semantics) is a candidate; scores follow BM25 saturation with
|
|
37
|
+
* field weights (`id`/`title` ×3, backlinks ×1, body ×1), a phrase bonus for a
|
|
38
|
+
* verbatim multi-term match, and a graph boost for notes linked from a scored
|
|
39
|
+
* hit.
|
|
40
|
+
* @param index - note index built by {@link buildIndex}.
|
|
41
|
+
* @param query - raw query text.
|
|
42
|
+
* @returns hits sorted by descending score.
|
|
43
|
+
*/
|
|
44
|
+
export function rankNotes(index, query) {
|
|
45
|
+
const lowered = query.toLowerCase();
|
|
46
|
+
const terms = [...new Set(lowered.split(/\s+/).filter(Boolean))];
|
|
47
|
+
if (terms.length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
const prepared = [...index.values()].map(note => ({
|
|
50
|
+
note,
|
|
51
|
+
titleField: `${note.id} ${note.title}`.toLowerCase(),
|
|
52
|
+
backlinkField: note.backlinks.join(' ').toLowerCase(),
|
|
53
|
+
bodyField: note.body.toLowerCase(),
|
|
54
|
+
docLen: Math.max(1, note.body.length),
|
|
55
|
+
}));
|
|
56
|
+
const docCount = prepared.length;
|
|
57
|
+
const avgdl = prepared.reduce((sum, p) => sum + p.docLen, 0) / Math.max(1, docCount);
|
|
58
|
+
const idf = new Map();
|
|
59
|
+
for (const term of terms) {
|
|
60
|
+
const df = prepared.filter(p => p.titleField.includes(term) || p.backlinkField.includes(term) || p.bodyField.includes(term)).length;
|
|
61
|
+
idf.set(term, Math.log(1 + (docCount - df + 0.5) / (df + 0.5)));
|
|
62
|
+
}
|
|
63
|
+
const sumIdf = [...idf.values()].reduce((a, b) => a + b, 0);
|
|
64
|
+
const scores = new Map();
|
|
65
|
+
const phrase = lowered.trim();
|
|
66
|
+
for (const p of prepared) {
|
|
67
|
+
let score = 0;
|
|
68
|
+
for (const term of terms) {
|
|
69
|
+
const tf = TITLE_WEIGHT * countOccurrences(p.titleField, term) +
|
|
70
|
+
BACKLINK_WEIGHT * countOccurrences(p.backlinkField, term) +
|
|
71
|
+
BODY_WEIGHT * countOccurrences(p.bodyField, term);
|
|
72
|
+
if (tf === 0)
|
|
73
|
+
continue;
|
|
74
|
+
const termIdf = idf.get(term) ?? 0;
|
|
75
|
+
score += termIdf * (tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * p.docLen) / avgdl));
|
|
76
|
+
}
|
|
77
|
+
if (terms.length > 1) {
|
|
78
|
+
if (p.titleField.includes(phrase))
|
|
79
|
+
score += sumIdf;
|
|
80
|
+
else if (p.bodyField.includes(phrase))
|
|
81
|
+
score += 0.5 * sumIdf;
|
|
82
|
+
}
|
|
83
|
+
if (score > 0)
|
|
84
|
+
scores.set(p.note.id, score);
|
|
85
|
+
}
|
|
86
|
+
// Graph boost: a note linked from a scored hit gains a fraction of that
|
|
87
|
+
// hit's score (the strongest link wins). `backlinks` lists the sources, so
|
|
88
|
+
// hit H links to T exactly when H.id ∈ T.backlinks.
|
|
89
|
+
const linkedBoost = new Map();
|
|
90
|
+
for (const p of prepared) {
|
|
91
|
+
for (const sourceId of p.note.backlinks) {
|
|
92
|
+
const sourceScore = scores.get(sourceId);
|
|
93
|
+
if (sourceScore !== undefined) {
|
|
94
|
+
linkedBoost.set(p.note.id, Math.max(linkedBoost.get(p.note.id) ?? 0, GRAPH_BOOST * sourceScore));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const hits = [];
|
|
99
|
+
for (const p of prepared) {
|
|
100
|
+
const score = (scores.get(p.note.id) ?? 0) + (linkedBoost.get(p.note.id) ?? 0);
|
|
101
|
+
if (score > 0)
|
|
102
|
+
hits.push(toHit(p.note, score));
|
|
103
|
+
}
|
|
104
|
+
hits.sort((a, b) => b.score - a.score || codeUnitCompare(a.id, b.id));
|
|
105
|
+
return hits;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Locale-independent code-unit ordering — `localeCompare` without a locale
|
|
109
|
+
* follows the host runtime locale, which makes tied-score ordering differ
|
|
110
|
+
* across machines.
|
|
111
|
+
*/
|
|
112
|
+
export function codeUnitCompare(a, b) {
|
|
113
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Fuse the lexical ranking with a semantic-search ranking using reciprocal
|
|
117
|
+
* rank fusion (k = 60). Semantic-only notes are surfaced with their index
|
|
118
|
+
* metadata so the output shape stays uniform.
|
|
119
|
+
* @param lexical - hits from {@link rankNotes}.
|
|
120
|
+
* @param semanticIds - note ids ordered by descending semantic similarity.
|
|
121
|
+
* @param index - the note index for metadata lookup.
|
|
122
|
+
* @returns fused hits sorted by descending RRF score.
|
|
123
|
+
*/
|
|
124
|
+
export function fuseRankings(lexical, semanticIds, index) {
|
|
125
|
+
const fused = new Map();
|
|
126
|
+
const contribute = (id, rank) => {
|
|
127
|
+
const existing = fused.get(id);
|
|
128
|
+
if (existing !== undefined) {
|
|
129
|
+
existing.score += 1 / (RRF_K + rank + 1);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const note = index.get(id);
|
|
133
|
+
if (note === undefined)
|
|
134
|
+
return; // never fabricate a hit for an id outside the vault index
|
|
135
|
+
fused.set(id, {
|
|
136
|
+
id,
|
|
137
|
+
title: note.title,
|
|
138
|
+
score: 1 / (RRF_K + rank + 1),
|
|
139
|
+
backlinks: note.backlinks,
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
lexical.forEach((hit, rank) => contribute(hit.id, rank));
|
|
143
|
+
semanticIds.forEach((id, rank) => contribute(id, rank));
|
|
144
|
+
const hits = [...fused.values()];
|
|
145
|
+
for (const hit of hits)
|
|
146
|
+
hit.score = Math.round(hit.score * 10000) / 10000;
|
|
147
|
+
hits.sort((a, b) => b.score - a.score || codeUnitCompare(a.id, b.id));
|
|
148
|
+
return hits;
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=search.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -88,6 +88,13 @@ export declare function splitFrontmatter(text: string): {
|
|
|
88
88
|
* @returns content hash stable across processes.
|
|
89
89
|
*/
|
|
90
90
|
export declare function noteVersion(text: string): string;
|
|
91
|
+
/**
|
|
92
|
+
* Normalize `created`/`updated` frontmatter timestamps to the vault's
|
|
93
|
+
* `+08:00` second-precision convention. Parseable values in any other form
|
|
94
|
+
* (`Z`, `+00:00`, millisecond precision) are rewritten; unparseable values
|
|
95
|
+
* pass through untouched so a non-date string is never corrupted.
|
|
96
|
+
*/
|
|
97
|
+
export declare function normalizeNoteTimestamps(frontmatter: Record<string, unknown>): void;
|
|
91
98
|
/**
|
|
92
99
|
* Find all Obsidian-style `[[link]]` references in note text. Aliases of the
|
|
93
100
|
* form `[[link|alias]]` return the link target only.
|
|
@@ -102,14 +109,33 @@ export declare function extractLinks(text: string): string[];
|
|
|
102
109
|
*/
|
|
103
110
|
export declare function dottedExtension(ext: string): string;
|
|
104
111
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
112
|
+
* A link resolver maps an Obsidian `[[target]]` to a vault-relative note id,
|
|
113
|
+
* or `undefined` when nothing matches.
|
|
114
|
+
*/
|
|
115
|
+
export type LinkResolver = (target: string) => string | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Build a resolver over the known note ids, matching Obsidian semantics: an
|
|
118
|
+
* exact vault-relative path first, then a path-suffix match, then a bare
|
|
119
|
+
* basename match — each tier tolerates the target carrying or omitting the
|
|
120
|
+
* extension. Ambiguity within a tier resolves to the id with the fewest path
|
|
121
|
+
* segments, ties by code-unit order, so results are identical across hosts.
|
|
122
|
+
* @param ids - vault-relative note ids (e.g. `shared/notes/foo.md`).
|
|
123
|
+
* @returns resolver function for `[[...]]` targets.
|
|
124
|
+
*/
|
|
125
|
+
export declare function createLinkResolver(ids: string[]): LinkResolver;
|
|
126
|
+
/**
|
|
127
|
+
* Resolve a link target to an existing note file inside the vault. When
|
|
128
|
+
* `resolve` is provided the target is first looked up in the note-id set with
|
|
129
|
+
* Obsidian semantics (exact path, then path suffix, then basename); otherwise
|
|
130
|
+
* the target is probed as a vault-relative path and extensions are tried in
|
|
131
|
+
* config order. Every result still passes the realpath containment check.
|
|
107
132
|
* @param root - vault root.
|
|
108
133
|
* @param extensions - note extensions.
|
|
109
134
|
* @param target - link target from `[[...]]`.
|
|
135
|
+
* @param resolve - optional resolver built by {@link createLinkResolver}.
|
|
110
136
|
* @returns the resolved absolute path, or `undefined` if no file exists.
|
|
111
137
|
*/
|
|
112
|
-
export declare function resolveLinkTarget(root: string, extensions: string[], target: string): Promise<string | undefined>;
|
|
138
|
+
export declare function resolveLinkTarget(root: string, extensions: string[], target: string, resolve?: LinkResolver): Promise<string | undefined>;
|
|
113
139
|
/**
|
|
114
140
|
* Recursively discover note files under the vault root. `.git`, `.obsidian`,
|
|
115
141
|
* and `node_modules` are always excluded; other dot-directories are skipped
|
|
@@ -137,9 +163,11 @@ export interface LinkedNote {
|
|
|
137
163
|
* @param maxLinkDepth - how many link hops to resolve.
|
|
138
164
|
* @param absolutePath - absolute path of the note.
|
|
139
165
|
* @param visited - set of already-visited absolute paths to prevent cycles.
|
|
166
|
+
* @param linkResolver - resolver over the vault's note ids; built once per
|
|
167
|
+
* call tree so bare `[[filename]]` links resolve without rescanning.
|
|
140
168
|
* @returns the parsed note with linked notes attached.
|
|
141
169
|
*/
|
|
142
|
-
export declare function readNote(root: string, extensions: string[], maxLinkDepth: number, absolutePath: string, visited?: ReadonlySet<string
|
|
170
|
+
export declare function readNote(root: string, extensions: string[], maxLinkDepth: number, absolutePath: string, visited?: ReadonlySet<string>, linkResolver?: LinkResolver): Promise<Note & {
|
|
143
171
|
linkedNotes: LinkedNote[];
|
|
144
172
|
}>;
|
|
145
173
|
/**
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-layer lexical ranking for `wiki_search`: field-weighted BM25-style
|
|
3
|
+
* scoring over note id/title/backlinks/body, a verbatim-phrase bonus, and a
|
|
4
|
+
* link-graph boost for notes referenced by strong lexical hits. Substring
|
|
5
|
+
* matching (not tokenization) keeps CJK text working without a segmenter.
|
|
6
|
+
* @module @jacklika/dsh-tool-memory-filesystem/search
|
|
7
|
+
*/
|
|
8
|
+
import type { IndexedNote, SearchResult } from './types.ts';
|
|
9
|
+
/** A search hit carrying its fused relevance score. */
|
|
10
|
+
export interface RankedHit extends SearchResult {
|
|
11
|
+
/** Relevance score: BM25 sum (+phrase/graph bonuses), or an RRF score when semantic fusion ran. */
|
|
12
|
+
score: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Rank every note in `index` against `query`. Any note containing at least one
|
|
16
|
+
* query term (OR semantics) is a candidate; scores follow BM25 saturation with
|
|
17
|
+
* field weights (`id`/`title` ×3, backlinks ×1, body ×1), a phrase bonus for a
|
|
18
|
+
* verbatim multi-term match, and a graph boost for notes linked from a scored
|
|
19
|
+
* hit.
|
|
20
|
+
* @param index - note index built by {@link buildIndex}.
|
|
21
|
+
* @param query - raw query text.
|
|
22
|
+
* @returns hits sorted by descending score.
|
|
23
|
+
*/
|
|
24
|
+
export declare function rankNotes(index: Map<string, IndexedNote>, query: string): RankedHit[];
|
|
25
|
+
/**
|
|
26
|
+
* Locale-independent code-unit ordering — `localeCompare` without a locale
|
|
27
|
+
* follows the host runtime locale, which makes tied-score ordering differ
|
|
28
|
+
* across machines.
|
|
29
|
+
*/
|
|
30
|
+
export declare function codeUnitCompare(a: string, b: string): number;
|
|
31
|
+
/**
|
|
32
|
+
* Fuse the lexical ranking with a semantic-search ranking using reciprocal
|
|
33
|
+
* rank fusion (k = 60). Semantic-only notes are surfaced with their index
|
|
34
|
+
* metadata so the output shape stays uniform.
|
|
35
|
+
* @param lexical - hits from {@link rankNotes}.
|
|
36
|
+
* @param semanticIds - note ids ordered by descending semantic similarity.
|
|
37
|
+
* @param index - the note index for metadata lookup.
|
|
38
|
+
* @returns fused hits sorted by descending RRF score.
|
|
39
|
+
*/
|
|
40
|
+
export declare function fuseRankings(lexical: RankedHit[], semanticIds: string[], index: Map<string, IndexedNote>): RankedHit[];
|
|
41
|
+
//# sourceMappingURL=search.d.ts.map
|
package/lib/types/types.d.ts
CHANGED
|
@@ -20,6 +20,17 @@ export interface Note {
|
|
|
20
20
|
* note in between.
|
|
21
21
|
*/
|
|
22
22
|
version: string;
|
|
23
|
+
/**
|
|
24
|
+
* File modification time rendered as a `+08:00` timestamp. Only set on the
|
|
25
|
+
* top-level note returned by `wiki_read`.
|
|
26
|
+
*/
|
|
27
|
+
mtime?: string;
|
|
28
|
+
/**
|
|
29
|
+
* True when the file's `mtime` changed since this plugin last observed it —
|
|
30
|
+
* evidence that an external writer (e.g. Obsidian) touched the note. Only
|
|
31
|
+
* set on the top-level note returned by `wiki_read`.
|
|
32
|
+
*/
|
|
33
|
+
modifiedExternally?: boolean;
|
|
23
34
|
}
|
|
24
35
|
/** Result of a search across the vault. */
|
|
25
36
|
export interface SearchResult {
|
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.6",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|