diff --git a/dashboard/src/components/AnalyticsTab.tsx b/dashboard/src/components/AnalyticsTab.tsx index 87f572ed..28d42c9a 100644 --- a/dashboard/src/components/AnalyticsTab.tsx +++ b/dashboard/src/components/AnalyticsTab.tsx @@ -1,71 +1,90 @@ import { useState, useEffect, useCallback } from 'preact/hooks'; -import { api, type StatsData, type AnalyticsData } from '../lib/api'; +import { api, type StatsData, type AnalyticsData, type PatternsData } from '../lib/api'; import { HealthScore } from './HealthScore'; import { MemoryTimeline } from './MemoryTimeline'; import { ValueMetrics } from './ValueMetrics'; import { CleanupSuggestions } from './CleanupSuggestions'; +import { UserPatterns } from './UserPatterns'; import { t } from '../lib/i18n'; export function AnalyticsTab() { const [stats, setStats] = useState(null); const [analytics, setAnalytics] = useState(null); + const [patterns, setPatterns] = useState(null); const [loading, setLoading] = useState(true); const loadData = useCallback(() => { setLoading(true); Promise.all([ - api('GET', '/v1/stats'), - api('GET', '/v1/analytics'), - ]).then(([s, a]) => { + api('GET', '/v1/stats').catch(() => null), + api('GET', '/v1/analytics').catch(() => null), + api('GET', '/v1/patterns').catch(() => null), + ]).then(([s, a, p]) => { setStats(s); setAnalytics(a); + setPatterns(p); }).finally(() => setLoading(false)); }, []); useEffect(() => { loadData(); }, [loadData]); if (loading) return
; - if (!stats || !analytics) return
{t('common.error')}: Failed to load analytics
; + if (!stats && !analytics) return
{t('common.error')}: Failed to load analytics
; return (
{/* Row 1: Stats overview */} -
-
{stats.totalEntities.toLocaleString()}
{t('analytics.totalMemories')}
-
{stats.totalObservations.toLocaleString()}
{t('analytics.knowledgeFacts')}
-
{stats.totalRelations.toLocaleString()}
{t('analytics.connections')}
-
{stats.totalTags.toLocaleString()}
{t('analytics.topics')}
-
+ {stats && ( +
+
{stats.totalEntities.toLocaleString()}
{t('analytics.totalMemories')}
+
{stats.totalObservations.toLocaleString()}
{t('analytics.knowledgeFacts')}
+
{stats.totalRelations.toLocaleString()}
{t('analytics.connections')}
+
{stats.totalTags.toLocaleString()}
{t('analytics.topics')}
+
+ )} {/* Row 2: Health Score */} - + {analytics && } {/* Row 3: Memory Timeline */} -
- -
+ {analytics && ( +
+ +
+ )} {/* Row 4: Value Metrics */} -
- -
+ {analytics && ( +
+ +
+ )} {/* Row 5: Cleanup Suggestions */} -
- -
+ {analytics && ( +
+ +
+ )} - {/* Row 6: Topics cloud (kept from original) */} - {(() => { + {/* Row 6: User Patterns */} + {patterns && ( +
+ +
+ )} + + {/* Row 7: Topics cloud (kept from original) */} + {stats && (() => { const internalPrefixes = ['auto_saved', 'auto-tracked', 'session_end', 'session:', 'source:', 'scope:', 'date:', 'urgency:']; const userTags = stats.tagDistribution.filter(tg => !internalPrefixes.some(p => tg.tag.startsWith(p)) && diff --git a/dashboard/src/components/UserPatterns.tsx b/dashboard/src/components/UserPatterns.tsx new file mode 100644 index 00000000..5ca6328c --- /dev/null +++ b/dashboard/src/components/UserPatterns.tsx @@ -0,0 +1,239 @@ +import type { PatternsData } from '../lib/api'; +import { t } from '../lib/i18n'; + +interface Props { + data: PatternsData; +} + +export function UserPatterns({ data }: Props) { + const { workSchedule, toolPreferences, focusAreas, workflow, strengths, learningAreas } = data; + + // Build hour heatmap data (0-23) + const hourMap = new Map(); + for (const h of workSchedule.hourDistribution) hourMap.set(h.hour, h.count); + const maxHourCount = Math.max(1, ...workSchedule.hourDistribution.map((h) => h.count)); + + // Find peak hours (top 3) + const peakHours = [...workSchedule.hourDistribution] + .sort((a, b) => b.count - a.count) + .slice(0, 3) + .map((h) => `${h.hour.toString().padStart(2, '0')}:00`); + + // Find busiest days (top 2) + const busiestDays = [...workSchedule.dayDistribution] + .sort((a, b) => b.count - a.count) + .slice(0, 2) + .map((d) => d.day); + + return ( +
+
{t('patterns.title')}
+ + {/* Work Schedule Heatmap */} +
+
+ {t('patterns.workSchedule')} +
+
+ {Array.from({ length: 24 }, (_, hour) => { + const count = hourMap.get(hour) || 0; + const intensity = count / maxHourCount; + return ( +
0 + ? `rgba(0, 214, 180, ${0.1 + intensity * 0.7})` + : 'var(--bg-0)', + border: '1px solid var(--border-subtle)', + fontSize: 8, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: intensity > 0.5 ? 'var(--bg-0)' : 'var(--text-3)', + fontFamily: 'var(--mono)', + }} + > + {hour} +
+ ); + })} +
+
+ {t('patterns.peakHours')}: {peakHours.join(', ')} + {busiestDays.length > 0 && ( + · {t('patterns.busiestDays')}: {busiestDays.join(', ')} + )} +
+
+ + {/* Top Tools */} + {toolPreferences.length > 0 && ( +
+
+ {t('patterns.tools')} +
+
+ {toolPreferences.slice(0, 10).map((tp) => ( + + {tp.tool} ({tp.sessions}) + + ))} +
+
+ )} + + {/* Workflow Stats */} +
+
+ {t('patterns.workflow')} +
+
+
+
+ {workflow.avgSessionMinutes > 0 ? `${Math.round(workflow.avgSessionMinutes)}m` : '—'} +
+
{t('patterns.avgSession')}
+
+
+
+ {workflow.totalSessions > 0 ? workflow.commitsPerSession.toFixed(1) : '—'} +
+
{t('patterns.commitsPerSession')}
+
+
+
+ + {/* Focus Areas */} + {focusAreas.length > 0 && ( +
+
+ {t('patterns.focus')} +
+
+ {focusAreas.slice(0, 12).map((fa) => ( + + {fa.type} ({fa.count}) + + ))} +
+
+ )} + + {/* Strengths & Learning Areas */} + {(strengths.length > 0 || learningAreas.length > 0) && ( +
+ {/* Strengths */} +
+
+ {t('patterns.strengths')} +
+ {strengths.slice(0, 5).map((s) => ( +
+
+ {s.type} + + {Math.round(s.avgConfidence * 100)}% + +
+
+
+
+
+ ))} + {strengths.length === 0 && ( +
+ )} +
+ + {/* Learning Areas */} +
+
+ {t('patterns.learning')} +
+ {learningAreas.slice(0, 5).map((la) => ( +
+ {la.tag} + + {la.count} + +
+ ))} + {learningAreas.length === 0 && ( +
+ )} +
+
+ )} +
+ ); +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index bbf777bb..b45d582a 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -91,6 +91,18 @@ export interface AnalyticsData { }; } +export interface PatternsData { + workSchedule: { + hourDistribution: Array<{ hour: number; count: number }>; + dayDistribution: Array<{ day: string; dayNum: number; count: number }>; + }; + toolPreferences: Array<{ tool: string; sessions: number }>; + focusAreas: Array<{ type: string; count: number }>; + workflow: { avgSessionMinutes: number; commitsPerSession: number; totalSessions: number; totalCommits: number }; + strengths: Array<{ type: string; avgConfidence: number; count: number }>; + learningAreas: Array<{ tag: string; count: number }>; +} + export interface GraphData { entities: Entity[]; relations: Array<{ from: string; to: string; type: string }>; diff --git a/dashboard/src/lib/i18n.ts b/dashboard/src/lib/i18n.ts index bb353e5d..d4c4fd49 100644 --- a/dashboard/src/lib/i18n.ts +++ b/dashboard/src/lib/i18n.ts @@ -104,6 +104,17 @@ const translations: Record> = { 'feedback.placeholder': 'Describe your feedback…', 'feedback.includeSys': 'Include system info', 'feedback.submit': 'Open GitHub Issue', + 'patterns.title': 'Your Patterns', + 'patterns.workSchedule': 'Work Schedule', + 'patterns.peakHours': 'Peak hours', + 'patterns.busiestDays': 'Busiest days', + 'patterns.tools': 'Top Tools', + 'patterns.workflow': 'Workflow', + 'patterns.avgSession': 'Avg Session', + 'patterns.commitsPerSession': 'Commits/Session', + 'patterns.focus': 'Focus Areas', + 'patterns.strengths': 'Strengths', + 'patterns.learning': 'Learning Areas', 'common.error': 'Error', 'common.loading': 'Loading…', }, @@ -210,6 +221,17 @@ const translations: Record> = { 'feedback.placeholder': '描述你的意見回饋…', 'feedback.includeSys': '附帶系統資訊', 'feedback.submit': '開啟 GitHub Issue', + 'patterns.title': '你的模式', + 'patterns.workSchedule': '工作時程', + 'patterns.peakHours': '尖峰時段', + 'patterns.busiestDays': '最忙碌的日子', + 'patterns.tools': '常用工具', + 'patterns.workflow': '工作流程', + 'patterns.avgSession': '平均工作階段', + 'patterns.commitsPerSession': '每次提交數', + 'patterns.focus': '關注領域', + 'patterns.strengths': '強項', + 'patterns.learning': '學習領域', 'common.error': '錯誤', 'common.loading': '載入中…', }, @@ -316,6 +338,17 @@ const translations: Record> = { 'feedback.placeholder': '描述你的反馈…', 'feedback.includeSys': '附带系统信息', 'feedback.submit': '打开 GitHub Issue', + 'patterns.title': '你的模式', + 'patterns.workSchedule': '工作时程', + 'patterns.peakHours': '高峰时段', + 'patterns.busiestDays': '最忙碌的日子', + 'patterns.tools': '常用工具', + 'patterns.workflow': '工作流程', + 'patterns.avgSession': '平均工作时长', + 'patterns.commitsPerSession': '每次提交数', + 'patterns.focus': '关注领域', + 'patterns.strengths': '强项', + 'patterns.learning': '学习领域', 'common.error': '错误', 'common.loading': '加载中…', }, @@ -421,6 +454,17 @@ const translations: Record> = { 'feedback.placeholder': 'フィードバックを記入…', 'feedback.includeSys': 'システム情報を含める', 'feedback.submit': 'GitHub Issue を作成', + 'patterns.title': 'あなたのパターン', + 'patterns.workSchedule': '作業スケジュール', + 'patterns.peakHours': 'ピーク時間帯', + 'patterns.busiestDays': '最も忙しい曜日', + 'patterns.tools': 'よく使うツール', + 'patterns.workflow': 'ワークフロー', + 'patterns.avgSession': '平均セッション', + 'patterns.commitsPerSession': 'コミット/セッション', + 'patterns.focus': 'フォーカスエリア', + 'patterns.strengths': '強み', + 'patterns.learning': '学習エリア', 'common.error': 'エラー', 'common.loading': '読み込み中…', }, @@ -526,6 +570,17 @@ const translations: Record> = { 'feedback.placeholder': '피드백을 설명해주세요…', 'feedback.includeSys': '시스템 정보 포함', 'feedback.submit': 'GitHub Issue 열기', + 'patterns.title': '나의 패턴', + 'patterns.workSchedule': '작업 일정', + 'patterns.peakHours': '피크 시간대', + 'patterns.busiestDays': '가장 바쁜 요일', + 'patterns.tools': '자주 쓰는 도구', + 'patterns.workflow': '워크플로우', + 'patterns.avgSession': '평균 세션', + 'patterns.commitsPerSession': '커밋/세션', + 'patterns.focus': '집중 영역', + 'patterns.strengths': '강점', + 'patterns.learning': '학습 영역', 'common.error': '오류', 'common.loading': '로딩 중…', }, @@ -631,6 +686,17 @@ const translations: Record> = { 'feedback.placeholder': 'Descreva seu feedback…', 'feedback.includeSys': 'Incluir informações do sistema', 'feedback.submit': 'Abrir GitHub Issue', + 'patterns.title': 'Seus Padrões', + 'patterns.workSchedule': 'Agenda de Trabalho', + 'patterns.peakHours': 'Horários de pico', + 'patterns.busiestDays': 'Dias mais movimentados', + 'patterns.tools': 'Ferramentas Principais', + 'patterns.workflow': 'Fluxo de Trabalho', + 'patterns.avgSession': 'Sessão Média', + 'patterns.commitsPerSession': 'Commits/Sessão', + 'patterns.focus': 'Áreas de Foco', + 'patterns.strengths': 'Pontos Fortes', + 'patterns.learning': 'Áreas de Aprendizado', 'common.error': 'Erro', 'common.loading': 'Carregando…', }, @@ -736,6 +802,17 @@ const translations: Record> = { 'feedback.placeholder': 'Décrivez votre retour…', 'feedback.includeSys': 'Inclure les infos système', 'feedback.submit': 'Ouvrir un GitHub Issue', + 'patterns.title': 'Vos habitudes', + 'patterns.workSchedule': 'Planning de travail', + 'patterns.peakHours': 'Heures de pointe', + 'patterns.busiestDays': 'Jours les plus chargés', + 'patterns.tools': 'Outils principaux', + 'patterns.workflow': 'Flux de travail', + 'patterns.avgSession': 'Session moyenne', + 'patterns.commitsPerSession': 'Commits/Session', + 'patterns.focus': 'Domaines de focus', + 'patterns.strengths': 'Points forts', + 'patterns.learning': 'Domaines d\'apprentissage', 'common.error': 'Erreur', 'common.loading': 'Chargement…', }, @@ -841,6 +918,17 @@ const translations: Record> = { 'feedback.placeholder': 'Beschreiben Sie Ihr Feedback…', 'feedback.includeSys': 'Systeminformationen einschließen', 'feedback.submit': 'GitHub Issue öffnen', + 'patterns.title': 'Ihre Muster', + 'patterns.workSchedule': 'Arbeitsplan', + 'patterns.peakHours': 'Spitzenzeiten', + 'patterns.busiestDays': 'Aktivste Tage', + 'patterns.tools': 'Top-Werkzeuge', + 'patterns.workflow': 'Arbeitsablauf', + 'patterns.avgSession': 'Durchschn. Sitzung', + 'patterns.commitsPerSession': 'Commits/Sitzung', + 'patterns.focus': 'Schwerpunkte', + 'patterns.strengths': 'Stärken', + 'patterns.learning': 'Lernbereiche', 'common.error': 'Fehler', 'common.loading': 'Laden…', }, @@ -946,6 +1034,17 @@ const translations: Record> = { 'feedback.placeholder': 'Mô tả phản hồi của bạn…', 'feedback.includeSys': 'Kèm thông tin hệ thống', 'feedback.submit': 'Mở GitHub Issue', + 'patterns.title': 'Kiểu mẫu của bạn', + 'patterns.workSchedule': 'Lịch làm việc', + 'patterns.peakHours': 'Giờ cao điểm', + 'patterns.busiestDays': 'Ngày bận rộn nhất', + 'patterns.tools': 'Công cụ hàng đầu', + 'patterns.workflow': 'Quy trình làm việc', + 'patterns.avgSession': 'Phiên trung bình', + 'patterns.commitsPerSession': 'Commits/Phiên', + 'patterns.focus': 'Lĩnh vực tập trung', + 'patterns.strengths': 'Điểm mạnh', + 'patterns.learning': 'Lĩnh vực học tập', 'common.error': 'Lỗi', 'common.loading': 'Đang tải…', }, @@ -1051,6 +1150,17 @@ const translations: Record> = { 'feedback.placeholder': 'Describe tu comentario…', 'feedback.includeSys': 'Incluir información del sistema', 'feedback.submit': 'Abrir GitHub Issue', + 'patterns.title': 'Tus patrones', + 'patterns.workSchedule': 'Horario de trabajo', + 'patterns.peakHours': 'Horas pico', + 'patterns.busiestDays': 'Días más ocupados', + 'patterns.tools': 'Herramientas principales', + 'patterns.workflow': 'Flujo de trabajo', + 'patterns.avgSession': 'Sesión promedio', + 'patterns.commitsPerSession': 'Commits/Sesión', + 'patterns.focus': 'Áreas de enfoque', + 'patterns.strengths': 'Fortalezas', + 'patterns.learning': 'Áreas de aprendizaje', 'common.error': 'Error', 'common.loading': 'Cargando…', }, @@ -1156,6 +1266,17 @@ const translations: Record> = { 'feedback.placeholder': 'อธิบายความคิดเห็นของคุณ…', 'feedback.includeSys': 'รวมข้อมูลระบบ', 'feedback.submit': 'เปิด GitHub Issue', + 'patterns.title': 'รูปแบบของคุณ', + 'patterns.workSchedule': 'ตารางการทำงาน', + 'patterns.peakHours': 'ช่วงเวลาพีค', + 'patterns.busiestDays': 'วันที่ยุ่งที่สุด', + 'patterns.tools': 'เครื่องมือยอดนิยม', + 'patterns.workflow': 'กระบวนการทำงาน', + 'patterns.avgSession': 'เซสชันเฉลี่ย', + 'patterns.commitsPerSession': 'คอมมิต/เซสชัน', + 'patterns.focus': 'พื้นที่โฟกัส', + 'patterns.strengths': 'จุดแข็ง', + 'patterns.learning': 'พื้นที่การเรียนรู้', 'common.error': 'ข้อผิดพลาด', 'common.loading': 'กำลังโหลด…', }, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 00324a11..b3b660f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,7 @@ ## Overview -MeMesh is a universal AI memory layer. It provides 7 operations (`remember`, `recall`, `forget`, `consolidate`, `export`, `import`, `learn`) accessible via three transports — CLI, HTTP REST, and MCP — backed by SQLite with FTS5 full-text search and optional sqlite-vec vector embeddings. Entities can be scoped to namespaces (`personal`, `team`, `global`) and shared across projects via JSON export/import. MeMesh includes self-improving memory: LLM-powered failure analysis automatically creates structured lessons from session errors, with proactive warnings at session start. +MeMesh is a universal AI memory layer. It provides 8 operations (`remember`, `recall`, `forget`, `consolidate`, `export`, `import`, `learn`, `user_patterns`) accessible via three transports — CLI, HTTP REST, and MCP — backed by SQLite with FTS5 full-text search and optional sqlite-vec vector embeddings. Entities can be scoped to namespaces (`personal`, `team`, `global`) and shared across projects via JSON export/import. MeMesh includes self-improving memory: LLM-powered failure analysis automatically creates structured lessons from session errors, with proactive warnings at session start. ``` ┌─────────────┐ @@ -61,6 +61,7 @@ src/ │ ├── failure-analyzer.ts # LLM-powered failure analysis → StructuredLesson │ ├── lesson-engine.ts # Structured lesson creation, upsert, project query │ ├── embedder.ts # Neural embeddings (Xenova/all-MiniLM-L6-v2, 384-dim) +│ ├── patterns.ts # User work patterns computation (shared by MCP + HTTP) │ └── version-check.ts # npm registry version check ├── db.ts # SQLite + FTS5 + sqlite-vec + migrations ├── knowledge-graph.ts # Entity CRUD, relations, FTS5 search, findConflicts @@ -98,6 +99,8 @@ src/ **lesson-engine.ts** — Structured lesson management. `createLesson()` stores a `StructuredLesson` as a `lesson_learned` entity with upsert-safe naming (`lesson-{project}-{errorPattern}`). Same error pattern in different sessions updates the existing lesson. `createExplicitLesson()` supports the `learn` MCP tool. `findProjectLessons()` queries lessons for proactive warnings. +**patterns.ts** — User work patterns computation (shared by MCP `user_patterns` tool and HTTP `GET /v1/patterns`). `computePatterns()` queries the database for work schedule (hour/day distribution), tool preferences, focus areas, workflow metrics, strengths, and learning areas. Accepts optional `categories` filter array. + **version-check.ts** — Queries the npm registry for the latest `@pcircle/memesh` version and emits an update notification if the installed version is behind. ### db.ts -- Database Layer @@ -150,10 +153,11 @@ Thin adapter: imports shared Zod schemas from `transports/schemas.ts`, validates | `export` | ExportSchema | Delegates to `operations.exportMemories()` | | `import` | ImportSchema | Delegates to `operations.importMemories()` | | `learn` | LearnSchema | Delegates to `operations.learn()` | +| `user_patterns` | UserPatternsSchema | Delegates to `core/patterns.computePatterns()` | ### transports/http/server.ts -- HTTP REST API Server -Express server exposed via `memesh serve` (default port 3737, 16 endpoints). Delegates all operations to `core/operations`. Includes `GET /v1/analytics` for computed health score, 30-day timeline, value metrics, and cleanup suggestions. See [HTTP REST API](#http-rest-api) in the API Reference. +Express server exposed via `memesh serve` (default port 3737, 17 endpoints). Delegates all operations to `core/operations`. Includes `GET /v1/analytics` for computed health score, 30-day timeline, value metrics, and cleanup suggestions. See [HTTP REST API](#http-rest-api) in the API Reference. ### transports/cli/cli.ts -- CLI @@ -426,7 +430,7 @@ Session with errors | Lesson Engine | `src/core/lesson-engine.ts` | Structured lesson CRUD + upsert dedup | | Stop Hook Integration | `scripts/hooks/session-summary.js` | Auto-triggers analysis after sessions | | Proactive Warnings | `scripts/hooks/session-start.js` | Shows known lessons at session start | -| Learn Tool | All transports | Explicit lesson creation (7th MCP tool) | +| Learn Tool | All transports | Explicit lesson creation (MCP tool) | ### Lesson Entity Structure diff --git a/docs/api/API_REFERENCE.md b/docs/api/API_REFERENCE.md index 25a607c5..adb7dde0 100644 --- a/docs/api/API_REFERENCE.md +++ b/docs/api/API_REFERENCE.md @@ -8,7 +8,7 @@ ## Tools -MeMesh exposes 7 tools via MCP. +MeMesh exposes 8 tools via MCP. --- @@ -330,6 +330,49 @@ Record a structured lesson from a mistake or discovery. Creates a `lesson_learne --- +### user_patterns + +Analyze user work patterns from existing memory. Returns work schedule (peak hours/days), tool preferences, focus areas, workflow metrics (session duration, commits/session), knowledge strengths, and learning areas. Use at session start for context about the user. + +**Input Schema**: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `categories` | string[] | No | Specific categories to return: `"workSchedule"`, `"toolPreferences"`, `"focusAreas"`, `"workflow"`, `"strengths"`, `"learningAreas"`. Omit for all. | + +**Response** (MCP returns markdown text; HTTP returns JSON): + +```json +{ + "workSchedule": { + "hourDistribution": [{"hour": 9, "count": 42}, {"hour": 14, "count": 38}], + "dayDistribution": [{"day": "Monday", "dayNum": 1, "count": 50}] + }, + "toolPreferences": [{"tool": "Read", "sessions": 15}], + "focusAreas": [{"type": "decision", "count": 12}], + "workflow": { + "avgSessionMinutes": 45, + "commitsPerSession": 2.3, + "totalSessions": 20, + "totalCommits": 46 + }, + "strengths": [{"type": "pattern", "avgConfidence": 0.95, "count": 8}], + "learningAreas": [{"tag": "async", "count": 3}] +} +``` + +**Examples**: + +```json +// Get all patterns +{} + +// Get only workflow and schedule +{"categories": ["workflow", "workSchedule"]} +``` + +--- + ## Data Model ### Entity @@ -396,6 +439,7 @@ Start: `memesh serve` (default: `localhost:3737`) | GET | /v1/stats | Aggregate counts: entities, observations, relations, tags; type/tag/status distributions | | GET | /v1/graph | All entities + all relations (for graph visualization) | | GET | /v1/analytics | Health score, 30-day timeline, value metrics, cleanup suggestions | +| GET | /v1/patterns | User work patterns: schedule, tools, focus areas, workflow, strengths, learning | | GET | /dashboard | Interactive web dashboard (HTML) | All responses: `{ success: true, data: ... }` or `{ success: false, error: "..." }` @@ -675,3 +719,10 @@ MeMesh runs as a stdio MCP server. Claude Code manages the connection automatica } } ``` + +### GET /v1/patterns + +Returns user work patterns extracted from existing memory entities. + +**Response fields:** `workSchedule` (hour/day distribution), `toolPreferences`, `focusAreas`, `workflow` (avg session minutes, commits/session), `strengths` (high-confidence types), `learningAreas` (tags from lessons/mistakes). + diff --git a/package-lock.json b/package-lock.json index aaee481d..49ae131c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pcircle/memesh", - "version": "3.1.1", + "version": "3.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pcircle/memesh", - "version": "3.1.1", + "version": "3.2.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -477,9 +477,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1053,12 +1053,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", @@ -2190,9 +2184,9 @@ } }, "node_modules/hono": { - "version": "4.11.10", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.10.tgz", - "integrity": "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2595,9 +2589,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -2619,9 +2613,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2702,9 +2696,9 @@ } }, "node_modules/protobufjs": { - "version": "6.11.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.5.tgz", - "integrity": "sha512-OKjVH3hDoXdIZ/s5MLv8O2X0s+wOxGfV7ar6WFSKGaSAxi/6gYn3px5POS4vi+mc/0zCOdL7Jkwrj0oT1Yst2A==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -2718,15 +2712,19 @@ "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.1", "@types/node": ">=13.7.0", - "long": "^4.0.0" + "long": "^5.0.0" }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" + "engines": { + "node": ">=12.0.0" } }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3490,9 +3488,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 06931d14..ee58940a 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,9 @@ "typescript": "^5.9.3", "vitest": "^4.0.17" }, + "overrides": { + "protobufjs": "^7.5.5" + }, "engines": { "node": ">=20.0.0" } diff --git a/scripts/smoke-packed-artifact.mjs b/scripts/smoke-packed-artifact.mjs index ed0c512d..a87022e5 100644 --- a/scripts/smoke-packed-artifact.mjs +++ b/scripts/smoke-packed-artifact.mjs @@ -71,6 +71,7 @@ const requiredFiles = [ 'dist/core/lesson-engine.js', 'dist/core/consolidator.js', 'dist/core/serializer.js', + 'dist/core/patterns.js', 'dist/core/embedder.js', // Dist — transports 'dist/transports/schemas.js', diff --git a/src/core/patterns.ts b/src/core/patterns.ts new file mode 100644 index 00000000..4243af3e --- /dev/null +++ b/src/core/patterns.ts @@ -0,0 +1,171 @@ +// ============================================================================= +// User Patterns — shared computation for MCP + HTTP transports +// Extracted to eliminate duplication between handlers.ts and http/server.ts +// ============================================================================= + +import type Database from 'better-sqlite3'; + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +export interface PatternsResult { + workSchedule: { + hourDistribution: Array<{ hour: number; count: number }>; + dayDistribution: Array<{ day: string; dayNum: number; count: number }>; + }; + toolPreferences: Array<{ tool: string; sessions: number }>; + focusAreas: Array<{ type: string; count: number }>; + workflow: { + avgSessionMinutes: number; + commitsPerSession: number; + totalSessions: number; + totalCommits: number; + }; + strengths: Array<{ type: string; avgConfidence: number; count: number }>; + learningAreas: Array<{ tag: string; count: number }>; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const AUTO_TYPES = ['session_keypoint', 'commit', 'session_identity', 'workflow_checkpoint', 'session-insight']; +const LEARNING_TYPES = ['lesson_learned', 'mistake', 'bug_fix', 'lesson']; + +// --------------------------------------------------------------------------- +// computePatterns — all SQL queries in one place +// --------------------------------------------------------------------------- + +export function computePatterns(db: Database.Database, categories?: string[]): PatternsResult { + const allCategories = !categories || categories.length === 0; + + // --- Work Schedule --- + let hourDistribution: Array<{ hour: number; count: number }> = []; + let dayDistribution: Array<{ day: string; dayNum: number; count: number }> = []; + + if (allCategories || categories!.includes('workSchedule')) { + hourDistribution = db.prepare(` + SELECT CAST(strftime('%H', created_at, 'localtime') AS INTEGER) as hour, COUNT(*) as count + FROM entities + GROUP BY hour ORDER BY hour + `).all() as Array<{ hour: number; count: number }>; + + dayDistribution = db.prepare(` + SELECT CASE CAST(strftime('%w', created_at, 'localtime') AS INTEGER) + WHEN 0 THEN 'Sunday' WHEN 1 THEN 'Monday' WHEN 2 THEN 'Tuesday' + WHEN 3 THEN 'Wednesday' WHEN 4 THEN 'Thursday' WHEN 5 THEN 'Friday' + WHEN 6 THEN 'Saturday' END as day, + CAST(strftime('%w', created_at, 'localtime') AS INTEGER) as dayNum, + COUNT(*) as count + FROM entities GROUP BY dayNum ORDER BY dayNum + `).all() as Array<{ day: string; dayNum: number; count: number }>; + } + + // --- Tool Preferences --- + let toolPreferences: Array<{ tool: string; sessions: number }> = []; + + if (allCategories || categories!.includes('toolPreferences')) { + const sessionObs = db.prepare(` + SELECT o.content FROM observations o + JOIN entities e ON o.entity_id = e.id + WHERE e.type IN ('session_keypoint', 'session-insight') AND o.content LIKE '[FOCUS]%' + LIMIT 500 + `).all() as Array<{ content: string }>; + + const toolCounts: Record = {}; + for (const row of sessionObs) { + const match = row.content.match(/Top tools: (.+)/); + if (match) { + for (const part of match[1].split(', ')) { + const name = part.split('(')[0].trim(); + if (name) toolCounts[name] = (toolCounts[name] || 0) + 1; + } + } + } + toolPreferences = Object.entries(toolCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([tool, sessions]) => ({ tool, sessions })); + } + + // --- Focus Areas --- + let focusAreas: Array<{ type: string; count: number }> = []; + + if (allCategories || categories!.includes('focusAreas')) { + focusAreas = db.prepare(` + SELECT type, COUNT(*) as count FROM entities + WHERE status = 'active' AND type NOT IN (${AUTO_TYPES.map(() => '?').join(',')}) + GROUP BY type ORDER BY count DESC LIMIT 10 + `).all(...AUTO_TYPES) as Array<{ type: string; count: number }>; + } + + // --- Workflow --- + let avgSessionMinutes = 0; + let commitsPerSession = 0; + let totalSessions = 0; + let totalCommits = 0; + + if (allCategories || categories!.includes('workflow')) { + const sessionDurations = db.prepare(` + SELECT o.content FROM observations o + JOIN entities e ON o.entity_id = e.id + WHERE e.type IN ('session_keypoint', 'session-insight') AND o.content LIKE '[SESSION]%' + LIMIT 200 + `).all() as Array<{ content: string }>; + + let totalMinutes = 0; + let sessionCount = 0; + for (const row of sessionDurations) { + const match = row.content.match(/Duration: (\d+)m/); + if (match) { + totalMinutes += parseInt(match[1]); + sessionCount++; + } + } + avgSessionMinutes = sessionCount > 0 ? Math.round(totalMinutes / sessionCount) : 0; + + totalCommits = (db.prepare( + "SELECT COUNT(*) as c FROM entities WHERE type = 'commit'" + ).get() as { c: number }).c; + totalSessions = (db.prepare( + "SELECT COUNT(*) as c FROM entities WHERE type IN ('session_keypoint', 'session-insight')" + ).get() as { c: number }).c; + commitsPerSession = totalSessions > 0 ? Math.round((totalCommits / totalSessions) * 10) / 10 : 0; + } + + // --- Strengths --- + let strengths: Array<{ type: string; avgConfidence: number; count: number }> = []; + + if (allCategories || categories!.includes('strengths')) { + strengths = db.prepare(` + SELECT type, ROUND(AVG(confidence), 2) as avgConfidence, COUNT(*) as count + FROM entities WHERE status = 'active' AND type NOT IN (${AUTO_TYPES.map(() => '?').join(',')}) + GROUP BY type HAVING count >= 2 + ORDER BY avgConfidence DESC LIMIT 5 + `).all(...AUTO_TYPES) as Array<{ type: string; avgConfidence: number; count: number }>; + } + + // --- Learning Areas --- + let learningAreas: Array<{ tag: string; count: number }> = []; + + if (allCategories || categories!.includes('learningAreas')) { + learningAreas = db.prepare(` + SELECT t.tag, COUNT(*) as count FROM tags t + JOIN entities e ON t.entity_id = e.id + WHERE e.type IN (${LEARNING_TYPES.map(() => '?').join(',')}) + AND t.tag NOT LIKE 'date:%' AND t.tag NOT LIKE 'auto%' AND t.tag NOT LIKE 'session%' + AND t.tag != 'scope:project' + GROUP BY t.tag ORDER BY count DESC LIMIT 10 + `).all(...LEARNING_TYPES) as Array<{ tag: string; count: number }>; + } + + return { + workSchedule: { hourDistribution, dayDistribution }, + toolPreferences, + focusAreas, + workflow: { avgSessionMinutes, commitsPerSession, totalSessions, totalCommits }, + strengths, + learningAreas, + }; +} diff --git a/src/core/schema-export.ts b/src/core/schema-export.ts index d06f79bb..5bd8ef43 100644 --- a/src/core/schema-export.ts +++ b/src/core/schema-export.ts @@ -84,5 +84,22 @@ export function exportOpenAITools(): object[] { }, }, }, + { + type: 'function', + function: { + name: 'memesh_user_patterns', + description: 'Analyze user work patterns from existing memory. Returns work schedule, tool preferences, focus areas, workflow metrics, strengths, and learning areas.', + parameters: { + type: 'object', + properties: { + categories: { + type: 'array', + items: { type: 'string', enum: ['workSchedule', 'toolPreferences', 'focusAreas', 'workflow', 'strengths', 'learningAreas'] }, + description: 'Specific categories to return. Omit for all.', + }, + }, + }, + }, + }, ]; } diff --git a/src/transports/http/server.ts b/src/transports/http/server.ts index 25d4e1a0..812d5ac0 100644 --- a/src/transports/http/server.ts +++ b/src/transports/http/server.ts @@ -7,6 +7,7 @@ import { remember, recallEnhanced, forget, consolidate, exportMemories, importMe import { KnowledgeGraph } from '../../knowledge-graph.js'; import { getDatabase } from '../../db.js'; import { logCapabilities, readConfig, updateConfig, detectCapabilities } from '../../core/config.js'; +import { computePatterns } from '../../core/patterns.js'; import type { CountRow } from '../../core/types.js'; import { RememberSchema as RememberBody, RecallSchema as RecallBody, @@ -29,6 +30,15 @@ const packageVersion = const app = express(); app.use(express.json({ limit: '1mb' })); +// --- Security headers --- +app.use((_req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '0'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + next(); +}); + // --- Rate limiting (in-memory, no external deps) --- const rateLimitWindowMs = 60_000; // 1 minute const rateLimitMax = 120; // max requests per window per IP @@ -303,9 +313,7 @@ app.get('/v1/analytics', (_req, res) => { try { const db = getDatabase(); - // Use SQLite datetime() for consistent comparison regardless of timestamp format - const thirtyDaysAgo = "datetime('now', '-30 days')"; - const sevenDaysAgo = "datetime('now', '-7 days')"; + // SQL datetime() literals are used inline in queries below — no JS interpolation needed // --- Health Score --- const totalActive = (db.prepare( @@ -314,7 +322,7 @@ app.get('/v1/analytics', (_req, res) => { // Activity: % of active entities accessed in last 30 days const recentlyAccessed = (db.prepare( - `SELECT COUNT(*) as c FROM entities WHERE status = 'active' AND last_accessed_at >= ${thirtyDaysAgo}` + `SELECT COUNT(*) as c FROM entities WHERE status = 'active' AND last_accessed_at >= datetime('now', '-30 days')` ).get() as CountRow).c; const activityRatio = totalActive > 0 ? recentlyAccessed / totalActive : 0; @@ -326,7 +334,7 @@ app.get('/v1/analytics', (_req, res) => { // Freshness: new entities this week relative to total (capped at 1.0) const newThisWeek = (db.prepare( - `SELECT COUNT(*) as c FROM entities WHERE created_at >= ${sevenDaysAgo}` + `SELECT COUNT(*) as c FROM entities WHERE created_at >= datetime('now', '-7 days')` ).get() as CountRow).c; const freshnessRatio = totalActive > 0 ? Math.min(newThisWeek / totalActive, 1.0) : 0; @@ -351,7 +359,7 @@ app.get('/v1/analytics', (_req, res) => { const createdTimeline = db.prepare(` SELECT DATE(created_at) as day, COUNT(*) as created FROM entities - WHERE created_at >= ${thirtyDaysAgo} + WHERE created_at >= datetime('now', '-30 days') GROUP BY DATE(created_at) ORDER BY day `).all() as Array<{ day: string; created: number }>; @@ -359,7 +367,7 @@ app.get('/v1/analytics', (_req, res) => { const recalledTimeline = db.prepare(` SELECT DATE(last_accessed_at) as day, COUNT(*) as recalled FROM entities - WHERE last_accessed_at >= ${thirtyDaysAgo} + WHERE last_accessed_at >= datetime('now', '-30 days') GROUP BY DATE(last_accessed_at) ORDER BY day `).all() as Array<{ day: string; recalled: number }>; @@ -406,7 +414,7 @@ app.get('/v1/analytics', (_req, res) => { FROM entities WHERE status = 'active' AND confidence < 0.4 - AND (last_accessed_at IS NULL OR last_accessed_at < ${thirtyDaysAgo}) + AND (last_accessed_at IS NULL OR last_accessed_at < datetime('now', '-30 days')) ORDER BY confidence ASC LIMIT 10 `).all(); @@ -440,6 +448,17 @@ app.get('/v1/analytics', (_req, res) => { } }); +// --- Patterns --- +app.get('/v1/patterns', (_req, res) => { + try { + const db = getDatabase(); + const data = computePatterns(db); + res.json({ success: true, data }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + // --- List entities --- app.get('/v1/entities', (req, res) => { try { diff --git a/src/transports/mcp/handlers.ts b/src/transports/mcp/handlers.ts index b5485ebf..a8a6cd22 100644 --- a/src/transports/mcp/handlers.ts +++ b/src/transports/mcp/handlers.ts @@ -8,9 +8,10 @@ import { z } from 'zod'; import { remember, recallEnhanced, forget, consolidate, exportMemories, importMemories, learn } from '../../core/operations.js'; import { KnowledgeGraph } from '../../knowledge-graph.js'; import { getDatabase } from '../../db.js'; +import { computePatterns } from '../../core/patterns.js'; import { RememberSchema, RecallSchema, ForgetSchema, ConsolidateSchema, - ExportSchema, ImportSchema, LearnSchema, + ExportSchema, ImportSchema, LearnSchema, UserPatternsSchema, } from '../schemas.js'; // --------------------------------------------------------------------------- @@ -195,6 +196,25 @@ export const TOOL_DEFINITIONS = [ additionalProperties: false, }, }, + { + name: 'user_patterns', + description: + 'Analyze user work patterns from existing memory. Returns: work schedule (peak hours/days), tool preferences, focus areas, workflow metrics (session duration, commits/session), knowledge strengths, and learning areas. Use at session start for context about the user.', + inputSchema: { + type: 'object' as const, + properties: { + categories: { + type: 'array', + items: { + type: 'string', + enum: ['workSchedule', 'toolPreferences', 'focusAreas', 'workflow', 'strengths', 'learningAreas'], + }, + description: 'Specific categories to return. Omit for all.', + }, + }, + additionalProperties: false, + }, + }, ] as const; // --------------------------------------------------------------------------- @@ -274,6 +294,87 @@ export async function handleTool(name: string, args: any): Promise { if (!r.ok) return r.result; return ok(learn(r.data)); } + if (name === 'user_patterns') { + const r = parseOrFail(UserPatternsSchema, args); + if (!r.ok) return r.result; + + const db = getDatabase(); + const cats = r.data.categories; + const allCategories = !cats || cats.length === 0; + const data = computePatterns(db, cats); + const lines: string[] = ['## User Patterns']; + + // --- Work Schedule --- + if (allCategories || cats!.includes('workSchedule')) { + lines.push('', '### Work Schedule'); + // Sort by count DESC for display (data is ordered by hour for consistency) + const peakHours = [...data.workSchedule.hourDistribution] + .sort((a, b) => b.count - a.count) + .slice(0, 3) + .map(h => `${String(h.hour).padStart(2, '0')}:00 (${h.count})`) + .join(', '); + lines.push(`Peak hours: ${peakHours || 'No data'}`); + const busiestDays = [...data.workSchedule.dayDistribution] + .sort((a, b) => b.count - a.count) + .slice(0, 3) + .map(d => `${d.day} (${d.count})`) + .join(', '); + lines.push(`Busiest days: ${busiestDays || 'No data'}`); + } + + // --- Tool Preferences --- + if (allCategories || cats!.includes('toolPreferences')) { + lines.push('', '### Tool Preferences'); + if (data.toolPreferences.length > 0) { + data.toolPreferences.forEach((tp, i) => { + lines.push(`${i + 1}. ${tp.tool} (${tp.sessions} sessions)`); + }); + } else { + lines.push('No tool usage data yet.'); + } + } + + // --- Focus Areas --- + if (allCategories || cats!.includes('focusAreas')) { + lines.push('', '### Focus Areas'); + if (data.focusAreas.length > 0) { + data.focusAreas.forEach(f => { + lines.push(`- ${f.type} (${f.count})`); + }); + } else { + lines.push('No focus area data yet.'); + } + } + + // --- Workflow --- + if (allCategories || cats!.includes('workflow')) { + lines.push('', '### Workflow'); + lines.push(`Avg session: ${data.workflow.avgSessionMinutes} min | Commits per session: ${data.workflow.commitsPerSession}`); + lines.push(`Total sessions: ${data.workflow.totalSessions} | Total commits: ${data.workflow.totalCommits}`); + } + + // --- Strengths --- + if (allCategories || cats!.includes('strengths')) { + lines.push('', '### Strengths (high confidence areas)'); + if (data.strengths.length > 0) { + lines.push('- ' + data.strengths.map(s => `${s.type} (${s.avgConfidence})`).join(', ')); + } else { + lines.push('No strength data yet.'); + } + } + + // --- Learning Areas --- + if (allCategories || cats!.includes('learningAreas')) { + lines.push('', '### Learning Areas'); + if (data.learningAreas.length > 0) { + lines.push('- ' + data.learningAreas.map(l => l.tag).join(', ')); + } else { + lines.push('No learning area data yet.'); + } + } + + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } return fail(`Unknown tool: ${name}`); } catch (err: any) { return fail(`Tool "${name}" failed: ${err.message}`); diff --git a/src/transports/schemas.ts b/src/transports/schemas.ts index a9342468..eef6b0a5 100644 --- a/src/transports/schemas.ts +++ b/src/transports/schemas.ts @@ -70,3 +70,8 @@ export const LearnSchema = z.object({ prevention: z.string().max(5000).optional(), severity: z.enum(['critical', 'major', 'minor']).optional(), }); + +export const UserPatternsSchema = z.object({ + categories: z.array(z.enum(['workSchedule', 'toolPreferences', 'focusAreas', 'workflow', 'strengths', 'learningAreas'])).optional() + .describe('Specific categories to return. Omit for all.'), +}); diff --git a/tests/core/patterns.test.ts b/tests/core/patterns.test.ts new file mode 100644 index 00000000..05af2d1c --- /dev/null +++ b/tests/core/patterns.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { openDatabase, closeDatabase, getDatabase } from '../../src/db.js'; +import { remember } from '../../src/core/operations.js'; +import { computePatterns } from '../../src/core/patterns.js'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-patterns-')); + openDatabase(path.join(tmpDir, 'test.db')); +}); + +afterEach(() => { + closeDatabase(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('computePatterns', () => { + it('returns all pattern categories for empty database', () => { + const db = getDatabase(); + const result = computePatterns(db); + expect(result.workSchedule).toBeDefined(); + expect(result.workSchedule.hourDistribution).toEqual([]); + expect(result.workSchedule.dayDistribution).toEqual([]); + expect(result.toolPreferences).toEqual([]); + expect(result.focusAreas).toEqual([]); + expect(result.workflow.totalSessions).toBe(0); + expect(result.workflow.totalCommits).toBe(0); + expect(result.workflow.avgSessionMinutes).toBe(0); + expect(result.workflow.commitsPerSession).toBe(0); + expect(result.strengths).toEqual([]); + expect(result.learningAreas).toEqual([]); + }); + + it('computes focus areas excluding auto-tracked types', () => { + remember({ name: 'e1', type: 'decision', observations: ['arch choice'] }); + remember({ name: 'e2', type: 'session_keypoint', observations: ['[SESSION] test'] }); + remember({ name: 'e3', type: 'lesson_learned', observations: ['Error: test'] }); + remember({ name: 'e4', type: 'commit', observations: ['fix: something'] }); + const db = getDatabase(); + const result = computePatterns(db); + const types = result.focusAreas.map(f => f.type); + expect(types).toContain('decision'); + expect(types).toContain('lesson_learned'); + expect(types).not.toContain('session_keypoint'); + expect(types).not.toContain('commit'); + }); + + it('filters by categories when specified', () => { + remember({ name: 'e1', type: 'decision', observations: ['test'] }); + const db = getDatabase(); + const result = computePatterns(db, ['workflow']); + expect(result.workflow).toBeDefined(); + expect(result.workflow.totalSessions).toBe(0); + // Categories not requested should be empty defaults + expect(result.focusAreas).toEqual([]); + expect(result.toolPreferences).toEqual([]); + }); + + it('computes hour distribution ordered by hour', () => { + remember({ name: 'e1', type: 'concept', observations: ['test'] }); + const db = getDatabase(); + const result = computePatterns(db); + expect(result.workSchedule.hourDistribution.length).toBeGreaterThan(0); + const total = result.workSchedule.hourDistribution.reduce((s, h) => s + h.count, 0); + expect(total).toBeGreaterThan(0); + }); + + it('includes dayNum in day distribution', () => { + remember({ name: 'e1', type: 'concept', observations: ['test'] }); + const db = getDatabase(); + const result = computePatterns(db); + expect(result.workSchedule.dayDistribution.length).toBeGreaterThan(0); + for (const entry of result.workSchedule.dayDistribution) { + expect(entry).toHaveProperty('day'); + expect(entry).toHaveProperty('dayNum'); + expect(entry).toHaveProperty('count'); + expect(typeof entry.dayNum).toBe('number'); + } + }); +}); diff --git a/tests/core/schema-export.test.ts b/tests/core/schema-export.test.ts index 427ea376..f48a8e8c 100644 --- a/tests/core/schema-export.test.ts +++ b/tests/core/schema-export.test.ts @@ -4,9 +4,9 @@ import { exportOpenAITools } from '../../src/core/schema-export.js'; describe('exportOpenAITools', () => { const tools = exportOpenAITools(); - it('returns an array of 5 tools', () => { + it('returns an array of 6 tools', () => { expect(Array.isArray(tools)).toBe(true); - expect(tools).toHaveLength(5); + expect(tools).toHaveLength(6); }); it('each tool has type "function" and a function object with name, description, parameters', () => { @@ -30,6 +30,7 @@ describe('exportOpenAITools', () => { 'memesh_forget', 'memesh_consolidate', 'memesh_learn', + 'memesh_user_patterns', ]); });