flint-agent 1.14.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 (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,112 @@
1
+ // Structured logger with levels: debug, info, warn, error
2
+ // Level from .env AGENT_LOG_LEVEL (default: "info")
3
+ // All levels >= configured write to sessions/<sessionId>.log
4
+ // warn+ also goes to stderr
5
+
6
+ import "dotenv/config"; // ensure .env is loaded before anything else
7
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
11
+ const LEVEL_NAMES = Object.fromEntries(Object.entries(LEVELS).map(([k, v]) => [v, k]));
12
+
13
+ const _rawLevel = process.env.AGENT_LOG_LEVEL;
14
+ const _level = LEVELS[(_rawLevel || "info").trim()] ?? LEVELS.info;
15
+ let _logFile = null;
16
+ let _buffer = []; // buffer messages before initLogger sets the file
17
+ let _inkActive = false; // suppress stderr when Ink TUI is rendering
18
+
19
+ /**
20
+ * Initialize logger with session info. Call once after session is created.
21
+ * @param {{ sessionsDir: string, sessionId: string }} opts
22
+ */
23
+ export function initLogger(opts = {}) {
24
+ if (opts.sessionsDir && opts.sessionId && _level < LEVELS.silent) {
25
+ try {
26
+ mkdirSync(opts.sessionsDir, { recursive: true });
27
+ _logFile = path.join(opts.sessionsDir, `${opts.sessionId}.log`);
28
+ // Flush buffered messages
29
+ if (_buffer.length > 0) {
30
+ writeFileSync(_logFile, _buffer.join("\n") + "\n");
31
+ _buffer = [];
32
+ }
33
+ } catch {}
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Set log level at runtime.
39
+ * @param {"debug"|"info"|"warn"|"error"|"silent"} level
40
+ */
41
+ export function setLogLevel(level) {
42
+ // Note: can't reassign const, but we can use this for future dynamic level
43
+ }
44
+
45
+ /**
46
+ * Suppress stderr output when Ink TUI is active.
47
+ * stderr.write during Ink rendering corrupts terminal layout.
48
+ */
49
+ export function setInkActive(active) {
50
+ _inkActive = active;
51
+ }
52
+
53
+ /** @returns {"debug"|"info"|"warn"|"error"|"silent"} */
54
+ export function getLogLevel() {
55
+ return LEVEL_NAMES[_level] || "info";
56
+ }
57
+
58
+ function formatMsg(level, module, msg, data) {
59
+ const d = new Date();
60
+ const ts = `${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}:${String(d.getSeconds()).padStart(2,"0")}.${String(d.getMilliseconds()).padStart(3,"0")}`;
61
+ const prefix = `[${ts}] [${level.toUpperCase()}] [${module}]`;
62
+ let line = `${prefix} ${msg}`;
63
+ if (data !== undefined) {
64
+ const extra = typeof data === "string" ? data : JSON.stringify(data);
65
+ line += ` ${extra}`;
66
+ }
67
+ return line;
68
+ }
69
+
70
+ function emit(levelNum, level, module, msg, data) {
71
+ if (levelNum < _level) return;
72
+
73
+ const line = formatMsg(level, module, msg, data);
74
+
75
+ // Write to log file (or buffer if file not ready yet)
76
+ if (_logFile) {
77
+ try { appendFileSync(_logFile, line + "\n"); } catch {}
78
+ } else {
79
+ _buffer.push(line);
80
+ }
81
+
82
+ // Write to stderr for warn+ ONLY when Ink TUI is NOT active
83
+ // stderr.write during Ink rendering breaks cursor positioning and causes UI duplication
84
+ if (levelNum >= LEVELS.warn && !_inkActive) {
85
+ process.stderr.write(line + "\n");
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Create a scoped logger for a module.
91
+ * @param {string} module - Module name (e.g. "server", "pairing", "heartbeat")
92
+ * @returns {{ debug, info, warn, error }}
93
+ */
94
+ export function createLogger(module) {
95
+ return {
96
+ debug: (msg, data) => emit(LEVELS.debug, "debug", module, msg, data),
97
+ info: (msg, data) => emit(LEVELS.info, "info", module, msg, data),
98
+ warn: (msg, data) => emit(LEVELS.warn, "warn", module, msg, data),
99
+ error: (msg, data) => emit(LEVELS.error, "error", module, msg, data),
100
+ };
101
+ }
102
+
103
+ // Log boot immediately
104
+ const _bootLog = createLogger("boot");
105
+ _bootLog.info("Logger initialized", {
106
+ level: LEVEL_NAMES[_level],
107
+ cwd: process.cwd(),
108
+ argv: process.argv.slice(2).join(" "),
109
+ AGENT_PORT: process.env.AGENT_PORT || "(default)",
110
+ AGENT_PARENT_PORT: process.env.AGENT_PARENT_PORT || "(none)",
111
+ pid: process.pid,
112
+ });
@@ -0,0 +1,20 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { config } from "../config.js";
3
+ import path from "node:path";
4
+
5
+ export function logToolResult(sessionId, { toolCallId, name, args, result }) {
6
+ if (!sessionId) return;
7
+ const file = path.join(config.sessionsDir, `${sessionId}.tools.log`);
8
+ const ts = new Date().toISOString().slice(11, 19);
9
+ const argsStr = Object.entries(args)
10
+ .map(([k, v]) => {
11
+ const s = typeof v === "string" && v.length > 100 ? v.slice(0, 100) + "..." : String(v);
12
+ return `${k}=${s}`;
13
+ })
14
+ .join(" ");
15
+ const header = `[${ts}] ${name}(${argsStr})`;
16
+ const sep = "-".repeat(60);
17
+ try {
18
+ appendFileSync(file, `${sep}\n${header}\n${sep}\n${result}\n\n`);
19
+ } catch {}
20
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * MCP client -- connects to MCP servers, discovers tools, and creates
3
+ * OpenAI-compatible tool definitions + handler functions.
4
+ *
5
+ * Config via MCP_SERVERS env var:
6
+ * MCP_SERVERS=name1|transport|url,name2|transport|url
7
+ * transport: "sse" (legacy), "http" (Streamable HTTP), or "stdio" (subprocess)
8
+ * Examples: screenbox|sse|http://localhost:8080/sse
9
+ * memory|stdio|~/agent-memory/agent-memory mcp
10
+ */
11
+
12
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13
+ import { createLogger } from "./logging/logger.js";
14
+ import { resolveContent } from "./agent/content-resolver.js";
15
+ const log = createLogger("mcp-client");
16
+
17
+ // Abort signal for MCP calls — set by agent loop, used by handlers
18
+ let _abortSignal = null;
19
+ export function setMcpAbortSignal(signal) { _abortSignal = signal; }
20
+ import { readFileSync } from "node:fs";
21
+ import { fileURLToPath } from "node:url";
22
+ const _flintVersion = (() => { try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version; } catch { return "?"; } })();
23
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
24
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
25
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
26
+ import { detectBase64Content, validateContentType } from "./security/content-validator.js";
27
+
28
+ // Active MCP client connections
29
+ const clients = new Map(); // name -> { client, transport, url, transportType }
30
+
31
+ // Allowed MCP URL schemes — only localhost HTTP for now
32
+ const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
33
+
34
+ function validateMcpUrl(url) {
35
+ try {
36
+ const parsed = new URL(url);
37
+ if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
38
+ throw new Error(`Unsupported MCP URL scheme: ${parsed.protocol} (allowed: http, https)`);
39
+ }
40
+ return parsed;
41
+ } catch (err) {
42
+ if (err.message.includes("Unsupported MCP URL")) throw err;
43
+ throw new Error(`Invalid MCP URL: ${url}`);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Parse MCP_SERVERS env string into server configs.
49
+ * Format: "name|transport|url,name2|transport|url2"
50
+ *
51
+ * Also takes an array of configs already parsed, as mcpJsonServers returns
52
+ * them from a Claude-style .mcp.json.
53
+ */
54
+ export function parseServerConfig(envStr) {
55
+ if (!envStr) return [];
56
+ if (Array.isArray(envStr)) return envStr.filter((s) => s && s.name);
57
+ return envStr.split(",").map((entry) => {
58
+ const [name, transport, ...urlParts] = entry.trim().split("|");
59
+ return { name: name.trim(), transport: transport.trim(), url: urlParts.join("|").trim() };
60
+ }).filter((s) => s.name && s.url);
61
+ }
62
+
63
+ /**
64
+ * Server configs from a .mcp.json (the common mcpServers format), the file a host
65
+ * may give every agent ({"mcpServers": {name: {type, url, headers} or
66
+ * {command, args, env}}}). The file is the operator's own configuration, so
67
+ * a stdio command may be a bare name found on PATH.
68
+ */
69
+ export function mcpJsonServers(json) {
70
+ const servers = json?.mcpServers || {};
71
+ return Object.entries(servers).map(([name, s]) => {
72
+ const transport = s.type || (s.command ? "stdio" : "http");
73
+ if (transport === "stdio") {
74
+ const args = Array.isArray(s.args) ? s.args.map(String) : [];
75
+ return { name, transport, command: s.command, args, env: s.env || null, url: [s.command, ...args].join(" "), fromFile: true };
76
+ }
77
+ return { name, transport, url: s.url, headers: s.headers || null, fromFile: true };
78
+ }).filter((s) => s.name && (s.url || s.command));
79
+ }
80
+
81
+ /**
82
+ * Connect to a single MCP server and return its tools + handlers.
83
+ */
84
+ async function connectServer(serverConfig) {
85
+ const { name, transport: transportType, url } = serverConfig;
86
+ const requestInit = serverConfig.headers ? { headers: serverConfig.headers } : undefined;
87
+
88
+ let transport;
89
+ if (transportType === "stdio") {
90
+ const parts = url.split(/\s+/);
91
+ const command = serverConfig.command || parts[0];
92
+ const args = serverConfig.command ? (serverConfig.args || []) : parts.slice(1);
93
+ // SEC-01: Validate stdio command is an absolute path to prevent command
94
+ // injection. A .mcp.json is the operator's own file and may name a
95
+ // command on PATH.
96
+ const isAbsolute = command.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(command) || command.startsWith("~");
97
+ if (!isAbsolute && !serverConfig.fromFile) {
98
+ throw new Error(`MCP stdio command must be an absolute path, got: "${command}"`);
99
+ }
100
+ // Sanitize server name to alphanumeric + underscore/dash
101
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
102
+ throw new Error(`MCP server name must be alphanumeric (got: "${name}")`);
103
+ }
104
+ // The SDK's default hands the server a minimal environment. A server from
105
+ // a .mcp.json gets ours plus its own env (a host's
106
+ // servers may read their agent's identity from it).
107
+ const env = serverConfig.fromFile ? { ...process.env, ...(serverConfig.env || {}) } : undefined;
108
+ transport = new StdioClientTransport({ command, args, ...(env ? { env } : {}) });
109
+ } else if (transportType === "sse") {
110
+ const parsedUrl = validateMcpUrl(url);
111
+ transport = new SSEClientTransport(parsedUrl, requestInit ? { requestInit } : undefined);
112
+ } else if (transportType === "http") {
113
+ const parsedUrl = validateMcpUrl(url);
114
+ transport = new StreamableHTTPClientTransport(parsedUrl, requestInit ? { requestInit } : undefined);
115
+ } else {
116
+ throw new Error(`Unsupported MCP transport: ${transportType} (supported: "sse", "http", "stdio")`);
117
+ }
118
+
119
+ const client = new Client(
120
+ { name: "flint", version: _flintVersion },
121
+ { capabilities: {} },
122
+ );
123
+
124
+ await client.connect(transport);
125
+
126
+ // Discover tools
127
+ const { tools: mcpTools } = await client.listTools();
128
+
129
+ clients.set(name, { client, transport, url, transportType, command: transportType === "stdio" ? url : undefined });
130
+
131
+ // Convert MCP tools -> OpenAI function calling format + handlers
132
+ const tools = [];
133
+ const handlers = {};
134
+
135
+ for (const tool of mcpTools) {
136
+ const toolName = `mcp_${name}_${tool.name}`;
137
+
138
+ // OpenAI function calling format — prefixed to avoid collisions
139
+ tools.push({
140
+ type: "function",
141
+ function: {
142
+ name: toolName,
143
+ description: tool.description || "",
144
+ parameters: tool.inputSchema || { type: "object", properties: {} },
145
+ },
146
+ });
147
+
148
+ // Handler that proxies to MCP server (use original name for callTool)
149
+ const originalName = tool.name;
150
+ handlers[toolName] = async (args) => {
151
+ // Timeout wrapper: MCP calls must complete within 60s
152
+ const MCP_TIMEOUT = 60000;
153
+ const timeoutController = new AbortController();
154
+ const timer = setTimeout(() => timeoutController.abort(), MCP_TIMEOUT);
155
+ // Combine agent abort signal with timeout
156
+ const combinedSignal = _abortSignal
157
+ ? AbortSignal.any([_abortSignal, timeoutController.signal])
158
+ : timeoutController.signal;
159
+ let result;
160
+ try {
161
+ result = await client.callTool(
162
+ { name: originalName, arguments: args },
163
+ undefined,
164
+ { signal: combinedSignal }
165
+ );
166
+ } catch (err) {
167
+ clearTimeout(timer);
168
+ if (timeoutController.signal.aborted && !_abortSignal?.aborted) {
169
+ log.warn("mcp-tool-timeout", { tool: toolName, timeout: MCP_TIMEOUT });
170
+ return `Error: MCP tool call timed out after ${MCP_TIMEOUT / 1000}s. The MCP server may be unresponsive. Try again.`;
171
+ }
172
+ throw err;
173
+ }
174
+ clearTimeout(timer);
175
+
176
+ // Debug: log raw MCP content block types for diagnostics
177
+ if (result.content?.length) {
178
+ const blockTypes = result.content.map(b => `${b.type}${b.mimeType ? `(${b.mimeType})` : ""}${b.data ? `[${b.data.length}b]` : ""}`);
179
+ log.debug("mcp-result-blocks", { tool: toolName, blocks: blockTypes });
180
+ }
181
+
182
+ if (result.isError) {
183
+ const errText = result.content
184
+ ?.map((c) => c.text || "")
185
+ .filter(Boolean)
186
+ .join("\n") || "MCP tool error";
187
+ return `Error: ${errText}`;
188
+ }
189
+
190
+ // Resolve content type from raw MCP blocks — no hardcoded type checks
191
+ const resolved = resolveContent(result.content);
192
+
193
+ // Security: validate binary data
194
+ if (resolved.data) {
195
+ const { isBinary, detected } = detectBase64Content(resolved.data);
196
+ if (isBinary && detected?.type === "executable") {
197
+ return `[Security: MCP server returned executable disguised as ${resolved.type} — blocked]`;
198
+ }
199
+ }
200
+
201
+ // Media types (image, audio, video) → _image format for agent.js perception
202
+ if (["image", "audio", "video"].includes(resolved.type)) {
203
+ const format = (resolved.mimeType || "application/octet-stream").split("/")[1] || "bin";
204
+ return {
205
+ _image: true, // generic flag for "needs perception" — not just images
206
+ data: resolved.data,
207
+ format,
208
+ meta: `mcp:${toolName}`,
209
+ text: resolved.text || undefined,
210
+ mediaType: resolved.type,
211
+ };
212
+ }
213
+
214
+ // Mixed content with media → extract media, attach text
215
+ if (resolved.type === "mixed" && resolved.parts) {
216
+ const mediaPart = resolved.parts.find(p => ["image", "audio", "video"].includes(p.type));
217
+ if (mediaPart) {
218
+ const textParts = resolved.parts.filter(p => p.type === "text").map(p => p.text).filter(Boolean);
219
+ const format = (mediaPart.mimeType || "application/octet-stream").split("/")[1] || "bin";
220
+ return {
221
+ _image: true,
222
+ data: mediaPart.data,
223
+ format,
224
+ meta: `mcp:${toolName}`,
225
+ text: textParts.join("\n") || undefined,
226
+ mediaType: mediaPart.type,
227
+ };
228
+ }
229
+ }
230
+
231
+ // Text/document/binary — return as string (strip binary data from fallback)
232
+ if (resolved.text) return resolved.text;
233
+ const { data: _data, ...safeResolved } = resolved;
234
+ return JSON.stringify(safeResolved) || "OK";
235
+ };
236
+ }
237
+
238
+ return { tools, handlers, serverName: name, toolCount: mcpTools.length };
239
+ }
240
+
241
+ /**
242
+ * Connect to all configured MCP servers and return combined tools + handlers.
243
+ */
244
+ export async function connectMcpServers(serversEnv) {
245
+ const configs = parseServerConfig(serversEnv);
246
+ if (!configs.length) return { tools: [], handlers: {} };
247
+
248
+ const allTools = [];
249
+ const allHandlers = {};
250
+ const results = [];
251
+
252
+ for (const config of configs) {
253
+ try {
254
+ const result = await connectServer(config);
255
+ allTools.push(...result.tools);
256
+ Object.assign(allHandlers, result.handlers);
257
+ results.push({ name: config.name, tools: result.toolCount, ok: true });
258
+ } catch (err) {
259
+ results.push({ name: config.name, tools: 0, ok: false, error: err.message });
260
+ }
261
+ }
262
+
263
+ return { tools: allTools, handlers: allHandlers, results };
264
+ }
265
+
266
+ /**
267
+ * Disconnect all MCP clients.
268
+ */
269
+ export async function disconnectAll() {
270
+ for (const [name, { client }] of clients) {
271
+ try {
272
+ await client.close();
273
+ } catch {}
274
+ }
275
+ clients.clear();
276
+ }
277
+
278
+ /**
279
+ * Disconnect a single MCP server by name.
280
+ */
281
+ export async function disconnectServer(name) {
282
+ const entry = clients.get(name);
283
+ if (!entry) return false;
284
+ try {
285
+ await entry.client.close();
286
+ } catch {}
287
+ clients.delete(name);
288
+ return true;
289
+ }
290
+
291
+ /**
292
+ * Reconnect a single MCP server by name (re-reads config from env).
293
+ */
294
+ export async function reconnectServer(name, serversEnv) {
295
+ // Disconnect if already connected
296
+ await disconnectServer(name);
297
+ const configs = parseServerConfig(serversEnv);
298
+ const cfg = configs.find((c) => c.name === name);
299
+ if (!cfg) throw new Error(`Server "${name}" not found in MCP_SERVERS config`);
300
+ return connectServer(cfg);
301
+ }
302
+
303
+ /**
304
+ * Get list of configured server names (from env) and their connection status.
305
+ */
306
+ export function getServerStatus(serversEnv) {
307
+ const configs = parseServerConfig(serversEnv);
308
+ return configs.map((cfg) => ({
309
+ name: cfg.name,
310
+ url: cfg.url,
311
+ transport: cfg.transport,
312
+ connected: clients.has(cfg.name),
313
+ }));
314
+ }
@@ -0,0 +1,113 @@
1
+ // Conversation Digest — deterministic per-turn log for context retention
2
+ // Stored in sessions/{sessionId}.digest.jsonl
3
+ // Zero LLM cost, zero latency — built from data we already have
4
+ // Injected into system prompt so model can recall previous turns
5
+
6
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { createHmac } from "node:crypto";
8
+ import path from "node:path";
9
+ import { config } from "../config.js";
10
+
11
+ function digestPath(sessionId) {
12
+ return path.join(config.sessionsDir, `${sessionId}.digest.jsonl`);
13
+ }
14
+
15
+ function hmacPath(sessionId) {
16
+ return path.join(config.sessionsDir, `${sessionId}.digest.hmac`);
17
+ }
18
+
19
+ function getHmacKey() {
20
+ if (process.env.AGENT_MEMORY_HMAC_KEY) return process.env.AGENT_MEMORY_HMAC_KEY;
21
+ const keyFile = path.join(config.sessionsDir, ".hmac-key");
22
+ if (existsSync(keyFile)) return readFileSync(keyFile, "utf-8").trim();
23
+ return "default"; // key file created by session-facts.js
24
+ }
25
+
26
+ function updateHmac(sessionId) {
27
+ const fp = digestPath(sessionId);
28
+ if (!existsSync(fp)) return;
29
+ const data = readFileSync(fp, "utf-8");
30
+ const hmac = createHmac("sha256", getHmacKey()).update(data, "utf-8").digest("hex");
31
+ writeFileSync(hmacPath(sessionId), hmac, "utf-8");
32
+ }
33
+
34
+ function verifyHmac(sessionId, data) {
35
+ const hp = hmacPath(sessionId);
36
+ if (!existsSync(hp)) return true; // no HMAC yet — allow (migration)
37
+ const stored = readFileSync(hp, "utf-8").trim();
38
+ const computed = createHmac("sha256", getHmacKey()).update(data, "utf-8").digest("hex");
39
+ return stored === computed;
40
+ }
41
+
42
+ /**
43
+ * Append a digest entry after a completed turn.
44
+ * @param {string} sessionId
45
+ * @param {{ userMessage: string, assistantResponse: string, toolsUsed?: string[] }} entry
46
+ */
47
+ export function appendDigestEntry(sessionId, entry) {
48
+ if (!sessionId || !entry) return;
49
+ mkdirSync(config.sessionsDir, { recursive: true });
50
+
51
+ const fp = digestPath(sessionId);
52
+ const existing = existsSync(fp) ? readFileSync(fp, "utf-8").split("\n").filter(Boolean).length : 0;
53
+
54
+ const record = {
55
+ turn: existing + 1,
56
+ ts: new Date().toISOString(),
57
+ user: truncate(typeof entry.userMessage === "string" ? entry.userMessage : "[multimodal]", 200),
58
+ assistant: truncate(entry.assistantResponse || "", 200),
59
+ tools: entry.toolsUsed || [],
60
+ };
61
+
62
+ appendFileSync(fp, JSON.stringify(record) + "\n", "utf-8");
63
+ updateHmac(sessionId);
64
+ }
65
+
66
+ /**
67
+ * Load all digest entries for a session.
68
+ */
69
+ export function loadDigest(sessionId) {
70
+ if (!sessionId) return [];
71
+ const fp = digestPath(sessionId);
72
+ if (!existsSync(fp)) return [];
73
+ const raw = readFileSync(fp, "utf-8");
74
+ if (!verifyHmac(sessionId, raw)) return [];
75
+ return raw.split("\n").filter(Boolean).map((line) => {
76
+ try { return JSON.parse(line); } catch { return null; }
77
+ }).filter(Boolean);
78
+ }
79
+
80
+ /**
81
+ * Format digest for injection into system prompt.
82
+ * Returns compact text summary of recent turns.
83
+ * @param {string} sessionId
84
+ * @param {number} maxEntries - max turns to include (newest)
85
+ */
86
+ export function getDigestForPrompt(sessionId, maxEntries = 25) {
87
+ const entries = loadDigest(sessionId);
88
+ if (!entries.length) return "";
89
+
90
+ const recent = entries.slice(-maxEntries);
91
+ const lines = recent.map((e) => {
92
+ const tools = e.tools.length ? ` [${e.tools.join(", ")}]` : "";
93
+ return `Turn ${e.turn}: "${e.user}" → ${e.assistant}${tools}`;
94
+ });
95
+
96
+ return `Conversation digest (${entries.length} turns, showing last ${recent.length}):\n${lines.join("\n")}`;
97
+ }
98
+
99
+ /**
100
+ * Clear digest for a session (used by /clear and /new).
101
+ */
102
+ export function clearDigest(sessionId) {
103
+ const fp = digestPath(sessionId);
104
+ if (existsSync(fp)) writeFileSync(fp, "", "utf-8");
105
+ const hp = hmacPath(sessionId);
106
+ if (existsSync(hp)) writeFileSync(hp, "", "utf-8");
107
+ }
108
+
109
+ function truncate(text, maxLen) {
110
+ if (!text) return "";
111
+ const clean = text.replace(/\n/g, " ").trim();
112
+ return clean.length > maxLen ? clean.slice(0, maxLen) + "..." : clean;
113
+ }
@@ -0,0 +1,98 @@
1
+ // Extract key facts from messages being compressed
2
+ // Uses the same OpenRouter API with a cheap/fast model
3
+
4
+ import { stripTimeStamp } from "../agent/time-stamp.js";
5
+ import { config } from "../config.js";
6
+ import { chatCompletion } from "../api/client.js";
7
+
8
+ const EXTRACTION_PROMPT = `You are a fact extractor. Given a conversation fragment, extract KEY FACTS that should be remembered.
9
+
10
+ Rules:
11
+ - Extract only important, reusable facts (not chit-chat)
12
+ - Categories: project (project details), tech (technologies, versions), decision (decisions made), preference (user preferences), bug (bugs found), person (people mentioned), env (environment details)
13
+ - Each fact = one short sentence in the SAME language as the conversation
14
+ - Max 10 facts per fragment
15
+ - Skip trivial things (greetings, confirmations, "ok", "done")
16
+ - If nothing important — return empty array
17
+
18
+ Return ONLY valid JSON array:
19
+ [{"content": "fact text", "category": "tech"}, ...]`;
20
+
21
+ // Use a fast cheap model for extraction — don't waste main model tokens
22
+ const EXTRACTION_MODEL = config.extractionModel;
23
+
24
+ export async function extractFacts(messages) {
25
+ if (!messages?.length) return [];
26
+
27
+ // Build a text representation of messages to analyze
28
+ const fragments = [];
29
+ for (const m of messages) {
30
+ if (m.role === "system") continue;
31
+ if (m.role === "user") {
32
+ const text = typeof m.content === "string" ? stripTimeStamp(m.content) : "[multimodal]";
33
+ fragments.push(`User: ${text}`);
34
+ } else if (m.role === "assistant") {
35
+ const text = m.content || "";
36
+ if (m.tool_calls?.length) {
37
+ for (const tc of m.tool_calls) {
38
+ fragments.push(`Agent called: ${tc.function.name}(${tc.function.arguments?.slice(0, 200)})`);
39
+ }
40
+ }
41
+ if (text) fragments.push(`Agent: ${text.slice(0, 500)}`);
42
+ } else if (m.role === "tool") {
43
+ const preview = (m.content || "").slice(0, 300);
44
+ fragments.push(`Tool ${m._toolName || "?"}: ${preview}`);
45
+ }
46
+ }
47
+
48
+ if (!fragments.length) return [];
49
+
50
+ // Limit to ~4000 chars to keep extraction cheap
51
+ const conversationText = fragments.join("\n").slice(0, 4000);
52
+
53
+ try {
54
+ // Through the one door to the provider. agent.js fires this per user message,
55
+ // fire-and-forget, so a late reply lands in the NEXT turn's drain rather
56
+ // than this one's. That is a lag in the session total, not a hole in it; a
57
+ // reply that arrives after the run ends is lost, which is why the number
58
+ // can still be a shade low.
59
+ //
60
+ // The door refuses when the budget is spent, and that refusal arrives here
61
+ // as a throw, which the catch below turns into "no facts this time". A
62
+ // memory that skips a fragment is a smaller problem than a memory that
63
+ // spends past the ceiling the operator set.
64
+ const { message } = await chatCompletion(
65
+ [
66
+ { role: "system", content: EXTRACTION_PROMPT },
67
+ { role: "user", content: conversationText },
68
+ ],
69
+ [],
70
+ null,
71
+ {
72
+ source: "facts",
73
+ model: EXTRACTION_MODEL,
74
+ maxTokens: 1024,
75
+ temperature: 0,
76
+ stream: false,
77
+ },
78
+ );
79
+ const text = message?.content || "";
80
+
81
+ // Parse JSON from response (handle markdown code blocks)
82
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
83
+ if (!jsonMatch) return [];
84
+
85
+ const facts = JSON.parse(jsonMatch[0]);
86
+ if (!Array.isArray(facts)) return [];
87
+
88
+ // Validate and clean
89
+ return facts
90
+ .filter((f) => f.content && typeof f.content === "string" && f.content.length > 3)
91
+ .map((f) => ({
92
+ content: f.content.slice(0, 500),
93
+ category: f.category || "auto",
94
+ }));
95
+ } catch {
96
+ return [];
97
+ }
98
+ }