@promptowl/contextnest-community 1.6.0 → 1.8.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
@@ -13,10 +13,11 @@ import {
13
13
  getPendingReview,
14
14
  getReviewHistory,
15
15
  getReviewQueue,
16
+ notifySlackForNest,
16
17
  reject,
17
18
  safePublishDocument,
18
19
  submitForReview
19
- } from "./chunk-MZGFKBOK.js";
20
+ } from "./chunk-Z3Y46ZGU.js";
20
21
  import {
21
22
  checkConflict,
22
23
  createVersion,
@@ -25,7 +26,7 @@ import {
25
26
  getDisplayStatus,
26
27
  getVersions,
27
28
  setApprovedVersion
28
- } from "./chunk-T5L4LYU4.js";
29
+ } from "./chunk-XNGOO6RI.js";
29
30
  import {
30
31
  AppError,
31
32
  ConflictError,
@@ -35,10 +36,10 @@ import {
35
36
  ValidationError,
36
37
  canCreateInNest,
37
38
  canManageStewards,
38
- canManageWith,
39
39
  canUserAccess,
40
40
  canUserApprove,
41
41
  canUserEdit,
42
+ collabPermToRole,
42
43
  createNest,
43
44
  createStewardRecord,
44
45
  deleteNest,
@@ -84,14 +85,14 @@ import {
84
85
  updateSteward,
85
86
  upsertEnvVar,
86
87
  validateLicense
87
- } from "./chunk-2TUMMVBG.js";
88
+ } from "./chunk-PJTAUVD7.js";
88
89
  import {
89
90
  config,
90
91
  getDb,
91
92
  initDb,
92
93
  insertOrIgnore,
93
94
  nowExpr
94
- } from "./chunk-7V33Z6CS.js";
95
+ } from "./chunk-PLAHCDQG.js";
95
96
  import {
96
97
  ANON_EMAIL,
97
98
  ANON_USER_ID
@@ -101,7 +102,8 @@ import {
101
102
  import { serve } from "@hono/node-server";
102
103
 
103
104
  // src/app.ts
104
- import { Hono as Hono9 } from "hono";
105
+ import { Hono as Hono10 } from "hono";
106
+ import { LinearRouter } from "hono/router/linear-router";
105
107
  import { createMiddleware as createMiddleware2 } from "hono/factory";
106
108
  import { cors } from "hono/cors";
107
109
 
@@ -487,7 +489,7 @@ authRoutes.post("/login", async (c) => {
487
489
  throw new ValidationError("email and password are required");
488
490
  }
489
491
  const ip = clientIp(c);
490
- const emailLower = body.email.toLowerCase();
492
+ const emailLower = normalizeEmail(body.email);
491
493
  const hasIp = ip !== "unknown";
492
494
  const ipKey = `login:ip:${ip}`;
493
495
  const emailKey = `login:email:${emailLower}`;
@@ -496,7 +498,7 @@ authRoutes.post("/login", async (c) => {
496
498
  }
497
499
  const db = getDb();
498
500
  const user = await db.get(
499
- "SELECT id, email, name, password_hash, is_admin FROM users WHERE LOWER(email) = ?",
501
+ "SELECT id, email, name, password_hash, is_admin, is_invited FROM users WHERE LOWER(email) = ?",
500
502
  [emailLower]
501
503
  );
502
504
  const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
@@ -509,6 +511,12 @@ authRoutes.post("/login", async (c) => {
509
511
  console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
510
512
  if (hasIp) clear(ipKey);
511
513
  clear(emailKey);
514
+ if (user.is_invited === 1) {
515
+ try {
516
+ await db.run("UPDATE users SET is_invited = 0 WHERE id = ?", [user.id]);
517
+ } catch {
518
+ }
519
+ }
512
520
  if (check.needsRehash) {
513
521
  try {
514
522
  const newHash = await hashPassword(body.password);
@@ -810,7 +818,7 @@ authRoutes.post("/password", authMiddleware, async (c) => {
810
818
  );
811
819
  }
812
820
  const newHash = await hashPassword(body.next);
813
- await db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
821
+ await db.run("UPDATE users SET password_hash = ?, is_invited = 0 WHERE id = ?", [
814
822
  newHash,
815
823
  userId
816
824
  ]);
@@ -853,14 +861,13 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
853
861
  newHash,
854
862
  target.id
855
863
  ]);
856
- await tx.run("DELETE FROM api_keys WHERE user_id = ?", [target.id]);
857
864
  });
858
865
  await deleteAllSessionsForUser(target.id);
859
866
  trackEvent("admin.reset_password", { adminId: callerId, userId: target.id });
860
867
  return c.json({
861
868
  ok: true,
862
869
  email: target.email,
863
- keys_revoked: true,
870
+ keys_revoked: false,
864
871
  // Plaintext returned ONCE, only when the server generated it.
865
872
  temporary_password: generated ?? void 0
866
873
  });
@@ -936,40 +943,40 @@ authRoutes.post("/invite", async (c) => {
936
943
  403
937
944
  );
938
945
  }
939
- let user = await db.get(
940
- "SELECT id, email FROM users WHERE LOWER(email) = ?",
946
+ const existing = await db.get(
947
+ "SELECT id, email, is_invited FROM users WHERE LOWER(email) = ?",
941
948
  [email]
942
949
  );
943
- if (!user) {
944
- const userId = uuid();
945
- const placeholderHash = await hashPassword(uuid());
950
+ if (existing && existing.is_invited === 0) {
951
+ return c.json(
952
+ {
953
+ error: "A user with this email already exists. Use Reset password to issue them a new password."
954
+ },
955
+ 409
956
+ );
957
+ }
958
+ const tempPassword = uuid().replace(/-/g, "").slice(0, 16);
959
+ const passwordHash = await hashPassword(tempPassword);
960
+ let userId;
961
+ if (existing) {
962
+ userId = existing.id;
963
+ await db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
964
+ passwordHash,
965
+ userId
966
+ ]);
967
+ } else {
968
+ userId = uuid();
946
969
  await db.run(
947
970
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
948
- [userId, email, null, placeholderHash]
971
+ [userId, email, null, passwordHash]
949
972
  );
950
- user = { id: userId, email };
951
973
  }
952
- const apiKey = generateApiKey();
953
- const keyId = uuid();
954
- await db.transaction(async (tx) => {
955
- await tx.run("DELETE FROM api_keys WHERE user_id = ?", [user.id]);
956
- await tx.run(
957
- "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)",
958
- [
959
- keyId,
960
- user.id,
961
- hashApiKey(apiKey),
962
- getKeyPrefix(apiKey),
963
- body.label || "teammate"
964
- ]
965
- );
966
- });
967
- trackEvent("admin.invite", { adminId: callerId, email: body.email });
974
+ trackEvent("admin.invite", { adminId: callerId, email });
968
975
  return c.json(
969
976
  {
970
- api_key: apiKey,
971
- user: { id: user.id, email: user.email },
972
- message: "Copy this key and share it securely \u2014 it won't be shown again."
977
+ temporary_password: tempPassword,
978
+ user: { id: userId, email },
979
+ message: "Share this temporary password securely. Your teammate signs in with their email + this password, then can change it and create their own API key."
973
980
  },
974
981
  201
975
982
  );
@@ -1284,7 +1291,7 @@ async function approveExternalEdit(input) {
1284
1291
  const node = await storage.readDocument(input.documentId);
1285
1292
  const versionNum = result.versionEntry.version;
1286
1293
  const tags = node.frontmatter.tags || [];
1287
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-MUZYTYMW.js");
1294
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-B3SDOJQE.js");
1288
1295
  await createVersion2({
1289
1296
  nestId: input.nestId,
1290
1297
  nodeId: input.documentId,
@@ -1370,6 +1377,10 @@ function bodyOnly(nodeId, raw) {
1370
1377
  return raw;
1371
1378
  }
1372
1379
  }
1380
+ var RUNNABLE_NODE_TYPES = ["agent", "skill"];
1381
+ function isRunnableType(type) {
1382
+ return RUNNABLE_NODE_TYPES.includes(type || "");
1383
+ }
1373
1384
  function toNodeResponse(node) {
1374
1385
  const fm = node.frontmatter;
1375
1386
  const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
@@ -1386,14 +1397,20 @@ function toNodeResponse(node) {
1386
1397
  created_at: fm.created_at,
1387
1398
  updated_at: fm.updated_at,
1388
1399
  content: node.body || "",
1389
- pendingChange: node.pendingChange ?? void 0
1400
+ pendingChange: node.pendingChange ?? void 0,
1401
+ // Same YAML-coercion rule as title above: `schedule: 123` parses as a
1402
+ // number — normalize to string at the API boundary.
1403
+ schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
1390
1404
  };
1391
1405
  }
1392
1406
  async function listNodesForCaller(nestId, userId, filters = {}) {
1393
1407
  const { storage, versions: versionManager } = await engineCache.get(nestId);
1394
1408
  let documents = await storage.discoverDocuments();
1395
1409
  if (filters.type) {
1396
- documents = documents.filter((n) => n.frontmatter.type === filters.type);
1410
+ const wanted = new Set(
1411
+ Array.isArray(filters.type) ? filters.type : [filters.type]
1412
+ );
1413
+ documents = documents.filter((n) => wanted.has(n.frontmatter.type));
1397
1414
  }
1398
1415
  if (filters.tag) {
1399
1416
  const tag = normalizeTag2(filters.tag);
@@ -1444,10 +1461,26 @@ async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
1444
1461
  }
1445
1462
  async function createNode(nestId, input, userEmail) {
1446
1463
  const { storage, versions: versionManager } = await engineCache.get(nestId);
1447
- const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1448
- const id = input.id ?? `nodes/${slug}`;
1464
+ const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1465
+ const slug = slugify(input.title);
1466
+ let id = input.id;
1467
+ if (!id) {
1468
+ const folderSegments = (input.folder ?? "").split("/").map(slugify).filter(Boolean);
1469
+ if (folderSegments.length > 8) {
1470
+ throw new ValidationError("folder may nest at most 8 levels deep");
1471
+ }
1472
+ if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
1473
+ throw new ValidationError("each folder or title segment must be at most 100 characters");
1474
+ }
1475
+ id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
1476
+ }
1449
1477
  const now = (/* @__PURE__ */ new Date()).toISOString();
1450
1478
  const tags = (input.tags || []).map(normalizeTag2);
1479
+ if (input.schedule !== void 0 && !isRunnableType(input.type)) {
1480
+ throw new ValidationError(
1481
+ `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
1482
+ );
1483
+ }
1451
1484
  const hasStewards = await isStewardshipEnabled(nestId);
1452
1485
  const initialStatus = hasStewards ? "draft" : "published";
1453
1486
  const initialVersion = hasStewards ? 1 : 0;
@@ -1462,7 +1495,11 @@ async function createNode(nestId, input, userEmail) {
1462
1495
  version: initialVersion,
1463
1496
  created_at: now,
1464
1497
  updated_at: now,
1465
- metadata: { owners: ["*"], scope: input.scope || "team" }
1498
+ metadata: {
1499
+ owners: ["*"],
1500
+ scope: input.scope || "team",
1501
+ ...input.schedule ? { schedule: input.schedule } : {}
1502
+ }
1466
1503
  },
1467
1504
  body: input.content,
1468
1505
  rawContent: ""
@@ -1589,6 +1626,17 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
1589
1626
  if (patch.title) {
1590
1627
  node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
1591
1628
  }
1629
+ if (patch.schedule !== void 0) {
1630
+ if (!isRunnableType(node.frontmatter.type)) {
1631
+ throw new ValidationError(
1632
+ `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
1633
+ );
1634
+ }
1635
+ const metadata = { ...node.frontmatter.metadata };
1636
+ if (patch.schedule === "") delete metadata.schedule;
1637
+ else metadata.schedule = patch.schedule;
1638
+ node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
1639
+ }
1592
1640
  const hasStewards = await isStewardshipEnabled(nestId);
1593
1641
  const currentTags = node.frontmatter.tags || [];
1594
1642
  if (hasStewards && await getPendingReview(nestId, nodeId)) {
@@ -1881,7 +1929,18 @@ nestRoutes.get("/", async (c) => {
1881
1929
  const permission = await effectivePermission(n.id, userId);
1882
1930
  const is_owner = permission === "owner";
1883
1931
  let owner_email = null;
1884
- const roles = is_owner ? ["owner"] : await resolveUserRoles(n.id, callerEmail);
1932
+ let roles;
1933
+ if (is_owner) {
1934
+ roles = ["owner"];
1935
+ } else {
1936
+ const grantRole = collabPermToRole(
1937
+ await getCollaboratorRole(n.id, callerEmail)
1938
+ );
1939
+ const stewardRoles = await getStewardRolesForUser(n.id, callerEmail);
1940
+ roles = [
1941
+ ...new Set([grantRole, ...stewardRoles].filter(Boolean))
1942
+ ];
1943
+ }
1885
1944
  if (!is_owner && n.user_id !== ANON_USER_ID) {
1886
1945
  const row = await db.get(ownerEmailSql, [n.user_id]);
1887
1946
  owner_email = row?.email ?? null;
@@ -1895,7 +1954,25 @@ nestRoutes.get("/", async (c) => {
1895
1954
  seen.add(n.id);
1896
1955
  out.push(await annotate(n));
1897
1956
  }
1898
- return c.json({ nests: out });
1957
+ const docRows = await db.all(
1958
+ "SELECT nest_id, COUNT(DISTINCT node_id) AS c FROM node_versions GROUP BY nest_id"
1959
+ );
1960
+ const docByNest = new Map(docRows.map((r) => [r.nest_id, r.c]));
1961
+ const collabRows = await db.all(
1962
+ "SELECT nest_id, COUNT(*) AS c FROM nest_collaborators GROUP BY nest_id"
1963
+ );
1964
+ const collabByNest = new Map(collabRows.map((r) => [r.nest_id, r.c]));
1965
+ const stewardRows = await db.all(
1966
+ "SELECT nest_id, COUNT(DISTINCT user_email) AS c FROM stewards WHERE is_active = 1 GROUP BY nest_id"
1967
+ );
1968
+ const stewardByNest = new Map(stewardRows.map((r) => [r.nest_id, r.c]));
1969
+ const withMeta = out.map((n) => ({
1970
+ ...n,
1971
+ document_count: docByNest.get(n.id) ?? 0,
1972
+ collaborator_count: collabByNest.get(n.id) ?? 0,
1973
+ steward_count: stewardByNest.get(n.id) ?? 0
1974
+ }));
1975
+ return c.json({ nests: withMeta });
1899
1976
  });
1900
1977
  nestRoutes.post("/", async (c) => {
1901
1978
  const body = await c.req.json();
@@ -2011,10 +2088,10 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
2011
2088
  const userId = c.get("userId");
2012
2089
  const isServerAdmin = await isLicenseAdminUserId(userId);
2013
2090
  const permission = await effectivePermission(nestId, userId);
2014
- if (!isServerAdmin && permission !== "owner") {
2091
+ if (!isServerAdmin && permission !== "owner" && permission !== "admin") {
2015
2092
  return c.json(
2016
2093
  {
2017
- error: "Only the nest owner or the server license-admin can update nest settings."
2094
+ error: "Only a nest admin, the nest owner, or the server license-admin can update nest settings."
2018
2095
  },
2019
2096
  403
2020
2097
  );
@@ -2025,6 +2102,14 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
2025
2102
  if (body.stewardship_enabled) {
2026
2103
  await setStewardshipEnabled(nestId, true);
2027
2104
  } else {
2105
+ if (!isServerAdmin && permission !== "owner") {
2106
+ return c.json(
2107
+ {
2108
+ error: "Only the nest owner or the server license-admin can disable stewardship (this permanently wipes stewards and pending reviews)."
2109
+ },
2110
+ 403
2111
+ );
2112
+ }
2028
2113
  wiped = await disableStewardshipAndWipeGovernance(nestId);
2029
2114
  }
2030
2115
  }
@@ -2112,6 +2197,10 @@ async function addCollaborator(params) {
2112
2197
  "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
2113
2198
  [collabId, nestId, userId, params.permission, granterId]
2114
2199
  );
2200
+ void notifySlackForNest(
2201
+ nestId,
2202
+ `:key: ${params.email || userId} added as *${params.permission}*${params.grantedByEmail ? ` by ${params.grantedByEmail}` : ""}`
2203
+ );
2115
2204
  return await db.get(
2116
2205
  "SELECT * FROM nest_collaborators WHERE id = ?",
2117
2206
  [collabId]
@@ -2220,416 +2309,113 @@ function isMarkdownFormat(c) {
2220
2309
  return c.req.query("format") === "markdown";
2221
2310
  }
2222
2311
 
2223
- // src/nodes/routes.ts
2224
- var nodeRoutes = new Hono4();
2225
- function nodeAsMarkdown(response, nodeId) {
2226
- return nodeToMarkdown({
2227
- id: nodeId,
2228
- title: response.title,
2229
- tags: response.tags,
2230
- status: response.status,
2231
- body: response.content
2232
- });
2312
+ // src/annotations/service.ts
2313
+ import { v4 as uuid3 } from "uuid";
2314
+
2315
+ // src/annotations/projection.ts
2316
+ var MAX_CONTEXT_CHARS = 250;
2317
+ var MAX_QUOTE_CHARS = 1e3;
2318
+ function clampAnchor(raw) {
2319
+ if (!raw) return null;
2320
+ const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
2321
+ if (!quote) return null;
2322
+ const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
2323
+ const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
2324
+ const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
2325
+ return { quote, before, after, ...line !== void 0 ? { line } : {} };
2233
2326
  }
2234
- nodeRoutes.get("/", async (c) => {
2235
- const nestId = c.req.param("nestId");
2236
- const userId = c.get("userId");
2237
- const nodes = await listNodesForCaller(nestId, userId);
2238
- return c.json({ count: nodes.length, nodes });
2239
- });
2240
- nodeRoutes.post("/", async (c) => {
2241
- const body = await c.req.json();
2242
- if (!body.title || !body.content) {
2243
- throw new ValidationError("title and content are required");
2327
+ function oneLine(s) {
2328
+ return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
2329
+ }
2330
+ function renderAnchor(anchor) {
2331
+ if (!anchor) {
2332
+ return "<!-- anchor: whole-artifact -->";
2244
2333
  }
2245
- const nestId = c.req.param("nestId");
2246
- const authorEmail = await getUserEmail(c);
2247
- const { node } = await createNode(
2248
- nestId,
2249
- {
2250
- title: body.title,
2251
- content: body.content,
2252
- type: body.type,
2253
- tags: body.tags,
2254
- scope: body.scope,
2255
- status: body.status
2256
- },
2257
- authorEmail
2258
- );
2259
- const resolved = await resolveStewardsForNode(nestId, node.id);
2260
- return c.json({
2261
- node: toNodeResponse(node),
2262
- stewards: resolved.length > 0 ? resolved.map((r) => ({
2263
- email: r.steward.userEmail,
2264
- role: r.steward.role,
2265
- source: r.source
2266
- })) : void 0
2267
- }, 201);
2268
- });
2269
- nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
2270
- const nestId = c.req.param("nestId");
2271
- const nodeId = c.req.param("nodeId");
2272
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-VIKF3VZB.js");
2273
- const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
2274
- nestId,
2275
- nodeId
2334
+ const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
2335
+ const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
2336
+ anchor.after
2337
+ )}\u2026`;
2338
+ return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
2339
+ }
2340
+ function statusHeading(thread) {
2341
+ if (thread.status === "resolved") {
2342
+ const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
2343
+ const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
2344
+ const meta = who || when ? ` (${who}${when})` : "";
2345
+ return `\u2705 RESOLVED${meta}`;
2346
+ }
2347
+ return "\u{1F7E0} OPEN";
2348
+ }
2349
+ function snapshotLabel(v) {
2350
+ return v == null ? "Unpinned" : `Snapshot v${v}`;
2351
+ }
2352
+ function orderThreads(threads) {
2353
+ return [...threads].sort((a, b) => {
2354
+ if (a.status !== b.status) return a.status === "open" ? -1 : 1;
2355
+ return a.createdAt.localeCompare(b.createdAt);
2356
+ });
2357
+ }
2358
+ function projectThreadsToMarkdown(artifactTitle, threads) {
2359
+ const lines = [];
2360
+ lines.push(`# ${artifactTitle} \u2014 Annotations`);
2361
+ lines.push("");
2362
+ lines.push(
2363
+ "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
2276
2364
  );
2277
- return c.json({
2278
- nodeId,
2279
- stewards: stewards.map((r) => ({
2280
- email: r.steward.userEmail,
2281
- role: r.steward.role,
2282
- scope: r.steward.scope,
2283
- source: r.source,
2284
- priority: r.priority
2285
- })),
2286
- fallbackToOwner,
2287
- ownerEmail
2365
+ lines.push("");
2366
+ if (threads.length === 0) {
2367
+ lines.push("_No annotations yet._");
2368
+ lines.push("");
2369
+ return lines.join("\n");
2370
+ }
2371
+ const buckets2 = /* @__PURE__ */ new Map();
2372
+ for (const t of threads) {
2373
+ const key = t.snapshotVersion ?? null;
2374
+ (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
2375
+ }
2376
+ const keys = [...buckets2.keys()].sort((a, b) => {
2377
+ if (a == null) return 1;
2378
+ if (b == null) return -1;
2379
+ return b - a;
2288
2380
  });
2289
- });
2290
- nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
2291
- const nestId = c.req.param("nestId");
2292
- const nodeId = c.req.param("nodeId");
2293
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-MUZYTYMW.js");
2294
- const allVersions = await getVersions2(nestId, nodeId);
2295
- const approved = await getApprovedVersion2(nestId, nodeId);
2381
+ for (const key of keys) {
2382
+ lines.push(`## ${snapshotLabel(key)}`);
2383
+ lines.push("");
2384
+ for (const thread of orderThreads(buckets2.get(key))) {
2385
+ lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
2386
+ lines.push(renderAnchor(thread.anchor));
2387
+ for (const comment of thread.comments) {
2388
+ lines.push(
2389
+ `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
2390
+ );
2391
+ }
2392
+ lines.push("");
2393
+ }
2394
+ }
2395
+ return lines.join("\n");
2396
+ }
2397
+
2398
+ // src/annotations/service.ts
2399
+ async function loadComments(threadId) {
2296
2400
  const db = getDb();
2297
- const resolutions = await db.all(
2298
- `SELECT version, status, resolved_by, resolved_at
2299
- FROM review_requests
2300
- WHERE nest_id = ? AND node_id = ?
2301
- AND status IN ('approved', 'rejected')
2302
- AND resolved_by IS NOT NULL
2303
- ORDER BY resolved_at DESC`,
2304
- [nestId, nodeId]
2401
+ const rows = await db.all(
2402
+ "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
2403
+ [threadId]
2305
2404
  );
2306
- const byVersion = /* @__PURE__ */ new Map();
2307
- for (const r of resolutions) {
2308
- if (!byVersion.has(r.version)) {
2309
- byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
2310
- }
2311
- }
2312
- const enriched = allVersions.map((v) => {
2313
- const r = byVersion.get(v.version);
2314
- return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
2315
- });
2316
- return c.json({
2317
- versions: enriched,
2318
- approvedVersion: approved,
2319
- currentVersion: allVersions[0]?.version || 0
2320
- });
2321
- });
2322
- nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
2323
- const nestId = c.req.param("nestId");
2324
- const nodeId = c.req.param("nodeId");
2325
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-QJ3FBOML.js");
2326
- const history = await getReviewHistory2(nestId, nodeId);
2327
- return c.json({ reviews: history });
2328
- });
2329
- nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
2330
- const nestId = c.req.param("nestId");
2331
- const nodeId = c.req.param("nodeId");
2332
- const { versions: versionManager } = await engineCache.get(nestId);
2333
- const userId = c.get("userId");
2334
- const userEmail = await resolveCallerEmail(userId);
2335
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
2336
- return c.json(
2337
- { error: "Access denied \u2014 no steward assignment for this node" },
2338
- 403
2339
- );
2340
- }
2341
- const body = await c.req.json().catch(() => ({}));
2342
- const targetVersion = Number(body.targetVersion);
2343
- if (!Number.isInteger(targetVersion) || targetVersion < 1) {
2344
- throw new ValidationError("targetVersion (a positive integer) is required");
2345
- }
2346
- let raw;
2347
- try {
2348
- raw = await versionManager.reconstructVersion(nodeId, targetVersion);
2349
- } catch {
2350
- throw new NotFoundError(
2351
- `Version ${targetVersion} not found for ${nodeId}`
2352
- );
2353
- }
2354
- const content = bodyOnly(nodeId, raw);
2355
- const { node, version } = await updateNode(
2356
- nestId,
2357
- nodeId,
2358
- { content, changeNote: `Restored from version ${targetVersion}` },
2359
- userEmail
2360
- );
2361
- await trackEvent("node.revert", { nestId, nodeId, targetVersion });
2362
- return c.json({ ok: true, version, node: toNodeResponse(node) });
2363
- });
2364
- nodeRoutes.get("/:nodeId{.+}", async (c) => {
2365
- const nestId = c.req.param("nestId");
2366
- const nodeId = c.req.param("nodeId");
2367
- const { storage, versions: versionManager } = await engineCache.get(nestId);
2368
- const userId = c.get("userId");
2369
- const userEmail = await resolveCallerEmail(userId);
2370
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
2371
- return c.json(
2372
- { error: "Access denied \u2014 no steward assignment for this node" },
2373
- 403
2374
- );
2375
- }
2376
- let node;
2377
- try {
2378
- node = await storage.readDocument(nodeId, { verifyChecksum: true });
2379
- } catch {
2380
- throw new NotFoundError(`Node not found: ${nodeId}`);
2381
- }
2382
- if (node.pendingChange) {
2383
- try {
2384
- await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
2385
- const refreshed = await getPendingChange(nestId, nodeId);
2386
- if (refreshed) node.pendingChange = refreshed;
2387
- } catch (err) {
2388
- console.error("[external-edit] stage-on-read failed:", err);
2389
- }
2390
- try {
2391
- const latest = await loadLatestApprovedNode(nestId, nodeId);
2392
- if (latest) {
2393
- node = { ...latest, pendingChange: node.pendingChange };
2394
- }
2395
- } catch (err) {
2396
- console.error("[external-edit] reconstruct-latest failed:", err);
2397
- }
2398
- }
2399
- const response = toNodeResponse(node);
2400
- if (await isPublicReader(nestId, userId)) {
2401
- const approved = await getApprovedVersion(nestId, nodeId);
2402
- if (approved != null) {
2403
- try {
2404
- const raw = await versionManager.reconstructVersion(
2405
- nodeId,
2406
- approved
2407
- );
2408
- response.content = bodyOnly(nodeId, raw);
2409
- } catch (err) {
2410
- console.error(
2411
- "reconstructVersion failed (public single)",
2412
- nodeId,
2413
- approved,
2414
- err
2415
- );
2416
- response.content = "";
2417
- }
2418
- response.version = approved;
2419
- response.status = "published";
2420
- if (isMarkdownFormat(c)) {
2421
- return c.body(nodeAsMarkdown(response, nodeId), 200, {
2422
- "Content-Type": "text/markdown; charset=utf-8"
2423
- });
2424
- }
2425
- return c.json({ node: response });
2426
- }
2427
- }
2428
- response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
2429
- if (response.status === "pending_review") {
2430
- const pending = await getPendingReview(nestId, nodeId);
2431
- response.pendingReviewBy = pending?.requestedBy ?? null;
2432
- }
2433
- if (isMarkdownFormat(c)) {
2434
- return c.body(nodeAsMarkdown(response, nodeId), 200, {
2435
- "Content-Type": "text/markdown; charset=utf-8"
2436
- });
2437
- }
2438
- return c.json({ node: response });
2439
- });
2440
- nodeRoutes.patch("/:nodeId{.+}", async (c) => {
2441
- const nestId = c.req.param("nestId");
2442
- const nodeId = c.req.param("nodeId");
2443
- const body = await c.req.json();
2444
- const baseVersionHeader = c.req.header("X-Base-Version");
2445
- if (baseVersionHeader) {
2446
- const baseVersion = parseInt(baseVersionHeader, 10);
2447
- const conflict = await checkConflict(nestId, nodeId, baseVersion);
2448
- if (conflict.conflict) {
2449
- return c.json(
2450
- {
2451
- error: "Version conflict",
2452
- your_version: baseVersion,
2453
- current_version: conflict.currentVersion,
2454
- updated_by: conflict.updatedBy,
2455
- updated_at: conflict.updatedAt,
2456
- rejected_content: body.content || body.append || null
2457
- },
2458
- 409
2459
- );
2460
- }
2461
- }
2462
- const authorEmail = await getUserEmail(c);
2463
- const { node, version: responseVersion } = await updateNode(
2464
- nestId,
2465
- nodeId,
2466
- {
2467
- content: body.content,
2468
- append: body.append,
2469
- tags: body.tags,
2470
- title: body.title,
2471
- status: body.status,
2472
- changeNote: body.changeNote
2473
- },
2474
- authorEmail
2475
- );
2476
- return c.json({ node: toNodeResponse(node), version: responseVersion });
2477
- });
2478
- nodeRoutes.delete("/:nodeId{.+}", async (c) => {
2479
- const nestId = c.req.param("nestId");
2480
- const nodeId = c.req.param("nodeId");
2481
- const { storage } = await engineCache.get(nestId);
2482
- if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
2483
- throw new LockedError(
2484
- "This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
2485
- );
2486
- }
2487
- try {
2488
- await storage.deleteDocument(nodeId);
2489
- } catch {
2490
- throw new NotFoundError(`Node not found: ${nodeId}`);
2491
- }
2492
- await removeNodeFromTagIndex(nestId, nodeId);
2493
- const db = getDb();
2494
- await db.transaction(async (tx) => {
2495
- await tx.run(
2496
- "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
2497
- [nestId, nodeId]
2498
- );
2499
- await tx.run(
2500
- "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
2501
- [nestId, nodeId]
2502
- );
2503
- await tx.run(
2504
- "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
2505
- [nestId, nodeId]
2506
- );
2507
- await tx.run(
2508
- `DELETE FROM stewards
2509
- WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2510
- [nestId, nodeId]
2511
- );
2512
- });
2513
- await trackEvent("node.delete", { nestId, nodeId });
2514
- return c.json({ deleted: true });
2515
- });
2516
- async function getUserEmail(c) {
2517
- const userId = c.get("userId");
2518
- const db = getDb();
2519
- const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
2520
- return user?.email || "anonymous@localhost";
2521
- }
2522
-
2523
- // src/annotations/routes.ts
2524
- import { Hono as Hono5 } from "hono";
2525
-
2526
- // src/annotations/service.ts
2527
- import { v4 as uuid3 } from "uuid";
2528
-
2529
- // src/annotations/projection.ts
2530
- var MAX_CONTEXT_CHARS = 250;
2531
- var MAX_QUOTE_CHARS = 1e3;
2532
- function clampAnchor(raw) {
2533
- if (!raw) return null;
2534
- const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
2535
- if (!quote) return null;
2536
- const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
2537
- const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
2538
- const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
2539
- return { quote, before, after, ...line !== void 0 ? { line } : {} };
2540
- }
2541
- function oneLine(s) {
2542
- return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
2543
- }
2544
- function renderAnchor(anchor) {
2545
- if (!anchor) {
2546
- return "<!-- anchor: whole-artifact -->";
2547
- }
2548
- const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
2549
- const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
2550
- anchor.after
2551
- )}\u2026`;
2552
- return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
2553
- }
2554
- function statusHeading(thread) {
2555
- if (thread.status === "resolved") {
2556
- const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
2557
- const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
2558
- const meta = who || when ? ` (${who}${when})` : "";
2559
- return `\u2705 RESOLVED${meta}`;
2560
- }
2561
- return "\u{1F7E0} OPEN";
2562
- }
2563
- function snapshotLabel(v) {
2564
- return v == null ? "Unpinned" : `Snapshot v${v}`;
2565
- }
2566
- function orderThreads(threads) {
2567
- return [...threads].sort((a, b) => {
2568
- if (a.status !== b.status) return a.status === "open" ? -1 : 1;
2569
- return a.createdAt.localeCompare(b.createdAt);
2570
- });
2571
- }
2572
- function projectThreadsToMarkdown(artifactTitle, threads) {
2573
- const lines = [];
2574
- lines.push(`# ${artifactTitle} \u2014 Annotations`);
2575
- lines.push("");
2576
- lines.push(
2577
- "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
2578
- );
2579
- lines.push("");
2580
- if (threads.length === 0) {
2581
- lines.push("_No annotations yet._");
2582
- lines.push("");
2583
- return lines.join("\n");
2584
- }
2585
- const buckets2 = /* @__PURE__ */ new Map();
2586
- for (const t of threads) {
2587
- const key = t.snapshotVersion ?? null;
2588
- (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
2589
- }
2590
- const keys = [...buckets2.keys()].sort((a, b) => {
2591
- if (a == null) return 1;
2592
- if (b == null) return -1;
2593
- return b - a;
2594
- });
2595
- for (const key of keys) {
2596
- lines.push(`## ${snapshotLabel(key)}`);
2597
- lines.push("");
2598
- for (const thread of orderThreads(buckets2.get(key))) {
2599
- lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
2600
- lines.push(renderAnchor(thread.anchor));
2601
- for (const comment of thread.comments) {
2602
- lines.push(
2603
- `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
2604
- );
2605
- }
2606
- lines.push("");
2607
- }
2608
- }
2609
- return lines.join("\n");
2610
- }
2611
-
2612
- // src/annotations/service.ts
2613
- async function loadComments(threadId) {
2614
- const db = getDb();
2615
- const rows = await db.all(
2616
- "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, rowid ASC",
2617
- [threadId]
2618
- );
2619
- return rows.map((r) => ({
2620
- id: r.id,
2621
- author: r.author,
2622
- body: r.body,
2623
- createdAt: r.created_at
2624
- }));
2625
- }
2626
- async function rowToThread(row) {
2627
- let anchor = null;
2628
- if (row.anchor_json) {
2629
- try {
2630
- anchor = JSON.parse(row.anchor_json);
2631
- } catch {
2632
- anchor = null;
2405
+ return rows.map((r) => ({
2406
+ id: r.id,
2407
+ author: r.author,
2408
+ body: r.body,
2409
+ createdAt: r.created_at
2410
+ }));
2411
+ }
2412
+ async function rowToThread(row) {
2413
+ let anchor = null;
2414
+ if (row.anchor_json) {
2415
+ try {
2416
+ anchor = JSON.parse(row.anchor_json);
2417
+ } catch {
2418
+ anchor = null;
2633
2419
  }
2634
2420
  }
2635
2421
  return {
@@ -2649,9 +2435,21 @@ async function rowToThread(row) {
2649
2435
  async function getThreadRow(threadId) {
2650
2436
  return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
2651
2437
  }
2438
+ async function countThreadsByNode(nestId) {
2439
+ const rows = await getDb().all(
2440
+ `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
2441
+ FROM annotation_threads
2442
+ WHERE nest_id = ?
2443
+ GROUP BY node_id`,
2444
+ [nestId]
2445
+ );
2446
+ const out = {};
2447
+ for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
2448
+ return out;
2449
+ }
2652
2450
  async function listThreads(nestId, nodeId) {
2653
2451
  const rows = await getDb().all(
2654
- "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, rowid ASC",
2452
+ "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
2655
2453
  [nestId, nodeId]
2656
2454
  );
2657
2455
  return Promise.all(rows.map(rowToThread));
@@ -2758,44 +2556,389 @@ function derivedAnnotationsId(sourceNodeId) {
2758
2556
  }
2759
2557
  async function syncAnnotationsNode(nestId, nodeId, userEmail) {
2760
2558
  try {
2761
- let title = nodeId;
2559
+ let title = nodeId;
2560
+ let type = null;
2561
+ try {
2562
+ const { storage } = await engineCache.get(nestId);
2563
+ const node = await storage.readDocument(nodeId);
2564
+ title = node.frontmatter?.title || nodeId;
2565
+ type = node.frontmatter?.type || "document";
2566
+ } catch {
2567
+ }
2568
+ if (type !== null && type !== "artifact") return;
2569
+ const threads = await listThreads(nestId, nodeId);
2570
+ const derivedTitle = `${title} \u2014 Annotations`;
2571
+ const markdown = projectThreadsToMarkdown(title, threads);
2572
+ const derivedId = derivedAnnotationsId(nodeId);
2573
+ try {
2574
+ await updateNode(nestId, derivedId, { content: markdown }, userEmail);
2575
+ } catch (err) {
2576
+ if (err instanceof NotFoundError) {
2577
+ await createNode(
2578
+ nestId,
2579
+ {
2580
+ id: derivedId,
2581
+ title: derivedTitle,
2582
+ content: markdown,
2583
+ type: "document",
2584
+ tags: ["annotations"]
2585
+ },
2586
+ userEmail
2587
+ );
2588
+ } else {
2589
+ throw err;
2590
+ }
2591
+ }
2592
+ } catch (err) {
2593
+ console.warn(
2594
+ `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
2595
+ err
2596
+ );
2597
+ }
2598
+ }
2599
+
2600
+ // src/nodes/routes.ts
2601
+ var nodeRoutes = new Hono4();
2602
+ function nodeAsMarkdown(response, nodeId) {
2603
+ return nodeToMarkdown({
2604
+ id: nodeId,
2605
+ title: response.title,
2606
+ tags: response.tags,
2607
+ status: response.status,
2608
+ body: response.content
2609
+ });
2610
+ }
2611
+ nodeRoutes.get("/", async (c) => {
2612
+ const nestId = c.req.param("nestId");
2613
+ const userId = c.get("userId");
2614
+ const nodes = await listNodesForCaller(nestId, userId);
2615
+ return c.json({ count: nodes.length, nodes });
2616
+ });
2617
+ nodeRoutes.post("/", async (c) => {
2618
+ const body = await c.req.json();
2619
+ if (!body.title || !body.content) {
2620
+ throw new ValidationError("title and content are required");
2621
+ }
2622
+ if (body.folder !== void 0 && typeof body.folder !== "string") {
2623
+ throw new ValidationError('folder must be a string path like "gtm/deals"');
2624
+ }
2625
+ const nestId = c.req.param("nestId");
2626
+ const authorEmail = await getUserEmail(c);
2627
+ const { node } = await createNode(
2628
+ nestId,
2629
+ {
2630
+ title: body.title,
2631
+ content: body.content,
2632
+ type: body.type,
2633
+ tags: body.tags,
2634
+ scope: body.scope,
2635
+ status: body.status,
2636
+ schedule: body.schedule,
2637
+ folder: body.folder
2638
+ },
2639
+ authorEmail
2640
+ );
2641
+ const resolved = await resolveStewardsForNode(nestId, node.id);
2642
+ return c.json({
2643
+ node: toNodeResponse(node),
2644
+ stewards: resolved.length > 0 ? resolved.map((r) => ({
2645
+ email: r.steward.userEmail,
2646
+ role: r.steward.role,
2647
+ source: r.source
2648
+ })) : void 0
2649
+ }, 201);
2650
+ });
2651
+ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
2652
+ const nestId = c.req.param("nestId");
2653
+ const nodeId = c.req.param("nodeId");
2654
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-ZXAMFTXO.js");
2655
+ const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
2656
+ nestId,
2657
+ nodeId
2658
+ );
2659
+ return c.json({
2660
+ nodeId,
2661
+ stewards: stewards.map((r) => ({
2662
+ email: r.steward.userEmail,
2663
+ role: r.steward.role,
2664
+ scope: r.steward.scope,
2665
+ source: r.source,
2666
+ priority: r.priority
2667
+ })),
2668
+ fallbackToOwner,
2669
+ ownerEmail
2670
+ });
2671
+ });
2672
+ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
2673
+ const nestId = c.req.param("nestId");
2674
+ const nodeId = c.req.param("nodeId");
2675
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-B3SDOJQE.js");
2676
+ const allVersions = await getVersions2(nestId, nodeId);
2677
+ const approved = await getApprovedVersion2(nestId, nodeId);
2678
+ const db = getDb();
2679
+ const resolutions = await db.all(
2680
+ `SELECT version, status, resolved_by, resolved_at
2681
+ FROM review_requests
2682
+ WHERE nest_id = ? AND node_id = ?
2683
+ AND status IN ('approved', 'rejected')
2684
+ AND resolved_by IS NOT NULL
2685
+ ORDER BY resolved_at DESC`,
2686
+ [nestId, nodeId]
2687
+ );
2688
+ const byVersion = /* @__PURE__ */ new Map();
2689
+ for (const r of resolutions) {
2690
+ if (!byVersion.has(r.version)) {
2691
+ byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
2692
+ }
2693
+ }
2694
+ const enriched = allVersions.map((v) => {
2695
+ const r = byVersion.get(v.version);
2696
+ return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
2697
+ });
2698
+ return c.json({
2699
+ versions: enriched,
2700
+ approvedVersion: approved,
2701
+ currentVersion: allVersions[0]?.version || 0
2702
+ });
2703
+ });
2704
+ nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
2705
+ const nestId = c.req.param("nestId");
2706
+ const nodeId = c.req.param("nodeId");
2707
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-G5US3SMG.js");
2708
+ const history = await getReviewHistory2(nestId, nodeId);
2709
+ return c.json({ reviews: history });
2710
+ });
2711
+ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
2712
+ const nestId = c.req.param("nestId");
2713
+ const nodeId = c.req.param("nodeId");
2714
+ const { versions: versionManager } = await engineCache.get(nestId);
2715
+ const userId = c.get("userId");
2716
+ const userEmail = await resolveCallerEmail(userId);
2717
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
2718
+ return c.json(
2719
+ { error: "Access denied \u2014 no steward assignment for this node" },
2720
+ 403
2721
+ );
2722
+ }
2723
+ const body = await c.req.json().catch(() => ({}));
2724
+ const targetVersion = Number(body.targetVersion);
2725
+ if (!Number.isInteger(targetVersion) || targetVersion < 1) {
2726
+ throw new ValidationError("targetVersion (a positive integer) is required");
2727
+ }
2728
+ let raw;
2729
+ try {
2730
+ raw = await versionManager.reconstructVersion(nodeId, targetVersion);
2731
+ } catch {
2732
+ throw new NotFoundError(
2733
+ `Version ${targetVersion} not found for ${nodeId}`
2734
+ );
2735
+ }
2736
+ const content = bodyOnly(nodeId, raw);
2737
+ const { node, version } = await updateNode(
2738
+ nestId,
2739
+ nodeId,
2740
+ { content, changeNote: `Restored from version ${targetVersion}` },
2741
+ userEmail
2742
+ );
2743
+ await trackEvent("node.revert", { nestId, nodeId, targetVersion });
2744
+ return c.json({ ok: true, version, node: toNodeResponse(node) });
2745
+ });
2746
+ nodeRoutes.get("/:nodeId{.+}", async (c) => {
2747
+ const nestId = c.req.param("nestId");
2748
+ const nodeId = c.req.param("nodeId");
2749
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
2750
+ const userId = c.get("userId");
2751
+ const userEmail = await resolveCallerEmail(userId);
2752
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
2753
+ return c.json(
2754
+ { error: "Access denied \u2014 no steward assignment for this node" },
2755
+ 403
2756
+ );
2757
+ }
2758
+ let node;
2759
+ try {
2760
+ node = await storage.readDocument(nodeId, { verifyChecksum: true });
2761
+ } catch {
2762
+ throw new NotFoundError(`Node not found: ${nodeId}`);
2763
+ }
2764
+ if (node.pendingChange) {
2762
2765
  try {
2763
- const { storage } = await engineCache.get(nestId);
2764
- const node = await storage.readDocument(nodeId);
2765
- title = node.frontmatter?.title || nodeId;
2766
- } catch {
2766
+ await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
2767
+ const refreshed = await getPendingChange(nestId, nodeId);
2768
+ if (refreshed) node.pendingChange = refreshed;
2769
+ } catch (err) {
2770
+ console.error("[external-edit] stage-on-read failed:", err);
2767
2771
  }
2768
- const threads = await listThreads(nestId, nodeId);
2769
- const derivedTitle = `${title} \u2014 Annotations`;
2770
- const markdown = projectThreadsToMarkdown(title, threads);
2771
- const derivedId = derivedAnnotationsId(nodeId);
2772
2772
  try {
2773
- await updateNode(nestId, derivedId, { content: markdown }, userEmail);
2773
+ const latest = await loadLatestApprovedNode(nestId, nodeId);
2774
+ if (latest) {
2775
+ node = { ...latest, pendingChange: node.pendingChange };
2776
+ }
2774
2777
  } catch (err) {
2775
- if (err instanceof NotFoundError) {
2776
- await createNode(
2777
- nestId,
2778
- {
2779
- id: derivedId,
2780
- title: derivedTitle,
2781
- content: markdown,
2782
- type: "document",
2783
- tags: ["annotations"]
2784
- },
2785
- userEmail
2778
+ console.error("[external-edit] reconstruct-latest failed:", err);
2779
+ }
2780
+ }
2781
+ const response = toNodeResponse(node);
2782
+ if (await isPublicReader(nestId, userId)) {
2783
+ const approved = await getApprovedVersion(nestId, nodeId);
2784
+ if (approved != null) {
2785
+ try {
2786
+ const raw = await versionManager.reconstructVersion(
2787
+ nodeId,
2788
+ approved
2786
2789
  );
2787
- } else {
2788
- throw err;
2790
+ response.content = bodyOnly(nodeId, raw);
2791
+ } catch (err) {
2792
+ console.error(
2793
+ "reconstructVersion failed (public single)",
2794
+ nodeId,
2795
+ approved,
2796
+ err
2797
+ );
2798
+ response.content = "";
2799
+ }
2800
+ response.version = approved;
2801
+ response.status = "published";
2802
+ if (isMarkdownFormat(c)) {
2803
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
2804
+ "Content-Type": "text/markdown; charset=utf-8"
2805
+ });
2789
2806
  }
2807
+ return c.json({ node: response });
2790
2808
  }
2791
- } catch (err) {
2792
- console.warn(
2793
- `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
2794
- err
2809
+ }
2810
+ response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
2811
+ if (response.status === "pending_review") {
2812
+ const pending = await getPendingReview(nestId, nodeId);
2813
+ response.pendingReviewBy = pending?.requestedBy ?? null;
2814
+ }
2815
+ if (isMarkdownFormat(c)) {
2816
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
2817
+ "Content-Type": "text/markdown; charset=utf-8"
2818
+ });
2819
+ }
2820
+ return c.json({ node: response });
2821
+ });
2822
+ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
2823
+ const nestId = c.req.param("nestId");
2824
+ const nodeId = c.req.param("nodeId");
2825
+ const body = await c.req.json();
2826
+ const baseVersionHeader = c.req.header("X-Base-Version");
2827
+ if (baseVersionHeader) {
2828
+ const baseVersion = parseInt(baseVersionHeader, 10);
2829
+ const conflict = await checkConflict(nestId, nodeId, baseVersion);
2830
+ if (conflict.conflict) {
2831
+ return c.json(
2832
+ {
2833
+ error: "Version conflict",
2834
+ your_version: baseVersion,
2835
+ current_version: conflict.currentVersion,
2836
+ updated_by: conflict.updatedBy,
2837
+ updated_at: conflict.updatedAt,
2838
+ rejected_content: body.content || body.append || null
2839
+ },
2840
+ 409
2841
+ );
2842
+ }
2843
+ }
2844
+ const authorEmail = await getUserEmail(c);
2845
+ const { node, version: responseVersion } = await updateNode(
2846
+ nestId,
2847
+ nodeId,
2848
+ {
2849
+ content: body.content,
2850
+ append: body.append,
2851
+ tags: body.tags,
2852
+ title: body.title,
2853
+ status: body.status,
2854
+ changeNote: body.changeNote,
2855
+ schedule: body.schedule
2856
+ },
2857
+ authorEmail
2858
+ );
2859
+ return c.json({ node: toNodeResponse(node), version: responseVersion });
2860
+ });
2861
+ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
2862
+ const nestId = c.req.param("nestId");
2863
+ const nodeId = c.req.param("nodeId");
2864
+ const { storage } = await engineCache.get(nestId);
2865
+ if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
2866
+ throw new LockedError(
2867
+ "This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
2795
2868
  );
2796
2869
  }
2870
+ try {
2871
+ await storage.deleteDocument(nodeId);
2872
+ } catch {
2873
+ throw new NotFoundError(`Node not found: ${nodeId}`);
2874
+ }
2875
+ await removeNodeFromTagIndex(nestId, nodeId);
2876
+ const derivedId = derivedAnnotationsId(nodeId);
2877
+ try {
2878
+ await storage.deleteDocument(derivedId);
2879
+ await removeNodeFromTagIndex(nestId, derivedId);
2880
+ } catch {
2881
+ }
2882
+ const db = getDb();
2883
+ await db.transaction(async (tx) => {
2884
+ await tx.run(
2885
+ "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
2886
+ [nestId, nodeId]
2887
+ );
2888
+ await tx.run(
2889
+ "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
2890
+ [nestId, nodeId]
2891
+ );
2892
+ await tx.run(
2893
+ "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
2894
+ [nestId, nodeId]
2895
+ );
2896
+ await tx.run(
2897
+ `DELETE FROM stewards
2898
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2899
+ [nestId, nodeId]
2900
+ );
2901
+ await tx.run(
2902
+ "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
2903
+ [nestId, nodeId]
2904
+ );
2905
+ await tx.run(
2906
+ "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
2907
+ [nestId, derivedId]
2908
+ );
2909
+ await tx.run(
2910
+ "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
2911
+ [nestId, derivedId]
2912
+ );
2913
+ await tx.run(
2914
+ "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
2915
+ [nestId, derivedId]
2916
+ );
2917
+ await tx.run(
2918
+ "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
2919
+ [nestId, derivedId]
2920
+ );
2921
+ await tx.run(
2922
+ `INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
2923
+ VALUES (?, ?, ?, ?)
2924
+ ON CONFLICT(nest_id, node_id) DO UPDATE
2925
+ SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
2926
+ [nestId, nodeId, await getUserEmail(c), (/* @__PURE__ */ new Date()).toISOString()]
2927
+ );
2928
+ });
2929
+ await trackEvent("node.delete", { nestId, nodeId });
2930
+ return c.json({ deleted: true });
2931
+ });
2932
+ async function getUserEmail(c) {
2933
+ const userId = c.get("userId");
2934
+ const db = getDb();
2935
+ const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
2936
+ return user?.email || "anonymous@localhost";
2797
2937
  }
2798
2938
 
2939
+ // src/annotations/routes.ts
2940
+ import { Hono as Hono5 } from "hono";
2941
+
2799
2942
  // src/annotations/types.ts
2800
2943
  var ARTIFACT_NODE_TYPE = "artifact";
2801
2944
 
@@ -3290,6 +3433,100 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
3290
3433
 
3291
3434
  // src/nodes/query-routes.ts
3292
3435
  var queryRoutes = new Hono6();
3436
+ queryRoutes.get("/changes", async (c) => {
3437
+ const nestId = c.req.param("nestId");
3438
+ const userId = c.get("userId");
3439
+ const sinceRaw = c.req.query("since");
3440
+ const sinceMs = sinceRaw ? Date.parse(sinceRaw) : NaN;
3441
+ if (!sinceRaw || Number.isNaN(sinceMs)) {
3442
+ throw new ValidationError(
3443
+ "since must be an ISO-8601 date, e.g. ?since=2026-07-01T00:00:00Z"
3444
+ );
3445
+ }
3446
+ const includeContent = ["1", "true"].includes(
3447
+ c.req.query("include_content") || ""
3448
+ );
3449
+ const all = await listNodesForCaller(nestId, userId);
3450
+ const db = getDb();
3451
+ const publicReader = await isPublicReader(nestId, userId);
3452
+ const approvedRows = await db.all(
3453
+ "SELECT node_id, approved_at FROM approved_versions WHERE nest_id = ?",
3454
+ [nestId]
3455
+ );
3456
+ const parseDbTs = (t) => Date.parse(t.includes("T") ? t : `${t.replace(" ", "T")}Z`);
3457
+ const approvedAtMsByNode = new Map(
3458
+ approvedRows.map((r) => [r.node_id, parseDbTs(r.approved_at)])
3459
+ );
3460
+ const publishedSince = new Set(
3461
+ approvedRows.filter((r) => parseDbTs(r.approved_at) > sinceMs).map((r) => r.node_id)
3462
+ );
3463
+ const summarize = (n, updatedOverride) => ({
3464
+ id: n.id,
3465
+ title: n.title,
3466
+ type: n.type,
3467
+ status: n.status,
3468
+ tags: n.tags,
3469
+ created_at: n.created_at,
3470
+ updated_at: updatedOverride ?? n.updated_at,
3471
+ ...includeContent ? { content: n.content } : {}
3472
+ });
3473
+ const created = [];
3474
+ const updated = [];
3475
+ for (const n of all) {
3476
+ if (publicReader) {
3477
+ const approvedMs = approvedAtMsByNode.get(n.id);
3478
+ if (approvedMs === void 0) continue;
3479
+ const approvedIso = new Date(approvedMs).toISOString();
3480
+ const createdAt2 = Date.parse(n.created_at || "");
3481
+ if (!Number.isNaN(createdAt2) && createdAt2 > sinceMs) {
3482
+ created.push(summarize(n, approvedIso));
3483
+ } else if (approvedMs > sinceMs) {
3484
+ updated.push(summarize(n, approvedIso));
3485
+ }
3486
+ continue;
3487
+ }
3488
+ const createdAt = Date.parse(n.created_at || "");
3489
+ const updatedAt = Date.parse(n.updated_at || "");
3490
+ if (!Number.isNaN(createdAt) && createdAt > sinceMs) created.push(summarize(n));
3491
+ else if (!Number.isNaN(updatedAt) && updatedAt > sinceMs) updated.push(summarize(n));
3492
+ else if (publishedSince.has(n.id)) updated.push(summarize(n));
3493
+ }
3494
+ const canSeeDeleted = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
3495
+ let deleted = null;
3496
+ if (canSeeDeleted) {
3497
+ const rows = await db.all(
3498
+ "SELECT node_id, deleted_by, deleted_at FROM node_deletions WHERE nest_id = ? AND deleted_at > ?",
3499
+ [nestId, new Date(sinceMs).toISOString()]
3500
+ );
3501
+ const liveIds = new Set(all.map((n) => n.id));
3502
+ 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 }));
3503
+ }
3504
+ return c.json({
3505
+ since: new Date(sinceMs).toISOString(),
3506
+ now: (/* @__PURE__ */ new Date()).toISOString(),
3507
+ created,
3508
+ updated,
3509
+ deleted
3510
+ });
3511
+ });
3512
+ queryRoutes.get("/runnables", async (c) => {
3513
+ const nestId = c.req.param("nestId");
3514
+ const userId = c.get("userId");
3515
+ const all = await listNodesForCaller(nestId, userId, {
3516
+ type: RUNNABLE_NODE_TYPES
3517
+ });
3518
+ const runnables = all.map((n) => ({
3519
+ id: n.id,
3520
+ title: n.title,
3521
+ type: n.type,
3522
+ schedule: n.schedule ?? null,
3523
+ status: n.status,
3524
+ tags: n.tags,
3525
+ updated_at: n.updated_at,
3526
+ content: n.content
3527
+ }));
3528
+ return c.json({ count: runnables.length, runnables });
3529
+ });
3293
3530
  queryRoutes.get("/graph", async (c) => {
3294
3531
  const nestId = c.req.param("nestId");
3295
3532
  const userId = c.get("userId");
@@ -3322,11 +3559,30 @@ queryRoutes.post("/context", async (c) => {
3322
3559
  const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
3323
3560
  const hops = body.hops ?? 2;
3324
3561
  const includeDrafts = body.include_drafts === true;
3562
+ const isVisible = (d) => includeDrafts || d.frontmatter.status === "published";
3325
3563
  let selector = body.selector?.trim() || null;
3326
3564
  let compileDetail = null;
3327
3565
  let titleMatches = [];
3566
+ let resolvedTitleSelector = false;
3328
3567
  const allDocs = await storage.discoverDocuments();
3329
- if (!selector && body.prompt) {
3568
+ if (selector && selector.includes("[[")) {
3569
+ const parts = selector.split("|").map((p) => p.trim()).filter(Boolean);
3570
+ const wantedTitles = /* @__PURE__ */ new Set();
3571
+ const rest = [];
3572
+ for (const p of parts) {
3573
+ const m = /^\[\[(.+)\]\]$/.exec(p);
3574
+ if (m) wantedTitles.add(m[1].trim().toLowerCase());
3575
+ else rest.push(p);
3576
+ }
3577
+ if (wantedTitles.size > 0) {
3578
+ selector = rest.join("|") || null;
3579
+ resolvedTitleSelector = true;
3580
+ titleMatches = allDocs.filter(
3581
+ (d) => wantedTitles.has(String(d.frontmatter.title || "").toLowerCase()) && isVisible(d)
3582
+ );
3583
+ }
3584
+ }
3585
+ if (!selector && !resolvedTitleSelector && body.prompt) {
3330
3586
  const titles = allDocs.map((d) => d.frontmatter.title);
3331
3587
  compileDetail = await compilePrompt(body.prompt, nestId, titles);
3332
3588
  selector = compileDetail.selector;
@@ -3352,6 +3608,44 @@ queryRoutes.post("/context", async (c) => {
3352
3608
  hopsUsed = result.hopsUsed;
3353
3609
  nodesTraversed = result.nodesTraversed;
3354
3610
  }
3611
+ if (titleMatches.length > 0 && hops > 0) {
3612
+ const byTitle = new Map(
3613
+ allDocs.map((d) => [
3614
+ String(d.frontmatter.title || "").toLowerCase(),
3615
+ d
3616
+ ])
3617
+ );
3618
+ const byId = new Map(allDocs.map((d) => [d.id, d]));
3619
+ const adj = /* @__PURE__ */ new Map();
3620
+ const link = (a, b) => {
3621
+ (adj.get(a) ?? adj.set(a, /* @__PURE__ */ new Set()).get(a)).add(b);
3622
+ (adj.get(b) ?? adj.set(b, /* @__PURE__ */ new Set()).get(b)).add(a);
3623
+ };
3624
+ for (const d of allDocs) {
3625
+ for (const target of extractWikiTargets(d.body || "")) {
3626
+ const t = byId.get(target) ?? byTitle.get(target.toLowerCase());
3627
+ if (t && t.id !== d.id) link(d.id, t.id);
3628
+ }
3629
+ }
3630
+ const seen = new Set(titleMatches.map((d) => d.id));
3631
+ let frontier = titleMatches.map((d) => d.id);
3632
+ for (let depth = 0; depth < hops && frontier.length; depth++) {
3633
+ const next = [];
3634
+ for (const id of frontier) {
3635
+ for (const nb of adj.get(id) ?? []) {
3636
+ if (seen.has(nb)) continue;
3637
+ seen.add(nb);
3638
+ const doc = byId.get(nb);
3639
+ if (!doc || !isVisible(doc)) continue;
3640
+ next.push(nb);
3641
+ titleMatches.push(doc);
3642
+ }
3643
+ }
3644
+ frontier = next;
3645
+ hopsUsed = Math.max(hopsUsed, depth + 1);
3646
+ }
3647
+ nodesTraversed += seen.size;
3648
+ }
3355
3649
  if (titleMatches.length > 0) {
3356
3650
  const seen = new Set(documents.map((d) => d.id));
3357
3651
  for (const t of titleMatches) {
@@ -3500,6 +3794,10 @@ queryRoutes.get("/overview", async (c) => {
3500
3794
  }))
3501
3795
  });
3502
3796
  });
3797
+ queryRoutes.get("/comment-counts", async (c) => {
3798
+ const counts = await countThreadsByNode(c.req.param("nestId"));
3799
+ return c.json({ counts });
3800
+ });
3503
3801
  queryRoutes.get("/context", async (c) => {
3504
3802
  const { storage } = await engineCache.get(c.req.param("nestId"));
3505
3803
  const content = await storage.readContextMd();
@@ -3620,7 +3918,87 @@ import { Hono as Hono7 } from "hono";
3620
3918
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3621
3919
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3622
3920
 
3921
+ // src/telemetry/trace-log.ts
3922
+ var RETENTION_DAYS = 14;
3923
+ var PRUNE_EVERY = 500;
3924
+ var insertsSincePrune = 0;
3925
+ async function logTraceEvent(e) {
3926
+ try {
3927
+ const db = getDb();
3928
+ await db.run(
3929
+ `INSERT INTO api_events
3930
+ (ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms)
3931
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3932
+ [
3933
+ (/* @__PURE__ */ new Date()).toISOString(),
3934
+ e.kind,
3935
+ e.method ?? null,
3936
+ e.path ?? null,
3937
+ e.tool ?? null,
3938
+ e.nestId ?? null,
3939
+ e.userId ?? null,
3940
+ e.userEmail ?? null,
3941
+ e.status ?? null,
3942
+ e.durationMs ?? null
3943
+ ]
3944
+ );
3945
+ if (++insertsSincePrune >= PRUNE_EVERY) {
3946
+ insertsSincePrune = 0;
3947
+ const cutoff = new Date(
3948
+ Date.now() - RETENTION_DAYS * 864e5
3949
+ ).toISOString();
3950
+ await db.run("DELETE FROM api_events WHERE ts < ?", [cutoff]);
3951
+ }
3952
+ } catch {
3953
+ }
3954
+ }
3955
+ async function listTraceEvents(filters = {}) {
3956
+ const db = getDb();
3957
+ const limit = Number.isFinite(filters.limit) ? Math.min(Math.max(filters.limit, 1), 1e3) : 25;
3958
+ const offset = Number.isFinite(filters.offset) ? Math.max(filters.offset, 0) : 0;
3959
+ const from = "FROM api_events e LEFT JOIN users u ON u.id = e.user_id";
3960
+ const where = [];
3961
+ const args = [];
3962
+ if (filters.kind) {
3963
+ where.push("e.kind = ?");
3964
+ args.push(filters.kind);
3965
+ }
3966
+ if (filters.nestId) {
3967
+ where.push("e.nest_id = ?");
3968
+ args.push(filters.nestId);
3969
+ }
3970
+ if (filters.user) {
3971
+ where.push("(COALESCE(e.user_email, u.email) LIKE ? OR e.user_id = ?)");
3972
+ args.push(`%${filters.user}%`, filters.user);
3973
+ }
3974
+ const whereSql = where.length ? ` WHERE ${where.join(" AND ")}` : "";
3975
+ try {
3976
+ const totalRow = await db.get(
3977
+ `SELECT COUNT(*) AS n ${from}${whereSql}`,
3978
+ args
3979
+ );
3980
+ const total = Number(totalRow?.n ?? 0);
3981
+ const events = await db.all(
3982
+ `SELECT e.*, COALESCE(e.user_email, u.email) AS caller
3983
+ ${from}${whereSql}
3984
+ ORDER BY e.id DESC
3985
+ LIMIT ? OFFSET ?`,
3986
+ [...args, limit, offset]
3987
+ );
3988
+ return { events, total };
3989
+ } catch {
3990
+ return { events: [], total: 0 };
3991
+ }
3992
+ }
3993
+
3623
3994
  // src/mcp/tools.ts
3995
+ var MAX_HOPS = 10;
3996
+ function normalizeHops(raw) {
3997
+ if (raw == null) return 2;
3998
+ const n = Number(raw);
3999
+ if (!Number.isFinite(n)) return 2;
4000
+ return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
4001
+ }
3624
4002
  var TOOL_DEFINITIONS = [
3625
4003
  {
3626
4004
  name: "context_init",
@@ -3649,7 +4027,11 @@ var TOOL_DEFINITIONS = [
3649
4027
  inputSchema: {
3650
4028
  type: "object",
3651
4029
  properties: {
3652
- query: { type: "string", description: "Selector query" }
4030
+ query: { type: "string", description: "Selector query" },
4031
+ hops: {
4032
+ type: "number",
4033
+ 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."
4034
+ }
3653
4035
  },
3654
4036
  required: ["query"]
3655
4037
  }
@@ -3690,9 +4072,37 @@ var TOOL_DEFINITIONS = [
3690
4072
  max_tokens: {
3691
4073
  type: "number",
3692
4074
  description: "Approximate token budget (default: 8000)"
4075
+ },
4076
+ hops: {
4077
+ type: "number",
4078
+ description: "Graph traversal depth from the matched nodes (default: 2)"
4079
+ }
4080
+ },
4081
+ required: ["selector"]
4082
+ }
4083
+ },
4084
+ {
4085
+ name: "context_export",
4086
+ description: "Export the ENTIRE nest as one markdown bundle \u2014 every accessible document's FULL content. Use when you need the whole nest as context rather than a targeted query. Respects an optional token budget.",
4087
+ inputSchema: {
4088
+ type: "object",
4089
+ properties: {
4090
+ max_tokens: {
4091
+ type: "number",
4092
+ description: "Approximate token budget; documents are included until it's reached (omit for no cap)."
3693
4093
  }
3694
- },
3695
- required: ["selector"]
4094
+ }
4095
+ }
4096
+ },
4097
+ {
4098
+ name: "context_comments",
4099
+ description: "Get the review comments/annotations on a document \u2014 anchored quotes, threaded replies, and open/resolved status \u2014 by title or ID. Use to pull human feedback into context.",
4100
+ inputSchema: {
4101
+ type: "object",
4102
+ properties: {
4103
+ title: { type: "string", description: "Title of the node" },
4104
+ id: { type: "string", description: "ID of the node" }
4105
+ }
3696
4106
  }
3697
4107
  },
3698
4108
  {
@@ -3715,7 +4125,11 @@ var TOOL_DEFINITIONS = [
3715
4125
  items: { type: "string" },
3716
4126
  description: "Tags"
3717
4127
  },
3718
- scope: { type: "string", description: "Visibility scope" }
4128
+ scope: { type: "string", description: "Visibility scope" },
4129
+ folder: {
4130
+ type: "string",
4131
+ description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
4132
+ }
3719
4133
  },
3720
4134
  required: ["title", "content"]
3721
4135
  }
@@ -3882,6 +4296,26 @@ async function resolveLlmBody(ctx, node) {
3882
4296
  }
3883
4297
  }
3884
4298
  async function handleToolCall(toolName, args, ctx) {
4299
+ const started = Date.now();
4300
+ let status = 200;
4301
+ try {
4302
+ return await runTool(toolName, args, ctx);
4303
+ } catch (err) {
4304
+ status = 500;
4305
+ throw err;
4306
+ } finally {
4307
+ void logTraceEvent({
4308
+ kind: "mcp",
4309
+ tool: toolName,
4310
+ nestId: ctx.nestId,
4311
+ userId: ctx.userId,
4312
+ userEmail: ctx.userEmail,
4313
+ status,
4314
+ durationMs: Date.now() - started
4315
+ });
4316
+ }
4317
+ }
4318
+ async function runTool(toolName, args, ctx) {
3885
4319
  const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
3886
4320
  switch (toolName) {
3887
4321
  case "context_init": {
@@ -3946,8 +4380,16 @@ ${nodeList}`;
3946
4380
  ${results}`;
3947
4381
  }
3948
4382
  case "context_query": {
3949
- const result = await queryEngine.query(args.query, { hops: 2 });
3950
- const nodes = result.documents;
4383
+ const result = await queryEngine.query(args.query, {
4384
+ hops: normalizeHops(args.hops)
4385
+ });
4386
+ const visibility = await Promise.all(
4387
+ result.documents.map(async (n) => ({
4388
+ node: n,
4389
+ visible: await resolveLlmBody(ctx, n) !== null
4390
+ }))
4391
+ );
4392
+ const nodes = visibility.filter((e) => e.visible).map((e) => e.node);
3951
4393
  if (!nodes.length) return `No nodes matched: ${args.query}`;
3952
4394
  const list = nodes.map(
3953
4395
  (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}`
@@ -3995,21 +4437,99 @@ ${body || "(no content)"}`;
3995
4437
  ${list}`;
3996
4438
  }
3997
4439
  case "context_resolve": {
3998
- const result = await queryEngine.query(args.selector, { hops: 2 });
4440
+ const result = await queryEngine.query(args.selector, {
4441
+ hops: normalizeHops(args.hops)
4442
+ });
3999
4443
  const maxTokens = args.max_tokens || 8e3;
4000
4444
  const approxChars = maxTokens * 4;
4445
+ const resolvedBodies = await Promise.all(
4446
+ result.documents.map(async (n) => ({
4447
+ node: n,
4448
+ body: await resolveLlmBody(ctx, n)
4449
+ }))
4450
+ );
4001
4451
  let total = 0;
4002
4452
  const resolved = [];
4003
- for (const n of result.documents) {
4453
+ for (const { node: n, body } of resolvedBodies) {
4454
+ if (body === null) continue;
4004
4455
  const entry = `## ${n.frontmatter.title}
4005
4456
 
4006
- ${n.body || ""}`;
4457
+ ${body}`;
4007
4458
  if (total + entry.length > approxChars) break;
4008
4459
  resolved.push(entry);
4009
4460
  total += entry.length;
4010
4461
  }
4011
4462
  return resolved.join("\n\n---\n\n") || "No nodes resolved.";
4012
4463
  }
4464
+ case "context_export": {
4465
+ const docs = await storage.discoverDocuments();
4466
+ const approxChars = args.max_tokens ? args.max_tokens * 4 : Infinity;
4467
+ const resolved = await Promise.all(
4468
+ docs.map(async (n) => ({ node: n, body: await resolveLlmBody(ctx, n) }))
4469
+ );
4470
+ let total = 0;
4471
+ let budgetHit = false;
4472
+ const parts = [];
4473
+ for (const { node: n, body } of resolved) {
4474
+ if (body === null) continue;
4475
+ const meta = [
4476
+ `**Title:** ${n.frontmatter.title}`,
4477
+ `**Type:** ${n.frontmatter.type || "document"}`,
4478
+ n.frontmatter.tags?.length ? `**Tags:** ${n.frontmatter.tags.join(" ")}` : null
4479
+ ].filter(Boolean).join("\n");
4480
+ const entry = `${meta}
4481
+
4482
+ ${body || "(no content)"}`;
4483
+ if (total + entry.length > approxChars) {
4484
+ budgetHit = true;
4485
+ break;
4486
+ }
4487
+ parts.push(entry);
4488
+ total += entry.length;
4489
+ }
4490
+ if (!parts.length) {
4491
+ return budgetHit ? "Token budget reached before any document fit \u2014 raise max_tokens, or use context_query/context_resolve to target." : "No documents available to export.";
4492
+ }
4493
+ const note = budgetHit ? `
4494
+
4495
+ _(Token budget reached \u2014 ${parts.length} of ${docs.length} documents included. Raise max_tokens, or use context_query/context_resolve to target.)_` : "";
4496
+ return `# Nest export \u2014 ${parts.length} document(s)
4497
+
4498
+ ${parts.join(
4499
+ "\n\n---\n\n"
4500
+ )}${note}`;
4501
+ }
4502
+ case "context_comments": {
4503
+ const docs = await storage.discoverDocuments();
4504
+ let node;
4505
+ if (args.title) {
4506
+ node = docs.find(
4507
+ (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
4508
+ );
4509
+ } else if (args.id) {
4510
+ node = docs.find((n) => n.id === args.id);
4511
+ }
4512
+ if (!node) return `Node not found: ${args.title || args.id}`;
4513
+ if (await resolveLlmBody(ctx, node) === null) {
4514
+ return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
4515
+ }
4516
+ const threads = await listThreads(nestId, node.id);
4517
+ if (!threads.length)
4518
+ return `No comments on "${node.frontmatter.title}".`;
4519
+ const openCount = threads.filter((t) => t.status === "open").length;
4520
+ const sections = threads.map((t, i) => {
4521
+ const quote = t.anchor?.quote ? `> ${t.anchor.quote.replace(/\n/g, " ")}
4522
+
4523
+ ` : "";
4524
+ const head = `### ${i + 1}. [${t.status}]${t.anchor?.quote ? "" : " (whole-document)"}`;
4525
+ const comments = t.comments.map((c) => `- **${c.author}** (${c.createdAt}): ${c.body}`).join("\n");
4526
+ return `${head}
4527
+ ${quote}${comments}`;
4528
+ }).join("\n\n");
4529
+ return `# Comments on "${node.frontmatter.title}" \u2014 ${threads.length} thread(s), ${openCount} open
4530
+
4531
+ ${sections}`;
4532
+ }
4013
4533
  case "context_create": {
4014
4534
  if (!await canCreateInNest(nestId, userEmail)) {
4015
4535
  return "You don't have permission to create documents in this nest.";
@@ -4021,7 +4541,8 @@ ${n.body || ""}`;
4021
4541
  content: args.content,
4022
4542
  type: args.type,
4023
4543
  tags: args.tags,
4024
- scope: args.scope
4544
+ scope: args.scope,
4545
+ folder: args.folder
4025
4546
  },
4026
4547
  userEmail
4027
4548
  );
@@ -4068,8 +4589,8 @@ ${n.body || ""}`;
4068
4589
 
4069
4590
  ${list}`;
4070
4591
  }
4071
- if (!canManageStewards(ctx.userEmail)) {
4072
- return "You don't have permission to list stewards. Only the super admin can do this.";
4592
+ if (!await canManageStewards(ctx.nestId, ctx.userId)) {
4593
+ return "You don't have permission to list stewards. Only a nest admin, the nest owner, or the server admin can do this.";
4073
4594
  }
4074
4595
  const allStewards = await getStewardsForNest(ctx.nestId);
4075
4596
  if (allStewards.length === 0) {
@@ -4207,8 +4728,8 @@ ${list}`;
4207
4728
  if (!["nest", "tag", "document"].includes(scope)) {
4208
4729
  return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
4209
4730
  }
4210
- if (!canManageStewards(ctx.userEmail)) {
4211
- return "You don't have permission to manage stewards. Only the super admin can do this.";
4731
+ if (!await canManageStewards(ctx.nestId, ctx.userId)) {
4732
+ return "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this.";
4212
4733
  }
4213
4734
  try {
4214
4735
  await createStewardRecord({
@@ -4226,9 +4747,9 @@ ${list}`;
4226
4747
  }
4227
4748
  }
4228
4749
  case "context_share_nest": {
4229
- const roles = await resolveUserRoles(ctx.nestId, ctx.userEmail);
4230
- if (!canManageWith(roles)) {
4231
- return "You don't have permission to share this nest via this tool (admin only). You can still invite from the UI with write access, or ask a nest admin.";
4750
+ const callerPermission = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(ctx.nestId, ctx.userId);
4751
+ if (permissionLevel(callerPermission) < permissionLevel("write")) {
4752
+ return "You don't have permission to share this nest. Sharing needs write access \u2014 ask a nest admin or the owner.";
4232
4753
  }
4233
4754
  const permission = args.permission || "read";
4234
4755
  try {
@@ -4236,7 +4757,8 @@ ${list}`;
4236
4757
  nestId: ctx.nestId,
4237
4758
  email: args.email,
4238
4759
  permission,
4239
- grantedByEmail: ctx.userEmail
4760
+ grantedByEmail: ctx.userEmail,
4761
+ callerPermission
4240
4762
  });
4241
4763
  const label = permission === "admin" ? "admin" : permission === "write" ? "editor" : "viewer";
4242
4764
  return `Shared this nest with **${args.email}** as ${label}.`;
@@ -4337,8 +4859,407 @@ mcpRoutes.all("/", async (c) => {
4337
4859
  }
4338
4860
  });
4339
4861
 
4340
- // src/governance/routes.ts
4862
+ // src/mcp/server-routes.ts
4341
4863
  import { Hono as Hono8 } from "hono";
4864
+ import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
4865
+ import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4866
+ import { z as z2 } from "zod";
4867
+ async function getUserEmail3(userId) {
4868
+ const db = getDb();
4869
+ const user = await db.get("SELECT email FROM users WHERE id = ?", [
4870
+ userId
4871
+ ]);
4872
+ return user?.email || "anonymous@localhost";
4873
+ }
4874
+ async function canRead(nestId, userId) {
4875
+ if (config.AUTH_MODE === "open") return true;
4876
+ const perm = await resolveNestPermission(nestId, userId);
4877
+ return permissionLevel(perm) >= permissionLevel("read");
4878
+ }
4879
+ async function resolveNestRef(ref, userId) {
4880
+ const db = getDb();
4881
+ const byId = await db.get("SELECT * FROM nests WHERE id = ?", [ref]);
4882
+ if (byId) return byId;
4883
+ const byName = await db.all(
4884
+ "SELECT * FROM nests WHERE LOWER(name) = LOWER(?) OR LOWER(slug) = LOWER(?)",
4885
+ [ref, ref]
4886
+ );
4887
+ if (byName.length <= 1) return byName[0] ?? null;
4888
+ const readable = (await Promise.all(
4889
+ byName.map(async (n) => await canRead(n.id, userId) ? n : null)
4890
+ )).filter((n) => n !== null);
4891
+ return readable.length === 1 ? readable[0] : null;
4892
+ }
4893
+ async function buildNestIndex(userId, nestScope) {
4894
+ const [owned, shared, publicExtras] = await Promise.all([
4895
+ listNests(userId),
4896
+ listSharedNests(userId),
4897
+ listPublicNests(userId)
4898
+ ]);
4899
+ const seen = /* @__PURE__ */ new Set();
4900
+ const rows = [];
4901
+ for (const n of [...owned, ...shared, ...publicExtras]) {
4902
+ if (seen.has(n.id)) continue;
4903
+ if (nestScope && n.id !== nestScope) continue;
4904
+ seen.add(n.id);
4905
+ rows.push(n);
4906
+ }
4907
+ return Promise.all(
4908
+ rows.map(async (n) => {
4909
+ let permission = "read";
4910
+ try {
4911
+ permission = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(n.id, userId);
4912
+ } catch {
4913
+ }
4914
+ let document_count = null;
4915
+ try {
4916
+ const { storage } = await engineCache.get(n.id);
4917
+ document_count = (await storage.discoverDocuments()).length;
4918
+ } catch {
4919
+ }
4920
+ return {
4921
+ id: n.id,
4922
+ name: n.name,
4923
+ description: n.description,
4924
+ visibility: n.visibility,
4925
+ permission,
4926
+ document_count
4927
+ };
4928
+ })
4929
+ );
4930
+ }
4931
+ function renderNestIndexMarkdown(nests, baseUrl) {
4932
+ const lines = [
4933
+ "# Nest index",
4934
+ "",
4935
+ `${nests.length} nest${nests.length === 1 ? "" : "s"} accessible with these credentials. Full interaction manual: ${baseUrl}/llms.txt`,
4936
+ ""
4937
+ ];
4938
+ for (const n of nests) {
4939
+ lines.push(`## ${n.name}`);
4940
+ lines.push("");
4941
+ lines.push(`- id: \`${n.id}\``);
4942
+ if (n.description) lines.push(`- description: ${n.description}`);
4943
+ lines.push(`- permission: ${n.permission} \xB7 visibility: ${n.visibility}`);
4944
+ if (n.document_count !== null)
4945
+ lines.push(`- documents: ${n.document_count}`);
4946
+ lines.push(
4947
+ `- retrieve: \`POST ${baseUrl}/nests/${n.id}/context\` \xB7 mcp: \`${baseUrl}/nests/${n.id}/mcp\` \xB7 export: \`${baseUrl}/nests/${n.id}/export?format=markdown\``
4948
+ );
4949
+ lines.push("");
4950
+ }
4951
+ return lines.join("\n");
4952
+ }
4953
+ var NEST_ARG = {
4954
+ nest: {
4955
+ type: "string",
4956
+ description: "Target nest \u2014 id, or exact name/slug (case-insensitive)"
4957
+ }
4958
+ };
4959
+ var SERVER_TOOL_DEFINITIONS = [
4960
+ {
4961
+ name: "nest_index",
4962
+ 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.",
4963
+ inputSchema: { type: "object", properties: {} }
4964
+ },
4965
+ {
4966
+ name: "context_query",
4967
+ description: "Run a structured selector query against one nest. Selector grammar: #tag, type:X, [[Title]], scope:X, combined with +AND, |OR, -NOT.",
4968
+ inputSchema: {
4969
+ type: "object",
4970
+ properties: {
4971
+ ...NEST_ARG,
4972
+ query: { type: "string", description: "Selector query" },
4973
+ hops: {
4974
+ type: "number",
4975
+ description: "Graph traversal depth from the matches (default: 2)"
4976
+ }
4977
+ },
4978
+ required: ["nest", "query"]
4979
+ }
4980
+ },
4981
+ {
4982
+ name: "context_search",
4983
+ description: "Full-text keyword search across one nest.",
4984
+ inputSchema: {
4985
+ type: "object",
4986
+ properties: {
4987
+ ...NEST_ARG,
4988
+ query: { type: "string", description: "Search terms" }
4989
+ },
4990
+ required: ["nest", "query"]
4991
+ }
4992
+ },
4993
+ {
4994
+ name: "context_get",
4995
+ description: "Get the FULL content of a node in a nest by title or id.",
4996
+ inputSchema: {
4997
+ type: "object",
4998
+ properties: {
4999
+ ...NEST_ARG,
5000
+ title: { type: "string", description: "Title of the node" },
5001
+ id: { type: "string", description: "ID of the node" }
5002
+ },
5003
+ required: ["nest"]
5004
+ }
5005
+ },
5006
+ {
5007
+ name: "context_list",
5008
+ description: "Browse one nest's contents with optional type, tag, or limit filters.",
5009
+ inputSchema: {
5010
+ type: "object",
5011
+ properties: {
5012
+ ...NEST_ARG,
5013
+ type: { type: "string", description: "Filter by node type" },
5014
+ tag: { type: "string", description: "Filter by tag" },
5015
+ limit: { type: "number", description: "Max nodes to return" }
5016
+ },
5017
+ required: ["nest"]
5018
+ }
5019
+ },
5020
+ {
5021
+ name: "context_overview",
5022
+ description: "Map of one nest: node count, types, tags, title+snippet per node.",
5023
+ inputSchema: {
5024
+ type: "object",
5025
+ properties: { ...NEST_ARG },
5026
+ required: ["nest"]
5027
+ }
5028
+ },
5029
+ {
5030
+ name: "context_resolve",
5031
+ description: "Full deterministic context resolution against one nest \u2014 selector + hops + token budget, complete node bodies.",
5032
+ inputSchema: {
5033
+ type: "object",
5034
+ properties: {
5035
+ ...NEST_ARG,
5036
+ selector: { type: "string", description: "Selector query string" },
5037
+ max_tokens: {
5038
+ type: "number",
5039
+ description: "Approximate token budget (default: 8000)"
5040
+ },
5041
+ hops: {
5042
+ type: "number",
5043
+ description: "Graph traversal depth from the matches (default: 2)"
5044
+ }
5045
+ },
5046
+ required: ["nest", "selector"]
5047
+ }
5048
+ }
5049
+ ];
5050
+ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
5051
+ if (toolName === "nest_index") {
5052
+ const nests = await buildNestIndex(userId, nestScope);
5053
+ if (!nests.length) return "No nests accessible with these credentials.";
5054
+ return renderNestIndexMarkdown(nests, baseUrl);
5055
+ }
5056
+ const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
5057
+ const nest = await resolveNestRef(String(args.nest ?? ""), userId);
5058
+ if (!nest) return inaccessible;
5059
+ if (nestScope && nest.id !== nestScope) return inaccessible;
5060
+ if (!await canRead(nest.id, userId)) return inaccessible;
5061
+ const engine = await engineCache.get(nest.id);
5062
+ return handleToolCall(toolName, args, {
5063
+ storage: engine.storage,
5064
+ queryEngine: engine.query,
5065
+ versionManager: engine.versions,
5066
+ nestId: nest.id,
5067
+ userId,
5068
+ userEmail
5069
+ });
5070
+ }
5071
+ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
5072
+ const server = new McpServer2(
5073
+ { name: "contextnest-server", version: "1.0.0" },
5074
+ { capabilities: { tools: {} } }
5075
+ );
5076
+ for (const tool of SERVER_TOOL_DEFINITIONS) {
5077
+ const props = tool.inputSchema.properties || {};
5078
+ const required = tool.inputSchema.required || [];
5079
+ const shape = {};
5080
+ for (const [key, def] of Object.entries(props)) {
5081
+ let field;
5082
+ if (def.type === "string") field = z2.string();
5083
+ else if (def.type === "number") field = z2.number();
5084
+ else if (def.type === "array") field = z2.array(z2.string());
5085
+ else field = z2.any();
5086
+ if (!required.includes(key)) field = field.optional();
5087
+ shape[key] = field;
5088
+ }
5089
+ server.tool(tool.name, tool.description, shape, async (args) => {
5090
+ const text = await handleServerToolCall(
5091
+ tool.name,
5092
+ args,
5093
+ userId,
5094
+ userEmail,
5095
+ nestScope,
5096
+ baseUrl
5097
+ );
5098
+ return { content: [{ type: "text", text }] };
5099
+ });
5100
+ }
5101
+ return server;
5102
+ }
5103
+ function requestBaseUrl(reqUrl) {
5104
+ if (config.PUBLIC_BASE_URL) return config.PUBLIC_BASE_URL;
5105
+ try {
5106
+ return new URL(reqUrl).origin;
5107
+ } catch {
5108
+ return "";
5109
+ }
5110
+ }
5111
+ var serverMcpRoutes = new Hono8();
5112
+ serverMcpRoutes.all("/", async (c) => {
5113
+ const userId = c.get("userId");
5114
+ const userEmail = await getUserEmail3(userId);
5115
+ const server = createServerMcp(
5116
+ userId,
5117
+ userEmail,
5118
+ c.get("nestScope"),
5119
+ requestBaseUrl(c.req.url)
5120
+ );
5121
+ const transport = new WebStandardStreamableHTTPServerTransport2({
5122
+ sessionIdGenerator: void 0,
5123
+ enableJsonResponse: true
5124
+ });
5125
+ await server.server.connect(transport);
5126
+ try {
5127
+ return await transport.handleRequest(c.req.raw);
5128
+ } finally {
5129
+ await transport.close();
5130
+ await server.server.close();
5131
+ }
5132
+ });
5133
+ var nestIndexRoutes = new Hono8();
5134
+ nestIndexRoutes.get("/", async (c) => {
5135
+ const userId = c.get("userId");
5136
+ const nests = await buildNestIndex(userId, c.get("nestScope"));
5137
+ if (c.req.query("format") === "json") {
5138
+ return c.json({ count: nests.length, nests });
5139
+ }
5140
+ const md = renderNestIndexMarkdown(nests, requestBaseUrl(c.req.url));
5141
+ return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
5142
+ });
5143
+ function renderLlmsTxt(baseUrl) {
5144
+ const B = baseUrl || "<server-url>";
5145
+ return `# ContextNest Community Server
5146
+
5147
+ Self-hosted context governance server. Nests are versioned knowledge vaults
5148
+ of markdown documents with tags, wiki-links ([[Title]]), and steward
5149
+ governance. This file is the complete interaction manual for agents.
5150
+
5151
+ ## Authentication
5152
+
5153
+ Every request (except this file and /health): \`Authorization: Bearer cnst_<api-key>\`.
5154
+ Keys are minted in the web UI (Connect dialog \u2192 Generate Key). A key is
5155
+ either user-level (sees every nest the user can read) or scoped to one nest.
5156
+ Servers in "open" auth mode need no key.
5157
+
5158
+ ## Start here
5159
+
5160
+ 1. \`GET ${B}/index\` \u2014 your grounded nest index (markdown; \`?format=json\` for JSON).
5161
+ One entry per accessible nest: name, id, permission, document count, and
5162
+ the per-nest URLs below.
5163
+ 2. Pick a nest id, then retrieve with the API or MCP.
5164
+
5165
+ ## Deterministic retrieval API
5166
+
5167
+ Same inputs \u2192 same context, every time. The primary call:
5168
+
5169
+ \`\`\`
5170
+ POST ${B}/nests/<nest-id>/context
5171
+ Content-Type: application/json
5172
+ { "selector": "#gtm", "hops": 2, "max_tokens": 4000, "include_drafts": false }
5173
+ \`\`\`
5174
+
5175
+ Returns \`{ context, nodes[], trace }\` \u2014 assembled markdown plus a trace with
5176
+ \`hops_used\` and \`nodes_traversed\` so you can audit exactly what was pulled.
5177
+
5178
+ Selector grammar:
5179
+ - \`#tag\` \u2014 nodes carrying a tag
5180
+ - \`type:document\` \u2014 nodes of a type
5181
+ - \`[[Title]]\` \u2014 a node by exact title
5182
+ - \`scope:team\` \u2014 nodes by visibility scope
5183
+ - Combine: \`+AND\`, \`|OR\`, \`-NOT\` (e.g. \`#gtm+type:document-#draft\`)
5184
+
5185
+ Graph-hop logic: \`hops\` is the traversal depth over wiki-links from the
5186
+ selector matches. \`hops: 0\` = exactly the matches; \`hops: 1\` = matches plus
5187
+ directly linked nodes; \`hops: 2\` (default) and up walk the link graph
5188
+ further out. For an exact set with no expansion, OR the selectors together
5189
+ (\`[[A]]|[[B]]\`) and pass \`hops: 0\`.
5190
+
5191
+ Skip logic \u2014 what retrieval excludes and why:
5192
+ - Draft/unapproved documents are skipped unless \`include_drafts: true\`
5193
+ (and public/read-only callers always get approved content only).
5194
+ - \`max_tokens\` is a budget: documents that don't fit are skipped, and the
5195
+ trace reports \`truncated_by_budget\` so silence never means "complete".
5196
+ - Documents are deduped by id when a title match and a selector match overlap.
5197
+
5198
+ Other read endpoints (all under \`${B}/nests/<nest-id>\`):
5199
+ - \`POST /query\` \u2014 \`{ "query": "<selector>", "hops": N }\`; matches + snippets, no bodies
5200
+ - \`GET /search?q=<terms>\` \u2014 full-text search
5201
+ - \`GET /overview\` \u2014 node count, types, tags
5202
+ - \`GET /nodes\` \u2014 list; \`GET /nodes/<node-id>?format=markdown\` \u2014 one document
5203
+ - \`GET /export?format=markdown\` \u2014 whole nest as one markdown file (optional \`selector\`, \`max_tokens\`)
5204
+ - \`GET /graph\` \u2014 ontology graph (nodes, links, tags, stewards)
5205
+
5206
+ Write endpoints (need write permission):
5207
+ - \`POST /nodes\` \u2014 \`{ "title", "content", "tags"?, "type"?, "folder"? }\`
5208
+ - \`PATCH /nodes/<node-id>\` \u2014 \`{ "content"? , "append"?, "tags"?, "title"? }\`
5209
+ - \`DELETE /nodes/<node-id>\`
5210
+ - Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`
5211
+
5212
+ ## MCP (Model Context Protocol)
5213
+
5214
+ Streamable HTTP, stateless. Two endpoints:
5215
+
5216
+ - \`${B}/mcp\` \u2014 server-level, one connection for everything readable.
5217
+ Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
5218
+ \`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
5219
+ each takes a \`nest\` argument (id or exact name) plus the same
5220
+ selector/hops/max_tokens parameters as the REST API. Read-only.
5221
+ - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
5222
+ governance (create/update, submit/approve/reject, stewards, sharing).
5223
+
5224
+ Claude Code / Cursor / VS Code connect natively via HTTP with the
5225
+ \`Authorization: Bearer\` header. Claude Desktop needs the mcp-remote bridge:
5226
+
5227
+ \`\`\`
5228
+ npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
5229
+ \`\`\`
5230
+
5231
+ ## ctx CLI (npm: @promptowl/contextnest-cli)
5232
+
5233
+ \`npm i -g @promptowl/contextnest-cli\`
5234
+
5235
+ - \`ctx push --server ${B} --nest <nest-id> --key <api-key>\` \u2014 upload a local
5236
+ vault into a nest (add \`--include-drafts\` for unpublished nodes).
5237
+ - All other ctx commands (\`query\`, \`add\`, \`search\`, \`checkpoint\`) operate on
5238
+ a LOCAL vault directory only. For remote reads use the API or MCP above \u2014
5239
+ there is currently no remote \`ctx query\`.
5240
+
5241
+ ## npm packages
5242
+
5243
+ - \`@promptowl/contextnest-community\` \u2014 this server
5244
+ - \`@promptowl/contextnest-cli\` \u2014 the ctx CLI
5245
+ - \`@promptowl/contextnest-engine\` \u2014 vault storage + selector/graph engine
5246
+
5247
+ ## Conventions
5248
+
5249
+ - Node ids are paths: \`nodes/<slug>\` or nested \`nodes/<folder>/<slug>\`.
5250
+ - Tags are \`#lowercase\`. Wiki-links are \`[[Exact Title]]\`.
5251
+ - Every write is versioned and hash-chained; governed nests route writes
5252
+ through steward review before they become AI-visible.
5253
+ `;
5254
+ }
5255
+ var llmsTxtRoutes = new Hono8();
5256
+ llmsTxtRoutes.get("/", (c) => {
5257
+ const md = renderLlmsTxt(requestBaseUrl(c.req.url));
5258
+ return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
5259
+ });
5260
+
5261
+ // src/governance/routes.ts
5262
+ import { Hono as Hono9 } from "hono";
4342
5263
 
4343
5264
  // src/governance/comment-service.ts
4344
5265
  import { v4 as uuid4 } from "uuid";
@@ -4642,7 +5563,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
4642
5563
  if (Number.isNaN(n) || n < 1) return def;
4643
5564
  return Math.min(n, max);
4644
5565
  }
4645
- var governanceRoutes = new Hono8();
5566
+ var governanceRoutes = new Hono9();
4646
5567
  governanceRoutes.get("/stewards", async (c) => {
4647
5568
  const nestId = c.req.param("nestId");
4648
5569
  const scope = c.req.query("scope");
@@ -4671,7 +5592,7 @@ governanceRoutes.get("/stewards", async (c) => {
4671
5592
  governanceRoutes.post("/stewards", async (c) => {
4672
5593
  const nestId = c.req.param("nestId");
4673
5594
  const body = await c.req.json();
4674
- const assignedBy = await getUserEmail3(c);
5595
+ const assignedBy = await getUserEmail4(c);
4675
5596
  if (!body.scope) throw new ValidationError("scope is required");
4676
5597
  if (body.scope === "folder") {
4677
5598
  throw new ValidationError(
@@ -4746,7 +5667,7 @@ governanceRoutes.get("/review-queue", async (c) => {
4746
5667
  limit,
4747
5668
  offset
4748
5669
  });
4749
- const email = await getUserEmail3(c);
5670
+ const email = await getUserEmail4(c);
4750
5671
  const canReviewCache = /* @__PURE__ */ new Map();
4751
5672
  const requests = [];
4752
5673
  for (const r of result.requests) {
@@ -4770,7 +5691,7 @@ governanceRoutes.get("/external-edits", async (c) => {
4770
5691
  });
4771
5692
  governanceRoutes.post("/external-edits/scan", async (c) => {
4772
5693
  const nestId = c.req.param("nestId");
4773
- const actor = await getUserEmail3(c);
5694
+ const actor = await getUserEmail4(c);
4774
5695
  const result = await scanNestForDrift(nestId, actor);
4775
5696
  return c.json(result);
4776
5697
  });
@@ -4780,7 +5701,7 @@ governanceRoutes.get("/activity", async (c) => {
4780
5701
  const activity = await getActivity({ nestId, limit });
4781
5702
  return c.json({ activity });
4782
5703
  });
4783
- var governanceNodeRoutes = new Hono8();
5704
+ var governanceNodeRoutes = new Hono9();
4784
5705
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4785
5706
  const nestId = c.req.param("nestId");
4786
5707
  const nodeId = c.req.param("nodeId");
@@ -4839,7 +5760,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
4839
5760
  const nestId = c.req.param("nestId");
4840
5761
  const nodeId = c.req.param("nodeId");
4841
5762
  const body = await c.req.json();
4842
- const author = await getUserEmail3(c);
5763
+ const author = await getUserEmail4(c);
4843
5764
  try {
4844
5765
  const comment = await createComment({
4845
5766
  nestId,
@@ -4863,7 +5784,7 @@ governanceNodeRoutes.post(
4863
5784
  const nestId = c.req.param("nestId");
4864
5785
  const nodeId = c.req.param("nodeId");
4865
5786
  const commentId = c.req.param("commentId");
4866
- const resolvedBy = await getUserEmail3(c);
5787
+ const resolvedBy = await getUserEmail4(c);
4867
5788
  try {
4868
5789
  const comment = await resolveComment({
4869
5790
  nestId,
@@ -4896,7 +5817,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
4896
5817
  if (currentVersion === 0) {
4897
5818
  throw new ValidationError("Node has no versions to review");
4898
5819
  }
4899
- const userEmail = await getUserEmail3(c);
5820
+ const userEmail = await getUserEmail4(c);
4900
5821
  let request;
4901
5822
  try {
4902
5823
  request = await submitForReview({
@@ -4931,7 +5852,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
4931
5852
  const nestId = c.req.param("nestId");
4932
5853
  const nodeId = c.req.param("nodeId");
4933
5854
  const body = await c.req.json();
4934
- const userEmail = await getUserEmail3(c);
5855
+ const userEmail = await getUserEmail4(c);
4935
5856
  const isAdmin = isSuperAdmin(userEmail);
4936
5857
  try {
4937
5858
  const request = await approve({
@@ -4954,7 +5875,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
4954
5875
  if (!body.note) {
4955
5876
  throw new ValidationError("Rejection note is required");
4956
5877
  }
4957
- const userEmail = await getUserEmail3(c);
5878
+ const userEmail = await getUserEmail4(c);
4958
5879
  try {
4959
5880
  const request = await reject({
4960
5881
  nestId,
@@ -4971,19 +5892,19 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
4971
5892
  governanceNodeRoutes.get("/:nodeId{.+}/can-access", async (c) => {
4972
5893
  const nestId = c.req.param("nestId");
4973
5894
  const nodeId = c.req.param("nodeId");
4974
- const userEmail = await getUserEmail3(c);
5895
+ const userEmail = await getUserEmail4(c);
4975
5896
  return c.json(await canUserAccess(nestId, nodeId, userEmail));
4976
5897
  });
4977
5898
  governanceNodeRoutes.get("/:nodeId{.+}/can-approve", async (c) => {
4978
5899
  const nestId = c.req.param("nestId");
4979
5900
  const nodeId = c.req.param("nodeId");
4980
- const userEmail = await getUserEmail3(c);
5901
+ const userEmail = await getUserEmail4(c);
4981
5902
  return c.json(await canUserApprove(nestId, nodeId, userEmail));
4982
5903
  });
4983
5904
  governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
4984
5905
  const nestId = c.req.param("nestId");
4985
5906
  const nodeId = c.req.param("nodeId");
4986
- const userEmail = await getUserEmail3(c);
5907
+ const userEmail = await getUserEmail4(c);
4987
5908
  return c.json(await canUserEdit(nestId, nodeId, userEmail));
4988
5909
  });
4989
5910
  governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
@@ -5016,7 +5937,7 @@ governanceNodeRoutes.post(
5016
5937
  const nodeId = c.req.param("nodeId");
5017
5938
  const suggestionId = c.req.param("suggestionId");
5018
5939
  const body = await c.req.json().catch(() => ({}));
5019
- const actor = await getUserEmail3(c);
5940
+ const actor = await getUserEmail4(c);
5020
5941
  try {
5021
5942
  const result = await approveExternalEdit({
5022
5943
  nestId,
@@ -5052,7 +5973,7 @@ governanceNodeRoutes.post(
5052
5973
  if (!body.reason) {
5053
5974
  throw new ValidationError("Rejection reason is required");
5054
5975
  }
5055
- const actor = await getUserEmail3(c);
5976
+ const actor = await getUserEmail4(c);
5056
5977
  try {
5057
5978
  const result = await rejectExternalEdit({
5058
5979
  nestId,
@@ -5073,7 +5994,7 @@ governanceNodeRoutes.post(
5073
5994
  governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
5074
5995
  const nestId = c.req.param("nestId");
5075
5996
  const nodeId = c.req.param("nodeId");
5076
- const userEmail = await getUserEmail3(c);
5997
+ const userEmail = await getUserEmail4(c);
5077
5998
  const request = await cancelReview({
5078
5999
  nestId,
5079
6000
  nodeId,
@@ -5081,7 +6002,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
5081
6002
  });
5082
6003
  return c.json({ review: request });
5083
6004
  });
5084
- async function getUserEmail3(c) {
6005
+ async function getUserEmail4(c) {
5085
6006
  const userId = c.get("userId");
5086
6007
  const db = getDb();
5087
6008
  const user = await db.get(
@@ -5091,6 +6012,30 @@ async function getUserEmail3(c) {
5091
6012
  return user?.email || "anonymous@localhost";
5092
6013
  }
5093
6014
 
6015
+ // src/shared/node-id.ts
6016
+ function assertSafeNodeId(rawId) {
6017
+ let id = rawId;
6018
+ try {
6019
+ id = decodeURIComponent(rawId);
6020
+ } catch {
6021
+ }
6022
+ if (!id || id.length > 512) {
6023
+ throw new ValidationError("invalid node id");
6024
+ }
6025
+ if (id.includes("\0") || id.includes("\\")) {
6026
+ throw new ValidationError("invalid node id");
6027
+ }
6028
+ if (id.startsWith("/")) {
6029
+ throw new ValidationError("invalid node id (absolute path)");
6030
+ }
6031
+ for (const seg of id.split("/")) {
6032
+ if (seg === "" || seg === "." || seg === "..") {
6033
+ throw new ValidationError("invalid node id (path traversal)");
6034
+ }
6035
+ }
6036
+ return id;
6037
+ }
6038
+
5094
6039
  // src/auth/anonymous.ts
5095
6040
  import bcrypt from "bcryptjs";
5096
6041
  async function ensureAnonymousUser() {
@@ -5130,6 +6075,7 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
5130
6075
  function isPublicReadEligiblePath(method, path) {
5131
6076
  if (method !== "GET") return false;
5132
6077
  if (!/^\/nests\/[^/]+(\/.*)?$/.test(path)) return false;
6078
+ if (/^\/nests\/[^/]+\/stewards/.test(path)) return false;
5133
6079
  return !/\/(collaborators|visibility|settings|mcp)/.test(path);
5134
6080
  }
5135
6081
  var flexAuthMiddleware = createMiddleware2(async (c, next) => {
@@ -5153,7 +6099,7 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
5153
6099
  return c.json({ error: "Missing or invalid credentials" }, 401);
5154
6100
  });
5155
6101
  function createApp() {
5156
- const app = new Hono9();
6102
+ const app = new Hono10({ router: new LinearRouter() });
5157
6103
  const corsOrigins = config.CORS_ORIGINS;
5158
6104
  app.use(
5159
6105
  "*",
@@ -5174,6 +6120,22 @@ function createApp() {
5174
6120
  }
5175
6121
  return next();
5176
6122
  });
6123
+ app.use("*", async (c, next) => {
6124
+ const path = c.req.path;
6125
+ const traced = /^\/(nests|auth|admin|stats|license|index|mcp)(\/|$)/.test(path) && !path.endsWith("/mcp");
6126
+ const started = Date.now();
6127
+ await next();
6128
+ if (!traced) return;
6129
+ void logTraceEvent({
6130
+ kind: "api",
6131
+ method: c.req.method,
6132
+ path,
6133
+ nestId: /^\/nests\/([^/]+)/.exec(path)?.[1] ?? null,
6134
+ userId: c.get("userId") ?? null,
6135
+ status: c.res.status,
6136
+ durationMs: Date.now() - started
6137
+ });
6138
+ });
5177
6139
  app.get(
5178
6140
  "/health",
5179
6141
  (c) => c.json({
@@ -5273,7 +6235,8 @@ function createApp() {
5273
6235
  logo_url: config.LOGO_URL,
5274
6236
  telemetry_enabled: config.TELEMETRY_ENABLED,
5275
6237
  public_base_url: config.PUBLIC_BASE_URL,
5276
- max_body_bytes: config.MAX_BODY_BYTES
6238
+ max_body_bytes: config.MAX_BODY_BYTES,
6239
+ slack_webhook_url: config.SLACK_WEBHOOK_URL ?? ""
5277
6240
  });
5278
6241
  app.get("/admin/settings", async (c) => {
5279
6242
  if (!await adminSettingsAllowed(c))
@@ -5308,6 +6271,12 @@ function createApp() {
5308
6271
  if ("telemetry_enabled" in body) {
5309
6272
  pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
5310
6273
  }
6274
+ if ("slack_webhook_url" in body) {
6275
+ const v = String(body.slack_webhook_url ?? "").trim();
6276
+ if (v && !/^https:\/\//i.test(v))
6277
+ errors.push("slack_webhook_url must be an https:// URL (or empty to disable)");
6278
+ else pending.push({ name: "SLACK_WEBHOOK_URL", value: v || null });
6279
+ }
5311
6280
  if ("max_body_bytes" in body) {
5312
6281
  const n = Number(body.max_body_bytes);
5313
6282
  if (!Number.isFinite(n) || n < 1024 * 1024 || n > 500 * 1024 * 1024)
@@ -5333,6 +6302,29 @@ function createApp() {
5333
6302
  }
5334
6303
  return c.json({ settings: currentServerSettings() });
5335
6304
  });
6305
+ app.use("/admin/trace", flexAuthMiddleware);
6306
+ const traceAllowed = async (c) => {
6307
+ if (config.AUTH_MODE === "open") return true;
6308
+ const userId = c.get("userId");
6309
+ if (await isLicenseAdminUserId(userId)) return true;
6310
+ return isSuperAdmin(await resolveCallerEmail(userId));
6311
+ };
6312
+ app.get("/admin/trace", async (c) => {
6313
+ if (!await traceAllowed(c))
6314
+ return c.json({ error: "Only the server admin can view this." }, 403);
6315
+ const limitRaw = Number(c.req.query("limit"));
6316
+ const offsetRaw = Number(c.req.query("offset"));
6317
+ const limit = Number.isFinite(limitRaw) ? limitRaw : 25;
6318
+ const offset = Number.isFinite(offsetRaw) ? offsetRaw : 0;
6319
+ const { events, total } = await listTraceEvents({
6320
+ limit,
6321
+ offset,
6322
+ kind: c.req.query("kind"),
6323
+ nestId: c.req.query("nest") || void 0,
6324
+ user: c.req.query("user") || void 0
6325
+ });
6326
+ return c.json({ count: events.length, total, limit, offset, events });
6327
+ });
5336
6328
  app.use("/stats", flexAuthMiddleware);
5337
6329
  app.get("/stats", async (c) => {
5338
6330
  const db = getDb();
@@ -5359,7 +6351,12 @@ function createApp() {
5359
6351
  users: usersRow.c
5360
6352
  });
5361
6353
  });
5362
- const nestsApp = new Hono9();
6354
+ app.route("/llms.txt", llmsTxtRoutes);
6355
+ app.use("/index", flexAuthMiddleware);
6356
+ app.route("/index", nestIndexRoutes);
6357
+ app.use("/mcp", flexAuthMiddleware);
6358
+ app.route("/mcp", serverMcpRoutes);
6359
+ const nestsApp = new Hono10();
5363
6360
  nestsApp.use("*", flexAuthMiddleware);
5364
6361
  nestsApp.use("*", async (c, next) => {
5365
6362
  const localPath = c.req.path.replace(/^\/nests\//, "");
@@ -5416,10 +6413,10 @@ function createApp() {
5416
6413
  const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
5417
6414
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
5418
6415
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
5419
- if (isStewardRoster && !canManageStewards(await resolveCallerEmail(userId))) {
6416
+ if (isStewardRoster && permission !== "owner" && permission !== "admin") {
5420
6417
  return c.json(
5421
6418
  {
5422
- error: "You don't have permission to manage stewards. Only the super admin can do this."
6419
+ error: "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this."
5423
6420
  },
5424
6421
  403
5425
6422
  );
@@ -5468,10 +6465,14 @@ function createApp() {
5468
6465
  c.set("nestPermission", permission);
5469
6466
  return next();
5470
6467
  });
6468
+ nestsApp.use("/:nestId/nodes/:nodeId{.+}", async (c, next) => {
6469
+ assertSafeNodeId(c.req.param("nodeId"));
6470
+ return next();
6471
+ });
5471
6472
  nestsApp.route("/", nestRoutes);
5472
6473
  nestsApp.route("/:nestId", governanceRoutes);
5473
- nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
5474
6474
  nestsApp.route("/:nestId/nodes", annotationRoutes);
6475
+ nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
5475
6476
  nestsApp.route("/:nestId/nodes", nodeRoutes);
5476
6477
  nestsApp.route("/:nestId", queryRoutes);
5477
6478
  nestsApp.route("/:nestId", sharingRoutes);