autoctxd 0.4.1

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 (50) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/CONTRIBUTING.md +80 -0
  3. package/LICENSE +21 -0
  4. package/README.md +301 -0
  5. package/SECURITY.md +81 -0
  6. package/package.json +55 -0
  7. package/scripts/install-hooks.ts +80 -0
  8. package/scripts/install.ps1 +71 -0
  9. package/scripts/install.sh +67 -0
  10. package/scripts/uninstall-hooks.ts +57 -0
  11. package/src/ai/active-guard.ts +96 -0
  12. package/src/ai/adaptive-ranker.ts +48 -0
  13. package/src/ai/classifier.ts +256 -0
  14. package/src/ai/compressor.ts +129 -0
  15. package/src/ai/decision-chains.ts +100 -0
  16. package/src/ai/decision-extractor.ts +148 -0
  17. package/src/ai/pattern-detector.ts +147 -0
  18. package/src/ai/proactive.ts +78 -0
  19. package/src/cli/doctor.ts +171 -0
  20. package/src/cli/embeddings.ts +209 -0
  21. package/src/cli/index.ts +574 -0
  22. package/src/cli/reclassify.ts +134 -0
  23. package/src/context/builder.ts +97 -0
  24. package/src/context/formatter.ts +109 -0
  25. package/src/context/ranker.ts +84 -0
  26. package/src/db/sqlite/decisions.ts +56 -0
  27. package/src/db/sqlite/feedback.ts +92 -0
  28. package/src/db/sqlite/observations.ts +58 -0
  29. package/src/db/sqlite/schema.ts +366 -0
  30. package/src/db/sqlite/sessions.ts +50 -0
  31. package/src/db/sqlite/summaries.ts +69 -0
  32. package/src/db/vector/client.ts +134 -0
  33. package/src/db/vector/embeddings.ts +119 -0
  34. package/src/db/vector/providers/factory.ts +99 -0
  35. package/src/db/vector/providers/minilm.ts +90 -0
  36. package/src/db/vector/providers/ollama.ts +92 -0
  37. package/src/db/vector/providers/tfidf.ts +98 -0
  38. package/src/db/vector/providers/types.ts +39 -0
  39. package/src/db/vector/search.ts +131 -0
  40. package/src/hooks/post-tool-use.ts +205 -0
  41. package/src/hooks/pre-tool-use.ts +305 -0
  42. package/src/hooks/stop.ts +334 -0
  43. package/src/mcp/server.ts +293 -0
  44. package/src/server/dashboard.html +268 -0
  45. package/src/server/dashboard.ts +170 -0
  46. package/src/util/debug.ts +56 -0
  47. package/src/util/ignore.ts +171 -0
  48. package/src/util/metrics.ts +236 -0
  49. package/src/util/path.ts +57 -0
  50. package/tsconfig.json +14 -0
