beecork 1.6.0 → 1.7.1

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.
@@ -0,0 +1,22 @@
1
+ import type { TabManager, NotifyCallback } from '../session/manager.js';
2
+ export declare class CronScheduler {
3
+ private tabManager;
4
+ private onNotify;
5
+ private scheduledJobs;
6
+ private store;
7
+ constructor(tabManager: TabManager, onNotify: NotifyCallback | null);
8
+ /** Load all cron jobs from store and schedule them */
9
+ loadAndSchedule(): void;
10
+ /** Check for the reload signal file and reload if present */
11
+ checkForReload(): void;
12
+ /** Stop all scheduled jobs */
13
+ stopAll(): void;
14
+ private scheduleJob;
15
+ private fireJob;
16
+ private handleSystemEvent;
17
+ }
18
+ /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to milliseconds */
19
+ export declare function intervalToMs(interval: string): number | null;
20
+ /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to cron expression */
21
+ export declare function intervalToCron(interval: string): string | null;
22
+ //# sourceMappingURL=scheduler.d.ts.map
@@ -0,0 +1,220 @@
1
+ import cron from 'node-cron';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CronStore } from './store.js';
5
+ import { getCronReloadSignalPath, getLogsDir } from '../util/paths.js';
6
+ import { logger } from '../util/logger.js';
7
+ export class CronScheduler {
8
+ tabManager;
9
+ onNotify;
10
+ scheduledJobs = new Map();
11
+ store = new CronStore();
12
+ constructor(tabManager, onNotify) {
13
+ this.tabManager = tabManager;
14
+ this.onNotify = onNotify;
15
+ }
16
+ /** Load all cron jobs from store and schedule them */
17
+ loadAndSchedule() {
18
+ // Cancel existing
19
+ for (const [, task] of this.scheduledJobs) {
20
+ task.stop();
21
+ }
22
+ this.scheduledJobs.clear();
23
+ const jobs = this.store.list();
24
+ let scheduled = 0;
25
+ let missedFires = 0;
26
+ for (const job of jobs) {
27
+ if (!job.enabled)
28
+ continue;
29
+ // Detect missed fires: one-time "at" jobs whose time has passed but never ran
30
+ if (job.scheduleType === 'at' && !job.lastRunAt) {
31
+ const targetTime = new Date(job.schedule).getTime();
32
+ if (targetTime <= Date.now()) {
33
+ logger.warn(`Cron: missed fire detected for "${job.name}" (was scheduled for ${job.schedule}), firing now`);
34
+ missedFires++;
35
+ this.fireJob(job);
36
+ this.store.update(job.id, { enabled: false }); // Disable after one-time execution
37
+ continue;
38
+ }
39
+ }
40
+ this.scheduleJob(job);
41
+ scheduled++;
42
+ }
43
+ logger.info(`Cron: loaded ${scheduled} active jobs (${jobs.length} total)${missedFires > 0 ? `, fired ${missedFires} missed` : ''}`);
44
+ }
45
+ /** Check for the reload signal file and reload if present */
46
+ checkForReload() {
47
+ const signalPath = getCronReloadSignalPath();
48
+ if (fs.existsSync(signalPath)) {
49
+ try {
50
+ fs.unlinkSync(signalPath);
51
+ }
52
+ catch { /* race condition, ok */ }
53
+ logger.info('Cron: reload signal detected, reloading schedules');
54
+ this.loadAndSchedule();
55
+ }
56
+ }
57
+ /** Stop all scheduled jobs */
58
+ stopAll() {
59
+ for (const [, task] of this.scheduledJobs) {
60
+ task.stop();
61
+ }
62
+ this.scheduledJobs.clear();
63
+ }
64
+ scheduleJob(job) {
65
+ switch (job.scheduleType) {
66
+ case 'cron': {
67
+ if (!cron.validate(job.schedule)) {
68
+ logger.error(`Cron: invalid expression for "${job.name}": ${job.schedule}`);
69
+ return;
70
+ }
71
+ const task = cron.schedule(job.schedule, () => this.fireJob(job));
72
+ this.scheduledJobs.set(job.id, task);
73
+ break;
74
+ }
75
+ case 'every': {
76
+ const cronExpr = intervalToCron(job.schedule);
77
+ if (cronExpr) {
78
+ if (!cron.validate(cronExpr)) {
79
+ logger.error(`Cron: invalid cron expression for "${job.name}": ${cronExpr}`);
80
+ return;
81
+ }
82
+ const task = cron.schedule(cronExpr, () => this.fireJob(job));
83
+ this.scheduledJobs.set(job.id, task);
84
+ }
85
+ else {
86
+ // Use setInterval for non-cron-expressible intervals
87
+ const totalMs = intervalToMs(job.schedule);
88
+ if (totalMs) {
89
+ const timer = setInterval(() => this.fireJob(job), totalMs);
90
+ this.scheduledJobs.set(job.id, { stop: () => clearInterval(timer) });
91
+ }
92
+ else {
93
+ logger.error(`Cron: invalid interval for "${job.name}": ${job.schedule}`);
94
+ }
95
+ }
96
+ break;
97
+ }
98
+ case 'at': {
99
+ const targetTime = new Date(job.schedule).getTime();
100
+ const delay = targetTime - Date.now();
101
+ if (delay <= 0) {
102
+ logger.warn(`Cron: one-time job "${job.name}" is in the past, skipping`);
103
+ return;
104
+ }
105
+ const timer = setTimeout(() => {
106
+ this.fireJob(job);
107
+ this.store.update(job.id, { enabled: false });
108
+ }, delay);
109
+ this.scheduledJobs.set(job.id, { stop: () => clearTimeout(timer) });
110
+ break;
111
+ }
112
+ }
113
+ }
114
+ async fireJob(job) {
115
+ logger.info(`Cron firing: "${job.name}" (${job.payloadType || 'agentTurn'}) → tab:${job.tabName}`);
116
+ const logFile = path.join(getLogsDir(), `cron-${job.name}.log`);
117
+ // Handle systemEvent — internal Beecork actions, not Claude Code
118
+ if (job.payloadType === 'systemEvent') {
119
+ try {
120
+ await this.handleSystemEvent(job);
121
+ this.store.update(job.id, { lastRunAt: new Date().toISOString() });
122
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] SYSTEM: ${job.message}\n`);
123
+ }
124
+ catch (err) {
125
+ const errMsg = err instanceof Error ? err.message : String(err);
126
+ logger.error(`System event "${job.name}" failed:`, err);
127
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] ERROR: ${errMsg}\n`);
128
+ }
129
+ return;
130
+ }
131
+ try {
132
+ this.tabManager.ensureTab(job.tabName);
133
+ const result = await this.tabManager.sendMessage(job.tabName, job.message);
134
+ this.store.update(job.id, { lastRunAt: new Date().toISOString() });
135
+ const firstLine = result.text.split('\n')[0]?.slice(0, 200) || '(no output)';
136
+ // Log result
137
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] SUCCESS: ${firstLine}\n`);
138
+ // Notify (separate try/catch — notification failure shouldn't be reported as job failure)
139
+ try {
140
+ if (this.onNotify) {
141
+ if (result.error) {
142
+ await this.onNotify(`❌ [${job.name}] Failed — ${firstLine}`);
143
+ }
144
+ else {
145
+ await this.onNotify(`✅ [${job.name}] Done — ${firstLine}`);
146
+ }
147
+ }
148
+ }
149
+ catch (notifyErr) {
150
+ logger.warn(`Cron job "${job.name}" notification failed:`, notifyErr);
151
+ }
152
+ }
153
+ catch (err) {
154
+ const errMsg = err instanceof Error ? err.message : String(err);
155
+ logger.error(`Cron job "${job.name}" failed:`, err);
156
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] ERROR: ${errMsg}\n`);
157
+ try {
158
+ await this.onNotify?.(`❌ [${job.name}] Failed — ${errMsg}`);
159
+ }
160
+ catch { /* notification best-effort */ }
161
+ }
162
+ }
163
+ async handleSystemEvent(job) {
164
+ switch (job.message) {
165
+ case 'health_check':
166
+ logger.info('System event: health check — daemon alive');
167
+ if (this.onNotify) {
168
+ await this.onNotify('🟢 Beecork health check: all systems operational');
169
+ }
170
+ break;
171
+ case 'memory_compaction':
172
+ logger.info('System event: memory compaction (not yet implemented)');
173
+ break;
174
+ default:
175
+ logger.warn(`Unknown system event: ${job.message}`);
176
+ }
177
+ }
178
+ }
179
+ /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to milliseconds */
180
+ export function intervalToMs(interval) {
181
+ const match = interval.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
182
+ if (!match || match.slice(1).every(g => g === undefined))
183
+ return null;
184
+ const weeks = parseInt(match[1] || '0', 10);
185
+ const days = parseInt(match[2] || '0', 10);
186
+ const hours = parseInt(match[3] || '0', 10);
187
+ const mins = parseInt(match[4] || '0', 10);
188
+ const totalMs = ((weeks * 7 * 24 * 60) + (days * 24 * 60) + (hours * 60) + mins) * 60 * 1000;
189
+ return totalMs > 0 ? totalMs : null;
190
+ }
191
+ /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to cron expression */
192
+ export function intervalToCron(interval) {
193
+ // Try combined format: 1h30m, 2h, 30m, 1d, 2w
194
+ const match = interval.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
195
+ if (!match || match.slice(1).every(g => g === undefined))
196
+ return null;
197
+ const weeks = parseInt(match[1] || '0', 10);
198
+ const days = parseInt(match[2] || '0', 10);
199
+ const hours = parseInt(match[3] || '0', 10);
200
+ const mins = parseInt(match[4] || '0', 10);
201
+ // Convert to total minutes for simple intervals
202
+ const totalMins = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + mins;
203
+ if (totalMins <= 0)
204
+ return null;
205
+ // Simple minute interval
206
+ if (totalMins <= 59)
207
+ return `*/${totalMins} * * * *`;
208
+ // Hourly intervals
209
+ if (mins === 0 && days === 0 && weeks === 0 && hours > 0 && hours <= 23)
210
+ return `0 */${hours} * * *`;
211
+ // Daily intervals
212
+ if (mins === 0 && hours === 0 && weeks === 0 && days > 0)
213
+ return `0 0 */${days} * *`;
214
+ // Weekly intervals
215
+ if (mins === 0 && hours === 0 && days === 0 && weeks > 0)
216
+ return `0 0 * * 0`;
217
+ // Combined or large intervals — return null, handled by setInterval in scheduleJob
218
+ return null;
219
+ }
220
+ //# sourceMappingURL=scheduler.js.map
@@ -0,0 +1,12 @@
1
+ import type { CronJob } from '../types.js';
2
+ export declare class CronStore {
3
+ constructor();
4
+ list(): CronJob[];
5
+ get(id: string): CronJob | undefined;
6
+ add(job: CronJob): void;
7
+ update(id: string, updates: Partial<CronJob>): boolean;
8
+ delete(id: string): boolean;
9
+ /** One-time migration from crontab.json to SQLite */
10
+ private migrateFromJson;
11
+ }
12
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1,83 @@
1
+ import fs from 'node:fs';
2
+ import { getDb } from '../db/index.js';
3
+ import { getCrontabPath } from '../util/paths.js';
4
+ import { logger } from '../util/logger.js';
5
+ function rowToJob(row) {
6
+ return {
7
+ id: row.id, name: row.name,
8
+ scheduleType: row.schedule_type,
9
+ schedule: row.schedule, tabName: row.tab_name, message: row.message,
10
+ payloadType: row.payload_type || 'agentTurn',
11
+ enabled: row.enabled === 1, createdAt: row.created_at,
12
+ lastRunAt: row.last_run_at, nextRunAt: row.next_run_at,
13
+ };
14
+ }
15
+ export class CronStore {
16
+ constructor() {
17
+ this.migrateFromJson();
18
+ }
19
+ list() {
20
+ const db = getDb();
21
+ return db.prepare('SELECT * FROM cron_jobs WHERE user_id = ? ORDER BY created_at').all('local').map(rowToJob);
22
+ }
23
+ get(id) {
24
+ const db = getDb();
25
+ const row = db.prepare('SELECT * FROM cron_jobs WHERE id = ?').get(id);
26
+ return row ? rowToJob(row) : undefined;
27
+ }
28
+ add(job) {
29
+ const db = getDb();
30
+ db.prepare(`INSERT INTO cron_jobs (id, name, schedule_type, schedule, tab_name, message, payload_type, enabled, user_id, created_at, last_run_at, next_run_at)
31
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(job.id, job.name, job.scheduleType, job.schedule, job.tabName, job.message, job.payloadType || 'agentTurn', job.enabled ? 1 : 0, 'local', job.createdAt, job.lastRunAt, job.nextRunAt);
32
+ }
33
+ update(id, updates) {
34
+ const db = getDb();
35
+ const existing = this.get(id);
36
+ if (!existing)
37
+ return false;
38
+ const merged = { ...existing, ...updates };
39
+ db.prepare(`UPDATE cron_jobs SET name=?, schedule_type=?, schedule=?, tab_name=?, message=?, enabled=?, last_run_at=?, next_run_at=? WHERE id=?`).run(merged.name, merged.scheduleType, merged.schedule, merged.tabName, merged.message, merged.enabled ? 1 : 0, merged.lastRunAt, merged.nextRunAt, id);
40
+ return true;
41
+ }
42
+ delete(id) {
43
+ const db = getDb();
44
+ const result = db.prepare('DELETE FROM cron_jobs WHERE id = ?').run(id);
45
+ return result.changes > 0;
46
+ }
47
+ /** One-time migration from crontab.json to SQLite */
48
+ migrateFromJson() {
49
+ const jsonPath = getCrontabPath();
50
+ if (!fs.existsSync(jsonPath))
51
+ return;
52
+ const db = getDb();
53
+ const count = db.prepare('SELECT COUNT(*) as count FROM cron_jobs').get();
54
+ if (count.count > 0) {
55
+ // Already migrated, clean up JSON
56
+ try {
57
+ fs.renameSync(jsonPath, jsonPath + '.bak');
58
+ }
59
+ catch { /* ok */ }
60
+ return;
61
+ }
62
+ try {
63
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
64
+ const jobs = data.jobs || [];
65
+ if (jobs.length === 0)
66
+ return;
67
+ const insert = db.prepare(`INSERT OR IGNORE INTO cron_jobs (id, name, schedule_type, schedule, tab_name, message, enabled, user_id, created_at, last_run_at, next_run_at)
68
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
69
+ const tx = db.transaction(() => {
70
+ for (const j of jobs) {
71
+ insert.run(j.id, j.name, j.scheduleType, j.schedule, j.tabName || 'default', j.message, j.enabled ? 1 : 0, 'local', j.createdAt, j.lastRunAt, j.nextRunAt);
72
+ }
73
+ });
74
+ tx();
75
+ fs.renameSync(jsonPath, jsonPath + '.bak');
76
+ logger.info(`Migrated ${jobs.length} cron jobs from JSON to SQLite`);
77
+ }
78
+ catch (err) {
79
+ logger.error('Failed to migrate cron jobs from JSON:', err);
80
+ }
81
+ }
82
+ }
83
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,7 @@
1
+ import type { Machine } from './registry.js';
2
+ /**
3
+ * Forward a message to a remote Beecork instance via SSH.
4
+ * Writes to the remote machine's pending_messages table.
5
+ */
6
+ export declare function forwardToMachine(machine: Machine, tabName: string, message: string): Promise<boolean>;
7
+ //# sourceMappingURL=forwarder.d.ts.map
@@ -0,0 +1,35 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { logger } from '../util/logger.js';
3
+ import { validateTabName } from '../config.js';
4
+ /**
5
+ * Forward a message to a remote Beecork instance via SSH.
6
+ * Writes to the remote machine's pending_messages table.
7
+ */
8
+ export async function forwardToMachine(machine, tabName, message) {
9
+ if (!machine.host || !machine.sshUser) {
10
+ logger.warn(`Cannot forward to ${machine.name}: no SSH config`);
11
+ return false;
12
+ }
13
+ // Validate inputs to prevent injection via execFileSync args
14
+ if (tabName !== 'default') {
15
+ const tabError = validateTabName(tabName);
16
+ if (tabError) {
17
+ logger.error(`Invalid tab name for forwarding: ${tabError}`);
18
+ return false;
19
+ }
20
+ }
21
+ try {
22
+ // Use execFileSync with array args to prevent shell injection
23
+ execFileSync('ssh', [
24
+ `${machine.sshUser}@${machine.host}`,
25
+ 'beecork', 'send', tabName, message,
26
+ ], { timeout: 30000, encoding: 'utf-8' });
27
+ logger.info(`Message forwarded to ${machine.name} (${machine.host}), tab: ${tabName}`);
28
+ return true;
29
+ }
30
+ catch (err) {
31
+ logger.error(`Failed to forward to ${machine.name}:`, err);
32
+ return false;
33
+ }
34
+ }
35
+ //# sourceMappingURL=forwarder.js.map
@@ -0,0 +1 @@
1
+ export { getMachineId, registerThisMachine, listMachines, type Machine } from './registry.js';
@@ -0,0 +1 @@
1
+ export { getMachineId, registerThisMachine, listMachines } from './registry.js';
@@ -0,0 +1,15 @@
1
+ export interface Machine {
2
+ id: string;
3
+ name: string;
4
+ host: string | null;
5
+ sshUser: string | null;
6
+ projectPaths: string[];
7
+ isPrimary: boolean;
8
+ lastSeenAt: string;
9
+ }
10
+ /** Get or create this machine's unique ID */
11
+ export declare function getMachineId(): string;
12
+ /** Register this machine in the database */
13
+ export declare function registerThisMachine(projectPaths: string[]): Machine;
14
+ /** List all machines */
15
+ export declare function listMachines(): Machine[];
@@ -0,0 +1,46 @@
1
+ import os from 'node:os';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { getDb } from '../db/index.js';
4
+ import { getBeecorkHome } from '../util/paths.js';
5
+ import { logger } from '../util/logger.js';
6
+ import fs from 'node:fs';
7
+ const MACHINE_ID_PATH = `${getBeecorkHome()}/machine-id`;
8
+ /** Get or create this machine's unique ID */
9
+ export function getMachineId() {
10
+ if (fs.existsSync(MACHINE_ID_PATH)) {
11
+ return fs.readFileSync(MACHINE_ID_PATH, 'utf-8').trim();
12
+ }
13
+ const id = uuidv4();
14
+ fs.writeFileSync(MACHINE_ID_PATH, id, { mode: 0o600 });
15
+ return id;
16
+ }
17
+ /** Register this machine in the database */
18
+ export function registerThisMachine(projectPaths) {
19
+ const db = getDb();
20
+ const id = getMachineId();
21
+ const name = os.hostname();
22
+ db.prepare(`
23
+ INSERT INTO machines (id, name, project_paths, last_seen_at)
24
+ VALUES (?, ?, ?, datetime('now'))
25
+ ON CONFLICT(id) DO UPDATE SET
26
+ name = excluded.name,
27
+ project_paths = excluded.project_paths,
28
+ last_seen_at = datetime('now')
29
+ `).run(id, name, JSON.stringify(projectPaths));
30
+ logger.info(`Machine registered: ${name} (${id.slice(0, 8)}) with ${projectPaths.length} project paths`);
31
+ return { id, name, host: null, sshUser: null, projectPaths, isPrimary: false, lastSeenAt: new Date().toISOString() };
32
+ }
33
+ /** List all machines */
34
+ export function listMachines() {
35
+ const db = getDb();
36
+ const rows = db.prepare('SELECT * FROM machines ORDER BY is_primary DESC, name').all();
37
+ return rows.map(r => ({
38
+ id: r.id,
39
+ name: r.name,
40
+ host: r.host,
41
+ sshUser: r.ssh_user,
42
+ projectPaths: JSON.parse(r.project_paths || '[]'),
43
+ isPrimary: !!r.is_primary,
44
+ lastSeenAt: r.last_seen_at,
45
+ }));
46
+ }
@@ -0,0 +1,8 @@
1
+ import type { MediaGenerator } from './types.js';
2
+ /**
3
+ * Discover and load community media generator packages from node_modules.
4
+ * Convention: packages named `beecork-media-<name>` must export a default
5
+ * class implementing the MediaGenerator interface.
6
+ */
7
+ export declare function loadCommunityGenerators(apiKeys?: Record<string, string>): Promise<MediaGenerator[]>;
8
+ //# sourceMappingURL=loader.d.ts.map
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { logger } from '../util/logger.js';
4
+ const MEDIA_PREFIX = 'beecork-media-';
5
+ /**
6
+ * Discover and load community media generator packages from node_modules.
7
+ * Convention: packages named `beecork-media-<name>` must export a default
8
+ * class implementing the MediaGenerator interface.
9
+ */
10
+ export async function loadCommunityGenerators(apiKeys) {
11
+ const generators = [];
12
+ const searchPaths = [path.join(process.cwd(), 'node_modules')];
13
+ for (const searchPath of searchPaths) {
14
+ if (!fs.existsSync(searchPath))
15
+ continue;
16
+ try {
17
+ for (const dir of fs.readdirSync(searchPath)) {
18
+ if (!dir.startsWith(MEDIA_PREFIX))
19
+ continue;
20
+ try {
21
+ const pkgPath = path.join(searchPath, dir);
22
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgPath, 'package.json'), 'utf-8'));
23
+ const entryPath = path.join(pkgPath, pkgJson.main || 'index.js');
24
+ if (!fs.existsSync(entryPath))
25
+ continue;
26
+ const module = await import(entryPath);
27
+ const GeneratorClass = module.default || module[Object.keys(module)[0]];
28
+ if (!GeneratorClass || typeof GeneratorClass !== 'function')
29
+ continue;
30
+ const name = dir.slice(MEDIA_PREFIX.length);
31
+ const instance = new GeneratorClass(apiKeys?.[name]);
32
+ if (!instance.id || !instance.name || typeof instance.generate !== 'function')
33
+ continue;
34
+ generators.push(instance);
35
+ logger.info(`Community media generator loaded: ${instance.name} (${instance.id})`);
36
+ }
37
+ catch (err) {
38
+ logger.warn(`Failed to load community generator ${dir}:`, err);
39
+ }
40
+ }
41
+ }
42
+ catch { }
43
+ }
44
+ return generators;
45
+ }
46
+ //# sourceMappingURL=loader.js.map
@@ -0,0 +1,5 @@
1
+ import type { BeecorkConfig } from '../types.js';
2
+ /** Auto-extract memories from a completed session */
3
+ export declare function extractMemories(config: BeecorkConfig, tabName: string, sessionText: string, durationMs: number): Promise<void>;
4
+ /** Inject relevant memories into a prompt */
5
+ export declare function getRelevantMemories(tabName: string): string[];
@@ -0,0 +1,157 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { getDb } from '../db/index.js';
3
+ import { logger } from '../util/logger.js';
4
+ const EXTRACTION_PROMPT = `Extract 0-5 key facts, decisions, or outcomes from this session transcript that would be useful in future sessions. Output ONLY a JSON array of strings. If nothing is worth remembering, output an empty array [].
5
+
6
+ Examples of what to extract:
7
+ - Server addresses, credentials, file paths
8
+ - User preferences ("prefers deployments after 11pm")
9
+ - Decisions made ("chose PostgreSQL over MySQL for X reason")
10
+ - Outcomes ("deploy succeeded", "bug was in auth middleware")
11
+
12
+ Session transcript:
13
+ `;
14
+ /** Auto-extract memories from a completed session */
15
+ export async function extractMemories(config, tabName, sessionText, durationMs) {
16
+ // Only extract from non-trivial sessions
17
+ if (durationMs < 10000 || sessionText.length < 200)
18
+ return;
19
+ // Rate limit: check if we extracted recently for this tab
20
+ const db = getDb();
21
+ const recent = db.prepare(`SELECT COUNT(*) as count FROM memories
22
+ WHERE tab_name = ? AND source = 'auto'
23
+ AND created_at > datetime('now', '-5 minutes')`).get(tabName);
24
+ if (recent.count > 0) {
25
+ logger.debug(`[${tabName}] Skipping memory extraction — too recent`);
26
+ return;
27
+ }
28
+ try {
29
+ const prompt = EXTRACTION_PROMPT + sessionText.slice(0, 5000); // Limit transcript size
30
+ const facts = await runExtractionSession(config, prompt);
31
+ if (facts.length === 0)
32
+ return;
33
+ for (const fact of facts) {
34
+ db.prepare('INSERT INTO memories (content, tab_name, source) VALUES (?, ?, ?)').run(fact, tabName, 'auto');
35
+ }
36
+ // Enforce maxLongTermEntries limit
37
+ const maxEntries = config.memory.maxLongTermEntries ?? 1000;
38
+ const count = db.prepare('SELECT COUNT(*) as c FROM memories').get().c;
39
+ if (count > maxEntries) {
40
+ const excess = count - maxEntries;
41
+ db.prepare('DELETE FROM memories WHERE rowid IN (SELECT rowid FROM memories ORDER BY created_at ASC LIMIT ?)').run(excess);
42
+ logger.info(`[${tabName}] Evicted ${excess} oldest memories (limit: ${maxEntries})`);
43
+ }
44
+ logger.info(`[${tabName}] Auto-extracted ${facts.length} memories`);
45
+ }
46
+ catch (err) {
47
+ logger.error(`[${tabName}] Memory extraction failed:`, err);
48
+ }
49
+ }
50
+ /** Inject relevant memories into a prompt */
51
+ export function getRelevantMemories(tabName) {
52
+ const db = getDb();
53
+ // Get recent global memories + tab-specific memories
54
+ const memories = db.prepare(`SELECT content FROM memories
55
+ WHERE tab_name IS NULL OR tab_name = ?
56
+ ORDER BY created_at DESC LIMIT 20`).all(tabName);
57
+ return memories.map(m => m.content);
58
+ }
59
+ async function runExtractionSession(config, prompt) {
60
+ // Prefer direct API call (cheaper, faster) when API key is available
61
+ if (config.pipe?.anthropicApiKey) {
62
+ return runExtractionViaApi(config.pipe.anthropicApiKey, config.pipe.routingModel, prompt);
63
+ }
64
+ // Fallback: spawn Claude Code subprocess
65
+ return runExtractionViaSubprocess(config, prompt);
66
+ }
67
+ function parseFactsFromText(text) {
68
+ const jsonMatch = text.match(/\[[\s\S]*?\]/);
69
+ if (jsonMatch) {
70
+ const facts = JSON.parse(jsonMatch[0]);
71
+ if (Array.isArray(facts) && facts.every(f => typeof f === 'string')) {
72
+ return facts.slice(0, 5);
73
+ }
74
+ }
75
+ return [];
76
+ }
77
+ let cachedClient = null;
78
+ let cachedApiKey = '';
79
+ async function runExtractionViaApi(apiKey, model, prompt) {
80
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
81
+ if (!cachedClient || cachedApiKey !== apiKey) {
82
+ cachedClient = new Anthropic({ apiKey });
83
+ cachedApiKey = apiKey;
84
+ }
85
+ const client = cachedClient;
86
+ try {
87
+ const response = await client.messages.create({
88
+ model,
89
+ max_tokens: 500,
90
+ system: 'Extract 0-5 key facts from this session transcript worth remembering across sessions. Output ONLY a JSON array of strings. If nothing is worth remembering, output [].',
91
+ messages: [{ role: 'user', content: prompt }],
92
+ }, { timeout: 15000 });
93
+ const text = response.content.find((b) => b.type === 'text')?.text ?? '[]';
94
+ return parseFactsFromText(text);
95
+ }
96
+ catch (err) {
97
+ logger.warn('API-based memory extraction failed, falling back to subprocess:', err);
98
+ return [];
99
+ }
100
+ }
101
+ async function runExtractionViaSubprocess(config, prompt) {
102
+ return new Promise((resolve, reject) => {
103
+ let resolved = false;
104
+ const safeResolve = (val) => { if (!resolved) {
105
+ resolved = true;
106
+ resolve(val);
107
+ } };
108
+ const safeReject = (err) => { if (!resolved) {
109
+ resolved = true;
110
+ reject(err);
111
+ } };
112
+ const args = [
113
+ '-p',
114
+ '--output-format', 'stream-json',
115
+ '--verbose',
116
+ ...config.claudeCode.defaultFlags,
117
+ '--no-session-persistence',
118
+ prompt,
119
+ ];
120
+ const proc = spawn(config.claudeCode.bin, args, {
121
+ cwd: process.cwd(),
122
+ stdio: ['ignore', 'pipe', 'pipe'],
123
+ });
124
+ let output = '';
125
+ let stdoutBuffer = '';
126
+ proc.stdout.on('data', (chunk) => {
127
+ stdoutBuffer += chunk.toString();
128
+ const lines = stdoutBuffer.split('\n');
129
+ stdoutBuffer = lines.pop() || '';
130
+ for (const line of lines) {
131
+ if (!line.trim())
132
+ continue;
133
+ try {
134
+ const event = JSON.parse(line);
135
+ if (event.type === 'result' && event.result) {
136
+ output = event.result;
137
+ }
138
+ }
139
+ catch { /* skip non-JSON */ }
140
+ }
141
+ });
142
+ proc.on('exit', () => {
143
+ clearTimeout(timer);
144
+ try {
145
+ safeResolve(parseFactsFromText(output));
146
+ }
147
+ catch {
148
+ safeResolve([]);
149
+ }
150
+ });
151
+ proc.on('error', safeReject);
152
+ const timer = setTimeout(() => {
153
+ proc.kill('SIGTERM');
154
+ safeResolve([]);
155
+ }, 30000);
156
+ });
157
+ }