@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.
Files changed (82) hide show
  1. package/README.md +85 -0
  2. package/bin/emdee.js +196 -0
  3. package/package.json +78 -0
  4. package/src/cli/read-commands.ts +64 -0
  5. package/src/core/indexer.ts +346 -0
  6. package/src/core/parseEdges.ts +106 -0
  7. package/src/core/resolveLink.ts +136 -0
  8. package/src/core/siblings.ts +65 -0
  9. package/src/core/syncDocEdges.ts +427 -0
  10. package/src/lib/cache/bust.ts +44 -0
  11. package/src/lib/cache/invalidation.ts +28 -0
  12. package/src/lib/mcp/activity.ts +254 -0
  13. package/src/lib/mcp/tools/add_association.ts +311 -0
  14. package/src/lib/mcp/tools/append_doc.ts +70 -0
  15. package/src/lib/mcp/tools/append_section.ts +121 -0
  16. package/src/lib/mcp/tools/create_child.ts +319 -0
  17. package/src/lib/mcp/tools/delete_doc.ts +68 -0
  18. package/src/lib/mcp/tools/distill_doc.ts +262 -0
  19. package/src/lib/mcp/tools/filename.ts +63 -0
  20. package/src/lib/mcp/tools/get_context.ts +227 -0
  21. package/src/lib/mcp/tools/get_doc.ts +91 -0
  22. package/src/lib/mcp/tools/get_image.ts +62 -0
  23. package/src/lib/mcp/tools/get_neighbors.ts +96 -0
  24. package/src/lib/mcp/tools/get_summary.ts +18 -0
  25. package/src/lib/mcp/tools/index.ts +26 -0
  26. package/src/lib/mcp/tools/lint.ts +552 -0
  27. package/src/lib/mcp/tools/lint_doc.ts +76 -0
  28. package/src/lib/mcp/tools/lint_gate.ts +49 -0
  29. package/src/lib/mcp/tools/list_docs.ts +18 -0
  30. package/src/lib/mcp/tools/list_summary_drift.ts +95 -0
  31. package/src/lib/mcp/tools/materialize_subgroup.ts +274 -0
  32. package/src/lib/mcp/tools/move_doc.ts +439 -0
  33. package/src/lib/mcp/tools/patch_preamble.ts +145 -0
  34. package/src/lib/mcp/tools/patch_section.ts +113 -0
  35. package/src/lib/mcp/tools/read_doc_section.ts +70 -0
  36. package/src/lib/mcp/tools/rename_doc.ts +167 -0
  37. package/src/lib/mcp/tools/restore_doc.ts +41 -0
  38. package/src/lib/mcp/tools/search.ts +36 -0
  39. package/src/lib/mcp/tools/sections.ts +130 -0
  40. package/src/lib/mcp/tools/split_doc.ts +129 -0
  41. package/src/lib/mcp/tools/trash_doc.ts +116 -0
  42. package/src/lib/mcp/tools/types.ts +7 -0
  43. package/src/lib/mcp/tools/upload_image.ts +77 -0
  44. package/src/lib/mcp/tools/validate_args.ts +36 -0
  45. package/src/lib/mcp/tools/vault.ts +430 -0
  46. package/src/lib/mcp/tools/write_doc.ts +81 -0
  47. package/src/lib/mcp/tools/write_doc_preview.ts +59 -0
  48. package/src/lib/owner/identity.ts +65 -0
  49. package/src/lib/storage/FilesystemStorage.ts +88 -0
  50. package/src/lib/storage/SupabaseStorage.ts +358 -0
  51. package/src/lib/storage/VaultStorage.ts +35 -0
  52. package/src/lib/storage/index.ts +41 -0
  53. package/src/lib/supabase/admin.ts +16 -0
  54. package/src/lib/supabase/client.ts +8 -0
  55. package/src/lib/supabase/oauth.ts +296 -0
  56. package/src/lib/supabase/server.ts +22 -0
  57. package/src/lib/system-nodes.ts +68 -0
  58. package/src/lib/trash/state.ts +88 -0
  59. package/src/mcp/server.ts +380 -0
  60. package/templates/types/CONCEPT.md +21 -0
  61. package/templates/types/HACKATHON.md +28 -0
  62. package/templates/types/NOVEL/CHARACTERS.md +29 -0
  63. package/templates/types/NOVEL/DRAFT.md +15 -0
  64. package/templates/types/NOVEL/EDITS.md +19 -0
  65. package/templates/types/NOVEL/INBOX.md +15 -0
  66. package/templates/types/NOVEL/INSTRUCTIONS.md +32 -0
  67. package/templates/types/NOVEL/LEARNINGS.md +9 -0
  68. package/templates/types/NOVEL/OUTBOX.md +15 -0
  69. package/templates/types/NOVEL/PLOT.md +27 -0
  70. package/templates/types/NOVEL/WORLDBUILDING.md +23 -0
  71. package/templates/types/NOVEL.md +32 -0
  72. package/templates/types/PERSON.md +27 -0
  73. package/templates/types/PROJECT/BRAND.md +23 -0
  74. package/templates/types/PROJECT/BUILD.md +9 -0
  75. package/templates/types/PROJECT/IDEAS.md +11 -0
  76. package/templates/types/PROJECT/INBOX.md +15 -0
  77. package/templates/types/PROJECT/INSTRUCTIONS.md +23 -0
  78. package/templates/types/PROJECT/LEARNINGS.md +9 -0
  79. package/templates/types/PROJECT/LOGS.md +9 -0
  80. package/templates/types/PROJECT/OUTBOX.md +15 -0
  81. package/templates/types/PROJECT/SPRINT.md +56 -0
  82. package/templates/types/PROJECT.md +26 -0
