@aisystemresources/emdee 0.1.0
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 +85 -0
- package/bin/emdee.js +196 -0
- package/package.json +78 -0
- package/src/cli/read-commands.ts +64 -0
- package/src/core/indexer.ts +346 -0
- package/src/core/parseEdges.ts +106 -0
- package/src/core/resolveLink.ts +136 -0
- package/src/core/siblings.ts +65 -0
- package/src/core/syncDocEdges.ts +427 -0
- package/src/lib/cache/bust.ts +44 -0
- package/src/lib/cache/invalidation.ts +28 -0
- package/src/lib/mcp/activity.ts +254 -0
- package/src/lib/mcp/tools/add_association.ts +311 -0
- package/src/lib/mcp/tools/append_doc.ts +70 -0
- package/src/lib/mcp/tools/append_section.ts +121 -0
- package/src/lib/mcp/tools/create_child.ts +319 -0
- package/src/lib/mcp/tools/delete_doc.ts +68 -0
- package/src/lib/mcp/tools/distill_doc.ts +262 -0
- package/src/lib/mcp/tools/filename.ts +63 -0
- package/src/lib/mcp/tools/get_context.ts +227 -0
- package/src/lib/mcp/tools/get_doc.ts +91 -0
- package/src/lib/mcp/tools/get_image.ts +62 -0
- package/src/lib/mcp/tools/get_neighbors.ts +96 -0
- package/src/lib/mcp/tools/get_summary.ts +18 -0
- package/src/lib/mcp/tools/index.ts +26 -0
- package/src/lib/mcp/tools/lint.ts +552 -0
- package/src/lib/mcp/tools/lint_doc.ts +76 -0
- package/src/lib/mcp/tools/lint_gate.ts +49 -0
- package/src/lib/mcp/tools/list_docs.ts +18 -0
- package/src/lib/mcp/tools/list_summary_drift.ts +95 -0
- package/src/lib/mcp/tools/materialize_subgroup.ts +274 -0
- package/src/lib/mcp/tools/move_doc.ts +439 -0
- package/src/lib/mcp/tools/patch_preamble.ts +145 -0
- package/src/lib/mcp/tools/patch_section.ts +113 -0
- package/src/lib/mcp/tools/read_doc_section.ts +70 -0
- package/src/lib/mcp/tools/rename_doc.ts +167 -0
- package/src/lib/mcp/tools/restore_doc.ts +41 -0
- package/src/lib/mcp/tools/search.ts +36 -0
- package/src/lib/mcp/tools/sections.ts +130 -0
- package/src/lib/mcp/tools/split_doc.ts +129 -0
- package/src/lib/mcp/tools/trash_doc.ts +116 -0
- package/src/lib/mcp/tools/types.ts +7 -0
- package/src/lib/mcp/tools/upload_image.ts +77 -0
- package/src/lib/mcp/tools/validate_args.ts +36 -0
- package/src/lib/mcp/tools/vault.ts +430 -0
- package/src/lib/mcp/tools/write_doc.ts +81 -0
- package/src/lib/mcp/tools/write_doc_preview.ts +59 -0
- package/src/lib/owner/identity.ts +65 -0
- package/src/lib/storage/FilesystemStorage.ts +88 -0
- package/src/lib/storage/SupabaseStorage.ts +358 -0
- package/src/lib/storage/VaultStorage.ts +35 -0
- package/src/lib/storage/index.ts +41 -0
- package/src/lib/supabase/admin.ts +16 -0
- package/src/lib/supabase/client.ts +8 -0
- package/src/lib/supabase/oauth.ts +296 -0
- package/src/lib/supabase/server.ts +22 -0
- package/src/lib/system-nodes.ts +68 -0
- package/src/lib/trash/state.ts +88 -0
- package/src/mcp/server.ts +380 -0
- package/templates/types/CONCEPT.md +21 -0
- package/templates/types/HACKATHON.md +28 -0
- package/templates/types/NOVEL/CHARACTERS.md +29 -0
- package/templates/types/NOVEL/DRAFT.md +15 -0
- package/templates/types/NOVEL/EDITS.md +19 -0
- package/templates/types/NOVEL/INBOX.md +15 -0
- package/templates/types/NOVEL/INSTRUCTIONS.md +32 -0
- package/templates/types/NOVEL/LEARNINGS.md +9 -0
- package/templates/types/NOVEL/OUTBOX.md +15 -0
- package/templates/types/NOVEL/PLOT.md +27 -0
- package/templates/types/NOVEL/WORLDBUILDING.md +23 -0
- package/templates/types/NOVEL.md +32 -0
- package/templates/types/PERSON.md +27 -0
- package/templates/types/PROJECT/BRAND.md +23 -0
- package/templates/types/PROJECT/BUILD.md +9 -0
- package/templates/types/PROJECT/IDEAS.md +11 -0
- package/templates/types/PROJECT/INBOX.md +15 -0
- package/templates/types/PROJECT/INSTRUCTIONS.md +23 -0
- package/templates/types/PROJECT/LEARNINGS.md +9 -0
- package/templates/types/PROJECT/LOGS.md +9 -0
- package/templates/types/PROJECT/OUTBOX.md +15 -0
- package/templates/types/PROJECT/SPRINT.md +56 -0
- package/templates/types/PROJECT.md +26 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pickByLocality, filenameSlug } from "./resolveLink";
|
|
4
|
+
import { missingSystemNodeFiles } from "../lib/system-nodes";
|
|
5
|
+
|
|
6
|
+
export type RelationKind = "hierarchy" | "assoc";
|
|
7
|
+
export type Role = "parent" | "child" | "assoc";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One bullet under a relationship section. The first wiki-link on the bullet
|
|
11
|
+
* is the declared edge target. Any other wiki-links in the same bullet are
|
|
12
|
+
* captured as `inline` references (navigational hints, not edges).
|
|
13
|
+
*/
|
|
14
|
+
export interface Link {
|
|
15
|
+
/** Raw target title from the leading wiki-link. */
|
|
16
|
+
title: string;
|
|
17
|
+
/** Trailing prose on the bullet after the leading link, trimmed. May be empty. */
|
|
18
|
+
note: string;
|
|
19
|
+
/** Other wiki-links found in the bullet's prose (excluding the leading one). */
|
|
20
|
+
inline: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DocNode {
|
|
24
|
+
path: string;
|
|
25
|
+
title: string;
|
|
26
|
+
content: string;
|
|
27
|
+
/** First blockquote line directly under the H1 (`> ...`), trimmed. May be empty. */
|
|
28
|
+
summary: string;
|
|
29
|
+
/** Parents this doc declares (via `## Child of`). */
|
|
30
|
+
parents: Link[];
|
|
31
|
+
/** Children this doc declares (via `## Parent of`). */
|
|
32
|
+
children: Link[];
|
|
33
|
+
/** Associates this doc declares (via `## Associated with`). */
|
|
34
|
+
associates: Link[];
|
|
35
|
+
/** All wiki-link titles found anywhere in the doc (for derived backlinks). */
|
|
36
|
+
mentions: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Edge {
|
|
40
|
+
from: string;
|
|
41
|
+
to: string;
|
|
42
|
+
kind: RelationKind;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DocIndex {
|
|
46
|
+
docs: DocNode[];
|
|
47
|
+
edges: Edge[];
|
|
48
|
+
entry: string | null;
|
|
49
|
+
needsNickname?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const WIKI_LINK = /\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/g;
|
|
53
|
+
const HEADING = /^(#{1,6})\s+(.+?)\s*$/;
|
|
54
|
+
const BULLET = /^\s*[-*+]\s+(.*)$/;
|
|
55
|
+
const BLOCKQUOTE = /^\s*>\s?(.*)$/;
|
|
56
|
+
|
|
57
|
+
export function deriveTitle(rel: string, content: string): string {
|
|
58
|
+
const heading = content.match(/^#\s+(.+)$/m);
|
|
59
|
+
if (heading) return heading[1].trim();
|
|
60
|
+
return path.basename(rel, ".md");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
64
|
+
|
|
65
|
+
/** Yields lines that are NOT inside fenced code blocks. */
|
|
66
|
+
function* outsideFences(content: string): IterableIterator<string> {
|
|
67
|
+
let inFence = false;
|
|
68
|
+
for (const line of content.split(/\r?\n/)) {
|
|
69
|
+
if (FENCE.test(line)) {
|
|
70
|
+
inFence = !inFence;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (inFence) continue;
|
|
74
|
+
yield line;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function deriveSummary(content: string): string {
|
|
79
|
+
// First blockquote line that appears after the H1, before the next heading.
|
|
80
|
+
let seenH1 = false;
|
|
81
|
+
for (const line of outsideFences(content)) {
|
|
82
|
+
const h = line.match(HEADING);
|
|
83
|
+
if (h) {
|
|
84
|
+
if (!seenH1 && h[1] === "#") {
|
|
85
|
+
seenH1 = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (seenH1) return ""; // hit another heading first — no summary
|
|
89
|
+
}
|
|
90
|
+
if (!seenH1) continue;
|
|
91
|
+
const bq = line.match(BLOCKQUOTE);
|
|
92
|
+
if (bq) return bq[1].trim();
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function classifyHeading(raw: string): Role | null {
|
|
98
|
+
const t = raw.trim().toLowerCase();
|
|
99
|
+
if (t === "parent of") return "child"; // children of this doc
|
|
100
|
+
if (t === "child of") return "parent"; // parents of this doc
|
|
101
|
+
if (t === "associated with" || t === "associated") return "assoc";
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface BulletParse {
|
|
106
|
+
leading: string;
|
|
107
|
+
note: string;
|
|
108
|
+
inline: string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseBullet(text: string): BulletParse | null {
|
|
112
|
+
const links = [...text.matchAll(WIKI_LINK)];
|
|
113
|
+
if (links.length === 0) return null;
|
|
114
|
+
const first = links[0];
|
|
115
|
+
const leading = first[1].trim();
|
|
116
|
+
const after = text.slice(first.index! + first[0].length);
|
|
117
|
+
// Strip common leading separators between the link and the prose.
|
|
118
|
+
const note = after.replace(/^\s*[—–\-:|·,]\s*/, "").trim();
|
|
119
|
+
const inline = links.slice(1).map((m) => m[1].trim()).filter(Boolean);
|
|
120
|
+
return { leading, note, inline };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface Sections {
|
|
124
|
+
parents: Link[];
|
|
125
|
+
children: Link[];
|
|
126
|
+
associates: Link[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function extractSections(content: string): Sections {
|
|
130
|
+
const out: Sections = { parents: [], children: [], associates: [] };
|
|
131
|
+
let role: Role | null = null;
|
|
132
|
+
// Heading level at which `role` was set. Deeper sub-headings keep us
|
|
133
|
+
// inside the relation section; equal-or-shallower headings exit it.
|
|
134
|
+
// Mirrors parseEdges.ts to keep the indexer and the doc_edges sync
|
|
135
|
+
// path in agreement.
|
|
136
|
+
let roleLevel = 0;
|
|
137
|
+
for (const line of outsideFences(content)) {
|
|
138
|
+
const h = line.match(HEADING);
|
|
139
|
+
if (h) {
|
|
140
|
+
const level = h[1].length;
|
|
141
|
+
const classified = classifyHeading(h[2]);
|
|
142
|
+
if (classified) {
|
|
143
|
+
role = classified;
|
|
144
|
+
roleLevel = level;
|
|
145
|
+
} else if (level <= roleLevel) {
|
|
146
|
+
role = null;
|
|
147
|
+
roleLevel = 0;
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!role) continue;
|
|
152
|
+
const b = line.match(BULLET);
|
|
153
|
+
if (!b) continue;
|
|
154
|
+
const parsed = parseBullet(b[1]);
|
|
155
|
+
if (!parsed) continue;
|
|
156
|
+
const link: Link = { title: parsed.leading, note: parsed.note, inline: parsed.inline };
|
|
157
|
+
if (role === "parent") out.parents.push(link);
|
|
158
|
+
else if (role === "child") out.children.push(link);
|
|
159
|
+
else out.associates.push(link);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extractAllMentions(content: string): string[] {
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
for (const line of outsideFences(content)) {
|
|
167
|
+
for (const m of line.matchAll(WIKI_LINK)) {
|
|
168
|
+
const t = m[1].trim();
|
|
169
|
+
if (t) seen.add(t);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return [...seen];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function walk(dir: string, base: string): Promise<string[]> {
|
|
176
|
+
const out: string[] = [];
|
|
177
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
178
|
+
for (const e of entries) {
|
|
179
|
+
if (e.name.startsWith(".")) continue;
|
|
180
|
+
const full = path.join(dir, e.name);
|
|
181
|
+
if (e.isDirectory()) out.push(...(await walk(full, base)));
|
|
182
|
+
else if (e.isFile() && e.name.endsWith(".md")) out.push(path.relative(base, full));
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Build an index from pre-loaded file contents — works without a filesystem. */
|
|
188
|
+
export function buildIndexFromContents(files: { path: string; content: string }[]): DocIndex {
|
|
189
|
+
const docs: DocNode[] = [];
|
|
190
|
+
for (const { path: rel, content } of files) {
|
|
191
|
+
const sections = extractSections(content);
|
|
192
|
+
docs.push({
|
|
193
|
+
path: rel,
|
|
194
|
+
title: deriveTitle(rel, content),
|
|
195
|
+
content,
|
|
196
|
+
summary: deriveSummary(content),
|
|
197
|
+
parents: dedupeLinks(sections.parents),
|
|
198
|
+
children: dedupeLinks(sections.children),
|
|
199
|
+
associates: dedupeLinks(sections.associates),
|
|
200
|
+
mentions: extractAllMentions(content),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Wiki-link resolution: prefer H1-title match, fall back to filename
|
|
205
|
+
// slug (last path segment without ".md"). Lets vaults that keep
|
|
206
|
+
// SCREAMING-KEBAB filenames but human-friendly H1s resolve their
|
|
207
|
+
// bullets to real docs without rewriting either side.
|
|
208
|
+
//
|
|
209
|
+
// Multi-maps because two docs can share a title or slug (e.g.
|
|
210
|
+
// SFPDI/DAY1 and GBI/DAY1). When that happens, the caller's path
|
|
211
|
+
// (`fromPath`) breaks the tie via path-locality scoring — see
|
|
212
|
+
// pickByLocality in resolveLink.ts.
|
|
213
|
+
const titleToPaths = new Map<string, string[]>();
|
|
214
|
+
const slugToPaths = new Map<string, string[]>();
|
|
215
|
+
for (const d of docs) {
|
|
216
|
+
const titleKey = d.title.toLowerCase();
|
|
217
|
+
const slugKey = filenameSlug(d.path).toLowerCase();
|
|
218
|
+
const titleArr = titleToPaths.get(titleKey) ?? [];
|
|
219
|
+
titleArr.push(d.path);
|
|
220
|
+
titleToPaths.set(titleKey, titleArr);
|
|
221
|
+
const slugArr = slugToPaths.get(slugKey) ?? [];
|
|
222
|
+
slugArr.push(d.path);
|
|
223
|
+
slugToPaths.set(slugKey, slugArr);
|
|
224
|
+
}
|
|
225
|
+
const resolve = (target: string, fromPath: string): string | undefined => {
|
|
226
|
+
const t = target.toLowerCase();
|
|
227
|
+
const titles = titleToPaths.get(t);
|
|
228
|
+
if (titles && titles.length > 0) {
|
|
229
|
+
if (titles.length === 1) return titles[0];
|
|
230
|
+
return pickByLocality(titles.map((path) => ({ path })), fromPath).path;
|
|
231
|
+
}
|
|
232
|
+
const slugs = slugToPaths.get(t);
|
|
233
|
+
if (slugs && slugs.length > 0) {
|
|
234
|
+
if (slugs.length === 1) return slugs[0];
|
|
235
|
+
return pickByLocality(slugs.map((path) => ({ path })), fromPath).path;
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const seen = new Set<string>();
|
|
241
|
+
const edges: Edge[] = [];
|
|
242
|
+
const pushHier = (parentPath: string, childPath: string) => {
|
|
243
|
+
if (parentPath === childPath) return;
|
|
244
|
+
const key = `H:${parentPath}->${childPath}`;
|
|
245
|
+
if (seen.has(key)) return;
|
|
246
|
+
seen.add(key);
|
|
247
|
+
edges.push({ from: parentPath, to: childPath, kind: "hierarchy" });
|
|
248
|
+
};
|
|
249
|
+
const pushAssoc = (a: string, b: string) => {
|
|
250
|
+
if (a === b) return;
|
|
251
|
+
const [x, y] = a < b ? [a, b] : [b, a];
|
|
252
|
+
const key = `A:${x}::${y}`;
|
|
253
|
+
if (seen.has(key)) return;
|
|
254
|
+
seen.add(key);
|
|
255
|
+
edges.push({ from: x, to: y, kind: "assoc" });
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Pass 1: hierarchy. Pass 2: associates, dropping pairs that are
|
|
259
|
+
// already conveyed by the hierarchy (direct parent/child OR shared
|
|
260
|
+
// parent — i.e. siblings). The hierarchy edge or the implicit
|
|
261
|
+
// sibling-through-parent relationship is the canonical link;
|
|
262
|
+
// duplicating it as an assoc just double-draws in the graph.
|
|
263
|
+
// lint_doc surfaces the same conditions (`associate_duplicates_hierarchy`,
|
|
264
|
+
// `sibling_assoc_redundant`) so the markdown can be cleaned at source.
|
|
265
|
+
for (const d of docs) {
|
|
266
|
+
for (const link of d.children) {
|
|
267
|
+
const childPath = resolve(link.title, d.path);
|
|
268
|
+
if (childPath) pushHier(d.path, childPath);
|
|
269
|
+
}
|
|
270
|
+
for (const link of d.parents) {
|
|
271
|
+
const parentPath = resolve(link.title, d.path);
|
|
272
|
+
if (parentPath) pushHier(parentPath, d.path);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Build a child→parents map from the hierarchy edges we just pushed,
|
|
277
|
+
// so the assoc pass can detect "these two docs share a parent" cheaply.
|
|
278
|
+
const parentsOf = new Map<string, Set<string>>();
|
|
279
|
+
for (const e of edges) {
|
|
280
|
+
if (e.kind !== "hierarchy") continue;
|
|
281
|
+
const set = parentsOf.get(e.to) ?? new Set<string>();
|
|
282
|
+
set.add(e.from);
|
|
283
|
+
parentsOf.set(e.to, set);
|
|
284
|
+
}
|
|
285
|
+
const shareParent = (a: string, b: string): boolean => {
|
|
286
|
+
const pa = parentsOf.get(a);
|
|
287
|
+
const pb = parentsOf.get(b);
|
|
288
|
+
if (!pa || !pb) return false;
|
|
289
|
+
for (const p of pa) if (pb.has(p)) return true;
|
|
290
|
+
return false;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
for (const d of docs) {
|
|
294
|
+
for (const link of d.associates) {
|
|
295
|
+
const assocPath = resolve(link.title, d.path);
|
|
296
|
+
if (!assocPath) continue;
|
|
297
|
+
if (seen.has(`H:${d.path}->${assocPath}`) || seen.has(`H:${assocPath}->${d.path}`)) continue;
|
|
298
|
+
if (shareParent(d.path, assocPath)) continue;
|
|
299
|
+
pushAssoc(d.path, assocPath);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Landing-doc resolution. INFO is the preferred starting point — it
|
|
304
|
+
// explains the conventions, so visitors get oriented before drilling.
|
|
305
|
+
// EMDEE is the secondary fallback for vaults that don't have INFO.
|
|
306
|
+
const overrideEntry = process.env.EMDEE_ENTRY?.toLowerCase();
|
|
307
|
+
const findByPath = (p: string) => docs.find((d) => d.path.toLowerCase() === p)?.path;
|
|
308
|
+
const entry =
|
|
309
|
+
(overrideEntry ? findByPath(overrideEntry) : undefined) ??
|
|
310
|
+
findByPath("info.md") ??
|
|
311
|
+
findByPath("emdee.md") ??
|
|
312
|
+
null;
|
|
313
|
+
|
|
314
|
+
return { docs, edges, entry };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function buildIndex(docsDir: string): Promise<DocIndex> {
|
|
318
|
+
let onDiskFiles: { path: string; content: string }[] = [];
|
|
319
|
+
try {
|
|
320
|
+
await stat(docsDir);
|
|
321
|
+
const filePaths = await walk(docsDir, docsDir);
|
|
322
|
+
onDiskFiles = await Promise.all(
|
|
323
|
+
filePaths.map(async (rel) => ({
|
|
324
|
+
path: rel,
|
|
325
|
+
content: await readFile(path.join(docsDir, rel), "utf8"),
|
|
326
|
+
}))
|
|
327
|
+
);
|
|
328
|
+
} catch {
|
|
329
|
+
// docsDir missing → treat as empty, still inject virtuals below so the
|
|
330
|
+
// 5 canonical OS-layer nodes surface even in a completely fresh vault.
|
|
331
|
+
}
|
|
332
|
+
const virtuals = missingSystemNodeFiles(onDiskFiles.map((f) => f.path));
|
|
333
|
+
return buildIndexFromContents([...onDiskFiles, ...virtuals]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function dedupeLinks(links: Link[]): Link[] {
|
|
337
|
+
const out: Link[] = [];
|
|
338
|
+
const seen = new Set<string>();
|
|
339
|
+
for (const link of links) {
|
|
340
|
+
const key = link.title.toLowerCase();
|
|
341
|
+
if (!key || seen.has(key)) continue;
|
|
342
|
+
seen.add(key);
|
|
343
|
+
out.push(link);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPRINT-018 Phase 2: shared bullet parser for `## Parent of` /
|
|
2
|
+
// `## Child of` / `## Associated with` sections. Returns a flat list of
|
|
3
|
+
// ParsedEdge — the caller (syncDocEdges, backfill) resolves targets and
|
|
4
|
+
// applies suppression.
|
|
5
|
+
//
|
|
6
|
+
// Kept dumb on purpose: no resolver, no de-dup, no cross-doc awareness.
|
|
7
|
+
// Mirrors the indexer's parsing rules (outside fences only, leading
|
|
8
|
+
// wiki-link wins, optional dash/colon between link and label prose).
|
|
9
|
+
|
|
10
|
+
export type ParsedEdgeKind = "parent_of" | "child_of" | "associated";
|
|
11
|
+
|
|
12
|
+
export interface ParsedEdge {
|
|
13
|
+
kind: ParsedEdgeKind;
|
|
14
|
+
/** Raw target title from the leading wiki-link (untrimmed of case). */
|
|
15
|
+
target: string;
|
|
16
|
+
/** Trailing prose on the bullet after the leading link, trimmed. May be null. */
|
|
17
|
+
label: string | null;
|
|
18
|
+
/** 0-based index within this section (resets per section). */
|
|
19
|
+
position: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const HEADING = /^(#{1,6})\s+(.*?)\s*$/;
|
|
23
|
+
const BULLET = /^\s*[*+\-]\s+(.*?)\s*$/;
|
|
24
|
+
const WIKI_LINK = /\[\[([^\]]+)\]\]/g;
|
|
25
|
+
const FENCE = /^(```|~~~)/;
|
|
26
|
+
|
|
27
|
+
function* outsideFences(content: string): IterableIterator<string> {
|
|
28
|
+
let inFence = false;
|
|
29
|
+
for (const line of content.split(/\r?\n/)) {
|
|
30
|
+
if (FENCE.test(line)) {
|
|
31
|
+
inFence = !inFence;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (inFence) continue;
|
|
35
|
+
yield line;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function classifyHeading(raw: string): ParsedEdgeKind | null {
|
|
40
|
+
const t = raw.trim().toLowerCase();
|
|
41
|
+
if (t === "parent of") return "parent_of";
|
|
42
|
+
if (t === "child of") return "child_of";
|
|
43
|
+
if (t === "associated with" || t === "associated") return "associated";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BulletParse {
|
|
48
|
+
leading: string;
|
|
49
|
+
label: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseBullet(text: string): BulletParse | null {
|
|
53
|
+
const links = [...text.matchAll(WIKI_LINK)];
|
|
54
|
+
if (links.length === 0) return null;
|
|
55
|
+
const first = links[0];
|
|
56
|
+
const leading = first[1].trim();
|
|
57
|
+
if (!leading) return null;
|
|
58
|
+
const after = text.slice((first.index ?? 0) + first[0].length);
|
|
59
|
+
// Strip common leading separators between the link and the prose.
|
|
60
|
+
const label = after.replace(/^\s*[—–\-:|·,]\s*/, "").trim();
|
|
61
|
+
return { leading, label: label || null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Flat-parse a doc body into the edge declarations it makes. Position is
|
|
66
|
+
* reset per relationship section so the caller can preserve sibling
|
|
67
|
+
* ordering off `parent_of` bullets.
|
|
68
|
+
*/
|
|
69
|
+
export function parseEdges(content: string): ParsedEdge[] {
|
|
70
|
+
const out: ParsedEdge[] = [];
|
|
71
|
+
let kind: ParsedEdgeKind | null = null;
|
|
72
|
+
// Heading level at which `kind` was set. A deeper sub-heading (e.g.
|
|
73
|
+
// `### Vocabulary & units` inside `## Parent of`) does NOT exit the
|
|
74
|
+
// relation section — bullets under the sub-heading still count.
|
|
75
|
+
// Only an equal-or-shallower heading exits the section.
|
|
76
|
+
let kindLevel = 0;
|
|
77
|
+
let pos = 0;
|
|
78
|
+
for (const line of outsideFences(content)) {
|
|
79
|
+
const h = line.match(HEADING);
|
|
80
|
+
if (h) {
|
|
81
|
+
const level = h[1].length;
|
|
82
|
+
const classified = classifyHeading(h[2]);
|
|
83
|
+
if (classified) {
|
|
84
|
+
kind = classified;
|
|
85
|
+
kindLevel = level;
|
|
86
|
+
pos = 0;
|
|
87
|
+
} else if (level <= kindLevel) {
|
|
88
|
+
// Sibling-or-shallower heading exits the relation section.
|
|
89
|
+
kind = null;
|
|
90
|
+
kindLevel = 0;
|
|
91
|
+
pos = 0;
|
|
92
|
+
}
|
|
93
|
+
// Otherwise (deeper heading inside relation section): keep `kind`
|
|
94
|
+
// and `pos` so bullets under sub-headings continue to accrue.
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!kind) continue;
|
|
98
|
+
const b = line.match(BULLET);
|
|
99
|
+
if (!b) continue;
|
|
100
|
+
const parsed = parseBullet(b[1]);
|
|
101
|
+
if (!parsed) continue;
|
|
102
|
+
out.push({ kind, target: parsed.leading, label: parsed.label, position: pos });
|
|
103
|
+
pos++;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { DocIndex, DocNode } from "./indexer";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a wiki-link target to a doc in the given index. Tries the H1
|
|
5
|
+
* title first (case-insensitive); falls back to the filename slug (last
|
|
6
|
+
* path segment without ".md"). The filename fallback handles vaults
|
|
7
|
+
* where wiki-links use the SCREAMING-KEBAB form while H1s carry a
|
|
8
|
+
* human-friendly title.
|
|
9
|
+
*
|
|
10
|
+
* When two docs share the same title or slug (e.g. an SFPDI/DAY1 and a
|
|
11
|
+
* GBI/DAY1), `fromPath` disambiguates by locality — a link from
|
|
12
|
+
* `events/seminars/SFPDI.md` to `[[DAY1]]` picks the candidate sitting
|
|
13
|
+
* under `events/seminars/SFPDI/`, not its GBI cousin. Without
|
|
14
|
+
* `fromPath` we fall back to first-match (legacy behaviour).
|
|
15
|
+
*/
|
|
16
|
+
export function resolveWikiLink(index: DocIndex, target: string, fromPath?: string): DocNode | null {
|
|
17
|
+
const t = target.trim().toLowerCase();
|
|
18
|
+
if (!t) return null;
|
|
19
|
+
const titleMatches = index.docs.filter((d) => d.title.toLowerCase() === t);
|
|
20
|
+
if (titleMatches.length === 1) return titleMatches[0];
|
|
21
|
+
if (titleMatches.length > 1) return pickByLocality(titleMatches, fromPath);
|
|
22
|
+
const slugMatches = index.docs.filter((d) => filenameSlug(d.path).toLowerCase() === t);
|
|
23
|
+
if (slugMatches.length === 1) return slugMatches[0];
|
|
24
|
+
if (slugMatches.length > 1) return pickByLocality(slugMatches, fromPath);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Last path segment with ".md" stripped. "events/foo/BAR.md" → "BAR". */
|
|
29
|
+
export function filenameSlug(path: string): string {
|
|
30
|
+
const last = path.split("/").pop() ?? path;
|
|
31
|
+
return last.replace(/\.md$/i, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a set of lowercase keys (H1 titles + filename slugs) that
|
|
36
|
+
* resolve to in-set docs. Used by rewriteForPublic to decide whether a
|
|
37
|
+
* wiki-link should remain navigable or be flattened to plain text.
|
|
38
|
+
*/
|
|
39
|
+
export function resolvableKeysLower(docs: DocNode[]): Set<string> {
|
|
40
|
+
const out = new Set<string>();
|
|
41
|
+
for (const d of docs) {
|
|
42
|
+
out.add(d.title.toLowerCase());
|
|
43
|
+
out.add(filenameSlug(d.path).toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tiered locality scoring for an ambiguous wiki-link. Higher tier wins;
|
|
50
|
+
* within a tier, shorter shared prefix loses (i.e. more shared segments
|
|
51
|
+
* is better). Falls back to first candidate if `fromPath` is missing.
|
|
52
|
+
*
|
|
53
|
+
* SPRINT-060 (SIG-007 part B): before locality scoring, candidates are
|
|
54
|
+
* filtered by **namespace family** to prevent shared content shadowing
|
|
55
|
+
* own content (and vice versa). The namespace family of a path:
|
|
56
|
+
* - `__shared:<ownerId>:<rest>` → shared, scoped to that ownerId
|
|
57
|
+
* - any other path → own
|
|
58
|
+
* A wiki-link from an own doc only resolves to own candidates when an
|
|
59
|
+
* own candidate exists; from a shared doc by owner X only to candidates
|
|
60
|
+
* also shared by X. If no same-family candidate exists, fall through to
|
|
61
|
+
* the cross-family pool (preserves prior behaviour for single-match
|
|
62
|
+
* shared docs linked from own context).
|
|
63
|
+
*
|
|
64
|
+
* Tiers (from `fromPath`'s point of view):
|
|
65
|
+
* 5 — same directory as the linking doc (sibling)
|
|
66
|
+
* 4 — descendant of "fromPath as a folder" (matches the common pattern
|
|
67
|
+
* where `SFPDI.md` indexes a sibling `SFPDI/` directory)
|
|
68
|
+
* 3 — ancestor directory of the linking doc (linking up the tree)
|
|
69
|
+
* 2 — shares any leading path segments
|
|
70
|
+
* 1 — no overlap; first match wins
|
|
71
|
+
*/
|
|
72
|
+
const SHARED_PATH_RE = /^__shared:([^:]+):/;
|
|
73
|
+
|
|
74
|
+
function namespaceFamily(path: string): { kind: "shared"; ownerId: string } | { kind: "own" } {
|
|
75
|
+
const m = path.match(SHARED_PATH_RE);
|
|
76
|
+
if (m) return { kind: "shared", ownerId: m[1] };
|
|
77
|
+
return { kind: "own" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sameFamily(a: string, b: string): boolean {
|
|
81
|
+
const fa = namespaceFamily(a);
|
|
82
|
+
const fb = namespaceFamily(b);
|
|
83
|
+
if (fa.kind !== fb.kind) return false;
|
|
84
|
+
if (fa.kind === "shared" && fb.kind === "shared") return fa.ownerId === fb.ownerId;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function pickByLocality<T extends { path: string }>(candidates: T[], fromPath?: string): T {
|
|
89
|
+
if (!fromPath || candidates.length === 0) return candidates[0];
|
|
90
|
+
|
|
91
|
+
// Namespace-family filter: if any candidate shares the linking doc's
|
|
92
|
+
// namespace family, restrict to those. Prevents own/shared shadowing
|
|
93
|
+
// when both have docs with the same title.
|
|
94
|
+
const sameFamilyCandidates = candidates.filter((c) => sameFamily(c.path, fromPath));
|
|
95
|
+
const effective = sameFamilyCandidates.length > 0 ? sameFamilyCandidates : candidates;
|
|
96
|
+
|
|
97
|
+
const fromSegs = fromPath.split("/");
|
|
98
|
+
const fromDir = fromSegs.slice(0, -1);
|
|
99
|
+
const fromBase = (fromSegs[fromSegs.length - 1] ?? "").replace(/\.md$/i, "");
|
|
100
|
+
|
|
101
|
+
let best = effective[0];
|
|
102
|
+
let bestScore = -1;
|
|
103
|
+
for (const c of effective) {
|
|
104
|
+
const cSegs = c.path.split("/");
|
|
105
|
+
const cDir = cSegs.slice(0, -1);
|
|
106
|
+
const shared = sharedPrefix(fromDir, cDir);
|
|
107
|
+
|
|
108
|
+
let tier = 1;
|
|
109
|
+
if (eqSegs(cDir, fromDir)) tier = 5;
|
|
110
|
+
else if (cDir.length > fromDir.length && eqSegs(cDir.slice(0, fromDir.length), fromDir) && cDir[fromDir.length] === fromBase) tier = 4;
|
|
111
|
+
else if (fromDir.length > cDir.length && eqSegs(fromDir.slice(0, cDir.length), cDir)) tier = 3;
|
|
112
|
+
else if (shared > 0) tier = 2;
|
|
113
|
+
|
|
114
|
+
// Score = tier * 1000 + shared-prefix-depth. Lets a tier-2 candidate
|
|
115
|
+
// with 4 shared segments beat a tier-2 with 2 shared segments.
|
|
116
|
+
const score = tier * 1000 + shared;
|
|
117
|
+
if (score > bestScore) {
|
|
118
|
+
bestScore = score;
|
|
119
|
+
best = c;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return best;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function eqSegs(a: string[], b: string[]): boolean {
|
|
126
|
+
if (a.length !== b.length) return false;
|
|
127
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sharedPrefix(a: string[], b: string[]): number {
|
|
132
|
+
const n = Math.min(a.length, b.length);
|
|
133
|
+
let i = 0;
|
|
134
|
+
while (i < n && a[i] === b[i]) i++;
|
|
135
|
+
return i;
|
|
136
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DocIndex, DocNode } from "./indexer";
|
|
2
|
+
import { resolveWikiLink } from "./resolveLink";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the prev / next sibling of a focal doc by walking its first
|
|
6
|
+
* declared parent's children. The parent's `## Parent of` bullet order is
|
|
7
|
+
* the source of truth — that ordering drives prev/next sibling navigation
|
|
8
|
+
* across the doc toolbar, the graph view, and the get_neighbors MCP tool.
|
|
9
|
+
*
|
|
10
|
+
* Fallback for asymmetric edges: when the parent's Parent-of list doesn't
|
|
11
|
+
* include the focal (the child declared `Child of [[parent]]` but the
|
|
12
|
+
* parent didn't reciprocate), we still want navigation to work. We
|
|
13
|
+
* augment the declared list with any other doc whose first declared
|
|
14
|
+
* parent matches the focal's first declared parent, sorted by title.
|
|
15
|
+
* Declared siblings come first (in their bullet order) so the fix doesn't
|
|
16
|
+
* change ordering for vaults with clean reciprocal edges. The lint_doc
|
|
17
|
+
* tool surfaces the asymmetric edge as a separate signal so the user can
|
|
18
|
+
* heal the underlying data when they want.
|
|
19
|
+
*
|
|
20
|
+
* Associated-with relationships never participate — siblings are purely a
|
|
21
|
+
* taxonomic concept (same parent in the hierarchy).
|
|
22
|
+
*
|
|
23
|
+
* Wiki-link resolution defers to resolveWikiLink, so a bullet like
|
|
24
|
+
* `[[DAY1]]` is disambiguated by path locality when multiple docs share
|
|
25
|
+
* a title or slug.
|
|
26
|
+
*/
|
|
27
|
+
export function getPrevNextSiblings(
|
|
28
|
+
index: DocIndex,
|
|
29
|
+
focalPath: string
|
|
30
|
+
): { prevPath: string | null; nextPath: string | null } {
|
|
31
|
+
const focalDoc = index.docs.find((d) => d.path === focalPath);
|
|
32
|
+
if (!focalDoc) return { prevPath: null, nextPath: null };
|
|
33
|
+
const primaryParent = focalDoc.parents[0];
|
|
34
|
+
if (!primaryParent) return { prevPath: null, nextPath: null };
|
|
35
|
+
|
|
36
|
+
const parentDoc = resolveWikiLink(index, primaryParent.title, focalPath);
|
|
37
|
+
if (!parentDoc) return { prevPath: null, nextPath: null };
|
|
38
|
+
|
|
39
|
+
const declared: string[] = [];
|
|
40
|
+
const declaredSet = new Set<string>();
|
|
41
|
+
for (const link of parentDoc.children) {
|
|
42
|
+
const child = resolveWikiLink(index, link.title, parentDoc.path);
|
|
43
|
+
if (!child || declaredSet.has(child.path)) continue;
|
|
44
|
+
declared.push(child.path);
|
|
45
|
+
declaredSet.add(child.path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const inverse: DocNode[] = [];
|
|
49
|
+
for (const d of index.docs) {
|
|
50
|
+
if (declaredSet.has(d.path)) continue;
|
|
51
|
+
const childPrimary = d.parents[0];
|
|
52
|
+
if (!childPrimary) continue;
|
|
53
|
+
const cp = resolveWikiLink(index, childPrimary.title, d.path);
|
|
54
|
+
if (cp?.path === parentDoc.path) inverse.push(d);
|
|
55
|
+
}
|
|
56
|
+
inverse.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
|
57
|
+
|
|
58
|
+
const allSiblings = [...declared, ...inverse.map((d) => d.path)];
|
|
59
|
+
const idx = allSiblings.indexOf(focalPath);
|
|
60
|
+
if (idx === -1) return { prevPath: null, nextPath: null };
|
|
61
|
+
return {
|
|
62
|
+
prevPath: allSiblings[idx - 1] ?? null,
|
|
63
|
+
nextPath: allSiblings[idx + 1] ?? null,
|
|
64
|
+
};
|
|
65
|
+
}
|