@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,296 @@
|
|
|
1
|
+
import { clerkClient } from "@clerk/nextjs/server";
|
|
2
|
+
import { adminClient, hashToken } from "./admin";
|
|
3
|
+
|
|
4
|
+
const TOKEN_TTL_DAYS = 30;
|
|
5
|
+
const CODE_TTL_MINUTES = 10;
|
|
6
|
+
|
|
7
|
+
export async function registerClient(clientName: string | null, redirectUris: string[]): Promise<string> {
|
|
8
|
+
const { data, error } = await adminClient()
|
|
9
|
+
.from("oauth_clients")
|
|
10
|
+
.insert({ client_name: clientName, redirect_uris: redirectUris })
|
|
11
|
+
.select("client_id")
|
|
12
|
+
.single();
|
|
13
|
+
if (error || !data) throw new Error(error?.message ?? "failed to register client");
|
|
14
|
+
return data.client_id;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getClient(clientId: string): Promise<{ client_id: string; redirect_uris: string[] } | null> {
|
|
18
|
+
const { data } = await adminClient()
|
|
19
|
+
.from("oauth_clients")
|
|
20
|
+
.select("client_id, redirect_uris")
|
|
21
|
+
.eq("client_id", clientId)
|
|
22
|
+
.maybeSingle();
|
|
23
|
+
return data ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function generateCode(): string {
|
|
27
|
+
const bytes = new Uint8Array(32);
|
|
28
|
+
crypto.getRandomValues(bytes);
|
|
29
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function fetchClerkEmail(clerkId: string): Promise<string | null> {
|
|
33
|
+
try {
|
|
34
|
+
const client = await clerkClient();
|
|
35
|
+
const user = await client.users.getUser(clerkId);
|
|
36
|
+
const primary = user.emailAddresses.find((e) => e.id === user.primaryEmailAddressId);
|
|
37
|
+
return primary?.emailAddress ?? user.emailAddresses[0]?.emailAddress ?? null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* When a profile gets an email for the first time, look up any pending
|
|
45
|
+
* share_invitations addressed to that email and convert them into doc_shares
|
|
46
|
+
* (then mark the invitations accepted). This is what makes "invite by email
|
|
47
|
+
* before signup" actually deliver access once the invitee joins.
|
|
48
|
+
*/
|
|
49
|
+
async function claimPendingInvitations(clerkId: string, email: string): Promise<void> {
|
|
50
|
+
const admin = adminClient();
|
|
51
|
+
const { data: invites } = await admin
|
|
52
|
+
.from("share_invitations")
|
|
53
|
+
.select("id, inviter_id, path_prefix, permission, share_root")
|
|
54
|
+
.eq("status", "pending")
|
|
55
|
+
.ilike("invitee_email", email);
|
|
56
|
+
if (!invites || invites.length === 0) return;
|
|
57
|
+
|
|
58
|
+
const rows = invites.map((inv) => ({
|
|
59
|
+
owner_id: inv.inviter_id,
|
|
60
|
+
grantee_id: clerkId,
|
|
61
|
+
path_prefix: inv.path_prefix,
|
|
62
|
+
permission: inv.permission,
|
|
63
|
+
share_root: inv.share_root,
|
|
64
|
+
}));
|
|
65
|
+
await admin.from("doc_shares").upsert(rows, {
|
|
66
|
+
onConflict: "owner_id,path_prefix,grantee_id",
|
|
67
|
+
ignoreDuplicates: true,
|
|
68
|
+
});
|
|
69
|
+
await admin
|
|
70
|
+
.from("share_invitations")
|
|
71
|
+
.update({ status: "accepted", accepted_at: new Date().toISOString() })
|
|
72
|
+
.in("id", invites.map((i) => i.id));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Recursively moves all Storage files under oldPrefix/ to newPrefix/ using
|
|
77
|
+
* the server-side move API (no download/upload — metadata rename only).
|
|
78
|
+
*/
|
|
79
|
+
async function moveStoragePrefix(oldPrefix: string, newPrefix: string, sub = ""): Promise<void> {
|
|
80
|
+
const folder = sub ? `${oldPrefix}/${sub}` : oldPrefix;
|
|
81
|
+
const { data: items } = await adminClient().storage.from("vaults").list(folder, { limit: 1000 });
|
|
82
|
+
if (!items) return;
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
const oldPath = sub ? `${oldPrefix}/${sub}/${item.name}` : `${oldPrefix}/${item.name}`;
|
|
85
|
+
const newPath = sub ? `${newPrefix}/${sub}/${item.name}` : `${newPrefix}/${item.name}`;
|
|
86
|
+
if (!item.id) {
|
|
87
|
+
// folder — recurse
|
|
88
|
+
await moveStoragePrefix(oldPrefix, newPrefix, sub ? `${sub}/${item.name}` : item.name);
|
|
89
|
+
} else {
|
|
90
|
+
const { error } = await adminClient().storage.from("vaults").move(oldPath, newPath);
|
|
91
|
+
if (error) console.warn(`storage move failed ${oldPath}: ${error.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* When a new Clerk ID signs in with an email that already has a profile row
|
|
98
|
+
* (from a prior Clerk instance), remap every table that uses clerk_id as a
|
|
99
|
+
* key so all existing vault data appears under the new ID.
|
|
100
|
+
*
|
|
101
|
+
* Handles: profiles (PK), doc_shares, oauth_tokens, sync_manifest, oauth_codes,
|
|
102
|
+
* share_invitations, publications, subscriptions, publication_events (FK tables),
|
|
103
|
+
* plus doc_edges, vault_files, mcp_activity (plain-text namespace fields),
|
|
104
|
+
* plus the Storage vaults bucket folder (server-side move, no data transfer).
|
|
105
|
+
*/
|
|
106
|
+
async function migrateClerkInstance(devId: string, prodId: string): Promise<void> {
|
|
107
|
+
const admin = adminClient();
|
|
108
|
+
|
|
109
|
+
// Remove any rows auto-created for the new ID so inserts below don't conflict.
|
|
110
|
+
await admin.from("vault_files").delete().eq("namespace", prodId);
|
|
111
|
+
await admin.from("doc_edges").delete().eq("namespace", prodId);
|
|
112
|
+
await admin.from("mcp_activity").delete().eq("clerk_id", prodId);
|
|
113
|
+
// Cascade-deletes FK children (doc_shares, sync_manifest, oauth_*, publications, subscriptions).
|
|
114
|
+
await admin.from("profiles").delete().eq("clerk_id", prodId);
|
|
115
|
+
|
|
116
|
+
// Insert prod profile, preserving vault_id + email + created_at from dev row.
|
|
117
|
+
const { data: devProfile } = await admin
|
|
118
|
+
.from("profiles")
|
|
119
|
+
.select("vault_id, email, created_at")
|
|
120
|
+
.eq("clerk_id", devId)
|
|
121
|
+
.single();
|
|
122
|
+
if (!devProfile) return;
|
|
123
|
+
await admin.from("profiles").insert({
|
|
124
|
+
clerk_id: prodId,
|
|
125
|
+
vault_id: devProfile.vault_id,
|
|
126
|
+
email: devProfile.email,
|
|
127
|
+
created_at: devProfile.created_at,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Update FK tables (prod profile now exists, so FK checks pass).
|
|
131
|
+
await admin.from("doc_shares").update({ owner_id: prodId }).eq("owner_id", devId);
|
|
132
|
+
await admin.from("doc_shares").update({ grantee_id: prodId }).eq("grantee_id", devId);
|
|
133
|
+
await admin.from("oauth_tokens").update({ clerk_id: prodId }).eq("clerk_id", devId);
|
|
134
|
+
await admin.from("sync_manifest").update({ clerk_id: prodId }).eq("clerk_id", devId);
|
|
135
|
+
await admin.from("oauth_codes").update({ clerk_id: prodId }).eq("clerk_id", devId);
|
|
136
|
+
await admin.from("share_invitations").update({ inviter_id: prodId }).eq("inviter_id", devId);
|
|
137
|
+
await admin.from("publications").update({ owner_id: prodId }).eq("owner_id", devId);
|
|
138
|
+
await admin.from("subscriptions").update({ subscriber_id: prodId }).eq("subscriber_id", devId);
|
|
139
|
+
await admin.from("publication_events").update({ viewer_user_id: prodId }).eq("viewer_user_id", devId);
|
|
140
|
+
|
|
141
|
+
// Update plain-text namespace fields.
|
|
142
|
+
await admin.from("doc_edges").update({ namespace: prodId }).eq("namespace", devId);
|
|
143
|
+
await admin.from("vault_files").update({ namespace: prodId }).eq("namespace", devId);
|
|
144
|
+
await admin.from("mcp_activity")
|
|
145
|
+
.update({ namespace: prodId, clerk_id: prodId })
|
|
146
|
+
.eq("clerk_id", devId);
|
|
147
|
+
|
|
148
|
+
// user_activity_stats (clerk_id PK — delete prod row first, then remap dev row)
|
|
149
|
+
await admin.from("user_activity_stats").delete().eq("clerk_id", prodId);
|
|
150
|
+
await admin.from("user_activity_stats").update({ clerk_id: prodId }).eq("clerk_id", devId);
|
|
151
|
+
|
|
152
|
+
// Remove the now-orphaned dev profile.
|
|
153
|
+
await admin.from("profiles").delete().eq("clerk_id", devId);
|
|
154
|
+
|
|
155
|
+
// Rename the Storage folder — server-side move, no download/upload.
|
|
156
|
+
await moveStoragePrefix(devId, prodId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Ensure a profiles row exists for this clerk_id so FK-bearing inserts succeed.
|
|
161
|
+
* Also backfills email from Clerk if the existing row has none — needed for
|
|
162
|
+
* email-based sharing lookups — and claims any pending share invitations
|
|
163
|
+
* addressed to that email.
|
|
164
|
+
*
|
|
165
|
+
* If the Clerk email matches an existing profile with a different clerk_id, this
|
|
166
|
+
* is a Clerk-instance migration (dev → prod). We remap all data to the new ID
|
|
167
|
+
* automatically instead of creating a new empty profile.
|
|
168
|
+
*/
|
|
169
|
+
export async function ensureProfile(clerkId: string): Promise<void> {
|
|
170
|
+
const admin = adminClient();
|
|
171
|
+
const { data: existing } = await admin
|
|
172
|
+
.from("profiles")
|
|
173
|
+
.select("clerk_id, email")
|
|
174
|
+
.eq("clerk_id", clerkId)
|
|
175
|
+
.maybeSingle();
|
|
176
|
+
|
|
177
|
+
if (existing?.email) return;
|
|
178
|
+
|
|
179
|
+
const email = await fetchClerkEmail(clerkId);
|
|
180
|
+
|
|
181
|
+
if (email) {
|
|
182
|
+
const { data: priorProfile } = await admin
|
|
183
|
+
.from("profiles")
|
|
184
|
+
.select("clerk_id")
|
|
185
|
+
.eq("email", email)
|
|
186
|
+
.neq("clerk_id", clerkId)
|
|
187
|
+
.maybeSingle();
|
|
188
|
+
|
|
189
|
+
if (priorProfile) {
|
|
190
|
+
await migrateClerkInstance(priorProfile.clerk_id, clerkId);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const row: { clerk_id: string; email?: string } = { clerk_id: clerkId };
|
|
196
|
+
if (email) row.email = email;
|
|
197
|
+
|
|
198
|
+
const { error } = await admin
|
|
199
|
+
.from("profiles")
|
|
200
|
+
.upsert(row, { onConflict: "clerk_id" });
|
|
201
|
+
if (error) throw new Error(`failed to ensure profile: ${error.message}`);
|
|
202
|
+
|
|
203
|
+
if (email) await claimPendingInvitations(clerkId, email);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function storeAuthCode(params: {
|
|
207
|
+
clientId: string;
|
|
208
|
+
clerkId: string;
|
|
209
|
+
redirectUri: string;
|
|
210
|
+
codeChallenge: string;
|
|
211
|
+
codeChallengeMethod: string;
|
|
212
|
+
scope: string;
|
|
213
|
+
}): Promise<string> {
|
|
214
|
+
await ensureProfile(params.clerkId);
|
|
215
|
+
const code = generateCode();
|
|
216
|
+
const expiresAt = new Date(Date.now() + CODE_TTL_MINUTES * 60 * 1000).toISOString();
|
|
217
|
+
const { error } = await adminClient().from("oauth_codes").insert({
|
|
218
|
+
code,
|
|
219
|
+
client_id: params.clientId,
|
|
220
|
+
clerk_id: params.clerkId,
|
|
221
|
+
redirect_uri: params.redirectUri,
|
|
222
|
+
code_challenge: params.codeChallenge,
|
|
223
|
+
code_challenge_method: params.codeChallengeMethod,
|
|
224
|
+
scope: params.scope,
|
|
225
|
+
expires_at: expiresAt,
|
|
226
|
+
});
|
|
227
|
+
if (error) throw new Error(error.message);
|
|
228
|
+
return code;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function verifyPkce(codeChallenge: string, codeVerifier: string): Promise<boolean> {
|
|
232
|
+
const encoded = new TextEncoder().encode(codeVerifier);
|
|
233
|
+
const digest = await crypto.subtle.digest("SHA-256", encoded);
|
|
234
|
+
const base64url = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
235
|
+
.replace(/\+/g, "-")
|
|
236
|
+
.replace(/\//g, "_")
|
|
237
|
+
.replace(/=+$/, "");
|
|
238
|
+
return base64url === codeChallenge;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function exchangeCode(params: {
|
|
242
|
+
code: string;
|
|
243
|
+
clientId: string;
|
|
244
|
+
redirectUri: string;
|
|
245
|
+
codeVerifier: string;
|
|
246
|
+
}): Promise<string | null> {
|
|
247
|
+
const supabase = adminClient();
|
|
248
|
+
const { data: row } = await supabase
|
|
249
|
+
.from("oauth_codes")
|
|
250
|
+
.select("*")
|
|
251
|
+
.eq("code", params.code)
|
|
252
|
+
.eq("client_id", params.clientId)
|
|
253
|
+
.maybeSingle();
|
|
254
|
+
|
|
255
|
+
if (!row) return null;
|
|
256
|
+
if (row.used) return null;
|
|
257
|
+
if (new Date(row.expires_at) < new Date()) return null;
|
|
258
|
+
if (row.redirect_uri !== params.redirectUri) return null;
|
|
259
|
+
if (!(await verifyPkce(row.code_challenge, params.codeVerifier))) return null;
|
|
260
|
+
|
|
261
|
+
// Mark code as used (single-use)
|
|
262
|
+
await supabase.from("oauth_codes").update({ used: true }).eq("code", params.code);
|
|
263
|
+
|
|
264
|
+
// Issue access token
|
|
265
|
+
const tokenBytes = new Uint8Array(32);
|
|
266
|
+
crypto.getRandomValues(tokenBytes);
|
|
267
|
+
const token = Array.from(tokenBytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
268
|
+
const hash = await hashToken(token);
|
|
269
|
+
const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
|
270
|
+
|
|
271
|
+
const { error } = await supabase.from("oauth_tokens").insert({
|
|
272
|
+
token_hash: hash,
|
|
273
|
+
client_id: params.clientId,
|
|
274
|
+
clerk_id: row.clerk_id,
|
|
275
|
+
scope: row.scope,
|
|
276
|
+
expires_at: expiresAt,
|
|
277
|
+
});
|
|
278
|
+
if (error) throw new Error(error.message);
|
|
279
|
+
return token;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Resolve an OAuth bearer token to a clerk_id. Returns null if invalid/expired. */
|
|
283
|
+
export async function clerkIdFromOAuthToken(req: Request): Promise<string | null> {
|
|
284
|
+
const authHeader = req.headers.get("authorization") ?? "";
|
|
285
|
+
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
|
286
|
+
if (!token) return null;
|
|
287
|
+
const hash = await hashToken(token);
|
|
288
|
+
const { data } = await adminClient()
|
|
289
|
+
.from("oauth_tokens")
|
|
290
|
+
.select("clerk_id, expires_at")
|
|
291
|
+
.eq("token_hash", hash)
|
|
292
|
+
.maybeSingle();
|
|
293
|
+
if (!data) return null;
|
|
294
|
+
if (new Date(data.expires_at) < new Date()) return null;
|
|
295
|
+
return data.clerk_id;
|
|
296
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createServerClient } from "@supabase/ssr";
|
|
2
|
+
import { cookies } from "next/headers";
|
|
3
|
+
|
|
4
|
+
export async function createClient() {
|
|
5
|
+
const cookieStore = await cookies();
|
|
6
|
+
return createServerClient(
|
|
7
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
8
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
9
|
+
{
|
|
10
|
+
cookies: {
|
|
11
|
+
getAll: () => cookieStore.getAll(),
|
|
12
|
+
setAll: (cookiesToSet) => {
|
|
13
|
+
try {
|
|
14
|
+
cookiesToSet.forEach(({ name, value, options }) =>
|
|
15
|
+
cookieStore.set(name, value, options)
|
|
16
|
+
);
|
|
17
|
+
} catch {}
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The 5 canonical OS-layer nodes that exist in every vault by default.
|
|
2
|
+
// These are never stored per-user — they're injected as virtual docs at
|
|
3
|
+
// index-build time so they always appear even for users who've never written
|
|
4
|
+
// to them. Updating content here takes effect for all users immediately.
|
|
5
|
+
// If a user has customised a node (written it via MCP), their stored version
|
|
6
|
+
// takes precedence because the injection is skipped when the path is present
|
|
7
|
+
// in the listed files.
|
|
8
|
+
|
|
9
|
+
export interface SystemNode {
|
|
10
|
+
path: string;
|
|
11
|
+
title: string;
|
|
12
|
+
summary: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SYSTEM_NODES: readonly SystemNode[] = [
|
|
16
|
+
{
|
|
17
|
+
path: "EMDEE.md",
|
|
18
|
+
title: "EMDEE",
|
|
19
|
+
summary: "Your knowledge graph, in markdown — the root of your vault.",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
path: "VAULT.md",
|
|
23
|
+
title: "VAULT",
|
|
24
|
+
summary: "Your private notes, projects, and knowledge.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
path: "SHARED.md",
|
|
28
|
+
title: "SHARED",
|
|
29
|
+
summary: "Content shared with you by others.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
path: "GRAVEYARD.md",
|
|
33
|
+
title: "GRAVEYARD",
|
|
34
|
+
summary: "Archived and retired documents.",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
path: "IMAGES.md",
|
|
38
|
+
title: "IMAGES",
|
|
39
|
+
summary: "Images and visual assets.",
|
|
40
|
+
},
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
export const SYSTEM_NODE_PATHS = new Set(SYSTEM_NODES.map((n) => n.path));
|
|
44
|
+
|
|
45
|
+
export function systemNodeContent(node: SystemNode): string {
|
|
46
|
+
// EMDEE is the root — no parent. All other system nodes are children of EMDEE.
|
|
47
|
+
const childOf = node.path !== "EMDEE.md" ? "\n## Child of\n\n* [[EMDEE]]\n" : "";
|
|
48
|
+
return `# ${node.title}\n\n> ${node.summary}\n${childOf}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return `{path, content}` entries for any system node NOT already present
|
|
53
|
+
* in `existingPaths`. Cloud + local index builders append these before the
|
|
54
|
+
* indexer runs so wiki-link resolution + doc listing sees the canonical
|
|
55
|
+
* 5-node OS layer without ever writing them to disk.
|
|
56
|
+
*/
|
|
57
|
+
export function missingSystemNodeFiles(
|
|
58
|
+
existingPaths: Iterable<string>,
|
|
59
|
+
): { path: string; content: string }[] {
|
|
60
|
+
const present = new Set(existingPaths);
|
|
61
|
+
const out: { path: string; content: string }[] = [];
|
|
62
|
+
for (const node of SYSTEM_NODES) {
|
|
63
|
+
if (!present.has(node.path)) {
|
|
64
|
+
out.push({ path: node.path, content: systemNodeContent(node) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPRINT-057 (SIG-008): trashed-state sidecar.
|
|
2
|
+
//
|
|
3
|
+
// Trash is a per-doc lifecycle state, NOT a reparent into `graveyard/`.
|
|
4
|
+
// Persisted out-of-band so the doc's markdown stays untouched and its
|
|
5
|
+
// graph edges remain intact for lossless restore.
|
|
6
|
+
//
|
|
7
|
+
// Storage location:
|
|
8
|
+
// - Local mode: `<docsDir>/.emdee/trashed.json`
|
|
9
|
+
// - Cloud mode: `<userId>/.emdee/trashed.json` in the `vaults` Supabase
|
|
10
|
+
// bucket. Doesn't appear in /api/index (the list filter only picks
|
|
11
|
+
// up `.md` files) so the sidecar is invisible to the renderer.
|
|
12
|
+
//
|
|
13
|
+
// Shape: `{ [docPath]: { original_parent_path, trashed_at } }`. Keyed
|
|
14
|
+
// by the doc's vault path (without namespace prefix); the value records
|
|
15
|
+
// where to restore the doc to + when it was trashed.
|
|
16
|
+
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { adminClient } from "../supabase/admin";
|
|
20
|
+
import type { ToolContext } from "../mcp/tools/types";
|
|
21
|
+
|
|
22
|
+
const VAULT_BUCKET = "vaults";
|
|
23
|
+
const SIDECAR_PATH = ".emdee/trashed.json";
|
|
24
|
+
|
|
25
|
+
export interface TrashedEntry {
|
|
26
|
+
original_parent_path: string;
|
|
27
|
+
trashed_at: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type TrashedState = Record<string, TrashedEntry>;
|
|
31
|
+
|
|
32
|
+
function localSidecarPath(docsDir: string): string {
|
|
33
|
+
return path.join(docsDir, SIDECAR_PATH);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cloudSidecarKey(userId: string): string {
|
|
37
|
+
return `${userId}/${SIDECAR_PATH}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function readTrashedState(ctx: ToolContext): Promise<TrashedState> {
|
|
41
|
+
if (ctx.mode === "local") {
|
|
42
|
+
try {
|
|
43
|
+
const raw = await fs.readFile(localSidecarPath(ctx.docsDir), "utf8");
|
|
44
|
+
return JSON.parse(raw) as TrashedState;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const { data, error } = await adminClient()
|
|
51
|
+
.storage.from(VAULT_BUCKET)
|
|
52
|
+
.download(cloudSidecarKey(ctx.userId));
|
|
53
|
+
if (error) {
|
|
54
|
+
// Not-found errors mean no trash file yet — treat as empty.
|
|
55
|
+
const msg = error.message?.toLowerCase() ?? "";
|
|
56
|
+
if (msg.includes("not found") || msg.includes("does not exist")) return {};
|
|
57
|
+
throw new Error(`trash state download failed: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
const text = await data.text();
|
|
60
|
+
return JSON.parse(text) as TrashedState;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function writeTrashedState(
|
|
64
|
+
ctx: ToolContext,
|
|
65
|
+
state: TrashedState,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const body = JSON.stringify(state, null, 2);
|
|
68
|
+
if (ctx.mode === "local") {
|
|
69
|
+
const filePath = localSidecarPath(ctx.docsDir);
|
|
70
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
71
|
+
await fs.writeFile(filePath, body, "utf8");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const { error } = await adminClient()
|
|
75
|
+
.storage.from(VAULT_BUCKET)
|
|
76
|
+
.upload(cloudSidecarKey(ctx.userId), body, {
|
|
77
|
+
contentType: "application/json",
|
|
78
|
+
upsert: true,
|
|
79
|
+
});
|
|
80
|
+
if (error) throw new Error(`trash state upload failed: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Convenience: the set of trashed paths in this vault. Used by /api/index
|
|
84
|
+
* to filter out trashed docs from the renderer view. */
|
|
85
|
+
export async function listTrashedPaths(ctx: ToolContext): Promise<Set<string>> {
|
|
86
|
+
const state = await readTrashedState(ctx);
|
|
87
|
+
return new Set(Object.keys(state));
|
|
88
|
+
}
|