@openmarket/rooms-client 0.1.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.
Files changed (87) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +25 -0
  3. package/dist/agent/armed-mode.d.ts +187 -0
  4. package/dist/agent/armed-mode.js +306 -0
  5. package/dist/agent/clients/common.d.ts +70 -0
  6. package/dist/agent/clients/common.js +46 -0
  7. package/dist/agent/lane-draft.d.ts +15 -0
  8. package/dist/agent/lane-draft.js +43 -0
  9. package/dist/agent/lane-prompts.d.ts +102 -0
  10. package/dist/agent/lane-prompts.js +120 -0
  11. package/dist/agent/library-context.d.ts +38 -0
  12. package/dist/agent/library-context.js +57 -0
  13. package/dist/agent/library-model.d.ts +41 -0
  14. package/dist/agent/library-model.js +173 -0
  15. package/dist/agent/room-context.d.ts +151 -0
  16. package/dist/agent/room-context.js +251 -0
  17. package/dist/agent/sidebar-model.d.ts +86 -0
  18. package/dist/agent/sidebar-model.js +162 -0
  19. package/dist/agent/topic-inbox.d.ts +119 -0
  20. package/dist/agent/topic-inbox.js +266 -0
  21. package/dist/agent/topic-view-batcher.d.ts +54 -0
  22. package/dist/agent/topic-view-batcher.js +115 -0
  23. package/dist/client.d.ts +421 -0
  24. package/dist/client.js +1428 -0
  25. package/dist/doc-reconcile.d.ts +100 -0
  26. package/dist/doc-reconcile.js +110 -0
  27. package/dist/doc-uri.d.ts +29 -0
  28. package/dist/doc-uri.js +55 -0
  29. package/dist/endpoints.d.ts +9 -0
  30. package/dist/endpoints.js +13 -0
  31. package/dist/jwt.d.ts +10 -0
  32. package/dist/jwt.js +51 -0
  33. package/dist/library-publisher.d.ts +52 -0
  34. package/dist/library-publisher.js +49 -0
  35. package/dist/merge3.d.ts +50 -0
  36. package/dist/merge3.js +280 -0
  37. package/dist/names.d.ts +6 -0
  38. package/dist/names.js +35 -0
  39. package/dist/shared/emoji-data.d.ts +22 -0
  40. package/dist/shared/emoji-data.js +8834 -0
  41. package/dist/shared/mentions.d.ts +6 -0
  42. package/dist/shared/mentions.js +32 -0
  43. package/dist/shared/rooms-protocol.d.ts +1183 -0
  44. package/dist/shared/rooms-protocol.js +160 -0
  45. package/dist/social-client.d.ts +361 -0
  46. package/dist/social-client.js +686 -0
  47. package/dist/social-types.d.ts +338 -0
  48. package/dist/social-types.js +1 -0
  49. package/dist/suggestion-attribution.d.ts +10 -0
  50. package/dist/suggestion-attribution.js +56 -0
  51. package/dist/topic-uri.d.ts +26 -0
  52. package/dist/topic-uri.js +40 -0
  53. package/dist/types.d.ts +2 -0
  54. package/dist/types.js +1 -0
  55. package/dist/version.d.ts +2 -0
  56. package/dist/version.js +2 -0
  57. package/dist/ws-client.d.ts +115 -0
  58. package/dist/ws-client.js +491 -0
  59. package/package.json +180 -0
  60. package/src/agent/armed-mode.ts +368 -0
  61. package/src/agent/clients/common.ts +91 -0
  62. package/src/agent/lane-draft.ts +47 -0
  63. package/src/agent/lane-prompts.ts +267 -0
  64. package/src/agent/library-context.ts +97 -0
  65. package/src/agent/library-model.ts +210 -0
  66. package/src/agent/room-context.ts +351 -0
  67. package/src/agent/sidebar-model.ts +235 -0
  68. package/src/agent/topic-inbox.ts +297 -0
  69. package/src/agent/topic-view-batcher.ts +134 -0
  70. package/src/client.ts +2331 -0
  71. package/src/doc-reconcile.ts +160 -0
  72. package/src/doc-uri.ts +83 -0
  73. package/src/endpoints.ts +14 -0
  74. package/src/jwt.ts +59 -0
  75. package/src/library-publisher.ts +93 -0
  76. package/src/merge3.ts +326 -0
  77. package/src/names.ts +44 -0
  78. package/src/shared/emoji-data.ts +8868 -0
  79. package/src/shared/mentions.ts +32 -0
  80. package/src/shared/rooms-protocol.ts +1339 -0
  81. package/src/social-client.ts +1287 -0
  82. package/src/social-types.ts +376 -0
  83. package/src/suggestion-attribution.ts +83 -0
  84. package/src/topic-uri.ts +64 -0
  85. package/src/types.ts +83 -0
  86. package/src/version.ts +2 -0
  87. package/src/ws-client.ts +611 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The ONE client reconcile model (phase 4 P4-RECONCILE). Every pending
