@delt/claude-jev-advisor 0.2.0 → 0.4.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.
@@ -18,6 +18,7 @@ var DEFAULT_CONFIG = {
18
18
  lang: "ko",
19
19
  keyFile: null,
20
20
  display: "mod",
21
+ shortcut: null,
21
22
  context: { enabled: true, minTokens: 25e4, compactMinTokens: 25e4, redRemainingPct: 20, unitDoneYes: 0.6, phaseDoneYes: 0.6 },
22
23
  rm: { enabled: true, jev: true, throwawayYes: 0.8, maxDirFiles: 50 }
23
24
  };
@@ -45,6 +46,7 @@ function normalizeConfig(raw) {
45
46
  lang: typeof r.lang === "string" && LANGS.includes(r.lang) ? r.lang : DEFAULT_CONFIG.lang,
46
47
  keyFile: typeof r.keyFile === "string" && r.keyFile !== "" ? r.keyFile : null,
47
48
  display: typeof r.display === "string" && DISPLAYS.includes(r.display) ? r.display : DEFAULT_CONFIG.display,
49
+ shortcut: typeof r.shortcut === "string" && r.shortcut !== "" ? r.shortcut : null,
48
50
  context: mergeSection(DEFAULT_CONFIG.context, r.version === CONFIG_VERSION ? r.context : withoutLegacyDefaults(r.context)),
49
51
  rm: mergeSection(DEFAULT_CONFIG.rm, r.rm)
50
52
  };
@@ -73,18 +75,20 @@ import path3 from "path";
73
75
 
74
76
  // src/display/line.ts
75
77
  var STALE_SIZE_RATIO = 0.7;
