@obtoai/agent-bridge 0.1.0-beta.12 → 0.1.0-beta.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obtoai/agent-bridge",
3
- "version": "0.1.0-beta.12",
3
+ "version": "0.1.0-beta.14",
4
4
  "description": "Local consumer for the OBTO Agent Bridge. Receives bridge events over SSE and drives a coding agent (Claude Code or OpenAI Codex) on your machine.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "OBTO Inc.",
@@ -29,7 +29,8 @@ const CODEX_DIR = path.join(os.homedir(), '.codex', 'sessions');
29
29
  const PREVIEW_MAX_CHARS = 200;
30
30
  const TITLE_MAX_CHARS = 80;
31
31
  const TAIL_READ_BYTES = 8192; // read the last 8KB of each JSONL for the last message
32
- const HEAD_READ_BYTES = 4096; // read the first 4KB for the first-user-message title
32
+ const TITLE_MAX_LINES = 40; // scan up to this many lines for a real first message
33
+ const TITLE_MAX_BYTES = 65536; // hard ceiling per file even if MAX_LINES never reached
33
34
 
34
35
  // Read the tail of a (potentially large) JSONL file without slurping the
35
36
  // whole thing into memory. Returns a string (UTF-8) or '' on any failure.
@@ -52,18 +53,76 @@ const readTail = (filePath, maxBytes = TAIL_READ_BYTES) => {
52
53
  }
53
54
  };
54
55
 
