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/README.md +2 -0
- package/agent.mjs +650 -0
- package/checkpoint.mjs +43 -0
- package/cli.js +199 -0
- package/config.mjs +118 -0
- package/context.mjs +160 -0
- package/distill.mjs +178 -0
- package/executor.mjs +207 -0
- package/mcp.mjs +209 -0
- package/memory.mjs +218 -0
- package/metaTools.mjs +523 -0
- package/package.json +26 -0
- package/provider.mjs +145 -0
- package/session.mjs +114 -0
- package/skills.mjs +147 -0
- package/tools/bash.mjs +41 -0
- package/tools/delete.mjs +57 -0
- package/tools/edit.mjs +71 -0
- package/tools/fetch.mjs +55 -0
- package/tools/glob.mjs +83 -0
- package/tools/grep.mjs +58 -0
- package/tools/index.mjs +41 -0
- package/tools/ls.mjs +62 -0
- package/tools/read.mjs +50 -0
- package/tools/websearch.mjs +58 -0
- package/tools/write.mjs +54 -0
- package/tools.mjs +15 -0
- package/tui.mjs +437 -0
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
|
+
|