agentic-comments 0.2.0 → 0.4.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/README.md +92 -2
- package/dist/anchoring.d.ts +74 -0
- package/dist/anchoring.js +122 -0
- package/dist/{chunk-C3P5TAC3.js → chunk-BSNBDKR2.js} +2 -4
- package/dist/{chunk-RWMBGIAS.js → chunk-DRACF66Y.js} +1 -1
- package/dist/{chunk-C43HLFQE.js → chunk-HXXJNFEO.js} +1 -1
- package/dist/{chunk-ZCM2UDLN.js → chunk-NQWRFKJ6.js} +1 -1
- package/dist/{chunk-UE5ULEXU.js → chunk-R4LH36VG.js} +3 -3
- package/dist/cli.js +5 -5
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -2
- package/dist/local.d.ts +4 -59
- package/dist/local.js +3 -3
- package/dist/mcp-ACC4QIEM.js +11 -0
- package/dist/mcp-bin.js +30 -11
- package/dist/service-Dxw9TVxY.d.ts +66 -0
- package/dist/{suggestions-U8tXRLZ5.d.ts → suggestions-oj8fd-nG.d.ts} +2 -3
- package/dist/worker.d.ts +58 -0
- package/dist/worker.js +287 -0
- package/llms.txt +1 -0
- package/package.json +14 -2
- package/dist/mcp-BOJM5NE6.js +0 -11
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Comment threads and suggestions anchored to any content, from people and agents.
|
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
|
-
Requires Node.js
|
|
14
|
+
Requires Node.js 22 or later. `yjs` is a peer dependency. Your app and this package must use the same
|
|
15
15
|
copy of `yjs`, because the package checks the Yjs types of your document. Install both:
|
|
16
16
|
|
|
17
17
|
```sh
|
|
@@ -55,6 +55,33 @@ normal sync, and every replica sees the same threads.
|
|
|
55
55
|
- A malformed thread from a bad replica is skipped. A bad message is left out, and `skipped` on the
|
|
56
56
|
thread counts those. `problems()` names them.
|
|
57
57
|
|
|
58
|
+
## Anchors for plain text
|
|
59
|
+
|
|
60
|
+
`agentic-comments/anchor` pins a range of a plain string. It needs no Yjs and no framework.
|
|
61
|
+
Offsets are UTF-16 code units, like `slice`.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { createAnchor, resolveAnchor } from "agentic-comments/anchor";
|
|
65
|
+
|
|
66
|
+
const anchor = createAnchor("Hello brave world", 6, 11);
|
|
67
|
+
const edits = [{ from: 0, to: 0, insert: "Oh, " }];
|
|
68
|
+
resolveAnchor(anchor, "Oh, Hello brave world", edits, 17);
|
|
69
|
+
// { state: "attached", from: 10, to: 15, method: "mapped" }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- An edit is `{ from, to, insert }`. Edits apply one after another, each in the text left by the one before.
|
|
73
|
+
- `resolveAnchor(anchor, newText, edits?, originalLength?)` maps the range through the edits. Pass
|
|
74
|
+
`originalLength`, the length of the old text, so it can check that the edits fit both texts.
|
|
75
|
+
- With edits, the edits are the truth, and the quote is not searched. A range that holds other text is `edited`.
|
|
76
|
+
- With no edits, it searches for the quote. One match wins. With many, the best fit of the text around the
|
|
77
|
+
quote wins. On a tie, the match at the old offsets wins, or else the anchor is `orphaned`, because a wrong
|
|
78
|
+
place is worse than none. More than 500 matches orphan too.
|
|
79
|
+
- `edited` means the range exists but holds other text. `orphaned` means the content is gone, the quote is ambiguous, the edits do not fit the text, or the anchor is bad data.
|
|
80
|
+
- An insert at an edge of the range stays outside it. Deleting the whole range orphans it.
|
|
81
|
+
- `createAnchor` throws a `RangeError` for an empty range, a range outside the text, or a range that cuts a
|
|
82
|
+
surrogate pair in two. It does not check grapheme boundaries, so a flag or a joined emoji can still be cut.
|
|
83
|
+
- `mapPosition` and `mapRange` move offsets through edits on their own.
|
|
84
|
+
|
|
58
85
|
## When the content changes
|
|
59
86
|
|
|
60
87
|
Every thread and suggestion has an `anchorState`:
|
|
@@ -170,6 +197,69 @@ npx agentic-comments accept <id>
|
|
|
170
197
|
`{"value": <JSON>}`.
|
|
171
198
|
- From code: `import { FileStore, CommentsService, operatorActor } from "agentic-comments/local"`.
|
|
172
199
|
|
|
200
|
+
## HTTP API
|
|
201
|
+
|
|
202
|
+
`agentic-comments/worker` is a Cloudflare Worker (Hono) with REST routes under `/api/v1`. It keeps each
|
|
203
|
+
document in a D1 database through the `Store` port of `agentic-identity`. It checks every request with the
|
|
204
|
+
`Auth` port, so the host picks who the caller is and no request carries an author.
|
|
205
|
+
|
|
206
|
+
Set the principals as a secret. It is JSON from a bearer token (32 bytes or more) to `kind:name`, or to
|
|
207
|
+
`{ "as": "kind:name", "scopes": [...] }`. One bad entry refuses the whole table. A request with no valid
|
|
208
|
+
token gets `401`.
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
wrangler secret put AGENTIC_COMMENTS_TOKENS
|
|
212
|
+
# {"<openssl rand -hex 32>": {"as": "human:ada", "scopes": ["comments:write"]}, "<another>": "agent_of_human:scribe"}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Also set `AGENTIC_COMMENTS_REGION` to `eu` or `us`. Without it every document route fails with `500 MISCONFIGURED`. Use `unknown` with
|
|
216
|
+
`AGENTIC_COMMENTS_UNSAFE_REGION=1` only under `wrangler dev`. The `wrangler.jsonc` in the repo shows the D1 binding.
|
|
217
|
+
|
|
218
|
+
| Scope | Allows |
|
|
219
|
+
| --- | --- |
|
|
220
|
+
| `comments:read` | Read documents, threads, and suggestions. |
|
|
221
|
+
| `comments:comment` | Also add threads, reply, resolve, reopen, and suggest. This is the default for a token with no scopes. |
|
|
222
|
+
| `comments:write` | Also create documents, relocate, and accept or reject. |
|
|
223
|
+
| `comments:admin` | Also let an agent accept or reject (the `comments:accept` right). |
|
|
224
|
+
|
|
225
|
+
| Route | Action |
|
|
226
|
+
| --- | --- |
|
|
227
|
+
| `GET /api/v1/me` | Who the token stands for. |
|
|
228
|
+
| `POST /api/v1/docs` `{id, text?}` | Create a document. |
|
|
229
|
+
| `GET /api/v1/docs/:doc` | Read the content text. |
|
|
230
|
+
| `GET`, `POST /api/v1/docs/:doc/threads` | List (`?status=`, `?anchorState=`, `?mentioning=`) or add. |
|
|
231
|
+
| `GET /api/v1/docs/:doc/threads/:id` | Read one thread. |
|
|
232
|
+
| `POST /api/v1/docs/:doc/threads/:id/replies` | Reply. |
|
|
233
|
+
| `POST .../threads/:id/resolve`, `reopen`, `relocate` | Change a thread. |
|
|
234
|
+
| `GET`, `POST /api/v1/docs/:doc/suggestions` | List or suggest. |
|
|
235
|
+
| `POST .../suggestions/:id/accept`, `reject` | Decide. An agent never decides its own suggestion. |
|
|
236
|
+
|
|
237
|
+
Bodies are the same as the MCP tool inputs. An error is `{"error": {"code", "message"}}`. A body with an
|
|
238
|
+
`author` key is refused with `400`.
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
curl -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
|
|
242
|
+
-d '{"anchor":{"type":"text","quote":"brave"},"body":"Word choice?"}' \
|
|
243
|
+
http://127.0.0.1:8787/api/v1/docs/d1/threads
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Live demo
|
|
247
|
+
|
|
248
|
+
A demo Worker runs at <https://agentic-comments-demo.hello-fd1.workers.dev>. It is a staging site. Check it with
|
|
249
|
+
`GET /health`. Every `/api/v1` route needs a bearer token, and the demo does not publish one, so a call
|
|
250
|
+
with no token gets `401`. The data lives in a D1 database in the EU (`weur`), and the Worker sets the
|
|
251
|
+
region to `eu`.
|
|
252
|
+
|
|
253
|
+
To deploy your own copy, log in with `wrangler login` or set `CLOUDFLARE_API_TOKEN`, then:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
npx wrangler d1 create agentic-comments-demo --location weur # once. Put the id in wrangler.demo.jsonc.
|
|
257
|
+
npx wrangler secret put AGENTIC_COMMENTS_TOKENS --config wrangler.demo.jsonc # see HTTP API
|
|
258
|
+
npm run deploy:demo
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`wrangler.demo.jsonc` sets no secret. Deploys run by hand, and no CI job holds a Cloudflare token.
|
|
262
|
+
|
|
173
263
|
## Notifications
|
|
174
264
|
|
|
175
265
|
`Notifications` turns changes into events that your app can send to a notifier. It watches the data, so it
|
|
@@ -225,7 +315,7 @@ const stop = notifications.subscribe(async (event) => {
|
|
|
225
315
|
|
|
226
316
|
## Limits
|
|
227
317
|
|
|
228
|
-
-
|
|
318
|
+
- The file store is for one machine. It is not a sync server. The Worker keeps each document as one D1 row and refuses a document past 600,000 bytes of state.
|
|
229
319
|
- `add` checks that a text anchor points into this document and matches its quote.
|
|
230
320
|
- The library trusts the host to call `stamp()` only for a principal it authenticated.
|
|
231
321
|
- A reply to a resolved thread is refused on the replica that sees the resolve. A reply and a
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-text anchors that survive edits. No Yjs, no framework. Offsets are UTF-16 code units, like
|
|
3
|
+
* `String.prototype.slice`. An anchor keeps its quote and some text around it, so it can be found again
|
|
4
|
+
* when you have no edit list.
|
|
5
|
+
*/
|
|
6
|
+
/** One edit: replace `text.slice(from, to)` with `insert`. A pure insert has `from === to`. */
|
|
7
|
+
interface Edit {
|
|
8
|
+
from: number;
|
|
9
|
+
to: number;
|
|
10
|
+
insert: string;
|
|
11
|
+
}
|
|
12
|
+
interface TextRangeAnchor {
|
|
13
|
+
/** The text under the range when the anchor was made. */
|
|
14
|
+
quote: string;
|
|
15
|
+
/** Text just before the range. */
|
|
16
|
+
prefix: string;
|
|
17
|
+
/** Text just after the range. */
|
|
18
|
+
suffix: string;
|
|
19
|
+
/** The range at creation, in the text it was made for. */
|
|
20
|
+
from: number;
|
|
21
|
+
to: number;
|
|
22
|
+
}
|
|
23
|
+
interface OffsetRange {
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `attached`: the range holds the quote. `edited`: the range still exists but holds other text.
|
|
29
|
+
* `orphaned`: the content is gone, or the quote is too ambiguous to trust.
|
|
30
|
+
*/
|
|
31
|
+
type Resolution = {
|
|
32
|
+
state: "attached";
|
|
33
|
+
from: number;
|
|
34
|
+
to: number;
|
|
35
|
+
method: "mapped" | "quote";
|
|
36
|
+
} | {
|
|
37
|
+
state: "edited";
|
|
38
|
+
from: number;
|
|
39
|
+
to: number;
|
|
40
|
+
} | {
|
|
41
|
+
state: "orphaned";
|
|
42
|
+
};
|
|
43
|
+
declare const CONTEXT_LENGTH = 32;
|
|
44
|
+
declare const MAX_QUOTE_LENGTH = 2000;
|
|
45
|
+
/** The most copies of a quote that `resolveAnchor` ranks. With more, the anchor is orphaned. */
|
|
46
|
+
declare const MAX_MATCHES = 500;
|
|
47
|
+
/** Pin the range `from..to` of `text`. Throws a `RangeError` when the range is empty, outside the text, or splits a character. */
|
|
48
|
+
declare function createAnchor(text: string, from: number, to: number): TextRangeAnchor;
|
|
49
|
+
/**
|
|
50
|
+
* Move one position through edits made one after another. `assoc` 1 puts a position that sits at an
|
|
51
|
+
* insert after the insert. `assoc` -1 puts it before. A position inside a replaced range moves to the
|
|
52
|
+
* end of the insert with `assoc` 1, and to the start of the insert with `assoc` -1. `length` is the text length before the first edit.
|
|
53
|
+
*/
|
|
54
|
+
declare function mapPosition(pos: number, edits: readonly Edit[], assoc?: 1 | -1, length?: number): number;
|
|
55
|
+
/**
|
|
56
|
+
* Move a range through edits. An insert at either edge stays outside the range. Returns null when the
|
|
57
|
+
* edits delete the whole range.
|
|
58
|
+
*/
|
|
59
|
+
declare function mapRange(range: OffsetRange, edits: readonly Edit[], length?: number): OffsetRange | null;
|
|
60
|
+
/**
|
|
61
|
+
* Find an anchor in `newText`.
|
|
62
|
+
*
|
|
63
|
+
* With `edits` (the changes from the text the anchor was made for to `newText`), the edits are the truth:
|
|
64
|
+
* the mapped range is `attached` if it holds the quote, `edited` if it holds other text, and `orphaned`
|
|
65
|
+
* if the edits delete it or it falls outside the text. The quote is not searched. Pass `originalLength`
|
|
66
|
+
* (the length of the old text) to also check that the edits fit both texts.
|
|
67
|
+
*
|
|
68
|
+
* With no edits, search for the quote. One match wins. With several, the match whose surrounding text
|
|
69
|
+
* fits best wins. On a tie, the match at the old offsets wins, else the anchor is `orphaned`: a wrong
|
|
70
|
+
* place is worse than none. A bad anchor, a half character, and too many matches also orphan.
|
|
71
|
+
*/
|
|
72
|
+
declare function resolveAnchor(anchor: TextRangeAnchor, newText: string, edits?: readonly Edit[], originalLength?: number): Resolution;
|
|
73
|
+
|
|
74
|
+
export { CONTEXT_LENGTH, type Edit, MAX_MATCHES, MAX_QUOTE_LENGTH, type OffsetRange, type Resolution, type TextRangeAnchor, createAnchor, mapPosition, mapRange, resolveAnchor };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// src/anchoring.ts
|
|
2
|
+
var CONTEXT_LENGTH = 32;
|
|
3
|
+
var MAX_QUOTE_LENGTH = 2e3;
|
|
4
|
+
var MAX_MATCHES = 500;
|
|
5
|
+
var isHigh = (c) => c >= 55296 && c <= 56319;
|
|
6
|
+
var isLow = (c) => c >= 56320 && c <= 57343;
|
|
7
|
+
var isLoneHalf = (code, atStart) => atStart ? isLow(code) : isHigh(code);
|
|
8
|
+
function splits(text, index) {
|
|
9
|
+
return index > 0 && index < text.length && isHigh(text.charCodeAt(index - 1)) && isLow(text.charCodeAt(index));
|
|
10
|
+
}
|
|
11
|
+
var whole = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
12
|
+
function createAnchor(text, from, to) {
|
|
13
|
+
if (typeof text !== "string") throw new TypeError("text must be a string");
|
|
14
|
+
if (!whole(from) || !whole(to) || to <= from || to > text.length) {
|
|
15
|
+
throw new RangeError(`the range ${String(from)}..${String(to)} is not inside the text, which has ${text.length} characters`);
|
|
16
|
+
}
|
|
17
|
+
if (splits(text, from) || splits(text, to)) throw new RangeError("the range cuts a character in two");
|
|
18
|
+
if (isLow(text.charCodeAt(from)) || isHigh(text.charCodeAt(to - 1))) throw new RangeError("the range starts or ends inside a character");
|
|
19
|
+
if (to - from > MAX_QUOTE_LENGTH) throw new RangeError(`a quote holds up to ${MAX_QUOTE_LENGTH} characters`);
|
|
20
|
+
let before = Math.max(0, from - CONTEXT_LENGTH);
|
|
21
|
+
if (splits(text, before)) before += 1;
|
|
22
|
+
let after = Math.min(text.length, to + CONTEXT_LENGTH);
|
|
23
|
+
if (splits(text, after)) after -= 1;
|
|
24
|
+
return { quote: text.slice(from, to), prefix: text.slice(before, from), suffix: text.slice(to, after), from, to };
|
|
25
|
+
}
|
|
26
|
+
function checkEdit(edit, length) {
|
|
27
|
+
if (typeof edit !== "object" || edit === null) throw new RangeError("an edit must be an object");
|
|
28
|
+
if (!whole(edit.from) || !whole(edit.to) || edit.to < edit.from || edit.to > length || typeof edit.insert !== "string") {
|
|
29
|
+
throw new RangeError(`the edit ${JSON.stringify([edit.from, edit.to])} is not inside a text of ${length} characters`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function mapPosition(pos, edits, assoc = 1, length = Infinity) {
|
|
33
|
+
if (!whole(pos) || !Array.isArray(edits) || assoc !== 1 && assoc !== -1) throw new RangeError("the position must be a whole number, the edits a list, and assoc 1 or -1");
|
|
34
|
+
if (length !== Infinity && (!whole(length) || pos > length)) throw new RangeError("the position must be inside a text of a whole length");
|
|
35
|
+
let len = length;
|
|
36
|
+
let p = pos;
|
|
37
|
+
for (const edit of edits) {
|
|
38
|
+
checkEdit(edit, len);
|
|
39
|
+
const removed = edit.to - edit.from;
|
|
40
|
+
const added = edit.insert.length;
|
|
41
|
+
if (p > edit.to) p += added - removed;
|
|
42
|
+
else if (p >= edit.from) {
|
|
43
|
+
if (removed === 0) p = assoc === 1 ? p + added : p;
|
|
44
|
+
else if (p === edit.from) p = edit.from;
|
|
45
|
+
else if (p === edit.to) p = edit.from + added;
|
|
46
|
+
else p = assoc === 1 ? edit.from + added : edit.from;
|
|
47
|
+
}
|
|
48
|
+
len = len === Infinity ? len : len - removed + added;
|
|
49
|
+
}
|
|
50
|
+
return p;
|
|
51
|
+
}
|
|
52
|
+
function mapRange(range, edits, length = Infinity) {
|
|
53
|
+
if (!Array.isArray(edits)) throw new RangeError("the edits must be a list");
|
|
54
|
+
if (!whole(range?.from) || !whole(range?.to) || range.to <= range.from) throw new RangeError("the range must be two whole numbers, from before to");
|
|
55
|
+
let { from, to } = range;
|
|
56
|
+
let len = length;
|
|
57
|
+
for (const edit of edits) {
|
|
58
|
+
from = mapPosition(from, [edit], 1, len);
|
|
59
|
+
to = mapPosition(to, [edit], -1, len);
|
|
60
|
+
if (to <= from) return null;
|
|
61
|
+
len = len === Infinity ? len : len - (edit.to - edit.from) + edit.insert.length;
|
|
62
|
+
}
|
|
63
|
+
return { from, to };
|
|
64
|
+
}
|
|
65
|
+
function occurrences(text, quote, max) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (let i = text.indexOf(quote); i >= 0 && out.length < max; i = text.indexOf(quote, i + 1)) out.push(i);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
var commonSuffix = (a, b) => {
|
|
71
|
+
let n = 0;
|
|
72
|
+
while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) n++;
|
|
73
|
+
return n;
|
|
74
|
+
};
|
|
75
|
+
var commonPrefix = (a, b) => {
|
|
76
|
+
let n = 0;
|
|
77
|
+
while (n < a.length && n < b.length && a[n] === b[n]) n++;
|
|
78
|
+
return n;
|
|
79
|
+
};
|
|
80
|
+
function resolveAnchor(anchor, newText, edits, originalLength) {
|
|
81
|
+
if (typeof newText !== "string") throw new TypeError("text must be a string");
|
|
82
|
+
if (typeof anchor?.quote !== "string" || anchor.quote === "") return { state: "orphaned" };
|
|
83
|
+
const quote = anchor.quote;
|
|
84
|
+
if (quote.length > MAX_QUOTE_LENGTH || edits !== void 0 && !Array.isArray(edits)) return { state: "orphaned" };
|
|
85
|
+
if (!whole(anchor.from) || !whole(anchor.to) || anchor.to - anchor.from !== quote.length) return { state: "orphaned" };
|
|
86
|
+
if (isLoneHalf(quote.charCodeAt(0), true) || isLoneHalf(quote.charCodeAt(quote.length - 1), false)) return { state: "orphaned" };
|
|
87
|
+
if (originalLength !== void 0) {
|
|
88
|
+
const final = (edits ?? []).reduce((n, e) => n - (e?.to - e?.from) + (typeof e?.insert === "string" ? e.insert.length : NaN), originalLength);
|
|
89
|
+
if (!whole(originalLength) || anchor.to > originalLength || edits && final !== newText.length) return { state: "orphaned" };
|
|
90
|
+
}
|
|
91
|
+
let mapped;
|
|
92
|
+
try {
|
|
93
|
+
mapped = edits ? mapRange({ from: anchor.from, to: anchor.to }, edits, originalLength) : { from: anchor.from, to: anchor.to };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof RangeError) return { state: "orphaned" };
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
if (edits && mapped === null) return { state: "orphaned" };
|
|
99
|
+
const valid = mapped && mapped.to <= newText.length && !splits(newText, mapped.from) && !splits(newText, mapped.to);
|
|
100
|
+
if (edits) {
|
|
101
|
+
if (!valid || !mapped) return { state: "orphaned" };
|
|
102
|
+
return newText.slice(mapped.from, mapped.to) === quote ? { state: "attached", from: mapped.from, to: mapped.to, method: "mapped" } : { state: "edited", from: mapped.from, to: mapped.to };
|
|
103
|
+
}
|
|
104
|
+
const found = occurrences(newText, quote, MAX_MATCHES + 1).filter((at) => !splits(newText, at) && !splits(newText, at + quote.length));
|
|
105
|
+
if (found.length === 0 || found.length > MAX_MATCHES) return { state: "orphaned" };
|
|
106
|
+
const result = (at) => ({ state: "attached", from: at, to: at + quote.length, method: at === anchor.from ? "mapped" : "quote" });
|
|
107
|
+
if (found.length === 1) return result(found[0]);
|
|
108
|
+
const score = (at) => commonSuffix(anchor.prefix ?? "", newText.slice(Math.max(0, at - CONTEXT_LENGTH), at)) + commonPrefix(anchor.suffix ?? "", newText.slice(at + quote.length, at + quote.length + CONTEXT_LENGTH));
|
|
109
|
+
const ranked = found.map((at) => ({ at, score: score(at) })).sort((a, b) => b.score - a.score);
|
|
110
|
+
const best = ranked.filter((r) => r.score === ranked[0].score);
|
|
111
|
+
if (best.length === 1) return result(best[0].at);
|
|
112
|
+
return best.some((r) => r.at === anchor.from) ? result(anchor.from) : { state: "orphaned" };
|
|
113
|
+
}
|
|
114
|
+
export {
|
|
115
|
+
CONTEXT_LENGTH,
|
|
116
|
+
MAX_MATCHES,
|
|
117
|
+
MAX_QUOTE_LENGTH,
|
|
118
|
+
createAnchor,
|
|
119
|
+
mapPosition,
|
|
120
|
+
mapRange,
|
|
121
|
+
resolveAnchor
|
|
122
|
+
};
|
|
@@ -114,7 +114,7 @@ function onlyOccurrence(type, quote) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
// src/principal.ts
|
|
117
|
-
|
|
117
|
+
import { PRINCIPAL_KINDS } from "agentic-identity";
|
|
118
118
|
var MAX_NAME = 128;
|
|
119
119
|
var issued = /* @__PURE__ */ new WeakSet();
|
|
120
120
|
var Actor = class _Actor {
|
|
@@ -158,9 +158,6 @@ function authorLabel(author) {
|
|
|
158
158
|
return author.kind === "human" ? "person" : author.kind === "user_token" ? "person via agent" : "agent";
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
// src/suggestions.ts
|
|
162
|
-
import * as Y2 from "yjs";
|
|
163
|
-
|
|
164
161
|
// src/validate.ts
|
|
165
162
|
var invalid = (message) => {
|
|
166
163
|
throw new CommentsError("INVALID_INPUT", message);
|
|
@@ -182,6 +179,7 @@ var strictCopy = (value) => JSON.parse(
|
|
|
182
179
|
);
|
|
183
180
|
|
|
184
181
|
// src/suggestions.ts
|
|
182
|
+
import * as Y2 from "yjs";
|
|
185
183
|
var ACCEPT_SCOPE = "comments:accept";
|
|
186
184
|
var MAX_CHANGE = 64e3;
|
|
187
185
|
var MAX_RECEIPT = 16e3;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
VERSION
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-DRACF66Y.js";
|
|
4
4
|
import {
|
|
5
5
|
CommentsService
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-NQWRFKJ6.js";
|
|
7
7
|
import {
|
|
8
8
|
CommentsError
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-BSNBDKR2.js";
|
|
10
10
|
|
|
11
11
|
// src/mcp.ts
|
|
12
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
package/dist/cli.js
CHANGED
|
@@ -3,17 +3,17 @@ import {
|
|
|
3
3
|
FileStore,
|
|
4
4
|
lockTimeoutFrom,
|
|
5
5
|
operatorActor
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-HXXJNFEO.js";
|
|
7
7
|
import {
|
|
8
8
|
VERSION
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-DRACF66Y.js";
|
|
10
10
|
import {
|
|
11
11
|
CommentsService,
|
|
12
12
|
TEXT_ROOT
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-NQWRFKJ6.js";
|
|
14
14
|
import {
|
|
15
15
|
CommentsError
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-BSNBDKR2.js";
|
|
17
17
|
|
|
18
18
|
// src/cli.ts
|
|
19
19
|
import { readFileSync, realpathSync } from "fs";
|
|
@@ -155,7 +155,7 @@ async function runCli(argv, io) {
|
|
|
155
155
|
return 0;
|
|
156
156
|
}
|
|
157
157
|
if (p.command === "mcp") {
|
|
158
|
-
const { startMcp } = await import("./mcp-
|
|
158
|
+
const { startMcp } = await import("./mcp-ACC4QIEM.js");
|
|
159
159
|
await startMcp(store, operatorActor(p.flags["--as"] ?? io.env.AGENTIC_COMMENTS_AS, io.env.AGENTIC_COMMENTS_SCOPES));
|
|
160
160
|
return 0;
|
|
161
161
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
1
|
+
import { a as Author, C as Comments, b as Suggestions } from './suggestions-oj8fd-nG.js';
|
|
2
|
+
export { c as ACCEPT_SCOPE, A as Actor, d as Anchor, e as AnchorKind, f as AnchorState, g as ApplyContext, h as CanvasAnchor, i as CellAnchor, j as CommentsOptions, M as Message, N as NewReply, k as NewSuggestion, l as NewThread, R as RelativePositionJson, m as RevertContext, n as SlideAnchor, o as StampInput, S as Suggestion, p as SuggestionFilter, q as SuggestionHost, r as TextAnchor, T as Thread, s as ThreadFilter, t as authorLabel, u as stamp, v as textAnchor, w as textOf } from './suggestions-oj8fd-nG.js';
|
|
3
|
+
export { PRINCIPAL_KINDS, PrincipalKind } from 'agentic-identity';
|
|
3
4
|
import 'yjs';
|
|
4
5
|
|
|
5
6
|
declare const VERSION: string;
|
|
6
7
|
|
|
7
|
-
type CommentsErrorCode = "INVALID_AUTHOR" | "INVALID_INPUT" | "INVALID_ANCHOR" | "NO_THREAD" | "THREAD_RESOLVED" | "CORRUPT" | "NO_SUGGESTION" | "FORBIDDEN" | "ALREADY_DECIDED" | "APPLY_FAILED" | "RELOCATE_FAILED" | "STORE_MISSING" | "STORE_EXISTS" | "STORE_CORRUPT" | "STORE_LOCKED" | "STORE_FULL" | "USAGE";
|
|
8
|
+
type CommentsErrorCode = "INVALID_AUTHOR" | "INVALID_INPUT" | "INVALID_ANCHOR" | "NO_THREAD" | "THREAD_RESOLVED" | "CORRUPT" | "NO_SUGGESTION" | "FORBIDDEN" | "ALREADY_DECIDED" | "APPLY_FAILED" | "RELOCATE_FAILED" | "STORE_MISSING" | "STORE_EXISTS" | "STORE_CORRUPT" | "STORE_LOCKED" | "STORE_FULL" | "NO_DOCUMENT" | "DOCUMENT_EXISTS" | "CONFLICT" | "MISCONFIGURED" | "USAGE";
|
|
8
9
|
declare class CommentsError extends Error {
|
|
9
10
|
readonly code: CommentsErrorCode;
|
|
10
11
|
constructor(code: CommentsErrorCode, message: string);
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
VERSION
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-DRACF66Y.js";
|
|
4
4
|
import {
|
|
5
5
|
ACCEPT_SCOPE,
|
|
6
6
|
Actor,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
textAnchor,
|
|
15
15
|
textOf,
|
|
16
16
|
validDate
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-BSNBDKR2.js";
|
|
18
18
|
|
|
19
19
|
// src/notifications.ts
|
|
20
20
|
import * as Y from "yjs";
|
package/dist/local.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as Y from 'yjs';
|
|
2
|
-
|
|
2
|
+
export { A as AnchorSpec, C as CommentsService, L as ListFilter } from './service-Dxw9TVxY.js';
|
|
3
|
+
import { q as SuggestionHost, A as Actor } from './suggestions-oj8fd-nG.js';
|
|
4
|
+
import 'agentic-identity';
|
|
3
5
|
|
|
4
6
|
/** The most bytes a saved document holds. A save over this is refused, so a file never grows past what a load accepts. */
|
|
5
7
|
declare const MAX_FILE: number;
|
|
@@ -35,63 +37,6 @@ declare class FileStore {
|
|
|
35
37
|
write<T>(fn: (ydoc: Y.Doc) => T): T;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
/** Where a comment or suggestion points, in terms a person or agent can write down. */
|
|
39
|
-
type AnchorSpec = {
|
|
40
|
-
type: "text";
|
|
41
|
-
quote: string;
|
|
42
|
-
occurrence?: number;
|
|
43
|
-
} | {
|
|
44
|
-
type: "cell";
|
|
45
|
-
key: string;
|
|
46
|
-
} | {
|
|
47
|
-
type: "slide";
|
|
48
|
-
elementId: string;
|
|
49
|
-
} | {
|
|
50
|
-
type: "canvas";
|
|
51
|
-
elementId: string;
|
|
52
|
-
};
|
|
53
|
-
interface ListFilter {
|
|
54
|
-
status?: string;
|
|
55
|
-
anchorState?: string;
|
|
56
|
-
mentioning?: string;
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Every action the CLI and MCP server offer, on a local file store. Each call takes the actor the operator
|
|
60
|
-
* stamped, and no input carries an author, so both faces return the same shapes and obey the same rules.
|
|
61
|
-
*/
|
|
62
|
-
declare class CommentsService {
|
|
63
|
-
private readonly store;
|
|
64
|
-
private readonly actor;
|
|
65
|
-
constructor(store: FileStore, actor: Actor);
|
|
66
|
-
private open;
|
|
67
|
-
private read;
|
|
68
|
-
private write;
|
|
69
|
-
threads(filter?: ListFilter): Thread[];
|
|
70
|
-
thread(id: string): Thread;
|
|
71
|
-
comment(input: {
|
|
72
|
-
anchor: AnchorSpec;
|
|
73
|
-
body: string;
|
|
74
|
-
mentions?: string[];
|
|
75
|
-
}): Thread;
|
|
76
|
-
reply(threadId: string, input: {
|
|
77
|
-
body: string;
|
|
78
|
-
mentions?: string[];
|
|
79
|
-
}): Message;
|
|
80
|
-
resolve(threadId: string): Thread;
|
|
81
|
-
reopen(threadId: string): Thread;
|
|
82
|
-
relocate(threadId: string): Thread;
|
|
83
|
-
suggestions(filter?: ListFilter): Suggestion[];
|
|
84
|
-
suggest(input: {
|
|
85
|
-
anchor: AnchorSpec;
|
|
86
|
-
change: unknown;
|
|
87
|
-
summary?: string;
|
|
88
|
-
}): Suggestion;
|
|
89
|
-
accept(suggestionId: string): Suggestion;
|
|
90
|
-
reject(suggestionId: string): Suggestion;
|
|
91
|
-
/** The content text of the store. */
|
|
92
|
-
text(): string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
40
|
/** The root Y.Text that a local store keeps its content in. */
|
|
96
41
|
declare const TEXT_ROOT = "text";
|
|
97
42
|
/** The root Y.Map that holds values for cell, slide, canvas, and custom anchors. */
|
|
@@ -109,4 +54,4 @@ declare const localHost: SuggestionHost;
|
|
|
109
54
|
*/
|
|
110
55
|
declare function operatorActor(as: string | undefined, scopes: string | undefined): Actor;
|
|
111
56
|
|
|
112
|
-
export {
|
|
57
|
+
export { FileStore, type FileStoreOptions, MAX_FILE, TEXT_ROOT, VALUES_ROOT, localHost, lockTimeoutFrom, operatorActor };
|
package/dist/local.js
CHANGED
|
@@ -3,14 +3,14 @@ import {
|
|
|
3
3
|
MAX_FILE,
|
|
4
4
|
lockTimeoutFrom,
|
|
5
5
|
operatorActor
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-HXXJNFEO.js";
|
|
7
7
|
import {
|
|
8
8
|
CommentsService,
|
|
9
9
|
TEXT_ROOT,
|
|
10
10
|
VALUES_ROOT,
|
|
11
11
|
localHost
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import "./chunk-
|
|
12
|
+
} from "./chunk-NQWRFKJ6.js";
|
|
13
|
+
import "./chunk-BSNBDKR2.js";
|
|
14
14
|
export {
|
|
15
15
|
CommentsService,
|
|
16
16
|
FileStore,
|
package/dist/mcp-bin.js
CHANGED
|
@@ -3,19 +3,38 @@ import {
|
|
|
3
3
|
FileStore,
|
|
4
4
|
lockTimeoutFrom,
|
|
5
5
|
operatorActor
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-HXXJNFEO.js";
|
|
7
7
|
import {
|
|
8
8
|
startMcp
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
} from "./chunk-R4LH36VG.js";
|
|
10
|
+
import {
|
|
11
|
+
VERSION
|
|
12
|
+
} from "./chunk-DRACF66Y.js";
|
|
13
|
+
import "./chunk-NQWRFKJ6.js";
|
|
14
|
+
import "./chunk-BSNBDKR2.js";
|
|
13
15
|
|
|
14
16
|
// src/mcp-bin.ts
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
var MCP_HELP = `agentic-comments-mcp ${VERSION}
|
|
18
|
+
|
|
19
|
+
Start the MCP server on stdio. Set the principal and scopes in the environment:
|
|
20
|
+
|
|
21
|
+
AGENTIC_COMMENTS_AS=kind:name Required. For example agent_of_human:scribe.
|
|
22
|
+
AGENTIC_COMMENTS_SCOPES=a,b Optional. A comma list, such as comments:accept.
|
|
23
|
+
AGENTIC_COMMENTS_FILE=./comments.ydoc The store file. The default is ./comments.ydoc.
|
|
24
|
+
AGENTIC_COMMENTS_LOCK_TIMEOUT_MS=5000 How long a write waits for another process.
|
|
25
|
+
|
|
26
|
+
Usage: agentic-comments-mcp [--help | --version]`;
|
|
27
|
+
var args = process.argv.slice(2);
|
|
28
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
29
|
+
console.log(MCP_HELP);
|
|
30
|
+
} else if (args.includes("--version") || args.includes("-v")) {
|
|
31
|
+
console.log(VERSION);
|
|
32
|
+
} else {
|
|
33
|
+
try {
|
|
34
|
+
const env = process.env;
|
|
35
|
+
await startMcp(new FileStore(env.AGENTIC_COMMENTS_FILE ?? "./comments.ydoc", { lockTimeoutMs: lockTimeoutFrom(env) }), operatorActor(env.AGENTIC_COMMENTS_AS, env.AGENTIC_COMMENTS_SCOPES));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(JSON.stringify({ error: { code: error.code ?? "INTERNAL", message: error instanceof Error ? error.message : String(error) } }));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
21
40
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
import { A as Actor, T as Thread, M as Message, S as Suggestion } from './suggestions-oj8fd-nG.js';
|
|
3
|
+
|
|
4
|
+
/** Where a comment or suggestion points, in terms a person or agent can write down. */
|
|
5
|
+
type AnchorSpec = {
|
|
6
|
+
type: "text";
|
|
7
|
+
quote: string;
|
|
8
|
+
occurrence?: number;
|
|
9
|
+
} | {
|
|
10
|
+
type: "cell";
|
|
11
|
+
key: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: "slide";
|
|
14
|
+
elementId: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: "canvas";
|
|
17
|
+
elementId: string;
|
|
18
|
+
};
|
|
19
|
+
/** Where a service reads and writes its document: a file store, or a document the host loaded. */
|
|
20
|
+
interface YStore {
|
|
21
|
+
read<T>(fn: (ydoc: Y.Doc) => T): T;
|
|
22
|
+
write<T>(fn: (ydoc: Y.Doc) => T): T;
|
|
23
|
+
}
|
|
24
|
+
interface ListFilter {
|
|
25
|
+
status?: string;
|
|
26
|
+
anchorState?: string;
|
|
27
|
+
mentioning?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Every action the CLI and MCP server offer, on a local file store. Each call takes the actor the operator
|
|
31
|
+
* stamped, and no input carries an author, so both faces return the same shapes and obey the same rules.
|
|
32
|
+
*/
|
|
33
|
+
declare class CommentsService {
|
|
34
|
+
private readonly store;
|
|
35
|
+
private readonly actor;
|
|
36
|
+
constructor(store: YStore, actor: Actor);
|
|
37
|
+
private open;
|
|
38
|
+
private read;
|
|
39
|
+
private write;
|
|
40
|
+
threads(filter?: ListFilter): Thread[];
|
|
41
|
+
thread(id: string): Thread;
|
|
42
|
+
comment(input: {
|
|
43
|
+
anchor: AnchorSpec;
|
|
44
|
+
body: string;
|
|
45
|
+
mentions?: string[];
|
|
46
|
+
}): Thread;
|
|
47
|
+
reply(threadId: string, input: {
|
|
48
|
+
body: string;
|
|
49
|
+
mentions?: string[];
|
|
50
|
+
}): Message;
|
|
51
|
+
resolve(threadId: string): Thread;
|
|
52
|
+
reopen(threadId: string): Thread;
|
|
53
|
+
relocate(threadId: string): Thread;
|
|
54
|
+
suggestions(filter?: ListFilter): Suggestion[];
|
|
55
|
+
suggest(input: {
|
|
56
|
+
anchor: AnchorSpec;
|
|
57
|
+
change: unknown;
|
|
58
|
+
summary?: string;
|
|
59
|
+
}): Suggestion;
|
|
60
|
+
accept(suggestionId: string): Suggestion;
|
|
61
|
+
reject(suggestionId: string): Suggestion;
|
|
62
|
+
/** The content text of the store. */
|
|
63
|
+
text(): string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { type AnchorSpec as A, CommentsService as C, type ListFilter as L };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as Y from 'yjs';
|
|
2
|
+
import { PrincipalKind } from 'agentic-identity';
|
|
2
3
|
|
|
3
4
|
/** A Yjs relative position as JSON. It names an item, not an index, so it follows its text. */
|
|
4
5
|
interface RelativePositionJson {
|
|
@@ -65,8 +66,6 @@ declare function textOf(type: Y.Text | Y.XmlText): string;
|
|
|
65
66
|
/** Pin a range of a Yjs text. Later edits by any replica do not move the anchor off its text. */
|
|
66
67
|
declare function textAnchor(type: Y.Text | Y.XmlText, from: number, to: number): TextAnchor;
|
|
67
68
|
|
|
68
|
-
declare const PRINCIPAL_KINDS: readonly ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
|
|
69
|
-
type PrincipalKind = (typeof PRINCIPAL_KINDS)[number];
|
|
70
69
|
/** What a thread keeps about who wrote a message. Scopes never reach storage. */
|
|
71
70
|
interface Author {
|
|
72
71
|
kind: PrincipalKind;
|
|
@@ -331,4 +330,4 @@ declare class Suggestions {
|
|
|
331
330
|
settle(): void;
|
|
332
331
|
}
|
|
333
332
|
|
|
334
|
-
export {
|
|
333
|
+
export { Actor as A, Comments as C, type Message as M, type NewReply as N, type RelativePositionJson as R, type Suggestion as S, type Thread as T, type Author as a, Suggestions as b, ACCEPT_SCOPE as c, type Anchor as d, type AnchorKind as e, type AnchorState as f, type ApplyContext as g, type CanvasAnchor as h, type CellAnchor as i, type CommentsOptions as j, type NewSuggestion as k, type NewThread as l, type RevertContext as m, type SlideAnchor as n, type StampInput as o, type SuggestionFilter as p, type SuggestionHost as q, type TextAnchor as r, type ThreadFilter as s, authorLabel as t, stamp as u, textAnchor as v, textOf as w };
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { Auth, Store, PrincipalContext, Region } from 'agentic-identity';
|
|
3
|
+
import { D1Like } from 'agentic-identity/cloudflare';
|
|
4
|
+
import { A as Actor } from './suggestions-oj8fd-nG.js';
|
|
5
|
+
import { C as CommentsService } from './service-Dxw9TVxY.js';
|
|
6
|
+
import 'yjs';
|
|
7
|
+
|
|
8
|
+
interface WorkerEnv {
|
|
9
|
+
/** Where the data lives: "eu" or "us". It is a claim you make at deploy time, with no default. "unknown" needs `AGENTIC_COMMENTS_UNSAFE_REGION=1` too. */
|
|
10
|
+
AGENTIC_COMMENTS_REGION?: string;
|
|
11
|
+
/** Set to "1" to accept the region "unknown" under `wrangler dev`. Never set it in production. */
|
|
12
|
+
AGENTIC_COMMENTS_UNSAFE_REGION?: string;
|
|
13
|
+
/** D1 database: each document, with its threads, as a Yjs update. */
|
|
14
|
+
DB?: D1Like;
|
|
15
|
+
/**
|
|
16
|
+
* JSON object: bearer token (32 bytes or more) to `kind:name`, or to `{ "as": "kind:name", "scopes": ["comments:write"] }`.
|
|
17
|
+
* Set it as a secret. A short token or a bad entry refuses the whole config.
|
|
18
|
+
*/
|
|
19
|
+
AGENTIC_COMMENTS_TOKENS?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* An `Auth` over a table of bearer tokens. One bad entry (a token under 32 bytes, a value that is not a valid principal)
|
|
23
|
+
* refuses the whole table, so a typo never leaves a weak token live. The compare visits every entry.
|
|
24
|
+
*/
|
|
25
|
+
declare function createTokenAuth(raw: string | undefined): Auth;
|
|
26
|
+
/** The documents, on the `Store` port. Each write loads the document, runs the change, and saves in one transaction. */
|
|
27
|
+
declare class CommentsDocs {
|
|
28
|
+
private readonly store;
|
|
29
|
+
constructor(store: Store);
|
|
30
|
+
private id;
|
|
31
|
+
private load;
|
|
32
|
+
private open;
|
|
33
|
+
create(id: string, text: string, actor: Actor): Promise<void>;
|
|
34
|
+
read<T>(id: string, actor: Actor, run: (service: CommentsService) => T): Promise<T>;
|
|
35
|
+
write<T>(id: string, actor: Actor, run: (service: CommentsService) => T): Promise<T>;
|
|
36
|
+
}
|
|
37
|
+
/** The docs, or a function that makes them from the Worker bindings. */
|
|
38
|
+
type DocsSource = CommentsDocs | ((env: WorkerEnv) => Promise<CommentsDocs>);
|
|
39
|
+
interface AppOptions {
|
|
40
|
+
/** The credential check. The default reads `AGENTIC_COMMENTS_TOKENS`. Pass any `Auth` from agentic-identity to replace it. */
|
|
41
|
+
auth?: Auth | ((env: WorkerEnv) => Auth);
|
|
42
|
+
}
|
|
43
|
+
type AppEnv = {
|
|
44
|
+
Bindings: WorkerEnv;
|
|
45
|
+
Variables: {
|
|
46
|
+
context: PrincipalContext;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
declare function createApp(source: DocsSource, options?: AppOptions): Hono<AppEnv>;
|
|
50
|
+
/** The region claim, or a MISCONFIGURED error. */
|
|
51
|
+
declare function regionOf(env: WorkerEnv): Region;
|
|
52
|
+
/** The documents over D1. */
|
|
53
|
+
declare function docsFor(env: WorkerEnv): Promise<CommentsDocs>;
|
|
54
|
+
declare const _default: {
|
|
55
|
+
fetch: (request: Request, env: WorkerEnv, ctx: unknown) => Response | Promise<Response>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export { type AppOptions, CommentsDocs, type DocsSource, type WorkerEnv, createApp, createTokenAuth, _default as default, docsFor, regionOf };
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CommentsService,
|
|
3
|
+
TEXT_ROOT
|
|
4
|
+
} from "./chunk-NQWRFKJ6.js";
|
|
5
|
+
import {
|
|
6
|
+
ACCEPT_SCOPE,
|
|
7
|
+
CommentsError,
|
|
8
|
+
PRINCIPAL_KINDS,
|
|
9
|
+
exact,
|
|
10
|
+
invalid,
|
|
11
|
+
stamp
|
|
12
|
+
} from "./chunk-BSNBDKR2.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
|
@@ -5,6 +5,7 @@ Comment threads and suggestions anchored to any content, from people and agents.
|
|
|
5
5
|
Keywords: comments,threads,mentions,suggestions,annotations,collaboration,agents,yjs.
|
|
6
6
|
|
|
7
7
|
- SDK: import { Comments, Suggestions, textAnchor } from "agentic-comments". The host calls stamp(); an agent never does.
|
|
8
|
+
- Plain text anchors: import { createAnchor, resolveAnchor, mapRange } from "agentic-comments/anchor". No Yjs. resolveAnchor maps through edits, then falls back to quote + context, else orphaned.
|
|
8
9
|
- Threads live on a Yjs document and merge across replicas.
|
|
9
10
|
- Events: new Notifications(comments, suggestions).subscribe(listener). Ids only, never text.
|
|
10
11
|
- CLI: agentic-comments <command>. MCP: agentic-comments mcp. Both use a local store file.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentic-comments",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Comment threads and suggestions anchored to any content, from people and agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"comments",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"type": "module",
|
|
27
27
|
"private": false,
|
|
28
28
|
"engines": {
|
|
29
|
-
"node": ">=
|
|
29
|
+
"node": ">=22"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
@@ -44,9 +44,17 @@
|
|
|
44
44
|
"types": "./dist/index.d.ts",
|
|
45
45
|
"import": "./dist/index.js"
|
|
46
46
|
},
|
|
47
|
+
"./anchor": {
|
|
48
|
+
"types": "./dist/anchoring.d.ts",
|
|
49
|
+
"import": "./dist/anchoring.js"
|
|
50
|
+
},
|
|
47
51
|
"./local": {
|
|
48
52
|
"types": "./dist/local.d.ts",
|
|
49
53
|
"import": "./dist/local.js"
|
|
54
|
+
},
|
|
55
|
+
"./worker": {
|
|
56
|
+
"types": "./dist/worker.d.ts",
|
|
57
|
+
"import": "./dist/worker.js"
|
|
50
58
|
}
|
|
51
59
|
},
|
|
52
60
|
"bin": {
|
|
@@ -57,12 +65,15 @@
|
|
|
57
65
|
"build": "tsup",
|
|
58
66
|
"typecheck": "tsc --noEmit",
|
|
59
67
|
"test": "vitest run",
|
|
68
|
+
"deploy:demo": "npm run build && WRANGLER_SEND_METRICS=false wrangler deploy --config wrangler.demo.jsonc",
|
|
60
69
|
"clean": "rm -rf dist",
|
|
61
70
|
"prepack": "npm run build",
|
|
62
71
|
"ci:local": "npm run build && npm run typecheck && npm test"
|
|
63
72
|
},
|
|
64
73
|
"dependencies": {
|
|
65
74
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
75
|
+
"agentic-identity": "^0.4.0",
|
|
76
|
+
"hono": "^4.13.14",
|
|
66
77
|
"zod": "^3.25.76"
|
|
67
78
|
},
|
|
68
79
|
"peerDependencies": {
|
|
@@ -73,6 +84,7 @@
|
|
|
73
84
|
"tsup": "^8.0.0",
|
|
74
85
|
"typescript": "^5.6.0",
|
|
75
86
|
"vitest": "^3.0.0",
|
|
87
|
+
"wrangler": "^4.155.0",
|
|
76
88
|
"yjs": "^13.6.0"
|
|
77
89
|
}
|
|
78
90
|
}
|