@obtoai/agent-bridge 0.1.0-beta.11 → 0.1.0-beta.13

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.11",
3
+ "version": "0.1.0-beta.13",
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.",
@@ -27,7 +27,10 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects');
27
27
  const CODEX_DIR = path.join(os.homedir(), '.codex', 'sessions');
28
28
 
29
29
  const PREVIEW_MAX_CHARS = 200;
30
+ const TITLE_MAX_CHARS = 80;
30
31
  const TAIL_READ_BYTES = 8192; // read the last 8KB of each JSONL for the last message
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
31
34
 
32
35
  // Read the tail of a (potentially large) JSONL file without slurping the
33
36
  // whole thing into memory. Returns a string (UTF-8) or '' on any failure.
@@ -50,6 +53,83 @@ const readTail = (filePath, maxBytes = TAIL_READ_BYTES) => {
50
53
  }
51
54
  };
52
55
 
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) => {
81
+ let fd = null;
82
+ try {
83
+ fd = fs.openSync(filePath, 'r');
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 '';
126
+ } catch (_) {
127
+ return '';
128
+ } finally {
129
+ if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
130
+ }
131
+ };
132
+
53
133
  // Walk a JSONL tail backwards, parse each non-empty line as JSON, return the
54
134
  // first one we can extract a message from. Tolerant of multiple shapes —
55
135
  // Claude and Codex write slightly different envelopes and the formats have
@@ -143,11 +223,13 @@ const scanClaude = () => {
143
223
  try { stat = fs.statSync(filePath); } catch (_) { continue; }
144
224
  const tail = readTail(filePath);
145
225
  const lastMsg = extractLastMessage(tail);
226
+ const title = extractTitleFromFile(filePath);
146
227
  out.push({
147
228
  source: 'claude',
148
229
  sessionId,
149
230
  projectDir: entry, // raw encoded form
150
231
  projectName: decodeClaudeProjectDir(entry), // best-effort decoded
232
+ title: title, // first-user-message slice
151
233
  lastActivityAt: stat.mtimeMs,
152
234
  lastMessagePreview: lastMsg ? lastMsg.preview : '',
153
235
  lastMessageAuthor: lastMsg ? lastMsg.author : null,
@@ -214,12 +296,15 @@ const scanCodex = () => {
214
296
  }
215
297
 
216
298
  const tail = readTail(filePath);
299
+ const head = readHead(filePath);
217
300
  const lastMsg = extractLastMessage(tail);
301
+ const title = extractTitle(head);
218
302
  out.push({
219
303
  source: 'codex',
220
304
  sessionId,
221
305
  projectDir: projectDir || `${y}/${m}/${d}`,
222
306
  projectName: projectDir || null,
307
+ title: title,
223
308
  lastActivityAt: stat.mtimeMs,
224
309
  lastMessagePreview: lastMsg ? lastMsg.preview : '',
225
310
  lastMessageAuthor: lastMsg ? lastMsg.author : null,