openclaw-scheduler 0.2.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.
Files changed (70) hide show
  1. package/AGENTS.md +302 -0
  2. package/BEST-PRACTICES.md +506 -0
  3. package/CHANGELOG.md +82 -0
  4. package/CODE_OF_CONDUCT.md +22 -0
  5. package/CONTEXT.md +26 -0
  6. package/CONTRIBUTING.md +73 -0
  7. package/IMPLEMENTATION_SPEC.md +170 -0
  8. package/INSTALL-ADDITIONAL-HOST.md +333 -0
  9. package/INSTALL-LINUX.md +419 -0
  10. package/INSTALL-WINDOWS.md +305 -0
  11. package/INSTALL.md +364 -0
  12. package/JOB-QUICK-REF.md +222 -0
  13. package/LICENSE +21 -0
  14. package/QUICK-START.md +256 -0
  15. package/README.md +2170 -0
  16. package/SECURITY.md +34 -0
  17. package/UNINSTALL.md +129 -0
  18. package/UPGRADING.md +436 -0
  19. package/agents.js +67 -0
  20. package/approval.js +107 -0
  21. package/backup.js +390 -0
  22. package/bin/openclaw-scheduler.js +138 -0
  23. package/cli.js +1083 -0
  24. package/db.js +122 -0
  25. package/dispatch/529-recovery.mjs +204 -0
  26. package/dispatch/README.md +372 -0
  27. package/dispatch/config.example.json +24 -0
  28. package/dispatch/deliver-watcher.sh +57 -0
  29. package/dispatch/hooks.mjs +171 -0
  30. package/dispatch/index.mjs +1836 -0
  31. package/dispatch/watcher.mjs +1396 -0
  32. package/dispatch-queue.js +112 -0
  33. package/dispatcher-approvals.js +96 -0
  34. package/dispatcher-delivery.js +43 -0
  35. package/dispatcher-maintenance.js +242 -0
  36. package/dispatcher-shell.js +29 -0
  37. package/dispatcher-strategies.js +1280 -0
  38. package/dispatcher-utils.js +81 -0
  39. package/dispatcher.js +855 -0
  40. package/docs/adr-schedule-ownership.md +73 -0
  41. package/docs/gateway-contract.md +904 -0
  42. package/docs/plans/2026-03-09-fix-typescript-types.md +91 -0
  43. package/docs/plans/2026-03-09-test-coverage-gaps.md +83 -0
  44. package/docs/plans/2026-03-10-dispatcher-refactor.md +801 -0
  45. package/docs/trust-architecture.md +266 -0
  46. package/gateway.js +473 -0
  47. package/idempotency.js +119 -0
  48. package/index.d.ts +864 -0
  49. package/index.js +17 -0
  50. package/jobs.js +1224 -0
  51. package/messages.js +357 -0
  52. package/migrate-consolidate.js +694 -0
  53. package/migrate.js +125 -0
  54. package/package.json +130 -0
  55. package/paths.js +79 -0
  56. package/prompt-context.js +94 -0
  57. package/retrieval.js +176 -0
  58. package/runs.js +270 -0
  59. package/scheduler-schema.js +101 -0
  60. package/schema.sql +480 -0
  61. package/scripts/dispatch-cli-utils.mjs +65 -0
  62. package/scripts/inbox-consumer.mjs +288 -0
  63. package/scripts/stuck-detector.sh +18 -0
  64. package/scripts/stuck-run-detector.mjs +333 -0
  65. package/scripts/telegram-webhook-check.mjs +238 -0
  66. package/setup.mjs +724 -0
  67. package/shell-result.js +214 -0
  68. package/task-tracker.js +300 -0
  69. package/team-adapter.js +335 -0
  70. package/v02-runtime.js +599 -0
