agentic-documents 0.4.0 → 0.6.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/CHANGELOG.md +56 -0
- package/README.md +161 -11
- package/dist/{chunk-TFCRPEEM.js → chunk-34C47PDC.js} +1 -1
- package/dist/chunk-5IQX2W52.js +195 -0
- package/dist/chunk-6UYJV2EJ.js +163 -0
- package/dist/{chunk-R5XUTBC3.js → chunk-CN6UAW7B.js} +32 -9
- package/dist/chunk-DWVJIKVT.js +8 -0
- package/dist/{chunk-YBBQNCMC.js → chunk-GQ5E6T7Z.js} +18 -191
- package/dist/{chunk-FZA5LQV3.js → chunk-JBAQEIOH.js} +89 -12
- package/dist/chunk-KC5KFB5R.js +354 -0
- package/dist/{chunk-IU3YIQ4Y.js → chunk-V6TBTOHW.js} +38 -13
- package/dist/cli.js +16 -12
- package/dist/client.d.ts +23 -6
- package/dist/client.js +9 -2
- package/dist/{doc-C_8kCsl7.d.ts → doc-BXG9qG5X.d.ts} +8 -2
- package/dist/editor.css +37 -0
- package/dist/embed.d.ts +86 -0
- package/dist/embed.js +431 -0
- package/dist/formats.d.ts +52 -0
- package/dist/formats.js +24 -0
- package/dist/index.d.ts +12 -10
- package/dist/index.js +15 -9
- package/dist/live-worker.d.ts +2 -2
- package/dist/live-worker.js +7 -4
- package/dist/live.d.ts +6 -2
- package/dist/live.js +5 -3
- package/dist/{mcp-P2C75IIG.js → mcp-ZTUTC4CS.js} +5 -2
- package/dist/render-463xHb-x.d.ts +16 -0
- package/dist/{review-BQW2Ci2h.d.ts → review-Ds-i5Vnc.d.ts} +2 -16
- package/dist/{service-Do2Jwb5U.d.ts → service-Ck6oX-Kq.d.ts} +38 -5
- package/dist/ui/_headers +4 -0
- package/dist/ui/app.css +1 -0
- package/dist/ui/app.js +270 -0
- package/dist/ui/assets/inter-latin-ext-wght-normal-HA22NDSG.woff2 +0 -0
- package/dist/ui/assets/inter-latin-wght-normal-NRMW37G5.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-TSLVDSQR.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-wght-normal-VBS632QH.woff2 +0 -0
- package/dist/ui/index.html +13 -0
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +7 -4
- package/llms.txt +1 -0
- package/package.json +43 -10
- package/dist/chunk-GYQFTF2E.js +0 -389
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MAX_LIVE_BYTES
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-JBAQEIOH.js";
|
|
4
4
|
import {
|
|
5
5
|
AgenticDoc,
|
|
6
6
|
DOC_ERROR_CODES,
|
|
7
|
-
DocError
|
|
7
|
+
DocError
|
|
8
|
+
} from "./chunk-GQ5E6T7Z.js";
|
|
9
|
+
import {
|
|
8
10
|
PRINCIPAL_KINDS
|
|
9
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-DWVJIKVT.js";
|
|
10
12
|
|
|
11
13
|
// src/live.ts
|
|
12
14
|
import * as Y from "yjs";
|
|
@@ -128,16 +130,37 @@ var LiveService = class {
|
|
|
128
130
|
throw unreachable(error);
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
body: JSON.stringify({ id, markdown })
|
|
133
|
+
async call(path, init) {
|
|
134
|
+
const response = await fetch(`${base(this.options.server)}${path}`, {
|
|
135
|
+
method: init.method,
|
|
136
|
+
headers: { authorization: `Bearer ${this.options.token}`, ...init.body ? { "content-type": "application/json" } : {} },
|
|
137
|
+
body: init.body ? JSON.stringify(init.body) : void 0
|
|
137
138
|
}).catch((error) => {
|
|
138
139
|
throw unreachable(error);
|
|
139
140
|
});
|
|
140
141
|
if (!response.ok) throw await refusal(response, "the server refused the request");
|
|
142
|
+
return response;
|
|
143
|
+
}
|
|
144
|
+
/** The room is only a copy, so a new document is made in the store. A room seeds from it on the next join. */
|
|
145
|
+
async create(id, markdown) {
|
|
146
|
+
await this.call("/api/v1/docs", { method: "POST", body: { id, markdown } });
|
|
147
|
+
}
|
|
148
|
+
// The catalog lives in the store, not in a room, so these go to the HTTP API.
|
|
149
|
+
async list() {
|
|
150
|
+
const body = await (await this.call("/api/v1/docs", { method: "GET" })).json().catch(() => void 0);
|
|
151
|
+
const who = (w) => typeof w === "object" && w !== null && PRINCIPAL_KINDS.includes(w.kind) && typeof w.name === "string" && w.name !== "";
|
|
152
|
+
const row = (r) => {
|
|
153
|
+
const d = r;
|
|
154
|
+
return typeof r === "object" && r !== null && typeof d.id === "string" && typeof d.title === "string" && typeof d.createdAt === "number" && typeof d.updatedAt === "number" && who(d.createdBy) && who(d.updatedBy);
|
|
155
|
+
};
|
|
156
|
+
if (!Array.isArray(body) || !body.every(row)) throw new DocError("INVALID_INPUT", "the server sent a document list that is not valid");
|
|
157
|
+
return body;
|
|
158
|
+
}
|
|
159
|
+
async rename(id, title) {
|
|
160
|
+
await this.call(`/api/v1/docs/${encodeURIComponent(id)}/rename`, { method: "POST", body: { title } });
|
|
161
|
+
}
|
|
162
|
+
async remove(id) {
|
|
163
|
+
await this.call(`/api/v1/docs/${encodeURIComponent(id)}/remove`, { method: "POST", body: {} });
|
|
141
164
|
}
|
|
142
165
|
async read(id, run) {
|
|
143
166
|
const live = await this.join(id);
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PRINCIPAL_KINDS
|
|
3
|
+
} from "./chunk-DWVJIKVT.js";
|
|
4
|
+
|
|
1
5
|
// src/errors.ts
|
|
2
6
|
var DOC_ERROR_CODES = ["NO_MATCH", "AMBIGUOUS_MATCH", "STALE_ANCHOR", "NO_SECTION", "INVALID_AUTHOR", "INVALID_INPUT", "NO_THREAD", "NO_SUGGESTION", "ALREADY_DECIDED", "FORBIDDEN", "NO_DOCUMENT", "DOCUMENT_EXISTS", "LOCKED", "CONFLICT", "MISCONFIGURED"];
|
|
3
7
|
var DocError = class extends Error {
|
|
@@ -314,7 +318,6 @@ function settle(ydoc) {
|
|
|
314
318
|
var PREAMBLE = "preamble";
|
|
315
319
|
var FRAGMENT = "default";
|
|
316
320
|
var CONTEXT_CHARS = 20;
|
|
317
|
-
var PRINCIPAL_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
|
|
318
321
|
var isSection = (t) => "section" in t;
|
|
319
322
|
var isAnchor = (t) => "anchor" in t;
|
|
320
323
|
function checkAuthor(author) {
|
|
@@ -361,9 +364,18 @@ var AgenticDoc = class _AgenticDoc {
|
|
|
361
364
|
});
|
|
362
365
|
}
|
|
363
366
|
static fromMarkdown(markdown, author, options) {
|
|
367
|
+
checkAuthor(author);
|
|
368
|
+
return _AgenticDoc.fromBlocks(markdownToBlocks(markdown), author, options);
|
|
369
|
+
}
|
|
370
|
+
/** Make a document from blocks in ProseMirror JSON form. Blocks that the schema does not allow fail with INVALID_INPUT. */
|
|
371
|
+
static fromBlocks(content, author, options) {
|
|
364
372
|
const who = checkAuthor(author);
|
|
373
|
+
try {
|
|
374
|
+
if (content.length > 0) schema.nodeFromJSON({ type: "doc", content }).check();
|
|
375
|
+
} catch (error) {
|
|
376
|
+
throw new DocError("INVALID_INPUT", `the blocks do not fit the document schema: ${error instanceof Error ? error.message : String(error)}`);
|
|
377
|
+
}
|
|
365
378
|
const doc = new _AgenticDoc(new Y2.Doc(), options);
|
|
366
|
-
const content = markdownToBlocks(markdown);
|
|
367
379
|
doc.change("create", who, () => {
|
|
368
380
|
doc.fragment.insert(0, toYBlocks(content));
|
|
369
381
|
return { inserted: content.length, deleted: 0 };
|
|
@@ -397,7 +409,7 @@ var AgenticDoc = class _AgenticDoc {
|
|
|
397
409
|
change(op, author, run, extra = {}) {
|
|
398
410
|
const at = this.now().toISOString();
|
|
399
411
|
this.ydoc.transact(() => {
|
|
400
|
-
const entry = { op, at, author, ...run(), ...extra };
|
|
412
|
+
const entry = { op, at, author, client: this.ydoc.clientID, ...run(), ...extra };
|
|
401
413
|
if (entry.section === void 0) delete entry.section;
|
|
402
414
|
this.log.push([entry]);
|
|
403
415
|
});
|
|
@@ -546,199 +558,14 @@ var AgenticDoc = class _AgenticDoc {
|
|
|
546
558
|
}
|
|
547
559
|
};
|
|
548
560
|
|
|
549
|
-
// src/review.ts
|
|
550
|
-
import * as Y3 from "yjs";
|
|
551
|
-
var newId = () => globalThis.crypto.randomUUID();
|
|
552
|
-
function nonEmpty(value, what) {
|
|
553
|
-
if (typeof value !== "string" || value.trim() === "") throw new DocError("INVALID_INPUT", `${what} must not be empty`);
|
|
554
|
-
return value;
|
|
555
|
-
}
|
|
556
|
-
function anchorFor(doc, target) {
|
|
557
|
-
if ("anchor" in target) {
|
|
558
|
-
doc.resolve(target);
|
|
559
|
-
return target.anchor;
|
|
560
|
-
}
|
|
561
|
-
return doc.anchor(target.text, target.occurrence);
|
|
562
|
-
}
|
|
563
|
-
function isStale(doc, anchor) {
|
|
564
|
-
try {
|
|
565
|
-
doc.resolve({ anchor });
|
|
566
|
-
return { stale: false, currentText: anchor.text };
|
|
567
|
-
} catch (error) {
|
|
568
|
-
if (!(error instanceof DocError) || error.code !== "STALE_ANCHOR") throw error;
|
|
569
|
-
return { stale: true, currentText: null };
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
function fieldsOf(doc, name) {
|
|
573
|
-
return doc.ydoc.getMap(name);
|
|
574
|
-
}
|
|
575
|
-
var optional = (map, key) => map.has(key) ? { [key]: map.get(key) } : {};
|
|
576
|
-
function setAll(map, values) {
|
|
577
|
-
for (const [key, value] of Object.entries(values)) if (value !== void 0) map.set(key, value);
|
|
578
|
-
}
|
|
579
|
-
var Threads = class {
|
|
580
|
-
constructor(doc) {
|
|
581
|
-
this.doc = doc;
|
|
582
|
-
}
|
|
583
|
-
doc;
|
|
584
|
-
get map() {
|
|
585
|
-
return fieldsOf(this.doc, "threads");
|
|
586
|
-
}
|
|
587
|
-
get(id) {
|
|
588
|
-
const thread = this.map.get(id);
|
|
589
|
-
if (!thread) throw new DocError("NO_THREAD", `no thread with id "${id}"`);
|
|
590
|
-
return thread;
|
|
591
|
-
}
|
|
592
|
-
message(body, author) {
|
|
593
|
-
return { id: newId(), at: this.doc.now().toISOString(), author, body };
|
|
594
|
-
}
|
|
595
|
-
add(target, body, author) {
|
|
596
|
-
const who = checkAuthor(author);
|
|
597
|
-
nonEmpty(body, "a comment");
|
|
598
|
-
const anchor = anchorFor(this.doc, target);
|
|
599
|
-
const id = newId();
|
|
600
|
-
this.doc.ydoc.transact(() => {
|
|
601
|
-
const thread = new Y3.Map();
|
|
602
|
-
this.map.set(id, thread);
|
|
603
|
-
setAll(thread, { anchor, status: "open" });
|
|
604
|
-
const messages = new Y3.Array();
|
|
605
|
-
thread.set("messages", messages);
|
|
606
|
-
messages.push([this.message(body, who)]);
|
|
607
|
-
});
|
|
608
|
-
return id;
|
|
609
|
-
}
|
|
610
|
-
reply(id, body, author) {
|
|
611
|
-
const who = checkAuthor(author);
|
|
612
|
-
nonEmpty(body, "a reply");
|
|
613
|
-
this.get(id).get("messages").push([this.message(body, who)]);
|
|
614
|
-
}
|
|
615
|
-
setStatus(id, status, author) {
|
|
616
|
-
const who = checkAuthor(author);
|
|
617
|
-
const thread = this.get(id);
|
|
618
|
-
this.doc.ydoc.transact(() => {
|
|
619
|
-
thread.set("status", status);
|
|
620
|
-
if (status === "open") {
|
|
621
|
-
thread.delete("resolvedBy");
|
|
622
|
-
thread.delete("resolvedAt");
|
|
623
|
-
} else setAll(thread, { resolvedBy: who, resolvedAt: this.doc.now().toISOString() });
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
resolve(id, author) {
|
|
627
|
-
this.setStatus(id, "resolved", author);
|
|
628
|
-
}
|
|
629
|
-
reopen(id, author) {
|
|
630
|
-
this.setStatus(id, "open", author);
|
|
631
|
-
}
|
|
632
|
-
list(filter = {}) {
|
|
633
|
-
return [...this.map.entries()].map(([id, t]) => {
|
|
634
|
-
const anchor = t.get("anchor");
|
|
635
|
-
return {
|
|
636
|
-
id,
|
|
637
|
-
anchor,
|
|
638
|
-
status: t.get("status"),
|
|
639
|
-
...optional(t, "resolvedBy"),
|
|
640
|
-
...optional(t, "resolvedAt"),
|
|
641
|
-
messages: t.get("messages").toArray(),
|
|
642
|
-
...isStale(this.doc, anchor)
|
|
643
|
-
};
|
|
644
|
-
}).filter((t) => filter.status === void 0 || t.status === filter.status).sort((a, b) => a.messages[0].at.localeCompare(b.messages[0].at) || a.id.localeCompare(b.id));
|
|
645
|
-
}
|
|
646
|
-
};
|
|
647
|
-
var ACCEPT_SCOPE = "docs:accept";
|
|
648
|
-
var sameWho = (a, b) => a.kind === b.kind && a.name === b.name;
|
|
649
|
-
var isPerson = (who) => who.kind === "human" || who.kind === "user_token";
|
|
650
|
-
var mayDecide = (who) => isPerson(who) || who.scopes?.includes(ACCEPT_SCOPE) === true;
|
|
651
|
-
var Suggestions = class {
|
|
652
|
-
constructor(doc) {
|
|
653
|
-
this.doc = doc;
|
|
654
|
-
}
|
|
655
|
-
doc;
|
|
656
|
-
get map() {
|
|
657
|
-
return fieldsOf(this.doc, "suggestions");
|
|
658
|
-
}
|
|
659
|
-
get(id) {
|
|
660
|
-
const fields = this.map.get(id);
|
|
661
|
-
if (!fields) throw new DocError("NO_SUGGESTION", `no suggestion with id "${id}"`);
|
|
662
|
-
return { fields, suggestion: this.read(id, fields) };
|
|
663
|
-
}
|
|
664
|
-
read(id, f) {
|
|
665
|
-
const anchor = f.get("anchor");
|
|
666
|
-
return {
|
|
667
|
-
id,
|
|
668
|
-
kind: f.get("kind"),
|
|
669
|
-
...optional(f, "at"),
|
|
670
|
-
anchor,
|
|
671
|
-
replacement: f.get("replacement"),
|
|
672
|
-
author: f.get("author"),
|
|
673
|
-
createdAt: f.get("createdAt"),
|
|
674
|
-
status: f.get("status"),
|
|
675
|
-
...optional(f, "decidedBy"),
|
|
676
|
-
...optional(f, "decidedAt"),
|
|
677
|
-
stale: isStale(this.doc, anchor).stale
|
|
678
|
-
};
|
|
679
|
-
}
|
|
680
|
-
/** An `at` on the target means insert. An empty replacement means delete. */
|
|
681
|
-
add(target, replacement, author) {
|
|
682
|
-
const who = checkAuthor(author);
|
|
683
|
-
const kind = target.at ? "insert" : replacement === "" ? "delete" : "replace";
|
|
684
|
-
if (kind === "insert") nonEmpty(replacement, "an inserted text");
|
|
685
|
-
const anchor = anchorFor(this.doc, target);
|
|
686
|
-
const id = newId();
|
|
687
|
-
this.doc.ydoc.transact(() => {
|
|
688
|
-
const fields = new Y3.Map();
|
|
689
|
-
this.map.set(id, fields);
|
|
690
|
-
setAll(fields, { kind, at: target.at, anchor, replacement, author: who, createdAt: this.doc.now().toISOString(), status: "pending" });
|
|
691
|
-
});
|
|
692
|
-
return id;
|
|
693
|
-
}
|
|
694
|
-
decide(fields, status, author, at) {
|
|
695
|
-
setAll(fields, { status, decidedBy: author, decidedAt: at });
|
|
696
|
-
}
|
|
697
|
-
/**
|
|
698
|
-
* Apply the edit as the accepting author. The check and the edit share one transaction. Accepting
|
|
699
|
-
* an accepted suggestion changes nothing. A stale anchor throws and the suggestion stays pending.
|
|
700
|
-
*/
|
|
701
|
-
accept(id, author) {
|
|
702
|
-
const who = checkAuthor(author);
|
|
703
|
-
this.doc.ydoc.transact(() => {
|
|
704
|
-
const { fields, suggestion: s } = this.get(id);
|
|
705
|
-
if (!mayDecide(author) || sameWho(s.author, who)) {
|
|
706
|
-
throw new DocError("FORBIDDEN", `${who.kind}:${who.name} cannot accept this suggestion: a person who is not its author, or an agent with the ${ACCEPT_SCOPE} scope, can`);
|
|
707
|
-
}
|
|
708
|
-
if (s.status === "accepted") return;
|
|
709
|
-
if (s.status !== "pending") throw new DocError("ALREADY_DECIDED", `suggestion "${id}" is already ${s.status}`);
|
|
710
|
-
const { at, inserted } = this.doc.edit(s.kind, { anchor: s.anchor, at: s.at }, s.replacement, who, { suggestion: { id, author: s.author } });
|
|
711
|
-
const record = { by: who, at, ...inserted };
|
|
712
|
-
this.doc.ydoc.getMap(APPLIED).set(`${id}:${this.doc.ydoc.clientID}`, record);
|
|
713
|
-
this.decide(fields, "accepted", who, at);
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
/** Same right as accept. A person can withdraw their own suggestion. An agent can never decide its own. */
|
|
717
|
-
reject(id, author) {
|
|
718
|
-
const who = checkAuthor(author);
|
|
719
|
-
this.doc.ydoc.transact(() => {
|
|
720
|
-
const { fields, suggestion: s } = this.get(id);
|
|
721
|
-
if (!mayDecide(author) || !isPerson(who) && sameWho(s.author, who)) {
|
|
722
|
-
throw new DocError("FORBIDDEN", `${who.kind}:${who.name} cannot reject this suggestion: a person, or an agent with the ${ACCEPT_SCOPE} scope that did not write it, can`);
|
|
723
|
-
}
|
|
724
|
-
if (s.status !== "pending") throw new DocError("ALREADY_DECIDED", `suggestion "${id}" is already ${s.status}`);
|
|
725
|
-
this.decide(fields, "rejected", who, this.doc.now().toISOString());
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
list(filter = {}) {
|
|
729
|
-
return [...this.map.entries()].map(([id, f]) => this.read(id, f)).filter((s) => filter.status === void 0 || s.status === filter.status).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
730
|
-
}
|
|
731
|
-
};
|
|
732
|
-
|
|
733
561
|
export {
|
|
734
562
|
DOC_ERROR_CODES,
|
|
735
563
|
DocError,
|
|
736
564
|
markdownToBlocks,
|
|
737
565
|
blocksToMarkdown,
|
|
738
566
|
schema,
|
|
739
|
-
|
|
567
|
+
APPLIED,
|
|
568
|
+
checkAuthor,
|
|
740
569
|
parseAuthor,
|
|
741
|
-
AgenticDoc
|
|
742
|
-
Threads,
|
|
743
|
-
Suggestions
|
|
570
|
+
AgenticDoc
|
|
744
571
|
};
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
|
-
AgenticDoc,
|
|
3
|
-
DocError,
|
|
4
|
-
PRINCIPAL_KINDS,
|
|
5
2
|
Suggestions,
|
|
6
|
-
Threads
|
|
7
|
-
|
|
3
|
+
Threads,
|
|
4
|
+
isPerson
|
|
5
|
+
} from "./chunk-5IQX2W52.js";
|
|
6
|
+
import {
|
|
7
|
+
AgenticDoc,
|
|
8
|
+
DocError
|
|
9
|
+
} from "./chunk-GQ5E6T7Z.js";
|
|
10
|
+
import {
|
|
11
|
+
PRINCIPAL_KINDS
|
|
12
|
+
} from "./chunk-DWVJIKVT.js";
|
|
8
13
|
|
|
9
14
|
// src/service.ts
|
|
10
15
|
import * as Y from "yjs";
|
|
@@ -16,7 +21,10 @@ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
16
21
|
var isString = (v) => typeof v === "string";
|
|
17
22
|
var isWho = (v) => isObject(v) && PRINCIPAL_KINDS.includes(v.kind) && isString(v.name) && v.name !== "";
|
|
18
23
|
var isAnchor = (v) => isObject(v) && isString(v.text) && isObject(v.start) && isObject(v.end);
|
|
19
|
-
var ROOTS = /* @__PURE__ */ new Set(["default", "edits", "suggestions", "threads", "applied"]);
|
|
24
|
+
var ROOTS = /* @__PURE__ */ new Set(["default", "edits", "suggestions", "threads", "applied", "__tiptap_position_migrations:root:default"]);
|
|
25
|
+
var MAX_TITLE = 120;
|
|
26
|
+
var REMOVE_SCOPE = "docs:remove";
|
|
27
|
+
var MAX_LISTED = 500;
|
|
20
28
|
var DocsService = class {
|
|
21
29
|
constructor(ports, options = {}) {
|
|
22
30
|
this.options = options;
|
|
@@ -47,7 +55,7 @@ var DocsService = class {
|
|
|
47
55
|
* has none. A merge is safe to repeat and to run in any order. The save is conditional, so two
|
|
48
56
|
* checkpoints at once both land.
|
|
49
57
|
*/
|
|
50
|
-
async checkpoint(id, update, author) {
|
|
58
|
+
async checkpoint(id, update, author, options = {}) {
|
|
51
59
|
const key = this.key(id);
|
|
52
60
|
const scratch = new Y.Doc();
|
|
53
61
|
try {
|
|
@@ -62,6 +70,7 @@ var DocsService = class {
|
|
|
62
70
|
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
63
71
|
const found = await this.blobs.get(key);
|
|
64
72
|
if (!found) {
|
|
73
|
+
if (options.create === false) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
|
|
65
74
|
const created = AgenticDoc.fromUpdate(update, this.options);
|
|
66
75
|
const fresh = this.checked(id, created);
|
|
67
76
|
this.fits(id, fresh);
|
|
@@ -73,7 +82,10 @@ var DocsService = class {
|
|
|
73
82
|
doc.applyUpdate(update);
|
|
74
83
|
const merged = this.checked(id, doc);
|
|
75
84
|
this.fits(id, merged);
|
|
76
|
-
if (await this.blobs.put(key, merged, { ifMatch: found.version }))
|
|
85
|
+
if (await this.blobs.put(key, merged, { ifMatch: found.version })) {
|
|
86
|
+
await this.touch(id, author);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
77
89
|
}
|
|
78
90
|
throw new DocError("CONFLICT", `document "${id}" kept changing; try again`);
|
|
79
91
|
}
|
|
@@ -86,7 +98,7 @@ var DocsService = class {
|
|
|
86
98
|
const refuse = (what) => new DocError("INVALID_INPUT", `the update would leave document "${id}" unreadable: ${what}`);
|
|
87
99
|
try {
|
|
88
100
|
doc.toMarkdown();
|
|
89
|
-
if (!doc.edits().every((e) => isObject(e) && isWho(e.author) && isString(e.at) && isString(e.op))) throw refuse("an edit log entry is malformed");
|
|
101
|
+
if (!doc.edits().every((e) => isObject(e) && isWho(e.author) && isString(e.at) && isString(e.op) && (e.client === void 0 || Number.isSafeInteger(e.client) && e.client >= 0))) throw refuse("an edit log entry is malformed");
|
|
90
102
|
for (const [, t] of doc.ydoc.getMap("threads")) {
|
|
91
103
|
const messages = t instanceof Y.Map ? t.get("messages") : void 0;
|
|
92
104
|
const ok = t instanceof Y.Map && isAnchor(t.get("anchor")) && ["open", "resolved"].includes(t.get("status")) && messages instanceof Y.Array && messages.length > 0 && messages.toArray().every((m) => isObject(m) && isString(m.id) && isString(m.at) && isString(m.body) && isWho(m.author));
|
|
@@ -108,9 +120,71 @@ var DocsService = class {
|
|
|
108
120
|
}
|
|
109
121
|
async catalog(id, author) {
|
|
110
122
|
await this.store.transaction(async (tx) => {
|
|
111
|
-
if (await tx.get(CATALOG, id) === null)
|
|
123
|
+
if (await tx.get(CATALOG, id) === null) {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const who = { kind: author.kind, name: author.name };
|
|
126
|
+
await tx.put(CATALOG, id, { createdBy: who, createdAt: now, updatedAt: now, updatedBy: who });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Record a save. The catalog is a side record that follows the blob, so a failure here never fails a save that worked:
|
|
132
|
+
* the caller would repeat a change that is not safe to repeat. A document with no catalog entry stays without one.
|
|
133
|
+
*/
|
|
134
|
+
async touch(id, author) {
|
|
135
|
+
if (!author) return;
|
|
136
|
+
try {
|
|
137
|
+
await this.store.transaction(async (tx) => {
|
|
138
|
+
const entry = await tx.get(CATALOG, id);
|
|
139
|
+
if (entry !== null) await tx.put(CATALOG, id, { ...entry, updatedAt: Date.now(), updatedBy: { kind: author.kind, name: author.name } });
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Every cataloged document, newest first, up to ${MAX_LISTED}. A damaged entry is skipped, so one bad entry never hides the list. */
|
|
145
|
+
async list() {
|
|
146
|
+
const found = [];
|
|
147
|
+
let cursor;
|
|
148
|
+
do {
|
|
149
|
+
const page = await this.store.query(CATALOG, { orderBy: { field: "createdAt", direction: "desc" }, limit: 100, cursor });
|
|
150
|
+
for (const { id, data } of page.items) {
|
|
151
|
+
const entry = isObject(data) ? data : {};
|
|
152
|
+
const { createdBy, createdAt } = entry;
|
|
153
|
+
if (!isWho(createdBy) || typeof createdAt !== "number") continue;
|
|
154
|
+
const by = entry.updatedBy;
|
|
155
|
+
if (by !== void 0 && !isWho(by)) continue;
|
|
156
|
+
found.push({ id, title: typeof entry.title === "string" && entry.title !== "" ? entry.title : id, createdBy, createdAt, updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : createdAt, updatedBy: by ?? createdBy });
|
|
157
|
+
}
|
|
158
|
+
cursor = page.nextCursor;
|
|
159
|
+
} while (cursor !== void 0 && found.length < MAX_LISTED);
|
|
160
|
+
return found.slice(0, MAX_LISTED);
|
|
161
|
+
}
|
|
162
|
+
/** Give the document a name. It does not change the text. */
|
|
163
|
+
async rename(id, title, author) {
|
|
164
|
+
const key = this.key(id);
|
|
165
|
+
const clean = title.trim();
|
|
166
|
+
if (clean === "" || [...clean].length > MAX_TITLE || /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/.test(clean)) {
|
|
167
|
+
throw new DocError("INVALID_INPUT", `a title is 1 to ${MAX_TITLE} characters of plain text`);
|
|
168
|
+
}
|
|
169
|
+
const found = await this.blobs.get(key);
|
|
170
|
+
if (!found) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
|
|
171
|
+
const who = { kind: author.kind, name: author.name };
|
|
172
|
+
const first = AgenticDoc.fromUpdate(found.data, this.options).edits()[0];
|
|
173
|
+
const created = first ? { createdBy: { kind: first.author.kind, name: first.author.name }, createdAt: Date.parse(first.at) } : { createdBy: who, createdAt: Date.now() };
|
|
174
|
+
await this.store.transaction(async (tx) => {
|
|
175
|
+
const entry = await tx.get(CATALOG, id);
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
await tx.put(CATALOG, id, entry === null ? { ...created, createdAt: Number.isFinite(created.createdAt) ? created.createdAt : now, title: clean, updatedAt: now, updatedBy: who } : { ...entry, title: clean, updatedAt: now, updatedBy: who });
|
|
112
178
|
});
|
|
113
179
|
}
|
|
180
|
+
/** Delete the document and its catalog entry. An agent needs the remove scope. A live room keeps its own copy of the text. */
|
|
181
|
+
async remove(id, author) {
|
|
182
|
+
const key = this.key(id);
|
|
183
|
+
if (!isPerson(author) && author.scopes?.includes(REMOVE_SCOPE) !== true) throw new DocError("FORBIDDEN", `an agent needs the ${REMOVE_SCOPE} scope to remove a document`);
|
|
184
|
+
if (!await this.blobs.head(key)) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
|
|
185
|
+
await this.store.delete(CATALOG, id);
|
|
186
|
+
await this.blobs.delete(key);
|
|
187
|
+
}
|
|
114
188
|
/** Who created the document and when, or null when it has no catalog entry (made before the catalog, or a failed catalog write). */
|
|
115
189
|
async info(id) {
|
|
116
190
|
this.key(id);
|
|
@@ -131,14 +205,17 @@ var DocsService = class {
|
|
|
131
205
|
* Load, run the change, and save only if nobody saved in between. When someone did, load again and
|
|
132
206
|
* run the change again, so a check such as "is it still pending" sees the new state.
|
|
133
207
|
*/
|
|
134
|
-
async write(id, run) {
|
|
208
|
+
async write(id, run, author) {
|
|
135
209
|
const key = this.key(id);
|
|
136
210
|
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
137
211
|
const found = await this.blobs.get(key);
|
|
138
212
|
if (!found) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
|
|
139
213
|
const doc = AgenticDoc.fromUpdate(found.data, this.options);
|
|
140
214
|
const result = run(doc);
|
|
141
|
-
if (await this.blobs.put(key, doc.encode(), { ifMatch: found.version }))
|
|
215
|
+
if (await this.blobs.put(key, doc.encode(), { ifMatch: found.version })) {
|
|
216
|
+
await this.touch(id, author);
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
142
219
|
}
|
|
143
220
|
throw new DocError("CONFLICT", `document "${id}" kept changing; try again`);
|
|
144
221
|
}
|