@1presence/bridge 0.20.0 → 0.21.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,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.makeBridgeAccumulator = makeBridgeAccumulator;
4
+ exports.postSaveTurn = postSaveTurn;
5
+ function makeBridgeAccumulator() {
6
+ const state = {
7
+ assistantText: '',
8
+ toolCalls: [],
9
+ toolResults: {},
10
+ };
11
+ let textEmitted = false;
12
+ let turnTextEmitted = false;
13
+ function appendText(text) {
14
+ if (textEmitted && !turnTextEmitted)
15
+ state.assistantText += '\n\n';
16
+ state.assistantText += text;
17
+ turnTextEmitted = true;
18
+ textEmitted = true;
19
+ }
20
+ return {
21
+ consume(event) {
22
+ const type = event['type'];
23
+ if (type === 'text') {
24
+ const t = event['text'];
25
+ if (t)
26
+ appendText(t);
27
+ return;
28
+ }
29
+ if (type === 'assistant') {
30
+ // New API turn — reset the per-turn flag so the next text emission
31
+ // gets a `\n\n` separator iff text was already emitted in a prior turn.
32
+ turnTextEmitted = false;
33
+ const msg = event['message'];
34
+ const content = msg?.['content'];
35
+ if (!Array.isArray(content))
36
+ return;
37
+ for (const block of content) {
38
+ if (block['type'] === 'text') {
39
+ const t = block['text'];
40
+ if (t)
41
+ appendText(t);
42
+ }
43
+ else if (block['type'] === 'tool_use') {
44
+ const id = block['id'];
45
+ const name = block['name'];
46
+ const input = block['input'] ?? {};
47
+ if (!id || !name)
48
+ continue;
49
+ const bareName = name.replace(/^mcp__1presence__/, '');
50
+ state.toolCalls.push({ id, name: bareName, input });
51
+ if (bareName === 'set_conversation_title') {
52
+ const raw = String(input['title'] ?? '').trim();
53
+ if (raw)
54
+ state.title = raw.charAt(0).toUpperCase() + raw.slice(1);
55
+ }
56
+ }
57
+ }
58
+ return;
59
+ }
60
+ if (type === 'user') {
61
+ const msg = event['message'];
62
+ const content = msg?.['content'];
63
+ if (!Array.isArray(content))
64
+ return;
65
+ for (const block of content) {
66
+ if (block['type'] !== 'tool_result')
67
+ continue;
68
+ const id = block['tool_use_id'];
69
+ const result = block['content'];
70
+ if (!id)
71
+ continue;
72
+ state.toolResults[id] = typeof result === 'string' ? result : JSON.stringify(result);
73
+ }
74
+ return;
75
+ }
76
+ },
77
+ state() {
78
+ return state;
79
+ },
80
+ };
81
+ }
82
+ async function postSaveTurn(gatewayHttp, token, record) {
83
+ let res;
84
+ try {
85
+ res = await fetch(`${gatewayHttp}/bridge/save-turn`, {
86
+ method: 'POST',
87
+ headers: {
88
+ 'Content-Type': 'application/json',
89
+ 'Authorization': `Bearer ${token}`,
90
+ },
91
+ body: JSON.stringify({
92
+ sessionId: record.sessionId,
93
+ conversationId: record.conversationId,
94
+ userMessage: record.userMessage,
95
+ assistantText: record.assistantText,
96
+ toolCalls: record.toolCalls,
97
+ toolResults: record.toolResults,
98
+ ...(record.title ? { title: record.title } : {}),
99
+ ...(record.usage ? { usage: record.usage } : {}),
100
+ }),
101
+ });
102
+ }
103
+ catch (err) {
104
+ return { ok: false, finalized: false, status: 0, error: err.message };
105
+ }
106
+ if (!res.ok) {
107
+ const body = await res.text().catch(() => '');
108
+ return { ok: false, finalized: false, status: res.status, error: body.slice(0, 200) };
109
+ }
110
+ const data = await res.json().catch(() => ({}));
111
+ return { ok: true, finalized: data.finalized === true, status: res.status };
112
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ const auth_1 = require("./auth");
12
12
  const claude_1 = require("./claude");
13
13
  const config_1 = require("./config");
14
14
  const update_1 = require("./update");
15
+ const accumulator_1 = require("./accumulator");
16
+ const outbox_1 = require("./outbox");
15
17
  const package_json_1 = require("../package.json");
16
18
  // Published tarballs don't ship src/, so this fires only when running the
17
19
  // dist build from a live workspace checkout. Catches the trap where editing
@@ -161,6 +163,46 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
161
163
  return;
162
164
  }
163
165
  let responding = false;
