@promptowl/contextnest-community 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  reject,
17
17
  safePublishDocument,
18
18
  submitForReview
19
- } from "./chunk-MZGFKBOK.js";
19
+ } from "./chunk-MOXICJPD.js";
20
20
  import {
21
21
  checkConflict,
22
22
  createVersion,
@@ -25,7 +25,7 @@ import {
25
25
  getDisplayStatus,
26
26
  getVersions,
27
27
  setApprovedVersion
28
- } from "./chunk-T5L4LYU4.js";
28
+ } from "./chunk-VD5QX2ZQ.js";
29
29
  import {
30
30
  AppError,
31
31
  ConflictError,
@@ -35,10 +35,10 @@ import {
35
35
  ValidationError,
36
36
  canCreateInNest,
37
37
  canManageStewards,
38
- canManageWith,
39
38
  canUserAccess,
40
39
  canUserApprove,
41
40
  canUserEdit,
41
+ collabPermToRole,
42
42
  createNest,
43
43
  createStewardRecord,
44
44
  deleteNest,
@@ -84,14 +84,14 @@ import {
84
84
  updateSteward,
85
85
  upsertEnvVar,
86
86
  validateLicense
87
- } from "./chunk-2TUMMVBG.js";
87
+ } from "./chunk-43DOX4LH.js";
88
88
  import {
89
89
  config,
90
90
  getDb,
91
91
  initDb,
92
92
  insertOrIgnore,
93
93
  nowExpr
94
- } from "./chunk-7V33Z6CS.js";
94
+ } from "./chunk-QMLAXQES.js";
95
95
  import {
96
96
  ANON_EMAIL,
97
97
  ANON_USER_ID
@@ -487,7 +487,7 @@ authRoutes.post("/login", async (c) => {
487
487
  throw new ValidationError("email and password are required");
488
488
  }
489
489
  const ip = clientIp(c);
490
- const emailLower = body.email.toLowerCase();
490
+ const emailLower = normalizeEmail(body.email);
491
491
  const hasIp = ip !== "unknown";
492
492
  const ipKey = `login:ip:${ip}`;
493
493
  const emailKey = `login:email:${emailLower}`;
@@ -496,7 +496,7 @@ authRoutes.post("/login", async (c) => {
496
496
  }
497
497
  const db = getDb();
498
498
  const user = await db.get(
499
- "SELECT id, email, name, password_hash, is_admin FROM users WHERE LOWER(email) = ?",
499
+ "SELECT id, email, name, password_hash, is_admin, is_invited FROM users WHERE LOWER(email) = ?",
500
500
  [emailLower]
501
501
  );
502
502
  const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
@@ -509,6 +509,12 @@ authRoutes.post("/login", async (c) => {
509
509
  console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
510
510
  if (hasIp) clear(ipKey);
511
511
  clear(emailKey);
512
+ if (user.is_invited === 1) {
513
+ try {
514
+ await db.run("UPDATE users SET is_invited = 0 WHERE id = ?", [user.id]);
515
+ } catch {
516
+ }
517
+ }
512
518
  if (check.needsRehash) {
513
519
  try {
514
520
  const newHash = await hashPassword(body.password);
@@ -810,7 +816,7 @@ authRoutes.post("/password", authMiddleware, async (c) => {
810
816
  );
811
817
  }
812
818
  const newHash = await hashPassword(body.next);
813
- await db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
819
+ await db.run("UPDATE users SET password_hash = ?, is_invited = 0 WHERE id = ?", [
814
820
  newHash,
815
821
  userId
816
822
  ]);
@@ -853,14 +859,13 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
853
859
  newHash,
854
860
  target.id
855
861
  ]);
856
- await tx.run("DELETE FROM api_keys WHERE user_id = ?", [target.id]);
857
862
  });
858
863
  await deleteAllSessionsForUser(target.id);
859
864
  trackEvent("admin.reset_password", { adminId: callerId, userId: target.id });
860
865
  return c.json({
861
866
  ok: true,
862
867
  email: target.email,
863
- keys_revoked: true,
868
+ keys_revoked: false,
864
869
  // Plaintext returned ONCE, only when the server generated it.
865
870
  temporary_password: generated ?? void 0
866
871
  });
@@ -936,40 +941,40 @@ authRoutes.post("/invite", async (c) => {
936
941
  403
937
942
  );
938
943
  }
939
- let user = await db.get(
940
- "SELECT id, email FROM users WHERE LOWER(email) = ?",
944
+ const existing = await db.get(
945
+ "SELECT id, email, is_invited FROM users WHERE LOWER(email) = ?",
941
946
  [email]
942
947
  );
943
- if (!user) {
944
- const userId = uuid();
945
- const placeholderHash = await hashPassword(uuid());
948
+ if (existing && existing.is_invited === 0) {
949
+ return c.json(
950
+ {
951
+ error: "A user with this email already exists. Use Reset password to issue them a new password."
952
+ },
953
+ 409
954
+ );
955
+ }
956
+ const tempPassword = uuid().replace(/-/g, "").slice(0, 16);
957
+ const passwordHash = await hashPassword(tempPassword);
958
+ let userId;
959
+ if (existing) {
960
+ userId = existing.id;
961
+ await db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
962
+ passwordHash,
963
+ userId
964
+ ]);
965
+ } else {
966
+ userId = uuid();
946
967
  await db.run(
947
968
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
948
- [userId, email, null, placeholderHash]
969
+ [userId, email, null, passwordHash]
949
970
  );
950
- user = { id: userId, email };
951
971
  }
952
- const apiKey = generateApiKey();
953
- const keyId = uuid();
954
- await db.transaction(async (tx) => {
955
- await tx.run("DELETE FROM api_keys WHERE user_id = ?", [user.id]);
956
- await tx.run(
957
- "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)",
958
- [
959
- keyId,
960
- user.id,
961
- hashApiKey(apiKey),
962
- getKeyPrefix(apiKey),
963
- body.label || "teammate"
964
- ]
965
- );
966
- });
967
- trackEvent("admin.invite", { adminId: callerId, email: body.email });
972
+ trackEvent("admin.invite", { adminId: callerId, email });
968
973
  return c.json(
969
974
  {
970
- api_key: apiKey,
971
- user: { id: user.id, email: user.email },
972
- message: "Copy this key and share it securely \u2014 it won't be shown again."
975
+ temporary_password: tempPassword,
976
+ user: { id: userId, email },
977
+ message: "Share this temporary password securely. Your teammate signs in with their email + this password, then can change it and create their own API key."
973
978
  },
974
979
  201
975
980
  );
@@ -1284,7 +1289,7 @@ async function approveExternalEdit(input) {
1284
1289
  const node = await storage.readDocument(input.documentId);
1285
1290
  const versionNum = result.versionEntry.version;
1286
1291
  const tags = node.frontmatter.tags || [];
1287
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-MUZYTYMW.js");
1292
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-FJWVTZKR.js");
1288
1293
  await createVersion2({
1289
1294
  nestId: input.nestId,
1290
1295
  nodeId: input.documentId,
@@ -1444,8 +1449,19 @@ async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
1444
1449
  }
1445
1450
  async function createNode(nestId, input, userEmail) {
1446
1451
  const { storage, versions: versionManager } = await engineCache.get(nestId);
1447
- const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1448
- const id = input.id ?? `nodes/${slug}`;
1452
+ const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1453
+ const slug = slugify(input.title);
1454
+ let id = input.id;
1455
+ if (!id) {
1456
+ const folderSegments = (input.folder ?? "").split("/").map(slugify).filter(Boolean);
1457
+ if (folderSegments.length > 8) {
1458
+ throw new ValidationError("folder may nest at most 8 levels deep");
1459
+ }
1460
+ if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
1461
+ throw new ValidationError("each folder or title segment must be at most 100 characters");
1462
+ }
1463
+ id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
1464
+ }
1449
1465
  const now = (/* @__PURE__ */ new Date()).toISOString();
1450
1466
  const tags = (input.tags || []).map(normalizeTag2);
1451
1467
  const hasStewards = await isStewardshipEnabled(nestId);
@@ -1881,7 +1897,18 @@ nestRoutes.get("/", async (c) => {
1881
1897
  const permission = await effectivePermission(n.id, userId);
1882
1898
  const is_owner = permission === "owner";
1883
1899
  let owner_email = null;
1884
- const roles = is_owner ? ["owner"] : await resolveUserRoles(n.id, callerEmail);
1900
+ let roles;
1901
+ if (is_owner) {
1902
+ roles = ["owner"];
1903
+ } else {
1904
+ const grantRole = collabPermToRole(
1905
+ await getCollaboratorRole(n.id, callerEmail)
1906
+ );
1907
+ const stewardRoles = await getStewardRolesForUser(n.id, callerEmail);
1908
+ roles = [
1909
+ ...new Set([grantRole, ...stewardRoles].filter(Boolean))
1910
+ ];
1911
+ }
1885
1912
  if (!is_owner && n.user_id !== ANON_USER_ID) {
1886
1913
  const row = await db.get(ownerEmailSql, [n.user_id]);
1887
1914
  owner_email = row?.email ?? null;
@@ -1895,7 +1922,25 @@ nestRoutes.get("/", async (c) => {
1895
1922
  seen.add(n.id);
1896
1923
  out.push(await annotate(n));
1897
1924
  }
1898
- return c.json({ nests: out });
1925
+ const docRows = await db.all(
1926
+ "SELECT nest_id, COUNT(DISTINCT node_id) AS c FROM node_versions GROUP BY nest_id"
1927
+ );
1928
+ const docByNest = new Map(docRows.map((r) => [r.nest_id, r.c]));
1929
+ const collabRows = await db.all(
1930
+ "SELECT nest_id, COUNT(*) AS c FROM nest_collaborators GROUP BY nest_id"
1931
+ );
1932
+ const collabByNest = new Map(collabRows.map((r) => [r.nest_id, r.c]));
1933
+ const stewardRows = await db.all(
1934
+ "SELECT nest_id, COUNT(DISTINCT user_email) AS c FROM stewards WHERE is_active = 1 GROUP BY nest_id"
1935
+ );
1936
+ const stewardByNest = new Map(stewardRows.map((r) => [r.nest_id, r.c]));
1937
+ const withMeta = out.map((n) => ({
1938
+ ...n,
1939
+ document_count: docByNest.get(n.id) ?? 0,
1940
+ collaborator_count: collabByNest.get(n.id) ?? 0,
1941
+ steward_count: stewardByNest.get(n.id) ?? 0
1942
+ }));
1943
+ return c.json({ nests: withMeta });
1899
1944
  });
1900
1945
  nestRoutes.post("/", async (c) => {
1901
1946
  const body = await c.req.json();
@@ -2011,10 +2056,10 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
2011
2056
  const userId = c.get("userId");
2012
2057
  const isServerAdmin = await isLicenseAdminUserId(userId);
2013
2058
  const permission = await effectivePermission(nestId, userId);
2014
- if (!isServerAdmin && permission !== "owner") {
2059
+ if (!isServerAdmin && permission !== "owner" && permission !== "admin") {
2015
2060
  return c.json(
2016
2061
  {
2017
- error: "Only the nest owner or the server license-admin can update nest settings."
2062
+ error: "Only a nest admin, the nest owner, or the server license-admin can update nest settings."
2018
2063
  },
2019
2064
  403
2020
2065
  );
@@ -2025,6 +2070,14 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
2025
2070
  if (body.stewardship_enabled) {
2026
2071
  await setStewardshipEnabled(nestId, true);
2027
2072
  } else {
2073
+ if (!isServerAdmin && permission !== "owner") {
2074
+ return c.json(
2075
+ {
2076
+ error: "Only the nest owner or the server license-admin can disable stewardship (this permanently wipes stewards and pending reviews)."
2077
+ },
2078
+ 403
2079
+ );
2080
+ }
2028
2081
  wiped = await disableStewardshipAndWipeGovernance(nestId);
2029
2082
  }
2030
2083
  }
@@ -2220,416 +2273,113 @@ function isMarkdownFormat(c) {
2220
2273
  return c.req.query("format") === "markdown";
2221
2274
  }
2222
2275
 
2223
- // src/nodes/routes.ts
2224
- var nodeRoutes = new Hono4();
2225
- function nodeAsMarkdown(response, nodeId) {
2226
- return nodeToMarkdown({
2227
- id: nodeId,
2228
- title: response.title,
2229
- tags: response.tags,
2230
- status: response.status,
2231
- body: response.content
2232
- });
2276
+ // src/annotations/service.ts
2277
+ import { v4 as uuid3 } from "uuid";
2278
+
2279
+ // src/annotations/projection.ts
2280
+ var MAX_CONTEXT_CHARS = 250;
2281
+ var MAX_QUOTE_CHARS = 1e3;
2282
+ function clampAnchor(raw) {
2283
+ if (!raw) return null;
2284
+ const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
2285
+ if (!quote) return null;
2286
+ const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
2287
+ const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
2288
+ const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
2289
+ return { quote, before, after, ...line !== void 0 ? { line } : {} };
2233
2290
  }
2234
- nodeRoutes.get("/", async (c) => {
2235
- const nestId = c.req.param("nestId");
2236
- const userId = c.get("userId");
2237
- const nodes = await listNodesForCaller(nestId, userId);
2238
- return c.json({ count: nodes.length, nodes });
2239
- });
2240
- nodeRoutes.post("/", async (c) => {
2241
- const body = await c.req.json();
2242
- if (!body.title || !body.content) {
2243
- throw new ValidationError("title and content are required");
2291
+ function oneLine(s) {
2292
+ return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
2293
+ }
2294
+ function renderAnchor(anchor) {
2295
+ if (!anchor) {
2296
+ return "<!-- anchor: whole-artifact -->";
2244
2297
  }
2245
- const nestId = c.req.param("nestId");
2246
- const authorEmail = await getUserEmail(c);
2247
- const { node } = await createNode(
2248
- nestId,
2249
- {
2250
- title: body.title,
2251
- content: body.content,
2252
- type: body.type,
2253
- tags: body.tags,
2254
- scope: body.scope,
2255
- status: body.status
2256
- },
2257
- authorEmail
2258
- );
2259
- const resolved = await resolveStewardsForNode(nestId, node.id);
2260
- return c.json({
2261
- node: toNodeResponse(node),
2262
- stewards: resolved.length > 0 ? resolved.map((r) => ({
2263
- email: r.steward.userEmail,
2264
- role: r.steward.role,
2265
- source: r.source
2266
- })) : void 0
2267
- }, 201);
2268
- });
2269
- nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
2270
- const nestId = c.req.param("nestId");
2271
- const nodeId = c.req.param("nodeId");
2272
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-VIKF3VZB.js");
2273
- const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
2274
- nestId,
2275
- nodeId
2298
+ const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
2299
+ const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
2300
+ anchor.after
2301
+ )}\u2026`;
2302
+ return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
2303
+ }
2304
+ function statusHeading(thread) {
2305
+ if (thread.status === "resolved") {
2306
+ const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
2307
+ const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
2308
+ const meta = who || when ? ` (${who}${when})` : "";
2309
+ return `\u2705 RESOLVED${meta}`;
2310
+ }
2311
+ return "\u{1F7E0} OPEN";
2312
+ }
2313
+ function snapshotLabel(v) {
2314
+ return v == null ? "Unpinned" : `Snapshot v${v}`;
2315
+ }
2316
+ function orderThreads(threads) {
2317
+ return [...threads].sort((a, b) => {
2318
+ if (a.status !== b.status) return a.status === "open" ? -1 : 1;
2319
+ return a.createdAt.localeCompare(b.createdAt);
2320
+ });
2321
+ }
2322
+ function projectThreadsToMarkdown(artifactTitle, threads) {
2323
+ const lines = [];
2324
+ lines.push(`# ${artifactTitle} \u2014 Annotations`);
2325
+ lines.push("");
2326
+ lines.push(
2327
+ "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
2276
2328
  );
2277
- return c.json({
2278
- nodeId,
2279
- stewards: stewards.map((r) => ({
2280
- email: r.steward.userEmail,
2281
- role: r.steward.role,
2282
- scope: r.steward.scope,
2283
- source: r.source,
2284
- priority: r.priority
2285
- })),
2286
- fallbackToOwner,
2287
- ownerEmail
2329
+ lines.push("");
2330
+ if (threads.length === 0) {
2331
+ lines.push("_No annotations yet._");
2332
+ lines.push("");
2333
+ return lines.join("\n");
2334
+ }
2335
+ const buckets2 = /* @__PURE__ */ new Map();
2336
+ for (const t of threads) {
2337
+ const key = t.snapshotVersion ?? null;
2338
+ (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
2339
+ }
2340
+ const keys = [...buckets2.keys()].sort((a, b) => {
2341
+ if (a == null) return 1;
2342
+ if (b == null) return -1;
2343
+ return b - a;
2288
2344
  });
2289
- });
2290
- nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
2291
- const nestId = c.req.param("nestId");
2292
- const nodeId = c.req.param("nodeId");
2293
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-MUZYTYMW.js");
2294
- const allVersions = await getVersions2(nestId, nodeId);
2295
- const approved = await getApprovedVersion2(nestId, nodeId);
2345
+ for (const key of keys) {
2346
+ lines.push(`## ${snapshotLabel(key)}`);
2347
+ lines.push("");
2348
+ for (const thread of orderThreads(buckets2.get(key))) {
2349
+ lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
2350
+ lines.push(renderAnchor(thread.anchor));
2351
+ for (const comment of thread.comments) {
2352
+ lines.push(
2353
+ `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
2354
+ );
2355
+ }
2356
+ lines.push("");
2357
+ }
2358
+ }
2359
+ return lines.join("\n");
2360
+ }
2361
+
2362
+ // src/annotations/service.ts
2363
+ async function loadComments(threadId) {
2296
2364
  const db = getDb();
2297
- const resolutions = await db.all(
2298
- `SELECT version, status, resolved_by, resolved_at
2299
- FROM review_requests
2300
- WHERE nest_id = ? AND node_id = ?
2301
- AND status IN ('approved', 'rejected')
2302
- AND resolved_by IS NOT NULL
2303
- ORDER BY resolved_at DESC`,
2304
- [nestId, nodeId]
2365
+ const rows = await db.all(
2366
+ "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
2367
+ [threadId]
2305
2368
  );
2306
- const byVersion = /* @__PURE__ */ new Map();
2307
- for (const r of resolutions) {
2308
- if (!byVersion.has(r.version)) {
2309
- byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
2310
- }
2311
- }
2312
- const enriched = allVersions.map((v) => {
2313
- const r = byVersion.get(v.version);
2314
- return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
2315
- });
2316
- return c.json({
2317
- versions: enriched,
2318
- approvedVersion: approved,
2319
- currentVersion: allVersions[0]?.version || 0
2320
- });
2321
- });
2322
- nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
2323
- const nestId = c.req.param("nestId");
2324
- const nodeId = c.req.param("nodeId");
2325
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-QJ3FBOML.js");
2326
- const history = await getReviewHistory2(nestId, nodeId);
2327
- return c.json({ reviews: history });
2328
- });
2329
- nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
2330
- const nestId = c.req.param("nestId");
2331
- const nodeId = c.req.param("nodeId");
2332
- const { versions: versionManager } = await engineCache.get(nestId);
2333
- const userId = c.get("userId");
2334
- const userEmail = await resolveCallerEmail(userId);
2335
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
2336
- return c.json(
2337
- { error: "Access denied \u2014 no steward assignment for this node" },
2338
- 403
2339
- );
2340
- }
2341
- const body = await c.req.json().catch(() => ({}));
2342
- const targetVersion = Number(body.targetVersion);
2343
- if (!Number.isInteger(targetVersion) || targetVersion < 1) {
2344
- throw new ValidationError("targetVersion (a positive integer) is required");
2345
- }
2346
- let raw;
2347
- try {
2348
- raw = await versionManager.reconstructVersion(nodeId, targetVersion);
2349
- } catch {
2350
- throw new NotFoundError(
2351
- `Version ${targetVersion} not found for ${nodeId}`
2352
- );
2353
- }
2354
- const content = bodyOnly(nodeId, raw);
2355
- const { node, version } = await updateNode(
2356
- nestId,
2357
- nodeId,
2358
- { content, changeNote: `Restored from version ${targetVersion}` },
2359
- userEmail
2360
- );
2361
- await trackEvent("node.revert", { nestId, nodeId, targetVersion });
2362
- return c.json({ ok: true, version, node: toNodeResponse(node) });
2363
- });
2364
- nodeRoutes.get("/:nodeId{.+}", async (c) => {
2365
- const nestId = c.req.param("nestId");
2366
- const nodeId = c.req.param("nodeId");
2367
- const { storage, versions: versionManager } = await engineCache.get(nestId);
2368
- const userId = c.get("userId");
2369
- const userEmail = await resolveCallerEmail(userId);
2370
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
2371
- return c.json(
2372
- { error: "Access denied \u2014 no steward assignment for this node" },
2373
- 403
2374
- );
2375
- }
2376
- let node;
2377
- try {
2378
- node = await storage.readDocument(nodeId, { verifyChecksum: true });
2379
- } catch {
2380
- throw new NotFoundError(`Node not found: ${nodeId}`);
2381
- }
2382
- if (node.pendingChange) {
2383
- try {
2384
- await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
2385
- const refreshed = await getPendingChange(nestId, nodeId);
2386
- if (refreshed) node.pendingChange = refreshed;
2387
- } catch (err) {
2388
- console.error("[external-edit] stage-on-read failed:", err);
2389
- }
2390
- try {
2391
- const latest = await loadLatestApprovedNode(nestId, nodeId);
2392
- if (latest) {
2393
- node = { ...latest, pendingChange: node.pendingChange };
2394
- }
2395
- } catch (err) {
2396
- console.error("[external-edit] reconstruct-latest failed:", err);
2397
- }
2398
- }
2399
- const response = toNodeResponse(node);
2400
- if (await isPublicReader(nestId, userId)) {
2401
- const approved = await getApprovedVersion(nestId, nodeId);
2402
- if (approved != null) {
2403
- try {
2404
- const raw = await versionManager.reconstructVersion(
2405
- nodeId,
2406
- approved
2407
- );
2408
- response.content = bodyOnly(nodeId, raw);
2409
- } catch (err) {
2410
- console.error(
2411
- "reconstructVersion failed (public single)",
2412
- nodeId,
2413
- approved,
2414
- err
2415
- );
2416
- response.content = "";
2417
- }
2418
- response.version = approved;
2419
- response.status = "published";
2420
- if (isMarkdownFormat(c)) {
2421
- return c.body(nodeAsMarkdown(response, nodeId), 200, {
2422
- "Content-Type": "text/markdown; charset=utf-8"
2423
- });
2424
- }
2425
- return c.json({ node: response });
2426
- }
2427
- }
2428
- response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
2429
- if (response.status === "pending_review") {
2430
- const pending = await getPendingReview(nestId, nodeId);
2431
- response.pendingReviewBy = pending?.requestedBy ?? null;
2432
- }
2433
- if (isMarkdownFormat(c)) {
2434
- return c.body(nodeAsMarkdown(response, nodeId), 200, {
2435
- "Content-Type": "text/markdown; charset=utf-8"
2436
- });
2437
- }
2438
- return c.json({ node: response });
2439
- });
2440
- nodeRoutes.patch("/:nodeId{.+}", async (c) => {
2441
- const nestId = c.req.param("nestId");
2442
- const nodeId = c.req.param("nodeId");
2443
- const body = await c.req.json();
2444
- const baseVersionHeader = c.req.header("X-Base-Version");
2445
- if (baseVersionHeader) {
2446
- const baseVersion = parseInt(baseVersionHeader, 10);
2447
- const conflict = await checkConflict(nestId, nodeId, baseVersion);
2448
- if (conflict.conflict) {
2449
- return c.json(
2450
- {
2451
- error: "Version conflict",
2452
- your_version: baseVersion,
2453
- current_version: conflict.currentVersion,
2454
- updated_by: conflict.updatedBy,
2455
- updated_at: conflict.updatedAt,
2456
- rejected_content: body.content || body.append || null
2457
- },
2458
- 409
2459
- );
2460
- }
2461
- }
2462
- const authorEmail = await getUserEmail(c);
2463
- const { node, version: responseVersion } = await updateNode(
2464
- nestId,
2465
- nodeId,
2466
- {
2467
- content: body.content,
2468
- append: body.append,
2469
- tags: body.tags,
2470
- title: body.title,
2471
- status: body.status,
2472
- changeNote: body.changeNote
2473
- },
2474
- authorEmail
2475
- );
2476
- return c.json({ node: toNodeResponse(node), version: responseVersion });
2477
- });
2478
- nodeRoutes.delete("/:nodeId{.+}", async (c) => {
2479
- const nestId = c.req.param("nestId");
2480
- const nodeId = c.req.param("nodeId");
2481
- const { storage } = await engineCache.get(nestId);
2482
- if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
2483
- throw new LockedError(
2484
- "This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
2485
- );
2486
- }
2487
- try {
2488
- await storage.deleteDocument(nodeId);
2489
- } catch {
2490
- throw new NotFoundError(`Node not found: ${nodeId}`);
2491
- }
2492
- await removeNodeFromTagIndex(nestId, nodeId);
2493
- const db = getDb();
2494
- await db.transaction(async (tx) => {
2495
- await tx.run(
2496
- "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
2497
- [nestId, nodeId]
2498
- );
2499
- await tx.run(
2500
- "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
2501
- [nestId, nodeId]
2502
- );
2503
- await tx.run(
2504
- "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
2505
- [nestId, nodeId]
2506
- );
2507
- await tx.run(
2508
- `DELETE FROM stewards
2509
- WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2510
- [nestId, nodeId]
2511
- );
2512
- });
2513
- await trackEvent("node.delete", { nestId, nodeId });
2514
- return c.json({ deleted: true });
2515
- });
2516
- async function getUserEmail(c) {
2517
- const userId = c.get("userId");
2518
- const db = getDb();
2519
- const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
2520
- return user?.email || "anonymous@localhost";
2521
- }
2522
-
2523
- // src/annotations/routes.ts
2524
- import { Hono as Hono5 } from "hono";
2525
-
2526
- // src/annotations/service.ts
2527
- import { v4 as uuid3 } from "uuid";
2528
-
2529
- // src/annotations/projection.ts
2530
- var MAX_CONTEXT_CHARS = 250;
2531
- var MAX_QUOTE_CHARS = 1e3;
2532
- function clampAnchor(raw) {
2533
- if (!raw) return null;
2534
- const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
2535
- if (!quote) return null;
2536
- const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
2537
- const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
2538
- const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
2539
- return { quote, before, after, ...line !== void 0 ? { line } : {} };
2540
- }
2541
- function oneLine(s) {
2542
- return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
2543
- }
2544
- function renderAnchor(anchor) {
2545
- if (!anchor) {
2546
- return "<!-- anchor: whole-artifact -->";
2547
- }
2548
- const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
2549
- const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
2550
- anchor.after
2551
- )}\u2026`;
2552
- return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
2553
- }
2554
- function statusHeading(thread) {
2555
- if (thread.status === "resolved") {
2556
- const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
2557
- const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
2558
- const meta = who || when ? ` (${who}${when})` : "";
2559
- return `\u2705 RESOLVED${meta}`;
2560
- }
2561
- return "\u{1F7E0} OPEN";
2562
- }
2563
- function snapshotLabel(v) {
2564
- return v == null ? "Unpinned" : `Snapshot v${v}`;
2565
- }
2566
- function orderThreads(threads) {
2567
- return [...threads].sort((a, b) => {
2568
- if (a.status !== b.status) return a.status === "open" ? -1 : 1;
2569
- return a.createdAt.localeCompare(b.createdAt);
2570
- });
2571
- }
2572
- function projectThreadsToMarkdown(artifactTitle, threads) {
2573
- const lines = [];
2574
- lines.push(`# ${artifactTitle} \u2014 Annotations`);
2575
- lines.push("");
2576
- lines.push(
2577
- "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
2578
- );
2579
- lines.push("");
2580
- if (threads.length === 0) {
2581
- lines.push("_No annotations yet._");
2582
- lines.push("");
2583
- return lines.join("\n");
2584
- }
2585
- const buckets2 = /* @__PURE__ */ new Map();
2586
- for (const t of threads) {
2587
- const key = t.snapshotVersion ?? null;
2588
- (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
2589
- }
2590
- const keys = [...buckets2.keys()].sort((a, b) => {
2591
- if (a == null) return 1;
2592
- if (b == null) return -1;
2593
- return b - a;
2594
- });
2595
- for (const key of keys) {
2596
- lines.push(`## ${snapshotLabel(key)}`);
2597
- lines.push("");
2598
- for (const thread of orderThreads(buckets2.get(key))) {
2599
- lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
2600
- lines.push(renderAnchor(thread.anchor));
2601
- for (const comment of thread.comments) {
2602
- lines.push(
2603
- `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
2604
- );
2605
- }
2606
- lines.push("");
2607
- }
2608
- }
2609
- return lines.join("\n");
2610
- }
2611
-
2612
- // src/annotations/service.ts
2613
- async function loadComments(threadId) {
2614
- const db = getDb();
2615
- const rows = await db.all(
2616
- "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, rowid ASC",
2617
- [threadId]
2618
- );
2619
- return rows.map((r) => ({
2620
- id: r.id,
2621
- author: r.author,
2622
- body: r.body,
2623
- createdAt: r.created_at
2624
- }));
2625
- }
2626
- async function rowToThread(row) {
2627
- let anchor = null;
2628
- if (row.anchor_json) {
2629
- try {
2630
- anchor = JSON.parse(row.anchor_json);
2631
- } catch {
2632
- anchor = null;
2369
+ return rows.map((r) => ({
2370
+ id: r.id,
2371
+ author: r.author,
2372
+ body: r.body,
2373
+ createdAt: r.created_at
2374
+ }));
2375
+ }
2376
+ async function rowToThread(row) {
2377
+ let anchor = null;
2378
+ if (row.anchor_json) {
2379
+ try {
2380
+ anchor = JSON.parse(row.anchor_json);
2381
+ } catch {
2382
+ anchor = null;
2633
2383
  }
2634
2384
  }
2635
2385
  return {
@@ -2649,9 +2399,21 @@ async function rowToThread(row) {
2649
2399
  async function getThreadRow(threadId) {
2650
2400
  return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
2651
2401
  }
2402
+ async function countThreadsByNode(nestId) {
2403
+ const rows = await getDb().all(
2404
+ `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
2405
+ FROM annotation_threads
2406
+ WHERE nest_id = ?
2407
+ GROUP BY node_id`,
2408
+ [nestId]
2409
+ );
2410
+ const out = {};
2411
+ for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
2412
+ return out;
2413
+ }
2652
2414
  async function listThreads(nestId, nodeId) {
2653
2415
  const rows = await getDb().all(
2654
- "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, rowid ASC",
2416
+ "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
2655
2417
  [nestId, nodeId]
2656
2418
  );
2657
2419
  return Promise.all(rows.map(rowToThread));
@@ -2758,44 +2520,380 @@ function derivedAnnotationsId(sourceNodeId) {
2758
2520
  }
2759
2521
  async function syncAnnotationsNode(nestId, nodeId, userEmail) {
2760
2522
  try {
2761
- let title = nodeId;
2523
+ let title = nodeId;
2524
+ let type = null;
2525
+ try {
2526
+ const { storage } = await engineCache.get(nestId);
2527
+ const node = await storage.readDocument(nodeId);
2528
+ title = node.frontmatter?.title || nodeId;
2529
+ type = node.frontmatter?.type || "document";
2530
+ } catch {
2531
+ }
2532
+ if (type !== null && type !== "artifact") return;
2533
+ const threads = await listThreads(nestId, nodeId);
2534
+ const derivedTitle = `${title} \u2014 Annotations`;
2535
+ const markdown = projectThreadsToMarkdown(title, threads);
2536
+ const derivedId = derivedAnnotationsId(nodeId);
2537
+ try {
2538
+ await updateNode(nestId, derivedId, { content: markdown }, userEmail);
2539
+ } catch (err) {
2540
+ if (err instanceof NotFoundError) {
2541
+ await createNode(
2542
+ nestId,
2543
+ {
2544
+ id: derivedId,
2545
+ title: derivedTitle,
2546
+ content: markdown,
2547
+ type: "document",
2548
+ tags: ["annotations"]
2549
+ },
2550
+ userEmail
2551
+ );
2552
+ } else {
2553
+ throw err;
2554
+ }
2555
+ }
2556
+ } catch (err) {
2557
+ console.warn(
2558
+ `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
2559
+ err
2560
+ );
2561
+ }
2562
+ }
2563
+
2564
+ // src/nodes/routes.ts
2565
+ var nodeRoutes = new Hono4();
2566
+ function nodeAsMarkdown(response, nodeId) {
2567
+ return nodeToMarkdown({
2568
+ id: nodeId,
2569
+ title: response.title,
2570
+ tags: response.tags,
2571
+ status: response.status,
2572
+ body: response.content
2573
+ });
2574
+ }
2575
+ nodeRoutes.get("/", async (c) => {
2576
+ const nestId = c.req.param("nestId");
2577
+ const userId = c.get("userId");
2578
+ const nodes = await listNodesForCaller(nestId, userId);
2579
+ return c.json({ count: nodes.length, nodes });
2580
+ });
2581
+ nodeRoutes.post("/", async (c) => {
2582
+ const body = await c.req.json();
2583
+ if (!body.title || !body.content) {
2584
+ throw new ValidationError("title and content are required");
2585
+ }
2586
+ if (body.folder !== void 0 && typeof body.folder !== "string") {
2587
+ throw new ValidationError('folder must be a string path like "gtm/deals"');
2588
+ }
2589
+ const nestId = c.req.param("nestId");
2590
+ const authorEmail = await getUserEmail(c);
2591
+ const { node } = await createNode(
2592
+ nestId,
2593
+ {
2594
+ title: body.title,
2595
+ content: body.content,
2596
+ type: body.type,
2597
+ tags: body.tags,
2598
+ scope: body.scope,
2599
+ status: body.status,
2600
+ folder: body.folder
2601
+ },
2602
+ authorEmail
2603
+ );
2604
+ const resolved = await resolveStewardsForNode(nestId, node.id);
2605
+ return c.json({
2606
+ node: toNodeResponse(node),
2607
+ stewards: resolved.length > 0 ? resolved.map((r) => ({
2608
+ email: r.steward.userEmail,
2609
+ role: r.steward.role,
2610
+ source: r.source
2611
+ })) : void 0
2612
+ }, 201);
2613
+ });
2614
+ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
2615
+ const nestId = c.req.param("nestId");
2616
+ const nodeId = c.req.param("nodeId");
2617
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-I2JAFYJU.js");
2618
+ const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
2619
+ nestId,
2620
+ nodeId
2621
+ );
2622
+ return c.json({
2623
+ nodeId,
2624
+ stewards: stewards.map((r) => ({
2625
+ email: r.steward.userEmail,
2626
+ role: r.steward.role,
2627
+ scope: r.steward.scope,
2628
+ source: r.source,
2629
+ priority: r.priority
2630
+ })),
2631
+ fallbackToOwner,
2632
+ ownerEmail
2633
+ });
2634
+ });
2635
+ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
2636
+ const nestId = c.req.param("nestId");
2637
+ const nodeId = c.req.param("nodeId");
2638
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-FJWVTZKR.js");
2639
+ const allVersions = await getVersions2(nestId, nodeId);
2640
+ const approved = await getApprovedVersion2(nestId, nodeId);
2641
+ const db = getDb();
2642
+ const resolutions = await db.all(
2643
+ `SELECT version, status, resolved_by, resolved_at
2644
+ FROM review_requests
2645
+ WHERE nest_id = ? AND node_id = ?
2646
+ AND status IN ('approved', 'rejected')
2647
+ AND resolved_by IS NOT NULL
2648
+ ORDER BY resolved_at DESC`,
2649
+ [nestId, nodeId]
2650
+ );
2651
+ const byVersion = /* @__PURE__ */ new Map();
2652
+ for (const r of resolutions) {
2653
+ if (!byVersion.has(r.version)) {
2654
+ byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
2655
+ }
2656
+ }
2657
+ const enriched = allVersions.map((v) => {
2658
+ const r = byVersion.get(v.version);
2659
+ return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
2660
+ });
2661
+ return c.json({
2662
+ versions: enriched,
2663
+ approvedVersion: approved,
2664
+ currentVersion: allVersions[0]?.version || 0
2665
+ });
2666
+ });
2667
+ nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
2668
+ const nestId = c.req.param("nestId");
2669
+ const nodeId = c.req.param("nodeId");
2670
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-2FSKW425.js");
2671
+ const history = await getReviewHistory2(nestId, nodeId);
2672
+ return c.json({ reviews: history });
2673
+ });
2674
+ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
2675
+ const nestId = c.req.param("nestId");
2676
+ const nodeId = c.req.param("nodeId");
2677
+ const { versions: versionManager } = await engineCache.get(nestId);
2678
+ const userId = c.get("userId");
2679
+ const userEmail = await resolveCallerEmail(userId);
2680
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
2681
+ return c.json(
2682
+ { error: "Access denied \u2014 no steward assignment for this node" },
2683
+ 403
2684
+ );
2685
+ }
2686
+ const body = await c.req.json().catch(() => ({}));
2687
+ const targetVersion = Number(body.targetVersion);
2688
+ if (!Number.isInteger(targetVersion) || targetVersion < 1) {
2689
+ throw new ValidationError("targetVersion (a positive integer) is required");
2690
+ }
2691
+ let raw;
2692
+ try {
2693
+ raw = await versionManager.reconstructVersion(nodeId, targetVersion);
2694
+ } catch {
2695
+ throw new NotFoundError(
2696
+ `Version ${targetVersion} not found for ${nodeId}`
2697
+ );
2698
+ }
2699
+ const content = bodyOnly(nodeId, raw);
2700
+ const { node, version } = await updateNode(
2701
+ nestId,
2702
+ nodeId,
2703
+ { content, changeNote: `Restored from version ${targetVersion}` },
2704
+ userEmail
2705
+ );
2706
+ await trackEvent("node.revert", { nestId, nodeId, targetVersion });
2707
+ return c.json({ ok: true, version, node: toNodeResponse(node) });
2708
+ });
2709
+ nodeRoutes.get("/:nodeId{.+}", async (c) => {
2710
+ const nestId = c.req.param("nestId");
2711
+ const nodeId = c.req.param("nodeId");
2712
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
2713
+ const userId = c.get("userId");
2714
+ const userEmail = await resolveCallerEmail(userId);
2715
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
2716
+ return c.json(
2717
+ { error: "Access denied \u2014 no steward assignment for this node" },
2718
+ 403
2719
+ );
2720
+ }
2721
+ let node;
2722
+ try {
2723
+ node = await storage.readDocument(nodeId, { verifyChecksum: true });
2724
+ } catch {
2725
+ throw new NotFoundError(`Node not found: ${nodeId}`);
2726
+ }
2727
+ if (node.pendingChange) {
2762
2728
  try {
2763
- const { storage } = await engineCache.get(nestId);
2764
- const node = await storage.readDocument(nodeId);
2765
- title = node.frontmatter?.title || nodeId;
2766
- } catch {
2729
+ await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
2730
+ const refreshed = await getPendingChange(nestId, nodeId);
2731
+ if (refreshed) node.pendingChange = refreshed;
2732
+ } catch (err) {
2733
+ console.error("[external-edit] stage-on-read failed:", err);
2767
2734
  }
2768
- const threads = await listThreads(nestId, nodeId);
2769
- const derivedTitle = `${title} \u2014 Annotations`;
2770
- const markdown = projectThreadsToMarkdown(title, threads);
2771
- const derivedId = derivedAnnotationsId(nodeId);
2772
2735
  try {
2773
- await updateNode(nestId, derivedId, { content: markdown }, userEmail);
2736
+ const latest = await loadLatestApprovedNode(nestId, nodeId);
2737
+ if (latest) {
2738
+ node = { ...latest, pendingChange: node.pendingChange };
2739
+ }
2774
2740
  } catch (err) {
2775
- if (err instanceof NotFoundError) {
2776
- await createNode(
2777
- nestId,
2778
- {
2779
- id: derivedId,
2780
- title: derivedTitle,
2781
- content: markdown,
2782
- type: "document",
2783
- tags: ["annotations"]
2784
- },
2785
- userEmail
2741
+ console.error("[external-edit] reconstruct-latest failed:", err);
2742
+ }
2743
+ }
2744
+ const response = toNodeResponse(node);
2745
+ if (await isPublicReader(nestId, userId)) {
2746
+ const approved = await getApprovedVersion(nestId, nodeId);
2747
+ if (approved != null) {
2748
+ try {
2749
+ const raw = await versionManager.reconstructVersion(
2750
+ nodeId,
2751
+ approved
2786
2752
  );
2787
- } else {
2788
- throw err;
2753
+ response.content = bodyOnly(nodeId, raw);
2754
+ } catch (err) {
2755
+ console.error(
2756
+ "reconstructVersion failed (public single)",
2757
+ nodeId,
2758
+ approved,
2759
+ err
2760
+ );
2761
+ response.content = "";
2762
+ }
2763
+ response.version = approved;
2764
+ response.status = "published";
2765
+ if (isMarkdownFormat(c)) {
2766
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
2767
+ "Content-Type": "text/markdown; charset=utf-8"
2768
+ });
2789
2769
  }
2770
+ return c.json({ node: response });
2790
2771
  }
2791
- } catch (err) {
2792
- console.warn(
2793
- `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
2794
- err
2772
+ }
2773
+ response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
2774
+ if (response.status === "pending_review") {
2775
+ const pending = await getPendingReview(nestId, nodeId);
2776
+ response.pendingReviewBy = pending?.requestedBy ?? null;
2777
+ }
2778
+ if (isMarkdownFormat(c)) {
2779
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
2780
+ "Content-Type": "text/markdown; charset=utf-8"
2781
+ });
2782
+ }
2783
+ return c.json({ node: response });
2784
+ });
2785
+ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
2786
+ const nestId = c.req.param("nestId");
2787
+ const nodeId = c.req.param("nodeId");
2788
+ const body = await c.req.json();
2789
+ const baseVersionHeader = c.req.header("X-Base-Version");
2790
+ if (baseVersionHeader) {
2791
+ const baseVersion = parseInt(baseVersionHeader, 10);
2792
+ const conflict = await checkConflict(nestId, nodeId, baseVersion);
2793
+ if (conflict.conflict) {
2794
+ return c.json(
2795
+ {
2796
+ error: "Version conflict",
2797
+ your_version: baseVersion,
2798
+ current_version: conflict.currentVersion,
2799
+ updated_by: conflict.updatedBy,
2800
+ updated_at: conflict.updatedAt,
2801
+ rejected_content: body.content || body.append || null
2802
+ },
2803
+ 409
2804
+ );
2805
+ }
2806
+ }
2807
+ const authorEmail = await getUserEmail(c);
2808
+ const { node, version: responseVersion } = await updateNode(
2809
+ nestId,
2810
+ nodeId,
2811
+ {
2812
+ content: body.content,
2813
+ append: body.append,
2814
+ tags: body.tags,
2815
+ title: body.title,
2816
+ status: body.status,
2817
+ changeNote: body.changeNote
2818
+ },
2819
+ authorEmail
2820
+ );
2821
+ return c.json({ node: toNodeResponse(node), version: responseVersion });
2822
+ });
2823
+ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
2824
+ const nestId = c.req.param("nestId");
2825
+ const nodeId = c.req.param("nodeId");
2826
+ const { storage } = await engineCache.get(nestId);
2827
+ if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
2828
+ throw new LockedError(
2829
+ "This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
2795
2830
  );
2796
2831
  }
2832
+ try {
2833
+ await storage.deleteDocument(nodeId);
2834
+ } catch {
2835
+ throw new NotFoundError(`Node not found: ${nodeId}`);
2836
+ }
2837
+ await removeNodeFromTagIndex(nestId, nodeId);
2838
+ const derivedId = derivedAnnotationsId(nodeId);
2839
+ try {
2840
+ await storage.deleteDocument(derivedId);
2841
+ await removeNodeFromTagIndex(nestId, derivedId);
2842
+ } catch {
2843
+ }
2844
+ const db = getDb();
2845
+ await db.transaction(async (tx) => {
2846
+ await tx.run(
2847
+ "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
2848
+ [nestId, nodeId]
2849
+ );
2850
+ await tx.run(
2851
+ "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
2852
+ [nestId, nodeId]
2853
+ );
2854
+ await tx.run(
2855
+ "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
2856
+ [nestId, nodeId]
2857
+ );
2858
+ await tx.run(
2859
+ `DELETE FROM stewards
2860
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2861
+ [nestId, nodeId]
2862
+ );
2863
+ await tx.run(
2864
+ "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
2865
+ [nestId, nodeId]
2866
+ );
2867
+ await tx.run(
2868
+ "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
2869
+ [nestId, derivedId]
2870
+ );
2871
+ await tx.run(
2872
+ "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
2873
+ [nestId, derivedId]
2874
+ );
2875
+ await tx.run(
2876
+ "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
2877
+ [nestId, derivedId]
2878
+ );
2879
+ await tx.run(
2880
+ "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
2881
+ [nestId, derivedId]
2882
+ );
2883
+ });
2884
+ await trackEvent("node.delete", { nestId, nodeId });
2885
+ return c.json({ deleted: true });
2886
+ });
2887
+ async function getUserEmail(c) {
2888
+ const userId = c.get("userId");
2889
+ const db = getDb();
2890
+ const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
2891
+ return user?.email || "anonymous@localhost";
2797
2892
  }
