claude-brain 0.17.13 → 0.22.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.
Files changed (46) hide show
  1. package/VERSION +1 -1
  2. package/package.json +3 -1
  3. package/scripts/postinstall.mjs +80 -104
  4. package/src/cli/auto-setup.ts +1 -9
  5. package/src/cli/bin.ts +23 -2
  6. package/src/cli/commands/export.ts +130 -0
  7. package/src/cli/commands/reindex.ts +107 -0
  8. package/src/cli/commands/serve.ts +54 -0
  9. package/src/cli/commands/status.ts +158 -0
  10. package/src/code-intelligence/indexer.ts +315 -0
  11. package/src/code-intelligence/linker.ts +178 -0
  12. package/src/code-intelligence/parser.ts +484 -0
  13. package/src/code-intelligence/query.ts +291 -0
  14. package/src/code-intelligence/schema.ts +83 -0
  15. package/src/code-intelligence/types.ts +95 -0
  16. package/src/config/defaults.ts +3 -3
  17. package/src/config/loader.ts +6 -0
  18. package/src/config/schema.ts +28 -2
  19. package/src/health/index.ts +5 -2
  20. package/src/hooks/brain-hook.ts +4 -1
  21. package/src/hooks/context-hook.ts +69 -10
  22. package/src/hooks/installer.ts +4 -7
  23. package/src/intelligence/cross-project/index.ts +1 -7
  24. package/src/intelligence/prediction/index.ts +1 -7
  25. package/src/intelligence/reasoning/index.ts +1 -7
  26. package/src/memory/compression.ts +105 -0
  27. package/src/memory/fts5-search.ts +456 -0
  28. package/src/memory/index.ts +342 -38
  29. package/src/memory/migrations/add-fts5.ts +98 -0
  30. package/src/memory/pruning.ts +60 -0
  31. package/src/routing/intent-classifier.ts +58 -1
  32. package/src/routing/response-filter.ts +128 -0
  33. package/src/routing/router.ts +457 -54
  34. package/src/server/http-api.ts +319 -1
  35. package/src/server/providers/resources.ts +1 -42
  36. package/src/server/services.ts +113 -12
  37. package/src/server/web-viewer.ts +1115 -0
  38. package/src/setup/index.ts +12 -22
  39. package/src/tools/schemas.ts +1 -1
  40. package/src/intelligence/cross-project/affinity.ts +0 -159
  41. package/src/intelligence/cross-project/transfer.ts +0 -201
  42. package/src/intelligence/prediction/context-anticipator.ts +0 -198
  43. package/src/intelligence/prediction/decision-predictor.ts +0 -184
  44. package/src/intelligence/reasoning/counterfactual.ts +0 -248
  45. package/src/intelligence/reasoning/synthesizer.ts +0 -167
  46. package/src/setup/wizard.ts +0 -459
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Activity Log Pruning — Phase 30
3
+ * Removes old activity_log entries to prevent database bloat.
4
+ * Runs on server startup and can be scheduled for periodic cleanup.
5
+ */
6
+
7
+ import type { Database } from 'bun:sqlite'
8
+ import type { Logger } from 'pino'
9
+
10
+ /**
11
+ * Prune activity log entries older than maxAgeDays.
12
+ * Returns the number of rows deleted.
13
+ */
14
+ export function pruneActivityLog(db: Database, maxAgeDays: number = 30): number {
15
+ const cutoff = new Date(Date.now() - maxAgeDays * 86400000).toISOString()
16
+
17
+ // Count before delete since bun:sqlite db.run doesn't return changes easily
18
+ const countBefore = (db.prepare('SELECT COUNT(*) as cnt FROM activity_log WHERE created_at < ?').get(cutoff) as any)?.cnt ?? 0
19
+ if (countBefore === 0) return 0
20
+
21
+ db.prepare('DELETE FROM activity_log WHERE created_at < ?').run(cutoff)
22
+ return countBefore
23
+ }
24
+
25
+ /**
26
+ * Start periodic pruning on a timer.
27
+ * Returns a cleanup function to stop the timer.
28
+ */
29
+ export function startPeriodicPruning(
30
+ db: Database,
31
+ logger: Logger,
32
+ maxAgeDays: number = 30,
33
+ intervalMs: number = 24 * 60 * 60 * 1000 // 24 hours
34
+ ): () => void {
35
+ const pruneLogger = logger.child({ component: 'pruning' })
36
+
37
+ // Run immediately on startup
38
+ try {
39
+ const deleted = pruneActivityLog(db, maxAgeDays)
40
+ if (deleted > 0) {
41
+ pruneLogger.info({ deleted, maxAgeDays }, 'Activity log pruned on startup')
42
+ }
43
+ } catch (error) {
44
+ pruneLogger.warn({ error }, 'Failed to prune activity log on startup')
45
+ }
46
+
47
+ // Schedule periodic cleanup
48
+ const timer = setInterval(() => {
49
+ try {
50
+ const deleted = pruneActivityLog(db, maxAgeDays)
51
+ if (deleted > 0) {
52
+ pruneLogger.info({ deleted, maxAgeDays }, 'Periodic activity log prune complete')
53
+ }
54
+ } catch (error) {
55
+ pruneLogger.warn({ error }, 'Periodic activity log prune failed')
56
+ }
57
+ }, intervalMs)
58
+
59
+ return () => clearInterval(timer)
60
+ }
@@ -25,6 +25,8 @@ export type Intent =
25
25
  | 'list_all'
