@miphamai/cli 0.16.5 → 0.18.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.16.5",
3
+ "version": "0.18.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -5,6 +5,7 @@ import { homedir } from 'node:os'
5
5
  import { ContextManager } from '../core/context'
6
6
  import type { ToolDefinition } from '../shared/index.ts'
7
7
  import type { AgentDefinition } from './types'
8
+ import { AgentExperience } from './agent-experience'
8
9
 
9
10
  export interface AgentContextResult {
10
11
  context: ContextManager
@@ -31,28 +32,96 @@ function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local')
31
32
  break
32
33
  }
33
34
 
34
- if (!existsSync(memoryDir)) return ''
35
+ // Load static memory files
36
+ let staticMemory = ''
37
+ if (existsSync(memoryDir)) {
38
+ try {
39
+ const files = readdirSync(memoryDir).filter((f) => f.endsWith('.md'))
40
+ if (files.length > 0) {
41
+ const contents: string[] = []
42
+ for (const file of files.slice(0, 10)) {
43
+ try {
44
+ const content = readFileSync(join(memoryDir, file), 'utf-8').trim()
45
+ if (content) contents.push(content)
46
+ } catch {
47
+ // skip unreadable
48
+ }
49
+ }
50
+ if (contents.length > 0) {
51
+ staticMemory = [`[Agent Memory — ${scope} scope]`, ...contents].join('\n\n')
52
+ }
53
+ }
54
+ } catch {
55
+ // memory dir unreadable
56
+ }
57
+ }
35
58
 
