@axplusb/kepler 2.2.0 → 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/core/agent-loop.mjs +8 -2
- package/src/core/cache-control.mjs +92 -0
- package/src/core/headless.mjs +85 -9
- package/src/core/local-agent.mjs +121 -14
- package/src/core/tasks.mjs +67 -1
- package/src/core/work-scope.mjs +217 -0
- package/src/terminal/main.mjs +2 -0
- package/src/terminal/repl.mjs +91 -4
- package/src/ui/commands.mjs +5 -4
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache_control breakpoints for Anthropic prompt caching (PRD-071 Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Shared by every direct-API call site that talks to Anthropic — LocalAgent
|
|
5
|
+
* (Anthropic direct + OpenRouter passthrough) and agent-loop's Task sub-agents.
|
|
6
|
+
*
|
|
7
|
+
* Anthropic allows 4 breakpoints per request; we spend 3:
|
|
8
|
+
* 1) End of system prompt (1h TTL — persistent across long-idle sessions)
|
|
9
|
+
* 2) Last tool schema (1h TTL — persistent)
|
|
10
|
+
* 3) Second-to-last user message (5min TTL — rolls each turn, cheaper to write)
|
|
11
|
+
*
|
|
12
|
+
* The 4th slot stays reserved (attachments, future retrieval prefix).
|
|
13
|
+
*
|
|
14
|
+
* Extended 1-hour TTL is a beta — pass ANTHROPIC_BETA_HEADER on the request
|
|
15
|
+
* whenever any block carries ttl:'1h'.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const ANTHROPIC_BETA_HEADER = 'extended-cache-ttl-2025-04-11';
|
|
19
|
+
|
|
20
|
+
const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
|
|
21
|
+
const CACHE_5M = { type: 'ephemeral' };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Turn a `system` string into a content-block array with a cache_control
|
|
25
|
+
* breakpoint on the tail. If the caller already passed blocks, returns them
|
|
26
|
+
* unchanged. Undefined / non-string inputs pass through as-is.
|
|
27
|
+
*/
|
|
28
|
+
export function cacheableSystem(systemPrompt) {
|
|
29
|
+
if (Array.isArray(systemPrompt)) return systemPrompt;
|
|
30
|
+
if (!systemPrompt || typeof systemPrompt !== 'string') return systemPrompt;
|
|
31
|
+
return [{ type: 'text', text: systemPrompt, cache_control: CACHE_1H }];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return a copy of `tools` with cache_control on the LAST tool. Anthropic
|
|
36
|
+
* caches system + tools as one prefix from that breakpoint, so this single
|
|
37
|
+
* marker covers the whole tool schema regardless of length.
|
|
38
|
+
*/
|
|
39
|
+
export function cacheableTools(tools) {
|
|
40
|
+
if (!Array.isArray(tools) || tools.length === 0) return tools || [];
|
|
41
|
+
const out = tools.slice();
|
|
42
|
+
const last = out[out.length - 1];
|
|
43
|
+
out[out.length - 1] = { ...last, cache_control: CACHE_1H };
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Tag the SECOND-to-last user message with a 5-min cache_control breakpoint.
|
|
49
|
+
* Leaves the last turn write-through so the next round extends the cache
|
|
50
|
+
* instead of re-writing it. Returns messages unchanged when there aren't
|
|
51
|
+
* enough user turns yet (< 2).
|
|
52
|
+
*
|
|
53
|
+
* Handles both content shapes: string (wrapped into blocks) and block array
|
|
54
|
+
* (tagged on the last block).
|
|
55
|
+
*/
|
|
56
|
+
export function withMessageBreakpoint(messages) {
|
|
57
|
+
if (!Array.isArray(messages) || messages.length < 2) return messages;
|
|
58
|
+
const userIdx = [];
|
|
59
|
+
for (let i = 0; i < messages.length; i++) {
|
|
60
|
+
if (messages[i].role === 'user') userIdx.push(i);
|
|
61
|
+
}
|
|
62
|
+
if (userIdx.length < 2) return messages;
|
|
63
|
+
const targetIdx = userIdx[userIdx.length - 2];
|
|
64
|
+
const msg = messages[targetIdx];
|
|
65
|
+
|
|
66
|
+
if (typeof msg.content === 'string') {
|
|
67
|
+
return messages.map((m, i) => i === targetIdx ? {
|
|
68
|
+
...m,
|
|
69
|
+
content: [{ type: 'text', text: m.content, cache_control: CACHE_5M }],
|
|
70
|
+
} : m);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(msg.content) && msg.content.length > 0) {
|
|
74
|
+
const blocks = msg.content.slice();
|
|
75
|
+
const last = blocks[blocks.length - 1];
|
|
76
|
+
blocks[blocks.length - 1] = { ...last, cache_control: CACHE_5M };
|
|
77
|
+
return messages.map((m, i) => i === targetIdx ? { ...m, content: blocks } : m);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return messages;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* True if the given model id needs Anthropic-style explicit cache_control
|
|
85
|
+
* (vs OpenAI/DeepSeek which auto-cache). Handles bare Claude ids and
|
|
86
|
+
* OpenRouter's `anthropic/*` prefix.
|
|
87
|
+
*/
|
|
88
|
+
export function needsExplicitCacheControl(model) {
|
|
89
|
+
if (!model) return false;
|
|
90
|
+
const m = model.toLowerCase();
|
|
91
|
+
return m.startsWith('claude') || m.startsWith('anthropic/');
|
|
92
|
+
}
|
package/src/core/headless.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { TarangStreamClient } from './stream-client.mjs';
|
|
15
15
|
import { createToolExecutor } from './tool-executor.mjs';
|
|
16
|
+
import { buildWorkScope } from './work-scope.mjs';
|
|
16
17
|
import { persistProjectArtifacts } from './project-artifacts.mjs';
|
|
17
18
|
import { TarangAuth } from '../auth/tarang-auth.mjs';
|
|
18
19
|
import { ApprovalManager } from './approval.mjs';
|
|
@@ -26,7 +27,7 @@ import { ApprovalManager } from './approval.mjs';
|
|
|
26
27
|
* @param {number} [opts.maxCost] - abort if cost exceeds this USD amount
|
|
27
28
|
* @param {boolean} [opts.verbose] - show progress on stderr
|
|
28
29
|
*/
|
|
29
|
-
export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false }) {
|
|
30
|
+
export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false, cacheReport = null, local = false }) {
|
|
30
31
|
const startTime = Date.now();
|
|
31
32
|
|
|
32
33
|
const log = (msg) => {
|
|
@@ -51,13 +52,41 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
51
52
|
// Auto-approve everything — no prompts
|
|
52
53
|
const approval = new ApprovalManager({ autoApprove: true });
|
|
53
54
|
|
|
54
|
-
// ──
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
// ── Client selection ──
|
|
56
|
+
// PRD-071 Phase 2 measurement — --local forces the CLI-side LocalAgent path,
|
|
57
|
+
// bypassing the backend so we can exercise the cache_control wiring we
|
|
58
|
+
// just added to _callClaude / _callOpenRouter. Model comes from the
|
|
59
|
+
// --model flag (which overrides settings dynamically for benchmarking).
|
|
60
|
+
let client;
|
|
61
|
+
if (local) {
|
|
62
|
+
const { LocalAgent } = await import('./local-agent.mjs');
|
|
63
|
+
const localModel = model || creds.models?.local || 'anthropic/claude-sonnet-4';
|
|
64
|
+
const orKey = process.env.OPENROUTER_API_KEY || creds.openRouterKey;
|
|
65
|
+
const anthKey = process.env.ANTHROPIC_API_KEY || creds.anthropicKey;
|
|
66
|
+
if (!orKey && !anthKey) {
|
|
67
|
+
emit({ type: 'error', error: '--local requires OPENROUTER_API_KEY or ANTHROPIC_API_KEY' });
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
client = {
|
|
71
|
+
execute: (instr, ctx) => new LocalAgent({
|
|
72
|
+
apiKey: anthKey,
|
|
73
|
+
openRouterKey: orKey,
|
|
74
|
+
model: localModel,
|
|
75
|
+
toolExecutor,
|
|
76
|
+
verbose,
|
|
77
|
+
cwd: process.cwd(),
|
|
78
|
+
maxTurns: 50,
|
|
79
|
+
}).execute(instr, ctx),
|
|
80
|
+
};
|
|
81
|
+
log(`Local mode: ${localModel}`);
|
|
82
|
+
} else {
|
|
83
|
+
client = new TarangStreamClient({
|
|
84
|
+
baseUrl: creds.backendUrl,
|
|
85
|
+
token: creds.token,
|
|
86
|
+
toolExecutor,
|
|
87
|
+
approvalManager: approval,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
61
90
|
|
|
62
91
|
// ── Timeout ──
|
|
63
92
|
const timeoutMs = timeout * 1000;
|
|
@@ -70,10 +99,16 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
70
99
|
// ── Execute ──
|
|
71
100
|
emit({ type: 'start', timestamp: Date.now(), instruction, model: model || 'default', cwd: process.cwd() });
|
|
72
101
|
|
|
102
|
+
const projectResources = toolExecutor.getProjectResources();
|
|
73
103
|
const execContext = {
|
|
74
104
|
cwd: process.cwd(),
|
|
75
105
|
freeswim: true,
|
|
76
|
-
project_resources:
|
|
106
|
+
project_resources: projectResources,
|
|
107
|
+
work_scope: buildWorkScope({
|
|
108
|
+
instruction,
|
|
109
|
+
cwd: process.cwd(),
|
|
110
|
+
projectResources,
|
|
111
|
+
}),
|
|
77
112
|
agent_context: toolExecutor.getAgentContext(),
|
|
78
113
|
};
|
|
79
114
|
if (model) execContext.model_override = model;
|
|
@@ -219,6 +254,47 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
219
254
|
content_length: finalContent.length,
|
|
220
255
|
});
|
|
221
256
|
|
|
257
|
+
// PRD-071 §1.5 — cache summary for benchmark harness. Machine-readable,
|
|
258
|
+
// one file per run. Fields match what benchmark/cache-check.sh already
|
|
259
|
+
// computes (input, cache_read, cache_write, rate) so the shell script
|
|
260
|
+
// becomes a thin reader instead of re-doing the arithmetic.
|
|
261
|
+
if (cacheReport && usage) {
|
|
262
|
+
const cacheRead = usage.cache_read || 0;
|
|
263
|
+
const cacheWrite = usage.cache_write || 0;
|
|
264
|
+
const inputT = usage.input_tokens || 0;
|
|
265
|
+
// Two conventions in the wild:
|
|
266
|
+
// OpenAI/DeepSeek: input_tokens INCLUDES cached tokens
|
|
267
|
+
// → hit_rate = cache_read / input_tokens
|
|
268
|
+
// Anthropic: input_tokens EXCLUDES cache reads AND writes
|
|
269
|
+
// → hit_rate = cache_read / (input + cache_read + cache_write)
|
|
270
|
+
// Report both. Also expose `cache_hit_rate_pct` as the "sane" number
|
|
271
|
+
// — auto-detects convention by whether cache_read > input_tokens.
|
|
272
|
+
const rateOpenAI = inputT > 0 ? Math.round((cacheRead / inputT) * 100) : 0;
|
|
273
|
+
const anthropicDenom = inputT + cacheRead + cacheWrite;
|
|
274
|
+
const rateAnthropic = anthropicDenom > 0 ? Math.round((cacheRead / anthropicDenom) * 100) : 0;
|
|
275
|
+
const rateAuto = cacheRead > inputT ? rateAnthropic : rateOpenAI;
|
|
276
|
+
const report = {
|
|
277
|
+
schema: 'kepler.cache-report/1',
|
|
278
|
+
model: model || 'default',
|
|
279
|
+
input_tokens: inputT,
|
|
280
|
+
output_tokens: usage.output_tokens || 0,
|
|
281
|
+
cache_read_tokens: cacheRead,
|
|
282
|
+
cache_write_tokens: cacheWrite,
|
|
283
|
+
cache_hit_rate_pct: rateAuto,
|
|
284
|
+
cache_hit_rate_openai_pct: rateOpenAI,
|
|
285
|
+
cache_hit_rate_anthropic_pct: rateAnthropic,
|
|
286
|
+
duration_s: Math.round(durationS * 10) / 10,
|
|
287
|
+
cost_usd: totalCost,
|
|
288
|
+
};
|
|
289
|
+
try {
|
|
290
|
+
const fs = await import('node:fs');
|
|
291
|
+
fs.writeFileSync(cacheReport, JSON.stringify(report, null, 2) + '\n');
|
|
292
|
+
log(`Cache report written: ${cacheReport}`);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
log(`Cache report write failed: ${err.message}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
222
298
|
log(`Done: ${toolCount} tools, ${durationS.toFixed(1)}s, $${totalCost.toFixed(3)}`);
|
|
223
299
|
|
|
224
300
|
// Write final content to stderr so it's human-readable (stdout is JSONL)
|
package/src/core/local-agent.mjs
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { ContextRetriever } from '../context/retriever.mjs';
|
|
8
8
|
import { createStagnationTracker, stagnationMessage } from './stagnation.mjs';
|
|
9
|
+
import { PromptCache } from './cache.mjs';
|
|
10
|
+
import {
|
|
11
|
+
ANTHROPIC_BETA_HEADER,
|
|
12
|
+
cacheableSystem,
|
|
13
|
+
cacheableTools,
|
|
14
|
+
withMessageBreakpoint,
|
|
15
|
+
needsExplicitCacheControl,
|
|
16
|
+
} from './cache-control.mjs';
|
|
9
17
|
|
|
10
18
|
const MAX_ITERATIONS = 50;
|
|
11
19
|
|
|
@@ -209,12 +217,14 @@ export class LocalAgent {
|
|
|
209
217
|
this.stagnationDetection = stagnationDetection;
|
|
210
218
|
this.stagnationThreshold = stagnationThreshold;
|
|
211
219
|
this._cancelled = false;
|
|
220
|
+
this.promptCache = new PromptCache();
|
|
212
221
|
}
|
|
213
222
|
|
|
214
223
|
async *execute(instruction, context = {}) {
|
|
215
224
|
this._cancelled = false;
|
|
216
225
|
const startTime = Date.now();
|
|
217
226
|
let toolCount = 0;
|
|
227
|
+
const usageTotals = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 };
|
|
218
228
|
|
|
219
229
|
yield { type: 'status', data: { message: `Local mode: ${this.model}` } };
|
|
220
230
|
|
|
@@ -252,7 +262,15 @@ export class LocalAgent {
|
|
|
252
262
|
return;
|
|
253
263
|
}
|
|
254
264
|
|
|
255
|
-
const { content, stopReason } = response;
|
|
265
|
+
const { content, stopReason, usage } = response;
|
|
266
|
+
|
|
267
|
+
if (usage) {
|
|
268
|
+
this.promptCache.updateStats(usage);
|
|
269
|
+
usageTotals.input_tokens += usage.input_tokens || 0;
|
|
270
|
+
usageTotals.output_tokens += usage.output_tokens || 0;
|
|
271
|
+
usageTotals.cache_read_tokens += usage.cache_read_input_tokens || 0;
|
|
272
|
+
usageTotals.cache_creation_tokens += usage.cache_creation_input_tokens || 0;
|
|
273
|
+
}
|
|
256
274
|
|
|
257
275
|
// Process content blocks
|
|
258
276
|
let hasToolUse = false;
|
|
@@ -303,13 +321,29 @@ export class LocalAgent {
|
|
|
303
321
|
|
|
304
322
|
if (!hasToolUse || stopReason === 'end_turn') {
|
|
305
323
|
const duration = (Date.now() - startTime) / 1000;
|
|
306
|
-
yield {
|
|
324
|
+
yield {
|
|
325
|
+
type: 'complete',
|
|
326
|
+
data: {
|
|
327
|
+
summary: 'Done (local)',
|
|
328
|
+
changes: toolCount,
|
|
329
|
+
duration_s: duration,
|
|
330
|
+
usage: _buildLocalUsageEnvelope(this.model, usageTotals),
|
|
331
|
+
},
|
|
332
|
+
};
|
|
307
333
|
return;
|
|
308
334
|
}
|
|
309
335
|
}
|
|
310
336
|
|
|
311
337
|
yield { type: 'error', data: { message: `Max turns (${this.maxTurns}) reached.` } };
|
|
312
|
-
yield {
|
|
338
|
+
yield {
|
|
339
|
+
type: 'complete',
|
|
340
|
+
data: {
|
|
341
|
+
summary: 'Aborted (max turns)',
|
|
342
|
+
changes: toolCount,
|
|
343
|
+
duration_s: (Date.now() - startTime) / 1000,
|
|
344
|
+
usage: _buildLocalUsageEnvelope(this.model, usageTotals),
|
|
345
|
+
},
|
|
346
|
+
};
|
|
313
347
|
}
|
|
314
348
|
|
|
315
349
|
async _callLLM(systemPrompt, messages, tools) {
|
|
@@ -333,18 +367,26 @@ export class LocalAgent {
|
|
|
333
367
|
}
|
|
334
368
|
|
|
335
369
|
async _callClaude(systemPrompt, messages, tools) {
|
|
370
|
+
// PRD-071 Phase 2 — cache_control breakpoints for Anthropic direct.
|
|
371
|
+
// Extended 1-hour TTL beta on the persistent prefix (system + tools).
|
|
372
|
+
// Message history breakpoint stays at default 5-min TTL.
|
|
373
|
+
const cachedSystem = cacheableSystem(systemPrompt);
|
|
374
|
+
const cachedTools = cacheableTools(tools);
|
|
375
|
+
const cachedMessages = withMessageBreakpoint(messages);
|
|
376
|
+
|
|
336
377
|
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
|
337
378
|
method: 'POST',
|
|
338
379
|
headers: {
|
|
339
380
|
'x-api-key': this.apiKey,
|
|
340
381
|
'anthropic-version': '2023-06-01',
|
|
382
|
+
'anthropic-beta': ANTHROPIC_BETA_HEADER,
|
|
341
383
|
'content-type': 'application/json',
|
|
342
384
|
},
|
|
343
385
|
body: JSON.stringify({
|
|
344
386
|
model: this.model,
|
|
345
|
-
system:
|
|
346
|
-
messages,
|
|
347
|
-
tools:
|
|
387
|
+
system: cachedSystem,
|
|
388
|
+
messages: cachedMessages,
|
|
389
|
+
tools: cachedTools.length > 0 ? cachedTools : undefined,
|
|
348
390
|
max_tokens: 8192,
|
|
349
391
|
}),
|
|
350
392
|
});
|
|
@@ -353,7 +395,7 @@ export class LocalAgent {
|
|
|
353
395
|
throw new Error(`Claude API ${resp.status}: ${text.slice(0, 200)}`);
|
|
354
396
|
}
|
|
355
397
|
const data = await resp.json();
|
|
356
|
-
return { content: data.content || [], stopReason: data.stop_reason };
|
|
398
|
+
return { content: data.content || [], stopReason: data.stop_reason, usage: data.usage || null };
|
|
357
399
|
}
|
|
358
400
|
|
|
359
401
|
async _callOpenRouter(systemPrompt, messages, tools) {
|
|
@@ -363,17 +405,43 @@ export class LocalAgent {
|
|
|
363
405
|
model = `anthropic/${model}`;
|
|
364
406
|
}
|
|
365
407
|
|
|
366
|
-
|
|
408
|
+
// PRD-071 Phase 2 — for anthropic/* models we need explicit cache_control
|
|
409
|
+
// breakpoints (OpenRouter relays them through to Anthropic). OpenAI +
|
|
410
|
+
// DeepSeek auto-cache, so the string system prompt path is fine there.
|
|
411
|
+
const isAnthropic = needsExplicitCacheControl(model);
|
|
412
|
+
const systemForOR = isAnthropic
|
|
413
|
+
? { role: 'system', content: cacheableSystem(systemPrompt) }
|
|
414
|
+
: { role: 'system', content: systemPrompt };
|
|
415
|
+
const messagesForOR = isAnthropic ? withMessageBreakpoint(messages) : messages;
|
|
416
|
+
const orMessages = [systemForOR, ...messagesForOR];
|
|
417
|
+
|
|
418
|
+
// Same tool-schema shape as before, but tag the last tool for Anthropic
|
|
419
|
+
// (OpenRouter passes cache_control on function tools through).
|
|
420
|
+
const orTools = tools.length > 0
|
|
421
|
+
? tools.map(t => ({
|
|
422
|
+
type: 'function',
|
|
423
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
424
|
+
}))
|
|
425
|
+
: [];
|
|
426
|
+
const finalTools = isAnthropic ? cacheableTools(orTools) : orTools;
|
|
427
|
+
|
|
428
|
+
const headers = {
|
|
429
|
+
'Authorization': `Bearer ${this.openRouterKey}`,
|
|
430
|
+
'Content-Type': 'application/json',
|
|
431
|
+
};
|
|
432
|
+
// OpenRouter forwards `anthropic-beta` to Anthropic upstreams.
|
|
433
|
+
if (isAnthropic) headers['anthropic-beta'] = ANTHROPIC_BETA_HEADER;
|
|
434
|
+
|
|
367
435
|
const resp = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
368
436
|
method: 'POST',
|
|
369
|
-
headers
|
|
370
|
-
'Authorization': `Bearer ${this.openRouterKey}`,
|
|
371
|
-
'Content-Type': 'application/json',
|
|
372
|
-
},
|
|
437
|
+
headers,
|
|
373
438
|
body: JSON.stringify({
|
|
374
439
|
model,
|
|
375
440
|
messages: orMessages,
|
|
376
|
-
tools:
|
|
441
|
+
tools: finalTools.length > 0 ? finalTools : undefined,
|
|
442
|
+
// Ask OpenRouter to include upstream cache accounting in the
|
|
443
|
+
// usage payload so PromptCache.updateStats() sees real numbers.
|
|
444
|
+
usage: { include: true },
|
|
377
445
|
}),
|
|
378
446
|
});
|
|
379
447
|
if (!resp.ok) {
|
|
@@ -389,7 +457,11 @@ export class LocalAgent {
|
|
|
389
457
|
content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments || '{}') });
|
|
390
458
|
}
|
|
391
459
|
}
|
|
392
|
-
return {
|
|
460
|
+
return {
|
|
461
|
+
content,
|
|
462
|
+
stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : 'tool_use',
|
|
463
|
+
usage: _normalizeOpenRouterUsage(data.usage),
|
|
464
|
+
};
|
|
393
465
|
}
|
|
394
466
|
|
|
395
467
|
_buildToolDefs() {
|
|
@@ -427,3 +499,38 @@ export class LocalAgent {
|
|
|
427
499
|
|
|
428
500
|
cancel() { this._cancelled = true; }
|
|
429
501
|
}
|
|
502
|
+
|
|
503
|
+
// Shape the accumulated per-turn totals into the same envelope the remote
|
|
504
|
+
// SSE `complete` event uses, so repl.mjs:837 can consume both modes with the
|
|
505
|
+
// same code path. `models[0].role = 'local'` distinguishes single-agent
|
|
506
|
+
// local mode from remote's Coder/Explorer/Planner breakdown.
|
|
507
|
+
function _buildLocalUsageEnvelope(model, totals) {
|
|
508
|
+
return {
|
|
509
|
+
total_input_tokens: totals.input_tokens,
|
|
510
|
+
total_output_tokens: totals.output_tokens,
|
|
511
|
+
models: [{
|
|
512
|
+
model,
|
|
513
|
+
role: 'local',
|
|
514
|
+
input_tokens: totals.input_tokens,
|
|
515
|
+
output_tokens: totals.output_tokens,
|
|
516
|
+
cache_read_tokens: totals.cache_read_tokens,
|
|
517
|
+
cache_creation_tokens: totals.cache_creation_tokens,
|
|
518
|
+
}],
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Normalize OpenRouter usage into Anthropic's field names so downstream
|
|
523
|
+
// consumers (PromptCache, pricing.calculateCost) don't branch on shape.
|
|
524
|
+
// OpenRouter returns OpenAI-style: prompt_tokens, completion_tokens,
|
|
525
|
+
// prompt_tokens_details.cached_tokens. When the underlying model is
|
|
526
|
+
// Anthropic, OpenRouter also relays cache_read_input_tokens verbatim.
|
|
527
|
+
function _normalizeOpenRouterUsage(usage) {
|
|
528
|
+
if (!usage) return null;
|
|
529
|
+
const cachedFromOpenAI = usage.prompt_tokens_details?.cached_tokens || 0;
|
|
530
|
+
return {
|
|
531
|
+
input_tokens: usage.prompt_tokens || 0,
|
|
532
|
+
output_tokens: usage.completion_tokens || 0,
|
|
533
|
+
cache_read_input_tokens: usage.cache_read_input_tokens || cachedFromOpenAI || 0,
|
|
534
|
+
cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
|
|
535
|
+
};
|
|
536
|
+
}
|
package/src/core/tasks.mjs
CHANGED
|
@@ -67,6 +67,57 @@ export function appendTask({ cwd = process.cwd(), list = 'backlog', text }) {
|
|
|
67
67
|
return { list: normalized, path: filePath, text: taskText };
|
|
68
68
|
}
|
|
69
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
|
+
|
|
70
121
|
export function taskCounts(board) {
|
|
71
122
|
const lists = board?.lists || {};
|
|
72
123
|
return Object.fromEntries(
|
|
@@ -74,7 +125,7 @@ export function taskCounts(board) {
|
|
|
74
125
|
);
|
|
75
126
|
}
|
|
76
127
|
|
|
77
|
-
function normalizeList(value) {
|
|
128
|
+
export function normalizeList(value) {
|
|
78
129
|
const key = String(value || '').toLowerCase();
|
|
79
130
|
if (key === 'todo' || key === 'pending') return 'backlog';
|
|
80
131
|
if (key === 'current' || key === 'doing') return 'active';
|
|
@@ -83,6 +134,21 @@ function normalizeList(value) {
|
|
|
83
134
|
throw new Error(`Unknown task list: ${value}`);
|
|
84
135
|
}
|
|
85
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
|
+
|
|
86
152
|
function readText(filePath) {
|
|
87
153
|
try {
|
|
88
154
|
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
|