@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.
Files changed (110) hide show
  1. package/README.md +58 -7
  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/banner.js +27 -40
  31. package/dist/brain/extract.d.ts +11 -0
  32. package/dist/brain/extract.js +154 -0
  33. package/dist/brain/index.d.ts +3 -0
  34. package/dist/brain/index.js +2 -0
  35. package/dist/brain/store.d.ts +42 -0
  36. package/dist/brain/store.js +225 -0
  37. package/dist/brain/types.d.ts +45 -0
  38. package/dist/brain/types.js +5 -0
  39. package/dist/commands/daemon.js +2 -1
  40. package/dist/commands/start.js +16 -3
  41. package/dist/config.js +1 -1
  42. package/dist/index.js +27 -2
  43. package/dist/learnings/extractor.d.ts +13 -0
  44. package/dist/learnings/extractor.js +69 -8
  45. package/dist/learnings/index.d.ts +1 -1
  46. package/dist/learnings/index.js +1 -1
  47. package/dist/learnings/store.js +42 -13
  48. package/dist/learnings/types.d.ts +1 -1
  49. package/dist/mcp/client.d.ts +1 -1
  50. package/dist/mcp/client.js +5 -5
  51. package/dist/mcp/config.d.ts +1 -1
  52. package/dist/mcp/config.js +1 -1
  53. package/dist/panel/html.d.ts +2 -0
  54. package/dist/panel/html.js +409 -146
  55. package/dist/panel/server.js +19 -0
  56. package/dist/pricing.js +3 -2
  57. package/dist/proxy/fallback.d.ts +3 -1
  58. package/dist/proxy/fallback.js +4 -4
  59. package/dist/proxy/server.js +29 -11
  60. package/dist/proxy/sse-translator.js +1 -1
  61. package/dist/router/categories.d.ts +21 -0
  62. package/dist/router/categories.js +96 -0
  63. package/dist/router/index.d.ts +9 -2
  64. package/dist/router/index.js +106 -27
  65. package/dist/router/local-elo.d.ts +32 -0
  66. package/dist/router/local-elo.js +107 -0
  67. package/dist/router/selector.d.ts +46 -0
  68. package/dist/router/selector.js +106 -0
  69. package/dist/session/storage.d.ts +5 -1
  70. package/dist/session/storage.js +24 -2
  71. package/dist/social/a11y.d.ts +1 -1
  72. package/dist/social/a11y.js +5 -1
  73. package/dist/social/browser.d.ts +5 -0
  74. package/dist/social/browser.js +22 -0
  75. package/dist/social/preflight.d.ts +4 -0
  76. package/dist/social/preflight.js +42 -3
  77. package/dist/stats/failures.d.ts +20 -0
  78. package/dist/stats/failures.js +63 -0
  79. package/dist/stats/format.d.ts +6 -0
  80. package/dist/stats/format.js +23 -0
  81. package/dist/stats/insights.js +1 -21
  82. package/dist/stats/session-tracker.d.ts +21 -0
  83. package/dist/stats/session-tracker.js +28 -0
  84. package/dist/stats/tracker.d.ts +1 -1
  85. package/dist/stats/tracker.js +1 -1
  86. package/dist/tools/bash.d.ts +14 -1
  87. package/dist/tools/bash.js +132 -7
  88. package/dist/tools/edit.js +77 -14
  89. package/dist/tools/glob.js +13 -3
  90. package/dist/tools/grep.js +30 -12
  91. package/dist/tools/imagegen.js +3 -3
  92. package/dist/tools/index.d.ts +1 -1
  93. package/dist/tools/index.js +5 -1
  94. package/dist/tools/read.d.ts +16 -2
  95. package/dist/tools/read.js +36 -8
  96. package/dist/tools/searchx.d.ts +6 -2
  97. package/dist/tools/searchx.js +221 -44
  98. package/dist/tools/subagent.js +37 -3
  99. package/dist/tools/task.js +43 -7
  100. package/dist/tools/validate.d.ts +11 -0
  101. package/dist/tools/validate.js +42 -0
  102. package/dist/tools/webfetch.js +18 -7
  103. package/dist/tools/websearch.js +41 -7
  104. package/dist/tools/write.js +26 -6
  105. package/dist/ui/app.js +31 -6
  106. package/dist/ui/model-picker.d.ts +1 -1
  107. package/dist/ui/model-picker.js +1 -1
  108. package/dist/ui/terminal.d.ts +1 -1
  109. package/dist/ui/terminal.js +1 -1
  110. package/package.json +2 -2
