codetrap 0.1.2 → 0.1.3

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.
Files changed (47) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +107 -32
  3. package/docs/installation.md +18 -10
  4. package/package.json +4 -1
  5. package/plugins/codetrap-agent/.codex-plugin/plugin.json +34 -0
  6. package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +25 -0
  7. package/plugins/codetrap-agent/hooks/pre-edit.example.sh +10 -0
  8. package/plugins/codetrap-agent/hooks.json +11 -0
  9. package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +19 -0
  10. package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +14 -0
  11. package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +25 -0
  12. package/scripts/release-preflight.ts +55 -0
  13. package/skills/codetrap-add/SKILL.md +4 -1
  14. package/skills/codetrap-check/SKILL.md +24 -4
  15. package/skills/codetrap-search/SKILL.md +32 -12
  16. package/src/commands/command-result.ts +29 -0
  17. package/src/commands/router.ts +6 -400
  18. package/src/commands/workflow.ts +466 -0
  19. package/src/db/embedding-queries.ts +33 -0
  20. package/src/db/queries.ts +119 -20
  21. package/src/db/repository.ts +39 -2
  22. package/src/db/schema.ts +35 -0
  23. package/src/domain/trap.ts +31 -2
  24. package/src/index.ts +13 -1
  25. package/src/lib/config.ts +102 -0
  26. package/src/lib/constants.ts +1 -1
  27. package/src/lib/doctor.ts +76 -0
  28. package/src/lib/embedding-health.ts +49 -0
  29. package/src/lib/format.ts +5 -1
  30. package/src/lib/output-json.ts +116 -0
  31. package/src/lib/scope-context.ts +116 -0
  32. package/src/lib/scope-migration.ts +360 -0
  33. package/src/lib/search-normalizer.ts +6 -0
  34. package/src/lib/search-policy.ts +276 -0
  35. package/src/lib/search-result-card.ts +1 -0
  36. package/src/lib/search-service.ts +36 -98
  37. package/src/lib/store.ts +96 -107
  38. package/src/lib/trap-archive.ts +9 -42
  39. package/src/lib/trap-codec.ts +113 -0
  40. package/src/lib/trap-json-fields.ts +12 -0
  41. package/src/lib/trap-mutation-result.ts +36 -0
  42. package/src/lib/trap-operations.ts +27 -6
  43. package/src/lib/trap-scope-match.ts +112 -0
  44. package/src/lib/trap-search-document.ts +8 -1
  45. package/src/lib/trap-transfer.ts +88 -0
  46. package/src/mcp/server.ts +75 -57
  47. package/src/mcp/tools.ts +32 -5
