@promptowl/contextnest-community 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/API.md +2092 -0
  2. package/CONFIGURATION.md +5 -2
  3. package/README.md +62 -10
  4. package/STEWARDSHIP.md +229 -0
  5. package/dist/{chunk-PX3P4FTU.js → chunk-2TPQTN4Y.js} +5 -4
  6. package/dist/{chunk-OQXZ43HG.js → chunk-5EZOPA47.js} +40 -10
  7. package/dist/{chunk-MY4JIWQD.js → chunk-I3CSD6CK.js} +1 -1
  8. package/dist/{chunk-W5ILNGPD.js → chunk-KIAAEHWL.js} +80 -28
  9. package/dist/{chunk-J2OQ3MEB.js → chunk-LA3VTQ22.js} +63 -1
  10. package/dist/{chunk-ZAV4QUZA.js → chunk-XUIWAWDO.js} +28 -33
  11. package/dist/{chunk-6WESEA75.js → chunk-ZTT4U4NE.js} +2 -2
  12. package/dist/{client-CSCHXUNX.js → client-KEY4PYJH.js} +1 -1
  13. package/dist/{engine-XKRP7GOQ.js → engine-S3QBQ7LH.js} +2 -2
  14. package/dist/{external-edit-service-TXW7SXSF.js → external-edit-service-6FNFPOJJ.js} +3 -3
  15. package/dist/{grants-service-UZ2MGDO5.js → grants-service-UT3EQ3R7.js} +2 -2
  16. package/dist/index.js +1261 -305
  17. package/dist/{migrations.postgres-KKICIH2C.js → migrations.postgres-AIQ7WSU7.js} +60 -4
  18. package/dist/{review-service-XFWTENTN.js → review-service-HHGOTO6P.js} +6 -6
  19. package/dist/{stewardship-service-5HBQGDMK.js → stewardship-service-4DHNRULG.js} +3 -3
  20. package/dist/{version-service-TKNYJMCM.js → version-service-KIOQME6U.js} +3 -3
  21. package/dist/web3/assets/ActivityTracePage-BOZHgJ8S.js +1 -0
  22. package/dist/web3/assets/AgentDocsPage-CIiaBqMy.js +1 -0
  23. package/dist/web3/assets/CollaboratorManager-By91wrIr.js +1 -0
  24. package/dist/web3/assets/CollaboratorsTab-D1EE64Fs.js +1 -0
  25. package/dist/web3/assets/DocumentEditor-DrQfrW3d.js +36 -0
  26. package/dist/web3/assets/DocumentsTab-Vba-AzBv.js +6 -0
  27. package/dist/web3/assets/ExternalEditsTab-BSzAQIGM.js +1 -0
  28. package/dist/web3/assets/MarkdownEditor-74N_naNQ.css +1 -0
  29. package/dist/web3/assets/MarkdownEditor-C9zqZjC5.js +643 -0
  30. package/dist/web3/assets/NestPageHeader-CoLUV1Oz.js +1 -0
  31. package/dist/web3/assets/NestView-CTZ55tXD.js +63 -0
  32. package/dist/web3/assets/OverviewTab-Dxi-Hu-N.js +1 -0
  33. package/dist/web3/assets/PersonCombobox-CPOlNs6G.js +1 -0
  34. package/dist/web3/assets/ReasonDialog-DgLxfRWv.js +1 -0
  35. package/dist/web3/assets/ReviewActions-OfOdqzCn.js +6 -0
  36. package/dist/web3/assets/ReviewTab-CyX8yxd3.js +1 -0
  37. package/dist/web3/assets/StewardsTab-DBEEfAQI.js +1 -0
  38. package/dist/web3/assets/SubmitForReviewModal-Ci2DdWs0.js +1 -0
  39. package/dist/web3/assets/alert-dialog-DTozKlmV.js +7 -0
  40. package/dist/web3/assets/arrow-left-BCKt4CwQ.js +6 -0
  41. package/dist/web3/assets/backlinks-CYd4xENL.js +24 -0
  42. package/dist/web3/assets/card-XG2dP5Xf.js +1 -0
  43. package/dist/web3/assets/chevron-left-BPwUT9DS.js +6 -0
  44. package/dist/web3/assets/circle-check-DI8IBGeo.js +6 -0
  45. package/dist/web3/assets/circle-x-VKVhC88W.js +6 -0
  46. package/dist/web3/assets/code-xml-CMabYZvH.js +6 -0
  47. package/dist/web3/assets/corner-down-right-BGxwK6wH.js +6 -0
  48. package/dist/web3/assets/count-skeleton-DMbdpscX.js +1 -0
  49. package/dist/web3/assets/dates-BCxbm4_q.js +1 -0
  50. package/dist/web3/assets/earth-BEp1EKTo.js +6 -0
  51. package/dist/web3/assets/file-exclamation-point-r8qZGIjP.js +6 -0
  52. package/dist/web3/assets/folder-input-Cmtx1Jhk.js +11 -0
  53. package/dist/web3/assets/folder-target-CUSWqImF.js +1 -0
  54. package/dist/web3/assets/index-BM-h3DwI.css +1 -0
  55. package/dist/web3/assets/index-C1wTSjfP.js +29 -0
  56. package/dist/web3/assets/index-EaX2yql0.js +389 -0
  57. package/dist/web3/assets/page-B3yvQvHy.js +1 -0
  58. package/dist/web3/assets/page-BExMvttJ.js +1 -0
  59. package/dist/web3/assets/page-BUOADv2X.js +1 -0
  60. package/dist/web3/assets/page-BYUFhYWZ.js +1 -0
  61. package/dist/web3/assets/page-Bicf02Cz.js +1 -0
  62. package/dist/web3/assets/page-BsPMDm5d.js +45 -0
  63. package/dist/web3/assets/page-ByyVLDVo.js +16 -0
  64. package/dist/web3/assets/page-CC0NJi8v.js +1 -0
  65. package/dist/web3/assets/page-CHb0F5hV.js +24 -0
  66. package/dist/web3/assets/page-CPjWQNKx.js +11 -0
  67. package/dist/web3/assets/page-CQt3ZGbf.js +2 -0
  68. package/dist/web3/assets/page-Cj1NetQ-.js +1 -0
  69. package/dist/web3/assets/page-CtaW65El.js +1 -0
  70. package/dist/web3/assets/page-DGOL9l8i.js +6 -0
  71. package/dist/web3/assets/page-title-ClvvBsIo.js +1 -0
  72. package/dist/web3/assets/play-C5YNKpAH.js +6 -0
  73. package/dist/web3/assets/refresh-cw-BhHzSH9m.js +6 -0
  74. package/dist/web3/assets/scroll-area-dRWncRqa.css +1 -0
  75. package/dist/web3/assets/scroll-area-hz-xtayP.js +1 -0
  76. package/dist/web3/assets/select-DBFTrUmI.js +6 -0
  77. package/dist/web3/assets/send-0CHvmluT.js +6 -0
  78. package/dist/web3/assets/settings-CeeF4LEb.js +6 -0
  79. package/dist/web3/assets/share-2-CMnOlOt-.js +6 -0
  80. package/dist/web3/assets/tag-DQ_6J5Gv.js +11 -0
  81. package/dist/web3/assets/trash-2-HBi-Pslz.js +6 -0
  82. package/dist/web3/assets/triangle-alert-CDy8-7sv.js +6 -0
  83. package/dist/web3/assets/user-plus-CvBNcpn4.js +6 -0
  84. package/dist/web3/assets/x-By7piikG.js +6 -0
  85. package/dist/web3/assets/zap-C2T8riBp.js +11 -0
  86. package/dist/web3/index.html +2 -2
  87. package/package.json +4 -2
  88. package/dist/web3/assets/index-C2hW_9j9.js +0 -1382
  89. package/dist/web3/assets/index-T4pilFNL.css +0 -1
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  getPendingReview,
19
19
  getReviewHistory,
20
20
  getReviewQueue,
21
+ hasAnyConnector,
21
22
  isNestTarget,
22
23
  isSafeConnectorUrl,
23
24
  listDeletionRequests,