36
- try {
37
- const files = readdirSync(memoryDir).filter((f) => f.endsWith('.md'))
38
- if (files.length === 0) return ''
59
+ // Load auto-accumulated experience (always from user scope)
60
+ const exp = new AgentExperience(agentName)
61
+ const experienceContent = exp.getExperience()
39
62
 
40
- const contents: string[] = []
41
- for (const file of files.slice(0, 10)) {
42
- // max 10 files
43
- try {
44
- const content = readFileSync(join(memoryDir, file), 'utf-8').trim()
45
- if (content) contents.push(content)
46
- } catch {
47
- // skip unreadable
48
- }
63
+ let experienceMemory = ''
64
+ if (experienceContent) {
65
+ const lines = experienceContent.split('\n')
66
+ const statsIdx = lines.findIndex((l) => l.startsWith('## Stats'))
67
+ const successIdx = lines.findIndex((l) => l.startsWith('## Success Patterns'))
68
+ const failureIdx = lines.findIndex((l) => l.startsWith('## Failure Patterns'))
69
+
70
+ // Header: content before the first section
71
+ const firstSection = Math.min(
72
+ successIdx !== -1 ? successIdx : Infinity,
73
+ failureIdx !== -1 ? failureIdx : Infinity,
74
+ statsIdx !== -1 ? statsIdx : Infinity,
75
+ )
76
+ const header = lines.slice(0, firstSection === Infinity ? 3 : firstSection)
77
+
78
+ // Last 5 success entries (summary line only, not detail)
79
+ const successLines: string[] = []
80
+ if (successIdx !== -1) {
81
+ const endIdx = Math.min(
82
+ failureIdx !== -1 && failureIdx > successIdx ? failureIdx : Infinity,
83
+ statsIdx !== -1 && statsIdx > successIdx ? statsIdx : Infinity,
84
+ )
85
+ successLines.push(
86
+ ...lines
87
+ .slice(successIdx + 1, endIdx === Infinity ? undefined : endIdx)
88
+ .filter((l) => l.startsWith('- ['))
89
+ .slice(-5),
90
+ )
49
91
  }
50
92
 
51
- if (contents.length === 0) return ''
52
- return [`[Agent Memory — ${scope} scope]`, ...contents].join('\n\n')
53
- } catch {
54
- return ''
93
+ // Last 3 failure entries (summary line only, not detail)
94
+ const failureLines: string[] = []
95
+ if (failureIdx !== -1) {
96
+ const endIdx = statsIdx !== -1 && statsIdx > failureIdx ? statsIdx : Infinity
97
+ failureLines.push(
98
+ ...lines
99
+ .slice(failureIdx + 1, endIdx === Infinity ? undefined : endIdx)
100
+ .filter((l) => l.startsWith('- ['))
101
+ .slice(-3),
102
+ )
103
+ }
104
+
105
+ // Stats section (header + stat line)
106
+ const stats = statsIdx !== -1 ? lines.slice(statsIdx, statsIdx + 3) : []
107
+
108
+ if (successLines.length > 0 || failureLines.length > 0) {
109
+ experienceMemory = [
110
+ '## Agent Experience',
111
+ ...header.filter((l) => l.trim()),
112
+ ...successLines,
113
+ ...failureLines,
114
+ ...stats,
115
+ ].join('\n')
116
+ }
117
+ }
118
+
119
+ // Combine both memory sources
120
+ if (staticMemory && experienceMemory) {
121
+ return `${staticMemory}\n\n---\n\n${experienceMemory}`
55
122
  }
123
+ if (experienceMemory) return experienceMemory
124
+ return staticMemory
56
125
  }
57
126
 
58
127
  /**
@@ -0,0 +1,105 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const MAX_EXPERIENCES = 20
5
+
6
+ export class AgentExperience {
7
+ private readonly expFile: string
8
+ private readonly expDir: string
9
+
10
+ constructor(
11
+ private readonly agentName: string,
12
+ baseDir: string = join(process.env.HOME || '~', '.mipham', 'agent-memory'),
13
+ ) {
14
+ this.expDir = join(baseDir, agentName)
15
+ this.expFile = join(this.expDir, 'experience.md')
16
+ }
17
+
18
+ logSuccess(description: string, whenToApply: string): void {
19
+ const date = new Date().toISOString().slice(0, 10)
20
+ const entry = `- [${date}] ${description}\n **When to apply:** ${whenToApply}\n`
21
+ this.appendToSection('## Success Patterns', entry, 'success')
22
+ }
23
+
24
+ logFailure(description: string, whenToAvoid: string): void {
25
+ const date = new Date().toISOString().slice(0, 10)
26
+ const entry = `- [${date}] ${description}\n **When to avoid:** ${whenToAvoid}\n`
27
+ this.appendToSection('## Failure Patterns', entry, 'failure')
28
+ }
29
+
30
+ getExperience(): string {
31
+ if (!existsSync(this.expFile)) return ''
32
+ try {
33
+ return readFileSync(this.expFile, 'utf-8')
34
+ } catch {
35
+ return ''
36
+ }
37
+ }
38
+
39
+ reset(): void {
40
+ if (existsSync(this.expFile)) {
41
+ try {
42
+ unlinkSync(this.expFile)
43
+ } catch {
44
+ /* ok */
45
+ }
46
+ }
47
+ }
48
+
49
+ private appendToSection(section: string, entry: string, type: 'success' | 'failure'): void {
50
+ mkdirSync(this.expDir, { recursive: true })
51
+
52
+ let content = this.getExperience()
53
+ if (!content) {
54
+ content = `# Agent Experience — ${this.agentName}\n\n## Success Patterns\n\n## Failure Patterns\n\n## Stats\n- 总执行: 0 次 | 成功: 0 | 失败: 0\n`
55
+ }
56
+
57
+ // Find section and append entry
58
+ const sectionIndex = content.indexOf(section)
59
+ if (sectionIndex === -1) {
60
+ // Section missing — add before Stats
61
+ const statsIndex = content.indexOf('## Stats')
62
+ if (statsIndex !== -1) {
63
+ content =
64
+ content.slice(0, statsIndex) + `${section}\n${entry}\n` + content.slice(statsIndex)
65
+ } else {
66
+ content += `\n${section}\n${entry}\n`
67
+ }
68
+ } else {
69
+ // Find next section header after this one
70
+ const nextSection = content.indexOf('\n## ', sectionIndex + section.length)
71
+ const insertAt = nextSection !== -1 ? nextSection : content.length
72
+ content = content.slice(0, insertAt) + entry + content.slice(insertAt)
73
+ }
74
+
75
+ // Trim old entries if over limit
76
+ const lines = content.split('\n')
77
+ let finalEntries = lines.filter((l) => l.startsWith('- ['))
78
+ while (finalEntries.length > MAX_EXPERIENCES) {
79
+ // Remove oldest entry (first one found)
80
+ const firstEntryIdx = lines.findIndex((l) => l.startsWith('- ['))
81
+ if (firstEntryIdx !== -1) {
82
+ const nextEntryLine = lines[firstEntryIdx + 1]
83
+ const removeCount = nextEntryLine?.startsWith(' **') ? 2 : 1
84
+ lines.splice(firstEntryIdx, removeCount)
85
+ finalEntries = lines.filter((l) => l.startsWith('- ['))
86
+ } else {
87
+ break
88
+ }
89
+ }
90
+ content = lines.join('\n')
91
+
92
+ // Update stats (merged from incrementStat — single file write)
93
+ content = content.replace(
94
+ /- 总执行: (\d+) 次 \| 成功: (\d+) \| 失败: (\d+)/,
95
+ (_match, total: string, success: string, failure: string) => {
96
+ const newTotal = parseInt(total) + 1
97
+ const newSuccess = type === 'success' ? parseInt(success) + 1 : parseInt(success)
98
+ const newFailure = type === 'failure' ? parseInt(failure) + 1 : parseInt(failure)
99
+ return `- 总执行: ${newTotal} 次 | 成功: ${newSuccess} | 失败: ${newFailure}`
100
+ },
101
+ )
102
+
103
+ writeFileSync(this.expFile, content, 'utf-8')
104
+ }
105
+ }
@@ -6,6 +6,7 @@ import { getBackgroundAgentRegistry } from './background-registry'
6
6
  import { getMessageBus } from './message-bus'
