@promptowl/contextnest-community 1.23.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +2195 -0
- package/CONFIGURATION.md +8 -2
- package/README.md +62 -10
- package/STEWARDSHIP.md +253 -0
- package/dist/{chunk-2YPY2HGZ.js → chunk-2KHM5C7V.js} +40 -5
- package/dist/{chunk-XYD6V2LH.js → chunk-32SZCH36.js} +28 -33
- package/dist/{chunk-QLXC6542.js → chunk-7FROMOZM.js} +69 -27
- package/dist/{chunk-MLQU4I5J.js → chunk-DTLMBF4W.js} +10 -8
- package/dist/{chunk-QWNXWRWO.js → chunk-JDAOA5KP.js} +67 -1
- package/dist/{chunk-UOMW7TT6.js → chunk-KPGNZLZL.js} +2 -2
- package/dist/{chunk-YVMSM7LS.js → chunk-LHWOJPFE.js} +7 -0
- package/dist/{chunk-5CVZMHHB.js → chunk-TI7HMP64.js} +99 -12
- package/dist/{client-LWDMNKX3.js → client-D44ZHDTJ.js} +1 -1
- package/dist/{engine-QFF2IA3G.js → engine-TER6FDBP.js} +3 -3
- package/dist/{external-edit-service-F7D3VSA2.js → external-edit-service-CAGENHDJ.js} +4 -4
- package/dist/{grants-service-TBGTCO5K.js → grants-service-2OTFS7EH.js} +3 -3
- package/dist/index.js +1120 -331
- package/dist/{migrations.postgres-APCVSYUE.js → migrations.postgres-23VJJJJX.js} +56 -5
- package/dist/{review-service-A7PNLWG5.js → review-service-4G2XUFS6.js} +7 -7
- package/dist/{stewardship-service-32UHIRQ5.js → stewardship-service-S22V2JKG.js} +6 -4
- package/dist/{version-service-RJ6CWZ3O.js → version-service-X4FAG2OZ.js} +4 -4
- package/dist/web3/assets/ActivityTracePage-D2iIbEph.js +1 -0
- package/dist/web3/assets/AgentDocsPage-Dkhgb1hk.js +1 -0
- package/dist/web3/assets/CollaboratorManager-Bo81D--Q.js +1 -0
- package/dist/web3/assets/CollaboratorsTab-DRCitQuX.js +1 -0
- package/dist/web3/assets/DocumentEditor-DV2WHYbj.js +36 -0
- package/dist/web3/assets/DocumentsTab-BUxBCN8o.js +6 -0
- package/dist/web3/assets/ExternalEditsTab-kIBimZyE.js +1 -0
- package/dist/web3/assets/MarkdownEditor-CsVusP2d.css +1 -0
- package/dist/web3/assets/MarkdownEditor-Dfk5uIE5.js +648 -0
- package/dist/web3/assets/NestPageHeader-yOK-OIYZ.js +1 -0
- package/dist/web3/assets/NestView-DwIagxwJ.js +68 -0
- package/dist/web3/assets/OverviewTab-DFOFMVyE.js +1 -0
- package/dist/web3/assets/PersonCombobox-D350WlrJ.js +1 -0
- package/dist/web3/assets/ReasonDialog-CN3ECAnQ.js +1 -0
- package/dist/web3/assets/ReviewActions-BXDTDPp1.js +6 -0
- package/dist/web3/assets/ReviewTab-CZewAYiz.js +1 -0
- package/dist/web3/assets/StewardsTab-AagNWcWS.js +1 -0
- package/dist/web3/assets/SubmitForReviewModal-BOZJxwRN.js +1 -0
- package/dist/web3/assets/alert-dialog-4SiLKlz8.js +7 -0
- package/dist/web3/assets/arrow-left-BAgIocLn.js +6 -0
- package/dist/web3/assets/backlinks-Cb8Uf8mw.js +24 -0
- package/dist/web3/assets/card-UDyMRcps.js +1 -0
- package/dist/web3/assets/chevron-left-XR4ReJ9Z.js +6 -0
- package/dist/web3/assets/circle-check-CSkZUEFK.js +6 -0
- package/dist/web3/assets/circle-x-in2o3aHU.js +6 -0
- package/dist/web3/assets/client-attribution-BraarYaP.js +1 -0
- package/dist/web3/assets/code-xml-pPQgdctI.js +6 -0
- package/dist/web3/assets/corner-down-right-CuxZWscX.js +6 -0
- package/dist/web3/assets/count-skeleton-DBWCDhRy.js +1 -0
- package/dist/web3/assets/dates-BCxbm4_q.js +1 -0
- package/dist/web3/assets/earth-CXOcThqn.js +6 -0
- package/dist/web3/assets/file-exclamation-point-CSphs1VD.js +6 -0
- package/dist/web3/assets/folder-input-BAwK9tFL.js +11 -0
- package/dist/web3/assets/folder-target-CUSWqImF.js +1 -0
- package/dist/web3/assets/index-A0_ymcqL.js +29 -0
- package/dist/web3/assets/index-B5hLclEq.js +389 -0
- package/dist/web3/assets/index-DrUAtoQM.css +1 -0
- package/dist/web3/assets/page-BHD4beun.js +1 -0
- package/dist/web3/assets/page-BSYUXYmO.js +11 -0
- package/dist/web3/assets/page-BYc2DIux.js +45 -0
- package/dist/web3/assets/page-BfibLg8e.js +1 -0
- package/dist/web3/assets/page-CGe8MtUu.js +9 -0
- package/dist/web3/assets/page-CIgPJmG6.js +2 -0
- package/dist/web3/assets/page-CUjuSs5Q.js +24 -0
- package/dist/web3/assets/page-Ck31nx9b.js +1 -0
- package/dist/web3/assets/page-Cpdj_11Y.js +1 -0
- package/dist/web3/assets/page-D0sN3GO6.js +1 -0
- package/dist/web3/assets/page-DEpH91X2.js +1 -0
- package/dist/web3/assets/page-l8-B7CGt.js +1 -0
- package/dist/web3/assets/page-mnSHmk6A.js +6 -0
- package/dist/web3/assets/page-title-BkqlUA0j.js +1 -0
- package/dist/web3/assets/page-xzYqkBUP.js +16 -0
- package/dist/web3/assets/play-B8xE9j5f.js +6 -0
- package/dist/web3/assets/refresh-cw-CsCtmWwX.js +6 -0
- package/dist/web3/assets/scroll-area-CbcH6yrZ.js +1 -0
- package/dist/web3/assets/scroll-area-dRWncRqa.css +1 -0
- package/dist/web3/assets/select-DrL0mpRN.js +6 -0
- package/dist/web3/assets/send-CvAgOUPG.js +6 -0
- package/dist/web3/assets/settings-EY4A_DYv.js +6 -0
- package/dist/web3/assets/share-2-vGmZUl90.js +6 -0
- package/dist/web3/assets/tag-Br_y1f00.js +11 -0
- package/dist/web3/assets/trash-2-B4hRXo-p.js +6 -0
- package/dist/web3/assets/triangle-alert-CsYGyfGZ.js +6 -0
- package/dist/web3/assets/user-plus-4CTYEkd3.js +6 -0
- package/dist/web3/assets/x-qxt1WrUi.js +6 -0
- package/dist/web3/assets/zap-BcsKR0ef.js +16 -0
- package/dist/web3/index.html +2 -2
- package/package.json +5 -3
- package/dist/web3/assets/index-BJ-LRNis.js +0 -1382
- package/dist/web3/assets/index-DPMEt-A_.css +0 -1
package/dist/index.js
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
submitForReview,
|
|
39
39
|
withNodeWriteLock,
|
|
40
40
|
withdrawDeletionRequest
|
|
41
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-DTLMBF4W.js";
|
|
42
42
|
import {
|
|
43
43
|
canCreateInNest,
|
|
44
44
|
canManageStewards,
|
|
@@ -56,9 +56,10 @@ import {
|
|
|
56
56
|
resolveStewardsForNode,
|
|
57
57
|
resolveStewardsWithFallback,
|
|
58
58
|
resolveUserRoles,
|
|
59
|
+
stewardDocumentCreator,
|
|
59
60
|
syncFromConfig,
|
|
60
61
|
updateSteward
|
|
61
|
-
} from "./chunk-
|
|
62
|
+
} from "./chunk-2KHM5C7V.js";
|
|
62
63
|
import {
|
|
63
64
|
createGrant,
|
|
64
65
|
deleteGrant,
|
|
@@ -67,7 +68,7 @@ import {
|
|
|
67
68
|
listGrants,
|
|
68
69
|
listUserGrants,
|
|
69
70
|
resolveNodeGrant
|
|
70
|
-
} from "./chunk-
|
|
71
|
+
} from "./chunk-KPGNZLZL.js";
|
|
71
72
|
import {
|
|
72
73
|
generateApiKey,
|
|
73
74
|
getKeyPrefix,
|
|
@@ -78,10 +79,14 @@ import {
|
|
|
78
79
|
verifyPassword
|
|
79
80
|
} from "./chunk-F66VCBLA.js";
|
|
80
81
|
import {
|
|
82
|
+
attributeRequest,
|
|
81
83
|
checkConflict,
|
|
84
|
+
clientToolField,
|
|
85
|
+
connectionClient,
|
|
82
86
|
createVersion,
|
|
83
87
|
deleteSupersededDrafts,
|
|
84
88
|
deleteUnsealedVersions,
|
|
89
|
+
deserializeClient,
|
|
85
90
|
getApprovedVersion,
|
|
86
91
|
getApprovedVersions,
|
|
87
92
|
getCurrentVersion,
|
|
@@ -91,9 +96,12 @@ import {
|
|
|
91
96
|
getTrackedNodeIds,
|
|
92
97
|
getVersions,
|
|
93
98
|
listMyDrafts,
|
|
99
|
+
mergeClient,
|
|
100
|
+
parseClientMetadata,
|
|
101
|
+
serializeClient,
|
|
94
102
|
setApprovedVersion,
|
|
95
103
|
upsertVersion
|
|
96
|
-
} from "./chunk-
|
|
104
|
+
} from "./chunk-TI7HMP64.js";
|
|
97
105
|
import {
|
|
98
106
|
approveExternalEdit,
|
|
99
107
|
getExternalEditDetail,
|
|
@@ -105,7 +113,7 @@ import {
|
|
|
105
113
|
scanDocumentForDrift,
|
|
106
114
|
scanNestForDrift,
|
|
107
115
|
startDriftScanner
|
|
108
|
-
} from "./chunk-
|
|
116
|
+
} from "./chunk-32SZCH36.js";
|
|
109
117
|
import {
|
|
110
118
|
addMember,
|
|
111
119
|
addWatcher,
|
|
@@ -162,6 +170,7 @@ import {
|
|
|
162
170
|
loadServerSettings,
|
|
163
171
|
markNotificationsRead,
|
|
164
172
|
nestAllowsSelfApprove,
|
|
173
|
+
nestCreatorIsReviewer,
|
|
165
174
|
nestName,
|
|
166
175
|
nestPrimeTags,
|
|
167
176
|
nestReviewsPrimeOnly,
|
|
@@ -195,6 +204,7 @@ import {
|
|
|
195
204
|
restoreNest,
|
|
196
205
|
sendEmailToRecipient,
|
|
197
206
|
setAllowSelfApprove,
|
|
207
|
+
setCreatorIsReviewer,
|
|
198
208
|
setPrimeOnlyReview,
|
|
199
209
|
setPrimeTags,
|
|
200
210
|
setReaderMode,
|
|
@@ -211,25 +221,26 @@ import {
|
|
|
211
221
|
updateMemberRole,
|
|
212
222
|
validateLicense,
|
|
213
223
|
verifySmtp
|
|
214
|
-
} from "./chunk-
|
|
224
|
+
} from "./chunk-7FROMOZM.js";
|
|
215
225
|
import {
|
|
216
226
|
AppError,
|
|
217
227
|
ConflictError,
|
|
218
228
|
ForbiddenError,
|
|
219
229
|
LockedError,
|
|
220
230
|
NotFoundError,
|
|
231
|
+
PayloadTooLargeError,
|
|
221
232
|
UnauthorizedError,
|
|
222
233
|
ValidationError,
|
|
223
234
|
VaultBusyError,
|
|
224
235
|
isVaultLockTimeout
|
|
225
|
-
} from "./chunk-
|
|
236
|
+
} from "./chunk-LHWOJPFE.js";
|
|
226
237
|
import {
|
|
227
238
|
config,
|
|
228
239
|
getDb,
|
|
229
240
|
initDb,
|
|
230
241
|
isEmailListish,
|
|
231
242
|
isEmailish
|
|
232
|
-
} from "./chunk-
|
|
243
|
+
} from "./chunk-JDAOA5KP.js";
|
|
233
244
|
import {
|
|
234
245
|
ANON_EMAIL,
|
|
235
246
|
ANON_USER_ID,
|
|
@@ -243,7 +254,7 @@ import {
|
|
|
243
254
|
import { serve } from "@hono/node-server";
|
|
244
255
|
|
|
245
256
|
// src/app.ts
|
|
246
|
-
import { Hono as
|
|
257
|
+
import { Hono as Hono26 } from "hono";
|
|
247
258
|
import { LinearRouter } from "hono/router/linear-router";
|
|
248
259
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
249
260
|
import { cors } from "hono/cors";
|
|
@@ -430,7 +441,7 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
430
441
|
if (key) {
|
|
431
442
|
const keyHash = hashApiKey(key);
|
|
432
443
|
const record = await db.get(
|
|
433
|
-
"SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?",
|
|
444
|
+
"SELECT user_id, nest_id, label FROM api_keys WHERE key_hash = ?",
|
|
434
445
|
[keyHash]
|
|
435
446
|
);
|
|
436
447
|
if (record) {
|
|
@@ -440,10 +451,26 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
440
451
|
);
|
|
441
452
|
c.set("userId", record.user_id);
|
|
442
453
|
c.set("nestScope", record.nest_id);
|
|
454
|
+
if (record.label) c.set("apiKeyLabel", record.label);
|
|
443
455
|
return next();
|
|
444
456
|
}
|
|
445
457
|
}
|
|
446
|
-
|
|
458
|
+
if (sessionId) {
|
|
459
|
+
return c.json(
|
|
460
|
+
{ error: "Your session has expired \u2014 sign in again.", code: "session_expired" },
|
|
461
|
+
401
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
if (key) {
|
|
465
|
+
return c.json(
|
|
466
|
+
{ error: "This API key is invalid or has been revoked \u2014 generate a new one.", code: "invalid_key" },
|
|
467
|
+
401
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return c.json(
|
|
471
|
+
{ error: "Authentication required \u2014 sign in or provide an API key.", code: "missing_credentials" },
|
|
472
|
+
401
|
|
473
|
+
);
|
|
447
474
|
});
|
|
448
475
|
|
|
449
476
|
// src/shared/rate-limit.ts
|
|
@@ -1696,6 +1723,15 @@ authRoutes.get("/admin-status", async (c) => {
|
|
|
1696
1723
|
me
|
|
1697
1724
|
});
|
|
1698
1725
|
});
|
|
1726
|
+
authRoutes.patch("/profile", authMiddleware, async (c) => {
|
|
1727
|
+
const userId = c.get("userId");
|
|
1728
|
+
const body = await c.req.json();
|
|
1729
|
+
if (typeof body.name !== "string") throw new ValidationError("name is required");
|
|
1730
|
+
const name = body.name.trim();
|
|
1731
|
+
if (name.length > 80) throw new ValidationError("name must be 80 characters or fewer");
|
|
1732
|
+
await getDb().run("UPDATE users SET name = ? WHERE id = ?", [name || null, userId]);
|
|
1733
|
+
return c.json({ ok: true, name: name || null });
|
|
1734
|
+
});
|
|
1699
1735
|
authRoutes.post("/password", authMiddleware, async (c) => {
|
|
1700
1736
|
const body = await c.req.json();
|
|
1701
1737
|
if (!body.current || !body.next) {
|
|
@@ -1977,7 +2013,9 @@ import {
|
|
|
1977
2013
|
DocumentNotFoundError,
|
|
1978
2014
|
NODE_TYPES,
|
|
1979
2015
|
TAG_PATTERN,
|
|
1980
|
-
generateIndexMd
|
|
2016
|
+
generateIndexMd,
|
|
2017
|
+
readPdfBinary,
|
|
2018
|
+
pdfSidecarPath
|
|
1981
2019
|
} from "@promptowl/contextnest-engine";
|
|
1982
2020
|
import { rename, mkdir, rm, stat } from "fs/promises";
|
|
1983
2021
|
import { dirname, join } from "path";
|
|
@@ -2557,8 +2595,40 @@ var ACCEPTED_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
|
2557
2595
|
...NODE_TYPES,
|
|
2558
2596
|
...RUNNABLE_NODE_TYPES,
|
|
2559
2597
|
"artifact",
|
|
2560
|
-
"table"
|
|
2598
|
+
"table",
|
|
2599
|
+
"pdf"
|
|
2561
2600
|
]);
|
|
2601
|
+
var PDF_NODE_TYPE = "pdf";
|
|
2602
|
+
var PDF_UPLOAD_HINT = "upload the file to POST /nests/:nestId/nodes/pdf (the Upload PDF action)";
|
|
2603
|
+
function pdfEngineError(err) {
|
|
2604
|
+
const code = err?.code;
|
|
2605
|
+
const message = err?.message || "The PDF could not be imported.";
|
|
2606
|
+
if (code === "VALIDATION_FAILED" || code === "INVALID_DOCUMENT_ID") {
|
|
2607
|
+
return new ValidationError(message);
|
|
2608
|
+
}
|
|
2609
|
+
if (code === "DOCUMENT_ALREADY_EXISTS") return new ConflictError(message);
|
|
2610
|
+
return err;
|
|
2611
|
+
}
|
|
2612
|
+
function assertAcceptedType(type) {
|
|
2613
|
+
if (type === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
|
|
2614
|
+
throw new ValidationError("Artifact nodes are disabled on this server");
|
|
2615
|
+
}
|
|
2616
|
+
if (type === "table" && !config.TYPE_TABLE_ENABLED) {
|
|
2617
|
+
throw new ValidationError("Table nodes are disabled on this server");
|
|
2618
|
+
}
|
|
2619
|
+
if (!ACCEPTED_NODE_TYPES.has(type)) {
|
|
2620
|
+
throw new ValidationError(
|
|
2621
|
+
`unknown node type "${type}"; allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")}`
|
|
2622
|
+
);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
function assertUpdatableType(storedType) {
|
|
2626
|
+
const type = storedType || "document";
|
|
2627
|
+
if (ACCEPTED_NODE_TYPES.has(type)) return;
|
|
2628
|
+
throw new ValidationError(
|
|
2629
|
+
`node has legacy type "${type}" which this server no longer accepts; include "type": "<accepted type>" in the same PATCH to migrate it (allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")})`
|
|
2630
|
+
);
|
|
2631
|
+
}
|
|
2562
2632
|
function toNodeResponse(node) {
|
|
2563
2633
|
const fm = node.frontmatter;
|
|
2564
2634
|
const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
|
|
@@ -2583,7 +2653,8 @@ function toNodeResponse(node) {
|
|
|
2583
2653
|
// Same YAML-coercion rule as title above: `schedule: 123` parses as a
|
|
2584
2654
|
// number — normalize to string at the API boundary.
|
|
2585
2655
|
schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0,
|
|
2586
|
-
prime: documentPrimeFlag(fm.metadata)
|
|
2656
|
+
prime: documentPrimeFlag(fm.metadata),
|
|
2657
|
+
...fm.type === "pdf" && fm.pdf ? { pdf: fm.pdf } : {}
|
|
2587
2658
|
};
|
|
2588
2659
|
}
|
|
2589
2660
|
async function resolveAuthorship(nestId, nodes) {
|
|
@@ -2976,9 +3047,11 @@ async function assertMayFlagPrime(nestId, userEmail) {
|
|
|
2976
3047
|
);
|
|
2977
3048
|
}
|
|
2978
3049
|
}
|
|
2979
|
-
async function autoPublishReason(nestId, userEmail, doc) {
|
|
2980
|
-
|
|
3050
|
+
async function autoPublishReason(nestId, userEmail, doc, opts = {}) {
|
|
3051
|
+
const governed = opts.governed ?? await isStewardshipEnabled(nestId);
|
|
3052
|
+
if (!governed) return "no stewards configured";
|
|
2981
3053
|
if (await isPrimeDocument(nestId, doc.tags, doc.metadata)) return null;
|
|
3054
|
+
if (opts.creatorReviews) return "creator is the document's reviewer";
|
|
2982
3055
|
if (await nestReviewsPrimeOnly(nestId)) {
|
|
2983
3056
|
return "prime-only review, document is not prime";
|
|
2984
3057
|
}
|
|
@@ -3008,17 +3081,13 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
|
|
|
3008
3081
|
id = `${prefix}${slug || await generatedSlug(storage, prefix)}`;
|
|
3009
3082
|
}
|
|
3010
3083
|
const tags = requireTags(input.tags);
|
|
3011
|
-
const requestedType = input.type || "document";
|
|
3012
|
-
|
|
3013
|
-
|
|
3084
|
+
const requestedType = input.pdf ? input.type || PDF_NODE_TYPE : input.type || "document";
|
|
3085
|
+
assertAcceptedType(requestedType);
|
|
3086
|
+
if (requestedType === PDF_NODE_TYPE && !input.pdf) {
|
|
3087
|
+
throw new ValidationError(`PDF documents can't be created from text \u2014 ${PDF_UPLOAD_HINT}.`);
|
|
3014
3088
|
}
|
|
3015
|
-
if (
|
|
3016
|
-
throw new ValidationError(
|
|
3017
|
-
}
|
|
3018
|
-
if (!ACCEPTED_NODE_TYPES.has(requestedType)) {
|
|
3019
|
-
throw new ValidationError(
|
|
3020
|
-
`unknown node type "${requestedType}"; allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")}`
|
|
3021
|
-
);
|
|
3089
|
+
if (input.pdf && requestedType !== PDF_NODE_TYPE) {
|
|
3090
|
+
throw new ValidationError(`a PDF upload creates a "pdf" node, not "${requestedType}"`);
|
|
3022
3091
|
}
|
|
3023
3092
|
if (input.schedule !== void 0 && !isRunnableType(input.type)) {
|
|
3024
3093
|
throw new ValidationError(
|
|
@@ -3064,42 +3133,85 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
|
|
|
3064
3133
|
...input.schedule ? { schedule: input.schedule } : {},
|
|
3065
3134
|
...input.prime === void 0 ? {} : { prime: input.prime }
|
|
3066
3135
|
};
|
|
3067
|
-
const
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
let published = publishReason !== null;
|
|
3136
|
+
const governed = await isStewardshipEnabled(nestId);
|
|
3137
|
+
const creatorReviews = opts.creatorReviews !== false && governed && await nestCreatorIsReviewer(nestId);
|
|
3138
|
+
const creatorSteward = creatorReviews ? await stewardDocumentCreator(nestId, id, userEmail) : null;
|
|
3139
|
+
let published = false;
|
|
3140
|
+
let publishNote;
|
|
3073
3141
|
try {
|
|
3074
|
-
await
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
content: input.content,
|
|
3080
|
-
type: requestedType,
|
|
3081
|
-
// A skill node is invalid without a skill block, and this surface has
|
|
3082
|
-
// no field for one — default the trigger from the title, same as the
|
|
3083
|
-
// CLI and mcp-server do.
|
|
3084
|
-
...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
|
|
3085
|
-
...tags.length ? { tags } : {},
|
|
3086
|
-
...input.status ? { status: input.status } : {},
|
|
3087
|
-
metadata,
|
|
3088
|
-
publish: published,
|
|
3089
|
-
...publishNote ? { note: publishNote } : {}
|
|
3090
|
-
},
|
|
3091
|
-
await opContext(nestId, userEmail)
|
|
3142
|
+
const publishReason = await autoPublishReason(
|
|
3143
|
+
nestId,
|
|
3144
|
+
userEmail,
|
|
3145
|
+
{ tags, metadata },
|
|
3146
|
+
{ creatorReviews: creatorSteward?.canApprove === true, governed }
|
|
3092
3147
|
);
|
|
3093
|
-
|
|
3094
|
-
|
|
3148
|
+
publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
|
|
3149
|
+
published = publishReason !== null;
|
|
3095
3150
|
try {
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3151
|
+
if (input.pdf) {
|
|
3152
|
+
try {
|
|
3153
|
+
await engineApi.run(
|
|
3154
|
+
"context_import_pdf",
|
|
3155
|
+
{
|
|
3156
|
+
id,
|
|
3157
|
+
title: input.title,
|
|
3158
|
+
bytes_base64: Buffer.from(input.pdf.bytes).toString("base64"),
|
|
3159
|
+
...input.pdf.filename ? { filename: input.pdf.filename } : {},
|
|
3160
|
+
...tags.length ? { tags } : {},
|
|
3161
|
+
publish: published,
|
|
3162
|
+
...publishNote ? { note: publishNote } : {},
|
|
3163
|
+
...input.client ? { client: input.client } : {}
|
|
3164
|
+
},
|
|
3165
|
+
await opContext(nestId, userEmail)
|
|
3166
|
+
);
|
|
3167
|
+
} catch (err) {
|
|
3168
|
+
throw pdfEngineError(err);
|
|
3169
|
+
}
|
|
3170
|
+
} else {
|
|
3171
|
+
await engineApi.run(
|
|
3172
|
+
"context_create",
|
|
3173
|
+
{
|
|
3174
|
+
id,
|
|
3175
|
+
title: input.title,
|
|
3176
|
+
content: input.content,
|
|
3177
|
+
type: requestedType,
|
|
3178
|
+
// A skill node is invalid without a skill block, and this surface
|
|
3179
|
+
// has no field for one — default the trigger from the title, same
|
|
3180
|
+
// as the CLI and mcp-server do.
|
|
3181
|
+
...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
|
|
3182
|
+
...tags.length ? { tags } : {},
|
|
3183
|
+
...input.status ? { status: input.status } : {},
|
|
3184
|
+
metadata,
|
|
3185
|
+
publish: published,
|
|
3186
|
+
...publishNote ? { note: publishNote } : {}
|
|
3187
|
+
},
|
|
3188
|
+
await opContext(nestId, userEmail)
|
|
3189
|
+
);
|
|
3190
|
+
}
|
|
3191
|
+
} catch (err) {
|
|
3192
|
+
let onDisk = true;
|
|
3193
|
+
try {
|
|
3194
|
+
await storage.readDocument(id);
|
|
3195
|
+
} catch {
|
|
3196
|
+
onDisk = false;
|
|
3197
|
+
}
|
|
3198
|
+
if (!onDisk) throw err;
|
|
3199
|
+
console.error("publishDocument failed (node create auto-publish)", err);
|
|
3200
|
+
published = false;
|
|
3201
|
+
}
|
|
3202
|
+
} catch (err) {
|
|
3203
|
+
if (creatorSteward?.insertedId) {
|
|
3204
|
+
try {
|
|
3205
|
+
await removeSteward(creatorSteward.insertedId);
|
|
3206
|
+
} catch (cleanupErr) {
|
|
3207
|
+
console.error(
|
|
3208
|
+
"removeSteward failed while compensating a failed create",
|
|
3209
|
+
id,
|
|
3210
|
+
cleanupErr
|
|
3211
|
+
);
|
|
3212
|
+
}
|
|
3099
3213
|
}
|
|
3100
|
-
|
|
3101
|
-
console.error("publishDocument failed (node create auto-publish)", err);
|
|
3102
|
-
published = false;
|
|
3214
|
+
throw err;
|
|
3103
3215
|
}
|
|
3104
3216
|
const node = await storage.readDocument(id);
|
|
3105
3217
|
await syncNodeTags(nestId, id, tags);
|
|
@@ -3114,7 +3226,8 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
|
|
|
3114
3226
|
author: userEmail,
|
|
3115
3227
|
status: "published",
|
|
3116
3228
|
tags,
|
|
3117
|
-
changeNote: publishNote
|
|
3229
|
+
changeNote: publishNote,
|
|
3230
|
+
client: input.client
|
|
3118
3231
|
});
|
|
3119
3232
|
await setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
3120
3233
|
} else {
|
|
@@ -3122,10 +3235,12 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
|
|
|
3122
3235
|
nestId,
|
|
3123
3236
|
nodeId: id,
|
|
3124
3237
|
version: 1,
|
|
3125
|
-
|
|
3238
|
+
// A pdf node's body is what the engine extracted, not anything sent.
|
|
3239
|
+
content: input.pdf ? node.body || "" : input.content,
|
|
3126
3240
|
author: userEmail,
|
|
3127
3241
|
status: "draft",
|
|
3128
|
-
tags
|
|
3242
|
+
tags,
|
|
3243
|
+
client: input.client
|
|
3129
3244
|
});
|
|
3130
3245
|
}
|
|
3131
3246
|
await trackEvent("node.create", { nestId, nodeId: id });
|
|
@@ -3179,7 +3294,7 @@ async function alignOnDiskStatus(nestId, documentId, status) {
|
|
|
3179
3294
|
);
|
|
3180
3295
|
}
|
|
3181
3296
|
}
|
|
3182
|
-
async function registerImportedDocuments(nestId, userEmail) {
|
|
3297
|
+
async function registerImportedDocuments(nestId, userEmail, client) {
|
|
3183
3298
|
const startedAt = Date.now();
|
|
3184
3299
|
const tracked = await getTrackedNodeIds(nestId);
|
|
3185
3300
|
(await engineCache.get(nestId)).invalidateDiscovery();
|
|
@@ -3209,7 +3324,8 @@ async function registerImportedDocuments(nestId, userEmail) {
|
|
|
3209
3324
|
author: userEmail,
|
|
3210
3325
|
status: d.status,
|
|
3211
3326
|
changeNote: d.status === "published" ? "Imported from existing folder" : "Imported from existing folder \u2014 could not be published, kept as a draft",
|
|
3212
|
-
tags
|
|
3327
|
+
tags,
|
|
3328
|
+
client
|
|
3213
3329
|
});
|
|
3214
3330
|
if (d.status === "published") {
|
|
3215
3331
|
await setApprovedVersion(nestId, d.id, d.version, userEmail);
|
|
@@ -3231,6 +3347,44 @@ async function registerImportedDocuments(nestId, userEmail) {
|
|
|
3231
3347
|
await trackEvent("nest.import.documents", { nestId, registered: docs.length });
|
|
3232
3348
|
return docs.length;
|
|
3233
3349
|
}
|
|
3350
|
+
async function stagePdfImport(nestId, nodeId, pdf, userEmail, client, priorRaw) {
|
|
3351
|
+
const { storage } = await engineCache.get(nestId);
|
|
3352
|
+
const sidecar = pdfSidecarPath(nodeId);
|
|
3353
|
+
let priorSidecar = null;
|
|
3354
|
+
try {
|
|
3355
|
+
priorSidecar = await storage.readVaultBinary(sidecar);
|
|
3356
|
+
} catch {
|
|
3357
|
+
}
|
|
3358
|
+
const sealedBefore = await storage.maxRecordedVersion(nodeId);
|
|
3359
|
+
const rollback = async () => {
|
|
3360
|
+
if (await storage.maxRecordedVersion(nodeId).catch(() => sealedBefore) > sealedBefore) {
|
|
3361
|
+
return;
|
|
3362
|
+
}
|
|
3363
|
+
try {
|
|
3364
|
+
await storage.writeDocument(nodeId, priorRaw);
|
|
3365
|
+
if (priorSidecar) await storage.writeVaultBinary(sidecar, priorSidecar);
|
|
3366
|
+
await storage.regenerateIndex();
|
|
3367
|
+
} catch (err) {
|
|
3368
|
+
console.error("[pdf] could not roll back a failed PDF replace", nestId, nodeId, err);
|
|
3369
|
+
}
|
|
3370
|
+
};
|
|
3371
|
+
try {
|
|
3372
|
+
const result = await engineApi.run(
|
|
3373
|
+
"context_import_pdf",
|
|
3374
|
+
{
|
|
3375
|
+
id: nodeId,
|
|
3376
|
+
bytes_base64: Buffer.from(pdf.bytes).toString("base64"),
|
|
3377
|
+
...pdf.filename ? { filename: pdf.filename } : {},
|
|
3378
|
+
publish: false,
|
|
3379
|
+
...client ? { client } : {}
|
|
3380
|
+
},
|
|
3381
|
+
await opContext(nestId, userEmail)
|
|
3382
|
+
);
|
|
3383
|
+
return { unchanged: result.unchanged === true, rollback };
|
|
3384
|
+
} catch (err) {
|
|
3385
|
+
throw pdfEngineError(err);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3234
3388
|
async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
|
|
3235
3389
|
return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
|
|
3236
3390
|
try {
|
|
@@ -3243,6 +3397,35 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
|
|
|
3243
3397
|
} catch {
|
|
3244
3398
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
3245
3399
|
}
|
|
3400
|
+
const priorRaw = node.rawContent;
|
|
3401
|
+
let pdfRollback = null;
|
|
3402
|
+
if (patch.type === void 0 || patch.type === node.frontmatter.type) {
|
|
3403
|
+
assertUpdatableType(node.frontmatter.type);
|
|
3404
|
+
}
|
|
3405
|
+
const storedType = node.frontmatter.type || "document";
|
|
3406
|
+
const isPdfNode = storedType === PDF_NODE_TYPE;
|
|
3407
|
+
if (patch.pdf && !isPdfNode) {
|
|
3408
|
+
throw new ValidationError(
|
|
3409
|
+
`${nodeId} is a "${storedType}" node, not a PDF \u2014 a PDF can only replace the PDF of a pdf node.`
|
|
3410
|
+
);
|
|
3411
|
+
}
|
|
3412
|
+
if (patch.type !== void 0 && patch.type !== storedType) {
|
|
3413
|
+
if (isPdfNode || patch.type === PDF_NODE_TYPE) {
|
|
3414
|
+
throw new ValidationError(
|
|
3415
|
+
isPdfNode ? "A PDF document can't be converted to another type \u2014 its body is the text of its PDF." : `A document can't be converted to a PDF \u2014 ${PDF_UPLOAD_HINT}.`
|
|
3416
|
+
);
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
if (isPdfNode) {
|
|
3420
|
+
const readOnly = "This is a PDF document: its text is extracted from the PDF and can't be edited directly. Upload a new version of the PDF (Replace PDF) to change it.";
|
|
3421
|
+
if (patch.append) throw new ConflictError(readOnly);
|
|
3422
|
+
if (patch.content !== void 0) {
|
|
3423
|
+
if (patch.content.trim() !== (node.body || "").trim()) {
|
|
3424
|
+
throw new ConflictError(readOnly);
|
|
3425
|
+
}
|
|
3426
|
+
patch = { ...patch, content: void 0 };
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3246
3429
|
const previousBody = node.body || "";
|
|
3247
3430
|
const opInput = {};
|
|
3248
3431
|
if (patch.content !== void 0) {
|
|
@@ -3282,6 +3465,20 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
|
|
|
3282
3465
|
};
|
|
3283
3466
|
opInput.title = patch.title;
|
|
3284
3467
|
}
|
|
3468
|
+
if (patch.type !== void 0 && patch.type !== node.frontmatter.type) {
|
|
3469
|
+
assertAcceptedType(patch.type);
|
|
3470
|
+
if (patch.type === "skill") {
|
|
3471
|
+
opInput.trigger = `when asked to ${String(node.frontmatter.title || nodeId).toLowerCase()}`;
|
|
3472
|
+
}
|
|
3473
|
+
if (isRunnableType(node.frontmatter.type) && !isRunnableType(patch.type)) {
|
|
3474
|
+
const metadata = { ...node.frontmatter.metadata };
|
|
3475
|
+
delete metadata.schedule;
|
|
3476
|
+
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
3477
|
+
opInput.metadata = { ...opInput.metadata, schedule: null };
|
|
3478
|
+
}
|
|
3479
|
+
node = { ...node, frontmatter: { ...node.frontmatter, type: patch.type } };
|
|
3480
|
+
opInput.type = patch.type;
|
|
3481
|
+
}
|
|
3285
3482
|
if (patch.schedule !== void 0) {
|
|
3286
3483
|
if (!isRunnableType(node.frontmatter.type)) {
|
|
3287
3484
|
throw new ValidationError(
|
|
@@ -3324,11 +3521,30 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
|
|
|
3324
3521
|
if (publishReason === null || isAuthorEditingOwnReview) {
|
|
3325
3522
|
const currentVersion = await getCurrentVersion(nestId, nodeId);
|
|
3326
3523
|
const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3524
|
+
if (patch.pdf) {
|
|
3525
|
+
const staged = await stagePdfImport(
|
|
3526
|
+
nestId,
|
|
3527
|
+
nodeId,
|
|
3528
|
+
patch.pdf,
|
|
3529
|
+
userEmail,
|
|
3530
|
+
patch.client,
|
|
3531
|
+
priorRaw
|
|
3532
|
+
);
|
|
3533
|
+
if (staged.unchanged && Object.keys(opInput).length === 0) {
|
|
3534
|
+
return { node, version: node.frontmatter.version || currentVersion || 1 };
|
|
3535
|
+
}
|
|
3536
|
+
if (!staged.unchanged) pdfRollback = staged.rollback;
|
|
3537
|
+
}
|
|
3538
|
+
try {
|
|
3539
|
+
await engineApi.run(
|
|
3540
|
+
"context_update",
|
|
3541
|
+
{ id: nodeId, ...opInput, publish: false, version: newVersion },
|
|
3542
|
+
await opContext(nestId, userEmail)
|
|
3543
|
+
);
|
|
3544
|
+
} catch (err) {
|
|
3545
|
+
if (pdfRollback) await pdfRollback();
|
|
3546
|
+
throw err;
|
|
3547
|
+
}
|
|
3332
3548
|
node = await storage.readDocument(nodeId);
|
|
3333
3549
|
await syncNodeTags(nestId, nodeId, currentTags);
|
|
3334
3550
|
await deleteSupersededDrafts(nestId, nodeId, newVersion);
|
|
@@ -3340,7 +3556,8 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
|
|
|
3340
3556
|
author: userEmail,
|
|
3341
3557
|
status: "draft",
|
|
3342
3558
|
tags: currentTags,
|
|
3343
|
-
changeNote: patch.changeNote
|
|
3559
|
+
changeNote: patch.changeNote,
|
|
3560
|
+
client: patch.client
|
|
3344
3561
|
});
|
|
3345
3562
|
if (isAuthorEditingOwnReview) {
|
|
3346
3563
|
await repointPendingReview({ nestId, nodeId, newVersion });
|
|
@@ -3349,6 +3566,20 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
|
|
|
3349
3566
|
} else {
|
|
3350
3567
|
const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
|
|
3351
3568
|
let publishedVersion;
|
|
3569
|
+
if (patch.pdf) {
|
|
3570
|
+
const staged = await stagePdfImport(
|
|
3571
|
+
nestId,
|
|
3572
|
+
nodeId,
|
|
3573
|
+
patch.pdf,
|
|
3574
|
+
userEmail,
|
|
3575
|
+
patch.client,
|
|
3576
|
+
priorRaw
|
|
3577
|
+
);
|
|
3578
|
+
if (staged.unchanged && Object.keys(opInput).length === 0) {
|
|
3579
|
+
return { node, version: node.frontmatter.version || 1 };
|
|
3580
|
+
}
|
|
3581
|
+
if (!staged.unchanged) pdfRollback = staged.rollback;
|
|
3582
|
+
}
|
|
3352
3583
|
try {
|
|
3353
3584
|
const result = await engineApi.run(
|
|
3354
3585
|
"context_update",
|
|
@@ -3361,6 +3592,7 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
|
|
|
3361
3592
|
"publishDocument failed (node patch auto-publish)",
|
|
3362
3593
|
err
|
|
3363
3594
|
);
|
|
3595
|
+
if (pdfRollback) await pdfRollback();
|
|
3364
3596
|
throw err;
|
|
3365
3597
|
}
|
|
3366
3598
|
node = await storage.readDocument(nodeId);
|
|
@@ -3373,7 +3605,8 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
|
|
|
3373
3605
|
author: userEmail,
|
|
3374
3606
|
status: "published",
|
|
3375
3607
|
tags: currentTags,
|
|
3376
|
-
changeNote: publishNote
|
|
3608
|
+
changeNote: publishNote,
|
|
3609
|
+
client: patch.client
|
|
3377
3610
|
});
|
|
3378
3611
|
await setApprovedVersion(
|
|
3379
3612
|
nestId,
|
|
@@ -3479,6 +3712,11 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
|
|
|
3479
3712
|
} catch {
|
|
3480
3713
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
3481
3714
|
}
|
|
3715
|
+
if (node.frontmatter.type === PDF_NODE_TYPE) {
|
|
3716
|
+
throw new ValidationError(
|
|
3717
|
+
"PDF documents can't be moved to another folder yet. Upload the PDF again in the destination folder instead."
|
|
3718
|
+
);
|
|
3719
|
+
}
|
|
3482
3720
|
await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
|
|
3483
3721
|
const leaf = nodeId.split("/").pop();
|
|
3484
3722
|
const segments = folderToSegments(folder);
|
|
@@ -3575,6 +3813,16 @@ async function discardDraft(nestId, nodeId, userEmail) {
|
|
|
3575
3813
|
await storage.writeDocument(nodeId, raw);
|
|
3576
3814
|
await storage.regenerateIndex();
|
|
3577
3815
|
const restored = parseDocument(`${nodeId}.md`, raw, nodeId);
|
|
3816
|
+
let warning;
|
|
3817
|
+
if (restored.frontmatter.type === PDF_NODE_TYPE) {
|
|
3818
|
+
try {
|
|
3819
|
+
const bytes = await readPdfBinary(storage, nodeId);
|
|
3820
|
+
await storage.writeVaultBinary(pdfSidecarPath(nodeId), bytes);
|
|
3821
|
+
} catch (err) {
|
|
3822
|
+
console.error("[pdf] could not restore the sealed binary on discard", nestId, nodeId, err);
|
|
3823
|
+
warning = `The draft was discarded, but the PDF of version ${sealed} could not be restored (its archived copy is missing or no longer matches). Upload the PDF again, or ask an admin to verify the nest.`;
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3578
3826
|
await syncNodeTags(
|
|
3579
3827
|
nestId,
|
|
3580
3828
|
nodeId,
|
|
@@ -3582,7 +3830,7 @@ async function discardDraft(nestId, nodeId, userEmail) {
|
|
|
3582
3830
|
);
|
|
3583
3831
|
await deleteUnsealedVersions(nestId, nodeId, sealed);
|
|
3584
3832
|
await trackEvent("node.discard_draft", { nestId, nodeId, version: sealed });
|
|
3585
|
-
return { deleted: false, version: sealed };
|
|
3833
|
+
return { deleted: false, version: sealed, ...warning ? { warning } : {} };
|
|
3586
3834
|
});
|
|
3587
3835
|
}
|
|
3588
3836
|
async function deleteNode(nestId, nodeId, userEmail) {
|
|
@@ -3809,7 +4057,7 @@ function collectMarkdownFiles(dir, root) {
|
|
|
3809
4057
|
}
|
|
3810
4058
|
return out;
|
|
3811
4059
|
}
|
|
3812
|
-
async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
4060
|
+
async function syncUnsyncedFolder(userId, folderName, callerEmail, client) {
|
|
3813
4061
|
console.log(
|
|
3814
4062
|
`[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
|
|
3815
4063
|
);
|
|
@@ -3850,7 +4098,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
|
3850
4098
|
let documents;
|
|
3851
4099
|
try {
|
|
3852
4100
|
await stageImportedFiles(nest.id, files, callerEmail);
|
|
3853
|
-
documents = await registerImportedDocuments(nest.id, callerEmail);
|
|
4101
|
+
documents = await registerImportedDocuments(nest.id, callerEmail, client);
|
|
3854
4102
|
} catch (err) {
|
|
3855
4103
|
await deleteNest(nest.id).catch(() => {
|
|
3856
4104
|
});
|
|
@@ -3971,7 +4219,11 @@ nestRoutes.get("/", async (c) => {
|
|
|
3971
4219
|
seen.add(n.id);
|
|
3972
4220
|
deduped.push(n);
|
|
3973
4221
|
}
|
|
3974
|
-
const
|
|
4222
|
+
const memberIds = new Set([...owned, ...shared].map((n) => n.id));
|
|
4223
|
+
const out = (await Promise.all(deduped.map(annotate))).map((row) => ({
|
|
4224
|
+
...row,
|
|
4225
|
+
discovered: !memberIds.has(row.id)
|
|
4226
|
+
}));
|
|
3975
4227
|
const docRows = await db.all(
|
|
3976
4228
|
`SELECT nest_id,
|
|
3977
4229
|
COUNT(DISTINCT CASE
|
|
@@ -4070,7 +4322,14 @@ nestRoutes.post("/:nestId", async (c) => {
|
|
|
4070
4322
|
}
|
|
4071
4323
|
const email2 = await resolveCallerEmail(userId);
|
|
4072
4324
|
if (body.finalize) {
|
|
4073
|
-
return c.json({
|
|
4325
|
+
return c.json({
|
|
4326
|
+
nest: nest2,
|
|
4327
|
+
documents: await registerImportedDocuments(
|
|
4328
|
+
target,
|
|
4329
|
+
email2,
|
|
4330
|
+
attributeRequest(c, body.client)
|
|
4331
|
+
)
|
|
4332
|
+
});
|
|
4074
4333
|
}
|
|
4075
4334
|
const registered = await getDb().get(
|
|
4076
4335
|
"SELECT COUNT(*) AS c FROM node_versions WHERE nest_id = ?",
|
|
@@ -4097,7 +4356,11 @@ nestRoutes.post("/:nestId", async (c) => {
|
|
|
4097
4356
|
if (body.partial) {
|
|
4098
4357
|
return c.json({ nest, rejected }, 201);
|
|
4099
4358
|
}
|
|
4100
|
-
const documents = await registerImportedDocuments(
|
|
4359
|
+
const documents = await registerImportedDocuments(
|
|
4360
|
+
nest.id,
|
|
4361
|
+
email,
|
|
4362
|
+
attributeRequest(c, body.client)
|
|
4363
|
+
);
|
|
4101
4364
|
return c.json({ nest, documents, rejected }, 201);
|
|
4102
4365
|
} catch (err) {
|
|
4103
4366
|
await deleteNest(nest.id).catch(() => {
|
|
@@ -4127,7 +4390,8 @@ nestRoutes.post("/:nestId/sync", async (c) => {
|
|
|
4127
4390
|
const result = await syncUnsyncedFolder(
|
|
4128
4391
|
userId,
|
|
4129
4392
|
body.name,
|
|
4130
|
-
await resolveCallerEmail(userId)
|
|
4393
|
+
await resolveCallerEmail(userId),
|
|
4394
|
+
attributeRequest(c, body.client)
|
|
4131
4395
|
);
|
|
4132
4396
|
return c.json(result, 201);
|
|
4133
4397
|
});
|
|
@@ -4249,7 +4513,8 @@ nestRoutes.get("/:nestId/settings", async (c) => {
|
|
|
4249
4513
|
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
4250
4514
|
allow_self_approve: await nestAllowsSelfApprove(nestId),
|
|
4251
4515
|
prime_only_review: await nestReviewsPrimeOnly(nestId),
|
|
4252
|
-
prime_tags: await nestPrimeTags(nestId)
|
|
4516
|
+
prime_tags: await nestPrimeTags(nestId),
|
|
4517
|
+
creator_is_reviewer: await nestCreatorIsReviewer(nestId)
|
|
4253
4518
|
});
|
|
4254
4519
|
});
|
|
4255
4520
|
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
@@ -4294,11 +4559,15 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
|
4294
4559
|
Array.isArray(body.prime_tags) ? body.prime_tags : [body.prime_tags]
|
|
4295
4560
|
);
|
|
4296
4561
|
}
|
|
4562
|
+
if (typeof body.creator_is_reviewer === "boolean") {
|
|
4563
|
+
await setCreatorIsReviewer(nestId, body.creator_is_reviewer);
|
|
4564
|
+
}
|
|
4297
4565
|
return c.json({
|
|
4298
4566
|
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
4299
4567
|
allow_self_approve: await nestAllowsSelfApprove(nestId),
|
|
4300
4568
|
prime_only_review: await nestReviewsPrimeOnly(nestId),
|
|
4301
4569
|
prime_tags: await nestPrimeTags(nestId),
|
|
4570
|
+
creator_is_reviewer: await nestCreatorIsReviewer(nestId),
|
|
4302
4571
|
wiped
|
|
4303
4572
|
});
|
|
4304
4573
|
});
|
|
@@ -4513,12 +4782,24 @@ sharingRoutes.patch("/visibility", async (c) => {
|
|
|
4513
4782
|
if (!body.visibility || !["private", "org", "public"].includes(body.visibility)) {
|
|
4514
4783
|
throw new ValidationError("visibility must be private, org or public");
|
|
4515
4784
|
}
|
|
4516
|
-
|
|
4785
|
+
const ack = body.acknowledge_public === true;
|
|
4786
|
+
const shared = config.SHARED_DEPLOYMENT;
|
|
4787
|
+
if (body.visibility === "public" && !ack) {
|
|
4517
4788
|
return c.json(
|
|
4518
4789
|
{
|
|
4519
4790
|
error: "Publishing this nest requires acknowledgement.",
|
|
4520
4791
|
requires_acknowledgement: true,
|
|
4521
|
-
warning: "This makes the nest readable by anyone on the internet, outside your organization."
|
|
4792
|
+
warning: shared ? "This makes the nest readable by anyone on the internet \u2014 including every account on this server, which belongs to other organizations too." : "This makes the nest readable by anyone on the internet, outside your organization."
|
|
4793
|
+
},
|
|
4794
|
+
409
|
|
4795
|
+
);
|
|
4796
|
+
}
|
|
4797
|
+
if (body.visibility === "org" && shared && !ack) {
|
|
4798
|
+
return c.json(
|
|
4799
|
+
{
|
|
4800
|
+
error: "Opening this nest to the organization requires acknowledgement.",
|
|
4801
|
+
requires_acknowledgement: true,
|
|
4802
|
+
warning: "This server is shared: 'Organization' makes the nest readable by every account on this server, including other organizations \u2014 not just yours."
|
|
4522
4803
|
},
|
|
4523
4804
|
409
|
|
4524
4805
|
);
|
|
@@ -4590,12 +4871,19 @@ sharingRoutes.patch("/reader-mode", async (c) => {
|
|
|
4590
4871
|
import { Hono as Hono5 } from "hono";
|
|
4591
4872
|
import { v4 as uuid7 } from "uuid";
|
|
4592
4873
|
var grantRoutes = new Hono5();
|
|
4593
|
-
async function requireManage(c, nestId) {
|
|
4594
|
-
if (
|
|
4874
|
+
async function requireManage(c, nestId, opts = {}) {
|
|
4875
|
+
if (config.AUTH_MODE === "open") return;
|
|
4876
|
+
const access = await resolveNestAccess(nestId, c.get("userId"));
|
|
4877
|
+
if (access.rawPermission !== "owner" && access.rawPermission !== "admin") {
|
|
4595
4878
|
throw new ForbiddenError(
|
|
4596
4879
|
"Only a nest admin, the nest owner, or the server admin can manage sharing."
|
|
4597
4880
|
);
|
|
4598
4881
|
}
|
|
4882
|
+
if (access.archived && !opts.readOnly) {
|
|
4883
|
+
throw new ForbiddenError(
|
|
4884
|
+
"This nest is archived, so it is read-only. Restore it to change sharing."
|
|
4885
|
+
);
|
|
4886
|
+
}
|
|
4599
4887
|
}
|
|
4600
4888
|
async function resolveGrantee(email) {
|
|
4601
4889
|
const db = getDb();
|
|
@@ -4685,7 +4973,7 @@ grantRoutes.post("/", async (c) => {
|
|
|
4685
4973
|
});
|
|
4686
4974
|
grantRoutes.get("/", async (c) => {
|
|
4687
4975
|
const nestId = c.req.param("nestId");
|
|
4688
|
-
await requireManage(c, nestId);
|
|
4976
|
+
await requireManage(c, nestId, { readOnly: true });
|
|
4689
4977
|
const grants = await listGrants(nestId);
|
|
4690
4978
|
const db = getDb();
|
|
4691
4979
|
const withEmail = await Promise.all(
|
|
@@ -5305,7 +5593,7 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
|
5305
5593
|
tags: ["annotations"]
|
|
5306
5594
|
},
|
|
5307
5595
|
userEmail,
|
|
5308
|
-
{ notifyMentions: false }
|
|
5596
|
+
{ notifyMentions: false, creatorReviews: false }
|
|
5309
5597
|
);
|
|
5310
5598
|
} else {
|
|
5311
5599
|
throw err;
|
|
@@ -5471,7 +5759,7 @@ var GATE_EYE_SCRIPT = `<script>
|
|
|
5471
5759
|
</script>`;
|
|
5472
5760
|
function gatePage(row, message) {
|
|
5473
5761
|
const wantsCode = row.access_mode === "code";
|
|
5474
|
-
const
|
|
5762
|
+
const field2 = wantsCode ? `<label>Access code<span class="wrap">
|
|
5475
5763
|
<input id="cn-code" name="code" type="password" autocomplete="off" required autofocus>
|
|
5476
5764
|
<button type="button" id="cn-eye" class="eye" aria-label="Show code" aria-pressed="false">${EYE_SVG}</button>
|
|
5477
5765
|
</span></label>` : `<label>Your email<input name="email" type="email" autocomplete="email" required autofocus></label>`;
|
|
@@ -5495,7 +5783,7 @@ function gatePage(row, message) {
|
|
|
5495
5783
|
.err{color:#be123c;font-weight:600}
|
|
5496
5784
|
.brand{font-size:12px;color:#a8a29e;margin:18px 0 0;text-align:center}
|
|
5497
5785
|
</style></head><body><form class="card" method="POST" action="/p/${esc(row.slug)}/gate">
|
|
5498
|
-
<h1>Protected document</h1><p>${esc(note)}</p>${err}${
|
|
5786
|
+
<h1>Protected document</h1><p>${esc(note)}</p>${err}${field2}
|
|
5499
5787
|
<button type="submit">${cta}</button>
|
|
5500
5788
|
<p class="brand">Secured by PromptOwl \xB7 ContextNest</p>
|
|
5501
5789
|
</form>${wantsCode ? GATE_EYE_SCRIPT : ""}</body></html>`;
|
|
@@ -6075,6 +6363,7 @@ function toSearchHit(node, query) {
|
|
|
6075
6363
|
|
|
6076
6364
|
// src/nodes/routes.ts
|
|
6077
6365
|
import { Hono as Hono11 } from "hono";
|
|
6366
|
+
import { parseDocument as parseDocument3, readPdfBinary as readPdfBinary2 } from "@promptowl/contextnest-engine";
|
|
6078
6367
|
|
|
6079
6368
|
// src/nodes/markdown-export.ts
|
|
6080
6369
|
import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
|
|
@@ -6191,7 +6480,8 @@ nodeRoutes.post("/", async (c) => {
|
|
|
6191
6480
|
status: body.status,
|
|
6192
6481
|
schedule: body.schedule,
|
|
6193
6482
|
folder: body.folder,
|
|
6194
|
-
prime: body.prime
|
|
6483
|
+
prime: body.prime,
|
|
6484
|
+
client: attributeRequest(c, body.client)
|
|
6195
6485
|
},
|
|
6196
6486
|
authorEmail
|
|
6197
6487
|
);
|
|
@@ -6208,7 +6498,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
6208
6498
|
nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
6209
6499
|
const nestId = c.req.param("nestId");
|
|
6210
6500
|
const nodeId = c.req.param("nodeId");
|
|
6211
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
6501
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-S22V2JKG.js");
|
|
6212
6502
|
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
6213
6503
|
nestId,
|
|
6214
6504
|
nodeId
|
|
@@ -6229,7 +6519,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
6229
6519
|
nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
6230
6520
|
const nestId = c.req.param("nestId");
|
|
6231
6521
|
const nodeId = c.req.param("nodeId");
|
|
6232
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
6522
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-4G2XUFS6.js");
|
|
6233
6523
|
const history = await getReviewHistory2(nestId, nodeId);
|
|
6234
6524
|
return c.json({ reviews: history });
|
|
6235
6525
|
});
|
|
@@ -6250,7 +6540,7 @@ nodeRoutes.post("/:nodeId{.+}/discard", async (c) => {
|
|
|
6250
6540
|
nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
6251
6541
|
const nestId = c.req.param("nestId");
|
|
6252
6542
|
const nodeId = c.req.param("nodeId");
|
|
6253
|
-
const { versions: versionManager } = await engineCache.get(nestId);
|
|
6543
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
6254
6544
|
const userId = c.get("userId");
|
|
6255
6545
|
const userEmail = await resolveCallerEmail(userId);
|
|
6256
6546
|
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
@@ -6272,11 +6562,26 @@ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
|
6272
6562
|
`Version ${targetVersion} not found for ${nodeId}`
|
|
6273
6563
|
);
|
|
6274
6564
|
}
|
|
6565
|
+
let restoredPdf = null;
|
|
6566
|
+
if (pdfMetaOf(nodeId, raw)) {
|
|
6567
|
+
try {
|
|
6568
|
+
restoredPdf = await readPdfBinary2(storage, nodeId, {
|
|
6569
|
+
version: targetVersion
|
|
6570
|
+
});
|
|
6571
|
+
} catch (err) {
|
|
6572
|
+
console.error("[pdf] revert could not read the version's binary", nestId, nodeId, targetVersion, err);
|
|
6573
|
+
throw new NotFoundError(`The PDF of version ${targetVersion} is not available to restore.`);
|
|
6574
|
+
}
|
|
6575
|
+
}
|
|
6275
6576
|
const content = bodyOnly(nodeId, raw);
|
|
6276
6577
|
const { node, version } = await updateNode(
|
|
6277
6578
|
nestId,
|
|
6278
6579
|
nodeId,
|
|
6279
|
-
{
|
|
6580
|
+
{
|
|
6581
|
+
...restoredPdf ? { pdf: { bytes: new Uint8Array(restoredPdf) } } : { content },
|
|
6582
|
+
changeNote: `Restored from version ${targetVersion}`,
|
|
6583
|
+
client: attributeRequest(c, body.client)
|
|
6584
|
+
},
|
|
6280
6585
|
userEmail
|
|
6281
6586
|
);
|
|
6282
6587
|
await trackEvent("node.revert", { nestId, nodeId, targetVersion });
|
|
@@ -6368,7 +6673,8 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
6368
6673
|
}
|
|
6369
6674
|
}
|
|
6370
6675
|
response.myGrant = await resolveNodeGrant(nestId, userId, nodeId);
|
|
6371
|
-
|
|
6676
|
+
const approvedOnly = c.req.query("approved_only") === "1" || c.req.query("approved_only") === "true";
|
|
6677
|
+
if (publicReader || approvedOnly) {
|
|
6372
6678
|
const approved = await getApprovedVersion(nestId, nodeId);
|
|
6373
6679
|
if (approved != null) {
|
|
6374
6680
|
let approvedRaw = "";
|
|
@@ -6378,6 +6684,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
6378
6684
|
approved
|
|
6379
6685
|
);
|
|
6380
6686
|
response.content = bodyOnly(nodeId, approvedRaw);
|
|
6687
|
+
if (response.type === "pdf") {
|
|
6688
|
+
const approvedPdf = pdfMetaOf(nodeId, approvedRaw);
|
|
6689
|
+
if (approvedPdf) response.pdf = approvedPdf;
|
|
6690
|
+
}
|
|
6381
6691
|
} catch (err) {
|
|
6382
6692
|
console.error(
|
|
6383
6693
|
"reconstructVersion failed (public single)",
|
|
@@ -6399,6 +6709,9 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
6399
6709
|
}
|
|
6400
6710
|
return c.json({ node: response });
|
|
6401
6711
|
}
|
|
6712
|
+
if (approvedOnly) {
|
|
6713
|
+
throw new NotFoundError("This document has no approved version yet.");
|
|
6714
|
+
}
|
|
6402
6715
|
}
|
|
6403
6716
|
response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
|
|
6404
6717
|
if (response.status === "pending_review") {
|
|
@@ -6423,6 +6736,9 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
6423
6736
|
const nestId = c.req.param("nestId");
|
|
6424
6737
|
const nodeId = c.req.param("nodeId");
|
|
6425
6738
|
const body = await c.req.json();
|
|
6739
|
+
if (body.type !== void 0 && typeof body.type !== "string") {
|
|
6740
|
+
throw new ValidationError("type must be a string");
|
|
6741
|
+
}
|
|
6426
6742
|
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
6427
6743
|
if (baseVersionHeader) {
|
|
6428
6744
|
const baseVersion = parseInt(baseVersionHeader, 10);
|
|
@@ -6453,7 +6769,9 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
6453
6769
|
status: body.status,
|
|
6454
6770
|
changeNote: body.changeNote,
|
|
6455
6771
|
schedule: body.schedule,
|
|
6456
|
-
prime: body.prime
|
|
6772
|
+
prime: body.prime,
|
|
6773
|
+
client: attributeRequest(c, body.client),
|
|
6774
|
+
type: body.type
|
|
6457
6775
|
},
|
|
6458
6776
|
authorEmail
|
|
6459
6777
|
);
|
|
@@ -6465,6 +6783,13 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
|
6465
6783
|
await deleteNode(nestId, nodeId, await getUserEmail(c));
|
|
6466
6784
|
return c.json({ deleted: true });
|
|
6467
6785
|
});
|
|
6786
|
+
function pdfMetaOf(nodeId, raw) {
|
|
6787
|
+
try {
|
|
6788
|
+
return parseDocument3(`${nodeId}.md`, raw, nodeId).frontmatter.pdf;
|
|
6789
|
+
} catch {
|
|
6790
|
+
return void 0;
|
|
6791
|
+
}
|
|
6792
|
+
}
|
|
6468
6793
|
async function getUserEmail(c) {
|
|
6469
6794
|
const userId = c.get("userId");
|
|
6470
6795
|
const db = getDb();
|
|
@@ -6957,7 +7282,18 @@ var STOPWORDS = /* @__PURE__ */ new Set([
|
|
|
6957
7282
|
"ok",
|
|
6958
7283
|
"please",
|
|
6959
7284
|
"thanks",
|
|
6960
|
-
"thank"
|
|
7285
|
+
"thank",
|
|
7286
|
+
// Greetings and fillers a chat turn opens with — never a topic.
|
|
7287
|
+
"hello",
|
|
7288
|
+
"hi",
|
|
7289
|
+
"hey",
|
|
7290
|
+
"there",
|
|
7291
|
+
"good",
|
|
7292
|
+
"morning",
|
|
7293
|
+
"afternoon",
|
|
7294
|
+
"evening",
|
|
7295
|
+
"bye",
|
|
7296
|
+
"cheers"
|
|
6961
7297
|
]);
|
|
6962
7298
|
function tokenizePrompt(prompt) {
|
|
6963
7299
|
const words = prompt.toLowerCase().split(/[^a-z0-9_-]+/).filter(Boolean).filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
|
@@ -6999,6 +7335,34 @@ async function compilePrompt(prompt, nestId, titles) {
|
|
|
6999
7335
|
unmatched
|
|
7000
7336
|
};
|
|
7001
7337
|
}
|
|
7338
|
+
function scorePrompt(doc, tokens) {
|
|
7339
|
+
if (tokens.length === 0) return { score: 0, via: null };
|
|
7340
|
+
const title = doc.frontmatter.title || "";
|
|
7341
|
+
const tags = (doc.frontmatter.tags || []).map(normalizeTag);
|
|
7342
|
+
const body = (doc.body || "").toLowerCase();
|
|
7343
|
+
let sum = 0;
|
|
7344
|
+
let via = null;
|
|
7345
|
+
for (const t of tokens) {
|
|
7346
|
+
const inTitle = titleScore(title, t);
|
|
7347
|
+
if (inTitle > 0) {
|
|
7348
|
+
sum += inTitle;
|
|
7349
|
+
via = "title";
|
|
7350
|
+
} else if (tags.some((tag) => tag.startsWith(t))) {
|
|
7351
|
+
sum += 2;
|
|
7352
|
+
if (via !== "title") via = "tag";
|
|
7353
|
+
} else if (startsWord(body, t)) {
|
|
7354
|
+
sum += 1;
|
|
7355
|
+
via ??= "fulltext";
|
|
7356
|
+
}
|
|
7357
|
+
}
|
|
7358
|
+
return { score: sum / (4 * tokens.length), via };
|
|
7359
|
+
}
|
|
7360
|
+
function startsWord(text, term) {
|
|
7361
|
+
for (let i = text.indexOf(term); i !== -1; i = text.indexOf(term, i + 1)) {
|
|
7362
|
+
if (i === 0 || !/[a-z0-9]/.test(text[i - 1])) return true;
|
|
7363
|
+
}
|
|
7364
|
+
return false;
|
|
7365
|
+
}
|
|
7002
7366
|
|
|
7003
7367
|
// src/nodes/readable-body.ts
|
|
7004
7368
|
async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
|
|
@@ -7151,6 +7515,17 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
|
7151
7515
|
}
|
|
7152
7516
|
}
|
|
7153
7517
|
const tags = [...tagCounts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
|
|
7518
|
+
for (const docIds of tagToDocs.values()) {
|
|
7519
|
+
const ids = [...docIds].sort();
|
|
7520
|
+
for (let i = 0; i < ids.length; i++) {
|
|
7521
|
+
for (let j = i + 1; j < ids.length; j++) {
|
|
7522
|
+
const key = `${ids[i]}->${ids[j]}`;
|
|
7523
|
+
if (seen.has(key) || seen.has(`${ids[j]}->${ids[i]}`)) continue;
|
|
7524
|
+
seen.add(key);
|
|
7525
|
+
allLinks.push({ source: ids[i], target: ids[j] });
|
|
7526
|
+
}
|
|
7527
|
+
}
|
|
7528
|
+
}
|
|
7154
7529
|
const exposeStewards = await isStewardshipEnabled(nestId) && !await isPublicReader(nestId, userId);
|
|
7155
7530
|
const stewardRows = exposeStewards ? (await listStewards({ nestId })).filter(
|
|
7156
7531
|
// Drop document-scoped rows whose node the caller can't see.
|
|
@@ -7393,7 +7768,8 @@ queryRoutes.post("/context", async (c) => {
|
|
|
7393
7768
|
const baseUrl = requestBaseUrl(c.req.url);
|
|
7394
7769
|
const { query: queryEngine, storage } = await engineCache.get(nestId);
|
|
7395
7770
|
const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
|
|
7396
|
-
const
|
|
7771
|
+
const promptPath = !body.selector?.trim() && !!body.prompt;
|
|
7772
|
+
const hops = body.hops ?? (promptPath ? 0 : 2);
|
|
7397
7773
|
const includeDrafts = body.include_drafts === true;
|
|
7398
7774
|
const isVisible = (d) => includeDrafts || d.frontmatter.status === "published";
|
|
7399
7775
|
let selector = body.selector?.trim() || null;
|
|
@@ -7427,7 +7803,7 @@ queryRoutes.post("/context", async (c) => {
|
|
|
7427
7803
|
compileDetail.matchedTitles.map((t) => t.toLowerCase())
|
|
7428
7804
|
);
|
|
7429
7805
|
titleMatches = allDocs.filter(
|
|
7430
|
-
(d) => matchedLower.has(d.frontmatter.title.toLowerCase())
|
|
7806
|
+
(d) => matchedLower.has(d.frontmatter.title.toLowerCase()) && isVisible(d)
|
|
7431
7807
|
);
|
|
7432
7808
|
}
|
|
7433
7809
|
}
|
|
@@ -7491,6 +7867,27 @@ queryRoutes.post("/context", async (c) => {
|
|
|
7491
7867
|
}
|
|
7492
7868
|
}
|
|
7493
7869
|
}
|
|
7870
|
+
const relevance = /* @__PURE__ */ new Map();
|
|
7871
|
+
let fallback = null;
|
|
7872
|
+
if (compileDetail) {
|
|
7873
|
+
const tokens = tokenizePrompt(body.prompt);
|
|
7874
|
+
const compiled = new Set(documents.map((d) => d.id));
|
|
7875
|
+
for (const d of allDocs) {
|
|
7876
|
+
const { score, via } = scorePrompt(d, tokens);
|
|
7877
|
+
if (compiled.has(d.id)) relevance.set(d.id, { score, via: via ?? "hop" });
|
|
7878
|
+
else if (score > 0 && isVisible(d)) {
|
|
7879
|
+
relevance.set(d.id, { score, via });
|
|
7880
|
+
documents.push(d);
|
|
7881
|
+
}
|
|
7882
|
+
}
|
|
7883
|
+
if (compiled.size === 0 && documents.length > 0) fallback = "fulltext";
|
|
7884
|
+
const top = Math.max(0, ...documents.map((d) => relevance.get(d.id).score));
|
|
7885
|
+
documents = documents.filter((d) => {
|
|
7886
|
+
const r = relevance.get(d.id);
|
|
7887
|
+
return compiled.has(d.id) || r.via === "hop" || r.score >= top / 2;
|
|
7888
|
+
});
|
|
7889
|
+
documents.sort((a, b) => relevance.get(b.id).score - relevance.get(a.id).score);
|
|
7890
|
+
}
|
|
7494
7891
|
const userId = c.get("userId");
|
|
7495
7892
|
const userEmail = await resolveCallerEmail(userId);
|
|
7496
7893
|
const beforePermission = documents.length;
|
|
@@ -7508,10 +7905,14 @@ queryRoutes.post("/context", async (c) => {
|
|
|
7508
7905
|
for (const doc of readable) {
|
|
7509
7906
|
const block = formatContextBlock(doc, doc.url);
|
|
7510
7907
|
const blockTokens = approxTokens(block);
|
|
7511
|
-
if (tokenCount + blockTokens > maxTokens
|
|
7908
|
+
if (tokenCount + blockTokens > maxTokens) continue;
|
|
7512
7909
|
included.push(doc);
|
|
7513
7910
|
tokenCount += blockTokens;
|
|
7514
7911
|
}
|
|
7912
|
+
if (included.length === 0 && readable.length > 0) {
|
|
7913
|
+
included.push(readable[0]);
|
|
7914
|
+
tokenCount = approxTokens(formatContextBlock(readable[0], readable[0].url));
|
|
7915
|
+
}
|
|
7515
7916
|
const truncatedByBudget = accessible.length - included.length;
|
|
7516
7917
|
const contextMarkdown = included.map((n) => formatContextBlock(n, n.url)).join("\n\n---\n\n");
|
|
7517
7918
|
trackEvent("context.resolve", {
|
|
@@ -7528,7 +7929,8 @@ queryRoutes.post("/context", async (c) => {
|
|
|
7528
7929
|
title: n.frontmatter.title,
|
|
7529
7930
|
url: n.url,
|
|
7530
7931
|
tags: n.frontmatter.tags || [],
|
|
7531
|
-
type: n.frontmatter.type || "document"
|
|
7932
|
+
type: n.frontmatter.type || "document",
|
|
7933
|
+
...relevance.get(n.id)
|
|
7532
7934
|
})),
|
|
7533
7935
|
trace: {
|
|
7534
7936
|
selector_used: selector,
|
|
@@ -7536,7 +7938,8 @@ queryRoutes.post("/context", async (c) => {
|
|
|
7536
7938
|
compiled: compileDetail ? {
|
|
7537
7939
|
matched_tags: compileDetail.matchedTags,
|
|
7538
7940
|
matched_titles: compileDetail.matchedTitles,
|
|
7539
|
-
unmatched_tokens: compileDetail.unmatched
|
|
7941
|
+
unmatched_tokens: compileDetail.unmatched,
|
|
7942
|
+
fallback
|
|
7540
7943
|
} : null,
|
|
7541
7944
|
total_matched: documents.length,
|
|
7542
7945
|
permission_filtered: permissionFiltered,
|
|
@@ -7721,11 +8124,11 @@ queryRoutes.get("/export", async (c) => {
|
|
|
7721
8124
|
let tokens = 0;
|
|
7722
8125
|
for (const f of fields) {
|
|
7723
8126
|
const t = approxTokens(nodeToMarkdown(f));
|
|
7724
|
-
if (tokens + t > maxTokens
|
|
8127
|
+
if (tokens + t > maxTokens) continue;
|
|
7725
8128
|
kept.push(f);
|
|
7726
8129
|
tokens += t;
|
|
7727
8130
|
}
|
|
7728
|
-
included = kept;
|
|
8131
|
+
included = kept.length > 0 ? kept : fields.slice(0, 1);
|
|
7729
8132
|
}
|
|
7730
8133
|
trackEvent("nest.export", {
|
|
7731
8134
|
nestId,
|
|
@@ -7771,7 +8174,8 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
7771
8174
|
content: doc.content,
|
|
7772
8175
|
type: doc.type,
|
|
7773
8176
|
tags: doc.tags,
|
|
7774
|
-
scope: doc.scope
|
|
8177
|
+
scope: doc.scope,
|
|
8178
|
+
folder: doc.folder
|
|
7775
8179
|
},
|
|
7776
8180
|
userEmail
|
|
7777
8181
|
);
|
|
@@ -7807,6 +8211,9 @@ import { Hono as Hono15 } from "hono";
|
|
|
7807
8211
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7808
8212
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
7809
8213
|
|
|
8214
|
+
// src/mcp/tools.ts
|
|
8215
|
+
import { parseDocument as parseDocument4 } from "@promptowl/contextnest-engine";
|
|
8216
|
+
|
|
7810
8217
|
// src/telemetry/trace-log.ts
|
|
7811
8218
|
var PRUNE_EVERY = 500;
|
|
7812
8219
|
var insertsSincePrune = 0;
|
|
@@ -7815,8 +8222,8 @@ async function logTraceEvent(e) {
|
|
|
7815
8222
|
const db = getDb();
|
|
7816
8223
|
await db.run(
|
|
7817
8224
|
`INSERT INTO api_events
|
|
7818
|
-
(ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms)
|
|
7819
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
8225
|
+
(ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms, client_json)
|
|
8226
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
7820
8227
|
[
|
|
7821
8228
|
(/* @__PURE__ */ new Date()).toISOString(),
|
|
7822
8229
|
e.kind,
|
|
@@ -7827,7 +8234,8 @@ async function logTraceEvent(e) {
|
|
|
7827
8234
|
e.userId ?? null,
|
|
7828
8235
|
e.userEmail ?? null,
|
|
7829
8236
|
e.status ?? null,
|
|
7830
|
-
e.durationMs ?? null
|
|
8237
|
+
e.durationMs ?? null,
|
|
8238
|
+
serializeClient(e.client)
|
|
7831
8239
|
]
|
|
7832
8240
|
);
|
|
7833
8241
|
if (++insertsSincePrune >= PRUNE_EVERY) {
|
|
@@ -7876,7 +8284,13 @@ async function listTraceEvents(filters = {}) {
|
|
|
7876
8284
|
LIMIT ? OFFSET ?`,
|
|
7877
8285
|
[...args, limit, offset]
|
|
7878
8286
|
);
|
|
7879
|
-
return {
|
|
8287
|
+
return {
|
|
8288
|
+
events: events.map(({ client_json, ...e }) => {
|
|
8289
|
+
const client = deserializeClient(client_json);
|
|
8290
|
+
return client ? { ...e, client } : e;
|
|
8291
|
+
}),
|
|
8292
|
+
total
|
|
8293
|
+
};
|
|
7880
8294
|
} catch {
|
|
7881
8295
|
return { events: [], total: 0 };
|
|
7882
8296
|
}
|
|
@@ -8924,6 +9338,19 @@ function summarizeNode(node, body) {
|
|
|
8924
9338
|
body: body ?? void 0
|
|
8925
9339
|
};
|
|
8926
9340
|
}
|
|
9341
|
+
function pdfSourceLine(node, body) {
|
|
9342
|
+
if ((node.frontmatter.type || "document") !== "pdf") return null;
|
|
9343
|
+
let pdf = node.frontmatter.pdf;
|
|
9344
|
+
if (body && body.startsWith("---")) {
|
|
9345
|
+
try {
|
|
9346
|
+
pdf = parseDocument4(`${node.id}.md`, body, node.id).frontmatter.pdf ?? pdf;
|
|
9347
|
+
} catch {
|
|
9348
|
+
}
|
|
9349
|
+
}
|
|
9350
|
+
if (!pdf) return "**Source file:** PDF";
|
|
9351
|
+
const pages = `${pdf.pages} page${pdf.pages === 1 ? "" : "s"}`;
|
|
9352
|
+
return `**Source file:** PDF, ${pages}${pdf.text_layer ? "" : ", no text layer"}`;
|
|
9353
|
+
}
|
|
8927
9354
|
var TOOL_DEFINITIONS = [
|
|
8928
9355
|
{
|
|
8929
9356
|
name: "context_init",
|
|
@@ -9154,7 +9581,7 @@ var TOOL_DEFINITIONS = [
|
|
|
9154
9581
|
tags: {
|
|
9155
9582
|
type: "array",
|
|
9156
9583
|
items: { type: "string" },
|
|
9157
|
-
description: "
|
|
9584
|
+
description: "The node's full tag set \u2014 REPLACES the existing tags (same as the engine catalog). To add one, send the current tags plus the new one; to remove one, send the set without it."
|
|
9158
9585
|
},
|
|
9159
9586
|
status: {
|
|
9160
9587
|
type: "string",
|
|
@@ -9323,10 +9750,14 @@ var TOOL_DEFINITIONS = [
|
|
|
9323
9750
|
type: "object",
|
|
9324
9751
|
properties: {
|
|
9325
9752
|
title: { type: "string", description: "Title of the node to submit" },
|
|
9753
|
+
id: {
|
|
9754
|
+
type: "string",
|
|
9755
|
+
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.'
|
|
9756
|
+
},
|
|
9326
9757
|
note: { type: "string", description: "Optional note for the reviewer" },
|
|
9327
9758
|
priority: { type: "string", description: "Priority: low, normal (default), high, urgent" }
|
|
9328
|
-
}
|
|
9329
|
-
|
|
9759
|
+
}
|
|
9760
|
+
// One of id or title — see context_update.
|
|
9330
9761
|
}
|
|
9331
9762
|
},
|
|
9332
9763
|
{
|
|
@@ -9464,8 +9895,30 @@ async function resolveLlmBody(ctx, node, gate) {
|
|
|
9464
9895
|
async function handleToolCall(toolName, args, ctx) {
|
|
9465
9896
|
const started = Date.now();
|
|
9466
9897
|
let status = 200;
|
|
9898
|
+
let client;
|
|
9899
|
+
try {
|
|
9900
|
+
client = mergeClient(ctx.client, parseClientMetadata(args.client));
|
|
9901
|
+
} catch (err) {
|
|
9902
|
+
void logTraceEvent({
|
|
9903
|
+
kind: "mcp",
|
|
9904
|
+
tool: toolName,
|
|
9905
|
+
nestId: ctx.nestId,
|
|
9906
|
+
userId: ctx.userId,
|
|
9907
|
+
userEmail: ctx.userEmail,
|
|
9908
|
+
status: 400,
|
|
9909
|
+
durationMs: Date.now() - started
|
|
9910
|
+
});
|
|
9911
|
+
return toolError(err.message, "VALIDATION_FAILED");
|
|
9912
|
+
}
|
|
9913
|
+
const attributed = { ...ctx, client };
|
|
9914
|
+
if ((toolName === "context_create" || toolName === "context_update") && typeof args.content === "string" && /^\s*---\r?\n[\w-]+:/.test(args.content)) {
|
|
9915
|
+
return toolError(
|
|
9916
|
+
"`content` is the markdown body only \u2014 it starts with a YAML frontmatter block, which would be stored as body text. Drop the `---` block and pass tags/status as parameters; the server owns the frontmatter.",
|
|
9917
|
+
"VALIDATION_FAILED"
|
|
9918
|
+
);
|
|
9919
|
+
}
|
|
9467
9920
|
try {
|
|
9468
|
-
return await runTool(toolName, args,
|
|
9921
|
+
return await runTool(toolName, args, attributed);
|
|
9469
9922
|
} catch (err) {
|
|
9470
9923
|
if (isVaultLockTimeout(err) || err instanceof VaultBusyError) {
|
|
9471
9924
|
status = 503;
|
|
@@ -9484,7 +9937,10 @@ async function handleToolCall(toolName, args, ctx) {
|
|
|
9484
9937
|
userId: ctx.userId,
|
|
9485
9938
|
userEmail: ctx.userEmail,
|
|
9486
9939
|
status,
|
|
9487
|
-
durationMs: Date.now() - started
|
|
9940
|
+
durationMs: Date.now() - started,
|
|
9941
|
+
// Reads record attribution HERE — the activity trace is this edition's
|
|
9942
|
+
// §9.2 access trace, and a read seals no version to carry it.
|
|
9943
|
+
client
|
|
9488
9944
|
});
|
|
9489
9945
|
}
|
|
9490
9946
|
}
|
|
@@ -9679,7 +10135,8 @@ ${list}`,
|
|
|
9679
10135
|
const meta = [
|
|
9680
10136
|
`**Title:** ${node.frontmatter.title}`,
|
|
9681
10137
|
`**Type:** ${node.frontmatter.type || "document"}`,
|
|
9682
|
-
node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null
|
|
10138
|
+
node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null,
|
|
10139
|
+
pdfSourceLine(node, body)
|
|
9683
10140
|
].filter(Boolean).join("\n");
|
|
9684
10141
|
return {
|
|
9685
10142
|
text: `${meta}
|
|
@@ -9942,7 +10399,8 @@ ${sections}`;
|
|
|
9942
10399
|
type: args.type,
|
|
9943
10400
|
tags: args.tags,
|
|
9944
10401
|
scope: args.scope,
|
|
9945
|
-
folder: args.folder
|
|
10402
|
+
folder: args.folder,
|
|
10403
|
+
client: ctx.client
|
|
9946
10404
|
},
|
|
9947
10405
|
userEmail
|
|
9948
10406
|
);
|
|
@@ -9970,16 +10428,16 @@ ${sections}`;
|
|
|
9970
10428
|
"UNAUTHORIZED_ACTION"
|
|
9971
10429
|
);
|
|
9972
10430
|
}
|
|
9973
|
-
const mergedTags = args.tags ? [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...args.tags])] : void 0;
|
|
9974
10431
|
const { node: updated, version } = await updateNode(
|
|
9975
10432
|
nestId,
|
|
9976
10433
|
node.id,
|
|
9977
10434
|
{
|
|
9978
10435
|
content: args.content,
|
|
9979
10436
|
append: args.append,
|
|
9980
|
-
tags:
|
|
10437
|
+
tags: args.tags,
|
|
9981
10438
|
status: args.status,
|
|
9982
|
-
changeNote: args.changeNote
|
|
10439
|
+
changeNote: args.changeNote,
|
|
10440
|
+
client: ctx.client
|
|
9983
10441
|
},
|
|
9984
10442
|
userEmail
|
|
9985
10443
|
);
|
|
@@ -10032,10 +10490,11 @@ Use context_request_deletion to ask a nest owner/admin to delete it, with a reas
|
|
|
10032
10490
|
args.folder,
|
|
10033
10491
|
userEmail
|
|
10034
10492
|
);
|
|
10493
|
+
const data = { id: moved.id, previous_id: previousId, title: moved.frontmatter.title };
|
|
10035
10494
|
if (previousId === moved.id) {
|
|
10036
|
-
return `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move
|
|
10495
|
+
return { text: `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`, data };
|
|
10037
10496
|
}
|
|
10038
|
-
return `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}
|
|
10497
|
+
return { text: `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``, data };
|
|
10039
10498
|
}
|
|
10040
10499
|
// ─── Governance Tool Handlers ──────────────────────────────────────
|
|
10041
10500
|
case "context_stewards": {
|
|
@@ -10256,21 +10715,20 @@ ${list}${more}
|
|
|
10256
10715
|
Submit one with \`context_submit_review\`.`;
|
|
10257
10716
|
}
|
|
10258
10717
|
case "context_submit_review": {
|
|
10259
|
-
const
|
|
10260
|
-
|
|
10261
|
-
|
|
10262
|
-
|
|
10263
|
-
if (!node) return toolError(`Node not found: ${args.title}`, "DOCUMENT_NOT_FOUND");
|
|
10718
|
+
const resolved = await resolveTargetNode(storage, args);
|
|
10719
|
+
if (typeof resolved === "string") return toolError(resolved, "DOCUMENT_NOT_FOUND");
|
|
10720
|
+
const node = resolved;
|
|
10721
|
+
const nodeLabel = args.title || args.id;
|
|
10264
10722
|
const submitCheck = await canUserEdit(ctx.nestId, node.id, userEmail);
|
|
10265
10723
|
if (!submitCheck.allowed) {
|
|
10266
10724
|
return toolError(
|
|
10267
|
-
`You don't have permission to submit "${
|
|
10725
|
+
`You don't have permission to submit "${nodeLabel}" for review: ${submitCheck.reason}`,
|
|
10268
10726
|
"UNAUTHORIZED_ACTION"
|
|
10269
10727
|
);
|
|
10270
10728
|
}
|
|
10271
10729
|
const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
|
|
10272
10730
|
if (currentVersion === 0)
|
|
10273
|
-
return toolError(`No versions found for "${
|
|
10731
|
+
return toolError(`No versions found for "${nodeLabel}"`, "VERSION_NOT_FOUND");
|
|
10274
10732
|
try {
|
|
10275
10733
|
const request = await submitForReview({
|
|
10276
10734
|
nestId: ctx.nestId,
|
|
@@ -10279,18 +10737,19 @@ Submit one with \`context_submit_review\`.`;
|
|
|
10279
10737
|
requestedBy: ctx.userEmail,
|
|
10280
10738
|
note: args.note,
|
|
10281
10739
|
priority: args.priority,
|
|
10282
|
-
baseUrl: ctx.baseUrl
|
|
10740
|
+
baseUrl: ctx.baseUrl,
|
|
10741
|
+
client: ctx.client
|
|
10283
10742
|
});
|
|
10284
|
-
const
|
|
10743
|
+
const resolved2 = await resolveStewardsForNode(
|
|
10285
10744
|
ctx.nestId,
|
|
10286
10745
|
node.id
|
|
10287
10746
|
);
|
|
10288
|
-
const stewardList =
|
|
10747
|
+
const stewardList = resolved2.length > 0 ? `
|
|
10289
10748
|
|
|
10290
10749
|
**Stewards who can review:**
|
|
10291
|
-
${
|
|
10750
|
+
${resolved2.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
|
|
10292
10751
|
return {
|
|
10293
|
-
text: `Submitted "${
|
|
10752
|
+
text: `Submitted "${nodeLabel}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
|
|
10294
10753
|
// No catalog shape to conform to — `context_submit_review` is a
|
|
10295
10754
|
// governance-namespace operation and that namespace is declared but
|
|
10296
10755
|
// unimplemented in the engine (`api/index.ts`, NAMESPACES). So this
|
|
@@ -10311,7 +10770,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
|
|
|
10311
10770
|
requested_by: request.requestedBy,
|
|
10312
10771
|
requested_at: request.requestedAt
|
|
10313
10772
|
},
|
|
10314
|
-
reviewers:
|
|
10773
|
+
reviewers: resolved2.map((r) => r.steward.userEmail)
|
|
10315
10774
|
}
|
|
10316
10775
|
};
|
|
10317
10776
|
} catch (err) {
|
|
@@ -10330,7 +10789,8 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
|
|
|
10330
10789
|
nodeId: node.id,
|
|
10331
10790
|
approvedBy: ctx.userEmail,
|
|
10332
10791
|
note: args.note,
|
|
10333
|
-
baseUrl: ctx.baseUrl
|
|
10792
|
+
baseUrl: ctx.baseUrl,
|
|
10793
|
+
client: ctx.client
|
|
10334
10794
|
});
|
|
10335
10795
|
return `Approved "${args.title}" v${request.version}. This version is now live for AI queries.${args.note ? `
|
|
10336
10796
|
Note: ${args.note}` : ""}`;
|
|
@@ -10371,7 +10831,9 @@ Reason: ${args.note}`;
|
|
|
10371
10831
|
};
|
|
10372
10832
|
}
|
|
10373
10833
|
const list = allVersions.map(
|
|
10374
|
-
(v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}
|
|
10834
|
+
(v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}${// §9.4: which agent wrote it. In the prose as well as the
|
|
10835
|
+
// structured payload — a chat client reads only this half.
|
|
10836
|
+
v.client?.agent ? ` [via ${v.client.agent}]` : ""}`
|
|
10375
10837
|
).join("\n");
|
|
10376
10838
|
return {
|
|
10377
10839
|
text: `# Version History: ${label}
|
|
@@ -10395,7 +10857,11 @@ ${list}`,
|
|
|
10395
10857
|
status: v.status,
|
|
10396
10858
|
edited_by: v.editedBy,
|
|
10397
10859
|
edited_at: v.editedAt,
|
|
10398
|
-
note: v.changeNote
|
|
10860
|
+
note: v.changeNote,
|
|
10861
|
+
// §9.4: which agent wrote this version, in which session. Omitted
|
|
10862
|
+
// (not `{}`) when the write carried no attribution — "not
|
|
10863
|
+
// attributed" and "attributed to nobody" are different claims.
|
|
10864
|
+
...v.client ? { client: v.client } : {}
|
|
10399
10865
|
}))
|
|
10400
10866
|
}
|
|
10401
10867
|
};
|
|
@@ -10456,8 +10922,8 @@ ${list}`,
|
|
|
10456
10922
|
if (!target) return "target is required (a node id or folder prefix).";
|
|
10457
10923
|
if (!["read", "write"].includes(role)) return "role must be read or write.";
|
|
10458
10924
|
try {
|
|
10459
|
-
const { createGrant: createGrant2 } = await import("./grants-service-
|
|
10460
|
-
const db = (await import("./client-
|
|
10925
|
+
const { createGrant: createGrant2 } = await import("./grants-service-2OTFS7EH.js");
|
|
10926
|
+
const db = (await import("./client-D44ZHDTJ.js")).getDb();
|
|
10461
10927
|
const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
|
|
10462
10928
|
const e = normalizeEmail2(String(args.email || ""));
|
|
10463
10929
|
if (!e) return "email is required.";
|
|
@@ -10506,7 +10972,8 @@ ${list}`;
|
|
|
10506
10972
|
const { nest, documents } = await syncUnsyncedFolder(
|
|
10507
10973
|
userId,
|
|
10508
10974
|
args.name,
|
|
10509
|
-
userEmail
|
|
10975
|
+
userEmail,
|
|
10976
|
+
ctx.client
|
|
10510
10977
|
);
|
|
10511
10978
|
return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
|
|
10512
10979
|
} catch (err) {
|
|
@@ -10529,7 +10996,7 @@ async function getUserEmail2(userId) {
|
|
|
10529
10996
|
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
10530
10997
|
return user?.email || "anonymous@localhost";
|
|
10531
10998
|
}
|
|
10532
|
-
function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
|
|
10999
|
+
function createMcpServerForNest(nestId, userId, userEmail, baseUrl, c) {
|
|
10533
11000
|
const server = new McpServer(
|
|
10534
11001
|
{ name: `contextnest-${nestId}`, version: "1.0.0" },
|
|
10535
11002
|
{ capabilities: { tools: {} } }
|
|
@@ -10540,16 +11007,18 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
|
|
|
10540
11007
|
const required = tool.inputSchema.required || [];
|
|
10541
11008
|
const shape = {};
|
|
10542
11009
|
for (const [key, def] of Object.entries(props)) {
|
|
10543
|
-
let
|
|
10544
|
-
if (def.type === "string")
|
|
10545
|
-
else if (def.type === "number")
|
|
10546
|
-
else if (def.type === "array")
|
|
10547
|
-
else
|
|
10548
|
-
if (!required.includes(key))
|
|
10549
|
-
shape[key] =
|
|
10550
|
-
}
|
|
11010
|
+
let field2;
|
|
11011
|
+
if (def.type === "string") field2 = z.string();
|
|
11012
|
+
else if (def.type === "number") field2 = z.number();
|
|
11013
|
+
else if (def.type === "array") field2 = z.array(z.string());
|
|
11014
|
+
else field2 = z.any();
|
|
11015
|
+
if (!required.includes(key)) field2 = field2.optional();
|
|
11016
|
+
shape[key] = field2;
|
|
11017
|
+
}
|
|
11018
|
+
Object.assign(shape, clientToolField);
|
|
10551
11019
|
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
10552
11020
|
const engine = await engineCache.get(nestId);
|
|
11021
|
+
const client = attributeRequest(c, void 0, connectionClient(server));
|
|
10553
11022
|
const out = await handleToolCall(tool.name, args, {
|
|
10554
11023
|
storage: engine.storage,
|
|
10555
11024
|
queryEngine: engine.query,
|
|
@@ -10557,7 +11026,8 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
|
|
|
10557
11026
|
nestId,
|
|
10558
11027
|
userId,
|
|
10559
11028
|
userEmail,
|
|
10560
|
-
baseUrl
|
|
11029
|
+
baseUrl,
|
|
11030
|
+
client
|
|
10561
11031
|
});
|
|
10562
11032
|
return toCallToolResult(out);
|
|
10563
11033
|
});
|
|
@@ -10572,7 +11042,8 @@ mcpRoutes.all("/", async (c) => {
|
|
|
10572
11042
|
nestId,
|
|
10573
11043
|
userId,
|
|
10574
11044
|
userEmail,
|
|
10575
|
-
requestBaseUrl(c.req.url)
|
|
11045
|
+
requestBaseUrl(c.req.url),
|
|
11046
|
+
c
|
|
10576
11047
|
);
|
|
10577
11048
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
10578
11049
|
sessionIdGenerator: void 0,
|
|
@@ -11642,8 +12113,185 @@ assetRoutes.get("/:file", async (c) => {
|
|
|
11642
12113
|
});
|
|
11643
12114
|
});
|
|
11644
12115
|
|
|
11645
|
-
// src/
|
|
12116
|
+
// src/nodes/pdf-routes.ts
|
|
11646
12117
|
import { Hono as Hono22 } from "hono";
|
|
12118
|
+
import { bodyLimit } from "hono/body-limit";
|
|
12119
|
+
import { createHash as createHash3 } from "crypto";
|
|
12120
|
+
import { isPdf, readPdfBinary as readPdfBinary3 } from "@promptowl/contextnest-engine";
|
|
12121
|
+
var pdfNodeRoutes = new Hono22();
|
|
12122
|
+
var PDF_UPLOAD_PATH = /^\/nests\/[^/]+\/nodes\/pdf\/?$/;
|
|
12123
|
+
function pdfMaxRequestBytes() {
|
|
12124
|
+
return config.PDF_MAX_BYTES + 1024 * 1024;
|
|
12125
|
+
}
|
|
12126
|
+
function tooLarge(bytes) {
|
|
12127
|
+
return new PayloadTooLargeError(
|
|
12128
|
+
`PDF too large (${(bytes / (1024 * 1024)).toFixed(1)} MB, max ${config.PDF_MAX_BYTES / (1024 * 1024)} MB)`
|
|
12129
|
+
);
|
|
12130
|
+
}
|
|
12131
|
+
function field(body, name) {
|
|
12132
|
+
const v = body[name];
|
|
12133
|
+
const one = Array.isArray(v) ? v[0] : v;
|
|
12134
|
+
return typeof one === "string" ? one.trim() : "";
|
|
12135
|
+
}
|
|
12136
|
+
function tagsField(body) {
|
|
12137
|
+
const v = body["tags"];
|
|
12138
|
+
if (v === void 0) return void 0;
|
|
12139
|
+
const raw = (Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string");
|
|
12140
|
+
const tags = raw.flatMap((s) => s.split(",")).map((t) => t.trim().replace(/^#/, "")).filter(Boolean);
|
|
12141
|
+
return tags;
|
|
12142
|
+
}
|
|
12143
|
+
function titleFromFilename(name) {
|
|
12144
|
+
return name.split(/[/\\]/).pop().replace(/\.pdf$/i, "").replace(/[_]+/g, " ").trim().slice(0, 200);
|
|
12145
|
+
}
|
|
12146
|
+
pdfNodeRoutes.post(
|
|
12147
|
+
"/pdf",
|
|
12148
|
+
(c, next) => bodyLimit({
|
|
12149
|
+
maxSize: pdfMaxRequestBytes(),
|
|
12150
|
+
onError: (c2) => c2.json(
|
|
12151
|
+
{
|
|
12152
|
+
error: `PDF too large (max ${config.PDF_MAX_BYTES / (1024 * 1024)} MB)`
|
|
12153
|
+
},
|
|
12154
|
+
413
|
|
12155
|
+
)
|
|
12156
|
+
})(c, next),
|
|
12157
|
+
async (c) => {
|
|
12158
|
+
const nestId = c.req.param("nestId");
|
|
12159
|
+
const lenStr = c.req.header("Content-Length");
|
|
12160
|
+
if (lenStr) {
|
|
12161
|
+
const len = parseInt(lenStr, 10);
|
|
12162
|
+
if (Number.isFinite(len) && len > pdfMaxRequestBytes()) throw tooLarge(len);
|
|
12163
|
+
}
|
|
12164
|
+
const body = await c.req.parseBody({ all: true });
|
|
12165
|
+
const fileField = Array.isArray(body["file"]) ? body["file"][0] : body["file"];
|
|
12166
|
+
if (!(fileField instanceof File)) {
|
|
12167
|
+
throw new ValidationError('multipart field "file" (a PDF) is required');
|
|
12168
|
+
}
|
|
12169
|
+
const file = fileField;
|
|
12170
|
+
if (file.size > config.PDF_MAX_BYTES) throw tooLarge(file.size);
|
|
12171
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
12172
|
+
if (!isPdf(bytes)) {
|
|
12173
|
+
throw new ValidationError("That file is not a PDF (it does not start with %PDF-).");
|
|
12174
|
+
}
|
|
12175
|
+
const userEmail = await resolveCallerEmail(c.get("userId"));
|
|
12176
|
+
const title = field(body, "title");
|
|
12177
|
+
const tags = tagsField(body);
|
|
12178
|
+
const client = attributeRequest(c, void 0);
|
|
12179
|
+
const pdf = { bytes, filename: file.name || void 0 };
|
|
12180
|
+
const targetId = field(body, "nodeId");
|
|
12181
|
+
if (targetId) {
|
|
12182
|
+
assertSafeNodeId(targetId);
|
|
12183
|
+
const changeNote = field(body, "changeNote") || "Replaced the PDF";
|
|
12184
|
+
const { node: node2, version: version2 } = await updateNode(
|
|
12185
|
+
nestId,
|
|
12186
|
+
targetId,
|
|
12187
|
+
{
|
|
12188
|
+
pdf,
|
|
12189
|
+
...title ? { title } : {},
|
|
12190
|
+
...tags ? { tags } : {},
|
|
12191
|
+
changeNote,
|
|
12192
|
+
client
|
|
12193
|
+
},
|
|
12194
|
+
userEmail
|
|
12195
|
+
);
|
|
12196
|
+
return c.json({ node: toNodeResponse(node2), version: version2 });
|
|
12197
|
+
}
|
|
12198
|
+
const folder = field(body, "folder");
|
|
12199
|
+
const { node, version } = await createNode(
|
|
12200
|
+
nestId,
|
|
12201
|
+
{
|
|
12202
|
+
title: title || titleFromFilename(file.name || "") || "Untitled PDF",
|
|
12203
|
+
content: "",
|
|
12204
|
+
type: PDF_NODE_TYPE,
|
|
12205
|
+
...tags ? { tags } : {},
|
|
12206
|
+
...folder ? { folder } : {},
|
|
12207
|
+
client,
|
|
12208
|
+
pdf
|
|
12209
|
+
},
|
|
12210
|
+
userEmail
|
|
12211
|
+
);
|
|
12212
|
+
const resolved = await resolveStewardsForNode(nestId, node.id);
|
|
12213
|
+
return c.json(
|
|
12214
|
+
{
|
|
12215
|
+
node: toNodeResponse(node),
|
|
12216
|
+
version,
|
|
12217
|
+
stewards: resolved.length > 0 ? resolved.map((r) => ({
|
|
12218
|
+
email: r.steward.userEmail,
|
|
12219
|
+
role: r.steward.role,
|
|
12220
|
+
source: r.source
|
|
12221
|
+
})) : void 0
|
|
12222
|
+
},
|
|
12223
|
+
201
|
|
12224
|
+
);
|
|
12225
|
+
}
|
|
12226
|
+
);
|
|
12227
|
+
pdfNodeRoutes.get("/:nodeId{.+}/pdf", async (c, next) => {
|
|
12228
|
+
const nestId = c.req.param("nestId");
|
|
12229
|
+
const nodeId = c.req.param("nodeId");
|
|
12230
|
+
const userId = c.get("userId");
|
|
12231
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
12232
|
+
let node;
|
|
12233
|
+
try {
|
|
12234
|
+
node = await getNodeForCaller(nestId, { id: nodeId }, userId, userEmail);
|
|
12235
|
+
} catch {
|
|
12236
|
+
return next();
|
|
12237
|
+
}
|
|
12238
|
+
if (node.frontmatter.type !== PDF_NODE_TYPE) return next();
|
|
12239
|
+
const requested = c.req.query("version");
|
|
12240
|
+
if (requested !== void 0 && !/^[1-9]\d*$/.test(requested)) {
|
|
12241
|
+
throw new ValidationError("version must be a positive integer");
|
|
12242
|
+
}
|
|
12243
|
+
const approvedOnly = c.req.query("approved_only") === "1" || c.req.query("approved_only") === "true";
|
|
12244
|
+
let version;
|
|
12245
|
+
if (approvedOnly || await isPublicReader(nestId, userId)) {
|
|
12246
|
+
const approved = await getApprovedVersion(nestId, nodeId);
|
|
12247
|
+
if (approved == null) {
|
|
12248
|
+
throw new NotFoundError("This document has no approved version yet.");
|
|
12249
|
+
}
|
|
12250
|
+
version = approved;
|
|
12251
|
+
} else if (requested !== void 0) {
|
|
12252
|
+
version = Number(requested);
|
|
12253
|
+
}
|
|
12254
|
+
const { storage } = await engineCache.get(nestId);
|
|
12255
|
+
let bytes;
|
|
12256
|
+
try {
|
|
12257
|
+
bytes = await readPdfBinary3(
|
|
12258
|
+
storage,
|
|
12259
|
+
nodeId,
|
|
12260
|
+
version !== void 0 ? { version } : {}
|
|
12261
|
+
);
|
|
12262
|
+
} catch (err) {
|
|
12263
|
+
const code = err?.code;
|
|
12264
|
+
if (code === "INTEGRITY_ERROR") {
|
|
12265
|
+
console.error("[pdf] integrity failure serving", nestId, nodeId, version, err);
|
|
12266
|
+
throw new AppError(
|
|
12267
|
+
500,
|
|
12268
|
+
"This PDF no longer matches the version history's record of it, so it is not being served. Ask an admin to verify the nest."
|
|
12269
|
+
);
|
|
12270
|
+
}
|
|
12271
|
+
throw new NotFoundError(
|
|
12272
|
+
version !== void 0 ? `Version ${version} of this PDF was not found.` : "This document's PDF was not found."
|
|
12273
|
+
);
|
|
12274
|
+
}
|
|
12275
|
+
const etag = `"sha256:${createHash3("sha256").update(bytes).digest("hex")}"`;
|
|
12276
|
+
const leaf = (nodeId.split("/").pop() || "document").replace(/[^A-Za-z0-9._-]/g, "-");
|
|
12277
|
+
const disposition = c.req.query("download") === "1" ? "attachment" : "inline";
|
|
12278
|
+
const headers = {
|
|
12279
|
+
"Content-Type": "application/pdf",
|
|
12280
|
+
"Content-Disposition": `${disposition}; filename="${leaf}.pdf"`,
|
|
12281
|
+
"Content-Security-Policy": "sandbox",
|
|
12282
|
+
"X-Content-Type-Options": "nosniff",
|
|
12283
|
+
"Cache-Control": "private, no-cache",
|
|
12284
|
+
ETag: etag
|
|
12285
|
+
};
|
|
12286
|
+
const ifNoneMatch = c.req.header("If-None-Match");
|
|
12287
|
+
if (ifNoneMatch && ifNoneMatch.split(",").map((t) => t.trim()).includes(etag)) {
|
|
12288
|
+
return c.body(null, 304, headers);
|
|
12289
|
+
}
|
|
12290
|
+
return c.body(new Uint8Array(bytes), 200, headers);
|
|
12291
|
+
});
|
|
12292
|
+
|
|
12293
|
+
// src/auth/community-sites-routes.ts
|
|
12294
|
+
import { Hono as Hono23 } from "hono";
|
|
11647
12295
|
var serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
|
|
11648
12296
|
var isHttpUrl = (v) => /^https?:\/\/\S+$/i.test(v);
|
|
11649
12297
|
var serializeSite = (site) => ({
|
|
@@ -11655,7 +12303,7 @@ var serializeSite = (site) => ({
|
|
|
11655
12303
|
created_at: site.created_at,
|
|
11656
12304
|
updated_at: site.updated_at
|
|
11657
12305
|
});
|
|
11658
|
-
var communitySiteRoutes = new
|
|
12306
|
+
var communitySiteRoutes = new Hono23();
|
|
11659
12307
|
communitySiteRoutes.get("/", async (c) => {
|
|
11660
12308
|
if (!await serverAdminAllowed(c))
|
|
11661
12309
|
return c.json({ error: "Only the server admin can view this." }, 403);
|
|
@@ -11743,11 +12391,24 @@ communitySiteRoutes.delete("/:id", async (c) => {
|
|
|
11743
12391
|
});
|
|
11744
12392
|
|
|
11745
12393
|
// src/mcp/server-routes.ts
|
|
11746
|
-
import { Hono as
|
|
12394
|
+
import { Hono as Hono24 } from "hono";
|
|
11747
12395
|
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11748
12396
|
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
11749
12397
|
import { z as z2 } from "zod";
|
|
11750
|
-
var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
12398
|
+
var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
12399
|
+
"nest_create",
|
|
12400
|
+
"context_create",
|
|
12401
|
+
"context_update",
|
|
12402
|
+
"context_delete",
|
|
12403
|
+
"context_move",
|
|
12404
|
+
"context_submit_review"
|
|
12405
|
+
]);
|
|
12406
|
+
var FANOUT_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
12407
|
+
"context_search",
|
|
12408
|
+
"context_list",
|
|
12409
|
+
"context_query",
|
|
12410
|
+
"context_overview"
|
|
12411
|
+
]);
|
|
11751
12412
|
async function getUserEmail3(userId) {
|
|
11752
12413
|
const db = getDb();
|
|
11753
12414
|
const user = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
@@ -11847,116 +12508,45 @@ var NEST_ARG = {
|
|
|
11847
12508
|
description: "Target nest \u2014 id, or exact name/slug (case-insensitive)"
|
|
11848
12509
|
}
|
|
11849
12510
|
};
|
|
12511
|
+
var NEST_ARG_OPTIONAL = {
|
|
12512
|
+
nest: {
|
|
12513
|
+
type: "string",
|
|
12514
|
+
description: "Target nest \u2014 id or name. Omit to search/list across ALL accessible nests."
|
|
12515
|
+
}
|
|
12516
|
+
};
|
|
12517
|
+
var SERVER_NEST_TOOLS = [
|
|
12518
|
+
// Fan-out reads — `nest` optional: omit it to span every accessible nest.
|
|
12519
|
+
...FANOUT_READ_TOOLS,
|
|
12520
|
+
// Targeted reads and writes — `nest` required.
|
|
12521
|
+
"context_get",
|
|
12522
|
+
"context_resolve",
|
|
12523
|
+
"context_comments",
|
|
12524
|
+
"context_versions",
|
|
12525
|
+
"context_create",
|
|
12526
|
+
"context_update",
|
|
12527
|
+
"context_delete",
|
|
12528
|
+
"context_move",
|
|
12529
|
+
"context_submit_review"
|
|
12530
|
+
];
|
|
12531
|
+
function withNestArg(tool) {
|
|
12532
|
+
const fanout = FANOUT_READ_TOOLS.has(tool.name);
|
|
12533
|
+
const schema = tool.inputSchema;
|
|
12534
|
+
return {
|
|
12535
|
+
name: tool.name,
|
|
12536
|
+
description: fanout ? `${tool.description} On this server-level endpoint, omit \`nest\` to run it across ALL accessible nests (each hit names its nest).` : `${tool.description} On this server-level endpoint, \`nest\` (id, name or slug) is required.`,
|
|
12537
|
+
inputSchema: {
|
|
12538
|
+
...schema,
|
|
12539
|
+
properties: { ...fanout ? NEST_ARG_OPTIONAL : NEST_ARG, ...schema.properties },
|
|
12540
|
+
required: [...fanout ? [] : ["nest"], ...schema.required ?? []]
|
|
12541
|
+
}
|
|
12542
|
+
};
|
|
12543
|
+
}
|
|
11850
12544
|
var SERVER_TOOL_DEFINITIONS = [
|
|
11851
12545
|
{
|
|
11852
12546
|
name: "nest_index",
|
|
11853
|
-
description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST
|
|
12547
|
+
description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST.",
|
|
11854
12548
|
inputSchema: { type: "object", properties: {} }
|
|
11855
12549
|
},
|
|
11856
|
-
{
|
|
11857
|
-
name: "context_query",
|
|
11858
|
-
description: "Run a structured selector query against one nest. Selector grammar: #tag, type:X, [[Title]], scope:X, combined with +AND, |OR, -NOT.",
|
|
11859
|
-
inputSchema: {
|
|
11860
|
-
type: "object",
|
|
11861
|
-
properties: {
|
|
11862
|
-
...NEST_ARG,
|
|
11863
|
-
query: { type: "string", description: "Selector query" },
|
|
11864
|
-
hops: {
|
|
11865
|
-
type: "number",
|
|
11866
|
-
description: "Graph traversal depth from the matches (default: 2)"
|
|
11867
|
-
}
|
|
11868
|
-
},
|
|
11869
|
-
required: ["nest", "query"]
|
|
11870
|
-
}
|
|
11871
|
-
},
|
|
11872
|
-
{
|
|
11873
|
-
name: "context_search",
|
|
11874
|
-
description: "Full-text keyword search across one nest \u2014 the same index-backed match as REST GET /search. Returns `count` (everything found) and the first `limit` hits.",
|
|
11875
|
-
inputSchema: {
|
|
11876
|
-
type: "object",
|
|
11877
|
-
properties: {
|
|
11878
|
-
...NEST_ARG,
|
|
11879
|
-
query: { type: "string", description: "Search terms" },
|
|
11880
|
-
limit: {
|
|
11881
|
-
type: "number",
|
|
11882
|
-
description: "Max hits to return (default 20, max 200)"
|
|
11883
|
-
}
|
|
11884
|
-
},
|
|
11885
|
-
required: ["nest", "query"]
|
|
11886
|
-
}
|
|
11887
|
-
},
|
|
11888
|
-
{
|
|
11889
|
-
name: "context_get",
|
|
11890
|
-
description: "Get the FULL content of a node in a nest by title or id.",
|
|
11891
|
-
inputSchema: {
|
|
11892
|
-
type: "object",
|
|
11893
|
-
properties: {
|
|
11894
|
-
...NEST_ARG,
|
|
11895
|
-
title: { type: "string", description: "Title of the node" },
|
|
11896
|
-
id: { type: "string", description: "ID of the node" }
|
|
11897
|
-
},
|
|
11898
|
-
required: ["nest"]
|
|
11899
|
-
}
|
|
11900
|
-
},
|
|
11901
|
-
{
|
|
11902
|
-
name: "context_list",
|
|
11903
|
-
description: "Browse one nest's contents with optional type, tag, or limit filters.",
|
|
11904
|
-
inputSchema: {
|
|
11905
|
-
type: "object",
|
|
11906
|
-
properties: {
|
|
11907
|
-
...NEST_ARG,
|
|
11908
|
-
type: { type: "string", description: "Filter by node type" },
|
|
11909
|
-
tag: { type: "string", description: "Filter by tag" },
|
|
11910
|
-
limit: { type: "number", description: "Max nodes to return" }
|
|
11911
|
-
},
|
|
11912
|
-
required: ["nest"]
|
|
11913
|
-
}
|
|
11914
|
-
},
|
|
11915
|
-
{
|
|
11916
|
-
name: "context_overview",
|
|
11917
|
-
description: "Map of one nest: node count, types, tags, title+snippet per node.",
|
|
11918
|
-
inputSchema: {
|
|
11919
|
-
type: "object",
|
|
11920
|
-
properties: { ...NEST_ARG },
|
|
11921
|
-
required: ["nest"]
|
|
11922
|
-
}
|
|
11923
|
-
},
|
|
11924
|
-
{
|
|
11925
|
-
name: "context_resolve",
|
|
11926
|
-
description: "Full deterministic context resolution against one nest \u2014 selector + hops + token budget, complete node bodies.",
|
|
11927
|
-
inputSchema: {
|
|
11928
|
-
type: "object",
|
|
11929
|
-
properties: {
|
|
11930
|
-
...NEST_ARG,
|
|
11931
|
-
selector: { type: "string", description: "Selector query string" },
|
|
11932
|
-
max_tokens: {
|
|
11933
|
-
type: "number",
|
|
11934
|
-
description: "Approximate token budget (default: 8000)"
|
|
11935
|
-
},
|
|
11936
|
-
hops: {
|
|
11937
|
-
type: "number",
|
|
11938
|
-
description: "Graph traversal depth from the matches (default: 2)"
|
|
11939
|
-
}
|
|
11940
|
-
},
|
|
11941
|
-
required: ["nest", "selector"]
|
|
11942
|
-
}
|
|
11943
|
-
},
|
|
11944
|
-
{
|
|
11945
|
-
// Read-only, so it belongs on this endpoint: an agent reading a nest's
|
|
11946
|
-
// documents across nests also needs the feedback left on them. Writing a
|
|
11947
|
-
// comment stays on the per-nest endpoint with the other write tools.
|
|
11948
|
-
name: "context_comments",
|
|
11949
|
-
description: "Get the review comments/annotations on a document in a nest \u2014 anchored quotes, threaded replies, and open/resolved status. Use to pull human feedback into context before acting on a document.",
|
|
11950
|
-
inputSchema: {
|
|
11951
|
-
type: "object",
|
|
11952
|
-
properties: {
|
|
11953
|
-
nest: { type: "string", description: "Nest name or id (see nest_index)" },
|
|
11954
|
-
title: { type: "string", description: "Title of the node" },
|
|
11955
|
-
id: { type: "string", description: "ID of the node" }
|
|
11956
|
-
},
|
|
11957
|
-
required: ["nest"]
|
|
11958
|
-
}
|
|
11959
|
-
},
|
|
11960
12550
|
{
|
|
11961
12551
|
name: "nest_create",
|
|
11962
12552
|
description: "Create a new nest owned by the caller. Returns the new nest's id \u2014 target it with the other tools afterwards.",
|
|
@@ -11969,32 +12559,18 @@ var SERVER_TOOL_DEFINITIONS = [
|
|
|
11969
12559
|
required: ["name"]
|
|
11970
12560
|
}
|
|
11971
12561
|
},
|
|
11972
|
-
{
|
|
11973
|
-
name
|
|
11974
|
-
|
|
11975
|
-
|
|
11976
|
-
|
|
11977
|
-
properties: {
|
|
11978
|
-
...NEST_ARG,
|
|
11979
|
-
title: { type: "string", description: "Descriptive title" },
|
|
11980
|
-
content: { type: "string", description: "Markdown content body" },
|
|
11981
|
-
type: { type: "string", description: "Node type (default: document)" },
|
|
11982
|
-
tags: { type: "array", items: { type: "string" }, description: "Tags" },
|
|
11983
|
-
scope: { type: "string", description: "Visibility scope" },
|
|
11984
|
-
folder: {
|
|
11985
|
-
type: "string",
|
|
11986
|
-
description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
|
|
11987
|
-
}
|
|
11988
|
-
},
|
|
11989
|
-
required: ["nest", "title", "content"]
|
|
11990
|
-
}
|
|
11991
|
-
}
|
|
12562
|
+
...SERVER_NEST_TOOLS.map((name) => {
|
|
12563
|
+
const tool = TOOL_DEFINITIONS.find((t) => t.name === name);
|
|
12564
|
+
if (!tool) throw new Error(`server-level tool ${name} has no per-nest definition`);
|
|
12565
|
+
return withNestArg(tool);
|
|
12566
|
+
})
|
|
11992
12567
|
];
|
|
11993
|
-
async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
|
|
12568
|
+
async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl, client) {
|
|
11994
12569
|
if (toolName === "nest_index") {
|
|
11995
12570
|
const nests = await buildNestIndex(userId, nestScope);
|
|
11996
|
-
|
|
11997
|
-
|
|
12571
|
+
const data = { nests };
|
|
12572
|
+
if (!nests.length) return { text: "No nests accessible with these credentials.", data };
|
|
12573
|
+
return { text: renderNestIndexMarkdown(nests, baseUrl), data };
|
|
11998
12574
|
}
|
|
11999
12575
|
if (SERVER_WRITE_TOOLS.has(toolName)) {
|
|
12000
12576
|
if (isSuspended()) {
|
|
@@ -12021,8 +12597,87 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
|
|
|
12021
12597
|
throw err;
|
|
12022
12598
|
}
|
|
12023
12599
|
}
|
|
12024
|
-
const
|
|
12025
|
-
|
|
12600
|
+
const nestArg = args.nest;
|
|
12601
|
+
if (!nestArg && FANOUT_READ_TOOLS.has(toolName)) {
|
|
12602
|
+
const nests = await buildNestIndex(userId, nestScope);
|
|
12603
|
+
if (!nests.length) return "No nests accessible with these credentials.";
|
|
12604
|
+
const nonEmpty = nests.filter((n) => (n.document_count ?? 0) > 0);
|
|
12605
|
+
const results = await Promise.allSettled(
|
|
12606
|
+
nonEmpty.map(async (n) => {
|
|
12607
|
+
const engine2 = await engineCache.get(n.id);
|
|
12608
|
+
const result = await handleToolCall(toolName, args, {
|
|
12609
|
+
storage: engine2.storage,
|
|
12610
|
+
queryEngine: engine2.query,
|
|
12611
|
+
versionManager: engine2.versions,
|
|
12612
|
+
nestId: n.id,
|
|
12613
|
+
userId,
|
|
12614
|
+
userEmail,
|
|
12615
|
+
baseUrl,
|
|
12616
|
+
// Same attribution the single-nest branch below passes (§9.4). A
|
|
12617
|
+
// fan-out read is still one call per nest on the trace, and each row
|
|
12618
|
+
// has to name the caller — without this the derived agent/session is
|
|
12619
|
+
// dropped and only an explicit `client` in the args survives.
|
|
12620
|
+
client
|
|
12621
|
+
});
|
|
12622
|
+
return { nest: n, result };
|
|
12623
|
+
})
|
|
12624
|
+
);
|
|
12625
|
+
const parts = [];
|
|
12626
|
+
let firstError;
|
|
12627
|
+
let firstRejection;
|
|
12628
|
+
const tag = (n, items) => Array.isArray(items) ? items.map((it) => ({ ...it, nest: { id: n.id, name: n.name } })) : [];
|
|
12629
|
+
const perNestDocs = [];
|
|
12630
|
+
const perNestHits = [];
|
|
12631
|
+
let count = 0;
|
|
12632
|
+
let nodesTraversed = 0;
|
|
12633
|
+
let hopsUsed = 0;
|
|
12634
|
+
let mode;
|
|
12635
|
+
for (const r of results) {
|
|
12636
|
+
if (r.status !== "fulfilled") {
|
|
12637
|
+
firstRejection ??= r;
|
|
12638
|
+
continue;
|
|
12639
|
+
}
|
|
12640
|
+
const { nest: n, result } = r.value;
|
|
12641
|
+
if (typeof result !== "string" && result.isError) {
|
|
12642
|
+
firstError ??= result;
|
|
12643
|
+
continue;
|
|
12644
|
+
}
|
|
12645
|
+
parts.push(`## ${n.name} (${n.id})
|
|
12646
|
+
|
|
12647
|
+
${typeof result === "string" ? result : result.text}`);
|
|
12648
|
+
const d = typeof result === "string" ? void 0 : result.data;
|
|
12649
|
+
if (!d) continue;
|
|
12650
|
+
perNestDocs.push(tag(n, d.documents));
|
|
12651
|
+
perNestHits.push(tag(n, d.results));
|
|
12652
|
+
count += d.count ?? 0;
|
|
12653
|
+
nodesTraversed += d.traversal?.nodes_traversed ?? 0;
|
|
12654
|
+
hopsUsed = Math.max(hopsUsed, d.traversal?.hops_used ?? 0);
|
|
12655
|
+
mode ??= d.traversal?.mode;
|
|
12656
|
+
}
|
|
12657
|
+
const interleave = (lists) => {
|
|
12658
|
+
const out = [];
|
|
12659
|
+
for (let i = 0; lists.some((l) => i < l.length); i++) {
|
|
12660
|
+
for (const l of lists) if (i < l.length) out.push(l[i]);
|
|
12661
|
+
}
|
|
12662
|
+
return out;
|
|
12663
|
+
};
|
|
12664
|
+
if (!parts.length && firstError) return firstError;
|
|
12665
|
+
if (!parts.length && firstRejection) throw firstRejection.reason;
|
|
12666
|
+
const documents = interleave(perNestDocs);
|
|
12667
|
+
const listLimit = typeof args.limit === "number" && args.limit >= 1 ? Math.floor(args.limit) : 50;
|
|
12668
|
+
const data = toolName === "context_search" ? { results: interleave(perNestHits).slice(0, normalizeSearchLimit(args.limit)), count } : toolName === "context_query" ? { documents, traversal: { mode: mode ?? "selector", hops_used: hopsUsed, nodes_traversed: nodesTraversed } } : { documents: documents.slice(0, listLimit) };
|
|
12669
|
+
const reply = (text) => toolName === "context_overview" ? text : { text, data };
|
|
12670
|
+
if (!nonEmpty.length) {
|
|
12671
|
+
return reply("All accessible nests are empty. Use context_create to add documents first.");
|
|
12672
|
+
}
|
|
12673
|
+
if (!parts.length) return reply("No results found across any accessible nests.");
|
|
12674
|
+
return reply(parts.join("\n\n---\n\n"));
|
|
12675
|
+
}
|
|
12676
|
+
const inaccessible = `Nest not found or not accessible: "${nestArg}". Use nest_index to see what these credentials can read.`;
|
|
12677
|
+
if (!nestArg) {
|
|
12678
|
+
return "This tool requires a `nest` argument. Use nest_index to see available nests.";
|
|
12679
|
+
}
|
|
12680
|
+
const nest = await resolveNestRef(String(nestArg), userId);
|
|
12026
12681
|
if (!nest) return inaccessible;
|
|
12027
12682
|
if (nestScope && nest.id !== nestScope) return inaccessible;
|
|
12028
12683
|
if (!await canRead(nest.id, userId)) return inaccessible;
|
|
@@ -12034,10 +12689,11 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
|
|
|
12034
12689
|
nestId: nest.id,
|
|
12035
12690
|
userId,
|
|
12036
12691
|
userEmail,
|
|
12037
|
-
baseUrl
|
|
12692
|
+
baseUrl,
|
|
12693
|
+
client
|
|
12038
12694
|
});
|
|
12039
12695
|
}
|
|
12040
|
-
function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
12696
|
+
function createServerMcp(userId, userEmail, nestScope, baseUrl, c) {
|
|
12041
12697
|
const server = new McpServer2(
|
|
12042
12698
|
{ name: "contextnest-server", version: "1.0.0" },
|
|
12043
12699
|
{ capabilities: { tools: {} } }
|
|
@@ -12047,14 +12703,15 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
|
12047
12703
|
const required = tool.inputSchema.required || [];
|
|
12048
12704
|
const shape = {};
|
|
12049
12705
|
for (const [key, def] of Object.entries(props)) {
|
|
12050
|
-
let
|
|
12051
|
-
if (def.type === "string")
|
|
12052
|
-
else if (def.type === "number")
|
|
12053
|
-
else if (def.type === "array")
|
|
12054
|
-
else
|
|
12055
|
-
if (!required.includes(key))
|
|
12056
|
-
shape[key] =
|
|
12057
|
-
}
|
|
12706
|
+
let field2;
|
|
12707
|
+
if (def.type === "string") field2 = z2.string();
|
|
12708
|
+
else if (def.type === "number") field2 = z2.number();
|
|
12709
|
+
else if (def.type === "array") field2 = z2.array(z2.string());
|
|
12710
|
+
else field2 = z2.any();
|
|
12711
|
+
if (!required.includes(key)) field2 = field2.optional();
|
|
12712
|
+
shape[key] = field2;
|
|
12713
|
+
}
|
|
12714
|
+
Object.assign(shape, clientToolField);
|
|
12058
12715
|
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
12059
12716
|
const out = await handleServerToolCall(
|
|
12060
12717
|
tool.name,
|
|
@@ -12062,14 +12719,15 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
|
12062
12719
|
userId,
|
|
12063
12720
|
userEmail,
|
|
12064
12721
|
nestScope,
|
|
12065
|
-
baseUrl
|
|
12722
|
+
baseUrl,
|
|
12723
|
+
attributeRequest(c, void 0, connectionClient(server))
|
|
12066
12724
|
);
|
|
12067
12725
|
return toCallToolResult(out);
|
|
12068
12726
|
});
|
|
12069
12727
|
}
|
|
12070
12728
|
return server;
|
|
12071
12729
|
}
|
|
12072
|
-
var serverMcpRoutes = new
|
|
12730
|
+
var serverMcpRoutes = new Hono24();
|
|
12073
12731
|
serverMcpRoutes.all("/", async (c) => {
|
|
12074
12732
|
const userId = c.get("userId");
|
|
12075
12733
|
const userEmail = await getUserEmail3(userId);
|
|
@@ -12077,7 +12735,8 @@ serverMcpRoutes.all("/", async (c) => {
|
|
|
12077
12735
|
userId,
|
|
12078
12736
|
userEmail,
|
|
12079
12737
|
c.get("nestScope"),
|
|
12080
|
-
requestBaseUrl(c.req.url)
|
|
12738
|
+
requestBaseUrl(c.req.url),
|
|
12739
|
+
c
|
|
12081
12740
|
);
|
|
12082
12741
|
const transport = new WebStandardStreamableHTTPServerTransport2({
|
|
12083
12742
|
sessionIdGenerator: void 0,
|
|
@@ -12091,7 +12750,7 @@ serverMcpRoutes.all("/", async (c) => {
|
|
|
12091
12750
|
await server.server.close();
|
|
12092
12751
|
}
|
|
12093
12752
|
});
|
|
12094
|
-
var nestIndexRoutes = new
|
|
12753
|
+
var nestIndexRoutes = new Hono24();
|
|
12095
12754
|
nestIndexRoutes.get("/", async (c) => {
|
|
12096
12755
|
const userId = c.get("userId");
|
|
12097
12756
|
const nests = await buildNestIndex(userId, c.get("nestScope"));
|
|
@@ -12164,26 +12823,61 @@ Other read endpoints (all under \`${B}/nests/<nest-id>\`):
|
|
|
12164
12823
|
- \`GET /nodes/<node-id>?format=raw\` \u2014 direct consumption path: the node BODY as plain text (no JSON envelope; add \`&frontmatter=1\` for the stored YAML block)
|
|
12165
12824
|
- \`GET /export?format=markdown\` \u2014 whole nest as one markdown file (optional \`selector\`, \`max_tokens\`)
|
|
12166
12825
|
- \`GET /graph\` \u2014 ontology graph (nodes, links, tags, stewards)
|
|
12826
|
+
- \`GET /folders\` \u2014 folder tree; \`GET /nodes?folder=<path>\` \u2014 one folder level
|
|
12827
|
+
- \`POST /table-query\` \u2014 exact rows from a table node: \`{ "table": "[[Title]]", "query": "where <col> <op> <value> [select cols] [limit N]" }\`
|
|
12828
|
+
- \`GET /nodes/<node-id>/versions\` \u2014 history (who, when, approval state); \`/versions/<n>\` \u2014 one version
|
|
12829
|
+
- \`GET /nodes/<node-id>/comments\` \u2014 review threads (anchored quotes, replies, open/resolved)
|
|
12167
12830
|
|
|
12168
12831
|
Write endpoints (need write permission):
|
|
12169
12832
|
- \`POST /nodes\` \u2014 \`{ "title", "content", "tags"?, "type"?, "folder"? }\`
|
|
12170
12833
|
- \`PATCH /nodes/<node-id>\` \u2014 \`{ "content"? , "append"?, "tags"?, "title"? }\`
|
|
12171
|
-
- \`DELETE /nodes/<node-id
|
|
12172
|
-
-
|
|
12834
|
+
- \`DELETE /nodes/<node-id>\`; \`POST /nodes/<id>/request-deletion\` when you lack delete rights
|
|
12835
|
+
- \`POST /nodes/<id>/move\` \u2014 \`{ "folder" }\`; history and comments follow
|
|
12836
|
+
- \`POST /nodes/<id>/revert\` \u2014 \`{ "targetVersion" }\` restores a past version as a new one
|
|
12837
|
+
- \`POST /nodes/<id>/comments\` \u2014 leave or reply to a review comment
|
|
12838
|
+
- Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`, \`/cancel-review\`
|
|
12839
|
+
- \`GET /review-queue\` \u2014 what is waiting on a steward in this nest
|
|
12840
|
+
|
|
12841
|
+
## Your inbox (server-level)
|
|
12842
|
+
|
|
12843
|
+
- \`GET ${B}/me/work\` \u2014 reviews waiting on you and handoffs, across every nest
|
|
12844
|
+
- \`GET ${B}/me/drafts\` \u2014 your documents never submitted for review
|
|
12845
|
+
- \`GET ${B}/me/notifications\` \u2014 watcher and review notifications
|
|
12846
|
+
|
|
12847
|
+
## Say who you are: \`client\`
|
|
12848
|
+
|
|
12849
|
+
Every write body and every MCP tool call takes an optional
|
|
12850
|
+
\`client\` object \u2014 \`{ "agent": "claude-code", "session_id": "s-9f2" }\`, plus any
|
|
12851
|
+
custom scalar keys. A write that seals a version records it on that version
|
|
12852
|
+
(\`GET /nodes/<id>/versions\` and \`context_versions\` serve it back); every call
|
|
12853
|
+
records it on this server's activity trace, which is how a READ gets attributed.
|
|
12854
|
+
|
|
12855
|
+
Send it. Nothing requires it and no call is refused for omitting it, but a vault
|
|
12856
|
+
serving several agents cannot otherwise tell which of you wrote what. It is a
|
|
12857
|
+
label, never an identity claim: it is not authenticated, grants nothing, and is
|
|
12858
|
+
never part of a hash. Bounded \u2014 scalar values up to 512 chars, at most 16 custom
|
|
12859
|
+
keys beyond \`agent\` and \`session_id\`.
|
|
12173
12860
|
|
|
12174
12861
|
## MCP (Model Context Protocol)
|
|
12175
12862
|
|
|
12176
12863
|
Streamable HTTP, stateless. Two endpoints:
|
|
12177
12864
|
|
|
12178
|
-
- \`${B}/mcp\` \u2014 server-level, one connection for everything
|
|
12179
|
-
Tools: \`nest_index\` (call first), then
|
|
12180
|
-
|
|
12181
|
-
|
|
12182
|
-
|
|
12183
|
-
|
|
12184
|
-
|
|
12185
|
-
- \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset
|
|
12186
|
-
|
|
12865
|
+
- \`${B}/mcp\` \u2014 server-level, one connection for everything.
|
|
12866
|
+
Tools: \`nest_index\` (call first), then:
|
|
12867
|
+
- Fan-out reads (omit \`nest\` to search/list ALL nests): \`context_search\`,
|
|
12868
|
+
\`context_list\`, \`context_query\`, \`context_overview\`
|
|
12869
|
+
- Targeted reads (\`nest\` required): \`context_get\`, \`context_resolve\`, \`context_comments\`, \`context_versions\`
|
|
12870
|
+
- Writes (\`nest\` required): \`nest_create\`, \`context_create\`, \`context_update\`,
|
|
12871
|
+
\`context_delete\`, \`context_move\`, \`context_submit_review\`
|
|
12872
|
+
- \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, the full toolset. Same names as the
|
|
12873
|
+
server-level tools without the \`nest\` argument, plus:
|
|
12874
|
+
- read: \`context_init\` (operating instructions \u2014 call first), \`context_export\`, \`context_table_query\`, \`context_versions\`
|
|
12875
|
+
- write: \`context_update\`, \`context_delete\`, \`context_move\`, \`context_request_deletion\`
|
|
12876
|
+
- review: \`context_submit_review\`, \`context_approve\`, \`context_reject\`, \`context_review_queue\`, \`context_my_drafts\`
|
|
12877
|
+
- comments: \`context_comment\`, \`context_resolve_comment\`
|
|
12878
|
+
- people: \`context_stewards\`, \`context_assign_steward\`, \`context_share_nest\`, \`context_grant\`
|
|
12879
|
+
- admin: \`context_deletion_queue\`, \`context_resolve_deletion\`, \`context_unsynced_list\`, \`context_sync_folder\`
|
|
12880
|
+
- \`workflow_*\` (runs, edges, edge types) when the nest's workflow plane is on
|
|
12187
12881
|
Sharing a slice is one call: \`context_grant\` with
|
|
12188
12882
|
\`{ "email", "target_type": "document" | "folder", "target", "role": "read" | "write" }\`
|
|
12189
12883
|
\u2014 \`target\` is a node id (\`nodes/win-loss-log\`) or a folder prefix
|
|
@@ -12209,9 +12903,11 @@ on the command line.
|
|
|
12209
12903
|
export CONTEXTNEST_API_KEY=<api-key>
|
|
12210
12904
|
# every nest these credentials can read, through the server-level endpoint
|
|
12211
12905
|
ctx vault add contextnest --url ${B}/mcp --bearer-env CONTEXTNEST_API_KEY
|
|
12212
|
-
#
|
|
12906
|
+
ctx vault list # lists contextnest/<nest> for each nest
|
|
12907
|
+
ctx search "pricing" --vault contextnest # reads span every nest
|
|
12908
|
+
ctx update nodes/x --tags a,b --vault contextnest/<nest> # a write names one nest
|
|
12909
|
+
# or pin one nest by its own endpoint
|
|
12213
12910
|
ctx vault add mynest --url ${B}/nests/<nest-id>/mcp --bearer-env CONTEXTNEST_API_KEY
|
|
12214
|
-
ctx query "#gtm" --vault mynest
|
|
12215
12911
|
\`\`\`
|
|
12216
12912
|
|
|
12217
12913
|
Servers in "open" auth mode: drop \`--bearer-env\`. Pushing a local folder up
|
|
@@ -12230,14 +12926,14 @@ is \`ctx push --server ${B} --nest <nest-id>\` with the same env var set.
|
|
|
12230
12926
|
through steward review before they become AI-visible.
|
|
12231
12927
|
`;
|
|
12232
12928
|
}
|
|
12233
|
-
var llmsTxtRoutes = new
|
|
12929
|
+
var llmsTxtRoutes = new Hono24();
|
|
12234
12930
|
llmsTxtRoutes.get("/", (c) => {
|
|
12235
12931
|
const md = renderLlmsTxt(requestBaseUrl(c.req.url));
|
|
12236
12932
|
return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
|
|
12237
12933
|
});
|
|
12238
12934
|
|
|
12239
12935
|
// src/governance/routes.ts
|
|
12240
|
-
import { Hono as
|
|
12936
|
+
import { Hono as Hono25 } from "hono";
|
|
12241
12937
|
|
|
12242
12938
|
// src/governance/feed-service.ts
|
|
12243
12939
|
var SNIPPET_CHARS = 200;
|
|
@@ -12669,7 +13365,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
|
|
|
12669
13365
|
if (Number.isNaN(n) || n < 1) return def;
|
|
12670
13366
|
return Math.min(n, max);
|
|
12671
13367
|
}
|
|
12672
|
-
var governanceRoutes = new
|
|
13368
|
+
var governanceRoutes = new Hono25();
|
|
12673
13369
|
governanceRoutes.get("/stewards", async (c) => {
|
|
12674
13370
|
const nestId = c.req.param("nestId");
|
|
12675
13371
|
const scope = c.req.query("scope");
|
|
@@ -12809,6 +13505,7 @@ governanceRoutes.post("/review-queue/bulk", async (c) => {
|
|
|
12809
13505
|
}
|
|
12810
13506
|
const userEmail = await getUserEmail4(c);
|
|
12811
13507
|
const baseUrl = requestBaseUrl(c.req.url);
|
|
13508
|
+
const client = attributeRequest(c, body.client);
|
|
12812
13509
|
const results = [];
|
|
12813
13510
|
for (const nodeId of nodeIds) {
|
|
12814
13511
|
try {
|
|
@@ -12819,7 +13516,8 @@ governanceRoutes.post("/review-queue/bulk", async (c) => {
|
|
|
12819
13516
|
approvedBy: userEmail,
|
|
12820
13517
|
note: body.note,
|
|
12821
13518
|
override: false,
|
|
12822
|
-
baseUrl
|
|
13519
|
+
baseUrl,
|
|
13520
|
+
client
|
|
12823
13521
|
});
|
|
12824
13522
|
} else {
|
|
12825
13523
|
await reject({
|
|
@@ -13022,6 +13720,7 @@ governanceRoutes.get("/external-edits", async (c) => {
|
|
|
13022
13720
|
await scanNestForDrift(nestId, "user:refresh");
|
|
13023
13721
|
}
|
|
13024
13722
|
const entries = await listNestExternalEdits(nestId);
|
|
13723
|
+
if (c.req.query("count") === "1") return c.json({ total: entries.length });
|
|
13025
13724
|
return c.json({ entries, total: entries.length });
|
|
13026
13725
|
});
|
|
13027
13726
|
governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
@@ -13036,7 +13735,7 @@ governanceRoutes.get("/activity", async (c) => {
|
|
|
13036
13735
|
const activity = await getActivity({ nestId, limit });
|
|
13037
13736
|
return c.json({ activity });
|
|
13038
13737
|
});
|
|
13039
|
-
var governanceNodeRoutes = new
|
|
13738
|
+
var governanceNodeRoutes = new Hono25();
|
|
13040
13739
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
13041
13740
|
const nestId = c.req.param("nestId");
|
|
13042
13741
|
const nodeId = c.req.param("nodeId");
|
|
@@ -13242,7 +13941,10 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
13242
13941
|
requestedBy: userEmail,
|
|
13243
13942
|
note: body.note,
|
|
13244
13943
|
priority: body.priority,
|
|
13245
|
-
baseUrl: requestBaseUrl(c.req.url)
|
|
13944
|
+
baseUrl: requestBaseUrl(c.req.url),
|
|
13945
|
+
// Submitting seals the draft into the engine's append-only chain, so the
|
|
13946
|
+
// block lands on that entry as well as on this request's trace row.
|
|
13947
|
+
client: attributeRequest(c, body.client)
|
|
13246
13948
|
});
|
|
13247
13949
|
} catch (err) {
|
|
13248
13950
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -13376,7 +14078,8 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
13376
14078
|
approvedBy: userEmail,
|
|
13377
14079
|
note: body.note,
|
|
13378
14080
|
override: body.override && isAdmin,
|
|
13379
|
-
baseUrl: requestBaseUrl(c.req.url)
|
|
14081
|
+
baseUrl: requestBaseUrl(c.req.url),
|
|
14082
|
+
client: attributeRequest(c, body.client)
|
|
13380
14083
|
});
|
|
13381
14084
|
return c.json({ review: request });
|
|
13382
14085
|
} catch (err) {
|
|
@@ -13391,6 +14094,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
13391
14094
|
throw new ValidationError("Rejection note is required");
|
|
13392
14095
|
}
|
|
13393
14096
|
const userEmail = await getUserEmail4(c);
|
|
14097
|
+
attributeRequest(c, body.client);
|
|
13394
14098
|
try {
|
|
13395
14099
|
const request = await reject({
|
|
13396
14100
|
nestId,
|
|
@@ -13488,6 +14192,41 @@ async function ensureAnonymousUser() {
|
|
|
13488
14192
|
return ANON_USER_ID;
|
|
13489
14193
|
}
|
|
13490
14194
|
|
|
14195
|
+
// src/shared/posthog-key.ts
|
|
14196
|
+
var POSTHOG_KEY_MIN_TOKEN = 20;
|
|
14197
|
+
function posthogKeyError(value) {
|
|
14198
|
+
const v = value.trim();
|
|
14199
|
+
if (!v) return null;
|
|
14200
|
+
if (!v.startsWith("phc_"))
|
|
14201
|
+
return "PostHog project key must start with phc_ \u2014 copy the project API key from PostHog settings.";
|
|
14202
|
+
const token = v.slice(4);
|
|
14203
|
+
if (!/^[A-Za-z0-9]*$/.test(token))
|
|
14204
|
+
return "PostHog project key may only contain letters and digits after phc_ \u2014 check for spaces or punctuation.";
|
|
14205
|
+
if (token.length < POSTHOG_KEY_MIN_TOKEN)
|
|
14206
|
+
return `PostHog project key looks too short \u2014 a real key is about 47 characters long (phc_ plus ${POSTHOG_KEY_MIN_TOKEN}+ letters and digits). Copy the whole key from PostHog settings.`;
|
|
14207
|
+
return null;
|
|
14208
|
+
}
|
|
14209
|
+
|
|
14210
|
+
// src/shared/public-base-url.ts
|
|
14211
|
+
var UNSAFE = /["'`\\$;|&<>(){}\s]/;
|
|
14212
|
+
function publicBaseUrlError(value) {
|
|
14213
|
+
const v = value.trim();
|
|
14214
|
+
if (!v) return null;
|
|
14215
|
+
let parsed;
|
|
14216
|
+
try {
|
|
14217
|
+
parsed = new URL(v);
|
|
14218
|
+
} catch {
|
|
14219
|
+
return "Public base URL must be a full http(s) URL, e.g. https://nest.example.com";
|
|
14220
|
+
}
|
|
14221
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
14222
|
+
return "Public base URL must use http or https, e.g. https://nest.example.com";
|
|
14223
|
+
if (!parsed.hostname)
|
|
14224
|
+
return "Public base URL must include a hostname, e.g. https://nest.example.com";
|
|
14225
|
+
if (UNSAFE.test(v))
|
|
14226
|
+
return "Public base URL must not contain spaces, quotes or shell characters (\" ' ` \\ $ ; | & < > ( ) { }) \u2014 it is pasted into terminal commands and config files.";
|
|
14227
|
+
return null;
|
|
14228
|
+
}
|
|
14229
|
+
|
|
13491
14230
|
// src/app.ts
|
|
13492
14231
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
13493
14232
|
import { fileURLToPath } from "url";
|
|
@@ -13537,7 +14276,10 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
13537
14276
|
c.set("nestScope", null);
|
|
13538
14277
|
return next();
|
|
13539
14278
|
}
|
|
13540
|
-
return c.json(
|
|
14279
|
+
return c.json(
|
|
14280
|
+
{ error: "Authentication required \u2014 sign in or provide an API key.", code: "missing_credentials" },
|
|
14281
|
+
401
|
|
14282
|
+
);
|
|
13541
14283
|
});
|
|
13542
14284
|
function nestPathParts(path) {
|
|
13543
14285
|
return path.replace(/^\/nests\//, "").split("/").filter(Boolean);
|
|
@@ -13555,14 +14297,24 @@ var flexAuthWithPromptowlTicket = createMiddleware2(
|
|
|
13555
14297
|
return flexAuthMiddleware(c, next);
|
|
13556
14298
|
}
|
|
13557
14299
|
let claims = null;
|
|
14300
|
+
let expired = false;
|
|
13558
14301
|
for (const secret of secrets) {
|
|
13559
14302
|
try {
|
|
13560
14303
|
claims = verifySsoTicket(token, secret);
|
|
13561
14304
|
break;
|
|
13562
|
-
} catch {
|
|
14305
|
+
} catch (err) {
|
|
14306
|
+
if (err instanceof SsoTokenError && err.message === "Ticket expired") {
|
|
14307
|
+
expired = true;
|
|
14308
|
+
}
|
|
13563
14309
|
}
|
|
13564
14310
|
}
|
|
13565
14311
|
if (!claims) {
|
|
14312
|
+
if (expired) {
|
|
14313
|
+
return c.json(
|
|
14314
|
+
{ error: "Service ticket expired \u2014 reconnect to refresh.", code: "ticket_expired" },
|
|
14315
|
+
401
|
|
14316
|
+
);
|
|
14317
|
+
}
|
|
13566
14318
|
return c.json({ error: "Invalid service ticket" }, 401);
|
|
13567
14319
|
}
|
|
13568
14320
|
if (claims.scope !== "mcp" || claims.jti) {
|
|
@@ -13609,7 +14361,7 @@ async function canApproveInNest(nestId, email) {
|
|
|
13609
14361
|
return ok;
|
|
13610
14362
|
}
|
|
13611
14363
|
function createApp() {
|
|
13612
|
-
const app = new
|
|
14364
|
+
const app = new Hono26({ router: new LinearRouter() });
|
|
13613
14365
|
const corsOrigins = config.CORS_ORIGINS;
|
|
13614
14366
|
app.use(
|
|
13615
14367
|
"*",
|
|
@@ -13627,7 +14379,8 @@ function createApp() {
|
|
|
13627
14379
|
const lenStr = c.req.header("Content-Length");
|
|
13628
14380
|
if (lenStr) {
|
|
13629
14381
|
const len = parseInt(lenStr, 10);
|
|
13630
|
-
const
|
|
14382
|
+
const isPost = c.req.method === "POST";
|
|
14383
|
+
const cap = isPost && ASSET_UPLOAD_PATH.test(c.req.path) ? Math.max(config.MAX_BODY_BYTES, ASSET_MAX_REQUEST_BYTES) : isPost && PDF_UPLOAD_PATH.test(c.req.path) ? Math.max(config.MAX_BODY_BYTES, pdfMaxRequestBytes()) : config.MAX_BODY_BYTES;
|
|
13631
14384
|
if (Number.isFinite(len) && len > cap) {
|
|
13632
14385
|
return c.json(
|
|
13633
14386
|
{
|
|
@@ -13652,7 +14405,10 @@ function createApp() {
|
|
|
13652
14405
|
nestId: /^\/nests\/([^/]+)/.exec(path)?.[1] ?? null,
|
|
13653
14406
|
userId: c.get("userId") ?? null,
|
|
13654
14407
|
status: c.res.status,
|
|
13655
|
-
durationMs: Date.now() - started
|
|
14408
|
+
durationMs: Date.now() - started,
|
|
14409
|
+
// Set by whichever handler validated the request's `client` block
|
|
14410
|
+
// (§9.4). Absent on a request that sent none.
|
|
14411
|
+
client: c.get("clientMetadata")
|
|
13656
14412
|
});
|
|
13657
14413
|
});
|
|
13658
14414
|
app.get("/health", (c) => {
|
|
@@ -13697,6 +14453,11 @@ function createApp() {
|
|
|
13697
14453
|
// API would refuse it — existing nodes of the type stay readable.
|
|
13698
14454
|
type_artifact_enabled: config.TYPE_ARTIFACT_ENABLED,
|
|
13699
14455
|
type_table_enabled: config.TYPE_TABLE_ENABLED,
|
|
14456
|
+
// This server hosts several organizations (PromptOwl-hosted) rather than
|
|
14457
|
+
// one. The visibility picker words 'Organization' and 'Public' by it and
|
|
14458
|
+
// confirms before 'org'; the server enforces the same in
|
|
14459
|
+
// PATCH /nests/:id/visibility.
|
|
14460
|
+
shared_deployment: config.SHARED_DEPLOYMENT,
|
|
13700
14461
|
// Sub-agent runs gate its own recursion surface separately (env-only,
|
|
13701
14462
|
// must clear a security review — NOT a UI toggle). Exposed read-only so
|
|
13702
14463
|
// the Runs page shows the "spawn sub-agent" option only when it's live.
|
|
@@ -13820,6 +14581,7 @@ function createApp() {
|
|
|
13820
14581
|
oidc_auto_provision: config.OIDC_AUTO_PROVISION,
|
|
13821
14582
|
oidc_department_tagging: config.OIDC_DEPARTMENT_TAGGING,
|
|
13822
14583
|
people_suggest_all_users: config.PEOPLE_SUGGEST_ALL_USERS,
|
|
14584
|
+
shared_deployment: config.SHARED_DEPLOYMENT,
|
|
13823
14585
|
// Agent SSO / token-exchange. MCP_SIGNING_SECRET is WRITE-ONLY — only its
|
|
13824
14586
|
// presence is reported, never the value (same discipline as the OIDC secret).
|
|
13825
14587
|
sso_token_exchange_enabled: config.SSO_TOKEN_EXCHANGE_ENABLED,
|
|
@@ -13872,8 +14634,8 @@ function createApp() {
|
|
|
13872
14634
|
return c.json({ error: "Invalid JSON body" }, 400);
|
|
13873
14635
|
}
|
|
13874
14636
|
const fieldErrors = {};
|
|
13875
|
-
const addError = (
|
|
13876
|
-
fieldErrors[
|
|
14637
|
+
const addError = (field2, message) => {
|
|
14638
|
+
fieldErrors[field2] = message;
|
|
13877
14639
|
};
|
|
13878
14640
|
const pending = [];
|
|
13879
14641
|
if ("promptowl_sign_in_gate" in body) {
|
|
@@ -13894,14 +14656,18 @@ function createApp() {
|
|
|
13894
14656
|
}
|
|
13895
14657
|
if ("public_base_url" in body) {
|
|
13896
14658
|
const v = String(body.public_base_url ?? "").trim().replace(/\/$/, "");
|
|
13897
|
-
|
|
14659
|
+
const err = v ? publicBaseUrlError(v) : null;
|
|
14660
|
+
if (err) addError("public_base_url", err);
|
|
14661
|
+
else pending.push({ name: "PUBLIC_BASE_URL", value: v || null });
|
|
13898
14662
|
}
|
|
13899
14663
|
if ("telemetry_enabled" in body) {
|
|
13900
14664
|
pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
|
|
13901
14665
|
}
|
|
13902
14666
|
if ("posthog_key" in body) {
|
|
13903
14667
|
const v = String(body.posthog_key ?? "").trim();
|
|
13904
|
-
|
|
14668
|
+
const keyError = posthogKeyError(v);
|
|
14669
|
+
if (keyError) addError("posthog_key", keyError);
|
|
14670
|
+
else pending.push({ name: "POSTHOG_KEY", value: v || null });
|
|
13905
14671
|
}
|
|
13906
14672
|
if ("posthog_host" in body) {
|
|
13907
14673
|
const v = String(body.posthog_host ?? "").trim().replace(/\/$/, "");
|
|
@@ -14046,6 +14812,12 @@ function createApp() {
|
|
|
14046
14812
|
value: body.people_suggest_all_users ? "true" : null
|
|
14047
14813
|
});
|
|
14048
14814
|
}
|
|
14815
|
+
if ("shared_deployment" in body) {
|
|
14816
|
+
pending.push({
|
|
14817
|
+
name: "SHARED_DEPLOYMENT",
|
|
14818
|
+
value: body.shared_deployment ? "true" : null
|
|
14819
|
+
});
|
|
14820
|
+
}
|
|
14049
14821
|
if ("sso_token_exchange_enabled" in body) {
|
|
14050
14822
|
pending.push({
|
|
14051
14823
|
name: "SSO_TOKEN_EXCHANGE_ENABLED",
|
|
@@ -14096,13 +14868,13 @@ function createApp() {
|
|
|
14096
14868
|
const effAllowedDomains = "oidc_allowed_domains" in body ? String(body.oidc_allowed_domains ?? "").split(",").map((d) => d.trim()).filter(Boolean) : config.OIDC_ALLOWED_DOMAINS;
|
|
14097
14869
|
const effAutoProvision = "oidc_auto_provision" in body ? !!body.oidc_auto_provision : config.OIDC_AUTO_PROVISION;
|
|
14098
14870
|
if (effAutoProvision && effAllowedDomains.length === 0) {
|
|
14099
|
-
for (const
|
|
14871
|
+
for (const field2 of [
|
|
14100
14872
|
"ms_token_exchange_enabled",
|
|
14101
14873
|
"google_token_exchange_enabled"
|
|
14102
14874
|
]) {
|
|
14103
|
-
if (body[
|
|
14875
|
+
if (body[field2])
|
|
14104
14876
|
addError(
|
|
14105
|
-
|
|
14877
|
+
field2,
|
|
14106
14878
|
"Set allowed email domains (or turn auto-provision off) before enabling token exchange \u2014 otherwise anyone with an account at that provider can sign themselves up and mint a token."
|
|
14107
14879
|
);
|
|
14108
14880
|
}
|
|
@@ -14552,7 +15324,7 @@ function createApp() {
|
|
|
14552
15324
|
app.route("/index", nestIndexRoutes);
|
|
14553
15325
|
app.use("/mcp", flexAuthWithPromptowlTicket);
|
|
14554
15326
|
app.route("/mcp", serverMcpRoutes);
|
|
14555
|
-
const nestsApp = new
|
|
15327
|
+
const nestsApp = new Hono26();
|
|
14556
15328
|
nestsApp.use("*", async (c, next) => {
|
|
14557
15329
|
const parts = nestPathParts(c.req.path);
|
|
14558
15330
|
const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
|
|
@@ -14666,7 +15438,7 @@ function createApp() {
|
|
|
14666
15438
|
const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
|
|
14667
15439
|
const isDeletionRequestAction = /\/request-deletion$/.test(path) || /\/deletion-requests\/[^/]+\/withdraw$/.test(path);
|
|
14668
15440
|
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
14669
|
-
if (isStewardRoster &&
|
|
15441
|
+
if (isStewardRoster && access.rawPermission !== "owner" && access.rawPermission !== "admin") {
|
|
14670
15442
|
return c.json(
|
|
14671
15443
|
{
|
|
14672
15444
|
error: "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this."
|
|
@@ -14674,6 +15446,13 @@ function createApp() {
|
|
|
14674
15446
|
403
|
|
14675
15447
|
);
|
|
14676
15448
|
}
|
|
15449
|
+
if (isStewardRoster && access.archived && c.req.method !== "GET") {
|
|
15450
|
+
return deny(
|
|
15451
|
+
403,
|
|
15452
|
+
"This nest is archived, so it is read-only. Restore it to change stewards.",
|
|
15453
|
+
{ archived: true }
|
|
15454
|
+
);
|
|
15455
|
+
}
|
|
14677
15456
|
const resource = parts[1];
|
|
14678
15457
|
if (resource === "visibility" || resource === "reader-mode") {
|
|
14679
15458
|
required = "admin";
|
|
@@ -14702,7 +15481,9 @@ function createApp() {
|
|
|
14702
15481
|
stewardEditorBypass = resolved.some(
|
|
14703
15482
|
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
|
|
14704
15483
|
);
|
|
14705
|
-
} else if (
|
|
15484
|
+
} else if (c.req.method === "POST" && // POST /nodes (create) and POST /nodes/pdf (PDF upload — a create, or
|
|
15485
|
+
// a new version of a pdf node) share the nest-scope editor rule.
|
|
15486
|
+
(parts.length === 2 || parts.length === 3 && parts[2] === "pdf")) {
|
|
14706
15487
|
const resolved = await resolveStewardsForNode(nestId, "");
|
|
14707
15488
|
stewardEditorBypass = resolved.some(
|
|
14708
15489
|
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
|
|
@@ -14712,6 +15493,13 @@ function createApp() {
|
|
|
14712
15493
|
const grantLevel = grantRole ? permissionLevel(grantRole) : grantNestVisible ? permissionLevel("read") : 0;
|
|
14713
15494
|
const effectiveLevel = Math.max(permissionLevel(permission), grantLevel);
|
|
14714
15495
|
if (!stewardEditorBypass && effectiveLevel < permissionLevel(required)) {
|
|
15496
|
+
if (access.archived && permissionLevel(access.rawPermission) >= permissionLevel(required)) {
|
|
15497
|
+
return deny(
|
|
15498
|
+
403,
|
|
15499
|
+
"This nest is archived, so it is read-only. Restore it to make changes.",
|
|
15500
|
+
{ archived: true }
|
|
15501
|
+
);
|
|
15502
|
+
}
|
|
14715
15503
|
return deny(
|
|
14716
15504
|
403,
|
|
14717
15505
|
`You don't have access to perform this action on this nest. Required permission: '${required}', your permission: '${permission}'. Ask the nest owner or a server admin to grant you ${required} access.`,
|
|
@@ -14757,6 +15545,7 @@ function createApp() {
|
|
|
14757
15545
|
});
|
|
14758
15546
|
nestsApp.route("/", nestRoutes);
|
|
14759
15547
|
nestsApp.route("/:nestId", governanceRoutes);
|
|
15548
|
+
nestsApp.route("/:nestId/nodes", pdfNodeRoutes);
|
|
14760
15549
|
nestsApp.route("/:nestId/nodes", annotationRoutes);
|
|
14761
15550
|
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
14762
15551
|
nestsApp.route("/:nestId/nodes", nodeRoutes);
|