@@ -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
+ };
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Franklin Brain — JSONL storage for entities, observations, relations.
3
+ * All in-memory with JSONL persistence. No database.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import crypto from 'node:crypto';
8
+ import { BLOCKRUN_DIR } from '../config.js';
9
+ const BRAIN_DIR = path.join(BLOCKRUN_DIR, 'brain');
10
+ const ENTITIES_FILE = path.join(BRAIN_DIR, 'entities.jsonl');
11
+ const OBSERVATIONS_FILE = path.join(BRAIN_DIR, 'observations.jsonl');
12
+ const RELATIONS_FILE = path.join(BRAIN_DIR, 'relations.jsonl');
13
+ const MAX_ENTITIES = 200;
14
+ function uid() { return crypto.randomBytes(8).toString('hex'); }
15
+ function ensureDir() {
16
+ fs.mkdirSync(BRAIN_DIR, { recursive: true });
17
+ }
18
+ // ─── Generic JSONL helpers ────────────────────────────────────────────────
19
+ function loadJsonl(file) {
20
+ try {
21
+ const raw = fs.readFileSync(file, 'utf-8');
22
+ const results = [];
23
+ for (const line of raw.split('\n')) {
24
+ if (!line.trim())
25
+ continue;
26
+ try {
27
+ results.push(JSON.parse(line));
28
+ }
29
+ catch { /* skip corrupt */ }
30
+ }
31
+ return results;
32
+ }
33
+ catch {
34
+ return [];
35
+ }
36
+ }
37
+ function saveJsonl(file, items) {
38
+ ensureDir();
39
+ const tmp = file + '.tmp';
40
+ fs.writeFileSync(tmp, items.map(i => JSON.stringify(i)).join('\n') + '\n');
41
+ fs.renameSync(tmp, file);
42
+ }
43
+ function appendJsonl(file, item) {
44
+ ensureDir();
45
+ fs.appendFileSync(file, JSON.stringify(item) + '\n');
46
+ }
47
+ // ─── Entities ─────────────────────────────────────────────────────────────
48
+ export function loadEntities() {
49
+ return loadJsonl(ENTITIES_FILE);
50
+ }
51
+ export function saveEntities(entities) {
52
+ saveJsonl(ENTITIES_FILE, entities);
53
+ }
54
+ /**
55
+ * Find entity by name or alias (case-insensitive).
56
+ */
57
+ export function findEntity(entities, nameOrAlias) {
58
+ const lower = nameOrAlias.toLowerCase().trim();
59
+ return entities.find(e => e.name.toLowerCase() === lower ||
60
+ e.aliases.some(a => a.toLowerCase() === lower));
61
+ }
62
+ /**
63
+ * Create or update an entity. Returns the entity ID.
64
+ * If an entity with a matching name/alias exists, merges aliases and bumps reference_count.
65
+ */
66
+ export function upsertEntity(entities, name, type, aliases = []) {
67
+ const existing = findEntity(entities, name) ||
68
+ aliases.map(a => findEntity(entities, a)).find(Boolean);
69
+ if (existing) {
70
+ // Merge aliases
71
+ const allAliases = new Set([...existing.aliases, ...aliases, name]);
72
+ allAliases.delete(existing.name); // Don't alias canonical name
73
+ existing.aliases = [...allAliases];
74
+ existing.reference_count++;
75
+ existing.updated_at = Date.now();
76
+ return existing.id;
77
+ }
78
+ // New entity
79
+ const entity = {
80
+ id: uid(),
81
+ type,
82
+ name,
83
+ aliases: aliases.filter(a => a.toLowerCase() !== name.toLowerCase()),
84
+ created_at: Date.now(),
85
+ updated_at: Date.now(),
86
+ reference_count: 1,
87
+ };
88
+ entities.push(entity);
89
+ // Cap at MAX_ENTITIES — prune least-referenced
90
+ if (entities.length > MAX_ENTITIES) {
91
+ entities.sort((a, b) => b.reference_count - a.reference_count);
92
+ entities.length = MAX_ENTITIES;
93
+ }
94
+ return entity.id;
95
+ }
96
+ // ─── Observations ─────────────────────────────────────────────────────────
97
+ export function loadObservations() {
98
+ return loadJsonl(OBSERVATIONS_FILE);
99
+ }
100
+ export function getEntityObservations(entityId) {
101
+ return loadObservations().filter(o => o.entity_id === entityId);
102
+ }
103
+ /**
104
+ * Add an observation. Deduplicates by content similarity (exact match).
105
+ */
106
+ export function addObservation(entityId, content, source, confidence = 0.8, tags = ['fact']) {
107
+ const existing = loadObservations();
108
+ const contentLower = content.toLowerCase().trim();
109
+ // Skip exact duplicates for this entity
110
+ if (existing.some(o => o.entity_id === entityId && o.content.toLowerCase().trim() === contentLower)) {
111
+ return;
112
+ }
113
+ appendJsonl(OBSERVATIONS_FILE, {
114
+ id: uid(),
115
+ entity_id: entityId,
116
+ content,
117
+ source,
118
+ confidence,
119
+ tags,
120
+ created_at: Date.now(),
121
+ });
122
+ }
123
+ // ─── Relations ────────────────────────────────────────────────────────────
124
+ export function loadRelations() {
125
+ return loadJsonl(RELATIONS_FILE);
126
+ }
127
+ export function getEntityRelations(entityId) {
128
+ return loadRelations().filter(r => r.from_id === entityId || r.to_id === entityId);
129
+ }
130
+ /**
131
+ * Add or update a relation. If same from+to+type exists, bumps count.
132
+ */
133
+ export function upsertRelation(fromId, toId, type, confidence = 0.8) {
134
+ const relations = loadRelations();
135
+ const existing = relations.find(r => r.from_id === fromId && r.to_id === toId && r.type === type);
136
+ if (existing) {
137
+ existing.count++;
138
+ existing.last_seen = Date.now();
139
+ existing.confidence = Math.min(existing.confidence + 0.05, 1.0);
140
+ saveJsonl(RELATIONS_FILE, relations);
141
+ }
142
+ else {
143
+ appendJsonl(RELATIONS_FILE, {
144
+ id: uid(),
145
+ from_id: fromId,
146
+ to_id: toId,
147
+ type,
148
+ confidence,
149
+ count: 1,
150
+ last_seen: Date.now(),
151
+ });
152
+ }
153
+ }
154
+ // ─── Search ───────────────────────────────────────────────────────────────
155
+ /**
156
+ * Search entities by name/alias substring match.
157
+ */
158
+ export function searchEntities(query, limit = 10) {
159
+ const lower = query.toLowerCase().trim();
160
+ if (!lower)
161
+ return [];
162
+ return loadEntities()
163
+ .filter(e => e.name.toLowerCase().includes(lower) ||
164
+ e.aliases.some(a => a.toLowerCase().includes(lower)))
165
+ .sort((a, b) => b.reference_count - a.reference_count)
166
+ .slice(0, limit);
167
+ }
168
+ // ─── Context building (for system prompt injection) ───────────────────────
169
+ const MAX_BRAIN_CHARS = 1500;
170
+ /**
171
+ * Build context string for entities mentioned in the conversation.
172
+ * Returns empty string if no relevant entities found.
173
+ */
174
+ export function buildEntityContext(mentionedNames) {
175
+ if (mentionedNames.length === 0)
176
+ return '';
177
+ const entities = loadEntities();
178
+ const matched = [];
179
+ for (const name of mentionedNames) {
180
+ const entity = findEntity(entities, name);
181
+ if (entity)
182
+ matched.push(entity);
183
+ }
184
+ if (matched.length === 0)
185
+ return '';
186
+ const lines = ['# Known Entities'];
187
+ let chars = lines[0].length;
188
+ for (const entity of matched) {
189
+ const observations = getEntityObservations(entity.id)
190
+ .sort((a, b) => b.confidence - a.confidence)
191
+ .slice(0, 5);
192
+ const relations = getEntityRelations(entity.id);
193
+ const header = `\n## ${entity.name} (${entity.type})`;
194
+ if (chars + header.length > MAX_BRAIN_CHARS)
195
+ break;
196
+ lines.push(header);
197
+ chars += header.length;
198
+ for (const obs of observations) {
199
+ const line = `- ${obs.content}`;
200
+ if (chars + line.length + 1 > MAX_BRAIN_CHARS)
201
+ break;
202
+ lines.push(line);
203
+ chars += line.length + 1;
204
+ }
205
+ for (const rel of relations.slice(0, 3)) {
206
+ const otherEntity = entities.find(e => e.id === (rel.from_id === entity.id ? rel.to_id : rel.from_id));
207
+ if (!otherEntity)
208
+ continue;
209
+ const line = `- ${rel.type} → ${otherEntity.name}`;
210
+ if (chars + line.length + 1 > MAX_BRAIN_CHARS)
211
+ break;
212
+ lines.push(line);
213
+ chars += line.length + 1;
214
+ }
215
+ }
216
+ return lines.length > 1 ? lines.join('\n') : '';
217
+ }
218
+ // ─── Stats ────────────────────────────────────────────────────────────────
219
+ export function getBrainStats() {
220
+ return {
221
+ entities: loadEntities().length,
222
+ observations: loadObservations().length,
223
+ relations: loadRelations().length,
224
+ };
225
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Franklin Brain — entity-based knowledge graph.
3
+ * Inspired by GBrain (Garry Tan). Lightweight JSONL, no database.
4
+ */
5
+ export type EntityType = 'person' | 'project' | 'company' | 'product' | 'concept';
6
+ export interface Entity {
7
+ id: string;
8
+ type: EntityType;
9
+ name: string;
10
+ aliases: string[];
11
+ created_at: number;
12
+ updated_at: number;
13
+ reference_count: number;
14
+ }
15
+ export interface Observation {
16
+ id: string;
17
+ entity_id: string;
18
+ content: string;
19
+ source: string;
20
+ confidence: number;
21
+ tags: string[];
22
+ created_at: number;
23
+ }
24
+ export interface Relation {
25
+ id: string;
26
+ from_id: string;
27
+ to_id: string;
28
+ type: string;
29
+ confidence: number;
30
+ count: number;
31
+ last_seen: number;
32
+ }
33
+ export interface BrainExtraction {
34
+ entities: Array<{
35
+ name: string;
36
+ type: EntityType;
37
+ aliases?: string[];
38
+ observations: string[];
39
+ }>;
40
+ relations: Array<{
41
+ from: string;
42
+ to: string;
43
+ type: string;
44
+ }>;
45
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Franklin Brain — entity-based knowledge graph.
3
+ * Inspired by GBrain (Garry Tan). Lightweight JSONL, no database.
4
+ */
5
+ export {};
@@ -50,7 +50,8 @@ export async function daemonCommand(action, options) {
50
50
  fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
51
51
  const child = spawn(runcodeBin, ['proxy', '--port', String(port)], {
52
52
  detached: true,
53
- stdio: ['ignore', fs.openSync(LOG_FILE, 'a'), fs.openSync(LOG_FILE, 'a')],
53
+ // stdout → /dev/null (banner + startup messages), stderr → log file (debug/errors only)
54
+ stdio: ['ignore', 'ignore', fs.openSync(LOG_FILE, 'a')],
54
55
  });
55
56
  child.unref();
56
57
  fs.writeFileSync(PID_FILE, String(child.pid));
@@ -7,6 +7,7 @@ import { printBanner } from '../banner.js';
7
7
  import { assembleInstructions } from '../agent/context.js';
8
8
  import { interactiveSession } from '../agent/loop.js';
9
9
  import { allCapabilities, createSubAgentCapability } from '../tools/index.js';
10
+ import { validateToolDescriptions } from '../tools/validate.js';
10
11
  import { launchInkUI } from '../ui/app.js';
11
12
  import { pickModel, resolveModel } from '../ui/model-picker.js';
12
13
  import { loadMcpConfig } from '../mcp/config.js';
@@ -112,7 +113,7 @@ export async function startCommand(options) {
112
113
  onBalanceFetched?.(balStr);
113
114
  })();
114
115
  // Assemble system instructions
115
- const systemInstructions = assembleInstructions(workDir);
116
+ const systemInstructions = assembleInstructions(workDir, model);
116
117
  // Connect MCP servers (non-blocking — add tools if servers are available)
117
118
  const mcpConfig = loadMcpConfig(workDir);
118
119
  let mcpTools = [];
@@ -133,6 +134,13 @@ export async function startCommand(options) {
133
134
  // Build capabilities (built-in + MCP + sub-agent)
134
135
  const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities);
135
136
  const capabilities = [...allCapabilities, ...mcpTools, subAgent];
137
+ // Validate tool descriptions (self-evolution: detect SearchX-style description bugs)
138
+ if (options.debug) {
139
+ const issues = validateToolDescriptions(capabilities);
140
+ for (const issue of issues) {
141
+ console.error(`[validate] ${issue.severity}: ${issue.toolName} — ${issue.issue}`);
142
+ }
143
+ }
136
144
  // Agent config
137
145
  const agentConfig = {
138
146
  model,
@@ -214,11 +222,16 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
214
222
  if (sessionHistory && sessionHistory.length >= 4) {
215
223
  try {
216
224
  const { extractLearnings } = await import('../learnings/extractor.js');
225
+ const { extractBrainEntities } = await import('../brain/extract.js');
217
226
  const { ModelClient } = await import('../agent/llm.js');
218
227
  const client = new ModelClient({ apiUrl: agentConfig.apiUrl, chain: agentConfig.chain });
228
+ const sid = `session-${new Date().toISOString()}`;
219
229
  await Promise.race([
220
- extractLearnings(sessionHistory, `session-${new Date().toISOString()}`, client),
221
- new Promise(resolve => setTimeout(resolve, 10_000)),
230
+ Promise.all([
231
+ extractLearnings(sessionHistory, sid, client),
232
+ extractBrainEntities(sessionHistory, sid, client),
233
+ ]),
234
+ new Promise(resolve => setTimeout(resolve, 15_000)),
222
235
  ]);
223
236
  }
224
237
  catch { /* extraction is best-effort */ }
package/dist/config.js CHANGED
@@ -11,7 +11,7 @@ try {
11
11
  catch { /* use default */ }
12
12
  export const VERSION = _version;
13
13
  // Shared User-Agent string for all outbound HTTP requests
14
- export const USER_AGENT = `runcode/${_version} (node/${process.versions.node}; ${process.platform}; ${process.arch})`;
14
+ export const USER_AGENT = `franklin/${_version} (node/${process.versions.node}; ${process.platform}; ${process.arch})`;
15
15
  export const BLOCKRUN_DIR = path.join(os.homedir(), '.blockrun');
16
16
  export const CHAIN_FILE = path.join(BLOCKRUN_DIR, 'payment-chain');
17
17
  export const API_URLS = {
package/dist/index.js CHANGED
@@ -173,10 +173,25 @@ program
173
173
  });
174
174
  // Default action: if no subcommand given, run 'start'
175
175
  const args = process.argv.slice(2);
176
- const knownCommands = program.commands.map(c => c.name());
177
176
  const firstArg = args[0];
177
+ const HELP_FLAGS = new Set(['-h', '--help']);
178
+ const VERSION_FLAGS = new Set(['-V', '--version']);
179
+ const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model']);
180
+ function hasAnyFlag(argv, flags) {
181
+ return argv.some(arg => flags.has(arg));
182
+ }
183
+ function hasStartOnlyFlag(argv) {
184
+ return argv.some(arg => START_ONLY_FLAGS.has(arg));
185
+ }
178
186
  // Handle chain shortcuts: `runcode solana` or `runcode base`
179
187
  if (firstArg === 'solana' || firstArg === 'base') {
188
+ if (hasAnyFlag(args, HELP_FLAGS)) {
189
+ program.parse(['node', 'franklin', 'start', '--help']);
190
+ }
191
+ if (hasAnyFlag(args, VERSION_FLAGS)) {
192
+ console.log(version);
193
+ process.exit(0);
194
+ }
180
195
  const { saveChain } = await import('./config.js');
181
196
  saveChain(firstArg);
182
197
  const startOpts = { version };
@@ -192,7 +207,17 @@ if (firstArg === 'solana' || firstArg === 'base') {
192
207
  await startCommand(startOpts);
193
208
  process.exit(0);
194
209
  }
195
- else if (!firstArg || (firstArg.startsWith('-') && !['-h', '--help', '-V', '--version'].includes(firstArg))) {
210
+ else if (!firstArg || firstArg.startsWith('-')) {
211
+ if (hasAnyFlag(args, HELP_FLAGS) && hasStartOnlyFlag(args)) {
212
+ program.parse(['node', 'franklin', 'start', '--help']);
213
+ }
214
+ if (hasAnyFlag(args, VERSION_FLAGS) && hasStartOnlyFlag(args)) {
215
+ console.log(version);
216
+ process.exit(0);
217
+ }
218
+ if (hasAnyFlag(args, HELP_FLAGS) || hasAnyFlag(args, VERSION_FLAGS)) {
219
+ program.parse();
220
+ }
196
221
  // No subcommand or only flags — treat as 'start' with flags
197
222
  const startOpts = { version };
198
223
  for (let i = 0; i < args.length; i++) {
@@ -14,3 +14,16 @@ export declare function bootstrapFromClaudeConfig(client: ModelClient): Promise<
14
14
  * Runs asynchronously — caller should fire-and-forget.
15
15
  */
16
16
  export declare function extractLearnings(history: Dialogue[], sessionId: string, client: ModelClient): Promise<void>;
17
+ /**
18
+ * Check if mid-session extraction should run, and if so, run it in background.
19
+ * Called from the agent loop after tool execution completes.
20
+ *
21
+ * Triggers when:
22
+ * 1. Token count exceeds init threshold (first extraction) OR update threshold (subsequent)
23
+ * 2. AND enough tool calls have happened since last extraction
24
+ * 3. AND we haven't hit the per-session cap
25
+ *
26
+ * Inspired by Claude Code's SessionMemory which runs a background subagent
27
+ * to extract conversation notes periodically.
28
+ */
29
+ export declare function maybeMidSessionExtract(history: Dialogue[], estimatedTokens: number, totalToolCalls: number, sessionId: string, client: ModelClient): void;
@@ -6,17 +6,19 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import os from 'node:os';
8
8
  import { loadLearnings, mergeLearning, saveLearnings } from './store.js';
9
- // Cheapest models that reliably output structured JSON
9
+ // Free models for learning extraction JSON extraction is simple enough.
10
+ // Ordered by reliability: try the best free model first, fall back to others.
10
11
  const EXTRACTION_MODELS = [
11
- 'google/gemini-2.5-flash-lite',
12
- 'google/gemini-2.5-flash',
13
- 'nvidia/nemotron-super-49b',
12
+ 'nvidia/nemotron-ultra-253b', // Best free model for structured output
13
+ 'nvidia/qwen3-coder-480b', // Strong at JSON tasks
14
+ 'nvidia/devstral-2-123b', // Fallback
14
15
  ];
15
16
  const VALID_CATEGORIES = new Set([
16
17
  'language', 'model_preference', 'tool_pattern', 'coding_style',
17
- 'communication', 'domain', 'correction', 'workflow', 'other',
18
+ 'communication', 'domain', 'correction', 'negative', 'project_context',
19
+ 'workflow', 'other',
18
20
  ]);
19
- const EXTRACTION_PROMPT = `You are analyzing a conversation between a user and an AI coding agent. Extract user preferences and behavioral patterns that would help personalize future interactions.
21
+ const EXTRACTION_PROMPT = `You are analyzing a conversation between a user and an AI coding agent. Extract user preferences, behavioral patterns, and project knowledge that would help personalize future interactions.
20
22
 
21
23
  Analyze for:
22
24
  1. Language — what language does the user write in? (English, Chinese, mixed?)
@@ -25,16 +27,21 @@ Analyze for:
25
27
  4. Communication — are they terse or verbose? Do they want explanations or just code?
26
28
  5. Domain — what tech stack, frameworks, or project type?
27
29
  6. Corrections — did they repeatedly correct the same agent behavior?
28
- 7. Workflow — do they prefer short tasks or long planning sessions?
30
+ 7. **Negative signals** did the user say "don't do X", "stop doing Y", "never Z"? These are HIGH PRIORITY (confidence 0.9+). Use category "negative".
31
+ 8. **Project context** — architecture decisions, key file locations, deployment patterns, team conventions. Use category "project_context".
32
+ 9. Workflow — do they prefer short tasks or long planning sessions?
29
33
 
30
34
  Rules:
31
35
  - ONLY extract signals clearly supported by evidence in the conversation.
32
36
  - Do NOT speculate. If evidence is weak, set confidence below 0.5.
37
+ - **Negative signals get HIGH confidence** (0.9+) — when a user says "don't" or "stop" or corrects the agent, that's a strong signal.
38
+ - **Project context gets MEDIUM confidence** (0.7) — architecture/tech decisions are usually deliberate.
33
39
  - If the conversation is too short or generic, return an empty array.
34
40
  - Each learning should be one clear, actionable sentence.
41
+ - For negative learnings, start with "NEVER" or "Do NOT" to make the instruction clear.
35
42
 
36
43
  Respond with ONLY a JSON object (no markdown fences, no commentary):
37
- {"learnings":[{"learning":"...","category":"language|model_preference|tool_pattern|coding_style|communication|domain|correction|workflow|other","confidence":0.5}]}`;
44
+ {"learnings":[{"learning":"...","category":"language|model_preference|tool_pattern|coding_style|communication|domain|correction|negative|project_context|workflow|other","confidence":0.5}]}`;
38
45
  /**
39
46
  * Condense session history into a compact text for extraction.
40
47
  * Only includes user messages and assistant text — skips tool calls/results.
@@ -198,6 +205,9 @@ export async function extractLearnings(history, sessionId, client) {
198
205
  const condensed = condenseHistory(history);
199
206
  if (condensed.length < 100)
200
207
  return; // Too little content
208
+ await runExtraction(condensed, sessionId, client);
209
+ }
210
+ async function runExtraction(condensed, sessionId, client) {
201
211
  // Try each model until one succeeds
202
212
  let result = null;
203
213
  for (const model of EXTRACTION_MODELS) {
@@ -232,3 +242,54 @@ export async function extractLearnings(history, sessionId, client) {
232
242
  }
233
243
  saveLearnings(existing);
234
244
  }
245
+ const midSessionState = {
246
+ lastExtractionTokens: 0,
247
+ lastExtractionToolCalls: 0,
248
+ extractionCount: 0,
249
+ };
250
+ /** Token threshold before first mid-session extraction */
251
+ const MID_SESSION_INIT_THRESHOLD = 30_000;
252
+ /** Token growth since last extraction to trigger another */
253
+ const MID_SESSION_UPDATE_THRESHOLD = 25_000;
254
+ /** Minimum tool calls since last extraction */
255
+ const MID_SESSION_TOOL_CALLS_THRESHOLD = 5;
256
+ /** Max mid-session extractions per session (don't spam) */
257
+ const MID_SESSION_MAX_EXTRACTIONS = 3;
258
+ /**
259
+ * Check if mid-session extraction should run, and if so, run it in background.
260
+ * Called from the agent loop after tool execution completes.
261
+ *
262
+ * Triggers when:
263
+ * 1. Token count exceeds init threshold (first extraction) OR update threshold (subsequent)
264
+ * 2. AND enough tool calls have happened since last extraction
265
+ * 3. AND we haven't hit the per-session cap
266
+ *
267
+ * Inspired by Claude Code's SessionMemory which runs a background subagent
268
+ * to extract conversation notes periodically.
269
+ */
270
+ export function maybeMidSessionExtract(history, estimatedTokens, totalToolCalls, sessionId, client) {
271
+ // Cap reached — stop extracting
272
+ if (midSessionState.extractionCount >= MID_SESSION_MAX_EXTRACTIONS)
273
+ return;
274
+ // Check token threshold
275
+ const tokenGrowth = estimatedTokens - midSessionState.lastExtractionTokens;
276
+ const threshold = midSessionState.extractionCount === 0
277
+ ? MID_SESSION_INIT_THRESHOLD
278
+ : MID_SESSION_UPDATE_THRESHOLD;
279
+ if (tokenGrowth < threshold)
280
+ return;
281
+ // Check tool calls threshold
282
+ const toolCallGrowth = totalToolCalls - midSessionState.lastExtractionToolCalls;
283
+ if (toolCallGrowth < MID_SESSION_TOOL_CALLS_THRESHOLD)
284
+ return;
285
+ // Trigger extraction — fire and forget (never blocks the conversation)
286
+ midSessionState.lastExtractionTokens = estimatedTokens;
287
+ midSessionState.lastExtractionToolCalls = totalToolCalls;
288
+ midSessionState.extractionCount++;
289
+ const condensed = condenseHistory(history);
290
+ if (condensed.length < 100)
291
+ return;
292
+ // Run in background — errors are silently swallowed
293
+ runExtraction(condensed, `${sessionId}:mid-${midSessionState.extractionCount}`, client)
294
+ .catch(() => { });
295
+ }
@@ -1,3 +1,3 @@
1
1
  export type { Learning, LearningCategory, ExtractionResult } from './types.js';
2
2
  export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt } from './store.js';
3
- export { extractLearnings, bootstrapFromClaudeConfig } from './extractor.js';
3
+ export { extractLearnings, bootstrapFromClaudeConfig, maybeMidSessionExtract } from './extractor.js';
@@ -1,2 +1,2 @@
1
1
  export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt } from './store.js';
2
- export { extractLearnings, bootstrapFromClaudeConfig } from './extractor.js';
2
+ export { extractLearnings, bootstrapFromClaudeConfig, maybeMidSessionExtract } from './extractor.js';
@@ -111,20 +111,49 @@ const MAX_PROMPT_CHARS = 2000; // ~500 tokens
111
111
  export function formatForPrompt(learnings) {
112
112
  if (learnings.length === 0)
113
113
  return '';
114
- const sorted = [...learnings].sort((a, b) => score(b) - score(a));
115
- const lines = [];
114
+ // Separate negative learnings (highest priority) from others
115
+ const negative = learnings.filter(l => l.category === 'negative');
116
+ const projectCtx = learnings.filter(l => l.category === 'project_context');
117
+ const preferences = learnings.filter(l => l.category !== 'negative' && l.category !== 'project_context');
118
+ const sections = [];
116
119
  let chars = 0;
117
- const header = '# Personal Context\nPreferences learned from previous sessions:\n';
118
- chars += header.length;
119
- for (const l of sorted) {
120
- const conf = l.confidence >= 0.8 ? '●' : l.confidence >= 0.5 ? '◐' : '○';
121
- const line = `- ${conf} ${l.learning}`;
122
- if (chars + line.length + 1 > MAX_PROMPT_CHARS)
123
- break;
124
- lines.push(line);
125
- chars += line.length + 1;
120
+ // Negative learnings first (most important prevents repeating mistakes)
121
+ if (negative.length > 0) {
122
+ const negSorted = [...negative].sort((a, b) => score(b) - score(a));
123
+ const negLines = negSorted
124
+ .filter(l => { if (chars + l.learning.length + 5 > MAX_PROMPT_CHARS)
125
+ return false; chars += l.learning.length + 5; return true; })
126
+ .map(l => `- ⛔ ${l.learning}`);
127
+ if (negLines.length > 0) {
128
+ sections.push('## Rules (from past corrections)\n' + negLines.join('\n'));
129
+ }
130
+ }
131
+ // Project context
132
+ if (projectCtx.length > 0) {
133
+ const ctxSorted = [...projectCtx].sort((a, b) => score(b) - score(a));
134
+ const ctxLines = ctxSorted
135
+ .filter(l => { if (chars + l.learning.length + 5 > MAX_PROMPT_CHARS)
136
+ return false; chars += l.learning.length + 5; return true; })
137
+ .map(l => `- ${l.learning}`);
138
+ if (ctxLines.length > 0) {
139
+ sections.push('## Project Context\n' + ctxLines.join('\n'));
140
+ }
141
+ }
142
+ // General preferences
143
+ if (preferences.length > 0) {
144
+ const prefSorted = [...preferences].sort((a, b) => score(b) - score(a));
145
+ const prefLines = prefSorted
146
+ .filter(l => { if (chars + l.learning.length + 5 > MAX_PROMPT_CHARS)
147
+ return false; chars += l.learning.length + 5; return true; })
148
+ .map(l => {
149
+ const conf = l.confidence >= 0.8 ? '●' : l.confidence >= 0.5 ? '◐' : '○';
150
+ return `- ${conf} ${l.learning}`;
151
+ });
152
+ if (prefLines.length > 0) {
153
+ sections.push('## Preferences\n' + prefLines.join('\n'));
154
+ }
126
155
  }
127
- if (lines.length === 0)
156
+ if (sections.length === 0)
128
157
  return '';
129
- return header + lines.join('\n');
158
+ return '# Personal Context\nLearned from previous sessions:\n\n' + sections.join('\n\n');
130
159
  }