@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,70 @@
1
+ // SPRINT-024 Phase 1: per-section read. Returns one section's body +
2
+ // content_hash + section_id without paying for the entire doc body.
3
+ // Supports the same hash-conditional short-circuit as get_doc: pass
4
+ // `expected_content_hash` from a prior read and we'll short-return
5
+ // `{ unchanged: true, ... }` when the section body is byte-equal.
6
+ //
7
+ // Lookup precedence mirrors patch_section / append_section exactly
8
+ // (see resolveSection in ./sections.ts) so the section_id flow is
9
+ // uniform across read and write tools.
10
+
11
+ import { validatePath, readVaultFile } from "./vault";
12
+ import {
13
+ parseSections,
14
+ extractBody,
15
+ hashBody,
16
+ sectionId,
17
+ resolveSection,
18
+ } from "./sections";
19
+ import type { ToolContext } from "./types";
20
+
21
+ function json(value: unknown) {
22
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
23
+ }
24
+
25
+ export async function readDocSection(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
26
+ const rel = String(args.path);
27
+ validatePath(rel);
28
+ const headingArg = args.heading !== undefined ? String(args.heading).trim() : "";
29
+ const sectionIdArg = args.section_id !== undefined ? String(args.section_id).trim() : "";
30
+ const expected = args.expected_content_hash !== undefined ? String(args.expected_content_hash) : "";
31
+ if (!headingArg && !sectionIdArg) throw new Error("heading or section_id required");
32
+
33
+ const content = await readVaultFile(ctx, rel);
34
+ if (content === null) return json({ error: "doc_not_found", path: rel });
35
+
36
+ const sections = parseSections(content);
37
+ const resolved = resolveSection(sections, sectionIdArg, headingArg);
38
+ if (resolved.kind === "mismatch") {
39
+ return json({
40
+ error: "section_id_heading_mismatch",
41
+ section_id_resolves_to: resolved.section_id_resolves_to,
42
+ heading_resolves_to: resolved.heading_resolves_to,
43
+ });
44
+ }
45
+ if (resolved.kind === "not_found") {
46
+ return json({
47
+ error: "section_not_found",
48
+ heading: headingArg || undefined,
49
+ section_id: sectionIdArg || undefined,
50
+ available: resolved.available,
51
+ });
52
+ }
53
+
54
+ const { loc, idx } = resolved;
55
+ const body = extractBody(content, loc);
56
+ const contentHash = hashBody(body);
57
+ const id = sectionId(loc.heading, idx);
58
+
59
+ if (expected && expected === contentHash) {
60
+ return json({ unchanged: true, section_id: id, content_hash: contentHash });
61
+ }
62
+
63
+ return json({
64
+ path: rel,
65
+ section_id: id,
66
+ heading: loc.heading,
67
+ body,
68
+ content_hash: contentHash,
69
+ });
70
+ }
@@ -0,0 +1,167 @@
1
+ import path from "node:path";
2
+ import { validatePath, readVaultFile, writeVaultFile, deleteVaultFile, loadVaultIndex } from "./vault";
3
+ import { adminClient } from "../../supabase/admin";
4
+ import type { ToolContext } from "./types";
5
+ import { validateArgs } from "./validate_args";
6
+
7
+ const ARG_SPEC = {
8
+ allowed: ["old_path", "new_title", "new_path"],
9
+ required: ["old_path", "new_title"],
10
+ } as const;
11
+
12
+ function escapeRegex(s: string): string {
13
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ }
15
+
16
+ function deriveTitle(content: string, fallbackPath: string): string {
17
+ const heading = content.match(/^#\s+(.+)$/m);
18
+ if (heading) return heading[1].trim();
19
+ return path.basename(fallbackPath, ".md");
20
+ }
21
+
22
+ function rewriteH1(content: string, newTitle: string): string {
23
+ if (/^#\s+.+$/m.test(content)) {
24
+ return content.replace(/^#\s+.+$/m, `# ${newTitle}`);
25
+ }
26
+ // No H1 — prepend one with the standard blockquote-summary placeholder.
27
+ return `# ${newTitle}\n\n> \n\n${content}`;
28
+ }
29
+
30
+ function sanitizeFilename(title: string): string {
31
+ // Match what the AddChild flow does — strip slashes/backslashes to keep
32
+ // the filename within a single directory. Other characters (spaces,
33
+ // hyphens, dashes) are preserved so titles like "ATLAS — BUILD" map
34
+ // back to "ATLAS — BUILD.md".
35
+ return title.replace(/[/\\]/g, "_");
36
+ }
37
+
38
+ function defaultNewPath(oldPath: string, newTitle: string): string {
39
+ const dir = path.dirname(oldPath);
40
+ const fname = `${sanitizeFilename(newTitle)}.md`;
41
+ return dir === "." ? fname : `${dir}/${fname}`;
42
+ }
43
+
44
+ /**
45
+ * Rewrites every `[[<oldTitle>]]` and `[[<oldTitle>|alias]]` occurrence
46
+ * (case-insensitive) to point at `newTitle`. Aliases are preserved.
47
+ * Returns the new content plus a boolean indicating whether anything
48
+ * changed.
49
+ */
50
+ function rewriteWikiLinks(content: string, oldTitle: string, newTitle: string): { content: string; changed: boolean } {
51
+ if (oldTitle.toLowerCase() === newTitle.toLowerCase()) return { content, changed: false };
52
+ const re = new RegExp(`\\[\\[${escapeRegex(oldTitle)}(\\|[^\\]]+)?\\]\\]`, "gi");
53
+ let changed = false;
54
+ const next = content.replace(re, (_match, alias: string | undefined) => {
55
+ changed = true;
56
+ return `[[${newTitle}${alias ?? ""}]]`;
57
+ });
58
+ return { content: next, changed };
59
+ }
60
+
61
+ /**
62
+ * Atomic rename of a doc:
63
+ * - rewrite its H1 to the new title
64
+ * - write to the new path (default: same directory, sanitized title)
65
+ * - delete the old path if it moved
66
+ * - rewrite all `[[<old_title>]]` references across every other doc
67
+ * - update doc_shares / share_invitations / sync_manifest path refs
68
+ * (cloud mode only)
69
+ *
70
+ * Pre-flight checks block destination-path collisions and title
71
+ * collisions with other docs. Self-references inside the doc are
72
+ * rewritten too.
73
+ *
74
+ * On failure during the cross-doc rewrite phase, the renamed doc stays
75
+ * at the new path — partial rewrites are left in place. The caller can
76
+ * re-run with the same args; per-doc writes are idempotent because the
77
+ * old-title links won't be present in already-rewritten docs.
78
+ */
79
+ export async function renameDoc(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
80
+ const argErr = validateArgs(args, ARG_SPEC);
81
+ if (argErr) return json(argErr);
82
+ const oldPath = String(args.old_path ?? "");
83
+ const newTitle = String(args.new_title ?? "").trim();
84
+ const newPath = args.new_path ? String(args.new_path) : defaultNewPath(oldPath, newTitle);
85
+
86
+ if (!oldPath) return json({ error: "old_path required" });
87
+ if (!newTitle) return json({ error: "new_title required" });
88
+
89
+ validatePath(oldPath);
90
+ validatePath(newPath);
91
+
92
+ const oldContent = await readVaultFile(ctx, oldPath);
93
+ if (oldContent === null) return json({ error: "source_not_found", path: oldPath });
94
+
95
+ const oldTitle = deriveTitle(oldContent, oldPath);
96
+
97
+ const index = await loadVaultIndex(ctx);
98
+
99
+ // Pre-flight: destination path must be free unless it equals source.
100
+ if (newPath !== oldPath) {
101
+ const exists = index.docs.some((d) => d.path === newPath);
102
+ if (exists) return json({ error: "destination_path_exists", path: newPath });
103
+ }
104
+
105
+ // Pre-flight: new title can't collide with another doc's title (the
106
+ // indexer uses title→path to resolve wiki-links, so duplicates would
107
+ // break navigation).
108
+ if (oldTitle.toLowerCase() !== newTitle.toLowerCase()) {
109
+ const conflict = index.docs.find(
110
+ (d) => d.path !== oldPath && d.title.toLowerCase() === newTitle.toLowerCase()
111
+ );
112
+ if (conflict) {
113
+ return json({ error: "title_conflict", path: conflict.path, title: conflict.title });
114
+ }
115
+ }
116
+
117
+ // Stage 1: rewrite the source doc's body (H1 + self-references).
118
+ let updatedSelf = rewriteH1(oldContent, newTitle);
119
+ updatedSelf = rewriteWikiLinks(updatedSelf, oldTitle, newTitle).content;
120
+ await writeVaultFile(ctx, newPath, updatedSelf);
121
+ if (newPath !== oldPath) {
122
+ await deleteVaultFile(ctx, oldPath);
123
+ }
124
+
125
+ // Stage 2: rewrite wiki-links in every other doc that mentions oldTitle.
126
+ let docsRewritten = 0;
127
+ for (const d of index.docs) {
128
+ if (d.path === oldPath) continue;
129
+ const result = rewriteWikiLinks(d.content, oldTitle, newTitle);
130
+ if (!result.changed) continue;
131
+ await writeVaultFile(ctx, d.path, result.content);
132
+ docsRewritten++;
133
+ }
134
+
135
+ // Stage 3 (cloud only): update DB rows that reference the path directly.
136
+ // doc_shares.path_prefix / share_root, share_invitations.path_prefix /
137
+ // share_root, sync_manifest.file_path (which uses the namespaced form).
138
+ if (ctx.mode === "cloud" && newPath !== oldPath) {
139
+ const admin = adminClient();
140
+ const ownerId = ctx.userId;
141
+ await Promise.all([
142
+ admin.from("doc_shares").update({ path_prefix: newPath })
143
+ .eq("owner_id", ownerId).eq("path_prefix", oldPath),
144
+ admin.from("doc_shares").update({ share_root: newPath })
145
+ .eq("owner_id", ownerId).eq("share_root", oldPath),
146
+ admin.from("share_invitations").update({ path_prefix: newPath })
147
+ .eq("inviter_id", ownerId).eq("path_prefix", oldPath),
148
+ admin.from("share_invitations").update({ share_root: newPath })
149
+ .eq("inviter_id", ownerId).eq("share_root", oldPath),
150
+ admin.from("sync_manifest").update({ file_path: `${ownerId}/${newPath}` })
151
+ .eq("clerk_id", ownerId).eq("file_path", `${ownerId}/${oldPath}`),
152
+ ]);
153
+ }
154
+
155
+ return json({
156
+ ok: true,
157
+ old_path: oldPath,
158
+ new_path: newPath,
159
+ old_title: oldTitle,
160
+ new_title: newTitle,
161
+ docs_rewritten: docsRewritten,
162
+ });
163
+ }
164
+
165
+ function json(value: unknown) {
166
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
167
+ }
@@ -0,0 +1,41 @@
1
+ import { validatePath } from "./vault";
2
+ import { readTrashedState, writeTrashedState } from "../../trash/state";
3
+ import type { ToolContext } from "./types";
4
+ import { validateArgs } from "./validate_args";
5
+
6
+ const ARG_SPEC = { allowed: ["path"], required: ["path"] } as const;
7
+
8
+ // SPRINT-057 (SIG-008): restore a previously-trashed doc by clearing its
9
+ // entry in `.emdee/trashed.json`. The doc's markdown is untouched
10
+ // throughout the trash → restore cycle; only the lifecycle flag moves.
11
+
12
+ function json(value: unknown) {
13
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
14
+ }
15
+
16
+ export async function restoreDoc(
17
+ ctx: ToolContext,
18
+ args: Record<string, unknown>,
19
+ ): Promise<unknown> {
20
+ const argErr = validateArgs(args, ARG_SPEC);
21
+ if (argErr) return json(argErr);
22
+ const docPath = String(args.path ?? "");
23
+ if (!docPath) return json({ error: "path required" });
24
+ validatePath(docPath);
25
+
26
+ const state = await readTrashedState(ctx);
27
+ const entry = state[docPath];
28
+ if (!entry) {
29
+ return json({ error: "not_trashed", path: docPath });
30
+ }
31
+
32
+ delete state[docPath];
33
+ await writeTrashedState(ctx, state);
34
+
35
+ return json({
36
+ ok: true,
37
+ path: docPath,
38
+ restored_to: entry.original_parent_path,
39
+ was_trashed_at: entry.trashed_at,
40
+ });
41
+ }
@@ -0,0 +1,36 @@
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 makeSnippet(content: string, query: string, radius = 60): string {
9
+ const i = content.toLowerCase().indexOf(query.toLowerCase());
10
+ if (i < 0) return "";
11
+ const start = Math.max(0, i - radius);
12
+ const end = Math.min(content.length, i + query.length + radius);
13
+ return (start > 0 ? "…" : "") + content.slice(start, end).replace(/\s+/g, " ").trim() + (end < content.length ? "…" : "");
14
+ }
15
+
16
+ export async function search(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
17
+ const query = String(args.query ?? "").trim();
18
+ if (!query) return json([]);
19
+ const limit = Math.max(1, Math.min(50, Number(args.limit ?? 10)));
20
+ const idx = await loadVaultIndex(ctx);
21
+ const q = query.toLowerCase();
22
+ const hits = idx.docs
23
+ .map((d) => {
24
+ const titleHit = d.title.toLowerCase().includes(q);
25
+ const summaryHit = d.summary.toLowerCase().includes(q);
26
+ const contentHit = d.content.toLowerCase().includes(q);
27
+ if (!titleHit && !summaryHit && !contentHit) return null;
28
+ const score = (titleHit ? 3 : 0) + (summaryHit ? 2 : 0) + (contentHit ? 1 : 0);
29
+ return { score, ref: { path: d.path, title: d.title, summary: d.summary, snippet: titleHit ? "" : makeSnippet(d.content, query) } };
30
+ })
31
+ .filter((x): x is { score: number; ref: { path: string; title: string; summary: string; snippet: string } } => x !== null)
32
+ .sort((a, b) => b.score - a.score)
33
+ .slice(0, limit)
34
+ .map((x) => x.ref);
35
+ return json(hits);
36
+ }
@@ -0,0 +1,130 @@
1
+ // Shared section-parsing primitives used by get_doc / patch_section /
2
+ // append_section / read_doc_section. Hoisted in SPRINT-024 Phase 1 to
3
+ // keep the H2 parser + content-hash helper in one place; previous
4
+ // per-tool copies were drifting at the edges.
5
+
6
+ import { createHash } from "node:crypto";
7
+
8
+ const FENCE_RE = /^\s*(?:```|~~~)/;
9
+ const H2_RE = /^##\s+(.+?)\s*$/;
10
+
11
+ export interface SectionLoc {
12
+ heading: string;
13
+ headingLineIdx: number;
14
+ bodyStartLineIdx: number;
15
+ bodyEndLineIdx: number;
16
+ }
17
+
18
+ export function parseSections(content: string): SectionLoc[] {
19
+ const lines = content.split("\n");
20
+ const sections: SectionLoc[] = [];
21
+ let inFence = false;
22
+ for (let i = 0; i < lines.length; i++) {
23
+ if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
24
+ if (inFence) continue;
25
+ const m = lines[i].match(H2_RE);
26
+ if (!m) continue;
27
+ if (sections.length > 0) sections[sections.length - 1].bodyEndLineIdx = i;
28
+ sections.push({ heading: m[1].trim(), headingLineIdx: i, bodyStartLineIdx: i + 1, bodyEndLineIdx: lines.length });
29
+ }
30
+ return sections;
31
+ }
32
+
33
+ export function extractBody(content: string, loc: SectionLoc): string {
34
+ return content
35
+ .split("\n")
36
+ .slice(loc.bodyStartLineIdx, loc.bodyEndLineIdx)
37
+ .join("\n")
38
+ .replace(/^\s*\n+/, "")
39
+ .replace(/\n+\s*$/, "");
40
+ }
41
+
42
+ export function findSectionByHeading(sections: SectionLoc[], heading: string): SectionLoc | undefined {
43
+ const target = heading.replace(/^##\s*/, "").trim().toLowerCase();
44
+ return sections.find((s) => s.heading.toLowerCase() === target);
45
+ }
46
+
47
+ /**
48
+ * sha256-first-16-hex of the input string. Used for both section bodies
49
+ * (the version-guard hash that gates patch_section) and whole-doc raw
50
+ * content (the doc_content_hash returned by get_doc / get_context for
51
+ * hash-conditional reads, SPRINT-024 Phase 1).
52
+ */
53
+ export function hashBody(body: string): string {
54
+ return createHash("sha256").update(body, "utf8").digest("hex").slice(0, 16);
55
+ }
56
+
57
+ /**
58
+ * Deterministic short ID for an H2 section. Derived from the lowercased
59
+ * heading text plus the section's 0-indexed ordinal among H2s in the doc,
60
+ * so two sections sharing a heading get distinct IDs. The ordinal also
61
+ * means the ID is stable across rename of the heading IF the section
62
+ * stays at the same position.
63
+ */
64
+ export function sectionId(heading: string, ordinalIdx: number): string {
65
+ return createHash("sha256")
66
+ .update(heading.toLowerCase() + ":" + ordinalIdx, "utf8")
67
+ .digest("hex")
68
+ .slice(0, 12);
69
+ }
70
+
71
+ /**
72
+ * Resolve a section lookup. Shared between patch_section / append_section /
73
+ * read_doc_section so the section_id_heading_mismatch and section_not_found
74
+ * shapes are identical across tools.
75
+ *
76
+ * Precedence:
77
+ * - section_id alone: derive IDs by ordinal; first match wins.
78
+ * - heading alone: fuzzy heading match.
79
+ * - both: resolve each independently; if they pick different sections,
80
+ * returns { kind: "mismatch" } so the caller emits section_id_heading_mismatch.
81
+ */
82
+ export type SectionResolution =
83
+ | { kind: "ok"; loc: SectionLoc; idx: number }
84
+ | {
85
+ kind: "mismatch";
86
+ section_id_resolves_to: string;
87
+ heading_resolves_to: string;
88
+ }
89
+ | { kind: "not_found"; available: Array<{ id: string; heading: string }> };
90
+
91
+ export function resolveSection(
92
+ sections: SectionLoc[],
93
+ sectionIdArg: string,
94
+ headingArg: string,
95
+ ): SectionResolution {
96
+ let idTarget: { loc: SectionLoc; idx: number } | undefined;
97
+ let headingTarget: { loc: SectionLoc; idx: number } | undefined;
98
+
99
+ if (sectionIdArg) {
100
+ for (let i = 0; i < sections.length; i++) {
101
+ if (sectionId(sections[i].heading, i) === sectionIdArg) {
102
+ idTarget = { loc: sections[i], idx: i };
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ if (headingArg) {
108
+ const loc = findSectionByHeading(sections, headingArg);
109
+ if (loc) {
110
+ const idx = sections.indexOf(loc);
111
+ headingTarget = { loc, idx };
112
+ }
113
+ }
114
+
115
+ if (sectionIdArg && headingArg && idTarget && headingTarget && idTarget.loc !== headingTarget.loc) {
116
+ return {
117
+ kind: "mismatch",
118
+ section_id_resolves_to: idTarget.loc.heading,
119
+ heading_resolves_to: headingTarget.loc.heading,
120
+ };
121
+ }
122
+
123
+ const picked = idTarget ?? headingTarget;
124
+ if (picked) return { kind: "ok", loc: picked.loc, idx: picked.idx };
125
+
126
+ return {
127
+ kind: "not_found",
128
+ available: sections.map((s, i) => ({ id: sectionId(s.heading, i), heading: s.heading })),
129
+ };
130
+ }
@@ -0,0 +1,129 @@
1
+ import path from "node:path";
2
+ import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
3
+ import type { ToolContext } from "./types";
4
+ import { validateArgs } from "./validate_args";
5
+
6
+ const ARG_SPEC = {
7
+ allowed: ["source_path", "rewrite_source_content", "extracts"],
8
+ required: ["source_path", "rewrite_source_content", "extracts"],
9
+ } as const;
10
+
11
+ interface ExtractInput {
12
+ path: string;
13
+ content: string;
14
+ }
15
+
16
+ function deriveTitle(rel: string, content: string): string {
17
+ const heading = content.match(/^#\s+(.+)$/m);
18
+ if (heading) return heading[1].trim();
19
+ return path.basename(rel, ".md");
20
+ }
21
+
22
+ /**
23
+ * Atomically refactor a doc into concept nodes.
24
+ *
25
+ * Use this when a doc has grown into multiple distinct reusable ideas
26
+ * (e.g. "TFAR", "Always complete what you've started") that deserve their
27
+ * own nodes the rest of the vault can wiki-link to. Claude builds the
28
+ * extraction plan in chat, then calls split_doc once to execute.
29
+ *
30
+ * Pre-flight checks catch the failure modes that bit us before:
31
+ * - extract path already exists
32
+ * - extract H1 title collides with an existing doc (would break the
33
+ * title→path map the indexer uses for wiki-link resolution)
34
+ * - two extracts share a path or title
35
+ *
36
+ * On write failure mid-flight, the source rewrite is skipped so the
37
+ * original references aren't orphaned. Any extracts already written
38
+ * stay (idempotent — calling again with the same plan will fail the
39
+ * existence check, which is the desired safety).
40
+ */
41
+ export async function splitDoc(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
42
+ const argErr = validateArgs(args, ARG_SPEC);
43
+ if (argErr) return json(argErr);
44
+ const sourcePath = String(args.source_path ?? "");
45
+ const rewriteContent = String(args.rewrite_source_content ?? "");
46
+ const extracts = Array.isArray(args.extracts) ? (args.extracts as ExtractInput[]) : [];
47
+
48
+ if (!sourcePath) return json({ error: "source_path required" });
49
+ if (!rewriteContent) return json({ error: "rewrite_source_content required (provide the new full markdown for the source doc with inline content replaced by wiki-links)" });
50
+ if (extracts.length === 0) return json({ error: "extracts array must be non-empty" });
51
+
52
+ validatePath(sourcePath);
53
+ for (const e of extracts) {
54
+ if (!e || typeof e.path !== "string" || typeof e.content !== "string") {
55
+ return json({ error: "each extract requires { path, content }" });
56
+ }
57
+ validatePath(e.path);
58
+ }
59
+
60
+ const index = await loadVaultIndex(ctx);
61
+ const sourceDoc = index.docs.find((d) => d.path === sourcePath);
62
+ if (!sourceDoc) return json({ error: "source_not_found", path: sourcePath });
63
+
64
+ const existingPaths = new Set(index.docs.map((d) => d.path));
65
+ const existingTitles = new Map<string, string>();
66
+ for (const d of index.docs) existingTitles.set(d.title.toLowerCase(), d.path);
67
+
68
+ const errors: string[] = [];
69
+ const seenPaths = new Set<string>();
70
+ const seenTitles = new Map<string, string>();
71
+
72
+ for (const e of extracts) {
73
+ if (seenPaths.has(e.path)) errors.push(`duplicate extract path in plan: ${e.path}`);
74
+ seenPaths.add(e.path);
75
+
76
+ if (existingPaths.has(e.path)) errors.push(`path already exists in vault: ${e.path}`);
77
+
78
+ const title = deriveTitle(e.path, e.content);
79
+ const titleLc = title.toLowerCase();
80
+
81
+ const collidingPath = existingTitles.get(titleLc);
82
+ if (collidingPath && collidingPath !== sourcePath) {
83
+ errors.push(`title "${title}" collides with existing doc ${collidingPath} — pick a different H1 or path`);
84
+ }
85
+
86
+ if (seenTitles.has(titleLc)) {
87
+ errors.push(`two extracts share title "${title}": ${seenTitles.get(titleLc)} and ${e.path}`);
88
+ }
89
+ seenTitles.set(titleLc, e.path);
90
+ }
91
+
92
+ if (errors.length > 0) {
93
+ return json({ error: "validation_failed", errors });
94
+ }
95
+
96
+ // Backup source so we can attempt rollback on partial failure.
97
+ const sourceBackup = await readVaultFile(ctx, sourcePath);
98
+ if (sourceBackup === null) return json({ error: "source_disappeared", path: sourcePath });
99
+
100
+ const written: string[] = [];
101
+ try {
102
+ for (const e of extracts) {
103
+ await writeVaultFile(ctx, e.path, e.content);
104
+ written.push(e.path);
105
+ }
106
+ await writeVaultFile(ctx, sourcePath, rewriteContent);
107
+ } catch (err) {
108
+ return json({
109
+ error: "write_failed_mid_flight",
110
+ message: (err as Error).message,
111
+ extracts_written: written,
112
+ source_rewritten: false,
113
+ note: "Source was NOT rewritten so existing references stay valid. Inspect the written extracts and either delete them or call split_doc again with only the remaining extracts.",
114
+ });
115
+ }
116
+
117
+ return json({
118
+ ok: true,
119
+ source_rewritten: sourcePath,
120
+ extracts_created: extracts.map((e) => ({
121
+ path: e.path,
122
+ title: deriveTitle(e.path, e.content),
123
+ })),
124
+ });
125
+ }
126
+
127
+ function json(value: unknown) {
128
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
129
+ }
@@ -0,0 +1,116 @@
1
+ import { validatePath, readVaultFile, loadVaultIndex } from "./vault";
2
+ import { resolveWikiLink } from "../../../core/resolveLink";
3
+ import { readTrashedState, writeTrashedState } from "../../trash/state";
4
+ import type { ToolContext } from "./types";
5
+ import { validateArgs } from "./validate_args";
6
+
7
+ const ARG_SPEC = {
8
+ allowed: ["path", "original_parent_path"],
9
+ required: ["path"],
10
+ } as const;
11
+
12
+ // SPRINT-057 (SIG-008): trash a doc by flagging it in the .emdee/trashed.json
13
+ // sidecar. The markdown is untouched; original Child of / Parent of edges
14
+ // stay in the file so restore is lossless. The trash flag is what the
15
+ // renderer consults to hide it from non-graveyard views.
16
+ //
17
+ // Pre-flight requires the doc to declare a parent via `## Child of`. The
18
+ // first declared parent becomes the restore target. Multi-parent docs use
19
+ // the first bullet — pass `original_parent_path` to override.
20
+
21
+ const H2_RE = /^##\s+(.+?)\s*$/;
22
+ const FENCE_RE = /^\s*(?:```|~~~)/;
23
+ const BULLET_LINK_RE = /^\s*[-*]\s+\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/i;
24
+
25
+ function findChildOfFirstWikiLink(content: string): string | null {
26
+ const lines = content.split("\n");
27
+ let inFence = false;
28
+ let inChildOf = false;
29
+ for (const line of lines) {
30
+ if (FENCE_RE.test(line)) {
31
+ inFence = !inFence;
32
+ continue;
33
+ }
34
+ if (inFence) continue;
35
+ const h = line.match(H2_RE);
36
+ if (h) {
37
+ inChildOf = h[1].trim().toLowerCase() === "child of";
38
+ continue;
39
+ }
40
+ if (!inChildOf) continue;
41
+ const m = line.match(BULLET_LINK_RE);
42
+ if (m) return m[1].trim();
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function json(value: unknown) {
48
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
49
+ }
50
+
51
+ export async function trashDoc(
52
+ ctx: ToolContext,
53
+ args: Record<string, unknown>,
54
+ ): Promise<unknown> {
55
+ const argErr = validateArgs(args, ARG_SPEC);
56
+ if (argErr) return json(argErr);
57
+ const docPath = String(args.path ?? "");
58
+ const explicitParent =
59
+ args.original_parent_path !== undefined ? String(args.original_parent_path) : "";
60
+
61
+ if (!docPath) return json({ error: "path required" });
62
+ validatePath(docPath);
63
+ if (explicitParent) validatePath(explicitParent);
64
+
65
+ const content = await readVaultFile(ctx, docPath);
66
+ if (content === null) return json({ error: "doc_not_found", path: docPath });
67
+
68
+ // Resolve original parent. Explicit arg wins; otherwise derive from the
69
+ // doc's first Child of bullet via the indexer.
70
+ let originalParent = explicitParent;
71
+ if (!originalParent) {
72
+ const parentTitle = findChildOfFirstWikiLink(content);
73
+ if (!parentTitle) {
74
+ return json({
75
+ error: "no_resolvable_parent",
76
+ path: docPath,
77
+ hint: "doc has no `## Child of` bullet — pass original_parent_path to record an explicit restore target, or give the doc a parent via patch_section first",
78
+ });
79
+ }
80
+ const index = await loadVaultIndex(ctx);
81
+ const resolved = resolveWikiLink(index, parentTitle, docPath);
82
+ if (!resolved || !resolved.path) {
83
+ return json({
84
+ error: "unresolved_parent",
85
+ path: docPath,
86
+ declared_parent_title: parentTitle,
87
+ hint: "the doc's Child of bullet points to a non-existent doc — pass original_parent_path to record an explicit restore target",
88
+ });
89
+ }
90
+ originalParent = resolved.path;
91
+ }
92
+
93
+ // Idempotent: already-trashed doc returns ok with the existing entry.
94
+ const state = await readTrashedState(ctx);
95
+ if (state[docPath]) {
96
+ return json({
97
+ ok: true,
98
+ path: docPath,
99
+ original_parent_path: state[docPath].original_parent_path,
100
+ trashed_at: state[docPath].trashed_at,
101
+ already_trashed: true,
102
+ });
103
+ }
104
+
105
+ const trashedAt = new Date().toISOString();
106
+ state[docPath] = { original_parent_path: originalParent, trashed_at: trashedAt };
107
+ await writeTrashedState(ctx, state);
108
+
109
+ return json({
110
+ ok: true,
111
+ path: docPath,
112
+ original_parent_path: originalParent,
113
+ trashed_at: trashedAt,
114
+ already_trashed: false,
115
+ });
116
+ }