@@ -0,0 +1,18 @@
1
+ import { loadVaultIndex } from "./vault";
2
+ import type { ToolContext } from "./types";
3
+
4
+ function json(value: unknown) {
5
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
6
+ }
7
+
8
+ function text(value: string) {
9
+ return { content: [{ type: "text" as const, text: value }] };
10
+ }
11
+
12
+ export async function listDocs(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
13
+ const idx = await loadVaultIndex(ctx);
14
+ if (args.format === "text") {
15
+ return text(idx.docs.map((d) => d.path).join("\n"));
16
+ }
17
+ return json(idx.docs.map((d) => ({ path: d.path, title: d.title, summary: d.summary })));
18
+ }
@@ -0,0 +1,95 @@
1
+ // SPRINT-081: list docs whose body has changed since their summary was
2
+ // last authored — the working set for the summariser workflow.
3
+ //
4
+ // Cloud mode: reads the vault_files cache, computes the current content
5
+ // hash per row in-app, keeps rows where the snapshot doesn't match (or is
6
+ // NULL, meaning never baselined post-migration). Local mode has no
7
+ // persistence — every doc is returned as a candidate.
8
+ //
9
+ // Response is intentionally minimal: path + current summary + drift
10
+ // reason. `format: "text"` returns newline-delimited paths only.
11
+
12
+ import { loadVaultIndex } from "./vault";
13
+ import { hashBody } from "./sections";
14
+ import { adminClient } from "../../supabase/admin";
15
+ import { deriveSummary } from "../../../core/indexer";
16
+ import type { ToolContext } from "./types";
17
+
18
+ const CACHE_TABLE = "vault_files";
19
+
20
+ interface DriftCandidate {
21
+ path: string;
22
+ current_summary: string;
23
+ reason: "never_baselined" | "body_drifted";
24
+ }
25
+
26
+ function json(value: unknown) {
27
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
28
+ }
29
+
30
+ function text(value: string) {
31
+ return { content: [{ type: "text" as const, text: value }] };
32
+ }
33
+
34
+ async function candidatesLocal(ctx: Extract<ToolContext, { mode: "local" }>): Promise<DriftCandidate[]> {
35
+ const idx = await loadVaultIndex(ctx);
36
+ return idx.docs.map((d) => ({
37
+ path: d.path,
38
+ current_summary: d.summary ?? "",
39
+ reason: "never_baselined" as const,
40
+ }));
41
+ }
42
+
43
+ async function candidatesCloud(ctx: Extract<ToolContext, { mode: "cloud" }>): Promise<DriftCandidate[]> {
44
+ // HARD RULE 6: PostgREST caps `.select()` at 1000 rows server-side, so
45
+ // any vault > 1000 docs would silently truncate without pagination.
46
+ // Explicit .range() loop until a short page comes back.
47
+ const admin = adminClient();
48
+ const PAGE = 1000;
49
+ const out: DriftCandidate[] = [];
50
+ for (let offset = 0; ; offset += PAGE) {
51
+ const { data, error } = await admin
52
+ .from(CACHE_TABLE)
53
+ .select("file_path, content, content_hash_at_summary_write")
54
+ .eq("namespace", ctx.userId)
55
+ .order("file_path", { ascending: true })
56
+ .range(offset, offset + PAGE - 1);
57
+ if (error) throw new Error(`list_summary_drift query failed: ${error.message}`);
58
+ const rows = data ?? [];
59
+ for (const row of rows) {
60
+ const content = (row.content as string) ?? "";
61
+ const stored = (row.content_hash_at_summary_write as string | null) ?? null;
62
+ const now = hashBody(content);
63
+ if (stored === null) {
64
+ out.push({ path: row.file_path as string, current_summary: deriveSummary(content), reason: "never_baselined" });
65
+ } else if (stored !== now) {
66
+ out.push({ path: row.file_path as string, current_summary: deriveSummary(content), reason: "body_drifted" });
67
+ }
68
+ }
69
+ if (rows.length < PAGE) break;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ export async function listSummaryDrift(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
75
+ const prefix = typeof args.prefix === "string" ? args.prefix : "";
76
+ const limit = Math.max(1, Number(args.limit ?? 20) | 0);
77
+ const offset = Math.max(0, Number(args.offset ?? 0) | 0);
78
+
79
+ const all = ctx.mode === "local" ? await candidatesLocal(ctx) : await candidatesCloud(ctx);
80
+ const filtered = all
81
+ .filter((c) => !prefix || c.path.startsWith(prefix))
82
+ .sort((a, b) => a.path.localeCompare(b.path))
83
+ .slice(offset, offset + limit);
84
+
85
+ if (args.format === "text") {
86
+ return text(filtered.map((c) => c.path).join("\n"));
87
+ }
88
+ return json({
89
+ total_before_slice: all.filter((c) => !prefix || c.path.startsWith(prefix)).length,
90
+ returned: filtered.length,
91
+ offset,
92
+ limit,
93
+ candidates: filtered,
94
+ });
95
+ }
@@ -0,0 +1,274 @@
1
+ import path from "node:path";
2
+ import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
3
+ import { resolveWikiLink } from "../../../core/resolveLink";
4
+ import type { ToolContext } from "./types";
5
+ import { validateArgs } from "./validate_args";
6
+
7
+ const ARG_SPEC = {
8
+ allowed: ["source_path", "subgroup_heading", "new_doc_title", "new_doc_path", "summary"],
9
+ required: ["source_path", "subgroup_heading"],
10
+ } as const;
11
+
12
+ const H1_RE = /^#\s+(.+?)\s*$/m;
13
+ const H2_RE = /^##\s+(.+?)\s*$/;
14
+ const H3_RE = /^###\s+(.+?)\s*$/;
15
+ const BULLET_RE = /^\s*[-*]\s+/;
16
+ const WIKI_LINK_RE = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/;
17
+
18
+ function deriveTitle(content: string, fallbackPath: string): string {
19
+ const m = content.match(H1_RE);
20
+ if (m) return m[1].trim();
21
+ return path.basename(fallbackPath, ".md");
22
+ }
23
+
24
+ function sanitizeFilename(title: string): string {
25
+ // Match the rename_doc convention but also strip the em-dash since
26
+ // Supabase Storage rejects it. Replace ` — ` with `-`, then strip any
27
+ // remaining unsafe chars.
28
+ return title.replace(/\s*—\s*/g, "-").replace(/[/\\]/g, "_");
29
+ }
30
+
31
+ function escapeRegex(s: string): string {
32
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ }
34
+
35
+ interface SubgroupExtract {
36
+ bulletLines: string[];
37
+ bulletTitles: string[];
38
+ // Line indices (inclusive) of the entire subgroup region: from the H3
39
+ // line through the last bullet of the subgroup. Used for surgical
40
+ // replacement in the source.
41
+ startLineIdx: number;
42
+ endLineIdx: number;
43
+ }
44
+
45
+ /**
46
+ * Walk source content, locate the named H3 subgroup inside `## Parent of`,
47
+ * extract its bullets, and return the line indices of the subgroup region
48
+ * so we can splice in a single replacement bullet later.
49
+ */
50
+ function extractSubgroup(content: string, subgroupHeading: string): SubgroupExtract | null {
51
+ const lines = content.split("\n");
52
+ const targetLc = subgroupHeading.trim().toLowerCase();
53
+ let inFence = false;
54
+ let inParentOf = false;
55
+ let h3Idx = -1;
56
+ let endIdx = -1;
57
+ const bulletLines: string[] = [];
58
+ const bulletTitles: string[] = [];
59
+
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const line = lines[i];
62
+ if (/^\s*(?:```|~~~)/.test(line)) { inFence = !inFence; continue; }
63
+ if (inFence) continue;
64
+
65
+ const h2 = line.match(H2_RE);
66
+ if (h2) {
67
+ if (h3Idx !== -1) {
68
+ // Hit the next H2 after collecting our subgroup — done.
69
+ endIdx = i - 1;
70
+ break;
71
+ }
72
+ inParentOf = h2[1].trim().toLowerCase() === "parent of";
73
+ continue;
74
+ }
75
+ if (!inParentOf) continue;
76
+
77
+ const h3 = line.match(H3_RE);
78
+ if (h3) {
79
+ if (h3Idx !== -1) {
80
+ // Hit the next H3 within Parent of — our subgroup ends here.
81
+ endIdx = i - 1;
82
+ break;
83
+ }
84
+ if (h3[1].trim().toLowerCase() === targetLc) {
85
+ h3Idx = i;
86
+ }
87
+ continue;
88
+ }
89
+
90
+ if (h3Idx === -1) continue;
91
+ if (BULLET_RE.test(line)) {
92
+ const leading = line.replace(BULLET_RE, "").match(WIKI_LINK_RE);
93
+ if (leading) {
94
+ bulletLines.push(line);
95
+ bulletTitles.push(leading[1].trim());
96
+ }
97
+ }
98
+ }
99
+
100
+ if (h3Idx === -1) return null;
101
+ if (endIdx === -1) endIdx = lines.length - 1;
102
+ // Trim trailing blank lines from the subgroup region so the splice
103
+ // doesn't leave double-blank gaps.
104
+ while (endIdx > h3Idx && lines[endIdx].trim() === "") endIdx--;
105
+ return { bulletLines, bulletTitles, startLineIdx: h3Idx, endLineIdx: endIdx };
106
+ }
107
+
108
+ /**
109
+ * Replace the subgroup region (H3 + its bullets) with a single bullet
110
+ * pointing at the new intermediate doc.
111
+ */
112
+ function rewriteSourceParentOf(content: string, sub: SubgroupExtract, newDocTitle: string): string {
113
+ const lines = content.split("\n");
114
+ const replacement = `* [[${newDocTitle}]]`;
115
+ const before = lines.slice(0, sub.startLineIdx);
116
+ const after = lines.slice(sub.endLineIdx + 1);
117
+ return [...before, replacement, ...after].join("\n");
118
+ }
119
+
120
+ /**
121
+ * Rewrite a child doc's `## Child of` bullet from the old parent's title
122
+ * to the new intermediate's title. Preserves any `|alias` suffix and any
123
+ * trailing prose on the bullet. Only touches the Child of section.
124
+ */
125
+ function rewriteChildOf(content: string, oldTitle: string, newTitle: string): { content: string; changed: boolean } {
126
+ const lines = content.split("\n");
127
+ const out: string[] = [];
128
+ let inFence = false;
129
+ let inChildOf = false;
130
+ let changed = false;
131
+ const re = new RegExp(`\\[\\[${escapeRegex(oldTitle)}(\\|[^\\]]+)?\\]\\]`, "gi");
132
+ for (const line of lines) {
133
+ if (/^\s*(?:```|~~~)/.test(line)) { inFence = !inFence; out.push(line); continue; }
134
+ if (inFence) { out.push(line); continue; }
135
+ const h2 = line.match(H2_RE);
136
+ if (h2) {
137
+ inChildOf = h2[1].trim().toLowerCase() === "child of";
138
+ out.push(line);
139
+ continue;
140
+ }
141
+ if (!inChildOf) { out.push(line); continue; }
142
+ const next = line.replace(re, (_m, alias: string | undefined) => {
143
+ changed = true;
144
+ return `[[${newTitle}${alias ?? ""}]]`;
145
+ });
146
+ out.push(next);
147
+ }
148
+ return { content: out.join("\n"), changed };
149
+ }
150
+
151
+ function buildIntermediateDoc(title: string, summary: string, sourceTitle: string, bulletLines: string[]): string {
152
+ const summaryLine = summary.trim() || `Intermediate node grouping the ${bulletLines.length} concepts originally listed under "${title}" in [[${sourceTitle}]].`;
153
+ return [
154
+ `# ${title}`,
155
+ "",
156
+ `> ${summaryLine}`,
157
+ "",
158
+ "## Child of",
159
+ "",
160
+ `* [[${sourceTitle}]]`,
161
+ "",
162
+ "## Parent of",
163
+ "",
164
+ ...bulletLines,
165
+ "",
166
+ ].join("\n");
167
+ }
168
+
169
+ /**
170
+ * Promote an H3 subgroup inside a doc's `## Parent of` to a real
171
+ * intermediate parent node. Atomically:
172
+ * - creates the new intermediate doc with the subgroup's bullets as
173
+ * its own `## Parent of`
174
+ * - rewrites the source's `## Parent of` so the H3 region is replaced
175
+ * by a single bullet pointing at the new intermediate
176
+ * - rewires each child's `## Child of` from the source's title to the
177
+ * new intermediate's title (single-parent convention preserved)
178
+ *
179
+ * Used when a parent doc has accumulated too many children and the user
180
+ * already grouped them semantically with H3 sub-headings — this turns
181
+ * the visual grouping into a structural one. Detection lives in lint
182
+ * (`subgroup_materialization_candidate`).
183
+ */
184
+ export async function materializeSubgroup(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
185
+ const argErr = validateArgs(args, ARG_SPEC);
186
+ if (argErr) return json(argErr);
187
+ const sourcePath = String(args.source_path ?? "");
188
+ const subgroupHeading = String(args.subgroup_heading ?? "");
189
+ const summary = args.summary !== undefined ? String(args.summary) : "";
190
+
191
+ if (!sourcePath) return json({ error: "source_path required" });
192
+ if (!subgroupHeading) return json({ error: "subgroup_heading required" });
193
+ validatePath(sourcePath);
194
+
195
+ const sourceContent = await readVaultFile(ctx, sourcePath);
196
+ if (sourceContent === null) return json({ error: "source_not_found", path: sourcePath });
197
+
198
+ const sourceTitle = deriveTitle(sourceContent, sourcePath);
199
+
200
+ const newDocTitle = args.new_doc_title
201
+ ? String(args.new_doc_title).trim()
202
+ : `${sourceTitle} — ${subgroupHeading.trim()}`;
203
+ const newDocPath = args.new_doc_path
204
+ ? String(args.new_doc_path)
205
+ : (() => {
206
+ const dir = path.dirname(sourcePath);
207
+ const fname = `${sanitizeFilename(newDocTitle)}.md`;
208
+ return dir === "." ? fname : `${dir}/${fname}`;
209
+ })();
210
+ validatePath(newDocPath);
211
+
212
+ const sub = extractSubgroup(sourceContent, subgroupHeading);
213
+ if (!sub) return json({ error: "subgroup_not_found", heading: subgroupHeading });
214
+ if (sub.bulletTitles.length === 0) return json({ error: "subgroup_empty", heading: subgroupHeading });
215
+
216
+ const index = await loadVaultIndex(ctx);
217
+
218
+ // Pre-flight: destination path must be free.
219
+ if (index.docs.some((d) => d.path === newDocPath)) {
220
+ return json({ error: "new_doc_path_exists", path: newDocPath });
221
+ }
222
+ // Pre-flight: title can't collide with another doc.
223
+ const titleConflict = index.docs.find(
224
+ (d) => d.path !== sourcePath && d.title.toLowerCase() === newDocTitle.toLowerCase()
225
+ );
226
+ if (titleConflict) {
227
+ return json({ error: "title_collision", path: titleConflict.path, title: titleConflict.title });
228
+ }
229
+
230
+ // Resolve every bullet to a child doc path so we can rewrite their
231
+ // `## Child of`. Unresolved bullets are reported but don't block —
232
+ // they may be forward-references to docs not yet created.
233
+ const childDocs: Array<{ title: string; path: string }> = [];
234
+ const unresolved: string[] = [];
235
+ for (const t of sub.bulletTitles) {
236
+ const resolved = resolveWikiLink(index, t, sourcePath);
237
+ if (resolved) childDocs.push({ title: t, path: resolved.path });
238
+ else unresolved.push(t);
239
+ }
240
+
241
+ const newDocContent = buildIntermediateDoc(newDocTitle, summary, sourceTitle, sub.bulletLines);
242
+ const newSourceContent = rewriteSourceParentOf(sourceContent, sub, newDocTitle);
243
+
244
+ // Stage child rewrites in memory so we can validate before any writes.
245
+ const childUpdates: Array<{ path: string; content: string }> = [];
246
+ for (const c of childDocs) {
247
+ const content = await readVaultFile(ctx, c.path);
248
+ if (content === null) continue;
249
+ const result = rewriteChildOf(content, sourceTitle, newDocTitle);
250
+ if (result.changed) childUpdates.push({ path: c.path, content: result.content });
251
+ }
252
+
253
+ // Execute: write the intermediate first (idempotent on retry — the
254
+ // existence check catches the destination collision), then the source
255
+ // rewrite, then each child rewrite.
256
+ await writeVaultFile(ctx, newDocPath, newDocContent);
257
+ await writeVaultFile(ctx, sourcePath, newSourceContent);
258
+ for (const u of childUpdates) {
259
+ await writeVaultFile(ctx, u.path, u.content);
260
+ }
261
+
262
+ return json({
263
+ ok: true,
264
+ new_doc_path: newDocPath,
265
+ new_doc_title: newDocTitle,
266
+ bullets_promoted: sub.bulletTitles.length,
267
+ children_rewired: childUpdates.length,
268
+ unresolved_bullets: unresolved,
269
+ });
270
+ }
271
+
272
+ function json(value: unknown) {
273
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
274
+ }