@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 +1 -1
- package/src/agent/agent-context.ts +86 -17
- package/src/agent/agent-experience.ts +105 -0
- package/src/agent/sub-agent.ts +56 -0
- package/src/core/memory/memory-manager.ts +211 -3
- package/src/core/session-store.ts +133 -0
- package/src/index.tsx +36 -2
- package/src/ui/app.tsx +8 -4
- package/src/ui/chat.tsx +1 -3
- package/src/ui/commands.ts +79 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
59
|
+
// Load auto-accumulated experience (always from user scope)
|
|
60
|
+
const exp = new AgentExperience(agentName)
|
|
61
|
+
const experienceContent = exp.getExperience()
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -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:
|
|
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
|
-
${
|
|
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
|
-
|
|
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}>
|
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 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:
|
|
2021
|
-
'To resume
|
|
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',
|