@axplusb/kepler 2.0.7 → 2.3.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/KEPLER-README.md +157 -142
- package/README.md +37 -65
- package/package.json +2 -2
- package/src/config/cli-args.mjs +14 -0
- package/src/config/hook-runner.mjs +100 -0
- package/src/config/memory-loader.mjs +32 -0
- package/src/config/settings-loader.mjs +45 -0
- package/src/core/agent-loop.mjs +8 -2
- package/src/core/approval-log.mjs +104 -0
- package/src/core/approval.mjs +164 -24
- package/src/core/cache-control.mjs +92 -0
- package/src/core/context-envelope.mjs +54 -0
- package/src/core/headless.mjs +99 -10
- package/src/core/jsonl-writer.mjs +50 -0
- package/src/core/local-agent.mjs +121 -14
- package/src/core/local-store.mjs +486 -5
- package/src/core/policy-resolver.mjs +156 -0
- package/src/core/project-context-loader.mjs +139 -0
- package/src/core/rate-limit-display.mjs +97 -0
- package/src/core/resume-mode.mjs +154 -0
- package/src/core/risk-tier.mjs +88 -2
- package/src/core/safety.mjs +3 -0
- package/src/core/session-manager.mjs +53 -10
- package/src/core/stream-client.mjs +69 -10
- package/src/core/system-prompt.mjs +6 -1
- package/src/core/tasks.mjs +196 -0
- package/src/core/tool-executor.mjs +72 -6
- package/src/core/trust.mjs +158 -0
- package/src/core/work-scope.mjs +217 -0
- package/src/onboarding/preflight.mjs +27 -11
- package/src/permissions/command-classifier.mjs +78 -0
- package/src/terminal/init.mjs +145 -0
- package/src/terminal/main.mjs +9 -0
- package/src/terminal/repl.mjs +1822 -140
- package/src/tools/bash.mjs +57 -9
- package/src/tools/project-overview.mjs +83 -10
- package/src/ui/approval.mjs +154 -35
- package/src/ui/commands.mjs +5 -4
- package/src/ui/formatter.mjs +39 -5
- package/src/ui/mission-report.mjs +55 -22
- package/src/ui/slash-commands.mjs +57 -5
- package/src/ui/tool-card.mjs +51 -1
package/src/core/risk-tier.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
export const TIERS = Object.freeze({
|
|
20
20
|
READ: 'read',
|
|
21
|
+
SENSITIVE_READ: 'sensitive-read',
|
|
21
22
|
LOCAL_EDIT: 'local-edit',
|
|
22
23
|
SHELL_SAFE: 'shell-safe',
|
|
23
24
|
SHELL_MEDIUM: 'shell-medium',
|
|
@@ -35,6 +36,7 @@ export const TIERS = Object.freeze({
|
|
|
35
36
|
*/
|
|
36
37
|
export const BEHAVIOR = Object.freeze({
|
|
37
38
|
[TIERS.READ]: 'auto',
|
|
39
|
+
[TIERS.SENSITIVE_READ]: 'prompt-explicit',
|
|
38
40
|
[TIERS.LOCAL_EDIT]: 'auto-with-undo',
|
|
39
41
|
[TIERS.SHELL_SAFE]: 'auto',
|
|
40
42
|
[TIERS.SHELL_MEDIUM]: 'prompt-safe',
|
|
@@ -58,6 +60,15 @@ const READ_TOOLS = new Set([
|
|
|
58
60
|
'validate_file', 'validate_structure',
|
|
59
61
|
]);
|
|
60
62
|
|
|
63
|
+
const READ_PATH_KEYS = [
|
|
64
|
+
'path', 'file_path', 'file', 'target',
|
|
65
|
+
'pattern', 'glob',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const READ_PATH_ARRAY_KEYS = [
|
|
69
|
+
'paths', 'file_paths', 'files', 'targets', 'patterns', 'globs',
|
|
70
|
+
];
|
|
71
|
+
|
|
61
72
|
const LOCAL_EDIT_TOOLS = new Set([
|
|
62
73
|
'edit_file', 'write_file', 'write_project',
|
|
63
74
|
]);
|
|
@@ -77,7 +88,7 @@ const SHELL_SAFE_RE = [
|
|
|
77
88
|
// `cd` / `pushd` / `popd` only change the process working directory; if
|
|
78
89
|
// chained with something dangerous, the multi-segment classifier still
|
|
79
90
|
// catches the danger (`cd /x && rm -rf .` → SHELL_DANGEROUS).
|
|
80
|
-
/^\s*(cd|pushd|popd|ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|fd|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)\b/i,
|
|
91
|
+
/^\s*(cd|pushd|popd|ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|fd|sed|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)\b/i,
|
|
81
92
|
// mkdir -p / touch are creation primitives but harmless in scope.
|
|
82
93
|
/^\s*mkdir\s+-p\b/i,
|
|
83
94
|
/^\s*touch\s/i,
|
|
@@ -111,6 +122,8 @@ const SHELL_DANGEROUS_RE = [
|
|
|
111
122
|
/\bcurl\b.*\|\s*(sh|bash|zsh)/i,
|
|
112
123
|
/\bwget\b.*\|\s*(sh|bash|zsh)/i,
|
|
113
124
|
/\beval\s+["'$(]/i,
|
|
125
|
+
/\bfind\s+\/(?:\s|$)/i,
|
|
126
|
+
/\bfind\b.*\s-(?:exec|execdir|ok|okdir|delete|fprint|fprint0|fls|fprintf)\b/i,
|
|
114
127
|
/\bkubectl\s+delete/i,
|
|
115
128
|
/\bdocker\s+(rm|rmi|system\s+prune|volume\s+rm|network\s+rm)/i,
|
|
116
129
|
/\bdrop\s+(table|database|schema)/i,
|
|
@@ -132,6 +145,8 @@ const SHELL_MEDIUM_RE = [
|
|
|
132
145
|
/^\s*make(\s|$)/i,
|
|
133
146
|
/^\s*git\s+(commit|push|pull|merge|rebase|fetch|checkout(?!\s+\.)|cherry-pick|revert|tag|stash(?!\s+drop))/i,
|
|
134
147
|
/^\s*docker\s+(build|run|exec|compose|pull|push|tag)/i,
|
|
148
|
+
/^\s*sed\b.*\s-i\S*(?:\s|$)/i,
|
|
149
|
+
/^\s*sed\b.*\s--in-place(?:=|\s|$)/i,
|
|
135
150
|
];
|
|
136
151
|
|
|
137
152
|
export function classifyShell(command) {
|
|
@@ -141,6 +156,8 @@ export function classifyShell(command) {
|
|
|
141
156
|
// Dangerous wins over safe — never let a safe-looking prefix mask `&& rm -rf`.
|
|
142
157
|
if (SHELL_DANGEROUS_RE.some(re => re.test(cmd))) return TIERS.SHELL_DANGEROUS;
|
|
143
158
|
|
|
159
|
+
if (hasUnsafeOutputRedirection(cmd)) return TIERS.SHELL_MEDIUM;
|
|
160
|
+
|
|
144
161
|
// For a chained command, classify each segment and take the riskiest —
|
|
145
162
|
// never let a safe-looking prefix mask `&& npm install` or worse.
|
|
146
163
|
if (/&&|\|\||;|\|(?!\|)/.test(cmd)) {
|
|
@@ -161,6 +178,31 @@ export function classifyShell(command) {
|
|
|
161
178
|
return TIERS.SHELL_MEDIUM;
|
|
162
179
|
}
|
|
163
180
|
|
|
181
|
+
function hasUnsafeOutputRedirection(cmd) {
|
|
182
|
+
let inSingle = false;
|
|
183
|
+
let inDouble = false;
|
|
184
|
+
let escaped = false;
|
|
185
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
186
|
+
const ch = cmd[i];
|
|
187
|
+
if (escaped) { escaped = false; continue; }
|
|
188
|
+
if (ch === '\\' && !inSingle) { escaped = true; continue; }
|
|
189
|
+
if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
|
|
190
|
+
if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
|
|
191
|
+
if (inSingle || inDouble || ch !== '>') continue;
|
|
192
|
+
|
|
193
|
+
const prev = cmd[i - 1] || '';
|
|
194
|
+
const next = cmd[i + 1] || '';
|
|
195
|
+
if (next === '&') continue;
|
|
196
|
+
let cursor = next === '>' ? i + 2 : i + 1;
|
|
197
|
+
while (/\s/.test(cmd[cursor] || '')) cursor++;
|
|
198
|
+
const target = cmd.slice(cursor).match(/^[^\s;&|]+/)?.[0] || '';
|
|
199
|
+
if (target === '/dev/null') continue;
|
|
200
|
+
if (prev === '2' && target === '/dev/null') continue;
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
164
206
|
function splitShellSegments(cmd) {
|
|
165
207
|
// Split on top-level &&, ||, ;, | — naive but enough for the classifier.
|
|
166
208
|
return cmd.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean);
|
|
@@ -168,6 +210,7 @@ function splitShellSegments(cmd) {
|
|
|
168
210
|
|
|
169
211
|
const TIER_ORDER = [
|
|
170
212
|
TIERS.READ,
|
|
213
|
+
TIERS.SENSITIVE_READ,
|
|
171
214
|
TIERS.SHELL_SAFE,
|
|
172
215
|
TIERS.LOCAL_EDIT,
|
|
173
216
|
TIERS.NETWORK,
|
|
@@ -191,7 +234,9 @@ function riskier(a, b) {
|
|
|
191
234
|
export function classify(tool, args = {}) {
|
|
192
235
|
if (!tool) return TIERS.SHELL_MEDIUM;
|
|
193
236
|
|
|
194
|
-
if (READ_TOOLS.has(tool))
|
|
237
|
+
if (READ_TOOLS.has(tool)) {
|
|
238
|
+
return isSensitiveRead(args) ? TIERS.SENSITIVE_READ : TIERS.READ;
|
|
239
|
+
}
|
|
195
240
|
if (LOCAL_EDIT_TOOLS.has(tool)) return TIERS.LOCAL_EDIT;
|
|
196
241
|
if (DESTRUCTIVE_TOOLS.has(tool)) return TIERS.DESTRUCTIVE;
|
|
197
242
|
if (NETWORK_TOOLS.has(tool)) return TIERS.NETWORK;
|
|
@@ -220,6 +265,7 @@ export function classify(tool, args = {}) {
|
|
|
220
265
|
export function label(tier) {
|
|
221
266
|
switch (tier) {
|
|
222
267
|
case TIERS.READ: return 'READ';
|
|
268
|
+
case TIERS.SENSITIVE_READ: return 'SENSITIVE-READ';
|
|
223
269
|
case TIERS.LOCAL_EDIT: return 'LOCAL-EDIT';
|
|
224
270
|
case TIERS.SHELL_SAFE: return 'SHELL-SAFE';
|
|
225
271
|
case TIERS.SHELL_MEDIUM: return 'SHELL-MEDIUM';
|
|
@@ -243,3 +289,43 @@ export function requiresExplicitApproval(tier) {
|
|
|
243
289
|
export function requiresCheckpoint(tier) {
|
|
244
290
|
return behavior(tier) === 'auto-with-undo';
|
|
245
291
|
}
|
|
292
|
+
|
|
293
|
+
export function isSensitiveRead(args = {}) {
|
|
294
|
+
return extractReadPaths(args).some(isSensitiveReadPath);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function isSensitiveReadPath(filePath = '') {
|
|
298
|
+
const normalized = String(filePath || '')
|
|
299
|
+
.replace(/\\/g, '/')
|
|
300
|
+
.replace(/\/+/g, '/')
|
|
301
|
+
.replace(/^\.\//, '')
|
|
302
|
+
.trim();
|
|
303
|
+
if (!normalized) return false;
|
|
304
|
+
|
|
305
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
306
|
+
const base = parts[parts.length - 1] || normalized;
|
|
307
|
+
if (base === '.env') return true;
|
|
308
|
+
if (/\.pem$/i.test(base)) return true;
|
|
309
|
+
return parts.includes('secrets');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function extractReadPaths(args = {}) {
|
|
313
|
+
const paths = [];
|
|
314
|
+
for (const key of READ_PATH_KEYS) {
|
|
315
|
+
if (typeof args[key] === 'string') paths.push(args[key]);
|
|
316
|
+
}
|
|
317
|
+
for (const key of READ_PATH_ARRAY_KEYS) {
|
|
318
|
+
const value = args[key];
|
|
319
|
+
if (!Array.isArray(value)) continue;
|
|
320
|
+
for (const item of value) {
|
|
321
|
+
if (typeof item === 'string') {
|
|
322
|
+
paths.push(item);
|
|
323
|
+
} else if (item && typeof item === 'object') {
|
|
324
|
+
for (const nestedKey of READ_PATH_KEYS) {
|
|
325
|
+
if (typeof item[nestedKey] === 'string') paths.push(item[nestedKey]);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return paths;
|
|
331
|
+
}
|
package/src/core/safety.mjs
CHANGED
|
@@ -67,6 +67,9 @@ const HIGH_RISK_COMMANDS = [
|
|
|
67
67
|
/\brm\s/, // ALL rm commands require explicit approval
|
|
68
68
|
/\bunlink\s/, // unlink
|
|
69
69
|
/\brmdir\s/, // rmdir
|
|
70
|
+
/\bkill\s/, // process termination requires explicit approval
|
|
71
|
+
/\bpkill\s/, // process termination by pattern requires explicit approval
|
|
72
|
+
/\bxargs\s+kill\b/, // process termination via piped pid list
|
|
70
73
|
/git\s+push\s+--force/,
|
|
71
74
|
/git\s+reset\s+--hard/,
|
|
72
75
|
/git\s+clean\s+-[fd]/,
|
|
@@ -210,16 +210,54 @@ export class SessionManager {
|
|
|
210
210
|
* @returns {{ role: string, content: string }[]}
|
|
211
211
|
*/
|
|
212
212
|
loadMessages(sessionId) {
|
|
213
|
+
return this.loadConversation(sessionId).messages;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Load the header and all messages from a session conversation file.
|
|
218
|
+
* @param {string} sessionId
|
|
219
|
+
* @returns {{ header: object|null, messages: { role: string, content: string }[] }}
|
|
220
|
+
*/
|
|
221
|
+
loadConversation(sessionId) {
|
|
213
222
|
const filePath = this._conversationPath(sessionId);
|
|
214
|
-
if (!fs.existsSync(filePath)) return [];
|
|
223
|
+
if (!fs.existsSync(filePath)) return { header: null, messages: [] };
|
|
215
224
|
|
|
216
225
|
const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
|
|
217
|
-
|
|
226
|
+
const entries = lines
|
|
218
227
|
.map(line => {
|
|
219
228
|
try { return JSON.parse(line); } catch { return null; }
|
|
220
229
|
})
|
|
221
|
-
.filter(
|
|
230
|
+
.filter(Boolean);
|
|
231
|
+
const header = entries.find(entry => entry.type === 'header') || null;
|
|
232
|
+
const messages = entries
|
|
233
|
+
.filter(entry => entry.role) // skip header (type=header, no role)
|
|
222
234
|
.map(entry => ({ role: entry.role, content: entry.content }));
|
|
235
|
+
return { header, messages };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Activate an existing conversation as the current session so additional
|
|
240
|
+
* turns append to the same JSONL file.
|
|
241
|
+
* @param {string} sessionId
|
|
242
|
+
* @param {object|null} header
|
|
243
|
+
* @param {{ role: string, content: string }[]} messages
|
|
244
|
+
*/
|
|
245
|
+
activateSession(sessionId, header = null, messages = []) {
|
|
246
|
+
this._ensureDirs();
|
|
247
|
+
this.currentState = {
|
|
248
|
+
instruction: header?.instruction || messages.find(m => m.role === 'user')?.content || '',
|
|
249
|
+
started_at: header?.started_at || new Date().toISOString(),
|
|
250
|
+
status: 'running',
|
|
251
|
+
task_id: null,
|
|
252
|
+
job_id: null,
|
|
253
|
+
session_id: sessionId,
|
|
254
|
+
tool_count: 0,
|
|
255
|
+
turn_count: messages.filter(m => m.role === 'user').length,
|
|
256
|
+
events: [],
|
|
257
|
+
resumed_at: new Date().toISOString(),
|
|
258
|
+
};
|
|
259
|
+
this._writeState();
|
|
260
|
+
return this.currentState;
|
|
223
261
|
}
|
|
224
262
|
|
|
225
263
|
/**
|
|
@@ -249,29 +287,32 @@ export class SessionManager {
|
|
|
249
287
|
instruction: header?.instruction || '',
|
|
250
288
|
startedAt: header?.started_at || '',
|
|
251
289
|
project: header?.project_name || '',
|
|
290
|
+
projectPath: header?.project || '',
|
|
252
291
|
};
|
|
253
292
|
}
|
|
254
293
|
|
|
255
294
|
/**
|
|
256
295
|
* List sessions that have conversation history (resumable).
|
|
257
296
|
* Reads metadata from JSONL header line — no cross-referencing needed.
|
|
258
|
-
* @param {number} [limit=
|
|
259
|
-
* @returns {Array<{ sessionId, instruction, startedAt, project, messageCount }>}
|
|
297
|
+
* @param {number} [limit=Infinity]
|
|
298
|
+
* @returns {Array<{ sessionId, instruction, startedAt, updatedAt, project, messageCount }>}
|
|
260
299
|
*/
|
|
261
|
-
listResumable(limit =
|
|
300
|
+
listResumable(limit = Infinity) {
|
|
262
301
|
if (!fs.existsSync(this.conversationsDir)) return [];
|
|
263
302
|
|
|
264
|
-
// Sort by
|
|
303
|
+
// Sort by latest activity (conversation file mtime) so resumed sessions
|
|
304
|
+
// move back to the top. The UI displays this same timestamp.
|
|
265
305
|
const files = fs.readdirSync(this.conversationsDir)
|
|
266
306
|
.filter(f => f.endsWith('.jsonl'))
|
|
267
307
|
.map(f => ({
|
|
268
308
|
name: f,
|
|
269
309
|
mtime: fs.statSync(path.join(this.conversationsDir, f)).mtimeMs,
|
|
270
310
|
}))
|
|
271
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
272
|
-
|
|
311
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
312
|
+
|
|
313
|
+
const limited = Number.isFinite(limit) ? files.slice(0, limit) : files;
|
|
273
314
|
|
|
274
|
-
return
|
|
315
|
+
return limited.map(({ name, mtime }) => {
|
|
275
316
|
const sessionId = name.replace('.jsonl', '');
|
|
276
317
|
const convPath = path.join(this.conversationsDir, name);
|
|
277
318
|
const lines = fs.readFileSync(convPath, 'utf-8').split('\n').filter(Boolean);
|
|
@@ -284,7 +325,9 @@ export class SessionManager {
|
|
|
284
325
|
sessionId,
|
|
285
326
|
instruction: header?.instruction || '(no instruction)',
|
|
286
327
|
startedAt: header?.started_at || '',
|
|
328
|
+
updatedAt: new Date(mtime).toISOString(),
|
|
287
329
|
project: header?.project_name || '',
|
|
330
|
+
projectPath: header?.project || '',
|
|
288
331
|
messageCount,
|
|
289
332
|
};
|
|
290
333
|
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { sendCallback, sendSkippedCallback, sendApprovalDecision } from './callback-client.mjs';
|
|
13
13
|
import { ApprovalManager } from './approval.mjs';
|
|
14
|
+
import { rateLimitErrorMessage } from './rate-limit-display.mjs';
|
|
14
15
|
|
|
15
16
|
export const EVENT_TYPES = Object.freeze({
|
|
16
17
|
// Phase 1 — handled
|
|
@@ -57,18 +58,26 @@ export class TarangStreamClient {
|
|
|
57
58
|
* @param {Object} opts.toolExecutor - { execute(name, args) }
|
|
58
59
|
* @param {boolean} [opts.verbose=false]
|
|
59
60
|
*/
|
|
60
|
-
constructor({ baseUrl, token, toolExecutor, verbose = false, approvalManager = null }) {
|
|
61
|
+
constructor({ baseUrl, token, toolExecutor, verbose = false, approvalManager = null, product = null }) {
|
|
61
62
|
this.baseUrl = (baseUrl || '').replace(/\/$/, '');
|
|
62
63
|
this.token = token;
|
|
63
64
|
this.toolExecutor = toolExecutor;
|
|
64
65
|
this.verbose = verbose;
|
|
65
66
|
this.approval = approvalManager || new ApprovalManager();
|
|
67
|
+
this.product = product || process.env.TARANG_PRODUCT || process.env.KEPLER_PRODUCT || 'kepler';
|
|
66
68
|
this.currentTaskId = null;
|
|
67
69
|
this.sessionId = null; // Set by backend on first turn, reused on subsequent turns
|
|
68
70
|
this._cancelled = false;
|
|
69
71
|
this._paused = false;
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
_headers(extra = {}) {
|
|
75
|
+
const headers = { ...extra };
|
|
76
|
+
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
77
|
+
if (this.product) headers['X-Product'] = this.product;
|
|
78
|
+
return headers;
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
/**
|
|
73
82
|
* Execute an instruction via SSE stream.
|
|
74
83
|
* Yields parsed events. Client-side tool requests are shown, executed
|
|
@@ -87,11 +96,10 @@ export class TarangStreamClient {
|
|
|
87
96
|
if (messages && messages.length > 0) body.messages = messages;
|
|
88
97
|
if (this.sessionId) body.session_id = this.sessionId;
|
|
89
98
|
|
|
90
|
-
const headers = {
|
|
99
|
+
const headers = this._headers({
|
|
91
100
|
'Accept': 'text/event-stream',
|
|
92
101
|
'Content-Type': 'application/json',
|
|
93
|
-
};
|
|
94
|
-
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
102
|
+
});
|
|
95
103
|
|
|
96
104
|
// Abort controller so cancel() can break out of a stalled reader
|
|
97
105
|
// instead of waiting for the next SSE event to notice _cancelled.
|
|
@@ -118,6 +126,27 @@ export class TarangStreamClient {
|
|
|
118
126
|
yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `kepler login` to re-authenticate.', fatal: true } };
|
|
119
127
|
return;
|
|
120
128
|
}
|
|
129
|
+
if (response.status === 429) {
|
|
130
|
+
const text = await response.text().catch(() => '');
|
|
131
|
+
let payload = null;
|
|
132
|
+
try {
|
|
133
|
+
payload = text ? JSON.parse(text) : null;
|
|
134
|
+
} catch {
|
|
135
|
+
payload = { detail: { message: text } };
|
|
136
|
+
}
|
|
137
|
+
const detail = payload?.detail && typeof payload.detail === 'object' ? payload.detail : payload;
|
|
138
|
+
yield {
|
|
139
|
+
type: EVENT_TYPES.ERROR,
|
|
140
|
+
data: {
|
|
141
|
+
message: rateLimitErrorMessage(payload),
|
|
142
|
+
code: detail?.code || 'rate_limited',
|
|
143
|
+
retry_after: detail?.retry_after ?? detail?.rate_limit?.retry_after,
|
|
144
|
+
rate_limit: detail?.rate_limit || null,
|
|
145
|
+
fatal: true,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
121
150
|
if (!response.ok) {
|
|
122
151
|
const text = await response.text().catch(() => 'Unknown error');
|
|
123
152
|
yield { type: EVENT_TYPES.ERROR, data: { message: `Backend error ${response.status}: ${text}`, fatal: true } };
|
|
@@ -307,7 +336,7 @@ export class TarangStreamClient {
|
|
|
307
336
|
}
|
|
308
337
|
|
|
309
338
|
// Use the same ApprovalManager for consistent UX
|
|
310
|
-
const { approved, reason: denyReason } = await this.approval.check(
|
|
339
|
+
const { approved, reason: denyReason, scope: approvedScope } = await this.approval.check(
|
|
311
340
|
tool,
|
|
312
341
|
args || {},
|
|
313
342
|
true,
|
|
@@ -323,6 +352,8 @@ export class TarangStreamClient {
|
|
|
323
352
|
scope = 'all';
|
|
324
353
|
} else if (this.approval.approvedToolTypes.has(tool)) {
|
|
325
354
|
scope = 'type';
|
|
355
|
+
} else if (approvedScope) {
|
|
356
|
+
scope = String(approvedScope).toLowerCase();
|
|
326
357
|
} else {
|
|
327
358
|
scope = 'once';
|
|
328
359
|
}
|
|
@@ -357,7 +388,7 @@ export class TarangStreamClient {
|
|
|
357
388
|
try {
|
|
358
389
|
await fetch(`${this.baseUrl}/api/cancel/${this.currentTaskId}`, {
|
|
359
390
|
method: 'POST',
|
|
360
|
-
headers:
|
|
391
|
+
headers: this._headers(),
|
|
361
392
|
});
|
|
362
393
|
} catch { /* best effort */ }
|
|
363
394
|
}
|
|
@@ -373,7 +404,7 @@ export class TarangStreamClient {
|
|
|
373
404
|
if (this.currentTaskId) {
|
|
374
405
|
await fetch(`${this.baseUrl}/api/pause/${this.currentTaskId}`, {
|
|
375
406
|
method: 'POST',
|
|
376
|
-
headers:
|
|
407
|
+
headers: this._headers(),
|
|
377
408
|
});
|
|
378
409
|
}
|
|
379
410
|
}
|
|
@@ -384,12 +415,40 @@ export class TarangStreamClient {
|
|
|
384
415
|
const body = instruction ? JSON.stringify({ instruction }) : undefined;
|
|
385
416
|
await fetch(`${this.baseUrl}/api/resume/${this.currentTaskId}`, {
|
|
386
417
|
method: 'POST',
|
|
387
|
-
headers: {
|
|
388
|
-
'Authorization': `Bearer ${this.token}`,
|
|
418
|
+
headers: this._headers({
|
|
389
419
|
'Content-Type': 'application/json',
|
|
390
|
-
},
|
|
420
|
+
}),
|
|
391
421
|
body,
|
|
392
422
|
});
|
|
393
423
|
}
|
|
394
424
|
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Summarize a prior transcript for resume continuity.
|
|
428
|
+
*
|
|
429
|
+
* @param {Array<{role:string,content:string}>} messages
|
|
430
|
+
* @param {Object} [opts]
|
|
431
|
+
* @returns {Promise<{summary:string,source:string,model:string}>}
|
|
432
|
+
*/
|
|
433
|
+
async summarizeSession(messages, opts = {}) {
|
|
434
|
+
const response = await fetch(`${this.baseUrl}/api/summarize/session`, {
|
|
435
|
+
method: 'POST',
|
|
436
|
+
headers: this._headers({
|
|
437
|
+
'Content-Type': 'application/json',
|
|
438
|
+
'Accept': 'application/json',
|
|
439
|
+
}),
|
|
440
|
+
body: JSON.stringify({
|
|
441
|
+
messages,
|
|
442
|
+
session_id: opts.sessionId || null,
|
|
443
|
+
project_path: opts.projectPath || null,
|
|
444
|
+
max_tokens: opts.maxTokens || 800,
|
|
445
|
+
}),
|
|
446
|
+
signal: AbortSignal.timeout(opts.timeoutMs || 15000),
|
|
447
|
+
});
|
|
448
|
+
if (!response.ok) {
|
|
449
|
+
const text = await response.text().catch(() => '');
|
|
450
|
+
throw new Error(`summary request failed (${response.status})${text ? `: ${text.slice(0, 200)}` : ''}`);
|
|
451
|
+
}
|
|
452
|
+
return await response.json();
|
|
453
|
+
}
|
|
395
454
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* System Prompt Builder — loads and merges CLAUDE.md files.
|
|
2
|
+
* System Prompt Builder — loads and merges CLAUDE.md and KEPLER.md files.
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
5
|
* - Loads CLAUDE.md from: ~/.claude/CLAUDE.md, project root, parent dirs
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import fs from 'fs';
|
|
11
11
|
import path from 'path';
|
|
12
12
|
import os from 'os';
|
|
13
|
+
import { loadKeplerMemory } from '../config/memory-loader.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Load all CLAUDE.md files and merge them in order.
|
|
@@ -88,6 +89,10 @@ export function buildSystemPrompt({ cwd, tools, override, addDirs } = {}) {
|
|
|
88
89
|
parts.push(f.content);
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
for (const f of loadKeplerMemory({ cwd })) {
|
|
93
|
+
parts.push(f.content);
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
// The static prefix is the base prompt + CLAUDE.md content (cacheable)
|
|
92
97
|
const staticPrefix = parts.join('\n\n');
|
|
93
98
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const TASK_FILES = Object.freeze({
|
|
5
|
+
active: 'active.md',
|
|
6
|
+
backlog: 'backlog.md',
|
|
7
|
+
blocked: 'blocked.md',
|
|
8
|
+
done: 'done.md',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const DEFAULT_CONTENT = Object.freeze({
|
|
12
|
+
'README.md': '# Kepler Tasks\n\nChecklist files are read every turn.\n',
|
|
13
|
+
'active.md': '# Active\n\n',
|
|
14
|
+
'backlog.md': '# Backlog\n\n',
|
|
15
|
+
'blocked.md': '# Blocked\n\n',
|
|
16
|
+
'done.md': '# Done\n\n',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function ensureTaskFiles({ cwd = process.cwd() } = {}) {
|
|
20
|
+
const dir = path.join(cwd, '.kepler', 'tasks');
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
const written = [];
|
|
23
|
+
for (const [name, content] of Object.entries(DEFAULT_CONTENT)) {
|
|
24
|
+
const filePath = path.join(dir, name);
|
|
25
|
+
if (fs.existsSync(filePath)) continue;
|
|
26
|
+
fs.writeFileSync(filePath, content);
|
|
27
|
+
written.push(filePath);
|
|
28
|
+
}
|
|
29
|
+
return { dir, written };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadTaskBoard({ cwd = process.cwd(), create = false } = {}) {
|
|
33
|
+
const dir = path.join(cwd, '.kepler', 'tasks');
|
|
34
|
+
if (create) ensureTaskFiles({ cwd });
|
|
35
|
+
const lists = {};
|
|
36
|
+
for (const [list, fileName] of Object.entries(TASK_FILES)) {
|
|
37
|
+
const filePath = path.join(dir, fileName);
|
|
38
|
+
const content = readText(filePath);
|
|
39
|
+
lists[list] = {
|
|
40
|
+
list,
|
|
41
|
+
fileName,
|
|
42
|
+
path: filePath,
|
|
43
|
+
exists: fs.existsSync(filePath),
|
|
44
|
+
content,
|
|
45
|
+
tasks: parseTaskMarkdown(content, list),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const planPath = path.join(cwd, '.kepler', 'plan.md');
|
|
50
|
+
const goalPath = path.join(cwd, '.kepler', 'goal.md');
|
|
51
|
+
return {
|
|
52
|
+
dir,
|
|
53
|
+
lists,
|
|
54
|
+
plan: { path: planPath, exists: fs.existsSync(planPath), content: readText(planPath) },
|
|
55
|
+
goal: { path: goalPath, exists: fs.existsSync(goalPath), content: readText(goalPath) },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function appendTask({ cwd = process.cwd(), list = 'backlog', text }) {
|
|
60
|
+
const normalized = normalizeList(list);
|
|
61
|
+
const taskText = String(text || '').trim();
|
|
62
|
+
if (!taskText) throw new Error('Task text is required');
|
|
63
|
+
const { dir } = ensureTaskFiles({ cwd });
|
|
64
|
+
const filePath = path.join(dir, TASK_FILES[normalized]);
|
|
65
|
+
const prefix = normalized === 'done' ? '- [x]' : '- [ ]';
|
|
66
|
+
fs.appendFileSync(filePath, `${prefix} ${taskText}\n`);
|
|
67
|
+
return { list: normalized, path: filePath, text: taskText };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function updateTask({ cwd = process.cwd(), list = 'active', index, text, checked } = {}) {
|
|
71
|
+
const normalized = normalizeList(list);
|
|
72
|
+
const taskIndex = normalizeTaskIndex(index);
|
|
73
|
+
ensureTaskFiles({ cwd });
|
|
74
|
+
const filePath = taskFilePath(cwd, normalized);
|
|
75
|
+
const content = readText(filePath);
|
|
76
|
+
const tasks = parseTaskMarkdown(content, normalized);
|
|
77
|
+
const task = tasks[taskIndex - 1];
|
|
78
|
+
if (!task) throw new Error(`No task ${taskIndex} in ${normalized}`);
|
|
79
|
+
|
|
80
|
+
const lines = content.split(/\r?\n/);
|
|
81
|
+
const nextText = String(text ?? task.text).trim();
|
|
82
|
+
if (!nextText) throw new Error('Task text is required');
|
|
83
|
+
const nextChecked = checked === undefined ? task.checked : Boolean(checked);
|
|
84
|
+
lines[task.line - 1] = taskLine(nextText, normalized, nextChecked);
|
|
85
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
86
|
+
return { list: normalized, path: filePath, index: taskIndex, previous: task, text: nextText, checked: nextChecked };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function removeTask({ cwd = process.cwd(), list = 'active', index } = {}) {
|
|
90
|
+
const normalized = normalizeList(list);
|
|
91
|
+
const taskIndex = normalizeTaskIndex(index);
|
|
92
|
+
ensureTaskFiles({ cwd });
|
|
93
|
+
const filePath = taskFilePath(cwd, normalized);
|
|
94
|
+
const content = readText(filePath);
|
|
95
|
+
const tasks = parseTaskMarkdown(content, normalized);
|
|
96
|
+
const task = tasks[taskIndex - 1];
|
|
97
|
+
if (!task) throw new Error(`No task ${taskIndex} in ${normalized}`);
|
|
98
|
+
|
|
99
|
+
const lines = content.split(/\r?\n/);
|
|
100
|
+
lines.splice(task.line - 1, 1);
|
|
101
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
102
|
+
return { list: normalized, path: filePath, index: taskIndex, task };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function moveTask({ cwd = process.cwd(), from = 'active', index, to = 'done', text } = {}) {
|
|
106
|
+
const source = normalizeList(from);
|
|
107
|
+
const target = normalizeList(to);
|
|
108
|
+
const removed = removeTask({ cwd, list: source, index });
|
|
109
|
+
const taskText = String(text ?? removed.task.text).trim();
|
|
110
|
+
const appended = appendTask({ cwd, list: target, text: taskText });
|
|
111
|
+
return {
|
|
112
|
+
from: source,
|
|
113
|
+
to: target,
|
|
114
|
+
index: removed.index,
|
|
115
|
+
text: taskText,
|
|
116
|
+
sourcePath: removed.path,
|
|
117
|
+
targetPath: appended.path,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function taskCounts(board) {
|
|
122
|
+
const lists = board?.lists || {};
|
|
123
|
+
return Object.fromEntries(
|
|
124
|
+
Object.entries(TASK_FILES).map(([list]) => [list, lists[list]?.tasks?.length || 0]),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function normalizeList(value) {
|
|
129
|
+
const key = String(value || '').toLowerCase();
|
|
130
|
+
if (key === 'todo' || key === 'pending') return 'backlog';
|
|
131
|
+
if (key === 'current' || key === 'doing') return 'active';
|
|
132
|
+
if (key === 'complete' || key === 'completed') return 'done';
|
|
133
|
+
if (key in TASK_FILES) return key;
|
|
134
|
+
throw new Error(`Unknown task list: ${value}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeTaskIndex(value) {
|
|
138
|
+
const n = Number(value);
|
|
139
|
+
if (!Number.isInteger(n) || n < 1) throw new Error('Task index must be a positive number');
|
|
140
|
+
return n;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function taskFilePath(cwd, list) {
|
|
144
|
+
return path.join(cwd, '.kepler', 'tasks', TASK_FILES[list]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function taskLine(text, list, checked = false) {
|
|
148
|
+
const mark = checked || list === 'done' ? 'x' : ' ';
|
|
149
|
+
return `- [${mark}] ${text}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readText(filePath) {
|
|
153
|
+
try {
|
|
154
|
+
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
|
|
155
|
+
} catch {
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseTaskMarkdown(content, list = 'backlog') {
|
|
161
|
+
const tasks = [];
|
|
162
|
+
let section = '';
|
|
163
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
164
|
+
for (let i = 0; i < lines.length; i++) {
|
|
165
|
+
const line = lines[i];
|
|
166
|
+
const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
167
|
+
if (heading) {
|
|
168
|
+
section = heading[1].trim();
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const checkbox = line.match(/^\s*[-*]\s+\[([ xX!-])\]\s+(.+?)\s*$/);
|
|
173
|
+
if (checkbox) {
|
|
174
|
+
tasks.push({
|
|
175
|
+
list,
|
|
176
|
+
line: i + 1,
|
|
177
|
+
section,
|
|
178
|
+
checked: checkbox[1].toLowerCase() === 'x',
|
|
179
|
+
text: checkbox[2].trim(),
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const bullet = line.match(/^\s*[-*]\s+(.+?)\s*$/);
|
|
185
|
+
if (bullet && !bullet[1].startsWith('`')) {
|
|
186
|
+
tasks.push({
|
|
187
|
+
list,
|
|
188
|
+
line: i + 1,
|
|
189
|
+
section,
|
|
190
|
+
checked: list === 'done',
|
|
191
|
+
text: bullet[1].trim(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return tasks;
|
|
196
|
+
}
|