@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.
- package/README.md +82 -0
- package/package.json +36 -4
- package/pulse/app/activity/page.tsx +190 -0
- package/pulse/app/api/activity/route.ts +138 -0
- package/pulse/app/api/costs/route.ts +88 -0
- package/pulse/app/api/export/route.ts +77 -0
- package/pulse/app/api/history/route.ts +11 -0
- package/pulse/app/api/import/route.ts +31 -0
- package/pulse/app/api/memory/route.ts +52 -0
- package/pulse/app/api/plans/route.ts +9 -0
- package/pulse/app/api/projects/[slug]/route.ts +96 -0
- package/pulse/app/api/projects/route.ts +121 -0
- package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
- package/pulse/app/api/sessions/[id]/route.ts +31 -0
- package/pulse/app/api/sessions/route.ts +112 -0
- package/pulse/app/api/settings/route.ts +14 -0
- package/pulse/app/api/stats/route.ts +143 -0
- package/pulse/app/api/todos/route.ts +9 -0
- package/pulse/app/api/tools/route.ts +160 -0
- package/pulse/app/costs/page.tsx +179 -0
- package/pulse/app/export/page.tsx +465 -0
- package/pulse/app/favicon.ico +0 -0
- package/pulse/app/globals.css +263 -0
- package/pulse/app/help/page.tsx +142 -0
- package/pulse/app/history/page.tsx +157 -0
- package/pulse/app/layout.tsx +46 -0
- package/pulse/app/memory/page.tsx +365 -0
- package/pulse/app/overview-client.tsx +393 -0
- package/pulse/app/page.tsx +14 -0
- package/pulse/app/plans/page.tsx +308 -0
- package/pulse/app/projects/[slug]/page.tsx +390 -0
- package/pulse/app/projects/page.tsx +110 -0
- package/pulse/app/sessions/[id]/page.tsx +243 -0
- package/pulse/app/sessions/page.tsx +39 -0
- package/pulse/app/settings/page.tsx +188 -0
- package/pulse/app/todos/page.tsx +211 -0
- package/pulse/app/tools/page.tsx +249 -0
- package/pulse/cli.js +159 -0
- package/pulse/components/activity/day-of-week-chart.tsx +35 -0
- package/pulse/components/activity/streak-card.tsx +36 -0
- package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
- package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
- package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
- package/pulse/components/costs/model-token-table.tsx +60 -0
- package/pulse/components/global-search.tsx +193 -0
- package/pulse/components/keyboard-nav-provider.tsx +23 -0
- package/pulse/components/layout/bottom-nav.tsx +52 -0
- package/pulse/components/layout/client-layout.tsx +31 -0
- package/pulse/components/layout/sidebar-context.tsx +50 -0
- package/pulse/components/layout/sidebar.tsx +182 -0
- package/pulse/components/layout/top-bar.tsx +121 -0
- package/pulse/components/overview/activity-heatmap.tsx +107 -0
- package/pulse/components/overview/conversation-table.tsx +148 -0
- package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
- package/pulse/components/overview/peak-hours-chart.tsx +87 -0
- package/pulse/components/overview/project-activity-donut.tsx +96 -0
- package/pulse/components/overview/stat-card.tsx +102 -0
- package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
- package/pulse/components/projects/project-card.tsx +175 -0
- package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
- package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
- package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
- package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
- package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
- package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
- package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
- package/pulse/components/sessions/session-badges.tsx +49 -0
- package/pulse/components/sessions/session-table.tsx +299 -0
- package/pulse/components/theme-provider.tsx +44 -0
- package/pulse/components/tools/feature-adoption-table.tsx +58 -0
- package/pulse/components/tools/mcp-server-panel.tsx +45 -0
- package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
- package/pulse/components/tools/version-history-table.tsx +32 -0
- package/pulse/components/ui/alert.tsx +66 -0
- package/pulse/components/ui/badge.tsx +48 -0
- package/pulse/components/ui/breadcrumb.tsx +109 -0
- package/pulse/components/ui/button.tsx +64 -0
- package/pulse/components/ui/calendar.tsx +220 -0
- package/pulse/components/ui/card.tsx +92 -0
- package/pulse/components/ui/command.tsx +158 -0
- package/pulse/components/ui/dialog.tsx +158 -0
- package/pulse/components/ui/input.tsx +21 -0
- package/pulse/components/ui/popover.tsx +89 -0
- package/pulse/components/ui/progress.tsx +31 -0
- package/pulse/components/ui/select.tsx +190 -0
- package/pulse/components/ui/separator.tsx +28 -0
- package/pulse/components/ui/sheet.tsx +143 -0
- package/pulse/components/ui/skeleton.tsx +13 -0
- package/pulse/components/ui/table.tsx +116 -0
- package/pulse/components/ui/tabs.tsx +91 -0
- package/pulse/components/ui/tooltip.tsx +57 -0
- package/pulse/components/use-global-keyboard-nav.ts +79 -0
- package/pulse/components.json +23 -0
- package/pulse/eslint.config.mjs +18 -0
- package/pulse/lib/claude-reader.ts +594 -0
- package/pulse/lib/decode.ts +129 -0
- package/pulse/lib/pricing.ts +102 -0
- package/pulse/lib/replay-parser.ts +165 -0
- package/pulse/lib/tool-categories.ts +127 -0
- package/pulse/lib/utils.ts +6 -0
- package/pulse/next-env.d.ts +6 -0
- package/pulse/next.config.ts +16 -0
- package/pulse/package.json +45 -0
- package/pulse/postcss.config.mjs +7 -0
- package/pulse/public/activity.png +0 -0
- package/pulse/public/cc-lens.png +0 -0
- package/pulse/public/command-k.png +0 -0
- package/pulse/public/costs.png +0 -0
- package/pulse/public/dashboard-dark.png +0 -0
- package/pulse/public/dashboard-white.png +0 -0
- package/pulse/public/export.png +0 -0
- package/pulse/public/file.svg +1 -0
- package/pulse/public/globe.svg +1 -0
- package/pulse/public/next.svg +1 -0
- package/pulse/public/projects.png +0 -0
- package/pulse/public/session-chat.png +0 -0
- package/pulse/public/todos.png +0 -0
- package/pulse/public/tools.png +0 -0
- package/pulse/public/vercel.svg +1 -0
- package/pulse/public/window.svg +1 -0
- package/pulse/tsconfig.json +34 -0
- package/pulse/types/claude.ts +294 -0
- package/src/agents/loader.mjs +89 -0
- package/src/agents/parser.mjs +98 -0
- package/src/agents/teams.mjs +123 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/auth/tarang-auth.mjs +277 -0
- package/src/config/cli-args.mjs +173 -0
- package/src/config/env.mjs +263 -0
- package/src/config/settings.mjs +132 -0
- package/src/context/ast-parser.mjs +298 -0
- package/src/context/bm25.mjs +85 -0
- package/src/context/retriever.mjs +270 -0
- package/src/context/skeleton.mjs +134 -0
- package/src/core/agent-loop.mjs +480 -0
- package/src/core/approval.mjs +273 -0
- package/src/core/backend-url.mjs +57 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/callback-client.mjs +149 -0
- package/src/core/checkpoints.mjs +142 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/headless.mjs +168 -0
- package/src/core/hooks-manager.mjs +87 -0
- package/src/core/jsonl-writer.mjs +351 -0
- package/src/core/local-agent.mjs +429 -0
- package/src/core/local-store.mjs +325 -0
- package/src/core/mode-selector.mjs +51 -0
- package/src/core/output-filter.mjs +177 -0
- package/src/core/paths.mjs +98 -0
- package/src/core/pricing.mjs +314 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/safety.mjs +200 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session-manager.mjs +317 -0
- package/src/core/session.mjs +143 -0
- package/src/core/settings-sync.mjs +85 -0
- package/src/core/stagnation.mjs +57 -0
- package/src/core/stream-client.mjs +367 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +135 -0
- package/src/core/tool-executor.mjs +725 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/index.mjs +370 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/command-classifier.mjs +573 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/loader.mjs +147 -0
- package/src/skills/runner.mjs +55 -0
- package/src/telemetry/index.mjs +96 -0
- package/src/terminal/agents.mjs +177 -0
- package/src/terminal/analytics.mjs +292 -0
- package/src/terminal/ansi.mjs +421 -0
- package/src/terminal/main.mjs +150 -0
- package/src/terminal/repl.mjs +1484 -0
- package/src/terminal/tool-display.mjs +58 -0
- package/src/tools/agent.mjs +137 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +148 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +129 -0
- package/src/tools/lint.mjs +71 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +132 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/test-runner.mjs +49 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/banner.mjs +237 -0
- package/src/ui/commands.mjs +499 -0
- package/src/ui/formatter.mjs +379 -0
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/slash-commands.mjs +258 -0
- 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
|
+
}
|