166
+ const accumulator = (0, accumulator_1.makeBridgeAccumulator)();
167
+ const startedAt = Date.now();
168
+ const turnSessionId = sessionId ?? conversationId; // gateway always supplies one; defensive fallback
169
+ function buildSpoolRecord(usage, model) {
170
+ const s = accumulator.state();
171
+ return {
172
+ sessionId: turnSessionId,
173
+ conversationId,
174
+ userMessage: text,
175
+ assistantText: s.assistantText,
176
+ toolCalls: s.toolCalls,
177
+ toolResults: s.toolResults,
178
+ ...(s.title ? { title: s.title } : {}),
179
+ usage: usage
180
+ ? { ...usage, ...(model ? { model } : {}) }
181
+ : null,
182
+ startedAt,
183
+ finalizedAt: Date.now(),
184
+ };
185
+ }
186
+ async function finalizeAndPost(record) {
187
+ // Spool BEFORE the network call so a crash between here and the POST
188
+ // ack is recoverable by drain-on-startup. The gateway dedupes on
189
+ // conversationId, so a replay is idempotent.
190
+ try {
191
+ (0, outbox_1.writeSpool)(record);
192
+ }
193
+ catch (err) {
194
+ console.warn(`[bridge] spool write failed: ${err.message}`);
195
+ }
196
+ const result = await (0, accumulator_1.postSaveTurn)(GATEWAY_HTTP, activeAuth.token, record);
197
+ if (result.ok) {
198
+ (0, outbox_1.deleteSpool)(record.conversationId);
199
+ }
200
+ else {
201
+ // Leave the spool file in place — next startup or next successful
202
+ // POST opportunity will retry. Quietly log so users aren't alarmed.
203
+ console.warn(`[bridge] save-turn POST failed (${result.status}): ${result.error ?? 'unknown'} — kept on disk for retry`);
204
+ }
205
+ }
164
206
  (0, claude_1.spawnClaude)({
165
207
  conversationId,
166
208
  presenceSessionId: sessionId,
@@ -170,6 +212,7 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
170
212
  clientCapabilities,
171
213
  syncedFolders,
172
214
  onEvent: (event) => {
215
+ accumulator.consume(event);
173
216
  if (!responding && event['type'] === 'assistant') {
174
217
  responding = true;
175
218
  console.log(`[${new Date().toLocaleTimeString()}] ◐ responding…`);
@@ -186,6 +229,7 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
186
229
  parts.push(costStr);
187
230
  const suffix = parts.length ? ` ${parts.join(' ')}` : '';
188
231
  console.log(`[${new Date().toLocaleTimeString()}] ✓ done${suffix}`);
232
+ const mapped = toBridgeUsage(usage);
189
233
  if (currentWs?.readyState === ws_1.default.OPEN) {
190
234
  currentWs.send(JSON.stringify({
191
235
  type: 'done',
@@ -193,34 +237,60 @@ async function handleMessage(conversationId, text, sessionId, auth, vaultFileOpe
193
237
  messageCount,
194
238
  costUsd,
195
239
  model,
196
- usage: usage ? {
197
- inputTokens: usage.input_tokens,
198
- outputTokens: usage.output_tokens,
199
- cacheReadTokens: usage.cache_read_input_tokens,
200
- cacheCreationTokens: usage.cache_creation_input_tokens,
201
- } : null,
240
+ usage: mapped,
202
241
  }));
203
242
  }
243
+ // HTTP fallback runs unconditionally — gateway dedupes against WS path.
244
+ void finalizeAndPost(buildSpoolRecord(mapped, model));
204
245
  },
205
246
  onError: (message, usage, model) => {
206
247
  console.error(`[${new Date().toLocaleTimeString()}] ✗ ${message}`);
248
+ const mapped = toBridgeUsage(usage);
207
249
  if (currentWs?.readyState === ws_1.default.OPEN) {
208
250
  currentWs.send(JSON.stringify({
209
251
  type: 'error',
210
252
  conversationId,
211
253
  message,
212
254
  model,
213
- usage: usage ? {
214
- inputTokens: usage.input_tokens,
215
- outputTokens: usage.output_tokens,
216
- cacheReadTokens: usage.cache_read_input_tokens,
217
- cacheCreationTokens: usage.cache_creation_input_tokens,
218
- } : null,
255
+ usage: mapped,
219
256
  }));
220
257
  }
258
+ void finalizeAndPost(buildSpoolRecord(mapped, model));
221
259
  },
222
260
  });
223
261
  }
262
+ // ─── Usage shape adapter ──────────────────────────────────────────────────────
263
+ function toBridgeUsage(usage) {
264
+ if (!usage)
265
+ return null;
266
+ return {
267
+ inputTokens: usage.input_tokens,
268
+ outputTokens: usage.output_tokens,
269
+ cacheReadTokens: usage.cache_read_input_tokens,
270
+ cacheCreationTokens: usage.cache_creation_input_tokens,
271
+ };
272
+ }
273
+ // ─── Outbox drain ─────────────────────────────────────────────────────────────
274
+ //
275
+ // On bridge startup and on every successful reconnect, replay any spool
276
+ // records that didn't get a successful POST ack last time. The gateway
277
+ // dedupes on conversationId — if it already saved via the WS path, the
278
+ // reply is finalized=false and we still delete the spool.
279
+ async function drainOutbox(auth) {
280
+ const records = (0, outbox_1.listSpool)();
281
+ if (records.length === 0)
282
+ return;
283
+ console.log(`[bridge] draining ${records.length} pending save record${records.length === 1 ? '' : 's'}…`);
284
+ for (const record of records) {
285
+ const result = await (0, accumulator_1.postSaveTurn)(GATEWAY_HTTP, auth.token, record);
286
+ if (result.ok) {
287
+ (0, outbox_1.deleteSpool)(record.conversationId);
288
+ }
289
+ else {
290
+ console.warn(`[bridge] drain POST failed (${result.status}): ${result.error ?? 'unknown'} — leaving on disk`);
291
+ }
292
+ }
293
+ }
224
294
  // ─── WebSocket connection ─────────────────────────────────────────────────────
225
295
  // Application-level heartbeat — avoids relying on WebSocket control frames (ping/pong),
226
296
  // which some proxies (GKE LB) may not forward reliably.
@@ -260,6 +330,12 @@ function connect(auth, retryDelay = 1000) {
260
330
  retryDelay = 1000;
261
331
  console.log('✓ Bridge connected. Local Mode active on all your devices.\n');
262
332
  startPing();
333
+ // Drain any save records left behind by an earlier crashed/dropped session.
334
+ // Fire-and-forget — the network is up, the gateway is reachable, and we
335
+ // don't want to block message handling on this.
336
+ if (currentAuth) {
337
+ drainOutbox(currentAuth).catch(err => console.warn(`[bridge] drain failed: ${err.message}`));
338
+ }
263
339
  });