2798
2893
 
2894
+ // src/annotations/routes.ts
2895
+ import { Hono as Hono5 } from "hono";
2896
+
2799
2897
  // src/annotations/types.ts
2800
2898
  var ARTIFACT_NODE_TYPE = "artifact";
2801
2899
 
@@ -3322,11 +3420,30 @@ queryRoutes.post("/context", async (c) => {
3322
3420
  const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
3323
3421
  const hops = body.hops ?? 2;
3324
3422
  const includeDrafts = body.include_drafts === true;
3423
+ const isVisible = (d) => includeDrafts || d.frontmatter.status === "published";
3325
3424
  let selector = body.selector?.trim() || null;
3326
3425
  let compileDetail = null;
3327
3426
  let titleMatches = [];
3427
+ let resolvedTitleSelector = false;
3328
3428
  const allDocs = await storage.discoverDocuments();
3329
- if (!selector && body.prompt) {
3429
+ if (selector && selector.includes("[[")) {
3430
+ const parts = selector.split("|").map((p) => p.trim()).filter(Boolean);
3431
+ const wantedTitles = /* @__PURE__ */ new Set();
3432
+ const rest = [];
3433
+ for (const p of parts) {
3434
+ const m = /^\[\[(.+)\]\]$/.exec(p);
3435
+ if (m) wantedTitles.add(m[1].trim().toLowerCase());
3436
+ else rest.push(p);
3437
+ }
3438
+ if (wantedTitles.size > 0) {
3439
+ selector = rest.join("|") || null;
3440
+ resolvedTitleSelector = true;
3441
+ titleMatches = allDocs.filter(
3442
+ (d) => wantedTitles.has(String(d.frontmatter.title || "").toLowerCase()) && isVisible(d)
3443
+ );
3444
+ }
3445
+ }
3446
+ if (!selector && !resolvedTitleSelector && body.prompt) {
3330
3447
  const titles = allDocs.map((d) => d.frontmatter.title);
3331
3448
  compileDetail = await compilePrompt(body.prompt, nestId, titles);
3332
3449
  selector = compileDetail.selector;
@@ -3352,6 +3469,44 @@ queryRoutes.post("/context", async (c) => {
3352
3469
  hopsUsed = result.hopsUsed;
3353
3470
  nodesTraversed = result.nodesTraversed;
3354
3471
  }
3472
+ if (titleMatches.length > 0 && hops > 0) {
3473
+ const byTitle = new Map(
3474
+ allDocs.map((d) => [
3475
+ String(d.frontmatter.title || "").toLowerCase(),
3476
+ d
3477
+ ])
3478
+ );
3479
+ const byId = new Map(allDocs.map((d) => [d.id, d]));
3480
+ const adj = /* @__PURE__ */ new Map();
3481
+ const link = (a, b) => {
3482
+ (adj.get(a) ?? adj.set(a, /* @__PURE__ */ new Set()).get(a)).add(b);
3483
+ (adj.get(b) ?? adj.set(b, /* @__PURE__ */ new Set()).get(b)).add(a);
3484
+ };
3485
+ for (const d of allDocs) {
3486
+ for (const target of extractWikiTargets(d.body || "")) {
3487
+ const t = byId.get(target) ?? byTitle.get(target.toLowerCase());
3488
+ if (t && t.id !== d.id) link(d.id, t.id);
3489
+ }
3490
+ }
3491
+ const seen = new Set(titleMatches.map((d) => d.id));
3492
+ let frontier = titleMatches.map((d) => d.id);
3493
+ for (let depth = 0; depth < hops && frontier.length; depth++) {
3494
+ const next = [];
3495
+ for (const id of frontier) {
3496
+ for (const nb of adj.get(id) ?? []) {
3497
+ if (seen.has(nb)) continue;
3498
+ seen.add(nb);
3499
+ const doc = byId.get(nb);
3500
+ if (!doc || !isVisible(doc)) continue;
3501
+ next.push(nb);
3502
+ titleMatches.push(doc);
3503
+ }
3504
+ }
3505
+ frontier = next;
3506
+ hopsUsed = Math.max(hopsUsed, depth + 1);
3507
+ }
3508
+ nodesTraversed += seen.size;
3509
+ }
3355
3510
  if (titleMatches.length > 0) {
3356
3511
  const seen = new Set(documents.map((d) => d.id));
3357
3512
  for (const t of titleMatches) {
@@ -3500,6 +3655,10 @@ queryRoutes.get("/overview", async (c) => {
3500
3655
  }))
3501
3656
  });
3502
3657
  });
3658
+ queryRoutes.get("/comment-counts", async (c) => {
3659
+ const counts = await countThreadsByNode(c.req.param("nestId"));
3660
+ return c.json({ counts });
3661
+ });
3503
3662
  queryRoutes.get("/context", async (c) => {
3504
3663
  const { storage } = await engineCache.get(c.req.param("nestId"));
3505
3664
  const content = await storage.readContextMd();
@@ -3621,6 +3780,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3621
3780
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3622
3781
 
3623
3782
  // src/mcp/tools.ts
3783
+ var MAX_HOPS = 10;
3784
+ function normalizeHops(raw) {
3785
+ if (raw == null) return 2;
3786
+ const n = Number(raw);
3787
+ if (!Number.isFinite(n)) return 2;
3788
+ return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
3789
+ }
3624
3790
  var TOOL_DEFINITIONS = [
3625
3791
  {
3626
3792
  name: "context_init",
@@ -3649,7 +3815,11 @@ var TOOL_DEFINITIONS = [
3649
3815
  inputSchema: {
3650
3816
  type: "object",
3651
3817
  properties: {
3652
- query: { type: "string", description: "Selector query" }
3818
+ query: { type: "string", description: "Selector query" },
3819
+ hops: {
3820
+ type: "number",
3821
+ description: "Graph traversal depth from the matched nodes (default: 2). Use 1 for just the matches + direct links, higher to pull in more of the neighborhood."
3822
+ }
3653
3823
  },
3654
3824
  required: ["query"]
3655
3825
  }
@@ -3690,11 +3860,39 @@ var TOOL_DEFINITIONS = [
3690
3860
  max_tokens: {
3691
3861
  type: "number",
3692
3862
  description: "Approximate token budget (default: 8000)"
3863
+ },
3864
+ hops: {
3865
+ type: "number",
3866
+ description: "Graph traversal depth from the matched nodes (default: 2)"
3693
3867
  }
3694
3868
  },
3695
3869
  required: ["selector"]
3696
3870
  }
3697
3871
  },
3872
+ {
3873
+ name: "context_export",
3874
+ description: "Export the ENTIRE nest as one markdown bundle \u2014 every accessible document's FULL content. Use when you need the whole nest as context rather than a targeted query. Respects an optional token budget.",
3875
+ inputSchema: {
3876
+ type: "object",
3877
+ properties: {
3878
+ max_tokens: {
3879
+ type: "number",
3880
+ description: "Approximate token budget; documents are included until it's reached (omit for no cap)."
3881
+ }
3882
+ }
3883
+ }
3884
+ },
3885
+ {
3886
+ name: "context_comments",
3887
+ description: "Get the review comments/annotations on a document \u2014 anchored quotes, threaded replies, and open/resolved status \u2014 by title or ID. Use to pull human feedback into context.",
3888
+ inputSchema: {
3889
+ type: "object",
3890
+ properties: {
3891
+ title: { type: "string", description: "Title of the node" },
3892
+ id: { type: "string", description: "ID of the node" }
3893
+ }
3894
+ }
3895
+ },
3698
3896
  {
3699
3897
  name: "context_create",
3700
3898
  description: "Create a new knowledge node in the vault.",
@@ -3715,7 +3913,11 @@ var TOOL_DEFINITIONS = [
3715
3913
  items: { type: "string" },
3716
3914
  description: "Tags"
3717
3915
  },
3718
- scope: { type: "string", description: "Visibility scope" }
3916
+ scope: { type: "string", description: "Visibility scope" },
3917
+ folder: {
3918
+ type: "string",
3919
+ description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
3920
+ }
3719
3921
  },
3720
3922
  required: ["title", "content"]
3721
3923
  }
@@ -3946,8 +4148,16 @@ ${nodeList}`;
3946
4148
  ${results}`;
3947
4149
  }
3948
4150
  case "context_query": {
3949
- const result = await queryEngine.query(args.query, { hops: 2 });
3950
- const nodes = result.documents;
4151
+ const result = await queryEngine.query(args.query, {
4152
+ hops: normalizeHops(args.hops)
4153
+ });
4154
+ const visibility = await Promise.all(
4155
+ result.documents.map(async (n) => ({
4156
+ node: n,
4157
+ visible: await resolveLlmBody(ctx, n) !== null
4158
+ }))
4159
+ );
4160
+ const nodes = visibility.filter((e) => e.visible).map((e) => e.node);
3951
4161
  if (!nodes.length) return `No nodes matched: ${args.query}`;
3952
4162
  const list = nodes.map(
3953
4163
  (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}`
@@ -3995,21 +4205,99 @@ ${body || "(no content)"}`;
3995
4205
  ${list}`;
3996
4206
  }
3997
4207
  case "context_resolve": {
3998
- const result = await queryEngine.query(args.selector, { hops: 2 });
4208
+ const result = await queryEngine.query(args.selector, {
4209
+ hops: normalizeHops(args.hops)
4210
+ });
3999
4211
  const maxTokens = args.max_tokens || 8e3;
4000
4212
  const approxChars = maxTokens * 4;
4213
+ const resolvedBodies = await Promise.all(
4214
+ result.documents.map(async (n) => ({
4215
+ node: n,
4216
+ body: await resolveLlmBody(ctx, n)
4217
+ }))
4218
+ );
4001
4219
  let total = 0;
4002
4220
  const resolved = [];
4003
- for (const n of result.documents) {
4221
+ for (const { node: n, body } of resolvedBodies) {
4222
+ if (body === null) continue;
4004
4223
  const entry = `## ${n.frontmatter.title}
4005
4224
 
4006
- ${n.body || ""}`;
4225
+ ${body}`;
4007
4226
  if (total + entry.length > approxChars) break;
4008
4227
  resolved.push(entry);
4009
4228
  total += entry.length;
4010
4229
  }
4011
4230
  return resolved.join("\n\n---\n\n") || "No nodes resolved.";
4012
4231
  }
4232
+ case "context_export": {
4233
+ const docs = await storage.discoverDocuments();
4234
+ const approxChars = args.max_tokens ? args.max_tokens * 4 : Infinity;
4235
+ const resolved = await Promise.all(
4236
+ docs.map(async (n) => ({ node: n, body: await resolveLlmBody(ctx, n) }))
4237
+ );
4238
+ let total = 0;
4239
+ let budgetHit = false;
4240
+ const parts = [];
4241
+ for (const { node: n, body } of resolved) {
4242
+ if (body === null) continue;
4243
+ const meta = [
4244
+ `**Title:** ${n.frontmatter.title}`,
4245
+ `**Type:** ${n.frontmatter.type || "document"}`,
4246
+ n.frontmatter.tags?.length ? `**Tags:** ${n.frontmatter.tags.join(" ")}` : null
4247
+ ].filter(Boolean).join("\n");
4248
+ const entry = `${meta}
4249
+
4250
+ ${body || "(no content)"}`;
4251
+ if (total + entry.length > approxChars) {
4252
+ budgetHit = true;
4253
+ break;
4254
+ }
4255
+ parts.push(entry);
4256
+ total += entry.length;
4257
+ }
4258
+ if (!parts.length) {
4259
+ return budgetHit ? "Token budget reached before any document fit \u2014 raise max_tokens, or use context_query/context_resolve to target." : "No documents available to export.";
4260
+ }
4261
+ const note = budgetHit ? `
4262
+
4263
+ _(Token budget reached \u2014 ${parts.length} of ${docs.length} documents included. Raise max_tokens, or use context_query/context_resolve to target.)_` : "";
4264
+ return `# Nest export \u2014 ${parts.length} document(s)
4265
+
4266
+ ${parts.join(
4267
+ "\n\n---\n\n"
4268
+ )}${note}`;
4269
+ }
4270
+ case "context_comments": {
4271
+ const docs = await storage.discoverDocuments();
4272
+ let node;
4273
+ if (args.title) {
4274
+ node = docs.find(
4275
+ (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
4276
+ );
4277
+ } else if (args.id) {
4278
+ node = docs.find((n) => n.id === args.id);
4279
+ }
4280
+ if (!node) return `Node not found: ${args.title || args.id}`;
4281
+ if (await resolveLlmBody(ctx, node) === null) {
4282
+ return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
4283
+ }
4284
+ const threads = await listThreads(nestId, node.id);
4285
+ if (!threads.length)
4286
+ return `No comments on "${node.frontmatter.title}".`;
4287
+ const openCount = threads.filter((t) => t.status === "open").length;
4288
+ const sections = threads.map((t, i) => {
4289
+ const quote = t.anchor?.quote ? `> ${t.anchor.quote.replace(/\n/g, " ")}
4290
+
4291
+ ` : "";
4292
+ const head = `### ${i + 1}. [${t.status}]${t.anchor?.quote ? "" : " (whole-document)"}`;
4293
+ const comments = t.comments.map((c) => `- **${c.author}** (${c.createdAt}): ${c.body}`).join("\n");
4294
+ return `${head}
4295
+ ${quote}${comments}`;
4296
+ }).join("\n\n");
4297
+ return `# Comments on "${node.frontmatter.title}" \u2014 ${threads.length} thread(s), ${openCount} open
4298
+
4299
+ ${sections}`;
4300
+ }
4013
4301
  case "context_create": {
4014
4302
  if (!await canCreateInNest(nestId, userEmail)) {
4015
4303
  return "You don't have permission to create documents in this nest.";
@@ -4021,7 +4309,8 @@ ${n.body || ""}`;
4021
4309
  content: args.content,
4022
4310
  type: args.type,
4023
4311
  tags: args.tags,
4024
- scope: args.scope
4312
+ scope: args.scope,
4313
+ folder: args.folder
4025
4314
  },
4026
4315
  userEmail
4027
4316
  );
@@ -4068,8 +4357,8 @@ ${n.body || ""}`;
4068
4357
 
4069
4358
  ${list}`;
4070
4359
  }
4071
- if (!canManageStewards(ctx.userEmail)) {
4072
- return "You don't have permission to list stewards. Only the super admin can do this.";
4360
+ if (!await canManageStewards(ctx.nestId, ctx.userId)) {
4361
+ return "You don't have permission to list stewards. Only a nest admin, the nest owner, or the server admin can do this.";
4073
4362
  }
4074
4363
  const allStewards = await getStewardsForNest(ctx.nestId);
4075
4364
  if (allStewards.length === 0) {
@@ -4207,8 +4496,8 @@ ${list}`;
4207
4496
  if (!["nest", "tag", "document"].includes(scope)) {
4208
4497
  return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
4209
4498
  }
4210
- if (!canManageStewards(ctx.userEmail)) {
4211
- return "You don't have permission to manage stewards. Only the super admin can do this.";
4499
+ if (!await canManageStewards(ctx.nestId, ctx.userId)) {
4500
+ return "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this.";
4212
4501
  }
4213
4502
  try {
4214
4503
  await createStewardRecord({
@@ -4226,9 +4515,9 @@ ${list}`;
4226
4515
  }
4227
4516
  }
4228
4517
  case "context_share_nest": {
4229
- const roles = await resolveUserRoles(ctx.nestId, ctx.userEmail);
4230
- if (!canManageWith(roles)) {
4231
- return "You don't have permission to share this nest via this tool (admin only). You can still invite from the UI with write access, or ask a nest admin.";
4518
+ const callerPermission = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(ctx.nestId, ctx.userId);
4519
+ if (permissionLevel(callerPermission) < permissionLevel("write")) {
4520
+ return "You don't have permission to share this nest. Sharing needs write access \u2014 ask a nest admin or the owner.";
4232
4521
  }
4233
4522
  const permission = args.permission || "read";
4234
4523
  try {
@@ -4236,7 +4525,8 @@ ${list}`;
4236
4525
  nestId: ctx.nestId,
4237
4526
  email: args.email,
4238
4527
  permission,
4239
- grantedByEmail: ctx.userEmail
4528
+ grantedByEmail: ctx.userEmail,
4529
+ callerPermission
4240
4530
  });
4241
4531
  const label = permission === "admin" ? "admin" : permission === "write" ? "editor" : "viewer";
4242
4532
  return `Shared this nest with **${args.email}** as ${label}.`;
@@ -5130,6 +5420,7 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
5130
5420
  function isPublicReadEligiblePath(method, path) {
5131
5421
  if (method !== "GET") return false;
5132
5422
  if (!/^\/nests\/[^/]+(\/.*)?$/.test(path)) return false;
5423
+ if (/^\/nests\/[^/]+\/stewards/.test(path)) return false;
5133
5424
  return !/\/(collaborators|visibility|settings|mcp)/.test(path);
5134
5425
  }
5135
5426
  var flexAuthMiddleware = createMiddleware2(async (c, next) => {
@@ -5416,10 +5707,10 @@ function createApp() {
5416
5707
  const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
5417
5708
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
5418
5709
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
5419
- if (isStewardRoster && !canManageStewards(await resolveCallerEmail(userId))) {
5710
+ if (isStewardRoster && permission !== "owner" && permission !== "admin") {
5420
5711
  return c.json(
5421
5712
  {
5422
- error: "You don't have permission to manage stewards. Only the super admin can do this."
5713
+ error: "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this."
5423
5714
  },
5424
5715
  403
5425
5716
  );