borgmcp 4.9.0 → 4.10.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.
@@ -0,0 +1,98 @@
1
+ import type { MessageTaxonomy } from 'borgmcp-shared/templates';
2
+ import { formatLogEntryMarkdown } from './regen-format.js';
3
+
4
+ // Small backlogs remain verbatim so normal wake triage is unchanged.
5
+ export const DIGEST_THRESHOLD = 50;
6
+ // The latest decisions stay in full while older activity is summarized.
7
+ export const DIGEST_TAIL = 25;
8
+ // One reattach cannot consume an unbounded amount of local memory or output.
9
+ export const DIGEST_FETCH_CAP = 2000;
10
+
11
+ interface ReadLogDigestInput {
12
+ entries: any[];
13
+ selfDroneId: string;
14
+ taxonomy: MessageTaxonomy | null | undefined;
15
+ droneById: Map<string, any>;
16
+ roleById: Map<string, any>;
17
+ tail: number;
18
+ capped: number;
19
+ }
20
+
21
+ export interface ReadLogDigest {
22
+ text: string;
23
+ tailEntries: any[];
24
+ omitted: number;
25
+ }
26
+
27
+ function senderLabel(
28
+ entry: any,
29
+ droneById: Map<string, any>,
30
+ roleById: Map<string, any>,
31
+ ): string {
32
+ const drone = droneById.get(entry.drone_id);
33
+ const role = drone ? roleById.get(drone.role_id) : undefined;
34
+ const label = drone?.label ?? entry.drone_label ?? '?';
35
+ const roleName = role?.name ?? entry.role_name ?? '?';
36
+ return `${label} (${roleName})`;
37
+ }
38
+
39
+ function messageClass(message: unknown, taxonomy: MessageTaxonomy | null | undefined): string {
40
+ if (typeof message !== 'string') return 'other';
41
+ for (const classDef of taxonomy ?? []) {
42
+ if (classDef.prefixes?.some((prefix) => message.startsWith(prefix))) return classDef.class;
43
+ }
44
+ return 'other';
45
+ }
46
+
47
+ function increment(counts: Map<string, number>, key: string): void {
48
+ counts.set(key, (counts.get(key) ?? 0) + 1);
49
+ }
50
+
51
+ export function buildReadLogDigest(input: ReadLogDigestInput): ReadLogDigest {
52
+ const tailEntries = input.entries.slice(-input.tail);
53
+ const omitted = input.entries.length - tailEntries.length;
54
+ const oldest = new Date(input.entries[0].created_at).toISOString();
55
+ const newest = new Date(input.entries[input.entries.length - 1].created_at).toISOString();
56
+ const senderCounts = new Map<string, number>();
57
+ const classCounts = new Map<string, number>();
58
+
59
+ for (const entry of input.entries) {
60
+ increment(senderCounts, senderLabel(entry, input.droneById, input.roleById));
61
+ increment(classCounts, messageClass(entry.message, input.taxonomy));
62
+ }
63
+
64
+ const lines = [
65
+ `Reattach digest — ${input.entries.length} unread entries from ${oldest} to ${newest}; ${omitted} older entries are summarized, not shown. Older directed entries may be superseded: confirm with the sender or borg_read-entry before acting.`,
66
+ ];
67
+ if (input.capped > 0) {
68
+ lines.push(`${input.capped} additional unread ${input.capped === 1 ? 'entry was' : 'entries were'} not covered because the ${DIGEST_FETCH_CAP}-entry fetch cap was reached.`);
69
+ }
70
+
71
+ lines.push('', '## Counts by sender');
72
+ for (const [sender, count] of senderCounts) lines.push(`- ${sender}: ${count}`);
73
+ lines.push('', '## Counts by message class');
74
+ for (const [className, count] of classCounts) lines.push(`- ${className}: ${count}`);
75
+
76
+ const directed = input.entries.slice(0, omitted).filter((entry) =>
77
+ entry.visibility === 'direct' &&
78
+ Array.isArray(entry.recipient_drone_ids) &&
79
+ entry.recipient_drone_ids.includes(input.selfDroneId)
80
+ );
81
+ if (directed.length > 0) {
82
+ lines.push('', '## Older entries directed to this seat');
83
+ for (const entry of directed) {
84
+ const ts = new Date(entry.created_at).toISOString();
85
+ const message = typeof entry.message === 'string'
86
+ ? entry.message.replace(/\s+/g, ' ').slice(0, 120)
87
+ : '';
88
+ lines.push(`[${ts}] [entry_id: ${entry.id}] ${senderLabel(entry, input.droneById, input.roleById)}: ${message}`);
89
+ }
90
+ }
91
+
92
+ lines.push('', `## Newest ${tailEntries.length} entries`);
93
+ for (const entry of tailEntries) {
94
+ lines.push(formatLogEntryMarkdown(entry, input.droneById, input.roleById));
95
+ }
96
+
97
+ return { text: lines.join('\n'), tailEntries, omitted };
98
+ }
@@ -98,6 +98,7 @@ import {
98
98
  getLocalServerCursor,
99
99
  type LocalServerCursor,
100
100
  } from './local-server-cursor.js';
