@slope-dev/slope 1.53.0 → 1.55.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/dist/cli/commands/agent.d.ts +35 -0
- package/dist/cli/commands/agent.d.ts.map +1 -0
- package/dist/cli/commands/agent.js +345 -0
- package/dist/cli/commands/agent.js.map +1 -0
- package/dist/cli/commands/briefing.d.ts.map +1 -1
- package/dist/cli/commands/briefing.js +5 -3
- package/dist/cli/commands/briefing.js.map +1 -1
- package/dist/cli/commands/commit-ready.d.ts +29 -0
- package/dist/cli/commands/commit-ready.d.ts.map +1 -0
- package/dist/cli/commands/commit-ready.js +263 -0
- package/dist/cli/commands/commit-ready.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +9 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +163 -3
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/gate.d.ts +2 -0
- package/dist/cli/commands/gate.d.ts.map +1 -0
- package/dist/cli/commands/gate.js +175 -0
- package/dist/cli/commands/gate.js.map +1 -0
- package/dist/cli/commands/guard.d.ts.map +1 -1
- package/dist/cli/commands/guard.js +4 -0
- package/dist/cli/commands/guard.js.map +1 -1
- package/dist/cli/commands/hook.js +6 -1
- package/dist/cli/commands/hook.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +23 -6
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/pr.d.ts +49 -0
- package/dist/cli/commands/pr.d.ts.map +1 -0
- package/dist/cli/commands/pr.js +214 -0
- package/dist/cli/commands/pr.js.map +1 -0
- package/dist/cli/commands/retro.d.ts +18 -0
- package/dist/cli/commands/retro.d.ts.map +1 -0
- package/dist/cli/commands/retro.js +251 -0
- package/dist/cli/commands/retro.js.map +1 -0
- package/dist/cli/commands/review-state.d.ts +1 -2
- package/dist/cli/commands/review-state.d.ts.map +1 -1
- package/dist/cli/commands/review-state.js +71 -25
- package/dist/cli/commands/review-state.js.map +1 -1
- package/dist/cli/commands/roadmap.d.ts.map +1 -1
- package/dist/cli/commands/roadmap.js +31 -4
- package/dist/cli/commands/roadmap.js.map +1 -1
- package/dist/cli/commands/sprint-plan.d.ts +21 -0
- package/dist/cli/commands/sprint-plan.d.ts.map +1 -0
- package/dist/cli/commands/sprint-plan.js +226 -0
- package/dist/cli/commands/sprint-plan.js.map +1 -0
- package/dist/cli/commands/sprint.d.ts.map +1 -1
- package/dist/cli/commands/sprint.js +137 -0
- package/dist/cli/commands/sprint.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +34 -1
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/ticket.d.ts +9 -0
- package/dist/cli/commands/ticket.d.ts.map +1 -0
- package/dist/cli/commands/ticket.js +168 -0
- package/dist/cli/commands/ticket.js.map +1 -0
- package/dist/cli/commands/version.d.ts.map +1 -1
- package/dist/cli/commands/version.js +33 -4
- package/dist/cli/commands/version.js.map +1 -1
- package/dist/cli/commands/vision.d.ts.map +1 -1
- package/dist/cli/commands/vision.js +14 -1
- package/dist/cli/commands/vision.js.map +1 -1
- package/dist/cli/guards/branch-before-commit.d.ts.map +1 -1
- package/dist/cli/guards/branch-before-commit.js +2 -1
- package/dist/cli/guards/branch-before-commit.js.map +1 -1
- package/dist/cli/guards/next-action.d.ts.map +1 -1
- package/dist/cli/guards/next-action.js +3 -2
- package/dist/cli/guards/next-action.js.map +1 -1
- package/dist/cli/guards/post-hole-enforcement.d.ts +18 -0
- package/dist/cli/guards/post-hole-enforcement.d.ts.map +1 -0
- package/dist/cli/guards/post-hole-enforcement.js +100 -0
- package/dist/cli/guards/post-hole-enforcement.js.map +1 -0
- package/dist/cli/guards/pr-review.d.ts +16 -1
- package/dist/cli/guards/pr-review.d.ts.map +1 -1
- package/dist/cli/guards/pr-review.js +87 -5
- package/dist/cli/guards/pr-review.js.map +1 -1
- package/dist/cli/guards/roadmap-edit-shipped.d.ts +12 -0
- package/dist/cli/guards/roadmap-edit-shipped.d.ts.map +1 -0
- package/dist/cli/guards/roadmap-edit-shipped.js +155 -0
- package/dist/cli/guards/roadmap-edit-shipped.js.map +1 -0
- package/dist/cli/index.js +47 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/registry.d.ts +1 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/registry.js +67 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/core/analyzers/git.d.ts +14 -0
- package/dist/core/analyzers/git.d.ts.map +1 -1
- package/dist/core/analyzers/git.js +47 -0
- package/dist/core/analyzers/git.js.map +1 -1
- package/dist/core/formatter.d.ts.map +1 -1
- package/dist/core/formatter.js +51 -2
- package/dist/core/formatter.js.map +1 -1
- package/dist/core/guard.d.ts +1 -1
- package/dist/core/guard.d.ts.map +1 -1
- package/dist/core/guard.js +16 -0
- package/dist/core/guard.js.map +1 -1
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +3 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/json-memory-backend.d.ts +23 -0
- package/dist/core/json-memory-backend.d.ts.map +1 -0
- package/dist/core/json-memory-backend.js +98 -0
- package/dist/core/json-memory-backend.js.map +1 -0
- package/dist/core/memory-backend.d.ts +30 -0
- package/dist/core/memory-backend.d.ts.map +1 -0
- package/dist/core/memory-backend.js +14 -0
- package/dist/core/memory-backend.js.map +1 -0
- package/dist/core/memory-types.d.ts +25 -0
- package/dist/core/memory-types.d.ts.map +1 -0
- package/dist/core/memory-types.js +4 -0
- package/dist/core/memory-types.js.map +1 -0
- package/dist/core/memory-validation.d.ts +4 -0
- package/dist/core/memory-validation.d.ts.map +1 -0
- package/dist/core/memory-validation.js +35 -0
- package/dist/core/memory-validation.js.map +1 -0
- package/dist/core/memory.d.ts +21 -37
- package/dist/core/memory.d.ts.map +1 -1
- package/dist/core/memory.js +97 -147
- package/dist/core/memory.js.map +1 -1
- package/dist/core/pi-settings.d.ts.map +1 -1
- package/dist/core/pi-settings.js +8 -0
- package/dist/core/pi-settings.js.map +1 -1
- package/dist/core/roadmap.d.ts +16 -2
- package/dist/core/roadmap.d.ts.map +1 -1
- package/dist/core/roadmap.js +81 -24
- package/dist/core/roadmap.js.map +1 -1
- package/dist/core/vision.d.ts +10 -0
- package/dist/core/vision.d.ts.map +1 -1
- package/dist/core/vision.js +68 -1
- package/dist/core/vision.js.map +1 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +18 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite-memory-backend.d.ts +43 -0
- package/dist/store/sqlite-memory-backend.d.ts.map +1 -0
- package/dist/store/sqlite-memory-backend.js +181 -0
- package/dist/store/sqlite-memory-backend.js.map +1 -0
- package/dist/store-pg/index.d.ts.map +1 -1
- package/dist/store-pg/index.js +23 -0
- package/dist/store-pg/index.js.map +1 -1
- package/package.json +1 -1
- package/packages/pi-extension/dist/index.d.ts +19 -0
- package/packages/pi-extension/dist/index.js +339 -15
|
@@ -22,6 +22,63 @@ function slopeCmd(args, cwd) {
|
|
|
22
22
|
function hasSlopeProject(cwd) {
|
|
23
23
|
return existsSync(join(cwd, '.slope', 'config.json'));
|
|
24
24
|
}
|
|
25
|
+
// ── Vague-Prompt Detector ────────────────────────────
|
|
26
|
+
// Matches vague action verbs at the start of a prompt. Shared by isVaguePrompt() and getPlannerSignals().
|
|
27
|
+
const VAGUE_VERB_RE = /^(optimize|improve|make\s+\S+\s+better|make\s+better|fix|refactor|clean\s+up|tidy|architect)\b/i;
|
|
28
|
+
export function isVaguePrompt(prompt) {
|
|
29
|
+
const trimmed = prompt.trim();
|
|
30
|
+
return (trimmed.length < 200 &&
|
|
31
|
+
VAGUE_VERB_RE.test(trimmed) &&
|
|
32
|
+
!HAS_PATH_RE.test(trimmed) &&
|
|
33
|
+
!HAS_SYMBOL_RE.test(trimmed));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Module-level ref populated by registerModelRouter so the vague-prompt
|
|
37
|
+
* detector (registered first) can call doSwitch without accessing the closure.
|
|
38
|
+
* Set synchronously during extension init, before any events fire.
|
|
39
|
+
*/
|
|
40
|
+
let _routerRef = null;
|
|
41
|
+
// ── Plan-Gate Helpers ────────────────────────────────
|
|
42
|
+
/** Returns the active sprint phase, or null if no sprint state exists. */
|
|
43
|
+
function readSprintPhase(cwd) {
|
|
44
|
+
const sprintStatePath = join(cwd, '.slope', 'sprint-state.json');
|
|
45
|
+
if (!existsSync(sprintStatePath))
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
const state = JSON.parse(readFileSync(sprintStatePath, 'utf8'));
|
|
49
|
+
return state.phase ?? null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function inSprintPhase(cwd) {
|
|
56
|
+
const phase = readSprintPhase(cwd);
|
|
57
|
+
return phase === 'planning' || phase === 'implementing' || phase === 'scoring';
|
|
58
|
+
}
|
|
59
|
+
function hasRecentPlan(ctx) {
|
|
60
|
+
const entries = ctx.sessionManager.getEntries();
|
|
61
|
+
// Scan last 5 assistant messages for a structured plan.
|
|
62
|
+
const assistantMessages = entries
|
|
63
|
+
.filter(e => e.role === 'assistant' && typeof e.content === 'string')
|
|
64
|
+
.slice(-5);
|
|
65
|
+
const PLAN_HEADING = /^##+\s*(plan|approach|steps?)\b/im;
|
|
66
|
+
const NUMBERED_LIST = /^\s*1\.\s.+\n\s*2\.\s.+\n\s*3\.\s/m;
|
|
67
|
+
const PLAN_MARKER = /\[plan\]|<plan>/i;
|
|
68
|
+
return assistantMessages.some(m => PLAN_HEADING.test(m.content ?? '') ||
|
|
69
|
+
NUMBERED_LIST.test(m.content ?? '') ||
|
|
70
|
+
PLAN_MARKER.test(m.content ?? ''));
|
|
71
|
+
}
|
|
72
|
+
// Read-only bash commands that are always exempt from the plan-gate.
|
|
73
|
+
const BASH_EXEMPT_PREFIXES = [
|
|
74
|
+
'git status', 'git log', 'git diff', 'git show', 'git branch',
|
|
75
|
+
'ls', 'pwd', 'cat ', 'grep', 'find ', 'which ', 'echo ',
|
|
76
|
+
'head ', 'tail ', 'wc ', 'stat ', 'file ', 'type ',
|
|
77
|
+
];
|
|
78
|
+
function isBashExempt(command) {
|
|
79
|
+
const trimmed = command.trim();
|
|
80
|
+
return BASH_EXEMPT_PREFIXES.some(prefix => trimmed.startsWith(prefix));
|
|
81
|
+
}
|
|
25
82
|
// ── Onboarding State ────────────────────────────────
|
|
26
83
|
const ONBOARDING_STATE_FILE = '.slope/.pi-onboarding.json';
|
|
27
84
|
function loadOnboardingState(cwd) {
|
|
@@ -42,22 +99,12 @@ function saveOnboardingState(cwd, state) {
|
|
|
42
99
|
}
|
|
43
100
|
/** Determine the SLOPE project state for this workspace */
|
|
44
101
|
function getProjectState(cwd) {
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
return 'fresh';
|
|
48
|
-
}
|
|
49
|
-
try {
|
|
50
|
-
const state = JSON.parse(readFileSync(sprintStatePath, 'utf8'));
|
|
51
|
-
if (state.phase === 'planning' ||
|
|
52
|
-
state.phase === 'implementing' ||
|
|
53
|
-
state.phase === 'scoring') {
|
|
54
|
-
return 'active';
|
|
55
|
-
}
|
|
56
|
-
return 'complete';
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
102
|
+
const phase = readSprintPhase(cwd);
|
|
103
|
+
if (phase === null)
|
|
59
104
|
return 'fresh';
|
|
60
|
-
|
|
105
|
+
if (phase === 'planning' || phase === 'implementing' || phase === 'scoring')
|
|
106
|
+
return 'active';
|
|
107
|
+
return 'complete';
|
|
61
108
|
}
|
|
62
109
|
// ── Extension Entry Point ───────────────────────────
|
|
63
110
|
export default function slopeExtension(pi, _cwdOverride) {
|
|
@@ -381,6 +428,53 @@ export default function slopeExtension(pi, _cwdOverride) {
|
|
|
381
428
|
}
|
|
382
429
|
});
|
|
383
430
|
} // end guards event handlers
|
|
431
|
+
if (isSkillEnabled(settings, 'plan-gate')) {
|
|
432
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
433
|
+
const { toolName, input } = event;
|
|
434
|
+
const inp = input;
|
|
435
|
+
// Only gate destructive tools.
|
|
436
|
+
if (toolName !== 'write' && toolName !== 'edit' && toolName !== 'bash')
|
|
437
|
+
return;
|
|
438
|
+
// Bash: exempt read-only commands.
|
|
439
|
+
if (toolName === 'bash' && typeof inp.command === 'string' && isBashExempt(inp.command))
|
|
440
|
+
return;
|
|
441
|
+
// Not a gate situation when a sprint is active.
|
|
442
|
+
if (inSprintPhase(ctx.cwd))
|
|
443
|
+
return;
|
|
444
|
+
// Not a gate situation when the assistant recently produced a plan.
|
|
445
|
+
if (hasRecentPlan(ctx))
|
|
446
|
+
return;
|
|
447
|
+
const reason = 'plan-gate: Run /sprint start or write a plan before this action.';
|
|
448
|
+
// Interactive: ask the user; non-interactive: hard block.
|
|
449
|
+
const ui = ctx.ui;
|
|
450
|
+
if (typeof ui?.confirm === 'function') {
|
|
451
|
+
const ok = await ui.confirm('No plan detected', 'Run /sprint start or write a plan first. Proceed anyway?');
|
|
452
|
+
if (!ok)
|
|
453
|
+
return { block: true, reason };
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
return { block: true, reason };
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
// Vague-prompt detector — registered BEFORE model-router's before_agent_start so
|
|
460
|
+
// the forced local-planner tier sticks when the router handler fires next.
|
|
461
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
462
|
+
if (!isVaguePrompt(event.prompt ?? ''))
|
|
463
|
+
return;
|
|
464
|
+
// Inject planning preamble into the chained system prompt.
|
|
465
|
+
const preamble = '\n\nBefore any tool calls, produce a written plan addressing what to change, ' +
|
|
466
|
+
'where, and the order of work. Format: a numbered list of steps under a ' +
|
|
467
|
+
'"## Plan" heading. Do not call write/edit/bash before the plan is written.';
|
|
468
|
+
// Force-route to local-planner when the model-router skill is active.
|
|
469
|
+
if (_routerRef) {
|
|
470
|
+
_routerRef.state.turnsSinceSwitch = 3; // satisfy hysteresis so doSwitch fires
|
|
471
|
+
await doSwitch('local-planner', 'vague-prompt-detector', ctx, _routerRef.pi, _routerRef.state);
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
systemPrompt: event.systemPrompt + preamble,
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
} // end plan-gate event handlers
|
|
384
478
|
if (isSkillEnabled(settings, 'planning')) {
|
|
385
479
|
// Guard: post-push sprint nudge
|
|
386
480
|
pi.on('tool_result', async (event, ctx) => {
|
|
@@ -449,6 +543,10 @@ export default function slopeExtension(pi, _cwdOverride) {
|
|
|
449
543
|
} // end memory slash command
|
|
450
544
|
// Settings command
|
|
451
545
|
registerSettingsCommand(pi, settings, cwd);
|
|
546
|
+
// ── Model Router ────────────────────────────────
|
|
547
|
+
if (isSkillEnabled(settings, 'model-router')) {
|
|
548
|
+
registerModelRouter(pi, cwd);
|
|
549
|
+
}
|
|
452
550
|
// ── Session Start: Inject Briefing on First Turn ──
|
|
453
551
|
let briefingInjected = false;
|
|
454
552
|
pi.on('session_start', async (_event, ctx) => {
|
|
@@ -552,6 +650,232 @@ export default function slopeExtension(pi, _cwdOverride) {
|
|
|
552
650
|
} // end briefing event handlers
|
|
553
651
|
}
|
|
554
652
|
// ── Settings Command ──────────────────────────────
|
|
653
|
+
// ── Model Router ──────────────────────────────────────────
|
|
654
|
+
const COMPLEX_KEYWORDS = [
|
|
655
|
+
'architect', 'design', 'refactor', 'debug', 'investigate',
|
|
656
|
+
'why does', 'how should', 'what\'s the best', 'review this',
|
|
657
|
+
'performance', 'security', 'migration', 'breaking change',
|
|
658
|
+
'multi-file', 'cross-cutting', 'redesign', 'strategy',
|
|
659
|
+
'explain why', 'trade-off', 'compare approaches',
|
|
660
|
+
];
|
|
661
|
+
const SIMPLE_KEYWORDS = [
|
|
662
|
+
'fix typo', 'rename', 'add import', 'run test', 'format',
|
|
663
|
+
'lint', 'commit', 'push', 'status', 'list files', 'show',
|
|
664
|
+
'create file', 'delete file', 'move file',
|
|
665
|
+
];
|
|
666
|
+
// Standalone ambiguous noun tokens. 'it' is intentionally excluded — too common.
|
|
667
|
+
const AMBIGUOUS_NOUN_TOKENS = [
|
|
668
|
+
'the code', 'this', 'everything', 'all of it', 'the whole thing', 'the codebase',
|
|
669
|
+
];
|
|
670
|
+
const HAS_PATH_RE = /\.(tsx?|jsx?|py|rs|go|md|json|ya?ml|sh|sql|css|html|toml)\b/i;
|
|
671
|
+
// Function-call shape `foo()` OR PascalCase identifier `MyClass`.
|
|
672
|
+
const HAS_SYMBOL_RE = /\b[a-z][A-Za-z0-9_]*\(\)|\b[A-Z][a-z][A-Za-z0-9]+\b/;
|
|
673
|
+
function hasCodeIdentifier(prompt) {
|
|
674
|
+
return HAS_PATH_RE.test(prompt) || HAS_SYMBOL_RE.test(prompt);
|
|
675
|
+
}
|
|
676
|
+
function getPlannerSignals(prompt) {
|
|
677
|
+
const trimmed = prompt.trim();
|
|
678
|
+
const lower = trimmed.toLowerCase();
|
|
679
|
+
const signals = [];
|
|
680
|
+
if (VAGUE_VERB_RE.test(trimmed))
|
|
681
|
+
signals.push('vague-verb');
|
|
682
|
+
for (const t of AMBIGUOUS_NOUN_TOKENS) {
|
|
683
|
+
if (lower.includes(t)) {
|
|
684
|
+
signals.push('ambiguous-noun');
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (trimmed.length < 200 && !hasCodeIdentifier(trimmed))
|
|
689
|
+
signals.push('short-no-code');
|
|
690
|
+
return signals;
|
|
691
|
+
}
|
|
692
|
+
export function scoreComplexity(prompt) {
|
|
693
|
+
const trimmed = prompt.trim();
|
|
694
|
+
const lower = trimmed.toLowerCase();
|
|
695
|
+
// 0–5 score (cloud-escalation signal). Same weights as the prior implementation.
|
|
696
|
+
let score = 0;
|
|
697
|
+
let topComplexKw = null;
|
|
698
|
+
for (const kw of COMPLEX_KEYWORDS) {
|
|
699
|
+
if (lower.includes(kw)) {
|
|
700
|
+
score += 2;
|
|
701
|
+
if (!topComplexKw)
|
|
702
|
+
topComplexKw = kw;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
for (const kw of SIMPLE_KEYWORDS) {
|
|
706
|
+
if (lower.includes(kw))
|
|
707
|
+
score -= 1;
|
|
708
|
+
}
|
|
709
|
+
if (trimmed.length > 500)
|
|
710
|
+
score += 1;
|
|
711
|
+
if (trimmed.length > 1000)
|
|
712
|
+
score += 1;
|
|
713
|
+
if (trimmed.length < 50)
|
|
714
|
+
score -= 1;
|
|
715
|
+
const questions = (trimmed.match(/\?/g) ?? []).length;
|
|
716
|
+
if (questions >= 2)
|
|
717
|
+
score += 1;
|
|
718
|
+
const fileRefs = (trimmed.match(/\.[a-z]{1,4}\b/g) ?? []).length;
|
|
719
|
+
if (fileRefs >= 3)
|
|
720
|
+
score += 1;
|
|
721
|
+
score = Math.max(0, Math.min(5, score));
|
|
722
|
+
// Tier selection. Order matters: cloud > planner > coder > general.
|
|
723
|
+
if (score >= 3) {
|
|
724
|
+
return { tier: 'cloud', score, signal: topComplexKw ?? 'high-complexity' };
|
|
725
|
+
}
|
|
726
|
+
const planner = getPlannerSignals(trimmed);
|
|
727
|
+
if (planner.length >= 2) {
|
|
728
|
+
return { tier: 'local-planner', score, signal: planner.join('+') };
|
|
729
|
+
}
|
|
730
|
+
if (hasCodeIdentifier(trimmed)) {
|
|
731
|
+
return { tier: 'local-coder', score, signal: 'code-identifier' };
|
|
732
|
+
}
|
|
733
|
+
// Coder-flavoured keywords still hint at coder when no identifier is present.
|
|
734
|
+
const CODER_HINT_KWS = ['fix typo', 'rename', 'add import', 'run test', 'format', 'lint',
|
|
735
|
+
'commit', 'push', 'create file', 'delete file', 'move file', 'debug', 'refactor'];
|
|
736
|
+
for (const kw of CODER_HINT_KWS) {
|
|
737
|
+
if (lower.includes(kw))
|
|
738
|
+
return { tier: 'local-coder', score, signal: kw };
|
|
739
|
+
}
|
|
740
|
+
return { tier: 'local-general', score, signal: 'general-reasoning' };
|
|
741
|
+
}
|
|
742
|
+
const TIER_ICONS = {
|
|
743
|
+
'local-coder': '\u{1F6E0}',
|
|
744
|
+
'local-general': '\u{1F9E0}',
|
|
745
|
+
'local-planner': '\u{1F4CB}',
|
|
746
|
+
'cloud': '☁',
|
|
747
|
+
};
|
|
748
|
+
/** Backwards-compat: persisted RouterState may carry the old 'local' tier name. */
|
|
749
|
+
function normalizeTier(tier) {
|
|
750
|
+
if (tier === 'local')
|
|
751
|
+
return 'local-coder';
|
|
752
|
+
if (tier === 'local-coder' || tier === 'local-general' || tier === 'local-planner' || tier === 'cloud') {
|
|
753
|
+
return tier;
|
|
754
|
+
}
|
|
755
|
+
return 'local-coder';
|
|
756
|
+
}
|
|
757
|
+
function registerModelRouter(pi, _cwd) {
|
|
758
|
+
const state = {
|
|
759
|
+
currentTier: 'local-coder',
|
|
760
|
+
turnsSinceSwitch: 0,
|
|
761
|
+
lastSwitchReason: 'startup',
|
|
762
|
+
};
|
|
763
|
+
// Expose state + pi so the vague-prompt detector (registered earlier) can call doSwitch.
|
|
764
|
+
_routerRef = { state, pi };
|
|
765
|
+
// Restore state from session
|
|
766
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
767
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
768
|
+
if (entry.type === 'custom' && entry.customType === 'slope-model-router') {
|
|
769
|
+
const persisted = entry.data;
|
|
770
|
+
state.currentTier = normalizeTier(persisted.currentTier ?? 'local-coder');
|
|
771
|
+
state.turnsSinceSwitch = persisted.turnsSinceSwitch ?? 0;
|
|
772
|
+
state.lastSwitchReason = persisted.lastSwitchReason ?? 'restored';
|
|
773
|
+
state.plannerPreambleStaged = persisted.plannerPreambleStaged;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
ctx.ui.setStatus('model-router', `${TIER_ICONS[state.currentTier]} ${state.currentTier}`);
|
|
777
|
+
});
|
|
778
|
+
// Analyze complexity before each agent turn
|
|
779
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
780
|
+
const prompt = (event.prompt ?? '').toLowerCase();
|
|
781
|
+
// Explicit user commands \u2014 match longest variants first.
|
|
782
|
+
if (/(use|switch|\/route)\s+local-planner\b/.test(prompt)) {
|
|
783
|
+
await doSwitch('local-planner', 'user request', ctx, pi, state);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (/(use|switch|\/route)\s+local-general\b/.test(prompt)) {
|
|
787
|
+
await doSwitch('local-general', 'user request', ctx, pi, state);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (/(use|switch|\/route)\s+local-coder\b/.test(prompt)) {
|
|
791
|
+
await doSwitch('local-coder', 'user request', ctx, pi, state);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const FORCE_LOCAL = ['use local', '/route local', 'switch local'];
|
|
795
|
+
const FORCE_CLOUD = ['use cloud', '/route cloud', 'switch cloud', 'use sonnet', 'use opus'];
|
|
796
|
+
if (FORCE_LOCAL.some(k => prompt.includes(k))) {
|
|
797
|
+
await doSwitch('local-coder', 'user request (legacy "local")', ctx, pi, state);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (FORCE_CLOUD.some(k => prompt.includes(k))) {
|
|
801
|
+
await doSwitch('cloud', 'user request', ctx, pi, state);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const result = scoreComplexity(event.prompt ?? '');
|
|
805
|
+
// Hysteresis: don't switch too frequently (min 3 turns between switches).
|
|
806
|
+
if (state.turnsSinceSwitch < 3) {
|
|
807
|
+
state.turnsSinceSwitch++;
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (result.tier !== state.currentTier) {
|
|
811
|
+
await doSwitch(result.tier, `auto: ${result.signal} (score ${result.score})`, ctx, pi, state);
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
state.turnsSinceSwitch++;
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
// Track turns
|
|
818
|
+
pi.on('turn_end', async () => {
|
|
819
|
+
state.turnsSinceSwitch++;
|
|
820
|
+
});
|
|
821
|
+
// Manual command
|
|
822
|
+
pi.registerCommand('route', {
|
|
823
|
+
description: 'Switch model routing: /route local-coder | local-general | local-planner | cloud | status',
|
|
824
|
+
handler: async (args, ctx) => {
|
|
825
|
+
const arg = (args ?? '').trim().toLowerCase();
|
|
826
|
+
if (arg === 'status' || arg === '') {
|
|
827
|
+
ctx.ui.notify(`Model router: ${state.currentTier} (${state.turnsSinceSwitch} turns since switch, reason: ${state.lastSwitchReason})`, 'info');
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
// Back-compat: bare 'local' = local-coder.
|
|
831
|
+
if (arg === 'local') {
|
|
832
|
+
await doSwitch('local-coder', 'manual /route (legacy "local")', ctx, pi, state);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (arg === 'local-coder' || arg === 'local-general' || arg === 'local-planner' || arg === 'cloud') {
|
|
836
|
+
await doSwitch(arg, 'manual /route command', ctx, pi, state);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
ctx.ui.notify('Usage: /route local-coder | local-general | local-planner | cloud | status', 'info');
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
export async function doSwitch(tier, reason, ctx, pi, state) {
|
|
844
|
+
const registry = ctx.modelRegistry;
|
|
845
|
+
let model;
|
|
846
|
+
if (tier === 'cloud') {
|
|
847
|
+
model = registry.find('openrouter', 'anthropic/claude-sonnet-4-5')
|
|
848
|
+
?? registry.find('openrouter', 'google/gemini-2.5-pro-preview')
|
|
849
|
+
?? registry.find('anthropic', 'claude-sonnet-4-20250514');
|
|
850
|
+
}
|
|
851
|
+
else if (tier === 'local-coder') {
|
|
852
|
+
model = registry.find('mlx-local', 'Qwen3-Coder-Next')
|
|
853
|
+
?? registry.find('mlx-local', 'Qwen3-Coder')
|
|
854
|
+
?? registry.find('ollama', 'qwen3-coder:30b');
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
// local-general and local-planner share the dense reasoning model.
|
|
858
|
+
model = registry.find('mlx-local', 'Qwen3.6-27B')
|
|
859
|
+
?? registry.find('mlx-local', 'Qwen3-27B')
|
|
860
|
+
?? registry.find('ollama', 'qwen3:27b');
|
|
861
|
+
}
|
|
862
|
+
if (!model) {
|
|
863
|
+
ctx.ui.notify(`No ${tier} model available — check models.json`, 'warning');
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const success = await pi.setModel(model);
|
|
867
|
+
if (success) {
|
|
868
|
+
state.currentTier = tier;
|
|
869
|
+
state.turnsSinceSwitch = 0;
|
|
870
|
+
state.lastSwitchReason = reason;
|
|
871
|
+
state.plannerPreambleStaged = tier === 'local-planner';
|
|
872
|
+
ctx.ui.setStatus('model-router', `${TIER_ICONS[tier]} ${tier}`);
|
|
873
|
+
ctx.ui.notify(`Model router: switched to ${tier} (${model.name ?? model.id}) — ${reason}`, 'info');
|
|
874
|
+
// Persist state
|
|
875
|
+
pi.appendEntry('slope-model-router', state);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// ── Settings Command ──────────────────────────────────
|
|
555
879
|
function registerSettingsCommand(pi, settings, cwd) {
|
|
556
880
|
pi.registerCommand('slope-settings', {
|
|
557
881
|
description: 'Show and manage SLOPE Pi feature settings',
|