codetrap 0.1.6 → 0.1.7

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 CHANGED
@@ -160,7 +160,8 @@ codetrap/
160
160
  | `repair-scope` | Move legacy mis-scoped project traps into the current project (dry-run by default, `--apply` to mutate, `--json`) |
161
161
  | `migrate-project` | Move project traps between initialized projects (`--from-project-path`, `--to-project-path`, dry-run by default, `--apply`, `--json`) |
162
162
  | `embed` | Generate embeddings (requires JINA_API_KEY) |
163
- | `session` | Start a development session, append notes, promote explicit structured trap notes into candidates, and accept/reject candidates |
163
+ | `session` | Start a development session, append notes, promote explicit structured trap notes into candidates, accept/reject candidates, and clean up session files |
164
+ | `web` | Start the local review and trap library console |
164
165
  | `serve` | Start MCP server |
165
166
 
166
167
  ### Session Mode
@@ -179,6 +180,14 @@ codetrap session accept cand-001
179
180
 
180
181
  `session accept` writes the confirmed lesson through `TrapOperations`, attaches session evidence, and checks similar active traps before saving. `--edit-json` is applied before the conflict check, so edits to scope/module/title/tags/path globs affect both the saved trap and conflict detection. If a possible conflict is found, the candidate keeps its edited trap shape and conflict diagnostics; use `--accept-anyway` to keep both traps or `--supersedes <trap-id>` to preserve lifecycle history.
181
182
 
183
+ Session maintenance commands keep temporary files from becoming stale context:
184
+
185
+ ```bash
186
+ codetrap session cleanup <session-id> --deleted-trap-candidates
187
+ codetrap session delete <session-id>
188
+ codetrap session prune --older-than 90d --apply
189
+ ```
190
+
182
191
  ## Agent Integration
183
192
 
184
193
  For AI coding agents, use the CLI as the default integration path:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codetrap",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Capture and retrieve coding pitfalls so AI doesn't repeat mistakes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,6 +10,7 @@ import {
10
10
  } from "../lib/scope-migration";
11
11
  import { TrapOperations } from "../lib/trap-operations";
12
12
  import { buildDoctorReport, formatDoctorText } from "../lib/doctor";
13
+ import { formatEmbedText } from "../lib/embed-output";
13
14
  import { searchDefaultsFromConfig } from "../lib/config";
14
15
  import { SessionStore } from "../lib/session-store";
15
16
  import { SessionOperations, type SessionConflictResult } from "../lib/session-operations";
@@ -44,6 +45,7 @@ import {
44
45
  sessionIdRequestFromArgs,
45
46
  sessionListRequestFromArgs,
46
47
  sessionNoteRequestFromArgs,
48
+ sessionPruneRequestFromArgs,
47
49
  sessionRejectRequestFromArgs,
48
50
  sessionShowRequestFromArgs,
49
51
  sessionStartRequestFromArgs,
@@ -380,12 +382,7 @@ async function cmdEmbed(args: string[], store: TrapStore): Promise<CommandResult
380
382
  const { opts } = parseArgs(args);
381
383
  try {
382
384
  const result = await store.ensureEmbeddings(embedRequestFromArgs(opts));
383
- return textResult([
384
- ...result.scopes.map((scoped) =>
385
- `[${scoped.scope}] embeddings generated: ${scoped.generated}, skipped: ${scoped.skipped}, batches: ${scoped.batches}`
386
- ),
387
- `Total generated: ${result.generated}, skipped: ${result.skipped}, batches: ${result.batches}`,
388
- ].join("\n"));
385
+ return textResult(formatEmbedText(result));
389
386
  } catch (error) {
390
387
  return errorFrom(error);
391
388
  }
@@ -424,8 +421,14 @@ async function cmdSession(args: string[], store: TrapStore, trapOperations: Trap
424
421
  return cmdSessionAccept(rest, sessions);
425
422
  case "reject":
426
423
  return cmdSessionReject(rest, sessions);
424
+ case "delete":
425
+ return cmdSessionDelete(rest, sessions);
426
+ case "prune":
427
+ return cmdSessionPrune(rest, sessions);
428
+ case "cleanup":
429
+ return cmdSessionCleanup(rest, sessions);
427
430
  default:
428
- return errorResult("Usage: codetrap session <start|note|status|list|show|notes|close|candidates|candidate|accept|reject>");
431
+ return errorResult("Usage: codetrap session <start|note|status|list|show|notes|close|candidates|candidate|accept|reject|delete|prune|cleanup>");
429
432
  }
430
433
  } catch (error) {
431
434
  return errorFrom(error);
@@ -589,6 +592,45 @@ function cmdSessionReject(args: string[], sessions: SessionOperations): CommandR
589
592
  return textResult(`Rejected ${rejected.candidate.id}.`);
590
593
  }
