codetrap 0.1.0
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/.env.example +3 -0
- package/LICENSE +22 -0
- package/README.md +305 -0
- package/docs/installation.md +306 -0
- package/package.json +62 -0
- package/scripts/build-release.ts +64 -0
- package/scripts/check-release-version.ts +19 -0
- package/skills/codetrap-add/SKILL.md +65 -0
- package/skills/codetrap-check/SKILL.md +47 -0
- package/skills/codetrap-search/SKILL.md +43 -0
- package/src/commands/router.ts +407 -0
- package/src/db/connection.ts +36 -0
- package/src/db/embedding-queries.ts +154 -0
- package/src/db/queries.ts +296 -0
- package/src/db/repository.ts +141 -0
- package/src/db/schema.ts +205 -0
- package/src/domain/trap.ts +304 -0
- package/src/index.ts +58 -0
- package/src/lib/constants.ts +56 -0
- package/src/lib/embedder.ts +133 -0
- package/src/lib/embedding-job.ts +68 -0
- package/src/lib/format.ts +97 -0
- package/src/lib/fts-query.ts +17 -0
- package/src/lib/scope.ts +30 -0
- package/src/lib/search-normalizer.ts +92 -0
- package/src/lib/search-result-card.ts +38 -0
- package/src/lib/search-service.ts +189 -0
- package/src/lib/store.ts +272 -0
- package/src/lib/trap-archive.ts +91 -0
- package/src/lib/trap-json-fields.ts +42 -0
- package/src/lib/trap-operations.ts +127 -0
- package/src/lib/trap-search-document.ts +73 -0
- package/src/mcp/resources.ts +26 -0
- package/src/mcp/server.ts +167 -0
- package/src/mcp/tools.ts +106 -0
- package/src/mcp-server.ts +6 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
2
|
+
import type { Trap } from "../domain/trap";
|
|
3
|
+
import { DEFAULT_TRAP_STATUS, type TrapStatus } from "../lib/constants";
|
|
4
|
+
import {
|
|
5
|
+
decodeEmbedding,
|
|
6
|
+
encodeEmbedding,
|
|
7
|
+
type EmbeddingConfig,
|
|
8
|
+
type FreshEmbedding,
|
|
9
|
+
type StoredEmbedding,
|
|
10
|
+
} from "../lib/embedder";
|
|
11
|
+
import { embeddingIsFresh, passageHashForTrap } from "../lib/trap-search-document";
|
|
12
|
+
import { countTraps, listTraps } from "./queries";
|
|
13
|
+
|
|
14
|
+
type TrapEmbeddingRow = {
|
|
15
|
+
trap_id: number;
|
|
16
|
+
provider: string;
|
|
17
|
+
model: string;
|
|
18
|
+
dimensions: number;
|
|
19
|
+
passage_version: number;
|
|
20
|
+
passage_hash: string;
|
|
21
|
+
embedding: Uint8Array;
|
|
22
|
+
updated_at: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function getEmbedding(db: Database, trapId: number): StoredEmbedding | null {
|
|
26
|
+
const row = db
|
|
27
|
+
.query("SELECT * FROM trap_embeddings WHERE trap_id = ?")
|
|
28
|
+
.get(trapId) as TrapEmbeddingRow | null;
|
|
29
|
+
return row ? rowToStoredEmbedding(row) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function upsertEmbedding(db: Database, record: StoredEmbedding): void {
|
|
33
|
+
db.prepare(`
|
|
34
|
+
INSERT INTO trap_embeddings (
|
|
35
|
+
trap_id, provider, model, dimensions, passage_version, passage_hash, embedding, updated_at
|
|
36
|
+
)
|
|
37
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
38
|
+
ON CONFLICT(trap_id) DO UPDATE SET
|
|
39
|
+
provider = excluded.provider,
|
|
40
|
+
model = excluded.model,
|
|
41
|
+
dimensions = excluded.dimensions,
|
|
42
|
+
passage_version = excluded.passage_version,
|
|
43
|
+
passage_hash = excluded.passage_hash,
|
|
44
|
+
embedding = excluded.embedding,
|
|
45
|
+
updated_at = datetime('now')
|
|
46
|
+
`).run(
|
|
47
|
+
record.trap_id,
|
|
48
|
+
record.provider,
|
|
49
|
+
record.model,
|
|
50
|
+
record.dimensions,
|
|
51
|
+
record.passage_version,
|
|
52
|
+
record.passage_hash,
|
|
53
|
+
encodeEmbedding(record.embedding)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function deleteEmbedding(db: Database, trapId: number): void {
|
|
58
|
+
db.prepare("DELETE FROM trap_embeddings WHERE trap_id = ?").run(trapId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getAllFreshEmbeddings(
|
|
62
|
+
db: Database,
|
|
63
|
+
config: EmbeddingConfig,
|
|
64
|
+
opts: { category?: string; scope?: string; status?: TrapStatus | "all" } = {}
|
|
65
|
+
): FreshEmbedding[] {
|
|
66
|
+
const conditions = [
|
|
67
|
+
"e.provider = ?",
|
|
68
|
+
"e.model = ?",
|
|
69
|
+
"e.dimensions = ?",
|
|
70
|
+
"e.passage_version = ?",
|
|
71
|
+
];
|
|
72
|
+
const params: SQLQueryBindings[] = [
|
|
73
|
+
config.provider,
|
|
74
|
+
config.model,
|
|
75
|
+
config.dimensions,
|
|
76
|
+
config.passageVersion,
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
if (opts.category) {
|
|
80
|
+
conditions.push("t.category = ?");
|
|
81
|
+
params.push(opts.category);
|
|
82
|
+
}
|
|
83
|
+
if (opts.scope) {
|
|
84
|
+
conditions.push("t.scope = ?");
|
|
85
|
+
params.push(opts.scope);
|
|
86
|
+
}
|
|
87
|
+
if (opts.status !== "all") {
|
|
88
|
+
conditions.push("t.status = ?");
|
|
89
|
+
params.push(opts.status ?? DEFAULT_TRAP_STATUS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rows = db
|
|
93
|
+
.query(
|
|
94
|
+
`
|
|
95
|
+
SELECT
|
|
96
|
+
t.*,
|
|
97
|
+
e.embedding AS embedding,
|
|
98
|
+
e.passage_hash AS embedding_passage_hash
|
|
99
|
+
FROM trap_embeddings e
|
|
100
|
+
JOIN traps t ON t.id = e.trap_id
|
|
101
|
+
WHERE ${conditions.join(" AND ")}
|
|
102
|
+
`
|
|
103
|
+
)
|
|
104
|
+
.all(...params) as (Trap & { embedding: Uint8Array; embedding_passage_hash: string })[];
|
|
105
|
+
|
|
106
|
+
return rows.flatMap((row) => {
|
|
107
|
+
if (row.embedding_passage_hash !== passageHashForTrap(row)) return [];
|
|
108
|
+
const { embedding, embedding_passage_hash: _passageHash, ...trap } = row;
|
|
109
|
+
return [{ trap: trap as Trap, embedding: decodeEmbedding(embedding) }];
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getTrapsNeedingEmbeddings(
|
|
114
|
+
db: Database,
|
|
115
|
+
config: EmbeddingConfig,
|
|
116
|
+
opts: { scope?: string; category?: string; status?: TrapStatus | "all"; force?: boolean; limit?: number } = {}
|
|
117
|
+
): Trap[] {
|
|
118
|
+
const traps = listTraps(db, {
|
|
119
|
+
scope: opts.scope,
|
|
120
|
+
category: opts.category,
|
|
121
|
+
status: opts.status,
|
|
122
|
+
limit: 100000,
|
|
123
|
+
});
|
|
124
|
+
const needed: Trap[] = [];
|
|
125
|
+
|
|
126
|
+
for (const trap of traps) {
|
|
127
|
+
const embedding = getEmbedding(db, trap.id);
|
|
128
|
+
if (opts.force || !embeddingIsFresh(trap, embedding, config)) {
|
|
129
|
+
needed.push(trap);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return needed.slice(0, opts.limit ?? needed.length);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function countEmbeddableTraps(
|
|
137
|
+
db: Database,
|
|
138
|
+
opts: { scope?: string; category?: string; status?: TrapStatus | "all" } = {}
|
|
139
|
+
): number {
|
|
140
|
+
return countTraps(db, opts);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function rowToStoredEmbedding(row: TrapEmbeddingRow): StoredEmbedding {
|
|
144
|
+
return {
|
|
145
|
+
trap_id: row.trap_id,
|
|
146
|
+
provider: row.provider,
|
|
147
|
+
model: row.model,
|
|
148
|
+
dimensions: row.dimensions,
|
|
149
|
+
passage_version: row.passage_version,
|
|
150
|
+
passage_hash: row.passage_hash,
|
|
151
|
+
embedding: decodeEmbedding(row.embedding),
|
|
152
|
+
updated_at: row.updated_at,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
2
|
+
import { DEFAULT_SEVERITY, DEFAULT_TRAP_STATUS, type TrapStatus } from "../lib/constants";
|
|
3
|
+
import {
|
|
4
|
+
TRAP_UPDATE_FIELDS,
|
|
5
|
+
type Trap,
|
|
6
|
+
type TrapEvidence,
|
|
7
|
+
type TrapExportRecord,
|
|
8
|
+
type TrapEvidenceInput,
|
|
9
|
+
type TrapInput,
|
|
10
|
+
type TrapSearchResult,
|
|
11
|
+
type TrapUpdate,
|
|
12
|
+
} from "../domain/trap";
|
|
13
|
+
import { prepareFTSQuery } from "../lib/fts-query";
|
|
14
|
+
import { normalizeQuery } from "../lib/search-normalizer";
|
|
15
|
+
import { normalizeEvidenceForExport } from "../lib/trap-archive";
|
|
16
|
+
import { encodeEvidenceRelatedFiles, encodeTrapTags } from "../lib/trap-json-fields";
|
|
17
|
+
import { buildTrapSearchText, passageFieldsChanged, searchTextFieldsChanged } from "../lib/trap-search-document";
|
|
18
|
+
|
|
19
|
+
export type TrapStatusFilter = TrapStatus | "all";
|
|
20
|
+
|
|
21
|
+
export function insertTrap(db: Database, input: TrapInput): number {
|
|
22
|
+
const tags = encodeTrapTags(input.tags);
|
|
23
|
+
const searchText = buildTrapSearchText({ ...input, tags });
|
|
24
|
+
const stmt = db.prepare(`
|
|
25
|
+
INSERT INTO traps (
|
|
26
|
+
title, category, tags, scope, context, mistake, fix, search_text,
|
|
27
|
+
before_code, after_code, severity, state_key, status, supersedes_id,
|
|
28
|
+
valid_from, valid_until, project_path
|
|
29
|
+
)
|
|
30
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?)
|
|
31
|
+
`);
|
|
32
|
+
const result = stmt.run(
|
|
33
|
+
input.title,
|
|
34
|
+
input.category,
|
|
35
|
+
tags,
|
|
36
|
+
input.scope,
|
|
37
|
+
input.context,
|
|
38
|
+
input.mistake,
|
|
39
|
+
input.fix,
|
|
40
|
+
searchText,
|
|
41
|
+
input.before_code ?? null,
|
|
42
|
+
input.after_code ?? null,
|
|
43
|
+
input.severity ?? DEFAULT_SEVERITY,
|
|
44
|
+
null,
|
|
45
|
+
DEFAULT_TRAP_STATUS,
|
|
46
|
+
null,
|
|
47
|
+
null,
|
|
48
|
+
input.project_path ?? null
|
|
49
|
+
);
|
|
50
|
+
return Number(result.lastInsertRowid);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function searchTraps(
|
|
54
|
+
db: Database,
|
|
55
|
+
query: string,
|
|
56
|
+
opts: { category?: string; scope?: string; limit?: number; status?: TrapStatusFilter } = {}
|
|
57
|
+
): TrapSearchResult[] {
|
|
58
|
+
const prepared = prepareFTSQuery(normalizeQuery(query));
|
|
59
|
+
if (!prepared) return [];
|
|
60
|
+
|
|
61
|
+
const conditions = ["traps_fts MATCH ?"];
|
|
62
|
+
const params: SQLQueryBindings[] = [prepared];
|
|
63
|
+
|
|
64
|
+
addTrapFilters(conditions, params, opts, "t");
|
|
65
|
+
|
|
66
|
+
params.push(opts.limit ?? 20);
|
|
67
|
+
|
|
68
|
+
const rows = db
|
|
69
|
+
.query(
|
|
70
|
+
`
|
|
71
|
+
SELECT t.*, rank
|
|
72
|
+
FROM traps_fts f
|
|
73
|
+
JOIN traps t ON t.id = f.rowid
|
|
74
|
+
WHERE ${conditions.join(" AND ")}
|
|
75
|
+
ORDER BY rank
|
|
76
|
+
LIMIT ?
|
|
77
|
+
`
|
|
78
|
+
)
|
|
79
|
+
.all(...params) as (Trap & { rank: number })[];
|
|
80
|
+
return rows.map((r) => ({ trap: r, rank: r.rank }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getTrap(db: Database, id: number): Trap | null {
|
|
84
|
+
return db.query("SELECT * FROM traps WHERE id = ?").get(id) as Trap | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function listTraps(
|
|
88
|
+
db: Database,
|
|
89
|
+
opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatusFilter } = {}
|
|
90
|
+
): Trap[] {
|
|
91
|
+
const conditions: string[] = [];
|
|
92
|
+
const params: SQLQueryBindings[] = [];
|
|
93
|
+
|
|
94
|
+
addTrapFilters(conditions, params, opts);
|
|
95
|
+
|
|
96
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
97
|
+
const limit = opts.limit ?? 50;
|
|
98
|
+
const offset = opts.offset ?? 0;
|
|
99
|
+
|
|
100
|
+
return db
|
|
101
|
+
.query(`SELECT * FROM traps ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)
|
|
102
|
+
.all(...params, limit, offset) as Trap[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function updateTrap(db: Database, id: number, input: TrapUpdate): boolean {
|
|
106
|
+
const updates: string[] = [];
|
|
107
|
+
const params: SQLQueryBindings[] = [];
|
|
108
|
+
const current = searchTextFieldsChanged(input) || passageFieldsChanged(input) ? getTrap(db, id) : null;
|
|
109
|
+
|
|
110
|
+
for (const key of TRAP_UPDATE_FIELDS) {
|
|
111
|
+
if (key === "tags") continue;
|
|
112
|
+
const value = input[key];
|
|
113
|
+
if (value !== undefined) {
|
|
114
|
+
updates.push(`${key} = ?`);
|
|
115
|
+
params.push(value ?? null);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (input.tags !== undefined) {
|
|
119
|
+
updates.push("tags = ?");
|
|
120
|
+
params.push(encodeTrapTags(input.tags));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (current && searchTextFieldsChanged(input)) {
|
|
124
|
+
const merged = {
|
|
125
|
+
...current,
|
|
126
|
+
...input,
|
|
127
|
+
tags: input.tags !== undefined ? encodeTrapTags(input.tags) : current.tags,
|
|
128
|
+
};
|
|
129
|
+
updates.push("search_text = ?");
|
|
130
|
+
params.push(buildTrapSearchText(merged));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (updates.length === 0) return false;
|
|
134
|
+
|
|
135
|
+
updates.push("updated_at = datetime('now')");
|
|
136
|
+
params.push(id);
|
|
137
|
+
|
|
138
|
+
const result = db.prepare(`UPDATE traps SET ${updates.join(", ")} WHERE id = ?`).run(...params);
|
|
139
|
+
return result.changes > 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function deleteTrap(db: Database, id: number): boolean {
|
|
143
|
+
const result = db.prepare("DELETE FROM traps WHERE id = ?").run(id);
|
|
144
|
+
return result.changes > 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function addTrapEvidence(db: Database, trapId: number, input: TrapEvidenceInput): number {
|
|
148
|
+
const result = db
|
|
149
|
+
.prepare(
|
|
150
|
+
`
|
|
151
|
+
INSERT INTO trap_evidence (
|
|
152
|
+
trap_id, source_type, source_ref, observed_at, related_files, note
|
|
153
|
+
)
|
|
154
|
+
VALUES (?, ?, ?, COALESCE(?, datetime('now')), ?, ?)
|
|
155
|
+
`
|
|
156
|
+
)
|
|
157
|
+
.run(
|
|
158
|
+
trapId,
|
|
159
|
+
input.source_type,
|
|
160
|
+
input.source_ref ?? null,
|
|
161
|
+
input.observed_at ?? null,
|
|
162
|
+
encodeEvidenceRelatedFiles(input.related_files),
|
|
163
|
+
input.note ?? null
|
|
164
|
+
);
|
|
165
|
+
return Number(result.lastInsertRowid);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function listTrapEvidence(db: Database, trapId: number): TrapEvidence[] {
|
|
169
|
+
return db
|
|
170
|
+
.query("SELECT * FROM trap_evidence WHERE trap_id = ? ORDER BY observed_at DESC, id DESC")
|
|
171
|
+
.all(trapId) as TrapEvidence[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function archiveTrap(db: Database, id: number): boolean {
|
|
175
|
+
const result = db
|
|
176
|
+
.prepare(
|
|
177
|
+
`
|
|
178
|
+
UPDATE traps
|
|
179
|
+
SET status = 'archived',
|
|
180
|
+
valid_until = COALESCE(valid_until, datetime('now')),
|
|
181
|
+
updated_at = datetime('now')
|
|
182
|
+
WHERE id = ?
|
|
183
|
+
`
|
|
184
|
+
)
|
|
185
|
+
.run(id);
|
|
186
|
+
return result.changes > 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function supersedeTrap(
|
|
190
|
+
db: Database,
|
|
191
|
+
id: number,
|
|
192
|
+
supersededById: number,
|
|
193
|
+
stateKey?: string
|
|
194
|
+
): boolean {
|
|
195
|
+
if (id === supersededById) return false;
|
|
196
|
+
|
|
197
|
+
const oldTrap = getTrap(db, id);
|
|
198
|
+
const newTrap = getTrap(db, supersededById);
|
|
199
|
+
if (!oldTrap || !newTrap) return false;
|
|
200
|
+
|
|
201
|
+
const key = stateKey ?? oldTrap.state_key ?? newTrap.state_key ?? `trap:${id}`;
|
|
202
|
+
const tx = db.transaction(() => {
|
|
203
|
+
db.prepare(
|
|
204
|
+
`
|
|
205
|
+
UPDATE traps
|
|
206
|
+
SET status = 'superseded',
|
|
207
|
+
state_key = ?,
|
|
208
|
+
valid_until = COALESCE(valid_until, datetime('now')),
|
|
209
|
+
updated_at = datetime('now')
|
|
210
|
+
WHERE id = ?
|
|
211
|
+
`
|
|
212
|
+
).run(key, id);
|
|
213
|
+
db.prepare(
|
|
214
|
+
`
|
|
215
|
+
UPDATE traps
|
|
216
|
+
SET status = 'active',
|
|
217
|
+
state_key = ?,
|
|
218
|
+
supersedes_id = ?,
|
|
219
|
+
valid_from = COALESCE(valid_from, datetime('now')),
|
|
220
|
+
valid_until = NULL,
|
|
221
|
+
updated_at = datetime('now')
|
|
222
|
+
WHERE id = ?
|
|
223
|
+
`
|
|
224
|
+
).run(key, id, supersededById);
|
|
225
|
+
});
|
|
226
|
+
tx();
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function incrementHitCount(db: Database, id: number): void {
|
|
231
|
+
db.prepare("UPDATE traps SET hit_count = hit_count + 1, updated_at = datetime('now') WHERE id = ?").run(id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getTopTraps(db: Database, scope: string, limit = 20): Trap[] {
|
|
235
|
+
return db
|
|
236
|
+
.query("SELECT * FROM traps WHERE scope = ? AND status = 'active' ORDER BY hit_count DESC LIMIT ?")
|
|
237
|
+
.all(scope, limit) as Trap[];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function getStats(db: Database): {
|
|
241
|
+
total: number;
|
|
242
|
+
byCategory: Record<string, number>;
|
|
243
|
+
bySeverity: Record<string, number>;
|
|
244
|
+
} {
|
|
245
|
+
const total = (db.query("SELECT COUNT(*) as c FROM traps").get() as { c: number }).c;
|
|
246
|
+
const byCategory = db
|
|
247
|
+
.query("SELECT category, COUNT(*) as c FROM traps GROUP BY category")
|
|
248
|
+
.all() as { category: string; c: number }[];
|
|
249
|
+
const bySeverity = db
|
|
250
|
+
.query("SELECT severity, COUNT(*) as c FROM traps GROUP BY severity")
|
|
251
|
+
.all() as { severity: string; c: number }[];
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
total,
|
|
255
|
+
byCategory: Object.fromEntries(byCategory.map((r) => [r.category, r.c])),
|
|
256
|
+
bySeverity: Object.fromEntries(bySeverity.map((r) => [r.severity, r.c])),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function exportTraps(db: Database): TrapExportRecord[] {
|
|
261
|
+
const traps = db.query("SELECT * FROM traps").all() as Trap[];
|
|
262
|
+
return traps.map((trap) => ({
|
|
263
|
+
...trap,
|
|
264
|
+
evidence: listTrapEvidence(db, trap.id).map(normalizeEvidenceForExport),
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function countTraps(db: Database, opts: { scope?: string; category?: string; status?: TrapStatusFilter } = {}): number {
|
|
269
|
+
const conditions: string[] = [];
|
|
270
|
+
const params: SQLQueryBindings[] = [];
|
|
271
|
+
addTrapFilters(conditions, params, opts);
|
|
272
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
273
|
+
const row = db.query(`SELECT COUNT(*) as c FROM traps ${where}`).get(...params) as { c: number };
|
|
274
|
+
return row.c;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function addTrapFilters(
|
|
278
|
+
conditions: string[],
|
|
279
|
+
params: SQLQueryBindings[],
|
|
280
|
+
opts: { category?: string; scope?: string; status?: TrapStatusFilter },
|
|
281
|
+
alias?: string
|
|
282
|
+
): void {
|
|
283
|
+
const prefix = alias ? `${alias}.` : "";
|
|
284
|
+
if (opts.category) {
|
|
285
|
+
conditions.push(`${prefix}category = ?`);
|
|
286
|
+
params.push(opts.category);
|
|
287
|
+
}
|
|
288
|
+
if (opts.scope) {
|
|
289
|
+
conditions.push(`${prefix}scope = ?`);
|
|
290
|
+
params.push(opts.scope);
|
|
291
|
+
}
|
|
292
|
+
if (opts.status !== "all") {
|
|
293
|
+
conditions.push(`${prefix}status = ?`);
|
|
294
|
+
params.push(opts.status ?? DEFAULT_TRAP_STATUS);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type {
|
|
3
|
+
Trap,
|
|
4
|
+
TrapDetails,
|
|
5
|
+
TrapEvidenceInput,
|
|
6
|
+
TrapExportRecord,
|
|
7
|
+
TrapInput,
|
|
8
|
+
TrapSearchResult,
|
|
9
|
+
TrapUpdate,
|
|
10
|
+
} from "../domain/trap";
|
|
11
|
+
import * as embeddingQueries from "./embedding-queries";
|
|
12
|
+
import { SearchService, type SearchOptions } from "../lib/search-service";
|
|
13
|
+
import {
|
|
14
|
+
type EmbeddingConfig,
|
|
15
|
+
type EmbeddingProvider,
|
|
16
|
+
type StoredEmbedding,
|
|
17
|
+
} from "../lib/embedder";
|
|
18
|
+
import { runEmbeddingJob } from "../lib/embedding-job";
|
|
19
|
+
import { passageFieldsChanged } from "../lib/trap-search-document";
|
|
20
|
+
import * as queries from "./queries";
|
|
21
|
+
import type { TrapStatus } from "../lib/constants";
|
|
22
|
+
|
|
23
|
+
export type TrapStats = ReturnType<typeof queries.getStats>;
|
|
24
|
+
|
|
25
|
+
export class TrapRepository {
|
|
26
|
+
private readonly searchService: SearchService;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly db: Database,
|
|
30
|
+
private readonly embedder?: EmbeddingProvider
|
|
31
|
+
) {
|
|
32
|
+
this.searchService = new SearchService(db, embedder);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
add(input: TrapInput): number {
|
|
36
|
+
return queries.insertTrap(this.db, input);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
search(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
|
|
40
|
+
return this.searchService.search(query, opts);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get(id: number): Trap | null {
|
|
44
|
+
return queries.getTrap(this.db, id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getDetails(id: number, scope: "project" | "global"): TrapDetails | null {
|
|
48
|
+
const trap = queries.getTrap(this.db, id);
|
|
49
|
+
if (!trap) return null;
|
|
50
|
+
return {
|
|
51
|
+
trap,
|
|
52
|
+
evidence: queries.listTrapEvidence(this.db, id),
|
|
53
|
+
scope,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
list(opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatus | "all" } = {}): Trap[] {
|
|
58
|
+
return queries.listTraps(this.db, opts);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
update(id: number, input: TrapUpdate): boolean {
|
|
62
|
+
const success = queries.updateTrap(this.db, id, input);
|
|
63
|
+
if (success && passageFieldsChanged(input)) {
|
|
64
|
+
embeddingQueries.deleteEmbedding(this.db, id);
|
|
65
|
+
}
|
|
66
|
+
return success;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
delete(id: number): boolean {
|
|
70
|
+
return queries.deleteTrap(this.db, id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
addEvidence(trapId: number, input: TrapEvidenceInput): number | null {
|
|
74
|
+
if (!queries.getTrap(this.db, trapId)) return null;
|
|
75
|
+
return queries.addTrapEvidence(this.db, trapId, input);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
archive(id: number): boolean {
|
|
79
|
+
return queries.archiveTrap(this.db, id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
supersede(id: number, supersededById: number, stateKey?: string): boolean {
|
|
83
|
+
return queries.supersedeTrap(this.db, id, supersededById, stateKey);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
hit(id: number): void {
|
|
87
|
+
queries.incrementHitCount(this.db, id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
top(scope: string, limit = 20): Trap[] {
|
|
91
|
+
return queries.getTopTraps(this.db, scope, limit);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
stats(): TrapStats {
|
|
95
|
+
return queries.getStats(this.db);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
exportAll(): TrapExportRecord[] {
|
|
99
|
+
return queries.exportTraps(this.db);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getEmbedding(trapId: number): StoredEmbedding | null {
|
|
103
|
+
return embeddingQueries.getEmbedding(this.db, trapId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
upsertEmbedding(record: StoredEmbedding): void {
|
|
107
|
+
embeddingQueries.upsertEmbedding(this.db, record);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
deleteEmbedding(trapId: number): void {
|
|
111
|
+
embeddingQueries.deleteEmbedding(this.db, trapId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getTrapsNeedingEmbeddings(
|
|
115
|
+
config: EmbeddingConfig,
|
|
116
|
+
opts: { scope?: string; category?: string; status?: TrapStatus | "all"; force?: boolean; limit?: number } = {}
|
|
117
|
+
): Trap[] {
|
|
118
|
+
return embeddingQueries.getTrapsNeedingEmbeddings(this.db, config, opts);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async ensureEmbeddings(opts: { scope?: string; category?: string; limit?: number; force?: boolean; batchSize?: number } = {}): Promise<{
|
|
122
|
+
generated: number;
|
|
123
|
+
skipped: number;
|
|
124
|
+
batches: number;
|
|
125
|
+
}> {
|
|
126
|
+
if (!this.embedder) {
|
|
127
|
+
throw new Error("Embedding provider is unavailable. Set JINA_API_KEY to generate embeddings.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return runEmbeddingJob(
|
|
131
|
+
{
|
|
132
|
+
countEmbeddable: (countOpts) => embeddingQueries.countEmbeddableTraps(this.db, countOpts),
|
|
133
|
+
trapsNeedingEmbeddings: (config, jobOpts) =>
|
|
134
|
+
embeddingQueries.getTrapsNeedingEmbeddings(this.db, config, jobOpts),
|
|
135
|
+
saveEmbedding: (record) => embeddingQueries.upsertEmbedding(this.db, record),
|
|
136
|
+
},
|
|
137
|
+
this.embedder,
|
|
138
|
+
opts
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|