@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 +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/commands/keys.ts +94 -0
- package/src/config/keys-manager.ts +121 -0
- package/src/core/memory/memory-manager.ts +211 -3
- package/src/core/session-store.ts +133 -0
- package/src/index.tsx +45 -2
- package/src/security/gate.ts +93 -0
- package/src/ui/app.tsx +8 -4
- package/src/ui/chat.tsx +1 -3
- package/src/ui/commands.ts +92 -2
|
@@ -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,26 @@ 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
|
+
|
|
157
|
+
// Key expiry reminder (Task 7)
|
|
158
|
+
const { KeyManager } = await import('./config/keys-manager')
|
|
159
|
+
const keyManager = new KeyManager()
|
|
160
|
+
const keyReminder = keyManager.getExpiryReminder()
|
|
161
|
+
if (keyReminder) {
|
|
162
|
+
prompt = `${prompt}\n\n<system-reminder name="key-expiry">\n${keyReminder}\n</system-reminder>`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
context.setSystemPrompt(prompt)
|
|
147
166
|
}
|
|
148
167
|
|
|
149
168
|
// Create tool registry with all built-in tools
|
|
@@ -234,7 +253,9 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
234
253
|
engine.setupContextSummarizer()
|
|
235
254
|
|
|
236
255
|
// Auto-save session on exit
|
|
256
|
+
let saved = false
|
|
237
257
|
const saveAndExit = () => {
|
|
258
|
+
saved = true
|
|
238
259
|
artifactServer.stop()
|
|
239
260
|
if (context.getMessageCount() > 0) {
|
|
240
261
|
SessionStore.save(sessionName, context.getMessages(), {
|
|
@@ -249,6 +270,28 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
249
270
|
process.on('SIGINT', saveAndExit)
|
|
250
271
|
process.on('SIGTERM', saveAndExit)
|
|
251
272
|
|
|
273
|
+
// Safety net: auto-save on exit for paths that bypass saveAndExit
|
|
274
|
+
process.on('exit', () => {
|
|
275
|
+
if (!saved && context.getMessageCount() > 0) {
|
|
276
|
+
SessionStore.autoSave(context.getMessages(), {
|
|
277
|
+
provider: defaultProvider,
|
|
278
|
+
model: defaultModel,
|
|
279
|
+
cwd: process.cwd(),
|
|
280
|
+
})
|
|
281
|
+
// Distill learnings from the last 5 user messages into memory
|
|
282
|
+
const allMessages = context.getMessages()
|
|
283
|
+
const userMessages = allMessages
|
|
284
|
+
.filter((m) => m.role === 'user')
|
|
285
|
+
.slice(-5)
|
|
286
|
+
.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
|
|
287
|
+
.join('\n- ')
|
|
288
|
+
if (userMessages) {
|
|
289
|
+
const summary = `Session highlights:\n- ${userMessages}`
|
|
290
|
+
getMemoryManager().distillFromSession(summary, sessionName)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
252
295
|
const { waitUntilExit } = render(
|
|
253
296
|
<App
|
|
254
297
|
engine={engine}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface GateResult {
|
|
2
|
+
blocked: boolean
|
|
3
|
+
reason?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const PROMPT_INJECTION_PATTERNS: Array<{ regex: RegExp; label: string }> = [
|
|
7
|
+
{
|
|
8
|
+
regex: /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?)/i,
|
|
9
|
+
label: 'ignore-previous-instructions',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
regex: /^system\s*:\s*(now\s+)?(act|pretend|you\s+are)/im,
|
|
13
|
+
label: 'role-impersonation',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
regex: /(^|\n)(---\s*BEGIN|<\|\w+\|>)/,
|
|
17
|
+
label: 'delimiter-injection',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
regex: /you\s+are\s+now\s+(dan|jailbroken|unrestricted)/i,
|
|
21
|
+
label: 'dan-jailbreak',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
regex:
|
|
25
|
+
/(disregard|override|supersede)\s+(all\s+)?(previous|prior|above|system)\s+(instructions?|rules?|prompts?)/i,
|
|
26
|
+
label: 'override-instructions',
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const DANGEROUS_BASH_PATTERNS: Array<{ regex: RegExp; label: string }> = [
|
|
31
|
+
{ regex: /\$\(.+\)/, label: 'command-substitution' },
|
|
32
|
+
{ regex: /`[^`]+`/, label: 'backtick-substitution' },
|
|
33
|
+
{ regex: /;\s*(rm|cat|sh|bash)\b/, label: 'command-chain-injection' },
|
|
34
|
+
{ regex: /curl.+\|\s*(sh|bash)\b/, label: 'curl-pipe-shell' },
|
|
35
|
+
{ regex: /\|\s*(sh|bash)\b/, label: 'pipe-to-shell' },
|
|
36
|
+
{ regex: />\s*\/dev\//, label: 'redirect-to-dev' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const API_KEY_PATTERNS: Array<{ regex: RegExp; label: string }> = [
|
|
40
|
+
{ regex: /sk-ant-[a-zA-Z0-9_-]{20,}/, label: 'anthropic-key' },
|
|
41
|
+
{ regex: /sk-[a-zA-Z0-9]{32,}/, label: 'openai-key' },
|
|
42
|
+
{ regex: /eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{10,}/, label: 'jwt-token' },
|
|
43
|
+
{ regex: /x-api-key:\s*[a-zA-Z0-9_-]{20,}/i, label: 'api-key-header' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
export class SecurityGate {
|
|
47
|
+
static checkPromptInjection(input: string): GateResult {
|
|
48
|
+
if (!input || input.length < 10) return { blocked: false }
|
|
49
|
+
for (const pattern of PROMPT_INJECTION_PATTERNS) {
|
|
50
|
+
if (pattern.regex.test(input)) {
|
|
51
|
+
return { blocked: true, reason: `prompt injection detected: ${pattern.label}` }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { blocked: false }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static checkPathTraversal(path: string, _cwd: string): GateResult {
|
|
58
|
+
if (!path) return { blocked: false }
|
|
59
|
+
if (/\0/.test(path)) {
|
|
60
|
+
return { blocked: true, reason: 'null byte in path' }
|
|
61
|
+
}
|
|
62
|
+
if (/%25|%2e%2e/i.test(path)) {
|
|
63
|
+
return { blocked: true, reason: 'double-encoded path traversal' }
|
|
64
|
+
}
|
|
65
|
+
const segments = path.replace(/\\/g, '/').split('/')
|
|
66
|
+
for (const seg of segments) {
|
|
67
|
+
if (seg === '..') {
|
|
68
|
+
return { blocked: true, reason: 'path traversal attempt (..)' }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { blocked: false }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static checkBashCommand(command: string): GateResult {
|
|
75
|
+
if (!command) return { blocked: false }
|
|
76
|
+
for (const pattern of DANGEROUS_BASH_PATTERNS) {
|
|
77
|
+
if (pattern.regex.test(command)) {
|
|
78
|
+
return { blocked: true, reason: `dangerous bash pattern: ${pattern.label}` }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { blocked: false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static checkCredentialLeak(output: string): GateResult {
|
|
85
|
+
if (!output || output.length < 10) return { blocked: false }
|
|
86
|
+
for (const pattern of API_KEY_PATTERNS) {
|
|
87
|
+
if (pattern.regex.test(output)) {
|
|
88
|
+
return { blocked: true, reason: `credential leak detected: ${pattern.label}` }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { blocked: false }
|
|
92
|
+
}
|
|
93
|
+
}
|
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}>
|