@axplusb/kepler 0.0.1 → 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.
Files changed (218) hide show
  1. package/README.md +82 -0
  2. package/package.json +36 -4
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/costs/route.ts +88 -0
  6. package/pulse/app/api/export/route.ts +77 -0
  7. package/pulse/app/api/history/route.ts +11 -0
  8. package/pulse/app/api/import/route.ts +31 -0
  9. package/pulse/app/api/memory/route.ts +52 -0
  10. package/pulse/app/api/plans/route.ts +9 -0
  11. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  12. package/pulse/app/api/projects/route.ts +121 -0
  13. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  14. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  15. package/pulse/app/api/sessions/route.ts +112 -0
  16. package/pulse/app/api/settings/route.ts +14 -0
  17. package/pulse/app/api/stats/route.ts +143 -0
  18. package/pulse/app/api/todos/route.ts +9 -0
  19. package/pulse/app/api/tools/route.ts +160 -0
  20. package/pulse/app/costs/page.tsx +179 -0
  21. package/pulse/app/export/page.tsx +465 -0
  22. package/pulse/app/favicon.ico +0 -0
  23. package/pulse/app/globals.css +263 -0
  24. package/pulse/app/help/page.tsx +142 -0
  25. package/pulse/app/history/page.tsx +157 -0
  26. package/pulse/app/layout.tsx +46 -0
  27. package/pulse/app/memory/page.tsx +365 -0
  28. package/pulse/app/overview-client.tsx +393 -0
  29. package/pulse/app/page.tsx +14 -0
  30. package/pulse/app/plans/page.tsx +308 -0
  31. package/pulse/app/projects/[slug]/page.tsx +390 -0
  32. package/pulse/app/projects/page.tsx +110 -0
  33. package/pulse/app/sessions/[id]/page.tsx +243 -0
  34. package/pulse/app/sessions/page.tsx +39 -0
  35. package/pulse/app/settings/page.tsx +188 -0
  36. package/pulse/app/todos/page.tsx +211 -0
  37. package/pulse/app/tools/page.tsx +249 -0
  38. package/pulse/cli.js +159 -0
  39. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  40. package/pulse/components/activity/streak-card.tsx +36 -0
  41. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  42. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  43. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  44. package/pulse/components/costs/model-token-table.tsx +60 -0
  45. package/pulse/components/global-search.tsx +193 -0
  46. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  47. package/pulse/components/layout/bottom-nav.tsx +52 -0
  48. package/pulse/components/layout/client-layout.tsx +31 -0
  49. package/pulse/components/layout/sidebar-context.tsx +50 -0
  50. package/pulse/components/layout/sidebar.tsx +182 -0
  51. package/pulse/components/layout/top-bar.tsx +121 -0
  52. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  53. package/pulse/components/overview/conversation-table.tsx +148 -0
  54. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  55. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  56. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  57. package/pulse/components/overview/stat-card.tsx +102 -0
  58. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  59. package/pulse/components/projects/project-card.tsx +175 -0
  60. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  61. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  62. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  63. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  64. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  65. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  66. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  67. package/pulse/components/sessions/session-badges.tsx +49 -0
  68. package/pulse/components/sessions/session-table.tsx +299 -0
  69. package/pulse/components/theme-provider.tsx +44 -0
  70. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  71. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  72. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  73. package/pulse/components/tools/version-history-table.tsx +32 -0
  74. package/pulse/components/ui/alert.tsx +66 -0
  75. package/pulse/components/ui/badge.tsx +48 -0
  76. package/pulse/components/ui/breadcrumb.tsx +109 -0
  77. package/pulse/components/ui/button.tsx +64 -0
  78. package/pulse/components/ui/calendar.tsx +220 -0
  79. package/pulse/components/ui/card.tsx +92 -0
  80. package/pulse/components/ui/command.tsx +158 -0
  81. package/pulse/components/ui/dialog.tsx +158 -0
  82. package/pulse/components/ui/input.tsx +21 -0
  83. package/pulse/components/ui/popover.tsx +89 -0
  84. package/pulse/components/ui/progress.tsx +31 -0
  85. package/pulse/components/ui/select.tsx +190 -0
  86. package/pulse/components/ui/separator.tsx +28 -0
  87. package/pulse/components/ui/sheet.tsx +143 -0
  88. package/pulse/components/ui/skeleton.tsx +13 -0
  89. package/pulse/components/ui/table.tsx +116 -0
  90. package/pulse/components/ui/tabs.tsx +91 -0
  91. package/pulse/components/ui/tooltip.tsx +57 -0
  92. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  93. package/pulse/components.json +23 -0
  94. package/pulse/eslint.config.mjs +18 -0
  95. package/pulse/lib/claude-reader.ts +594 -0
  96. package/pulse/lib/decode.ts +129 -0
  97. package/pulse/lib/pricing.ts +102 -0
  98. package/pulse/lib/replay-parser.ts +165 -0
  99. package/pulse/lib/tool-categories.ts +127 -0
  100. package/pulse/lib/utils.ts +6 -0
  101. package/pulse/next-env.d.ts +6 -0
  102. package/pulse/next.config.ts +16 -0
  103. package/pulse/package.json +45 -0
  104. package/pulse/postcss.config.mjs +7 -0
  105. package/pulse/public/activity.png +0 -0
  106. package/pulse/public/cc-lens.png +0 -0
  107. package/pulse/public/command-k.png +0 -0
  108. package/pulse/public/costs.png +0 -0
  109. package/pulse/public/dashboard-dark.png +0 -0
  110. package/pulse/public/dashboard-white.png +0 -0
  111. package/pulse/public/export.png +0 -0
  112. package/pulse/public/file.svg +1 -0
  113. package/pulse/public/globe.svg +1 -0
  114. package/pulse/public/next.svg +1 -0
  115. package/pulse/public/projects.png +0 -0
  116. package/pulse/public/session-chat.png +0 -0
  117. package/pulse/public/todos.png +0 -0
  118. package/pulse/public/tools.png +0 -0
  119. package/pulse/public/vercel.svg +1 -0
  120. package/pulse/public/window.svg +1 -0
  121. package/pulse/tsconfig.json +34 -0
  122. package/pulse/types/claude.ts +294 -0
  123. package/src/agents/loader.mjs +89 -0
  124. package/src/agents/parser.mjs +98 -0
  125. package/src/agents/teams.mjs +123 -0
  126. package/src/auth/oauth.mjs +220 -0
  127. package/src/auth/tarang-auth.mjs +277 -0
  128. package/src/config/cli-args.mjs +173 -0
  129. package/src/config/env.mjs +263 -0
  130. package/src/config/settings.mjs +132 -0
  131. package/src/context/ast-parser.mjs +298 -0
  132. package/src/context/bm25.mjs +85 -0
  133. package/src/context/retriever.mjs +270 -0
  134. package/src/context/skeleton.mjs +134 -0
  135. package/src/core/agent-loop.mjs +480 -0
  136. package/src/core/approval.mjs +273 -0
  137. package/src/core/backend-url.mjs +57 -0
  138. package/src/core/cache.mjs +105 -0
  139. package/src/core/callback-client.mjs +149 -0
  140. package/src/core/checkpoints.mjs +142 -0
  141. package/src/core/context-manager.mjs +198 -0
  142. package/src/core/headless.mjs +168 -0
  143. package/src/core/hooks-manager.mjs +87 -0
  144. package/src/core/jsonl-writer.mjs +351 -0
  145. package/src/core/local-agent.mjs +429 -0
  146. package/src/core/local-store.mjs +325 -0
  147. package/src/core/mode-selector.mjs +51 -0
  148. package/src/core/output-filter.mjs +177 -0
  149. package/src/core/paths.mjs +98 -0
  150. package/src/core/pricing.mjs +314 -0
  151. package/src/core/providers.mjs +219 -0
  152. package/src/core/rate-limiter.mjs +119 -0
  153. package/src/core/safety.mjs +200 -0
  154. package/src/core/scheduler.mjs +173 -0
  155. package/src/core/session-manager.mjs +317 -0
  156. package/src/core/session.mjs +143 -0
  157. package/src/core/settings-sync.mjs +85 -0
  158. package/src/core/stagnation.mjs +57 -0
  159. package/src/core/stream-client.mjs +367 -0
  160. package/src/core/streaming.mjs +182 -0
  161. package/src/core/system-prompt.mjs +135 -0
  162. package/src/core/tool-executor.mjs +725 -0
  163. package/src/hooks/engine.mjs +162 -0
  164. package/src/index.mjs +370 -0
  165. package/src/mcp/client.mjs +253 -0
  166. package/src/mcp/transport-shttp.mjs +130 -0
  167. package/src/mcp/transport-sse.mjs +131 -0
  168. package/src/mcp/transport-ws.mjs +134 -0
  169. package/src/permissions/checker.mjs +57 -0
  170. package/src/permissions/command-classifier.mjs +573 -0
  171. package/src/permissions/injection-check.mjs +60 -0
  172. package/src/permissions/path-check.mjs +102 -0
  173. package/src/permissions/prompt.mjs +73 -0
  174. package/src/permissions/sandbox.mjs +112 -0
  175. package/src/plugins/loader.mjs +138 -0
  176. package/src/skills/loader.mjs +147 -0
  177. package/src/skills/runner.mjs +55 -0
  178. package/src/telemetry/index.mjs +96 -0
  179. package/src/terminal/agents.mjs +177 -0
  180. package/src/terminal/analytics.mjs +292 -0
  181. package/src/terminal/ansi.mjs +421 -0
  182. package/src/terminal/main.mjs +150 -0
  183. package/src/terminal/repl.mjs +1484 -0
  184. package/src/terminal/tool-display.mjs +58 -0
  185. package/src/tools/agent.mjs +137 -0
  186. package/src/tools/ask-user.mjs +61 -0
  187. package/src/tools/bash.mjs +148 -0
  188. package/src/tools/cron-create.mjs +120 -0
  189. package/src/tools/cron-delete.mjs +49 -0
  190. package/src/tools/cron-list.mjs +37 -0
  191. package/src/tools/edit.mjs +82 -0
  192. package/src/tools/enter-worktree.mjs +69 -0
  193. package/src/tools/exit-worktree.mjs +57 -0
  194. package/src/tools/glob.mjs +117 -0
  195. package/src/tools/grep.mjs +129 -0
  196. package/src/tools/lint.mjs +71 -0
  197. package/src/tools/ls.mjs +58 -0
  198. package/src/tools/lsp.mjs +115 -0
  199. package/src/tools/multi-edit.mjs +94 -0
  200. package/src/tools/notebook-edit.mjs +96 -0
  201. package/src/tools/read-mcp-resource.mjs +57 -0
  202. package/src/tools/read.mjs +138 -0
  203. package/src/tools/registry.mjs +132 -0
  204. package/src/tools/remote-trigger.mjs +84 -0
  205. package/src/tools/send-message.mjs +64 -0
  206. package/src/tools/skill.mjs +52 -0
  207. package/src/tools/test-runner.mjs +49 -0
  208. package/src/tools/todo-write.mjs +68 -0
  209. package/src/tools/tool-search.mjs +77 -0
  210. package/src/tools/web-fetch.mjs +65 -0
  211. package/src/tools/web-search.mjs +89 -0
  212. package/src/tools/write.mjs +55 -0
  213. package/src/ui/banner.mjs +237 -0
  214. package/src/ui/commands.mjs +499 -0
  215. package/src/ui/formatter.mjs +379 -0
  216. package/src/ui/markdown.mjs +278 -0
  217. package/src/ui/slash-commands.mjs +258 -0
  218. package/index.js +0 -1
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Safety Guardrails — prevent destructive tool execution.
3
+ *
4
+ * Protects against:
5
+ * - Deleting/overwriting source code directories
6
+ * - rm -rf on critical paths
7
+ * - Writing outside allowed boundaries
8
+ * - Dangerous shell commands (fork bombs, disk wipes, etc.)
9
+ */
10
+
11
+ import * as path from 'node:path';
12
+ import * as fs from 'node:fs';
13
+
14
+ // ── Protected Paths ──────────────────────────────────────────
15
+
16
+ /** Files/dirs that should NEVER be deleted or overwritten wholesale. */
17
+ const PROTECTED_NAMES = new Set([
18
+ '.git',
19
+ '.env',
20
+ '.env.local',
21
+ '.env.production',
22
+ 'node_modules',
23
+ 'package.json',
24
+ 'package-lock.json',
25
+ 'yarn.lock',
26
+ 'pnpm-lock.yaml',
27
+ 'Cargo.lock',
28
+ 'go.sum',
29
+ ]);
30
+
31
+ /** Directory names that indicate a source root — never delete the dir itself. */
32
+ const SOURCE_DIRS = new Set([
33
+ 'src',
34
+ 'lib',
35
+ 'app',
36
+ 'pages',
37
+ 'components',
38
+ 'packages',
39
+ 'dist',
40
+ 'build',
41
+ 'test',
42
+ 'tests',
43
+ '__tests__',
44
+ 'spec',
45
+ ]);
46
+
47
+ /** Shell patterns that are always blocked. */
48
+ const DANGEROUS_SHELL_PATTERNS = [
49
+ /rm\s+(-\w*[rR]\w*\s+)*(\/|~|\$HOME|\.\.|\.\/\.\.)/, // rm -rf / or ~ or ..
50
+ /rm\s+(-\w*[rR]\w*\s+)*\.\s*$/, // rm -rf .
51
+ /rm\s+(-\w*[rR]\w*\s+)*\*\s*$/, // rm -rf *
52
+ /:\(\)\s*\{\s*:\|\s*:\s*&\s*\}\s*;/, // fork bomb
53
+ /mkfs\./, // format filesystem
54
+ /dd\s+.*of=\/dev\//, // disk wipe
55
+ />\s*\/dev\/sd/, // overwrite disk
56
+ /chmod\s+(-\w+\s+)*777\s+\//, // chmod 777 /
57
+ /curl.*\|\s*(ba)?sh/, // pipe curl to shell
58
+ /wget.*\|\s*(ba)?sh/, // pipe wget to shell
59
+ /eval\s*\$\(/, // eval command substitution
60
+ /\bfind\s+\/\s/, // find / (scans entire filesystem)
61
+ /\bfind\s+\/\s*$/, // find / at end
62
+ /\bls\s+(-\w+\s+)*\/\s*$/, // ls / (root listing)
63
+ ];
64
+
65
+ /** Commands that need extra scrutiny — require approval even if auto-approved. */
66
+ const HIGH_RISK_COMMANDS = [
67
+ /\brm\s/, // ALL rm commands require explicit approval
68
+ /\bunlink\s/, // unlink
69
+ /\brmdir\s/, // rmdir
70
+ /git\s+push\s+--force/,
71
+ /git\s+reset\s+--hard/,
72
+ /git\s+clean\s+-[fd]/,
73
+ /drop\s+table/i,
74
+ /drop\s+database/i,
75
+ /truncate\s+table/i,
76
+ ];
77
+
78
+ // ── Validation Functions ─────────────────────────────────────
79
+
80
+ /**
81
+ * Check if a path is protected (should not be deleted/overwritten entirely).
82
+ * @param {string} filePath - Absolute path
83
+ * @param {string} cwd - Current working directory
84
+ * @returns {{ safe: boolean, reason?: string }}
85
+ */
86
+ export function validatePath(filePath, cwd = process.cwd()) {
87
+ if (!filePath) return { safe: false, reason: 'Empty path' };
88
+
89
+ const resolved = path.resolve(cwd, filePath);
90
+ const basename = path.basename(resolved);
91
+ const relative = path.relative(cwd, resolved);
92
+
93
+ // Block anything outside CWD parent
94
+ const cwdParent = path.dirname(cwd);
95
+ if (!resolved.startsWith(cwdParent)) {
96
+ return { safe: false, reason: `Path outside workspace: ${filePath}` };
97
+ }
98
+
99
+ // Block protected file names at any level
100
+ if (PROTECTED_NAMES.has(basename) && !resolved.includes('node_modules/')) {
101
+ return { safe: false, reason: `Protected path: ${basename}` };
102
+ }
103
+
104
+ return { safe: true };
105
+ }
106
+
107
+ /**
108
+ * Check if a delete operation is safe.
109
+ * Stricter than validatePath — also blocks source directories.
110
+ * @param {string} filePath - Path to delete
111
+ * @param {string} cwd - Current working directory
112
+ * @returns {{ safe: boolean, reason?: string }}
113
+ */
114
+ export function validateDelete(filePath, cwd = process.cwd()) {
115
+ const pathCheck = validatePath(filePath, cwd);
116
+ if (!pathCheck.safe) return pathCheck;
117
+
118
+ const resolved = path.resolve(cwd, filePath);
119
+ const basename = path.basename(resolved);
120
+
121
+ // Never delete source root directories
122
+ if (SOURCE_DIRS.has(basename)) {
123
+ try {
124
+ const stat = fs.statSync(resolved);
125
+ if (stat.isDirectory()) {
126
+ return { safe: false, reason: `Cannot delete source directory: ${basename}/` };
127
+ }
128
+ } catch { /* file doesn't exist, safe to proceed */ }
129
+ }
130
+
131
+ // Never delete CWD itself
132
+ if (resolved === cwd || resolved === path.dirname(cwd)) {
133
+ return { safe: false, reason: 'Cannot delete working directory' };
134
+ }
135
+
136
+ return { safe: true };
137
+ }
138
+
139
+ /**
140
+ * Check if a shell command is safe to execute.
141
+ * @param {string} command
142
+ * @returns {{ safe: boolean, reason?: string, highRisk?: boolean }}
143
+ */
144
+ export function validateShellCommand(command) {
145
+ if (!command) return { safe: false, reason: 'Empty command' };
146
+
147
+ // Always block dangerous patterns
148
+ for (const pattern of DANGEROUS_SHELL_PATTERNS) {
149
+ if (pattern.test(command)) {
150
+ return { safe: false, reason: `Blocked dangerous command: ${command.slice(0, 60)}` };
151
+ }
152
+ }
153
+
154
+ // Flag high-risk commands (still allowed, but should force approval)
155
+ for (const pattern of HIGH_RISK_COMMANDS) {
156
+ if (pattern.test(command)) {
157
+ return { safe: true, highRisk: true, reason: `High-risk command: ${command.slice(0, 60)}` };
158
+ }
159
+ }
160
+
161
+ return { safe: true };
162
+ }
163
+
164
+ /**
165
+ * Check if a write operation targets a sensible path.
166
+ * @param {string} filePath
167
+ * @param {string} content
168
+ * @param {string} cwd
169
+ * @returns {{ safe: boolean, reason?: string }}
170
+ */
171
+ export function validateWrite(filePath, content, cwd = process.cwd()) {
172
+ const pathCheck = validatePath(filePath, cwd);
173
+ if (!pathCheck.safe) return pathCheck;
174
+
175
+ const resolved = path.resolve(cwd, filePath);
176
+
177
+ // Don't allow overwriting .git internals
178
+ if (resolved.includes('/.git/')) {
179
+ return { safe: false, reason: 'Cannot write to .git directory' };
180
+ }
181
+
182
+ // Warn if writing a very large file (> 1MB)
183
+ if (content && content.length > 1_000_000) {
184
+ return { safe: true, reason: `Large file write: ${(content.length / 1024).toFixed(0)}KB` };
185
+ }
186
+
187
+ return { safe: true };
188
+ }
189
+
190
+ /**
191
+ * Get a human-readable summary of safety rules.
192
+ */
193
+ export function getSafetyRules() {
194
+ return {
195
+ protectedNames: [...PROTECTED_NAMES],
196
+ sourceDirs: [...SOURCE_DIRS],
197
+ blockedPatterns: DANGEROUS_SHELL_PATTERNS.length,
198
+ highRiskPatterns: HIGH_RISK_COMMANDS.length,
199
+ };
200
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Scheduler — cron-based task scheduling.
3
+ *
4
+ * Stores scheduled tasks in ~/.claude/scheduled_tasks.json.
5
+ * Tasks have a cron expression, prompt, and optional environment.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import crypto from 'crypto';
12
+
13
+ export class Scheduler {
14
+ /**
15
+ * @param {string} [tasksFile] - path to scheduled tasks JSON
16
+ */
17
+ constructor(tasksFile) {
18
+ this.tasksFile = tasksFile ||
19
+ path.join(os.homedir(), '.claude', 'scheduled_tasks.json');
20
+ this.timers = new Map();
21
+ }
22
+
23
+ /**
24
+ * Create a new scheduled task.
25
+ * @param {string} cron - cron expression or interval shorthand (e.g., "5m", "1h")
26
+ * @param {string} prompt - prompt to execute
27
+ * @param {object} [options]
28
+ * @param {string} [options.name] - human-readable name
29
+ * @param {string} [options.model] - model to use
30
+ * @param {boolean} [options.enabled] - whether task is enabled (default: true)
31
+ * @returns {object} created task
32
+ */
33
+ async create(cron, prompt, options = {}) {
34
+ const tasks = this._loadTasks();
35
+ const task = {
36
+ id: `task_${crypto.randomBytes(4).toString('hex')}`,
37
+ name: options.name || `Task ${tasks.length + 1}`,
38
+ cron,
39
+ prompt,
40
+ model: options.model || null,
41
+ enabled: options.enabled !== false,
42
+ createdAt: new Date().toISOString(),
43
+ lastRun: null,
44
+ runCount: 0,
45
+ intervalMs: parseCronInterval(cron),
46
+ };
47
+
48
+ tasks.push(task);
49
+ this._saveTasks(tasks);
50
+ return task;
51
+ }
52
+
53
+ /**
54
+ * Delete a scheduled task by ID.
55
+ * @param {string} taskId
56
+ * @returns {boolean} true if deleted
57
+ */
58
+ async delete(taskId) {
59
+ const tasks = this._loadTasks();
60
+ const idx = tasks.findIndex(t => t.id === taskId);
61
+ if (idx === -1) return false;
62
+
63
+ tasks.splice(idx, 1);
64
+ this._saveTasks(tasks);
65
+
66
+ // Clear timer if running
67
+ if (this.timers.has(taskId)) {
68
+ clearInterval(this.timers.get(taskId));
69
+ this.timers.delete(taskId);
70
+ }
71
+
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * List all scheduled tasks.
77
+ * @returns {Array<object>}
78
+ */
79
+ async list() {
80
+ return this._loadTasks();
81
+ }
82
+
83
+ /**
84
+ * Check and return tasks that are due to run.
85
+ * @returns {Array<object>} tasks that are due
86
+ */
87
+ async runDue() {
88
+ const tasks = this._loadTasks();
89
+ const due = [];
90
+ const now = Date.now();
91
+
92
+ for (const task of tasks) {
93
+ if (!task.enabled) continue;
94
+ if (!task.intervalMs) continue;
95
+
96
+ const lastRun = task.lastRun ? new Date(task.lastRun).getTime() : 0;
97
+ if (now - lastRun >= task.intervalMs) {
98
+ task.lastRun = new Date().toISOString();
99
+ task.runCount = (task.runCount || 0) + 1;
100
+ due.push(task);
101
+ }
102
+ }
103
+
104
+ if (due.length > 0) {
105
+ this._saveTasks(tasks);
106
+ }
107
+
108
+ return due;
109
+ }
110
+
111
+ /**
112
+ * Enable or disable a task.
113
+ * @param {string} taskId
114
+ * @param {boolean} enabled
115
+ * @returns {boolean}
116
+ */
117
+ async setEnabled(taskId, enabled) {
118
+ const tasks = this._loadTasks();
119
+ const task = tasks.find(t => t.id === taskId);
120
+ if (!task) return false;
121
+
122
+ task.enabled = enabled;
123
+ this._saveTasks(tasks);
124
+ return true;
125
+ }
126
+
127
+ /**
128
+ * Load tasks from disk.
129
+ * @returns {Array<object>}
130
+ */
131
+ _loadTasks() {
132
+ try {
133
+ const raw = fs.readFileSync(this.tasksFile, 'utf-8');
134
+ return JSON.parse(raw);
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Save tasks to disk.
142
+ * @param {Array<object>} tasks
143
+ */
144
+ _saveTasks(tasks) {
145
+ try {
146
+ const dir = path.dirname(this.tasksFile);
147
+ fs.mkdirSync(dir, { recursive: true });
148
+ fs.writeFileSync(this.tasksFile, JSON.stringify(tasks, null, 2));
149
+ } catch {
150
+ // Best effort
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Parse a cron expression or interval shorthand into milliseconds.
157
+ * @param {string} cron
158
+ * @returns {number}
159
+ */
160
+ export function parseCronInterval(cron) {
161
+ const match = cron.match(/^(\d+)(s|m|h|d)$/);
162
+ if (match) {
163
+ const value = parseInt(match[1], 10);
164
+ const units = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
165
+ return value * (units[match[2]] || 60000);
166
+ }
167
+
168
+ const num = parseInt(cron, 10);
169
+ if (!isNaN(num)) return num * 60000;
170
+
171
+ // Full cron expression — default to 5 minutes
172
+ return 300000;
173
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Session Manager — persist session state, history, and conversation messages.
3
+ *
4
+ * All data lives under ~/.orca/:
5
+ * ~/.orca/
6
+ * projects/{hash}/
7
+ * state.json — current session metadata
8
+ * sessions/ — session metadata archive
9
+ * conversations/
10
+ * {sessionId}.jsonl — conversation messages (JSONL)
11
+ * Line 1: {"type":"header","instruction":"...","project":"..."}
12
+ * Line 2+: {"role":"user","content":"...","timestamp":"..."}
13
+ *
14
+ * Zero per-project files. /resume works from anywhere.
15
+ */
16
+
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import {
20
+ projectDir as getProjectDir,
21
+ statePath as getStatePath,
22
+ sessionsDir as getSessionsDir,
23
+ conversationsDir as getConversationsDir,
24
+ conversationPath as getConversationPath,
25
+ } from './paths.mjs';
26
+
27
+ const MAX_SESSIONS = 100;
28
+
29
+ export class SessionManager {
30
+ constructor(projectPath = process.cwd()) {
31
+ this.projectPath = projectPath;
32
+ this.projectOrcaDir = getProjectDir(projectPath);
33
+ this.statePath = getStatePath(projectPath);
34
+ this.sessionsDir = getSessionsDir(projectPath);
35
+ this.conversationsDir = getConversationsDir();
36
+ this.currentState = null;
37
+ }
38
+
39
+ _ensureDirs() {
40
+ fs.mkdirSync(this.projectOrcaDir, { recursive: true });
41
+ fs.mkdirSync(this.sessionsDir, { recursive: true });
42
+ fs.mkdirSync(this.conversationsDir, { recursive: true });
43
+ }
44
+
45
+ /** Start tracking a new session. */
46
+ start(instruction) {
47
+ this._ensureDirs();
48
+ this.currentState = {
49
+ instruction,
50
+ started_at: new Date().toISOString(),
51
+ status: 'running',
52
+ task_id: null,
53
+ job_id: null,
54
+ session_id: null,
55
+ tool_count: 0,
56
+ turn_count: 0,
57
+ events: [],
58
+ };
59
+ this._writeState();
60
+ }
61
+
62
+ /** Update state from session_info event. */
63
+ setSessionInfo(data) {
64
+ if (!this.currentState) return;
65
+ this.currentState.task_id = data.task_id || this.currentState.task_id;
66
+ this.currentState.job_id = data.job_id || this.currentState.job_id;
67
+ this.currentState.session_id = data.session_id || this.currentState.session_id;
68
+ this._writeState();
69
+ }
70
+
71
+ /** Record a tool call. */
72
+ recordToolCall(toolName) {
73
+ if (!this.currentState) return;
74
+ this.currentState.tool_count++;
75
+ }
76
+
77
+ /** Mark session as complete. */
78
+ complete(summary) {
79
+ if (!this.currentState) return;
80
+ this.currentState.status = 'completed';
81
+ this.currentState.completed_at = new Date().toISOString();
82
+ this.currentState.summary = summary;
83
+ const duration = (Date.now() - new Date(this.currentState.started_at).getTime()) / 1000;
84
+ this.currentState.duration_s = Math.round(duration * 10) / 10;
85
+ this._writeState();
86
+ this._saveToHistory();
87
+ }
88
+
89
+ /** Mark session as failed. */
90
+ fail(errorMessage) {
91
+ if (!this.currentState) return;
92
+ this.currentState.status = 'failed';
93
+ this.currentState.error = errorMessage;
94
+ this.currentState.completed_at = new Date().toISOString();
95
+ this._writeState();
96
+ this._saveToHistory();
97
+ }
98
+
99
+ /** Mark session as cancelled. */
100
+ cancel() {
101
+ if (!this.currentState) return;
102
+ this.currentState.status = 'cancelled';
103
+ this.currentState.completed_at = new Date().toISOString();
104
+ this._writeState();
105
+ this._saveToHistory();
106
+ }
107
+
108
+ /** Mark session as paused. */
109
+ pause() {
110
+ if (!this.currentState) return;
111
+ this.currentState.status = 'paused';
112
+ this._writeState();
113
+ }
114
+
115
+ /** Load saved state for resume. */
116
+ loadState() {
117
+ try {
118
+ if (fs.existsSync(this.statePath)) {
119
+ return JSON.parse(fs.readFileSync(this.statePath, 'utf-8'));
120
+ }
121
+ } catch { /* corrupt file */ }
122
+ return null;
123
+ }
124
+
125
+ /** List recent sessions. */
126
+ listSessions(limit = 20) {
127
+ if (!fs.existsSync(this.sessionsDir)) return [];
128
+ return fs.readdirSync(this.sessionsDir)
129
+ .filter(f => f.endsWith('.json'))
130
+ .sort()
131
+ .reverse()
132
+ .slice(0, limit)
133
+ .map(file => {
134
+ try {
135
+ const data = JSON.parse(fs.readFileSync(path.join(this.sessionsDir, file), 'utf-8'));
136
+ return { file, ...data };
137
+ } catch {
138
+ return { file, status: 'unreadable' };
139
+ }
140
+ });
141
+ }
142
+
143
+ // ── Conversation Persistence ──
144
+
145
+ /**
146
+ * Get the JSONL file path for a session's conversation.
147
+ * @param {string} [sessionId] - defaults to current session
148
+ */
149
+ _conversationPath(sessionId) {
150
+ const id = sessionId || this.currentState?.session_id || this.currentState?.task_id || 'unknown';
151
+ return getConversationPath(id);
152
+ }
153
+
154
+ /**
155
+ * Append a message to the conversation JSONL file.
156
+ * On first write, prepends a header line with session metadata.
157
+ * @param {string} role - 'user' or 'assistant'
158
+ * @param {string} content - message content
159
+ * @param {object} [meta] - optional metadata (tokens, cost, tools)
160
+ */
161
+ saveMessage(role, content, meta = {}) {
162
+ if (!this.currentState) return;
163
+ this._ensureDirs();
164
+
165
+ const convPath = this._conversationPath();
166
+
167
+ // Write header line on first message (so listResumable can read metadata)
168
+ if (!fs.existsSync(convPath)) {
169
+ const header = {
170
+ type: 'header',
171
+ instruction: this.currentState.instruction || '',
172
+ project: this.projectPath,
173
+ project_name: path.basename(this.projectPath),
174
+ started_at: this.currentState.started_at || new Date().toISOString(),
175
+ session_id: this.currentState.session_id || '',
176
+ };
177
+ fs.appendFileSync(convPath, JSON.stringify(header) + '\n');
178
+ }
179
+
180
+ const entry = {
181
+ role,
182
+ content,
183
+ timestamp: new Date().toISOString(),
184
+ turn: this.currentState.turn_count || 0,
185
+ ...meta,
186
+ };
187
+
188
+ fs.appendFileSync(convPath, JSON.stringify(entry) + '\n');
189
+ }
190
+
191
+ /**
192
+ * Read the header line from a conversation JSONL file.
193
+ * @param {string} filePath
194
+ * @returns {object|null}
195
+ */
196
+ _readHeader(filePath) {
197
+ try {
198
+ const first = fs.readFileSync(filePath, 'utf-8').split('\n')[0];
199
+ if (!first) return null;
200
+ const parsed = JSON.parse(first);
201
+ return parsed.type === 'header' ? parsed : null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Load all messages from a session's conversation file (skips header).
209
+ * @param {string} sessionId - session to load
210
+ * @returns {{ role: string, content: string }[]}
211
+ */
212
+ loadMessages(sessionId) {
213
+ const filePath = this._conversationPath(sessionId);
214
+ if (!fs.existsSync(filePath)) return [];
215
+
216
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
217
+ return lines
218
+ .map(line => {
219
+ try { return JSON.parse(line); } catch { return null; }
220
+ })
221
+ .filter(entry => entry && entry.role) // skip header (type=header, no role)
222
+ .map(entry => ({ role: entry.role, content: entry.content }));
223
+ }
224
+
225
+ /**
226
+ * Get the most recent session that has a conversation file.
227
+ * @returns {{ sessionId: string, instruction: string, startedAt: string }|null}
228
+ */
229
+ getLastSession() {
230
+ if (!fs.existsSync(this.conversationsDir)) return null;
231
+
232
+ // Sort by file modification time (most recent first)
233
+ const files = fs.readdirSync(this.conversationsDir)
234
+ .filter(f => f.endsWith('.jsonl'))
235
+ .map(f => ({
236
+ name: f,
237
+ mtime: fs.statSync(path.join(this.conversationsDir, f)).mtimeMs,
238
+ }))
239
+ .sort((a, b) => b.mtime - a.mtime);
240
+
241
+ if (files.length === 0) return null;
242
+
243
+ const file = files[0].name;
244
+ const sessionId = file.replace('.jsonl', '');
245
+ const header = this._readHeader(path.join(this.conversationsDir, file));
246
+
247
+ return {
248
+ sessionId,
249
+ instruction: header?.instruction || '',
250
+ startedAt: header?.started_at || '',
251
+ project: header?.project_name || '',
252
+ };
253
+ }
254
+
255
+ /**
256
+ * List sessions that have conversation history (resumable).
257
+ * Reads metadata from JSONL header line — no cross-referencing needed.
258
+ * @param {number} [limit=10]
259
+ * @returns {Array<{ sessionId, instruction, startedAt, project, messageCount }>}
260
+ */
261
+ listResumable(limit = 10) {
262
+ if (!fs.existsSync(this.conversationsDir)) return [];
263
+
264
+ // Sort by modification time (most recent first)
265
+ const files = fs.readdirSync(this.conversationsDir)
266
+ .filter(f => f.endsWith('.jsonl'))
267
+ .map(f => ({
268
+ name: f,
269
+ mtime: fs.statSync(path.join(this.conversationsDir, f)).mtimeMs,
270
+ }))
271
+ .sort((a, b) => b.mtime - a.mtime)
272
+ .slice(0, limit);
273
+
274
+ return files.map(({ name }) => {
275
+ const sessionId = name.replace('.jsonl', '');
276
+ const convPath = path.join(this.conversationsDir, name);
277
+ const lines = fs.readFileSync(convPath, 'utf-8').split('\n').filter(Boolean);
278
+ const header = this._readHeader(convPath);
279
+
280
+ // Message count = total lines minus header
281
+ const messageCount = header ? lines.length - 1 : lines.length;
282
+
283
+ return {
284
+ sessionId,
285
+ instruction: header?.instruction || '(no instruction)',
286
+ startedAt: header?.started_at || '',
287
+ project: header?.project_name || '',
288
+ messageCount,
289
+ };
290
+ });
291
+ }
292
+
293
+ _writeState() {
294
+ this._ensureDirs();
295
+ fs.writeFileSync(this.statePath, JSON.stringify(this.currentState, null, 2));
296
+ }
297
+
298
+ _saveToHistory() {
299
+ this._ensureDirs();
300
+ const filename = this.currentState.started_at.replace(/[:.]/g, '-') + '.json';
301
+ fs.writeFileSync(
302
+ path.join(this.sessionsDir, filename),
303
+ JSON.stringify(this.currentState, null, 2)
304
+ );
305
+ this._pruneHistory();
306
+ }
307
+
308
+ _pruneHistory() {
309
+ const files = fs.readdirSync(this.sessionsDir)
310
+ .filter(f => f.endsWith('.json'))
311
+ .sort();
312
+ while (files.length > MAX_SESSIONS) {
313
+ const oldest = files.shift();
314
+ try { fs.unlinkSync(path.join(this.sessionsDir, oldest)); } catch {}
315
+ }
316
+ }
317
+ }