borgmcp 5.5.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +7 -4
  2. package/dist/claude.d.ts.map +1 -1
  3. package/dist/claude.js +12 -0
  4. package/dist/claude.js.map +1 -1
  5. package/dist/cli-help.d.ts.map +1 -1
  6. package/dist/cli-help.js +16 -6
  7. package/dist/cli-help.js.map +1 -1
  8. package/dist/docs-sections.js +1 -1
  9. package/dist/docs-sections.js.map +1 -1
  10. package/dist/hermes-plugin-install.d.ts +19 -0
  11. package/dist/hermes-plugin-install.d.ts.map +1 -0
  12. package/dist/hermes-plugin-install.js +131 -0
  13. package/dist/hermes-plugin-install.js.map +1 -0
  14. package/dist/local-server-cursor.d.ts +9 -0
  15. package/dist/local-server-cursor.d.ts.map +1 -1
  16. package/dist/local-server-cursor.js +50 -1
  17. package/dist/local-server-cursor.js.map +1 -1
  18. package/dist/log-stream.d.ts +13 -0
  19. package/dist/log-stream.d.ts.map +1 -1
  20. package/dist/log-stream.js +45 -13
  21. package/dist/log-stream.js.map +1 -1
  22. package/dist/remote-client.d.ts +3 -0
  23. package/dist/remote-client.d.ts.map +1 -1
  24. package/dist/remote-client.js +7 -1
  25. package/dist/remote-client.js.map +1 -1
  26. package/dist/representative-cmd.d.ts +11 -0
  27. package/dist/representative-cmd.d.ts.map +1 -1
  28. package/dist/representative-cmd.js +48 -15
  29. package/dist/representative-cmd.js.map +1 -1
  30. package/dist/representative-core.d.ts +53 -7
  31. package/dist/representative-core.d.ts.map +1 -1
  32. package/dist/representative-core.js +229 -43
  33. package/dist/representative-core.js.map +1 -1
  34. package/dist/representative-delivery-store.d.ts +79 -0
  35. package/dist/representative-delivery-store.d.ts.map +1 -0
  36. package/dist/representative-delivery-store.js +233 -0
  37. package/dist/representative-delivery-store.js.map +1 -0
  38. package/dist/representative-listener-store.d.ts +46 -0
  39. package/dist/representative-listener-store.d.ts.map +1 -0
  40. package/dist/representative-listener-store.js +119 -0
  41. package/dist/representative-listener-store.js.map +1 -0
  42. package/dist/representative-listener.d.ts +32 -0
  43. package/dist/representative-listener.d.ts.map +1 -0
  44. package/dist/representative-listener.js +293 -0
  45. package/dist/representative-listener.js.map +1 -0
  46. package/dist/representative-mcp.d.ts +10 -3
  47. package/dist/representative-mcp.d.ts.map +1 -1
  48. package/dist/representative-mcp.js +48 -19
  49. package/dist/representative-mcp.js.map +1 -1
  50. package/dist/representative-store.d.ts +7 -0
  51. package/dist/representative-store.d.ts.map +1 -1
  52. package/dist/representative-store.js +17 -2
  53. package/dist/representative-store.js.map +1 -1
  54. package/dist/seat-probe.d.ts +1 -0
  55. package/dist/seat-probe.d.ts.map +1 -1
  56. package/dist/seat-probe.js +1 -1
  57. package/dist/seat-probe.js.map +1 -1
  58. package/dist/server-trust.d.ts +10 -0
  59. package/dist/server-trust.d.ts.map +1 -1
  60. package/dist/server-trust.js +23 -6
  61. package/dist/server-trust.js.map +1 -1
  62. package/dist/stream-owner.d.ts.map +1 -1
  63. package/dist/stream-owner.js +32 -3
  64. package/dist/stream-owner.js.map +1 -1
  65. package/docs/HUMAN_REPRESENTATIVE.md +310 -36
  66. package/hermes-plugin/borg-representative-push/__init__.py +743 -0
  67. package/hermes-plugin/borg-representative-push/plugin.yaml +39 -0
  68. package/package.json +3 -1
  69. package/src/claude.ts +12 -0
  70. package/src/cli-help.ts +16 -6
  71. package/src/docs-sections.ts +1 -1
  72. package/src/hermes-plugin-install.ts +147 -0
  73. package/src/local-server-cursor.ts +45 -1
  74. package/src/log-stream.ts +47 -14
  75. package/src/remote-client.ts +9 -1
  76. package/src/representative-cmd.ts +52 -20
  77. package/src/representative-core.ts +255 -46
  78. package/src/representative-delivery-store.ts +235 -0
  79. package/src/representative-listener-store.ts +115 -0
  80. package/src/representative-listener.ts +215 -0
  81. package/src/representative-mcp.ts +58 -17
  82. package/src/representative-store.ts +18 -2
  83. package/src/seat-probe.ts +1 -1
  84. package/src/server-trust.ts +25 -6
  85. package/src/stream-owner.ts +34 -2