26
26
  | 'update_memory'
27
27
  | 'delete_memory'
28
+ | 'detail_request'
29
+ | 'timeline'
28
30
  | 'no_action'
29
31
 
30
32
  export interface ClassificationResult {
@@ -201,6 +203,28 @@ const TEMPORAL_PHRASES = [
201
203
  'earlier this', 'earlier today', 'over the past'
202
204
  ]
203
205
 
206
+ // Phase 27: Detail request patterns — "details obs_abc123", "show me obs_abc123"
207
+ const DETAIL_PATTERNS = [
208
+ /^details?\s+(\S+)/i, // "details obs_abc123"
209
+ /^show\s+(?:me\s+)?(\S+)/i, // "show me obs_abc123" (only when followed by an ID-like string)
210
+ /^get\s+(?:details?\s+)?(\S+)/i, // "get details obs_abc123"
211
+ /^expand\s+(\S+)/i, // "expand obs_abc123"
212
+ /^more\s+(?:about\s+)?(\S+)/i, // "more about obs_abc123"
213
+ ]
214
+
215
+ // Phase 27: Timeline patterns
216
+ const TIMELINE_PATTERNS = [
217
+ /timeline\s+(?:for\s+)?(.+)/i, // "timeline for expense-tracker"
218
+ /what\s+did\s+I\s+do\s+(.+)/i, // "what did I do yesterday"
219
+ /show\s+(?:me\s+)?history/i, // "show me history"
220
+ /recent\s+(?:activity|changes)/i, // "recent activity"
221
+ ]
222
+
223
+ /** Check if a string looks like an observation ID (contains underscores or 8+ alphanumeric chars) */
224
+ function isIdLike(s: string): boolean {
225
+ return /[_-]/.test(s) || /^[a-f0-9]{8,}$/i.test(s)
226
+ }
227
+
204
228
  export class IntentClassifier {
205
229
  /**
206
230
  * Classify the intent of a message
@@ -275,7 +299,18 @@ export class IntentClassifier {
275
299
  return { primary: 'session_start', confidence: 0.90, secondary }
276
300
  }
277
301
 
278
- // 12. exploration: timeline, trends, graph, history
302
+ // 12. detail_request: "details obs_abc123", "show me <id>" (before exploration to avoid misclassification)
303
+ if (this.isDetailRequest(lower)) {
304
+ return { primary: 'detail_request', confidence: 0.90, secondary }
305
+ }
306
+
307
+ // 12b. timeline: "timeline for project", "what did I do yesterday", "recent activity" (before exploration)
308
+ if (this.isTimeline(lower)) {
309
+ if (hasTemporal) secondary.push('exploration')
310
+ return { primary: 'timeline', confidence: 0.85, secondary }
311
+ }
312
+
313
+ // 12c. exploration: trends, graph, evolution, history (general exploration that isn't a specific timeline)
279
314
  if (this.isExploration(lower)) {
280
315
  if (this.isQuestion(lower, message)) secondary.push('question')
281
316
  if (hasTemporal) secondary.push('exploration')
@@ -430,6 +465,28 @@ export class IntentClassifier {
430
465
  return SESSION_START_PHRASES.some(p => lower.includes(p))
431
466
  }
432
467
 
468
+ /**
469
+ * Phase 27: Detect detail requests — "details <id>", "show me <id>", "expand <id>"
470
+ * Only matches when the argument looks like an ID (contains underscores, hyphens, or 8+ hex chars).
471
+ */
472
+ private isDetailRequest(lower: string): boolean {
473
+ for (const pattern of DETAIL_PATTERNS) {
474
+ const match = lower.match(pattern)
475
+ if (match && match[1] && isIdLike(match[1])) {
476
+ return true
477
+ }
478
+ }
479
+ return false
480
+ }
481
+
482
+ /**
483
+ * Phase 27: Detect timeline requests — "timeline for X", "recent activity", "what did I do yesterday"
484
+ * Distinct from exploration because it specifically asks for chronological activity view.
485
+ */
486
+ private isTimeline(lower: string): boolean {
487
+ return TIMELINE_PATTERNS.some(p => p.test(lower))
488
+ }
489
+
433
490
  /**
434
491
  * Phase 19 B3: Detect temporal signals in the message.
435
492
  * Used to add 'temporal' context to secondary intents.
@@ -259,3 +259,131 @@ export class ResponseFilter {
259
259
  return `Low relevance match (${score}%)`
260
260
  }
261
261
  }
262
+
263
+ // =============================================================================
264
+ // Phase 27: Progressive Disclosure — Compact Response Formatters
265
+ // =============================================================================
266
+
267
+ /**
268
+ * Truncate text to maxLen chars, appending "..." if truncated
269
+ */
270
+ function truncate(text: string, maxLen: number): string {
271
+ if (!text) return ''
272
+ if (text.length <= maxLen) return text
273
+ return text.substring(0, maxLen).trimEnd() + '...'
274
+ }
275
+
276
+ /**
277
+ * Format a date as a human-readable relative time string
278
+ */
279
+ function formatRelativeTime(dateStr: string | Date | undefined): string {
280
+ if (!dateStr) return 'unknown'
281
+ const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
282
+ if (isNaN(date.getTime())) return 'unknown'
283
+ const now = new Date()
284
+ const diffMs = now.getTime() - date.getTime()
285
+ const diffMins = Math.floor(diffMs / 60000)
286
+ const diffHours = Math.floor(diffMs / 3600000)
287
+ const diffDays = Math.floor(diffMs / 86400000)
288
+
289
+ if (diffMins < 1) return 'just now'
290
+ if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`
291
+ if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
292
+ if (diffDays === 1) return 'yesterday'
293
+ if (diffDays < 7) return `${diffDays} days ago`
294
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) === 1 ? '' : 's'} ago`
295
+ return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) === 1 ? '' : 's'} ago`
296
+ }
297
+
298
+ /**
299
+ * Layer 1: Format a single result as a compact one-liner with metadata
300
+ */
301
+ export function formatCompactResult(result: any, index: number): string {
302
+ const meta = result.metadata || {}
303
+ const category = result.category || result.type || meta.category || result.source || 'memory'
304
+ const summary = truncate(result.content || result.text || result.decision || '', 80)
305
+ const project = result.project || meta.project || 'general'
306
+ const timeAgo = formatRelativeTime(
307
+ result.createdAt || result.created_at || result.timestamp ||
308
+ result.date || meta.created_at || meta.createdAt || meta.date
309
+ )
310
+ const id = result.id || meta.id || meta.decision_id || 'unknown'
311
+
312
+ return `${index}. [${category}] ${summary}\n Project: ${project} | ${timeAgo}\n ID: ${id}`
313
+ }
314
+
315
+ /**
316
+ * Layer 1: Full compact response with header, compact items, and footer
317
+ */
318
+ export function formatCompactResponse(results: any[], query: string): string {
319
+ if (results.length === 0) {
320
+ return `No memories found for "${query}".`
321
+ }
322
+
323
+ const header = `Found ${results.length} relevant ${results.length === 1 ? 'memory' : 'memories'} for "${query}":`
324
+ const items = results.map((r, i) => formatCompactResult(r, i + 1)).join('\n\n')
325
+ const footer = `\nUse brain("details {ID}") for full context.`
326
+
327
+ return header + '\n\n' + items + footer
328
+ }
329
+
330
+ /**
331
+ * Layer 2: Full detail view for a single observation/memory
332
+ */
333
+ export function formatDetailResponse(observation: any): string {
334
+ const category = observation.category || observation.type || 'Memory'
335
+ const content = observation.content || observation.text || observation.decision || ''
336
+ const context = observation.context || observation.metadata?.context || ''
337
+ const reasoning = observation.reasoning || observation.metadata?.reasoning || ''
338
+ const tags = observation.tags || observation.metadata?.tags || []
339
+ const created = observation.createdAt || observation.created_at || observation.timestamp || ''
340
+
341
+ const lines: string[] = []
342
+ lines.push(`${category.charAt(0).toUpperCase() + category.slice(1)}: ${content}`)
343
+ if (context) lines.push(`\nContext: ${context}`)
344
+ if (reasoning) lines.push(`Reasoning: ${reasoning}`)
345
+ if (tags.length > 0) lines.push(`Tags: ${Array.isArray(tags) ? tags.join(', ') : tags}`)
346
+ if (created) lines.push(`Created: ${created}`)
347
+
348
+ return lines.join('\n')
349
+ }
350
+
351
+ /**
352
+ * Layer 3: Group observations by day for timeline view
353
+ */
354
+ export function groupByDay(observations: any[]): Map<string, any[]> {
355
+ const groups = new Map<string, any[]>()
356
+ for (const obs of observations) {
357
+ const date = new Date(obs.createdAt || obs.created_at || obs.timestamp || Date.now())
358
+ const dayKey = isNaN(date.getTime())
359
+ ? 'Unknown'
360
+ : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
361
+ if (!groups.has(dayKey)) groups.set(dayKey, [])
362
+ groups.get(dayKey)!.push(obs)
363
+ }
364
+ return groups
365
+ }
366
+
367
+ /**
368
+ * Layer 3: Format a timeline from day-grouped observations
369
+ */
370
+ export function formatTimeline(grouped: Map<string, any[]>, project?: string): string {
371
+ if (grouped.size === 0) {
372
+ return project ? `No activity found for "${project}".` : 'No recent activity found.'
373
+ }
374
+
375
+ const header = project ? `Timeline for ${project}:` : 'Recent timeline:'
376
+ const sections: string[] = [header, '']
377
+
378
+ for (const [day, observations] of grouped) {
379
+ sections.push(`${day}:`)
380
+ for (const obs of observations) {
381
+ const category = obs.category || obs.type || 'memory'
382
+ const summary = truncate(obs.content || obs.text || obs.decision || '', 60)
383
+ sections.push(` - [${category}] ${summary}`)
384
+ }
385
+ sections.push('')
386
+ }
387
+
388
+ return sections.join('\n').trim()
389
+ }