claude-spotter 0.5.2 → 0.6.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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ **Preamble-once: 初回のみ role+schema+catalog を送り、以降は per-turn delta のみ**。v0.5.x で実測した「resumed 呼び出しが first より遅い」問題の原因に手を入れた構造変更。
6
+
7
+ ### 事の発端
8
+
9
+ v0.5.2 で可視化した duration_ms を数ターン観測したところ、`mode=first=7.4s → mode=resumed=12.5s → mode=resumed=20.2s` と、**resumed が first より遅い**結果になった。プラン §5.5 は「`--resume` で cold-start を消せる」を前提にしていたので、この傾向は設計意図と逆。
10
+
11
+ 調べたところ、v0.5.x の daemon は Haiku 呼び出しのたびに `SHARED_HEADER + catalog + user_input + instruction` を full で組み立てて送っていた。つまり `--resume` で session を継いでいるのに、毎回同じ前置きを再送して session を肥大化させていた。`--resume` の prefill caching 節約より、肥大した prompt の送信・prefill コストのほうが大きい、という構造。
12
+
13
+ 同作者の [OpenClaw](https://github.com/kitepon-rgb/OpenClaw) は Discord から同一セッションへ長期間会話を流し続ける運用で、こちらは**初回のみ role を確立し以降は差分だけ送る**形で動いている。Spotter にも同じ形を持ち込めば、resumed のコストが first より重くなる理由は消えるはず。
14
+
15
+ ### 変更点
16
+
17
+ - **[src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: `buildPreamble({ catalog })` を新設。`SHARED_HEADER` に `stage=user_input` / `stage=turn_end` 両方の判定指示と few-shot を集約し、カタログと一緒に初回 1 回だけ送る。`buildFirstStagePrompt` / `buildFinalStagePrompt` はカタログと role を剥がして per-turn payload (stage マーカー + 入力タグ) のみに縮小。`createHaikuCaller({ preamble, ... })` が optional preamble を受け取り、`isFirstCall === true` のときだけ prompt に prepend する。`reset()` は `isFirstCall = true` を復元するので role collapse 回復時は新 session に preamble が再送される。
18
+ - **[src/daemon/daemon.mjs](src/daemon/daemon.mjs)**: startup で `buildPreamble({ catalog })` を 1 回作って `createHaikuCaller` に渡す。`handleUserInput` / `handleTurnEnd` の呼び出し側はカタログを渡さないシンプルな形に戻る (カタログは daemon 内部でのみ保持、preamble に封じ込む)。
19
+ - **[test/haiku-caller.test.mjs](test/haiku-caller.test.mjs)**: `buildPreamble` が role+schema+catalog+few-shot を全て含むこと、per-turn 側のプロンプトが catalog/role を含まないこと、non-string な preamble が TypeError になることを検証。
20
+ - **[test/daemon.test.mjs](test/daemon.test.mjs)**: 既存の「every Haiku invocation receives the full catalog prompt」テストを **逆の主張 (per-turn prompt は catalog を含まない)** に置き換え。
21
+
22
+ ### 期待される効果
23
+
24
+ - **per-turn prompt サイズが大幅減**: v0.5.x の full prompt (カタログ JSON + SHARED_HEADER + few-shot で 2KB 前後) が、v0.6.0 では stage マーカー + ユーザー入力 (数百バイト) に縮む。Stop hook 側は final_response と used_tools の分だけ増えるが、カタログ再送よりは軽い。
25
+ - **resumed の cold-start 削減が数値で出るはず**: 次セッションで `mode=resumed, duration_ms=<N>` が `mode=first` より短ければ、プラン §5.5 の前提が正しく機能したことが実測で確認できる。
26
+ - **role collapse 耐性**: 既存の reset 機構はそのまま動き、session renew 時に preamble が自動で再送される構造なので、v0.5.x の回復挙動を保存。
27
+
28
+ ### 既知のリスク
29
+
30
+ - 「preamble を session replay 任せにする」ので、Anthropic 側で session replay が不完全だと Haiku が role を見失う (= role collapse 発生頻度が上がる可能性)。v0.5.0 で入れた `E_HAIKU_SCHEMA → reset()` 回復機構と `role_collapse_reset` ログで観測可能。多発するなら preamble を毎回送る形に戻す判断を v0.6.1 以降で検討。
31
+
3
32
  ## 0.5.2
4
33
 
5
34
  **Haiku 呼び出しのレイテンシ可視化 (観測性の改善のみ、機能変更なし)**。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,13 +1,17 @@
1
1
  // Session-scoped daemon — receives hook events, dispatches to handlers,
2
2
  // calls Haiku on user_input / turn_end, keeps used_tools in process memory.
3
3
  //
4
+ // v0.6.0: preamble (role + schema + catalog) is sent only on the first Haiku call of the
5
+ // session; subsequent calls send only the per-turn delta, relying on --resume to replay
6
+ // the preamble from session history. This is the OpenClaw pattern and addresses v0.5.x's
7
+ // "resumed calls slower than first" observation (prompt bloat had been outweighing the
8
+ // --resume prefill savings).
9
+ //
4
10
  // v0.5.0: Haiku calls are session-scoped at the claude -p layer (--session-id + --resume).
5
- // The daemon holds one Haiku caller for the session's lifetime; the first call establishes
6
- // the claude -p session and every subsequent call reattaches, avoiding cold-start spawn.
7
- // Role collapse (Haiku drifting into Bell's persona, previously the reason v0.4.0 reverted
8
- // to stateless) is handled by a recovery mechanism rather than structural prevention:
9
- // E_HAIKU_SCHEMA → haikuCaller.reset() + silent-pass the offending turn. This is the §0
10
- // "想定済み異常 = 記録 + 正常リターン" classification.
11
+ // Role collapse (Haiku drifting into Bell's persona) is handled by recovery rather than
12
+ // structural prevention: E_HAIKU_SCHEMA haikuCaller.reset() + silent-pass the offending
13
+ // turn (a §0 "想定済み異常 = 記録 + 正常リターン" classification). reset() restores
14
+ // isFirstCall=true so the next attempt re-sends the preamble on a fresh session.
11
15
  //
12
16
  // §5.7: event dispatch follows the envelope contract.
13
17
  // §14: unexpected errors are thrown; hooks convert them to exit codes.
@@ -26,6 +30,7 @@ import { createServer, ensureRuntimeDir, socketPath } from './transport.mjs';
26
30
  import {
27
31
  buildFirstStagePrompt,
28
32
  buildFinalStagePrompt,
33
+ buildPreamble,
29
34
  parseHaikuResponse,
30
35
  createHaikuCaller,
31
36
  HaikuError,
@@ -73,7 +78,11 @@ export async function startDaemon({
73
78
  const catalog = await loadCatalog(catalogPath);
74
79
  logFn(`catalog loaded: ${catalog.tools.length} tools from ${catalogPath}`);
75
80
 
76
- const callHaiku = haikuCaller ?? createHaikuCaller({ timeoutMs: DEFAULT_HAIKU_TIMEOUT_MS });
81
+ // v0.6.0: preamble (role + schema + catalog) is built once and threaded into the Haiku
82
+ // caller. The caller prepends it on the first call only; --resume keeps it in session
83
+ // history for all subsequent calls.
84
+ const preamble = buildPreamble({ catalog });
85
+ const callHaiku = haikuCaller ?? createHaikuCaller({ preamble, timeoutMs: DEFAULT_HAIKU_TIMEOUT_MS });
77
86
 
78
87
  // Per-turn state, reset on turn_end.
79
88
  const state = {
@@ -169,7 +178,7 @@ export async function startDaemon({
169
178
  state.lastUserInput = userInput;
170
179
  state.usedTools = []; // reset tools for this turn
171
180
 
172
- const { parsed, meta } = await runHaikuJudgment('user_input', buildFirstStagePrompt({ catalog, userInput }));
181
+ const { parsed, meta } = await runHaikuJudgment('user_input', buildFirstStagePrompt({ userInput }));
173
182
  logFn(
174
183
  `user_input: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}, mode=${meta.mode}, duration_ms=${meta.durationMs}${
175
184
  parsed.reason ? `, reason=${parsed.reason}` : ''
@@ -213,7 +222,6 @@ export async function startDaemon({
213
222
  const { parsed, meta } = await runHaikuJudgment(
214
223
  'turn_end',
215
224
  buildFinalStagePrompt({
216
- catalog,
217
225
  userInput: savedUserInput,
218
226
  usedTools: savedUsedTools,
219
227
  finalResponse,
@@ -1,17 +1,17 @@
1
1
  // claude -p --model claude-haiku-4-5-* wrapper.
2
2
  // §5.5: structured JSON I/O, no retries, schema violations throw.
3
3
  //
4
- // v0.5.0: session-scoped Haiku is back. Each daemon holds a single Haiku session-id for
5
- // the life of the Bell session; the first call uses --session-id only, subsequent calls
6
- // use --session-id + --resume to reattach to the same claude -p conversation, avoiding
7
- // per-turn cold-start spawn (observed 20–50s in v0.4.x stateless mode).
4
+ // v0.6.0: preamble-once. The full role + schema + few-shot + catalog (the "preamble") is
5
+ // sent only on the first call of a Haiku session; every subsequent call sends only the
6
+ // per-turn delta. Anthropic's --resume replays the preamble from session history, so
7
+ // Haiku keeps its role and catalog context without us re-transmitting ~2KB of boilerplate
8
+ // per turn. This is the OpenClaw pattern (same author, proven in production with Discord
9
+ // → Claude long-lived sessions). Role collapse remains handled by reset() → fresh session
10
+ // → preamble resent on next call.
8
11
  //
9
- // Role-collapse (Haiku drifting into Bell's persona over long sessions the failure mode
10
- // v0.4.0 reverted to stateless to avoid) is now handled by a recovery mechanism instead of
11
- // structural prevention: if the daemon detects a schema violation (E_HAIKU_SCHEMA) from
12
- // parseHaikuResponse, it calls reset() on the caller to renew the session-id, then silent-
13
- // passes the offending turn. See daemon.mjs for the catch site. This makes role collapse a
14
- // "想定済み異常" (CLAUDE.md §0) — recorded, recovered from, never bubbled up as exit 2.
12
+ // v0.5.x prior behaviour (re-sending full context every turn) made subsequent "resumed"
13
+ // calls *slower* than the first (prompt bloat outweighed --resume prefill savings). v0.6.0
14
+ // puts the catalog only in the first turn's user message; the session retains it for free.
15
15
 
16
16
  import { spawn } from 'node:child_process';
17
17
  import { homedir } from 'node:os';
@@ -36,13 +36,8 @@ export async function ensureWorkdir() {
36
36
  return WORKDIR;
37
37
  }
38
38
 
39
- // v0.4.3 minimization (retained in v0.5.0):
40
- // Aggressive trim no adversarial prompt-injection list, no 【最重要】 tags, no triple
41
- // restatement. The judgment-anchoring instruction at the tail stays prominent.
42
- //
43
- // Shared header (role + schema + few-shot) is identical between first/final stages, which
44
- // keeps the Anthropic prompt-cache prefix stable across calls of the same stage.
45
-
39
+ // Shared header covers BOTH stages — the preamble documents stage=user_input and
40
+ // stage=turn_end so per-turn prompts only need to announce which stage they are.
46
41
  const SHARED_HEADER = [
47
42
  'あなたは Spotter。Bell (主役の Claude) が呼び忘れるツールを検出する監査役です。',
48
43
  'ユーザーへの会話文は生成せず、必ず下記 JSON のみを返します。',
@@ -52,50 +47,55 @@ const SHARED_HEADER = [
52
47
  '- pass:true なら missing_tools は空、pass:false なら 1 件以上',
53
48
  '- JSON のみ。前置き・コードフェンス禁止',
54
49
  '',
50
+ '## 判定対象',
51
+ '各ターン、以下いずれかの stage で判定リクエストを受けます:',
52
+ '- stage=user_input: <user_input> のみ届く。when_to_use に明確に該当するツールを列挙',
53
+ '- stage=turn_end: <user_input> + <used_tools> + <final_response> が届く。既使用を除き Bell が呼び忘れたツールを列挙',
54
+ 'どちらも推測禁止。該当なしなら pass:true。',
55
+ '',
55
56
  '## 例',
56
- '- "今何時?" → {"pass":false,"missing_tools":[{"name":"current_time","reason":"時刻の直接質問"}]}',
57
- '- "ありがとう" → {"pass":true,"missing_tools":[]}',
57
+ '- stage=user_input "今何時?" → {"pass":false,"missing_tools":[{"name":"current_time","reason":"時刻の直接質問"}]}',
58
+ '- stage=user_input "ありがとう" → {"pass":true,"missing_tools":[]}',
58
59
  ].join('\n');
59
60
 
60
- // Build the first-stage prompt sent on UserPromptSubmit before tools are invoked.
61
- export function buildFirstStagePrompt({ catalog, userInput }) {
61
+ // Preamble sent exactly once per Haiku session (first call). Contains the role,
62
+ // output contract, few-shot examples, and the tool catalog. The Anthropic session
63
+ // retains this in history so subsequent --resume calls can judge with only a small
64
+ // per-turn payload.
65
+ export function buildPreamble({ catalog }) {
62
66
  return [
63
67
  SHARED_HEADER,
64
68
  '',
65
69
  '## カタログ',
66
70
  JSON.stringify(projectCatalog(catalog), null, 2),
67
- '',
68
- '## ユーザー入力',
71
+ ].join('\n');
72
+ }
73
+
74
+ // Per-turn prompt — UserPromptSubmit stage. Sent as-is on every call (first and resumed);
75
+ // createHaikuCaller prepends the preamble on first call only.
76
+ export function buildFirstStagePrompt({ userInput }) {
77
+ return [
78
+ 'stage=user_input',
69
79
  '<user_input>',
70
80
  userInput,
71
81
  '</user_input>',
72
- '',
73
- 'when_to_use に明確に該当するツールだけを列挙。推測禁止。該当なしなら pass:true。',
74
82
  ].join('\n');
75
83
  }
76
84
 
77
- // Build the final-stage prompt — Stop hook, after Bell's response.
78
- export function buildFinalStagePrompt({ catalog, userInput, usedTools, finalResponse }) {
85
+ // Per-turn prompt — Stop hook stage.
86
+ export function buildFinalStagePrompt({ userInput, usedTools, finalResponse }) {
87
+ const usedList = usedTools.length > 0 ? usedTools.map((t) => `- ${t}`).join('\n') : '(なし)';
79
88
  return [
80
- SHARED_HEADER,
81
- '',
82
- '## カタログ',
83
- JSON.stringify(projectCatalog(catalog), null, 2),
84
- '',
85
- '## ユーザー入力',
89
+ 'stage=turn_end',
86
90
  '<user_input>',
87
91
  userInput,
88
92
  '</user_input>',
89
- '',
90
- '## Bell が既に使用したツール',
91
- usedTools.length > 0 ? usedTools.map((t) => `- ${t}`).join('\n') : '(なし)',
92
- '',
93
- '## Bell の応答',
93
+ '<used_tools>',
94
+ usedList,
95
+ '</used_tools>',
94
96
  '<final_response>',
95
97
  finalResponse,
96
98
  '</final_response>',
97
- '',
98
- '既使用ツールを除き、when_to_use に明確に該当するのに Bell が呼び忘れたツールを列挙。推測禁止。該当なしなら pass:true。',
99
99
  ].join('\n');
100
100
  }
101
101
 
@@ -167,11 +167,6 @@ function truncate(s, n = 300) {
167
167
  // On Windows, the `claude` entry is typically a .cmd shim which Node's spawn cannot locate
168
168
  // without going through the shell. We use cmd.exe /c explicitly rather than spawn({ shell:
169
169
  // true }) because the latter triggers DEP0190 on Node 24+.
170
- //
171
- // v0.5.1: claude CLI rejects `--session-id` together with `--resume` unless `--fork-session`
172
- // is present (fork would create a new id, defeating the point). So first call uses
173
- // `--session-id <uuid>` to pin the id; every subsequent call uses `--resume <uuid>` alone to
174
- // re-attach. buildSpawnArgs is exported so tests can assert flag wiring without spawning.
175
170
  export function buildSpawnArgs({ claudeBin, model, sessionId, resume }) {
176
171
  const args = resume
177
172
  ? ['-p', '--resume', sessionId, '--model', model]
@@ -186,20 +181,25 @@ export function buildSpawnArgs({ claudeBin, model, sessionId, resume }) {
186
181
  // §5.5: no retry on failure. §14.1: silent fallback forbidden (role-collapse recovery in
187
182
  // daemon.mjs is an explicit §0 exception, not silent fallback).
188
183
  //
189
- // v0.5.0: session-scoped. The caller keeps one session-id for the session's lifetime, and
190
- // exposes a reset() method that rotates the session-id (used by daemon on E_HAIKU_SCHEMA
191
- // to recover from role collapse). The returned value is a function (so existing tests that
192
- // check `typeof caller === 'function'` still pass) with `reset` and `sessionId` attached.
193
- export function createHaikuCaller({ timeoutMs, claudeBin = 'claude', model = HAIKU_MODEL, env = process.env }) {
184
+ // v0.6.0: accepts an optional `preamble` string that is prepended to the user message on
185
+ // the first call only. Subsequent calls (after a successful first call) send only the
186
+ // per-turn prompt the preamble lives in Anthropic's session history via --resume. A
187
+ // reset() call (used on role collapse) restores isFirstCall=true so the preamble is
188
+ // re-sent on the next attempt with a fresh session-id.
189
+ export function createHaikuCaller({ preamble, timeoutMs, claudeBin = 'claude', model = HAIKU_MODEL, env = process.env }) {
194
190
  if (typeof timeoutMs !== 'number' || timeoutMs <= 0) {
195
191
  throw new TypeError('timeoutMs must be a positive number');
196
192
  }
193
+ if (preamble !== undefined && typeof preamble !== 'string') {
194
+ throw new TypeError('preamble must be a string if provided');
195
+ }
197
196
 
198
197
  let currentSessionId = randomUUID();
199
198
  let isFirstCall = true;
200
199
 
201
200
  const callHaiku = async function (prompt) {
202
201
  await ensureWorkdir();
202
+ const wirePrompt = (isFirstCall && preamble) ? `${preamble}\n\n${prompt}` : prompt;
203
203
  return new Promise((resolve, reject) => {
204
204
  const { cmd, cmdArgs } = buildSpawnArgs({
205
205
  claudeBin,
@@ -243,13 +243,13 @@ export function createHaikuCaller({ timeoutMs, claudeBin = 'claude', model = HAI
243
243
  return;
244
244
  }
245
245
  // Flip isFirstCall only after a successful spawn — a failed first call should
246
- // still be treated as "session not yet established" so the next attempt uses
247
- // --session-id only (not --resume against a non-existent session).
246
+ // still be treated as "preamble not yet delivered" so the next attempt re-sends
247
+ // the full prelude against a fresh --session-id (not --resume a non-existent one).
248
248
  isFirstCall = false;
249
249
  resolve(stdout);
250
250
  });
251
251
 
252
- child.stdin.end(prompt, 'utf8');
252
+ child.stdin.end(wirePrompt, 'utf8');
253
253
  });
254
254
  };
255
255