agentic-comments 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/worker.js ADDED
@@ -0,0 +1,287 @@
1
+ import {
2
+ CommentsService,
3
+ TEXT_ROOT
4
+ } from "./chunk-IB6ANO32.js";
5
+ import {
6
+ ACCEPT_SCOPE,
7
+ CommentsError,
8
+ PRINCIPAL_KINDS,
9
+ exact,
10
+ invalid,
11
+ stamp
12
+ } from "./chunk-GSAJOCDV.js";
13
+
14
+ // src/worker.ts
15
+ import { Hono } from "hono";
16
+ import { StoreConflictError, assertRegion, isScope, parseRegion, hasScope } from "agentic-identity";
17
+ import { D1_SCHEMA, createD1Store } from "agentic-identity/cloudflare";
18
+ import * as Y from "yjs";
19
+ var READ = "comments:read";
20
+ var COMMENT = "comments:comment";
21
+ var WRITE = "comments:write";
22
+ var ADMIN = "comments:admin";
23
+ var DEFAULT_SCOPES = [COMMENT];
24
+ var MIN_TOKEN_BYTES = 32;
25
+ var warned = /* @__PURE__ */ new Set();
26
+ function refuse(reason) {
27
+ if (!warned.has(reason)) {
28
+ warned.add(reason);
29
+ console.error(`AGENTIC_COMMENTS_TOKENS is refused: ${reason}`);
30
+ }
31
+ return void 0;
32
+ }
33
+ function same(a, b) {
34
+ let diff = a.length ^ b.length;
35
+ for (let i = 0; i < Math.max(a.length, b.length); i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
36
+ return diff === 0;
37
+ }
38
+ function contextOf(entry) {
39
+ const object = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? entry : void 0;
40
+ const who = typeof entry === "string" ? entry : object?.as;
41
+ const cut = typeof who === "string" ? who.indexOf(":") : -1;
42
+ if (typeof who !== "string" || cut < 1 || cut === who.length - 1) return void 0;
43
+ const kind = who.slice(0, cut);
44
+ const name = who.slice(cut + 1);
45
+ if (!PRINCIPAL_KINDS.includes(kind)) return void 0;
46
+ const given = object?.scopes;
47
+ if (given !== void 0 && !(Array.isArray(given) && given.every(isScope))) return void 0;
48
+ try {
49
+ stamp({ kind, name });
50
+ } catch {
51
+ return void 0;
52
+ }
53
+ return { principalId: name, kind, chain: [{ id: name, kind }], scopes: given ?? [...DEFAULT_SCOPES] };
54
+ }
55
+ function createTokenAuth(raw) {
56
+ let table;
57
+ try {
58
+ const parsed = JSON.parse(raw ?? "{}");
59
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { authenticate: async () => (refuse("not a JSON object"), null) };
60
+ table = /* @__PURE__ */ new Map();
61
+ for (const [token, entry] of Object.entries(parsed)) {
62
+ const context = contextOf(entry);
63
+ if (new TextEncoder().encode(token).length < MIN_TOKEN_BYTES) throw new Error(`a token is shorter than ${MIN_TOKEN_BYTES} bytes`);
64
+ if (!context) throw new Error("an entry is not a valid kind:name or { as, scopes }");
65
+ table.set(token, context);
66
+ }
67
+ } catch (error) {
68
+ table = void 0;
69
+ refuse(error instanceof SyntaxError ? "not valid JSON" : error.message);
70
+ }
71
+ const known = table;
72
+ return {
73
+ async authenticate(request) {
74
+ const bearer = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1];
75
+ if (bearer === void 0 || !known) return null;
76
+ let found = null;
77
+ for (const [token, context] of known) if (same(token, bearer)) found = context;
78
+ return found;
79
+ }
80
+ };
81
+ }
82
+ function actorOf(context) {
83
+ const scopes = hasScope(context.scopes, ADMIN) ? [ACCEPT_SCOPE] : [];
84
+ return stamp({ kind: context.kind, name: context.principalId, scopes });
85
+ }
86
+ var DOCS = "comments_docs";
87
+ var DOC_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
88
+ var MAX_STATE = 6e5;
89
+ var MAX_BODY = 256 * 1024;
90
+ var toBase64 = (bytes) => {
91
+ let out = "";
92
+ for (let i = 0; i < bytes.length; i += 32768) out += String.fromCharCode(...bytes.subarray(i, i + 32768));
93
+ return btoa(out);
94
+ };
95
+ var fromBase64 = (text) => Uint8Array.from(atob(text), (ch) => ch.charCodeAt(0));
96
+ var CommentsDocs = class {
97
+ constructor(store) {
98
+ this.store = store;
99
+ }
100
+ store;
101
+ id(id) {
102
+ if (!DOC_ID.test(id)) invalid(`invalid document id "${id}": use 1 to 64 of a-z, 0-9, "-" and "_"`);
103
+ return id;
104
+ }
105
+ load(row, id) {
106
+ if (row === null) throw new CommentsError("NO_DOCUMENT", `no document with id "${id}"`);
107
+ const ydoc = new Y.Doc();
108
+ try {
109
+ if (typeof row.state !== "string") throw new Error("no state");
110
+ Y.applyUpdate(ydoc, fromBase64(row.state));
111
+ } catch {
112
+ throw new CommentsError("CORRUPT", `document "${id}" is damaged`);
113
+ }
114
+ return ydoc;
115
+ }
116
+ open(ydoc, actor) {
117
+ const fixed = { read: (fn) => fn(ydoc), write: (fn) => fn(ydoc) };
118
+ return new CommentsService(fixed, actor);
119
+ }
120
+ async create(id, text, actor) {
121
+ this.id(id);
122
+ const ydoc = new Y.Doc();
123
+ ydoc.getText(TEXT_ROOT).insert(0, text);
124
+ const state = Y.encodeStateAsUpdate(ydoc);
125
+ if (state.length > MAX_STATE) throw new CommentsError("STORE_FULL", `the text is too large for one document (${MAX_STATE} bytes of state)`);
126
+ await this.store.transaction(async (tx) => {
127
+ if (await tx.get(DOCS, id) !== null) throw new CommentsError("DOCUMENT_EXISTS", `document "${id}" already exists`);
128
+ await tx.put(DOCS, id, { state: toBase64(state), createdBy: { kind: actor.kind, name: actor.name }, createdAt: Date.now() });
129
+ });
130
+ }
131
+ async read(id, actor, run) {
132
+ return run(this.open(this.load(await this.store.get(DOCS, this.id(id)), id), actor));
133
+ }
134
+ async write(id, actor, run) {
135
+ this.id(id);
136
+ return this.store.transaction(async (tx) => {
137
+ const row = await tx.get(DOCS, id);
138
+ const ydoc = this.load(row, id);
139
+ const result = run(this.open(ydoc, actor));
140
+ const state = Y.encodeStateAsUpdate(ydoc);
141
+ if (state.length > MAX_STATE) throw new CommentsError("STORE_FULL", `document "${id}" would pass ${MAX_STATE} bytes of state`);
142
+ await tx.put(DOCS, id, { state: toBase64(state), createdBy: row?.createdBy ?? null, createdAt: row?.createdAt ?? null });
143
+ return result;
144
+ });
145
+ }
146
+ };
147
+ var STATUS = {
148
+ INVALID_AUTHOR: 400,
149
+ INVALID_INPUT: 400,
150
+ INVALID_ANCHOR: 400,
151
+ NO_THREAD: 404,
152
+ NO_SUGGESTION: 404,
153
+ NO_DOCUMENT: 404,
154
+ THREAD_RESOLVED: 409,
155
+ ALREADY_DECIDED: 409,
156
+ DOCUMENT_EXISTS: 409,
157
+ CONFLICT: 409,
158
+ APPLY_FAILED: 409,
159
+ RELOCATE_FAILED: 409,
160
+ FORBIDDEN: 403,
161
+ STORE_FULL: 413,
162
+ CORRUPT: 500,
163
+ MISCONFIGURED: 500
164
+ };
165
+ var fail = (code, message, status) => Response.json({ error: { code, message } }, { status });
166
+ async function body(c) {
167
+ if (Number(c.req.header("content-length") ?? 0) > MAX_BODY) invalid(`the body is over ${MAX_BODY} bytes`);
168
+ const text = await c.req.text();
169
+ if (text.length > MAX_BODY) invalid(`the body is over ${MAX_BODY} bytes`);
170
+ let parsed;
171
+ try {
172
+ parsed = JSON.parse(text);
173
+ } catch {
174
+ parsed = void 0;
175
+ }
176
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) invalid("the body must be a JSON object");
177
+ return parsed;
178
+ }
179
+ function createApp(source, options = {}) {
180
+ const app2 = new Hono();
181
+ const docsOf = (c) => typeof source === "function" ? source(c.env ?? {}) : Promise.resolve(source);
182
+ app2.use("*", async (c, next) => {
183
+ await next();
184
+ c.header("x-content-type-options", "nosniff");
185
+ });
186
+ app2.get("/health", (c) => c.json({ ok: true, pack: "agentic-comments" }));
187
+ app2.use("/api/v1/*", async (c, next) => {
188
+ const auth = typeof options.auth === "function" ? options.auth(c.env ?? {}) : options.auth ?? createTokenAuth(c.env?.AGENTIC_COMMENTS_TOKENS);
189
+ const context = await auth.authenticate(c.req.raw);
190
+ if (!context) return fail("UNAUTHENTICATED", "send a valid bearer token", 401);
191
+ c.set("context", context);
192
+ await next();
193
+ });
194
+ app2.get("/api/v1/me", (c) => {
195
+ const { principalId, kind, scopes } = c.get("context");
196
+ return c.json({ kind, name: principalId, scopes });
197
+ });
198
+ const act = (c, need, write, run) => (async () => {
199
+ const context = c.get("context");
200
+ if (!hasScope(context.scopes, need)) throw new CommentsError("FORBIDDEN", `this token needs the ${need} scope`);
201
+ const docs = await docsOf(c);
202
+ const actor = actorOf(context);
203
+ const id = c.req.param("doc") ?? "";
204
+ return c.json(await (write ? docs.write(id, actor, run) : docs.read(id, actor, run)));
205
+ })();
206
+ const doc = "/api/v1/docs/:doc";
207
+ app2.post("/api/v1/docs", async (c) => {
208
+ const given = exact(await body(c), ["id", "text"], "a document");
209
+ if (typeof given.id !== "string") invalid("a document needs an id");
210
+ if (given.text !== void 0 && typeof given.text !== "string") invalid("text must be a string");
211
+ const context = c.get("context");
212
+ if (!hasScope(context.scopes, WRITE)) throw new CommentsError("FORBIDDEN", `this token needs the ${WRITE} scope`);
213
+ await (await docsOf(c)).create(given.id, given.text ?? "", actorOf(context));
214
+ return c.json({ id: given.id }, 201);
215
+ });
216
+ app2.get(doc, (c) => act(c, READ, false, (s) => ({ id: c.req.param("doc"), text: s.text() })));
217
+ app2.get(`${doc}/threads`, (c) => act(c, READ, false, (s) => s.threads(c.req.query())));
218
+ app2.get(`${doc}/threads/:id`, (c) => act(c, READ, false, (s) => s.thread(c.req.param("id"))));
219
+ app2.post(`${doc}/threads`, async (c) => {
220
+ const input = await body(c);
221
+ return act(c, COMMENT, true, (s) => s.comment(input));
222
+ });
223
+ app2.post(`${doc}/threads/:id/replies`, async (c) => {
224
+ const input = await body(c);
225
+ return act(c, COMMENT, true, (s) => s.reply(c.req.param("id"), input));
226
+ });
227
+ for (const action of ["resolve", "reopen"]) {
228
+ app2.post(`${doc}/threads/:id/${action}`, (c) => act(c, COMMENT, true, (s) => s[action](c.req.param("id"))));
229
+ }
230
+ app2.post(`${doc}/threads/:id/relocate`, (c) => act(c, WRITE, true, (s) => s.relocate(c.req.param("id"))));
231
+ app2.get(`${doc}/suggestions`, (c) => act(c, READ, false, (s) => s.suggestions(c.req.query())));
232
+ app2.post(`${doc}/suggestions`, async (c) => {
233
+ const input = await body(c);
234
+ return act(c, COMMENT, true, (s) => s.suggest(input));
235
+ });
236
+ for (const action of ["accept", "reject"]) {
237
+ app2.post(`${doc}/suggestions/:id/${action}`, (c) => act(c, WRITE, true, (s) => s[action](c.req.param("id"))));
238
+ }
239
+ app2.notFound(() => fail("NOT_FOUND", "no such route", 404));
240
+ app2.onError((error) => {
241
+ if (error instanceof CommentsError) return fail(error.code, error.message, STATUS[error.code] ?? 400);
242
+ if (error instanceof StoreConflictError) return fail("CONFLICT", "the document kept changing; try again", 409);
243
+ console.error(error instanceof Error ? error.name : "error");
244
+ return fail("INTERNAL", "the server could not finish the request", 500);
245
+ });
246
+ return app2;
247
+ }
248
+ var schemas = /* @__PURE__ */ new WeakMap();
249
+ function regionOf(env) {
250
+ let region;
251
+ try {
252
+ region = parseRegion(env.AGENTIC_COMMENTS_REGION);
253
+ } catch {
254
+ throw new CommentsError("MISCONFIGURED", 'set AGENTIC_COMMENTS_REGION to "eu" or "us" ("unknown" only for local development)');
255
+ }
256
+ if (region === "unknown" && env.AGENTIC_COMMENTS_UNSAFE_REGION !== "1") {
257
+ throw new CommentsError("MISCONFIGURED", 'the region "unknown" turns the residency check off; set AGENTIC_COMMENTS_UNSAFE_REGION=1 to accept that for local development');
258
+ }
259
+ return region;
260
+ }
261
+ async function docsFor(env) {
262
+ if (!env.DB) throw new CommentsError("MISCONFIGURED", "bind a D1 database as DB");
263
+ const region = regionOf(env);
264
+ const db = env.DB;
265
+ let ready = schemas.get(db);
266
+ if (!ready) {
267
+ ready = db.prepare(D1_SCHEMA).run().catch((error) => {
268
+ schemas.delete(db);
269
+ throw error;
270
+ });
271
+ schemas.set(db, ready);
272
+ }
273
+ await ready;
274
+ const store = createD1Store(db, { region });
275
+ assertRegion(region, store);
276
+ return new CommentsDocs(store);
277
+ }
278
+ var app = createApp(docsFor);
279
+ var worker_default = { fetch: (request, env, ctx) => app.fetch(request, env, ctx) };
280
+ export {
281
+ CommentsDocs,
282
+ createApp,
283
+ createTokenAuth,
284
+ worker_default as default,
285
+ docsFor,
286
+ regionOf
287
+ };
package/llms.txt CHANGED
@@ -6,7 +6,8 @@ Keywords: comments,threads,mentions,suggestions,annotations,collaboration,agents
6
6
 
