@promptowl/contextnest-community 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIGURATION.md +182 -181
- package/LICENSE.md +142 -142
- package/README.md +224 -224
- package/dist/{chunk-43DOX4LH.js → chunk-PJTAUVD7.js} +11 -1
- package/dist/{chunk-QMLAXQES.js → chunk-PLAHCDQG.js} +59 -1
- package/dist/{chunk-VD5QX2ZQ.js → chunk-XNGOO6RI.js} +1 -1
- package/dist/{chunk-MOXICJPD.js → chunk-Z3Y46ZGU.js} +62 -3
- package/dist/index.js +745 -35
- package/dist/{migrations.postgres-3HMGLKOV.js → migrations.postgres-VCG45LMK.js} +35 -1
- package/dist/{review-service-2FSKW425.js → review-service-G5US3SMG.js} +4 -4
- package/dist/{stewardship-service-I2JAFYJU.js → stewardship-service-ZXAMFTXO.js} +2 -2
- package/dist/{version-service-FJWVTZKR.js → version-service-B3SDOJQE.js} +2 -2
- package/dist/web3/assets/{hootie-_U3ECslt.svg → hootie-C2ocYkn4.svg} +8 -8
- package/dist/web3/assets/{index-DpM8J4RN.js → index-Den-5nzY.js} +239 -224
- package/dist/web3/assets/index-Jfj-2MHm.css +1 -0
- package/dist/web3/index.html +14 -14
- package/package.json +152 -152
- package/dist/web3/assets/index-24dBhQSQ.css +0 -1
package/dist/index.js
CHANGED
|
@@ -13,10 +13,11 @@ import {
|
|
|
13
13
|
getPendingReview,
|
|
14
14
|
getReviewHistory,
|
|
15
15
|
getReviewQueue,
|
|
16
|
+
notifySlackForNest,
|
|
16
17
|
reject,
|
|
17
18
|
safePublishDocument,
|
|
18
19
|
submitForReview
|
|
19
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-Z3Y46ZGU.js";
|
|
20
21
|
import {
|
|
21
22
|
checkConflict,
|
|
22
23
|
createVersion,
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
getDisplayStatus,
|
|
26
27
|
getVersions,
|
|
27
28
|
setApprovedVersion
|
|
28
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-XNGOO6RI.js";
|
|
29
30
|
import {
|
|
30
31
|
AppError,
|
|
31
32
|
ConflictError,
|
|
@@ -84,14 +85,14 @@ import {
|
|
|
84
85
|
updateSteward,
|
|
85
86
|
upsertEnvVar,
|
|
86
87
|
validateLicense
|
|
87
|
-
} from "./chunk-
|
|
88
|
+
} from "./chunk-PJTAUVD7.js";
|
|
88
89
|
import {
|
|
89
90
|
config,
|
|
90
91
|
getDb,
|
|
91
92
|
initDb,
|
|
92
93
|
insertOrIgnore,
|
|
93
94
|
nowExpr
|
|
94
|
-
} from "./chunk-
|
|
95
|
+
} from "./chunk-PLAHCDQG.js";
|
|
95
96
|
import {
|
|
96
97
|
ANON_EMAIL,
|
|
97
98
|
ANON_USER_ID
|
|
@@ -101,7 +102,8 @@ import {
|
|
|
101
102
|
import { serve } from "@hono/node-server";
|
|
102
103
|
|
|
103
104
|
// src/app.ts
|
|
104
|
-
import { Hono as
|
|
105
|
+
import { Hono as Hono10 } from "hono";
|
|
106
|
+
import { LinearRouter } from "hono/router/linear-router";
|
|
105
107
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
106
108
|
import { cors } from "hono/cors";
|
|
107
109
|
|
|
@@ -1289,7 +1291,7 @@ async function approveExternalEdit(input) {
|
|
|
1289
1291
|
const node = await storage.readDocument(input.documentId);
|
|
1290
1292
|
const versionNum = result.versionEntry.version;
|
|
1291
1293
|
const tags = node.frontmatter.tags || [];
|
|
1292
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1294
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-B3SDOJQE.js");
|
|
1293
1295
|
await createVersion2({
|
|
1294
1296
|
nestId: input.nestId,
|
|
1295
1297
|
nodeId: input.documentId,
|
|
@@ -1375,6 +1377,10 @@ function bodyOnly(nodeId, raw) {
|
|
|
1375
1377
|
return raw;
|
|
1376
1378
|
}
|
|
1377
1379
|
}
|
|
1380
|
+
var RUNNABLE_NODE_TYPES = ["agent", "skill"];
|
|
1381
|
+
function isRunnableType(type) {
|
|
1382
|
+
return RUNNABLE_NODE_TYPES.includes(type || "");
|
|
1383
|
+
}
|
|
1378
1384
|
function toNodeResponse(node) {
|
|
1379
1385
|
const fm = node.frontmatter;
|
|
1380
1386
|
const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
|
|
@@ -1391,14 +1397,20 @@ function toNodeResponse(node) {
|
|
|
1391
1397
|
created_at: fm.created_at,
|
|
1392
1398
|
updated_at: fm.updated_at,
|
|
1393
1399
|
content: node.body || "",
|
|
1394
|
-
pendingChange: node.pendingChange ?? void 0
|
|
1400
|
+
pendingChange: node.pendingChange ?? void 0,
|
|
1401
|
+
// Same YAML-coercion rule as title above: `schedule: 123` parses as a
|
|
1402
|
+
// number — normalize to string at the API boundary.
|
|
1403
|
+
schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
|
|
1395
1404
|
};
|
|
1396
1405
|
}
|
|
1397
1406
|
async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
1398
1407
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
1399
1408
|
let documents = await storage.discoverDocuments();
|
|
1400
1409
|
if (filters.type) {
|
|
1401
|
-
|
|
1410
|
+
const wanted = new Set(
|
|
1411
|
+
Array.isArray(filters.type) ? filters.type : [filters.type]
|
|
1412
|
+
);
|
|
1413
|
+
documents = documents.filter((n) => wanted.has(n.frontmatter.type));
|
|
1402
1414
|
}
|
|
1403
1415
|
if (filters.tag) {
|
|
1404
1416
|
const tag = normalizeTag2(filters.tag);
|
|
@@ -1464,6 +1476,11 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1464
1476
|
}
|
|
1465
1477
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1466
1478
|
const tags = (input.tags || []).map(normalizeTag2);
|
|
1479
|
+
if (input.schedule !== void 0 && !isRunnableType(input.type)) {
|
|
1480
|
+
throw new ValidationError(
|
|
1481
|
+
`schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1467
1484
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1468
1485
|
const initialStatus = hasStewards ? "draft" : "published";
|
|
1469
1486
|
const initialVersion = hasStewards ? 1 : 0;
|
|
@@ -1478,7 +1495,11 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1478
1495
|
version: initialVersion,
|
|
1479
1496
|
created_at: now,
|
|
1480
1497
|
updated_at: now,
|
|
1481
|
-
metadata: {
|
|
1498
|
+
metadata: {
|
|
1499
|
+
owners: ["*"],
|
|
1500
|
+
scope: input.scope || "team",
|
|
1501
|
+
...input.schedule ? { schedule: input.schedule } : {}
|
|
1502
|
+
}
|
|
1482
1503
|
},
|
|
1483
1504
|
body: input.content,
|
|
1484
1505
|
rawContent: ""
|
|
@@ -1605,6 +1626,17 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1605
1626
|
if (patch.title) {
|
|
1606
1627
|
node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
|
|
1607
1628
|
}
|
|
1629
|
+
if (patch.schedule !== void 0) {
|
|
1630
|
+
if (!isRunnableType(node.frontmatter.type)) {
|
|
1631
|
+
throw new ValidationError(
|
|
1632
|
+
`schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
const metadata = { ...node.frontmatter.metadata };
|
|
1636
|
+
if (patch.schedule === "") delete metadata.schedule;
|
|
1637
|
+
else metadata.schedule = patch.schedule;
|
|
1638
|
+
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
1639
|
+
}
|
|
1608
1640
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1609
1641
|
const currentTags = node.frontmatter.tags || [];
|
|
1610
1642
|
if (hasStewards && await getPendingReview(nestId, nodeId)) {
|
|
@@ -2165,6 +2197,10 @@ async function addCollaborator(params) {
|
|
|
2165
2197
|
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
|
|
2166
2198
|
[collabId, nestId, userId, params.permission, granterId]
|
|
2167
2199
|
);
|
|
2200
|
+
void notifySlackForNest(
|
|
2201
|
+
nestId,
|
|
2202
|
+
`:key: ${params.email || userId} added as *${params.permission}*${params.grantedByEmail ? ` by ${params.grantedByEmail}` : ""}`
|
|
2203
|
+
);
|
|
2168
2204
|
return await db.get(
|
|
2169
2205
|
"SELECT * FROM nest_collaborators WHERE id = ?",
|
|
2170
2206
|
[collabId]
|
|
@@ -2597,6 +2633,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
2597
2633
|
tags: body.tags,
|
|
2598
2634
|
scope: body.scope,
|
|
2599
2635
|
status: body.status,
|
|
2636
|
+
schedule: body.schedule,
|
|
2600
2637
|
folder: body.folder
|
|
2601
2638
|
},
|
|
2602
2639
|
authorEmail
|
|
@@ -2614,7 +2651,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
2614
2651
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
2615
2652
|
const nestId = c.req.param("nestId");
|
|
2616
2653
|
const nodeId = c.req.param("nodeId");
|
|
2617
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
2654
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-ZXAMFTXO.js");
|
|
2618
2655
|
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
2619
2656
|
nestId,
|
|
2620
2657
|
nodeId
|
|
@@ -2635,7 +2672,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
2635
2672
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
2636
2673
|
const nestId = c.req.param("nestId");
|
|
2637
2674
|
const nodeId = c.req.param("nodeId");
|
|
2638
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
2675
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-B3SDOJQE.js");
|
|
2639
2676
|
const allVersions = await getVersions2(nestId, nodeId);
|
|
2640
2677
|
const approved = await getApprovedVersion2(nestId, nodeId);
|
|
2641
2678
|
const db = getDb();
|
|
@@ -2667,7 +2704,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
2667
2704
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
2668
2705
|
const nestId = c.req.param("nestId");
|
|
2669
2706
|
const nodeId = c.req.param("nodeId");
|
|
2670
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
2707
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-G5US3SMG.js");
|
|
2671
2708
|
const history = await getReviewHistory2(nestId, nodeId);
|
|
2672
2709
|
return c.json({ reviews: history });
|
|
2673
2710
|
});
|
|
@@ -2814,7 +2851,8 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
2814
2851
|
tags: body.tags,
|
|
2815
2852
|
title: body.title,
|
|
2816
2853
|
status: body.status,
|
|
2817
|
-
changeNote: body.changeNote
|
|
2854
|
+
changeNote: body.changeNote,
|
|
2855
|
+
schedule: body.schedule
|
|
2818
2856
|
},
|
|
2819
2857
|
authorEmail
|
|
2820
2858
|
);
|
|
@@ -2880,6 +2918,13 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
|
2880
2918
|
"DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
|
|
2881
2919
|
[nestId, derivedId]
|
|
2882
2920
|
);
|
|
2921
|
+
await tx.run(
|
|
2922
|
+
`INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
|
|
2923
|
+
VALUES (?, ?, ?, ?)
|
|
2924
|
+
ON CONFLICT(nest_id, node_id) DO UPDATE
|
|
2925
|
+
SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
|
|
2926
|
+
[nestId, nodeId, await getUserEmail(c), (/* @__PURE__ */ new Date()).toISOString()]
|
|
2927
|
+
);
|
|
2883
2928
|
});
|
|
2884
2929
|
await trackEvent("node.delete", { nestId, nodeId });
|
|
2885
2930
|
return c.json({ deleted: true });
|
|
@@ -3388,6 +3433,100 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
|
3388
3433
|
|
|
3389
3434
|
// src/nodes/query-routes.ts
|
|
3390
3435
|
var queryRoutes = new Hono6();
|
|
3436
|
+
queryRoutes.get("/changes", async (c) => {
|
|
3437
|
+
const nestId = c.req.param("nestId");
|
|
3438
|
+
const userId = c.get("userId");
|
|
3439
|
+
const sinceRaw = c.req.query("since");
|
|
3440
|
+
const sinceMs = sinceRaw ? Date.parse(sinceRaw) : NaN;
|
|
3441
|
+
if (!sinceRaw || Number.isNaN(sinceMs)) {
|
|
3442
|
+
throw new ValidationError(
|
|
3443
|
+
"since must be an ISO-8601 date, e.g. ?since=2026-07-01T00:00:00Z"
|
|
3444
|
+
);
|
|
3445
|
+
}
|
|
3446
|
+
const includeContent = ["1", "true"].includes(
|
|
3447
|
+
c.req.query("include_content") || ""
|
|
3448
|
+
);
|
|
3449
|
+
const all = await listNodesForCaller(nestId, userId);
|
|
3450
|
+
const db = getDb();
|
|
3451
|
+
const publicReader = await isPublicReader(nestId, userId);
|
|
3452
|
+
const approvedRows = await db.all(
|
|
3453
|
+
"SELECT node_id, approved_at FROM approved_versions WHERE nest_id = ?",
|
|
3454
|
+
[nestId]
|
|
3455
|
+
);
|
|
3456
|
+
const parseDbTs = (t) => Date.parse(t.includes("T") ? t : `${t.replace(" ", "T")}Z`);
|
|
3457
|
+
const approvedAtMsByNode = new Map(
|
|
3458
|
+
approvedRows.map((r) => [r.node_id, parseDbTs(r.approved_at)])
|
|
3459
|
+
);
|
|
3460
|
+
const publishedSince = new Set(
|
|
3461
|
+
approvedRows.filter((r) => parseDbTs(r.approved_at) > sinceMs).map((r) => r.node_id)
|
|
3462
|
+
);
|
|
3463
|
+
const summarize = (n, updatedOverride) => ({
|
|
3464
|
+
id: n.id,
|
|
3465
|
+
title: n.title,
|
|
3466
|
+
type: n.type,
|
|
3467
|
+
status: n.status,
|
|
3468
|
+
tags: n.tags,
|
|
3469
|
+
created_at: n.created_at,
|
|
3470
|
+
updated_at: updatedOverride ?? n.updated_at,
|
|
3471
|
+
...includeContent ? { content: n.content } : {}
|
|
3472
|
+
});
|
|
3473
|
+
const created = [];
|
|
3474
|
+
const updated = [];
|
|
3475
|
+
for (const n of all) {
|
|
3476
|
+
if (publicReader) {
|
|
3477
|
+
const approvedMs = approvedAtMsByNode.get(n.id);
|
|
3478
|
+
if (approvedMs === void 0) continue;
|
|
3479
|
+
const approvedIso = new Date(approvedMs).toISOString();
|
|
3480
|
+
const createdAt2 = Date.parse(n.created_at || "");
|
|
3481
|
+
if (!Number.isNaN(createdAt2) && createdAt2 > sinceMs) {
|
|
3482
|
+
created.push(summarize(n, approvedIso));
|
|
3483
|
+
} else if (approvedMs > sinceMs) {
|
|
3484
|
+
updated.push(summarize(n, approvedIso));
|
|
3485
|
+
}
|
|
3486
|
+
continue;
|
|
3487
|
+
}
|
|
3488
|
+
const createdAt = Date.parse(n.created_at || "");
|
|
3489
|
+
const updatedAt = Date.parse(n.updated_at || "");
|
|
3490
|
+
if (!Number.isNaN(createdAt) && createdAt > sinceMs) created.push(summarize(n));
|
|
3491
|
+
else if (!Number.isNaN(updatedAt) && updatedAt > sinceMs) updated.push(summarize(n));
|
|
3492
|
+
else if (publishedSince.has(n.id)) updated.push(summarize(n));
|
|
3493
|
+
}
|
|
3494
|
+
const canSeeDeleted = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
|
|
3495
|
+
let deleted = null;
|
|
3496
|
+
if (canSeeDeleted) {
|
|
3497
|
+
const rows = await db.all(
|
|
3498
|
+
"SELECT node_id, deleted_by, deleted_at FROM node_deletions WHERE nest_id = ? AND deleted_at > ?",
|
|
3499
|
+
[nestId, new Date(sinceMs).toISOString()]
|
|
3500
|
+
);
|
|
3501
|
+
const liveIds = new Set(all.map((n) => n.id));
|
|
3502
|
+
deleted = rows.filter((r) => !liveIds.has(r.node_id)).map((r) => ({ id: r.node_id, deleted_by: r.deleted_by, deleted_at: r.deleted_at }));
|
|
3503
|
+
}
|
|
3504
|
+
return c.json({
|
|
3505
|
+
since: new Date(sinceMs).toISOString(),
|
|
3506
|
+
now: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3507
|
+
created,
|
|
3508
|
+
updated,
|
|
3509
|
+
deleted
|
|
3510
|
+
});
|
|
3511
|
+
});
|
|
3512
|
+
queryRoutes.get("/runnables", async (c) => {
|
|
3513
|
+
const nestId = c.req.param("nestId");
|
|
3514
|
+
const userId = c.get("userId");
|
|
3515
|
+
const all = await listNodesForCaller(nestId, userId, {
|
|
3516
|
+
type: RUNNABLE_NODE_TYPES
|
|
3517
|
+
});
|
|
3518
|
+
const runnables = all.map((n) => ({
|
|
3519
|
+
id: n.id,
|
|
3520
|
+
title: n.title,
|
|
3521
|
+
type: n.type,
|
|
3522
|
+
schedule: n.schedule ?? null,
|
|
3523
|
+
status: n.status,
|
|
3524
|
+
tags: n.tags,
|
|
3525
|
+
updated_at: n.updated_at,
|
|
3526
|
+
content: n.content
|
|
3527
|
+
}));
|
|
3528
|
+
return c.json({ count: runnables.length, runnables });
|
|
3529
|
+
});
|
|
3391
3530
|
queryRoutes.get("/graph", async (c) => {
|
|
3392
3531
|
const nestId = c.req.param("nestId");
|
|
3393
3532
|
const userId = c.get("userId");
|
|
@@ -3779,6 +3918,79 @@ import { Hono as Hono7 } from "hono";
|
|
|
3779
3918
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3780
3919
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3781
3920
|
|
|
3921
|
+
// src/telemetry/trace-log.ts
|
|
3922
|
+
var RETENTION_DAYS = 14;
|
|
3923
|
+
var PRUNE_EVERY = 500;
|
|
3924
|
+
var insertsSincePrune = 0;
|
|
3925
|
+
async function logTraceEvent(e) {
|
|
3926
|
+
try {
|
|
3927
|
+
const db = getDb();
|
|
3928
|
+
await db.run(
|
|
3929
|
+
`INSERT INTO api_events
|
|
3930
|
+
(ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms)
|
|
3931
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3932
|
+
[
|
|
3933
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
3934
|
+
e.kind,
|
|
3935
|
+
e.method ?? null,
|
|
3936
|
+
e.path ?? null,
|
|
3937
|
+
e.tool ?? null,
|
|
3938
|
+
e.nestId ?? null,
|
|
3939
|
+
e.userId ?? null,
|
|
3940
|
+
e.userEmail ?? null,
|
|
3941
|
+
e.status ?? null,
|
|
3942
|
+
e.durationMs ?? null
|
|
3943
|
+
]
|
|
3944
|
+
);
|
|
3945
|
+
if (++insertsSincePrune >= PRUNE_EVERY) {
|
|
3946
|
+
insertsSincePrune = 0;
|
|
3947
|
+
const cutoff = new Date(
|
|
3948
|
+
Date.now() - RETENTION_DAYS * 864e5
|
|
3949
|
+
).toISOString();
|
|
3950
|
+
await db.run("DELETE FROM api_events WHERE ts < ?", [cutoff]);
|
|
3951
|
+
}
|
|
3952
|
+
} catch {
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
async function listTraceEvents(filters = {}) {
|
|
3956
|
+
const db = getDb();
|
|
3957
|
+
const limit = Number.isFinite(filters.limit) ? Math.min(Math.max(filters.limit, 1), 1e3) : 25;
|
|
3958
|
+
const offset = Number.isFinite(filters.offset) ? Math.max(filters.offset, 0) : 0;
|
|
3959
|
+
const from = "FROM api_events e LEFT JOIN users u ON u.id = e.user_id";
|
|
3960
|
+
const where = [];
|
|
3961
|
+
const args = [];
|
|
3962
|
+
if (filters.kind) {
|
|
3963
|
+
where.push("e.kind = ?");
|
|
3964
|
+
args.push(filters.kind);
|
|
3965
|
+
}
|
|
3966
|
+
if (filters.nestId) {
|
|
3967
|
+
where.push("e.nest_id = ?");
|
|
3968
|
+
args.push(filters.nestId);
|
|
3969
|
+
}
|
|
3970
|
+
if (filters.user) {
|
|
3971
|
+
where.push("(COALESCE(e.user_email, u.email) LIKE ? OR e.user_id = ?)");
|
|
3972
|
+
args.push(`%${filters.user}%`, filters.user);
|
|
3973
|
+
}
|
|
3974
|
+
const whereSql = where.length ? ` WHERE ${where.join(" AND ")}` : "";
|
|
3975
|
+
try {
|
|
3976
|
+
const totalRow = await db.get(
|
|
3977
|
+
`SELECT COUNT(*) AS n ${from}${whereSql}`,
|
|
3978
|
+
args
|
|
3979
|
+
);
|
|
3980
|
+
const total = Number(totalRow?.n ?? 0);
|
|
3981
|
+
const events = await db.all(
|
|
3982
|
+
`SELECT e.*, COALESCE(e.user_email, u.email) AS caller
|
|
3983
|
+
${from}${whereSql}
|
|
3984
|
+
ORDER BY e.id DESC
|
|
3985
|
+
LIMIT ? OFFSET ?`,
|
|
3986
|
+
[...args, limit, offset]
|
|
3987
|
+
);
|
|
3988
|
+
return { events, total };
|
|
3989
|
+
} catch {
|
|
3990
|
+
return { events: [], total: 0 };
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3782
3994
|
// src/mcp/tools.ts
|
|
3783
3995
|
var MAX_HOPS = 10;
|
|
3784
3996
|
function normalizeHops(raw) {
|
|
@@ -4084,6 +4296,26 @@ async function resolveLlmBody(ctx, node) {
|
|
|
4084
4296
|
}
|
|
4085
4297
|
}
|
|
4086
4298
|
async function handleToolCall(toolName, args, ctx) {
|
|
4299
|
+
const started = Date.now();
|
|
4300
|
+
let status = 200;
|
|
4301
|
+
try {
|
|
4302
|
+
return await runTool(toolName, args, ctx);
|
|
4303
|
+
} catch (err) {
|
|
4304
|
+
status = 500;
|
|
4305
|
+
throw err;
|
|
4306
|
+
} finally {
|
|
4307
|
+
void logTraceEvent({
|
|
4308
|
+
kind: "mcp",
|
|
4309
|
+
tool: toolName,
|
|
4310
|
+
nestId: ctx.nestId,
|
|
4311
|
+
userId: ctx.userId,
|
|
4312
|
+
userEmail: ctx.userEmail,
|
|
4313
|
+
status,
|
|
4314
|
+
durationMs: Date.now() - started
|
|
4315
|
+
});
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
async function runTool(toolName, args, ctx) {
|
|
4087
4319
|
const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
|
|
4088
4320
|
switch (toolName) {
|
|
4089
4321
|
case "context_init": {
|
|
@@ -4627,8 +4859,407 @@ mcpRoutes.all("/", async (c) => {
|
|
|
4627
4859
|
}
|
|
4628
4860
|
});
|
|
4629
4861
|
|
|
4630
|
-
// src/
|
|
4862
|
+
// src/mcp/server-routes.ts
|
|
4631
4863
|
import { Hono as Hono8 } from "hono";
|
|
4864
|
+
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4865
|
+
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
4866
|
+
import { z as z2 } from "zod";
|
|
4867
|
+
async function getUserEmail3(userId) {
|
|
4868
|
+
const db = getDb();
|
|
4869
|
+
const user = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
4870
|
+
userId
|
|
4871
|
+
]);
|
|
4872
|
+
return user?.email || "anonymous@localhost";
|
|
4873
|
+
}
|
|
4874
|
+
async function canRead(nestId, userId) {
|
|
4875
|
+
if (config.AUTH_MODE === "open") return true;
|
|
4876
|
+
const perm = await resolveNestPermission(nestId, userId);
|
|
4877
|
+
return permissionLevel(perm) >= permissionLevel("read");
|
|
4878
|
+
}
|
|
4879
|
+
async function resolveNestRef(ref, userId) {
|
|
4880
|
+
const db = getDb();
|
|
4881
|
+
const byId = await db.get("SELECT * FROM nests WHERE id = ?", [ref]);
|
|
4882
|
+
if (byId) return byId;
|
|
4883
|
+
const byName = await db.all(
|
|
4884
|
+
"SELECT * FROM nests WHERE LOWER(name) = LOWER(?) OR LOWER(slug) = LOWER(?)",
|
|
4885
|
+
[ref, ref]
|
|
4886
|
+
);
|
|
4887
|
+
if (byName.length <= 1) return byName[0] ?? null;
|
|
4888
|
+
const readable = (await Promise.all(
|
|
4889
|
+
byName.map(async (n) => await canRead(n.id, userId) ? n : null)
|
|
4890
|
+
)).filter((n) => n !== null);
|
|
4891
|
+
return readable.length === 1 ? readable[0] : null;
|
|
4892
|
+
}
|
|
4893
|
+
async function buildNestIndex(userId, nestScope) {
|
|
4894
|
+
const [owned, shared, publicExtras] = await Promise.all([
|
|
4895
|
+
listNests(userId),
|
|
4896
|
+
listSharedNests(userId),
|
|
4897
|
+
listPublicNests(userId)
|
|
4898
|
+
]);
|
|
4899
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4900
|
+
const rows = [];
|
|
4901
|
+
for (const n of [...owned, ...shared, ...publicExtras]) {
|
|
4902
|
+
if (seen.has(n.id)) continue;
|
|
4903
|
+
if (nestScope && n.id !== nestScope) continue;
|
|
4904
|
+
seen.add(n.id);
|
|
4905
|
+
rows.push(n);
|
|
4906
|
+
}
|
|
4907
|
+
return Promise.all(
|
|
4908
|
+
rows.map(async (n) => {
|
|
4909
|
+
let permission = "read";
|
|
4910
|
+
try {
|
|
4911
|
+
permission = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(n.id, userId);
|
|
4912
|
+
} catch {
|
|
4913
|
+
}
|
|
4914
|
+
let document_count = null;
|
|
4915
|
+
try {
|
|
4916
|
+
const { storage } = await engineCache.get(n.id);
|
|
4917
|
+
document_count = (await storage.discoverDocuments()).length;
|
|
4918
|
+
} catch {
|
|
4919
|
+
}
|
|
4920
|
+
return {
|
|
4921
|
+
id: n.id,
|
|
4922
|
+
name: n.name,
|
|
4923
|
+
description: n.description,
|
|
4924
|
+
visibility: n.visibility,
|
|
4925
|
+
permission,
|
|
4926
|
+
document_count
|
|
4927
|
+
};
|
|
4928
|
+
})
|
|
4929
|
+
);
|
|
4930
|
+
}
|
|
4931
|
+
function renderNestIndexMarkdown(nests, baseUrl) {
|
|
4932
|
+
const lines = [
|
|
4933
|
+
"# Nest index",
|
|
4934
|
+
"",
|
|
4935
|
+
`${nests.length} nest${nests.length === 1 ? "" : "s"} accessible with these credentials. Full interaction manual: ${baseUrl}/llms.txt`,
|
|
4936
|
+
""
|
|
4937
|
+
];
|
|
4938
|
+
for (const n of nests) {
|
|
4939
|
+
lines.push(`## ${n.name}`);
|
|
4940
|
+
lines.push("");
|
|
4941
|
+
lines.push(`- id: \`${n.id}\``);
|
|
4942
|
+
if (n.description) lines.push(`- description: ${n.description}`);
|
|
4943
|
+
lines.push(`- permission: ${n.permission} \xB7 visibility: ${n.visibility}`);
|
|
4944
|
+
if (n.document_count !== null)
|
|
4945
|
+
lines.push(`- documents: ${n.document_count}`);
|
|
4946
|
+
lines.push(
|
|
4947
|
+
`- retrieve: \`POST ${baseUrl}/nests/${n.id}/context\` \xB7 mcp: \`${baseUrl}/nests/${n.id}/mcp\` \xB7 export: \`${baseUrl}/nests/${n.id}/export?format=markdown\``
|
|
4948
|
+
);
|
|
4949
|
+
lines.push("");
|
|
4950
|
+
}
|
|
4951
|
+
return lines.join("\n");
|
|
4952
|
+
}
|
|
4953
|
+
var NEST_ARG = {
|
|
4954
|
+
nest: {
|
|
4955
|
+
type: "string",
|
|
4956
|
+
description: "Target nest \u2014 id, or exact name/slug (case-insensitive)"
|
|
4957
|
+
}
|
|
4958
|
+
};
|
|
4959
|
+
var SERVER_TOOL_DEFINITIONS = [
|
|
4960
|
+
{
|
|
4961
|
+
name: "nest_index",
|
|
4962
|
+
description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST, then target a nest with the other tools.",
|
|
4963
|
+
inputSchema: { type: "object", properties: {} }
|
|
4964
|
+
},
|
|
4965
|
+
{
|
|
4966
|
+
name: "context_query",
|
|
4967
|
+
description: "Run a structured selector query against one nest. Selector grammar: #tag, type:X, [[Title]], scope:X, combined with +AND, |OR, -NOT.",
|
|
4968
|
+
inputSchema: {
|
|
4969
|
+
type: "object",
|
|
4970
|
+
properties: {
|
|
4971
|
+
...NEST_ARG,
|
|
4972
|
+
query: { type: "string", description: "Selector query" },
|
|
4973
|
+
hops: {
|
|
4974
|
+
type: "number",
|
|
4975
|
+
description: "Graph traversal depth from the matches (default: 2)"
|
|
4976
|
+
}
|
|
4977
|
+
},
|
|
4978
|
+
required: ["nest", "query"]
|
|
4979
|
+
}
|
|
4980
|
+
},
|
|
4981
|
+
{
|
|
4982
|
+
name: "context_search",
|
|
4983
|
+
description: "Full-text keyword search across one nest.",
|
|
4984
|
+
inputSchema: {
|
|
4985
|
+
type: "object",
|
|
4986
|
+
properties: {
|
|
4987
|
+
...NEST_ARG,
|
|
4988
|
+
query: { type: "string", description: "Search terms" }
|
|
4989
|
+
},
|
|
4990
|
+
required: ["nest", "query"]
|
|
4991
|
+
}
|
|
4992
|
+
},
|
|
4993
|
+
{
|
|
4994
|
+
name: "context_get",
|
|
4995
|
+
description: "Get the FULL content of a node in a nest by title or id.",
|
|
4996
|
+
inputSchema: {
|
|
4997
|
+
type: "object",
|
|
4998
|
+
properties: {
|
|
4999
|
+
...NEST_ARG,
|
|
5000
|
+
title: { type: "string", description: "Title of the node" },
|
|
5001
|
+
id: { type: "string", description: "ID of the node" }
|
|
5002
|
+
},
|
|
5003
|
+
required: ["nest"]
|
|
5004
|
+
}
|
|
5005
|
+
},
|
|
5006
|
+
{
|
|
5007
|
+
name: "context_list",
|
|
5008
|
+
description: "Browse one nest's contents with optional type, tag, or limit filters.",
|
|
5009
|
+
inputSchema: {
|
|
5010
|
+
type: "object",
|
|
5011
|
+
properties: {
|
|
5012
|
+
...NEST_ARG,
|
|
5013
|
+
type: { type: "string", description: "Filter by node type" },
|
|
5014
|
+
tag: { type: "string", description: "Filter by tag" },
|
|
5015
|
+
limit: { type: "number", description: "Max nodes to return" }
|
|
5016
|
+
},
|
|
5017
|
+
required: ["nest"]
|
|
5018
|
+
}
|
|
5019
|
+
},
|
|
5020
|
+
{
|
|
5021
|
+
name: "context_overview",
|
|
5022
|
+
description: "Map of one nest: node count, types, tags, title+snippet per node.",
|
|
5023
|
+
inputSchema: {
|
|
5024
|
+
type: "object",
|
|
5025
|
+
properties: { ...NEST_ARG },
|
|
5026
|
+
required: ["nest"]
|
|
5027
|
+
}
|
|
5028
|
+
},
|
|
5029
|
+
{
|
|
5030
|
+
name: "context_resolve",
|
|
5031
|
+
description: "Full deterministic context resolution against one nest \u2014 selector + hops + token budget, complete node bodies.",
|
|
5032
|
+
inputSchema: {
|
|
5033
|
+
type: "object",
|
|
5034
|
+
properties: {
|
|
5035
|
+
...NEST_ARG,
|
|
5036
|
+
selector: { type: "string", description: "Selector query string" },
|
|
5037
|
+
max_tokens: {
|
|
5038
|
+
type: "number",
|
|
5039
|
+
description: "Approximate token budget (default: 8000)"
|
|
5040
|
+
},
|
|
5041
|
+
hops: {
|
|
5042
|
+
type: "number",
|
|
5043
|
+
description: "Graph traversal depth from the matches (default: 2)"
|
|
5044
|
+
}
|
|
5045
|
+
},
|
|
5046
|
+
required: ["nest", "selector"]
|
|
5047
|
+
}
|
|
5048
|
+
}
|
|
5049
|
+
];
|
|
5050
|
+
async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
|
|
5051
|
+
if (toolName === "nest_index") {
|
|
5052
|
+
const nests = await buildNestIndex(userId, nestScope);
|
|
5053
|
+
if (!nests.length) return "No nests accessible with these credentials.";
|
|
5054
|
+
return renderNestIndexMarkdown(nests, baseUrl);
|
|
5055
|
+
}
|
|
5056
|
+
const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
|
|
5057
|
+
const nest = await resolveNestRef(String(args.nest ?? ""), userId);
|
|
5058
|
+
if (!nest) return inaccessible;
|
|
5059
|
+
if (nestScope && nest.id !== nestScope) return inaccessible;
|
|
5060
|
+
if (!await canRead(nest.id, userId)) return inaccessible;
|
|
5061
|
+
const engine = await engineCache.get(nest.id);
|
|
5062
|
+
return handleToolCall(toolName, args, {
|
|
5063
|
+
storage: engine.storage,
|
|
5064
|
+
queryEngine: engine.query,
|
|
5065
|
+
versionManager: engine.versions,
|
|
5066
|
+
nestId: nest.id,
|
|
5067
|
+
userId,
|
|
5068
|
+
userEmail
|
|
5069
|
+
});
|
|
5070
|
+
}
|
|
5071
|
+
function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
5072
|
+
const server = new McpServer2(
|
|
5073
|
+
{ name: "contextnest-server", version: "1.0.0" },
|
|
5074
|
+
{ capabilities: { tools: {} } }
|
|
5075
|
+
);
|
|
5076
|
+
for (const tool of SERVER_TOOL_DEFINITIONS) {
|
|
5077
|
+
const props = tool.inputSchema.properties || {};
|
|
5078
|
+
const required = tool.inputSchema.required || [];
|
|
5079
|
+
const shape = {};
|
|
5080
|
+
for (const [key, def] of Object.entries(props)) {
|
|
5081
|
+
let field;
|
|
5082
|
+
if (def.type === "string") field = z2.string();
|
|
5083
|
+
else if (def.type === "number") field = z2.number();
|
|
5084
|
+
else if (def.type === "array") field = z2.array(z2.string());
|
|
5085
|
+
else field = z2.any();
|
|
5086
|
+
if (!required.includes(key)) field = field.optional();
|
|
5087
|
+
shape[key] = field;
|
|
5088
|
+
}
|
|
5089
|
+
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
5090
|
+
const text = await handleServerToolCall(
|
|
5091
|
+
tool.name,
|
|
5092
|
+
args,
|
|
5093
|
+
userId,
|
|
5094
|
+
userEmail,
|
|
5095
|
+
nestScope,
|
|
5096
|
+
baseUrl
|
|
5097
|
+
);
|
|
5098
|
+
return { content: [{ type: "text", text }] };
|
|
5099
|
+
});
|
|
5100
|
+
}
|
|
5101
|
+
return server;
|
|
5102
|
+
}
|
|
5103
|
+
function requestBaseUrl(reqUrl) {
|
|
5104
|
+
if (config.PUBLIC_BASE_URL) return config.PUBLIC_BASE_URL;
|
|
5105
|
+
try {
|
|
5106
|
+
return new URL(reqUrl).origin;
|
|
5107
|
+
} catch {
|
|
5108
|
+
return "";
|
|
5109
|
+
}
|
|
5110
|
+
}
|
|
5111
|
+
var serverMcpRoutes = new Hono8();
|
|
5112
|
+
serverMcpRoutes.all("/", async (c) => {
|
|
5113
|
+
const userId = c.get("userId");
|
|
5114
|
+
const userEmail = await getUserEmail3(userId);
|
|
5115
|
+
const server = createServerMcp(
|
|
5116
|
+
userId,
|
|
5117
|
+
userEmail,
|
|
5118
|
+
c.get("nestScope"),
|
|
5119
|
+
requestBaseUrl(c.req.url)
|
|
5120
|
+
);
|
|
5121
|
+
const transport = new WebStandardStreamableHTTPServerTransport2({
|
|
5122
|
+
sessionIdGenerator: void 0,
|
|
5123
|
+
enableJsonResponse: true
|
|
5124
|
+
});
|
|
5125
|
+
await server.server.connect(transport);
|
|
5126
|
+
try {
|
|
5127
|
+
return await transport.handleRequest(c.req.raw);
|
|
5128
|
+
} finally {
|
|
5129
|
+
await transport.close();
|
|
5130
|
+
await server.server.close();
|
|
5131
|
+
}
|
|
5132
|
+
});
|
|
5133
|
+
var nestIndexRoutes = new Hono8();
|
|
5134
|
+
nestIndexRoutes.get("/", async (c) => {
|
|
5135
|
+
const userId = c.get("userId");
|
|
5136
|
+
const nests = await buildNestIndex(userId, c.get("nestScope"));
|
|
5137
|
+
if (c.req.query("format") === "json") {
|
|
5138
|
+
return c.json({ count: nests.length, nests });
|
|
5139
|
+
}
|
|
5140
|
+
const md = renderNestIndexMarkdown(nests, requestBaseUrl(c.req.url));
|
|
5141
|
+
return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
|
|
5142
|
+
});
|
|
5143
|
+
function renderLlmsTxt(baseUrl) {
|
|
5144
|
+
const B = baseUrl || "<server-url>";
|
|
5145
|
+
return `# ContextNest Community Server
|
|
5146
|
+
|
|
5147
|
+
Self-hosted context governance server. Nests are versioned knowledge vaults
|
|
5148
|
+
of markdown documents with tags, wiki-links ([[Title]]), and steward
|
|
5149
|
+
governance. This file is the complete interaction manual for agents.
|
|
5150
|
+
|
|
5151
|
+
## Authentication
|
|
5152
|
+
|
|
5153
|
+
Every request (except this file and /health): \`Authorization: Bearer cnst_<api-key>\`.
|
|
5154
|
+
Keys are minted in the web UI (Connect dialog \u2192 Generate Key). A key is
|
|
5155
|
+
either user-level (sees every nest the user can read) or scoped to one nest.
|
|
5156
|
+
Servers in "open" auth mode need no key.
|
|
5157
|
+
|
|
5158
|
+
## Start here
|
|
5159
|
+
|
|
5160
|
+
1. \`GET ${B}/index\` \u2014 your grounded nest index (markdown; \`?format=json\` for JSON).
|
|
5161
|
+
One entry per accessible nest: name, id, permission, document count, and
|
|
5162
|
+
the per-nest URLs below.
|
|
5163
|
+
2. Pick a nest id, then retrieve with the API or MCP.
|
|
5164
|
+
|
|
5165
|
+
## Deterministic retrieval API
|
|
5166
|
+
|
|
5167
|
+
Same inputs \u2192 same context, every time. The primary call:
|
|
5168
|
+
|
|
5169
|
+
\`\`\`
|
|
5170
|
+
POST ${B}/nests/<nest-id>/context
|
|
5171
|
+
Content-Type: application/json
|
|
5172
|
+
{ "selector": "#gtm", "hops": 2, "max_tokens": 4000, "include_drafts": false }
|
|
5173
|
+
\`\`\`
|
|
5174
|
+
|
|
5175
|
+
Returns \`{ context, nodes[], trace }\` \u2014 assembled markdown plus a trace with
|
|
5176
|
+
\`hops_used\` and \`nodes_traversed\` so you can audit exactly what was pulled.
|
|
5177
|
+
|
|
5178
|
+
Selector grammar:
|
|
5179
|
+
- \`#tag\` \u2014 nodes carrying a tag
|
|
5180
|
+
- \`type:document\` \u2014 nodes of a type
|
|
5181
|
+
- \`[[Title]]\` \u2014 a node by exact title
|
|
5182
|
+
- \`scope:team\` \u2014 nodes by visibility scope
|
|
5183
|
+
- Combine: \`+AND\`, \`|OR\`, \`-NOT\` (e.g. \`#gtm+type:document-#draft\`)
|
|
5184
|
+
|
|
5185
|
+
Graph-hop logic: \`hops\` is the traversal depth over wiki-links from the
|
|
5186
|
+
selector matches. \`hops: 0\` = exactly the matches; \`hops: 1\` = matches plus
|
|
5187
|
+
directly linked nodes; \`hops: 2\` (default) and up walk the link graph
|
|
5188
|
+
further out. For an exact set with no expansion, OR the selectors together
|
|
5189
|
+
(\`[[A]]|[[B]]\`) and pass \`hops: 0\`.
|
|
5190
|
+
|
|
5191
|
+
Skip logic \u2014 what retrieval excludes and why:
|
|
5192
|
+
- Draft/unapproved documents are skipped unless \`include_drafts: true\`
|
|
5193
|
+
(and public/read-only callers always get approved content only).
|
|
5194
|
+
- \`max_tokens\` is a budget: documents that don't fit are skipped, and the
|
|
5195
|
+
trace reports \`truncated_by_budget\` so silence never means "complete".
|
|
5196
|
+
- Documents are deduped by id when a title match and a selector match overlap.
|
|
5197
|
+
|
|
5198
|
+
Other read endpoints (all under \`${B}/nests/<nest-id>\`):
|
|
5199
|
+
- \`POST /query\` \u2014 \`{ "query": "<selector>", "hops": N }\`; matches + snippets, no bodies
|
|
5200
|
+
- \`GET /search?q=<terms>\` \u2014 full-text search
|
|
5201
|
+
- \`GET /overview\` \u2014 node count, types, tags
|
|
5202
|
+
- \`GET /nodes\` \u2014 list; \`GET /nodes/<node-id>?format=markdown\` \u2014 one document
|
|
5203
|
+
- \`GET /export?format=markdown\` \u2014 whole nest as one markdown file (optional \`selector\`, \`max_tokens\`)
|
|
5204
|
+
- \`GET /graph\` \u2014 ontology graph (nodes, links, tags, stewards)
|
|
5205
|
+
|
|
5206
|
+
Write endpoints (need write permission):
|
|
5207
|
+
- \`POST /nodes\` \u2014 \`{ "title", "content", "tags"?, "type"?, "folder"? }\`
|
|
5208
|
+
- \`PATCH /nodes/<node-id>\` \u2014 \`{ "content"? , "append"?, "tags"?, "title"? }\`
|
|
5209
|
+
- \`DELETE /nodes/<node-id>\`
|
|
5210
|
+
- Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`
|
|
5211
|
+
|
|
5212
|
+
## MCP (Model Context Protocol)
|
|
5213
|
+
|
|
5214
|
+
Streamable HTTP, stateless. Two endpoints:
|
|
5215
|
+
|
|
5216
|
+
- \`${B}/mcp\` \u2014 server-level, one connection for everything readable.
|
|
5217
|
+
Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
|
|
5218
|
+
\`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
|
|
5219
|
+
each takes a \`nest\` argument (id or exact name) plus the same
|
|
5220
|
+
selector/hops/max_tokens parameters as the REST API. Read-only.
|
|
5221
|
+
- \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
|
|
5222
|
+
governance (create/update, submit/approve/reject, stewards, sharing).
|
|
5223
|
+
|
|
5224
|
+
Claude Code / Cursor / VS Code connect natively via HTTP with the
|
|
5225
|
+
\`Authorization: Bearer\` header. Claude Desktop needs the mcp-remote bridge:
|
|
5226
|
+
|
|
5227
|
+
\`\`\`
|
|
5228
|
+
npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
|
|
5229
|
+
\`\`\`
|
|
5230
|
+
|
|
5231
|
+
## ctx CLI (npm: @promptowl/contextnest-cli)
|
|
5232
|
+
|
|
5233
|
+
\`npm i -g @promptowl/contextnest-cli\`
|
|
5234
|
+
|
|
5235
|
+
- \`ctx push --server ${B} --nest <nest-id> --key <api-key>\` \u2014 upload a local
|
|
5236
|
+
vault into a nest (add \`--include-drafts\` for unpublished nodes).
|
|
5237
|
+
- All other ctx commands (\`query\`, \`add\`, \`search\`, \`checkpoint\`) operate on
|
|
5238
|
+
a LOCAL vault directory only. For remote reads use the API or MCP above \u2014
|
|
5239
|
+
there is currently no remote \`ctx query\`.
|
|
5240
|
+
|
|
5241
|
+
## npm packages
|
|
5242
|
+
|
|
5243
|
+
- \`@promptowl/contextnest-community\` \u2014 this server
|
|
5244
|
+
- \`@promptowl/contextnest-cli\` \u2014 the ctx CLI
|
|
5245
|
+
- \`@promptowl/contextnest-engine\` \u2014 vault storage + selector/graph engine
|
|
5246
|
+
|
|
5247
|
+
## Conventions
|
|
5248
|
+
|
|
5249
|
+
- Node ids are paths: \`nodes/<slug>\` or nested \`nodes/<folder>/<slug>\`.
|
|
5250
|
+
- Tags are \`#lowercase\`. Wiki-links are \`[[Exact Title]]\`.
|
|
5251
|
+
- Every write is versioned and hash-chained; governed nests route writes
|
|
5252
|
+
through steward review before they become AI-visible.
|
|
5253
|
+
`;
|
|
5254
|
+
}
|
|
5255
|
+
var llmsTxtRoutes = new Hono8();
|
|
5256
|
+
llmsTxtRoutes.get("/", (c) => {
|
|
5257
|
+
const md = renderLlmsTxt(requestBaseUrl(c.req.url));
|
|
5258
|
+
return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
|
|
5259
|
+
});
|
|
5260
|
+
|
|
5261
|
+
// src/governance/routes.ts
|
|
5262
|
+
import { Hono as Hono9 } from "hono";
|
|
4632
5263
|
|
|
4633
5264
|
// src/governance/comment-service.ts
|
|
4634
5265
|
import { v4 as uuid4 } from "uuid";
|
|
@@ -4932,7 +5563,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
|
|
|
4932
5563
|
if (Number.isNaN(n) || n < 1) return def;
|
|
4933
5564
|
return Math.min(n, max);
|
|
4934
5565
|
}
|
|
4935
|
-
var governanceRoutes = new
|
|
5566
|
+
var governanceRoutes = new Hono9();
|
|
4936
5567
|
governanceRoutes.get("/stewards", async (c) => {
|
|
4937
5568
|
const nestId = c.req.param("nestId");
|
|
4938
5569
|
const scope = c.req.query("scope");
|
|
@@ -4961,7 +5592,7 @@ governanceRoutes.get("/stewards", async (c) => {
|
|
|
4961
5592
|
governanceRoutes.post("/stewards", async (c) => {
|
|
4962
5593
|
const nestId = c.req.param("nestId");
|
|
4963
5594
|
const body = await c.req.json();
|
|
4964
|
-
const assignedBy = await
|
|
5595
|
+
const assignedBy = await getUserEmail4(c);
|
|
4965
5596
|
if (!body.scope) throw new ValidationError("scope is required");
|
|
4966
5597
|
if (body.scope === "folder") {
|
|
4967
5598
|
throw new ValidationError(
|
|
@@ -5036,7 +5667,7 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
5036
5667
|
limit,
|
|
5037
5668
|
offset
|
|
5038
5669
|
});
|
|
5039
|
-
const email = await
|
|
5670
|
+
const email = await getUserEmail4(c);
|
|
5040
5671
|
const canReviewCache = /* @__PURE__ */ new Map();
|
|
5041
5672
|
const requests = [];
|
|
5042
5673
|
for (const r of result.requests) {
|
|
@@ -5060,7 +5691,7 @@ governanceRoutes.get("/external-edits", async (c) => {
|
|
|
5060
5691
|
});
|
|
5061
5692
|
governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
5062
5693
|
const nestId = c.req.param("nestId");
|
|
5063
|
-
const actor = await
|
|
5694
|
+
const actor = await getUserEmail4(c);
|
|
5064
5695
|
const result = await scanNestForDrift(nestId, actor);
|
|
5065
5696
|
return c.json(result);
|
|
5066
5697
|
});
|
|
@@ -5070,7 +5701,7 @@ governanceRoutes.get("/activity", async (c) => {
|
|
|
5070
5701
|
const activity = await getActivity({ nestId, limit });
|
|
5071
5702
|
return c.json({ activity });
|
|
5072
5703
|
});
|
|
5073
|
-
var governanceNodeRoutes = new
|
|
5704
|
+
var governanceNodeRoutes = new Hono9();
|
|
5074
5705
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
5075
5706
|
const nestId = c.req.param("nestId");
|
|
5076
5707
|
const nodeId = c.req.param("nodeId");
|
|
@@ -5129,7 +5760,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
|
|
|
5129
5760
|
const nestId = c.req.param("nestId");
|
|
5130
5761
|
const nodeId = c.req.param("nodeId");
|
|
5131
5762
|
const body = await c.req.json();
|
|
5132
|
-
const author = await
|
|
5763
|
+
const author = await getUserEmail4(c);
|
|
5133
5764
|
try {
|
|
5134
5765
|
const comment = await createComment({
|
|
5135
5766
|
nestId,
|
|
@@ -5153,7 +5784,7 @@ governanceNodeRoutes.post(
|
|
|
5153
5784
|
const nestId = c.req.param("nestId");
|
|
5154
5785
|
const nodeId = c.req.param("nodeId");
|
|
5155
5786
|
const commentId = c.req.param("commentId");
|
|
5156
|
-
const resolvedBy = await
|
|
5787
|
+
const resolvedBy = await getUserEmail4(c);
|
|
5157
5788
|
try {
|
|
5158
5789
|
const comment = await resolveComment({
|
|
5159
5790
|
nestId,
|
|
@@ -5186,7 +5817,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
5186
5817
|
if (currentVersion === 0) {
|
|
5187
5818
|
throw new ValidationError("Node has no versions to review");
|
|
5188
5819
|
}
|
|
5189
|
-
const userEmail = await
|
|
5820
|
+
const userEmail = await getUserEmail4(c);
|
|
5190
5821
|
let request;
|
|
5191
5822
|
try {
|
|
5192
5823
|
request = await submitForReview({
|
|
@@ -5221,7 +5852,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
5221
5852
|
const nestId = c.req.param("nestId");
|
|
5222
5853
|
const nodeId = c.req.param("nodeId");
|
|
5223
5854
|
const body = await c.req.json();
|
|
5224
|
-
const userEmail = await
|
|
5855
|
+
const userEmail = await getUserEmail4(c);
|
|
5225
5856
|
const isAdmin = isSuperAdmin(userEmail);
|
|
5226
5857
|
try {
|
|
5227
5858
|
const request = await approve({
|
|
@@ -5244,7 +5875,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
5244
5875
|
if (!body.note) {
|
|
5245
5876
|
throw new ValidationError("Rejection note is required");
|
|
5246
5877
|
}
|
|
5247
|
-
const userEmail = await
|
|
5878
|
+
const userEmail = await getUserEmail4(c);
|
|
5248
5879
|
try {
|
|
5249
5880
|
const request = await reject({
|
|
5250
5881
|
nestId,
|
|
@@ -5261,19 +5892,19 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
5261
5892
|
governanceNodeRoutes.get("/:nodeId{.+}/can-access", async (c) => {
|
|
5262
5893
|
const nestId = c.req.param("nestId");
|
|
5263
5894
|
const nodeId = c.req.param("nodeId");
|
|
5264
|
-
const userEmail = await
|
|
5895
|
+
const userEmail = await getUserEmail4(c);
|
|
5265
5896
|
return c.json(await canUserAccess(nestId, nodeId, userEmail));
|
|
5266
5897
|
});
|
|
5267
5898
|
governanceNodeRoutes.get("/:nodeId{.+}/can-approve", async (c) => {
|
|
5268
5899
|
const nestId = c.req.param("nestId");
|
|
5269
5900
|
const nodeId = c.req.param("nodeId");
|
|
5270
|
-
const userEmail = await
|
|
5901
|
+
const userEmail = await getUserEmail4(c);
|
|
5271
5902
|
return c.json(await canUserApprove(nestId, nodeId, userEmail));
|
|
5272
5903
|
});
|
|
5273
5904
|
governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
|
|
5274
5905
|
const nestId = c.req.param("nestId");
|
|
5275
5906
|
const nodeId = c.req.param("nodeId");
|
|
5276
|
-
const userEmail = await
|
|
5907
|
+
const userEmail = await getUserEmail4(c);
|
|
5277
5908
|
return c.json(await canUserEdit(nestId, nodeId, userEmail));
|
|
5278
5909
|
});
|
|
5279
5910
|
governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
|
|
@@ -5306,7 +5937,7 @@ governanceNodeRoutes.post(
|
|
|
5306
5937
|
const nodeId = c.req.param("nodeId");
|
|
5307
5938
|
const suggestionId = c.req.param("suggestionId");
|
|
5308
5939
|
const body = await c.req.json().catch(() => ({}));
|
|
5309
|
-
const actor = await
|
|
5940
|
+
const actor = await getUserEmail4(c);
|
|
5310
5941
|
try {
|
|
5311
5942
|
const result = await approveExternalEdit({
|
|
5312
5943
|
nestId,
|
|
@@ -5342,7 +5973,7 @@ governanceNodeRoutes.post(
|
|
|
5342
5973
|
if (!body.reason) {
|
|
5343
5974
|
throw new ValidationError("Rejection reason is required");
|
|
5344
5975
|
}
|
|
5345
|
-
const actor = await
|
|
5976
|
+
const actor = await getUserEmail4(c);
|
|
5346
5977
|
try {
|
|
5347
5978
|
const result = await rejectExternalEdit({
|
|
5348
5979
|
nestId,
|
|
@@ -5363,7 +5994,7 @@ governanceNodeRoutes.post(
|
|
|
5363
5994
|
governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
5364
5995
|
const nestId = c.req.param("nestId");
|
|
5365
5996
|
const nodeId = c.req.param("nodeId");
|
|
5366
|
-
const userEmail = await
|
|
5997
|
+
const userEmail = await getUserEmail4(c);
|
|
5367
5998
|
const request = await cancelReview({
|
|
5368
5999
|
nestId,
|
|
5369
6000
|
nodeId,
|
|
@@ -5371,7 +6002,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
|
5371
6002
|
});
|
|
5372
6003
|
return c.json({ review: request });
|
|
5373
6004
|
});
|
|
5374
|
-
async function
|
|
6005
|
+
async function getUserEmail4(c) {
|
|
5375
6006
|
const userId = c.get("userId");
|
|
5376
6007
|
const db = getDb();
|
|
5377
6008
|
const user = await db.get(
|
|
@@ -5381,6 +6012,30 @@ async function getUserEmail3(c) {
|
|
|
5381
6012
|
return user?.email || "anonymous@localhost";
|
|
5382
6013
|
}
|
|
5383
6014
|
|
|
6015
|
+
// src/shared/node-id.ts
|
|
6016
|
+
function assertSafeNodeId(rawId) {
|
|
6017
|
+
let id = rawId;
|
|
6018
|
+
try {
|
|
6019
|
+
id = decodeURIComponent(rawId);
|
|
6020
|
+
} catch {
|
|
6021
|
+
}
|
|
6022
|
+
if (!id || id.length > 512) {
|
|
6023
|
+
throw new ValidationError("invalid node id");
|
|
6024
|
+
}
|
|
6025
|
+
if (id.includes("\0") || id.includes("\\")) {
|
|
6026
|
+
throw new ValidationError("invalid node id");
|
|
6027
|
+
}
|
|
6028
|
+
if (id.startsWith("/")) {
|
|
6029
|
+
throw new ValidationError("invalid node id (absolute path)");
|
|
6030
|
+
}
|
|
6031
|
+
for (const seg of id.split("/")) {
|
|
6032
|
+
if (seg === "" || seg === "." || seg === "..") {
|
|
6033
|
+
throw new ValidationError("invalid node id (path traversal)");
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
return id;
|
|
6037
|
+
}
|
|
6038
|
+
|
|
5384
6039
|
// src/auth/anonymous.ts
|
|
5385
6040
|
import bcrypt from "bcryptjs";
|
|
5386
6041
|
async function ensureAnonymousUser() {
|
|
@@ -5444,7 +6099,7 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
5444
6099
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
5445
6100
|
});
|
|
5446
6101
|
function createApp() {
|
|
5447
|
-
const app = new
|
|
6102
|
+
const app = new Hono10({ router: new LinearRouter() });
|
|
5448
6103
|
const corsOrigins = config.CORS_ORIGINS;
|
|
5449
6104
|
app.use(
|
|
5450
6105
|
"*",
|
|
@@ -5465,6 +6120,22 @@ function createApp() {
|
|
|
5465
6120
|
}
|
|
5466
6121
|
return next();
|
|
5467
6122
|
});
|
|
6123
|
+
app.use("*", async (c, next) => {
|
|
6124
|
+
const path = c.req.path;
|
|
6125
|
+
const traced = /^\/(nests|auth|admin|stats|license|index|mcp)(\/|$)/.test(path) && !path.endsWith("/mcp");
|
|
6126
|
+
const started = Date.now();
|
|
6127
|
+
await next();
|
|
6128
|
+
if (!traced) return;
|
|
6129
|
+
void logTraceEvent({
|
|
6130
|
+
kind: "api",
|
|
6131
|
+
method: c.req.method,
|
|
6132
|
+
path,
|
|
6133
|
+
nestId: /^\/nests\/([^/]+)/.exec(path)?.[1] ?? null,
|
|
6134
|
+
userId: c.get("userId") ?? null,
|
|
6135
|
+
status: c.res.status,
|
|
6136
|
+
durationMs: Date.now() - started
|
|
6137
|
+
});
|
|
6138
|
+
});
|
|
5468
6139
|
app.get(
|
|
5469
6140
|
"/health",
|
|
5470
6141
|
(c) => c.json({
|
|
@@ -5564,7 +6235,8 @@ function createApp() {
|
|
|
5564
6235
|
logo_url: config.LOGO_URL,
|
|
5565
6236
|
telemetry_enabled: config.TELEMETRY_ENABLED,
|
|
5566
6237
|
public_base_url: config.PUBLIC_BASE_URL,
|
|
5567
|
-
max_body_bytes: config.MAX_BODY_BYTES
|
|
6238
|
+
max_body_bytes: config.MAX_BODY_BYTES,
|
|
6239
|
+
slack_webhook_url: config.SLACK_WEBHOOK_URL ?? ""
|
|
5568
6240
|
});
|
|
5569
6241
|
app.get("/admin/settings", async (c) => {
|
|
5570
6242
|
if (!await adminSettingsAllowed(c))
|
|
@@ -5599,6 +6271,12 @@ function createApp() {
|
|
|
5599
6271
|
if ("telemetry_enabled" in body) {
|
|
5600
6272
|
pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
|
|
5601
6273
|
}
|
|
6274
|
+
if ("slack_webhook_url" in body) {
|
|
6275
|
+
const v = String(body.slack_webhook_url ?? "").trim();
|
|
6276
|
+
if (v && !/^https:\/\//i.test(v))
|
|
6277
|
+
errors.push("slack_webhook_url must be an https:// URL (or empty to disable)");
|
|
6278
|
+
else pending.push({ name: "SLACK_WEBHOOK_URL", value: v || null });
|
|
6279
|
+
}
|
|
5602
6280
|
if ("max_body_bytes" in body) {
|
|
5603
6281
|
const n = Number(body.max_body_bytes);
|
|
5604
6282
|
if (!Number.isFinite(n) || n < 1024 * 1024 || n > 500 * 1024 * 1024)
|
|
@@ -5624,6 +6302,29 @@ function createApp() {
|
|
|
5624
6302
|
}
|
|
5625
6303
|
return c.json({ settings: currentServerSettings() });
|
|
5626
6304
|
});
|
|
6305
|
+
app.use("/admin/trace", flexAuthMiddleware);
|
|
6306
|
+
const traceAllowed = async (c) => {
|
|
6307
|
+
if (config.AUTH_MODE === "open") return true;
|
|
6308
|
+
const userId = c.get("userId");
|
|
6309
|
+
if (await isLicenseAdminUserId(userId)) return true;
|
|
6310
|
+
return isSuperAdmin(await resolveCallerEmail(userId));
|
|
6311
|
+
};
|
|
6312
|
+
app.get("/admin/trace", async (c) => {
|
|
6313
|
+
if (!await traceAllowed(c))
|
|
6314
|
+
return c.json({ error: "Only the server admin can view this." }, 403);
|
|
6315
|
+
const limitRaw = Number(c.req.query("limit"));
|
|
6316
|
+
const offsetRaw = Number(c.req.query("offset"));
|
|
6317
|
+
const limit = Number.isFinite(limitRaw) ? limitRaw : 25;
|
|
6318
|
+
const offset = Number.isFinite(offsetRaw) ? offsetRaw : 0;
|
|
6319
|
+
const { events, total } = await listTraceEvents({
|
|
6320
|
+
limit,
|
|
6321
|
+
offset,
|
|
6322
|
+
kind: c.req.query("kind"),
|
|
6323
|
+
nestId: c.req.query("nest") || void 0,
|
|
6324
|
+
user: c.req.query("user") || void 0
|
|
6325
|
+
});
|
|
6326
|
+
return c.json({ count: events.length, total, limit, offset, events });
|
|
6327
|
+
});
|
|
5627
6328
|
app.use("/stats", flexAuthMiddleware);
|
|
5628
6329
|
app.get("/stats", async (c) => {
|
|
5629
6330
|
const db = getDb();
|
|
@@ -5650,7 +6351,12 @@ function createApp() {
|
|
|
5650
6351
|
users: usersRow.c
|
|
5651
6352
|
});
|
|
5652
6353
|
});
|
|
5653
|
-
|
|
6354
|
+
app.route("/llms.txt", llmsTxtRoutes);
|
|
6355
|
+
app.use("/index", flexAuthMiddleware);
|
|
6356
|
+
app.route("/index", nestIndexRoutes);
|
|
6357
|
+
app.use("/mcp", flexAuthMiddleware);
|
|
6358
|
+
app.route("/mcp", serverMcpRoutes);
|
|
6359
|
+
const nestsApp = new Hono10();
|
|
5654
6360
|
nestsApp.use("*", flexAuthMiddleware);
|
|
5655
6361
|
nestsApp.use("*", async (c, next) => {
|
|
5656
6362
|
const localPath = c.req.path.replace(/^\/nests\//, "");
|
|
@@ -5759,10 +6465,14 @@ function createApp() {
|
|
|
5759
6465
|
c.set("nestPermission", permission);
|
|
5760
6466
|
return next();
|
|
5761
6467
|
});
|
|
6468
|
+
nestsApp.use("/:nestId/nodes/:nodeId{.+}", async (c, next) => {
|
|
6469
|
+
assertSafeNodeId(c.req.param("nodeId"));
|
|
6470
|
+
return next();
|
|
6471
|
+
});
|
|
5762
6472
|
nestsApp.route("/", nestRoutes);
|
|
5763
6473
|
nestsApp.route("/:nestId", governanceRoutes);
|
|
5764
|
-
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
5765
6474
|
nestsApp.route("/:nestId/nodes", annotationRoutes);
|
|
6475
|
+
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
5766
6476
|
nestsApp.route("/:nestId/nodes", nodeRoutes);
|
|
5767
6477
|
nestsApp.route("/:nestId", queryRoutes);
|
|
5768
6478
|
nestsApp.route("/:nestId", sharingRoutes);
|