101
+ import { DIGEST_FETCH_CAP, DIGEST_THRESHOLD } from './read-log-digest.js';
101
102
  import { readBoundedResponseBody } from './server-response.js';
102
103
  import { normalizeLogAudience, type LogAudience } from './direct-log.js';
103
104
  import { RoleSectionConflictError } from './local-manage-tool-result.js';
@@ -1093,7 +1094,16 @@ export async function readLog(
1093
1094
  unreadOnly?: boolean;
1094
1095
  serverTrustIdentity?: string;
1095
1096
  } = {}
1096
- ): Promise<{ entries: any[]; drones: any[]; roles: any[]; behind_by?: number; has_more?: boolean }> {
1097
+ ): Promise<{
1098
+ entries: any[];
1099
+ drones: any[];
1100
+ roles: any[];
1101
+ message_taxonomy?: MessageTaxonomy | null;
1102
+ behind_by?: number;
1103
+ has_more?: boolean;
1104
+ digest: boolean;
1105
+ capped: number;
1106
+ }> {
1097
1107
  const local = await localAuthorityContext(
1098
1108
  sessionToken,
1099
1109
  apiUrl,
@@ -1102,7 +1112,7 @@ export async function readLog(
1102
1112
  let cursor: LocalServerCursor | null = null;
1103
1113
  if (opts.unreadOnly) cursor = await getLocalServerCursor(localCursorBinding(local));
1104
1114
  if (opts.since !== undefined) cursor = await resolveLocalLogCursor(local, opts.since);
1105
- const page = await localReadLogPage(local, {
1115
+ let page = await localReadLogPage(local, {
1106
1116
  cursor,
1107
1117
  limit: opts.limit,
1108
1118
  // Keep the cursor payload stable across a lost response; do not re-read or
@@ -1112,13 +1122,37 @@ export async function readLog(
1112
1122
  if (opts.unreadOnly && page.cursor) {
1113
1123
  await advanceLocalServerCursor(localCursorBinding(local), page.cursor);
1114
1124
  }
1125
+ const entries = [...page.entries];
1126
+ const backlog = entries.length + (typeof page.behind_by === 'number' ? page.behind_by : 0);
1127
+ const digest = opts.unreadOnly === true && opts.since === undefined && backlog > DIGEST_THRESHOLD;
1128
+ if (digest) {
1129
+ while (page.has_more === true && entries.length < DIGEST_FETCH_CAP) {
1130
+ if (!page.cursor || page.entries.length === 0) {
1131
+ throw new ProtocolContractError('Unread log page reported more entries without advancing its cursor.');
1132
+ }
1133
+ page = await localReadLogPage(local, {
1134
+ cursor: page.cursor,
1135
+ limit: Math.min(500, DIGEST_FETCH_CAP - entries.length),
1136
+ retryMode: 'unread-cursor',
1137
+ });
1138
+ if (page.cursor) {
1139
+ await advanceLocalServerCursor(localCursorBinding(local), page.cursor);
1140
+ }
1141
+ entries.push(...page.entries);
1142
+ }
1143
+ }
1115
1144
  const composed = await localCubeComposition(local);
1116
1145
  return {
1117
- entries: page.entries,
1146
+ entries,
1118
1147
  drones: composed.drones,
1119
1148
  roles: composed.roles,
1149
+ message_taxonomy: composed.cube.message_taxonomy ?? null,
1120
1150
  behind_by: page.behind_by,
1121
1151
  has_more: page.has_more,
1152
+ digest,
1153
+ capped: digest && page.has_more === true && typeof page.behind_by === 'number'
1154
+ ? page.behind_by
1155
+ : 0,
1122
1156
  };
1123
1157
  }
1124
1158
 
@@ -220,7 +220,7 @@ const BASE_TOOL_MANIFEST: ToolManifestEntry[] = [
220
220
  "with the drone that wrote it and that drone's role. For wake triage, prefer " +
221
221
  '`unread_only=true` with a modest limit and drain until `has_more=false`; ' +
222
222
  'this reads oldest-unread-first from your server cursor and ' +
223
- 'advances the watermark so bursts are not skipped. Optional `since` is a strict-after ' +
223
+ 'advances the watermark so bursts are not skipped. A backlog above 50 returns a digest plus the newest 25 entries. Optional `since` is a strict-after ' +
224
224
  'cursor for explicit bounded reads only; do not use it with the same timestamp as a ' +
225
225
  'notification preview because it can skip the boundary entry. Use `borg_read-entry` ' +
226
226
  'to fetch one known entry without changing the unread cursor.',
@@ -915,6 +915,7 @@ export const TOOL_OUTPUT_SCHEMAS: Record<string, OutputSchema> = {
915
915
  entries: { type: 'array', items: LOG_ENTRY_OUTPUT },
916
916
  behind_by: { type: ['number', 'null'], description: 'Visible entries still unread after this read; null when the server did not report it.' },
917
917
  has_more: { type: 'boolean' },
918
+ omitted: { type: 'number', description: 'Older fetched entries summarized outside the structured entry tail; absent when digest mode was not used.' },
918
919
  },
919
920
  required: ['entries', 'behind_by', 'has_more'],
920
921
  },