package/migrate.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // Migrate existing OpenClaw cron jobs.json -> SQLite scheduler
3
+ import { readFileSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { initDb } from './db.js';
6
+ import { createJob, listJobs } from './jobs.js';
7
+
8
+ import { homedir } from 'os';
9
+
10
+ const JOBS_JSON = process.env.OPENCLAW_JOBS_JSON
11
+ || join(process.env.HOME || homedir(), '.openclaw/cron/jobs.json');
12
+
13
+ function cronFromSchedule(schedule) {
14
+ // OpenClaw supports: cron (expr), every (everyMs), at (one-shot ISO)
15
+ if (schedule.kind === 'cron') {
16
+ return { cron: schedule.expr, tz: schedule.tz || 'UTC' };
17
+ }
18
+ if (schedule.kind === 'every') {
19
+ // Convert interval to approximate cron. everyMs -> minutes
20
+ if (schedule.everyMs < 60000) {
21
+ console.warn(` WARN: ${schedule.everyMs}ms interval rounded up to 1 minute (cron minimum)`);
22
+ }
23
+ const mins = Math.max(1, Math.round(schedule.everyMs / 60000));
24
+ if (mins < 60) return { cron: `*/${mins} * * * *`, tz: schedule.tz || 'UTC' };
25
+ const hours = Math.max(1, Math.round(mins / 60));
26
+ const remaining = mins % 60;
27
+ const clampedHours = Math.min(hours, 23);
28
+ if (hours > 23) {
29
+ console.warn(` WARN: ${schedule.everyMs}ms interval exceeds 23 hours; clamping to */23 hours`);
30
+ }
31
+ if (remaining !== 0) {
32
+ console.warn(` WARN: ${schedule.everyMs}ms interval (${mins}min) cannot be represented exactly in standard cron; approximating to every ${clampedHours} hour(s)`);
33
+ }
34
+ return { cron: `0 */${clampedHours} * * *`, tz: schedule.tz || 'UTC' };
35
+ }
36
+ if (schedule.kind === 'at') {
37
+ // One-shot: compute the specific minute/hour/day cron that fires once
38
+ const d = new Date(schedule.at);
39
+ return {
40
+ cron: `${d.getUTCMinutes()} ${d.getUTCHours()} ${d.getUTCDate()} ${d.getUTCMonth() + 1} *`,
41
+ tz: 'UTC',
42
+ };
43
+ }
44
+ throw new Error(`Unknown schedule kind: ${schedule.kind}`);
45
+ }
46
+
47
+ function main() {
48
+ if (!existsSync(JOBS_JSON)) {
49
+ console.error(`No jobs.json found at: ${JOBS_JSON}`);
50
+ process.exit(1);
51
+ }
52
+
53
+ let data;
54
+ try {
55
+ data = JSON.parse(readFileSync(JOBS_JSON, 'utf8'));
56
+ } catch (err) {
57
+ console.error(`Failed to parse ${JOBS_JSON}: ${err.message}`);
58
+ process.exit(1);
59
+ }
60
+ const jobs = data.jobs || [];
61
+
62
+ console.log(`Found ${jobs.length} job(s) in ${JOBS_JSON}`);
63
+
64
+ initDb();
65
+
66
+ const existing = listJobs();
67
+ const existingIds = new Set(existing.map(j => j.id));
68
+
69
+ let imported = 0;
70
+ let skipped = 0;
71
+
72
+ for (const job of jobs) {
73
+ if (existingIds.has(job.id)) {
74
+ console.log(` SKIP: ${job.name} (already exists)`);
75
+ skipped++;
76
+ continue;
77
+ }
78
+
79
+ try {
80
+ const { cron, tz } = cronFromSchedule(job.schedule);
81
+
82
+ let deliveryMode = job.delivery?.mode || 'announce';
83
+ const deliveryTo = job.delivery?.to || null;
84
+ let deliveryOptOutReason = null;
85
+
86
+ if ((deliveryMode === 'announce' || deliveryMode === 'announce-always') && !deliveryTo) {
87
+ console.warn(` WARN: ${job.name}: delivery_mode='${deliveryMode}' but no delivery_to configured; downgrading to 'none'`);
88
+ deliveryMode = 'none';
89
+ deliveryOptOutReason = 'migrated: no delivery target configured';
90
+ }
91
+
92
+ createJob({
93
+ id: job.id,
94
+ name: job.name,
95
+ enabled: job.enabled !== false,
96
+ schedule_cron: cron,
97
+ schedule_tz: tz,
98
+ session_target: job.sessionTarget || 'isolated',
99
+ agent_id: job.agentId || 'main',
100
+ payload_kind: job.payload?.kind || 'agentTurn',
101
+ payload_message: job.payload?.message || job.payload?.text || '',
102
+ payload_model: job.payload?.model || null,
103
+ payload_thinking: job.payload?.thinking || null,
104
+ payload_timeout_seconds: job.payload?.timeoutSeconds || 120,
105
+ overlap_policy: 'skip',
106
+ run_timeout_ms: 300000,
107
+ delivery_mode: deliveryMode,
108
+ delivery_channel: job.delivery?.channel || null,
109
+ delivery_to: deliveryTo,
110
+ delivery_opt_out_reason: deliveryOptOutReason,
111
+ delete_after_run: job.schedule?.kind === 'at',
112
+ origin: job.origin || 'system',
113
+ });
114
+
115
+ console.log(` OK: ${job.name} -> cron="${cron}" tz=${tz}`);
116
+ imported++;
117
+ } catch (err) {
118
+ console.error(` ERR: ${job.name}: ${err.message}`);
119
+ }
120
+ }
121
+
122
+ console.log(`\nDone: ${imported} imported, ${skipped} skipped`);
123
+ }
124
+
125
+ main();
package/package.json ADDED
@@ -0,0 +1,130 @@
1
+ {
2
+ "name": "openclaw-scheduler",
3
+ "version": "0.2.0",
4
+ "description": "SQLite-backed job scheduler and workflow engine for OpenClaw agents",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "bin": {
8
+ "openclaw-scheduler": "bin/openclaw-scheduler.js",
9
+ "openclaw-inbox-consumer": "scripts/inbox-consumer.mjs"
10
+ },
11
+ "types": "./index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./index.d.ts",
15
+ "default": "./index.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "start": "node dispatcher.js",
24
+ "migrate": "node migrate.js",
25
+ "test": "SCHEDULER_DB=:memory: node test.js",
26
+ "lint": "eslint . --max-warnings=0",
27
+ "coverage": "SCHEDULER_DB=:memory: node scripts/coverage.mjs",
28
+ "typecheck": "tsc --project tsconfig.json",
29
+ "verify:smoke": "node scripts/verify-local.mjs --smoke",
30
+ "verify:local": "node scripts/verify-local.mjs",
31
+ "prepublishOnly": "npm run verify:local"
32
+ },
33
+ "overrides": {
34
+ "brace-expansion": "^5.0.5",
35
+ "flatted": "^3.4.2"
36
+ },
37
+ "files": [
38
+ "bin",
39
+ "dispatch/529-recovery.mjs",
40
+ "dispatch/config.example.json",
41
+ "dispatch/deliver-watcher.sh",
42
+ "dispatch/hooks.mjs",
43
+ "dispatch/index.mjs",
44
+ "dispatch/README.md",
45
+ "dispatch/watcher.mjs",
46
+ "scripts/dispatch-cli-utils.mjs",
47
+ "scripts/inbox-consumer.mjs",
48
+ "scripts/stuck-run-detector.mjs",
49
+ "scripts/stuck-detector.sh",
50
+ "scripts/telegram-webhook-check.mjs",
51
+ "agents.js",
52
+ "approval.js",
53
+ "backup.js",
54
+ "cli.js",
55
+ "db.js",
56
+ "dispatch-queue.js",
57
+ "dispatcher-approvals.js",
58
+ "dispatcher-delivery.js",
59
+ "dispatcher-maintenance.js",
60
+ "dispatcher-shell.js",
61
+ "dispatcher-strategies.js",
62
+ "dispatcher-utils.js",
63
+ "dispatcher.js",
64
+ "gateway.js",
65
+ "idempotency.js",
66
+ "index.d.ts",
67
+ "index.js",
68
+ "jobs.js",
69
+ "messages.js",
70
+ "migrate.js",
71
+ "migrate-consolidate.js",
72
+ "paths.js",
73
+ "prompt-context.js",
74
+ "retrieval.js",
75
+ "runs.js",
76
+ "scheduler-schema.js",
77
+ "schema.sql",
78
+ "setup.mjs",
79
+ "shell-result.js",
80
+ "task-tracker.js",
81
+ "team-adapter.js",
82
+ "v02-runtime.js",
83
+ "README.md",
84
+ "LICENSE",
85
+ "CHANGELOG.md",
86
+ "CONTRIBUTING.md",
87
+ "SECURITY.md",
88
+ "CODE_OF_CONDUCT.md",
89
+ "INSTALL*.md",
90
+ "UNINSTALL.md",
91
+ "UPGRADING.md",
92
+ "BEST-PRACTICES.md",
93
+ "QUICK-START.md",
94
+ "AGENTS.md",
95
+ "CONTEXT.md",
96
+ "JOB-QUICK-REF.md",
97
+ "IMPLEMENTATION_SPEC.md",
98
+ "docs"
99
+ ],
100
+ "keywords": [
101
+ "openclaw",
102
+ "scheduler",
103
+ "cron",
104
+ "workflow",
105
+ "sqlite",
106
+ "agent",
107
+ "automation"
108
+ ],
109
+ "author": "Alex Mittell",
110
+ "license": "MIT",
111
+ "repository": {
112
+ "type": "git",
113
+ "url": "git+https://github.com/amittell/openclaw-scheduler.git"
114
+ },
115
+ "bugs": {
116
+ "url": "https://github.com/amittell/openclaw-scheduler/issues"
117
+ },
118
+ "homepage": "https://github.com/amittell/openclaw-scheduler#readme",
119
+ "dependencies": {
120
+ "better-sqlite3": "^11.10.0",
121
+ "croner": "^10.0.1"
122
+ },
123
+ "devDependencies": {
124
+ "@eslint/js": "^10.0.1",
125
+ "c8": "^11.0.0",
126
+ "eslint": "^10.1.0",
127
+ "globals": "^17.4.0",
128
+ "typescript": "^5.9.2"
129
+ }
130
+ }
package/paths.js ADDED
@@ -0,0 +1,79 @@
1
+ import { accessSync, constants, existsSync, mkdirSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ function firstNonEmpty(value) {
9
+ if (typeof value !== 'string') return '';
10
+ const trimmed = value.trim();
11
+ return trimmed.length > 0 ? trimmed : '';
12
+ }
13
+
14
+ function ensureWritableDir(dirPath) {
15
+ try {
16
+ mkdirSync(dirPath, { recursive: true });
17
+ accessSync(dirPath, constants.W_OK);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ function isNodeModulesInstall(moduleDir) {
25
+ return /[\\/]node_modules[\\/](?:@[^\\/]+[\\/])?openclaw-scheduler(?:[\\/]|$)/.test(moduleDir);
26
+ }
27
+
28
+ export function resolveSchedulerHome(env = process.env) {
29
+ const explicitHome = firstNonEmpty(env.SCHEDULER_HOME);
30
+ if (explicitHome) return explicitHome;
31
+ const home = firstNonEmpty(env.HOME) || homedir();
32
+ return join(home, '.openclaw', 'scheduler');
33
+ }
34
+
35
+ export function resolveSchedulerDbPath(params = {}) {
36
+ const env = params.env || process.env;
37
+ const explicitPath = firstNonEmpty(params.explicitPath);
38
+ if (explicitPath) return explicitPath;
39
+
40
+ const envDbPath = firstNonEmpty(env.SCHEDULER_DB);
41
+ if (envDbPath) return envDbPath;
42
+
43
+ const moduleDir = firstNonEmpty(params.moduleDir) || __dirname;
44
+ const moduleDbPath = join(moduleDir, 'scheduler.db');
45
+ const moduleDirWritable = !isNodeModulesInstall(moduleDir) && ensureWritableDir(moduleDir);
46
+ if (!isNodeModulesInstall(moduleDir) && (existsSync(moduleDbPath) || moduleDirWritable)) {
47
+ return moduleDbPath;
48
+ }
49
+
50
+ return join(resolveSchedulerHome(env), 'scheduler.db');
51
+ }
52
+
53
+ export function ensureSchedulerDbParent(dbPath) {
54
+ const parent = dirname(dbPath);
55
+ mkdirSync(parent, { recursive: true });
56
+ return parent;
57
+ }
58
+
59
+ export function resolveBackupStagingDir(env = process.env) {
60
+ const explicit = firstNonEmpty(env.SCHEDULER_BACKUP_STAGING_DIR);
61
+ if (explicit) return explicit;
62
+ return join(resolveSchedulerHome(env), '.backup-staging');
63
+ }
64
+
65
+ export function resolveArtifactsDir(params = {}) {
66
+ const env = params.env || process.env;
67
+ const explicit = firstNonEmpty(params.explicitPath) || firstNonEmpty(env.SCHEDULER_ARTIFACTS_DIR);
68
+ if (explicit) return explicit;
69
+ const dbPath = firstNonEmpty(params.dbPath);
70
+ if (dbPath && dbPath !== ':memory:') {
71
+ return join(dirname(dbPath), 'artifacts');
72
+ }
73
+ return join(resolveSchedulerHome(env), 'artifacts');
74
+ }
75
+
76
+ export function ensureArtifactsDir(dirPath) {
77
+ mkdirSync(dirPath, { recursive: true });
78
+ return dirPath;
79
+ }
@@ -0,0 +1,94 @@
1
+ import { getRun } from './runs.js';
2
+ import { getJob } from './jobs.js';
3
+ import { extractShellResultFromRun } from './shell-result.js';
4
+
5
+ function truncate(text, limit) {
6
+ if (!text || text.length <= limit) return text;
7
+ return `${text.slice(0, Math.max(0, limit - 16))}\n...[truncated]`;
8
+ }
9
+
10
+ export function buildTriggeredRunContext(run, deps = {}) {
11
+ if (!run?.triggered_by_run) {
12
+ return { text: '', meta: {} };
13
+ }
14
+
15
+ const getRunById = deps.getRunById || getRun;
16
+ const getJobById = deps.getJobById || getJob;
17
+ const parentRun = getRunById(run.triggered_by_run);
18
+
19
+ if (!parentRun) {
20
+ return {
21
+ text: '',
22
+ meta: {
23
+ triggered_by_run: run.triggered_by_run,
24
+ parent_run_missing: true
25
+ }
26
+ };
27
+ }
28
+
29
+ const parentJob = getJobById(parentRun.job_id);
30
+ const lines = [
31
+ '',
32
+ '--- Trigger Context ---',
33
+ `Triggered by: ${parentJob?.name || parentRun.job_id}`,
34
+ `Parent run status: ${parentRun.status}`
35
+ ];
36
+
37
+ const shellResult = extractShellResultFromRun(parentRun);
38
+ if (shellResult) {
39
+ lines.push('Parent shell result:');
40
+ if (shellResult.timedOut) {
41
+ lines.push('Timed out: true');
42
+ }
43
+ if (typeof shellResult.exitCode === 'number') {
44
+ lines.push(`Exit code: ${shellResult.exitCode}`);
45
+ }
46
+ if (shellResult.signal) {
47
+ lines.push(`Signal: ${shellResult.signal}`);
48
+ }
49
+ if (shellResult.errorMessage) {
50
+ lines.push(`Error: ${shellResult.errorMessage}`);
51
+ }
52
+ if (shellResult.stdout?.trim()) {
53
+ lines.push('stdout:');
54
+ lines.push(truncate(shellResult.stdout, 3000));
55
+ }
56
+ if (shellResult.stdoutPath) {
57
+ lines.push(`stdout file: ${shellResult.stdoutPath} (${shellResult.stdoutBytes || 0} bytes)`);
58
+ }
59
+ if (shellResult.stderr?.trim()) {
60
+ lines.push('stderr:');
61
+ lines.push(truncate(shellResult.stderr, 3000));
62
+ }
63
+ if (shellResult.stderrPath) {
64
+ lines.push(`stderr file: ${shellResult.stderrPath} (${shellResult.stderrBytes || 0} bytes)`);
65
+ }
66
+ } else if (parentRun.summary?.trim()) {
67
+ lines.push('Parent run output:');
68
+ lines.push(parentRun.summary.slice(0, 5000));
69
+ } else if (parentRun.error_message?.trim()) {
70
+ lines.push('Parent run error:');
71
+ lines.push(parentRun.error_message.slice(0, 2000));
72
+ }
73
+
74
+ lines.push('---');
75
+
76
+ return {
77
+ text: lines.join('\n'),
78
+ meta: {
79
+ triggered_by_run: parentRun.id,
80
+ parent_job_id: parentRun.job_id,
81
+ parent_job_name: parentJob?.name || null,
82
+ parent_run_status: parentRun.status,
83
+ ...(shellResult
84
+ ? {
85
+ parent_shell_exit_code: shellResult.exitCode,
86
+ parent_shell_signal: shellResult.signal,
87
+ parent_shell_timed_out: shellResult.timedOut,
88
+ parent_shell_stdout_path: shellResult.stdoutPath || null,
89
+ parent_shell_stderr_path: shellResult.stderrPath || null,
90
+ }
91
+ : {})
92
+ }
93
+ };
94
+ }
package/retrieval.js ADDED
@@ -0,0 +1,176 @@
1
+ // Hybrid retrieval for injecting prior-run context into job prompts
2
+ import { getDb } from './db.js';
3
+
4
+ // ---- TF-IDF helpers ----
5
+
6
+ const STOPWORDS = new Set([
7
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
8
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'shall',
9
+ 'should', 'may', 'might', 'must', 'can', 'could', 'to', 'of', 'in',
10
+ 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through',
11
+ 'during', 'before', 'after', 'above', 'below', 'between', 'and',
12
+ 'but', 'or', 'nor', 'not', 'so', 'yet', 'both', 'either', 'neither',
13
+ 'each', 'every', 'all', 'any', 'few', 'more', 'most', 'other', 'some',
14
+ 'such', 'no', 'only', 'own', 'same', 'than', 'too', 'very', 'just',
15
+ 'it', 'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
16
+ 'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her',
17
+ 'they', 'them', 'their', 'what', 'which', 'who', 'whom',
18
+ ]);
19
+
20
+ function tokenize(text) {
21
+ return text
22
+ .toLowerCase()
23
+ .split(/\s+/)
24
+ .map(t => t.replace(/[^a-z0-9]/g, ''))
25
+ .filter(t => t.length > 1 && !STOPWORDS.has(t));
26
+ }
27
+
28
+ function computeTF(tokens) {
29
+ const freq = {};
30
+ for (const t of tokens) {
31
+ freq[t] = (freq[t] || 0) + 1;
32
+ }
33
+ const len = tokens.length || 1;
34
+ const tf = {};
35
+ for (const [term, count] of Object.entries(freq)) {
36
+ tf[term] = count / len;
37
+ }
38
+ return tf;
39
+ }
40
+
41
+ function computeIDF(docs) {
42
+ // docs: array of TF maps
43
+ const N = docs.length || 1;
44
+ const df = {}; // document frequency per term
45
+ for (const tf of docs) {
46
+ for (const term of Object.keys(tf)) {
47
+ df[term] = (df[term] || 0) + 1;
48
+ }
49
+ }
50
+ const idf = {};
51
+ for (const [term, count] of Object.entries(df)) {
52
+ idf[term] = Math.log(N / count) + 1; // smoothed IDF
53
+ }
54
+ return idf;
55
+ }
56
+
57
+ function tfidfScore(queryTokens, docTF, idf) {
58
+ let score = 0;
59
+ for (const qt of queryTokens) {
60
+ if (docTF[qt]) {
61
+ score += docTF[qt] * (idf[qt] || 1);
62
+ }
63
+ }
64
+ return score;
65
+ }
66
+
67
+ /**
68
+ * Extract readable text from a context_summary field.
69
+ * The field may be a JSON string (object with string values) or plain text.
70
+ */
71
+ function extractSummaryText(raw) {
72
+ if (!raw) return '';
73
+ try {
74
+ const parsed = JSON.parse(raw);
75
+ if (parsed && typeof parsed === 'object') {
76
+ return Object.values(parsed)
77
+ .filter(v => typeof v === 'string')
78
+ .join(' ');
79
+ }
80
+ return String(parsed);
81
+ } catch {
82
+ return raw;
83
+ }
84
+ }
85
+
86
+ // ---- Exports ----
87
+
88
+ /**
89
+ * Get the last N run summaries (non-null context_summary) for a job.
90
+ */
91
+ export function getRecentRunSummaries(jobId, limit = 5) {
92
+ return getDb().prepare(`
93
+ SELECT id, job_id, started_at, finished_at, status, context_summary, summary
94
+ FROM runs
95
+ WHERE job_id = ? AND context_summary IS NOT NULL
96
+ ORDER BY started_at DESC
97
+ LIMIT ?
98
+ `).all(jobId, limit);
99
+ }
100
+
101
+ /**
102
+ * Search run summaries using hybrid substring + TF-IDF scoring.
103
+ * Returns top N matches from the last 50 runs with summaries.
104
+ */
105
+ export function searchRunSummaries(jobId, query, limit = 5) {
106
+ // Fetch candidate pool
107
+ const candidates = getDb().prepare(`
108
+ SELECT id, job_id, started_at, finished_at, status, context_summary, summary
109
+ FROM runs
110
+ WHERE job_id = ? AND context_summary IS NOT NULL
111
+ ORDER BY started_at DESC
112
+ LIMIT 50
113
+ `).all(jobId);
114
+
115
+ if (!candidates.length || !query) return candidates.slice(0, limit);
116
+
117
+ const queryTokens = tokenize(query);
118
+ const queryLower = query.toLowerCase();
119
+
120
+ // Build TF for each doc
121
+ const docTFs = candidates.map(c => {
122
+ const text = [extractSummaryText(c.context_summary), c.summary || ''].join(' ');
123
+ return computeTF(tokenize(text));
124
+ });
125
+
126
+ const idf = computeIDF(docTFs);
127
+
128
+ // Score each candidate
129
+ const scored = candidates.map((c, i) => {
130
+ const text = [extractSummaryText(c.context_summary), c.summary || ''].join(' ').toLowerCase();
131
+ // Substring bonus
132
+ const substringBonus = text.includes(queryLower) ? 1.0 : 0;
133
+ // TF-IDF score
134
+ const tfidf = tfidfScore(queryTokens, docTFs[i], idf);
135
+ return { ...c, _score: tfidf + substringBonus };
136
+ });
137
+
138
+ scored.sort((a, b) => b._score - a._score);
139
+ return scored.slice(0, limit).filter(s => s._score > 0);
140
+ }
141
+
142
+ /**
143
+ * Build retrieval context string to inject into a job prompt.
144
+ * Returns empty string if retrieval is disabled or no results found.
145
+ */
146
+ export function buildRetrievalContext(job) {
147
+ if (!job.context_retrieval || job.context_retrieval === 'none') return '';
148
+
149
+ const limit = job.context_retrieval_limit || 5;
150
+ let runs;
151
+
152
+ if (job.context_retrieval === 'recent') {
153
+ runs = getRecentRunSummaries(job.id, limit);
154
+ } else if (job.context_retrieval === 'hybrid') {
155
+ // Use the job's payload_message as the search query
156
+ runs = searchRunSummaries(job.id, job.payload_message, limit);
157
+ // Fallback to recent if search returns nothing
158
+ if (!runs.length) {
159
+ runs = getRecentRunSummaries(job.id, limit);
160
+ }
161
+ } else {
162
+ return '';
163
+ }
164
+
165
+ if (!runs.length) return '';
166
+
167
+ const lines = ['--- Prior Run Context ---'];
168
+ for (const run of runs) {
169
+ const date = run.started_at || 'unknown';
170
+ const summaryText = extractSummaryText(run.context_summary) || run.summary || '';
171
+ lines.push(`[${date}] ${summaryText}`);
172
+ }
173
+ lines.push('--- End Prior Run Context ---');
174
+
175
+ return lines.join('\n');
176
+ }