@winci/local-rag 0.2.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/.claude-plugin/plugin.json +24 -0
- package/.mcp.json +11 -0
- package/LICENSE +21 -0
- package/README.md +567 -0
- package/hooks/hooks.json +25 -0
- package/hooks/scripts/reindex-file.sh +19 -0
- package/hooks/scripts/session-start.sh +11 -0
- package/package.json +52 -0
- package/skills/local-rag/SKILL.md +42 -0
- package/src/cli/commands/analytics.ts +58 -0
- package/src/cli/commands/benchmark.ts +30 -0
- package/src/cli/commands/checkpoint.ts +85 -0
- package/src/cli/commands/conversation.ts +102 -0
- package/src/cli/commands/demo.ts +119 -0
- package/src/cli/commands/eval.ts +31 -0
- package/src/cli/commands/index-cmd.ts +26 -0
- package/src/cli/commands/init.ts +35 -0
- package/src/cli/commands/map.ts +21 -0
- package/src/cli/commands/remove.ts +15 -0
- package/src/cli/commands/search-cmd.ts +59 -0
- package/src/cli/commands/serve.ts +5 -0
- package/src/cli/commands/status.ts +13 -0
- package/src/cli/index.ts +117 -0
- package/src/cli/progress.ts +21 -0
- package/src/cli/setup.ts +192 -0
- package/src/config/index.ts +101 -0
- package/src/conversation/indexer.ts +147 -0
- package/src/conversation/parser.ts +323 -0
- package/src/db/analytics.ts +116 -0
- package/src/db/annotations.ts +161 -0
- package/src/db/checkpoints.ts +166 -0
- package/src/db/conversation.ts +241 -0
- package/src/db/files.ts +146 -0
- package/src/db/graph.ts +250 -0
- package/src/db/index.ts +468 -0
- package/src/db/search.ts +244 -0
- package/src/db/types.ts +85 -0
- package/src/embeddings/embed.ts +73 -0
- package/src/graph/resolver.ts +305 -0
- package/src/indexing/chunker.ts +523 -0
- package/src/indexing/indexer.ts +263 -0
- package/src/indexing/parse.ts +99 -0
- package/src/indexing/watcher.ts +84 -0
- package/src/main.ts +8 -0
- package/src/search/benchmark.ts +139 -0
- package/src/search/eval.ts +171 -0
- package/src/search/hybrid.ts +194 -0
- package/src/search/reranker.ts +99 -0
- package/src/search/usages.ts +27 -0
- package/src/server/index.ts +126 -0
- package/src/tools/analytics-tools.ts +58 -0
- package/src/tools/annotation-tools.ts +89 -0
- package/src/tools/checkpoint-tools.ts +147 -0
- package/src/tools/conversation-tools.ts +86 -0
- package/src/tools/git-tools.ts +103 -0
- package/src/tools/graph-tools.ts +163 -0
- package/src/tools/index-tools.ts +91 -0
- package/src/tools/index.ts +33 -0
- package/src/tools/search.ts +238 -0
- package/src/types.ts +9 -0
- package/src/utils/log.ts +39 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { readFileSync, statSync, openSync, readSync, closeSync } from "fs";
|
|
2
|
+
import { Glob } from "bun";
|
|
3
|
+
|
|
4
|
+
// ── JSONL entry types ──────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface JournalEntry {
|
|
7
|
+
type: "user" | "assistant" | "queue-operation" | "file-history-snapshot";
|
|
8
|
+
uuid?: string;
|
|
9
|
+
parentUuid?: string | null;
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
sessionId?: string;
|
|
12
|
+
isSidechain?: boolean;
|
|
13
|
+
requestId?: string;
|
|
14
|
+
message?: {
|
|
15
|
+
role: string;
|
|
16
|
+
content: ContentBlock[];
|
|
17
|
+
usage?: {
|
|
18
|
+
input_tokens?: number;
|
|
19
|
+
output_tokens?: number;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
toolUseResult?: {
|
|
23
|
+
type?: string;
|
|
24
|
+
filenames?: string[];
|
|
25
|
+
durationMs?: number;
|
|
26
|
+
numFiles?: number;
|
|
27
|
+
truncated?: boolean;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ContentBlock =
|
|
32
|
+
| { type: "text"; text: string }
|
|
33
|
+
| { type: "thinking"; thinking: string }
|
|
34
|
+
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
|
|
35
|
+
| { type: "tool_result"; tool_use_id: string; content: string | ContentBlock[] };
|
|
36
|
+
|
|
37
|
+
// ── Parsed turn ────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface ParsedTurn {
|
|
40
|
+
turnIndex: number;
|
|
41
|
+
timestamp: string;
|
|
42
|
+
sessionId: string;
|
|
43
|
+
userText: string;
|
|
44
|
+
assistantText: string;
|
|
45
|
+
toolResults: ToolResultInfo[];
|
|
46
|
+
toolsUsed: string[];
|
|
47
|
+
filesReferenced: string[];
|
|
48
|
+
tokenCost: number;
|
|
49
|
+
summary: string; // first 200 chars of assistant text
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ToolResultInfo {
|
|
53
|
+
toolName: string;
|
|
54
|
+
content: string;
|
|
55
|
+
durationMs?: number;
|
|
56
|
+
filenames: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Tools whose results are redundant with the code index — skip their content
|
|
60
|
+
const SKIP_CONTENT_TOOLS = new Set(["Read", "Glob", "Write", "Edit", "NotebookEdit"]);
|
|
61
|
+
|
|
62
|
+
// Maximum size for "short" tool results that are always indexed
|
|
63
|
+
const SHORT_RESULT_THRESHOLD = 500;
|
|
64
|
+
|
|
65
|
+
// ── JSONL parsing ──────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read a JSONL file from a byte offset. Returns parsed entries and
|
|
69
|
+
* the new byte offset (for incremental reads).
|
|
70
|
+
*/
|
|
71
|
+
export function readJSONL(
|
|
72
|
+
filePath: string,
|
|
73
|
+
fromOffset = 0
|
|
74
|
+
): { entries: JournalEntry[]; newOffset: number } {
|
|
75
|
+
const stat = statSync(filePath);
|
|
76
|
+
if (fromOffset >= stat.size) {
|
|
77
|
+
return { entries: [], newOffset: fromOffset };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const bytesToRead = stat.size - fromOffset;
|
|
81
|
+
const buf = Buffer.alloc(bytesToRead);
|
|
82
|
+
const fd = openSync(filePath, "r");
|
|
83
|
+
try {
|
|
84
|
+
readSync(fd, buf, 0, bytesToRead, fromOffset);
|
|
85
|
+
} finally {
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
}
|
|
88
|
+
const text = buf.toString("utf-8");
|
|
89
|
+
const entries: JournalEntry[] = [];
|
|
90
|
+
|
|
91
|
+
for (const line of text.split("\n")) {
|
|
92
|
+
const trimmed = line.trim();
|
|
93
|
+
if (!trimmed) continue;
|
|
94
|
+
try {
|
|
95
|
+
entries.push(JSON.parse(trimmed));
|
|
96
|
+
} catch {
|
|
97
|
+
// Skip malformed lines
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { entries, newOffset: stat.size };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse JSONL entries into conversation turns.
|
|
106
|
+
*
|
|
107
|
+
* A "turn" starts with a user text message and includes everything
|
|
108
|
+
* until the next user text message. Tool use/result exchanges within
|
|
109
|
+
* a turn are aggregated.
|
|
110
|
+
*/
|
|
111
|
+
export function parseTurns(
|
|
112
|
+
entries: JournalEntry[],
|
|
113
|
+
sessionId?: string,
|
|
114
|
+
startTurnIndex = 0
|
|
115
|
+
): ParsedTurn[] {
|
|
116
|
+
const turns: ParsedTurn[] = [];
|
|
117
|
+
|
|
118
|
+
// Collect only user/assistant messages (skip queue-operation, file-history-snapshot)
|
|
119
|
+
const messages = entries.filter(
|
|
120
|
+
(e) => (e.type === "user" || e.type === "assistant") && e.message
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Track the current tool_use name by tool_use_id so we can label results
|
|
124
|
+
const toolUseNames = new Map<string, string>();
|
|
125
|
+
|
|
126
|
+
let current: {
|
|
127
|
+
userText: string;
|
|
128
|
+
assistantText: string;
|
|
129
|
+
toolResults: ToolResultInfo[];
|
|
130
|
+
toolsUsed: string[];
|
|
131
|
+
filesReferenced: string[];
|
|
132
|
+
tokenCost: number;
|
|
133
|
+
timestamp: string;
|
|
134
|
+
sessionId: string;
|
|
135
|
+
} | null = null;
|
|
136
|
+
|
|
137
|
+
function flushTurn() {
|
|
138
|
+
if (!current) return;
|
|
139
|
+
// Only create a turn if there's meaningful content
|
|
140
|
+
if (!current.userText && !current.assistantText) return;
|
|
141
|
+
|
|
142
|
+
const summary = current.assistantText.slice(0, 200);
|
|
143
|
+
turns.push({
|
|
144
|
+
turnIndex: startTurnIndex + turns.length,
|
|
145
|
+
timestamp: current.timestamp,
|
|
146
|
+
sessionId: current.sessionId,
|
|
147
|
+
userText: current.userText,
|
|
148
|
+
assistantText: current.assistantText,
|
|
149
|
+
toolResults: current.toolResults,
|
|
150
|
+
toolsUsed: [...new Set(current.toolsUsed)],
|
|
151
|
+
filesReferenced: [...new Set(current.filesReferenced)],
|
|
152
|
+
tokenCost: current.tokenCost,
|
|
153
|
+
summary,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const msg of messages) {
|
|
158
|
+
const content = msg.message!.content;
|
|
159
|
+
if (!Array.isArray(content)) continue;
|
|
160
|
+
|
|
161
|
+
if (msg.type === "user") {
|
|
162
|
+
// Check if this is a real user message (has text) or a tool_result
|
|
163
|
+
const hasText = content.some(
|
|
164
|
+
(b) => b.type === "text" && typeof (b as { text: string }).text === "string"
|
|
165
|
+
);
|
|
166
|
+
const hasToolResult = content.some((b) => b.type === "tool_result");
|
|
167
|
+
|
|
168
|
+
if (hasText && !hasToolResult) {
|
|
169
|
+
// New turn boundary
|
|
170
|
+
flushTurn();
|
|
171
|
+
const textParts = content
|
|
172
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
173
|
+
.map((b) => b.text);
|
|
174
|
+
|
|
175
|
+
current = {
|
|
176
|
+
userText: textParts.join("\n"),
|
|
177
|
+
assistantText: "",
|
|
178
|
+
toolResults: [],
|
|
179
|
+
toolsUsed: [],
|
|
180
|
+
filesReferenced: [],
|
|
181
|
+
tokenCost: 0,
|
|
182
|
+
timestamp: msg.timestamp || "",
|
|
183
|
+
sessionId: sessionId || msg.sessionId || "",
|
|
184
|
+
};
|
|
185
|
+
} else if (hasToolResult && current) {
|
|
186
|
+
// Tool result — extract content selectively
|
|
187
|
+
for (const block of content) {
|
|
188
|
+
if (block.type !== "tool_result") continue;
|
|
189
|
+
|
|
190
|
+
const toolResult = block as {
|
|
191
|
+
type: "tool_result";
|
|
192
|
+
tool_use_id: string;
|
|
193
|
+
content: string | ContentBlock[];
|
|
194
|
+
};
|
|
195
|
+
const toolName = toolUseNames.get(toolResult.tool_use_id) || "unknown";
|
|
196
|
+
|
|
197
|
+
// Extract text from tool result
|
|
198
|
+
let resultText = "";
|
|
199
|
+
if (typeof toolResult.content === "string") {
|
|
200
|
+
resultText = toolResult.content;
|
|
201
|
+
} else if (Array.isArray(toolResult.content)) {
|
|
202
|
+
resultText = toolResult.content
|
|
203
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
204
|
+
.map((c) => c.text)
|
|
205
|
+
.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Collect file references from toolUseResult metadata
|
|
209
|
+
const filenames = msg.toolUseResult?.filenames || [];
|
|
210
|
+
if (filenames.length > 0) {
|
|
211
|
+
current.filesReferenced.push(...filenames);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Selective indexing: skip content for Read/Glob/Write/Edit,
|
|
215
|
+
// keep Bash/Grep output and short results
|
|
216
|
+
const shouldIndex =
|
|
217
|
+
!SKIP_CONTENT_TOOLS.has(toolName) ||
|
|
218
|
+
resultText.length <= SHORT_RESULT_THRESHOLD;
|
|
219
|
+
|
|
220
|
+
if (shouldIndex && resultText) {
|
|
221
|
+
current.toolResults.push({
|
|
222
|
+
toolName,
|
|
223
|
+
content: resultText,
|
|
224
|
+
durationMs: msg.toolUseResult?.durationMs,
|
|
225
|
+
filenames,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} else if (msg.type === "assistant" && current) {
|
|
231
|
+
for (const block of content) {
|
|
232
|
+
if (block.type === "text") {
|
|
233
|
+
const textBlock = block as { type: "text"; text: string };
|
|
234
|
+
if (current.assistantText) current.assistantText += "\n";
|
|
235
|
+
current.assistantText += textBlock.text;
|
|
236
|
+
} else if (block.type === "tool_use") {
|
|
237
|
+
const toolBlock = block as { type: "tool_use"; id: string; name: string };
|
|
238
|
+
current.toolsUsed.push(toolBlock.name);
|
|
239
|
+
toolUseNames.set(toolBlock.id, toolBlock.name);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Accumulate token cost
|
|
244
|
+
const usage = msg.message!.usage;
|
|
245
|
+
if (usage) {
|
|
246
|
+
current.tokenCost += (usage.input_tokens || 0) + (usage.output_tokens || 0);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Flush last turn
|
|
252
|
+
flushTurn();
|
|
253
|
+
|
|
254
|
+
return turns;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build the indexable text for a turn. Combines user text, assistant text,
|
|
259
|
+
* and selected tool result content.
|
|
260
|
+
*/
|
|
261
|
+
export function buildTurnText(turn: ParsedTurn): string {
|
|
262
|
+
const parts: string[] = [];
|
|
263
|
+
|
|
264
|
+
if (turn.userText) {
|
|
265
|
+
parts.push(`User: ${turn.userText}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (turn.assistantText) {
|
|
269
|
+
parts.push(`Assistant: ${turn.assistantText}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const result of turn.toolResults) {
|
|
273
|
+
parts.push(`[${result.toolName}]: ${result.content}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return parts.join("\n\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Session discovery ──────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
export interface SessionInfo {
|
|
282
|
+
sessionId: string;
|
|
283
|
+
jsonlPath: string;
|
|
284
|
+
mtime: number;
|
|
285
|
+
size: number;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Find all conversation JSONL files for a given project directory.
|
|
290
|
+
* Claude Code stores transcripts in ~/.claude/projects/<encoded-path>/.
|
|
291
|
+
*/
|
|
292
|
+
export function discoverSessions(projectDir: string): SessionInfo[] {
|
|
293
|
+
const encoded = projectDir.replace(/\//g, "-");
|
|
294
|
+
const claudeProjectDir = `${process.env.HOME}/.claude/projects/${encoded}`;
|
|
295
|
+
|
|
296
|
+
const sessions: SessionInfo[] = [];
|
|
297
|
+
const glob = new Glob("*.jsonl");
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
for (const file of glob.scanSync(claudeProjectDir)) {
|
|
301
|
+
const fullPath = `${claudeProjectDir}/${file}`;
|
|
302
|
+
const sessionId = file.replace(".jsonl", "");
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const stat = statSync(fullPath);
|
|
306
|
+
sessions.push({
|
|
307
|
+
sessionId,
|
|
308
|
+
jsonlPath: fullPath,
|
|
309
|
+
mtime: stat.mtimeMs,
|
|
310
|
+
size: stat.size,
|
|
311
|
+
});
|
|
312
|
+
} catch {
|
|
313
|
+
// Skip files we can't stat
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
// Claude project dir doesn't exist yet
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Sort by mtime descending (most recent first)
|
|
321
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
322
|
+
return sessions;
|
|
323
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
export function logQuery(db: Database, query: string, resultCount: number, topScore: number | null, topPath: string | null, durationMs: number) {
|
|
4
|
+
db.run(
|
|
5
|
+
"INSERT INTO query_log (query, result_count, top_score, top_path, duration_ms, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
6
|
+
[query, resultCount, topScore, topPath, durationMs, new Date().toISOString()]
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getAnalytics(db: Database, days: number = 30): {
|
|
11
|
+
totalQueries: number;
|
|
12
|
+
avgResultCount: number;
|
|
13
|
+
avgTopScore: number | null;
|
|
14
|
+
zeroResultQueries: { query: string; count: number }[];
|
|
15
|
+
lowScoreQueries: { query: string; topScore: number; timestamp: string }[];
|
|
16
|
+
topSearchedTerms: { query: string; count: number }[];
|
|
17
|
+
queriesPerDay: { date: string; count: number }[];
|
|
18
|
+
} {
|
|
19
|
+
const since = new Date(Date.now() - days * 86400000).toISOString();
|
|
20
|
+
|
|
21
|
+
const total = db
|
|
22
|
+
.query<{ count: number }, [string]>("SELECT COUNT(*) as count FROM query_log WHERE created_at >= ?")
|
|
23
|
+
.get(since)!;
|
|
24
|
+
|
|
25
|
+
const avgResult = db
|
|
26
|
+
.query<{ avg: number | null }, [string]>("SELECT AVG(result_count) as avg FROM query_log WHERE created_at >= ?")
|
|
27
|
+
.get(since)!;
|
|
28
|
+
|
|
29
|
+
const avgScore = db
|
|
30
|
+
.query<{ avg: number | null }, [string]>("SELECT AVG(top_score) as avg FROM query_log WHERE top_score IS NOT NULL AND created_at >= ?")
|
|
31
|
+
.get(since)!;
|
|
32
|
+
|
|
33
|
+
const zeroResult = db
|
|
34
|
+
.query<{ query: string; count: number }, [string]>(
|
|
35
|
+
"SELECT query, COUNT(*) as count FROM query_log WHERE result_count = 0 AND created_at >= ? GROUP BY query ORDER BY count DESC LIMIT 10"
|
|
36
|
+
)
|
|
37
|
+
.all(since);
|
|
38
|
+
|
|
39
|
+
const lowScore = db
|
|
40
|
+
.query<{ query: string; top_score: number; created_at: string }, [string]>(
|
|
41
|
+
"SELECT query, top_score, created_at FROM query_log WHERE top_score IS NOT NULL AND top_score < 0.3 AND created_at >= ? ORDER BY top_score ASC LIMIT 10"
|
|
42
|
+
)
|
|
43
|
+
.all(since)
|
|
44
|
+
.map((r) => ({ query: r.query, topScore: r.top_score, timestamp: r.created_at }));
|
|
45
|
+
|
|
46
|
+
const topTerms = db
|
|
47
|
+
.query<{ query: string; count: number }, [string]>(
|
|
48
|
+
"SELECT query, COUNT(*) as count FROM query_log WHERE created_at >= ? GROUP BY query ORDER BY count DESC LIMIT 10"
|
|
49
|
+
)
|
|
50
|
+
.all(since);
|
|
51
|
+
|
|
52
|
+
const perDay = db
|
|
53
|
+
.query<{ date: string; count: number }, [string]>(
|
|
54
|
+
"SELECT substr(created_at, 1, 10) as date, COUNT(*) as count FROM query_log WHERE created_at >= ? GROUP BY date ORDER BY date"
|
|
55
|
+
)
|
|
56
|
+
.all(since);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
totalQueries: total.count,
|
|
60
|
+
avgResultCount: avgResult.avg ?? 0,
|
|
61
|
+
avgTopScore: avgScore.avg,
|
|
62
|
+
zeroResultQueries: zeroResult,
|
|
63
|
+
lowScoreQueries: lowScore,
|
|
64
|
+
topSearchedTerms: topTerms,
|
|
65
|
+
queriesPerDay: perDay,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getAnalyticsTrend(db: Database, days: number = 7): {
|
|
70
|
+
current: { totalQueries: number; avgTopScore: number | null; zeroResultRate: number };
|
|
71
|
+
previous: { totalQueries: number; avgTopScore: number | null; zeroResultRate: number };
|
|
72
|
+
delta: { queries: number; avgTopScore: number | null; zeroResultRate: number };
|
|
73
|
+
} {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const currentStart = new Date(now - days * 86400000).toISOString();
|
|
76
|
+
const previousStart = new Date(now - days * 2 * 86400000).toISOString();
|
|
77
|
+
|
|
78
|
+
const getCounts = (since: string, until: string) => {
|
|
79
|
+
const total = db
|
|
80
|
+
.query<{ count: number }, [string, string]>(
|
|
81
|
+
"SELECT COUNT(*) as count FROM query_log WHERE created_at >= ? AND created_at < ?"
|
|
82
|
+
)
|
|
83
|
+
.get(since, until)!;
|
|
84
|
+
|
|
85
|
+
const avgScore = db
|
|
86
|
+
.query<{ avg: number | null }, [string, string]>(
|
|
87
|
+
"SELECT AVG(top_score) as avg FROM query_log WHERE top_score IS NOT NULL AND created_at >= ? AND created_at < ?"
|
|
88
|
+
)
|
|
89
|
+
.get(since, until)!;
|
|
90
|
+
|
|
91
|
+
const zeroCount = db
|
|
92
|
+
.query<{ count: number }, [string, string]>(
|
|
93
|
+
"SELECT COUNT(*) as count FROM query_log WHERE result_count = 0 AND created_at >= ? AND created_at < ?"
|
|
94
|
+
)
|
|
95
|
+
.get(since, until)!;
|
|
96
|
+
|
|
97
|
+
const zeroResultRate = total.count > 0 ? zeroCount.count / total.count : 0;
|
|
98
|
+
|
|
99
|
+
return { totalQueries: total.count, avgTopScore: avgScore.avg, zeroResultRate };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const farFuture = "9999-12-31T23:59:59.999Z";
|
|
103
|
+
const current = getCounts(currentStart, farFuture);
|
|
104
|
+
const previous = getCounts(previousStart, currentStart);
|
|
105
|
+
|
|
106
|
+
const delta = {
|
|
107
|
+
queries: current.totalQueries - previous.totalQueries,
|
|
108
|
+
avgTopScore:
|
|
109
|
+
current.avgTopScore !== null && previous.avgTopScore !== null
|
|
110
|
+
? current.avgTopScore - previous.avgTopScore
|
|
111
|
+
: null,
|
|
112
|
+
zeroResultRate: current.zeroResultRate - previous.zeroResultRate,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return { current, previous, delta };
|
|
116
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { type AnnotationRow } from "./types";
|
|
3
|
+
|
|
4
|
+
export function upsertAnnotation(
|
|
5
|
+
db: Database,
|
|
6
|
+
path: string,
|
|
7
|
+
note: string,
|
|
8
|
+
embedding: Float32Array,
|
|
9
|
+
symbolName?: string | null,
|
|
10
|
+
author?: string | null
|
|
11
|
+
): number {
|
|
12
|
+
let annotationId = 0;
|
|
13
|
+
|
|
14
|
+
const tx = db.transaction(() => {
|
|
15
|
+
let existing: { id: number; note: string } | null = null;
|
|
16
|
+
if (symbolName) {
|
|
17
|
+
existing = db
|
|
18
|
+
.query<{ id: number; note: string }, [string, string]>(
|
|
19
|
+
"SELECT id, note FROM annotations WHERE path = ? AND symbol_name = ?"
|
|
20
|
+
)
|
|
21
|
+
.get(path, symbolName);
|
|
22
|
+
} else {
|
|
23
|
+
existing = db
|
|
24
|
+
.query<{ id: number; note: string }, [string]>(
|
|
25
|
+
"SELECT id, note FROM annotations WHERE path = ? AND symbol_name IS NULL"
|
|
26
|
+
)
|
|
27
|
+
.get(path);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const now = new Date().toISOString();
|
|
31
|
+
|
|
32
|
+
if (existing) {
|
|
33
|
+
db.run(
|
|
34
|
+
"INSERT INTO fts_annotations(fts_annotations, rowid, note) VALUES ('delete', ?, ?)",
|
|
35
|
+
[existing.id, existing.note]
|
|
36
|
+
);
|
|
37
|
+
db.run(
|
|
38
|
+
"UPDATE annotations SET note = ?, author = ?, updated_at = ? WHERE id = ?",
|
|
39
|
+
[note, author ?? null, now, existing.id]
|
|
40
|
+
);
|
|
41
|
+
db.run("INSERT INTO fts_annotations(rowid, note) VALUES (?, ?)", [existing.id, note]);
|
|
42
|
+
db.run("DELETE FROM vec_annotations WHERE annotation_id = ?", [existing.id]);
|
|
43
|
+
db.run(
|
|
44
|
+
"INSERT INTO vec_annotations (annotation_id, embedding) VALUES (?, ?)",
|
|
45
|
+
[existing.id, new Uint8Array(embedding.buffer)]
|
|
46
|
+
);
|
|
47
|
+
annotationId = existing.id;
|
|
48
|
+
} else {
|
|
49
|
+
db.run(
|
|
50
|
+
"INSERT INTO annotations (path, symbol_name, note, author, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
51
|
+
[path, symbolName ?? null, note, author ?? null, now, now]
|
|
52
|
+
);
|
|
53
|
+
annotationId = Number(
|
|
54
|
+
db.query<{ id: number }, []>("SELECT last_insert_rowid() as id").get()!.id
|
|
55
|
+
);
|
|
56
|
+
db.run("INSERT INTO fts_annotations(rowid, note) VALUES (?, ?)", [annotationId, note]);
|
|
57
|
+
db.run(
|
|
58
|
+
"INSERT INTO vec_annotations (annotation_id, embedding) VALUES (?, ?)",
|
|
59
|
+
[annotationId, new Uint8Array(embedding.buffer)]
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
tx();
|
|
65
|
+
return annotationId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getAnnotations(db: Database, path?: string, symbolName?: string | null): AnnotationRow[] {
|
|
69
|
+
let sql = "SELECT * FROM annotations WHERE 1=1";
|
|
70
|
+
const params: (string | null)[] = [];
|
|
71
|
+
|
|
72
|
+
if (path !== undefined) {
|
|
73
|
+
sql += " AND path = ?";
|
|
74
|
+
params.push(path);
|
|
75
|
+
}
|
|
76
|
+
if (symbolName !== undefined) {
|
|
77
|
+
if (symbolName === null) {
|
|
78
|
+
sql += " AND symbol_name IS NULL";
|
|
79
|
+
} else {
|
|
80
|
+
sql += " AND symbol_name = ?";
|
|
81
|
+
params.push(symbolName);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
sql += " ORDER BY updated_at DESC";
|
|
86
|
+
|
|
87
|
+
return db
|
|
88
|
+
.query<
|
|
89
|
+
{ id: number; path: string; symbol_name: string | null; note: string; author: string | null; created_at: string; updated_at: string },
|
|
90
|
+
(string | null)[]
|
|
91
|
+
>(sql)
|
|
92
|
+
.all(...params)
|
|
93
|
+
.map((r) => ({
|
|
94
|
+
id: r.id,
|
|
95
|
+
path: r.path,
|
|
96
|
+
symbolName: r.symbol_name,
|
|
97
|
+
note: r.note,
|
|
98
|
+
author: r.author,
|
|
99
|
+
createdAt: r.created_at,
|
|
100
|
+
updatedAt: r.updated_at,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function searchAnnotations(
|
|
105
|
+
db: Database,
|
|
106
|
+
queryEmbedding: Float32Array,
|
|
107
|
+
topK: number = 10
|
|
108
|
+
): (AnnotationRow & { score: number })[] {
|
|
109
|
+
return db
|
|
110
|
+
.query<
|
|
111
|
+
{
|
|
112
|
+
annotation_id: number;
|
|
113
|
+
distance: number;
|
|
114
|
+
id: number;
|
|
115
|
+
path: string;
|
|
116
|
+
symbol_name: string | null;
|
|
117
|
+
note: string;
|
|
118
|
+
author: string | null;
|
|
119
|
+
created_at: string;
|
|
120
|
+
updated_at: string;
|
|
121
|
+
},
|
|
122
|
+
[Uint8Array, number]
|
|
123
|
+
>(
|
|
124
|
+
`SELECT v.annotation_id, v.distance,
|
|
125
|
+
a.id, a.path, a.symbol_name, a.note, a.author, a.created_at, a.updated_at
|
|
126
|
+
FROM (SELECT annotation_id, distance FROM vec_annotations WHERE embedding MATCH ? ORDER BY distance LIMIT ?) v
|
|
127
|
+
JOIN annotations a ON a.id = v.annotation_id`
|
|
128
|
+
)
|
|
129
|
+
.all(new Uint8Array(queryEmbedding.buffer), topK)
|
|
130
|
+
.map((row) => ({
|
|
131
|
+
id: row.id,
|
|
132
|
+
path: row.path,
|
|
133
|
+
symbolName: row.symbol_name,
|
|
134
|
+
note: row.note,
|
|
135
|
+
author: row.author,
|
|
136
|
+
createdAt: row.created_at,
|
|
137
|
+
updatedAt: row.updated_at,
|
|
138
|
+
score: 1 / (1 + row.distance),
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function deleteAnnotation(db: Database, id: number): boolean {
|
|
143
|
+
const existing = db
|
|
144
|
+
.query<{ id: number; note: string }, [number]>(
|
|
145
|
+
"SELECT id, note FROM annotations WHERE id = ?"
|
|
146
|
+
)
|
|
147
|
+
.get(id);
|
|
148
|
+
if (!existing) return false;
|
|
149
|
+
|
|
150
|
+
const tx = db.transaction(() => {
|
|
151
|
+
db.run(
|
|
152
|
+
"INSERT INTO fts_annotations(fts_annotations, rowid, note) VALUES ('delete', ?, ?)",
|
|
153
|
+
[id, existing.note]
|
|
154
|
+
);
|
|
155
|
+
db.run("DELETE FROM vec_annotations WHERE annotation_id = ?", [id]);
|
|
156
|
+
db.run("DELETE FROM annotations WHERE id = ?", [id]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
tx();
|
|
160
|
+
return true;
|
|
161
|
+
}
|