clauderipple 0.8.9 → 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 +62 -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/dist/main.js +23 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/index.js +1 -1
- package/dist/cli/src/settings.js +5 -2
- package/dist/cli/src/tray.js +41 -5
- 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 +195 -53
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -2
- package/dist/router/src/compat.js +10 -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/anthropic-account-pool.js +10 -4
- package/dist/router/src/providers/anthropic-accounts.js +26 -1
- package/dist/router/src/providers/chatgpt/catalog.js +45 -14
- package/dist/router/src/providers/chatgpt/index.js +9 -3
- package/dist/router/src/providers/chatgpt/translate.js +9 -5
- 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 +932 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +923 -237
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -408
- package/docs/ARCHITECTURE.md +134 -2
- package/package.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
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
|
+
|
|
39
|
+
## 0.8.10 — 2026-10-10
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **Choose which Claude account is used first.** With several Claude accounts, the current Claude
|
|
44
|
+
login was always tried first. Each account card now has a "Use first" button (Providers →
|
|
45
|
+
Claude → Accounts). New conversations start on that account, move to the next one when it hits
|
|
46
|
+
its limit, and return to it once the limit resets. A conversation already running stays on its
|
|
47
|
+
account to keep its cache.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **DeepSeek subagents no longer fail with 400 on the Artifact tool.** DeepSeek's Anthropic
|
|
52
|
+
endpoint rejected Claude Desktop's Artifact tool over a regular expression it cannot compile
|
|
53
|
+
(`"^[^\0]*$" is not a "regex"`). That pattern is now removed on the way out, as it already was for
|
|
54
|
+
OpenAI-format providers (#51).
|
|
55
|
+
- **The tray starts again after a Windows restart.** Installed with the install script, the tray
|
|
56
|
+
was set to open at logon without its own script, so Windows opened a bare Electron instead
|
|
57
|
+
(#50). Restarting the tray once after updating fixes the entry. `clauderipple tray` now also
|
|
58
|
+
reports a tray that quits right after starting, with its output in `logs/tray.log`. Running it
|
|
59
|
+
while a tray is already up no longer adds a second icon.
|
|
60
|
+
- **New ChatGPT models show up without the Codex CLI installed.** OpenAI's model list hides
|
|
61
|
+
models from older Codex versions, and without a Codex CLI the router asked as 0.155.0, so GPT-6.1
|
|
62
|
+
Sol never appeared (#50). It now asks as the newest published Codex. New models appear unticked in
|
|
63
|
+
the ChatGPT provider form.
|
|
64
|
+
|
|
3
65
|
## 0.8.9 — 2026-10-08
|
|
4
66
|
|
|
5
67
|
### Fixed
|
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/dist/main.js
CHANGED
|
@@ -512,9 +512,19 @@ function writeAppState(patch) {
|
|
|
512
512
|
/* a read-only home only costs us the remembered default */
|
|
513
513
|
}
|
|
514
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* How Windows starts this tray at login. A packaged app is its own binary. Installed from npm, the
|
|
517
|
+
* binary is a bare Electron that needs our script after it: registered without one, every logon
|
|
518
|
+
* opened Electron's default app and no tray (issue #50). macOS ignores `path`/`args`.
|
|
519
|
+
*/
|
|
520
|
+
function loginItemLaunch() {
|
|
521
|
+
if (electron_1.app.isPackaged || process.platform !== "win32" || !process.argv[1])
|
|
522
|
+
return undefined;
|
|
523
|
+
return { path: process.execPath, args: [node_path_1.default.resolve(process.argv[1])] };
|
|
524
|
+
}
|
|
515
525
|
function loginItemOn() {
|
|
516
526
|
try {
|
|
517
|
-
return electron_1.app.getLoginItemSettings().openAtLogin;
|
|
527
|
+
return electron_1.app.getLoginItemSettings(loginItemLaunch()).openAtLogin;
|
|
518
528
|
}
|
|
519
529
|
catch {
|
|
520
530
|
return false;
|
|
@@ -522,7 +532,7 @@ function loginItemOn() {
|
|
|
522
532
|
}
|
|
523
533
|
function setLoginItem(on) {
|
|
524
534
|
try {
|
|
525
|
-
electron_1.app.setLoginItemSettings({ openAtLogin: on });
|
|
535
|
+
electron_1.app.setLoginItemSettings({ openAtLogin: on, ...loginItemLaunch() });
|
|
526
536
|
}
|
|
527
537
|
catch {
|
|
528
538
|
/* macOS may refuse in an unsigned development build */
|
|
@@ -798,7 +808,9 @@ async function offerUpdate(check) {
|
|
|
798
808
|
availableUpdate = null;
|
|
799
809
|
if (electron_1.Notification.isSupported())
|
|
800
810
|
new electron_1.Notification({ title: "ClaudeRipple", body: L.updateDone(version) }).show();
|
|
801
|
-
// A new tray from the new files, then this one goes.
|
|
811
|
+
// A new tray from the new files, then this one goes. The lock is let go first, or the new tray
|
|
812
|
+
// would see this one and leave.
|
|
813
|
+
electron_1.app.releaseSingleInstanceLock();
|
|
802
814
|
await runCliResult(["tray"]);
|
|
803
815
|
electron_1.app.quit();
|
|
804
816
|
}
|
|
@@ -914,6 +926,10 @@ function render() {
|
|
|
914
926
|
tray.setContextMenu(electron_1.Menu.buildFromTemplate(template));
|
|
915
927
|
tray.setToolTip(s ? L.tooltip(state) : L.tooltipDown);
|
|
916
928
|
}
|
|
929
|
+
// One tray. `clauderipple tray` run while one is up started a second icon (Windows VM,
|
|
930
|
+
// 2026-10-10); the newcomer now leaves at once with code 0, which the CLI reads as "already running".
|
|
931
|
+
if (!electron_1.app.requestSingleInstanceLock())
|
|
932
|
+
electron_1.app.exit(0);
|
|
917
933
|
electron_1.app.whenReady().then(() => {
|
|
918
934
|
L = STRINGS[uiLang()];
|
|
919
935
|
// Without this, Windows attributes our notifications to "Electron" instead of ClaudeRipple.
|
|
@@ -927,6 +943,10 @@ electron_1.app.whenReady().then(() => {
|
|
|
927
943
|
// Default to starting at login on first run; after that the user's choice stands.
|
|
928
944
|
if (!appState().loginItemInitialized)
|
|
929
945
|
setLoginItem(true);
|
|
946
|
+
// An entry an older version wrote names bare Electron (the query without args finds it); the
|
|
947
|
+
// user had start-at-login on, so it is rewritten with the script rather than left broken.
|
|
948
|
+
else if (loginItemLaunch() && electron_1.app.getLoginItemSettings().openAtLogin && !loginItemOn())
|
|
949
|
+
setLoginItem(true);
|
|
930
950
|
tray = new electron_1.Tray(icon("down"));
|
|
931
951
|
tray.on("click", () => tray?.popUpContextMenu());
|
|
932
952
|
render();
|
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/index.js
CHANGED
|
@@ -647,7 +647,7 @@ try {
|
|
|
647
647
|
await update();
|
|
648
648
|
break;
|
|
649
649
|
case "tray": {
|
|
650
|
-
const result = args.includes("--install") ? installTrayRuntime() : startTray();
|
|
650
|
+
const result = args.includes("--install") ? installTrayRuntime() : await startTray();
|
|
651
651
|
console.log(result.message);
|
|
652
652
|
if (!result.ok)
|
|
653
653
|
process.exit(1);
|
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;
|
package/dist/cli/src/tray.js
CHANGED
|
@@ -52,10 +52,11 @@ export function trayEnv(env = process.env) {
|
|
|
52
52
|
return out;
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
* Starts the tray and returns
|
|
56
|
-
* command typed in a terminal wants; the supervisor uses the foreground
|
|
55
|
+
* Starts the tray and returns once it has stayed up for a moment. `detached` outlives this
|
|
56
|
+
* process, which is what a command typed in a terminal wants; the supervisor uses the foreground
|
|
57
|
+
* form instead.
|
|
57
58
|
*/
|
|
58
|
-
export function startTray(options = {}) {
|
|
59
|
+
export async function startTray(options = {}) {
|
|
59
60
|
const current = runtime();
|
|
60
61
|
if (current.packaged) {
|
|
61
62
|
// The packaged app IS the tray: relaunching its own binary with no script is what opens it.
|
|
@@ -75,15 +76,50 @@ export function startTray(options = {}) {
|
|
|
75
76
|
if (!fs.existsSync(main)) {
|
|
76
77
|
return { ok: false, message: `the tray is not built: ${main} is missing (run: npm run build)` };
|
|
77
78
|
}
|
|
79
|
+
// Electron's own output goes to a file. With it discarded, a tray that died at launch still
|
|
80
|
+
// printed "✓ tray started" and left nothing to go on (issue #50).
|
|
81
|
+
const log = trayLogPath();
|
|
82
|
+
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
83
|
+
const out = fs.openSync(log, "a");
|
|
78
84
|
const child = spawn(electron, [main], {
|
|
79
85
|
detached: options.detached ?? true,
|
|
80
|
-
stdio: "ignore",
|
|
86
|
+
stdio: ["ignore", out, out],
|
|
81
87
|
// Electron would otherwise re-enter as a plain Node process, since that is how the CLI runs.
|
|
82
88
|
env: trayEnv(),
|
|
83
89
|
});
|
|
90
|
+
fs.closeSync(out);
|
|
84
91
|
if (options.detached ?? true)
|
|
85
92
|
child.unref();
|
|
86
|
-
return
|
|
93
|
+
return earlyExit(child, log);
|
|
94
|
+
}
|
|
95
|
+
export function trayLogPath() {
|
|
96
|
+
return path.join(homeDir(), "logs", "tray.log");
|
|
97
|
+
}
|
|
98
|
+
/** A tray that is still running after a moment has started; one that exits before then has not. */
|
|
99
|
+
export function earlyExit(child, log, waitMs = 2_000) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
// Not unref'd: the command has to stay up long enough to see the exit it is waiting for.
|
|
102
|
+
const timer = setTimeout(() => resolve({ ok: true, message: "✓ tray started" }), waitMs);
|
|
103
|
+
const failed = (detail) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
let tail = "";
|
|
106
|
+
try {
|
|
107
|
+
tail = fs.readFileSync(log, "utf8").trim().split(/\r?\n/).slice(-8).join("\n");
|
|
108
|
+
}
|
|
109
|
+
catch { /* nothing logged */ }
|
|
110
|
+
resolve({ ok: false, message: `the tray exited at once (${detail}). Its output is in ${log}${tail ? `:\n${tail}` : ""}` });
|
|
111
|
+
};
|
|
112
|
+
child.once("error", (error) => failed(error.message));
|
|
113
|
+
child.once("exit", (code, signal) => {
|
|
114
|
+
// A clean exit at once is a tray that found another one running (main.ts's single-instance lock).
|
|
115
|
+
if (code === 0 && !signal) {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
resolve({ ok: true, message: "✓ tray is already running" });
|
|
118
|
+
}
|
|
119
|
+
else
|
|
120
|
+
failed(signal ? `signal ${signal}` : `exit code ${code}`);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
87
123
|
}
|
|
88
124
|
/** The npm that came with the Node running us, as a script we can hand to that same Node. */
|
|
89
125
|
function npmCli() {
|
|
@@ -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
|
+
}
|