@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.
@@ -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: 1e5, compactMinTokens: 2e5, redRemainingPct: 20, unitDoneYes: 0.7, goalDoneYes: 0.8 },
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
- if (counted.phase === "working") return `\u{1F7E2} ${k}`;
91
- if (counted.clear) return `\u{1F7E1} ${k} ${phrases.clear}`;
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
- fs3.appendFileSync(file, `${JSON.stringify({ at: now.toISOString(), ...record })}
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` reached a natural stopping point in `last_assistant_reply`?",
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 requested work is finished, verified or handed over; anything still mentioned is optional.",
214
- false: "The work is still in progress: the reply asks a question it needs answered to continue, says what it will do next, or reports something still running or failing."
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
- goal_done: {
237
+ phase_done: {
218
238
  type: "noul",
219
- instructions: "Does `last_assistant_reply` wrap up the overall goal behind `recent_requests`, with no further steps on that goal left to take next?",
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: "The goal is finished: the reply hands over the result or reports a commit, release or completion, and plans or proposes no further work toward the same goal.",
222
- false: "Steps toward the same goal remain: the next task of a plan, a proposed follow-up, or a review, test or decision the user still has to make."
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, goal_done: QUESTIONS.goal_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
- if (unit < config.context.unitDoneYes) return { phase: "working", clear: false };
241
- const goal = answers.goal_done;
242
- return { phase: "unit_done", clear: typeof goal === "number" && goal >= config.context.goalDoneYes };
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
- function hasBackgroundTasks(value) {
245
- if (Array.isArray(value)) return value.length > 0;
246
- return typeof value === "object" && value !== null && Object.keys(value).length > 0;
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 requestText(content) {
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 recentRequests(entries, count = REQUEST_COUNT, maxChars = REQUEST_MAX_CHARS) {
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
- const entry = entries[i];
306
- if (entry.type !== "user" || entry.isSidechain === true || !isRecord2(entry.origin) || !REQUEST_ORIGINS.has(String(entry.origin.kind))) continue;
307
- const text = requestText(isRecord2(entry.message) ? entry.message.content : void 0).trim();
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 readTranscript(file, limits = { start: TAIL_START_BYTES, max: TAIL_MAX_BYTES }) {
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) return { size, requests };
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 ADVICE = /\/(clear|compact)$/;
323
- function factsOf(transcriptPath) {
324
- if (!transcriptPath) return { size: null, requests: [] };
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 { size, requests } = factsOf(transcriptPath);
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 (hasBackgroundTasks(input.background_tasks)) {
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, typeof input.last_assistant_message === "string" ? input.last_assistant_message : "");
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
- appendLog(deps.home, at, { helper: "context", event: "stop", sessionId, transcriptPath, size, reason, judgment, jev });
378
- if (config.display !== "message") return null;
379
- const line = adviceLine({ size, threshold: null, judgment, config });
380
- return ADVICE.test(line) ? JSON.stringify({ systemMessage: line }) : null;
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