@axplusb/kepler 2.0.6 → 2.2.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/package.json +2 -2
- 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/approval-log.mjs +104 -0
- package/src/core/approval.mjs +164 -24
- package/src/core/backend-url.mjs +2 -2
- package/src/core/context-envelope.mjs +54 -0
- package/src/core/headless.mjs +14 -1
- package/src/core/jsonl-writer.mjs +50 -0
- 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 +130 -0
- package/src/core/tool-executor.mjs +72 -6
- package/src/core/trust.mjs +158 -0
- package/src/onboarding/preflight.mjs +28 -12
- package/src/permissions/command-classifier.mjs +78 -0
- package/src/terminal/init.mjs +145 -0
- package/src/terminal/main.mjs +7 -0
- package/src/terminal/repl.mjs +1735 -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/banner.mjs +0 -17
- package/src/ui/formatter.mjs +40 -6
- package/src/ui/mission-report.mjs +55 -22
- package/src/ui/slash-commands.mjs +57 -5
- package/src/ui/tool-card.mjs +51 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_POLICY = Object.freeze({
|
|
6
|
+
version: 1,
|
|
7
|
+
context: {
|
|
8
|
+
loadEveryTurn: ['KEPLER.md', 'project.md', 'style.md', 'goal.md', 'plan.md', 'tasks/*.md'],
|
|
9
|
+
showReloadNotice: true,
|
|
10
|
+
injectCommandOptions: true,
|
|
11
|
+
injectActionableTips: true,
|
|
12
|
+
tipTtlTurns: 2,
|
|
13
|
+
},
|
|
14
|
+
planning: {
|
|
15
|
+
owner: 'auto',
|
|
16
|
+
allowedOwners: ['main_agent', 'planner_subagent', 'auto', 'manual'],
|
|
17
|
+
delegateWhen: {
|
|
18
|
+
taskCountGte: 4,
|
|
19
|
+
estimatedFilesGte: 6,
|
|
20
|
+
touchesMultiplePackages: true,
|
|
21
|
+
},
|
|
22
|
+
onUserEditedPlan: 'prefer_user_plan',
|
|
23
|
+
},
|
|
24
|
+
tasks: {
|
|
25
|
+
storage: 'project_markdown',
|
|
26
|
+
syncTodoWrite: true,
|
|
27
|
+
resumePrompt: true,
|
|
28
|
+
},
|
|
29
|
+
commands: {
|
|
30
|
+
enabled: ['map', 'probe', 'footprint', 'heal', 'align', 'distill', 'brief', 'rewind'],
|
|
31
|
+
defaultMode: 'interactive',
|
|
32
|
+
dryRunDefault: false,
|
|
33
|
+
suggestions: true,
|
|
34
|
+
suggestOnlyWhenActionable: true,
|
|
35
|
+
timeouts: {
|
|
36
|
+
defaultSeconds: 300,
|
|
37
|
+
healSeconds: 600,
|
|
38
|
+
probeSeconds: 180,
|
|
39
|
+
briefSeconds: 60,
|
|
40
|
+
},
|
|
41
|
+
aliases: {
|
|
42
|
+
fix: 'heal',
|
|
43
|
+
repair: 'heal',
|
|
44
|
+
search: 'probe',
|
|
45
|
+
summarize: 'brief',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
hitl: {
|
|
49
|
+
defaultScope: 'once',
|
|
50
|
+
allowSessionTrust: true,
|
|
51
|
+
allowProjectTrust: false,
|
|
52
|
+
reaskAfterMinutes: 30,
|
|
53
|
+
reaskOnCommandShapeChange: true,
|
|
54
|
+
reaskOnRiskIncrease: true,
|
|
55
|
+
reaskOnPathBoundaryChange: true,
|
|
56
|
+
alwaysAskForDangerous: true,
|
|
57
|
+
},
|
|
58
|
+
hooks: {
|
|
59
|
+
timeoutSeconds: 5,
|
|
60
|
+
},
|
|
61
|
+
ui: {
|
|
62
|
+
recentActions: true,
|
|
63
|
+
verbosity: 'normal',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export function deepClone(value) {
|
|
68
|
+
return JSON.parse(JSON.stringify(value));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function deepMerge(target, source) {
|
|
72
|
+
if (!source || typeof source !== 'object') return deepClone(target);
|
|
73
|
+
const result = Array.isArray(target) ? [...target] : { ...(target || {}) };
|
|
74
|
+
for (const [key, value] of Object.entries(source)) {
|
|
75
|
+
if (
|
|
76
|
+
value &&
|
|
77
|
+
typeof value === 'object' &&
|
|
78
|
+
!Array.isArray(value) &&
|
|
79
|
+
result[key] &&
|
|
80
|
+
typeof result[key] === 'object' &&
|
|
81
|
+
!Array.isArray(result[key])
|
|
82
|
+
) {
|
|
83
|
+
result[key] = deepMerge(result[key], value);
|
|
84
|
+
} else {
|
|
85
|
+
result[key] = deepClone(value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readJson(filePath) {
|
|
92
|
+
try {
|
|
93
|
+
if (!fs.existsSync(filePath)) return null;
|
|
94
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return { __error: err.message };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function projectConfigPath(cwd) {
|
|
101
|
+
return path.join(cwd, '.kepler', 'config.json');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function globalPolicyPath() {
|
|
105
|
+
return path.join(os.homedir(), '.kepler', 'policy.json');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function flatten(obj, prefix = '', out = []) {
|
|
109
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
110
|
+
const dotted = prefix ? `${prefix}.${key}` : key;
|
|
111
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) flatten(value, dotted, out);
|
|
112
|
+
else out.push([dotted, value]);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function loadEffectivePolicy({ cwd = process.cwd(), cli = {}, session = {} } = {}) {
|
|
118
|
+
const layers = [
|
|
119
|
+
{ name: 'default', path: null, data: deepClone(DEFAULT_POLICY) },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const global = readJson(globalPolicyPath());
|
|
123
|
+
if (global && !global.__error) layers.push({ name: 'global', path: globalPolicyPath(), data: global });
|
|
124
|
+
else if (global?.__error) layers.push({ name: 'global', path: globalPolicyPath(), error: global.__error, data: {} });
|
|
125
|
+
|
|
126
|
+
const project = readJson(projectConfigPath(cwd));
|
|
127
|
+
if (project && !project.__error) layers.push({ name: 'project', path: projectConfigPath(cwd), data: project });
|
|
128
|
+
else if (project?.__error) layers.push({ name: 'project', path: projectConfigPath(cwd), error: project.__error, data: {} });
|
|
129
|
+
|
|
130
|
+
if (session && Object.keys(session).length > 0) layers.push({ name: 'session', path: null, data: session });
|
|
131
|
+
if (cli && Object.keys(cli).length > 0) layers.push({ name: 'cli', path: null, data: cli });
|
|
132
|
+
|
|
133
|
+
let policy = {};
|
|
134
|
+
const sources = {};
|
|
135
|
+
for (const layer of layers) {
|
|
136
|
+
policy = deepMerge(policy, layer.data || {});
|
|
137
|
+
for (const [key, value] of flatten(layer.data || {})) {
|
|
138
|
+
sources[key] = { source: layer.name, path: layer.path, value };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { policy, sources, layers };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function formatPolicySourceRows(effective) {
|
|
146
|
+
const rows = [];
|
|
147
|
+
for (const [key, meta] of Object.entries(effective?.sources || {}).sort()) {
|
|
148
|
+
rows.push({
|
|
149
|
+
key,
|
|
150
|
+
source: meta.source,
|
|
151
|
+
path: meta.path || '',
|
|
152
|
+
value: meta.value,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return rows;
|
|
156
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
import { loadKeplerMemory } from '../config/memory-loader.mjs';
|
|
6
|
+
|
|
7
|
+
function sha(content) {
|
|
8
|
+
return crypto.createHash('sha256').update(content || '').digest('hex').slice(0, 12);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readFile(filePath, label, maxChars = 12000) {
|
|
12
|
+
try {
|
|
13
|
+
if (!fs.existsSync(filePath)) return null;
|
|
14
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
15
|
+
return {
|
|
16
|
+
label,
|
|
17
|
+
path: filePath,
|
|
18
|
+
hash: sha(content),
|
|
19
|
+
bytes: Buffer.byteLength(content),
|
|
20
|
+
content: content.length > maxChars
|
|
21
|
+
? content.slice(0, maxChars) + '\n\n[...truncated...]'
|
|
22
|
+
: content,
|
|
23
|
+
truncated: content.length > maxChars,
|
|
24
|
+
};
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return { label, path: filePath, error: err.message, content: '' };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readTasks(keplerDir) {
|
|
31
|
+
const tasksDir = path.join(keplerDir, 'tasks');
|
|
32
|
+
const files = [];
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.existsSync(tasksDir)) return files;
|
|
35
|
+
for (const name of fs.readdirSync(tasksDir).sort()) {
|
|
36
|
+
if (!name.endsWith('.md')) continue;
|
|
37
|
+
const file = readFile(path.join(tasksDir, name), `tasks/${name}`, 6000);
|
|
38
|
+
if (file) files.push(file);
|
|
39
|
+
}
|
|
40
|
+
} catch { /* best effort */ }
|
|
41
|
+
return files;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function scanSkills(dir, scope) {
|
|
45
|
+
const skillsDir = path.join(dir, 'skills');
|
|
46
|
+
const out = [];
|
|
47
|
+
try {
|
|
48
|
+
if (!fs.existsSync(skillsDir)) return out;
|
|
49
|
+
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
50
|
+
let filePath = null;
|
|
51
|
+
let name = entry.name;
|
|
52
|
+
if (entry.isDirectory()) filePath = path.join(skillsDir, entry.name, 'SKILL.md');
|
|
53
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
54
|
+
filePath = path.join(skillsDir, entry.name);
|
|
55
|
+
name = entry.name.replace(/\.md$/, '');
|
|
56
|
+
}
|
|
57
|
+
if (!filePath || !fs.existsSync(filePath)) continue;
|
|
58
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
59
|
+
const desc = raw.match(/^description:\s*(.+)$/mi)?.[1]?.trim()
|
|
60
|
+
|| raw.match(/^#\s+.+\n+([^\n]+)/)?.[1]?.trim()
|
|
61
|
+
|| '';
|
|
62
|
+
out.push({ name, description: desc, scope, source_id: `${scope}:${name}`, path: filePath });
|
|
63
|
+
}
|
|
64
|
+
} catch { /* best effort */ }
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function loadProjectContext({ cwd = process.cwd(), previous = null } = {}) {
|
|
69
|
+
const keplerDir = path.join(cwd, '.kepler');
|
|
70
|
+
const files = [];
|
|
71
|
+
for (const file of loadKeplerMemory({ cwd })) {
|
|
72
|
+
const label = file.path.endsWith(path.join('.kepler', 'KEPLER.md'))
|
|
73
|
+
? 'KEPLER.md'
|
|
74
|
+
: path.basename(file.path);
|
|
75
|
+
files.push({
|
|
76
|
+
label,
|
|
77
|
+
source: file.source,
|
|
78
|
+
path: file.path,
|
|
79
|
+
hash: sha(file.content),
|
|
80
|
+
bytes: Buffer.byteLength(file.content),
|
|
81
|
+
content: file.content,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const name of ['config.json', 'project.md', 'style.md', 'goal.md', 'plan.md', 'hitl.md']) {
|
|
86
|
+
const file = readFile(path.join(keplerDir, name), name, name.endsWith('.json') ? 4000 : 12000);
|
|
87
|
+
if (file) files.push(file);
|
|
88
|
+
}
|
|
89
|
+
files.push(...readTasks(keplerDir));
|
|
90
|
+
|
|
91
|
+
const previousHashes = new Map((previous?.files || []).map(f => [f.path, f.hash]));
|
|
92
|
+
const changed = files.filter(f => f.hash && previousHashes.get(f.path) && previousHashes.get(f.path) !== f.hash);
|
|
93
|
+
const loaded = files.filter(f => !f.error).map(f => ({
|
|
94
|
+
label: f.label,
|
|
95
|
+
path: f.path,
|
|
96
|
+
hash: f.hash,
|
|
97
|
+
bytes: f.bytes,
|
|
98
|
+
source: f.source || 'project',
|
|
99
|
+
included: true,
|
|
100
|
+
changed: changed.some(c => c.path === f.path),
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
const skills = [
|
|
104
|
+
...scanSkills(path.join(os.homedir(), '.kepler'), 'global'),
|
|
105
|
+
...scanSkills(keplerDir, 'project'),
|
|
106
|
+
];
|
|
107
|
+
const byName = new Map();
|
|
108
|
+
for (const skill of skills) byName.set(skill.name, skill);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
root: cwd,
|
|
112
|
+
kepler_dir: keplerDir,
|
|
113
|
+
files,
|
|
114
|
+
loaded,
|
|
115
|
+
changed,
|
|
116
|
+
available_skills: [...byName.values()],
|
|
117
|
+
task_state: files
|
|
118
|
+
.filter(f => f.label.startsWith('tasks/'))
|
|
119
|
+
.map(f => ({ name: f.label.replace('tasks/', ''), hash: f.hash, content: f.content })),
|
|
120
|
+
loaded_at: new Date().toISOString(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function contextToPromptBlock(context) {
|
|
125
|
+
const parts = [];
|
|
126
|
+
if (context?.root) {
|
|
127
|
+
parts.push([
|
|
128
|
+
'--- task workflow ---',
|
|
129
|
+
'Keep .kepler/tasks/active.md current for the work in progress.',
|
|
130
|
+
'Use .kepler/tasks/backlog.md for deferred work, .kepler/tasks/blocked.md for items waiting on input, and .kepler/tasks/done.md for completed work.',
|
|
131
|
+
'When the task state changes materially, update the appropriate task markdown file before the turn finishes.',
|
|
132
|
+
].join('\n'));
|
|
133
|
+
}
|
|
134
|
+
for (const file of context?.files || []) {
|
|
135
|
+
if (!file.content || file.label === 'config.json') continue;
|
|
136
|
+
parts.push(`--- ${file.label} (${file.hash}) ---\n${file.content}`);
|
|
137
|
+
}
|
|
138
|
+
return parts.join('\n\n');
|
|
139
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing helpers for PRD-065 rolling message windows.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function normalizeRateLimit(rateLimit) {
|
|
6
|
+
if (!rateLimit || typeof rateLimit !== 'object') return null;
|
|
7
|
+
|
|
8
|
+
const used = numberOrNull(rateLimit.msgs_used_in_window);
|
|
9
|
+
const limit = numberOrNull(rateLimit.msgs_per_window);
|
|
10
|
+
const configured = numberOrNull(rateLimit.configured_msgs_per_window);
|
|
11
|
+
const retryAfter = numberOrNull(rateLimit.retry_after ?? rateLimit.retry_after_seconds);
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
...rateLimit,
|
|
15
|
+
msgs_used_in_window: used,
|
|
16
|
+
msgs_per_window: limit,
|
|
17
|
+
configured_msgs_per_window: configured,
|
|
18
|
+
retry_after: retryAfter,
|
|
19
|
+
unlimited: Boolean(rateLimit.unlimited),
|
|
20
|
+
byok: Boolean(rateLimit.byok),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function messagesRemaining(rateLimit) {
|
|
25
|
+
const rl = normalizeRateLimit(rateLimit);
|
|
26
|
+
if (!rl) return null;
|
|
27
|
+
if (rl.unlimited || rl.msgs_per_window === -1) return Infinity;
|
|
28
|
+
if (typeof rl.msgs_used_in_window !== 'number' || typeof rl.msgs_per_window !== 'number') return null;
|
|
29
|
+
return Math.max(0, rl.msgs_per_window - rl.msgs_used_in_window);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatRetryAfter(seconds) {
|
|
33
|
+
const secs = Math.max(0, Math.ceil(Number(seconds) || 0));
|
|
34
|
+
if (secs <= 0) return 'soon';
|
|
35
|
+
const hours = Math.floor(secs / 3600);
|
|
36
|
+
const minutes = Math.ceil((secs % 3600) / 60);
|
|
37
|
+
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
|
|
38
|
+
if (hours > 0) return `${hours}h`;
|
|
39
|
+
if (minutes > 0) return `${minutes}m`;
|
|
40
|
+
return `${secs}s`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resetLabel(rateLimit, now = Date.now()) {
|
|
44
|
+
const rl = normalizeRateLimit(rateLimit);
|
|
45
|
+
if (!rl?.window_reset_at) return null;
|
|
46
|
+
const resetMs = Date.parse(rl.window_reset_at);
|
|
47
|
+
if (!Number.isFinite(resetMs)) return null;
|
|
48
|
+
return formatRetryAfter(Math.max(0, (resetMs - now) / 1000));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatMessageWindow(rateLimit, { includeReset = true } = {}) {
|
|
52
|
+
const rl = normalizeRateLimit(rateLimit);
|
|
53
|
+
if (!rl) return null;
|
|
54
|
+
|
|
55
|
+
const tier = rl.tier ? String(rl.tier).toUpperCase() : null;
|
|
56
|
+
if (rl.byok || rl.unlimited || rl.msgs_per_window === -1) {
|
|
57
|
+
return [tier, 'unlimited messages'].filter(Boolean).join(' · ');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (typeof rl.msgs_used_in_window !== 'number' || typeof rl.msgs_per_window !== 'number') {
|
|
61
|
+
return tier || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const remaining = Math.max(0, rl.msgs_per_window - rl.msgs_used_in_window);
|
|
65
|
+
const parts = [
|
|
66
|
+
tier,
|
|
67
|
+
`${remaining} / ${rl.msgs_per_window} messages this window`,
|
|
68
|
+
].filter(Boolean);
|
|
69
|
+
|
|
70
|
+
const reset = includeReset ? resetLabel(rl) : null;
|
|
71
|
+
if (reset) parts.push(`resets in ${reset}`);
|
|
72
|
+
return parts.join(' · ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function lowWindowStatus(rateLimit) {
|
|
76
|
+
const rl = normalizeRateLimit(rateLimit);
|
|
77
|
+
if (!rl || rl.byok || rl.unlimited || rl.msgs_per_window === -1) return 'ok';
|
|
78
|
+
const remaining = messagesRemaining(rl);
|
|
79
|
+
if (typeof remaining !== 'number' || typeof rl.msgs_per_window !== 'number') return 'ok';
|
|
80
|
+
if (remaining <= 0) return 'exhausted';
|
|
81
|
+
if (remaining <= Math.max(5, Math.floor(rl.msgs_per_window * 0.2))) return 'low';
|
|
82
|
+
return 'ok';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function rateLimitErrorMessage(payload, fallback = 'Message limit reached.') {
|
|
86
|
+
const detail = payload?.detail && typeof payload.detail === 'object' ? payload.detail : payload;
|
|
87
|
+
const retryAfter = detail?.retry_after ?? detail?.rate_limit?.retry_after ?? detail?.rate_limit?.retry_after_seconds;
|
|
88
|
+
if (detail?.message) return detail.message;
|
|
89
|
+
if (retryAfter != null) return `Message limit reached — try again in ${formatRetryAfter(retryAfter)}.`;
|
|
90
|
+
return fallback;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function numberOrNull(value) {
|
|
94
|
+
if (value == null) return null;
|
|
95
|
+
const n = Number(value);
|
|
96
|
+
return Number.isFinite(n) ? n : null;
|
|
97
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume Mode — decide how much of a transcript to send to the agent (PRD-068 §5.14.3).
|
|
3
|
+
*
|
|
4
|
+
* Rule of thumb: if the projected context (transcript + system prompt overhead)
|
|
5
|
+
* fits comfortably in the current model's window, resume with `full` silently.
|
|
6
|
+
* Only ask the user when we're above the highWatermark.
|
|
7
|
+
*
|
|
8
|
+
* Config lives in .kepler/settings.json:
|
|
9
|
+
* {
|
|
10
|
+
* "resume": {
|
|
11
|
+
* "highWatermark": 0.50, // prompt above this (default 50%)
|
|
12
|
+
* "hardCap": 0.85 // refuse full above this (default 85%)
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// System overhead — tools, memory, .kepler/ context, kepler.md, skills index.
|
|
18
|
+
// Rough constant; individual runs vary. Tuned toward "assume ~4k of overhead".
|
|
19
|
+
const DEFAULT_SYSTEM_OVERHEAD_TOKENS = 4000;
|
|
20
|
+
|
|
21
|
+
// Model context windows we know about. Fallback used when unknown.
|
|
22
|
+
// Keep this table small — it's a hint, not a source of truth.
|
|
23
|
+
const MODEL_CONTEXT_WINDOWS = {
|
|
24
|
+
'anthropic/claude-sonnet-4': 200000,
|
|
25
|
+
'anthropic/claude-4-sonnet-20250522': 200000,
|
|
26
|
+
'anthropic/claude-opus-4': 200000,
|
|
27
|
+
'deepseek/deepseek-v4-flash': 128000,
|
|
28
|
+
'deepseek/deepseek-v4-pro': 128000,
|
|
29
|
+
'deepseek/deepseek-chat-v3-0324': 128000,
|
|
30
|
+
'openai/gpt-5': 400000,
|
|
31
|
+
'openai/gpt-5-mini': 400000,
|
|
32
|
+
'google/gemini-2.5-pro': 1000000,
|
|
33
|
+
'xiaomi/mimo-v2.5': 128000,
|
|
34
|
+
};
|
|
35
|
+
const DEFAULT_CONTEXT_WINDOW = 128000;
|
|
36
|
+
|
|
37
|
+
export function modelContextWindow(model) {
|
|
38
|
+
if (!model) return DEFAULT_CONTEXT_WINDOW;
|
|
39
|
+
return MODEL_CONTEXT_WINDOWS[model] || DEFAULT_CONTEXT_WINDOW;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decide the resume mode for a session based on projected context usage.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} args
|
|
46
|
+
* @param {number} args.transcriptTokens — projected transcript size when serialized
|
|
47
|
+
* @param {string} [args.model] — current agent model id
|
|
48
|
+
* @param {object} [args.settings] — .kepler/settings.json contents (optional)
|
|
49
|
+
* @param {number} [args.systemOverhead] — override system overhead (defaults to 4k)
|
|
50
|
+
* @returns {{
|
|
51
|
+
* mode: 'full' | 'ask' | 'no-full-allowed',
|
|
52
|
+
* defaultChoice: 'full' | 'tail-20' | 'summary',
|
|
53
|
+
* projected: number, // total projected tokens
|
|
54
|
+
* windowSize: number, // model window
|
|
55
|
+
* usageRatio: number, // projected / windowSize
|
|
56
|
+
* highWatermark: number,
|
|
57
|
+
* hardCap: number,
|
|
58
|
+
* }}
|
|
59
|
+
*
|
|
60
|
+
* `mode = 'full'` — resume immediately in full mode; do not prompt
|
|
61
|
+
* `mode = 'ask'` — show the tri-choice overlay
|
|
62
|
+
* `mode = 'no-full-allowed'` — above hardCap; user must pick a tail mode or summary
|
|
63
|
+
*/
|
|
64
|
+
export function decideResumeMode({
|
|
65
|
+
transcriptTokens,
|
|
66
|
+
model,
|
|
67
|
+
settings,
|
|
68
|
+
systemOverhead = DEFAULT_SYSTEM_OVERHEAD_TOKENS,
|
|
69
|
+
} = {}) {
|
|
70
|
+
const cfg = settings?.resume || {};
|
|
71
|
+
const highWatermark = clampRatio(cfg.highWatermark, 0.50);
|
|
72
|
+
const hardCap = clampRatio(cfg.hardCap, 0.85);
|
|
73
|
+
|
|
74
|
+
const windowSize = modelContextWindow(model);
|
|
75
|
+
const projected = Math.max(0, Number(transcriptTokens) || 0) + systemOverhead;
|
|
76
|
+
const usageRatio = windowSize > 0 ? projected / windowSize : 0;
|
|
77
|
+
|
|
78
|
+
let mode;
|
|
79
|
+
let defaultChoice;
|
|
80
|
+
if (usageRatio > hardCap) {
|
|
81
|
+
mode = 'no-full-allowed';
|
|
82
|
+
// Above hardCap — last 20 turns is usually the least-lossy fit; user picks.
|
|
83
|
+
defaultChoice = 'tail-20';
|
|
84
|
+
} else if (usageRatio > highWatermark) {
|
|
85
|
+
mode = 'ask';
|
|
86
|
+
defaultChoice = 'full';
|
|
87
|
+
} else {
|
|
88
|
+
mode = 'full';
|
|
89
|
+
defaultChoice = 'full';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
mode,
|
|
94
|
+
defaultChoice,
|
|
95
|
+
projected,
|
|
96
|
+
windowSize,
|
|
97
|
+
usageRatio,
|
|
98
|
+
highWatermark,
|
|
99
|
+
hardCap,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Estimate the context tokens for a candidate mode. Used by the tri-choice
|
|
105
|
+
* overlay to render "62k / 14k / 2k" per option before the user commits.
|
|
106
|
+
*
|
|
107
|
+
* @param {'full' | 'summary' | 'tail-10' | 'tail-20' | 'recap+tail'} choice
|
|
108
|
+
* @param {number} fullTokens — projected transcript size in full mode
|
|
109
|
+
* @param {object} [opts]
|
|
110
|
+
* @returns {number} projected tokens for the chosen mode
|
|
111
|
+
*/
|
|
112
|
+
export function projectedTokensForChoice(choice, fullTokens, opts = {}) {
|
|
113
|
+
const {
|
|
114
|
+
tailTurns = null,
|
|
115
|
+
// Rough per-turn estimate. Summary block is ~1-2k.
|
|
116
|
+
tailBaseTokens = 2000,
|
|
117
|
+
perTailTurnTokens = 500,
|
|
118
|
+
summaryBaseTokens = 2000,
|
|
119
|
+
} = opts;
|
|
120
|
+
|
|
121
|
+
switch (choice) {
|
|
122
|
+
case 'full':
|
|
123
|
+
return Math.max(0, Number(fullTokens) || 0);
|
|
124
|
+
case 'tail-10':
|
|
125
|
+
return tailBaseTokens + (10 * perTailTurnTokens);
|
|
126
|
+
case 'tail-20':
|
|
127
|
+
return tailBaseTokens + (20 * perTailTurnTokens);
|
|
128
|
+
case 'recap+tail':
|
|
129
|
+
return tailBaseTokens + ((Number(tailTurns) || 8) * perTailTurnTokens);
|
|
130
|
+
case 'summary':
|
|
131
|
+
return summaryBaseTokens;
|
|
132
|
+
default:
|
|
133
|
+
return Math.max(0, Number(fullTokens) || 0);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function clampRatio(value, fallback) {
|
|
138
|
+
const n = Number(value);
|
|
139
|
+
if (!Number.isFinite(n)) return fallback;
|
|
140
|
+
if (n <= 0) return fallback;
|
|
141
|
+
if (n > 1) return fallback;
|
|
142
|
+
return n;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Format tokens with a k/M suffix — "8k", "62k", "1.2M".
|
|
147
|
+
* Used by the picker + overlay so numbers fit in narrow columns.
|
|
148
|
+
*/
|
|
149
|
+
export function formatTokens(n) {
|
|
150
|
+
const v = Math.round(Number(n) || 0);
|
|
151
|
+
if (v < 1000) return `${v}`;
|
|
152
|
+
if (v < 1_000_000) return `${Math.round(v / 1000)}k`;
|
|
153
|
+
return `${(v / 1_000_000).toFixed(1)}M`;
|
|
154
|
+
}
|