codetrap 0.1.3 → 0.1.4
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/README.md +5 -1
- package/package.json +1 -1
- package/src/commands/workflow.ts +17 -64
- package/src/db/queries.ts +46 -28
- package/src/db/repository.ts +36 -16
- package/src/domain/trap.ts +7 -8
- package/src/lib/command-requests.ts +133 -0
- package/src/lib/doctor.ts +11 -1
- package/src/lib/embedding-health.ts +3 -3
- package/src/lib/embedding-index.ts +53 -0
- package/src/lib/format.ts +1 -1
- package/src/lib/output-json.ts +33 -8
- package/src/lib/scope-context.ts +6 -4
- package/src/lib/scope-maintenance.ts +71 -0
- package/src/lib/scope-migration.ts +23 -68
- package/src/lib/scope-path.ts +99 -0
- package/src/lib/scope.ts +16 -11
- package/src/lib/search-policy.ts +91 -2
- package/src/lib/search-result-card.ts +1 -7
- package/src/lib/search-service.ts +43 -34
- package/src/lib/store.ts +39 -7
- package/src/lib/trap-lifecycle.ts +37 -0
- package/src/lib/trap-operations.ts +5 -5
- package/src/mcp/server.ts +11 -24
package/README.md
CHANGED
|
@@ -13,7 +13,11 @@ AI coding agents make the same mistakes repeatedly across sessions and projects.
|
|
|
13
13
|
For detailed setup options, see [Installation](docs/installation.md). Maintainers can use the Chinese [Release Playbook](docs/release-playbook.zh-CN.md) when publishing updates.
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
# Prerequisites: Bun >= 1.x (https://bun.sh)
|
|
16
|
+
# Prerequisites: Bun >= 1.x (https://bun.sh) for npm/source installs
|
|
17
|
+
|
|
18
|
+
# npm global install (recommended)
|
|
19
|
+
npm install -g codetrap
|
|
20
|
+
codetrap --help
|
|
17
21
|
|
|
18
22
|
# Source install
|
|
19
23
|
git clone <repo-url> && cd codetrap
|
package/package.json
CHANGED
package/src/commands/workflow.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { TrapStore } from "../lib/store";
|
|
3
3
|
import { formatTrapShort, formatTrapDetails, formatTrapActionCard } from "../lib/format";
|
|
4
4
|
import type { Trap } from "../domain/trap";
|
|
5
|
-
import { SEARCH_MODES, type SearchMode } from "../lib/constants";
|
|
6
5
|
import {
|
|
7
6
|
formatScopeMigrationText,
|
|
8
7
|
runScopeMigration,
|
|
@@ -24,6 +23,13 @@ import {
|
|
|
24
23
|
type CommandResult,
|
|
25
24
|
} from "./command-result";
|
|
26
25
|
import { mutationJsonPayload } from "../lib/trap-mutation-result";
|
|
26
|
+
import {
|
|
27
|
+
embedRequestFromArgs,
|
|
28
|
+
evidenceRequestFromArgs,
|
|
29
|
+
listRequestFromArgs,
|
|
30
|
+
searchRequestFromArgs,
|
|
31
|
+
statsRequestFromArgs,
|
|
32
|
+
} from "../lib/command-requests";
|
|
27
33
|
|
|
28
34
|
type ParsedArgs = {
|
|
29
35
|
opts: Record<string, string>;
|
|
@@ -141,21 +147,8 @@ async function cmdSearch(args: string[], operations: TrapOperations): Promise<Co
|
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
try {
|
|
144
|
-
const mode = opts.mode ? parseSearchMode(opts.mode) : undefined;
|
|
145
150
|
const defaults = searchDefaultsFromConfig();
|
|
146
|
-
const cards = await operations.searchTrapCards(
|
|
147
|
-
query,
|
|
148
|
-
category: opts.category,
|
|
149
|
-
scope: opts.scope ?? defaults.scope,
|
|
150
|
-
limit: opts.limit ? parseOptionalInt(opts.limit) : defaults.limit,
|
|
151
|
-
mode: mode ?? defaults.mode,
|
|
152
|
-
status: opts.status,
|
|
153
|
-
path: opts.path,
|
|
154
|
-
module: opts.module,
|
|
155
|
-
owner: opts.owner,
|
|
156
|
-
rerank: opts["no-rerank"] !== undefined ? false : defaults.rerank,
|
|
157
|
-
includeRankingSignals: opts["ranking-signals"] !== undefined,
|
|
158
|
-
});
|
|
151
|
+
const cards = await operations.searchTrapCards(searchRequestFromArgs(query, opts, defaults));
|
|
159
152
|
if (opts.json !== undefined) return jsonResult(toCliSearchJson(cards));
|
|
160
153
|
return textResult(cards.length > 0 ? cards.map(formatTrapActionCard).join("\n\n") : "No traps found.");
|
|
161
154
|
} catch (error) {
|
|
@@ -166,15 +159,7 @@ async function cmdSearch(args: string[], operations: TrapOperations): Promise<Co
|
|
|
166
159
|
function cmdList(args: string[], operations: TrapOperations): CommandResult {
|
|
167
160
|
const { opts } = parseArgs(args);
|
|
168
161
|
try {
|
|
169
|
-
const groups = operations.listTraps(
|
|
170
|
-
category: opts.category,
|
|
171
|
-
scope: opts.scope,
|
|
172
|
-
status: opts.status,
|
|
173
|
-
path: opts.path,
|
|
174
|
-
module: opts.module,
|
|
175
|
-
owner: opts.owner,
|
|
176
|
-
limit: parseOptionalInt(opts.limit, 50),
|
|
177
|
-
});
|
|
162
|
+
const groups = operations.listTraps(listRequestFromArgs(opts));
|
|
178
163
|
if (opts.json !== undefined) return jsonResult(toListJson(groups));
|
|
179
164
|
|
|
180
165
|
const lines = groups.flatMap((group) =>
|
|
@@ -246,13 +231,7 @@ function cmdAddTrapEvidence(args: string[], operations: TrapOperations): Command
|
|
|
246
231
|
if (typeof id !== "number") return id;
|
|
247
232
|
|
|
248
233
|
try {
|
|
249
|
-
const input = opts.json ? JSON.parse(opts.json) :
|
|
250
|
-
source_type: opts.source_type ?? opts["source-type"],
|
|
251
|
-
source_ref: opts.source_ref ?? opts["source-ref"],
|
|
252
|
-
observed_at: opts.observed_at ?? opts["observed-at"],
|
|
253
|
-
related_files: parseCsv(opts.related_files ?? opts["related-files"]),
|
|
254
|
-
note: opts.note,
|
|
255
|
-
};
|
|
234
|
+
const input = opts.json ? JSON.parse(opts.json) : evidenceRequestFromArgs(opts);
|
|
256
235
|
const result = operations.addTrapEvidence(id, input, opts.scope);
|
|
257
236
|
if (opts["output-json"] !== undefined) {
|
|
258
237
|
return mutationJsonResult({ id, ...result }, `Trap #${id} not found.`);
|
|
@@ -331,8 +310,9 @@ function cmdImport(args: string[], operations: TrapOperations): CommandResult {
|
|
|
331
310
|
|
|
332
311
|
function cmdStats(args: string[], operations: TrapOperations): CommandResult {
|
|
333
312
|
const { opts } = parseArgs(args);
|
|
334
|
-
const
|
|
335
|
-
const
|
|
313
|
+
const request = statsRequestFromArgs(opts);
|
|
314
|
+
const stats = operations.getStats(request.scope);
|
|
315
|
+
const embeddingStats = operations.getEmbeddingStats(request.scope);
|
|
336
316
|
return opts.json !== undefined
|
|
337
317
|
? jsonResult(toStatsJson(stats, embeddingStats))
|
|
338
318
|
: textResult(formatStatsText(stats));
|
|
@@ -378,13 +358,7 @@ function cmdScopeMigration(
|
|
|
378
358
|
async function cmdEmbed(args: string[], store: TrapStore): Promise<CommandResult> {
|
|
379
359
|
const { opts } = parseArgs(args);
|
|
380
360
|
try {
|
|
381
|
-
const result = await store.ensureEmbeddings(
|
|
382
|
-
scope: opts.scope,
|
|
383
|
-
category: opts.category,
|
|
384
|
-
limit: opts.limit ? parseOptionalInt(opts.limit) : undefined,
|
|
385
|
-
force: opts.force === "true",
|
|
386
|
-
batchSize: opts["batch-size"] ? parseOptionalInt(opts["batch-size"]) : undefined,
|
|
387
|
-
});
|
|
361
|
+
const result = await store.ensureEmbeddings(embedRequestFromArgs(opts));
|
|
388
362
|
return textResult([
|
|
389
363
|
...result.scopes.map((scoped) =>
|
|
390
364
|
`[${scoped.scope}] embeddings generated: ${scoped.generated}, skipped: ${scoped.skipped}, batches: ${scoped.batches}`
|
|
@@ -401,7 +375,9 @@ function formatStatsText(stats: ReturnType<TrapOperations["getStats"]>): string
|
|
|
401
375
|
if (stats.project) {
|
|
402
376
|
sections.push("── Project ──", formatStatsBlock(stats.project));
|
|
403
377
|
}
|
|
404
|
-
|
|
378
|
+
if (stats.global) {
|
|
379
|
+
sections.push("── Global ──", formatStatsBlock(stats.global));
|
|
380
|
+
}
|
|
405
381
|
return sections.join("\n");
|
|
406
382
|
}
|
|
407
383
|
|
|
@@ -415,41 +391,18 @@ function formatStatsBlock(stats: { total: number; byCategory: Record<string, num
|
|
|
415
391
|
].join("\n");
|
|
416
392
|
}
|
|
417
393
|
|
|
418
|
-
function parseSearchMode(mode: string): SearchMode {
|
|
419
|
-
if ((SEARCH_MODES as readonly string[]).includes(mode)) return mode as SearchMode;
|
|
420
|
-
throw new Error(`Invalid search mode: ${mode}. Expected one of: ${SEARCH_MODES.join(", ")}`);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
394
|
function parseId(value: string | undefined, usage: string): number | CommandResult {
|
|
424
395
|
if (value === undefined) return errorResult(usage);
|
|
425
396
|
const id = Number.parseInt(value, 10);
|
|
426
397
|
return Number.isNaN(id) ? errorResult("Error: id must be a number") : id;
|
|
427
398
|
}
|
|
428
399
|
|
|
429
|
-
function parseOptionalInt(value: string | undefined, fallback?: number): number {
|
|
430
|
-
if (value === undefined) {
|
|
431
|
-
if (fallback === undefined) throw new Error("Missing numeric value.");
|
|
432
|
-
return fallback;
|
|
433
|
-
}
|
|
434
|
-
const parsed = Number.parseInt(value, 10);
|
|
435
|
-
if (Number.isNaN(parsed)) throw new Error(`Invalid number: ${value}`);
|
|
436
|
-
return parsed;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
400
|
function readQuery(positionals: string[]): string {
|
|
440
401
|
if (positionals.length > 0) return positionals.join(" ").trim();
|
|
441
402
|
if (process.stdin.isTTY) return "";
|
|
442
403
|
return readFileSync(0, "utf-8").trim();
|
|
443
404
|
}
|
|
444
405
|
|
|
445
|
-
function parseCsv(value?: string): string[] | undefined {
|
|
446
|
-
if (!value) return undefined;
|
|
447
|
-
return value
|
|
448
|
-
.split(",")
|
|
449
|
-
.map((item) => item.trim())
|
|
450
|
-
.filter(Boolean);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
406
|
function mutationJsonResult<T extends Record<string, unknown> & { success: boolean }>(
|
|
454
407
|
value: T,
|
|
455
408
|
error: string
|
package/src/db/queries.ts
CHANGED
|
@@ -180,6 +180,10 @@ export function listTrapEvidence(db: Database, trapId: number): TrapEvidence[] {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
export function archiveTrap(db: Database, id: number): boolean {
|
|
183
|
+
return markTrapArchived(db, id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function markTrapArchived(db: Database, id: number): boolean {
|
|
183
187
|
const result = db
|
|
184
188
|
.prepare(
|
|
185
189
|
`
|
|
@@ -208,33 +212,43 @@ export function supersedeTrap(
|
|
|
208
212
|
|
|
209
213
|
const key = stateKey ?? oldTrap.state_key ?? newTrap.state_key ?? `trap:${id}`;
|
|
210
214
|
const tx = db.transaction(() => {
|
|
211
|
-
db
|
|
212
|
-
|
|
213
|
-
UPDATE traps
|
|
214
|
-
SET status = 'superseded',
|
|
215
|
-
state_key = ?,
|
|
216
|
-
valid_until = COALESCE(valid_until, datetime('now')),
|
|
217
|
-
updated_at = datetime('now')
|
|
218
|
-
WHERE id = ?
|
|
219
|
-
`
|
|
220
|
-
).run(key, id);
|
|
221
|
-
db.prepare(
|
|
222
|
-
`
|
|
223
|
-
UPDATE traps
|
|
224
|
-
SET status = 'active',
|
|
225
|
-
state_key = ?,
|
|
226
|
-
supersedes_id = ?,
|
|
227
|
-
valid_from = COALESCE(valid_from, datetime('now')),
|
|
228
|
-
valid_until = NULL,
|
|
229
|
-
updated_at = datetime('now')
|
|
230
|
-
WHERE id = ?
|
|
231
|
-
`
|
|
232
|
-
).run(key, id, supersededById);
|
|
215
|
+
markTrapSuperseded(db, id, key);
|
|
216
|
+
markTrapSuperseding(db, supersededById, id, key);
|
|
233
217
|
});
|
|
234
218
|
tx();
|
|
235
219
|
return true;
|
|
236
220
|
}
|
|
237
221
|
|
|
222
|
+
export function markTrapSuperseded(db: Database, id: number, stateKey: string): boolean {
|
|
223
|
+
const result = db.prepare(supersedeTrapSql).run(stateKey, id);
|
|
224
|
+
return result.changes > 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function markTrapSuperseding(db: Database, id: number, supersedesId: number, stateKey: string): boolean {
|
|
228
|
+
const result = db.prepare(supersedingTrapSql).run(stateKey, supersedesId, id);
|
|
229
|
+
return result.changes > 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const supersedeTrapSql = `
|
|
233
|
+
UPDATE traps
|
|
234
|
+
SET status = 'superseded',
|
|
235
|
+
state_key = ?,
|
|
236
|
+
valid_until = COALESCE(valid_until, datetime('now')),
|
|
237
|
+
updated_at = datetime('now')
|
|
238
|
+
WHERE id = ?
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
const supersedingTrapSql = `
|
|
242
|
+
UPDATE traps
|
|
243
|
+
SET status = 'active',
|
|
244
|
+
state_key = ?,
|
|
245
|
+
supersedes_id = ?,
|
|
246
|
+
valid_from = COALESCE(valid_from, datetime('now')),
|
|
247
|
+
valid_until = NULL,
|
|
248
|
+
updated_at = datetime('now')
|
|
249
|
+
WHERE id = ?
|
|
250
|
+
`;
|
|
251
|
+
|
|
238
252
|
export function incrementHitCount(db: Database, id: number): void {
|
|
239
253
|
db.prepare("UPDATE traps SET hit_count = hit_count + 1, updated_at = datetime('now') WHERE id = ?").run(id);
|
|
240
254
|
}
|
|
@@ -245,18 +259,22 @@ export function getTopTraps(db: Database, scope: string, limit = 20): Trap[] {
|
|
|
245
259
|
.all(scope, limit) as Trap[];
|
|
246
260
|
}
|
|
247
261
|
|
|
248
|
-
export function getStats(db: Database): {
|
|
262
|
+
export function getStats(db: Database, opts: { scope?: string; status?: TrapStatusFilter } = {}): {
|
|
249
263
|
total: number;
|
|
250
264
|
byCategory: Record<string, number>;
|
|
251
265
|
bySeverity: Record<string, number>;
|
|
252
266
|
} {
|
|
253
|
-
const
|
|
267
|
+
const conditions: string[] = [];
|
|
268
|
+
const params: SQLQueryBindings[] = [];
|
|
269
|
+
addTrapFilters(conditions, params, opts);
|
|
270
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
271
|
+
const total = (db.query(`SELECT COUNT(*) as c FROM traps ${where}`).get(...params) as { c: number }).c;
|
|
254
272
|
const byCategory = db
|
|
255
|
-
.query(
|
|
256
|
-
.all() as { category: string; c: number }[];
|
|
273
|
+
.query(`SELECT category, COUNT(*) as c FROM traps ${where} GROUP BY category`)
|
|
274
|
+
.all(...params) as { category: string; c: number }[];
|
|
257
275
|
const bySeverity = db
|
|
258
|
-
.query(
|
|
259
|
-
.all() as { severity: string; c: number }[];
|
|
276
|
+
.query(`SELECT severity, COUNT(*) as c FROM traps ${where} GROUP BY severity`)
|
|
277
|
+
.all(...params) as { severity: string; c: number }[];
|
|
260
278
|
|
|
261
279
|
return {
|
|
262
280
|
total,
|
package/src/db/repository.ts
CHANGED
|
@@ -8,7 +8,6 @@ import type {
|
|
|
8
8
|
TrapSearchResult,
|
|
9
9
|
TrapUpdate,
|
|
10
10
|
} from "../domain/trap";
|
|
11
|
-
import * as embeddingQueries from "./embedding-queries";
|
|
12
11
|
import { SearchService, type SearchOptions } from "../lib/search-service";
|
|
13
12
|
import {
|
|
14
13
|
type EmbeddingConfig,
|
|
@@ -20,20 +19,24 @@ import { passageFieldsChanged } from "../lib/trap-search-document";
|
|
|
20
19
|
import * as queries from "./queries";
|
|
21
20
|
import type { TrapStatus } from "../lib/constants";
|
|
22
21
|
import { TrapSearchPolicy } from "../lib/search-policy";
|
|
22
|
+
import { DatabaseEmbeddingIndex } from "../lib/embedding-index";
|
|
23
|
+
import { archiveTrapLifecycle, supersedeTrapLifecycle } from "../lib/trap-lifecycle";
|
|
23
24
|
|
|
24
25
|
export type TrapStats = ReturnType<typeof queries.getStats>;
|
|
25
|
-
export type EmbeddingStateCounts = ReturnType<
|
|
26
|
+
export type EmbeddingStateCounts = ReturnType<DatabaseEmbeddingIndex["stateCounts"]>;
|
|
26
27
|
export type TrapRecordInsert = queries.TrapRecordInsert;
|
|
27
28
|
|
|
28
29
|
export class TrapRepository {
|
|
29
30
|
private readonly searchService: SearchService;
|
|
30
31
|
private readonly searchPolicy = new TrapSearchPolicy();
|
|
32
|
+
private readonly embeddingIndex: DatabaseEmbeddingIndex;
|
|
31
33
|
|
|
32
34
|
constructor(
|
|
33
35
|
private readonly db: Database,
|
|
34
36
|
private readonly embedder?: EmbeddingProvider
|
|
35
37
|
) {
|
|
36
38
|
this.searchService = new SearchService(db, embedder);
|
|
39
|
+
this.embeddingIndex = new DatabaseEmbeddingIndex(db);
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
add(input: TrapInput): number {
|
|
@@ -67,10 +70,16 @@ export class TrapRepository {
|
|
|
67
70
|
.slice(0, limit);
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
listMisScoped(expectedScope: string): Trap[] {
|
|
74
|
+
return queries
|
|
75
|
+
.listTraps(this.db, { status: "all", limit: 100000 })
|
|
76
|
+
.filter((trap) => trap.scope !== expectedScope);
|
|
77
|
+
}
|
|
78
|
+
|
|
70
79
|
update(id: number, input: TrapUpdate): boolean {
|
|
71
80
|
const success = queries.updateTrap(this.db, id, input);
|
|
72
81
|
if (success && passageFieldsChanged(input)) {
|
|
73
|
-
|
|
82
|
+
this.embeddingIndex.delete(id);
|
|
74
83
|
}
|
|
75
84
|
return success;
|
|
76
85
|
}
|
|
@@ -85,11 +94,11 @@ export class TrapRepository {
|
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
archive(id: number): boolean {
|
|
88
|
-
return
|
|
97
|
+
return archiveTrapLifecycle(this.lifecycleAdapter(), id);
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
supersede(id: number, supersededById: number, stateKey?: string): boolean {
|
|
92
|
-
return
|
|
101
|
+
return supersedeTrapLifecycle(this.lifecycleAdapter(), id, supersededById, stateKey);
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
hit(id: number): void {
|
|
@@ -100,12 +109,12 @@ export class TrapRepository {
|
|
|
100
109
|
return queries.getTopTraps(this.db, scope, limit);
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
stats(): TrapStats {
|
|
104
|
-
return queries.getStats(this.db);
|
|
112
|
+
stats(opts: { scope?: string; status?: TrapStatus | "all" } = {}): TrapStats {
|
|
113
|
+
return queries.getStats(this.db, opts);
|
|
105
114
|
}
|
|
106
115
|
|
|
107
|
-
embeddingStats(config: EmbeddingConfig | null): EmbeddingStateCounts {
|
|
108
|
-
return
|
|
116
|
+
embeddingStats(config: EmbeddingConfig | null, opts: { scope?: string; status?: TrapStatus | "all" } = {}): EmbeddingStateCounts {
|
|
117
|
+
return this.embeddingIndex.stateCounts(config, opts);
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
exportAll(): TrapExportRecord[] {
|
|
@@ -137,22 +146,22 @@ export class TrapRepository {
|
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
getEmbedding(trapId: number): StoredEmbedding | null {
|
|
140
|
-
return
|
|
149
|
+
return this.embeddingIndex.get(trapId);
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
upsertEmbedding(record: StoredEmbedding): void {
|
|
144
|
-
|
|
153
|
+
this.embeddingIndex.save(record);
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
deleteEmbedding(trapId: number): void {
|
|
148
|
-
|
|
157
|
+
this.embeddingIndex.delete(trapId);
|
|
149
158
|
}
|
|
150
159
|
|
|
151
160
|
getTrapsNeedingEmbeddings(
|
|
152
161
|
config: EmbeddingConfig,
|
|
153
162
|
opts: { scope?: string; category?: string; status?: TrapStatus | "all"; force?: boolean; limit?: number } = {}
|
|
154
163
|
): Trap[] {
|
|
155
|
-
return
|
|
164
|
+
return this.embeddingIndex.trapsNeedingEmbeddings(config, opts);
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
async ensureEmbeddings(opts: { scope?: string; category?: string; limit?: number; force?: boolean; batchSize?: number } = {}): Promise<{
|
|
@@ -166,13 +175,24 @@ export class TrapRepository {
|
|
|
166
175
|
|
|
167
176
|
return runEmbeddingJob(
|
|
168
177
|
{
|
|
169
|
-
countEmbeddable: (countOpts) =>
|
|
178
|
+
countEmbeddable: (countOpts) => this.embeddingIndex.countEmbeddable(countOpts),
|
|
170
179
|
trapsNeedingEmbeddings: (config, jobOpts) =>
|
|
171
|
-
|
|
172
|
-
saveEmbedding: (record) =>
|
|
180
|
+
this.embeddingIndex.trapsNeedingEmbeddings(config, jobOpts),
|
|
181
|
+
saveEmbedding: (record) => this.embeddingIndex.save(record),
|
|
173
182
|
},
|
|
174
183
|
this.embedder,
|
|
175
184
|
opts
|
|
176
185
|
);
|
|
177
186
|
}
|
|
187
|
+
|
|
188
|
+
private lifecycleAdapter() {
|
|
189
|
+
return {
|
|
190
|
+
get: (id: number) => queries.getTrap(this.db, id),
|
|
191
|
+
transaction: <T>(callback: () => T) => this.transaction(callback),
|
|
192
|
+
markArchived: (id: number) => queries.markTrapArchived(this.db, id),
|
|
193
|
+
markSuperseded: (id: number, stateKey: string) => queries.markTrapSuperseded(this.db, id, stateKey),
|
|
194
|
+
markSuperseding: (id: number, supersedesId: number, stateKey: string) =>
|
|
195
|
+
queries.markTrapSuperseding(this.db, id, supersedesId, stateKey),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
178
198
|
}
|
package/src/domain/trap.ts
CHANGED
|
@@ -60,10 +60,15 @@ export interface TrapSearchResult {
|
|
|
60
60
|
rank: number;
|
|
61
61
|
sources?: ("fts" | "semantic")[];
|
|
62
62
|
score?: number;
|
|
63
|
-
diagnostics?:
|
|
63
|
+
diagnostics?: TrapSearchDiagnostic[];
|
|
64
64
|
ranking_signals?: RankingSignal[];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export interface TrapSearchDiagnostic {
|
|
68
|
+
code: string;
|
|
69
|
+
message: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
export interface RankingSignal {
|
|
68
73
|
code: string;
|
|
69
74
|
weight: number;
|
|
@@ -80,14 +85,8 @@ export interface TrapActionCard {
|
|
|
80
85
|
severity: string;
|
|
81
86
|
score: number | null;
|
|
82
87
|
sources: ("fts" | "semantic")[];
|
|
88
|
+
diagnostics?: TrapSearchDiagnostic[];
|
|
83
89
|
ranking_signals?: RankingSignal[];
|
|
84
|
-
next_action: {
|
|
85
|
-
details_tool: "get_trap";
|
|
86
|
-
details_args: {
|
|
87
|
-
id: number;
|
|
88
|
-
scope: Scope;
|
|
89
|
-
};
|
|
90
|
-
};
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
export type TrapUpdate = Partial<Omit<TrapInput, "scope" | "project_path">>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { SEARCH_MODES, type SearchMode } from "./constants";
|
|
2
|
+
import type { SearchDefaults } from "./config";
|
|
3
|
+
import type { SearchTrapsArgs, ListTrapsArgs } from "./trap-operations";
|
|
4
|
+
|
|
5
|
+
type RawArgs = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
export type EmbedRequest = {
|
|
8
|
+
scope?: string;
|
|
9
|
+
category?: string;
|
|
10
|
+
limit?: number;
|
|
11
|
+
force?: boolean;
|
|
12
|
+
batchSize?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type StatsRequest = {
|
|
16
|
+
scope?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function searchRequestFromArgs(query: string, args: RawArgs, defaults: SearchDefaults): SearchTrapsArgs {
|
|
20
|
+
return {
|
|
21
|
+
query,
|
|
22
|
+
category: stringOption(args, "category"),
|
|
23
|
+
scope: stringOption(args, "scope") ?? defaults.scope,
|
|
24
|
+
limit: intOption(args, "limit", defaults.limit),
|
|
25
|
+
mode: searchModeOption(args, "mode") ?? defaults.mode,
|
|
26
|
+
status: stringOption(args, "status"),
|
|
27
|
+
path: stringOption(args, "path"),
|
|
28
|
+
module: stringOption(args, "module"),
|
|
29
|
+
owner: stringOption(args, "owner"),
|
|
30
|
+
rerank: flagPresent(args, "no-rerank") ? false : booleanOption(args, "rerank") ?? defaults.rerank,
|
|
31
|
+
includeRankingSignals: booleanOption(args, "ranking_signals", "ranking-signals") ?? false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listRequestFromArgs(args: RawArgs): ListTrapsArgs {
|
|
36
|
+
return {
|
|
37
|
+
category: stringOption(args, "category"),
|
|
38
|
+
scope: stringOption(args, "scope"),
|
|
39
|
+
status: stringOption(args, "status"),
|
|
40
|
+
path: stringOption(args, "path"),
|
|
41
|
+
module: stringOption(args, "module"),
|
|
42
|
+
owner: stringOption(args, "owner"),
|
|
43
|
+
limit: intOption(args, "limit", 50),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function statsRequestFromArgs(args: RawArgs): StatsRequest {
|
|
48
|
+
return {
|
|
49
|
+
scope: stringOption(args, "scope"),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function embedRequestFromArgs(args: RawArgs): EmbedRequest {
|
|
54
|
+
return {
|
|
55
|
+
scope: stringOption(args, "scope"),
|
|
56
|
+
category: stringOption(args, "category"),
|
|
57
|
+
limit: optionalIntOption(args, "limit"),
|
|
58
|
+
force: booleanOption(args, "force") === true,
|
|
59
|
+
batchSize: optionalIntOption(args, "batch_size", "batch-size"),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function evidenceRequestFromArgs(args: RawArgs): RawArgs {
|
|
64
|
+
return {
|
|
65
|
+
source_type: stringOption(args, "source_type", "source-type"),
|
|
66
|
+
source_ref: stringOption(args, "source_ref", "source-ref"),
|
|
67
|
+
observed_at: stringOption(args, "observed_at", "observed-at"),
|
|
68
|
+
related_files: csvOrArrayOption(args, "related_files", "related-files"),
|
|
69
|
+
note: stringOption(args, "note"),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stringOption(args: RawArgs, ...keys: string[]): string | undefined {
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const value = args[key];
|
|
76
|
+
if (typeof value === "string" && value.trim() !== "") return value;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function intOption(args: RawArgs, key: string, fallback: number): number {
|
|
82
|
+
return optionalIntOption(args, key) ?? fallback;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function optionalIntOption(args: RawArgs, ...keys: string[]): number | undefined {
|
|
86
|
+
for (const key of keys) {
|
|
87
|
+
const value = args[key];
|
|
88
|
+
if (value === undefined) continue;
|
|
89
|
+
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
90
|
+
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
|
91
|
+
throw new Error(`Invalid number: ${String(value)}`);
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function searchModeOption(args: RawArgs, key: string): SearchMode | undefined {
|
|
97
|
+
const value = stringOption(args, key);
|
|
98
|
+
if (!value) return undefined;
|
|
99
|
+
if ((SEARCH_MODES as readonly string[]).includes(value)) return value as SearchMode;
|
|
100
|
+
throw new Error(`Invalid search mode: ${value}. Expected one of: ${SEARCH_MODES.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function booleanOption(args: RawArgs, ...keys: string[]): boolean | undefined {
|
|
104
|
+
for (const key of keys) {
|
|
105
|
+
const value = args[key];
|
|
106
|
+
if (value === undefined) continue;
|
|
107
|
+
if (typeof value === "boolean") return value;
|
|
108
|
+
if (typeof value === "string") {
|
|
109
|
+
if (["true", "1", "yes", "on"].includes(value.toLowerCase())) return true;
|
|
110
|
+
if (["false", "0", "no", "off"].includes(value.toLowerCase())) return false;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`Invalid boolean: ${String(value)}`);
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function flagPresent(args: RawArgs, key: string): boolean {
|
|
118
|
+
return args[key] !== undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function csvOrArrayOption(args: RawArgs, ...keys: string[]): string[] | undefined {
|
|
122
|
+
for (const key of keys) {
|
|
123
|
+
const value = args[key];
|
|
124
|
+
if (Array.isArray(value)) return value.map(String);
|
|
125
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
126
|
+
return value
|
|
127
|
+
.split(",")
|
|
128
|
+
.map((item) => item.trim())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
package/src/lib/doctor.ts
CHANGED
|
@@ -18,6 +18,11 @@ export type DoctorReport = {
|
|
|
18
18
|
semantic_available: boolean;
|
|
19
19
|
fallback_reason: HybridFallbackReason | null;
|
|
20
20
|
};
|
|
21
|
+
diagnostics: {
|
|
22
|
+
mis_scoped_traps: {
|
|
23
|
+
global_db_project_traps: ReturnType<TrapStore["diagnostics"]>["mis_scoped_traps"]["global_db_project_traps"];
|
|
24
|
+
};
|
|
25
|
+
};
|
|
21
26
|
mcp_hint: string;
|
|
22
27
|
};
|
|
23
28
|
|
|
@@ -35,13 +40,16 @@ export function buildDoctorReport(
|
|
|
35
40
|
...scope,
|
|
36
41
|
traps: {
|
|
37
42
|
project: stats.project?.total ?? null,
|
|
38
|
-
global: stats.global
|
|
43
|
+
global: stats.global?.total ?? 0,
|
|
39
44
|
},
|
|
40
45
|
embeddings,
|
|
41
46
|
hybrid_search: {
|
|
42
47
|
semantic_available: semanticAvailable,
|
|
43
48
|
fallback_reason: hybridFallbackReason(semanticAvailable, embeddings),
|
|
44
49
|
},
|
|
50
|
+
diagnostics: {
|
|
51
|
+
mis_scoped_traps: store.diagnostics().mis_scoped_traps,
|
|
52
|
+
},
|
|
45
53
|
mcp_hint: "Pass cwd in MCP tool calls, or restart codetrap serve after changing projects.",
|
|
46
54
|
};
|
|
47
55
|
}
|
|
@@ -60,6 +68,8 @@ export function formatDoctorText(report: DoctorReport): string {
|
|
|
60
68
|
"Hybrid search:",
|
|
61
69
|
` semantic_available: ${report.hybrid_search.semantic_available ? "yes" : "no"}`,
|
|
62
70
|
` fallback_reason: ${report.hybrid_search.fallback_reason ?? "(none)"}`,
|
|
71
|
+
"Diagnostics:",
|
|
72
|
+
` global_db_project_traps: ${report.diagnostics.mis_scoped_traps.global_db_project_traps.length}`,
|
|
63
73
|
`mcp_hint: ${report.mcp_hint}`,
|
|
64
74
|
].join("\n");
|
|
65
75
|
}
|
|
@@ -17,7 +17,7 @@ export type EmbeddingStateSummary = EmbeddingStateCounts & {
|
|
|
17
17
|
|
|
18
18
|
export type EmbeddingStatsResult = {
|
|
19
19
|
project: EmbeddingStateSummary | null;
|
|
20
|
-
global: EmbeddingStateSummary;
|
|
20
|
+
global: EmbeddingStateSummary | null;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export type HybridFallbackReason = "semantic_unavailable" | "semantic_no_candidates";
|
|
@@ -42,8 +42,8 @@ export function hybridFallbackReason(
|
|
|
42
42
|
): HybridFallbackReason | null {
|
|
43
43
|
if (!providerAvailable) return "semantic_unavailable";
|
|
44
44
|
|
|
45
|
-
const fresh = (embeddings.project?.fresh ?? 0) + embeddings.global
|
|
46
|
-
const total = (embeddings.project?.total ?? 0) + embeddings.global
|
|
45
|
+
const fresh = (embeddings.project?.fresh ?? 0) + (embeddings.global?.fresh ?? 0);
|
|
46
|
+
const total = (embeddings.project?.total ?? 0) + (embeddings.global?.total ?? 0);
|
|
47
47
|
if (total > 0 && fresh === 0) return "semantic_no_candidates";
|
|
48
48
|
return null;
|
|
49
49
|
}
|