@@ -29,6 +29,7 @@ import {
29
29
  } from './representative-core.js';
30
30
  import {
31
31
  RepresentativeStoreError,
32
+ bindingFingerprint,
32
33
  representativeRecoveryCommand,
33
34
  type RepresentativeBinding,
34
35
  type RepresentativeStore,
@@ -41,7 +42,9 @@ export const DEFAULT_REPRESENTATIVE_ROLE = 'hermes-representative';
41
42
  export type RepresentativeCommand =
42
43
  | { action: 'prepare'; coordinator: string; role: string; rebind: boolean; worktreeName?: string; host?: string }
43
44
  | { action: 'status'; worktree?: string }
44
- | { action: 'mcp'; worktree?: string };
45
+ | { action: 'mcp'; worktree?: string }
46
+ | { action: 'listen'; worktree?: string; replayAfter?: string }
47
+ | { action: 'hermes-plugin-install'; hermesHome?: string; force: boolean };
45
48
 
46
49
  export type ParsedRepresentativeArgs =
47
50
  | { ok: true; command: RepresentativeCommand }
@@ -62,12 +65,13 @@ export interface RepresentativeCmdDeps {
62
65
 
63
66
  export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresentativeArgs {
64
67
  const [action, ...rest] = args;
65
- if (action !== 'prepare' && action !== 'status' && action !== 'mcp') {
66
- return { ok: false, error: 'expected one of: prepare, status, mcp' };
68
+ if (action === 'hermes-plugin') return parseHermesPluginArgs(rest);
69
+ if (action !== 'prepare' && action !== 'status' && action !== 'mcp' && action !== 'listen') {
70
+ return { ok: false, error: 'expected one of: prepare, status, mcp, listen, hermes-plugin' };
67
71
  }
68
72
  const values: Record<string, string> = {};
69
73
  let rebind = false;
70
- const valueFlags = action === 'prepare' ? ['--coordinator', '--role', '--worktree', '--host'] : ['--worktree'];
74
+ const valueFlags = action === 'prepare' ? ['--coordinator', '--role', '--worktree', '--host'] : action === 'listen' ? ['--worktree', '--replay-after'] : ['--worktree'];
71
75
  for (let i = 0; i < rest.length; i += 1) {
72
76
  const arg = rest[i];
73
77
  if (action === 'prepare' && arg === '--rebind') {
@@ -91,7 +95,11 @@ export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresen
91
95
  if (worktree !== undefined && !isAbsolute(worktree)) {
92
96
  return { ok: false, error: '--worktree must be an absolute path to the representative worktree' };
93
97
  }
94
- return { ok: true, command: { action, ...(worktree ? { worktree } : {}) } };
98
+ const replayAfter = values['--replay-after'];
99
+ if (replayAfter && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(replayAfter)) {
100
+ return { ok: false, error: '--replay-after must be an entry UUID' };
101
+ }
102
+ return { ok: true, command: { action, ...(worktree ? { worktree } : {}), ...(replayAfter ? { replayAfter } : {}) } };
95
103
  }
96
104
  const coordinator = values['--coordinator'];
97
105
  if (!coordinator) {
@@ -118,6 +126,30 @@ export function parseRepresentativeArgs(args: readonly string[]): ParsedRepresen
118
126
  };
119
127
  }
120
128
 
129
+ function parseHermesPluginArgs(args: readonly string[]): ParsedRepresentativeArgs {
130
+ const [subcommand, ...rest] = args;
131
+ if (subcommand !== 'install') return { ok: false, error: 'expected: hermes-plugin install [--hermes-home <path>] [--force]' };
132
+ let hermesHome: string | undefined;
133
+ let force = false;
134
+ for (let i = 0; i < rest.length; i += 1) {
135
+ const arg = rest[i];
136
+ if (arg === '--force') {
137
+ force = true;
138
+ } else if (arg === '--hermes-home') {
139
+ const next = rest[i + 1];
140
+ if (typeof next !== 'string' || next.length === 0 || next.startsWith('-')) {
141
+ return { ok: false, error: '--hermes-home requires a value' };
142
+ }
143
+ if (!isAbsolute(next)) return { ok: false, error: '--hermes-home must be an absolute path' };
144
+ hermesHome = next;
145
+ i += 1;
146
+ } else {
147
+ return { ok: false, error: `unknown argument: ${arg}. Supported: --hermes-home, --force` };
148
+ }
149
+ }
150
+ return { ok: true, command: { action: 'hermes-plugin-install', force, ...(hermesHome ? { hermesHome } : {}) } };
151
+ }
152
+
121
153
  function canonicalWorktree(path: string, deps: Pick<RepresentativeCmdDeps, 'findProjectRoot'>): string {
122
154
  let real = resolve(path);
123
155
  try {
@@ -267,7 +299,9 @@ export async function runRepresentativeStatus(
267
299
  const status = await representativeStatus(ctx);
268
300
  const { representativeOwnership } = await import('./representative-owner.js');
269
301
  const ownership = await representativeOwnership(ctx.binding);
270
- deps.stdout(`${JSON.stringify({ ...status, ownership }, null, 2)}\n`);
302
+ const { representativeListenerStatus } = await import('./representative-listener.js');
303
+ const listener = await representativeListenerStatus(ctx.binding);
304
+ deps.stdout(`${JSON.stringify({ ...status, ownership, listener }, null, 2)}\n`);
271
305
  return status.connected ? 0 : 1;
272
306
  } catch (error) {
273
307
  deps.stderr(`◼ borg representative status: ${describeError(error)}\n`);
@@ -303,20 +337,9 @@ export async function runRepresentativeMcp(
303
337
  heartbeatIntervalMs: io.heartbeatIntervalMs,
304
338
  ...(io.stdin ? { stdin: io.stdin } : {}),
305
339
  ...(io.stdout ? { stdout: io.stdout } : {}),
306
- context: async () => {
307
- const ctx = await resolveRepresentativeContext(worktree, deps);
308
- if (
309
- ctx.binding.cubeId !== pinned.cubeId ||
310
- ctx.binding.coordinatorDroneId !== pinned.coordinatorDroneId ||
311
- ctx.binding.representativeDroneId !== pinned.representativeDroneId
312
- ) {
313
- throw new RepresentativeError(
314
- 'BINDING_MISMATCH',
315
- 'The operator changed this connection\'s cube or Coordinator while it was running. Restart the MCP server to use the new binding.',
316
- );
317
- }
318
- return ctx;
319
- },
340
+ // The full generation, so any rebind (same selection included) is refused.
341
+ pinnedFingerprint: bindingFingerprint(pinned),
342
+ context: () => resolveRepresentativeContext(worktree, deps),
320
343
  });
321
344
  const stdin = io.stdin ?? process.stdin;
322
345
  stdin.once('end', () => { void served.close(); });
@@ -361,3 +384,12 @@ export async function buildDefaultRepresentativeDeps(): Promise<RepresentativeCm
361
384
  stderr: (text) => { process.stderr.write(text); },
362
385
  };
363
386
  }
387
+
388
+ export async function runRepresentativeListen(
389
+ command: Extract<RepresentativeCommand, { action: 'listen' }>,
390
+ deps: RepresentativeCmdDeps,
391
+ options: import('./representative-listener.js').ListenerOptions = {},
392
+ ): Promise<number> {
393
+ const { runListener } = await import('./representative-listener.js');
394
+ return runListener(command, deps, options);
395
+ }
@@ -25,21 +25,20 @@ import {
25
25
  BorgServerTrustError,
26
26
  BorgServerUnreachableError,
27
27
  } from './server-errors.js';
28
- import { representativeRecoveryCommand, isRepresentativeUuid, type RepresentativeBinding, type RepresentativeStore } from './representative-store.js';
28
+ import { bindingFingerprint, representativeRecoveryCommand, isRepresentativeUuid, type RepresentativeBinding, type RepresentativeStore } from './representative-store.js';
29
+ import { comparePoints, createDeliveryStore, type DeliveryState } from './representative-delivery-store.js';
30
+ import { readPrivateLocalServerCursor, type LocalServerCursor } from './local-server-cursor.js';
31
+ import { validatePrivateDirectory } from './representative-listener-store.js';
32
+ import { borgConfigRoot } from './private-root.js';
29
33
 
30
34
  const UUID_SCAN_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
31
35
  export const REPRESENTATIVE_MESSAGE_LIMIT_BYTES = 3000;
32
36
 
33
37
  export const REPRESENTATIVE_DELIVERY_NOTE =
34
- 'Explicit send/read round trips only: this connection has no background wake or push delivery. ' +
35
- 'Coordinator replies are seen only when borg_representative-read is called. Each read consumes the unread view of ' +
36
- 'everything it fetched. The host must persist each read result before relaying it, map request_id to its conversation, ' +
37
- 'route replies by in_reply_to, and hold replies with an unknown or missing request_id for the human. An already-read ' +
38
- 'reply cannot be retrieved through this connection. The first send/read/ack takes an exclusive process lease for this ' +
39
- 'representative drone; other processes refuse those calls without ledger, cursor or network activity. Status stays ' +
40
- 'read-only and reports ownership. After owner exit, death or lease expiry another process can take over. ' +
41
- 'A process that loses its lease refuses further calls until restarted. Ownership does not route conversations inside the host. ' +
42
- 'A local lease cannot cancel an in-flight request; retry an ambiguous send with its original request_id.';
38
+ 'read returns undelivered replies without consuming them; persist, route by in_reply_to (unknown: hold for the ' +
39
+ 'human), then deliver through the last persisted entry_id. ack only notifies the Coordinator. A separate ' +
40
+ 'borg representative listen process emits body-free wake hints. One process at a time owns send/read/deliver/ack; ' +
41
+ 'status is read-only. Stop routing if binding_fingerprint changes.';
43
42
 
44
43
  export type RepresentativeErrorCode =
45
44
  | typeof ErrorCode.INVALID_INPUT
@@ -59,7 +58,9 @@ export type RepresentativeErrorCode =
59
58
  | 'COORDINATOR_UNAVAILABLE'
60
59
  | 'REPRESENTATIVE_ROLE_NOT_PERMITTED'
61
60
  | 'REPRESENTATIVE_ROLE_MISMATCH'
62
- | 'NOT_A_COORDINATOR_REPLY';
61
+ | 'NOT_A_COORDINATOR_REPLY'
62
+ | 'REPRESENTATIVE_DELIVER_UNKNOWN_ENTRY'
63
+ | 'REPRESENTATIVE_READ_OVERSIZE';
63
64
 
64
65
  export interface RepresentativeErrorDetails {
65
66
  owner?: import('./stream-owner.js').StreamOwnershipSnapshot;
@@ -67,6 +68,9 @@ export interface RepresentativeErrorDetails {
67
68
  cause_code?: string;
68
69
  cause_message?: string;
69
70
  recovery?: string;
71
+ entry_id?: string;
72
+ measured_bytes?: number;
73
+ bound?: number;
70
74
  }
71
75
 
72
76
  export class RepresentativeError extends Error {
@@ -99,11 +103,12 @@ export interface RepresentativeBackend {
99
103
  unreachableRecipients?: Array<{ id: string; label: string }>;
100
104
  }>;
101
105
  /**
102
- * Drains THIS seat's own unread cursor only (no other drone's cursor exists
103
- * here) — the WHOLE returned page, including entries the caller then filters
104
- * out. `limit` is a page-size hint: the client's digest mode may return more.
106
+ * One stateless page of the cube log strictly after an exact (created_at, id)
107
+ * cursor, ascending. Reads and advances no unread cursor; never digest mode.
105
108
  */
106
- readUnread(limit?: number, continuationGuard?: () => Promise<void>): Promise<{ entries: LogEntry[]; has_more?: boolean }>;
109
+ readAfter(cursor: LocalServerCursor | null, limit: number, continuationGuard?: () => Promise<void>): Promise<{ entries: LogEntry[]; has_more?: boolean }>;
110
+ /** This seat's client-owned unread cursor, read only; the slice 2 migration input. */
111
+ unreadCursor(): Promise<LocalServerCursor | null>;
107
112
  readEntry(entryId: string): Promise<{ entry: LogEntry }>;
108
113
  ack(entryId: string): Promise<void>;
109
114
  }
@@ -113,6 +118,8 @@ export interface RepresentativeContext {
113
118
  backend: RepresentativeBackend;
114
119
  store: RepresentativeStore;
115
120
  now?: () => Date;
121
+ /** Checked immediately before each private delivery-state write (the tools lease in MCP). */
122
+ guard?: () => Promise<void>;
116
123
  }
117
124
 
118
125
  /** Real backend: the existing seat-scoped client calls for one hydrated seat. */
@@ -126,8 +133,20 @@ export async function createSeatBackend(active: ActiveCube): Promise<Representat
126
133
  client.appendLog(active.sessionToken, active.apiUrl, message, {
127
134
  to, postId, transportRetry: false, serverTrustIdentity: trust,
128
135
  }),
129
- readUnread: (limit, continuationGuard) =>
130
- client.readLog(active.sessionToken, active.apiUrl, { unreadOnly: true, limit, serverTrustIdentity: trust, continuationGuard }),
136
+ readAfter: (cursor, limit, continuationGuard) =>
137
+ client.readLog(active.sessionToken, active.apiUrl, { cursor, limit, serverTrustIdentity: trust, continuationGuard }),
138
+ // Migration input only: an unsafe private root or cursor file is no cursor,
139
+ // so the checkpoint starts empty and replays instead of trusting it.
140
+ unreadCursor: async () => {
141
+ try {
142
+ if (!await validatePrivateDirectory(borgConfigRoot(), false)) return null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ return readPrivateLocalServerCursor({
147
+ origin: active.apiUrl, trustIdentity: trust!, cubeId: active.cubeId, droneId: active.droneId,
148
+ });
149
+ },
131
150
  readEntry: (entryId) =>
132
151
  client.readLogEntry(active.sessionToken, active.apiUrl, { entry_id: entryId }, trust),
133
152
  ack: (entryId) => client.ackLogEntry(active.sessionToken, active.apiUrl, entryId, 'ack', trust),
@@ -184,7 +203,7 @@ export function resolveCoordinator(
184
203
  }
185
204
 
186
205
  /** Re-prove, against the live cube, that this seat and the bound Coordinator are still the bound ones. */
187
- async function verifyLiveBinding(ctx: RepresentativeContext): Promise<{ coordinator: RosterDrone; self: RosterDrone }> {
206
+ export async function verifyLiveBinding(ctx: RepresentativeContext): Promise<{ coordinator: RosterDrone; self: RosterDrone }> {
188
207
  const { binding, backend } = ctx;
189
208
  const me = await backend.whoami();
190
209
  if (me.cube_id !== binding.cubeId || me.drone_id !== binding.representativeDroneId) {
@@ -402,6 +421,13 @@ function classifyAppendFailure(error: unknown, binding: RepresentativeBinding):
402
421
  export async function sendRepresentativeMessage(
403
422
  ctx: RepresentativeContext,
404
423
  raw: unknown,
424
+ ): Promise<RepresentativeSendResult & { binding_fingerprint: string }> {
425
+ return { ...await sendOnce(ctx, raw), binding_fingerprint: bindingFingerprint(ctx.binding) };
426
+ }
427
+
428
+ async function sendOnce(
429
+ ctx: RepresentativeContext,
430
+ raw: unknown,
405
431
  ): Promise<RepresentativeSendResult> {
406
432
  const input = validateSendInput(raw);
407
433
  const { binding, store } = ctx;
@@ -574,6 +600,10 @@ export interface RepresentativeReply {
574
600
  /** A ledger request id quoted in the reply text. Textual correlation only. */
575
601
  in_reply_to: string | null;
576
602
  message: string;
603
+ /** This entry alone exceeds max_bytes; it is returned whole and alone. */
604
+ oversize?: true;
605
+ /** Oversize and still above the envelope bound: citations were reduced to ids. */
606
+ documents_reduced?: true;
577
607
  }
578
608
 
579
609
  function isAddressedCoordinatorEntry(binding: RepresentativeBinding, entry: LogEntry): 'direct' | 'broadcast' | null {
@@ -584,48 +614,215 @@ function isAddressedCoordinatorEntry(binding: RepresentativeBinding, entry: LogE
584
614
  return 'broadcast';
585
615
  }
586
616
 
617
+ /** The exact text an MCP tool result carries; `max_bytes` measures this. */
618
+ export function serializeRepresentativeResult(body: unknown): string {
619
+ return JSON.stringify(body, null, 2);
620
+ }
621
+
622
+ const READ_SCAN_PAGE = 500;
623
+ /**
624
+ * A serialized read result never exceeds max(max_bytes, this). An oversize
625
+ * entry is returned alone; if it still exceeds the bound, its citations are
626
+ * reduced to ids. Message text is never cut: the server caps a post (4096
627
+ * bytes by default), so the reduced entry fits.
628
+ */
629
+ export const REPRESENTATIVE_ENVELOPE_FLOOR = 16384;
630
+
631
+ /**
632
+ * Run `use` with this generation's delivery state. On the first call for a
633
+ * generation, `use` runs alone in the per-seat queue with the proposed start,
634
+ * and nothing is written until it calls `commit`: a read refused as oversize
635
+ * leaves no tombstone and no checkpoint behind.
636
+ */
637
+ async function withDeliveryState<T>(
638
+ ctx: RepresentativeContext,
639
+ use: (state: DeliveryState, commit: () => Promise<void>) => Promise<T>,
640
+ ): Promise<T> {
641
+ const store = createDeliveryStore(ctx.binding);
642
+ const saved = await store.load();
643
+ if (saved) {
644
+ // A checkpoint always implies an upgraded seat; restore a lost tombstone.
645
+ if (!await store.migrated()) await store.markMigrated(ctx.guard);
646
+ return use(saved, async () => {});
647
+ }
648
+ return store.initialize(async () => {
649
+ const again = await store.load();
650
+ if (again) return use(again, async () => {});
651
+ // The seat already upgraded (a tombstone or a sibling generation exists):
652
+ // this generation (rebind, new Coordinator, or a removed invalid checkpoint)
653
+ // starts empty and replays its addressed history. Nothing on disk is ever
654
+ // read back as a position, and the legacy cursor is never imported again.
655
+ const upgraded = await store.migrated() || await store.otherGenerationExists();
656
+ // One-time upgrade: start where the pre-checkpoint destructive read left
657
+ // the unread view. The cursor is used only in memory.
658
+ const cursor = upgraded ? null : await ctx.backend.unreadCursor();
659
+ return use({ checkpoint: cursor, readThrough: cursor, returned: [] }, async () => {
660
+ // The tombstone is created exclusively first, so an interruption before
661
+ // the checkpoint replays (duplicates the host dedupes). An initializer in
662
+ // another process that won the create (excluded by the tools lease in
663
+ // practice) makes this one start empty.
664
+ const start = !upgraded && await store.markMigrated(ctx.guard) ? cursor : null;
665
+ if (upgraded) await store.markMigrated(ctx.guard);
666
+ await store.advance({ checkpoint: start, readThrough: start }, ctx.guard);
667
+ });
668
+ });
669
+ }
670
+
671
+ const checkpointView = (point: LocalServerCursor | null) =>
672
+ ({ entry_id: point?.id ?? null, created_at: point?.created_at ?? null });
673
+
587
674
  export async function readRepresentativeReplies(
588
675
  ctx: RepresentativeContext,
589
676
  raw: unknown,
590
- ): Promise<{ replies: RepresentativeReply[]; ignored_entries: number; has_more: boolean; delivery: string }> {
677
+ ): Promise<{
678
+ replies: RepresentativeReply[]; checkpoint: { entry_id: string | null; created_at: string | null };
679
+ has_more: boolean; ignored_entries: number; binding_fingerprint: string; delivery: string;
680
+ }> {
591
681
  const input = (raw ?? {}) as Record<string, unknown>;
592
- const unknown = Object.keys(input).filter((key) => key !== 'include_broadcast' && key !== 'limit' && input[key] !== undefined);
682
+ const allowed = ['include_broadcast', 'limit', 'max_bytes'];
683
+ const unknown = Object.keys(input).filter((key) => !allowed.includes(key) && input[key] !== undefined);
593
684
  if (unknown.length > 0) throw new RepresentativeError(ErrorCode.INVALID_INPUT, `Unsupported field(s): ${unknown.join(', ')}.`);
594
685
  if (input.include_broadcast !== undefined && typeof input.include_broadcast !== 'boolean') {
595
686
  throw new RepresentativeError(ErrorCode.INVALID_INPUT, 'include_broadcast must be a boolean.');
596
687
  }
597
- if (input.limit !== undefined && (!Number.isInteger(input.limit) || (input.limit as number) < 1 || (input.limit as number) > 200)) {
598
- throw new RepresentativeError(ErrorCode.INVALID_INPUT, 'limit must be an integer from 1 to 200.');
599
- }
688
+ const bounded = (name: string, min: number, max: number, fallback: number) => {
689
+ const value = input[name];
690
+ if (value === undefined) return fallback;
691
+ if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) {
692
+ throw new RepresentativeError(ErrorCode.INVALID_INPUT, `${name} must be an integer from ${min} to ${max}.`);
693
+ }
694
+ return value as number;
695
+ };
696
+ const limit = bounded('limit', 1, 50, 10);
697
+ const maxBytes = bounded('max_bytes', 4096, 60000, 32768);
600
698
  await verifyLiveBinding(ctx);
601
- const page = await ctx.backend.readUnread(input.limit as number | undefined);
699
+ return withDeliveryState(ctx, async (state, commit) => {
602
700
  const requestIds = await ctx.store.transactRequests(ctx.binding.worktree, (records) =>
603
701
  new Set(records.map((record) => record.requestId)));
604
- const replies: RepresentativeReply[] = [];
605
- for (const entry of page.entries) {
606
- const addressed = isAddressedCoordinatorEntry(ctx.binding, entry);
607
- if (addressed === null || (addressed === 'broadcast' && input.include_broadcast !== true)) continue;
608
- const quoted = (entry.message.match(UUID_SCAN_RE) ?? []).map((id) => id.toLowerCase());
609
- replies.push({
610
- entry_id: entry.id,
611
- created_at: entry.created_at,
612
- from_drone_id: ctx.binding.coordinatorDroneId,
613
- from_label: ctx.binding.coordinatorLabel,
614
- addressed,
615
- in_reply_to: quoted.find((id) => requestIds.has(id)) ?? null,
616
- message: entry.message,
617
- ...(entry.documents?.length ? {
618
- documents: entry.documents,
619
- document_delivery: 'Document bodies are not included and cannot be fetched through this connection. Ask the Coordinator to provide the content through a supported channel.',
620
- } : {}),
621
- });
702
+
703
+ // Deliberate ceiling: every read scans the cube log from the checkpoint,
704
+ // including entries not addressed here, so an undelivered backlog costs a
705
+ // growing scan. Upgrade path: a separate scan hint that deliver advances.
706
+ // One addressed entry beyond `limit` is collected only to answer has_more.
707
+ const candidates: Array<{ reply: RepresentativeReply; point: LocalServerCursor; ignoredBefore: number }> = [];
708
+ let ignored = 0;
709
+ let cursor = state.checkpoint;
710
+ let last = state.checkpoint;
711
+ scan: for (;;) {
712
+ const page = await ctx.backend.readAfter(cursor, READ_SCAN_PAGE);
713
+ for (const entry of page.entries) {
714
+ const point = { id: entry.id, created_at: entry.created_at };
715
+ // Client-side (created_at, id) filter: never repeat or regress.
716
+ if (comparePoints(point, last) <= 0) continue;
717
+ last = point;
718
+ const addressed = isAddressedCoordinatorEntry(ctx.binding, entry);
719
+ if (addressed === null || (addressed === 'broadcast' && input.include_broadcast !== true)) { ignored += 1; continue; }
720
+ const quoted = (entry.message.match(UUID_SCAN_RE) ?? []).map((id) => id.toLowerCase());
721
+ candidates.push({ point, ignoredBefore: ignored, reply: {
722
+ entry_id: entry.id,
723
+ created_at: entry.created_at,
724
+ from_drone_id: ctx.binding.coordinatorDroneId,
725
+ from_label: ctx.binding.coordinatorLabel,
726
+ addressed,
727
+ in_reply_to: quoted.find((id) => requestIds.has(id)) ?? null,
728
+ message: entry.message,
729
+ ...(entry.documents?.length ? {
730
+ documents: entry.documents,
731
+ document_delivery: 'Document bodies are not included and cannot be fetched through this connection. Ask the Coordinator to provide the content through a supported channel.',
732
+ } : {}),
733
+ } });
734
+ if (candidates.length > limit) break scan;
735
+ }
736
+ const tail = page.entries.at(-1);
737
+ if (!page.has_more || !tail) break;
738
+ cursor = { id: tail.id, created_at: tail.created_at };
739
+ }
740
+
741
+ const result = (count: number, oversize = false) => {
742
+ const taken = candidates.slice(0, count);
743
+ const replies: RepresentativeReply[] = taken.map(({ reply }, index) =>
744
+ (oversize && index === 0 ? { ...reply, oversize: true as const } : reply));
745
+ return {
746
+ replies,
747
+ checkpoint: checkpointView(state.checkpoint),
748
+ has_more: candidates.length > count,
749
+ ignored_entries: count < candidates.length ? candidates[count].ignoredBefore : ignored,
750
+ binding_fingerprint: bindingFingerprint(ctx.binding),
751
+ delivery: REPRESENTATIVE_DELIVERY_NOTE,
752
+ };
753
+ };
754
+ // Measure the real serialized result; entries are whole or omitted.
755
+ let count = 0;
756
+ while (count < Math.min(limit, candidates.length) &&
757
+ Buffer.byteLength(serializeRepresentativeResult(result(count + 1))) <= maxBytes) count += 1;
758
+ const oversize = count === 0 && candidates.length > 0;
759
+ let final = result(oversize ? 1 : count, oversize);
760
+ const bound = Math.max(maxBytes, REPRESENTATIVE_ENVELOPE_FLOOR);
761
+ if (oversize && final.replies[0].documents?.length &&
762
+ Buffer.byteLength(serializeRepresentativeResult(final)) > bound) {
763
+ const reply = final.replies[0];
764
+ final = { ...final, replies: [{
765
+ ...reply, documents: reply.documents!.map(({ id }) => ({ id }) as DocumentCitation), documents_reduced: true as const,
766
+ }] };
767
+ }
768
+ const measured = Buffer.byteLength(serializeRepresentativeResult(final));
769
+ if (oversize && measured > bound) {
770
+ // Nothing is cut or overrun, and nothing moves: no content, no deliver.
771
+ throw new RepresentativeError('REPRESENTATIVE_READ_OVERSIZE',
772
+ `The next reply does not fit ${bound} bytes even alone with its citations reduced to ids. Nothing was read or ` +
773
+ 'advanced. Raise max_bytes (up to 60000); beyond that the operator must reduce the server post limit.',
774
+ { entry_id: final.replies[0].entry_id, measured_bytes: measured, bound });
775
+ }
776
+ await commit();
777
+ const window = candidates.slice(0, final.replies.length).map(({ point }) => point);
778
+ if (window.length > 0) {
779
+ // The deliver fence and membership widen before the caller sees the entries.
780
+ await createDeliveryStore(ctx.binding).advance({ readThrough: window.at(-1)!, returned: window }, ctx.guard);
781
+ }
782
+ return final;
783
+ });
784
+ }
785
+
786
+ export async function deliverRepresentativeReplies(
787
+ ctx: RepresentativeContext,
788
+ raw: unknown,
789
+ ): Promise<{ checkpoint: { entry_id: string | null; created_at: string | null }; advanced: boolean; binding_fingerprint: string }> {
790
+ const input = (raw ?? {}) as Record<string, unknown>;
791
+ const unknown = Object.keys(input).filter((key) => key !== 'through');
792
+ if (unknown.length > 0 || !isRepresentativeUuid(input.through)) {
793
+ throw new RepresentativeError(ErrorCode.INVALID_INPUT, 'through must be the full UUID of a reply returned by borg_representative-read.');
794
+ }
795
+ const through = input.through;
796
+ await verifyLiveBinding(ctx);
797
+ return withDeliveryState(ctx, async (state, commit) => {
798
+ await commit();
799
+ const outside = () => new RepresentativeError('REPRESENTATIVE_DELIVER_UNKNOWN_ENTRY',
800
+ 'That entry is not in the current read window: deliver only a reply that borg_representative-read returned. Nothing changed.');
801
+ let entry: LogEntry;
802
+ try {
803
+ ({ entry } = await ctx.backend.readEntry(through));
804
+ } catch (error) {
805
+ if ((error as { status?: unknown })?.status === 404) throw outside();
806
+ throw error;
622
807
  }
808
+ if (entry.id !== through || isAddressedCoordinatorEntry(ctx.binding, entry) === null) throw outside();
809
+ const point = { id: entry.id, created_at: entry.created_at };
810
+ const fingerprint = bindingFingerprint(ctx.binding);
811
+ if (state.checkpoint && comparePoints(point, state.checkpoint) <= 0) {
812
+ return { checkpoint: checkpointView(state.checkpoint), advanced: false, binding_fingerprint: fingerprint };
813
+ }
814
+ // Membership of the full tuple, not the range or the id alone: only an entry
815
+ // a read actually returned, as the server reports it now, inside the window.
816
+ if (!state.returned.some((returned) => returned.id === point.id && returned.created_at === point.created_at) ||
817
+ state.readThrough === null || comparePoints(point, state.readThrough) > 0) throw outside();
818
+ const { before, after } = await createDeliveryStore(ctx.binding).advance({ checkpoint: point }, ctx.guard);
623
819
  return {
624
- replies,
625
- ignored_entries: page.entries.length - replies.length,
626
- has_more: page.has_more === true,
627
- delivery: REPRESENTATIVE_DELIVERY_NOTE,
820
+ checkpoint: checkpointView(after.checkpoint),
821
+ // The serialized transition, not this call's earlier snapshot.
822
+ advanced: comparePoints(after.checkpoint!, before?.checkpoint ?? null) > 0,
823
+ binding_fingerprint: fingerprint,
628
824
  };
825
+ });
629
826
  }
630
827
 
631
828
  export async function ackRepresentativeReply(
@@ -661,6 +858,9 @@ export async function representativeStatus(ctx: RepresentativeContext): Promise<
661
858
  unresolved_requests: Array<{ request_id: string; state: string; kind: string; updated_at: string }>;
662
859
  delivery: string;
663
860
  authority: string;
861
+ binding_fingerprint: string;
862
+ checkpoint_problem?: { code: string; message: string };
863
+ envelope_floor: number;
664
864
  }> {
665
865
  const { binding } = ctx;
666
866
  let problem: { code: string; message: string } | undefined;
@@ -672,6 +872,12 @@ export async function representativeStatus(ctx: RepresentativeContext): Promise<
672
872
  message: error instanceof Error ? error.message : 'Unknown error',
673
873
  };
674
874
  }
875
+ let checkpointProblem: { code: string; message: string } | undefined;
876
+ try {
877
+ await createDeliveryStore(binding).load();
878
+ } catch (error) {
879
+ checkpointProblem = { code: (error as { code?: string }).code ?? 'BACKEND_ERROR', message: error instanceof Error ? error.message : 'Unknown error' };
880
+ }
675
881
  const unresolved = (await ctx.store.readRequests(binding.worktree))
676
882
  .filter((record) => record.state === 'pending' || record.state === 'ambiguous')
677
883
  .map((record) => ({
@@ -695,5 +901,8 @@ export async function representativeStatus(ctx: RepresentativeContext): Promise<
695
901
  authority:
696
902
  'Borg records these messages as posts from the representative drone. The user-authorized / model-advice label is this ' +
697
903
  'connection\'s own attribution and is not verified or enforced by the Borg server.',
904
+ binding_fingerprint: bindingFingerprint(binding),
905
+ ...(checkpointProblem ? { checkpoint_problem: checkpointProblem } : {}),
906
+ envelope_floor: REPRESENTATIVE_ENVELOPE_FLOOR,
698
907
  };
699
908
  }