@@ -0,0 +1,129 @@
1
+ // Heuristic session compressor - generates summaries without API calls
2
+
3
+ import type { Observation } from "../db/sqlite/observations";
4
+ import { normalizePath } from "../util/path";
5
+
6
+ interface SessionSummary {
7
+ text: string;
8
+ keyFiles: string[];
9
+ decisionsDetected: string[];
10
+ unfinished: string[];
11
+ }
12
+
13
+ export function compressSession(observations: Observation[], projectPath?: string): SessionSummary {
14
+ const keyFiles = extractTopFiles(observations);
15
+ const decisionsDetected = extractDecisions(observations);
16
+ const unfinished = extractUnfinished(observations);
17
+
18
+ // Deduce what the session was about from the work done
19
+ const workFocus = deduceWorkFocus(observations);
20
+
21
+ const lines: string[] = [];
22
+
23
+ // One-line session summary instead of raw dump
24
+ const projectName = projectPath ? projectPath.split(/[/\\]/).filter(Boolean).pop() || projectPath : "unknown";
25
+ lines.push(`${projectName}: ${workFocus} (${observations.length} actions)`);
26
+
27
+ if (keyFiles.length > 0) {
28
+ lines.push(`Files: ${keyFiles.slice(0, 5).map(f => f.split(/[/\\]/).pop()).join(", ")}`);
29
+ }
30
+
31
+ if (decisionsDetected.length > 0) {
32
+ lines.push("Decisions:");
33
+ for (const d of decisionsDetected) {
34
+ lines.push(` • ${d}`);
35
+ }
36
+ }
37
+
38
+ if (unfinished.length > 0) {
39
+ lines.push("Blocked:");
40
+ for (const u of unfinished) {
41
+ lines.push(` ⚠ ${u}`);
42
+ }
43
+ }
44
+
45
+ return {
46
+ text: lines.join("\n"),
47
+ keyFiles,
48
+ decisionsDetected,
49
+ unfinished,
50
+ };
51
+ }
52
+
53
+ function deduceWorkFocus(observations: Observation[]): string {
54
+ const typeCounts = new Map<string, number>();
55
+ for (const o of observations) {
56
+ typeCounts.set(o.type, (typeCounts.get(o.type) || 0) + 1);
57
+ }
58
+
59
+ const sorted = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]);
60
+ const primary = sorted[0]?.[0] || "other";
61
+
62
+ const focusLabels: Record<string, string> = {
63
+ bug_fix: "Bug fixing",
64
+ refactor: "Refactoring",
65
+ new_feature: "Building new features",
66
+ config: "Configuration & setup",
67
+ research: "Research & exploration",
68
+ test: "Testing",
69
+ decision: "Architecture decisions",
70
+ blocked: "Debugging blockers",
71
+ deploy: "Deployment",
72
+ other: "General work",
73
+ };
74
+
75
+ // Add detail from high-importance observations
76
+ const topObs = observations
77
+ .filter(o => (o.importance_score ?? 0) >= 6)
78
+ .sort((a, b) => (b.importance_score ?? 0) - (a.importance_score ?? 0))
79
+ .slice(0, 3);
80
+
81
+ const details = topObs.map(o => {
82
+ const s = o.summary;
83
+ // Extract just the meaningful part (file edited, package installed, etc)
84
+ if (s.startsWith("Edited ")) return s.split(":")[0];
85
+ if (s.startsWith("Wrote ")) return `Created ${s.split(" ")[1]?.split(/[/\\]/).pop() || "file"}`;
86
+ if (s.startsWith("Bash: ")) return s.slice(6);
87
+ return truncate(s, 60);
88
+ });
89
+
90
+ const label = focusLabels[primary] || "General work";
91
+ if (details.length > 0) {
92
+ return `${label} — ${details.join(", ")}`;
93
+ }
94
+ return label;
95
+ }
96
+
97
+ function extractTopFiles(observations: Observation[]): string[] {
98
+ const fileCounts = new Map<string, number>();
99
+ for (const obs of observations) {
100
+ if (obs.file_paths) {
101
+ for (const fp of obs.file_paths.split(",")) {
102
+ const norm = normalizePath(fp);
103
+ if (norm) {
104
+ fileCounts.set(norm, (fileCounts.get(norm) || 0) + 1);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return [...fileCounts.entries()]
110
+ .sort((a, b) => b[1] - a[1])
111
+ .map(([f]) => f);
112
+ }
113
+
114
+ function extractDecisions(observations: Observation[]): string[] {
115
+ return observations
116
+ .filter(o => o.type === "decision")
117
+ .map(o => truncate(o.summary, 100));
118
+ }
119
+
120
+ function extractUnfinished(observations: Observation[]): string[] {
121
+ return observations
122
+ .filter(o => o.type === "blocked")
123
+ .map(o => truncate(o.summary, 100));
124
+ }
125
+
126
+ function truncate(text: string, maxLen: number): string {
127
+ if (text.length <= maxLen) return text;
128
+ return text.slice(0, maxLen - 3) + "...";
129
+ }
@@ -0,0 +1,100 @@
1
+ // Detects cross-session decision chains like "switched A → B" then later "replaced B → C".
2
+ // Writes them into the patterns table as type 'decision_chain' for surfacing in the dashboard.
3
+
4
+ import { getDb } from "../db/sqlite/schema";
5
+
6
+ interface DecisionNode {
7
+ id: number;
8
+ title: string;
9
+ decision_text: string;
10
+ created_at: string;
11
+ }
12
+
13
+ export function detectDecisionChains(projectPath: string): void {
14
+ const db = getDb();
15
+
16
+ const decisions = db.prepare(`
17
+ SELECT id, title, decision_text, created_at
18
+ FROM decisions
19
+ WHERE project_path = ?
20
+ ORDER BY created_at ASC
21
+ `).all(projectPath) as DecisionNode[];
22
+
23
+ if (decisions.length < 2) return;
24
+
25
+ // Extract { from, to } tuples from each decision
26
+ const tuples = decisions
27
+ .map(d => ({ d, tuple: extractTransition(d.decision_text + " " + d.title) }))
28
+ .filter(x => x.tuple !== null) as Array<{ d: DecisionNode; tuple: { from: string; to: string } }>;
29
+
30
+ // Build chains: if A→B exists and later B→C exists, record A→B→C
31
+ const chains: string[][] = [];
32
+ for (let i = 0; i < tuples.length; i++) {
33
+ const chain = [tuples[i].tuple.from, tuples[i].tuple.to];
34
+ let current = tuples[i].tuple.to;
35
+ for (let j = i + 1; j < tuples.length; j++) {
36
+ if (normalize(tuples[j].tuple.from) === normalize(current)) {
37
+ chain.push(tuples[j].tuple.to);
38
+ current = tuples[j].tuple.to;
39
+ }
40
+ }
41
+ if (chain.length >= 3) {
42
+ chains.push(chain);
43
+ }
44
+ }
45
+
46
+ // Deduplicate chains by their stringified form
47
+ const seen = new Set<string>();
48
+ for (const chain of chains) {
49
+ const description = chain.join(" → ");
50
+ if (seen.has(description)) continue;
51
+ seen.add(description);
52
+
53
+ const existing = db.prepare(`
54
+ SELECT id, frequency FROM patterns
55
+ WHERE project_path = ? AND pattern_type = 'decision_chain' AND description = ?
56
+ `).get(projectPath, description) as any;
57
+
58
+ if (existing) {
59
+ db.prepare(`
60
+ UPDATE patterns SET frequency = frequency + 1, last_seen = datetime('now') WHERE id = ?
61
+ `).run(existing.id);
62
+ } else {
63
+ db.prepare(`
64
+ INSERT INTO patterns (project_path, pattern_type, description, frequency, examples)
65
+ VALUES (?, 'decision_chain', ?, 1, ?)
66
+ `).run(projectPath, description, `Chain spans ${chain.length} decisions`);
67
+ }
68
+ }
69
+ }
70
+
71
+ const TRANSITION_PATTERNS = [
72
+ /(?:switched|migrated)\s+(?:from\s+)?([\w\-/@.]+?)\s+to\s+([\w\-/@.]+)/i,
73
+ /replaced\s+([\w\-/@.]+?)\s+with\s+([\w\-/@.]+)/i,
74
+ /chose\s+([\w\-/@.]+?)\s+over\s+([\w\-/@.]+)/i,
75
+ /([\w\-/@.]+?)\s+(?:instead\s+of|rather\s+than)\s+([\w\-/@.]+)/i,
76
+ // Dependency add: from nothing to pkg
77
+ /(?:npm|bun|pnpm|yarn|pip|cargo)\s+(?:install|add|i)\s+([@\w\-/\.]+)/i,
78
+ ];
79
+
80
+ function extractTransition(text: string): { from: string; to: string } | null {
81
+ // Dependency decisions: "from" is implicit (no prior choice)
82
+ const depMatch = text.match(TRANSITION_PATTERNS[4]);
83
+ if (depMatch) {
84
+ return { from: "∅", to: depMatch[1] };
85
+ }
86
+
87
+ for (let i = 0; i < 4; i++) {
88
+ const m = text.match(TRANSITION_PATTERNS[i]);
89
+ if (m) {
90
+ // Pattern 3 is "X instead of Y" — X is chosen, Y is rejected
91
+ if (i === 3) return { from: m[2], to: m[1] };
92
+ return { from: m[1], to: m[2] };
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function normalize(s: string): string {
99
+ return s.toLowerCase().replace(/[^a-z0-9]/g, "");
100
+ }
@@ -0,0 +1,148 @@
1
+ // Extracts architectural decisions from observation summaries
2
+
3
+ import type { Observation } from "../db/sqlite/observations";
4
+ import type { Decision } from "../db/sqlite/decisions";
5
+
6
+ const DECISION_PATTERNS = [
7
+ /chose\s+(.+?)\s+(?:over|instead\s+of|rather\s+than)\s+(.+)/i,
8
+ /decided\s+(?:to\s+)?(?:use|go\s+with|switch\s+to)\s+(.+?)(?:\s+because|\s+since|\.|$)/i,
9
+ /switched\s+(?:from\s+(.+?)\s+)?to\s+(.+?)(?:\s+because|\s+since|\.|$)/i,
10
+ /replaced\s+(.+?)\s+with\s+(.+)/i,
11
+ /migrated?\s+(?:from\s+(.+?)\s+)?to\s+(.+)/i,
12
+ /picked\s+(.+?)\s+(?:over|for)/i,
13
+ ];
14
+
15
+ export function extractDecisionsFromObservations(
16
+ observations: Observation[],
17
+ projectPath?: string
18
+ ): Decision[] {
19
+ const decisions: Decision[] = [];
20
+ const seen = new Set<string>();
21
+
22
+ for (const obs of observations) {
23
+ const dep = detectDependencyDecision(obs.summary);
24
+ if (dep) {
25
+ const key = `dep:${dep.pkg}`;
26
+ if (seen.has(key)) continue;
27
+ seen.add(key);
28
+ decisions.push({
29
+ project_path: projectPath,
30
+ title: `Added ${dep.manager} dep: ${dep.pkg}`,
31
+ decision_text: obs.summary,
32
+ alternatives: undefined,
33
+ rationale: `Installed via ${dep.manager}`,
34
+ files_affected: obs.file_paths || undefined,
35
+ });
36
+ continue;
37
+ }
38
+
39
+ if (obs.type !== "decision") continue;
40
+
41
+ // Promote to architectural decision ONLY when the summary actually matches
42
+ // one of our decision patterns (chose/switched/migrated/replaced/...).
43
+ // Without this, anything the classifier scored as `decision` due to a stray
44
+ // keyword (e.g. an Agent prompt mentioning "architecture") leaks in.
45
+ if (!matchesDecisionPattern(obs.summary)) continue;
46
+
47
+ // Belt-and-suspenders: never let tool-prefixed noise through. The list
48
+ // grew over time as new shapes of fake decisions surfaced in real DBs:
49
+ // raw Bash/PowerShell outputs, Edit summaries, Monitor JSON, etc.
50
+ if (/^(Wrote |mcp__|Created |Spawned agent:|WebFetch |WebSearch:|Notebook |Bash:|PowerShell:|Edit:|Edited |Monitor:|ToolSearch:|TodoWrite|TaskStop:|TaskOutput:)/.test(obs.summary)) continue;
51
+
52
+ const title = extractTitle(obs.summary);
53
+ const key = `title:${title.toLowerCase()}`;
54
+ if (seen.has(key)) continue;
55
+ seen.add(key);
56
+
57
+ const { alternatives, rationale } = parseDecisionDetails(obs.summary);
58
+
59
+ decisions.push({
60
+ project_path: projectPath,
61
+ title,
62
+ decision_text: obs.summary,
63
+ alternatives: alternatives || undefined,
64
+ rationale: rationale || undefined,
65
+ files_affected: obs.file_paths || undefined,
66
+ });
67
+ }
68
+
69
+ return decisions;
70
+ }
71
+
72
+ function matchesDecisionPattern(summary: string): boolean {
73
+ return DECISION_PATTERNS.some(p => p.test(summary));
74
+ }
75
+
76
+ // Generic words that appear in logs/monitors/task descriptions and were being
77
+ // captured as if they were package names. None of these are real npm/pip/cargo
78
+ // packages — they're metadata leaking from surrounding text.
79
+ const PACKAGE_REJECT_LIST = new Set([
80
+ "output", "progress", "tsx", "tests", "test", "dev", "prod", "build",
81
+ "start", "all", "true", "false", "yes", "no", "latest", "beta", "alpha",
82
+ "rc", "stable", "debug", "verbose", "help", "version", "init", "run",
83
+ "fix", "lint", "format", "check", "watch", "serve", "clean",
84
+ ]);
85
+
86
+ function detectDependencyDecision(summary: string): { manager: string; pkg: string } | null {
87
+ const m = summary.match(/\b(npm|bun|pnpm|yarn|pip|cargo|go)\s+(?:install|add|i)\s+([@\w\-/\.]+)/i);
88
+ if (!m) return null;
89
+ const pkg = m[2];
90
+
91
+ // Skip common generic flags
92
+ if (/^(-|--)/.test(pkg)) return null;
93
+ if (!isLikelyPackageName(pkg)) return null;
94
+
95
+ return { manager: m[1].toLowerCase(), pkg };
96
+ }
97
+
98
+ function isLikelyPackageName(pkg: string): boolean {
99
+ // Scoped packages are always valid (@scope/name)
100
+ if (pkg.startsWith("@") && pkg.includes("/")) return true;
101
+
102
+ // Reject known noise words that appear in log streams
103
+ if (PACKAGE_REJECT_LIST.has(pkg.toLowerCase())) return false;
104
+
105
+ // Reject very short bare words unless they contain @, -, _ or /
106
+ if (pkg.length < 3 && !/[@\-_/]/.test(pkg)) return false;
107
+
108
+ // Reject numeric-only tokens (versions, etc.)
109
+ if (/^[\d.]+$/.test(pkg)) return false;
110
+
111
+ // Reject tokens with no letters at all
112
+ if (!/[a-z]/i.test(pkg)) return false;
113
+
114
+ return true;
115
+ }
116
+
117
+ function extractTitle(summary: string): string {
118
+ // Try to find a concise title from the decision
119
+ for (const pattern of DECISION_PATTERNS) {
120
+ const match = summary.match(pattern);
121
+ if (match) {
122
+ const relevant = match.slice(1).filter(Boolean).join(" → ");
123
+ return relevant.slice(0, 80);
124
+ }
125
+ }
126
+ // Fallback: first sentence, truncated
127
+ const firstSentence = summary.split(/[.!?\n]/)[0];
128
+ return firstSentence.slice(0, 80);
129
+ }
130
+
131
+ function parseDecisionDetails(summary: string): { alternatives: string | null; rationale: string | null } {
132
+ let alternatives: string | null = null;
133
+ let rationale: string | null = null;
134
+
135
+ // Extract "instead of X" / "over X"
136
+ const altMatch = summary.match(/(?:instead\s+of|over|rather\s+than|rejected?)\s+(.+?)(?:\.|,|because|since|$)/i);
137
+ if (altMatch) {
138
+ alternatives = altMatch[1].trim();
139
+ }
140
+
141
+ // Extract "because X" / "since X"
142
+ const reasonMatch = summary.match(/(?:because|since|reason:|rationale:)\s+(.+?)(?:\.|$)/i);
143
+ if (reasonMatch) {
144
+ rationale = reasonMatch[1].trim();
145
+ }
146
+
147
+ return { alternatives, rationale };
148
+ }
@@ -0,0 +1,147 @@
1
+ // Detects recurring patterns in user's work based on observations
2
+
3
+ import { getDb } from "../db/sqlite/schema";
4
+ import type { Observation } from "../db/sqlite/observations";
5
+ import { normalizePath } from "../util/path";
6
+
7
+ interface PatternCandidate {
8
+ pattern_type: string;
9
+ description: string;
10
+ examples: string;
11
+ }
12
+
13
+ export function detectPatterns(observations: Observation[], projectPath: string): void {
14
+ const db = getDb();
15
+ const candidates = analyzeObservations(observations, projectPath);
16
+
17
+ for (const candidate of candidates) {
18
+ // Check if this pattern already exists
19
+ const existing = db.prepare(`
20
+ SELECT id, frequency FROM patterns
21
+ WHERE project_path = ? AND pattern_type = ? AND description = ?
22
+ `).get(projectPath, candidate.pattern_type, candidate.description) as any;
23
+
24
+ if (existing) {
25
+ // Update frequency
26
+ db.prepare(`
27
+ UPDATE patterns SET frequency = frequency + 1, last_seen = datetime('now'),
28
+ examples = ? WHERE id = ?
29
+ `).run(candidate.examples, existing.id);
30
+ } else {
31
+ // Insert new pattern
32
+ db.prepare(`
33
+ INSERT INTO patterns (project_path, pattern_type, description, frequency, examples)
34
+ VALUES (?, ?, ?, 1, ?)
35
+ `).run(projectPath, candidate.pattern_type, candidate.description, candidate.examples);
36
+ }
37
+ }
38
+ }
39
+
40
+ function analyzeObservations(observations: Observation[], projectPath: string): PatternCandidate[] {
41
+ const patterns: PatternCandidate[] = [];
42
+ const db = getDb();
43
+
44
+ // Pattern 1: Tool usage patterns
45
+ const toolCounts = new Map<string, number>();
46
+ for (const obs of observations) {
47
+ if (obs.tool_name) {
48
+ toolCounts.set(obs.tool_name, (toolCounts.get(obs.tool_name) || 0) + 1);
49
+ }
50
+ }
51
+
52
+ const dominantTool = [...toolCounts.entries()].sort((a, b) => b[1] - a[1])[0];
53
+ if (dominantTool && dominantTool[1] >= 5) {
54
+ // Description must be stable so the (project_path, pattern_type, description)
55
+ // upsert key matches across sessions — otherwise every session with a
56
+ // different count inserts a fresh row ("Heavily uses Bash (7 times...)" vs
57
+ // "(32 times...)" both showing in the same context block).
58
+ patterns.push({
59
+ pattern_type: "tool_preference",
60
+ description: `Heavily uses ${dominantTool[0]}`,
61
+ examples: `Last session: ${dominantTool[1]}/${observations.length} actions`,
62
+ });
63
+ }
64
+
65
+ // Pattern 2: Work type patterns (from historical data)
66
+ const typeCounts = new Map<string, number>();
67
+ for (const obs of observations) {
68
+ typeCounts.set(obs.type, (typeCounts.get(obs.type) || 0) + 1);
69
+ }
70
+
71
+ const dominantType = [...typeCounts.entries()].sort((a, b) => b[1] - a[1])[0];
72
+ if (dominantType && dominantType[1] >= 3) {
73
+ patterns.push({
74
+ pattern_type: "work_focus",
75
+ description: `Sessions focused on ${dominantType[0]}`,
76
+ examples: observations.filter(o => o.type === dominantType[0]).map(o => o.summary.slice(0, 60)).slice(0, 3).join("; "),
77
+ });
78
+ }
79
+
80
+ // Pattern 3: File hotspots
81
+ const fileCounts = new Map<string, number>();
82
+ for (const obs of observations) {
83
+ if (obs.file_paths) {
84
+ for (const fp of obs.file_paths.split(",")) {
85
+ const norm = normalizePath(fp);
86
+ if (norm) {
87
+ fileCounts.set(norm, (fileCounts.get(norm) || 0) + 1);
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ const hotFiles = [...fileCounts.entries()].filter(([, c]) => c >= 3).sort((a, b) => b[1] - a[1]);
94
+ if (hotFiles.length > 0) {
95
+ patterns.push({
96
+ pattern_type: "file_hotspot",
97
+ description: `Frequently modifies: ${hotFiles.slice(0, 3).map(([f]) => f.split(/[/\\]/).pop()).join(", ")}`,
98
+ examples: hotFiles.map(([f, c]) => `${f} (${c}x)`).join("; "),
99
+ });
100
+ }
101
+
102
+ // Pattern 4: Testing pattern detection
103
+ const hasTests = observations.some(o => o.type === "test");
104
+ const hasBugFix = observations.some(o => o.type === "bug_fix");
105
+ const hasNewFeature = observations.some(o => o.type === "new_feature");
106
+
107
+ if (hasBugFix && hasTests) {
108
+ const bugFixIdx = observations.findIndex(o => o.type === "bug_fix");
109
+ const testIdx = observations.findIndex(o => o.type === "test");
110
+ if (testIdx > bugFixIdx) {
111
+ patterns.push({
112
+ pattern_type: "workflow",
113
+ description: "Writes tests after fixing bugs (fix-then-test pattern)",
114
+ examples: "",
115
+ });
116
+ } else {
117
+ patterns.push({
118
+ pattern_type: "workflow",
119
+ description: "Writes tests before fixing bugs (TDD pattern)",
120
+ examples: "",
121
+ });
122
+ }
123
+ }
124
+
125
+ // Pattern 5: Time-of-day pattern (from historical)
126
+ try {
127
+ const hourRows = db.prepare(`
128
+ SELECT CAST(strftime('%H', started_at) AS INTEGER) as hour, COUNT(*) as c
129
+ FROM sessions WHERE project_path = ?
130
+ GROUP BY hour ORDER BY c DESC LIMIT 3
131
+ `).all(projectPath) as Array<{ hour: number; c: number }>;
132
+
133
+ if (hourRows.length > 0 && hourRows[0].c >= 3) {
134
+ const peakHour = hourRows[0].hour;
135
+ const period = peakHour < 12 ? "morning" : peakHour < 18 ? "afternoon" : "evening";
136
+ patterns.push({
137
+ pattern_type: "schedule",
138
+ description: `Most active in the ${period} (around ${peakHour}:00)`,
139
+ examples: hourRows.map(h => `${h.hour}:00 (${h.c} sessions)`).join(", "),
140
+ });
141
+ }
142
+ } catch {
143
+ // Historical data not available yet
144
+ }
145
+
146
+ return patterns;
147
+ }
@@ -0,0 +1,78 @@
1
+ // Proactive suggestions: surface unfinished work and pending blockers from past sessions
2
+
3
+ import { getDb } from "../db/sqlite/schema";
4
+
5
+ export interface UnfinishedItem {
6
+ summary: string;
7
+ session_id: string;
8
+ timestamp: string;
9
+ age_days: number;
10
+ file_paths?: string;
11
+ }
12
+
13
+ export function getUnfinishedItems(projectPath: string, limit = 3): UnfinishedItem[] {
14
+ const db = getDb();
15
+
16
+ try {
17
+ // Look for blocked observations in the last 30 days for this project
18
+ const rows = db.prepare(`
19
+ SELECT o.summary, o.session_id, o.timestamp, o.file_paths
20
+ FROM observations o
21
+ JOIN sessions s ON o.session_id = s.session_id
22
+ WHERE s.project_path = ?
23
+ AND o.type = 'blocked'
24
+ AND o.timestamp >= datetime('now', '-30 days')
25
+ ORDER BY o.timestamp DESC
26
+ LIMIT ?
27
+ `).all(projectPath, limit * 2) as Array<{
28
+ summary: string;
29
+ session_id: string;
30
+ timestamp: string;
31
+ file_paths: string | null;
32
+ }>;
33
+
34
+ const items: UnfinishedItem[] = rows.map(r => ({
35
+ summary: r.summary,
36
+ session_id: r.session_id,
37
+ timestamp: r.timestamp,
38
+ age_days: ageDays(r.timestamp),
39
+ file_paths: r.file_paths || undefined,
40
+ }));
41
+
42
+ // Filter: skip items from the currently active session
43
+ const active = db.prepare(`
44
+ SELECT session_id FROM sessions
45
+ WHERE project_path = ? AND ended_at IS NULL
46
+ ORDER BY started_at DESC LIMIT 1
47
+ `).get(projectPath) as { session_id: string } | undefined;
48
+
49
+ const filtered = active
50
+ ? items.filter(i => i.session_id !== active.session_id)
51
+ : items;
52
+
53
+ // Dedup near-identical summaries
54
+ const seen = new Set<string>();
55
+ const deduped: UnfinishedItem[] = [];
56
+ for (const item of filtered) {
57
+ const key = item.summary.slice(0, 60).toLowerCase();
58
+ if (!seen.has(key)) {
59
+ seen.add(key);
60
+ deduped.push(item);
61
+ }
62
+ if (deduped.length >= limit) break;
63
+ }
64
+
65
+ return deduped;
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ function ageDays(timestamp: string): number {
72
+ try {
73
+ const diff = Date.now() - new Date(timestamp).getTime();
74
+ return Math.floor(diff / (1000 * 60 * 60 * 24));
75
+ } catch {
76
+ return 0;
77
+ }
78
+ }