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,71 @@
1
+ # autoctxd one-command installer for Windows (PowerShell)
2
+ # irm https://raw.githubusercontent.com/autoctxd/autoctxd/main/scripts/install.ps1 | iex
3
+
4
+ $ErrorActionPreference = "Stop"
5
+
6
+ Write-Host ""
7
+ Write-Host "================================================="
8
+ Write-Host " autoctxd installer"
9
+ Write-Host "================================================="
10
+ Write-Host ""
11
+
12
+ $ClaudeDir = if ($env:CLAUDE_DIR) { $env:CLAUDE_DIR } else { Join-Path $env:USERPROFILE ".claude" }
13
+ $CtxDir = Join-Path $ClaudeDir "autoctxd"
14
+ $RepoUrl = if ($env:AUTOCTXD_REPO) { $env:AUTOCTXD_REPO } else { "https://github.com/autoctxd/autoctxd.git" }
15
+
16
+ # 1. Check Bun
17
+ if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
18
+ Write-Host "Bun not found. Installing Bun..."
19
+ irm bun.sh/install.ps1 | iex
20
+ $env:Path = "$env:USERPROFILE\.bun\bin;$env:Path"
21
+ }
22
+ Write-Host " Bun: $(bun --version)"
23
+
24
+ # 2. git check
25
+ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
26
+ Write-Error "git is required. Please install Git for Windows and re-run."
27
+ exit 1
28
+ }
29
+
30
+ # 3. Clone or update
31
+ if (-not (Test-Path $ClaudeDir)) { New-Item -ItemType Directory -Path $ClaudeDir | Out-Null }
32
+
33
+ if (Test-Path (Join-Path $CtxDir ".git")) {
34
+ Write-Host " Updating existing install at $CtxDir..."
35
+ Push-Location $CtxDir
36
+ git pull --ff-only
37
+ Pop-Location
38
+ } elseif (Test-Path $CtxDir) {
39
+ Write-Host " Found existing non-git install at $CtxDir - using as-is."
40
+ } else {
41
+ Write-Host " Cloning to $CtxDir..."
42
+ git clone $RepoUrl $CtxDir
43
+ }
44
+
45
+ Push-Location $CtxDir
46
+
47
+ # 4. deps
48
+ Write-Host " Installing dependencies..."
49
+ bun install
50
+
51
+ # 5. init DB
52
+ Write-Host " Initializing database..."
53
+ bun run src/cli/index.ts init
54
+
55
+ # 6. hooks
56
+ Write-Host " Registering hooks in $ClaudeDir\settings.json..."
57
+ bun run scripts/install-hooks.ts
58
+
59
+ # 7. doctor
60
+ Write-Host ""
61
+ Write-Host " Running doctor..."
62
+ bun run src/cli/index.ts doctor
63
+
64
+ Pop-Location
65
+
66
+ Write-Host ""
67
+ Write-Host "================================================="
68
+ Write-Host " Installed. Restart Claude Code to activate."
69
+ Write-Host " CLI: cd $CtxDir; bun run src/cli/index.ts"
70
+ Write-Host "================================================="
71
+ Write-Host ""
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # autoctxd one-command installer for macOS/Linux
3
+ # curl -fsSL https://raw.githubusercontent.com/autoctxd/autoctxd/main/scripts/install.sh | bash
4
+
5
+ set -e
6
+
7
+ echo ""
8
+ echo "================================================="
9
+ echo " autoctxd installer"
10
+ echo "================================================="
11
+ echo ""
12
+
13
+ CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}"
14
+ CTX_DIR="$CLAUDE_DIR/autoctxd"
15
+ REPO_URL="${AUTOCTXD_REPO:-https://github.com/autoctxd/autoctxd.git}"
16
+
17
+ # 1. Check Bun
18
+ if ! command -v bun >/dev/null 2>&1; then
19
+ echo "Bun not found. Installing Bun..."
20
+ curl -fsSL https://bun.sh/install | bash
21
+ export PATH="$HOME/.bun/bin:$PATH"
22
+ fi
23
+ echo " Bun: $(bun --version)"
24
+
25
+ # 2. Check git
26
+ if ! command -v git >/dev/null 2>&1; then
27
+ echo "Error: git is required. Please install git and re-run."
28
+ exit 1
29
+ fi
30
+
31
+ # 3. Clone or update
32
+ mkdir -p "$CLAUDE_DIR"
33
+ if [ -d "$CTX_DIR/.git" ]; then
34
+ echo " Updating existing install at $CTX_DIR..."
35
+ cd "$CTX_DIR" && git pull --ff-only
36
+ elif [ -d "$CTX_DIR" ] && [ ! -d "$CTX_DIR/.git" ]; then
37
+ echo " Found existing non-git install at $CTX_DIR — using as-is."
38
+ cd "$CTX_DIR"
39
+ else
40
+ echo " Cloning to $CTX_DIR..."
41
+ git clone "$REPO_URL" "$CTX_DIR"
42
+ cd "$CTX_DIR"
43
+ fi
44
+
45
+ # 4. Install deps
46
+ echo " Installing dependencies..."
47
+ bun install
48
+
49
+ # 5. Init DB
50
+ echo " Initializing database..."
51
+ bun run src/cli/index.ts init
52
+
53
+ # 6. Install hooks
54
+ echo " Registering hooks in $CLAUDE_DIR/settings.json..."
55
+ bun run scripts/install-hooks.ts
56
+
57
+ # 7. Verify
58
+ echo ""
59
+ echo " Running doctor..."
60
+ bun run src/cli/index.ts doctor
61
+
62
+ echo ""
63
+ echo "================================================="
64
+ echo " Installed. Restart Claude Code to activate."
65
+ echo " CLI: cd $CTX_DIR && bun run src/cli/index.ts"
66
+ echo "================================================="
67
+ echo ""
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bun
2
+ // Removes only autoctxd hook entries from settings.json, leaves other hooks intact.
3
+
4
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from "fs";
5
+ import { join, resolve } from "path";
6
+
7
+ const CTX_ROOT = resolve(join(import.meta.dir, ".."));
8
+ const CLAUDE_DIR = resolve(join(CTX_ROOT, ".."));
9
+ const SETTINGS = join(CLAUDE_DIR, "settings.json");
10
+
11
+ function isClaudeCtxHookEntry(entry: any): boolean {
12
+ return JSON.stringify(entry).includes("autoctxd");
13
+ }
14
+
15
+ async function main() {
16
+ if (!existsSync(SETTINGS)) {
17
+ console.log(" No settings.json found — nothing to uninstall.");
18
+ return;
19
+ }
20
+
21
+ const backup = `${SETTINGS}.backup.${Date.now()}`;
22
+ copyFileSync(SETTINGS, backup);
23
+ console.log(` Backed up → ${backup}`);
24
+
25
+ let cfg: any;
26
+ try {
27
+ cfg = JSON.parse(readFileSync(SETTINGS, "utf8"));
28
+ } catch (e) {
29
+ console.error(` Cannot parse settings.json: ${e}`);
30
+ process.exit(1);
31
+ }
32
+
33
+ let removed = 0;
34
+ for (const hookName of ["PreToolUse", "PostToolUse", "Stop"]) {
35
+ const entries: any[] = cfg.hooks?.[hookName] || [];
36
+ const kept = entries.filter(e => !isClaudeCtxHookEntry(e));
37
+ removed += entries.length - kept.length;
38
+
39
+ if (kept.length === 0) {
40
+ delete cfg.hooks?.[hookName];
41
+ } else {
42
+ cfg.hooks[hookName] = kept;
43
+ }
44
+ }
45
+
46
+ // Clean empty hooks object
47
+ if (cfg.hooks && Object.keys(cfg.hooks).length === 0) {
48
+ delete cfg.hooks;
49
+ }
50
+
51
+ writeFileSync(SETTINGS, JSON.stringify(cfg, null, 2) + "\n");
52
+ console.log(` Removed ${removed} autoctxd hook entries.`);
53
+ console.log(` Data directory preserved. To wipe memory: bun run src/cli/index.ts reset`);
54
+ console.log(` Restart Claude Code for changes to take effect.`);
55
+ }
56
+
57
+ main();
@@ -0,0 +1,96 @@
1
+ // Active Guard: the revolutionary piece. Detects when Claude's about to do
2
+ // something that contradicts a past architectural decision and surfaces a
3
+ // warning that MCP serves into Claude's context on-demand.
4
+
5
+ import { getDb } from "../db/sqlite/schema";
6
+ import { getDecisionsByProject, type Decision } from "../db/sqlite/decisions";
7
+
8
+ export interface GuardWarning {
9
+ decision: Decision;
10
+ reason: string;
11
+ confidence: number; // 0..1
12
+ }
13
+
14
+ // Given a proposed action (tool call or code snippet), return any decisions
15
+ // that the action might contradict. Used by MCP's check_intent tool.
16
+ export function checkIntent(
17
+ projectPath: string,
18
+ intent: string
19
+ ): GuardWarning[] {
20
+ const decisions = getDecisionsByProject(projectPath);
21
+ if (decisions.length === 0) return [];
22
+
23
+ const warnings: GuardWarning[] = [];
24
+ const intentLower = intent.toLowerCase();
25
+
26
+ for (const d of decisions) {
27
+ const warning = analyzeDecision(d, intent, intentLower);
28
+ if (warning) warnings.push(warning);
29
+ }
30
+
31
+ warnings.sort((a, b) => b.confidence - a.confidence);
32
+ return warnings.slice(0, 3);
33
+ }
34
+
35
+ function analyzeDecision(d: Decision, intent: string, intentLower: string): GuardWarning | null {
36
+ // Extract the rejected alternative(s)
37
+ if (d.alternatives) {
38
+ const alternatives = d.alternatives.toLowerCase().split(/[,;]/).map(s => s.trim());
39
+ for (const alt of alternatives) {
40
+ if (alt.length >= 3 && intentLower.includes(alt)) {
41
+ return {
42
+ decision: d,
43
+ reason: `Intent mentions "${alt}", which was rejected in favor of the original decision.`,
44
+ confidence: 0.85,
45
+ };
46
+ }
47
+ }
48
+ }
49
+
50
+ // Dependency decisions: if a new install matches a different package than the one chosen
51
+ const depMatch = intent.match(/\b(?:npm|bun|pnpm|yarn|pip|cargo)\s+(?:install|add|i)\s+([@\w\-/\.]+)/i);
52
+ if (depMatch && /Added .+ dep: (\S+)/i.test(d.title)) {
53
+ const chosenDep = d.title.match(/Added .+ dep: (\S+)/i)?.[1]?.toLowerCase();
54
+ const proposedDep = depMatch[1].toLowerCase();
55
+ if (chosenDep && proposedDep !== chosenDep && isSameCategory(chosenDep, proposedDep)) {
56
+ return {
57
+ decision: d,
58
+ reason: `You installed "${chosenDep}" for this role previously. Installing "${proposedDep}" may duplicate or conflict.`,
59
+ confidence: 0.7,
60
+ };
61
+ }
62
+ }
63
+
64
+ // Migration pattern: if decision said "migrated from A to B", warn on references to A
65
+ const migrMatch = d.decision_text.match(/(?:migrated|switched|moved)\s+(?:from\s+)?([\w\-/@.]+?)\s+to\s+[\w\-/@.]+/i);
66
+ if (migrMatch) {
67
+ const oldTech = migrMatch[1].toLowerCase();
68
+ if (oldTech.length >= 3 && intentLower.includes(oldTech)) {
69
+ return {
70
+ decision: d,
71
+ reason: `You migrated away from "${oldTech}" in this project. Intent mentions it — likely regression.`,
72
+ confidence: 0.75,
73
+ };
74
+ }
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ // Very rough category heuristic so we don't warn "redis vs sqlite" (different categories)
81
+ // but do warn "express vs hono" (both web frameworks)
82
+ const CATEGORIES: Record<string, string[]> = {
83
+ web_framework: ["express", "hono", "fastify", "koa", "oak", "flask", "fastapi", "gin", "axum"],
84
+ orm: ["drizzle-orm", "prisma", "typeorm", "sequelize", "sqlalchemy", "mongoose"],
85
+ validation: ["zod", "yup", "joi", "ajv", "valibot"],
86
+ testing: ["jest", "vitest", "mocha", "pytest"],
87
+ state_mgmt: ["redux", "zustand", "jotai", "valtio", "pinia", "vuex"],
88
+ db_driver: ["better-sqlite3", "pg", "mysql2", "mongodb", "redis"],
89
+ };
90
+
91
+ function isSameCategory(a: string, b: string): boolean {
92
+ for (const group of Object.values(CATEGORIES)) {
93
+ if (group.includes(a) && group.includes(b)) return true;
94
+ }
95
+ return false;
96
+ }
@@ -0,0 +1,48 @@
1
+ // Adaptive ranker: the same base ranker, but adjusts scores using accumulated
2
+ // user feedback. Items marked irrelevant get suppressed; items marked useful
3
+ // get amplified. This is what makes autoctxd learn over time.
4
+
5
+ import { getScoreDeltas } from "../db/sqlite/feedback";
6
+
7
+ export interface RankableItem {
8
+ type: "decision" | "observation" | "summary" | "pattern" | "unfinished";
9
+ id: string | number;
10
+ text: string;
11
+ baseScore: number;
12
+ }
13
+
14
+ export interface RankedItem extends RankableItem {
15
+ finalScore: number;
16
+ feedbackDelta: number;
17
+ suppressed: boolean;
18
+ }
19
+
20
+ const SUPPRESSION_THRESHOLD = -4;
21
+
22
+ export function adaptiveRank(
23
+ items: RankableItem[],
24
+ projectPath?: string
25
+ ): RankedItem[] {
26
+ const deltas = getScoreDeltas(projectPath);
27
+ const globalDeltas = projectPath ? getScoreDeltas() : new Map();
28
+
29
+ const ranked: RankedItem[] = items.map(item => {
30
+ const key = `${item.type}:${item.id}`;
31
+ const projectDelta = deltas.get(key) || 0;
32
+ const globalDelta = globalDeltas.get(key) || 0;
33
+ // Project-specific feedback weighs 2x vs cross-project
34
+ const totalDelta = projectDelta * 2 + globalDelta;
35
+ const finalScore = item.baseScore + totalDelta;
36
+
37
+ return {
38
+ ...item,
39
+ finalScore,
40
+ feedbackDelta: totalDelta,
41
+ suppressed: totalDelta <= SUPPRESSION_THRESHOLD,
42
+ };
43
+ });
44
+
45
+ return ranked
46
+ .filter(r => !r.suppressed)
47
+ .sort((a, b) => b.finalScore - a.finalScore);
48
+ }
@@ -0,0 +1,256 @@
1
+ // Heuristic classifier for observations - no API calls needed
2
+
3
+ export type ObservationType =
4
+ | "bug_fix"
5
+ | "refactor"
6
+ | "new_feature"
7
+ | "config"
8
+ | "research"
9
+ | "test"
10
+ | "decision"
11
+ | "blocked"
12
+ | "deploy"
13
+ | "other";
14
+
15
+ interface ClassificationResult {
16
+ type: ObservationType;
17
+ importance: number; // 0-10
18
+ }
19
+
20
+ const PATTERNS: Array<{ type: ObservationType; keywords: RegExp; baseImportance: number }> = [
21
+ {
22
+ type: "bug_fix",
23
+ keywords: /\b(fix|bug|error|issue|crash|broken|patch|hotfix|resolve|regression|exception|throw|catch|undefined|null\s+ref|NaN|infinite\s+loop)\b/i,
24
+ baseImportance: 7,
25
+ },
26
+ {
27
+ type: "decision",
28
+ keywords: /\b(chose|decided|picked|selected|switched\s+to|migrated?\s+to|replaced|instead\s+of|over|vs|versus|trade-?off|architecture|design\s+decision|went\s+with)\b/i,
29
+ baseImportance: 9,
30
+ },
31
+ {
32
+ type: "refactor",
33
+ keywords: /\b(refactor|rename|extract|inline|move|restructure|reorganize|clean\s*up|simplif|deduplicate|DRY|split|merge|consolidat)\b/i,
34
+ baseImportance: 5,
35
+ },
36
+ {
37
+ type: "new_feature",
38
+ keywords: /\b(add|implement|create|new\s+(feature|component|endpoint|page|route|module|service|function|class|hook)|introduce|build|scaffold)\b/i,
39
+ baseImportance: 7,
40
+ },
41
+ {
42
+ type: "test",
43
+ keywords: /\b(test|spec|assert|expect|mock|stub|coverage|jest|vitest|mocha|pytest|describe\(|it\(|should)\b/i,
44
+ baseImportance: 4,
45
+ },
46
+ {
47
+ type: "config",
48
+ keywords: /\b(config|setting|env|\.env|docker|compose|nginx|webpack|vite|tsconfig|package\.json|eslint|prettier|ci|cd|pipeline|yaml|yml|toml)\b/i,
49
+ baseImportance: 4,
50
+ },
51
+ {
52
+ type: "deploy",
53
+ keywords: /\b(deploy|release|publish|ship|production|staging|build|bundle|dist|push\s+to|merge\s+to\s+main|tag\s+v)\b/i,
54
+ baseImportance: 8,
55
+ },
56
+ {
57
+ type: "blocked",
58
+ keywords: /\b(blocked|stuck|can'?t|cannot|impossible|workaround|hack|TODO|FIXME|HACK|skip|disable|comment\s*out)\b/i,
59
+ baseImportance: 6,
60
+ },
61
+ {
62
+ type: "research",
63
+ keywords: /\b(research|investigat|explor|read|review|understand|learn|docs|documentation|stack\s*overflow|github\s+issue|RFC)\b/i,
64
+ baseImportance: 3,
65
+ },
66
+ ];
67
+
68
+ // Tool calls that are internal harness/agent plumbing, not user-driven work.
69
+ // These were leaking into the activity timeline as the dominant share of
70
+ // "other" observations. They still get persisted so the DB stays complete,
71
+ // but at importance 1 they fall out of top-observations and context injection.
72
+ const META_TOOLS = new Set([
73
+ "Monitor",
74
+ "ToolSearch",
75
+ "TaskStop",
76
+ "TaskOutput",
77
+ "TodoWrite",
78
+ ]);
79
+
80
+ // Same idea, but for cases where the hook captured the tool as Bash/PowerShell
81
+ // while the summary clearly carries the metadata payload of another tool.
82
+ const META_SUMMARY_PATTERNS: RegExp[] = [
83
+ /^Monitor:\s*\{/,
84
+ /^ToolSearch:\s*\{/,
85
+ /^TaskStop:\s*\{/,
86
+ /^TodoWrite/,
87
+ ];
88
+
89
+ export function classifyObservation(
90
+ toolName: string,
91
+ summary: string,
92
+ filePaths?: string[]
93
+ ): ClassificationResult {
94
+ // MCP external tools are never architectural decisions
95
+ if (toolName.startsWith("mcp__")) {
96
+ return { type: "other", importance: 2 };
97
+ }
98
+
99
+ // Internal tool metadata — keep the record but mark it low-importance so it
100
+ // doesn't pollute "top observations" or get injected as recovered context.
101
+ if (META_TOOLS.has(toolName) || META_SUMMARY_PATTERNS.some(p => p.test(summary))) {
102
+ return { type: "other", importance: 1 };
103
+ }
104
+
105
+ // Hard overrides that always win over keyword scoring
106
+ const override = hardOverride(toolName, summary);
107
+ if (override) {
108
+ let result = { ...override };
109
+ if (toolName === "Edit" || toolName === "Write") {
110
+ result.importance = Math.min(10, result.importance + 1);
111
+ }
112
+ return result;
113
+ }
114
+
115
+ let bestMatch: ClassificationResult = { type: "other", importance: 3 };
116
+ let highestScore = 0;
117
+
118
+ for (const pattern of PATTERNS) {
119
+ const matches = summary.match(pattern.keywords);
120
+ if (matches) {
121
+ const score = matches.length * pattern.baseImportance;
122
+ if (score > highestScore) {
123
+ highestScore = score;
124
+ bestMatch = { type: pattern.type, importance: pattern.baseImportance };
125
+ }
126
+ }
127
+ }
128
+
129
+ // Fallback heuristics by tool + file path when keywords miss
130
+ if (bestMatch.type === "other") {
131
+ const inferred = inferFromToolAndFiles(toolName, summary, filePaths);
132
+ if (inferred) bestMatch = inferred;
133
+ }
134
+
135
+ // Boost importance for certain tool types
136
+ if (toolName === "Edit" || toolName === "Write") {
137
+ bestMatch.importance = Math.min(10, bestMatch.importance + 1);
138
+ }
139
+
140
+ // Boost for critical files
141
+ if (filePaths?.some(f => /(auth|payment|security|migration|schema)/i.test(f))) {
142
+ bestMatch.importance = Math.min(10, bestMatch.importance + 2);
143
+ }
144
+
145
+ // Reduce for common/trivial files
146
+ if (filePaths?.every(f => /(readme|changelog|license|\.md$)/i.test(f))) {
147
+ bestMatch.importance = Math.max(1, bestMatch.importance - 2);
148
+ }
149
+
150
+ return bestMatch;
151
+ }
152
+
153
+ function hardOverride(toolName: string, summary: string): ClassificationResult | null {
154
+ // Package manager installs are always stack decisions — higher signal than
155
+ // generic "add" keyword that would otherwise classify as new_feature.
156
+ if (toolName === "Bash" && /\b(npm|bun|pnpm|yarn|pip|cargo|go)\s+(?:install|add|i)\b/i.test(summary)) {
157
+ return { type: "decision", importance: 8 };
158
+ }
159
+ // Spawning an Agent is exploration/research, never an architectural decision —
160
+ // even when the agent's prompt happens to contain words like "architecture"
161
+ // or "decision" that would otherwise trigger keyword scoring.
162
+ if (toolName === "Agent") {
163
+ return { type: "research", importance: 4 };
164
+ }
165
+ return null;
166
+ }
167
+
168
+ function inferFromToolAndFiles(
169
+ toolName: string,
170
+ summary: string,
171
+ filePaths?: string[]
172
+ ): ClassificationResult | null {
173
+ const files = filePaths || [];
174
+ const anyMatch = (rx: RegExp) => files.some(f => rx.test(f));
175
+
176
+ // Test files
177
+ if (anyMatch(/(\.test\.|\.spec\.|__tests__|\/tests?\/)/i)) {
178
+ return { type: "test", importance: 5 };
179
+ }
180
+
181
+ // Config files
182
+ if (anyMatch(/(package\.json|tsconfig|\.env|webpack|vite\.config|eslint|prettier|docker|compose|\.ya?ml$|\.toml$|Cargo\.toml|pyproject|go\.mod)/i)) {
183
+ return { type: "config", importance: 5 };
184
+ }
185
+
186
+ // Bash / PowerShell: detect flavor by command. (PowerShell is captured under
187
+ // the same heuristics — they all show up in the same activity stream and
188
+ // were the bulk of the legacy "other" share.)
189
+ if (toolName === "Bash" || toolName === "PowerShell") {
190
+ if (/\b(npm|bun|pip|cargo|go)\s+(install|add|i)\b/i.test(summary)) {
191
+ return { type: "decision", importance: 8 }; // dependency choice = stack decision
192
+ }
193
+ if (/\b(test|vitest|jest|pytest|go\s+test|cargo\s+test)\b/i.test(summary)) {
194
+ return { type: "test", importance: 5 };
195
+ }
196
+ if (/\b(build|bundle|compile|tsc|webpack)\b/i.test(summary)) {
197
+ return { type: "deploy", importance: 6 };
198
+ }
199
+ if (/\b(git\s+(push|tag)|deploy|publish|release)\b/i.test(summary)) {
200
+ return { type: "deploy", importance: 8 };
201
+ }
202
+ // Read-only git commands are exploration, not config or deploy
203
+ if (/\bgit\s+(status|log|diff|show|blame|branch|remote|fetch)\b/i.test(summary)) {
204
+ return { type: "research", importance: 2 };
205
+ }
206
+ // Filesystem / process inspection
207
+ if (/^(?:Bash:\s*|PowerShell:\s*)?(?:List|Check|Find|Show|View|Read|Cat|Look|Verify|Inspect)\b/i.test(summary)) {
208
+ return { type: "research", importance: 2 };
209
+ }
210
+ if (/\b(kill|pkill|ps\b|netstat|lsof|top|tasklist|taskkill)\b/i.test(summary)) {
211
+ return { type: "other", importance: 2 };
212
+ }
213
+ if (/\b(rm|del|delete|drop\s+table)\b/i.test(summary)) {
214
+ return { type: "refactor", importance: 5 };
215
+ }
216
+ }
217
+
218
+ // Write of new source file → new_feature
219
+ if (toolName === "Write" && anyMatch(/\.(ts|tsx|js|jsx|py|go|rs|java|rb|php|vue|svelte)$/i)) {
220
+ return { type: "new_feature", importance: 7 };
221
+ }
222
+
223
+ // Edit of source file → refactor by default
224
+ if (toolName === "Edit" && anyMatch(/\.(ts|tsx|js|jsx|py|go|rs|java|rb|php|vue|svelte)$/i)) {
225
+ return { type: "refactor", importance: 5 };
226
+ }
227
+
228
+ // Web research
229
+ if (toolName === "WebFetch" || toolName === "WebSearch") {
230
+ return { type: "research", importance: 3 };
231
+ }
232
+
233
+ // Agent spawn → usually exploration
234
+ if (toolName === "Agent") {
235
+ return { type: "research", importance: 4 };
236
+ }
237
+
238
+ return null;
239
+ }
240
+
241
+ export function extractFilePaths(text: string): string[] {
242
+ const patterns = [
243
+ /(?:^|\s)([a-zA-Z]:\\[\w\\.-]+)/g, // Windows absolute
244
+ /(?:^|\s)(\/[\w/.-]+\.\w+)/g, // Unix absolute
245
+ /(?:^|\s)((?:src|lib|app|pages|components|test|spec)\/[\w/.-]+)/g, // Relative common
246
+ ];
247
+
248
+ const paths = new Set<string>();
249
+ for (const pattern of patterns) {
250
+ let match;
251
+ while ((match = pattern.exec(text)) !== null) {
252
+ paths.add(match[1].trim());
253
+ }
254
+ }
255
+ return [...paths];
256
+ }