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.
Files changed (68) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/codex.js +3 -1
  10. package/dist/cli/src/settings.js +5 -2
  11. package/dist/mod/.claude-plugin/plugin.json +9 -1
  12. package/dist/mod/hooks/cache.ts +60 -0
  13. package/dist/mod/hooks/compact.ts +40 -0
  14. package/dist/mod/hooks/handoff.ts +43 -0
  15. package/dist/mod/hooks/i18n.ts +2716 -0
  16. package/dist/mod/hooks/judge.ts +226 -0
  17. package/dist/mod/hooks/ladder.js +179 -0
  18. package/dist/mod/hooks/ladder.ts +174 -0
  19. package/dist/mod/hooks/modes.ts +93 -0
  20. package/dist/mod/hooks/register.tsx +1458 -256
  21. package/dist/mod/hooks/session.ts +62 -0
  22. package/dist/mod/hooks/settings.js +342 -0
  23. package/dist/mod/hooks/settings.ts +321 -0
  24. package/dist/mod/hooks/stats.ts +59 -0
  25. package/dist/mod/hooks/ui/agents.tsx +138 -0
  26. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  27. package/dist/mod/hooks/ui/art.ts +628 -0
  28. package/dist/mod/hooks/ui/band.tsx +836 -0
  29. package/dist/mod/hooks/ui/kit.tsx +258 -0
  30. package/dist/mod/hooks/ui/log.tsx +109 -0
  31. package/dist/mod/hooks/ui/parts.tsx +217 -0
  32. package/dist/mod/hooks/ui/ripple.ts +614 -0
  33. package/dist/mod/hooks/ui/settings.tsx +376 -0
  34. package/dist/mod/hooks/ui/setup.tsx +148 -0
  35. package/dist/mod/hooks/ui/theme.ts +217 -0
  36. package/dist/mod/hooks/ui/view.ts +371 -0
  37. package/dist/mod/hooks/worker.ts +1 -1
  38. package/dist/mod/hooks/workers.ts +217 -0
  39. package/dist/mod/tests/register.test.tsx +1730 -0
  40. package/dist/mod/tests/ui.test.tsx +853 -0
  41. package/dist/mod/tsconfig.json +2 -1
  42. package/dist/mod/types/index.d.ts +320 -1
  43. package/dist/router/src/admin.js +170 -52
  44. package/dist/router/src/agents.js +24 -5
  45. package/dist/router/src/bootstrap.js +4 -2
  46. package/dist/router/src/config.js +3 -0
  47. package/dist/router/src/health.js +33 -0
  48. package/dist/router/src/index.js +1 -0
  49. package/dist/router/src/ingress/models.js +4 -1
  50. package/dist/router/src/mod-settings.js +119 -0
  51. package/dist/router/src/model-policy.js +6 -0
  52. package/dist/router/src/model-scores.js +159 -0
  53. package/dist/router/src/picker.js +7 -0
  54. package/dist/router/src/providers/chatgpt/index.js +6 -1
  55. package/dist/router/src/providers/chatgpt/translate.js +1 -1
  56. package/dist/router/src/providers/google/index.js +6 -2
  57. package/dist/router/src/providers/openai/index.js +4 -1
  58. package/dist/router/src/providers/openai/translate.js +1 -1
  59. package/dist/router/src/proxy.js +14 -3
  60. package/dist/router/src/version.js +1 -1
  61. package/dist/ui/app.js +908 -209
  62. package/dist/ui/favicon-32.png +0 -0
  63. package/dist/ui/favicon.svg +12 -0
  64. package/dist/ui/i18n.js +919 -233
  65. package/dist/ui/index.html +159 -87
  66. package/dist/ui/style.css +789 -410
  67. package/docs/ARCHITECTURE.md +128 -0
  68. 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
- <img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
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
- <img src="docs/media/icon.png" width="128" alt="ClaudeRipple">
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>
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.8.10",
3
+ "version": "0.9.0",
4
4
  "type": "commonjs"
5
5
  }
@@ -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
- const entries = (cached.models ?? []).filter((m) => typeof m.slug === "string");
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;
@@ -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 = "clauderipple-status";
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
- return JSON.parse(fs.readFileSync(path.join(dir, ".claude-plugin", "plugin.json"), "utf8")).name === STATUS_MOD_NAME;
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
- { "name": "clauderipple-status", "version": "0.1.0", "description": "Shows where ClaudeRipple sent each request and its request log, and keeps ClaudeRipple workers on their own model and effort.", "types": "./types/index.d.ts" }
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
+ }