@blockrun/franklin 3.3.3 → 3.5.1

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.
Files changed (109) hide show
  1. package/README.md +65 -25
  2. package/dist/agent/commands.d.ts +1 -1
  3. package/dist/agent/commands.js +128 -17
  4. package/dist/agent/compact.d.ts +2 -2
  5. package/dist/agent/compact.js +148 -22
  6. package/dist/agent/context.d.ts +8 -3
  7. package/dist/agent/context.js +301 -108
  8. package/dist/agent/error-classifier.d.ts +11 -2
  9. package/dist/agent/error-classifier.js +64 -10
  10. package/dist/agent/llm.d.ts +8 -1
  11. package/dist/agent/llm.js +114 -19
  12. package/dist/agent/loop.d.ts +1 -2
  13. package/dist/agent/loop.js +509 -61
  14. package/dist/agent/optimize.d.ts +2 -2
  15. package/dist/agent/optimize.js +9 -7
  16. package/dist/agent/permissions.d.ts +1 -1
  17. package/dist/agent/permissions.js +1 -1
  18. package/dist/agent/planner.d.ts +42 -0
  19. package/dist/agent/planner.js +110 -0
  20. package/dist/agent/reduce.d.ts +7 -1
  21. package/dist/agent/reduce.js +85 -3
  22. package/dist/agent/streaming-executor.d.ts +6 -1
  23. package/dist/agent/streaming-executor.js +83 -5
  24. package/dist/agent/tokens.d.ts +11 -2
  25. package/dist/agent/tokens.js +38 -5
  26. package/dist/agent/tool-guard.d.ts +27 -0
  27. package/dist/agent/tool-guard.js +324 -0
  28. package/dist/agent/types.d.ts +7 -1
  29. package/dist/agent/types.js +1 -1
  30. package/dist/brain/extract.d.ts +11 -0
  31. package/dist/brain/extract.js +154 -0
  32. package/dist/brain/index.d.ts +3 -0
  33. package/dist/brain/index.js +2 -0
  34. package/dist/brain/store.d.ts +42 -0
  35. package/dist/brain/store.js +225 -0
  36. package/dist/brain/types.d.ts +45 -0
  37. package/dist/brain/types.js +5 -0
  38. package/dist/commands/daemon.js +2 -1
  39. package/dist/commands/start.js +19 -7
  40. package/dist/config.js +1 -1
  41. package/dist/index.js +27 -2
  42. package/dist/learnings/extractor.d.ts +13 -0
  43. package/dist/learnings/extractor.js +69 -8
  44. package/dist/learnings/index.d.ts +1 -1
  45. package/dist/learnings/index.js +1 -1
  46. package/dist/learnings/store.js +42 -13
  47. package/dist/learnings/types.d.ts +1 -1
  48. package/dist/mcp/client.d.ts +1 -1
  49. package/dist/mcp/client.js +5 -5
  50. package/dist/mcp/config.d.ts +1 -1
  51. package/dist/mcp/config.js +1 -1
  52. package/dist/panel/html.d.ts +2 -0
  53. package/dist/panel/html.js +409 -146
  54. package/dist/panel/server.js +19 -0
  55. package/dist/pricing.js +3 -2
  56. package/dist/proxy/fallback.d.ts +3 -1
  57. package/dist/proxy/fallback.js +4 -4
  58. package/dist/proxy/server.js +29 -11
  59. package/dist/proxy/sse-translator.js +1 -1
  60. package/dist/router/categories.d.ts +21 -0
  61. package/dist/router/categories.js +96 -0
  62. package/dist/router/index.d.ts +9 -2
  63. package/dist/router/index.js +106 -27
  64. package/dist/router/local-elo.d.ts +32 -0
  65. package/dist/router/local-elo.js +107 -0
  66. package/dist/router/selector.d.ts +46 -0
  67. package/dist/router/selector.js +106 -0
  68. package/dist/session/storage.d.ts +5 -1
  69. package/dist/session/storage.js +24 -2
  70. package/dist/social/a11y.d.ts +1 -1
  71. package/dist/social/a11y.js +5 -1
  72. package/dist/social/browser.d.ts +5 -0
  73. package/dist/social/browser.js +22 -0
  74. package/dist/social/preflight.d.ts +4 -0
  75. package/dist/social/preflight.js +42 -3
  76. package/dist/stats/failures.d.ts +20 -0
  77. package/dist/stats/failures.js +63 -0
  78. package/dist/stats/format.d.ts +6 -0
  79. package/dist/stats/format.js +23 -0
  80. package/dist/stats/insights.js +1 -21
  81. package/dist/stats/session-tracker.d.ts +21 -0
  82. package/dist/stats/session-tracker.js +28 -0
  83. package/dist/stats/tracker.d.ts +1 -1
  84. package/dist/stats/tracker.js +1 -1
  85. package/dist/tools/bash.d.ts +14 -1
  86. package/dist/tools/bash.js +132 -7
  87. package/dist/tools/edit.js +77 -14
  88. package/dist/tools/glob.js +13 -3
  89. package/dist/tools/grep.js +30 -12
  90. package/dist/tools/imagegen.js +5 -5
  91. package/dist/tools/index.d.ts +1 -1
  92. package/dist/tools/index.js +5 -1
  93. package/dist/tools/read.d.ts +16 -2
  94. package/dist/tools/read.js +36 -8
  95. package/dist/tools/searchx.d.ts +6 -2
  96. package/dist/tools/searchx.js +221 -44
  97. package/dist/tools/subagent.js +37 -3
  98. package/dist/tools/task.js +43 -7
  99. package/dist/tools/validate.d.ts +11 -0
  100. package/dist/tools/validate.js +42 -0
  101. package/dist/tools/webfetch.js +18 -7
  102. package/dist/tools/websearch.js +41 -7
  103. package/dist/tools/write.js +26 -6
  104. package/dist/ui/app.js +31 -6
  105. package/dist/ui/model-picker.d.ts +1 -1
  106. package/dist/ui/model-picker.js +1 -1
  107. package/dist/ui/terminal.d.ts +1 -1
  108. package/dist/ui/terminal.js +1 -1
  109. 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
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Core types for the runcode agent system.
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 {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Core types for the runcode agent system.
2
+ * Core types for the Franklin agent system.
3
3
  * All type names and structures are original designs.
4
4
  */
5
5
  export {};
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export type { Entity, EntityType, Observation, Relation, BrainExtraction } from './types.js';
2
+ export { loadEntities, saveEntities, findEntity, upsertEntity, loadObservations, getEntityObservations, addObservation, loadRelations, getEntityRelations, upsertRelation, searchEntities, buildEntityContext, getBrainStats, } from './store.js';
3
+ export { extractBrainEntities } from './extract.js';
@@ -0,0 +1,2 @@
1
+ export { loadEntities, saveEntities, findEntity, upsertEntity, loadObservations, getEntityObservations, addObservation, loadRelations, getEntityRelations, upsertRelation, searchEntities, buildEntityContext, getBrainStats, } from './store.js';
2
+ export { extractBrainEntities } from './extract.js';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Franklin Brain — JSONL storage for entities, observations, relations.
3
+ * All in-memory with JSONL persistence. No database.
4
+ */
5
+ import type { Entity, EntityType, Observation, Relation } from './types.js';
6
+ export declare function loadEntities(): Entity[];
7
+ export declare function saveEntities(entities: Entity[]): void;
8
+ /**
9
+ * Find entity by name or alias (case-insensitive).
10
+ */
11
+ export declare function findEntity(entities: Entity[], nameOrAlias: string): Entity | undefined;
12
+ /**
13
+ * Create or update an entity. Returns the entity ID.
14
+ * If an entity with a matching name/alias exists, merges aliases and bumps reference_count.
15
+ */
16
+ export declare function upsertEntity(entities: Entity[], name: string, type: EntityType, aliases?: string[]): string;
17
+ export declare function loadObservations(): Observation[];
18
+ export declare function getEntityObservations(entityId: string): Observation[];
19
+ /**
20
+ * Add an observation. Deduplicates by content similarity (exact match).
21
+ */
22
+ export declare function addObservation(entityId: string, content: string, source: string, confidence?: number, tags?: string[]): void;
23
+ export declare function loadRelations(): Relation[];
24
+ export declare function getEntityRelations(entityId: string): Relation[];
25
+ /**
26
+ * Add or update a relation. If same from+to+type exists, bumps count.
27
+ */
28
+ export declare function upsertRelation(fromId: string, toId: string, type: string, confidence?: number): void;
29
+ /**
30
+ * Search entities by name/alias substring match.
31
+ */
32
+ export declare function searchEntities(query: string, limit?: number): Entity[];
33
+ /**
34
+ * Build context string for entities mentioned in the conversation.
35
+ * Returns empty string if no relevant entities found.
36
+ */
37
+ export declare function buildEntityContext(mentionedNames: string[]): string;
38
+ export declare function getBrainStats(): {
39
+ entities: number;
40
+ observations: number;
41
+ relations: number;
42
+ };