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,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
|
+
}
|