@delt/claude-jev-advisor 0.1.1 → 0.2.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/README.md +52 -20
- package/dist/cli.js +248 -34
- package/dist/context-hook.js +111 -44
- package/dist/rm-hook.js +532 -25
- package/dist/statusline.js +84 -14
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +15 -6
- package/package.json +1 -1
package/dist/context-hook.js
CHANGED
|
@@ -8,13 +8,15 @@ import fs from "fs";
|
|
|
8
8
|
import path2 from "path";
|
|
9
9
|
|
|
10
10
|
// src/config-shape.ts
|
|
11
|
+
var CONFIG_VERSION = 2;
|
|
11
12
|
var DEFAULT_CONFIG = {
|
|
12
13
|
lang: "ko",
|
|
13
14
|
keyFile: null,
|
|
14
15
|
display: "mod",
|
|
15
|
-
context: { enabled: true, minTokens:
|
|
16
|
+
context: { enabled: true, minTokens: 25e4, compactMinTokens: 25e4, redRemainingPct: 20, unitDoneYes: 0.6, phaseDoneYes: 0.6 },
|
|
16
17
|
rm: { enabled: true, jev: true, throwawayYes: 0.8, maxDirFiles: 50 }
|
|
17
18
|
};
|
|
19
|
+
var LEGACY_CONTEXT_DEFAULTS = { minTokens: 1e5, compactMinTokens: 2e5, unitDoneYes: 0.7 };
|
|
18
20
|
var LANGS = ["ko", "en"];
|
|
19
21
|
var DISPLAYS = ["mod", "statusline", "message"];
|
|
20
22
|
function isRecord(value) {
|
|
@@ -28,13 +30,17 @@ function mergeSection(defaults, raw) {
|
|
|
28
30
|
}
|
|
29
31
|
return out;
|
|
30
32
|
}
|
|
33
|
+
function withoutLegacyDefaults(context) {
|
|
34
|
+
if (!isRecord(context)) return context;
|
|
35
|
+
return Object.fromEntries(Object.entries(context).filter(([key, value]) => LEGACY_CONTEXT_DEFAULTS[key] !== value));
|
|
36
|
+
}
|
|
31
37
|
function normalizeConfig(raw) {
|
|
32
38
|
const r = isRecord(raw) ? raw : {};
|
|
33
39
|
return {
|
|
34
40
|
lang: typeof r.lang === "string" && LANGS.includes(r.lang) ? r.lang : DEFAULT_CONFIG.lang,
|
|
35
41
|
keyFile: typeof r.keyFile === "string" && r.keyFile !== "" ? r.keyFile : null,
|
|
36
42
|
display: typeof r.display === "string" && DISPLAYS.includes(r.display) ? r.display : DEFAULT_CONFIG.display,
|
|
37
|
-
context: mergeSection(DEFAULT_CONFIG.context, r.context),
|
|
43
|
+
context: mergeSection(DEFAULT_CONFIG.context, r.version === CONFIG_VERSION ? r.context : withoutLegacyDefaults(r.context)),
|
|
38
44
|
rm: mergeSection(DEFAULT_CONFIG.rm, r.rm)
|
|
39
45
|
};
|
|
40
46
|
}
|
|
@@ -75,6 +81,11 @@ function formatSize(tokens) {
|
|
|
75
81
|
function remainingPct(size, threshold) {
|
|
76
82
|
return Math.max(0, Math.round((threshold - size) / threshold * 100));
|
|
77
83
|
}
|
|
84
|
+
function adviceKind(size, judgment, config) {
|
|
85
|
+
if (size === null || size < config.context.minTokens || !judgment || judgment.phase === "working") return null;
|
|
86
|
+
if (judgment.clear) return "clear";
|
|
87
|
+
return size >= config.context.compactMinTokens ? "compact" : null;
|
|
88
|
+
}
|
|
78
89
|
function adviceLine({ size, threshold, judgment, config }) {
|
|
79
90
|
if (size === null) return "";
|
|
80
91
|
const k = formatSize(size);
|
|
@@ -87,10 +98,8 @@ function adviceLine({ size, threshold, judgment, config }) {
|
|
|
87
98
|
return `${head} \xB7 ${counted.clear ? phrases.clear : phrases.compact}`;
|
|
88
99
|
}
|
|
89
100
|
if (!counted) return k;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (size >= config.context.compactMinTokens) return `\u{1F7E1} ${k} ${phrases.compact}`;
|
|
93
|
-
return `\u{1F7E2} ${k}`;
|
|
101
|
+
const kind = adviceKind(size, counted, config);
|
|
102
|
+
return kind ? `\u{1F7E1} ${k} ${phrases[kind]}` : `\u{1F7E2} ${k}`;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
// src/jev.ts
|
|
@@ -112,6 +121,12 @@ function readJevKey(env, keyFile) {
|
|
|
112
121
|
const key = line?.slice(KEY_PREFIX.length).trim().replace(/^["']|["']$/g, "");
|
|
113
122
|
return key || null;
|
|
114
123
|
}
|
|
124
|
+
function jevKeys(env, keyFile) {
|
|
125
|
+
return [...new Set([readJevKey(env, keyFile), readJevKey({}, keyFile)].filter((key) => Boolean(key)))];
|
|
126
|
+
}
|
|
127
|
+
function withoutKeys(text, keys) {
|
|
128
|
+
return keys.reduce((out, key) => out.split(key).join("[redacted]"), text);
|
|
129
|
+
}
|
|
115
130
|
async function callJev(request, opts) {
|
|
116
131
|
const now = opts.now ?? Date.now;
|
|
117
132
|
const fetchFn = opts.fetchFn ?? fetch;
|
|
@@ -196,10 +211,15 @@ function removeState(home, sessionId) {
|
|
|
196
211
|
function logFile(home, now) {
|
|
197
212
|
return path3.join(logDir(home), `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}.jsonl`);
|
|
198
213
|
}
|
|
199
|
-
function appendLog(home, now, record) {
|
|
214
|
+
function appendLog(home, now, record, secrets = []) {
|
|
200
215
|
const file = logFile(home, now);
|
|
201
216
|
fs3.mkdirSync(path3.dirname(file), { recursive: true });
|
|
202
|
-
|
|
217
|
+
let line = JSON.stringify({ at: now.toISOString(), ...record });
|
|
218
|
+
for (const secret of secrets) {
|
|
219
|
+
if (!secret) continue;
|
|
220
|
+
for (const form of [secret, JSON.stringify(secret).slice(1, -1)]) line = line.split(form).join("[redacted]");
|
|
221
|
+
}
|
|
222
|
+
fs3.appendFileSync(file, `${line}
|
|
203
223
|
`);
|
|
204
224
|
}
|
|
205
225
|
|
|
@@ -208,18 +228,26 @@ var REPLY_EDGE_CHARS = 1500;
|
|
|
208
228
|
var QUESTIONS = {
|
|
209
229
|
unit_done: {
|
|
210
230
|
type: "noul",
|
|
211
|
-
instructions: "Has the work asked for in `recent_requests`
|
|
231
|
+
instructions: "Has Claude finished or handed back the work the user asked for in `recent_requests`, so that `last_assistant_reply` is a natural break before the user's next instruction?",
|
|
212
232
|
criteria: {
|
|
213
|
-
true: "The
|
|
214
|
-
false: "
|
|
233
|
+
true: "The asked-for work is done, checked, reported or handed back. It still counts as done when the reply also lists unverified points, risks or follow-ups, offers optional extras, or asks the user to choose or approve what comes next.",
|
|
234
|
+
false: "Claude stopped partway through the asked-for work: it needs information to continue it, is waiting for background work that belongs to it, reports a failure it has not resolved, or says it will now go on with the next step of the same request."
|
|
215
235
|
}
|
|
216
236
|
},
|
|
217
|
-
|
|
237
|
+
phase_done: {
|
|
218
238
|
type: "noul",
|
|
219
|
-
instructions: "Does `last_assistant_reply`
|
|
239
|
+
instructions: "Does `last_assistant_reply` close a whole stage of the work, so that what comes next is a new stage that can start from the saved results (commits, documents, a release) rather than from this conversation?",
|
|
220
240
|
criteria: {
|
|
221
|
-
true:
|
|
222
|
-
|
|
241
|
+
true: [
|
|
242
|
+
"A whole plan, feature, fix or multi-step job is finished and merged, pushed, released, deployed, installed or put into operation.",
|
|
243
|
+
"An investigation or analysis is finished and its final conclusion is reported or saved as a document.",
|
|
244
|
+
"A design or implementation plan is written and saved as a document, ready for review or for the next stage to work from."
|
|
245
|
+
],
|
|
246
|
+
false: [
|
|
247
|
+
"Only one task, step or part of a larger stage is done, and the same stage goes on (for example the next task of the same plan).",
|
|
248
|
+
"The reply answers a single small request or question, or makes a small edit, without closing a larger stage.",
|
|
249
|
+
"Work is still going on, failed, or is waiting for information it needs to continue."
|
|
250
|
+
]
|
|
223
251
|
}
|
|
224
252
|
}
|
|
225
253
|
};
|
|
@@ -231,19 +259,26 @@ ${text.slice(-edge)}`;
|
|
|
231
259
|
function jevRequest(requests, lastReply) {
|
|
232
260
|
return {
|
|
233
261
|
state: { recent_requests: requests, last_assistant_reply: cutReply(lastReply) },
|
|
234
|
-
questions: { unit_done: QUESTIONS.unit_done,
|
|
262
|
+
questions: { unit_done: QUESTIONS.unit_done, phase_done: QUESTIONS.phase_done }
|
|
235
263
|
};
|
|
236
264
|
}
|
|
237
265
|
function judgmentFrom(answers, config) {
|
|
238
266
|
const unit = answers.unit_done;
|
|
239
267
|
if (typeof unit !== "number") return null;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return { phase: "unit_done", clear:
|
|
268
|
+
const stage = answers.phase_done;
|
|
269
|
+
if (typeof stage === "number" && stage >= config.context.phaseDoneYes) return { phase: "unit_done", clear: true };
|
|
270
|
+
return unit >= config.context.unitDoneYes ? { phase: "unit_done", clear: false } : { phase: "working", clear: false };
|
|
243
271
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
272
|
+
var OWN_BACKGROUND_WORK = /* @__PURE__ */ new Set(["subagent", "workflow"]);
|
|
273
|
+
function backgroundTypes(value) {
|
|
274
|
+
if (!Array.isArray(value)) return [];
|
|
275
|
+
return value.map((task) => {
|
|
276
|
+
const type = typeof task === "object" && task !== null ? task.type : void 0;
|
|
277
|
+
return typeof type === "string" ? type : "unknown";
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
function busyInBackground(types) {
|
|
281
|
+
return types.some((type) => OWN_BACKGROUND_WORK.has(type));
|
|
247
282
|
}
|
|
248
283
|
|
|
249
284
|
// src/context/transcript.ts
|
|
@@ -252,7 +287,7 @@ var TAIL_START_BYTES = 1024 * 1024;
|
|
|
252
287
|
var TAIL_MAX_BYTES = 8 * 1024 * 1024;
|
|
253
288
|
var REQUEST_COUNT = 3;
|
|
254
289
|
var REQUEST_MAX_CHARS = 1e3;
|
|
255
|
-
var REQUEST_ORIGINS = /* @__PURE__ */ new Set(["human", "channel"]);
|
|
290
|
+
var REQUEST_ORIGINS = /* @__PURE__ */ new Set(["human", "channel", "peer"]);
|
|
256
291
|
function isRecord2(value) {
|
|
257
292
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
258
293
|
}
|
|
@@ -294,38 +329,66 @@ function contextSize(entries) {
|
|
|
294
329
|
}
|
|
295
330
|
return null;
|
|
296
331
|
}
|
|
297
|
-
function
|
|
332
|
+
function messageText(content) {
|
|
298
333
|
if (typeof content === "string") return content;
|
|
299
334
|
if (!Array.isArray(content)) return "";
|
|
300
335
|
return content.filter((block) => isRecord2(block) && block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
|
|
301
336
|
}
|
|
302
|
-
function
|
|
337
|
+
function isRequest(entry) {
|
|
338
|
+
return entry.type === "user" && entry.isSidechain !== true && isRecord2(entry.origin) && REQUEST_ORIGINS.has(String(entry.origin.kind));
|
|
339
|
+
}
|
|
340
|
+
function requestText(entry) {
|
|
341
|
+
return messageText(isRecord2(entry.message) ? entry.message.content : void 0).trim();
|
|
342
|
+
}
|
|
343
|
+
function recentRequests(entries, count = REQUEST_COUNT, maxChars = REQUEST_MAX_CHARS, clean = (text) => text) {
|
|
303
344
|
const found = [];
|
|
304
345
|
for (let i = entries.length - 1; i >= 0 && found.length < count; i--) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (text) found.push(text.slice(0, maxChars));
|
|
346
|
+
if (!isRequest(entries[i])) continue;
|
|
347
|
+
const text = requestText(entries[i]);
|
|
348
|
+
if (text) found.push(clean(text).slice(0, maxChars));
|
|
309
349
|
}
|
|
310
350
|
return found.reverse();
|
|
311
351
|
}
|
|
312
|
-
function
|
|
352
|
+
function replyWritten(entries, reply) {
|
|
353
|
+
const wanted = reply.trim();
|
|
354
|
+
if (!wanted) return true;
|
|
355
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
356
|
+
const entry = entries[i];
|
|
357
|
+
if (isRequest(entry)) return false;
|
|
358
|
+
if (entry.type !== "assistant" || entry.isSidechain === true || !isRecord2(entry.message)) continue;
|
|
359
|
+
const text = messageText(entry.message.content).trim();
|
|
360
|
+
if (text && wanted.endsWith(text)) return true;
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
function readTranscript(file, reply = "", limits = { start: TAIL_START_BYTES, max: TAIL_MAX_BYTES }, clean = (text) => text) {
|
|
313
365
|
for (let bytes = limits.start; ; bytes *= 2) {
|
|
314
366
|
const { entries, whole } = readTail(file, Math.min(bytes, limits.max));
|
|
315
367
|
const size = contextSize(entries);
|
|
316
|
-
const requests = recentRequests(entries);
|
|
317
|
-
if (size !== null && requests.length >= REQUEST_COUNT || whole || bytes >= limits.max)
|
|
368
|
+
const requests = recentRequests(entries, REQUEST_COUNT, REQUEST_MAX_CHARS, clean);
|
|
369
|
+
if (size !== null && requests.length >= REQUEST_COUNT || whole || bytes >= limits.max) {
|
|
370
|
+
return { size, requests, replySeen: replyWritten(entries, reply) };
|
|
371
|
+
}
|
|
318
372
|
}
|
|
319
373
|
}
|
|
320
374
|
|
|
321
375
|
// src/context/run.ts
|
|
322
|
-
var
|
|
323
|
-
|
|
324
|
-
|
|
376
|
+
var REPLY_WAIT_MS = 3e3;
|
|
377
|
+
var REPLY_POLL_MS = 100;
|
|
378
|
+
var pause2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
379
|
+
function readFacts(transcriptPath, reply, clean) {
|
|
325
380
|
try {
|
|
326
|
-
return readTranscript(transcriptPath);
|
|
381
|
+
return readTranscript(transcriptPath, reply, void 0, clean);
|
|
327
382
|
} catch {
|
|
328
|
-
return { size: null, requests: [] };
|
|
383
|
+
return { size: null, requests: [], replySeen: false };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function factsAfterReply(transcriptPath, reply, clean, sleep) {
|
|
387
|
+
if (!transcriptPath) return { size: null, requests: [], replySeen: false, waitedMs: 0 };
|
|
388
|
+
for (let waitedMs = 0; ; waitedMs += REPLY_POLL_MS) {
|
|
389
|
+
const facts = readFacts(transcriptPath, reply, clean);
|
|
390
|
+
if (facts.replySeen || waitedMs >= REPLY_WAIT_MS) return { ...facts, waitedMs };
|
|
391
|
+
await sleep(REPLY_POLL_MS);
|
|
329
392
|
}
|
|
330
393
|
}
|
|
331
394
|
async function runContextHook(event, raw, deps) {
|
|
@@ -341,7 +404,11 @@ async function runContextHook(event, raw, deps) {
|
|
|
341
404
|
}
|
|
342
405
|
if (input.stop_hook_active === true) return null;
|
|
343
406
|
const transcriptPath = typeof input.transcript_path === "string" ? input.transcript_path : null;
|
|
344
|
-
const
|
|
407
|
+
const reply = typeof input.last_assistant_message === "string" ? input.last_assistant_message : "";
|
|
408
|
+
const keys = jevKeys(deps.env, config.keyFile);
|
|
409
|
+
const clean = (text) => withoutKeys(text, keys);
|
|
410
|
+
const { size, requests, replySeen, waitedMs } = await factsAfterReply(transcriptPath, reply, clean, deps.sleep ?? pause2);
|
|
411
|
+
const background = backgroundTypes(input.background_tasks);
|
|
345
412
|
let judgment = null;
|
|
346
413
|
let reason = null;
|
|
347
414
|
let jev = null;
|
|
@@ -349,7 +416,7 @@ async function runContextHook(event, raw, deps) {
|
|
|
349
416
|
reason = "unknown_size";
|
|
350
417
|
} else if (size < config.context.minTokens) {
|
|
351
418
|
reason = "below_min";
|
|
352
|
-
} else if (
|
|
419
|
+
} else if (busyInBackground(background)) {
|
|
353
420
|
reason = "background_tasks";
|
|
354
421
|
judgment = { phase: "working", clear: false };
|
|
355
422
|
} else {
|
|
@@ -357,7 +424,7 @@ async function runContextHook(event, raw, deps) {
|
|
|
357
424
|
if (!key) {
|
|
358
425
|
reason = "no_key";
|
|
359
426
|
} else {
|
|
360
|
-
const request = jevRequest(requests,
|
|
427
|
+
const request = jevRequest(requests, clean(reply));
|
|
361
428
|
const result = await callJev(request, { key, fetchFn: deps.fetchFn });
|
|
362
429
|
jev = { state: request.state, ...result };
|
|
363
430
|
if ("error" in result) {
|
|
@@ -374,10 +441,10 @@ async function runContextHook(event, raw, deps) {
|
|
|
374
441
|
writeState(deps.home, state);
|
|
375
442
|
} catch {
|
|
376
443
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
return
|
|
444
|
+
const advice = adviceKind(size, judgment, config);
|
|
445
|
+
appendLog(deps.home, at, { helper: "context", event: "stop", sessionId, transcriptPath, size, replySeen, waitedMs, background, reason, judgment, advice, jev }, keys);
|
|
446
|
+
if (config.display !== "message" || !advice) return null;
|
|
447
|
+
return JSON.stringify({ systemMessage: adviceLine({ size, threshold: null, judgment, config }) });
|
|
381
448
|
}
|
|
382
449
|
|
|
383
450
|
// src/context/hook.ts
|