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,97 @@
|
|
|
1
|
+
// Assembles the context block from multiple data sources
|
|
2
|
+
|
|
3
|
+
import { getDb } from "../db/sqlite/schema";
|
|
4
|
+
import { getRecentSessions } from "../db/sqlite/sessions";
|
|
5
|
+
import { getRecentSummaries } from "../db/sqlite/summaries";
|
|
6
|
+
import { getDecisionsByProject } from "../db/sqlite/decisions";
|
|
7
|
+
import { generateEmbedding } from "../db/vector/embeddings";
|
|
8
|
+
import { searchSimilar } from "../db/vector/client";
|
|
9
|
+
import { formatContextBlock } from "./formatter";
|
|
10
|
+
import { normalizePath } from "../util/path";
|
|
11
|
+
|
|
12
|
+
export async function buildContext(projectPath: string, gitBranch?: string): Promise<string> {
|
|
13
|
+
const db = getDb();
|
|
14
|
+
|
|
15
|
+
// Gather data from all sources
|
|
16
|
+
const recentSessions = getRecentSessions(projectPath, 3);
|
|
17
|
+
const recentSummaries = getRecentSummaries(projectPath, 1, 3);
|
|
18
|
+
const decisions = getDecisionsByProject(projectPath);
|
|
19
|
+
|
|
20
|
+
// Semantic search for similar past work
|
|
21
|
+
let semanticSummaries: Array<{ session_id: string; text: string; level: number; project_path: string; created_at: string }> = [];
|
|
22
|
+
try {
|
|
23
|
+
const contextText = `project ${projectPath} ${gitBranch || ""} development session`;
|
|
24
|
+
const embedding = await generateEmbedding(contextText);
|
|
25
|
+
semanticSummaries = await searchSimilar(Array.from(embedding), 5, projectPath);
|
|
26
|
+
} catch {
|
|
27
|
+
// Semantic search unavailable
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Merge semantic results with recent summaries
|
|
31
|
+
const allSummaries = [...recentSummaries];
|
|
32
|
+
for (const sr of semanticSummaries) {
|
|
33
|
+
if (!allSummaries.some(s => s.session_id === sr.session_id)) {
|
|
34
|
+
allSummaries.push({
|
|
35
|
+
session_id: sr.session_id,
|
|
36
|
+
level: sr.level,
|
|
37
|
+
text: sr.text,
|
|
38
|
+
project_path: sr.project_path,
|
|
39
|
+
created_at: sr.created_at,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Hot files
|
|
45
|
+
const hotFiles = getHotFiles(db, projectPath);
|
|
46
|
+
|
|
47
|
+
// Patterns — pick the highest-frequency row per pattern_type so two rows
|
|
48
|
+
// for the same kind of insight (e.g. "Heavily uses Bash") never both appear.
|
|
49
|
+
const patterns = db.prepare(`
|
|
50
|
+
SELECT p.pattern_type, p.description
|
|
51
|
+
FROM patterns p
|
|
52
|
+
JOIN (
|
|
53
|
+
SELECT pattern_type, MAX(frequency) AS max_freq
|
|
54
|
+
FROM patterns WHERE project_path = ?
|
|
55
|
+
GROUP BY pattern_type
|
|
56
|
+
) m ON m.pattern_type = p.pattern_type AND m.max_freq = p.frequency
|
|
57
|
+
WHERE p.project_path = ?
|
|
58
|
+
ORDER BY p.frequency DESC, p.last_seen DESC
|
|
59
|
+
LIMIT 5
|
|
60
|
+
`).all(projectPath, projectPath) as Array<{ pattern_type: string; description: string }>;
|
|
61
|
+
|
|
62
|
+
return formatContextBlock({
|
|
63
|
+
projectPath,
|
|
64
|
+
gitBranch,
|
|
65
|
+
recentSessions,
|
|
66
|
+
recentSummaries: allSummaries.slice(0, 5),
|
|
67
|
+
decisions: decisions.slice(0, 5),
|
|
68
|
+
hotFiles,
|
|
69
|
+
patterns,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getHotFiles(db: any, projectPath: string): string[] {
|
|
74
|
+
try {
|
|
75
|
+
const rows = db.prepare(`
|
|
76
|
+
SELECT file_paths FROM observations o
|
|
77
|
+
JOIN sessions s ON o.session_id = s.session_id
|
|
78
|
+
WHERE s.project_path = ? AND o.file_paths IS NOT NULL AND o.file_paths != ''
|
|
79
|
+
ORDER BY o.timestamp DESC LIMIT 100
|
|
80
|
+
`).all(projectPath) as Array<{ file_paths: string }>;
|
|
81
|
+
|
|
82
|
+
const counts = new Map<string, number>();
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
for (const fp of row.file_paths.split(",")) {
|
|
85
|
+
const norm = normalizePath(fp);
|
|
86
|
+
if (norm) counts.set(norm, (counts.get(norm) || 0) + 1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return [...counts.entries()]
|
|
91
|
+
.filter(([, count]) => count >= 3)
|
|
92
|
+
.sort((a, b) => b[1] - a[1])
|
|
93
|
+
.map(([f]) => f);
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Formats the context block that gets injected into Claude's session
|
|
2
|
+
|
|
3
|
+
import type { Decision } from "../db/sqlite/decisions";
|
|
4
|
+
import type { Summary } from "../db/sqlite/summaries";
|
|
5
|
+
import type { Session } from "../db/sqlite/sessions";
|
|
6
|
+
|
|
7
|
+
interface ContextData {
|
|
8
|
+
projectPath: string;
|
|
9
|
+
gitBranch?: string;
|
|
10
|
+
recentSessions: Session[];
|
|
11
|
+
recentSummaries: Summary[];
|
|
12
|
+
decisions: Decision[];
|
|
13
|
+
hotFiles?: string[];
|
|
14
|
+
patterns?: Array<{ pattern_type: string; description: string }>;
|
|
15
|
+
unfinished?: Array<{ summary: string; age_days: number; file_paths?: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatContextBlock(data: ContextData): string {
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
|
|
21
|
+
const branch = data.gitBranch ? ` | Branch: ${data.gitBranch}` : "";
|
|
22
|
+
lines.push(`╔══════════════════════════════════════════╗`);
|
|
23
|
+
lines.push(`║ CLAUDE-CTX: SESSION CONTEXT ║`);
|
|
24
|
+
lines.push(`║ Project: ${truncPath(data.projectPath, 28)}${branch} ║`);
|
|
25
|
+
lines.push(`╚══════════════════════════════════════════╝`);
|
|
26
|
+
lines.push("");
|
|
27
|
+
|
|
28
|
+
// Proactive: unfinished items bubble to the top
|
|
29
|
+
if (data.unfinished && data.unfinished.length > 0) {
|
|
30
|
+
lines.push("▸ UNFINISHED FROM PAST SESSIONS");
|
|
31
|
+
for (const u of data.unfinished.slice(0, 3)) {
|
|
32
|
+
const age = u.age_days === 0 ? "today" : u.age_days === 1 ? "yesterday" : `${u.age_days}d ago`;
|
|
33
|
+
lines.push(` ⚠ [${age}] ${truncate(u.summary, 100)}`);
|
|
34
|
+
if (u.file_paths) {
|
|
35
|
+
const shortPaths = u.file_paths.split(",").slice(0, 2).map(p => p.trim().split(/[/\\]/).pop()).join(", ");
|
|
36
|
+
if (shortPaths) lines.push(` Files: ${shortPaths}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
lines.push("");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Decisions (always relevant, never compressed)
|
|
43
|
+
if (data.decisions.length > 0) {
|
|
44
|
+
lines.push("▸ ARCHITECTURAL DECISIONS");
|
|
45
|
+
for (const d of data.decisions.slice(0, 5)) {
|
|
46
|
+
const date = d.created_at ? formatDate(d.created_at) : "";
|
|
47
|
+
const alt = d.alternatives ? ` — rejected ${truncate(d.alternatives, 40)}` : "";
|
|
48
|
+
lines.push(` • ${d.title} [${date}]${alt}`);
|
|
49
|
+
if (d.rationale) {
|
|
50
|
+
lines.push(` Reason: ${truncate(d.rationale, 60)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
lines.push("");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Recent session summaries — compact format
|
|
57
|
+
if (data.recentSummaries.length > 0) {
|
|
58
|
+
lines.push("▸ RECENT SESSIONS");
|
|
59
|
+
for (const s of data.recentSummaries.slice(0, 3)) {
|
|
60
|
+
const date = s.created_at ? formatDate(s.created_at) : "";
|
|
61
|
+
const summaryLines = s.text.split("\n").filter(l => l.trim()).slice(0, 6);
|
|
62
|
+
lines.push(` [${date}] ${summaryLines[0] || "Session"}`);
|
|
63
|
+
for (const line of summaryLines.slice(1)) {
|
|
64
|
+
lines.push(` ${line.trim()}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
lines.push("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Hot files
|
|
71
|
+
if (data.hotFiles && data.hotFiles.length > 0) {
|
|
72
|
+
lines.push(`▸ HOT FILES (frequently modified)`);
|
|
73
|
+
lines.push(` ${data.hotFiles.slice(0, 6).join(", ")}`);
|
|
74
|
+
lines.push("");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Patterns
|
|
78
|
+
if (data.patterns && data.patterns.length > 0) {
|
|
79
|
+
lines.push("▸ YOUR PATTERNS IN THIS PROJECT");
|
|
80
|
+
for (const p of data.patterns.slice(0, 4)) {
|
|
81
|
+
lines.push(` • ${p.description}`);
|
|
82
|
+
}
|
|
83
|
+
lines.push("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function truncPath(path: string, maxLen: number): string {
|
|
90
|
+
if (path.length <= maxLen) return path;
|
|
91
|
+
const parts = path.split(/[/\\]/);
|
|
92
|
+
if (parts.length <= 2) return path.slice(-maxLen);
|
|
93
|
+
return "..." + parts.slice(-2).join("/");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function truncate(text: string, maxLen: number): string {
|
|
97
|
+
if (text.length <= maxLen) return text;
|
|
98
|
+
return text.slice(0, maxLen - 3) + "...";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatDate(dateStr: string): string {
|
|
102
|
+
try {
|
|
103
|
+
const d = new Date(dateStr);
|
|
104
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
105
|
+
return `${months[d.getMonth()]} ${d.getDate()}`;
|
|
106
|
+
} catch {
|
|
107
|
+
return dateStr.slice(0, 10);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Ranks context items by relevance to prioritize what gets injected
|
|
2
|
+
|
|
3
|
+
import type { Decision } from "../db/sqlite/decisions";
|
|
4
|
+
import type { Summary } from "../db/sqlite/summaries";
|
|
5
|
+
|
|
6
|
+
interface RankedItem {
|
|
7
|
+
type: "decision" | "summary" | "pattern";
|
|
8
|
+
text: string;
|
|
9
|
+
score: number;
|
|
10
|
+
metadata: Record<string, any>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function rankContextItems(
|
|
14
|
+
decisions: Decision[],
|
|
15
|
+
summaries: Summary[],
|
|
16
|
+
patterns: Array<{ pattern_type: string; description: string; frequency?: number }>,
|
|
17
|
+
tokenBudget = 800
|
|
18
|
+
): RankedItem[] {
|
|
19
|
+
const items: RankedItem[] = [];
|
|
20
|
+
|
|
21
|
+
// Decisions always get high priority (never compressed)
|
|
22
|
+
for (const d of decisions) {
|
|
23
|
+
const recency = getRecencyScore(d.created_at);
|
|
24
|
+
items.push({
|
|
25
|
+
type: "decision",
|
|
26
|
+
text: `${d.title}: ${d.decision_text}`,
|
|
27
|
+
score: 10 + recency, // Base 10 for decisions
|
|
28
|
+
metadata: d,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Summaries scored by recency and level
|
|
33
|
+
for (const s of summaries) {
|
|
34
|
+
const recency = getRecencyScore(s.created_at);
|
|
35
|
+
const levelBonus = s.level === 2 ? 2 : s.level === 3 ? 3 : 0;
|
|
36
|
+
items.push({
|
|
37
|
+
type: "summary",
|
|
38
|
+
text: s.text,
|
|
39
|
+
score: 5 + recency + levelBonus,
|
|
40
|
+
metadata: s,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Patterns scored by frequency
|
|
45
|
+
for (const p of patterns) {
|
|
46
|
+
items.push({
|
|
47
|
+
type: "pattern",
|
|
48
|
+
text: p.description,
|
|
49
|
+
score: 3 + Math.min(5, (p.frequency || 1) / 2),
|
|
50
|
+
metadata: p,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Sort by score descending
|
|
55
|
+
items.sort((a, b) => b.score - a.score);
|
|
56
|
+
|
|
57
|
+
// Trim to fit token budget (rough estimate: 4 chars per token)
|
|
58
|
+
let totalChars = 0;
|
|
59
|
+
const result: RankedItem[] = [];
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
const itemChars = item.text.length + 50; // 50 for formatting overhead
|
|
62
|
+
if (totalChars + itemChars > tokenBudget * 4) break;
|
|
63
|
+
totalChars += itemChars;
|
|
64
|
+
result.push(item);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getRecencyScore(dateStr?: string): number {
|
|
71
|
+
if (!dateStr) return 0;
|
|
72
|
+
try {
|
|
73
|
+
const ageMs = Date.now() - new Date(dateStr).getTime();
|
|
74
|
+
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
75
|
+
if (ageDays < 1) return 5;
|
|
76
|
+
if (ageDays < 3) return 4;
|
|
77
|
+
if (ageDays < 7) return 3;
|
|
78
|
+
if (ageDays < 14) return 2;
|
|
79
|
+
if (ageDays < 30) return 1;
|
|
80
|
+
return 0;
|
|
81
|
+
} catch {
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getDb } from "./schema";
|
|
2
|
+
|
|
3
|
+
export interface Decision {
|
|
4
|
+
id?: number;
|
|
5
|
+
project_path?: string;
|
|
6
|
+
title: string;
|
|
7
|
+
decision_text: string;
|
|
8
|
+
alternatives?: string;
|
|
9
|
+
rationale?: string;
|
|
10
|
+
files_affected?: string;
|
|
11
|
+
embedding_id?: string;
|
|
12
|
+
created_at?: string;
|
|
13
|
+
last_referenced?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function insertDecision(decision: Decision): void {
|
|
17
|
+
const db = getDb();
|
|
18
|
+
// INSERT OR IGNORE pairs with the unique index on (project_path, title)
|
|
19
|
+
// declared in schema.ts: re-extracting the same decision across sessions
|
|
20
|
+
// becomes a no-op instead of duplicating the row.
|
|
21
|
+
db.prepare(`
|
|
22
|
+
INSERT OR IGNORE INTO decisions (project_path, title, decision_text, alternatives, rationale, files_affected, embedding_id)
|
|
23
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
24
|
+
`).run(
|
|
25
|
+
decision.project_path || null,
|
|
26
|
+
decision.title,
|
|
27
|
+
decision.decision_text,
|
|
28
|
+
decision.alternatives || null,
|
|
29
|
+
decision.rationale || null,
|
|
30
|
+
decision.files_affected || null,
|
|
31
|
+
decision.embedding_id || null
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getDecisionsByProject(projectPath: string): Decision[] {
|
|
36
|
+
const db = getDb();
|
|
37
|
+
return db.prepare(`
|
|
38
|
+
SELECT * FROM decisions WHERE project_path = ? ORDER BY created_at DESC
|
|
39
|
+
`).all(projectPath) as Decision[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function searchDecisions(query: string, limit = 5): Decision[] {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
return db.prepare(`
|
|
45
|
+
SELECT d.* FROM decisions d
|
|
46
|
+
JOIN decisions_fts fts ON d.id = fts.rowid
|
|
47
|
+
WHERE decisions_fts MATCH ?
|
|
48
|
+
ORDER BY rank
|
|
49
|
+
LIMIT ?
|
|
50
|
+
`).all(query, limit) as Decision[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function touchDecision(id: number) {
|
|
54
|
+
const db = getDb();
|
|
55
|
+
db.prepare(`UPDATE decisions SET last_referenced = datetime('now') WHERE id = ?`).run(id);
|
|
56
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Stores user feedback on what autoctxd surfaced. Used by the adaptive ranker
|
|
2
|
+
// to suppress irrelevant items and amplify useful ones for this specific user.
|
|
3
|
+
|
|
4
|
+
import { getDb } from "./schema";
|
|
5
|
+
|
|
6
|
+
export type Verdict = "useful" | "irrelevant" | "wrong";
|
|
7
|
+
|
|
8
|
+
export interface Feedback {
|
|
9
|
+
id?: number;
|
|
10
|
+
target_type: "decision" | "observation" | "summary" | "pattern" | "unfinished";
|
|
11
|
+
target_id: string;
|
|
12
|
+
target_text?: string;
|
|
13
|
+
verdict: Verdict;
|
|
14
|
+
reason?: string;
|
|
15
|
+
project_path?: string;
|
|
16
|
+
session_id?: string;
|
|
17
|
+
created_at?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function recordFeedback(fb: Feedback): number {
|
|
21
|
+
const db = getDb();
|
|
22
|
+
const info = db.prepare(`
|
|
23
|
+
INSERT INTO feedback (target_type, target_id, target_text, verdict, reason, project_path, session_id)
|
|
24
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
25
|
+
`).run(
|
|
26
|
+
fb.target_type,
|
|
27
|
+
fb.target_id,
|
|
28
|
+
fb.target_text || null,
|
|
29
|
+
fb.verdict,
|
|
30
|
+
fb.reason || null,
|
|
31
|
+
fb.project_path || null,
|
|
32
|
+
fb.session_id || null
|
|
33
|
+
);
|
|
34
|
+
return Number(info.lastInsertRowid);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getFeedbackFor(targetType: string, targetId: string): Feedback[] {
|
|
38
|
+
const db = getDb();
|
|
39
|
+
return db.prepare(`
|
|
40
|
+
SELECT * FROM feedback WHERE target_type = ? AND target_id = ? ORDER BY created_at DESC
|
|
41
|
+
`).all(targetType, targetId) as Feedback[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Returns a score delta per target: negative if irrelevant/wrong, positive if useful
|
|
45
|
+
export function getScoreDeltas(projectPath?: string): Map<string, number> {
|
|
46
|
+
const db = getDb();
|
|
47
|
+
const rows = (projectPath
|
|
48
|
+
? db.prepare(`
|
|
49
|
+
SELECT target_type, target_id, verdict, COUNT(*) as c FROM feedback
|
|
50
|
+
WHERE project_path = ? GROUP BY target_type, target_id, verdict
|
|
51
|
+
`).all(projectPath)
|
|
52
|
+
: db.prepare(`
|
|
53
|
+
SELECT target_type, target_id, verdict, COUNT(*) as c FROM feedback
|
|
54
|
+
GROUP BY target_type, target_id, verdict
|
|
55
|
+
`).all()
|
|
56
|
+
) as Array<{ target_type: string; target_id: string; verdict: Verdict; c: number }>;
|
|
57
|
+
|
|
58
|
+
const deltas = new Map<string, number>();
|
|
59
|
+
for (const r of rows) {
|
|
60
|
+
const key = `${r.target_type}:${r.target_id}`;
|
|
61
|
+
const sign = r.verdict === "useful" ? 1 : r.verdict === "irrelevant" ? -2 : -3;
|
|
62
|
+
deltas.set(key, (deltas.get(key) || 0) + sign * r.c);
|
|
63
|
+
}
|
|
64
|
+
return deltas;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Aggregated stats for dashboard/telemetry
|
|
68
|
+
export function getFeedbackStats(): {
|
|
69
|
+
total: number;
|
|
70
|
+
useful: number;
|
|
71
|
+
irrelevant: number;
|
|
72
|
+
wrong: number;
|
|
73
|
+
byType: Record<string, { useful: number; irrelevant: number; wrong: number }>;
|
|
74
|
+
} {
|
|
75
|
+
const db = getDb();
|
|
76
|
+
const total = (db.prepare("SELECT COUNT(*) as c FROM feedback").get() as any).c;
|
|
77
|
+
const useful = (db.prepare("SELECT COUNT(*) as c FROM feedback WHERE verdict = 'useful'").get() as any).c;
|
|
78
|
+
const irrelevant = (db.prepare("SELECT COUNT(*) as c FROM feedback WHERE verdict = 'irrelevant'").get() as any).c;
|
|
79
|
+
const wrong = (db.prepare("SELECT COUNT(*) as c FROM feedback WHERE verdict = 'wrong'").get() as any).c;
|
|
80
|
+
|
|
81
|
+
const byTypeRows = db.prepare(`
|
|
82
|
+
SELECT target_type, verdict, COUNT(*) as c FROM feedback GROUP BY target_type, verdict
|
|
83
|
+
`).all() as Array<{ target_type: string; verdict: Verdict; c: number }>;
|
|
84
|
+
|
|
85
|
+
const byType: Record<string, { useful: number; irrelevant: number; wrong: number }> = {};
|
|
86
|
+
for (const r of byTypeRows) {
|
|
87
|
+
byType[r.target_type] ||= { useful: 0, irrelevant: 0, wrong: 0 };
|
|
88
|
+
byType[r.target_type][r.verdict] = r.c;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { total, useful, irrelevant, wrong, byType };
|
|
92
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getDb } from "./schema";
|
|
2
|
+
|
|
3
|
+
export interface Observation {
|
|
4
|
+
id?: number;
|
|
5
|
+
session_id: string;
|
|
6
|
+
type: string;
|
|
7
|
+
tool_name?: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
file_paths?: string;
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
importance_score?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function insertObservation(obs: Observation): void {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
db.prepare(`
|
|
17
|
+
INSERT INTO observations (session_id, type, tool_name, summary, file_paths, importance_score)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
19
|
+
`).run(
|
|
20
|
+
obs.session_id,
|
|
21
|
+
obs.type,
|
|
22
|
+
obs.tool_name || null,
|
|
23
|
+
obs.summary,
|
|
24
|
+
obs.file_paths || null,
|
|
25
|
+
obs.importance_score ?? 5
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getObservationsBySession(sessionId: string): Observation[] {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
return db.prepare(`
|
|
32
|
+
SELECT * FROM observations WHERE session_id = ? ORDER BY importance_score DESC, timestamp DESC
|
|
33
|
+
`).all(sessionId) as Observation[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getTopObservations(sessionId: string, limit = 5): Observation[] {
|
|
37
|
+
const db = getDb();
|
|
38
|
+
return db.prepare(`
|
|
39
|
+
SELECT * FROM observations WHERE session_id = ? ORDER BY importance_score DESC LIMIT ?
|
|
40
|
+
`).all(sessionId, limit) as Observation[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function searchObservations(query: string, limit = 10): Observation[] {
|
|
44
|
+
const db = getDb();
|
|
45
|
+
return db.prepare(`
|
|
46
|
+
SELECT o.* FROM observations o
|
|
47
|
+
JOIN observations_fts fts ON o.id = fts.rowid
|
|
48
|
+
WHERE observations_fts MATCH ?
|
|
49
|
+
ORDER BY rank
|
|
50
|
+
LIMIT ?
|
|
51
|
+
`).all(query, limit) as Observation[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function countObservationsBySession(sessionId: string): number {
|
|
55
|
+
const db = getDb();
|
|
56
|
+
const row = db.prepare(`SELECT COUNT(*) as count FROM observations WHERE session_id = ?`).get(sessionId) as any;
|
|
57
|
+
return row?.count ?? 0;
|
|
58
|
+
}
|