@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,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
|
+
}
|