@obtoai/agent-bridge 0.1.0-beta.12 → 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 +1 -1
- package/src/external-scanner.js +70 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obtoai/agent-bridge",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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.",
|
package/src/external-scanner.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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 =
|
|
226
|
+
const title = extractTitleFromFile(filePath);
|
|
202
227
|
out.push({
|
|
203
228
|
source: 'claude',
|
|
204
229
|
sessionId,
|