@oomkapwn/enquire-mcp 3.5.13 → 3.6.0-rc.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/CHANGELOG.md +106 -0
- package/dist/eval.js +1 -1
- package/dist/eval.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/tools/index.d.ts +6 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/media.d.ts +182 -0
- package/dist/tools/media.d.ts.map +1 -0
- package/dist/tools/media.js +304 -0
- package/dist/tools/media.js.map +1 -0
- package/dist/tools/meta.d.ts +201 -0
- package/dist/tools/meta.d.ts.map +1 -0
- package/dist/tools/meta.js +752 -0
- package/dist/tools/meta.js.map +1 -0
- package/dist/tools/read.d.ts +251 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +643 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/search.d.ts +279 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +891 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/write.d.ts +145 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +560 -0
- package/dist/tools/write.js.map +1 -0
- package/package.json +1 -1
- package/dist/tools.d.ts +0 -980
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js +0 -3132
- package/dist/tools.js.map +0 -1
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { parseDql, runDql } from "../dql.js";
|
|
2
|
+
import { findBestMatch, normalizeTag, stripMd } from "./meta.js";
|
|
3
|
+
import { sliceSnippet } from "./search.js";
|
|
4
|
+
import { extractFrontmatterTagsLower, resolveTarget } from "./write.js";
|
|
5
|
+
export async function listNotes(vault, args) {
|
|
6
|
+
await vault.ensureExists();
|
|
7
|
+
const limit = args.limit ?? 50;
|
|
8
|
+
const sinceMs = args.since_date ? Date.parse(args.since_date) : null;
|
|
9
|
+
if (sinceMs !== null && Number.isNaN(sinceMs)) {
|
|
10
|
+
throw new Error(`Invalid since_date: ${args.since_date}. Use ISO 8601 (YYYY-MM-DD).`);
|
|
11
|
+
}
|
|
12
|
+
const wantTag = args.tag ? normalizeTag(args.tag) : null;
|
|
13
|
+
const entries = await vault.listMarkdown(args.folder);
|
|
14
|
+
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const e of entries) {
|
|
17
|
+
if (sinceMs !== null && e.mtimeMs < sinceMs)
|
|
18
|
+
continue;
|
|
19
|
+
const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
20
|
+
if (wantTag && !parsed.tags.some((t) => normalizeTag(t) === wantTag))
|
|
21
|
+
continue;
|
|
22
|
+
out.push({
|
|
23
|
+
title: stripMd(e.basename),
|
|
24
|
+
path: e.relPath,
|
|
25
|
+
frontmatter: parsed.frontmatter,
|
|
26
|
+
tags: parsed.tags,
|
|
27
|
+
mtime: new Date(e.mtimeMs).toISOString()
|
|
28
|
+
});
|
|
29
|
+
if (out.length >= limit)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
export async function readNote(vault, args) {
|
|
35
|
+
await vault.ensureExists();
|
|
36
|
+
const entry = await resolveTarget(vault, args);
|
|
37
|
+
const { content, parsed, mtimeMs } = await vault.readNote(entry.absPath, entry.mtimeMs);
|
|
38
|
+
if (args.format === "map") {
|
|
39
|
+
// Document-map projection — headings + frontmatter keys + counts. Lets an
|
|
40
|
+
// LLM plan a surgical edit without paying token cost for the full body.
|
|
41
|
+
return {
|
|
42
|
+
path: entry.relPath,
|
|
43
|
+
title: stripMd(entry.basename),
|
|
44
|
+
format: "map",
|
|
45
|
+
frontmatter_keys: Object.keys(parsed.frontmatter),
|
|
46
|
+
headings: extractHeadings(parsed.body),
|
|
47
|
+
wikilinks_count: parsed.wikilinks.length,
|
|
48
|
+
embeds_count: parsed.embeds.length,
|
|
49
|
+
tags: parsed.tags,
|
|
50
|
+
mtime: new Date(mtimeMs).toISOString(),
|
|
51
|
+
byte_size: Buffer.byteLength(content, "utf8")
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: entry.relPath,
|
|
56
|
+
title: stripMd(entry.basename),
|
|
57
|
+
content: parsed.body,
|
|
58
|
+
frontmatter: parsed.frontmatter,
|
|
59
|
+
wikilinks: parsed.wikilinks,
|
|
60
|
+
embeds: parsed.embeds,
|
|
61
|
+
tags: parsed.tags,
|
|
62
|
+
mtime: new Date(mtimeMs).toISOString()
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Pull ATX headings (`#`, `##`, `###`, etc.) out of note body for the
|
|
66
|
+
* document-map projection. Skips ATX inside fenced code blocks via a simple
|
|
67
|
+
* line-by-line backtick toggle. */
|
|
68
|
+
function extractHeadings(body) {
|
|
69
|
+
const out = [];
|
|
70
|
+
const lines = body.split("\n");
|
|
71
|
+
let inFence = false;
|
|
72
|
+
for (let i = 0; i < lines.length; i++) {
|
|
73
|
+
const line = lines[i] ?? "";
|
|
74
|
+
if (/^\s*```/.test(line)) {
|
|
75
|
+
inFence = !inFence;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (inFence)
|
|
79
|
+
continue;
|
|
80
|
+
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
81
|
+
if (m?.[1] && m[2]) {
|
|
82
|
+
out.push({ level: m[1].length, text: m[2], line: i + 1 });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
export async function resolveWikilink(vault, args) {
|
|
88
|
+
await vault.ensureExists();
|
|
89
|
+
const cleaned = args.wikilink.replace(/^!?\[\[|\]\]$/g, "");
|
|
90
|
+
const aliasIdx = cleaned.indexOf("|");
|
|
91
|
+
const alias = aliasIdx === -1 ? null : cleaned.slice(aliasIdx + 1).trim();
|
|
92
|
+
let rest = aliasIdx === -1 ? cleaned : cleaned.slice(0, aliasIdx);
|
|
93
|
+
const blockIdx = rest.indexOf("^");
|
|
94
|
+
const block = blockIdx === -1 ? null : rest.slice(blockIdx + 1).trim();
|
|
95
|
+
rest = blockIdx === -1 ? rest : rest.slice(0, blockIdx);
|
|
96
|
+
const hashIdx = rest.indexOf("#");
|
|
97
|
+
const section = hashIdx === -1 ? null : rest.slice(hashIdx + 1).trim();
|
|
98
|
+
const target = (hashIdx === -1 ? rest : rest.slice(0, hashIdx)).trim();
|
|
99
|
+
if (!target) {
|
|
100
|
+
return { found: false, path: null, title: null, content: null, section, block, alias };
|
|
101
|
+
}
|
|
102
|
+
const all = await vault.listMarkdown();
|
|
103
|
+
const match = findBestMatch(all, target, args.from_note);
|
|
104
|
+
if (!match) {
|
|
105
|
+
return { found: false, path: null, title: null, content: null, section, block, alias };
|
|
106
|
+
}
|
|
107
|
+
let body = null;
|
|
108
|
+
if (args.include_content !== false) {
|
|
109
|
+
const { parsed } = await vault.readNote(match.absPath, match.mtimeMs);
|
|
110
|
+
body = parsed.body;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
found: true,
|
|
114
|
+
path: match.relPath,
|
|
115
|
+
title: stripMd(match.basename),
|
|
116
|
+
content: body,
|
|
117
|
+
section,
|
|
118
|
+
block,
|
|
119
|
+
alias
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export async function getRecentEdits(vault, args) {
|
|
123
|
+
await vault.ensureExists();
|
|
124
|
+
const limit = args.limit ?? 20;
|
|
125
|
+
const sinceMs = args.since_minutes !== undefined ? Date.now() - args.since_minutes * 60_000 : null;
|
|
126
|
+
const entries = await vault.listMarkdown(args.folder);
|
|
127
|
+
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const e of entries) {
|
|
130
|
+
if (sinceMs !== null && e.mtimeMs < sinceMs)
|
|
131
|
+
break;
|
|
132
|
+
const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
133
|
+
out.push({
|
|
134
|
+
title: stripMd(e.basename),
|
|
135
|
+
path: e.relPath,
|
|
136
|
+
frontmatter: parsed.frontmatter,
|
|
137
|
+
tags: parsed.tags,
|
|
138
|
+
mtime: new Date(e.mtimeMs).toISOString()
|
|
139
|
+
});
|
|
140
|
+
if (out.length >= limit)
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
export async function getBacklinks(vault, args) {
|
|
146
|
+
await vault.ensureExists();
|
|
147
|
+
const limit = args.limit ?? 50;
|
|
148
|
+
const includeEmbeds = args.include_embeds !== false;
|
|
149
|
+
const target = await resolveTarget(vault, args);
|
|
150
|
+
const targetAbs = target.absPath;
|
|
151
|
+
const all = await vault.listMarkdown();
|
|
152
|
+
const hits = [];
|
|
153
|
+
for (const e of all) {
|
|
154
|
+
if (e.absPath === targetAbs)
|
|
155
|
+
continue;
|
|
156
|
+
const { content, parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
157
|
+
const linkBag = [
|
|
158
|
+
...parsed.wikilinks.map((l) => ({ link: l, kind: "wikilink" })),
|
|
159
|
+
...(includeEmbeds ? parsed.embeds.map((l) => ({ link: l, kind: "embed" })) : [])
|
|
160
|
+
];
|
|
161
|
+
if (!linkBag.length)
|
|
162
|
+
continue;
|
|
163
|
+
let count = 0;
|
|
164
|
+
const kindFlags = { wikilink: false, embed: false };
|
|
165
|
+
const snippets = [];
|
|
166
|
+
for (const { link, kind } of linkBag) {
|
|
167
|
+
const match = findBestMatch(all, link.target, e.relPath);
|
|
168
|
+
if (!match || match.absPath !== targetAbs)
|
|
169
|
+
continue;
|
|
170
|
+
count += 1;
|
|
171
|
+
kindFlags[kind] = true;
|
|
172
|
+
if (snippets.length < 2) {
|
|
173
|
+
const literal = `${(kind === "embed" ? "![[" : "[[") + link.raw}]]`;
|
|
174
|
+
const idx = content.indexOf(literal);
|
|
175
|
+
const { snippet } = sliceSnippet(content, idx, literal.length);
|
|
176
|
+
if (snippet)
|
|
177
|
+
snippets.push(snippet);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (count === 0)
|
|
181
|
+
continue;
|
|
182
|
+
hits.push({
|
|
183
|
+
path: e.relPath,
|
|
184
|
+
title: stripMd(e.basename),
|
|
185
|
+
count,
|
|
186
|
+
snippets,
|
|
187
|
+
link_kind: kindFlags.wikilink && kindFlags.embed ? "mixed" : kindFlags.embed ? "embed" : "wikilink"
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
hits.sort((a, b) => b.count - a.count);
|
|
191
|
+
return hits.slice(0, limit);
|
|
192
|
+
}
|
|
193
|
+
export async function dataviewQuery(vault, args) {
|
|
194
|
+
await vault.ensureExists();
|
|
195
|
+
const parsed = parseDql(args.query);
|
|
196
|
+
const rows = await runDql(vault, parsed);
|
|
197
|
+
return { query: args.query, rows };
|
|
198
|
+
}
|
|
199
|
+
export async function getUnresolvedWikilinks(vault, args) {
|
|
200
|
+
await vault.ensureExists();
|
|
201
|
+
const limit = args.limit ?? 200;
|
|
202
|
+
const includeEmbeds = args.include_embeds !== false;
|
|
203
|
+
const entries = await vault.listMarkdown(args.folder);
|
|
204
|
+
const all = await vault.listMarkdown();
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const e of entries) {
|
|
207
|
+
if (out.length >= limit)
|
|
208
|
+
break;
|
|
209
|
+
const { content, parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
210
|
+
const candidates = [
|
|
211
|
+
...parsed.wikilinks.map((l) => ({ link: l, kind: "wikilink" })),
|
|
212
|
+
...(includeEmbeds ? parsed.embeds.map((l) => ({ link: l, kind: "embed" })) : [])
|
|
213
|
+
];
|
|
214
|
+
for (const { link, kind } of candidates) {
|
|
215
|
+
if (out.length >= limit)
|
|
216
|
+
break;
|
|
217
|
+
if (!link.target)
|
|
218
|
+
continue;
|
|
219
|
+
const match = findBestMatch(all, link.target, e.relPath);
|
|
220
|
+
if (match)
|
|
221
|
+
continue;
|
|
222
|
+
const literal = `${(kind === "embed" ? "![[" : "[[") + link.raw}]]`;
|
|
223
|
+
const idx = content.indexOf(literal);
|
|
224
|
+
const { snippet, line } = sliceSnippet(content, idx, literal.length);
|
|
225
|
+
out.push({
|
|
226
|
+
from_path: e.relPath,
|
|
227
|
+
target: link.target,
|
|
228
|
+
raw: link.raw,
|
|
229
|
+
kind,
|
|
230
|
+
alias: link.alias ?? null,
|
|
231
|
+
section: link.section ?? null,
|
|
232
|
+
block: link.block ?? null,
|
|
233
|
+
line,
|
|
234
|
+
snippet
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
export async function getOutboundLinks(vault, args) {
|
|
241
|
+
await vault.ensureExists();
|
|
242
|
+
const includeEmbeds = args.include_embeds !== false;
|
|
243
|
+
const includeUnresolved = args.include_unresolved !== false;
|
|
244
|
+
const entry = await resolveTarget(vault, args);
|
|
245
|
+
const { parsed } = await vault.readNote(entry.absPath, entry.mtimeMs);
|
|
246
|
+
const all = await vault.listMarkdown();
|
|
247
|
+
const candidates = [
|
|
248
|
+
...parsed.wikilinks.map((l) => ({ link: l, kind: "wikilink" })),
|
|
249
|
+
...(includeEmbeds ? parsed.embeds.map((l) => ({ link: l, kind: "embed" })) : [])
|
|
250
|
+
];
|
|
251
|
+
const links = [];
|
|
252
|
+
for (const { link, kind } of candidates) {
|
|
253
|
+
const match = findBestMatch(all, link.target, entry.relPath);
|
|
254
|
+
if (!match && !includeUnresolved)
|
|
255
|
+
continue;
|
|
256
|
+
links.push({
|
|
257
|
+
raw: link.raw,
|
|
258
|
+
target: link.target,
|
|
259
|
+
kind,
|
|
260
|
+
alias: link.alias ?? null,
|
|
261
|
+
section: link.section ?? null,
|
|
262
|
+
block: link.block ?? null,
|
|
263
|
+
resolved_path: match ? match.relPath : null,
|
|
264
|
+
resolved_title: match ? stripMd(match.basename) : null
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
from_path: entry.relPath,
|
|
269
|
+
from_title: stripMd(entry.basename),
|
|
270
|
+
links
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
export async function listTags(vault, args) {
|
|
274
|
+
await vault.ensureExists();
|
|
275
|
+
const limit = args.limit ?? 200;
|
|
276
|
+
const minCount = args.min_count ?? 1;
|
|
277
|
+
const entries = await vault.listMarkdown(args.folder);
|
|
278
|
+
const counts = new Map();
|
|
279
|
+
for (const e of entries) {
|
|
280
|
+
const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
281
|
+
const fmSet = new Set(extractFrontmatterTagsLower(parsed.frontmatter));
|
|
282
|
+
for (const t of parsed.tags) {
|
|
283
|
+
const key = t.toLowerCase();
|
|
284
|
+
const slot = counts.get(key) ?? { count: 0, fm: 0, inline: 0 };
|
|
285
|
+
slot.count += 1;
|
|
286
|
+
if (fmSet.has(key))
|
|
287
|
+
slot.fm += 1;
|
|
288
|
+
else
|
|
289
|
+
slot.inline += 1;
|
|
290
|
+
counts.set(key, slot);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const out = [];
|
|
294
|
+
for (const [tag, slot] of counts) {
|
|
295
|
+
if (slot.count < minCount)
|
|
296
|
+
continue;
|
|
297
|
+
out.push({ tag, count: slot.count, frontmatter_count: slot.fm, inline_count: slot.inline });
|
|
298
|
+
}
|
|
299
|
+
out.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
|
300
|
+
return out.slice(0, limit);
|
|
301
|
+
}
|
|
302
|
+
const CHAT_HEADING_RE = /^### (user|assistant|system) · (.+?)\s*$/;
|
|
303
|
+
// Multi-line flag: `## Chat:` heading can appear anywhere in the body, not
|
|
304
|
+
// only at string start. The append codepath uses .test(body); the read
|
|
305
|
+
// codepath uses .exec(line) per-line so the flag is harmless there.
|
|
306
|
+
const CHAT_THREAD_TITLE_RE = /^## Chat: (.+?)\s*$/m;
|
|
307
|
+
/** Append a message to a note's chat thread. Creates the note (and the
|
|
308
|
+
* `## Chat: <title>` heading) if absent. Idempotent in the sense that
|
|
309
|
+
* appending always creates a fresh `### <role> · <timestamp>` block — no
|
|
310
|
+
* silent overwrites. */
|
|
311
|
+
export async function chatThreadAppend(vault, args) {
|
|
312
|
+
await vault.ensureExists();
|
|
313
|
+
if (!args.note_path?.trim())
|
|
314
|
+
throw new Error("chat_thread_append: `note_path` is required");
|
|
315
|
+
if (!args.content?.trim())
|
|
316
|
+
throw new Error("chat_thread_append: `content` is required");
|
|
317
|
+
const role = args.role;
|
|
318
|
+
if (role !== "user" && role !== "assistant" && role !== "system") {
|
|
319
|
+
throw new Error(`chat_thread_append: invalid role "${role}" (must be user|assistant|system)`);
|
|
320
|
+
}
|
|
321
|
+
const targetRel = args.note_path.toLowerCase().endsWith(".md") ? args.note_path : `${args.note_path}.md`;
|
|
322
|
+
const abs = vault.resolveInside(targetRel);
|
|
323
|
+
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
324
|
+
const messageBlock = `\n### ${role} · ${timestamp}\n\n${args.content.trim()}\n`;
|
|
325
|
+
// Read existing or create new with thread heading.
|
|
326
|
+
let existed = true;
|
|
327
|
+
let body = "";
|
|
328
|
+
try {
|
|
329
|
+
body = await vault.readFile(abs);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
existed = false;
|
|
333
|
+
}
|
|
334
|
+
let toAppend;
|
|
335
|
+
if (existed && CHAT_THREAD_TITLE_RE.test(body)) {
|
|
336
|
+
// Existing thread — just append message.
|
|
337
|
+
toAppend = messageBlock;
|
|
338
|
+
}
|
|
339
|
+
else if (existed) {
|
|
340
|
+
// Existing note without a chat heading — add heading first.
|
|
341
|
+
const title = args.thread_title?.trim() || `chat — ${timestamp.slice(0, 10)}`;
|
|
342
|
+
toAppend = `\n\n## Chat: ${title}\n${messageBlock}`;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
// New note from scratch.
|
|
346
|
+
const title = args.thread_title?.trim() || `chat — ${timestamp.slice(0, 10)}`;
|
|
347
|
+
const initial = `# ${title}\n\n## Chat: ${title}\n${messageBlock}`;
|
|
348
|
+
const result = await vault.writeNote(targetRel, initial, { overwrite: false });
|
|
349
|
+
return {
|
|
350
|
+
note_path: result.relPath,
|
|
351
|
+
line_start: 4,
|
|
352
|
+
line_end: 4 + messageBlock.split("\n").length
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const before = body.length;
|
|
356
|
+
const newBody = body.replace(/\n+$/, "") + toAppend;
|
|
357
|
+
await vault.writeNote(targetRel, newBody, { overwrite: true });
|
|
358
|
+
const lineStart = (body.slice(0, before).match(/\n/g) ?? []).length + 1;
|
|
359
|
+
return {
|
|
360
|
+
note_path: vault.toRel(abs),
|
|
361
|
+
line_start: lineStart,
|
|
362
|
+
line_end: lineStart + toAppend.split("\n").length
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** Parse a note's chat thread into structured messages. Non-chat content
|
|
366
|
+
* (anything outside the `## Chat: <title>` block) is ignored. */
|
|
367
|
+
export async function chatThreadRead(vault, args) {
|
|
368
|
+
await vault.ensureExists();
|
|
369
|
+
const targetRel = args.note_path.toLowerCase().endsWith(".md") ? args.note_path : `${args.note_path}.md`;
|
|
370
|
+
const abs = vault.resolveInside(targetRel);
|
|
371
|
+
const body = await vault.readFile(abs);
|
|
372
|
+
const lines = body.split("\n");
|
|
373
|
+
let threadTitle = null;
|
|
374
|
+
let inThread = false;
|
|
375
|
+
const messages = [];
|
|
376
|
+
let current = null;
|
|
377
|
+
for (let i = 0; i < lines.length; i++) {
|
|
378
|
+
const ln = lines[i] ?? "";
|
|
379
|
+
const titleMatch = CHAT_THREAD_TITLE_RE.exec(ln);
|
|
380
|
+
if (titleMatch) {
|
|
381
|
+
if (current) {
|
|
382
|
+
messages.push({
|
|
383
|
+
role: current.role,
|
|
384
|
+
timestamp: current.timestamp,
|
|
385
|
+
content: current.lines.join("\n").trim(),
|
|
386
|
+
line_start: current.line_start,
|
|
387
|
+
line_end: i
|
|
388
|
+
});
|
|
389
|
+
current = null;
|
|
390
|
+
}
|
|
391
|
+
threadTitle = (titleMatch[1] ?? "").trim();
|
|
392
|
+
inThread = true;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (!inThread)
|
|
396
|
+
continue;
|
|
397
|
+
// Higher-level heading or a different `## Chat:` block ends the thread.
|
|
398
|
+
if (/^# /.test(ln) || (/^## /.test(ln) && !CHAT_THREAD_TITLE_RE.test(ln))) {
|
|
399
|
+
if (current) {
|
|
400
|
+
messages.push({
|
|
401
|
+
role: current.role,
|
|
402
|
+
timestamp: current.timestamp,
|
|
403
|
+
content: current.lines.join("\n").trim(),
|
|
404
|
+
line_start: current.line_start,
|
|
405
|
+
line_end: i
|
|
406
|
+
});
|
|
407
|
+
current = null;
|
|
408
|
+
}
|
|
409
|
+
inThread = false;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const headingMatch = CHAT_HEADING_RE.exec(ln);
|
|
413
|
+
if (headingMatch?.[1] && headingMatch[2]) {
|
|
414
|
+
if (current) {
|
|
415
|
+
messages.push({
|
|
416
|
+
role: current.role,
|
|
417
|
+
timestamp: current.timestamp,
|
|
418
|
+
content: current.lines.join("\n").trim(),
|
|
419
|
+
line_start: current.line_start,
|
|
420
|
+
line_end: i
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
current = {
|
|
424
|
+
role: headingMatch[1],
|
|
425
|
+
timestamp: headingMatch[2].trim(),
|
|
426
|
+
line_start: i + 1,
|
|
427
|
+
lines: []
|
|
428
|
+
};
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (current)
|
|
432
|
+
current.lines.push(ln);
|
|
433
|
+
}
|
|
434
|
+
if (current) {
|
|
435
|
+
messages.push({
|
|
436
|
+
role: current.role,
|
|
437
|
+
timestamp: current.timestamp,
|
|
438
|
+
content: current.lines.join("\n").trim(),
|
|
439
|
+
line_start: current.line_start,
|
|
440
|
+
line_end: lines.length
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
note_path: vault.toRel(abs),
|
|
445
|
+
thread_title: threadTitle,
|
|
446
|
+
messages,
|
|
447
|
+
message_count: messages.length
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
// ─── obsidian_frontmatter_{get,set,search} (v2.3.0 — atomic YAML ops) ──────
|
|
451
|
+
// Surgical YAML manipulation. Pre-fix, agents wanting to set `status:
|
|
452
|
+
// published` on 12 notes had to find/replace text — error-prone (multi-line
|
|
453
|
+
// strings, special chars, key-collision). Now: parse via gray-matter, edit,
|
|
454
|
+
// rewrite. Code-fence-aware via gray-matter (frontmatter is delimited
|
|
455
|
+
// strictly by leading `---`, so no fence ambiguity).
|
|
456
|
+
//
|
|
457
|
+
// _get is read-only; _set + _delete are write-gated.
|
|
458
|
+
export async function frontmatterGet(vault, args) {
|
|
459
|
+
await vault.ensureExists();
|
|
460
|
+
const target = await resolveTarget(vault, args);
|
|
461
|
+
const note = await vault.readNote(target.absPath, target.mtimeMs);
|
|
462
|
+
if (args.key) {
|
|
463
|
+
return {
|
|
464
|
+
path: target.relPath,
|
|
465
|
+
frontmatter: note.parsed.frontmatter,
|
|
466
|
+
value: note.parsed.frontmatter[args.key]
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
return { path: target.relPath, frontmatter: note.parsed.frontmatter };
|
|
470
|
+
}
|
|
471
|
+
export async function frontmatterSearch(vault, args) {
|
|
472
|
+
await vault.ensureExists();
|
|
473
|
+
if (!args.key)
|
|
474
|
+
throw new Error("frontmatter_search: `key` is required");
|
|
475
|
+
const predicates = [args.equals !== undefined, args.exists !== undefined, args.contains !== undefined].filter(Boolean);
|
|
476
|
+
if (predicates.length !== 1) {
|
|
477
|
+
throw new Error("frontmatter_search: exactly one of `equals` / `exists` / `contains` must be set");
|
|
478
|
+
}
|
|
479
|
+
const limit = args.limit ?? 100;
|
|
480
|
+
const entries = await vault.listMarkdown(args.folder);
|
|
481
|
+
const matches = [];
|
|
482
|
+
for (const e of entries) {
|
|
483
|
+
if (matches.length >= limit)
|
|
484
|
+
break;
|
|
485
|
+
try {
|
|
486
|
+
const note = await vault.readNote(e.absPath, e.mtimeMs);
|
|
487
|
+
const value = note.parsed.frontmatter[args.key];
|
|
488
|
+
let hit = false;
|
|
489
|
+
if (args.exists === true)
|
|
490
|
+
hit = value !== undefined;
|
|
491
|
+
else if (args.equals !== undefined)
|
|
492
|
+
hit = JSON.stringify(value) === JSON.stringify(args.equals);
|
|
493
|
+
else if (args.contains !== undefined) {
|
|
494
|
+
if (Array.isArray(value)) {
|
|
495
|
+
hit = value.some((v) => JSON.stringify(v) === JSON.stringify(args.contains));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (hit) {
|
|
499
|
+
matches.push({ path: e.relPath, value, mtime: new Date(e.mtimeMs).toISOString() });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// skip unparseable notes
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { key: args.key, total_matches: matches.length, matches };
|
|
507
|
+
}
|
|
508
|
+
export async function getNoteNeighbors(vault, args) {
|
|
509
|
+
await vault.ensureExists();
|
|
510
|
+
const cap = args.max_per_bucket ?? 20;
|
|
511
|
+
const target = await resolveTarget(vault, args);
|
|
512
|
+
const entries = await vault.listMarkdown();
|
|
513
|
+
const { parsed: targetParsed } = await vault.readNote(target.absPath, target.mtimeMs);
|
|
514
|
+
const targetTagsLower = new Set(targetParsed.tags.map((t) => t.toLowerCase()));
|
|
515
|
+
// Outbound: resolved unique destinations from the target.
|
|
516
|
+
const seenOut = new Set();
|
|
517
|
+
const outbound = [];
|
|
518
|
+
for (const link of targetParsed.wikilinks) {
|
|
519
|
+
const m = findBestMatch(entries, link.target, target.relPath);
|
|
520
|
+
if (!m || seenOut.has(m.relPath))
|
|
521
|
+
continue;
|
|
522
|
+
seenOut.add(m.relPath);
|
|
523
|
+
const { parsed: nbrParsed } = await vault.readNote(m.absPath, m.mtimeMs);
|
|
524
|
+
outbound.push({ path: m.relPath, title: stripMd(m.basename), tags: nbrParsed.tags });
|
|
525
|
+
if (outbound.length >= cap)
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
// Inbound: notes that link to target, with backlink count.
|
|
529
|
+
const inboundCounts = new Map();
|
|
530
|
+
for (const e of entries) {
|
|
531
|
+
if (e.absPath === target.absPath)
|
|
532
|
+
continue;
|
|
533
|
+
const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
534
|
+
let cnt = 0;
|
|
535
|
+
for (const link of parsed.wikilinks) {
|
|
536
|
+
const m = findBestMatch(entries, link.target, e.relPath);
|
|
537
|
+
if (m && m.absPath === target.absPath)
|
|
538
|
+
cnt += 1;
|
|
539
|
+
}
|
|
540
|
+
if (cnt > 0)
|
|
541
|
+
inboundCounts.set(e.relPath, { entry: e, count: cnt, tags: parsed.tags });
|
|
542
|
+
}
|
|
543
|
+
const inbound = [...inboundCounts.values()]
|
|
544
|
+
.sort((a, b) => b.count - a.count)
|
|
545
|
+
.slice(0, cap)
|
|
546
|
+
.map((x) => ({ path: x.entry.relPath, title: stripMd(x.entry.basename), tags: x.tags, count: x.count }));
|
|
547
|
+
// Tag siblings: notes sharing ≥1 tag with target, excluding outbound/inbound.
|
|
548
|
+
const tag_siblings = [];
|
|
549
|
+
if (targetTagsLower.size > 0) {
|
|
550
|
+
const exclude = new Set([target.relPath, ...seenOut, ...inboundCounts.keys()]);
|
|
551
|
+
const candidates = [];
|
|
552
|
+
for (const e of entries) {
|
|
553
|
+
if (exclude.has(e.relPath))
|
|
554
|
+
continue;
|
|
555
|
+
const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
556
|
+
const shared = [];
|
|
557
|
+
for (const t of parsed.tags) {
|
|
558
|
+
if (targetTagsLower.has(t.toLowerCase()))
|
|
559
|
+
shared.push(t);
|
|
560
|
+
}
|
|
561
|
+
if (shared.length > 0) {
|
|
562
|
+
candidates.push({ path: e.relPath, title: stripMd(e.basename), shared });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
candidates.sort((a, b) => b.shared.length - a.shared.length);
|
|
566
|
+
for (const c of candidates.slice(0, cap)) {
|
|
567
|
+
tag_siblings.push({ path: c.path, title: c.title, shared_tags: c.shared });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
center: {
|
|
572
|
+
path: target.relPath,
|
|
573
|
+
title: stripMd(target.basename),
|
|
574
|
+
tags: targetParsed.tags,
|
|
575
|
+
mtime: new Date(target.mtimeMs).toISOString()
|
|
576
|
+
},
|
|
577
|
+
outbound,
|
|
578
|
+
inbound,
|
|
579
|
+
tag_siblings
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
export async function getVaultStats(vault, args) {
|
|
583
|
+
await vault.ensureExists();
|
|
584
|
+
const topTagsLimit = args.top_tags ?? 10;
|
|
585
|
+
const entries = await vault.listMarkdown();
|
|
586
|
+
const sevenDaysMs = Date.now() - 7 * 24 * 3600 * 1000;
|
|
587
|
+
let totalSize = 0;
|
|
588
|
+
let totalWords = 0;
|
|
589
|
+
let recent = 0;
|
|
590
|
+
let withFm = 0;
|
|
591
|
+
const tagCounts = new Map();
|
|
592
|
+
// Build inbound map in one pass so orphans and broken counts are O(N).
|
|
593
|
+
const inbound = new Map();
|
|
594
|
+
let broken = 0;
|
|
595
|
+
// outboundPresence is collected in the same single pass (cache hits keep
|
|
596
|
+
// this O(N) instead of the previous O(2N) re-read).
|
|
597
|
+
const outboundPresence = new Set();
|
|
598
|
+
for (const e of entries) {
|
|
599
|
+
const { content, parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
600
|
+
totalSize += Buffer.byteLength(content, "utf8");
|
|
601
|
+
totalWords += content.trim() ? content.trim().split(/\s+/).length : 0;
|
|
602
|
+
if (e.mtimeMs >= sevenDaysMs)
|
|
603
|
+
recent += 1;
|
|
604
|
+
if (Object.keys(parsed.frontmatter).length > 0)
|
|
605
|
+
withFm += 1;
|
|
606
|
+
if (parsed.wikilinks.length > 0)
|
|
607
|
+
outboundPresence.add(e.relPath);
|
|
608
|
+
for (const t of parsed.tags) {
|
|
609
|
+
const key = t.toLowerCase();
|
|
610
|
+
tagCounts.set(key, (tagCounts.get(key) ?? 0) + 1);
|
|
611
|
+
}
|
|
612
|
+
for (const link of parsed.wikilinks) {
|
|
613
|
+
const m = findBestMatch(entries, link.target, e.relPath);
|
|
614
|
+
if (!m) {
|
|
615
|
+
broken += 1;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
inbound.set(m.relPath, (inbound.get(m.relPath) ?? 0) + 1);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
let orphans = 0;
|
|
622
|
+
for (const e of entries) {
|
|
623
|
+
if (!inbound.get(e.relPath) && !outboundPresence.has(e.relPath))
|
|
624
|
+
orphans += 1;
|
|
625
|
+
}
|
|
626
|
+
const top_tags = [...tagCounts.entries()]
|
|
627
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
628
|
+
.slice(0, topTagsLimit)
|
|
629
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
630
|
+
return {
|
|
631
|
+
total_notes: entries.length,
|
|
632
|
+
total_size_bytes: totalSize,
|
|
633
|
+
avg_note_words: entries.length === 0 ? 0 : Math.round(totalWords / entries.length),
|
|
634
|
+
recently_modified_7d: recent,
|
|
635
|
+
orphans,
|
|
636
|
+
broken_wikilinks: broken,
|
|
637
|
+
total_tags: tagCounts.size,
|
|
638
|
+
top_tags,
|
|
639
|
+
notes_with_frontmatter: withFm,
|
|
640
|
+
generated_at: new Date().toISOString()
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
//# sourceMappingURL=read.js.map
|