264
340
  ws.on('message', (raw) => {
265
341
  let msg;
@@ -283,8 +359,7 @@ function connect(auth, retryDelay = 1000) {
283
359
  return;
284
360
  const { conversationId, text, sessionId, vaultFileOpen, clientCapabilities, syncedFolders } = msg;
285
361
  const ts = new Date().toLocaleTimeString();
286
- const preview = text.length > 80 ? text.slice(0, 80) + '…' : text;
287
- console.log(`[${ts}] ▶ ${preview}`);
362
+ console.log(`[${ts}] ${text}`);
288
363
  handleMessage(conversationId, text, sessionId ?? null, auth, vaultFileOpen, clientCapabilities, syncedFolders).catch((err) => {
289
364
  console.error(`[bridge] handleMessage error: ${err.message}`);
290
365
  });
package/dist/outbox.js ADDED
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.writeSpool = writeSpool;
4
+ exports.deleteSpool = deleteSpool;
5
+ exports.listSpool = listSpool;
6
+ const fs_1 = require("fs");
7
+ const os_1 = require("os");
8
+ const path_1 = require("path");
9
+ // ─── On-disk turn spool ───────────────────────────────────────────────────────
10
+ //
11
+ // Each in-flight bridge turn writes a record to ~/.1presence/outbox/. The file
12
+ // exists from the moment the turn starts until the gateway acks the save-turn
13
+ // POST. If the bridge is killed (Ctrl+C, terminal closed, crash) between
14
+ // Claude finishing and the ack landing, the next startup drains the directory
15
+ // — covering the failure mode the WS+HTTP path alone can't.
16
+ //
17
+ // Records are keyed by conversationId, which the gateway also dedupes on, so
18
+ // a drained replay is idempotent: if it already saved via WS, the POST is a
19
+ // 200 no-op and the spool file is deleted.
20
+ //
21
+ // Payload mode 0600 — the file contains the user's assistant transcript and
22
+ // tool inputs. Tightened on every write to handle legacy world-readable files.
23
+ const OUTBOX_DIR = (0, path_1.join)((0, os_1.homedir)(), '.1presence', 'outbox');
24
+ function ensureDir() {
25
+ (0, fs_1.mkdirSync)(OUTBOX_DIR, { recursive: true });
26
+ }
27
+ function pathFor(conversationId) {
28
+ return (0, path_1.join)(OUTBOX_DIR, `${conversationId}.json`);
29
+ }
30
+ function writeSpool(record) {
31
+ ensureDir();
32
+ (0, fs_1.writeFileSync)(pathFor(record.conversationId), JSON.stringify(record), { mode: 0o600 });
33
+ }
34
+ function deleteSpool(conversationId) {
35
+ try {
36
+ (0, fs_1.unlinkSync)(pathFor(conversationId));
37
+ }
38
+ catch { /* already gone — fine */ }
39
+ }
40
+ function listSpool() {
41
+ ensureDir();
42
+ const out = [];
43
+ for (const file of (0, fs_1.readdirSync)(OUTBOX_DIR)) {
44
+ if (!file.endsWith('.json'))
45
+ continue;
46
+ try {
47
+ const raw = (0, fs_1.readFileSync)((0, path_1.join)(OUTBOX_DIR, file), 'utf-8');
48
+ out.push(JSON.parse(raw));
49
+ }
50
+ catch {
51
+ // Malformed file — leave it alone so a human can inspect.
52
+ }
53
+ }
54
+ return out;
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Run 1Presence on your Mac and use your Claude.ai Pro subscription from any device",
5
5
  "bin": {
6
6
  "1presence-bridge": "dist/index.js"