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/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
+ }