@miphamai/cli 0.70.0 → 0.72.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.70.0",
3
+ "version": "0.72.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -235,6 +235,19 @@ export function renderManagedRuleSource(signal: CrsiSignal): string | null {
235
235
  return null
236
236
  }
237
237
 
238
+ /**
239
+ * 只路由不禁用护栏(CRSI 教训 #learning):拒绝「禁用某内置能力」的 blanket 规则。
240
+ * 命中返回 true——producer 拒绝产出 managed rule,强制改写成「prefer X over Y」路由。
241
+ */
242
+ const DISABLE_INTENT_RE =
243
+ /(禁用|禁止|停用|封禁)[^,。\n]{0,20}(工具|能力|功能|tool|capabilit)|\bnever\s+use\b|\bdon'?t\s+use\b|\bdo\s+not\s+use\b/i
244
+
245
+ /** 检测信号是否表达「禁用某能力」的 blanket 意图(而非「prefer X over Y」路由)。 */
246
+ export function hasDisableIntent(signal: CrsiSignal): boolean {
247
+ if (/^(disable|ban|never-use|never_use)$/.test(signal.category)) return true
248
+ return DISABLE_INTENT_RE.test(`${signal.category} ${signal.title} ${signal.suggestion}`)
249
+ }
250
+
238
251
  /** 产出受管理规则变更候选(毕业路径)。无合格信号 / 同名规则已存在时返回 null。 */