7
7
  import type { HookEngine } from '../core/hooks'
8
8
  import type { PermissionSystem } from '../core/permission'
9
+ import { AgentExperience } from './agent-experience'
9
10
 
10
11
  const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
11
12
  general: 'You are a focused sub-agent. Complete the assigned task thoroughly and return results.',
@@ -73,6 +74,17 @@ export class SubAgent {
73
74
  task.result || task.error,
74
75
  )
75
76
  }
77
+ // Auto-log agent experience for background tasks
78
+ if (task.status === 'completed') {
79
+ this.logSuccessExperience(agentType, description, task.result || '', options.agentDef)
80
+ } else {
81
+ this.logFailureExperience(
82
+ agentType,
83
+ description,
84
+ task.error || 'Unknown error',
85
+ options.agentDef,
86
+ )
87
+ }
76
88
  })
77
89
 
78
90
  return `[background-task:${taskId}]`
@@ -84,6 +96,7 @@ export class SubAgent {
84
96
  if (this.hookEngine) {
85
97
  await this.hookEngine.executeSubagentStop(agentType, description, 'sub-agent', true, result)
86
98
  }
99
+ this.logSuccessExperience(agentType, description, result, options.agentDef)
87
100
  return result
88
101
  } catch (err) {
89
102
  if (this.hookEngine) {
@@ -95,6 +108,7 @@ export class SubAgent {
95
108
  String(err),
96
109
  )
97
110
  }
111
+ this.logFailureExperience(agentType, description, String(err), options.agentDef)
98
112
  throw err
99
113
  }
100
114
  }
@@ -314,4 +328,46 @@ export class SubAgent {
314
328
 
315
329
  return chunks.join('')
316
330
  }