@@ -0,0 +1,276 @@
1
+ import type { RankingSignal, Trap, TrapSearchResult } from "../domain/trap";
2
+ import { EmbeddingProviderUnavailableError } from "./embedder";
3
+ import { parseTrapPathGlobs, parseTrapTags } from "./trap-json-fields";
4
+ import {
5
+ hasSpecificPathMatch,
6
+ trapMatchesApplicability,
7
+ type ApplicabilityFilter,
8
+ } from "./trap-scope-match";
9
+
10
+ export interface SearchPolicyOptions extends ApplicabilityFilter {
11
+ limit?: number;
12
+ rerank?: boolean;
13
+ includeRankingSignals?: boolean;
14
+ }
15
+
16
+ export interface RankingConfig {
17
+ rrfK: number;
18
+ semanticMinScore: number;
19
+ lengthNormAnchor: number;
20
+ maxBoost: number;
21
+ titleTokenBoost: number;
22
+ tagTokenBoost: number;
23
+ identifierBoost: number;
24
+ severityBoost: Record<string, number>;
25
+ pathMatchBoost: number;
26
+ moduleMatchBoost: number;
27
+ ownerMatchBoost: number;
28
+ }
29
+
30
+ export const DEFAULT_RANKING_CONFIG: RankingConfig = {
31
+ rrfK: 60,
32
+ semanticMinScore: 0.3,
33
+ lengthNormAnchor: 500,
34
+ maxBoost: 0.45,
35
+ titleTokenBoost: 0.16,
36
+ tagTokenBoost: 0.2,
37
+ identifierBoost: 0.18,
38
+ severityBoost: {
39
+ warning: 0,
40
+ error: 0.04,
41
+ critical: 0.07,
42
+ },
43
+ pathMatchBoost: 0.12,
44
+ moduleMatchBoost: 0.08,
45
+ ownerMatchBoost: 0.04,
46
+ };
47
+
48
+ export class TrapSearchPolicy {
49
+ constructor(private readonly ranking: RankingConfig = DEFAULT_RANKING_CONFIG) {}
50
+
51
+ candidateLimit(opts: SearchPolicyOptions, resultLimit: number): number {
52
+ return shouldOverfetch(opts) ? Math.max(resultLimit * 5, 50) : resultLimit;
53
+ }
54
+
55
+ semanticMinScore(): number {
56
+ return this.ranking.semanticMinScore;
57
+ }
58
+
59
+ filterResults(results: TrapSearchResult[], filter: ApplicabilityFilter): TrapSearchResult[] {
60
+ return results.filter((result) => trapMatchesApplicability(result.trap, filter));
61
+ }
62
+
63
+ matchesTrap(trap: Trap, filter: ApplicabilityFilter): boolean {
64
+ return trapMatchesApplicability(trap, filter);
65
+ }
66
+
67
+ filterTraps(traps: Trap[], filter: ApplicabilityFilter): Trap[] {
68
+ return traps.filter((trap) => this.matchesTrap(trap, filter));
69
+ }
70
+
71
+ rankResults(
72
+ results: TrapSearchResult[],
73
+ query: string,
74
+ opts: SearchPolicyOptions,
75
+ limit: number
76
+ ): TrapSearchResult[] {
77
+ return applyReranking(results, query, opts, this.ranking).slice(0, limit);
78
+ }
79
+
80
+ fuse(
81
+ ftsResults: TrapSearchResult[],
82
+ semanticResults: TrapSearchResult[],
83
+ query: string,
84
+ opts: SearchPolicyOptions,
85
+ limit: number
86
+ ): TrapSearchResult[] {
87
+ const byId = new Map<number, TrapSearchResult & { score: number; sources: ("fts" | "semantic")[] }>();
88
+
89
+ addRankedResults(byId, ftsResults, "fts", this.ranking);
90
+ addRankedResults(byId, semanticResults, "semantic", this.ranking);
91
+
92
+ const fused = [...byId.values()]
93
+ .map((result) => ({
94
+ ...result,
95
+ score: applyLengthNormalization(result.score, result.trap, this.ranking),
96
+ rank: applyLengthNormalization(result.score, result.trap, this.ranking),
97
+ }))
98
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
99
+ .slice(0, limit);
100
+
101
+ return this.rankResults(fused, query, opts, limit);
102
+ }
103
+
104
+ withDiagnostics(
105
+ results: TrapSearchResult[],
106
+ diagnostic: { code: string; message: string }
107
+ ): TrapSearchResult[] {
108
+ return results.map((result) => ({
109
+ ...result,
110
+ diagnostics: [...(result.diagnostics ?? []), diagnostic],
111
+ }));
112
+ }
113
+
114
+ semanticDiagnostic(error: unknown): { code: string; message: string } {
115
+ if (error instanceof EmbeddingProviderUnavailableError) {
116
+ return {
117
+ code: "semantic_unavailable",
118
+ message: error.message,
119
+ };
120
+ }
121
+ return {
122
+ code: "semantic_failed",
123
+ message: error instanceof Error ? error.message : "Semantic search failed; hybrid search returned FTS results.",
124
+ };
125
+ }
126
+ }
127
+
128
+ function addRankedResults(
129
+ byId: Map<number, TrapSearchResult & { score: number; sources: ("fts" | "semantic")[] }>,
130
+ results: TrapSearchResult[],
131
+ source: "fts" | "semantic",
132
+ ranking: RankingConfig
133
+ ): void {
134
+ results.forEach((result, index) => {
135
+ const score = 1 / (ranking.rrfK + index + 1);
136
+ const existing = byId.get(result.trap.id);
137
+ if (existing) {
138
+ existing.score += score;
139
+ if (!existing.sources.includes(source)) existing.sources.push(source);
140
+ return;
141
+ }
142
+ byId.set(result.trap.id, {
143
+ ...result,
144
+ score,
145
+ sources: [source],
146
+ });
147
+ });
148
+ }
149
+
150
+ function applyLengthNormalization(score: number, trap: Trap, ranking: RankingConfig): number {
151
+ const length = `${trap.context}\n${trap.mistake}\n${trap.fix}`.length;
152
+ if (length <= ranking.lengthNormAnchor) return score;
153
+ return score * Math.sqrt(ranking.lengthNormAnchor / length);
154
+ }
155
+
156
+ function applyReranking(
157
+ results: TrapSearchResult[],
158
+ query: string,
159
+ opts: SearchPolicyOptions,
160
+ ranking: RankingConfig
161
+ ): TrapSearchResult[] {
162
+ if (opts.rerank === false) return stripRankingSignals(results, opts);
163
+
164
+ const queryInfo = analyzeQuery(query);
165
+ return results
166
+ .map((result) => {
167
+ const signals = rankingSignals(result.trap, queryInfo, opts, ranking);
168
+ const boost = Math.min(
169
+ ranking.maxBoost,
170
+ signals.reduce((sum, signal) => sum + signal.weight, 0)
171
+ );
172
+ const score = (result.score ?? result.rank ?? 0) * (1 + boost);
173
+ return {
174
+ ...result,
175
+ score,
176
+ rank: score,
177
+ ...(opts.includeRankingSignals && signals.length > 0 ? { ranking_signals: signals } : {}),
178
+ };
179
+ })
180
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
181
+ }
182
+
183
+ function stripRankingSignals(results: TrapSearchResult[], opts: SearchPolicyOptions): TrapSearchResult[] {
184
+ if (opts.includeRankingSignals) return results.map((result) => ({ ...result, ranking_signals: [] }));
185
+ return results.map(({ ranking_signals: _rankingSignals, ...result }) => result);
186
+ }
187
+
188
+ function rankingSignals(
189
+ trap: Trap,
190
+ query: QueryInfo,
191
+ filter: ApplicabilityFilter,
192
+ ranking: RankingConfig
193
+ ): RankingSignal[] {
194
+ const signals: RankingSignal[] = [];
195
+ const titleTokens = tokenize(trap.title);
196
+ const tags = parseTrapTags(trap.tags).map((tag) => tag.toLowerCase());
197
+ const allFieldTokens = tokenize([
198
+ trap.title,
199
+ trap.context,
200
+ trap.mistake,
201
+ trap.fix,
202
+ trap.before_code ?? "",
203
+ trap.after_code ?? "",
204
+ parseTrapPathGlobs(trap.path_globs).join(" "),
205
+ trap.module ?? "",
206
+ trap.owner ?? "",
207
+ ].join(" "));
208
+
209
+ for (const token of query.tokens) {
210
+ if (titleTokens.has(token)) {
211
+ signals.push({ code: "title_token_exact", weight: ranking.titleTokenBoost, detail: token });
212
+ break;
213
+ }
214
+ }
215
+
216
+ for (const token of query.tokens) {
217
+ if (tags.includes(token)) {
218
+ signals.push({ code: "tag_exact", weight: ranking.tagTokenBoost, detail: token });
219
+ break;
220
+ }
221
+ }
222
+
223
+ for (const token of query.identifierTokens) {
224
+ if (allFieldTokens.has(token)) {
225
+ signals.push({ code: "code_identifier_exact", weight: ranking.identifierBoost, detail: token });
226
+ break;
227
+ }
228
+ }
229
+
230
+ const severityBoost = ranking.severityBoost[trap.severity] ?? 0;
231
+ if (severityBoost > 0) {
232
+ signals.push({ code: "severity", weight: severityBoost, detail: trap.severity });
233
+ }
234
+
235
+ if (hasSpecificPathMatch(trap, filter.path)) {
236
+ signals.push({ code: "path_scope_match", weight: ranking.pathMatchBoost, detail: filter.path });
237
+ }
238
+ if (filter.module && trap.module === filter.module) {
239
+ signals.push({ code: "module_scope_match", weight: ranking.moduleMatchBoost, detail: filter.module });
240
+ }
241
+ if (filter.owner && trap.owner === filter.owner) {
242
+ signals.push({ code: "owner_scope_match", weight: ranking.ownerMatchBoost, detail: filter.owner });
243
+ }
244
+
245
+ return signals;
246
+ }
247
+
248
+ type QueryInfo = {
249
+ tokens: Set<string>;
250
+ identifierTokens: Set<string>;
251
+ };
252
+
253
+ function analyzeQuery(query: string): QueryInfo {
254
+ const tokens = tokenize(query);
255
+ return {
256
+ tokens,
257
+ identifierTokens: new Set([...tokens].filter(isIdentifierLike)),
258
+ };
259
+ }
260
+
261
+ function tokenize(value: string): Set<string> {
262
+ return new Set((value.match(/[A-Za-z0-9_.$/@:-]+/g) ?? []).map((token) => token.toLowerCase()));
263
+ }
264
+
265
+ function isIdentifierLike(token: string): boolean {
266
+ return (
267
+ /[_.$/@:-]/.test(token) ||
268
+ /\d/.test(token) ||
269
+ /[a-z][A-Z]/.test(token) ||
270
+ token.length >= 8
271
+ );
272
+ }
273
+
274
+ function shouldOverfetch(opts: SearchPolicyOptions): boolean {
275
+ return Boolean(opts.path || opts.module || opts.owner || opts.rerank !== false);
276
+ }
@@ -15,6 +15,7 @@ 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.ranking_signals ? { ranking_signals: result.ranking_signals } : {}),
18
19
  next_action: {
19
20
  details_tool: "get_trap",
20
21
  details_args: {
@@ -1,7 +1,7 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import * as embeddingQueries from "../db/embedding-queries";
3
3
  import * as queries from "../db/queries";
4
- import type { Trap, TrapSearchResult } from "../domain/trap";
4
+ import type { TrapSearchResult } from "../domain/trap";
5
5
  import type { SearchMode, TrapStatus } from "./constants";
6
6
  import {
7
7
  cosineSimilarity,
@@ -9,6 +9,11 @@ import {
9
9
  embeddingConfig,
10
10
  type EmbeddingProvider,
11
11
  } from "./embedder";
12
+ import {
13
+ DEFAULT_RANKING_CONFIG,
14
+ TrapSearchPolicy,
15
+ type RankingConfig,
16
+ } from "./search-policy";
12
17
 
13
18
  export interface SearchOptions {
14
19
  category?: string;
@@ -16,28 +21,25 @@ export interface SearchOptions {
16
21
  limit?: number;
17
22
  mode?: SearchMode;
18
23
  status?: TrapStatus | "all";
24
+ path?: string;
25
+ module?: string;
26
+ owner?: string;
27
+ rerank?: boolean;
28
+ includeRankingSignals?: boolean;
19
29
  }
20
30
 
21
- export interface RankingConfig {
22
- rrfK: number;
23
- semanticMinScore: number;
24
- lengthNormAnchor: number;
25
- }
26
-
27
- export const DEFAULT_RANKING_CONFIG: RankingConfig = {
28
- rrfK: 60,
29
- semanticMinScore: 0.3,
30
- lengthNormAnchor: 500,
31
- };
32
-
33
31
  const DEFAULT_LIMIT = 20;
34
32
 
35
33
  export class SearchService {
34
+ private readonly policy: TrapSearchPolicy;
35
+
36
36
  constructor(
37
37
  private readonly db: Database,
38
38
  private readonly embedder?: EmbeddingProvider,
39
- private readonly ranking: RankingConfig = DEFAULT_RANKING_CONFIG
40
- ) {}
39
+ ranking: RankingConfig = DEFAULT_RANKING_CONFIG
40
+ ) {
41
+ this.policy = new TrapSearchPolicy(ranking);
42
+ }
41
43
 
42
44
  async search(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
43
45
  if (!query.trim()) return [];
@@ -56,11 +58,17 @@ export class SearchService {
56
58
  }
57
59
 
58
60
  ftsSearch(query: string, opts: SearchOptions = {}): TrapSearchResult[] {
59
- return queries.searchTraps(this.db, query, opts).map((result) => ({
60
- ...result,
61
- sources: ["fts"],
62
- score: ftsScore(result.rank),
63
- }));
61
+ const limit = opts.limit ?? DEFAULT_LIMIT;
62
+ const searchLimit = this.policy.candidateLimit(opts, limit);
63
+ const candidates = queries
64
+ .searchTraps(this.db, query, { ...opts, limit: searchLimit })
65
+ .filter((result) => this.policy.matchesTrap(result.trap, opts))
66
+ .map((result) => ({
67
+ ...result,
68
+ sources: ["fts"] as ("fts")[],
69
+ score: ftsScore(result.rank),
70
+ }));
71
+ return this.policy.rankResults(candidates, query, opts, limit);
64
72
  }
65
73
 
66
74
  async semanticSearch(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
@@ -78,7 +86,7 @@ export class SearchService {
78
86
  status: opts.status,
79
87
  });
80
88
 
81
- return candidates
89
+ const results = candidates
82
90
  .map(({ trap, embedding }) => {
83
91
  const score = cosineSimilarity(queryEmbedding, embedding);
84
92
  return {
@@ -88,9 +96,11 @@ export class SearchService {
88
96
  score,
89
97
  };
90
98
  })
91
- .filter((result) => (result.score ?? 0) >= this.ranking.semanticMinScore)
99
+ .filter((result) => this.policy.matchesTrap(result.trap, opts))
100
+ .filter((result) => (result.score ?? 0) >= this.policy.semanticMinScore())
92
101
  .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
93
- .slice(0, opts.limit ?? DEFAULT_LIMIT);
102
+ .map((result) => result as TrapSearchResult);
103
+ return this.policy.rankResults(results, query, opts, opts.limit ?? DEFAULT_LIMIT);
94
104
  }
95
105
 
96
106
  async hybridSearch(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
@@ -100,90 +110,18 @@ export class SearchService {
100
110
  try {
101
111
  const semanticResults = await this.semanticSearch(query, { ...opts, limit });
102
112
  if (semanticResults.length === 0) {
103
- return withDiagnostics(ftsResults, {
113
+ return this.policy.withDiagnostics(ftsResults, {
104
114
  code: "semantic_no_candidates",
105
115
  message: "Hybrid search used FTS results because no fresh semantic candidates passed the score threshold.",
106
116
  });
107
117
  }
108
- return rrfFuse(ftsResults, semanticResults, limit, this.ranking);
118
+ return this.policy.fuse(ftsResults, semanticResults, query, opts, limit);
109
119
  } catch (error) {
110
- return withDiagnostics(ftsResults, semanticDiagnostic(error));
120
+ return this.policy.withDiagnostics(ftsResults, this.policy.semanticDiagnostic(error));
111
121
  }
112
122
  }
113
123
  }
114
124
 
115
- export function rrfFuse(
116
- ftsResults: TrapSearchResult[],
117
- semanticResults: TrapSearchResult[],
118
- limit = DEFAULT_LIMIT,
119
- ranking: RankingConfig = DEFAULT_RANKING_CONFIG
120
- ): TrapSearchResult[] {
121
- const byId = new Map<number, TrapSearchResult & { score: number; sources: ("fts" | "semantic")[] }>();
122
-
123
- addRankedResults(byId, ftsResults, "fts", ranking);
124
- addRankedResults(byId, semanticResults, "semantic", ranking);
125
-
126
- return [...byId.values()]
127
- .map((result) => ({
128
- ...result,
129
- score: applyLengthNormalization(result.score, result.trap, ranking),
130
- rank: applyLengthNormalization(result.score, result.trap, ranking),
131
- }))
132
- .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
133
- .slice(0, limit);
134
- }
135
-
136
- function addRankedResults(
137
- byId: Map<number, TrapSearchResult & { score: number; sources: ("fts" | "semantic")[] }>,
138
- results: TrapSearchResult[],
139
- source: "fts" | "semantic",
140
- ranking: RankingConfig
141
- ): void {
142
- results.forEach((result, index) => {
143
- const score = 1 / (ranking.rrfK + index + 1);
144
- const existing = byId.get(result.trap.id);
145
- if (existing) {
146
- existing.score += score;
147
- if (!existing.sources.includes(source)) existing.sources.push(source);
148
- return;
149
- }
150
- byId.set(result.trap.id, {
151
- ...result,
152
- score,
153
- sources: [source],
154
- });
155
- });
156
- }
157
-
158
- function applyLengthNormalization(score: number, trap: Trap, ranking: RankingConfig): number {
159
- const length = `${trap.context}\n${trap.mistake}\n${trap.fix}`.length;
160
- if (length <= ranking.lengthNormAnchor) return score;
161
- return score * Math.sqrt(ranking.lengthNormAnchor / length);
162
- }
163
-
164
125
  function ftsScore(rank: number): number {
165
126
  return Number.isFinite(rank) ? -rank : 0;
166
127
  }
167
-
168
- function withDiagnostics(
169
- results: TrapSearchResult[],
170
- diagnostic: { code: string; message: string }
171
- ): TrapSearchResult[] {
172
- return results.map((result) => ({
173
- ...result,
174
- diagnostics: [...(result.diagnostics ?? []), diagnostic],
175
- }));
176
- }
177
-
178
- function semanticDiagnostic(error: unknown): { code: string; message: string } {
179
- if (error instanceof EmbeddingProviderUnavailableError) {
180
- return {
181
- code: "semantic_unavailable",
182
- message: error.message,
183
- };
184
- }
185
- return {
186
- code: "semantic_failed",
187
- message: error instanceof Error ? error.message : "Semantic search failed; hybrid search returned FTS results.",
188
- };
189
- }