@blockrun/franklin 3.3.2 → 3.5.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/README.md +58 -7
- package/dist/agent/commands.d.ts +1 -1
- package/dist/agent/commands.js +128 -17
- package/dist/agent/compact.d.ts +2 -2
- package/dist/agent/compact.js +148 -22
- package/dist/agent/context.d.ts +8 -3
- package/dist/agent/context.js +301 -108
- package/dist/agent/error-classifier.d.ts +11 -2
- package/dist/agent/error-classifier.js +64 -10
- package/dist/agent/llm.d.ts +8 -1
- package/dist/agent/llm.js +114 -19
- package/dist/agent/loop.d.ts +1 -2
- package/dist/agent/loop.js +509 -61
- package/dist/agent/optimize.d.ts +2 -2
- package/dist/agent/optimize.js +9 -7
- package/dist/agent/permissions.d.ts +1 -1
- package/dist/agent/permissions.js +1 -1
- package/dist/agent/planner.d.ts +42 -0
- package/dist/agent/planner.js +110 -0
- package/dist/agent/reduce.d.ts +7 -1
- package/dist/agent/reduce.js +85 -3
- package/dist/agent/streaming-executor.d.ts +6 -1
- package/dist/agent/streaming-executor.js +83 -5
- package/dist/agent/tokens.d.ts +11 -2
- package/dist/agent/tokens.js +38 -5
- package/dist/agent/tool-guard.d.ts +27 -0
- package/dist/agent/tool-guard.js +324 -0
- package/dist/agent/types.d.ts +7 -1
- package/dist/agent/types.js +1 -1
- package/dist/banner.js +27 -40
- package/dist/brain/extract.d.ts +11 -0
- package/dist/brain/extract.js +154 -0
- package/dist/brain/index.d.ts +3 -0
- package/dist/brain/index.js +2 -0
- package/dist/brain/store.d.ts +42 -0
- package/dist/brain/store.js +225 -0
- package/dist/brain/types.d.ts +45 -0
- package/dist/brain/types.js +5 -0
- package/dist/commands/daemon.js +2 -1
- package/dist/commands/start.js +16 -3
- package/dist/config.js +1 -1
- package/dist/index.js +27 -2
- package/dist/learnings/extractor.d.ts +13 -0
- package/dist/learnings/extractor.js +69 -8
- package/dist/learnings/index.d.ts +1 -1
- package/dist/learnings/index.js +1 -1
- package/dist/learnings/store.js +42 -13
- package/dist/learnings/types.d.ts +1 -1
- package/dist/mcp/client.d.ts +1 -1
- package/dist/mcp/client.js +5 -5
- package/dist/mcp/config.d.ts +1 -1
- package/dist/mcp/config.js +1 -1
- package/dist/panel/html.d.ts +2 -0
- package/dist/panel/html.js +409 -146
- package/dist/panel/server.js +19 -0
- package/dist/pricing.js +3 -2
- package/dist/proxy/fallback.d.ts +3 -1
- package/dist/proxy/fallback.js +4 -4
- package/dist/proxy/server.js +29 -11
- package/dist/proxy/sse-translator.js +1 -1
- package/dist/router/categories.d.ts +21 -0
- package/dist/router/categories.js +96 -0
- package/dist/router/index.d.ts +9 -2
- package/dist/router/index.js +106 -27
- package/dist/router/local-elo.d.ts +32 -0
- package/dist/router/local-elo.js +107 -0
- package/dist/router/selector.d.ts +46 -0
- package/dist/router/selector.js +106 -0
- package/dist/session/storage.d.ts +5 -1
- package/dist/session/storage.js +24 -2
- package/dist/social/a11y.d.ts +1 -1
- package/dist/social/a11y.js +5 -1
- package/dist/social/browser.d.ts +5 -0
- package/dist/social/browser.js +22 -0
- package/dist/social/preflight.d.ts +4 -0
- package/dist/social/preflight.js +42 -3
- package/dist/stats/failures.d.ts +20 -0
- package/dist/stats/failures.js +63 -0
- package/dist/stats/format.d.ts +6 -0
- package/dist/stats/format.js +23 -0
- package/dist/stats/insights.js +1 -21
- package/dist/stats/session-tracker.d.ts +21 -0
- package/dist/stats/session-tracker.js +28 -0
- package/dist/stats/tracker.d.ts +1 -1
- package/dist/stats/tracker.js +1 -1
- package/dist/tools/bash.d.ts +14 -1
- package/dist/tools/bash.js +132 -7
- package/dist/tools/edit.js +77 -14
- package/dist/tools/glob.js +13 -3
- package/dist/tools/grep.js +30 -12
- package/dist/tools/imagegen.js +3 -3
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +5 -1
- package/dist/tools/read.d.ts +16 -2
- package/dist/tools/read.js +36 -8
- package/dist/tools/searchx.d.ts +6 -2
- package/dist/tools/searchx.js +221 -44
- package/dist/tools/subagent.js +37 -3
- package/dist/tools/task.js +43 -7
- package/dist/tools/validate.d.ts +11 -0
- package/dist/tools/validate.js +42 -0
- package/dist/tools/webfetch.js +18 -7
- package/dist/tools/websearch.js +41 -7
- package/dist/tools/write.js +26 -6
- package/dist/ui/app.js +31 -6
- package/dist/ui/model-picker.d.ts +1 -1
- package/dist/ui/model-picker.js +1 -1
- package/dist/ui/terminal.d.ts +1 -1
- package/dist/ui/terminal.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_WEBSEARCHES_PER_TURN = 8;
|
|
4
|
+
const MAX_SIMILAR_SEARCHES_PER_TURN = 4;
|
|
5
|
+
const MAX_NO_SIGNAL_SEARCHES_PER_FAMILY = 2;
|
|
6
|
+
const SEARCH_FAMILY_SIMILARITY = 0.58;
|
|
7
|
+
const DUPLICATE_READ_TURN_WINDOW = 1;
|
|
8
|
+
const DUPLICATE_FETCH_TURN_WINDOW = 1;
|
|
9
|
+
const MAX_PREVIEW_CHARS = 320;
|
|
10
|
+
const SEARCH_STOPWORDS = new Set([
|
|
11
|
+
'a', 'an', 'and', 'april', 'at', 'builder', 'builders', 'com', 'developer',
|
|
12
|
+
'developers', 'for', 'from', 'in', 'latest', 'live', 'may', 'of', 'on', 'or',
|
|
13
|
+
'post', 'posts', 'recent', 'reply', 'replies', 'site', 'status', 'the', 'to',
|
|
14
|
+
'tweet', 'tweets', 'via', 'x',
|
|
15
|
+
]);
|
|
16
|
+
function stemToken(token) {
|
|
17
|
+
let result = token.toLowerCase();
|
|
18
|
+
if (/^\d{4}$/.test(result))
|
|
19
|
+
return '';
|
|
20
|
+
if (result.endsWith('ing') && result.length > 6)
|
|
21
|
+
result = result.slice(0, -3);
|
|
22
|
+
else if (result.endsWith('ers') && result.length > 5)
|
|
23
|
+
result = result.slice(0, -3);
|
|
24
|
+
else if (result.endsWith('er') && result.length > 4)
|
|
25
|
+
result = result.slice(0, -2);
|
|
26
|
+
else if (result.endsWith('ed') && result.length > 4)
|
|
27
|
+
result = result.slice(0, -2);
|
|
28
|
+
else if (result.endsWith('es') && result.length > 4)
|
|
29
|
+
result = result.slice(0, -2);
|
|
30
|
+
else if (result.endsWith('s') && result.length > 4)
|
|
31
|
+
result = result.slice(0, -1);
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
export function normalizeSearchQuery(query) {
|
|
35
|
+
const tokens = query
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
38
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
39
|
+
.split(/\s+/)
|
|
40
|
+
.map(stemToken)
|
|
41
|
+
.filter((token) => token.length >= 2 && !SEARCH_STOPWORDS.has(token));
|
|
42
|
+
const normalized = [...new Set(tokens)].sort().join(' ');
|
|
43
|
+
return { normalized, tokens: [...new Set(tokens)] };
|
|
44
|
+
}
|
|
45
|
+
function jaccardSimilarity(a, b) {
|
|
46
|
+
if (a.size === 0 || b.size === 0)
|
|
47
|
+
return 0;
|
|
48
|
+
let intersection = 0;
|
|
49
|
+
for (const token of a) {
|
|
50
|
+
if (b.has(token))
|
|
51
|
+
intersection++;
|
|
52
|
+
}
|
|
53
|
+
const union = new Set([...a, ...b]).size;
|
|
54
|
+
return union === 0 ? 0 : intersection / union;
|
|
55
|
+
}
|
|
56
|
+
function summarizeOutput(output) {
|
|
57
|
+
const compact = output
|
|
58
|
+
.split('\n')
|
|
59
|
+
.map((line) => line.trim())
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.slice(0, 4)
|
|
62
|
+
.join('\n');
|
|
63
|
+
return compact.length > MAX_PREVIEW_CHARS
|
|
64
|
+
? compact.slice(0, MAX_PREVIEW_CHARS - 3) + '...'
|
|
65
|
+
: compact;
|
|
66
|
+
}
|
|
67
|
+
function isNoSignalSearchResult(output, isError) {
|
|
68
|
+
const lower = output.toLowerCase();
|
|
69
|
+
return Boolean(isError ||
|
|
70
|
+
lower.startsWith('no results found for:') ||
|
|
71
|
+
lower.startsWith('no candidate posts found') ||
|
|
72
|
+
lower.startsWith('search timed out') ||
|
|
73
|
+
lower.startsWith('search error:') ||
|
|
74
|
+
lower.startsWith('searchx error:'));
|
|
75
|
+
}
|
|
76
|
+
function readKey(resolved, offset, limit) {
|
|
77
|
+
return `${resolved}::${offset ?? 1}::${limit ?? 2000}`;
|
|
78
|
+
}
|
|
79
|
+
function fetchKey(url, maxLength) {
|
|
80
|
+
return `${url}::${maxLength ?? 12288}`;
|
|
81
|
+
}
|
|
82
|
+
export class SessionToolGuard {
|
|
83
|
+
turn = 0;
|
|
84
|
+
webSearchesThisTurn = 0;
|
|
85
|
+
searchFamilies = [];
|
|
86
|
+
searchCache = new Map();
|
|
87
|
+
pendingSearches = new Map();
|
|
88
|
+
recentReads = new Map();
|
|
89
|
+
pendingReads = new Map();
|
|
90
|
+
recentFetches = new Map();
|
|
91
|
+
pendingFetches = new Map();
|
|
92
|
+
toolErrorCounts = new Map();
|
|
93
|
+
startTurn() {
|
|
94
|
+
this.turn++;
|
|
95
|
+
this.webSearchesThisTurn = 0;
|
|
96
|
+
for (const family of this.searchFamilies) {
|
|
97
|
+
family.turnSearches = 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async beforeExecute(invocation, scope) {
|
|
101
|
+
// Hard-block tools that have failed too many times this session
|
|
102
|
+
const errorCount = this.toolErrorCounts.get(invocation.name) ?? 0;
|
|
103
|
+
if (errorCount >= 3) {
|
|
104
|
+
return {
|
|
105
|
+
output: `${invocation.name} has failed ${errorCount} times this session and is now disabled. ` +
|
|
106
|
+
'Tell the user what went wrong and suggest alternatives.',
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
switch (invocation.name) {
|
|
111
|
+
case 'WebSearch':
|
|
112
|
+
case 'SearchX':
|
|
113
|
+
return this.beforeWebSearch(invocation);
|
|
114
|
+
case 'Read':
|
|
115
|
+
return this.beforeRead(invocation, scope);
|
|
116
|
+
case 'WebFetch':
|
|
117
|
+
return this.beforeWebFetch(invocation);
|
|
118
|
+
default:
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
afterExecute(invocation, result) {
|
|
123
|
+
// Track per-tool error counts across the session
|
|
124
|
+
if (result.isError) {
|
|
125
|
+
this.toolErrorCounts.set(invocation.name, (this.toolErrorCounts.get(invocation.name) ?? 0) + 1);
|
|
126
|
+
}
|
|
127
|
+
switch (invocation.name) {
|
|
128
|
+
case 'WebSearch':
|
|
129
|
+
case 'SearchX':
|
|
130
|
+
this.afterWebSearch(invocation, result);
|
|
131
|
+
break;
|
|
132
|
+
case 'Read':
|
|
133
|
+
this.afterRead(invocation, result);
|
|
134
|
+
break;
|
|
135
|
+
case 'WebFetch':
|
|
136
|
+
this.afterWebFetch(invocation, result);
|
|
137
|
+
break;
|
|
138
|
+
default:
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
cancelInvocation(invocationId) {
|
|
143
|
+
this.pendingSearches.delete(invocationId);
|
|
144
|
+
this.pendingReads.delete(invocationId);
|
|
145
|
+
this.pendingFetches.delete(invocationId);
|
|
146
|
+
}
|
|
147
|
+
beforeWebSearch(invocation) {
|
|
148
|
+
const query = String(invocation.input.query ?? '').trim();
|
|
149
|
+
const fingerprint = normalizeSearchQuery(query);
|
|
150
|
+
const normalized = fingerprint.normalized || query.toLowerCase().trim();
|
|
151
|
+
const cached = this.searchCache.get(normalized);
|
|
152
|
+
if (cached) {
|
|
153
|
+
const reason = cached.noSignal
|
|
154
|
+
? 'That same WebSearch already returned no useful signal earlier in this session.'
|
|
155
|
+
: 'That same WebSearch already ran earlier in this session.';
|
|
156
|
+
return {
|
|
157
|
+
output: `${reason} Reuse the prior result already in context instead of searching again.\n\n` +
|
|
158
|
+
`Previous search: ${cached.query}\n` +
|
|
159
|
+
`Summary:\n${cached.preview}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (this.webSearchesThisTurn >= MAX_WEBSEARCHES_PER_TURN) {
|
|
163
|
+
return {
|
|
164
|
+
output: `WebSearch budget reached for this turn (${MAX_WEBSEARCHES_PER_TURN} searches). ` +
|
|
165
|
+
'Stop searching and synthesize the results already collected.',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
let bestFamily = null;
|
|
169
|
+
let bestSimilarity = 0;
|
|
170
|
+
const tokenSet = new Set(fingerprint.tokens);
|
|
171
|
+
for (const family of this.searchFamilies) {
|
|
172
|
+
const similarity = jaccardSimilarity(tokenSet, family.tokens);
|
|
173
|
+
if (similarity > bestSimilarity) {
|
|
174
|
+
bestSimilarity = similarity;
|
|
175
|
+
bestFamily = family;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (bestFamily && bestSimilarity >= SEARCH_FAMILY_SIMILARITY) {
|
|
179
|
+
if (bestFamily.noSignalSearches >= MAX_NO_SIGNAL_SEARCHES_PER_FAMILY) {
|
|
180
|
+
return {
|
|
181
|
+
output: `Search stopped: ${bestFamily.noSignalSearches} similar WebSearch queries for this topic ` +
|
|
182
|
+
`already returned empty or low-signal results.\n\n` +
|
|
183
|
+
`Topic exemplar: ${bestFamily.exemplarQuery}\n` +
|
|
184
|
+
'Present what you have instead of rephrasing the same search.',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (bestFamily.turnSearches >= MAX_SIMILAR_SEARCHES_PER_TURN) {
|
|
188
|
+
return {
|
|
189
|
+
output: `Search stopped: you already ran ${bestFamily.turnSearches} similar WebSearch queries ` +
|
|
190
|
+
`for this topic in the current turn.\n\n` +
|
|
191
|
+
`Topic exemplar: ${bestFamily.exemplarQuery}\n` +
|
|
192
|
+
'Synthesize or switch to a materially different angle.',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const family = bestFamily && bestSimilarity >= SEARCH_FAMILY_SIMILARITY
|
|
197
|
+
? bestFamily
|
|
198
|
+
: {
|
|
199
|
+
exemplarQuery: query,
|
|
200
|
+
tokens: tokenSet,
|
|
201
|
+
totalSearches: 0,
|
|
202
|
+
turnSearches: 0,
|
|
203
|
+
noSignalSearches: 0,
|
|
204
|
+
};
|
|
205
|
+
if (family === bestFamily) {
|
|
206
|
+
family.tokens = new Set([...family.tokens, ...tokenSet]);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
this.searchFamilies.push(family);
|
|
210
|
+
}
|
|
211
|
+
family.totalSearches++;
|
|
212
|
+
family.turnSearches++;
|
|
213
|
+
this.webSearchesThisTurn++;
|
|
214
|
+
this.pendingSearches.set(invocation.id, { normalized, family });
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
beforeRead(invocation, scope) {
|
|
218
|
+
const filePath = String(invocation.input.file_path ?? '');
|
|
219
|
+
if (!filePath)
|
|
220
|
+
return null;
|
|
221
|
+
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(scope.workingDir, filePath);
|
|
222
|
+
let stat;
|
|
223
|
+
try {
|
|
224
|
+
stat = fs.statSync(resolved);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (stat.isDirectory())
|
|
230
|
+
return null;
|
|
231
|
+
const offset = Number(invocation.input.offset ?? 1);
|
|
232
|
+
const limit = Number(invocation.input.limit ?? 2000);
|
|
233
|
+
const key = readKey(resolved, offset, limit);
|
|
234
|
+
const pending = [...this.pendingReads.values()].find((snapshot) => snapshot.key === key);
|
|
235
|
+
if (pending && pending.mtimeMs === stat.mtimeMs && pending.size === stat.size) {
|
|
236
|
+
return {
|
|
237
|
+
output: `Skipped duplicate Read of ${resolved}. The same file and line range is already being read ` +
|
|
238
|
+
'in this turn, so reuse that content instead of reading it again.',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const previous = this.recentReads.get(key);
|
|
242
|
+
if (previous &&
|
|
243
|
+
this.turn - previous.turn <= DUPLICATE_READ_TURN_WINDOW &&
|
|
244
|
+
previous.mtimeMs === stat.mtimeMs &&
|
|
245
|
+
previous.size === stat.size) {
|
|
246
|
+
return {
|
|
247
|
+
output: `Skipped duplicate Read of ${resolved}. Same file and line range were already read ` +
|
|
248
|
+
`${previous.turn === this.turn ? 'this turn' : 'in the previous turn'}, and the file has not changed.`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
this.pendingReads.set(invocation.id, {
|
|
252
|
+
key,
|
|
253
|
+
resolved,
|
|
254
|
+
offset,
|
|
255
|
+
limit,
|
|
256
|
+
turn: this.turn,
|
|
257
|
+
mtimeMs: stat.mtimeMs,
|
|
258
|
+
size: stat.size,
|
|
259
|
+
});
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
beforeWebFetch(invocation) {
|
|
263
|
+
const url = String(invocation.input.url ?? '').trim();
|
|
264
|
+
if (!url)
|
|
265
|
+
return null;
|
|
266
|
+
const maxLength = Number(invocation.input.max_length ?? 12288);
|
|
267
|
+
const key = fetchKey(url, maxLength);
|
|
268
|
+
const pending = [...this.pendingFetches.values()].find((snapshot) => snapshot.key === key);
|
|
269
|
+
if (pending) {
|
|
270
|
+
return {
|
|
271
|
+
output: `Skipped duplicate WebFetch of ${url}. The same URL is already being fetched in this turn, ` +
|
|
272
|
+
'so reuse that result instead of fetching it again.',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const previous = this.recentFetches.get(key);
|
|
276
|
+
if (previous && this.turn - previous.turn <= DUPLICATE_FETCH_TURN_WINDOW) {
|
|
277
|
+
return {
|
|
278
|
+
output: `Skipped duplicate WebFetch of ${url}. The same URL was already fetched recently in this session; ` +
|
|
279
|
+
'reuse that content already in context instead of fetching it again.',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
this.pendingFetches.set(invocation.id, {
|
|
283
|
+
key,
|
|
284
|
+
url,
|
|
285
|
+
maxLength,
|
|
286
|
+
turn: this.turn,
|
|
287
|
+
});
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
afterWebSearch(invocation, result) {
|
|
291
|
+
const pending = this.pendingSearches.get(invocation.id);
|
|
292
|
+
if (!pending)
|
|
293
|
+
return;
|
|
294
|
+
this.pendingSearches.delete(invocation.id);
|
|
295
|
+
const query = String(invocation.input.query ?? '').trim();
|
|
296
|
+
const noSignal = isNoSignalSearchResult(result.output, result.isError);
|
|
297
|
+
if (noSignal) {
|
|
298
|
+
pending.family.noSignalSearches++;
|
|
299
|
+
}
|
|
300
|
+
this.searchCache.set(pending.normalized, {
|
|
301
|
+
query,
|
|
302
|
+
preview: summarizeOutput(result.output),
|
|
303
|
+
noSignal,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
afterRead(invocation, result) {
|
|
307
|
+
const pending = this.pendingReads.get(invocation.id);
|
|
308
|
+
if (!pending)
|
|
309
|
+
return;
|
|
310
|
+
this.pendingReads.delete(invocation.id);
|
|
311
|
+
if (result.isError)
|
|
312
|
+
return;
|
|
313
|
+
this.recentReads.set(pending.key, pending);
|
|
314
|
+
}
|
|
315
|
+
afterWebFetch(invocation, result) {
|
|
316
|
+
const pending = this.pendingFetches.get(invocation.id);
|
|
317
|
+
if (!pending)
|
|
318
|
+
return;
|
|
319
|
+
this.pendingFetches.delete(invocation.id);
|
|
320
|
+
if (result.isError)
|
|
321
|
+
return;
|
|
322
|
+
this.recentFetches.set(pending.key, pending);
|
|
323
|
+
}
|
|
324
|
+
}
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Core types for the
|
|
2
|
+
* Core types for the Franklin agent system.
|
|
3
3
|
* All type names and structures are original designs.
|
|
4
4
|
*/
|
|
5
5
|
export type Role = 'user' | 'assistant';
|
|
@@ -45,6 +45,8 @@ export interface CapabilityHandler {
|
|
|
45
45
|
spec: CapabilityDefinition;
|
|
46
46
|
execute(input: Record<string, unknown>, ctx: ExecutionScope): Promise<CapabilityResult>;
|
|
47
47
|
concurrent?: boolean;
|
|
48
|
+
/** Dynamic concurrency check — called per-invocation. Overrides `concurrent` when provided. */
|
|
49
|
+
isConcurrentSafe?: (input: Record<string, unknown>) => boolean;
|
|
48
50
|
}
|
|
49
51
|
export interface CapabilityResult {
|
|
50
52
|
output: string;
|
|
@@ -97,6 +99,10 @@ export interface StreamUsageInfo {
|
|
|
97
99
|
outputTokens: number;
|
|
98
100
|
model: string;
|
|
99
101
|
calls: number;
|
|
102
|
+
tier?: 'SIMPLE' | 'MEDIUM' | 'COMPLEX' | 'REASONING';
|
|
103
|
+
confidence?: number;
|
|
104
|
+
savings?: number;
|
|
105
|
+
contextPct?: number;
|
|
100
106
|
}
|
|
101
107
|
export type StreamEvent = StreamTextDelta | StreamThinkingDelta | StreamCapabilityStart | StreamCapabilityInputDelta | StreamCapabilityProgress | StreamCapabilityDone | StreamTurnDone | StreamUsageInfo;
|
|
102
108
|
export interface AgentConfig {
|
package/dist/agent/types.js
CHANGED
package/dist/banner.js
CHANGED
|
@@ -7,32 +7,26 @@ import chalk from 'chalk';
|
|
|
7
7
|
// https://commons.wikimedia.org/wiki/File:BenFranklinDuplessis.jpg
|
|
8
8
|
//
|
|
9
9
|
// Pipeline:
|
|
10
|
-
// 1. Crop the
|
|
11
|
-
// (sips --cropToHeightWidth
|
|
10
|
+
// 1. Crop the 800×989 thumb to a 500×500 square centred on the face
|
|
11
|
+
// (sips --cropToHeightWidth 500 500 --cropOffset 140 150)
|
|
12
12
|
// 2. Convert via chafa:
|
|
13
|
-
// chafa --size=
|
|
13
|
+
// chafa --size=16x8 --symbols=block --colors=full ben-face.jpg
|
|
14
14
|
// 3. Strip cursor visibility control codes (\x1b[?25l / \x1b[?25h)
|
|
15
15
|
// 4. Paste here as hex-escaped string array (readable + diff-friendly)
|
|
16
16
|
//
|
|
17
|
-
// Visible dimensions: ~
|
|
17
|
+
// Visible dimensions: ~16 characters wide × 8 rows tall.
|
|
18
18
|
//
|
|
19
19
|
// Rendered best in a 256-color or truecolor terminal. Degrades gracefully
|
|
20
20
|
// on ancient terminals — but those are long gone and we don't support them.
|
|
21
21
|
const BEN_PORTRAIT_ROWS = [
|
|
22
|
-
'\x1b[0m\x1b[38;
|
|
23
|
-
'\x1b[38;
|
|
24
|
-
'\x1b[38;
|
|
25
|
-
'\x1b[38;
|
|
26
|
-
'\x1b[38;
|
|
27
|
-
'\x1b[38;
|
|
28
|
-
'\x1b[38;
|
|
29
|
-
'\x1b[38;
|
|
30
|
-
'\x1b[38;5;234;48;5;235m▏\x1b[38;5;236;48;5;237m▋\x1b[38;5;237;48;5;8m▃\x1b[38;5;235;48;5;238m▗\x1b[38;5;237m▖\x1b[38;5;58;48;5;234m▌\x1b[38;5;234;48;5;233m▎\x1b[38;5;236;48;5;137m▎\x1b[38;5;137;48;5;173m▄ \x1b[38;5;173;48;5;179m▄▃ \x1b[38;5;179;48;5;215m▅\x1b[38;5;173;48;5;179m▄\x1b[38;5;179;48;5;137m▘\x1b[38;5;137;48;5;131m▌\x1b[48;5;94m \x1b[38;5;58m▗\x1b[38;5;233;48;5;58m▗\x1b[48;5;233m \x1b[38;5;234;48;5;236m▘ \x1b[38;5;236;48;5;235m▃▞\x1b[38;5;235;48;5;236m▄\x1b[48;5;235m \x1b[0m',
|
|
31
|
-
'\x1b[38;5;234;48;5;235m▏▆\x1b[38;5;235;48;5;237m▌\x1b[38;5;236m▝\x1b[38;5;237;48;5;234m▍\x1b[38;5;234;48;5;233m▖\x1b[38;5;240;48;5;234m▗\x1b[38;5;101;48;5;186m▌\x1b[38;5;137m▝\x1b[48;5;137m \x1b[48;5;173m▆▄▃\x1b[38;5;131m▂\x1b[38;5;130;48;5;137m▂\x1b[38;5;58;48;5;94m▃\x1b[48;5;58m \x1b[38;5;234;48;5;233m▏\x1b[38;5;235;48;5;234m▅\x1b[48;5;236m▌ ▝ \x1b[48;5;235m \x1b[0m',
|
|
32
|
-
'\x1b[38;5;234;48;5;233m▕\x1b[38;5;239;48;5;235m▂\x1b[38;5;95m▃\x1b[48;5;237m▄\x1b[48;5;236m▄\x1b[48;5;235m▄\x1b[38;5;236;48;5;240m▘\x1b[38;5;101;48;5;95m▕\x1b[48;5;186m▖\x1b[38;5;179;48;5;229m▝\x1b[38;5;223;48;5;137m▃\x1b[38;5;137;48;5;131m▁\x1b[38;5;95m▅\x1b[38;5;94m▂\x1b[48;5;94m \x1b[38;5;58m▗\x1b[38;5;94;48;5;58m▔\x1b[38;5;236m▁ \x1b[48;5;235m▆\x1b[38;5;235;48;5;236m▍\x1b[38;5;236;48;5;235m▆\x1b[48;5;236m \x1b[38;5;235m▅\x1b[48;5;235m \x1b[0m',
|
|
33
|
-
'\x1b[38;5;237;48;5;95m▔ \x1b[38;5;137;48;5;101m▝\x1b[48;5;187m▅\x1b[38;5;180;48;5;229m▂\x1b[38;5;143;48;5;222m▔\x1b[38;5;186;48;5;58m▅\x1b[38;5;179m▂\x1b[38;5;95m▁\x1b[38;5;235m▂\x1b[38;5;236m▄\x1b[48;5;233m▌\x1b[38;5;235m▔\x1b[38;5;233;48;5;236m▅\x1b[38;5;234m▃\x1b[38;5;235m▁ ▔\x1b[48;5;235m \x1b[0m',
|
|
34
|
-
'\x1b[38;5;101;48;5;137m▔\x1b[38;5;95;48;5;101m▄▔\x1b[38;5;101;48;5;95m▄ ▗ \x1b[38;5;240m▖\x1b[38;5;95;48;5;101m▘\x1b[38;5;137m▔\x1b[48;5;222m▅\x1b[48;5;186m▃\x1b[48;5;179m▂\x1b[38;5;101;48;5;95m▌\x1b[48;5;58m \x1b[38;5;238;48;5;236m▁\x1b[38;5;180;48;5;234m▃\x1b[48;5;235m▄\x1b[38;5;179;48;5;234m▃\x1b[38;5;95m▁\x1b[38;5;234;48;5;235m▊\x1b[48;5;236m▆\x1b[38;5;235m▃\x1b[38;5;234m▂\x1b[38;5;235m▁ \x1b[38;5;236;48;5;235m▎\x1b[0m',
|
|
35
|
-
'\x1b[38;5;137;48;5;137m \x1b[48;5;95m▄ \x1b[38;5;95;48;5;101m▖\x1b[48;5;137m▝\x1b[48;5;95m \x1b[38;5;101m▅\x1b[48;5;239m▋\x1b[48;5;95m \x1b[38;5;95;48;5;137m▋\x1b[38;5;101;48;5;95m▍\x1b[38;5;95;48;5;101m▖\x1b[38;5;101;48;5;95m▆\x1b[38;5;239m▗\x1b[38;5;101m▄ \x1b[38;5;95;48;5;137m▅\x1b[38;5;137;48;5;180m▅\x1b[38;5;180;48;5;186m▃\x1b[48;5;143m▆\x1b[38;5;95m▔\x1b[38;5;143;48;5;235m▖\x1b[48;5;234m \x1b[38;5;235m▆\x1b[38;5;234;48;5;235m▝\x1b[38;5;235;48;5;234m▞\x1b[38;5;234;48;5;235m▄ \x1b[0m',
|
|
22
|
+
'\x1b[0m\x1b[38;2;7;0;0;48;2;8;0;0m▔ \x1b[38;2;9;1;0m▂\x1b[38;2;56;36;15;48;2;11;2;0m▗\x1b[38;2;100;73;36;48;2;31;16;6m▅\x1b[38;2;189;141;75;48;2;117;87;43m▅\x1b[38;2;217;162;85;48;2;152;111;51m▆\x1b[38;2;164;122;64;48;2;215;158;85m▔\x1b[38;2;124;90;46;48;2;217;160;93m▔\x1b[38;2;185;136;75;48;2;77;48;20m▅\x1b[38;2;100;61;24;48;2;39;18;4m▖\x1b[38;2;48;26;9;48;2;32;13;3m▃\x1b[38;2;39;18;4;48;2;30;11;2m▄\x1b[38;2;38;17;4;48;2;32;13;3m▄\x1b[38;2;40;20;5;48;2;35;15;2m▃\x1b[38;2;41;21;5;48;2;36;16;3m▂\x1b[0m',
|
|
23
|
+
'\x1b[7m\x1b[38;2;8;0;0m \x1b[0m\x1b[38;2;0;0;0;48;2;8;0;0m \x1b[38;2;13;2;1;48;2;45;26;10m▊\x1b[38;2;61;40;17;48;2;87;63;31m▎\x1b[38;2;88;61;29;48;2;134;94;42m▋\x1b[38;2;182;132;66;48;2;223;172;93m▏\x1b[38;2;140;91;38;48;2;233;193;106m▂\x1b[38;2;135;82;35;48;2;229;178;106m▂\x1b[38;2;201;145;78;48;2;223;166;95m▂\x1b[38;2;133;88;46;48;2;198;148;86m▁\x1b[38;2;144;96;47;48;2;96;57;21m▍\x1b[38;2;66;42;15;48;2;58;33;11m▗\x1b[38;2;59;36;13;48;2;47;25;9m▆\x1b[38;2;57;35;11;48;2;46;24;7m▅\x1b[38;2;58;36;11;48;2;50;29;8m▖\x1b[38;2;53;32;8;48;2;48;26;7m▃\x1b[0m',
|
|
24
|
+
'\x1b[38;2;12;3;3;48;2;9;0;0m▁\x1b[38;2;102;76;40;48;2;19;8;4m▗\x1b[38;2;110;83;45;48;2;56;35;15m▄\x1b[38;2;91;67;37;48;2;105;79;45m▌\x1b[38;2;96;64;31;48;2;186;135;70m▊\x1b[38;2;226;169;101;48;2;217;162;91m▗\x1b[38;2;216;159;89;48;2;144;93;44m▅\x1b[38;2;195;145;83;48;2;112;62;24m▅\x1b[38;2;233;178;110;48;2;206;151;81m▆\x1b[38;2;207;155;92;48;2;105;61;30m▎\x1b[38;2;145;94;46;48;2;94;50;19m▖\x1b[38;2;90;48;17;48;2;52;26;8m▎\x1b[38;2;59;33;9;48;2;64;40;14m▖\x1b[38;2;63;39;13;48;2;65;41;13m▊\x1b[38;2;58;36;11;48;2;64;40;14m▝\x1b[38;2;60;38;13;48;2;57;35;10m▍\x1b[0m',
|
|
25
|
+
'\x1b[38;2;37;22;12;48;2;11;2;2m▕\x1b[38;2;52;32;16;48;2;94;67;32m▘\x1b[38;2;77;53;21;48;2;125;96;52m▗\x1b[38;2;44;15;6;48;2;83;48;21m▞\x1b[38;2;122;73;33;48;2;195;138;72m▍\x1b[38;2;209;149;77;48;2;223;160;89m▋\x1b[38;2;228;157;84;48;2;234;173;98m▆\x1b[38;2;207;140;80;48;2;225;167;96m▝\x1b[38;2;213;151;88;48;2;193;135;79m▏\x1b[38;2;164;111;60;48;2;104;54;21m▍\x1b[38;2;175;110;52;48;2;136;78;32m▘\x1b[38;2;93;47;15;48;2;26;5;2m▎\x1b[38;2;39;13;4;48;2;54;28;8m▍\x1b[38;2;63;40;13;48;2;67;44;16m▔\x1b[38;2;68;44;15;48;2;65;41;16m▊\x1b[38;2;60;36;11;48;2;63;39;14m▝\x1b[0m',
|
|
26
|
+
'\x1b[38;2;12;1;0;48;2;55;33;13m▌\x1b[38;2;92;63;32;48;2;68;43;17m▝\x1b[38;2;75;51;24;48;2;93;65;34m▗\x1b[38;2;88;61;30;48;2;42;18;8m▘\x1b[38;2;62;35;18;48;2;191;150;83m▍\x1b[38;2;186;140;75;48;2;194;138;63m▁\x1b[38;2;189;130;61;48;2;219;157;79m▄\x1b[38;2;191;132;70;48;2;217;159;87m▂\x1b[38;2;179;105;60;48;2;207;146;83m▔\x1b[38;2;171;106;51;48;2;135;79;32m▋\x1b[38;2;64;30;8;48;2;120;69;27m▗\x1b[38;2;56;26;8;48;2;39;13;5m▂\x1b[38;2;44;18;7;48;2;72;44;16m▘\x1b[38;2;72;47;18;48;2;69;44;14m▖\x1b[38;2;70;46;14;48;2;68;44;14m▁\x1b[38;2;65;41;12;48;2;65;41;14m▘\x1b[0m',
|
|
27
|
+
'\x1b[38;2;77;56;35;48;2;22;8;3m▂\x1b[38;2;126;100;69;48;2;59;36;15m▃\x1b[38;2;131;105;70;48;2;80;54;27m▄\x1b[38;2;128;103;68;48;2;57;33;14m▄\x1b[38;2;191;174;117;48;2;125;103;69m▝\x1b[38;2;191;164;108;48;2;236;227;160m▞\x1b[38;2;220;202;137;48;2;173;123;63m▃\x1b[38;2;130;85;43;48;2;164;111;58m▄\x1b[38;2;117;68;26;48;2;185;116;58m▆\x1b[38;2;135;80;33;48;2;94;52;15m▘\x1b[38;2;51;28;9;48;2;80;50;16m▂\x1b[38;2;62;33;9;48;2;76;46;14m▘\x1b[38;2;75;50;16;48;2;74;47;15m▗\x1b[38;2;71;46;14;48;2;72;47;15m▝\x1b[38;2;73;48;16;48;2;69;44;14m▏\x1b[38;2;65;41;11;48;2;66;41;15m▆\x1b[0m',
|
|
28
|
+
'\x1b[38;2;125;101;70;48;2;159;129;87m▔\x1b[38;2;145;114;71;48;2;124;100;70m▆\x1b[38;2;152;123;81;48;2;121;100;69m▃\x1b[38;2;117;95;60;48;2;129;106;70m▖\x1b[38;2;115;91;61;48;2;131;105;69m▗\x1b[38;2;166;145;103;48;2;140;113;71m▔\x1b[38;2;162;135;87;48;2;231;217;147m▅\x1b[38;2;133;107;71;48;2;199;171;110m▂\x1b[38;2;131;100;59;48;2;107;75;37m▍\x1b[38;2;166;139;88;48;2;67;40;14m▃\x1b[38;2;204;179;121;48;2;39;19;8m▄\x1b[38;2;137;112;73;48;2;52;28;10m▖\x1b[38;2;54;32;10;48;2;76;49;16m▅\x1b[38;2;56;33;9;48;2;74;48;15m▃\x1b[38;2;60;37;10;48;2;70;47;14m▁\x1b[38;2;66;43;12;48;2;64;40;11m▅\x1b[0m',
|
|
29
|
+
'\x1b[38;2;157;128;85;48;2;167;138;98m▝\x1b[38;2;141;111;71;48;2;166;136;98m▝\x1b[38;2;149;119;83;48;2;126;96;60m▞\x1b[38;2;157;129;93;48;2;139;113;81m▅\x1b[38;2;144;117;79;48;2;117;92;58m▋\x1b[38;2;130;102;62;48;2;169;138;87m▋\x1b[38;2;171;141;87;48;2;143;117;77m▖\x1b[38;2;144;117;79;48;2;122;96;63m▊\x1b[38;2;132;105;68;48;2;144;117;82m▖\x1b[38;2;153;127;92;48;2;140;115;83m▞\x1b[38;2;134;108;71;48;2;217;193;135m▅\x1b[38;2;176;150;98;48;2;129;105;66m▋\x1b[38;2;118;94;61;48;2;54;32;14m▂\x1b[38;2;44;23;8;48;2;59;37;13m▃\x1b[38;2;62;41;16;48;2;48;26;9m▖\x1b[38;2;46;24;6;48;2;66;42;15m▖\x1b[0m',
|
|
36
30
|
];
|
|
37
31
|
// ─── FRANKLIN text banner (gold → emerald gradient) ────────────────────────
|
|
38
32
|
//
|
|
@@ -69,9 +63,9 @@ function interpolateHex(start, end, t) {
|
|
|
69
63
|
}
|
|
70
64
|
// ─── Banner layout ─────────────────────────────────────────────────────────
|
|
71
65
|
// Minimum terminal width to show the side-by-side portrait + text layout.
|
|
72
|
-
// The portrait is ~
|
|
73
|
-
// gap =
|
|
74
|
-
const MIN_WIDTH_FOR_PORTRAIT =
|
|
66
|
+
// The portrait is ~16 chars, the FRANKLIN text is ~65 chars, plus a 3-char
|
|
67
|
+
// gap = 84 chars. We round up to 85 cols as the threshold.
|
|
68
|
+
const MIN_WIDTH_FOR_PORTRAIT = 85;
|
|
75
69
|
/**
|
|
76
70
|
* Pad a line to an exact visual width, ignoring ANSI escape codes when
|
|
77
71
|
* measuring. Used to align the portrait's right edge before the text block.
|
|
@@ -99,28 +93,21 @@ export function printBanner(version) {
|
|
|
99
93
|
}
|
|
100
94
|
/**
|
|
101
95
|
* Full layout: Ben Franklin portrait on the left, FRANKLIN text block on the
|
|
102
|
-
* right. Portrait is
|
|
103
|
-
* centred inside the portrait with
|
|
104
|
-
* tagline sitting right under the FRANKLIN block.
|
|
96
|
+
* right. Portrait is 8 rows × ~16 chars, text is 6 rows — text is vertically
|
|
97
|
+
* centred inside the portrait with 1 row of padding above.
|
|
105
98
|
*
|
|
106
|
-
* [portrait row
|
|
107
|
-
* [portrait row
|
|
108
|
-
* [portrait row
|
|
109
|
-
* [portrait row
|
|
110
|
-
* [portrait row
|
|
111
|
-
* [portrait row
|
|
112
|
-
* [portrait row
|
|
113
|
-
* [portrait row
|
|
114
|
-
* [portrait row 9] ██║ ██║ ██║██║ ██║...
|
|
115
|
-
* [portrait row 10] ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝...
|
|
116
|
-
* [portrait row 11] blockrun.ai · The AI agent with a wallet · vX
|
|
117
|
-
* [portrait row 12] (empty)
|
|
118
|
-
* [portrait row 13] (empty)
|
|
119
|
-
* [portrait row 14] (empty)
|
|
99
|
+
* [portrait row 1] (empty)
|
|
100
|
+
* [portrait row 2] ███████╗██████╗ █████╗ ...
|
|
101
|
+
* [portrait row 3] ██╔════╝██╔══██╗██╔══██╗...
|
|
102
|
+
* [portrait row 4] █████╗ ██████╔╝███████║...
|
|
103
|
+
* [portrait row 5] ██╔══╝ ██╔══██╗██╔══██║...
|
|
104
|
+
* [portrait row 6] ██║ ██║ ██║██║ ██║...
|
|
105
|
+
* [portrait row 7] ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝...
|
|
106
|
+
* [portrait row 8] blockrun.ai · The AI agent with a wallet · vX
|
|
120
107
|
*/
|
|
121
108
|
function printSideBySide(version) {
|
|
122
|
-
const TEXT_TOP_OFFSET =
|
|
123
|
-
const PORTRAIT_WIDTH =
|
|
109
|
+
const TEXT_TOP_OFFSET = 1; // rows of portrait above the text
|
|
110
|
+
const PORTRAIT_WIDTH = 17; // columns (char width) of the portrait + 1 pad
|
|
124
111
|
const GAP = ' '; // gap between portrait and text
|
|
125
112
|
const portraitRows = BEN_PORTRAIT_ROWS;
|
|
126
113
|
const textRows = FRANKLIN_ART.length;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Franklin Brain — entity extraction from session traces.
|
|
3
|
+
* Uses cheap model to detect people, projects, companies from conversation.
|
|
4
|
+
*/
|
|
5
|
+
import { ModelClient } from '../agent/llm.js';
|
|
6
|
+
import type { Dialogue } from '../agent/types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Extract entities from a session and store in the brain.
|
|
9
|
+
* Fire-and-forget — caller should not await.
|
|
10
|
+
*/
|
|
11
|
+
export declare function extractBrainEntities(history: Dialogue[], sessionId: string, client: ModelClient): Promise<number>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Franklin Brain — entity extraction from session traces.
|
|
3
|
+
* Uses cheap model to detect people, projects, companies from conversation.
|
|
4
|
+
*/
|
|
5
|
+
import { loadEntities, saveEntities, upsertEntity, addObservation, upsertRelation, } from './store.js';
|
|
6
|
+
const EXTRACTION_MODELS = [
|
|
7
|
+
'google/gemini-2.5-flash-lite',
|
|
8
|
+
'google/gemini-2.5-flash',
|
|
9
|
+
'nvidia/nemotron-super-49b',
|
|
10
|
+
];
|
|
11
|
+
const VALID_TYPES = new Set(['person', 'project', 'company', 'product', 'concept']);
|
|
12
|
+
const BRAIN_PROMPT = `You are analyzing a conversation between a user and an AI agent. Extract entities (people, projects, companies, products, concepts) mentioned in the conversation.
|
|
13
|
+
|
|
14
|
+
For each entity, provide:
|
|
15
|
+
- name: canonical name (e.g. "Garry Tan" not "garry" or "Garry")
|
|
16
|
+
- type: person | project | company | product | concept
|
|
17
|
+
- aliases: other names used for the same entity (handles, abbreviations)
|
|
18
|
+
- observations: 1-3 facts learned about this entity from the conversation
|
|
19
|
+
|
|
20
|
+
Also extract relationships between entities:
|
|
21
|
+
- from: entity name
|
|
22
|
+
- to: entity name
|
|
23
|
+
- type: founded | works_on | partnered_with | uses | mentioned | replied_to | depends_on
|
|
24
|
+
|
|
25
|
+
Rules:
|
|
26
|
+
- Only extract entities with CLEAR evidence in the conversation.
|
|
27
|
+
- Do NOT extract the AI agent itself or generic concepts ("TypeScript", "JavaScript").
|
|
28
|
+
- DO extract specific people, specific projects, specific companies, specific products.
|
|
29
|
+
- Observations should be concrete facts, not vague descriptions.
|
|
30
|
+
- If no entities are found, return empty arrays.
|
|
31
|
+
|
|
32
|
+
Respond with ONLY a JSON object (no markdown fences):
|
|
33
|
+
{"entities":[{"name":"...","type":"person","aliases":["@handle"],"observations":["Founded X in 2025"]}],"relations":[{"from":"Person","to":"Project","type":"founded"}]}`;
|
|
34
|
+
function condenseForBrain(history) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
let chars = 0;
|
|
37
|
+
for (const msg of history) {
|
|
38
|
+
if (chars >= 3000)
|
|
39
|
+
break;
|
|
40
|
+
let text = '';
|
|
41
|
+
if (typeof msg.content === 'string') {
|
|
42
|
+
text = msg.content;
|
|
43
|
+
}
|
|
44
|
+
else if (Array.isArray(msg.content)) {
|
|
45
|
+
text = msg.content
|
|
46
|
+
.filter(p => p.type === 'text')
|
|
47
|
+
.map(p => p.text ?? '')
|
|
48
|
+
.join('\n');
|
|
49
|
+
}
|
|
50
|
+
if (!text.trim())
|
|
51
|
+
continue;
|
|
52
|
+
if (text.length > 400)
|
|
53
|
+
text = text.slice(0, 400) + '…';
|
|
54
|
+
const role = msg.role === 'user' ? 'User' : 'Assistant';
|
|
55
|
+
const line = `${role}: ${text}`;
|
|
56
|
+
parts.push(line);
|
|
57
|
+
chars += line.length;
|
|
58
|
+
}
|
|
59
|
+
return parts.join('\n\n');
|
|
60
|
+
}
|
|
61
|
+
function parseExtraction(raw) {
|
|
62
|
+
let cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
|
|
63
|
+
const start = cleaned.indexOf('{');
|
|
64
|
+
const end = cleaned.lastIndexOf('}');
|
|
65
|
+
if (start === -1 || end === -1)
|
|
66
|
+
return { entities: [], relations: [] };
|
|
67
|
+
cleaned = cleaned.slice(start, end + 1);
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(cleaned);
|
|
70
|
+
const entities = (parsed.entities || [])
|
|
71
|
+
.filter((e) => typeof e.name === 'string' && e.name.length > 1 &&
|
|
72
|
+
typeof e.type === 'string' && VALID_TYPES.has(e.type))
|
|
73
|
+
.map((e) => ({
|
|
74
|
+
name: e.name.slice(0, 100),
|
|
75
|
+
type: e.type,
|
|
76
|
+
aliases: Array.isArray(e.aliases) ? e.aliases.slice(0, 5) : [],
|
|
77
|
+
observations: Array.isArray(e.observations)
|
|
78
|
+
? e.observations.filter(o => typeof o === 'string' && o.length > 5).slice(0, 5)
|
|
79
|
+
: [],
|
|
80
|
+
}));
|
|
81
|
+
const relations = (parsed.relations || [])
|
|
82
|
+
.filter((r) => typeof r.from === 'string' && typeof r.to === 'string' && typeof r.type === 'string')
|
|
83
|
+
.map((r) => ({
|
|
84
|
+
from: r.from,
|
|
85
|
+
to: r.to,
|
|
86
|
+
type: r.type.slice(0, 30),
|
|
87
|
+
}));
|
|
88
|
+
return { entities, relations };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { entities: [], relations: [] };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Extract entities from a session and store in the brain.
|
|
96
|
+
* Fire-and-forget — caller should not await.
|
|
97
|
+
*/
|
|
98
|
+
export async function extractBrainEntities(history, sessionId, client) {
|
|
99
|
+
if (history.length < 4)
|
|
100
|
+
return 0;
|
|
101
|
+
const condensed = condenseForBrain(history);
|
|
102
|
+
if (condensed.length < 80)
|
|
103
|
+
return 0;
|
|
104
|
+
let result = null;
|
|
105
|
+
for (const model of EXTRACTION_MODELS) {
|
|
106
|
+
try {
|
|
107
|
+
const response = await client.complete({
|
|
108
|
+
model,
|
|
109
|
+
messages: [{ role: 'user', content: condensed }],
|
|
110
|
+
system: BRAIN_PROMPT,
|
|
111
|
+
max_tokens: 1500,
|
|
112
|
+
temperature: 0.2,
|
|
113
|
+
});
|
|
114
|
+
const text = response.content
|
|
115
|
+
.filter(p => p.type === 'text')
|
|
116
|
+
.map(p => p.text ?? '')
|
|
117
|
+
.join('');
|
|
118
|
+
result = parseExtraction(text);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!result || (result.entities.length === 0 && result.relations.length === 0))
|
|
126
|
+
return 0;
|
|
127
|
+
// Store entities + observations
|
|
128
|
+
const entities = loadEntities();
|
|
129
|
+
const nameToId = new Map();
|
|
130
|
+
for (const extracted of result.entities) {
|
|
131
|
+
const entityId = upsertEntity(entities, extracted.name, extracted.type, extracted.aliases);
|
|
132
|
+
nameToId.set(extracted.name.toLowerCase(), entityId);
|
|
133
|
+
for (const obs of extracted.observations) {
|
|
134
|
+
addObservation(entityId, obs, sessionId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
saveEntities(entities);
|
|
138
|
+
// Store relations
|
|
139
|
+
for (const rel of result.relations) {
|
|
140
|
+
const fromId = nameToId.get(rel.from.toLowerCase()) ||
|
|
141
|
+
findEntityIdByName(entities, rel.from);
|
|
142
|
+
const toId = nameToId.get(rel.to.toLowerCase()) ||
|
|
143
|
+
findEntityIdByName(entities, rel.to);
|
|
144
|
+
if (fromId && toId) {
|
|
145
|
+
upsertRelation(fromId, toId, rel.type);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return result.entities.length;
|
|
149
|
+
}
|
|
150
|
+
function findEntityIdByName(entities, name) {
|
|
151
|
+
const lower = name.toLowerCase();
|
|
152
|
+
return entities.find(e => e.name.toLowerCase() === lower ||
|
|
153
|
+
e.aliases.some(a => a.toLowerCase() === lower))?.id;
|
|
154
|
+
}
|