junecoder 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/executor.mjs ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Tool execution engine — two-phase tool call processing.
3
+ *
4
+ * Phase 1: parse + validate + permission check (serial)
5
+ * Phase 2: execute parallel-ready tools together, serial tools one-by-one
6
+ */
7
+ import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import {
11
+ TOOL_RESULT_OFFLOAD_LIMIT,
12
+ TOOL_RESULT_PREVIEW,
13
+ } from './agent.mjs';
14
+
15
+ // ─── executeToolCalls ─────────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Execute a batch of tool calls from the LLM.
19
+ *
20
+ * @param {object} agent - agent instance
21
+ * @param {Map<string, object>} toolByName - tool name → tool definition
22
+ * @param {object[]} toolCalls - LLM tool_calls [{id, function:{name,arguments}}]
23
+ * @param {object} callbacks - { onToolCall, onToolOutput, onPermissionRequest }
24
+ * @param {number} depth - nesting depth (0 = top-level)
25
+ * @param {AbortSignal} [signal]
26
+ * @returns {Promise<object[]>} results in original toolCalls order [{id, name, output, error?}]
27
+ */
28
+ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks, depth, signal) {
29
+ if (!toolCalls || toolCalls.length === 0) return [];
30
+
31
+ const cb = callbacks || {};
32
+
33
+ // ── Phase 1: Prepare (serial) ──────────────────────────────────────────
34
+ const prepared = [];
35
+
36
+ for (const tc of toolCalls) {
37
+ const id = tc.id || `call_${prepared.length}`;
38
+ const name = tc.function?.name || '';
39
+ let args = {};
40
+
41
+ // Parse arguments
42
+ try {
43
+ args = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {};
44
+ } catch {
45
+ prepared.push({ id, name, error: `Invalid JSON arguments: ${tc.function?.arguments?.slice(0, 100)}` });
46
+ continue;
47
+ }
48
+
49
+ // Check tool exists
50
+ const tool = toolByName.get(name);
51
+ if (!tool) {
52
+ prepared.push({ id, name, error: `Unknown tool: ${name}` });
53
+ continue;
54
+ }
55
+
56
+ // Plan mode: reject non-readonly tools
57
+ if (agent.planMode && !tool.readonly) {
58
+ prepared.push({
59
+ id,
60
+ name,
61
+ denied: true,
62
+ output: `Tool "${name}" is not allowed in plan mode (read-only tools only). Exit plan mode first.`,
63
+ });
64
+ continue;
65
+ }
66
+
67
+ // Permission request for non-readonly tools
68
+ let permissionDenied = false;
69
+ if (!tool.readonly && cb.onPermissionRequest) {
70
+ try {
71
+ const allowed = await cb.onPermissionRequest(tool, args);
72
+ if (!allowed) {
73
+ permissionDenied = true;
74
+ }
75
+ } catch {
76
+ permissionDenied = true;
77
+ }
78
+ }
79
+
80
+ if (permissionDenied) {
81
+ prepared.push({
82
+ id,
83
+ name,
84
+ denied: true,
85
+ output: `Permission denied for "${name}".`,
86
+ });
87
+ continue;
88
+ }
89
+
90
+ prepared.push({ id, name, tool, args });
91
+ }
92
+
93
+ // ── Phase 2: Execute (parallel groups, then serial) ────────────────────
94
+
95
+ const results = new Array(toolCalls.length).fill(null);
96
+
97
+ // Assemble results by original index
98
+ const preparedWithIndex = prepared.map((p, i) => ({ ...p, _idx: i }));
99
+
100
+ // Group: parallel (readonly or explicit parallel) vs serial
101
+ const parallelItems = [];
102
+ const serialItems = [];
103
+
104
+ for (const item of preparedWithIndex) {
105
+ if (item.error || item.denied) {
106
+ // Already resolved in phase 1
107
+ results[item._idx] = {
108
+ id: item.id,
109
+ name: item.name,
110
+ output: item.error ? null : item.output,
111
+ error: item.error || null,
112
+ denied: item.denied || false,
113
+ };
114
+ } else if (item.tool.parallel === true || item.tool.readonly) {
115
+ parallelItems.push(item);
116
+ } else {
117
+ serialItems.push(item);
118
+ }
119
+ }
120
+
121
+ // Execute parallel group
122
+ if (parallelItems.length > 0) {
123
+ const parallelResults = await Promise.all(
124
+ parallelItems.map((item) => runOne(agent, item, cb, signal)),
125
+ );
126
+ for (let i = 0; i < parallelItems.length; i++) {
127
+ results[parallelItems[i]._idx] = parallelResults[i];
128
+ }
129
+ }
130
+
131
+ // Execute serial group one by one
132
+ for (const item of serialItems) {
133
+ try {
134
+ // Single-item parallel-ish (but in a serial loop)
135
+ const [result] = await Promise.all([runOne(agent, item, cb, signal)]);
136
+ results[item._idx] = result;
137
+ } catch {
138
+ results[item._idx] = {
139
+ id: item.id,
140
+ name: item.name,
141
+ output: null,
142
+ error: 'Serial execution failed',
143
+ };
144
+ }
145
+ }
146
+
147
+ return results.filter(Boolean);
148
+ }
149
+
150
+ // ─── runOne ───────────────────────────────────────────────────────────────────
151
+
152
+ async function runOne(agent, item, callbacks, signal) {
153
+ const { id, name, tool, args } = item;
154
+
155
+ if (callbacks.onToolCall) {
156
+ try {
157
+ callbacks.onToolCall(name, args);
158
+ } catch { /* ignore callback errors */ }
159
+ }
160
+
161
+ let output;
162
+ let error = null;
163
+
164
+ try {
165
+ const raw = await tool.execute(args, agent, { signal });
166
+ output = typeof raw === 'string' ? raw : JSON.stringify(raw);
167
+
168
+ // Offload long results
169
+ if (output.length > TOOL_RESULT_OFFLOAD_LIMIT) {
170
+ output = offloadToolResult(name, output);
171
+ }
172
+ } catch (err) {
173
+ error = err.message || String(err);
174
+ output = null;
175
+ }
176
+
177
+ if (callbacks.onToolOutput) {
178
+ try {
179
+ callbacks.onToolOutput(name, output, error);
180
+ } catch { /* ignore callback errors */ }
181
+ }
182
+
183
+ return { id, name, output, error };
184
+ }
185
+
186
+ // ─── offloadToolResult ────────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Write long tool results to disk, return a preview + file path.
190
+ * Directory: ~/.junecode/tool-results/
191
+ */
192
+ function offloadToolResult(toolName, fullResult) {
193
+ const dir = join(homedir(), '.junecode', 'tool-results');
194
+ if (!existsSync(dir)) {
195
+ mkdirSync(dir, { recursive: true });
196
+ }
197
+
198
+ const ts = Date.now();
199
+ const rand = Math.random().toString(36).slice(2, 8);
200
+ const filename = `${ts}-${rand}-${toolName}.txt`;
201
+ const filepath = join(dir, filename);
202
+
203
+ writeFileSync(filepath, fullResult, 'utf-8');
204
+
205
+ const preview = fullResult.slice(0, TOOL_RESULT_PREVIEW);
206
+ return `${preview}\n\n[Result offloaded: ${fullResult.length} chars. Full output at ${filepath}]`;
207
+ }
package/mcp.mjs ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * MCP (Model Context Protocol) management.
3
+ *
4
+ * Communicates with MCP servers via JSON-RPC over stdio.
5
+ * Each connected server's tools are added to the agent with an mcp_ prefix.
6
+ * Child processes are tracked on agent._mcpProcesses for cleanup.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+
11
+ // ─── JSON-RPC helpers ──────────────────────────────────────────────────────────
12
+
13
+ let _nextId = 1;
14
+
15
+ /** Send a JSON-RPC request and wait for the response. */
16
+ function jsonRpc(proc, method, params = null) {
17
+ return new Promise((resolve, reject) => {
18
+ const id = _nextId++;
19
+ const request = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
20
+
21
+ const onData = (chunk) => {
22
+ buffer += chunk.toString();
23
+ // Process complete lines
24
+ const lines = buffer.split('\n');
25
+ buffer = lines.pop(); // keep incomplete line in buffer
26
+ for (const line of lines) {
27
+ if (!line.trim()) continue;
28
+ try {
29
+ const msg = JSON.parse(line);
30
+ if (msg.id === id) {
31
+ proc.stdout.removeListener('data', onData);
32
+ clearTimeout(timer);
33
+ if (msg.error) reject(new Error(`MCP error: ${msg.error.message || JSON.stringify(msg.error)}`));
34
+ else resolve(msg.result);
35
+ }
36
+ } catch { /* skip parse errors */ }
37
+ }
38
+ };
39
+
40
+ let buffer = '';
41
+ proc.stdout.on('data', onData);
42
+
43
+ const timer = setTimeout(() => {
44
+ proc.stdout.removeListener('data', onData);
45
+ reject(new Error(`MCP request timed out (method: ${method})`));
46
+ }, 15000);
47
+
48
+ proc.stdin.write(request);
49
+ });
50
+ }
51
+
52
+ /** Send a JSON-RPC notification (no response expected). */
53
+ function jsonRpcNotify(proc, method, params = null) {
54
+ const msg = JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n';
55
+ proc.stdin.write(msg);
56
+ }
57
+
58
+ // ─── Tool conversion ───────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Convert an MCP tool definition to an internal tool with an execute wrapper.
62
+ * MCP tools send tools/call requests to the child process.
63
+ */
64
+ function mcpToolToInternal(mcpTool, proc, serverName) {
65
+ const name = `mcp_${serverName}_${mcpTool.name}`;
66
+
67
+ // MCP tool inputSchema → OpenAI parameters
68
+ const parameters = mcpTool.inputSchema || { type: 'object', properties: {} };
69
+
70
+ return {
71
+ name,
72
+ description: mcpTool.description || `MCP tool: ${mcpTool.name}`,
73
+ parameters,
74
+ readonly: false,
75
+ parallel: true,
76
+
77
+ async execute(args, agent) {
78
+ try {
79
+ const result = await jsonRpc(proc, 'tools/call', {
80
+ name: mcpTool.name,
81
+ arguments: args,
82
+ });
83
+
84
+ // MCP result is an array of content items
85
+ if (result.content && Array.isArray(result.content)) {
86
+ return result.content
87
+ .map((c) => (c.type === 'text' ? c.text : JSON.stringify(c)))
88
+ .join('\n');
89
+ }
90
+ if (result.isError) {
91
+ return `MCP tool error: ${JSON.stringify(result)}`;
92
+ }
93
+ return JSON.stringify(result);
94
+ } catch (err) {
95
+ return `MCP tool "${mcpTool.name}" failed: ${err.message}`;
96
+ }
97
+ },
98
+ };
99
+ }
100
+
101
+ // ─── Public API ────────────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Connect to an MCP server. Returns list of internal tool definitions.
105
+ *
106
+ * @param {object} srv - Server config: { name, command, args?: string[], env?: object }
107
+ * @returns {Promise<object[]>} array of tool definitions
108
+ */
109
+ export async function connectMcpServer(srv) {
110
+ if (!srv || !srv.command) return [];
111
+
112
+ const name = srv.name || 'mcp';
113
+ const command = srv.command;
114
+ const args = srv.args || [];
115
+ const env = { ...process.env, ...(srv.env || {}) };
116
+
117
+ let proc;
118
+ try {
119
+ proc = spawn(command, args, {
120
+ stdio: ['pipe', 'pipe', 'pipe'],
121
+ env,
122
+ shell: false,
123
+ });
124
+ } catch (err) {
125
+ throw new Error(`Failed to spawn MCP server "${name}": ${err.message}`);
126
+ }
127
+
128
+ // Collect stderr for debugging
129
+ let stderr = '';
130
+ proc.stderr.on('data', (chunk) => {
131
+ stderr += chunk.toString();
132
+ });
133
+ proc.on('error', () => { /* handled via exit */ });
134
+
135
+ try {
136
+ // Step 1: Initialize
137
+ const initResult = await jsonRpc(proc, 'initialize', {
138
+ protocolVersion: '2024-11-05',
139
+ capabilities: { tools: {} },
140
+ clientInfo: { name: 'junecode', version: '1.0.0' },
141
+ });
142
+
143
+ // Step 2: Send initialized notification
144
+ jsonRpcNotify(proc, 'notifications/initialized');
145
+
146
+ // Step 3: List tools
147
+ const listResult = await jsonRpc(proc, 'tools/list');
148
+ const mcpTools = listResult.tools || [];
149
+
150
+ // Convert to internal tools
151
+ const tools = mcpTools.map((t) => mcpToolToInternal(t, proc, name));
152
+
153
+ // Attach process reference so we can close it later
154
+ tools._mcpProc = proc;
155
+ tools._mcpName = name;
156
+
157
+ return tools;
158
+ } catch (err) {
159
+ // Clean up on failure
160
+ try { proc.kill(); } catch { /* ignore */ }
161
+ throw new Error(`MCP server "${name}" connection failed: ${err.message}${stderr ? '\nStderr: ' + stderr.slice(-500) : ''}`);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Close all active MCP connections for an agent.
167
+ * Kills all tracked child processes.
168
+ * @param {object} agent
169
+ */
170
+ export async function closeAllMcp(agent) {
171
+ if (!agent || !agent._mcpProcesses) return;
172
+
173
+ for (const proc of agent._mcpProcesses) {
174
+ try { proc.kill('SIGTERM'); } catch { /* already dead */ }
175
+ }
176
+ agent._mcpProcesses = [];
177
+ }
178
+
179
+ /**
180
+ * Remove MCP tools associated with a named server from an agent's tool set.
181
+ * @param {object} agent
182
+ * @param {string} name - server name
183
+ */
184
+ export function removeMcpTools(agent, name) {
185
+ if (!agent || !agent.tools) return;
186
+
187
+ const prefix = `mcp_${name}_`;
188
+
189
+ // Filter out tools with matching prefix
190
+ agent.tools = agent.tools.filter((t) => {
191
+ if (t.name?.startsWith(prefix)) {
192
+ // Also remove from toolByName map if it exists
193
+ if (agent._toolByName) agent._toolByName.delete(t.name);
194
+ return false;
195
+ }
196
+ return true;
197
+ });
198
+
199
+ // Kill the process if tracked
200
+ if (agent._mcpProcesses) {
201
+ agent._mcpProcesses = agent._mcpProcesses.filter((p) => {
202
+ if (p._mcpName === name) {
203
+ try { p.kill('SIGTERM'); } catch { /* ignore */ }
204
+ return false;
205
+ }
206
+ return true;
207
+ });
208
+ }
209
+ }
package/memory.mjs ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Memory module — search and reindex for the agent's long-term memory.
3
+ *
4
+ * Memory is a simple keyword-search store backed by JSON files on disk.
5
+ * The `memory` object shape: { dir: string, entries?: MemoryEntry[] }.
6
+ *
7
+ * Each memory entry: { id, content, source, tags, timestamp }.
8
+ */
9
+
10
+ import { join, basename } from 'node:path';
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
12
+ import { createHash } from 'node:crypto';
13
+ import { homedir } from 'node:os';
14
+
15
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
16
+
17
+ /** Default memory directory */
18
+ export function memoryDir() {
19
+ return join(homedir(), '.junecode', 'memory');
20
+ }
21
+
22
+ /** Load all memory entries from disk. */
23
+ function loadEntries(dir) {
24
+ if (!existsSync(dir)) return [];
25
+ let files;
26
+ try {
27
+ files = readdirSync(dir);
28
+ } catch {
29
+ return [];
30
+ }
31
+ const entries = [];
32
+ for (const f of files) {
33
+ if (!f.endsWith('.json')) continue;
34
+ try {
35
+ const raw = readFileSync(join(dir, f), 'utf-8');
36
+ entries.push(JSON.parse(raw));
37
+ } catch {
38
+ // skip corrupt files
39
+ }
40
+ }
41
+ return entries;
42
+ }
43
+
44
+ /** Save a single entry to disk. */
45
+ function saveEntry(dir, entry) {
46
+ try {
47
+ mkdirSync(dir, { recursive: true });
48
+ } catch { /* dir exists */ }
49
+ writeFileSync(join(dir, entry.id + '.json'), JSON.stringify(entry, null, 2), 'utf-8');
50
+ }
51
+
52
+ /** Generate a content-based ID. */
53
+ function hashId(content) {
54
+ return createHash('sha256').update(content).digest('hex').slice(0, 16);
55
+ }
56
+
57
+ /** Simple tokenizer: lowercase, split on non-word chars, filter short tokens. */
58
+ function tokenize(text) {
59
+ return text
60
+ .toLowerCase()
61
+ .split(/[^a-z0-9\u4e00-\u9fff]+/i)
62
+ .filter((t) => t.length > 1);
63
+ }
64
+
65
+ /** Score a memory entry against query tokens. */
66
+ function scoreEntry(entry, queryTokens) {
67
+ const contentTokens = tokenize(entry.content);
68
+ const tagTokens = tokenize((entry.tags || '').join(' '));
69
+ const allTokens = [...contentTokens, ...tagTokens];
70
+
71
+ let score = 0;
72
+ const seen = new Set();
73
+ for (const qt of queryTokens) {
74
+ if (seen.has(qt)) continue;
75
+ seen.add(qt);
76
+ for (const t of allTokens) {
77
+ if (t === qt) {
78
+ score += 1;
79
+ } else if (t.includes(qt) || qt.includes(t)) {
80
+ score += 0.5;
81
+ }
82
+ }
83
+ }
84
+ return score;
85
+ }
86
+
87
+ // ─── Public API ────────────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Search the agent's memory.
91
+ * @param {object} memory - memory instance with { dir, entries? }
92
+ * @param {string} input - query string
93
+ * @param {{ limit?: number }} [opts]
94
+ * @returns {Promise<string>} formatted search results
95
+ */
96
+ export async function search(memory, input, opts = {}) {
97
+ if (!memory) return '';
98
+
99
+ const dir = memory.dir || memoryDir();
100
+ const limit = opts.limit || 5;
101
+ const queryTokens = tokenize(input);
102
+ if (queryTokens.length === 0) return '';
103
+
104
+ // Load entries (from cache or disk)
105
+ let entries = memory.entries;
106
+ if (!entries) {
107
+ entries = loadEntries(dir);
108
+ memory.entries = entries;
109
+ }
110
+
111
+ if (entries.length === 0) return '';
112
+
113
+ // Score and rank
114
+ const scored = entries
115
+ .map((e) => ({ entry: e, score: scoreEntry(e, queryTokens) }))
116
+ .filter((s) => s.score > 0)
117
+ .sort((a, b) => b.score - a.score)
118
+ .slice(0, limit);
119
+
120
+ if (scored.length === 0) return '';
121
+
122
+ return scored
123
+ .map((s) => {
124
+ const e = s.entry;
125
+ const preview = e.content.length > 200
126
+ ? e.content.slice(0, 200) + '...'
127
+ : e.content;
128
+ const tags = e.tags?.length ? ` [${e.tags.join(', ')}]` : '';
129
+ return `- ${preview}${tags} (source: ${e.source || 'unknown'})`;
130
+ })
131
+ .join('\n');
132
+ }
133
+
134
+ /**
135
+ * Re-index a file in the agent's memory.
136
+ * Reads the file content and stores it as a memory entry.
137
+ * @param {object} memory - memory instance
138
+ * @param {string} cwd - working directory
139
+ * @param {string} absPath - absolute path of the file that changed
140
+ */
141
+ export async function reindexFile(memory, cwd, absPath) {
142
+ if (!memory) return;
143
+
144
+ const dir = memory.dir || memoryDir();
145
+
146
+ // Read the file
147
+ let content;
148
+ try {
149
+ content = readFileSync(absPath, 'utf-8');
150
+ } catch {
151
+ return; // can't read — skip
152
+ }
153
+
154
+ // Skip overly large files (avoid blowing up memory)
155
+ if (content.length > 50000) return;
156
+
157
+ const id = hashId(absPath + content.slice(0, 1000));
158
+ const fileName = basename(absPath);
159
+
160
+ const entry = {
161
+ id,
162
+ content: `File ${fileName}: ${content.slice(0, 2000)}`,
163
+ source: absPath,
164
+ tags: ['file', fileName],
165
+ timestamp: Date.now(),
166
+ };
167
+
168
+ // Update in-memory cache
169
+ let entries = memory.entries;
170
+ if (!entries) {
171
+ entries = loadEntries(dir);
172
+ memory.entries = entries;
173
+ }
174
+
175
+ // Replace existing entry with same source
176
+ const idx = entries.findIndex((e) => e.source === absPath);
177
+ if (idx >= 0) {
178
+ entries[idx] = entry;
179
+ } else {
180
+ entries.push(entry);
181
+ }
182
+
183
+ // Persist to disk
184
+ saveEntry(dir, entry);
185
+ }
186
+
187
+ /**
188
+ * Save a memory entry manually (called by memory_put tool).
189
+ * @param {object} memory - memory instance
190
+ * @param {object} entry - { type, title, content, tags, scope }
191
+ * @returns {Promise<string>} status message
192
+ */
193
+ export async function put(memory, entry) {
194
+ if (!memory) return 'no memory store';
195
+
196
+ const dir = memory.dir || memoryDir();
197
+ const id = hashId(entry.content || entry.title || String(Date.now()));
198
+
199
+ const record = {
200
+ id,
201
+ content: `[${entry.type || 'knowledge'}] ${entry.title || ''}: ${entry.content}`,
202
+ source: entry.scope || 'manual',
203
+ tags: [...(entry.tags || []), entry.type || 'knowledge'],
204
+ timestamp: Date.now(),
205
+ };
206
+
207
+ // Update in-memory cache
208
+ let entries = memory.entries;
209
+ if (!entries) {
210
+ entries = loadEntries(dir);
211
+ memory.entries = entries;
212
+ }
213
+ entries.push(record);
214
+
215
+ saveEntry(dir, record);
216
+ return `saved: ${entry.title || id}`;
217
+ }
218
+