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/cli.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* junecode CLI entry point — starts the TUI or single-shot execution.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* junecode [directory] # Start TUI in directory (default: .)
|
|
7
|
+
* junecode open [directory] # Start TUI in directory (default: .)
|
|
8
|
+
* junecode -d <dir> [prompt] # Specify working directory
|
|
9
|
+
* junecode "write a hello world" # Single-shot (non-TUI)
|
|
10
|
+
*
|
|
11
|
+
* API keys can be provided via:
|
|
12
|
+
* 1. .env file in project directory (DEEPSEEK_API_KEY=sk-...)
|
|
13
|
+
* 2. ~/.junecode/.env
|
|
14
|
+
* 3. DEEPSEEK_API_KEY environment variable
|
|
15
|
+
* 4. ~/.junecode/config.json
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { resolve, join } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { createAgent } from './agent.mjs';
|
|
21
|
+
import { startTUI } from './tui.mjs';
|
|
22
|
+
import { loadConfig } from './config.mjs';
|
|
23
|
+
import { loadSession } from './session.mjs';
|
|
24
|
+
import { memoryDir } from './memory.mjs';
|
|
25
|
+
|
|
26
|
+
function expandHome(p) {
|
|
27
|
+
if (!p) return p;
|
|
28
|
+
if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
|
|
29
|
+
return join(homedir(), p.slice(1));
|
|
30
|
+
}
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rawArgs = process.argv.slice(2);
|
|
35
|
+
|
|
36
|
+
let targetDir = process.cwd();
|
|
37
|
+
let isTui = true;
|
|
38
|
+
let promptInput = null;
|
|
39
|
+
|
|
40
|
+
if (rawArgs.length > 0) {
|
|
41
|
+
const first = rawArgs[0];
|
|
42
|
+
|
|
43
|
+
if (first === '-h' || first === '--help' || first === 'help') {
|
|
44
|
+
console.log(`junecode - AI coding agent
|
|
45
|
+
|
|
46
|
+
Usage:
|
|
47
|
+
junecode [directory] Start TUI in directory (default: .)
|
|
48
|
+
junecode open [directory] Start TUI in directory (default: .)
|
|
49
|
+
junecode --dir <path> [prompt] Set working directory
|
|
50
|
+
junecode -p, --prompt <prompt> Run single-shot prompt (non-TUI)
|
|
51
|
+
junecode "your prompt here" Run single-shot prompt (non-TUI)
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
-d, --dir <path> Working directory
|
|
55
|
+
-t, --tui Force TUI mode
|
|
56
|
+
-p, --prompt <text> Run single-shot mode with prompt
|
|
57
|
+
-h, --help Show help message
|
|
58
|
+
-v, --version Show version number
|
|
59
|
+
`);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (first === '-v' || first === '--version') {
|
|
64
|
+
try {
|
|
65
|
+
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'));
|
|
66
|
+
console.log(`junecode v${pkg.version}`);
|
|
67
|
+
} catch {
|
|
68
|
+
console.log('junecode v1.0.0');
|
|
69
|
+
}
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check subcommands and flags
|
|
74
|
+
if (first === 'open' || first === 'ui' || first === 'tui') {
|
|
75
|
+
isTui = true;
|
|
76
|
+
if (rawArgs.length > 1 && !rawArgs[1].startsWith('-')) {
|
|
77
|
+
targetDir = resolve(expandHome(rawArgs[1]));
|
|
78
|
+
}
|
|
79
|
+
} else if (first === '-t' || first === '--tui') {
|
|
80
|
+
isTui = true;
|
|
81
|
+
if (rawArgs.length > 1 && !rawArgs[1].startsWith('-')) {
|
|
82
|
+
const candidate = resolve(expandHome(rawArgs[1]));
|
|
83
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
84
|
+
targetDir = candidate;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else if (first === '-d' || first === '--dir' || first === '--cwd') {
|
|
88
|
+
if (rawArgs.length > 1) {
|
|
89
|
+
targetDir = resolve(expandHome(rawArgs[1]));
|
|
90
|
+
const rest = rawArgs.slice(2);
|
|
91
|
+
if (rest.length > 0) {
|
|
92
|
+
if (rest[0] === '-t' || rest[0] === '--tui') {
|
|
93
|
+
isTui = true;
|
|
94
|
+
} else if (rest[0] === '-p' || rest[0] === '--prompt') {
|
|
95
|
+
isTui = false;
|
|
96
|
+
promptInput = rest.slice(1).join(' ');
|
|
97
|
+
} else {
|
|
98
|
+
isTui = false;
|
|
99
|
+
promptInput = rest.join(' ');
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
isTui = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else if (first === '-p' || first === '--prompt' || first === '-s' || first === '--single-shot') {
|
|
106
|
+
isTui = false;
|
|
107
|
+
promptInput = rawArgs.slice(1).join(' ');
|
|
108
|
+
} else {
|
|
109
|
+
// Check if the argument is a directory path or relative directory reference
|
|
110
|
+
const candidate = resolve(expandHome(first));
|
|
111
|
+
const isDirRef =
|
|
112
|
+
first === '.' ||
|
|
113
|
+
first === './' ||
|
|
114
|
+
first === '.\\' ||
|
|
115
|
+
first === '..' ||
|
|
116
|
+
first === '../' ||
|
|
117
|
+
first === '..\\' ||
|
|
118
|
+
first.startsWith('~') ||
|
|
119
|
+
(existsSync(candidate) && statSync(candidate).isDirectory());
|
|
120
|
+
|
|
121
|
+
if (isDirRef) {
|
|
122
|
+
isTui = true;
|
|
123
|
+
targetDir = candidate;
|
|
124
|
+
if (rawArgs.length > 1) {
|
|
125
|
+
const rest = rawArgs.slice(1);
|
|
126
|
+
if (rest[0] === '-p' || rest[0] === '--prompt') {
|
|
127
|
+
isTui = false;
|
|
128
|
+
promptInput = rest.slice(1).join(' ');
|
|
129
|
+
} else if (rest[0] === '-t' || rest[0] === '--tui') {
|
|
130
|
+
isTui = true;
|
|
131
|
+
} else {
|
|
132
|
+
isTui = false;
|
|
133
|
+
promptInput = rest.join(' ');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
// Prompt text passed directly
|
|
138
|
+
isTui = false;
|
|
139
|
+
promptInput = rawArgs.join(' ');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Change process working directory if targetDir is specified
|
|
145
|
+
if (existsSync(targetDir) && statSync(targetDir).isDirectory()) {
|
|
146
|
+
process.chdir(targetDir);
|
|
147
|
+
} else {
|
|
148
|
+
console.error(`Error: Working directory "${targetDir}" does not exist.`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Load config and API key
|
|
153
|
+
const config = loadConfig();
|
|
154
|
+
const apiKey = config.provider.apiKey || process.env.DEEPSEEK_API_KEY;
|
|
155
|
+
|
|
156
|
+
if (!apiKey) {
|
|
157
|
+
console.error('Error: No API key found. Set DEEPSEEK_API_KEY in .env or in ~/.junecode/config.json.');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
config.provider.apiKey = apiKey;
|
|
162
|
+
|
|
163
|
+
// Create Agent instance with target cwd
|
|
164
|
+
const agent = createAgent({
|
|
165
|
+
provider: config.provider,
|
|
166
|
+
config,
|
|
167
|
+
cwd: process.cwd(),
|
|
168
|
+
memory: { dir: memoryDir() },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!isTui) {
|
|
172
|
+
// Single-shot mode
|
|
173
|
+
if (!promptInput) {
|
|
174
|
+
console.error('Error: No prompt provided for single-shot execution.');
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const { runAgent } = await import('./agent.mjs');
|
|
179
|
+
try {
|
|
180
|
+
const result = await runAgent(agent, promptInput, {
|
|
181
|
+
onToken: (t) => process.stdout.write(t),
|
|
182
|
+
onToolCall: (name) => process.stdout.write(`\n[tool: ${name}] `),
|
|
183
|
+
});
|
|
184
|
+
console.log('');
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error('Error:', err.message);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// TUI mode
|
|
193
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
194
|
+
console.error('Error: Terminal UI requires an interactive TTY.');
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const restored = loadSession(agent.cwd);
|
|
199
|
+
startTUI(agent, { projectDir: agent.cwd, restored });
|
package/config.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration module — provides configDir path and default config factory.
|
|
3
|
+
*
|
|
4
|
+
* The config object is passed into createAgent() externally; this module
|
|
5
|
+
* provides the directory for config files and default values.
|
|
6
|
+
*/
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
// Load .env files before any code reads process.env.
|
|
12
|
+
// Must happen here (not cli.mjs) because ESM static imports are hoisted —
|
|
13
|
+
// this module's body runs before cli.mjs's body.
|
|
14
|
+
for (const envPath of ['.env', join(homedir(), '.junecode', '.env')]) {
|
|
15
|
+
try {
|
|
16
|
+
if (existsSync(envPath)) {
|
|
17
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
18
|
+
for (const line of content.split('\n')) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
21
|
+
const eq = trimmed.indexOf('=');
|
|
22
|
+
if (eq === -1) continue;
|
|
23
|
+
const key = trimmed.slice(0, eq).trim();
|
|
24
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
25
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
26
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
27
|
+
val = val.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
if (!process.env[key]) process.env[key] = val;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch { /* ignore missing/unreadable .env */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Default config directory: ~/.junecode */
|
|
36
|
+
export const configDir = join(homedir(), '.junecode');
|
|
37
|
+
|
|
38
|
+
/** Default configuration object. Callers can spread/override with user settings. */
|
|
39
|
+
export function defaultConfig() {
|
|
40
|
+
return {
|
|
41
|
+
agent: {
|
|
42
|
+
maxTurns: 50,
|
|
43
|
+
subagentTurns: 20,
|
|
44
|
+
goalTurns: 30,
|
|
45
|
+
contextWindow: 1_000_000, // assumed model context window (estimated tokens)
|
|
46
|
+
compactThreshold: 750_000, // compress at 75% of contextWindow
|
|
47
|
+
},
|
|
48
|
+
provider: {
|
|
49
|
+
type: 'deepseek',
|
|
50
|
+
apiKey: process.env.DEEPSEEK_API_KEY || '',
|
|
51
|
+
model: 'deepseek-v4-pro',
|
|
52
|
+
baseURL: 'https://api.deepseek.com',
|
|
53
|
+
thinking: { type: 'enabled' },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load user config from configDir/config.json.
|
|
60
|
+
* Returns merged config: defaults overridden by user settings.
|
|
61
|
+
*/
|
|
62
|
+
export function loadConfig(dir = configDir) {
|
|
63
|
+
const defaults = defaultConfig();
|
|
64
|
+
const configPath = join(dir, 'config.json');
|
|
65
|
+
|
|
66
|
+
if (!existsSync(configPath)) return defaults;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
70
|
+
const user = JSON.parse(raw);
|
|
71
|
+
return deepMerge(defaults, user);
|
|
72
|
+
} catch {
|
|
73
|
+
return defaults;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── TUI support exports ──────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** Provider preset definitions for the TUI provider selector. */
|
|
80
|
+
export const PROVIDER_PRESETS = {};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get the thinking API spec for a given model.
|
|
84
|
+
* @param {string} model
|
|
85
|
+
* @returns {{ thinkApi: string }}
|
|
86
|
+
*/
|
|
87
|
+
export function specForModel(model) {
|
|
88
|
+
return { thinkApi: 'default' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the context compaction threshold for a provider/model pair.
|
|
93
|
+
* @param {string} provider
|
|
94
|
+
* @param {string} model
|
|
95
|
+
* @returns {{ value: number }}
|
|
96
|
+
*/
|
|
97
|
+
export function resolveCompactThreshold(provider, model) {
|
|
98
|
+
return { value: 100_000 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Shallow-ish deep merge: only merges top-level objects. */
|
|
102
|
+
function deepMerge(base, override) {
|
|
103
|
+
const result = { ...base };
|
|
104
|
+
for (const key of Object.keys(override)) {
|
|
105
|
+
if (
|
|
106
|
+
typeof base[key] === 'object' &&
|
|
107
|
+
base[key] !== null &&
|
|
108
|
+
!Array.isArray(base[key]) &&
|
|
109
|
+
typeof override[key] === 'object' &&
|
|
110
|
+
override[key] !== null
|
|
111
|
+
) {
|
|
112
|
+
result[key] = { ...base[key], ...override[key] };
|
|
113
|
+
} else {
|
|
114
|
+
result[key] = override[key];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
package/context.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context compression module.
|
|
3
|
+
*
|
|
4
|
+
* Handles history truncation when the conversation grows too long.
|
|
5
|
+
* Two strategies:
|
|
6
|
+
* 1. compressIfNeeded — LLM-based summarization (stub for now)
|
|
7
|
+
* 2. compressFallback — deterministic truncation when LLM summarization fails
|
|
8
|
+
*/
|
|
9
|
+
import { AUTO_REMINDER } from './agent.mjs';
|
|
10
|
+
|
|
11
|
+
/** How many consecutive compression failures before triggering fallback. */
|
|
12
|
+
export const COMPRESS_FAILURE_LIMIT = 3;
|
|
13
|
+
|
|
14
|
+
/** Rough token estimate: ~4 characters per token for English text. */
|
|
15
|
+
const CHARS_PER_TOKEN = 4;
|
|
16
|
+
|
|
17
|
+
// ─── Token estimation ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Estimate token count from a message array.
|
|
21
|
+
* Uses char/4 heuristic — fast, deterministic, good enough for threshold checks.
|
|
22
|
+
*/
|
|
23
|
+
export function estimateTokens(messages) {
|
|
24
|
+
if (!messages || messages.length === 0) return 0;
|
|
25
|
+
let chars = 0;
|
|
26
|
+
for (const msg of messages) {
|
|
27
|
+
if (!msg) continue;
|
|
28
|
+
chars += String(msg.content || '').length;
|
|
29
|
+
if (msg.tool_calls) chars += JSON.stringify(msg.tool_calls).length;
|
|
30
|
+
if (msg.tool_call_id) chars += String(msg.tool_call_id).length;
|
|
31
|
+
if (msg.name) chars += String(msg.name).length;
|
|
32
|
+
if (msg.reasoning_content) chars += String(msg.reasoning_content).length;
|
|
33
|
+
}
|
|
34
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── LLM-based compression (stub) ─────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if compression is needed and attempt LLM summarization.
|
|
41
|
+
* Currently stubbed: returns false to let the caller track failure count.
|
|
42
|
+
*
|
|
43
|
+
* When the stub is removed:
|
|
44
|
+
* 1. If estimated tokens < threshold, return false (no action needed).
|
|
45
|
+
* 2. Call LLM to generate a summary of the conversation so far.
|
|
46
|
+
* 3. Replace history with: system prompt + summary message + recent messages.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} agent
|
|
49
|
+
* @param {number} threshold - token threshold to trigger compression
|
|
50
|
+
* @returns {Promise<boolean>} true if compression was performed, false if skipped/failed
|
|
51
|
+
*/
|
|
52
|
+
export async function compressIfNeeded(agent, threshold) {
|
|
53
|
+
const tokens = estimateTokens(agent.history);
|
|
54
|
+
if (tokens < threshold) return false;
|
|
55
|
+
|
|
56
|
+
// Stub: not implementing LLM summarization yet.
|
|
57
|
+
// Returns false so the caller increments _compressFailures and
|
|
58
|
+
// eventually triggers compressFallback.
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Deterministic fallback truncation ────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Deterministic fallback: truncate old messages, keep system + recent context.
|
|
66
|
+
*
|
|
67
|
+
* Strategy:
|
|
68
|
+
* 1. System messages stay at the top, unmodified.
|
|
69
|
+
* 2. Keep the last `keepRecent` messages from history.
|
|
70
|
+
* 3. Drop everything else.
|
|
71
|
+
* 4. Insert a truncation note between system messages and kept messages.
|
|
72
|
+
* 5. Reset failure counter on success.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} agent
|
|
75
|
+
* @param {number} [keepRecent=10] - number of recent non-system messages to keep
|
|
76
|
+
*/
|
|
77
|
+
export function compressFallback(agent, keepRecent = 10) {
|
|
78
|
+
const history = agent.history;
|
|
79
|
+
if (!history || history.length === 0) return;
|
|
80
|
+
|
|
81
|
+
// Separate system messages from the rest
|
|
82
|
+
const systemMsgs = [];
|
|
83
|
+
const otherMsgs = [];
|
|
84
|
+
for (const msg of history) {
|
|
85
|
+
if (msg && msg.role === 'system') {
|
|
86
|
+
systemMsgs.push(msg);
|
|
87
|
+
} else {
|
|
88
|
+
otherMsgs.push(msg);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// If the rest is already short enough, nothing to do
|
|
93
|
+
if (otherMsgs.length <= keepRecent) {
|
|
94
|
+
agent._compressFailures = 0;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Keep only the last keepRecent non-system messages
|
|
99
|
+
let kept = otherMsgs.slice(-keepRecent);
|
|
100
|
+
// Never start mid-tool-batch: leading "tool" messages whose assistant
|
|
101
|
+
// (tool_calls) parent was cut away are rejected by providers (400).
|
|
102
|
+
while (kept.length > 0 && kept[0].role === 'tool') kept = kept.slice(1);
|
|
103
|
+
const dropped = otherMsgs.length - kept.length;
|
|
104
|
+
|
|
105
|
+
// Build truncation note
|
|
106
|
+
const summaryLine = `[Context compressed: ${dropped} earlier messages were truncated. ` +
|
|
107
|
+
`The conversation continues from here. Please continue based on recent context.]`;
|
|
108
|
+
|
|
109
|
+
// Rebuild history: system + truncation note + kept recent messages
|
|
110
|
+
agent.history = [
|
|
111
|
+
...systemMsgs,
|
|
112
|
+
{ role: 'user', content: summaryLine, _transient: true },
|
|
113
|
+
...kept,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
// Log a reminder about the compression
|
|
117
|
+
agent._pendingReminders = agent._pendingReminders || [];
|
|
118
|
+
agent._pendingReminders.push(AUTO_REMINDER);
|
|
119
|
+
|
|
120
|
+
// Reset failure counter
|
|
121
|
+
agent._compressFailures = 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Convenience: check + fallback ────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Full compression check: try LLM summarization, fall back to truncation.
|
|
128
|
+
* Called once per turn from the main loop.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} agent
|
|
131
|
+
* @param {number} threshold - token threshold
|
|
132
|
+
* @returns {Promise<boolean>} true if any compression action was taken
|
|
133
|
+
*/
|
|
134
|
+
export async function checkAndCompress(agent, threshold) {
|
|
135
|
+
// Don't compress if threshold is 0 (disabled)
|
|
136
|
+
if (!threshold || threshold <= 0) return false;
|
|
137
|
+
|
|
138
|
+
// Under threshold: nothing to do. This is NOT a compression failure —
|
|
139
|
+
// reset the counter so the fallback only fires after real over-threshold
|
|
140
|
+
// failures, not every COMPRESS_FAILURE_LIMIT turns.
|
|
141
|
+
if (estimateTokens(agent.history) < threshold) {
|
|
142
|
+
agent._compressFailures = 0;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Try LLM-based compression
|
|
147
|
+
const compressed = await compressIfNeeded(agent, threshold);
|
|
148
|
+
if (compressed) return true;
|
|
149
|
+
|
|
150
|
+
// LLM compression didn't happen — track failures
|
|
151
|
+
agent._compressFailures = (agent._compressFailures || 0) + 1;
|
|
152
|
+
|
|
153
|
+
// If we've failed too many times, fall back to deterministic truncation
|
|
154
|
+
if (agent._compressFailures >= COMPRESS_FAILURE_LIMIT) {
|
|
155
|
+
compressFallback(agent);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return false;
|
|
160
|
+
}
|
package/distill.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge distillation — extract reusable knowledge from conversations.
|
|
3
|
+
*
|
|
4
|
+
* Extracts candidate knowledge items (facts, patterns, decisions) from
|
|
5
|
+
* conversation transcripts for long-term memory.
|
|
6
|
+
*
|
|
7
|
+
* Extraction uses heuristics (keyword matching) by default. When a provider
|
|
8
|
+
* is available, extractCandidates can optionally use LLM-based extraction.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
// ─── Candidate extraction heuristics ───────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Indicator words that suggest a line contains reusable knowledge. */
|
|
16
|
+
const INDICATORS = [
|
|
17
|
+
// Decisions
|
|
18
|
+
/^(?:we|i|let'?s?)\s+(?:decided?|chose|went with|opted)\b/i,
|
|
19
|
+
/\b(?:decision|choice|prefer|policy)\s*(?::|is|was)\b/i,
|
|
20
|
+
// Conventions
|
|
21
|
+
/\b(?:convention|standard|rule|guideline|idiom|best practice)\s*(?::|is|was)\b/i,
|
|
22
|
+
/\b(?:always|never|should|must not|don't|do not)\s+\w+\s+(?:use|do|call|import|require|write)\b/i,
|
|
23
|
+
// Patterns
|
|
24
|
+
/\b(?:pattern|approach|strategy|workflow|recipe)\s*(?::|is|was|for)\b/i,
|
|
25
|
+
/\b(?:the pattern is|we follow|common pattern)\b/i,
|
|
26
|
+
// Facts
|
|
27
|
+
/\b(?:note|important|remember|key|tldr)\s*:/i,
|
|
28
|
+
/\b(?:this project uses|we use|configured to)\b/i,
|
|
29
|
+
/\b(?:because|since|due to|as a result)\b.*\b(?:so|therefore|thus|hence)\b/i,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Minimum length for extracted content snippet. */
|
|
33
|
+
const MIN_LENGTH = 20;
|
|
34
|
+
|
|
35
|
+
/** Categorize a candidate snippet based on its language. */
|
|
36
|
+
function categorize(text) {
|
|
37
|
+
const t = text.toLowerCase();
|
|
38
|
+
if (/\b(?:decided?|chose|decision|choice)\b/.test(t)) return 'decision';
|
|
39
|
+
if (/\b(?:convention|standard|rule|must|should|always|never|guideline)\b/.test(t)) return 'rule';
|
|
40
|
+
if (/\b(?:pattern|approach|workflow|recipe|strategy)\b/.test(t)) return 'pattern';
|
|
41
|
+
return 'knowledge';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extract a short title from a snippet. */
|
|
45
|
+
function extractTitle(text) {
|
|
46
|
+
// First sentence, truncated
|
|
47
|
+
const cleaned = text.replace(/^[\s*\-–—:]+/, '').trim();
|
|
48
|
+
const sentence = cleaned.split(/[.!?]\s/)[0].trim();
|
|
49
|
+
if (sentence.length <= 80) return sentence;
|
|
50
|
+
return sentence.slice(0, 77) + '...';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Public API ────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract candidate knowledge items from a transcript.
|
|
57
|
+
* Uses heuristic pattern matching. When provider is available and enabled,
|
|
58
|
+
* can use LLM-based extraction for higher quality.
|
|
59
|
+
*
|
|
60
|
+
* @param {object|null} provider - LLM provider (optional, for LLM-based extraction)
|
|
61
|
+
* @param {string} transcript - conversation transcript
|
|
62
|
+
* @param {{ useLLM?: boolean }} [opts]
|
|
63
|
+
* @returns {Promise<object[]>} array of { type, title, content, tags }
|
|
64
|
+
*/
|
|
65
|
+
export async function extractCandidates(provider, transcript, opts = {}) {
|
|
66
|
+
if (!transcript) return [];
|
|
67
|
+
|
|
68
|
+
const lines = transcript.split('\n');
|
|
69
|
+
const candidates = [];
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
|
|
72
|
+
// Process each line
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const line = lines[i].trim();
|
|
75
|
+
if (!line || line.length < MIN_LENGTH) continue;
|
|
76
|
+
|
|
77
|
+
// Check against indicator patterns
|
|
78
|
+
let matched = false;
|
|
79
|
+
for (const re of INDICATORS) {
|
|
80
|
+
if (re.test(line)) {
|
|
81
|
+
matched = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!matched) continue;
|
|
87
|
+
|
|
88
|
+
// Collect context: this line +- 1 surrounding lines
|
|
89
|
+
const start = Math.max(0, i - 1);
|
|
90
|
+
const end = Math.min(lines.length, i + 2);
|
|
91
|
+
const snippet = lines.slice(start, end).join(' ').trim();
|
|
92
|
+
|
|
93
|
+
// Deduplicate by content hash
|
|
94
|
+
const hash = createHash('sha256').update(snippet).digest('hex').slice(0, 12);
|
|
95
|
+
if (seen.has(hash)) continue;
|
|
96
|
+
seen.add(hash);
|
|
97
|
+
|
|
98
|
+
const type = categorize(snippet);
|
|
99
|
+
const title = extractTitle(snippet);
|
|
100
|
+
|
|
101
|
+
candidates.push({
|
|
102
|
+
type,
|
|
103
|
+
title,
|
|
104
|
+
content: snippet,
|
|
105
|
+
tags: [type],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return candidates;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Convert agent history array to a plain-text transcript.
|
|
114
|
+
* @param {object[]} history - array of { role, content, tool_calls? } messages
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function historyToTranscript(history) {
|
|
118
|
+
if (!history || history.length === 0) return '';
|
|
119
|
+
|
|
120
|
+
return history
|
|
121
|
+
.filter((m) => m && m.role)
|
|
122
|
+
.map((m) => {
|
|
123
|
+
let body = '';
|
|
124
|
+
if (m.content) body = m.content;
|
|
125
|
+
if (m.tool_calls) {
|
|
126
|
+
const names = m.tool_calls.map((tc) => tc.function?.name || '?').join(', ');
|
|
127
|
+
body = body ? `${body} [tool_calls: ${names}]` : `[tool_calls: ${names}]`;
|
|
128
|
+
}
|
|
129
|
+
// For tool results, show preview
|
|
130
|
+
if (m.role === 'tool' && m.tool_call_id) {
|
|
131
|
+
const preview = (m.content || '').slice(0, 200);
|
|
132
|
+
return `[tool result: ${m.name || m.tool_call_id}]: ${preview}`;
|
|
133
|
+
}
|
|
134
|
+
return `[${m.role}]: ${body}`;
|
|
135
|
+
})
|
|
136
|
+
.join('\n\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Save a candidate knowledge item to memory.
|
|
141
|
+
* Converts candidate to a memory entry and persists it.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} memory - memory instance
|
|
144
|
+
* @param {object} candidate - { type, title, content, tags }
|
|
145
|
+
* @param {{ autoApprove?: boolean }} [opts]
|
|
146
|
+
* @returns {Promise<string>} status message
|
|
147
|
+
*/
|
|
148
|
+
export async function saveCandidate(memory, candidate, opts = {}) {
|
|
149
|
+
if (!memory) return 'no memory store available';
|
|
150
|
+
|
|
151
|
+
const id = createHash('sha256')
|
|
152
|
+
.update(candidate.content || candidate.title || '')
|
|
153
|
+
.digest('hex')
|
|
154
|
+
.slice(0, 16);
|
|
155
|
+
|
|
156
|
+
const entry = {
|
|
157
|
+
id,
|
|
158
|
+
content: `[${candidate.type || 'knowledge'}] ${candidate.title}: ${candidate.content}`,
|
|
159
|
+
source: 'distill',
|
|
160
|
+
tags: [...(candidate.tags || []), candidate.type || 'knowledge', 'distilled'],
|
|
161
|
+
timestamp: Date.now(),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const { join } = await import('node:path');
|
|
165
|
+
const { mkdirSync, writeFileSync } = await import('node:fs');
|
|
166
|
+
const { homedir } = await import('node:os');
|
|
167
|
+
|
|
168
|
+
const dir = memory.dir || join(homedir(), '.junecode', 'memory');
|
|
169
|
+
try { mkdirSync(dir, { recursive: true }); } catch { /* dir exists */ }
|
|
170
|
+
|
|
171
|
+
writeFileSync(join(dir, entry.id + '.json'), JSON.stringify(entry, null, 2), 'utf-8');
|
|
172
|
+
|
|
173
|
+
if (memory.entries) {
|
|
174
|
+
memory.entries.push(entry);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return `saved: ${candidate.title || candidate.type || 'knowledge'}`;
|
|
178
|
+
}
|