@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,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Manager — save, resume, and teleport sessions.
|
|
3
|
+
*
|
|
4
|
+
* Sessions are stored at ~/.claude/projects/<hash>/session.json
|
|
5
|
+
* and contain conversation history, token usage, and metadata.
|
|
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 SessionManager {
|
|
14
|
+
constructor(projectDir = process.cwd()) {
|
|
15
|
+
this.projectDir = projectDir;
|
|
16
|
+
this.sessionId = `sess_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
17
|
+
this.conversationId = null;
|
|
18
|
+
this.startedAt = new Date().toISOString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the session storage directory for the current project.
|
|
23
|
+
*/
|
|
24
|
+
getSessionDir() {
|
|
25
|
+
const hash = crypto.createHash('sha256')
|
|
26
|
+
.update(this.projectDir)
|
|
27
|
+
.digest('hex')
|
|
28
|
+
.slice(0, 16);
|
|
29
|
+
|
|
30
|
+
return path.join(os.homedir(), '.claude', 'projects', hash);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Save the current session state.
|
|
35
|
+
* @param {object} state - agent loop state to save
|
|
36
|
+
*/
|
|
37
|
+
save(state) {
|
|
38
|
+
const dir = this.getSessionDir();
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
const session = {
|
|
42
|
+
id: this.sessionId,
|
|
43
|
+
conversationId: this.conversationId,
|
|
44
|
+
projectDir: this.projectDir,
|
|
45
|
+
startedAt: this.startedAt,
|
|
46
|
+
savedAt: new Date().toISOString(),
|
|
47
|
+
model: state.model,
|
|
48
|
+
turnCount: state.turnCount,
|
|
49
|
+
tokenUsage: state.tokenUsage,
|
|
50
|
+
messages: state.messages,
|
|
51
|
+
systemPrompt: state.systemPrompt,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const filePath = path.join(dir, 'session.json');
|
|
55
|
+
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
|
|
56
|
+
return filePath;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resume a saved session.
|
|
61
|
+
* @param {object} state - agent loop state to restore into
|
|
62
|
+
* @returns {boolean} true if session was restored
|
|
63
|
+
*/
|
|
64
|
+
resume(state) {
|
|
65
|
+
const dir = this.getSessionDir();
|
|
66
|
+
const filePath = path.join(dir, 'session.json');
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
70
|
+
const session = JSON.parse(raw);
|
|
71
|
+
|
|
72
|
+
state.messages = session.messages || [];
|
|
73
|
+
state.turnCount = session.turnCount || 0;
|
|
74
|
+
state.tokenUsage = session.tokenUsage || { input: 0, output: 0 };
|
|
75
|
+
if (session.model) state.model = session.model;
|
|
76
|
+
|
|
77
|
+
this.sessionId = session.id;
|
|
78
|
+
this.conversationId = session.conversationId;
|
|
79
|
+
this.startedAt = session.startedAt;
|
|
80
|
+
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Export session for teleport (transfer between machines).
|
|
89
|
+
* @param {object} state - agent loop state
|
|
90
|
+
* @returns {string} base64-encoded session data
|
|
91
|
+
*/
|
|
92
|
+
exportForTeleport(state) {
|
|
93
|
+
const session = {
|
|
94
|
+
id: this.sessionId,
|
|
95
|
+
projectDir: this.projectDir,
|
|
96
|
+
messages: state.messages,
|
|
97
|
+
turnCount: state.turnCount,
|
|
98
|
+
model: state.model,
|
|
99
|
+
exportedAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return Buffer.from(JSON.stringify(session)).toString('base64');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Import a teleported session.
|
|
107
|
+
* @param {string} data - base64-encoded session data
|
|
108
|
+
* @param {object} state - agent loop state to restore into
|
|
109
|
+
*/
|
|
110
|
+
importFromTeleport(data, state) {
|
|
111
|
+
const session = JSON.parse(Buffer.from(data, 'base64').toString());
|
|
112
|
+
state.messages = session.messages || [];
|
|
113
|
+
state.turnCount = session.turnCount || 0;
|
|
114
|
+
if (session.model) state.model = session.model;
|
|
115
|
+
this.sessionId = `sess_teleport_${Date.now()}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get session info.
|
|
120
|
+
*/
|
|
121
|
+
info() {
|
|
122
|
+
return {
|
|
123
|
+
id: this.sessionId,
|
|
124
|
+
conversationId: this.conversationId,
|
|
125
|
+
projectDir: this.projectDir,
|
|
126
|
+
startedAt: this.startedAt,
|
|
127
|
+
sessionDir: this.getSessionDir(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Delete the saved session.
|
|
133
|
+
*/
|
|
134
|
+
clear() {
|
|
135
|
+
const filePath = path.join(this.getSessionDir(), 'session.json');
|
|
136
|
+
try {
|
|
137
|
+
fs.unlinkSync(filePath);
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Sync — fetch user settings from Tarang web and cache locally.
|
|
3
|
+
*
|
|
4
|
+
* Syncs: gateway_type, model preferences, configured providers.
|
|
5
|
+
* Cached in ~/.orca/config.json alongside auth token.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolveWebUrl } from './backend-url.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fetch user settings from the web API using CLI token.
|
|
12
|
+
* @param {string} token - CLI auth token (orca_xxx)
|
|
13
|
+
* @returns {Promise<Object|null>} Settings object or null on failure
|
|
14
|
+
*/
|
|
15
|
+
export async function fetchRemoteSettings(token) {
|
|
16
|
+
if (!token) return null;
|
|
17
|
+
|
|
18
|
+
const webUrl = resolveWebUrl();
|
|
19
|
+
const url = `${webUrl}/api/cli/settings`;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const resp = await fetch(url, {
|
|
23
|
+
headers: {
|
|
24
|
+
'Authorization': `Bearer ${token}`,
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
},
|
|
27
|
+
signal: AbortSignal.timeout(10_000),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!resp.ok) {
|
|
31
|
+
if (resp.status === 401) {
|
|
32
|
+
process.stderr.write('\x1b[33mSettings sync: token expired or invalid. Run `orca login` to re-authenticate.\x1b[0m\n');
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return await resp.json();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// Network error — silently fail, use cached settings
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Merge remote settings into local config.
|
|
46
|
+
* Remote settings override local only for fields that are set.
|
|
47
|
+
* @param {Object} localConfig - Current ~/.orca/config.json content
|
|
48
|
+
* @param {Object} remote - Settings from fetchRemoteSettings()
|
|
49
|
+
* @returns {Object} Merged config to save
|
|
50
|
+
*/
|
|
51
|
+
export function mergeRemoteSettings(localConfig, remote) {
|
|
52
|
+
if (!remote) return localConfig;
|
|
53
|
+
|
|
54
|
+
const merged = { ...localConfig };
|
|
55
|
+
|
|
56
|
+
// Gateway type
|
|
57
|
+
if (remote.gateway_type) {
|
|
58
|
+
merged.gateway_type = remote.gateway_type;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Model preferences
|
|
62
|
+
if (remote.models) {
|
|
63
|
+
merged.models = {
|
|
64
|
+
...(merged.models || {}),
|
|
65
|
+
...Object.fromEntries(
|
|
66
|
+
Object.entries(remote.models).filter(([, v]) => v != null)
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Configured providers list (informational)
|
|
72
|
+
if (remote.configured_providers) {
|
|
73
|
+
merged.configured_providers = remote.configured_providers;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Gateway config (non-secret, e.g. azure endpoint, aws region)
|
|
77
|
+
if (remote.gateway_config && Object.keys(remote.gateway_config).length > 0) {
|
|
78
|
+
merged.gateway_config = remote.gateway_config;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Timestamp
|
|
82
|
+
merged.last_synced_at = new Date().toISOString();
|
|
83
|
+
|
|
84
|
+
return merged;
|
|
85
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const DEFAULT_STAGNATION_THRESHOLD = 3;
|
|
2
|
+
|
|
3
|
+
function canonicalize(value) {
|
|
4
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
5
|
+
if (value && typeof value === 'object') {
|
|
6
|
+
return Object.fromEntries(
|
|
7
|
+
Object.keys(value)
|
|
8
|
+
.sort()
|
|
9
|
+
.map(key => [key, canonicalize(value[key])]),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createStagnationTracker({
|
|
16
|
+
enabled = true,
|
|
17
|
+
threshold = DEFAULT_STAGNATION_THRESHOLD,
|
|
18
|
+
} = {}) {
|
|
19
|
+
const effectiveThreshold = Number.isInteger(threshold) && threshold >= 2
|
|
20
|
+
? threshold
|
|
21
|
+
: DEFAULT_STAGNATION_THRESHOLD;
|
|
22
|
+
let previousSignature = null;
|
|
23
|
+
let consecutiveCount = 0;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
record(tool, input) {
|
|
27
|
+
if (!enabled) return { detected: false, count: 0 };
|
|
28
|
+
|
|
29
|
+
const signature = JSON.stringify({
|
|
30
|
+
tool,
|
|
31
|
+
input: canonicalize(input || {}),
|
|
32
|
+
});
|
|
33
|
+
if (signature === previousSignature) {
|
|
34
|
+
consecutiveCount++;
|
|
35
|
+
} else {
|
|
36
|
+
previousSignature = signature;
|
|
37
|
+
consecutiveCount = 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
detected: consecutiveCount >= effectiveThreshold,
|
|
42
|
+
count: consecutiveCount,
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
reset() {
|
|
47
|
+
previousSignature = null;
|
|
48
|
+
consecutiveCount = 0;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function stagnationMessage(tool, count) {
|
|
54
|
+
return `STAGNATION WARNING: Tool "${tool}" was called ${count} consecutive times ` +
|
|
55
|
+
`with identical arguments. The duplicate call was skipped. Review the previous ` +
|
|
56
|
+
`result, change the arguments, try another tool, or finish the task.`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TarangStreamClient — SSE consumer for Tarang backend.
|
|
3
|
+
*
|
|
4
|
+
* Replaces OCC's agent-loop.mjs. Instead of calling the LLM API directly,
|
|
5
|
+
* this client POSTs to the Tarang backend, parses the SSE stream, intercepts
|
|
6
|
+
* tool_request/tool_call events (executes locally, POSTs callback), and
|
|
7
|
+
* yields all other events to the caller for rendering.
|
|
8
|
+
*
|
|
9
|
+
* Phase 2: handles all 22 event types. Approval flow integrated.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { sendCallback, sendSkippedCallback, sendApprovalDecision } from './callback-client.mjs';
|
|
13
|
+
import { ApprovalManager } from './approval.mjs';
|
|
14
|
+
|
|
15
|
+
export const EVENT_TYPES = Object.freeze({
|
|
16
|
+
// Phase 1 — handled
|
|
17
|
+
STATUS: 'status',
|
|
18
|
+
TOOL_REQUEST: 'tool_request',
|
|
19
|
+
TOOL_CALL: 'tool_call',
|
|
20
|
+
PLAN: 'plan',
|
|
21
|
+
ERROR: 'error',
|
|
22
|
+
COMPLETE: 'complete',
|
|
23
|
+
// Phase 2 — stubbed
|
|
24
|
+
SESSION_INFO: 'session_info',
|
|
25
|
+
TOOL_DONE: 'tool_done',
|
|
26
|
+
THINKING: 'thinking',
|
|
27
|
+
PHASE_UPDATE: 'phase_update',
|
|
28
|
+
PHASE_SUMMARY: 'phase_summary',
|
|
29
|
+
PHASE_START: 'phase_start',
|
|
30
|
+
WORKER_UPDATE: 'worker_update',
|
|
31
|
+
WORKER_START: 'worker_start',
|
|
32
|
+
WORKER_DONE: 'worker_done',
|
|
33
|
+
DELEGATION: 'delegation',
|
|
34
|
+
CHANGE: 'change',
|
|
35
|
+
CONTENT: 'content',
|
|
36
|
+
CONTENT_PARTIAL: 'content_partial',
|
|
37
|
+
TOOL_RESULT: 'tool_result',
|
|
38
|
+
SUB_AGENT_START: 'sub_agent_start',
|
|
39
|
+
SUB_AGENT_TOOL: 'sub_agent_tool',
|
|
40
|
+
SUB_AGENT_COMPLETE: 'sub_agent_complete',
|
|
41
|
+
STAGNATION: 'stagnation',
|
|
42
|
+
CANCELLED: 'cancelled',
|
|
43
|
+
PAUSED: 'paused',
|
|
44
|
+
RESUMED: 'resumed',
|
|
45
|
+
PAUSE_INSTRUCTION: 'pause_instruction',
|
|
46
|
+
// HITL approval events (from framework)
|
|
47
|
+
APPROVAL_REQUIRED: 'approval_required',
|
|
48
|
+
APPROVAL_GRANTED: 'approval_granted',
|
|
49
|
+
APPROVAL_DENIED: 'approval_denied',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export class TarangStreamClient {
|
|
53
|
+
/**
|
|
54
|
+
* @param {Object} opts
|
|
55
|
+
* @param {string} opts.baseUrl - Tarang backend URL
|
|
56
|
+
* @param {string} opts.token - CLI auth token
|
|
57
|
+
* @param {Object} opts.toolExecutor - { execute(name, args) }
|
|
58
|
+
* @param {boolean} [opts.verbose=false]
|
|
59
|
+
*/
|
|
60
|
+
constructor({ baseUrl, token, toolExecutor, verbose = false, approvalManager = null }) {
|
|
61
|
+
this.baseUrl = (baseUrl || '').replace(/\/$/, '');
|
|
62
|
+
this.token = token;
|
|
63
|
+
this.toolExecutor = toolExecutor;
|
|
64
|
+
this.verbose = verbose;
|
|
65
|
+
this.approval = approvalManager || new ApprovalManager();
|
|
66
|
+
this.currentTaskId = null;
|
|
67
|
+
this.sessionId = null; // Set by backend on first turn, reused on subsequent turns
|
|
68
|
+
this._cancelled = false;
|
|
69
|
+
this._paused = false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Execute an instruction via SSE stream.
|
|
74
|
+
* Yields parsed events. Client-side tool requests are shown, executed
|
|
75
|
+
* locally, callback-posted, then followed by a local tool_result event.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} instruction
|
|
78
|
+
* @param {Object} [context={}]
|
|
79
|
+
* @param {string} [model]
|
|
80
|
+
* @yields {{ type: string, data: Object }}
|
|
81
|
+
*/
|
|
82
|
+
async *execute(instruction, context = {}, messages = null) {
|
|
83
|
+
this._cancelled = false;
|
|
84
|
+
|
|
85
|
+
const url = `${this.baseUrl}/api/execute`;
|
|
86
|
+
const body = { instruction, context };
|
|
87
|
+
if (messages && messages.length > 0) body.messages = messages;
|
|
88
|
+
if (this.sessionId) body.session_id = this.sessionId;
|
|
89
|
+
|
|
90
|
+
const headers = {
|
|
91
|
+
'Accept': 'text/event-stream',
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
};
|
|
94
|
+
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
95
|
+
|
|
96
|
+
let response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetch(url, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers,
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: `Network error: ${err.message}. Check your connection or use --local mode.`, fatal: true } };
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (response.status === 401) {
|
|
109
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `orca login` to re-authenticate.', fatal: true } };
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const text = await response.text().catch(() => 'Unknown error');
|
|
114
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: `Backend error ${response.status}: ${text}`, fatal: true } };
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Grab task ID from response header
|
|
119
|
+
this.currentTaskId = response.headers.get('X-Task-ID') || null;
|
|
120
|
+
|
|
121
|
+
// Parse SSE stream
|
|
122
|
+
for await (const { event, data } of this._parseSSE(response)) {
|
|
123
|
+
if (this._cancelled) {
|
|
124
|
+
yield { type: EVENT_TYPES.STATUS, data: { message: 'Cancelled by user.' } };
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Capture session_id from backend (first turn creates it, subsequent turns reuse)
|
|
129
|
+
if (event === EVENT_TYPES.SESSION_INFO && data?.session_id) {
|
|
130
|
+
this.sessionId = data.session_id;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Framework HITL: approval_required — show menu, POST decision
|
|
134
|
+
if (event === EVENT_TYPES.APPROVAL_REQUIRED) {
|
|
135
|
+
yield { type: event, data }; // Show to user (renders "Approval needed: write_file")
|
|
136
|
+
const approvalEvent = await this._handleApprovalRequired(data);
|
|
137
|
+
if (approvalEvent) yield approvalEvent;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Framework HITL: approval result events — yield for rendering
|
|
142
|
+
if (event === EVENT_TYPES.APPROVAL_GRANTED || event === EVENT_TYPES.APPROVAL_DENIED) {
|
|
143
|
+
yield { type: event, data };
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Tool requests — show to user, then execute locally and POST callback
|
|
148
|
+
// NOTE: With framework HITL enabled, tool_call events no longer carry
|
|
149
|
+
// require_approval — the framework handles that via approval_required above.
|
|
150
|
+
// This path remains for backwards compatibility with older backends.
|
|
151
|
+
if (event === EVENT_TYPES.TOOL_REQUEST || event === EVENT_TYPES.TOOL_CALL) {
|
|
152
|
+
yield { type: event, data }; // Show tool call to user first
|
|
153
|
+
if (data?.server_side) continue;
|
|
154
|
+
const toolEvent = await this._handleToolRequest(data);
|
|
155
|
+
if (toolEvent) yield toolEvent;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// All other events yielded to caller
|
|
160
|
+
yield { type: event, data };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parse SSE from a fetch Response using ReadableStream.
|
|
166
|
+
* @param {Response} response
|
|
167
|
+
* @yields {{ event: string, data: Object }}
|
|
168
|
+
*/
|
|
169
|
+
async *_parseSSE(response) {
|
|
170
|
+
const reader = response.body.getReader();
|
|
171
|
+
const decoder = new TextDecoder();
|
|
172
|
+
let buffer = '';
|
|
173
|
+
let currentEvent = 'message';
|
|
174
|
+
let currentData = [];
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
while (true) {
|
|
178
|
+
const { done, value } = await reader.read();
|
|
179
|
+
if (done) break;
|
|
180
|
+
|
|
181
|
+
buffer += decoder.decode(value, { stream: true });
|
|
182
|
+
const lines = buffer.split('\n');
|
|
183
|
+
buffer = lines.pop(); // keep incomplete last line
|
|
184
|
+
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
const trimmed = line.trim();
|
|
187
|
+
|
|
188
|
+
if (!trimmed) {
|
|
189
|
+
// Empty line = event boundary
|
|
190
|
+
if (currentData.length > 0) {
|
|
191
|
+
const rawData = currentData.join('\n');
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(rawData);
|
|
195
|
+
} catch {
|
|
196
|
+
parsed = { message: rawData };
|
|
197
|
+
}
|
|
198
|
+
yield { event: currentEvent, data: parsed };
|
|
199
|
+
}
|
|
200
|
+
currentEvent = 'message';
|
|
201
|
+
currentData = [];
|
|
202
|
+
} else if (trimmed.startsWith('event:')) {
|
|
203
|
+
currentEvent = trimmed.slice(6).trim();
|
|
204
|
+
} else if (trimmed.startsWith('data:')) {
|
|
205
|
+
currentData.push(trimmed.slice(5).trim());
|
|
206
|
+
}
|
|
207
|
+
// ignore other fields (id:, retry:, comments)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Flush remaining
|
|
212
|
+
if (currentData.length > 0) {
|
|
213
|
+
const rawData = currentData.join('\n');
|
|
214
|
+
let parsed;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(rawData);
|
|
217
|
+
} catch {
|
|
218
|
+
parsed = { message: rawData };
|
|
219
|
+
}
|
|
220
|
+
yield { event: currentEvent, data: parsed };
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
reader.releaseLock();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Handle a tool_call event: execute tool locally, POST result via callback.
|
|
229
|
+
*
|
|
230
|
+
* Approval is handled by the framework (HITL) BEFORE this is called.
|
|
231
|
+
* By the time a tool_call arrives here, it's already approved.
|
|
232
|
+
*
|
|
233
|
+
* @param {Object} data - { call_id, tool, args }
|
|
234
|
+
* @returns {Object} local tool result event to yield
|
|
235
|
+
*/
|
|
236
|
+
async _handleToolRequest(data) {
|
|
237
|
+
const { call_id, request_id, tool, args } = data;
|
|
238
|
+
const callId = call_id || request_id;
|
|
239
|
+
const toolName = tool;
|
|
240
|
+
|
|
241
|
+
if (this.verbose) {
|
|
242
|
+
process.stderr.write(`\x1b[2m[tool] ${toolName}(${JSON.stringify(args).slice(0, 80)}...)\x1b[0m\n`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Execute tool locally — framework already approved this
|
|
246
|
+
const startTime = Date.now();
|
|
247
|
+
let result;
|
|
248
|
+
try {
|
|
249
|
+
result = await this.toolExecutor.execute(toolName, args || {});
|
|
250
|
+
} catch (err) {
|
|
251
|
+
result = { success: false, output: `Tool execution error: ${err.message}` };
|
|
252
|
+
}
|
|
253
|
+
const durationMs = Date.now() - startTime;
|
|
254
|
+
|
|
255
|
+
if (this.verbose) {
|
|
256
|
+
const status = result.success ? 'OK' : 'FAIL';
|
|
257
|
+
process.stderr.write(`\x1b[2m[tool] ${toolName} → ${status} (${durationMs}ms)\x1b[0m\n`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// POST callback to backend
|
|
261
|
+
if (this.currentTaskId && callId) {
|
|
262
|
+
await sendCallback(this.baseUrl, this.token, this.currentTaskId, callId, result);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
type: EVENT_TYPES.TOOL_RESULT,
|
|
267
|
+
data: {
|
|
268
|
+
...result,
|
|
269
|
+
call_id: callId,
|
|
270
|
+
tool: toolName,
|
|
271
|
+
args: args || {},
|
|
272
|
+
duration_ms: durationMs,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Handle framework HITL approval_required event.
|
|
279
|
+
* Shows the same approval menu as tool_call, but POSTs the decision
|
|
280
|
+
* to /api/approval_callback instead of skipping the tool.
|
|
281
|
+
*
|
|
282
|
+
* @param {Object} data - { tool_id, tool, args, risk, reason }
|
|
283
|
+
* @returns {Object|null} optional status event to yield
|
|
284
|
+
*/
|
|
285
|
+
async _handleApprovalRequired(data) {
|
|
286
|
+
const { tool_id, tool, args, risk, reason } = data;
|
|
287
|
+
|
|
288
|
+
if (this.verbose) {
|
|
289
|
+
process.stderr.write(`\x1b[2m[hitl] Approval needed: ${tool} (${risk})\x1b[0m\n`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Use the same ApprovalManager for consistent UX
|
|
293
|
+
const { approved, reason: denyReason } = await this.approval.check(tool, args || {}, true);
|
|
294
|
+
|
|
295
|
+
// Map ApprovalManager decision to framework scope
|
|
296
|
+
let decision, scope;
|
|
297
|
+
if (approved) {
|
|
298
|
+
decision = 'grant';
|
|
299
|
+
// Determine scope from ApprovalManager state
|
|
300
|
+
if (this.approval.approveAll) {
|
|
301
|
+
scope = 'all';
|
|
302
|
+
} else if (this.approval.approvedToolTypes.has(tool)) {
|
|
303
|
+
scope = 'type';
|
|
304
|
+
} else {
|
|
305
|
+
scope = 'once';
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
decision = 'deny';
|
|
309
|
+
scope = 'once';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// POST decision to backend
|
|
313
|
+
if (this.currentTaskId && tool_id) {
|
|
314
|
+
await sendApprovalDecision(
|
|
315
|
+
this.baseUrl, this.token, this.currentTaskId,
|
|
316
|
+
tool_id, decision, scope, denyReason || '',
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!approved) {
|
|
321
|
+
return {
|
|
322
|
+
type: EVENT_TYPES.STATUS,
|
|
323
|
+
data: { message: `Denied ${tool}: ${denyReason || 'rejected'}` },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null; // Approved — framework continues with tool execution
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Cancel the current stream. */
|
|
331
|
+
async cancel() {
|
|
332
|
+
this._cancelled = true;
|
|
333
|
+
if (this.currentTaskId) {
|
|
334
|
+
try {
|
|
335
|
+
await fetch(`${this.baseUrl}/api/cancel/${this.currentTaskId}`, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: { 'Authorization': `Bearer ${this.token}` },
|
|
338
|
+
});
|
|
339
|
+
} catch { /* best effort */ }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Pause the current stream. */
|
|
344
|
+
async pause() {
|
|
345
|
+
if (this.currentTaskId) {
|
|
346
|
+
await fetch(`${this.baseUrl}/api/pause/${this.currentTaskId}`, {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: { 'Authorization': `Bearer ${this.token}` },
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Resume a paused stream. */
|
|
354
|
+
async resume(instruction = null) {
|
|
355
|
+
if (this.currentTaskId) {
|
|
356
|
+
const body = instruction ? JSON.stringify({ instruction }) : undefined;
|
|
357
|
+
await fetch(`${this.baseUrl}/api/resume/${this.currentTaskId}`, {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: {
|
|
360
|
+
'Authorization': `Bearer ${this.token}`,
|
|
361
|
+
'Content-Type': 'application/json',
|
|
362
|
+
},
|
|
363
|
+
body,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|