sprag-cli 3.65.0 → 3.67.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/bin/cli.js +8 -1
- package/mod/sprag-doc2md/hooks/register.ts +34 -7
- package/mod/sprag-statusline/.claude-plugin/plugin.json +7 -0
- package/mod/sprag-statusline/hooks/hooks.json +3 -0
- package/mod/sprag-statusline/hooks/register.tsx +176 -0
- package/mod/sprag-statusline/types/index.d.ts +5 -0
- package/package.json +3 -2
- package/presets/ratchet-rules.json +7 -0
- package/src/agents.js +33 -11
- package/src/commands/cowork.js +107 -0
- package/src/commands/delegate.js +43 -0
- package/src/commands/doc2md.js +10 -1
- package/src/commands/install.js +13 -0
- package/src/commands/route-scan.js +6 -0
- package/src/commands/uninstall.js +4 -0
- package/src/cowork-sync.js +361 -0
- package/src/delegation-agents.js +133 -0
- package/src/doc2md.cjs +33 -28
- package/src/harness.js +1 -1
- package/src/implementation-delegation.js +5 -2
- package/src/installer.js +23 -1
- package/src/model-alias.js +66 -15
- package/src/model-rules.js +12 -5
- package/src/route-inject.js +65 -8
package/src/doc2md.cjs
CHANGED
|
@@ -678,8 +678,9 @@ function decideForRead(context, opts = {}) {
|
|
|
678
678
|
return { deny: true, reason: bits.join('\n') };
|
|
679
679
|
}
|
|
680
680
|
|
|
681
|
-
// From here down the Read is allowed through untouched
|
|
682
|
-
//
|
|
681
|
+
// From here down the Read is allowed through untouched, except for the
|
|
682
|
+
// locked and unsafe-archive cases below. The only question is whether the
|
|
683
|
+
// model is told why nothing was converted.
|
|
683
684
|
if (result.reason === 'no-markitdown') {
|
|
684
685
|
if (noticeAlreadyShown()) return null;
|
|
685
686
|
markNoticeShown();
|
|
@@ -690,21 +691,10 @@ function decideForRead(context, opts = {}) {
|
|
|
690
691
|
+ ` 진행 상황: ${INSTALL_HINT} 를 직접 실행하면 설치 로그를 볼 수 있습니다.`,
|
|
691
692
|
};
|
|
692
693
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
+ ' DRM 은 암호 입력으로 풀리지 않습니다. 벤더 에이전트가 허용한 프로그램에서만 평문이 보이므로, '
|
|
698
|
-
+ '사용자에게 DRM 클라이언트에서 연 뒤 해제본으로 저장하거나 반출 승인을 받은 사본을 요청하십시오.',
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
if (result.reason === 'encrypted') {
|
|
702
|
-
return {
|
|
703
|
-
deny: false,
|
|
704
|
-
reason: `[doc2md] ${name} 는 암호가 걸린 문서라 변환하지 못했습니다. `
|
|
705
|
-
+ '사용자에게 암호를 푼 사본을 요청하십시오. 이 도구는 암호를 묻거나 저장하지 않습니다.',
|
|
706
|
-
};
|
|
707
|
-
}
|
|
694
|
+
// An encrypted document blocks the Read. It cannot be opened anyway, and
|
|
695
|
+
// handing the original on only invites the model to try to get around it.
|
|
696
|
+
const locked = lockedNotice(name, result, 'ko');
|
|
697
|
+
if (locked) return { deny: true, reason: '[doc2md] ' + locked };
|
|
708
698
|
if (result.reason === 'python-too-old') {
|
|
709
699
|
if (noticeAlreadyShown()) return null;
|
|
710
700
|
markNoticeShown();
|
|
@@ -827,6 +817,23 @@ function conversionSummary(sourcePath, meta = {}) {
|
|
|
827
817
|
return parts.join(', ');
|
|
828
818
|
}
|
|
829
819
|
|
|
820
|
+
/**
|
|
821
|
+
* The one sentence for a document that cannot be opened because it is
|
|
822
|
+
* encrypted, or null for any other result.
|
|
823
|
+
*
|
|
824
|
+
* Password protection and DRM are deliberately not told apart here. To the
|
|
825
|
+
* user both are a document that cannot be opened, and releasing either one
|
|
826
|
+
* goes through the company's own export procedure, so asking for an unlocked
|
|
827
|
+
* copy is never something the model should do. The Read hook, the prompt hook,
|
|
828
|
+
* the CLI diagnostic and the mod all share this wording.
|
|
829
|
+
*/
|
|
830
|
+
function lockedNotice(name, result, lang = 'en') {
|
|
831
|
+
if (!result || (result.reason !== 'encrypted' && result.reason !== 'drm-protected')) return null;
|
|
832
|
+
return lang === 'ko'
|
|
833
|
+
? `${name} 는 암호화된 문서라 내용을 확인할 수 없습니다${result.detail ? `(${result.detail})` : ''}. 사용자에게 그대로 알리고, 암호 해제나 다른 변환 방법은 시도하지 마십시오.`
|
|
834
|
+
: `${name} is encrypted, so its contents cannot be read${result.detail ? ` (${result.detail})` : ''}. Tell the user so, and do not try to unlock it or convert it another way.`;
|
|
835
|
+
}
|
|
836
|
+
|
|
830
837
|
/**
|
|
831
838
|
* Context to inject for a UserPromptSubmit payload, or null.
|
|
832
839
|
*
|
|
@@ -863,14 +870,8 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
863
870
|
: lang === 'ko'
|
|
864
871
|
? ` ${name}: 변환기를 설치하는 중입니다(첫 실행에만 걸립니다). 설치가 끝나면 다음 요청부터 자동 변환됩니다.`
|
|
865
872
|
: ` ${name}: the converter is installing now (first run only). It will convert automatically from the next request.`);
|
|
866
|
-
} else if (result.reason === 'drm-protected') {
|
|
867
|
-
lines.push(
|
|
868
|
-
? ` ${name}: DRM 으로 보호된 파일입니다(${result.detail || ''}). 암호로는 풀 수 없으므로, DRM 클라이언트에서 저장한 해제본이나 반출 승인 사본을 달라고 사용자에게 요청하십시오.`
|
|
869
|
-
: ` ${name}: the file is DRM-wrapped (${result.detail || ''}). A password will not open it — ask the user for a copy released from DRM.`);
|
|
870
|
-
} else if (result.reason === 'encrypted') {
|
|
871
|
-
lines.push(lang === 'ko'
|
|
872
|
-
? ` ${name}: 암호가 걸린 문서입니다. 암호를 푼 사본을 달라고 사용자에게 요청하십시오.`
|
|
873
|
-
: ` ${name}: the document is password-protected. Ask the user for an unlocked copy.`);
|
|
873
|
+
} else if (result.reason === 'drm-protected' || result.reason === 'encrypted') {
|
|
874
|
+
lines.push(` ${lockedNotice(name, result, lang)}`);
|
|
874
875
|
} else if (result.reason === 'python-too-old') {
|
|
875
876
|
lines.push(lang === 'ko'
|
|
876
877
|
? ` ${name}: 변환기가 Python 3.10 이상을 요구하는데 PATH 에 없습니다(macOS 기본은 3.9). \`brew install python\` 후 다시 시도하도록 사용자에게 안내하십시오.`
|
|
@@ -921,15 +922,17 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
921
922
|
/**
|
|
922
923
|
* The standing note injected at session start.
|
|
923
924
|
*
|
|
924
|
-
*
|
|
925
|
-
* costs far more than naming its path,
|
|
926
|
-
* will meet on a pptx has a one-command answer
|
|
925
|
+
* Three things the model cannot work out on its own: that attaching a document
|
|
926
|
+
* costs far more than naming its path, that the binary-file refusal it
|
|
927
|
+
* will meet on a pptx has a one-command answer, and that an encrypted document
|
|
928
|
+
* is to be reported, not worked around.
|
|
927
929
|
*/
|
|
928
930
|
function sessionNote(lang = 'en') {
|
|
929
931
|
if (lang === 'ko') {
|
|
930
932
|
return [
|
|
931
933
|
'[doc2md] 문서(pptx·xlsx·pdf·docx)를 다룰 때의 규칙입니다.',
|
|
932
934
|
' 문서를 열어야 하는데 Read 가 이진 파일이라며 거부하면, `sprag doc2md <경로>` 를 실행하고 그 결과로 나온 .md 를 Read 하십시오.',
|
|
935
|
+
' 변환이 암호화 문서(encrypted·drm-protected)라는 이유로 실패하면, 내용을 확인할 수 없다고 사용자에게 알리고 암호 해제나 다른 변환 방법은 시도하지 마십시오.',
|
|
933
936
|
' 사용자가 문서를 메시지에 직접 첨부했다면, 첨부는 내용 전체가 컨텍스트에 실려 토큰을 크게 씁니다. 다음부터는 첨부 대신 파일 경로를 적어 달라고 안내하십시오. 경로로 주면 변환본만 읽습니다.',
|
|
934
937
|
' 변환은 단방향입니다. 변환본 .md 를 고쳐도 원본에는 반영되지 않습니다. 문서 수정 요청을 받으면: ① 원본을 복사하고 ② 복사본을 스크립트로 수정하십시오. pptx·docx·xlsx 는 도구 venv 파이썬에 python-pptx·python-docx·openpyxl 이 준비되어 있고(`sprag doc2md` 상태 출력에 경로가 나옵니다), .fig 는 openfig-core 로 편집·재인코드합니다. ③ 수정한 복사본을 doc2md 로 재변환해 변경이 들어갔는지 검증하십시오. 차트·이미지 같은 시각 요소는 변환본에 안 잡히므로 텍스트 검증만으로 완료를 단정하지 마십시오.',
|
|
935
938
|
].join('\n');
|
|
@@ -937,6 +940,7 @@ function sessionNote(lang = 'en') {
|
|
|
937
940
|
return [
|
|
938
941
|
'[doc2md] Handling documents (pptx/xlsx/pdf/docx):',
|
|
939
942
|
' If Read refuses a document as a binary file, run `sprag doc2md <path>` and Read the .md it prints.',
|
|
943
|
+
' If conversion fails because the document is encrypted (encrypted or drm-protected), tell the user its contents cannot be read, and do not try to unlock it or convert it another way.',
|
|
940
944
|
' If the user attached a document to their message, its full contents were billed into the context. Tell them that naming the file path instead is far cheaper, since only the converted Markdown gets read.',
|
|
941
945
|
' Conversions are one-way: editing the cached .md changes nothing in the source. When asked to modify a document: ① copy the original, ② edit the COPY with a script — the managed venv python has python-pptx/python-docx/openpyxl, and .fig edits go through openfig-core — then ③ re-convert the copy with doc2md to verify the change landed. Charts and images do not appear in conversions, so text verification alone does not prove visual edits.',
|
|
942
946
|
].join('\n');
|
|
@@ -1043,6 +1047,7 @@ module.exports = {
|
|
|
1043
1047
|
isTargetPath,
|
|
1044
1048
|
isSensitivePath,
|
|
1045
1049
|
documentPathsIn,
|
|
1050
|
+
lockedNotice,
|
|
1046
1051
|
contextForPrompt,
|
|
1047
1052
|
sessionNote,
|
|
1048
1053
|
findInterpreter,
|
package/src/harness.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
|
|
2
|
+
|
|
1
3
|
/** Shared policy, rendered with each client's native model selector. */
|
|
2
4
|
export function implementationDelegationRule(agent = 'claude', lang = 'en') {
|
|
3
5
|
const target = agent === 'codex'
|
|
4
6
|
? 'the resolved T1 model for the current parent model and provider (explicit mapping first, automatic resolution otherwise)'
|
|
5
|
-
: 'a model: sonnet subagent';
|
|
7
|
+
: (resolveAgentName('sonnet', null) ? agentPhraseEn('sonnet', { root: null }) : 'a model: sonnet subagent');
|
|
8
|
+
const targetKo = resolveAgentName('sonnet', null) ? agentPhrase('sonnet', { root: null }) : 'model: sonnet 서브에이전트';
|
|
6
9
|
if (lang === 'ko') return [
|
|
7
10
|
'- **구현 분업**: 여러 파일을 고칠 때 설계와 스펙 확정은 메인 모델이 맡습니다.',
|
|
8
|
-
` 구현은 ${agent === 'codex' ? '현재 부모 모델과 프로바이더의 T1 모델' :
|
|
11
|
+
` 구현은 ${agent === 'codex' ? '현재 부모 모델과 프로바이더의 T1 모델' : targetKo}에 위임합니다.`,
|
|
9
12
|
' Claude는 sonnet을 쓰고, Codex는 명시적 T1 매핑을 우선하며 없으면 자동 해석한 T1을 씁니다.',
|
|
10
13
|
' 두 모델의 성능이 같다고 가정하지 않으며 haiku나 T2에 구현을 맡기지 않습니다.',
|
|
11
14
|
' 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산과',
|
package/src/installer.js
CHANGED
|
@@ -709,7 +709,8 @@ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
709
709
|
const PLUGIN_DIRS_ENV = 'CLAUDE_CODE_PLUGIN_DIRS';
|
|
710
710
|
export const DOC2MD_MOD = 'sprag-doc2md';
|
|
711
711
|
export const DELEGATION_METER_MOD = 'sprag-delegation-meter';
|
|
712
|
-
export const
|
|
712
|
+
export const STATUSLINE_MOD = 'sprag-statusline';
|
|
713
|
+
export const MOD_NAMES = [DOC2MD_MOD, DELEGATION_METER_MOD, STATUSLINE_MOD];
|
|
713
714
|
|
|
714
715
|
export function modDir(name) {
|
|
715
716
|
return join(PACKAGE_ROOT, 'mod', name);
|
|
@@ -809,6 +810,26 @@ export function installDelegationMeterMod() {
|
|
|
809
810
|
return { path: file, action: read.state === 'absent' ? 'created' : 'updated' };
|
|
810
811
|
}
|
|
811
812
|
|
|
813
|
+
// The statusline mod draws sprag's statusline in the Claude desktop app, whose
|
|
814
|
+
// Code tab never runs settings.json's statusLine.command (see
|
|
815
|
+
// mod/sprag-statusline). The terminal keeps its own statusline; the mod draws
|
|
816
|
+
// on the desktop surface only. Ships on with installAll() and is taken out
|
|
817
|
+
// with every other mod by uninstallAll().
|
|
818
|
+
export function installStatuslineMod() {
|
|
819
|
+
const dir = claudeUserDir();
|
|
820
|
+
const file = join(dir, 'settings.json');
|
|
821
|
+
mkdirSync(dir, { recursive: true });
|
|
822
|
+
const read = readSettings(file);
|
|
823
|
+
if (read.state === 'unusable') return { path: file, action: 'skipped', reason: read.reason };
|
|
824
|
+
const settings = read.settings;
|
|
825
|
+
const changed = addModDir(settings, STATUSLINE_MOD);
|
|
826
|
+
if (typeof changed === 'string') return { path: file, action: 'skipped', reason: changed };
|
|
827
|
+
if (!changed) return { path: file, action: 'exists' };
|
|
828
|
+
const writeProblem = writeSettings(file, settings);
|
|
829
|
+
if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
|
|
830
|
+
return { path: file, action: read.state === 'absent' ? 'created' : 'updated' };
|
|
831
|
+
}
|
|
832
|
+
|
|
812
833
|
// Registers the PreToolUse hook that converts attached documents to Markdown
|
|
813
834
|
// before the model reads them. PreToolUse rather than PostToolUse because the
|
|
814
835
|
// point is to intervene before a pptx lands in the context window; afterwards
|
|
@@ -1247,6 +1268,7 @@ export function installAll({ force = false } = {}) {
|
|
|
1247
1268
|
sessionStartHook: installSessionStartHook(),
|
|
1248
1269
|
delegationHook: installDelegationHook(),
|
|
1249
1270
|
delegationMeterMod: installDelegationMeterMod(),
|
|
1271
|
+
statuslineMod: installStatuslineMod(),
|
|
1250
1272
|
briefHook: installBriefHook(),
|
|
1251
1273
|
cacheMonitorMigration: migrateLegacyCacheMonitorHook(),
|
|
1252
1274
|
legacy: removeLegacyCommand(),
|
package/src/model-alias.js
CHANGED
|
@@ -371,18 +371,59 @@ function addVote(votes, pid, role, kind) {
|
|
|
371
371
|
}
|
|
372
372
|
|
|
373
373
|
/**
|
|
374
|
-
*
|
|
375
|
-
*
|
|
374
|
+
* The model id Claude Code told a transcript's agent it runs on: the
|
|
375
|
+
* `attachment.type === 'model'` record (`identity.modelId`) written at the
|
|
376
|
+
* start of a session and of every subagent run. On a gateway `message.model`
|
|
377
|
+
* is an opaque profile ARN, while this names what the alias resolved to.
|
|
378
|
+
*/
|
|
379
|
+
function declaredModelOf(entry) {
|
|
380
|
+
const a = entry?.type === 'attachment' ? entry.attachment : null;
|
|
381
|
+
if (!a || a.type !== 'model') return null;
|
|
382
|
+
const id = a.identity?.modelId;
|
|
383
|
+
return typeof id === 'string' && id ? id : null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The role one delegated run votes for its profile id, or null for no vote.
|
|
388
|
+
*
|
|
389
|
+
* What the run was told it is outranks what its caller asked for. A Task that
|
|
390
|
+
* asks for haiku in a session whose haiku alias resolves to nothing runs on
|
|
391
|
+
* the session model instead: the Claude Desktop app does this when its
|
|
392
|
+
* provider settings register no haiku model, and the transcript keeps the
|
|
393
|
+
* `model: "haiku"` request while the run answers on the parent's profile.
|
|
394
|
+
* Counting the request stamped that profile 'haiku' through the explicit-veto
|
|
395
|
+
* path in tallyVotes (1 explicit vote against 4,153 inferred 'main' ones,
|
|
396
|
+
* measured 2026-10-12), and once the gateway table aged out every record of
|
|
397
|
+
* the session model would have priced as Haiku.
|
|
398
|
+
*
|
|
399
|
+
* A declared id that names a family decides. One that is opaque too (an alias
|
|
400
|
+
* pointed at a profile ARN) and equals the parent's own declared model means
|
|
401
|
+
* the run fell back, so it says nothing about the tier it was asked for.
|
|
402
|
+
* Transcripts written before the declaration existed keep voting the request.
|
|
403
|
+
*/
|
|
404
|
+
export function voteRole(requested, declared, parentDeclared) {
|
|
405
|
+
const stated = roleFromModelParam(declared);
|
|
406
|
+
if (stated) return stated;
|
|
407
|
+
if (declared && declared === parentDeclared && requested && !rolesAgree(requested, 'main')) return null;
|
|
408
|
+
return requested || null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Read one main transcript: which profile id the session itself ran on, the
|
|
413
|
+
* model it was declared to run on, and which role each Task/Agent tool_use
|
|
414
|
+
* asked for (joined later by tool_use id).
|
|
376
415
|
*/
|
|
377
416
|
async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCache) {
|
|
378
417
|
let sawGateway = false;
|
|
418
|
+
let declared = null;
|
|
379
419
|
const rl = createInterface({
|
|
380
420
|
input: createReadStream(path, { encoding: 'utf8' }),
|
|
381
421
|
crlfDelay: Infinity,
|
|
382
422
|
});
|
|
383
423
|
try {
|
|
384
424
|
for await (const line of rl) {
|
|
385
|
-
if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')
|
|
425
|
+
if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')
|
|
426
|
+
&& !line.includes('"identity"')) {
|
|
386
427
|
continue;
|
|
387
428
|
}
|
|
388
429
|
let entry;
|
|
@@ -391,6 +432,7 @@ async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCach
|
|
|
391
432
|
} catch {
|
|
392
433
|
continue;
|
|
393
434
|
}
|
|
435
|
+
if (!declared && entry.isSidechain !== true) declared = declaredModelOf(entry);
|
|
394
436
|
const msg = entry.message;
|
|
395
437
|
if (!msg) continue;
|
|
396
438
|
|
|
@@ -418,31 +460,36 @@ async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCach
|
|
|
418
460
|
} finally {
|
|
419
461
|
rl.close();
|
|
420
462
|
}
|
|
421
|
-
return sawGateway;
|
|
463
|
+
return { sawGateway, declared };
|
|
422
464
|
}
|
|
423
465
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
466
|
+
/**
|
|
467
|
+
* First profile id used by a subagent transcript (a run uses exactly one),
|
|
468
|
+
* and the model the run was declared to run on, which comes before it.
|
|
469
|
+
*/
|
|
470
|
+
async function subagentModelInfo(path) {
|
|
471
|
+
let declared = null;
|
|
426
472
|
const rl = createInterface({
|
|
427
473
|
input: createReadStream(path, { encoding: 'utf8' }),
|
|
428
474
|
crlfDelay: Infinity,
|
|
429
475
|
});
|
|
430
476
|
try {
|
|
431
477
|
for await (const line of rl) {
|
|
432
|
-
if (!line.includes('arn:aws:bedrock:')) continue;
|
|
478
|
+
if (!line.includes('arn:aws:bedrock:') && !line.includes('"identity"')) continue;
|
|
433
479
|
let entry;
|
|
434
480
|
try {
|
|
435
481
|
entry = JSON.parse(line);
|
|
436
482
|
} catch {
|
|
437
483
|
continue;
|
|
438
484
|
}
|
|
485
|
+
declared ??= declaredModelOf(entry);
|
|
439
486
|
const pid = profileIdFrom(entry.message?.model);
|
|
440
|
-
if (pid) return pid;
|
|
487
|
+
if (pid) return { pid, declared };
|
|
441
488
|
}
|
|
442
489
|
} finally {
|
|
443
490
|
rl.close();
|
|
444
491
|
}
|
|
445
|
-
return null;
|
|
492
|
+
return { pid: null, declared };
|
|
446
493
|
}
|
|
447
494
|
|
|
448
495
|
/** Subagent transcripts a session spawned (mirrors subagent-records layout). */
|
|
@@ -580,7 +627,8 @@ function newestFirst(paths) {
|
|
|
580
627
|
* The join is exact rather than time-windowed: `.meta.json` carries the
|
|
581
628
|
* `toolUseId` of the Task block that spawned the run, and that block names the
|
|
582
629
|
* model tier. A subagent transcript uses exactly one profile id, so the run's
|
|
583
|
-
* id and the requested role identify each other
|
|
630
|
+
* id and the requested role identify each other, unless the run declares that
|
|
631
|
+
* it ran on another model (see voteRole).
|
|
584
632
|
*
|
|
585
633
|
* @param {object} opts
|
|
586
634
|
* @param {string[]} opts.sessionPaths transcripts to read, in any order — they
|
|
@@ -597,8 +645,9 @@ export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40
|
|
|
597
645
|
for (const sessionPath of newestFirst(sessionPaths).slice(0, maxSessions)) {
|
|
598
646
|
const requestedByToolUse = new Map();
|
|
599
647
|
let sawGateway = false;
|
|
648
|
+
let parentDeclared = null;
|
|
600
649
|
try {
|
|
601
|
-
sawGateway = await scanMainTranscript(sessionPath, votes, requestedByToolUse, agentTypeCache);
|
|
650
|
+
({ sawGateway, declared: parentDeclared } = await scanMainTranscript(sessionPath, votes, requestedByToolUse, agentTypeCache));
|
|
602
651
|
} catch {
|
|
603
652
|
continue;
|
|
604
653
|
}
|
|
@@ -609,14 +658,16 @@ export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40
|
|
|
609
658
|
try {
|
|
610
659
|
meta = JSON.parse(await readFile(jsonl.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
|
|
611
660
|
} catch { /* pre-toolUseId runs simply cast no vote */ }
|
|
612
|
-
const
|
|
661
|
+
const requested = (meta?.toolUseId && requestedByToolUse.get(meta.toolUseId))
|
|
613
662
|
|| roleFromAgentType(meta?.agentType, agentTypeCache);
|
|
614
|
-
if (!
|
|
615
|
-
const pid = await
|
|
663
|
+
if (!requested) continue;
|
|
664
|
+
const { pid, declared } = await subagentModelInfo(jsonl);
|
|
616
665
|
if (!pid) continue;
|
|
666
|
+
const role = voteRole(requested, declared, parentDeclared);
|
|
667
|
+
if (!role) continue;
|
|
617
668
|
sawGateway = true;
|
|
618
669
|
// Stated evidence: the Task call named this tier, or the agent
|
|
619
|
-
// definition it used did.
|
|
670
|
+
// definition it used did, or the run itself declared what it ran on.
|
|
620
671
|
addVote(votes, pid, role, 'explicit');
|
|
621
672
|
}
|
|
622
673
|
if (sawGateway) gateway = true;
|
package/src/model-rules.js
CHANGED
|
@@ -33,7 +33,7 @@ import { join, dirname } from 'node:path';
|
|
|
33
33
|
import { homedir } from 'node:os';
|
|
34
34
|
import { userDataDir } from './paths.js';
|
|
35
35
|
import { userLanguage } from './config.js';
|
|
36
|
-
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
36
|
+
import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
|
|
37
37
|
import { writeStateFile } from './state-file.js';
|
|
38
38
|
import { signedUsd } from './money.js';
|
|
39
39
|
import { implementationDelegationRule } from './implementation-delegation.js';
|
|
@@ -132,16 +132,23 @@ function shortExample(text) {
|
|
|
132
132
|
return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// T1 is named through its delegation agent only when that file exists; without
|
|
136
|
+
// it the literal `model: sonnet` stays so output on such machines is unchanged.
|
|
137
|
+
function t1Target(root, ko) {
|
|
138
|
+
if (resolveAgentName('sonnet', root) === null) return null;
|
|
139
|
+
return (ko ? agentPhrase : agentPhraseEn)('sonnet', { root });
|
|
140
|
+
}
|
|
141
|
+
|
|
135
142
|
export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
|
|
136
143
|
if (lang === 'ko') {
|
|
137
144
|
return g.tier === 'T2'
|
|
138
145
|
? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
|
|
139
|
-
: `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 model: sonnet에 위임`;
|
|
146
|
+
: `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
|
|
140
147
|
}
|
|
141
148
|
const example = g.exampleEn || g.example;
|
|
142
149
|
return g.tier === 'T2'
|
|
143
150
|
? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
|
|
144
|
-
: `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to a model: sonnet subagent`;
|
|
151
|
+
: `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
// See the note in route-scan.js — paths.js is the only place that resolves
|
|
@@ -401,10 +408,10 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
401
408
|
// match at a glance.
|
|
402
409
|
const rule = ko
|
|
403
410
|
? `"${t2.label}": 기본 ${agentPhrase(t2.agent, { root })}(예: "${ex(t2)}"), ` +
|
|
404
|
-
`여러 단계·여러 파일이 얽히면 model: sonnet(예: "${ex(t1)}"). ` +
|
|
411
|
+
`여러 단계·여러 파일이 얽히면 ${t1Target(root, true) ?? 'model: sonnet'}(예: "${ex(t1)}"). ` +
|
|
405
412
|
mergedBudget(t2, t1)
|
|
406
413
|
: `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent, { root })} by default (e.g. "${ex(t2)}"), ` +
|
|
407
|
-
|
|
414
|
+
`${t1Target(root, false) ?? 'model: sonnet'} when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
|
|
408
415
|
mergedBudget(t2, t1);
|
|
409
416
|
lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)}${extraHealth} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)}${extraStats} -->`);
|
|
410
417
|
} else if (t2 || t1) {
|
package/src/route-inject.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
import { categorizeScored, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
|
|
35
35
|
import { loadModelRules, budgetCapPhrase, ruleBudget } from './model-rules.js';
|
|
36
36
|
import { sharedProjectRoots, realProjectRoot, scopeRank } from './shared-model-rules.js';
|
|
37
|
-
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
37
|
+
import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
|
|
38
38
|
import { userLanguage } from './config.js';
|
|
39
39
|
import { aliasForRole, resolveModelAlias } from './model-alias.js';
|
|
40
40
|
import { isRecognizedModelId, modelRank } from './cost.js';
|
|
@@ -111,6 +111,29 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
|
|
|
111
111
|
return rankOf(snap && snap.model);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Tiers a delegation cannot reach in this session, because the alias the hint
|
|
116
|
+
* names resolves to no model here.
|
|
117
|
+
*
|
|
118
|
+
* A host that manages the provider writes the ANTHROPIC_DEFAULT_*_MODEL
|
|
119
|
+
* variables itself, from the models registered in its own settings, and
|
|
120
|
+
* leaves the unregistered ones empty; settings.json `env` does not override
|
|
121
|
+
* them. The Claude Desktop app does this in its third-party mode
|
|
122
|
+
* (CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1), and a Task(model: "haiku") there
|
|
123
|
+
* runs on the session model. Measured 2026-10-12: with no haiku registered the
|
|
124
|
+
* run answered on the parent's Opus profile, and after registering one, on the
|
|
125
|
+
* Haiku profile. Hinting a delegation in that state costs a subagent's context
|
|
126
|
+
* and saves nothing. Outside such a host an unset variable selects Claude
|
|
127
|
+
* Code's built-in default for the tier, so nothing is reported unreachable.
|
|
128
|
+
*/
|
|
129
|
+
const TIER_ALIAS_ENV = { T2: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', T1: 'ANTHROPIC_DEFAULT_SONNET_MODEL' };
|
|
130
|
+
const TIER_ALIAS = { T2: 'haiku', T1: 'sonnet' };
|
|
131
|
+
|
|
132
|
+
export function unreachableTiers(env = process.env) {
|
|
133
|
+
if (env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST !== '1') return [];
|
|
134
|
+
return Object.keys(TIER_ALIAS_ENV).filter((tier) => !env[TIER_ALIAS_ENV[tier]]);
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
/**
|
|
115
138
|
* The rules a prompt matches, or null when the hint should stay quiet.
|
|
116
139
|
*
|
|
@@ -120,9 +143,13 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
|
|
|
120
143
|
* @param {number|null} [opts.sessionRank] price rank of the model reading the
|
|
121
144
|
* hint (see sessionModelRank). null means unknown, and an unknown session
|
|
122
145
|
* model filters nothing.
|
|
123
|
-
* @
|
|
146
|
+
* @param {object} [opts.env] environment to read tier aliases from (see
|
|
147
|
+
* unreachableTiers)
|
|
148
|
+
* @returns {{cat: object, t2: object|undefined, t1: object|undefined, unreachable?: string[]}|null}
|
|
149
|
+
* `unreachable` is set, with neither t2 nor t1, when every matching tier is
|
|
150
|
+
* one this session cannot reach
|
|
124
151
|
*/
|
|
125
|
-
export function routeMatch(text, { rules, sessionRank = null, root } = {}) {
|
|
152
|
+
export function routeMatch(text, { rules, sessionRank = null, root, env = process.env } = {}) {
|
|
126
153
|
const t = String(text || '').trim();
|
|
127
154
|
if (t.length < MIN_LEN) return null;
|
|
128
155
|
// Judgement and irreversible work stay on the top tier. This check comes
|
|
@@ -167,9 +194,15 @@ export function routeMatch(text, { rules, sessionRank = null, root } = {}) {
|
|
|
167
194
|
? matched
|
|
168
195
|
: matched.filter((r) => worthDelegating(r.tier, sessionRank));
|
|
169
196
|
if (!usable.length) return null;
|
|
197
|
+
// A tier whose alias resolves to nothing would run on the session model, so
|
|
198
|
+
// it is dropped like a tier that saves nothing. When that empties the list
|
|
199
|
+
// the match is still reported, so the hint can say why it does not delegate.
|
|
200
|
+
const unreachable = unreachableTiers(env);
|
|
201
|
+
const reachable = usable.filter((r) => !unreachable.includes(r.tier));
|
|
202
|
+
if (!reachable.length) return { cat, unreachable: [...new Set(usable.map((r) => r.tier))] };
|
|
170
203
|
|
|
171
204
|
// The closest scope states the cap, whatever order the registry holds.
|
|
172
|
-
const nearest = [...
|
|
205
|
+
const nearest = [...reachable].sort((a, b) => scopeRank(b) - scopeRank(a));
|
|
173
206
|
const t2 = nearest.find((r) => r.tier === 'T2');
|
|
174
207
|
const t1 = nearest.find((r) => r.tier === 'T1');
|
|
175
208
|
return t2 || t1 ? { cat, t2, t1 } : null;
|
|
@@ -184,6 +217,24 @@ export function matchCaps(match) {
|
|
|
184
217
|
return caps;
|
|
185
218
|
}
|
|
186
219
|
|
|
220
|
+
/**
|
|
221
|
+
* What the hint says instead of a delegation when every matching tier is
|
|
222
|
+
* unreachable (see unreachableTiers). The model is told to work directly, and
|
|
223
|
+
* to pass the fix on once: the registration lives in the app's settings,
|
|
224
|
+
* which only the user can change.
|
|
225
|
+
*/
|
|
226
|
+
function unreachableNotice(label, tiers, ko) {
|
|
227
|
+
const models = tiers.map((t) => TIER_ALIAS[t]).join(', ');
|
|
228
|
+
return ko
|
|
229
|
+
? `[sprag] 이 요청은 위임 룰 "${label}" 에 해당하지만, 이 세션에는 ${models} 모델이 등록되어 있지 않습니다. `
|
|
230
|
+
+ `위임해도 세션 모델이 그대로 처리하므로 비용이 줄지 않으니, 위임하지 말고 직접 처리하십시오. `
|
|
231
|
+
+ `이 안내를 이번 세션에서 처음 받았다면 답변 끝에 "Claude 앱 설정에 ${models} 모델을 등록하면 이런 요청을 더 싼 모델에 맡길 수 있습니다."라고 사용자에게 한 줄로 알리십시오.`
|
|
232
|
+
: `[sprag] This request matches the delegation rule "${label}", but no ${models} model is registered for this session, `
|
|
233
|
+
+ `so a delegated run would stay on the session model and save nothing. Handle it directly instead of delegating. `
|
|
234
|
+
+ `If this is the first such notice in this session, end your reply with one line for the user: `
|
|
235
|
+
+ `"Register a ${models} model in the Claude app settings to hand requests like this to a cheaper model."`;
|
|
236
|
+
}
|
|
237
|
+
|
|
187
238
|
/**
|
|
188
239
|
* @param {string} text the prompt just submitted
|
|
189
240
|
* @param {object} [opts] as routeMatch, plus:
|
|
@@ -194,12 +245,13 @@ export function matchCaps(match) {
|
|
|
194
245
|
* @param {object|null} [opts.match] a routeMatch result the caller already has
|
|
195
246
|
* @returns {string|null} the line to inject, or null to stay quiet
|
|
196
247
|
*/
|
|
197
|
-
export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match } = {}) {
|
|
198
|
-
const found = match === undefined ? routeMatch(text, { rules, sessionRank, root }) : match;
|
|
248
|
+
export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match, env } = {}) {
|
|
249
|
+
const found = match === undefined ? routeMatch(text, { rules, sessionRank, root, env }) : match;
|
|
199
250
|
if (!found) return null;
|
|
200
251
|
const { cat, t2, t1 } = found;
|
|
201
252
|
const ko = lang === 'ko';
|
|
202
253
|
const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
|
|
254
|
+
if (!t2 && !t1 && found.unreachable?.length) return unreachableNotice(label, found.unreachable, ko);
|
|
203
255
|
// `root` is threaded through to agentPhrase, which names the subagent only
|
|
204
256
|
// when its .md actually exists on this machine. A caller that pins it can
|
|
205
257
|
// therefore get a deterministic phrase; a test that does not pin it reads
|
|
@@ -207,11 +259,16 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
|
|
|
207
259
|
const raw = ko ? agentPhrase : agentPhraseEn;
|
|
208
260
|
const phrase = (name) => raw(name, root === undefined ? undefined : { root });
|
|
209
261
|
|
|
262
|
+
// T1 is named through its delegation agent only when that file exists here;
|
|
263
|
+
// otherwise the literal tier string stays, so output without agents is unchanged.
|
|
264
|
+
const t1Root = root === undefined ? undefined : root;
|
|
265
|
+
const t1Named = resolveAgentName('sonnet', t1Root) !== null;
|
|
266
|
+
const t1Phrase = t1Named ? phrase('sonnet') : 'model: sonnet';
|
|
210
267
|
let target;
|
|
211
268
|
if (t2 && t1) {
|
|
212
269
|
target = ko
|
|
213
|
-
? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면
|
|
214
|
-
: `${phrase(t2.agent)} by default,
|
|
270
|
+
? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 ${t1Phrase}`
|
|
271
|
+
: `${phrase(t2.agent)} by default, ${t1Phrase} when it spans multiple steps or file edits`;
|
|
215
272
|
} else {
|
|
216
273
|
const only = t2 || t1;
|
|
217
274
|
target = ko ? phrase(only.agent) : phrase(only.agent);
|