@miphamai/cli 0.74.0 → 0.76.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/package.json +1 -1
- package/src/config/loader.ts +1 -0
- package/src/core/git-pr.ts +51 -0
- package/src/core/instructions.ts +9 -0
- package/src/core/permission-rules.ts +21 -2
- package/src/i18n-core/locales/en-US.json +10 -0
- package/src/i18n-core/locales/zh-CN.json +10 -0
- package/src/index.tsx +1 -1
- package/src/shared/constants.ts +90 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +6 -1
- package/src/skills/loader.ts +20 -2
- package/src/skills/usage.ts +34 -0
- package/src/tools/agent/skill.ts +3 -0
- package/src/ui/app.tsx +19 -1
- package/src/ui/commands.ts +69 -0
package/package.json
CHANGED
package/src/config/loader.ts
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { exec } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export interface GitPr {
|
|
4
|
+
number: number
|
|
5
|
+
state: 'OPEN' | 'CLOSED' | 'MERGED'
|
|
6
|
+
isDraft: boolean
|
|
7
|
+
reviewDecision: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type PrColor = 'white' | 'green' | 'yellow' | 'magenta' | 'gray'
|
|
11
|
+
|
|
12
|
+
/** Parse `gh pr list --json number,state,isDraft,reviewDecision` output into the first PR.
|
|
13
|
+
* Bad input / empty list / missing number → null. */
|
|
14
|
+
export function parseGitPr(json: string): GitPr | null {
|
|
15
|
+
try {
|
|
16
|
+
const arr = JSON.parse(json)
|
|
17
|
+
if (!Array.isArray(arr) || arr.length === 0) return null
|
|
18
|
+
const first = arr[0]
|
|
19
|
+
if (!first || typeof first.number !== 'number') return null
|
|
20
|
+
return {
|
|
21
|
+
number: first.number,
|
|
22
|
+
state: first.state ?? 'OPEN',
|
|
23
|
+
isDraft: Boolean(first.isDraft),
|
|
24
|
+
reviewDecision: first.reviewDecision ?? '',
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Map PR state to an Ink color — mirrors Claude Code's status-line PR badge:
|
|
32
|
+
* merged → magenta, closed/draft → gray, approved → green, changes-requested → yellow,
|
|
33
|
+
* otherwise (open, no review) → white. */
|
|
34
|
+
export function prColor(pr: GitPr): PrColor {
|
|
35
|
+
if (pr.state === 'MERGED') return 'magenta'
|
|
36
|
+
if (pr.state === 'CLOSED' || pr.isDraft) return 'gray'
|
|
37
|
+
if (pr.reviewDecision === 'APPROVED') return 'green'
|
|
38
|
+
if (pr.reviewDecision === 'CHANGES_REQUESTED') return 'yellow'
|
|
39
|
+
return 'white'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Detect the PR whose head is `branch`. gh unavailable / no PR / non-zero exit → null.
|
|
43
|
+
* Async so the status line never blocks startup. */
|
|
44
|
+
export function resolveGitPr(branch: string): Promise<GitPr | null> {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const cmd = `gh pr list --head "${branch}" --state all --json number,state,isDraft,reviewDecision`
|
|
47
|
+
exec(cmd, { timeout: 3000 }, (err, stdout) => {
|
|
48
|
+
resolve(err ? null : parseGitPr(stdout))
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
}
|
package/src/core/instructions.ts
CHANGED
|
@@ -275,6 +275,15 @@ say so and read it first, rather than answering hastily and retracting
|
|
|
275
275
|
afterwards. This applies to every code question, not only research or
|
|
276
276
|
borrow-analysis tasks.`)
|
|
277
277
|
|
|
278
|
+
// CRSI code-review 合并门 — 合并 PR 前必须先 review,勿靠「碰巧触发」
|
|
279
|
+
parts.push(`## Code-Review Merge Gate
|
|
280
|
+
|
|
281
|
+
Before merging a PR or committing changes that have not been
|
|
282
|
+
code-reviewed, run a code review first (the /code-review command or the
|
|
283
|
+
code-review skill) and address its findings. Do not rely on "the user
|
|
284
|
+
happened to ask" to trigger a review — review proactively as a fixed
|
|
285
|
+
step before merge.`)
|
|
286
|
+
|
|
278
287
|
// CRSI 教训召回 — 把 crsi-lessons.md 的教训精华注入,让模型「写后召回」而非只写不读
|
|
279
288
|
const lessonsBlock = buildCrsiLessonsBlock(this.crsiLessonSummaries)
|
|
280
289
|
if (lessonsBlock) parts.push(lessonsBlock)
|
|
@@ -145,6 +145,24 @@ function extractSubstitutions(command: string): string[] {
|
|
|
145
145
|
return inners
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Flatten a command into every matchable sub-command: its shell segments plus
|
|
150
|
+
* the commands nested inside `$(...)`/backtick substitutions (recursively). So
|
|
151
|
+
* a `Bash(rm *)` deny rule also matches `REPORTTIME=$(rm -rf ~)` — zsh evaluates
|
|
152
|
+
* substitutions in REPORTTIME/REPORTMEMORY/DIRSTACKSIZE assignments immediately.
|
|
153
|
+
* Over-matching is the safe direction for a deny rule.
|
|
154
|
+
*/
|
|
155
|
+
function flattenCommand(command: string): string[] {
|
|
156
|
+
const out: string[] = []
|
|
157
|
+
for (const seg of splitShellSegments(command)) {
|
|
158
|
+
out.push(seg)
|
|
159
|
+
for (const inner of extractSubstitutions(seg)) {
|
|
160
|
+
out.push(...flattenCommand(inner))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
148
166
|
/**
|
|
149
167
|
* Detect reader/writer commands at the front of each shell segment and recurse
|
|
150
168
|
* into command substitutions, so `echo $(cat .git-credentials)` is caught.
|
|
@@ -215,10 +233,11 @@ export function matchBashRule(
|
|
|
215
233
|
if (toolName !== baseTool!) return false
|
|
216
234
|
|
|
217
235
|
// For Bash: match against the command string (any segment of a compound
|
|
218
|
-
// command — `rm -rf /` buried in `foo && rm -rf /` still matches
|
|
236
|
+
// command — `rm -rf /` buried in `foo && rm -rf /` still matches — or a
|
|
237
|
+
// `$(...)`/backtick substitution, so `Bash(rm *)` catches `x=$(rm -rf ~)`).
|
|
219
238
|
if (baseTool === 'Bash') {
|
|
220
239
|
const cmd = String(toolInput.command || '')
|
|
221
|
-
return
|
|
240
|
+
return flattenCommand(cmd).some((seg) => wildcardMatch(subPattern!, seg))
|
|
222
241
|
}
|
|
223
242
|
|
|
224
243
|
// For Write/Edit/Read: match against the file_path with path-glob semantics.
|
|
@@ -613,6 +613,16 @@
|
|
|
613
613
|
"audit_clean": " ✓ no code-derivable sections found",
|
|
614
614
|
"audit_found": "Add the sections above to the `prompt-exclude` frontmatter to stop sending them to the model."
|
|
615
615
|
},
|
|
616
|
+
"skillDoctor": {
|
|
617
|
+
"title": "── Skill Doctor ──",
|
|
618
|
+
"unavailable": " (skills info unavailable)",
|
|
619
|
+
"summary": "Loaded {total} skills — ~{tokens} tokens in context. {unused} never used (~{unusedTokens} tokens reclaimable).",
|
|
620
|
+
"unused_header": "Never used:",
|
|
621
|
+
"used_header": "Recently used:",
|
|
622
|
+
"never": "never used",
|
|
623
|
+
"days_ago": "{days}d ago",
|
|
624
|
+
"today": "today"
|
|
625
|
+
},
|
|
616
626
|
"fix": {
|
|
617
627
|
"title": "── Fix ──",
|
|
618
628
|
"dryrun_banner": "[dry-run] preview — no changes written",
|
|
@@ -613,6 +613,16 @@
|
|
|
613
613
|
"audit_clean": " ✓ 未发现可从代码推断的冗余章节",
|
|
614
614
|
"audit_found": "将上述章节加入 `prompt-exclude` frontmatter,可停止发送给模型、节省 token。"
|
|
615
615
|
},
|
|
616
|
+
"skillDoctor": {
|
|
617
|
+
"title": "── 技能体检 ──",
|
|
618
|
+
"unavailable": " (技能信息不可用)",
|
|
619
|
+
"summary": "已加载 {total} 个技能 — 占 context ~{tokens} tokens。{unused} 个从未使用(可省 ~{unusedTokens} tokens)。",
|
|
620
|
+
"unused_header": "从未使用:",
|
|
621
|
+
"used_header": "最近使用:",
|
|
622
|
+
"never": "从未使用",
|
|
623
|
+
"days_ago": "{days} 天前",
|
|
624
|
+
"today": "今天"
|
|
625
|
+
},
|
|
616
626
|
"fix": {
|
|
617
627
|
"title": "── 修复 ──",
|
|
618
628
|
"dryrun_banner": "[dry-run] 预览模式 — 不落盘",
|
package/src/index.tsx
CHANGED
|
@@ -410,7 +410,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
410
410
|
if (context.getMessageCount() === 0) {
|
|
411
411
|
const basePrompt = instructions.buildSystemPrompt(config.permission as string)
|
|
412
412
|
const memoryReminder = loadSessionMemories(basePrompt)
|
|
413
|
-
const skillsReminder = skillsLoader.buildSystemReminder()
|
|
413
|
+
const skillsReminder = skillsLoader.buildSystemReminder(5000, config.skills?.reminder ?? 'full')
|
|
414
414
|
|
|
415
415
|
// Inject previous session summary for AI continuity
|
|
416
416
|
let prompt = basePrompt
|
package/src/shared/constants.ts
CHANGED
|
@@ -68,6 +68,15 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
|
|
|
68
68
|
vision: true,
|
|
69
69
|
status: 'active',
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
id: 'claude-mythos-5',
|
|
73
|
+
name: 'Claude Mythos 5',
|
|
74
|
+
providerId: 'anthropic',
|
|
75
|
+
contextWindow: 1_000_000,
|
|
76
|
+
maxOutput: 128_000,
|
|
77
|
+
vision: true,
|
|
78
|
+
status: 'active',
|
|
79
|
+
},
|
|
71
80
|
{
|
|
72
81
|
id: 'claude-sonnet-5',
|
|
73
82
|
name: 'Claude Sonnet 5',
|
|
@@ -349,6 +358,78 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
|
|
|
349
358
|
},
|
|
350
359
|
],
|
|
351
360
|
},
|
|
361
|
+
{
|
|
362
|
+
id: 'minimax',
|
|
363
|
+
name: 'MiniMax (国内)',
|
|
364
|
+
protocol: 'openai-compatible',
|
|
365
|
+
baseUrl: 'https://api.minimaxi.com/v1',
|
|
366
|
+
apiKey: '${MINIMAX_API_KEY}',
|
|
367
|
+
models: [
|
|
368
|
+
{
|
|
369
|
+
id: 'MiniMax-M2.7',
|
|
370
|
+
name: 'MiniMax M2.7',
|
|
371
|
+
providerId: 'minimax',
|
|
372
|
+
contextWindow: 204_800,
|
|
373
|
+
maxOutput: 64_000,
|
|
374
|
+
vision: false,
|
|
375
|
+
status: 'active',
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
id: 'MiniMax-M2',
|
|
379
|
+
name: 'MiniMax M2',
|
|
380
|
+
providerId: 'minimax',
|
|
381
|
+
contextWindow: 204_800,
|
|
382
|
+
maxOutput: 64_000,
|
|
383
|
+
vision: false,
|
|
384
|
+
status: 'active',
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
id: 'MiniMax-Text-01',
|
|
388
|
+
name: 'MiniMax Text 01',
|
|
389
|
+
providerId: 'minimax',
|
|
390
|
+
contextWindow: 1_000_000,
|
|
391
|
+
maxOutput: 64_000,
|
|
392
|
+
vision: false,
|
|
393
|
+
status: 'active',
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
id: 'minimax-global',
|
|
399
|
+
name: 'MiniMax (国际)',
|
|
400
|
+
protocol: 'openai-compatible',
|
|
401
|
+
baseUrl: 'https://api.minimax.io/v1',
|
|
402
|
+
apiKey: '${MINIMAX_GLOBAL_API_KEY}',
|
|
403
|
+
models: [
|
|
404
|
+
{
|
|
405
|
+
id: 'MiniMax-M2.7',
|
|
406
|
+
name: 'MiniMax M2.7',
|
|
407
|
+
providerId: 'minimax-global',
|
|
408
|
+
contextWindow: 204_800,
|
|
409
|
+
maxOutput: 64_000,
|
|
410
|
+
vision: false,
|
|
411
|
+
status: 'active',
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
id: 'MiniMax-M2',
|
|
415
|
+
name: 'MiniMax M2',
|
|
416
|
+
providerId: 'minimax-global',
|
|
417
|
+
contextWindow: 204_800,
|
|
418
|
+
maxOutput: 64_000,
|
|
419
|
+
vision: false,
|
|
420
|
+
status: 'active',
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
id: 'MiniMax-Text-01',
|
|
424
|
+
name: 'MiniMax Text 01',
|
|
425
|
+
providerId: 'minimax-global',
|
|
426
|
+
contextWindow: 1_000_000,
|
|
427
|
+
maxOutput: 64_000,
|
|
428
|
+
vision: false,
|
|
429
|
+
status: 'active',
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
},
|
|
352
433
|
MIPHAM_PROVIDER,
|
|
353
434
|
{
|
|
354
435
|
id: 'ollama',
|
|
@@ -401,6 +482,15 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
|
|
|
401
482
|
vision: false,
|
|
402
483
|
status: 'active',
|
|
403
484
|
},
|
|
485
|
+
{
|
|
486
|
+
id: 'gpt-6-astra',
|
|
487
|
+
name: 'GPT-6 Astra',
|
|
488
|
+
providerId: 'openai',
|
|
489
|
+
contextWindow: 1_050_000,
|
|
490
|
+
maxOutput: 128_000,
|
|
491
|
+
vision: true,
|
|
492
|
+
status: 'active',
|
|
493
|
+
},
|
|
404
494
|
],
|
|
405
495
|
},
|
|
406
496
|
{
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.
|
|
12
|
+
export const PACKAGE_VERSION = '0.76.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/types.ts
CHANGED
|
@@ -147,7 +147,12 @@ export interface MiphamConfig {
|
|
|
147
147
|
/** When false, disable the slash-command picker auto-popup on `/`. Default false. */
|
|
148
148
|
showCommandPicker?: boolean
|
|
149
149
|
providers: ProviderConfig[]
|
|
150
|
-
skills?: {
|
|
150
|
+
skills?: {
|
|
151
|
+
paths: string[]
|
|
152
|
+
mcpServers: McpServerConfig[]
|
|
153
|
+
/** Startup skill-list budget: full (default) | compact (one-line desc) | off. */
|
|
154
|
+
reminder?: 'full' | 'compact' | 'off'
|
|
155
|
+
}
|
|
151
156
|
marketplace?: {
|
|
152
157
|
/** If set, only allow installs from matching repos (e.g. ["One-Mipham/*"]) */
|
|
153
158
|
strictKnownMarketplaces?: string[]
|
package/src/skills/loader.ts
CHANGED
|
@@ -204,8 +204,15 @@ export class SkillsLoader implements Skills {
|
|
|
204
204
|
* the Skill tool. A single full listing keeps the whole catalog discoverable:
|
|
205
205
|
* at session start there is no query to match against, so a keyword "recall"
|
|
206
206
|
* would silently hide most skills. Capped at `maxTokens` to stay bounded.
|
|
207
|
+
*
|
|
208
|
+
* `mode` controls the startup token budget:
|
|
209
|
+
* - `full` (default): name + full description
|
|
210
|
+
* - `compact`: name + description collapsed to one short line
|
|
211
|
+
* - `off`: no reminder at all
|
|
207
212
|
*/
|
|
208
|
-
buildSystemReminder(maxTokens: number = 5000): string {
|
|
213
|
+
buildSystemReminder(maxTokens: number = 5000, mode: 'full' | 'compact' | 'off' = 'full'): string {
|
|
214
|
+
if (mode === 'off') return ''
|
|
215
|
+
|
|
209
216
|
const selected = this.list().filter((s) => !s.disableModelInvocation)
|
|
210
217
|
|
|
211
218
|
if (selected.length === 0) return ''
|
|
@@ -218,7 +225,8 @@ export class SkillsLoader implements Skills {
|
|
|
218
225
|
let tokenBudget = 0
|
|
219
226
|
for (const skill of selected) {
|
|
220
227
|
const safeDesc = sanitizeSkillDescription(skill.description, skill.type)
|
|
221
|
-
const
|
|
228
|
+
const desc = mode === 'compact' ? truncateSkillDescription(safeDesc) : safeDesc
|
|
229
|
+
const entry = `- ${skill.name}: ${desc}`
|
|
222
230
|
const entryTokens = Math.ceil(entry.length / 4) + 1 // rough estimate
|
|
223
231
|
if (tokenBudget + entryTokens > maxTokens) break
|
|
224
232
|
|
|
@@ -242,3 +250,13 @@ export class SkillsLoader implements Skills {
|
|
|
242
250
|
return base.replace(/\.(SKILL|mipham-skill)\.md$/i, '')
|
|
243
251
|
}
|
|
244
252
|
}
|
|
253
|
+
|
|
254
|
+
const COMPACT_DESC_MAX_CHARS = 80
|
|
255
|
+
|
|
256
|
+
/** Compact reminder: collapse a description to a single short line. */
|
|
257
|
+
function truncateSkillDescription(desc: string): string {
|
|
258
|
+
const firstLine = desc.split('\n')[0]?.trim() ?? ''
|
|
259
|
+
return firstLine.length > COMPACT_DESC_MAX_CHARS
|
|
260
|
+
? firstLine.slice(0, COMPACT_DESC_MAX_CHARS) + '…'
|
|
261
|
+
: firstLine
|
|
262
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
5
|
+
|
|
6
|
+
// Skill 使用记录:name → 最近调用时间戳(epoch ms)。持久化到 ~/.mipham/skill-usage.json,
|
|
7
|
+
// 供 /skill-doctor 识别「从未被调用」的 skill(跨会话累积,按证据 prune)。
|
|
8
|
+
const USAGE_DIR = join(homedir(), '.mipham')
|
|
9
|
+
const USAGE_FILE = join(USAGE_DIR, 'skill-usage.json')
|
|
10
|
+
|
|
11
|
+
/** 读 skill 使用记录。文件缺失或损坏(非法 JSON / 类型不符)→ 空 Map,绝不抛错。 */
|
|
12
|
+
export function loadSkillUsage(): Map<string, number> {
|
|
13
|
+
try {
|
|
14
|
+
const raw = readFileSync(USAGE_FILE, 'utf-8')
|
|
15
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>
|
|
16
|
+
const map = new Map<string, number>()
|
|
17
|
+
for (const [name, ts] of Object.entries(parsed)) {
|
|
18
|
+
if (typeof ts === 'number') map.set(name, ts)
|
|
19
|
+
}
|
|
20
|
+
return map
|
|
21
|
+
} catch {
|
|
22
|
+
return new Map()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 记录一次 skill 调用。原子写(temp + rename),读者要么见旧值要么见新值,绝不读到半截。 */
|
|
27
|
+
export function recordSkillUsage(name: string, now = Date.now()): void {
|
|
28
|
+
const map = loadSkillUsage()
|
|
29
|
+
map.set(name, now)
|
|
30
|
+
const obj: Record<string, number> = {}
|
|
31
|
+
for (const [k, v] of map) obj[k] = v
|
|
32
|
+
mkdirSync(USAGE_DIR, { recursive: true })
|
|
33
|
+
atomicWriteFileSync(USAGE_FILE, JSON.stringify(obj))
|
|
34
|
+
}
|
package/src/tools/agent/skill.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { executeForkedSkill } from '../../skills/fork-executor'
|
|
|
3
3
|
import { sanitizeSkillBody } from '../../skills/sanitizer'
|
|
4
4
|
import { ensureSkillAssets } from '../../skills/skill-assets'
|
|
5
5
|
import { checkRequiredBins } from '../../skills/bin-check'
|
|
6
|
+
import { recordSkillUsage } from '../../skills/usage'
|
|
6
7
|
|
|
7
8
|
export const skillTool: ToolDefinition = {
|
|
8
9
|
name: 'Skill',
|
|
@@ -82,6 +83,7 @@ export const skillTool: ToolDefinition = {
|
|
|
82
83
|
ctx.llm,
|
|
83
84
|
)
|
|
84
85
|
// Return to AI as internal context
|
|
86
|
+
recordSkillUsage(skillName)
|
|
85
87
|
return { success: true, content: `[Forked skill "${skillName}" result]:\n${result}` }
|
|
86
88
|
} catch (err) {
|
|
87
89
|
return {
|
|
@@ -108,6 +110,7 @@ export const skillTool: ToolDefinition = {
|
|
|
108
110
|
bodyText,
|
|
109
111
|
].filter(Boolean)
|
|
110
112
|
|
|
113
|
+
recordSkillUsage(skillName)
|
|
111
114
|
return { success: true, content: lines.join('\n') }
|
|
112
115
|
}
|
|
113
116
|
|
package/src/ui/app.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import type { RemoteEngine } from '../daemon/remote-engine'
|
|
|
9
9
|
import type { MiphamConfig } from '../shared/index.ts'
|
|
10
10
|
import type { Llm } from '../providers/llm'
|
|
11
11
|
import { AUTOCOMPLETE_MAX_CONTEXT, type RecentMessage } from '../core/autocomplete'
|
|
12
|
+
import { resolveGitPr, prColor, type GitPr } from '../core/git-pr'
|
|
12
13
|
import type { SkillsLoader } from '../skills/loader'
|
|
13
14
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
14
15
|
import { setPreference } from '../config/preferences'
|
|
@@ -200,6 +201,18 @@ export function App({
|
|
|
200
201
|
return null
|
|
201
202
|
}
|
|
202
203
|
})
|
|
204
|
+
// Current branch's PR (head = branch) — async detect; gh unavailable / no PR → null.
|
|
205
|
+
const [gitPr, setGitPr] = useState<GitPr | null>(null)
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!gitBranch) return
|
|
208
|
+
let cancelled = false
|
|
209
|
+
resolveGitPr(gitBranch).then((pr) => {
|
|
210
|
+
if (!cancelled) setGitPr(pr)
|
|
211
|
+
})
|
|
212
|
+
return () => {
|
|
213
|
+
cancelled = true
|
|
214
|
+
}
|
|
215
|
+
}, [gitBranch])
|
|
203
216
|
|
|
204
217
|
// 启动后台查新版(非阻塞;离线静默失败)
|
|
205
218
|
useEffect(() => {
|
|
@@ -1249,7 +1262,12 @@ export function App({
|
|
|
1249
1262
|
</Box>
|
|
1250
1263
|
|
|
1251
1264
|
{/* Git branch — dim, bottom-most, mirrors Claude Code's "⏺ main" */}
|
|
1252
|
-
{gitBranch &&
|
|
1265
|
+
{gitBranch && (
|
|
1266
|
+
<Box>
|
|
1267
|
+
<Text dimColor>⏺ {gitBranch}</Text>
|
|
1268
|
+
{gitPr && <Text color={prColor(gitPr)}> · PR #{gitPr.number}</Text>}
|
|
1269
|
+
</Box>
|
|
1270
|
+
)}
|
|
1253
1271
|
</>
|
|
1254
1272
|
)}
|
|
1255
1273
|
</Box>
|
package/src/ui/commands.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { QueryEngine } from '../core/engine'
|
|
|
8
8
|
import type { MiphamConfig } from '../shared/index.ts'
|
|
9
9
|
import { formatContextWindow } from '../shared/format'
|
|
10
10
|
import type { SkillsLoader } from '../skills/loader'
|
|
11
|
+
import { loadSkillUsage } from '../skills/usage'
|
|
11
12
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
12
13
|
import type { Message } from '../shared/types.js'
|
|
13
14
|
import type { UpdateStatus } from '../shared/update'
|
|
@@ -3198,6 +3199,71 @@ const doctorCmd: CommandHandler = async (ctx) => {
|
|
|
3198
3199
|
return { content: lines.join('\n') }
|
|
3199
3200
|
}
|
|
3200
3201
|
|
|
3202
|
+
const skillDoctorCmd: CommandHandler = async (ctx) => {
|
|
3203
|
+
const t = resolveT(ctx)
|
|
3204
|
+
const lines: string[] = [t('commands.skillDoctor.title'), '']
|
|
3205
|
+
|
|
3206
|
+
const loader = ctx.skillsLoader
|
|
3207
|
+
if (!loader) {
|
|
3208
|
+
lines.push(t('commands.skillDoctor.unavailable'))
|
|
3209
|
+
return { content: lines.join('\n') }
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
const skills = loader.list()
|
|
3213
|
+
const usage = loadSkillUsage()
|
|
3214
|
+
|
|
3215
|
+
// Per-skill context cost = the reminder entry estimate (matches buildSystemReminder).
|
|
3216
|
+
const rows = skills.map((s) => {
|
|
3217
|
+
const entry = `- ${s.name}: ${s.description}`
|
|
3218
|
+
return {
|
|
3219
|
+
name: s.name,
|
|
3220
|
+
type: s.type,
|
|
3221
|
+
tokens: Math.ceil(entry.length / 4) + 1,
|
|
3222
|
+
lastUsed: usage.get(s.name),
|
|
3223
|
+
}
|
|
3224
|
+
})
|
|
3225
|
+
|
|
3226
|
+
const unused = rows.filter((r) => r.lastUsed === undefined)
|
|
3227
|
+
const used = rows
|
|
3228
|
+
.filter((r) => r.lastUsed !== undefined)
|
|
3229
|
+
.sort((a, b) => b.lastUsed! - a.lastUsed!)
|
|
3230
|
+
const totalTokens = rows.reduce((sum, r) => sum + r.tokens, 0)
|
|
3231
|
+
const unusedTokens = unused.reduce((sum, r) => sum + r.tokens, 0)
|
|
3232
|
+
|
|
3233
|
+
const age = (ts: number): string => {
|
|
3234
|
+
const days = Math.floor((Date.now() - ts) / 86400000)
|
|
3235
|
+
if (days <= 0) return t('commands.skillDoctor.today')
|
|
3236
|
+
return t('commands.skillDoctor.days_ago', { days: String(days) })
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
lines.push(
|
|
3240
|
+
t('commands.skillDoctor.summary', {
|
|
3241
|
+
total: String(skills.length),
|
|
3242
|
+
tokens: String(totalTokens),
|
|
3243
|
+
unused: String(unused.length),
|
|
3244
|
+
unusedTokens: String(unusedTokens),
|
|
3245
|
+
}),
|
|
3246
|
+
)
|
|
3247
|
+
lines.push('')
|
|
3248
|
+
|
|
3249
|
+
if (unused.length > 0) {
|
|
3250
|
+
lines.push(t('commands.skillDoctor.unused_header'))
|
|
3251
|
+
for (const r of unused) {
|
|
3252
|
+
lines.push(` • ${r.name} (${r.type}) — ~${r.tokens} tokens`)
|
|
3253
|
+
}
|
|
3254
|
+
lines.push('')
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
if (used.length > 0) {
|
|
3258
|
+
lines.push(t('commands.skillDoctor.used_header'))
|
|
3259
|
+
for (const r of used) {
|
|
3260
|
+
lines.push(` • ${r.name} (${r.type}) — ${age(r.lastUsed!)}`)
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
return { content: lines.join('\n') }
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3201
3267
|
const fixCmd: CommandHandler = async (ctx, args) => {
|
|
3202
3268
|
const t = resolveT(ctx)
|
|
3203
3269
|
const { readFileSync, writeFileSync } = await import('node:fs')
|
|
@@ -5143,6 +5209,7 @@ const commandsListCmd: CommandHandler = () => {
|
|
|
5143
5209
|
'/save': 'Session & Identity',
|
|
5144
5210
|
'/export': 'Session & Identity',
|
|
5145
5211
|
'/doctor': 'Session & Identity',
|
|
5212
|
+
'/skill-doctor': 'Session & Identity',
|
|
5146
5213
|
'/dream': 'Session & Identity',
|
|
5147
5214
|
'/constitution': 'Session & Identity',
|
|
5148
5215
|
'/bug-report': 'Session & Identity',
|
|
@@ -5388,6 +5455,7 @@ registry.set('/pr-comments', prCommentsCmd)
|
|
|
5388
5455
|
|
|
5389
5456
|
// Session Management
|
|
5390
5457
|
registry.set('/doctor', doctorCmd)
|
|
5458
|
+
registry.set('/skill-doctor', skillDoctorCmd)
|
|
5391
5459
|
registry.set('/fix', fixCmd)
|
|
5392
5460
|
registry.set('/export', exportCmd)
|
|
5393
5461
|
registry.set('/resume', resumeCmd)
|
|
@@ -5493,6 +5561,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
5493
5561
|
'/save': 'Save conversation to Obsidian wiki (skill: save-to-wiki)',
|
|
5494
5562
|
'/export': 'Export conversation to file',
|
|
5495
5563
|
'/doctor': 'System diagnostics',
|
|
5564
|
+
'/skill-doctor': 'Show unused skills and their context cost',
|
|
5496
5565
|
'/fix': 'Deterministic self-repair: doctor/config/cache',
|
|
5497
5566
|
'/dream': 'Background memory consolidation',
|
|
5498
5567
|
'/constitution': 'View or reload constitutional principles',
|