@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,7 @@
1
+ import type { VaultStorage } from "../../storage/VaultStorage";
2
+
3
+ export type ToolContext =
4
+ | { mode: "local"; docsDir: string }
5
+ | { mode: "cloud"; storage: VaultStorage; userId: string };
6
+
7
+ export type { DocIndex, DocNode, Link } from "../../../core/indexer";
@@ -0,0 +1,77 @@
1
+ import { adminClient } from "../../supabase/admin";
2
+ import { writeVaultFile } from "./vault";
3
+ import type { ToolContext } from "./types";
4
+
5
+ const IMAGE_BUCKET = "vault-images";
6
+
7
+ const SUPPORTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] as const;
8
+ type SupportedMediaType = (typeof SUPPORTED_TYPES)[number];
9
+
10
+ function ext(mediaType: SupportedMediaType): string {
11
+ const map: Record<SupportedMediaType, string> = {
12
+ "image/jpeg": "jpg",
13
+ "image/png": "png",
14
+ "image/gif": "gif",
15
+ "image/webp": "webp",
16
+ };
17
+ return map[mediaType];
18
+ }
19
+
20
+ function slugify(s: string): string {
21
+ // SPRINT-055 (SIG-004): uppercase filenames are project convention.
22
+ return s.toUpperCase().trim().replace(/[^A-Z0-9]+/g, "-").replace(/^-|-$/g, "");
23
+ }
24
+
25
+ function json(value: unknown) {
26
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
27
+ }
28
+
29
+ export async function uploadImage(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
30
+ if (ctx.mode === "local") {
31
+ return json({ error: "upload_image requires cloud mode — not available in local (stdio) mode" });
32
+ }
33
+
34
+ const imageData = String(args.image_data ?? "");
35
+ const mediaType = String(args.media_type ?? "") as SupportedMediaType;
36
+ const titleArg = args.title !== undefined ? String(args.title) : null;
37
+ const description = args.description !== undefined ? String(args.description) : "";
38
+ const pathArg = args.path !== undefined ? String(args.path) : null;
39
+
40
+ if (!imageData) return json({ error: "image_data is required" });
41
+ if (!(SUPPORTED_TYPES as readonly string[]).includes(mediaType)) {
42
+ return json({ error: `unsupported media_type — must be one of: ${SUPPORTED_TYPES.join(", ")}` });
43
+ }
44
+
45
+ let imageBuffer: Buffer;
46
+ try {
47
+ imageBuffer = Buffer.from(imageData, "base64");
48
+ } catch {
49
+ return json({ error: "image_data is not valid base64" });
50
+ }
51
+ if (imageBuffer.length === 0) return json({ error: "image_data decoded to empty buffer" });
52
+
53
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
54
+ const filename = `${ts}.${ext(mediaType)}`;
55
+ const storagePath = `${ctx.userId}/${filename}`;
56
+
57
+ const { error: uploadErr } = await adminClient()
58
+ .storage
59
+ .from(IMAGE_BUCKET)
60
+ .upload(storagePath, imageBuffer, { contentType: mediaType, upsert: false });
61
+
62
+ if (uploadErr) return json({ error: `storage upload failed: ${uploadErr.message}` });
63
+
64
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
65
+ const imageUrl = `${supabaseUrl}/storage/v1/object/public/${IMAGE_BUCKET}/${storagePath}`;
66
+
67
+ const title = titleArg ?? `Image ${ts.slice(0, 10)}`;
68
+ const summary = description || "Image stored in vault";
69
+ const docSlug = titleArg ? slugify(titleArg) : ts.slice(0, 10);
70
+ const docPath = pathArg ?? `images/${docSlug}.md`;
71
+
72
+ const docContent = `# ${title}\n\n> ${summary}\n\n![${title}](${imageUrl})\n\n## Notes\n\n`;
73
+
74
+ await writeVaultFile(ctx, docPath, docContent);
75
+
76
+ return json({ doc_path: docPath, image_url: imageUrl, doc_created: true });
77
+ }
@@ -0,0 +1,36 @@
1
+ // SPRINT-092: strict argument validation for write-side MCP tools.
2
+ //
3
+ // The MCP SDK (@modelcontextprotocol/sdk/dist/esm/server/index.js) validates
4
+ // only the outer CallToolRequestSchema — individual tool `arguments` are
5
+ // passed through as Record<string, unknown> without JSON-Schema enforcement.
6
+ // Adding `additionalProperties: false` to inputSchema is documentation only.
7
+ //
8
+ // This helper closes the gap at each tool's entry point: unknown parameter
9
+ // names return a loud error instead of being silently dropped, and required
10
+ // parameters must actually be present (not just default to "" when missing).
11
+
12
+ export interface ArgSpec {
13
+ allowed: readonly string[];
14
+ required?: readonly string[];
15
+ }
16
+
17
+ export type ArgValidationError =
18
+ | { error: "unknown_arguments"; unknown: string[]; allowed: string[] }
19
+ | { error: "missing_required"; missing: string[]; allowed: string[] };
20
+
21
+ export function validateArgs(
22
+ args: Record<string, unknown>,
23
+ spec: ArgSpec,
24
+ ): ArgValidationError | null {
25
+ const allowedSet = new Set(spec.allowed);
26
+ const unknown = Object.keys(args).filter((k) => !allowedSet.has(k));
27
+ if (unknown.length > 0) {
28
+ return { error: "unknown_arguments", unknown, allowed: [...spec.allowed] };
29
+ }
30
+ const required = spec.required ?? [];
31
+ const missing = required.filter((k) => !(k in args));
32
+ if (missing.length > 0) {
33
+ return { error: "missing_required", missing, allowed: [...spec.allowed] };
34
+ }
35
+ return null;
36
+ }
@@ -0,0 +1,430 @@
1
+ import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { buildIndex, buildIndexFromContents, type DocIndex } from "../../../core/indexer";
4
+ import { adminClient } from "../../supabase/admin";
5
+ import { subscribeNamespaceInvalidate } from "../../cache/invalidation";
6
+ import { missingSystemNodeFiles } from "../../system-nodes";
7
+ import type { ToolContext } from "./types";
8
+
9
+ /**
10
+ * Paths under SHARED_PATH_PREFIX point at docs owned by another user that
11
+ * this user has been granted read access to. Format:
12
+ * __shared__/<owner_clerk_id>/<rel_path>
13
+ * These look like normal vault paths to MCP clients but are routed
14
+ * cross-namespace by readVaultFile and refused by every write op.
15
+ */
16
+ export const SHARED_PATH_PREFIX = "__shared__/";
17
+
18
+ export function validatePath(rel: string): void {
19
+ if (!rel || rel.includes("..")) throw new Error("invalid path");
20
+ if (!rel.endsWith(".md")) throw new Error("path must end in .md");
21
+ }
22
+
23
+ function localSafePath(docsDir: string, rel: string): string {
24
+ const resolved = path.resolve(docsDir, rel);
25
+ if (!resolved.startsWith(docsDir + path.sep) && resolved !== docsDir) {
26
+ throw new Error("path escapes docs directory");
27
+ }
28
+ return resolved;
29
+ }
30
+
31
+ function parseSharedPath(rel: string): { ownerId: string; relPath: string } | null {
32
+ if (!rel.startsWith(SHARED_PATH_PREFIX)) return null;
33
+ const rest = rel.slice(SHARED_PATH_PREFIX.length);
34
+ const slash = rest.indexOf("/");
35
+ if (slash === -1) return null;
36
+ return { ownerId: rest.slice(0, slash), relPath: rest.slice(slash + 1) };
37
+ }
38
+
39
+ async function listSharedDocsForGrantee(granteeId: string): Promise<Array<{ ownerId: string; relPath: string }>> {
40
+ const { data } = await adminClient()
41
+ .from("doc_shares")
42
+ .select("owner_id, path_prefix")
43
+ .eq("grantee_id", granteeId);
44
+ return (data ?? []).map((r) => ({ ownerId: r.owner_id as string, relPath: r.path_prefix as string }));
45
+ }
46
+
47
+ async function hasShareAccess(granteeId: string, ownerId: string, relPath: string): Promise<boolean> {
48
+ const { data } = await adminClient()
49
+ .from("doc_shares")
50
+ .select("id")
51
+ .eq("grantee_id", granteeId)
52
+ .eq("owner_id", ownerId)
53
+ .eq("path_prefix", relPath)
54
+ .maybeSingle();
55
+ return !!data;
56
+ }
57
+
58
+ /**
59
+ * Direct match OR ancestor match for `permission='write'`. Ancestor walk
60
+ * uses path conventions (cascade-by-hierarchy): write on
61
+ * `projects/DOUBLELEAD.md` covers `projects/DOUBLELEAD/IDEAS/NewIdea.md`.
62
+ * The strict-ancestor form (`<parts>/...md`) avoids cross-prefix false
63
+ * positives like `DOUBLELEAD-OFFSHOOT.md`.
64
+ */
65
+ async function isWritableSharedPath(
66
+ granteeId: string,
67
+ ownerId: string,
68
+ relPath: string,
69
+ ): Promise<boolean> {
70
+ const admin = adminClient();
71
+
72
+ const { data: direct } = await admin
73
+ .from("doc_shares")
74
+ .select("id")
75
+ .eq("grantee_id", granteeId)
76
+ .eq("owner_id", ownerId)
77
+ .eq("path_prefix", relPath)
78
+ .eq("permission", "write")
79
+ .maybeSingle();
80
+ if (direct) return true;
81
+
82
+ const parts = relPath.split("/");
83
+ const candidates: string[] = [];
84
+ for (let i = parts.length - 1; i > 0; i--) {
85
+ candidates.push(parts.slice(0, i).join("/") + ".md");
86
+ }
87
+ if (candidates.length > 0) {
88
+ const { data: anc } = await admin
89
+ .from("doc_shares")
90
+ .select("path_prefix")
91
+ .eq("grantee_id", granteeId)
92
+ .eq("owner_id", ownerId)
93
+ .eq("permission", "write")
94
+ .in("path_prefix", candidates);
95
+ if ((anc?.length ?? 0) > 0) return true;
96
+ }
97
+
98
+ // Sibling-directory fallback: the share root (e.g. signals/SIGNALS.md) lives
99
+ // at the same level as the new file (signals/SIG-009.md), so neither a direct
100
+ // nor a directory-ancestor match fires. If ANY write-permission row exists for
101
+ // a file in the same directory, the grantee's cascade write grant covers it.
102
+ const dir = parts.slice(0, -1).join("/");
103
+ if (!dir) return false;
104
+ const { data: sib } = await admin
105
+ .from("doc_shares")
106
+ .select("id")
107
+ .eq("grantee_id", granteeId)
108
+ .eq("owner_id", ownerId)
109
+ .eq("permission", "write")
110
+ .like("path_prefix", `${dir}/%`)
111
+ .limit(1);
112
+ return (sib?.length ?? 0) > 0;
113
+ }
114
+
115
+ /**
116
+ * SPRINT-032: after a write-permission grantee creates a doc under a
117
+ * shared subtree, the share API never inserted a doc_shares row for the
118
+ * new path — so neither the writer nor any other grantee of the share
119
+ * root sees it. Mirror the cascade by inserting one row per
120
+ * (grantee, share_root) for the new path, copying each grantee's existing
121
+ * permission. Read grantees still see (read-only); write grantees can
122
+ * keep editing. Idempotent — repeat writes upsert to the same rows.
123
+ *
124
+ * Best-effort: a failure here is logged by the caller but doesn't fail
125
+ * the underlying write.
126
+ */
127
+ async function propagateShareToNewPath(
128
+ ownerId: string,
129
+ newPath: string,
130
+ writerGranteeId: string,
131
+ ): Promise<void> {
132
+ const admin = adminClient();
133
+
134
+ // 1. Which share_roots authorised this write?
135
+ // First try ancestor-level matches (e.g. DOUBLELEAD.md covers DOUBLELEAD/**).
136
+ // If that finds nothing, fall back to sibling-directory: the share root lives
137
+ // at the same folder level as the new file (e.g. signals/SIGNALS.md is the
138
+ // root for signals/SIG-009.md). Without this fallback, propagation silently
139
+ // fails for the common case where the root is an index file in a sub-folder.
140
+ const parts = newPath.split("/");
141
+ const candidates: string[] = [];
142
+ for (let i = parts.length - 1; i > 0; i--) {
143
+ candidates.push(parts.slice(0, i).join("/") + ".md");
144
+ }
145
+
146
+ let authRows: { share_root: string | null }[] | null = null;
147
+ if (candidates.length > 0) {
148
+ const { data } = await admin
149
+ .from("doc_shares")
150
+ .select("share_root")
151
+ .eq("owner_id", ownerId)
152
+ .eq("grantee_id", writerGranteeId)
153
+ .eq("permission", "write")
154
+ .in("path_prefix", candidates);
155
+ authRows = data;
156
+ }
157
+
158
+ if (!authRows || authRows.length === 0) {
159
+ const dir = parts.slice(0, -1).join("/");
160
+ if (!dir) return;
161
+ const { data: sibData } = await admin
162
+ .from("doc_shares")
163
+ .select("share_root")
164
+ .eq("owner_id", ownerId)
165
+ .eq("grantee_id", writerGranteeId)
166
+ .eq("permission", "write")
167
+ .like("path_prefix", `${dir}/%`);
168
+ authRows = sibData;
169
+ }
170
+
171
+ if (!authRows || authRows.length === 0) return;
172
+
173
+ const shareRoots = [
174
+ ...new Set(
175
+ authRows
176
+ .map((r) => r.share_root as string | null)
177
+ .filter((s): s is string => !!s),
178
+ ),
179
+ ];
180
+ if (shareRoots.length === 0) return;
181
+
182
+ // 2. Every grantee of those share_roots, incl. read-only. Same group,
183
+ // same view.
184
+ const { data: groupRows } = await admin
185
+ .from("doc_shares")
186
+ .select("grantee_id, permission, share_root")
187
+ .eq("owner_id", ownerId)
188
+ .in("share_root", shareRoots);
189
+ if (!groupRows || groupRows.length === 0) return;
190
+
191
+ // 3. Dedupe by (grantee, share_root). Write beats read for the same
192
+ // pair (degenerate case — defensive only).
193
+ type Insert = {
194
+ owner_id: string;
195
+ grantee_id: string;
196
+ path_prefix: string;
197
+ permission: "read" | "write";
198
+ share_root: string;
199
+ };
200
+ const byKey = new Map<string, Insert>();
201
+ for (const r of groupRows) {
202
+ const grantee = r.grantee_id as string;
203
+ const share_root = r.share_root as string;
204
+ const permission = r.permission as "read" | "write";
205
+ const key = `${grantee}::${share_root}`;
206
+ const existing = byKey.get(key);
207
+ if (existing) {
208
+ if (permission === "write" && existing.permission === "read") {
209
+ existing.permission = "write";
210
+ }
211
+ continue;
212
+ }
213
+ byKey.set(key, {
214
+ owner_id: ownerId,
215
+ grantee_id: grantee,
216
+ path_prefix: newPath,
217
+ permission,
218
+ share_root,
219
+ });
220
+ }
221
+
222
+ const inserts = Array.from(byKey.values());
223
+ if (inserts.length === 0) return;
224
+
225
+ const { error } = await admin
226
+ .from("doc_shares")
227
+ .upsert(inserts, { onConflict: "owner_id,path_prefix,grantee_id" });
228
+ if (error) throw new Error(error.message);
229
+ }
230
+
231
+ /**
232
+ * SPRINT-035: module-scope `loadVaultIndex` memo. Replaces the per-ctx
233
+ * WeakMap (which was empty per-request because the MCP HTTP route
234
+ * allocates a fresh ToolContext per call — see
235
+ * `app/api/mcp/route.ts:164`), so a single Vercel function instance
236
+ * shares the computed index across all tool calls within the TTL window.
237
+ *
238
+ * Keyed by `(mode, userId | docsDir)` so cross-user requests on the same
239
+ * function instance don't collide. Invalidated whenever a write to that
240
+ * namespace publishes through the invalidation hub (which fires from
241
+ * SupabaseStorage.write / .delete regardless of whether the write
242
+ * originated from MCP, web, or any other surface).
243
+ *
244
+ * TTL bounded so stale reads from silent external mutations (anything
245
+ * bypassing the storage layer) are visible within one minute.
246
+ */
247
+ interface IndexMemoEntry {
248
+ promise: Promise<DocIndex>;
249
+ expiresAt: number;
250
+ }
251
+ const indexMemoByKey = new Map<string, IndexMemoEntry>();
252
+ const INDEX_MEMO_TTL_MS = 60_000;
253
+
254
+ const indexStats = {
255
+ hits: 0,
256
+ misses: 0,
257
+ invalidations: 0,
258
+ };
259
+
260
+ function indexMemoKey(ctx: ToolContext): string {
261
+ return ctx.mode === "local" ? `local:${ctx.docsDir}` : `cloud:${ctx.userId}`;
262
+ }
263
+
264
+ function invalidateIndexMemo(ctx: ToolContext): void {
265
+ if (indexMemoByKey.delete(indexMemoKey(ctx))) indexStats.invalidations++;
266
+ }
267
+
268
+ // Subscribe to namespace-level invalidation events published by
269
+ // SupabaseStorage.write / .delete. A write to namespace `X` clears the
270
+ // `cloud:X` memo so the next read sees the fresh state. Cross-namespace
271
+ // shared writes (Sim Yee → Edmund's vault) fire with Edmund's namespace,
272
+ // so Edmund's memo clears even though Sim Yee was the writer.
273
+ subscribeNamespaceInvalidate((namespace) => {
274
+ if (indexMemoByKey.delete(`cloud:${namespace}`)) indexStats.invalidations++;
275
+ });
276
+
277
+ export function getIndexMemoStats(): {
278
+ hits: number;
279
+ misses: number;
280
+ invalidations: number;
281
+ size: number;
282
+ hitRate: number;
283
+ } {
284
+ const total = indexStats.hits + indexStats.misses;
285
+ return {
286
+ hits: indexStats.hits,
287
+ misses: indexStats.misses,
288
+ invalidations: indexStats.invalidations,
289
+ size: indexMemoByKey.size,
290
+ hitRate: total === 0 ? 0 : indexStats.hits / total,
291
+ };
292
+ }
293
+
294
+ async function buildVaultIndex(ctx: ToolContext): Promise<DocIndex> {
295
+ if (ctx.mode === "local") return buildIndex(ctx.docsDir);
296
+
297
+ const ownPrefix = `${ctx.userId}/`;
298
+ const ownFiles = await ctx.storage.listWithContent(ownPrefix);
299
+ const ownWithContent = ownFiles.map((f) => ({
300
+ path: f.path.slice(ownPrefix.length),
301
+ content: f.content,
302
+ }));
303
+ // SPRINT-093: inject the 5 canonical system nodes when the user hasn't
304
+ // written a stored version. Matches the /api/index injection so MCP
305
+ // readers see the same starter set the web renderer does.
306
+ const virtuals = missingSystemNodeFiles(ownWithContent.map((f) => f.path));
307
+ const ownIndex = buildIndexFromContents([...ownWithContent, ...virtuals]);
308
+
309
+ const shared = await listSharedDocsForGrantee(ctx.userId);
310
+ if (shared.length === 0) return ownIndex;
311
+
312
+ const sharedFiles = await Promise.all(
313
+ shared.map(async (s) => ({
314
+ path: `${SHARED_PATH_PREFIX}${s.ownerId}/${s.relPath}`,
315
+ content: (await ctx.storage.read(`${s.ownerId}/${s.relPath}`)) ?? "",
316
+ }))
317
+ );
318
+ const sharedIndex = buildIndexFromContents(sharedFiles);
319
+
320
+ return {
321
+ docs: [...ownIndex.docs, ...sharedIndex.docs],
322
+ edges: [...ownIndex.edges, ...sharedIndex.edges],
323
+ entry: ownIndex.entry,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Builds the index of the user's own vault, then appends docs shared with
329
+ * them. Both sub-indexes are sourced via storage.listWithContent / the
330
+ * cache so the bulk read is one round-trip. Edges from the two
331
+ * sub-indexes don't cross-link — wiki-link resolution stays within each
332
+ * owner's namespace, so the grantee can navigate shared docs by title
333
+ * without leaking back to their own vault.
334
+ *
335
+ * Memoised at module scope keyed by `(mode, userId)`: see `indexMemoByKey` above.
336
+ */
337
+ export async function loadVaultIndex(ctx: ToolContext): Promise<DocIndex> {
338
+ const key = indexMemoKey(ctx);
339
+ const hit = indexMemoByKey.get(key);
340
+ if (hit && hit.expiresAt > Date.now()) {
341
+ indexStats.hits++;
342
+ return hit.promise;
343
+ }
344
+ const promise = buildVaultIndex(ctx).catch((err) => {
345
+ // Don't cache failures — a transient Supabase blip shouldn't poison
346
+ // future calls.
347
+ indexMemoByKey.delete(key);
348
+ throw err;
349
+ });
350
+ indexMemoByKey.set(key, { promise, expiresAt: Date.now() + INDEX_MEMO_TTL_MS });
351
+ indexStats.misses++;
352
+ return promise;
353
+ }
354
+
355
+ export async function readVaultFile(ctx: ToolContext, rel: string): Promise<string | null> {
356
+ if (ctx.mode === "local") {
357
+ try {
358
+ return await readFile(localSafePath(ctx.docsDir, rel), "utf8");
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+ const shared = parseSharedPath(rel);
364
+ if (shared) {
365
+ const allowed = await hasShareAccess(ctx.userId, shared.ownerId, shared.relPath);
366
+ if (!allowed) return null;
367
+ return ctx.storage.read(`${shared.ownerId}/${shared.relPath}`);
368
+ }
369
+ return ctx.storage.read(`${ctx.userId}/${rel}`);
370
+ }
371
+
372
+ export async function writeVaultFile(ctx: ToolContext, rel: string, content: string): Promise<void> {
373
+ const shared = parseSharedPath(rel);
374
+ if (shared) {
375
+ if (ctx.mode === "local") {
376
+ throw new Error("shared paths not available in local mode");
377
+ }
378
+ const allowed = await isWritableSharedPath(ctx.userId, shared.ownerId, shared.relPath);
379
+ if (!allowed) {
380
+ throw new Error("shared docs are read-only for you — ask the owner to grant write access or make the edit");
381
+ }
382
+ try {
383
+ await ctx.storage.write(`${shared.ownerId}/${shared.relPath}`, content);
384
+ } finally {
385
+ invalidateIndexMemo(ctx);
386
+ }
387
+ // SPRINT-032: extend share rows to the new path so the writer (and
388
+ // other grantees of the same share root) actually see it.
389
+ // Best-effort — a propagation failure is logged but doesn't fail
390
+ // the underlying write (the doc was created successfully).
391
+ try {
392
+ await propagateShareToNewPath(shared.ownerId, shared.relPath, ctx.userId);
393
+ } catch (e) {
394
+ console.error(
395
+ `share propagation failed for ${shared.ownerId}/${shared.relPath}:`,
396
+ e,
397
+ );
398
+ }
399
+ return;
400
+ }
401
+ try {
402
+ if (ctx.mode === "local") {
403
+ const file = localSafePath(ctx.docsDir, rel);
404
+ await mkdir(path.dirname(file), { recursive: true });
405
+ await writeFile(file, content, "utf8");
406
+ return;
407
+ }
408
+ await ctx.storage.write(`${ctx.userId}/${rel}`, content);
409
+ } finally {
410
+ // Bust the per-request memo whether the write succeeded or threw —
411
+ // a partial write may still have committed bucket content, and
412
+ // either way the cached index is stale.
413
+ invalidateIndexMemo(ctx);
414
+ }
415
+ }
416
+
417
+ export async function deleteVaultFile(ctx: ToolContext, rel: string): Promise<void> {
418
+ if (rel.startsWith(SHARED_PATH_PREFIX)) {
419
+ throw new Error("shared docs are read-only — ask the owner to delete");
420
+ }
421
+ try {
422
+ if (ctx.mode === "local") {
423
+ await rm(localSafePath(ctx.docsDir, rel), { force: true });
424
+ return;
425
+ }
426
+ await ctx.storage.delete(`${ctx.userId}/${rel}`);
427
+ } finally {
428
+ invalidateIndexMemo(ctx);
429
+ }
430
+ }
@@ -0,0 +1,81 @@
1
+ import { validatePath, writeVaultFile, loadVaultIndex } from "./vault";
2
+ import { lintDocContent } from "./lint";
3
+ import { evaluateLintGate } from "./lint_gate";
4
+ import { buildLintVaultContext } from "./lint_doc";
5
+ import { isUppercaseFilename, normalizeFilenameInPath } from "./filename";
6
+ import type { ToolContext } from "./types";
7
+ import { validateArgs } from "./validate_args";
8
+
9
+ const ARG_SPEC = {
10
+ allowed: ["path", "content", "gate_on_warnings"],
11
+ required: ["path", "content"],
12
+ } as const;
13
+
14
+ function json(value: unknown) {
15
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
16
+ }
17
+
18
+ // Codes whose detection needs vault context (cross-doc lookups). Pulling
19
+ // the index is expensive on the hot path, so we only do it when the
20
+ // caller explicitly gated on one of these. Keep in sync with lint.ts.
21
+ const CROSS_DOC_CODES = new Set([
22
+ "asymmetric_parent_edge",
23
+ "asymmetric_child_edge",
24
+ "sibling_assoc_redundant",
25
+ ]);
26
+
27
+ function parseGateCodes(raw: unknown): string[] {
28
+ if (!Array.isArray(raw)) return [];
29
+ return raw.filter((c): c is string => typeof c === "string");
30
+ }
31
+
32
+ /**
33
+ * Create or overwrite a doc. Returns an envelope including any lint warnings
34
+ * surfaced from the just-written content (missing preamble, undeclared
35
+ * inline mentions). Warnings are signal, not gate — the write always
36
+ * succeeds; the caller decides whether to act on them.
37
+ *
38
+ * Opt-in hard gate via `gate_on_warnings: string[]` — when non-empty,
39
+ * the proposed content is linted BEFORE the write; if any warning whose
40
+ * code is in the list fires, the write is skipped and the response is
41
+ * `{ error: "lint_gate_failed", fixes, original_warnings }`. Default
42
+ * `[]` preserves the legacy signal-not-gate behaviour.
43
+ */
44
+ export async function writeDoc(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
45
+ const argErr = validateArgs(args, ARG_SPEC);
46
+ if (argErr) return json(argErr);
47
+ const rel = String(args.path);
48
+ validatePath(rel);
49
+
50
+ // SPRINT-055 (SIG-004): refuse non-uppercase filenames at the entry point.
51
+ // Cheaper than letting them in and lint-warning later — keeps the on-disk
52
+ // namespace homogeneous. Auto-fix via `normalizeFilenameInPath` is offered
53
+ // in the error envelope so the caller can re-run without recomputing.
54
+ if (!isUppercaseFilename(rel)) {
55
+ return json({
56
+ error: "filename_not_uppercase",
57
+ path: rel,
58
+ suggested: normalizeFilenameInPath(rel),
59
+ hint: "EMDEE filenames are all-caps ASCII (CLAUDE.md, SPRINT-029.md). Re-run write_doc with `path: <suggested>`.",
60
+ });
61
+ }
62
+
63
+ const content = String(args.content ?? "");
64
+ const gateCodes = parseGateCodes(args.gate_on_warnings);
65
+
66
+ if (gateCodes.length > 0) {
67
+ const needsVault = gateCodes.some((c) => CROSS_DOC_CODES.has(c));
68
+ const vaultCtx = needsVault ? buildLintVaultContext(await loadVaultIndex(ctx), rel) : undefined;
69
+ const gate = evaluateLintGate(content, gateCodes, vaultCtx);
70
+ if (!gate.ok) {
71
+ return json({ error: "lint_gate_failed", fixes: gate.fixes, original_warnings: gate.original_warnings });
72
+ }
73
+ }
74
+
75
+ await writeVaultFile(ctx, rel, content);
76
+
77
+ const lint = lintDocContent(content);
78
+ const payload: Record<string, unknown> = { ok: true, path: rel, message: `wrote ${rel}` };
79
+ if (lint.warnings.length > 0) payload.warnings = lint.warnings;
80
+ return json(payload);
81
+ }
@@ -0,0 +1,59 @@
1
+ import { validatePath, readVaultFile } from "./vault";
2
+ import type { ToolContext } from "./types";
3
+ import { validateArgs } from "./validate_args";
4
+
5
+ const ARG_SPEC = {
6
+ allowed: ["path", "content"],
7
+ required: ["path", "content"],
8
+ } as const;
9
+
10
+ function json(value: unknown) {
11
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
12
+ }
13
+
14
+ interface SectionLoc { heading: string; headingLineIdx: number; bodyStartLineIdx: number; bodyEndLineIdx: number; }
15
+ const FENCE_RE = /^\s*(?:```|~~~)/;
16
+ const H2_RE = /^##\s+(.+?)\s*$/;
17
+
18
+ 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
+ function simpleDiff(before: string, after: string): string {
34
+ const bl = before.split("\n"), al = after.split("\n");
35
+ let cp = 0, cs = 0;
36
+ while (cp < bl.length && cp < al.length && bl[cp] === al[cp]) cp++;
37
+ while (cs < bl.length - cp && cs < al.length - cp && bl[bl.length - 1 - cs] === al[al.length - 1 - cs]) cs++;
38
+ const out = [`--- before (${bl.length} lines)`, `+++ after (${al.length} lines)`];
39
+ if (cp > 0) out.push(` … ${cp} unchanged …`);
40
+ for (let i = cp; i < bl.length - cs; i++) out.push(`- ${bl[i]}`);
41
+ for (let i = cp; i < al.length - cs; i++) out.push(`+ ${al[i]}`);
42
+ if (cs > 0) out.push(` … ${cs} unchanged …`);
43
+ return out.join("\n");
44
+ }
45
+
46
+ export async function writeDocPreview(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
47
+ const argErr = validateArgs(args, ARG_SPEC);
48
+ if (argErr) return json(argErr);
49
+ const rel = String(args.path);
50
+ validatePath(rel);
51
+ const newContent = String(args.content ?? "");
52
+ const before = await readVaultFile(ctx, rel);
53
+ if (before === null) return json({ action: "create", path: rel, new_size_lines: newContent.split("\n").length });
54
+ if (before === newContent) return json({ action: "no_change", path: rel });
55
+ const beforeSections = parseSections(before).map((s) => s.heading);
56
+ const afterSections = parseSections(newContent).map((s) => s.heading);
57
+ const removed = beforeSections.filter((h) => !afterSections.includes(h));
58
+ return json({ action: "replace", path: rel, sections_removed: removed, sections_before: beforeSections, sections_after: afterSections, diff: simpleDiff(before, newContent) });
59
+ }