7
7
  - SDK: import { Comments, Suggestions, textAnchor } from "agentic-comments". The host calls stamp(); an agent never does.
8
8
  - Threads live on a Yjs document and merge across replicas.
9
- - The CLI and MCP server are not built yet.
9
+ - Events: new Notifications(comments, suggestions).subscribe(listener). Ids only, never text.
10
+ - CLI: agentic-comments <command>. MCP: agentic-comments mcp. Both use a local store file.
10
11
 
11
12
  ## Docs
12
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-comments",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Comment threads and suggestions anchored to any content, from people and agents.",
5
5
  "keywords": [
6
6
  "comments",
@@ -10,7 +10,8 @@
10
10
  "annotations",
11
11
  "collaboration",
12
12
  "agents",
13
- "yjs"
13
+ "yjs",
14
+ "mcp"
14
15
  ],
15
16
  "license": "MIT",
16
17
  "author": "Pooria Arab",
@@ -25,7 +26,7 @@
25
26
  "type": "module",
26
27
  "private": false,
27
28
  "engines": {
28
- "node": ">=20"
29
+ "node": ">=22"
29
30
  },
30
31
  "files": [
31
32
  "dist",
@@ -35,28 +36,51 @@
35
36
  "publishConfig": {
36
37
  "access": "public"
37
38
  },
39
+ "mcpName": "io.github.pooriaarab/agentic-comments",
38
40
  "main": "./dist/index.js",
39
41
  "types": "./dist/index.d.ts",
40
42
  "exports": {
41
43
  ".": {
42
44
  "types": "./dist/index.d.ts",
43
45
  "import": "./dist/index.js"
46
+ },
47
+ "./local": {
48
+ "types": "./dist/local.d.ts",
49
+ "import": "./dist/local.js"
50
+ },
51
+ "./worker": {
52
+ "types": "./dist/worker.d.ts",
53
+ "import": "./dist/worker.js"
44
54
  }
45
55
  },
56
+ "bin": {
57
+ "agentic-comments": "./dist/cli.js",
58
+ "agentic-comments-mcp": "./dist/mcp-bin.js"
59
+ },
46
60
  "scripts": {
47
61
  "build": "tsup",
48
62
  "typecheck": "tsc --noEmit",
49
63
  "test": "vitest run",
64
+ "deploy:demo": "npm run build && WRANGLER_SEND_METRICS=false wrangler deploy --config wrangler.demo.jsonc",
50
65
  "clean": "rm -rf dist",
66
+ "prepack": "npm run build",
51
67
  "ci:local": "npm run build && npm run typecheck && npm test"
52
68
  },
53
69
  "dependencies": {
70
+ "@modelcontextprotocol/sdk": "^1.32.2",
71
+ "agentic-identity": "^0.4.0",
72
+ "hono": "^4.13.14",
73
+ "zod": "^3.25.76"
74
+ },
75
+ "peerDependencies": {
54
76
  "yjs": "^13.6.0"
55
77
  },
56
78
  "devDependencies": {
57
79
  "@types/node": "^22.0.0",
58
80
  "tsup": "^8.0.0",
59
81
  "typescript": "^5.6.0",
60
- "vitest": "^3.0.0"
82
+ "vitest": "^3.0.0",
83
+ "wrangler": "^4.155.0",
84
+ "yjs": "^13.6.0"
61
85
  }
62
86
  }