@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.
@@ -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,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
- 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
+
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}>