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.
- package/CHANGELOG.md +62 -0
- package/CONTRIBUTING.md +80 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/SECURITY.md +81 -0
- package/package.json +55 -0
- package/scripts/install-hooks.ts +80 -0
- package/scripts/install.ps1 +71 -0
- package/scripts/install.sh +67 -0
- package/scripts/uninstall-hooks.ts +57 -0
- package/src/ai/active-guard.ts +96 -0
- package/src/ai/adaptive-ranker.ts +48 -0
- package/src/ai/classifier.ts +256 -0
- package/src/ai/compressor.ts +129 -0
- package/src/ai/decision-chains.ts +100 -0
- package/src/ai/decision-extractor.ts +148 -0
- package/src/ai/pattern-detector.ts +147 -0
- package/src/ai/proactive.ts +78 -0
- package/src/cli/doctor.ts +171 -0
- package/src/cli/embeddings.ts +209 -0
- package/src/cli/index.ts +574 -0
- package/src/cli/reclassify.ts +134 -0
- package/src/context/builder.ts +97 -0
- package/src/context/formatter.ts +109 -0
- package/src/context/ranker.ts +84 -0
- package/src/db/sqlite/decisions.ts +56 -0
- package/src/db/sqlite/feedback.ts +92 -0
- package/src/db/sqlite/observations.ts +58 -0
- package/src/db/sqlite/schema.ts +366 -0
- package/src/db/sqlite/sessions.ts +50 -0
- package/src/db/sqlite/summaries.ts +69 -0
- package/src/db/vector/client.ts +134 -0
- package/src/db/vector/embeddings.ts +119 -0
- package/src/db/vector/providers/factory.ts +99 -0
- package/src/db/vector/providers/minilm.ts +90 -0
- package/src/db/vector/providers/ollama.ts +92 -0
- package/src/db/vector/providers/tfidf.ts +98 -0
- package/src/db/vector/providers/types.ts +39 -0
- package/src/db/vector/search.ts +131 -0
- package/src/hooks/post-tool-use.ts +205 -0
- package/src/hooks/pre-tool-use.ts +305 -0
- package/src/hooks/stop.ts +334 -0
- package/src/mcp/server.ts +293 -0
- package/src/server/dashboard.html +268 -0
- package/src/server/dashboard.ts +170 -0
- package/src/util/debug.ts +56 -0
- package/src/util/ignore.ts +171 -0
- package/src/util/metrics.ts +236 -0
- package/src/util/path.ts +57 -0
- 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
|
+
}
|