fix(graceful-handoff): model-aware context window detection (v2.1.0)
Stop hook fallback antok 200K-vindu. På Opus 4.7 (faktisk 1M) kunne auto-handoff fyre 5–7x for tidlig — estimert 70% når reell bruk var ~14%. Erstatter enkel fallback med 4-stegs resolution-kjede: 1. payload.context_window.used_percentage (autoritativ) 2. payload.context_window.context_window_size + transcript-estimat 3. MODEL_WINDOWS[payload.model.id] + estimat 4. FALLBACK_WINDOW=1_000_000 + estimat (2026-default) additionalContext-meldinger inkluderer nå [kilde: <source>] for innsyn. Brief som kilde-artefakt i docs/brief-context-window-detection.md. 6 nye tester (57 totalt). Ingen regresjoner.
This commit is contained in:
parent
346b4c4fb7
commit
40a82ccdb4
10 changed files with 347 additions and 34 deletions
|
|
@ -11,18 +11,30 @@
|
|||
// Lock file at <transcript_dir>/.handoff-lock-<session_id> prevents repeat
|
||||
// firing in the same session.
|
||||
//
|
||||
// Token estimation: char_count / 3.5 → approximate tokens. Compares against
|
||||
// context_window_size from payload (200000 fallback). Approximation is
|
||||
// known to drift ±10% — 70% threshold is conservative buffer.
|
||||
// Context resolution (4-step fallback, v2.1):
|
||||
// 1. payload.context_window.used_percentage → authoritative, model-agnostic
|
||||
// 2. payload.context_window.context_window_size + transcript estimate
|
||||
// 3. MODEL_WINDOWS[payload.model.id] + transcript estimate
|
||||
// 4. FALLBACK_WINDOW (1M, 2026 default) + transcript estimate
|
||||
// Token estimation (steps 2-4): char_count / 3.5. Approximation drifts ±10%;
|
||||
// 70% threshold is conservative buffer.
|
||||
|
||||
import { readFileSync, statSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const THRESHOLD = 0.70;
|
||||
const FALLBACK_WINDOW = 200_000;
|
||||
const FALLBACK_WINDOW = 1_000_000;
|
||||
const CHARS_PER_TOKEN = 3.5;
|
||||
|
||||
// Model → context window mapping. Sonnet 4.6 has an opt-in 1M tier that is
|
||||
// not always active and not exposed in payload — use the safer 200k default.
|
||||
const MODEL_WINDOWS = {
|
||||
'claude-opus-4-7': 1_000_000,
|
||||
'claude-sonnet-4-6': 200_000,
|
||||
'claude-haiku-4-5-20251001': 200_000,
|
||||
};
|
||||
|
||||
// Test injection: tests can override these by setting on the export.
|
||||
export const __testHooks = {
|
||||
spawn: spawnSync,
|
||||
|
|
@ -50,6 +62,32 @@ function estimateUsedPct(transcriptPath, windowSize) {
|
|||
}
|
||||
}
|
||||
|
||||
// Resolve context usage via the 4-step fallback chain documented above.
|
||||
// Returns { pct, source } or null if pct cannot be computed.
|
||||
export function resolveContextSource(payload, transcriptPath) {
|
||||
const ctx = payload?.context_window;
|
||||
const direct = ctx?.used_percentage;
|
||||
if (typeof direct === 'number' && !isNaN(direct) && direct > 0) {
|
||||
return { pct: direct / 100, source: 'direct' };
|
||||
}
|
||||
|
||||
const payloadSize = ctx?.context_window_size;
|
||||
if (typeof payloadSize === 'number' && payloadSize > 0) {
|
||||
const pct = estimateUsedPct(transcriptPath, payloadSize);
|
||||
return pct == null ? null : { pct, source: 'payload-size' };
|
||||
}
|
||||
|
||||
const modelId = payload?.model?.id;
|
||||
const mapped = modelId ? MODEL_WINDOWS[modelId] : undefined;
|
||||
if (mapped) {
|
||||
const pct = estimateUsedPct(transcriptPath, mapped);
|
||||
return pct == null ? null : { pct, source: 'model-map' };
|
||||
}
|
||||
|
||||
const pct = estimateUsedPct(transcriptPath, FALLBACK_WINDOW);
|
||||
return pct == null ? null : { pct, source: 'default-1m' };
|
||||
}
|
||||
|
||||
function emit(output) {
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
}
|
||||
|
|
@ -72,12 +110,12 @@ function main() {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
// Read context_window_size from payload if available (research/03)
|
||||
const windowSize = payload?.context_window?.context_window_size || FALLBACK_WINDOW;
|
||||
const pctRaw = estimateUsedPct(transcriptPath, windowSize);
|
||||
if (pctRaw == null) {
|
||||
// 4-step resolution: used_percentage → payload-size → model-map → 1M fallback
|
||||
const resolved = resolveContextSource(payload, transcriptPath);
|
||||
if (resolved == null) {
|
||||
process.exit(0);
|
||||
}
|
||||
const { pct: pctRaw, source } = resolved;
|
||||
const pct = Math.round(pctRaw * 100);
|
||||
|
||||
if (pctRaw < THRESHOLD) {
|
||||
|
|
@ -105,7 +143,7 @@ function main() {
|
|||
emit({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'Stop',
|
||||
additionalContext: `⚠️ Auto-handoff aborted at est. ${pct}%: CLAUDE_PLUGIN_ROOT not set, cannot locate handoff-pipeline.mjs.`,
|
||||
additionalContext: `⚠️ Auto-handoff aborted at est. ${pct}% [kilde: ${source}]: CLAUDE_PLUGIN_ROOT not set, cannot locate handoff-pipeline.mjs.`,
|
||||
},
|
||||
});
|
||||
process.exit(0);
|
||||
|
|
@ -122,7 +160,7 @@ function main() {
|
|||
emit({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'Stop',
|
||||
additionalContext: `⚠️ Auto-handoff FAILED at est. ${pct}%: ${result.error.message}. Run /graceful-handoff manually.`,
|
||||
additionalContext: `⚠️ Auto-handoff FAILED at est. ${pct}% [kilde: ${source}]: ${result.error.message}. Run /graceful-handoff manually.`,
|
||||
},
|
||||
});
|
||||
process.exit(0);
|
||||
|
|
@ -132,7 +170,7 @@ function main() {
|
|||
emit({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'Stop',
|
||||
additionalContext: `⚠️ Auto-handoff pipeline exited ${result.status} at est. ${pct}%. stderr: ${(result.stderr || '').slice(0, 300)}. Run /graceful-handoff manually.`,
|
||||
additionalContext: `⚠️ Auto-handoff pipeline exited ${result.status} at est. ${pct}% [kilde: ${source}]. stderr: ${(result.stderr || '').slice(0, 300)}. Run /graceful-handoff manually.`,
|
||||
},
|
||||
});
|
||||
process.exit(0);
|
||||
|
|
@ -146,7 +184,7 @@ function main() {
|
|||
emit({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'Stop',
|
||||
additionalContext: `⚠️ Auto-handoff at est. ${pct}%: pipeline output unparseable. Run /graceful-handoff manually.`,
|
||||
additionalContext: `⚠️ Auto-handoff at est. ${pct}% [kilde: ${source}]: pipeline output unparseable. Run /graceful-handoff manually.`,
|
||||
},
|
||||
});
|
||||
process.exit(0);
|
||||
|
|
@ -157,7 +195,7 @@ function main() {
|
|||
emit({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'Stop',
|
||||
additionalContext: `⚠️ Auto-handoff at est. ${pct}% partially completed with errors: ${errors.join('; ')}. Artifact: ${pipelineResult.artifact_path || 'not written'}. Run git push manually.`,
|
||||
additionalContext: `⚠️ Auto-handoff at est. ${pct}% [kilde: ${source}] partially completed with errors: ${errors.join('; ')}. Artifact: ${pipelineResult.artifact_path || 'not written'}. Run git push manually.`,
|
||||
},
|
||||
});
|
||||
process.exit(0);
|
||||
|
|
@ -166,7 +204,7 @@ function main() {
|
|||
emit({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'Stop',
|
||||
additionalContext: `⚠️ Auto-handoff utført ved estimert ${pct}%: artefakt ${pipelineResult.artifact_path}. Push gjenstår — kjør \`git push\` når du er klar.`,
|
||||
additionalContext: `⚠️ Auto-handoff utført ved estimert ${pct}% [kilde: ${source}]: artefakt ${pipelineResult.artifact_path}. Push gjenstår — kjør \`git push\` når du er klar.`,
|
||||
},
|
||||
});
|
||||
process.exit(0);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue