@promptowl/contextnest-community 1.15.0 → 1.16.1

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
@@ -35,14 +35,11 @@ import {
35
35
  requireWorkflowPlane,
36
36
  resolveCallerEmail,
37
37
  safeJson,
38
- safePublishDocument,
39
38
  seedDefaultEdgeTypes,
40
39
  sendTestNotification,
41
- serializeNodeSafe,
42
- stripUndefinedDeep,
43
40
  submitForReview,
44
41
  withNodeWriteLock
45
- } from "./chunk-C4U75IUR.js";
42
+ } from "./chunk-QJVRZT6M.js";
46
43
  import {
47
44
  createGrant,
48
45
  deleteGrant,
@@ -51,7 +48,7 @@ import {
51
48
  listGrants,
52
49
  listUserGrants,
53
50
  resolveNodeGrant
54
- } from "./chunk-5IAKUUZF.js";
51
+ } from "./chunk-OTGZQZKK.js";
55
52
  import {
56
53
  generateApiKey,
57
54
  getKeyPrefix,
@@ -64,13 +61,15 @@ import {
64
61
  checkConflict,
65
62
  createVersion,
66
63
  getApprovedVersion,
64
+ getApprovedVersions,
67
65
  getCurrentVersion,
68
66
  getDisplayStatus,
67
+ getTrackedNodeIds,
69
68
  getVersions,
70
69
  listMyDrafts,
71
70
  setApprovedVersion,
72
71
  upsertVersion
73
- } from "./chunk-JWVZ3T35.js";
72
+ } from "./chunk-D6HICFVW.js";
74
73
  import {
75
74
  canCreateInNest,
76
75
  canManageStewards,
@@ -90,7 +89,7 @@ import {
90
89
  resolveUserRoles,
91
90
  syncFromConfig,
92
91
  updateSteward
93
- } from "./chunk-GWDOLYFJ.js";
92
+ } from "./chunk-CLKZZZS2.js";
94
93
  import {
95
94
  addMember,
96
95
  buildTitleMap,
@@ -101,6 +100,7 @@ import {
101
100
  deleteTeam,
102
101
  disableStewardshipAndWipeGovernance,
103
102
  docLink,
103
+ engineApi,
104
104
  engineCache,
105
105
  findTeamByExternalId,
106
106
  folderForNode,
@@ -136,6 +136,7 @@ import {
136
136
  nestStorageRoot,
137
137
  normalizeTag,
138
138
  nowExpr,
139
+ opContext,
139
140
  permissionLevel,
140
141
  persistSetting,
141
142
  removeMember,
@@ -161,7 +162,7 @@ import {
161
162
  updateMemberRole,
162
163
  validateLicense,
163
164
  verifySmtp
164
- } from "./chunk-7BOG3S5H.js";
165
+ } from "./chunk-L7UCMGWZ.js";
165
166
  import {
166
167
  AppError,
167
168
  ConflictError,
@@ -177,7 +178,7 @@ import {
177
178
  initDb,
178
179
  isEmailListish,
179
180
  isEmailish
180
- } from "./chunk-BCFKLY4H.js";
181
+ } from "./chunk-JMJLNEXE.js";
181
182
  import {
182
183
  ANON_EMAIL,
183
184
  ANON_USER_ID
@@ -195,6 +196,29 @@ import { LinearRouter } from "hono/router/linear-router";
195
196
  import { createMiddleware as createMiddleware2 } from "hono/factory";
196
197
  import { cors } from "hono/cors";
197
198
 
199
+ // src/shared/artifact-sandbox.ts
200
+ var ARTIFACT_SANDBOX_CSP = [
201
+ "sandbox allow-scripts allow-popups allow-modals",
202
+ "default-src 'none'",
203
+ "script-src 'unsafe-inline'",
204
+ "style-src 'unsafe-inline'",
205
+ "img-src data: blob:",
206
+ "font-src data:",
207
+ "connect-src 'none'",
208
+ "base-uri 'none'",
209
+ "form-action 'none'"
210
+ ].join("; ");
211
+ function mergeFrameAncestors(existing, directive) {
212
+ if (!existing) return directive;
213
+ if (/(^|;)\s*frame-ancestors\b/i.test(existing)) return existing;
214
+ return `${existing}; ${directive}`;
215
+ }
216
+ function applyArtifactSandboxHeaders(c) {
217
+ c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
218
+ c.header("X-Content-Type-Options", "nosniff");
219
+ c.header("Referrer-Policy", "no-referrer");
220
+ }
221
+
198
222
  // src/auth/routes.ts
199
223
  import { Hono as Hono2 } from "hono";
200
224
  import { v4 as uuid2 } from "uuid";
@@ -1580,17 +1604,42 @@ authRoutes.get("/teammates", async (c) => {
1580
1604
 
1581
1605
  // src/nests/routes.ts
1582
1606
  import { Hono as Hono3 } from "hono";
1583
- import { streamText } from "hono/streaming";
1584
1607
 
1585
1608
  // src/nodes/service.ts
1586
1609
  import {
1587
- serializeDocument,
1588
1610
  parseDocument as parseDocument2,
1589
1611
  normalizeStatus,
1612
+ isRejected,
1590
1613
  DocumentNotFoundError
1591
1614
  } from "@promptowl/contextnest-engine";
1592
- import { rename, mkdir, rm } from "fs/promises";
1593
- import { dirname, join as join2, basename } from "path";
1615
+ import { rename, mkdir } from "fs/promises";
1616
+ import { dirname, join as join2 } from "path";
1617
+
1618
+ // src/governance/safe-publish.ts
1619
+ import {
1620
+ publishDocument,
1621
+ serializeDocument
1622
+ } from "@promptowl/contextnest-engine";
1623
+ function serializeNodeSafe(node) {
1624
+ return serializeDocument({
1625
+ ...node,
1626
+ frontmatter: stripUndefinedDeep(node.frontmatter)
1627
+ });
1628
+ }
1629
+ function stripUndefinedDeep(value) {
1630
+ if (Array.isArray(value)) {
1631
+ return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
1632
+ }
1633
+ if (value && typeof value === "object") {
1634
+ const out = {};
1635
+ for (const [k, v] of Object.entries(value)) {
1636
+ if (v === void 0) continue;
1637
+ out[k] = stripUndefinedDeep(v);
1638
+ }
1639
+ return out;
1640
+ }
1641
+ return value;
1642
+ }
1594
1643
 
1595
1644
  // src/governance/prime-service.ts
1596
1645
  async function isPrimeDocument(nestId, tags, metadata) {
@@ -1615,6 +1664,7 @@ import { join } from "path";
1615
1664
  import {
1616
1665
  detectDrift,
1617
1666
  stageSuggestion,
1667
+ ChainEventLog,
1618
1668
  approveSuggestion,
1619
1669
  rejectSuggestion,
1620
1670
  listSuggestions,
@@ -1697,7 +1747,8 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
1697
1747
  return { meta: result.meta, created: true };
1698
1748
  }
1699
1749
  async function scanNestForDrift(nestId, actor = "system:scanner") {
1700
- const { storage } = await engineCache.get(nestId);
1750
+ const { storage, invalidateDiscovery } = await engineCache.get(nestId);
1751
+ invalidateDiscovery();
1701
1752
  const docs = await storage.discoverDocuments();
1702
1753
  const results = await Promise.all(
1703
1754
  docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
@@ -1768,6 +1819,68 @@ async function getExternalEditDetail(nestId, documentId, suggestionId) {
1768
1819
  patch: found.patch
1769
1820
  };
1770
1821
  }
1822
+ async function recordVerdict(nestId, event) {
1823
+ try {
1824
+ const { storage } = await engineCache.get(nestId);
1825
+ await new ChainEventLog(storage).append(event);
1826
+ return true;
1827
+ } catch (err) {
1828
+ console.error(
1829
+ `[external-edit] failed to record verdict ${event.event_id} for nest ${nestId}:`,
1830
+ err
1831
+ );
1832
+ return false;
1833
+ }
1834
+ }
1835
+ async function listExternalEditVerdicts(nestId, documentId) {
1836
+ try {
1837
+ const { storage } = await engineCache.get(nestId);
1838
+ const events = await new ChainEventLog(storage).readByDocument(documentId);
1839
+ return events.flatMap((e) => {
1840
+ const meta = e.action_metadata || {};
1841
+ if (typeof meta.suggestion_id !== "string") return [];
1842
+ const reason = meta.rejection_reason;
1843
+ const comment = meta.approval_comment;
1844
+ return [
1845
+ {
1846
+ suggestion_id: meta.suggestion_id,
1847
+ status: typeof reason === "string" ? "rejected" : "approved",
1848
+ actor: e.actor,
1849
+ at: e.timestamp,
1850
+ note: typeof reason === "string" ? reason : typeof comment === "string" ? comment : void 0,
1851
+ source: typeof meta.source === "string" ? meta.source : void 0
1852
+ }
1853
+ ];
1854
+ });
1855
+ } catch (err) {
1856
+ console.error(
1857
+ `[external-edit] failed to read verdicts for ${nestId}/${documentId}:`,
1858
+ err
1859
+ );
1860
+ return [];
1861
+ }
1862
+ }
1863
+ async function mirrorVersion(input) {
1864
+ const { storage } = await engineCache.get(input.nestId);
1865
+ const node = await storage.readDocument(input.documentId);
1866
+ const { upsertVersion: upsertVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-V6K5SZ3P.js");
1867
+ await upsertVersion2({
1868
+ nestId: input.nestId,
1869
+ nodeId: input.documentId,
1870
+ version: input.version,
1871
+ content: node.body || "",
1872
+ author: input.actor,
1873
+ status: "published",
1874
+ tags: node.frontmatter.tags || [],
1875
+ changeNote: input.note
1876
+ });
1877
+ await setApprovedVersion2(
1878
+ input.nestId,
1879
+ input.documentId,
1880
+ input.version,
1881
+ input.actor
1882
+ );
1883
+ }
1771
1884
  async function approveExternalEdit(input) {
1772
1885
  const { storage } = await engineCache.get(input.nestId);
1773
1886
  let result;
@@ -1789,33 +1902,21 @@ async function approveExternalEdit(input) {
1789
1902
  throw err;
1790
1903
  }
1791
1904
  try {
1792
- const node = await storage.readDocument(input.documentId);
1793
- const versionNum = result.versionEntry.version;
1794
- const tags = node.frontmatter.tags || [];
1795
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-ECYCBVSE.js");
1796
- await createVersion2({
1905
+ await mirrorVersion({
1797
1906
  nestId: input.nestId,
1798
- nodeId: input.documentId,
1799
- version: versionNum,
1800
- content: node.body || "",
1801
- author: input.actor,
1802
- status: "published",
1803
- tags,
1804
- changeNote: input.comment || "External edit approved"
1907
+ documentId: input.documentId,
1908
+ version: result.versionEntry.version,
1909
+ actor: input.actor,
1910
+ note: input.comment ? `External edit approved: ${input.comment}` : "External edit approved"
1805
1911
  });
1806
- await setApprovedVersion2(
1807
- input.nestId,
1808
- input.documentId,
1809
- versionNum,
1810
- input.actor
1811
- );
1812
1912
  } catch (err) {
1813
1913
  console.error(
1814
1914
  `[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
1815
1915
  err
1816
1916
  );
1817
1917
  }
1818
- return result;
1918
+ const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1919
+ return { ...result, historyRecorded };
1819
1920
  }
1820
1921
  async function rejectExternalEdit(input) {
1821
1922
  const { storage } = await engineCache.get(input.nestId);
@@ -1828,6 +1929,7 @@ async function rejectExternalEdit(input) {
1828
1929
  zone: "default",
1829
1930
  reason: input.reason
1830
1931
  });
1932
+ const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1831
1933
  try {
1832
1934
  const approved = await loadChainHead(storage, input.documentId);
1833
1935
  if (approved) {
@@ -1839,7 +1941,7 @@ async function rejectExternalEdit(input) {
1839
1941
  err
1840
1942
  );
1841
1943
  }
1842
- return result;
1944
+ return { ...result, historyRecorded };
1843
1945
  }
1844
1946
  var scannerTimer = null;
1845
1947
  async function scanAllNests() {
@@ -1881,12 +1983,24 @@ function assertTitleLength(title) {
1881
1983
  );
1882
1984
  }
1883
1985
  }
1986
+ function assertTitleSluggable(title) {
1987
+ if (!slugify(title)) {
1988
+ throw new ValidationError(
1989
+ "the document id is built from the title using a-z and 0-9, so the title needs at least one of those"
1990
+ );
1991
+ }
1992
+ }
1884
1993
  var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1885
1994
  function isIdUnderFolder(id, folder) {
1886
1995
  return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
1887
1996
  }
1888
1997
  function folderToSegments(folder) {
1889
1998
  const segments = folder.split("/").map(slugify).filter(Boolean);
1999
+ if (folder.trim() && segments.length === 0) {
2000
+ throw new ValidationError(
2001
+ "the folder path is built from the name using a-z and 0-9, so the name needs at least one of those"
2002
+ );
2003
+ }
1890
2004
  if (segments[0] === "nodes") segments.shift();
1891
2005
  if (segments.length > 8) {
1892
2006
  throw new ValidationError("folder may nest at most 8 levels deep");
@@ -1898,7 +2012,6 @@ function folderToSegments(folder) {
1898
2012
  }
1899
2013
  return segments;
1900
2014
  }
1901
- var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
1902
2015
  async function departmentTagFor(userEmail) {
1903
2016
  if (!config.OIDC_DEPARTMENT_TAGGING) return null;
1904
2017
  try {
@@ -1915,6 +2028,12 @@ async function departmentTagFor(userEmail) {
1915
2028
  }
1916
2029
  function toSafeError(err, context, message) {
1917
2030
  if (err instanceof AppError) return err;
2031
+ if (err?.code === "REJECTED_DOCUMENT") {
2032
+ console.error(`${context}:`, err);
2033
+ return new ConflictError(
2034
+ "This document is rejected. Change its status before saving."
2035
+ );
2036
+ }
1918
2037
  console.error(`${context}:`, err);
1919
2038
  return new AppError(500, message);
1920
2039
  }
@@ -2005,28 +2124,89 @@ async function resolveAuthorship(nestId, nodes) {
2005
2124
  }
2006
2125
  return result;
2007
2126
  }
2008
- async function listNodesForCaller(nestId, userId, filters = {}) {
2009
- const { storage, versions: versionManager } = await engineCache.get(nestId);
2010
- let documents = await storage.discoverDocuments();
2011
- if (filters.type) {
2012
- const wanted = new Set(
2013
- Array.isArray(filters.type) ? filters.type : [filters.type]
2127
+ async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
2128
+ if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
2129
+ throw new ForbiddenError(
2130
+ "Access denied \u2014 no steward assignment for this node"
2014
2131
  );
2015
- documents = documents.filter((n) => wanted.has(n.frontmatter.type));
2016
2132
  }
2017
- if (filters.tag) {
2018
- const tag = normalizeTag2(filters.tag);
2019
- documents = documents.filter(
2020
- (n) => (n.frontmatter.tags || []).includes(tag)
2133
+ let got;
2134
+ try {
2135
+ got = await engineApi.run(
2136
+ "context_get",
2137
+ {
2138
+ ...selector,
2139
+ include_raw: true,
2140
+ // A retired node stays readable here: its stewards decide whether to
2141
+ // revive it, and they cannot do that if the read refuses.
2142
+ allow_rejected: true,
2143
+ ...opts.verifyChecksum ? { verify_checksum: true } : {}
2144
+ },
2145
+ await opContext(nestId, userEmail)
2146
+ );
2147
+ } catch (err) {
2148
+ if (err?.code !== "DOCUMENT_NOT_FOUND") {
2149
+ console.error(`getNodeForCaller failed (${nestId}/${selector.id ?? selector.title})`, err);
2150
+ }
2151
+ throw new NotFoundError(`Node not found: ${selector.id ?? selector.title}`);
2152
+ }
2153
+ if (!selector.id && !await canReadNode(nestId, got.id, userId, userEmail)) {
2154
+ throw new ForbiddenError(
2155
+ "Access denied \u2014 no steward assignment for this node"
2021
2156
  );
2022
2157
  }
2158
+ return {
2159
+ id: got.id,
2160
+ filePath: "",
2161
+ rawContent: got.raw ?? "",
2162
+ frontmatter: got.frontmatter,
2163
+ body: got.body,
2164
+ ...got.pendingChange ? { pendingChange: got.pendingChange } : {}
2165
+ };
2166
+ }
2167
+ async function listNodesForCaller(nestId, userId, filters = {}) {
2168
+ const { versions: versionManager } = await engineCache.get(nestId);
2023
2169
  const userEmail = await resolveCallerEmail(userId);
2024
- let accessible = await filterAccessible(nestId, userId, userEmail, documents);
2170
+ const { documents } = await engineApi.run(
2171
+ "context_list",
2172
+ {
2173
+ ...filters.type ? { type: filters.type } : {},
2174
+ ...filters.tag ? { tag: filters.tag } : {},
2175
+ ...filters.status ? { status: filters.status } : {},
2176
+ // Unlike the CLI and mcp-server, a retired node stays in this listing:
2177
+ // here it is an unpublished document its stewards may still need to act
2178
+ // on, not one removed from retrieval. An imported vault carrying
2179
+ // `status: rejected` registers exactly that way.
2180
+ include_retired: true,
2181
+ full: true
2182
+ },
2183
+ await opContext(nestId, userEmail)
2184
+ );
2185
+ const nodes = documents.map((d) => ({
2186
+ id: d.id,
2187
+ filePath: "",
2188
+ rawContent: "",
2189
+ frontmatter: d.frontmatter,
2190
+ body: d.body
2191
+ }));
2192
+ const publicReader = await isPublicReader(nestId, userId);
2193
+ const approvedOnly = publicReader || filters.approvedOnly === true;
2194
+ const approvedVersions = approvedOnly ? await getApprovedVersions(nestId) : null;
2195
+ let accessible = await filterAccessible(
2196
+ nestId,
2197
+ userId,
2198
+ userEmail,
2199
+ nodes,
2200
+ approvedVersions ?? void 0
2201
+ );
2025
2202
  if (await resolveNestPermission(nestId, userId) === "none" && !await isStewardshipEnabled(nestId)) {
2026
2203
  const grants = await listUserGrants(nestId, userId);
2027
- accessible = grants.length ? documents.filter((d) => grantCoversNode(grants, d.id)) : accessible;
2204
+ accessible = grants.length ? nodes.filter((d) => grantCoversNode(grants, d.id)) : accessible;
2028
2205
  }
2029
- const publicReader = await isPublicReader(nestId, userId);
2206
+ if (approvedVersions && !publicReader) {
2207
+ accessible = accessible.filter((d) => approvedVersions.has(d.id));
2208
+ }
2209
+ if (filters.limit) accessible = accessible.slice(0, filters.limit);
2030
2210
  const authorship = publicReader ? null : await resolveAuthorship(
2031
2211
  nestId,
2032
2212
  accessible.map((d) => ({
@@ -2042,8 +2222,8 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2042
2222
  r.author = people.author;
2043
2223
  r.collaborators = people.collaborators;
2044
2224
  }
2045
- if (publicReader) {
2046
- const approved = await getApprovedVersion(nestId, doc.id);
2225
+ if (approvedVersions) {
2226
+ const approved = approvedVersions.get(doc.id);
2047
2227
  if (approved != null) {
2048
2228
  try {
2049
2229
  const raw = await versionManager.reconstructVersion(
@@ -2080,7 +2260,7 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2080
2260
  return r;
2081
2261
  })
2082
2262
  );
2083
- return filters.limit ? enriched.slice(0, filters.limit) : enriched;
2263
+ return enriched;
2084
2264
  }
2085
2265
  async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
2086
2266
  return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
@@ -2109,13 +2289,13 @@ async function autoPublishReason(nestId, userEmail, doc) {
2109
2289
  async function createNode(nestId, input, userEmail) {
2110
2290
  const { storage, versions: versionManager } = await engineCache.get(nestId);
2111
2291
  assertTitleLength(input.title);
2292
+ if (!input.id) assertTitleSluggable(input.title);
2112
2293
  const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
2113
2294
  let id = input.id;
2114
2295
  if (!id) {
2115
2296
  const folderSegments = folderToSegments(input.folder ?? "");
2116
2297
  id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
2117
2298
  }
2118
- const now = (/* @__PURE__ */ new Date()).toISOString();
2119
2299
  const tags = (input.tags || []).map(normalizeTag2);
2120
2300
  const requestedType = input.type || "document";
2121
2301
  if (requestedType === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
@@ -2172,33 +2352,62 @@ async function createNode(nestId, input, userEmail) {
2172
2352
  tags,
2173
2353
  metadata
2174
2354
  });
2175
- const autoPublish = publishReason !== null;
2176
- const initialStatus = autoPublish ? "published" : "draft";
2177
- const initialVersion = autoPublish ? 0 : 1;
2178
- let node = {
2179
- id,
2180
- filePath: "",
2181
- frontmatter: {
2182
- title: input.title,
2183
- type: requestedType,
2184
- tags,
2185
- status: input.status || initialStatus,
2186
- version: initialVersion,
2187
- created_at: now,
2188
- updated_at: now,
2189
- metadata
2190
- },
2191
- body: input.content,
2192
- rawContent: ""
2193
- };
2194
- await storage.writeDocument(id, serializeDocument(node));
2355
+ const publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
2356
+ let published = publishReason !== null;
2357
+ try {
2358
+ await engineApi.run(
2359
+ "context_create",
2360
+ {
2361
+ id,
2362
+ title: input.title,
2363
+ content: input.content,
2364
+ type: requestedType,
2365
+ // A skill node is invalid without a skill block, and this surface has
2366
+ // no field for one — default the trigger from the title, same as the
2367
+ // CLI and mcp-server do.
2368
+ ...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
2369
+ ...tags.length ? { tags } : {},
2370
+ ...input.status ? { status: input.status } : {},
2371
+ metadata,
2372
+ publish: published,
2373
+ ...publishNote ? { note: publishNote } : {}
2374
+ },
2375
+ await opContext(nestId, userEmail)
2376
+ );
2377
+ } catch (err) {
2378
+ let onDisk = true;
2379
+ try {
2380
+ await storage.readDocument(id);
2381
+ } catch {
2382
+ onDisk = false;
2383
+ }
2384
+ if (!onDisk) throw err;
2385
+ console.error("publishDocument failed (node create auto-publish)", err);
2386
+ published = false;
2387
+ }
2388
+ const node = await storage.readDocument(id);
2195
2389
  await syncNodeTags(nestId, id, tags);
2196
2390
  let savedVersion = 1;
2197
- if (publishReason === null) {
2198
- try {
2199
- await versionManager.createVersion(node, userEmail);
2200
- } catch (err) {
2201
- console.error("VersionManager.createVersion failed (node create)", err);
2391
+ if (published) {
2392
+ savedVersion = node.frontmatter.version || 1;
2393
+ await createVersion({
2394
+ nestId,
2395
+ nodeId: id,
2396
+ version: savedVersion,
2397
+ content: node.body || "",
2398
+ author: userEmail,
2399
+ status: "published",
2400
+ tags,
2401
+ changeNote: publishNote
2402
+ });
2403
+ await setApprovedVersion(nestId, id, savedVersion, userEmail);
2404
+ } else {
2405
+ if (publishReason === null) {
2406
+ try {
2407
+ await versionManager.createVersion(node, userEmail);
2408
+ } catch (err) {
2409
+ console.error("VersionManager.createVersion failed (node create)", err);
2410
+ }
2202
2411
  }
2203
2412
  await createVersion({
2204
2413
  nestId,
@@ -2209,170 +2418,100 @@ async function createNode(nestId, input, userEmail) {
2209
2418
  status: "draft",
2210
2419
  tags
2211
2420
  });
2212
- } else {
2213
- const publishNote = `Auto-published on create (${publishReason})`;
2214
- try {
2215
- const result = await safePublishDocument(storage, id, {
2216
- editedBy: userEmail,
2217
- note: publishNote
2218
- });
2219
- savedVersion = result.node.frontmatter.version || 1;
2220
- await createVersion({
2221
- nestId,
2222
- nodeId: id,
2223
- version: savedVersion,
2224
- content: result.node.body || "",
2225
- author: userEmail,
2226
- status: "published",
2227
- tags,
2228
- changeNote: publishNote
2229
- });
2230
- await setApprovedVersion(nestId, id, savedVersion, userEmail);
2231
- node = result.node;
2232
- } catch (err) {
2233
- console.error("publishDocument failed (node create auto-publish)", err);
2234
- await createVersion({
2235
- nestId,
2236
- nodeId: id,
2237
- version: 1,
2238
- content: input.content,
2239
- author: userEmail,
2240
- status: "draft",
2241
- tags
2242
- });
2243
- }
2244
2421
  }
2245
2422
  await trackEvent("node.create", { nestId, nodeId: id });
2246
2423
  return { node, version: savedVersion };
2247
2424
  });
2248
2425
  }
2249
- function explicitFrontmatterStatus(raw) {
2250
- const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2251
- if (!fm) return null;
2252
- const m = fm[1].match(/^status:[ \t]*["']?([A-Za-z_-]+)["']?[ \t]*$/m);
2253
- return m ? normalizeStatus(m[1]) : null;
2254
- }
2255
- async function resetImportedDocForPublish(storage, nestId, nodeId) {
2256
- const versionsDir = join2(
2257
- resolveNestPath(nestId),
2258
- dirname(nodeId),
2259
- ".versions",
2260
- basename(nodeId)
2261
- );
2262
- await rm(versionsDir, { recursive: true, force: true });
2263
- const node = await storage.readDocument(nodeId);
2264
- const fm = { ...node.frontmatter };
2265
- delete fm.version;
2266
- delete fm.checksum;
2267
- await storage.writeDocument(nodeId, serializeNodeSafe({ ...node, frontmatter: fm }));
2268
- }
2269
- async function registerImportedDocuments(nestId, userEmail, onProgress) {
2270
- const { storage } = await engineCache.get(nestId);
2271
- let docs;
2426
+ async function stageImportedFiles(nestId, files, userEmail) {
2427
+ const carried = files.filter(
2428
+ (f) => !String(f?.path || "").split(/[\\/]/).includes("_suggestions")
2429
+ );
2430
+ if (!carried.length) return { written: 0, rejected: [] };
2431
+ const result = await engineApi.run(
2432
+ "context_import",
2433
+ { files: carried, publish: false },
2434
+ await opContext(nestId, userEmail)
2435
+ );
2436
+ const rejected = (result.failed ?? []).map((f) => f.id ?? "(unnamed)");
2437
+ if (rejected.length) {
2438
+ console.warn(
2439
+ `[import] ${rejected.length} file(s) rejected:`,
2440
+ (result.failed ?? []).map((f) => `${f.id}: ${f.error}`).join(", ")
2441
+ );
2442
+ }
2443
+ return { written: result.written ?? 0, rejected };
2444
+ }
2445
+ async function alignOnDiskStatus(nestId, documentId, status) {
2272
2446
  try {
2273
- docs = await storage.discoverDocuments();
2447
+ const { storage } = await engineCache.get(nestId);
2448
+ const node = await storage.readDocument(documentId);
2449
+ if (node.frontmatter.status === status) return;
2450
+ await storage.writeDocument(
2451
+ documentId,
2452
+ serializeNodeSafe({
2453
+ ...node,
2454
+ frontmatter: { ...node.frontmatter, status }
2455
+ })
2456
+ );
2274
2457
  } catch (err) {
2275
- console.error("registerImportedDocuments: discovery failed", nestId, err);
2276
- return 0;
2458
+ console.warn(
2459
+ `[import] could not align on-disk status for ${nestId}/${documentId}:`,
2460
+ err
2461
+ );
2277
2462
  }
2463
+ }
2464
+ async function registerImportedDocuments(nestId, userEmail) {
2278
2465
  const startedAt = Date.now();
2279
- console.log(`[import] register: discovered=${docs.length} nest=${nestId}`);
2280
- await onProgress?.(0, docs.length);
2281
- let registered = 0;
2282
- let alreadyTracked = 0;
2283
- let publishFailed = 0;
2284
- let publishRecovered = 0;
2285
- let keptUnpublished = 0;
2286
- let processed = 0;
2287
- for (let doc of docs) {
2288
- const nodeId = doc.id;
2289
- await onProgress?.(++processed, docs.length);
2290
- if (await getCurrentVersion(nestId, nodeId) > 0) {
2291
- alreadyTracked++;
2292
- continue;
2293
- }
2294
- const title = doc.frontmatter?.title ?? (nodeId.split("/").pop() || nodeId);
2295
- doc = { ...doc, frontmatter: { ...doc.frontmatter, title, author: userEmail } };
2296
- try {
2297
- await storage.writeDocument(nodeId, serializeNodeSafe(doc));
2298
- } catch (err) {
2299
- console.error("import: failed to persist import metadata", nodeId, err);
2300
- }
2301
- const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
2302
- const tags = rawTags.map((t) => normalizeTag2(String(t)));
2303
- const fmVersion = Number(doc.frontmatter?.version);
2304
- let version = Number.isInteger(fmVersion) && fmVersion > 0 ? fmVersion : 1;
2305
- let content = doc.body || "";
2306
- const status = explicitFrontmatterStatus(doc.rawContent);
2307
- if (status && status !== "published" && status !== "approved") {
2308
- await createVersion({
2309
- nestId,
2310
- nodeId,
2311
- version,
2312
- content,
2313
- author: userEmail,
2314
- status: "draft",
2315
- changeNote: "Imported from existing folder (kept unpublished)",
2316
- tags
2317
- });
2318
- await syncNodeTags(nestId, nodeId, tags);
2319
- keptUnpublished++;
2320
- registered++;
2321
- continue;
2322
- }
2323
- let published = false;
2324
- try {
2325
- const result = await safePublishDocument(storage, nodeId, {
2326
- editedBy: userEmail,
2327
- note: "Imported from existing folder"
2328
- });
2329
- version = result.node.frontmatter.version || version;
2330
- content = result.node.body || content;
2331
- published = true;
2332
- } catch (err) {
2333
- console.warn(
2334
- `[import] publish retry needed for ${nodeId}: ${err?.message ?? err}`
2335
- );
2336
- try {
2337
- await resetImportedDocForPublish(storage, nestId, nodeId);
2338
- const result = await safePublishDocument(storage, nodeId, {
2339
- editedBy: userEmail,
2340
- note: "Imported from existing folder (reset chain)"
2341
- });
2342
- version = result.node.frontmatter.version || 1;
2343
- content = result.node.body || content;
2344
- published = true;
2345
- publishRecovered++;
2346
- } catch (err2) {
2347
- publishFailed++;
2348
- console.error(
2349
- "reset+republish failed (import register)",
2350
- nodeId,
2351
- err2
2352
- );
2353
- }
2354
- }
2466
+ const tracked = await getTrackedNodeIds(nestId);
2467
+ (await engineCache.get(nestId)).invalidateDiscovery();
2468
+ let result;
2469
+ try {
2470
+ result = await engineApi.run(
2471
+ "context_import",
2472
+ { discover: true, author: userEmail, exclude_ids: [...tracked] },
2473
+ await opContext(nestId, userEmail)
2474
+ );
2475
+ } catch (err) {
2476
+ console.error("[import] register failed", nestId, err);
2477
+ throw new Error(
2478
+ `Failed to import documents: ${err?.message ?? String(err)}`
2479
+ );
2480
+ }
2481
+ const docs = result.documents ?? [];
2482
+ const engineMs = Date.now() - startedAt;
2483
+ const dbStartedAt = Date.now();
2484
+ for (const d of docs) {
2485
+ const tags = d.tags.map((t) => normalizeTag2(String(t)));
2355
2486
  await createVersion({
2356
2487
  nestId,
2357
- nodeId,
2358
- version,
2359
- content,
2488
+ nodeId: d.id,
2489
+ version: d.version,
2490
+ content: d.content,
2360
2491
  author: userEmail,
2361
- status: published ? "published" : "draft",
2362
- changeNote: "Imported from existing folder",
2492
+ status: d.status,
2493
+ changeNote: d.status === "published" ? "Imported from existing folder" : "Imported from existing folder \u2014 could not be published, kept as a draft",
2363
2494
  tags
2364
2495
  });
2365
- if (published) {
2366
- await setApprovedVersion(nestId, nodeId, version, userEmail);
2496
+ if (d.status === "published") {
2497
+ await setApprovedVersion(nestId, d.id, d.version, userEmail);
2498
+ } else {
2499
+ await alignOnDiskStatus(nestId, d.id, d.status);
2367
2500
  }
2368
- await syncNodeTags(nestId, nodeId, tags);
2369
- registered++;
2501
+ await syncNodeTags(nestId, d.id, tags);
2370
2502
  }
2503
+ const published = docs.filter((d) => d.status === "published").length;
2371
2504
  console.log(
2372
- `[import] register done: registered=${registered} alreadyTracked=${alreadyTracked} keptUnpublished=${keptUnpublished} publishRecovered=${publishRecovered} publishFailed=${publishFailed} tookMs=${Date.now() - startedAt}`
2505
+ `[import] register done: registered=${docs.length} published=${published} kept=${docs.length - published} failed=${result.failed.length} alreadyTracked=${tracked.size} engineMs=${engineMs} dbMs=${Date.now() - dbStartedAt}`
2373
2506
  );
2374
- await trackEvent("nest.import.documents", { nestId, registered });
2375
- return registered;
2507
+ if (result.failed.length) {
2508
+ console.warn(
2509
+ "[import] documents that would not publish:",
2510
+ result.failed.map((f) => `${f.id}: ${f.error}`).join(", ")
2511
+ );
2512
+ }
2513
+ await trackEvent("nest.import.documents", { nestId, registered: docs.length });
2514
+ return docs.length;
2376
2515
  }
2377
2516
  async function updateNode(nestId, nodeId, patch, userEmail) {
2378
2517
  return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
@@ -2386,33 +2525,43 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2386
2525
  } catch {
2387
2526
  throw new NotFoundError(`Node not found: ${nodeId}`);
2388
2527
  }
2528
+ const opInput = {};
2389
2529
  if (patch.content !== void 0) {
2390
2530
  node = { ...node, body: patch.content };
2531
+ opInput.content = patch.content;
2391
2532
  }
2392
2533
  if (patch.append) {
2393
2534
  node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
2535
+ opInput.append = "\n" + patch.append;
2394
2536
  }
2395
2537
  if (patch.tags) {
2396
- const newTags = patch.tags.map(normalizeTag2);
2397
- const merged = [
2398
- .../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])
2399
- ];
2400
- node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
2538
+ const nextTags = [...new Set(patch.tags.map(normalizeTag2))];
2539
+ node = { ...node, frontmatter: { ...node.frontmatter, tags: nextTags } };
2540
+ opInput.tags = nextTags;
2401
2541
  }
2402
2542
  if (patch.status) {
2403
2543
  node = {
2404
2544
  ...node,
2405
2545
  frontmatter: { ...node.frontmatter, status: patch.status }
2406
2546
  };
2547
+ opInput.status = normalizeStatus(patch.status);
2548
+ } else if (isRejected(node)) {
2549
+ node = {
2550
+ ...node,
2551
+ frontmatter: { ...node.frontmatter, status: "draft" }
2552
+ };
2553
+ opInput.status = "draft";
2407
2554
  }
2408
2555
  if (patch.title) {
2409
2556
  if (patch.title !== node.frontmatter.title) {
2410
2557
  assertTitleLength(patch.title);
2558
+ assertTitleSluggable(patch.title);
2411
2559
  }
2412
2560
  node = {
2413
2561
  ...node,
2414
2562
  frontmatter: { ...node.frontmatter, title: patch.title }
2415
2563
  };
2564
+ opInput.title = patch.title;
2416
2565
  }
2417
2566
  if (patch.schedule !== void 0) {
2418
2567
  if (!isRunnableType(node.frontmatter.type)) {
@@ -2426,6 +2575,10 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2426
2575
  if (patch.schedule === "") delete metadata.schedule;
2427
2576
  else metadata.schedule = patch.schedule;
2428
2577
  node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2578
+ opInput.metadata = {
2579
+ ...opInput.metadata,
2580
+ schedule: patch.schedule === "" ? null : patch.schedule
2581
+ };
2429
2582
  }
2430
2583
  if (patch.prime !== void 0) {
2431
2584
  await assertMayFlagPrime(nestId, userEmail);
@@ -2433,6 +2586,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2433
2586
  if (patch.prime === null) delete metadata.prime;
2434
2587
  else metadata.prime = patch.prime;
2435
2588
  node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2589
+ opInput.metadata = { ...opInput.metadata, prime: patch.prime };
2436
2590
  }
2437
2591
  const hasStewards = await isStewardshipEnabled(nestId);
2438
2592
  const currentTags = node.frontmatter.tags || [];
@@ -2451,19 +2605,12 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2451
2605
  if (publishReason === null || isAuthorEditingOwnReview) {
2452
2606
  const currentVersion = await getCurrentVersion(nestId, nodeId);
2453
2607
  const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
2454
- node = {
2455
- ...node,
2456
- frontmatter: {
2457
- ...node.frontmatter,
2458
- version: newVersion,
2459
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2460
- // Drop stale published-state checksum so the next verified read
2461
- // doesn't flag this write as external drift.
2462
- checksum: void 0
2463
- }
2464
- };
2465
- node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
2466
- await storage.writeDocument(nodeId, serializeDocument(node));
2608
+ await engineApi.run(
2609
+ "context_update",
2610
+ { id: nodeId, ...opInput, publish: false, version: newVersion },
2611
+ await opContext(nestId, userEmail)
2612
+ );
2613
+ node = await storage.readDocument(nodeId);
2467
2614
  await syncNodeTags(nestId, nodeId, currentTags);
2468
2615
  try {
2469
2616
  await versionManager.createVersion(node, userEmail, {
@@ -2491,26 +2638,15 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2491
2638
  }
2492
2639
  responseVersion = newVersion;
2493
2640
  } else {
2494
- node = {
2495
- ...node,
2496
- frontmatter: {
2497
- ...node.frontmatter,
2498
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2499
- checksum: void 0
2500
- }
2501
- };
2502
- node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
2503
- await storage.writeDocument(nodeId, serializeDocument(node));
2504
- await syncNodeTags(nestId, nodeId, currentTags);
2505
- let publishedVersion = (node.frontmatter.version || 0) + 1;
2506
2641
  const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
2642
+ let publishedVersion;
2507
2643
  try {
2508
- const result = await safePublishDocument(storage, nodeId, {
2509
- editedBy: userEmail,
2510
- note: publishNote
2511
- });
2512
- publishedVersion = result.node.frontmatter.version || publishedVersion;
2513
- node = result.node;
2644
+ const result = await engineApi.run(
2645
+ "context_update",
2646
+ { id: nodeId, ...opInput, publish: true, note: publishNote },
2647
+ await opContext(nestId, userEmail)
2648
+ );
2649
+ publishedVersion = result.version;
2514
2650
  } catch (err) {
2515
2651
  console.error(
2516
2652
  "publishDocument failed (node patch auto-publish)",
@@ -2518,6 +2654,8 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2518
2654
  );
2519
2655
  throw err;
2520
2656
  }
2657
+ node = await storage.readDocument(nodeId);
2658
+ await syncNodeTags(nestId, nodeId, currentTags);
2521
2659
  await upsertVersion({
2522
2660
  nestId,
2523
2661
  nodeId,
@@ -2685,14 +2823,22 @@ async function deleteNode(nestId, nodeId, userEmail) {
2685
2823
  const { storage } = await engineCache.get(nestId);
2686
2824
  await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
2687
2825
  try {
2688
- await storage.deleteDocument(nodeId);
2826
+ await engineApi.run(
2827
+ "context_delete",
2828
+ { id: nodeId },
2829
+ await opContext(nestId, userEmail)
2830
+ );
2689
2831
  } catch {
2690
2832
  throw new NotFoundError(`Node not found: ${nodeId}`);
2691
2833
  }
2692
2834
  await removeNodeFromTagIndex(nestId, nodeId);
2693
2835
  const derivedId = derivedIdOf(nodeId);
2694
2836
  try {
2695
- await storage.deleteDocument(derivedId);
2837
+ await engineApi.run(
2838
+ "context_delete",
2839
+ { id: derivedId },
2840
+ await opContext(nestId, userEmail)
2841
+ );
2696
2842
  await removeNodeFromTagIndex(nestId, derivedId);
2697
2843
  } catch {
2698
2844
  }
@@ -2738,8 +2884,9 @@ async function deleteFolder(nestId, folder, userEmail) {
2738
2884
  }
2739
2885
 
2740
2886
  // src/nests/unsynced-service.ts
2741
- import { readdirSync, readFileSync, rmSync, statSync } from "fs";
2887
+ import { readdirSync, readFileSync, statSync } from "fs";
2742
2888
  import { join as join3, relative, resolve } from "path";
2889
+ import { rm } from "fs/promises";
2743
2890
  var RESERVED = /* @__PURE__ */ new Set(["nests"]);
2744
2891
  function isNestStorageRoot(absPath) {
2745
2892
  return resolve(absPath) === resolve(nestStorageRoot());
@@ -2907,23 +3054,31 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
2907
3054
  `[unsynced] name "${baseName}" already in use, using "${nestName2}" instead`
2908
3055
  );
2909
3056
  }
2910
- const nest = await importNest(userId, nestName2, files);
3057
+ const nest = await importNest(userId, nestName2);
2911
3058
  console.log(
2912
3059
  `[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
2913
3060
  );
2914
- const documents = await registerImportedDocuments(nest.id, callerEmail);
3061
+ let documents;
3062
+ try {
3063
+ await stageImportedFiles(nest.id, files, callerEmail);
3064
+ documents = await registerImportedDocuments(nest.id, callerEmail);
3065
+ } catch (err) {
3066
+ await deleteNest(nest.id).catch(() => {
3067
+ });
3068
+ throw err;
3069
+ }
2915
3070
  console.log(
2916
3071
  `[unsynced] registered ${documents} document(s) for nest ${nest.id}`
2917
3072
  );
2918
3073
  try {
2919
- rmSync(src, { recursive: true, force: true });
3074
+ await rm(src, { recursive: true, force: true });
2920
3075
  console.log(`[unsynced] removed source folder ${src}`);
2921
3076
  } catch (err) {
2922
3077
  console.error("[unsynced] failed to remove source folder", src, err);
2923
3078
  }
2924
3079
  return { nest, documents };
2925
3080
  }
2926
- function deleteUnsyncedFolder(folderName) {
3081
+ async function deleteUnsyncedFolder(folderName) {
2927
3082
  console.log(`[unsynced] delete requested folder="${folderName}"`);
2928
3083
  assertSafeFolderName(folderName);
2929
3084
  const src = join3(config.DATA_ROOT, folderName);
@@ -2939,7 +3094,7 @@ function deleteUnsyncedFolder(folderName) {
2939
3094
  if (!stat.isDirectory()) {
2940
3095
  throw new ValidationError(`Not a directory: ${folderName}`);
2941
3096
  }
2942
- rmSync(src, { recursive: true, force: true });
3097
+ await rm(src, { recursive: true, force: true });
2943
3098
  console.log(`[unsynced] deleted folder ${src}`);
2944
3099
  }
2945
3100
 
@@ -3046,42 +3201,50 @@ nestRoutes.post("/:nestId", async (c) => {
3046
3201
  throw new NotFoundError("Not found");
3047
3202
  }
3048
3203
  const body = await c.req.json();
3204
+ const files = Array.isArray(body.files) ? body.files : [];
3205
+ const userId = c.get("userId");
3206
+ if (body.nestId) {
3207
+ const target = body.nestId;
3208
+ const nest2 = await getNest(target);
3209
+ if (!nest2 || !nest2.is_imported || await effectivePermission(target, userId) !== "owner") {
3210
+ throw new NotFoundError("Nest not found");
3211
+ }
3212
+ const email2 = await resolveCallerEmail(userId);
3213
+ if (body.finalize) {
3214
+ return c.json({ nest: nest2, documents: await registerImportedDocuments(target, email2) });
3215
+ }
3216
+ const registered = await getDb().get(
3217
+ "SELECT COUNT(*) AS c FROM node_versions WHERE nest_id = ?",
3218
+ [target]
3219
+ );
3220
+ if (Number(registered?.c ?? 0) > 0) {
3221
+ throw new ValidationError(
3222
+ "This nest has already been imported. Start a new import instead of adding files to a finished one."
3223
+ );
3224
+ }
3225
+ console.log(`[import] chunk: nest=${target} files=${files.length}`);
3226
+ return c.json(await stageImportedFiles(target, files, email2));
3227
+ }
3049
3228
  if (!body.name) {
3050
3229
  throw new ValidationError("name is required");
3051
3230
  }
3052
- const files = Array.isArray(body.files) ? body.files : [];
3053
- const userId = c.get("userId");
3054
3231
  console.log(
3055
- `[import] request: name=${JSON.stringify(body.name)} files=${files.length}`
3232
+ `[import] request: name=${JSON.stringify(body.name)} files=${files.length}` + (body.partial ? " (first chunk)" : "")
3056
3233
  );
3057
- if (c.req.query("stream") === "1") {
3058
- const name = body.name;
3059
- return streamText(c, async (stream) => {
3060
- try {
3061
- await stream.writeln(JSON.stringify({ phase: "write" }));
3062
- const nest2 = await importNest(userId, name, files);
3063
- const email = await resolveCallerEmail(userId);
3064
- const documents2 = await registerImportedDocuments(
3065
- nest2.id,
3066
- email,
3067
- async (done, total) => {
3068
- await stream.writeln(JSON.stringify({ phase: "register", done, total }));
3069
- }
3070
- );
3071
- await stream.writeln(JSON.stringify({ complete: true, nest: nest2, documents: documents2 }));
3072
- } catch (err) {
3073
- await stream.writeln(
3074
- JSON.stringify({ error: err?.message || "Import failed" })
3075
- );
3076
- }
3234
+ const email = await resolveCallerEmail(userId);
3235
+ const nest = await importNest(userId, body.name);
3236
+ try {
3237
+ const { rejected } = await stageImportedFiles(nest.id, files, email);
3238
+ if (body.partial) {
3239
+ return c.json({ nest, rejected }, 201);
3240
+ }
3241
+ const documents = await registerImportedDocuments(nest.id, email);
3242
+ return c.json({ nest, documents, rejected }, 201);
3243
+ } catch (err) {
3244
+ await deleteNest(nest.id).catch(() => {
3077
3245
  });
3246
+ throw err;
3078
3247
  }
3079
- const nest = await importNest(userId, body.name, files);
3080
- const documents = await registerImportedDocuments(
3081
- nest.id,
3082
- await resolveCallerEmail(userId)
3083
- );
3084
- return c.json({ nest, documents }, 201);
3085
3248
  });
3086
3249
  async function handleUnsyncedList(c) {
3087
3250
  const userId = c.get("userId");
@@ -3121,7 +3284,7 @@ nestRoutes.delete("/:nestId/sync", async (c) => {
3121
3284
  if (!body?.name) {
3122
3285
  throw new ValidationError("name is required");
3123
3286
  }
3124
- deleteUnsyncedFolder(body.name);
3287
+ await deleteUnsyncedFolder(body.name);
3125
3288
  return c.json({ ok: true });
3126
3289
  });
3127
3290
  nestRoutes.get("/:nestId", async (c) => {
@@ -3835,30 +3998,36 @@ teamRoutes.delete("/:teamId/members/:userId", async (c) => {
3835
3998
  callerUserId: userId,
3836
3999
  allowAdmin: await isLicenseAdminUserId(userId)
3837
4000
  });
3838
- return c.json({ team });
3839
- });
4001
+ return c.json({ team });
4002
+ });
4003
+
4004
+ // src/nodes/search.ts
4005
+ function matchDocuments(documents, query) {
4006
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
4007
+ if (terms.length === 0) return [];
4008
+ return documents.filter((node) => {
4009
+ const haystack = [
4010
+ node.frontmatter.title,
4011
+ node.body || "",
4012
+ node.frontmatter.type || "",
4013
+ ...node.frontmatter.tags || []
4014
+ ].join(" ").toLowerCase();
4015
+ return terms.every((term) => haystack.includes(term));
4016
+ });
4017
+ }
4018
+ function toSearchHit(node) {
4019
+ return {
4020
+ id: node.id,
4021
+ title: node.frontmatter.title,
4022
+ type: node.frontmatter.type || "document",
4023
+ tags: node.frontmatter.tags || [],
4024
+ snippet: (node.body || "").slice(0, 200).replace(/\n/g, " ")
4025
+ };
4026
+ }
3840
4027
 
3841
4028
  // src/nodes/routes.ts
3842
4029
  import { Hono as Hono8 } from "hono";
3843
4030
 
3844
- // src/shared/artifact-sandbox.ts
3845
- var ARTIFACT_SANDBOX_CSP = [
3846
- "sandbox allow-scripts allow-popups allow-modals",
3847
- "default-src 'none'",
3848
- "script-src 'unsafe-inline'",
3849
- "style-src 'unsafe-inline'",
3850
- "img-src data: blob:",
3851
- "font-src data:",
3852
- "connect-src 'none'",
3853
- "base-uri 'none'",
3854
- "form-action 'none'"
3855
- ].join("; ");
3856
- function applyArtifactSandboxHeaders(c) {
3857
- c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
3858
- c.header("X-Content-Type-Options", "nosniff");
3859
- c.header("Referrer-Policy", "no-referrer");
3860
- }
3861
-
3862
4031
  // src/nodes/markdown-export.ts
3863
4032
  import { parseDocument as parseDocument3 } from "@promptowl/contextnest-engine";
3864
4033
  function nodeToMarkdown(node) {
@@ -3933,7 +4102,17 @@ function rawNodeBody(c, response, storedRaw) {
3933
4102
  nodeRoutes.get("/", async (c) => {
3934
4103
  const nestId = c.req.param("nestId");
3935
4104
  const userId = c.get("userId");
3936
- const nodes = await listNodesForCaller(nestId, userId);
4105
+ const q = c.req.query();
4106
+ const limit = q.limit ? parseInt(q.limit, 10) : void 0;
4107
+ const nodes = await listNodesForCaller(nestId, userId, {
4108
+ ...q.type ? { type: q.type } : {},
4109
+ ...q.tag ? { tag: q.tag } : {},
4110
+ ...q.status ? { status: q.status } : {},
4111
+ // Lets an owner preview what making the nest public would expose.
4112
+ ...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
4113
+ // Ignore junk rather than 400 — a bad ?limit should not fail a listing.
4114
+ ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {}
4115
+ });
3937
4116
  return c.json({ count: nodes.length, nodes });
3938
4117
  });
3939
4118
  nodeRoutes.post("/", async (c) => {
@@ -3974,7 +4153,7 @@ nodeRoutes.post("/", async (c) => {
3974
4153
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3975
4154
  const nestId = c.req.param("nestId");
3976
4155
  const nodeId = c.req.param("nodeId");
3977
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-FHJ2RBKN.js");
4156
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-LTREGVSJ.js");
3978
4157
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
3979
4158
  nestId,
3980
4159
  nodeId
@@ -3995,7 +4174,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3995
4174
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
3996
4175
  const nestId = c.req.param("nestId");
3997
4176
  const nodeId = c.req.param("nodeId");
3998
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-JUAFYB73.js");
4177
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-V2GR6VM3.js");
3999
4178
  const history = await getReviewHistory2(nestId, nodeId);
4000
4179
  return c.json({ reviews: history });
4001
4180
  });
@@ -4084,18 +4263,13 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
4084
4263
  const { storage, versions: versionManager } = await engineCache.get(nestId);
4085
4264
  const userId = c.get("userId");
4086
4265
  const userEmail = await resolveCallerEmail(userId);
4087
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
4088
- return c.json(
4089
- { error: "Access denied \u2014 no steward assignment for this node" },
4090
- 403
4091
- );
4092
- }
4093
- let node;
4094
- try {
4095
- node = await storage.readDocument(nodeId, { verifyChecksum: true });
4096
- } catch {
4097
- throw new NotFoundError(`Node not found: ${nodeId}`);
4098
- }
4266
+ let node = await getNodeForCaller(
4267
+ nestId,
4268
+ { id: nodeId },
4269
+ userId,
4270
+ userEmail,
4271
+ { verifyChecksum: true }
4272
+ );
4099
4273
  if (node.pendingChange) {
4100
4274
  try {
4101
4275
  await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
@@ -5612,29 +5786,14 @@ queryRoutes.get("/search", async (c) => {
5612
5786
  const nestId = c.req.param("nestId");
5613
5787
  const { storage } = await engineCache.get(nestId);
5614
5788
  const documents = await storage.discoverDocuments();
5615
- const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
5616
- const matches = documents.filter((node) => {
5617
- const haystack = [
5618
- node.frontmatter.title,
5619
- node.body || "",
5620
- node.frontmatter.type || "",
5621
- ...node.frontmatter.tags || []
5622
- ].join(" ").toLowerCase();
5623
- return terms.every((term) => haystack.includes(term));
5624
- });
5789
+ const matches = matchDocuments(documents, q);
5625
5790
  const userId = c.get("userId");
5626
5791
  const userEmail = await resolveCallerEmail(userId);
5627
5792
  const accessible = await filterAccessible(nestId, userId, userEmail, matches);
5628
5793
  return c.json({
5629
5794
  query: q,
5630
5795
  count: accessible.length,
5631
- nodes: accessible.map((n) => ({
5632
- id: n.id,
5633
- title: n.frontmatter.title,
5634
- type: n.frontmatter.type || "document",
5635
- tags: n.frontmatter.tags || [],
5636
- snippet: (n.body || "").slice(0, 200).replace(/\n/g, " ")
5637
- }))
5796
+ nodes: accessible.map(toSearchHit)
5638
5797
  });
5639
5798
  });
5640
5799
  queryRoutes.get("/overview", async (c) => {
@@ -5793,6 +5952,7 @@ queryRoutes.post("/publish", async (c) => {
5793
5952
  await syncNodeTags(nestId, id, tags);
5794
5953
  created.push(id);
5795
5954
  }
5955
+ if (created.length > 0 || body.context_md) await storage.regenerateIndex();
5796
5956
  trackEvent("nest.publish", { nestId, count: created.length });
5797
5957
  return c.json(
5798
5958
  {
@@ -5809,6 +5969,30 @@ import { Hono as Hono12 } from "hono";
5809
5969
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5810
5970
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5811
5971
 
5972
+ // src/shared/node-id.ts
5973
+ function assertSafeNodeId(rawId) {
5974
+ let id = rawId;
5975
+ try {
5976
+ id = decodeURIComponent(rawId);
5977
+ } catch {
5978
+ }
5979
+ if (!id || id.length > 512) {
5980
+ throw new ValidationError("invalid node id");
5981
+ }
5982
+ if (id.includes("\0") || id.includes("\\")) {
5983
+ throw new ValidationError("invalid node id");
5984
+ }
5985
+ if (id.startsWith("/")) {
5986
+ throw new ValidationError("invalid node id (absolute path)");
5987
+ }
5988
+ for (const seg of id.split("/")) {
5989
+ if (seg === "" || seg === "." || seg === "..") {
5990
+ throw new ValidationError("invalid node id (path traversal)");
5991
+ }
5992
+ }
5993
+ return id;
5994
+ }
5995
+
5812
5996
  // src/telemetry/trace-log.ts
5813
5997
  var PRUNE_EVERY = 500;
5814
5998
  var insertsSincePrune = 0;
@@ -5887,30 +6071,6 @@ async function listTraceEvents(filters = {}) {
5887
6071
  // src/mcp/workflow-tools.ts
5888
6072
  import { v4 as uuid8 } from "uuid";
5889
6073
 
5890
- // src/shared/node-id.ts
5891
- function assertSafeNodeId(rawId) {
5892
- let id = rawId;
5893
- try {
5894
- id = decodeURIComponent(rawId);
5895
- } catch {
5896
- }
5897
- if (!id || id.length > 512) {
5898
- throw new ValidationError("invalid node id");
5899
- }
5900
- if (id.includes("\0") || id.includes("\\")) {
5901
- throw new ValidationError("invalid node id");
5902
- }
5903
- if (id.startsWith("/")) {
5904
- throw new ValidationError("invalid node id (absolute path)");
5905
- }
5906
- for (const seg of id.split("/")) {
5907
- if (seg === "" || seg === "." || seg === "..") {
5908
- throw new ValidationError("invalid node id (path traversal)");
5909
- }
5910
- }
5911
- return id;
5912
- }
5913
-
5914
6074
  // src/workflow/run-service.ts
5915
6075
  import { v4 as uuid6 } from "uuid";
5916
6076
  var RUNNABLE_TYPES = ["agent", "skill"];
@@ -6306,6 +6466,18 @@ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
6306
6466
  }
6307
6467
  }
6308
6468
  }
6469
+ async function assertNoDuplicateEdge(db, nestId, fromNode, toNode, type, excludeId) {
6470
+ const row = await db.get(
6471
+ `SELECT id FROM edges
6472
+ WHERE nest_id = ? AND from_node = ? AND to_node = ? AND type_id = ?` + (excludeId ? " AND id != ?" : ""),
6473
+ excludeId ? [nestId, fromNode, toNode, type.id, excludeId] : [nestId, fromNode, toNode, type.id]
6474
+ );
6475
+ if (row) {
6476
+ throw new ConflictError(
6477
+ `a "${type.name}" connection from "${fromNode}" to "${toNode}" already exists`
6478
+ );
6479
+ }
6480
+ }
6309
6481
  var edgeRoutes = new Hono11();
6310
6482
  edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
6311
6483
  const nestId = c.req.param("nestId");
@@ -6376,6 +6548,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
6376
6548
  const now = (/* @__PURE__ */ new Date()).toISOString();
6377
6549
  const id = uuid7();
6378
6550
  await db.transaction(async (tx) => {
6551
+ await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
6379
6552
  if (type.is_flow) {
6380
6553
  await assertNoFlowCycle(tx, nestId, fromNode, toNode);
6381
6554
  }
@@ -6403,6 +6576,8 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6403
6576
  "SELECT * FROM edge_types WHERE id = ?",
6404
6577
  [row.type_id]
6405
6578
  );
6579
+ const wasFlow = !!type.is_flow;
6580
+ let retype = null;
6406
6581
  if (typeof body.type === "string" && body.type.trim()) {
6407
6582
  const next = await db.get(
6408
6583
  "SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
@@ -6413,9 +6588,7 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6413
6588
  `unknown edge type "${body.type}" \u2014 define it in the registry first`
6414
6589
  );
6415
6590
  }
6416
- if (next.is_flow && !type.is_flow) {
6417
- await assertNoFlowCycle(db, nestId, row.from_node, row.to_node);
6418
- }
6591
+ if (next.id !== type.id) retype = next;
6419
6592
  type = next;
6420
6593
  }
6421
6594
  let mode = row.condition_mode;
@@ -6429,11 +6602,19 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6429
6602
  mode = v.mode;
6430
6603
  stored = v.stored;
6431
6604
  }
6432
- await db.run(
6433
- `UPDATE edges SET type_id = ?, condition_mode = ?, condition = ?, updated_at = ?
6434
- WHERE id = ?`,
6435
- [type.id, mode, stored, (/* @__PURE__ */ new Date()).toISOString(), id]
6436
- );
6605
+ await db.transaction(async (tx) => {
6606
+ if (retype) {
6607
+ await assertNoDuplicateEdge(tx, nestId, row.from_node, row.to_node, retype, id);
6608
+ if (retype.is_flow && !wasFlow) {
6609
+ await assertNoFlowCycle(tx, nestId, row.from_node, row.to_node);
6610
+ }
6611
+ }
6612
+ await tx.run(
6613
+ `UPDATE edges SET type_id = ?, condition_mode = ?, condition = ?, updated_at = ?
6614
+ WHERE id = ?`,
6615
+ [type.id, mode, stored, (/* @__PURE__ */ new Date()).toISOString(), id]
6616
+ );
6617
+ });
6437
6618
  const updated = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
6438
6619
  return c.json({ edge: toResponse(updated, type) });
6439
6620
  });
@@ -6768,6 +6949,7 @@ async function createEdge(ctx, args) {
6768
6949
  const now = (/* @__PURE__ */ new Date()).toISOString();
6769
6950
  const id = uuid8();
6770
6951
  await db.transaction(async (tx) => {
6952
+ await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
6771
6953
  if (type.is_flow) {
6772
6954
  await assertNoFlowCycle(tx, ctx.nestId, fromNode, toNode);
6773
6955
  }
@@ -6868,6 +7050,30 @@ function normalizeHops(raw) {
6868
7050
  if (!Number.isFinite(n)) return 2;
6869
7051
  return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
6870
7052
  }
7053
+ function toolError(text, code) {
7054
+ return { text, isError: true, data: { code, message: text } };
7055
+ }
7056
+ function toCallToolResult(out) {
7057
+ if (typeof out === "string") {
7058
+ return { content: [{ type: "text", text: out }] };
7059
+ }
7060
+ return {
7061
+ content: [{ type: "text", text: out.text }],
7062
+ structuredContent: out.data,
7063
+ ...out.isError ? { isError: true } : {}
7064
+ };
7065
+ }
7066
+ function summarizeNode(node, body) {
7067
+ return {
7068
+ id: node.id,
7069
+ title: node.frontmatter.title,
7070
+ description: node.frontmatter.description,
7071
+ type: node.frontmatter.type || "document",
7072
+ status: node.frontmatter.status || "draft",
7073
+ tags: node.frontmatter.tags,
7074
+ body: body ?? void 0
7075
+ };
7076
+ }
6871
7077
  var TOOL_DEFINITIONS = [
6872
7078
  {
6873
7079
  name: "context_init",
@@ -6936,12 +7142,23 @@ var TOOL_DEFINITIONS = [
6936
7142
  },
6937
7143
  {
6938
7144
  name: "context_list",
6939
- description: "Browse vault contents with optional type, tag, or limit filters.",
7145
+ description: "Browse vault contents with optional type, tag, status, or limit filters.",
6940
7146
  inputSchema: {
6941
7147
  type: "object",
6942
7148
  properties: {
6943
7149
  type: { type: "string", description: "Filter by node type" },
6944
- tag: { type: "string", description: "Filter by tag" },
7150
+ tag: {
7151
+ type: "string",
7152
+ description: "Filter by tag (leading # optional, case-insensitive)"
7153
+ },
7154
+ status: {
7155
+ type: "string",
7156
+ description: "Filter by status: draft | pending_review | approved | published | rejected. Aliases normalized. Unlike the CLI and the OSS server, retired nodes stay in this listing whether or not you ask \u2014 here they are unpublished documents their stewards may still act on."
7157
+ },
7158
+ approved_only: {
7159
+ type: "boolean",
7160
+ description: "Return only nodes with an approved version, each served at that version rather than the live file \u2014 the view a public nest gives a stranger."
7161
+ },
6945
7162
  limit: { type: "number", description: "Max nodes to return" }
6946
7163
  }
6947
7164
  }
@@ -7023,13 +7240,20 @@ var TOOL_DEFINITIONS = [
7023
7240
  },
7024
7241
  {
7025
7242
  name: "context_update",
7026
- description: "Update an existing node \u2014 append, replace content, add tags.",
7243
+ description: "Update an existing node \u2014 replace or append content, set tags, move it through its lifecycle. Whether the edit publishes is decided by this nest's stewardship rules, not by the caller.",
7027
7244
  inputSchema: {
7028
7245
  type: "object",
7029
7246
  properties: {
7030
7247
  title: {
7248
+ // Selects the node; it does NOT rename it. Titles are unique here
7249
+ // precisely so MCP callers can address a document this way. Renaming
7250
+ // is not reachable from this tool.
7031
7251
  type: "string",
7032
- description: "Title of node to update"
7252
+ description: "Title of node to update (selects it; does not rename)"
7253
+ },
7254
+ id: {
7255
+ type: "string",
7256
+ description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
7033
7257
  },
7034
7258
  content: {
7035
7259
  type: "string",
@@ -7041,9 +7265,19 @@ var TOOL_DEFINITIONS = [
7041
7265
  items: { type: "string" },
7042
7266
  description: "Tags to add"
7043
7267
  },
7044
- scope: { type: "string", description: "New scope" }
7045
- },
7046
- required: ["title"]
7268
+ status: {
7269
+ type: "string",
7270
+ description: "New lifecycle status: draft | pending_review | approved | published | rejected. Aliases are normalized."
7271
+ },
7272
+ changeNote: {
7273
+ type: "string",
7274
+ description: "Why this changed \u2014 recorded in version history"
7275
+ }
7276
+ }
7277
+ // Neither is required alone — one of them is (the engine catalog's
7278
+ // `.refine(id || title)`, which JSON Schema can't express here).
7279
+ // `title` was required, which made this tool unreachable for `ctx`:
7280
+ // it addresses nodes by id and never sends a title.
7047
7281
  }
7048
7282
  },
7049
7283
  {
@@ -7110,7 +7344,7 @@ var TOOL_DEFINITIONS = [
7110
7344
  // repo's governance tables, which the engine doesn't own. Adopt an engine
7111
7345
  // descriptor here if the operation is ever promoted upstream.
7112
7346
  name: "context_request_deletion",
7113
- description: "Ask for something to be deleted when you can't delete it yourself. Flags a document, a folder (with everything in it), or the whole nest for a nest owner/admin to action. Nothing is removed by this call \u2014 it opens a request, and the person who resolves it either deletes the target or declines with a note. Use this instead of context_delete when that returns a permission error, or when the document belongs to someone else.",
7347
+ description: "Ask for something to be deleted when you can't delete it yourself. Flags a document, a folder (with everything in it), or the whole nest for a nest owner/admin to action. Nothing is removed by this call \u2014 it opens a request, and the person who resolves it either deletes the target or rejects it with a note. Use this instead of context_delete when that returns a permission error, or when the document belongs to someone else.",
7114
7348
  inputSchema: {
7115
7349
  type: "object",
7116
7350
  properties: {
@@ -7143,14 +7377,14 @@ var TOOL_DEFINITIONS = [
7143
7377
  properties: {
7144
7378
  status: {
7145
7379
  type: "string",
7146
- description: "pending (default) or declined \u2014 declined shows past verdicts and their notes."
7380
+ description: '"pending" (default) or "declined" \u2014 past rejections and their notes.'
7147
7381
  }
7148
7382
  }
7149
7383
  }
7150
7384
  },
7151
7385
  {
7152
7386
  name: "context_resolve_deletion",
7153
- description: "Action a deletion request: delete the flagged document/folder/nest, or decline it with a note explaining why it stays. Owner/admin only. Declining tells the requester the verdict; deleting is irreversible.",
7387
+ description: "Action a deletion request: delete the flagged document/folder/nest, or reject it with a note explaining why it stays. Owner/admin only. Rejecting tells the requester the verdict; deleting is irreversible.",
7154
7388
  inputSchema: {
7155
7389
  type: "object",
7156
7390
  properties: {
@@ -7160,11 +7394,11 @@ var TOOL_DEFINITIONS = [
7160
7394
  },
7161
7395
  action: {
7162
7396
  type: "string",
7163
- description: '"delete" to honour it, "decline" to keep the target'
7397
+ description: '"delete" to honour it, "decline" to reject it and keep the target'
7164
7398
  },
7165
7399
  note: {
7166
7400
  type: "string",
7167
- description: "Why you declined. Required when action is decline."
7401
+ description: 'Why you rejected it. Required when action is "decline".'
7168
7402
  }
7169
7403
  },
7170
7404
  required: ["request_id", "action"]
@@ -7234,9 +7468,13 @@ var TOOL_DEFINITIONS = [
7234
7468
  inputSchema: {
7235
7469
  type: "object",
7236
7470
  properties: {
7237
- title: { type: "string", description: "Title of the node" }
7238
- },
7239
- required: ["title"]
7471
+ title: { type: "string", description: "Title of the node" },
7472
+ id: {
7473
+ type: "string",
7474
+ description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
7475
+ }
7476
+ }
7477
+ // One of id or title — see context_update.
7240
7478
  }
7241
7479
  },
7242
7480
  {
@@ -7347,7 +7585,7 @@ async function resolveTargetNode(storage, args) {
7347
7585
  if (!args.id && !args.title) return "Provide a title or an id.";
7348
7586
  if (args.id) {
7349
7587
  try {
7350
- return await storage.readDocument(args.id);
7588
+ return await storage.readDocument(assertSafeNodeId(args.id));
7351
7589
  } catch {
7352
7590
  return `Node not found: ${args.id}`;
7353
7591
  }
@@ -7365,36 +7603,47 @@ async function runTool(toolName, args, ctx) {
7365
7603
  const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
7366
7604
  switch (toolName) {
7367
7605
  case "context_init": {
7368
- const content = await storage.readContextMd();
7369
- if (!content)
7606
+ const { context_md } = await engineApi.run(
7607
+ "context_init",
7608
+ {},
7609
+ await opContext(nestId, userEmail)
7610
+ );
7611
+ if (!context_md)
7370
7612
  return "No CONTEXT.md found. Use context_overview to see what's available.";
7371
7613
  return `# Vault Instructions (CONTEXT.md)
7372
7614
 
7373
- ${content}`;
7615
+ ${context_md}`;
7374
7616
  }
7375
7617
  case "context_overview": {
7376
- const docs = await storage.discoverDocuments();
7618
+ const { total } = await engineApi.run(
7619
+ "context_init",
7620
+ {},
7621
+ await opContext(nestId, userEmail)
7622
+ );
7623
+ const visible = await listNodesForCallerByEmail(nestId, userEmail, {});
7377
7624
  const types = {};
7378
7625
  const tags = {};
7379
- for (const n of docs) {
7380
- const t = n.frontmatter.type || "document";
7381
- types[t] = (types[t] || 0) + 1;
7382
- for (const tag of n.frontmatter.tags || []) tags[tag] = (tags[tag] || 0) + 1;
7383
- }
7384
- const typeList = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([t, c]) => ` ${t}: ${c}`).join("\n");
7385
- const tagList = Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, c]) => ` ${t}: ${c}`).join("\n");
7386
- const nodeList = docs.map(
7387
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).slice(0, 4).join(" ")}
7388
- ${(n.body || "").slice(0, 120).replace(/\n/g, " ")}`
7626
+ for (const n of visible) {
7627
+ types[n.type] = (types[n.type] || 0) + 1;
7628
+ for (const tag of n.tags || []) tags[tag] = (tags[tag] || 0) + 1;
7629
+ }
7630
+ const fmt = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
7631
+ const nodeList = visible.map(
7632
+ (n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).slice(0, 4).join(" ")}
7633
+ ${(n.content || "").slice(0, 120).replace(/\n/g, " ")}`
7389
7634
  ).join("\n\n");
7635
+ const withheld = total - visible.length;
7636
+ const note = withheld > 0 ? `
7637
+ _${withheld} node(s) not visible to you._
7638
+ ` : "";
7390
7639
  return `# Vault Overview
7391
- **Total nodes:** ${docs.length}
7392
-
7640
+ **Total nodes:** ${visible.length}
7641
+ ${note}
7393
7642
  ## Types
7394
- ${typeList}
7643
+ ${fmt(types)}
7395
7644
 
7396
7645
  ## Tags
7397
- ${tagList}
7646
+ ${fmt(tags)}
7398
7647
 
7399
7648
  ## All Nodes
7400
7649
 
@@ -7416,14 +7665,26 @@ ${nodeList}`;
7416
7665
  ].join(" ").toLowerCase();
7417
7666
  return terms.every((t) => hay.includes(t));
7418
7667
  });
7419
- if (!matches.length) return `No nodes matched search: "${args.query}"`;
7668
+ if (!matches.length)
7669
+ return {
7670
+ text: `No nodes matched search: "${args.query}"`,
7671
+ data: { results: [] }
7672
+ };
7420
7673
  const results = matches.map(
7421
7674
  ({ node: n, body }, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
7422
7675
  ${(body || "").slice(0, 200).replace(/\n/g, " ")}`
7423
7676
  ).join("\n\n");
7424
- return `Found ${matches.length} node(s) matching "${args.query}":
7425
-
7426
- ${results}`;
7677
+ return {
7678
+ text: `Found ${matches.length} node(s) matching "${args.query}":
7679
+
7680
+ ${results}`,
7681
+ // Same gated bodies the prose is built from — `resolveLlmBody`
7682
+ // already refused everything this caller may not read. No `score`:
7683
+ // this is a term-containment filter, not a ranked search.
7684
+ data: {
7685
+ results: matches.map(({ node: n, body }) => summarizeNode(n, body))
7686
+ }
7687
+ };
7427
7688
  }
7428
7689
  case "context_table_query": {
7429
7690
  const result = await queryTableNode(
@@ -7440,61 +7701,119 @@ ${results}`;
7440
7701
  ${lines.join("\n")}`;
7441
7702
  }
7442
7703
  case "context_query": {
7443
- const result = await queryEngine.query(args.query, {
7444
- hops: normalizeHops(args.hops)
7445
- });
7704
+ const { documents, traversal } = await engineApi.run(
7705
+ "context_query",
7706
+ { query: args.query, hops: normalizeHops(args.hops) },
7707
+ await opContext(nestId, userEmail)
7708
+ );
7446
7709
  const visibility = await Promise.all(
7447
- result.documents.map(async (n) => ({
7448
- node: n,
7449
- visible: await resolveLlmBody(ctx, n) !== null
7710
+ documents.map(async (d) => ({
7711
+ doc: d,
7712
+ body: await resolveLlmBody(ctx, {
7713
+ id: d.id,
7714
+ body: d.body ?? "",
7715
+ frontmatter: { title: d.title }
7716
+ })
7450
7717
  }))
7451
7718
  );
7452
- const nodes = visibility.filter((e) => e.visible).map((e) => e.node);
7453
- if (!nodes.length) return `No nodes matched: ${args.query}`;
7454
- const list = nodes.map(
7455
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}`
7456
- ).join("\n\n");
7457
- return `Found ${nodes.length} node(s) for \`${args.query}\`:
7458
-
7459
- ${list}`;
7719
+ const visible = visibility.filter((e) => e.body !== null);
7720
+ const nodes = visible.map((e) => e.doc);
7721
+ if (!nodes.length)
7722
+ return {
7723
+ text: `No nodes matched: ${args.query}`,
7724
+ data: { documents: [], traversal }
7725
+ };
7726
+ const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).join(" ")}`).join("\n\n");
7727
+ return {
7728
+ text: `Found ${nodes.length} node(s) for \`${args.query}\`:
7729
+
7730
+ ${list}`,
7731
+ // The engine already hands back catalog `nodeSummary` shapes — only
7732
+ // `body` is swapped for the gated one resolveLlmBody returned.
7733
+ // `source_nodes` is deliberately absent: this handler never gates
7734
+ // them, so serving them would hand out content the prose refuses.
7735
+ data: {
7736
+ documents: visible.map((e) => ({ ...e.doc, body: e.body ?? void 0 })),
7737
+ traversal
7738
+ }
7739
+ };
7460
7740
  }
7461
7741
  case "context_get": {
7462
- const docs = await storage.discoverDocuments();
7463
7742
  let node;
7464
- if (args.title) {
7465
- node = docs.find(
7466
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7743
+ try {
7744
+ node = await getNodeForCaller(
7745
+ nestId,
7746
+ args.title ? { title: args.title } : { id: args.id },
7747
+ ctx.userId,
7748
+ userEmail
7749
+ );
7750
+ } catch {
7751
+ return toolError(
7752
+ `Node not found: ${args.title || args.id}`,
7753
+ "DOCUMENT_NOT_FOUND"
7467
7754
  );
7468
- } else if (args.id) {
7469
- node = docs.find((n) => n.id === args.id);
7470
7755
  }
7471
- if (!node) return `Node not found: ${args.title || args.id}`;
7472
7756
  const body = await resolveLlmBody(ctx, node);
7473
7757
  if (body === null) {
7474
- return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
7758
+ return toolError(
7759
+ `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`,
7760
+ "UNAUTHORIZED_ACTION"
7761
+ );
7475
7762
  }
7476
7763
  const meta = [
7477
7764
  `**Title:** ${node.frontmatter.title}`,
7478
7765
  `**Type:** ${node.frontmatter.type || "document"}`,
7479
7766
  node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null
7480
7767
  ].filter(Boolean).join("\n");
7481
- return `${meta}
7768
+ return {
7769
+ text: `${meta}
7482
7770
 
7483
7771
  ---
7484
7772
 
7485
- ${body || "(no content)"}`;
7773
+ ${body || "(no content)"}`,
7774
+ // `body` is the gated one, not node.body — under stewardship that is
7775
+ // the APPROVED snapshot, which can be older than the live draft this
7776
+ // frontmatter came from. So the payload carries only the fields the
7777
+ // prose above already reports, none of which claim to describe a
7778
+ // specific version. `version`, `status`, `checksum` and the
7779
+ // timestamps are deliberately absent: they describe the draft, and
7780
+ // pairing them with an approved body would misdescribe it (checksum
7781
+ // would also be a draft hash — see context_versions). `raw` is absent
7782
+ // too: no include_raw input here (Phase 2).
7783
+ data: {
7784
+ id: node.id,
7785
+ frontmatter: {
7786
+ title: node.frontmatter.title,
7787
+ description: node.frontmatter.description,
7788
+ type: node.frontmatter.type || "document",
7789
+ tags: node.frontmatter.tags
7790
+ },
7791
+ body
7792
+ }
7793
+ };
7486
7794
  }
7487
7795
  case "context_list": {
7488
7796
  const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
7489
7797
  type: args.type,
7490
7798
  tag: args.tag,
7799
+ status: args.status,
7800
+ approvedOnly: args.approved_only === true,
7491
7801
  limit: args.limit || 50
7492
7802
  });
7493
- if (!nodes.length) return "No nodes found with the given filters.";
7803
+ const documents = nodes.map((n) => ({
7804
+ id: n.id,
7805
+ title: n.title,
7806
+ description: n.description,
7807
+ type: n.type || "document",
7808
+ status: n.status || "draft",
7809
+ tags: n.tags
7810
+ }));
7811
+ if (!nodes.length)
7812
+ return { text: "No nodes found with the given filters.", data: { documents } };
7494
7813
  const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
7495
- return `${nodes.length} node(s):
7814
+ return { text: `${nodes.length} node(s):
7496
7815
 
7497
- ${list}`;
7816
+ ${list}`, data: { documents } };
7498
7817
  }
7499
7818
  case "context_resolve": {
7500
7819
  const result = await queryEngine.query(args.selector, {
@@ -7592,7 +7911,10 @@ ${sections}`;
7592
7911
  }
7593
7912
  case "context_create": {
7594
7913
  if (!await canCreateInNest(nestId, userEmail)) {
7595
- return "You don't have permission to create documents in this nest.";
7914
+ return toolError(
7915
+ "You don't have permission to create documents in this nest.",
7916
+ "UNAUTHORIZED_ACTION"
7917
+ );
7596
7918
  }
7597
7919
  const wanted = String(args.title ?? "").toLowerCase();
7598
7920
  return withNodeWriteLock(nodeWriteKey(nestId, "*create*"), async () => {
@@ -7601,9 +7923,12 @@ ${sections}`;
7601
7923
  (n) => String(n.frontmatter.title ?? "").toLowerCase() === wanted
7602
7924
  );
7603
7925
  if (twin) {
7604
- return `A node titled "${twin.frontmatter.title}" already exists (${twin.id}). Titles must be unique \u2014 MCP tools resolve documents by title, so a second one would be unreachable. Use context_update to change it, context_move to refile it, or create this one under a different title.`;
7926
+ return toolError(
7927
+ `A node titled "${twin.frontmatter.title}" already exists (${twin.id}). Titles must be unique \u2014 MCP tools resolve documents by title, so a second one would be unreachable. Use context_update to change it, context_move to refile it, or create this one under a different title.`,
7928
+ "DOCUMENT_ALREADY_EXISTS"
7929
+ );
7605
7930
  }
7606
- const { node } = await createNode(
7931
+ const { node, version } = await createNode(
7607
7932
  nestId,
7608
7933
  {
7609
7934
  title: args.title,
@@ -7615,42 +7940,75 @@ ${sections}`;
7615
7940
  },
7616
7941
  userEmail
7617
7942
  );
7618
- return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
7943
+ return {
7944
+ text: `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`,
7945
+ // checkpoint is null by construction: this server seals nothing —
7946
+ // a write becomes live through steward review, not a checkpoint.
7947
+ data: {
7948
+ id: node.id,
7949
+ version,
7950
+ status: node.frontmatter.status,
7951
+ checkpoint: null
7952
+ }
7953
+ };
7619
7954
  });
7620
7955
  }
7621
7956
  case "context_update": {
7622
- const docs = await storage.discoverDocuments();
7623
- const node = docs.find(
7624
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7625
- );
7626
- if (!node) return `Node not found: ${args.title}`;
7957
+ const node = await resolveTargetNode(storage, args);
7958
+ if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
7959
+ const label = args.id ? node.frontmatter.title : args.title;
7627
7960
  const editCheck = await canUserEdit(nestId, node.id, userEmail);
7628
7961
  if (!editCheck.allowed) {
7629
- return `You don't have permission to edit "${args.title}": ${editCheck.reason}`;
7962
+ return toolError(
7963
+ `You don't have permission to edit "${label}": ${editCheck.reason}`,
7964
+ "UNAUTHORIZED_ACTION"
7965
+ );
7630
7966
  }
7631
- const { node: updated } = await updateNode(
7967
+ const mergedTags = args.tags ? [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...args.tags])] : void 0;
7968
+ const { node: updated, version } = await updateNode(
7632
7969
  nestId,
7633
7970
  node.id,
7634
7971
  {
7635
7972
  content: args.content,
7636
7973
  append: args.append,
7637
- tags: args.tags
7974
+ tags: mergedTags,
7975
+ status: args.status,
7976
+ changeNote: args.changeNote
7638
7977
  },
7639
7978
  userEmail
7640
7979
  );
7641
- return `Updated node: **${updated.frontmatter.title}**`;
7980
+ return {
7981
+ text: `Updated node: **${updated.frontmatter.title}** \u2014 v${version}, status: ${updated.frontmatter.status}`,
7982
+ // checkpoint null — see context_create.
7983
+ data: {
7984
+ id: updated.id,
7985
+ version,
7986
+ status: updated.frontmatter.status,
7987
+ checkpoint: null
7988
+ }
7989
+ };
7642
7990
  }
7643
7991
  case "context_delete": {
7644
7992
  const node = await resolveTargetNode(storage, args);
7645
- if (typeof node === "string") return node;
7993
+ if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
7646
7994
  const deleteCheck = await canUserEdit(nestId, node.id, userEmail);
7647
7995
  if (!deleteCheck.allowed) {
7648
- return `You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
7996
+ return toolError(
7997
+ `You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
7649
7998
 
7650
- Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`;
7999
+ Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`,
8000
+ "UNAUTHORIZED_ACTION"
8001
+ );
7651
8002
  }
7652
8003
  await deleteNode(nestId, node.id, userEmail);
7653
- return `Deleted node: **${node.frontmatter.title}** (${node.id})`;
8004
+ return {
8005
+ text: `Deleted node: **${node.frontmatter.title}** (${node.id})`,
8006
+ data: {
8007
+ id: node.id,
8008
+ title: node.frontmatter.title,
8009
+ deleted: true
8010
+ }
8011
+ };
7654
8012
  }
7655
8013
  case "context_move": {
7656
8014
  if (typeof args.folder !== "string") {
@@ -7726,7 +8084,7 @@ ${sections}`;
7726
8084
  reason,
7727
8085
  baseUrl: ctx.baseUrl
7728
8086
  });
7729
- return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or decline.
8087
+ return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or reject the request.
7730
8088
  Request id: \`${request2.id}\``;
7731
8089
  }
7732
8090
  if (typeof args.folder === "string" && args.folder.trim()) {
@@ -7744,7 +8102,7 @@ Request id: \`${request2.id}\``;
7744
8102
  reason,
7745
8103
  baseUrl: ctx.baseUrl
7746
8104
  });
7747
- return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or decline.
8105
+ return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or reject the request.
7748
8106
  Request id: \`${request2.id}\``;
7749
8107
  }
7750
8108
  const node = await resolveTargetNode(storage, args);
@@ -7760,19 +8118,19 @@ Request id: \`${request2.id}\``;
7760
8118
  reason,
7761
8119
  baseUrl: ctx.baseUrl
7762
8120
  });
7763
- return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or decline \u2014 nothing is removed yet.
8121
+ return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or reject the request \u2014 nothing is removed yet.
7764
8122
  Request id: \`${request.id}\``;
7765
8123
  }
7766
8124
  case "context_deletion_queue": {
7767
8125
  const status = args.status === "declined" ? "declined" : "pending";
7768
8126
  const requests = await listDeletionRequests({ nestId, status });
7769
8127
  if (requests.length === 0) {
7770
- return status === "pending" ? "No deletion requests waiting. All caught up!" : "No declined deletion requests.";
8128
+ return status === "pending" ? "No deletion requests waiting. All caught up!" : "No rejected deletion requests.";
7771
8129
  }
7772
8130
  const list = requests.map((r, i) => {
7773
8131
  const kind = r.targetType === "document" ? "" : `${r.targetType}: `;
7774
8132
  const verdict = r.status === "declined" ? `
7775
- Declined by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
8133
+ Rejected by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
7776
8134
  return `${i + 1}. **${kind}${r.title || r.nodeId}**
7777
8135
  Asked by ${r.requestedBy}: "${r.reason}"
7778
8136
  Request id: \`${r.id}\`${verdict}`;
@@ -7800,7 +8158,7 @@ ${list}`;
7800
8158
  if (action === "decline") {
7801
8159
  const note = typeof args.note === "string" ? args.note.trim() : "";
7802
8160
  if (!note) {
7803
- return "note is required when declining \u2014 the requester is told why it stays.";
8161
+ return "note is required when rejecting \u2014 the requester is told why it stays.";
7804
8162
  }
7805
8163
  await declineDeletion({
7806
8164
  nestId,
@@ -7809,7 +8167,7 @@ ${list}`;
7809
8167
  note,
7810
8168
  baseUrl: ctx.baseUrl
7811
8169
  });
7812
- return `Declined the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
8170
+ return `Rejected the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
7813
8171
  }
7814
8172
  if (request.targetType === "nest") {
7815
8173
  await deleteNest(nestId);
@@ -7880,13 +8238,17 @@ Submit one with \`context_submit_review\`.`;
7880
8238
  const node = docs.find(
7881
8239
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7882
8240
  );
7883
- if (!node) return `Node not found: ${args.title}`;
8241
+ if (!node) return toolError(`Node not found: ${args.title}`, "DOCUMENT_NOT_FOUND");
7884
8242
  const submitCheck = await canUserEdit(ctx.nestId, node.id, userEmail);
7885
8243
  if (!submitCheck.allowed) {
7886
- return `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`;
8244
+ return toolError(
8245
+ `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`,
8246
+ "UNAUTHORIZED_ACTION"
8247
+ );
7887
8248
  }
7888
8249
  const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
7889
- if (currentVersion === 0) return `No versions found for "${args.title}"`;
8250
+ if (currentVersion === 0)
8251
+ return toolError(`No versions found for "${args.title}"`, "VERSION_NOT_FOUND");
7890
8252
  try {
7891
8253
  const request = await submitForReview({
7892
8254
  nestId: ctx.nestId,
@@ -7905,9 +8267,33 @@ Submit one with \`context_submit_review\`.`;
7905
8267
 
7906
8268
  **Stewards who can review:**
7907
8269
  ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
7908
- return `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`;
8270
+ return {
8271
+ text: `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
8272
+ // No catalog shape to conform to — `context_submit_review` is a
8273
+ // governance-namespace operation and that namespace is declared but
8274
+ // unimplemented in the engine (`api/index.ts`, NAMESPACES). So this
8275
+ // is the review state the handler already computed, and nothing
8276
+ // more: no `version`/`checkpoint` at the top level, because this
8277
+ // operation writes no new version and seals no checkpoint — it
8278
+ // points a review request at the version that already existed.
8279
+ // Built from the same gated values the prose above is built from
8280
+ // (`request`, `resolved`), never re-read past the permission check.
8281
+ data: {
8282
+ id: node.id,
8283
+ submitted: true,
8284
+ review: {
8285
+ id: request.id,
8286
+ version: request.version,
8287
+ status: request.status,
8288
+ priority: request.priority,
8289
+ requested_by: request.requestedBy,
8290
+ requested_at: request.requestedAt
8291
+ },
8292
+ reviewers: resolved.map((r) => r.steward.userEmail)
8293
+ }
8294
+ };
7909
8295
  } catch (err) {
7910
- return `Failed to submit: ${err.message}`;
8296
+ return toolError(`Failed to submit: ${err.message}`, "INTERNAL");
7911
8297
  }
7912
8298
  }
7913
8299
  case "context_approve": {
@@ -7951,23 +8337,46 @@ Reason: ${args.note}`;
7951
8337
  }
7952
8338
  }
7953
8339
  case "context_versions": {
7954
- const docs = await storage.discoverDocuments();
7955
- const node = docs.find(
7956
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7957
- );
7958
- if (!node) return `Node not found: ${args.title}`;
8340
+ const node = await resolveTargetNode(storage, args);
8341
+ if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
8342
+ const label = args.id ? node.frontmatter.title : args.title;
7959
8343
  const allVersions = await getVersions(ctx.nestId, node.id);
7960
8344
  const approved = await getApprovedVersion(ctx.nestId, node.id);
7961
8345
  if (allVersions.length === 0) {
7962
- return `No version history for "${args.title}".`;
8346
+ return {
8347
+ text: `No version history for "${label}".`,
8348
+ data: { id: node.id, approved_version: approved ?? null, versions: [] }
8349
+ };
7963
8350
  }
7964
8351
  const list = allVersions.map(
7965
8352
  (v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}`
7966
8353
  ).join("\n");
7967
- return `# Version History: ${args.title}
8354
+ return {
8355
+ text: `# Version History: ${label}
7968
8356
  **Current:** v${allVersions[0]?.version || 0} | **AI-active:** ${approved ? `v${approved}` : "none"}
7969
8357
 
7970
- ${list}`;
8358
+ ${list}`,
8359
+ // Five catalog fields have no equivalent here and are omitted rather
8360
+ // than faked: `keyframe_interval` and `keyframe` (content lives on
8361
+ // disk — there is no keyframe+diff model), `chain_hash` (integrity is
8362
+ // server-side, not a per-version chain), `content_hash` (the column
8363
+ // exists but is conflict-detection state this tool has never served,
8364
+ // and it is ungated), and `published_at` (a version carries a
8365
+ // lifecycle status, not a publish timestamp). `status` and
8366
+ // `approved_version` are the community equivalent — the same two
8367
+ // facts the prose above reports.
8368
+ data: {
8369
+ id: node.id,
8370
+ approved_version: approved ?? null,
8371
+ versions: allVersions.map((v) => ({
8372
+ version: v.version,
8373
+ status: v.status,
8374
+ edited_by: v.editedBy,
8375
+ edited_at: v.editedAt,
8376
+ note: v.changeNote
8377
+ }))
8378
+ }
8379
+ };
7971
8380
  }
7972
8381
  case "context_assign_steward": {
7973
8382
  const scope = args.scope;
@@ -8025,8 +8434,8 @@ ${list}`;
8025
8434
  if (!target) return "target is required (a node id or folder prefix).";
8026
8435
  if (!["read", "write"].includes(role)) return "role must be read or write.";
8027
8436
  try {
8028
- const { createGrant: createGrant2 } = await import("./grants-service-O4AO326A.js");
8029
- const db = (await import("./client-NFUHPLLM.js")).getDb();
8437
+ const { createGrant: createGrant2 } = await import("./grants-service-I535QVTW.js");
8438
+ const db = (await import("./client-OBS5HOTA.js")).getDb();
8030
8439
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
8031
8440
  const e = normalizeEmail2(String(args.email || ""));
8032
8441
  if (!e) return "email is required.";
@@ -8119,7 +8528,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
8119
8528
  }
8120
8529
  server.tool(tool.name, tool.description, shape, async (args) => {
8121
8530
  const engine = await engineCache.get(nestId);
8122
- const text = await handleToolCall(tool.name, args, {
8531
+ const out = await handleToolCall(tool.name, args, {
8123
8532
  storage: engine.storage,
8124
8533
  queryEngine: engine.query,
8125
8534
  versionManager: engine.versions,
@@ -8128,7 +8537,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
8128
8537
  userEmail,
8129
8538
  baseUrl
8130
8539
  });
8131
- return { content: [{ type: "text", text }] };
8540
+ return toCallToolResult(out);
8132
8541
  });
8133
8542
  }
8134
8543
  return server;
@@ -9487,7 +9896,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
9487
9896
  shape[key] = field;
9488
9897
  }
9489
9898
  server.tool(tool.name, tool.description, shape, async (args) => {
9490
- const text = await handleServerToolCall(
9899
+ const out = await handleServerToolCall(
9491
9900
  tool.name,
9492
9901
  args,
9493
9902
  userId,
@@ -9495,7 +9904,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
9495
9904
  nestScope,
9496
9905
  baseUrl
9497
9906
  );
9498
- return { content: [{ type: "text", text }] };
9907
+ return toCallToolResult(out);
9499
9908
  });
9500
9909
  }
9501
9910
  return server;
@@ -10424,7 +10833,14 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
10424
10833
  return c.json({
10425
10834
  versions: enriched,
10426
10835
  approvedVersion: approved,
10427
- currentVersion: allVersions[0]?.version || 0
10836
+ currentVersion: allVersions[0]?.version || 0,
10837
+ // Verdicts on out-of-band edits. A rejection commits no content, so it has
10838
+ // no version row to hang off — it lives in the engine's chain-event log and
10839
+ // rides alongside the version list rather than inside it.
10840
+ externalEditVerdicts: await listExternalEditVerdicts(
10841
+ nestId,
10842
+ nodeId
10843
+ )
10428
10844
  });
10429
10845
  });
10430
10846
  governanceNodeRoutes.get("/:nodeId{.+}/versions/:version{[0-9]+}", async (c) => {
@@ -10606,7 +11022,9 @@ governanceNodeRoutes.post(
10606
11022
  return c.json({
10607
11023
  approved: true,
10608
11024
  version: result.versionEntry.version,
10609
- chainEvent: result.chainEvent.event_id
11025
+ chainEvent: result.chainEvent.event_id,
11026
+ // As on reject: false means the verdict never reached the audit log.
11027
+ historyRecorded: result.historyRecorded
10610
11028
  });
10611
11029
  } catch (err) {
10612
11030
  console.error(
@@ -10641,7 +11059,10 @@ governanceNodeRoutes.post(
10641
11059
  });
10642
11060
  return c.json({
10643
11061
  rejected: true,
10644
- chainEvent: result.chainEvent.event_id
11062
+ chainEvent: result.chainEvent.event_id,
11063
+ // False means the edit was rejected but the audit entry did not land —
11064
+ // the caller is not shown a clean success (server log carries why).
11065
+ historyRecorded: result.historyRecorded
10645
11066
  });
10646
11067
  } catch (err) {
10647
11068
  return c.json({ error: err.message }, 400);
@@ -10886,6 +11307,13 @@ function createApp() {
10886
11307
  "*",
10887
11308
  corsOrigins === "*" ? cors({ origin: "*" }) : cors({ origin: corsOrigins, credentials: true })
10888
11309
  );
11310
+ const frameAncestors = `frame-ancestors ${config.FRAME_ANCESTORS}`;
11311
+ app.use("*", async (c, next) => {
11312
+ await next();
11313
+ const existing = c.res.headers.get("Content-Security-Policy");
11314
+ const merged = mergeFrameAncestors(existing, frameAncestors);
11315
+ if (merged !== existing) c.header("Content-Security-Policy", merged);
11316
+ });
10889
11317
  const ASSET_UPLOAD_PATH = /^\/nests\/[^/]+\/assets\/?$/;
10890
11318
  app.use("*", async (c, next) => {
10891
11319
  const lenStr = c.req.header("Content-Length");
@@ -11666,6 +12094,38 @@ function createApp() {
11666
12094
  users: usersRow.c
11667
12095
  });
11668
12096
  });
12097
+ app.use("/search", flexAuthMiddleware);
12098
+ app.get("/search", async (c) => {
12099
+ const q = (c.req.query("q") || "").trim();
12100
+ if (!q) throw new ValidationError("q query parameter is required");
12101
+ const limit = Math.min(Math.max(Number(c.req.query("limit")) || 20, 1), 50);
12102
+ const userId = c.get("userId");
12103
+ const userEmail = await resolveCallerEmail(userId);
12104
+ const perNest = await Promise.all(
12105
+ (await listVisibleNests(userId)).map(async (nest) => {
12106
+ try {
12107
+ const { storage } = await engineCache.get(nest.id);
12108
+ const matches = matchDocuments(await storage.discoverDocuments(), q);
12109
+ if (matches.length === 0) return [];
12110
+ const accessible = await filterAccessible(nest.id, userId, userEmail, matches);
12111
+ return accessible.map((n) => ({
12112
+ ...toSearchHit(n),
12113
+ nest_id: nest.id,
12114
+ nest_name: nest.name ?? nest.id
12115
+ }));
12116
+ } catch (e) {
12117
+ console.error("[search] skipped nest", nest.id, e);
12118
+ return [];
12119
+ }
12120
+ })
12121
+ );
12122
+ const hits = perNest.flat();
12123
+ return c.json({
12124
+ query: q,
12125
+ count: hits.length,
12126
+ nodes: hits.slice(0, limit)
12127
+ });
12128
+ });
11669
12129
  app.use("/runs/*", flexAuthMiddleware);
11670
12130
  app.route("/runs", runDetailRoutes);
11671
12131
  app.route("/hooks", hookFireRoutes);
@@ -11877,6 +12337,7 @@ function createApp() {
11877
12337
  nestsApp.route("/:nestId/assets", assetRoutes);
11878
12338
  nestsApp.route("/:nestId/mcp", mcpRoutes);
11879
12339
  app.route("/nests", nestsApp);
12340
+ app.all("/.well-known/*", (c) => c.json({ error: "Not found" }, 404));
11880
12341
  app.use(
11881
12342
  "/assets/*",
11882
12343
  serveStatic({
@@ -11884,14 +12345,20 @@ function createApp() {
11884
12345
  onFound: (_p, c) => c.header("Cache-Control", "public, max-age=31536000, immutable")
11885
12346
  })
11886
12347
  );
12348
+ const isDocumentNavigation = (c) => c.req.header("Sec-Fetch-Mode") === "navigate" || (c.req.header("Accept") || "").includes("text/html");
11887
12349
  app.get(
11888
12350
  "*",
12351
+ async (c, next) => {
12352
+ if (isDocumentNavigation(c)) return next();
12353
+ return c.json({ error: "Not found" }, 404);
12354
+ },
11889
12355
  serveStatic({
11890
12356
  root: UI_DIR_REL,
11891
12357
  path: "index.html",
11892
12358
  onFound: (_p, c) => c.header("Cache-Control", "no-cache")
11893
12359
  })
11894
12360
  );
12361
+ app.notFound((c) => c.json({ error: "Not found" }, 404));
11895
12362
  app.onError((err, c) => {
11896
12363
  if (err instanceof AppError) {
11897
12364
  return c.json({ error: err.message }, err.statusCode);
@@ -12327,6 +12794,7 @@ async function main() {
12327
12794
  POST /nests/:id/query Selector query
12328
12795
  POST /nests/:id/context One-call context retrieval
12329
12796
  GET /nests/:id/search?q= Full-text search
12797
+ GET /search?q= Search every nest you can see
12330
12798
  POST /nests/:id/publish Bulk publish
12331
12799
  POST /nests/:id/mcp MCP endpoint
12332
12800
  GET /health Health check