@miphamai/cli 0.16.5 → 0.17.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.17.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
  }
@@ -5,12 +5,15 @@ import {
5
5
  writeFileSync,
6
6
  unlinkSync,
7
7
  existsSync,
8
+ statSync,
8
9
  } from 'node:fs'
9
10
  import { join, extname } from 'node:path'
10
11
 
11
12
  export interface MemoryMetadata {
12
13
  type: 'user' | 'feedback' | 'project' | 'reference'
13
14
  relevance: string[]
15
+ why?: string
16
+ howToApply?: string
14
17
  }
15
18
 
16
19
  export interface MemoryEntry {
@@ -26,6 +29,7 @@ const INDEX_FILE = 'MEMORY.md'
26
29
 
27
30
  export class MemoryManager {
28
31
  private memories = new Map<string, MemoryEntry>()
32
+ private linkGraph: Map<string, Set<string>> = new Map()
29
33
 
30
34
  constructor(private memoryDir: string) {
31
35
  mkdirSync(memoryDir, { recursive: true })
@@ -33,6 +37,7 @@ export class MemoryManager {
33
37
 
34
38
  loadAll(): void {
35
39
  this.memories.clear()
40
+ this.linkGraph.clear()
36
41
  if (!existsSync(this.memoryDir)) return
37
42
 
38
43
  let entries: string[] = []
@@ -55,9 +60,26 @@ export class MemoryManager {
55
60
  // skip unparseable
56
61
  }
57
62
  }
63
+ this.rebuildLinkGraph()
58
64
  }
59
65
 
60
66
  write(name: string, content: string, metadata: MemoryMetadata): void {
67
+ // Dedup: same name = update, don't create duplicate
68
+ const formattedBody = this.formatMemoryBody(metadata, content)
69
+ const existing = this.memories.get(name)
70
+ if (existing) {
71
+ existing.content = formattedBody
72
+ existing.metadata = metadata
73
+ existing.description = metadata.relevance.join(', ')
74
+ existing.updatedAt = new Date()
75
+ this.memories.set(name, existing)
76
+ const body = this.formatMemoryFile(name, metadata, content)
77
+ writeFileSync(existing.filePath, body, 'utf-8')
78
+ this.updateWikilinks(name, content)
79
+ this.updateIndex()
80
+ return
81
+ }
82
+
61
83
  const fileName = `${name}.md`
62
84
  const filePath = join(this.memoryDir, fileName)
63
85
 
@@ -68,18 +90,20 @@ export class MemoryManager {
68
90
  name,
69
91
  description: metadata.relevance.join(', '),
70
92
  metadata,
71
- content,
93
+ content: formattedBody,
72
94
  filePath,
73
95
  updatedAt: new Date(),
74
96
  }
75
97
 
76
98
  this.memories.set(name, entry)
99
+ this.updateWikilinks(name, content)
77
100
  this.updateIndex()
78
101
  }
79
102
 
80
103
  recall(context: string, limit: number = 10): MemoryEntry[] {
81
104
  const contextLower = context.toLowerCase()
82
105
  const scored: Array<{ entry: MemoryEntry; score: number }> = []
106
+ const seen = new Set<string>()
83
107
 
84
108
  for (const entry of this.memories.values()) {
85
109
  let score = 0
@@ -95,6 +119,31 @@ export class MemoryManager {
95
119
  }
96
120
  if (score > 0) {
97
121
  scored.push({ entry, score })
122
+ seen.add(entry.name)
123
+ }
124
+ }
125
+
126
+ // Boost wikilink-connected memories with lower weight
127
+ for (const scoredItem of [...scored]) {
128
+ const linkedNames = this.linkGraph.get(scoredItem.entry.name)
129
+ if (!linkedNames) continue
130
+ for (const linkedName of linkedNames) {
131
+ if (seen.has(linkedName)) continue
132
+ const linkedEntry = this.memories.get(linkedName)
133
+ if (linkedEntry) {
134
+ scored.push({ entry: linkedEntry, score: 1 })
135
+ seen.add(linkedName)
136
+ }
137
+ }
138
+ }
139
+
140
+ // Apply time decay: memories older than 30 days get 50% weight
141
+ const now = Date.now()
142
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
143
+ for (const item of scored) {
144
+ const age = now - item.entry.updatedAt.getTime()
145
+ if (age > THIRTY_DAYS_MS) {
146
+ item.score *= 0.5
98
147
  }
99
148
  }
100
149
 
@@ -102,6 +151,17 @@ export class MemoryManager {
102
151
  return scored.slice(0, limit).map((s) => s.entry)
103
152
  }
104
153
 
154
+ getLinkedMemories(name: string): MemoryEntry[] {
155
+ const targets = this.linkGraph.get(name)
156
+ if (!targets || targets.size === 0) return []
157
+ const results: MemoryEntry[] = []
158
+ for (const target of targets) {
159
+ const entry = this.memories.get(target)
160
+ if (entry) results.push(entry)
161
+ }
162
+ return results
163
+ }
164
+
105
165
  delete(name: string): void {
106
166
  const entry = this.memories.get(name)
107
167
  if (!entry) return
@@ -134,6 +194,84 @@ export class MemoryManager {
134
194
  return lines.join('\n')
135
195
  }
136
196
 
197
+ distillFromSession(summary: string, sessionId: string): MemoryEntry[] {
198
+ const results: MemoryEntry[] = []
199
+ // Split on bullet points (both - and *)
200
+ const bullets = summary.split(/\n\s*[-*]\s+/).filter((b) => b.trim().length > 20)
201
+
202
+ for (const bullet of bullets) {
203
+ // Extract Why / How to apply if present
204
+ const whyMatch = bullet.match(/\*\*Why:\*\*\s*(.+?)(?:\s*\*\*How to apply:|$)/)
205
+ const howMatch = bullet.match(/\*\*How to apply:\*\*\s*(.+)$/)
206
+
207
+ // Strip Why/How markers from content — they're passed as metadata fields
208
+ // and formatMemoryBody appends them, so leaving them in duplicates the text
209
+ const stripped = bullet
210
+ .trim()
211
+ .replace(/\s*\*\*Why:\*\*.*/, '')
212
+ .replace(/\s*\*\*How to apply:\*\*.*/, '')
213
+ .trim()
214
+ const slug = `auto-${sessionId}-${results.length}`
215
+
216
+ this.write(slug, stripped, {
217
+ type: 'feedback',
218
+ relevance: this.extractKeywords(stripped),
219
+ why: whyMatch?.[1]?.trim(),
220
+ howToApply: howMatch?.[1]?.trim(),
221
+ })
222
+
223
+ const entry = this.memories.get(slug)
224
+ if (entry) results.push(entry)
225
+ }
226
+ return results
227
+ }
228
+
229
+ private extractKeywords(content: string): string[] {
230
+ const words = content
231
+ .toLowerCase()
232
+ .replace(/[^a-z0-9\s]/g, '')
233
+ .split(/\s+/)
234
+ const stopWords = new Set([
235
+ 'the',
236
+ 'a',
237
+ 'an',
238
+ 'is',
239
+ 'are',
240
+ 'was',
241
+ 'were',
242
+ 'in',
243
+ 'on',
244
+ 'at',
245
+ 'to',
246
+ 'for',
247
+ 'of',
248
+ 'and',
249
+ 'or',
250
+ 'but',
251
+ 'not',
252
+ 'this',
253
+ 'that',
254
+ 'with',
255
+ 'from',
256
+ 'by',
257
+ 'as',
258
+ 'be',
259
+ 'has',
260
+ 'have',
261
+ 'it',
262
+ 'its',
263
+ ])
264
+ const seen = new Set<string>()
265
+ const result: string[] = []
266
+ for (const word of words) {
267
+ if (word.length > 3 && !stopWords.has(word) && !seen.has(word)) {
268
+ seen.add(word)
269
+ result.push(word)
270
+ }
271
+ }
272
+ return result.slice(0, 10)
273
+ }
274
+
137
275
  private updateIndex(): void {
138
276
  const lines: string[] = [
139
277
  '# Memory Index',
@@ -149,6 +287,62 @@ export class MemoryManager {
149
287
  writeFileSync(join(this.memoryDir, INDEX_FILE), lines.join('\n') + '\n', 'utf-8')
150
288
  }
151
289
 
290
+ private extractWikilinks(content: string): string[] {
291
+ const re = /\[\[([^\]]+)\]\]/g
292
+ const links: string[] = []
293
+ let match: RegExpExecArray | null
294
+ while ((match = re.exec(content)) !== null) {
295
+ if (links.length >= 10) break // hard cap
296
+ links.push(match[1]!)
297
+ }
298
+ return links
299
+ }
300
+
301
+ private updateWikilinks(name: string, content: string): void {
302
+ // Clear stale outbound links for this memory
303
+ this.linkGraph.delete(name)
304
+
305
+ // Extract outbound wikilinks from content
306
+ const links = this.extractWikilinks(content)
307
+ if (links.length > 0) {
308
+ this.linkGraph.set(name, new Set(links))
309
+ }
310
+
311
+ // Update reverse links: other memories that link to this one
312
+ for (const [otherName, otherEntry] of this.memories) {
313
+ if (otherName === name) continue
314
+ const otherLinks = this.extractWikilinks(otherEntry.content)
315
+ if (otherLinks.includes(name)) {
316
+ const otherSet = this.linkGraph.get(otherName) || new Set()
317
+ otherSet.add(name)
318
+ this.linkGraph.set(otherName, otherSet)
319
+ }
320
+ }
321
+ }
322
+
323
+ private rebuildLinkGraph(): void {
324
+ this.linkGraph.clear()
325
+ for (const [name, entry] of this.memories) {
326
+ const links = this.extractWikilinks(entry.content)
327
+ if (links.length > 0) {
328
+ this.linkGraph.set(name, new Set(links))
329
+ }
330
+ }
331
+ // Compute reverse links: other memories that link to each memory
332
+ for (const [name, entry] of this.memories) {
333
+ const linked = this.extractWikilinks(entry.content)
334
+ for (const target of linked) {
335
+ if (target === name) continue
336
+ const targetSet = this.linkGraph.get(target)
337
+ if (targetSet) {
338
+ targetSet.add(name)
339
+ } else {
340
+ this.linkGraph.set(target, new Set([name]))
341
+ }
342
+ }
343
+ }
344
+ }
345
+
152
346
  private parseMemoryFile(raw: string, filePath: string): MemoryEntry | null {
153
347
  const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
154
348
  if (!match) return null
@@ -173,11 +367,12 @@ export class MemoryManager {
173
367
  },
174
368
  content,
175
369
  filePath,
176
- updatedAt: new Date(),
370
+ updatedAt: statSync(filePath).mtime,
177
371
  }
178
372
  }
179
373
 
180
374
  private formatMemoryFile(name: string, metadata: MemoryMetadata, content: string): string {
375
+ const body = this.formatMemoryBody(metadata, content)
181
376
  return `---
182
377
  name: ${name}
183
378
  description: ${metadata.relevance.join(', ')}
@@ -186,7 +381,20 @@ metadata:
186
381
  relevance: [${metadata.relevance.join(', ')}]
187
382
  ---
188
383
 
189
- ${content}
384
+ ${body}
190
385
  `
191
386
  }
387
+
388
+ private formatMemoryBody(metadata: MemoryMetadata, content: string): string {
389
+ let body = content
390
+
391
+ if (metadata.why) {
392
+ body += `\n\n**Why:** ${metadata.why}`
393
+ }
394
+ if (metadata.howToApply) {
395
+ body += `\n\n**How to apply:** ${metadata.howToApply}`
396
+ }
397
+
398
+ return body
399
+ }
192
400
  }
@@ -27,6 +27,21 @@ export interface StoredSession {
27
27
 
28
28
  const HOME = process.env.HOME || '~'
29
29
  const SESSIONS_DIR = join(HOME, '.mipham', 'sessions')
30
+ const INDEX_FILE = join(SESSIONS_DIR, '.index.json')
31
+ const SUMMARIES_DIR = join(SESSIONS_DIR, '.summaries')
32
+
33
+ export interface SessionIndexEntry {
34
+ name: string
35
+ createdAt: string
36
+ updatedAt: string
37
+ provider: string
38
+ model: string
39
+ messageCount: number
40
+ tokenCount: number
41
+ cwd?: string
42
+ summary?: string
43
+ tags?: string[]
44
+ }
30
45
 
31
46
  function ensureDir(): void {
32
47
  mkdirSync(SESSIONS_DIR, { recursive: true })
@@ -67,6 +82,9 @@ export class SessionStore {
67
82
  const tmp = path + '.tmp'
68
83
  writeFileSync(tmp, JSON.stringify(session) + '\n', 'utf-8')
69
84
  renameSync(tmp, path)
85
+
86
+ // Incremental index update — only touch this session's entry
87
+ SessionStore.updateIndexEntry(name, session.metadata)
70
88
  }
71
89
 
72
90
  /**
@@ -142,4 +160,119 @@ export class SessionStore {
142
160
  SessionStore.save(name, messages, metadata)
143
161
  return name
144
162
  }
163
+
164
+ /**
165
+ * Write .index.json with all session metadata, merging existing summary/tags.
166
+ */
167
+ static updateIndex(): void {
168
+ ensureDir()
169
+ const sessions = SessionStore.list()
170
+ const index: SessionIndexEntry[] = sessions.map((s) => ({
171
+ name: s.name,
172
+ createdAt: s.createdAt,
173
+ updatedAt: s.updatedAt,
174
+ provider: s.provider,
175
+ model: s.model,
176
+ messageCount: s.messageCount,
177
+ tokenCount: 0,
178
+ cwd: s.cwd,
179
+ }))
180
+
181
+ // Merge with existing summaries and tags from prior index
182
+ const existing = SessionStore.loadIndexRaw()
183
+ for (const entry of index) {
184
+ const prev = existing.find((e) => e.name === entry.name)
185
+ if (prev) {
186
+ entry.summary = prev.summary
187
+ entry.tags = prev.tags
188
+ entry.tokenCount = prev.tokenCount || 0
189
+ }
190
+ }
191
+
192
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
193
+ }
194
+
195
+ /**
196
+ * Incrementally update a single session's entry in the index.
197
+ * Only modifies one entry — faster than full rebuild. Used by save().
198
+ */
199
+ static updateIndexEntry(name: string, metadata: SessionMetadata): void {
200
+ ensureDir()
201
+ const index = SessionStore.loadIndexRaw()
202
+ const existing = index.find((e) => e.name === name)
203
+ const entry: SessionIndexEntry = {
204
+ name,
205
+ createdAt: metadata.createdAt,
206
+ updatedAt: metadata.updatedAt,
207
+ provider: metadata.provider,
208
+ model: metadata.model,
209
+ messageCount: metadata.messageCount,
210
+ tokenCount: existing?.tokenCount || 0,
211
+ cwd: metadata.cwd,
212
+ summary: existing?.summary,
213
+ tags: existing?.tags,
214
+ }
215
+ if (existing) {
216
+ Object.assign(existing, entry)
217
+ } else {
218
+ index.push(entry)
219
+ }
220
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
221
+ }
222
+
223
+ /**
224
+ * Persist an LLM-generated summary for a session.
225
+ * Writes a markdown file to .summaries/ and updates the index entry.
226
+ */
227
+ static saveSummary(name: string, summary: string, tags: string[]): void {
228
+ ensureDir()
229
+ mkdirSync(SUMMARIES_DIR, { recursive: true })
230
+
231
+ const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100)
232
+ const summaryPath = join(SUMMARIES_DIR, `${safe}.md`)
233
+ writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, 'utf-8')
234
+
235
+ // Update index entry — create minimal one if not present
236
+ const index = SessionStore.loadIndexRaw()
237
+ const entry = index.find((e) => e.name === name)
238
+ if (entry) {
239
+ entry.summary = summary
240
+ entry.tags = tags
241
+ } else {
242
+ index.push({
243
+ name,
244
+ createdAt: new Date().toISOString(),
245
+ updatedAt: new Date().toISOString(),
246
+ provider: 'unknown',
247
+ model: 'unknown',
248
+ messageCount: 0,
249
+ tokenCount: 0,
250
+ summary,
251
+ tags,
252
+ })
253
+ }
254
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
255
+ }
256
+
257
+ /**
258
+ * Return the most recent session from the index, or null if none exist.
259
+ */
260
+ static getLatest(): SessionIndexEntry | null {
261
+ const index = SessionStore.loadIndexRaw()
262
+ if (index.length === 0) return null
263
+ index.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
264
+ return index[0]!
265
+ }
266
+
267
+ /**
268
+ * Read the raw index file, returning empty array if missing or corrupt.
269
+ */
270
+ private static loadIndexRaw(): SessionIndexEntry[] {
271
+ if (!existsSync(INDEX_FILE)) return []
272
+ try {
273
+ return JSON.parse(readFileSync(INDEX_FILE, 'utf-8'))
274
+ } catch {
275
+ return []
276
+ }
277
+ }
145
278
  }
package/src/index.tsx CHANGED
@@ -5,7 +5,7 @@ import { App } from './ui/app'
5
5
  import { loadConfig, loadInferenceHookConfig, loadCredentialMaskingConfig } from './config/loader'
6
6
  import { bootstrapProviders } from './providers/bootstrap'
7
7
  import { InstructionsLoader } from './core/instructions'
8
- import { loadSessionMemories } from './core/memory/memory-loader'
8
+ import { loadSessionMemories, getMemoryManager } from './core/memory/memory-loader'
9
9
  import { ContextManager } from './core/context'
10
10
  import { QueryEngine } from './core/engine'
11
11
  import { SessionStore } from './core/session-store'
@@ -143,7 +143,17 @@ export async function runApp(options: RunOptions): Promise<void> {
143
143
  if (context.getMessageCount() === 0) {
144
144
  const basePrompt = instructions.buildSystemPrompt()
145
145
  const memoryReminder = loadSessionMemories(basePrompt)
146
- context.setSystemPrompt(memoryReminder ? `${basePrompt}\n\n${memoryReminder}` : basePrompt)
146
+
147
+ // Inject previous session summary for AI continuity
148
+ let prompt = basePrompt
149
+ if (memoryReminder) {
150
+ prompt = `${prompt}\n\n${memoryReminder}`
151
+ }
152
+ const latestSession = SessionStore.getLatest()
153
+ if (latestSession?.summary) {
154
+ prompt = `${prompt}\n\n<system-context name="previous-session">\n# Previous Session Summary\n${latestSession.summary}\n</system-context>`
155
+ }
156
+ context.setSystemPrompt(prompt)
147
157
  }
148
158
 
149
159
  // Create tool registry with all built-in tools
@@ -234,7 +244,9 @@ export async function runApp(options: RunOptions): Promise<void> {
234
244
  engine.setupContextSummarizer()
235
245
 
236
246
  // Auto-save session on exit
247
+ let saved = false
237
248
  const saveAndExit = () => {
249
+ saved = true
238
250
  artifactServer.stop()
239
251
  if (context.getMessageCount() > 0) {
240
252
  SessionStore.save(sessionName, context.getMessages(), {
@@ -249,6 +261,28 @@ export async function runApp(options: RunOptions): Promise<void> {
249
261
  process.on('SIGINT', saveAndExit)
250
262
  process.on('SIGTERM', saveAndExit)
251
263
 
264
+ // Safety net: auto-save on exit for paths that bypass saveAndExit
265
+ process.on('exit', () => {
266
+ if (!saved && context.getMessageCount() > 0) {
267
+ SessionStore.autoSave(context.getMessages(), {
268
+ provider: defaultProvider,
269
+ model: defaultModel,
270
+ cwd: process.cwd(),
271
+ })
272
+ // Distill learnings from the last 5 user messages into memory
273
+ const allMessages = context.getMessages()
274
+ const userMessages = allMessages
275
+ .filter((m) => m.role === 'user')
276
+ .slice(-5)
277
+ .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
278
+ .join('\n- ')
279
+ if (userMessages) {
280
+ const summary = `Session highlights:\n- ${userMessages}`
281
+ getMemoryManager().distillFromSession(summary, sessionName)
282
+ }
283
+ }
284
+ })
285
+
252
286
  const { waitUntilExit } = render(
253
287
  <App
254
288
  engine={engine}
package/src/ui/app.tsx CHANGED
@@ -313,6 +313,13 @@ export function App({
313
313
  if (result.nextProvider) setProviderId(result.nextProvider)
314
314
  if (result.nextModel) setModelId(result.nextModel)
315
315
  if (result.exit) process.exit(0)
316
+ if (result.forwardedMessages && result.forwardedMessages.length > 0) {
317
+ const restored: ChatMessage[] = result.forwardedMessages.map((msg) => ({
318
+ role: msg.role,
319
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
320
+ }))
321
+ setMessages((prev) => [...prev, ...restored])
322
+ }
316
323
  if (result.copyContent) {
317
324
  // Copy to clipboard via pbcopy (macOS) or clip (Windows)
318
325
  try {
@@ -364,10 +371,7 @@ export function App({
364
371
  if (isNewTurn) {
365
372
  turnContent = chunk.content
366
373
  isNewTurn = false
367
- setMessages((prev) => [
368
- ...prev,
369
- { role: 'assistant', content: turnContent },
370
- ])
374
+ setMessages((prev) => [...prev, { role: 'assistant', content: turnContent }])
371
375
  } else {
372
376
  turnContent += chunk.content
373
377
  setMessages((prev) => {
package/src/ui/chat.tsx CHANGED
@@ -95,9 +95,7 @@ const MessageRow = React.memo(
95
95
  export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
96
96
  // Group consecutive tool calls into compact summaries to reduce visual noise.
97
97
  // In focus mode, use the more aggressive compactForFocus.
98
- const displayMessages = focusMode
99
- ? compactForFocus(messages)
100
- : compactToolGroups(messages)
98
+ const displayMessages = focusMode ? compactForFocus(messages) : compactToolGroups(messages)
101
99
 
102
100
  return (
103
101
  <Box flexDirection="column" marginY={1} flexGrow={1}>
@@ -8,6 +8,7 @@ import type { QueryEngine } from '../core/engine'
8
8
  import type { MiphamConfig } from '../shared/index.ts'
9
9
  import type { SkillsLoader } from '../skills/loader'
10
10
  import type { PluginManager } from '../plugin/plugin-manager'
11
+ import type { Message } from '../shared/types.js'
11
12
  import { McpClient } from '../mcp/client'
12
13
  import { NPM_INSTALL_COMMAND, NPM_UPDATE_COMMAND, PACKAGE_VERSION } from '../shared/index.ts'
13
14
  import { getPreference } from '../config/preferences'
@@ -58,6 +59,8 @@ export interface CommandResult {
58
59
  /** If set, route this message to the AI engine — bridges slash commands → skill/agent invocation.
59
60
  * The app layer replaces the user input with this string and falls through to AI processing. */
60
61
  forwardToAI?: string
62
+ /** If set, restored messages to load into the session (used by /resume last). */
63
+ forwardedMessages?: Message[]
61
64
  }
62
65
 
63
66
  export type CommandHandler = (
@@ -98,6 +101,8 @@ const helpCmd: CommandHandler = (ctx) => {
98
101
  /export Export conversation to file
99
102
  /doctor System diagnostics
100
103
  /resume List saved sessions
104
+ /resume last Restore most recent session
105
+ /resume delete <name> Delete a saved session
101
106
  /branch <name> Fork conversation
102
107
 
103
108
  ── History ─────────────────────────
@@ -1967,7 +1972,70 @@ const prCommentsCmd: CommandHandler = async () => {
1967
1972
  // Resume
1968
1973
  // ═══════════════════════════════════════════════════════════════
1969
1974
 
1975
+ const resumeLastCmd: CommandHandler = async () => {
1976
+ const { SessionStore } = await import('../core/session-store')
1977
+
1978
+ const latest = SessionStore.getLatest()
1979
+ if (!latest) {
1980
+ return {
1981
+ content:
1982
+ '─ Resume Session ─\n\nNo saved sessions found.\n\nSessions are auto-saved to ~/.mipham/sessions/ when Mipham Code exits.',
1983
+ }
1984
+ }
1985
+
1986
+ const session = SessionStore.load(latest.name)
1987
+ if (!session) {
1988
+ return {
1989
+ content: `─ Load Failed ─\n\nCould not load session "${latest.name}". The file may have been removed.`,
1990
+ }
1991
+ }
1992
+
1993
+ const date = new Date(latest.updatedAt).toLocaleString()
1994
+ return {
1995
+ content: [
1996
+ '─ Session Restored ─',
1997
+ '',
1998
+ `Name: ${latest.name}`,
1999
+ `Messages: ${session.messages.length}`,
2000
+ `Provider: ${latest.provider} / ${latest.model}`,
2001
+ `Updated: ${date}`,
2002
+ '',
2003
+ `${session.messages.length} messages loaded. Context has been restored.`,
2004
+ ].join('\n'),
2005
+ forwardedMessages: session.messages,
2006
+ }
2007
+ }
2008
+
2009
+ const resumeDeleteCmd: CommandHandler = async (_ctx, args) => {
2010
+ const name = args.join(' ').trim()
2011
+ if (!name) {
2012
+ return {
2013
+ content:
2014
+ 'Usage: /resume delete <session-name>\n\nDelete a saved session. Use /resume to list all sessions.',
2015
+ }
2016
+ }
2017
+
2018
+ const { SessionStore } = await import('../core/session-store')
2019
+ const deleted = SessionStore.delete(name)
2020
+
2021
+ return deleted
2022
+ ? { content: `✓ Session "${name}" deleted.` }
2023
+ : { content: `✗ Session "${name}" not found. Use /resume to list all sessions.` }
2024
+ }
2025
+
1970
2026
  const resumeCmd: CommandHandler = async (_ctx, args) => {
2027
+ const sub = args[0]?.toLowerCase()
2028
+
2029
+ // Sub-command: /resume last
2030
+ if (sub === 'last') {
2031
+ return resumeLastCmd(_ctx, args.slice(1))
2032
+ }
2033
+
2034
+ // Sub-command: /resume delete <name>
2035
+ if (sub === 'delete') {
2036
+ return resumeDeleteCmd(_ctx, args.slice(1))
2037
+ }
2038
+
1971
2039
  const { SessionStore } = await import('../core/session-store')
1972
2040
 
1973
2041
  // If a name is provided, show load instructions
@@ -1985,6 +2053,7 @@ const resumeCmd: CommandHandler = async (_ctx, args) => {
1985
2053
  `Updated: ${session.metadata.updatedAt}`,
1986
2054
  '',
1987
2055
  'To resume this session:',
2056
+ ` /resume last — restore the most recent session`,
1988
2057
  ` mipham --resume "${targetName}"`,
1989
2058
  '',
1990
2059
  'Or restart Mipham Code — the most recent session loads automatically.',
@@ -2017,8 +2086,10 @@ const resumeCmd: CommandHandler = async (_ctx, args) => {
2017
2086
  '',
2018
2087
  `Total: ${sessions.length} session(s) • Location: ~/.mipham/sessions/`,
2019
2088
  '',
2020
- 'To resume a session: /resume <name>',
2021
- 'To resume from CLI: mipham --resume "<name>"',
2089
+ 'To resume a session: /resume <name>',
2090
+ 'To resume most recent: /resume last',
2091
+ 'To delete a session: /resume delete <name>',
2092
+ 'To resume from CLI: mipham --resume "<name>"',
2022
2093
  '',
2023
2094
  'Sessions are auto-saved on exit. The most recent session loads automatically on restart.',
2024
2095
  ]
@@ -2993,6 +3064,8 @@ const commandsListCmd: CommandHandler = () => {
2993
3064
  '/export': 'Session & Identity',
2994
3065
  '/doctor': 'Session & Identity',
2995
3066
  '/resume': 'Session & Identity',
3067
+ '/resume last': 'Session & Identity',
3068
+ '/resume delete': 'Session & Identity',
2996
3069
  '/branch': 'Session & Identity',
2997
3070
  '/rewind': 'History',
2998
3071
  '/undo': 'History',
@@ -3175,6 +3248,8 @@ registry.set('/pr-comments', prCommentsCmd)
3175
3248
  registry.set('/doctor', doctorCmd)
3176
3249
  registry.set('/export', exportCmd)
3177
3250
  registry.set('/resume', resumeCmd)
3251
+ registry.set('/resume last', resumeLastCmd)
3252
+ registry.set('/resume delete', resumeDeleteCmd)
3178
3253
  registry.set('/memory', memoryCmd)
3179
3254
  registry.set('/upgrade', upgradeCmd)
3180
3255
 
@@ -3266,6 +3341,8 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
3266
3341
  '/export': 'Export conversation to file',
3267
3342
  '/doctor': 'System diagnostics',
3268
3343
  '/resume': 'List saved sessions',
3344
+ '/resume last': 'Restore the most recent saved session',
3345
+ '/resume delete': 'Delete a saved session',
3269
3346
  '/branch': 'Fork conversation',
3270
3347
  '/rewind': 'Undo last AI turn',
3271
3348
  '/undo': 'Same as /rewind',