3
+ * reconcile, whatever produced it, is the same shape: three full contents
4
+ * (base / mine / theirs), the merge3 regions where they genuinely diverge,
5
+ * and the server head rev the merge was computed against. Three sources
6
+ * feed it:
7
+ *
8
+ * - "publish-conflict": two working copies published; the second writer's
9
+ * publishDoc came back {status:"conflict"}. mine = the draft, theirs =
10
+ * the server head, base = the published content at the draft's baseRev.
11
+ * - "disk-edit": the operator's on-disk mirror copy collided with a landed
12
+ * head (the library conflict path re-pointed at this model). mine = the
13
+ * local file, theirs = the server head, base = the receipt revision.
14
+ * - "run-revert": undoing an agent run diverged from later edits. mine =
15
+ * the CURRENT head, theirs = the pre-run content the revert restores,
16
+ * base = the run's final content (tharamine returns all three).
17
+ *
18
+ * Resolution always rides an expected-head fence, never a blind write:
19
+ * publish-conflict and run-revert resolutions publish through the one
20
+ * checkpoint primitive CAS'd on `headRev`; disk-edit resolutions ride the
21
+ * existing applyResolution path with `expectedHeadRev: headRev`. Either
22
+ * way a head that moved after mediation returns conflict/stale DATA and
23
+ * the model refreshes via `refreshDocReconcile`; nothing is overwritten
24
+ * and no draft is lost.
25
+ *
26
+ * Pure module (merge3 + types only), browser-safe by construction: the
27
+ * chat GUI builds and resolves models client-side.
28
+ */
29
+ import { type Merge3Region, type MergeSkeletonPart } from "./merge3.js";
30
+ /** What produced a pending reconcile (labels and application route differ). */
31
+ export type DocReconcileKind = "publish-conflict" | "disk-edit" | "run-revert";
32
+ /** A per-region resolution: keep my side or take theirs. */
33
+ export type DocRegionPick = "mine" | "theirs";
34
+ export interface DocReconcileInput {
35
+ kind: DocReconcileKind;
36
+ docId: string;
37
+ path: string;
38
+ /**
39
+ * The shared ancestor of the two sides: the draft's baseRev content
40
+ * (publish-conflict), the mirror receipt revision (disk-edit), or the
41
+ * run's final content (run-revert). "" when unavailable: merge3 then
42
+ * degrades to one whole-document region, the side-by-side floor.
43
+ */
44
+ base: string;
45
+ /** My side: the working copy / local file, or the current head (run-revert). */
46
+ mine: string;
47
+ /** Their side: the server head, or the pre-run content (run-revert). */
48
+ theirs: string;
49
+ /** The server head rev the merge was computed against: the resolution fence. */
50
+ headRev: number;
51
+ /** Head author/note for the screen's explainer line (when known). */
52
+ theirsAuthor?: string | undefined;
53
+ theirsNote?: string | undefined;
54
+ /** run-revert: the run being undone. */
55
+ runId?: string | undefined;
56
+ }
57
+ export interface DocReconcileModel extends DocReconcileInput {
58
+ /** Diverging chunks (empty exactly when the merge is clean). */
59
+ regions: Merge3Region[];
60
+ /** Splice skeleton; null when the merge is clean (nothing to splice). */
61
+ skeleton: MergeSkeletonPart[] | null;
62
+ /** True when the two sides merge without mediation. */
63
+ clean: boolean;
64
+ /** The clean merge result ("" while regions exist). */
65
+ merged: string;
66
+ }
67
+ /**
68
+ * Which side of the model tracks the SERVER HEAD (the side that goes stale
69
+ * when the fence fails). For a run revert the head is "mine": the revert
70
+ * merges the pre-run content onto whatever the doc says now.
71
+ */
72
+ export declare function docReconcileHeadSide(kind: DocReconcileKind): DocRegionPick;
73
+ /** Build the model: one merge3 pass over the three contents. */
74
+ export declare function buildDocReconcile(input: DocReconcileInput): DocReconcileModel;
75
+ /**
76
+ * Turn per-region picks into the full resolved document. A clean model
77
+ * needs no picks (the merge IS the resolution); a diverging model requires
78
+ * exactly one pick per region, spliced through the merge3 skeleton so
79
+ * untouched text is never regenerated.
80
+ *
81
+ * The splice carries the picked LINE ARRAYS directly, never a joined
82
+ * string: spliceSkeleton's "" resolution means "drop the region" (a
83
+ * contract pinned for its other callers), so a picked side of [""] (one
84
+ * empty line, e.g. a separator blanked in place) would vanish through the
85
+ * string form. Here an empty array contributes nothing and [""] contributes
86
+ * exactly one empty line, so the applied document IS the picked bytes.
87
+ */
88
+ export declare function resolveDocReconcile(model: DocReconcileModel, picks: DocRegionPick[]): string;
89
+ /**
90
+ * Fence failure: the server head moved after the model was built. Re-point
91
+ * the head-tracking side at the fresh head and re-merge; every other side
92
+ * (the draft, the local file, the pre-run target) is carried over verbatim,
93
+ * so nothing of mine is lost by a refresh.
94
+ */
95
+ export declare function refreshDocReconcile(model: DocReconcileModel, head: {
96
+ rev: number;
97
+ content: string;
98
+ authorHandle?: string;
99
+ note?: string;
100
+ }): DocReconcileModel;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The ONE client reconcile model (phase 4 P4-RECONCILE). Every pending
3
+ * reconcile, whatever produced it, is the same shape: three full contents
4
+ * (base / mine / theirs), the merge3 regions where they genuinely diverge,
5
+ * and the server head rev the merge was computed against. Three sources
6
+ * feed it:
7
+ *
8
+ * - "publish-conflict": two working copies published; the second writer's
9
+ * publishDoc came back {status:"conflict"}. mine = the draft, theirs =
10
+ * the server head, base = the published content at the draft's baseRev.
11
+ * - "disk-edit": the operator's on-disk mirror copy collided with a landed
12
+ * head (the library conflict path re-pointed at this model). mine = the
13
+ * local file, theirs = the server head, base = the receipt revision.
14
+ * - "run-revert": undoing an agent run diverged from later edits. mine =
15
+ * the CURRENT head, theirs = the pre-run content the revert restores,
16
+ * base = the run's final content (tharamine returns all three).
17
+ *
18
+ * Resolution always rides an expected-head fence, never a blind write:
19
+ * publish-conflict and run-revert resolutions publish through the one
20
+ * checkpoint primitive CAS'd on `headRev`; disk-edit resolutions ride the
21
+ * existing applyResolution path with `expectedHeadRev: headRev`. Either
22
+ * way a head that moved after mediation returns conflict/stale DATA and
23
+ * the model refreshes via `refreshDocReconcile`; nothing is overwritten
24
+ * and no draft is lost.
25
+ *
26
+ * Pure module (merge3 + types only), browser-safe by construction: the
27
+ * chat GUI builds and resolves models client-side.
28
+ */
29
+ import { merge3 } from "./merge3.js";
30
+ /**
31
+ * Which side of the model tracks the SERVER HEAD (the side that goes stale
32
+ * when the fence fails). For a run revert the head is "mine": the revert
33
+ * merges the pre-run content onto whatever the doc says now.
34
+ */
35
+ export function docReconcileHeadSide(kind) {
36
+ return kind === "run-revert" ? "mine" : "theirs";
37
+ }
38
+ /** Build the model: one merge3 pass over the three contents. */
39
+ export function buildDocReconcile(input) {
40
+ const result = merge3(input.base, input.mine, input.theirs);
41
+ return {
42
+ ...input,
43
+ regions: result.regions,
44
+ skeleton: result.skeleton ?? null,
45
+ clean: result.clean,
46
+ merged: result.merged,
47
+ };
48
+ }
49
+ /**
50
+ * Turn per-region picks into the full resolved document. A clean model
51
+ * needs no picks (the merge IS the resolution); a diverging model requires
52
+ * exactly one pick per region, spliced through the merge3 skeleton so
53
+ * untouched text is never regenerated.
54
+ *
55
+ * The splice carries the picked LINE ARRAYS directly, never a joined
56
+ * string: spliceSkeleton's "" resolution means "drop the region" (a
57
+ * contract pinned for its other callers), so a picked side of [""] (one
58
+ * empty line, e.g. a separator blanked in place) would vanish through the
59
+ * string form. Here an empty array contributes nothing and [""] contributes
60
+ * exactly one empty line, so the applied document IS the picked bytes.
61
+ */
62
+ export function resolveDocReconcile(model, picks) {
63
+ if (model.clean)
64
+ return model.merged;
65
+ if (!model.skeleton)
66
+ throw new Error("doc-reconcile: diverging model without a skeleton");
67
+ if (picks.length !== model.regions.length) {
68
+ throw new Error(`doc-reconcile: ${model.regions.length} regions need ${model.regions.length} picks, got ${picks.length}`);
69
+ }
70
+ for (const [index, pick] of picks.entries()) {
71
+ if (pick !== "mine" && pick !== "theirs") {
72
+ throw new Error(`doc-reconcile: invalid pick for region ${index}`);
73
+ }
74
+ }
75
+ const lines = [];
76
+ for (const part of model.skeleton) {
77
+ if (part.kind === "lines") {
78
+ lines.push(...part.lines);
79
+ continue;
80
+ }
81
+ const region = model.regions[part.index];
82
+ const pick = picks[part.index];
83
+ if (!region || pick === undefined) {
84
+ throw new Error(`doc-reconcile: missing resolution for region ${part.index}`);
85
+ }
86
+ lines.push(...region[pick]);
87
+ }
88
+ return lines.join("\n");
89
+ }
90
+ /**
91
+ * Fence failure: the server head moved after the model was built. Re-point
92
+ * the head-tracking side at the fresh head and re-merge; every other side
93
+ * (the draft, the local file, the pre-run target) is carried over verbatim,
94
+ * so nothing of mine is lost by a refresh.
95
+ */
96
+ export function refreshDocReconcile(model, head) {
97
+ const side = docReconcileHeadSide(model.kind);
98
+ return buildDocReconcile({
99
+ kind: model.kind,
100
+ docId: model.docId,
101
+ path: model.path,
102
+ base: model.base,
103
+ mine: side === "mine" ? head.content : model.mine,
104
+ theirs: side === "theirs" ? head.content : model.theirs,
105
+ headRev: head.rev,
106
+ theirsAuthor: side === "theirs" ? (head.authorHandle ?? model.theirsAuthor) : model.theirsAuthor,
107
+ theirsNote: side === "theirs" ? (head.note ?? model.theirsNote) : model.theirsNote,
108
+ runId: model.runId,
109
+ });
110
+ }
@@ -0,0 +1,29 @@
1
+ import type { RoomDoc } from "./social-types.js";
2
+ /**
3
+ * om://doc URIs — how docs travel through chat.
4
+ *
5
+ * A share is plain text on the wire (`om://doc/<docId>@<rev>`), so it
6
+ * survives every transport (rooms, DMs, copy-paste, other clients) with no
7
+ * protocol changes. Clients that understand the scheme render it as a pill
8
+ * and can annotate liveness ("head moved") from their local library cache;
9
+ * clients that don't still show a usable, resolvable reference.
10
+ */
11
+ export interface DocUriRef {
12
+ docId: string;
13
+ rev?: number | undefined;
14
+ }
15
+ export declare function makeDocUri(docId: string, rev?: number): string;
16
+ export declare function parseDocUris(text: string): DocUriRef[];
17
+ export interface DocUriRenderContext {
18
+ /** Resolve a docId to its cached head (path + headRev), when known. */
19
+ lookup?: ((docId: string) => Pick<RoomDoc, "path" | "headRev"> | undefined) | undefined;
20
+ }
21
+ export declare function setDocUriLookup(lookup: DocUriRenderContext["lookup"]): void;
22
+ export declare function decorateDocUrisLive(text: string): string;
23
+ /**
24
+ * Replace om://doc URIs with a rendered pill. With a library cache lookup the
25
+ * pill shows the doc's path and a head receipt (`@r3` vs `@r3→r5` when the
26
+ * head has moved past the shared revision); without one it stays a compact
27
+ * reference that is still visibly a doc link.
28
+ */
29
+ export declare function decorateDocUris(text: string, context?: DocUriRenderContext): string;
@@ -0,0 +1,55 @@
1
+ import pc from "picocolors";
2
+ const DOC_URI_RE = /om:\/\/doc\/([A-Za-z0-9-]{8,64})(?:@(\d{1,9}))?/g;
3
+ export function makeDocUri(docId, rev) {
4
+ return `om://doc/${docId}${rev !== undefined ? `@${rev}` : ""}`;
5
+ }
6
+ export function parseDocUris(text) {
7
+ const refs = [];
8
+ for (const match of text.matchAll(DOC_URI_RE)) {
9
+ const docId = match[1];
10
+ if (!docId)
11
+ continue;
12
+ const rev = match[2] !== undefined ? Number.parseInt(match[2], 10) : undefined;
13
+ refs.push({ docId, ...(rev !== undefined ? { rev } : {}) });
14
+ }
15
+ return refs;
16
+ }
17
+ /**
18
+ * Process-wide lookup used by the tape renderers. The chat TUI registers its
19
+ * LibraryModel here once; every renderer (room tape, DMs, the legacy REPL)
20
+ * then annotates pills with live head receipts without threading the cache
21
+ * through a dozen render signatures. Renderers work unregistered too — pills
22
+ * just lose the path/receipt annotation.
23
+ */
24
+ let activeLookup;
25
+ export function setDocUriLookup(lookup) {
26
+ activeLookup = lookup;
27
+ }
28
+ export function decorateDocUrisLive(text) {
29
+ if (!text.includes("om://doc/"))
30
+ return text;
31
+ return decorateDocUris(text, { lookup: activeLookup });
32
+ }
33
+ /**
34
+ * Replace om://doc URIs with a rendered pill. With a library cache lookup the
35
+ * pill shows the doc's path and a head receipt (`@r3` vs `@r3→r5` when the
36
+ * head has moved past the shared revision); without one it stays a compact
37
+ * reference that is still visibly a doc link.
38
+ */
39
+ export function decorateDocUris(text, context = {}) {
40
+ return text.replace(DOC_URI_RE, (_raw, docId, revRaw) => {
41
+ const rev = revRaw !== undefined ? Number.parseInt(revRaw, 10) : undefined;
42
+ const known = context.lookup?.(docId);
43
+ if (!known) {
44
+ const revLabel = rev !== undefined ? pc.dim(`@r${rev}`) : "";
45
+ return `${pc.cyan(`⧉ doc:${docId.slice(0, 8)}`)}${revLabel}`;
46
+ }
47
+ const moved = rev !== undefined && known.headRev > rev;
48
+ const revLabel = rev === undefined
49
+ ? pc.dim(`@r${known.headRev}`)
50
+ : moved
51
+ ? pc.yellow(`@r${rev}→r${known.headRev}`)
52
+ : pc.dim(`@r${rev}`);
53
+ return `${pc.cyan(`⧉ ${known.path}`)}${revLabel}`;
54
+ });
55
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Default production endpoints, in their own module so the browser-importable
3
+ * client graph never touches constants.ts (which reads node:os/path for
4
+ * OM_HOME). Env overrides live in envString(), which no-ops in browsers.
5
+ */
6
+ export declare const ROOMS_WS_URL = "wss://chat.kiyotaka.ai/ws/rooms";
7
+ export declare const ROOM_CHAT_API_URL = "https://chat.kiyotaka.ai/chat";
8
+ /** process.env reader that is safe in browsers (returns undefined). */
9
+ export declare function envString(name: string): string | undefined;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Default production endpoints, in their own module so the browser-importable
3
+ * client graph never touches constants.ts (which reads node:os/path for
4
+ * OM_HOME). Env overrides live in envString(), which no-ops in browsers.
5
+ */
6
+ export const ROOMS_WS_URL = "wss://chat.kiyotaka.ai/ws/rooms";
7
+ export const ROOM_CHAT_API_URL = "https://chat.kiyotaka.ai/chat";
8
+ /** process.env reader that is safe in browsers (returns undefined). */
9
+ export function envString(name) {
10
+ const proc = globalThis.process;
11
+ const value = proc?.env?.[name];
12
+ return typeof value === "string" ? value : undefined;
13
+ }
package/dist/jwt.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { type RoomAuthSuccessPayload } from "./types.js";
2
+ /**
3
+ * Derive a room auth identity from an OM API key (a JWT) for the HTTP fallback
4
+ * path, where there is no server AUTH_SUCCESS frame. The claims are read
5
+ * best-effort; an unparseable key yields a synthesized placeholder identity
6
+ * rather than throwing.
7
+ */
8
+ export declare function authFromApiKey(apiKey: string): RoomAuthSuccessPayload;
9
+ export declare function decodeJwtPayload(apiKey: string): Record<string, unknown>;
10
+ export declare function stringClaim(payload: Record<string, unknown>, key: string): string | undefined;
package/dist/jwt.js ADDED
@@ -0,0 +1,51 @@
1
+ // Global crypto/atob: works in Bun, Node 18+, and browsers.
2
+ const randomUUID = () => globalThis.crypto.randomUUID();
3
+ import { SUPPORTED_ROOM_PROTOCOL_VERSION } from "./types.js";
4
+ /**
5
+ * Derive a room auth identity from an OM API key (a JWT) for the HTTP fallback
6
+ * path, where there is no server AUTH_SUCCESS frame. The claims are read
7
+ * best-effort; an unparseable key yields a synthesized placeholder identity
8
+ * rather than throwing.
9
+ */
10
+ export function authFromApiKey(apiKey) {
11
+ const payload = decodeJwtPayload(apiKey);
12
+ const userId = stringClaim(payload, "sub") || stringClaim(payload, "id") || stringClaim(payload, "jti") || "";
13
+ const username = stringClaim(payload, "username") ||
14
+ stringClaim(payload, "handle") ||
15
+ (userId ? `om-${userId.slice(-6)}` : "om-user");
16
+ return {
17
+ userId,
18
+ username,
19
+ handle: username,
20
+ connectionId: `http-${randomUUID()}`,
21
+ protocolVersion: SUPPORTED_ROOM_PROTOCOL_VERSION,
22
+ };
23
+ }
24
+ export function decodeJwtPayload(apiKey) {
25
+ const [, payload] = apiKey.split(".");
26
+ if (!payload)
27
+ return {};
28
+ try {
29
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
30
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
31
+ const parsed = JSON.parse(base64Utf8(padded));
32
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
33
+ ? parsed
34
+ : {};
35
+ }
36
+ catch {
37
+ return {};
38
+ }
39
+ }
40
+ export function stringClaim(payload, key) {
41
+ const value = payload[key];
42
+ return typeof value === "string" && value.length > 0 ? value : undefined;
43
+ }
44
+ function base64Utf8(value) {
45
+ const nodeBuffer = globalThis.Buffer;
46
+ if (nodeBuffer)
47
+ return nodeBuffer.from(value, "base64").toString("utf8");
48
+ const binary = atob(value);
49
+ const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
50
+ return new TextDecoder().decode(bytes);
51
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The checkpoint publish primitive (phase 4 spine). Every surface that turns
3
+ * a private working copy into a shared revision (GUI editor, agent proposals,
4
+ * run undo, and eventually the disk mirror) publishes through `publishDoc`
5
+ * and nothing else.
6
+ *
7
+ * Contract (binding for the later work packages):
8
+ * - One write path. `publishDoc` routes through the existing `updateRoomDoc`
9
+ * CAS call, which lands in tharamine's `commitContentChange` transaction.
10
+ * There is no second writer here and none may be added: new publish
11
+ * behavior extends this function, not the wire.
12
+ * - Publishing from a stale base never overwrites. A moved head comes back
13
+ * as `{status: "conflict"}` DATA carrying the server's current head
14
+ * (rev + content when available) so the reconcile surface can mediate;
15
+ * it is never a thrown string. Non-conflict transport/HTTP failures still
16
+ * throw (`RoomSocialError`), because they are errors, not outcomes.
17
+ * - Every publish is attributed. `source` defaults to "gui" (the working-copy
18
+ * editor is the primary caller); agent, mirror, and CLI callers pass their
19
+ * own source plus `credentialId`/`runId` when they have them. Absent rider
20
+ * fields stay absent on the revision.
21
+ */
22
+ import type { RoomDoc, RoomDocAttribution, RoomDocConflictHead, RoomSocialConfig } from "./social-types.js";
23
+ export interface PublishDocInput extends RoomDocAttribution {
24
+ docId: string;
25
+ /** The published revision the working copy started from (the CAS base). */
26
+ baseRev: number;
27
+ /** The full document to land as the checkpoint. */
28
+ content: string;
29
+ /** Optional human change note (absent = server auto-derives "+A -R lines"). */
30
+ note?: string | undefined;
31
+ /** Display flag: the write was authored by an agent acting as the operator. */
32
+ asAgent?: boolean | undefined;
33
+ }
34
+ export type PublishDocResult = {
35
+ status: "published";
36
+ doc: RoomDoc;
37
+ /** The revision the checkpoint landed as. */
38
+ rev: number;
39
+ /** True when the content matched the head byte-for-byte (no revision). */
40
+ unchanged: boolean;
41
+ } | {
42
+ status: "conflict";
43
+ /** The stale base the caller published from. */
44
+ baseRev: number;
45
+ /**
46
+ * The server's current head (content included) for the mediation
47
+ * surface. Null only when even the fallback head re-fetch failed.
48
+ */
49
+ head: RoomDocConflictHead | null;
50
+ };
51
+ /** Publish a working copy as one attributed revision, CAS'd on `baseRev`. */
52
+ export declare function publishDoc(config: RoomSocialConfig, input: PublishDocInput): Promise<PublishDocResult>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The checkpoint publish primitive (phase 4 spine). Every surface that turns
3
+ * a private working copy into a shared revision (GUI editor, agent proposals,
4
+ * run undo, and eventually the disk mirror) publishes through `publishDoc`
5
+ * and nothing else.
6
+ *
7
+ * Contract (binding for the later work packages):
8
+ * - One write path. `publishDoc` routes through the existing `updateRoomDoc`
9
+ * CAS call, which lands in tharamine's `commitContentChange` transaction.
10
+ * There is no second writer here and none may be added: new publish
11
+ * behavior extends this function, not the wire.
12
+ * - Publishing from a stale base never overwrites. A moved head comes back
13
+ * as `{status: "conflict"}` DATA carrying the server's current head
14
+ * (rev + content when available) so the reconcile surface can mediate;
15
+ * it is never a thrown string. Non-conflict transport/HTTP failures still
16
+ * throw (`RoomSocialError`), because they are errors, not outcomes.
17
+ * - Every publish is attributed. `source` defaults to "gui" (the working-copy
18
+ * editor is the primary caller); agent, mirror, and CLI callers pass their
19
+ * own source plus `credentialId`/`runId` when they have them. Absent rider
20
+ * fields stay absent on the revision.
21
+ */
22
+ import { RoomDocConflictError, updateRoomDoc } from "./social-client.js";
23
+ const DEFAULT_SOURCE = "gui";
24
+ /** Publish a working copy as one attributed revision, CAS'd on `baseRev`. */
25
+ export async function publishDoc(config, input) {
26
+ try {
27
+ const result = await updateRoomDoc(config, input.docId, {
28
+ baseRev: input.baseRev,
29
+ content: input.content,
30
+ source: input.source ?? DEFAULT_SOURCE,
31
+ ...(input.note !== undefined ? { note: input.note } : {}),
32
+ ...(input.asAgent !== undefined ? { asAgent: input.asAgent } : {}),
33
+ ...(input.credentialId !== undefined ? { credentialId: input.credentialId } : {}),
34
+ ...(input.runId !== undefined ? { runId: input.runId } : {}),
35
+ });
36
+ return {
37
+ status: "published",
38
+ doc: result.doc,
39
+ rev: result.rev,
40
+ unchanged: result.unchanged,
41
+ };
42
+ }
43
+ catch (error) {
44
+ if (error instanceof RoomDocConflictError) {
45
+ return { status: "conflict", baseRev: input.baseRev, head: error.head };
46
+ }
47
+ throw error;
48
+ }
49
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Line-based three-way merge (diff3 semantics) for the library mirror's
3
+ * agent-mediated conflict resolution. Pure, no deps, byte-faithful: content
4
+ * is split on "\n" only and re-joined identically, so CRLF and no-trailing-
5
+ * newline round-trip exactly.
6
+ *
7
+ * The contract callers rely on:
8
+ * - `clean: true` means every collision region was one-sided or identical on
9
+ * both sides; `merged` is the full merged document.
10
+ * - `clean: false` means at least one region genuinely diverged (or an input
11
+ * bound tripped); `merged` is "" so no caller can write a half-merge, and
12
+ * `regions` carries the diverging chunks for a mediator to look at.
13
+ * - Identical changes on BOTH sides merge clean. This is load-bearing: a
14
+ * crash between the merged-file write and the manifest save re-runs the
15
+ * merge with mine already containing theirs' lines, and it must converge.
16
+ * - Touching-or-overlapping changes from the two sides conflict (git's
17
+ * conservative rule); insertions at the same anchor conflict.
18
+ */
19
+ export interface Merge3Region {
20
+ base: string[];
21
+ mine: string[];
22
+ theirs: string[];
23
+ }
24
+ /** The not-clean document as resolved line chunks with placeholders where
25
+ * the conflict regions go: everything a region-scoped mediator needs to
26
+ * splice resolved regions back without regenerating the whole document. */
27
+ export type MergeSkeletonPart = {
28
+ kind: "lines";
29
+ lines: string[];
30
+ } | {
31
+ kind: "region";
32
+ index: number;
33
+ };
34
+ export interface Merge3Result {
35
+ clean: boolean;
36
+ merged: string;
37
+ regions: Merge3Region[];
38
+ /** Present exactly when regions exist. */
39
+ skeleton?: MergeSkeletonPart[];
40
+ }
41
+ /** Reassemble a skeleton with one resolved text per region ("" = the region
42
+ * resolves to nothing). The counterpart of a region-scoped mediation. */
43
+ export declare function spliceSkeleton(skeleton: MergeSkeletonPart[], resolved: string[]): string;
44
+ export interface Merge3Options {
45
+ /** Per-side line cap; above it merge3 bails to not-clean. */
46
+ maxLines?: number;
47
+ /** Myers edit-distance bound per diff; above it merge3 bails to not-clean. */
48
+ maxEditDistance?: number;
49
+ }
50
+ export declare function merge3(base: string, mine: string, theirs: string, options?: Merge3Options): Merge3Result;