@@ -37,7 +38,7 @@ import {
37
38
  submitForReview,
38
39
  withNodeWriteLock,
39
40
  withdrawDeletionRequest
40
- } from "./chunk-OQXZ43HG.js";
41
+ } from "./chunk-5EZOPA47.js";
41
42
  import {
42
43
  canCreateInNest,
43
44
  canManageStewards,
@@ -57,7 +58,7 @@ import {
57
58
  resolveUserRoles,
58
59
  syncFromConfig,
59
60
  updateSteward
60
- } from "./chunk-PX3P4FTU.js";
61
+ } from "./chunk-2TPQTN4Y.js";
61
62
  import {
62
63
  createGrant,
63
64
  deleteGrant,
@@ -66,7 +67,7 @@ import {
66
67
  listGrants,
67
68
  listUserGrants,
68
69
  resolveNodeGrant
69
- } from "./chunk-MY4JIWQD.js";
70
+ } from "./chunk-I3CSD6CK.js";
70
71
  import {
71
72
  generateApiKey,
72
73
  getKeyPrefix,
@@ -92,7 +93,7 @@ import {
92
93
  listMyDrafts,
93
94
  setApprovedVersion,
94
95
  upsertVersion
95
- } from "./chunk-6WESEA75.js";
96
+ } from "./chunk-ZTT4U4NE.js";
96
97
  import {
97
98
  approveExternalEdit,
98
99
  getExternalEditDetail,
@@ -104,12 +105,13 @@ import {
104
105
  scanDocumentForDrift,
105
106
  scanNestForDrift,
106
107
  startDriftScanner
107
- } from "./chunk-ZAV4QUZA.js";
108
+ } from "./chunk-XUIWAWDO.js";
108
109
  import {
109
110
  addMember,
110
111
  addWatcher,
111
112
  archiveNest,
112
113
  assertSafeNodeId,
114
+ buildDocContext,
113
115
  buildTitleMap,
114
116
  canApproveWith,
115
117
  canEditWith,
@@ -119,7 +121,6 @@ import {
119
121
  createTeam,
120
122
  deleteNest,
121
123
  deleteTeam,
122
- describeNode,
123
124
  disableStewardshipAndWipeGovernance,
124
125
  docLink,
125
126
  engineApi,
@@ -169,6 +170,7 @@ import {
169
170
  normalizeTag,
170
171
  notifyNestInvite,
171
172
  nowExpr,
173
+ nowMsExpr,
172
174
  opContext,
173
175
  parseSharedTeams,
174
176
  permissionLevel,
@@ -200,6 +202,7 @@ import {
200
202
  shareTeamWithNest,
201
203
  startLicenseSafetyPoll,
202
204
  startTelemetryLoop,
205
+ suffixOf,
203
206
  syncNodeTags,
204
207
  trackEvent,
205
208
  uniqueNestName,
@@ -208,7 +211,7 @@ import {
208
211
  updateMemberRole,
209
212
  validateLicense,
210
213
  verifySmtp
211
- } from "./chunk-W5ILNGPD.js";
214
+ } from "./chunk-KIAAEHWL.js";
212
215
  import {
213
216
  AppError,
214
217
  ConflictError,
@@ -226,7 +229,7 @@ import {
226
229
  initDb,
227
230
  isEmailListish,
228
231
  isEmailish
229
- } from "./chunk-J2OQ3MEB.js";
232
+ } from "./chunk-LA3VTQ22.js";
230
233
  import {
231
234
  ANON_EMAIL,
232
235
  ANON_USER_ID,
@@ -240,7 +243,7 @@ import {
240
243
  import { serve } from "@hono/node-server";
241
244
 
242
245
  // src/app.ts
243
- import { Hono as Hono23 } from "hono";
246
+ import { Hono as Hono25 } from "hono";
244
247
  import { LinearRouter } from "hono/router/linear-router";
245
248
  import { createMiddleware as createMiddleware2 } from "hono/factory";
246
249
  import { cors } from "hono/cors";
@@ -270,7 +273,7 @@ function applyArtifactSandboxHeaders(c) {
270
273
 
271
274
  // src/auth/routes.ts
272
275
  import { Hono as Hono2 } from "hono";
273
- import { v4 as uuid3 } from "uuid";
276
+ import { v4 as uuid4 } from "uuid";
274
277
 
275
278
  // src/auth/middleware.ts
276
279
  import { createMiddleware } from "hono/factory";
@@ -565,10 +568,89 @@ function verifySsoTicket(token, secret, nowSec = Math.floor(Date.now() / 1e3)) {
565
568
  return claims;
566
569
  }
567
570
 
571
+ // src/auth/community-sites.ts
572
+ import { v4 as uuid } from "uuid";
573
+ function rowToSite(row) {
574
+ return { ...row, is_active: !!row.is_active };
575
+ }
576
+ var SELECT_COLUMNS = "id, name, url, secret, is_active, created_at, updated_at";
577
+ var activeSitesCache = [];
578
+ async function loadActiveCommunitySites() {
579
+ const rows = await getDb().all(
580
+ `SELECT ${SELECT_COLUMNS} FROM community_sites WHERE is_active = 1 ORDER BY name`
581
+ );
582
+ activeSitesCache = rows.map(rowToSite);
583
+ }
584
+ function getActiveCommunitySites() {
585
+ return activeSitesCache;
586
+ }
587
+ async function listCommunitySites() {
588
+ const rows = await getDb().all(
589
+ `SELECT ${SELECT_COLUMNS} FROM community_sites ORDER BY name`
590
+ );
591
+ return rows.map(rowToSite);
592
+ }
593
+ async function getCommunitySite(id) {
594
+ const row = await getDb().get(
595
+ `SELECT ${SELECT_COLUMNS} FROM community_sites WHERE id = ?`,
596
+ [id]
597
+ );
598
+ return row ? rowToSite(row) : null;
599
+ }
600
+ async function createCommunitySite(input) {
601
+ const id = uuid();
602
+ await getDb().run(
603
+ `INSERT INTO community_sites (id, name, url, secret, is_active) VALUES (?, ?, ?, ?, ?)`,
604
+ [id, input.name, input.url, input.secret, input.is_active ? 1 : 0]
605
+ );
606
+ const site = await getCommunitySite(id);
607
+ if (!site) throw new Error("Failed to create community site");
608
+ return site;
609
+ }
610
+ async function updateCommunitySite(id, patch) {
611
+ const existing = await getCommunitySite(id);
612
+ if (!existing) return null;
613
+ const next = {
614
+ name: patch.name ?? existing.name,
615
+ url: patch.url ?? existing.url,
616
+ secret: patch.secret ?? existing.secret,
617
+ is_active: patch.is_active ?? existing.is_active
618
+ };
619
+ const db = getDb();
620
+ await db.run(
621
+ `UPDATE community_sites
622
+ SET name = ?, url = ?, secret = ?, is_active = ?, updated_at = ${nowExpr(db)}
623
+ WHERE id = ?`,
624
+ [next.name, next.url, next.secret, next.is_active ? 1 : 0, id]
625
+ );
626
+ return getCommunitySite(id);
627
+ }
628
+ async function deleteCommunitySite(id) {
629
+ const result = await getDb().run("DELETE FROM community_sites WHERE id = ?", [
630
+ id
631
+ ]);
632
+ return result.changes > 0;
633
+ }
634
+ function legacyCandidate() {
635
+ const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
636
+ if (!secret) return null;
637
+ return { id: "legacy", label: "legacy", secret, url: config.PUBLIC_BASE_URL };
638
+ }
639
+ function ssoCandidates() {
640
+ const fromDb = getActiveCommunitySites().map((s) => ({
641
+ id: s.id,
642
+ label: s.name,
643
+ secret: s.secret,
644
+ url: s.url.replace(/\/$/, "")
645
+ }));
646
+ const legacy = legacyCandidate();
647
+ return legacy ? [...fromDb, legacy] : fromDb;
648
+ }
649
+
568
650
  // src/auth/oidc.ts
569
651
  import { Hono } from "hono";
570
652
  import { createHash, randomBytes as randomBytes2 } from "crypto";
571
- import { v4 as uuid } from "uuid";
653
+ import { v4 as uuid2 } from "uuid";
572
654
  import { createRemoteJWKSet, jwtVerify } from "jose";
573
655
 
574
656
  // src/shared/base-url.ts
@@ -824,8 +906,8 @@ oidcRoutes.get("/callback", async (c) => {
824
906
  );
825
907
  if (!user) {
826
908
  if (!config.OIDC_AUTO_PROVISION) return bounce("not_invited");
827
- const userId = uuid();
828
- const placeholderHash = await hashPassword(uuid());
909
+ const userId = uuid2();
910
+ const placeholderHash = await hashPassword(uuid2());
829
911
  try {
830
912
  await db.run(
831
913
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
@@ -897,7 +979,7 @@ oidcRoutes.get("/logout", async (c) => {
897
979
  });
898
980
 
899
981
  // src/auth/token-exchange.ts
900
- import { v4 as uuid2 } from "uuid";
982
+ import { v4 as uuid3 } from "uuid";
901
983
  import { jwtVerify as jwtVerify2 } from "jose";
902
984
  var TX_LIMIT = { max: 60, windowMs: 15 * 6e4 };
903
985
  var ExchangeError = class extends Error {
@@ -984,8 +1066,8 @@ async function resolveUser(email, name, department) {
984
1066
  if (!config.OIDC_AUTO_PROVISION || config.OIDC_ALLOWED_DOMAINS.length === 0) {
985
1067
  throw new ExchangeError("no_account", 403);
986
1068
  }
987
- const userId = uuid2();
988
- const placeholderHash = await hashPassword(uuid2());
1069
+ const userId = uuid3();
1070
+ const placeholderHash = await hashPassword(uuid3());
989
1071
  try {
990
1072
  await db.run(
991
1073
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
@@ -1161,8 +1243,8 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
1161
1243
  [meEmail]
1162
1244
  );
1163
1245
  if (!user) {
1164
- const userId = uuid3();
1165
- const placeholderHash = await hashPassword(uuid3());
1246
+ const userId = uuid4();
1247
+ const placeholderHash = await hashPassword(uuid4());
1166
1248
  await db.run(
1167
1249
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
1168
1250
  [userId, meEmail, rawName || null, placeholderHash]
@@ -1235,7 +1317,7 @@ authRoutes.post("/register", async (c) => {
1235
1317
  }
1236
1318
  throw new ValidationError("Email already registered");
1237
1319
  }
1238
- const userId = uuid3();
1320
+ const userId = uuid4();
1239
1321
  const passwordHash = await hashPassword(body.password);
1240
1322
  await db.run(
1241
1323
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
@@ -1337,7 +1419,7 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
1337
1419
  });
1338
1420
  if (!check.ok) return c.json({ error: check.error }, check.status);
1339
1421
  const apiKey = generateApiKey();
1340
- const keyId = uuid3();
1422
+ const keyId = uuid4();
1341
1423
  await db.run(
1342
1424
  "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)",
1343
1425
  [
@@ -1366,7 +1448,7 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1366
1448
  if (!check.ok) return c.json({ error: check.error }, check.status);
1367
1449
  const prior = check.key;
1368
1450
  const apiKey = generateApiKey();
1369
- const keyId = uuid3();
1451
+ const keyId = uuid4();
1370
1452
  const nestScope = "nest_id" in body ? body.nest_id || null : prior.nest_id;
1371
1453
  const replaced = await db.transaction(async (tx) => {
1372
1454
  const removed = await tx.run(
@@ -1520,25 +1602,30 @@ authRoutes.post("/promptowl", async (c) => {
1520
1602
  });
1521
1603
  authRoutes.get("/sso", async (c) => {
1522
1604
  const ssoError = (code) => c.redirect(`/?sso_error=${code}`, 302);
1523
- const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
1524
- if (!secret) return ssoError("not_supported");
1605
+ const candidates = ssoCandidates();
1606
+ if (candidates.length === 0) return ssoError("not_supported");
1525
1607
  if (!tryConsume(`sso:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
1526
1608
  return ssoError("rate_limited");
1527
1609
  }
1528
1610
  const ticket = c.req.query("ticket");
1529
1611
  if (!ticket) return ssoError("missing_ticket");
1530
1612
  let claims;
1531
- try {
1532
- claims = verifySsoTicket(ticket, secret);
1533
- } catch {
1534
- return ssoError("invalid_ticket");
1613
+ let matched;
1614
+ for (const candidate of candidates) {
1615
+ try {
1616
+ claims = verifySsoTicket(ticket, candidate.secret);
1617
+ matched = candidate;
1618
+ break;
1619
+ } catch {
1620
+ }
1535
1621
  }
1622
+ if (!claims || !matched) return ssoError("invalid_ticket");
1536
1623
  if ((claims.scope || "") === "mcp") return ssoError("invalid_ticket");
1537
1624
  const expectedAud = config.PUBLIC_BASE_URL;
1538
1625
  if (!expectedAud && !warnedMissingSsoAudience) {
1539
1626
  warnedMissingSsoAudience = true;
1540
1627
  console.warn(
1541
- "[sso] OFFICIAL_COMMUNITY_SSO_SECRET is set but PUBLIC_BASE_URL is empty \u2014 ticket audience binding is DISABLED. Set PUBLIC_BASE_URL so a ticket minted for another server cannot be replayed against this one."
1628
+ `[sso] site "${matched.label}" has a secret but PUBLIC_BASE_URL is unset \u2014 ticket audience binding is DISABLED. Set PUBLIC_BASE_URL to this deployment's own URL so a ticket minted for another deployment cannot be replayed against this one.`
1542
1629
  );
1543
1630
  }
1544
1631
  if (expectedAud) {
@@ -1609,6 +1696,15 @@ authRoutes.get("/admin-status", async (c) => {
1609
1696
  me
1610
1697
  });
1611
1698
  });
1699
+ authRoutes.patch("/profile", authMiddleware, async (c) => {
1700
+ const userId = c.get("userId");
1701
+ const body = await c.req.json();
1702
+ if (typeof body.name !== "string") throw new ValidationError("name is required");
1703
+ const name = body.name.trim();
1704
+ if (name.length > 80) throw new ValidationError("name must be 80 characters or fewer");
1705
+ await getDb().run("UPDATE users SET name = ? WHERE id = ?", [name || null, userId]);
1706
+ return c.json({ ok: true, name: name || null });
1707
+ });
1612
1708
  authRoutes.post("/password", authMiddleware, async (c) => {
1613
1709
  const body = await c.req.json();
1614
1710
  if (!body.current || !body.next) {
@@ -1674,7 +1770,7 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
1674
1770
  if (supplied !== void 0) {
1675
1771
  assertValidPassword(supplied);
1676
1772
  }
1677
- const generated = supplied ? null : uuid3().replace(/-/g, "").slice(0, 16);
1773
+ const generated = supplied ? null : uuid4().replace(/-/g, "").slice(0, 16);
1678
1774
  const newPassword = supplied ?? generated;
1679
1775
  const newHash = await hashPassword(newPassword);
1680
1776
  await db.transaction(async (tx) => {
@@ -1789,7 +1885,7 @@ authRoutes.post("/invite", async (c) => {
1789
1885
  409
1790
1886
  );
1791
1887
  }
1792
- const tempPassword = uuid3().replace(/-/g, "").slice(0, 16);
1888
+ const tempPassword = uuid4().replace(/-/g, "").slice(0, 16);
1793
1889
  const passwordHash = await hashPassword(tempPassword);
1794
1890
  let userId;
1795
1891
  if (existing) {
@@ -1799,7 +1895,7 @@ authRoutes.post("/invite", async (c) => {
1799
1895
  userId
1800
1896
  ]);
1801
1897
  } else {
1802
- userId = uuid3();
1898
+ userId = uuid4();
1803
1899
  await db.run(
1804
1900
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1805
1901
  [userId, email, null, passwordHash]
@@ -1939,7 +2035,43 @@ function documentPrimeFlag(metadata) {
1939
2035
  }
1940
2036
 
1941
2037
  // src/governance/mention-notify.ts
1942
- import { v4 as uuid4 } from "uuid";
2038
+ import { v4 as uuid5 } from "uuid";
2039
+
2040
+ // src/shared/mentions.ts
2041
+ var MENTION = /(?:^|\s)@(?:\(([^)]+)\)|([a-zA-Z0-9._@-]+))/g;
2042
+ function stripCode(markdown2) {
2043
+ return markdown2.replace(/```[\s\S]*?```/g, " ").replace(/`[^`\n]*`/g, " ");
2044
+ }
2045
+ function extractMentions(markdown2) {
2046
+ const names = [];
2047
+ const seen = /* @__PURE__ */ new Set();
2048
+ const text = stripCode(markdown2 || "");
2049
+ let match;
2050
+ MENTION.lastIndex = 0;
2051
+ while ((match = MENTION.exec(text)) !== null) {
2052
+ const name = (match[1] || match[2] || "").trim();
2053
+ const key = name.toLowerCase();
2054
+ if (!name || seen.has(key)) continue;
2055
+ seen.add(key);
2056
+ names.push(name);
2057
+ }
2058
+ return names;
2059
+ }
2060
+ function newMentions(previous, next) {
2061
+ const before = new Set(extractMentions(previous).map((n) => n.toLowerCase()));
2062
+ return extractMentions(next).filter((n) => !before.has(n.toLowerCase()));
2063
+ }
2064
+ var MAX_MENTION_RECIPIENTS = 25;
2065
+ function resolveMention(people, name) {
2066
+ const n = name.trim().toLowerCase();
2067
+ if (!n) return void 0;
2068
+ const exact = people.find((p) => p.email.toLowerCase() === n);
2069
+ if (exact) return exact;
2070
+ const matches = people.filter(
2071
+ (p) => p.email.toLowerCase().split("@")[0] === n || (p.name || "").toLowerCase() === n
2072
+ );
2073
+ return matches.length === 1 ? matches[0] : void 0;
2074
+ }
1943
2075
 
1944
2076
  // src/nests/people-service.ts
1945
2077
  var PERSON_SOURCES = [
@@ -2003,40 +2135,129 @@ async function listNestPeople(nestId) {
2003
2135
  }
2004
2136
  return people;
2005
2137
  }
2006
-
2007
- // src/shared/mentions.ts
2008
- var MENTION = /(?:^|\s)@(?:\(([^)]+)\)|([a-zA-Z0-9._@-]+))/g;
2009
- function stripCode(markdown2) {
2010
- return markdown2.replace(/```[\s\S]*?```/g, " ").replace(/`[^`\n]*`/g, " ");
2011
- }
2012
- function extractMentions(markdown2) {
2013
- const names = [];
2014
- const seen = /* @__PURE__ */ new Set();
2015
- const text = stripCode(markdown2 || "");
2016
- let match;
2017
- MENTION.lastIndex = 0;
2018
- while ((match = MENTION.exec(text)) !== null) {
2019
- const name = (match[1] || match[2] || "").trim();
2020
- const key = name.toLowerCase();
2021
- if (!name || seen.has(key)) continue;
2022
- seen.add(key);
2023
- names.push(name);
2138
+ async function withDirectory(roster) {
2139
+ if (!config.PEOPLE_SUGGEST_ALL_USERS) return roster;
2140
+ const seen = new Set(roster.map((p) => p.email.toLowerCase()));
2141
+ const rows = await getDb().all(
2142
+ "SELECT id, email, name FROM users WHERE id != ? ORDER BY email LIMIT ?",
2143
+ [ANON_USER_ID, SUGGEST_FETCH]
2144
+ );
2145
+ const extra = [];
2146
+ for (const r of rows) {
2147
+ const email = (r.email || "").trim();
2148
+ if (!email || seen.has(email.toLowerCase())) continue;
2149
+ extra.push({ email, name: r.name ?? null, userId: r.id, source: "directory" });
2024
2150
  }
2025
- return names;
2151
+ return [...roster, ...extra];
2026
2152
  }
2027
- function newMentions(previous, next) {
2028
- const before = new Set(extractMentions(previous).map((n) => n.toLowerCase()));
2029
- return extractMentions(next).filter((n) => !before.has(n.toLowerCase()));
2030
- }
2031
- function resolveMention(people, name) {
2153
+ async function findDirectoryPerson(name) {
2154
+ if (!config.PEOPLE_SUGGEST_ALL_USERS) return void 0;
2032
2155
  const n = name.trim().toLowerCase();
2033
2156
  if (!n) return void 0;
2034
- const exact = people.find((p) => p.email.toLowerCase() === n);
2035
- if (exact) return exact;
2036
- const matches = people.filter(
2037
- (p) => p.email.toLowerCase().split("@")[0] === n || (p.name || "").toLowerCase() === n
2038
- );
2039
- return matches.length === 1 ? matches[0] : void 0;
2157
+ const rows = await getDb().all(
2158
+ `SELECT id, email, name FROM users
2159
+ WHERE id != ?
2160
+ AND (LOWER(email) = ? OR LOWER(email) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(name, '')) = ?)`,
2161
+ [ANON_USER_ID, n, `${escapeLike(n)}@%`, n]
2162
+ );
2163
+ return resolveMention(
2164
+ rows.map((r) => ({ email: r.email, name: r.name ?? null, userId: r.id, source: "directory" })),
2165
+ name
2166
+ );
2167
+ }
2168
+ var SUGGEST_LIMIT = 20;
2169
+ var SUGGEST_FETCH = 200;
2170
+ function matchesQuery(person, q) {
2171
+ if (!q) return true;
2172
+ return person.email.toLowerCase().includes(q) || (person.name || "").toLowerCase().includes(q);
2173
+ }
2174
+ var escapeLike = (s) => s.replace(/([\\%_])/g, "\\$1");
2175
+ function queryFilter(q) {
2176
+ if (!q) return { sql: "", args: [] };
2177
+ const pattern = `%${escapeLike(q)}%`;
2178
+ return {
2179
+ sql: " AND (LOWER(u.email) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(u.name, '')) LIKE ? ESCAPE '\\')",
2180
+ args: [pattern, pattern]
2181
+ };
2182
+ }
2183
+ async function suggestPeopleForUser(callerUserId, query = "", limit = SUGGEST_LIMIT) {
2184
+ const db = getDb();
2185
+ const q = query.trim().toLowerCase();
2186
+ const cap = Math.min(Math.max(limit, 1), SUGGEST_LIMIT);
2187
+ const caller = await db.get("SELECT email FROM users WHERE id = ?", [
2188
+ callerUserId
2189
+ ]);
2190
+ const callerEmail = (caller?.email || "").toLowerCase();
2191
+ const filter = queryFilter(q);
2192
+ const wholeDirectory = config.PEOPLE_SUGGEST_ALL_USERS || await isServerAdminUserId(callerUserId);
2193
+ const rows = await (async () => {
2194
+ if (wholeDirectory) {
2195
+ return await db.all(
2196
+ `SELECT u.id AS user_id, u.email AS email, u.name AS name, 'directory' AS source
2197
+ FROM users u
2198
+ WHERE u.id != ? AND u.id != ?${filter.sql}
2199
+ ORDER BY u.email
2200
+ LIMIT ?`,
2201
+ [callerUserId, ANON_USER_ID, ...filter.args, SUGGEST_FETCH]
2202
+ );
2203
+ }
2204
+ const args = [callerUserId, ANON_USER_ID];
2205
+ const myNests = () => {
2206
+ args.push(callerUserId, callerUserId);
2207
+ return `SELECT id FROM nests WHERE user_id = ?
2208
+ UNION SELECT nest_id FROM nest_collaborators WHERE user_id = ?`;
2209
+ };
2210
+ const ownedNests = myNests();
2211
+ const collabNests = myNests();
2212
+ const stewardNests = myNests();
2213
+ const sql = `SELECT u.id AS user_id, u.email AS email, u.name AS name, 'nest' AS source
2214
+ FROM users u
2215
+ WHERE u.id != ? AND u.id != ?
2216
+ AND (u.id IN (SELECT user_id FROM nests WHERE id IN (${ownedNests}))
2217
+ OR u.id IN (SELECT user_id FROM nest_collaborators
2218
+ WHERE nest_id IN (${collabNests}))
2219
+ OR LOWER(u.email) IN (SELECT LOWER(user_email) FROM stewards
2220
+ WHERE is_active = 1
2221
+ AND nest_id IN (${stewardNests})))${filter.sql}
2222
+ ORDER BY u.email
2223
+ LIMIT ?`;
2224
+ args.push(...filter.args, SUGGEST_FETCH);
2225
+ return await db.all(sql, args);
2226
+ })();
2227
+ const byEmail = /* @__PURE__ */ new Map();
2228
+ const add = (email, name, source) => {
2229
+ const addr = (email || "").trim();
2230
+ const key = addr.toLowerCase();
2231
+ if (!addr || !key || key === callerEmail || byEmail.has(key)) return;
2232
+ const person = { email: addr, name: name ?? null, source };
2233
+ if (!matchesQuery(person, q)) return;
2234
+ byEmail.set(key, person);
2235
+ };
2236
+ for (const r of rows) add(r.email, r.name, r.source);
2237
+ const teamEmails = /* @__PURE__ */ new Set();
2238
+ for (const team of wholeDirectory ? [] : await listTeamsForUser(callerUserId)) {
2239
+ for (const member of team.members ?? []) {
2240
+ const key = (member.email || "").trim().toLowerCase();
2241
+ if (key && key !== callerEmail && !byEmail.has(key)) teamEmails.add(key);
2242
+ }
2243
+ }
2244
+ if (teamEmails.size > 0) {
2245
+ const list = [...teamEmails].slice(0, SUGGEST_FETCH);
2246
+ const named = await db.all(
2247
+ `SELECT email, name FROM users
2248
+ WHERE LOWER(email) IN (${list.map(() => "?").join(",")})`,
2249
+ list
2250
+ );
2251
+ const nameOf = new Map(
2252
+ named.map((u) => [u.email.toLowerCase(), u.name ?? null])
2253
+ );
2254
+ for (const email of list) add(email, nameOf.get(email) ?? null, "team");
2255
+ }
2256
+ return [...byEmail.values()].sort(
2257
+ (a, b) => (a.name || a.email).localeCompare(b.name || b.email, void 0, {
2258
+ sensitivity: "base"
2259
+ })
2260
+ ).slice(0, cap);
2040
2261
  }
2041
2262
 
2042
2263
  // src/governance/mention-notify.ts
@@ -2047,48 +2268,172 @@ async function canOpenNode(nestId, nodeId, userId, email) {
2047
2268
  }
2048
2269
  return canReadNode(nestId, nodeId, userId, email);
2049
2270
  }
2271
+ async function resolveRecipients(params) {
2272
+ const { nestId, nodeId, names, actorEmail } = params;
2273
+ if (names.length === 0) return [];
2274
+ const roster = await listNestPeople(nestId);
2275
+ const actor = actorEmail.toLowerCase();
2276
+ const candidates = /* @__PURE__ */ new Map();
2277
+ const outsiders = [];
2278
+ for (const name of names) {
2279
+ const member = resolveMention(roster, name);
2280
+ const person = member ?? await findDirectoryPerson(name);
2281
+ if (!person?.userId) continue;
2282
+ const key = person.email.toLowerCase();
2283
+ if (key === actor || candidates.has(key)) continue;
2284
+ const r = { email: person.email, userId: person.userId };
2285
+ candidates.set(key, r);
2286
+ if (!member) outsiders.push(r);
2287
+ if (candidates.size >= MAX_MENTION_RECIPIENTS) break;
2288
+ }
2289
+ if (outsiders.length) await inviteMentioned(nestId, actorEmail, outsiders);
2290
+ const checked = await Promise.all(
2291
+ [...candidates.values()].map(async (r) => ({
2292
+ r,
2293
+ allowed: await canOpenNode(nestId, nodeId, r.userId, r.email)
2294
+ }))
2295
+ );
2296
+ return checked.filter((c) => c.allowed).map((c) => c.r);
2297
+ }
2298
+ async function inviteMentioned(nestId, actorEmail, outsiders) {
2299
+ const db = getDb();
2300
+ const actor = await db.get("SELECT id FROM users WHERE LOWER(email) = LOWER(?)", [
2301
+ actorEmail
2302
+ ]);
2303
+ if (!actor) return;
2304
+ const access = await resolveNestAccess(nestId, actor.id);
2305
+ if (permissionLevel(access.permission) < permissionLevel("write")) return;
2306
+ for (const r of outsiders) {
2307
+ await db.run(
2308
+ `INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by)
2309
+ SELECT ?, ?, ?, 'read', ?
2310
+ WHERE NOT EXISTS (SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?)`,
2311
+ [uuid5(), nestId, r.userId, actor.id, nestId, r.userId]
2312
+ );
2313
+ }
2314
+ }
2315
+ async function insertInboxRows(params) {
2316
+ const db = getDb();
2317
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2318
+ let created = 0;
2319
+ for (const r of params.recipients) {
2320
+ await db.run(
2321
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
2322
+ VALUES (?, ?, ?, 'mention', ?, ?, ?)`,
2323
+ [uuid5(), params.nestId, r.email.toLowerCase(), params.nodeId, params.message, now]
2324
+ );
2325
+ created++;
2326
+ }
2327
+ return created;
2328
+ }
2329
+ async function deliverMentionMessages(params) {
2330
+ const { nestId, nodeId, recipients, channelMessage, actorEmail, excerpt: excerpt2, ctx } = params;
2331
+ let throttled = 0;
2332
+ const channelReachable = await hasAnyConnector(nestId, "mention");
2333
+ if (channelReachable && canDeliver("channel", nestId, actorEmail)) {
2334
+ void dispatchEvent({
2335
+ kind: "mention",
2336
+ nestId,
2337
+ subjectId: nodeId,
2338
+ actor: actorEmail,
2339
+ message: channelMessage
2340
+ });
2341
+ } else if (channelReachable) {
2342
+ throttled++;
2343
+ }
2344
+ for (const r of isEmailConfigured() ? recipients : []) {
2345
+ if (!canDeliver("email", nestId, actorEmail)) {
2346
+ throttled++;
2347
+ continue;
2348
+ }
2349
+ void sendEmailToRecipient(r.email, {
2350
+ status: "mention",
2351
+ docTitle: ctx.docTitle,
2352
+ path: ctx.path,
2353
+ link: ctx.link,
2354
+ actor: actorEmail,
2355
+ note: excerpt2 || null
2356
+ });
2357
+ }
2358
+ if (throttled > 0) {
2359
+ console.warn(
2360
+ `[notify] mention delivery budget exhausted for ${actorEmail} in ${nestId} \u2014 ${throttled} outbound message(s) skipped; inbox rows unaffected`
2361
+ );
2362
+ }
2363
+ }
2364
+ var MENTION_DELIVERY_BUDGET = { max: 50, windowMs: 5 * 6e4 };
2365
+ var MENTION_CHANNEL_BUDGET = { max: 25, windowMs: 5 * 6e4 };
2366
+ function canDeliver(kind, nestId, actorEmail) {
2367
+ return tryConsume(
2368
+ `mention-${kind}:${nestId}:${actorEmail.toLowerCase()}`,
2369
+ kind === "email" ? MENTION_DELIVERY_BUDGET : MENTION_CHANNEL_BUDGET
2370
+ );
2371
+ }
2372
+ function channelSentence(actorEmail, recipients, where) {
2373
+ const names = recipients.map((r) => r.email).join(", ");
2374
+ return `${actorEmail} mentioned ${names} ${where}`;
2375
+ }
2376
+ function excerptOf(body, max = 140) {
2377
+ const s = (body || "").replace(/\s+/g, " ").trim();
2378
+ return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
2379
+ }
2050
2380
  async function notifyMentions(params) {
2051
- const { nestId, nodeId, previousBody, nextBody, actorEmail } = params;
2381
+ const { nestId, nodeId, previousBody, nextBody, actorEmail, baseUrl } = params;
2052
2382
  try {
2053
2383
  const names = newMentions(previousBody, nextBody);
2054
- if (names.length === 0) return 0;
2055
- const people = await listNestPeople(nestId);
2056
- const actor = actorEmail.toLowerCase();
2057
- const recipients = /* @__PURE__ */ new Map();
2058
- for (const name of names) {
2059
- const person = resolveMention(people, name);
2060
- if (!person?.userId) continue;
2061
- const key = person.email.toLowerCase();
2062
- if (key === actor || recipients.has(key)) continue;
2063
- recipients.set(key, { email: person.email, userId: person.userId });
2064
- }
2065
- if (recipients.size === 0) return 0;
2066
- const label = await describeNode(nestId, nodeId);
2067
- const now = (/* @__PURE__ */ new Date()).toISOString();
2068
- const db = getDb();
2069
- let created = 0;
2070
- for (const { email, userId } of recipients.values()) {
2071
- if (!await canOpenNode(nestId, nodeId, userId, email)) continue;
2072
- await db.run(
2073
- `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
2074
- VALUES (?, ?, ?, 'mention', ?, ?, ?)`,
2075
- [
2076
- uuid4(),
2077
- nestId,
2078
- email.toLowerCase(),
2079
- nodeId,
2080
- `${actorEmail} mentioned you in "${label}"`,
2081
- now
2082
- ]
2083
- );
2084
- created++;
2085
- }
2384
+ const recipients = await resolveRecipients({ nestId, nodeId, names, actorEmail });
2385
+ if (recipients.length === 0) return 0;
2386
+ const ctx = await buildDocContext(nestId, nodeId, baseUrl);
2387
+ const message = `${actorEmail} mentioned you in "${ctx.label}"`;
2388
+ const created = await insertInboxRows({ nestId, nodeId, recipients, message });
2389
+ await deliverMentionMessages({
2390
+ nestId,
2391
+ nodeId,
2392
+ recipients,
2393
+ channelMessage: channelSentence(
2394
+ actorEmail,
2395
+ recipients,
2396
+ `in *${ctx.label}*`
2397
+ ),
2398
+ actorEmail,
2399
+ excerpt: "",
2400
+ ctx
2401
+ });
2086
2402
  return created;
2087
2403
  } catch (err) {
2088
2404
  console.error("[notify] mention fan-out failed", nestId, nodeId, err);
2089
2405
  return 0;
2090
2406
  }
2091
2407
  }
2408
+ async function notifyCommentMentions(params) {
2409
+ const { nestId, nodeId, body, actorEmail, baseUrl } = params;
2410
+ try {
2411
+ const names = extractMentions(body || "");
2412
+ const recipients = await resolveRecipients({ nestId, nodeId, names, actorEmail });
2413
+ if (recipients.length === 0) return 0;
2414
+ const ctx = await buildDocContext(nestId, nodeId, baseUrl);
2415
+ const excerpt2 = excerptOf(body);
2416
+ const message = `${actorEmail} mentioned you in a comment on "${ctx.label}"`;
2417
+ const created = await insertInboxRows({ nestId, nodeId, recipients, message });
2418
+ await deliverMentionMessages({
2419
+ nestId,
2420
+ nodeId,
2421
+ recipients,
2422
+ channelMessage: channelSentence(
2423
+ actorEmail,
2424
+ recipients,
2425
+ `in a comment on *${ctx.label}*`
2426
+ ),
2427
+ actorEmail,
2428
+ excerpt: excerpt2,
2429
+ ctx
2430
+ });
2431
+ return created;
2432
+ } catch (err) {
2433
+ console.error("[notify] comment mention fan-out failed", nestId, nodeId, err);
2434
+ return 0;
2435
+ }
2436
+ }
2092
2437
 
2093
2438
  // src/nodes/service.ts
2094
2439
  async function userIdFromEmail(email) {
@@ -2223,6 +2568,19 @@ var ACCEPTED_NODE_TYPES = /* @__PURE__ */ new Set([
2223
2568
  "artifact",
2224
2569
  "table"
2225
2570
  ]);
2571
+ function assertAcceptedType(type) {
2572
+ if (type === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
2573
+ throw new ValidationError("Artifact nodes are disabled on this server");
2574
+ }
2575
+ if (type === "table" && !config.TYPE_TABLE_ENABLED) {
2576
+ throw new ValidationError("Table nodes are disabled on this server");
2577
+ }
2578
+ if (!ACCEPTED_NODE_TYPES.has(type)) {
2579
+ throw new ValidationError(
2580
+ `unknown node type "${type}"; allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")}`
2581
+ );
2582
+ }
2583
+ }
2226
2584
  function toNodeResponse(node) {
2227
2585
  const fm = node.frontmatter;
2228
2586
  const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
@@ -2312,8 +2670,9 @@ function engineScopesFolders() {
2312
2670
  }
2313
2671
  return folderScopeSupported;
2314
2672
  }
2673
+ var DERIVED_ANNOTATIONS_SUFFIX = "--annotations";
2315
2674
  function isDerivedAnnotationsNode(id) {
2316
- return id.endsWith("--annotations");
2675
+ return id.endsWith(DERIVED_ANNOTATIONS_SUFFIX);
2317
2676
  }
2318
2677
  async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
2319
2678
  if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
@@ -2647,11 +3006,11 @@ async function autoPublishReason(nestId, userEmail, doc) {
2647
3006
  }
2648
3007
  return await actorAutoPublishes(nestId, userEmail) ? "owner/admin self-approve" : null;
2649
3008
  }
2650
- async function createNode(nestId, input, userEmail) {
3009
+ async function createNode(nestId, input, userEmail, opts = {}) {
2651
3010
  const generatedId = !input.id && !slugify(input.title);
2652
3011
  for (let attempt = 0; ; attempt++) {
2653
3012
  try {
2654
- return await createNodeOnce(nestId, input, userEmail);
3013
+ return await createNodeOnce(nestId, input, userEmail, opts);
2655
3014
  } catch (err) {
2656
3015
  if (!generatedId || attempt >= 3 || !(err instanceof ConflictError)) {
2657
3016
  throw err;
@@ -2659,7 +3018,7 @@ async function createNode(nestId, input, userEmail) {
2659
3018
  }
2660
3019
  }
2661
3020
  }
2662
- async function createNodeOnce(nestId, input, userEmail) {
3021
+ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
2663
3022
  const { storage } = await engineCache.get(nestId);
2664
3023
  assertTitleLength(input.title);
2665
3024
  assertTitleReadable(input.title);
@@ -2672,17 +3031,7 @@ async function createNodeOnce(nestId, input, userEmail) {
2672
3031
  }
2673
3032
  const tags = requireTags(input.tags);
2674
3033
  const requestedType = input.type || "document";
2675
- if (requestedType === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
2676
- throw new ValidationError("Artifact nodes are disabled on this server");
2677
- }
2678
- if (requestedType === "table" && !config.TYPE_TABLE_ENABLED) {
2679
- throw new ValidationError("Table nodes are disabled on this server");
2680
- }
2681
- if (!ACCEPTED_NODE_TYPES.has(requestedType)) {
2682
- throw new ValidationError(
2683
- `unknown node type "${requestedType}"; allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")}`
2684
- );
2685
- }
3034
+ assertAcceptedType(requestedType);
2686
3035
  if (input.schedule !== void 0 && !isRunnableType(input.type)) {
2687
3036
  throw new ValidationError(
2688
3037
  `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(
@@ -2792,13 +3141,15 @@ async function createNodeOnce(nestId, input, userEmail) {
2792
3141
  });
2793
3142
  }
2794
3143
  await trackEvent("node.create", { nestId, nodeId: id });
2795
- await notifyMentions({
2796
- nestId,
2797
- nodeId: id,
2798
- previousBody: "",
2799
- nextBody: node.body || "",
2800
- actorEmail: userEmail
2801
- });
3144
+ if (opts.notifyMentions !== false) {
3145
+ await notifyMentions({
3146
+ nestId,
3147
+ nodeId: id,
3148
+ previousBody: "",
3149
+ nextBody: node.body || "",
3150
+ actorEmail: userEmail
3151
+ });
3152
+ }
2802
3153
  return { node, version: savedVersion };
2803
3154
  });
2804
3155
  }
@@ -2892,7 +3243,7 @@ async function registerImportedDocuments(nestId, userEmail) {
2892
3243
  await trackEvent("nest.import.documents", { nestId, registered: docs.length });
2893
3244
  return docs.length;
2894
3245
  }
2895
- async function updateNode(nestId, nodeId, patch, userEmail) {
3246
+ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
2896
3247
  return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
2897
3248
  try {
2898
3249
  const { storage, versions: versionManager } = await engineCache.get(
@@ -2943,6 +3294,20 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2943
3294
  };
2944
3295
  opInput.title = patch.title;
2945
3296
  }
3297
+ if (patch.type !== void 0 && patch.type !== node.frontmatter.type) {
3298
+ assertAcceptedType(patch.type);
3299
+ if (patch.type === "skill") {
3300
+ opInput.trigger = `when asked to ${String(node.frontmatter.title || nodeId).toLowerCase()}`;
3301
+ }
3302
+ if (isRunnableType(node.frontmatter.type) && !isRunnableType(patch.type)) {
3303
+ const metadata = { ...node.frontmatter.metadata };
3304
+ delete metadata.schedule;
3305
+ node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
3306
+ opInput.metadata = { ...opInput.metadata, schedule: null };
3307
+ }
3308
+ node = { ...node, frontmatter: { ...node.frontmatter, type: patch.type } };
3309
+ opInput.type = patch.type;
3310
+ }
2946
3311
  if (patch.schedule !== void 0) {
2947
3312
  if (!isRunnableType(node.frontmatter.type)) {
2948
3313
  throw new ValidationError(
@@ -3044,13 +3409,15 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
3044
3409
  );
3045
3410
  responseVersion = publishedVersion;
3046
3411
  }
3047
- await notifyMentions({
3048
- nestId,
3049
- nodeId,
3050
- previousBody,
3051
- nextBody: node.body || "",
3052
- actorEmail: userEmail
3053
- });
3412
+ if (opts.notifyMentions !== false) {
3413
+ await notifyMentions({
3414
+ nestId,
3415
+ nodeId,
3416
+ previousBody,
3417
+ nextBody: node.body || "",
3418
+ actorEmail: userEmail
3419
+ });
3420
+ }
3054
3421
  return { node, version: responseVersion };
3055
3422
  } catch (err) {
3056
3423
  throw toSafeError(
@@ -3632,8 +3999,13 @@ nestRoutes.get("/", async (c) => {
3632
3999
  }
3633
4000
  const out = await Promise.all(deduped.map(annotate));
3634
4001
  const docRows = await db.all(
3635
- `SELECT nest_id, COUNT(DISTINCT node_id) AS c, MAX(created_at) AS last_activity
3636
- FROM node_versions GROUP BY nest_id`
4002
+ `SELECT nest_id,
4003
+ COUNT(DISTINCT CASE
4004
+ WHEN ${suffixOf(db, "node_id", DERIVED_ANNOTATIONS_SUFFIX.length)} <> ?
4005
+ THEN node_id END) AS c,
4006
+ MAX(created_at) AS last_activity
4007
+ FROM node_versions GROUP BY nest_id`,
4008
+ [DERIVED_ANNOTATIONS_SUFFIX]
3637
4009
  );
3638
4010
  const docByNest = new Map(docRows.map((r) => [r.nest_id, Number(r.c)]));
3639
4011
  const activityByNest = new Map(docRows.map((r) => [r.nest_id, r.last_activity]));
@@ -3675,6 +4047,7 @@ nestRoutes.get("/", async (c) => {
3675
4047
  );
3676
4048
  for (const r of rows) {
3677
4049
  const targets = grantTargetsByNest.get(r.nest_id) ?? [];
4050
+ if (isDerivedAnnotationsNode(r.node_id)) continue;
3678
4051
  if (grantCoversNode(targets, r.node_id)) {
3679
4052
  grantedDocsByNest.set(r.nest_id, (grantedDocsByNest.get(r.nest_id) ?? 0) + 1);
3680
4053
  }
@@ -3960,7 +4333,7 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
3960
4333
  import { Hono as Hono4 } from "hono";
3961
4334
 
3962
4335
  // src/nests/sharing-service.ts
3963
- import { v4 as uuid5 } from "uuid";
4336
+ import { v4 as uuid6 } from "uuid";
3964
4337
  var VALID_PERMISSIONS = ["read", "write", "admin"];
3965
4338
  async function addCollaborator(params) {
3966
4339
  const { nestId } = params;
@@ -3987,10 +4360,10 @@ async function addCollaborator(params) {
3987
4360
  userId = existing.id;
3988
4361
  } else {
3989
4362
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3990
- userId = uuid5();
4363
+ userId = uuid6();
3991
4364
  await db.run(
3992
4365
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
3993
- [userId, email, null, await hashPassword2(uuid5())]
4366
+ [userId, email, null, await hashPassword2(uuid6())]
3994
4367
  );
3995
4368
  }
3996
4369
  }
@@ -4028,7 +4401,7 @@ async function addCollaborator(params) {
4028
4401
  [params.grantedByEmail]
4029
4402
  ))?.id : void 0;
4030
4403
  const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
4031
- const collabId = uuid5();
4404
+ const collabId = uuid6();
4032
4405
  await db.run(
4033
4406
  "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
4034
4407
  [collabId, nestId, userId, params.permission, granterId]
@@ -4101,7 +4474,7 @@ sharingRoutes.get("/collaborators", async (c) => {
4101
4474
  }
4102
4475
  return c.json({
4103
4476
  collaborators: enriched,
4104
- people: await listNestPeople(nestId)
4477
+ people: await withDirectory(await listNestPeople(nestId))
4105
4478
  });
4106
4479
  });
4107
4480
  sharingRoutes.post("/collaborators", async (c) => {
@@ -4145,6 +4518,22 @@ sharingRoutes.delete("/collaborators/:collabId", async (c) => {
4145
4518
  }
4146
4519
  return c.json({ removed: true });
4147
4520
  });
4521
+ sharingRoutes.get("/mentionable", async (c) => {
4522
+ const nestId = c.req.param("nestId");
4523
+ const userId = c.get("userId");
4524
+ const people = await listNestPeople(nestId);
4525
+ const callerEmail = (await resolveCallerEmail(userId)).toLowerCase();
4526
+ const onRoster = people.some((p) => p.email.toLowerCase() === callerEmail);
4527
+ if (!onRoster) {
4528
+ const access = await resolveNestAccess(nestId, userId);
4529
+ if (permissionLevel(access.permission) < permissionLevel("write")) {
4530
+ throw new ForbiddenError("Only people on this nest can see its roster");
4531
+ }
4532
+ }
4533
+ return c.json({
4534
+ people: (await withDirectory(people)).map((p) => ({ email: p.email, name: p.name }))
4535
+ });
4536
+ });
4148
4537
  sharingRoutes.patch("/visibility", async (c) => {
4149
4538
  const body = await c.req.json();
4150
4539
  if (!body.visibility || !["private", "org", "public"].includes(body.visibility)) {
@@ -4176,10 +4565,14 @@ sharingRoutes.patch("/visibility", async (c) => {
4176
4565
  });
4177
4566
  sharingRoutes.patch("/reader-mode", async (c) => {
4178
4567
  const userId = c.get("userId");
4568
+ const nestId = c.req.param("nestId");
4179
4569
  if (config.AUTH_MODE !== "open" && !await isServerAdminUserId(userId)) {
4180
- throw new ForbiddenError(
4181
- "Only the server admin can configure reader mode"
4182
- );
4570
+ const access = await resolveNestAccess(nestId, userId);
4571
+ if (permissionLevel(access.permission) < permissionLevel("admin")) {
4572
+ throw new ForbiddenError(
4573
+ "Only the nest owner, a nest admin, or the server admin can configure reader mode"
4574
+ );
4575
+ }
4183
4576
  }
4184
4577
  const body = await c.req.json();
4185
4578
  if (body.enabled !== void 0 && typeof body.enabled !== "boolean") {
@@ -4202,7 +4595,6 @@ sharingRoutes.patch("/reader-mode", async (c) => {
4202
4595
  );
4203
4596
  }
4204
4597
  const db = getDb();
4205
- const nestId = c.req.param("nestId");
4206
4598
  const exists = await db.get("SELECT id FROM nests WHERE id = ?", [
4207
4599
  nestId
4208
4600
  ]);
@@ -4222,14 +4614,21 @@ sharingRoutes.patch("/reader-mode", async (c) => {
4222
4614
 
4223
4615
  // src/nests/grant-routes.ts
4224
4616
  import { Hono as Hono5 } from "hono";
4225
- import { v4 as uuid6 } from "uuid";
4617
+ import { v4 as uuid7 } from "uuid";
4226
4618
  var grantRoutes = new Hono5();
4227
- async function requireManage(c, nestId) {
4228
- if (!await canManageStewards(nestId, c.get("userId"))) {
4619
+ async function requireManage(c, nestId, opts = {}) {
4620
+ if (config.AUTH_MODE === "open") return;
4621
+ const access = await resolveNestAccess(nestId, c.get("userId"));
4622
+ if (access.rawPermission !== "owner" && access.rawPermission !== "admin") {
4229
4623
  throw new ForbiddenError(
4230
4624
  "Only a nest admin, the nest owner, or the server admin can manage sharing."
4231
4625
  );
4232
4626
  }
4627
+ if (access.archived && !opts.readOnly) {
4628
+ throw new ForbiddenError(
4629
+ "This nest is archived, so it is read-only. Restore it to change sharing."
4630
+ );
4631
+ }
4233
4632
  }
4234
4633
  async function resolveGrantee(email) {
4235
4634
  const db = getDb();
@@ -4237,10 +4636,10 @@ async function resolveGrantee(email) {
4237
4636
  const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
4238
4637
  if (existing) return existing.id;
4239
4638
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
4240
- const id = uuid6();
4639
+ const id = uuid7();
4241
4640
  await db.run(
4242
4641
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
4243
- [id, e, null, await hashPassword2(uuid6())]
4642
+ [id, e, null, await hashPassword2(uuid7())]
4244
4643
  );
4245
4644
  return id;
4246
4645
  }
@@ -4319,7 +4718,7 @@ grantRoutes.post("/", async (c) => {
4319
4718
  });
4320
4719
  grantRoutes.get("/", async (c) => {
4321
4720
  const nestId = c.req.param("nestId");
4322
- await requireManage(c, nestId);
4721
+ await requireManage(c, nestId, { readOnly: true });
4323
4722
  const grants = await listGrants(nestId);
4324
4723
  const db = getDb();
4325
4724
  const withEmail = await Promise.all(
@@ -4342,7 +4741,7 @@ grantRoutes.delete("/:id", async (c) => {
4342
4741
  import { Hono as Hono6 } from "hono";
4343
4742
 
4344
4743
  // src/nests/publish-service.ts
4345
- import { v4 as uuid7 } from "uuid";
4744
+ import { v4 as uuid8 } from "uuid";
4346
4745
  import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
4347
4746
  var MODES = ["public", "email", "code", "invite"];
4348
4747
  var LINK_TTL_SECONDS = 60 * 60;
@@ -4411,7 +4810,7 @@ async function createPublish(params) {
4411
4810
  );
4412
4811
  }
4413
4812
  const db = getDb();
4414
- const id = uuid7();
4813
+ const id = uuid8();
4415
4814
  const slug = newSlug();
4416
4815
  const created = nowIso();
4417
4816
  const codeHash = mode === "code" && params.code ? await hashPassword(params.code.trim()) : null;
@@ -4442,7 +4841,7 @@ async function createPublish(params) {
4442
4841
  await db.run(
4443
4842
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4444
4843
  VALUES (?, ?, 'invite', ?, NULL, NULL, NULL, ?)`,
4445
- [uuid7(), id, email, created]
4844
+ [uuid8(), id, email, created]
4446
4845
  );
4447
4846
  }
4448
4847
  }
@@ -4497,7 +4896,7 @@ async function mintMagicLink(publishId, email) {
4497
4896
  await getDb().run(
4498
4897
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4499
4898
  VALUES (?, ?, 'link', ?, ?, ?, NULL, ?)`,
4500
- [uuid7(), publishId, e, sha256Hex(token), expires, nowIso()]
4899
+ [uuid8(), publishId, e, sha256Hex(token), expires, nowIso()]
4501
4900
  );
4502
4901
  return token;
4503
4902
  }
@@ -4527,7 +4926,7 @@ async function createSession2(publishId, email) {
4527
4926
  await getDb().run(
4528
4927
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4529
4928
  VALUES (?, ?, 'session', ?, ?, ?, NULL, ?)`,
4530
- [uuid7(), publishId, email, sha256Hex(token), expires, nowIso()]
4929
+ [uuid8(), publishId, email, sha256Hex(token), expires, nowIso()]
4531
4930
  );
4532
4931
  return token;
4533
4932
  }
@@ -4544,7 +4943,7 @@ async function logView(publishId, email) {
4544
4943
  await getDb().run(
4545
4944
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4546
4945
  VALUES (?, ?, 'view', ?, NULL, NULL, NULL, ?)`,
4547
- [uuid7(), publishId, email, nowIso()]
4946
+ [uuid8(), publishId, email, nowIso()]
4548
4947
  );
4549
4948
  }
4550
4949
  async function listAllowlist(publishId) {
@@ -4637,7 +5036,7 @@ import { Hono as Hono7 } from "hono";
4637
5036
  import MarkdownIt from "markdown-it";
4638
5037
 
4639
5038
  // src/annotations/service.ts
4640
- import { v4 as uuid8 } from "uuid";
5039
+ import { v4 as uuid9 } from "uuid";
4641
5040
 
4642
5041
  // src/annotations/projection.ts
4643
5042
  var MAX_CONTEXT_CHARS = 250;
@@ -4762,6 +5161,10 @@ async function rowToThread(row) {
4762
5161
  async function getThreadRow(threadId) {
4763
5162
  return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
4764
5163
  }
5164
+ async function getThread(threadId) {
5165
+ const row = await getThreadRow(threadId);
5166
+ return row ? await rowToThread(row) : null;
5167
+ }
4765
5168
  async function countThreadsByNode(nestId) {
4766
5169
  const rows = await getDb().all(
4767
5170
  `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
@@ -4787,14 +5190,14 @@ async function createThread(nestId, nodeId, input, authorEmail) {
4787
5190
  throw new Error("comment body is required");
4788
5191
  }
4789
5192
  const db = getDb();
4790
- const id = uuid8();
5193
+ const id = uuid9();
4791
5194
  const anchor = clampAnchor(input.anchor);
4792
5195
  const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
4793
5196
  await db.transaction(async (tx) => {
4794
5197
  await tx.run(
4795
5198
  `INSERT INTO annotation_threads
4796
- (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
4797
- VALUES (?, ?, ?, ?, ?, 'open', ?)`,
5199
+ (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by, created_at)
5200
+ VALUES (?, ?, ?, ?, ?, 'open', ?, ${nowMsExpr(db)})`,
4798
5201
  [
4799
5202
  id,
4800
5203
  nestId,
@@ -4805,8 +5208,9 @@ async function createThread(nestId, nodeId, input, authorEmail) {
4805
5208
  ]
4806
5209
  );
4807
5210
  await tx.run(
4808
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4809
- [uuid8(), id, authorEmail, body]
5211
+ `INSERT INTO annotation_comments (id, thread_id, author, body, created_at)
5212
+ VALUES (?, ?, ?, ?, ${nowMsExpr(db)})`,
5213
+ [uuid9(), id, authorEmail, body]
4810
5214
  );
4811
5215
  });
4812
5216
  return rowToThread(await getThreadRow(id));
@@ -4824,9 +5228,11 @@ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
4824
5228
  throw new Error("comment body is required");
4825
5229
  }
4826
5230
  await getScopedThreadRow(threadId, nestId, nodeId);
4827
- await getDb().run(
4828
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4829
- [uuid8(), threadId, authorEmail, trimmed]
5231
+ const db = getDb();
5232
+ await db.run(
5233
+ `INSERT INTO annotation_comments (id, thread_id, author, body, created_at)
5234
+ VALUES (?, ?, ?, ?, ${nowMsExpr(db)})`,
5235
+ [uuid9(), threadId, authorEmail, trimmed]
4830
5236
  );
4831
5237
  return rowToThread(await getThreadRow(threadId));
4832
5238
  }
@@ -4913,7 +5319,13 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
4913
5319
  const markdown2 = projectThreadsToMarkdown(title, threads);
4914
5320
  const derivedId = derivedAnnotationsId(nodeId);
4915
5321
  try {
4916
- await updateNode(nestId, derivedId, { content: markdown2 }, userEmail);
5322
+ await updateNode(
5323
+ nestId,
5324
+ derivedId,
5325
+ { content: markdown2 },
5326
+ userEmail,
5327
+ { notifyMentions: false }
5328
+ );
4917
5329
  } catch (err) {
4918
5330
  if (err instanceof NotFoundError) {
4919
5331
  await createNode(
@@ -4925,7 +5337,8 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
4925
5337
  type: "document",
4926
5338
  tags: ["annotations"]
4927
5339
  },
4928
- userEmail
5340
+ userEmail,
5341
+ { notifyMentions: false }
4929
5342
  );
4930
5343
  } else {
4931
5344
  throw err;
@@ -5350,8 +5763,19 @@ nestTeamRoutes.delete("/:teamId", async (c) => {
5350
5763
  return c.json({ teams });
5351
5764
  });
5352
5765
 
5353
- // src/governance/team-routes.ts
5766
+ // src/nests/people-routes.ts
5354
5767
  import { Hono as Hono9 } from "hono";
5768
+ var peopleRoutes = new Hono9();
5769
+ peopleRoutes.get("/suggest", async (c) => {
5770
+ const userId = c.get("userId");
5771
+ const q = c.req.query("q") ?? "";
5772
+ const rawLimit = Number(c.req.query("limit"));
5773
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : void 0;
5774
+ return c.json({ people: await suggestPeopleForUser(userId, q, limit) });
5775
+ });
5776
+
5777
+ // src/governance/team-routes.ts
5778
+ import { Hono as Hono10 } from "hono";
5355
5779
 
5356
5780
  // src/governance/promptowl-teams.ts
5357
5781
  var PROMPTOWL_SOURCE = "promptowl";
@@ -5498,7 +5922,7 @@ async function desiredRoster(poTeam, callerEmail) {
5498
5922
  }
5499
5923
 
5500
5924
  // src/governance/team-routes.ts
5501
- var teamRoutes = new Hono9();
5925
+ var teamRoutes = new Hono10();
5502
5926
  var PROMPTOWL_LIMIT = { max: 10, windowMs: 15 * 6e4 };
5503
5927
  var CONNECT_REQUIRED = "promptowl_connect_required";
5504
5928
  var CONNECT_REQUIRED_STATUS = 428;
@@ -5683,7 +6107,7 @@ function toSearchHit(node, query) {
5683
6107
  }
5684
6108
 
5685
6109
  // src/nodes/routes.ts
5686
- import { Hono as Hono10 } from "hono";
6110
+ import { Hono as Hono11 } from "hono";
5687
6111
 
5688
6112
  // src/nodes/markdown-export.ts
5689
6113
  import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
@@ -5735,7 +6159,7 @@ ${m[1]}
5735
6159
  }
5736
6160
 
5737
6161
  // src/nodes/routes.ts
5738
- var nodeRoutes = new Hono10();
6162
+ var nodeRoutes = new Hono11();
5739
6163
  function nodeAsMarkdown(response, nodeId) {
5740
6164
  return nodeToMarkdown({
5741
6165
  id: nodeId,
@@ -5817,7 +6241,7 @@ nodeRoutes.post("/", async (c) => {
5817
6241
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
5818
6242
  const nestId = c.req.param("nestId");
5819
6243
  const nodeId = c.req.param("nodeId");
5820
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-5HBQGDMK.js");
6244
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-4DHNRULG.js");
5821
6245
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
5822
6246
  nestId,
5823
6247
  nodeId
@@ -5838,7 +6262,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
5838
6262
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
5839
6263
  const nestId = c.req.param("nestId");
5840
6264
  const nodeId = c.req.param("nodeId");
5841
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-XFWTENTN.js");
6265
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-HHGOTO6P.js");
5842
6266
  const history = await getReviewHistory2(nestId, nodeId);
5843
6267
  return c.json({ reviews: history });
5844
6268
  });
@@ -5977,7 +6401,8 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
5977
6401
  }
5978
6402
  }
5979
6403
  response.myGrant = await resolveNodeGrant(nestId, userId, nodeId);
5980
- if (publicReader) {
6404
+ const approvedOnly = c.req.query("approved_only") === "1" || c.req.query("approved_only") === "true";
6405
+ if (publicReader || approvedOnly) {
5981
6406
  const approved = await getApprovedVersion(nestId, nodeId);
5982
6407
  if (approved != null) {
5983
6408
  let approvedRaw = "";
@@ -6008,6 +6433,9 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
6008
6433
  }
6009
6434
  return c.json({ node: response });
6010
6435
  }
6436
+ if (approvedOnly) {
6437
+ throw new NotFoundError("This document has no approved version yet.");
6438
+ }
6011
6439
  }
6012
6440
  response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
6013
6441
  if (response.status === "pending_review") {
@@ -6032,6 +6460,9 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
6032
6460
  const nestId = c.req.param("nestId");
6033
6461
  const nodeId = c.req.param("nodeId");
6034
6462
  const body = await c.req.json();
6463
+ if (body.type !== void 0 && typeof body.type !== "string") {
6464
+ throw new ValidationError("type must be a string");
6465
+ }
6035
6466
  const baseVersionHeader = c.req.header("X-Base-Version");
6036
6467
  if (baseVersionHeader) {
6037
6468
  const baseVersion = parseInt(baseVersionHeader, 10);
@@ -6062,7 +6493,8 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
6062
6493
  status: body.status,
6063
6494
  changeNote: body.changeNote,
6064
6495
  schedule: body.schedule,
6065
- prime: body.prime
6496
+ prime: body.prime,
6497
+ type: body.type
6066
6498
  },
6067
6499
  authorEmail
6068
6500
  );
@@ -6082,13 +6514,13 @@ async function getUserEmail(c) {
6082
6514
  }
6083
6515
 
6084
6516
  // src/annotations/routes.ts
6085
- import { Hono as Hono11 } from "hono";
6517
+ import { Hono as Hono12 } from "hono";
6086
6518
 
6087
6519
  // src/annotations/types.ts
6088
6520
  var ARTIFACT_NODE_TYPE = "artifact";
6089
6521
 
6090
6522
  // src/annotations/routes.ts
6091
- var annotationRoutes = new Hono11();
6523
+ var annotationRoutes = new Hono12();
6092
6524
  function getNodeId(c) {
6093
6525
  const raw = c.req.param("nodeId");
6094
6526
  try {
@@ -6129,6 +6561,13 @@ annotationRoutes.post("/:nodeId{.+}/annotations", async (c) => {
6129
6561
  },
6130
6562
  userEmail
6131
6563
  );
6564
+ await notifyCommentMentions({
6565
+ nestId,
6566
+ nodeId,
6567
+ body: input.body,
6568
+ actorEmail: userEmail,
6569
+ baseUrl: requestBaseUrl(c.req.url)
6570
+ });
6132
6571
  await syncAnnotationsNode(nestId, nodeId, userEmail);
6133
6572
  return c.json({ thread }, 201);
6134
6573
  });
@@ -6144,6 +6583,13 @@ async function handleAnnotationReply(c, nodeId, threadId) {
6144
6583
  throw new ValidationError("body is required");
6145
6584
  }
6146
6585
  const thread = await addComment(nestId, nodeId, threadId, userEmail, input.body);
6586
+ await notifyCommentMentions({
6587
+ nestId,
6588
+ nodeId,
6589
+ body: input.body,
6590
+ actorEmail: userEmail,
6591
+ baseUrl: requestBaseUrl(c.req.url)
6592
+ });
6147
6593
  await syncAnnotationsNode(nestId, nodeId, userEmail);
6148
6594
  return c.json({ thread });
6149
6595
  }
@@ -6202,7 +6648,7 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
6202
6648
  });
6203
6649
 
6204
6650
  // src/nodes/query-routes.ts
6205
- import { Hono as Hono12 } from "hono";
6651
+ import { Hono as Hono13 } from "hono";
6206
6652
 
6207
6653
  // src/tables/service.ts
6208
6654
  function splitCsvLine(line) {
@@ -6552,7 +6998,18 @@ var STOPWORDS = /* @__PURE__ */ new Set([
6552
6998
  "ok",
6553
6999
  "please",
6554
7000
  "thanks",
6555
- "thank"
7001
+ "thank",
7002
+ // Greetings and fillers a chat turn opens with — never a topic.
7003
+ "hello",
7004
+ "hi",
7005
+ "hey",
7006
+ "there",
7007
+ "good",
7008
+ "morning",
7009
+ "afternoon",
7010
+ "evening",
7011
+ "bye",
7012
+ "cheers"
6556
7013
  ]);
6557
7014
  function tokenizePrompt(prompt) {
6558
7015
  const words = prompt.toLowerCase().split(/[^a-z0-9_-]+/).filter(Boolean).filter((w) => w.length >= 3 && !STOPWORDS.has(w));
@@ -6594,6 +7051,34 @@ async function compilePrompt(prompt, nestId, titles) {
6594
7051
  unmatched
6595
7052
  };
6596
7053
  }
7054
+ function scorePrompt(doc, tokens) {
7055
+ if (tokens.length === 0) return { score: 0, via: null };
7056
+ const title = doc.frontmatter.title || "";
7057
+ const tags = (doc.frontmatter.tags || []).map(normalizeTag);
7058
+ const body = (doc.body || "").toLowerCase();
7059
+ let sum = 0;
7060
+ let via = null;
7061
+ for (const t of tokens) {
7062
+ const inTitle = titleScore(title, t);
7063
+ if (inTitle > 0) {
7064
+ sum += inTitle;
7065
+ via = "title";
7066
+ } else if (tags.some((tag) => tag.startsWith(t))) {
7067
+ sum += 2;
7068
+ if (via !== "title") via = "tag";
7069
+ } else if (startsWord(body, t)) {
7070
+ sum += 1;
7071
+ via ??= "fulltext";
7072
+ }
7073
+ }
7074
+ return { score: sum / (4 * tokens.length), via };
7075
+ }
7076
+ function startsWord(text, term) {
7077
+ for (let i = text.indexOf(term); i !== -1; i = text.indexOf(term, i + 1)) {
7078
+ if (i === 0 || !/[a-z0-9]/.test(text[i - 1])) return true;
7079
+ }
7080
+ return false;
7081
+ }
6597
7082
 
6598
7083
  // src/nodes/readable-body.ts
6599
7084
  async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
@@ -6836,7 +7321,7 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
6836
7321
  }
6837
7322
 
6838
7323
  // src/nodes/query-routes.ts
6839
- var queryRoutes = new Hono12();
7324
+ var queryRoutes = new Hono13();
6840
7325
  queryRoutes.get("/changes", async (c) => {
6841
7326
  const nestId = c.req.param("nestId");
6842
7327
  const userId = c.get("userId");
@@ -6988,7 +7473,8 @@ queryRoutes.post("/context", async (c) => {
6988
7473
  const baseUrl = requestBaseUrl(c.req.url);
6989
7474
  const { query: queryEngine, storage } = await engineCache.get(nestId);
6990
7475
  const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
6991
- const hops = body.hops ?? 2;
7476
+ const promptPath = !body.selector?.trim() && !!body.prompt;
7477
+ const hops = body.hops ?? (promptPath ? 0 : 2);
6992
7478
  const includeDrafts = body.include_drafts === true;
6993
7479
  const isVisible = (d) => includeDrafts || d.frontmatter.status === "published";
6994
7480
  let selector = body.selector?.trim() || null;
@@ -7022,7 +7508,7 @@ queryRoutes.post("/context", async (c) => {
7022
7508
  compileDetail.matchedTitles.map((t) => t.toLowerCase())
7023
7509
  );
7024
7510
  titleMatches = allDocs.filter(
7025
- (d) => matchedLower.has(d.frontmatter.title.toLowerCase())
7511
+ (d) => matchedLower.has(d.frontmatter.title.toLowerCase()) && isVisible(d)
7026
7512
  );
7027
7513
  }
7028
7514
  }
@@ -7086,6 +7572,27 @@ queryRoutes.post("/context", async (c) => {
7086
7572
  }
7087
7573
  }
7088
7574
  }
7575
+ const relevance = /* @__PURE__ */ new Map();
7576
+ let fallback = null;
7577
+ if (compileDetail) {
7578
+ const tokens = tokenizePrompt(body.prompt);
7579
+ const compiled = new Set(documents.map((d) => d.id));
7580
+ for (const d of allDocs) {
7581
+ const { score, via } = scorePrompt(d, tokens);
7582
+ if (compiled.has(d.id)) relevance.set(d.id, { score, via: via ?? "hop" });
7583
+ else if (score > 0 && isVisible(d)) {
7584
+ relevance.set(d.id, { score, via });
7585
+ documents.push(d);
7586
+ }
7587
+ }
7588
+ if (compiled.size === 0 && documents.length > 0) fallback = "fulltext";
7589
+ const top = Math.max(0, ...documents.map((d) => relevance.get(d.id).score));
7590
+ documents = documents.filter((d) => {
7591
+ const r = relevance.get(d.id);
7592
+ return compiled.has(d.id) || r.via === "hop" || r.score >= top / 2;
7593
+ });
7594
+ documents.sort((a, b) => relevance.get(b.id).score - relevance.get(a.id).score);
7595
+ }
7089
7596
  const userId = c.get("userId");
7090
7597
  const userEmail = await resolveCallerEmail(userId);
7091
7598
  const beforePermission = documents.length;
@@ -7103,10 +7610,14 @@ queryRoutes.post("/context", async (c) => {
7103
7610
  for (const doc of readable) {
7104
7611
  const block = formatContextBlock(doc, doc.url);
7105
7612
  const blockTokens = approxTokens(block);
7106
- if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
7613
+ if (tokenCount + blockTokens > maxTokens) continue;
7107
7614
  included.push(doc);
7108
7615
  tokenCount += blockTokens;
7109
7616
  }
7617
+ if (included.length === 0 && readable.length > 0) {
7618
+ included.push(readable[0]);
7619
+ tokenCount = approxTokens(formatContextBlock(readable[0], readable[0].url));
7620
+ }
7110
7621
  const truncatedByBudget = accessible.length - included.length;
7111
7622
  const contextMarkdown = included.map((n) => formatContextBlock(n, n.url)).join("\n\n---\n\n");
7112
7623
  trackEvent("context.resolve", {
@@ -7123,7 +7634,8 @@ queryRoutes.post("/context", async (c) => {
7123
7634
  title: n.frontmatter.title,
7124
7635
  url: n.url,
7125
7636
  tags: n.frontmatter.tags || [],
7126
- type: n.frontmatter.type || "document"
7637
+ type: n.frontmatter.type || "document",
7638
+ ...relevance.get(n.id)
7127
7639
  })),
7128
7640
  trace: {
7129
7641
  selector_used: selector,
@@ -7131,7 +7643,8 @@ queryRoutes.post("/context", async (c) => {
7131
7643
  compiled: compileDetail ? {
7132
7644
  matched_tags: compileDetail.matchedTags,
7133
7645
  matched_titles: compileDetail.matchedTitles,
7134
- unmatched_tokens: compileDetail.unmatched
7646
+ unmatched_tokens: compileDetail.unmatched,
7647
+ fallback
7135
7648
  } : null,
7136
7649
  total_matched: documents.length,
7137
7650
  permission_filtered: permissionFiltered,
@@ -7316,11 +7829,11 @@ queryRoutes.get("/export", async (c) => {
7316
7829
  let tokens = 0;
7317
7830
  for (const f of fields) {
7318
7831
  const t = approxTokens(nodeToMarkdown(f));
7319
- if (tokens + t > maxTokens && kept.length > 0) break;
7832
+ if (tokens + t > maxTokens) continue;
7320
7833
  kept.push(f);
7321
7834
  tokens += t;
7322
7835
  }
7323
- included = kept;
7836
+ included = kept.length > 0 ? kept : fields.slice(0, 1);
7324
7837
  }
7325
7838
  trackEvent("nest.export", {
7326
7839
  nestId,
@@ -7398,7 +7911,7 @@ queryRoutes.post("/publish", async (c) => {
7398
7911
  });
7399
7912
 
7400
7913
  // src/mcp/routes.ts
7401
- import { Hono as Hono14 } from "hono";
7914
+ import { Hono as Hono15 } from "hono";
7402
7915
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7403
7916
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7404
7917
 
@@ -7478,10 +7991,10 @@ async function listTraceEvents(filters = {}) {
7478
7991
  }
7479
7992
 
7480
7993
  // src/mcp/workflow-tools.ts
7481
- import { v4 as uuid11 } from "uuid";
7994
+ import { v4 as uuid12 } from "uuid";
7482
7995
 
7483
7996
  // src/workflow/run-service.ts
7484
- import { v4 as uuid9 } from "uuid";
7997
+ import { v4 as uuid10 } from "uuid";
7485
7998
  var RUNNABLE_TYPES = ["agent", "skill"];
7486
7999
  async function flowSubgraph(nestId, agentNode) {
7487
8000
  const db = getDb();
@@ -7611,7 +8124,7 @@ async function triggerRun(opts) {
7611
8124
  }
7612
8125
  const edges = await flowSubgraph(nestId, agentNode);
7613
8126
  const definitions = await referencedDefinitions(nestId, edges);
7614
- const id = `run_${uuid9()}`;
8127
+ const id = `run_${uuid10()}`;
7615
8128
  const db = getDb();
7616
8129
  await db.transaction(async (tx) => {
7617
8130
  if (parentRunId) {
@@ -7797,8 +8310,8 @@ async function listRuns(nestId, opts) {
7797
8310
  }
7798
8311
 
7799
8312
  // src/workflow/edge-routes.ts
7800
- import { Hono as Hono13 } from "hono";
7801
- import { v4 as uuid10 } from "uuid";
8313
+ import { Hono as Hono14 } from "hono";
8314
+ import { v4 as uuid11 } from "uuid";
7802
8315
  function toResponse(e, t) {
7803
8316
  return {
7804
8317
  id: e.id,
@@ -7859,7 +8372,9 @@ async function validateCondition(nestId, mode, condition) {
7859
8372
  async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
7860
8373
  if (fromNode === toNode) {
7861
8374
  throw new ConflictError(
7862
- `This step loops back on itself: ${await nodeTrail(db, nestId, [fromNode])}. A flow can't return to a step it already ran.`
8375
+ `This step loops back on itself: ${await nodeTrail(db, nestId, [fromNode])}.
8376
+
8377
+ A flow can't return to a step it already ran.`
7863
8378
  );
7864
8379
  }
7865
8380
  const rows = await db.all(
@@ -7888,7 +8403,9 @@ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
7888
8403
  }
7889
8404
  path.reverse().push(toNode);
7890
8405
  throw new ConflictError(
7891
- `These steps loop back on each other: ${await nodeTrail(db, nestId, path)}. A flow can't return to a step it already ran.`
8406
+ `These steps loop back on each other: ${await nodeTrail(db, nestId, path)}.
8407
+
8408
+ A flow can't return to a step it already ran.`
7892
8409
  );
7893
8410
  }
7894
8411
  for (const nb of adj.get(cur) ?? []) {
@@ -7912,7 +8429,7 @@ async function assertNoDuplicateEdge(db, nestId, fromNode, toNode, type, exclude
7912
8429
  );
7913
8430
  }
7914
8431
  }
7915
- var edgeRoutes = new Hono13();
8432
+ var edgeRoutes = new Hono14();
7916
8433
  edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
7917
8434
  const nestId = c.req.param("nestId");
7918
8435
  const db = getDb();
@@ -7980,7 +8497,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
7980
8497
  );
7981
8498
  const metadata = body.metadata === void 0 || body.metadata === null ? null : JSON.stringify(body.metadata);
7982
8499
  const now = (/* @__PURE__ */ new Date()).toISOString();
7983
- const id = uuid10();
8500
+ const id = uuid11();
7984
8501
  await db.transaction(async (tx) => {
7985
8502
  await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
7986
8503
  if (type.is_flow) {
@@ -8381,7 +8898,7 @@ async function createEdge(ctx, args) {
8381
8898
  );
8382
8899
  const metadata = args.metadata === void 0 || args.metadata === null ? null : JSON.stringify(args.metadata);
8383
8900
  const now = (/* @__PURE__ */ new Date()).toISOString();
8384
- const id = uuid11();
8901
+ const id = uuid12();
8385
8902
  await db.transaction(async (tx) => {
8386
8903
  await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
8387
8904
  if (type.is_flow) {
@@ -8445,7 +8962,7 @@ async function upsertEdgeType(ctx, args) {
8445
8962
  `INSERT INTO edge_types
8446
8963
  (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
8447
8964
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
8448
- [uuid11(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
8965
+ [uuid12(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
8449
8966
  );
8450
8967
  }
8451
8968
  });
@@ -8654,6 +9171,43 @@ var TOOL_DEFINITIONS = [
8654
9171
  }
8655
9172
  }
8656
9173
  },
9174
+ {
9175
+ name: "context_comment",
9176
+ description: "Leave a comment on a document \u2014 the same threads the web UI's comments panel shows. Omit `thread` to open a new one; pass a thread id (from context_comments) to reply to it. Use this to answer feedback you were given, or to raise something you noticed while working. Anyone who can read the document may comment; `@name` in the body notifies that person.",
9177
+ inputSchema: {
9178
+ type: "object",
9179
+ properties: {
9180
+ title: { type: "string", description: "Title of the node" },
9181
+ id: { type: "string", description: "ID of the node" },
9182
+ body: { type: "string", description: "The comment text" },
9183
+ thread: {
9184
+ type: "string",
9185
+ description: "Thread id to reply to (from context_comments). Omit to open a new thread on the document."
9186
+ }
9187
+ },
9188
+ required: ["body"]
9189
+ }
9190
+ },
9191
+ {
9192
+ name: "context_resolve_comment",
9193
+ description: "Mark a comment thread resolved once you have addressed it, or reopen one that was closed too early. Records you as the resolver, exactly as the web UI does. Resolve only threads you actually acted on.",
9194
+ inputSchema: {
9195
+ type: "object",
9196
+ properties: {
9197
+ title: { type: "string", description: "Title of the node" },
9198
+ id: { type: "string", description: "ID of the node" },
9199
+ thread: {
9200
+ type: "string",
9201
+ description: "Thread id, from context_comments"
9202
+ },
9203
+ reopen: {
9204
+ type: "boolean",
9205
+ description: "Reopen the thread instead of resolving it"
9206
+ }
9207
+ },
9208
+ required: ["thread"]
9209
+ }
9210
+ },
8657
9211
  {
8658
9212
  name: "context_create",
8659
9213
  description: "Create a new knowledge node in the vault. The title must not already be in use \u2014 these tools resolve documents by title, so a duplicate is refused rather than filed as an unreachable twin. To change an existing document use context_update; to refile it use context_move.",
@@ -9359,8 +9913,8 @@ ${parts.join(
9359
9913
  node = docs.find((n) => n.id === args.id);
9360
9914
  }
9361
9915
  if (!node) return `Node not found: ${args.title || args.id}`;
9362
- if (await resolveLlmBody(ctx, node) === null) {
9363
- return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
9916
+ if (!await canReadNode(nestId, node.id, ctx.userId, userEmail)) {
9917
+ return `Node not found: ${args.title || args.id}`;
9364
9918
  }
9365
9919
  const threads = await listThreads(nestId, node.id);
9366
9920
  if (!threads.length)
@@ -9379,6 +9933,89 @@ ${quote}${comments}`;
9379
9933
 
9380
9934
  ${sections}`;
9381
9935
  }
9936
+ // ── Comments: write half of context_comments ──────────────────────────
9937
+ //
9938
+ // Both cases target the annotation store the UI's comments panel uses, so
9939
+ // an agent's comment and a human's are the same object, and both carry the
9940
+ // side effects the REST routes carry: the @mention fan-out and the derived
9941
+ // `— Annotations` projection an artifact's agents read back.
9942
+ //
9943
+ // PRIMARY permission gate, like context_create's: the /mcp middleware
9944
+ // admits read-tier callers (the transport is always POST), so the check
9945
+ // here is the real one. Commenting is READ-tier — the same rule REST
9946
+ // applies in app.ts (isAnnotationAction) — so canReadNode is the bar, not
9947
+ // write access. Deliberately NOT gated on an approved version: a reviewer
9948
+ // comments on a draft, and unlike reading a thread, writing one discloses
9949
+ // nothing (the caller supplies the text).
9950
+ case "context_comment":
9951
+ case "context_resolve_comment": {
9952
+ const body = String(args.body ?? "");
9953
+ const isResolve = toolName === "context_resolve_comment";
9954
+ if (!isResolve && !body.trim()) {
9955
+ return "body is required \u2014 the comment text.";
9956
+ }
9957
+ const threadId = args.thread ? String(args.thread) : "";
9958
+ if (isResolve && !threadId) {
9959
+ return "thread is required \u2014 the id from context_comments.";
9960
+ }
9961
+ const docs = await storage.discoverDocuments();
9962
+ let node;
9963
+ if (args.title) {
9964
+ node = docs.find(
9965
+ (n) => n.frontmatter.title.toLowerCase() === String(args.title).toLowerCase()
9966
+ );
9967
+ } else if (args.id) {
9968
+ node = docs.find((n) => n.id === String(args.id));
9969
+ }
9970
+ if (!node) {
9971
+ return toolError(
9972
+ `Node not found: ${args.title || args.id || "(no title or id given)"}`,
9973
+ "DOCUMENT_NOT_FOUND"
9974
+ );
9975
+ }
9976
+ if (!await canReadNode(nestId, node.id, ctx.userId, userEmail)) {
9977
+ return toolError(
9978
+ `Node not found: ${args.title || args.id}`,
9979
+ "DOCUMENT_NOT_FOUND"
9980
+ );
9981
+ }
9982
+ const label = node.frontmatter.title || node.id;
9983
+ if (threadId) {
9984
+ const existing = await getThread(threadId);
9985
+ if (!existing || existing.nodeId !== node.id || existing.nestId !== nestId) {
9986
+ return toolError(
9987
+ `Thread ${threadId} is not a thread on "${label}". Call context_comments to list its threads.`,
9988
+ "DOCUMENT_NOT_FOUND"
9989
+ );
9990
+ }
9991
+ }
9992
+ if (isResolve) {
9993
+ const reopen = args.reopen === true;
9994
+ const thread2 = await setThreadStatus(
9995
+ nestId,
9996
+ node.id,
9997
+ threadId,
9998
+ reopen ? "open" : "resolved",
9999
+ userEmail
10000
+ );
10001
+ await syncAnnotationsNode(nestId, node.id, userEmail);
10002
+ return reopen ? `Reopened the thread on "${label}". It is back in the open list for whoever picks it up.` : `Resolved the thread on "${label}", recorded against ${thread2.resolvedBy}.`;
10003
+ }
10004
+ const thread = threadId ? await addComment(nestId, node.id, threadId, userEmail, body) : await createThread(nestId, node.id, { body, anchor: null }, userEmail);
10005
+ await notifyCommentMentions({
10006
+ nestId,
10007
+ nodeId: node.id,
10008
+ body,
10009
+ actorEmail: userEmail,
10010
+ // Without this the notification still reaches the person, but its
10011
+ // "Open document" link falls back to PUBLIC_BASE_URL and comes out
10012
+ // empty when that isn't configured — every other notifying tool in
10013
+ // this file passes it, and the REST route passes requestBaseUrl.
10014
+ baseUrl: ctx.baseUrl
10015
+ });
10016
+ await syncAnnotationsNode(nestId, node.id, userEmail);
10017
+ return threadId ? `Replied on "${label}", in thread ${thread.id}.` : `Commented on "${label}" \u2014 opened thread ${thread.id}. Reply to it by passing that id as \`thread\`.`;
10018
+ }
9382
10019
  case "context_create": {
9383
10020
  if (!await canCreateInNest(nestId, userEmail)) {
9384
10021
  return toolError(
@@ -9727,21 +10364,20 @@ ${list}${more}
9727
10364
  Submit one with \`context_submit_review\`.`;
9728
10365
  }
9729
10366
  case "context_submit_review": {
9730
- const docs = await storage.discoverDocuments();
9731
- const node = docs.find(
9732
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
9733
- );
9734
- if (!node) return toolError(`Node not found: ${args.title}`, "DOCUMENT_NOT_FOUND");
10367
+ const resolved = await resolveTargetNode(storage, args);
10368
+ if (typeof resolved === "string") return toolError(resolved, "DOCUMENT_NOT_FOUND");
10369
+ const node = resolved;
10370
+ const nodeLabel = args.title || args.id;
9735
10371
  const submitCheck = await canUserEdit(ctx.nestId, node.id, userEmail);
9736
10372
  if (!submitCheck.allowed) {
9737
10373
  return toolError(
9738
- `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`,
10374
+ `You don't have permission to submit "${nodeLabel}" for review: ${submitCheck.reason}`,
9739
10375
  "UNAUTHORIZED_ACTION"
9740
10376
  );
9741
10377
  }
9742
10378
  const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
9743
10379
  if (currentVersion === 0)
9744
- return toolError(`No versions found for "${args.title}"`, "VERSION_NOT_FOUND");
10380
+ return toolError(`No versions found for "${nodeLabel}"`, "VERSION_NOT_FOUND");
9745
10381
  try {
9746
10382
  const request = await submitForReview({
9747
10383
  nestId: ctx.nestId,
@@ -9752,16 +10388,16 @@ Submit one with \`context_submit_review\`.`;
9752
10388
  priority: args.priority,
9753
10389
  baseUrl: ctx.baseUrl
9754
10390
  });
9755
- const resolved = await resolveStewardsForNode(
10391
+ const resolved2 = await resolveStewardsForNode(
9756
10392
  ctx.nestId,
9757
10393
  node.id
9758
10394
  );
9759
- const stewardList = resolved.length > 0 ? `
10395
+ const stewardList = resolved2.length > 0 ? `
9760
10396
 
9761
10397
  **Stewards who can review:**
9762
- ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
10398
+ ${resolved2.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
9763
10399
  return {
9764
- text: `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
10400
+ text: `Submitted "${nodeLabel}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
9765
10401
  // No catalog shape to conform to — `context_submit_review` is a
9766
10402
  // governance-namespace operation and that namespace is declared but
9767
10403
  // unimplemented in the engine (`api/index.ts`, NAMESPACES). So this
@@ -9782,7 +10418,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
9782
10418
  requested_by: request.requestedBy,
9783
10419
  requested_at: request.requestedAt
9784
10420
  },
9785
- reviewers: resolved.map((r) => r.steward.userEmail)
10421
+ reviewers: resolved2.map((r) => r.steward.userEmail)
9786
10422
  }
9787
10423
  };
9788
10424
  } catch (err) {
@@ -9927,19 +10563,19 @@ ${list}`,
9927
10563
  if (!target) return "target is required (a node id or folder prefix).";
9928
10564
  if (!["read", "write"].includes(role)) return "role must be read or write.";
9929
10565
  try {
9930
- const { createGrant: createGrant2 } = await import("./grants-service-UZ2MGDO5.js");
9931
- const db = (await import("./client-CSCHXUNX.js")).getDb();
10566
+ const { createGrant: createGrant2 } = await import("./grants-service-UT3EQ3R7.js");
10567
+ const db = (await import("./client-KEY4PYJH.js")).getDb();
9932
10568
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
9933
10569
  const e = normalizeEmail2(String(args.email || ""));
9934
10570
  if (!e) return "email is required.";
9935
10571
  let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
9936
10572
  if (!row) {
9937
10573
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
9938
- const { v4: uuid19 } = await import("uuid");
9939
- const id = uuid19();
10574
+ const { v4: uuid20 } = await import("uuid");
10575
+ const id = uuid20();
9940
10576
  await db.run(
9941
10577
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
9942
- [id, e, null, await hashPassword2(uuid19())]
10578
+ [id, e, null, await hashPassword2(uuid20())]
9943
10579
  );
9944
10580
  row = { id };
9945
10581
  }
@@ -9994,7 +10630,7 @@ ${list}`;
9994
10630
 
9995
10631
  // src/mcp/routes.ts
9996
10632
  import { z } from "zod";
9997
- var mcpRoutes = new Hono14();
10633
+ var mcpRoutes = new Hono15();
9998
10634
  async function getUserEmail2(userId) {
9999
10635
  const db = getDb();
10000
10636
  const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
@@ -10060,8 +10696,8 @@ mcpRoutes.all("/", async (c) => {
10060
10696
  });
10061
10697
 
10062
10698
  // src/workflow/run-routes.ts
10063
- import { Hono as Hono15 } from "hono";
10064
- import { v4 as uuid12 } from "uuid";
10699
+ import { Hono as Hono16 } from "hono";
10700
+ import { v4 as uuid13 } from "uuid";
10065
10701
  var RUNNABLE_TYPES2 = ["agent", "skill"];
10066
10702
  var safeJson2 = (s) => {
10067
10703
  if (!s) return null;
@@ -10179,7 +10815,7 @@ async function buildRunBundle(nestId, agentNode) {
10179
10815
  definitions
10180
10816
  };
10181
10817
  }
10182
- var runTriggerRoutes = new Hono15();
10818
+ var runTriggerRoutes = new Hono16();
10183
10819
  async function resolveParentRun2(nestId, parentRunId, childAgent) {
10184
10820
  if (!config.FEATURE_SUBAGENT_RUNS) {
10185
10821
  throw new ValidationError(
@@ -10258,7 +10894,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
10258
10894
  }
10259
10895
  const bundle = await buildRunBundle(nestId, agentNode);
10260
10896
  const triggeredBy = await resolveCallerEmail(c.get("userId"));
10261
- const id = `run_${uuid12()}`;
10897
+ const id = `run_${uuid13()}`;
10262
10898
  await getDb().run(
10263
10899
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
10264
10900
  VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)`,
@@ -10287,7 +10923,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
10287
10923
  201
10288
10924
  );
10289
10925
  });
10290
- var runListRoutes = new Hono15();
10926
+ var runListRoutes = new Hono16();
10291
10927
  runListRoutes.get("/", requireWorkflowPlane, async (c) => {
10292
10928
  const nestId = c.req.param("nestId");
10293
10929
  const db = getDb();
@@ -10319,7 +10955,7 @@ runListRoutes.get("/", requireWorkflowPlane, async (c) => {
10319
10955
  }))
10320
10956
  });
10321
10957
  });
10322
- var runDetailRoutes = new Hono15();
10958
+ var runDetailRoutes = new Hono16();
10323
10959
  async function loadRunWithPermission(c, runId, required) {
10324
10960
  const run = await getDb().get("SELECT * FROM runs WHERE id = ?", [
10325
10961
  runId
@@ -10470,8 +11106,8 @@ runDetailRoutes.get("/:id", requireWorkflowPlane, async (c) => {
10470
11106
  });
10471
11107
 
10472
11108
  // src/workflow/schedule-routes.ts
10473
- import { Hono as Hono16 } from "hono";
10474
- import { v4 as uuid13 } from "uuid";
11109
+ import { Hono as Hono17 } from "hono";
11110
+ import { v4 as uuid14 } from "uuid";
10475
11111
  var RUNNABLE_TYPES3 = ["agent", "skill"];
10476
11112
  var MIN_EVERY = 5;
10477
11113
  var MAX_EVERY = 7 * 24 * 60;
@@ -10508,7 +11144,7 @@ function parseEvery(v) {
10508
11144
  }
10509
11145
  return n;
10510
11146
  }
10511
- var scheduleRoutes = new Hono16();
11147
+ var scheduleRoutes = new Hono17();
10512
11148
  scheduleRoutes.get("/", requireWorkflowPlane, async (c) => {
10513
11149
  const nestId = c.req.param("nestId");
10514
11150
  const rows = await getDb().all(
@@ -10549,7 +11185,7 @@ scheduleRoutes.post("/", requireWorkflowPlane, async (c) => {
10549
11185
  );
10550
11186
  }
10551
11187
  const now = (/* @__PURE__ */ new Date()).toISOString();
10552
- const id = `sch_${uuid13()}`;
11188
+ const id = `sch_${uuid14()}`;
10553
11189
  await db.run(
10554
11190
  `INSERT INTO schedules (id, nest_id, agent_node, every_minutes, enabled, last_run_at, max_runs, runs_created, created_by, created_at, updated_at)
10555
11191
  VALUES (?, ?, ?, ?, ?, NULL, ?, 0, ?, ?, ?)`,
@@ -10604,13 +11240,14 @@ scheduleRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
10604
11240
  });
10605
11241
 
10606
11242
  // src/notify/connector-routes.ts
10607
- import { Hono as Hono17 } from "hono";
10608
- import { v4 as uuid14 } from "uuid";
11243
+ import { Hono as Hono18 } from "hono";
11244
+ import { v4 as uuid15 } from "uuid";
10609
11245
  var KNOWN_EVENTS = [
10610
11246
  "review_requested",
10611
11247
  "review_approved",
10612
11248
  "review_rejected",
10613
- "run_failed"
11249
+ "run_failed",
11250
+ "mention"
10614
11251
  ];
10615
11252
  var CHANNELS = ["slack", "teams", "webhook"];
10616
11253
  function toResponse3(r) {
@@ -10651,7 +11288,7 @@ function parseUrl(v) {
10651
11288
  'url must be a public https:// URL (loopback/private/metadata hosts are refused) or an env reference like "env:SLACK_WEBHOOK"'
10652
11289
  );
10653
11290
  }
10654
- var connectorRoutes = new Hono17();
11291
+ var connectorRoutes = new Hono18();
10655
11292
  connectorRoutes.get("/", async (c) => {
10656
11293
  const nestId = c.req.param("nestId");
10657
11294
  const rows = await getDb().all(
@@ -10670,7 +11307,7 @@ connectorRoutes.post("/", async (c) => {
10670
11307
  const url = parseUrl(body.url);
10671
11308
  const events = parseEvents(body.events);
10672
11309
  const now = (/* @__PURE__ */ new Date()).toISOString();
10673
- const id = `con_${uuid14()}`;
11310
+ const id = `con_${uuid15()}`;
10674
11311
  await getDb().run(
10675
11312
  `INSERT INTO connectors (id, nest_id, channel, url, events, enabled, created_by, created_at, updated_at)
10676
11313
  VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
@@ -10713,7 +11350,7 @@ connectorRoutes.delete("/:id", async (c) => {
10713
11350
  });
10714
11351
 
10715
11352
  // src/workflow/hook-routes.ts
10716
- import { Hono as Hono18 } from "hono";
11353
+ import { Hono as Hono19 } from "hono";
10717
11354
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2, randomUUID } from "crypto";
10718
11355
  var PRESETS = ["slack", "teams", "webhook"];
10719
11356
  var RUNNABLE_TYPES4 = ["agent", "skill"];
@@ -10736,7 +11373,7 @@ function toResponse4(c, r, opts = {}) {
10736
11373
  created_at: r.created_at
10737
11374
  };
10738
11375
  }
10739
- var hookNestRoutes = new Hono18();
11376
+ var hookNestRoutes = new Hono19();
10740
11377
  hookNestRoutes.get("/", requireWorkflowPlane, async (c) => {
10741
11378
  const nestId = c.req.param("nestId");
10742
11379
  const agent = c.req.query("agent");
@@ -10810,7 +11447,7 @@ function teamsSignatureValid(secret, authHeader, rawBody) {
10810
11447
  function stripMentions(text) {
10811
11448
  return text.replace(/<@[^>]+>/g, "").replace(/<at>[^<]*<\/at>/gi, "").trim();
10812
11449
  }
10813
- var hookFireRoutes = new Hono18();
11450
+ var hookFireRoutes = new Hono19();
10814
11451
  hookFireRoutes.post("/:token", async (c) => {
10815
11452
  const token = c.req.param("token");
10816
11453
  if (!tryConsume("hook:*", { max: 120, windowMs: 6e4 })) {
@@ -10912,9 +11549,9 @@ hookFireRoutes.post("/:token", async (c) => {
10912
11549
  });
10913
11550
 
10914
11551
  // src/definitions/routes.ts
10915
- import { Hono as Hono19 } from "hono";
10916
- import { v4 as uuid15 } from "uuid";
10917
- var definitionRoutes = new Hono19();
11552
+ import { Hono as Hono20 } from "hono";
11553
+ import { v4 as uuid16 } from "uuid";
11554
+ var definitionRoutes = new Hono20();
10918
11555
  definitionRoutes.get("/", async (c) => {
10919
11556
  const nestId = c.req.param("nestId");
10920
11557
  const db = getDb();
@@ -10993,7 +11630,7 @@ definitionRoutes.post("/", async (c) => {
10993
11630
  await applyUpdate(tx, existing.id);
10994
11631
  targetId = existing.id;
10995
11632
  } else {
10996
- targetId = uuid15();
11633
+ targetId = uuid16();
10997
11634
  wasCreated = true;
10998
11635
  await tx.run(
10999
11636
  `INSERT INTO definitions (id, nest_id, term, definition, linked_tag, defined_by, created_at, updated_at)
@@ -11022,11 +11659,11 @@ definitionRoutes.delete("/:id", async (c) => {
11022
11659
  });
11023
11660
 
11024
11661
  // src/nests/asset-routes.ts
11025
- import { Hono as Hono20 } from "hono";
11026
- import { v4 as uuid16 } from "uuid";
11662
+ import { Hono as Hono21 } from "hono";
11663
+ import { v4 as uuid17 } from "uuid";
11027
11664
  import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
11028
11665
  import { join as join4 } from "path";
11029
- var assetRoutes = new Hono20();
11666
+ var assetRoutes = new Hono21();
11030
11667
  var CONTENT_TYPES = {
11031
11668
  png: "image/png",
11032
11669
  jpg: "image/jpeg",
@@ -11072,7 +11709,7 @@ assetRoutes.post("/", async (c) => {
11072
11709
  `${isVideo ? "video" : "image"} too large (${(file.size / (1024 * 1024)).toFixed(1)} MB, max ${maxBytes / (1024 * 1024)} MB for ${isVideo ? "video" : "images"})`
11073
11710
  );
11074
11711
  }
11075
- const name = `${uuid16()}.${ext === "jpeg" ? "jpg" : ext}`;
11712
+ const name = `${uuid17()}.${ext === "jpeg" ? "jpg" : ext}`;
11076
11713
  const dir = join4(resolveNestPath(nestId), "assets");
11077
11714
  await mkdir2(dir, { recursive: true });
11078
11715
  await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
@@ -11112,12 +11749,125 @@ assetRoutes.get("/:file", async (c) => {
11112
11749
  });
11113
11750
  });
11114
11751
 
11752
+ // src/auth/community-sites-routes.ts
11753
+ import { Hono as Hono22 } from "hono";
11754
+ var serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
11755
+ var isHttpUrl = (v) => /^https?:\/\/\S+$/i.test(v);
11756
+ var serializeSite = (site) => ({
11757
+ id: site.id,
11758
+ name: site.name,
11759
+ url: site.url,
11760
+ secret_set: !!site.secret,
11761
+ is_active: site.is_active,
11762
+ created_at: site.created_at,
11763
+ updated_at: site.updated_at
11764
+ });
11765
+ var communitySiteRoutes = new Hono22();
11766
+ communitySiteRoutes.get("/", async (c) => {
11767
+ if (!await serverAdminAllowed(c))
11768
+ return c.json({ error: "Only the server admin can view this." }, 403);
11769
+ const sites = await listCommunitySites();
11770
+ return c.json({ community_sites: sites.map(serializeSite) });
11771
+ });
11772
+ communitySiteRoutes.post("/", async (c) => {
11773
+ if (!await serverAdminAllowed(c))
11774
+ return c.json({ error: "Only the server admin can add a community site." }, 403);
11775
+ let body;
11776
+ try {
11777
+ body = await c.req.json();
11778
+ } catch {
11779
+ return c.json({ error: "Invalid JSON body" }, 400);
11780
+ }
11781
+ const name = typeof body.name === "string" ? body.name.trim() : "";
11782
+ const url = typeof body.url === "string" ? body.url.trim().replace(/\/$/, "") : "";
11783
+ const secret = typeof body.secret === "string" ? body.secret.trim() : "";
11784
+ const is_active = body.is_active === void 0 ? true : !!body.is_active;
11785
+ if (!name) return c.json({ error: "A name is required." }, 400);
11786
+ if (!url || !isHttpUrl(url))
11787
+ return c.json({ error: "A valid http(s) site URL is required." }, 400);
11788
+ if (!secret || secret.length < 16)
11789
+ return c.json({ error: "A secret of at least 16 characters is required." }, 400);
11790
+ try {
11791
+ const site = await createCommunitySite({ name, url, secret, is_active });
11792
+ await loadActiveCommunitySites();
11793
+ return c.json(serializeSite(site), 201);
11794
+ } catch (err) {
11795
+ const msg = err instanceof Error ? err.message : String(err);
11796
+ if (/unique|duplicate/i.test(msg))
11797
+ return c.json({ error: "A community site with this URL already exists." }, 409);
11798
+ throw err;
11799
+ }
11800
+ });
11801
+ communitySiteRoutes.patch("/:id", async (c) => {
11802
+ if (!await serverAdminAllowed(c))
11803
+ return c.json({ error: "Only the server admin can edit a community site." }, 403);
11804
+ const id = c.req.param("id");
11805
+ let body;
11806
+ try {
11807
+ body = await c.req.json();
11808
+ } catch {
11809
+ return c.json({ error: "Invalid JSON body" }, 400);
11810
+ }
11811
+ const patch = {};
11812
+ if ("name" in body) {
11813
+ const v = typeof body.name === "string" ? body.name.trim() : "";
11814
+ if (!v) return c.json({ error: "A name is required." }, 400);
11815
+ patch.name = v;
11816
+ }
11817
+ if ("url" in body) {
11818
+ const v = typeof body.url === "string" ? body.url.trim().replace(/\/$/, "") : "";
11819
+ if (!v || !isHttpUrl(v))
11820
+ return c.json({ error: "A valid http(s) site URL is required." }, 400);
11821
+ patch.url = v;
11822
+ }
11823
+ if ("secret" in body) {
11824
+ const v = typeof body.secret === "string" ? body.secret.trim() : "";
11825
+ if (!v || v.length < 16)
11826
+ return c.json({ error: "A secret of at least 16 characters is required." }, 400);
11827
+ patch.secret = v;
11828
+ }
11829
+ if ("is_active" in body) patch.is_active = !!body.is_active;
11830
+ try {
11831
+ const site = await updateCommunitySite(id, patch);
11832
+ if (!site) return c.json({ error: "Community site not found." }, 404);
11833
+ await loadActiveCommunitySites();
11834
+ return c.json(serializeSite(site));
11835
+ } catch (err) {
11836
+ const msg = err instanceof Error ? err.message : String(err);
11837
+ if (/unique|duplicate/i.test(msg))
11838
+ return c.json({ error: "A community site with this URL already exists." }, 409);
11839
+ throw err;
11840
+ }
11841
+ });
11842
+ communitySiteRoutes.delete("/:id", async (c) => {
11843
+ if (!await serverAdminAllowed(c))
11844
+ return c.json({ error: "Only the server admin can remove a community site." }, 403);
11845
+ const id = c.req.param("id");
11846
+ const removed = await deleteCommunitySite(id);
11847
+ if (!removed) return c.json({ error: "Community site not found." }, 404);
11848
+ await loadActiveCommunitySites();
11849
+ return c.json({ ok: true, id });
11850
+ });
11851
+
11115
11852
  // src/mcp/server-routes.ts
11116
- import { Hono as Hono21 } from "hono";
11853
+ import { Hono as Hono23 } from "hono";
11117
11854
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
11118
11855
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
11119
11856
  import { z as z2 } from "zod";
11120
- var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set(["nest_create", "context_create"]);
11857
+ var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set([
11858
+ "nest_create",
11859
+ "context_create",
11860
+ "context_update",
11861
+ "context_delete",
11862
+ "context_move",
11863
+ "context_submit_review"
11864
+ ]);
11865
+ var FANOUT_READ_TOOLS = /* @__PURE__ */ new Set([
11866
+ "context_search",
11867
+ "context_list",
11868
+ "context_query",
11869
+ "context_overview"
11870
+ ]);
11121
11871
  async function getUserEmail3(userId) {
11122
11872
  const db = getDb();
11123
11873
  const user = await db.get("SELECT email FROM users WHERE id = ?", [
@@ -11173,7 +11923,9 @@ async function buildNestIndex(userId, nestScope) {
11173
11923
  let document_count = null;
11174
11924
  try {
11175
11925
  const { storage } = await engineCache.get(n.id);
11176
- document_count = (await storage.discoverDocuments()).length;
11926
+ document_count = (await storage.discoverDocuments()).filter(
11927
+ (d) => !isDerivedAnnotationsNode(d.id)
11928
+ ).length;
11177
11929
  } catch {
11178
11930
  }
11179
11931
  return {
@@ -11215,77 +11967,83 @@ var NEST_ARG = {
11215
11967
  description: "Target nest \u2014 id, or exact name/slug (case-insensitive)"
11216
11968
  }
11217
11969
  };
11970
+ var NEST_ARG_OPTIONAL = {
11971
+ nest: {
11972
+ type: "string",
11973
+ description: "Target nest \u2014 id or name. Omit to search/list across ALL accessible nests."
11974
+ }
11975
+ };
11218
11976
  var SERVER_TOOL_DEFINITIONS = [
11219
11977
  {
11220
11978
  name: "nest_index",
11221
- 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.",
11979
+ description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST.",
11222
11980
  inputSchema: { type: "object", properties: {} }
11223
11981
  },
11982
+ // ─── Fan-out reads (nest optional) ─────────────────────────────────────
11224
11983
  {
11225
11984
  name: "context_query",
11226
- description: "Run a structured selector query against one nest. Selector grammar: #tag, type:X, [[Title]], scope:X, combined with +AND, |OR, -NOT.",
11985
+ description: "Run a structured selector query. Omit `nest` to query ALL accessible nests. Selector: #tag, type:X, [[Title]], scope:X, +AND, |OR, -NOT.",
11227
11986
  inputSchema: {
11228
11987
  type: "object",
11229
11988
  properties: {
11230
- ...NEST_ARG,
11989
+ ...NEST_ARG_OPTIONAL,
11231
11990
  query: { type: "string", description: "Selector query" },
11232
11991
  hops: {
11233
11992
  type: "number",
11234
11993
  description: "Graph traversal depth from the matches (default: 2)"
11235
11994
  }
11236
11995
  },
11237
- required: ["nest", "query"]
11996
+ required: ["query"]
11238
11997
  }
11239
11998
  },
11240
11999
  {
11241
12000
  name: "context_search",
11242
- description: "Full-text keyword search across one nest \u2014 the same index-backed match as REST GET /search. Returns `count` (everything found) and the first `limit` hits.",
12001
+ description: "Full-text keyword search. Omit `nest` to search ALL accessible nests. Returns count and first `limit` hits.",
11243
12002
  inputSchema: {
11244
12003
  type: "object",
11245
12004
  properties: {
11246
- ...NEST_ARG,
12005
+ ...NEST_ARG_OPTIONAL,
11247
12006
  query: { type: "string", description: "Search terms" },
11248
12007
  limit: {
11249
12008
  type: "number",
11250
12009
  description: "Max hits to return (default 20, max 200)"
11251
12010
  }
11252
12011
  },
11253
- required: ["nest", "query"]
11254
- }
11255
- },
11256
- {
11257
- name: "context_get",
11258
- description: "Get the FULL content of a node in a nest by title or id.",
11259
- inputSchema: {
11260
- type: "object",
11261
- properties: {
11262
- ...NEST_ARG,
11263
- title: { type: "string", description: "Title of the node" },
11264
- id: { type: "string", description: "ID of the node" }
11265
- },
11266
- required: ["nest"]
12012
+ required: ["query"]
11267
12013
  }
11268
12014
  },
11269
12015
  {
11270
12016
  name: "context_list",
11271
- description: "Browse one nest's contents with optional type, tag, or limit filters.",
12017
+ description: "Browse contents with optional type, tag, or limit filters. Omit `nest` to list ALL accessible nests.",
11272
12018
  inputSchema: {
11273
12019
  type: "object",
11274
12020
  properties: {
11275
- ...NEST_ARG,
12021
+ ...NEST_ARG_OPTIONAL,
11276
12022
  type: { type: "string", description: "Filter by node type" },
11277
12023
  tag: { type: "string", description: "Filter by tag" },
11278
12024
  limit: { type: "number", description: "Max nodes to return" }
11279
- },
11280
- required: ["nest"]
12025
+ }
11281
12026
  }
11282
12027
  },
11283
12028
  {
11284
12029
  name: "context_overview",
11285
- description: "Map of one nest: node count, types, tags, title+snippet per node.",
12030
+ description: "Map of nests: node count, types, tags, title+snippet per node. Omit `nest` for ALL accessible nests.",
12031
+ inputSchema: {
12032
+ type: "object",
12033
+ properties: { ...NEST_ARG_OPTIONAL }
12034
+ }
12035
+ },
12036
+ // ─── Targeted reads (nest required) ────────────────────────────────────
12037
+ {
12038
+ name: "context_get",
12039
+ description: "Get the FULL content of a node in a nest by title or id.",
11286
12040
  inputSchema: {
11287
12041
  type: "object",
11288
- properties: { ...NEST_ARG },
12042
+ properties: {
12043
+ ...NEST_ARG,
12044
+ title: { type: "string", description: "Title of the node" },
12045
+ id: { type: "string", description: "ID of the node" }
12046
+ },
11289
12047
  required: ["nest"]
11290
12048
  }
11291
12049
  },
@@ -11309,6 +12067,22 @@ var SERVER_TOOL_DEFINITIONS = [
11309
12067
  required: ["nest", "selector"]
11310
12068
  }
11311
12069
  },
12070
+ {
12071
+ // Read-only, so it belongs on this endpoint: an agent reading a nest's
12072
+ // documents across nests also needs the feedback left on them. Writing a
12073
+ // comment stays on the per-nest endpoint with the other write tools.
12074
+ name: "context_comments",
12075
+ description: "Get the review comments/annotations on a document in a nest \u2014 anchored quotes, threaded replies, and open/resolved status. Use to pull human feedback into context before acting on a document.",
12076
+ inputSchema: {
12077
+ type: "object",
12078
+ properties: {
12079
+ nest: { type: "string", description: "Nest name or id (see nest_index)" },
12080
+ title: { type: "string", description: "Title of the node" },
12081
+ id: { type: "string", description: "ID of the node" }
12082
+ },
12083
+ required: ["nest"]
12084
+ }
12085
+ },
11312
12086
  {
11313
12087
  name: "nest_create",
11314
12088
  description: "Create a new nest owned by the caller. Returns the new nest's id \u2014 target it with the other tools afterwards.",
@@ -11321,9 +12095,10 @@ var SERVER_TOOL_DEFINITIONS = [
11321
12095
  required: ["name"]
11322
12096
  }
11323
12097
  },
12098
+ // ─── Writes (nest required) ────────────────────────────────────────────
11324
12099
  {
11325
12100
  name: "context_create",
11326
- description: "Create a new knowledge node (document) in a nest. Needs write permission on that nest. A `folder` path creates the folder implicitly \u2014 folders exist through the nodes placed in them.",
12101
+ description: "Create a new document in a nest. Needs write permission. A `folder` path creates the folder implicitly.",
11327
12102
  inputSchema: {
11328
12103
  type: "object",
11329
12104
  properties: {
@@ -11340,6 +12115,66 @@ var SERVER_TOOL_DEFINITIONS = [
11340
12115
  },
11341
12116
  required: ["nest", "title", "content"]
11342
12117
  }
12118
+ },
12119
+ {
12120
+ name: "context_update",
12121
+ description: "Update an existing node by title or id. Needs write permission. Use `content` to replace, `append` to add.",
12122
+ inputSchema: {
12123
+ type: "object",
12124
+ properties: {
12125
+ ...NEST_ARG,
12126
+ title: { type: "string", description: "Title of node to update" },
12127
+ id: { type: "string", description: "Node id (use when title is ambiguous)" },
12128
+ content: { type: "string", description: "New content (replaces)" },
12129
+ append: { type: "string", description: "Content to append" },
12130
+ tags: { type: "array", items: { type: "string" }, description: "Tags to add" },
12131
+ status: { type: "string", description: "New status: draft|pending_review|approved|published" },
12132
+ changeNote: { type: "string", description: "Why this changed \u2014 recorded in history" }
12133
+ },
12134
+ required: ["nest"]
12135
+ }
12136
+ },
12137
+ {
12138
+ name: "context_delete",
12139
+ description: "Delete a node by title or id. Needs write permission. Irreversible.",
12140
+ inputSchema: {
12141
+ type: "object",
12142
+ properties: {
12143
+ ...NEST_ARG,
12144
+ title: { type: "string", description: "Title of node to delete" },
12145
+ id: { type: "string", description: "Node id (use when title is ambiguous)" }
12146
+ },
12147
+ required: ["nest"]
12148
+ }
12149
+ },
12150
+ {
12151
+ name: "context_move",
12152
+ description: "Move a node to a different folder. The id changes; history and links follow. Needs write permission.",
12153
+ inputSchema: {
12154
+ type: "object",
12155
+ properties: {
12156
+ ...NEST_ARG,
12157
+ title: { type: "string", description: "Title of node to move" },
12158
+ id: { type: "string", description: "Node id (use when title is ambiguous)" },
12159
+ folder: { type: "string", description: 'Destination folder (e.g. "gtm/deals"); "" for root' }
12160
+ },
12161
+ required: ["nest", "folder"]
12162
+ }
12163
+ },
12164
+ {
12165
+ name: "context_submit_review",
12166
+ description: "Submit a node for steward review. Stewards will be notified. Use on governed nests.",
12167
+ inputSchema: {
12168
+ type: "object",
12169
+ properties: {
12170
+ ...NEST_ARG,
12171
+ title: { type: "string", description: "Title of node to submit" },
12172
+ id: { type: "string", description: "Node id (use when title is ambiguous)" },
12173
+ note: { type: "string", description: "Note for the reviewer" },
12174
+ priority: { type: "string", description: "Priority: low, normal, high, urgent" }
12175
+ },
12176
+ required: ["nest"]
12177
+ }
11343
12178
  }
11344
12179
  ];
11345
12180
  async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
@@ -11373,8 +12208,49 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
11373
12208
  throw err;
11374
12209
  }
11375
12210
  }
11376
- const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
11377
- const nest = await resolveNestRef(String(args.nest ?? ""), userId);
12211
+ const nestArg = args.nest;
12212
+ if (!nestArg && FANOUT_READ_TOOLS.has(toolName)) {
12213
+ const nests = await buildNestIndex(userId, nestScope);
12214
+ if (!nests.length) return "No nests accessible with these credentials.";
12215
+ const nonEmpty = nests.filter((n) => (n.document_count ?? 0) > 0);
12216
+ if (!nonEmpty.length) {
12217
+ return "All accessible nests are empty. Use context_create to add documents first.";
12218
+ }
12219
+ const results = await Promise.allSettled(
12220
+ nonEmpty.map(async (n) => {
12221
+ const engine2 = await engineCache.get(n.id);
12222
+ const result = await handleToolCall(toolName, args, {
12223
+ storage: engine2.storage,
12224
+ queryEngine: engine2.query,
12225
+ versionManager: engine2.versions,
12226
+ nestId: n.id,
12227
+ userId,
12228
+ userEmail,
12229
+ baseUrl
12230
+ });
12231
+ return { nest: n, result };
12232
+ })
12233
+ );
12234
+ const parts = [];
12235
+ for (const r of results) {
12236
+ if (r.status === "fulfilled") {
12237
+ const { nest: n, result } = r.value;
12238
+ const text = typeof result === "string" ? result : result.text;
12239
+ parts.push(`## ${n.name} (${n.id})
12240
+
12241
+ ${text}`);
12242
+ }
12243
+ }
12244
+ if (!parts.length) {
12245
+ return "No results found across any accessible nests.";
12246
+ }
12247
+ return parts.join("\n\n---\n\n");
12248
+ }
12249
+ const inaccessible = `Nest not found or not accessible: "${nestArg}". Use nest_index to see what these credentials can read.`;
12250
+ if (!nestArg) {
12251
+ return "This tool requires a `nest` argument. Use nest_index to see available nests.";
12252
+ }
12253
+ const nest = await resolveNestRef(String(nestArg), userId);
11378
12254
  if (!nest) return inaccessible;
11379
12255
  if (nestScope && nest.id !== nestScope) return inaccessible;
11380
12256
  if (!await canRead(nest.id, userId)) return inaccessible;
@@ -11421,7 +12297,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
11421
12297
  }
11422
12298
  return server;
11423
12299
  }
11424
- var serverMcpRoutes = new Hono21();
12300
+ var serverMcpRoutes = new Hono23();
11425
12301
  serverMcpRoutes.all("/", async (c) => {
11426
12302
  const userId = c.get("userId");
11427
12303
  const userEmail = await getUserEmail3(userId);
@@ -11443,7 +12319,7 @@ serverMcpRoutes.all("/", async (c) => {
11443
12319
  await server.server.close();
11444
12320
  }
11445
12321
  });
11446
- var nestIndexRoutes = new Hono21();
12322
+ var nestIndexRoutes = new Hono23();
11447
12323
  nestIndexRoutes.get("/", async (c) => {
11448
12324
  const userId = c.get("userId");
11449
12325
  const nests = await buildNestIndex(userId, c.get("nestScope"));
@@ -11516,26 +12392,47 @@ Other read endpoints (all under \`${B}/nests/<nest-id>\`):
11516
12392
  - \`GET /nodes/<node-id>?format=raw\` \u2014 direct consumption path: the node BODY as plain text (no JSON envelope; add \`&frontmatter=1\` for the stored YAML block)
11517
12393
  - \`GET /export?format=markdown\` \u2014 whole nest as one markdown file (optional \`selector\`, \`max_tokens\`)
11518
12394
  - \`GET /graph\` \u2014 ontology graph (nodes, links, tags, stewards)
12395
+ - \`GET /folders\` \u2014 folder tree; \`GET /nodes?folder=<path>\` \u2014 one folder level
12396
+ - \`POST /table-query\` \u2014 exact rows from a table node: \`{ "table": "[[Title]]", "query": "where <col> <op> <value> [select cols] [limit N]" }\`
12397
+ - \`GET /nodes/<node-id>/versions\` \u2014 history (who, when, approval state); \`/versions/<n>\` \u2014 one version
12398
+ - \`GET /nodes/<node-id>/comments\` \u2014 review threads (anchored quotes, replies, open/resolved)
11519
12399
 
11520
12400
  Write endpoints (need write permission):
11521
12401
  - \`POST /nodes\` \u2014 \`{ "title", "content", "tags"?, "type"?, "folder"? }\`
11522
12402
  - \`PATCH /nodes/<node-id>\` \u2014 \`{ "content"? , "append"?, "tags"?, "title"? }\`
11523
- - \`DELETE /nodes/<node-id>\`
11524
- - Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`
12403
+ - \`DELETE /nodes/<node-id>\`; \`POST /nodes/<id>/request-deletion\` when you lack delete rights
12404
+ - \`POST /nodes/<id>/move\` \u2014 \`{ "folder" }\`; history and comments follow
12405
+ - \`POST /nodes/<id>/revert\` \u2014 \`{ "targetVersion" }\` restores a past version as a new one
12406
+ - \`POST /nodes/<id>/comments\` \u2014 leave or reply to a review comment
12407
+ - Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`, \`/cancel-review\`
12408
+ - \`GET /review-queue\` \u2014 what is waiting on a steward in this nest
12409
+
12410
+ ## Your inbox (server-level)
12411
+
12412
+ - \`GET ${B}/me/work\` \u2014 reviews waiting on you and handoffs, across every nest
12413
+ - \`GET ${B}/me/drafts\` \u2014 your documents never submitted for review
12414
+ - \`GET ${B}/me/notifications\` \u2014 watcher and review notifications
11525
12415
 
11526
12416
  ## MCP (Model Context Protocol)
11527
12417
 
11528
12418
  Streamable HTTP, stateless. Two endpoints:
11529
12419
 
11530
- - \`${B}/mcp\` \u2014 server-level, one connection for everything readable.
11531
- Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
11532
- \`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
11533
- each takes a \`nest\` argument (id or exact name) plus the same
11534
- selector/hops/max_tokens parameters as the REST API. Two bootstrap writes:
11535
- \`nest_create\` (new nest owned by the caller) and \`context_create\`
11536
- (new document in a writable nest; a \`folder\` path creates the folder).
11537
- - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
11538
- governance (create/update, submit/approve/reject, stewards, sharing).
12420
+ - \`${B}/mcp\` \u2014 server-level, one connection for everything.
12421
+ Tools: \`nest_index\` (call first), then:
12422
+ - Fan-out reads (omit \`nest\` to search/list ALL nests): \`context_search\`,
12423
+ \`context_list\`, \`context_query\`, \`context_overview\`
12424
+ - Targeted reads (\`nest\` required): \`context_get\`, \`context_resolve\`, \`context_comments\`
12425
+ - Writes (\`nest\` required): \`nest_create\`, \`context_create\`, \`context_update\`,
12426
+ \`context_delete\`, \`context_move\`, \`context_submit_review\`
12427
+ - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, the full toolset. Same names as the
12428
+ server-level tools without the \`nest\` argument, plus:
12429
+ - read: \`context_init\` (operating instructions \u2014 call first), \`context_export\`, \`context_table_query\`, \`context_versions\`
12430
+ - write: \`context_update\`, \`context_delete\`, \`context_move\`, \`context_request_deletion\`
12431
+ - review: \`context_submit_review\`, \`context_approve\`, \`context_reject\`, \`context_review_queue\`, \`context_my_drafts\`
12432
+ - comments: \`context_comment\`, \`context_resolve_comment\`
12433
+ - people: \`context_stewards\`, \`context_assign_steward\`, \`context_share_nest\`, \`context_grant\`
12434
+ - admin: \`context_deletion_queue\`, \`context_resolve_deletion\`, \`context_unsynced_list\`, \`context_sync_folder\`
12435
+ - \`workflow_*\` (runs, edges, edge types) when the nest's workflow plane is on
11539
12436
  Sharing a slice is one call: \`context_grant\` with
11540
12437
  \`{ "email", "target_type": "document" | "folder", "target", "role": "read" | "write" }\`
11541
12438
  \u2014 \`target\` is a node id (\`nodes/win-loss-log\`) or a folder prefix
@@ -11582,14 +12479,14 @@ is \`ctx push --server ${B} --nest <nest-id>\` with the same env var set.
11582
12479
  through steward review before they become AI-visible.
11583
12480
  `;
11584
12481
  }
11585
- var llmsTxtRoutes = new Hono21();
12482
+ var llmsTxtRoutes = new Hono23();
11586
12483
  llmsTxtRoutes.get("/", (c) => {
11587
12484
  const md = renderLlmsTxt(requestBaseUrl(c.req.url));
11588
12485
  return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
11589
12486
  });
11590
12487
 
11591
12488
  // src/governance/routes.ts
11592
- import { Hono as Hono22 } from "hono";
12489
+ import { Hono as Hono24 } from "hono";
11593
12490
 
11594
12491
  // src/governance/feed-service.ts
11595
12492
  var SNIPPET_CHARS = 200;
@@ -11720,7 +12617,7 @@ async function getNestFeed(params) {
11720
12617
  }
11721
12618
 
11722
12619
  // src/governance/comment-service.ts
11723
- import { v4 as uuid17 } from "uuid";
12620
+ import { v4 as uuid18 } from "uuid";
11724
12621
  async function createComment(params) {
11725
12622
  const db = getDb();
11726
12623
  const body = (params.body ?? "").trim();
@@ -11736,7 +12633,7 @@ async function createComment(params) {
11736
12633
  throw new Error("Parent comment not found on this node");
11737
12634
  }
11738
12635
  }
11739
- const id = uuid17();
12636
+ const id = uuid18();
11740
12637
  await db.run(
11741
12638
  `INSERT INTO comments
11742
12639
  (id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
@@ -12021,7 +12918,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
12021
12918
  if (Number.isNaN(n) || n < 1) return def;
12022
12919
  return Math.min(n, max);
12023
12920
  }
12024
- var governanceRoutes = new Hono22();
12921
+ var governanceRoutes = new Hono24();
12025
12922
  governanceRoutes.get("/stewards", async (c) => {
12026
12923
  const nestId = c.req.param("nestId");
12027
12924
  const scope = c.req.query("scope");
@@ -12374,6 +13271,7 @@ governanceRoutes.get("/external-edits", async (c) => {
12374
13271
  await scanNestForDrift(nestId, "user:refresh");
12375
13272
  }
12376
13273
  const entries = await listNestExternalEdits(nestId);
13274
+ if (c.req.query("count") === "1") return c.json({ total: entries.length });
12377
13275
  return c.json({ entries, total: entries.length });
12378
13276
  });
12379
13277
  governanceRoutes.post("/external-edits/scan", async (c) => {
@@ -12388,7 +13286,7 @@ governanceRoutes.get("/activity", async (c) => {
12388
13286
  const activity = await getActivity({ nestId, limit });
12389
13287
  return c.json({ activity });
12390
13288
  });
12391
- var governanceNodeRoutes = new Hono22();
13289
+ var governanceNodeRoutes = new Hono24();
12392
13290
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
12393
13291
  const nestId = c.req.param("nestId");
12394
13292
  const nodeId = c.req.param("nodeId");
@@ -12520,8 +13418,9 @@ governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
12520
13418
  }
12521
13419
  const body = await c.req.json();
12522
13420
  const author = await getUserEmail4(c);
13421
+ let comment;
12523
13422
  try {
12524
- const comment = await createComment({
13423
+ comment = await createComment({
12525
13424
  nestId,
12526
13425
  nodeId,
12527
13426
  author,
@@ -12530,12 +13429,19 @@ governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
12530
13429
  anchor: body.anchor,
12531
13430
  parentId: body.parentId
12532
13431
  });
12533
- return c.json({ comment }, 201);
12534
13432
  } catch (err) {
12535
13433
  throw new ValidationError(
12536
13434
  err instanceof Error ? err.message : String(err)
12537
13435
  );
12538
13436
  }
13437
+ await notifyCommentMentions({
13438
+ nestId,
13439
+ nodeId,
13440
+ body: comment.body,
13441
+ actorEmail: author,
13442
+ baseUrl: requestBaseUrl(c.req.url)
13443
+ });
13444
+ return c.json({ comment }, 201);
12539
13445
  });
12540
13446
  governanceNodeRoutes.post(
12541
13447
  "/:nodeId{.+}/comments/:commentId/resolve",
@@ -12832,6 +13738,21 @@ async function ensureAnonymousUser() {
12832
13738
  return ANON_USER_ID;
12833
13739
  }
12834
13740
 
13741
+ // src/shared/posthog-key.ts
13742
+ var POSTHOG_KEY_MIN_TOKEN = 20;
13743
+ function posthogKeyError(value) {
13744
+ const v = value.trim();
13745
+ if (!v) return null;
13746
+ if (!v.startsWith("phc_"))
13747
+ return "PostHog project key must start with phc_ \u2014 copy the project API key from PostHog settings.";
13748
+ const token = v.slice(4);
13749
+ if (!/^[A-Za-z0-9]*$/.test(token))
13750
+ return "PostHog project key may only contain letters and digits after phc_ \u2014 check for spaces or punctuation.";
13751
+ if (token.length < POSTHOG_KEY_MIN_TOKEN)
13752
+ return `PostHog project key looks too short \u2014 a real key is about 47 characters long (phc_ plus ${POSTHOG_KEY_MIN_TOKEN}+ letters and digits). Copy the whole key from PostHog settings.`;
13753
+ return null;
13754
+ }
13755
+
12835
13756
  // src/app.ts
12836
13757
  import { serveStatic } from "@hono/node-server/serve-static";
12837
13758
  import { fileURLToPath } from "url";
@@ -12953,7 +13874,7 @@ async function canApproveInNest(nestId, email) {
12953
13874
  return ok;
12954
13875
  }
12955
13876
  function createApp() {
12956
- const app = new Hono23({ router: new LinearRouter() });
13877
+ const app = new Hono25({ router: new LinearRouter() });
12957
13878
  const corsOrigins = config.CORS_ORIGINS;
12958
13879
  app.use(
12959
13880
  "*",
@@ -13134,7 +14055,7 @@ function createApp() {
13134
14055
  return c.json({ error: msg }, 500);
13135
14056
  }
13136
14057
  });
13137
- const serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
14058
+ const serverAdminAllowed2 = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
13138
14059
  app.use("/admin/settings", flexAuthMiddleware);
13139
14060
  app.use("/admin/settings/*", flexAuthMiddleware);
13140
14061
  const currentServerSettings = () => ({
@@ -13163,6 +14084,7 @@ function createApp() {
13163
14084
  oidc_allowed_domains: config.OIDC_ALLOWED_DOMAINS.join(", "),
13164
14085
  oidc_auto_provision: config.OIDC_AUTO_PROVISION,
13165
14086
  oidc_department_tagging: config.OIDC_DEPARTMENT_TAGGING,
14087
+ people_suggest_all_users: config.PEOPLE_SUGGEST_ALL_USERS,
13166
14088
  // Agent SSO / token-exchange. MCP_SIGNING_SECRET is WRITE-ONLY — only its
13167
14089
  // presence is reported, never the value (same discipline as the OIDC secret).
13168
14090
  sso_token_exchange_enabled: config.SSO_TOKEN_EXCHANGE_ENABLED,
@@ -13187,12 +14109,12 @@ function createApp() {
13187
14109
  notify_email_to: config.NOTIFY_EMAIL_TO ?? ""
13188
14110
  });
13189
14111
  app.get("/admin/settings", async (c) => {
13190
- if (!await serverAdminAllowed(c))
14112
+ if (!await serverAdminAllowed2(c))
13191
14113
  return c.json({ error: "Only the server admin can view this." }, 403);
13192
14114
  return c.json(currentServerSettings());
13193
14115
  });
13194
14116
  app.post("/admin/settings/test-notification", async (c) => {
13195
- if (!await serverAdminAllowed(c))
14117
+ if (!await serverAdminAllowed2(c))
13196
14118
  return c.json({ error: "Only the server admin can do this." }, 403);
13197
14119
  let body;
13198
14120
  try {
@@ -13206,7 +14128,7 @@ function createApp() {
13206
14128
  return result.ok ? c.json({ ok: true }) : c.json({ error: result.error }, 502);
13207
14129
  });
13208
14130
  app.patch("/admin/settings", async (c) => {
13209
- if (!await serverAdminAllowed(c))
14131
+ if (!await serverAdminAllowed2(c))
13210
14132
  return c.json({ error: "Only the server admin can change this." }, 403);
13211
14133
  let body;
13212
14134
  try {
@@ -13244,7 +14166,9 @@ function createApp() {
13244
14166
  }
13245
14167
  if ("posthog_key" in body) {
13246
14168
  const v = String(body.posthog_key ?? "").trim();
13247
- pending.push({ name: "POSTHOG_KEY", value: v || null });
14169
+ const keyError = posthogKeyError(v);
14170
+ if (keyError) addError("posthog_key", keyError);
14171
+ else pending.push({ name: "POSTHOG_KEY", value: v || null });
13248
14172
  }
13249
14173
  if ("posthog_host" in body) {
13250
14174
  const v = String(body.posthog_host ?? "").trim().replace(/\/$/, "");
@@ -13383,6 +14307,12 @@ function createApp() {
13383
14307
  value: body.oidc_department_tagging ? "true" : null
13384
14308
  });
13385
14309
  }
14310
+ if ("people_suggest_all_users" in body) {
14311
+ pending.push({
14312
+ name: "PEOPLE_SUGGEST_ALL_USERS",
14313
+ value: body.people_suggest_all_users ? "true" : null
14314
+ });
14315
+ }
13386
14316
  if ("sso_token_exchange_enabled" in body) {
13387
14317
  pending.push({
13388
14318
  name: "SSO_TOKEN_EXCHANGE_ENABLED",
@@ -13551,7 +14481,7 @@ function createApp() {
13551
14481
  });
13552
14482
  app.use("/admin/trace", flexAuthMiddleware);
13553
14483
  app.get("/admin/trace", async (c) => {
13554
- if (!await serverAdminAllowed(c))
14484
+ if (!await serverAdminAllowed2(c))
13555
14485
  return c.json({ error: "Only the server admin can view this." }, 403);
13556
14486
  const limitRaw = Number(c.req.query("limit"));
13557
14487
  const offsetRaw = Number(c.req.query("offset"));
@@ -13569,7 +14499,7 @@ function createApp() {
13569
14499
  app.use("/admin/super-admins", flexAuthMiddleware);
13570
14500
  app.use("/admin/super-admins/*", flexAuthMiddleware);
13571
14501
  app.get("/admin/super-admins", async (c) => {
13572
- if (!await serverAdminAllowed(c))
14502
+ if (!await serverAdminAllowed2(c))
13573
14503
  return c.json({ error: "Only the server admin can view this." }, 403);
13574
14504
  const callerEmail = (await resolveCallerEmail(c.get("userId"))).toLowerCase();
13575
14505
  const byEmail = /* @__PURE__ */ new Map();
@@ -13595,7 +14525,7 @@ function createApp() {
13595
14525
  return c.json({ super_admins: [...byEmail.values()] });
13596
14526
  });
13597
14527
  app.post("/admin/super-admins", async (c) => {
13598
- if (!await serverAdminAllowed(c))
14528
+ if (!await serverAdminAllowed2(c))
13599
14529
  return c.json({ error: "Only the server admin can grant superadmin." }, 403);
13600
14530
  let body;
13601
14531
  try {
@@ -13640,7 +14570,7 @@ function createApp() {
13640
14570
  return c.json({ email, source: "granted", granted_by: grantedBy }, 201);
13641
14571
  });
13642
14572
  app.delete("/admin/super-admins/:email", async (c) => {
13643
- if (!await serverAdminAllowed(c))
14573
+ if (!await serverAdminAllowed2(c))
13644
14574
  return c.json({ error: "Only the server admin can revoke superadmin." }, 403);
13645
14575
  const email = c.req.param("email").trim().toLowerCase();
13646
14576
  const callerEmail = (await resolveCallerEmail(c.get("userId"))).toLowerCase();
@@ -13681,9 +14611,14 @@ function createApp() {
13681
14611
  await loadGrantedSuperAdmins();
13682
14612
  return c.json({ ok: true, email });
13683
14613
  });
14614
+ app.use("/admin/community-sites", flexAuthMiddleware);
14615
+ app.use("/admin/community-sites/*", flexAuthMiddleware);
14616
+ app.route("/admin/community-sites", communitySiteRoutes);
13684
14617
  app.use("/teams/*", flexAuthMiddleware);
13685
14618
  app.use("/teams", flexAuthMiddleware);
13686
14619
  app.route("/teams", teamRoutes);
14620
+ app.use("/people/*", flexAuthMiddleware);
14621
+ app.route("/people", peopleRoutes);
13687
14622
  app.use("/me/*", flexAuthMiddleware);
13688
14623
  app.get("/me/work", async (c) => {
13689
14624
  const userId = c.get("userId");
@@ -13827,7 +14762,9 @@ function createApp() {
13827
14762
  for (const nest of visibleNests) {
13828
14763
  try {
13829
14764
  const { storage } = await engineCache.get(nest.id);
13830
- const docs = await storage.discoverDocuments();
14765
+ const docs = (await storage.discoverDocuments()).filter(
14766
+ (d) => !isDerivedAnnotationsNode(d.id)
14767
+ );
13831
14768
  documents += (await filterAccessible(nest.id, userId, userEmail, docs)).length;
13832
14769
  } catch {
13833
14770
  }
@@ -13882,7 +14819,7 @@ function createApp() {
13882
14819
  app.route("/index", nestIndexRoutes);
13883
14820
  app.use("/mcp", flexAuthWithPromptowlTicket);
13884
14821
  app.route("/mcp", serverMcpRoutes);
13885
- const nestsApp = new Hono23();
14822
+ const nestsApp = new Hono25();
13886
14823
  nestsApp.use("*", async (c, next) => {
13887
14824
  const parts = nestPathParts(c.req.path);
13888
14825
  const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
@@ -13996,7 +14933,7 @@ function createApp() {
13996
14933
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
13997
14934
  const isDeletionRequestAction = /\/request-deletion$/.test(path) || /\/deletion-requests\/[^/]+\/withdraw$/.test(path);
13998
14935
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
13999
- if (isStewardRoster && permission !== "owner" && permission !== "admin") {
14936
+ if (isStewardRoster && access.rawPermission !== "owner" && access.rawPermission !== "admin") {
14000
14937
  return c.json(
14001
14938
  {
14002
14939
  error: "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this."
@@ -14004,6 +14941,13 @@ function createApp() {
14004
14941
  403
14005
14942
  );
14006
14943
  }
14944
+ if (isStewardRoster && access.archived && c.req.method !== "GET") {
14945
+ return deny(
14946
+ 403,
14947
+ "This nest is archived, so it is read-only. Restore it to change stewards.",
14948
+ { archived: true }
14949
+ );
14950
+ }
14007
14951
  const resource = parts[1];
14008
14952
  if (resource === "visibility" || resource === "reader-mode") {
14009
14953
  required = "admin";
@@ -14042,6 +14986,13 @@ function createApp() {
14042
14986
  const grantLevel = grantRole ? permissionLevel(grantRole) : grantNestVisible ? permissionLevel("read") : 0;
14043
14987
  const effectiveLevel = Math.max(permissionLevel(permission), grantLevel);
14044
14988
  if (!stewardEditorBypass && effectiveLevel < permissionLevel(required)) {
14989
+ if (access.archived && permissionLevel(access.rawPermission) >= permissionLevel(required)) {
14990
+ return deny(
14991
+ 403,
14992
+ "This nest is archived, so it is read-only. Restore it to make changes.",
14993
+ { archived: true }
14994
+ );
14995
+ }
14045
14996
  return deny(
14046
14997
  403,
14047
14998
  `You don't have access to perform this action on this nest. Required permission: '${required}', your permission: '${permission}'. Ask the nest owner or a server admin to grant you ${required} access.`,
@@ -14414,7 +15365,7 @@ async function repairGraftedVersionChains(db) {
14414
15365
  }
14415
15366
 
14416
15367
  // src/workflow/scheduler.ts
14417
- import { v4 as uuid18 } from "uuid";
15368
+ import { v4 as uuid19 } from "uuid";
14418
15369
  function isDue(s, now) {
14419
15370
  if (!s.last_run_at) return true;
14420
15371
  const last = Date.parse(s.last_run_at);
@@ -14442,7 +15393,7 @@ async function schedulerTick(now = Date.now()) {
14442
15393
  );
14443
15394
  continue;
14444
15395
  }
14445
- const id = `run_${uuid18()}`;
15396
+ const id = `run_${uuid19()}`;
14446
15397
  const nowIso2 = new Date(now).toISOString();
14447
15398
  await db.run(
14448
15399
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
@@ -14512,6 +15463,11 @@ async function main() {
14512
15463
  } catch (err) {
14513
15464
  console.error("[access] failed to load DB-granted superadmins:", err);
14514
15465
  }
15466
+ try {
15467
+ await loadActiveCommunitySites();
15468
+ } catch (err) {
15469
+ console.error("[sso] failed to load community sites:", err);
15470
+ }
14515
15471
  const license = await validateLicense();
14516
15472
  if (!license.valid) {
14517
15473
  console.warn(`