331
+
332
+ /**
333
+ * Log a successful execution to AgentExperience.
334
+ * Wrapped in try/catch so it never breaks agent execution.
335
+ */
336
+ private logSuccessExperience(
337
+ agentType: SubAgentType,
338
+ description: string,
339
+ result: string,
340
+ agentDef?: AgentDefinition,
341
+ ): void {
342
+ try {
343
+ const name = agentDef?.name || agentType
344
+ const exp = new AgentExperience(name)
345
+ if (result && result.trim()) {
346
+ const firstLine = result.trim().split('\n')[0]?.slice(0, 150) || 'Task completed'
347
+ exp.logSuccess(firstLine, description)
348
+ }
349
+ } catch {
350
+ // Never let experience logging break execution
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Log a failed execution to AgentExperience.
356
+ * Wrapped in try/catch so it never breaks agent execution.
357
+ */
358
+ private logFailureExperience(
359
+ agentType: SubAgentType,
360
+ description: string,
361
+ errMsg: string,
362
+ agentDef?: AgentDefinition,
363
+ ): void {
364
+ try {
365
+ const name = agentDef?.name || agentType
366
+ const exp = new AgentExperience(name)
367
+ const truncated = errMsg.slice(0, 200)
368
+ exp.logFailure(truncated, description)
369
+ } catch {
370
+ // Never let experience logging break execution
371
+ }
372
+ }
317
373
  }
@@ -0,0 +1,94 @@
1
+ import type { CommandHandler } from '../ui/commands'
2
+ import { KeyManager } from '../config/keys-manager'
3
+
4
+ export const keysCmd: CommandHandler = async (_ctx, args) => {
5
+ const manager = new KeyManager()
6
+ const sub = args[0]?.toLowerCase()
7
+
8
+ // /keys rotate <provider>
9
+ if (sub === 'rotate') {
10
+ const provider = args[1]
11
+ if (!provider) {
12
+ return {
13
+ content: 'Usage: /keys rotate <provider>\n\nExample: /keys rotate deepseek',
14
+ }
15
+ }
16
+ // Interactive: prompt user to provide new key via follow-up chat message
17
+ return {
18
+ content: [
19
+ '── Key Rotation ──',
20
+ '',
21
+ `Provider: ${provider}`,
22
+ '',
23
+ 'To complete rotation, the new API key must be provided.',
24
+ 'Type your new key in chat and the AI will handle rotation securely.',
25
+ '',
26
+ 'The old key will be backed up to ~/.mipham/keys/<provider>.backup (chmod 600).',
27
+ ].join('\n'),
28
+ forwardToAI: `The user wants to rotate the API key for provider "${provider}". Ask them to provide the new key value, then:
29
+ 1. Call KeyManager.rotate("${provider}", newKey) with the provided key
30
+ 2. The new key should be stored in ~/.mipham/config.json (or appropriate config)
31
+ 3. Confirm rotation count and backup location
32
+ 4. Remind them the old key is backed up at ~/.mipham/keys/${provider}.backup`,
33
+ }
34
+ }
35
+
36
+ // /keys audit
37
+ if (sub === 'audit') {
38
+ const expired = manager.audit()
39
+ if (expired.length === 0) {
40
+ return {
41
+ content:
42
+ '── Key Audit ──\n\n✅ All keys are within the 90-day rotation window.\n\nNo expired keys found.',
43
+ }
44
+ }
45
+
46
+ const lines: string[] = [
47
+ '── Key Audit ──',
48
+ '',
49
+ `⚠️ ${expired.length} key(s) have exceeded the 90-day rotation threshold:`,
50
+ '',
51
+ ...expired.map(
52
+ (k) =>
53
+ ` 🔴 ${k.provider.padEnd(16)} ${k.ageDays} days since last rotation (${k.lastRotated.slice(0, 10)})`,
54
+ ),
55
+ '',
56
+ 'Rotate expired keys with: /keys rotate <provider>',
57
+ ]
58
+ return { content: lines.join('\n') }
59
+ }
60
+
61
+ // /keys (list)
62
+ const keys = manager.list()
63
+
64
+ if (keys.length === 0) {
65
+ return {
66
+ content: [
67
+ '── API Keys ──',
68
+ '',
69
+ 'No API keys tracked yet.',
70
+ '',
71
+ 'Keys are tracked in ~/.mipham/keys.json when you configure providers.',
72
+ 'Use /keys rotate <provider> to begin tracking rotation for a provider.',
73
+ ].join('\n'),
74
+ }
75
+ }
76
+
77
+ const lines: string[] = [
78
+ '── API Keys ──',
79
+ '',
80
+ 'Provider Age Rotations Status',
81
+ '─'.repeat(55),
82
+ ...keys.map((k) => {
83
+ const status = k.expired ? '🔴 EXPIRED' : '🟢 OK'
84
+ return ` ${k.provider.padEnd(16)} ${String(k.ageDays).padStart(4)}d ${String(k.rotationCount).padStart(5)} ${status}`
85
+ }),
86
+ '',
87
+ `${keys.length} key(s) tracked.`,
88
+ '',
89
+ 'Commands:',
90
+ ' /keys rotate <provider> — rotate a key',
91
+ ' /keys audit — check for expired keys',
92
+ ]
93
+ return { content: lines.join('\n') }
94
+ }
@@ -0,0 +1,121 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ const MIPHAM_DIR = join(homedir(), '.mipham')
6
+ const KEYS_FILE = join(MIPHAM_DIR, 'keys.json')
7
+
8
+ export interface KeyEntry {
9
+ createdAt: string
10
+ lastRotated: string
11
+ rotationCount: number
12
+ provider: string
13
+ }
14
+
15
+ export interface KeyStatus {
16
+ provider: string
17
+ createdAt: string
18
+ lastRotated: string
19
+ rotationCount: number
20
+ ageDays: number
21
+ expired: boolean
22
+ }
23
+
24
+ export interface KeysData {
25
+ [provider: string]: KeyEntry
26
+ }
27
+
28
+ function loadKeys(): KeysData {
29
+ if (!existsSync(KEYS_FILE)) return {}
30
+ try {
31
+ const raw = readFileSync(KEYS_FILE, 'utf-8')
32
+ return JSON.parse(raw) as KeysData
33
+ } catch {
34
+ return {}
35
+ }
36
+ }
37
+
38
+ function saveKeys(data: KeysData): void {
39
+ mkdirSync(dirname(KEYS_FILE), { recursive: true })
40
+ const tmp = KEYS_FILE + '.tmp'
41
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
42
+ writeFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
43
+ try {
44
+ chmodSync(KEYS_FILE, 0o600)
45
+ } catch {
46
+ // chmod on Windows is a no-op
47
+ }
48
+ }
49
+
50
+ function daysSince(iso: string): number {
51
+ const then = new Date(iso).getTime()
52
+ const now = Date.now()
53
+ return Math.floor((now - then) / (1000 * 60 * 60 * 24))
54
+ }
55
+
56
+ export class KeyManager {
57
+ list(): KeyStatus[] {
58
+ const data = loadKeys()
59
+ return Object.entries(data).map(([provider, entry]) => ({
60
+ provider,
61
+ createdAt: entry.createdAt,
62
+ lastRotated: entry.lastRotated,
63
+ rotationCount: entry.rotationCount,
64
+ ageDays: daysSince(entry.lastRotated),
65
+ expired: daysSince(entry.lastRotated) > 90,
66
+ }))
67
+ }
68
+
69
+ rotate(provider: string, _newKey: string): { success: boolean; message: string } {
70
+ const data = loadKeys()
71
+ const now = new Date().toISOString()
72
+ const existing = data[provider]
73
+
74
+ // Backup old entry if it exists
75
+ if (existing) {
76
+ const backupDir = join(MIPHAM_DIR, 'keys')
77
+ mkdirSync(backupDir, { recursive: true })
78
+ const backupPath = join(backupDir, `${provider}.backup`)
79
+ writeFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
80
+ try {
81
+ chmodSync(backupPath, 0o600)
82
+ } catch {
83
+ // Windows
84
+ }
85
+
86
+ data[provider] = {
87
+ createdAt: existing.createdAt,
88
+ lastRotated: now,
89
+ rotationCount: existing.rotationCount + 1,
90
+ provider,
91
+ }
92
+ } else {
93
+ data[provider] = {
94
+ createdAt: now,
95
+ lastRotated: now,
96
+ rotationCount: 1,
97
+ provider,
98
+ }
99
+ }
100
+
101
+ saveKeys(data)
102
+ return {
103
+ success: true,
104
+ message: `Key for "${provider}" rotated (rotation #${data[provider]!.rotationCount}). Backup saved to ~/.mipham/keys/${provider}.backup`,
105
+ }
106
+ }
107
+
108
+ audit(): KeyStatus[] {
109
+ return this.list().filter((k) => k.expired)
110
+ }
111
+
112
+ getExpiryReminder(): string | null {
113
+ const expired = this.audit()
114
+ if (expired.length === 0) return null
115
+
116
+ const lines = expired.map(
117
+ (k) => ` - ${k.provider}: last rotated ${k.ageDays} days ago (90-day threshold exceeded)`,
118
+ )
119
+ return `⚠️ API key rotation overdue:\n${lines.join('\n')}\n\nRun /keys rotate <provider> to rotate.`
120
+ }
121
+ }