76
- var PHRASES = {
78
+ var QUESTIONS = {
77
79
  ko: {
78
- clear: "\uC0C8\uB86D\uAC8C \uC2DC\uC791\uD558\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694? /clear",
79
- compact: "\uC9C0\uAE08\uAE4C\uC9C0 \uC815\uB9AC\uD558\uACE0 \uC774\uC5B4\uAC00\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694? /compact",
80
- compactLater: "\uC791\uC5C5\uC774 \uB05D\uB098\uBA74 \uC815\uB9AC\uD558\uACE0 \uC774\uC5B4\uAC00\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694? /compact"
80
+ clear: "\uC0C8\uB86D\uAC8C \uC2DC\uC791\uD558\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694?",
81
+ compact: "\uC9C0\uAE08\uAE4C\uC9C0 \uC815\uB9AC\uD558\uACE0 \uC774\uC5B4\uAC00\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694?",
82
+ compactLater: "\uC791\uC5C5\uC774 \uB05D\uB098\uBA74 \uC815\uB9AC\uD558\uACE0 \uC774\uC5B4\uAC00\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694?"
81
83
  },
82
84
  en: {
83
- clear: "Start fresh? /clear",
84
- compact: "Wrap up what you have and continue? /compact",
85
- compactLater: "When this work is done, wrap up and continue? /compact"
85
+ clear: "Start fresh?",
86
+ compact: "Wrap up what you have and continue?",
87
+ compactLater: "When this work is done, wrap up and continue?"
86
88
  }
87
89
  };
90
+ var COMMANDS = { clear: "/clear", compact: "/compact", compactLater: "/compact" };
91
+ var phrase = (lang, advice) => `${QUESTIONS[lang][advice]} ${COMMANDS[advice]}`;
88
92
  function isRecord2(value) {
89
93
  return typeof value === "object" && value !== null && !Array.isArray(value);
90
94
  }
@@ -99,20 +103,29 @@ function adviceKind(size, judgment, config) {
99
103
  if (judgment.clear) return "clear";
100
104
  return size >= config.context.compactMinTokens ? "compact" : null;
101
105
  }
102
- function adviceLine({ size, threshold, judgment, config }) {
106
+ function increaseSuffix(increase) {
107
+ if (increase === null || increase === void 0) return "";
108
+ const k = Math.round(increase / 1e3);
109
+ return k > 0 ? ` +${k}k` : "";
110
+ }
111
+ function redRemaining(size, threshold, config) {
112
+ if (threshold === null || threshold <= 0 || size < threshold * (1 - config.context.redRemainingPct / 100)) return null;
113
+ return remainingPct(size, threshold);
114
+ }
115
+ function adviceLine({ size, threshold, judgment, config, increase }) {
103
116
  if (size === null) return "";
104
- const k = formatSize(size);
105
- const phrases = PHRASES[config.lang];
117
+ const k = `${formatSize(size)}${increaseSuffix(increase)}`;
106
118
  const counted = size >= config.context.minTokens ? judgment : null;
107
- if (threshold !== null && threshold > 0 && size >= threshold * (1 - config.context.redRemainingPct / 100)) {
108
- const head = `\u{1F534} ${k} ${remainingPct(size, threshold)}%`;
119
+ const red = redRemaining(size, threshold, config);
120
+ if (red !== null) {
121
+ const head = `\u{1F534} ${k} ${red}%`;
109
122
  if (!counted) return head;
110
- if (counted.phase === "working") return `${head} \xB7 ${phrases.compactLater}`;
111
- return `${head} \xB7 ${counted.clear ? phrases.clear : phrases.compact}`;
123
+ if (counted.phase === "working") return `${head} \xB7 ${phrase(config.lang, "compactLater")}`;
124
+ return `${head} \xB7 ${phrase(config.lang, counted.clear ? "clear" : "compact")}`;
112
125
  }
113
126
  if (!counted) return k;
114
127
  const kind = adviceKind(size, counted, config);
115
- return kind ? `\u{1F7E1} ${k} ${phrases[kind]}` : `\u{1F7E2} ${k}`;
128
+ return kind ? `\u{1F7E1} ${k} ${phrase(config.lang, kind)}` : `\u{1F7E2} ${k}`;
116
129
  }
117
130
  function usableJudgment(state, liveSize, turnStartedAt) {
118
131
  if (!state || !state.judgment || state.at < turnStartedAt) return null;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jev-advisor",
3
- "version": "0.2.0",
4
- "description": "Bottom-row compact/clear advice from @delt/claude-jev-advisor (unofficial)",
3
+ "version": "0.4.0",
4
+ "description": "Bottom-row compact/clear advice and an advice button from @delt/claude-jev-advisor (unofficial)",
5
5
  "author": { "name": "delt96" },
6
6
  "userConfig": {
7
7
  "dataDir": {
@@ -4,6 +4,7 @@ var DEFAULT_CONFIG = {
4
4
  lang: "ko",
5
5
  keyFile: null,
6
6
  display: "mod",
7
+ shortcut: null,
7
8
  context: { enabled: true, minTokens: 25e4, compactMinTokens: 25e4, redRemainingPct: 20, unitDoneYes: 0.6, phaseDoneYes: 0.6 },
8
9
  rm: { enabled: true, jev: true, throwawayYes: 0.8, maxDirFiles: 50 }
9
10
  };
@@ -31,6 +32,7 @@ function normalizeConfig(raw) {
31
32
  lang: typeof r.lang === "string" && LANGS.includes(r.lang) ? r.lang : DEFAULT_CONFIG.lang,
32
33
  keyFile: typeof r.keyFile === "string" && r.keyFile !== "" ? r.keyFile : null,
33
34
  display: typeof r.display === "string" && DISPLAYS.includes(r.display) ? r.display : DEFAULT_CONFIG.display,
35
+ shortcut: typeof r.shortcut === "string" && r.shortcut !== "" ? r.shortcut : null,
34
36
  context: mergeSection(DEFAULT_CONFIG.context, r.version === CONFIG_VERSION ? r.context : withoutLegacyDefaults(r.context)),
35
37
  rm: mergeSection(DEFAULT_CONFIG.rm, r.rm)
36
38
  };
@@ -38,21 +40,26 @@ function normalizeConfig(raw) {
38
40
 
39
41
  // src/display/line.ts
40
42
  var STALE_SIZE_RATIO = 0.7;
41
- var PHRASES = {
43
+ var QUESTIONS = {
42
44
  ko: {
43
- clear: "\uC0C8\uB86D\uAC8C \uC2DC\uC791\uD558\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694? /clear",
44
- compact: "\uC9C0\uAE08\uAE4C\uC9C0 \uC815\uB9AC\uD558\uACE0 \uC774\uC5B4\uAC00\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694? /compact",
45
- compactLater: "\uC791\uC5C5\uC774 \uB05D\uB098\uBA74 \uC815\uB9AC\uD558\uACE0 \uC774\uC5B4\uAC00\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694? /compact"
45
+ clear: "\uC0C8\uB86D\uAC8C \uC2DC\uC791\uD558\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694?",
46
+ compact: "\uC9C0\uAE08\uAE4C\uC9C0 \uC815\uB9AC\uD558\uACE0 \uC774\uC5B4\uAC00\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694?",
47
+ compactLater: "\uC791\uC5C5\uC774 \uB05D\uB098\uBA74 \uC815\uB9AC\uD558\uACE0 \uC774\uC5B4\uAC00\uB294 \uAC74 \uC5B4\uB5A0\uC138\uC694?"
46
48
  },
47
49
  en: {
48
- clear: "Start fresh? /clear",
49
- compact: "Wrap up what you have and continue? /compact",
50
- compactLater: "When this work is done, wrap up and continue? /compact"
50
+ clear: "Start fresh?",
51
+ compact: "Wrap up what you have and continue?",
52
+ compactLater: "When this work is done, wrap up and continue?"
51
53
  }
52
54
  };
55
+ var COMMANDS = { clear: "/clear", compact: "/compact", compactLater: "/compact" };
56
+ var phrase = (lang, advice) => `${QUESTIONS[lang][advice]} ${COMMANDS[advice]}`;
53
57
  function isRecord2(value) {
54
58
  return typeof value === "object" && value !== null && !Array.isArray(value);
55
59
  }
60
+ function adviceQuestion(kind, lang) {
61
+ return QUESTIONS[lang][kind];
62
+ }
56
63
  function formatSize(tokens) {
57
64
  return `${Math.round(tokens / 1e3)}k`;
58
65
  }
@@ -64,20 +71,42 @@ function adviceKind(size, judgment, config) {
64
71
  if (judgment.clear) return "clear";
65
72
  return size >= config.context.compactMinTokens ? "compact" : null;
66
73
  }
67
- function adviceLine({ size, threshold, judgment, config }) {
74
+ function increaseSuffix(increase) {
75
+ if (increase === null || increase === void 0) return "";
76
+ const k = Math.round(increase / 1e3);
77
+ return k > 0 ? ` +${k}k` : "";
78
+ }
79
+ function redRemaining(size, threshold, config) {
80
+ if (threshold === null || threshold <= 0 || size < threshold * (1 - config.context.redRemainingPct / 100)) return null;
81
+ return remainingPct(size, threshold);
82
+ }
83
+ function adviceLine({ size, threshold, judgment, config, increase }) {
68
84
  if (size === null) return "";
69
- const k = formatSize(size);
70
- const phrases = PHRASES[config.lang];
85
+ const k = `${formatSize(size)}${increaseSuffix(increase)}`;
71
86
  const counted = size >= config.context.minTokens ? judgment : null;
72
- if (threshold !== null && threshold > 0 && size >= threshold * (1 - config.context.redRemainingPct / 100)) {
73
- const head = `\u{1F534} ${k} ${remainingPct(size, threshold)}%`;
87
+ const red = redRemaining(size, threshold, config);
88
+ if (red !== null) {
89
+ const head = `\u{1F534} ${k} ${red}%`;
74
90
  if (!counted) return head;
75
- if (counted.phase === "working") return `${head} \xB7 ${phrases.compactLater}`;
76
- return `${head} \xB7 ${counted.clear ? phrases.clear : phrases.compact}`;
91
+ if (counted.phase === "working") return `${head} \xB7 ${phrase(config.lang, "compactLater")}`;
92
+ return `${head} \xB7 ${phrase(config.lang, counted.clear ? "clear" : "compact")}`;
77
93
  }
78
94
  if (!counted) return k;
79
95
  const kind = adviceKind(size, counted, config);
80
- return kind ? `\u{1F7E1} ${k} ${phrases[kind]}` : `\u{1F7E2} ${k}`;
96
+ return kind ? `\u{1F7E1} ${k} ${phrase(config.lang, kind)}` : `\u{1F7E2} ${k}`;
97
+ }
98
+ function bandAdvice({ size, threshold, judgment, config }) {
99
+ if (size === null) return null;
100
+ const counted = size >= config.context.minTokens ? judgment : null;
101
+ if (!counted || counted.phase === "working") return null;
102
+ const red = redRemaining(size, threshold, config);
103
+ if (red !== null) return { kind: counted.clear ? "clear" : "compact", remainingPct: red };
104
+ const kind = adviceKind(size, counted, config);
105
+ return kind ? { kind, remainingPct: null } : null;
106
+ }
107
+ function modTail(input) {
108
+ if (input.size === null) return "";
109
+ return bandAdvice(input) ? `${formatSize(input.size)}${increaseSuffix(input.increase)}` : adviceLine(input);
81
110
  }
82
111
  function usableJudgment(state, liveSize2, turnStartedAt) {
83
112
  if (!state || !state.judgment || state.at < turnStartedAt) return null;
@@ -91,6 +120,43 @@ function parseState(raw) {
91
120
  return { sessionId: raw.sessionId, at: raw.at, size: typeof raw.size === "number" ? raw.size : null, judgment };
92
121
  }
93
122
 
123
+ // src/display/shortcut.ts
124
+ var SHORTCUT_ACTION = "diff:back";
125
+
126
+ // src/mod/band.ts
127
+ var ICONS = { compact: "\u{1F4E6}", clear: "\u{1F9F9}" };
128
+ function drawBand(ui, { advice, config, onPress }) {
129
+ const { Box, Text, Button } = ui;
130
+ const head = advice.remainingPct === null ? "\u{1F7E1}" : `\u{1F534} ${advice.remainingPct}%`;
131
+ const controls = [
132
+ h(Button, {
133
+ key: `jev-${advice.kind}`,
134
+ label: `${ICONS[advice.kind]} /${advice.kind}`,
135
+ variant: "primary",
136
+ autoFocus: true,
137
+ hover: { bold: true },
138
+ onPress,
139
+ ...config.shortcut ? { action: SHORTCUT_ACTION } : {}
140
+ })
141
+ ];
142
+ if (config.shortcut) controls.push(h(Text, { dimColor: true }, config.shortcut));
143
+ return h(
144
+ Box,
145
+ { flexDirection: "row", flexWrap: "wrap", columnGap: 2, paddingTop: 1 },
146
+ h(
147
+ Box,
148
+ { flexDirection: "row", gap: 1, minWidth: 0, flexShrink: 1 },
149
+ h(Box, { flexShrink: 0 }, h(Text, { bold: advice.remainingPct !== null }, head)),
150
+ h(
151
+ Box,
152
+ { minWidth: 0, flexShrink: 1 },
153
+ h(Text, { wrap: "truncate-end" }, adviceQuestion(advice.kind, config.lang))
154
+ )
155
+ ),
156
+ h(Box, { key: `jev-${advice.kind}-control`, flexDirection: "row", gap: 1, flexShrink: 0 }, ...controls)
157
+ );
158
+ }
159
+
94
160
  // src/mod/register.ts
95
161
  var REFRESH_MS = 2e3;
96
162
  async function readJson($, file) {
@@ -130,13 +196,36 @@ async function liveSize($) {
130
196
  return null;
131
197
  }
132
198
  }
199
+ function reason(err) {
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ return message.replace(/^[^:]+: \$\.[\w]+(?:\.[\w]+)*: /, "");
202
+ }
203
+ async function press($, kind) {
204
+ try {
205
+ $.ui.invalidate("ui.render");
206
+ if (kind === "compact") {
207
+ const result = await $.session.compact();
208
+ if (result?.skip) $.ui.toast("/compact: skipped by a hook");
209
+ } else {
210
+ await $.command.run({ command: "clear" });
211
+ }
212
+ } catch (err) {
213
+ try {
214
+ $.ui.toast(`/${kind}: ${reason(err)}`);
215
+ } catch {
216
+ }
217
+ }
218
+ }
133
219
  function register(on, options) {
134
220
  const dataDir = typeof options.dataDir === "string" ? options.dataDir.replace(/[\\/]+$/, "") : "";
135
221
  let config = null;
136
222
  let state = null;
137
223
  let threshold = null;
138
224
  let turnStartedAt = 0;
225
+ let baseline = null;
139
226
  let seen = "";
227
+ let handledAt = -Infinity;
228
+ const judgmentFor = (size) => state && state.at <= handledAt ? null : usableJudgment(state, size, turnStartedAt);
140
229
  on("session.start", async ($, e, next) => {
141
230
  const result = await next(e);
142
231
  if (!dataDir || e.isInteractive === false) return result;
@@ -159,8 +248,10 @@ function register(on, options) {
159
248
  return result;
160
249
  });
161
250
  on("turn.start", async ($, e, next) => {
251
+ baseline = null;
162
252
  try {
163
253
  turnStartedAt = await $.clock.now();
254
+ baseline = await liveSize($);
164
255
  $.ui.invalidate("ui.render");
165
256
  } catch {
166
257
  }
@@ -171,19 +262,50 @@ function register(on, options) {
171
262
  threshold = await autoCompactThreshold($);
172
263
  return result;
173
264
  });
265
+ on("session.end", async ($, e, next) => {
266
+ baseline = null;
267
+ state = null;
268
+ seen = "";
269
+ try {
270
+ $.ui.invalidate("ui.render");
271
+ } catch {
272
+ }
273
+ return next(e);
274
+ });
174
275
  on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
175
276
  let tail = "";
176
277
  try {
177
278
  const current = config;
178
279
  if (current && current.context.enabled && current.display === "mod") {
179
280
  const size = await liveSize($);
180
- tail = adviceLine({ size, threshold, judgment: usableJudgment(state, size, turnStartedAt), config: current });
281
+ const increase = baseline !== null && size !== null ? size - baseline : null;
282
+ tail = modTail({ size, threshold, judgment: judgmentFor(size), config: current, increase });
181
283
  }
182
284
  } catch {
183
285
  tail = "";
184
286
  }
185
287
  return tail ? next({ ...e, props: { ...e.props, tail } }) : next(e);
186
288
  });
289
+ on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
290
+ let band = null;
291
+ try {
292
+ const current = config;
293
+ const props = e.props ?? {};
294
+ if (current && current.context.enabled && current.display === "mod" && props.isWorking !== true && props.hasSurvey !== true) {
295
+ const size = await liveSize($);
296
+ const advice = bandAdvice({ size, threshold, judgment: judgmentFor(size), config: current });
297
+ const at = state?.at;
298
+ if (advice && at !== void 0) band = drawBand($.ui.resolve(e), { advice, config: current, onPress: () => {
299
+ if (at <= handledAt) return;
300
+ handledAt = at;
301
+ void press($, advice.kind);
302
+ } });
303
+ }
304
+ } catch {
305
+ band = null;
306
+ }
307
+ return band ?? next(e);
308
+ });
187
309
  }
188
310
  export {
189
311
  register
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delt/claude-jev-advisor",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Unofficial Claude Code helpers using TypeSafe Jev: compact/clear advice and an rm guard",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
- "mod",
11
+ "mod/.claude-plugin/plugin.json",
12
+ "mod/hooks",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {
@@ -17,7 +18,7 @@
17
18
  "scripts": {
18
19
  "build": "tsup",
19
20
  "typecheck": "tsc --noEmit",
20
- "test": "node --import tsx --import ./test/isolate-home.ts --test --test-timeout=30000 \"test/**/*.test.ts\"",
21
+ "test": "node --import tsx --import ./test/isolate-home.ts --test --test-timeout=120000 \"test/**/*.test.ts\"",
21
22
  "prepack": "npm run build"
22
23
  },
23
24
  "keywords": [