239
252
  export function produceRuleProposal(
240
253
  signal: CrsiSignal,
@@ -246,6 +259,8 @@ export function produceRuleProposal(
246
259
  originalContent: string
247
260
  blastRadius: string[]
248
261
  } | null {
262
+ // 只路由不禁用护栏:拒绝「禁用某内置能力」的 blanket 规则。
263
+ if (hasDisableIntent(signal)) return null
249
264
  const ruleSource = renderManagedRuleSource(signal)
250
265
  if (!ruleSource) return null
251
266
 
@@ -1,8 +1,9 @@
1
1
  // CRSI 改进轨:噪声自适应改进判定 + 台账 + pending verdict 闸。
2
2
  // A1 不破:verdict / minEffect / 改进率全是确定性算术(均值/标准差/阈值/Wilson),无 LLM 裁判。
3
- import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'
3
+ import { readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { homedir } from 'node:os'
6
+ import { atomicWriteFileSync } from '../shared/atomic-write'
6
7
  import type { SkillDeltaSample } from './task-performance'
7
8
 
8
9
  export type ImprovementVerdict = 'improved' | 'regressed' | 'inconclusive'
@@ -115,7 +116,11 @@ export function improvementPath(): string {
115
116
  export function appendImprovement(record: ImprovementRecord): void {
116
117
  const file = improvementPath()
117
118
  mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
118
- appendFileSync(file, JSON.stringify(record) + '\n', 'utf-8')
119
+ // 原子激活(④):整账本读-改-写 + temp 文件 rename(见 shared/atomic-write),读者要么见旧要么见新。
120
+ // 非原子的 appendFileSync 写中途崩溃会留撕裂行,readImprovements 会 JSON.parse 抛错。
121
+ const existing = readImprovements()
122
+ existing.push(record)
123
+ atomicWriteFileSync(file, existing.map((r) => JSON.stringify(r)).join('\n') + '\n')
119
124
  }
120
125
 
121
126
  export function readImprovements(): ImprovementRecord[] {
@@ -124,19 +129,42 @@ export function readImprovements(): ImprovementRecord[] {
124
129
  return readFileSync(file, 'utf-8')
125
130
  .split('\n')
126
131
  .filter((line) => line.trim() !== '')
127
- .map((line) => JSON.parse(line) as ImprovementRecord)
132
+ .flatMap((line) => {
133
+ try {
134
+ return [JSON.parse(line) as ImprovementRecord]
135
+ } catch {
136
+ return [] // 残留撕裂/坏行跳过,不抛(原子激活的容错侧)
137
+ }
138
+ })
128
139
  }
129
140
 
130
141
  // ── pending verdict 闸(倒退才拦) ──
142
+ // 原子激活(④):pending verdict 是「激活指针」,持久化为不可变 manifest + 原子替换
143
+ // (temp+rename,见 shared/atomic-write),替代易失内存变量(进程重启即丢、无 manifest)。
131
144
 
132
- let pendingVerdict: ImprovementVerdict | null = null
145
+ export function pendingVerdictPath(): string {
146
+ return join(homedir(), '.mipham', 'crsi', 'pending-verdict.json')
147
+ }
133
148
 
134
149
  export function setPendingVerdict(v: ImprovementVerdict | null): void {
135
- pendingVerdict = v
150
+ const file = pendingVerdictPath()
151
+ if (v === null) {
152
+ rmSync(file, { force: true }) // 原子清除(unlink 原子)
153
+ return
154
+ }
155
+ mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
156
+ atomicWriteFileSync(file, JSON.stringify({ verdict: v, timestamp: new Date().toISOString() }))
136
157
  }
137
158
 
138
159
  export function getPendingVerdict(): ImprovementVerdict | null {
139
- return pendingVerdict
160
+ const file = pendingVerdictPath()
161
+ if (!existsSync(file)) return null
162
+ try {
163
+ const parsed = JSON.parse(readFileSync(file, 'utf-8')) as { verdict: ImprovementVerdict }
164
+ return parsed.verdict
165
+ } catch {
166
+ return null // manifest 损坏 → 视为无 pending(与旧 `?? 'inconclusive'` 的 fail-open 一致)
167
+ }
140
168
  }
141
169
 
142
170
  export function shouldBlockApproval(v: ImprovementVerdict): boolean {
@@ -9,7 +9,7 @@ import {
9
9
  statSync,
10
10
  } from 'node:fs'
11
11
  import { join, extname, basename } from 'node:path'
12
- import { similarities } from './tfidf'
12
+ import { similarities, findNearDuplicates } from './tfidf'
13
13
 
14
14
  export interface MemoryMetadata {
15
15
  type: 'user' | 'feedback' | 'project' | 'reference'
@@ -333,6 +333,26 @@ export class MemoryManager {
333
333
  return { merged, removed }
334
334
  }
335
335
 
336
+ /**
337
+ * 只读「近重复报告」:跨所有记忆(含手写)列出 TF-IDF 余弦 > 阈值的近重复对,
338
+ * 供 /memory dedup 展示。只检测不合并——合并是用户的决定(写时去重已拦新增,
339
+ * 这里报告存量近重复供人工审视)。
340
+ */
341
+ listNearDuplicates(
342
+ threshold: number = DEDUP_THRESHOLD,
343
+ ): Array<{ a: string; b: string; similarity: number }> {
344
+ const entries = [...this.memories.values()]
345
+ const pairs = findNearDuplicates(
346
+ entries.map((e) => e.content),
347
+ threshold,
348
+ )
349
+ return pairs.map(({ i, j, similarity }) => ({
350
+ a: entries[i]!.name,
351
+ b: entries[j]!.name,
352
+ similarity,
353
+ }))
354
+ }
355
+
336
356
  buildSystemReminder(context: string, maxTokens?: number, grounding?: string): string {
337
357
  // Adaptive budget: 5% of context window, min 5000, max 75000
338
358
  const effectiveMaxTokens =
@@ -106,3 +106,33 @@ export function similarities(query: string, docs: string[]): number[] {
106
106
  const queryVec = tfidfVector(queryTokens, df, numDocs)
107
107
  return docTokens.map((tokens) => cosine(queryVec, tfidfVector(tokens, df, numDocs)))
108
108
  }
109
+
110
+ /**
111
+ * All pairs (i < j) of docs whose TF-IDF cosine similarity is `> threshold`,
112
+ * sorted by similarity descending. Deterministic near-duplicate detection —
113
+ * flags candidates for manual merge, never merges automatically.
114
+ */
115
+ export function findNearDuplicates(
116
+ docs: string[],
117
+ threshold: number,
118
+ ): Array<{ i: number; j: number; similarity: number }> {
119
+ const docTokens = docs.map(tokenize)
120
+ const numDocs = docs.length
121
+
122
+ const df = new Map<string, number>()
123
+ for (const tokens of docTokens) {
124
+ for (const term of new Set(tokens)) {
125
+ df.set(term, (df.get(term) ?? 0) + 1)
126
+ }
127
+ }
128
+
129
+ const vectors = docTokens.map((tokens) => tfidfVector(tokens, df, numDocs))
130
+ const pairs: Array<{ i: number; j: number; similarity: number }> = []
131
+ for (let i = 0; i < numDocs; i++) {
132
+ for (let j = i + 1; j < numDocs; j++) {
133
+ const similarity = cosine(vectors[i]!, vectors[j]!)
134
+ if (similarity > threshold) pairs.push({ i, j, similarity })
135
+ }
136
+ }
137
+ return pairs.sort((a, b) => b.similarity - a.similarity)
138
+ }
@@ -1,6 +1,182 @@
1
1
  import type { PermissionRuleEntry } from '../shared/index.ts'
2
2
  import { matchPath } from './credential-masker/matcher'
3
3
 
4
+ // ── Bash command analysis (Read/Write/Edit deny-rule extension) ──
5
+ //
6
+ // A Read(/etc/passwd) deny rule must also refuse Bash commands that read that
7
+ // file (`cat /etc/passwd`, `tac /etc/passwd`, `grep x /etc/passwd`,
8
+ // `< /etc/passwd`) — not only the Read tool. Same for Write/Edit rules and
9
+ // `> file` redirects / in-place editors. These helpers map a Bash command to
10
+ // the file paths it touches, conservatively: over-matching is the safe
11
+ // direction for a deny rule, under-matching is not.
12
+
13
+ /** Commands that read a file and print its contents (bypass Read() rules today). */
14
+ const READER_COMMANDS = new Set([
15
+ 'cat',
16
+ 'tac',
17
+ 'head',
18
+ 'tail',
19
+ 'less',
20
+ 'more',
21
+ 'grep',
22
+ 'egrep',
23
+ 'fgrep',
24
+ 'rg',
25
+ 'sed',
26
+ 'perl',
27
+ 'awk',
28
+ 'nl',
29
+ 'sort',
30
+ 'uniq',
31
+ 'cut',
32
+ 'diff',
33
+ 'comm',
34
+ 'join',
35
+ 'paste',
36
+ 'file',
37
+ 'stat',
38
+ 'xxd',
39
+ 'od',
40
+ 'hexdump',
41
+ 'strings',
42
+ 'wc',
43
+ 'view',
44
+ 'zcat',
45
+ 'bzcat',
46
+ ])
47
+
48
+ /** Commands that write/modify a file (bypass Write()/Edit() rules today). */
49
+ const WRITER_COMMANDS = new Set([
50
+ 'tee',
51
+ 'touch',
52
+ 'nano',
53
+ 'vi',
54
+ 'vim',
55
+ 'emacs',
56
+ 'ed',
57
+ 'ex',
58
+ 'cp',
59
+ 'mv',
60
+ 'rm',
61
+ 'dd',
62
+ 'install',
63
+ 'truncate',
64
+ ])
65
+
66
+ /**
67
+ * Split a (possibly compound) shell command into simple-command segments, so a
68
+ * Bash(pattern) rule matches any segment rather than only the whole string
69
+ * (`rm -rf /` buried in `foo && rm -rf /` must still match).
70
+ *
71
+ * Separators: `&&` `||` `;` `|` `&` and newline. Redirect operators (`>`,
72
+ * `>>`, `<`) are NOT separators — they belong to the same simple command.
73
+ * Quoting is not parsed (a `&` inside quotes still splits), which only
74
+ * over-matches — the safe direction for deny rules.
75
+ */
76
+ export function splitShellSegments(command: string): string[] {
77
+ return command
78
+ .split(/&&|\|\||;|\||&|\n|\r/g)
79
+ .map((s) => s.trim())
80
+ .filter((s) => s.length > 0)
81
+ }
82
+
83
+ function stripQuotes(s: string): string {
84
+ if (s.length >= 2) {
85
+ const first = s[0]
86
+ const last = s[s.length - 1]
87
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
88
+ return s.slice(1, -1)
89
+ }
90
+ }
91
+ return s
92
+ }
93
+
94
+ function uniq(items: string[]): string[] {
95
+ return [...new Set(items)]
96
+ }
97
+
98
+ /** File paths a Bash command reads or writes. */
99
+ export interface BashFileAccess {
100
+ read: string[]
101
+ write: string[]
102
+ }
103
+
104
+ /**
105
+ * Extract the file paths a Bash command reads or writes, so Read()/Write()/
106
+ * Edit() deny rules also apply to Bash (not just the Read/Write/Edit tools).
107
+ * Covers redirects (`<` reads, `>`/`>>` writes) and reader/writer command
108
+ * arguments — including inside `$(...)` and backtick substitutions. Deliberately
109
+ * conservative, not a full shell parser.
110
+ */
111
+ export function extractBashFileAccess(command: string): BashFileAccess {
112
+ const read: string[] = []
113
+ const write: string[] = []
114
+
115
+ // 1. Redirect targets. Handles `> file` and `>file`, optional fd prefix
116
+ // (`2>`, `&>`). Heredocs (`<<`) and fd-dup targets (`2>&1`) are skipped —
117
+ // their "target" is a delimiter or file descriptor, not a path.
118
+ const redirectRe = /(?:^|[\s;|&])([0-9]*&?)?(>>|<<|<|>)\s*([^\s;|&<>]+)/g
119
+ let m: RegExpExecArray | null
120
+ while ((m = redirectRe.exec(command)) !== null) {
121
+ const op = m[2]!
122
+ if (op === '<<') continue // heredoc delimiter
123
+ const target = stripQuotes(m[3]!)
124
+ if (op === '<') read.push(target)
125
+ else write.push(target)
126
+ }
127
+
128
+ // 2. Reader/writer command arguments (non-flag args are candidate paths),
129
+ // recursing into `$(...)` / backtick substitutions.
130
+ scanReaderWriterCommands(command, read, write)
131
+
132
+ return { read: uniq(read), write: uniq(write) }
133
+ }
134
+
135
+ /** Extract the inner commands of `$(...)` and backtick substitutions. */
136
+ function extractSubstitutions(command: string): string[] {
137
+ const inners: string[] = []
138
+ let m: RegExpExecArray | null
139
+ // `$(...)` — non-nested groups; deeper nesting is handled by the recursion
140
+ // in scanReaderWriterCommands (each level is extracted on the next pass).
141
+ const dollarParen = /\$\(([^()]*)\)/g
142
+ while ((m = dollarParen.exec(command)) !== null) inners.push(m[1]!)
143
+ const backtick = /`([^`]*)`/g
144
+ while ((m = backtick.exec(command)) !== null) inners.push(m[1]!)
145
+ return inners
146
+ }
147
+
148
+ /**
149
+ * Detect reader/writer commands at the front of each shell segment and recurse
150
+ * into command substitutions, so `echo $(cat .git-credentials)` is caught.
151
+ */
152
+ function scanReaderWriterCommands(command: string, read: string[], write: string[]): void {
153
+ for (const seg of splitShellSegments(command)) {
154
+ const tokens = seg.split(/\s+/).filter(Boolean)
155
+ if (tokens.length > 0) {
156
+ const base = (tokens[0] || '').split('/').pop() || ''
157
+ const args = tokens.slice(1)
158
+ if (READER_COMMANDS.has(base)) {
159
+ // `sed -i` / `perl -i` read AND write their file args.
160
+ const inPlace = args.some((a) => a === '-i' || a.startsWith('--in-place'))
161
+ for (const arg of args) {
162
+ if (arg.startsWith('-')) continue
163
+ const p = stripQuotes(arg)
164
+ read.push(p)
165
+ if (inPlace) write.push(p)
166
+ }
167
+ } else if (WRITER_COMMANDS.has(base)) {
168
+ for (const arg of args) {
169
+ if (arg.startsWith('-')) continue
170
+ write.push(stripQuotes(arg))
171
+ }
172
+ }
173
+ }
174
+ for (const inner of extractSubstitutions(seg)) {
175
+ scanReaderWriterCommands(inner, read, write)
176
+ }
177
+ }
178
+ }
179
+
4
180
  // Match a tool(parameter) rule against an actual tool call.
5
181
  //
6
182
  // Pattern formats:
@@ -25,12 +201,24 @@ export function matchBashRule(
25
201
 
26
202
  const [, baseTool, subPattern] = parenMatch
27
203
 
204
+ // A Read/Write/Edit rule must also refuse a Bash command that touches the
205
+ // same file (via a reader/editor command or a redirect), not only the
206
+ // Read/Write/Edit tool itself. Otherwise `cat .git-credentials` bypasses a
207
+ // `Read(.git-credentials)` deny rule.
208
+ if (toolName === 'Bash' && (baseTool === 'Read' || baseTool === 'Write' || baseTool === 'Edit')) {
209
+ const cmd = String(toolInput.command || '')
210
+ const access = extractBashFileAccess(cmd)
211
+ const paths = baseTool === 'Read' ? access.read : access.write
212
+ return paths.some((p) => matchPath(p, subPattern!))
213
+ }
214
+
28
215
  if (toolName !== baseTool!) return false
29
216
 
30
- // For Bash: match against the command string
217
+ // For Bash: match against the command string (any segment of a compound
218
+ // command — `rm -rf /` buried in `foo && rm -rf /` still matches).
31
219
  if (baseTool === 'Bash') {
32
220
  const cmd = String(toolInput.command || '')
33
- return wildcardMatch(subPattern!, cmd.trim())
221
+ return splitShellSegments(cmd).some((seg) => wildcardMatch(subPattern!, seg))
34
222
  }
35
223
 
36
224
  // For Write/Edit/Read: match against the file_path with path-glob semantics.
@@ -235,6 +235,7 @@
235
235
  "title": "── Token Usage (estimated) ──",
236
236
  "context_tokens": "Context tokens:",
237
237
  "usage": "Usage:",
238
+ "prompt_cache": "Prompt cache: ~{cached} cached ({ratio}% hit) · ~{uncached} uncached",
238
239
  "footer": "Token counting is approximate (chars/4).\nActual API usage depends on provider and model."
239
240
  },
240
241
  "model": {
@@ -415,7 +416,10 @@
415
416
  "gc_done": "Done. Archived files moved to ~/.mipham/memory/archive/",
416
417
  "consolidate_title": "─ Memory Consolidate ─",
417
418
  "consolidate_none": "No auto-* memories to consolidate.",
418
- "consolidate_result": "Consolidated: {removed} auto-* removed, {merged} lessons written."
419
+ "consolidate_result": "Consolidated: {removed} auto-* removed, {merged} lessons written.",
420
+ "dedup_title": "─ Memory Near-Duplicates ─",
421
+ "dedup_none": "No near-duplicate memories found.",
422
+ "dedup_footer": "Pairs above the TF-IDF cosine threshold. Merge is manual — run /memory consolidate for auto-* clustering, or edit the files directly."
419
423
  },
420
424
  "upgrade": {
421
425
  "uptodate": "── Upgrade Mipham Code ──\n\nCurrent version: v{current}\nLatest: v{latest}\n\n✓ Already up to date.\n\nTo check manually: https://www.npmjs.com/package/@miphamai/cli",
@@ -235,6 +235,7 @@
235
235
  "title": "── Token 用量(估算)──",
236
236
  "context_tokens": "上下文 Token:",
237
237
  "usage": "使用率:",
238
+ "prompt_cache": "Prompt 缓存: ~{cached} 命中 ({ratio}% hit) · ~{uncached} 未命中",
238
239
  "footer": "Token 计数为近似值(字符数/4)。\n实际 API 用量取决于提供商和模型。"
239
240
  },
240
241
  "model": {
@@ -415,7 +416,10 @@
415
416
  "gc_done": "完成。归档文件已移到 ~/.mipham/memory/archive/",
416
417
  "consolidate_title": "─ 记忆合并 ─",
417
418
  "consolidate_none": "没有需要合并的 auto-* 记忆。",
418
- "consolidate_result": "合并完成:删除 {removed} 条 auto-*,写入 {merged} 条 lesson-*。"
419
+ "consolidate_result": "合并完成:删除 {removed} 条 auto-*,写入 {merged} 条 lesson-*。",
420
+ "dedup_title": "─ 记忆近重复 ─",
421
+ "dedup_none": "未发现近重复记忆。",
422
+ "dedup_footer": "以上为 TF-IDF 余弦超过阈值的近重复对。合并需手动——auto-* 走 /memory consolidate 聚簇,或直接编辑文件。"
419
423
  },
420
424
  "upgrade": {
421
425
  "uptodate": "── 升级 Mipham Code ──\n\n当前版本: v{current}\n最新版本: v{latest}\n\n✓ 已是最新版本。\n\n手动检查: https://www.npmjs.com/package/@miphamai/cli",
@@ -96,11 +96,12 @@ export class OpenAICompatProvider implements ProviderInstance {
96
96
  if (data === '[DONE]') {
97
97
  // Emit any pending tool calls before stopping
98
98
  for (const [, tc] of pendingToolCalls) {
99
+ if (!tc.name) continue // drop malformed tool call (missing name)
99
100
  yield {
100
101
  type: 'tool_use',
101
102
  toolUse: {
102
103
  type: 'tool_use',
103
- id: tc.id,
104
+ id: tc.id || `call_${Date.now()}`,
104
105
  name: tc.name,
105
106
  input: this.safeParseJson(tc.arguments),
106
107
  },
@@ -155,6 +156,7 @@ export class OpenAICompatProvider implements ProviderInstance {
155
156
  if (choice.finish_reason === 'tool_calls') {
156
157
  // Emit fully accumulated tool calls
157
158
  for (const [, tc] of pendingToolCalls) {
159
+ if (!tc.name) continue // drop malformed tool call (missing name)
158
160
  yield {
159
161
  type: 'tool_use',
160
162
  toolUse: {
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.70.0' as const
12
+ export const PACKAGE_VERSION = '0.72.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -130,7 +130,10 @@ export function isBlocked(command: string): string | null {
130
130
 
131
131
  // Detect dangerous git operations invoked via Bash — anywhere in the command,
132
132
  // so `echo ok && git push --force` is still caught (not just `^git`).
133
- if (/(?:^|[\s;&|])(?:sudo\s+)?(?:git|gh)\s+/.test(normalized)) {
133
+ // `gh` is deliberately NOT scanned: DANGEROUS_GIT_PATTERNS are all git
134
+ // subcommands (push/reset/clean/…), and scanning `gh` only produced false
135
+ // positives (e.g. `gh issue create --body "...git config user.name..."`).
136
+ if (/(?:^|[\s;&|])(?:sudo\s+)?git\s+/.test(normalized)) {
134
137
  for (const { pattern, description } of DANGEROUS_GIT_PATTERNS) {
135
138
  if (pattern.test(normalized)) {
136
139
  return `Dangerous git command blocked: "${description}". Run manually if intended.`
@@ -276,7 +276,7 @@ export const webFetchTool: ToolDefinition = {
276
276
  const message =
277
277
  err instanceof Error && err.name === 'AbortError'
278
278
  ? 'Request timed out (30s)'
279
- : `Fetch failed: ${String(err)}`
279
+ : `Fetch failed: ${String(err)}. If direct network is blocked, retry via the web-access skill (CDP through the user's logged-in Chrome).`
280
280
  return { success: false, content: '', error: message }
281
281
  }
282
282
  },
@@ -126,6 +126,8 @@ export const webSearchTool: ToolDefinition = {
126
126
  'Alternatives (add API key for any):',
127
127
  ' TAVILY_API_KEY — https://tavily.com',
128
128
  ' SERPAPI_API_KEY — https://serpapi.com',
129
+ '',
130
+ 'No API key? Use the web-access skill (CDP through your logged-in Chrome) for live web search.',
129
131
  ].join('\n'),
130
132
  }
131
133
  }
@@ -199,7 +201,7 @@ export const webSearchTool: ToolDefinition = {
199
201
  const message =
200
202
  err instanceof Error && err.name === 'AbortError'
201
203
  ? 'Search timed out (15s). Try a more specific query.'
202
- : `Search failed: ${String(err)}`
204
+ : `Search failed: ${String(err)}. If direct network is blocked, retry via the web-access skill (CDP through the user's logged-in Chrome).`
203
205
  return { success: false, content: '', error: message }
204
206
  }
205
207
  },
@@ -400,11 +400,20 @@ const statusCmd: CommandHandler = async (ctx) => {
400
400
  const costCmd: CommandHandler = (ctx) => {
401
401
  const t = resolveT(ctx)
402
402
  const tokens = ctx.engine.getContext().getEstimatedTokens()
403
+ const cacheStatus = ctx.engine.getContext().getCacheStatus()
404
+ const cachedTokens = cacheStatus.cachedTokens
405
+ const hitRatio = tokens > 0 ? Math.min(1, cachedTokens / tokens) : 0
406
+ const uncachedTokens = Math.max(0, tokens - cachedTokens)
403
407
  return {
404
408
  content: stripIndent`
405
409
  ${t('commands.context_tokens.title')}
406
410
  ${t('commands.context_tokens.context_tokens')} ~${tokens.toLocaleString()} / 200,000
407
411
  ${t('commands.context_tokens.usage')} ${((tokens / 200_000) * 100).toFixed(1)}%
412
+ ${t('commands.context_tokens.prompt_cache', {
413
+ cached: cachedTokens.toLocaleString(),
414
+ ratio: (hitRatio * 100).toFixed(1),
415
+ uncached: uncachedTokens.toLocaleString(),
416
+ })}
408
417
 
409
418
  ${t('commands.context_tokens.footer')}
410
419
  `,
@@ -4086,6 +4095,23 @@ const memoryCmd: CommandHandler = async (ctx, args) => {
4086
4095
  return { content: lines.join('\n') }
4087
4096
  }
4088
4097
 
4098
+ // /memory dedup — 只读近重复报告:列出 TF-IDF 余弦 > 阈值的近重复对,用户决定是否合并
4099
+ if (args[0]?.toLowerCase() === 'dedup') {
4100
+ const mm = getMemoryManager()
4101
+ const pairs = mm.listNearDuplicates()
4102
+ const lines: string[] = [t('commands.memories.dedup_title'), '']
4103
+ if (pairs.length === 0) {
4104
+ lines.push(t('commands.memories.dedup_none'))
4105
+ } else {
4106
+ for (const { a, b, similarity } of pairs) {
4107
+ lines.push(` ${a} ↔ ${b} (${(similarity * 100).toFixed(0)}%)`)
4108
+ }
4109
+ lines.push('')
4110
+ lines.push(t('commands.memories.dedup_footer'))
4111
+ }
4112
+ return { content: lines.join('\n') }
4113
+ }
4114
+
4089
4115
  if (!existsSync(memoryDir)) {
4090
4116
  return { content: t('commands.memories.no_memories') }
4091
4117
  }
@@ -4495,6 +4521,14 @@ const mcpCmd: CommandHandler = async (ctx, args) => {
4495
4521
  }
4496
4522
  }
4497
4523
  const via = config.url ? 'HTTP' : 'stdio'
4524
+ if (config.url) {
4525
+ const headerKeys = config.headers ? Object.keys(config.headers) : []
4526
+ const headerLine = headerKeys.length > 0 ? `\nHeaders to send: ${headerKeys.join(', ')}` : ''
4527
+ return {
4528
+ content: `── MCP Connect: ${name} ──\n\nConnecting via HTTP\nURL: ${config.url}${headerLine}\n\n⚠️ Verify the URL — any configured headers (e.g. Authorization) will be sent to this server.`,
4529
+ forwardToAI: `Connect to MCP server "${name}" using McpClient.getInstance().connect(config), then register its tools. Report the result.`,
4530
+ }
4531
+ }
4498
4532
  return {
4499
4533
  content: `── MCP Connect: ${name} ──\n\nConnecting via ${via}...`,
4500
4534
  forwardToAI: `Connect to MCP server "${name}" using McpClient.getInstance().connect(config), then register its tools. Report the result.`,