55
- // Read the head of a JSONL file (first ~4KB). Used to extract the first user
56
- // message for a meaningful session title "fix the auth bug" beats "OBTO".
57
- const readHead = (filePath, maxBytes = HEAD_READ_BYTES) => {
56
+ // Claude Code (and to a lesser extent Codex) prepend a parade of injected
57
+ // "user" messages BEFORE the human's first real prompt system reminders,
58
+ // IDE state, untrusted-metadata wrappers, command blocks, etc. These are
59
+ // noise from a label perspective. Filter them so the first PLAIN user
60
+ // prompt is what we use as the thread title.
61
+ const isInjectionMessage = (text) => {
62
+ const s = String(text || '').trim();
63
+ if (!s) return true;
64
+ if (/^<[a-zA-Z!]/.test(s)) return true; // <system-reminder>, <ide_opened_file>, etc.
65
+ if (/^Sender \(untrusted metadata\)/i.test(s)) return true;
66
+ if (/^Conversation info \(untrusted metadata\)/i.test(s)) return true;
67
+ if (/^untrusted metadata/i.test(s)) return true;
68
+ if (/^Caveat:/i.test(s)) return true;
69
+ if (/^Claude was launched/i.test(s)) return true;
70
+ if (/^```json/i.test(s)) return true;
71
+ if (/^`<command-name>/.test(s)) return true;
72
+ if (/^This session is being continued/i.test(s)) return true; // /resume preamble
73
+ return false;
74
+ };
75
+
76
+ // Walk a JSONL file line-by-line until we find a plain user message (skipping
77
+ // metadata injections) or we've checked TITLE_MAX_LINES / TITLE_MAX_BYTES.
78
+ // Streaming so big files don't get fully slurped. Returns a clean ≤80-char
79
+ // title or '' if nothing meaningful was found in the bound.
80
+ const extractTitleFromFile = (filePath) => {
58
81
  let fd = null;
59
82
  try {
60
- const stat = fs.statSync(filePath);
61
- const len = Math.min(stat.size, maxBytes);
62
- if (len <= 0) return '';
63
83
  fd = fs.openSync(filePath, 'r');
64
- const buf = Buffer.alloc(len);
65
- fs.readSync(fd, buf, 0, len, 0);
66
- return buf.toString('utf8');
84
+ const CHUNK = 16 * 1024;
85
+ const buf = Buffer.alloc(CHUNK);
86
+ let carry = '';
87
+ let bytesRead = 0;
88
+ let linesChecked = 0;
89
+ while (bytesRead < TITLE_MAX_BYTES && linesChecked < TITLE_MAX_LINES) {
90
+ const n = fs.readSync(fd, buf, 0, CHUNK, bytesRead);
91
+ if (n === 0) break;
92
+ bytesRead += n;
93
+ carry += buf.toString('utf8', 0, n);
94
+ const lines = carry.split(/\r?\n/);
95
+ carry = lines.pop() || '';
96
+ for (const line of lines) {
97
+ if (linesChecked >= TITLE_MAX_LINES) break;
98
+ if (!line.trim()) continue;
99
+ linesChecked++;
100
+ let obj;
101
+ try { obj = JSON.parse(line); } catch (_) { continue; }
102
+ let role = null, raw = null;
103
+ if (obj && obj.message && (obj.message.role || obj.type)) {
104
+ role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
105
+ raw = obj.message.content;
106
+ } else if (obj && obj.role && (obj.content || obj.text)) {
107
+ role = obj.role;
108
+ raw = obj.content != null ? obj.content : obj.text;
109
+ }
110
+ if (role !== 'user' || raw == null) continue;
111
+ let text = '';
112
+ if (typeof raw === 'string') text = raw;
113
+ else if (Array.isArray(raw)) {
114
+ text = raw
115
+ .filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
116
+ .map((p) => String(p.text || ''))
117
+ .join(' ');
118
+ }
119
+ text = text.replace(/\s+/g, ' ').trim();
120
+ if (!text) continue;
121
+ if (isInjectionMessage(text)) continue;
122
+ return text.length > TITLE_MAX_CHARS ? text.slice(0, TITLE_MAX_CHARS) : text;
123
+ }
124
+ }
125
+ return '';
67
126
  } catch (_) {
68
127
  return '';
69
128
  } finally {
@@ -71,39 +130,6 @@ const readHead = (filePath, maxBytes = HEAD_READ_BYTES) => {
71
130
  }
72
131
  };
73
132
 
74
- // Walk JSONL lines forward to find the first user message. Same tolerance
75
- // for the two SDKs' shapes as extractLastMessage. Returns a short title
76
- // suitable for a thread name, or '' if no user message landed in the head.
77
- const extractTitle = (jsonlHead) => {
78
- if (!jsonlHead) return '';
79
- const lines = jsonlHead.split(/\r?\n/).filter((l) => l.trim().length > 0);
80
- for (let i = 0; i < lines.length; i++) {
81
- let obj;
82
- try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
83
- let role = null;
84
- let raw = null;
85
- if (obj && obj.message && (obj.message.role || obj.type)) {
86
- role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
87
- raw = obj.message.content;
88
- } else if (obj && obj.role && (obj.content || obj.text)) {
89
- role = obj.role;
90
- raw = obj.content != null ? obj.content : obj.text;
91
- }
92
- if (role !== 'user' || raw == null) continue;
93
- let text = '';
94
- if (typeof raw === 'string') text = raw;
95
- else if (Array.isArray(raw)) {
96
- text = raw
97
- .filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
98
- .map((p) => String(p.text || ''))
99
- .join(' ');
100
- }
101
- text = text.trim().replace(/\s+/g, ' ');
102
- if (text) return text.slice(0, TITLE_MAX_CHARS);
103
- }
104
- return '';
105
- };
106
-
107
133
  // Walk a JSONL tail backwards, parse each non-empty line as JSON, return the
108
134
  // first one we can extract a message from. Tolerant of multiple shapes —
109
135
  // Claude and Codex write slightly different envelopes and the formats have
@@ -196,9 +222,8 @@ const scanClaude = () => {
196
222
  let stat;
197
223
  try { stat = fs.statSync(filePath); } catch (_) { continue; }
198
224
  const tail = readTail(filePath);
199
- const head = readHead(filePath);
200
225
  const lastMsg = extractLastMessage(tail);
201
- const title = extractTitle(head);
226
+ const title = extractTitleFromFile(filePath);
202
227
  out.push({
203
228
  source: 'claude',
204
229
  sessionId,
@@ -271,9 +296,8 @@ const scanCodex = () => {
271
296
  }
272
297
 
273
298
  const tail = readTail(filePath);
274
- const head = readHead(filePath);
275
299
  const lastMsg = extractLastMessage(tail);
276
- const title = extractTitle(head);
300
+ const title = extractTitleFromFile(filePath);
277
301
  out.push({
278
302
  source: 'codex',
279
303
  sessionId,