clauderipple 0.8.10 → 0.9.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/CHANGELOG.md +36 -0
- package/README.es.md +0 -4
- package/README.ja.md +0 -4
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.pt-BR.md +0 -4
- package/README.zh-CN.md +0 -4
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/settings.js +5 -2
- package/dist/mod/.claude-plugin/plugin.json +9 -1
- package/dist/mod/hooks/cache.ts +60 -0
- package/dist/mod/hooks/compact.ts +40 -0
- package/dist/mod/hooks/handoff.ts +43 -0
- package/dist/mod/hooks/i18n.ts +2716 -0
- package/dist/mod/hooks/judge.ts +226 -0
- package/dist/mod/hooks/ladder.js +179 -0
- package/dist/mod/hooks/ladder.ts +174 -0
- package/dist/mod/hooks/modes.ts +93 -0
- package/dist/mod/hooks/register.tsx +1458 -256
- package/dist/mod/hooks/session.ts +62 -0
- package/dist/mod/hooks/settings.js +342 -0
- package/dist/mod/hooks/settings.ts +321 -0
- package/dist/mod/hooks/stats.ts +59 -0
- package/dist/mod/hooks/ui/agents.tsx +138 -0
- package/dist/mod/hooks/ui/alerts.tsx +370 -0
- package/dist/mod/hooks/ui/art.ts +628 -0
- package/dist/mod/hooks/ui/band.tsx +836 -0
- package/dist/mod/hooks/ui/kit.tsx +258 -0
- package/dist/mod/hooks/ui/log.tsx +109 -0
- package/dist/mod/hooks/ui/parts.tsx +217 -0
- package/dist/mod/hooks/ui/ripple.ts +614 -0
- package/dist/mod/hooks/ui/settings.tsx +376 -0
- package/dist/mod/hooks/ui/setup.tsx +148 -0
- package/dist/mod/hooks/ui/theme.ts +217 -0
- package/dist/mod/hooks/ui/view.ts +371 -0
- package/dist/mod/hooks/worker.ts +1 -1
- package/dist/mod/hooks/workers.ts +217 -0
- package/dist/mod/tests/register.test.tsx +1730 -0
- package/dist/mod/tests/ui.test.tsx +853 -0
- package/dist/mod/tsconfig.json +2 -1
- package/dist/mod/types/index.d.ts +320 -1
- package/dist/router/src/admin.js +170 -52
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -2
- package/dist/router/src/config.js +3 -0
- package/dist/router/src/health.js +33 -0
- package/dist/router/src/index.js +1 -0
- package/dist/router/src/ingress/models.js +4 -1
- package/dist/router/src/mod-settings.js +119 -0
- package/dist/router/src/model-policy.js +6 -0
- package/dist/router/src/model-scores.js +159 -0
- package/dist/router/src/picker.js +7 -0
- package/dist/router/src/providers/chatgpt/index.js +6 -1
- package/dist/router/src/providers/chatgpt/translate.js +1 -1
- package/dist/router/src/providers/google/index.js +6 -2
- package/dist/router/src/providers/openai/index.js +4 -1
- package/dist/router/src/providers/openai/translate.js +1 -1
- package/dist/router/src/proxy.js +14 -3
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +908 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +919 -233
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -410
- package/docs/ARCHITECTURE.md +128 -0
- package/package.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 — 2026-10-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **The ripple mod.** A band above the Code tab's prompt shows reasoning effort, context, the
|
|
8
|
+
cache timer, and which model answered each request with its cache hit. Log opens the request
|
|
9
|
+
log, ⚙ opens quick settings, and the dashboard has a mod page with every setting.
|
|
10
|
+
- **Effort picked per prompt.** A small judge model (Haiku 5.5 or Jev) reads the prompt and the
|
|
11
|
+
recent turns, then sets low through max. Pick an effort yourself whenever you want; Auto comes
|
|
12
|
+
back when you switch it on.
|
|
13
|
+
- **Workers picked per task.** The main chat hands work to `ripple:auto`, and the judge decides
|
|
14
|
+
how hard each task is and sends it to the matching rung: light work to DeepSeek or GLM, hard work
|
|
15
|
+
to GPT or Opus. Rungs, candidate models and named modes (prefer or avoid a model, eager
|
|
16
|
+
delegation, switch off at a quota reset) are set on the dashboard. Every pick is kept in
|
|
17
|
+
`logs/worker-picks.jsonl`.
|
|
18
|
+
- **Warnings that save tokens.** Cards appear when the prompt cache has gone cold on a big chat,
|
|
19
|
+
when the context gets heavy, and when a plan window passes 80%. Each card offers compact or a
|
|
20
|
+
handoff.
|
|
21
|
+
- **Handoff to a fresh session.** A handoff writes a summary and opens a new session in the same
|
|
22
|
+
folder with it in the prompt box. The current chat is left as it is.
|
|
23
|
+
- **리플이**, the mascot, on the band and every card, acting out what the band is doing. Mod text
|
|
24
|
+
in six languages, following the Mac's system language.
|
|
25
|
+
- **Artificial Analysis scores** for every routable model, refreshed daily, used to rank worker
|
|
26
|
+
candidates by measured cost.
|
|
27
|
+
- A provider that answers 402 or says its quota is exhausted is skipped for a while.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- Dashboard settings are rebuilt in the style of macOS System Settings.
|
|
32
|
+
- The mod's settings live in the router, so every session follows a change at once.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Judge-only models such as Jev no longer appear in pickers, worker lists or Codex.
|
|
37
|
+
- The install script works under macOS `/bin/sh` again.
|
|
38
|
+
|
|
3
39
|
## 0.8.10 — 2026-10-10
|
|
4
40
|
|
|
5
41
|
### Added
|
package/README.es.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
1
|
<h1 align="center">ClaudeRipple</h1>
|
|
6
2
|
|
|
7
3
|
<p align="center"><a href="README.md">English</a> · <a href="README.ko.md">한국어</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.ja.md">日本語</a> · <b>Español</b> · <a href="README.pt-BR.md">Português (BR)</a></p>
|
package/README.ja.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
1
|
<h1 align="center">ClaudeRipple</h1>
|
|
6
2
|
|
|
7
3
|
<p align="center"><a href="README.md">English</a> · <a href="README.ko.md">한국어</a> · <a href="README.zh-CN.md">简体中文</a> · <b>日本語</b> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (BR)</a></p>
|
package/README.ko.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="brand/mark-dark.svg">
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="brand/mark.svg">
|
|
5
|
+
<img src="brand/mark.svg" width="120" height="120" alt="ClaudeRipple 마스코트 리플이: 속이 빈 파문 고리에 물마루가 말려 오른 모습">
|
|
6
|
+
</picture>
|
|
3
7
|
</p>
|
|
4
8
|
|
|
5
9
|
<h1 align="center">ClaudeRipple</h1>
|
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="brand/mark-dark.svg">
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="brand/mark.svg">
|
|
5
|
+
<img src="brand/mark.svg" width="120" height="120" alt="The ClaudeRipple mascot: a hollow ripple ring with a curling wave crest">
|
|
6
|
+
</picture>
|
|
3
7
|
</p>
|
|
4
8
|
|
|
5
9
|
<h1 align="center">ClaudeRipple</h1>
|
package/README.pt-BR.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
1
|
<h1 align="center">ClaudeRipple</h1>
|
|
6
2
|
|
|
7
3
|
<p align="center"><a href="README.md">English</a> · <a href="README.ko.md">한국어</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <b>Português (BR)</b></p>
|
package/README.zh-CN.md
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
1
|
<h1 align="center">ClaudeRipple</h1>
|
|
6
2
|
|
|
7
3
|
<p align="center"><a href="README.md">English</a> · <a href="README.ko.md">한국어</a> · <b>简体中文</b> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (BR)</a></p>
|
package/dist/app/package.json
CHANGED
package/dist/cli/src/codex.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { isJudgeOnlyModel } from "../../router/src/model-policy.js";
|
|
7
8
|
const START = "# >>> ClaudeRipple Codex provider >>>";
|
|
8
9
|
const END = "# <<< ClaudeRipple Codex provider <<<";
|
|
9
10
|
const PROFILE_START = "# >>> ClaudeRipple Codex profile >>>";
|
|
@@ -76,7 +77,8 @@ export function writeCodexCatalog(models, home = codexHome()) {
|
|
|
76
77
|
catch {
|
|
77
78
|
return undefined;
|
|
78
79
|
}
|
|
79
|
-
|
|
80
|
+
models = models.filter(m => !isJudgeOnlyModel(m.id));
|
|
81
|
+
const entries = (cached.models ?? []).filter((m) => typeof m.slug === "string" && !isJudgeOnlyModel(m.slug));
|
|
80
82
|
const template = entries.find((m) => m.slug === "gpt-5.5" && m.model_messages) ?? entries.find((m) => m.model_messages);
|
|
81
83
|
if (!template)
|
|
82
84
|
return undefined;
|
package/dist/cli/src/settings.js
CHANGED
|
@@ -216,11 +216,14 @@ export function agentTitleHookEnabled() {
|
|
|
216
216
|
}
|
|
217
217
|
// ---- Status mod (packages/mod) ------------------------------------------------------------
|
|
218
218
|
/** The `name` in packages/mod/.claude-plugin/plugin.json: how our entry is told from anyone else's. */
|
|
219
|
-
export const STATUS_MOD_NAME = "
|
|
219
|
+
export const STATUS_MOD_NAME = "ripple";
|
|
220
|
+
/** Its name until 0.8.10: an entry an older install added is still ours to replace or remove. */
|
|
221
|
+
const OLD_STATUS_MOD_NAMES = ["clauderipple-status"];
|
|
220
222
|
const PLUGIN_DIRS = "CLAUDE_CODE_PLUGIN_DIRS";
|
|
221
223
|
function isStatusMod(dir) {
|
|
222
224
|
try {
|
|
223
|
-
|
|
225
|
+
const name = JSON.parse(fs.readFileSync(path.join(dir, ".claude-plugin", "plugin.json"), "utf8")).name;
|
|
226
|
+
return name === STATUS_MOD_NAME || OLD_STATUS_MOD_NAMES.includes(name);
|
|
224
227
|
}
|
|
225
228
|
catch {
|
|
226
229
|
return false;
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "ripple",
|
|
3
|
+
"displayName": "ClaudeRipple",
|
|
4
|
+
"version": "0.7.0",
|
|
5
|
+
"description": "Picks the reasoning effort for every prompt and the worker for every delegated task, can keep the prompt cache warm, and shows where ClaudeRipple sent each request.",
|
|
6
|
+
"author": { "name": "ClaudeRipple" },
|
|
7
|
+
"license": "GPL-3.0-only",
|
|
8
|
+
"types": "./types/index.d.ts"
|
|
9
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ModelForkResult } from 'claude-code'
|
|
2
|
+
import type { CacheMemo, Settings, Usage, WarmState } from '../types'
|
|
3
|
+
import { weighted } from './stats'
|
|
4
|
+
import { t } from './i18n.ts'
|
|
5
|
+
import type { Lang } from './i18n.ts'
|
|
6
|
+
|
|
7
|
+
const HOUR = 3600000
|
|
8
|
+
const FIVE_MINUTES = 300000
|
|
9
|
+
|
|
10
|
+
export function ttlFromUsage(usage: Usage): number {
|
|
11
|
+
if ((usage.cache_creation?.ephemeral_1h_input_tokens ?? 0) > 0) return HOUR
|
|
12
|
+
if ((usage.cache_creation?.ephemeral_5m_input_tokens ?? 0) > 0) return FIVE_MINUTES
|
|
13
|
+
return HOUR
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A five-minute tail must not shorten a mostly-read hour-long prefix. */
|
|
17
|
+
export function nextMemo(prev: CacheMemo | null, usage: Usage, now: number): CacheMemo {
|
|
18
|
+
const total = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0)
|
|
19
|
+
const mostlyRead = (usage.cache_read_input_tokens ?? 0) > total / 2
|
|
20
|
+
const explicit = (usage.cache_creation?.ephemeral_1h_input_tokens ?? 0) > 0 || (usage.cache_creation?.ephemeral_5m_input_tokens ?? 0) > 0
|
|
21
|
+
let ttlMs = explicit ? ttlFromUsage(usage) : prev?.ttlMs ?? HOUR
|
|
22
|
+
if (prev?.ttlMs === HOUR && mostlyRead) ttlMs = HOUR
|
|
23
|
+
if (!explicit && prev && now - prev.lastReplyAt > FIVE_MINUTES && mostlyRead) ttlMs = HOUR
|
|
24
|
+
return { ttlMs, lastReplyAt: now, expiresAt: now + ttlMs }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function secondsLeft(memo: CacheMemo | null, now: number): number | null {
|
|
28
|
+
return memo ? Math.max(0, Math.ceil((memo.expiresAt - now) / 1000)) : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Never pay to revive an expired cache or extend the last-real-reply bound with our own pings. */
|
|
32
|
+
export function shouldPing(input: {
|
|
33
|
+
settings: Settings; memo: CacheMemo | null; warm: WarmState; now: number; idle: boolean
|
|
34
|
+
contextTokens: number; lastRealReplyAt: number | null
|
|
35
|
+
}): { ping: true } | { ping: false; reason?: string } {
|
|
36
|
+
const { settings, memo, warm, now } = input
|
|
37
|
+
if (!settings.keepWarm) return { ping: false, reason: 'keep warm is off' }
|
|
38
|
+
if (warm.stopped) return { ping: false, reason: warm.stopped }
|
|
39
|
+
if (!input.idle) return { ping: false, reason: 'chat is busy' }
|
|
40
|
+
if (!memo || memo.expiresAt <= now) return { ping: false, reason: 'cache is not warm' }
|
|
41
|
+
if (input.lastRealReplyAt === null || now >= input.lastRealReplyAt + settings.keepWarmHours * HOUR) return { ping: false, reason: 'keep warm time bound reached' }
|
|
42
|
+
if (input.contextTokens < settings.keepWarmMinTokens) return { ping: false, reason: 'chat is too small' }
|
|
43
|
+
const lead = memo.ttlMs === FIVE_MINUTES ? 60000 : 240000
|
|
44
|
+
return memo.expiresAt - now <= lead ? { ping: true } : { ping: false }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Failed forks stop once; answered cache misses still count the real tokens they spent. */
|
|
48
|
+
export function afterPing(warm: WarmState, forkResult: ModelForkResult | null, now: number, lang: Lang = 'en'): WarmState {
|
|
49
|
+
if (!forkResult?.isAnswered) return { ...warm, stopped: t(lang, 'toasts.warmNoReply') }
|
|
50
|
+
const next: WarmState = { pings: warm.pings + 1, cost: warm.cost + weighted(forkResult.usage), lastPingAt: now }
|
|
51
|
+
if (forkResult.usage.cache_read_input_tokens <= 0) next.stopped = t(lang, 'toasts.warmNoCache')
|
|
52
|
+
return next
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function restoreFromResume(secondsSinceLastResponse: number | undefined, likelyExpired: boolean | undefined, ttlMs: number, now: number): CacheMemo | null {
|
|
56
|
+
if (secondsSinceLastResponse === undefined || !Number.isFinite(secondsSinceLastResponse) || secondsSinceLastResponse < 0) return null
|
|
57
|
+
const lastReplyAt = now - secondsSinceLastResponse * 1000
|
|
58
|
+
const expiresAt = lastReplyAt + ttlMs
|
|
59
|
+
return { ttlMs, lastReplyAt, expiresAt: likelyExpired || expiresAt <= now ? now - 1 : expiresAt }
|
|
60
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SessionCompacted, SessionMessage } from 'claude-code'
|
|
2
|
+
|
|
3
|
+
export const COMPACT_SYSTEM = `Create a faithful continuation record of this coding conversation. Keep the user's intent and changes of mind, decisions and constraints, relevant concepts, files and code touched, errors and their fixes, and user feedback. List user requests in order, distinguish solved from pending work, and state exactly what is happening now. Include a next step only when the conversation justifies it. Preserve useful exact paths and commands, but omit secrets, redundant logs, and obsolete detail. Do not invent verification or claim unfinished work is complete. Write in the user's language.`
|
|
4
|
+
|
|
5
|
+
function clipped(text: string, limit: number): string {
|
|
6
|
+
return text.length > limit ? `${text.slice(0, limit)}\n[truncated]` : text
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function render(messages: readonly SessionMessage[], tight: boolean): string {
|
|
10
|
+
return messages.map((message, index) => {
|
|
11
|
+
const tail = index >= messages.length - 6
|
|
12
|
+
const inputLimit = tail ? (tight ? 4000 : 20000) : 400
|
|
13
|
+
const resultLimit = tail ? (tight ? 4000 : 20000) : (tight ? 300 : 2000)
|
|
14
|
+
const parts = [`${message.role}:\n${message.text}`]
|
|
15
|
+
for (const use of message.toolUses) {
|
|
16
|
+
parts.push(`tool ${use.tool} (${use.tool_use_id}):\n${clipped(JSON.stringify(use.input), inputLimit)}`)
|
|
17
|
+
const result = use.text ?? (use.result !== undefined ? JSON.stringify(use.result) : undefined)
|
|
18
|
+
if (result !== undefined) parts.push(`result${use.isError ? ' ERROR' : ''}:\n${clipped(result, resultLimit)}`)
|
|
19
|
+
}
|
|
20
|
+
if (!message.toolUses.length) {
|
|
21
|
+
for (const result of message.toolResults ?? []) parts.push(`result ${result.tool_use_id}${result.isError ? ' ERROR' : ''}:\n${clipped(result.text, resultLimit)}`)
|
|
22
|
+
}
|
|
23
|
+
return parts.join('\n\n')
|
|
24
|
+
}).join('\n\n---\n\n')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Shrink only tool bulk; losing user intent to hit a budget is worse than engine fallback. */
|
|
28
|
+
export function serializeTranscript(messages: readonly SessionMessage[], budget = 2400000): string | null {
|
|
29
|
+
if (!messages.length) return null
|
|
30
|
+
const full = render(messages, false)
|
|
31
|
+
if (full.length <= budget) return full
|
|
32
|
+
const tight = render(messages, true)
|
|
33
|
+
return tight.length <= budget ? tight : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** No handle: the engine builds this replacement from role, text and tool blocks. */
|
|
37
|
+
export function compactResult(summary: string): SessionCompacted {
|
|
38
|
+
return { messages: [{ role: 'user', toolUses: [], text:
|
|
39
|
+
`Continuation record from the previous conversation:\n\n${summary.trim()}\n\nResume the pending work from this record. Do not repeat completed work or ask for information already recorded.` }] }
|
|
40
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SessionMessage } from 'claude-code'
|
|
2
|
+
import type { HandoffKind, Settings } from '../types'
|
|
3
|
+
|
|
4
|
+
export const QUICK_PROMPT = `Prepare a concise resume note for a fresh coding chat, in the user's language, about forty lines of bullets. Use only this conversation; do not run tools. Record project, folder and branch if known, the goal, verified completed work, unfinished work and blockers, git state as last checked, decisions and failed approaches, permissions or resume details, and the highest-priority runnable next action. Mark actions requiring the user. Keep only current relevant state and point to files or specs instead of copying them. Exclude secrets, environment values and raw logs. Output only the note, without an introduction or farewell.`
|
|
5
|
+
|
|
6
|
+
export const FULL_PROMPT = `Write a durable handoff for the next coding chat. First verify the working directory, git status, recent commits and whether changes are pushed; check open pull requests if gh is available. Rebuild project-root HANDOFF.md from the current facts, fewer than eighty lines, in the user's language. Include the goal, verified done and undone work, blockers, changed files, decisions and failed approaches, permissions, exact current state and a prioritized next action. Distinguish facts from unverified claims. Never expose secrets, environment values or raw logs. Finish with the saved file path and a concise status for the next chat.`
|
|
7
|
+
|
|
8
|
+
export function wrapForNewChat(text: string, after: Settings['handoffAfter'], kind: HandoffKind, hasSkill: boolean): string {
|
|
9
|
+
const source = kind === 'full' && hasSkill ?
|
|
10
|
+
'First read the saved handoff files named below; if no file is named, read HANDOFF.md. The last answer alone may not contain the handoff.\n\n' : ''
|
|
11
|
+
const action = after === 'confirm' ?
|
|
12
|
+
'State the current status and proposed next action in two lines, then wait for my confirmation without starting work.' :
|
|
13
|
+
'Continue with the justified next action. If it needs my permission or input, explain what is needed and wait.'
|
|
14
|
+
return `${source}Resume context from the previous chat:\n\n${text.trim()}\n\n${action}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ADVICE_SYSTEM = `Decide whether starting a fresh chat clearly helps this coding work. Recommend it only when completed work is followed by a different task, unrelated history is dead weight, the topic changed, or repeated failed approaches are going in circles. A long chat, high context usage, or an ordinary next step is not enough. When uncertain, do not recommend it. Output only JSON {"handoff":null} or {"handoff":"reason"}, using the user's language and at most eight words.`
|
|
18
|
+
|
|
19
|
+
/** Cadence counts real eligible prompts, not tool steps, pings or judge requests. */
|
|
20
|
+
export function adviceDue(count: number, percent: number): boolean {
|
|
21
|
+
return percent >= 30 && Number.isInteger(count) && count > 0 && count % 2 === 0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function adviceInput(input: { messages: readonly SessionMessage[]; next: string; contextPercent: number; warm: boolean }): string {
|
|
25
|
+
const users = input.messages.filter(message => message.role === 'user' && message.text.trim())
|
|
26
|
+
const assistant = input.messages.findLast(message => message.role === 'assistant' && message.text.trim())
|
|
27
|
+
const trail = users.slice(-8, -1).map(message => message.text.replace(/\s+/g, ' ').slice(0, 160)).join('\n')
|
|
28
|
+
return [`Context: ${Math.round(input.contextPercent)}%; user messages: ${users.length}; cache: ${input.warm ? 'warm' : 'cold'}`,
|
|
29
|
+
`Initial request: ${users[0]?.text.slice(0, 500) ?? ''}`, `Earlier requests:\n${trail}`,
|
|
30
|
+
`Assistant tail: ${assistant?.text.slice(-700) ?? ''}`, `Next request: ${input.next.slice(0, 600)}`].join('\n\n')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseAdvice(text: string): string | null {
|
|
34
|
+
const span = /\{[\s\S]*\}/.exec(text)?.[0]
|
|
35
|
+
if (!span) return null
|
|
36
|
+
try {
|
|
37
|
+
const value: unknown = JSON.parse(span)
|
|
38
|
+
if (!value || typeof value !== 'object' || !('handoff' in value)) return null
|
|
39
|
+
return typeof value.handoff === 'string' && value.handoff.trim() ? value.handoff.trim().slice(0, 70) : null
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|