591
594
 
595
+ function cmdSessionDelete(args: string[], sessions: SessionOperations): CommandResult {
596
+ const { opts, positionals } = parseArgs(args);
597
+ const request = sessionShowRequestFromArgs(positionals);
598
+ const result = sessions.deleteSession(request.sessionId);
599
+ const payload = { success: result.deleted, ...result };
600
+ if (opts.json !== undefined) return jsonResult(payload);
601
+ return textResult(`Deleted session ${result.session_id}.`);
602
+ }
603
+
604
+ function cmdSessionPrune(args: string[], sessions: SessionOperations): CommandResult {
605
+ const { opts } = parseArgs(args);
606
+ const result = sessions.pruneSessions(sessionPruneRequestFromArgs(opts));
607
+ if (opts.json !== undefined) return jsonResult(result);
608
+ const verb = result.dry_run ? "Would delete" : "Deleted";
609
+ const lines = [`${verb} ${result.dry_run ? result.sessions.length : result.deleted_count} session(s) older than ${result.cutoff}.`];
610
+ if (result.dry_run && result.sessions.length > 0) {
611
+ lines.push("Run with --apply to delete them.");
612
+ }
613
+ lines.push(...result.sessions.map((session) => `- ${session.id} [${session.status}] ${session.goal}`));
614
+ return textResult(lines.join("\n"));
615
+ }
616
+
617
+ function cmdSessionCleanup(args: string[], sessions: SessionOperations): CommandResult {
618
+ const { opts, positionals } = parseArgs(args);
619
+ if (opts["deleted-trap-candidates"] === undefined && opts.deleted_trap_candidates === undefined) {
620
+ return errorResult("Usage: codetrap session cleanup [session-id] --deleted-trap-candidates [--json]");
621
+ }
622
+ const request = sessionIdRequestFromArgs(positionals);
623
+ const result = sessions.cleanupDeletedTrapCandidates(request.sessionId);
624
+ const payload = {
625
+ success: true,
626
+ session_id: result.session.id,
627
+ removed_count: result.removed_count,
628
+ removed_candidate_ids: result.removed_candidate_ids,
629
+ };
630
+ if (opts.json !== undefined) return jsonResult(payload);
631
+ return textResult(`Removed ${result.removed_count} deleted-trap candidate(s) from session ${result.session.id}.`);
632
+ }
633
+
592
634
  function formatStatsText(stats: ReturnType<TrapOperations["getStats"]>): string {
593
635
  const sections: string[] = [];
594
636
  if (stats.project) {
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { getGlobalDB, getProjectDB } from "../lib/scope";
3
3
  import { initSchema } from "./schema";
4
4
 
5
- let globalDB: Database | null = null;
5
+ const globalDBs = new Map<string, Database>();
6
6
  const projectDBs = new Map<string, Database>();
7
7
 
8
8
  export function openDatabase(path = ":memory:"): Database {
@@ -11,12 +11,12 @@ export function openDatabase(path = ":memory:"): Database {
11
11
  return db;
12
12
  }
13
13
 
14
- export function openGlobal(): Database {
15
- if (!globalDB) {
16
- const path = getGlobalDB();
17
- globalDB = openDatabase(path);
14
+ export function openGlobal(home?: string): Database {
15
+ const path = getGlobalDB(home);
16
+ if (!globalDBs.has(path)) {
17
+ globalDBs.set(path, openDatabase(path));
18
18
  }
19
- return globalDB;
19
+ return globalDBs.get(path)!;
20
20
  }
21
21
 
22
22
  export function openProject(root: string): Database {
package/src/index.ts CHANGED
@@ -47,7 +47,7 @@ function showHelp(): void {
47
47
  console.log(" supersede_trap Mark one trap as superseded by another");
48
48
  console.log(" embed Generate embeddings for semantic search");
49
49
  console.log(" session Record implementation notes and capture candidate traps");
50
- console.log(" web Start local candidate review console");
50
+ console.log(" web Start local review and trap library console");
51
51
  console.log(" export Export traps as JSON");
52
52
  console.log(" import <file.json> Import traps from JSON");
53
53
  console.log(" stats Show statistics");
@@ -68,9 +68,9 @@ function showHelp(): void {
68
68
  console.log(" --no-rerank Disable query-aware search reranking");
69
69
  console.log(" --ranking-signals Include search ranking diagnostics in JSON cards");
70
70
  console.log(" --batch-size <n> Embedding generation batch size");
71
- console.log(" --project <path> Project path for web review console");
72
- console.log(" --host <host> Host for web review console (default 127.0.0.1)");
73
- console.log(" --port <n> Port for web review console (default 4737)");
71
+ console.log(" --project <path> Project path for web console");
72
+ console.log(" --host <host> Host for web console (default 127.0.0.1)");
73
+ console.log(" --port <n> Port for web console (default 4737)");
74
74
  console.log(" --json JSON output for search/show/list/stats/doctor; JSON input for add/edit");
75
75
  console.log(" --output-json JSON output for add/edit when --json is used as input");
76
76
  console.log(" --from-project-path <path> Source project path for scope repair/migration");
@@ -63,6 +63,11 @@ export type SessionRejectRequest = SessionCandidateRequest & {
63
63
  reason?: string;
64
64
  };
65
65
 
66
+ export type SessionPruneRequest = {
67
+ olderThanDays: number;
68
+ apply: boolean;
69
+ };
70
+
66
71
  export type SessionNoteStdin = {
67
72
  isTTY: boolean;
68
73
  read: () => string;
@@ -205,6 +210,15 @@ export function sessionRejectRequestFromArgs(positionals: string[], args: RawArg
205
210
  };
206
211
  }
207
212
 
213
+ export function sessionPruneRequestFromArgs(args: RawArgs): SessionPruneRequest {
214
+ const olderThan = stringOption(args, "older_than", "older-than");
215
+ if (!olderThan) throw new Error("--older-than is required.");
216
+ return {
217
+ olderThanDays: parseDurationDays(olderThan),
218
+ apply: flagPresent(args, "apply"),
219
+ };
220
+ }
221
+
208
222
  function stringOption(args: RawArgs, ...keys: string[]): string | undefined {
209
223
  for (const key of keys) {
210
224
  const value = args[key];
@@ -282,6 +296,14 @@ function jsonObjectOption(args: RawArgs, key: string): Record<string, unknown> |
282
296
  }
283
297
  }
284
298
 
299
+ function parseDurationDays(value: string): number {
300
+ const match = value.trim().match(/^(\d+)\s*(d|day|days)?$/i);
301
+ if (!match) throw new Error(`Invalid duration: ${value}. Use a value like 90d.`);
302
+ const days = Number.parseInt(match[1], 10);
303
+ if (!Number.isInteger(days) || days <= 0) throw new Error(`Invalid duration: ${value}. Use a positive day count.`);
304
+ return days;
305
+ }
306
+
285
307
  function requiredPositional(positionals: string[], index: number, name: string): string {
286
308
  const value = positionals[index];
287
309
  if (!value) throw new Error(`${name} is required.`);
package/src/lib/doctor.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import type { TrapStore } from "./store";
2
2
  import type { TrapOperations } from "./trap-operations";
3
- import type { EmbeddingStatsResult, HybridFallbackReason } from "./embedding-health";
3
+ import type { EmbeddingStateSummary, EmbeddingStatsResult, HybridFallbackReason } from "./embedding-health";
4
4
  import { createScopeContext } from "./scope-context";
5
5
  import { hybridFallbackReason } from "./embedding-health";
6
6
 
7
+ export type DoctorNextAction = {
8
+ command: string;
9
+ reason: string;
10
+ };
11
+
7
12
  export type DoctorReport = {
8
13
  cwd: string;
9
14
  project_root: string | null;
@@ -23,6 +28,7 @@ export type DoctorReport = {
23
28
  global_db_project_traps: ReturnType<TrapStore["diagnostics"]>["mis_scoped_traps"]["global_db_project_traps"];
24
29
  };
25
30
  };
31
+ next_actions: DoctorNextAction[];
26
32
  mcp_hint: string;
27
33
  };
28
34
 
@@ -35,6 +41,7 @@ export function buildDoctorReport(
35
41
  const stats = operations.getStats();
36
42
  const embeddings = operations.getEmbeddingStats();
37
43
  const semanticAvailable = store.hasEmbeddingProvider();
44
+ const diagnostics = store.diagnostics();
38
45
 
39
46
  return {
40
47
  ...scope,
@@ -48,8 +55,9 @@ export function buildDoctorReport(
48
55
  fallback_reason: hybridFallbackReason(semanticAvailable, embeddings),
49
56
  },
50
57
  diagnostics: {
51
- mis_scoped_traps: store.diagnostics().mis_scoped_traps,
58
+ mis_scoped_traps: diagnostics.mis_scoped_traps,
52
59
  },
60
+ next_actions: buildDoctorNextActions(semanticAvailable, embeddings, diagnostics),
53
61
  mcp_hint: "Pass cwd in MCP tool calls, or restart codetrap serve after changing projects.",
54
62
  };
55
63
  }
@@ -70,10 +78,62 @@ export function formatDoctorText(report: DoctorReport): string {
70
78
  ` fallback_reason: ${report.hybrid_search.fallback_reason ?? "(none)"}`,
71
79
  "Diagnostics:",
72
80
  ` global_db_project_traps: ${report.diagnostics.mis_scoped_traps.global_db_project_traps.length}`,
81
+ "Next actions:",
82
+ ...formatNextActions(report.next_actions),
73
83
  `mcp_hint: ${report.mcp_hint}`,
74
84
  ].join("\n");
75
85
  }
76
86
 
87
+ function buildDoctorNextActions(
88
+ semanticAvailable: boolean,
89
+ embeddings: EmbeddingStatsResult,
90
+ diagnostics: ReturnType<TrapStore["diagnostics"]>
91
+ ): DoctorNextAction[] {
92
+ const actions: DoctorNextAction[] = [];
93
+ if (!semanticAvailable) {
94
+ actions.push({
95
+ command: "export JINA_API_KEY=<your-jina-api-key>",
96
+ reason: "Enable semantic and hybrid search; otherwise use --mode fts.",
97
+ });
98
+ } else {
99
+ const projectAction = embeddingRefreshAction("project", embeddings.project);
100
+ const globalAction = embeddingRefreshAction("global", embeddings.global);
101
+ if (projectAction) actions.push(projectAction);
102
+ if (globalAction) actions.push(globalAction);
103
+ }
104
+
105
+ const stranded = diagnostics.mis_scoped_traps.global_db_project_traps.length;
106
+ if (stranded > 0) {
107
+ actions.push({
108
+ command: "codetrap repair-scope --dry-run --json",
109
+ reason: `${stranded} project-scoped trap(s) are stored in the global database.`,
110
+ });
111
+ }
112
+ return actions;
113
+ }
114
+
115
+ function embeddingRefreshAction(
116
+ scope: "project" | "global",
117
+ stats: EmbeddingStateSummary | null
118
+ ): DoctorNextAction | null {
119
+ if (!stats || stats.total === 0) return null;
120
+ const needsRefresh = stats.missing + stats.stale;
121
+ if (needsRefresh === 0) return null;
122
+ const parts = [
123
+ stats.missing > 0 ? `${stats.missing} missing` : null,
124
+ stats.stale > 0 ? `${stats.stale} stale` : null,
125
+ ].filter((item): item is string => item !== null);
126
+ return {
127
+ command: `codetrap embed --scope ${scope}`,
128
+ reason: `${scope} embeddings need refresh (${parts.join(", ")}).`,
129
+ };
130
+ }
131
+
132
+ function formatNextActions(actions: DoctorNextAction[]): string[] {
133
+ if (actions.length === 0) return [" (none)"];
134
+ return actions.map((action) => ` - ${action.command} # ${action.reason}`);
135
+ }
136
+
77
137
  function formatEmbeddingStats(
78
138
  label: string,
79
139
  stats: EmbeddingStatsResult["global"] | null
@@ -0,0 +1,26 @@
1
+ export type EmbedCommandResult = {
2
+ generated: number;
3
+ skipped: number;
4
+ batches: number;
5
+ scopes: { scope: string; generated: number; skipped: number; batches: number }[];
6
+ };
7
+
8
+ export function formatEmbedText(result: EmbedCommandResult): string {
9
+ return [
10
+ ...result.scopes.map((scoped) =>
11
+ `[${scoped.scope}] embeddings generated: ${scoped.generated}, skipped: ${scoped.skipped}, batches: ${scoped.batches}`
12
+ ),
13
+ `Total generated: ${result.generated}, skipped: ${result.skipped}, batches: ${result.batches}`,
14
+ `Next: ${embedNextAction(result)}`,
15
+ ].join("\n");
16
+ }
17
+
18
+ function embedNextAction(result: EmbedCommandResult): string {
19
+ if (result.generated > 0) {
20
+ return 'codetrap search "<query>" --mode hybrid';
21
+ }
22
+ if (result.skipped > 0) {
23
+ return "embeddings are already fresh; run codetrap doctor to verify hybrid search.";
24
+ }
25
+ return "add traps first, then rerun codetrap embed --scope project or --scope global.";
26
+ }
@@ -15,14 +15,14 @@ export type ScopeContext = {
15
15
  global_db: string;
16
16
  };
17
17
 
18
- export function createScopeContext(cwd = process.cwd()): ScopeContext {
19
- const resolvedCwd = resolveScopePath(cwd);
20
- const projectRoot = findProjectRoot(resolvedCwd);
18
+ export function createScopeContext(cwd = process.cwd(), home?: string): ScopeContext {
19
+ const resolvedCwd = resolveScopePath(cwd, home ?? cwd);
20
+ const projectRoot = findProjectRoot(resolvedCwd, home);
21
21
  return {
22
22
  cwd: resolvedCwd,
23
23
  project_root: projectRoot,
24
24
  project_db: projectRoot ? scopePath.join(projectRoot, CODETRAP_DIR, TRAPS_DB_FILE) : null,
25
- global_db: getGlobalDB(),
25
+ global_db: getGlobalDB(home),
26
26
  };
27
27
  }
28
28
 
@@ -36,8 +36,8 @@ export class ScopedRepositoryContext {
36
36
  private globalRepository?: TrapRepository;
37
37
  private projectRepository?: TrapRepository;
38
38
 
39
- constructor(cwd = process.cwd(), private readonly embedder?: EmbeddingProvider) {
40
- this.context = createScopeContext(cwd);
39
+ constructor(cwd = process.cwd(), private readonly embedder?: EmbeddingProvider, private readonly home?: string) {
40
+ this.context = createScopeContext(cwd, home);
41
41
  }
42
42
 
43
43
  hasProject(): boolean {
@@ -94,7 +94,7 @@ export class ScopedRepositoryContext {
94
94
 
95
95
  private globalRepo(): TrapRepository {
96
96
  if (!this.globalRepository) {
97
- this.globalRepository = new TrapRepository(openGlobal(), this.embedder);
97
+ this.globalRepository = new TrapRepository(openGlobal(this.home), this.embedder);
98
98
  }
99
99
  return this.globalRepository;
100
100
  }
package/src/lib/scope.ts CHANGED
@@ -5,14 +5,14 @@ import { defaultScopePathResolver, resolveScopePath, ScopePathResolver } from ".
5
5
 
6
6
  export { resolveScopePath, ScopePathResolver } from "./scope-path";
7
7
 
8
- export function getGlobalDir(): string {
9
- const dir = defaultScopePathResolver.join(homedir(), CODETRAP_DIR);
8
+ export function getGlobalDir(homeDir = homedir()): string {
9
+ const dir = defaultScopePathResolver.join(homeDir, CODETRAP_DIR);
10
10
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
11
11
  return dir;
12
12
  }
13
13
 
14
- export function getGlobalDB(): string {
15
- return defaultScopePathResolver.join(getGlobalDir(), TRAPS_DB_FILE);
14
+ export function getGlobalDB(homeDir = homedir()): string {
15
+ return defaultScopePathResolver.join(getGlobalDir(homeDir), TRAPS_DB_FILE);
16
16
  }
17
17
 
18
18
  export function findProjectRoot(
@@ -56,6 +56,11 @@ export type SearchEvalMetrics = {
56
56
  semantic_error_count: number;
57
57
  };
58
58
 
59
+ export type SearchEvalNextAction = {
60
+ command: string;
61
+ reason: string;
62
+ };
63
+
59
64
  export type SearchEvalReport = {
60
65
  mode: "deterministic" | "live";
61
66
  fixture: string;
@@ -71,6 +76,7 @@ export type SearchEvalReport = {
71
76
  failures: EvalCaseReport[];
72
77
  misses: EvalCaseReport[];
73
78
  noisy_hits: EvalCaseReport[];
79
+ next_actions: SearchEvalNextAction[];
74
80
  };
75
81
 
76
82
  export type RecordDogfoodResult = {
@@ -111,17 +117,22 @@ export async function reportDogfood(fixturePath: string, live: boolean): Promise
111
117
  const fixture = readEvalFixture(fixturePath);
112
118
  const provider = live ? createDefaultEmbeddingProvider() : new EvalEmbedder();
113
119
  const evaluated = await evaluateSearchFixture(fixture, provider);
114
- return {
115
- mode: live ? "live" : "deterministic",
120
+ const mode: SearchEvalReport["mode"] = live ? "live" : "deterministic";
121
+ const report: Omit<SearchEvalReport, "next_actions"> = {
122
+ mode,
116
123
  fixture: fixturePath,
117
124
  ...evaluated,
118
125
  };
126
+ return {
127
+ ...report,
128
+ next_actions: buildSearchEvalNextActions(report),
129
+ };
119
130
  }
120
131
 
121
132
  export async function evaluateSearchFixture(
122
133
  fixture: EvalFixture,
123
134
  provider: EmbeddingProvider | undefined
124
- ): Promise<Omit<SearchEvalReport, "mode" | "fixture">> {
135
+ ): Promise<Omit<SearchEvalReport, "mode" | "fixture" | "next_actions">> {
125
136
  const repo = fixtureRepository(fixture, provider);
126
137
 
127
138
  let providerError: string | null = null;
@@ -153,6 +164,9 @@ export async function evaluateSearchFixture(
153
164
  }
154
165
 
155
166
  const dogfoodCases = cases.filter((item) => item.phaseGate === "dogfood" || item.judgment !== undefined);
167
+ const failures = cases.filter((item) => !item.passed);
168
+ const misses = cases.filter((item) => item.judgment === "miss" || item.recallAt5 < (fixtureQuery(item, fixture)?.minRecallAt5 ?? 1));
169
+ const noisyHits = cases.filter((item) => item.judgment === "noisy_hit");
156
170
  const metrics = aggregateMetrics(cases);
157
171
  return {
158
172
  provider: provider ? embeddingConfig(provider) : null,
@@ -173,9 +187,9 @@ export async function evaluateSearchFixture(
173
187
  no_relevant_trap: dogfoodCases.filter((item) => item.judgment === "no_relevant_trap").length,
174
188
  },
175
189
  },
176
- failures: cases.filter((item) => !item.passed),
177
- misses: cases.filter((item) => item.judgment === "miss" || item.recallAt5 < (fixtureQuery(item, fixture)?.minRecallAt5 ?? 1)),
178
- noisy_hits: cases.filter((item) => item.judgment === "noisy_hit"),
190
+ failures,
191
+ misses,
192
+ noisy_hits: noisyHits,
179
193
  };
180
194
  }
181
195
 
@@ -211,11 +225,90 @@ export function formatSearchEvalReport(report: SearchEvalReport): string {
211
225
  `MRR: ${report.metrics.mrr}`,
212
226
  `Hybrid fallback count: ${report.metrics.hybrid_fallback_count}`,
213
227
  `Semantic error count: ${report.metrics.semantic_error_count}`,
214
- `Dogfood cases: ${report.dogfood.total}`
228
+ `Dogfood cases: ${report.dogfood.total}`,
229
+ `Judgments: ${formatJudgmentCounts(report.dogfood.judgment_counts)}`
215
230
  );
231
+ appendCaseSection(lines, "Failures", report.failures);
232
+ appendCaseSection(lines, "Misses to inspect", report.misses);
233
+ appendCaseSection(lines, "Noisy hits to inspect", report.noisy_hits);
234
+ lines.push("Next actions:", ...formatNextActions(report.next_actions));
216
235
  return lines.join("\n");
217
236
  }
218
237
 
238
+ function buildSearchEvalNextActions(
239
+ report: Omit<SearchEvalReport, "next_actions">
240
+ ): SearchEvalNextAction[] {
241
+ const actions: SearchEvalNextAction[] = [];
242
+ if (report.mode === "live" && !report.semantic_available) {
243
+ actions.push({
244
+ command: "export JINA_API_KEY=<your-jina-api-key>",
245
+ reason: "Enable live semantic checks, then rerun bun run eval:dogfood -- report --live.",
246
+ });
247
+ }
248
+ if (report.failures.length > 0) {
249
+ actions.push({
250
+ command: "bun run eval:dogfood -- report --json",
251
+ reason: "Inspect expected ids, top results, and errors before changing search behavior or fixture expectations.",
252
+ });
253
+ }
254
+ if (report.misses.length > 0) {
255
+ actions.push({
256
+ command: 'codetrap search "<miss query>" --mode hybrid --ranking-signals --json',
257
+ reason: "Replay a miss with ranking signals before deciding whether to tune search or promote a fixture case.",
258
+ });
259
+ }
260
+ if (report.noisy_hits.length > 0) {
261
+ actions.push({
262
+ command: 'codetrap search "<noisy query>" --mode hybrid --ranking-signals --json',
263
+ reason: "Inspect why noisy results ranked before deciding whether the case belongs in dogfood eval.",
264
+ });
265
+ }
266
+ if (actions.length === 0) {
267
+ actions.push({
268
+ command: 'codetrap search "<task keywords>" --mode hybrid --json',
269
+ reason: "Keep logging real pre-edit searches in dogfood-log.md before automating promotion.",
270
+ });
271
+ }
272
+ return actions;
273
+ }
274
+
275
+ function formatJudgmentCounts(counts: Record<DogfoodJudgment, number>): string {
276
+ return DOGFOOD_JUDGMENTS.map((judgment) => `${judgment}=${counts[judgment]}`).join(", ");
277
+ }
278
+
279
+ function appendCaseSection(lines: string[], title: string, cases: EvalCaseReport[]): void {
280
+ if (cases.length === 0) return;
281
+ lines.push(`${title}:`);
282
+ for (const item of cases.slice(0, 5)) {
283
+ lines.push(` - [${item.mode}] ${item.query}`);
284
+ if (item.goldTrapIds.length > 0) {
285
+ lines.push(` expected: ${formatExpected(item)}`);
286
+ }
287
+ lines.push(` top: ${formatTopResults(item)}`);
288
+ if (item.error) lines.push(` error: ${item.error}`);
289
+ }
290
+ if (cases.length > 5) lines.push(` ... ${cases.length - 5} more`);
291
+ }
292
+
293
+ function formatExpected(item: EvalCaseReport): string {
294
+ return item.goldTrapIds
295
+ .map((id, index) => `#${id} ${item.expectedTitles[index] ?? ""}`.trim())
296
+ .join(", ");
297
+ }
298
+
299
+ function formatTopResults(item: EvalCaseReport): string {
300
+ if (item.topResults.length === 0) return "(none)";
301
+ return item.topResults
302
+ .slice(0, 3)
303
+ .map((result) => `#${result.id} ${result.title}`)
304
+ .join("; ");
305
+ }
306
+
307
+ function formatNextActions(actions: SearchEvalNextAction[]): string[] {
308
+ if (actions.length === 0) return [" (none)"];
309
+ return actions.map((action) => ` - ${action.command} # ${action.reason}`);
310
+ }
311
+
219
312
  function fixtureRepository(fixture: EvalFixture, provider: EmbeddingProvider | undefined): TrapRepository {
220
313
  const repo = new TrapRepository(openDatabase(":memory:"), provider);
221
314
  for (const trap of fixture.traps) repo.add(trap);
@@ -1,11 +1,15 @@
1
1
  import type { CandidateTrap } from "../domain/session";
2
2
  import { buildTrapInput } from "../domain/trap";
3
+ import type { Scope } from "./constants";
3
4
  import type { TrapOperations } from "./trap-operations";
4
5
  import { findCandidateConflicts, type CandidateConflict } from "./session-conflicts";
5
6
  import type {
6
7
  AcceptCandidateResult,
7
8
  AddSessionNoteArgs,
8
9
  CloseSessionResult,
10
+ DeleteSessionResult,
11
+ PruneSessionsResult,
12
+ RemoveSessionCandidatesResult,
9
13
  SessionStore,
10
14
  StartSessionArgs,
11
15
  } from "./session-store";
@@ -30,6 +34,12 @@ export type SessionRejectRequest = {
30
34
  reason?: string | null;
31
35
  };
32
36
 
37
+ export type SessionPruneRequest = {
38
+ olderThanDays: number;
39
+ apply: boolean;
40
+ now?: Date;
41
+ };
42
+
33
43
  export type SessionConflictResult = {
34
44
  success: false;
35
45
  session_id: string;
@@ -155,6 +165,29 @@ export class SessionOperations {
155
165
  reason: request.reason,
156
166
  });
157
167
  }
168
+
169
+ deleteSession(sessionId: string): DeleteSessionResult {
170
+ return this.sessions.deleteSession(sessionId);
171
+ }
172
+
173
+ pruneSessions(request: SessionPruneRequest): PruneSessionsResult {
174
+ const now = request.now ?? new Date();
175
+ const cutoff = new Date(now.getTime() - request.olderThanDays * 24 * 60 * 60 * 1000);
176
+ return this.sessions.pruneSessions({ cutoff, dryRun: !request.apply });
177
+ }
178
+
179
+ cleanupDeletedTrapCandidates(sessionId?: string): RemoveSessionCandidatesResult {
180
+ const document = this.sessions.candidateDocument(sessionId);
181
+ const missingCandidateIds = document.candidates
182
+ .filter((candidate) => candidate.status === "accepted")
183
+ .filter((candidate) => {
184
+ const trapId = candidate.accepted_trap_id;
185
+ if (trapId === undefined) return true;
186
+ return !this.traps.getTrapDetails(trapId, acceptedScope(candidate));
187
+ })
188
+ .map((candidate) => candidate.id);
189
+ return this.sessions.removeCandidates(sessionId, missingCandidateIds);
190
+ }
158
191
  }
159
192
 
160
193
  function candidateWithTrapEdits(candidate: CandidateTrap, edit: Record<string, unknown> | undefined): CandidateTrap {
@@ -209,6 +242,10 @@ function candidateRelatedFiles(candidate: CandidateTrap): string[] {
209
242
  return uniqueStrings(candidate.evidence.flatMap((evidence) => evidence.related_files ?? []));
210
243
  }
211
244
 
245
+ function acceptedScope(candidate: CandidateTrap): Scope {
246
+ return candidate.accepted_scope ?? (candidate.trap.scope === "global" ? "global" : "project");
247
+ }
248
+
212
249
  function uniqueStrings(values: string[]): string[] {
213
250
  return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
214
251
  }