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
|
@@ -15,14 +15,8 @@ export function toTrapActionCard(result: TrapSearchResult, scope: Scope): TrapAc
|
|
|
15
15
|
severity: trap.severity,
|
|
16
16
|
score: result.score ?? null,
|
|
17
17
|
sources: result.sources ?? [],
|
|
18
|
+
...(result.diagnostics ? { diagnostics: result.diagnostics } : {}),
|
|
18
19
|
...(result.ranking_signals ? { ranking_signals: result.ranking_signals } : {}),
|
|
19
|
-
next_action: {
|
|
20
|
-
details_tool: "get_trap",
|
|
21
|
-
details_args: {
|
|
22
|
-
id: trap.id,
|
|
23
|
-
scope,
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
20
|
};
|
|
27
21
|
}
|
|
28
22
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
|
-
import * as embeddingQueries from "../db/embedding-queries";
|
|
3
2
|
import * as queries from "../db/queries";
|
|
4
3
|
import type { TrapSearchResult } from "../domain/trap";
|
|
5
4
|
import type { SearchMode, TrapStatus } from "./constants";
|
|
@@ -13,7 +12,9 @@ import {
|
|
|
13
12
|
DEFAULT_RANKING_CONFIG,
|
|
14
13
|
TrapSearchPolicy,
|
|
15
14
|
type RankingConfig,
|
|
15
|
+
type SearchRetrievalPlan,
|
|
16
16
|
} from "./search-policy";
|
|
17
|
+
import { DatabaseEmbeddingIndex } from "./embedding-index";
|
|
17
18
|
|
|
18
19
|
export interface SearchOptions {
|
|
19
20
|
category?: string;
|
|
@@ -32,6 +33,7 @@ const DEFAULT_LIMIT = 20;
|
|
|
32
33
|
|
|
33
34
|
export class SearchService {
|
|
34
35
|
private readonly policy: TrapSearchPolicy;
|
|
36
|
+
private readonly embeddingIndex: DatabaseEmbeddingIndex;
|
|
35
37
|
|
|
36
38
|
constructor(
|
|
37
39
|
private readonly db: Database,
|
|
@@ -39,6 +41,7 @@ export class SearchService {
|
|
|
39
41
|
ranking: RankingConfig = DEFAULT_RANKING_CONFIG
|
|
40
42
|
) {
|
|
41
43
|
this.policy = new TrapSearchPolicy(ranking);
|
|
44
|
+
this.embeddingIndex = new DatabaseEmbeddingIndex(db);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
async search(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
|
|
@@ -58,20 +61,51 @@ export class SearchService {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
ftsSearch(query: string, opts: SearchOptions = {}): TrapSearchResult[] {
|
|
61
|
-
const
|
|
62
|
-
|
|
64
|
+
const plan = this.policy.plan(opts, DEFAULT_LIMIT);
|
|
65
|
+
return this.policy.finalizeResults(this.retrieveFtsCandidates(query, plan), query, opts, plan);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async semanticSearch(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
|
|
69
|
+
const plan = this.policy.plan(opts, DEFAULT_LIMIT);
|
|
70
|
+
return this.policy.finalizeResults(await this.retrieveSemanticCandidates(query, plan), query, opts, plan);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async hybridSearch(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
|
|
74
|
+
const plan = this.policy.plan(opts, DEFAULT_LIMIT);
|
|
75
|
+
const ftsCandidates = this.retrieveFtsCandidates(query, plan);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const semanticCandidates = await this.retrieveSemanticCandidates(query, plan);
|
|
79
|
+
if (semanticCandidates.length === 0) {
|
|
80
|
+
return this.policy.withDiagnostics(this.policy.finalizeResults(ftsCandidates, query, opts, plan), {
|
|
81
|
+
code: "semantic_no_candidates",
|
|
82
|
+
message: "Hybrid search used FTS results because no fresh semantic candidates passed the score threshold.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return this.policy.fuseAndFinalize(ftsCandidates, semanticCandidates, query, opts, plan);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return this.policy.withDiagnostics(
|
|
88
|
+
this.policy.finalizeResults(ftsCandidates, query, opts, plan),
|
|
89
|
+
this.policy.semanticDiagnostic(error)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private retrieveFtsCandidates(query: string, plan: SearchRetrievalPlan): TrapSearchResult[] {
|
|
63
95
|
const candidates = queries
|
|
64
|
-
.searchTraps(this.db, query,
|
|
65
|
-
.filter((result) => this.policy.matchesTrap(result.trap, opts))
|
|
96
|
+
.searchTraps(this.db, query, plan.ftsStorageFilter)
|
|
66
97
|
.map((result) => ({
|
|
67
98
|
...result,
|
|
68
99
|
sources: ["fts"] as ("fts")[],
|
|
69
100
|
score: ftsScore(result.rank),
|
|
70
101
|
}));
|
|
71
|
-
return this.policy.
|
|
102
|
+
return this.policy.prepareRetrievedResults(candidates, "fts", plan);
|
|
72
103
|
}
|
|
73
104
|
|
|
74
|
-
async
|
|
105
|
+
private async retrieveSemanticCandidates(
|
|
106
|
+
query: string,
|
|
107
|
+
plan: SearchRetrievalPlan
|
|
108
|
+
): Promise<TrapSearchResult[]> {
|
|
75
109
|
if (!this.embedder) {
|
|
76
110
|
throw new EmbeddingProviderUnavailableError();
|
|
77
111
|
}
|
|
@@ -80,11 +114,7 @@ export class SearchService {
|
|
|
80
114
|
if (!queryEmbedding) return [];
|
|
81
115
|
|
|
82
116
|
const config = embeddingConfig(this.embedder);
|
|
83
|
-
const candidates =
|
|
84
|
-
category: opts.category,
|
|
85
|
-
scope: opts.scope,
|
|
86
|
-
status: opts.status,
|
|
87
|
-
});
|
|
117
|
+
const candidates = this.embeddingIndex.freshEmbeddings(config, plan.semanticStorageFilter);
|
|
88
118
|
|
|
89
119
|
const results = candidates
|
|
90
120
|
.map(({ trap, embedding }) => {
|
|
@@ -96,29 +126,8 @@ export class SearchService {
|
|
|
96
126
|
score,
|
|
97
127
|
};
|
|
98
128
|
})
|
|
99
|
-
.filter((result) => this.policy.matchesTrap(result.trap, opts))
|
|
100
|
-
.filter((result) => (result.score ?? 0) >= this.policy.semanticMinScore())
|
|
101
|
-
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
102
129
|
.map((result) => result as TrapSearchResult);
|
|
103
|
-
return this.policy.
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async hybridSearch(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
|
|
107
|
-
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
108
|
-
const ftsResults = this.ftsSearch(query, { ...opts, limit });
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
const semanticResults = await this.semanticSearch(query, { ...opts, limit });
|
|
112
|
-
if (semanticResults.length === 0) {
|
|
113
|
-
return this.policy.withDiagnostics(ftsResults, {
|
|
114
|
-
code: "semantic_no_candidates",
|
|
115
|
-
message: "Hybrid search used FTS results because no fresh semantic candidates passed the score threshold.",
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
return this.policy.fuse(ftsResults, semanticResults, query, opts, limit);
|
|
119
|
-
} catch (error) {
|
|
120
|
-
return this.policy.withDiagnostics(ftsResults, this.policy.semanticDiagnostic(error));
|
|
121
|
-
}
|
|
130
|
+
return this.policy.prepareRetrievedResults(results, "semantic", plan);
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
|
package/src/lib/store.ts
CHANGED
|
@@ -167,19 +167,51 @@ export class TrapStore {
|
|
|
167
167
|
return this.scopes.repositoryFor(resolvedScope).top(resolvedScope, limit);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
stats(): { project: TrapStats | null; global: TrapStats } {
|
|
171
|
-
const
|
|
172
|
-
|
|
170
|
+
stats(opts: { scope?: string } = {}): { project: TrapStats | null; global: TrapStats | null } {
|
|
171
|
+
const scope = opts.scope ? normalizeScope(opts.scope) : null;
|
|
172
|
+
const project = scope === "global"
|
|
173
|
+
? null
|
|
174
|
+
: this.scopes.repositoryEntry("project")?.repository.stats({ scope: "project" }) ?? null;
|
|
175
|
+
const global = scope === "project"
|
|
176
|
+
? null
|
|
177
|
+
: this.scopes.repositoryFor("global").stats({ scope: "global" });
|
|
178
|
+
return { project, global };
|
|
173
179
|
}
|
|
174
180
|
|
|
175
|
-
embeddingStats(): TrapEmbeddingStats {
|
|
181
|
+
embeddingStats(opts: { scope?: string } = {}): TrapEmbeddingStats {
|
|
182
|
+
const scope = opts.scope ? normalizeScope(opts.scope) : null;
|
|
176
183
|
const config = this.embeddingConfig();
|
|
177
|
-
const project =
|
|
178
|
-
?
|
|
184
|
+
const project = scope === "global"
|
|
185
|
+
? null
|
|
186
|
+
: this.scopes.repositoryEntry("project")
|
|
187
|
+
? summarizeEmbeddingState(this.scopes.repositoryFor("project").embeddingStats(config, { scope: "project" }), config)
|
|
179
188
|
: null;
|
|
189
|
+
const global = scope === "project"
|
|
190
|
+
? null
|
|
191
|
+
: summarizeEmbeddingState(this.scopes.repositoryFor("global").embeddingStats(config, { scope: "global" }), config);
|
|
180
192
|
return {
|
|
181
193
|
project,
|
|
182
|
-
global
|
|
194
|
+
global,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
diagnostics(): {
|
|
199
|
+
mis_scoped_traps: {
|
|
200
|
+
global_db_project_traps: Pick<Trap, "id" | "title" | "scope" | "project_path" | "status">[];
|
|
201
|
+
};
|
|
202
|
+
} {
|
|
203
|
+
return {
|
|
204
|
+
mis_scoped_traps: {
|
|
205
|
+
global_db_project_traps: this.scopes.repositoryFor("global")
|
|
206
|
+
.listMisScoped("global")
|
|
207
|
+
.map((trap) => ({
|
|
208
|
+
id: trap.id,
|
|
209
|
+
title: trap.title,
|
|
210
|
+
scope: trap.scope,
|
|
211
|
+
project_path: trap.project_path,
|
|
212
|
+
status: trap.status,
|
|
213
|
+
})),
|
|
214
|
+
},
|
|
183
215
|
};
|
|
184
216
|
}
|
|
185
217
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Trap } from "../domain/trap";
|
|
2
|
+
|
|
3
|
+
export type TrapLifecycleAdapter = {
|
|
4
|
+
get(id: number): Trap | null;
|
|
5
|
+
transaction<T>(callback: () => T): T;
|
|
6
|
+
markArchived(id: number): boolean;
|
|
7
|
+
markSuperseded(id: number, stateKey: string): boolean;
|
|
8
|
+
markSuperseding(id: number, supersedesId: number, stateKey: string): boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function archiveTrapLifecycle(adapter: TrapLifecycleAdapter, id: number): boolean {
|
|
12
|
+
return adapter.markArchived(id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function supersedeTrapLifecycle(
|
|
16
|
+
adapter: TrapLifecycleAdapter,
|
|
17
|
+
id: number,
|
|
18
|
+
supersededById: number,
|
|
19
|
+
stateKey?: string
|
|
20
|
+
): boolean {
|
|
21
|
+
if (id === supersededById) return false;
|
|
22
|
+
|
|
23
|
+
const oldTrap = adapter.get(id);
|
|
24
|
+
const newTrap = adapter.get(supersededById);
|
|
25
|
+
if (!oldTrap || !newTrap) return false;
|
|
26
|
+
|
|
27
|
+
const key = lifecycleStateKey(oldTrap, newTrap, stateKey);
|
|
28
|
+
return adapter.transaction(() => {
|
|
29
|
+
adapter.markSuperseded(id, key);
|
|
30
|
+
adapter.markSuperseding(supersededById, id, key);
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function lifecycleStateKey(oldTrap: Trap, newTrap: Trap, requested?: string): string {
|
|
36
|
+
return requested ?? oldTrap.state_key ?? newTrap.state_key ?? `trap:${oldTrap.id}`;
|
|
37
|
+
}
|
|
@@ -18,7 +18,7 @@ export type TrapListGroup = { traps: Trap[]; scope: string };
|
|
|
18
18
|
export type TrapSearchGroup = { results: TrapSearchResult[]; scope: string };
|
|
19
19
|
export type { AddTrapEvidenceResult, TrapMutationResult };
|
|
20
20
|
|
|
21
|
-
export type TrapStatsResult = { project: TrapStats | null; global: TrapStats };
|
|
21
|
+
export type TrapStatsResult = { project: TrapStats | null; global: TrapStats | null };
|
|
22
22
|
export type EmbeddingStatsResult = ReturnType<TrapStore["embeddingStats"]>;
|
|
23
23
|
|
|
24
24
|
export interface SearchTrapsArgs {
|
|
@@ -130,12 +130,12 @@ export class TrapOperations {
|
|
|
130
130
|
return this.store.supersede(id, supersededById, scope, stateKey);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
getStats(): TrapStatsResult {
|
|
134
|
-
return this.store.stats();
|
|
133
|
+
getStats(scope?: string): TrapStatsResult {
|
|
134
|
+
return this.store.stats({ scope });
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
getEmbeddingStats(): EmbeddingStatsResult {
|
|
138
|
-
return this.store.embeddingStats();
|
|
137
|
+
getEmbeddingStats(scope?: string): EmbeddingStatsResult {
|
|
138
|
+
return this.store.embeddingStats({ scope });
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
exportTraps(scope?: string): ReturnType<TrapStore["exportAll"]> {
|
package/src/mcp/server.ts
CHANGED
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
toTrapDetailsJson,
|
|
22
22
|
} from "../lib/output-json";
|
|
23
23
|
import { storeForScopeContext } from "../lib/scope-context";
|
|
24
|
+
import {
|
|
25
|
+
listRequestFromArgs,
|
|
26
|
+
searchRequestFromArgs,
|
|
27
|
+
statsRequestFromArgs,
|
|
28
|
+
} from "../lib/command-requests";
|
|
24
29
|
|
|
25
30
|
type ToolArgs = Record<string, any>;
|
|
26
31
|
|
|
@@ -30,19 +35,8 @@ export async function handleToolCall(store: TrapStore, name: string, args: ToolA
|
|
|
30
35
|
try {
|
|
31
36
|
switch (name) {
|
|
32
37
|
case "search_traps": {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
scope: args.scope,
|
|
36
|
-
category: args.category,
|
|
37
|
-
mode: args.mode,
|
|
38
|
-
limit: args.limit ?? 20,
|
|
39
|
-
status: args.status,
|
|
40
|
-
path: args.path,
|
|
41
|
-
module: args.module,
|
|
42
|
-
owner: args.owner,
|
|
43
|
-
rerank: args.rerank,
|
|
44
|
-
includeRankingSignals: args.ranking_signals,
|
|
45
|
-
});
|
|
38
|
+
const defaults = { mode: "hybrid" as const, limit: args.limit ?? 20, rerank: true };
|
|
39
|
+
const cards = await operations.searchTrapCards(searchRequestFromArgs(args.query, args, defaults));
|
|
46
40
|
return toMcpTextJson(toMcpSearchJson(cards));
|
|
47
41
|
}
|
|
48
42
|
|
|
@@ -60,15 +54,7 @@ export async function handleToolCall(store: TrapStore, name: string, args: ToolA
|
|
|
60
54
|
}
|
|
61
55
|
|
|
62
56
|
case "list_traps": {
|
|
63
|
-
const groups = operations.listTraps(
|
|
64
|
-
scope: args.scope,
|
|
65
|
-
category: args.category,
|
|
66
|
-
status: args.status,
|
|
67
|
-
limit: args.limit ?? 50,
|
|
68
|
-
path: args.path,
|
|
69
|
-
module: args.module,
|
|
70
|
-
owner: args.owner,
|
|
71
|
-
});
|
|
57
|
+
const groups = operations.listTraps(listRequestFromArgs(args));
|
|
72
58
|
return toMcpTextJson(toListJson(groups));
|
|
73
59
|
}
|
|
74
60
|
|
|
@@ -98,8 +84,9 @@ export async function handleToolCall(store: TrapStore, name: string, args: ToolA
|
|
|
98
84
|
}
|
|
99
85
|
|
|
100
86
|
case "get_stats": {
|
|
101
|
-
const
|
|
102
|
-
|
|
87
|
+
const request = statsRequestFromArgs(args);
|
|
88
|
+
const stats = operations.getStats(request.scope);
|
|
89
|
+
return toMcpTextJson(toStatsJson(stats, operations.getEmbeddingStats(request.scope)));
|
|
103
90
|
}
|
|
104
91
|
|
|
105
92
|
default:
|