@promptowl/contextnest-community 1.7.0 → 1.9.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/dist/index.js CHANGED
@@ -1,22 +1,35 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- generateApiKey,
4
- getKeyPrefix,
5
- hashApiKey,
6
- hashPassword,
7
- parseBearerToken,
8
- verifyPassword
9
- } from "./chunk-XRK6SQSC.js";
10
2
  import {
11
3
  approve,
12
4
  cancelReview,
13
5
  getPendingReview,
14
6
  getReviewHistory,
15
7
  getReviewQueue,
8
+ notifyNestEvent,
16
9
  reject,
17
10
  safePublishDocument,
18
11
  submitForReview
19
- } from "./chunk-MOXICJPD.js";
12
+ } from "./chunk-VC3QNHHB.js";
13
+ import {
14
+ createGrant,
15
+ deleteGrant,
16
+ grantCoversNode,
17
+ hasAnyGrant,
18
+ listGrants,
19
+ listUserGrants,
20
+ resolveNodeGrant
21
+ } from "./chunk-TIO5XOFD.js";
22
+ import {
23
+ generateApiKey,
24
+ getKeyPrefix,
25
+ hashApiKey,
26
+ hashPassword,
27
+ parseBearerToken,
28
+ verifyPassword
29
+ } from "./chunk-XRK6SQSC.js";
30
+ import {
31
+ normalizeEmail
32
+ } from "./chunk-FRQJWGN3.js";
20
33
  import {
21
34
  checkConflict,
22
35
  createVersion,
@@ -25,14 +38,8 @@ import {
25
38
  getDisplayStatus,
26
39
  getVersions,
27
40
  setApprovedVersion
28
- } from "./chunk-VD5QX2ZQ.js";
41
+ } from "./chunk-HRQWNRVI.js";
29
42
  import {
30
- AppError,
31
- ConflictError,
32
- ForbiddenError,
33
- LockedError,
34
- NotFoundError,
35
- ValidationError,
36
43
  canCreateInNest,
37
44
  canManageStewards,
38
45
  canUserAccess,
@@ -43,6 +50,7 @@ import {
43
50
  createStewardRecord,
44
51
  deleteNest,
45
52
  disableStewardshipAndWipeGovernance,
53
+ docLink,
46
54
  engineCache,
47
55
  getCollaboratorRole,
48
56
  getCurrentLicense,
@@ -65,6 +73,7 @@ import {
65
73
  listStewards,
66
74
  loadAccessConfig,
67
75
  nestAllowsSelfApprove,
76
+ nestName,
68
77
  nestStorageRoot,
69
78
  permissionLevel,
70
79
  removeSteward,
@@ -84,14 +93,26 @@ import {
84
93
  updateSteward,
85
94
  upsertEnvVar,
86
95
  validateLicense
87
- } from "./chunk-43DOX4LH.js";
96
+ } from "./chunk-BYS4HDME.js";
97
+ import {
98
+ AppError,
99
+ ConflictError,
100
+ ForbiddenError,
101
+ LockedError,
102
+ NotFoundError,
103
+ ValidationError
104
+ } from "./chunk-3JTODC3Y.js";
105
+ import {
106
+ insertOrIgnore,
107
+ nowExpr
108
+ } from "./chunk-XQ46F76G.js";
88
109
  import {
89
110
  config,
90
111
  getDb,
91
112
  initDb,
92
- insertOrIgnore,
93
- nowExpr
94
- } from "./chunk-QMLAXQES.js";
113
+ isEmailListish,
114
+ isEmailish
115
+ } from "./chunk-DPHV6Q26.js";
95
116
  import {
96
117
  ANON_EMAIL,
97
118
  ANON_USER_ID
@@ -101,7 +122,8 @@ import {
101
122
  import { serve } from "@hono/node-server";
102
123
 
103
124
  // src/app.ts
104
- import { Hono as Hono9 } from "hono";
125
+ import { Hono as Hono16 } from "hono";
126
+ import { LinearRouter } from "hono/router/linear-router";
105
127
  import { createMiddleware as createMiddleware2 } from "hono/factory";
106
128
  import { cors } from "hono/cors";
107
129
 
@@ -227,11 +249,6 @@ var authMiddleware = createMiddleware(async (c, next) => {
227
249
  return c.json({ error: "Missing or invalid credentials" }, 401);
228
250
  });
229
251
 
230
- // src/shared/email.ts
231
- function normalizeEmail(email) {
232
- return email.trim().toLowerCase();
233
- }
234
-
235
252
  // src/shared/rate-limit.ts
236
253
  var buckets = /* @__PURE__ */ new Map();
237
254
  function liveBucket(key, cutoff) {
@@ -1084,7 +1101,8 @@ async function canReadNode(nestId, nodeId, userId, userEmail) {
1084
1101
  return await getApprovedVersion(nestId, nodeId) !== null;
1085
1102
  }
1086
1103
  if (!await isStewardshipEnabled(nestId)) return true;
1087
- return (await canUserAccess(nestId, nodeId, userEmail)).allowed;
1104
+ if ((await canUserAccess(nestId, nodeId, userEmail)).allowed) return true;
1105
+ return await resolveNodeGrant(nestId, userId, nodeId) !== null;
1088
1106
  }
1089
1107
  async function filterAccessible(nestId, userId, userEmail, nodes) {
1090
1108
  if (await isPublicReader(nestId, userId)) {
@@ -1097,9 +1115,10 @@ async function filterAccessible(nestId, userId, userEmail, nodes) {
1097
1115
  return filtered;
1098
1116
  }
1099
1117
  if (!await isStewardshipEnabled(nestId)) return nodes;
1118
+ const grants = await listUserGrants(nestId, userId);
1100
1119
  const accessible = [];
1101
1120
  for (const n of nodes) {
1102
- if ((await canUserAccess(nestId, n.id, userEmail)).allowed) {
1121
+ if ((await canUserAccess(nestId, n.id, userEmail)).allowed || grantCoversNode(grants, n.id)) {
1103
1122
  accessible.push(n);
1104
1123
  }
1105
1124
  }
@@ -1289,7 +1308,7 @@ async function approveExternalEdit(input) {
1289
1308
  const node = await storage.readDocument(input.documentId);
1290
1309
  const versionNum = result.versionEntry.version;
1291
1310
  const tags = node.frontmatter.tags || [];
1292
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-FJWVTZKR.js");
1311
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-6J4OWQL6.js");
1293
1312
  await createVersion2({
1294
1313
  nestId: input.nestId,
1295
1314
  nodeId: input.documentId,
@@ -1375,6 +1394,10 @@ function bodyOnly(nodeId, raw) {
1375
1394
  return raw;
1376
1395
  }
1377
1396
  }
1397
+ var RUNNABLE_NODE_TYPES = ["agent", "skill"];
1398
+ function isRunnableType(type) {
1399
+ return RUNNABLE_NODE_TYPES.includes(type || "");
1400
+ }
1378
1401
  function toNodeResponse(node) {
1379
1402
  const fm = node.frontmatter;
1380
1403
  const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
@@ -1391,14 +1414,20 @@ function toNodeResponse(node) {
1391
1414
  created_at: fm.created_at,
1392
1415
  updated_at: fm.updated_at,
1393
1416
  content: node.body || "",
1394
- pendingChange: node.pendingChange ?? void 0
1417
+ pendingChange: node.pendingChange ?? void 0,
1418
+ // Same YAML-coercion rule as title above: `schedule: 123` parses as a
1419
+ // number — normalize to string at the API boundary.
1420
+ schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
1395
1421
  };
1396
1422
  }
1397
1423
  async function listNodesForCaller(nestId, userId, filters = {}) {
1398
1424
  const { storage, versions: versionManager } = await engineCache.get(nestId);
1399
1425
  let documents = await storage.discoverDocuments();
1400
1426
  if (filters.type) {
1401
- documents = documents.filter((n) => n.frontmatter.type === filters.type);
1427
+ const wanted = new Set(
1428
+ Array.isArray(filters.type) ? filters.type : [filters.type]
1429
+ );
1430
+ documents = documents.filter((n) => wanted.has(n.frontmatter.type));
1402
1431
  }
1403
1432
  if (filters.tag) {
1404
1433
  const tag = normalizeTag2(filters.tag);
@@ -1407,7 +1436,11 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
1407
1436
  );
1408
1437
  }
1409
1438
  const userEmail = await resolveCallerEmail(userId);
1410
- const accessible = await filterAccessible(nestId, userId, userEmail, documents);
1439
+ let accessible = await filterAccessible(nestId, userId, userEmail, documents);
1440
+ if (await resolveNestPermission(nestId, userId) === "none" && !await isStewardshipEnabled(nestId)) {
1441
+ const grants = await listUserGrants(nestId, userId);
1442
+ accessible = grants.length ? documents.filter((d) => grantCoversNode(grants, d.id)) : accessible;
1443
+ }
1411
1444
  const publicReader = await isPublicReader(nestId, userId);
1412
1445
  const enriched = await Promise.all(
1413
1446
  accessible.map(async (doc) => {
@@ -1464,6 +1497,11 @@ async function createNode(nestId, input, userEmail) {
1464
1497
  }
1465
1498
  const now = (/* @__PURE__ */ new Date()).toISOString();
1466
1499
  const tags = (input.tags || []).map(normalizeTag2);
1500
+ if (input.schedule !== void 0 && !isRunnableType(input.type)) {
1501
+ throw new ValidationError(
1502
+ `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
1503
+ );
1504
+ }
1467
1505
  const hasStewards = await isStewardshipEnabled(nestId);
1468
1506
  const initialStatus = hasStewards ? "draft" : "published";
1469
1507
  const initialVersion = hasStewards ? 1 : 0;
@@ -1478,7 +1516,11 @@ async function createNode(nestId, input, userEmail) {
1478
1516
  version: initialVersion,
1479
1517
  created_at: now,
1480
1518
  updated_at: now,
1481
- metadata: { owners: ["*"], scope: input.scope || "team" }
1519
+ metadata: {
1520
+ owners: ["*"],
1521
+ scope: input.scope || "team",
1522
+ ...input.schedule ? { schedule: input.schedule } : {}
1523
+ }
1482
1524
  },
1483
1525
  body: input.content,
1484
1526
  rawContent: ""
@@ -1545,9 +1587,18 @@ async function registerImportedDocuments(nestId, userEmail) {
1545
1587
  return 0;
1546
1588
  }
1547
1589
  let registered = 0;
1548
- for (const doc of docs) {
1590
+ for (let doc of docs) {
1549
1591
  const nodeId = doc.id;
1550
1592
  if (await getCurrentVersion(nestId, nodeId) > 0) continue;
1593
+ if (!doc.frontmatter?.title) {
1594
+ const title = nodeId.split("/").pop() || nodeId;
1595
+ doc = { ...doc, frontmatter: { ...doc.frontmatter, title } };
1596
+ try {
1597
+ await storage.writeDocument(nodeId, serializeDocument(doc));
1598
+ } catch (err) {
1599
+ console.error("import: failed to persist filename title", nodeId, err);
1600
+ }
1601
+ }
1551
1602
  const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
1552
1603
  const tags = rawTags.map((t) => normalizeTag2(String(t)));
1553
1604
  const fmVersion = Number(doc.frontmatter?.version);
@@ -1605,6 +1656,17 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
1605
1656
  if (patch.title) {
1606
1657
  node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
1607
1658
  }
1659
+ if (patch.schedule !== void 0) {
1660
+ if (!isRunnableType(node.frontmatter.type)) {
1661
+ throw new ValidationError(
1662
+ `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
1663
+ );
1664
+ }
1665
+ const metadata = { ...node.frontmatter.metadata };
1666
+ if (patch.schedule === "") delete metadata.schedule;
1667
+ else metadata.schedule = patch.schedule;
1668
+ node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
1669
+ }
1608
1670
  const hasStewards = await isStewardshipEnabled(nestId);
1609
1671
  const currentTags = node.frontmatter.tags || [];
1610
1672
  if (hasStewards && await getPendingReview(nestId, nodeId)) {
@@ -1849,13 +1911,13 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
1849
1911
  }
1850
1912
  const segments = folderName.split("/").filter(Boolean);
1851
1913
  const baseName = segments[segments.length - 1] || folderName;
1852
- const nestName = await uniqueNestName(userId, baseName);
1853
- if (nestName !== baseName) {
1914
+ const nestName2 = await uniqueNestName(userId, baseName);
1915
+ if (nestName2 !== baseName) {
1854
1916
  console.log(
1855
- `[unsynced] name "${baseName}" already in use, using "${nestName}" instead`
1917
+ `[unsynced] name "${baseName}" already in use, using "${nestName2}" instead`
1856
1918
  );
1857
1919
  }
1858
- const nest = await importNest(userId, nestName, files);
1920
+ const nest = await importNest(userId, nestName2, files);
1859
1921
  console.log(
1860
1922
  `[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
1861
1923
  );
@@ -1913,7 +1975,14 @@ nestRoutes.get("/", async (c) => {
1913
1975
  const row = await db.get(ownerEmailSql, [n.user_id]);
1914
1976
  owner_email = row?.email ?? null;
1915
1977
  }
1916
- return { ...n, permission, is_owner, owner_email, roles };
1978
+ return {
1979
+ ...n,
1980
+ permission,
1981
+ is_owner,
1982
+ owner_email,
1983
+ roles,
1984
+ stewardship_enabled: await isStewardshipEnabled(n.id)
1985
+ };
1917
1986
  };
1918
1987
  const seen = /* @__PURE__ */ new Set();
1919
1988
  const out = [];
@@ -2165,12 +2234,36 @@ async function addCollaborator(params) {
2165
2234
  "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
2166
2235
  [collabId, nestId, userId, params.permission, granterId]
2167
2236
  );
2237
+ const shareNestName = await nestName(nestId);
2238
+ void notifyNestEvent(
2239
+ nestId,
2240
+ `:key: ${params.email || userId} added as *${params.permission}*${params.grantedByEmail ? ` by ${params.grantedByEmail}` : ""}`,
2241
+ {
2242
+ status: "shared",
2243
+ docTitle: shareNestName,
2244
+ path: shareNestName,
2245
+ link: docLink(nestId, void 0, params.baseUrl),
2246
+ actor: params.email || userId,
2247
+ permission: params.permission,
2248
+ by: params.grantedByEmail || void 0
2249
+ }
2250
+ );
2168
2251
  return await db.get(
2169
2252
  "SELECT * FROM nest_collaborators WHERE id = ?",
2170
2253
  [collabId]
2171
2254
  );
2172
2255
  }
2173
2256
 
2257
+ // src/shared/base-url.ts
2258
+ function requestBaseUrl(reqUrl) {
2259
+ if (config.PUBLIC_BASE_URL) return config.PUBLIC_BASE_URL;
2260
+ try {
2261
+ return new URL(reqUrl).origin;
2262
+ } catch {
2263
+ return "";
2264
+ }
2265
+ }
2266
+
2174
2267
  // src/nests/sharing-routes.ts
2175
2268
  var sharingRoutes = new Hono3();
2176
2269
  sharingRoutes.get("/collaborators", async (c) => {
@@ -2207,7 +2300,8 @@ sharingRoutes.post("/collaborators", async (c) => {
2207
2300
  grantedByUserId: c.get("userId"),
2208
2301
  // Enforce the escalation cap against the caller's own nest permission
2209
2302
  // (set by the access guard) — a write collaborator can't grant admin.
2210
- callerPermission: c.get("nestPermission")
2303
+ callerPermission: c.get("nestPermission"),
2304
+ baseUrl: requestBaseUrl(c.req.url)
2211
2305
  });
2212
2306
  return c.json({ collaborator: collab }, 201);
2213
2307
  });
@@ -2243,15 +2337,93 @@ sharingRoutes.patch("/visibility", async (c) => {
2243
2337
  throw new ValidationError("visibility must be private or public");
2244
2338
  }
2245
2339
  const db = getDb();
2246
- await db.run("UPDATE nests SET visibility = ? WHERE id = ?", [
2340
+ const nestId = c.req.param("nestId");
2341
+ const result = await db.run("UPDATE nests SET visibility = ? WHERE id = ?", [
2247
2342
  body.visibility,
2248
- c.req.param("nestId")
2343
+ nestId
2344
+ ]);
2345
+ if (result.changes === 0) {
2346
+ throw new NotFoundError("Nest not found");
2347
+ }
2348
+ const row = await db.get("SELECT visibility FROM nests WHERE id = ?", [
2349
+ nestId
2249
2350
  ]);
2250
- return c.json({ visibility: body.visibility });
2351
+ return c.json({ visibility: row.visibility });
2251
2352
  });
2252
2353
 
2253
- // src/nodes/routes.ts
2354
+ // src/nests/grant-routes.ts
2254
2355
  import { Hono as Hono4 } from "hono";
2356
+ import { v4 as uuid3 } from "uuid";
2357
+ var grantRoutes = new Hono4();
2358
+ async function requireManage(c, nestId) {
2359
+ if (!await canManageStewards(nestId, c.get("userId"))) {
2360
+ throw new ForbiddenError(
2361
+ "Only a nest admin, the nest owner, or the server admin can manage sharing."
2362
+ );
2363
+ }
2364
+ }
2365
+ async function resolveGrantee(email) {
2366
+ const db = getDb();
2367
+ const e = normalizeEmail(email);
2368
+ const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
2369
+ if (existing) return existing.id;
2370
+ const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
2371
+ const id = uuid3();
2372
+ await db.run(
2373
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
2374
+ [id, e, null, await hashPassword2(uuid3())]
2375
+ );
2376
+ return id;
2377
+ }
2378
+ grantRoutes.post("/", async (c) => {
2379
+ const nestId = c.req.param("nestId");
2380
+ await requireManage(c, nestId);
2381
+ const body = await c.req.json();
2382
+ const targetType = body.target_type;
2383
+ const target = (body.target || "").trim();
2384
+ const role = body.role || "read";
2385
+ if (!["document", "folder"].includes(targetType)) {
2386
+ throw new ValidationError("target_type must be document | folder");
2387
+ }
2388
+ if (!target) throw new ValidationError("target is required");
2389
+ if (!body.email?.trim()) throw new ValidationError("email is required");
2390
+ if (!["read", "write"].includes(role)) {
2391
+ throw new ValidationError("role must be read | write");
2392
+ }
2393
+ const userId = await resolveGrantee(body.email);
2394
+ const grant = await createGrant({
2395
+ nestId,
2396
+ targetType,
2397
+ target,
2398
+ userId,
2399
+ role,
2400
+ grantedBy: await resolveCallerEmail(c.get("userId"))
2401
+ });
2402
+ return c.json({ grant }, 201);
2403
+ });
2404
+ grantRoutes.get("/", async (c) => {
2405
+ const nestId = c.req.param("nestId");
2406
+ await requireManage(c, nestId);
2407
+ const grants = await listGrants(nestId);
2408
+ const db = getDb();
2409
+ const withEmail = await Promise.all(
2410
+ grants.map(async (g) => {
2411
+ const u = await db.get("SELECT email FROM users WHERE id = ?", [g.user_id]);
2412
+ return { ...g, email: u?.email ?? null };
2413
+ })
2414
+ );
2415
+ return c.json({ count: withEmail.length, grants: withEmail });
2416
+ });
2417
+ grantRoutes.delete("/:id", async (c) => {
2418
+ const nestId = c.req.param("nestId");
2419
+ await requireManage(c, nestId);
2420
+ const ok = await deleteGrant(nestId, c.req.param("id"));
2421
+ if (!ok) throw new NotFoundError("Grant not found");
2422
+ return c.json({ deleted: true });
2423
+ });
2424
+
2425
+ // src/nodes/routes.ts
2426
+ import { Hono as Hono5 } from "hono";
2255
2427
 
2256
2428
  // src/nodes/markdown-export.ts
2257
2429
  function nodeToMarkdown(node) {
@@ -2274,7 +2446,7 @@ function isMarkdownFormat(c) {
2274
2446
  }
2275
2447
 
2276
2448
  // src/annotations/service.ts
2277
- import { v4 as uuid3 } from "uuid";
2449
+ import { v4 as uuid4 } from "uuid";
2278
2450
 
2279
2451
  // src/annotations/projection.ts
2280
2452
  var MAX_CONTEXT_CHARS = 250;
@@ -2424,7 +2596,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
2424
2596
  throw new Error("comment body is required");
2425
2597
  }
2426
2598
  const db = getDb();
2427
- const id = uuid3();
2599
+ const id = uuid4();
2428
2600
  const anchor = clampAnchor(input.anchor);
2429
2601
  const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
2430
2602
  await db.transaction(async (tx) => {
@@ -2443,7 +2615,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
2443
2615
  );
2444
2616
  await tx.run(
2445
2617
  "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
2446
- [uuid3(), id, authorEmail, body]
2618
+ [uuid4(), id, authorEmail, body]
2447
2619
  );
2448
2620
  });
2449
2621
  return rowToThread(await getThreadRow(id));
@@ -2463,7 +2635,7 @@ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
2463
2635
  await getScopedThreadRow(threadId, nestId, nodeId);
2464
2636
  await getDb().run(
2465
2637
  "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
2466
- [uuid3(), threadId, authorEmail, trimmed]
2638
+ [uuid4(), threadId, authorEmail, trimmed]
2467
2639
  );
2468
2640
  return rowToThread(await getThreadRow(threadId));
2469
2641
  }
@@ -2562,7 +2734,7 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
2562
2734
  }
2563
2735
 
2564
2736
  // src/nodes/routes.ts
2565
- var nodeRoutes = new Hono4();
2737
+ var nodeRoutes = new Hono5();
2566
2738
  function nodeAsMarkdown(response, nodeId) {
2567
2739
  return nodeToMarkdown({
2568
2740
  id: nodeId,
@@ -2597,6 +2769,7 @@ nodeRoutes.post("/", async (c) => {
2597
2769
  tags: body.tags,
2598
2770
  scope: body.scope,
2599
2771
  status: body.status,
2772
+ schedule: body.schedule,
2600
2773
  folder: body.folder
2601
2774
  },
2602
2775
  authorEmail
@@ -2614,7 +2787,7 @@ nodeRoutes.post("/", async (c) => {
2614
2787
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
2615
2788
  const nestId = c.req.param("nestId");
2616
2789
  const nodeId = c.req.param("nodeId");
2617
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-I2JAFYJU.js");
2790
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-3KCVVXCW.js");
2618
2791
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
2619
2792
  nestId,
2620
2793
  nodeId
@@ -2635,7 +2808,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
2635
2808
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
2636
2809
  const nestId = c.req.param("nestId");
2637
2810
  const nodeId = c.req.param("nodeId");
2638
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-FJWVTZKR.js");
2811
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-6J4OWQL6.js");
2639
2812
  const allVersions = await getVersions2(nestId, nodeId);
2640
2813
  const approved = await getApprovedVersion2(nestId, nodeId);
2641
2814
  const db = getDb();
@@ -2667,7 +2840,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
2667
2840
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
2668
2841
  const nestId = c.req.param("nestId");
2669
2842
  const nodeId = c.req.param("nodeId");
2670
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-2FSKW425.js");
2843
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-A3XFQTJH.js");
2671
2844
  const history = await getReviewHistory2(nestId, nodeId);
2672
2845
  return c.json({ reviews: history });
2673
2846
  });
@@ -2814,7 +2987,8 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
2814
2987
  tags: body.tags,
2815
2988
  title: body.title,
2816
2989
  status: body.status,
2817
- changeNote: body.changeNote
2990
+ changeNote: body.changeNote,
2991
+ schedule: body.schedule
2818
2992
  },
2819
2993
  authorEmail
2820
2994
  );
@@ -2880,6 +3054,13 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
2880
3054
  "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
2881
3055
  [nestId, derivedId]
2882
3056
  );
3057
+ await tx.run(
3058
+ `INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
3059
+ VALUES (?, ?, ?, ?)
3060
+ ON CONFLICT(nest_id, node_id) DO UPDATE
3061
+ SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
3062
+ [nestId, nodeId, await getUserEmail(c), (/* @__PURE__ */ new Date()).toISOString()]
3063
+ );
2883
3064
  });
2884
3065
  await trackEvent("node.delete", { nestId, nodeId });
2885
3066
  return c.json({ deleted: true });
@@ -2892,13 +3073,13 @@ async function getUserEmail(c) {
2892
3073
  }
2893
3074
 
2894
3075
  // src/annotations/routes.ts
2895
- import { Hono as Hono5 } from "hono";
3076
+ import { Hono as Hono6 } from "hono";
2896
3077
 
2897
3078
  // src/annotations/types.ts
2898
3079
  var ARTIFACT_NODE_TYPE = "artifact";
2899
3080
 
2900
3081
  // src/annotations/routes.ts
2901
- var annotationRoutes = new Hono5();
3082
+ var annotationRoutes = new Hono6();
2902
3083
  function getNodeId(c) {
2903
3084
  const raw = c.req.param("nodeId");
2904
3085
  try {
@@ -3025,7 +3206,7 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
3025
3206
  });
3026
3207
 
3027
3208
  // src/nodes/query-routes.ts
3028
- import { Hono as Hono6 } from "hono";
3209
+ import { Hono as Hono7 } from "hono";
3029
3210
  import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
3030
3211
 
3031
3212
  // src/nodes/prompt-compiler.ts
@@ -3247,6 +3428,10 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
3247
3428
  // src/nodes/graph-service.ts
3248
3429
  var MAX_GRAPH_NODES = 150;
3249
3430
  var WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
3431
+ function bareSlug(idOrTarget) {
3432
+ const idx = idOrTarget.lastIndexOf("/");
3433
+ return idx >= 0 ? idOrTarget.slice(idx + 1) : idOrTarget;
3434
+ }
3250
3435
  function extractWikiTargets(body) {
3251
3436
  const out = [];
3252
3437
  if (!body) return out;
@@ -3264,9 +3449,12 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
3264
3449
  const accessible = await filterAccessible(nestId, userId, userEmail, docs);
3265
3450
  const idSet = new Set(accessible.map((d) => d.id));
3266
3451
  const titleToId = /* @__PURE__ */ new Map();
3452
+ const slugToId = /* @__PURE__ */ new Map();
3267
3453
  for (const d of accessible) {
3268
3454
  const t = (d.frontmatter?.title || "").toLowerCase().trim();
3269
3455
  if (t && !titleToId.has(t)) titleToId.set(t, d.id);
3456
+ const slug = bareSlug(d.id).toLowerCase();
3457
+ if (slug && !slugToId.has(slug)) slugToId.set(slug, d.id);
3270
3458
  }
3271
3459
  const allNodes = accessible.map((d) => ({
3272
3460
  id: d.id,
@@ -3278,7 +3466,8 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
3278
3466
  const seen = /* @__PURE__ */ new Set();
3279
3467
  for (const d of accessible) {
3280
3468
  for (const raw of extractWikiTargets(d.body || "")) {
3281
- const targetId = idSet.has(raw) ? raw : titleToId.get(raw.toLowerCase().trim());
3469
+ const norm = raw.toLowerCase().trim();
3470
+ const targetId = idSet.has(raw) ? raw : titleToId.get(norm) ?? slugToId.get(bareSlug(raw).toLowerCase());
3282
3471
  if (!targetId || targetId === d.id) continue;
3283
3472
  const key = `${d.id}->${targetId}`;
3284
3473
  if (seen.has(key)) continue;
@@ -3387,13 +3576,124 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
3387
3576
  }
3388
3577
 
3389
3578
  // src/nodes/query-routes.ts
3390
- var queryRoutes = new Hono6();
3579
+ var queryRoutes = new Hono7();
3580
+ queryRoutes.get("/changes", async (c) => {
3581
+ const nestId = c.req.param("nestId");
3582
+ const userId = c.get("userId");
3583
+ const sinceRaw = c.req.query("since");
3584
+ const sinceMs = sinceRaw ? Date.parse(sinceRaw) : NaN;
3585
+ if (!sinceRaw || Number.isNaN(sinceMs)) {
3586
+ throw new ValidationError(
3587
+ "since must be an ISO-8601 date, e.g. ?since=2026-07-01T00:00:00Z"
3588
+ );
3589
+ }
3590
+ const includeContent = ["1", "true"].includes(
3591
+ c.req.query("include_content") || ""
3592
+ );
3593
+ const all = await listNodesForCaller(nestId, userId);
3594
+ const db = getDb();
3595
+ const publicReader = await isPublicReader(nestId, userId);
3596
+ const approvedRows = await db.all(
3597
+ "SELECT node_id, approved_at FROM approved_versions WHERE nest_id = ?",
3598
+ [nestId]
3599
+ );
3600
+ const parseDbTs = (t) => Date.parse(t.includes("T") ? t : `${t.replace(" ", "T")}Z`);
3601
+ const approvedAtMsByNode = new Map(
3602
+ approvedRows.map((r) => [r.node_id, parseDbTs(r.approved_at)])
3603
+ );
3604
+ const publishedSince = new Set(
3605
+ approvedRows.filter((r) => parseDbTs(r.approved_at) > sinceMs).map((r) => r.node_id)
3606
+ );
3607
+ const summarize = (n, updatedOverride) => ({
3608
+ id: n.id,
3609
+ title: n.title,
3610
+ type: n.type,
3611
+ status: n.status,
3612
+ tags: n.tags,
3613
+ created_at: n.created_at,
3614
+ updated_at: updatedOverride ?? n.updated_at,
3615
+ ...includeContent ? { content: n.content } : {}
3616
+ });
3617
+ const created = [];
3618
+ const updated = [];
3619
+ for (const n of all) {
3620
+ if (publicReader) {
3621
+ const approvedMs = approvedAtMsByNode.get(n.id);
3622
+ if (approvedMs === void 0) continue;
3623
+ const approvedIso = new Date(approvedMs).toISOString();
3624
+ const createdAt2 = Date.parse(n.created_at || "");
3625
+ if (!Number.isNaN(createdAt2) && createdAt2 > sinceMs) {
3626
+ created.push(summarize(n, approvedIso));
3627
+ } else if (approvedMs > sinceMs) {
3628
+ updated.push(summarize(n, approvedIso));
3629
+ }
3630
+ continue;
3631
+ }
3632
+ const createdAt = Date.parse(n.created_at || "");
3633
+ const updatedAt = Date.parse(n.updated_at || "");
3634
+ if (!Number.isNaN(createdAt) && createdAt > sinceMs) created.push(summarize(n));
3635
+ else if (!Number.isNaN(updatedAt) && updatedAt > sinceMs) updated.push(summarize(n));
3636
+ else if (publishedSince.has(n.id)) updated.push(summarize(n));
3637
+ }
3638
+ const canSeeDeleted = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
3639
+ let deleted = null;
3640
+ if (canSeeDeleted) {
3641
+ const rows = await db.all(
3642
+ "SELECT node_id, deleted_by, deleted_at FROM node_deletions WHERE nest_id = ? AND deleted_at > ?",
3643
+ [nestId, new Date(sinceMs).toISOString()]
3644
+ );
3645
+ const liveIds = new Set(all.map((n) => n.id));
3646
+ deleted = rows.filter((r) => !liveIds.has(r.node_id)).map((r) => ({ id: r.node_id, deleted_by: r.deleted_by, deleted_at: r.deleted_at }));
3647
+ }
3648
+ return c.json({
3649
+ since: new Date(sinceMs).toISOString(),
3650
+ now: (/* @__PURE__ */ new Date()).toISOString(),
3651
+ created,
3652
+ updated,
3653
+ deleted
3654
+ });
3655
+ });
3656
+ queryRoutes.get("/runnables", async (c) => {
3657
+ const nestId = c.req.param("nestId");
3658
+ const userId = c.get("userId");
3659
+ const all = await listNodesForCaller(nestId, userId, {
3660
+ type: RUNNABLE_NODE_TYPES
3661
+ });
3662
+ const runnables = all.map((n) => ({
3663
+ id: n.id,
3664
+ title: n.title,
3665
+ type: n.type,
3666
+ schedule: n.schedule ?? null,
3667
+ status: n.status,
3668
+ tags: n.tags,
3669
+ updated_at: n.updated_at,
3670
+ content: n.content
3671
+ }));
3672
+ return c.json({ count: runnables.length, runnables });
3673
+ });
3391
3674
  queryRoutes.get("/graph", async (c) => {
3392
3675
  const nestId = c.req.param("nestId");
3393
3676
  const userId = c.get("userId");
3394
3677
  const userEmail = await resolveCallerEmail(userId);
3395
3678
  const canSeeIdentities = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
3396
3679
  const graph = await buildNestGraph(nestId, userId, userEmail, canSeeIdentities);
3680
+ if (config.FEATURE_WORKFLOW_PLANE) {
3681
+ const rows = await getDb().all(
3682
+ `SELECT e.from_node, e.to_node, e.condition_mode, t.name as type, t.color, t.is_flow
3683
+ FROM edges e JOIN edge_types t ON t.id = e.type_id
3684
+ WHERE e.nest_id = ?`,
3685
+ [nestId]
3686
+ );
3687
+ const visible = new Set(graph.nodes.map((n) => n.id));
3688
+ graph.typedEdges = rows.filter((r) => visible.has(r.from_node) && visible.has(r.to_node)).map((r) => ({
3689
+ source: r.from_node,
3690
+ target: r.to_node,
3691
+ type: r.type,
3692
+ color: r.color,
3693
+ is_flow: !!r.is_flow,
3694
+ condition_mode: r.condition_mode
3695
+ }));
3696
+ }
3397
3697
  return c.json(graph);
3398
3698
  });
3399
3699
  function approxTokens(text) {
@@ -3775,89 +4075,1262 @@ queryRoutes.post("/publish", async (c) => {
3775
4075
  });
3776
4076
 
3777
4077
  // src/mcp/routes.ts
3778
- import { Hono as Hono7 } from "hono";
4078
+ import { Hono as Hono10 } from "hono";
3779
4079
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3780
4080
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3781
4081
 
3782
- // src/mcp/tools.ts
3783
- var MAX_HOPS = 10;
3784
- function normalizeHops(raw) {
3785
- if (raw == null) return 2;
3786
- const n = Number(raw);
3787
- if (!Number.isFinite(n)) return 2;
3788
- return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
3789
- }
3790
- var TOOL_DEFINITIONS = [
3791
- {
3792
- name: "context_init",
3793
- description: "Load the vault's CONTEXT.md which contains operating instructions and behavioral guidelines. Call this FIRST in every conversation.",
3794
- inputSchema: { type: "object", properties: {} }
3795
- },
3796
- {
3797
- name: "context_overview",
3798
- description: "Get a complete map of the vault: total node count, types, tags, and a title+snippet for every node.",
3799
- inputSchema: { type: "object", properties: {} }
3800
- },
3801
- {
3802
- name: "context_search",
3803
- description: "Full-text keyword search across all node content, titles, tags, and metadata.",
3804
- inputSchema: {
3805
- type: "object",
3806
- properties: {
3807
- query: { type: "string", description: "Search terms" }
3808
- },
3809
- required: ["query"]
4082
+ // src/telemetry/trace-log.ts
4083
+ var RETENTION_DAYS = 14;
4084
+ var PRUNE_EVERY = 500;
4085
+ var insertsSincePrune = 0;
4086
+ async function logTraceEvent(e) {
4087
+ try {
4088
+ const db = getDb();
4089
+ await db.run(
4090
+ `INSERT INTO api_events
4091
+ (ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms)
4092
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
4093
+ [
4094
+ (/* @__PURE__ */ new Date()).toISOString(),
4095
+ e.kind,
4096
+ e.method ?? null,
4097
+ e.path ?? null,
4098
+ e.tool ?? null,
4099
+ e.nestId ?? null,
4100
+ e.userId ?? null,
4101
+ e.userEmail ?? null,
4102
+ e.status ?? null,
4103
+ e.durationMs ?? null
4104
+ ]
4105
+ );
4106
+ if (++insertsSincePrune >= PRUNE_EVERY) {
4107
+ insertsSincePrune = 0;
4108
+ const cutoff = new Date(
4109
+ Date.now() - RETENTION_DAYS * 864e5
4110
+ ).toISOString();
4111
+ await db.run("DELETE FROM api_events WHERE ts < ?", [cutoff]);
3810
4112
  }
3811
- },
3812
- {
3813
- name: "context_query",
3814
- description: "Run a structured selector query. Supports: #tag, type:X, [[Title]], scope:X. Combine with +AND, |OR, -NOT.",
3815
- inputSchema: {
3816
- type: "object",
3817
- properties: {
3818
- query: { type: "string", description: "Selector query" },
3819
- hops: {
3820
- type: "number",
3821
- description: "Graph traversal depth from the matched nodes (default: 2). Use 1 for just the matches + direct links, higher to pull in more of the neighborhood."
3822
- }
3823
- },
3824
- required: ["query"]
4113
+ } catch {
4114
+ }
4115
+ }
4116
+ async function listTraceEvents(filters = {}) {
4117
+ const db = getDb();
4118
+ const limit = Number.isFinite(filters.limit) ? Math.min(Math.max(filters.limit, 1), 1e3) : 25;
4119
+ const offset = Number.isFinite(filters.offset) ? Math.max(filters.offset, 0) : 0;
4120
+ const from = "FROM api_events e LEFT JOIN users u ON u.id = e.user_id";
4121
+ const where = [];
4122
+ const args = [];
4123
+ if (filters.kind) {
4124
+ where.push("e.kind = ?");
4125
+ args.push(filters.kind);
4126
+ }
4127
+ if (filters.nestId) {
4128
+ where.push("e.nest_id = ?");
4129
+ args.push(filters.nestId);
4130
+ }
4131
+ if (filters.user) {
4132
+ where.push("(COALESCE(e.user_email, u.email) LIKE ? OR e.user_id = ?)");
4133
+ args.push(`%${filters.user}%`, filters.user);
4134
+ }
4135
+ const whereSql = where.length ? ` WHERE ${where.join(" AND ")}` : "";
4136
+ try {
4137
+ const totalRow = await db.get(
4138
+ `SELECT COUNT(*) AS n ${from}${whereSql}`,
4139
+ args
4140
+ );
4141
+ const total = Number(totalRow?.n ?? 0);
4142
+ const events = await db.all(
4143
+ `SELECT e.*, COALESCE(e.user_email, u.email) AS caller
4144
+ ${from}${whereSql}
4145
+ ORDER BY e.id DESC
4146
+ LIMIT ? OFFSET ?`,
4147
+ [...args, limit, offset]
4148
+ );
4149
+ return { events, total };
4150
+ } catch {
4151
+ return { events: [], total: 0 };
4152
+ }
4153
+ }
4154
+
4155
+ // src/mcp/workflow-tools.ts
4156
+ import { v4 as uuid8 } from "uuid";
4157
+
4158
+ // src/shared/node-id.ts
4159
+ function assertSafeNodeId(rawId) {
4160
+ let id = rawId;
4161
+ try {
4162
+ id = decodeURIComponent(rawId);
4163
+ } catch {
4164
+ }
4165
+ if (!id || id.length > 512) {
4166
+ throw new ValidationError("invalid node id");
4167
+ }
4168
+ if (id.includes("\0") || id.includes("\\")) {
4169
+ throw new ValidationError("invalid node id");
4170
+ }
4171
+ if (id.startsWith("/")) {
4172
+ throw new ValidationError("invalid node id (absolute path)");
4173
+ }
4174
+ for (const seg of id.split("/")) {
4175
+ if (seg === "" || seg === "." || seg === "..") {
4176
+ throw new ValidationError("invalid node id (path traversal)");
3825
4177
  }
3826
- },
3827
- {
3828
- name: "context_get",
3829
- description: "Get the FULL content of a specific node by title or ID.",
3830
- inputSchema: {
3831
- type: "object",
3832
- properties: {
3833
- title: { type: "string", description: "Title of the node" },
3834
- id: { type: "string", description: "ID of the node" }
4178
+ }
4179
+ return id;
4180
+ }
4181
+
4182
+ // src/workflow/run-service.ts
4183
+ import { v4 as uuid5 } from "uuid";
4184
+
4185
+ // src/shared/json.ts
4186
+ var safeJson = (s) => {
4187
+ if (!s) return null;
4188
+ try {
4189
+ return JSON.parse(s);
4190
+ } catch {
4191
+ return null;
4192
+ }
4193
+ };
4194
+
4195
+ // src/workflow/run-service.ts
4196
+ var RUNNABLE_TYPES = ["agent", "skill"];
4197
+ async function flowSubgraph(nestId, agentNode) {
4198
+ const db = getDb();
4199
+ const rows = await db.all(
4200
+ `SELECT e.id, e.from_node, e.to_node, e.condition_mode, e.condition, e.metadata,
4201
+ t.name as type_name, t.color as type_color
4202
+ FROM edges e JOIN edge_types t ON t.id = e.type_id
4203
+ WHERE e.nest_id = ? AND t.is_flow = 1`,
4204
+ [nestId]
4205
+ );
4206
+ const byFrom = /* @__PURE__ */ new Map();
4207
+ for (const r of rows) {
4208
+ if (!byFrom.has(r.from_node)) byFrom.set(r.from_node, []);
4209
+ byFrom.get(r.from_node).push(r);
4210
+ }
4211
+ const seen = /* @__PURE__ */ new Set([agentNode]);
4212
+ const queue = [agentNode];
4213
+ const edges = [];
4214
+ while (queue.length) {
4215
+ const cur = queue.shift();
4216
+ for (const e of byFrom.get(cur) ?? []) {
4217
+ edges.push(e);
4218
+ if (!seen.has(e.to_node)) {
4219
+ seen.add(e.to_node);
4220
+ queue.push(e.to_node);
3835
4221
  }
3836
4222
  }
3837
- },
3838
- {
3839
- name: "context_list",
3840
- description: "Browse vault contents with optional type, tag, or limit filters.",
3841
- inputSchema: {
3842
- type: "object",
3843
- properties: {
3844
- type: { type: "string", description: "Filter by node type" },
3845
- tag: { type: "string", description: "Filter by tag" },
3846
- limit: { type: "number", description: "Max nodes to return" }
3847
- }
4223
+ }
4224
+ return edges.map((e) => ({
4225
+ id: e.id,
4226
+ from_node: e.from_node,
4227
+ to_node: e.to_node,
4228
+ type: { name: e.type_name, color: e.type_color },
4229
+ condition_mode: e.condition_mode,
4230
+ condition: e.condition_mode === "structured" ? safeJson(e.condition) : e.condition,
4231
+ metadata: safeJson(e.metadata)
4232
+ }));
4233
+ }
4234
+ async function referencedDefinitions(nestId, edges) {
4235
+ const terms = /* @__PURE__ */ new Set();
4236
+ for (const e of edges) {
4237
+ if (e.condition_mode === "structured" && e.condition && typeof e.condition === "object") {
4238
+ const t = e.condition.term;
4239
+ if (typeof t === "string" && t.trim()) terms.add(t.trim());
3848
4240
  }
3849
- },
3850
- {
3851
- name: "context_resolve",
3852
- description: "Full context resolution \u2014 run a selector and return complete node content, respecting a token budget.",
3853
- inputSchema: {
3854
- type: "object",
3855
- properties: {
3856
- selector: {
3857
- type: "string",
3858
- description: "Selector query string"
3859
- },
3860
- max_tokens: {
4241
+ }
4242
+ if (!terms.size) return [];
4243
+ try {
4244
+ const db = getDb();
4245
+ const list = [...terms];
4246
+ const placeholders = list.map(() => "LOWER(?)").join(", ");
4247
+ const rows = await db.all(
4248
+ `SELECT term, definition, linked_tag FROM definitions
4249
+ WHERE nest_id = ? AND LOWER(term) IN (${placeholders})`,
4250
+ [nestId, ...list]
4251
+ );
4252
+ return rows;
4253
+ } catch {
4254
+ return [];
4255
+ }
4256
+ }
4257
+ async function resolveParentRun(nestId, parentRunId, childAgent) {
4258
+ if (!config.FEATURE_SUBAGENT_RUNS) {
4259
+ throw new ValidationError(
4260
+ "Sub-agent runs are not enabled on this server (FEATURE_SUBAGENT_RUNS)."
4261
+ );
4262
+ }
4263
+ const db = getDb();
4264
+ let cursor = parentRunId;
4265
+ let parentDepth = -1;
4266
+ for (let i = 0; i <= config.SUBAGENT_MAX_DEPTH + 1; i++) {
4267
+ if (!cursor) break;
4268
+ const row = await db.get(
4269
+ "SELECT id, nest_id, agent_node, status, depth, parent_run_id FROM runs WHERE id = ?",
4270
+ [cursor]
4271
+ );
4272
+ if (!row || row.nest_id !== nestId) {
4273
+ throw new NotFoundError(`Parent run not found: ${cursor}`);
4274
+ }
4275
+ if (cursor === parentRunId && row.status !== "running") {
4276
+ throw new ConflictError(
4277
+ `Parent run is ${row.status} \u2014 cannot spawn a sub-agent under a closed run`
4278
+ );
4279
+ }
4280
+ if (row.agent_node === childAgent) {
4281
+ throw new ConflictError(
4282
+ `re-entrancy: "${childAgent}" is already running in this call chain \u2014 sub-agent recursion must be acyclic`
4283
+ );
4284
+ }
4285
+ if (cursor === parentRunId) parentDepth = row.depth;
4286
+ cursor = row.parent_run_id;
4287
+ }
4288
+ const depth = parentDepth + 1;
4289
+ if (depth > config.SUBAGENT_MAX_DEPTH) {
4290
+ throw new ForbiddenError(
4291
+ `sub-agent depth ${depth} exceeds the limit of ${config.SUBAGENT_MAX_DEPTH}`
4292
+ );
4293
+ }
4294
+ return { depth };
4295
+ }
4296
+ async function loadRun(runId) {
4297
+ return await getDb().get("SELECT * FROM runs WHERE id = ?", [runId]);
4298
+ }
4299
+ async function triggerRun(opts) {
4300
+ const { nestId, inputs, triggeredBy, storage } = opts;
4301
+ const agentNode = assertSafeNodeId(opts.agentNode);
4302
+ let doc;
4303
+ try {
4304
+ doc = await storage.readDocument(agentNode);
4305
+ } catch {
4306
+ throw new NotFoundError(`Agent node not found: ${agentNode}`);
4307
+ }
4308
+ const nodeType = doc.frontmatter.type || "document";
4309
+ if (!RUNNABLE_TYPES.includes(nodeType)) {
4310
+ throw new ValidationError(
4311
+ `"${agentNode}" is type "${nodeType}" \u2014 only agent/skill nodes are runnable`
4312
+ );
4313
+ }
4314
+ let parentRunId = null;
4315
+ let depth = 0;
4316
+ if (opts.parentRunId !== void 0 && opts.parentRunId !== null) {
4317
+ if (typeof opts.parentRunId !== "string") {
4318
+ throw new ValidationError("parent_run_id must be a string");
4319
+ }
4320
+ ({ depth } = await resolveParentRun(nestId, opts.parentRunId, agentNode));
4321
+ parentRunId = opts.parentRunId;
4322
+ }
4323
+ const edges = await flowSubgraph(nestId, agentNode);
4324
+ const definitions = await referencedDefinitions(nestId, edges);
4325
+ const id = `run_${uuid5()}`;
4326
+ const db = getDb();
4327
+ await db.transaction(async (tx) => {
4328
+ if (parentRunId) {
4329
+ if (db.dialect === "postgres") {
4330
+ await tx.get("SELECT id FROM runs WHERE id = ? FOR UPDATE", [parentRunId]);
4331
+ }
4332
+ const kids = await tx.get(
4333
+ "SELECT COUNT(*) as c FROM runs WHERE parent_run_id = ?",
4334
+ [parentRunId]
4335
+ );
4336
+ if (Number(kids.c) >= config.SUBAGENT_MAX_CHILDREN) {
4337
+ throw new ForbiddenError(
4338
+ `parent run already has ${kids.c} children (limit ${config.SUBAGENT_MAX_CHILDREN})`
4339
+ );
4340
+ }
4341
+ }
4342
+ await tx.run(
4343
+ `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
4344
+ VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)`,
4345
+ [
4346
+ id,
4347
+ nestId,
4348
+ agentNode,
4349
+ triggeredBy,
4350
+ inputs === void 0 ? null : JSON.stringify(inputs),
4351
+ (/* @__PURE__ */ new Date()).toISOString(),
4352
+ parentRunId,
4353
+ depth
4354
+ ]
4355
+ );
4356
+ });
4357
+ return {
4358
+ run_id: id,
4359
+ parent_run_id: parentRunId,
4360
+ depth,
4361
+ agent: {
4362
+ id: agentNode,
4363
+ title: String(doc.frontmatter.title ?? agentNode),
4364
+ type: nodeType,
4365
+ schedule: doc.frontmatter.metadata?.schedule ?? null,
4366
+ content: doc.body || ""
4367
+ },
4368
+ edges,
4369
+ definitions,
4370
+ trace_hint: {
4371
+ retrieval: "POST /context is deterministic \u2014 record selector+hops per read so the run replays to the identical read-set",
4372
+ steps: "POST /runs/{run_id}/steps per action; PATCH /runs/{run_id} to close"
4373
+ }
4374
+ };
4375
+ }
4376
+ var STEP_ACTIONS = ["read", "write", "branch", "notify", "external"];
4377
+ async function appendRunStep(runId, input) {
4378
+ if (typeof input.action !== "string" || !input.action.trim()) {
4379
+ throw new ValidationError("action is required (read|write|branch|notify|external)");
4380
+ }
4381
+ const action = input.action.trim();
4382
+ if (!STEP_ACTIONS.includes(action)) {
4383
+ throw new ValidationError(
4384
+ "action must be read | write | branch | notify | external"
4385
+ );
4386
+ }
4387
+ const nodeId = typeof input.node_id === "string" ? input.node_id : null;
4388
+ const edgeId = typeof input.edge_id === "string" ? input.edge_id : null;
4389
+ const detail = input.detail === void 0 ? null : JSON.stringify(input.detail);
4390
+ const db = getDb();
4391
+ let seq = 0;
4392
+ await db.transaction(async (tx) => {
4393
+ const cur = await tx.get("SELECT status FROM runs WHERE id = ?", [runId]);
4394
+ if (!cur) throw new NotFoundError("Run not found");
4395
+ if (cur.status !== "running") {
4396
+ throw new ConflictError(
4397
+ `Run is ${cur.status} \u2014 steps can't be appended`
4398
+ );
4399
+ }
4400
+ const last = await tx.get(
4401
+ "SELECT MAX(seq) as m FROM run_steps WHERE run_id = ?",
4402
+ [runId]
4403
+ );
4404
+ seq = (Number(last.m) || 0) + 1;
4405
+ if (seq > config.RUN_MAX_STEPS) {
4406
+ throw new ConflictError(
4407
+ `Run has reached the step limit of ${config.RUN_MAX_STEPS} \u2014 no more steps can be appended`
4408
+ );
4409
+ }
4410
+ await tx.run(
4411
+ `INSERT INTO run_steps (run_id, seq, node_id, edge_id, action, detail, at)
4412
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
4413
+ [runId, seq, nodeId, edgeId, action, detail, (/* @__PURE__ */ new Date()).toISOString()]
4414
+ );
4415
+ });
4416
+ return { seq };
4417
+ }
4418
+ var RUN_STATUSES = ["running", "succeeded", "failed", "cancelled"];
4419
+ async function closeRun(runId, input) {
4420
+ const status = input.status;
4421
+ if (typeof status !== "string" || !RUN_STATUSES.includes(status)) {
4422
+ throw new ValidationError("status must be running | succeeded | failed | cancelled");
4423
+ }
4424
+ const terminal = status !== "running";
4425
+ const res = await getDb().run(
4426
+ `UPDATE runs SET status = ?, trace = COALESCE(?, trace), finished_at = ?
4427
+ WHERE id = ? AND status = 'running'`,
4428
+ [
4429
+ status,
4430
+ input.trace === void 0 ? null : JSON.stringify(input.trace),
4431
+ terminal ? (/* @__PURE__ */ new Date()).toISOString() : null,
4432
+ runId
4433
+ ]
4434
+ );
4435
+ if (res.changes === 0) {
4436
+ const run = await loadRun(runId);
4437
+ if (!run) throw new NotFoundError("Run not found");
4438
+ throw new ConflictError("Run already terminal \u2014 status can't be changed");
4439
+ }
4440
+ return { status };
4441
+ }
4442
+ async function getRunTrace(run, opts) {
4443
+ const { canSeeDetail } = opts;
4444
+ const steps = await getDb().all(
4445
+ "SELECT seq, node_id, edge_id, action, detail, at FROM run_steps WHERE run_id = ? ORDER BY seq",
4446
+ [run.id]
4447
+ );
4448
+ return {
4449
+ run: {
4450
+ ...run,
4451
+ inputs: canSeeDetail ? safeJson(run.inputs) : null,
4452
+ trace: canSeeDetail ? safeJson(run.trace) : null
4453
+ },
4454
+ steps: steps.map((s) => ({
4455
+ ...s,
4456
+ detail: canSeeDetail ? safeJson(s.detail) : null
4457
+ }))
4458
+ };
4459
+ }
4460
+ async function listRuns(nestId, opts) {
4461
+ const db = getDb();
4462
+ let sql = "SELECT * FROM runs WHERE nest_id = ?";
4463
+ const args = [nestId];
4464
+ if (opts.agent) {
4465
+ sql += " AND agent_node = ?";
4466
+ args.push(opts.agent);
4467
+ }
4468
+ if (opts.status) {
4469
+ sql += " AND status = ?";
4470
+ args.push(opts.status);
4471
+ }
4472
+ sql += " ORDER BY started_at DESC LIMIT ?";
4473
+ args.push(Math.min(Math.max(Number(opts.limit) || 50, 1), 500));
4474
+ const rows = await db.all(sql, args);
4475
+ return {
4476
+ count: rows.length,
4477
+ runs: rows.map((r) => ({
4478
+ ...r,
4479
+ inputs: opts.canSeeDetail ? safeJson(r.inputs) : null,
4480
+ trace: opts.canSeeDetail ? safeJson(r.trace) : null
4481
+ }))
4482
+ };
4483
+ }
4484
+
4485
+ // src/workflow/edge-routes.ts
4486
+ import { Hono as Hono9 } from "hono";
4487
+ import { v4 as uuid7 } from "uuid";
4488
+
4489
+ // src/workflow/edge-type-routes.ts
4490
+ import { Hono as Hono8 } from "hono";
4491
+ import { v4 as uuid6 } from "uuid";
4492
+ var requireWorkflowPlane = async (c, next) => {
4493
+ if (!config.FEATURE_WORKFLOW_PLANE) {
4494
+ return c.json(
4495
+ {
4496
+ error: "The workflow plane is not enabled on this server. An admin can turn it on with FEATURE_WORKFLOW_PLANE=true."
4497
+ },
4498
+ 404
4499
+ );
4500
+ }
4501
+ return next();
4502
+ };
4503
+ async function seedDefaultEdgeTypes(nestId) {
4504
+ const db = getDb();
4505
+ const existing = await db.get(
4506
+ "SELECT COUNT(*) as c FROM edge_types WHERE nest_id = ?",
4507
+ [nestId]
4508
+ );
4509
+ if (existing.c > 0) return;
4510
+ const owner = await db.get(
4511
+ "SELECT u.email FROM nests n JOIN users u ON u.id = n.user_id WHERE n.id = ?",
4512
+ [nestId]
4513
+ );
4514
+ const createdBy = owner?.email ?? "admin@localhost";
4515
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4516
+ const defaults = [
4517
+ ["next", "Unconditional flow: after the source completes, run the target.", 1, "#16a34a"],
4518
+ ["on-success", "Follow only when the source step succeeded.", 1, "#16a34a"],
4519
+ ["on-failure", "Follow only when the source step failed.", 1, "#ef4444"],
4520
+ ["depends-on", "The source requires the target (ordering/lineage; not conditional flow).", 0, "#3b82f6"],
4521
+ ["owned-by", "Ownership: the target person/team is accountable for the source node.", 0, "#64748b"]
4522
+ ];
4523
+ for (const [name, description, isFlow, color] of defaults) {
4524
+ await db.run(
4525
+ `INSERT INTO edge_types
4526
+ (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
4527
+ VALUES (?, ?, ?, ?, 'directed', ?, NULL, ?, ?, ?, ?)
4528
+ ON CONFLICT DO NOTHING`,
4529
+ [uuid6(), nestId, name, description, isFlow, color, createdBy, now, now]
4530
+ );
4531
+ }
4532
+ }
4533
+ function parseConditionSchema(raw) {
4534
+ if (raw === void 0 || raw === null || raw === "") return null;
4535
+ const obj = typeof raw === "string" ? safeJson(raw) : raw;
4536
+ if (!obj || typeof obj !== "object" || !Array.isArray(obj.params) || !obj.params.every((p) => typeof p === "string")) {
4537
+ throw new ValidationError(
4538
+ 'condition_schema must be {"params": [string\u2026], "mode_default"?: "structured"|"nl"|"open"}'
4539
+ );
4540
+ }
4541
+ const mode = obj.mode_default;
4542
+ if (mode !== void 0 && !["structured", "nl", "open"].includes(mode)) {
4543
+ throw new ValidationError(
4544
+ "condition_schema.mode_default must be structured | nl | open"
4545
+ );
4546
+ }
4547
+ return JSON.stringify({ params: obj.params, mode_default: mode ?? "structured" });
4548
+ }
4549
+ var edgeTypeToResponse = (r) => ({
4550
+ ...r,
4551
+ is_flow: !!r.is_flow,
4552
+ condition_schema: r.condition_schema ? safeJson(r.condition_schema) : null
4553
+ });
4554
+ var edgeTypeRoutes = new Hono8();
4555
+ edgeTypeRoutes.get("/", requireWorkflowPlane, async (c) => {
4556
+ const nestId = c.req.param("nestId");
4557
+ await seedDefaultEdgeTypes(nestId);
4558
+ const rows = await getDb().all(
4559
+ "SELECT * FROM edge_types WHERE nest_id = ? ORDER BY LOWER(name)",
4560
+ [nestId]
4561
+ );
4562
+ return c.json({ count: rows.length, edge_types: rows.map(edgeTypeToResponse) });
4563
+ });
4564
+ edgeTypeRoutes.post("/", requireWorkflowPlane, async (c) => {
4565
+ const nestId = c.req.param("nestId");
4566
+ const body = await c.req.json();
4567
+ if (typeof body.name !== "string" || !body.name.trim()) {
4568
+ throw new ValidationError("name is required");
4569
+ }
4570
+ if (typeof body.description !== "string" || !body.description.trim()) {
4571
+ throw new ValidationError("description (the articulation) is required");
4572
+ }
4573
+ const name = body.name.trim();
4574
+ if (name.length > 100 || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
4575
+ throw new ValidationError(
4576
+ "name must be alphanumeric-with-dashes, 100 chars max (e.g. escalates-when)"
4577
+ );
4578
+ }
4579
+ const direction = String(body.direction ?? "directed");
4580
+ if (!["directed", "undirected"].includes(direction)) {
4581
+ throw new ValidationError("direction must be directed | undirected");
4582
+ }
4583
+ const conditionSchema = parseConditionSchema(body.condition_schema);
4584
+ const isFlow = body.is_flow === true ? 1 : 0;
4585
+ let color = null;
4586
+ if (body.color !== void 0 && body.color !== null && body.color !== "") {
4587
+ if (typeof body.color !== "string" || !/^#[0-9a-f]{6}$/i.test(body.color)) {
4588
+ throw new ValidationError("color must be a 6-digit hex, e.g. #16a34a");
4589
+ }
4590
+ color = body.color;
4591
+ }
4592
+ const db = getDb();
4593
+ const definedBy = await resolveCallerEmail(c.get("userId"));
4594
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4595
+ await seedDefaultEdgeTypes(nestId);
4596
+ let created = false;
4597
+ await db.transaction(async (tx) => {
4598
+ const existing = await tx.get(
4599
+ "SELECT id FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
4600
+ [nestId, name]
4601
+ );
4602
+ if (existing) {
4603
+ await tx.run(
4604
+ `UPDATE edge_types SET name = ?, description = ?, direction = ?, is_flow = ?,
4605
+ condition_schema = ?, color = ?, updated_at = ? WHERE id = ?`,
4606
+ [name, body.description.trim(), direction, isFlow, conditionSchema, color, now, existing.id]
4607
+ );
4608
+ } else {
4609
+ created = true;
4610
+ await tx.run(
4611
+ `INSERT INTO edge_types
4612
+ (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
4613
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
4614
+ [uuid6(), nestId, name, body.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
4615
+ );
4616
+ }
4617
+ });
4618
+ const row = await db.get(
4619
+ "SELECT * FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
4620
+ [nestId, name]
4621
+ );
4622
+ return c.json({ edge_type: edgeTypeToResponse(row) }, created ? 201 : 200);
4623
+ });
4624
+ edgeTypeRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
4625
+ const nestId = c.req.param("nestId");
4626
+ const id = c.req.param("id");
4627
+ await getDb().transaction(async (tx) => {
4628
+ const row = await tx.get(
4629
+ "SELECT id FROM edge_types WHERE id = ? AND nest_id = ?",
4630
+ [id, nestId]
4631
+ );
4632
+ if (!row) throw new NotFoundError("Edge type not found");
4633
+ const inUse = await tx.get(
4634
+ "SELECT COUNT(*) as c FROM edges WHERE type_id = ?",
4635
+ [id]
4636
+ );
4637
+ if (inUse.c > 0) {
4638
+ throw new ValidationError(
4639
+ `Edge type is in use by ${inUse.c} edge${inUse.c === 1 ? "" : "s"} \u2014 delete those first.`
4640
+ );
4641
+ }
4642
+ await tx.run("DELETE FROM edge_types WHERE id = ?", [id]);
4643
+ });
4644
+ return c.json({ deleted: true });
4645
+ });
4646
+
4647
+ // src/workflow/edge-routes.ts
4648
+ function toResponse(e, t) {
4649
+ return {
4650
+ id: e.id,
4651
+ from_node: e.from_node,
4652
+ to_node: e.to_node,
4653
+ type: { id: t.id, name: t.name, color: t.color, is_flow: !!t.is_flow },
4654
+ condition_mode: e.condition_mode,
4655
+ condition: e.condition_mode === "structured" ? safeJson(e.condition) : e.condition,
4656
+ metadata: safeJson(e.metadata),
4657
+ created_by: e.created_by,
4658
+ created_at: e.created_at,
4659
+ updated_at: e.updated_at
4660
+ };
4661
+ }
4662
+ async function validateCondition(nestId, mode, condition) {
4663
+ if (mode === void 0 || mode === null) {
4664
+ if (condition !== void 0 && condition !== null) {
4665
+ throw new ValidationError("condition requires condition_mode");
4666
+ }
4667
+ return { mode: null, stored: null };
4668
+ }
4669
+ if (!["structured", "nl", "open"].includes(mode)) {
4670
+ throw new ValidationError("condition_mode must be structured | nl | open");
4671
+ }
4672
+ if (condition === void 0 || condition === null || condition === "") {
4673
+ throw new ValidationError(`condition_mode "${mode}" requires a condition`);
4674
+ }
4675
+ if (mode === "structured") {
4676
+ if (typeof condition !== "object" || Array.isArray(condition)) {
4677
+ throw new ValidationError(
4678
+ 'structured condition must be an object, e.g. {"term":"CAC","op":">","value":500}'
4679
+ );
4680
+ }
4681
+ const c = condition;
4682
+ if (typeof c.term === "string" && c.term.trim()) {
4683
+ try {
4684
+ const row = await getDb().get(
4685
+ "SELECT id FROM definitions WHERE nest_id = ? AND LOWER(term) = LOWER(?)",
4686
+ [nestId, c.term.trim()]
4687
+ );
4688
+ if (!row) {
4689
+ throw new ValidationError(
4690
+ `structured condition references undefined term "${c.term}" \u2014 define it in the glossary first`
4691
+ );
4692
+ }
4693
+ } catch (err) {
4694
+ if (err instanceof ValidationError) throw err;
4695
+ if (!/no such table/i.test(err?.message ?? "")) throw err;
4696
+ }
4697
+ }
4698
+ return { mode, stored: JSON.stringify(condition) };
4699
+ }
4700
+ if (typeof condition !== "string") {
4701
+ throw new ValidationError(`condition for mode "${mode}" must be a string`);
4702
+ }
4703
+ return { mode, stored: condition };
4704
+ }
4705
+ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
4706
+ if (fromNode === toNode) {
4707
+ throw new ConflictError(
4708
+ `cycle: ["${fromNode}","${toNode}"] \u2014 flow edges must form a DAG`
4709
+ );
4710
+ }
4711
+ const rows = await db.all(
4712
+ `SELECT e.from_node, e.to_node FROM edges e
4713
+ JOIN edge_types t ON t.id = e.type_id
4714
+ WHERE e.nest_id = ? AND t.is_flow = 1`,
4715
+ [nestId]
4716
+ );
4717
+ const adj = /* @__PURE__ */ new Map();
4718
+ for (const r of rows) {
4719
+ (adj.get(r.from_node) ?? adj.set(r.from_node, []).get(r.from_node)).push(
4720
+ r.to_node
4721
+ );
4722
+ }
4723
+ const parent = /* @__PURE__ */ new Map();
4724
+ const queue = [toNode];
4725
+ const seen = /* @__PURE__ */ new Set([toNode]);
4726
+ while (queue.length) {
4727
+ const cur = queue.shift();
4728
+ if (cur === fromNode) {
4729
+ const path = [fromNode];
4730
+ let p = fromNode;
4731
+ while (p !== toNode) {
4732
+ p = parent.get(p);
4733
+ path.push(p);
4734
+ }
4735
+ path.reverse().push(toNode);
4736
+ throw new ConflictError(
4737
+ `cycle: ${JSON.stringify(path)} \u2014 flow edges must form a DAG`
4738
+ );
4739
+ }
4740
+ for (const nb of adj.get(cur) ?? []) {
4741
+ if (!seen.has(nb)) {
4742
+ seen.add(nb);
4743
+ parent.set(nb, cur);
4744
+ queue.push(nb);
4745
+ }
4746
+ }
4747
+ }
4748
+ }
4749
+ var edgeRoutes = new Hono9();
4750
+ edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
4751
+ const nestId = c.req.param("nestId");
4752
+ const db = getDb();
4753
+ const typeFilter = c.req.query("type")?.trim();
4754
+ const nodeFilter = c.req.query("node")?.trim();
4755
+ let sql = `SELECT e.*, t.id as t_id, t.name as t_name, t.color as t_color, t.is_flow as t_is_flow
4756
+ FROM edges e JOIN edge_types t ON t.id = e.type_id
4757
+ WHERE e.nest_id = ?`;
4758
+ const args = [nestId];
4759
+ if (typeFilter) {
4760
+ sql += " AND (t.id = ? OR LOWER(t.name) = LOWER(?))";
4761
+ args.push(typeFilter, typeFilter);
4762
+ }
4763
+ if (nodeFilter) {
4764
+ sql += " AND (e.from_node = ? OR e.to_node = ?)";
4765
+ args.push(nodeFilter, nodeFilter);
4766
+ }
4767
+ sql += " ORDER BY e.created_at";
4768
+ const rows = await db.all(sql, args);
4769
+ const edges = rows.map(
4770
+ (r) => toResponse(r, {
4771
+ id: r.t_id,
4772
+ name: r.t_name,
4773
+ color: r.t_color,
4774
+ is_flow: r.t_is_flow
4775
+ })
4776
+ );
4777
+ return c.json({ count: edges.length, edges });
4778
+ });
4779
+ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
4780
+ const nestId = c.req.param("nestId");
4781
+ const body = await c.req.json();
4782
+ const fromRaw = typeof body.from_node === "string" ? body.from_node.trim() : "";
4783
+ const toRaw = typeof body.to_node === "string" ? body.to_node.trim() : "";
4784
+ const typeRef = typeof body.type === "string" ? body.type.trim() : "";
4785
+ if (!fromRaw || !toRaw || !typeRef) {
4786
+ throw new ValidationError("from_node, to_node, and type are required");
4787
+ }
4788
+ const fromNode = assertSafeNodeId(fromRaw);
4789
+ const toNode = assertSafeNodeId(toRaw);
4790
+ const db = getDb();
4791
+ const createdBy = await resolveCallerEmail(c.get("userId"));
4792
+ await seedDefaultEdgeTypes(nestId);
4793
+ const type = await db.get(
4794
+ "SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
4795
+ [nestId, typeRef, typeRef]
4796
+ );
4797
+ if (!type) {
4798
+ throw new ValidationError(
4799
+ `unknown edge type "${typeRef}" \u2014 define it in the registry first`
4800
+ );
4801
+ }
4802
+ const { storage } = await engineCache.get(nestId);
4803
+ for (const nodeId of [fromNode, toNode]) {
4804
+ try {
4805
+ await storage.readDocument(nodeId);
4806
+ } catch {
4807
+ throw new ValidationError(`node not found: "${nodeId}"`);
4808
+ }
4809
+ }
4810
+ const { mode, stored } = await validateCondition(
4811
+ nestId,
4812
+ body.condition_mode,
4813
+ body.condition
4814
+ );
4815
+ const metadata = body.metadata === void 0 || body.metadata === null ? null : JSON.stringify(body.metadata);
4816
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4817
+ const id = uuid7();
4818
+ await db.transaction(async (tx) => {
4819
+ if (type.is_flow) {
4820
+ await assertNoFlowCycle(tx, nestId, fromNode, toNode);
4821
+ }
4822
+ await tx.run(
4823
+ `INSERT INTO edges
4824
+ (id, nest_id, from_node, to_node, type_id, condition_mode, condition, metadata, created_by, created_at, updated_at)
4825
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
4826
+ [id, nestId, fromNode, toNode, type.id, mode, stored, metadata, createdBy, now, now]
4827
+ );
4828
+ });
4829
+ const row = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
4830
+ return c.json({ edge: toResponse(row, type) }, 201);
4831
+ });
4832
+ edgeRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
4833
+ const nestId = c.req.param("nestId");
4834
+ const id = c.req.param("id");
4835
+ const db = getDb();
4836
+ const row = await db.get(
4837
+ "SELECT id FROM edges WHERE id = ? AND nest_id = ?",
4838
+ [id, nestId]
4839
+ );
4840
+ if (!row) throw new NotFoundError("Edge not found");
4841
+ await db.run("DELETE FROM edges WHERE id = ?", [id]);
4842
+ return c.json({ deleted: true });
4843
+ });
4844
+
4845
+ // src/mcp/workflow-tools.ts
4846
+ var WORKFLOW_TOOL_DEFINITIONS = [
4847
+ // ── Execute ──────────────────────────────────────────────────────────
4848
+ {
4849
+ name: "workflow_run",
4850
+ description: "Trigger a governed workflow run for an agent/skill node and get the executable bundle back (agent instructions, the reachable flow-edge subgraph, and referenced glossary definitions) plus a run_id. The server does not execute \u2014 YOU are the runner: walk the edges, then report progress with workflow_run_step and finish with workflow_run_close.",
4851
+ inputSchema: {
4852
+ type: "object",
4853
+ properties: {
4854
+ agent: { type: "string", description: "Node id of the agent/skill to run" },
4855
+ inputs: { type: "object", description: "Optional caller inputs recorded on the run" },
4856
+ parent_run_id: {
4857
+ type: "string",
4858
+ description: "Optional: spawn this as a sub-agent run under a still-running parent (FEATURE_SUBAGENT_RUNS). Bounded by depth/fan-out and guarded against re-entrancy."
4859
+ }
4860
+ },
4861
+ required: ["agent"]
4862
+ }
4863
+ },
4864
+ {
4865
+ name: "workflow_run_step",
4866
+ description: "Append one step to a run's trace after every action. seq is assigned server-side. For branch steps put the evaluated condition, result and (NL mode) rationale in detail. Fails if the run is already closed.",
4867
+ inputSchema: {
4868
+ type: "object",
4869
+ properties: {
4870
+ run_id: { type: "string" },
4871
+ action: { type: "string", description: "read | write | branch | notify | external" },
4872
+ node_id: { type: "string", description: "Node the step touched (optional)" },
4873
+ edge_id: { type: "string", description: "Edge the step followed (optional)" },
4874
+ detail: { type: "object", description: "Freeform payload: reads, condition results, rationale" }
4875
+ },
4876
+ required: ["run_id", "action"]
4877
+ }
4878
+ },
4879
+ {
4880
+ name: "workflow_run_close",
4881
+ description: "Close a run with a terminal status (succeeded | failed | cancelled) and an optional trace summary. Terminal is final \u2014 a second close returns a conflict.",
4882
+ inputSchema: {
4883
+ type: "object",
4884
+ properties: {
4885
+ run_id: { type: "string" },
4886
+ status: { type: "string", description: "succeeded | failed | cancelled (or running to update trace)" },
4887
+ trace: { type: "object", description: "Optional summary/totals recorded on the run" }
4888
+ },
4889
+ required: ["run_id", "status"]
4890
+ }
4891
+ },
4892
+ {
4893
+ name: "workflow_run_get",
4894
+ description: "Fetch one run's full trace (steps in order). Freeform payloads (inputs, trace, step detail) are redacted unless you have write access to the nest.",
4895
+ inputSchema: {
4896
+ type: "object",
4897
+ properties: { run_id: { type: "string" } },
4898
+ required: ["run_id"]
4899
+ }
4900
+ },
4901
+ {
4902
+ name: "workflow_runs_list",
4903
+ description: "List runs in this nest, most recent first. Filter by agent node id and/or status (running|succeeded|failed|cancelled).",
4904
+ inputSchema: {
4905
+ type: "object",
4906
+ properties: {
4907
+ agent: { type: "string" },
4908
+ status: { type: "string" },
4909
+ limit: { type: "number" }
4910
+ },
4911
+ required: []
4912
+ }
4913
+ },
4914
+ // ── Build: edges ─────────────────────────────────────────────────────
4915
+ {
4916
+ name: "workflow_edges_list",
4917
+ description: "List typed edges in this nest. Optional filters: type (edge-type name or id) and node (id appearing as either endpoint).",
4918
+ inputSchema: {
4919
+ type: "object",
4920
+ properties: { type: { type: "string" }, node: { type: "string" } },
4921
+ required: []
4922
+ }
4923
+ },
4924
+ {
4925
+ name: "workflow_edge_create",
4926
+ description: "Create a typed edge between two existing nodes. The edge type must already exist in the registry. Optional condition in mode structured (JSON predicate {term,op,value}; term must be a defined glossary term), nl (natural-language, judged at run time), or open. Flow-type edges must keep the graph a DAG \u2014 a cycle is rejected.",
4927
+ inputSchema: {
4928
+ type: "object",
4929
+ properties: {
4930
+ from_node: { type: "string" },
4931
+ to_node: { type: "string" },
4932
+ type: { type: "string", description: "Edge-type name or id" },
4933
+ condition_mode: { type: "string", description: "structured | nl | open" },
4934
+ condition: { type: "object", description: "Predicate object (structured) or string (nl/open)" },
4935
+ metadata: { type: "object" }
4936
+ },
4937
+ required: ["from_node", "to_node", "type"]
4938
+ }
4939
+ },
4940
+ {
4941
+ name: "workflow_edge_delete",
4942
+ description: "Delete an edge by id.",
4943
+ inputSchema: {
4944
+ type: "object",
4945
+ properties: { edge_id: { type: "string" } },
4946
+ required: ["edge_id"]
4947
+ }
4948
+ },
4949
+ // ── Build: edge types ────────────────────────────────────────────────
4950
+ {
4951
+ name: "workflow_edge_types_list",
4952
+ description: "List this nest's edge-type registry \u2014 the articulated vocabulary of relations. Seeds the five stock flow types on first use.",
4953
+ inputSchema: { type: "object", properties: {}, required: [] }
4954
+ },
4955
+ {
4956
+ name: "workflow_edge_type_upsert",
4957
+ description: "Create or update an edge type by name (case-insensitive). name is a slug (alphanumeric-with-dashes). Set is_flow true for control-flow relations (they get DAG-validated). condition_schema is an optional {params:[string\u2026], mode_default?} template.",
4958
+ inputSchema: {
4959
+ type: "object",
4960
+ properties: {
4961
+ name: { type: "string" },
4962
+ description: { type: "string" },
4963
+ direction: { type: "string", description: "directed | undirected (default directed)" },
4964
+ is_flow: { type: "boolean" },
4965
+ condition_schema: { type: "object" },
4966
+ color: { type: "string", description: "6-digit hex, e.g. #16a34a" }
4967
+ },
4968
+ required: ["name", "description"]
4969
+ }
4970
+ },
4971
+ {
4972
+ name: "workflow_edge_type_delete",
4973
+ description: "Delete an edge type by id. Refused while any edge still references it.",
4974
+ inputSchema: {
4975
+ type: "object",
4976
+ properties: { edge_type_id: { type: "string" } },
4977
+ required: ["edge_type_id"]
4978
+ }
4979
+ }
4980
+ ];
4981
+ var WORKFLOW_TOOL_NAMES = new Set(WORKFLOW_TOOL_DEFINITIONS.map((t) => t.name));
4982
+ var isWorkflowTool = (name) => WORKFLOW_TOOL_NAMES.has(name);
4983
+ async function nestPermission(ctx) {
4984
+ if (config.AUTH_MODE === "open") return "owner";
4985
+ return resolveNestPermission(ctx.nestId, ctx.userId);
4986
+ }
4987
+ function requireTier(perm, tier) {
4988
+ if (permissionLevel(perm) < permissionLevel(tier)) {
4989
+ throw new ForbiddenError(
4990
+ `This action needs ${tier} access to the nest.`
4991
+ );
4992
+ }
4993
+ }
4994
+ async function loadNestRun(ctx, runId) {
4995
+ if (typeof runId !== "string" || !runId) {
4996
+ throw new ValidationError("run_id is required");
4997
+ }
4998
+ const run = await loadRun(runId);
4999
+ if (!run || run.nest_id !== ctx.nestId) throw new NotFoundError("Run not found");
5000
+ return run;
5001
+ }
5002
+ var json = (v) => JSON.stringify(v, null, 2);
5003
+ async function runWorkflowTool(toolName, args, ctx) {
5004
+ const perm = await nestPermission(ctx);
5005
+ switch (toolName) {
5006
+ // ── Execute ────────────────────────────────────────────────────────
5007
+ case "workflow_run": {
5008
+ requireTier(perm, "write");
5009
+ const triggeredBy = await resolveCallerEmail(ctx.userId);
5010
+ const bundle = await triggerRun({
5011
+ nestId: ctx.nestId,
5012
+ agentNode: String(args.agent ?? ""),
5013
+ inputs: args.inputs,
5014
+ triggeredBy,
5015
+ storage: ctx.storage,
5016
+ parentRunId: args.parent_run_id
5017
+ });
5018
+ return json(bundle);
5019
+ }
5020
+ case "workflow_run_step": {
5021
+ requireTier(perm, "write");
5022
+ const run = await loadNestRun(ctx, args.run_id);
5023
+ const { seq } = await appendRunStep(run.id, {
5024
+ action: args.action,
5025
+ node_id: args.node_id,
5026
+ edge_id: args.edge_id,
5027
+ detail: args.detail
5028
+ });
5029
+ return json({ ok: true, run_id: run.id, seq });
5030
+ }
5031
+ case "workflow_run_close": {
5032
+ requireTier(perm, "write");
5033
+ const run = await loadNestRun(ctx, args.run_id);
5034
+ const { status } = await closeRun(run.id, {
5035
+ status: args.status,
5036
+ trace: args.trace
5037
+ });
5038
+ return json({ ok: true, run_id: run.id, status });
5039
+ }
5040
+ case "workflow_run_get": {
5041
+ requireTier(perm, "read");
5042
+ const run = await loadNestRun(ctx, args.run_id);
5043
+ const canSeeDetail = permissionLevel(perm) >= permissionLevel("write");
5044
+ return json(await getRunTrace(run, { canSeeDetail }));
5045
+ }
5046
+ case "workflow_runs_list": {
5047
+ requireTier(perm, "read");
5048
+ const canSeeDetail = permissionLevel(perm) >= permissionLevel("write");
5049
+ return json(
5050
+ await listRuns(ctx.nestId, {
5051
+ agent: typeof args.agent === "string" ? args.agent : void 0,
5052
+ status: typeof args.status === "string" ? args.status : void 0,
5053
+ limit: typeof args.limit === "number" ? args.limit : void 0,
5054
+ canSeeDetail
5055
+ })
5056
+ );
5057
+ }
5058
+ // ── Build: edges ───────────────────────────────────────────────────
5059
+ case "workflow_edges_list": {
5060
+ requireTier(perm, "read");
5061
+ return json(await listEdges(ctx.nestId, args.type, args.node));
5062
+ }
5063
+ case "workflow_edge_create": {
5064
+ requireTier(perm, "write");
5065
+ return json(await createEdge(ctx, args));
5066
+ }
5067
+ case "workflow_edge_delete": {
5068
+ requireTier(perm, "write");
5069
+ const id = String(args.edge_id ?? "");
5070
+ const row = await getDb().get(
5071
+ "SELECT id FROM edges WHERE id = ? AND nest_id = ?",
5072
+ [id, ctx.nestId]
5073
+ );
5074
+ if (!row) throw new NotFoundError("Edge not found");
5075
+ await getDb().run("DELETE FROM edges WHERE id = ?", [id]);
5076
+ return json({ deleted: true, edge_id: id });
5077
+ }
5078
+ // ── Build: edge types ──────────────────────────────────────────────
5079
+ case "workflow_edge_types_list": {
5080
+ requireTier(perm, "read");
5081
+ await seedDefaultEdgeTypes(ctx.nestId);
5082
+ const rows = await getDb().all(
5083
+ "SELECT * FROM edge_types WHERE nest_id = ? ORDER BY LOWER(name)",
5084
+ [ctx.nestId]
5085
+ );
5086
+ return json({ count: rows.length, edge_types: rows.map(edgeTypeToResponse) });
5087
+ }
5088
+ case "workflow_edge_type_upsert": {
5089
+ requireTier(perm, "write");
5090
+ return json(await upsertEdgeType(ctx, args));
5091
+ }
5092
+ case "workflow_edge_type_delete": {
5093
+ requireTier(perm, "write");
5094
+ return json(await deleteEdgeType(ctx.nestId, String(args.edge_type_id ?? "")));
5095
+ }
5096
+ default:
5097
+ return json({ error: `Unknown workflow tool: ${toolName}` });
5098
+ }
5099
+ }
5100
+ async function listEdges(nestId, type, node) {
5101
+ const db = getDb();
5102
+ let sql = `SELECT e.*, t.id as t_id, t.name as t_name, t.color as t_color, t.is_flow as t_is_flow
5103
+ FROM edges e JOIN edge_types t ON t.id = e.type_id
5104
+ WHERE e.nest_id = ?`;
5105
+ const args = [nestId];
5106
+ if (typeof type === "string" && type.trim()) {
5107
+ sql += " AND (t.id = ? OR LOWER(t.name) = LOWER(?))";
5108
+ args.push(type.trim(), type.trim());
5109
+ }
5110
+ if (typeof node === "string" && node.trim()) {
5111
+ sql += " AND (e.from_node = ? OR e.to_node = ?)";
5112
+ args.push(node.trim(), node.trim());
5113
+ }
5114
+ sql += " ORDER BY e.created_at";
5115
+ const rows = await db.all(sql, args);
5116
+ const edges = rows.map(
5117
+ (r) => toResponse(r, {
5118
+ id: r.t_id,
5119
+ name: r.t_name,
5120
+ color: r.t_color,
5121
+ is_flow: r.t_is_flow
5122
+ })
5123
+ );
5124
+ return { count: edges.length, edges };
5125
+ }
5126
+ async function createEdge(ctx, args) {
5127
+ const fromNode = typeof args.from_node === "string" ? args.from_node.trim() : "";
5128
+ const toNode = typeof args.to_node === "string" ? args.to_node.trim() : "";
5129
+ const typeRef = typeof args.type === "string" ? args.type.trim() : "";
5130
+ if (!fromNode || !toNode || !typeRef) {
5131
+ throw new ValidationError("from_node, to_node, and type are required");
5132
+ }
5133
+ assertSafeNodeId(fromNode);
5134
+ assertSafeNodeId(toNode);
5135
+ const db = getDb();
5136
+ const createdBy = await resolveCallerEmail(ctx.userId);
5137
+ await seedDefaultEdgeTypes(ctx.nestId);
5138
+ const type = await db.get(
5139
+ "SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
5140
+ [ctx.nestId, typeRef, typeRef]
5141
+ );
5142
+ if (!type) {
5143
+ throw new ValidationError(
5144
+ `unknown edge type "${typeRef}" \u2014 create it first with workflow_edge_type_upsert`
5145
+ );
5146
+ }
5147
+ for (const nodeId of [fromNode, toNode]) {
5148
+ try {
5149
+ await ctx.storage.readDocument(nodeId);
5150
+ } catch {
5151
+ throw new ValidationError(`node not found: "${nodeId}"`);
5152
+ }
5153
+ }
5154
+ const { mode, stored } = await validateCondition(
5155
+ ctx.nestId,
5156
+ args.condition_mode,
5157
+ args.condition
5158
+ );
5159
+ const metadata = args.metadata === void 0 || args.metadata === null ? null : JSON.stringify(args.metadata);
5160
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5161
+ const id = uuid8();
5162
+ await db.transaction(async (tx) => {
5163
+ if (type.is_flow) {
5164
+ await assertNoFlowCycle(tx, ctx.nestId, fromNode, toNode);
5165
+ }
5166
+ await tx.run(
5167
+ `INSERT INTO edges
5168
+ (id, nest_id, from_node, to_node, type_id, condition_mode, condition, metadata, created_by, created_at, updated_at)
5169
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
5170
+ [id, ctx.nestId, fromNode, toNode, type.id, mode, stored, metadata, createdBy, now, now]
5171
+ );
5172
+ });
5173
+ const row = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
5174
+ return { edge: toResponse(row, type) };
5175
+ }
5176
+ async function upsertEdgeType(ctx, args) {
5177
+ if (typeof args.name !== "string" || !args.name.trim()) {
5178
+ throw new ValidationError("name is required");
5179
+ }
5180
+ if (typeof args.description !== "string" || !args.description.trim()) {
5181
+ throw new ValidationError("description (the articulation) is required");
5182
+ }
5183
+ const name = args.name.trim();
5184
+ if (name.length > 100 || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
5185
+ throw new ValidationError(
5186
+ "name must be alphanumeric-with-dashes, 100 chars max (e.g. escalates-when)"
5187
+ );
5188
+ }
5189
+ const direction = String(args.direction ?? "directed");
5190
+ if (!["directed", "undirected"].includes(direction)) {
5191
+ throw new ValidationError("direction must be directed | undirected");
5192
+ }
5193
+ const conditionSchema = parseConditionSchema(args.condition_schema);
5194
+ const isFlow = args.is_flow === true ? 1 : 0;
5195
+ let color = null;
5196
+ if (args.color !== void 0 && args.color !== null && args.color !== "") {
5197
+ if (typeof args.color !== "string" || !/^#[0-9a-f]{6}$/i.test(args.color)) {
5198
+ throw new ValidationError("color must be a 6-digit hex, e.g. #16a34a");
5199
+ }
5200
+ color = args.color;
5201
+ }
5202
+ const db = getDb();
5203
+ const definedBy = await resolveCallerEmail(ctx.userId);
5204
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5205
+ await seedDefaultEdgeTypes(ctx.nestId);
5206
+ let created = false;
5207
+ await db.transaction(async (tx) => {
5208
+ const existing = await tx.get(
5209
+ "SELECT id FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
5210
+ [ctx.nestId, name]
5211
+ );
5212
+ if (existing) {
5213
+ await tx.run(
5214
+ `UPDATE edge_types SET name = ?, description = ?, direction = ?, is_flow = ?,
5215
+ condition_schema = ?, color = ?, updated_at = ? WHERE id = ?`,
5216
+ [name, args.description.trim(), direction, isFlow, conditionSchema, color, now, existing.id]
5217
+ );
5218
+ } else {
5219
+ created = true;
5220
+ await tx.run(
5221
+ `INSERT INTO edge_types
5222
+ (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
5223
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
5224
+ [uuid8(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
5225
+ );
5226
+ }
5227
+ });
5228
+ const row = await db.get(
5229
+ "SELECT * FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
5230
+ [ctx.nestId, name]
5231
+ );
5232
+ return { created, edge_type: edgeTypeToResponse(row) };
5233
+ }
5234
+ async function deleteEdgeType(nestId, id) {
5235
+ await getDb().transaction(async (tx) => {
5236
+ const row = await tx.get(
5237
+ "SELECT id FROM edge_types WHERE id = ? AND nest_id = ?",
5238
+ [id, nestId]
5239
+ );
5240
+ if (!row) throw new NotFoundError("Edge type not found");
5241
+ const inUse = await tx.get(
5242
+ "SELECT COUNT(*) as c FROM edges WHERE type_id = ?",
5243
+ [id]
5244
+ );
5245
+ if (inUse.c > 0) {
5246
+ throw new ValidationError(
5247
+ `Edge type is in use by ${inUse.c} edge${inUse.c === 1 ? "" : "s"} \u2014 delete those first.`
5248
+ );
5249
+ }
5250
+ await tx.run("DELETE FROM edge_types WHERE id = ?", [id]);
5251
+ });
5252
+ return { deleted: true, edge_type_id: id };
5253
+ }
5254
+
5255
+ // src/mcp/tools.ts
5256
+ var MAX_HOPS = 10;
5257
+ function normalizeHops(raw) {
5258
+ if (raw == null) return 2;
5259
+ const n = Number(raw);
5260
+ if (!Number.isFinite(n)) return 2;
5261
+ return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
5262
+ }
5263
+ var TOOL_DEFINITIONS = [
5264
+ {
5265
+ name: "context_init",
5266
+ description: "Load the vault's CONTEXT.md which contains operating instructions and behavioral guidelines. Call this FIRST in every conversation.",
5267
+ inputSchema: { type: "object", properties: {} }
5268
+ },
5269
+ {
5270
+ name: "context_overview",
5271
+ description: "Get a complete map of the vault: total node count, types, tags, and a title+snippet for every node.",
5272
+ inputSchema: { type: "object", properties: {} }
5273
+ },
5274
+ {
5275
+ name: "context_search",
5276
+ description: "Full-text keyword search across all node content, titles, tags, and metadata.",
5277
+ inputSchema: {
5278
+ type: "object",
5279
+ properties: {
5280
+ query: { type: "string", description: "Search terms" }
5281
+ },
5282
+ required: ["query"]
5283
+ }
5284
+ },
5285
+ {
5286
+ name: "context_query",
5287
+ description: "Run a structured selector query. Supports: #tag, type:X, [[Title]], scope:X. Combine with +AND, |OR, -NOT.",
5288
+ inputSchema: {
5289
+ type: "object",
5290
+ properties: {
5291
+ query: { type: "string", description: "Selector query" },
5292
+ hops: {
5293
+ type: "number",
5294
+ description: "Graph traversal depth from the matched nodes (default: 2). Use 1 for just the matches + direct links, higher to pull in more of the neighborhood."
5295
+ }
5296
+ },
5297
+ required: ["query"]
5298
+ }
5299
+ },
5300
+ {
5301
+ name: "context_get",
5302
+ description: "Get the FULL content of a specific node by title or ID.",
5303
+ inputSchema: {
5304
+ type: "object",
5305
+ properties: {
5306
+ title: { type: "string", description: "Title of the node" },
5307
+ id: { type: "string", description: "ID of the node" }
5308
+ }
5309
+ }
5310
+ },
5311
+ {
5312
+ name: "context_list",
5313
+ description: "Browse vault contents with optional type, tag, or limit filters.",
5314
+ inputSchema: {
5315
+ type: "object",
5316
+ properties: {
5317
+ type: { type: "string", description: "Filter by node type" },
5318
+ tag: { type: "string", description: "Filter by tag" },
5319
+ limit: { type: "number", description: "Max nodes to return" }
5320
+ }
5321
+ }
5322
+ },
5323
+ {
5324
+ name: "context_resolve",
5325
+ description: "Full context resolution \u2014 run a selector and return complete node content, respecting a token budget.",
5326
+ inputSchema: {
5327
+ type: "object",
5328
+ properties: {
5329
+ selector: {
5330
+ type: "string",
5331
+ description: "Selector query string"
5332
+ },
5333
+ max_tokens: {
3861
5334
  type: "number",
3862
5335
  description: "Approximate token budget (default: 8000)"
3863
5336
  },
@@ -4051,6 +5524,26 @@ var TOOL_DEFINITIONS = [
4051
5524
  required: ["email"]
4052
5525
  }
4053
5526
  },
5527
+ {
5528
+ name: "context_grant",
5529
+ description: "Share a SINGLE document or a FOLDER with a person (not the whole nest). The recipient can reach exactly that document (or everything under that folder) and nothing else. read = view, write = view+edit. Owner/admin only.",
5530
+ inputSchema: {
5531
+ type: "object",
5532
+ properties: {
5533
+ email: { type: "string", description: "Email of the person to share with" },
5534
+ target_type: {
5535
+ type: "string",
5536
+ description: "'document' to share one node, or 'folder' to share a path prefix"
5537
+ },
5538
+ target: {
5539
+ type: "string",
5540
+ description: "Node id (e.g. nodes/win-loss-log) for a document, or a folder prefix (e.g. nodes/gtm/deals) for a folder"
5541
+ },
5542
+ role: { type: "string", description: "read (default) or write" }
5543
+ },
5544
+ required: ["email", "target_type", "target"]
5545
+ }
5546
+ },
4054
5547
  // ─── Unsynced Folder Tools ─────────────────────────────────────────
4055
5548
  {
4056
5549
  name: "context_unsynced_list",
@@ -4084,6 +5577,26 @@ async function resolveLlmBody(ctx, node) {
4084
5577
  }
4085
5578
  }
4086
5579
  async function handleToolCall(toolName, args, ctx) {
5580
+ const started = Date.now();
5581
+ let status = 200;
5582
+ try {
5583
+ return await runTool(toolName, args, ctx);
5584
+ } catch (err) {
5585
+ status = 500;
5586
+ throw err;
5587
+ } finally {
5588
+ void logTraceEvent({
5589
+ kind: "mcp",
5590
+ tool: toolName,
5591
+ nestId: ctx.nestId,
5592
+ userId: ctx.userId,
5593
+ userEmail: ctx.userEmail,
5594
+ status,
5595
+ durationMs: Date.now() - started
5596
+ });
5597
+ }
5598
+ }
5599
+ async function runTool(toolName, args, ctx) {
4087
5600
  const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
4088
5601
  switch (toolName) {
4089
5602
  case "context_init": {
@@ -4415,7 +5928,8 @@ ${list}`;
4415
5928
  version: currentVersion,
4416
5929
  requestedBy: ctx.userEmail,
4417
5930
  note: args.note,
4418
- priority: args.priority
5931
+ priority: args.priority,
5932
+ baseUrl: ctx.baseUrl
4419
5933
  });
4420
5934
  const resolved = await resolveStewardsForNode(
4421
5935
  ctx.nestId,
@@ -4443,7 +5957,8 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
4443
5957
  nodeId: node.id,
4444
5958
  version: currentVersion,
4445
5959
  approvedBy: ctx.userEmail,
4446
- note: args.note
5960
+ note: args.note,
5961
+ baseUrl: ctx.baseUrl
4447
5962
  });
4448
5963
  return `Approved "${args.title}" v${currentVersion}. This version is now live for AI queries.${args.note ? `
4449
5964
  Note: ${args.note}` : ""}`;
@@ -4464,7 +5979,8 @@ Note: ${args.note}` : ""}`;
4464
5979
  nodeId: node.id,
4465
5980
  version: currentVersion,
4466
5981
  rejectedBy: ctx.userEmail,
4467
- note: args.note
5982
+ note: args.note,
5983
+ baseUrl: ctx.baseUrl
4468
5984
  });
4469
5985
  return `Rejected "${args.title}" v${currentVersion}.
4470
5986
  Reason: ${args.note}`;
@@ -4534,104 +6050,796 @@ ${list}`;
4534
6050
  return `Failed to share nest: ${err.message}`;
4535
6051
  }
4536
6052
  }
4537
- case "context_unsynced_list": {
4538
- if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
4539
- return "You don't have permission to list unsynced folders. Server admin only.";
6053
+ case "context_grant": {
6054
+ if (!await canManageStewards(ctx.nestId, ctx.userId)) {
6055
+ return "You don't have permission to share documents in this nest (owner/admin only).";
6056
+ }
6057
+ const targetType = String(args.target_type || "");
6058
+ const target = String(args.target || "").trim();
6059
+ const role = String(args.role || "read");
6060
+ if (!["document", "folder"].includes(targetType)) {
6061
+ return "target_type must be 'document' or 'folder'.";
6062
+ }
6063
+ if (!target) return "target is required (a node id or folder prefix).";
6064
+ if (!["read", "write"].includes(role)) return "role must be read or write.";
6065
+ try {
6066
+ const { createGrant: createGrant2 } = await import("./grants-service-MDLRPMZR.js");
6067
+ const db = (await import("./client-GI74NPIW.js")).getDb();
6068
+ const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
6069
+ const e = normalizeEmail2(String(args.email || ""));
6070
+ if (!e) return "email is required.";
6071
+ let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
6072
+ if (!row) {
6073
+ const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
6074
+ const { v4: uuid12 } = await import("uuid");
6075
+ const id = uuid12();
6076
+ await db.run(
6077
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
6078
+ [id, e, null, await hashPassword2(uuid12())]
6079
+ );
6080
+ row = { id };
6081
+ }
6082
+ await createGrant2({
6083
+ nestId: ctx.nestId,
6084
+ targetType,
6085
+ target,
6086
+ userId: row.id,
6087
+ role,
6088
+ grantedBy: ctx.userEmail
6089
+ });
6090
+ return `Shared ${targetType} \`${target}\` with **${args.email}** as ${role}.`;
6091
+ } catch (err) {
6092
+ return `Failed to share: ${err.message}`;
6093
+ }
6094
+ }
6095
+ case "context_unsynced_list": {
6096
+ if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
6097
+ return "You don't have permission to list unsynced folders. Server admin only.";
6098
+ }
6099
+ const folders = listUnsyncedFolders();
6100
+ if (!folders.length) return "No unsynced folders found.";
6101
+ const list = folders.map(
6102
+ (f, i) => `${i + 1}. **${f.label}** \`${f.name}\` \u2014 ${f.mdCount} md file(s), ${f.sizeBytes} bytes`
6103
+ ).join("\n");
6104
+ return `# Unsynced Folders (${folders.length})
6105
+
6106
+ ${list}`;
6107
+ }
6108
+ case "context_sync_folder": {
6109
+ if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
6110
+ return "You don't have permission to sync folders. Server admin only.";
6111
+ }
6112
+ try {
6113
+ const { nest, documents } = await syncUnsyncedFolder(
6114
+ userId,
6115
+ args.name,
6116
+ userEmail
6117
+ );
6118
+ return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
6119
+ } catch (err) {
6120
+ return `Failed to sync folder: ${err.message}`;
6121
+ }
6122
+ }
6123
+ default:
6124
+ if (isWorkflowTool(toolName)) {
6125
+ return runWorkflowTool(toolName, args, ctx);
6126
+ }
6127
+ return `Unknown tool: ${toolName}`;
6128
+ }
6129
+ }
6130
+
6131
+ // src/mcp/routes.ts
6132
+ import { z } from "zod";
6133
+ var mcpRoutes = new Hono10();
6134
+ async function getUserEmail2(userId) {
6135
+ const db = getDb();
6136
+ const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
6137
+ return user?.email || "anonymous@localhost";
6138
+ }
6139
+ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
6140
+ const server = new McpServer(
6141
+ { name: `contextnest-${nestId}`, version: "1.0.0" },
6142
+ { capabilities: { tools: {} } }
6143
+ );
6144
+ const toolDefs = config.FEATURE_WORKFLOW_PLANE ? [...TOOL_DEFINITIONS, ...WORKFLOW_TOOL_DEFINITIONS] : TOOL_DEFINITIONS;
6145
+ for (const tool of toolDefs) {
6146
+ const props = tool.inputSchema.properties || {};
6147
+ const required = tool.inputSchema.required || [];
6148
+ const shape = {};
6149
+ for (const [key, def] of Object.entries(props)) {
6150
+ let field;
6151
+ if (def.type === "string") field = z.string();
6152
+ else if (def.type === "number") field = z.number();
6153
+ else if (def.type === "array") field = z.array(z.string());
6154
+ else field = z.any();
6155
+ if (!required.includes(key)) field = field.optional();
6156
+ shape[key] = field;
6157
+ }
6158
+ server.tool(tool.name, tool.description, shape, async (args) => {
6159
+ const engine = await engineCache.get(nestId);
6160
+ const text = await handleToolCall(tool.name, args, {
6161
+ storage: engine.storage,
6162
+ queryEngine: engine.query,
6163
+ versionManager: engine.versions,
6164
+ nestId,
6165
+ userId,
6166
+ userEmail,
6167
+ baseUrl
6168
+ });
6169
+ return { content: [{ type: "text", text }] };
6170
+ });
6171
+ }
6172
+ return server;
6173
+ }
6174
+ mcpRoutes.all("/", async (c) => {
6175
+ const nestId = c.req.param("nestId");
6176
+ const userId = c.get("userId");
6177
+ const userEmail = await getUserEmail2(userId);
6178
+ const server = createMcpServerForNest(
6179
+ nestId,
6180
+ userId,
6181
+ userEmail,
6182
+ requestBaseUrl(c.req.url)
6183
+ );
6184
+ const transport = new WebStandardStreamableHTTPServerTransport({
6185
+ sessionIdGenerator: void 0,
6186
+ enableJsonResponse: true
6187
+ });
6188
+ await server.server.connect(transport);
6189
+ try {
6190
+ const response = await transport.handleRequest(c.req.raw);
6191
+ return response;
6192
+ } finally {
6193
+ await transport.close();
6194
+ await server.server.close();
6195
+ }
6196
+ });
6197
+
6198
+ // src/workflow/run-routes.ts
6199
+ import { Hono as Hono11 } from "hono";
6200
+ var runTriggerRoutes = new Hono11();
6201
+ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
6202
+ const nestId = c.req.param("nestId");
6203
+ const agentNode = assertSafeNodeId(c.req.param("agentNode"));
6204
+ const body = await c.req.json().catch(() => ({}));
6205
+ const { storage } = await engineCache.get(nestId);
6206
+ const triggeredBy = await resolveCallerEmail(c.get("userId"));
6207
+ const bundle = await triggerRun({
6208
+ nestId,
6209
+ agentNode,
6210
+ inputs: body.inputs,
6211
+ triggeredBy,
6212
+ storage,
6213
+ parentRunId: body.parent_run_id
6214
+ });
6215
+ return c.json(bundle, 201);
6216
+ });
6217
+ var runListRoutes = new Hono11();
6218
+ runListRoutes.get("/", requireWorkflowPlane, async (c) => {
6219
+ const nestId = c.req.param("nestId");
6220
+ const canSeeDetail = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
6221
+ const result = await listRuns(nestId, {
6222
+ agent: c.req.query("agent") || void 0,
6223
+ status: c.req.query("status") || void 0,
6224
+ limit: Number(c.req.query("limit")) || void 0,
6225
+ canSeeDetail
6226
+ });
6227
+ return c.json(result);
6228
+ });
6229
+ var runDetailRoutes = new Hono11();
6230
+ async function loadRunWithPermission(c, runId, required) {
6231
+ const run = await loadRun(runId);
6232
+ if (!run) throw new NotFoundError("Run not found");
6233
+ const nestScope = c.get("nestScope");
6234
+ if (nestScope && nestScope !== run.nest_id) {
6235
+ throw new ForbiddenError("API key not authorized for this nest");
6236
+ }
6237
+ if (required === "write") {
6238
+ if (isSuspended()) {
6239
+ throw new AppError(503, `Server suspended by PromptOwl: ${getSuspensionReason()}`);
6240
+ }
6241
+ if (!getCurrentLicense()?.valid) {
6242
+ throw new AppError(503, "A valid PromptOwl license is required to write runs.");
6243
+ }
6244
+ }
6245
+ const perm = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(run.nest_id, c.get("userId"));
6246
+ if (perm === "none") {
6247
+ throw new NotFoundError("Run not found");
6248
+ }
6249
+ if (permissionLevel(perm) < permissionLevel(required)) {
6250
+ throw new ForbiddenError(
6251
+ `This run belongs to a nest where you lack ${required} access.`
6252
+ );
6253
+ }
6254
+ c.set("runPermission", perm);
6255
+ return run;
6256
+ }
6257
+ runDetailRoutes.post("/:id/steps", requireWorkflowPlane, async (c) => {
6258
+ const run = await loadRunWithPermission(c, c.req.param("id"), "write");
6259
+ const body = await c.req.json();
6260
+ const { seq } = await appendRunStep(run.id, body);
6261
+ return c.json({ ok: true, seq }, 201);
6262
+ });
6263
+ runDetailRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6264
+ const run = await loadRunWithPermission(c, c.req.param("id"), "write");
6265
+ const body = await c.req.json();
6266
+ const { status } = await closeRun(run.id, body);
6267
+ return c.json({ ok: true, status });
6268
+ });
6269
+ runDetailRoutes.get("/:id", requireWorkflowPlane, async (c) => {
6270
+ const run = await loadRunWithPermission(c, c.req.param("id"), "read");
6271
+ const canSeeDetail = permissionLevel(c.get("runPermission")) >= permissionLevel("write");
6272
+ const result = await getRunTrace(run, { canSeeDetail });
6273
+ return c.json(result);
6274
+ });
6275
+
6276
+ // src/definitions/routes.ts
6277
+ import { Hono as Hono12 } from "hono";
6278
+ import { v4 as uuid9 } from "uuid";
6279
+ var definitionRoutes = new Hono12();
6280
+ definitionRoutes.get("/", async (c) => {
6281
+ const nestId = c.req.param("nestId");
6282
+ const db = getDb();
6283
+ const term = c.req.query("term")?.trim();
6284
+ if (term) {
6285
+ const row = await db.get(
6286
+ "SELECT * FROM definitions WHERE nest_id = ? AND LOWER(term) = LOWER(?)",
6287
+ [nestId, term]
6288
+ );
6289
+ if (!row) throw new NotFoundError(`No definition for "${term}"`);
6290
+ return c.json({ definition: row });
6291
+ }
6292
+ const rows = await db.all(
6293
+ "SELECT * FROM definitions WHERE nest_id = ? ORDER BY LOWER(term)",
6294
+ [nestId]
6295
+ );
6296
+ return c.json({ count: rows.length, definitions: rows });
6297
+ });
6298
+ definitionRoutes.post("/", async (c) => {
6299
+ const nestId = c.req.param("nestId");
6300
+ const body = await c.req.json();
6301
+ if (body.term !== void 0 && typeof body.term !== "string")
6302
+ throw new ValidationError("term must be a string");
6303
+ if (body.definition !== void 0 && typeof body.definition !== "string")
6304
+ throw new ValidationError("definition must be a string");
6305
+ if (body.linked_tag !== void 0 && body.linked_tag !== null && typeof body.linked_tag !== "string")
6306
+ throw new ValidationError("linked_tag must be a string or null");
6307
+ if (body.editing_id !== void 0 && typeof body.editing_id !== "string")
6308
+ throw new ValidationError("editing_id must be a string");
6309
+ const term = body.term?.trim();
6310
+ const definition = body.definition?.trim();
6311
+ const editingId = body.editing_id?.trim() || void 0;
6312
+ if (!term || !definition) {
6313
+ throw new ValidationError("term and definition are required");
6314
+ }
6315
+ if (term.length > 200) {
6316
+ throw new ValidationError("term must be 200 characters or fewer");
6317
+ }
6318
+ const linkedTagProvided = "linked_tag" in body;
6319
+ const linkedTag = body.linked_tag?.trim() ? `#${body.linked_tag.trim().replace(/^#+/, "").toLowerCase()}` : null;
6320
+ const db = getDb();
6321
+ const definedBy = await resolveCallerEmail(c.get("userId"));
6322
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6323
+ const applyUpdate = async (tx, id) => {
6324
+ if (linkedTagProvided) {
6325
+ await tx.run(
6326
+ "UPDATE definitions SET term = ?, definition = ?, linked_tag = ?, defined_by = ?, updated_at = ? WHERE id = ?",
6327
+ [term, definition, linkedTag, definedBy, now, id]
6328
+ );
6329
+ } else {
6330
+ await tx.run(
6331
+ "UPDATE definitions SET term = ?, definition = ?, defined_by = ?, updated_at = ? WHERE id = ?",
6332
+ [term, definition, definedBy, now, id]
6333
+ );
6334
+ }
6335
+ };
6336
+ const { row, created } = await db.transaction(async (tx) => {
6337
+ const existing = await tx.get(
6338
+ "SELECT id FROM definitions WHERE nest_id = ? AND LOWER(term) = LOWER(?)",
6339
+ [nestId, term]
6340
+ );
6341
+ let targetId;
6342
+ let wasCreated = false;
6343
+ if (editingId) {
6344
+ const editing = await tx.get(
6345
+ "SELECT id FROM definitions WHERE id = ? AND nest_id = ?",
6346
+ [editingId, nestId]
6347
+ );
6348
+ if (!editing) throw new NotFoundError("Definition not found");
6349
+ if (existing && existing.id !== editingId) {
6350
+ throw new ConflictError(`A definition for "${term}" already exists`);
4540
6351
  }
4541
- const folders = listUnsyncedFolders();
4542
- if (!folders.length) return "No unsynced folders found.";
4543
- const list = folders.map(
4544
- (f, i) => `${i + 1}. **${f.label}** \`${f.name}\` \u2014 ${f.mdCount} md file(s), ${f.sizeBytes} bytes`
4545
- ).join("\n");
4546
- return `# Unsynced Folders (${folders.length})
4547
-
4548
- ${list}`;
6352
+ await applyUpdate(tx, editingId);
6353
+ targetId = editingId;
6354
+ } else if (existing) {
6355
+ await applyUpdate(tx, existing.id);
6356
+ targetId = existing.id;
6357
+ } else {
6358
+ targetId = uuid9();
6359
+ wasCreated = true;
6360
+ await tx.run(
6361
+ `INSERT INTO definitions (id, nest_id, term, definition, linked_tag, defined_by, created_at, updated_at)
6362
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
6363
+ [targetId, nestId, term, definition, linkedTag, definedBy, now, now]
6364
+ );
4549
6365
  }
4550
- case "context_sync_folder": {
4551
- if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
4552
- return "You don't have permission to sync folders. Server admin only.";
6366
+ const fresh = await tx.get("SELECT * FROM definitions WHERE id = ?", [
6367
+ targetId
6368
+ ]);
6369
+ return { row: fresh, created: wasCreated };
6370
+ });
6371
+ return c.json({ definition: row }, created ? 201 : 200);
6372
+ });
6373
+ definitionRoutes.delete("/:id", async (c) => {
6374
+ const nestId = c.req.param("nestId");
6375
+ const id = c.req.param("id");
6376
+ const db = getDb();
6377
+ const row = await db.get(
6378
+ "SELECT id FROM definitions WHERE id = ? AND nest_id = ?",
6379
+ [id, nestId]
6380
+ );
6381
+ if (!row) throw new NotFoundError("Definition not found");
6382
+ await db.run("DELETE FROM definitions WHERE id = ?", [id]);
6383
+ return c.json({ deleted: true });
6384
+ });
6385
+
6386
+ // src/nests/asset-routes.ts
6387
+ import { Hono as Hono13 } from "hono";
6388
+ import { v4 as uuid10 } from "uuid";
6389
+ import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
6390
+ import { join as join3 } from "path";
6391
+ var assetRoutes = new Hono13();
6392
+ var CONTENT_TYPES = {
6393
+ png: "image/png",
6394
+ jpg: "image/jpeg",
6395
+ jpeg: "image/jpeg",
6396
+ gif: "image/gif",
6397
+ webp: "image/webp"
6398
+ };
6399
+ var SAFE_NAME = /^[0-9a-f-]{36}\.(png|jpe?g|gif|webp)$/;
6400
+ assetRoutes.post("/", async (c) => {
6401
+ const nestId = c.req.param("nestId");
6402
+ const body = await c.req.parseBody();
6403
+ const file = body["file"];
6404
+ if (!(file instanceof File)) {
6405
+ throw new ValidationError('multipart field "file" (an image) is required');
6406
+ }
6407
+ const ext = (file.name.split(".").pop() || "").toLowerCase();
6408
+ if (!CONTENT_TYPES[ext]) {
6409
+ throw new ValidationError(
6410
+ `unsupported image type ".${ext}" \u2014 allowed: ${Object.keys(CONTENT_TYPES).join(", ")}`
6411
+ );
6412
+ }
6413
+ const name = `${uuid10()}.${ext === "jpeg" ? "jpg" : ext}`;
6414
+ const dir = join3(resolveNestPath(nestId), "assets");
6415
+ await mkdir(dir, { recursive: true });
6416
+ await writeFile(join3(dir, name), Buffer.from(await file.arrayBuffer()));
6417
+ return c.json(
6418
+ {
6419
+ file: name,
6420
+ url: `/nests/${nestId}/assets/${name}`,
6421
+ markdown: `![${file.name.replace(/\.[^.]+$/, "")}](/nests/${nestId}/assets/${name})`
6422
+ },
6423
+ 201
6424
+ );
6425
+ });
6426
+ assetRoutes.get("/:file", async (c) => {
6427
+ const nestId = c.req.param("nestId");
6428
+ const name = c.req.param("file");
6429
+ if (!SAFE_NAME.test(name)) {
6430
+ throw new ValidationError("invalid asset name");
6431
+ }
6432
+ let bytes;
6433
+ try {
6434
+ bytes = await readFile2(join3(resolveNestPath(nestId), "assets", name));
6435
+ } catch {
6436
+ throw new NotFoundError("Asset not found");
6437
+ }
6438
+ const ext = name.split(".").pop();
6439
+ return c.body(new Uint8Array(bytes), 200, {
6440
+ "Content-Type": CONTENT_TYPES[ext],
6441
+ // uuid names never change content — cache hard.
6442
+ "Cache-Control": "public, max-age=31536000, immutable"
6443
+ });
6444
+ });
6445
+
6446
+ // src/mcp/server-routes.ts
6447
+ import { Hono as Hono14 } from "hono";
6448
+ import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
6449
+ import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6450
+ import { z as z2 } from "zod";
6451
+ async function getUserEmail3(userId) {
6452
+ const db = getDb();
6453
+ const user = await db.get("SELECT email FROM users WHERE id = ?", [
6454
+ userId
6455
+ ]);
6456
+ return user?.email || "anonymous@localhost";
6457
+ }
6458
+ async function canRead(nestId, userId) {
6459
+ if (config.AUTH_MODE === "open") return true;
6460
+ const perm = await resolveNestPermission(nestId, userId);
6461
+ return permissionLevel(perm) >= permissionLevel("read");
6462
+ }
6463
+ async function resolveNestRef(ref, userId) {
6464
+ const db = getDb();
6465
+ const byId = await db.get("SELECT * FROM nests WHERE id = ?", [ref]);
6466
+ if (byId) return byId;
6467
+ const byName = await db.all(
6468
+ "SELECT * FROM nests WHERE LOWER(name) = LOWER(?) OR LOWER(slug) = LOWER(?)",
6469
+ [ref, ref]
6470
+ );
6471
+ if (byName.length <= 1) return byName[0] ?? null;
6472
+ const readable = (await Promise.all(
6473
+ byName.map(async (n) => await canRead(n.id, userId) ? n : null)
6474
+ )).filter((n) => n !== null);
6475
+ return readable.length === 1 ? readable[0] : null;
6476
+ }
6477
+ async function buildNestIndex(userId, nestScope) {
6478
+ const [owned, shared, publicExtras] = await Promise.all([
6479
+ listNests(userId),
6480
+ listSharedNests(userId),
6481
+ listPublicNests(userId)
6482
+ ]);
6483
+ const seen = /* @__PURE__ */ new Set();
6484
+ const rows = [];
6485
+ for (const n of [...owned, ...shared, ...publicExtras]) {
6486
+ if (seen.has(n.id)) continue;
6487
+ if (nestScope && n.id !== nestScope) continue;
6488
+ seen.add(n.id);
6489
+ rows.push(n);
6490
+ }
6491
+ return Promise.all(
6492
+ rows.map(async (n) => {
6493
+ let permission = "read";
6494
+ try {
6495
+ permission = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(n.id, userId);
6496
+ } catch {
4553
6497
  }
6498
+ let document_count = null;
4554
6499
  try {
4555
- const { nest, documents } = await syncUnsyncedFolder(
4556
- userId,
4557
- args.name,
4558
- userEmail
4559
- );
4560
- return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
4561
- } catch (err) {
4562
- return `Failed to sync folder: ${err.message}`;
6500
+ const { storage } = await engineCache.get(n.id);
6501
+ document_count = (await storage.discoverDocuments()).length;
6502
+ } catch {
4563
6503
  }
4564
- }
4565
- default:
4566
- return `Unknown tool: ${toolName}`;
6504
+ return {
6505
+ id: n.id,
6506
+ name: n.name,
6507
+ description: n.description,
6508
+ visibility: n.visibility,
6509
+ permission,
6510
+ document_count
6511
+ };
6512
+ })
6513
+ );
6514
+ }
6515
+ function renderNestIndexMarkdown(nests, baseUrl) {
6516
+ const lines = [
6517
+ "# Nest index",
6518
+ "",
6519
+ `${nests.length} nest${nests.length === 1 ? "" : "s"} accessible with these credentials. Full interaction manual: ${baseUrl}/llms.txt`,
6520
+ ""
6521
+ ];
6522
+ for (const n of nests) {
6523
+ lines.push(`## ${n.name}`);
6524
+ lines.push("");
6525
+ lines.push(`- id: \`${n.id}\``);
6526
+ if (n.description) lines.push(`- description: ${n.description}`);
6527
+ lines.push(`- permission: ${n.permission} \xB7 visibility: ${n.visibility}`);
6528
+ if (n.document_count !== null)
6529
+ lines.push(`- documents: ${n.document_count}`);
6530
+ lines.push(
6531
+ `- retrieve: \`POST ${baseUrl}/nests/${n.id}/context\` \xB7 mcp: \`${baseUrl}/nests/${n.id}/mcp\` \xB7 export: \`${baseUrl}/nests/${n.id}/export?format=markdown\``
6532
+ );
6533
+ lines.push("");
4567
6534
  }
6535
+ return lines.join("\n");
4568
6536
  }
4569
-
4570
- // src/mcp/routes.ts
4571
- import { z } from "zod";
4572
- var mcpRoutes = new Hono7();
4573
- async function getUserEmail2(userId) {
4574
- const db = getDb();
4575
- const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
4576
- return user?.email || "anonymous@localhost";
6537
+ var NEST_ARG = {
6538
+ nest: {
6539
+ type: "string",
6540
+ description: "Target nest \u2014 id, or exact name/slug (case-insensitive)"
6541
+ }
6542
+ };
6543
+ var SERVER_TOOL_DEFINITIONS = [
6544
+ {
6545
+ name: "nest_index",
6546
+ description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST, then target a nest with the other tools.",
6547
+ inputSchema: { type: "object", properties: {} }
6548
+ },
6549
+ {
6550
+ name: "context_query",
6551
+ description: "Run a structured selector query against one nest. Selector grammar: #tag, type:X, [[Title]], scope:X, combined with +AND, |OR, -NOT.",
6552
+ inputSchema: {
6553
+ type: "object",
6554
+ properties: {
6555
+ ...NEST_ARG,
6556
+ query: { type: "string", description: "Selector query" },
6557
+ hops: {
6558
+ type: "number",
6559
+ description: "Graph traversal depth from the matches (default: 2)"
6560
+ }
6561
+ },
6562
+ required: ["nest", "query"]
6563
+ }
6564
+ },
6565
+ {
6566
+ name: "context_search",
6567
+ description: "Full-text keyword search across one nest.",
6568
+ inputSchema: {
6569
+ type: "object",
6570
+ properties: {
6571
+ ...NEST_ARG,
6572
+ query: { type: "string", description: "Search terms" }
6573
+ },
6574
+ required: ["nest", "query"]
6575
+ }
6576
+ },
6577
+ {
6578
+ name: "context_get",
6579
+ description: "Get the FULL content of a node in a nest by title or id.",
6580
+ inputSchema: {
6581
+ type: "object",
6582
+ properties: {
6583
+ ...NEST_ARG,
6584
+ title: { type: "string", description: "Title of the node" },
6585
+ id: { type: "string", description: "ID of the node" }
6586
+ },
6587
+ required: ["nest"]
6588
+ }
6589
+ },
6590
+ {
6591
+ name: "context_list",
6592
+ description: "Browse one nest's contents with optional type, tag, or limit filters.",
6593
+ inputSchema: {
6594
+ type: "object",
6595
+ properties: {
6596
+ ...NEST_ARG,
6597
+ type: { type: "string", description: "Filter by node type" },
6598
+ tag: { type: "string", description: "Filter by tag" },
6599
+ limit: { type: "number", description: "Max nodes to return" }
6600
+ },
6601
+ required: ["nest"]
6602
+ }
6603
+ },
6604
+ {
6605
+ name: "context_overview",
6606
+ description: "Map of one nest: node count, types, tags, title+snippet per node.",
6607
+ inputSchema: {
6608
+ type: "object",
6609
+ properties: { ...NEST_ARG },
6610
+ required: ["nest"]
6611
+ }
6612
+ },
6613
+ {
6614
+ name: "context_resolve",
6615
+ description: "Full deterministic context resolution against one nest \u2014 selector + hops + token budget, complete node bodies.",
6616
+ inputSchema: {
6617
+ type: "object",
6618
+ properties: {
6619
+ ...NEST_ARG,
6620
+ selector: { type: "string", description: "Selector query string" },
6621
+ max_tokens: {
6622
+ type: "number",
6623
+ description: "Approximate token budget (default: 8000)"
6624
+ },
6625
+ hops: {
6626
+ type: "number",
6627
+ description: "Graph traversal depth from the matches (default: 2)"
6628
+ }
6629
+ },
6630
+ required: ["nest", "selector"]
6631
+ }
6632
+ }
6633
+ ];
6634
+ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
6635
+ if (toolName === "nest_index") {
6636
+ const nests = await buildNestIndex(userId, nestScope);
6637
+ if (!nests.length) return "No nests accessible with these credentials.";
6638
+ return renderNestIndexMarkdown(nests, baseUrl);
6639
+ }
6640
+ const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
6641
+ const nest = await resolveNestRef(String(args.nest ?? ""), userId);
6642
+ if (!nest) return inaccessible;
6643
+ if (nestScope && nest.id !== nestScope) return inaccessible;
6644
+ if (!await canRead(nest.id, userId)) return inaccessible;
6645
+ const engine = await engineCache.get(nest.id);
6646
+ return handleToolCall(toolName, args, {
6647
+ storage: engine.storage,
6648
+ queryEngine: engine.query,
6649
+ versionManager: engine.versions,
6650
+ nestId: nest.id,
6651
+ userId,
6652
+ userEmail,
6653
+ baseUrl
6654
+ });
4577
6655
  }
4578
- function createMcpServerForNest(nestId, userId, userEmail) {
4579
- const server = new McpServer(
4580
- { name: `contextnest-${nestId}`, version: "1.0.0" },
6656
+ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
6657
+ const server = new McpServer2(
6658
+ { name: "contextnest-server", version: "1.0.0" },
4581
6659
  { capabilities: { tools: {} } }
4582
6660
  );
4583
- for (const tool of TOOL_DEFINITIONS) {
6661
+ for (const tool of SERVER_TOOL_DEFINITIONS) {
4584
6662
  const props = tool.inputSchema.properties || {};
4585
6663
  const required = tool.inputSchema.required || [];
4586
6664
  const shape = {};
4587
6665
  for (const [key, def] of Object.entries(props)) {
4588
6666
  let field;
4589
- if (def.type === "string") field = z.string();
4590
- else if (def.type === "number") field = z.number();
4591
- else if (def.type === "array") field = z.array(z.string());
4592
- else field = z.any();
6667
+ if (def.type === "string") field = z2.string();
6668
+ else if (def.type === "number") field = z2.number();
6669
+ else if (def.type === "array") field = z2.array(z2.string());
6670
+ else field = z2.any();
4593
6671
  if (!required.includes(key)) field = field.optional();
4594
6672
  shape[key] = field;
4595
6673
  }
4596
6674
  server.tool(tool.name, tool.description, shape, async (args) => {
4597
- const engine = await engineCache.get(nestId);
4598
- const text = await handleToolCall(tool.name, args, {
4599
- storage: engine.storage,
4600
- queryEngine: engine.query,
4601
- versionManager: engine.versions,
4602
- nestId,
6675
+ const text = await handleServerToolCall(
6676
+ tool.name,
6677
+ args,
4603
6678
  userId,
4604
- userEmail
4605
- });
6679
+ userEmail,
6680
+ nestScope,
6681
+ baseUrl
6682
+ );
4606
6683
  return { content: [{ type: "text", text }] };
4607
6684
  });
4608
6685
  }
4609
6686
  return server;
4610
6687
  }
4611
- mcpRoutes.all("/", async (c) => {
4612
- const nestId = c.req.param("nestId");
6688
+ var serverMcpRoutes = new Hono14();
6689
+ serverMcpRoutes.all("/", async (c) => {
4613
6690
  const userId = c.get("userId");
4614
- const userEmail = await getUserEmail2(userId);
4615
- const server = createMcpServerForNest(nestId, userId, userEmail);
4616
- const transport = new WebStandardStreamableHTTPServerTransport({
6691
+ const userEmail = await getUserEmail3(userId);
6692
+ const server = createServerMcp(
6693
+ userId,
6694
+ userEmail,
6695
+ c.get("nestScope"),
6696
+ requestBaseUrl(c.req.url)
6697
+ );
6698
+ const transport = new WebStandardStreamableHTTPServerTransport2({
4617
6699
  sessionIdGenerator: void 0,
4618
6700
  enableJsonResponse: true
4619
6701
  });
4620
6702
  await server.server.connect(transport);
4621
6703
  try {
4622
- const response = await transport.handleRequest(c.req.raw);
4623
- return response;
6704
+ return await transport.handleRequest(c.req.raw);
4624
6705
  } finally {
4625
6706
  await transport.close();
4626
6707
  await server.server.close();
4627
6708
  }
4628
6709
  });
6710
+ var nestIndexRoutes = new Hono14();
6711
+ nestIndexRoutes.get("/", async (c) => {
6712
+ const userId = c.get("userId");
6713
+ const nests = await buildNestIndex(userId, c.get("nestScope"));
6714
+ if (c.req.query("format") === "json") {
6715
+ return c.json({ count: nests.length, nests });
6716
+ }
6717
+ const md = renderNestIndexMarkdown(nests, requestBaseUrl(c.req.url));
6718
+ return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
6719
+ });
6720
+ function renderLlmsTxt(baseUrl) {
6721
+ const B = baseUrl || "<server-url>";
6722
+ return `# ContextNest Community Server
6723
+
6724
+ Self-hosted context governance server. Nests are versioned knowledge vaults
6725
+ of markdown documents with tags, wiki-links ([[Title]]), and steward
6726
+ governance. This file is the complete interaction manual for agents.
6727
+
6728
+ ## Authentication
6729
+
6730
+ Every request (except this file and /health): \`Authorization: Bearer cnst_<api-key>\`.
6731
+ Keys are minted in the web UI (Connect dialog \u2192 Generate Key). A key is
6732
+ either user-level (sees every nest the user can read) or scoped to one nest.
6733
+ Servers in "open" auth mode need no key.
6734
+
6735
+ ## Start here
6736
+
6737
+ 1. \`GET ${B}/index\` \u2014 your grounded nest index (markdown; \`?format=json\` for JSON).
6738
+ One entry per accessible nest: name, id, permission, document count, and
6739
+ the per-nest URLs below.
6740
+ 2. Pick a nest id, then retrieve with the API or MCP.
6741
+
6742
+ ## Deterministic retrieval API
6743
+
6744
+ Same inputs \u2192 same context, every time. The primary call:
6745
+
6746
+ \`\`\`
6747
+ POST ${B}/nests/<nest-id>/context
6748
+ Content-Type: application/json
6749
+ { "selector": "#gtm", "hops": 2, "max_tokens": 4000, "include_drafts": false }
6750
+ \`\`\`
6751
+
6752
+ Returns \`{ context, nodes[], trace }\` \u2014 assembled markdown plus a trace with
6753
+ \`hops_used\` and \`nodes_traversed\` so you can audit exactly what was pulled.
6754
+
6755
+ Selector grammar:
6756
+ - \`#tag\` \u2014 nodes carrying a tag
6757
+ - \`type:document\` \u2014 nodes of a type
6758
+ - \`[[Title]]\` \u2014 a node by exact title
6759
+ - \`scope:team\` \u2014 nodes by visibility scope
6760
+ - Combine: \`+AND\`, \`|OR\`, \`-NOT\` (e.g. \`#gtm+type:document-#draft\`)
6761
+
6762
+ Graph-hop logic: \`hops\` is the traversal depth over wiki-links from the
6763
+ selector matches. \`hops: 0\` = exactly the matches; \`hops: 1\` = matches plus
6764
+ directly linked nodes; \`hops: 2\` (default) and up walk the link graph
6765
+ further out. For an exact set with no expansion, OR the selectors together
6766
+ (\`[[A]]|[[B]]\`) and pass \`hops: 0\`.
6767
+
6768
+ Skip logic \u2014 what retrieval excludes and why:
6769
+ - Draft/unapproved documents are skipped unless \`include_drafts: true\`
6770
+ (and public/read-only callers always get approved content only).
6771
+ - \`max_tokens\` is a budget: documents that don't fit are skipped, and the
6772
+ trace reports \`truncated_by_budget\` so silence never means "complete".
6773
+ - Documents are deduped by id when a title match and a selector match overlap.
6774
+
6775
+ Other read endpoints (all under \`${B}/nests/<nest-id>\`):
6776
+ - \`POST /query\` \u2014 \`{ "query": "<selector>", "hops": N }\`; matches + snippets, no bodies
6777
+ - \`GET /search?q=<terms>\` \u2014 full-text search
6778
+ - \`GET /overview\` \u2014 node count, types, tags
6779
+ - \`GET /nodes\` \u2014 list; \`GET /nodes/<node-id>?format=markdown\` \u2014 one document
6780
+ - \`GET /export?format=markdown\` \u2014 whole nest as one markdown file (optional \`selector\`, \`max_tokens\`)
6781
+ - \`GET /graph\` \u2014 ontology graph (nodes, links, tags, stewards)
6782
+
6783
+ Write endpoints (need write permission):
6784
+ - \`POST /nodes\` \u2014 \`{ "title", "content", "tags"?, "type"?, "folder"? }\`
6785
+ - \`PATCH /nodes/<node-id>\` \u2014 \`{ "content"? , "append"?, "tags"?, "title"? }\`
6786
+ - \`DELETE /nodes/<node-id>\`
6787
+ - Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`
6788
+
6789
+ ## MCP (Model Context Protocol)
6790
+
6791
+ Streamable HTTP, stateless. Two endpoints:
6792
+
6793
+ - \`${B}/mcp\` \u2014 server-level, one connection for everything readable.
6794
+ Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
6795
+ \`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
6796
+ each takes a \`nest\` argument (id or exact name) plus the same
6797
+ selector/hops/max_tokens parameters as the REST API. Read-only.
6798
+ - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
6799
+ governance (create/update, submit/approve/reject, stewards, sharing).
6800
+
6801
+ Claude Code / Cursor / VS Code connect natively via HTTP with the
6802
+ \`Authorization: Bearer\` header. Claude Desktop needs the mcp-remote bridge:
6803
+
6804
+ \`\`\`
6805
+ npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
6806
+ \`\`\`
6807
+
6808
+ ## ctx CLI (npm: @promptowl/contextnest-cli)
6809
+
6810
+ \`npm i -g @promptowl/contextnest-cli\`
6811
+
6812
+ - \`ctx push --server ${B} --nest <nest-id> --key <api-key>\` \u2014 upload a local
6813
+ vault into a nest (add \`--include-drafts\` for unpublished nodes).
6814
+ - All other ctx commands (\`query\`, \`add\`, \`search\`, \`checkpoint\`) operate on
6815
+ a LOCAL vault directory only. For remote reads use the API or MCP above \u2014
6816
+ there is currently no remote \`ctx query\`.
6817
+
6818
+ ## npm packages
6819
+
6820
+ - \`@promptowl/contextnest-community\` \u2014 this server
6821
+ - \`@promptowl/contextnest-cli\` \u2014 the ctx CLI
6822
+ - \`@promptowl/contextnest-engine\` \u2014 vault storage + selector/graph engine
6823
+
6824
+ ## Conventions
6825
+
6826
+ - Node ids are paths: \`nodes/<slug>\` or nested \`nodes/<folder>/<slug>\`.
6827
+ - Tags are \`#lowercase\`. Wiki-links are \`[[Exact Title]]\`.
6828
+ - Every write is versioned and hash-chained; governed nests route writes
6829
+ through steward review before they become AI-visible.
6830
+ `;
6831
+ }
6832
+ var llmsTxtRoutes = new Hono14();
6833
+ llmsTxtRoutes.get("/", (c) => {
6834
+ const md = renderLlmsTxt(requestBaseUrl(c.req.url));
6835
+ return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
6836
+ });
4629
6837
 
4630
6838
  // src/governance/routes.ts
4631
- import { Hono as Hono8 } from "hono";
6839
+ import { Hono as Hono15 } from "hono";
4632
6840
 
4633
6841
  // src/governance/comment-service.ts
4634
- import { v4 as uuid4 } from "uuid";
6842
+ import { v4 as uuid11 } from "uuid";
4635
6843
  async function createComment(params) {
4636
6844
  const db = getDb();
4637
6845
  const body = (params.body ?? "").trim();
@@ -4647,7 +6855,7 @@ async function createComment(params) {
4647
6855
  throw new Error("Parent comment not found on this node");
4648
6856
  }
4649
6857
  }
4650
- const id = uuid4();
6858
+ const id = uuid11();
4651
6859
  await db.run(
4652
6860
  `INSERT INTO comments
4653
6861
  (id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
@@ -4847,7 +7055,7 @@ function rowToComment(row) {
4847
7055
 
4848
7056
  // src/governance/stewards-parser.ts
4849
7057
  import { readFileSync as readFileSync2, existsSync } from "fs";
4850
- import { join as join3 } from "path";
7058
+ import { join as join4 } from "path";
4851
7059
  function parseStewardsYaml(content) {
4852
7060
  const result = { version: 1 };
4853
7061
  const lines = content.split("\n");
@@ -4913,9 +7121,9 @@ function parseEntry(str) {
4913
7121
  function loadStewardsConfig(nestId) {
4914
7122
  const nestPath = resolveNestPath(nestId);
4915
7123
  const candidates = [
4916
- join3(nestPath, "stewards.yaml"),
4917
- join3(nestPath, "stewards.yml"),
4918
- join3(nestPath, ".context", "stewards.yaml")
7124
+ join4(nestPath, "stewards.yaml"),
7125
+ join4(nestPath, "stewards.yml"),
7126
+ join4(nestPath, ".context", "stewards.yaml")
4919
7127
  ];
4920
7128
  for (const candidatePath of candidates) {
4921
7129
  if (existsSync(candidatePath)) {
@@ -4932,7 +7140,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
4932
7140
  if (Number.isNaN(n) || n < 1) return def;
4933
7141
  return Math.min(n, max);
4934
7142
  }
4935
- var governanceRoutes = new Hono8();
7143
+ var governanceRoutes = new Hono15();
4936
7144
  governanceRoutes.get("/stewards", async (c) => {
4937
7145
  const nestId = c.req.param("nestId");
4938
7146
  const scope = c.req.query("scope");
@@ -4961,7 +7169,7 @@ governanceRoutes.get("/stewards", async (c) => {
4961
7169
  governanceRoutes.post("/stewards", async (c) => {
4962
7170
  const nestId = c.req.param("nestId");
4963
7171
  const body = await c.req.json();
4964
- const assignedBy = await getUserEmail3(c);
7172
+ const assignedBy = await getUserEmail4(c);
4965
7173
  if (!body.scope) throw new ValidationError("scope is required");
4966
7174
  if (body.scope === "folder") {
4967
7175
  throw new ValidationError(
@@ -5036,7 +7244,7 @@ governanceRoutes.get("/review-queue", async (c) => {
5036
7244
  limit,
5037
7245
  offset
5038
7246
  });
5039
- const email = await getUserEmail3(c);
7247
+ const email = await getUserEmail4(c);
5040
7248
  const canReviewCache = /* @__PURE__ */ new Map();
5041
7249
  const requests = [];
5042
7250
  for (const r of result.requests) {
@@ -5060,7 +7268,7 @@ governanceRoutes.get("/external-edits", async (c) => {
5060
7268
  });
5061
7269
  governanceRoutes.post("/external-edits/scan", async (c) => {
5062
7270
  const nestId = c.req.param("nestId");
5063
- const actor = await getUserEmail3(c);
7271
+ const actor = await getUserEmail4(c);
5064
7272
  const result = await scanNestForDrift(nestId, actor);
5065
7273
  return c.json(result);
5066
7274
  });
@@ -5070,7 +7278,7 @@ governanceRoutes.get("/activity", async (c) => {
5070
7278
  const activity = await getActivity({ nestId, limit });
5071
7279
  return c.json({ activity });
5072
7280
  });
5073
- var governanceNodeRoutes = new Hono8();
7281
+ var governanceNodeRoutes = new Hono15();
5074
7282
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
5075
7283
  const nestId = c.req.param("nestId");
5076
7284
  const nodeId = c.req.param("nodeId");
@@ -5129,7 +7337,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
5129
7337
  const nestId = c.req.param("nestId");
5130
7338
  const nodeId = c.req.param("nodeId");
5131
7339
  const body = await c.req.json();
5132
- const author = await getUserEmail3(c);
7340
+ const author = await getUserEmail4(c);
5133
7341
  try {
5134
7342
  const comment = await createComment({
5135
7343
  nestId,
@@ -5153,7 +7361,7 @@ governanceNodeRoutes.post(
5153
7361
  const nestId = c.req.param("nestId");
5154
7362
  const nodeId = c.req.param("nodeId");
5155
7363
  const commentId = c.req.param("commentId");
5156
- const resolvedBy = await getUserEmail3(c);
7364
+ const resolvedBy = await getUserEmail4(c);
5157
7365
  try {
5158
7366
  const comment = await resolveComment({
5159
7367
  nestId,
@@ -5186,7 +7394,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
5186
7394
  if (currentVersion === 0) {
5187
7395
  throw new ValidationError("Node has no versions to review");
5188
7396
  }
5189
- const userEmail = await getUserEmail3(c);
7397
+ const userEmail = await getUserEmail4(c);
5190
7398
  let request;
5191
7399
  try {
5192
7400
  request = await submitForReview({
@@ -5195,7 +7403,8 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
5195
7403
  version: currentVersion,
5196
7404
  requestedBy: userEmail,
5197
7405
  note: body.note,
5198
- priority: body.priority
7406
+ priority: body.priority,
7407
+ baseUrl: requestBaseUrl(c.req.url)
5199
7408
  });
5200
7409
  } catch (err) {
5201
7410
  const msg = err instanceof Error ? err.message : String(err);
@@ -5221,7 +7430,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
5221
7430
  const nestId = c.req.param("nestId");
5222
7431
  const nodeId = c.req.param("nodeId");
5223
7432
  const body = await c.req.json();
5224
- const userEmail = await getUserEmail3(c);
7433
+ const userEmail = await getUserEmail4(c);
5225
7434
  const isAdmin = isSuperAdmin(userEmail);
5226
7435
  try {
5227
7436
  const request = await approve({
@@ -5230,7 +7439,8 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
5230
7439
  version: await getCurrentVersion(nestId, nodeId),
5231
7440
  approvedBy: userEmail,
5232
7441
  note: body.note,
5233
- override: body.override && isAdmin
7442
+ override: body.override && isAdmin,
7443
+ baseUrl: requestBaseUrl(c.req.url)
5234
7444
  });
5235
7445
  return c.json({ review: request });
5236
7446
  } catch (err) {
@@ -5244,14 +7454,15 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
5244
7454
  if (!body.note) {
5245
7455
  throw new ValidationError("Rejection note is required");
5246
7456
  }
5247
- const userEmail = await getUserEmail3(c);
7457
+ const userEmail = await getUserEmail4(c);
5248
7458
  try {
5249
7459
  const request = await reject({
5250
7460
  nestId,
5251
7461
  nodeId,
5252
7462
  version: await getCurrentVersion(nestId, nodeId),
5253
7463
  rejectedBy: userEmail,
5254
- note: body.note
7464
+ note: body.note,
7465
+ baseUrl: requestBaseUrl(c.req.url)
5255
7466
  });
5256
7467
  return c.json({ review: request });
5257
7468
  } catch (err) {
@@ -5261,19 +7472,19 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
5261
7472
  governanceNodeRoutes.get("/:nodeId{.+}/can-access", async (c) => {
5262
7473
  const nestId = c.req.param("nestId");
5263
7474
  const nodeId = c.req.param("nodeId");
5264
- const userEmail = await getUserEmail3(c);
7475
+ const userEmail = await getUserEmail4(c);
5265
7476
  return c.json(await canUserAccess(nestId, nodeId, userEmail));
5266
7477
  });
5267
7478
  governanceNodeRoutes.get("/:nodeId{.+}/can-approve", async (c) => {
5268
7479
  const nestId = c.req.param("nestId");
5269
7480
  const nodeId = c.req.param("nodeId");
5270
- const userEmail = await getUserEmail3(c);
7481
+ const userEmail = await getUserEmail4(c);
5271
7482
  return c.json(await canUserApprove(nestId, nodeId, userEmail));
5272
7483
  });
5273
7484
  governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
5274
7485
  const nestId = c.req.param("nestId");
5275
7486
  const nodeId = c.req.param("nodeId");
5276
- const userEmail = await getUserEmail3(c);
7487
+ const userEmail = await getUserEmail4(c);
5277
7488
  return c.json(await canUserEdit(nestId, nodeId, userEmail));
5278
7489
  });
5279
7490
  governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
@@ -5306,7 +7517,7 @@ governanceNodeRoutes.post(
5306
7517
  const nodeId = c.req.param("nodeId");
5307
7518
  const suggestionId = c.req.param("suggestionId");
5308
7519
  const body = await c.req.json().catch(() => ({}));
5309
- const actor = await getUserEmail3(c);
7520
+ const actor = await getUserEmail4(c);
5310
7521
  try {
5311
7522
  const result = await approveExternalEdit({
5312
7523
  nestId,
@@ -5342,7 +7553,7 @@ governanceNodeRoutes.post(
5342
7553
  if (!body.reason) {
5343
7554
  throw new ValidationError("Rejection reason is required");
5344
7555
  }
5345
- const actor = await getUserEmail3(c);
7556
+ const actor = await getUserEmail4(c);
5346
7557
  try {
5347
7558
  const result = await rejectExternalEdit({
5348
7559
  nestId,
@@ -5363,7 +7574,7 @@ governanceNodeRoutes.post(
5363
7574
  governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
5364
7575
  const nestId = c.req.param("nestId");
5365
7576
  const nodeId = c.req.param("nodeId");
5366
- const userEmail = await getUserEmail3(c);
7577
+ const userEmail = await getUserEmail4(c);
5367
7578
  const request = await cancelReview({
5368
7579
  nestId,
5369
7580
  nodeId,
@@ -5371,7 +7582,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
5371
7582
  });
5372
7583
  return c.json({ review: request });
5373
7584
  });
5374
- async function getUserEmail3(c) {
7585
+ async function getUserEmail4(c) {
5375
7586
  const userId = c.get("userId");
5376
7587
  const db = getDb();
5377
7588
  const user = await db.get(
@@ -5402,12 +7613,12 @@ async function ensureAnonymousUser() {
5402
7613
  // src/app.ts
5403
7614
  import { serveStatic } from "@hono/node-server/serve-static";
5404
7615
  import { fileURLToPath } from "url";
5405
- import { dirname, join as join4, relative as relative2 } from "path";
7616
+ import { dirname, join as join5, relative as relative2 } from "path";
5406
7617
  import { existsSync as existsSync2 } from "fs";
5407
7618
  var HERE = dirname(fileURLToPath(import.meta.url));
5408
7619
  var UI_DIR_CANDIDATES = [
5409
- join4(HERE, "web3"),
5410
- join4(process.cwd(), "dist", "web3")
7620
+ join5(HERE, "web3"),
7621
+ join5(process.cwd(), "dist", "web3")
5411
7622
  ];
5412
7623
  var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
5413
7624
  var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
@@ -5444,7 +7655,7 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
5444
7655
  return c.json({ error: "Missing or invalid credentials" }, 401);
5445
7656
  });
5446
7657
  function createApp() {
5447
- const app = new Hono9();
7658
+ const app = new Hono16({ router: new LinearRouter() });
5448
7659
  const corsOrigins = config.CORS_ORIGINS;
5449
7660
  app.use(
5450
7661
  "*",
@@ -5465,6 +7676,22 @@ function createApp() {
5465
7676
  }
5466
7677
  return next();
5467
7678
  });
7679
+ app.use("*", async (c, next) => {
7680
+ const path = c.req.path;
7681
+ const traced = /^\/(nests|auth|admin|stats|license|index|mcp)(\/|$)/.test(path) && !path.endsWith("/mcp");
7682
+ const started = Date.now();
7683
+ await next();
7684
+ if (!traced) return;
7685
+ void logTraceEvent({
7686
+ kind: "api",
7687
+ method: c.req.method,
7688
+ path,
7689
+ nestId: /^\/nests\/([^/]+)/.exec(path)?.[1] ?? null,
7690
+ userId: c.get("userId") ?? null,
7691
+ status: c.res.status,
7692
+ durationMs: Date.now() - started
7693
+ });
7694
+ });
5468
7695
  app.get(
5469
7696
  "/health",
5470
7697
  (c) => c.json({
@@ -5474,6 +7701,14 @@ function createApp() {
5474
7701
  auth_mode: config.AUTH_MODE,
5475
7702
  logo_url: config.LOGO_URL,
5476
7703
  promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
7704
+ // Public feature flags the SPA needs before auth to decide what to
7705
+ // render. Mirrors the server-side route gate so UI and API agree:
7706
+ // flag off → the plane's routes 404 AND its UI stays hidden.
7707
+ workflow_plane_enabled: config.FEATURE_WORKFLOW_PLANE,
7708
+ // Sub-agent runs gate its own recursion surface separately (env-only,
7709
+ // must clear a security review — NOT a UI toggle). Exposed read-only so
7710
+ // the Runs page shows the "spawn sub-agent" option only when it's live.
7711
+ subagent_runs_enabled: config.FEATURE_SUBAGENT_RUNS,
5477
7712
  ...isSuspended() && { suspended_reason: getSuspensionReason() }
5478
7713
  })
5479
7714
  );
@@ -5564,7 +7799,13 @@ function createApp() {
5564
7799
  logo_url: config.LOGO_URL,
5565
7800
  telemetry_enabled: config.TELEMETRY_ENABLED,
5566
7801
  public_base_url: config.PUBLIC_BASE_URL,
5567
- max_body_bytes: config.MAX_BODY_BYTES
7802
+ max_body_bytes: config.MAX_BODY_BYTES,
7803
+ workflow_plane_enabled: config.FEATURE_WORKFLOW_PLANE,
7804
+ subagent_runs_enabled: config.FEATURE_SUBAGENT_RUNS,
7805
+ slack_webhook_url: config.SLACK_WEBHOOK_URL ?? "",
7806
+ smtp_url: config.SMTP_URL ?? "",
7807
+ notify_email_from: config.NOTIFY_EMAIL_FROM ?? "",
7808
+ notify_email_to: config.NOTIFY_EMAIL_TO ?? ""
5568
7809
  });
5569
7810
  app.get("/admin/settings", async (c) => {
5570
7811
  if (!await adminSettingsAllowed(c))
@@ -5599,6 +7840,49 @@ function createApp() {
5599
7840
  if ("telemetry_enabled" in body) {
5600
7841
  pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
5601
7842
  }
7843
+ if ("workflow_plane_enabled" in body) {
7844
+ pending.push({
7845
+ name: "FEATURE_WORKFLOW_PLANE",
7846
+ value: body.workflow_plane_enabled ? "true" : null
7847
+ });
7848
+ }
7849
+ if ("subagent_runs_enabled" in body) {
7850
+ const planeOn = "workflow_plane_enabled" in body ? !!body.workflow_plane_enabled : config.FEATURE_WORKFLOW_PLANE;
7851
+ if (body.subagent_runs_enabled && !planeOn) {
7852
+ errors.push(
7853
+ "subagent_runs_enabled requires workflow_plane_enabled to be on"
7854
+ );
7855
+ } else {
7856
+ pending.push({
7857
+ name: "FEATURE_SUBAGENT_RUNS",
7858
+ value: body.subagent_runs_enabled ? "true" : null
7859
+ });
7860
+ }
7861
+ }
7862
+ if ("slack_webhook_url" in body) {
7863
+ const v = String(body.slack_webhook_url ?? "").trim();
7864
+ if (v && !/^https:\/\//i.test(v))
7865
+ errors.push("slack_webhook_url must be an https:// URL (or empty to disable)");
7866
+ else pending.push({ name: "SLACK_WEBHOOK_URL", value: v || null });
7867
+ }
7868
+ if ("smtp_url" in body) {
7869
+ const v = String(body.smtp_url ?? "").trim();
7870
+ if (v && !/^smtps?:\/\//i.test(v))
7871
+ errors.push("smtp_url must be an smtp:// or smtps:// URL (or empty to disable)");
7872
+ else pending.push({ name: "SMTP_URL", value: v || null });
7873
+ }
7874
+ if ("notify_email_from" in body) {
7875
+ const v = String(body.notify_email_from ?? "").trim();
7876
+ if (v && !isEmailish(v))
7877
+ errors.push("notify_email_from must be a plain email address");
7878
+ else pending.push({ name: "NOTIFY_EMAIL_FROM", value: v || null });
7879
+ }
7880
+ if ("notify_email_to" in body) {
7881
+ const v = String(body.notify_email_to ?? "").trim();
7882
+ if (v && !isEmailListish(v))
7883
+ errors.push("notify_email_to must be a comma-separated list of email addresses");
7884
+ else pending.push({ name: "NOTIFY_EMAIL_TO", value: v || null });
7885
+ }
5602
7886
  if ("max_body_bytes" in body) {
5603
7887
  const n = Number(body.max_body_bytes);
5604
7888
  if (!Number.isFinite(n) || n < 1024 * 1024 || n > 500 * 1024 * 1024)
@@ -5624,6 +7908,29 @@ function createApp() {
5624
7908
  }
5625
7909
  return c.json({ settings: currentServerSettings() });
5626
7910
  });
7911
+ app.use("/admin/trace", flexAuthMiddleware);
7912
+ const traceAllowed = async (c) => {
7913
+ if (config.AUTH_MODE === "open") return true;
7914
+ const userId = c.get("userId");
7915
+ if (await isLicenseAdminUserId(userId)) return true;
7916
+ return isSuperAdmin(await resolveCallerEmail(userId));
7917
+ };
7918
+ app.get("/admin/trace", async (c) => {
7919
+ if (!await traceAllowed(c))
7920
+ return c.json({ error: "Only the server admin can view this." }, 403);
7921
+ const limitRaw = Number(c.req.query("limit"));
7922
+ const offsetRaw = Number(c.req.query("offset"));
7923
+ const limit = Number.isFinite(limitRaw) ? limitRaw : 25;
7924
+ const offset = Number.isFinite(offsetRaw) ? offsetRaw : 0;
7925
+ const { events, total } = await listTraceEvents({
7926
+ limit,
7927
+ offset,
7928
+ kind: c.req.query("kind"),
7929
+ nestId: c.req.query("nest") || void 0,
7930
+ user: c.req.query("user") || void 0
7931
+ });
7932
+ return c.json({ count: events.length, total, limit, offset, events });
7933
+ });
5627
7934
  app.use("/stats", flexAuthMiddleware);
5628
7935
  app.get("/stats", async (c) => {
5629
7936
  const db = getDb();
@@ -5650,7 +7957,14 @@ function createApp() {
5650
7957
  users: usersRow.c
5651
7958
  });
5652
7959
  });
5653
- const nestsApp = new Hono9();
7960
+ app.use("/runs/*", flexAuthMiddleware);
7961
+ app.route("/runs", runDetailRoutes);
7962
+ app.route("/llms.txt", llmsTxtRoutes);
7963
+ app.use("/index", flexAuthMiddleware);
7964
+ app.route("/index", nestIndexRoutes);
7965
+ app.use("/mcp", flexAuthMiddleware);
7966
+ app.route("/mcp", serverMcpRoutes);
7967
+ const nestsApp = new Hono16();
5654
7968
  nestsApp.use("*", flexAuthMiddleware);
5655
7969
  nestsApp.use("*", async (c, next) => {
5656
7970
  const localPath = c.req.path.replace(/^\/nests\//, "");
@@ -5698,8 +8012,27 @@ function createApp() {
5698
8012
  return next();
5699
8013
  }
5700
8014
  const permission = await resolveNestPermission(nestId, userId);
8015
+ const isReadQuery = parts.length === 2 && ["context", "query", "search", "export"].includes(parts[1]);
8016
+ let grantRole = null;
8017
+ let grantNestVisible = false;
5701
8018
  if (permission === "none") {
5702
- return c.json({ error: "Nest not found" }, 404);
8019
+ if (parts[1] === "nodes" && parts.length >= 3) {
8020
+ let targetTail = parts.slice(2).join("/");
8021
+ try {
8022
+ targetTail = decodeURIComponent(targetTail);
8023
+ } catch {
8024
+ }
8025
+ grantRole = await resolveNodeGrant(nestId, userId, targetTail);
8026
+ } else if (parts.length === 1 && c.req.method === "GET") {
8027
+ grantNestVisible = await hasAnyGrant(nestId, userId);
8028
+ } else if (parts[1] === "nodes" && parts.length === 2 && c.req.method === "GET") {
8029
+ grantNestVisible = await hasAnyGrant(nestId, userId);
8030
+ } else if (isReadQuery) {
8031
+ grantNestVisible = await hasAnyGrant(nestId, userId);
8032
+ }
8033
+ if (!grantRole && !grantNestVisible) {
8034
+ return c.json({ error: "Nest not found" }, 404);
8035
+ }
5703
8036
  }
5704
8037
  let required = "read";
5705
8038
  const path = c.req.path;
@@ -5720,7 +8053,7 @@ function createApp() {
5720
8053
  required = "admin";
5721
8054
  } else if (resource === "collaborators") {
5722
8055
  required = c.req.method === "GET" || c.req.method === "POST" ? "write" : "admin";
5723
- } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction) {
8056
+ } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery) {
5724
8057
  required = "write";
5725
8058
  }
5726
8059
  const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
@@ -5746,7 +8079,9 @@ function createApp() {
5746
8079
  );
5747
8080
  }
5748
8081
  }
5749
- if (!stewardEditorBypass && permissionLevel(permission) < permissionLevel(required)) {
8082
+ const grantLevel = grantRole ? permissionLevel(grantRole) : grantNestVisible ? permissionLevel("read") : 0;
8083
+ const effectiveLevel = Math.max(permissionLevel(permission), grantLevel);
8084
+ if (!stewardEditorBypass && effectiveLevel < permissionLevel(required)) {
5750
8085
  return c.json(
5751
8086
  {
5752
8087
  error: `You don't have access to perform this action on this nest. Required permission: '${required}', your permission: '${permission}'. Ask the nest owner or a server admin to grant you ${required} access.`,
@@ -5756,16 +8091,30 @@ function createApp() {
5756
8091
  403
5757
8092
  );
5758
8093
  }
5759
- c.set("nestPermission", permission);
8094
+ c.set(
8095
+ "nestPermission",
8096
+ permission === "none" && grantRole ? grantRole : permission
8097
+ );
8098
+ return next();
8099
+ });
8100
+ nestsApp.use("/:nestId/nodes/:nodeId{.+}", async (c, next) => {
8101
+ assertSafeNodeId(c.req.param("nodeId"));
5760
8102
  return next();
5761
8103
  });
5762
8104
  nestsApp.route("/", nestRoutes);
5763
8105
  nestsApp.route("/:nestId", governanceRoutes);
5764
- nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
5765
8106
  nestsApp.route("/:nestId/nodes", annotationRoutes);
8107
+ nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
5766
8108
  nestsApp.route("/:nestId/nodes", nodeRoutes);
5767
8109
  nestsApp.route("/:nestId", queryRoutes);
5768
8110
  nestsApp.route("/:nestId", sharingRoutes);
8111
+ nestsApp.route("/:nestId/grants", grantRoutes);
8112
+ nestsApp.route("/:nestId/edge-types", edgeTypeRoutes);
8113
+ nestsApp.route("/:nestId/edges", edgeRoutes);
8114
+ nestsApp.route("/:nestId/run", runTriggerRoutes);
8115
+ nestsApp.route("/:nestId/runs", runListRoutes);
8116
+ nestsApp.route("/:nestId/definitions", definitionRoutes);
8117
+ nestsApp.route("/:nestId/assets", assetRoutes);
5769
8118
  nestsApp.route("/:nestId/mcp", mcpRoutes);
5770
8119
  app.route("/nests", nestsApp);
5771
8120
  app.use("/assets/*", serveStatic({ root: UI_DIR_REL }));