@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
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { createToolRegistry } from '../tools/registry.mjs';
|
|
12
|
-
import { filterOutput } from './output-filter.mjs';
|
|
12
|
+
import { detectCommandType, filterOutput } from './output-filter.mjs';
|
|
13
13
|
import { validatePath, validateDelete, validateShellCommand, validateWrite } from './safety.mjs';
|
|
14
14
|
import { classifyCommand, isExitCodeError } from '../permissions/command-classifier.mjs';
|
|
15
15
|
import { analyzeCode } from '../context/ast-parser.mjs';
|
|
16
16
|
import { ProjectRegistry } from '../tools/project-overview.mjs';
|
|
17
17
|
import { SkillsLoader } from '../skills/loader.mjs';
|
|
18
|
+
import { HookRunner } from '../config/hook-runner.mjs';
|
|
18
19
|
import * as fs from 'node:fs';
|
|
19
20
|
import * as path from 'node:path';
|
|
20
21
|
import { execSync } from 'node:child_process';
|
|
@@ -29,6 +30,7 @@ export function createToolExecutor({
|
|
|
29
30
|
projectRegistry = new ProjectRegistry(),
|
|
30
31
|
skillsLoader = new SkillsLoader().load(process.cwd()),
|
|
31
32
|
checkpoints = null,
|
|
33
|
+
hookRunner = null,
|
|
32
34
|
} = {}) {
|
|
33
35
|
const occRegistry = createToolRegistry();
|
|
34
36
|
const skillTool = occRegistry.get('Skill');
|
|
@@ -49,6 +51,43 @@ export function createToolExecutor({
|
|
|
49
51
|
return resolvePath(args.cwd || null, args);
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function longRunningObservationTimeoutMs() {
|
|
55
|
+
const configured = Number(process.env.KEPLER_LONG_RUNNING_TIMEOUT_MS);
|
|
56
|
+
return Number.isFinite(configured) && configured > 0 ? configured : 15_000;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLikelyLongRunningCommand(command) {
|
|
60
|
+
const cmd = String(command || '').trim();
|
|
61
|
+
if (!cmd) return false;
|
|
62
|
+
if (/^(?:timeout|gtimeout)\s+\S+\s+/i.test(cmd)) return false;
|
|
63
|
+
if (/(?:^|[;&|]\s*)(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview)\b/i.test(cmd)) return true;
|
|
64
|
+
if (/(?:^|[;&|]\s*)(?:vite|next|nuxt|astro|webpack-dev-server)\b/i.test(cmd)) return true;
|
|
65
|
+
if (/\b(?:uvicorn|gunicorn|flask\s+run|rails\s+server|bin\/rails\s+server|django-admin\s+runserver|manage\.py\s+runserver)\b/i.test(cmd)) return true;
|
|
66
|
+
if (/\b(?:python|python3)\s+-m\s+http\.server\b/i.test(cmd)) return true;
|
|
67
|
+
if (/\bnode\b[\s\S]*(?:setInterval|\.listen\s*\(|createServer\s*\()/i.test(cmd)) return true;
|
|
68
|
+
if (/\b(?:docker\s+compose|docker-compose)\s+up\b(?![\s\S]*\s-d\b)/i.test(cmd)) return true;
|
|
69
|
+
if (/\btail\s+-f\b/i.test(cmd)) return true;
|
|
70
|
+
if (/\b(?:--watch|watch)\b/i.test(cmd)) return true;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function limitTail(text, maxChars = 8000) {
|
|
75
|
+
const value = String(text || '');
|
|
76
|
+
if (value.length <= maxChars) return value;
|
|
77
|
+
return `... (tail truncated)\n${value.slice(value.length - maxChars)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatObservationTimeoutOutput(rawOutput, timeoutMs) {
|
|
81
|
+
const tail = String(rawOutput || '')
|
|
82
|
+
.replace(/^Error:\s*Command timed out after \d+ms\s*/i, '')
|
|
83
|
+
.trim();
|
|
84
|
+
const body = tail || '(no output captured before timeout)';
|
|
85
|
+
return limitTail(
|
|
86
|
+
`Observation timeout after ${timeoutMs}ms for a likely long-running command. ` +
|
|
87
|
+
`The process was stopped after collecting the output tail.\n${body}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
52
91
|
function updateProjectIndex(filePath) {
|
|
53
92
|
try {
|
|
54
93
|
projectRegistry.projectForPath(filePath)?.retriever.updateFile(filePath);
|
|
@@ -215,20 +254,31 @@ export function createToolExecutor({
|
|
|
215
254
|
}
|
|
216
255
|
}
|
|
217
256
|
|
|
257
|
+
const observationTimeout = args.timeout == null && isLikelyLongRunningCommand(args.command);
|
|
258
|
+
const effectiveTimeout = observationTimeout ? longRunningObservationTimeoutMs() : args.timeout;
|
|
218
259
|
const result = await occRegistry.call('Bash', {
|
|
219
260
|
command: args.command,
|
|
220
|
-
timeout:
|
|
261
|
+
timeout: effectiveTimeout,
|
|
221
262
|
description: args.description || `Run: ${(args.command || '').slice(0, 50)}`,
|
|
222
263
|
cwd,
|
|
223
264
|
});
|
|
224
265
|
const rawOutput = typeof result === 'string' ? result : String(result);
|
|
266
|
+
const timedOut = /^Error:\s*Command timed out after \d+ms/i.test(rawOutput);
|
|
225
267
|
const exitMatch = rawOutput.match(/Exit code: (\d+)/);
|
|
226
|
-
const exitCode = exitMatch ? parseInt(exitMatch[1]) : 0;
|
|
268
|
+
const exitCode = timedOut ? 124 : (exitMatch ? parseInt(exitMatch[1]) : 0);
|
|
227
269
|
// Semantic exit code: grep returns 1 for "no matches" (not an error)
|
|
228
|
-
const success = !isExitCodeError(args.command, exitCode);
|
|
270
|
+
const success = observationTimeout && timedOut ? true : (!timedOut && !isExitCodeError(args.command, exitCode));
|
|
229
271
|
|
|
230
272
|
// Apply smart filtering based on command type
|
|
231
|
-
const filtered =
|
|
273
|
+
const filtered = observationTimeout && timedOut
|
|
274
|
+
? {
|
|
275
|
+
output: formatObservationTimeoutOutput(rawOutput, effectiveTimeout),
|
|
276
|
+
commandType: detectCommandType(args.command),
|
|
277
|
+
truncated: false,
|
|
278
|
+
originalLines: rawOutput.split('\n').length,
|
|
279
|
+
filteredLines: rawOutput.split('\n').length,
|
|
280
|
+
}
|
|
281
|
+
: filterOutput(rawOutput, args.command, success);
|
|
232
282
|
|
|
233
283
|
return {
|
|
234
284
|
success,
|
|
@@ -238,6 +288,9 @@ export function createToolExecutor({
|
|
|
238
288
|
_classification: args._classification,
|
|
239
289
|
_commandType: filtered.commandType,
|
|
240
290
|
_filtered: filtered.truncated || filtered.originalLines !== filtered.filteredLines,
|
|
291
|
+
_timed_out: timedOut,
|
|
292
|
+
_observation_timeout: observationTimeout && timedOut,
|
|
293
|
+
_observation_timeout_ms: observationTimeout && timedOut ? effectiveTimeout : undefined,
|
|
241
294
|
};
|
|
242
295
|
},
|
|
243
296
|
|
|
@@ -875,8 +928,21 @@ print('OK: replaced')
|
|
|
875
928
|
if (!handler) {
|
|
876
929
|
return { success: false, output: `Unknown tool: ${name}`, _tool: name };
|
|
877
930
|
}
|
|
931
|
+
const hooks = hookRunner || new HookRunner({ cwd: process.cwd() });
|
|
878
932
|
try {
|
|
879
|
-
|
|
933
|
+
const pre = await hooks.run('PreToolUse', { toolName: name, input: args || {} });
|
|
934
|
+
if (pre.blocked) {
|
|
935
|
+
return { success: false, output: `BLOCKED by hook: ${pre.message}`, _tool: name, _blocked: true };
|
|
936
|
+
}
|
|
937
|
+
let result = await handler(args);
|
|
938
|
+
const post = await hooks.run('PostToolUse', { toolName: name, input: args || {}, result });
|
|
939
|
+
for (const item of post.results || []) {
|
|
940
|
+
if (item.parsed?.modifiedResult !== undefined) result = item.parsed.modifiedResult;
|
|
941
|
+
if (item.parsed?.feedback && result && typeof result === 'object') {
|
|
942
|
+
result.output = `${result.output || ''}\n\n--- Hook Feedback ---\n${item.parsed.feedback}`.trim();
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return result;
|
|
880
946
|
} catch (err) {
|
|
881
947
|
return { success: false, output: `Tool error (${name}): ${err.message}`, _tool: name };
|
|
882
948
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { TIERS } from './risk-tier.mjs';
|
|
5
|
+
|
|
6
|
+
function trustPath(cwd) {
|
|
7
|
+
return path.join(cwd, '.kepler', 'trust.json');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function nowIso() {
|
|
11
|
+
return new Date().toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function commandShape(command) {
|
|
15
|
+
const parts = String(command || '').trim().split(/\s+/).filter(Boolean);
|
|
16
|
+
return parts.slice(0, 2).join(' ');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shellPattern(args) {
|
|
20
|
+
const shape = commandShape(args?.command);
|
|
21
|
+
return shape ? `${shape}*` : '*';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pathPattern(args) {
|
|
25
|
+
const p = String(args?.file_path || args?.path || '').trim();
|
|
26
|
+
if (!p) return '*';
|
|
27
|
+
const dir = path.dirname(p);
|
|
28
|
+
return dir === '.' ? p : path.join(dir, '**');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function patternFor(tool, args = {}) {
|
|
32
|
+
if (tool === 'shell') return shellPattern(args);
|
|
33
|
+
return pathPattern(args);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function matches(pattern, value) {
|
|
37
|
+
if (!pattern || pattern === '*') return true;
|
|
38
|
+
if (pattern.endsWith('*')) return String(value || '').startsWith(pattern.slice(0, -1));
|
|
39
|
+
if (pattern.endsWith('/**')) return String(value || '').startsWith(pattern.slice(0, -3));
|
|
40
|
+
return pattern === value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function valueFor(tool, args = {}) {
|
|
44
|
+
if (tool === 'shell') return String(args.command || '');
|
|
45
|
+
return String(args.file_path || args.path || '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class TrustStore {
|
|
49
|
+
constructor({ cwd = process.cwd(), policy = {} } = {}) {
|
|
50
|
+
this.cwd = cwd;
|
|
51
|
+
this.policy = policy;
|
|
52
|
+
this.sessionRules = [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
loadProjectRules() {
|
|
56
|
+
try {
|
|
57
|
+
const file = trustPath(this.cwd);
|
|
58
|
+
if (!fs.existsSync(file)) return [];
|
|
59
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
60
|
+
return Array.isArray(data.rules) ? data.rules : [];
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
saveProjectRules(rules) {
|
|
67
|
+
const file = trustPath(this.cwd);
|
|
68
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
fs.writeFileSync(file, JSON.stringify({ version: 1, rules }, null, 2) + '\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
allRules() {
|
|
73
|
+
return [...this.sessionRules, ...this.loadProjectRules()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
find(tool, args = {}, tier = '') {
|
|
77
|
+
const value = valueFor(tool, args);
|
|
78
|
+
const rules = this.allRules().filter(rule => rule.tool === tool && matches(rule.pattern, value));
|
|
79
|
+
const deny = rules.find(rule => rule.decision === 'deny');
|
|
80
|
+
if (deny) return { decision: 'deny', rule: deny };
|
|
81
|
+
const allow = rules.find(rule => rule.decision === 'allow');
|
|
82
|
+
if (!allow) return null;
|
|
83
|
+
|
|
84
|
+
const reask = this.shouldReask(allow, args, tier);
|
|
85
|
+
if (reask) return { decision: 'reask', rule: allow, reason: reask };
|
|
86
|
+
return { decision: 'allow', rule: allow };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
shouldReask(rule, args = {}, tier = '') {
|
|
90
|
+
const reask = rule.reask || {};
|
|
91
|
+
const after = reask.after_minutes ?? this.policy.hitl?.reaskAfterMinutes;
|
|
92
|
+
if (after && rule.created_at) {
|
|
93
|
+
const ageMs = Date.now() - Date.parse(rule.created_at);
|
|
94
|
+
if (Number.isFinite(ageMs) && ageMs > after * 60 * 1000) {
|
|
95
|
+
return `approval expired after ${after}m`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if ((reask.on_risk_increase ?? this.policy.hitl?.reaskOnRiskIncrease) && rule.tier && tier) {
|
|
99
|
+
const order = [TIERS.READ, TIERS.SHELL_SAFE, TIERS.LOCAL_EDIT, TIERS.SHELL_MEDIUM, TIERS.NETWORK, TIERS.SHELL_DANGEROUS, TIERS.DESTRUCTIVE];
|
|
100
|
+
if (order.indexOf(tier) > order.indexOf(rule.tier)) return 'risk tier increased';
|
|
101
|
+
}
|
|
102
|
+
if ((this.policy.hitl?.alwaysAskForDangerous ?? true) && (tier === TIERS.SHELL_DANGEROUS || tier === TIERS.DESTRUCTIVE)) {
|
|
103
|
+
return 'dangerous tier requires fresh approval';
|
|
104
|
+
}
|
|
105
|
+
if ((reask.on_command_shape_change ?? this.policy.hitl?.reaskOnCommandShapeChange) && rule.command_shape && args.command) {
|
|
106
|
+
if (rule.command_shape !== commandShape(args.command)) return 'command shape changed';
|
|
107
|
+
}
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
add({ tool, args = {}, tier, scope = 'SESSION', decision = 'allow' }) {
|
|
112
|
+
const normalizedScope = String(scope || 'SESSION').toUpperCase();
|
|
113
|
+
const pattern = patternFor(tool, args);
|
|
114
|
+
const id = `${tool}-${crypto.createHash('sha1').update(pattern + normalizedScope).digest('hex').slice(0, 10)}`;
|
|
115
|
+
const ttl = this.policy.hitl?.reaskAfterMinutes ?? 30;
|
|
116
|
+
const rule = {
|
|
117
|
+
id,
|
|
118
|
+
tool,
|
|
119
|
+
pattern,
|
|
120
|
+
scope: normalizedScope,
|
|
121
|
+
decision,
|
|
122
|
+
tier,
|
|
123
|
+
command_shape: tool === 'shell' ? commandShape(args.command) : undefined,
|
|
124
|
+
created_at: nowIso(),
|
|
125
|
+
expires_at: normalizedScope === 'SESSION'
|
|
126
|
+
? new Date(Date.now() + ttl * 60 * 1000).toISOString()
|
|
127
|
+
: undefined,
|
|
128
|
+
reask: {
|
|
129
|
+
after_minutes: ttl,
|
|
130
|
+
on_command_shape_change: this.policy.hitl?.reaskOnCommandShapeChange ?? true,
|
|
131
|
+
on_risk_increase: this.policy.hitl?.reaskOnRiskIncrease ?? true,
|
|
132
|
+
on_path_boundary_change: this.policy.hitl?.reaskOnPathBoundaryChange ?? true,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
if (normalizedScope === 'PROJECT') {
|
|
136
|
+
const rules = this.loadProjectRules().filter(r => r.id !== id);
|
|
137
|
+
rules.push(rule);
|
|
138
|
+
this.saveProjectRules(rules);
|
|
139
|
+
} else {
|
|
140
|
+
this.sessionRules = this.sessionRules.filter(r => r.id !== id);
|
|
141
|
+
this.sessionRules.push(rule);
|
|
142
|
+
}
|
|
143
|
+
return rule;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
revoke() {
|
|
147
|
+
const active = this.sessionRules.length > 0;
|
|
148
|
+
this.sessionRules = [];
|
|
149
|
+
return active;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
summary() {
|
|
153
|
+
return {
|
|
154
|
+
sessionRules: this.sessionRules.length,
|
|
155
|
+
projectRules: this.loadProjectRules().length,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const SCHEMA = 'kepler.work_scope/1';
|
|
7
|
+
const ROOT_MARKERS = [
|
|
8
|
+
'.kepler',
|
|
9
|
+
'.git',
|
|
10
|
+
'package.json',
|
|
11
|
+
'pyproject.toml',
|
|
12
|
+
'setup.py',
|
|
13
|
+
'go.mod',
|
|
14
|
+
'Cargo.toml',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function stable(value) {
|
|
18
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
19
|
+
if (value && typeof value === 'object') {
|
|
20
|
+
return Object.fromEntries(
|
|
21
|
+
Object.entries(value)
|
|
22
|
+
.filter(([, v]) => v !== undefined)
|
|
23
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
24
|
+
.map(([k, v]) => [k, stable(v)]),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sha(payload) {
|
|
31
|
+
return crypto
|
|
32
|
+
.createHash('sha256')
|
|
33
|
+
.update(JSON.stringify(stable(payload)))
|
|
34
|
+
.digest('hex')
|
|
35
|
+
.slice(0, 16);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizePathInput(value) {
|
|
39
|
+
let s = String(value || '').trim();
|
|
40
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
41
|
+
s = s.slice(1, -1);
|
|
42
|
+
}
|
|
43
|
+
if (s === '~' || s.startsWith('~/')) {
|
|
44
|
+
s = path.join(os.homedir(), s.slice(1));
|
|
45
|
+
}
|
|
46
|
+
return s.replace(/\\([ \t()&$;'"])/g, '$1');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nearestProjectRoot(candidate) {
|
|
50
|
+
let resolved = normalizePathInput(candidate);
|
|
51
|
+
if (!resolved || !path.isAbsolute(resolved)) return null;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
resolved = fs.realpathSync(resolved);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let dir = resolved;
|
|
60
|
+
try {
|
|
61
|
+
if (fs.statSync(resolved).isFile()) dir = path.dirname(resolved);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const root = path.parse(dir).root;
|
|
67
|
+
let current = dir;
|
|
68
|
+
while (current && current !== root) {
|
|
69
|
+
if (ROOT_MARKERS.some(marker => fs.existsSync(path.join(current, marker)))) {
|
|
70
|
+
return fs.realpathSync(current);
|
|
71
|
+
}
|
|
72
|
+
current = path.dirname(current);
|
|
73
|
+
}
|
|
74
|
+
return fs.realpathSync(dir);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractQuotedPaths(text) {
|
|
78
|
+
const paths = [];
|
|
79
|
+
const re = /(['"])(\/[^'"\n]+)\1/g;
|
|
80
|
+
let match;
|
|
81
|
+
while ((match = re.exec(String(text || ''))) !== null) {
|
|
82
|
+
paths.push(match[2]);
|
|
83
|
+
}
|
|
84
|
+
return paths;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractPastedPaths(text) {
|
|
88
|
+
const source = String(text || '');
|
|
89
|
+
const paths = [];
|
|
90
|
+
for (let i = 0; i < source.length; i++) {
|
|
91
|
+
if (source[i] !== '/') continue;
|
|
92
|
+
const line = source.slice(i).split(/\r?\n/, 1)[0].replace(/[),.;:]+$/g, '');
|
|
93
|
+
const parts = line.split(/\s+/).filter(Boolean);
|
|
94
|
+
let candidate = '';
|
|
95
|
+
let lastRoot = null;
|
|
96
|
+
for (const part of parts.slice(0, 12)) {
|
|
97
|
+
candidate = candidate ? `${candidate} ${part}` : part;
|
|
98
|
+
const root = nearestProjectRoot(candidate.replace(/[),.;:]+$/g, ''));
|
|
99
|
+
if (root) lastRoot = root;
|
|
100
|
+
}
|
|
101
|
+
if (lastRoot) paths.push(lastRoot);
|
|
102
|
+
}
|
|
103
|
+
return paths;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function uniqueRoots(entries) {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const result = [];
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (!entry?.path || seen.has(entry.path)) continue;
|
|
111
|
+
seen.add(entry.path);
|
|
112
|
+
result.push(entry);
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function roleForRoot(root, cwd) {
|
|
118
|
+
const base = path.basename(root).toLowerCase();
|
|
119
|
+
if (base.includes('backend')) return 'backend';
|
|
120
|
+
if (base.includes('frontend') || base.includes('web')) return 'frontend';
|
|
121
|
+
if (base.includes('deploy')) return 'deploy';
|
|
122
|
+
if (base.includes('docs') || base.includes('prd')) return 'docs';
|
|
123
|
+
if (base.includes('npm') || base.includes('cli')) return 'cli';
|
|
124
|
+
if (root === cwd) return 'primary';
|
|
125
|
+
return 'workspace';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function truncate(value, max = 280) {
|
|
129
|
+
const s = String(value || '').replace(/\s+/g, ' ').trim();
|
|
130
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sortedResources(resources) {
|
|
134
|
+
return (Array.isArray(resources) ? resources : [])
|
|
135
|
+
.filter(resource => resource && resource.root)
|
|
136
|
+
.map(resource => ({
|
|
137
|
+
project_id: String(resource.project_id || ''),
|
|
138
|
+
root: String(resource.root || ''),
|
|
139
|
+
name: String(resource.name || path.basename(String(resource.root || ''))),
|
|
140
|
+
index_version: String(resource.index_version || ''),
|
|
141
|
+
}))
|
|
142
|
+
.sort((a, b) => a.root.localeCompare(b.root));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildWorkScope({
|
|
146
|
+
instruction = '',
|
|
147
|
+
cwd = process.cwd(),
|
|
148
|
+
projectResources = [],
|
|
149
|
+
} = {}) {
|
|
150
|
+
const cwdRoot = nearestProjectRoot(cwd) || path.resolve(cwd);
|
|
151
|
+
const roots = [{
|
|
152
|
+
path: cwdRoot,
|
|
153
|
+
role: roleForRoot(cwdRoot, cwdRoot),
|
|
154
|
+
source: 'cwd',
|
|
155
|
+
status: 'active',
|
|
156
|
+
}];
|
|
157
|
+
|
|
158
|
+
for (const raw of [...extractQuotedPaths(instruction), ...extractPastedPaths(instruction)]) {
|
|
159
|
+
const root = nearestProjectRoot(raw);
|
|
160
|
+
if (!root) continue;
|
|
161
|
+
roots.push({
|
|
162
|
+
path: root,
|
|
163
|
+
role: roleForRoot(root, cwdRoot),
|
|
164
|
+
source: 'prompt',
|
|
165
|
+
status: 'active',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const resource of sortedResources(projectResources)) {
|
|
170
|
+
roots.push({
|
|
171
|
+
path: resource.root,
|
|
172
|
+
role: roleForRoot(resource.root, cwdRoot),
|
|
173
|
+
source: 'registered',
|
|
174
|
+
status: 'active',
|
|
175
|
+
project_id: resource.project_id || undefined,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const activeRoots = uniqueRoots(roots);
|
|
180
|
+
const resources = sortedResources(projectResources);
|
|
181
|
+
const scope = {
|
|
182
|
+
schema: SCHEMA,
|
|
183
|
+
primary_root: cwdRoot,
|
|
184
|
+
intent: truncate(instruction),
|
|
185
|
+
active_roots: activeRoots,
|
|
186
|
+
candidate_roots: [],
|
|
187
|
+
workspace_resources: resources,
|
|
188
|
+
cache_policy: {
|
|
189
|
+
stable_system: false,
|
|
190
|
+
placement: 'pinned_context',
|
|
191
|
+
reason: 'scope changes with user intent and discovered roots',
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
scope.version = sha({
|
|
195
|
+
schema: scope.schema,
|
|
196
|
+
primary_root: scope.primary_root,
|
|
197
|
+
intent: scope.intent,
|
|
198
|
+
active_roots: scope.active_roots,
|
|
199
|
+
workspace_resources: scope.workspace_resources,
|
|
200
|
+
});
|
|
201
|
+
return scope;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function summarizeWorkScope(scope) {
|
|
205
|
+
if (!scope || typeof scope !== 'object') return '';
|
|
206
|
+
const roots = Array.isArray(scope.active_roots) ? scope.active_roots : [];
|
|
207
|
+
const lines = [
|
|
208
|
+
`Work scope ${scope.version || 'unknown'}`,
|
|
209
|
+
`Primary: ${scope.primary_root || '(unknown)'}`,
|
|
210
|
+
];
|
|
211
|
+
if (scope.intent) lines.push(`Intent: ${scope.intent}`);
|
|
212
|
+
for (const root of roots) {
|
|
213
|
+
if (!root?.path) continue;
|
|
214
|
+
lines.push(`- ${root.path} [${root.role || 'workspace'}; ${root.source || 'unknown'}]`);
|
|
215
|
+
}
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
|
@@ -30,6 +30,7 @@ import { URL } from 'node:url';
|
|
|
30
30
|
import { paint } from '../ui/palette.mjs';
|
|
31
31
|
import { icons } from '../ui/icons.mjs';
|
|
32
32
|
import { term } from '../ui/term.mjs';
|
|
33
|
+
import { formatMessageWindow, lowWindowStatus } from '../core/rate-limit-display.mjs';
|
|
33
34
|
|
|
34
35
|
const OK = (s) => `${paint.state.success('[✓]')} ${s}`;
|
|
35
36
|
const WARN = (s) => `${paint.state.warn('[⚠]')} ${s}`;
|
|
@@ -37,18 +38,22 @@ const FAIL = (s) => `${paint.state.danger('[✗]')} ${s}`;
|
|
|
37
38
|
|
|
38
39
|
// ── Individual checks (each returns { status, label, hint? }) ──────────
|
|
39
40
|
|
|
40
|
-
async function checkAuthAndBackend(auth, { timeoutMs
|
|
41
|
+
export async function checkAuthAndBackend(auth, { timeoutMs } = {}) {
|
|
41
42
|
const creds = auth.loadCredentials();
|
|
42
43
|
const hasToken = !!creds.token;
|
|
43
44
|
const url = creds.backendUrl;
|
|
45
|
+
// Local Docker backends round-trip Supabase and often take 2–4s. Give them
|
|
46
|
+
// more headroom so preflight doesn't falsely report Offline.
|
|
47
|
+
const isLocal = /^https?:\/\/(127\.0\.0\.1|localhost)(:|$|\/)/i.test(url || '');
|
|
48
|
+
timeoutMs = timeoutMs ?? (isLocal ? 8000 : 2500);
|
|
44
49
|
|
|
45
50
|
// No token: just probe whether the backend is reachable so we can hint
|
|
46
51
|
// /login when it makes sense.
|
|
47
52
|
if (!hasToken) {
|
|
48
53
|
const reachable = url ? await ping(url, timeoutMs).catch(() => false) : false;
|
|
49
54
|
return reachable
|
|
50
|
-
? { status: 'warn', label: '
|
|
51
|
-
: { status: 'warn', label: '
|
|
55
|
+
? { status: 'warn', label: 'Online', hint: '/login to sign in' }
|
|
56
|
+
: { status: 'warn', label: 'Offline' };
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
// Token present: real authenticated round-trip against /api/user/me.
|
|
@@ -66,15 +71,14 @@ async function checkAuthAndBackend(auth, { timeoutMs = 2500 } = {}) {
|
|
|
66
71
|
|
|
67
72
|
if (resp.ok) {
|
|
68
73
|
const user = await resp.json().catch(() => null);
|
|
69
|
-
|
|
70
|
-
return { status: 'ok', label: `Signed in as ${who} · connected`, user };
|
|
74
|
+
return { status: 'ok', label: 'Online', user };
|
|
71
75
|
}
|
|
72
76
|
if (resp.status === 401 || resp.status === 403) {
|
|
73
|
-
return { status: 'warn', label: '
|
|
77
|
+
return { status: 'warn', label: 'Online', hint: '/login again to refresh' };
|
|
74
78
|
}
|
|
75
|
-
return { status: 'warn', label:
|
|
79
|
+
return { status: 'warn', label: 'Offline' };
|
|
76
80
|
} catch {
|
|
77
|
-
return { status: 'warn', label: '
|
|
81
|
+
return { status: 'warn', label: 'Offline' };
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
84
|
|
|
@@ -86,7 +90,7 @@ async function checkAuthAndBackend(auth, { timeoutMs = 2500 } = {}) {
|
|
|
86
90
|
* { status, label } — shown as a preflight row
|
|
87
91
|
* null — silent (e.g. BYOK or no signal)
|
|
88
92
|
*/
|
|
89
|
-
async function checkCreditsAndPlan(auth, { timeoutMs = 2000 } = {}) {
|
|
93
|
+
export async function checkCreditsAndPlan(auth, { timeoutMs = 2000 } = {}) {
|
|
90
94
|
const creds = auth.loadCredentials();
|
|
91
95
|
if (!creds.token || !creds.backendUrl) return null;
|
|
92
96
|
try {
|
|
@@ -103,10 +107,22 @@ async function checkCreditsAndPlan(auth, { timeoutMs = 2000 } = {}) {
|
|
|
103
107
|
const data = await resp.json().catch(() => null);
|
|
104
108
|
if (!data) return null;
|
|
105
109
|
|
|
110
|
+
const tier = (data.tier || 'free').toUpperCase();
|
|
111
|
+
const windowLabel = formatMessageWindow(data.rate_limit);
|
|
112
|
+
if (windowLabel) {
|
|
113
|
+
const status = lowWindowStatus(data.rate_limit);
|
|
114
|
+
if (status === 'exhausted') {
|
|
115
|
+
return { status: 'fail', label: windowLabel, hint: 'message window exhausted — try again after reset' };
|
|
116
|
+
}
|
|
117
|
+
if (status === 'low') {
|
|
118
|
+
return { status: 'warn', label: windowLabel, hint: 'low message window — codekepler.ai/pricing' };
|
|
119
|
+
}
|
|
120
|
+
return { status: 'ok', label: windowLabel };
|
|
121
|
+
}
|
|
122
|
+
|
|
106
123
|
if (data.byok_enabled) {
|
|
107
|
-
return { status: 'ok', label: `Plan: ${
|
|
124
|
+
return { status: 'ok', label: `Plan: ${tier || 'BYOK'} · billed by your provider` };
|
|
108
125
|
}
|
|
109
|
-
const tier = (data.tier || 'free').toUpperCase();
|
|
110
126
|
const remaining = data.balance?.total;
|
|
111
127
|
if (typeof remaining !== 'number') {
|
|
112
128
|
return { status: 'ok', label: `Plan: ${tier}` };
|