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