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