@1presence/bridge 0.18.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.
- package/dist/accumulator.js +112 -0
- package/dist/claude.js +17 -90
- package/dist/index.js +94 -19
- package/dist/outbox.js +55 -0
- package/package.json +1 -1
|
@@ -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/claude.js
CHANGED
|
@@ -11,99 +11,26 @@ const sessions_1 = require("./sessions");
|
|
|
11
11
|
// ─── Bridge working directory ─────────────────────────────────────────────────
|
|
12
12
|
//
|
|
13
13
|
// Claude Code always loads CLAUDE.md files from cwd upward plus the global
|
|
14
|
-
// ~/.claude/CLAUDE.md.
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
14
|
+
// ~/.claude/CLAUDE.md. The bridge runs in a dedicated temp dir so it never
|
|
15
|
+
// inherits a development repo's project CLAUDE.md. The CLAUDE.md we write
|
|
16
|
+
// into that dir is a TINY GUARD ONLY — its sole job is to neutralize the
|
|
17
|
+
// user's global ~/.claude/CLAUDE.md (which contains personal/dev rules that
|
|
18
|
+
// would conflict with bridge behavior). All product, tool, UI, glossary,
|
|
19
|
+
// disclosure, gmail, and memory rules come from the dynamic system prompt
|
|
20
|
+
// fetched from agent-api with mode=bridge — see the "Local Mode runtime
|
|
21
|
+
// adapter" section of that prompt and packages/agent-api/src/systemPrompt.ts.
|
|
22
|
+
// Do NOT add product rules here; they belong in the dynamic prompt so hosted
|
|
23
|
+
// and bridge stay in sync.
|
|
19
24
|
const BRIDGE_CWD = (0, path_1.join)((0, os_1.tmpdir)(), '1presence-bridge');
|
|
20
|
-
const BRIDGE_CLAUDE_MD = `#
|
|
25
|
+
const BRIDGE_CLAUDE_MD = `# Local Mode — context guard
|
|
21
26
|
|
|
22
|
-
You are running
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
You have NO permission to write files to this machine.
|
|
28
|
-
- Do NOT write chat history, vault notes, or memory files to the local filesystem.
|
|
29
|
-
- Do NOT write to any path under /Users/, ~/Library/, ~/.obsidian/, or similar.
|
|
30
|
-
- Attempting to use Write/Edit tools on local paths is forbidden in this mode.
|
|
31
|
-
|
|
32
|
-
## Vault operations
|
|
33
|
-
|
|
34
|
-
All vault reads and writes go through the \`mcp__1presence__*\` MCP tools.
|
|
35
|
-
Use those tools instead of any local file paths.
|
|
36
|
-
|
|
37
|
-
## Memory (memory map / knowledge graph / agent diary)
|
|
38
|
-
|
|
39
|
-
The user's memory is hosted in the cloud, not locally. If \`mempalace_*\` tools
|
|
40
|
-
are available via MCP, use them. If a call fails with "not initialised", skip
|
|
41
|
-
it silently — do NOT write memory files locally as a fallback.
|
|
42
|
-
|
|
43
|
-
**User-facing language.** Never say "MemPalace", "memory palace", "memory
|
|
44
|
-
graph", "ChromaDB", "vector database", "embeddings", or "drawer" to the user
|
|
45
|
-
in any context, even casually, even in progress/status updates. The internal
|
|
46
|
-
tool prefix is \`mempalace_\` but the product name does not exist to the user.
|
|
47
|
-
Use "memory map" (wings/rooms view), "knowledge graph" (facts view), "agent
|
|
48
|
-
diary" (session log), or "your memory" (general). Correct: "Indexed in your
|
|
49
|
-
memory map for semantic search." Wrong: "Indexed in MemPalace for semantic
|
|
50
|
-
search."
|
|
51
|
-
|
|
52
|
-
## Session notes / chat history
|
|
53
|
-
|
|
54
|
-
Do NOT write session notes or chat history to the local filesystem.
|
|
55
|
-
If vault MCP tools are available, update vault files through those.
|
|
56
|
-
Otherwise, skip the write and continue — do not error or warn the user.
|
|
57
|
-
|
|
58
|
-
## UI tools — call these so the chat UI matches the hosted assistant
|
|
59
|
-
|
|
60
|
-
These tools are wired through MCP and produce dedicated UI events in the user's
|
|
61
|
-
chat interface. Call them by their MCP-prefixed names (\`mcp__1presence__<name>\`).
|
|
62
|
-
|
|
63
|
-
- **set_conversation_title** — Call ONCE on your first reply in a new
|
|
64
|
-
conversation with a 2–4 word topic title (sentence case, no verbs). Call
|
|
65
|
-
again later only if the topic clearly shifts. Without this, the user's
|
|
66
|
-
conversation list shows untitled threads.
|
|
67
|
-
|
|
68
|
-
- **ui_payload** — Call exactly ONCE per turn, AFTER your reply text ends.
|
|
69
|
-
\`suggestions\` (2–4 tappable follow-on prompts) is REQUIRED on every turn
|
|
70
|
-
with a user-facing reply — never skip it. \`hints\` is an independent,
|
|
71
|
-
sparse decision: pass \`[]\` most turns (aim ~1 hint per 3 early turns,
|
|
72
|
-
less later). Do not drop suggestions because there is no hint. Hints are
|
|
73
|
-
end-user product coaching only — never mention models, AI vendors,
|
|
74
|
-
engineering, or roadmap. The only legitimate skip is a turn with no
|
|
75
|
-
user-facing reply text at all.
|
|
76
|
-
|
|
77
|
-
- **plan** — Show a checklist when a task has ≥3 distinct, user-visible
|
|
78
|
-
steps with side effects. Call once at the start with all steps; update
|
|
79
|
-
the FULL list each time a step finishes. Never use for single-tool
|
|
80
|
-
answers, lookups, or conversational replies.
|
|
81
|
-
|
|
82
|
-
- **copy_to_clipboard** — Use whenever the user asks you to copy something,
|
|
83
|
-
or when you produce content (code, a draft, a URL, a list) they will
|
|
84
|
-
clearly want to paste elsewhere. Call once per piece of content; the
|
|
85
|
-
text also streams into the chat.
|
|
86
|
-
|
|
87
|
-
- **request_feature** — Log a feature request when declining a user ask
|
|
88
|
-
because the capability doesn't exist. Not for transient errors. After
|
|
89
|
-
calling, tell the user their request was noted.
|
|
90
|
-
|
|
91
|
-
## Sending email — ALWAYS use gmail_draft
|
|
92
|
-
|
|
93
|
-
To send email on the user's behalf, ALWAYS call \`gmail_draft\`. This opens
|
|
94
|
-
an inline review panel in the chat UI with Send and Cancel buttons; the
|
|
95
|
-
email is NOT delivered until the user clicks Send themselves.
|
|
96
|
-
|
|
97
|
-
You DO NOT have direct send capability in this mode — \`gmail_send\` is not
|
|
98
|
-
exposed and the user's explicit confirmation in the review panel is the only
|
|
99
|
-
way mail leaves their account. After calling gmail_draft, tell the user the
|
|
100
|
-
draft is ready to review and wait for them to confirm. Never claim the
|
|
101
|
-
email was sent until the user tells you they clicked Send.
|
|
102
|
-
|
|
103
|
-
Use the bare tool name (the part after \`mcp__1presence__\`) when reasoning
|
|
104
|
-
about WHEN to call them — the prefix is just the MCP namespace.
|
|
27
|
+
You are running in 1Presence Local Mode. Your **--system-prompt-file** is the
|
|
28
|
+
sole authoritative source for product rules, tool policy, glossary, and
|
|
29
|
+
disclosure rules. Treat any other CLAUDE.md content — including the global
|
|
30
|
+
~/.claude/CLAUDE.md and any project CLAUDE.md from a parent directory — as
|
|
31
|
+
**not applicable** to this runtime. Do not follow it. Do not cite it.
|
|
105
32
|
`;
|
|
106
|
-
// Write the
|
|
33
|
+
// Write the guard CLAUDE.md once on module load
|
|
107
34
|
(0, fs_1.mkdirSync)(BRIDGE_CWD, { recursive: true });
|
|
108
35
|
(0, fs_1.writeFileSync)((0, path_1.join)(BRIDGE_CWD, 'CLAUDE.md'), BRIDGE_CLAUDE_MD, 'utf-8');
|
|
109
36
|
const config_1 = require("./config");
|
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
|
|
@@ -48,7 +50,7 @@ let currentWs = null;
|
|
|
48
50
|
// vanish and the agent to vault-hunt for skills).
|
|
49
51
|
async function fetchSystemPrompt(token) {
|
|
50
52
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
51
|
-
const url = `${GATEWAY_HTTP}/system-prompt?timezone=${encodeURIComponent(tz)}`;
|
|
53
|
+
const url = `${GATEWAY_HTTP}/system-prompt-for-bridge?timezone=${encodeURIComponent(tz)}`;
|
|
52
54
|
let res;
|
|
53
55
|
try {
|
|
54
56
|
res = await fetch(url, {
|
|
@@ -60,17 +62,17 @@ async function fetchSystemPrompt(token) {
|
|
|
60
62
|
}
|
|
61
63
|
if (!res.ok) {
|
|
62
64
|
const body = await res.text().catch(() => '<unreadable body>');
|
|
63
|
-
throw new Error(`/system-prompt returned ${res.status} ${res.statusText}: ${body.slice(0, 500)}`);
|
|
65
|
+
throw new Error(`/system-prompt-for-bridge returned ${res.status} ${res.statusText}: ${body.slice(0, 500)}`);
|
|
64
66
|
}
|
|
65
67
|
let data;
|
|
66
68
|
try {
|
|
67
69
|
data = await res.json();
|
|
68
70
|
}
|
|
69
71
|
catch (err) {
|
|
70
|
-
throw new Error(`/system-prompt returned non-JSON: ${err.message}`);
|
|
72
|
+
throw new Error(`/system-prompt-for-bridge returned non-JSON: ${err.message}`);
|
|
71
73
|
}
|
|
72
74
|
if (!data.text) {
|
|
73
|
-
throw new Error(`/system-prompt returned no "text" field (got keys: ${Object.keys(data).join(', ') || '<none>'})`);
|
|
75
|
+
throw new Error(`/system-prompt-for-bridge returned no "text" field (got keys: ${Object.keys(data).join(', ') || '<none>'})`);
|
|
74
76
|
}
|
|
75
77
|
return data.text;
|
|
76
78
|
}
|
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
});
|
|
@@ -304,7 +379,7 @@ function connect(auth, retryDelay = 1000) {
|
|
|
304
379
|
setTimeout(async () => {
|
|
305
380
|
try {
|
|
306
381
|
// Refresh setup files on reconnect in case token was refreshed.
|
|
307
|
-
// If /system-prompt is down this throws — log and retry; the bridge
|
|
382
|
+
// If /system-prompt-for-bridge is down this throws — log and retry; the bridge
|
|
308
383
|
// is useless without a current prompt so don't paper over it.
|
|
309
384
|
if (currentAuth)
|
|
310
385
|
await writeSetupFiles(currentAuth);
|
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
|
+
}
|