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 +29 -0
- package/package.json +1 -1
- package/src/daemon/daemon.mjs +17 -9
- package/src/daemon/haiku-caller.mjs +53 -53
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
package/src/daemon/daemon.mjs
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
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({
|
|
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
|
-
// the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
40
|
-
//
|
|
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
|
-
//
|
|
61
|
-
|
|
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
|
-
//
|
|
78
|
-
export function buildFinalStagePrompt({
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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.
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
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 "
|
|
247
|
-
// --session-id
|
|
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(
|
|
252
|
+
child.stdin.end(wirePrompt, 'utf8');
|
|
253
253
|
});
|
|
254
254
|
};
|
|
255
255
|
|